C-9-和--NET5-软件架构-全-

C#9 和 .NET5 软件架构(全)

原文:zh.annas-archive.org/md5/83D8F5A1D11ACA866E980121BEEF9AAA

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书涵盖了现代基于云和分布式软件架构中涉及的最常见设计模式和框架。它通过提供实际的现实场景,讨论了何时以及如何使用每种模式。本书还介绍了 DevOps、微服务、Kubernetes、持续集成和云计算等技术和流程,以便您可以开发并交付给客户最佳的软件解决方案。

本书将帮助您了解客户想要的产品。它将指导您在开发过程中解决可能遇到的最大问题。它还涵盖了在云环境中管理应用程序时需要遵循的注意事项,以及您需要遵循的做和不做。您将了解不同的架构方法,如分层架构、面向服务的架构、微服务、单页应用程序和云架构,并了解如何将它们应用于特定的业务需求。

最后,您将使用 Azure 在远程环境或云中部署代码。本书中的所有概念都将通过实际的实际用例来解释,其中设计原则在创建安全和健壮的应用程序时起到关键作用。通过本书,您将能够开发和交付高度可扩展和安全的企业级应用程序,满足最终客户的业务需求。

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

这第二版在代码和解释方面得到了改进,并且根据 C# 9 和.Net 5 提供的新机会进行了调整。我们添加了去年出现的所有新框架和技术,如 gRPC 和 Blazor,并在专门的章节中更详细地描述了 Kubernetes。

这本书是为谁写的

这本书适用于任何希望提高与 C#相关的 Azure 解决方案知识的软件架构师。它还适用于渴望成为架构师或希望使用.NET 堆栈构建企业应用程序的工程师和高级开发人员。需要具备 C#和.NET 的经验。

本书涵盖了什么

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

第二章《非功能需求》指导您在应用程序开发的重要阶段,即收集和考虑应用程序必须满足的所有约束和目标,如可伸缩性、可用性、弹性、性能、多线程、互操作性和安全性。

第三章《使用 Azure DevOps 记录需求》描述了记录需求、错误和应用程序其他信息的技术。虽然大部分概念是通用的,但本章重点介绍了 Azure DevOps 和 GitHub 的使用。

第四章《选择最佳基于云的解决方案》为您提供了云中可用工具和资源以及 Microsoft Azure 的广泛概述。在这里,您将学习如何搜索合适的工具和资源,以及如何配置它们来满足您的需求。

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

第六章,Azure Service Fabric,描述了微软特定的微服务编排器 Azure Service Fabric。在这里,您将实现一个简单的基于微服务的应用程序。

第七章,Azure Kubernetes Service,描述了 Kubernetes 的 Azure 实现,它是微服务编排的事实标准。在这里,您将在 Kubernetes 上打包和部署微服务应用程序。

第八章,使用 C#与数据交互-Entity Framework Core,详细解释了您的应用程序如何借助对象关系映射(ORM)以及特别是 Entity Framework Core 5.0 与各种存储引擎交互。

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

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

第十一章,设计模式和.NET 5 实现,描述了常见的软件模式,并提供了.NET 5 的示例。在这里,您将了解模式的重要性以及使用它们的最佳实践。

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

第十三章,使用 C# 9 实现代码重用,描述了在.NET 5 应用程序中最大化代码重用的模式和最佳实践,以及代码重构的重要性。

第十四章,使用.NET Core 应用服务导向架构,描述了服务导向架构,它使您能够将应用程序的功能公开为 Web 或私有网络上的端点,以便用户可以通过各种类型的客户端与它们交互。在这里,您将学习如何使用 ASP.NET Core 和 gRPC 实现面向服务的架构端点,并如何使用现有的 OpenAPI 包自行记录它们。

第十五章,介绍 ASP.NET Core MVC,详细描述了 ASP.NET Core 框架。在这里,您将学习如何基于模型-视图-控制器(MVC)模式实现 Web 应用程序,以及如何根据领域驱动设计的规定组织它们,该设计在第十二章“理解软件解决方案中的不同领域”中有描述。

第十六章,Blazor WebAssembly,描述了利用 WebAssembly 在用户浏览器中运行.NET 的新 Blazor 框架。在这里,您将学习如何使用 C#实现单页应用程序。

第十七章,C# 9 编码最佳实践,描述了在使用 C# 9 开发.NET 5 应用程序时应遵循的最佳实践。

第十八章,使用单元测试用例和 TDD 测试您的代码,描述了如何测试您的应用程序。在这里,您将学习如何使用 xUnit 测试.NET Core 应用程序,以及如何轻松地开发和维护满足您规范的代码,借助测试驱动设计的帮助。

第十九章使用工具编写更好的代码,描述了评估软件质量的度量标准,以及如何借助 Visual Studio 中包含的所有工具来衡量它们。

第二十章理解 DevOps 原则,描述了 DevOps 软件开发和维护方法论的基础。在这里,您将学习如何组织应用程序的持续集成/持续交付周期。它还描述了如何自动化整个部署过程,从在源代码库中创建新版本,经过各种测试和批准步骤,到在实际生产环境中部署应用程序的最终部署。在这里,您将学习如何使用 Azure Pipelines 和 GitHub Actions 来自动化整个部署过程。

第二十一章应用 CI 场景的挑战,补充了对 DevOps 的描述,介绍了持续集成场景。

第二十二章功能测试的自动化,专门讲述了自动功能测试 - 也就是自动验证整个应用程序版本是否符合约定的功能规格的测试。在这里,您将学习如何使用自动化工具模拟用户操作,以及如何与 xUnit 一起使用这些工具编写功能测试。

充分利用本书

  • 本书涵盖了许多主题。请将其视为您可能需要在不同情况下多次查看的指南。

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

  • 确保您理解 C# .NET 原则。

下载示例代码文件

该书的代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5。我们还有其他书籍和视频的代码包,可在github.com/PacktPublishing/上找到。请查看!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781800566040_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“当您调用sb.ToString()获取最终结果时,它们只会在最终字符串中复制一次。”

代码块设置如下:

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

任何命令行输入或输出都是这样写的:

kubectl create -f myClusterConfiguration.yaml 

粗体:表示新术语、重要单词或屏幕上看到的单词,例如菜单或对话框中的单词,也会在文本中出现。例如:“在解决方案资源管理器中,右键单击即可选择发布...”。

警告或重要提示是这样的。

提示和技巧是这样的。

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

软件架构是当今软件行业中讨论最多的话题之一,它的重要性将来肯定会增长。我们建造的解决方案越复杂、奇妙,我们就越需要出色的软件架构来维护它们。但是,新功能被添加到这��软件解决方案的速度不断增加,新的架构机会也不断出现。这就是你决定阅读这本书的原因;也是我们决定写第二版的原因。

写这个重要的话题并不是一件简单的任务,因为它提供了许多替代技术和解决方案。本书的主要目标不是建立一个详尽而永无止境的可用技术和解决方案清单,而是展示各种技术家族是如何相关的,以及它们如何在实践中影响可维护和可持续解决方案的构建。

我们需要将注意力集中在创建实际、有效的企业解决方案上的需求不断增加;用户总是需要他们的应用程序中的更多新功能。此外,由于市场变化迅速,需要频繁交付应用程序版本的需求增加了我们对复杂软件架构和开发技术的义务。

本章将涵盖以下主题:

  • 对软件架构的理解

  • 一些可能对软件架构师有帮助的软件开发过程模型

  • 收集设计高质量软件所需的正确信息的过程

  • 帮助开发过程的设计技术

  • 需求影响系统结果的情况

  • 本书案例研究的介绍

本书的案例研究将带你了解为名为World Wild Travel ClubWWTravelClub)的旅行社创建软件架构的过程。这个案例研究的目的是帮助你理解每一章节中解释的理论,并提供一个如何使用 Azure、Azure DevOps、C# 9、.NET 5、ASP.NET 和其他本书中介绍的技术开发企业应用的示例。

在本章结束时,你将能够准确理解软件架构的使命。你还将学习 Azure 是什么,以及如何在平台上创建你的账户。你还将获得关于软件流程、模型和其他技术的概述,这些将使你能够管理你的团队。

什么是软件架构?

如果你今天在阅读这本书,你应该感谢那些决定将软件开发视为工程领域的计算机科学家。这发生在上个世纪,更具体地说,是在六十年代末,当他们提出我们开发软件的方式很像我们建造建筑物的方式时。这就是为什么我们有了软件架构这个名字。就像建筑师设计建筑并监督其建造一样,软件架构师的主要目标是确保软件应用程序的良好实施;而良好的实施需要设计一个出色的解决方案。

在专业开发项目中,你必须做以下几件事情:

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

  • 设计一个出色的解决方案来满足这些要求

  • 实施设计好的解决方案

  • 与客户验证解决方案

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

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

设计出色解决方案的主要点是本书的目的。您必须了解伟大的现实世界解决方案伴随着一些基本的约束:

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

  • 解决方案需要按时交付

  • 解决方案需要符合项目预算

  • 解决方案需要提供良好的质量

  • 解决方案需要保证安全和有效的未来发展

出色的解决方案需要可持续发展,您必须了解没有伟大的软件架构就没有可持续的软件。如今,出色的软件架构取决于现代工具和现代环境,以完美地满足用户的需求。

因此,本书将使用微软提供的一些优秀工具。该公司已宣布.NET 5 作为软件开发的统一平台,这为我们创造了一个巨大的机会来创建出色的解决方案。

Introducing .NET 5 | .NET Blog

图 1.1:.NET 5 平台

.NET 5 与 C# 9 一起发布。考虑到.NET 面向如此多的平台和设备,C#现在是世界上使用最多的编程语言之一,并在不同的操作系统和环境中运行在小型设备上到大型服务器上。

本书还将使用Azure,这是微软的云平台,在那里您将找到公司提供的所有组件,用于构建先进的软件架构解决方案。其中之一是Azure DevOps,这是一个应用程序生命周期管理环境,您可以在其中使用最新的软件开发方法构建解决方案。

作为软件架构师意味着理解上述技术,以及其他许多技术。本书将指导您进行一次旅程,在这次旅程中,作为团队中的软件架构师,您将使用所列工具提供最佳解决方案。让我们通过创建您的 Azure 账户开始这次旅程。

创建 Azure 账户

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

如果您想了解 Microsoft Azure 拥有的各种组件,只需查看 Alexey Polkovnikov 开发的这个令人难以置信的网站:azurecharts.com/

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

使用网站地址azure.microsoft.com开始访问 Azure。在那里,您将找到开始订阅所需的信息。通常会自动设置为您的母语翻译。

  1. 一旦您访问了这个门户,就可以注册。如果您以前从未这样做过,可以选择免费开始,这样您就可以在不花钱的情况下使用一些 Azure 功能。请查看azure.microsoft.com/en-us/free/上的免费计划选项。

  2. 创建免费账户的过程非常简单,您将通过一个表格进行引导,该表格要求您拥有Microsoft 账户GitHub 账户

  3. 在过程中,您还将被要求提供信用卡号,以验证您的身份并防止垃圾邮件和机器人。但是,除非您升级账户,否则不会收取费用。

  4. 要完成任务,您需要接受订阅协议、优惠详情和隐私声明。

一旦您填写完表格,您就可以访问 Azure 门户。正如您在下面的截图中所看到的,面板显示了一个您可以自定义的仪表板,以及左侧的菜单,您可以在其中设置您将在解决方案中使用的 Azure 组件。在本书中,我们将回到这个截图来设置组件,以帮助我们创建现代软件架构。要找到下一页,只需选择左侧菜单图标(汉堡菜单)并单击所有服务

图 1.2:Azure 门户

一旦您创建了 Azure 账户,您就可以了解软件架构师如何引导团队利用 Azure 提供的所有机会来开发软件。然而,重要的是要记住,软件架构师需要超越技术,因为这个角色是由人来扮演的,他们被期望定义软件的交付方式。

如今,软件架构师不仅要设计软件的基础,还要确定整个软件开发和部署过程的进行方式。下一个主题将涵盖全球范围内使用最广泛的软件开发范式之一。我们将首先描述社区所称的传统软件工程。之后,我们将介绍改变了我们如今构建软件方式的敏捷模型。

软件开发流程模型

作为软件架构师,了解当前大多数企业中使用的一些常见开发流程是很重要的。软件开发流程定义了团队中的人员如何生产和交付软件。一般来说,这个流程与一个被称为软件开发流程模型的软件工程理论相关。自软件开发被定义为工程过程以来,已经提出了许多用于开发软件的过程模型。让我们回顾一下传统的软件模型,然后再看看目前常见的敏捷模型。

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

软件工程理论中引入的一些模型已经被认为是传统的和相当过时的。本书并不旨在涵盖所有这些模型,但在这里,我们将简要解释一些仍然在一些公司中使用的模型-瀑布和增量模型。

了解瀑布模型原则

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

  • 需求,创建产品需求文档,这是软件开发的基础

  • 设计,根据需求开发软件架构

  • 实施,编写软件

  • 验证,应用测试

  • 维护,在交付后再次开始循环

让我们看一下这个图表的表示:

图 1.3:瀑布式开发周期(https://en.wikipedia.org/wiki/Waterfall_model)

通常,瀑布模型的使用会导致与软件功能版本交付延迟相关的问题,以及由于期望与最终交付的产品之间的差距而导致用户不满。此外,根据我的经验,只有在开发完成后才开始应用测试总是让人感到非常有压力。

分析增量模型

增量开发是一种试图克服瀑布模型最大问题的方法:用户只能在项目结束时测试解决方案。这种模型的理念是尽早给用户与解决方案互动的机会,以便他们能够提供有用的反馈,这将有助于软件的开发。

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

在前面图片中介绍的增量模型被引入作为瀑布方法的一种替代方案。该模型的理念是为每个增量运行一组与软件开发相关的实践(沟通规划建模构建部署)。尽管它减轻了与客户沟通不足相关的问题,但对于大型项目来说,较少的增量仍然是一个问题,因为增量仍然太长。

当增量方法被大规模使用时——主要是在上个世纪末——由于需要大量文档,报告了许多与项目官僚主义相关的问题。这种笨重的情况导致了软件开发行业中一个非常重要的运动的兴起——敏捷

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

在本世纪初,开发软件被认为是工程中最混乱的活动之一。软件项目失败的比例非常高,这一事实证明了需要一种不同的方法来处理软件开发项目所需的灵活性。

2001 年,敏捷宣言被引入世界,从那时起,各种敏捷过程模型被提出。其中一些一直存活至今,并且仍然非常普遍。

敏捷宣言已被翻译成 60 多种语言。请查看其链接agilemanifesto.org/

敏捷模型和传统模型之间最大的区别之一是开发人员与客户互动的方式。所有敏捷模型传达的信息是,你把软件更快地交付给用户,就越好。这个想法有时会让软件开发人员感到困惑,他们会把这理解为——让我们试着编码,然后就这样了,伙计们!

然而,敏捷宣言中有一个重要观察,许多人在开始使用敏捷时没有读到:

图 1.5:敏捷软件开发宣言

软件架构师始终需要记住这一点。敏捷过程并不意味着缺乏纪律。此外,当你使用敏捷过程时,你会很快意识到,没有纪律就无法开发出优秀的软件。另一方面,作为软件架构师,你需要明白意味着灵活性。一个拒绝灵活的软件项目往往会随着时间的推移而毁掉自己。

敏捷背后的 12 个原则是这种灵活方法的基础:

  1. 持续交付有价值的软件以满足客户的需求必须是任何开发人员的最高优先级。

  2. 需求的变化需要被理解为使客户更有竞争力的机会。

  3. 使用每周的时间表来交付软件。

  4. 软件团队必须由商业人员和开发人员组成。

  5. 软件团队需要被信任,并且应该有正确的环境来完成项目。

  6. 与软件团队沟通的最佳方式是面对面。

  7. 你可以理解最伟大的软件团队成就是当软件真正在生产中运行。

  8. 敏捷在可持续发展时是有效的。

  9. 你投入的技术和良好设计越多,你就越敏捷。

  10. 简单至关重要。

  11. 团队自组织程度越高,交付的质量就越好。

  12. 软件团队倾向于不断改进他们的行为,分析和调整他们的流程。

即使在敏捷宣言发布 20 年后,其重要性和与软件团队当前需求的联系仍然保持完好。当然,有许多公司不接受这种方法,但作为软件架构师,您应该将其视为改变实践和与您一起工作的团队发展的机会。

有许多技术和模型被提出给敏捷方法的软件社区。接下来的子主题将讨论精益软件开发、极限编程和 Scrum,以便您作为软件架构师决定您可能使用哪些来改进软件交付。

精益软件开发

在敏捷宣言之后,精益软件开发方法被引入社区,作为汽车工程中一个著名运动——丰田汽车制造模式的一种适应。全球精益制造方法即使资源有限也能提供高质量。

Mary 和 Tom Poppendieck 列出了软件开发的七个精益原则,与敏捷和本世纪许多公司的方法真正相关。我在这里列出了它们:

  1. 消除浪费:您可能认为浪费是任何可能干扰实际客户需求交付的事物。

  2. 内建质量:一个希望保证质量的组织需要在从一开始构建代码的过程中促进质量,而不仅仅是在代码被测试后考虑它。

  3. 创造知识:取得卓越成就的公司通常通过有纪律的实验、记录和确保知识在整个组织中传播来生成新知识。

  4. 推迟承诺:在造成项目损害之前,计划决策到最后一刻。

  5. 快速交付:软件交付得越快,浪费就会越少。竞争频率高的公司比竞争对手具有明显优势。

  6. 尊重人:给团队合理的目标,以及指导他们自组织日常工作的计划,是尊重你一起工作的人的问题。

  7. 优化整体:精益公司改进整个价值循环;从接收新需求的时刻到软件完成交付的时刻。

精益原则导致团队或公司的方法改进客户真正需要的功能的质量。它还减少了在软件交付给客户时不会使用的功能上花费的时间。在精益中,决定对客户重要的功能指导团队交付重要的软件,这正是敏捷宣言在软件团队中意图促进的内容。

极限编程

就在敏捷宣言发布之前,一些设计该文件的参与者,特别是肯特·贝克,向世界展示了开发软件的极限编程XP方法论

XP 基于简单性、沟通、反馈、尊重和勇气的价值观。根据贝克在他关于这个主题的第二本书中的说法,它后来被认为是编程中的社会变革。它确实促进了开发流程的巨大变革。

XP 指出每个团队都应该有简单性,只做被要求的事情,每天面对面交流,早期演示软件以获得反馈,尊重团队每个成员的专业知识,并有勇气就进展和估计告诉真相,将团队的工作视为一个整体。

XP 还提供了一套规则。如果团队发现某些规则不起作用,可以进行更改,但始终保持方法论的价值观是很重要的。

这些规则分为规划、管理、设计、编码和测试。唐·韦尔斯在网站www.extremeprogramming.org/上绘制了 XP。尽管这种方法论的一些想法受到许多公司和专家的强烈批评,但今天仍有许多好的实践:

  • 使用用户故事编写软件需求:用户故事被认为是描述用户需求的敏捷方法,以及用于保证正确实施的验收测试。

  • 将软件分成迭代并交付小版本:在瀑布模型之后,所有方法论都支持软件开发的迭代实践。更快地交付版本降低了未能实现客户期望的风险。

  • 避免加班并保证可持续速度:尽管这可能是软件架构师所面临的最艰巨的任务之一,加班工作表明流程中有些地方出了问题。

  • 保持简单:在开发解决方案时,很常见地试图预测客户想要的功能。这种方法增加了开发的复杂性和解决方案上市的时间。采用不同的方法会导致高成本,可能在你正在开发的系统中使用的功能水平较低。

  • 重构:持续重构代码的方法很好,因为它能够使软件不断发展,并保证由于你用来开发的平台的正常技术变化而真正需要的设计改进。

  • 始终保持客户可用:如果你遵循 XP 的规则,你应该在团队内有一个专家客户。这当然是难以获得和处理的,但这种方法的主要思想是确保客户参与到开发的所有部分中。另外,让客户接近你的团队意味着他们了解团队所面临的困难和专业知识,从而增加了双方之间的信任。

  • 持续集成:这种实践是当前 DevOps 方法的基础之一。你个人的代码库和主要代码库之间的差异越小,越好。

  • 先编写单元测试代码:单元测试是一种方法,你可以为测试项目的单个单元(类/方法)编写特定的代码。这在一种当前的开发方法论中被称为测试驱动开发TDD)。这里的主要目标是确保每个业务规则都有自己的单元测试用例。

  • 编写的代码必须符合约定的标准:确定编码标准的需要与这样一个想法相关联,即无论你有哪个开发人员在项目的特定部分工作,代码必须编写得让他们中的任何一个都能理解。

  • 配对编程:配对编程是另一种在软件项目的每一分钟都难以实现的方法,但这种技术本身——一个程序员编码,另一个积极观察并提供评论、批评和建议——在关键场景中是有用的。

  • 验收测试:采用验收测试来满足用户故事是确保新发布版本的软件不会损害其当前需求的好方法。更好的选择是将这些验收测试自动化。

值得一提的是,这些规则中的许多今天被认为是不同软件开发方法论中的重要实践,包括 DevOps 和 Scrum。我们将在本书的后面讨论 DevOps,在第二十章理解 DevOps 原则。现在让我们进入 Scrum 模型。

进入 Scrum 模型

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

请查看此链接以获取有关 Scrum 框架的更多信息:www.scrum.org/

正如你在下图中所看到的,Scrum 的基础是你有一个灵活的用户需求待办事项(产品待办事项),需要在每个敏捷周期(称为冲刺)中讨论。冲刺目标冲刺待办事项)由 Scrum 团队确定,由产品负责人、Scrum 大师和开发团队组成。产品负责人负责确定在该冲刺中将交付什么。在冲刺期间,这个人将帮助团队开发所需的功能。在 Scrum 过程中领导团队的人被称为Scrum 大师。所有的会议和流程都由这个人进行。

图 1.6:Scrum 过程

重要的是要注意,Scrum 过程不讨论软件需要如何实现,也不讨论将完成哪些活动。再次强调,你必须记住软件开发的基础,这是本章开头讨论的;这意味着 Scrum 需要与一个过程模型一起实施。DevOps 是一种可能帮助你将软件开发过程模型与 Scrum 一起使用的方法之一。查看第二十章了解 DevOps 原则,以更好地理解它。

收集正确的信息来设计高质量的软件

太棒了!你刚刚开始了一个软件开发项目。现在,是时候利用你所有的知识来交付你能做到的最好的软件了。你的第一个问题可能是 - 我该如何开始? 好吧,作为软件架构师,你将成为回答这个问题的人。你可以肯定,你的答案将随着你领导的每个软件项目的发展而不断发展。

定义软件开发过程是第一个任务。这通常是在项目规划过程中完成的,或者可能在项目开始之前完成。

另一个非常重要的任务是收集软件需求。无论你决定使用哪种软件开发过程,收集真实的用户需求都是一个困难且持续的工作的一部分。当然,有一些技术可以帮助你,你可以肯定,收集需求将帮助你定义软件架构的重要方面。

这两个任务被大多数软件开发专家认为是开发项目旅程结束时成功的关键。作为软件架构师,你需要使它们能够尽可能避免尽可能多的问题,同时指导你的团队。

了解需求收集过程

有许多不同的方式来表示需求。最传统的方法是在分析开始之前你必须写出完美的规范。敏捷方法建议,你需要在准备开始开发周期时立即编写用户故事。

记住:你不仅为用户编写需求;你也为你和你的团队编写需求。

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

图 1.7:需求工程过程

在这个过程中,你需要确保解决方案是可行的。在某些情况下,可行性分析也是项目规划过程的一部分,当你开始需求获取时,你已经完成了可行性报告。因此,让我们检查这个过程的其他部分,这将为软件架构提供许多重要信息。

检测确切的用户需求

有很多方法可以检测特定情景下用户的确切需求。这个过程被称为引出。一般来说,这可以通过帮助您理解我们所说的用户需求的技术来完成。在这里,您有一个常见技术清单:

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

  • 问卷调查:这个工具对于检测常见和重要的需求非常有用,比如用户数量和类型,系统的高峰使用情况,以及常用的操作系统OS)和 Web 浏览器。

  • 面试:与用户进行面试可以帮助您作为架构师发现用户需求,也许问卷调查和您的想象力无法覆盖的需求。

  • 观察:了解用户的日常工作方式最好的方法就是和他们一起度过一天。

一旦您应用了这些技术中的一个或多个,您将获得关于用户需求的重要和有价值的信息。

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

在那时,您将能够开始分析这些用户需求,并检测用户和系统需求。让我们在下一节看看如何做到这一点。

分析需求

当您发现用户需求时,就是开始分析需求的时候了。为此,您可以使用以下技术:

  • 原型制作:原型制作非常好,可以澄清和实现系统需求。今天,我们有许多工具可以帮助您模拟界面。一个很好的开源工具是Pencil Project。您可以在pencil.evolus.vn/找到更多信息。

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

图 1.8:用例图示例

当您分析系统的需求时,您将能够准确澄清用户的需求。当您不确定需要解决的真正问题时,这是很有帮助的,比起直接开始编程系统并希望一切顺利,这要好得多。在需求分析上投入的时间是以后编写更好代码的时间。

编写规范

完成分析后,将其记录为规范很重要。规范文档可以使用传统需求或用户故事来编写,在敏捷项目中通常使用。

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

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

  • 文件需要清晰明了。

  • 您需要对每个需求进行分类。

  • 使用简单的将来时代表每个需求:

  • 不好的例子:普通用户注册自己。

  • 好的例子:普通用户应该注册自己。

  • 需要避免模棱两可和争议。

一些额外的信息可以帮助团队了解他们将要处理的项目的背景。以下是一些关于如何添加有用信息的提示:

  • 编写一个引言章节,以便全面了解解决方案。

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

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

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

  • 功能需求相当容易理解,因为它们准确描述了软件将要做的事情。另一方面,非功能性需求确定了与软件相关的限制,这意味着可扩展性、健壮性、安全性和性能。我们将在下一节中涵盖这些方面。

  • 附上可以帮助用户理解规则的文件。

如果您决定编写用户故事,一个好的建议是按照以下方式编写短句,代表系统中每个用户的每个时刻:

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

这种方法将准确解释为什么要实现该功能。这也是帮助您分析最关键的故事,并优先考虑项目成功的好工具。它们也可以很好地通知应该构建的自动验收测试。

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

检测需求是一个让您了解您将要开发的软件的任务。然而,作为软件架构师,您必须关注不仅仅是该系统的功能需求。理解非功能性需求很重要,也是软件架构师最早的活动之一。

我们将在第二章 非功能性需求中更详细地讨论这一点,但在这一点上,重要的是要知道可扩展性、健壮性、安全性和性能的原则需要应用于需求收集过程。让我们来看看每个概念:

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

  • 健壮性:无论您的应用程序有多可扩展,如果它无法保证稳定和始终可用的解决方案,您将无法获得任何平静。健壮性对于关键解决方案非常重要,因为您没有机会随时进行维护,因为应用程序解决的问题类型。在许多行业中,软件不能停止,许多例行程序在没有人员可用时运行(过夜、假期等)。设计一个健壮的解决方案将使您在软件运行良好时获得自由。

  • 安全性:这是另一个非常重要的领域,在需求阶段之后需要讨论。每个人都担心安全性,并且世界各地都有不同的法律与之相关。作为软件架构师,您必须了解安全性需要通过设计提供。这是应对安全社区目前正在讨论的所有需求的唯一途径。

  • 性能:了解您将要开发的系统的过程可能会给您一个很好的想法,您需要做些什么才能从系统中获得所需的性能。这个话题需要与用户讨论,以确定在开发阶段将面临的大部分瓶颈。

值得一提的是,所有这些概念都是世界所需要的新一代解决方案的要求。优秀软件与令人难以置信的软件之间的区别在于满足项目要求所做的工作量。

审查规范

一旦编写了规范,就是时候与利益相关者确认他们是否同意了。这可以在审查会议中完成,也可以使用协作工具在线完成。

这时,您将展示您收集到的所有原型、文件和信息。一旦每个人都同意了规范,您就可以开始研究实施项目的最佳方式。

值得一提的是,你可以使用这里描述的过程来完成整个软件或其中的一小部分。

使用设计技术作为一个有用的工具

定义解决方案并不容易。确定其技术增加了这样做的难度。在你作为软件架构师的职业生涯中,你会发现许多项目,你的客户会给你一个“准备开发”的解决方案。如果你把这个解决方案视为正确的解决方案,情况可能会变得非常复杂;大多数时候,会有架构和功能上的错误,这将在未来导致解决方案出现问题。

有些情况下问题更严重——当客户不知道问题的最佳解决方案时。一些设计技术可以帮助我们解决这个问题,我们将在这里介绍其中的两种:设计思维和设计冲刺。

你必须明白的是,这些技术可以是发现真实需求的一个绝佳选择。作为软件架构师,你有责任帮助你的团队在正确的时间使用正确的工具,而这些工具可能是确保项目成功的正确选择。

设计思维

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

这个过程基于以下步骤:

  • 共情:在这一步中,你必须进行现场调研,以发现用户的关注点。这是你了解系统用户的地方。这个过程有助于让你理解为什么以及为谁开发这个软件。

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

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

  • 原型:这些解决方案可以���发成模型,以确认它们是否是好的解决方案。

  • 测试:测试原型将帮助你了解最符合用户真实需求的原型。

这种技术的重点是加速发现正确产品的过程,考虑到“最小可行产品”(MVP)。原型过程将帮助利益相关者理解最终产品,并同时鼓励团队提供最佳解决方案。

设计冲刺

设计冲刺是一个专注于通过设计在五天冲刺中解决关键业务问题的过程。这个技术是由谷歌提出的,它是一个允许你快速测试和学习一个想法的替代方案,需要构建和推出一个解决方案到市场上。

这个过程是基于专家们致力于解决所提到的问题的一周时间,在一个为此目的准备的战争室中进行的。这一周分为以下几个部分:

  • 星期一:这一天的重点是确定冲刺的目标,并将挑战映射到实现它的地方。

  • 星期二:在了解了冲刺的目标之后,参与者开始勾画可能解决问题的解决方案。现在是时候找到客户来测试新的解决方案了。

  • 星期三:这是团队需要决定哪些解决方案有最大机会解决问题的时间。此外,星期三团队必须将这些解决方案绘制成故事板,为原型制定计划。

  • 星期四:现在是在故事板上计划的想法的原型制作时间。

  • 星期五:完成了原型后,团队向客户展示它,通过从客户对设计的反应中获取信息来学习。

正如你在这两种技术中所看到的,从客户那里收集反馈的加速来自原型,这将使你的团队的想法更具体化,更贴近最终用户。

需求收集过程影响系统结果的常见情况

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

了解一些未能执行你所读的活动导致软件项目出现问题的案例也是一个好主意。以下案例旨在描述可能出现的问题,以及先前的技术如何帮助开发团队解决问题。

在大多数情况下,简单的行动可能会保证团队和客户之间更好的沟通,这种简单的沟通流程将把一个大问题转化为一个真正的解决方案。让我们来看看三种常见情况,需求收集如何影响了性能、功能和可用性的结果。

案例 1 - 我的网站打开那个页面太慢了!

性能是你作为软件架构师在职业生涯中将要处理的最大问题之一。任何软件的这一方面如此棘手的原因在于我们没有无限的计算资源来解决问题。此外,计算成本仍然很高,特别是当你谈论具有大量同时用户的软件时。

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

非功能性需求:性能 - 该软件的任何网页在有 1,000 个用户同时访问时,至少应在 2 秒内响应。

前面的句子只是让每个人(用户、测试人员、开发人员、架构师、经理等等)确信任何网页都有一个要达到的目标。这是一个良好的开始,但还不够。一个既能开发又能部署你的应用程序的良好环境也很重要。这就是.NET 5 可以帮助你的地方;特别是如果你谈论的是 Web 应用程序,ASP.NET Core 被认为是今天提供解决方案的最快选项之一。

如果你谈论性能,作为软件架构师,你应该考虑使用以下部分列出的技术以及特定测试来保证这一非功能性需求。同时,重要的是要提到 ASP.NET Core 将帮助你轻松使用它们,以及微软 Azure 提供的一些平台即服务PaaS)解决方案。

理解缓存

缓存是一种很好的技术,可以避免可能消耗时间并且通常会给出相同结果的查询。例如,如果你在数据库中获取可用的汽车型号,数据库中的汽车数量可能会增加,但它们不会改变。一旦你有一个不断访问汽车型号的应用程序,一个好的做法是对这些信息进行缓存。

重要的是要理解缓存存储在后端,并且缓存由整个应用程序共享(内存缓存)。一个要关注的重点是,当你在开发可扩展的解决方案时,你可以使用 Azure 平台配置分布式缓存来解决问题。事实上,ASP.NET 提供了两者,因此你可以选择最适合你需求的那个。第二章非功能性需求,涵盖了 Azure 平台的可扩展性方面。

应用异步编程

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

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

处理对象分配

避免性能不足的一个很好的提示是了解垃圾收集器GC)的工作原理。当您使用完内存时,GC 是会自动释放内存的引擎。由于 GC 的复杂性,这个主题有一些非常重要的方面。

如果您不释放它们,某些类型的对象将不会被 GC 收集。这个列表包括与 I/O 交互的任何对象,比如文件和流。如果您没有正确使用 C#语法来创建和销毁这种对象,您将会有内存泄漏,这将损害您的应用程序性能。

处理 I/O 对象的不正确方式是:

System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\sample.txt");
file.WriteLine("Just writing a simple line"); 

处理 I/O 对象的正确方式是:

using (System.IO.StreamWriter file = new System.IO.StreamWriter(@"C:\sample.txt"))
{
file.WriteLine("Just writing a simple line");
} 

值得注意的是,这种正确的方法还确保文件被写入(它调用 Flush)。在不正确的示例中,内容甚至可能都没有被写入文件。尽管对 I/O 对象来说,前面的做法是强制性的,但强烈建议您在所有可处置的对象中继续这样做。事实上,在解决方案中使用警告作为错误的代码分析器将防止您意外地犯这些错误!这将有助于 GC,并将使您的应用程序以正确的内存量运行。根据对象的类型,这里的错误可能会滚雪球,您最终可能会遇到其他不良情况,例如端口/连接耗尽。

另一个重要的方面是,GC 花费在收集对象上的时间会干扰应用程序的性能。因此,避免分配大型对象;否则,它可能会导致您等待 GC 完成其任务。

改进数据库访问

数据库访问是最常见的性能弱点之一。为什么这仍然是一个大问题是因为在编写查询或 lambda 表达式以从数据库获取信息时缺乏注意。本书将在第八章 使用 C#与数据交互-Entity Framework Core中涵盖 Entity Framework Core,但重要的是要知道如何选择以及从数据库中读取正确的数据信息。对于希望提供性能的应用程序来说,过滤列和行是必不可少的。

好消息是,与缓存、异步编程和对象分配相关的最佳实践完全适用于数据库环境。只需要选择正确的模式,就能获得更好的性能软件。

情况 2-用户的需求没有���到正确实现

技术在各种领域的广泛应用,使得精确满足用户需求变得更加困难。也许这句话对您听起来很奇怪,但您必须明白,开发人员一般来说学习如何开发软件,但很少学习如何满足特定领域的需求。当然,学习如何开发软件并不容易,但要理解特定领域的特定需求更加困难。如今,软件开发为所有可能的行业提供软件。问题在于开发人员,无论是软件架构师还是其他人,如何能够进步到足以在他们负责的领域交付软件?

收集软件需求将帮助你完成这项艰巨的任务;编写它们将使你理解和组织系统的架构。有几种方法可以最小化实施与用户真正需求不同的风险:

  • 原型界面以更快地理解用户界面

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

  • 经常开会以了解用户当前的需求并对增量交付进行调整

同样,作为软件架构师,你将不得不定义软件的实现方式。大多数情况下,你不会是编程的人,但你始终是负责的人。因此,一些技术可以帮助避免错误的实现:

  • 与开发人员一起审查需求,以确保他们理解他们需要开发什么。

  • 代码检查以验证预定义的代码标准。我们将在第十九章使用工具编写更好的代码中介绍这一点。

  • 开会消除障碍。

记住,实现符合用户需求是你的责任。利用你能够满足它的每一个工具。

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

可用性是软件项目成功的关键点。软件的呈现方式以及它如何解决问题可以帮助用户决定他们是否想要使用它。作为软件架构师,你必须记住,现在交付具有良好可用性的软件是强制性的。

本书不打算涵盖可用性的基本概念,但在涉及可用性时满足正确的用户需求的一个好方法是了解谁将使用软件。设计思维可以在这方面帮助你很多,正如本章前面讨论的那样。

了解用户将帮助你决定软件是否将在网页上运行,还是在手机上,甚至在后台。这种理解对于软件架构师非常重要,因为如果你正确地映射谁将使用它,系统的元素将呈现得更好。

另一方面,如果你不在乎这一点,你只会交付能够运行的软件。这在短期内可能是好的,但它不会完全满足让一个人要求你设计软件的真正需求。你必须记住选项并理解,优秀的软件是设计用于在许多平台和设备上运行的。

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

案例研究-介绍 World Wild Travel Club

正如我们在本章开头提到的,本书的案例研究将带你走上为一家名为World Wild Travel ClubWWTravelClub)的旅行社创建软件架构的旅程。

WWTravelClub 是一家旅行社,旨在改变人们对他们的度假和世界各地其他旅行做出决定的方式。为此,他们正在开发一个在线服务,其中每次旅行体验的每个细节都将由专门为每个目的地精选的专家俱乐部协助。

这个平台的概念是你可以同时成为游客和目的地专家。你在一个目的地作为专家参与的越多,你得分就越高。这些分数可以在平台上购买的门票上兑换。

客户提出了平台的以下要求。重要的是要知道,一般来说,客户并不会为开发准备好需求。这就是为什么需求收集过程如此重要:

  • 普通用户视图:

  • 主页上的促销套餐

  • 搜索套餐

  • 每个套餐的详情:

  • 购买套餐

  • 购买包含专家俱乐部的套餐:

  • 评论您的经验

  • 询问专家

  • 评估专家

  • 注册为普通用户

  • 目的地专家视图:

  • 与普通用户视图相同

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

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

  • 兑换积分以换取门票

  • 管理员视图:

  • 管理套餐

  • 管理普通用户

  • 管理目的地专家

最后,重要的是要注意,WWTravelClub 打算每个套餐拥有超过 100 名目的地专家,并将在全球提供大约 1,000 个不同的套餐。

了解用户需求和系统要求

要总结 WWTravelClub 的用户需求,您可以阅读以下用户故事:

  • US_001: 作为普通用户,我想要在主页上查看促销套餐,这样我就可以轻松找到我的下一个假期

  • US_002: 作为普通用户,我想要搜索主页上找不到的套餐,这样我就可以探索其他旅行机会

  • US_003: 作为普通用户,我想要查看套餐的详情,这样我就可以决定购买哪个套餐

  • US_004: 作为普通用户,我想要注册自己,这样我就可以开始购买套餐

  • US_005: 作为注册用户,我想要处理付款,这样我就可以购买套餐

  • US_006: 作为注册用户,我想要购买包���专家推荐的套餐,这样我就可以拥有独特的旅行体验

  • US_007: 作为注册用户,我想要询问专家,这样我就可以找到我旅行中可以做的最好的事情

  • US_008: 作为注册用户,我想要评论我的经验,这样我就可以从我的旅行中得到反馈

  • US_009: 作为注册用户,我想要评价帮助我的专家,这样我就可以与他人分享他们有多棒

  • US_010: 作为注册用户,我想要注册为目的地专家视图,这样我就可以帮助到来我城市旅行的人

  • US_011: 作为一个专家用户,我想要回答关于我的城市的问题,这样我就可以得分,以便将来兑换

  • US_012: 作为一个专家用户,我想要兑换积分以换取门票,这样我就可以更多地环游世界

  • US_013: 作为管理员用户,我想要管理套餐,这样用户可以有机会进行梦幻般的旅行

  • US_014: 作为管理员用户,我想要管理注册用户,这样 WWTravelClub 可以保证良好的服务质量

  • US_015: 作为管理员用户,我想要管理专家用户,这样我们所有关于目的地的问题都能得到回答

  • US_016: 作为管理员用户,我想要在全球提供超过 1,000 个套餐,这样不同的国家可以体验 WWTravelClub 的服务

  • US_017: 作为 CEO,我想要有超过 1,000 名用户同时访问网站,这样业务可以有效扩展

  • US_018: 作为用户,我想要用我的母语访问 WWTravelClub,这样我就可以轻松理解所提供的套餐

  • US_019: 作为用户,我想要在 Chrome、Firefox 和 Edge 网页浏览器中访问 WWTravelClub,这样我就可以使用我喜欢的网页浏览器

  • US_020: 作为用户,我想要知道我的信用卡信息被安全存储,这样我就可以安全购买套餐

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

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

  • SR_001: 该系统应使用 Microsoft Azure 组件以提供所需的可伸缩性

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

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

  • SR_004: 该系统的任何网页应在至少 2 秒内响应,同时具有 1,000 个用户的并发访问

列出用户故事和系统需求的目的是帮助您了解,如果从架构的角度思考,平台的开发可能有多复杂。

总结

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

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

问题

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

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

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

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

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

  6. 设计思维和设计冲刺如何帮助软件架构师收集需求?

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

  8. 开发非常好的性能软件的好技术是什么?

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

进一步阅读

在这里,您可以阅读一些书籍和链接,以获取有关本章的更多信息。

有关 Azure 信息,请查看以下链接:

.NET 5 的信息可以在这里找到:

软件开发过程模型链接:

第二章:非功能性需求

一旦您收集了系统需求,就是时候考虑它们对架构设计的影响了。可伸缩性、可用性、弹性、性能、多线程、互操作性、安全性和其他主题需要进行分析,以便我们能够满足用户需求。我们将这些方面称为非功能性需求。

本章将涵盖以下主题:

  • .NET 5 和 Azure 如何实现可伸缩性、可用性和弹性?

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

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

  • .NET 5 和互操作性

  • 通过设计实现安全性

  • 书籍用例-了解.NET Core 项目的主要类型

技术要求

本章提供的示例将需要安装有.NET 5 SDK 的 Visual Studio 2019 社区版。

您可以在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5找到本章的示例代码。

使用 Azure 和.NET 5 实现可伸缩性、可用性和弹性

对可伸缩性的简短搜索返回了这样的定义,即系统在需求增加时保持良好运行的能力。一旦开发人员读到这一点,他们中的许多人错误地得出结论可伸缩性只意味着添加更多硬件以使应用程序在不停止的情况下继续工作

可伸缩性依赖于涉及硬件解决方案的技术。然而,作为软件架构师,您必须意识到良好的软件将保持可伸缩性在可持续模型中,这意味着良好架构的软件可以节省大量资金。因此,这不仅仅是硬件的问题,也是整体软件设计的问题。这里的重点是系统的运行成本也应该是架构决策的一个因素。

第一章 理解软件架构的重要性中,我们在讨论软件性能时提出了一些克服性能问题的好建议。相同的建议也将帮助您实现可伸缩性。我们在每个过程上花费的资源越少,应用程序就能处理更多用户。

尽管可伸缩性很重要,但云计算应用程序必须设计为能够处理系统故障。每当您确保应用程序从故障中恢复而不向最终用户暴露这种故障时,您就在创建一个弹性应用程序。

您可以在docs.microsoft.com/en-us/azure/architecture/patterns/category/resiliency找到云架构弹性模式。

在云场景中,弹性尤为重要的原因是因为提供给您的基础设施可能需要一小段时间来管理更新、重置,甚至硬件升级。您还更有可能与多个系统一起工作,并且在与它们通信时可能会发生瞬态错误。这就是为什么这种非功能性需求在最近几年开始传播。

当您能够在系统中实现高可用性时,具有可伸缩性和弹性解决方案的可能性变得更加令人兴奋。本书中提出的所有方法都将指导您设计具有良好可用性的解决方案,但在某些情况下,您需要设计特定的替代方案来实现您的目标。

您可以在docs.microsoft.com/en-us/azure/architecture/patterns/category/availability找到云架构可用性模式。

值得知道的是,Azure 和.NET 5 Web 应用程序可以配置以实现这些非功能性需求。让我们在以下小节中进行检查。

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

在 Azure 中创建一个可扩展的 Web 应用程序非常简单。您必须这样做的原因是能够在不同季节维护不同数量的用户。用户越多,您就需要更多的硬件。让我们向您展示如何在 Azure 中创建一个可扩展的 Web 应用程序。

一旦您登录到 Azure 帐户,您就可以创建新资源(Web 应用程序、数据库、虚拟机等),如下屏幕截图所示:

图 2.1:Microsoft Azure - 创建资源

之后,您可以在热门选项中选择Web 应用程序,或者在搜索市场文本框中输入它。此操作将带您到以下屏幕:

图 2.2:Microsoft Azure - 创建 Web 应用

所需的项目详细信息如下:

  • 订阅:这是将为所有应用程序成本收费的帐户。

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

此外,实例详细信息如下:

  • 名称:如您所见,Web 应用程序名称是在创建后您的解决方案将采用的 URL。将检查名称以确保其可用。

  • 发布:此参数指示 Web 应用程序是否将直接交付,或者是否将使用 Docker 技术来发布内容。 Docker 将在第五章“将微服务架构应用于企业应用程序”中进行更详细的讨论。如果选择 Docker 容器发布,您将能够配置图像源访问类型图像以及标记信息,以便将其部署到 Web 应用程序。

  • 运行时堆栈:显然,此选项仅在您决定直接交付代码时才可用。在撰写本章时,您可以为.NET Core、ASP.NET、Java 11、Java 8、Node、PHP、Python 和 Ruby 定义堆栈。

  • 操作系统:这是用于托管 Web 应用程序的操作系统的选项。 Windows 和 Linux 都可以用于 ASP.NET Core 项目。

  • 区域:您可以考虑要部署应用程序的位置,考虑到 Azure 在世界各地有许多不同的数据中心。

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

  • 监控:这是一个用于监控和故障排除 Web 应用程序的有用的 Azure 工具集。在本节中,您将能够启用应用程序洞察。建议您始终为解决方案的不同组件保持相同的区域,因为这将节省从一个数据中心到另一个数据中心的流量交换成本。

创建 Web 应用程序后,可以以两种概念上不同的方式扩展此应用程序:垂直(向上扩展)和水平(向外扩展)。如您在以下屏幕截图中所见,这两种方式都可以在 Web 应用程序设置中找到:

图 2.3:Web 应用程序的扩展选项

让我们来看看两种扩展类型。

垂直扩展(向上扩展)

扩展意味着更改将托管您的应用程序的硬件规格。在 Azure 中,您有机会从免费共享硬件开始,并在几次点击后转移到独立的机器。以下屏幕截图显示了扩展 Web 应用程序的用户界面:

图 2.4:垂直扩展选项

通过选择提供的选项之一,您可以选择更强大的硬件(具有更多 CPU、存储和 RAM 的机器)。监视您的应用程序及其应用服务计划将指导您决定运行您正在验证的解决方案的最佳基础设施。它还将提供关键见解,例如可能的 CPU、内存和 I/O 瓶颈。

水平扩展(扩展)

扩展意味着将所有请求分配给更多具有相同容量的服务器,而不是使用更强大的机器。所有服务器的负载都会被 Azure 基础架构自动平衡。当整体负载可能在未来发生相当大的变化时,建议使用这种解决方案,因为水平扩展可以自动适应当前负载。下面的屏幕截图显示了由两个简单规则定义的自动扩展策略,该策略由 CPU 使用率触发:

图 2.5:水平扩展示例

值得强调的是,您可以选择具有硬编码实例计数或实现自动缩放规则。

所有可用自动缩放规则的完整描述超出了本书的目的。但是,它们相当容易理解,进一步阅读部分包含了完整文档的链接。

扩展功能仅适用于付费服务计划。

总的来说,水平扩展是一种确保应用程序即使在大量同时访问的情况下也可用的方法。当然,它的使用并不是保持系统可用性的唯一方法,但它肯定会有所帮助。

使用.NET 5 创建可扩展的 Web 应用程序

在所有可用的实现 Web 应用程序的框架中,使用 ASP.NET Core 5 运行可以确保良好的性能,同时具有较低的生产和维护成本。C#作为一种强类型和先进的通用语言,与框架中实现的持续性能改进相结合,使得这个选择在近年来成为企业开发中最佳选择之一。

接下来的步骤将指导您创建基于 ASP.NET Core Runtime 5 的 Web 应用程序。所有步骤都非常简单,但一些细节需要注意。

值得一提的是,.NET 5 为开发提供了在任何平台上进行开发的机会-桌面(WPF,Windows Forms 和 UWP),Web(ASP.NET),云(Azure),移动(Xamarin),游戏(Unity),物联网(ARM32 和 ARM64)和人工智能(ML.NET 和.NET for Apache Spark)。因此,从现在开始的建议是只使用.NET 5。在这种情况下,您可以在 Windows 和更便宜的 Linux 服务器上运行 Web 应用程序。

如今,微软建议使用经典的.NET,以防您需要的功能在.NET Core 中不可用,或者当您在不支持.NET Core 的环境中部署 Web 应用程序时。在其他情况下,您应该优先选择.NET Core 框架,因为它允许您执行以下操作:

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

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

  • 拥有高性能和可伸缩的系统

容器和微服务将在第五章 将微服务架构应用于企业应用程序中进行介绍。在那里,您将更好地了解这些技术的优势。目前,可以说.NET 5 和微服务是为性能和可伸缩性而设计的,这就是为什么您应该在所有新项目中优先选择.NET 5 的原因。

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

  • 当您启动 VS 2019 时,您将能够点击创建新项目

  • 一旦您选择ASP.NET Core Web 应用程序,您将被引导到一个屏幕,在那里您将被要求设置项目名称位置解决方案名称

图 2.6:创建 ASP.NET Core Web 应用程序

  • 之后,您将能够选择要使用的 .NET 版本。选择ASP.NET Core 5.0以获得最先进和全新的平台。对于此演示,您可以选择Web 应用程序模板。

  • 现在我们已经添加了基本细节,您可以将您的 Web 应用项目连接到您的 Azure 帐户并将其发布。

  • 如果您右键单击您创建的项目,在解决方案资源管理器中,您将有发布选项:

图 2.7:发布 ASP.NET Core Web 应用程序

  • 您将找到不同的目标来发布您的 Web 应用。选择Azure作为目标。

  • 然后,您将能够决定要发布的具体目标。为此演示选择Azure App Service (Windows)

  • 此时可能需要定义您的 Microsoft 帐户凭据。这是因为 Visual Studio 与 Azure 之间有完全集成。这使您有机会在开发环境中查看您在 Azure 门户中创建的所有资源!

图 2.8:Visual Studio 与 Azure 的集成

  • 一旦您决定了您的发布设置,也就是发布配置文件,当您点击发布时,Web 应用将会自动发布。请注意在这里选择 F1 定价层,以便此过程不会产生任何费用:

图 2.9:发布配置文件选项

  • 您目前有两种部署模式。第一种是 Framework-dependent,将需要配置目标框架的 Web 应用。第二种是 self-contained,将不再需要此功能,因为框架的二进制文件将与应用程序一起发布。

  • 值得一提的是,为了以 Framework-dependent 模式发布 ASP.NET 预览版本,您必须在 Azure 门户中的 Web 应用设置面板中添加一个扩展,如下截图所示。但是,在预览版本下,考虑使用自包含模式!

图 2.10:在 Azure Web 应用服务中添加扩展

有关将 ASP.NET Core 5.0 部署到 Azure App Service 的更多信息,请参阅以下链接:docs.microsoft.com/en-us/aspnet/core/host-and-deploy/azure-apps/?view=aspnetcore-5.0&tabs=visual-studio

在这里,我们描述了部署 Web 应用的最简单方法。在第二十章理解 DevOps 原则第二十一章应用 CI 场景的挑战中,我们将向您介绍 Azure DevOps 持续集成/持续交付CI/CD)管道。这个管道是一个进一步的 Azure 工具集,它自动化了将应用程序投入生产所需的所有步骤,即构建、测试、部署到暂存区和部署到生产环境。

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

如今,C# 是全球最常用的编程语言之一,因此关于 C# 编程的良好建议对于设计满足最常见非功能性需求的良好架构至关重要。

以下部分提到了一些简单但有效的提示-相关的代码示例可在本书的 GitHub 存储库中找到。

字符串连接

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

因此,如果我们连接,例如,平均长度为 100 的 10 个字符串,第一个操作的成本为 200,第二个操作的成本为200+100=300,第三个操作的成本为300+100=400,依此类推。很容易让你相信总体成本增长像mn²,其中n是字符串的数量,m是它们的平均长度。n²对于小n(比如,n* < 10)来说并不算太大,但当n达到 100-1,000 数量级时,它就变得相当大,当n达到 10,000-100,000 数量级时就变得不可接受。

让我们用一些测试代码来看看这个问题,比较天真的连接和使用StringBuilder类执行相同操作的代码(该代码可在本书的 GitHub 存储库中找到):

图 2.11:连接测试代码结果

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

当然,你可能永远不会找到一个像前面那个连接 100,000 个字符串的函数的软件片段。然而,你需要识别类似这些的代码片段,比如在同时处理多个请求的 Web 服务器中连接 20-100 个字符串可能会导致损害性能的瓶颈。

异常

永远记住,异常比正常代码流慢得多!因此,使用try-catch需要简洁和必要,否则,你将会创建大的性能问题。

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

private static string ParseIntWithTryParse()
{
    string result = string.Empty; 
    if (int.TryParse(result, out var value))
        result = value.ToString();
    else
        result = "There is no int value";
    return $"Final result: {result}";
}
private static string ParseIntWithException()
{
    string result = string.Empty;
    try
    {
        result = Convert.ToInt32(result).ToString();
    }
    catch (Exception)
    {
        result = "There is no int value";
    }
    return $"Final result: {result}";
} 

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

图 2.12:异常测试代码结果

总之,异常必须用于处理打破正常控制流的异常情况,例如,操作必须因某些意外原因而中止,控制必须返回调用堆栈中的几个级别。

更好的多线程环境-做和不做

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

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

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

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

  • 使用 System.Threading.Tasks.Parallel 类进行编程:自.NET Framework 4.0 以来,您可以使用并行类以更简单的方式启用线程。这很好,因为您不需要担心创建的线程的生命周期,但这将使您对每个线程中发生的事情控制更少。

  • 使用异步编程进行开发:这无疑是开发多线程应用程序的最简单方式,因为编译器承担了大部分工作。根据您调用异步方法的方式,您可能会创建的Task与用于调用它的Thread并行运行,甚至可能会有这个Thread等待而不挂起,直到创建的任务结束。这样,异步代码模仿了经典同步代码的行为,同时保留了一般并行编程的大部分性能优势:

  • 总体行为是确定性的,不取决于每个任务完成所花费的时间,因此不可重现的错误更难发生,生成的代码易于测试/调试/维护。将方法定义为异步任务与否是程序员留下的唯一选择;其他一切都由运行时自动处理。您唯一需要关注的是哪些方法应该具有异步行为。值得一提的是,将方法定义为async并不意味着它将在单独的线程上执行。您可以在docs.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/async/上找到有用的信息。

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

无论您选择哪个选项,作为软件架构师,您都必须注意一些要点和禁忌。具体如下:

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

  • 担心静态变量:无法说静态变量在多线程开发中是被禁止的,但您应该注意它们。同样,多个线程处理相同的变量可能会引起很多麻烦。如果您使用[ThreadStatic]属性修饰静态变量,每个线程将看到该变量的不同副本,从而解决了多个线程竞争相同值的问题。然而,ThreadStatic变量不能用于额外的线程通信,因为一个线程写入的值不能被其他线程读取。在异步编程中,AsyncLocal<T>是执行类似操作的选项。

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

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

  • 不要忘记规划系统应该有多少线程:这对于 32 位程序尤其重要。在任何环境中,你可以拥有多少线程都有限制。在设计系统时,你应该考虑这一点。

  • 不要忘记结束你的线程:如果你没有为每个线程正确的终止程序,你可能会在内存和处理泄漏方面遇到麻烦。

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

可扩展性、性能提示和多线程是我们可以用来调整机器性能的主要工具。然而,你设计的系统的有效性取决于整个处理流水线的整体性能,其中包括人和机器。

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

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

  • UI 不得在数据插入中引起任何类型的减速;数据输入速度必须仅受用户输入能力的限制,而不受系统延迟或可以避免的额外手势的限制。

值得一提的是,市场上有 UX 专家。作为软件架构师,你必须决定他们对项目成功至关重要的时候。以下是在设计易于学习的用户界面时的一些建议:

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

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

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

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

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

快速的用户界面是通过对以下三个要求的有效解决方案实现的:

  • 输入字段必须按照通常填写的顺序放置,并且应该可以使用 Tab 键或 Enter 键移动到下一个输入。此外,通常保持为空的字段应该放在表单的底部。简而言之,在填写表单时应尽量减少使用鼠标。这样,用户手势的数量就会被最小化。在 Web 应用程序中,一旦确定了输入字段的最佳放置位置,只需使用tabindex属性即可定义用户使用 Tab 键从一个输入字段移动到下一个输入字段的正确方式。

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

  • 有效的选择逻辑。选择现有项目应尽可能简单;例如,在提供的数千种产品中选择一种产品必须能够通过几个手势轻松完成,而无需记住确切的产品名称或其条形码。下一小节分析了我们可以使用的技术,以减少复杂性以实现快速选择。

第十六章Blazor WebAssembly中,我们将讨论这项微软技术如何帮助我们解决使用 C#代码构建基于 Web 的应用程序的挑战。

设计快速选择逻辑

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

图 2.13:简单的下拉菜单

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

图 2.14:复杂的下拉菜单

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

当名称非常复杂时,当搜索用户输入的字符时,它们应该在每个项目字符串内进行扩展。这种操作无法在通常的数据库中高效执行,需要专门的数据结构。

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

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

这个类别中最著名的算法可能是Levenshtein算法,大多数拼写检查器使用它来找到一个最适合用户错误输入的单词。该算法最小化了描述和用户输入的字符串之间的 Levenshtein 距离,即将一个字符串转换为另一个字符串所需的最小字符删除和添加次数。

Levenshtein 算法效果很好,但计算成本很高。在这里,我们提供了一个更快的算法,用于在描述中搜索字符出现次数。用户输入的字符不需要在描述中连续出现,但必须按相同顺序出现。有些字符可能会缺失。每个描述都会根据缺失的字符和用户输入的字符出现的距离给予惩罚。更具体地说,该算法对每个描述进行了两个数字的排名:

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

  • 每个描述都被赋予一个惩罚,等于描述中用户输入字符之间的总距离。

以下屏幕截图显示了单词Ireland与用户输入的字符串ilad的排名:

图 2.15:Levenshtein 用法示例

出现次数为四(4),而字符出现之间的总距离为三(3)。

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

图 2.16:Levenshtein 算法用户界面体验

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

从大量项目中进行选择

在这里,“巨大”并不是指存储数据所需的空间量,而是指用户在记住每个项目的特性时所遇到的困难。当需要从 10,000-100,000 个项目中选择一个项目时,通过在描述中搜索字符出现的次数来寻找它是没有希望的。在这种情况下,用户必须通过一系列类别来选择正确的项目。

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

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

图 2.17:级联下拉菜单示例

显然,每个下拉菜单在需要时都可以被自动完成所取代,因为它具有大量选项。

如果通过交叉几个不同的层次来做出正确的选择,级联下拉菜单也变得低效,我们需要一个筛选表单,如下所示:

图 2.18:筛选表单示例

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

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

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

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

微软提供了脚本来帮助您在 Linux 和 macOS 上安装.NET Core。您可以在docs.microsoft.com/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 安装只能通过命令行运行且无需用户登录,这可能非常有用:

  • 第一步是创建一个将运行命令行应用程序的文件。该应用程序的名称是app.dll,并且安装在appfolder中。该应用程序将每 5000 毫秒进行一次检查。此服务是在 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 
  • 创建文件后,您必须将服务文件复制到系统位置。之后,您必须重新加载system并启用服务,以便在重新启动时重新启动:
sudo cp sample.service /lib/systemd/system
sudosystemctl daemon-reload 
sudosystemctl enable sample 
  • 完成!现在,您可以使用以下命令启动、停止和检查服务。您在命令行应用程序中需要提供的整个输入如下:
# Start the service
sudosystemctl start sample
# View service status
sudosystemctl status sample
# Stop the service
sudosystemctl stop sample 

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

通过设计实现安全

正如我们在本书中所看到的,我们用于开发软件的机会和技术是令人难以置信的。如果您在接下来的章节中阅读有关云计算的所有信息,您将会发现机会不断增加,与此同时,维护所有这些计算环境的复杂性也在增加。

作为软件架构师,您必须理解这些机会伴随着许多责任。在过去的几年里,世界发生了很大变化。21 世纪的第二个十年需要大量的技术。应用程序、社交媒体、工业 4.0、大数据和人工智能不再是未来的目标,而主要是您将在日常工作中领导和处理的当前项目。

考虑到这种情况,安全必须有不同的方法。世界已经开始监管管理个人数据的公司。例如,GDPR - 《通用数据保护条例》 - 不仅在欧洲领土上是强制性的,因为它已经改变了软件开发的方式,不仅在欧洲,而且在全球范围内。有许多类似于 GDPR 的倡议必须列入我们的技术和法规范围,考虑到您设计的软件将受到它们的影响。

安全设计必须是您设计新应用程序时关注的一个领域。这个主题很庞大,在这本书中不可能完全涵盖,但作为软件架构师,您必须理解在您的团队中有信息安全领域的专家是确保遵守政策和实践以避免网络攻击并保持服务的机密性、隐私、完整性、真实性和可用性的必要性。

在保护 ASP.NET Core 应用程序方面,值得一提的是该框架具有许多功能来帮助我们。例如,它包括身份验证和授权模式。在 OWASP Cheat Sheet Series 中,您将能够阅读关于许多其他.NET 实践的信息。

开放网络应用安全项目®(OWASP)是一个致力于改善软件安全的非营利基金会。请查看其信息网站owasp.org/

ASP.NET 还提供了帮助我们遵守 GDPR 的功能。基本上,有 API 和模板来指导您实施政策声明和 cookie 使用同意。

实现安全架构的实践清单

以下与安全相关的实践清单当然并未涵盖整个主题。但是,这些实践肯定会帮助您作为软件架构师探索与此主题相关的一些解决方案。

认证

为您的 Web 应用程序定义身份验证方法。现在有许多身份验证选项可供选择,从 ASP.NET Core Identity 到外部提供者身份验证方法,例如 Facebook 或 Google。作为软件架构师,您必须考虑应用程序的目标受众是谁。如果选择这条路,考虑使用 Azure Active Directory 也是值得考虑的起点。

您可能会发现将身份验证与 Azure AD 相关联很有用,这是一个用于管理您所在公司的 Active Directory 的组件。在某些场景中,特别是内部使用,这种替代方案非常好。Azure 目前提供用于B2B - 企业对企业B2C - 企业对消费者的 Active Directory。

根据您正在构建的解决方案的情景,您将需要实施MFA - 多因素身份验证。这种模式的想法是在允许解决方案使用之前要求至少两种身份证明形式。值得一提的是,Azure AD 为您简化了这一点。

不要忘记您必须为您提供的 API 确定身份验证方法。 JSON Web Token 是一个非常好的模式,其使用完全跨平台。

您必须确定您将在 Web 应用程序中使用的授权模型。有四种模型选项:

  1. 简单,您只需在类或方法中使用[Authorize]属性;

  2. 基于角色,您可以为访问您正在开发的Controller声明Roles

  3. 基于声明,您可以定义在身份验证期间必须接收的值,以指示用户是否被授权;

  4. 基于策略,其中有一个旨在定义Controller中的访问权限的策略。

您还可以将控制器或类中的方法定义为对任何用户���全可访问,方法是定义属性[AllowAnonymous]。确保这种实现不会在您设计的系统中造成任何漏洞。

您决定使用的模型将确切地定义每个用户在应用程序中能够做什么。

敏感数据

在设计时,作为软件架构师,您将不得不决定存储的数据的哪一部分是敏感的,并且需要受到保护。通过连接到 Azure,您的 Web 应用程序将能够在诸如 Azure 存储和 Azure Key Vault 之类的组件中存储受保护的数据。 Azure 存储将在第九章“如何在云中选择您的数据存储”中讨论。

值得一提的是,Azure Key Vault 用于保护您的应用可能具有的秘密。当您有这种要求时,请考虑使用这种解决方案。

Web 安全

在没有启用 HTTPS 协议的情况下部署生产解决方案是完全不可接受的。 Azure Web 应用程序和 ASP.NET Core 解决方案有各种可能性,不仅可以使用,而且可以强制使用这种安全协议。

有许多已知的攻击和恶意模式,例如跨站点请求伪造,开放式重定向和跨站点脚本。 ASP.NET Core 保证并提供了解决它们的 API。您需要检测对您的解决方案有用的那些。

通过在查询中使用参数来避免 SQL 注入等良好的编程实践是另一个重要目标。

您可以在docs.microsoft.com/en-us/azure/architecture/patterns/category/security找到云架构安全模式。

最后,值得一提的是,安全性需要使用洋葱方法来处理,这意味着需要实现许多层安全性。您必须有一个确定的策略来保证访问数据的过程,包括使用您正在开发的系统的人员的物理访问。此外,您还必须开发一个灾难恢复解决方案,以防系统遭受攻击。灾难恢复解决方案将取决于您的云解决方案。我们将在第四章决定最佳基于云的解决方案中讨论这一点。

书籍用例 - 了解主要类型的.NET Core 项目

本书用例的开发将基于各种类型的.NET Core Visual Studio 项目。本节描述了它们。让我们在 Visual Studio 的文件菜单中选择新项目

您可以通过在搜索引擎中输入来筛选.NET Core项目类型:

图 2.19:在 Visual Studio 中搜索.NET Core 项目类型

在那里,您将找到常见的 C#项目(控制台、类库、Windows Form、WPF),以及基于不同测试框架的各种类型的测试项目:xUnit、NUnit 和 MSTest。在各种测试框架中进行选择只是一种偏好,因为它们都提供了可比较的功能。向解决方案中的每个软件组件添加测试是一种常见做法,可以使软件经常修改而不危及其可靠性。

您还可以在.NET Standard下定义您的类库项目,这将在第十三章在 C# 9 中实现代码重用性中讨论。这些类库基于标���,使它们与多个.NET 版本兼容。例如,基于 2.0 标准的库与所有大于或等于 2.0 的.NET Core 版本兼容,并与所有大于 4.6 的.NET Framework 版本兼容。这种兼容性优势是以功能较少为代价的。

除了将项目类型筛选为,我们还有几种项目类型。其中一些将使我们能够定义微服务。基于微服务的架构允许将应用程序拆分为几个独立的微服务。可以创建并分布在多台机器上的同一微服务的多个实例,以微调每个应用程序部分的性能。微服务将在以下章节中描述:

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

  • 第六章Azure Service Fabric

  • 第七章Azure Kubernetes Service

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

总结

描述系统行为的功能要求必须与约束系统性能、可扩展性、可用性、弹性、互操作性、可用性和安全性的非功能性需求一起完成。

性能要求来自响应时间和系统负载要求。作为软件架构师,您应该确保以最低成本获得所需的性能,构建高效的算法,并充分利用多线程的可用硬件资源。

可伸缩性是系统适应增加负载的能力。系统可以通过提供更强大的硬件来进行垂直扩展,也可以通过复制和负载平衡相同的硬件来进行水平扩展,从而增加可用性。总的来说,云和 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 的演进,它提供了各种新功能,可以帮助开发人员记录和组织他们的软件。本章的目的是介绍 Microsoft 提供的这个工具的概述。

本章将涵盖以下主题:

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

  • 了解 Azure DevOps 提供的功能

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

  • 在 Azure DevOps 中提出用例

本章的前两节总结了 Azure DevOps 提供的所有功能,而其余部分专注于文档需求和支持整个开发过程的工具。在其他章节中,将更详细地分析在前两节中介绍的大部分功能。

技术要求

本章要求您创建一个新的免费 Azure 帐户或使用现有的帐户。第一章了解软件架构的重要性部分解释了如何创建一个。Azure DevOps 存储库子部分还需要 Visual Studio 2019 社区版(免费)或更高版本。

介绍 Azure DevOps

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

当然,Azure DevOps 是一个完整的框架,它提供了当前可用的软件开发生态系统。自动化软件生产中涉及的所有步骤确保不断改进和改善现有解决方案,以使其适应市场需求。

您可以在 Azure 门户中开始该过程。如果您不知道如何创建 Azure 门户帐户,请查看第一章了解软件架构的重要性。创建 Azure DevOps 帐户的步骤非常简单:

  1. 选择创建资源,然后选择DevOps Starter

图 3.1:DevOps 起始页

  1. 一旦开始创建项目的向导,您可以从几种不同的平台中选择如何交付您的系统。这是 Azure DevOps 的最大优势之一,因为您不仅限于 Microsoft 工具和产品,而是可以从市场上所有常见的平台、工具和产品中进行选择:

图 3.2:DevOps 技术选择

  1. 可用的选项将取决于第一步选择的平台。在某些情况下,您可以从几种部署选项中进行选择,如下面的屏幕截图所示,如果您选择.NET 平台:

图 3.3:DevOps 技术选择,更多细节

图 3.4:DevOps 部署选项

  1. 设置完成后,您将能够根据您提供的信息使用项目门户管理项目。值得一提的是,如果您没有 Azure DevOps 服务,此向导将创建一个。此外,还会自动创建所选的部署资源,例如,如果您选择Windows Web App,将创建一个 Web 应用程序,如果您选择虚拟机,将创建一个虚拟机。Azure DevOps 组织是您可以组织所有 Azure DevOps 项目的地方。整个过程不到 20 分钟:

图 3.5:Web 应用程序项目的 DevOps 详细信息

图 3.6:虚拟机项目的 DevOps 详细信息

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

图 3.7:DevOps 项目页面

正如您从前面的屏幕截图中所看到的,创建 Azure DevOps 帐户并开始开发最佳的 DevOps 工具的过程非常简单。值得一提的是,只要您的团队中没有超过五名开发人员,加上任意数量的利益相关者,您就可以免费使用这个出色的工具。

值得一提的是,利益相关者的数量没有限制,因为他们可以使用的功能非常有限。基本上,他们对面板和工作项具有只读权限,并且几乎没有修改它们的可能性。具体来说,他们可以添加新的工作项和现有标签到工作项,并且可以提供反馈。关于构建和发布,他们只能批准发布(在撰写本书时,还有一些其他有限的功能处于预览状态)。

使用 Azure DevOps 组织您的工作

DevOps 将在第二十章中进行详细讨论,理解 DevOps 原则,但您需要将其理解为一种专注于为客户提供价值的哲学。它是人、流程和产品的结合,使用持续集成和持续部署CI/CD)方法来对交付给生产环境的软件应用程序进行持续改进。Azure DevOps 是一个强大的工具,其应用范围涵盖了应用程序的初始开发和随后的 CI/CD 过程中涉及的所有步骤。

Azure DevOps 包含用于收集需求和组织整个开发过程的工具。它们可以通过单击 Azure DevOps 页面上的面板菜单来访问,并将在接下来的两个小节中进行更详细的描述:

图 3.8:面板菜单

Azure DevOps 中的所有其他功能在以下小节中进行了简要回顾。它们将在其他章节中进行详细讨论。具体来说,CI 和构建/测试管道在第十八章中讨论,使用单元测试用例和 TDD 测试您的代码,以及第二十一章应用 CI 场景的挑战,而 DevOps 原则和发布管道在第二十章中讨论,理解 DevOps 原则

Azure DevOps 存储库

存储库菜单项让您访问默认的 Git 存储库,您可以在其中放置项目的代码:

图 3.9:存储库菜单

单击文件项目,您将进入默认存储库的初始页面。它是空的,并包含有关如何连接到此默认存储库的说明。

您可以通过页面顶部的下拉菜单添加更多的存储库:

图 3.10:添加新存储库

所有创建的存储库都可以通过同一个下拉菜单访问。

如前面的屏幕截图所示,每个存储库的初始页面包含存储库地址和一个按钮,用于生成特定于存储库的凭据,因此您可以使用您喜欢的 Git 工具连接到您的 DevOps 存储库。但是,您也可以从 Visual Studio 内部以非常简单的方式连接:

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

  2. 选择团队资源管理器选项卡,然后单击连接按钮:

图 3.11:添加新存储库

  1. 单击 Azure DevOps 的连接...链接,您将被引导设置与您的 Azure DevOps 项目之一的连接。

一旦连接到您的 DevOps 远程存储库,您可以使用 Visual Studio Git 工具,并且还可以从 Visual Studio 内部与其他 DevOps 功能进行交互。在撰写本书时,交互的类型取决于 Visual Studio 选项中的新 Git 用户体验复选框设置:

图 3.12:新的 Git 用户体验复选框

如果未经检查,您将拥有“经典”的 Visual Studio 体验,其中您可以使用 Team Explorer 访问 Git 和其他 DevOps 功能:

  1. 单击 Team Explorer 的主页按钮。现在,您将看到执行 Git 操作和与其他 Azure DevOps 区域进行交互的命令:

图 3.13:Team Explorer 选项

  1. 如果您被指定为初始化 DevOps 存储库的人,请创建一个起始解决方案,并通过单击更改按钮并按照随后的说明提交您的代码。

  2. 单击同步按钮,将本地存储库与远程 Azure DevOps 存储库同步。

  3. 一旦所有团队成员都使用上述步骤初始化了他们的本地机器存储库和 Azure DevOps 存储库,现在可以打开 Visual Studio。在Team Explorer窗口的底部区域中将显示在本地存储库中创建的解决方案。

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

相反,如果新 Git 用户体验复选框被选中,则Team Explorer窗口只有用于非 Git DevOps 操作的命令,以及用于打开专门用于 Git 操作的新窗口的打开 Git 更改链接:

图 3.14:新 Team Explorer 窗口

一旦进入此窗口,您将被要求克隆或创建一个新的存储库。在两种情况下,您将被提示提供远程 DevOps 存储库的地址。一旦您创建了连接到远程 DevOps 存储库的本地存储库,您可以开始使用新的 Git 窗口,该窗口提供的选项比经典的 Team Explorer 窗口提供的选项更多,总体上提供了更完整的用户体验:

图 3.15:Git 更改窗口

当您有要提交的更改时,您可以在窗口顶部的文本框中插入消息,并通过单击提交所有按钮在本地提交,或者您可以单击此按钮旁边的下拉菜单以访问更多选项:

图 3.16:提交选项

您可以提交和推送,或者提交和同步,但也可以暂存您的更改。Git 更改窗口右上角的三个箭头分别触发获取、拉取和推送。与此同时,窗口顶部的下拉菜单负责分支操作:

图 3.17:分支操作

软件包源

Artifacts菜单处理项目使用或创建的软件包。在那里,您可以为基本上所有类型的软件包定义 feeds,包括 NuGet、Node.js 和 Python。由于商业项目也使用私有软件包,因此需要私有 feeds,因此您需要一个放置它们的地方。此外,构建过程中生成的软件包也放置在这些 feeds 中,因此具有这些软件包作为依赖项的其他模块可以立即使用它们。

一旦进入Artifacts区域,您可以通过单击+创建 Feed按钮创建多个 feeds,每个 feed 可以处理多种类型的软件包,如图 3.18所示:

图 3.18:Feed 创建

如果选择连接到公共来源的软件包选项,默认情况下,feed 会连接到npmjsnuget.orgpypi.org。但是,您可以转到Feed设置页面中的上游来源选项卡,删除/添加软件包来源。通过单击 feed 页面右上角的设置图标可以进入设置页面。以下是新创建的 feed 页面的截图:

图 3.19:Feed 页面

每个 feed 的连接到 feed按钮显示一个窗口,解释了如何连接到 feed 的每种软件包类型。特别是对于 NuGet 软件包,您应该将所有项目 feed 添加到您的 Visual Studio 项目/解决方案的nuget.config文件中,以便本地机器也可以使用它们,否则,您的本地构建将失败:

图 3.20:Feed 连接信息

测试计划

测试计划部分允许您定义要使用的测试计划及其设置。测试在第十八章“使用单元测试用例和 TDD 测试代码”和第二十二章“功能测试自动化”中有详细讨论,但在这里,我们想总结一下 Azure DevOps 提供的机会。测试相关的操作和设置可以通过测试计划菜单项访问:

图 3.21:Feed 测试计划菜单

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

  • 手动验收测试

  • 自动单元测试

  • 负载测试

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

  • 一些配置设置

  • 何时执行它们

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

对于手动测试,您可以在项目文档中为操作员定义完整的指令,涵盖执行它们的环境(例如操作系统)以及报告结果的位置。您还可以定义如何执行负载测试以及如何测量结果。

管道

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

图 3.22:管道菜单

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

通常,在测试通过后,应用程序会自动部署到一个暂存区,在那里可以进行 Beta 测试。您还可以定义自动部署到生产环境的标准。这些标准包括但不限于以下内容:

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

  • 在 Beta 测试期间发现的错误数量和/或最后一次代码更改删除的错误数量

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

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

在 Azure DevOps 中管理系统需求

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

DevOps 通常与敏捷方法相结合,因此 Azure DevOps 使用迭代,并且整个开发过程组织为一组冲刺。可用的工作项取决于您在创建 Azure DevOps 项目时选择的工作项流程。以下各小节包含在选择敏捷Scrum 工作项流程时出现的最常见工作项类型的描述(默认为敏捷)。

史诗级工作项

想象一下,您正在开发由各种子系统组成的系统。您可能不会在单个迭代中完成整个系统。因此,我们需要一个跨越多个迭代的大伞来包含每个子系统的所有特性。每个史诗级工作项代表这些大伞中的一个,可以包含在各种开发迭代中实现的多个特性。

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

特性工作项

您在史诗级工作项中提供的所有信息也可以放在特性工作项中。因此,这两种类型的工作项之间的区别不是与它们包含的信息类型有关,而是与它们的角色和您的团队将达到的目标有关。史诗可能跨越多个迭代,并且在特性之上具有层次结构,也就是说,每个史诗级工作项与多个子特性相关联,而每个特性工作项通常在几个迭代中实现,并且是单个史诗级工作项的一部分。

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

特性工作项是记录用户需求的起点。例如,您可以编写一个名为访问控制的特性工作项,以定义实现系统访问控制所需的完整功能。

产品积压项/用户故事工作项

这些工作项中的哪些是可用的取决于所选的工作项流程。它们之间存在细微差异,但它们的目的基本相同。它们包含了与它们连接的特性工作项描述的功能的详细要求。更具体地说,每个产品积压/用户故事工作项都指定了作为其父特性工作项描述的行为的一部分的单个功能的要求。例如,在用于系统访问控制的特性工作项中,用户的维护和登录界面应该是两个不同的用户故事/产品积压项。这些要求将指导其他子工作项的创建:

  • 任务:这些是描述需要完成的工作的重要工作项,以满足父产品积压项/用户故事工作项中规定的要求。任务工作项可以包含有助于团队容量管理和整体安排的时间估计。

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

您将为每个产品积压项/用户故事工作项创建的任务和测试用例的数量将根据您使用的开发和测试场景而变化。

用例 - 在 Azure DevOps 中呈现用例

本节通过 WWTravelClub 的实际示例澄清了前一节中概念。考虑到第一章中描述的场景,理解软件架构的重要性,我们决定定义三个史诗级工作项,如下所示:

图 3.23:用户案例史诗

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

  1. 在每个工作项内部,我们链接不同类型的工作项,就像您在图 3.24中看到的那样。

  2. 了解工作项之间的连接在软件开发过程中非常重要。因此,作为软件架构师,您必须向团队提供这些知识,而且更重要的是,您必须激励他们建立这些连接!

图 3.24:定义链接

  1. 一旦您创建了一个特性工作项,您将能够将其连接到几个详细说明其规格的产品积压工作项。以下截图显示了产品积压工作项的详细信息!

图 3.25:产品积压工作项

  1. 之后,可以为每个产品积压工作项创建任务和测试用例工作项。Azure DevOps 提供的用户界面非常有效,因为它使您能够跟踪功能链和它们之间的关系!

图 3.26:看板视图

  1. 一旦您完成了产品积压和任务工作项的输入,您将能够与团队一起规划项目冲刺。计划视图使您能够将产品积压工作项拖放到每个计划的冲刺中!

图 3.27:积压视图

通过在右侧点击特定的冲刺,您将只看到分配给该冲刺的工作项。每个冲刺页面与积压页面非常相似,但包含更多选项卡:

图 3.28:用户案例史诗

左侧的冲刺菜单也非常有用,它使每个用户能够立即跳转到他们参与的所有项目的当前冲刺。

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

总结

本章介绍了如何为软件开发项目创建 Azure DevOps 帐户,以及如何开始使用 Azure DevOps 管理您的项目。它还简要回顾了所有 Azure DevOps 功能,并解释了如何通过 Azure DevOps 主菜单访问它们。本章更详细地描述了如何管理系统需求,以及如何使用各种工作项规划和组织可以提供史诗解决方案和许多特性的冲刺。

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

问题

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

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

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

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

  5. 史诗和特性工作项之间有什么区别?

  6. 任务和产品积压项/用户故事工作项之间存在什么样的关系?

进一步阅读

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

第四章:选择最佳的基于云的解决方案

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

本章将涵盖以下主题:

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

  • 平台即服务解决方案

  • 软件即服务解决方案

  • 无服务器解决方案

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

值得一提的是,在这些选项之间的选择取决于项目场景的不同方面。这也将在本章中讨论。

技术要求

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

不同的软件部署模型

云解决方案可以使用不同的模型进行部署。你决定如何部署你的应用程序取决于你所在团队的类型。在有基础设施工程师的公司,你可能会发现更多的人使用基础设施即服务IaaS)。另一方面,在 IT 不是核心业务的公司,你会发现一堆软件即服务SaaS)系统。对于开发人员来说,决定使用平台即服务PaaS)选项或者无服务器选项是很常见的,因为在这种情况下他们不需要交付基础设施。

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

IaaS 和 Azure 的机会

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

在 Azure 中,提供了几种基于 IaaS 模型的服务。其中大部分是收费的,在测试时需要注意这一点。值得一提的是,本书并不打算详细描述 Azure 提供的所有 IaaS 服务。然而,作为软件架构师,你只需要了解到你会找到以下这些服务:

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

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

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

要在 Azure 中创建任何服务,你必须找到最适合你需求的服务,然后创建一个资源。下面的截图显示了正在配置 Windows Server 虚拟机。

图 4.1:在 Azure 中创建虚拟机

按照 Azure 提供的向导设置你的虚拟机,你将能够使用远程桌面协议RDP)连接到它。下一张截图展示了你部署虚拟机的一些硬件选项。考虑到我们只需点击选择按钮就可以获得不同的容量,这是一个有趣的想法。

图 4.2:Azure 中可用的虚拟机大小

如果你将本地交付硬件的速度与云交付的速度进行比较,你会意识到在上市时间方面,没有比云更好的了。例如,截图底部呈现的D64s_v3机器,具有 64 个 CPU,256GB 的 RAM 和 512GB 的临时存储,这是你在本地数据中心可能找不到的。此外,在某些用例中,这台机器可能只在一个月中的某些小时内使用,因此在本地场景中无法证明其购买是合理的。这就是云计算如此令人惊叹的原因!

IaaS 中的安全责任

安全责任是关于 IaaS 平台的另一个重要事项。许多人认为一旦你决定转向云端,所有安全问题都由提供商解决了。然而,这是不正确的,正如你在下面的截图中所看到的:

图 4.3:云计算中的安全管理

IaaS 将迫使你从操作系统到应用程序都要关注安全。在某些情况下,这是不可避免的,但你必须明白这将增加你的系统成本。

如果你只想将已经存在的本地结构转移到云端,IaaS 可能是一个不错的选择。这使得可伸缩性成为可能,因为 Azure 提供给你的工具以及所有其他服务。然而,如果你计划从头开始开发一个应用程序,你也应该考虑 Azure 上其他可用的选项。

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

PaaS-开发者的无限机会

如果你正在学习或已经学习了软件架构,你可能完全理解下一句的意思:当涉及软件开发时,世界要求高速!如果你同意这一点,你会喜欢 PaaS。

正如你在前面的截图中所看到的,PaaS 使你只需要担心与你的业务更密切相关的安全方面:你的数据和应用程序。对于开发人员来说,这意味着不必实施一堆使你的解决方案安全运行的配置。

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

现在在 Azure 中有很多作为 PaaS 交付的服务,再次强调,本书的目的不是列举所有这些服务。然而,有些确实需要提到。列表不断增长,建议是:尽可能多地使用和测试这些服务!确保你会以此为考虑来提供更好设计的解决方案。

另一方面,值得一提的是,使用 PaaS 解决方案,你将无法完全控制操作系统。事实上,在许多情况下,你甚至无法连接到它。大多数情况下这是好事,但在某些调试情况下,你可能会错过这个功能。好消息是,PaaS 组件每天都在不断发展,微软最关注的问题之一就是使它们广泛可见。

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

Web 应用程序

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

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

此外,Web 应用程序还有一个计划选项,您无需支付使用费。当然,也有一些限制,比如只能运行 32 位应用程序,无法实现可伸缩性,但这对于原型设计来说可能是一个很好的场景。

SQL 数据库

想象一下,如果您拥有完整的 SQL 服务器的强大功能,而无需为部署此数据库而支付大型服务器的费用,您可以多快部署解决方案。这适用于 SQL 数据库。使用它们,您可以使用 Microsoft SQL Server 执行您最需要的功能-存储和数据处理。在这种情况下,Azure 负责备份数据库。

SQL 数据库甚至可以自行管理性能。这被称为自动调整。同样,使用 PaaS 组件,您将能够专注于对您的业务至关重要的事情:非常快速的上市时间。

创建 SQL 数据库的步骤非常简单,就像我们之前为其他组件所见的那样。但是,有两件事情需要注意:服务器本身的创建以及如何收费。

当您创建资源时,您可以搜索SQL 数据库,您将找到此向导来帮助您:

图 4.4:在 Azure 中创建 SQL 数据库

SQL 数据库依赖于 SQL 服务器来托管它。因此,正如您所看到的,您必须创建(至少对于第一个数据库)一个database.windows.net服务器,您的数据库将托管在那里。该服务器将提供您访问 SQL 服务器数据库所需的所有参数,如 Visual Studio、SQL Server 管理工作室和 Azure 数据工作室等当前工具。值得一提的是,您有许多关于安全性的功能,如透明数据加密和 IP 防火墙。

一旦您决定数据库服务器的名称,您将能够选择系统将收费的定价层。特别是在 SQL 数据库中,有几种不同的定价选项,如您在下面的截图中所见。您应该仔细研究每一个,因为根据您的情况,通过优化定价层,您可能会节省一些费用:

图 4.5:配置 Azure SQL 数据库定价层

有关 SQL 配置的更多信息,您可以使用此链接:azure.microsoft.com/en-us/services/sql-database/

一旦您完成配置,您将能够以与在本地安装 SQL 服务器时相同的方式连接到此服务器数据库。您需要注意的唯一细节是 Azure SQL 服务器防火墙的配置,但这很容易设置,并且很好地展示了 PaaS 服务的安全性。

Azure 认知服务

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

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

PaaS 的优点从这个场景中可以看出。在本地环境或 IaaS 环境中,您需要执行大量任务来准备应用程序。在 PaaS 中,您无需担心这个。您完全专注于作为软件架构师真正关心的问题:解决业务问题的解决方案。

在 Azure 帐户中设置 Azure 认知服务也非常简单。首先,您需要像添加其他 Azure 组件一样添加认知服务,如下图所示:

图 4.6:在 Azure 中创建认知服务 API

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

图 4.7:创建的认知服务端点

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

private static async Task<string> PostAPI(string api, string key, string region, string textToTranslate)
{
   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);
   client.Timeout = TimeSpan.FromSeconds(5);
   var body = new[] { new { Text = textToTranslate } };
   var requestBody = JsonConvert.SerializeObject(body);
   request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
   var response = await client.SendAsync(request);
      response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
} 

值得一提的是,上述代码将允许您将任何文本翻译为任何语言,前提是您在参数中定义了它。以下是调用前面方法的主程序:

static async Task Main()
{
   var host = "https://api.cognitive.microsofttranslator.com";
   var route = "/translate?api-version=3.0&to=es";
   var subscriptionKey = "[YOUR KEY HERE]";
   var region = "[YOUR REGION HERE]";

   var translatedSentence = await PostAPI(host + route,
   subscriptionKey,region, "Hello World!");

   Console.WriteLine(translatedSentence);

} 

有关更多信息,请访问docs.microsoft.com/en-us/azure/cognitive-services/translator/reference/v3-0-languages

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

SaaS - 只需登录并开始使用!

SaaS 可能是使用基于云的服务最简单的方式。云服务提供商为其最终用户提供了许多解决公司常见问题的好选择。

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

另一个很好的 SaaS 平台例子是 Azure DevOps。作为软件架构师,在 Azure DevOps 之前,您需要安装和配置 Team Foundation Server(TFS)(甚至是更早的工具,如 Microsoft Visual SourceSafe),以便团队使用共享存储库和应用程序生命周期管理工具。

我们过去花了很多时间,要么是为 TFS 安装准备服务器,要么是升级和持续维护已安装的 TFS。由于 SaaS Azure DevOps 的简单性,这不再需要。

理解无服务器的含义

无服务器解决方案是一种不关注代码运行位置的解决方案。即使在“无服务器”解决方案中,仍然存在服务器。问题在于,您不知道或不关心代码在哪个服务器上执行。

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

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

第十章“使用 Azure 函数”中,您将探索微软在 Azure 中提供的最佳无服务器实现之一——Azure 函数。在那里,我们将重点介绍如何开发无服务器解决方案以及它们的优缺点。

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

混合解决方案是指其部分不共享统一的架构选择的解决方案;每个部分都做出不同的架构选择。在云中,混合一词主要指混合云子系统与本地子系统的解决方案。然而,它也可以指混合网络子系统与特定设备子系统,如移动设备或任何运行代码的其他设备。

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

您需要了解这些先入之见中有一些真理,但人们的想法并非如此。当然,作为软件架构师,您不能忽视它们。特别是在开发关键系统时,您必须决定是否一切都可以在云上运行,还是最好将系统的一部分交付到边缘。

边缘计算范式是一种用于在更靠近所需位置的机器或设备上部署系统部分的方法。这有助于减少响应时间和带宽消耗。

移动解决方案可以被视为混合应用的经典示例,因为它们将基于 Web 的架构与基于设备的架构混合在一起,以提供更好的用户体验。有许多情况可以用响应式网站替换移动应用程序。然而,当涉及界面质量和性能时,也许响应式网站无法给最终用户真正需要的东西。

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

书中用例——哪种云解决方案最好?

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

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

乍一看,任何开发人员都会回答:Web 应用。然而,iOS 和 Android 平台也需要您作为软件架构师的关注。在这种情况下,就像在几种情况下一样,用户体验是项目成功的关键。决策不仅需要由开发速度驱动,而且还需要由提供出色用户体验所获得的好处驱动。

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

以下屏幕截图代表了 WWTravelClub 架构的第一个选择。依赖 Azure 组件的决定与成本和维护考虑相关。这些项目中的每一项将在本书的后续部分中讨论,在第八章在 C#中与数据交互-Entity Framework Core第九章如何在云中选择您的数据存储,以及第十章使用 Azure Functions,以及选择的原因。现在,知道 WWTravelClub 是一个混合应用程序,在移动设备上运行 Xamarin 应用程序,并在服务器端运行 ASP.NET Core Web 应用程序就足够了。

图 4.8:WWTravelClub 架构

正如您在图片中所看到的,WWTravelClub 架构主要是由 Azure 提供的 PaaS 和无服务器组件设计的。所有的开发将在 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 如何处理微服务?

  • 管理微服务所需的工具有哪些?

通过本章的学习,您将学会如何在.NET 中实现单个微服务。第六章“Azure Service Fabric”和第七章“Azure Kubernetes Service”还介绍了如何部署、调试和管理基于微服务的整个应用程序。

技术要求

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

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

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

  • 如果您想在 Visual Studio 中调试 Docker 容器化的微服务,需要 Windows 版 Docker Desktop(www.docker.com/products/docker-desktop)。

什么是微服务?

微服务架构允许将解决方案的每个模块独立于其他模块进行扩展,以实现最大吞吐量和最小成本。事实上,对整个系统进行扩展而不是当前瓶颈部分必然会导致资源的明显浪费,因此对子系统扩展的细粒度控制对系统的整体成本有着重要影响。

然而,微服务不仅仅是可扩展的组件-它们是可以独立开发、维护和部署的软件构建块。将开发和维护分割成可以独立开发、维护和部署的模块,可以改善整个系统的 CI/CD 周期(CI/CD 概念在第三章的“使用 Azure DevOps 组织工作”部分和“使用 Azure DevOps 记录需求”部分中有更详细的解释)。

由于微服务的“独立性”,CI/CD 的改进是可能的,因为它实现了以下功能:

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

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

  • 将开发任务分配给完全独立的小团队,从而简化工作组织并减少处理大型团队时不可避免的协调低效问题。

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

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

  • 遗留子系统可以嵌入独立的微服务中,从而使它们能够与新的子系统合作。这样,公司可以减少新系统版本的上市时间。此外,这样,遗留系统可以逐渐向更现代的系统演进,对成本和组织的影响可接受。

下一小节解释了微服务的概念是如何构思的。然后,我们将继续通过探索基本的微服务设计原则并分析为什么微服务通常被设计为 Docker 容器来继续介绍本章节。

微服务和模块概念的演变

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

  • 代码模块化是指使我们能够修改一块代码而不影响应用程序其余部分的代码组织。通常,它是通过面向对象设计来实现的,其中模块可以用类来标识。

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

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

作为对导致微服务发展的主要里程碑的简要回顾,我们可以说,首先,将单体可执行文件拆分为静态库。随后,动态链接库(DLL)取代了静态库。

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

然而,.NET 不能接受两个引用的 DLL - 假设为AB - 使用共同依赖项的两个不同版本 - 假设为C。例如,假设有一个新版本的A,具有许多我们想要使用的新功能,反过来依赖于B不支持的C的新版本。在这种情况下,由于CB的不兼容性,我们应该放弃A的新版本。这个困难导致了两个重要的变化:

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

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

微服务是 SOA 的演变,并增加了更多功能和约束,以改善服务的可伸缩性和模块化,以改善整体的 CI/CD 周期。有时人们说微服务是 SOA 做得好

微服务设计原则

总之,微服务架构是最大程度地实现独立性和细粒度扩展的 SOA。现在我们已经澄清了微服务独立性和细粒度扩展的所有优势,以及独立性的本质,我们可以看看微服务设计原则。

让我们从独立性约束产生的原则开始。我们将在单独的小节中讨论它们。

设计选择的独立性

每个微服务的设计不能依赖于在其他微服务实现中所做的设计选择。这个原则使得每个微服务的 CI/CD 周期完全独立,并让我们在如何实现每个微服务上有更多的技术选择。这样,我们可以选择最好的可用技术来实现每个微服务。

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

在这里,拥有专用的数据存储并不意味着物理数据库分布在微服务本身的进程边界内,而是微服务具有对由外部数据库引擎处理的数据库或一组数据库表的独占访问权限。事实上,出于性能原因,数据库引擎必须在专用硬件上运行,并具有针对其存储功能进行优化的操作系统和硬件功能。

通常,设计选择的独立性以更轻的形式解释,通过区分逻辑和物理微服务。更具体地说,逻辑微服务是由使用相同数据存储但独立负载平衡的多个物理微服务实现的。也就是说,逻辑微服务被设计为一个逻辑单元,然后分割成更多的物理微服务以实现更好的负载平衡。

独立于部署环境

微服务在不同的硬件节点上进行扩展,并且不同的微服务可以托管在同一节点上。因此,微服务越少依赖操作系统提供的服务和其他安装的软件,它就可以部署在更多的硬件节点上。还可以进行更多的节点优化。

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

松散耦合

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

不要有链接的请求/响应

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

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

值得指出的是,“设计选择的独立性”原则实际上是领域驱动设计中的有界上下文原则,我们将在《第十二章:理解软件解决方案中的不同领域》中详细讨论。在本章中,我们将看到,通常情况下,完整的领域驱动设计方法对于每个微服务的“更新”子系统非常有用。

总的来说,按照有界上下文原则开发的所有系统通常都更适合使用微服务架构来实现。实际上,一旦将系统分解为几个完全独立且松耦合的部分,由于不同的流量和不同的资源需求,这些不同的部分很可能需要独立扩展。

除了上述约束,我们还必须添加一些构建可重用 SOA 的最佳实践。关于这些最佳实践的更多细节将在《第十四章:使用.NET Core 应用面向服务的架构》中给出,但是现在,大多数 SOA 最佳实践都是由用于实现 Web 服务的工具和框架自动强制执行的。

细粒度的扩展要求微服务足够小,以便隔离明确定义的功能,但这也需要一个复杂的基础架构来自动实例化微服务,将实例分配到各种硬件计算资源上,通常称为“节点”,并根据需要进行扩展。这些结构将在本章的“需要哪些工具来管理微服务?”部分中介绍,并在《第六章:Azure Service Fabric》和《第七章:Azure Kubernetes Service》中详细讨论。

此外,通过异步通信进行通信的细粒度扩展的分布式微服务要求每个微服务具有弹性。实际上,由于硬件故障或在负载平衡操作期间目标实例被终止或移动到另一个节点的简单原因,针对特定微服务实例的通信可能会失败。

临时故障可以通过指数级重试来克服。这意味着在每次失败后,我们会延迟指数级地重试相同的操作,直到达到最大尝试次数。例如,首先,我们会在 10 毫秒后重试,如果这次重试操作失败,那么在 20 毫秒后进行新的尝试,然后是 40 毫秒,依此类推。

另一方面,长期故障通常会导致重试操作的激增,可能会使所有系统资源饱和,类似于拒绝服务攻击。因此,通常会将指数级重试与“断路器策略”一起使用:在一定数量的失败之后,假定存在长期故障,并且通过返回立即失败而不尝试通信操作来阻止对资源的访问一段时间。

同样重要的是,某些子系统的拥塞,无论是由于故障还是请求高峰,都不会传播到其他系统部分,以防止整体系统拥塞。隔离舱壁通过以下方式避免拥塞传播:

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

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

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

重试策略可能导致同一消息被接收和处理多次,因为发送方未收到消息已被接收的确认,或者因为操作超时,而接收方实际上已接收了消息。这个问题的唯一可能解决方案是设计所有消息都是幂等的,也就是说,设计消息的处理多次与处理一次具有相同的效果。

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

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

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

  • 拒绝旧消息。

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

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

我们将在第六章的示例中使用这种技术,Azure Service Fabric

值得指出的是,一些消息代理(如 Azure Service Bus)提供了实施先前描述的技术的设施。Azure Service Bus 在“.NET 通信设施”子部分中进行了讨论。

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

容器和 Docker

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

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

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

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

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

图像是指定放入每个容器的内容以及要在容器外部公开的容器资源(如通信端口)的文件。图像不需要显式指定其完整内容,但可以分层。这样,通过在现有图像之上添加新的软件和配置信息来构建图像。

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

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

在实例化每个容器之前,Docker 运行时必须解决所有递归引用。这个繁琐的工作不是每次创建新容器时都执行的,因为 Docker 运行时有一个缓存,它存储与每个输入图像对应的完全组装的图像。

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

  • 部署哪些图像。

  • 如何将每个图像公开的内部资源映射到主机机器的物理资源。例如,如何将 Docker 图像公开的通信端口映射到物理机器的端口。

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

Docker 运行时处理单个机器上的图像和容器,但通常,容器化的微服务是部署和负载均衡在由多台机器组成的集群上的。集群由称为编排器的软件组成。编排器将在本章的需要哪些工具来管理微服务?部分中介绍,并在第六章Azure 服务织物第七章Azure Kubernetes 服务中详细描述。

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

微服务何时有帮助?

回答这个问题需要我们理解微服务在现代软件架构中的作用。我们将在以下两个小节中进行讨论:

  • 分层架构和微服务

  • 什么时候考虑微服务架构是值得的?

让我们详细了解分层架构和微服务。

分层架构和微服务

企业系统通常以逻辑独立的层组织。第一层是与用户交互的层,称为表示层,而最后一层负责存储/检索数据,称为数据层。请求起源于表示层,并通过所有层传递,直到达到数据层,然后返回,反向穿过所有层,直到达到表示层,表示层负责向用户/客户端呈现结果。层不能“跳过”。

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

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

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

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

关于如何构建分层架构的详细示例将在《第十二章》《理解软件解决方案中的不同领域》的《用例 - 理解用例的领域》部分中给出,该部分专门讨论领域驱动设计。

话虽如此,微服务如何适应分层架构?它们是否适用于所有层的功能还是只适用于某些层?单个微服务是否可以跨越多个层?

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

表示层

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

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

ASP.NET 是一个轻量级的框架,运行在轻量级的 Kestrel Web 服务器上,因此可以高效地容器化,并用于内部网络应用的微服务。然而,公共高流量的网站需要专用的硬件/软件组件,阻止它们与其他微服务一起部署。实际上,虽然 Kestrel 对于内部网络网站是一个可接受的解决方案,但公共网站需要更完整的 Web 服务器,如 IIS、Apache 或 NGINX。在这种情况下,安全性和负载均衡要求更加紧迫,需要专用的硬件/软件节点和组件。因此,基于微服务的架构通常提供专门的组件来处理与外部世界的接口。例如,在第七章《Azure Kubernetes 服务》中,我们将看到在 Kubernetes 集群中,这个角色由所谓的“入口”扮演。

单体网站可以轻松地分解为负载均衡的较小子网站,而无需使用微服务特定的技术,但是微服务架构可以将所有微服务的优势带入单个 HTML 页面的构建中。更具体地说,不同的微服务可以负责每个 HTML 页面的不同区域。不幸的是,在撰写本文时,使用现有的.NET 技术很难实现类似的场景。

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

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

什么时候值得考虑微服务架构?

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

  • 为节点分配实例并对其进行扩展会产生云费用或内部基础设施和许可证的成本。

  • 将一个独特的进程分解为更小的通信进程会增加通信成本和硬件需求,特别是如果微服务被容器化。

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

那么,什么时候微服务的成本值得使用?有哪些功能必须实现为微服务?

对于第二个问题的粗略答案是:是的,当应用程序在流量和/或软件复杂性方面足够大时。实际上,随着应用程序的复杂性增加和流量增加,我们建议支付与其扩展相关的成本,因为这样可以进行更多的扩展优化,并在开发团队方面更好地处理。我们为此支付的成本很快就会超过采用微服务的成本。

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

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

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

如果负责整个系统的 CI/CD 的团队规模增长过大,这个大团队的组织和协调会导致困难和低效。在这种情况下,最好将整个 CI/CD 周期分解为可以由较小团队负责的独立部分的架构。

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

从这个可以得出结论,如果系统和开发团队增长过快,就需要将开发团队分成较小的团队,每个团队负责一个高效的有界上下文子系统。在类似的情况下,微服务架构很可能是唯一可行的选择。

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

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

.NET 如何处理微服务?

.NET 被设计为一个多平台框架,足够轻量级和快速,以实现高效的微服务。特别是,ASP.NET 是实现文本 REST 和二进制 gRPC API 与微服务通信的理想工具,因为它可以在轻量级 Web 服务器(如 Kestrel)上高效运行,并且本身也是轻量级和模块化的。

整个.NET 框架在设计时就考虑了微服务作为战略部署平台,并提供了用于构建高效轻量级 HTTP 和 gRPC 通信的工具和包,以确保服务的弹性和处理长时间运行的任务。下面的小节描述了一些可以用来实现基于.NET 的微服务架构的不同工具或解决方案。

.NET 通信设施

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

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

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

虽然.NET 没有直接提供可帮助实现异步通信或实现发布者/订阅者通信的客户端/服务器工具,但 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. 在基于主题的通信中,每个发送者将消息发送到一个主题,而每个接收者从与该主题关联的私有订阅中拉取消息。

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

RabbitMQ 的功能与 Azure Service Bus 提供的功能类似,但您必须处理所有实现细节、执行操作的确认等,而 Azure Service Bus 会处理所有低级操作并为您提供一个更简单的接口。Azure Service Bus 和 RabbitMQ 将在第十一章“设计模式和.NET 5 实现”中与基于发布者/订阅者的通信一起进行描述。

如果微服务发布到 Azure Service Fabric 中,将在下一章(第六章“Azure Service Fabric”)中描述,我们可以使用内置的可靠二进制通信。

通信是弹性的,因为通信原语自动使用重试策略。这种通信是同步的,但这不是一个大的限制,因为 Azure Service Fabric 中的微服务具有内置队列;因此,一旦接收者接收到消息,他们可以将其放入队列中并立即返回,而不会阻塞发送者。

然后,队列中的消息由一个单独的线程处理。这种内置通信的主要限制是它不基于发布者/订阅者模式;发送者和接收者必须相互了解。当这种情况不可接受时,应该使用 Azure Service Bus。我们将在第六章“Azure Service Fabric”中学习如何使用 Service Fabric 的内置通信。

弹性任务执行

弹性通信和一般情况下的弹性任务执行可以通过一个名为 Polly 的.NET 库轻松实现,该项目是.NET 基金会的成员之一。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 秒,第二次重试 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 配置为任何 ASP.NET 和.NET 应用程序的依赖注入部分的HttPClient定义的一部分。这样,定义弹性客户端就非常简单。

官方 Polly 文档的链接在进一步阅读部分中。

使用通用主机

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

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

Web 主机的概念主要是为了实现 ASP.NET Core Web 框架,但是从.NET Core 2.1 开始,主机概念扩展到了所有.NET 应用程序。

在撰写本书时,在任何 ASP.NET Core 或 Blazor 项目中,都会自动为您创建一个Host,因此您只需要在其他项目类型中手动添加它。

Host概念相关的所有功能都包含在Microsoft.Extensions.Hosting NuGet 包中。

首先,您需要使用流畅的接口配置主机,从一个HostBuilder实例开始。此配置的最后一步是调用Build方法,该方法使用我们提供的所有配置信息组装实际的主机:

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

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

值得指出的是,ASP.NET Core 和 Blazor 项目使用执行Host的预配置方法,其中包括前面列出的几个任务。

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

host.Start(); 

程序在前面的指令上保持阻塞,直到主机关闭。主机可以通过其中一个托管服务或通过调用awaithost.StopAsync(timeout)来关闭。这里,timeout是一个时间段,定义了等待托管服务正常停止的最长时间。在此时间之后,如果托管服务尚未终止,所有托管服务都将被中止。

通常,微服务关闭的事实是通过在协调器启动微服务时传递的cancellationToken来表示的。当微服务托管在 Azure Service Fabric 中时,就会发生这种情况。

因此,在大多数情况下,我们可以使用RunAsyncRun方法,而不是使用host.Start(),可能会传递一个从协调器或操作系统中获取的cancellationToken

await host.RunAsync(cancellationToken) 

这种关闭方式在cancellationToken进入取消状态时立即触发。默认情况下,主机在关闭时有 5 秒的超时时间,即一旦请求关闭,它会等待 5 秒钟然后退出。这个时间可以在ConfigureServices方法中进行更改,该方法用于声明托管服务和其他资源:

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

然而,增加主机超时时间不会增加编排器超时时间,因此如果主机等待时间过长,编排器将终止整个微服务。

如果在RunRunAsync中没有显式传递取消令牌,则会自动生成一个取消令牌,并在操作系统通知应用程序即将终止时自动发出信号。这个取消令牌将传递给所有托管服务,以便它们有机会优雅地停止。

托管服务是IHostedService接口的实现,其唯一的方法是StartAsync(cancellationToken)StopAsync(cancellationToken)

这两个方法都传递了一个cancellationTokenStartAsync方法中的cancellationToken表示请求了关闭。StartAsync方法在执行启动主机所需的所有操作时定期检查这个cancellationToken,如果它被触发,主机启动过程将被中止。另一方面,StopAsync方法中的cancellationToken表示关闭超时已过期。

托管服务可以在用于定义主机选项的同一个ConfigureServices方法中声明,如下所示:

services.AddHostedService<MyHostedService>(); 

然而,一些项目模板(如 ASP.NET Core 项目模板)在不同的类中定义了一个ConfigureServices方法。如果这个方法接收与HostBuilder.ConfigureServices方法中可用的services参数相同的参数,那么这将正常工作。

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

using Microsoft.Extensions.DependencyInjection; 

通常情况下,不直接实现IHostedService接口,而是可以从BackgroundService抽象类继承,该抽象类公开了更容易实现的ExecuteAsync(CancellationToken)方法,我们可以在其中放置整个服务的逻辑。通过将cancellationToken作为参数传递,可以更容易地处理关闭。我们将在第六章的示例中查看IHostedService的实现,Azure Service Fabric

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

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

当创建托管服务时,它会自动传递一个IHostApplicationLifetime的实现,其中的StopApplication方法将触发主机关闭。这个实现是自动处理的,但我们也可以声明自定义资源,其实例将自动传递给所有声明它们为参数的主机服务构造函数。因此,假设我们定义了如下构造函数:

Public MyClass(MyResource x, IResourceInterface1 y)
{
    ...
} 

有几种方法可以定义上述构造函数所需的资源:

services.AddTransient<MyResource>();
services.AddTransient<IResourceInterface1, MyResource1>();
services.AddSingleton<MyResource>();
services.AddSingleton<IResourceInterface1, MyResource1>(); 

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

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

HostBuilder还有一个方法,我们可以用来定义默认文件夹,也就是用来解析所有.NET 方法中提到的所有相对路径的文件夹:

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

它还有一些方法,我们可以用来添加日志记录目标:

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

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

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

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

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

Visual Studio 对 Docker 的支持

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

Docker 支持将以一个简单的 ASP.NET MVC 项目来描述。让我们创建一个。要做到这一点,请按照以下步骤:

  1. 将项目命名为MvcDockerTest

  2. 为简单起见,如果尚未禁用身份验证,请禁用身份验证。

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

一旦您的 ASP.NET MVC 应用程序脚手架和运行,右键单击解决方案资源管理器中的项目图标,然后选择添加,然后选择容器编排器支持 | Docker Compose

您将会看到一个对话框,询问您选择容器应该使用的操作系统;选择与安装Windows Docker 桌面时选择的相同的操作系统。这将不仅启用 Docker 图像的创建,还将创建一个 Docker Compose 项目,帮助您配置 Docker Compose 文件,以便它们同时运行和部署多个 Docker 图像。实际上,如果您向解决方案添加另一个 MVC 项目并为其启用容器编排器支持,新的 Docker 图像将被添加到相同的 Docker Compose 文件中。

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

如果您的 Docker 运行时已经正确安装并运行,您应该能够从 Visual Studio 运行 Docker 图像。

分析 Docker 文件

让我们分析一下由 Visual Studio 创建的 Docker 文件。这是一系列的图像创建步骤。每个步骤都是通过From指令来丰富现有的图像,这是一个对已经存在的图像的引用。以下是第一步:

FROM mcr.microsoft.com/dotnet/aspnet:x.x AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443 

第一步使用了由 Microsoft 在 Docker 公共存储库中发布的mcr.microsoft.com/dotnet/aspnet:x.x ASP.NET(Core)运行时(其中x.x是您项目中选择的 ASP.NET(Core)版本)。

WORKDIR命令在将要创建的图像中创建了随后的目录。如果目录尚不存在,则在图像中创建它。两个EXPOSE命令声明了图像端口将被暴露到图像外部并映射到实际托管机器的端口。映射的端口在部署阶段通过 Docker 命令的命令行参数或 Docker Compose 文件中决定。在我们的例子中,有两个端口:一个用于 HTTP(80),另一个用于 HTTPS(443)。

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

第二步生成一个不同的图像,不用于部署,而是用于创建将被部署的特定应用程序文件:

FROM mcr.microsoft.com/dotnet/core/sdk:x  AS build
WORKDIR /src
COPY ["MvcDockerTest/MvcDockerTest.csproj", "MvcDockerTest/"]
RUN dotnet restore MvcDockerTest/MvcDockerTest.csproj
COPY . .
WORKDIR /src/MvcDockerTest
RUN dotnet build MvcDockerTest.csproj -c Release -o /app/build
FROM build AS publish
RUN dotnet publish MvcDockerTest.csproj -c Release -o /app/publish 

此步骤从包含我们不需要添加到部署的 ASP.NET SDK 图像开始;这些是用于处理项目代码的。在“构建”图像中创建了新的src目录,并使其成为当前图像目录。然后,将项目文件复制到/src/MvcDockerTest中。

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

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

最后一步从我们在第一步中创建的图像开始,其中包含 ASP.NET(Core)运行时,并添加在上一步中发布的所有文件:

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

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

发布项目

如果我们右键单击项目并单击“发布”,将显示几个选项:

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

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

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

以下 Docker Compose 文件将两个 ASP.NET 应用程序添加到同一个 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://+:8 
    ports:
      - "3150:80"
      - "44355:443"
    volumes:
      - ${APPDATA}/Asp.NET/Https:/root/.aspnet/https: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 

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

主机中的文件直接映射到图像中。每个声明包含主机中的路径,路径在图像中的映射方式以及所需的访问权限。在我们的例子中,使用volumes来映射 Visual Studio 使用的自签名 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 Server 的配置和安装参数。更具体地说,前面的代码包含以下信息:

  • 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 将在第六章Azure Service Fabric中详细介绍。

Visual Studio 还具有特定的项目模板,用于定义要部署在 Azure Kubernetes 中的微服务,并且具有用于调试单个微服务的扩展,同时与部署在 Azure Kubernetes 中的其他微服务进行通信。

还提供了用于在开发机器上测试和调试多个通信微服务的工具,无需安装任何 Kubernetes 软件,并且可以使用最少的配置信息自动部署到 Azure Kubernetes 上。

所有用于 Azure Kubernetes 的 Visual Studio 工具将在第七章Azure Kubernetes Service中进行描述。

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

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

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

  • 监视服务的健康状态,并在发生硬件/软件故障时替换故障服务

  • 记录和展示分析数据

  • 允许设计师动态更改要分配给集群的硬件节点、服务实例数量等要求

下面的小节描述了我们可以使用的 Azure 设施来存储 Docker 镜像。Azure 中可用的微服务编排器在各自的章节中进行了描述,即第六章Azure Service Fabric第七章Azure Kubernetes Service

在 Azure 中定义您的私有 Docker 注册表

在 Azure 中定义您的私有 Docker 注册表很容易。只需在 Azure 搜索栏中键入Container registries,然后选择Container registries。在出现的页面上,点击Add按钮。

将出现以下表单:

图 5.1:创建 Azure 私有 Docker 注册表

您选择的名称用于组成整体注册表 URI:.azurecr.io。与往常一样,您可以指定订阅、资源组和位置。SKU下拉菜单可让您选择不同级别的服务,这些服务在性能、可用内存和一些其他辅助功能方面有所不同。

无论何时在 Docker 命令或 Visual Studio 发布表单中提到图像名称,都必须在注册表 URI 前加上前缀:.azurecr.io/

如果使用 Visual Studio 创建了图像,则可以按照发布项目后出现的说明进行发布。否则,您必须使用docker命令将它们推送到您的注册表中。

使用与 Azure 注册表交互的 Docker 命令的最简单方法是在计算机上安装 Azure CLI。从aka.ms/installazurecliwindows下载安装程序并执行它。安装了 Azure CLI 后,您可以从 Windows 命令提示符或 PowerShell 使用az命令。为了连接到您的 Azure 帐户,您必须执行以下登录命令:

az login 

此命令应启动您的默认浏览器,并引导您完成手动登录过程。

登录到 Azure 帐户后,您可以通过输入以下命令登录到私有注册表:

az acr login --name {registryname} 

现在,假设您在另一个注册表中有一个 Docker 镜像。作为第一步,让我们在本地计算机上拉取镜像:

docker pull other.registry.io/samples/myimage 

如果有几个版本的前面的图像,则将拉取最新版本,因为没有指定版本。可以按如下方式指定图像的版本:

docker pull other.registry.io/samples/myimage:version1.0 

使用以下命令,您应该在本地图像列表中看到myimage

docker images 

然后,使用您想要在 Azure 注册表中分配的路径为图像打上标签:

docker tag myimage myregistry.azurecr.io/testpath/myimage 

名称和目标标签都可以有版本(:<version name>)。

最后,使用以下命令将其推送到您的注册表中:

docker push myregistry.azurecr.io/testpath/myimage 

在这种情况下,您可以指定一个版本;否则,将推送最新版本。

通过执行以下命令,您可以使用以下命令从本地计算机中删除图像:

docker rmi myregistry.azurecr.io/testpath/myimage 

摘要

在本章中,我们描述了什么是微服务以及它们是如何从模块的概念演变而来的。然后,我们讨论了微服务的优势以及何时值得使用它们,以及它们的设计的一般标准。我们还解释了 Docker 容器是什么,并分析了容器与微服务架构之间的紧密联系。

然后,我们通过描述在.NET 中可用的所有工具来进行更实际的实现,以便我们可以实现基于微服务的架构。我们还描述了微服务所需的基础设施以及 Azure 集群如何提供 Azure Kubernetes 服务和 Azure Service Fabric。

下一章详细讨论了 Azure Service Fabric 编排器。

问题

  1. 模块概念的双重性质是什么?

  2. 缩放优化是微服务的唯一优势吗?如果不是,请列出一些其他优势。

  3. Polly 是什么?

  4. Visual Studio 提供了哪些对 Docker 的支持?

  5. 什么是编排器,Azure 上有哪些编排器可用?

  6. 为什么基于发布者/订阅者的通信在微服务中如此重要?

  7. 什么是 RabbitMQ?

  8. 为什么幂等消息如此重要?

进一步阅读

以下是 Azure Service Bus 和 RabbitMQ 两种事件总线技术的官方文档链接:

第六章:Azure Service Fabric

本章专门描述了 Azure Service Fabric,它是微软的一种主观的微服务编排器。它在 Azure 上可用,但 Service Fabric 软件也可以下载,这意味着用户可以使用它来定义自己的本地微服务集群。

虽然 Service Fabric 并不像 Kubernetes 那样广泛使用,但它具有更好的学习曲线,使您能够尝试微服务的基本概念,并在很短的时间内构建复杂的解决方案。此外,它提供了一个集成的部署环境,包括您实现完整应用所需的一切。更具体地说,它还提供了集成的通信协议和一种简单可靠的存储状态信息的方式。

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

  • Visual Studio 对 Azure Service Fabric 应用程序的支持

  • 如何定义和配置 Azure Service Fabric 集群

  • 如何通过“日志微服务”使用案例来实践编写可靠的服务及其通信

通过本章的学习,您将学会如何基于 Azure Service Fabric 实现一个完整的解决方案。

技术要求

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

为了避免安装问题,请确保您的 Windows 版本是最新的。此外,仿真器使用 PowerShell 高特权级命令,默认情况下被 PowerShell 阻止。要启用它们,您需要在 Visual Studio 包管理器控制台或任何 PowerShell 控制台中执行以下命令。为了使以下命令成功,必须以管理员身份启动 Visual Studio 或外部 PowerShell 控制台:

Set-ExecutionPolicy -ExecutionPolicy Unrestricted -Force -Scope CurrentUser 

Visual Studio 对 Azure Service Fabric 的支持

Visual Studio 具有针对微服务应用程序的特定项目模板,基于 Service Fabric 平台,您可以在其中定义各种微服务,配置它们,并将它们部署到 Azure Service Fabric,这是一个微服务编排器。Azure Service Fabric 将在下一节中详细介绍。

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

可以通过在“Visual Studio 项目类型下拉筛选器”中选择来找到 Service Fabric 应用程序:

图 6.1:选择 Service Fabric 应用程序

选择项目并选择项目和解决方案名称后,您可以选择多种服务:

图 6.2:服务选择

所有基于.NET Core 的项目都使用了针对 Azure Service Fabric 特定的微服务模型。Guest Executable 在现有的 Windows 应用程序周围添加了一个包装器,将其转换为可以在 Azure Service Fabric 中运行的微服务。Container 应用程序允许在 Service Fabric 应用程序中添加任何 Docker 镜像。所有其他选择都提供了一个模板,允许您使用 Service Fabric 特定的模式编写微服务。

如果您选择无状态服务并填写所有请求信息,Visual Studio 将创建两个项目:一个包含整个应用程序的配置信息的应用程序项目,以及一个包含您选择的特定服务的服务代码和特定服务配置的项目。如果您想向应用程序添加更多微服务,请右键单击应用程序项目,然后选择添加 | 新的 Service Fabric 服务

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

如果您选择Guest Executable,您需要提供以下内容:

  • 服务名称。

  • 一个包含主可执行文件的文件夹,以及为了正常工作而需要的所有文件。如果您想要在项目中创建此文件夹的副本,或者只是链接到现有文件夹,您需要这个。

  • 是否要添加一个链接到此文件夹,或者将所选文件夹复制到 Service Fabric 项目中。

  • 主可执行文件。

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

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

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

  • 服务名称。

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

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

  • 您可以访问服务的端口(主机端口)以及主机端口必须映射到的容器内部的端口(容器端口)。容器端口必须是在 Dockerfile 中公开并用于定义 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) 

而无状态服务则需要指定:

protected override IEnumerable< ServiceInstanceListener > CreateServiceInstanceListeners()
protected override async Task RunAsync(CancellationToken cancellationToken) 

CreateServiceReplicaListenersCreateServiceInstanceListeners方法指定了微服务用于接收消息和处理这些消息的代码的监听器列表。监听器可以使用任何协议,但它们需要指定相对套接字的实现。

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

ASP.NET Core 模板遵循相同的模式;但是,它们使用基于 ASP.NET Core 的唯一侦听器和没有RunAsync实现,因为可以从 ASP.NET Core 内部启动后台任务,其侦听器定义了一个完整的WebHost。但是,您可以将更多侦听器添加到由 Visual Studio 创建的CreateServiceReplicaListeners实现返回的侦听器数组中,还可以添加自定义的RunAsync覆盖。

值得指出的是,由于RunAsync是可选的,并且由于 ASP.NET Core 模板没有实现它,因此CreateServiceReplicaListenersCreateServiceInstanceListeners也是可选的,例如,基于计时器的后台工作程序不需要实现它们中的任何一个。

有关 Service Fabric 的本机服务模式的更多详细信息将在下一节中提供,而本章的用例-日志记录微服务部分将提供一个完整的代码示例,专门针对本书的用例。

定义和配置 Azure Service Fabric 集群

Azure Service Fabric 是主要的微软编排器,可以托管 Docker 容器、本地.NET 应用程序和一种名为可靠服务的分布式计算模型。我们已经在Visual Studio 支持 Azure Service Fabric部分中解释了如何创建包含这三种类型服务的应用程序。在本节中,我们将解释如何在 Azure 门户中创建 Azure Service Fabric 集群,并提供一些关于可靠服务的详细信息。有关可靠服务的更多实际细节将在用例-日志记录微服务部分中提供。

您可以通过在 Azure 搜索栏中输入Service Fabric并选择Service Fabric Cluster来进入 Azure 的 Service Fabric 部分。

显示了所有 Service Fabric 集群的摘要页面,对于您的情况,应该是空的。当您点击添加按钮创建第一个集群时,将显示一个多步骤向导。以下小节描述了可用的步骤。

第 1 步-基本信息

以下截图显示了 Azure Service Fabric 的创建过程:

图 6.3:Azure Service Fabric 创建

在这里,您可以选择操作系统、资源组、订阅、位置以及要用于连接远程桌面到所有集群节点的用户名和密码。

您需要选择一个集群名称,该名称将用于组成集群 URI,格式为<集群名称>.<位置>.cloudapp.azure.com,其中位置是您选择的数据中心位置的名称。由于 Service Fabric 主要是为 Windows 设计的,所以选择 Windows 是一个更好的选择。对于 Linux 机器来说,更好的选择是 Kubernetes,这将在下一章中介绍。

然后,您需要选择节点类型,即您想要为主节点使用的虚拟机类型,以及初始规模集,即要使用的虚拟机的最大数量。请选择一个廉价的节点类型,最多不超过三个节点,否则您可能很快就会耗尽所有的免费 Azure 信用额。

有关节点配置的更多详细信息将在下一小节中给出。

最后,您可以选择一个证书来保护节点之间的通信。让我们点击选择证书链接,在打开的窗口中选择自动创建新密钥保管库和新证书。有关安全性的更多信息将在第 3 步-安全配置部分中提供。

第 2 步-集群配置

在第二步中,您可以对集群节点类型和数量进行微调:

图 6.4:集群配置

更具体地说,在上一步中,我们选择了集群的主节点。在这里,我们可以选择是否添加各种类型的辅助节点及其规模容量。一旦您创建了不同的节点类型,您可以配置服务仅在其需求所需的能力足够的特定节点类型上运行。

让我们点击添加按钮来添加一个新的节点类型:

图 6.5:添加一个新的节点类型

不同节点类型的节点可以独立进行扩展,主节点类型是 Azure Service Fabric 运行时服务的托管位置。对于每个节点类型,您可以指定机器类型(耐久性层)、机器规格(CPU 和 RAM)和初始节点数。

您还可以指定所有从集群外部可见的端口(自定义端点)。

托管在集群的不同节点上的服务可以通过任何端口进行通信,因为它们是同一本地网络的一部分。因此,自定义端点必须声明需要接受来自集群外部的流量的端口。在自定义端点中公开的端口是集群的公共接口,可以通过集群 URI(即<cluster name>.<location>.cloudapp.azure.com)访问。它们的流量会自动重定向到由集群负载均衡器打开相同端口的所有微服务。

要理解启用反向代理选项,我们必须解释在服务的物理地址在其生命周期中发生变化时,如何将通信发送到多个实例。在集群内部,服务通过fabric://<application name>/<service name>这样的 URI 进行标识。也就是说,这个名称允许我们访问<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来访问此端点。默认情况下,DNS 服务是活动的,但您可以通过在辅助节点屏幕上点击配置高级设置来显示高级设置选择,或者转到高级选项卡来禁用它。

  • 反向代理: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。默认情况下,反向代理未启用。

由于我们将使用 Service Fabric 可靠服务与 Service Fabric 内置通信设施,并且由于这些内置通信设施不需要反向代理或 DNS,请避免更改这些设置。

此外,如果您只是为了在本章末尾的简单示例中进行实验而创建 Service Fabric 集群,请仅使用主节点,并避免通过创建辅助节点来浪费您的免费 Azure 信用。

第 3 步-安全配置

完成第二步后,我们来到一个安全页面:

图 6.6:安全页面

在第一步中,我们已经定义了主要的证书。在这里,您可以选择一个次要的证书,在主要证书接近到期时使用。您还可以添加一个证书,用于在反向代理上启用 HTTPS 通信。由于在我们的示例中,我们不使用 Docker 化服务(因此不需要反向代理),所以我们不需要这个选项。

在这一点上,我们可以点击“审查和创建”按钮来创建集群。提交您的批准将创建集群。请注意:一个集群可能会在短时间内消耗您的免费 Azure 信用,所以在测试时请保持您的集群开启。之后,您应该删除它。

我们需要将主要证书下载到开发机器上,因为我们需要它来部署我们的应用程序。一旦证书下载完成,只需双击它即可将其安装在我们的机器上。在部署应用程序之前,您需要将以下信息插入到 Visual Studio Service Fabric 应用程序的Cloud Publish Profile中(有关更多详细信息,请参见本章的用例-日志记录微服务部分):

<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 密钥保管库中复制。值得一提的是,您还可以通过在第 3 步中选择相应的选项来将特定于客户端的证书添加到主服务器证书中。

正如我们在Visual Studio 对 Azure Service Fabric 的支持小节中提到的,Azure Service Fabric 支持两种类型的可靠服务:无状态和有状态。无状态服务要么不存储永久数据,要么将其存储在外部支持中,例如 Redis 缓存或数据库(有关 Azure 提供的主要存储选项,请参见第九章如何选择云中的数据存储)。

另一方面,有状态服务使用 Service Fabric 特定的分布式字典和队列。每个分布式数据结构可以从服务的所有相同副本中访问,但只允许一个副本,称为主副本,在其上进行写操作,以避免对这些分布式资源的同步访问,这可能会导致瓶颈。

所有其他副本,即辅助副本,只能从这些分布式数据结构中读取。

您可以通过查看您的代码从 Azure Service Fabric 运行时接收到的上下文对象来检查副本是否为主副本,但通常情况下,您不需要这样做。实际上,当您声明服务端点时,您需要声明那些只读的端点。只读端点应该接收请求,以便它可以从共享数据结构中读取数据。因此,由于只有只读端点被激活用于辅助副本,如果您正确实现了它们,写/更新操作应该自动在有状态辅助副本上被阻止,无需进行进一步的检查。

在有状态服务中,辅助副本可以在读操作上实现并行处理,因此为了在写/更新操作上实现并行处理,有状态服务被分配了不同的数据分区。具体来说,对于每个有状态服务,Service Fabric 会为每个分区创建一个主实例。然后,每个分区可能有多个辅助副本。

分布式数据结构在每个分区的主实例和其辅助副本之间共享。可以根据对要存储的数据进行哈希算法生成的分区键将有状态服务中可以存储的全部数据范围划分为所选的分区数。

通常,分区键是属于给定间隔的整数,该间隔在所有可用分区之间进行划分。例如,可以通过调用一个众所周知的哈希算法对一个或多个字符串字段进行哈希运算来生成分区键,以获得然后处理为唯一整数的整数(例如,对整数位进行异或运算)。然后,可以通过取整数除法的余数来限制该整数在选择的分区键的整数间隔中(例如,除以 1,000 的余数将是 0-999 间隔中的整数)。确保所有服务使用完全相同的哈希算法非常重要,因此更好的解决方案是为所有服务提供一个公共的哈希库。

假设我们想要四个分区,这些分区将在 0-999 的整数键中进行选择。在这里,Service Fabric 将自动创建我们有状态服务的四个主实例,并将它们分配给以下四个分区键子区间:0-249,250-499,500-749 和 750-999。

在代码中,您需要计算发送到有状态服务的数据的分区键。然后,Service Fabric 的运行时将为您选择正确的主实例。下面的部分将提供更多关于此的实际细节以及如何在实践中使用可靠服务。

用例 - 日志微服务

在本节中,我们将看一个基于微服务的系统,该系统记录与我们的 WWTravelClub 用例中的各个目的地相关的购买数据。特别是,我们将设计微服务来计算每个位置的每日收入。在这里,我们假设这些微服务从同一 Azure Service Fabric 应用程序中托管的其他子系统接收数据。具体来说,每个购买日志消息由位置名称、总体套餐费用以及购买日期和时间组成。

首先,让我们确保我们在本章技术要求部分提到的 Service Fabric 模拟器已经安装并在您的开发机器上运行。现在,我们需要将其切换,以便它运行5 个节点:右键单击您在 Windows 通知区域中拥有的小 Service Fabric 集群图标,在打开的上下文菜单中,选择切换集群模式 -> 5 个节点

现在,我们可以按照Visual Studio 对 Azure Service Fabric 的支持部分中列出的步骤来创建一个名为PurchaseLogging的 Service Fabric 项目。选择一个.NET Core 有状态可靠服务,并将其命名为LogStore

由 Visual Studio 创建的解决方案由一个代表整体应用程序的PurchaseLogging项目和一个包含在PurchaseLogging应用程序中的第一个微服务的实现的LogStore项目组成。

PackageRoot文件夹下,LogStore服务和每个可靠服务都包含ServiceManifest.xml配置文件和一个Settings.xml文件夹(在Config子文件夹下)。Settings.xml文件夹包含一些将从服务代码中读取的设置。初始文件包含了 Service Fabric 运行时所需的预定义设置。让我们添加一个新的Settings部分,如下面的代码所示:

<?xml version="1.0" encoding="utf-8" ?>
<Settings xmlns:xsd="http://www.w3.org/2001/XMLSchema" 
          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
          xmlns="http://schemas.microsoft.com/2011/01/fabric">
<!-- 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 protcol 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 自动创建。让我们用以下代码片段中的值替换 Visual Studio 建议的这些初始值:

<Parameter Name="LogStore_MinReplicaSetSize" DefaultValue="1" />
<Parameter Name="LogStore_PartitionCount" DefaultValue="2" />
<Parameter Name="LogStore_TargetReplicaSetSize" DefaultValue="1" /> 

我们将使用两个分区来展示分区是如何工作的,但您可以增加此值以提高写入/更新并行性。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包含在各种部署环境(即实际的 Azure Service Fabric 集群和具有一个或五个节点的本地仿真器)中为ApplicationManifest.xml中定义的参数提供可能的覆盖。

  • PublishProfiles包含发布应用程序所需的设置,这些设置与ApplicationParameters文件夹处理的相同环境相关。您只需要使用实际的 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" /> 

需要遵循的其余步骤已经组织成几个子部分。让我们首先看看如何确保消息的幂等性。

确保消息的幂等性

由于故障或负载平衡引起的小超时,消息可能会丢失。在这里,我们将使用预定义的基于远程通信的通信,以在发生故障时执行自动消息重试。但是,这可能导致相同的消息被接收两次。由于我们正在对采购订单的收入进行汇总,因此必须防止多次对同一采购进行汇总。

为此,我们将实现一个包含必要工具的库,以确保消息副本被丢弃。

让我们向解决方案添加一个名为IdempotencyTools的新的.NET Standard 2.0 库项目。现在,我们可以删除 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)
    {
        return new IdempotencyFilter()
            {
                dictionary = await
                sm.GetOrAddAsync<IReliableDictionary<Guid,
                DateTimeOffset>>(name),
                maxDelaySeconds = maxDelaySeconds,
                lastClear = DateTimeOffset.UtcNow,
                sm = sm,
            };
}
...
... 

字典包含每条消息的时间标记,由消息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 (var tx = this.sm.CreateTransaction())
    {
        ...
        ...
    }
 } 

首先,该方法验证是否是清除字典的时间以及消息是否太旧。然后,它启动事务以访问字典。所有分布式字典操作都必须包含在事务中,如下面的代码所示:

using (ITransaction tx = this.sm.CreateTransaction())
{
    if (await dictionary.TryAddAsync(tx, message.Id, message.Time))
    {
         await tx.CommitAsync();
         return message.Value;
    }
    else
    {
         return default;
    }
} 

如果在字典中找到消息Guid,则事务将被中止,因为不需要更新字典,并且该方法返回default(T),实际上是null,因为不必处理消息。否则,将消息条目添加到字典中,并返回未包装的消息。

Clear方法的代码可以在与本书关联的 GitHub 存储库中找到。

交互库

有一些类型必须在所有微服务之间共享。如果内部通信是使用远程调用或 WCF 实现的,每个微服务必须公开一个接口,其中包含其他微服务调用的所有方法。这些接口必须在所有微服务之间共享。此外,对于所有通信接口,实现消息的类也必须在所有微服务之间共享(或在它们的一些子集之间共享)。因此,所有这些结构都在外部库中声明,并由微服务引用。

现在,让我们向我们的解决方案添加一个名为Interactions的新的.NET Standard 2.0 库项目。由于此库必须使用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;
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中,以避免调用者保持阻塞,等待消息处理完成。通过这种方式,我们既实现了同步消息传递协议的可靠性(调用者知道消息已被接收),又实现了异步消息处理的性能优势,这是异步通信的典型特点。

作为所有分布式集合的最佳实践,LoqQueueRunAsync方法中创建,因此如果第一个调用在 Azure Service Fabric 运行时调用RunAsync之前到达,则LogQueue可能为空。在这种情况下,该方法返回false以表示服务尚未准备好,发送方将稍等一会然后重新发送消息。否则,将创建事务以将新消息加入队列。

然而,如果我们不提供一个返回服务想要激活的所有监听器的CreateServiceReplicaListeners()的实现,我们的服务将不会接收任何通信。在远程通信的情况下,有一个预定义的方法来执行整个工作,所以我们只需要调用它:

protected override IEnumerable<ServiceReplicaListener>
    CreateServiceReplicaListeners()
{
    return this.CreateServiceRemotingReplicaListeners<LogStore>();
} 

在这里,CreateServiceRemotingReplicaListeners是在远程通信库中定义的扩展方法。它为主副本和辅助副本(用于只读操作)创建监听器。在创建客户端时,我们可以指定它的通信是针对主副本还是辅助副本。

如果您想使用不同的监听器,您必须创建ServiceReplicaListener实例的IEnumerable。对于每个监听器,您必须使用三个参数调用ServiceReplicaListener构造函数:

  • 一个接收可靠服务上下文对象作为输入并返回ICommunicationListener接口实现的函数。

  • 监听器的名称。当服务有多个监听器时,这第二个参数就变得必须。

  • 一个布尔值,如果监听器必须在辅助副本上激活,则为 true。

例如,如果我们想要添加自定义和 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的实现也必须有一个Close方法,它必须关闭已打开的通信通道,以及一个Abort方法,它必须立即关闭通信通道(不优雅地,也就是说,不通知连接的客户端等)。

现在我们已经打开了通信,我们可以实现服务逻辑。

实现服务逻辑

服务逻辑由在RunAsync被 Service Fabric 运行时启动的独立线程执行。当您只需要实现一个任务时,创建IHost并将所有任务设计为IHostedService实现是一个好的做法。事实上,IHostedService实现是独立的软件块,更容易进行单元测试。IHostIHostedService使用通用主机第五章将微服务架构应用于企业应用程序的子章节中有详细讨论。

在本节中,我们将实现计算每个位置的日收入的逻辑,这个逻辑位于名为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 = normalizedTime.Date;           
           ... 
           ...
        } 

如果是新的一天,我们假设前一天的运行总数计算已经完成,所以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;
} 

ComputeStatisticsIHostedService实现需要一些参数才能正常工作,如下所示:

  • 包含所有传入消息的队列

  • IReliableStateManager服务,这样它就可以创建分布式字典来存储数据

  • ConfigurationPackage服务,以便它可以读取在Settings.xml服务文件中定义的设置,以及可能在应用程序清单中被覆盖的设置

在通过依赖注入由IHost创建ComputeStatistics实例时,必须将前面的参数传递给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.NewIdempotencyFilterAsync(
        "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 时,必须更改其版本号,因为版本号更改告诉 Service Fabric 运行时在群集中要更改什么。版本号还出现在其他配置设置中。每当它们所引用的实体发生更改时,必须更改它们。

MessageMaxDelaySeconds与已接收消息的字典的名称以及IReliableStateManager服务的实例一起传递给幂等性过滤器的实例。最后,创建用于存储累计总数的主分布式字典。

之后,服务进入循环,并在stoppingToken被标记时结束,即当 Service Fabric 运行时发出信号表示服务将被停止时:

while (!stoppingToken.IsCancellationRequested)
    {
        while (!queueEmpty && !stoppingToken.IsCancellationRequested)
        {
            RunningTotal total = null;
            using (ITransaction tx = stateManager.CreateTransaction())
            {
                ...
                ... 
                ...
            }
        }
        await Task.Delay(100, stoppingToken);
        queueEmpty = false;
    }
} 

内部循环运行直到队列变为空,然后退出并等待 100 毫秒,然后验证是否有新的消息被入队。

以下是封装在事务中的内部循环的代码:

RunningTotal finalDayTotal = 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();
    finalDayTotal = 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(finalDayTotal != null)
{
    await SendTotal(finalDayTotal, 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 端点公开所有统计信息的服务。在阅读了专门介绍 Web API 的第十四章《使用.NET Core 应用服务导向架构》之后,您可能希望使用连接到数据库的无状态 ASP.NET Core 微服务实现类似的服务。无状态 ASP.NET Core 服务模板会自动为您创建一个基于 ASP.NET Core 的 HTTP 端点。

然而,由于该服务必须从SendTotal方法接收数据,它还需要基于远程的端点。因此,我们必须创建它们,就像我们为LogStore微服务所做的那样,并将基于远程的端点数组与包含 HTTP 端点的预先存在的数组连接起来。

定义微服务的主机

现在我们已经准备好定义微服务的RunAsync方法了:

protected override async Task RunAsync(CancellationToken cancellationToken)
{
    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服务发送随机数据。右键单击Solution Explorer中的PurchaseLogging项目,然后选择Add | Service Fabric Service。然后,选择.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.UtcNow,
            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 区间内的一个密钥。我们使用GetHashCode,因为我们确信所有涉及的服务都使用相同的.NET Core 版本,因此我们确信它们使用相同的GetHashCode实现,以完全相同的方式计算哈希。然而,一般来说,最好提供一个具有标准哈希码实现的库。

这个整数被传递给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).Address; 

此外,在通用通信协议的情况下,我们必须使用 Polly 这样的库手动处理故障和重试(有关更多信息,请参见第五章将微服务架构应用于企业应用程序具有弹性的任务执行子部分)。

测试应用程序

为了测试应用程序,您需要以管理员权限启动 Visual Studio。因此,关闭 Visual Studio,然后右键单击 Visual Studio 图标,并选择以管理员身份启动的选项。一旦您再次进入 Visual Studio,加载PurchaseLogging解决方案,并在ComputeStatistics.cs文件中设置断点:

total = newCounter.Update(item.Time, item.Cost);
if (counter.HasValue)...//put breakpoint on this line 

每次断点被触发时,查看newCounter的内容,以验证所有位置的运行总数如何变化。在调试模式下启动应用程序之前,请确保本地集群正在运行五个节点。如果您从一个节点更改为五个节点,则本地集群菜单会变灰,直到操作完成,请等待菜单恢复正常。

一旦启动应用程序并构建应用程序,控制台将出现,并且您将开始在 Visual Studio 中接收操作完成的通知。应用程序需要一些时间在所有节点上加载;之后,您的断点应该开始被触发。

摘要

在本章中,我们描述了如何在 Visual Studio 中定义 Service Fabric 解决方案,以及如何在 Azure 中设置和配置 Service Fabric 集群。

我们描述了 Service Fabric 的构建模块、可靠服务、各种类型的可靠服务以及它们在 Service Fabric 应用程序中的角色。

最后,我们通过实现 Service Fabric 应用程序将这些概念付诸实践。在这里,我们提供了关于每个可靠服务架构的更多实际细节,以及如何组织和编写它们的通信。

下一章描述了另一个著名的微服务编排器 Kubernetes 及其在 Azure Cloud 中的实现。

问题

  1. 什么是可靠服务?

  2. 您能列出可靠服务的不同类型及其在 Service Fabric 应用程序中的角色吗?

  3. 什么是ConfigureServices

  4. 在定义 Azure Service Fabric 集群时必须声明哪些端口类型?

  5. 为什么需要可靠有状态服务的分区?

  6. 我们如何声明远程通信必须由辅助副本处理?其他类型的通信呢?

进一步阅读

微软还实现了一个独立于 Service Fabric 的高级 Actor 模型,称为 Orleans 框架。有关 Orleans 的更多信息可以在以下链接找到:

第七章:Azure Kubernetes Service

本章致力于描述 Kubernetes 微服务编排器,特别是在 Azure 中的实现,名为 Azure Kubernetes Service。该章节解释了基本的 Kubernetes 概念,然后重点介绍了如何与 Kubernetes 集群进行交互,以及如何部署 Azure Kubernetes 应用程序。所有概念都通过简单的示例进行了实践。我们建议在阅读本章之前先阅读第五章将微服务架构应用于企业应用程序第六章Azure Service Fabric,因为它依赖于这些先前章节中解释的概念。

更具体地说,在本章中,您将学习以下主题:

  • Kubernetes 基础

  • 与 Azure Kubernetes 集群交互

  • 高级 Kubernetes 概念

通过本章结束时,您将学会如何实现和部署基于 Azure Kubernetes 的完整解决方案。

技术要求

  • Visual Studio 2019 免费的 Community Edition 或更高版本,安装了所有数据库工具,或者任何其他.yaml文件编辑器,如 Visual Studio Code。

  • 免费的 Azure 账户。第一章创建 Azure 账户部分解释了如何创建一个。

本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5找到。

Kubernetes 基础

Kubernetes 是一个先进的开源编排器,您可以在私人机器集群上本地安装。在撰写本文时,它是最广泛使用的编排器,因此微软也将其作为 Azure Service Fabric 的更好替代品,因为它目前是事实上的标准,并且可以依赖于广泛的工具和应用程序生态系统。本节介绍了基本的 Kubernetes 概念和实体。

Kubernetes 集群是运行 Kubernetes 编排器的虚拟机集群。与 Azure Service Fabric 一样,组成集群的虚拟机被称为节点。我们可以在 Kubernetes 上部署的最小软件单元不是单个应用程序,而是一组容器化的应用程序,称为 pod。虽然 Kubernetes 支持各种类型的容器,但最常用的容器类型是 Docker,我们在第五章将微服务架构应用于企业应用程序中进行了分析,因此我们将把讨论限制在 Docker 上。

pod很重要,因为属于同一 pod 的应用程序确保在同一节点上运行。这意味着它们可以通过本地主机端口轻松通信。然而,不同 pod 之间的通信更复杂,因为 pod 的 IP 地址是临时资源,因为 pod 没有固定的节点在其上运行,而是由编排器从一个节点移动到另一个节点。此外,为了提高性能,pod 可能会被复制,因此,通常情况下,将消息发送到特定 pod 是没有意义的,而只需发送到同一 pod 的任何相同副本之一即可。

在 Azure Service Fabric 中,基础设施会自动为相同副本组分配虚拟网络地址,而在 Kubernetes 中,我们需要定义显式资源,称为服务,这些服务由 Kubernetes 基础设施分配虚拟地址,并将其通信转发到相同 pod 的集合。简而言之,服务是 Kubernetes 分配常量虚拟地址给 pod 副本集的方式。

所有 Kubernetes 实体都可以分配名称值对,称为标签,用于通过模式匹配机制引用它们。更具体地说,选择器通过列出它们必须具有的标签来选择 Kubernetes 实体。

因此,例如,所有从同一服务接收流量的 pod 都是通过在服务定义中指定的标签来选择的。

服务将其流量路由到所有连接的 Pod 的方式取决于 Pod 的组织方式。无状态的 Pod 被组织在所谓的ReplicaSets中,它们类似于 Azure Service Fabric 服务的无状态副本。与 Azure Service Fabric 无状态服务一样,ReplicaSets分配给整个组的唯一虚拟地址,并且流量在组中的所有 Pod 之间均匀分配。

有状态的 Kubernetes Pod 副本被组织成所谓的StatefulSets。与 Azure Service Fabric 有状态服务类似,StatefulSets使用分片将流量分配给它们的所有 Pod。因此,Kubernetes 服务为它们连接的StatefulSet的每个 Pod 分配一个不同的名称。这些名称看起来像这样:basename-0.<base URL>basename-1.<base URL>,...,basename-n.<base URL>。这样,消息分片可以轻松地完成如下:

  1. 每次需要将消息发送到由N个副本组成的StatefulSet时,计算 0 到N-1 之间的哈希值,例如x

  2. 将后缀x添加到基本名称以获取集群地址,例如basename-x.<base URL>

  3. 将消息发送到basename-x.<base URL>集群地址。

Kubernetes 没有预定义的存储设施,也不能使用节点磁盘存储,因为 Pod 会在可用节点之间移动,因此必须使用分片的云数据库或其他类型的云存储来提供长期存储。虽然StatefulSet的每个 Pod 可以使用常规的连接字符串技术访问分片的云数据库,但 Kubernetes 提供了一种技术来抽象外部 Kubernetes 集群环境提供的类似磁盘的云存储。我们将在高级 Kubernetes 概念部分中描述这些内容。

在这个简短的介绍中提到的所有 Kubernetes 实体都可以在.yaml文件中定义,一旦部署到 Kubernetes 集群中,就会创建文件中定义的所有实体。接下来的子节描述了.yaml文件,而随后的其他子节详细描述了到目前为止提到的所有基本 Kubernetes 对象,并解释了如何在.yaml文件中定义它们。在整个章节中将描述更多的 Kubernetes 对象。

.yaml 文件

.yaml文件与 JSON 文件一样,是一种以人类可读的方式描述嵌套对象和集合的方法,但它们使用不同的语法。你有对象和列表,但对象属性不用{}括起来,列表也不用[]括起来。相反,嵌套对象通过简单地缩进其内容来声明。可以自由选择缩进的空格数,但一旦选择了,就必须一致使用。

列表项可以通过在前面加上连字符(-)来与对象属性区分开。

以下是涉及嵌套对象和集合的示例:

Name: Jhon
Surname: Smith
Spouse: 
  Name: Mary
  Surname: Smith
Addresses:
- Type: home
  Country: England
  Town: London
  Street: My home street
- Type: office
  Country: England
  Town: London
  Street: My home street 

前面的Person对象有一个嵌套的Spouse对象和一个嵌套的地址集合。

.yaml文件可以包含多个部分,每个部分定义一个不同的实体,它们由包含---字符串的行分隔。注释以#符号开头,在每行注释前必须重复该符号。

每个部分都以声明 Kubernetes API 组和版本开始。实际上,并不是所有对象都属于同一个 API 组。对于属于core API 组的对象,我们可以只指定 API 版本,如下面的示例所示:

apiVersion: v1 

虽然属于不同 API 组的对象也必须指定 API 名称,如下面的示例所示:

apiVersion: apps/v1 

在下一个子节中,我们将详细分析构建在其上的ReplicaSetsDeployments

ReplicaSets 和 Deployments

Kubernetes 应用程序的最重要的构建块是ReplicaSet,即一个被复制n次的 Pod。然而,通常情况下,您会采用一个更复杂的对象,该对象建立在ReplicaSet之上 - DeploymentDeployments不仅创建ReplicaSet,还监视它们以确保副本的数量保持恒定,独立于硬件故障和可能涉及ReplicaSets的其他事件。换句话说,它们是一种声明性的定义ReplicaSets和 Pod 的方式。

每个Deployment都有一个名称(metadata->name),一个指定所需副本数量的属性(spec->replicas),一个键值对(spec->selector->matchLabels)用于选择要监视的 Pod,以及一个模板(spec->template),用于指定如何构建 Pod 副本:

apiVersion: apps/v1
kind: Deployment
metadata: 
  name: my-deployment-name
  namespace: my-namespace #this is optional
spec: 
   replicas: 3
   selector: 
     matchLabels: 
       my-pod-label-name: my-pod-label-value
         ...
   template:
      ... 

namespace是可选的,如果未提供,则假定为名为default的命名空间。命名空间是保持 Kubernetes 集群中对象分开的一种方式。例如,一个集群可以托管两个完全独立的应用程序的对象,每个应用程序都放在一个单独的namespace中。

缩进在模板内部是要复制的 Pod 的定义。复杂的对象(如Deployments)还可以包含其他类型的模板,例如外部环境所需的类似磁盘的内存的模板。我们将在“高级 Kubernetes 概念”部分进一步讨论这个问题。

反过来,Pod 模板包含一个metadata部分,其中包含用于选择 Pod 的标签,以及一个spec部分,其中包含所有容器的列表:

metadata: 
  labels: 
    my-pod-label-name: my-pod-label-value
      ...
spec: 
  containers:
   ...
  - name: my-container-name
    image: <Docker imagename>
    resources: 
      requests: 
        cpu: 100m 
        memory: 128Mi 
      limits: 
        cpu: 250m 
        memory: 256Mi 
    ports: 
    - containerPort: 6379
    env: 
    - name: env-name
      value: env-value
       ... 

每个容器都有一个名称,并且必须指定用于创建容器的 Docker 镜像的名称。如果 Docker 镜像不包含在公共 Docker 注册表中,则名称必须是包含存储库位置的 URI。

然后,容器必须指定它们需要创建在resources->requests对象中的内存和 CPU 资源。只有在当前可用这些资源的情况下才会创建 Pod 副本。相反,resources->limits对象指定容器副本实际可以使用的最大资源。如果在容器执行过程中超过了这些限制,将采取措施限制它们。具体来说,如果超过了 CPU 限制,容器将被限制(其执行将停止以恢复其 CPU 消耗),而如果超过了内存限制,容器将被重新启动。containerPort必须是容器暴露的端口。在这里,我们还可以指定其他信息,例如使用的协议。

CPU 时间以毫核表示,其中 1,000 毫核表示 100%的 CPU 时间,而内存以 Mebibytes(1Mi = 10241024 字节*)或其他单位表示。env列出了要传递给容器的所有环境变量及其值。

容器和 Pod 模板都可以包含进一步的字段,例如定义虚拟文件的属性和定义返回容器就绪状态和健康状态的命令的属性。我们将在“高级 Kubernetes 概念”部分中分析这些内容。

下面的子部分描述了用于存储状态信息的 Pod 集。

有状态集

StatefulSetsReplicaSet非常相似,但是ReplicaSet的 Pod 是不可区分的处理器,通过负载均衡策略并行地为相同的工作贡献,而StatefulSet中的 Pod 具有唯一的标识,并且只能通过分片方式共享相同的工作负载。这是因为StatefulSets被设计用于存储信息,而信息无法并行存储,只能通过分片的方式在多个存储之间分割。

出于同样的原因,每个 Pod 实例始终与其所需的任何虚拟磁盘空间绑定在一起(参见“高级 Kubernetes 概念”部分),因此每个 Pod 实例负责向特定存储写入。

此外,StatefulSets的 pod 实例附带有序号。它们按照这些序号顺序启动,并按相反的顺序停止。如果StatefulSet包含N个副本,这些序号从零到N-1。此外,通过将模板中指定的 pod 名称与实例序号链接起来,可以获得每个实例的唯一名称,方式如下 - <pod 名称>-<实例序号>。因此,实例名称将类似于mypodname-0mypodname-1等。正如我们将在服务子部分中看到的那样,实例名称用于为所有实例构建唯一的集群网络 URI,以便其他 pod 可以与StatefulSets的特定实例通信。

以下是典型的StatefulSet定义:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: my-stateful-set-name
spec:
  selector:
    matchLabels:
      my-pod-label-name: my-pod-label-value
...
  serviceName: "my-service-name"
  replicas: 3 
  template:
    ... 

模板部分与Deployments相同。与Deployments的主要概念上的区别是serviceName字段。它指定必须与StatefulSets连接以为所有 pod 实例提供唯一网络地址的服务的名称。我们将在服务子部分中详细讨论这个主题。此外,通常,StatefulSets使用某种形式的存储。我们将在高级 Kubernetes 概念部分详细讨论这个问题。

值得指出的是,StatefulSets的默认有序创建和停止策略可以通过为spec->podManagementPolicy属性指定显式的Parallel值来更改(默认值为OrderedReady)。

下面的子部分描述了如何为ReplicaSetsStatefulSets提供稳定的网络地址。

服务

由于 pod 实例可以在节点之间移动,因此它们没有与之关联的稳定 IP 地址。服务负责为整个ReplicaSet分配一个唯一且稳定的虚拟地址,并将流量负载均衡到所有与之连接的实例。服务不是在集群中创建的软件对象,只是为实施其功能所需的各种设置和活动的抽象。

服务在协议栈的第 4 级工作,因此它们理解诸如 TCP 之类的协议,但它们无法执行例如 HTTP 特定的操作/转换,例如确保安全的 HTTPS 连接。因此,如果您需要在 Kubernetes 集群上安装 HTTPS 证书,您需要一个能够在协议栈的第 7 级进行交互的更复杂的对象。Ingress对象就是为此而设计的。我们将在下一个子部分中讨论这个问题。

服务还负责为StatefulSet的每个实例分配一个唯一的虚拟地址。实际上,有各种类型的服务;一些是为ReplicaSet设计的,另一些是为StatefulSet设计的。

ClusterIP服务类型被分配一个唯一的集群内部 IP 地址。它通过标签模式匹配指定与之连接的ReplicaSetsDeployments。它使用由 Kubernetes 基础设施维护的表来将接收到的流量在所有与之连接的 pod 实例之间进行负载均衡。

因此,其他 pod 可以通过与分配了稳定网络名称<service 名称>.<service 命名空间>.svc.cluster.local的服务进行交互,与连接到该服务的 pod 进行通信。由于它们只分配了本地 IP 地址,因此无法从 Kubernetes 集群外部访问ClusterIP服务。以下是典型ClusterIP服务的定义:

apiVersion: v1
kind: Service
metadata:
  name: my-service
  namespace: my-namespace
spec:
  selector:
    my-selector-label: my-selector-value
    ...
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 9376
    - name: https
      protocol: TCP
      port: 443
      targetPort: 9377 

每个服务可以在多个端口上工作,并且可以将任何端口(port)路由到容器公开的端口(targetPort)。但是,很常见的情况是port = targetPort。端口可以有名称,但这些名称是可选的。此外,协议的规范是可选的,如果不指定,则允许所有支持的第 4 级协议。spec->selector属性指定选择服务要将其接收到的通信路由到的所有名称/值对。

由于无法从 Kubernetes 集群外部访问ClusterIP服务,我们需要其他类型的服务来将 Kubernetes 应用程序暴露在公共 IP 地址上。

NodePort类型的服务是将 pod 暴露给外部世界的最简单方式。为了实现NodePort服务,在 Kubernetes 集群的所有节点上都打开相同的端口x,并且每个节点将其接收到的流量路由到一个新创建的ClusterIP服务。

反过来,ClusterIP服务将其流量路由到服务选择的所有 pod:

图 7.1:NodePort 服务

因此,只需通过任何集群节点的公共 IP 与端口x通信,就可以访问与NodePort服务连接的 pod。当然,整个过程对开发人员来说是完全自动和隐藏的,他们唯一需要关注的是获取端口号x以确定外部流量的转发位置。

NodePort服务的定义与ClusterIP服务的定义相同,唯一的区别是它们将spec->type属性的值设置为NodePort

...
spec:
  type: NodePort
  selector:
  ... 

默认情况下,每个Service指定的targetPort都会自动选择 30000-327673 范围内的节点端口x。对于NodePortServices来说,与每个targetPort关联的端口属性是无意义的,因为所有流量都通过所选的节点端口x传递,并且按照惯例,设置为与targetPort相同的值。开发人员还可以通过nodePort属性直接设置节点端口x

...
ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30007
    - name: https
      protocol: TCP
      port: 443
      targetPort: 443
      nodePort: 30020
... 

当 Kubernetes 集群托管在云中时,将一些 pod 暴露给外部世界的更方便的方式是通过LoadBalancer服务,此时 Kubernetes 集群通过所选云提供商的第四层负载均衡器暴露给外部世界。

LoadBalancer服务的定义与ClusterIp服务相同,唯一的区别是spec->type属性必须设置为LoadBalancer

...
spec:
  type: LoadBalancer
  selector:
  ... 

如果没有添加进一步的规范,动态公共 IP 将被随机分配。然而,如果需要特定的公共 IP 地址给云提供商,可以通过在spec->loadBalancerIP属性中指定它来用作集群负载均衡器的公共 IP 地址:

...
spec:
  type: LoadBalancer
  loadBalancerIP: <your public ip>
  selector:
  ... 

在 Azure Kubernetes 中,您还必须在注释中指定分配 IP 地址的资源组:

apiVersion: v1
kind: Service
metadata:
  annotations:
    service.beta.kubernetes.io/azure-load-balancer-resource-group: <IP resource group name>
  name: my-service name
... 

在 Azure Kubernetes 中,您可以保留动态 IP 地址,但可以获得类型为<my-service-label>.<location>.cloudapp.azure.com的公共静态域名,其中<location>是您为资源选择的地理标签。<my-service-label>是一个您验证过使前面的域名唯一的标签。所选标签必须在您的服务的注释中声明,如下所示:

apiVersion: v1
kind: Service
metadata:
  annotations:
service.beta.kubernetes.io/azure-dns-label-name: <my-service-label>
  name: my-service-name
... 

StatefulSets不需要任何负载均衡,因为每个 pod 实例都有自己的标识,只需要为每个 pod 实例提供一个唯一的 URL 地址。这个唯一的 URL 由所谓的无头服务提供。无头服务的定义与ClusterIP服务相同,唯一的区别是它们的spec->clusterIP属性设置为none

...
spec:
clusterIP: none
  selector:
... 

所有由无头服务处理的StatefulSets必须将服务名称放置在其spec->serviceName属性中,如StatefulSets子部分中所述。

无头服务为其处理的所有StatefulSets pod 实例提供的唯一名称是<unique pod name>.<service name>.<namespace>.svc.cluster.local

服务只能理解低级协议,如 TCP/IP,但大多数 Web 应用程序位于更复杂的 HTTP 协议上。这就是为什么 Kubernetes 提供了基于服务的更高级实体Ingresses。下一小节描述了这些内容,并解释了如何通过级别 7 协议负载均衡器将一组pods公开,该负载均衡器可以为您提供典型的 HTTP 服务,而不是通过LoadBalancer服务。

Ingresses

Ingresses主要用于使用 HTTP(S)。它们提供以下服务:

  • HTTPS 终止。它们接受 HTTPS 连接并将其路由到云中的任何服务的 HTTP 格式。

  • 基于名称的虚拟主机。它们将多个域名与同一个 IP 地址关联,并将每个域或<domain>/<path prefix>路由到不同的集群服务。

  • 负载均衡。

Ingresses依赖于 Web 服务器来提供上述服务。实际上,只有在安装了Ingress Controller之后才能使用IngressesIngress Controllers是必须安装在集群中的自定义 Kubernetes 对象。它们处理 Kubernetes 与 Web 服务器之间的接口,可以是外部 Web 服务器或作为Ingress Controller安装的 Web 服务器的一部分。

我们将在高级 Kubernetes 概念部分中描述基于 NGINX Web 服务器的Ingress Controller的安装,作为使用 Helm 的示例。 进一步阅读部分包含有关如何安装与外部 Azure 应用程序网关进行接口的Ingress Controller的信息。

HTTPS 终止和基于名称的虚拟主机可以在Ingress定义中进行配置,与所选择的Ingress Controller无关,而负载均衡的实现方式取决于所选择的特定Ingress Controller及其配置。一些Ingress Controller配置数据可以通过Ingress定义的metadata->annotations字段传递。

基于名称的虚拟主机在 Ingress 定义的spec>rules部分中定义:

...
spec:
...
  rules:
  - host: *.mydomain.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-service-name
            port:
              number: 80
  - host: my-subdomain.anotherdomain.com
... 

每个规则都指定了一个可选的主机名,可以包含*通配符。如果没有提供主机名,则规则匹配所有主机名。对于每个规则,我们可以指定多个路径,每个路径重定向到不同的服务/端口对,其中服务通过其名称引用。与每个path的匹配方式取决于pathType的值;如果该值为Prefix,则指定的path必须是任何匹配路径的前缀。否则,如果该值为Exact,则匹配必须完全相同。匹配区分大小写。

通过将特定主机名与在 Kubernetes 密钥中编码的证书关联,可以指定特定主机名上的 HTTPS 终止:

...
spec:
...
  tls:
  - hosts:
      - www.mydomain.com
      secretName: my-certificate1
      - my-subdomain.anotherdomain.com
      secretName: my-certificate2
... 

可以免费获取 HTTPS 证书,网址为letsencrypt.org/。该过程在网站上有详细说明,但基本上,与所有证书颁发机构一样,您提供一个密钥,他们根据该密钥返回证书。还可以安装一个证书管理器,它负责自动安装和更新证书。在 Kubernetes 密钥/证书对如何编码为 Kubernetes 密钥的字符串中,详细说明在高级 Kubernetes 概念部分中。

整个Ingress定义如下所示:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-example-ingress
  namespace: my-namespace
spec:
  tls:
  ...
  rules:
... 

在这里,namespace是可选的,如果未指定,则假定为default

在下一节中,我们将通过定义 Azure Kubernetes 集群并部署一个简单应用程序来实践这里解释的一些概念。

与 Azure Kubernetes 集群交互

要创建一个Azure Kubernetes 服务AKS)集群,请在 Azure 搜索框中键入AKS,选择Kubernetes 服务,然后单击添加按钮。将显示以下表单:

图 7.2:创建 Kubernetes 集群

值得一提的是,您可以通过将鼠标悬停在任何带有圆圈的“i”上来获取帮助,如上述屏幕截图所示。

与往常一样,您需要指定订阅、资源组和区域。然后,您可以选择一个唯一的名称(Kubernetes 集群名称),以及您想要使用的 Kubernetes 版本。对于计算能力,您需要为每个节点选择一个机器模板(节点大小)和节点数量。初始屏幕显示默认的三个节点。由于三个节点对于 Azure 免费信用来说太多了,我们将其减少为两个。此外,默认虚拟机也应该被更便宜的虚拟机替换,因此单击“更改大小”并选择“DS1 v2”。

“可用区”设置允许您将节点分布在多个地理区域以实现更好的容错性。默认值为三个区域。由于我们只有两个节点,请将其更改为两个区域。

在进行了上述更改后,您应该会看到以下设置:

图 7.3:选择的设置

现在,您可以通过单击“查看+创建”按钮来创建您的集群。应该会出现一个审查页面,请确认并创建集群。

如果您单击“下一步”而不是“查看+创建”,您还可以定义其他节点类型,然后可以提供安全信息,即“服务主体”,并指定是否希望启用基于角色的访问控制。在 Azure 中,服务主体是与您可能用于定义资源访问策略的服务相关联的帐户。您还可以更改默认网络设置和其他设置。

部署可能需要一些时间(10-20 分钟)。之后,您将拥有您的第一个 Kubernetes 集群!在本章结束时,当不再需要该集群时,请不要忘记删除它,以避免浪费您的 Azure 免费信用。

在下一小节中,您将学习如何通过 Kubernetes 的官方客户端 Kubectl 与集群进行交互。

使用 Kubectl

创建完集群后,您可以使用 Azure Cloud Shell 与其进行交互。单击 Azure 门户页面右上角的控制台图标。以下屏幕截图显示了 Azure Shell 图标:

图 7.4:Azure Shell 图标

在提示时,选择“Bash Shell”。然后,您将被提示创建一个存储帐户,确认并创建它。

我们将使用此 Shell 与我们的集群进行交互。在 Shell 的顶部有一个文件图标,我们将使用它来上传我们的.yaml文件:

图 7.5:如何在 Azure Cloud Shell 中上传文件

还可以下载一个名为 Azure CLI 的客户端,并在本地机器上安装它(参见docs.microsoft.com/en-US/cli/azure/install-azure-cli),但在这种情况下,您还需要安装与 Kubernetes 集群交互所需的所有工具(Kubectl 和 Helm),这些工具已预先安装在 Azure Cloud Shell 中。

创建 Kubernetes 集群后,您可以通过kubectl命令行工具与其进行交互。kubectl已集成在 Azure Shell 中,因此您只需激活集群凭据即可使用它。您可以使用以下 Cloud Shell 命令来完成此操作:

az aks get-credentials --resource-group <resource group> --name <cluster name> 

上述命令将凭据存储在/.kube/config配置文件中,该凭据是自动创建的,以便您与集群进行交互。从现在开始,您可以无需进一步身份验证即可发出kubectl命令。

如果您发出kubectl get nodes命令,您将获得所有 Kubernetes 节点的列表。通常,kubectl get <对象类型>列出给定类型的所有对象。您可以将其与nodespodsstatefulset等一起使用。kubectl get all显示在您的集群中创建的所有对象的列表。如果您还添加了特定对象的名称,您将只获取该特定对象的信息,如下所示:

kubectl get <object type><object name> 

如果添加--watch选项,对象列表将持续更新,因此您可以看到所有选定对象的状态随时间变化。您可以通过按下 Ctrl + c 来退出此观察状态。

以下命令显示了有关特定对象的详细报告:

kubectl describe <object name> 

可以使用以下命令创建在.yaml文件中描述的所有对象,例如myClusterConfiguration.yaml

kubectl create -f myClusterConfiguration.yaml 

然后,如果您修改了.yaml文件,可以使用apply命令在集群上反映所有修改,如下所示:

kubectl apply -f myClusterConfiguration.yaml 

apply执行与create相同的工作,但如果资源已经存在,apply会覆盖它,而create则会显示错误消息。

您可以通过将相同的文件传递给delete命令来销毁使用.yaml文件创建的所有对象,如下所示:

kubectl delete -f myClusterConfiguration.yaml 

delete命令还可以传递对象类型和要销毁的该类型对象的名称列表,如下例所示:

kubectl delete deployment deployment1 deployment2... 

上述kubectl命令应足以满足大部分实际需求。有关更多详细信息,请参阅Further reading部分中的官方文档链接。

在下一小节中,我们将使用kubectl create安装一个简单的演示应用程序。

部署演示 Guestbook 应用程序

Guestbook 应用程序是官方 Kubernetes 文档示例中使用的演示应用程序。我们将使用它作为 Kubernetes 应用程序的示例,因为它的 Docker 镜像已经在公共 Docker 存储库中可用,所以我们不需要编写软件。

Guestbook 应用程序存储了访问酒店或餐厅的客户的意见。它由一个使用Deployment实现的 UI 层和一个使用基于 Redis 的内存存储实现的数据库层组成。而 Redis 存储则是由一个用于写入/更新的唯一主存储和几个只读副本组成,这些副本始终基于 Redis,并实现了读取并行性。写入/更新并行性可以通过多个分片的 Redis 主节点来实现,但由于应用程序的特性,写入操作不应占主导地位,因此在实际情况下,单个主数据库应该足够满足单个餐厅/酒店的需求。整个应用程序由三个.yaml文件组成,您可以在与本书相关的 GitHub 存储库中找到。

以下是包含在redis-master.yaml文件中的基于 Redis 的主存储的代码:

apiVersion: apps/v1 
kind: Deployment
metadata:
  name: redis-master
  labels:
    app: redis
spec:
  selector:
    matchLabels:
      app: redis
      role: master
      tier: backend
  replicas: 1
  template:
    metadata:
      labels:
        app: redis
        role: master
        tier: backend
    spec:
      containers:
      - name: master
        image: k8s.gcr.io/redis:e2e
        resources:
          requests:
            cpu: 100m
            memory: 100Mi
        ports:
        - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: redis-master
  labels:
    app: redis
    role: master
    tier: backend
spec:
  ports:
  - port: 6379
    targetPort: 6379
  selector:
    app: redis
    role: master
    tier: backend 

该文件由两个对象定义组成,由一个只包含---的行分隔,即.yaml文件的对象定义分隔符。第一个对象是一个具有单个副本的Deployment,第二个对象是一个ClusterIPService,它在内部redis-master.default.svc.cluster.local网络地址上的6379端口上公开DeploymentDeployment pod template定义了三个approletier标签及其值,这些值在 Service 的selector定义中用于将 Service 与在Deployment中定义的唯一 pod 连接起来。

让我们将redis-master.yaml文件上传到 Cloud Shell,然后使用以下命令在集群中部署它:

kubectl create -f redis-master.yaml 

操作完成后,您可以使用kubectl get all命令检查集群的内容。

redis-slave.yaml文件中定义了从存储,它与主存储完全类似,唯一的区别是这次有两个副本和不同的 Docker 镜像。

让我们也上传此文件,并使用以下命令部署它:

kubectl create -f redis-slave.yaml 

UI 层的代码包含在frontend.yaml文件中。Deployment有三个副本和不同的服务类型。让我们使用以下命令上传并部署此文件:

kubectl create -f frontend.yaml 

值得分析的是frontend.yaml文件中的服务代码:

apiVersion: v1
kind: Service
metadata:
  name: frontend
  labels:
    app: guestbook
    tier: frontend
spec:
  type: LoadBalancer
  ports:
  - port: 80
  selector:
    app: guestbook
    tier: frontend 

这种类型的服务属于LoadBalancer类型,因为它必须在公共 IP 地址上公开应用程序。为了获取分配给服务和应用程序的公共 IP 地址,请使用以下命令:

kubectl get service 

前面的命令应该显示所有已安装服务的信息。您应该在列表的EXTERNAL-IP列下找到公共 IP。如果您只看到<none>的值,请重复该命令,直到公共 IP 地址分配给负载均衡器。

一旦获得 IP 地址,使用浏览器导航到该地址。应用程序的主页现在应该显示出来了!

在完成对应用程序的实验后,使用以下命令从集群中删除应用程序,以避免浪费您的 Azure 免费信用额度(公共 IP 地址需要付费):

kubectl delete deployment frontend redis-master redis-slave 
kubectl delete service frontend redis-master redis-slave 

在下一节中,我们将分析其他重要的 Kubernetes 功能。

高级 Kubernetes 概念

在本节中,我们将讨论其他重要的 Kubernetes 功能,包括如何为StatefulSets分配永久存储,如何存储密码、连接字符串或证书等秘密信息,容器如何通知 Kubernetes 其健康状态,以及如何使用 Helm 处理复杂的 Kubernetes 包。所有主题都按照专门的子部分进行组织。我们将从永久存储的问题开始。

需要永久存储

由于 Pod 会在节点之间移动,它们不能依赖于当前运行它们的节点提供的永久存储。这给我们留下了两个选择:

  1. 使用外部数据库:借助数据库,ReplicaSets也可以存储信息。然而,如果我们需要更好的写入/更新操作性能,我们应该使用基于非 SQL 引擎(如 Cosmos DB 或 MongoDB)的分布式分片数据库(参见第九章如何在云中选择数据存储)。在这种情况下,为了充分利用表分片,我们需要使用StatefulSets,其中每个pod实例负责不同的表分片。

  2. 使用云存储:由于不与物理集群节点绑定,云存储可以永久关联到StatefulSets的特定 Pod 实例。

由于访问外部数据库不需要任何特定于 Kubernetes 的技术,而是可以使用通常的连接字符串来完成,我们将集中讨论云存储。

Kubernetes 提供了一个名为PersistentVolumeClaim(PVC)的存储抽象,它独立于底层存储提供商。更具体地说,PVC 是分配请求,可以与预定义资源匹配或动态分配。当 Kubernetes 集群在云中时,通常使用由云提供商安装的动态提供商进行动态分配。

Azure 等云提供商提供具有不同性能和不同成本的不同存储类。此外,PVC 还可以指定accessMode,可以是:

  • ReadWriteOnce - 卷可以被单个 Pod 挂载为读写。

  • ReadOnlyMany - 卷可以被多个 Pod 挂载为只读。

  • ReadWriteMany - 卷可以被多个 Pod 挂载为读写。

卷声明可以添加到StatefulSets的特定spec->volumeClaimTemplates对象中:

volumeClaimTemplates:
-  metadata:
   name: my-claim-template-name
spec:
  resources:
    request:
      storage: 5Gi
  volumeMode: Filesystem
  accessModes:
    - ReadWriteOnce
  storageClassName: my-optional-storage-class 

storage属性包含存储需求。将volumeMode设置为Filesystem是一种标准设置,表示存储将作为文件路径可用。另一个可能的值是Block,它将内存分配为未格式化storageClassName必须设置为云提供商提供的现有存储类。如果省略,则将假定默认存储类。

可以使用以下命令列出所有可用的存储类:

kubectl get storageclass 

一旦volumeClaimTemplates定义了如何创建永久存储,那么每个容器必须指定将永久存储附加到的文件路径,位于spec->containers->volumeMounts属性中。

...
volumeMounts
- name: my-claim-template-name
  mountPath: /my/requested/storage
  readOnly: false
... 

在这里,name必须与 PVC 的名称相对应。

以下子节显示了如何使用 Kubernetes secrets。

Kubernetes secrets

秘密是一组键值对,它们被加密以保护它们。可以通过将每个值放入文件中,然后调用以下kubectl命令来创建它们:

kubectl create secret generic my-secret-name \
  --from-file=./secret1.bin \
  --from-file=./secret2.bin 

在这种情况下,文件名成为键,文件内容成为值。

当值为字符串时,可以直接在kubectl命令中指定,如下所示:

kubectl create secret generic dev-db-secret \
  --from-literal=username=devuser \
  --from-literal=password=sdsd_weew1' 

在这种情况下,键和值按顺序列出,由=字符分隔。

定义后,可以在 pod(DeploymentStatefulSettemplate)的spec->volume属性中引用 secrets,如下所示:

...
volumes:
  - name: my-volume-with-secrets
    secret:
      secretName: my-secret-name
... 

之后,每个容器可以在spec->containers->volumeMounts属性中指定要将它们挂载到的路径:

...
volumeMounts:
    - name: my-volume-with-secrets
      mountPath: "/my/secrets"
      readOnly: true
... 

在上面的示例中,每个键被视为具有与键相同名称的文件。文件的内容是秘密值,经过 base64 编码。因此,读取每个文件的代码必须解码其内容(在.NET 中,Convert.FromBase64可以完成这项工作)。

当 secrets 包含字符串时,它们也可以作为环境变量传递给spec->containers->env object

env:
    - name: SECRET_USERNAME
      valueFrom:
        secretKeyRef:
          name: dev-db-secret
          key: username
    - name: SECRET_PASSWORD
      valueFrom:
        secretKeyRef:
          name: dev-db-secret
          key: password 

在这里,name属性必须与 secret 的name匹配。当容器托管 ASP.NET Core 应用程序时,将 secrets 作为环境变量传递非常方便,因为在这种情况下,环境变量立即在配置对象中可用(请参阅第十五章加载配置数据并与选项框架一起使用部分,介绍 ASP.NET Core MVC)。

Secrets 还可以使用以下kubectl命令对 HTTPS 证书的密钥/证书对进行编码:

kubectl create secret tls test-tls --key="tls.key" --cert="tls.crt" 

以这种方式定义的 secrets 可用于在Ingresses中启用 HTTPS 终止。只需将 secret 名称放置在spec->tls->hosts->secretName属性中即可。

活跃性和就绪性检查

Kubernetes 会自动监视所有容器,以确保它们仍然存活,并且将资源消耗保持在spec->containers->resources->limits对象中声明的限制范围内。当某些条件被违反时,容器要么被限制,要么被重新启动,要么整个 pod 实例在不同的节点上重新启动。Kubernetes 如何知道容器处于健康状态?虽然它可以使用操作系统来检查节点的健康状态,但它没有适用于所有容器的通用检查。

因此,容器本身必须通知 Kubernetes 它们的健康状态,否则 Kubernetes 必须放弃验证它们。容器可以通过两种方式通知 Kubernetes 它们的健康状态,一种是声明返回健康状态的控制台命令,另一种是声明提供相同信息的端点。

这两个声明都在spec->containers->livenessProb对象中提供。控制台命令检查声明如下所示:

...
  livenessProbe:
    exec:
      command:
      - cat
      - /tmp/healthy
    initialDelaySeconds: 10
    periodSeconds: 5
 ... 

如果command返回0,则容器被视为健康。在上面的示例中,我们假设在容器中运行的软件将其健康状态记录在/tmp/healthy文件中,因此cat/tmp/healthy命令返回它。PeriodSeconds是检查之间的时间,而initialDelaySeconds是执行第一次检查之前的初始延迟。始终需要初始延迟,以便给容器启动的时间。

端点检查非常类似:

...
  livenessProbe:
    exec:
      httpGet:
        path: /healthz
        port: 8080
        httpHeaders:
          - name: Custom-Health-Header
          value: container-is-ok
    initialDelaySeconds: 10
    periodSeconds: 5
 ... 

如果 HTTP 响应包含声明的标头和声明的值,则测试成功。您还可以使用纯 TCP 检查,如下所示:

...
  livenessProbe:
    exec:
      tcpSocket:
        port: 8080
    initialDelaySeconds: 10
    periodSeconds: 5
 ... 

在这种情况下,如果 Kubernetes 能够在声明的端口上打开与容器的 TCP 套接字,则检查成功。

类似地,一旦安装了容器,就会使用就绪性检查来监视容器的就绪性。就绪性检查的定义方式与活跃性检查完全相同,唯一的区别是将livenessProbe替换为readinessProbe

以下小节解释了如何自动缩放Deployments

自动缩放

与手动修改Deployment中的副本数以适应负载的减少或增加不同,我们可以让 Kubernetes 自行决定副本的数量,试图保持声明的资源消耗恒定。因此,例如,如果我们声明目标为 10%的 CPU 消耗,当每个副本的平均资源消耗超过此限制时,将创建一个新副本,而如果平均 CPU 低于此限制,则销毁一个副本。用于监视副本的典型资源是 CPU 消耗,但我们也可以使用内存消耗。

通过定义HorizontalPodAutoscaler对象来实现自动缩放。以下是HorizontalPodAutoscaler定义的示例:

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: my-autoscaler
spec:
  scaleTargetRef:
    apiVersion: extensions/v1beta1
    kind: Deployment
    name: my-deployment-name
  minReplicas: 1
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      targetAverageUtilization: 25 

spec-> scaleTargetRef->name指定要自动缩放的Deployment的名称,而targetAverageUtilization指定目标资源(在我们的情况下是 CPU)的使用百分比(在我们的情况下是 25%)。

以下小节简要介绍了 Helm 软件包管理器和 Helm 图表,并解释了如何在 Kubernetes 集群上安装 Helm 图表。给出了安装Ingress Controller的示例。

Helm - 安装 Ingress Controller

Helm 图表是组织安装包含多个.yaml文件的复杂 Kubernetes 应用程序的一种方式。Helm 图表是一组.yaml文件,组织成文件夹和子文件夹。以下是从官方文档中获取的 Helm 图表的典型文件夹结构:

图 7.6:Helm 图表的文件夹结构

特定于应用程序的.yaml文件放置在顶级templates目录中,而charts目录可能包含其他用作辅助库的 Helm 图表。顶级Chart.yaml文件包含有关软件包(名称和描述)的一般信息,以及应用程序版本和 Helm 图表版本。以下是典型示例:

apiVersion: v2
name: myhelmdemo
description: My Helm chart
type: application
version: 1.3.0
appVersion: 1.2.0 

在这里,type可以是applicationlibrary。只能部署application图表,而library图表是用于开发其他图表的实用程序。library图表放置在其他 Helm 图表的charts文件夹中。

为了配置每个特定应用程序的安装,Helm 图表.yaml文件包含在安装 Helm 图表时指定的变量。此外,Helm 图表还提供了一个简单的模板语言,允许仅在满足取决于输入变量的条件时包含一些声明。顶级values.yaml文件声明了输入变量的默认值,这意味着开发人员只需要指定几个需要与默认值不同的变量。我们不会描述 Helm 图表模板语言,但您可以在进一步阅读部分中找到官方 Helm 文档。

Helm 图表通常以与 Docker 镜像类似的方式组织在公共或私有存储库中。有一个 Helm 客户端,您可以使用它从远程存储库下载软件包,并在 Kubernetes 集群中安装图表。Helm 客户端立即在 Azure Cloud Shell 中可用,因此您可以开始在 Azure Kubernetes 集群中使用 Helm,而无需安装它。

在使用其软件包之前,必须添加远程存储库,如下例所示:

helm repo add <my-repo-local-name> https://kubernetes-charts.storage.googleapis.com/ 

上述命令使远程存储库的软件包可用,并为其指定本地名称。之后,可以使用以下命令安装远程存储库的任何软件包:

helm install <instance name><my-repo-local-name>/<package name> -n <namespace> 

在这里,<namespace>是要安装应用程序的命名空间。通常情况下,如果未提供,则假定为default命名空间。<instance name>是您为安装的应用程序指定的名称。您需要此名称才能使用以下命令获取有关已安装应用程序的信息:

helm status <instance name> 

您还可以使用以下命令获取使用 Helm 安装的所有应用程序的信息:

helm ls 

还需要应用程序名称来通过以下命令从集群中删除应用程序:

helm delete <instance name> 

当我们安装应用程序时,我们还可以提供一个包含要覆盖的所有变量值的.yaml文件。我们还可以指定 Helm 图表的特定版本,否则将假定为最新版本。以下是一个同时覆盖版本和值的示例:

helm install <instance name><my-repo-local-name>/<package name> -f  values.yaml –version <version> 

最后,值覆盖也可以通过--set选项提供,如下所示:

...--set <variable1>=<value1>,<variable2>=<value2>... 

我们还可以使用upgrade命令升级现有的安装,如下所示:

helm upgrade <instance name><my-repo-local-name>/<package name>... 

upgrade命令可以使用-f选项或--set选项指定新的值覆盖,并且可以使用--version指定新版本。

让我们使用 Helm 为 guestbook 演示应用程序提供一个Ingress。更具体地说,我们将使用 Helm 安装一个基于 Nginx 的Ingress-Controller。要遵循的详细过程如下:

  1. 添加远程存储库:
helm repo add gcharts https://kubernetes-charts.storage.googleapis.com/ 
  1. 安装Ingress-Controller
helm install ingress gcharts/nginx-ingress 
  1. 安装完成后,如果键入kubectl get service,您应该在已安装的服务中看到已安装的Ingress-Controller的条目。该条目应包含一个公共 IP。请记下此 IP,因为它将是应用程序的公共 IP。

  2. 打开frontend.yaml文件并删除type: LoadBalancer行。保存并上传到 Azure Cloud Shell。我们将前端应用程序的服务类型从LoadBalancer更改为ClusterIP(默认)。此服务将连接到您将要定义的新 Ingress。

  3. 使用kubectl部署redis-master.yamlredis-slave.yamlfrontend.yaml,如部署演示 Guestbook 应用程序子部分所述。创建一个frontend-ingress.yaml文件,并将以下代码放入其中:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: simple-frontend-ingress
spec:
  rules:
  - http:
      paths:
      - path:/
        backend:
          serviceName: frontend
          servicePort: 80 
  1. frontend-ingress.yaml上传到 Cloud Shell,并使用以下命令部署它:
kubectl apply -f frontend-ingress.yaml 
  1. 打开浏览器并导航到您在步骤 3中注释的公共 IP。在那里,您应该看到应用程序正在运行。

由于分配给Ingress-Controller的公共 IP 在 Azure 的公共 IP 地址部分中可用(使用 Azure 搜索框找到它),您可以在那里检索它,并为其分配一个类型为<chosen name>.<your Azure region>.cloudeapp.com的主机名。

鼓励您为应用程序公共 IP 分配一个主机名,然后使用此主机名从letsencrypt.org/获取免费的 HTTPS 证书。获得证书后,可以使用以下命令从中生成一个密钥:

kubectl create secret tls guestbook-tls --key="tls.key" --cert="tls.crt" 

然后,您可以通过将前面的密钥添加到frontend-ingress.yamlIngress中,将以下spec->tls部分添加到其中:

...
spec:
...
  tls:
  - hosts:
      - <chosen name>.<your Azure region>.cloudeapp.com
secretName: guestbook-tls 

进行更正后,将文件上传到 Azure Cloud Shell,并使用以下内容更新先前的Ingress定义:

kubectl apply frontend-ingress.yaml 

此时,您应该能够通过 HTTPS 访问 Guestbook 应用程序。

当您完成实验时,请不要忘记从集群中删除所有内容,以避免浪费您的免费 Azure 信用额度。您可以通过以下命令来完成:

kubectl delete frontend-ingress.yaml
kubectl delete frontend.yaml
kubectl delete redis-slave.yaml
kubectl delete redis-master.yaml
helm delete ingress 

摘要

在本章中,我们介绍了 Kubernetes 的基本概念和对象,然后解释了如何创建 Azure Kubernetes 集群。我们还展示了如何部署应用程序,以及如何使用简单的演示应用程序监视和检查集群的状态。

本章还介绍了更高级的 Kubernetes 功能,这些功能在实际应用程序中起着基本作用,包括如何为在 Kubernetes 上运行的容器提供持久存储,如何通知 Kubernetes 容器的健康状态,以及如何提供高级 HTTP 服务,如 HTTPS 和基于名称的虚拟主机。

最后,我们回顾了如何使用 Helm 安装复杂的应用程序,并对 Helm 和 Helm 命令进行了简短的描述。

在下一章中,您将学习如何使用 Entity Framework 将.NET 应用程序与数据库连接。

问题

  1. 为什么需要服务(Services)?

  2. 为什么需要Ingress

  3. 为什么需要 Helm?

  4. 是否可以在同一个.yaml文件中定义多个 Kubernetes 对象?如果可以,如何操作?

  5. Kubernetes 如何检测容器故障?

  6. 为什么需要持久卷索赔(Persistent Volume Claims)?

  7. ReplicaSetStatefulSet之间有什么区别?

进一步阅读

第八章:在 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 5 中包含的 ORM。

技术要求

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

本章中的所有概念都将通过基于 WWTravelClub 书籍用例的实际示例进行澄清。您可以在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5找到本章的代码。

理解 ORM 基础知识

ORM 将关系数据库表映射为内存中的对象集合,其中对象属性对应于数据库表字段。来自 C#的类型,如布尔值、数字类型和字符串,都有对应的数据库类型。如果映射的数据库中没有 GUID,则诸如 GUID 之类的类型将映射到它们的等效字符串表示。所有日期和时间类型都映射到 C#的DateTime,当日期/时间不包含时区信息时,或者映射到DateTimeOffset,当日期/时间还包含显式时区信息时。任何数据库时间持续时间都映射到TimeSpan。最后,单个字符根本不应该映射到数据库字段。

由于大多数面向对象语言的字符串属性没有与之关联的长度限制(而数据库字符串字段通常有长度限制),因此在数据库映射配置中考虑了数据库限制。一般来说,当需要指定数据库类型和面向对象语言类型之间的映射时,这些选项都在映射配置中声明。

整个配置的定义方式取决于具体的 ORM。Entity Framework Core 提供了三种选项:

  • 数据注释(属性注释)

  • 名称约定

  • 基于配置对象和方法的流畅配置接口

虽然流畅接口可以用于指定任何配置选项,但数据注释和名称约定只能用于其中的一小部分。

就个人而言,我更喜欢对大多数设置使用流畅的接口。我仅在指定具有 ID 属性名称的主键时使用名称约定,因为我发现仅依赖名称约定进行更复杂的设置也是非常危险的。实际上,名称约定上没有编译时检查,因此重新工程操作可能会错误地更改或破坏一些 ORM 设置。

我主要使用数据注释来指定属性可能值的约束,例如值的最大长度,或者属性是必填的且不能为空。实际上,这些约束限制了每个属性中指定的类型,因此将它们放在应用的属性旁边可以增加代码的可读性。

为了增加代码的可读性和可维护性,所有其他设置最好通过使用流畅的接口进行分组和组织。

每个 ORM 都适应于特定的 DB 类型(Oracle、MySQL、SQL Server 等),具有称为提供程序连接器的特定于 DB 的适配器。Entity Framework Core 具有大多数可用 DB 引擎的提供程序。

可以在docs.microsoft.com/en-US/ef/core/providers/找到完整的提供程序列表。

适配器对于 DB 类型的差异、事务处理方式以及 SQL 语言未标准化的所有其他特性都是必需的。

表之间的关系用对象指针表示。例如,在一对多关系中,映射到关系方的类包含一个集合,该集合由关系方上的相关对象填充。另一方面,映射到关系方的类具有一个简单的属性,该属性由关系方上的唯一相关对象填充。

整个数据库(或其中的一部分)由一个内存缓存类表示,该类包含映射到 DB 表的每个集合的属性。首先,在内存缓存类的实例上执行查询和更新操作,然后将此实例与数据库同步。

Entity Framework Core 使用的内存缓存类称为DbContext,它还包含映射配置。更具体地说,通过继承DbContext并将其添加到所有映射集合和所有必要的配置信息中,可以获得特定于应用程序的内存缓存类。

总之,DbContext子类实例包含与数据库同步以获取/更新实际数据的 DB 的部分快照。

使用在内存缓存类的集合上进行方法调用的查询语言执行 DB 查询。实际的 SQL 是在同步阶段创建和执行的。例如,Entity Framework Core 在映射到 DB 表的集合上执行语言集成查询LINQ)。

一般来说,LINQ 查询会产生IEnumerable实例,也就是说,在查询结束时创建IEnumerable时,集合的元素并不会被计算,而是当您尝试从IEnumerable中实际检索集合元素时才会计算。这称为延迟评估或延迟执行。它的工作方式如下:

  • DbContext的映射集合开始的 LINQ 查询会创建IQueryable的特定子类型。

  • IQueryable包含发出对数据库查询所需的所有信息,但是当检索到IQueryable的第一个元素时,实际的 SQL 才会被生成和执行。

  • 通常,每个 Entity Framework 查询都以ToListToArray操作结束,将IQueryable转换为列表或数组,从而导致在数据库上实际执行查询。

  • 如果查询预计只返回单个元素或根本没有元素,通常我们会执行一个SingleOrDefault操作,该操作返回一个元素(如果有的话)或null

此外,通过在表示数据库表的DbContext集合属性上模拟这些操作,也可以对 DB 表执行更新、删除和添加新实体。但是,只有在通过查询加载到内存集合中后,才能以这种方式更新或删除实体。更新查询需要根据需要修改实体的内存表示,而删除查询需要从其内存映射集合中删除实体的内存表示。在 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 Standard.NET (Core)

虽然.NET Core 库与特定的.NET Core 版本相关联,但.NET Standard 2.0 库具有广泛的应用范围,因为它们可以与大于 2.0 的任何.NET 版本以及经典的.NET Framework 4.7.2 及以上版本一起使用。

然而,Microsoft.EntityFrameworkCore包的第 5 版,也就是随.NET 5 一起发布的版本,仅依赖于.NET Standard 2.1。这意味着它不是设计用于特定的.NET(Core)版本,而是只需要支持.NET Standard 2.1 的.NET Core 版本。因此,Entity Framework 5 可以与.NET 5 以及高于或等于 2.1 的任何.NET Core 版本正常工作。

由于我们的库不是通用库(它只是特定.NET 5 应用程序的一个组件),所以我们可以选择.NET 5 库而不是选择.NET Standard 库项目。我们的.NET 5 库项目可以按以下方式创建和准备:

  1. 打开 Visual Studio 并定义一个名为WWTravelClubDB的新解决方案,然后选择可用的最新.NET Core 版本的类库(.NET Core)

  2. 我们必须安装所有与 Entity Framework Core 相关的依赖项。安装所有必要的依赖项的最简单方法是添加我们将要使用的数据库引擎提供程序的 NuGet 包 - 在我们的情况下是 SQL Server - 正如我们在第四章决定最佳基于云的解决方案中提到的。实际上,任何提供程序都将安装所有所需的包,因为它们都作为依赖项。因此,让我们添加最新稳定版本的Microsoft.EntityFrameworkCore.SqlServer。如果您计划使用多个数据库引擎,还可以添加其他提供程序,因为它们可以并存。在本章的后面,我们将安装其他包含我们需要处理 Entity Framework Core 的工具的 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.DataAnnotationsSystem.ComponentModel.DataAnnotations.Schema命名空间中。因此,最好将它们都添加到所有实体定义中。

  • 指定哪些字段是必填的,哪些是可选的:如果项目没有使用新的可空引用类型功能,默认情况下,所有引用类型(例如所有字符串)都被假定为可选的,而所有值类型(例如数字和 GUID)都被假定为必填的。如果我们希望引用类型是必填的,那么我们必须用Required属性进行修饰。另一方面,如果我们希望T类型的属性是可选的,并且T是值类型或者可空引用类型功能已经开启,那么我们必须用T?替换T

  • 指定哪个属性代表主键:可以通过用Key属性修饰属性来指定主键。然而,如果没有找到Key属性,那么名为Id的属性(如果有的话)将被视为主键。在我们的情况下,不需要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 nvarchar(MAX)字段的无限长度实现。我们可以以类似的方式编写Package类的代码:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace WWTravelClubDB.Models
{
    public class Package
    {
        public int Id { get; set; }
        [MaxLength(128), Required]
        public string Name { get; set; }
        [MaxLength(128)]
        public string Description { get; set; }
        public decimal Price { get; set; }
        public int DurationInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public Destination MyDestination { get; set; }
        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方法替换,因为我们是从关系的另一侧开始的。在这里,我们还可以选择每个小数属性在其映射的数据库字段中表示的精度。默认情况下,小数由 18 位和 2 位小数表示。您可以使用类似以下内容为每个属性更改此设置:

...
.Property(m => m.Price)
        .HasPrecision(10, 3); 

ModelBuilder builder对象允许我们使用以下内容指定数据库索引:

builder.Entity<T>()
   .HasIndex(m => m.PropertyName); 

多属性索引定义如下:

builder.Entity<T>()
    .HasIndex("propertyName1", "propertyName2", ...); 

从版本 5 开始,索引也可以通过应用于类的属性来定义。以下是单属性索引的情况:

[Index(nameof(Property), IsUnique = true)]
public class MyClass
{
    public int Id { get; set; }
    [MaxLength(128)]
    public string Property { get; set; }
} 

以下是多属性索引的情况:

[Index(nameof(Property1), nameof(Property2), IsUnique = false)]
public class MyComplexIndexClass
{
    public int Id { get; set; }
    [MaxLength(64)]
    public string Property1 { get; set; }
    [MaxLength(64)]
    public string Property2 { get; set; }
} 

如果我们添加了所有必要的配置信息,那么我们的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(nameof(Package.StartValidityDate),
                  nameof(Package.EndValidityDate));
} 

前面的示例展示了一对多关系,但 Entity Framework Core 5 也支持多对多关系:

 modelBuilder
        .Entity<Teacher>()
        .HasMany(e => e.Classrooms)
        .WithMany(e => e.Teachers) 

在前面的情况下,联接实体和数据库联接表是自动创建的,但您也可以指定现有实体作为联接实体。在前面的示例中,联接实体可能是老师在每个教室教授的课程:

modelBuilder
  Entity<Teacher>()
  .HasMany(e => e.Classrooms)
  .WithMany(e => e.Teachers)
      .UsingEntity<Course>(
           b => b.HasOne(e => e.Teacher).WithMany()
           .HasForeignKey(e => e.TeacherId),
           b => b.HasOne(e => e.Classroom).WithMany()
           .HasForeignKey(e => e.ClassroomId)); 

一旦配置了 Entity Framework Core,我们可以使用所有配置信息来创建实际的数据库,并在应用程序发展过程中放置所有需要的工具,以便更新数据库的结构。下一节将解释如何进行。

Entity Framework Core 迁移

现在我们已经配置了 Entity Framework 并定义了特定于应用程序的DbContext子类,我们可以使用 Entity Framework Core 设计工具来生成物理数据库,并创建 Entity Framework Core 与数据库交互所需的数据库结构快照。

每个需要它们的项目中必须安装 Entity Framework Core 设计工具作为 NuGet 包。有两个等效的选项:

  • 适用于任何 Windows 控制台的工具:这些工具通过Microsoft.EntityFrameworkCore.Design NuGet 包提供。所有 Entity Framework Core 命令都以dotnet ef .....格式,因为它们包含在ef命令行的.NET Core 应用程序中。

  • 专门用于 Visual Studio Package Manager 控制台的工具:这些工具包含在Microsoft.EntityFrameworkCore.Tools NuGet 包中。它们不需要dotnet ef前缀,因为它们只能从 Visual Studio 内的Package Manager Console中启动。

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 键发出此命令。在发出此命令之前,请验证是否已添加了Microsoft.EntityFrameworkCore.Tools NuGet 包,否则可能会出现“未识别的命令”错误!

图 8.1:添加第一个迁移

initial是我们给第一个迁移的名称。因此,一般来说,命令是Add-Migration <迁移名称>。当我们操作现有数据库时,必须在第一个迁移(仅在第一个迁移)中添加-IgnoreChanges选项,以便创建一个空迁移。有关整套命令的参考可以在进一步阅读部分找到。

  1. 如果在创建迁移之后,但在将迁移应用到数据库之前,我们意识到我们犯了一些错误,我们可以使用Remove-Migration命令撤消我们的操作。如果迁移已经应用到数据库,纠正错误的最简单方法是对代码进行所有必要的更改,然后应用另一个迁移。

  2. 一旦执行Add-Migration命令,我们的项目中会出现一个新文件夹!

图 8.2:Add-Migration 命令创建的文件

20201008150827_initial.cs是我们用易于理解的语言表达的迁移。

您可以查看代码以验证一切是否正常,您也可以修改迁移内容(只有当您足够专业时才能可靠地这样做)。每个迁移都包含一个Up方法和一个Down方法。Up方法表示迁移,而Down方法撤消其更改。因此,Down方法包含与Up方法中包含的所有操作的相反操作,按照相反的顺序。

20201008150827_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 命令和声明自动生成。例如,可以通过migrationBuilder.Sql("<sql scommand>")方法在UpDown方法中手动包含存储过程和通用 SQL 字符串。

最安全的方法是添加一个迁移而不进行任何配置更改,以便在创建时迁移为空。然后,我们可以将必要的 SQL 命令添加到此迁移的空Up方法中,以及在空的Down方法中添加它们的相反命令。将所有 SQL 字符串放在资源文件(.resx文件)的属性中是一个好的做法。

现在,您已经准备好通过 Entity Framework Core 与数据库进行交互了。

使用 Entity Framework Core 查询和更新数据

为了测试我们的 DB 层,我们需要根据与我们的库相同的.NET Core 版本向解决方案中添加一个基于控制台的项目。让我们开始吧:

  1. 让我们将新的控制台项目命名为WWTravelClubDBTest

  2. 现在,我们需要将数据层作为控制台项目的依赖项添加到References节点中,然后选择Add reference

  3. 删除program.cs文件中Main静态方法的内容,并开始编写以下内容:

Console.WriteLine("program start: populate database, press a key to continue");
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),
            DurationInDays=7,
            Price=1000
        },
        new Package
        {
            Name = "Winter in Florence",
            StartValidityDate = new DateTime(2019, 12, 1),
            EndValidityDate = new DateTime(2020, 2, 1),
            DurationInDays=7,
            Price=500
        }
    }
};
context.Destinations.Add(firstDestination);
context.SaveChanges();
Console.WriteLine(
    "DB populated: first destination id is "+
    firstDestination.Id);
Console.ReadKey(); 

无需指定主键,因为它们是自动生成的,并将由数据库填充。事实上,在SaveChanges()操作后,我们的上下文与实际数据库同步后,firstDestination.Id属性具有非零值。对于Package的主键也是如此。

当我们声明一个实体(在我们的情况下是Package)是另一个实体(在我们的情况下是Destination)的子实体,通过将其插入到父实体集合(在我们的情况下是Packages集合)中时,由于 Entity Framework Core 会自动推断外键(在我们的情况下是DestinationId),因此无需显式设置外键。创建并与firstDestination数据库同步后,我们可以以两种不同的方式添加更多的套餐:

  • 创建一个Package类实例,将其DestinationId外键设置为firstDestinatination.Id,并将其添加到context.Packages

  • 创建一个Package类实例,无需设置其外键,然后将其添加到其父Destination实例的Packages集合中。

后一种选项是唯一的可能性,当子实体(Package)与其父实体(Destination)一起添加,并且父实体具有自动生成的主键时,因为在这种情况下,外键在执行添加时不可用。在大多数其他情况下,前一种选项更简单,因为第二种选项要求在内存中加载父Destination实体,以及其Packages集合,即与Destination对象相关联的所有套餐(默认情况下,连接的实体不会通过查询加载)。

现在,假设我们想修改佛罗伦萨目的地,并为所有佛罗伦萨套餐价格增加 10%。我们该如何操作?按照以下步骤找出答案:

  1. 首先,注释掉所有以前用于填充数据库的指令,但保留DbContext创建指令。

  2. 然后,我们需要使用查询将实体加载到内存中,修改它,并调用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(); 

如果使用Include方法包含的实体本身包含我们想要包含的嵌套集合,我们可以使用ThenInclude,如下所示:

.Include(m => m.NestedCollection)
.ThenInclude(m => m.NestedNestedCollection) 

由于 Entity Framework 始终尝试将每个 LINQ 翻译为单个 SQL 查询,有时生成的查询可能过于复杂和缓慢。在这种情况下,从第 5 版开始,我们可以允许 Entity Framework 将 LinQ 查询拆分为多个 SQL 查询,如下所示:

.AsSplitQuery().Include(m => m.NestedCollection)
.ThenInclude(m => m.NestedNestedCollection) 

通过检查ToQueryString方法生成的 LinQ 查询的 SQL,可以解决性能问题:

var mySQL = myLinQQuery.ToQueryString (); 

从第 5 版开始,包含的嵌套集合也可以使用Where进行过滤,如下所示:

.Include(m => m.Packages.Where(l-> l.Price < x)) 

到目前为止,我们执行的查询的唯一目的是更新检索到的实体。接下来,我们将解释如何检索将向用户显示和/或由复杂业务操作使用的信息。

将数据返回到表示层

为了保持层之间的分离,并根据每个用例实际需要的数据调整查询,DB 实体不会按原样发送到表示层。相反,数据将投影到包含用例所需信息的较小类中,这些类由表示层的调用方法实现。将数据从一层移动到另一层的对象称为数据传输对象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 DurationInDays { 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, DurationInDays, DestinationName, Price);
        }
    }
} 

我们不需要将实体加载到内存中,然后将其数据复制到 DTO 中,而是可以直接将数据库数据投影到 DTO 中,这要归功于 LINQ 的Select子句。这样可以最大程度地减少与数据库交换的数据量。

  1. 例如,我们可以使用查询填充我们的 DTO,该查询检查所有在 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,
        DurationInDays=m.DurationInDays,
        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. 程序在每个Console.ReadKey()方法处停止,等待您按任意键。这样,您就有时间分析由我们添加到Main方法的所有代码片段产生的输出。

  3. 现在,在解决方案资源管理器中右键单击 WWTravelClubDBTest 项目,并将其设置为启动项目。然后,运行解决方案。

现在,我们将学习如何处理不能有效映射到表示数据库表的内存集合中的即时操作的操作。

发出直接的 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 类型。该方法返回受影响的行数。

必须通过与这些实体相关联的映射集合的FromSqlRaw方法发出返回实体集合的 SQL 命令:

context.<mapped collection>.FromSqlRaw(string sql, params object[] parameters) 

因此,例如,返回Package实例的命令看起来像这样:

var results = context.Packages.FromSqlRaw("<some sql>", par1, par2, ...).ToList(); 

SQL 字符串和参数在ExecuteSqlRaw方法中的工作方式如下。以下是一个简单的例子:

var allPackages =context.Packages.FromSqlRaw(
    "SELECT * FROM Products WHERE Name = {0}",
    myPackageName) 

将所有 SQL 字符串放入资源文件中,并将所有ExecuteSqlRawFromSqlRaw调用封装在您在基于 Entity Framework Core 的数据层中定义的公共方法中,以便将特定数据库的依赖性保持在内部。

处理事务

DbContext实例所做的所有更改都在第一次SaveChanges调用时作为单个事务传递。然而,有时需要在同一个事务中包含查询和更新。在这些情况下,我们必须显式处理事务。如果我们将它们放在与事务对象关联的using块中,那么几个 Entity Framework Core 命令可以包含在事务中:

using (var dbContextTransaction = context.Database.BeginTransaction())
try{
   ...
   ...
   dbContextTransaction.Commit();
 }
 catch
 {
   dbContextTransaction.Rollback();
 } 

在上述代码中,context是我们DbContext子类的一个实例。在using块内,可以通过调用其RollbackCommit方法来中止和提交事务。包含在事务块中的任何SaveChanges调用都使用它们已经存在的事务,而不是创建新的事务。

部署数据层

当数据库层部署到生产环境或暂存环境时,通常已经存在一个空数据库,因此必须应用所有迁移以创建所有数据库对象。这可以通过调用context.Database.Migrate()来完成。Migrate方法应用尚未应用到数据库的迁移,因此在应用程序的生命周期中可以安全地多次调用。context是我们的DbContext类的一个实例,必须通过具有足够权限来创建表和执行迁移中包含的所有操作的连接字符串进行传递。因此,通常,此连接字符串与我们在正常应用程序操作期间使用的字符串不同。

在 Azure 上部署 Web 应用程序时,我们有机会使用我们提供的连接字符串来检查迁移。我们还可以在应用程序启动时通过调用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 的高级特性

值得一提的一个有趣的 Entity Framework 高级特性是全局过滤器,这是在 2017 年底引入的。它们可以实现软删除和多租户表等技术,这些表由多个用户共享,每个用户只能看到自己的记录。

全局过滤器是使用modelBuilder对象定义的,该对象在DbContextOnModelCreating方法中可用。此方法的语法如下:

modelBuilder.Entity<MyEntity>().HasQueryFilter(m => <define filter condition here>); 

例如,如果我们向我们的Package类添加一个IsDeleted属性,我们可以通过定义以下过滤器软删除Package而不从数据库中删除它:

modelBuilder.Entity<Package>().HasQueryFilter(m => !m.IsDeleted); 

但是,过滤器包含DbContext属性。因此,例如,如果我们向我们的DbContext子类添加一个CurrentUserID属性(其值在创建DbContext实例时设置),那么我们可以向所有引用用户 ID 的实体添加以下过滤器:

modelBuilder.Entity<Document>().HasQueryFilter(m => m.UserId == CurrentUserId); 

通过上述过滤器,当前登录的用户只能访问他们拥有的文档(具有他们的UserId的文档)。类似的技术在多租户应用程序的实现中非常有用。

另一个值得一提的有趣特性是将实体映射到不可更新的数据库查询,这是在版本 5 中引入的。

当您定义一个实体时,您可以明确定义映射的数据库表的名称或映射的可更新视图的名称:

 modelBuilder.Entity<MyEntity1>().ToTable("MyTable");
 modelBuilder.Entity<MyEntity2>().ToView("MyView"); 

当实体映射到视图时,数据库迁移不会生成表,因此必须由开发人员手动定义数据库视图。

如果我们想要映射实体的视图不可更新,LinQ 无法使用它将更新传递给数据库。在这种情况下,我们可以同时将相同实体映射到视图和表:

modelBuilder.Entity<MyEntity>().ToTable("MyTable").ToView("MyView"); 

Entity Framework 将使用视图进行查询和表进行更新。当我们创建数据库表的新版本,但又希望在所有查询中同时从旧版本的表中获取数据时,这是非常有用的。在这种情况下,我们可以定义一个视图,该视图从旧表和新表中获取数据,但只在新表上传递所有更新。

摘要

在本章中,我们讨论了 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 集群,以增加可靠性和计算能力。然而,通常情况下,自定义架构并不是最佳解决方案,并且无法充分利用云基础设施提供的机会。

因此,本章不会讨论这些自定义架构,而主要关注云中和 Azure 上可用的各种平台即服务PaaS)存储方案。这些方案包括基于普通磁盘空间、关系型数据库、NoSQL 数据库和 Redis 等内存数据存储的可扩展解决方案。

选择更合适的存储类型不仅基于应用程序的功能要求,还基于性能和扩展要求。事实上,尽管在处理资源时进行扩展会导致性能线性增加,但扩展存储资源并不一定意味着性能会有可接受的增加。简而言之,无论您如何复制数据存储设备,如果多个请求影响相同的数据块,它们将始终排队等待相同的时间来访问它!

扩展数据会导致读操作吞吐量线性增加,因为每个副本可以处理不同的请求,但对于写操作的吞吐量并不意味着同样的增加,因为相同数据块的所有副本都必须更新!因此,需要更复杂的技术来扩展存储设备,并非所有存储引擎都能够同样良好地扩展。

在所有场景中,关系型数据库并不都能很好地扩展。因此,扩展需求和地理数据分布的需求在选择存储引擎以及 SaaS 提供方面起着基本作用。

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

  • 了解不同用途的不同存储库

  • 在关系型或 NoSQL 存储之间进行选择

  • Azure Cosmos DB - 管理多大陆数据库的机会

  • 用例 - 存储数据

让我们开始吧!

技术要求

本章需要您具备以下内容:

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

  • 免费的 Azure 账户。第一章创建 Azure 账户小节解释了如何创建账户。

  • 为了获得更好的开发体验,我们建议您还安装 Cosmos DB 的本地模拟器,可以在aka.ms/cosmosdb-emulator找到。

了解不同用途的不同存储库

本节描述了最流行的数据存储技术提供的功能。主要关注它们能够满足的功能要求。性能和扩展功能将在下一节中进行分析,该节专门比较关系型和 NoSQL 数据库。

在 Azure 中,可以通过在所有 Azure 门户页面顶部的搜索栏中输入产品名称来找到各种产品。

以下小节描述了我们在 C#项目中可以使用的各种数据库类型。

关系型数据库

关系数据库是最常见和研究的存储类型。随着它们的发展,社会保证了高水平的服务和无数的存储数据。已经设计了数十种应用程序来存储这种类型的数据库中的数据,我们可以在银行、商店、工业等领域找到它们。当您将数据存储在关系数据库中时,基本原则是定义您将在其中保存的实体和属性,并定义这些实体之间的正确关系。

几十年来,关系数据库是设计大型项目所想象的唯一选择。世界上许多大公司都建立了自己的数据库管理系统。Oracle、MySQL 和 MS SQL Server 被许多人列为您可以信任存储数据的数据库。

通常,云提供多种数据库引擎。Azure 提供各种流行的数据库引擎,如 Oracle、MySQL 和 SQL Server(Azure SQL)。

关于 Oracle 数据库引擎,Azure 提供可配置的虚拟机,上面安装了各种 Oracle 版本,您可以通过在 Azure 门户搜索栏中键入Oracle后获得的建议轻松验证。Azure 的费用不包括 Oracle 许可证;它们只包括计算时间,因此您必须自行携带许可证到 Azure。

在 Azure 上使用 MySQL,您需要支付使用私有服务器实例的费用。您产生的费用取决于您拥有的核心数、必须分配的内存量以及备份保留时间。

MySQL 实例是冗余的,您可以选择本地或地理分布式冗余:

图 9.1:在 Azure 上创建 MySQL 服务器

Azure SQL 是最灵活的选择。在这里,您可以配置每个数据库使用的资源。创建数据库时,您可以选择将其放置在现有服务器实例上,或创建一个新实例。在定义解决方案时,您可以选择几种定价选项,Azure 会不断增加它们,以确保您能够处理云中的数据。基本上,它们因您需要的计算能力而异。

例如,在数据库事务单位DTUs)模型中,费用基于已预留的数据库存储容量和由参考工作负载确定的 I/O 操作、CPU 使用率和内存使用率的线性组合。粗略地说,当您增加 DTUs 时,最大的数据库性能会线性增加。

图 9.2:创建 Azure SQL 数据库

您还可以通过启用读取扩展来配置数据复制。这样,您可以提高读取操作的性能。备份保留对于每个提供级别(基本、标准和高级)都是固定的。

如果您选择作为是否要使用 SQL 弹性池?的答案,数据库将被添加到弹性池中。添加到同一弹性池的数据库将共享其资源,因此未被数据库使用的资源可以在其他数据库的 CPU 使用高峰期间使用。值得一提的是,弹性池只能包含托管在同一服务器实例上的数据库。弹性池是优化资源使用以减少成本的有效方式。

NoSQL 数据库

关系数据库带来的最大挑战之一是与数据库结构模式更改相关的问题。本世纪初所需的变化的灵活性带来了使用新数据库样式的机会,称为 NoSQL。这里有几种类型的 NoSQL 数据库:

  • 面向文档的数据库:最常见的数据库类型,其中您有一个称为文档的键和复杂数据。

  • 图数据库:社交媒体倾向于使用这种类型的数据库,因为数据存储为图形。

  • 键值数据库:用于实现缓存的有用数据库,因为您有机会存储键值对。

  • 宽列存储数据库:每行中相同的列可以存储不同的数据。

在 NoSQL 数据库中,关系表被更一般的集合所取代,这些集合可以包含异构的 JSON 对象。也就是说,集合没有预定义的结构,也没有预定义的字段长度约束(对于字符串),但可以包含任何类型的对象。与每个集合关联的唯一结构约束是充当主键的属性的名称。

更具体地说,每个集合条目都可以包含嵌套对象和嵌套在对象属性中的对象集合,即在关系数据库中包含在不同表中并通过外部键连接的相关实体。在 NoSQL 中,数据库可以嵌套在其父实体中。由于集合条目包含复杂的嵌套对象而不是简单的属性/值对,因此条目不被称为元组或行,而是文档

无法在属于同一集合或不同集合的文档之间定义关系和/或外部键约束。如果文档在其属性中包含另一个文档的主键,那么它就自担风险。开发人员有责任维护和保持这些一致的引用。

最后,由于 NoSQL 存储相当便宜,整个二进制文件可以作为文档属性的值存储为 Base64 字符串。开发人员可以定义规则来决定在集合中索引哪些属性。由于文档是嵌套对象,属性是树路径。通常,默认情况下,所有路径都被索引,但您可以指定要索引的路径和子路径的集合。

NoSQL 数据库可以使用 SQL 的子集或基于 JSON 的语言进行查询,其中查询是 JSON 对象,其路径表示要查询的属性,其值表示已应用于它们的查询约束。

在关系数据库中,可以通过一对多关系来模拟在文档中嵌套子对象的可能性。但是,在关系数据库中,我们被迫重新定义所有相关表的确切结构,而 NoSQL 集合不对其包含的对象施加任何预定义的结构。唯一的约束是每个文档必须为主键属性提供唯一值。因此,当我们的对象结构非常可变时,NoSQL 数据库是唯一的选择。

然而,通常它们被选择是因为它们在扩展读写操作方面的性能优势,更一般地说,在分布式环境中的性能优势。它们的性能特性将在下一节中进行讨论,该节将它们与关系数据库进行比较。

图形数据模型是完全无结构文档的极端情况。整个数据库是一个图形,其中查询可以添加、更改和删除图形文档。

在这种情况下,我们有两种文档:节点和关系。虽然关系具有明确定义的结构(由关系连接的节点的主键加上关系的名称),但节点根本没有结构,因为在节点更新操作期间,属性及其值会被添加在一起。图形数据模型旨在表示人和他们操纵的对象(媒体、帖子等)以及它们在社交应用程序中的关系的特征。Gremlin 语言是专门为查询图形数据模型而设计的。我们不会在本章中讨论这一点,但在进一步阅读部分中有参考资料。

NoSQL 数据库将在本章的其余部分中进行详细分析,这些部分专门描述了 Azure Cosmos DB 并将其与关系数据库进行比较。

Redis

Redis是基于键值对的分布式并发内存存储,支持分布式排队。它可以用作永久的内存存储,以及数据库数据的 Web 应用程序缓存。或者,它可以用作预渲染内容的缓存。

Redis还可以用于存储 Web 应用程序的用户会话数据。事实上,ASP.NET Core支持会话数据,以克服HTTP协议是无状态的事实。更具体地说,保持在页面更改之间的用户数据存储在服务器端存储中,例如Redis,并由存储在cookies中的会话密钥索引。

与云中的Redis服务器的交互通常基于提供易于使用界面的客户端实现。.NET.NET Core的客户端可以通过StackExchange.Redis NuGet包获得。StackExchange.Redis客户端的基本操作已在stackexchange.github.io/StackExchange.Redis/Basics中记录,完整文档可以在stackexchange.github.io/StackExchange.Redis中找到。

Azure上定义Redis服务器的用户界面非常简单:

图 9.3:创建Redis缓存

定价层下拉菜单允许我们选择可用的内存/复制选项之一。可以在docs.microsoft.com/en-us/azure/azure-cache-for-redis/cache-dotnet-core-quickstart找到一个快速入门指南,该指南解释了如何在.NET Core客户端中使用Azure Redis凭据和URI

Azure存储账户

所有云都提供可扩展和冗余的通用磁盘内存,您可以将其用作虚拟机中的虚拟磁盘和/或外部文件存储。Azure存储账户磁盘空间也可以结构化为队列。如果您需要廉价的blob存储,可以考虑使用此选项。但是,正如我们之前提到的,还有更复杂的选项。根据您的情况,Azure NoSQL数据库比表更好,Azure RedisAzure存储队列更好。

图 9.4:创建存储账户

在本章的其余部分,我们将专注于NoSQL数据库以及它们与关系数据库的区别。接下来,我们将看看如何在两者之间进行选择。

在结构化或NoSQL存储之间进行选择

作为软件架构师,您可能会考虑结构化和NoSQL存储的一些方面,以决定最适合您的存储选项。在许多情况下,两者都是需要的。关键点在于您的数据有多有组织以及数据库将变得多大。

在前一节中,我们指出当数据几乎没有预定义的结构时,应优先选择NoSQL数据库。NoSQL数据库不仅使可变属性靠近其所有者,而且还使一些相关对象靠近,因为它们允许将相关对象嵌套在属性和集合中。

在关系数据库中可以表示非结构化数据,因为元组t的可变属性可以放在一个包含属性名称、属性值和t的外部键的连接表中。然而,在这种情况下的问题是性能。事实上,属于单个对象的属性值将分散在可用内存空间中。在小型数据库中,“分散在可用内存空间中”意味着远离但在同一磁盘上;在较大的数据库中,它意味着远离但在不同的磁盘单元中;在分布式云环境中,它意味着远离但在不同的 - 也可能是地理分布的 - 服务器中。

在 NoSQL 数据库设计中,我们总是试图将所有可能一起处理的相关对象放入单个条目中。访问频率较低的相关对象放在不同的条目中。由于外部键约束不会自动执行,而且 NoSQL 事务非常灵活,开发人员可以在性能和一致性之间选择最佳折衷方案。

因此,我们可以得出结论,当通常一起访问的表可以被存储在一起时,关系数据库的表现良好。另一方面,NoSQL 数据库会自动确保相关数据保持在一起,因为每个条目都将大部分相关数据作为嵌套对象保存在其中。因此,当它们分布到不同的内存和不同地理分布的服务器时,NoSQL 数据库的表现更好。

不幸的是,扩展存储写操作的唯一方法是根据分片键的值将集合条目分布到多个服务器上。例如,我们可以将所有以A开头的用户名记录放在一个服务器上,将以B开头的用户名记录放在另一个服务器上,依此类推。这样,具有不同起始字母的用户名的写操作可以并行执行,确保写吞吐量随着服务器数量的增加而线性增加。

然而,如果一个分片集合与其他几个集合相关联,就无法保证相关记录会被放在同一台服务器上。此外,将不同的集合放在不同的服务器上而不使用集合分片会使写吞吐量线性增加,直到达到单个服务器上的单个集合的限制,但这并不能解决被迫在不同服务器上执行多个操作以检索或更新通常一起处理的数据的问题。

这个问题对关系数据库的性能造成了灾难性的影响,如果访问相关的分布式对象必须是事务性的和/或必须确保结构约束(如外部键约束)不被违反。在这种情况下,所有相关的对象在事务期间必须被阻塞,防止其他请求在耗时的分布式操作的整个生命周期内访问它们。

NoSQL 数据库不会遇到这个问题,并且在分片和因此写扩展输出方面表现更好。这是因为它们不会将相关数据分布到不同的存储单元,而是将它们存储为同一数据库条目的嵌套对象。另一方面,它们遇到了不支持事务的不同问题。

值得一提的是,有些情况下关系数据库在分片时表现良好。一个典型的例子是多租户应用。在多租户应用中,所有条目集合可以被分成不重叠的集合,称为租户。只有属于同一个租户的条目才能相互引用,因此如果所有集合都按照它们的对象租户以相同的方式分片,那么所有相关记录最终都会在同一个分片中,也就是在同一个服务器上,并且可以被高效地导航。

多租户应用在云中并不罕见,因为所有为多个不同用户提供相同服务的应用通常都是作为多租户应用实现的,其中每个租户对应一个用户订阅。因此,关系数据库被设计为在云中工作,例如 Azure SQL Server,并通常为多租户应用提供分片选项。通常,分片不是云服务,必须使用数据库引擎命令来定义。在这里,我们不会描述如何使用 Azure SQL Server 定义分片,但进一步阅读部分包含了官方微软文档的链接。

总之,关系数据库提供了数据的纯逻辑视图,与实际存储方式无关,并使用声明性语言来查询和更新数据。这简化了开发和系统维护,但在需要写入扩展的分布式环境中可能会导致性能问题。在 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 帐户

您可以通过在 Azure 门户搜索栏中键入Cosmos DB并单击添加来定义 Cosmos DB 帐户。将出现以下页面:

图 9.5:创建 Azure Cosmos DB 帐户

您选择的帐户名称将在资源 URI 中用作{account_name}.documents.azure.comAPI下拉菜单可让您选择所需的接口类型(例如 SQL、MongoDB 或 Gremlin)。然后,您可以决定主数据库将放置在哪个数据中心,以及是否要启用地理分布式复制。启用地理分布式复制后,您可以选择要使用的副本数量以及放置它们的位置。

微软一直在改进其许多 Azure 服务。在撰写本书时,容量模式和笔记本的无服务器选项处于预览状态。了解任何 Azure 组件的新功能的最佳方法是不时查看其文档。

多区域写入切换允许您在地理分布的副本上启用写入。如果不这样做,所有写操作将被路由到主数据中心。最后,您还可以在创建过程中定义备份策略和加密。

创建 Azure Cosmos 容器

创建帐户后,选择Data Explorer来创建数据库和其中的容器。容器是预留吞吐量和存储的可扩展单位。

由于数据库只有名称而没有配置,您可以直接添加一个容器,然后将其放置在希望放置它的数据库中:

图 9.6:在 Azure Cosmos DB 中添加容器

在这里,您可以决定数据库和容器的名称以及用于分片的属性(分区键)。由于 NoSQL 条目是对象树,因此属性名称被指定为路径。您还可以添加值必须唯一的属性。

然而,唯一性 ID 在每个分片内进行检查,因此此选项仅在某些情况下有用,例如多租户应用程序(其中每个租户包含在单个分片中)。费用取决于您选择的集合吞吐量。

这是您需要将所有资源参数定位到您的需求的地方。吞吐量以每秒请求单位表示,其中每秒请求单位定义为执行每秒 1 KB 读取时的吞吐量。因此,如果选择预留数据库吞吐量选项,则所选的吞吐量将与整个数据库共享,而不是作为单个集合保留。

访问 Azure Cosmos 数据

创建 Azure Cosmos 容器后,您将能够访问数据。要获取连接信息,您可以选择Keys菜单。在那里,您将看到连接到您的应用程序的 Cosmos DB 帐户所需的所有信息。连接信息页面将为您提供帐户 URI 和两个连接密钥,这两个密钥可以互换使用以连接到帐户。

图 9.7:连接信息页面

还有具有只读权限的密钥。每个密钥都可以重新生成,每个帐户都有两个等效的密钥,就像许多其他 Azure 组件一样。这种方法使操作能够有效地处理;也就是说,当一个密钥被更改时,另一个密钥被保留。因此,在升级到新密钥之前,现有应用程序可以继续使用另一个密钥。

定义数据库一致性

考虑到您处于分布式数据库的上下文中,Azure Cosmos DB 使您能够定义您将拥有的默认读一致性级别。通过在 Cosmos DB 帐户的主菜单中选择默认一致性,您可以选择要应用于所有容器的默认复制一致性。

可以在数据资源管理器或以编程方式中覆盖每个容器的默认设置。读/写操作中的一致性问题是数据复制的结果。具体来说,如果读操作在接收到不同部分更新的不同副本上执行,则各种读操作的结果可能不一致。

以下是可用的一致性级别。这些级别已经按从最弱到最强的顺序排列:

  • 最终一致性:足够的时间过去后,如果没有进一步的写操作,所有读取将收敛并应用所有写操作。写入的顺序也不能保证,因此在处理写入时,您可能会读取先前读取的较早版本。

  • 一致性前缀:所有写操作在所有副本上以相同的顺序执行。因此,如果有n个写操作,每次读取都与应用前m个写操作的结果一致,其中m小于或等于n

  • 会话:这与一致性前缀相同,但还保证每个写入者在所有后续读取操作中看到其自己写入的结果,并且每个读取者的后续读取是一致的(要么是相同的数据库,要么是更新的版本)。

  • 有界陈旧性:这与延迟时间Delta或多个操作N相关联。每次读取都会看到在时间Delta(或最后N次操作)之前执行的所有写操作的结果。也就是说,它的读取与最大时间延迟Delta(或最大操作延迟N)的所有写操作的结果收敛。

  • 强一致性:这是有界陈旧性与Delta = 0相结合。在这里,每次读取都反映了所有先前的写操作的结果。

最强的一致性可以通过牺牲性能来获得。默认情况下,一致性设置为Session,这是一致性和性能之间的良好折衷。较低级别的一致性在应用程序中很难处理,通常只有在会话是只读或只写时才可接受。

如果您在数据库容器的Data Explorer菜单中选择Settings选项,您可以配置要对哪些路径进行索引以及对每个路径的每种数据类型应用哪种类型的索引。配置由 JSON 对象组成。让我们分析其各种属性:

{
    "indexingMode": "consistent",
    "automatic": true,
    ... 

如果将indexingMode设置为none而不是consistent,则不会生成索引,并且集合可以用作由集合主键索引的键值字典。在这种情况下,不会生成次要索引,因此无法有效地进行搜索。当automatic设置为true时,所有文档属性都会自动索引:

{
    ...
    "includedPaths": [
        {
            "path": "/*",
            "indexes": [
                {
                    "kind": "Range",
                    "dataType": "Number",
                    "precision": -1
                },
                {
                    "kind": "Range",
                    "dataType": "String",
                    "precision": -1
                },
                {
                    "kind": "Spatial",
                    "dataType": "Point"
                }
            ]
        }
    ]
},
... 

IncludedPaths中的每个条目都指定了一个路径模式,例如/subpath1/subpath2/?(设置仅适用于/subpath1/subpath2/property)或/subpath1/subpath2/*(设置适用于以/subpath1/subpath2/开头的所有路径)。

当需要将设置应用于集合属性中包含的子对象时,模式包含[]符号;例如,/subpath1/subpath2/[]/?/subpath1/subpath2/[]/childpath1/?等。设置指定要应用于每种数据类型(字符串、数字、地理点等)的索引类型。范围索引用于比较操作,而哈希索引在需要进行相等比较时更有效。

可以指定精度,即在所有索引键中使用的最大字符或数字的数量。-1表示最大精度,始终建议使用:

 ...
    "excludedPaths": [
   {
            "path": "/\"_etag\"/?"
        }
    ] 

excludedPaths中包含的路径根本不被索引。索引设置也可以以编程方式指定。

在这里,您有两种连接到 Cosmos DB 的选项:使用首选编程语言的官方客户端的版本,或者使用 Cosmos DB 的 Entity Framework Core 提供程序。在接下来的小节中,我们将看看这两个选项。然后,我们将描述如何使用 Cosmos DB 的 Entity Framework Core 提供程序,并提供一个实际示例。

Cosmos DB 客户端

.NET 5 的 Cosmos DB 客户端可通过Microsoft.Azure.Cosmos NuGet 包获得。它提供了对所有 Cosmos DB 功能的完全控制,而 Cosmos DB Entity Framework 提供程序更易于使用,但隐藏了一些 Cosmos DB 的特殊性。按照以下步骤通过.NET 5 的官方 Cosmos DB 客户端与 Cosmos DB 进行交互。

以下代码示例显示了使用客户端组件创建数据库和容器。任何操作都需要创建客户端对象。不要忘记,当您不再需要它时,必须通过调用其Dispose方法(或将引用它的代码封装在using语句中)来处理客户端:

 public static async Task CreateCosmosDB()
{
    using var cosmosClient = new CosmosClient(endpoint, key);
    Database database = await 
        cosmosClient.CreateDatabaseIfNotExistsAsync(databaseId);
    ContainerProperties cp = new ContainerProperties(containerId,
        "/DestinationName");
    Container container = await database.CreateContainerIfNotExistsAsync(cp);
    await AddItemsToContainerAsync(container);
} 

在创建集合时,可以传递ContainerProperties对象,其中可以指定一致性级别、如何对属性进行索引以及所有其他集合功能。

然后,您必须定义与您需要在集合中操作的 JSON 文档结构相对应的.NET 类。如果它们不相等,您还可以使用JsonProperty属性将类属性名称映射到 JSON 名称:

public class Destination
{
    [JsonProperty(PropertyName = "id")]
    public string Id { get; set; }
    public string DestinationName { get; set; }
    public string Country { get; set; }
    public string Description { get; set; }
    public Package[] Packages { get; set; }
} 

一旦您拥有所有必要的类,您可以使用客户端方法ReadItemAsyncCreateItemAsyncDeleteItemAsync。您还可以使用接受 SQL 命令的QueryDefinition对象来查询数据。您可以在docs.microsoft.com/en-us/azure/cosmos-db/sql-api-get-started找到有关此库的完整介绍。

Cosmos DB Entity Framework Core 提供程序

Entity Framework Core 的 Cosmos DB 提供程序包含在Microsoft.EntityFrameworkCore.Cosmos NuGet 包中。一旦将其添加到项目中,您可以以类似的方式进行操作,就像在第八章中使用 SQL Server 提供程序时一样,但有一些不同之处。让我们看看:

  • 由于 Cosmos DB 数据库没有结构需要更新,因此没有迁移。相反,它们有一种方法可以确保数据库以及所有必要的集合被创建:
context.Database.EnsureCreated(); 
  • 默认情况下,从DBContext映射到唯一容器的DbSet<T>属性,因为这是最便宜的选项。您可以通过显式指定要将某些实体映射到哪个容器来覆盖此默认设置,方法是使用以下配置指令:
builder.Entity<MyEntity>()
     .ToContainer("collection-name"); 
  • 实体类上唯一有用的注释是Key属性,当主键不叫Id时,它就变得强制性了。

  • 主键必须是字符串,不能自动增加以避免在分布式环境中出现同步问题。主键的唯一性可以通过生成 GUID 并将其转换为字符串来确保。

  • 在定义实体之间的关系时,您可以指定一个实体或实体集合是由另一个实体拥有的,这种情况下它将与父实体一起存储。

我们将在下一节中查看 Cosmos DB 的 Entity Framework 提供程序的用法。

用例-存储数据

现在我们已经学会了如何使用 NoSQL,我们必须决定 NoSQL 数据库是否适合我们的书籍使用案例 WWTravelClub 应用程序。我们需要存储以下数据系列:

  • 有关可用目的地和套餐的信息:此数据的相关操作是读取,因为套餐和目的地不经常更改。但是,它们必须尽可能快地从世界各地访问,以确保用户在浏览可用选项时有愉快的体验。因此,可能存在具有地理分布副本的分布式关系数据库,但并非必需,因为套餐可以存储在更便宜的 NoSQL 数据库中。

  • 目的地评论:在这种情况下,分布式写操作会产生不可忽略的影响。此外,大多数写入都是添加,因为评论通常不会更新。添加受益于分片,并且不像更新那样会导致一致性问题。因此,这些数据的最佳选择是 NoSQL 集合。

  • 预订:在这种情况下,一致性错误是不可接受的,因为它们可能导致超额预订。读取和写入具有可比较的影响,但我们需要可靠的事务和良好的一致性检查。幸运的是,数据可以组织在一个多租户数据库中,其中租户是目的地,因为属于不同目的地的预订信息是完全不相关的。因此,我们可以使用分片的 SQL Azure 数据库实例。

总之,第一和第二个要点的数据的最佳选择是 Cosmos DB,而第三个要点的最佳选择是 Azure SQL Server。实际应用可能需要对所有数据操作及其频率进行更详细的分析。在某些情况下,值得为各种可能的选项实施原型,并在所有选项上使用典型工作负载执行性能测试。

在本节的其余部分,我们将迁移我们在第八章 与 C#中的数据交互-Entity Framework Core中查看的目的地/套餐数据层到 Cosmos DB。

使用 Cosmos DB 实现目的地/套餐数据库

让我们继续按照以下步骤将我们在第八章 与 C#中的数据交互-Entity Framework Core中构建的数据库示例迁移到 Cosmos DB:

  1. 首先,我们需要复制 WWTravelClubDB 项目,并将WWTravelClubDBCosmo作为新的根文件夹。

  2. 打开项目并删除迁移文件夹,因为不再需要迁移。

  3. 我们需要用 Cosmos DB 提供程序替换 SQL Server Entity Framework 提供程序。为此,请转到管理 NuGet 包并卸载Microsoft.EntityFrameworkCore.SqlServer NuGet 包。然后,安装Microsoft.EntityFrameworkCore.Cosmos NuGet 包。

  4. 然后,在DestinationPackage实体上执行以下操作:

  • 删除所有数据注释。

  • 为它们的 Id 属性添加 [Key] 属性,因为这对于 Cosmos DB 提供程序是强制性的。

  • PackageDestinationId 属性的类型,以及 PackagesListDTO 类从 int 转换为 string。我们还需要将 PackagePackagesListDTO 类中的 DestinationId 外部引用转换为 string。实际上,在分布式数据库中,使用 GUID 生成的字符串作为键是最佳选择,因为在表数据分布在多个服务器之间时,很难维护标识计数器。

  1. MainDBContext 文件中,我们需要指定与目的地相关的包必须存储在目的地文档本身内。这可以通过在 OnModelCreatingmethod 方法中替换 Destination-Package 关系配置来实现,代码如下:
builder.Entity<Destination>()
    .OwnsMany(m =>m.Packages); 
  1. 在这里,我们必须用 OwnsMany 替换 HasMany。没有等效于 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 databaseName = "packagesdb";
        public "MainDBContext CreateDbContext"(params string[] args)
        {
            var builder = new DbContextOptionsBuilder<Main
DBContext>();
builder.UseCosmos(endpoint, key, databaseName);
            return new MainDBContext(builder.Options);
        }
    }
} 
  1. 最后,在我们的测试控制台中,我们必须明确使用 GUID 创建所有实体主键:
var context = new LibraryDesignTimeDbContextFactory()
    .CreateDbContext();
context.Database.EnsureCreated();
var firstDestination = new Destination
{
    Id = Guid.NewGuid().ToString(),
    Name = "Florence",
    Country = "Italy",
    Packages = new List<Package>()
    {
    new Package
    {
        Id=Guid.NewGuid().ToString(),
        Name = "Summer in Florence",
        StartValidityDate = new DateTime(2019, 6, 1),
        EndValidityDate = new DateTime(2019, 10, 1),
        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
    }
    }
}; 
  1. 在这里,我们调用 context.Database.EnsureCreated() 而不是应用迁移,因为我们只需要创建数据库。一旦数据库和集合被创建,我们可以从 Azure 门户微调它们的设置。希望未来版本的 Cosmos DB Entity Framework Core 提供程序将允许我们指定所有集合选项。

  2. 最后,以 context.Packages.Where... 开头的最终查询必须进行修改,因为查询不能以嵌套在其他文档中的实体(在我们的情况下是 Packages 实体)开头。因此,我们必须从我们的 DBContext 中唯一的根 DbSet<T> 属性开始查询,即 Destinations。我们可以通过 SelectMany 方法从列出外部集合转到列出所有内部集合,该方法执行所有嵌套 Packages 集合的逻辑合并。但是,由于 CosmosDB SQL 不支持 SelectMany,我们必须强制在客户端上模拟 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 帐户。您应该看到类似以下内容的内容:

图 9.8:执行结果

根据要求,包已嵌套在其目的地内,并且 Entity Framework Core 创建了一个与 DBContext 类同名的唯一集合。

如果您想继续尝试 Cosmos DB 开发而不浪费所有免费的 Azure 门户信用,您可以安装位于以下链接的 Cosmos DB 模拟器:aka.ms/cosmosdb-emulator

总结

在本章中,我们了解了 Azure 中可用的主要存储选项,并学会了何时使用它们。然后,我们比较了关系数据库和 NoSQL 数据库。我们指出,关系数据库提供自动一致性检查和事务隔离,但 NoSQL 数据库更便宜,性能更好,特别是在分布式写入占平均工作负载的高比例时。

然后,我们描述了 Azure 的主要 NoSQL 选项 Cosmos DB,并解释了如何配置它以及如何与客户端连接。

最后,我们学习了如何使用实体框架核心与 Cosmos DB 进行交互,并查看了基于 WWTravelClubDB 用例的实际示例。在这里,我们学习了如何在应用程序中涉及的所有数据族之间决定关系和 NoSQL 数据库之间的选择。这样,您可以选择确保在每个应用程序中数据一致性、速度和并行访问之间取得最佳折衷的数据存储方式。

在下一章中,我们将学习有关无服务器和 Azure 函数的所有内容。

问题

  1. Redis 是否是关系数据库的有效替代品?

  2. NoSQL 数据库是否是关系数据库的有效替代品?

  3. 在关系数据库中,哪种操作更难扩展?

  4. NoSQL 数据库的主要弱点是什么?它们的主要优势是什么?

  5. 您能列出所有 Cosmos DB 的一致性级别吗?

  6. 我们可以在 Cosmos DB 中使用自增整数键吗?

  7. 哪种实体框架配置方法用于将实体存储在其相关的父文档中?

  8. 在 Cosmos DB 中,可以有效地搜索嵌套集合吗?

进一步阅读

第十章:使用 Azure Functions

正如我们在第四章中提到的,无服务器架构是提供灵活软件解决方案的最新方式之一。为此,Microsoft Azure 提供了 Azure Functions,这是一种事件驱动、无服务器且可扩展的技术,可以加速您的项目开发。本章的主要目标是让您熟悉 Azure Functions 以及在使用它时可以实施的最佳实践。值得一提的是,使用 Azure Functions 是一个很好的选择,可以加速您的开发,为您提供无服务器实现的替代方案。借助它们,您可以更快地部署 API,启用由定时器触发的服务,甚至通过接收存储事件来触发流程。

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

  • 了解 Azure Functions 应用程序

  • 使用 C#编程 Azure Functions

  • 维护 Azure Functions

  • 用例-实现 Azure Functions 发送电子邮件

通过本章结束时,您将了解如何使用 C#中的 Azure Functions 来加快开发周期。

技术要求

本章要求您具备以下条件:

  • Visual Studio 2019 免费社区版或更高版本,所有 Azure 工具都已安装。

  • 一个免费的 Azure 账户。第一章创建 Azure 账户部分解释了如何创建。

您可以在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5/tree/master/ch10找到本章的示例代码。

了解 Azure Functions 应用程序

Azure Functions 应用程序是 Azure PaaS,您可以在其中构建代码片段(函数),并将它们连接到您的应用程序,并使用触发器启动它们。这个概念非常简单-您可以用您喜欢的语言编写函数,并决定启动它的触发器。您可以在系统中编写尽可能多的函数。有些情况下,整个系统都是用函数编写的。

创建必要环境的步骤与创建函数本身的步骤一样简单。以下屏幕截图显示了创建环境时必须决定的参数。在 Azure 中选择创建资源并按Function App进行筛选,然后单击创建按钮,您将看到以下屏幕:

图 10.1:创建 Azure 函数

在创建 Azure Functions 环境时,有几个关键点需要考虑。随着时间的推移,运行函数的可能性不断增加,编程语言选项和发布样式也在不断增加。我们最重要的配置之一是托管计划,这是您运行函数的地方。托管计划有三个选项:消耗(无服务器)、高级和应用服务计划。现在让我们来谈谈这些。

消耗计划

如果您选择消耗计划,您的函数只会在执行时消耗资源。这意味着只有在函数运行时才会收费。可扩展性和内存资源将由 Azure 自动管理。这确实是我们所说的无服务器。

在编写此计划中的函数时,我们需要注意超时。默认情况下,函数将在 5 分钟后超时。您可以使用host.json文件中的functionTimeout参数更改超时值。最大值为 10 分钟。

当您选择消耗计划时,您将被收取费用的方式取决于您执行的内容、执行时间和内存使用情况。有关更多信息,请访问azure.microsoft.com/en-us/pricing/details/functions/

请注意,当您的环境中没有应用服务,并且您正在运行低周期性的函数时,这可能是一个不错的选择。另一方面,如果您需要持续处理,您可能需要考虑应用服务计划。

高级计划

根据您使用函数的方式,特别是如果它们需要持续运行或几乎持续运行,或者如果某些函数执行时间超过 10 分钟,您可能需要考虑使用高级计划。此外,您可能需要将函数连接到 VNET/VPN 环境,在这种情况下,您将被迫在此计划中运行。

您可能还需要比消耗计划提供的更多 CPU 或内存选项。高级计划为您提供了一个核心、两个核心和四个核心的实例选项。

值得一提的是,即使您有无限的时间来运行函数,如果您决定使用 HTTP 触发函数,响应请求的最大允许时间为 230 秒。这个限制的原因与 Azure 负载均衡器有关。在这种情况下,您可能需要重新设计您的解决方案,以符合 Microsoft 设置的最佳实践(docs.microsoft.com/en-us/azure/azure-functions/functions-best-practices)。

应用服务计划

应用服务计划是您在创建 Azure 函数应用时可以选择的选项之一。以下是一些(由 Microsoft 建议的)您应该使用应用服务计划而不是消耗计划来维护函数的原因列表:

  • 您可以使用未充分利用的现有应用服务实例。

  • 您想在自定义镜像上运行函数应用。

在应用服务计划方案中,functionTimeout值根据 Azure 函数运行时版本而变化。但是,该值至少为 30 分钟。您可以在docs.microsoft.com/en-us/azure/azure-functions/functions-scale#timeout找到每个消耗计划中超时的表格比较。

使用 C#编程 Azure 函数

在本节中,您将学习如何创建 Azure 函数。值得一提的是,有几种使用 C#创建函数的方法。第一种方法是在 Azure 门户中创建函数并在其中开发它们。为此,让我们假设您已经创建了一个 Azure 函数应用,并且配置与本章开头的屏幕截图类似。

通过选择创建的资源并导航到函数菜单,您将能够在此环境中添加新的函数,如下面的屏幕截图所示:

图 10.2:添加函数

在这里,您需要决定要使用的触发器类型来启动执行。最常用的是HTTP 触发器定时器触发器。第一个可以创建一个将触发函数的 HTTP API。第二个意味着函数将由根据您的决定设置的定时器触发。

当您决定要使用的触发器时,您必须为函数命名。根据您决定的触发器,您将不得不设置一些参数。例如,HTTP 触发器要求您设置授权级别。有三个选项可用,即函数匿名管理员

图 10.3:配置 HTTP 函数

值得一提的是,本书并未涵盖在构建函数时可用的所有选项。作为软件架构师,您应该了解 Azure 在函数方面提供了良好的无服务器架构服务。这在几种情况下都可能很有用。这在第四章决定最佳基于云的解决方案中有更详细的讨论。

其结果如下。请注意,Azure 提供了一个编辑器,允许我们运行代码,检查日志,并测试我们创建的函数。这是一个用于测试和编写基本函数的良好界面:

图 10.4:HTTP 函数环境

然而,如果您想创建更复杂的函数,您可能需要一个更复杂的环境,以便您可以更有效地编写和调试它们。这就是 Visual Studio Azure 函数项目可以帮助您的地方。此外,使用 Visual Studio 执行函数的开发将使您朝着为函数使用源代码控制和 CI/CD 的方向迈进。

在 Visual Studio 中,您可以通过转到创建新项目来创建一个专用于 Azure 函数的项目:

图 10.5:在 Visual Studio 2019 中创建 Azure 函数项目

提交项目后,Visual Studio 将询问您正在使用的触发器类型以及您的函数将在哪个 Azure 版本上运行:

图 10.6:创建新的 Azure 函数应用程序

值得一提的是,Azure 函数支持不同的平台和编程语言。在撰写本文时,Azure 函数有三个运行时版本,C#可以在所有这些版本中运行。第一个版本兼容.NET Framework 4.7。在第二个版本中,您可以创建在.NET Core 2.2 上运行的函数。在第三个版本中,您将能够运行.NET Core 3.1 和.NET 5。

作为软件架构师,您必须牢记代码的可重用性。在这种情况下,您应该注意选择在哪个版本的 Azure 函数项目中构建您的函数。然而,建议您始终使用最新版本的运行时,一旦它获得一般可用性状态。

默认情况下,生成的代码与在 Azure 门户中创建 Azure 函数时生成的代码相似:

using System;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
namespace FunctionAppSample
{
    public static class FunctionTrigger
    {
        [FunctionName("FunctionTrigger")]
        public static void Run([TimerTrigger("0 */5 * * * *")]
            TimerInfo myTimer, ILogger log)
        {
             log.LogInformation($"C# Timer trigger function " +
                 $"executed at: {DateTime.Now}");
        }
    }
} 

发布方法遵循与我们在《第一章》理解软件架构的重要性中描述的 Web 应用程序的发布过程相同的步骤。然而,建议始终使用 CI/CD 管道,正如我们将在《第二十章》理解 DevOps 原则中描述的那样。

列出 Azure 函数模板

Azure 门户中有几个模板可供您使用以创建 Azure 函数。您可以不断更新可选择的模板数量。以下只是其中的一些:

  • Blob 触发器:您可能希望在文件上传到 blob 存储时立即处理某些内容。这可以是 Azure 函数的一个很好的用例。

  • Cosmos DB 触发器:您可能希望将到达 Cosmos DB 数据库的数据与处理方法同步。Cosmos DB 在《第九章》如何选择云中的数据存储中有详细讨论。

  • 事件网格触发器:这是管理 Azure 事件的一种好方法。函数可以被触发以便它们管理每个事件。

  • 事件中心触发器:使用此触发器,您可以构建与将数据发送到 Azure 事件中心的任何系统相关联的函数。

  • HTTP 触发器:此触发器对于构建无服务器 API 和 Web 应用程序事件非常有用。

  • IoT Hub 触发器:当您的应用程序通过 IoT Hub 与设备连接时,您可以在设备中收到新事件时使用此触发器。

  • 队列触发器:您可以使用函数作为服务解决方案来处理队列处理。

  • 服务总线队列触发器:这是另一个可以成为函数触发器的消息传递服务。Azure 服务总线将在《第十一章》设计模式和.NET 5 实现中进行更详细的介绍。

  • 定时器触发器:这通常与函数一起使用,您可以在其中指定时间触发器,以便可以持续处理来自系统的数据。

维护 Azure 函数

创建和编程函数后,您需要监视和维护它。为此,您可以使用各种工具,所有这些工具都可以在 Azure 门户中找到。这些工具将帮助您解决问题,因为您将能够收集大量信息。

在监视函数时的第一个选项是在 Azure 门户中的 Azure 函数界面内使用“监视”菜单。在那里,您将能够检查所有函数执行,包括成功的结果和失败的结果:

图 10.7:监控函数

任何结果可用需要大约 5 分钟。网格中显示的日期是 UTC 时间。

通过单击“在 Application Insights 中运行查询”,相同的界面允许您连接到此工具。这将带您进入一个几乎无限的选项世界,您可以使用它来分析您的函数数据。Application Insights 是当今最好的“应用程序性能管理”(APM)系统之一:

图 10.8:使用 Application Insights 进行监控

除了查询界面,您还可以使用 Azure 门户中的 Insights 界面检查函数的所有性能问题。在那里,您可以分析和过滤已收到的所有请求,并检查它们的性能和依赖关系。当您的一个端点发生异常时,您还可以触发警报:

图 10.9:使用 Application Insights 实时指标监控

作为软件架构师,您会发现这个工具对您的项目是一个很好的日常助手。值得一提的是,Application Insights 还适用于其他几个 Azure 服务,例如 Web 应用程序和虚拟机。这意味着您可以使用 Azure 提供的出色功能来监视系统的健康状况并进行维护。

用例 - 实现 Azure 函数发送电子邮件

在这里,我们将使用我们之前描述的 Azure 组件的子集。WWTravelClub 的用例提出了该服务的全球实施,并且有可能该服务将需要不同的架构设计来应对我们在第一章“理解软件架构的重要性”中描述的所有性能关键点。

如果您回顾一下在第一章“理解软件架构的重要性”中描述的用户故事,您会发现许多需求与通信有关。因此,在解决方案中通常会通过电子邮件提供一些警报。本章的用例将重点介绍如何发送电子邮件。该架构将完全无服务器。

以下图表显示了架构的基本结构。为了给用户带来良好的体验,应用程序发送的所有电子邮件都将以异步方式排队,从而防止系统响应出现显着延迟:

图 10.10:发送电子邮件的架构设计

请注意,没有服务器管理 Azure 函数来对 Azure 队列存储中的消息进行入队或出队操作。这正是我们所说的无服务器。值得一提的是,这种架构不仅限于发送电子邮件 - 它也可以用于处理任何 HTTPPOST请求。

现在,我们将学习如何在 API 中设置安全性,以便只有经过授权的应用程序可以使用给定的解决方案。

第一步 - 创建 Azure 队列存储

在 Azure 门户中创建存储非常简单。让我们来学习如何操作。首先,您需要通过单击 Azure 门户主页上的创建资源来创建一个存储账户,并搜索存储账户。然后,您可以设置其基本信息,如存储账户名称位置。此向导还可以检查有关网络数据保护的信息,如下图所示。这些设置有默认值,将覆盖演示:

图 10.11:创建 Azure 存储账户

一旦您设置好存储账户,您就可以设置一个队列。您可以通过单击存储账户中的概述链接并选择队列选项,或者通过存储账户菜单选择队列来找到此选项。然后,您将找到一个添加队列的选项(+队列),您只需要提供其名称即可:

图 10.12:定义监视电子邮件的队列

创建的队列将在 Azure 门户中为您提供概览。在那里,您将找到您的队列的 URL 并使用 Storage Explorer:

图 10.13:创建的队列

请注意,您还可以使用 Microsoft Azure Storage Explorer 连接到此存储(azure.microsoft.com/en-us/features/storage-explorer/):

图 10.14:使用 Microsoft Azure Storage Explorer 监视队列

如果您没有连接到 Azure 门户,此工具尤其有用。

第二步 - 创建发送电子邮件的函数

现在,您可以认真开始编程,通知队列等待发送电子邮件。在这里,我们需要使用 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 connectionString = Environment.GetEnvironmentVariable("AzureQueueStorage");
        var storageAccount = CloudStorageAccount.Parse(connectionString);
        var queueClient = storageAccount.CreateCloudQueueClient();
        var messageQueue = queueClient.GetQueueReference("email");
        var message = new CloudQueueMessage(requestData);
        await messageQueue.AddMessageAsync(message);
        log.LogInformation("HTTP trigger from SendEmail function processed a request.");
        var responseObj = new { success = true };
        return new HttpResponseMessage(HttpStatusCode.OK)
        {
            Content = new StringContent(JsonConvert.SerializeObject(responseObj), Encoding.UTF8, "application/json"),
         };
    }
} 

在某些情况下,您可以尝试避免使用前面代码中指示的队列设置,而是使用队列输出绑定。在docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue-output?tabs=csharp上查看详细信息。

您可以使用诸如 Postman 之类的工具通过运行 Azure Functions 模拟器来测试函数:

图 10.15:Postman 函数测试

结果将出现在 Microsoft Azure Storage Explorer 和 Azure 门户中。在 Azure 门户中,您可以管理每条消息并出列每条消息,甚至清除队列存储:

图 10.16:HTTP 触发器和队列存储测试

第三步 - 创建队列触发函数

之后,您可以创建第二个函数。这个函数将由进入队列的数据触发。值得一提的是,对于 Azure Functions v3,您将自动将Microsoft.Azure.WebJobs.Extensions.Storage库添加为 NuGet 引用:

图 10.17:创建队列触发

一旦您在local.settings.json中设置了连接字符串,您就可以运行这两个函数并使用 Postman 进行测试。不同之处在于,如果第二个函数正在运行,如果您在其开头设置断点,您将检查消息是否已发送:

图 10.18:在 Visual Studio 2019 中触发队列

从这一点开始,发送电子邮件的方式将取决于您拥有的邮件选项。您可以决定使用代理或直接连接到您的电子邮件服务器。

以这种方式创建电子邮件服务有几个优势:

  • 一旦您的服务已编码并经过测试,您就可以使用它从任何应用程序发送电子邮件。这意味着您的代码可以始终被重用。

  • 使用此服务的应用程序不会因为在 HTTP 服务中发布异步优势而停止发送电子邮件。

  • 不需要池化队列来检查数据是否准备好进行处理。

最后,队列进程并发运行,这在大多数情况下提供了更好的体验。可以通过在host.json中设置一些属性来关闭它。所有这些选项都可以在本章末尾的进一步阅读部分找到。

总结

在本章中,我们看了一些使用无服务器 Azure 函数开发功能的优势。您可以将其用作检查 Azure Functions 中可用的不同类型触发器和计划如何监视它们的指南。我们还看到了如何编程和维护 Azure 函数。最后,我们看了一个架构示例,其中您可以连接多个函数以避免池化数据并实现并发处理。

在下一章中,我们将分析设计模式的概念,了解它们为什么如此有用,并了解一些常见模式。

问题

  1. Azure 函数是什么?

  2. Azure 函数的编程选项是什么?

  3. 可以与 Azure 函数一起使用的计划是什么?

  4. 如何使用 Visual Studio 部署 Azure 函数?

  5. 可以使用哪些触发器来开发 Azure 函数?

  6. Azure Functions v1、v2 和 v3 有什么区别?

  7. 应用程序洞察如何帮助我们维护和监视 Azure 函数?

进一步阅读

如果您想了解有关创建 Azure 函数的更多信息,请查看以下链接:

第十一章:设计模式和.NET 5 实现

设计模式可以被定义为常见问题的现成架构解决方案,在软件开发过程中遇到这些问题是必不可少的。它们对于理解.NET Core 架构至关重要,并且对于解决我们在设计任何软件时面临的普通问题非常有用。在本章中,我们将看一些设计模式的实现。值得一提的是,本书并未解释我们可以使用的所有已知模式。重点在于解释学习和应用它们的重要性。

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

  • 理解设计模式及其目的

  • 了解.NET 5 中可用的设计模式

在本章结束时,您将学习到一些可以用设计模式实现的WWTravelClub的用例。

技术要求

要完成本章,您需要免费的 Visual Studio 2019 社区版或更高版本,安装了所有数据库工具,以及一个免费的 Azure 账户。第一章理解软件架构的重要性中的创建 Azure 账户小节解释了如何创建账户。

您可以在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5找到本章的示例代码。

理解设计模式及其目的

决定系统设计是具有挑战性的,与此任务相关的责任是巨大的。作为软件架构师,我们必须始终牢记,诸如良好的可重用性、良好的性能和良好的可维护性等功能对于提供良好的解决方案至关重要。这就是设计模式帮助并加速设计过程的地方。

正如我们之前提到的,设计模式是已经讨论和定义的解决常见软件架构问题的解决方案。这种方法在《设计模式-可复用面向对象软件的元素》一书发布后变得越来越受欢迎,四人帮GoF)将这些模式分为三种类型:创建型、结构型和行为型。

稍后,Bob 大叔向开发者社区介绍了 SOLID 原则,使我们有机会有效地组织每个系统的函数和数据结构。SOLID 设计原则指示软件组件应该如何设计和连接。值得一提的是,与 GoF 提出的设计模式相比,SOLID 原则并不提供代码配方。相反,它们给出了在设计解决方案时要遵循的基本原则,保持软件结构的强大和可靠。它们可以被定义如下:

  • 单一职责原则:一个模块或函数应该负责一个单一的目的

  • 开闭原则:软件构件应该对扩展开放,但对修改关闭

  • 里氏替换原则:当你用一个由原始对象的超类型定义的另一个组件替换一个组件时,程序的行为需要保持不变

  • 接口隔离原则:创建庞大的接口会导致依赖关系的发生,而在构建具体对象时,这对系统架构是有害的

  • 依赖倒置原则:最灵活的系统是那些对象依赖仅指向抽象的系统

随着技术和软件问题的变化,会产生更多的模式。云计算的发展带来了大量模式,所有这些模式都可以在docs.microsoft.com/en-us/azure/architecture/patterns/找到。新模式出现的原因与我们在开发新解决方案时面临的挑战有关。今天,可用性、数据管理、消息传递、监控、性能、可伸缩性、弹性和安全性是我们在交付云解决方案时必须处理的方面。

你应该始终考虑使用设计模式的原因非常简单——作为软件架构师,你不能花时间重新发明轮子。然而,使用和理解它们的另一个很好的原因是:你会发现许多这些模式已经在.NET 5 中实现了。

在接下来的几个小节中,我们将介绍一些最著名的模式。然而,本章的目的是让你知道它们的存在,并且需要学习它们,以便加速和简化你的项目。此外,每个模式都将以 C#代码片段的形式呈现,以便你可以在你的项目中轻松实现它们。

建造者模式

有些情况下,你会有一个由于其配置而具有不同行为的复杂对象。你可能希望将该对象的配置与其使用分离,使用已经构建好的自定义配置。这样,你就有了正在构建的实例的不同表示。这就是你应该使用建造者模式的地方。

以下的类图显示了为本书使用案例中的场景实现的模式。这个设计选择背后的想法是简化对 WWTravelClub 房间的描述方式:

图 11.1:建造者模式

如下面的代码所示,这个代码是以一种不在主程序中设置实例的配置的方式实现的。相反,你只需使用Build()方法构建对象。这个例子模拟了在 WWTravelClub 中创建不同房间样式(单人房和家庭房)的过程:

using DesignPatternsSample.BuilderSample;
using System;
namespace DesignPatternsSample
{
    class Program
    {
        static void Main()
        {
          #region Builder Sample
          Console.WriteLine("Builder Sample");
          var simpleRoom = new SimpleRoomBuilder().Build();
          simpleRoom.Describe();

          var familyRoom = new FamilyRoomBuilder().Build();
          familyRoom.Describe();
          #endregion
          Console.ReadKey();
        }
    }
} 

这个实现的结果非常简单,但澄清了为什么需要实现模式:

图 11.2:建造者模式示例结果

一旦你有了实现,进化这段代码就变得更简单、更容易。例如,如果你需要构建不同风格的房间,你只需为该类型的房间创建一个新的建造者,然后你就可以使用它了。

这个实现变得非常简单的原因与在Room类中使用链式方法有关:

 public class Room
    {
        private readonly string _name;
        private bool wiFiFreeOfCharge;
        private int numberOfBeds;
        private bool balconyAvailable;
        public Room(string name)
        {
            _name = name;
        }
        public Room WithBalcony()
        {
            balconyAvailable = true;
            return this;
        }
        public Room WithBed(int numberOfBeds)
        {
            this.numberOfBeds = numberOfBeds;
            return this;
        }
        public Room WithWiFi()
        {
            wiFiFreeOfCharge = true;
            return this;
        }
    ...
    } 

幸运的是,如果需要增加产品的配置设置,之前使用的所有具体类都将在建造者接口中定义并存储在那里,以便你可以轻松更新它们。

我们还将在.NET 5 中看到建造者模式的一个很好的实现,在了解.NET 5 中可用的设计模式部分。在那里,你将能够了解如何使用HostBuilder实现了通用主机。

工厂模式

工厂模式在有多个来自相同抽象的对象,并且在编码开始时不知道需要创建哪个对象的情况下非常有用。这意味着你将不得不根据特定的配置或软件当前所处的位置来创建实例。

例如,让我们看看 WWTravelClub 示例。在这里,有一个用户故事描述了该应用程序将有来自世界各地的客户支付他们的旅行。然而,在现实世界中,每个国家都有不同的付款服务可用。每个国家的支付过程都类似,但该系统将有多个可用的付款服务。简化此付款实现的一种好方法是使用工厂模式。以下图表显示了其架构实现的基本思想:

图 11.3:工厂模式

请注意,由于您有一个描述应用程序的付款服务的接口,您可以使用工厂模式根据可用的服务更改具体类:

static void Main()
{
    #region Factory Sample
    ProcessCharging(PaymentServiceFactory.ServicesAvailable.Brazilian,
        "gabriel@sample.com", 178.90f, EnumChargingOptions.CreditCard);

    ProcessCharging(PaymentServiceFactory.ServicesAvailable.Italian,
        "francesco@sample.com", 188.70f, EnumChargingOptions.DebitCard);
    #endregion
    Console.ReadKey();
}
private static void ProcessCharging
    (PaymentServiceFactory.ServicesAvailable serviceToCharge,
    string emailToCharge, float moneyToCharge, 
    EnumChargingOptions optionToCharge)
{
    PaymentServiceFactory factory = new PaymentServiceFactory();
    var service = factory.Create(serviceToCharge);
    service.EmailToCharge = emailToCharge;
    service.MoneyToCharge = moneyToCharge;
    service.OptionToCharge = optionToCharge;
    service.ProcessCharging();
} 

再次,由于实现的模式,服务的使用变得更加简单。如果您必须在真实世界的应用程序中使用此代码,您可以通过在工厂模式中定义所需的服务来更改实例的行为。

单例模式

当您在应用程序中实现单例时,您将在整个解决方案中实现对象的单个实例。这可以被认为是每个应用程序中最常用的模式之一。原因很简单-有许多用例需要一些类只有一个实例。单例通过提供比全局变量更好的解决方案来解决这个问题。

在单例模式中,类负责创建和提供应用程序将使用的单个对象。换句话说,单例类创建一个单一实例:

图 11.4:单例模式

为此,创建的对象是static,并在静态属性或方法中提供。以下代码实现了具有Message属性和Print()方法的单例模式:

public sealed class SingletonDemo
{
    #region This is the Singleton definition
    private static SingletonDemo _instance;
    public static SingletonDemo Current => _instance ??= new 
        SingletonDemo();
    #endregion
    public string Message { get; set; }
    public void Print()
    {
        Console.WriteLine(Message);
    }
} 

它的使用很简单-每次需要使用单例对象时,只需调用静态属性:

SingletonDemo.Current.Message = "This text will be printed by " +
  "the singleton.";
SingletonDemo.Current.Print(); 

您可能使用此模式的一个场景是需要以可以轻松从解决方案的任何地方访问的方式提供应用程序配置。例如,假设您有一些配置参数存储在应用程序需要在多个决策点查询的表中。您可以创建一个单例类来帮助您,而不是直接查询配置表。

图 11.5:单例模式的使用

此外,您需要在此单例中实现缓存,从而提高系统的性能,因为您可以决定系统是否每次需要时都会检查数据库中的每个配置,还是使用缓存。以下屏幕截图显示了缓存的实现,其中配置每 5 秒加载一次。在这种情况下读取的参数只是一个随机数:

图 11.6:单例模式内部的缓存实现

这对应用程序的性能非常有利。此外,在代码中的多个地方使用参数更简单,因为您不必在代码的各处创建配置实例。

值得一提的是,由于.NET 5 中的依赖注入实现,单例模式的使用变得不太常见,因为您可以设置依赖注入来处理您的单例对象。我们将在本章的后面部分介绍.NET 5 中的依赖注入。

代理模式

代理模式用于在需要提供控制对另一个对象访问的对象时使用。为什么要这样做的最大原因之一与创建被控制对象的成本有关。例如,如果被控制的对象创建时间过长或消耗过多内存,可以使用代理来确保只有在需要时才会创建对象的大部分。

以下类图是代理模式实现从Room加载图片的示例,但只有在请求时:

图 11.7:代理模式实现

该代理的客户端将请求其创建。在这里,代理只会从真实对象中收集基本信息(IdFileNameTags),而不会查询PictureData。当请求PictureData时,代理将加载它:

static void Main()
{
    Console.WriteLine("Proxy Sample");
    ExecuteProxySample(new ProxyRoomPicture());
}
private static void ExecuteProxySample(IRoomPicture roomPicture)
{
    Console.WriteLine($"Picture Id: {roomPicture.Id}");
    Console.WriteLine($"Picture FileName: {roomPicture.FileName}");
    Console.WriteLine($"Tags: {string.Join(";", roomPicture.Tags)}");
    Console.WriteLine($"1st call: Picture Data");
    Console.WriteLine($"Image: {roomPicture.PictureData}");
    Console.WriteLine($"2nd call: Picture Data");
    Console.WriteLine($"Image: {roomPicture.PictureData}");
} 

如果再次请求PictureData,由于图像数据已经就位,代理将保证不会重复加载图像。以下截图显示了运行上述代码的结果:

图 11.8:代理模式结果

这种技术也可以称为另一个众所周知的模式:惰性加载。事实上,代理模式是实现惰性加载的一种方式。实现惰性加载的另一种方法是使用Lazy<T>类型。例如,在 Entity Framework Core 5 中,正如第八章在 C#中与数据交互-Entity Framework Core中讨论的那样,你可以使用代理打开惰性加载。你可以在docs.microsoft.com/en-us/ef/core/querying/related-data#lazy-loading找到更多信息。

命令模式

有许多情况下,你需要执行一个会影响对象行为的命令。命令模式可以通过封装这种请求到一个对象中来帮助你。该模式还描述了如何处理请求的撤销/重做支持。

例如,让我们想象一下,在 WWTravelClub 网站上,用户可能有能力通过指定他们喜欢、不喜欢,甚至是喜爱他们的体验来评估套餐。

以下类图是一个示例,可以实现使用命令模式创建此评分系统:

图 11.9:命令模式

注意这种模式的工作方式——如果你需要一个不同的命令,比如Hate,你不需要更改使用命令的代码和类。Undo方法可以以类似的方式添加到Redo方法。这方面的完整代码示例可以在本书的 GitHub 存储库中找到。

还值得一提的是,ASP.NET Core MVC 使用命令模式来处理其IActionResult层次结构。此外,第十二章理解软件解决方案中的不同领域中描述的业务操作将使用该模式来执行业务规则。

发布者/订阅者模式

将对象的信息提供给一组其他对象在所有应用程序中都很常见。当有大量组件(订阅者)将接收包含对象发送的信息的消息时,发布者/订阅者模式几乎是必不可少的。

这里的概念非常简单易懂,并且在下图中有所展示:

图 11.10:发布者/订阅者示例案例

当你有无数个可能的订阅者时,将广播信息的组件与消费信息的组件解耦是至关重要的。发布者/订阅者模式为我们做到了这一点。

实施这种模式是复杂的,因为分发环境并不是一个简单的任务。因此,建议您考虑已经存在的技术来实现连接输入通道和输出通道的消息代理,而不是从头开始构建它。Azure Service Bus 是这种模式的可靠实现,所以你只需要连接到它。

我们在第五章中提到的 RabbitMQ,将微服务架构应用于企业应用程序,是另一个可以用来实现消息代理的服务,但它是该模式的较低级别实现,并且需要进行多个相关任务,例如手动编码重试以处理错误。

依赖注入模式

依赖注入模式被认为是实现依赖反转原则的一种好方法。一个有用的副作用是,它强制任何实现遵循所有其他 SOLID 原则。

这个概念非常简单。您只需要定义它们的依赖关系,声明它们的接口,并通过注入启用对象的接收,而不是创建组件所依赖的对象的实例。

有三种方法可以执行依赖注入:

  • 使用类的构造函数接收对象

  • 标记一些类属性以接收对象

  • 定义一个具有注入所有必要组件的方法的接口

以下图表显示了依赖注入模式的实现:

图 11.11:依赖注入模式

除此之外,依赖注入还可以与控制反转IoC)容器一起使用。该容器在被要求时自动注入依赖项。市场上有几个 IoC 容器框架可用,但是在.NET Core 中,无需使用第三方软件,因为它包含一组库来解决Microsoft.Extensions.DependencyInjection命名空间中的问题。

这个 IoC 容器负责创建和处理被请求的对象。依赖注入的实现基于构造函数类型。对于被注入组件的生命周期,有三个选项:

  • 瞬态:每次请求时都会创建对象。

  • 作用域:为应用程序中定义的每个作用域创建对象。在 Web 应用程序中,作用域是通过 Web 请求标识的。

  • 单例:每个对象具有相同的应用程序生命周期,因此重用单个对象来为给定类型的所有请求提供服务。如果您的对象包含状态,则不应使用此对象,除非它是线程安全的。

您将如何使用这些选项取决于您正在开发的项目的业务规则。这也取决于您将如何注册应用程序的服务。在决定正确的选项时,您需要小心,因为应用程序的行为将根据您注入的对象类型而改变。

了解.NET 5 中可用的设计模式

在前面的部分中,我们发现 C#允许我们实现任何模式。 .NET 5 在其 SDK 中提供了许多实现,遵循我们讨论过的所有模式,例如 Entity Framework Core 代理延迟加载。自.NET Core 2.1 以来可用的另一个很好的例子是.NET 通用主机。

第十五章中,介绍 ASP.NET Core MVC,我们将详细介绍.NET 5 中 Web 应用程序可用的托管。这个 Web 主机在应用程序的启动和生命周期管理方面对我们很有帮助。.NET 通用主机的想法是为不需要 HTTP 实现的应用程序启用这种模式。通过这个通用主机,任何.NET Core 程序都可以有一个启动类,我们可以在其中配置依赖注入引擎。这对于创建多服务应用程序非常有用。

您可以在docs.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host找到更多关于.NET 通用主机的信息,其中包含一些示例代码,并且是微软目前的推荐。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. 何时实现依赖注入模式是一个好主意?

进一步阅读

以下是一些书籍和网站,您可以在其中找到有关本章内容的更多信息:

第十二章:理解软件解决方案中的不同领域

本章专门介绍了一种称为领域驱动设计DDD)的现代软件开发技术,这是由 Eric Evans 首次提出的。虽然 DDD 已经存在了 15 年以上,但由于其解决了两个重要问题的能力,它在过去几年取得了巨大成功。

第一个问题是对复杂系统进行建模。没有一个专家对整个领域有深入的了解;相反,这种知识分散在几个人之间。正如我们将看到的,DDD 通过将整个 CI/CD 周期分成独立的部分分配给不同的团队来解决这个问题。这样,每个团队可以专注于特定的知识领域,只与该领域的专家进行互动。

DDD 很好地解决了第二个问题,即涉及多个开发团队的大型项目。项目被分成几个团队有很多原因,最常见的原因是团队的规模以及所有成员具有不同的技能和/或不同的地点。事实上,经验已经证明,超过 6-8 人的团队并不高效,而不同的技能和地点阻止了紧密的互动。团队分裂阻止了所有参与项目的人之间的紧密互动。

反过来,上述两个问题的重要性在过去几年中不断增加。软件系统一直占据着每个组织内大量的空间,并且变得越来越复杂和地理分布。与此同时,对频繁更新的需求增加,以便这些复杂的软件系统能够适应快速变化的市场需求。

这些问题导致了更复杂的 CI/CD 周期的构想和复杂分布式架构的采用,这些架构可以利用可靠性、高吞吐量、快速更新以及逐渐演变传统子系统的能力。是的——我们谈论的是微服务和基于容器的架构,我们在第五章将微服务架构应用于企业应用程序中进行了分析。

在这种情况下,常见的做法是实现与快速 CI/CD 周期相关的复杂软件系统,这些系统始终需要���多的人来进行演进和维护。反过来,这就产生了对适用于高复杂性领域以及对几个松散耦合的开发团队进行合作的技术的需求。

在本章中,我们将分析与 DDD 相关的基本原则、优势和常见模式,以及如何在我们的解决方案中使用它们。更具体地,我们将涵盖以下主题:

  • 什么是软件领域?

  • 理解领域驱动设计

  • 使用 SOLID 原则来映射您的领域

  • 用例——理解用例的领域

让我们开始吧。

技术要求

本章需要安装了所有数据库工具的 Visual Studio 2019 免费社区版或更高版本。

本章中的所有代码片段都可以在与本书相关的 GitHub 存储库中找到,github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5

什么是软件领域?

正如我们在第二章非功能性需求第三章使用 Azure DevOps 记录需求中讨论的,从领域专家到开发团队的知识传递在软件设计中起着基础性作用。开发人员试图与专家沟通,并用领域专家和利益相关者能够理解的语言描述他们的解决方案。然而,通常情况下,同一个词在组织的不同部分有不同的含义,在不同的上下文中,看似相同的概念实体在不同的情境中完全不同。

例如,在我们的 WWTravelClub 用例中,订单支付和包裹处理子系统使用完全不同的客户模型。订单支付通过支付方式和货币、银行账户和信用卡来描述客户,而包裹处理更关注过去访问和/或购买的位置和包裹、用户的偏好以及其地理位置。此外,订单支付涉及各种概念,使用的语言可以粗略地定义为银行语言,而包裹处理使用的语言则是旅行社/运营商的典型语言。

应对这些差异的经典方式是使用一个称为客户的唯一抽象实体,它投影到两个不同的视图——订单-支付视图和包裹处理视图。每个投影操作都从客户抽象实体中获取一些操作和一些属性,并更改它们的名称。由于领域专家只给我们提供了投影视图,作为系统设计师的主要任务是创建一个能够解释所有视图的概念模型。下图显示了如何处理不同的视图:

图 12.1:创建一个唯一模型

经典方法的主要优势在于我们对领域数据有一个独特和一致的表示。如果成功构建了这个概念模型,所有操作都将有一个正式的定义和目的,整个抽象将是整个组织应该工作的合理化,可能会突出和纠正错误,并简化一些程序。

然而,这种方法有什么不足之处呢?

对于一个小组织来说,快速采用新的单体数据模型可能会产生可接受的影响,当软件面向整个组织的一小部分,或者当软件自动化了数据流的足够小的百分比时。然而,如果软件成为复杂的地理分布式组织的支柱,急剧的变化变得不可接受和不可行。复杂结构的公司需要从旧组织逐渐过渡到新组织。反过来,只有在旧数据模型可以与新数据模型共存,并且组织的各个组成部分都被允许以自己的速度变化时,逐渐过渡才是可能的,也就是说,组织的每个组成部分都可以独立于其他部分发展。

此外,随着软件系统复杂性的增加,还会出现其他一些问题,例如:

  • 一致性问题:当我们将这些任务分解为更小、松散耦合的任务时,要达到数据的统一一致视图变得更加困难,因为我们无法保留复杂性。

  • 更新困难:随着复杂性的增长,系统需要频繁变更,但更新和维护一个唯一的全局模型是相当困难的。此外,由于系统中某些小部分的变更引入的错误可能通过唯一共享的模型传播到整个组织。

  • 团队组织问题:系统建模必须分配给几个团队,并且只能给予分离的松散耦合任务;如果两个任务紧密耦合,它们需要分配给同一个团队。

  • 并行问题:需要转向基于微服务的架构,这使得唯一数据库的瓶颈变得更加不可接受。

  • 语言问题:随着系统的增长,我们需要与更多的领域专家进行沟通,每个人说着不同的语言,对数据模型有不同的看法。因此,我们需要将我们唯一模型的属性和操作从更多的语言翻译成/翻译出来,以便与他们进行沟通。

随着系统的增长,处理具有数百/数千个字段的记录变得更加低效。这种低效性源于数据库引擎对具有多个字段的大记录的低效处理(内存碎片化,与太多相关索引的问题等)。然而,主要的低效性发生在对象关系映射ORMs)和业务层,它们被迫在其更新操作中处理这些大记录。事实上,虽然查询操作通常只需要从存储引擎中检索出的少数字段,但更新和业务处理涉及整个实体。

随着数据存储子系统中的流量增长,我们需要在所有数据操作中实现读取和更新/写入并行性。正如我们在第九章中讨论的如何在云中选择数据存储,读取并行性可以通过数据复制轻松实现,而写入并行性则需要分片,对于一个独特的单片和紧密连接的数据模型来说,分片是困难的。

这些问题是 DDD 在过去几年取得成功的原因,因为它们被更复杂的软件系统所特征,这些系统成为整个组织的支柱。DDD 的基本原则将在下一节中详细讨论。

理解领域驱动设计

DDD 是关于构建一个独特的领域模型,将所有视图保持为单独的模型。因此,整个应用程序领域被分割成更小的领域,每个领域都有一个单独的模型。这些单独的领域被称为有界上下文。每个领域都以专家所使用的语言为特征,并用于命名所有领域概念和操作。因此,每个领域定义了一个专家和开发团队都使用的通用语言,称为普遍语言。不再需要翻译,如果开发团队使用接口作为代码的基础,领域专家能够理解和验证它们,因为所有操作和属性都用专家使用的相同语言表达。

在这里,我们摆脱了繁琐的唯一抽象模型,但现在我们有几个分离的模型,我们需要以某种方式进行关联。DDD 建议将处理所有这些分离的模型,即所有有界上下文,如下所示:

  • 每当语言术语的含义发生变化时,我们需要添加有界上下文边界。例如,在 WWTravelClub 的用例中,订单支付和套餐处理属于不同的有界上下文,因为它们赋予了客户这个词不同的含义。

  • 我们需要明确表示有界上下文之间的关系。不同的开发团队可能在不同的有界上下文上工作,但每个团队必须清楚地了解其所在的有界上下文与所有其他模型之间的关系。因此,这些关系在一个共享给每个团队的唯一文档中表示。

  • 我们需要保持所有有界上下文与 CI 保持一致。会议被组织起来,简化的系统原型被构建,以验证所有有界上下文是否一致地发展,即所有有界上下文是否能够集成到所需的应用程序行为中。

以下图表显示了我们在前一节中讨论的 WWTravelClub 示例在采用 DDD 后的变化:

图 12.2:DDD 有界上下文之间的关系

每个有界上下文的客户实体之间存在关系,而套餐处理有界上下文的购买实体与支付相关。确定在各个有界上下文中映射到彼此的实体是正式定义代表上下文之间所有可能通信的接口的第一步。

例如,从前面的图表中,我们知道购买后会进行付款,因此我们可以推断订单-付款有界上下文必须有一个为特定客户创建付款的操作。在此领域中,如果新客户不存在,则会创建新客户。购买后立即触发付款创建操作。由于购买商品后会触发多个操作,我们可以使用我们在第十一章设计模式和.NET 5 实现中解释的发布者/订阅者模式来实现与购买事件相关的所有通信。这在 DDD 中被称为领域事件。使用事件来实现有界上下文之间的通信非常常见,因为它有助于保持有界上下文之间的松耦合。

一旦在有界上下文界面中定义的事件或操作的实例跨越上下文边界,它立即被转换为接收上下文的普遍语言。在输入数据开始与其他领域实体交互之前执行此转换非常重要,以避免其他领域的普遍语言被额外的上下文术语污染。

每个有界上下文实现必须包含一个完全以有界上下文普遍语言(类和接口名称以及属性和方法名称)表达的数据模型层,不受其他有界上下文普遍语言的污染,也不受编程技术内容的污染。这对于确保与领域专家的良好沟通以及确保领域规则被正确地转换为代码以便领域专家轻松验证是必要的。

当通信语言与目标普遍语言之间存在严重不匹配时,会在接收有界上下文边界添加一个反腐层。这个反腐层的唯一目的是执行语言翻译。

包含所有有界上下文的表示,以及有界上下文的相互关系和接口定义的文档称为上下文映射。上下文之间的关系包含组织约束,指定了在不同有界上下文上工作的团队之间所需的合作方式。这些关系不限制有界上下文的接口,但会影响它们在软件 CI/CD 周期中可能发展的方式。它们代表了团队合作的模式。最常见的模式如下:

  • 合作伙伴:这是 Eric Evans 建议的最初模式。其想法是两个团队在交付方面相互依赖。换句话说,他们共同决定,并在软件 CI/CD 周期中如有需要,更改有界上下文的相互通信规范。

  • 客户/供应商开发团队:在这种情况下,一个团队充当客户,另一个充当供应商。两个团队定义了有界上下文客户端的接口以及一些自动化验收测试来验证它。之后,供应商可以独立工作。当客户的有界上下文是唯一激活的部分,调用其他有界上下文公开的接口方法时,此模式适用。这对于订单-付款和包裹处理上下文之间的交互是足够的,其中订单-付款充当供应商,因为其功能从属于包裹处理的需求。当可以应用此模式时,它完全解耦了两个有界上下文。

  • 顺从者:这类似于客户/供应商,但在这种情况下,客户方接受了供应方强加的接口,没有谈判阶段。这种模式对其他模式没有优势,但有时我们被迫处于模式所描述的情况,因为供应方的有界上下文是在无法进行太多配置/修改的现有产品中实现的,或者因为它是我们不想修改的遗留子系统。

值得指出的是,如果结果的有界上下文松散耦合,那么有界上下文的分离才是有效的;否则,通过将整个系统分解为子部分而获得的复杂性减少将被协调和通信过程的复杂性所淹没。

然而,如果有界上下文是根据语言标准来定义的,也就是说,每当普遍语言发生变化时,就会添加有界上下文边界,那么实际情况应该是这样。事实上,不同的语言可能会由于组织子部分之间的松散交互而产生,因为每个子部分内部的紧密交互越多,与其他子部分的松散交互越多,每个子部分最终就会定义和使用自己的内部语言,这与其他子部分使用的语言不同。

此外,所有人类组织都可以通过演变成松散耦合的子部分来增长,原因是复杂软件系统可以作为松散耦合的子模块的合作来实现:这是人类应对复杂性的唯一方式。由此,我们可以得出结论,复杂的组织/人工系统总是可以分解成松散耦合的子部分。我们只需要了解如何

除了我们迄今提到的基本原则之外,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.Equals方法,即当两个对象具有相同的主键时,它们被视为相等。这很容易通过让所有实体继承自一个抽象的Entity类来实现,如下面的代码所示:

public abstract class Entity<K>: IEntity<K>
    where K: IEquatable<K>
{

    public virtual K Id { get; protected set; }
    public bool IsTransient()
    {
        return Object.Equals(Id, default(K));
    }
    public override bool Equals(object obj)
    {
        return obj is Entity<K> entity &&
          Equals(entity); 
    }
    public bool Equals(IEntity<K> other)
    {
        if (other == null || 
            other.IsTransient() || this.IsTransient())
            return false;
        return Object.Equals(Id, other.Id);
    }
    int? _requestedHashCode;
    public override int GetHashCode()
    {
        if (!IsTransient())
        {
            if (!_requestedHashCode.HasValue)
                _requestedHashCode = HashCode.Combine(Id);
            return _requestedHashCode.Value;
        }
        else
            return base.GetHashCode();
    }
    public static bool operator ==(Entity<K> left, Entity<K> right)
    {
        if (Object.Equals(left, null))
            return (Object.Equals(right, null));
        else
            return left.Equals(right);
    }
    public static bool operator !=(Entity<K> left, Entity<K> right)
    {
        return !(left == right);
    }
} 

值得指出的是,一旦我们在Entity类中重新定义了Object.Equals方法,我们还可以重写==!=运算符。

IsTransient谓词在实体最近创建并且尚未记录在永久存储中时返回true,因此其主键仍未定义。

在.NET 中,一个良好的做法是,每当重写类的Object.Equals方法时,也要重写其Object.GetHashCode方法,以便类实例可以有效地存储在诸如字典和集合之类的数据结构中。这就是为什么Entity类对其进行了重写。

此外,值得实现一个定义Entity<K>的所有属性/方法的IEntity<K>接口。每当我们需要在接口后面隐藏数据类时,这个接口就很有用。

另一方面,值对象代表无法用数字或字符串编码的复杂类型。因此,它们没有身份和主键。它们没有在其上定义的操作,并且是不可变的;也就是说,一旦它们被创建,所有字段都可以被读取,但不能被修改。因此,它们通常被编码为具有受保护/私有设置器的类。当所有独立属性都相等时,两个值对象被认为是相等的(有些属性不是独立的,因为它们只显示以不同方式由其他属性编码的数据,就像DateTime的刻度和其日期和时间字段的表示方式一样)。

由于所有record类型都会自动重写Equals方法,因此很容易使用 C# 9 的record类型来实现值类型,以便进行逐属性比较。此外,record类型的行为类似于structs,在每次赋值时都会创建一个新实例。但是,record类型也是不可变的;也就是说,一旦初始化,改变它们的值的唯一方法是创建一个新实例。以下是如何修改record的示例:

var modifiedAddress = myAddress with {Street = "new street"} 

以下是如何定义record的示例:

public record Address
{
   public string Country {get; init;}
   public string Town {get; init;}
   public string Street {get; init;}
} 

init关键字使record类型的属性不可变,因为它意味着它们只能被初始化。

典型的值对象包括以数字和货币符号表示的成本,以经度和纬度表示的位置,地址和联系信息。当存储引擎的接口是 Entity Framework 时,我们在第八章《使用 C#与数据交互-Entity Framework Core》和第九章《如何选择云中的数据存储》中进行了分析,值对象通过OwnsManyOwnsOne关系与使用它们的实体相连。事实上,这样的关系也接受没有主键定义的类。

当存储引擎是 NoSQL 数据库时,值对象存储在使用它们的实体的记录中。另一方面,在关系数据库的情况下,它们可以通过分离的表来实现,其主键由 Entity Framework 自动处理并对开发人员隐藏(没有属性被声明为主键),或者在OwnsOne的情况下,它们被展开并添加到使用它们的实体相关的表中。

使用 SOLID 原则来映射您的领域

在接下来的小节中,我们将描述一些常用的 DDD 模式。其中一些可以在所有项目中采用,而另一些只能用于特定的 Bounded Contexts。总体思想是业务层分为两层:

  • 应用层

  • 领域层

在这里,领域层是基于普遍语言对数据层的抽象。这是定义 DDD 实体和值对象以及检索和保存它们的操作的地方。这些操作在接口中定义,并在底层数据层(在我们的案例中是 Entity Framework)中实现。

应用层定义了使用领域层接口的操作,以获取 DDD 实体和值对象,并对它们进行操作以实现应用程序业务逻辑。

正如我们将在本章后面看到的那样,通常只使用领域层中的接口来实现领域层。因此,数据层必须引用领域层,因为它必须实现其接口,而应用层是通过应用层依赖注入引擎的记录将每个领域层接口与其实现连接起来的地方。更具体地说,应用层引用的唯一数据层对象是仅在依赖注入引擎中引用的这些接口实现。

每个应用层操作都需要从依赖引擎中获取所需的接口,使用它们获取 DDD 实体和值对象,对它们进行操作,并可能通过相同的接口保存它们。下面是一个显示本节讨论的三个层之间关系的图表:

图 12.3:层之间的关系

因此,领域层包含领域对象的表示、在其上使用的方法、验证约束以及与各种实体的关系。为了增加模块化和解耦,实体之间的通信通常使用事件进行编码,即发布者/订阅者模式。这意味着实体更新可以触发已连接到业务操作的事件,并且这些事件会对其他实体进行操作。

这种分层架构使我们能够更改整个数据层而不影响领域层,后者仅依赖于领域规范和语言,不依赖于数据处理的技术细节。

应用层包含可能影响多个实体的所有操作的定义,以及应用程序所需的所有查询的定义。业务操作和查询都使用领域层中定义的接口与数据层交互。

然而,虽然业务操作通过这些接口操作和交换实体,查询则向它们发送查询规范,并从它们接收通用的数据传输对象DTOs)。事实上,查询的目的只是向用户显示数据,而不是对其进行操作;因此,查询操作不需要整个实体及其所有方法、属性和验证规则,而只需要属性元组。

业务操作可以由其他层(通常是表示层)或通信操作调用。业务操作也可以连接到由其他操作修改实体时触发的事件。

总之,应用层在领域层中定义的接口上操作,而不是直接与数据层实现交互,这意味着应用层与数据层解耦。更具体地说,数据层对象仅在依赖注入引擎定义中提到。所有其他应用层组件都引用领域层中定义的接口,依赖注入引擎注入适当的实现。

应用层通过以下一种或多种模式与其他应用组件通信:

  • 它在通信端点上公开业务操作和查询,比如 HTTP Web API(参见第十四章使用.NET Core 应用服务导向架构)。在这种情况下,表示层可以连接到这个端点,或者连接到其他端点,这些端点反过来从这个和其他端点获取信息。从几个端点收集信息并在一个唯一端点中公开它们的应用组件称为网关。它们可以是自定义的,也可以是通用的,比如 Ocelot。

  • 它被应用程序作为库引用,直接实现表示层,比如 ASP.NET Core MVC Web 应用程序。

  • 它不通过端点公开所有信息,并将其处理/创建的一些数据传递给其他应用程序组件,这些组件反过来公开端点。这种通信通常使用发布者/订阅者模式来增加模块化。

在描述这些模式之前,我们需要了解聚合的概念。

聚合

到目前为止,我们已经谈到了实体作为基于 DDD 的业务层处理的单元。然而,可以操作并组成单个实体的多个实体。一个例子是采购订单及其所有项目。事实上,独立处理单个订单项而不考虑其所属的订单是毫无意义的。这是因为订单项实际上是订单的子部分,而不是独立的实体。

没有任何交易可能会影响单个订单项而不影响该项所在的订单。想象一下,同一家公司的两个不同的人试图增加水泥的总数量,但一个人增加了 1 型水泥(项目 1)的数量,而另一个人增加了 2 型水泥(项目 2)的数量。如果每个项目都被处理为独立实体,那么两个数量都将增加,这可能导致不一致的采购订单,因为水泥的总数量将增加两次。

另一方面,如果整个订单及其所有订单项都在每个交易中加载和保存,那么两者中的一个将覆盖另一个的更改,因此最终进行更改的人将满足其要求。在 Web 应用程序中,不可能锁定用户查看和修改订单的整个时间,因此使用乐观并发策略。如果数据层基于Entity FrameworkEF)Core,我们可以使用 EF 并发检查属性。如果我们使用[ConcurrencyCheck]属性修饰属性,当 EF 保存更改时,如果数据库中的属性值与检索实体时的值不同时,事务将中止并生成并发异常。

例如,只需为每个采购订单添加一个带有[ConcurrencyCheck]修饰的版本号,并执行以下操作:

  1. 在不打开任何交易的情况下阅读订单,并更新它。

  2. 在保存更新后的采购订单之前,我们会递增计数器。

  3. 当我们保存所有更改时,如果其他人在我们能够保存更改之前递增了此计数器,则会生成并发异常并中止操作。

  4. 重复从步骤 1开始,直到不再发生并发异常。

也可以使用自动生成的TimeStamp代替计数器。然而,正如我们将很快看到的,我们需要计数器来实现命令查询责任分离CQRS)模式。

采购订单及其所有子部分(订单项)称为聚合,而订单实体称为聚合的根。聚合始终具有根,因为它们是由子部分关系连接的实体的层次结构。

由于每个聚合表示单个复杂实体,因此必须通过唯一接口公开对其进行的所有操作。因此,聚合根通常表示整个聚合,聚合上的所有操作都被定义为根实体的方法。

当使用聚合模式时,业务层和数据层之间传输的信息单元称为聚合、查询和查询结果。因此,聚合取代了单个实体。

那么我们在第八章《使用 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方法,以便将所有未决更改保存在单个事务中。如果需要启动从存储引擎检索数据时开始的更广泛的事务,则必须由应用程序层处理程序启动并提交/中止,该处理程序借助IUnitOfWorkStartAsyncCommitAsyncRollbackAsync方法来处理整个操作。IRepository<T>继承自一个空的IRepository接口,以帮助自动发现仓库。与本书相关联的 GitHub 仓库包含一个RepositoryExtensions类,其AddAllRepositories IServiceCollection扩展方法会自动发现包含在程序集中的所有仓库实现,并将它们添加到依赖注入引擎中。

以下是基于仓库和工作单元模式的应用层/领域层/数据层架构的图表:

图 12.4:层责任和相互引用

避免直接引用仓库实现的主要优势是,如果我们模拟这些接口,各个模块可以很容易地进行测试。领域层中提到的领域事件是实现理解领域驱动设计部分中提到的不同边界上下文之间通信的事件。

领域驱动设计实体和 Entity Framework Core

DDD 要求以一种与我们在第八章 在 C#中与数据交互-Entity Framework Core中定义实体的方式不同的方式定义实体。实际上,Entity Framework 实体是类似记录的公共属性列表,几乎没有方法,而 DDD 实体应该具有编码领域逻辑、更复杂的验证逻辑和只读属性的方法。虽然可以在不破坏 Entity Framework 操作的情况下添加进一步的验证逻辑和方法,但添加不能映射到数据库属性的只读属性可能会产生必须适当处理的问题。防止属性映射到数据库非常容易——我们只需要用NotMapped属性装饰它们。

只读属性存在的问题有点复杂,可以通过三种基本方式解决:

  • 将 EF 实体映射到不同的类。将 DDD 实体定义为不同的类,并在实体返回/传递给存储库方法时复制数据。这是最简单的解决方案,但它需要编写一些代码,以便可以在两种格式之间转换实体。DDD 实体在领域层中定义,而 EF 实体继续在数据层中定义。这是更干净的解决方案,但它会在编写和维护代码方面产生非常大的开销。我建议在有多个复杂聚合和多个复杂方法时使用它。

  • 将表字段映射到私有属性。让 Entity Framework Core 将字段映射到私有类字段,以便您可以决定如何通过编写自定义 getter 和/或 setter 将它们暴露为属性。只需给这些私有字段命名为_<属性名称>_<属性名称的驼峰命名法>,Entity Framework 就会使用它们而不是它们关联的属性。在这种情况下,在领域层中定义的 DDD 实体也用作数据层实体。这种方法的主要缺点是我们不能使用数据注释来配置每个属性,因为 DDD 实体不能依赖底层数据层的实现方式。因此,我们必须在OnModelCreating DbContext方法中配置所有数据库映射。这是更简单的解决方案,但它会生成不可读且难以维护的代码,因此我不建议完全采用它。

  • 定义 DDD 接口。使用接口隐藏每个 Entity Framework 类及其所有公共属性,当需要时,只暴露属性的 getter。接口在领域层中定义,而实体继续在数据层中定义。在这种情况下,存储库必须暴露一个Create方法,该方法返回接口的实现;否则,更高层将无法创建可以添加到存储引擎的新实例,因为接口不能使用new创建。当存在多个简单实体时,这是我更喜欢的解决方案。

例如,假设我们想要为第八章 在 C#中与数据交互-Entity Framework Core定义 DB 实体子部分中定义的Destination类定义一个名为IDestination的 DDD 接口,并且假设我们想要将IdNameCountry属性暴露为只读,因为一旦创建了目的地,就不能再修改了。在这里,让Destination实现IDestination并在IDestination中将IdNameCountry定义为只读就足够了:

public interface IDestination
{ 
    int Id { get; }
    string Name { get; }   
    string Country { get; }
    string Description { get; set; }
    ...
} 

现在我们已经讨论了 DDD 的基本模式以及如何调整 Entity Framework 以满足 DDD 的需求,我们可以讨论更高级的 DDD 模式。在下一节中,我们将介绍 CQRS 模式。

命令查询职责分离(CQRS)模式

在其一般形式中,使用此模式非常简单:使用不同的结构存储和查询数据。在这里,关于如何存储和更新数据的要求与查询的要求不同。在 DDD 的情况下,存储单元是聚合,因此添加、删除和更新涉及聚合,而查询通常涉及从多个聚合中获取的属性的更多或更少复杂的转换。

此外,通常情况下,我们不会对查询结果执行业务操作。我们只是用它们来计算其他数据(平均值、总和等)。因此,虽然更新需要具有完整面向对象语义(方法、验证规则、封装信息等)的实体,但查询结果只需要一组属性/值对,因此只具有公共属性而没有方法的数据传输对象DTOs)可以很好地工作。

在其常见形式中,该模式可以描述如下:

图 12.5:命令和查询处理

从中可以得出的主要观点是,提取查询结果不需要经过实体和聚合的构造,而是必须从存储引擎中提取查询中显示的字段,并投影到特定的 DTO 中。如果使用 LINQ 实现查询,我们需要使用Select子句将必要的属性投影到 DTO 中:

ctx.MyTable.Where(...)....Select(new MyDto{...}).ToList(); 

然而,在更复杂的情况下,CQRS 可以以更强大的形式实现。换句话说,我们可以使用不同的有界上下文存储预处理的查询结果。当查询涉及存储在不同有界上下文中的数据,并由不同的分布式微服务处理时,这种方法很常见。

事实上,另一个选择是使用聚合器微服务查询所有必要的微服务,以组装每个查询结果。然而,对其他微服务进行递归调用以构建答案可能会导致无法接受的响应时间。此外,分解一些预处理可以更好地利用可用资源。该模式的实现如下:

  1. 查询处理被委托给专门的微服务。

  2. 每个查询处理微服务为其必须处理的每个查询使用一个数据库表。在那里,它存储查询返回的所有字段。这意味着查询不会在每个请求时计算,而是预先计算并存储在特定的数据库表中。显然,具有子集合的查询需要额外的表,每个子集合一个。

  3. 所有处理更新的微服务都将所有更改转发给感兴趣的查询处理微服务。记录被版本化,因此接收更改的查询处理微服务可以按正确顺序将其应用于其查询处理表。实际上,由于通信是异步的以提高性能,无法保证更改会按照发送的顺序接收。

  4. 每个查询处理微服务接收的更改在等待应用更改时被缓存。每当一个更改具有紧随最后应用的更改的版本号时,它就会被应用到正确的查询处理表中。

使用这种更强大形式的 CQRS 模式将通常的本地数据库事务转换为复杂耗时的分布式事务,因为单个查询预处理微服务的失败应该使整个事务无效。正如我们在第五章中解释的,将分布式事务通常不可接受,因为性能原因,有时根本不支持,因此常见的解决方案是放弃立即一致的整体数据库的想法,并接受每次更新后整体数据库最终一致。瞬态故障可以通过我们在第五章中分析的重试策略来解决,而永久故障则通过对已提交的本地事务执行纠正操作来处理,而不是假装实现整体全局分布式事务。

正如我们在第五章中讨论的,微服务之间的通信通常使用发布者/订阅者模式来实现,以改善微服务的分离。

此时,您可能会问以下问题:

一旦我们拥有了所有预处理的查询结果,为什么我们还需要保留原始数据呢?我们永远不会用它来回答查询!

对这个问题的一些答案如下:

  • 它们是我们可能需要从失败中恢复的真相来源。

  • 我们需要它们来在添加新查询时计算新的预处理结果。

  • 我们需要它们来处理新的更新。实际上,处理更新通常需要从数据库中检索一些数据,可能向用户显示,然后进行修改。例如,要修改现有采购订单中的项目,我们需要整个订单,以便我们可以向用户显示它并计算更改,以便我们可以将其转发给其他微服务。此外,每当我们修改或向存储引擎添加数据时,我们必须验证整体数据库的一致性(唯一键约束,外键约束等)。

在下一节中,我们将描述用于处理跨多个聚合和多个有界上下文的操作的常见模式。

命令处理程序和领域事件

通常,为了保持聚合分离,与其他聚合和其他有界上下文的交互是通过事件完成的。在处理每个聚合期间创建事件时,最好将所有事件存储起来,而不是立即执行它们,以防止事件执行干扰正在进行的聚合处理。这可以通过将以下代码添加到本章实体和值对象子部分中定义的抽象Entity类中轻松实现,如下所示:

public List<IEventNotification> DomainEvents { get; private set; }
public void AddDomainEvent(IEventNotification evt)
{
    DomainEvents ??= new List<IEventNotification>(); 
    DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
    DomainEvents?.Remove(evt);
} 

在这里,IEventNotification是一个空接口,用于将类标记为事件。

事件处理通常是在更改存储在存储引擎中之前立即执行的。因此,执行事件处理的好地方是在命令处理程序调用每个IUnitOfWork实现的SaveEntitiesAsync()方法之前(请参阅存储库和工作单元模式子部分)。同样,如果事件处理程序可以创建其他事件,它们必须在完成处理所有聚合后处理它们。

对事件T的订阅可以作为IEventHandler<T>接口的实现提供:

public interface IEventHandler<T>: IEventHandler
    where T: IEventNotification
{
    Task HandleAsync(T ev);
} 

类似地,业务操作可以由command对象描述,该对象包含操作的所有输入数据,而实现实际操作的代码可以通过实现ICommandHandler<T>接口提供:

public interface ICommandHandler<T>: ICommandHandler
    where T: ICommand
{
    Task HandleAsync(T command);
} 

在这里,ICommand是一个空接口,用于将类标记为命令。ICommandHandler<T>IEventHandler<T>是我们在第十一章《设计模式和.NET 5 实现》中描述的命令模式的例子。

每个ICommandHandler<T>都可以在依赖注入引擎中注册,以便需要执行命令T的类可以在其构造函数中使用ICommandHandler<T>。这样,我们将命令的抽象定义(command类)与其执行方式解耦。

同样的构造不能应用于事件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>生成一个依赖注入定义,并且必须处理使用AddEventHandler声明的每种类型T的所有处理程序。

以下扩展类为我们执行此操作:

public static class EventDIExtensions
{
    public static IServiceCollection AddEventHandler<T, H>
        (this IServiceCollection services)
        where T : IEventNotification
        where H: class, IEventHandler<T> 
    {
        services.AddScoped<H>();
        services.TryAddScoped(typeof(EventTrigger<>));
        return services;
    }
    ...
    ...
} 

传递给AddEventHandlerH类型被记录在依赖注入引擎中,第一次调用AddEventHandler时,EventTrigger<>也被添加到依赖注入引擎中。然后,当依赖注入引擎需要EventTrigger<T>实例时,所有添加到依赖注入引擎中的IEventHandler<T>类型都会被创建、收集,并传递给EventTrigger(IEnumerable<IEventHandler<T>> handlers)构造函数。

当程序启动时,所有ICommandHandler<T>IEventHandler<T>的实现都可以通过反射自动检索和注册。为了帮助自动发现,它们继承自ICommandHandlerIEventHandler,这两个都是空接口。EventDIExtensions类,该类在本书的 GitHub 存储库中可用,包含了用于自动发现和注册命令处理程序和事件处理程序的方法。GitHub 存储库还包含了IEventMediator接口及其EventMediator实现,其TriggerEvents(IEnumerable<IEventNotification> events)方法从依赖注入引擎中检索与其参数中接收到的事件相关的所有处理程序,并执行它们。只需将IEventMediator注入到一个类中,就可以触发事件。EventDIExtensions还包含一个扩展方法,用于发现实现空IQuery接口的所有查询,并将它们添加到依赖注入引擎中。

MediatR NuGet 包提供了更复杂的实现。下一小节将专门介绍 CQRS 模式的极端实现。

事件溯源

事件溯源是更强的 CQRS 的极端实现。当原始有界上下文数据库根本不用于检索信息,而仅用作真相的来源时,即用于从故障中恢复和进行软件维护时,它是有用的。在这种情况下,我们不是更新数据,而是简单地添加描述已执行操作的事件:删除记录 Id 15,将名称更改为Id 21 中的 John等。这些事件立即发送到所有依赖的有界上下文,而在故障和/或添加新查询的情况下,我们只需重新处理其中的一些事件。如果事件是幂等的,即多次处理相同的事件与处理一次具有相同的效果,那么事件重新处理不会引起问题。

正如在第五章 将微服务架构应用于企业应用程序中讨论的那样,幂等性是通过事件通信的微服务的标准要求。

尽管我们到目前为止描述的所有技术都可以在每种类型的项目中使用,只要进行一些小的修改,但事件溯源需要进行深入分析才能被采用,因为在几种情况下,它可能会带来比它能解决的问题更大的问题。要了解它被滥用时可能引起的问题,可以想象我们将其应用于已被多个用户修改和验证后才被批准的采购订单。由于需要在更新/验证之前检索采购订单,采购订单的有界上下文不仅仅用作真相的来源,因此不应将事件溯源应用于它。如果不是这种情况,那么我们可以将事件溯源应用于它,在这种情况下,我们的代码将被迫每次更新订单时从记录的事件中重建整个订单。

我们在第五章 将微服务架构应用于企业应用程序的结尾描述了其使用示例是收入记录系统。单个收入使用事件溯源记录,然后发送到我们在第五章 将微服务架构应用于企业应用程序中描述的微服���,该微服务又用于预处理未来的查询,即计算每日收入。

在接下来的部分中,我们将学习如何应用 DDD 来定义本书 WWTravelClub 用例的有界上下文。本书中描述的大部分模式和代码的有界上下文的完整示例可以在第十五章 介绍 ASP.NET Core MVC用例-在 ASP.NET Core MVC 中实现 Web 应用程序部分找到。

用例-理解用例的领域

根据第一章 理解软件架构的重要性中列出的要求以及第九章 如何在云中选择数据存储中的用例-存储数据部分的分析,我们知道 WWTravelClub 系统由以下部分组成:

  • 有关可用目的地和套餐的信息。我们在第九章 如何在云中选择数据存储中实现了该子系统数据层的第一个原型。

  • 预订/购买订单子系统。

  • 与专家/评论子系统的通信。

  • 支付子系统。我们在本章的理解领域驱动设计部分开始时简要分析了该子系统的特性以及与预订购买子系统的关系。

  • 用户帐户子系统。

  • 统计报告子系统。

上述子系统代表不同的有界上下文吗?一些子系统可以分成不同的有界上下文吗?这些问题的答案取决于每个子系统使用的语言。

  • 子系统 1 中使用的语言是旅行社的语言。没有客户的概念,只有位置、套餐及其特点。

  • 子系统 2 中使用的语言适用于所有服务购买,如可用资源、预订和购买订单。这是一个独立的有界上下文。

  • 子系统 3 中使用的语言与子系统 1 的语言有很多共同之处。然而,也有典型的社交媒体概念,如评分、聊天、帖子分享、媒体分享等。该子系统可以分为两部分:一个具有新有界上下文的社交媒体子系统和一个属于子系统 1 有界上下文的可用信息子系统。

  • 正如我们在理解领域驱动设计部分中指出的,子系统 4 中我们使用银行的语言。该子系统与预订购买子系统通信,并执行执行购买所需的任务。从这些观察中,我们可以看到它是一个不同的有界上下文,并且与购买/预订系统有客户/供应商关系。

  • 子系统 5 绝对是一个独立的有界上下文(几乎所有 Web 应用程序都是如此)。它与所有具有用户概念或客户概念的有界上下文都有关系,因为用户账户的概念总是映射到这些概念。但是如何呢?简单——当前登录的用户被假定为社交媒体有界上下文的社交媒体用户,预订/购买有界上下文的客户,以及支付有界上下文的付款人。

  • 仅查询子系统,即 6,使用分析和统计的语言,与其他子系统使用的语言有很大不同。然而,它与几乎所有有界上下文都有连接,因为它的所有输入都来自它们。前面的约束迫使我们采用 CQRS 的强形式,因此将其视为一个仅查询的独立有界上下文。我们在第五章 将微服务架构应用于企业应用程序中实现了部分内容,使用了符合 CQRS 强形式的微服务。

总之,列出的每个子系统都定义了一个不同的有界上下文,但必须将与专家/评论子系统的通信部分包含在可用目的地和套餐有界上下文的信息中。

随着分析的继续和原型的实施,一些有界上下文可能会分裂,而其他一些可能会被添加,但是立即开始对系统进行建模,并立即开始分析有界上下文之间的关系是至关重要的,因为这将推动进一步的调查,并帮助我们定义所需的通信协议和普遍语言,以便我们可以与领域专家进行交互。

以下是领域地图的基本初步草图:

图 12.6:WWTravelClub 领域地图

为简单起见,我们省略了统计报告有界上下文。在这里,我们假设用户账户社交有界上下文与所有与它们通信的其他有界上下文具有顺从关系,因为它们是使用已经存在的软件实现的,所以所有其他组件必须适应它们。

正如我们之前提到的,预订支付之间的关系是客户/供应商,因为支付提供用于执行预订任务的服务。所有其他关系都被归类为合作伙伴。大多数有界上下文具有的各种客户/用户概念由用户账户授权令牌协调,间接地负责在所有有界上下文之间映射这些概念。

Packages/locations子系统不仅传达了执行预订/购买所需的包裹信息,还负责通知待处理的购买订单可能的价格变化。最后,我们可以看到社交互动是从现有的评论或位置开始的,从而与Packages/locations Bounded Context 进行通信。

摘要

在本章中,我们分析了采用 DDD 的主要原因,以及它为什么以及如何满足市场需求。我们描述了如何识别领域以及如何协调在同一应用程序的不同领域上工作的团队与领域映射。然后,我们分析了 DDD 如何用实体、值对象和聚合表示数据,并提供建议和代码片段,以便我们可以在实践中实现它们。

我们还介绍了一些与 DDD 一起使用的典型模式,即仓库和工作单元模式、领域事件模式、CQRS 和事件溯源。然后,我们学习了如何在实践中实现它们。我们还向您展示了如何实现领域事件和命令模式,以及解耦处理,以便我们可以将代码片段添加到实际项目中。

最后,我们在实践中使用 DDD 原则来定义领域,并为本书的 WWTravelClub 用例创建了第一个领域映射的草图。

在下一章中,您将学习如何在项目中最大程度地重用代码。

问题

  1. 是什么提供了主要提示,以便我们可以发现领域边界?

  2. 用于协调单独 Bounded Context 开发的主要工具是什么?

  3. 每个组成聚合的条目是否都通过自己的方法与系统的其余部分进行通信?

  4. 为什么只有一个聚合根?

  5. 有多少个仓库可以管理一个聚合?

  6. 仓库如何与应用层交互?

  7. 为什么需要工作单元模式?

  8. 轻量级 CQRS 的原因是什么?最强形式的原因呢?

  9. 允许我们将命令/领域事件与其处理程序耦合的主要工具是什么?

  10. 事件溯源是否可以用于实现任何 Bounded Context?

进一步阅读

第十三章:在 C# 9 中实现代码重用

代码重用是软件架构中最重要的主题之一。本章旨在讨论如何启用代码重用的方法,以及帮助你了解.NET 5 如何朝着解决管理和维护可重用库的问题的方向发展。

这一章将涵盖以下主题:

  • 理解代码重用的原则

  • 使用.NET 5 与.NET Standard 的优势

  • 使用.NET Standard 创建可重用的库

尽管代码重用是一种非常好的实践,作为软件架构师,你必须意识到这对你正在处理的场景非常重要。许多优秀的软件架构师都同意,由于试图使事物可重用,往往会出现过度设计或者单次使用或者理解不足。

技术要求

这一章需要以下内容:

理解代码可重用性的原则

你可以用来证明代码重用的唯一理由是-如果在其他场景中已经运行良好,你就不必花费宝贵的时间重新发明轮子。这就是为什么大多数工程领域都基于可重用性原则。想想你家里的开关。

你能想象可以用相同的接口组件制作多少应用程序吗?代码重用的基本原则是相同的。再次强调,这是一个规划一个好的解决方案的问题,这样一部分可以在以后重用。

在软件工程中,代码重用是可以为软件项目带来许多优势的技术之一,例如以下:

  • 考虑到重用的代码片段已经在另一个应用程序中经过测试,因此对软件有信心。

  • 软件架构师和高级团队的使用更好,因为他们可以专注于解决这种问题。

  • 有可能将市场上已经接受的模式引入项目中。

  • 由于已经实现的组件,开发速度加快。

  • 维护更容易。

这些方面表明,只要可能,应该进行代码重用。作为软件架构师,你有责任确保利用前述优势,并且更重要的是,你要鼓励团队在他们创建的软件中启用重用。

什么不是代码重用?

你必须明白的第一件事是,代码重用并不意味着从一个类中复制和粘贴代码到另一个类中。即使这段代码是由另一个团队或项目编写的,这也不表示你正在正确地遵循可重用性原则。让我们想象一种场景,我们将在本书的用例中找到,WWTravelClub 评估。

在这个项目场景中,你可能想评估不同种类的主题,比如PackageDestinationExpertCityComments等等。无论你参考哪个主题,获取评估平均值的过程都是相同的。因此,你可能想通过复制和粘贴每个评估的代码来启用重用。结果(不好的)将会是这样:

图 13.1:糟糕的实现-这里没有代码重用

在前面的图表中,计算评估平均值的过程是分散的,这意味着相同的代码将在不同的类中重复。这将带来很多麻烦,特别是如果相同的方法在其他应用程序中也被使用。例如,如果有关如何计算平均值的新规范,或者如果计算公式中出现错误,你将不得不在所有代码实例中修复它。如果你忘记在所有地方更新它,你可能最终会得到一个不一致的实现。

什么是代码重用?

在前一节提到的问题的解决方案非常简单:你必须分析你的代码,并选择其中需要从你的应用程序中解耦的部分。

你应该解耦它们的最大原因与你如何确信这段代码可以在应用程序的其他部分甚至其他应用程序中重用有关:

图 13.2:专注于代码重用的实现

代码的集中化给你作为软件架构师带来了不同的责任。你必须记住,这段代码中的一个错误或不兼容性可能会对应用程序的许多部分或不同的应用程序造成损害。另一方面,一旦你测试并运行了这段代码,你就可以毫无顾虑地传播它的使用。此外,如果你需要改进平均计算过程,你只需要更改单个类中的代码。

值得一提的是,你使用相同的代码越多,这种开发就会变得越便宜。成本需要提到,因为一般来说,可重用软件的构思在开始阶段成本更高。

在开发生命周期中的可重用性

如果你明白了可重用性将带你到另一个代码实现层次,你应该一直在考虑如何在你的开发生命周期中使用这种技术。

事实上,创建和维护一个组件库并不容易,因为你将承担的责任和缺乏支持搜索现有组件的好工具。

另一方面,有一些东西你可能要考虑在每次启动新开发时在你的软件开发过程中实现:

  • 使用已经在用户库中实现的组件,选择软件需求规格中需要它们的功能。

  • 识别软件需求规格中作为库组件设计候选的功能。

  • 修改规格,考虑使用可重用组件开发这些功能。

  • 设计可重用组件,并确保它们具有适当的接口,可以在许多项目中使用。

  • 构建具有新组件库版本的项目架构。

  • 记录组件库版本,以便每个开发人员和团队都知道它。

使用-识别-修改-设计-构建过程是一种你可能要考虑在每次需要启用软件重用时实施的技术。一旦你有了为这个库编写的组件,你就需要决定提供这些组件的技术。

在软件开发的历史中,有许多方法来做到这一点;其中一些在第五章将微服务架构应用于企业应用程序中讨论过,在微服务和模块概念的演变部分。

使用.NET 5 或.NET 标准进行代码重用

.NET 自第一个版本以来已经发展了很多。这种发展不仅与命令数量和性能问题有关,还与支持的平台有关。正如我们在第一章中讨论的理解软件架构的重要性,即使在运行 Linux、Android、macOS 或 iOS 的数十亿设备上,您也可以运行 C# .NET。因此,.NET Standard 是首次与.NET Core 1.0 一起宣布的,但.NET Standard 在.NET Standard 2.0 时变得特别重要,当时.NET Framework 4.6、.NET Core 和 Xamarin 与之兼容。

关键点是,.NET Standard 不仅仅是一种 Visual Studio 项目。更重要的是,它是一个对所有.NET 实现都可用的正式规范。正如您在下表中所看到的,它涵盖了从.NET Framework 到 Unity 的所有内容:

.NET Standard 1.0 1.1 1.2 1.3 1.4 1.5 1.6 2.0 2.1
.NET Core 和.NET 5 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

您可以在docs.microsoft.com/en-us/dotnet/standard/net-standard找到完整的.NET Standard 概述。

前面的表格表明,如果您构建一个符合这一标准的类库,您将能够在所呈现的任何平台上重用它。想象一下,如果您计划在所有项目中都这样做,您的开发过程会变得多么快速。

显然,一些组件不包括在.NET Standard 中,但它的发展是持续的。值得一提的是,微软的官方文档指出版本越高,可用的 API 就越多

拥有一个适用于所有平台的单一框架的倡议使我们走向了.NET 5。微软指出,从现在开始,net5.0 或更高版本将在任何地方运行。作为软件架构师,您可能会问的下一个问题是:.NET Standard 会发生什么?

对这个问题的答案在 dotnet 博客上由 Immo Landwerth 做了很好的解释:devblogs.microsoft.com/dotnet/the-future-of-net-standard/。基本答案是,.NET 5.0(以及未来的版本)需要被视为未来共享代码的基础。

创建一个.NET Standard 库

创建一个与.NET Standard 兼容的类库非常简单。基本上,您需要在创建库时选择以下项目:

图 13.3:创建一个.NET Standard 库

一旦您完成了这一部分,您会注意到一个普通类库和您创建的类库之间唯一的区别是项目文件中定义的目标框架:

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

一旦您的项目加载完成,您就可以开始编写您打算重用的类。使用这种方法构建可重用类的优势是,您将能够在我们之前检查过的所有项目类型中重用编写的代码。另一方面,您会发现在这种类型的项目中,一些在.NET Framework 中可用的 API 在这里是不存在的。

C#如何处理代码重用?

C#帮助我们处理代码重用的方法有很多。正如我们在前一节中所做的那样,构建库的能力是其中之一。最重要的是,这种语言是面向对象的。此外,值得一提的是泛型为 C#语言带来的便利。本节将讨论我们提到的最后两个。

面向对象分析

面向对象的分析方法使我们能够以不同的方式重用代码,从继承的便利性到多态的可变性。完全采用面向对象编程将让您实现抽象和封装。

以下图表显示了使用面向对象方法使重用变得更容易。正如你所看到的,有不同的方法来计算评估的等级,考虑到你可以是系统的基本用户或高级用户:

图 13.4:面向对象的案例分析

在这个设计中,代码重用有两个方面需要分析。第一个是在每个子类中不需要声明属性,因为继承已经为你做了这件事。

第二个是使用多态的机会,使相同方法产生不同的行为。

public class PrimeUsersEvaluation : Evaluation
{
    /// <summary>
    /// The business rule implemented here indicates that grades that 
    /// came from prime users have 20% of increase
    /// </summary>
    /// <returns>the final grade from a prime user</returns>
    public override double CalculateGrade()
    {
         return Grade * 1.2;
    }
} 

在前述代码中,你可以看到多态原则的使用,高级用户的评估计算会增加 20%。现在,看看通过相同类继承的不同对象调用是多么容易。由于集合内容实现了相同的接口IContentEvaluated,它也可以有基本用户和高级用户。

public class EvaluationService
{
    public IContentEvaluated content { get; set; }
    /// <summary>
    /// No matter the Evaluation, the calculation will always get     
    /// values from the method CalculateGrade
    /// </summary>
    /// <returns>The average of the grade from Evaluations</returns>
    public double CalculateEvaluationAverage()
    {
            return content.Evaluations
                .Select(x => x.CalculateGrade())
                .Average();
    }
} 

面向对象的采用在使用 C#时可以被视为强制性的。然而,更具体的用法需要学习和实践。作为软件架构师,你应该始终鼓励你的团队学习面向对象的分析。他们拥有更多的抽象能力,代码重用就会变得更容易。

泛型

泛型在 C# 2.0 版本中引入,被认为是一种增加代码重用的方法。它还能最大化类型安全性和性能。

泛型的基本原则是,你可以在接口、类、方法、属性、事件,甚至委托中定义一个占位符,当使用前述实体之一时,它将在以后被特定类型替换。你可以使用这个特性的机会是不可思议的,因为你可以使用相同的代码来运行类型的不同版本,通用地。

以下代码是对前一节中介绍的EvaluationService的修改。这里的想法是使服务的泛化,让我们有机会自定义评估的目标:

public class EvaluationService<T> where T: IContentEvaluated 

这个声明表明,任何实现了IContentEvaluaded接口的类都可以用于这个服务。此外,服务将负责创建评估内容。

以下代码实现了自从服务构建以来创建的评估内容。这段代码使用了System.Reflection和类的泛型定义:

public EvaluationService()
{
    var name = GetTypeOfEvaluation();
    content = (T)Assembly.GetExecutingAssembly().CreateInstance(name);
} 

值得一提的是,这段代码将会工作,因为所有的类都在同一个程序集中。此外,在使用泛型时,反射并不是必需的。这个修改的结果可以在服务的实例创建中检查到:

var service = new EvaluationService<CityEvaluation>(); 

好消息是,现在你有了一个通用的服务,它将自动用你需要的内容的评估实例化列表对象。值得一提的是,泛型显然需要更多的时间用于第一个项目的构建。然而,一旦设计完成,你将拥有良好、快速和易于维护的代码。这就是我们所说的重用!

如果代码不能被重用怎么办?

事实上,任何代码都可以重用。关键在于你打算重用的代码是否写得好,并且遵循了良好的重用模式。有几个原因可以说明为什么代码不应该被考虑为可重用:

  • 代码在重用之前没有经过测试:在重用代码之前,保证它能够正常工作是一个很好的方法。

  • 代码是重复的:如果你有重复的代码,你需要找到它被使用的每个地方,这样你就只有一个版本的代码被重用。

  • 代码过于复杂,难以理解:在许多地方重用的代码需要简单编写,以便易于理解。

  • 代码耦合度太高:这是一个关于组合与继承的讨论,当构建独立的类库时。类(带接口)通常比可以被继承的基类更容易重用。

在任何这些情况下,考虑到重构策略可能是一个很好的方法。当你重构代码时,你正在以更好的方式编写它,同时尊重这段代码将处理的输入和输出数据。这将使得在改变代码时更全面、成本更低。Martin Fowler 指出了一些我们应该考虑重构的原因:

  • 它改善软件设计:你的团队变得越来越专业,设计就会变得越来越好。更好的软件设计不仅会带来更快的编码,还会为我们提供在更短的时间内处理更多任务的机会。

  • 它使软件更易理解:无论是初级开发人员还是高级开发人员,好的软件都需要被团队中的每个开发人员理解。

  • 它帮助我们找到错误:在重构时,你会发现可能没有被良好编程的业务规则,所以你会发现错误。

  • 它让我们更快地编程:重构的结果将是能够在未来实现更快开发的代码。

重构的过程取决于我们将要遵循的一些步骤,以确保良好的结果并在旅程中尽量减少错误:

  • 确保你有一套测试来保证正确处理:你拥有的测试集将消除清理代码的恐惧。

  • 消除重复:重构是消除代码重复的好机会。

  • 最小化复杂性:考虑到你的目标是使代码更易理解,遵循编程的最佳实践,如第十七章中提到的C# 9 编码最佳实践,将减少代码的复杂性。

  • 清理设计:重构是重新组织你的库设计的好时机。不要忘记也要更新它们。这是消除错误和安全问题的好方法。

作为软件架构师,你会收到团队的许多重构需求。持续做这件事的动力必须是持续的。但你必须提醒你的团队,没有遵循先前步骤的重构可能是有风险的。因此,你有责任以一种既能够实现快速编程又能够减少影响的方式来实现它,从而提供真正的商业价值。

我有我的库。我该如何推广它们?

考虑到你已经做出了所有必要的努力来确保你有好的库,这些库必须在你的许多项目中被重复使用,当启用可重用性时,你会发现另一个困难的情况出现了:让程序员知道你已经准备好可以重用的库并不简单。

有一些简单的方法来记录一个库。正如我们在谈论开发生命周期时提到的,文档是帮助开发人员注意到他们拥有的库的好方法。这里有两个关于记录可重用代码的例子我们想提一下。

使用 DocFX 记录.NET 库

这个工具是一个很好的选择,可以使用代码中的注释来记录库。通过简单地添加 NuGet 包docfx.console,该工具允许你创建一个任务,一旦你的库构建完成就会运行:

图 13.5:docfx.console NuGet 库

这个编译的输出是一个时尚的静态网站,包含了你的代码文档:

图 13.6:DocFx 结果

这个网站很有用,因为你可以把文档分发给你的团队,这样他们就可以搜索你拥有的库。你可以检查输出的自定义,并在dotnet.github.io/docfx/找到更多信息。

使用 Swagger 记录 Web API

毫无疑问,Web API 是促进代码重用的技术之一。因此,做好它的文档工作,更重要的是,遵守标准,是一个良好的做法,并表明你对这种方法是跟上了时代的。

OpenAPI 规范被认为是描述现代 API 的标准。在 ASP.NET Core Web API 中,最常用的用于对其进行文档化的工具之一是Swashbuckle.AspNetCore

实现Swashbuckle.AspNetCore库的好处在于,你可以为你的 Web API 设置 Swagger UI 查看器,这是一种良好的图形方式来分发 API。

我们将在下一章学习如何在 ASP.NET Core Web API 中使用这个库。在那之前,重要的是要理解,这份文档不仅会帮助你的团队,还会帮助任何可能使用你正在开发的 API 的开发人员。

用例 - 将代码重用作为快速交付良好和安全软件的途径

WWTravelClub 评估内容的最终设计可以如下所示。这种方法包括了本章讨论的许多主题。首先,所有的代码都放在一个.NET 标准类库中。这意味着你可以将这些代码添加到不同类型的解决方案中,比如.NET Core web 应用程序和用于 Android 和 iOS 平台的 Xamarin 应用程序:

图 13.7:WWTravelClub 重用方法

这种设计利用了面向对象的原则,比如继承,因此你不需要在多个类中重复编写属性和方法;以及多态性,这样你可以改变代码的行为而不改变方法的名称。

最后,这个设计通过引入泛型来抽象内容的概念,泛型可以简化对类似类的对象的处理,比如我们在 WWTravelClub 中用来评估城市、评论、目的地专家和旅行套餐的类。

一个鼓励代码重用的团队和一个不鼓励的团队之间的最大区别是向最终用户交付良好软件的速度。当然,开始这种方法并不容易,但请放心,在一段时间后你将会取得良好的结果。

总结

本章旨在帮助你了解代码重用的优势。它还让你了解了什么才是正确的代码重用。本章还介绍了代码重用和重构的方法。

考虑到没有流程的技术是无法带你走向任何地方的,因此提出了一个流程,帮助实现代码重用。这个流程涉及到使用已经完成的组件库中的组件;识别软件需求规范中可以设计为库组件的特性;修改规范以考虑这些特性;设计可重用组件;并使用新的组件库版本构建项目架构。

最后,本章介绍了.NET 标准库作为在不同的 C#平台上重用代码的一种方法,表明.NET 5 和新版本应该用于在不同平台上重用代码。本章还强调了在重用代码时面向对象编程的原则,并介绍了泛型作为简化具有相同特征的对象处理的一种复杂实现。在下一章中,我们将学习如何在.NET Core 中应用面向服务的架构(SOA)。

值得一提的是,SOA 被认为是在复杂环境中实现代码重用的一种方式。

问题

  1. 复制和粘贴可以被视为代码重用吗?这种方法的影响是什么?

  2. 如何在不复制和粘贴代码的情况下实现代码重用?

  3. 有没有一个流程可以帮助实现代码重用?

  4. .NET Standard 和.NET Core 之间有什么区别?

  5. 创建.NET Standard 库的优势是什么?

  6. 面向对象分析如何帮助代码重用?

  7. 泛型如何帮助代码重用?

  8. .NET Standard 将被.NET 5 取代吗?

  9. 与重构相关的挑战是什么?

进一步阅读

这些是一些书籍和网站,您将在其中找到有关本章的更多信息:

第十四章:使用.NET Core 应用面向服务的架构

“面向服务的架构”(SOA)一词指的是通过通信实现系统组件之间交互的模块化架构。SOA 允许来自不同组织的应用程序自动交换数据和交易,并允许组织在互联网上提供服务。

此外,正如我们在《微服务和模块概念的演变》章节中讨论的那样,《将微服务架构应用于企业应用程序》第五章,基于通信的交互解决了复杂系统中不可避免出现的模块共享相同地址空间的二进制兼容性和版本不匹配问题。此外,使用 SOA,您无需在使用它的各个系统/子系统中部署相同组件的不同副本-每个组件只需在一个地方部署。这可以是单个服务器,位于单个数据中心的集群,或地理分布的集群。在这里,您的组件的每个版本只部署一次,服务器/集群逻辑会自动创建所有必要的副本,从而简化整个持续集成/持续交付(CI/CD)周期。

如果新版本符合向客户端声明的通信接口,则不会发生不兼容性。另一方面,对于 DLLs/软件包,当保持相同接口时,可能会出现不兼容性,因为库模块可能与其客户端共享的其他 DLLs/软件包的依赖关系可能存在版本不匹配的情况。

在《将微服务架构应用于企业应用程序》第五章中讨论了组织协作服务的集群/网络。在本章中,我们将主要关注每个服务提供的通信接口。更具体地,我们将讨论以下主题:

  • 理解 SOA 方法的原则

  • SOAP 和 REST Web 服务

  • .NET 5 如何处理 SOA?

  • 用例-公开 WWTravelClub 套餐

通过本章结束时,您将了解如何通过 ASP.NET Core 服务公开 WWTravelClub 书籍用例中的数据。

技术要求

本章需要安装了所有数据库工具的 Visual Studio 2019 免费社区版或更高版本。

本章中的所有概念都将通过本书的 WWTravelClub 书籍用例的实际示例加以阐明。您可以在以下网址找到本章的代码:github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5

理解 SOA 方法的原则

与面向对象架构中的类一样,服务是接口的实现,而接口又来自系统的功能规范。因此,“服务”设计的第一步是定义其“抽象接口”。在此阶段,您将定义所有服务操作,作为操作您喜欢的语言类型(C#,Java,C ++,JavaScript 等)的接口方法,并决定使用同步通信实现哪些操作,使用异步通信实现哪些操作。

在这个初始阶段定义的接口不一定会在实际服务实现中使用,它们只是有用的设计工具。一旦我们决定了服务的架构,通常会重新定义这些接口,以便我们可以将它们适应架构的特殊性。

值得指出的是,SOA 消息必须保持与方法调用/回答相同类型的语义;也就是说,对消息的反应不能依赖于先前接收到的任何消息。在这里,消息必须彼此独立,服务不能记住任何先前接收到的消息。这就是我们所说的无状态开发。

例如,如果消息的目的是创建新的数据库条目,这种语义不能随着其他消息的上下文而改变,创建数据库条目的方式必须取决于当前消息的内容,而不是先前接收到的其他消息。因此,客户端不能创建会话,也不能登录到服务,执行一些操作,然后注销。认证令牌必须在每条消息中重复。

这种约束的原因是模块化、可测试性和可维护性。事实上,基于会话的服务将非常难以测试和修改,因为这些交互是隐藏在会话数据中的。

一旦您决定要由服务实现的接口,您必须决定采用哪种通信堆栈/SOA 架构。通信堆栈必须是某些官方或事实标准的一部分,以确保服务的互操作性。互操作性是 SOA 规定的主要约束:服务必须提供一个通信接口,不依赖于特定的通信库、实现语言或部署平台。

考虑到您已经决定了通信堆栈/架构,您需要根据架构的特点调整先前的接口(有关更多详细信息,请参阅本章的REST Web 服务子部分)。然后,您必须将这些接口翻译成所选择的通信语言。这意味着您必须将所有编程语言类型映射到所选择的通信语言中可用的类型。

数据的实际翻译通常由您的开发环境使用的 SOA 库自动执行。然而,可能需要一些配置,无论如何,我们必须意识到在每次通信之前我们的编程语言类型是如何转换的。例如,一些数字类型可能会被转换为精度较低或具有不同值范围的类型。

互操作性约束在微服务的情况下可以以较轻的形式解释,因为这些微服务无法在其集群之外访问,因此它们需要与属于同一集群的其他微服务进行通信。在这种情况下,这意味着通信堆栈可能是特定于平台的,以提高性能,但必须是标准的,以避免与随着应用程序发展可能添加到集群中的其他微服务的兼容性问题。

我们谈论的是通信堆栈而不是通信协议,因为 SOA 通信标准通常定义了消息内容的格式,并为用于嵌入这些消息的特定协议提供了不同的可能性。例如,SOAP 协议只是定义了各种消息的基于 XML 的格式,但 SOAP 消息可以通过各种协议传递。通常,用于 SOAP 的最常见协议是 HTTP,但您可以决定跳转到 HTTP 级别,并直接通过 TCP/IP 发送 SOAP 消息以获得更好的性能。

您应该采用的通信堆栈的选择取决于几个因素:

  • 兼容性约束:如果您的服务必须对业务客户在互联网上公开可用,那么您必须遵守最常见的选择,这意味着使用 SOAP over HTTP 或 JSON REST 服务。如果您的客户不是业务客户而是物联网IoT)客户,则最常见的选择是不同的。此外,在物联网中,不同应用领域使用的协议可能不同。例如,海洋车辆状态数据通常不与Signal K交换。

  • 开发/部署平台:并非所有通信堆栈都适用于所有开发框架和所有部署平台,但幸运的是,所有主要的开发/部署平台上都可以使用在公共业务服务中使用的所有最常见的通信堆栈,例如 SOAP 和基于 JSON 的 REST 通信。

  • 性能:如果您的系统不向外部公开,而是微服务集群的私有部分,性能考虑将具有更高的优先级。在这种情况下,我们将在本章后面讨论的 gRPC 可以被提及为一个不错的选择。

  • 团队中工具和知识的可用性:在选择可接受的通信堆栈时,团队/组织中的知识和工具的可用性具有重要的影响。然而,这种约束始终比兼容性约束的优先级低,因为构想一个对团队易于实现但几乎没有人能使用的系统是毫无意义的。

  • 灵活性与可用功能:一些通信解决方案虽然不够完整,但提供了更高程度的灵活性,而另一些解决方案虽然更完整,但提供的灵活性较少。在过去几年中,对灵活性的需求促使从基于 SOAP 的服务转向更灵活的 REST 服务。在本节的其余部分中,我们将更详细地讨论 SOAP 和 REST 服务。

  • 服务描述:当服务必须在互联网上公开时,客户端应用程序需要一个公开可用的服务规范描述,以设计其通信客户端。一些通信堆栈包括用于描述服务规范的语言和约定。以这种方式公开的正式服务规范可以被处理,以便它们自动创建通信客户端。SOAP 进一步允许通过包含每个 Web 服务可以执行的任务信息的公共基于 XML 的目录来发现服务。

选择要使用的通信堆栈后,必须使用开发环境中可用的工具来实现符合所选通信堆栈的服务。有时,开发工具会自动确保通信堆栈的符合性,但有时可能需要一些开发工作。例如,在.NET 世界中,如果使用 WCF,则开发工具会自动确保 SOAP 服务的符合性,而 REST 服务的符合性则由开发人员负责。SOA 解决方案的一些基本特性如下:

  • 身份验证:允许客户端进行身份验证以访问服务操作。

  • 授权:处理客户端的权限。

  • 安全性:这是通信如何保持安全的方式,即如何防止未经授权的系统读取和/或修改通信内容。通常,加密可以防止未经授权的修改和阅读,而电子签名算法只能防止修改。

  • 异常:向客户返回异常。

  • 消息可靠性:确保在可能的基础设施故障的情况下,消息能够可靠地到达目的地。

虽然有时是可取的,但以下特性并不总是必要的:

  • 分布式事务:处理分布式事务的能力,因此当分布式事务失败或中止时,撤消您所做的所有更改。

  • 支持发布者/订阅者模式:事件和通知的支持方式。

  • 寻址:引用其他服务和/或服务/方法的支持方式。

  • 路由:消息如何通过服务网络进行路由。

本节的其余部分致力于描述 SOAP 和 REST 服务,因为它们是在集群/服务器之外公开的业务服务的事实标准。出于性能原因,微服务使用其他协议,在第五章将微服务架构应用于企业应用程序第六章Azure 服务布局;和第七章Azure Kubernetes 服务中进行了讨论。对于集群间通信,使用高级消息队列协议AMQP),在进一步阅读部分给出了链接。

SOAP Web 服务

简单对象访问协议SOAP)允许单向消息和答复/响应消息。通信可以是同步的也可以是异步的,但是,如果底层协议是同步的,比如在 HTTP 的情况下,发送者会收到一个确认消息,说明消息已经被接收(但不一定被处理)。当使用异步通信时,发送者必须监听传入的通信。通常,异步通信是使用我们在第十一章设计模式和.NET 5 实现中描述的发布者/订阅者模式来实现的。

消息被表示为称为信封的 XML 文档。每个信封包含一个头部,一个主体和一个故障元素。主体是消息的实际内容所在。故障元素包含可能的错误,因此在通信发生时,这是交换异常的方式。最后,头部包含丰富协议的任何辅助信息,但不包含域数据。例如,头部可能包含身份验证令牌,和/或如果消息被签名的话。

用于发送 XML 信封的底层协议通常是 HTTP,但 SOAP 规范允许任何协议,因此我们可以直接使用 TCP/IP 或 SMTP。事实上,更为普遍的底层协议是 HTTP,因此,如果您没有选择其他协议的充分理由,应该使用 HTTP 以最大化服务的互操作性。

SOAP 规范包含消息交换的基础知识,而其他辅助功能则在称为WS- *的单独规范文档中描述,并通常通过在 SOAP 头部添加额外信息来处理。WS-*规范处理我们之前列出的 SOA 的所有基本和理想特性。例如,WS-Security负责安全性,包括身份验证、授权和加密/签名;WS-EventingWS-Notification是实现发布者/订阅者模式的两种替代方式;WS-ReliableMessaging关注在可能出现故障时消息的可靠传递;WS-Transaction关注分布式事务。

前述的WS-*规范并不是详尽无遗的,但是它们是更相关和受支持的特性。实际上,各种环境中的实际实现(如 Java 和.NET)提供了更相关的WS-*服务,但没有一种实现支持所有的WS-*规范。

SOAP 协议涉及的所有 XML 文档/文档部分在 XSD 文档中得到正式定义,这些文档是特殊的 XML 文档,其内容提供了 XML 结构的描述。此外,所有自定义数据结构(面向对象语言中的类和接口)必须被转换为 XSD,如果它们将成为 SOAP 信封的一部分。

每个 XSD 规范都有一个关联的命名空间,用于标识规范和可以找到规范的物理位置。命名空间和物理位置都是 URI。如果 Web 服务只能从内部网络访问,位置 URI 不需要公开访问。

服务的整个定义是一个可能包含对其他命名空间的引用的 XSD 规范,也就是说,对其他 XSD 文档的引用。简而言之,SOAP 通信的所有消息必须在 XSD 规范中定义。然后,如果服务器和客户端引用相同的 XSD 规范,它们可以进行通信。这意味着,例如,每次向消息添加另一个字段时,您需要创建一个新的 XSD 规范。之后,您需要更新所有引用旧消息定义的 XSD 文件到新消息定义,通过创建它们的新版本。反过来,这些修改需要为其他 XSD 文件创建其他版本,依此类推。因此,保持与先前行为兼容的简单修改(客户端可以简单地忽略添加的字段)可能会导致指数级的版本更改链。

在过去几年中,处理修改的困难以及处理所有WS-*规范的配置和性能问题,导致了逐渐向我们将在接下来的部分中描述的更简单的 REST 服务的转变。这一转变始于从 JavaScript 调用的服务,因为在 Web 浏览器中实现完整的 SOAP 客户端的困难。此外,复杂的 SOAP 机制对于运行在浏览器中的典型客户端的简单需求来说过于庞大,可能导致开发时间的完全浪费。

因此,面向非 JavaScript 客户端的服务开始大规模转向 REST 服务,如今首选的选择是 REST 服务,而 SOAP 则用于与遗留系统兼容或者在需要 REST 服务不支持的功能时使用。继续偏好 SOAP 系统的典型应用领域是支付/银行系统,因为这些系统需要WS-Transaction SOAP 规范提供的事务支持。在 REST 服务领域没有相应的功能。

REST 网络服务

REST 服务最初被构想为避免在简单情况下使用 SOAP 的复杂机制,比如从网页的 JavaScript 代码调用服务。然后,它们逐渐成为复杂系统的首选。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 服务类型兼容性规则。假设有几个服务使用包含“姓名”、“姓氏”和“地址”字符串字段的Person对象。这个对象由S1提供:

{
    Name: string,
    Surname: string,
    Address: string
} 

确保类型兼容性,如果服务和客户端引用前面定义的不同副本。客户端使用字段较少的定义也是可以接受的,因为它可以简单地忽略所有其他字段:

{
    Name: string,
    Surname: string,
} 

您只能在“自己”的代码中使用字段较少的定义。尝试在没有预期字段的情况下将信息发送回服务器可能会导致问题。

现在,想象一下这样的情景:您有一个S2服务,它从S1获取Person对象,并将它们添加到其某些方法返回的响应中。假设处理Person对象的S1服务用复杂对象替换了“地址”字符串:

{
    Name: string,
    Surname: string,
    Address:
        {
            Country: string,
            Town: string,
            Location: string
        }
} 

在破坏性更改之后,S2服务将不得不调整其调用S1服务的通信客户端以适应新格式。然后,它可以在使用Person对象在其响应中之前将新的Person格式转换为旧的格式。这样,S2服务避免了传播S1的破坏性更改。

总的来说,基于对象形状(嵌套属性树)而不是对相同正式类型定义的引用来确定类型兼容性,增加了灵活性和可修改性。我们为这种增加的灵活性付出的代价是,类型兼容性不能通过比较服务器和客户端接口的正式定义来自动计算。事实上,在没有明确规范的情况下,每次发布服务的新版本时,开发人员必须验证客户端和服务器共有的所有字段的语义是否与上一个版本保持不变。

REST 服务背后的基本思想是放弃严格的检查和复杂的协议,以换取更大的灵活性和简单性,而 SOAP 恰恰相反。

Rest 和本机 HTTP 功能

REST 服务宣言指出,REST 使用本机 HTTP 功能来实现所有所需的服务功能。因此,例如,身份验证将直接使用 HTTP 的Authorization字段进行,加密将通过 HTTPS 实现,异常将使用 HTTP 错误状态代码处理,路由和可靠的消息传递将由 HTTP 协议依赖的机制处理。通过使用 URL 来引用服务、它们的方法和其他资源来实现寻址。

由于 HTTP 是同步协议,因此没有原生支持异步通信。也没有原生支持发布者/订阅者模式,但两个服务可以通过各自公开一个端点来与发布者/订阅者模式进行交互。更具体地,第一个服务公开一个订阅端点,而第二个服务公开一个端点,用于接收其通知,这些通知通过在订阅期间交换的共同秘密进行授权。这种模式非常常见。GitHub 还允许我们将我们的 REST 服务发送到存储库事件。

REST 服务在实现分布式事务时没有简单的选项,这就是为什么支付/银行系统仍然更喜欢 SOAP。幸运的是,大多数应用领域不需要分布式事务所确保的强一致性。对于它们来说,轻量级的一致性形式,如最终一致性,已经足够,并且出于性能原因更受欢迎。请参阅第九章如何在云中选择您的数据存储,讨论各种一致性类型。

REST 宣言不仅规定了在 HTTP 中使用预定义的解决方案,还规定了使用类似 Web 的语义。更具体地说,所有服务操作必须被构想为对由 URL 标识的资源进行 CRUD 操作(同一资源可以由多个 URL 标识)。事实上,REST 是表现状态转移的缩写,意味着每个 URL 都是某种对象的表示。每种服务请求都需要采用适当的 HTTP 动词,如下所示:

  • GET(读取操作):URL 代表读取操作返回的资源。因此,GET操作模拟指针解引用。在操作成功的情况下,将返回 200(OK)状态码。

  • POST(创建操作):包含在请求体中的 JSON/XML 对象被添加为操作 URL 所代表的对象的新资源。如果新资源立即成功创建,将返回 201(已创建)状态码,以及取决于操作的响应对象和关于可以从哪里检索到创建的资源的指示。响应对象应包含标识创建的资源的最具体 URL。如果创建推迟到以后的时间,将返回 202(已接受)状态码。

  • 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 语言中方法的示例

现在,让我们通过一个简单的银行内部转账的示例来学习如何在 REST 语言中表达方法。银行账户可以通过以下 URL 表示:

https://mybank.com/bankaccounts/{bank account number} 

转账可以表示为一个PATCH请求,其主体包含一个表示金额、转账时间、描述和接收资金的账户的属性对象。

该操作修改了 URL 中提到的账户,同时也会影响到接收账户。如果账户没有足够的资金,将返回 403(禁止)状态码,以及一个包含所有错误细节的对象(错误描述、可用资金等)。

然而,由于所有的银行操作都记录在账单中,为与银行账户相关的bank account operations集合创建和添加一个新的转账对象是更好的表示转账的方式。在这种情况下,URL 可能是以下内容:

https://mybank.com/bankaccounts/{bank account number}/transactions 

在这里,HTTP 动词是POST,因为我们正在创建一个新对象。主体内容是相同的,如果资金不足,则返回422状态码。

转账的两种表示都会导致数据库中的相同更改。此外,一旦输入从不同的 URL 和可能不同的请求主体中提取出来,后续处理是相同的。在这两种情况下,我们有相同的输入和相同的处理 - 只是这两个请求的外观不同。

然而,引入虚拟的operations集合使我们能够通过几种特定于operations集合的方法来扩展服务。值得指出的是operations集合不需要与数据库表或任何物理对象连接:它存在于 URL 的世界中,并为我们建模转账提供了便利的方式。

REST 服务的增加使用导致了 REST 服务接口的描述被创建,就像为 SOAP 开发的那样。这个标准称为 OpenAPI。我们将在下一小节中讨论这个问题。

OpenAPI 标准

OpenAPI 是用于描述 REST API 的标准。目前是版本 3。整个服务由 JSON 端点描述,即用 JSON 对象描述服务的端点。这个 JSON 对象有一个适用于整个服务的一般部分,包含服务的一般特性,如其版本和描述,以及共享定义。

然后,每个服务端点都有一个特定部分,描述端点的 URL 或 URL 格式(如果 URL 中包含一些输入),所有的输入,所有可能的输出类型和状态码,以及所有的授权协议。每个特定端点部分可以引用一般部分中包含的定义。

本书不涵盖 OpenAPI 语法的描述,但在“进一步阅读”部分提供了参考资料。各种开发框架通过处理 REST API 代码自动生成 OpenAPI 文档,并由开发人员提供更多信息,因此您的团队不需要深入了解 OpenAPI 语法。其中一个例子是我们将在本章中介绍的Swashbuckle.AspNetCore NuGet 包。

“.NET 5 如何处理 SOA?”部分解释了我们如何在 ASP.NET Core REST API 项目中自动生成 OpenAPI 文档,而本章末尾的用例提供了其使用的实际示例。

我们将通过讨论如何处理 REST 服务中的身份验证和授权来结束本小节。

REST 服务授权和身份验证

由于 REST 服务是无状态的,当需要身份验证时,客户端必须在每个请求中发送身份验证令牌。该令牌通常放在 HTTP 授权标头中,但这取决于您使用的身份验证协议的类型。通过显式传输共享密钥是进行身份验证的最简单方式。可以使用以下代码来实现:

Authorization: Api-Key <string known by both server and client> 

共享密钥称为 API 密钥。由于在撰写本文时,尚无关于如何发送 API 密钥的标准,因此 API 密钥也可以在其他标头中发送,如下面的代码所示:

X-API-Key: <string known by both server and client> 

值得一提的是,基于 API 密钥的身份验证需要使用 HTTPS 来阻止共享密钥被窃取。API 密钥非常简单易用,但它们不传达有关用户授权的信息,因此当客户端允许的操作非常标准且没有复杂的授权模式时,可以采用它们。此外,在请求中交换 API 密钥时,API 密钥容易受到服务器或客户端的攻击。缓解这种情况的常见模式是创建一个“服务账户”用户,并将其授权限制在所需的范围内,并在与 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": "wwtravelclub.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 午夜开始的秒数。在这里,所有天都被认为是确切地有 86400 秒。

如果我们用唯一的 URI 表示,其他声明可以被定义为公共的;否则,它们被认为是颁发者和已知服务的私有声明。

.NET 5 如何处理 SOA?

WCF 技术尚未移植到.NET 5,并且没有计划对其进行完全移植。相反,微软正在投资于 gRPC,谷歌的开源技术。此外,.NET 5 通过 ASP.NET Core 对 REST 服务有出色的支持。

.NET 5 放弃 WCF 的主要原因如下:

  • 正如我们已经讨论过的,SOAP 技术在大多数应用领域已被 REST 技术取代。

  • WCF 技术严格绑定在 Windows 上,因此在.NET 5 中从头开始重新实现其所有功能将非常昂贵。由于对完整.NET 的支持将继续,需要 WCF 的用户仍然可以依赖它。

  • 作为一般策略,微软更倾向于投资于可以与其他竞争对手共享的开源技术。这就是为什么微软在.NET Core 3.0 开始提供了 gRPC 实现,而不是投资于 WCF。

下面的小节将介绍 Visual Studio 为我们提到的每种技术提供的支持。

SOAP 客户端支持

在 WCF 中,服务规范是通过.NET 接口定义的,实际的服务代码是在实现这些接口的类中提供的。端点、底层协议(HTTP 和 TCP/IP)以及任何其他特性都在配置文件中定义。配置文件可以通过易于使用的配置工具进行编辑。因此,开发人员只需提供标准的.NET 类作为服务行为,并以声明方式配置所有服务特性。这样,服务配置完全与实际的服务行为解耦,每个服务都可以重新配置,以适应不同的环境,而无需修改其代码。

虽然.NET 5 不支持 SOAP 技术,但它支持 SOAP 客户端。更具体地说,在 Visual Studio 中为现有的 SOAP 服务创建 SOAP 服务代理非常容易(请参阅第十一章设计模式和.NET 5 实现,讨论代理是什么以及代理模式)。

在服务的情况下,代理是实现服务接口的类,其方法通过调用远程服务的类似方法来执行它们的工作。

要创建服务代理,在解决方案资源管理器中的项目中右键单击依赖项,然后选择添加连接的服务。然后,在出现的表单中,选择Microsoft WCF 服务引用提供程序。在那里,您可以指定服务的 URL(包含 WSDL 服务描述的位置)、要添加代理类的命名空间等。在向导结束时,Visual Studio 会自动添加所有必要的 NuGet 包并生成代理类。这就足以创建此类的实例并调用其方法,以便与远程 SOAP 服务进行交互。

还有第三方,如 NuGet 包,提供了对 SOAP 服务的有限支持,但目前它们并不是很有用,因为这种有限支持不包括在 REST 服务中不可用的功能。

gRPC 支持

Visual Studio 2019 支持 gRPC 项目模板,可以为 gRPC 服务器和 gRPC 客户端生成脚手架。gRPC 实现了远程过程调用模式,提供了同步和异步调用,减少了客户端和服务器之间的消息流量。

尽管在撰写本书时,gRPC 在 Azure 的 IIS 和应用服务中不可用,但与此相关的伟大倡议。其中之一是 gRPC-Web(devblogs.microsoft.com/aspnet/grpc-web-for-net-now-available/)。

它的配置方式类似于 WCF 和.NET 远程调用,正如我们在第六章Azure Service Fabric末尾所描述的那样。也就是说,服务是通过接口定义的,它们的代码是在实现这些接口的类中提供的,而客户端通过实现相同的服务接口的代理与这些服务进行交互。

gRPC 是微服务集群内部通信的一个很好的选择,特别是如果集群不完全基于 Service Fabric 技术,并且不能依赖.NET 远程调用。由于所有主要语言和开发框架都有 gRPC 库,因此它可以在基于 Kubernetes 的集群中使用,以及在托管了在其他框架中实现的 Docker 镜像的 Service Fabric 集群中使用。

由于 gRPC 对数据的更紧凑表示以及更易于使用,因为协议的所有内容都由开发框架处理,所以 gRPC 比 REST 服务协议更有效。然而,在撰写本文时,它的特性都不依赖于成熟的标准,因此不能用于公开的端点 - 它只能用于集群内部通信。因此,我们不会详细描述 gRPC,但本章的进一步阅读部分包含了对 gRPC 的一般参考以及其.NET Core 实现的引用。

使用 gRPC 非常简单,因为 Visual Studio 的 gRPC 项目模板会自动生成 gRPC 服务和其客户端的所有内容。开发人员只需定义特定于应用程序的 C#服务接口和实现它的类。

您可以在docs.microsoft.com/en-us/aspnet/core/tutorials/grpc/grpc-start?view=aspnetcore-5.0上查看有关此实现的详细信息。

本节的其余部分专门介绍了.NET Core 对 REST 服务的支持,包括服务器端和客户端。

ASP.NET Core 简介

ASP.NET Core 应用程序是基于我们在“使用通用主机”子章节中描述的主机概念的.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>();
        });
} 

CreateDefaultBuilder设置了一个标准主机,而ConfigureWebHostDefaults配置了它,以便它可以处理 HTTP 管道。更具体地说,它为当前目录设置了IWebHostEnvironment接口的ContentRootPath属性。

然后,它从appsettings.jsonappsettings.[EnvironmentName].json中加载配置信息。一旦加载,JSON 对象属性中包含的配置信息可以使用 ASP.NET Core 选项框架映射到.NET 对象属性。更具体地说,appsettings.jsonappsettings.[EnvironmentName].json被合并,文件的特定于环境的信息会覆盖相应的appsettings.json设置。

EnvironmentName取自ASPNETCORE_ENVIRONMENT环境变量。反过来,当应用程序在 Visual Studio 中运行时,它在Properties\launchSettings.json文件中定义,位于解决方案资源管理器上方。在此文件中,您可以定义几个可以使用下拉菜单选择的环境,该下拉菜单位于 Visual Studio 运行按钮IIS Express旁边。默认情况下,IIS Express设置将ASPNETCORE_ENVIRONMENT设置为Development。以下是一个典型的launchSettings.json文件:

{
  "iisSettings": {
    "windowsAuthentication": false, 
    "anonymousAuthentication": true, 
    "iisExpress": {
      "applicationUrl": "http://localhost:2575",
      "sslPort": 44393
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    ...
    ...
    }
  }
} 

在应用程序发布时,可以在 Visual Studio 创建的发布 XML 文件中添加要使用的ASPNETCORE_ENVIRONMENT值。该值为<EnvironmentName>Staging</EnvironmentName>。它也可以在您的 Visual Studio ASP.NET Core 项目文件(.csproj)中指定:

<PropertyGroup>
<EnvironmentName>Staging</EnvironmentName>
</PropertyGroup> 

随后,应用程序配置主机日志记录,以便可以将日志写入控制台和调试输出。此设置可以通过进一步的配置进行更改。然后,它设置/连接 Web 服务器到 ASP.NET Core 管道。

当应用程序在 Linux 上运行时,ASP.NET Core 管道连接到.NET Core Kestrel Web 服务器。由于 Kestrel 是一个最小的 Web 服务器,您需要负责从完整的 Web 服务器(如 Apache 或 NGINX)进行反向代理请求,以添加 Kestrel 没有的功能。当应用程序在 Windows 上运行时,默认情况下,ConfigureWebHostDefaults将 ASP.NET Core 管道直接连接到Internet Information ServicesIIS)。但是,您也可以在 Windows 中使用 Kestrel,并且可以通过更改 Visual Studio 项目文件的AspNetCoreHostingModel设置将 IIS 请求反向代理到 Kestrel。

<PropertyGroup>
    ...
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
</PropertyGroup> 

UseStartup<Startup>()允许主机服务(参见第五章使用通用主机子章节,将 ASP.NET Core 管道的定义从项目的Startup.cs类的方法中获取。更具体地说,服务在其ConfigureServices(IServiceCollection services)方法中定义,而 ASP.NET Core 管道在Configure方法中定义。以下代码显示了使用 API REST 项目生成的标准Configure方法:

public void Configure(IApplicationBuilder app, 
    IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }
    app.UseHsts();
    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 应用程序的表示层部分中给出。

在下一小节中,我们将解释 MVC 框架如何让您实现 REST 服务。

使用 ASP.NET Core 实现 REST 服务

今天,我们可以保证 MVC 和 Web API 的使用已经得到巩固。在 MVC 框架中,HTTP 请求由称为控制器的类处理。每个请求都映射到调用控制器公共方法。所选的控制器和控制器方法取决于请求路径的形状,并且由路由规则定义,对于 REST API,通常通过与Controller类及其方法相关联的属性提供。

处理 HTTP 请求的Controller方法称为操作方法。当选择控制器和操作方法时,MVC 框架会创建一个控制器实例来处理请求。控制器构造函数的所有参数都通过Startup.cs类的ConfigureServices方法中定义的类型进行依赖注入解析。

有关如何在.NET Core 主机中使用依赖注入的描述,请参阅第五章应用微服务架构到企业应用程序使用通用主机子章节,并参阅第十一章设计模式和.NET 5 实现依赖注入模式子章节,以获取有关依赖注入的一般讨论。

以下是一个典型的 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请求上调用的,也就是在控制器名称后没有idGET请求上调用的。

多个操作方法可以具有相同的名称,但只有一个应与每个请求路径兼容;否则,将抛出异常。换句话说,路由规则和Http<verb>属性必须明确定义每个请求的哪个控制器及其哪个操作方法。

默认情况下,参数根据以下规则传递给 API 控制器的操作方法:

  • 如果路由规则将简单类型(整数浮点数日期时间)指定为参数,它们将从请求路径中获取,就像前面示例的[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.AddControllers().AddXmlSerializerFormatters()替换services.AddControllers()即可。

您可以通过使用适当的属性为操作方法参数添加前缀来自定义用于填充操作方法参数的源。以下代码显示了一些示例:

...MyActionMethod(....[FromHeader] string myHeader....)
// x is taken from a request header named myHeader
...MyActionMethod(....[FromServices] MyType x....)
// x is filled with an instance of MyType through dependency injection 

Action方法的返回类型必须是IActionResult接口或实现该接口的类型。反过来,IActionResult只有以下方法:

Task ExecuteResultAsync(ActionContext context); 

这种方法在正确的时间由 MVC 框架调用,以创建实际的响应和响应头。当传递给方法时,ActionContext对象包含 HTTP 请求的整个上下文,其中包括一个包含有关原始 HTTP 请求的所有必要信息的请求对象(标头、正文和 cookie),以及一个收集正在构建的响应的所有部分的响应对象。

您不必手动创建IActionResult的实现,因为ControllerBase已经有了创建IActionResult实现的方法,以便生成所有必要的 HTTP 响应。其中一些方法如下。

  • OK:这返回一个 200 状态代码,以及一个可选的结果对象。它可以作为return OK()return OK(myResult)使用。

  • BadRequest:这返回一个 400 状态代码,以及一个可选的响应对象。

  • Created(string uri, object o):这返回一个 201 状态代码,以及一个结果对象和创建的资源的 URI。

  • Accepted:这返回一个 202 状态结果,以及一个可选的结果对象和资源 URI。

  • Unauthorized:这返回一个 401 状态结果,以及一个可选的结果对象。

  • Forbid:这返回一个 403 状态结果,以及一个可选的失败权限列表。

  • StatusCode(int statusCode, object o = null):这返回一个自定义状态码,以及一个可选的结果对象。

操作方法可以直接返回一个结果对象,如 return myObject。这相当于返回 OK(myObject)

当所有结果路径返回相同类型的结果对象,比如 MyType,操作方法可以声明为返回 ActionResult<MyType>。您也可以返回诸如 NotFound 的响应,但是通过这种方法肯定会得到更好的类型检查。

默认情况下,结果对象以 JSON 格式序列化在响应主体中。然而,如果在 MVC 框架处理管道中添加了 XML 格式化程序,如前所示,结果的序列化方式取决于 HTTP 请求的 Accept 标头。更具体地说,如果客户端明确要求 XML 格式的 Accept 标头,对象将以 XML 格式序列化;否则,它将以 JSON 格式序列化。

作为输入传递给操作方法的复杂对象可以使用验证属性进行验证,如下所示:

public record 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.AddControllers();
    ...
    services.AddAuthorization(options =>
   {
      options.AddPolicy("CanDrive", policy =>
         policy.RequireAssertion(context =>
         context.User.HasClaim(c =>c.Type == "HasDrivingLicense"));
   });
} 

之后,您可以使用 [Authorize(Policy = "Father")] 装饰操作方法或控制器。

在使用基于 JWT 的授权之前,您必须在 Startup.cs 中进行配置。首先,您必须在 Configure 方法中定义的 ASP.NET Core 处理管道中添加处理身份验证令牌的中间件,如下所示:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
   ...
   app.UseAuthorization();
   ...
   app.UseEndpoints(endpoints =>
   {
   endpoints.MapControllers();
   });

} 

然后,您必须在 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.GetByte
                ("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.UseEndpoints(endpoints =>
{
   endpoints.MapControllers();
}); 

然后,我们必须在ConfigureServices方法中添加一些配置选项,如下所示:

services.AddSwaggerGen(c =>
{
c.SwaggerDoc("MyServiceName", new OpenApiInfo
   {
        Version = "v1",
        Title = "ToDo API",
        Description = "My service description",
   });
}); 

SwaggerDoc方法的第一个参数是文档端点名称。默认情况下,文档端点可以通过<webroot>//swagger/<endpoint name>/swagger.json路径访问,但可以通过多种方式进行更改。Info类中包含的其余信息是不言自明的。

我们可以添加多个SwaggerDoc调用来定义多个文档端点。然而,默认情况下,所有文档端点将包含相同的文档,其中包括项目中包含的所有 REST 服务的描述。可以通过在services.AddSwaggerGen(c => {...})中调用c.DocInclusionPredicate(Func<string, ApiDescription> predicate)方法来更改此默认设置。

DocInclusionPredicate必须传递一个函数,该函数接收一个 JSON 文档名称和一个操作方法描述,并且必须在该 JSON 文档中包含操作的文档时返回true

要声明您的 REST API 需要 JWT 令牌,您必须在services.AddSwaggerGen(c => {...})中添加以下代码:

var security = new Dictionary<string, IEnumerable<string>>
{
    {"Bearer", new string[] { }},
};
c.AddSecurityDefinition("Bearer", new ApiKeyScheme
{
    Description = "JWT Authorization header using the Bearer scheme. 
    Example: \"Authorization: Bearer {token}\"",
    Name = "Authorization",
    In = "header",
    Type = "apiKey"
});
c.AddSecurityRequirement(security); 

您可以使用从三斜杠注释中提取的信息来丰富 JSON 文档端点,这些注释通常用于生成自动代码文档。以下代码显示了一些示例。以下代码片段显示了如何添加方法描述和参数信息:

/// <summary>
/// Deletes a specific TodoItem.
/// </summary>
/// <param name="id">id to delete</param>
[HttpDelete("{id}")]
public IActionResultDelete(long id) 

以下代码片段显示了如何添加使用示例:

/// <summary>
/// Creates an item.
/// </summary>
/// <remarks>
/// Sample request:
///
/// POST /MyItem
/// {
/// "id": 1,
/// "name": "Item1"
/// }
///
/// </remarks> 

以下代码片段显示了如何为每个 HTTP 状态代码添加参数描述和返回类型描述:

/// <param name="item">item to be created</param>
/// <returns>A newly created TodoItem</returns>
/// <response code="201">Returns the newly created item</response>
/// <response code="400">If the item is null</response> 

要从三斜杠注释中提取信息,我们必须通过在项目文件(.csproj)中添加以下代码来启用代码文档创建:

<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup> 

然后,我们必须通过添加以下代码来从services.AddSwaggerGen(c => {...})中启用代码文档处理:

var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(xmlPath); 

一旦我们的文档端点准备就绪,我们可以添加一些中间件,该中间件包含在相同的Swashbuckle.AspNetCore NuGet 包中,以生成一个友好的用户界面,我们可以在其上测试我们的 REST API:

app.UseSwaggerUI(c =>
{
    c.SwaggerEndpoint("/swagger/<documentation name>/swagger.json", "
    <api name that appears in dropdown>");
}); 

如果您有多个文档端点,您需要为每个端点添加一个SwaggerEndpoint调用。我们将使用此接口来测试本章中定义的 REST API。

一旦您有一个可用的 JSON 文档端点,您可以使用以下方法之一自动生成代理类的 C#或 TypeScript 代码:

在下一小节中,您将学习如何从另一个 REST API 或.NET Core 客户端调用 REST API。

.Net Core HTTP 客户端

System.Net.Http命名空间中的HttpClient类是一个.NET 标准 2.0 内置的 HTTP 客户端类。虽然它可以直接使用,但在重复创建和释放HttpClient实例时会出现一些问题,如下所示:

  • 它们的创建是昂贵的。

  • 例如,当HttpClientusing语句中被释放时,底层连接不会立即关闭,而是在第一次垃圾回收会话时关闭,这是一个重复的创建。释放操作会迅速耗尽操作系统可以处理的最大连接数。

因此,要么重用单个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。这需要一个额外的包System.Net.Http.Json。在这里,我们声明注入到代理构造函数中的HttpClient已存储在webClient私有字段中:

//Add a bearer token to authenticate the call
webClient.DefaultRequestHeaders.Add("Authorization", "Bearer " + token);
...
//Call service method with a POST verb and get response
var response = await webClient.PostAsJsonAsync<MyPostModel>("my/method/relative/path",
    new MyPostModel
    {
        //fill model here
        ...
    });
//extract response status code
var status = response.StatusCode;
...
//extract body content from response
string stringResult = await response.Content.ReadAsStringAsync(); 

如果您使用 Polly,则无需拦截和处理通信错误,因为 Polly 会执行此任务。首先,您需要验证状态代码以决定下一步该做什么。然后,您可以解析响应主体中包含的 JSON 字符串,以获得一个.NET 类型的实例,通常取决于状态代码。执行解析的代码基于System.Text.Json NuGet 包的JsonSerializer类,如下所示:

var result = 
  JsonSerializer.Deserialize<MyResultClass>(stringResult); 

执行 GET 请求类似,但是,而不是调用PostAsJsonAsync,您需要调用GetAsync,如下所示。使用其他 HTTP 动词完全类似:

var response = 
  await webClient.GetAsync("my/getmethod/relative/path"); 

正如您可以在本主题中检查的那样,访问 HTTP API 非常简单,并且需要实现一些.NET 5 库。自.NET Core 开始,微软一直在努力改进框架的性能和简单性。您需要与他们不断实施的文档和设施保持联系。

使用情况 - 暴露 WWTravelClub 套餐

在本节中,我们将实现一个 ASP.NET REST 服务,列出给定假期开始和结束日期可用的所有套餐。出于教学目的,我们不会按照第十二章中描述的最佳实践结构化应用程序;相反,我们将简单地使用 LINQ 查询生成结果,并直接放置在控制器操作方法中。一个良好结构化的 ASP.NET Core 应用程序将在第十五章中介绍,介绍 ASP.NET Core MVC,该章节专门介绍 MVC 框架。

让我们复制WWTravelClubDB解决方案文件夹,并将新文件夹重命名为WWTravelClubREST。WWTravelClubDB 项目是在第八章的各个部分逐步构建的,在 C#中与实体框架核心交互。让我们打开新解决方案,并向其中添加一个名为WWTravelClubREST的新 ASP.NET Core API 项目(与新解决方案文件夹同名)。为简单起见,选择不进行身份验证。右键单击新创建的项目,选择设置为启动项目,使其成为运行解决方案时启动的默认项目。

最后,我们需要向 WWTravelClubDB 项目添加引用。

ASP.NET Core 项目将配置常量存储在appsettings.json文件中。让我们打开这个文件,并向其中添加我们在 WWTravelClubDB 项目中创建的数据库连接字符串,如下所示:

{
    "ConnectionStrings": {
        "DefaultConnection": "Server=
   (localdb)\\mssqllocaldb;Database=wwtravelclub;
Trusted_Connection=True;MultipleActiveResultSets=true"
    },
    ...
    ...
} 

现在,我们必须在Startup.csConfigureServices方法中添加 WWTravelClubDB 实体框架数据库上下文,如下所示:

services.AddDbContext<WWTravelClubDB.MainDBContext>(options =>
options.UseSqlServer(
Configuration.GetConnectionString("DefaultConnection"),
            b =>b.MigrationsAssembly("WWTravelClubDB"))); 

传递给AddDbContext的选项对象设置指定了使用从appsettings.json配置文件的ConnectionStrings部分提取的连接字符串的 SQL 服务器,使用Configuration.GetConnectionString("DefaultConnection")方法。b =>b.MigrationsAssembly("WWTravelClubDB") lambda 函数声明了包含数据库迁移的程序集的名称(参见第八章在 C#中与实体框架核心交互),在我们的情况下,这是由 WWTravelClubDB 项目生成的 DLL。为了使前面的代码编译,您应该添加Microsoft.EntityFrameworkCore

由于我们希望使用 OpenAPI 文档丰富我们的 REST 服务,让我们添加对Swashbuckle.AspNetCore NuGet 包的引用。现在,我们可以向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 自动生成的ValuesController。然后,右键单击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 record PackagesListDTO
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int DurationInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public string DestinationName { get; set; }
        public int DestinationId { get; set; }
    }
} 

最后,让我们将以下using子句添加到我们的控制器代码中,以便我们可以轻松地引用我们的 DTO 和 Entity Framework LINQ 方法:

using Microsoft.EntityFrameworkCore;
using 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,
            DurationInDays = m.DurationInDays,
            Id = m.Id,
            Price = m.Price,
            DestinationName = m.MyDestination.Name,
            DestinationId = m.DestinationId
        })
        .ToListAsync();
    return Ok(res);
}
catch (Exception err)
{
    return StatusCode(500, err);
} 

LINQ 查询类似于我们在第八章中测试的WWTravelClubDBTest项目中包含的查询,即在 C#中与数据交互 - Entity Framework Core。一旦结果计算完成,就会通过OK调用返回。该方法的代码通过捕获异常并返回 500 状态代码来处理内部服务器错误,因为坏请求会在Controller方法被ApiController属性调用之前自动处理。

让我们运行解决方案。当浏览器打开时,无法从我们的 ASP.NET Core 网站接收任何结果。让我们修改浏览器 URL,使其为https://localhost:<previous port>/swagger。OpenAPI 文档的用户界面将如下所示:

图 14.1:Swagger 输出

PackagesListDTO是我们定义的用于列出包的模型,而ProblemDetails是在发生坏请求时用于报告错误的模型。通过单击GET按钮,我们可以获取有关我们的GET方法的更多详细信息,并且还可以测试它,如下面的屏幕截图所示:

图 14.2:GET 方法详细信息

在插入数据库中由包覆盖的日期时要注意;否则,将返回一个空列表。在前面的屏幕截图中显示的应该可以工作。

日期必须以正确的 JSON 格式输入;否则,将返回 400 Bad Request 错误,就像下面的代码中所示的那样:

{
  "errors": {
    "start": [
      "The value '2019' is not valid."
    ]
  },
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "80000008-0000-f900-b63f-84710c7967bb"
} 

如果插入正确的输入参数,Swagger UI 将以 JSON 格式返回满足查询的包。

就是这样!您已经实现了您的第一个带有 OpenAPI 文档的 API!

总结

在本章中,我们介绍了 SOA 及其设计原则和约束。其中,值得记住的是互操作性。

然后,我们专注于业务应用程序的成熟标准,以实现公开服务所需的互操作性。因此,SOAP 和 REST 服务以及从 SOAP 服务过渡到 REST 服务的细节被详细讨论,这在过去几年中在大多数应用领域都已经发生。然后,更详细地描述了 REST 服务原则、验证/授权和其文档。

最后,我们看了一下在.NET 5 中可用的工具,我们可以使用这些工具来实现和交互服务。我们看了一系列用于集群内通信的框架,如.NET remoting 和 gRPC,以及用于 SOAP 和基于 REST 的公共服务的工具。

在这里,我们主要关注 REST 服务。它们的 ASP.NET Core 实现被详细描述,以及我们可以用来验证/授权它们和它们的文档的技术。我们还专注于如何实现高效的.NET Core 代理,以便我们可以与 REST 服务交互。

在下一章中,我们将学习如何在 ASP .NET Core MVC 上构建应用程序时使用.NET 5。

问题

  1. 服务可以使用基于 cookie 的会话吗?

  2. 使用自定义通信协议实现服务是一个好的做法吗?为什么是或者为什么不是?

  3. POST请求到 REST 服务会导致删除吗?

  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/。您还可以查看devblogs.microsoft.com/aspnet/grpc-web-for-net-now-available/上的 gRPC-Web。

ASP.NET Core 服务的更多详细信息可在官方文档中找到:docs.microsoft.com/en-US/aspnet/core/web-api/。有关.NET Core 的 HTTP 客户端的更多信息,请访问这里:docs.microsoft.com/en-US/aspnet/core/fundamentals/http-requests

有关 JWT 令牌认证的更多信息,请访问这里:jwt.io/。如果您想要使用 Identity Server 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 实现 Web 应用程序。

ASP.NET Core 是一个实现 Web 应用程序的.NET 框架。ASP.NET Core 在之前的章节中已经部分描述过,因此本章主要将重点放在 ASP.NET Core MVC 上。具体来说,本章将涵盖以下主题:

  • 理解 Web 应用程序的表示层

  • 理解 ASP.NET Core MVC 结构

  • ASP.NET Core 的最新版本有哪些新特性?

  • 理解 ASP.NET Core MVC 与设计原则之间的联系

  • 用例 - 在 ASP.NET Core MVC 中实现 Web 应用程序

我们将回顾并详细介绍 ASP.NET Core 框架的结构,部分内容在第十四章使用.NET Core 应用服务导向架构,和第四章决定最佳基于云的解决方案中已经讨论过。这里的重点是如何基于所谓的模型视图控制器MVC)架构模式实现基于 Web 的表示层。

我们还将分析 ASP.NET Core 5.0 版本中所有新功能,以及在 ASP.NET Core MVC 框架中包含的或在典型的 ASP.NET Core MVC 项目中使用的架构模式。其中一些模式在第十一章设计模式和.NET 5 实现,和第十二章理解软件解决方案中的不同领域中已经讨论过,而另一些,如 MVC 模式本身,是新的。

本章末尾的实际示例将教你如何实现一个 ASP.NET Core MVC 应用程序,以及如何组织整个 Visual Studio 解决方案。该示例描述了一个完整的 ASP.NET Core MVC 应用程序,用于编辑 WWTravelClub 书籍用例的包。

技术要求

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

本章中的所有概念都将以基于 WWTravelClub 书籍用例的实际示例进行澄清。本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5上找到。

理解 Web 应用程序的表示层

本章讨论了基于 ASP.NET Core 框架的 Web 应用程序的表示层的架构。Web 应用程序的表示层基于三种技术:

  • 通过 REST 或 SOAP 服务与服务器交换数据的移动或桌面本地应用程序:我们没有讨论它们,因为它们严格绑定到客户端设备及其操作系统。因此,分析它们完全超出了本书的范围,这需要一本专门的书。

  • 单页应用程序SPA):这些是基于 HTML 的应用程序,其动态 HTML 是在客户端使用 JavaScript 或 WebAssembly(一种可以作为 JavaScript 的高性能替代品的跨浏览器汇编)创建的。与本地应用程序一样,SPA 通过 REST 或 SOAP 服务与服务器交换数据,但它们的优势在于独立于设备及其操作系统,因为它们在浏览器中运行。第十六章Blazor WebAssembly,描述了基于 WebAssembly 的 Blazor SPA 框架,因为它本身是基于 WebAssembly 编译的.NET 运行时。

  • 由服务器创建的 HTML 页面,其内容取决于要向用户显示的数据:本章将讨论的 ASP.NET Core MVC 框架是用于创建这种动态 HTML 页面的框架。

本章的其余部分侧重于如何在服务器端创建 HTML 页面,更具体地说,侧重于 ASP.NET Core MVC,该内容将在下一节中介绍。

理解 ASP.NET Core MVC 结构

ASP.NET Core 基于通用主机的概念,如第五章将微服务架构应用于企业应用程序使用通用主机子部分中所解释的。ASP.NET Core 的基本架构在第十四章应用.NET Core 实现面向服务的架构简短介绍 ASP.NET Core子部分中概述了。

值得提醒您的是,主机配置是通过在Startup.cs文件中定义的Startup类来委托的,方法是调用IWebHostBuilder接口的.UseStartup<Startup>()方法。Startup类的ConfigureServices(IServiceCollection services)定义了可以通过依赖注入DI)注入到对象构造函数中的所有服务。DI 在第五章将微服务架构应用于企业应用程序使用通用主机子部分中有详细描述。

另一方面,Configure(IApplicationBuilder app, IWebHostEnvironment 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 模块的管道来处理,如下图所示:

图 15.1:ASP.NET Core 管道

插入到管道中的对象是包含传入请求数据的HttpContext实例。更具体地说,HttpContextRequest属性包含一个HttpRequest对象,其属性以结构化方式表示传入请求。有关标头、cookie、请求路径、参数、表单字段和请求正文的属性。

如果我们将它们写入HttpContext实例的Response属性中包含的HttpResponse对象中,各种模块可以为最终响应的构建做出贡献。HttpResponse类类似于HttpRequest类,但其属性指的是正在构建的响应。

一些模块可以构建一个中间数据结构,然后由管道中的其他模块使用。一般来说,这样的中间数据可以存储在HttpContext对象的Items属性中包含的IDictionary<object, object>的自定义条目中。但是,有一个预定义的属性User,其中包含有关当前登录用户的信息。已登录用户不会自动计算,因此它们必须由身份验证模块计算。第十四章应用.NET Core 实现面向服务的架构ASP.NET Core 服务授权子部分解释了如何向 ASP.NET Core 管道添加执行基于 JWT 令牌的身份验证的标准模块。

HttpContext还有一个Connection属性,其中包含与客户端建立的基础连接的信息,以及一个WebSockets属性,其中包含与客户端建立的可能的基于 WebSocket 的连接的信息。

HttpContext还有一个Features属性,其中包含IDictionary<Type, object>,指定了托管 Web 应用程序和管道模块的 Web 服务器支持的功能。功能可以使用.Set<TFeature>(TFeature o)方法进行设置,并可以使用.Get<TFeature>()方法进行检索。

Web 服务器功能是由框架自动添加的,而所有其他功能是在处理HttpContext时由管道模块添加的。

HttpContext还通过其RequestServices属性为我们提供了对依赖注入引擎的访问。您可以通过调用.RequestService.GetService(Type t)方法或者更好的是建立在其之上的.GetRequiredService<TService>()扩展方法来获取由依赖引擎管理的类型的实例。然而,正如我们将在本章的其余部分中看到的那样,所有由依赖注入引擎管理的类型通常都会自动注入到构造函数中,因此这些方法仅在构建自定义中间件或其他自定义 ASP.NET Core 引擎时使用。

为了访问HttpContext属性,不仅模块可以访问HttpContext实例,应用程序代码也可以通过 DI 访问。只需将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 before it is passed to the next 
            module.

        */

        await _next.Invoke(context);
        /*
            Insert here other module specific code that processes the 
            HttpContext instance, after all modules that follow this
            module finished their processing.
        */
    }
} 

通常,每个模块处理由管道中前一个模块传递的HttpContext实例,然后调用await _next.Invoke(context)来调用管道中其余模块。当其他模块完成其处理并为客户端准备好响应时,每个模块可以在_next.Invoke(context)调用后的代码中执行进一步的响应后处理。

通过在Startup.cs文件的Configure方法中调用UseMiddleware<T>方法,可以在 ASP.NET Core 管道中注册模块,如下所示:

public void Configure(IApplicationBuilder app, IWebHostEnvironment 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 类型添加到应用程序 DI 引擎中。在这些情况下,我们还定义了一个名为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,那么您就完成了!

图 15.2:Visual Studio 部署设置

否则,您可以按照以下步骤进行:

  1. 在向导中填写信息后,保存发布配置文件而不进行发布。

  2. 然后,使用文本编辑器编辑配置文件并添加 XML 属性,例如<EnvironmentName>Staging</EnvironmentName>。由于应用程序发布期间可以选择所有已定义的发布配置文件,因此您可以为每个环境定义不同的发布配置文件,然后在每次发布时选择所需的配置文件。

  • 在应用程序的 Visual Studio ASP.NET Core 项目文件(.csproj)中添加以下代码,可以指定部署期间必须将ASPNETCORE_ENVIRONMENT设置为的值:
<PropertyGroup> 
    <EnvironmentName>Staging</EnvironmentName>
</PropertyGroup> 

在 Visual Studio 中进行开发时,可以在 ASP.NET Core 项目的Properties\launchSettings.json文件中指定应用程序运行时要给ASPNETCORE_ENVIRONMENT的值。launchSettings.json文件包含几个命名的设置组。这些设置配置了在从 Visual Studio 运行 Web 应用程序时如何启动。您可以通过选择运行按钮旁边的下拉列表中的组名来应用组的所有设置:

图 15.3:启动设置组的选择

您从下拉列表中的选择将显示在运行按钮上,默认选择为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),在哪里启动浏览器以及一些环境变量的值。

通过IWebHostEnvironment接口可以在 ASP.NET Core 管道定义期间测试从ASPNETCORE_ENVIRONMENT操作系统环境变量中加载的当前环境。这是因为IWebHostEnvironment实例作为参数传递给Startup.cs文件的Configure方法。IWebHostEnvironment也通过 DI 对用户代码的其余部分可用。

IWebHostEnvironment.IsEnvironment(string environmentName)检查ASPNETCORE_ENVIRONMENT的当前值是否为environmentName。还有特定的快捷方式用于测试开发(.IsDevelopment())、生产(.IsProduction())和暂存(.IsStaging())。IWebHostEnvironment还包含 ASP.NET Core 应用程序的当前根目录(.WebRootPath)和用于静态文件的目录(.ContentRootPath),这些文件由 Web 服务器原样提供(CSS、JavaScript、图像等)。

在 Visual Studio 资源管理器中,launchSettings.json和所有发布配置文件都可以作为Properties节点的子节点访问,如下截图所示:

图 15.4:启动设置文件

一旦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中。

值得一提的是,选项类的属性可以应用于我们将用于 ViewModels 的相同验证属性(参见服务器端和客户端验证小节)。

下一小节描述了 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();
}
else
{
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
}
app.UseHttpsRedirection(); 

如果有错误,如果应用程序处于开发环境中,UseDeveloperExceptionPage安装的模块会向响应中添加详细的错误报告。这个模块是一个有价值的调试工具。

如果应用程序在非开发模式下发生错误,UseExceptionHandler将从接收到的路径恢复请求处理;也就是说,从/Home/Error开始。换句话说,它模拟了一个带有/Home/Error路径的新请求。此请求被推送到标准 MVC 处理,直到达到与/Home/Error路径相关联的端点,开发人员应该在那里放置处理错误的自定义代码。

当应用程序不处于开发模式时,UseHsts会向响应中添加Strict-Transport-Security头,通知浏览器只能使用 HTTPS 访问应用程序。声明后,符合规范的浏览器应自动将应用程序的任何 HTTP 请求转换为Strict-Transport-Security头中指定的时间内的 HTTPS 请求。默认情况下,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、图像和字体文件)通过其实际路径从 web 访问。

UseCookiePolicy 在 .NET 5 模板中已被移除,但您仍然可以手动添加。它确保 ASP.NET Core 管道处理 cookie,但仅在用户已经同意使用 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 in the code instead of using the configuration file:
services.Configure<CookiePolicyOptions>(options =>
{
    options.CheckConsentNeeded = context => true;
}); 

UseAuthentication 启用身份验证方案,仅在创建项目时选择身份验证方案时才会出现。

可以通过在 ConfigureServices 方法中配置选项对象来启用特定的身份验证方案,如下所示:

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 属性的身份验证方案。除了 DefaultScheme,还有其他属性允许进行更高级的自定义。

有关 JWT 身份验证的更多信息,请参阅 第十四章 使用 .NET Core 应用服务导向架构ASP.NET Core 服务授权 子章节。

如果只指定 services.AddAuthentication(),则假定使用具有默认参数的基于 cookie 的身份验证。

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中间件进行正常执行,还是立即返回 401(未经授权)/403(禁止)响应。

UseRouting middleware, but they are listed in the UseEndpoints method. While it might appear strange that URL patterns are not defined directly in the middleware that uses them, this was done mainly for coherence with the previous ASP.NET Core versions. In fact, previous versions contained no method analogous to UseRouting, but a unique middleware at the end of the pipeline. In the new version, patterns are still defined at the end of the pipeline for coherence with previous versions, but now, UseEndpoints just creates a data structure containing all patterns when the application starts. Then, this data structure is processed by the UseRouting middleware, as shown in the following code:
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}");

}); 

MapControllerRoute定义了与 MVC 引擎相关的模式,这将在下一小节中描述。还有其他定义其他类型模式的方法。例如,像.MapHub<MyHub>("/chat")这样的调用将路径映射到处理SignalR的 hub,这是建立在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 中,ASP.NET Core MVC 还使用放置在控制器或控制器方法上的属性来指定授权规则。但是,也可以将AuthorizeAttribute的实例添加到模式中,以将其授权约束应用于匹配该模式的所有 URL,如下例所示:

endpoints
 .MapHealthChecks("/health")
 .RequireAuthorization(new AuthorizeAttribute(){ Roles = "admin", }); 

上述代码使健康检查路径仅对管理员用户可用。

在描述了 ASP.NET Core 框架的基本结构之后,我们现在可以转向更多 MVC 特定的功能。下一小节描述了控制器,并解释了它们如何通过 ViewModel 与称为 Views 的 UI 组件进行交互。

定义控制器和 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 控制器的工作方式类似于我们在第十四章使用.NET Core 实现 REST 服务中描述的 API 控制器。唯一的区别是 API 控制器预期生成 JSON 或 XML,而 MVC 控制器预期生成 HTML。因此,虽然 API 控制器继承自ControllerBase类,但 MVC 控制器继承自Controller类,后者又继承自ControllerBase类,并添加了对 HTML 生成有用的方法,例如调用视图,在下一小节中描述,并创建重定向响应。

MVC 控制器也可以使用类似于 API 控制器之一的路由技术;即基于控制器和控制器方法属性的路由。通过在UseEndpoints中调用MapControllerRoute()方法来启用此行为。如果此调用放置在所有其他MapControllerRoute调用之前,则控制器路由优先于MapControllerRoute模式;否则,情况相反。

我们在 API 控制器中看到的所有属性也可以用于 MVC 控制器和动作方法(HttpGetHttpPost...Authorize等)。开发人员可以通过继承ActionFilter类或其他派生类来编写自定义属性。我现在不会详细介绍这些内容,但这些细节可以在官方文档中找到,官方文档在进一步阅读部分中有提到。

UseEndpoints模块调用控制器时,由于控制器实例本身是由 DI 引擎返回的,因此所有构造函数参数都由 DI 引擎填充,并且由于 DI 会以递归方式自动填充构造函数参数。

另一方面,动作方法参数来自以下来源:

  • 请求标头

  • 当前请求匹配的模式中的变量

  • 查询字符串参数

  • 表单参数(在 POST 请求的情况下)

  • 依赖注入(DI)

使用 DI 填充的参数按类型匹配,而所有其他参数按名称匹配,忽略字母大小写。也就是说,动作方法参数名称必须与标头、查询字符串、表单或模式变量匹配。当参数是复杂类型时,将在每个属性中搜索匹配,使用属性名称进行匹配。在嵌套复杂类型的情况下,将在每个嵌套属性的路径中搜索匹配,并且与路径相关联的名称是通过将路径中的所有属性名称链接在一起并用点分隔来获得的。例如,Property1.Property2.Property3...Propertyn是由嵌套属性Property1Property2、....、Propertyn组成的路径相关联的名称。以这种方式获得的名称必须与标头名称、模式变量名称、查询字符串参数名称等匹配。例如,包含复杂Address对象的OfficeAddress属性将生成名称如OfficeAddress.CountryOfficeAddress.Town等。

默认情况下,简单类型参数与模式变量和查询字符串变量匹配,而复杂类型参数与表单参数匹配。然而,可以通过在参数前加上属性来更改前述默认值,如下所述:

  • [FromForm] 强制与表单参数匹配

  • [FromHeader] 强制与请求标头匹配

  • [FromRoute] 强制与模式变量匹配

  • [FromQuery] 强制与查询字符串变量匹配

  • [FromServices] 强制使用 DI

在匹配期间,从所选源中提取的字符串将使用当前线程文化转换为操作方法参数的类型。如果转换失败或者对于必需的操作方法参数找不到匹配项,则整个操作方法调用过程将失败,并且将自动返回 404 响应。例如,在以下示例中,id参数将与查询字符串参数或模式变量匹配,因为它是一个简单类型,而myclass属性和嵌套属性将与表单参数匹配,因为MyClass是一个复杂类型。最后,myservice将从 DI 中获取,因为它带有[FromServices]属性前缀:

 public class HomeController : Controller
    {
        public IActionResult MyMethod(
            int id, 
            MyClass myclass, 
            [FromServices] MyService myservice)
        {
            ... 

如果在UseEndpoints模式中找不到id参数的匹配项,并且id参数在UseEndpoints模式中被声明为必需的,由于模式匹配失败,将自动返回 404 响应。当参数必须匹配非空单一类型时,通常将参数声明为非可选。如果在 DI 容器中找不到MyService实例,则会抛出异常,因为在这种情况下,失败不是由于错误的请求,而是由于设计错误。

MVC 控制器返回IActionResult接口或Task<IActionResult>结果,如果它们被声明为asyncIActionResult定义了具有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 生成的概念性工作,而视图 - 也就是模板 - 则负责所有的图形细节。

视图将在下一小节中详细描述,而模型(ViewModel)视图控制器模式将在本章的理解 ASP.NET Core MVC 和设计原则之间的联系部分中更详细地讨论。最后,在本章的用例 - 在 ASP.NET Core MVC 中实现 Web 应用程序部分将提供一个实际的例子。

另一个常见的IActionResultRedirectResult,它创建一个重定向响应,因此强制浏览器转到特定的 URL。一旦用户成功提交完成先前操作的表单,通常会使用重定向。在这种情况下,通常会将用户重定向到可以选择另一个操作的页面。

返回RedirectResult最简单的方法是通过将 URL 传递给Redirect方法。这是执行重定向到 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 类。默认情况下,每次构建和发布都启用了预编译,但您还可以启用运行时编译,以便在部署后可以修改 Views。在 Visual Studio 中创建项目时,可以通过选中启用 Razor 运行时编译复选框来启用此选项。您还可以通过向 Web 应用程序项目文件添加以下代码来禁用每次构建和发布的编译:

<PropertyGroup>
  <TargetFramework> net5.0 </TargetFramework>
  <!-- add code below -->
  <RazorCompileOnBuild>false</RazorCompileOnBuild>
  <RazorCompileOnPublish>false</RazorCompileOnPublish>
  <!-- end of code to add -->
    ...
</PropertyGroup> 

如果选择了 Razor 视图库项目,则视图也可以预编译为视图库。

此外,在编译后,视图仍然与其路径相关联,这些路径成为它们的完整名称。每个控制器在Views文件夹下有一个与控制器同名的关联文件夹,该文件夹预计包含该控制器使用的所有视图。

以下屏幕截图显示了与HomeController及其 Views 相关联的文件夹:

图 15.5:与控制器相关联的视图文件夹和共享文件夹

前面的屏幕截图还显示了Shared文件夹,该文件夹预计包含多个控制器使用的所有视图或部分视图。控制器通过其路径引用View方法中的视图,而不包括.cshtml扩展名。如果路径以/开头,则将路径解释为相对于应用程序根的路径。否则,首先尝试将路径解释为相对于与控制器关联的文件夹。如果在那里找不到视图,则会在Shared文件夹中搜索视图。

因此,例如,在前面的屏幕截图中的Privacy.cshtml视图文件可以在HomeController中从View("Privacy", MyViewModel)中引用。如果 View 的名称与操作方法的名称相同,我们可以简单地写View(MyViewModel)

Razor 视图是 HTML 代码与 C#代码的混合,加上一些特定于 Razor 的语句。它们都以包含 View 应该接收的 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#代码计算的文本。如果表达式复杂,由一系列属性和方法调用组成,必须用括号括起来。以下代码显示了一些示例:

<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
{
 ..
} 

如前面的代码所示,Razor 流程控制语句的每个块的开始都是在 C#模式下,并且直到遇到第一个 HTML 开放标签之前都保持在 C#模式下,然后开始 HTML 模式。在相应的 HTML 关闭标签之后,会恢复 C#模式。

可以使用forforeachwhiledo Razor 语句多次实例化 HTML 模板,如下例所示:

@for(int i=0; i< 10; i++)
{
}
@foreach(var x in myIEnumerable)
{
}
@while(true)
{

}
@do 
{

}
while(true) 

Razor 视图可以包含不生成任何代码的注释。在@*...*@中包含的任何文本都被视为注释,并在页面编译时被移除。下一个主题描述了所有视图中可用的属性。

了解 Razor 视图属性

每个视图中都预定义了一些标准变量。最重要的变量是Model,它包含传递给视图的 ViewModel。例如,如果我们将一个Person模型传递给一个视图,那么<span>@Model.Name</span>会显示传递给视图的Person模型的名称。

ViewData变量包含IDictionary<string, object>,与调用视图的控制器共享。也就是说,所有控制器都有一个包含IDictionary<string, object>ViewData属性,并且在控制器中设置的每个条目也可以在调用视图的ViewData变量中使用。ViewData是控制器传递信息给调用视图的替代方法。值得一提的是,ViewState字典也可以通过ViewBag属性作为动态对象进行访问。这意味着动态的ViewBag属性被映射到ViewData字符串索引,它们的值被映射到与这些索引对应的ViewState条目。

User变量包含当前登录的用户;也就是说,包含在当前请求的Http.Context.User属性中的相同实例。Url变量包含一个IUrlHelper接口的实例,其方法是用于计算应用程序页面的 URL 的实用程序。例如,Url.Action("action", "controller", new {par1=valueOfPar1,...})计算出导致调用controlleraction方法的 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="https://stackpath.bootstrapcdn.com/
bootstrap/4.3.1/js/bootstrap.bundle.min.js"
asp-fallback-src="~/lib/bootstrap/dist/js/
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 cache *@
</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"/> 

当标签中没有插入文本时,标签中显示的文本将从装饰属性(在本例中为Town)中获取;否则,将使用属性的名称。

如果spandiv包含asp-validation-for ="Address.Town"错误属性,则与Address.Town输入相关的验证消息将自动插入到该标记内。验证框架将在了解 ASP.NET Core MVC 与设计原则之间的关系部分中进行描述。

还可以通过在divspan后添加属性来自动创建验证错误摘要:

asp-validation-summary="ValidationSummary.{All, ModelOnly}" 

如果属性设置为ValidationSummary.ModelOnly,则仅会在摘要中显示与特定input字段不相关的消息,而如果值为ValidationSummary.All,则会显示所有错误消息。

asp-items属性可以应用于任何select标记,以便自动生成所有select选项。必须传递一个IEnumerable<SelectListItem>,其中每个SelectListItem包含选项的文本和值。SelectListItem还包含一个可选的Group属性,您可以使用它来将在select中显示的选项组织成组。

下一个主题将展示如何重用视图代码。

重用视图代码

ASP.NET Core MVC 包括几种重用视图代码的技术。最重要的是布局页面。

在每个 Web 应用程序中,几个页面共享相同的结构;例如,相同的主菜单或相同的左侧或右侧栏。在 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>
... 

每个视图的ViewState都会被复制到其布局页面的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>
}
..... 

如果操作方法预期向 AJAX 调用返回 HTML,则必须生成 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中呈现的可能输入字段将具有与直接由调用视图呈现时相同的名称。

可以通过将for替换为model,直接传递包含在变量中或由 C#表达式返回的对象,而不是通过调用视图(ViewModel)的属性来指定_viewname的 ViewModel,如本例所示:

<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

现在,让我们看一下 ASP.NET Core 的更近期的相关功能。

ASP.NET Core 的最新版本有什么新功能?

ASP.NET Core 的主要变化发生在 3.0 版本:路由引擎已从 MVC 引擎中分离出来,现在也可用于其他处理程序。在以前的版本中,路由和路由是 MVC 处理程序的一部分,并且通过app.UseMvc(....)添加;现在已经被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" }); 

此外,ASP.NET Core 现在有一个独立的 JSON 格式化程序,不再依赖于第三方 Newtonsoft JSON 序列化器。但是,如果您有兼容性问题,仍然可以选择通过安装Microsoft.AspNetCore.Mvc.NewtonsoftJson NuGet 包并配置控制器来将最小的 ASP.NET Core JSON 格式化程序替换为 Newtonsoft JSON 序列化器,如下所示:

services.AddControllersWithViews()
    .AddNewtonsoftJson(); 

在这里,AddNewtonsoftJson还有一个重载,接受 Newtonsoft JSON 序列化器的配置选项:

.AddNewtonsoftJson(options =>
           options.SerializerSettings.ContractResolver =
              new CamelCasePropertyNamesContractResolver()); 

微软的 JSON 序列化器是在版本 3 中引入的,但一开始它的实现是最小的。现在,在.NET 5 中,它提供了与 Newtonsoft JSON 序列化器相当的选项。

在 3.0 之前的版本中,您被迫将控制器和视图都添加到 DI 引擎中。现在,您仍然可以使用services.AddControllersWithViews注入控制器和视图,但如果您只打算实现 REST 端点,也可以使用AddControllers添加控制器。

由于.NET 性能的改进、JIT 编译器的改进(现在生成更短更优化的代码)以及 HTTP/2 协议实现的改进,版本 5 带来了显著的性能改进。基本上,您可以依赖于加倍的计算速度,以及更高效的内存和垃圾收集处理。

理解 ASP.NET Core MVC 与设计原则之间的连接

整个 ASP.NET Core 框架是建立在我们在第五章将微服务架构应用于企业应用程序第八章在 C#中与数据交互-Entity Framework Core第十一章设计模式和.NET 5 实现第十二章理解软件解决方案中的不同领域第十三章在 C# 9 中实现代码重用性中分析的设计原则和模式之上构建的。

此外,所有框架功能都通过 DI 提供,因此每个功能都可以被定制的替代品替换,而不会影响其余代码。然而,这些提供者不是单独添加到 DI 引擎中的;相反,它们被分组到选项对象中(参见加载配置数据并与选项框架一起使用小节),以符合 SOLID 单一职责原则。例如,所有模型绑定器、验证提供者和数据注释提供者都是如此。

此外,配置数据不再是从配置文件创建的唯一字典中获取,而是通过我们在本章第一节中描述的选项框架组织成选项对象。这也是对 SOLID 接口隔离原则的应用。

然而,ASP.NET Core 还应用了其他模式,这些模式是单一职责原则的特定实例,它是单一职责原则的泛化。它们如下:

  • 中间件模块架构(ASP.NET Core 管道)

  • 从应用程序代码中分解验证和全球化

  • MVC 模式本身

我们将在接下来的各个小节中分析每一个。

ASP.NET Core 管道的优势

ASP.NET Core 管道架构具有两个重要的优势:

  • 对初始请求执行的所有不同操作都根据单一职责原则分解到不同的模块中。

  • 执行这些不同操作的模块不需要相互调用,因为每个模块都是由 ASP.NET Core 框架一次性调用的。这样,每个模块的代码都不需要执行与其他模块分配的责任相关的任何操作。

这确保了功能的最大独立性和更简单的代码。例如,一旦授权和认证模块启用,其他模块就不需要再担心授权问题。每个控制器代码可以专注于特定于应用程序的业务内容。

服务器端和客户端验证

验证逻辑已完全从应用程序代码中分解出来,并且已限制在验证属性的定义中。开发人员只需通过使用适当的验证属性装饰属性来指定要应用于每个模型属性的验证规则。

当实例化操作方法参数时,验证规则会自动进行检查。然后,错误和模型中的路径(发生错误的位置)将记录在包含在ModelState控制器属性中的字典中。开发人员有责任通过检查ModelState.IsValid来验证是否存在错误,如果存在错误,则开发人员必须将相同的 ViewModel 返回到相同的视图中,以便用户可以纠正所有错误。

错误消息会自动显示在视图中,开发人员无需采取任何操作。开发人员只需要做以下事情:

  • 在每个输入字段旁边添加带有asp-validation-for属性的spandiv,该属性将自动填充可能的错误。

  • 添加带有asp-validation-summary属性的div,该属性将自动填充验证错误摘要。有关更多详细信息,请参阅使用 Razor 标签助手部分。

只需通过调用_ValidationScriptsPartial.cshtml视图,并使用partial标签添加一些 JavaScript 引用,即可在客户端启用相同的验证规则,以便在表单提交到服务器之前向用户显示错误。一些预定义的验证属性包含在System.ComponentModel.DataAnnotationsMicrosoft.AspNetCore.Mvc命名空间中,包括以下属性:

  • Required属性要求用户为其装饰的属性指定一个值。隐式的Required属性会自动应用于所有非空属性,例如所有浮点数、整数和小数,因为它们不能有null值。

  • Range属性限制数字数量在一个范围内。

  • 它们还包括限制字符串长度的属性。

自定义错误消息可以直接插入到属性中,或者属性可以引用包含它们的资源类型的属性。

开发人员可以通过在 C#和 JavaScript 中提供验证代码来定义其自定义属性,用于客户端验证。

基于属性的验证可以被其他验证提供程序替换,例如使用流畅接口为每种类型定义验证规则的流畅验证。只需在 MVC 选项对象中包含的集合中更改提供程序即可。这可以通过传递给services.AddControllersWithViews方法的操作来配置。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")
}; 

然后,我们将 culture 中间件添加到管道中,如下所示:

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 会选择新的区域设置。通常,一旦点击了语言链接,服务器还会通过提供程序 2 生成一个语言 cookie 来记住用户的选择。

提供内容本地化的最简单方法是为每种语言提供不同的视图。因此,如果我们想要为不同的语言本地化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 LocalizerIViewLocalizer注入视图。

  • 在需要的地方,将视图中的文本替换为对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应用程序名称对应的命名空间的第一个段将替换为ResourcesPath。如果ResourcesPath为空,并且您使用 Visual Studio 创建的默认命名空间,则资源文件必须放在包含与其关联的类的相同文件夹中。

可以通过将每组资源文件与类型(例如MyType)关联,然后注入IHtmlLocalizer<MyType>用于 HTML 片段或IStringLocalizer<MyType>用于需要进行 HTML 编码的字符串,来在控制器或其他地方本地化字符串和 HTML 片段。

它们的使用与IViewLocalizer的使用相同。与数据注释的情况一样,与MyType相关的资源文件的路径是计算的。如果您想要为整个应用程序使用一组唯一的资源文件,一个常见选择是使用Startup类作为参考类型(IStringLocalizer<Startup>IHtmlLocalizer<Startup>)。另一个常见选择是创建各种空类,以用作各种资源文件组的参考类型。

现在我们已经学会了如何在 ASP.NET Core 项目中管理全球化,在下一小节中,我们将描述 ASP.NET Core MVC 使用的更重要的模式,以强制关注点分离:MVC 模式本身。

MVC 模式

MVC 是用于实现 Web 应用程序的演示层的模式。基本思想是在演示层的逻辑和图形之间应用关注点分离。逻辑由控制器处理,而图形被分解为视图。控制器和视图通过模型进行通信,通常称为 ViewModel,以区别于业务和数据层的模型。

然而,演示层的逻辑是什么?在第一章理解软件架构的重要性中,我们看到软件需求可以用用例来记录,描述用户和系统之间的交互。

粗略地说,演示层的逻辑包括管理用例;因此,粗略地说,用例被映射到控制器,每个用例的单个操作被映射到这些控制器的操作方法。因此,控制器负责管理与用户的交互协议,并依赖业务层在每个操作期间涉及的任何业务处理。

每个操作方法从用户那里接收数据,执行一些业务处理,并根据此处理的结果决定向用户显示什么,并将其编码为 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 存储库的ch15文件夹中找到。在本节的其余部分,我们将描述应用程序的整体组织并讨论一些相关的代码示例。

定义应用程序架构

应用程序根据第十二章中描述的指南进行组织,理解软件解决方案中的不同领域,同时考虑 DDD 方法并使用 SOLID 原则来映射您的领域部分。也就是说,应用程序分为三个层,每个层都作为不同的项目实现:

  • 有一个数据层,其中包含存储库的实现和描述数据库实体的类。这是一个.NET Core 库项目。但是,由于它需要一些接口,如IServiceCollection,这些接口在Microsoft.NET.Sdk.web中定义,因此我们必须添加对.NET Core SDK 和 ASP.NET Core SDK 的引用。可以按照以下步骤完成:
  1. 在解决方案资源管理器中右键单击项目图标,然后选择编辑项目文件

  2. 显示目的地和套餐给用户的应用程序与管理面板使用相同的数据库。由于只有管理面板应用程序需要修改数据,因此将只有一个写入数据库副本和多个只读副本。

 <ItemGroup>
          <FrameworkReference Include="Microsoft.AspNetCore.App" />
     </ItemGroup> 
  • 还有一个域层,其中包含存储库规范;即描述存储库实现和 DDD 聚合的接口。在我们的实现中,我们决定通过在接口后隐藏根数据实体的禁止操作/属性来实现聚合。因此,例如,Package数据层类,它是一个聚合根,在域层中有一个相应的IPackage接口,它隐藏了Package实体的所有属性设置器。域层还包含所有领域事件的定义,而相应的事件处理程序在应用程序层中定义。

  • 最后,还有应用程序层 - 即 ASP.NET Core MVC 应用程序 - 在这里我们定义 DDD 查询、命令、命令处理程序和事件处理程序。控制器填充查询对象并执行它们以获取它们可以传递给视图的 ViewModels。它们通过填充命令对象并执行其关联的命令处理程序来更新存储。反过来,命令处理程序使用来自域层的IRepository接口和IUnitOfWork来管理和协调事务。

应用程序使用查询命令分离模式;因此,它使用命令对象来修改存储和查询对象来查询它。

查询的使用和实现都很简单:控制器填充它们的参数,然后调用它们的执行方法。反过来,查询对象有直接的 LINQ 实现,直接将结果投影到控制器视图中使用的 ViewModels 上,使用Select LINQ 方法。您也可以决定将 LINQ 实现隐藏在用于存储更新操作的相同存储库类后面,但这将使得简单查询的定义和修改变得非常耗时。

无论如何,将查询对象隐藏在接口后面是一个很好的做法,这样当你测试控制器时,它们的实现可以被假实现所替换。

然而,在执行命令涉及的对象和调用链更加复杂。这是因为它需要构建和修改聚合,以及定义聚合之间以及聚合与其他应用程序之间的交互,通过域事件来提供。

以下图表是存储更新操作的执行方式的草图。圆圈是在各个层之间交换的数据,而矩形是处理它们的过程。此外,虚线箭头连接接口和实现它们的类型:

图 15.6:命令执行的图表

以下是通过图 15.6的操作流程的步骤列表:

  1. 控制器的操作方法接收一个或多个 ViewModel,并进行验证。

  2. 一个或多个包含要应用的更改的 ViewModel 被隐藏在域层中定义的接口(IMyUpdate)后面。它们用于填充命令对象的属性。这些接口必须在域层中定义,因为它们将被用作在那里定义的存储库方法的参数。

  3. 在控制器操作方法中通过 DI 检索与之前命令匹配的命令处理程序(通过我们在定义控制器和 ViewModels子部分中描述的[FromServices]参数属性)。然后执行处理程序。在执行过程中,处理程序与各种存储库接口方法以及它们返回的聚合进行交互。

  4. 在创建步骤 3中讨论的命令处理程序时,ASP.NET Core DI 引擎会自动注入其构造函数中声明的所有参数。特别是,它会注入所有IRepository实现,以执行所有命令处理程序事务所需的操作。命令处理程序通过调用其构造函数中接收到的这些IRepository实现的方法来执行其工作,以构建聚合并修改构建的聚合。处理程序使用每个IRepository中包含的IUnitOfWork接口,以及数据层返回的并发异常,来组织它们的操作作为事务。值得指出的是,每个聚合都有自己的IRepository,更新每个聚合的整个逻辑都是在聚合本身中定义的,而不是在其关联的IRepository中,以保持代码更加模块化。

  5. 在数据层的幕后,IRepository实现使用 Entity Framework 来执行它们的工作。聚合由在域层中定义的接口隐藏的根数据实体来实现,而处理事务并将更改传递给数据库的IUnitOfWork方法则使用DbContext方法来实现。换句话说,IUnitOfWork是用应用的DbContext来实现的。

  6. 在每个聚合过程中生成域事件,并通过调用它们的AddDomainEvent方法将它们添加到聚合中。然而,它们不会立即触发。通常情况下,它们会在所有聚合处理结束之前触发,并在更改传递给数据库之前触发;然而,这并不是一个普遍的规则。

  7. 应用程序通过抛出异常来处理错误。一个更有效的方法是在依赖引擎中定义一个请求范围的对象,每个应用程序子部分都可以将其错误作为领域事件添加。然而,虽然这种方法更有效,但它增加了代码和应用程序开发时间的复杂性。

Visual Studio 解决方案由三个项目组成:

  • 有一个包含领域层的项目称为PackagesManagementDomain,这是一个.NET Standard 2.0 库。

  • 有一个包含整个数据层的项目称为PackagesManagementDB,这是一个.NET 5.0 库。

  • 最后,有一个名为PackagesManagement的 ASP.NET Core MVC 5.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 DurationInDays { get; }
    DateTime? StartValidityDate { get;}
    DateTime? EndValidityDate { get; }
    int DestinationId { get; }

} 

它包含了我们在第八章中看到的Package实体的相同属性,在 C#中与数据交互 - Entity Framework Core。唯一的区别是以下内容:

  • 它继承自IEntity<int>,提供了所有聚合的基本功能。

  • 它没有Id属性,因为它是从IEntity<int>继承的。

  • 所有属性都是只读的,并且它具有FullUpdate方法,因为所有聚合只能通过用户域中定义的更新操作进行修改(在我们的情况下,FullUpdate方法)。

现在,让我们也添加一个DTOs文件夹。在这里,我们放置所有用于将更新传递给聚合的接口。这些接口由应用程序层的 ViewModels 实现,用于定义这些更新。在我们的情况下,它包含IPackageFullEditDTO,我们可以使用它来更新现有的包裹。如果您想要添加管理目的地的逻辑,您必须为IDestination聚合定义一个类似的接口。

一个IRepository文件夹包含所有存储库规范;即IDestinationRepositoryIPackageRepositoryIPackageEventRepository。在这里,IPackageEventRepository是与IPackageEvent聚合相关联的存储库。例如,让我们看一下IPackageRepository存储库:

public interface IPackageRepository: 
        IRepository<IPackage>
{
    Task<IPackage> Get(int id);
    IPackage New();
    Task<IPackage> Delete(int id);
} 

存储库始终只包含少量方法,因为所有业务逻辑应表示为聚合方法 - 在我们的例子中,只有创建新包、检索现有包和删除现有包的方法。修改现有包的逻辑包含在IPackageFullUpdate方法中。

最后,与所有领域层项目一样,PackagesManagementDomain包含一个包含所有领域事件定义的事件文件夹。在我们的例子中,文件夹的名称为Events,包含了 package-deleted 事件和 price-changed 事件:

public class PackageDeleteEvent: IEventNotification
{
    public PackageDeleteEvent(int id, long oldVersion)
    {
        PackageId = id;
        OldVersion = oldVersion;
    }
    public int PackageId { get; }
    public long OldVersion { get; }

}
public class PackagePriceChangedEvent: IEventNotification
{
    public PackagePriceChangedEvent(int id, decimal price, 
        long oldVersion, long newVersion)
    {
            PackageId = id;
            NewPrice = price;
            OldVersion = oldVersion;
            NewVersion = newVersion;
     }
    public int PackageId { get; }
    public decimal NewPrice { get; }
    public long OldVersion { get; }
    public long NewVersion { get; }
} 

当一个聚合将所有更改发送到另一个应用程序时,它必须具有一个版本属性。接收更改的应用程序使用此版本属性以正确的顺序应用所有更改。显式版本号是必需的,因为更改是异步发送的,因此它们接收的顺序可能与它们发送的顺序不同。为此,用于在应用程序外部发布更改的事件具有OldVersion(更改之前的版本)和NewVersion(更改之后的版本)属性。与删除事件相关的事件没有NewVersion,因为在被删除后,实体无法存储任何版本。

下一小节解释了如何在数据层中实现领域层中定义的所有接口。

定义数据层

数据层项目包含对Microsoft.AspNetCore.Identity.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.SqlServer NuGet 包的引用,因为我们使用 Entity Framework Core 与 SQL Server。它引用了Microsoft.EntityFrameworkCore.ToolsMicrosoft.EntityFrameworkCore.Design,这些是生成数据库迁移所需的,如第八章在 C#中与数据交互 - Entity Framework CoreEntity Framework Core 迁移部分所述。

我们有一个包含所有数据库实体的Models文件夹。它们与第八章在 C#中与数据交互 - Entity Framework Core中的实体类似。唯一的区别如下:

  • 它们继承自Entity<T>,其中包含所有聚合的基本特性。请注意,只有聚合根需要继承自Entity<T>;所有其他实体必须按第八章中所述进行定义,在 C#中与数据交互 - Entity Framework Core。在我们的例子中,所有实体都是聚合根。

  • 它们没有Id,因为它是从Entity<T>继承的。

  • 其中一些具有使用[ConcurrencyCheck]属性修饰的EntityVersion属性。它包含发送所有实体更改到其他应用程序所需的实体版本。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;
        DurationInDays = o.DurationInDays;
        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 DurationInDays { get; set; }
    public DateTime? StartValidityDate { get; set; }
    public DateTime? EndValidityDate { get; set; }
    public Destination MyDestination { get; set; }
    [ConcurrencyCheck]
    public long EntityVersion{ get; set; }
    public int DestinationId { get; set; }
} 

FullUpdate方法是更新IPackage聚合的唯一方法,当价格更改时,将PackagePriceChangedEvent添加到实体事件列表中。

MainDBContext.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 Task CommitAsync()
{
    Database.CommitTransaction();
    return Task.CompletedTask;
}
public Task RollbackAsync()
{
    Database.RollbackTransaction();
    return Task.CompletedTask;
} 

然而,在分布式环境中,它们很少被命令类使用。这是因为重试相同的操作直到不返回并发异常通常比事务保证更好的性能。

值得分析的是将所有应用于DbContext的更改传递到数据库的方法的实现:

public async Task<bool> SaveEntitiesAsync()
{ 
    try
    {
        return await SaveChangesAsync() > 0;
    }
    catch (DbUpdateConcurrencyException ex)
    {
        foreach (var entry in ex.Entries)
        {
            entry.State = EntityState.Detached; 

        }
        throw;
    }
} 

前面的实现只是调用SaveChangesAsync DbContext上下文方法,该方法将所有更改保存到数据库,然后拦截所有并发异常,并从上下文中分离涉及并发错误的所有实体。这样,下次命令重试整个失败的操作时,它们的更新版本将从数据库重新加载。

Repositories文件夹包含所有存储库实现。值得分析的是IPackageRepository.Delete方法的实现:

public async Task<IPackage> Delete(int id)
{
    var model = await Get(id);
    if (model is not Package package) return null;
    context.Packages.Remove(package);
    model.AddDomainEvent(
        new PackageDeleteEvent(
            model.Id, package.EntityVersion));
    return model;
} 

它从数据库中读取实体,并正式将其从Packages数据集中移除。这将在将更改保存到数据库时强制删除实体。此外,它将PackageDeleteEvent添加到事件的聚合列表中。

Extensions文件夹包含DBExtensions静态类,该类定义了两个扩展方法,分别添加到应用程序 DI 引擎和 ASP.NET Core 管道中。一旦添加到管道中,这两种方法将连接数据库层和应用程序层。

AddDbLayerIServiceCollection扩展接受数据库连接字符串和包含所有迁移的.dll文件的名称作为其输入参数。然后,它执行以下操作:

services.AddDbContext<MainDbContext>(options =>
                options.UseSqlServer(connectionString, 
                b => b.MigrationsAssembly(migrationAssembly))); 

也就是说,它将数据库上下文添加到 DI 引擎并定义其选项;即使用 SQL Server、数据库连接字符串和包含所有迁移的.dll文件的名称。

然后,它执行以下操作:

services.AddIdentity<IdentityUser<int>, IdentityRole<int>>()
                .AddEntityFrameworkStores<MainDbContext>()
                .AddDefaultTokenProviders(); 

也就是说,它添加和配置了处理基于数据库的身份验证所需的所有类型。特别是,它添加了应用程序层可以使用的UserManagerRoleManager类型来管理用户和角色。AddDefaultTokenProviders添加了在用户登录时使用数据库中包含的数据创建身份验证令牌的提供程序。

最后,它通过调用在我们添加到域层项目中的 DDD 工具中定义的AddAllRepositories方法,发现并添加到 DI 引擎中所有存储库实现。

UseDBLayer扩展方法通过调用context.Database.Migrate()确保迁移应用到数据库,然后用一些初始对象填充数据库。在我们的情况下,它使用RoleManagerUserManager分别创建管理角色和初始管理员。然后,它创建一些示例目的地和包裹。

context.Database.Migrate()对于快速设置和更新暂存和测试环境非常有用。然而,在生产环境部署时,应该使用迁移工具从迁移中生成一个 SQL 脚本。然后,这个脚本应该在数据库维护人员应用之前进行检查。

创建迁移,我们必须将上述扩展方法添加到 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)
    ...
    app.UseAuthentication();
    app.UseAuthorization();
    ...
} 

请确保授权和认证模块都已添加到 ASP.NET Core 管道中;否则,认证/授权引擎将无法工作。

然后,我们必须像这样将连接字符串添加到appsettings.json文件中:

{
   "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=package-management;Trusted_Connection=True;MultipleActiveResultSets=true"

    },
    ...
} 

最后,让我们将Microsoft.EntityFrameworkCore.Design添加到 ASP.NET Core 项目中。

此时,让我们打开 Visual Studio Package Manager 控制台,选择PackageManagementDB作为默认项目,然后执行以下命令:

Add-Migration Initial -Project PackageManagementDB 

前面的命令将生成第一个迁移。我们可以使用Update-Database命令将其应用到数据库。请注意,如果您从 GitHub 复制项目,您不需要生成迁移,因为它们已经被创建,但您仍然需要更新数据库。

下一小节描述了应用程序层。

定义应用程序层

作为第一步,为了简单起见,让我们通过将以下代码添加到 ASP.NET Core 管道中,将应用程序的文化设置为en-US

app.UseAuthorization();
// Code to add: configure the Localization middleware
var ci = new CultureInfo("en-US"); 
app.UseRequestLocalization(new RequestLocalizationOptions
{
    DefaultRequestCulture = new RequestCulture(ci),
    SupportedCultures = new List<CultureInfo>
    {
        ci,
    },
     SupportedUICultures = new List<CultureInfo>
    {
        ci,
    }
}); 

然后,让我们创建一个Tools文件夹,并将ApplicationLayer代码放在那里,您可以在与本书相关的 GitHub 存储库的ch12代码中找到。有了这些工具,我们可以添加代码,自动发现并添加所有查询、命令处理程序和事件处理程序到 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
{
    private readonly 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,
            DurationInDays = m.DurationInDays,
            Id = m.Id,
            Price = m.Price,
            DestinationName = m.MyDestination.Name,
            DestinationId = m.DestinationId
        })
            .OrderByDescending(m=> m.EndValidityDate)
            .ToListAsync();
    }
} 

查询对象会自动注入到应用程序 DB 上下文中。GetAllPackages方法使用 LINQ 将所有所需信息投影到PackageInfosViewModel中,并按EndValidityDate属性的降序对所有结果进行排序。

PackageInfosViewModel与所有其他 ViewModel 一起放在Models文件夹中。将 ViewModels 组织到文件夹中,为每个控制器定义一个不同的文件夹是一个很好的做法。值得分析的是用于编辑包的 ViewModel:

public class PackageFullEditViewModel: IPackageFullEditDTO
    {
        public PackageFullEditViewModel() { }
        public PackageFullEditViewModel(IPackage o)
        {
            Id = o.Id;
            DestinationId = o.DestinationId;
            Name = o.Name;
            Description = o.Description;
            Price = o.Price;
            DurationInDays = o.DurationInDays;
            StartValidityDate = o.StartValidityDate;
            EndValidityDate = o.EndValidityDate;
        }
        ...
        ... 

它有一个接受IPackage聚合的构造函数。这样,包数据就被复制到用于填充编辑视图的 ViewModel 中。它实现了在域层中定义的IPackageFullEditDTO DTO 接口。这样,它可以直接用于将IPackage更新发送到域层。

所有属性都包含验证属性,这些属性会被客户端和服务器端验证引擎自动使用。每个属性都包含一个Display属性,该属性定义了用于编辑属性的输入字段的标签。最好将字段标签放在 ViewModels 中,而不是直接放在视图中,因为这样,相同的名称会自动在使用相同 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 DurationInDays { get; set; }
[Display(Name = "available from"), Required]
public DateTime? StartValidityDate { get; set; }
[Display(Name = "available to"), Required]
public DateTime? EndValidityDate { get; set; }
[Display(Name = "destination")]
public int DestinationId { get; set; } 

Commands文件夹包含所有命令。例如,让我们看一下用于修改包的命令:

public class UpdatePackageCommand: ICommand
{
    public UpdatePackageCommand(IPackageFullEditDTO updates)
    {
        Updates = updates;
    }
    public IPackageFullEditDTO Updates { get; private set; }
} 

它的构造函数必须使用IPackageFullEditDTO DTO 接口的实现来调用,而在我们的情况下,这就是我们之前描述的编辑 ViewModel。命令处理程序放在Handlers文件夹中。值得分析的是更新包的命令:

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;
    while (!done)
    {
        try
        {
            model = await repo.Get(command.Updates.Id);
            if (model == null) return;
            model.FullUpdate(command.Updates);
            await mediator.TriggerEvents(model.DomainEvents);
            await repo.UnitOfWork.SaveEntitiesAsync();
            done = true;
        }
        catch (DbUpdateConcurrencyException)
        {
          // add some logging here
        }
    }
} 

直到不返回并发异常为止,重复命令操作。HandleAsync使用存储库获取要修改的实体的实例。如果未找到实体(已删除),则命令将停止执行。否则,所有更改都将传递给检索到的聚合。更新后,立即触发聚合中包含的所有事件。特别是,如果价格已更改,则执行与价格更改相关的事件处理程序。在Package实体的EntityVersion属性上声明的[ConcurrencyCheck]属性确保包版本正确更新(通过将其先前版本号增加 1),以及价格更改事件传递正确的版本号。

此外,事件处理程序放置在Handlers文件夹中。例如,让我们看一下价格更改事件处理程序:

public class PackagePriceChangedEventHandler :
    IEventHandler<PackagePriceChangedEvent>
{
    private readonly IPackageEventRepository repo;
    public PackagePriceChangedEventHandler(IPackageEventRepository repo)
    {
        this.repo = repo;
    }
    public Task HandleAsync(PackagePriceChangedEvent ev)
    {
        repo.New(PackageEventType.CostChanged, ev.PackageId, 
            ev.OldVersion, ev.NewVersion, ev.NewPrice);
      return Task.CompletedTask;
    }
} 

构造函数已自动注入了处理数据库表和发送到其他应用程序的所有事件的IPackageEventRepository存储库。HandleAsync实现只是调用存储库方法,向该表添加新记录。

IPackageEventRepository处理表中的所有记录,可以通过在 DI 引擎中定义并行任务的方式检索并发送到所有感兴趣的微服务,例如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)
{
    // Clear the existing external cookie 
    //to ensure a clean login process
    await HttpContext
         .SignOutAsync(IdentityConstants.ExternalScheme);
    ViewData["ReturnUrl"] = returnUrl;
    return View();
} 

当浏览器被授权模块自动重定向到登录页面时,它将returnUrl作为参数接收。这发生在未登录用户尝试访问受保护页面时。returnUrl存储在传递给登录视图的ViewState字典中。登录视图中的表单在提交时将其与用户名和密码一起传递回控制器,如下所示:

<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post">
...
</form> 

表单提交由具有相同Login名称的动作方法拦截,但使用[HttpPost]属性进行修饰,如下所示:

[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(
    LoginViewModel model,
    string returnUrl = null)
        {
            ... 

前面的方法接收登录视图使用的Login模型,以及returnUrl查询字符串参数。ValidateAntiForgeryToken属性验证 MVC 表单自动添加的令牌(称为防伪令牌)。然后将其添加到隐藏字段中,以防止跨站点攻击。

作为第一步,如果用户已经登录,操作方法会注销用户:

if (User.Identity.IsAuthenticated)
{
      await _signInManager.SignOutAsync();

} 

否则,它会验证是否存在验证错误,如果有,则显示填充有 ViewModel 数据的相同视图,以便用户纠正其错误:

if (ModelState.IsValid)
{
     ...
}
else
 // If we got this far, something failed, redisplay form
 return View(model); 

如果模型有效,将使用_signInManager来登录用户:

var result = await _signInManager.PasswordSignInAsync(
    model.UserName, 
    model.Password, model.RememberMe, 
    lockoutOnFailure: false); 

如果操作返回的结果成功,操作方法将浏览器重定向到returnUrl(如果不为空);否则,将浏览器重定向到主页:

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属性中。将IEnumerables包含在 ViewModels 中是一个很好的做法,而不是直接将它们传递给视图,这样如果需要,可以添加其他属性,而无需修改现有的视图代码。结果显示在 Bootstrap 4 表中,因为 Visual Studio 自动创建了 Bootstrap 4 CSS。

结果如下所示:

图 15.7:应用程序包处理页面

新包链接(它的形状类似于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来检索现有的包。如果找不到包,这意味着它已被其他用户删除,并且浏览器会再次重定向到列表页面,以显示更新后的包列表。否则,聚合将传递给PackageFullEditViewModel ViewModel,该 ViewModel 由Edit视图呈现。

用于呈现包的视图必须呈现带有所有可能的包目的地的select,因此它需要IDestinationListQuery查询的一个实例,该查询已实现以辅助目的地选择 HTML 逻辑。由于视图有责任决定如何使用户能够选择目的地,因此该查询直接注入到视图中。注入查询并使用它的代码如下所示:

@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操作方法发送一个帖子。它已添加到正文的末尾,如下所示:

@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。我们还分析了最新版本中引入的所有改进。

最后,我们分析了 ASP.NET Core MVC 框架中实现的所有设计模式,特别是关注了关注点分离原则的重要性以及 ASP.NET Core MVC 如何在 ASP.NET Core 管道中以及其验证和全球化模块中实现它。我们更详细地关注了演示层逻辑和图形之间关注点分离的重要性,以及 MVC 模式如何确保它。

下一章将解释如何使用新的 Blazor WebAssembly 框架将演示层实现为单页应用程序SPA)。

问题

  1. 您能列出 Visual Studio 在 ASP.NET Core 项目中脚手架生成的所有中间件模块吗?

  2. ASP.NET Core 管道模块默认需要继承自基类或实现某个接口吗?

  3. 一个标签必须只有一个为其定义的标签助手,否则会抛出异常,这是真的吗?

  4. 您还记得如何在控制器中测试是否发生了验证错误吗?

  5. 在布局视图中包含被调用的主视图的指令是什么?

  6. 主视图的次要部分如何在布局视图中调用?

  7. 控制器如何调用视图?

  8. 全球化模块默认安装了多少提供程序?

  9. ViewModels 是控制器与调用的视图通信的唯一方式吗?

进一步阅读

第十六章:Blazor WebAssembly

在本章中,您将学习如何使用 Blazor WebAssembly 实现演示层。Blazor WebAssembly 应用程序是 C#应用程序,可以在支持 WebAssembly 技术的任何浏览器中运行。它们可以通过导航到特定的 URL 进行访问,并以标准静态内容的形式在浏览器中下载,由 HTML 页面和可下载文件组成。

Blazor 应用程序使用了我们在第十五章《介绍 ASP.NET Core MVC》中已经分析过的许多技术,比如依赖注入和 Razor。因此,我们强烈建议在阅读本章之前先学习第十五章《介绍 ASP.NET Core MVC》。

更具体地说,在本章中,您将学习以下主题:

  • Blazor WebAssembly 架构

  • Blazor 页面和组件

  • Blazor 表单和验证

  • Blazor 高级特性,如全球化、身份验证和 JavaScript 互操作性

  • Blazor WebAssembly 的第三方工具

  • 用例:在 Blazor WebAssembly 中实现一个简单的应用程序

虽然也有运行在服务器上的 Blazor,就像 ASP.NET Core MVC 一样,但本章仅讨论 Blazor WebAssembly,它完全在用户的浏览器中运行,因为本章的主要目的是提供一个相关的示例,展示如何使用客户端技术实现演示层。此外,作为一种服务器端技术,Blazor 无法提供与其他服务器端技术(如 ASP.NET Core MVC)相媲美的性能,我们已经在第十五章《介绍 ASP.NET Core MVC》中进行了分析。

第一节概述了 Blazor WebAssembly 的总体架构,而其余部分描述了具体特性。在需要时,通过分析和修改 Visual Studio 在选择 Blazor WebAssembly 项目模板时自动生成的示例代码来澄清概念。最后一节展示了如何将学到的所有概念应用到实践中,实现一个基于 WWTravelClub 书籍用例的简单应用程序。

技术要求

本章需要免费的 Visual Studio 2019 社区版或更高版本,并安装了所有数据库工具。所有概念都将通过一个简单的示例应用程序进行澄清,该应用程序基于 WWTravelClub 书籍用例。本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5上找到。

Blazor WebAssembly 架构

Blazor WebAssembly 利用了新的 WebAssembly 浏览器功能,在浏览器中执行.NET 运行时。这样,它使所有开发人员都能够在任何支持 WebAssembly 的浏览器中运行的应用程序的实现中使用整个.NET 代码库和生态系统。WebAssembly 被构想为 JavaScript 的高性能替代品。它是一种能够在浏览器中运行并遵守与 JavaScript 代码相同限制的汇编。这意味着 WebAssembly 代码,就像 JavaScript 代码一样,运行在一个具有非常有限访问所有机器资源的隔离执行环境中。

WebAssembly 与过去的类似选项(如 Flash 和 Silverlight)不同,因为它是 W3C 的官方标准。更具体地说,它于 2019 年 12 月 5 日成为官方标准,因此预计将有很长的寿命。事实上,所有主流浏览器已经支持它。

然而,WebAssembly 不仅带来了性能!它还为现代和先进的面向对象语言(如 C++(直接编译)、Java(字节码)和 C#(.NET))在浏览器中运行整个代码库创造了机会。

微软建议使用 Unity 3D 图形框架和 Blazor 在浏览器中运行.NET 代码。

在 WebAssembly 之前,浏览器中运行的演示层只能用 JavaScript 实现,这带来了语言维护所带来的所有问题。

现在,使用 Blazor,我们可以使用现代和先进的 C#来实现复杂的应用程序,利用 C#编译器和 Visual Studio 为这种语言提供的所有便利。

此外,使用 Blazor,所有.NET 开发人员都可以利用.NET 框架的全部功能来实现在浏览器中运行的表示层,并与在服务器端运行的所有其他层共享库和类。

接下来的小节描述了 Blazor 架构的整体情况。第一小节探讨了单页应用程序的一般概念,并指出了 Blazor 的特点。

什么是单页应用程序?

单页应用程序SPA)是一个基于 HTML 的应用程序,其中 HTML 由在浏览器中运行的代码更改,而不是向服务器发出新请求并从头开始呈现新的 HTML 页面。SPA 能够通过用新的 HTML 替换完整的页面区域来模拟多页面体验。

SPA 框架是专门设计用于实现单页应用程序的框架。在 WebAssembly 出现之前,所有的 SPA 框架都是基于 JavaScript 的。最著名的基于 JavaScript 的 SPA 框架是 Angular、React.js 和 Vue.js。

所有的 SPA 框架都提供了将数据转换为 HTML 以显示给用户的方法,并依赖一个称为router的模块来模拟页面更改。通常,数据填充到 HTML 模板的占位符中,并选择要呈现的模板部分(类似 if 的结构),以及呈现的次数(类似 for 的结构)。

Blazor 的模板语言是 Razor,我们已经在第十五章中描述过。

为了增加模块化,代码被组织成组件,这些组件是一种虚拟的 HTML 标记,一旦呈现,就会生成实际的 HTML 标记。像 HTML 标记一样,组件有它们的属性,通常被称为参数,以及它们的自定义事件。开发人员需要确保每个组件使用它的参数来创建适当的 HTML,并确保它生成足够的事件。组件可以以分层的方式嵌套在其他组件中。

应用程序路由器通过选择组件来执行其工作,充当页面,并将它们放置在预定义的区域。每个页面组件都有一个与之相关联的 Web 地址路径。这个路径与 Web 应用程序域连接在一起,成为一个唯一标识页面的 URL。与通常的 Web 应用程序一样,页面 URL 用于与路由器通信,以确定要加载哪个页面,可以使用常规链接或路由方法/函数。

一些 SPA 框架还提供了预定义的依赖注入引擎,以确保组件与在浏览器中运行的通用服务和业务代码之间有更好的分离。在本小节列出的框架中,只有 Blazor 和 Angular 具有开箱即用的依赖注入引擎。

基于 JavaScript 的 SPA 框架通常会将所有 JavaScript 代码编译成几个 JavaScript 文件,然后执行所谓的摇树操作,即删除所有未使用的代码。

目前,Blazor 将主应用程序引用的所有 DLL 分开,并对每个 DLL 执行摇树操作。

下一小节开始描述 Blazor 架构。鼓励您创建一个名为BlazorReview的 Blazor WebAssembly 项目,这样您就可以检查整个章节中解释的代码和构造。请选择个人用户帐户作为身份验证,以及ASP.NET Core hosted。这样,Visual Studio 还将创建一个与 Blazor 客户端应用程序通信的 ASP.NET Core 项目,其中包含所有身份验证和授权逻辑。

图 16.1:创建 BlazorReview 应用程序

如果启动应用程序并尝试登录或尝试访问需要登录的页面,则应该出现一个错误,指出数据库迁移尚未应用。只需单击消息旁边的链接即可应用待处理的迁移。否则,如第八章使用 C#与数据交互-Entity Framework Core部分中所解释的那样,转到 Visual Studio 包管理器控制台并运行Update-Database命令。

加载和启动应用程序

Blazor WebAssembly 应用程序的 URL 始终包括一个index.html静态 HTML 页面。在我们的BlazorReview项目中,index.html位于BlazorReview.Client->wwwroot->index.html。此页面是 Blazor 应用程序将创建其 HTML 的容器。它包含一个带有viewport meta声明、标题和整个应用程序 CSS 的 HTML 头。Visual Studio 默认项目模板添加了一个特定于应用程序的 CSS 文件和 Bootstrap CSS,具有中性样式。您可以使用具有自定义样式的默认 Bootstrap CSS 或完全不同的 CSS 框架来替换默认的 Bootstrap CSS。

正文包含以下代码:

<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
        An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">![](https://gitee.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/sw-arch-cs9-dn5/img/B16756_16_001.png)</a>
</div>
<script 
src="img/AuthenticationService.js">
</script>
<script src="img/blazor.webassembly.js"></script>
</body> 

初始的div是应用程序将放置其生成的代码的地方。放置在此div中的任何标记都将在 Blazor 应用程序加载和启动时出现,然后将被应用程序生成的 HTML 替换。第二个div通常是不可见的,只有在 Blazor 拦截到未处理的异常时才会出现。

blazor.webassembly.js包含 Blazor 框架的 JavaScript 部分。除其他外,它负责下载.NET 运行时以及所有应用程序 DLL。更具体地说,blazor.webassembly.js下载列出所有应用程序文件及其哈希值的blazor.boot.json文件。然后,blazor.webassembly.js下载此文件中列出的所有资源并验证它们的哈希值。blazor.webassembly.js下载的所有资源都是在构建或发布应用程序时创建的。

只有在项目启用身份验证时才会添加AuthenticationService.js,它负责 Blazor 利用其他身份验证凭据(如 cookie)来获取承载令牌的OpenID Connect协议。承载令牌是客户端通过 Web API 与服务器交互的首选身份验证凭据。身份验证将在本章后面的身份验证和授权子章节中更详细地讨论,而承载令牌将在第十四章应用 Service-Oriented Architectures with .NET Core部分中讨论。

Blazor 应用程序的入口点在BlazorReview.Client->Program.cs文件中。它具有以下结构:

public class Program
{
    public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");
            // Services added to the application 
            // Dependency Injection engine declared with statements like:
            // builder.Services.Add...
            await builder.Build().RunAsync();
        }
    } 

WebAssemblyHostBuilder是用于创建WebAssemblyHost的构建器,它是在第五章将微服务架构应用于企业应用程序中讨论的通用主机的 WebAssembly 特定实现(鼓励您查看该子章节)。第一个构建器配置指令声明了 Blazor 根组件(App),它将包含整个组件树,并在Index.html页面的哪个 HTML 标记中放置它(#app)。更具体地说,RootComponents.Add添加了一个托管服务,负责处理整个 Blazor 组件树。我们可以通过多次调用RootComponents.Add在同一个 HTML 页面中运行多个 Blazor WebAssembly 用户界面,每次使用不同的 HTML 标记引用。

builder.Services包含了所有通常的方法和扩展方法,用于向 Blazor 应用程序的依赖引擎添加服务:AddScopedAddTransientAddSingleton等等。就像在 ASP.NET Core MVC 应用程序中一样(第十五章介绍 ASP.NET Core MVC),服务是实现业务逻辑和存储共享状态的首选位置。在 ASP.NET Core MVC 中,服务通常传递给控制器,而在 Blazor WebAssembly 中,它们被注入到组件中。

下一小节将解释根App组件如何模拟页面更改。

路由

由主机构建代码引用的根App类在BlazorReview.Client->App.razor文件中定义。App是一个 Blazor 组件,像所有 Blazor 组件一样,它是在具有.razor扩展名的文件中定义的,并且使用富有组件标记的 Razor 语法,即用表示其他 Blazor 组件的类似 HTML 的标签。它包含了处理应用程序页面的全部逻辑:

<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData"
                    DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@*Template that specifies what to show 
when user is not authorized *@
</NotAuthorized>
</AuthorizeRouteView>
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
</CascadingAuthenticationState> 

前面代码中的所有标记都代表组件或特定的组件参数,称为模板。组件将在本章中详细讨论。暂时想象它们是一种我们可以用 C#和 Razor 代码定义的自定义 HTML 标记。模板则是接受 Razor 标记作为值的参数。模板将在本节的模板和级联参数小节中讨论。

CascadingAuthenticationState组件的唯一功能是将身份验证和授权信息传递给其内部组件树中的所有组件。只有在项目创建过程中选择添加授权时,Visual Studio 才会生成它。

Router组件是实际的应用程序路由器。它扫描AppAssembly参数中传递的程序集,寻找包含路由信息的组件,即可以作为页面工作的组件。Visual Studio 将包含Program类的程序集传递给它,即主应用程序。其他程序集中包含的页面可以通过AdditionalAssemblies参数添加,该参数接受一个程序集的IEnumerable

之后,路由器拦截所有通过代码或通过通常的<a> HTML 标签执行的页面更改,这些标签指向应用程序基地址内的地址。导航可以通过代码处理,通过从依赖注入中要求NavigationManager实例来处理。

Router组件有两个模板,一个用于找到请求的 URI 的页面(Found),另一个用于找不到请求的页面(NotFound)。当应用程序使用授权时,Found模板由AuthorizeRouteView组件组成,进一步区分用户是否有权访问所选页面。当应用程序不使用授权时,Found模板由RouteView组件组成:

<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /> 

RouteView接受所选页面,并在由DefaultLayout参数指定的布局页面内呈现它。这个规范只是一个默认值,因为每个页面都可以通过指定不同的布局页面来覆盖它。Blazor 布局页面的工作方式类似于第十五章介绍 ASP.NET Core MVC中描述的 ASP.NET Core MVC 布局页面,唯一的区别是指定页面标记的位置是用@Body指定的:

<div class="content px-4">
      @Body
</div> 

在 Visual Studio 模板中,默认的布局页面位于BlazorReview.Client->Shared->MainLayout.razor文件中。

如果应用程序使用授权,AuthorizeRouteView的工作方式类似于RouteView,但它还允许指定一个用户未经授权的情况下的模板:

<NotAuthorized>
@if (!context.User.Identity.IsAuthenticated)
{
<RedirectToLogin />
}
else
{
<p>You are not authorized to access this resource.</p>
}
</NotAuthorized> 

如果用户未经过身份验证,RedirectToLogin组件将使用NavigationManager实例来转到登录逻辑页面,否则,它会通知用户他们没有足够的权限来访问所选页面。

Blazor WebAssembly 还允许程序集延迟加载以减少初始应用程序加载时间,但由于篇幅有限,我们将不在此讨论。进一步阅读部分包含了官方 Blazor 文档的参考资料。

Blazor 页面和组件

在本节中,您将学习 Blazor 组件的基础知识,如何定义组件,其结构,如何将事件附加到 HTML 标记,如何定义它们的属性,以及如何在组件内部使用其他组件。我们已将所有内容组织成不同的子节。第一个子节描述了组件结构的基础知识。

组件结构

组件是在扩展名为.razor的文件中定义的。一旦编译,它们就变成了从ComponentBase继承的类。与所有其他 Visual Studio 项目元素一样,Blazor 组件可以通过添加新项菜单获得。通常,要用作页面的组件是在Pages文件夹中定义的,或者在其子文件夹中定义,而其他组件则组织在不同的文件夹中。默认的 Blazor 项目将所有非页面组件添加到Shared文件夹中,但您可以以不同的方式组织它们。

默认情况下,页面被分配一个与它们所在文件夹的路径相对应的命名空间。因此,例如,在我们的示例项目中,所有在BlazorReview.Client->Pages路径中的页面都被分配到BlazorReview.Client.Pages命名空间。但是,您可以通过在文件顶部的声明区域中放置一个@namespace声明来更改此默认命名空间。此区域还可以包含其他重要的声明。以下是一个显示所有声明的示例:

@page "/counter"
@layout MyCustomLayout
@namespace BlazorApp2.Client.Pages
@using Microsoft.AspNetCore.Authorization
@implements MyInterface
@inherits MyParentComponent
@typeparam T
@attribute [Authorize]
@inject NavigationManager navigation 

前两个指令只对必须作为页面工作的组件有意义。更具体地说,@layout指令用另一个组件覆盖默认的布局页面,而@page指令定义了页面的路径(路由)在应用程序的基本 URL 内。因此,例如,如果我们的应用程序在https://localhost:5001上运行,那么上述页面的 URL 将是https://localhost:5001/counter。页面路由也可以包含参数,就像在这个例子中一样:/orderitem/{customer}/{order}。参数名称必须与组件定义的参数的公共属性匹配。匹配不区分大小写,并且参数将在本小节后面进行解释。

实例化每个参数的字符串被转换为参数类型,如果此转换失败,则会抛出异常。可以通过将每个参数与类型关联来防止这种行为,在这种情况下,如果转换为指定类型失败,则与页面 URL 的匹配失败。只支持基本类型:/orderitem/{customer:int}/{order:int}。参数是强制性的,也就是说,如果找不到它们,匹配失败,路由器会尝试其他页面。但是,您可以通过指定两个@page指令使参数变为可选,一个带参数,另一个不带参数。

@namespace覆盖了组件的默认命名空间,而@using等同于通常的 C# using。在特殊的{project folder}->_Imports.razor文件夹中声明的@using会自动应用于所有组件。

@inherits声明组件是另一个组件的子类,而@implements声明它实现了一个接口。

如果组件是一个泛型类,则使用@typeparam,并声明泛型参数的名称,而@attribute声明应用于组件类的任何属性。属性级别的属性直接应用于代码区域中定义的属性,因此它们不需要特殊的标记。[Authorize]属性应用于作为页面使用的组件类,防止未经授权的用户访问页面。它的工作方式与在 ASP.NET Core MVC 中应用于控制器或操作方法时完全相同。

最后,@inject指令需要一个类型实例来注入依赖注入引擎,并将其插入到类型名称后声明的字段中;在前面的示例中,在navigation参数中。

组件文件的中间部分包含了将由 Razor 标记呈现的 HTML,其中可能包含对子组件的调用。

文件的底部由@code构造包围,并包含实现组件的类的字段、属性和方法:

@code{
 ...
 private string myField="0";
 [Parameter]
 public int Quantity {get; set;}=0;
 private void IncrementQuantity ()
 {
         Quantity++;
 }
 private void DecrementQuantity ()
 {
        Quantity--;
        if (Quantity<0) Quantity=0;
 }
 ... 
} 

[Parameter]属性修饰的公共属性作为组件参数工作;也就是说,当组件实例化到另一个组件中时,它们用于将值传递给修饰的属性,就像在 HTML 标记中将值传递给 HTML 元素一样:

<OrderItem Quantity ="2" Id="123"/> 

值也可以通过页面路由参数传递给组件参数,这些参数与属性名称进行不区分大小写的匹配:

OrderItem/{id}/{quantity} 

组件参数也可以接受复杂类型和函数:

<modal title='() => "Test title" ' ...../> 

如果组件是通用的,它们必须为每个使用typeparam声明的通用参数传递类型值:

<myGeneric T= "string"……/> 

然而,通常编译器能够从其他参数的类型中推断出通用类型。

最后,@code指令包围的代码也可以在与组件相同的名称和命名空间的部分类中声明:

public partial class Counter
{
  [Parameter] 
public int CurrentCounter {get; set;}=0;
  ...
  ...
} 

通常,这些部分类被声明在与组件相同的文件夹中,并且文件名等于组件文件名加上.cs后缀。因此,例如,与counter.razor组件关联的部分类将是counter.razor.cs

每个组件也可以有一个关联的 CSS 文件,其名称必须是组件文件名加上.css后缀。因此,例如,与counter.razor组件关联的 CSS 文件将是counter.razor.css。此文件中包含的 CSS 仅应用于该组件,对页面的其余部分没有影响。这称为 CSS 隔离,目前是通过向所有组件 HTML 根添加唯一属性来实现的。然后,组件 CSS 文件的所有选择器都被限定为此属性,以便它们不能影响其他 HTML。

每当一个组件用[Parameter(CaptureUnmatchedValues = true)]修饰一个IDictionary<string, object>参数时,那么所有未匹配的参数插入到标签中,也就是所有没有匹配组件属性的参数,都会作为键值对添加到IDictionary中。

此功能提供了一种简单的方法,将参数转发给组件标记中包含的 HTML 元素或其他子组件。例如,如果我们有一个Detail组件,它显示传递给其Value参数的对象的详细视图,我们可以使用此功能将所有常规 HTML 属性转发到组件的根 HTML 标记,如下例所示:

<div  @attributes="AdditionalAttributes">
...
</div>
@code{
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>
AdditionalAttributes { get; set; }
 [Parameter]
 Public T Value {get; set;}
} 

这样,添加到组件标记的常规 HTML 属性,例如 class,将被转发到组件的根div,并以某种方式用于样式化组件:

<Detail Value="myObject" class="my-css-class"/> 

下一小节解释了如何将生成标记的函数传递给组件。

模板和级联参数

Blazor 通过构建称为渲染树的数据结构来工作,该结构在 UI 更改时进行更新。在每次更改时,Blazor 会定位必须呈现的 HTML 部分,并使用渲染树中包含的信息来更新它。

RenderFragment委托定义了一个能够向渲染树的特定位置添加更多标记的函数。还有一个RenderFragment<T>,它接受一个进一步的参数,您可以使用它来驱动标记生成。例如,您可以将Customer对象传递给RenderFragment<T>,以便它可以呈现该特定客户的所有数据。

您可以使用 C#代码定义RenderFragmentRenderFragment<T>,但最简单的方法是在组件中使用 Razor 标记进行定义。Razor 编译器将负责为您生成适当的 C#代码:

RenderFragment myRenderFragment = @<p>The time is @DateTime.Now.</p>;
RenderFragment<Customer> customerRenderFragment = 
(item) => @<p>Customer name is @item.Name.</p>; 

有关添加标记的位置的信息是通过其接收的RenderTreeBuilder参数传递的。您可以通过简单调用它来在组件 Razor 标记中使用RenderFragment,如下例所示:

RenderFragment myRenderFragment = ...
  ...
<div>
  ...
  @myRenderFragment
  ...
</div>
  ... 

调用RenderFragment的位置定义了它将添加其标记的位置,因为组件编译器能够生成正确的RenderTreeBuilder参数传递给它。RenderFragment<T>委托的调用如下所示:

Customer myCustomer = ...
  ...
<div>
  ...
  @myRenderFragment(myCustomer)
  ...
</div>
  ... 

作为函数,渲染片段可以像所有其他类型一样传递给组件参数。但是,Blazor 有一种特定的语法,使同时定义和传递渲染片段到组件变得更容易,即模板语法。首先,在组件中定义参数:

[Parameter]
Public RenderFragment<Customer>CustomerTemplate {get; set;}
[Parameter]
Public RenderFragment Title {get; set;} 

然后,当您调用客户时,可以执行以下操作:

<Detail>
<Title>
<h5>This is a title</h5>
</Title>
<CustomerTemplate Context=customer>
<p>Customer name is @customer.Name.</p>
</CustomerTemplate >
</Detail> 

每个渲染片段参数都由与参数同名的标记表示。您可以将定义渲染片段的标记放在其中。对于具有参数的CustomerTemplateContext关键字在标记内定义了参数名称。在我们的示例中,选择的参数名称是customer

当组件只有一个渲染片段参数时,如果它的名称为ChildContent,则模板标记可以直接封闭在组件的开始和结束标记之间:

[Parameter]
Public RenderFragment<Customer> ChildContent {get; set;}
……………
……………
<IHaveJustOneRenderFragment Context=customer>
<p>Customer name is @customer.Name.</p>
</IHaveJustOneRenderFragment> 

为了熟悉组件模板,让我们修改Pages->FetchData.razor页面,以便不再使用foreach,而是使用Repeater组件。

让我们右键单击Shared文件夹,选择添加,然后Razor 组件,并添加一个新的Repeater.razor组件。然后,用以下内容替换现有代码:

@typeparam T
@foreach(var item in Values)
{
@ChildContent(item)
}
@code {
    [Parameter]
public RenderFragment<T> ChildContent { get; set; }
    [Parameter]
public IEnumerable<T> Values { get; set; }
} 

该组件使用泛型参数进行定义,以便可以与任何IEnumerable一起使用。现在让我们用这个替换FetchData.razor组件的tbody中的标记:

<Repeater Values="forecasts" Context="forecast">
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
</Repeater> 

由于Repeater组件只有一个模板,并且我们将其命名为ChildContent,因此我们可以直接在组件的开始和结束标记中放置我们的模板标记。运行它并验证页面是否正常工作。您已经学会了如何使用模板,以及放置在组件内部的标记定义了一个模板。

一个重要的预定义模板化 Blazor 组件是CascadingValue组件。它以不进行任何更改地呈现放置在其中的内容,但将类型实例传递给其所有后代组件:

<CascadingValue  Value="new MyOptionsInstance{...}">
……
</CascadingValue > 

现在,放置在CascadingValue标记内以及所有后代组件中的所有组件都可以捕获传递给CascadingValueValue参数的MyOptionsInstance实例。只需组件声明一个与MyOptionsInstance兼容的类型的公共或私有属性,并使用CascadingParameter属性进行修饰即可:

[CascadingParameter]
privateMyOptionsInstance options {get; set;} 

匹配是通过类型兼容性执行的。在与其他具有兼容类型的级联参数存在歧义的情况下,我们可以指定CascadingValue组件的Name可选参数,并将相同的名称传递给CascadingParameter属性:[CascadingParameter("myUnique name")]

CascadingValue标签还有一个IsFixed参数,出于性能原因,应尽可能设置为true。实际上,传播级联值非常有用,用于传递选项和设置,但计算成本非常高。

IsFixed设置为true时,传播仅在每个涉及内容的第一次呈现时执行,然后在内容的生命周期内不尝试更新级联值。因此,只要级联对象的指针在内容的生命周期内没有更改,就可以使用IsFixed

级联值的一个例子是我们在路由小节中遇到的CascadingAuthenticationState组件,它将认证和授权信息级联到所有渲染的组件中。

事件

HTML 标记和 Blazor 组件都使用属性/参数来获取输入。HTML 标记通过事件向页面的其余部分提供输出,Blazor 允许将 C#函数附加到 HTML 的on{event name}属性。语法显示在Pages->Counter.razor组件中:

<p>Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
    {
        currentCount++;
    }
} 

该函数也可以作为 lambda 内联传递。此外,它接受通常的event参数的 C#等价物。进一步阅读部分包含了指向 Blazor 官方文档页面的链接,列出了所有支持的事件及其参数。

Blazor 还允许组件中的事件,因此它们也可以返回输出。组件事件是类型为EventCallBackEventCallBack<T>的参数。EventCallBack是没有参数的组件事件类型,而EventCallBack<T>是带有类型为T的参数的组件事件类型。为了触发一个事件,比如MyEvent,组件调用:

awaitMyEvent.InvokeAsync() 

或者

awaitMyIntEvent.InvokeAsync(arg) 

这些调用执行与事件绑定的处理程序,如果没有绑定处理程序,则不执行任何操作。

一旦定义,组件事件可以与 HTML 元素事件完全相同的方式使用,唯一的区别在于不需要使用@前缀来命名事件,因为在 HTML 事件中,@是需要区分 HTML 属性和 Blazor 添加的具有相同名称的参数之间的区别:

[Parameter]
publicEventCallback MyEvent {get; set;}
[Parameter]
publicEventCallback<int> MyIntEvent {get; set;}
...
...
<ExampleComponent 
MyEvent="() => ..." 
MyIntEvent = "(i) =>..." /> 

实际上,HTML 元素事件也是EventCallBack<T>,这就是为什么这两种事件类型的行为完全相同。EventCallBackEventCallBack<T>都是结构体,而不是委托,因为它们包含一个委托,以及一个指向必须被通知事件已被触发的实体的指针。从形式上讲,这个实体由Microsoft.AspNetCore.Components.IHandleEvent接口表示。不用说,所有组件都实现了这个接口。通知IHandleEvent发生了状态变化。状态变化在 Blazor 更新页面 HTML 的方式中起着基本作用。我们将在下一小节中详细分析它们。

对于 HTML 元素,Blazor 还提供了通过向指定事件的属性添加:preventDefault:stopPropagation指令来阻止事件的默认操作和事件冒泡的可能性,就像这些例子中一样:

@onkeypress="KeyHandler" @onkeypress:preventDefault="true"
@onkeypress="KeyHandler" @onkeypress:preventDefault="true" @onkeypress:stopPropagation  ="true" 

绑定

通常,组件参数值必须与外部变量、属性或字段保持同步。这种同步的典型应用是在输入组件或 HTML 标记中编辑的对象属性。每当用户更改输入值时,对象属性必须一致更新,反之亦然。对象属性值必须在组件渲染时立即复制到组件中,以便用户可以编辑它。

类似的情况由参数-事件对处理。具体来说,一方面,属性被复制到输入组件参数中。另一方面,每当输入更改值时,都会触发一个更新属性的组件事件。这样,属性和输入值保持同步。

这种情况非常常见和有用,以至于 Blazor 有一个特定的语法,可以同时定义事件和将属性值复制到参数中。这种简化的语法要求事件与交互中涉及的参数具有相同的名称,但带有Changed后缀。

例如,假设一个组件有一个Value参数。那么相应的事件必须是ValueChanged。此外,每当用户更改组件值时,组件必须通过调用await ValueChanged.InvokeAsync(arg)来调用ValueChanged事件。有了这个设置,可以使用这里显示的语法将属性MyObject.MyPropertyValue属性同步:

<MyComponent @bind-Value="MyObject.MyProperty"/> 

上述语法称为绑定。Blazor 会自动附加一个更新MyObject.MyProperty属性的事件处理程序到ValueChanged事件。

HTML 元素的绑定方式类似,但由于开发人员无法决定参数和事件的名称,因此必须使用略有不同的约定。首先,无需在绑定中指定参数名称,因为它始终是 HTML 输入value属性。因此,绑定简单地写为@bind="object.MyProperty"。默认情况下,对象属性在change事件上更新,但您可以通过添加@bind-event: @bind-event="oninput"属性来指定不同的事件。

此外,HTML 输入的绑定尝试自动将输入字符串转换为目标类型。如果转换失败,输入将恢复到其初始值。这种行为相当原始,因为在出现错误时,不会向用户提供错误消息,并且文化设置没有得到正确的考虑(HTML5 输入使用不变的文化,但文本输入必须使用当前文化)。我们建议只将输入绑定到字符串目标类型。Blazor 具有专门用于处理日期和数字的组件,应该在目标类型不是字符串时使用。我们将在Blazor 表单和验证部分中对它们进行描述。

为了熟悉事件,让我们编写一个组件,当用户单击确认按钮时,同步输入文本类型的内容。右键单击Shared文件夹,然后添加一个新的ConfirmedText.razor组件。然后用以下代码替换其代码:

<input type="text" @bind="Value" @attributes="AdditionalAttributes"/>
<button class="btn btn-secondary" @onclick="Confirmed">@ButtonText</button>
@code {
    [Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object> AdditionalAttributes { get; set; }
    [Parameter]
public string Value {get; set;}
    [Parameter]
public EventCallback<string> ValueChanged { get; set; }
    [Parameter]
public string ButtonText { get; set; }
async Task Confirmed()
    {
        await ValueChanged.InvokeAsync(Value);
    }
} 

ConfirmedText组件利用按钮点击事件来触发ValueChanged事件。此外,组件本身使用@bind将其Value参数与 HTML 输入同步。值得指出的是,组件使用CaptureUnmatchedValues将应用于其标记的所有 HTML 属性转发到 HTML 输入。这样,ConfirmedText组件的用户可以通过简单地向组件标记添加class和/或style属性来设置输入字段的样式。

现在让我们在Pages->Index.razor页面中使用此组件,方法是将以下代码放在Index.razor的末尾:

<ConfirmedText @bind-Value="textValue" ButtonText="Confirm" />
<p>
    Confirmed value is: @textValue
</p>
@code{
private string textValue = null;
} 

如果运行项目并与输入及其确认按钮进行交互,您会发现每次单击确认按钮时,不仅会将输入值复制到textValue页面属性中,而且组件后面段落的内容也会得到一致的更新。

我们明确使用@bind-ValuetextValue与组件同步,但是谁负责保持textValue与段落内容同步?答案在下一小节中。

Blazor 如何更新 HTML

当我们在 Razor 标记中写入变量、属性或字段的内容时,例如@model.property,Blazor 不仅在组件呈现时呈现变量、属性或字段的实际值,而且还尝试在该值每次更改时更新 HTML,这个过程称为变更检测。变更检测是所有主要 SPA 框架的特性,但 Blazor 实现它的方式非常简单和优雅。

基本思想是,一旦所有 HTML 都被呈现,更改只能因为在事件内执行的代码而发生。这就是为什么EventCallBackEventCallBack<T>包含对IHandleEvent的引用。当组件将处理程序绑定到事件时,Razor 编译器创建一个EventCallBackEventCallBack<T>,并在其struct构造函数中传递绑定到事件的函数以及定义该函数的组件(IHandleEvent)。

处理程序的代码执行后,Blazor 运行时会通知IHandleEvent可能已更改。实际上,处理程序代码只能更改组件中定义处理程序的变量、属性或字段的值。反过来,这会触发组件中的变更检测。Blazor 验证了组件 Razor 标记中使用的变量、属性或字段的更改,并更新相关的 HTML。

如果更改的变量、属性或字段是另一个组件的输入参数,则该组件生成的 HTML 可能也需要更新。因此,会递归触发另一个根据该组件触发的变更检测过程。

先前概述的算法仅在满足以下列出的条件时才发现所有相关更改:

  1. 在事件处理程序中,没有组件引用其他组件的数据结构。

  2. 所有组件的输入都通过其参数而不是通过方法调用或其他公共成员到达。

如果由于前述条件之一的失败而未检测到更改,则开发人员必须手动声明组件可能的更改。这可以通过调用StateHasChanged()组件方法来实现。由于此调用可能会导致页面 HTML 的更改,因此其执行不能异步进行,而必须在 HTML 页面 UI 线程中排队。这是通过将要执行的函数传递给InvokeAsync组件方法来完成的。

总结一下,要执行的指令是await InvokeAsync(StateHasChanged)

下一小节总结了组件的生命周期及相关的生命周期方法的描述。

组件生命周期

每个组件生命周期事件都有一个关联的方法。一些方法既有同步版本又有异步版本,有些只有异步版本,而有些只有同步版本。

组件生命周期始于传递给组件的参数被复制到相关的组件属性中。您可以通过覆盖以下方法来自定义此步骤:

public override async Task SetParametersAsync(ParameterView parameters)
{
await ...
await base.SetParametersAsync(parameters);
} 

通常,定制包括修改其他数据结构,因此调用基本方法也执行将参数复制到相关属性的默认操作。

之后,与这两种方法相关联的组件初始化如下:

protected override void OnInitialized()
{
    ...
}
protected override async Task OnInitializedAsync()
{
await ...
} 

它们在组件生命周期中只被调用一次,即在组件创建并添加到渲染树后立即调用。请将任何初始化代码放在那里,而不是在组件构造函数中,因为这将提高组件的可测试性,因为在那里,您已经设置了所有参数,并且未来的 Blazor 版本可能会池化和重用组件实例。

如果初始化代码订阅了某些事件或执行需要在组件销毁时进行清理的操作,请实现IDisposable,并将所有清理代码放在其Dispose方法中。实际上,每当组件实现IDisposable时,Blazor 在销毁组件之前都会调用其Dispose方法。

组件初始化后,每次组件参数更改时,都会调用以下两种方法:

protected override async Task OnParametersSetAsync()
{
await ...
}
protected override void OnParametersSet()
{
    ...
} 

它们是更新依赖于组件参数值的数据结构的正确位置。

之后,组件被渲染或重新渲染。您可以通过覆盖ShouldRender方法来防止更新后组件重新渲染:

protected override bool ShouldRender()
{
...
} 

只有在确定其 HTML 代码将更改时,才让组件重新渲染是一种高级优化技术,用于组件库的实现中。

组件渲染阶段还涉及调用其子组件。因此,只有在所有后代组件完成渲染后,组件渲染才被认为是完整的。渲染完成后,将调用以下方法:

protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
    {
    }
...
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
    {
    await...
        ...
    }
    await ...
} 

由于在调用上述方法时,所有组件 HTML 都已更新,并且所有子组件都已执行完其生命周期方法,因此上述方法是执行以下操作的正确位置:

  • 调用操纵生成的 HTML 的 JavaScript 函数。JavaScript 调用在JavaScript 互操作性子部分中描述。

  • 处理附加到参数或级联参数的信息由后代组件。事实上,类似标签的组件和其他组件可能需要在根组件中注册一些子部件,因此根组件通常会级联一个数据结构,其中一些子组件可以注册。在AfterRenderAfterRenderAsync中编写的代码可以依赖于所有子部件已完成其注册的事实。

下一节描述了 Blazor 用于收集用户输入的工具。

Blazor 表单和验证

与所有主要的 SPA 框架类似,Blazor 还提供了特定的工具来处理用户输入,同时通过错误消息和即时视觉提示向用户提供有效的反馈。整个工具集被称为Blazor Forms,包括一个名为EditForm的表单组件,各种输入组件,数据注释验证器,验证错误摘要和验证错误标签。

EditForm负责编排所有输入组件的状态,通过表单内级联的EditContext类的实例。编排来自输入组件和数据注释验证器与此EditContext实例的交互。验证摘要和错误消息标签不参与编排,但会注册一些EditContext事件以便了解错误。

EditForm必须在其Model参数中传递其属性必须呈现的对象。值得指出的是,绑定到嵌套属性的输入组件不会被验证,因此EditForm必须传递一个扁平化的 ViewModel。EditForm创建一个新的EditContext实例,将其接收到的对象传递给其构造函数中的Model参数,并级联它以便它可以与表单内容交互。

您还可以直接在EditFormEditContext参数中传递一个EditContext自定义实例,而不是在其Model参数中传递对象,这种情况下,EditForm将使用您的自定义副本而不是创建一个新实例。通常,当您需要订阅EditContextOnValidationStateChangedOnFieldChanged事件时,可以这样做。

当使用提交按钮提交EditForm且没有错误时,表单会调用其OnValidSubmit回调,在这里您可以放置使用和处理用户输入的代码。如果有验证错误,表单会调用其OnInvalidSubmit回调。

每个输入的状态反映在自动添加到其中的一些 CSS 类中,即:validinvalidmodified。您可以使用这些类为用户提供适当的视觉反馈。默认的 Blazor Visual Studio 模板已经为它们提供了一些 CSS。

以下是一个典型的表单:

<EditForm Model="FixedInteger"OnValidSubmit="@HandleValidSubmit" >
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label for="integerfixed">Integer value</label>
<InputNumber @bind-Value="FixedInteger.Value"
id="integerfixed" class="form-control" />
<ValidationMessage For="@(() => FixedInteger.Value)" />
</div>
<button type="submit" class="btn btn-primary"> Submit</button>
</EditForm> 

标签是标准的 HTML 标签,而InputNumber是一个专门用于数字属性的 Blazor 组件。ValidationMessage是仅在验证错误发生时出现的错误标签。默认情况下,它以validation-message CSS 类呈现。与错误消息相关联的属性通过无参数的 lambda 传递给for参数,如示例所示。

DataAnnotationsValidator组件基于通常的.NET 验证属性(如RangeAttributeRequiredAttribute等)添加了验证。您还可以通过继承ValidationAttribute类来编写自定义验证属性。

您可以在验证属性中提供自定义错误消息。如果它们包含{0}占位符,如果找到DisplayAttribute,则将填充为属性显示名称,否则将填充为属性名称。

除了InputNumber组件外,Blazor 还支持用于string属性的InputText组件,用于在 HTMLtextarea中编辑string属性的InputTextArea组件,用于bool属性的InputCheckbox组件,以及用于呈现DateTimeDateTimeOffsetInputDate组件。它们的工作方式与InputNumber组件完全相同。没有其他 HTML5 输入类型的组件可用。特别是,没有用于呈现时间或日期和时间,或用于使用range小部件呈现数字的组件。

您可以通过继承InputBase<TValue>类并重写BuildRenderTreeFormatValueAsStringTryParseValueFromString方法来实现渲染时间或日期和时间。InputNumber组件的源代码显示了如何做到这一点:github.com/dotnet/aspnetcore/blob/15f341f8ee556fa0c2825cdddfe59a88b35a87e2/src/Components/Web/src/Forms/InputNumber.cs。您还可以使用Blazor WebAssembly 的第三方工具部分中描述的第三方库。

Blazor 还有一个专门用于呈现select的组件,其工作方式如下例所示:

<InputSelect @bind-Value="order.ProductColor">
<option value="">Select a color ...</option>
<option value="Red">Red</option>
<option value="Blue">Blue</option>
<option value="White">White</option>
</InputSelect> 

您还可以使用InputRadioGroupInputRadio组件将枚举呈现为单选按钮组,如下例所示:

<InputRadioGroup Name="color" @bind-Value="order.Color">
<InputRadio Name="color" Value="AllColors.Red" /> Red<br>
<InputRadio Name="color" Value="AllColors.Blue" /> Blue<br>
<InputRadio Name="color" Value="AllColors.White" /> White<br>
</InputRadioGroup> 

最后,Blazor 还提供了一个InputFile组件以及处理和上传文件的所有工具。我们不会在这里介绍,但进一步阅读部分包含指向官方文档的链接。

本小节结束了对 Blazor 基础知识的描述;下一节将分析一些高级功能。

Blazor 高级功能

本节收集了各种 Blazor 高级功能的简短描述,分为子节。由于篇幅有限,我们无法提供每个功能的所有细节,但缺少的细节在进一步阅读部分的链接中有所涵盖。我们从如何引用 Razor 标记中定义的组件和 HTML 元素开始。

组件和 HTML 元素的引用

有时我们可能需要引用组件以便调用其一些方法。例如,对于实现模态窗口的组件就是这种情况:

<Modal @ref="myModal">
...
</Modal>
...
<button type="button" class="btn btn-primary" 
@onclick="() => myModal.Show()">
Open modal
</button>
...
@code{
private Modal  myModal {get; set;}
 ...
} 

正如前面的例子所示,引用是使用@ref指令捕获的。相同的@ref指令也可以用于捕获对 HTML 元素的引用。HTML 引用具有ElementReference类型,并且通常用于在 HTML 元素上调用 JavaScript 函数,如下一小节所述。

JavaScript 互操作性

由于 Blazor 不会将所有 JavaScript 功能暴露给 C#代码,并且由于方便利用可用的大量 JavaScript 代码库,有时需要调用 JavaScript 函数。Blazor 通过IJSRuntime接口允许这样做,该接口可以通过依赖注入注入到组件中。

一旦有了IJSRuntime实例,就可以调用返回值的 JavaScript 函数,如下所示:

T result = await jsRuntime.InvokeAsync<T>(
"<name of JavaScript function or method>", arg1, arg2....); 

不返回任何参数的函数可以像这样被调用:

awaitjsRuntime.InvokeAsync(
"<name of JavaScript function or method>", arg1, arg2....); 

参数可以是基本类型或可以在 JSON 中序列化的对象,而 JavaScript 函数的名称是一个字符串,可以包含表示属性、子属性和方法名称的点,例如"myJavaScriptObject.myProperty.myMethod"字符串。

参数也可以是使用@ref指令捕获的ElementReference实例,在这种情况下,它们在 JavaScript 端作为 HTML 元素接收。

调用的 JavaScript 函数必须在Index.html文件中定义,或者在Index.html中引用的 JavaScript 文件中定义。

如果您正在编写一个带有 Razor 库项目的组件库,JavaScript 文件可以作为 DLL 库中的资源与 CSS 文件一起嵌入。只需在项目根目录中添加一个wwwroot文件夹,并将所需的 CSS 和 JavaScript 文件放在该文件夹或其子文件夹中。之后,这些文件可以被引用为:

_content/<dll name>/<file path in wwwroot> 

因此,如果文件名为myJsFile.js,dll 名称为MyCompany.MyLibrary,并且文件放在wwwroot内的js文件夹中,则其引用将是:

_content/MyCompany.MyLibrary/js/myJsFile.js 

如果您的 JavaScript 文件组织为 ES6 模块,您可以避免在Index.html中引用它们,并可以直接加载模块,如下所示:

// _content/MyCompany.MyLibrary/js/myJsFile.js  JavaScript file 
export function myFunction ()
{
...
}
...
//C# code
var module = await jsRuntime.InvokeAsync<JSObjectReference>(
    "import", "./_content/MyCompany.MyLibrary/js/myJsFile.js");
...
T res= await module.InvokeAsync<T>("myFunction") 

此外,可以从 JavaScript 代码中调用 C#对象的实例方法,采取以下步骤:

  1. 假设 C#方法名为MyMethod。请使用[JSInvokable]属性装饰MyMethod方法。

  2. 将 C#对象封装在DotNetObjectReference实例中,并通过 JavaScript 调用将其传递给 JavaScript:

var objRef = DotNetObjectReference.Create(myObjectInstance);
//pass objRef to JavaScript
....
//dispose the DotNetObjectReference
objRef.Dispose() 
  1. 在 JavaScript 方面,假设 C#对象在名为dotnetObject的变量中。然后只需调用:
dotnetObject.invokeMethodAsync("<dll name>", "MyMethod", arg1, ...).
then(result => {...}) 

下一节将解释如何处理内容和数字/日期本地化。

全球化和本地化

一旦 Blazor 应用程序启动,应用程序文化和应用程序 UI 文化都将设置为浏览器文化。但是,开发人员可以通过将所选文化分配给CultureInfo.DefaultThreadCurrentCultureCultureInfo.DefaultThreadCurrentUICulture来更改它们。通常,应用程序允许用户选择其支持的文化之一,或者仅在支持的情况下接受浏览器文化,否则将回退到支持的文化。实际上,只能支持合理数量的文化,因为所有应用程序字符串必须在所有支持的文化中进行翻译。

一旦设置了CurrentCulture,日期和数字将根据所选文化的惯例自动格式化。对于 UI 文化,开发人员必须手动提供包含所有支持的文化中所有应用程序字符串翻译的资源文件。

有两种使用资源文件的方法。使用第一种选项,您创建一个资源文件,比如myResource.resx,然后添加所有特定语言的文件:myResource.it.resxmyResource.pt.resx等。在这种情况下,Visual Studio 会创建一个名为myResource的静态类,其静态属性是每个资源文件的键。这些属性将自动包含与当前 UI 文化对应的本地化字符串。您可以在任何地方使用这些静态属性,并且您可以使用由资源类型和资源名称组成的对来设置验证属性的ErrorMessageResourceTypeErrorMessageResourceName属性,或其他属性的类似属性。这样,属性将使用自动本地化的字符串。

使用第二种选项,您只添加特定语言的资源文件(myResource.it.resxmyResource.pt.resx等)。在这种情况下,Visual Studio 不会创建与资源文件关联的任何类,您可以将资源文件与在组件中注入的IStringLocalizerIStringLocalizer<T>一起使用,就像在 ASP.NET Core MVC 视图中使用它们一样(请参阅第十五章ASP.NET Core 全球化部分,展示 ASP.NET Core MVC)。

认证和授权

Routing子部分中,我们概述了CascadingAuthenticationStateAuthorizeRouteView组件如何阻止未经授权的用户访问受[Authorize]属性保护的页面。让我们深入了解页面授权的工作原理。

在.NET 应用程序中,身份验证和授权信息通常包含在ClaimsPrincipal实例中。在服务器应用程序中,当用户登录时,将构建此实例,并从数据库中获取所需的信息。在 Blazor WebAssembly 中,此类信息必须由负责 SPA 身份验证的远程服务器提供。由于有几种方法可以为 Blazor WebAssembly 应用程序提供身份验证和授权,因此 Blazor 定义了AuthenticationStateProvider抽象。

身份验证和授权提供程序继承自AuthenticationStateProvider抽象类,并覆盖其GetAuthenticationStateAsync方法,该方法返回一个Task<AuthenticationState>,其中AuthenticationState包含身份验证和授权信息。实际上,AuthenticationState只包含一个具有ClaimsPrincipalUser属性。

一旦我们定义了AuthenticationStateProvider的具体实现,我们必须在应用程序的program.cs文件中将其注册到依赖引擎容器中。

services.AddScoped<AuthenticationStateProvider, MyAuthStateProvider>(); 

在描述了 Blazor 如何使用由注册的AuthenticationStateProvider提供的身份验证和授权信息后,我们将回到 Blazor 提供的AuthenticationStateProvider的预定义实现。

CascadingAuthenticationState组件调用注册的AuthenticationStateProviderGetAuthenticationStateAsync方法,并级联返回的Task<AuthenticationState>。您可以使用以下方式在组件中定义[CascadingParameter]来拦截此级联值:

[CascadingParameter]
private Task<AuthenticationState>myAuthenticationStateTask { get; set; }
……
ClaimsPrincipal user = (await myAuthenticationStateTask).User; 

然而,Blazor 应用程序通常使用AuthorizeRouteViewAuthorizeView组件来控制用户对内容的访问。

AuthorizeRouteView如果用户不满足页面[Authorize]属性的要求,则阻止访问页面,否则将呈现NotAuthorized模板中的内容。AuthorizeRouteView还有一个Authorizing模板,当正在检索用户信息时会显示该模板。

AuthorizeView可以在组件内部使用,仅向经过授权的用户显示其包含的标记。它包含与[Authorize]属性相同的RolesPolicy参数,您可以使用这些参数来指定用户必须满足的约束以访问内容。

<AuthorizeView Roles="Admin,SuperUser">
//authorized content
</AuthorizeView> 

AuthorizeView还可以指定NotAuthorizedAuthorizing模板:

<AuthorizeView>
<Authorized>
...
</Authorized>
<Authorizing>
        ...
</Authorizing>
<NotAuthorized>
        ...
</NotAuthorized>
</AuthorizeView> 

如果在创建 Blazor WebAssembly 项目时添加了授权,将向应用程序的依赖引擎添加以下方法调用:

builder.Services.AddApiAuthorization(); 

此方法添加了一个AuthenticationStateProvider,该提取用户信息的方式是从通常的 ASP.NET Core 身份验证 cookie 中提取。由于身份验证 cookie 是加密的,因此必须通过联系服务器公开的端点来执行此操作。此操作是通过本章的加载和启动应用程序子章节中看到的AuthenticationService.js JavaScript 文件来执行的。服务器端点以 bearer token 的形式返回用户信息,该 token 也可用于验证与服务器的 WEB API 的通信。有关 bearer token 的详细信息,请参见第十四章使用.NET Core 应用服务导向架构中的REST 服务授权和身份验证ASP.NET Core 服务授权部分。Blazor WebAssembly 通信将在下一子章节中描述。

如果找不到有效的身份验证 cookie,提供程序将创建一个未经身份验证的ClaimsPrincipal。这样,当用户尝试访问由[Authorize]属性保护的页面时,AuthorizeRouteView组件会调用RedirectToLogin组件,后者又会导航到Authentication.razor页面,并在其action路由参数中传递一个登录请求。

@page "/authentication/{action}"
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
<RemoteAuthenticatorView Action="@Action"  />
@code{
    [Parameter] public string Action { get; set; }
} 

RemoteAuthenticatorView充当与通常的 ASP.NET Core 用户登录/注册系统的接口,每当它接收要执行的“操作”时,都会将用户从 Blazor 应用程序重定向到适当的 ASP.NET Core 服务器页面(登录、注册、注销、用户资料)。

与服务器通信所需的所有信息都基于名称约定,但可以使用AddApiAuthorization方法的options参数进行自定义。例如,在那里,您可以更改用户可以注册的 URL,以及 Blazor 用于收集有关服务器设置的端点的地址。此端点位于BlazorReview.Server->Controller->OidcConfigurationController.cs文件中。

用户登录后,将被重定向到引起登录请求的 Blazor 应用程序页面。重定向 URL 由BlazorReview.Client->Shared->RedirectToLogin.razor组件计算,该组件从NavigationManager中提取 URL 并将其传递给RemoteAuthenticatorView组件。这次,AuthenticationStateProvider能够从登录操作创建的身份验证 cookie 中获取用户信息。

有关身份验证过程的更多详细信息,请参阅Further reading部分中的官方文档参考。

下一小节描述了HttpClient类和相关类型的 Blazor WebAssembly 特定实现。

与服务器的通信

Blazor WebAssembly 支持与第十四章应用.NET Core 的面向服务的架构中描述的相同的.NET HttpClientHttpClientFactory类。但是,由于浏览器的通信限制,它们的实现是不同的,并依赖于浏览器的fetch API

第十四章应用.NET Core 的面向服务的架构中,我们分析了如何利用HttpClientFactory来定义类型化的客户端。您也可以使用完全相同的语法在 Blazor 中定义类型化的客户端。

然而,由于 Blazor 需要在每个请求中发送在身份验证过程中创建的令牌到应用程序服务器,因此通常会定义一个命名客户端,如下所示:

builder.Services.AddHttpClient("BlazorReview.ServerAPI", client =>
    client.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
.AddHttpMessageHandler<BaseAddressAuthorizationMessageHandler>(); 

AddHttpMessageHandler添加了一个DelegatingHandler,即DelegatingHandler抽象类的子类。DelegatingHandler的实现重写了其SendAsync方法,以处理每个请求和每个相关响应:

protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
{
//modify request 
   ...
HttpResponseMessage= response = await base.SendAsync(
request, cancellationToken);
//modify response
   ...
return response;
} 

BaseAddressAuthorizationMessageHandler是通过我们在前一节中看到的AddApiAuthorization调用添加到依赖注入引擎中的。它将由授权过程生成的令牌添加到每个发送到应用程序服务器域的请求中。如果此令牌已过期或根本找不到,则它会尝试从用户身份验证 cookie 中获取新的令牌。如果此尝试也失败,则会抛出AccessTokenNotAvailableException。通常,类似的异常会被捕获并触发重定向到登录页面(默认情况下为/authentication/{action}):

try
    {
        //server call here
    }
catch (AccessTokenNotAvailableException exception)
    {
        exception.Redirect();
    } 

由于大多数请求都是针对应用程序服务器的,并且只有少数调用可能会与 CORS 联系其他服务器,因此BlazorReview.ServerAPI命名为client也被定义为默认的HttpClient实例:

builder.Services.AddScoped(sp =>
                sp.GetRequiredService<IHttpClientFactory>()
                    .CreateClient("BlazorReview.ServerAPI")); 

可以通过向依赖注入引擎请求HttpClient实例来获取默认客户端。可以通过定义使用其他令牌的其他命名客户端来处理对其他服务器的 CORS 请求。可以通过首先从依赖注入中获取IHttpClientFactory实例,然后调用其CreateClient("<named client name>")方法来获取命名客户端。Blazor 提供了用于获取令牌和连接到知名服务的包。它们在Further reading部分中的授权文档中有描述。

接下来的部分简要讨论了一些最相关的第三方工具和库,这些工具和库完善了 Blazor 的官方功能,并帮助提高 Blazor 项目的生产力。

Blazor WebAssembly 的第三方工具

尽管 Blazor 是一个年轻的产品,但其第三方工具和产品生态系统已经相当丰富。在开源、免费产品中,值得一提的是Blazorise项目(github.com/stsrki/Blazorise),其中包含各种免费的基本 Blazor 组件(输入、选项卡、模态框等),可以使用各种 CSS 框架(如 Bootstrap 和 Material)进行样式设置。它还包含一个简单的可编辑网格和一个简单的树视图。

另外值得一提的是BlazorStrapgithub.com/chanan/BlazorStrap),其中包含了所有 Bootstrap 4 组件和小部件的纯 Blazor 实现。

在所有商业产品中,值得一提的是Blazor Controls Toolkitblazor.mvc-controls.com/),这是一个用于实现商业应用程序的完整工具集。它包含了所有输入类型及其在浏览器不支持时的回退;所有 Bootstrap 组件;其他基本组件;以及一个完整的、高级的拖放框架;高级可定制和可编辑的组件,如详细视图、详细列表、网格、树重复器(树视图的泛化)。所有组件都基于一个复杂的元数据表示系统,使用户能够使用数据注释和内联 Razor 声明以声明方式设计标记。

此外,它还包含了额外复杂的验证属性,撤消用户输入的工具,计算发送到服务器的更改的工具,基于 OData 协议的复杂客户端和服务器端查询工具,以及用于维护和保存整个应用程序状态的工具。

还值得一提的是bUnit开源项目(github.com/egil/bUnit),它提供了测试 Blazor 组件的所有工具。

接下来的部分将展示如何将所学知识付诸实践,实现一个简单的应用程序。

用例 - 在 Blazor WebAssembly 中实现一个简单的应用程序

在本节中,我们将为WWTravelClub书籍使用案例实现一个包搜索应用程序。第一小节解释了如何利用我们在第十五章 介绍 ASP.NET Core MVC中已经实现的域层和数据层来设置解决方案。

准备解决方案

首先,创建一个PackagesManagement解决方案文件夹的副本,我们在第十五章 介绍 ASP.NET Core MVC中创建,并将其重命名为PackagesManagementBlazor

打开解决方案,右键单击 Web 项目(名为PackagesManagement)并删除它。然后,转到解决方案文件夹并删除整个 Web 项目文件夹(名为PackagesManagement)。

现在右键单击解决方案,然后选择添加新项目。添加一个名为PackagesManagementBlazor的新的 Blazor WebAssembly 项目。选择无身份验证ASP.NET Core 托管。我们不需要身份验证,因为我们将要实现的按位置搜索功能也必须对未注册用户可用。

确保PackagesManagementBlazor.Server项目是启动项目(其名称应为粗体)。如果不是,请右键单击它,然后单击设置为启动项目

服务器项目需要引用数据(PackagesManagementDB)和域(PackagesManagementDomain)项目,请将它们添加为引用。

让我们也将旧 Web 项目的相同连接字符串复制到PackagesManagementBlazor.Serverappsettings.json文件中:

"ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=package-management;Trusted_Connection=True;MultipleActiveResultSets=true"
 }, 

这样,我们可以重用我们已经创建的数据库。我们还需要添加与旧 Web 项目添加的相同的 DDD 工具。在项目根目录中添加一个名为Tools的文件夹,并将与该书籍关联的 GitHub 存储库的ch12->ApplicationLayer文件夹的内容复制到其中。

为了完成解决方案设置,我们只需要通过在Startup.cs文件的ConfigureServices方法的末尾添加以下代码来将PackagesManagementBlazor.Server与域层连接起来:

services.AddDbLayer(Configuration
                .GetConnectionString("DefaultConnection"),
                "PackagesManagementDB"); 

这是我们在旧的 Web 项目中添加的相同方法。最后,我们还可以添加AddAllQueries扩展方法,它会发现 Web 项目中的所有查询:

services.AddAllQueries(this.GetType().Assembly); 

由于这是一个仅查询的应用程序,我们不需要其他自动发现工具。

下一小节将解释如何设计服务器端的 REST API。

实现所需的 ASP.NET Core REST API

作为第一步,让我们定义在服务器和客户端应用程序之间通信中使用的 ViewModels。它们必须在被两个应用程序引用的PackagesManagementBlazor.Shared项目中定义。

让我们从PackageInfosViewModel ViewModel 开始:

using System;
namespace PackagesManagementBlazor.Shared
{
    public class PackageInfosViewModel
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public int DurationInDays { get; set; }
        public DateTime? StartValidityDate { get; set; }
        public DateTime? EndValidityDate { get; set; }
        public string DestinationName { get; set; }
        public int DestinationId { get; set; }
        public override string ToString()
        {
            return string.Format("{0}. {1} days in {2}, price: {3}",
                Name, DurationInDays, DestinationName, Price);
        }
    }
} 

然后,还要添加一个 ViewModel,它包含要返回给 Blazor 应用程序的所有软件包:

using System.Collections.Generic;
namespace PackagesManagementBlazor.Shared
{
    public class PackagesListViewModel
    {
        public IEnumerable<PackageInfosViewModel>
            Items { get; set; }
    }
} 

现在我们还可以添加我们的查询,通过位置搜索软件包。让我们在PackagesManagementBlazor.Server项目的根目录中添加一个Queries文件夹,然后添加定义我们查询的接口IPackagesListByLocationQuery

using DDD.ApplicationLayer;
using PackagesManagementBlazor.Shared;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace PackagesManagementBlazor.Server.Queries
{
    public interface IPackagesListByLocationQuery: IQuery
    {
        Task<IEnumerable<PackageInfosViewModel>>
            GetPackagesOf(string location); 
    }
} 

最后,让我们也添加查询实现:

public class PackagesListByLocationQuery:IPackagesListByLocationQuery
    {
        private readonly MainDbContext ctx;
        public PackagesListByLocationQuery(MainDbContext ctx)
        {
            this.ctx = ctx;
        }
        public async Task<IEnumerable<PackageInfosViewModel>> GetPackagesOf(string location)
        {
            return await ctx.Packages
                .Where(m => m.MyDestination.Name.StartsWith(location))
                .Select(m => new PackageInfosViewModel
            {
                StartValidityDate = m.StartValidityDate,
                EndValidityDate = m.EndValidityDate,
                Name = m.Name,
                DurationInDays = m.DurationInDays,
                Id = m.Id,
                Price = m.Price,
                DestinationName = m.MyDestination.Name,
                DestinationId = m.DestinationId
            })
                .OrderByDescending(m=> m.EndValidityDate)
                .ToListAsync();
        }
    } 

我们终于准备好定义我们的PackagesController

using Microsoft.AspNetCore.Mvc;
using PackagesManagementBlazor.Server.Queries;
using PackagesManagementBlazor.Shared;
using System.Threading.Tasks;
namespace PackagesManagementBlazor.Server.Controllers
{
    [Route("[controller]")]
    [ApiController]
    public class PackagesController : ControllerBase
    {
        // GET api/<PackagesController>/Flor
        [HttpGet("{location}")]
        public async Task<PackagesListViewModel> Get(string location, 
            [FromServices] IPackagesListByLocationQuery query )
        {
            return new PackagesListViewModel
            {
                Items = await query.GetPackagesOf(location)
            };
        }  
    }
} 

服务器端代码已经完成!让我们继续定义与服务器通信的 Blazor 服务。

在服务中实现业务逻辑

让我们在PackagesManagementBlazor.Client项目中添加一个ViewModels和一个Services文件夹。我们需要的大多数 ViewModel 已经在PackagesManagementBlazor.Shared项目中定义。我们只需要一个用于搜索表单的 ViewModel。让我们将其添加到ViewModels文件夹中:

using System.ComponentModel.DataAnnotations;
namespace PackagesManagementBlazor.Client.ViewModels
{
    public class SearchViewModel
    {
        [Required]
        public string Location { get; set; }
    }
} 

让我们称我们的服务为PackagesClient,并将其添加到Services文件夹中:

namespace PackagesManagementBlazor.Client.Services
{
    public class PackagesClient
    {
        private HttpClient client;
        public PackagesClient(HttpClient client)
        {
            this.client = client;
        }
        public async Task<IEnumerable<PackageInfosViewModel>>
            GetByLocation(string location)
        {
            var result =
                await client.GetFromJsonAsync<PackagesListViewModel>
                    ("Packages/" + Uri.EscapeDataString(location));
            return result.Items;
        }
    }
} 

代码很简单!Uri.EscapeDataString方法对参数进行 url 编码,以便可以安全地附加到 URL 上。

最后,让我们在依赖注入中注册服务:

builder.Services.AddScoped<PackagesClient>(); 

值得指出的是,在商业应用程序中,我们应该通过IPackagesClient接口注册服务,以便能够在测试中模拟它(.AddScoped<IPackagesClient, PackagesClient>())。

一切就绪;我们只需要构建 UI。

实现用户界面

作为第一步,让我们删除我们不需要的应用页面,即Pages->Counter.razorPages->FetchData.razor。让我们还从Shared->NavMenu.razor中的侧边菜单中删除它们的链接。

我们将把我们的代码放在Pages->Index.razor页面中。让我们用以下代码替换此页面的代码:

@using PackagesManagementBlazor.Client.ViewModels
@using PackagesManagementBlazor.Shared
@using PackagesManagementBlazor.Client.Services
@inject PackagesClient client
@page "/"
<h1>Search packages by location</h1>
<EditForm Model="search"
          OnValidSubmit="Search">
<DataAnnotationsValidator />
<div class="form-group">
<label for="integerfixed">Insert location starting chars</label>
<InputText @bind-Value="search.Location" />
<ValidationMessage For="@(() => search.Location)" />
</div>
<button type="submit" class="btn btn-primary">
        Search
</button>
</EditForm>
@code{
    SearchViewModel search { get; set; } 
= new SearchViewModel();
    async Task Search()
    {
        ...
    }
} 

前面的代码添加了所需的@using,在页面中注入了我们的PackagesClient服务,并定义了搜索表单。当表单成功提交时,它会调用Search回调,我们将在其中放置检索所有结果的代码。

现在是时候添加显示所有结果的逻辑并完成@code块了。以下代码必须立即放在搜索表单之后:

@if (packages != null)
{
...
}
else if (loading)
{
    <p><em>Loading...</em></p>
}
@code{
    SearchViewModel search { get; set; } = new SearchViewModel();
    private IEnumerable<PackageInfosViewModel> packages;
    bool loading;
    async Task Search()
    {
        packages = null;
        loading = true;
        await InvokeAsync(StateHasChanged);
        packages = await client.GetByLocation(search.Location);
        loading = false;
    }
} 

if块中省略的代码负责渲染带有所有结果的表格。在注释了前面的代码之后,我们将显示它。

在使用PackagesClient服务检索结果之前,我们删除所有先前的结果并设置loading字段,因此 Razor 代码选择else if路径,用加载消息替换先前的表。一旦我们设置了这些变量,就必须调用StateHasChanged来触发变化检测并刷新页面。在检索到所有结果并且回调返回后,不需要再次调用StateHasChanged,因为回调本身的终止会触发变化检测并导致所需的页面刷新。

以下是呈现包含所有结果的表的代码:

<div class="table-responsive">
  <table class="table">
    <thead>
      <tr>
        <th scope="col">Destination</th>
        <th scope="col">Name</th>
        <th scope="col">Duration/days</th>
        <th scope="col">Price</th>
        <th scope="col">Available from</th>
        <th scope="col">Available to</th>
      </tr>
    </thead>
    <tbody>
      @foreach (var package in packages)
      {
        <tr>
          <td>
            @package.DestinationName
          </td>
          <td>
            @package.Name
          </td>
          <td>
            @package.DurationInDays
          </td>
          <td>
            @package.Price
          </td>
          <td>
            @(package.StartValidityDate.HasValue ?
              package.StartValidityDate.Value.ToString("d")
              :
              String.Empty)
          </td>
          <td>
            @(package.EndValidityDate.HasValue ?
              package.EndValidityDate.Value.ToString("d")
              :
              String.Empty)
          </td>
        </tr>
      }
    </tbody>
  </table>
</div> 

运行项目并编写 Florence 的初始字符。由于在之前的章节中,我们在数据库中插入了 Florence 作为一个位置,所以应该会出现一些结果!

总结

在本章中,您了解了 SPA 是什么,并学习了如何基于 Blazor WebAssembly 框架构建 SPA。本章的第一部分描述了 Blazor WebAssembly 架构,然后解释了如何与 Blazor 组件交换输入/输出以及绑定的概念。

在解释了 Blazor 的一般原则之后,本章重点介绍了如何在提供用户输入的同时,在出现错误时为用户提供足够的反馈和视觉线索。然后,本章简要介绍了高级功能,如 JavaScript 互操作性,全球化,授权认证和客户端-服务器通信。

最后,从书中用户案例中提取的实际示例展示了如何在实践中使用 Blazor 来实现一个简单的旅游套餐搜索应用程序。

问题

  1. WebAssembly 是什么?

  2. SPA 是什么?

  3. Blazor router组件的目的是什么?

  4. Blazor 页面是什么?

  5. @namespace指令的目的是什么?

  6. EditContext是什么?

  7. 初始化组件的正确位置是什么?

  8. 处理用户输入的正确位置是什么?

  9. IJSRuntime接口是什么?

  10. @ref的目的是什么?

进一步阅读

第十七章:C# 9 的最佳编码实践

当你在项目中担任软件架构师时,你有责任定义和/或维护一个编码标准,指导团队按照公司的期望进行编程。本章涵盖了一些编码的最佳实践,将帮助像你这样的开发人员编写安全、简单和可维护的软件。它还包括了在 C#中编码的技巧和窍门。

本章将涵盖以下主题:

  • 你的代码复杂性如何影响性能

  • 使用版本控制系统的重要性

  • 在 C#中编写安全代码

  • 编码的.NET 核心技巧和窍门

  • 书中用例-编写代码的 Dos 和 Don'ts

C# 9 与.NET 5 一起推出。然而,这里介绍的实践可以在许多版本的.NET 中使用,但它们涉及 C#编程的基础。

技术要求

本章需要使用 Visual Studio 2019 免费的社区版或更高版本,并安装所有数据库工具。你可以在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5找到本章的示例代码。

你的代码越复杂,你就是一个越糟糕的程序员

对于许多人来说,一个优秀的程序员是那种编写复杂代码的人。然而,软件开发成熟度的演变意味着有一种不同的思考方式。复杂性并不意味着工作做得好;它意味着代码质量差。一些令人难以置信的科学家和研究人员已经证实了这一理论,并强调专业代码需要专注于时间、高质量和预算内完成。

即使你手头上有一个复杂的情景,如果你减少模糊不清的地方并澄清你编写的过程,特别是使用良好的方法和变量名称,并遵守 SOLID 原则,你将把复杂性转化为简单的代码。

因此,如果你想编写优秀的代码,你需要专注于如何做到这一点,考虑到你不是唯一一个以后会阅读它的人。这是一个改变你编写代码方式的好建议。这就是我们将讨论本章的每个要点的方式。

如果你对编写优秀代码的重要性的理解与在编写代码时的简单和清晰的想法一致,你应该看一下 Visual Studio 工具代码度量

图 17.1:在 Visual Studio 中计算代码度量

代码度量工具将提供度量标准,让你了解你正在交付的软件的质量。该工具提供的度量标准可以在此链接找到:docs.microsoft.com/en-us/visualstudio/code-quality/code-metrics-values?view=vs-2019。以下小节重点描述了它们在一些实际场景中的用途。

可维护性指数

这个指数表示维护代码的难易程度-代码越容易,指数越高(限制为 100)。易于维护是保持软件健康的关键点之一。显然,任何软件都将需要未来的更改,因为变化是不可避免的。因此,如果你的可维护性水平低,考虑重构你的代码。编写专门负责单一职责的类和方法,避免重复代码,限制每个方法的代码行数是你可以提高可维护性指数的例子。

圈复杂度

《圈复杂度指标》的作者是 Thomas J. McCabe。他根据软件函数可用的代码路径数量(图节点)来定义函数的复杂性。路径越多,函数就越复杂。McCabe 认为每个函数的复杂度得分必须小于 10。这意味着,如果代码有更复杂的方法,您必须对其进行重构,将这些代码的部分转换为单独的方法。有一些真实的场景可以很容易地检测到这种行为:

  • 循环内的循环

  • 大量连续的if-else

  • 在同一个方法中处理每个caseswitch

例如,看一下处理信用卡交易的不同响应的此方法的第一个版本。正如您所看到的,圈复杂度大于 McCabe 所考虑的基数。这种情况发生的原因是主switch的每个case内部的if-else的数量:

/// <summary>
/// This code is being used just for explaining the concept of cyclomatic complexity. 
/// It makes no sense at all. Please Calculate Code Metrics for understanding 
/// </summary>
private static void CyclomaticComplexitySample()
{
  var billingMode = GetBillingMode();
  var messageResponse = ProcessCreditCardMethod();
  switch (messageResponse)
    {
      case "A":
        if (billingMode == "M1")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "B":
        if (billingMode == "M2")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "C":
        if (billingMode == "M3")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "D":
        if (billingMode == "M4")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "E":
        if (billingMode == "M5")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "F":
        if (billingMode == "M6")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "G":
        if (billingMode == "M7")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      case "H":
        if (billingMode == "M8")
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        else
          Console.WriteLine($"Billing Mode {billingMode} for " +
            $"Message Response {messageResponse}");
        break;
      default:
        Console.WriteLine("The result of processing is unknown");
        break;
    }
} 

如果您计算此代码的代码指标,您将发现在圈复杂度方面的结果很糟糕,正如以下屏幕截图所示:

图 17.2:高圈复杂度

代码本身没有意义,但这里的重点是向您展示可以通过编写更好的代码来进行多少改进:

  • switch-case中的选项可以使用Enum来编写

  • 每个case处理可以在一个特定的方法中完成

  • 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 存储库中找到,并演示了如何实现更低复杂度的代码。以下屏幕截图显示了这些结果,根据代码指标:

图 17.3:重构后的圈复杂度减少

正如您在前面的屏幕截图中所看到的,重构后复杂性大大减少。在第十三章《在 C# 9 中实现代码重用性》中,我们讨论了重构对于代码重用的重要性。我们在这里做这个的原因是一样的-我们想要消除重复。

关键点在于,通过应用这些技术,代码的理解增加了,复杂性减少了,证明了圈复杂度的重要性。

继承深度

这个指标代表了与正在分析的类连接的类的数量。您继承的类越多,指标就会越糟。这就像类耦合一样,表明了更改代码有多困难。例如,以下屏幕截图中有四个继承类:

图 17.4:继承深度示例

您可以在以下屏幕截图中看到,更深的类具有更糟糕的指标,因为有三个其他类可以更改其行为:

图 17.5:继承深度指标

继承是基本的面向对象分析原则之一。然而,它有时可能对您的代码不利,因为它可能导致依赖性。因此,如果有意义的话,考虑使用组合而不是继承。

类耦合

当您在一个类中连接太多类时,显然会产生耦合,这可能会导致代码维护不良。例如,参考以下屏幕截图。它显示了一个已经执行了大量聚合的设计。代码本身没有意义:

图 17.6:类耦合示例

一旦您计算了前述设计的代码指标,您将看到ProcessData()方法的类耦合实例数,该方法调用ExecuteTypeA()ExecuteTypeB()ExecuteTypeC(),等于三(3):

图 17.7:类耦合度指标

一些论文指出,类耦合实例的最大数量应为九(9)。聚合比继承更好的实践,使用接口将解决类耦合问题。例如,相同的代码在以下设计中将给出更好的结果:

图 17.8:减少类耦合

注意,在设计中使用接口将允许您增加执行类型的数量,而不增加解决方案的类耦合度:

图 17.9:应用聚合后的类耦合结果

作为软件架构师,您必须考虑设计您的解决方案具有更多的内聚性而不是耦合性。文献表明,良好的软件具有低耦合和高内聚。在软件开发中,高内聚表示一个场景,其中每个类必须具有其方法和数据,并且它们之间有良好的关系。另一方面,低耦合表示软件中的类不是紧密和直接连接的。这是一个基本原则,可以指导您获得更好的架构模型。

代码行数

这个指标在让您了解您正在处理的代码规模方面是有用的。代码行数和复杂性之间没有联系,因为行数并不表示复杂性。另一方面,代码行数显示了软件的规模和软件设计。例如,如果一个类中有太多的代码行数(超过 1000 行代码-1KLOC),这表明它是一个糟糕的设计。

使用版本控制系统

你可能会觉得这本书中的这个主题有点显而易见,但许多人和公司仍然不将拥有版本控制系统视为软件开发的基本工具!写这个主题的想法是强迫你去理解它。如果你不使用版本控制系统,没有任何架构模型或最佳实践可以拯救软件开发。

在过去的几年里,我们一直在享受在线版本控制系统的优势,比如 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/中描述了它的流程。我们不知道哪一个最适合您的需求,但我们希望您明白您需要有控制代码的策略。

第二十章理解 DevOps 原则中,我们将更详细地讨论这个问题。

在 C#中编写安全的代码

C#可以被认为是一种安全的编程语言。除非强制使用,否则不需要指针,并且在大多数情况下,内存释放由垃圾收集器管理。即便如此,您应该小心,以便从代码中获得更好和更安全的结果。让我们看一些确保 C#代码安全的常见做法。

try-catch

编码中的异常是如此频繁,以至于每当它们发生时,您都应该有一种管理它们的方式。try-catch语句是用于管理异常的,并且对于保持代码安全非常重要。有很多情况下,应用程序崩溃的原因是缺乏使用try-catch。以下代码显示了缺乏使用try-catch语句的示例。值得一提的是,这只是一个例子,用于理解没有正确处理的异常概念。考虑使用int.TryParse(textToConvert, out int result)来处理解析不成功的情况:

private static int CodeWithNoTryCatch(string textToConvert)
{
    return Convert.ToInt32(textToConvert);
} 

另一方面,不正确使用try-catch也可能对您的代码造成损害,特别是因为您将看不到该代码的正确行为,并且可能会误解提供的结果。

以下代码显示了一个空的try-catch语句:

private static int CodeWithEmptyTryCatch(string textToConvert)
{
    try
    {
        return Convert.ToInt32(textToConvert);
    }
    catch
    {
        return 0;
    }
} 

try-catch语句必须始终与日志记录解决方案连接,以便您可以从系统获得响应,指示正确的行为,并且不会导致应用程序崩溃。以下代码显示了具有日志管理的理想try-catch语句。值得一提的是,尽可能捕获特定异常,因为捕获一般异常会隐藏意外异常:

private static int CodeWithCorrectTryCatch(string textToConvert)
{
    try
    {
        return Convert.ToInt32(textToConvert);
    }
    catch (FormatException err)
    {
        Logger.GenerateLog(err);
        return 0;
    }
} 

作为软件架构师,您应该进行代码检查,以修复代码中发现的这种行为。系统的不稳定性通常与代码中缺乏try-catch语句有关。

try-finally 和 using

内存泄漏可以被认为是软件的最糟糕行为之一。它们会导致不稳定性,计算机资源的不良使用和不希望的应用程序崩溃。C#试图通过垃圾收集器解决这个问题,一旦它意识到对象可以被释放,就会自动释放内存中的对象。

与 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 CorrectFileStreamManagementFirstOption()
{
    FileStream file = new FileStream("C:\\file.txt",
        FileMode.CreateNew);
    try
    {
        byte[] data = GetFileData();
        file.Write(data, 0, data.Length);
    }
    finally
    {
        file.Dispose();
    }
}
private static void CorrectFileStreamManagementSecondOption()
{
    using (FileStream file = new FileStream("C:\\file.txt", 
        FileMode.CreateNew))
    {
        byte[] data = GetFileData();
        file.Write(data, 0, data.Length);
    }
}
private static void CorrectFileStreamManagementThirdOption()
{
    using FileStream file = new FileStream("C:\\file.txt", 
        FileMode.CreateNew);
    byte[] data = GetFileData();
    file.Write(data, 0, data.Length);
} 

前面的代码准确显示了如何处理垃圾收集器未管理的对象。您同时实现了try-finallyusing。作为软件架构师,您确实需要注意这种代码。缺乏try-finallyusing语句可能会在运行时对软件行为造成巨大损害。值得一提的是,使用代码分析工具(现在与.NET 5 一起分发)将自动提醒您这类问题。

IDisposable 接口

与在方法中创建的对象不使用try-finally/using语句进行管理会导致问题类似,未正确实现IDisposable接口的类中创建的对象可能会导致应用程序中的内存泄漏。因此,当您有一个处理和创建对象的类时,应该实现可释放模式以确保释放其创建的所有资源:

图 17.10:IDisposable 接口实现

indicating it in your code and right-clicking on the Quick Actions and Refactoring option, as you can see in the preceding screenshot. 

插入代码后,您需要按照 TODO 说明执行,以实现正确的模式。

.NET 5 编码技巧和窍门

.NET 5 实现了一些有助于我们编写更好代码的好功能。其中最有用的之一是依赖注入DI),这已经在第十一章设计模式和.NET 5 实现中讨论过。有一些很好的理由可以考虑这一点。首先,您不需要担心处理注入的对象,因为您不会是它们的创建者。

此外,DI 使您能够注入ILogger,这是一个用于调试异常的有用工具,需要在代码中通过try-catch语句进行管理。此外,在 C#中使用.NET 5 进行编程必须遵循任何编程语言的通用最佳实践。以下列表显示了其中一些:

  • 类、方法和变量应具有可理解的名称:名称应该解释读者需要了解的一切。除非这些声明是公共的,否则不应该需要解释性注释。

  • 方法不能具有高复杂性级别:应检查圈复杂度,以便方法不具有太多行的代码。

  • 成员必须具有正确的可见性:作为面向对象的编程语言,C#允许使用不同的可见性关键字进行封装。C# 9.0 正在提供Init-only setters,因此您可以创建init属性/索引访问器而不是set,在对象构造后将这些成员定义为只读。

  • 应避免重复的代码:在 C#等高级编程语言中没有理由存在重复的代码。

  • 在使用之前应检查对象:由于可能存在空对象,代码必须进行空类型检查。值得一提的是,自 C# 8 以来,我们有可空引用类型,以避免与可空对象相关的错误。

  • 应使用常量和枚举器:避免在代码中使用魔术数字和文本的一个好方法是将这些信息转换为常量和枚举器,这通常更容易理解。

  • 应避免使用不安全的代码:不安全的代码使您能够在 C#中处理指针。除非没有其他实现解决方案的方法,否则应避免使用不安全的代码。

  • try-catch 语句不能是空的try-catch语句在catch区域没有处理是没有理由的。此外,捕获的异常应尽可能具体,而不仅仅是一个“异常”,以避免吞噬意外的异常。

  • 处理您创建的对象,如果它们是可处置的:即使对于垃圾收集器将处理已处置对象的对象,也要考虑处理您自己负责创建的对象。

  • 至少应该对公共方法进行注释:考虑到公共方法是在您的库之外使用的方法,必须对其进行解释以进行正确的外部使用。

  • switch-case 语句必须有默认处理:由于switch-case语句可能在某些情况下接收到未知的入口变量,因此默认处理将确保在这种情况下代码不会中断。

您可以参考docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/nullable-reference-types获取有关可空引用类型的更多信息。

作为软件架构师,您可以考虑为开发人员提供代码模式的良好实践,以保持代码风格的一致性。您还可以将此代码模式用作编码检查的检查表,从而提高软件代码质量。

WWTravelClub - 编写代码的 DOs 和 DON'Ts

作为软件架构师,您必须定义符合您所工作公司需求的代码标准。

在本书的示例项目中(在第一章了解软件架构的重要性中了解更多关于 WWTravelClub 项目的信息),情况并无不同。我们决定为其制定标准的方式是描述我们在编写生成的示例时遵循的 DO 和 DON'T 的列表。值得一提的是,这个列表是开始制定标准的好方法,作为软件架构师,您应该与团队中的开发人员讨论这个列表,以便以实际和良好的方式发展它。

此外,这些语句旨在澄清团队成员之间的沟通,并改善您正在开发的软件的性能和可维护性:

  • 用英文编写代码

  • 遵循 C#编码规范,使用驼峰命名法

  • 用易懂的名称编写类、方法和变量

  • 注释公共类、方法和属性

  • 尽可能使用using语句

  • 尽可能使用async实现

  • 不要编写空的try-catch语句

  • 不要编写循环复杂度得分超过 10 的方法

  • 不要在for/while/do-while/foreach语句中使用breakcontinue

这些 DO 和 DON'T 非常简单,而且比这更好的是,将为您的团队编写的代码产生很好的结果。在第十九章使用工具编写更好的代码中,我们将讨论帮助您实施这些规则的工具。

总结

在本章中,我们讨论了编写安全代码的一些重要提示。本章介绍了一个用于分析代码指标的工具,以便您可以管理正在开发的软件的复杂性和可维护性。最后,我们提出了一些好的建议,以确保您的软件不会因内存泄漏和异常而崩溃。在现实生活中,软件架构师总是会被要求解决这类问题。

在下一章中,我们将学习一些单元测试技术,单元测试的原则,以及一个专注于 C#测试项目的软件过程模型。

问题

  1. 我们为什么需要关注可维护性?

  2. 循环复杂度是什么?

  3. 列出使用版本控制系统的优势。

  4. 垃圾收集器是什么?

  5. 实现IDisposable接口的重要性是什么?

  6. 在编码方面,.NET 5 给我们带来了哪些优势?

进一步阅读

这些是一些书籍和网站,您可以在本章的主题中找到更多信息:

第十八章:使用单元测试用例和 TDD 测试您的代码

在开发软件时,确保应用程序没有错误并且满足所有要求是至关重要的。这可以通过在开发过程中测试所有模块,或者在整个应用程序已经完全或部分实现时进行测试来完成。

手动执行所有测试并不可行,因为大多数测试必须在应用程序修改时每次执行,并且正如本书中所解释的那样,现代软件正在不断修改以适应快速变化的市场需求。本章讨论了交付可靠软件所需的所有类型的测试,以及如何组织和自动化它们。

更具体地说,本章涵盖以下主题:

  • 了解单元测试和集成测试及其用法

  • 了解测试驱动开发TDD)的基础知识

  • 在 Visual Studio 中定义 C#测试项目

  • 用例 - 在 DevOps Azure 中自动化单元测试

在本章中,我们将看到哪些类型的测试值得实施,以及什么是单元测试。我们将看到可用的不同类型的项目以及如何在其中编写单元测试。在本章结束时,本书的用例将帮助我们在 Azure DevOps 中执行我们的测试,自动执行我们应用程序的持续集成/持续交付CI/CD)周期中的测试。

技术要求

本章需要安装 Visual Studio 2019 免费社区版或更高版本,并安装所有数据库工具。还需要一个免费的 Azure 帐户。如果您还没有创建,请参阅第一章中的创建 Azure 帐户部分。

本章中的所有概念都以基于 WWTravelClub 书用例的实际示例进行了澄清。本章的代码可在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5上找到。

了解单元测试和集成测试

延迟应用程序测试直到大部分功能已完全实现必须避免以下原因:

  • 如果一个类或模块设计或实现不正确,它可能已经影响了其他模块的实现方式。因此,在这一点上,修复问题可能会有很高的成本。

  • 测试所有可能执行路径所需的输入组合随着一起测试的模块或类的数量呈指数增长。因此,例如,如果类方法A的执行可以有三条不同的路径,而另一个方法B的执行可以有四条路径,那么测试AB将需要 3 x 4 个不同的输入。一般来说,如果我们一起测试几个模块,要测试的路径总数是每个模块中要测试的路径数的乘积。相反,如果模块分开测试,所需的输入数量只是测试每个模块所需的路径的总和。

  • 如果由N个模块组成的聚合的测试失败,那么在N个模块中定位错误的来源通常是一项非常耗时的活动。

  • 当一起测试N个模块时,我们必须重新定义涉及N个模块的所有测试,即使N个模块中的一个发生了变化。

这些考虑表明,更方便的是分别测试每个模块方法。不幸的是,验证所有方法而不考虑它们的上下文的一系列测试是不完整的,因为一些错误可能是由模块之间的不正确交互引起的。

因此,测试分为两个阶段:

  • 单元测试:这些测试验证每个模块的所有执行路径是否正常。它们非常完整,通常覆盖所有可能的路径。这是因为与整个应用程序的可能执行路径相比,每个方法或模块的可能执行路径并不多。

  • 集成测试:这些测试在软件通过所有单元测试后执行。集成测试验证所有模块是否正确交互以获得预期结果。集成测试不需要完整,因为单元测试已经验证了每个模块的所有执行路径是否正常工作。它们需要验证所有交互模式,也就是各种模块可能合作的所有可能方式。

通常,每种交互模式都有多个与之关联的测试:一种是典型的模式激活,另一种是一些极端情况下的激活。例如,如果整个交互模式接收一个数组作为输入,我们将编写一个测试来测试数组的典型大小,一个测试用null数组,一个测试用空数组,以及一个测试用非常大的数组。这样,我们可以验证单个模块的设计是否与整个交互模式的需求相匹配。

有了前面的策略,如果我们修改一个单个模块而不改变其公共接口,我们需要修改该模块的单元测试。

如果改变涉及到一些模块的交互方式,那么我们还需要添加新的集成测试或修改现有的集成测试。然而,通常情况下,这并不是一个大问题,因为大多数测试都是单元测试,因此重写所有集成测试的大部分并不需要太大的努力。此外,如果应用程序是根据单一职责开闭原则里氏替换原则接口隔离原则依赖倒置原则SOLID)原则设计的,那么在单个代码修改后必须更改的集成测试数量应该很小,因为修改应该只影响直接与修改的方法或类交互的几个类。

自动化单元和集成测试

在这一点上,应该清楚地知道单元测试和集成测试都必须在软件的整个生命周期中得到重复使用。这就是为什么值得自动化它们。自动化单元和集成测试可以避免手动测试执行可能出现的错误,并节省时间。几千个自动化测试可以在每次对现代软件的 CI/CD 周期中所需的频繁更改中,在几分钟内验证软件的完整性,从而使得频繁更改成为可能。

随着发现新的错误,会添加新的测试来发现它们,以便它们不会在软件的未来版本中重新出现。这样,自动化测试总是变得更加可靠,并更好地保护软件免受由于新更改而引入的错误。因此,添加新错误的概率(不会立即被发现的错误)大大降低了。

下一节将为我们提供组织和设计自动化单元和集成测试的基础,以及如何在C#测试项目定义部分中编写测试的实际细节。

编写自动化(单元和集成)测试

测试不是从头开始编写的;所有软件开发平台都有工具,可以帮助我们编写测试并运行它们(或其中一些)。一旦选择的测试被执行,所有工具都会显示报告,并提供调试所有失败测试代码的可能性。

更具体地说,所有单元和集成测试框架都由三个重要部分组成:

  • 定义所有测试的设施:它们验证实际结果是否与预期结果相符。通常,测试被组织成测试类,每个测试调用要么测试单个应用程序类,要么测试单个类方法。每个测试分为三个阶段:
  1. 测试准备:准备测试所需的一般环境。这个阶段只是为测试准备全局环境,比如要注入到类构造函数中的对象或数据库表的模拟;它不准备我们要测试的每个方法的单独输入。通常,相同的准备过程用于多个测试,因此测试准备被分解成专门的模块。

  2. 测试执行:使用适当的输入调用要测试的方法,并将其执行的所有结果与预期结果进行比较,使用诸如Assert.Equal(x, y)Assert.NotNull(x)之类的结构。

  3. 拆卸:清理整个环境,以避免一个测试的执行影响其他测试。这一步是步骤 1的相反。

  • 模拟设施:集成测试使用涉及对象协作模式的所有(或几乎所有)类,而单元测试则禁止使用其他应用程序类。因此,如果被测试的类 A 使用另一个应用程序类 B 的方法,该方法在其构造函数或方法 M 中被注入,那么为了测试 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 包一起使用,可以编写具有可接受的编程工作的自动化全面测试。自动化的 ASP.NET Core 验收测试将在第二十二章 功能测试自动化中详细描述。

性能测试对应用程序施加虚拟负载,以查看其是否能够处理典型的生产负载,发现其负载限制,并定位瓶颈。该应用程序部署在一个与硬件资源相同的实际生产环境的分期环境中。

然后,虚拟请求被创建并应用于系统,并收集响应时间和其他指标。虚拟请求批次应该与实际生产批次具有相同的组成。如果可用,它们可以从实际生产请求日志中生成。

如果响应时间不令人满意,将收集其他指标以发现可能的瓶颈(低内存、慢存储或慢软件模块)。一旦找到,就可以在调试器中分析负责问题的软件组件,以测量典型请求中涉及的各种方法调用的执行时间。

性能测试中的失败可能导致重新定义应用程序所需的硬件,或者优化一些软件模块、类或方法。

Azure 和 Visual Studio 都提供了创建虚拟负载和报告执行指标的工具。但是,它们已被宣布过时,并将被停用,因此我们不会对其进行描述。作为替代方案,有开源和第三方工具可供使用。其中一些列在进一步阅读部分。

下一节描述了一种给测试赋予中心作用的软件开发方法论。

理解测试驱动开发(TDD)

测试驱动开发TDD)是一种软件开发方法论,它赋予单元测试中心作用。根据这种方法论,单元测试是对每个类的规范的正式化,因此必须在编写类的代码之前编写。实际上,覆盖所有代码路径的完整测试唯一地定义了代码行为,因此可以被视为代码的规范。它不是通过某种正式语言定义代码行为的正式规范,而是基于行为示例的规范。

测试软件的理想方式是编写整个软件行为的正式规范,并使用一些完全自动化的工具验证实际生成的软件是否符合这些规范。过去,一些研究工作花费在定义描述代码规范的正式语言上,但用类似语言表达开发人员心中的行为是一项非常困难且容易出错的任务。因此,这些尝试很快被放弃,转而采用基于示例的方法。当时,主要目的是自动生成代码。

如今,自动生成代码已经大幅被放弃,并在小型应用领域中得以存留,例如创建设备驱动程序。在这些领域,将行为形式化为正式语言的工作量值得花费时间,因为这样做可以节省测试难以重现的并行线程行为的时间。

单元测试最初被构想为一种完全独立的编码示例规范的方式,作为一种名为极限编程的特定敏捷开发方法的一部分。然而,如今,TDD 独立于极限编程使用,并作为其他敏捷方法的强制规定。

尽管毫无疑问,经过发现数百个错误后细化的单元测试可以作为可靠的代码规范,但开发人员很难设计可以立即用作代码可靠规范的单元测试。事实上,通常情况下,如果随机选择示例,你需要无限或至少大量的示例来明确定义代码的行为。

只有在理解了所有可能的执行路径之后,才能用可接受的数量的示例来定义行为。事实上,在这一点上,只需为每个执行路径选择一个典型示例即可。因此,在完全编写了方法之后为该方法编写单元测试很容易:只需为已存在的代码的每个执行路径选择一个典型实例。然而,以这种方式编写单元测试并不能防止执行路径设计中的错误。可以说,事先编写测试并不能防止某人忘记测试一个值或值的组合-没有人是完美的!然而,它确实迫使您在实施之前明确考虑它们,这就是为什么您不太可能意外地忽略测试用例。

我们可以得出结论,编写单元测试时,开发人员必须以某种方式预测所有执行路径,寻找极端情况,并可能添加比严格需要的更多的示例。然而,开发人员在编写应用程序代码时可能会犯错误,而在设计单元测试时也可能会犯错误,无法预测所有可能的执行路径。

我们已经确定了 TDD 的主要缺点:单元测试本身可能是错误的。也就是说,不仅应用程序代码,而且其相关的 TDD 单元测试可能与开发人员心中的行为不一致。因此,在开始阶段,单元测试不能被视为软件规范,而是软件行为可能错误和不完整的描述。因此,我们对心中的行为有两种描述:应用程序代码本身以及在应用程序代码之前编写的 TDD 单元测试。

TDD 起作用的原因在于在编写测试和编写代码时犯同样错误的概率非常低。因此,每当测试失败时,测试或应用程序代码中都存在错误,反之亦然,如果应用程序代码或测试中存在错误,测试将失败的概率非常高。也就是说,使用 TDD 可以确保大多数错误立即被发现!

使用 TDD 编写类方法或一段代码是由三个阶段组成的循环:

  • 红色阶段:在这个阶段,开发人员编写空方法,要么抛出NotImplementedException,要么有空的方法体,并为它们设计新的单元测试,这些测试必须失败,因为此时还没有实现它们描述的行为的代码。

  • 绿色阶段:在这个阶段,开发人员编写最少的代码或对现有代码进行最少的修改,以通过所有单元测试。

  • 重构阶段:一旦测试通过,代码将被重构以确保良好的代码质量和最佳实践和模式的应用。特别是在这个阶段,一些代码可以被提取到其他方法或其他类中。在这个阶段,我们可能还会发现需要其他单元测试,因为发现或创建了新的执行路径或新的极端情况。

一旦所有测试通过而没有编写新代码或修改现有代码,循环就会停止。

有时,设计初始单元测试非常困难,因为很难想象代码可能如何工作以及可能采取的执行路径。在这种情况下,您可以通过编写应用程序代码的初始草图来更好地理解要使用的特定算法。在这个初始阶段,我们只需要专注于主要的执行路径,完全忽略极端情况和输入验证。一旦我们清楚了应该工作的算法背后的主要思想,我们就可以进入标准的三阶段 TDD 循环。

在下一节中,我们将列出 Visual Studio 中提供的所有测试项目,并详细描述 xUnit。

定义 C#测试项目

Visual Studio 包含三种类型的单元测试框架的项目模板,即 MSTest、xUnit 和 NUnit。一旦启动新项目向导,为了可视化它们中的适用于.NET Core C#应用程序的版本,将项目类型设置为测试语言设置为C#平台设置为Linux,因为.NET Core 项目是唯一可以部署在 Linux 上的项目。

以下屏幕截图显示了应该出现的选择:

图 18.1:添加测试项目

所有前述项目都自动包含用于在 Visual Studio 测试用户界面(Visual Studio 测试运行器)中运行所有测试的 NuGet 包。但它们不包含任何用于模拟接口的设施,因此您需要添加MoqNuGet 包,其中包含一个流行的模拟框架。

所有测试项目必须包含对要测试的项目的引用。

在接下来的部分中,我们将描述 xUnit,因为它可能是这三个框架中最受欢迎的。然而,这三个框架都非常相似,主要区别在于用于装饰各种测试类和方法的属性的名称以及断言方法的名称。

使用 xUnit 测试框架

在 xUnit 中,测试是用[Fact][Theory]属性装饰的方法。测试会被测试运行器自动发现,并在用户界面中列出所有测试,因此用户可以运行所有测试或只运行其中的一部分。

在运行每个测试之前,会创建测试类的一个新实例,因此在类构造函数中包含的测试准备代码会在类的每个测试之前执行。如果您还需要拆卸代码,测试类必须实现IDisposable接口,以便将拆卸代码包含在IDisposable.Dispose方法中。

测试代码调用要测试的方法,然后使用Assert静态类的方法测试结果,例如Assert.NotNull(x)Assert.Equal(x, y)Assert.NotEmpty(IEnumerable x)。还有一些方法可以验证调用是否引发了特定类型的异常,例如:

 Assert.Throws<MyException>(() => {/* test code */ ...}). 

当断言失败时,会抛出异常。如果测试代码或断言抛出未拦截的异常,则测试失败。

以下是定义单个测试的方法示例:

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

当一个方法只定义一个测试时,使用 [Fact] 属性,而当同一个方法定义多个测试时,每个测试都在不同的数据元组上使用时,使用 [Theory] 属性。数据元组可以以多种方式指定,并作为方法参数注入到测试中。

可以修改上述代码以在多个输入上测试 MethodToTest,如下所示:

[Theory]
[InlineData(1, 5)]
[InlineData(3, 10)]
[InlineData(5, 20)]
public void Test1(int testInput, int testOutput)
{
    var myInstanceToTest = new ClassToTest();
    Assert.Equal(testOutput, 
        myInstanceToTest.MethodToTest(testInput));
} 

每个 InlineData 属性指定要注入到方法参数中的元组。由于属性参数只能包含简单的常量数据,xUnit 还允许您从实现 IEnumerable 的类中获取所有数据元组,如下例所示:

public class Test1Data: IEnumerable<object[]>
{
    public IEnumerator<object[]> GetEnumerator()
    {
        yield return new object[] { 1, 5};
        yield return new object[] { 3, 10 };
        yield return new object[] { 5, 20 };
    }
    IEnumerator IEnumerable.GetEnumerator()=>GetEnumerator();

}
...
...
[Theory]
[ClassData(typeof(Test1Data))]
public void Test1(int testInput, int testOutput)
{
    var myInstanceToTest = new ClassToTest();
    Assert.Equal(testOutput, 
        myInstanceToTest.MethodToTest(testInput));
} 

提供测试数据的类的类型由 ClassData 属性指定。

还可以使用 MemberData 属性从返回 IEnumerable 的类的静态方法中获取数据,如下例所示:

[Theory]
[MemberData(nameof(MyStaticClass.Data), 
    MemberType= typeof(MyStaticClass))]
public void Test1(int testInput, int testOutput)
{
    ... 

MemberData 属性将方法名作为第一个参数传递,并在 MemberType 命名参数中指定类类型。如果静态方法是同一个测试类的一部分,则可以省略 MemberType 参数。

下一节将展示如何处理一些高级的准备和清理场景。

高级测试准备和清理场景

有时,准备代码包含非常耗时的操作,例如打开与数据库的连接,这些操作不需要在每个测试之前重复执行,但可以在同一个类中的所有测试之前执行一次。在 xUnit 中,这种类型的测试准备代码不能包含在测试类构造函数中;因为在每个单独的测试之前都会创建测试类的不同实例,所以必须将其分解到一个称为 fixture 类的单独类中。

如果我们还需要相应的清理代码,fixture 类必须实现 IDisposable。在其他测试框架(如 NUnit)中,测试类实例只会创建一次,因此不需要将 fixture 代码分解到其他类中。然而,不会在每个测试之前创建新实例的测试框架(如 NUnit)可能会因为测试方法之间的不必要交互而出现 bug。

以下是一个打开和关闭数据库连接的 xUnit fixture 类示例:

public class DatabaseFixture : IDisposable
{
    public DatabaseFixture()
    {
        Db = new SqlConnection("MyConnectionString");
    }
    public void Dispose()
    {
        Db.Close()
    }
    public SqlConnection Db { get; private set; }
} 

由于 fixture 类的实例在执行与 fixture 相关的所有测试之前只创建一次,并且在测试后立即被销毁,因此当 fixture 类被创建时数据库连接也只会创建一次,并且在 fixture 对象被销毁后立即被销毁。

通过让测试类实现空的 IClassFixture<T> 接口,fixture 类与每个测试类相关联,如下所示:

public class MyTestsClass : IClassFixture<DatabaseFixture>
{
    private readonly DatabaseFixture fixture;
    public MyDatabaseTests(DatabaseFixture fixture)
    {
        this.fixture = fixture;
    }
    ...
    ...
} 

为了使 fixture 测试准备中计算的所有数据对测试可用,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 参数提供了一个实际的 fixture 类实例,因此它可以在所有类测试中使用。

接下来的部分将展示如何使用Moq框架模拟接口。

使用 Moq 模拟接口

模拟能力不包括在我们在本节中列出的任何测试框架中,因为它们不包括在 xUnit 中。因此,它们必须通过安装特定的 NuGet 包来提供。Moq框架可在Moq NuGet 包中获得,是.NET 中最流行的模拟框架。它非常容易使用,并将在本节中简要描述。

一旦我们安装了 NuGet 包,我们需要在测试文件中添加using Moq语句。模拟实现很容易定义,如下所示:

 var myMockDependency = new Mock<IMyInterface>(); 

可以使用Setup/Return方法对特定输入的特定方法的模拟依赖行为进行定义,如下所示:

myMockDependency.Setup(x=>x.MyMethod(5)).Returns(10); 

我们可以为同一个方法添加多个Setup/Return指令。这样,我们可以指定无限数量的输入/输出行为。

我们可以使用通配符来匹配特定类型,而不是特定的输入值,如下所示:

myMockDependency.Setup(x => x.MyMethod(It.IsAny<int>()))
                  .Returns(10); 

配置了模拟依赖之后,我们可以从其Object属性中提取模拟的实例,并将其用作实际实现,如下所示:

var myMockedInstance=myMockDependency.Object;
...
myMockedInstance.MyMethod(10); 

然而,模拟的方法通常由测试中的代码调用,所以我们只需要提取模拟的实例并在测试中使用它作为输入。

我们也可以模拟属性和异步方法,如下所示:

myMockDependency.Setup(x => x.MyProperty)
                  .Returns(42);
...
myMockDependency.Setup(x => x.MyMethodAsync(1))
                    .ReturnsAsync("aasas");
var res=await myMockDependency.Object
    .MyMethodAsync(1); 

对于异步方法,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部分下载它。

首先,让我们复制解决方案文件夹并将其命名为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()
{
} 

由于我们要测试的Edit方法是async的,方法必须是async,返回类型必须是Task。在这个测试中,我们不需要模拟对象,因为不会使用任何注入的对象。因此,作为测试的准备,我们只需要创建一个控制器实例,并且必须向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,并且它包含在控制器中注入的相同模型:

var viewResult = Assert.IsType<ViewResult>(result);
Assert.Equal(vm, viewResult.Model); 

现在,我们还需要一个测试来验证,如果没有错误,命令处理程序被调用,然后浏览器被重定向到Index控制器的操作方法。我们调用DeletePostSuccessTest方法:

[Fact]
public async Task DeletePostSuccessTest()
{
} 

这次准备代码必须包括命令处理程序模拟的准备工作,如下所示:

var controller = new ManagePackagesController();
var commandDependency =
    new Mock<ICommandHandler<UpdatePackageCommand>>();
commandDependency
    .Setup(m => m.HandleAsync(It.IsAny<UpdatePackageCommand>()))
    .Returns(Task.CompletedTask);
var vm = new PackageFullEditViewModel(); 

由于处理程序HandleAsync方法没有返回async值,我们不能使用ReturnsAsync,而是必须使用Returns方法返回一个完成的Task(Task.Complete)。要测试的方法被调用时,传入了ViewModel和模拟的处理程序:

var result = await controller.Edit(vm, 
    commandDependency.Object); 

在这种情况下,验证代码如下:

commandDependency.Verify(m => m.HandleAsync(
    It.IsAny<UpdatePackageCommand>()), 
    Times.Once);
var redirectResult=Assert.IsType<RedirectToActionResult>(result);
Assert.Equal(nameof(ManagePackagesController.Index), 
    redirectResult.ActionName);
Assert.Null(redirectResult.ControllerName); 

作为第一步,我们验证命令处理程序是否实际被调用了一次。更好的验证还应包括检查它是否被调用,并且传递给操作方法的命令包括ViewModel。我们将把它作为一个练习来进行。

然后我们验证操作方法返回RedirectToActionResult,并且具有正确的操作方法名称,没有指定控制器名称。

一旦所有测试准备就绪,如果测试窗口没有出现在 Visual Studio 的左侧栏中,我们可以简单地从 Visual Studio 的测试菜单中选择运行所有测试项目。一旦测试窗口出现,进一步的调用可以从这个窗口内启动。

如果测试失败,我们可以在其代码中添加断点,这样我们就可以通过在测试窗口中右键单击它,然后选择调试选定的测试来启动调试会话。

连接到 Azure DevOps 存储库

测试在应用程序的 CI/CD 周期中发挥着基础作用,特别是在持续集成中。它们必须至少在每次应用程序存储库的主分支被修改时执行,以验证更改不会引入错误。

以下步骤显示了如何将我们的解决方案连接到 Azure DevOps 存储库,并且我们将定义一个 Azure DevOps 流水线来构建项目并启动其测试。这样,每天在所有开发人员推送他们的更改之后,我们可以启动流水线来验证存储库代码是否编译并通过了所有测试:

  1. 作为第一步,我们需要一个免费的 DevOps 订阅。如果你还没有,请点击此页面上的开始免费按钮创建一个:azure.microsoft.com/en-us/services/devops/。在这里,让我们定义一个组织,但在创建项目之前停下来,因为我们将从 Visual Studio 内部创建项目。

  2. 确保你已经用 Azure 账户登录到 Visual Studio(与创建 DevOps 账户时使用的相同)。在这一点上,你可以通过右键单击解决方案并选择配置到 Azure 的持续交付...来为你的解决方案创建一个 DevOps 存储库。在出现的窗口中,一个错误消息会告诉你你的代码没有配置存储库:

图 18.2:没有存储库错误消息

  1. 点击立即添加到源代码控制链接。之后,DevOps 屏幕将出现在 Visual Studio 的Team Explorer选项卡中:

图 18.3:发布存储库到 DevOps 面板

第三章所示,使用 Azure DevOps 记录需求,Team Explorer 正在被 Git Changes 取代,但如果这个自动向导带你到 Team Explorer,就用它来创建你的存储库。然后你可以使用 Git Changes 窗口。

  1. 单击“发布 Git 存储库”按钮后,将提示您选择 DevOps 组织和存储库的名称。成功将代码发布到 DevOps 存储库后,DevOps 屏幕应该会发生以下变化:

图 18.4:发布后的 DevOps 按钮

DevOps 屏幕显示了您在线 DevOps 项目的链接。将来,当您打开解决方案时,如果链接没有出现,请单击 DevOps 屏幕的“连接”按钮或“管理连接”链接(以后出现的那个)来选择并连接您的项目。

  1. 单击此链接转到在线项目。一旦进入那里,如果单击左侧菜单上的“存储库”项目,您将看到刚刚发布的存储库。

  2. 现在,单击“管道”菜单项来创建一个用于构建和测试项目的 DevOps 管道。在出现的窗口中,单击按钮创建新的管道:

图 18.5:管道页面

  1. 您将被提示选择存储库的位置:

图 18.6:存储库选择

  1. 选择“Azure Repos Git”,然后选择您的存储库。然后会提示您关于项目性质的信息:

图 18.7:管道配置

  1. 选择“ASP.NET Core”。将为您自动创建一个用于构建和测试项目的管道。通过将新创建的.yaml文件提交到存储库来保存它:

图 18.8:管道属性

  1. 可以通过选择“排队”按钮来运行管道,但由于 DevOps 标准管道在存储库的主分支上有一个触发器,每次提交更改或修改管道时都会自动启动。可以通过单击“编辑”按钮来修改管道:

图 18.9:管道代码

  1. 一旦进入编辑模式,所有管道步骤都可以通过单击每个步骤上方出现的“设置”链接进行编辑。可以按以下方式添加新的管道步骤:

  2. 在新步骤必须添加的地方写“- 任务:”,然后在输入任务名称时接受出现的建议之一。

  3. 一旦编写了有效的任务名称,新步骤上方将出现“设置”链接。单击它。

  4. 在出现的窗口中插入所需的任务参数,然后保存。

  5. 为了使我们的测试工作,我们需要指定定位包含测试的所有程序集的条件。在我们的情况下,由于我们有一个包含测试的唯一的.dll文件,只需指定其名称即可。单击VSTest@2测试任务的“设置”链接,并用以下内容替换自动建议的“测试文件”字段的内容:

**\PackagesManagementTest.dll
!**\*TestAdapter.dll
!**\obj\** 
  1. 然后单击“添加”以修改实际的管道内容。一旦在“保存并运行”对话框中确认了更改,管道就会启动,如果没有错误,测试结果就会被计算出来。可以通过在管道“历史”选项卡中选择特定构建,并单击出现的页面上的“测试”选项卡来分析特定构建期间启动的测试结果。在我们的情况下,应该看到类似以下截图的内容:

图 18.10:测试结果

  1. 如果单击管道页面的“分析”选项卡,您将看到与所有构建相关的分析,包括有关测试结果的分析:

图 18.11:构建分析

  1. 单击“分析”页面的测试区域会得到有关所有管道测试结果的详细报告。

总结一下,我们创建了一个新的 Azure DevOps 存储库,将解决方案发布到新存储库,然后创建了一个构建管道,在每次构建后执行我们的测试。构建管道一旦保存就会执行,并且每当有人提交到主分支时都会执行。

摘要

在本章中,我们解释了为什么值得自动化软件测试,然后我们专注于单元测试的重要性。我们还列出了所有类型的测试及其主要特点,主要关注单元测试。我们分析了 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# 9 编码的最佳实践,编码可以被视为一种艺术,但编写易懂的代码更像是哲学。在那一章中,我们讨论了作为软件架构师需要遵守的实践。在本章中,我们将描述代码分析的技术和工具,以便您为项目编写出良好的代码。

本章将涵盖以下主题:

  • 识别写得好的代码

  • 理解可以在过程中使用的工具,以使事情变得更容易

  • 应用扩展工具来分析代码

  • 在分析后检查最终代码

  • 用例——在发布应用程序之前实施代码检查

在本章结束时,您将能够确定要将哪些工具纳入软件开发生命周期,以便简化代码分析。

技术要求

本章需要使用 Visual Studio 2019 免费的 Community Edition 或更高版本。您可以在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5/tree/master/ch19找到本章的示例代码。

识别写得好的代码

很难定义代码是否写得好。第十七章中描述的最佳实践肯定可以指导您作为软件架构师为团队定义标准。但即使有了标准,错误也会发生,而且您可能只会在代码投入生产后才发现它们。因为代码不符合您定义的所有标准而决定在生产中重构代码并不是一个容易的决定,特别是如果涉及的代码正常运行。有些人得出结论,写得好的代码就是在生产中正常运行的代码。然而,这肯定会对软件的生命周期造成损害,因为开发人员可能会受到那些非标准代码的启发。

因此,作为软件架构师,您需要找到方法来强制执行您定义的编码标准。幸运的是,如今我们有许多工具可以帮助我们完成这项任务。它们被视为静态代码分析的自动化。这种技术被视为改进开发的软件和帮助开发人员的重大机会。

您的开发人员将通过代码分析而进步的原因是,您开始在代码检查期间在他们之间传播知识。我们现在拥有的工具也有同样的目的。更好的是,通过 Roslyn,它们可以在您编写代码时执行此任务。Roslyn 是.NET 的编译器平台,它使您能够开发一些用于分析代码的工具。这些分析器可以检查样式、质量、设计和其他问题。

例如,看看下面的代码。它毫无意义,但您仍然可以看到其中存在一些错误:

using System;
static void Main(string[] args)
{
    try
    {
        int variableUnused = 10;
        int variable = 10;
        if (variable == 10)
        {
             Console.WriteLine("variable equals 10");
        }
        else
        {
            switch (variable)
            {
                case 0:
                    Console.WriteLine("variable equals 0");
                    break;
            }
        }
    }
    catch
    {
    }
} 

这段代码的目的是向您展示一些工具的威力,以改进您正在交付的代码。让我们在下一节中研究每一个工具,包括如何设置它们。

理解和应用可以评估 C#代码的工具

Visual Studio 中代码分析的演变是持续的。这意味着 Visual Studio 2019 肯定比 Visual Studio 2017 等版本具有更多用于此目的的工具。

您(作为软件架构师)需要处理的问题之一是团队的编码风格。这肯定会导致对代码的更好理解。例如,如果您转到Visual Studio 菜单工具->选项,然后在左侧菜单中转到文本编辑器->C#,您将找到设置如何处理不同代码样式模式的方法,而糟糕的编码风格甚至被指定为代码样式选项中的错误,如下所示:

图 19.1:代码样式选项

前面的截图表明避免未使用的参数被视为错误。

在这种改变之后,与本章开头呈现的相同代码的编译结果是不同的,您可以在以下截图中看到:

图 19.2:代码样式结果

您可以导出您的编码样式配置并将其附加到您的项目,以便它遵循您定义的规则。

Visual Studio 2019 提供的另一个好工具是分析和代码清理。使用此工具,您可以设置一些代码标准,以清理您的代码。例如,在下面的截图中,它被设置为删除不必要的代码:

图 19.3:配置代码清理

运行代码清理的方法是通过在解决方案资源管理器区域中右键单击选择它,然后在要运行它的项目上运行。之后,此过程将在您拥有的所有代码文件中运行:

图 19.4:运行代码清理

在解决了代码样式和代码清理工具指示的错误之后,我们正在处理的示例代码有一些最小的简化,如下所示:

using System;
try
{
    int variable = 10;
    if (variable == 10)
    {
        Console.WriteLine("variable equals 10");
    }
    else
    {
        switch (variable)
        {
            case 0:
                Console.WriteLine("variable equals 0");
                break;
        }
    }
}
catch
{
} 

值得一提的是,前面的代码有许多改进仍需要解决。Visual Studio 允许您通过安装扩展来为 IDE 添加附加工具。这些工具可以帮助您提高代码质量,因为其中一些是为执行代码分析而构建的。本节将列出一些免费选项,以便您可以决定最适合您需求的选项。当然还有其他选项,甚至是付费选项。这里的想法不是指示特定的工具,而是给您一个对它们能力的概念。

要安装这些扩展,您需要在 Visual Studio 2019 中找到扩展菜单。以下是管理扩展选项的截图:

图 19.5:Visual Studio 2019 中的扩展

有许多其他很酷的扩展可以提高您的代码和解决方案的生产力和质量。在此管理器中搜索它们。

选择要安装的扩展后,您需要重新启动 Visual Studio。大多数扩展在安装后很容易识别,因为它们修改了 IDE 的行为。其中,Microsoft Code Analysis 2019 和 SonarLint for Visual Studio 2019 可以被认为是不错的工具,并将在下一节中讨论。

应用扩展工具来分析代码

尽管在代码样式和代码清理工具之后交付的示例代码比我们在本章开头呈现的代码要好,但显然远远不及第十七章中讨论的最佳实践。在接下来的章节中,您将能够检查两个扩展的行为,这些扩展可以帮助您改进这段代码:Microsoft Code Analysis 2019 和 SonarLint for Visual Studio 2019。

使用 Microsoft Code Analysis 2019

这个扩展由 Microsoft DevLabs 提供,是对我们过去自动化的 FxCop 规则的升级。它也可以作为 NuGet 包添加到项目中,因此可以成为应用程序 CI 构建的一部分。基本上,它有超过 100 个规则,用于在您输入代码时检测代码中的问题。

例如,仅通过启用扩展并重新构建我们在本章中使用的小样本,代码分析就发现了一个新的问题需要解决,如下截图所示:

图 19.6:代码分析用法

值得一提的是,我们在第十七章中讨论了空的try-catch语句的使用作为反模式。因此,如果能以这种方式暴露这种问题,对代码的健康将是有益的。

应用 SonarLint for Visual Studio 2019

SonarLint 是 Sonar Source 社区的开源倡议,用于在编码时检测错误和质量问题。它支持 C#、VB.NET、C、C++和 JavaScript。这个扩展的好处是它提供了解决检测到的问题的解释,这就是为什么我们说开发人员在使用这些工具时学会了如何编写良好的代码。查看以下屏幕截图,其中包含对样本代码进行的分析:

图 19.7:SonarLint 使用

我们可以验证此扩展能够指出错误,并且如承诺的那样,对每个警告都有解释。这不仅有助于检测问题,还有助于培训开发人员掌握良好的编码实践。

在分析后检查最终代码

在分析了两个扩展之后,我们终于解决了所有出现的问题。我们可以检查最终代码,如下所示:

using System;
try
{
    int variable = 10;
    if (variable == 10)
    {
        Console.WriteLine("variable equals 10");
    }
    else
    {
        switch (variable)
        {
            case 0:
                Console.WriteLine("variable equals 0");
                break;
            default:
                Console.WriteLine("Unknown behavior");
                break;
        }
    }
}
catch (Exception err)
{
    Console.WriteLine(err);
} 

正如您所看到的,前面的代码不仅更容易理解,而且更安全,并且能够考虑编程的不同路径,因为switch-case的默认值已经编程。这种模式也在第十七章 C# 9 编码最佳实践中讨论过,因此可以轻松地通过使用本章中提到的一个(或全部)扩展来遵循最佳实践。

用例-在发布应用程序之前评估 C#代码

第三章 使用 Azure DevOps 记录需求中,我们在平台上创建了 WWTravelClub 存储库。正如我们在那里看到的,Azure DevOps 支持持续集成,这可能很有用。在本节中,我们将讨论 DevOps 概念和 Azure DevOps 平台之所以如此有用的更多原因。

目前,我们想介绍的唯一一件事是,在开发人员提交代码后,但尚未发布时分析代码的可能性。如今,在面向应用程序生命周期工具的 SaaS 世界中,这仅仅是由于我们拥有一些 SaaS 代码分析平台才可能实现的。此用例将使用 Sonar Cloud。

Sonar Cloud 对于开源代码是免费的,并且可以分析存储在 GitHub、Bitbucket 和 Azure DevOps 中的代码。用户需要在这些平台上注册。一旦您登录,假设您的代码存储在 Azure DevOps 中,您可以按照以下文章中描述的步骤创建您的 Azure DevOps 和 Sonar Cloud 之间的连接:sonarcloud.io/documentation/analysis/scan/sonarscanner-for-azure-devops/

在设置 Azure DevOps 中项目与 Sonar Cloud 之间的连接后,您将拥有一个类似以下的构建管道:

图 19.8:Azure 构建管道中的 Sonar Cloud 配置

值得一提的是,C#项目没有 GUID 号码,而 Sonar Cloud 需要这个。您可以使用此链接(www.guidgenerator.com/)轻松生成一个,并且需要将其放置在以下屏幕截图中:

图 19.9:SonarQube 项目 GUID

一旦构建完成,代码分析的结果将在 Sonar Cloud 中呈现,如下屏幕截图所示。如果您想浏览此项目,可以访问sonarcloud.io/dashboard?id=WWTravelClubNet50

图 19.10:Sonar Cloud 结果

此时,经过分析的代码尚未发布,因此在发布系统之前,这对于获得下一个质量步骤非常有用。您可以将此方法用作在提交期间自动化代码分析的参考。

总结

本章介绍了可以用来应用C# 9 编码最佳实践中描述的最佳编码实践的工具。我们看了 Roslyn 编译器,它使开发人员在编码的同时进行代码分析,并且我们看了一个用例,即在发布应用程序之前评估 C#代码,该应用程序在 Azure DevOps 构建过程中实现代码分析使用 Sonar Cloud。

一旦您将本章学到的一切应用到您的项目中,代码分析将为您提供改进您交付给客户的代码质量的机会。这是作为软件架构师角色中非常重要的一部分。

在下一章中,您将使用 Azure DevOps 部署您的应用程序。

问题

  1. 软件如何被描述为写得很好的代码?

  2. 什么是 Roslyn?

  3. 什么是代码分析?

  4. 代码分析的重要性是什么?

  5. Roslyn 如何帮助进行代码分析?

  6. 什么是 Visual Studio 扩展?

  7. 为代码分析提供的扩展工具有哪些?

进一步阅读

以下是一些网站,您将在其中找到有关本章涵盖的主题的更多信息:

第二十章:理解 DevOps 原则

DevOps 是一个每个人都在学习和实践的过程。但作为软件架构师,您需要理解并传播 DevOps,不仅作为一个过程,而且作为一种理念。本章将涵盖您开发和交付软件所需的主要概念、原则和工具。

在考虑 DevOps 理念时,本章将专注于所谓的服务设计思维,即将您设计的软件视为向组织/部分组织提供的服务。这种方法的主要收获是您的软件为目标组织提供的价值最为重要。此外,您不仅提供可工作的代码和修复错误的协议,还提供了您的软件构思的所有需求的解决方案。换句话说,您的工作包括满足这些需求所需的一切,例如监控用户满意度并在用户需求变化时调整软件。最后,更容易监控软件以发现问题和新需求,并迅速修改以适应不断变化的需求。

服务设计思维与我们在第四章 决定最佳基于云的解决方案中讨论的软件即服务SaaS)模型紧密相关。事实上,基于 Web 服务提供解决方案的最简单方式是提供 Web 服务的使用作为服务,而不是销售实现它们的软件。

本章将涵盖以下主题:

  • 描述 DevOps 是什么,并查看如何在 WWTravelClub 项目中应用它的示例

  • 理解 DevOps 原则和部署阶段以利用部署过程

  • 理解使用 Azure DevOps 进行持续交付

  • 定义持续反馈,并讨论 Azure DevOps 中相关工具

  • 理解 SaaS 并为服务场景准备解决方案

  • 用例 - 使用 Azure Pipelines 部署我们的软件包管理应用程序

与其他章节不同,WWTravelClub 项目将在主题中呈现,并且我们将在章节结束时提供额外的结论,让您有机会了解如何实施 DevOps 理念。所有展示 DevOps 原则的截图都来自本书的主要用例,因此您将能够轻松理解 DevOps 原则。在本章结束时,您将能够根据服务设计思维原则设计软件,并使用 Azure Pipelines 部署应用程序。

技术要求

本章需要安装 Visual Studio 2019 社区版或更高版本,并安装所有 Azure 工具。您可能还需要一个 Azure DevOps 帐户,如第三章 使用 Azure DevOps 记录需求中所述。还需要一个免费的 Azure 帐户。如果您尚未创建,第一章 了解软件架构的重要性中的创建 Azure 帐户子章节解释了如何创建。本章使用与第十八章 使用单元测试案例和 TDD 测试代码相同的代码,可在此处找到:github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5

描述 DevOps

DevOps 源自开发和运维这两个词的结合,因此这个过程简单地统一了这两个领域的行动。然而,当您开始更深入地研究它时,您会意识到仅仅连接这两个领域是不足以实现这一理念的真正目标的。

我们还可以说,DevOps 是回答当前人类关于软件交付的需求的过程。

微软的首席 DevOps 经理 Donovan Brown 对 DevOps 有一个精彩的定义:DevOps 是人员、流程和产品的结合,以实现向最终用户持续交付价值donovanbrown.com/post/what-is-devops

持续向我们的最终用户交付价值的方法,使用流程、人员和产品:这是对 DevOps 哲学的最佳描述。我们需要开发和交付以客户为导向的软件。一旦公司的所有部门都明白关键点是最终用户,作为软件架构师,您的任务就是提供能够促进交付过程的技术。

值得一提的是,本书的所有内容都与这种方法相关。这绝不是了解一堆工具和技术的问题。作为软件架构师,您必须明白,这总是一种更快地为最终用户带来解决方案的方式,与他们的真实需求联系在一起。因此,您需要学习 DevOps 原则,这将在本章讨论。

了解 DevOps 原则

将 DevOps 视为一种哲学,值得一提的是,有一些原则可以使这个过程在您的团队中运行良好。这些原则是持续集成、持续交付和持续反馈。

微软有一个专门的网页来定义 DevOps 概述、文化、实践、工具及其与云的关系。请查看azure.microsoft.com/en-us/overview/what-is-devops/

在许多书籍和技术文章中,DevOps 以无限符号表示。这个符号代表软件开发生命周期中持续方法的必要性。在整个周期中,您需要计划、构建、持续集成、部署、运营、获得反馈,然后重新开始。这个过程必须是协作的,因为每个人都有同样的关注点——为最终用户提供价值。除了这些原则,作为软件架构师,您还需要决定最适合这种方法的最佳软件开发流程。我们在第一章 理解软件架构的重要性中讨论了这些流程。

定义持续集成

当您开始构建企业解决方案时,协作是更快地完成任务和满足用户需求的关键。版本控制系统,正如我们在第十七章 C# 9 编码最佳实践中讨论的那样,对于这个过程至关重要,但工具本身并不能完成工作,特别是如果工具没有很好地配置。

作为软件架构师,持续集成CI)将帮助您对软件开发协作有一个具体的方法。当您实施它时,一旦开发人员提交他们的代码,主要代码就会自动构建和测试。

应用 CI 的好处在于可以激励开发人员尽快合并他们的更改,以最小化合并冲突。此外,他们可以共享单元测试,这将提高软件的质量。

在 Azure DevOps 中设置 CI 非常简单。在构建管道中,您可以通过编辑配置找到该选项,如下面的屏幕截图所示:

图 20.1:启用持续集成复选框

值得一提的是,如果您的解决方案设置了单元测试和功能测试,一旦提交代码,它将自动编译和测试。这将使您的主分支在团队的每次提交后都保持稳定和安全。

CI 的关键点是能够更快地识别问题。当您允许他人测试和分析代码时,您将有这个机会。DevOps 方法的唯一帮助是确保这一切尽快发生。

了解使用 Azure DevOps 进行持续交付

一旦你的应用程序的每次提交都构建完成,并且这段代码经过了单元测试和功能测试,你可能也想要持续部署它。这不仅仅是配置工具的问题。作为软件架构师,你需要确保团队和流程准备好进行这一步。但让我们首先检查如何启用使用案例的第一个部署场景。

使用 Azure 管道部署我们的包管理应用程序

在本节中,我们将为在第十八章末尾定义的 DevOps 项目配置自动部署到 Azure App Service 平台。Azure DevOps 也可以自动创建新的 Web 应用程序,但为了防止配置错误(可能会消耗所有免费信用),我们将手动创建它,并让 Azure DevOps 只是部署应用程序。所有必需的步骤都被组织成各种小节,如下所示。

创建 Azure Web 应用程序和 Azure 数据库

可以通过以下简单的步骤定义 Azure Web 应用程序:

  1. 转到 Azure 门户,并选择应用服务,然后点击添加按钮创建一个新的 Web 应用程序。填写所有数据如下:

图 20.2:创建 Azure Web 应用程序

  1. 显然,你可以使用你已经拥有的资源组和对你来说最方便的区域。对于运行时堆栈,请选择与你在 Visual Studio 解决方案中使用的相同的.NET Core 版本。

  2. 现在,如果你有足够的信用,让我们为应用程序创建一个 SQL Server 数据库,并将其命名为PackagesManagementDatabase。如果你没有足够的信用,不要担心——你仍然可以测试应用程序部署,但当它尝试访问数据库时,应用程序将返回错误。请参考第九章关系数据库小节,了解如何创建 SQL Server 数据库。

配置你的 Visual Studio 解决方案

一旦你定义了 Azure Web 应用程序,你需要按照以下简单的步骤配置应用程序在 Azure 中运行:

  1. 如果你定义了 Azure 数据库,你需要在你的 Visual Studio 解决方案中有两个不同的连接字符串,一个用于开发的本地数据库,另一个用于你的 Azure Web 应用程序。

  2. 现在,在你的 Visual Studio 解决方案中打开appsettings.Development.jsonappsettings.json,如下所示:

图 20.3:在 Visual Studio 中打开设置

  1. 然后,将appsettings.json的整个ConnectionStrings节点复制到appsettings.Development.json中,如下所示:
"ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)....."
}, 
  1. 现在你在开发设置中有本地连接字符串,所以你可以将appsettings.json中的DefaultConnection更改为 Azure 数据库之一。

  2. 转到 Azure 门户中的数据库,复制连接字符串,并用你在定义数据库服务器时获得的用户名和密码填写它。

  3. 最后,本地提交你的更改,然后与远程存储库同步。现在,你的更改已经在 DevOps 管道上处理,以获得一个新的构建。

配置 Azure 管道

最后,通过以下步骤配置 Azure 管道,自动在 Azure 上交付你的应用程序:

  1. 通过点击 Visual Studio Team Explorer窗口的连接选项卡中的管理连接链接,将 Visual Studio 与你的 DevOps 项目连接起来。然后,点击 DevOps 链接进入你的在线项目。

  2. 修改PackagesManagementWithTest构建管道,添加一个单元测试步骤之后的进一步步骤。实际上,我们需要一个准备所有文件以在 ZIP 文件中部署的步骤。

  3. 点击PackagesManagementWithTest管道的编辑按钮,然后转到文件末尾并写入以下内容:

- task: PublishBuildArtifacts@1 
  1. 当新任务上方出现设置链接时,点击它来配置新任务:

图 20.4:配置发布构建构件窗格

  1. 接受默认的发布路径,因为它已与将部署应用程序的任务的路径同步,插入构件名称,然后选择Azure 管道作为位置。保存后,管道将启动,并且新添加的任务应该成功。

  2. 部署和其他发布构件被添加到称为发布管道的不同管道中,以将它们与构建相关的构件解耦。使用发布管道,您无法编辑.yaml文件,但您将使用图形界面进行操作。

  3. 单击发布左侧菜单选项卡以创建新的发布管道。一旦单击添加新管道,您将被提示添加第一个阶段的第一个任务。事实上,整个发布管道由不同的阶段组成,每个阶段都包含一系列任务。虽然每个阶段只是一系列任务,但阶段图可以分支,我们可以在每个阶段后添加几个分支。这样,我们可以部署到每个需要不同任务的不同平台。在我们的简单示例中,我们将使用单个阶段。

  4. 选择部署 Azure 应用服务任务。一旦添加此任务,您将被提示填写缺少的信息。

  5. 单击错误链接并填写缺少的参数:

图 20.5:配置发布阶段

  1. 选择您的订阅,然后,如果出现授权按钮,请单击它以授权Azure 管道访问您的订阅。然后,选择 Windows 作为部署平台,最后,从应用服务名称下拉列表中选择您创建的应用服务。任务设置在编写时会自动保存,因此您只需为整个管道单击保存按钮。

  2. 现在,我们需要将此管道连接到源构件。单击添加构件按钮,然后选择构建作为源类型,因为我们需要将新的发布管道与我们的构建管道创建的 ZIP 文件连接起来。将出现设置窗口:

图 20.6:定义要发布的构件

  1. 从下拉列表中选择我们之前的构建管道,并将最新保留为版本。接受源别名中的建议名称。

  2. 我们的发布管道已经准备就绪,可以直接使用。您刚刚添加的源构件的图像在其右上角包含一个触发图标,如下所示:

图 20.7:准备发布的构件

  1. 如果单击触发图标,您将有选项在新构建可用时自动触发发布管道:

图 20.8:启用持续部署触发器

  1. 保持其禁用;我们可以在完成并手动测试发布管道后启用它。

正如我们之前提到的,为了准备自动触发,我们需要在应用程序部署之前添加一个人工批准任务。

为发布添加手动批准

由于任务通常由软件代理执行,我们需要在手动作业中嵌入人工批准。让我们按照以下步骤添加它:

  1. 单击阶段 1标题右侧的三个点:

图 20.9:向阶段添加人工批准

  1. 然后,选择添加无代理作业。添加无代理作业后,单击添加按钮并添加手动干预任务。以下屏幕截图显示了手动干预设置:

图 20.10:配置阶段的人工批准

  1. 为操作员添加说明,并在通知用户字段中选择您的帐户。

  2. 现在,使用鼠标拖动整个无代理作业并将其放置在应用程序部署任务之前。它应该看起来像这样:

图 20.11:设置人工批准部署任务列表

  1. 完成!点击左上角的保存按钮保存管道。

现在,一切都准备好了,可以创建我们的第一个自动发布。

创建发布

一旦你准备好了一切,新的发布可以按照以下步骤准备和部署:

  1. 点击创建发布按钮开始创建新的发布,如下截图所示:

图 20.12:创建新发布

  1. 验证源别名是否是最后一个可用的,添加发布描述,然后点击创建。不久后,你应该会收到一封发布批准的电子邮件。点击其中包含的链接,进入批准页面:

图 20.13:批准发布

  1. 点击批准按钮批准发布。等待部署完成。你应该看到所有任务都成功完成,如下截图所示:

图 20.14:发布部署

  1. 你已经运行了你的第一个成功的发布管道!

在现实项目中,发布管道将包含一些额外的任务。事实上,应用程序(在实际生产环境中部署之前)会在一个分级环境中进行部署,在那里进行测试。因此,可能在这次首次部署之后,会有一些手动测试,手动授权进行生产部署,以及最终的生产部署。

多阶段环境

持续交付CD)相关的方法需要保证每次新部署都能保持生产环境的安全。为此,需要采用多阶段管道。下面的截图显示了一种常见阶段的方法,使用书籍用例作为演示:

图 20.15:使用 Azure DevOps 发布阶段

正如你所看到的,这些阶段是使用 Azure DevOps 发布管道进行配置的。每个阶段都有其自己的目的,这将提高最终交付产品的质量。让我们来看看这些阶段:

  • 开发/测试:这个阶段是开发人员和测试人员用来构建新功能的。这个环境肯定是最容易暴露出错误和不完整功能的环境。

  • 质量保证:这个环境为团队中与开发和测试无关的领域提供了新功能的简要版本。项目经理、市场营销、供应商等可以将其用作研究、验证甚至预生产的区域。此外,开发和质量团队可以保证新版本的正确部署,考虑到功能和基础设施。

  • 生产:这是客户运行其解决方案的阶段。根据 CD 的要求,一个良好的生产环境的目标是尽快更新。更新的频率会根据团队规模而有所不同,但有一些方法是这个过程每天发生多次。

采用这三个部署应用程序的阶段将影响解决方案的质量。它还将使团队能够拥有更安全的部署过程,减少风险,提高产品的稳定性。这种方法乍看起来可能有点昂贵,但如果没有它,糟糕的部署结果通常会比这个投资更昂贵。

除了所有的安全性,你还必须考虑多阶段的情况。你可以设置管道,只有在定义的授权下,你才能从一个阶段移动到另一个阶段:

图 20.16:定义预部署条件

正如你在前面的截图中所看到的,设置预部署条件非常简单,而且你可以在下面的截图中看到,有多个选项来自定义授权方法。这使你有可能完善 CD 方法,确切地满足你所处理的项目的需求。

以下屏幕截图显示了 Azure DevOps 提供的预部署批准选项。您可以定义可以批准阶段并为其设置策略的人员,即在完成流程之前重新验证批准者身份。作为软件架构师,您需要确定适合使用此方法创建的项目的配置:

图 20.17:预部署批准选项

值得一提的是,尽管这种方法远比单阶段部署好得多,但 DevOps 管道将引导您作为软件架构师进入另一个监控阶段。持续反馈将是一个令人难以置信的工具,我们将在下一节讨论这种方法。

定义持续反馈和相关的 DevOps 工具

一旦您在上一节描述的部署方案中完美运行的解决方案,反馈将对您的团队至关重要,以了解发布的结果以及版本对客户的运行情况。为了获得这种反馈,一些工具可以帮助开发人员和客户,将这些人聚集在一起,加快反馈过程。让我们来看看这些工具。

使用 Azure Monitor Application Insights 监控软件

Azure Monitor Application Insights是软件架构师需要持续反馈的工具。值得一提的是,应用程序洞察是 Azure Monitor 的一部分,它还包括警报、仪表板和工作簿等更广泛的监控功能。一旦您将应用程序连接到它,您就会开始收到有关对软件的每个请求的反馈。这使您不仅能够监视所做的请求,还能够监视数据库性能、应用程序可能遭受的错误以及处理时间最长的调用。

显然,将此工具插入到您的环境中将会产生成本,但工具提供的便利将是值得的。值得注意的是,对于简单的应用程序,甚至可能是免费的,因为您支付的是数据摄入费用,而有免费配额。此外,您需要了解,由于存储数据在应用程序洞察中的所有请求都在单独的线程中运行,因此性能成本非常小。

值得注意的是,一些服务,如应用服务、函数等,在初始创建过程中将有添加应用程序洞察的选项,因此您可能已经在阅读本书时创建了它。即便如此,下面的屏幕截图显示了您如何在您的环境中轻松创建一个工具。

图 20.18:在 Azure 中创建应用程序洞察资源

如果您想使用 Visual Studio 在应用程序中设置应用程序洞察,您可能会发现这篇微软教程有用:docs.microsoft.com/en-us/azure/azure-monitor/learn/dotnetcore-quick-start#configure-app-insights-sdk

例如,假设您需要分析应用程序中花费更多时间的请求。将应用程序洞察附加到您的 Web 应用程序的过程非常简单:只需在设置 Web 应用程序时立即完成。如果您不确定应用程序洞察是否已为您的 Web 应用程序配置,可以使用 Azure 门户进行查找。导航到应用服务并查看应用程序洞察设置,如下面的屏幕截图所示:

图 20.19:在应用服务中启用应用程序洞察

界面将让您有机会为您的 Web 应用程序创建或附加一个已创建的监视服务。值得一提的是,您可以将多个 Web 应用程序连接到同一个 Application Insights 组件。以下截图显示了如何将 Web 应用程序添加到已创建的 Application Insights 资源中:

图 20.20:在应用服务中启用应用洞察

一旦您为您的 Web 应用程序配置了 Application Insights,您将在应用服务中找到以下屏幕:

图 20.21:应用服务中的应用洞察

一旦它连接到您的解决方案,数据收集将持续进行,您将在组件提供的仪表板中看到结果。您可以在两个地方找到这个屏幕:

  • 与您配置 Application Insights 的地方相同,在 Web 应用程序门户内

  • 在 Azure 门户中,浏览 Application Insights 资源后:

图 20.22:应用洞察的实际应用

这个仪表板让您了解失败的请求、服务器响应时间和服务器请求。您还可以打开可用性检查,它将从 Azure 数据中心中的任何一个向您选择的 URL 发出请求。

但 Application Insights 的美妙之处在于它对系统进行了深入的分析。例如,在下面的截图中,它正在向您反馈网站上的请求次数。您可以通过排名来分析哪些请求处理时间更长,或者哪些请求更频繁:

图 20.23:使用 Application Insights 分析应用程序性能

考虑到这个视图可以以不同的方式进行过滤,并且您在 Web 应用程序中发生事件后立即收到信息,这无疑是一个定义持续反馈的工具。这是您可以使用 DevOps 原则实现客户需求的最佳方式之一。

Application Insights 是一个技术工具,正是您作为软件架构师需要的,用于监视现代应用程序的真实分析模型。它是基于用户在您正在开发的系统上的行为的持续反馈方法。

使用测试和反馈工具启用反馈

在持续反馈过程中另一个有用的工具是由微软设计的测试和反馈工具,旨在帮助产品所有者和质量保证用户分析新功能。

使用 Azure DevOps,您可以通过在每个工作项内选择一个选项来为您的团队请求反馈,如下截图所示:

图 20.24:使用 Azure DevOps 请求反馈

一旦您收到反馈请求,您可以使用测试和反馈工具来分析并向团队提供正确的反馈。您将能够将该工具连接到您的 Azure DevOps 项目,从而在分析反馈请求时获得更多功能。值得一提的是,这个工具是一个需要安装的网页浏览器扩展。以下截图显示了如何为测试和反馈工具设置 Azure DevOps 项目 URL:

您可以从marketplace.visualstudio.com/items?itemName=ms.vss-exploratorytesting-web下载此工具。

图 20.25:将测试和反馈连接到 Azure DevOps 组织

这个工具非常简单。您可以截图、记录一个过程,甚至做一个笔记。以下截图显示了您如何在截图中轻松写下一条消息:

图 20.26:使用测试和反馈工具提供反馈

好处是您可以在会话时间轴中记录所有这些分析。正如您在下一个截图中所看到的,您可以在同一个会话中获得更多反馈,这对分析过程很有帮助:

图 20.27:使用测试和反馈工具提供反馈

一旦您完成了分析并连接到 Azure DevOps,您将能够报告错误,创建任务,甚至开始一个新的测试用例:

图 20.28:在 Azure DevOps 中打开错误

创建的错误结果可以在 Azure DevOps 的工作项面板上进行检查。值得一提的是,您不需要 Azure DevOps 开发人员许可证即可访问环境中的这一区域。这使您作为软件架构师能够将这个基本而有用的工具传播给您所拥有的解决方案的许多关键用户。以下截图显示了一旦您将其连接到 Azure DevOps 项目后工具创建的错误:

图 20.29:Azure DevOps 中新报告的错误

拥有这样的工具对于获得项目的良好反馈是很重要的。但是,作为软件架构师,您可能需要找到加速这个过程的最佳解决方案。本书中探讨的工具是加速这一过程的好方法。每当您需要在开发过程中实施一个新步骤时,您都可以考虑这种方法。持续反馈是开发软件过程中的一个重要步骤,它将不断获得新功能。另一个可以利用 DevOps 的非常重要的方法是 SaaS。让我们在下一节中了解更多。

了解 SaaS

作为服务销售/使用软件涉及到一组更广泛的解决方案设计原则,称为服务设计思维。服务设计思维不仅仅是一种软件开发技术和/或软件部署方法,而且它影响到几个业务领域,即组织和人力资源、软件开发流程,最后是硬件基础设施和软件架构。

在接下来的小节中,我们将简要讨论我们列出的每个业务领域的影响,而在最后一个小节中,我们将专门关注 SaaS 部署模型。

使您的组织适应服务场景

第一个组织影响来自于需要优化软件对目标组织的价值。这需要一个人力资源或团队负责规划和监控软件对目标组织的影响,以最大化软件增加的价值。这种战略角色不仅在初始设计阶段需要,而且在应用的整个生命周期中都需要。事实上,这个角色负责保持软件与目标组织不断变化的需求保持一致。

另一个重要的影响领域是人力资源管理。事实上,由于主要优先考虑的是软件增加的价值(而不是利用现有资源和能力),人力资源必须根据项目需求进行调整。这意味着在需要时立即获取新人员,并通过适当的培训开发所需的能力。

下一小节涉及软件开发中涉及的所有流程的影响。

在服务场景中开发软件

影响软件开发流程的主要约束是需要将软件调整到组织的需求。这种需求可以通过基于 CI/CD 方法的任何敏捷方法来满足。有关 CI/CD 的简要介绍,请参阅《第三章》《使用 Azure DevOps 组织您的工作》部分,《使用 Azure DevOps 记录需求》。值得指出的是,任何设计良好的 CI/CD 周期都应包括处理用户反馈和用户满意度报告。

此外,为了优化软件增加的价值,最佳实践是组织阶段,其中开发团队(或其中的一部分)与系统用户密切联系,以便开发人员更好地理解软件对目标组织的影响。

在编写功能和非功能需求时,始终要记住软件的附加值。因此,有必要使用“用户故事”注释“为什么”和“如何”它们对价值的贡献。需求收集过程在“第二章”,“非功能需求”中讨论。

更多技术含义将在下一小节中讨论。

服务场景的技术含义

在服务场景中,硬件基础设施和软件架构都受到三个主要原则的约束,这是将软件调整到组织需求的要求的直接结果,即以下内容:

  • 需要监视软件以发现可能由系统故障或软件使用/用户需求变化引起的任何问题。这意味着从所有硬件/软件组件中提取健康检查和负载统计。用户执行的操作统计数据也可以提供有关组织需求变化的良好线索,具体来说,是用户和应用程序在每个操作实例上花费的平均时间,以及每个操作实例在单位时间(天、周或月)内执行的次数。

  • 还需要监控用户满意度。可以通过在每个应用程序屏幕上添加链接到易于填写的用户满意度报告页面来获得有关用户满意度的反馈。

  • 最后,有必要快速调整硬件和软件,以适应每个应用模块接收的流量以及组织需求的变化。这意味着以下内容:

  • 极度关注软件的模块化

  • 保持数据库引擎变更的可能性,并更喜欢面向服务的架构(SOA)或基于微服务的解决方案,而不是单片软件

  • 为新技术敞开大门

使硬件易于调整意味着允许硬件扩展,这又意味着要么采用云基础设施,要么采用硬件集群,或者两者兼而有之。同样重要的是要保持对云服务供应商变化的可能性,这意味着将对云平台的依赖封装在少量软件模块中。

通过选择最佳技术来实现每个模块,可以实现软件附加值的最大化,这意味着能够混合不同的技术。这就是容器化技术(如 Docker)发挥作用的地方。 Docker 和相关技术在以下进行了描述:

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

  • 第六章,Azure 服务布局

  • 第七章,Azure Kubernetes 服务

总之,我们列出的所有要求都趋向于本书中描述的大多数先进技术,如云服务、可扩展的 Web 应用程序、分布式/可扩展数据库、Docker、Kubernetes、SOA 和微服务架构。

如何为服务环境准备软件的更多细节将在下一节中给出,而下一小节专门关注 SaaS 应用程序的优缺点。

决定何时采用 SaaS 解决方案

SaaS 解决方案的主要吸引力在于其灵活的付款模式,它提供以下优势:

  • 您可以避免放弃大笔投资,转而选择更实惠的月度付款

  • 您可以从一个便宜的系统开始,然后只有在业务增长时才转向更昂贵的解决方案

然而,SaaS 解决方案还提供其他优势,即以下内容:

  • 在所有云解决方案中,您可以轻松扩展您的解决方案

  • 应用程序会自动更新

  • 由于 SaaS 解决方案是通过公共互联网提供的,因此可以从任何位置访问它们

不幸的是,SaaS 的优势是有代价的,因为 SaaS 也有一些不可忽视的缺点,即以下内容:

  • 您的业务严重依赖于 SaaS 提供商,他们可能会停止提供服务和/或以您不接受的方式进行修改。

  • 通常,您无法实现任何定制,只能使用 SaaS 供应商提供的少数标准选项。然而,有时 SaaS 供应商也提供添加自定义模块的可能性,这些模块可以由他们或您编写。

总之,SaaS 解决方案提供了有趣的优势,但也存在一些缺点,因此作为软件架构师,您必须进行详细分析以决定如何采用它们。

接下来的部分将解释如何调整软件以在服务场景中使用。

为服务场景准备解决方案

首先,为服务场景准备解决方案意味着专门为云和/或分布式环境设计。这意味着要考虑可伸缩性、容错性和自动故障恢复。

前面三点的主要影响与状态的处理方式有关。无状态的模块实例易于扩展和替换,因此您应该仔细规划哪些模块是无状态的,哪些模块有状态。此外,正如第九章中所解释的,您必须记住写入和读取操作的扩展方式完全不同。读取操作更容易通过复制进行扩展,而写入操作在关系数据库中扩展不佳,通常需要 NoSQL 解决方案。

在分布式环境中,高可伸缩性阻止了使用分布式事务和一般同步操作。因此,只能通过基于异步消息的更复杂技术来实现数据一致性和容错性,例如以下内容:

  • 一种技术是将要发送的所有消息存储在队列中,以便在出现错误或超时时可以重试异步传输。消息可以在收到接收确认时或模块决定中止产生消息的操作时从队列中移除。

  • 另一个问题是处理同一消息由于超时导致多次接收的可能性。

  • 如果需要,可以使用乐观并发和事件溯源等技术来最小化数据库中的并发问题。乐观并发在第十五章的用例的定义数据层子部分中有解释,而事件溯源则与其他数据层内容一起在第十二章使用 SOLID 原则来映射您的领域部分中描述。

前面列表中的前两点与其他分布式处理技术一起在第五章如何处理.NET Core 微服务部分中详细讨论。

容错性和自动故障恢复要求软件模块实现健康检查接口,云框架可能会调用这些接口,以验证模块是否正常工作,或者是否需要被终止并由另一个实例替换。ASP.NET Core 和所有 Azure 微服务解决方案都提供基本的即插即用健康检查,因此开发人员不需要关心它们。然而,可以通过实现一个简单的接口来添加更详细的自定义健康检查。

如果您的目标是可能更改某些应用程序模块的云提供商,则难度会增加。在这种情况下,对云平台的依赖必须封装在只有少数模块中,并且过于严格依赖特定云平台的解决方案必须被丢弃。

如果您的应用程序是为服务场景设计的,则一切都必须自动化:新版本的测试和验证,应用程序所需的整个云基础设施的创建以及应用程序在该基础设施上的部署。

所有云平台都提供语言和设施来自动化整个软件 CI/CD 周期,即构建代码,测试代码,触发手动版本批准,硬件基础设施创建和应用程序部署。

Azure Pipelines 允许完全自动化列出的所有步骤。第十八章中的用例使用单元测试用例和 TDD 测试代码展示了如何使用 Azure Pipelines 自动化所有步骤,包括软件测试。下一节中的用例将展示如何在 Azure Web Apps 平台上自动化应用程序部署。

在 SaaS 应用程序中,自动化扮演着更为基础的角色,因为必须通过客户订阅自动触发为每个新客户创建新租户的整个过程。更具体地说,多租户 SaaS 应用程序可以通过三种基本技术实现:

  • 所有客户共享相同的硬件基础设施和数据存储。这种解决方案最容易实现,因为它需要实现标准的 Web 应用程序。然而,这只适用于非常简单的 SaaS 服务,因为对于更复杂的应用程序来说,确保存储空间和计算时间在用户之间均匀分配变得越来越困难。此外,随着数据库变得越来越复杂,保持不同用户的数据安全隔离也变得越来越困难。

  • 所有客户共享相同的基础设施,但每个客户都有自己的数据存储。此选项解决了上一个解决方案的所有数据库问题,并且很容易自动化,因为创建新租户只需要创建新数据库。此解决方案提供了一种简单的方式来定义定价策略,将其与存储消耗联系起来。

  • 每个客户都有自己的私人基础设施和数据存储。这是最灵活的策略。从用户的角度来看,它唯一的缺点是更高的价格。因此,只有在每个用户所需的计算能力达到最低阈值以上时才方便。它更难自动化,因为必须为每个新客户创建整个基础设施,并在其上部署应用程序的新实例。

无论选择哪种策略,您都需要能够随着消费者增加而扩展您的云资源。

如果您还需要确保您的基础设施创建脚本可以跨多个云提供商使用,那么一方面,您不能使用太特定于单个云平台的功能,另一方面,您需要一种独特的基础设施创建语言,可以转换为更常见的云平台的本地语言。 Terraform 和 Ansible 是描述硬件基础设施的两种非常常见的选择。

WWTravelClub 项目方法

在本章中,WWTravelClub 项目的屏幕截图显示了实施良好的 DevOps 周期所需的步骤。WWTravelClub 团队决定使用 Azure DevOps,因为他们了解到该工具对于获得整个周期的最佳 DevOps 体验至关重要。

需求是使用用户故事编写的,可以在 Azure DevOps 的工作项部分找到。代码放在 Azure DevOps 项目的存储库中。这两个概念在第三章使用 Azure DevOps 记录需求中有解释。

用于完成任务的管理生命周期是 Scrum,在第一章《理解软件架构的重要性》中介绍。这种方法将实施分为 Sprints,这迫使需要在每个周期结束时交付价值。使用本章学到的持续集成设施,每当团队完成对存储库主分支的开发时,代码都将被编译。

一旦代码被编译和测试,部署的第一阶段就完成了。第一阶段通常被称为开发/测试,因为您可以为内部测试启用它。Application Insights 和测试与反馈可以用于获取新版本的第一反馈。

如果新版本的测试和反馈通过,那么就是时候进入第二阶段,质量保证。Application Insights 和测试与反馈现在可以再次使用,但现在是在一个更稳定的环境中。

循环以在生产阶段部署的授权结束。这无疑是一个艰难的决定,但 DevOps 表明您必须持续这样做,以便从客户那里获得更好的反馈。Application Insights 仍然是一个有用的工具,因为您可以监视新版本在生产中的演变,甚至将其与过去的版本进行比较。

这里描述的 WWTravelClub 项目方法可以用于许多其他现代应用程序开发生命周期。作为软件架构师,您必须监督这个过程。工具已经准备就绪,取决于您是否做对了!

总结

在本章中,我们了解到 DevOps 不仅是一堆技术和工具,用于连续交付软件,而且是一种哲学,可以实现对您正在开发的项目的最终用户持续交付价值。

考虑到这种方法,我们看到持续集成、持续交付和持续反馈对 DevOps 的目的至关重要。我们还看到 Azure、Azure DevOps 和 Microsoft 工具如何帮助您实现目标。

我们描述了服务设计思维原则和 SaaS 软件部署模型。现在,您应该能够分析这些方法对组织的所有影响,并且您应该能够调整现有的软件开发流程和硬件/软件架构,以利用它们提供的机会。

我们还解释了软件周期、云硬件基础架构配置和应用程序部署的自动化的需求和涉及的技术。

一旦您实施了所示的示例,您应该能够使用 Azure Pipelines 自动化基础架构配置和应用程序部署。本章以 WWTravelClub 为例阐明了这种方法,实现了 Azure DevOps 内的 CI/CD,并使用 Application Insights 和测试与反馈工具进行技术和功能反馈。在现实生活中,这些工具将使您能够更快地了解您正在开发的系统的当前行为,因为您将对其进行持续反馈。

在下一章中,我们将详细了解持续集成,这在服务场景和 SaaS 应用程序的维护中起着基础性的作用。

问题

  1. 什么是 DevOps?

  2. 什么是持续集成?

  3. 什么是持续交付?

  4. 什么是持续反馈?

  5. 构建和发布管道之间有什么区别?

  6. 在 DevOps 方法中,Application Insights 的主要目的是什么?

  7. 测试与反馈工具如何帮助 DevOps 的过程?

  8. 服务设计思维的主要目标是什么?

  9. 服务设计思维是否要求充分利用公司已有的所有能力?

  10. 为什么完全自动化对 SaaS 应用程序的生命周期至关重要?

  11. 是否可以使用平台无关的语言定义硬件云基础架构?

  12. 什么是首选的 Azure 工具,用于整个应用程序生命周期的自动化?

  13. 如果两个 SaaS 供应商提供相同的软件产品,您应该使用最可靠的还是最便宜的?

  14. 在服务场景中,可伸缩性是唯一重要的要求吗?

进一步阅读

这些是一些网站,您可以在本章涵盖的主题中找到更多信息:

第二十一章:应用 CI 场景的挑战

持续集成CI)有时被视为 DevOps 的先决条件。在上一章中,我们讨论了 CI 的基础知识以及 DevOps 对其的依赖。它的实施也在第二十章“理解 DevOps 原则”中进行了介绍。但与其他实践章节不同,本章的目的是讨论如何在实际场景中启用 CI,考虑到您作为软件架构师需要处理的挑战。

本章涵盖的主题如下:

  • 理解 CI

  • 持续集成和 GitHub

  • 了解在使用 CI 时面临的风险和挑战

  • 理解 WWTravelClub 项目在本章的方法

与上一章类似,在解释本章内容时,将介绍 WWTravelClub 的示例,因为用于说明 CI 的所有屏幕截图都来自它。除此之外,我们将在本章末尾提供结论,以便您能够轻松理解 CI 的原则。

到本章结束时,您将能够决定是否在项目环境中使用 CI。此外,您将能够定义成功使用此方法所需的工具。

技术要求

本章需要 Visual Studio 2019 社区版或更高版本。您可能还需要一个 Azure DevOps 帐户,如第三章“使用 Azure DevOps 记录需求”中所述。您可以在以下网址找到本章的示例代码:github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5

理解 CI

一旦您开始使用 Azure DevOps 这样的平台,启用 CI 肯定会很容易,当然,只需点击相应的选项即可,就像我们在第二十章“理解 DevOps 原则”中所看到的那样。因此,技术并不是实施这一流程的阿喀琉斯之踵。

以下截图显示了使用 Azure DevOps 启用 CI 有多么容易。通过点击构建管道并对其进行编辑,您将能够设置触发器,以便在一些点击后启用 CI:

图 21.1:启用持续集成触发器

事实是,CI 将帮助您解决一些问题。例如,它将迫使您测试您的代码,因为您需要更快地提交更改,这样其他开发人员就可以使用您正在编程的代码。

另一方面,您不能只是在 Azure DevOps 中启用 CI 构建。当然,一旦您提交了更改并完成了代码,您就可以启动构建的可能性,但这远非意味着您的解决方案中有可用的 CI。

作为软件架构师,您需要更多地关注这一点的原因与对 DevOps 的真正理解有关。正如在第二十章“理解 DevOps 原则”中所讨论的,向最终用户提供价值的需求将始终是决定和制定开发生命周期的良好方式。因此,即使启用 CI 很容易,但启用此功能对最终用户的真正业务影响是什么?一旦您对这个问题有了所有的答案,并且知道如何减少其实施的风险,那么您就能够说您已经实施了 CI 流程。

值得一提的是,CI 是一个原则,可以使 DevOps 工作更加高效和快速,正如在第二十章 理解 DevOps 原则中所讨论的那样。然而,一旦你不确定你的流程是否足够成熟,可以启用持续交付代码,DevOps 肯定可以在没有它的情况下运行。更重要的是,如果你在一个还不够成熟以处理其复杂性的团队中启用 CI,你可能会导致对 DevOps 的误解,因为在部署解决方案时,你可能会开始遇到一些风险。关键是,CI 不是 DevOps 的先决条件。一旦启用了 CI,你可以在 DevOps 中加快速度。然而,你可以在没有它的情况下实践 DevOps。

这就是为什么我们要专门为 CI 增加一个额外的章节。作为软件架构师,你需要了解开启 CI 的关键点。但在我们检查这个之前,让我们学习另一个工具,可以帮助我们进行持续集成 - GitHub。

持续集成和 GitHub

自从 GitHub 被微软收购以来,许多功能已经发展,并且提供了新的选项,增强了这个强大工具的功能。可以使用 Azure 门户网站,特别是使用 GitHub Actions 来检查这个集成。

GitHub Actions 是一组工具,用于自动化软件开发。它可以在任何平台上快速启用 CI/持续部署(CD)服务,使用 YAML 文件定义工作流程。你可以将 GitHub Actions 视为 Azure DevOps Pipelines 的替代方案。然而,值得一提的是,你可以使用 GitHub Actions 自动化任何 GitHub 事件,在 GitHub Marketplace 上有数千种可用的操作:

图 21.2:GitHub Actions

通过 GitHub Actions 界面创建构建.NET Core Web 应用程序的工作流程非常简单。正如你在前面的截图中所看到的,已经创建了一些工作流程来帮助我们。我们下面的 YAML 是通过在.NET Core下点击设置此工作流程选项生成的:

name: .NET Core
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Setup .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 3.1.301
    - name: Install dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --configuration Release --no-restore
    - name: Test
      run: dotnet test --no-restore --verbosity normal 

通过下面的调整,可以构建本章特定创建的应用程序。

name: .NET Core Chapter 21
on:
  push:
    branches: [ master ]
  pull_request:
    branches: [ master ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - name: Setup .NET Core
      uses: actions/setup-dotnet@v1
      with:
        dotnet-version: 5.0.100-preview.3.20216.6
    - name: Install dependencies
      run: dotnet restore ./ch21 
    - name: Build
      run: dotnet build ./ch21 --configuration Release --no-restore
    - name: Test
      run: dotnet test ./ch21 --no-restore --verbosity normal 

如你所见,一旦脚本更新,就可以检查工作流程的结果。如果你愿意,也可以启用持续部署。这只是定义正确脚本的问题:

图 21.3:使用 GitHub Actions 进行简单应用程序编译

微软专门提供文档来介绍 Azure 和 GitHub 的集成。在docs.microsoft.com/en-us/azure/developer/github查看。

作为软件架构师,你需要了解哪种工具最适合你的开发团队。Azure DevOps 为启用持续集成提供了一个很好的环境,GitHub 也是如此。关键在于,无论你决定选择哪个选项,一旦启用 CI,你将面临风险和挑战。让我们在下一个主题中看看它们。

了解使用 CI 时的风险和挑战

现在,你可能会考虑风险和挑战,作为避免使用 CI 的一种方式。但是,如果它可以帮助你创建更好的 DevOps 流程,为什么我们要避免使用它呢?这不是本章的目的。本节的目的是帮助你作为软件架构师,减轻风险,并找到通过良好的流程和技术来应对挑战的更好方式。

本节将讨论的风险和挑战列表如下:

  • 持续生产部署

  • 生产中的不完整功能

  • 测试中的不稳定解决方案

一旦你有了处理这些问题的技术和流程,就没有理由不使用 CI。值得一提的是,DevOps 并不依赖于 CI。然而,它确实可以使 DevOps 工作更加顺畅。现在,让我们来看一下它们。

禁用持续生产部署

持续生产部署是一个过程,在提交了新的代码片段并经过一些管道步骤后,你将在生产环境中拥有这段代码。这并非不可能,但是很难且成本高昂。此外,你需要一个成熟的团队来实现它。问题在于,大多数在互联网上找到的演示和示例都会向你展示一个快速部署代码的捷径。CI/CD 的演示看起来如此简单和容易!这种简单性可能会暗示你应该尽快开始实施。然而,如果你再多考虑一下,如果直接部署到生产环境,这种情况可能是危险的!在一个需要 24 小时、7 天全天候可用的解决方案中,这是不切实际的。因此,你需要担心这一点,并考虑不同的解决方案。

第一个是使用多阶段场景,如第二十章中所述,理解 DevOps 原则。多阶段场景可以为你构建的部署生态系统带来更多的安全性。此外,你将有更多的选择来避免不正确的部署到生产环境,比如预部署批准:

图 21.4:生产环境安全的多阶段场景

值得一提的是,你可以构建一个部署管道,通过这个工具更新所有的代码和软件结构。然而,如果你有一些超出这种情况的东西,比如数据库脚本和环境配置,一个不正确的发布可能会对最终用户造成损害。此外,生产环境何时更新的决定需要计划,并且在许多情况下,所有平台用户需要被通知即将发生的变化。在这些难以决定的情况下使用变更管理程序。

因此,将代码交付到生产环境的挑战将让你考虑一个时间表。无论你的周期是每月、每天,甚至每次提交。关键点在于你需要创建一个流程和管道,确保只有良好和经过批准的软件在生产阶段。然而,值得注意的是,你离开部署的时间越长,以前部署版本和新版本之间的偏差就会越大,一次推出的变化也会越多。你能够更频繁地管理这一点,就越好。

不完整的功能

当你的团队的开发人员正在创建一个新的功能或修复一个错误时,你可能会考虑生成一个分支,以避免使用为持续交付设计的分支。分支可以被认为是代码存储库中可用的功能,以启用独立的开发线,因为它隔离了代码。如下截图所示,使用 Visual Studio 创建一个分支非常简单:

图 21.5:在 Visual Studio 中创建分支

这似乎是一个不错的方法,但让我们假设开发人员认为实现已经准备好部署,并且刚刚将代码合并到主分支。如果这个功能还没有准备好,只是因为遗漏了一个需求呢?如果错误导致了不正确的行为呢?结果可能是发布一个不完整的功能或不正确的修复。

避免主分支中出现损坏的功能甚至不正确的修复的一个好的做法是使用拉取请求。拉取请求将让其他团队开发人员知道你开发的代码已经准备好合并。以下截图显示了如何使用 Azure DevOps 创建一个你所做更改的新拉取请求

图 21.6:创建拉取请求

一旦创建了拉取请求并确定了审阅者,每个审阅者都将能够分析代码,并决定这段代码是否足够健康,可以合并到主分支中。以下截图显示了使用比较工具来分析更改的方法:

图 21.7:分析拉取请求

一旦所有的批准都完成了,你就可以安全地将代码合并到主分支,就像你在下面的截图中所看到的那样。要合并代码,你需要点击“完成合并”。如果 CI 触发器已启用,就像在本章前面所示的那样,Azure DevOps 将启动一个构建流水线:

图 21.8:合并拉取请求

毫无疑问,如果没有这样的流程,主分支将遭受大量可能会造成损害的糟糕代码,尤其是在 CD 的情况下。值得一提的是,代码审查在 CI/CD 场景中是一个很好的实践,也被认为是创建高质量软件的绝佳实践。

你需要关注的挑战是确保只有完整的功能才会呈现给最终用户。你可以使用特性标志原则来解决这个问题,这是一种确保只有准备好的功能呈现给最终用户的技术。我们再次强调的不是 CI 作为一种工具,而是作为一种在每次需要为生产交付代码时定义和使用的过程。

值得一提的是,在控制环境中的特性可用性方面,特性标志比使用分支/拉取请求要安全得多。两者都有各自的用处,但拉取请求是关于在 CI 阶段控制代码质量,而特性标志是在 CD 阶段控制特性可用性。

一个不稳定的测试解决方案

考虑到你已经减轻了本主题中提出的另外两个风险,你可能会发现在 CI 之后出现糟糕的代码是不太常见的。确实,早前提到的担忧肯定会减轻,因为你正在处理一个多阶段的情景,并且在推送到第一个阶段之前进行了拉取请求。

但是有没有一种方法可以加速发布的评估,确保这个新版本已经准备好供利益相关者测试?是的,有!从技术上讲,你可以在第十八章“使用单元测试用例和 TDD 测试你的代码”和第二十二章“功能测试自动化”中找到这样做的方法。

在这两章讨论中,自动化软件的每一个部分都是不切实际的,考虑到所需的努力。此外,在用户界面或业务规则经常变化的情况下,自动化的维护成本可能更高。虽然这是一个艰难的决定,作为软件架构师,你必须始终鼓励自动化测试的使用。

为了举例说明,让我们看一下下面的截图,它显示了由 Azure DevOps 项目模板创建的 WWTravelClub 的单元测试和功能测试样本:

图 21.9:单元测试和功能测试项目

在第十一章“设计模式和.NET 5 实现”中介绍了一些架构模式,比如 SOLID,以及一些质量保证方法,比如同行评审,这些方法会给你比软件测试更好的结果。

然而,这些方法并不否定自动化实践。事实上,所有这些方法都将有助于获得稳定的解决方案,特别是在运行 CI 场景时。在这种环境中,你能做的最好的事情就是尽快检测错误和错误行为。正如前面所示,单元测试和功能测试都将帮助你做到这一点。

单元测试将在构建流水线期间帮助你发现业务逻辑错误。例如,在下面的截图中,你会发现一个模拟错误,导致单元测试未通过而取消了构建:

图 21.10:单元测试结果

获得此错误的方法非常简单。您需要编写一些不符合单元测试检查的代码。一旦提交,假设您已经启用了持续部署触发器,代码将在流水线中构建。我们创建的 Azure DevOps 项目向导提供的最后一步之一是执行单元测试。因此,在构建代码之后,将运行单元测试。如果代码不再符合测试,您将收到错误。

同时,以下截图显示了在开发/测试阶段功能测试中出现的错误。此时,开发/测试环境存在一个错误,被功能测试迅速检测到:

图 21.11:功能测试结果

但这不是在 CI/CD 过程中应用功能测试的唯一好处,一旦您用这种方法保护了其他部署阶段。例如,让我们看一下 Azure DevOps 中Releases流水线界面的以下截图。如果您查看Release-9,您将意识到,由于此错误发生在开发/测试环境中发布之后,多阶段环境将保护部署的其他阶段:

图 21.12:多阶段环境保护

CI 过程成功的关键是将其视为加速软件交付的有用工具,并且不要忘记团队始终需要为最终用户提供价值。采用这种方法,之前介绍的技术将为您的团队实现其目标提供令人难以置信的方式。

理解 WWTravelClub 项目的方法

在这一章中,展示了 WWTravelClub 项目的截图,示范了采用更安全的方法来实现 CI 的步骤。即使将 WWTravelClub 视为假设的情景,建立它时也考虑了一些问题:

  • CI 已启用,但多阶段场景也已启用。

  • 即使有多阶段场景,拉取请求也是确保只有高质量代码会出现在第一阶段的一种方式。

  • 为了在拉取请求中做好工作,进行了同行评审。

  • 同行评审检查,例如在创建新功能时是否存在功能标志。

  • 同行评审检查在创建新功能期间开发的单元测试和功能测试。

上述步骤不仅适用于 WWTravelClub。作为软件架构师,您需要定义一种方法来确保安全的 CI 场景。您可以将此作为起点。

总结

本章介绍了在软件开发生命周期中启用 CI 的重要性,考虑到您作为软件架构师决定为解决方案使用它时将面临的风险和挑战。

此外,本章介绍了一些可以使这个过程更容易的解决方案和概念,例如多阶段环境、拉取请求审查、功能标志、同行评审和自动化测试。理解这些技术和流程将使您能够在 DevOps 场景中引导项目朝着更安全的行为方向发展。

在下一章中,我们将看到软件测试的自动化是如何工作的。

问题

  1. 什么是 CI?

  2. 没有 CI,你能有 DevOps 吗?

  3. 在非成熟团队启用 CI 的风险是什么?

  4. 多阶段环境如何帮助 CI?

  5. 自动化测试如何帮助 CI?

  6. 拉取请求如何帮助 CI?

  7. 拉取请求只能与 CI 一起使用吗?

进一步阅读

以下是一些网站,您可以在其中找到有关本章涵盖主题的更多信息:

第二十二章:功能测试的自动化

在之前的章节中,我们讨论了单元测试和集成测试在软件开发中的重要性,并讨论了它们如何确保代码库的可靠性。我们还讨论了单元测试和集成测试如何成为所有软件生产阶段的组成部分,并且在每次代码库修改时运行。

还有其他重要的测试,称为功能测试。它们仅在每个冲刺结束时运行,以验证冲刺的输出实际上是否满足与利益相关者达成的规格。

本章专门致力于功能测试以及定义、执行和自动化它们的技术。更具体地,本章涵盖以下主题:

  • 理解功能测试的目的

  • 在 C#中使用单元测试工具自动化功能测试

  • 用例-自动化功能测试

在本章结束时,您将能够设计手动和自动测试,以验证冲刺产生的代码是否符合其规格。

技术要求

在继续本章之前,建议您阅读第十八章使用单元测试用例和 TDD 测试您的代码

本章需要 Visual Studio 2019 的免费社区版或更高版本,并安装了所有数据库工具。在这里,我们将修改第十八章中的代码,使用单元测试用例和 TDD 测试您的代码,该代码可在github.com/PacktPublishing/Software-Architecture-with-C-9-and-.NET-5上找到。

理解功能测试的目的

第十八章中,使用单元测试用例和 TDD 测试您的代码,我们讨论了自动测试的优势,如何设计它们以及它们的挑战。功能测试使用与单元测试和集成测试相同的技术和工具,但它们与它们不同之处在于它们仅在每个冲刺结束时运行。它们的基本作用是验证当前版本的整个软件是否符合其规格。

由于功能测试还涉及用户界面UI),它们需要进一步的工具来模拟用户在 UI 中的操作方式。我们将在整个章节中进一步讨论这一点。需要额外工具的需求并不是 UI 带来的唯一挑战,因为 UI 也经常发生重大变化。因此,我们不应设计依赖于 UI 图形细节的测试,否则我们可能会被迫在每次 UI 更改时完全重写所有测试。这就是为什么有时放弃自动测试并回归手动测试会更好。

无论是自动还是手动,功能测试都必须是一个正式的过程,用于以下目的:

  • 功能测试代表了利益相关者和开发团队之间合同的最重要部分,另一部分是验证非功能规格。这份合同的形式化方式取决于开发团队和利益相关者之间关系的性质:

  • 在供应商-客户关系的情况下,功能测试成为每个冲刺的供应商-客户业务合同的一部分,由为客户工作的团队编写。如果测试失败,那么冲刺将被拒绝,供应商必须进行补充冲刺以解决所有问题。

  • 如果没有供应商-客户业务关系,因为开发团队和利益相关者属于同一家公司,那么就没有业务合同。在这种情况下,利益相关者与团队一起编写一份内部文件,正式规定了冲刺的要求。如果测试失败,通常不会拒绝冲刺,而是使用测试结果来驱动下一个冲刺的规格。当然,如果失败率很高,冲刺可能会被拒绝并且需要重复。

  • 在每个冲刺结束时运行的正式功能测试可以防止之前冲刺取得的结果被新代码破坏。

  • 在使用敏捷开发方法时,保持更新的功能测试库是获得最终系统规范的正式表示的最佳方式,因为在敏捷开发过程中,最终系统的规范并不是在开发开始之前决定的,而是系统演变的结果。

由于最初阶段的前几个冲刺的输出可能与最终系统有很大不同,因此不值得花费太多时间编写详细的手动测试和/或自动化测试。因此,您可以将用户故事限制为仅有几个示例,这些示例将被用作软件开发的输入和手动测试。

随着系统功能变得更加稳定,值得投入时间编写详细和正式的功能测试。对于每个功能规范,我们必须编写验证其在极端情况下操作的测试。例如,在取款用例中,我们必须编写验证所有可能性的测试:

  • 资金不足

  • 卡已过期

  • 凭证错误

  • 重复的错误凭证

以下图片勾勒了整个过程及所有可能的结果:

图 22.1:取款示例

对于手动测试,对于前述每个场景,我们必须给出每个操作涉及的所有步骤的详细信息,以及每个步骤的预期结果。

一个重要的决定是是否要自动化所有或部分功能测试,因为编写模拟人操作与系统 UI 交互的自动化测试非常昂贵。最终决定取决于测试实施的成本除以预期使用次数。

在 CI/CD 的情况下,同一个功能测试可以被执行多次,但不幸的是,功能测试严格地与 UI 的实现方式相关联,而在现代系统中,UI 经常发生变化。因此,在这种情况下,测试与完全相同的 UI 执行不超过几次。

为了克服与 UI 相关的所有问题,一些功能测试可以被实施为皮下测试,也就是绕过 UI 的测试。例如,ASP.NET Core 应用程序的一些功能测试可以直接调用控制器动作方法,而不是通过浏览器发送实际请求。

不幸的是,皮下测试无法验证所有可能的实现错误,因为它们无法检测 UI 本身的错误。此外,在 Web 应用程序的情况下,皮下测试通常受到其他限制的影响,因为它们绕过了整个 HTTP 协议。

特别是在 ASP.NET Core 应用程序的情况下,如果我们直接调用控制器动作方法,就会绕过整个 ASP.NET Core 管道,该管道在将请求传递给正确的动作方法之前处理每个请求。因此,身份验证、授权、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 项目中。然而,在这种情况下,我们必须添加进一步的工具,这些工具能够与 UI 进行交互和检查。

在本章的其余部分,我们将专注于 Web 应用程序,因为它们是本书的主要焦点。因此,如果我们正在测试 Web API,我们只需要HttpClient实例,因为它们可以轻松地与 XML 和 JSON 格式的 Web API 端点进行交互。

对于返回 HTML 页面的 ASP.NET Core MVC 应用程序,交互更加复杂,因为我们还需要用于解析和与 HTML 页面 DOM 树交互的工具。AngleSharp NuGet 包是一个很好的解决方案,因为它支持最先进的 HTML 和最小的 CSS,并且具有用于外部提供的 JavaScript 引擎(如 Node.js)的扩展点。然而,我们不建议在测试中包含 JavaScript 和 CSS,因为它们严格绑定到目标浏览器,所以最好的选择是使用 JavaScript 特定的测试工具,可以直接在目标浏览器中运行它们。

使用HttpClient类测试 Web 应用程序有两个基本选项:

  • 分段应用程序。一个HttpClient实例通过互联网/内联网连接到实际的分段Web 应用程序,与所有其他正在进行软件测试的人一起。这种方法的优势在于你正在测试真实内容,但是测试更难构思,因为你无法控制每个测试之前应用程序的初始状态。

  • 受控应用程序。一个HttpClient实例连接到一个本地应用程序,该应用程序在每次单独的测试之前都被配置、初始化和启动。这种情况与单元测试场景完全类似。测试结果是可重现的,每个测试之前的初始状态是固定的,测试更容易设计,并且实际数据库可以被更快、更容易初始化的内存数据库替换。然而,在这种情况下,你离实际系统的运行很远。

一个好的策略是使用受控应用程序,在这里你完全控制初始状态,用于测试所有极端情况,然后使用分段应用程序来测试真实内容上的随机平均情况。

接下来的两个部分描述了这两种方法。这两种方法的唯一区别在于你如何定义测试的固定装置。

测试分段应用程序

在这种情况下,您的测试只需要一个HttpClient的实例,因此您必须定义一个有效的夹具,提供HttpClient的实例,避免耗尽 Windows 连接的风险。我们在第十四章.NET Core HTTP 客户端部分中遇到了这个问题,应用 Service-Oriented Architectures with .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 readonly ServiceProvider _serviceProvider;
    public UnitTest1(HttpClientFixture fixture)
    {
        _serviceProvider = fixture.ServiceProvider;
    }
    [Fact]
    public void Test1()
    {
        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.Mvc.Testing包含一个夹具类,用于启动本地 Web 服务器并提供用于与其交互的客户端。预定义的夹具类是WebApplicationFactory<T>。泛型T参数必须实例化为您的 Web 项目的Startup类。

测试看起来像以下的类:

public class UnitTest1 
    : IClassFixture<WebApplicationFactory<MyProject.Startup>>
{
    private readonly 
        WebApplicationFactory< MyProject.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>继承的自定义夹具的构造函数中。请注意,删除所有数据库数据并不像看起来那么容易,因为存在完整性约束。您有各种选择,但没有一种适用于所有情况:

  1. 删除整个数据库并使用迁移重新创建它,即DbContext.Database.Migrate()。这总是有效的,但速度慢,并且需要具有高权限的数据库用户。

  2. 禁用数据库约束,然后以任何顺序清除所有表。这种技术有时不起作用,并且需要具有高权限的数据库用户。

  3. 按正确顺序删除所有数据,因此不违反所有数据库约束。如果您保持一个有序的删除列表,其中包含数据库增长时添加到数据库的所有表,这并不难。这个删除列表是一个有用的资源,您也可以用它来修复数据库更新操作中的问题,并在生产数据库维护期间删除旧条目。不幸的是,这种方法在很少出现的循环依赖的情况下也会失败,例如一个具有指向自身的外键的表。

我更喜欢方法 3,并且只在由于循环依赖引起的困难的罕见情况下才返回到方法 2。作为方法 3 的示例,我们可以编写一个从WebApplicationFactory<Startup>继承的 fixture,删除第十八章中应用程序的所有测试记录,使用单元测试用例和 TDD 测试您的代码

如果您不需要测试身份验证/授权子系统,则删除包、目的地和事件的数据就足够了。删除顺序很简单;首先必须删除事件,因为没有任何依赖于它们,然后我们可以删除依赖于目的地的包,最后删除目的地本身。代码非常简单:

public class DBWebFixture: WebApplicationFactory<Startup>
{
    public DBWebFixture() : base()
    {
        var context = Services
            .GetService(typeof(MainDBContext))
                as MainDBContext;
        using (var tx = context.Database.BeginTransaction())
        {
            context.Database
                .ExecuteSqlRaw
                    ("DELETE FROM dbo.PackgeEvents");
            context.Database
                .ExecuteSqlRaw
                    ("DELETE FROM dbo.Packges");
            context.Database
                 .ExecuteSqlRaw
                    ("DELETE FROM dbo.Destinations");
            tx.Commit();
        }
    }
} 

我们从继承自WebApplicationFactory<Startup>的服务中获取DBContext实例,因此可以执行数据库操作。从表中同时删除所有数据的唯一方法是通过直接的数据库命令。因此,在这种情况下,我们无法使用SaveChanges方法将所有更改封装在单个事务中,我们被迫手动创建事务。

您可以通过将其添加到下一章的用例中来测试上面的类,该用例基于第十八章的代码,使用单元测试用例和 TDD 测试您的代码

用例 - 自动化功能测试

在本节中,我们将向第十八章的 ASP.NET Core 测试项目中添加一个简单的功能测试。我们的测试方法基于Microsoft.AspNetCore.Mvc.TestingAngleSharp NuGet 包。请制作整个解决方案的新副本。

测试项目已经引用了test下的 ASP.NET Core 项目和所有必需的 xUnit NuGet 包,因此我们只需要添加Microsoft.AspNetCore.Mvc.TestingAngleSharp NuGet 包。

现在,让我们添加一个名为UIExampleTest.cs的新类文件。我们需要using语句来引用所有必要的命名空间。更具体地说,我们需要以下内容:

  • 使用 PackagesManagement;:这是引用应用程序类所需的。

  • 使用 Microsoft.AspNetCore.Mvc.Testing;:这是引用客户端和服务器类所需的。

  • 使用 AngleSharp;和使用 AngleSharp.Html.Parser;:这是引用AngleSharp类所需的。

  • System.IO:这是为了从 HTTP 响应中提取 HTML 所需的。

  • 使用 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; 

我们将使用前面测试受控应用程序部分介绍的标准 fixture 类来编写以下测试类:

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 分析。以下代码显示了一种简单的方法:

string source = await response.Content.ReadAsStringAsync(); 

现在,我们必须将提取的 HTML 传递给我们之前的AngleSharp浏览上下文对象,以便它可以构建 DOM 树。以下代码显示了如何做到这一点:

var document = await context.OpenAsync(req => req.Content(source)); 

OpenAsync方法使用context中包含的设置执行 DOM 构建活动。构建 DOM 文档的输入由作为OpenAsync参数传递的 lambda 函数指定。在我们的情况下,req.Content(...)从客户端接收的响应中传递给Content方法的 HTML 字符串构建了 DOM 树。

一旦获得document对象,我们可以像在 JavaScript 中一样使用它。特别是,我们可以使用QuerySelector来查找具有所需链接的锚点:

var node = document.QuerySelector("a[href=\"/ManagePackages\"]"); 

现在只剩下验证node不为空了:

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 功能测试来检查应用程序返回的响应。

结论

在讨论了使用 C# 9 和.NET 5 开发解决方案的最佳实践和方法以及 Azure 中最新的云环境之后,您终于到达了本书的结尾。

正如您在职业生涯中可能已经注意到的那样,按时、按预算开发软件并满足客户需求的功能并不简单。本书的主要目的不仅在于展示软件开发周期基本领域的最佳实践,还演示了如何使用所提到的工具的功能和优势,以帮助您设计可扩展、安全和高性能的企业应用程序,并考虑智能软件设计。这就是为什么本书涵盖了每个广泛领域中的不同方法,从用户需求开始,到生产中的软件,不断部署和监控。

谈到持续交付软件,本书强调了编码、测试和监控解决方案的最佳实践的必要性。这不仅仅是开发一个项目的问题;作为软件架构师,您将对您在软件停用之前所做的决定负责。现在,由您决定最适合您情况的实践和模式。

问题

  1. 在快速 CI/CD 周期的情况下,自动化 UI 功能测试总是值得的吗?

  2. ASP.NET Core 应用程序的皮下测试的缺点是什么?

  3. 编写 ASP.NET Core 功能测试的建议技术是什么?

  4. 检查服务器返回的 HTML 的建议方式是什么?

进一步阅读

分享您的经验感谢您抽出时间阅读本书。如果您喜欢这本书,请帮助其他人找到它。在www.amazon.com/dp/1800566042上留下评论。

第二十三章:答案

第一章

  1. 软件架构师需要了解任何可以帮助他们更快解决问题并确保他们能够创建更高质量软件的技术。

  2. Azure 提供并不断改进许多组件,软件架构师可以在解决方案中实现这些组件。

  3. 最佳的软件开发过程模型取决于您所拥有的项目、团队和预算的类型。作为软件架构师,您需要考虑所有这些变量,并了解不同的过程模型,以便满足环境的需求。

  4. 软件架构师要注意任何可能影响性能、安全性、可用性等的用户或系统需求。

  5. 所有这些,但非功能性需求需要更多关注。

  6. 设计思维和设计冲刺是帮助软件架构师准确定义用户需求的工具。

  7. 用户故事在我们想要定义功能需求时很好。它们可以快速编写,并通常不仅提供所需的功能,还提供解决方案的验收标准。

  8. 缓存、异步编程和正确的对象分配。

  9. 为了检查实现是否正确,软件架构师将其与已经设计和验证的模型和原型进行比较。

第二章

  1. 纵向和横向。

  2. 是的,您可以自动部署到已定义的 Web 应用程序,或者直接使用 Visual Studio 创建一个新的 Web 应用程序。

  3. 通过最小化空闲时间来利用可用的硬件资源。

  4. 代码行为是确定性的,因此很容易调试。执行流程模仿了顺序代码的流程,这意味着更容易设计和理解。

  5. 因为正确的顺序可以最大程度地减少填写表单所需的手势数量。

  6. 因为它允许以独立于操作系统的方式操作路径文件。

  7. 它可以与多个.NET Core 版本一起使用,也可以与经典.NET 框架的多个版本一起使用。

  8. 控制台、.NET Core 和.NET 标准类库;ASP.NET Core、测试和微服务。

第三章

  1. 不,它适用于多个平台。

  2. 自动、手动和负载测试计划。

  3. 是的,它们可以 - 通过 Azure DevOps feeds。

  4. 管理需求并组织整个开发过程。

  5. 史诗工作项代表由多个功能组成的高级系统子部分。

  6. 父子关系。

第四章

  1. 当您从本地解决方案迁移或者拥有基础设施团队时,IaaS 是一个不错的选择。

  2. PaaS 是在团队专注于软件开发的系统中快速安全地交付软件的最佳选择。

  3. 如果您打算提供的解决方案是由知名厂商提供的,比如 SaaS,您应该考虑使用它。

  4. 在构建新系统时,无服务器绝对是一个选择,如果您没有专门从事基础设施的人员,并且不想担心可伸缩性。

  5. Azure SQL Server 数据库可以在几分钟内启动,之后您将拥有 Microsoft SQL Server 的所有功能。

  6. Azure 提供了一组名为 Azure 认知服务的服务。这些服务提供了视觉、语音、语言、搜索和知识的解决方案。

  7. 在混合场景中,您可以灵活决定系统的每个部分的最佳解决方案,同时尊重解决方案未来的发展路径。

第五章

  1. 代码的模块化和部署的模块化。

  2. 不。其他重要优势包括很好地处理开发团队和整个 CI/CD 周期,以及轻松有效地混合异构技术的可能性。

  3. 帮助我们实现弹性通信的库。

  4. 一旦在开发机器上安装了 Docker,您就可以开发、调试和部署 Docker 化的.NET Core 应用程序。您还可以将 Docker 映像添加到使用 Visual Studio 处理的 Service Fabric 应用程序中。

  5. 编排器是管理微服务和微服务集群中的节点的软件。Azure 支持两个相关的编排器:Azure Kubernetes 服务和 Azure Service Fabric。

  6. 因为它解耦了通信中发生的参与者。

  7. 消息代理。它负责服务与服务之间的通信和事件。

  8. 同一条消息可能会被接收多次,因为发送方在超时之前没有收到接收确认,因此发送方会再次发送消息。因此,接收一条消息一次或多次的效果必须相同。

第六章

  1. 可靠服务是本机的 Azure Service Fabric 服务。但是,Azure Service Fabric 也可以托管其他类型的服务,例如 Docker 化服务。

  2. 无状态和有状态。无状态服务用于实现不需要存储任何状态的微服务,而有状态服务用于实现需要存储状态信息的微服务。

  3. 这是HostBuilder方法,您可以在其中放置您的依赖注入容器。

  4. 暴露给集群外部流量并通过集群的 URI 访问的对象。

  5. 为了在有状态服务中实现分片的写入/修改并行性。

  6. 使用只读端点。通过提供ServiceReplicaListenerIEnumerable可以添加自定义通信协议。

第七章

  1. 由于服务需要将通信分派到 Pod,因为 Pod 没有稳定的 IP 地址。

  2. 服务了解 TCP/IP 等低级协议,但大多数 Web 应用程序依赖于更复杂的 HTTP 协议。这就是为什么 Kubernetes 提供了称为Ingresses的更高级实体,这些实体建立在服务之上。

  3. Helm 图表是组织模板和安装包含多个.yaml文件的复杂 Kubernetes 应用程序的一种方法。

  4. 是的,使用---分隔符。

  5. 使用livenessProbe

  6. 因为 Pod 没有稳定的位置,无法依赖于它们当前运行的节点的存储。

  7. StatefulSet通信可以被分片以实现写/更新并行性。

第八章

  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); 
  1. Add-Migration 和 Update-Database。

  2. 不,但您可以使用Include LINQ 子句或在配置DbContext时使用UseLazyLoadingProxies选项强制包含它们。

  3. 是的,谢谢Select LINQ 子句。

  4. 通过调用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#,PHP,Python 和 Node。您还可以使用 Azure 门户和 Visual Studio Code 创建函数。

  3. Azure Functions 有两种计划选项。第一个计划是按照您使用的数量收费的消耗计划。第二个计划是应用服务计划,您可以在该计划中与函数的需求共享应用服务资源。

  4. 在 Visual Studio 中部署函数的过程与 Web 应用程序部署相同。

  5. 我们可以通过许多方式触发 Azure 函数,例如使用 Blob 存储,Cosmos DB,事件网格,事件中心,HTTP,Microsoft Graph 事件,队列存储,服务总线,定时器和 Webhooks。

  6. Azure Functions v1 需要.NET Framework 引擎,而 v2 需要.NET Core 2.2,v3 需要.NET Core 3.1 和.NET 5。

  7. 每个 Azure 函数的执行都可以通过应用程序洞察监控。在这里,您可以检查处理所需的时间,资源使用情况,错误以及每个函数调用中发生的异常。

第十一章

  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. 泛型是一种复杂的实现,通过定义一个在编译时将被具体类型替换的占位符,简化了具有相同特征的对象的处理方式。

  8. 这个问题的答案由 Immo Landwerth 在 dotnet 博客上得到了很好的解释:devblogs.microsoft.com/dotnet/the-future-of-net-standard/。基本答案是,.NET 5.0(以及未来的版本)需要被认为是未来共享代码的基础。

  9. 当您重构代码时,您正在以更好的方式编写它,尊重该代码将处理的数据的输入和输出的合同。

第十四章

  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. 这是一个 W3C 标准:在符合 W3C 的浏览器中运行的虚拟机的组装。

  2. 一个 Web UI,其中动态 HTML 是在浏览器本身中创建的。

  3. 根据当前浏览器 URL 选择页面。

  4. 一个带有路由的 Blazor 组件。因此,Blazor router可以选择它。

  5. 定义 Blazor 组件类的.NET 命名空间。

  6. 一个本地服务,负责存储和处理所有与表单相关的信息,比如验证错误和 HTML 输入的更改。

  7. OnInitializedOnInitializedAsync

  8. 回调和服务。

  9. Blazor 与 JavaScript 交互的方式。

  10. 获取对组件或 HTML 元素实例的引用。

第十七章

  1. 可维护性使您有机会快速交付您设计的软件。它还允许您轻松修复错误。

  2. 圈复杂度是一种检测方法具有的节点数的度量标准。数字越高,影响越糟。

  3. 版本控制系统将保证您的源代码的完整性,使您有机会分析您所做的每次修改的历史。

  4. 垃圾收集器是.NET Core/.NET Framework 系统,它监视您的应用程序并检测您不再使用的对象。它处理这些对象以释放内存。

  5. IDisposable接口首先很重要,因为它是一种确定性清理的良好模式。其次,它在需要由程序员处理的实例化对象的类中是必需的,因为垃圾收集器无法处理它们。

  6. .NET Core 在其某些库中封装了一些设计模式,以一种可以保证更安全的代码的方式,比如依赖注入和构建器。

第十八章

  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. DevOps 是持续向最终用户交付价值的方法。为了成功地做到这一点,必须进行持续集成、持续交付和持续反馈。

  2. 持续集成允许您在每次提交更改时检查您正在交付的软件的质量。您可以通过在 Azure DevOps 中启用此功能来实现这一点。

  3. 持续交付允许您在确保所有质量检查都通过了您设计的测试之后部署解决方案。Azure DevOps 通过提供相关工具来帮助您实现这一目标。

  4. 持续反馈是在 DevOps 生命周期中采用工具,使得在性能、可用性和应用程序的其他方面快速获得反馈成为可能。

  5. 构建管道将让您运行用于构建和测试应用程序的任务,而发布管道将为您提供定义应用程序在每种情况下如何部署的机会。

  6. 应用程序洞察是一个有用的工具,用于监视您部署的系统的健康状况,这使其成为一个出色的持续反馈工具。

  7. 测试和反馈是一种工具,允许利益相关者分析您正在开发的软件,并与 Azure DevOps 建立连接,以打开任务甚至错误。

  8. 最大化软件为目标组织提供的价值。

  9. 不;它需要获得最大化软件增加值所需的所有能力。

  10. 因为当新用户订阅时,其租户必须自动创建,并且因为新软件更新必须分发到所有客户的基础设施。

  11. 是的;Terraform 就是一个例子。

  12. Azure 管道。

  13. 您的业务依赖于 SaaS 供应商,因此其可靠性至关重要。

  14. 不;可伸缩性和容错性以及自动故障恢复同样重要。

第二十一章

  1. 这是一种方法,确保代码存储库中的每个提交都经过构建和测试。这是通过频繁地将代码合并到主体代码中来完成的。

  2. 是的,您可以单独拥有 DevOps,然后稍后启用持续集成。您也可以在没有持续交付的情况下启用持续集成。您的团队和流程需要准备好并密切关注这一点。

  3. 您可能会误解 CI 为持续交付过程。在这种情况下,您可能会对生产环境造成损害。在最坏的情况下,您可能会有一个尚未准备好但已部署的功能,您可能会在客户的糟糕时刻停止,或者甚至由于不正确的修复而遭受糟糕的副作用。

  4. 多阶段环境在启用 CI-CD 时保护生产环境免受糟糕的发布。

  5. 自动化测试可以预测预览场景中的错误和不良行为。

  6. 拉取请求允许在提交到主分支之前进行代码审查。

  7. 不;拉取请求可以帮助您在任何开发方法中,其中 Git 是您的源代码控制。

第二十二章

  1. 不;这取决于用户界面的复杂性以及其变化频率。

  2. ASP.NET Core 管道不会被执行,而是直接将输入传递给控制器。

  3. 使用Microsoft.AspNetCore.Mvc.Testing NuGet 包。

  4. 使用AngleSharp NuGet 包。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(8)  评论(0编辑  收藏  举报