-NET-微服务设计模式-全-

.NET 微服务设计模式(全)

原文:zh.annas-archive.org/md5/218460e5cc8e8d7e6c35b968fbaa82a8

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

嗨,大家好!我们在这里是为了探索在设计和发展模式中我们可以利用的,当我们使用 .NET 构建微服务应用程序时。微服务架构涉及将可能复杂的应用程序分解成更小、更易于维护的服务,这些服务必须协同工作。本质上,我们取一个大的应用程序并将其分解成更小的部分。

这种方法在应用程序的设计中引入了一系列新的复杂性,因为这些现在分离的服务需要协作以向最终用户提供统一体验。因此,我们需要了解这种架构方法的缺点,并制定策略来解决各种问题。

我们将专注于使用 .NET,因为微软在发布和支持顶级工具和支持最新技术方面有着良好的记录,这些技术使我们能够开发前沿的解决方案。我们专注于使用 .NET 的原因如下:

  • 维护良好:微软不断推动边界,引入新的方法来完成旧事物,以及新的方法来实现解决方案并提高生产力。这是一个维护良好、受支持且文档齐全的生态系统。

  • 性能:.NET 在每个新版本中都提高了性能。微服务必须尽可能高效和响应迅速,以确保最终用户的体验尽可能干净。

  • 跨平台:.NET Core 是跨平台的,几乎可以在任何技术栈上部署。这减少了使用 .NET 技术编写的服务的部署和支持限制。

  • 支持各种技术:每个问题都有一种技术帮助我们实施解决方案。.NET 支持许多技术,使其成为通用开发需求的优秀候选者。

我们希望确保我们了解在基于微服务架构开发应用程序时所需的可能性和策略。我们将回顾每个问题的理论背景,然后探讨潜在解决方案和技术,这些解决方案和技术帮助我们实施最佳解决方案,以应对我们在开发解决方案时面临的挑战。

这本书面向的对象

这本书是为希望揭开微服务架构各种组成部分神秘面纱的 .NET 开发者设计的。为了最大限度地利用本书,你最好符合以下类别之一:

  • 团队领导:需要理解各种组成部分以及需要做出的设计决策背后的理论

  • 高级开发者:需要理解如何指导开发工作并实现复杂代码块的开发者

  • 中级 .NET 开发者:对 .NET 生态系统有实际了解的 .NET 开发者,希望深入了解开发更复杂解决方案

本书整体内容将帮助您理解微服务应用程序设计的动态性,并帮助您达到开发的新水平。

本书涵盖的内容

第一章, 微服务简介 全景图,从高层次上审视微服务架构,并试图理解我们可能遇到的早期问题,并探讨解决这些问题的设计模式。

第二章, 使用聚合模式工作,探讨了领域驱动设计和聚合模式如何为需求范围和微服务设计奠定基础。

第三章, 微服务间的同步通信,探讨了如何使微服务同步通信及其潜在缺点。

第四章, 微服务间的异步通信,考察了服务间的异步通信,这允许我们在调用其他微服务时发送数据并继续前进,无论其可用性或潜在的长时间运行。

第五章, 使用 CQRS 模式工作,探讨了 CQRS 模式及其在微服务开发中的实用性。

第六章, 应用事件源模式,讨论了事件源技术的复杂性以及我们如何实现它以确保服务间数据保持同步。

第七章, 使用每个服务一个数据库模式处理每个微服务的数据,涵盖了在不同服务中实现不同数据库的最佳实践。

第八章, 使用 Saga 模式在微服务间实现事务,探讨了 Saga 模式及其如何帮助我们实现微服务间的事务处理。

第九章, 构建弹性微服务,回顾了实现重试和退出策略逻辑,以增强服务间通信的弹性。

第十章, 对您的服务进行健康检查,回顾了如何在 ASP.NET Core API 中实现健康检查以及为什么它们是必不可少的。

第十一章, 实现 API 和 BFF 网关模式,深入探讨了 API 网关、前端后端模式以及它们如何帮助我们创建一个健壮的微服务应用程序。

第十二章, 使用 Bearer Tokens 保护微服务安全,回顾了如何使用 Bearer Tokens 来保护每个服务之间的通信。

第十三章, 微服务容器托管,探讨了容器化以及我们如何利用容器高效地托管微服务。

第十四章为微服务实现集中式日志记录,探讨了将多个服务的日志聚合到一个查看区域中的步骤和最佳实践。

第十五章总结全文,讨论了每一章的关键点,并强调了每一章在开发微服务应用程序中的作用。

要充分利用本书

您需要具备使用 ASP.NET Core 进行 API 开发的知识以及基本的数据库开发知识。本书最适合希望提高对开发设计模式理解的中级开发者。

本书涵盖的软件/硬件 操作系统要求
ASP.NET Core 6/7 Windows、macOS 或 Linux
C# 9/10 Windows、macOS 或 Linux
Docker Windows、macOS 或 Linux

大多数示例都是使用 NuGet 包管理器命令和 Visual Studio 2022 展示的。这些命令可以转换为 dotnet CLI 命令,可以在任何操作系统和替代 IDE(如 Visual Studio)中使用。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET。如果代码有更新,它将在 GitHub 仓库中更新。

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

下载彩色图像

我们还提供了一份包含本书中使用的截图和图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/dD3Jv

使用的约定

本书使用了几个文本约定。

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、命令和关键字。以下是一个示例:“目前,我们的CreateAppointmentHandler将处理该情况下所需的所有内容。”

代码块设置如下:

public class AppointmentCreated : IDomainEvent
    {
        public Appointment { get; set; }
        public DateTime ActionDate { get; private set; }

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的单词会以粗体显示。以下是一个示例:“从管理面板中选择系统信息。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们欢迎读者的反馈。

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

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

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

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

分享你的想法

一旦你阅读了《.NET 微服务设计模式》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

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

下载本书的免费 PDF 副本

感谢您购买本书!

你喜欢在路上阅读,但无法携带你的印刷书籍到处走?

你的电子书购买是否与你的选择设备不兼容?

别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。

优惠远不止这些,你还可以获得独家折扣、时事通讯和每日免费内容的邮箱访问权限。

按照以下简单步骤获取优惠:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781804610305

  1. 提交你的购买证明

  2. 就这些!我们将直接将你的免费 PDF 和其他优惠发送到你的邮箱。

第一部分:理解微服务和设计模式

本部分重点介绍微服务设计模式的基础知识。你将了解许多动态部分和范围技术,这些技术对于决定设计微服务应用程序的整体方法至关重要。在本部分结束时,你将意识到围绕这种开发模式的复杂性,并了解这种方法的优缺点。

本部分包含以下章节:

  • 第一章, 微服务简介 全景图

  • 第二章, 使用聚合器模式工作

  • 第三章, 微服务之间的同步通信

  • 第四章, 微服务之间的异步通信

  • 第五章, 使用 CQRS 模式

  • 第六章, 应用事件源模式

第一章:微服务简介——全景图

微服务正在软件开发的各个方面得到应用。微服务是一种软件开发风格,被誉为可以加快开发速度和效率,同时提高软件的可扩展性和交付。这种开发技术并非任何堆栈所独有,并在 Java、.NET 和 JavaScript(Node JS)开发中变得极为流行。虽然微服务的使用被视为一种模式,但还有几个子模式被采用,以确保代码库尽可能有效。

这本书的第一章是 15 章中的第一章,将涵盖微服务中使用的模式。我们将专注于使用.NET Core 开发堆栈实现它们,你将了解代码的编写和部署方式。你将了解设计和编码模式、第三方工具和环境,以及处理应用程序开发中某些场景的最佳实践。

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

  • 深入探讨微服务和其关键元素

  • 评估微服务的业务需求

  • 确定实施微服务的可行性

深入探讨微服务和其关键元素

传统上,在软件开发中,应用作为单一单元或单体进行开发。所有组件都紧密耦合,对任何一个组件的更改都可能对整个代码库和功能产生连锁反应。这使得长期维护成为一个主要问题,并可能阻碍开发者快速推出更新。

微服务将帮助你评估单体应用,将其分解成更小、更易感知的应用程序。每个应用程序都将与更大项目的一个子部分相关联,这被称为领域。然后我们将以独立单元的形式开发和维护每个应用程序的代码库。通常,微服务作为 API 开发,可能或可能不相互交互以完成用户通过统一用户界面执行的操作。通常,微服务架构由一系列小型独立服务组成,这些服务通过HTTPREST API)或gRPCGoogle 远程过程调用)进行通信。一般概念是每个微服务都是自治的,具有有限的范围,并有助于集体松散耦合的应用程序。

构建单体应用

让我们想象我们需要构建一个健康设施管理网络应用程序。我们需要管理客户信息、预订预约、生成发票并向客户交付测试结果。如果我们列出构建此类应用程序所需的所有步骤,关键的开发和范围活动将包括以下内容:

  1. 模拟应用,并为我们的客户入职、用户资料和基本文档确定需求范围。

  2. 定义与特定医生预约流程相关的需求。医生有日程安排和专长,因此我们必须相应地展示预约时间段。

  3. 当客户和医生之间找到匹配时,创建一个流程。一旦找到匹配,我们需要做以下事情:

    1. 预约医生的时间段

    2. 生成发票

    3. 可能收集访问的付款

    4. 向客户、医生和其他相关人员发送电子邮件通知

  4. 模拟一个数据库(可能是关系型)来存储所有这些信息。

  5. 为客户和医疗人员将使用的每个屏幕创建用户界面。

所有这些都作为一个应用程序开发,一个前端与一个后端、一个数据库和一个部署环境进行通信。此外,我们可能会添加一些第三方 API 集成,用于支付和电子邮件服务。这可以在多个服务器上进行负载均衡和托管,以减轻停机时间并提高响应速度:

图 1.1 – 应用构建

图 1.1 – 应用构建

然而,这种单体架构引入了一些挑战:

  • 尝试扩展功能可能会在多个模块中产生连锁反应,并引入新的数据库和安全需求。

潜在解决方案:执行彻底的单元和集成测试。

  • 开发团队面临的风险是过度依赖特定的栈,这使得保持代码库现代化变得更加困难。

潜在解决方案:实施适当的版本控制策略,并在技术变化时进行增量更新。

  • 随着代码库的扩展,更难对所有移动部件进行核算。

潜在解决方案:使用干净的架构方法保持代码库松散耦合和模块化。

事实上,我们可以通过某些架构决策克服一些这些挑战。这种一站式架构已经成为事实上的标准,坦白说,它很有效。这个项目架构简单,足够容易定义和开发,并且得到了大多数,如果不是所有,的开发栈和数据库的支持。我们已经建造了很长时间,也许我们已经忽视了我们在长期扩展和维护它们时面临的真正挑战。

图 1.2 展示了单体应用的典型架构:

图 1.2 – 一个用户界面由包含业务逻辑的 API 或代码库提供服务,并由一个数据库支持

图 1.2 – 一个用户界面由包含业务逻辑的 API 或代码库提供服务,并由一个数据库支持

现在我们已经探讨了单体方法及其潜在缺陷,让我们回顾一下使用微服务构建的类似应用程序。

构建微服务

现在,让我们以同样的应用为例,探讨如何使用微服务进行架构设计。在设计阶段,我们试图确定应用每个分块的特定功能。这就是我们识别我们的领域和子领域的地方;然后,我们开始为每个领域单独定义服务。例如,一个领域可能是客户管理。这个服务将仅处理用户账户和人口统计信息。此外,我们还可以定义预订和预约、文档管理,最后是支付。这又引出了另一个问题:当我们需要服务独立性时,这三个子领域之间存在依赖关系。使用领域驱动设计,我们确定依赖关系所在的位置,并识别可能需要重复某些实体的地方。例如,客户需要在预订和预约数据库以及支付中有所体现。如果我们为每个服务使用单独的数据库(这强烈推荐),这种重复是必要的。

微服务要求我们正确界定涉及多个服务参与的作业流程。例如,在预订时,我们需要做以下几步:

  1. 获取进行预订的客户。

  2. 确保首选时间段可用。

  3. 如果可用,生成发票。

  4. 收集付款。

  5. 确认预约。

仅这个过程就在服务之间有一些来回处理。正确编排这些服务对话对于拥有无缝系统和充分替代单体方法至关重要。因此,我们引入了各种设计模式和实现代码及基础设施的方法。尽管我们将可能复杂的操作和工作流程分解成更小、更易感知的块,但我们最终处于同样的位置,即应用需要执行特定操作,并作为一个整体满足原始需求。

图 1.3展示了微服务应用的典型架构:

图 1.3 – 每个微服务都是独立的,并在单个用户界面中统一,以实现用户交互

图 1.3 – 每个微服务都是独立的,并在单个用户界面中统一,以实现用户交互

既然你已经熟悉了单体和微服务方法之间的区别,我们可以探讨使用微服务设计模式的优缺点。

评估微服务的业务需求

如我们所见,微服务不易编写,并且伴随着许多横切关注点和挑战。在实施任何设计模式之前,始终问自己为什么?我真的需要它吗?是非常重要的。

从高层次来看,这种方法的一些好处如下:

  • 可伸缩性

  • 可用性

  • 开发速度

  • 改进数据存储

  • 监控

  • 部署

在接下来的章节中,我们将深入探讨每个细节。

可扩展性

在单体方法中,要么全部扩展,要么不扩展。在微服务中,更容易扩展应用程序的各个部分,并针对出现的特定性能差距进行处理。如果疫苗变得广泛可用,并且鼓励客户在线预约,那么在最初的几周内,我们肯定会经历大量负载。我们的客户微服务可能不会受到太大影响,但我们需要扩展我们的预订和预约以及支付服务。

我们可以实现横向扩展,这意味着当负载增加时,我们可以分配更多的 CPU 和 RAM。或者,我们可以通过创建更多实例来垂直扩展服务以进行负载均衡。更好的方法取决于服务的需求。使用合适的托管平台和基础设施,我们可以自动化这一过程。

可用性

可用性意味着在特定时间系统处于操作状态的概率。这个指标与可扩展性能力密切相关,但也解决了底层代码库和托管平台的可靠性问题。代码库在这方面起着重要作用,所以我们希望尽可能避免单点故障。如果单点故障在任何时候失败,它将影响整个系统。例如,我们将探索网关模式,其中我们将所有服务聚合在一个入口点后面。为了确保我们的分布式服务保持可用,这个网关必须始终在线。

这可以通过拥有垂直实例来实现,这些实例平衡负载并分配网关的预期响应性,以及由此产生的底层服务的响应性。

开发速度

由于应用程序已被划分为域,开发者可以集中精力确保他们的功能集被高效地开发。这也促进了功能的快速添加、测试和部署。现在,为每个子域设立一个团队将变得实际可行。此外,确定域的需求并专注于较少的功能需求变得更加容易。每个团队现在都可以独立,并从开发到部署拥有服务。

这使得敏捷和 DevOps方法更容易实施,并且更容易为每个团队确定资源需求。当然,我们已经看到服务仍然需要通信,因此我们仍然需要在团队之间进行集成编排。所以,虽然每个团队都是独立的,但他们仍然需要使他们的代码和文档易于访问。版本控制也变得很重要,因为服务将随着时间的推移而更新,但这必须是一个管理过程。

改进数据存储

我们的单体应用程序使用一个数据库来处理整个应用程序。有时你可能会使用一个数据库来处理多个微服务,但这通常是不被推荐的,并且更倾向于每个服务一个数据库的方法。服务必须是自治的,并且可以独立开发、部署和扩展。如果每个服务都有自己的数据存储,这将更有意义。考虑到存储的数据类型可能会影响所使用的数据存储类型,这一点尤其正确。每个服务可能需要不同类型的数据存储,从关系型数据库存储如Microsoft SQL Server到基于文档的数据库存储如Azure Cosmos DB。我们希望确保数据存储的更改只会影响相关的微服务。

当然,这也会带来自己的挑战,因为需要在不同服务之间同步数据。在单体架构中,我们可以将所有步骤包裹在一个事务中,这可能会对可能长时间运行的过程造成性能问题。在微服务架构中,我们面临着编排分布式事务的挑战,这也引入了性能风险,并威胁到我们数据的即时一致性。在这种情况下,我们必须转向最终一致性的概念。这意味着当服务的数据发生变化时,它会发布一个事件,订阅服务使用该事件作为更新自身数据的信号。这种做法是通过事件溯源模式实现的。我们接受在一段时间内,数据可能在子域之间不一致的风险。通常使用KafkaRabbitMQAzure Service Bus这样的消息队列系统来完成这项工作。

监控

分布式系统最重要的方面之一是监控。这使我们能够主动确保正常运行时间并减轻故障。我们需要能够查看我们的服务实例的健康状况。我们也开始考虑如何以统一的方式集中日志和性能指标,从而避免手动访问每个环境。KibanaGrafanaSplunk等工具允许我们创建丰富的仪表板,并可视化有关我们服务的各种信息。

一项非常重要的信息是健康检查。有时,一个微服务实例可能正在运行,但无法处理请求。例如,它可能已经耗尽了数据库连接。通过健康检查,我们可以快速查看服务的健康状况,并将这些数据点返回到仪表板。

记录日志也是监控和故障排除的关键工具。通常,每个微服务都会在其环境中将日志写入自己的文件。从这些日志中,我们可以看到有关错误、警告、信息和调试消息的信息。然而,这对于分布式系统来说并不高效。在这种情况下,我们使用日志聚合器。这为我们提供了一个中央区域,可以从仪表板中搜索和分析日志。您可以从几个日志聚合器中进行选择,例如 LogStashSplunkPaperTrail

部署

每个微服务都需要能够独立部署和扩展。这包括我们服务使用的所有安全、数据存储和附加资产。它们必须都存在于物理或虚拟服务器上,无论是在本地还是在云端。理想情况下,每个物理服务器都将拥有自己的内存、网络、处理和存储。虚拟基础设施可能具有相同的物理服务器,并为每个服务分配适当的资源。在这里,我们的想法是每个微服务实例与其他实例隔离,不会竞争资源。

现在,每个微服务将需要其自己的包和支撑库集。当在不同的机器(物理或虚拟)及其操作系统上配置时,这又成为另一个挑战。我们通过将每个微服务打包为容器镜像并作为容器部署来简化这一点。容器将封装构建服务所使用的技术的细节,并提供操作所需的全部 CPU、内存和微服务依赖项。这使得微服务易于在测试和生产环境之间迁移,并提供了环境一致性。

Docker 是首选的容器管理系统,与容器编排服务紧密协作。在多台机器上运行多个容器时,编排变得必要。我们需要在正确的时间启动正确的容器,处理存储考虑事项,并解决潜在的容器故障。所有这些任务都不适合手动处理,因此我们寻求 KubernetesDocker SwarmMarathon 的服务来自动化这些任务。最好将所有部署步骤自动化,并尽可能降低成本。

然后,我们寻求实施一个集成的管道,以尽可能少的努力处理服务的持续交付,同时保持尽可能高的一致性水平。

在本节中,我们已经探讨了相当多。我们回顾了为什么我们可能会考虑在我们的开发工作中使用微服务方法。我们还调查了一些最常用的技术。现在,让我们将注意力转向证明我们使用微服务的合理性。

确定实施微服务的可行性

随着我们探索微服务方法,我们看到它确实解决了某些问题,同时也引入了一些新的关注点。微服务方法当然不是解决你的架构挑战的救世主,它引入了许多复杂性。这些关注点和复杂性通常通过设计模式来解决,使用这些模式可以节省时间和精力。

在本书中,我们将探讨我们面临的最常见问题,并查看帮助我们解决这些关注点的设计模式概念。这些模式可以按以下方式分类。

让我们探索每个模式包含的内容:

  • 集成模式:我们已经讨论过,微服务需要相互通信。集成模式旨在统一我们完成这一目标的方式。集成模式管理我们用于实现跨服务通信的技术和技巧。

  • 数据库和存储设计模式:我们知道,在管理分布式服务中的数据时,我们将面临挑战。为每个服务提供自己的数据库似乎很简单,直到我们需要确保数据在不同数据存储之间保持一致性。有一些模式对我们保持对每次操作后所看到内容的信心至关重要。

  • 弹性、安全和基础设施模式:这些模式旨在为即将到来的风暴带来平静和安慰。鉴于我们已经确定的所有移动部件,确保尽可能多的自动化和部署一致性至关重要。此外,我们还想确保在系统需求和良好的用户体验之间平衡安全。这些模式帮助我们确保我们的系统始终以峰值效率运行。

接下来,让我们讨论将.NET Core 作为我们的微服务开发堆栈。

微服务和.NET Core

本书讨论了使用.NET Core 实现微服务和设计模式。我们已经提到,这种架构风格是平台无关的,并已使用多个框架实现。然而,相比之下,ASP.NET Core 使微服务开发变得非常简单,并提供了许多好处,包括云集成、快速开发和跨平台支持:

  • 在你的电脑上运行dotnet new webapi。如果你更喜欢功能齐全的 Visual Studio IDE,那么你可能仅限于 Windows 和 macOS。无论操作系统如何,你都将拥有成功所需的所有工具。

  • 稳定性:在撰写本书时,最新的稳定版本是.NET 7,具有标准支持期限。.NET 开发团队始终在推动边界,并确保每个主要版本发布时保持向后兼容性。这使得升级到下一个版本变得更加容易,你不必担心一次性出现太多破坏性变化。

  • 容器化和扩展:ASP.NET Core 应用程序可以轻松地安装在Docker容器上,虽然这并不一定是新的,但我们都能欣赏到保证的渲染速度和质量。我们还可以利用 Kubernetes,并利用 K8s 的所有功能轻松扩展我们的微服务。

.NET 开发已经走得很远,现在是时候利用他们的工具和服务来推动我们能够构建的边界了。

摘要

到现在为止,我希望你对微服务有了更好的理解,为什么你可能或可能不会最终使用这种架构风格,以及使用设计模式的重要性。在这本书的每一章中,我们将探讨如何使用设计模式,结合.NET Core 和多种支持技术,开发一个坚实和可靠的基于微服务的系统。

我们将保持现实态度,探讨每个设计决策的优缺点,并研究各种技术如何在我们整合所有内容中发挥关键作用。

在本章中,我们探讨了设计单体和微服务的区别,评估了构建微服务的可行性,并探讨了为什么.NET Core 是构建微服务的优秀选择。

在下一章中,我们将探讨如何在我们的微服务应用程序中实现聚合器模式

第二章:使用聚合模式进行工作

在上一章中,我们查看了一些构成微服务的关键元素。我们将把注意力转向这些概念的实际应用,从聚合模式领域驱动设计开始。这些结合为基于微服务设计原则构建的应用程序的范围设定了前提。

聚合模式与领域驱动设计相辅相成。目前,我们将领域驱动设计简称为DDD。因此,DDD 聚合是一组领域对象,作为一个单一单元组合在一起。在实践中,我们可能会有与文档不同的客户记录,但明智的做法是将所有这些数据点显示为一个数据点,即聚合。

在阅读完本章之后,我们将能够做到以下几件事情:

  • 理解 DDD

  • 理解如何在系统过程中推导领域

  • 理解聚合及其聚合模式的重要性

  • 区分聚合和实体

  • 理解值对象及其在设计过程中的作用

技术要求

本章中使用的代码引用可以在项目仓库中找到,该仓库托管在 GitHub 上,网址为 github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch02

确保您的机器上至少安装了以下软件之一,以便能够执行此代码(使用链接下载和安装):

探索 DDD 及其重要性

DDD 是一种软件开发方法,鼓励我们作为开发者评估过程和子过程,并解析其中的所有原子元素。"原子"意味着一个过程可能有多个组成部分,虽然它们组合起来产生一个输出,但它们都有自己的程序要执行。每个子过程都可以被视为自我管理的,并且可以进一步归因于一个领域。这促使我们将单体拆分成独立的微服务,这些微服务在自己的数据上执行自己的任务。那就是一个领域。

在我们进一步深入之前,让我们花些时间探索一些关键词及其定义:

  • 模型:这些是定义领域方面并用于解决领域问题的抽象。我们将有关目标领域的信息组织成较小的部分,并称它们为模型。模型是我们设计和开发过程中的一个中心参考点。然后,这些模型可以分组到逻辑模块中,一次处理一个。一个领域包含的信息太多,无法仅用一个模型来处理,有时,信息的一部分可以简单地省略。例如,我们的医疗管理系统确实需要捕获客户信息,但我们不需要知道他们的眼睛和头发颜色。这可能是一个简单的例子,但在实际场景中可能会变得更加复杂。筛选知识体系的相关部分需要与开发者、领域专家和同行设计师的紧密合作。

  • 通用语言:这是一种特定于领域模型的独特语言,在涉及特定领域活动的背景下由团队成员使用。我们已经确定,一个领域的模型需要通过与领域专家的合作来开发。鉴于技能集和认知的差异,将存在沟通障碍。开发者倾向于思考和谈论与编程相关的概念。他们通常用继承、多态等术语思考和交谈。不幸的是,领域专家通常不知道或不关心这些。领域专家将使用他们自己的术语和开发者不理解的语言。这种沟通差距对任何团队来说都不是好兆头。

  • 边界上下文:这定义了系统或子系统的边界,它告知特定团队将执行的工作以及他们在其领域内的努力焦点。一个边界上下文是领域的逻辑边界,其中适用术语和规则。所有在这个边界内的术语、定义和概念都构成了通用语言。建立边界上下文是领域驱动设计(DDD)的核心步骤,在大型团队中战略性地用于范围大型模型。在 DDD 中,我们将较大的模型细分为边界上下文,然后我们确定它们之间存在的关联。没有现实世界示例的 DDD 中的上下文映射可能会令人困惑。让我们以我们的医疗管理系统作为两个使用边界上下文映射的示例实现的样本,并学习如何分析映射之间的关系。假设我们有文档管理和患者预约的上下文。两者都有无关和相关的概念。文档仅存在于文档管理系统中,但将引用患者。上下文映射是 DDD 中用来描述边界上下文之间关系的常用策略。

图 2.1显示了两个领域之间的关系:

图 2.1 – 每个领域都是独立的,但有时数据会重叠。预约管理和文档管理都需要患者数据

图 2.1 – 每个领域都是独立的,但有时数据会重叠。预约管理和文档管理都需要患者数据

让我们退一步,从微服务设计模式的角度评估仅构建软件所需的内容。领域是一类业务规则和操作。如果你的软件将在银行中使用,那么领域就是银行;如果它在医院中使用,那么领域就是医疗保健。

因此,我们开发的软件必须补充领域,以解决整体问题。领域的核心概念和元素必须存在于软件的设计和模型中。

探索 DDD 的优缺点

DDD 是一项重大承诺。它促进了对领域较小、个体部分的关注,并且产生的软件更加灵活。它将应用程序分解成更小的块,使我们能够更容易地修改应用程序的部分和组件,并减少副作用。应用程序的代码往往组织良好且易于测试,领域业务逻辑被隔离到特定的代码库中。即使你不会在整个项目中使用 DDD,这些原则对你的应用程序实施活动也是有益的。DDD 最好用于分解复杂业务逻辑。它不适用于具有简单需求和创建、添加、更新和删除数据业务逻辑的应用程序。DDD 耗时且需要专家级的领域知识。因此,请记住,需要非技术资源人员,并且他们必须在项目期间可用。

现在我们已经从高层次上理解了 DDD,让我们探索它所促进的概念如何与微服务设计模式相结合。

DDD 和微服务

使用 DDD 进行范围规划的应用程序的最佳实现方式是通过微服务。到目前为止,我们可以认识到微服务架构促进了将一个大型应用程序概念划分为自包含和独立的服务。因此,为了使用边界和上下文的概念,每个微服务都服务于其自身的边界上下文。每个微服务都将拥有自己的模型、语言、业务规则和技术栈。

这并不是一个万能的解决方案,因为微服务与边界上下文之间的完美对齐可能并不总是成立,但包括我们的应用程序在内的某些应用程序是微服务和 DDD 的完美候选人。

我们可以考虑许多场景,在这些场景中我们可以隔离某些不是最明显的服务,这些服务最初也没有被定义为边界上下文。以电子邮件和警报系统为例。将这些逻辑和功能放在网络应用中是足够的,这样当提交预约时,我们会向我们的患者发送确认信息,向工作人员发送警报。这是合理的,但我们可以创建基于消息队列的服务,这些服务仅用于传递这些消息。这样,网络应用的责任就更少了,我们在处理电子邮件或警报维护问题时意外修改 UI 逻辑的风险也更小。

最终,因为 DDD 建议我们将上下文分离成独立的切片,微服务架构是支持这一雄心的完美开发模式。记住,DDD 作为一项初始指南,用于制定可以独立存在的业务规则,每个微服务都将被开发来支持那一组业务规则,并以最有效和松耦合的方式实现支持服务。

现在我们已经能够理解 DDD 和微服务是如何相辅相成的,让我们开始探讨聚合模式以及如何开始评估需要捕获的不同模型和数据。

聚合模式的目的和使用

聚合模式 是 DDD 中的一种特定软件设计模式。它促进了相关实体的收集并将它们聚合为一个单元。

聚合使得在大系统中定义元素的所有权变得更加容易。没有它们,我们面临的是蔓延和试图做太多的事情的风险。在确定了领域中的不同上下文之后,我们就可以开始从可能多个上下文和来源中提取我们确切需要的数据,并对它们进行建模。

聚合和聚合根

一个聚合包含一个或多个实体和价值对象模型,它们以某种方式相互交互。这种交互鼓励我们将它们作为一个单元来处理数据变更。我们还想确保在做出更改之前,聚合始终保持一致性。在我们的医疗管理系统概念中,我们已经确定了有一个患者,他很可能还有一个地址。对患者的记录及其地址的任何更改都应该被视为一个单一的事务。我们还希望考虑聚合有根或父对象,为所有其他聚合成员提供。

聚合使得在多个对象上强制执行某些数据验证规则变得更加容易。因此,在我们目前的例子中,一个患者可以有多个地址,但至少需要有一个地址才能处于有效状态。这类约束从根的更高层级更容易全面应用。这也更容易确保数据变更遵循ACID原子性、一致性、隔离性和持久性)原则。我们将在后面的章节中进一步探讨这些内容。

聚合根还有助于我们维护不变性。不变性是不可协商的条件,确保系统的一致性。在确定什么应该是聚合根的好指标是考虑删除记录是否应该触发对层次结构中其他对象的级联删除。本质上,我们的聚合根代表了一组相关的对象,作为一个单元进行数据相关变更。

图 2.2 展示了根实体与非根实体之间的关系:

图 2.2 – 患者是我们的聚合根,以地址为例,我们还有其他与根对象相关的实体

图 2.2 – 患者是我们的聚合根,以地址为例,我们还有其他与根对象相关的实体

如您所见,患者实体扮演着聚合根的角色,并与地址实体存在关联。

总是使用图表来可视化不同实体和对象之间的关系是很好的。这有助于在范围界定练习中提供更广泛的理解,因为我们评估每个模型在领域中的作用以及我们的数据策略如何实现。

现在,让我们将目光转向更深入地探索关系。我们需要查看系统的其他部分,并确定哪些应该是子实体、父实体,或者仅仅是兄弟实体。

聚合中的关系

考虑到聚合是相关对象的集群,我们完全理解这些对象之间的关系非常重要。一般来说,我们认为关系是双向关联——也就是说,对象 A 与对象 B 相关联,反之亦然。例如,一个患者有一个预约,我们需要他们有一个预约。这种思维方式可能与领域驱动设计(DDD)的原则相矛盾,因为我们在尝试简化事情,双向关系可能会增加当前任务的复杂性。

在领域驱动设计(DDD)中,我们希望推广单向关系的概念。如果我们选择双向关系,这可能会发生,我们需要确保增加的复杂性是合理的。关系允许一个对象遍历另一个对象的详细信息。这意味着,对于患者,我们应该能够看到相关预约的所有详细信息,但在相反方向我们不需要看到患者的所有详细信息。一个简单的患者 ID 引用就足够了。如果我们引入一个完整的双向关系,那么我们就在预约和患者之间创建了一个直接的依赖关系,这并不一定正确。在定义我们的模型时,一个好的衡量标准是问自己,“我能否在不需要另一个对象的情况下定义这个对象?

一个好的指导原则是我们应该始终确保我们的聚合从根到其依赖项的单向流动,而绝不相反方向。

我们已经看到了明显且紧密相连的关系,但当关系更广泛地分散时会发生什么?让我们讨论我们如何处理跨越聚合的关系。

处理跨越聚合的关系

我们知道,聚合是我们应用程序中逻辑分组之间的边界。我们通过限制对聚合中非根对象的直接引用来强制这些隔离。在我们的患者和地址的例子中,我们可以安全地让患者记录引用地址,使我们的地址成为一个实体或值对象。

在这个关联中需要注意的关键点是,获取患者正确地址的唯一方法是通过搜索患者记录。地址不会在其他任何地方被引用。然而,患者可以从其他聚合中引用,例如从预约记录或文档中引用。因此,了解何时可以直接引用或不能直接引用一些数据是很重要的,我们可以利用这一点来指导我们可以在应用程序设计中将哪些聚合作为核心。

考虑到设计我们的数据对象用于数据库访问库,例如 Entity Framework,我们必须考虑所有创建、读取、更新和删除CRUD)操作对我们数据的影响。根据我们实体类的一般设计模式,我们会在关系中的两个实体内部放置导航对象,但如果不妥善管理,这可能会导致级联问题。这是一个关键的设计决策,因为有时删除导航对象,牺牲一些 Entity Framework 的魔力,并保持对模型如何交互的更大控制和可预测性会更好。通过仅保留外键 ID 引用,我们可以更好地强制聚合以单向方式与非根实体相关联。现在我们已经了解了聚合、聚合根以及我们如何构建它们,让我们来探讨实体,并比较它们与聚合的不同之处。

聚合与实体

如前所述,聚合在概念上由实体和相互关联的值对象组成。我们需要完全理解实体是什么以及它在我们的开发过程中扮演的角色。

实体及其必要性

在领域驱动设计(DDD)中,决策是由行为驱动的,但行为需要对象。这些对象被称为 实体。实体是系统中数据的表示,是你需要能够检索、跟踪变更并存储的东西。实体通常还有一个标识键,最常见的是自增整数或 GUID 值。在代码中,你希望创建一个基实体类型,允许你根据派生类型设置所需的关键类型。以下是一个 C# 中 BaseEntity 类的示例:

public abstract class BaseEntity<TId>
    {
        public TId Id { get; set; }
    }

BaseEntity 类将接受一个泛型 type 参数,这使得我们能够根据需要灵活地设置 ID 类型。我们还确保将类类型设置为 abstract 以防止 BaseEntity 的独立实例化。

拥有关于你打算实施的实体和数据持久性策略的心理图是一回事。但当责任落到肩上时,建模和编码往往是不同的活动。鉴于 DDD 的独特需求,有一些模式和技巧可以采用,以确保某些技术属性在我们的 DDD 风格实体中得到实施。为你的系统建立中心实体,然后围绕这个中心设计所有其他部分是很重要的。例如,可以说预约预订是我们系统中最核心的操作,因此所有其他实体都只是作为参考。在另一个范围内,患者记录管理可能被视为最关键的部分,因此我们希望尽可能使这一方面尽可能稳健。

这些场景表明,你做出的决策需要与当前任务相关。没有一种方法适合所有人,你的设计考虑需要最适合你的整体环境。除此之外,我们还需要确保在开始实施领域事件、存储库、工厂以及任何其他与业务逻辑相关的元素之前,我们对要执行的操作有一个很好的理解。

现在,让我们看看在我们的 DDD 风格系统中实体具体的使用。我们需要了解实体应该如何被使用以及它们在我们系统中的实际用途。

实体在代码中的实际用途

实体主要是由其标识符定义的,对于领域模型来说非常重要。我们精心设计和实现实体是非常关键的。

实体代表可能需要穿越多个微服务的数据,因此需要有一个可以唯一标识它在任何系统中的身份值。我们通常在关系型数据库中使用顺序整数作为我们的 ID 值,但考虑到这个限制,我们不能依赖于这个顺序值在多个数据库中使用。因此,我们通常采用GUID的使用,它是一般随机生成的字母数字字符串块。它不是顺序的,但它更容易保证一致性,因为我们是在代码中设置的,而不是依赖于数据库来设置它。

相同的身份可以在多个微服务中建模。在一个身份值在微服务之间共享的场景中,这并不一定意味着每个微服务或边界上下文中的相同属性和行为都是相同的。例如,在患者管理微服务中的患者实体可能包含我们想要范围的所有关键属性和行为,但在预约预订微服务中的相同实体可能只需要最小数据和行为,以满足预约预订过程的需求。实体的内容将始终与微服务或边界上下文的需求相关。

领域实体通常以方法的形式实现行为,以及数据属性。在领域驱动设计(DDD)中,领域实体需要实现仅对特定领域或实体有用的行为和逻辑。在我们的patient类的情况下,必须实现与验证相关的任务和操作作为方法。这些方法将处理实体的不变性和规则,以确保它们不会散布在应用层。

在这一点上,我们已经开始看到我们的实体模型可能不仅仅是具有属性的类,也可能实现行为。接下来,我们将探讨贫血和丰富领域模型以及它们的实现方式。

丰富的领域模型与贫血领域模型

在这一点上,我们应该欣赏贫血模型丰富模型之间的差异。丰富模型在本质上更偏向于行为,符合我们所描述的——即实现与领域内模型相关的任务的方法。贫血模型更侧重于数据,并且倾向于仅实现属性。贫血模型通常作为子实体实现,其中没有特殊逻辑。逻辑是在聚合根或业务逻辑层中实现的。贫血领域模型使用过程式编程风格实现。这意味着模型没有行为,仅暴露出将要存储的数据点的属性。然后我们倾向于将所有行为放在业务层的service对象中,从而冒着最终得到乱炖代码的风险,从而失去了领域模型提供的优势。

让我们来看一个更简单或贫血的实体模型:

    public class Patient : BaseEntity<int>
    {
        public Patient(string name, string sex, int? 
          primaryDoctorId = null)
        {
            Name = name;
            Sex = sex;
            PrimaryDoctorId = primaryDoctorId;
        }
        public Patient(int id)
        {
            Id = id;
        }
        private Patient() // required for EF
        {
        }
        public string Name { get; private set; }
        public string Sex { get; private set; }
        public int? PrimaryDoctorId { get; private set; }
        public void UpdateName(string name)
        {
            Name = name;
        }
        public override string ToString()
        {
            return Name.ToString();
        }
    }

通过要求在对象实例化时设置值,可以在你的类中强制执行封装。最终,你对模型丰富程度或贫血程度的选择取决于微服务的使用或一般操作。贫血模型可能非常适合更简单的 CRUD 服务,而领域驱动设计(DDD)可能对于设计系统来说过于复杂。它们更简单地用于建模我们的持久化模型,因为我们只使用模型进行数据存储和 CRUD 操作。在下面的代码块中,我们将查看一个Appointment类作为丰富领域模型实现的示例,包括处理某些关键数据操作的逻辑。

示例已经被分解成更小的代码块,以突出丰富数据模型的不同一般组件:

public class Appointment : BaseEntity<Guid>
{
        public Appointment(Guid id,
          int appointmentTypeId,
          Guid scheduleId,
          int doctorId,
          int patientId,
          int roomId,
          DateTime start,
          DateTime end,
          string title,
          DateTime? dateTimeConfirmed = null)
{
            Id = id;
            AppointmentTypeId = appointmentTypeId;
            ScheduleId = scheduleId;
            DoctorId = doctorId;
            PatientId = patientId;
            RoomId = roomId;
            Start = start;
            End = end;
            Title = title;
            DateTimeConfirmed = dateTimeConfirmed;
        }

至少,我们需要确保使用构造函数来强制执行对象创建规则。我们列出在创建时所需的最小值,并在创建时进行赋值。在这个阶段,包括验证检查和/或默认值也是常见的做法:

        public void UpdateRoom(int newRoomId)
       {
            if (newRoomId == RoomId) return;
            RoomId = newRoomId;
        }
        public void UpdateStartTime(DateTime newStartTime,)
        {
            if (newStartTime == Start) return;
            Start = newStartTime;
        }
        public void Confirm(DateTime dateConfirmed)
        {
            if (DateTimeConfirmed.HasValue) return;
            DateTimeConfirmed = dateConfirmed;
        }
    }

我们还有一些在方法中实现的行为示例。传统上,你可能会想在业务逻辑层或存储库中实现这些,但对于丰富数据模型,我们为其配备了所需的方法来根据需要改变其数据。我们也可以在这些方法中实现我们自己的验证规则。

现在我们已经了解了什么是实体,以及它们创建的规则,并且有了一般指南来了解它们如何实现,我们现在可以探索值对象以及它们在我们领域模型中与实体有何不同。

理解和使用值对象

我们已经观察到了实体对象应该通过哪些主要属性来识别,这些属性是连续性和身份,而不是它们的值。这让我们提出了一个问题,我们该如何称呼那些确实由它们的值定义的对象?这些是值对象。它们在领域模型中也有它们的位置,因为它们用于衡量和量化领域的一部分。它们不像实体那样拥有身份键,但它们的键是通过所有属性值的组合形成的,因此得名值对象

由于它们存储的数据对于定义它们在我们系统中的身份和唯一性至关重要,因此一旦创建,这些对象永远不应改变,并且是不可变的。了解实体模型和值对象之间的差异也很重要。

图 2.3 展示了实体与值对象之间的比较:

图 2.3 – 值对象与领域实体在本质上不同,理解这些差异很重要

图 2.3 – 值对象与领域实体在本质上不同,理解这些差异是很重要的

不可变性意味着一旦创建了这些对象,它们的属性就不应该改变。相反,在必要时应该创建另一个具有新预期值的实例。如果这些对象需要比较,我们可以通过比较所有值来完成。自从 C# 10 引入record类型以来,这已经变得更容易且更实用了。record类型与类和结构体不同,因为record类型是基于值相等性进行比较的。如果记录定义相同,并且两个记录中每个字段的值都相等,则认为两个record对象是相等的。

值对象允许有方法和行为,但它们的范围应该有限。方法应该只计算,永远不要改变值对象的状态,或其中的值,并注意它是不可变的。记住,如果需要新值,我们应该为那个目的创建一个新的对象。

让我们更深入地探讨一下值对象的基础知识。事实上,我们在开发任务中经常使用它们,可能都没有注意到。这些值对象的常见例子包括字符串对象。在.NET 和大多数其他语言中,字符串是一系列字符,或者是一个char数组。这个字符集合赋予了字符串一个值或特定的含义。如果我们改变字符数组中的一个值,或者重新排序它们,那么整个字符串的含义就会改变。在.NET 中,通过允许我们更改字母大小写或提取字符串一部分的字符串操作方法,相对容易地增强这些值。在进行这些操作时,我们实际上并没有改变字符串的值,但我们最终得到了一个具有新值的全新对象。正如我们所说的,不可变对象的价值不会改变,但如果需要改变,必须创建一个新的对象。

在为您的系统定义值对象时,重要的是从一开始就评估所有必要的信息,以确保它们无懈可击。确保信息正确的一个好例子是体重。存储患者的数据并声明他们的体重为 50 公斤是足够容易的。但仅仅 50 这个数字本身是毫无意义的,考虑到有这么多可能的计量单位。因此,在实际应用中,这个值没有单位就没有意义。50 磅(磅)和 50 公斤是完全不同的测量单位。我们还需要确保用于存储这些信息的classrecord类型对一次可以更新的值有限制。例如,改变数值是可以的,因为一个人可能会增重或减重,但允许单独更新单位可能会对数值在体重变化方面的实际含义产生更深远的影响。确保单位只能在同时设置体重数值时设置将是一个好主意。您还可以考虑预约安排。我们绝不应该接受没有伴随结束日期和时间的开始日期和时间。如果我们在一个record类型中设置这个预约的开始和结束日期时间组合,那么将大大简化执行冲突的等价性检查,我们也不需要用重载和过多的逻辑来使我们的代码杂乱无章,以确保预约时间对系统是可接受的。

这里的最重要的目标,再次强调,是确保值对象的状态在创建后不被改变。因此,无论何时您选择使用recordclass类型,值都应该在对象创建时通过构造函数设置,所有验证和不变性检查也必须在构造函数中进行。值也应该通常设置为只读类型,以防止超出该范围的修改。但请记住,对于class类型,您需要确保包含适当的等价性比较逻辑,因为record类型内置了基于值等价语义的等价性比较。

我们已经探讨了值对象以及它们与实体如此不同的原因。我们还回顾了在 C#代码中实现它们的最佳方式,以确保它们的独特特性。

摘要

在本章中,我们探讨了相当多的事情。我们试图理解领域驱动设计(DDD)的基本原理以及它为何与常规软件设计方法如此不同。然后,我们将 DDD 的元素分解为数据对象和期望。最后,我们回顾了值对象,并探讨了在什么情况下我们会制定它们,以及如何在 C#中实现它们的最佳方式。

在下一章中,我们将探讨责任链模式以及如何在我们的微服务之间最佳地实现同步通信。

第三章:微服务之间的同步通信

在上一章中,我们学习了聚合器模式以及它们如何帮助我们为微服务定义存储考虑因素。现在,我们将关注服务在应用程序运行时如何相互通信。

我们已经确定微服务应该是自治的,并且应该处理所有与要完成的领域操作片段相关的操作。尽管它们在设计上是自治的,但现实情况是,某些操作在产生最终结果之前需要从多个服务中获取输入。

在那个阶段,我们需要考虑促进通信,其中一个服务将调用另一个服务,等待响应,然后根据该响应采取一些行动。

在阅读本章之后,我们将能够做到以下几点:

  • 理解为什么微服务需要通信

  • 理解使用 HTTP 和 gRPC 的同步通信

  • 理解微服务通信的缺点

技术要求

本章中使用的代码参考可以在 GitHub 上的项目仓库中找到,该仓库托管在github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch03

同步通信的使用案例

考虑到我们迄今为止关于服务独立性和隔离性的所有讨论,你可能想知道为什么我们需要涵盖这个主题。现实情况是,每个服务覆盖了我们应用程序流程和操作的一个特定部分。一些操作有多个步骤和部分需要由不同的服务完成,因此,正确界定可能需要的服务、何时需要以及如何最佳地实现服务之间的通信是很重要的。

微服务之间的通信需要高效。鉴于我们讨论的是多个小服务相互作用以完成一项活动,我们需要确保实现也是健壮的、容错的,并且通常有效。

图 3.1概述了微服务之间的同步通信:

图 3.1 – 一个请求可能需要几个后续调用到其他服务

图 3.1 – 一个请求可能需要几个后续调用到其他服务

现在我们已经了解了为什么服务需要通信,让我们讨论围绕服务间通信的不同挑战。

微服务通信的挑战

在这一点上,我们需要接受我们正在构建一个比单体架构更为复杂和分布式的系统。在导航网络服务调用的一般请求-响应周期、适当的协议以及如何处理故障或长时间运行的过程时,这会带来它自己的挑战。一般来说,我们在同步异步通信中有两种广泛的通信类别。除此之外,我们需要确定操作的性质并据此做出调用。如果操作需要立即响应,那么我们使用同步技术;对于不需要立即响应的长时间运行过程,我们将其异步化。

如前所述,我们需要确保我们的服务间操作具有以下特点:

  • 性能:在开发解决方案时,性能总是我们心中所考虑的事情。个别来说,我们需要每个服务尽可能高效,但这种要求也扩展到通信场景。我们需要确保当一个服务调用另一个服务时,调用是使用最有效的方法进行的。由于我们主要使用REST APIHTTP通信将是首选方法。此外,我们可以考虑使用gRPC,这允许我们以更高的吞吐量和更低的延迟调用 REST API。

  • 弹性:我们需要确保我们的服务调用是通过耐用通道完成的。记住,硬件可能会失败,或者当服务调用正在执行时,可能会出现网络中断。因此,我们需要考虑两种将我们的服务变得弹性的模式:重试断路器

    • 重试模式:瞬态故障很常见,暂时的故障可能会阻碍操作完成。然而,它们往往会自行消失,我们更愿意尝试操作几次,而不是完全失败应用程序的操作。使用这种模式,我们根据配置重试我们的服务调用几次,如果没有成功,则触发超时。对于增强数据的操作,我们需要更加小心,因为请求可能会被发送,瞬态故障可能会阻止响应被发送。这并不意味着操作实际上没有完成,重试可能会导致不希望的结果。

    • 断路器模式:这种模式用于限制我们尝试调用服务次数的次数。多次调用可能会因为瞬态故障解决所需的时间过长,或者服务请求的数量可能导致可用系统资源和分配出现瓶颈。因此,使用这种模式,我们可以配置它来限制我们尝试调用一个服务所花费的时间。

  • 跟踪和监控:我们已经确定单个操作可以跨越多个服务。这给通过所有服务进行监控和跟踪活动带来了另一个挑战,从单一源头开始。此时,我们需要确保我们使用的是一款能够处理分布式日志并将它们全部聚合到中央位置以便于查阅和问题跟踪的适当工具。

既然我们已经清楚地了解了为什么我们需要进行通信以及我们可能会面临哪些挑战,我们将探讨同步通信的实际应用场景。

实现同步通信

同步通信意味着我们从一项服务直接调用另一项服务并等待响应。考虑到我们可以实施的所有安全措施和重试策略,我们仍然根据是否收到对调用响应来评估调用的成功。

在我们的医院管理系统背景下,前端的一个简单查询需要同步执行。如果我们需要查看系统中的所有医生以向用户展示列表,那么我们需要直接调用医生的 API 微服务,该服务从数据库中检索记录并返回数据,很可能会以 200 响应表示成功。当然,这需要尽可能快和高效地完成,因为我们希望减少用户等待结果返回的时间。

我们可以使用多种方法来发起 API 调用,其中 HTTP 是最受欢迎的。大多数语言和框架都支持 HTTP 调用,C#和.NET 也不例外。HTTP 的一些优势体现在标准化的报告方法;能够缓存响应或使用代理;标准的请求和响应结构;以及响应负载的标准。HTTP 请求的负载通常在 JSON 格式。虽然可以使用其他格式,但由于其通用、灵活且易于使用的数据表示结构,JSON 已成为 HTTP 负载的事实标准。遵循 HTTP 标准的 RESTful API 服务将以资源的形式表示可用信息。在我们的医院管理系统中,资源可以是医生病人,这些资源可以通过使用标准的 HTTP 动词(如GETPOSTPUTDELETE)进行交互。

现在我们已经可以可视化微服务中同步 HTTP 通信的过程,让我们来看看在.NET 中可以使用的编码技巧,以促进通信。

实现 HTTP 同步通信

在本节中,我们将查看一些示例代码,展示如何在.NET 应用程序中调用 API 时进行 HTTP 通信。

基于 HTTP 的 API 通信的当前标准是REST表征状态转移)。RESTful API 公开了一组方法,允许我们通过标准的 HTTP 调用访问底层功能。通常,一个调用或请求包括一个 URL 或端点、一个动词或方法以及一些数据。

  • URL 或端点:请求的 URL 是 API 的地址以及你试图与之交互的资源。

  • GET(检索数据)、POST(创建记录)、PUT(更新数据)和DELETE(删除数据)。

  • 对预订微服务的POST请求需要包含需要创建的预订的详细信息。

本次对话的下一重要部分以响应或 HTTP 状态码的形式出现。HTTP 定义了标准的状态码,我们使用这些状态码来解释请求的成功或失败。状态码的分类如下所示:

  • 1xx (信息性): 这传达了协议级别的信息。

  • 2xx (成功): 这表示请求已被接受,并且在处理过程中没有发生错误。

  • 3xx (重定向): 这表示需要采取替代路线才能完成原始请求。

  • 4xx (客户端错误): 这是由于请求产生的错误的一般范围,例如数据格式不良(400)或地址错误(404)。

  • 5xx (服务器错误): 这些错误表示服务器由于某些不可预见的原因未能完成任务。在构建我们的服务时,我们正确记录请求的格式以及确保我们的响应与实际结果保持一致是非常重要的。

appsettings.json中,然后我们可以有一个常量类,其中我们定义服务的行为或资源。

我们的appsettings.json文件将装饰以下块,这允许我们从应用程序的任何地方访问服务地址值:

"ApiEndpoints": {
    "DoctorsApi": "DOCTORS_API_ENDPOINT",
    "PatientsApi": "PATIENTS_API_ENDPOINT",
    "DocumentsApi": "DOCUMENTS_API_ENDPOINT"
  }

每个服务的配置值将与相应网络服务的发布地址相关。这可能是一个用于开发的本地主机地址,一个服务器上的发布地址,或一个容器实例。我们将使用该基本地址以及我们的端点,我们可以在我们的静态类中定义端点。

为了使我们的代码保持一致性,我们可以实现一个基线代码来制作和处理 HTTP 请求和响应。通过使代码通用,我们传递预期的类类型、URL 以及可能需要的任何附加数据。此代码看起来可能如下所示:

    public class HttpRepository<T> : IHttpRepository<T> where T : class
    {
        private readonly HttpClient _client;
        public HttpRepository(HttpClient client)
        {
            _client = client;
        }
        public async Task Create(string url, T obj)
        {
            await _client.PostAsJsonAsync(url, obj);
        }
        public async Task Delete(string url, int id)
        {
            await _client.DeleteAsync($"{url}/{id}");
        }
        public async Task<T> Get(string url, int id)
        {
            return await _client.GetFromJsonAsync<T>($"{url}/
              {id}");
        }
        public async Task<T> GetDetails(string url, int id)
        {
            return await _client.GetFromJsonAsync<T>($"{url}/
              {id}/details");
        }
        public async Task<List<T>> GetAll(string url)
        {
            return await _client.
              GetFromJsonAsync<List<T>>($"{url}");
        }
        public async Task Update(string url, T obj, int id)
        {
            await _client.PutAsJsonAsync($"{url}/{id}", obj);
        }
    }

HttpClient 类可以被注入到任何类中,并在任何 ASP.NET Core 应用程序中即时使用。我们创建一个通用的 HTTP API 客户端工厂类,作为HttpClient类的包装,以便标准化所有源自应用程序或微服务的 RESTful API 调用。任何需要促进与另一个服务 RESTful 通信的微服务都可以实现此代码并相应地使用它。

现在我们对如何通过 HTTP 方法处理调用有了概念,我们可以回顾一下我们如何在服务之间设置 gRPC 通信。

实现 gRPC 同步通信

到目前为止,我们应该对 REST 以及与我们的微服务进行和之间通信的 HTTP 方法感到舒适。现在,我们将转向更详细地探索 gRPC。

RPC 代表远程过程调用,它允许我们以类似于在代码中调用方法的方式调用另一个服务。因此,在分布式系统中使用 gRPC 效果很好。通信可以更快地发生,整个框架从一开始就轻量级且性能出色。这并不是说我们应该完全用 gRPC 替换所有 REST 方法。我们现在知道我们只是选择最适合我们情况的工具并相应地使其工作,但了解 gRPC 在效率至关重要的场景中是最佳选择是很好的。

事实上,gRPC 完全由 ASP.NET Core 支持,这使得它成为我们.NET Core 基于的微服务解决方案的一个很好的候选者。鉴于其基于合同的本质,它自然地强制执行某些标准和期望,我们在创建自己的 REST API 服务类时尝试模仿这些标准和期望。它从一个名为proto的文件开始,这是一个合同文件。这个合同概述了服务器(或广播微服务)提供的属性和行为。

以下是一些代码片段(为了简洁起见,部分内容已被省略):

// Protos/document-search-service.proto

syntax = "proto3";
option csharp_namespace = "HealthCare.Documents.Api.Protos";
package DocumentSearch;
service DocumentService {
  rpc GetAll (Empty) returns (DocumentList);
  rpc Get (DocumentId) returns (Document);
}
message Empty{}
message Document {
  string patientId = 1;
  string name = 2;
}
message DocumentList{
    repeated Document documents = 1; 
}
message DocumentId {
  string Id = 1;
}

这个proto类定义了一些我们希望允许文档管理服务的方法。我们定义了一个检索所有文档的方法,以及一个基于提供的 ID 值检索文档的方法。现在我们已经定义了proto,我们将在实际的服务类中实现我们的方法:

public class DocumentsService : DocumentService.
    DocumentServiceBase
    {
        public override Task<Document> Get(DocumentId request, 
              ServerCallContext context)
        {
            return base.Get(request, context);
        }
        public override Task<DocumentList> GetAll(Empty 
              request, ServerCallContext context)
        {
            return base.GetAll(request, context);
        }
    }

在每个方法中,我们可以执行完成操作所需的操作,在这个上下文中,这将是我们数据库查询和潜在的数据转换。

接下来,我们需要确保调用服务有一个合同表示,并且知道如何进行调用。以下是我们如何连接到 gRPC 服务地址、创建客户端以及请求信息的示例:

// The port number must match the port of the gRPC server.
using var channel = GrpcChannel.ForAddress("ADDRESS_OF_
    SERVICE");
var client = new DocumentService. DocumentService 
    Client(channel);
var document = await client.Get(
                  new DocumentId { Id = "DOCUMENT_ID" });

现在我们已经看到了一些 gRPC 的代码示例,让我们来比较一下 HTTP REST 和 gRPC 的面对面比较。

HTTP 与 gRPC 通信对比

我们已经看到了如何通过 HTTP 或 RESTful 方法和 gRPC 协议与我们的服务进行交互的示例。现在,我们需要更清楚地了解在什么情况下我们会选择一种方法而不是另一种方法。

使用 REST 的好处包括以下内容:

  • 一致性:REST 提供了一个统一和标准的接口,用于向订阅者公开功能。

  • 客户端-服务器独立性:客户端和服务器应用程序之间存在明显的独立性。客户端仅与已公开或为功能所需的服务器 URI 进行交互。服务器对可能订阅的客户端一无所知。

  • 无状态:服务器不保留有关正在进行的请求的信息。它只是接收请求并生成响应。

  • 可缓存:API 资源可以被缓存,以便允许按请求更快地存储和检索信息。

注意,gRPC 确实有其优点,这就是为什么它被吹捧为 REST 通信的可行替代品。以下是一些优点:

  • 协议缓冲区:协议缓冲区(或简称为 protobuf)以二进制形式序列化和反序列化数据,由于压缩率更高,因此导致数据传输速度更快,消息大小更小。

  • HTTP2:与 HTTP 1.1 不同,HTTP2 支持预期的请求-响应流,以及双向通信。因此,如果一个服务从多个客户端接收多个请求,它可以通过同时处理多个请求和响应来实现多路复用。

你可以看到使用任何一种方法进行网络服务创建和通信的明显和非明显的优势。一些开发者认为 gRPC 是未来,鉴于其轻量级和更高效的特点。然而,REST API 仍然更为流行,更容易实现,并且有更多的第三方工具支持代码生成和文档。大多数基于微服务架构的项目都是使用 REST API 构建的,坦白说,除非你有特定的需求导致采用 gRPC 实现,否则在这个阶段大规模采用 gRPC 可能是一种风险。

既然我们已经探讨了关于同步通信及其最常用的促进方法,让我们来看看围绕我们的微服务之间进行同步通信的一些不利因素。

微服务之间同步通信的不利因素

虽然它是服务间通信的首选方法,但并不总是那个时刻的最佳选择。大多数情况甚至可能证明,一开始就选择这种方法并不是最好的主意。

请记住,我们的用户将等待服务间调用的结果在用户界面上显现出来。这意味着,无论这种通信持续多长时间,我们都有用户或用户坐在界面上等待,以便继续加载并提供结果。

从架构的角度来看,我们违反了微服务设计的一个关键原则,即服务应该独立存在,对彼此知之甚少,或者最好是根本不了解。通过让两个服务进行对话,我们就会了解另一个服务及其实现细节,而这些与服务的核心功能关系甚微。此外,这也引入了服务之间不希望看到的紧密耦合程度,对于需要与其他服务通信的每个服务来说,这种耦合程度会呈指数级增加。现在,对一个服务的更改可能会对其他服务产生不希望的功能和维护影响。

最后,如果我们最终得到一个服务调用链,这将使得跟踪和捕捉调用过程中发生的任何错误变得更加困难。想象一下,我们通过服务调用实现了一种形式的责任链,其中一个服务调用另一个服务,然后使用结果调用另一个服务,依此类推。如果我们有三个连续的服务调用,第一个调用失败,我们将得到一个错误,并且无法确定错误发生在哪个点。另一个问题可能是,我们有一些成功的调用,第一个错误打破了链,从而浪费了链中之前发生的有用性。

总是了解我们所采用技术的优缺点是件好事。我确实同意同步通信有时是必要的,但我们还必须意识到,由于它的使用,在短期和长期内都需要额外的开发努力。在这个阶段,我们开始考虑替代方案,比如异步通信和事件驱动编程。

摘要

在本章中,我们探讨了相当多的事情。我们试图了解网络资源之间的同步通信是什么,最常用的协议,以及这些技术的潜在优缺点。我们详细研究了 HTTP 通信是如何发生的,以及如何使用 C#实现,并将其与 gRPC 技术进行了比较。此外,我们还比较了这两种技术,以确保我们知道何时使用哪种技术最为合适。

在下一章中,我们将探讨服务之间的异步通信,最佳实践,以及通过这种服务到服务的通信方法可以解决哪些问题。

第四章:微服务之间的异步通信

我们刚刚回顾了微服务之间的同步通信及其优缺点。现在,我们将看看它的对立面,异步通信。

有时需要同步通信,根据正在执行的操作,这可能不可避免。它确实引入了可能的长等待时间以及某些操作中的潜在中断点。在这个时候,正确评估操作并决定是否需要来自附加服务的即时反馈以继续进行是很重要的。异步通信意味着我们向下一个服务发送数据,但不等待响应。用户会认为操作已经完成,但实际上工作是在后台进行的。

从这次回顾中可以明显看出,这种通信方法并不总是可以使用,但在我们的应用程序中高效地实现某些流程和操作是必要的。

在阅读完这一章后,我们将能够做到以下几件事情:

  • 理解异步通信是什么以及我们应该何时使用它

  • 实现发布-订阅通信

  • 学习如何配置消息总线(RabbitMQAzure Service Bus

技术要求

本章中使用的代码参考可以在项目仓库中找到,该仓库托管在 GitHub 上,网址为:github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch04

使用异步通信

在继续前进之前,让我们回顾一些重要的定义和概念。

我们将在微服务之间基本上使用两种消息模式:

  • 同步通信:我们在上一章中介绍了这种模式,其中一个服务直接调用另一个服务并等待响应

  • 异步通信:在这种模式中,我们使用消息进行通信,而不需要等待响应

有时候,一个微服务需要另一个微服务来完成一个操作,但此时它不需要知道任务的结果。让我们考虑在我们的健康管理系统成功预订预约后发送确认电子邮件的情况。想象一下用户在用户界面上等待加载操作完成并显示确认信息。在他们点击提交并看到确认信息之间,预订服务需要完成以下操作:

  1. 创建预约记录

  2. 向预约了预约的医生发送电子邮件

  3. 向预约了预约的患者发送电子邮件

  4. 为系统创建日历条目

尽管我们尽了最大努力,尝试完成这些操作的预订服务将需要一些时间,可能会导致用户体验不佳。我们也可以争论发送邮件的责任不应是预订服务的固有功能。同样,日历管理也应独立存在。因此,我们可以将预订服务的任务重构如下:

  1. 创建预约记录

  2. 同步调用邮件服务向预约的医生发送邮件

  3. 同步调用邮件服务向预约的患者发送邮件

  4. 同步调用日历管理服务为系统创建日历条目

现在,我们已经重构了预订服务,使其执行的操作更少,并将非预约预订操作的复杂性转移到其他服务上。这是一个好的重构,但我们保留了,甚至可能放大了这种设计的主要缺陷。我们仍然会在进行下一个操作之前等待一个可能耗时的操作完成,这同样存在风险。在这个时候,我们可以考虑等待这些操作响应的重要性,相对于我们在数据库中输入预订记录,这是最重要的操作,相对于让用户知道预订过程的结果。

在这种情况下,我们可以利用异步通信模式来确保主要操作完成,其他操作,如发送邮件和输入日历条目,最终会发生,而不会影响我们的用户体验。

在非常基本的情况下,我们仍然可以使用 HTTP 模式实现异步通信。让我们讨论这可以有多有效。

HTTP 异步通信

与我们迄今为止所探讨的似乎形成对比,我们实际上可以通过 HTTP 实现异步通信。如果我们评估 HTTP 通信的工作方式,我们会根据收到的 HTTP 响应形成成功或失败的评估。同步地,我们预计预订服务会调用邮件服务,并在收到 HTTP 200 OK 成功响应代码之前尝试进行下一个命令。同步地,我们实际上会在那一刻尝试发送邮件,并根据该操作的成功或失败形成我们的响应。

异步地,我们将让邮件服务响应 HTTP 202 ACCEPTED 状态码,这表示服务已接受任务并最终执行。这样,预订服务可以基于这个承诺继续其操作,并减少操作时间。在后台,邮件服务将在适当的时候执行该任务。

虽然这确实减轻了预订服务的一些压力,但还有其他模式,如 Pub-Sub 模式,可以实现以使此过程更顺畅。让我们回顾一下这个模式。

理解发布-订阅通信

发布-订阅模式已经获得了相当多的流行度和赞誉,并且在分布式系统中被广泛使用。Pub代表发布者,而Sub代表订阅者。本质上,这种模式围绕将数据(在上下文中称为消息)发布到一个中介消息系统,该系统可以描述为具有弹性和可靠性,然后让订阅应用程序监控这个中介系统。一旦检测到消息,订阅应用程序将根据需要进行处理。

理解消息队列

在我们探索发布-订阅方法之前,我们需要了解消息系统的基本原理以及它们是如何工作的。我们将首先查看的模型被称为消息队列。消息队列通常被实现为两个系统之间的桥梁,即发布者和消费者。当发布者将消息放入队列时,消费者会尽快处理消息中的信息。队列强制执行先进先出FIFO)的交付方法,因此处理顺序总是可以得到保证。这通常在点对点通信场景中实现,因此为每个订阅应用程序分配一个特定的队列。支付系统往往大量使用这种模式,其中提交支付的顺序很重要,并且它们需要确保指令的可靠性。如果你这么想,支付系统通常具有非常低的故障率。大多数时候,当我们提交支付请求时,我们可以放心,它将在未来的某个时刻成功完成。

图 4.1 展示了一个发布者与几个消息队列的交互。

图 4.1 – 每个消息队列确保每个应用程序获得所需的确切数据,而不多余任何数据

图 4.1 – 每个消息队列确保每个应用程序获得所需的确切数据,而不多余任何数据

这里不幸的是,一个坏消息可能会给队列中等待的其他消息带来麻烦,因此我们必须考虑这个潜在的缺点。我们还得考虑我们 armor 上的第一个漏洞。消息在读取后也会被丢弃,因此需要对未成功处理的消息做出特殊安排。

虽然消息队列确实有其用途,并为我们的系统设计带来了一定程度的可靠性,但在分布式系统中,它们可能有点低效,并引入一些我们可能不愿意接受的缺点。在这种情况下,我们将注意力转向更分布式的中介消息设置,例如消息总线。我们将在下一节讨论这一点。

理解消息总线系统

消息总线事件总线服务总线提供接口,其中一条发布的消息可以被多个或竞争的订阅者处理。在需要将相同的数据发布到多个应用程序或服务的情况下,这很有优势。这样,我们不需要连接到多个队列来发送消息,而只需有一个连接,完成一个发送操作,就不必担心其他事情。

图 4.2显示了一个发布者与消息的交互,该消息有多个消费者。

图 4.2 – 此消息总线有多个消费者或订阅者正在监听消息

图 4.2 – 此消息总线有多个消费者或订阅者正在监听消息

回到我们的场景,在预约时存在多个操作问题,我们可以使用消息总线将数据分发到相关服务,并允许它们在自己的时间完成操作。而不是直接调用其他 API,我们的预约 API 将创建一个消息并将其放置在消息总线上。电子邮件和日历服务订阅了消息总线,并相应地处理消息。

在解耦和应用程序可伸缩性方面,这种模式有几个优点。这有助于使微服务之间更加独立,并减少未来添加更多服务时的限制。它还增加了我们服务整体交互的稳定性,因为消息总线充当完成操作所需数据的存储中介。如果一个消费服务不可用,消息将被保留,一旦恢复正常,挂起的消息将被处理。如果所有服务都在运行且消息正在积压,那么我们可以扩展服务的实例数量,以更快地减少消息积压。

你可能会遇到几种不同类型的消息,但我们将专注于本章中的两种。它们是命令事件消息。命令消息本质上请求执行某些操作。因此,我们发送给日历服务的消息会指示创建一个日历条目。鉴于这些命令的性质,我们可以利用这种异步模式,并最终处理这些消息。这样,即使大量消息也会被处理。

事件消息只是宣布某些操作已经发生。由于这些消息是在操作之后生成的,它们使用的是过去时态,并且可以发送到多个微服务。在这种情况下,发送给电子邮件服务的消息可以被视为一个事件,我们的电子邮件服务将相应地传递该信息。这种类型的信息通常只包含足够的信息,让消费服务知道哪些操作已完成。

命令消息通常针对需要发布或修改数据的微服务。直到消息被消费并采取行动,预期的数据将无法在一段时间内可用。这是异步消息模型的一个主要缺点,被称为最终一致性。我们需要更深入地探讨这一点,并发现最佳采取的方法。

理解最终一致性

微服务设计中我们面临的最大挑战之一是数据管理和制定保持数据同步的策略,这有时意味着我们需要在几个微服务数据库中保留相同数据的多个副本。最终一致性是分布式计算系统中的一个概念,它接受数据将在一段时间内不同步。这种约束仅在分布式系统和容错应用程序中是可接受的。

在单一数据库中管理一个数据集是很容易的,就像单体应用程序一样,因为数据将始终是最新的,以便其他应用程序的部分能够访问。我们的应用程序采用单一数据库方法为我们提供了我们之前讨论过的 ACID 事务的保证,但我们仍然面临着并发性管理的挑战。并发性指的是我们可能在不同的时间点有相同数据的多个版本。在单一数据库应用程序中,这个挑战更容易管理,但在分布式系统中,它提出了独特的挑战。

当数据在一个微服务中发生变化时,数据将在另一个微服务中发生变化,并在一段时间内导致数据存储之间出现一些不一致。CAP 定理引入了这样一个概念,即我们无法保证分布式系统的三个主要属性:一致性可用性分区容错性

  • 一致性:这意味着对数据存储的每次读取操作都将返回数据的当前最新版本,或者如果系统无法保证这是最新版本,则会引发错误。

  • 可用性:这意味着对于读取操作,数据总是会返回,即使这不是最新保证的版本。

  • 分区容错性:这意味着即使存在可能导致系统在正常情况下停止的短暂错误,系统也会继续运行。想象一下,我们服务之间或/和消息系统之间存在轻微的连接问题。数据更新将被延迟,但这个原则将建议我们需要在可用性一致性之间做出选择。

选择一致性或可用性是一个非常重要的决定。鉴于微服务通常需要始终可用,我们必须谨慎地做出决定,以及如何严格地在我们对系统一致性策略(最终一致性或强一致性)的约束上。

有一些场景中不需要强一致性,因为操作执行的所有工作要么已经完成,要么已回滚。这些更新要么丢失(如果已回滚),要么将在自己的时间内传播到其他微服务,而不会对应用程序的整体操作产生任何不利影响。如果选择这种模式,我们可以通过敏感化和让他们知道更新并不总是立即出现在不同的屏幕和模块中来衡量用户体验。

使用 Pub-Sub 模型是实现这种服务之间事件驱动通信的第一种方式,其中它们都通过消息总线进行通信。每个操作的完成,每个微服务都会向消息总线发布一个事件消息,其他服务最终会接收到并处理它。

要实现最终一致性,我们通常使用基于事件的发布-订阅模型进行通信。当数据在一个微服务中更新时,你可以向中央消息总线发布一条消息,其他拥有数据副本的微服务可以通过订阅总线接收通知。因为调用是异步的,单个微服务可以继续使用它们已有的数据副本来服务请求,而系统需要容忍这种情况,即使数据可能一开始并不一致,这意味着数据可能不会立即同步,最终将在微服务之间保持一致。当然,实现最终一致性可能比仅仅向消息总线发送消息要复杂得多。在本书的后面部分,我们将探讨如何减轻风险的方法。

到目前为止,我们已经探讨了使用事件驱动或 Pub-Sub 模式来促进异步通信的概念。所有这些概念都基于这样一个想法,即我们有一个消息系统在服务之间和操作之间持久化和分发消息。现在,我们需要探索一些选项,例如 RabbitMQ 和 Azure Service Bus。

配置消息总线(RabbitMQ 或 Azure Service Bus)

在对消息总线和队列进行了一番诗意般的描述之后,我们终于可以讨论两个促进基于消息的服务通信的优秀选项了。它们是 RabbitMQAzure Service Bus

这些绝对不是唯一的选择,也不是最佳选择,但它们很受欢迎,并且可以完成任务。你可能遇到的替代方案包括 Apache Kafka,以其高性能和低延迟而闻名,或者 Redis Cache,它可以作为简单的键值缓存存储,也可以作为消息代理。最终,你使用的工具取决于你的需求以及工具为你提供的上下文。

让我们探索如何在 .NET Core 应用程序中与 RabbitMQ 集成。

在 ASP.NET Core Web API 中实现 RabbitMQ

RabbitMQ 是最广泛部署和使用的开源消息代理,至少在撰写本文时是这样。它支持多个操作系统,有一个现成的容器镜像,并且是一个由多种编程语言支持的可靠的中介消息系统。它还提供了一个管理界面,允许我们作为监控措施的一部分审查消息和整体系统性能。如果您计划在本地部署消息系统,那么 RabbitMQ 是一个很好的选择。

RabbitMQ 支持两种主要方式发送消息——队列交换机。我们已经了解了队列是什么,而交换机支持消息总线模式。

让我们看看在 Windows 计算机上配置 RabbitMQ 以及发布和消费所需的支撑 C# 代码。让我们首先通过 NuGet 包含 MassTransit.RabbitMQ RabbitMQ 包。在我们的 Program.cs 文件中,我们需要确保配置 MassTransit 使用 RabbitMQ,并添加以下行:

builder.Services.AddMassTransit(x =>
{
    x.UsingRabbitMq();
});

这创建了一个可注入的服务,可以在我们代码的任何其他部分访问。我们需要将 IPublishEndpoint 注入到我们的代码中,这将允许我们向 RabbitMQ 交换机提交消息:

[ApiController]
[Route("api/[controller]")]
public class AppointmentsController : ControllerBase
{
    private readonly IPublishEndpoint _publishEndpoint;
    private readonly IAppointmentRepository
        _appointmentRepository;
    public AppointmentsController (IAppointmentRepository
        appointmentRepository, IPublishEndpoint
            publishEndpoint)
    {
        _publishEndpoint = publishEndpoint;
        _appointmentRepository = appointmentRepository;
    }
[HttpPost]
public async Task<IActionResult> CreateAppointment
    (AppointmentDto appointment)
{
       var appointment = new Appointment()
       {
         CustomerId = AppointmentDto.CustomerId,
         DoctorId = AppointmentDto.DoctorId,
         Date = AppointmentDto.Date
       });
       await _appointmentRepository.Create(appointment);
      var appointmentMessage = new AppointmentMessage()
       {
        Id = appointment.Id
         CustomerId = appointment.CustomerId,
         DoctorId = appointment.DoctorId,
         Date = appointment.Date
       });
       await _publishEndpoint.Publish(appointmentMessage);
       return Ok();
  }
}

在创建预约记录后,我们可以在交换机上共享记录的详细信息。不同的订阅者将接收到这条消息并处理他们所需的内容。消费者通常被创建为始终开启并运行的服务或后台工作服务。以下示例展示了消费者代码可能的样子:

public class AppointmentCreatedConsumer :
    IConsumer<AppointmentMessage>
{
public async Task Consume(ConsumeContext<Appointment
    Message> context)
{
   // Code to extract the message from the context and
     complete processing – like forming email, etc…
        var jsonMessage =
            JsonConvert.SerializeObject(context.Message);
        Console.WriteLine($"ApoointmentCreated message:
            {jsonMessage}");
      }
}

我们的消费者将能够接收任何 AppointmentMessage 类型的消息,并按需使用这些信息。请注意,消息交换的数据类型在生产者和消费者之间是一致的。因此,我们最好有一个位于中间的 CommonModels 项目,提供这些公共数据类型。

如果我们在控制台应用程序中实现此消费者,那么我们需要注册一个 appointment-created-event。要创建一个控制台应用程序,该程序将监听直到我们终止实例,我们需要如下代码:

var busControl = Bus.Factory.CreateUsingRabbitMq(cfg =>
{
    cfg.ReceiveEndpoint("appointment-created-event", e =>
    {
        e.Consumer<AppointmentCreatedConsumer>();
    });
});
await busControl.StartAsync(new CancellationToken());
try
{
    Console.WriteLine("Press enter to exit");
    await Task.Run(() => Console.ReadLine());
}
finally
{
    await busControl.StopAsync();
}

既然我们已经简要地了解了与 RabbitMQ 交换机通信所需的内容,让我们回顾一下与基于云的 Azure Service Bus 通信所需的内容。

在 ASP.NET Core API 中实现 Azure Service Bus

Azure Service Bus 是云基础微服务的绝佳选择。它是一个完全托管的面向企业的消息代理,支持队列以及 Pub-Sub 主题。鉴于 Microsoft Azure 强大的可用性保证,此服务支持负载均衡,如果我们选择此选项,我们可以确保消息传输的安全和协调。与 RabbitMQ 类似,Azure Service Bus 支持队列和主题。主题是交换的直接等价物,我们可以有多个服务订阅并等待新消息。让我们重用我们刚刚在 RabbitMQ 中探讨的概念,并回顾发布主题上消息所需的代码,看看消费者会是什么样子。我们将专注于本节中的代码,并假设您已经创建了以下内容:

  • 一个 Azure Service Bus 资源

  • 一个 Azure Service Bus 主题

  • 一个订阅 Azure Service Bus 主题的 Azure Service Bus 订阅

这些元素都必须存在,我们将通过 Azure 门户检索 Azure Service Bus 的连接字符串。要开始编写代码,请将 Azure.Messaging.ServiceBus NuGet 包添加到生产者和消费者项目中。

在我们的发布者中,我们可以创建一个服务包装器,它可以注入到将发布消息的代码部分。我们将有类似以下的内容:

public interface IMessagePublisher {
    Task PublisherAsync<T> (T data);
}
public class MessagePublisher: IMessagePublisher {
    public async Task PublishMessage<T> (T data, string
       topicName) {
         await using var client = new ServiceBusClient
             (configuration["AzureServiceBusConnection"]);
        ServiceBusSender sender = client.CreateSender
            (topicName);
        var jsonMessage =
            JsonConvert.SerializeObject(data);
        ServiceBusMessage finalMessage = new
            ServiceBusMessage(Encoding.UTF8.GetBytes
                (jsonMessage))
        {
            CorrelationId = Guid.NewGuid().ToString()
        };
        await sender.SendMessageAsync(finalMessage);
        await client.DisposeAsync();
}

在此代码中,我们声明了一个接口,IMessagePublisher.cs,并通过 MessagePublisher.cs 实现它。当收到消息时,我们创建 ServiceBusMessage 并将其提交到指定的主题。

我们需要确保我们注册此服务,以便它可以注入到我们代码的其他部分:

services.AddScoped<IMessagePublisher, MessagePublisher>();

现在,让我们看看同一个控制器,以及它是如何将消息发布到 Azure Service Bus 而不是 RabbitMQ 的:

[ApiController]
[Route("api/[controller]")]
public class AppointmentsController : ControllerBase
{
    private readonly IMessageBus _messageBus;
    private readonly IAppointmentRepository
        _appointmentRepository;
    public AppointmentsController (IAppointmentRepository
        appointmentRepository, IMessageBus messageBus)
    {
      _appointmentRepository = appointmentRepository;
      _messageBus = messageBus;
    }
[HttpPost]
public async Task<IActionResult> CreateAppointment
    (AppointmentDto appointment)
{
       var appointment = new Appointment()
       {
         CustomerId = AppointmentDto.CustomerId,
         DoctorId = AppointmentDto.DoctorId,
         Date = AppointmentDto.Date
       });
       await _appointmentRepository.Create(appointment);
      var appointmentMessage = new AppointmentMessage()
       {
        Id = appointment.Id
         CustomerId = appointment.CustomerId,
         DoctorId = appointment.DoctorId,
         Date = appointment.Date
       });
       await _messageBus.PublishMessage(appointmentMessage,
           "appointments");
       return Ok();
  }
}

现在我们知道了如何设置发布者代码,让我们回顾消费者所需的元素。此代码可用于后台工作者或 Windows 服务以持续监视新消息:

public interface IAzureServiceBusConsumer
    {
        Task Start();
        Task Stop();
    }

我们从定义一个接口开始,该接口概述了 StartStop 方法。这个接口将由一个消费者服务类实现,该类将连接到 Azure Service Bus 并开始执行监听 Service Bus 新消息并相应消费它们的代码:

    public class AzureServiceBusConsumer :
        IAzureServiceBusConsumer
    {
        private readonly ServiceBusProcessor
            appointmentProcessor;
        private readonly string appointmentSubscription;
        private readonly IConfiguration _configuration;
        public AzureServiceBusConsumer(IConfiguration
            configuration)
        {
           _configuration = configuration;
            string appointmentSubscription =
              _configuration.GetValue<string>
                ("AppointmentProcessSubscription")
            var client = new ServiceBusClient
                (serviceBusConnectionString);
            appointmentProcessor = client.CreateProcessor
                ("appointments", appointmentSubscription);
        }
        public async Task Start()
        {
            appointmentProcessor.ProcessMessageAsync +=
                ProcessAppointment;
            appointmentProcessor.ProcessErrorAsync +=
                ErrorHandler;
            await appointmentProcessor
                .StartProcessingAsync();
        }
        public async Task Stop()
        {
            await appointmentProcessor
                .StopProcessingAsync();
            await appointmentProcessor.DisposeAsync();
        }
        Task ErrorHandler(ProcessErrorEventArgs args)
        {
            Console.WriteLine(args.Exception.ToString());
            return Task.CompletedTask;
        }
        private async Task ProcessAppointment
            (ProcessMessageEventArgs args)
        {
            // Code to extract the message from the args,
            parse to a concrete type, and complete 
            processing – like
forming email, etc…
           await args.CompleteMessageAsync(args.Message);
        }
    }

从我们与消息总线系统交互的两个示例中,我们可以看到它们在概念上非常相似。对于 .NET Core 库支持的任何其他消息总线系统,都会采用类似的考虑和技巧。

当然,当我们实现这种基于消息的通信时,肯定会有权衡。我们现在有一个额外的系统,以及潜在的故障点,因此我们必须考虑额外的基础设施需求。我们还看到,所需的代码增加了我们的代码库的复杂性。让我们深入了解这种方法的一些缺点。

微服务之间异步通信的缺点

就像任何系统或编程方法一样,总有优势和劣势伴随着它。我们已经探讨了为什么对于可能运行时间较长的操作来说,拥有异步消息模式是一个好主意。我们需要确保最终用户不会花费太多时间等待整个操作完成。消息系统是缩短完成操作感知时间的一种极好方式,并允许服务尽可能高效地独立运行。它们还帮助解耦系统,允许更大的可扩展性,并在数据传输和处理方面为系统引入一定程度的稳定性。

现在,当我们分析这种模式可能引入的实际复杂程度时,缺点就会逐渐显现。在设计我们的服务如何与其他服务交互、需要共享哪些数据以及操作完成后需要发布哪些事件时,需要考虑更多的协调。在同步模型中,我们将更有信心确保后续任务能够完成,因为我们不能在没有下一个服务链中服务有利的响应的情况下前进。在异步模型中使用队列和总线时,我们必须依赖消费服务(或服务)发布关于完成状态的事件。我们还需要确保没有重复的调用,在某些情况下,需要共同努力确保消息按特定顺序处理。

这又引出了另一个缺点,即数据一致性。记住,消息总线最初的响应表明操作是成功的,但这仅仅意味着数据已成功提交到总线。在此之后,我们的消费服务仍然需要继续并完成它们的操作。如果其中一项或多项服务未能处理并可能将数据提交到数据存储,那么我们的数据将出现不一致。这是我们需要注意的事情,因为它可能导致有害的副作用和用户流失。

摘要

在本章中,我们探索了一些内容。我们详细比较了通过同步 API 通信与异步通信处理流程的方式。然后,我们扩展了我们关于如何利用消息系统来支持我们服务的异步通信模型的一般知识。在这个过程中,我们讨论了在操作之间可能面临的数据一致性挑战,以及我们如何衡量这个不可避免因素的可接受指标。在后面的部分,我们回顾了两种流行的消息系统,并讨论了在这个范式下我们必须应对的一些明显缺点。

在下一章中,我们将探讨命令-查询责任分离CQRS)模式以及它如何帮助我们编写更干净的服务代码。

第五章:使用 CQRS 模式进行工作

我们现在知道,在规划阶段,微服务需要一定的前瞻性,我们需要确保我们采用最佳的模式和技术来支持我们的决策。在本章中,我们将探讨另一种在帮助我们编写干净且可维护的代码方面受到广泛赞誉的模式。这就是 命令查询责任分离或分离CQRS)模式,它是 命令查询分离CQS)模式的扩展。

这种模式使我们能够干净地分离我们的查询操作和命令操作。本质上,查询请求数据,而命令应该在操作结束时以某种方式修改数据。

作为程序员,我们倾向于在我们的应用程序中使用 创建、读取、更新和删除CRUD)。考虑到每个应用程序的核心功能都是支持 CRUD 操作,这是可以理解的。但随着应用程序变得更加复杂,我们需要更多地考虑围绕这些操作的商务逻辑,相对于我们正在解决的问题域。

到那时,我们开始使用诸如 行为场景 这样的词汇。我们开始考虑以某种方式构建我们的代码,使我们能够隔离行为,并轻松地确定这种行为是否仅仅是请求数据,还是会在操作结束时通过某种方式增强数据。

在阅读本章后,你将实现以下目标:

  • 理解 CQRS 模式的优势以及为什么它被用于微服务开发

  • 了解如何在 CQRS 模式中实现命令

  • 了解如何在 CQRS 模式中实现查询

技术要求

本章中使用的代码参考可以在项目仓库中找到,该仓库托管在 GitHub 上,网址为:github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch05

为什么要在微服务开发中使用 CQRS?

CQS 被引入作为一种模式,旨在帮助开发者将执行读取操作的代码与执行写入操作的代码分离。它的不足之处在于,它没有考虑到为每个操作建立特定的模型。CQRS 在此基础上发展,并引入了为要执行的操作定制特定模型的概念。

图 5.1 展示了典型的 CQRS 架构:

图 5.1 – 应用程序将与读取操作和写入操作(称为命令)的模型进行交互

图 5.1 – 应用程序将与读取操作和写入操作(称为命令)的模型进行交互

如果你更仔细地观察,可以认为 CQS 只考虑了一个数据存储,这意味着我们正在对同一个数据库进行读写操作。CQRS 会建议你拥有单独的数据存储,可能有一个标准的关系数据库用于你的写入操作,并为另一个存储(如文档数据库或数据仓库)执行读取操作。实现多个数据存储并不总是可行的,也不是必须的。

自从 CQRS 在开发中引入以来,它获得了许多赞誉,并被誉为微服务设计中的一个非常重要的支柱。事实上,它也可以用于标准应用程序,因此它并不仅限于微服务。它还增加了开发工作的新层次复杂性,因为它引入了需要编写更多特定类和代码的需求,这可能导致项目膨胀。

人们常说,“……当我们有一把锤子时,一切看起来都像钉子……”这在当我们听到一个新的模式并觉得需要到处使用它时仍然适用。我建议在使用此模式之前要谨慎并仔细考虑,并确保其在应用程序中的价值是合理的。

对于更大的应用程序,建议使用 CQRS 来帮助我们结构化代码,并更干净地处理可能复杂的业务逻辑和移动部件。尽管它很复杂,但它确实有好处。让我们回顾一下在我们的应用程序中实现 CQRS 模式的好处。

CQRS 模式的优点

CQRS 是关于将单个模型拆分为两个变体,一个用于读取,一个用于写入。然而,最终目标是一个更大的轮辐。这种方法的第一个好处是可扩展性。

评估你正在构建的系统的读写工作量是很重要的。考虑到一次读取可能需要从几个表中获取大量数据,并且每个请求可能都有其自己的数据外观要求,读取操作可能比写入操作更密集。一种观点鼓励我们采用一个专门用于和优化读取操作的数据存储。这使我们能够将读取操作与写入操作分开进行扩展。

一个专门的读取存储示例可以是数据仓库,其中数据通过某种形式的数据转换管道不断被转换,从可能是规范化关系数据库的写入数据存储。另一种常用的技术是 NoSQL 数据库,如MongoDBCosmos DB。数据仓库或 NoSQL 数据库提供的数据结构代表了从关系数据存储中提取的未规范化、只读版本的数据。

图 5.2展示了具有两个数据库的 CQRS 架构:

图 5.2 – 查询模型表示从事务数据库中优化的读取操作的数据

图 5.2 – 查询模型表示从事务数据库中优化的读取操作的数据表示

第二个好处是性能。尽管它似乎与可扩展性密不可分,但我们考虑的因素有所不同。使用独立的数据存储并不总是可行的选择,因此我们可以采用其他技术来优化我们的操作,而这些技术在不使用统一数据模型的情况下是不可能的。例如,我们可以应用缓存来查询执行读取操作。我们还可以使用针对我们请求的特定数据库功能和微调的原始 SQL 语句,而不是在命令方面使用对象关系映射ORM)代码。

我们可以从这个模式中获得另一个好处,那就是简单性。考虑到我们在本章前面的部分提到了复杂性,这似乎有些矛盾,但这取决于你用来评估你将长期获得的回报的视角。命令和查询有不同的需求,使用一个数据模型来满足这两组需求是不合理的。CQRS 迫使我们考虑为每个查询或命令创建特定的数据模型,这导致代码更容易维护。每个新的数据模型都负责特定的操作,对其进行的修改将对程序的其他方面影响很小或没有影响。

我们在这里看到,在我们的项目中使用 CQRS 有一些好处。但是,有利必有弊。让我们回顾一下采用这种设计模式的一些缺点。

CQRS 模式的缺点

我们考虑的每一个模式都必须彻底调查它所增加的价值和潜在的缺点。我们总是想确保好处大于坏处,并且我们不会后悔这些重大的设计决策。CQRS 对于将执行简单的 CRUD 操作的应用程序来说并不理想。它是以行为或场景驱动的,正如我们之前所提到的,所以在你跳入之前,一定要确保你能证明用例的合理性。这个模式会导致代码感知上的重复,因为它促进了关注点分离SoC)的概念,并鼓励命令和查询有专门的模式。

说到个人观点,我见过一些开发负责人一开始充满热情,却在实际上并不需要 CQRS 的项目中实施了它。结果,项目变得过于复杂,让所有的新开发人员完全搞不清楚东西在哪里。

正如我们所见,当我们使用 CQRS 时,存在多个数据存储区域的情况。这可能是它的最完整实现。一旦我们引入多个数据存储,我们就引入了数据一致性问题,并需要实现事件溯源技术,并包括服务级别协议SLAs)来让我们的用户了解我们读写操作之间可能存在的差距。当我们拥有多个数据库时,我们也必须考虑额外的成本,包括基础设施和一般操作。随着数据库的增加,潜在的故障点也增加,我们需要额外的监控和故障安全技术来确保系统尽可能平稳运行,即使在出现故障的情况下。

当 CQRS 被合理应用时,它可以给项目带来价值。当你的项目拥有复杂的业务逻辑,或者有明确的需求来分离数据存储和读写操作时,CQRS 将作为一个合适的架构选择大放异彩。

这种模式并不特定于任何编程语言,.NET 对这种模式有很好的支持。现在让我们通过一些工具和库的帮助,回顾使用 Mediator 模式实现 CQRS。

在.NET 中使用 CQRS 的 Mediator 模式

在我们深入探讨如何实现 CQRS 之前,我们应该讨论一个支持模式,称为Mediator模式。Mediator 模式涉及我们定义一个对象,该对象体现了对象之间的交互方式。因此,我们可以避免两个或多个对象之间直接依赖,而是使用一个中介来协调依赖关系,并将请求路由到适当的处理程序。处理程序将根据模型关联的场景或任务定义操作的所有细节。

图 5.3展示了 Mediator 模式的工作方式:

图 5.3 – 查询/命令模型在中介引擎中注册,然后选择适当的处理程序

图 5.3 – 查询/命令模型在中介引擎中注册,然后选择适当的处理程序

相对于为每个任务定义的代码实现,Mediator 模式在 CQRS 的实现中变得有用,因为我们需要促进代码之间的松耦合。它允许我们保持松耦合,并促进更高的可测试性和可扩展性。

在 .NET Core 中,我们有一个名为 MediatR 的优秀第三方包,它帮助我们相对容易地实现此模式。MediatR 扮演了 进程内 中介者的角色,它管理类在同一个进程中的交互方式。这里的局限性是,如果我们想要在不同系统中分离命令和查询,它可能不是最好的包。尽管有这个缺点,但它帮助我们相对容易、高效和可靠地编写基于 CQRS 的系统。我们可以开发强类型代码,这将确保我们不会不匹配模型和处理程序,我们甚至可以构建管道来管理请求在整个周期中的整个行为。

在 .NET Core 应用程序中,我们可以按照以下步骤设置 MediatR

  1. 安装 MediatR.Extensions.Microsoft.DependencyInjection NuGet 包

  2. 使用以下行修改我们的 Program.cs 文件:builder.Services.AddMediatR(typeof(Program));

完成此操作后,我们可以继续将我们的 IMediator 服务注入到我们的控制器或端点以供进一步使用。我们还需要实现两个文件,这两个文件将直接相互关联,即命令/查询模型和相应的处理器。

既然我们已经探讨了使用中介者模式设置 CQRS 实现的基础,让我们回顾一下实现命令所需的步骤。

实现一个命令

如我们所记,命令预期将执行将增强数据存储中数据的操作,这通常称为写操作。鉴于我们正在使用中介者模式来管理我们执行这些操作的方式,我们需要一个命令模型和一个处理器。

创建一个命令模型

我们的模式相对简单易实现。它通常是一个标准的类或记录,但在 MediatR 的帮助下,我们将实现一个名为 IRequest 的新类型。IRequest 将将这个模型类与一个相关的处理器关联起来,我们将在稍后探讨这一点。

在我们系统的预约示例中,我们可以相对容易地实现一个 CreateAppointmentCommand.cs 文件,如下所示:

public record CreateAppointmentCommand (int
  AppointmentTypeId, Guid DoctorId, Guid PatientId, Guid
    RoomId, DateTime Start, DateTime End, string Title):
      IRequest<string>;

在这个例子中,我们使用了一个 record 类型,但如果你觉得更舒服,它也可以是一个类定义。请注意,我们继承自 IRequest<string>,这向 MediatR 概述了以下内容:

  • 这个命令应该与一个具有相应返回类型的处理器相关联。

  • 与此命令相关联的处理程序预期将返回一个字符串值。这将是一个预约 Id 值。

命令并不总是需要返回一个类型。如果你不期望返回类型,你可以简单地从 IRequest 继承。

现在我们已经了解了命令模型需要看起来是什么样子,让我们实现相应的处理器。

创建一个命令处理器

我们的处理器是执行任务逻辑通常所在的地方。在某些实现中,在没有丰富数据模型的情况下,你可以执行所有必要的验证和附加任务,以确保任务得到妥善处理。这样,我们可以更好地隔离在发送特定命令以执行时预期的业务逻辑。

我们用于创建预约的处理器将被命名为 CreateAppointmentHandler.cs,可以像这样实现:

public class CreateAppointmentHandler :
  IRequestHandler<CreateAppointmentCommand, string>
{
    private readonly IAppointmentRepository _repo;
    public CreateAppointmentHandler(IAppointmentRepository
      repo /* Any Other Dependencies */)
{
        _repo = repo
        /* Any Other Dependencies */
      };
    public async Task<string> Handle
      (CreateAppointmentCommand request, CancellationToken
         cancellationToken)
    {
        // Handle Pre-checks and Validations Here
        var newAppointment = new Appointment
        (
            Guid.NewGuid(),
            request.AppointmentTypeId,
            request.DoctorId,
            request.PatientId,
            request.RoomId,
            request.Start,
            request.End,
            request.Title
        );
        await _repo.Add(newAppointment);
     //Perform post creation hand-off to services bus.
        return newAppointment.Id.ToString();
    }
}

在这里有几个需要注意的事项:

  • 处理器继承自 IRequestHandler<>。在类型括号中,我们概述了处理器将要实现的特定 命令模型类型期望的返回类型

  • 处理器的实现方式与其他类相同,并且可以根据需要注入依赖项。

  • 完成我们的操作后,我们必须返回一个与命令模型中 IRequest<> 概述的数据类型相匹配的值。

  • IRequestHandler 实现了一个名为 Handle 的方法,该方法默认为附加到命令模型的 IRequest<> 的概述返回类型。它将自动生成一个名为 request 的参数,该参数的数据类型为命令模型的数据类型。

如果我们不要求返回值,我们就必须返回 Unit.Value。这是 MediatR 的默认 void 表示形式。

现在我们已经为实现命令模型及其相应处理器编写了一些样板代码,让我们看看如何从控制器中实际调用处理器。

调用一个命令

我们已经定义了用于预约操作的控制器,并且它需要具有我们的 IMediator 服务的依赖项以开始编排我们的调用。我们需要像这样注入我们的依赖项:

private readonly IMediator _mediator;
  public AppointmentsController(IMediator mediator) =>
    _mediator = mediator;

一旦我们有了这个依赖项,我们就可以开始进行调用。这个解决方案之所以如此干净,是因为我们不需要担心任何业务逻辑和特定处理器的具体实现。我们只需要创建一个具有适当数据的命令模型对象,然后使用我们的中介发送它,它将调用适当的处理器。我们的 POST 方法的代码如下所示:

[HttpPost]
public async Task<ActionResult> Post([FromBody]
  CreateAppointmentCommand createAppointmentCommand)
{
   await _mediator.Send(createAppointmentCommand);
   return StatusCode(201);
}

实现这一部分有几种方法。一些替代方案包括以下内容:

  • 使用 模型数据传输对象(或简称为 DTO)来接受来自 API 端点的数据。这意味着我们将在发送之前将数据点从传入的模型对象传输到我们的命令模型,如下所示:

    [HttpPost]
    
    public async Task<ActionResult> Post([FromBody]
    
      AppointmentDto appointment)
    
    {
    
        var createAppointmentCommand = new
    
          CreateAppointmentCommand { /* Assign all values
    
            here*/}
    
       await _mediator.Send(createAppointmentCommand);
    
       return StatusCode(201);
    
    }
    
  • 我们不是在命令模型中定义所有数据属性,而是使用 DTO 作为命令模型的一个属性,这样我们就可以将它与中介请求一起传递,如下所示:

    // New Command Model with DTO property
    
      public record CreateAppointmentCommand
    
        (AppointmentDto Appointment) : IRequest<string>;
    
    // New Post method
    
    [HttpPost]
    
    public async Task<ActionResult> Post([FromBody]
    
      AppointmentDto appointment)
    
    {
    
        var createAppointmentCommand = new
    
          CreateAppointmentCommand { Appointment =
    
            appointment; }
    
       await _mediator.Send(createAppointmentCommand);
    
       return StatusCode(201);
    
    }
    

你可以观察到编写代码的每种方法都归结为同一件事,那就是在我们将模型发送到我们的中介之前,我们需要将适当的模型类型与适当的值组合起来。中介已经确定了哪个模型与哪个处理程序相匹配,并将继续执行其操作。

命令和查询通常遵循相同的实现。在下一节中,我们将探讨使用中介和 CQRS 模式实现查询模型/处理程序对。

实现查询

查询预计将搜索数据并返回结果。这种搜索可能很复杂,也可能足够简单。然而,事实是我们将这种模式实现为一个简单的方法,将查询逻辑与请求的发起者(如控制器)和命令逻辑分开。这种分离增加了团队维护应用程序任一方面的能力,而不会相互干扰,换句话说。我们将类似地使用中介模式来管理我们执行这些操作的方式,并且我们需要一个查询模型和一个处理程序。

创建查询模型

我们的模式足够简单,我们可以让它为空,或者包含在处理程序中将要执行的过程中的属性。我们继承了IRequest<>,它定义了返回类型。我敢说,考虑到这是一个查询,返回类型是必要的。

让我们看看两个查询的例子,一个将检索数据库中的所有预约,另一个将仅通过 ID 检索。我们可以像这样定义GetAppointmentsQuery.csGetAppointmentByIdQuery.cs

public record GetAppointmentsQuery(): Irequest
  <List<Appointment>>;
public record GetAppointmentByIdQuery(string Id):
  IRequest<AppointmentDetailsDto>;

只要注意到,任何查询模型都是特定于它需要表示的内容。GetAppointmentsQuery模型不需要任何属性,因为它只是将被用作处理程序创建关联的轮廓。GetAppointmentByIdQuery有一个Id属性,这是显而易见的原因,因为 ID 将需要处理程序正确执行当前任务。返回类型的差异也是一个需要注意的关键点,因为它为处理程序可以返回的内容定下了基调。

我们需要确保我们为预期的数据类型专门构建我们的查询模型,以便从匹配的处理程序中检索数据。现在,让我们看看如何实现这些处理程序。

创建查询处理程序

我们的查询处理程序将执行预期的查询,并按照IRequest<>定义的方式返回数据。如前所述,理想的使用方式将看到我们使用一个单独的数据存储,其中被查询的数据已经针对返回进行了优化。这将使我们的查询操作高效,并减少数据转换和清理的需求。

在我们的例子中,我们并不那么幸运地拥有一个独立的数据存储,所以我们将会使用同一个仓库来查询事务数据存储。我们用于获取所有预约的查询处理器将被命名为 GetAppointmentsHandler.cs,并且可以像这样实现:

public class GetAppointmentsHandler : IrequestHandler
  <GetAppointmentsQuery, List<Appointment>>
    {
        private readonly IAppointmentRepository _repo;
        public GetAppointmentsHandler
          (IAppointmentRepository repo) => _repo = repo;
        public async Task<List<Appointment>>
           Handle(GetAppointmentsQuery request,
             CancellationToken cancellationToken)
        {
            return await _repo.GetAll();
        }
    }

这个处理器的定义很简单,因为我们只是检索一个预约列表。当我们需要通过 ID 获取一个预约时,这意味着我们需要预约的详细信息。这将需要一个更复杂的查询,可能涉及连接,或者更好的是,需要同步 API 调用来获取相关记录的详细信息。这正是数据库设计的优点或缺点会发挥作用的地方。如果我们按照我们的 AppointmentDetailsDto 返回,如下所示:

public class GetAppointmentByIdHandler :
  IRequestHandler<GetAppointmentByIdQuery,
    AppointmentDetailsDto>
    {
        private readonly IAppointmentRepository _repo;
        public GetAppointmentByIdHandler
          (IAppointmentRepository repo)
        {
            _repo = repo;
        }
        public async Task<AppointmentDetailsDto>
          Handle(GetAppointmentByIdQuery request,
            CancellationToken cancellationToken)
        {
            // Carry out all query operations and convert
            the result to the expected return type
            return new AppointmentDetailsDto{ /* Fill model
              with appropriate values */ };
        }
    }

在我们收集了特定场景所需的数据之后,我们构建我们的 return 对象并将其发送回原始发送者。

现在,让我们看看控制器操作看起来像什么,它们试图完成查询。

调用一个查询

使用相同的控制器,我们可以假设 IMediator 依赖项已经被注入,并执行以下代码:

[HttpGet]
public async Task<ActionResult<Appointment>> Get()
{
    var appointments = await _mediator.Send(new
      GetAppointmentsQuery());
            return Ok(appointments);
}
[HttpGet("{id}")]
public async Task<ActionResult<AppointmentDetailsDto>>
    Get(string id)
{
  var appointment = await _mediator.Send(new
    GetAppointmentByIdQuery(id));
            return Ok(appointment);
}

我们的操作看起来很相似。每个操作都定义了它从请求中需要的参数。然后它们调用 mediator 对象和期望的查询模型类型的新对象。中介者将协调调用并将请求路由到适当的处理器。

摘要

从这种开发方法中可以获益的几个好处。我们已经概述了项目膨胀是其中的一部分,但可以强制执行和保证的一致性和结构水平可能值得额外的努力。

在本章中,我们探讨了 CQRS 模式以及我们如何在微服务应用程序中应用它。我们花时间评估我们需要解决的问题,为为什么我们添加这一新的复杂层次提供实际背景。然后我们查看我们如何重构我们的代码以方便我们的处理器和请求/命令对象。我们还查看了一些关于我们的数据存储的读写操作我们可以做出的设计决策。

在下一章中,我们将探讨与我们的 CQRS 模式相结合的事件源模式,这将帮助我们保持整个应用程序中的数据相关性。

第六章:应用事件源模式

在上一章中,我们探讨了 CQRS 中的一个流行模式。这个模式鼓励我们创建代码和数据源之间的清晰分离,这些数据源控制读写操作。有了这种分离,我们可能会在操作之间出现数据不同步的情况,这引入了需要额外技术来确保数据一致性的需求。

即使没有 CQRS,我们也必须应对典型的微服务模式,其中每个服务都期望有自己的数据存储。回想一下,将会有需要在不同服务之间共享数据的情况。需要有一种机制能够充分地在服务之间传输数据,以便它们保持同步。

事件源被誉为解决这一问题的方案,其中引入了一个新的数据存储,它跟踪所有发生的命令操作。这个数据存储中的记录被认为是事件,并包含足够的信息,以便系统跟踪每个命令操作发生的情况。这些记录被称为事件,它们充当事件驱动或异步服务架构的中间存储。它们还可以作为审计日志,因为它们将存储所有必要的详细信息,以便回放对领域所做的更改。

在本章中,我们将探讨事件源模式,并证明其作为解决我们可能不同步数据库的解决方案的合理性。

在阅读本章后,你将能够做到以下事项:

  • 了解事件是什么以及事件源能为你做什么

  • 在你的应用程序代码中应用事件源模式

  • 使用 CQRS 模式在事件之间创建事件和读取状态

  • 使用关系型或非关系型数据库创建事件存储

技术要求

本章中使用的代码参考可以在 GitHub 上的项目仓库中找到,该仓库托管在github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch06

什么是事件?

在软件开发背景下,事件指的是由于某个动作完成而发生的事情。然后,事件被用来执行后台操作,如下所示:

  • 为分析目的存储数据

  • 完成动作的通知

  • 数据库审计

事件的关键属性

事件可以用来构建任何应用程序核心功能的基础。虽然这个概念可能适用于许多情况,但了解事件的一些关键属性,以及正确界定引入它们的需求,并在我们的实现中保持某些标准,对我们来说非常重要:

  • 不可变性:这个词指的是对象的不变性。在事件的上下文中,一旦某件事情发生,它就变成了事实。这意味着我们无法改变它或现实世界中的结果。我们将这个相同的特性扩展到我们的事件中,并确保它们在生成后不能被更改。

  • 单次发生:每个事件都是唯一的。一旦生成,它就不能重复。即使后来发生相同的事情,也应该被视为一个新的事件。

  • 历史性:事件应该始终代表一个时间点。这样,我们可以追溯过去发生了什么以及何时发生。这种纪律也体现在我们命名事件的方式上,我们使用过去时来描述事件。

事件在其最佳状态下不包含任何行为或业务逻辑。它们通常只作为时间点的数据收集单元,并帮助我们跟踪应用程序中不同时间点发生的事情。

既然我们已经对事件是什么以及为什么在高级别使用它们有了很好的了解,让我们专注于事件和事件源模式的更多实际用途。

事件源模式能为我做些什么?

使用微服务架构构建的应用程序被设计成有一组松散耦合且独立的微服务。采用数据库-服务模式,我们进一步将每个服务隔离,为它提供一个独立的数据存储。这现在给我们在服务之间保持数据同步带来了独特的挑战。考虑到我们需要在 ACID 原则上进行妥协,这变得更加困难。我们可以回忆一下,ACID 这个缩写代表原子性、一致性、隔离性和持久性。在这个上下文中,我们最关心的是原子性原则。我们无法保证所有的写操作都将作为一个单元完成。原子原则规定,所有数据操作应该作为一个单元完成或失败。鉴于允许使用不同的技术来处理数据存储,我们无法绝对保证这一点。

考虑到所有这些因素,我们转向一个新的模式,称为事件源,它允许我们持久化跟踪每个服务中数据发生的所有活动的消息。这个模式对于服务之间的异步通信特别有用,我们可以以事件的形式跟踪所有变化。这些事件可以充当以下角色:

  • 持久事件:事件包含足够的信息来通知和重新创建领域对象

  • 审计日志:每次更改都会生成事件,因此它们可以双重作为审计

  • 实体状态识别:我们可以使用事件按需查看实体的即时状态

跟踪实体变化的想法被称为重放。我们可以分两步重放事件:

  1. 获取为给定聚合存储的所有或部分事件。

  2. 遍历所有事件并提取相关信息以刷新聚合实例。

事件溯源本质上是通过使用聚合 ID时间戳以某种方式查询记录。聚合 ID 代表原始记录的唯一标识符列,或主键值,该记录引发了事件。时间戳代表事件被引发的时间点。为此所需的查询对于关系型和非关系型事件存储看起来相似。事件回放操作要求我们遍历所有事件,获取信息,然后改变目标聚合的状态。除了聚合 ID 和时间戳之外,我们还将拥有填充聚合所需数据位所需的所有信息。

现在,让我们回顾一下在我们的系统中使用事件的一些好处。

事件溯源的优点

我们一直在研究跟踪针对我们的数据发生的操作历史记录的想法,特别是我们的聚合。我们已经遇到了事件和回放的概念。现在让我们看看事件回放是什么,它们可能如何对我们有益,以及使用事件的其他好处。

事件回放以及我们执行更新的方式取决于聚合是否为领域类。如果聚合依赖于领域服务进行操作,我们需要明确,回放并不是关于重复或重做命令。根据我们对 CQRS 的理解,命令会改变数据库中的状态和数据。这也可能是一个可能产生事件数据的长期运行操作,我们可能不希望这样。回放是关于查看数据并执行逻辑以提取信息。另一方面,事件回放复制事件的效果并将它们应用到聚合的新实例上。总的来说,存储的事件相对于采用该技术的应用程序可能会有不同的处理方式。

事件是存储在比纯状态更低级别的数据。这意味着我们可以重复使用它们来构建我们需要的任何数据投影。在 CQRS 项目结构中的读取数据存储、数据分析、商业智能,甚至人工智能和模拟都可以使用这些数据的具体项目。从上下文来看,如果我们有一个事件流并且可以提取一个特定的子集,那么我们可以回放它们并执行即兴的计算和流程以生成定制和可能的新信息。事件是恒定的,现在和将来始终相同。这是一个额外的优点,因为我们始终可以确信我们可以依赖数据的一致性。

就像生活中一样,每一组好处都伴随着一些持续存在的弊端。让我们来探讨一下关于事件溯源的一些普遍关注点。

事件溯源的缺点

在探索事件溯源时,我们必须牢记,我们需要引入额外的数据存储和可能妨碍应用程序性能的额外服务。让我们回顾一些关注点。

性能在应用程序中始终很重要。因此,当我们引入新的模式或一系列流程时,我们应谨慎确保性能影响最小。当我们需要处理大量事件以重建数据时会发生什么?这可能会迅速变成一个基于已记录事件数量的密集型操作,而这个数量只会增长,因为事件存储将是一个只读的数据存储。

为了解决这个问题,我们会对最近修改过的聚合状态和业务实体进行快照。然后我们可以使用这些快照作为记录的存储版本,并作为数据的最新版本使用,从而避免需要迭代大量事件。这个操作最好与我们的 CQRS 模式配对的只读数据存储相辅相成。快照将用于未来的读取操作。

现在我们已经探讨了该模式的一些更严重的后果以及可以用来减少它对我们应用程序可能产生影响的技巧,让我们回顾一下事件溯源和领域事件是如何相互关联的,以便我们能够加强我们的基础知识。

什么是领域事件?

在本书的早期部分,我们讨论了将领域驱动设计(DDD)用作设计模式,帮助我们确定在开发我们的微服务应用程序时可能需要的不同服务。事件可以用于实现此模式,帮助我们在我们限定的上下文中建模预期的结果。事件的范围基于在限定的上下文中建立的通用语言,并受到领域内决策的影响。

在领域内,聚合负责创建领域事件,我们的领域事件通常基于某些用户操作或命令的结果而触发。需要注意的是,领域事件不是基于以下操作触发的:

  • 按钮点击、鼠标移动、页面滚动事件或简单的应用程序异常。事件应基于限定的上下文中建立的通用语言。

  • 来自其他系统或当前上下文之外的事件。正确建立每个领域上下文之间的边界是很重要的。

  • 简单的用户系统请求。在这个阶段,用户请求是一个命令。事件是基于命令的结果触发的。

现在我们来更好地理解为什么领域事件对于实现事件溯源模式至关重要。

领域事件与事件溯源

事件溯源被实现,以提供一个单一参考点,用于在有限上下文中发生的历史。简单来说,事件溯源使用领域事件来存储聚合体经历过的状态。我们已经看到,事件溯源将使我们存储记录 ID、时间戳以及帮助我们理解数据当时外观的详细信息。在有限上下文中正确实现 领域事件 将为事件溯源的良好实现奠定基础,因此适当的范围和实现非常重要。

使用 MediatR 库在代码中实现领域事件可以相对简单,这个库在我们的 CQRS 模式实现中起着至关重要的作用。在下一节中,我们将探讨调整我们的应用程序以实现领域事件。

在我们的应用程序中探索领域事件

现在,让我们考虑将领域事件引入我们的预约预订系统。就我们所见,当在我们的系统中预订预约时,我们需要完成几个活动。我们可能还需要扩展我们系统的功能,以支持可能需要对原始预约进行更改的想法,并且应该跟踪这些更改。

让我们使用电子邮件派发活动。这需要在预约被系统接受并保存时发生。目前,我们的 CreateAppointmentHandler 将处理该情况下所需的所有内容。然后我们遇到了分离关注点的挑战,因为我们可能不希望我们的处理器负责太多动作。我们将电子邮件派发操作分离到其自己的处理器中会做得很好。

使用 MediatR,我们可以引入一种新的处理器类型,称为 INotificationHandler<T>。这个新的基类型允许我们定义与从继承自另一个 MediatR 基类型 INotification 的数据类型建模的事件相关的处理器。这些事件类型应该根据其创建的动作来命名,以方便使用,并将作为我们的 INotificationHandler<T> 中的泛型参数。我们的 INotificationHandler<T> 基类型将被继承,以执行与所需额外动作相关的任何特定动作。

在代码中,我们希望从一些基本的基类型开始,这将帮助我们定义我们的具体事件类型。第一个将是 IDomainEvent,它将作为所有后续领域事件的基类型。其定义看起来像这样:

public interface IDomainEvent : INotification
{}

我们的用户界面继承自 MediatR 内置的 INotification 接口,因此任何派生的数据类型也将自动成为通知类型。这个 IDomainEvent 接口还帮助我们强制执行任何必须存在于任何事件对象中的数据,例如动作的日期和时间。

现在我们有了基本类型,让我们定义我们的派生事件类,用于预约创建时。我们想要确保我们的事件类型名称准确地描述了引发事件的动作。因此,我们将此事件类型命名为 AppointmentCreated。我们简单地从我们的 IDomainEvent 接口继承,然后定义与事件所需数据相对应的额外字段:

public class AppointmentCreated : IDomainEvent
    {
        public Appointment { get; set; }
        public DateTime ActionDate { get; private set; }
        public AppointmentCreated(Appointment appointment,
          DateTime dateCreated)
        {
            Appointment = appointment;
            ActionDate = dateCreated;
        }
        public AppointmentCreated(Appointment appointment)
          : this(appointment, DateTime.Now)
        {
        }
    }

在我们的 AppointmentCreated 派生事件类型中,我们为我们的预约定义了一个属性和一个构造函数,确保在创建 Appointment 对象时存在。在这种情况下,您需要决定您需要多少或多少信息来有效地处理事件。例如,某些类型的事件可能只需要预约的 ID 值。但是,请务必正确地界定范围,发送所需的所有信息。您不希望只发送 ID,然后需要查询额外的详细信息,并冒着许多事件处理器尝试仅从 ID 值获取详细信息的风险。

现在,让我们看看如何定义我们的事件类型处理器。请注意,我说的是 处理器,因为根据发生的事件,定义多个事件处理器是可能且可行的。例如,当预约被创建时,我们可能有一个处理器会更新事件存储的新记录,或者有一个处理器会发送电子邮件警报,与更新 SignalR 集线器等分开。

为了便于更新事件存储,我们首先需要定义一个看起来像这样的处理器:

public class UpdateAppointmentEventStore :
  INotificationHandler<AppointmentCreated>
    {
        private readonly AppointmentsEventStoreService
          _appointmentsEventStore;
        public UpdateAppointmentEventStore
          (AppointmentsEventStoreService
            appointmentsEventStore)
        {
            this._appointmentsEventStore =
              appointmentsEventStore;
        }
        public async Task Handle(AppointmentCreated
         notification, CancellationToken cancellationToken)
        {
            await _appointmentsEventStore.CreateAsync
              (notification.Appointment);
        }
    }

我们的 AppointmentCreated 事件类型用作我们的 INotificationHandler 的目标类型。这就是添加特定逻辑序列到引发事件的全部内容。这也帮助我们分离关注点,更好地隔离与引发事件相关的代码片段。我们的通知对象包含预约记录,我们可以轻松地使用所需的数据。

当事件发生时,此代码将自动触发并相应地处理事件存储更新操作。

让我们看看我们的将发送电子邮件警报的事件处理器:

public class NotifyAppointmentCreated :
  INotificationHandler<AppointmentCreated>
    {
        private readonly IEmailSender _emailSender;
        private readonly IPatientsRepository
          _patientsRepository;
        public NotifyAppointmentCreated(IEmailSender
          emailSender, IPatientsRepository
            patientsRepository)
        {
            this._emailSender = emailSender;
            this._patientsRepository = patientsRepository;
        }
        public async Task Handle(AppointmentCreated
         notification, CancellationToken cancellationToken)
        {
            // Get patient record via Patients API call
            var patient = await _patientsRepository.Get
              (notification.Appointment.
                PatientId.ToString());
            string emailAddress = patient.EmailAddress;
            // Send Email Here
            var email = new Email
            {
                Body = $"Appointment Created for
                  {notification.Appointment.Start}",
                From = "noreply@appointments.com",
                Subject = "Appointment Created",
                To = emailAddress
            };
            await _emailSender.SendEmail(email);
        }
    }

注意,尽管我们没有直接访问患者的记录,但我们有他们的 ID。有了这个值,我们可以进行同步 API 调用来检索可以帮助我们制作和发送通知电子邮件的额外详细信息。

同样地,如果我们想为 SignalR 操作定义事件处理器,我们可以简单地为相同的事件类型定义第二个处理器:

public class NotifySignalRHubsAppointmentCreated :
  INotificationHandler<AppointmentCreated>
{
  public Task Handle(AppointmentCreated notification,
    CancellationToken cancellationToken)
  {
    // SignalR awesomeness here
    return Task.CompletedTask;
  }
}

现在我们能够引发事件,我们可以对我们的应用程序进行一些重构以反映这一点。我们可以重构我们的 CreateAppointmentHandlerCreateAppointmentCommand 类,使它们返回 Appointment 对象而不是之前定义的字符串值:

public record CreateAppointmentCommand(int
  AppointmentTypeId, Guid DoctorId, Guid PatientId, Guid
    RoomId, DateTime Start, DateTime End, string Title) :
      IRequest<Appointment>;
public class CreateAppointmentHandler :
  IRequestHandler<CreateAppointmentCommand, Appointment>
public async Task<Appointment> Handle
  (CreateAppointmentCommand request, CancellationToken
    cancellationToken){ … }

通过这次调整,我们现在可以检索创建的预约对象,并从原始的调用代码(控制器)中发布事件。我们预约 API 的 POST 方法现在看起来是这样的:

// POST api/<AppointmentsController>
        [HttpPost]
        public async Task<ActionResult> Post([FromBody]
         CreateAppointmentCommand createAppointmentCommand)
        {
            // Send appointment information to create
               handler
            var appointment = await
              _mediator.Send(createAppointmentCommand);
            //Publish AppointmentCreated event to all
              listeners
            await _mediator.Publish(new AppointmentCreated
              (appointment));
            // return success code to caller
            return StatusCode(201);
        }

现在我们使用 MediatR 库中的 Publish 方法来引发一个事件,并且所有被定义用于监视指定事件类型的处理器都将被激活。

这些代码重构将引入更多的代码和文件,但它们确实有助于帮助我们维护一个分布式和松散耦合的代码库。通过这项活动,我们已经回顾了如何干净地引入领域事件到我们的应用程序中,现在我们需要欣赏如何存储我们的事件。

创建事件存储

在我们进入创建事件存储的范围阶段之前,对我们来说,完全理解什么是事件存储非常重要。对这一主题的简单搜索可能会从各种来源产生许多结果,每个来源都引用了不同的定义。对于这本书,我们将得出结论,事件存储 是一个有序的、易于查询的、持久化的长期记录源,它表示数据存储中实体发生的事件

在探索数据存储实现的过程中,让我们分解关键部分以及它们是如何连接起来以形成最终的事件存储。一个事件记录将包含一个聚合 ID、一个时间戳、一个 EventType 标志,以及表示该时间点状态的 数据。应用程序将在数据存储中持久化事件记录。此数据存储有一个 API 或某种形式的接口,允许为实体或聚合添加和检索事件。事件存储也可能像消息代理一样工作,允许其他服务订阅由源发布的事件。它提供了一个 API,使服务能够订阅事件。当服务在事件存储中保存事件时,它将被发送给所有感兴趣的订阅者。图 6.1 展示了一个典型的事件存储架构。

图 6.1 – 事件存储位于 API 的命令处理器和查询处理器之间,可能需要以不同的方式投影原始存储的数据

图 6.1 – 事件存储位于 API 的命令处理器和查询处理器之间,可能需要以不同的方式投影原始存储的数据

让我们回顾一下我们可以采用的存储策略。

如何存储事件

我们已经确定事件应该是不可变的,数据存储应该是追加的。有了这两个要求,我们可以推断出,在确定数据存储的范围时,我们的 dos 和 don'ts 至少需要是最小的。

领域中发生的每个变化都需要记录在事件存储中。由于事件需要包含与所记录事件相关的某些细节,我们需要在数据结构上保持一定的灵活性。一个事件可能包含来自领域多个来源的数据。这意味着我们需要利用表之间的可能关系来获取记录事件所需的所有细节。

这使得标准的关系数据库模型作为事件数据存储库变得不太可行,因为我们希望在检索事件记录进行审计、回放或分析时尽可能高效。如果我们使用关系型数据存储,那么最好有从我们打算存储的事件数据中建模的规范化表。处理事件存储的另一种更有效的方法是使用非关系型或 NoSQL 数据库。这将使我们能够以更动态的方式将相关事件数据作为文档存储。

让我们探讨一些关于在关系数据库中存储事件的选择。

使用关系数据库实现事件溯源

我们已经审查了使用关系数据库作为事件存储的一些缺点。实际上,你设计这种存储的方式以及所使用的技术的选择,将对你的实现将如何适应未来产生重大影响。

如果我们选择使用事件的非规范化表表示,那么我们最终会陷入根据多个不同事件建模多个表的兔子洞。从长远来看,这可能不可持续,因为我们需要为每个新范围的事件引入新表,并且随着事件的发展不断更改设计。

另一个选择是创建一个单独的日志表,其列与我们所概述的数据点相匹配。例如,此表及其匹配的数据类型如下所示:

Column Name Data Type
Id Int
AggregateId Guid
Timestamp DateTime
EventType Varchar
Data Varchar
VersionNumber Int
  • Id: 事件记录的唯一标识符。

  • AggregateId: 与事件相关联的聚合记录的唯一标识符。

  • Timestamp: 记录此事件时的日期和时间。

  • EventType: 这是正在记录的事件名称的字符串表示。

  • Data: 这是与事件记录相关联的数据的序列化表示。这种序列化最好在易于操作的格式中完成,例如JSON

  • VersionNumber:版本号帮助我们了解如何排序事件。它表示每个新事件在流中记录的顺序,并且应该对每个聚合是唯一的。我们可以通过在AggregateIdVersionNumber列上引入一个UNIQUE索引来对我们的记录添加约束。这将帮助我们加快查询速度,并确保我们不会重复这些值的任何组合。

所使用的数据库技术类型确实在我们如何灵活和高效地存储和检索数据方面起着作用。使用PostgreSQLMicrosoft SQL Server的后续版本将使我们能够更有效地操作数据的序列化表示。

现在我们来看看我们如何对 NoSQL 数据存储进行建模。

使用非关系型数据库实现事件溯源

NoSQL 数据库也被称为文档数据库,其特点在于能够有效地存储非结构化数据。这意味着以下内容:

  • 记录不需要满足任何最小结构。在设计阶段不需要指定列,因此根据即时需求扩展和缩减数据很容易。

  • 数据类型不是严格实现的,因此数据结构可以在任何时间点演变,而不会对先前存储的记录产生不利影响。

  • 数据可以嵌套并且可以包含序列。这一点很重要,因为我们不需要将相关数据分散在多个文档中。一个文档可以代表来自多个来源的数据的非规范化表示。

流行的文档存储示例包括MongoDBMicrosoft Azure Cosmos DBAmazon DynamoDB等。除了每个数据库选项的特定查询和集成要求之外,文档的构建和存储的概念是相同的。

我们可以用与表格类似的方式概述文档的属性。然而,在文档数据存储中,数据以 JSON 格式存储(除非特别要求或实施其他方式)。一个事件条目可能看起来像这样:

{
    "type":"AppointmentCreated",
    "aggregateId":"aggregateId-guid-value",
    "data": {
        "doctorId": "doctorId-guid-value",
        "customerId": "customerId-guid-value",
        "dateTime": "recorded-date-time",
        ...
    },
    "timestamp":"2022-01-01T21:00:46Z"
}

使用文档数据存储的另一个优点是,我们可以在物化视图中更容易地表示带有其事件历史的记录。这可能看起来像这样:

{
    "type":"AppointmentCreated",
    "aggregateId":"aggregateId-guid-value",
    "doctorId": "doctorId-guid-value",
    "customerId": "customerId-guid-value",
    "dateTime": "recorded-date-time",
    ...
    "history": [
        {
            "type":"AppointmentCreated",
            "data": {
                "doctorId": "doctorId-guid-value",
                "customerId": "customerId-guid-value",
                "dateTime": "recorded-date-time",
                ...
            },
            "timestamp":"2022-01-01T21:00:46Z"
        },
        {
            "type":"AppointmentUpdated",
            "data": {
                "doctorId": "different-doctorId-guid-value",
                "customerId": "customerId-guid-value",
                "comment":"Update comment here"
            },
            "timestamp":"2022-01-01T21:00:46Z",
            ...
        },
        ...
    ],
    "createdDate":"2022-01-01T21:00:46Z",
    ...
}

这种类型的数据表示对于检索包含所有事件的记录可能是有利的,特别是如果我们打算在用户界面上显示这些数据。我们已经生成了一个视图,它既充当了聚合数据的当前状态的快照,也充当了影响它的事件流。这种形式的数据聚合有助于我们减少一些复杂性,并保持事件检索的概念简单。

现在我们已经看到我们如何实现只读和非规范化的数据存储,让我们回顾一下我们如何使用 CQRS 模式来检索数据的最新状态。

使用 CQRS 读取状态

我们已经回顾了 CQRS 模式,并看到我们可以在哪里创建执行写操作的处理器。在本章早期,我们通过触发事件增强了命令处理器的功能,这些事件在命令完成后能够触发额外的操作。

在事件溯源模式的背景下,这个额外操作包括使用适当的数据更新我们的只读数据存储。在创建我们的查询处理器时,我们可以依赖这些表来获取最新版本的数据。这与 CQRS 模式的理想实现完美结合,其中应使用不同的数据存储来执行读取和写入操作。

这也为我们提供了一个极好的机会,让我们能够提供更多具体的数据表示,这些数据是我们希望从读取操作中展示的。然而,这种方法引入了风险,即我们的数据存储在操作之间可能会变得不同步,这是我们必须接受并尽可能减轻的风险。

现在我们已经探讨了事件、事件溯源模式和事件存储选项,让我们回顾这些概念。

摘要

事件溯源和事件驱动设计模式为我们的软件实现所需的内容带来了全新的维度。这些模式涉及额外的代码,但确实有助于我们在保持数据存储中所有变化可靠日志的同时,实现完成命令的附加业务逻辑。

在本章中,我们探讨了事件是什么,事件溯源模式的各种因素,如何在真实应用程序中实现某些方面,以及使用关系型或非关系型存储选项的优缺点。

在下一章中,我们将探讨数据库按服务模式,并查看在实现每个微服务的数据访问层时的最佳实践。

第二部分:数据库和存储设计模式

在设计微服务应用程序时,理解和实现数据管理模式和技巧至关重要。每个微服务可能需要其自己的数据库,我们需要了解管理每个数据库的复杂性以及我们如何在服务之间协调努力。在本部分结束时,你将开始欣赏在微服务应用程序中围绕数据库需要做出的艰难决定。

本部分包含以下章节:

  • 第七章使用数据库按服务模式处理每个微服务的数据

  • 第八章使用 Saga 模式在微服务间实现事务

第七章:使用数据库-按服务模式处理每个微服务的数据

在上一章中,我们探讨了事件溯源和事件存储的概念。事件溯源模式帮助我们协调微服务之间数据存储的更改。一个微服务的操作可能需要将数据发送到其他微服务。出于效率的考虑,我们创建一个事件存储作为中介区域,微服务可以订阅更改,并能够在需要时获取数据的最新版本。

这个概念围绕着每个微服务都有自己的数据库的假设。在微服务架构中,考虑到每个微服务在操作和数据需求上的基本要求,即每个微服务需要在其操作和数据需求上保持自主性,这是一种推荐的方法。

在这个概念的基础上,我们将探讨处理每个微服务数据的最佳实践和技术。

在阅读本章后,你将了解以下内容:

  • 如何利用数据库-per-Service 模式

  • 如何开发数据库

  • 如何实现存储库模式

技术要求

本章中使用的代码参考可以在项目仓库中找到,该仓库托管在 GitHub 上,网址为github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch07

如何利用数据库-按服务模式

微服务架构的一个核心特征是我们服务之间的松散耦合。我们需要保持每个服务的独立性和个性,并允许它们在需要时自主地与所需数据交互。我们希望确保一个服务对数据的操作不会妨碍另一个服务使用其自己的数据。

每个微服务将被留给定义自己的数据访问层和参数,除非故意实现,否则没有两个服务将直接访问相同的数据存储。数据不会在两个服务之间持久化,并且这种模式带来的整体解耦意味着一个数据库的故障不会妨碍其他服务的操作。

我们还需要记住,微服务架构允许我们选择最适合实现的服务开发技术。不同的技术往往与特定的数据库配合得更好或支持特定的数据库。因此,鉴于我们希望使用最佳数据库技术来支持服务的需求,这个模式可能更像是需求而不是建议。

如常,有得必有失。我们需要考虑这种异构架构的成本,以及我们可能需要如何调整我们的知识库和团队以支持多个数据库,以及由此扩展的各种数据库技术。现在我们必须考虑到额外的维护、备份和调优操作,这可能会导致维护成本增加。

在实施这种模式时,可以采取几种方法,其中一些方法可以减少我们的基础设施需求并帮助我们节省成本。我们将在下一节讨论这些选项。

每个服务一个数据库技术

最终,我们需要为每个服务的真理来源建立清晰的界限。这并不意味着你绝对需要不同的数据库,但你可以利用如下关系数据存储的功能:

  • 按服务表:我们可以定义针对每个服务存储的数据进行优化的表。我们将在微服务代码中建模这些表,并确保我们只包括这些表。在这个模型中,常见的情况是某些表是其他表中可以找到的数据的非规范化表示,因为为该服务创建的表需要以这种格式存储数据。

  • 按服务模式:关系数据库允许我们指定模式值来分类我们的表。模式是数据库中的一个组织单元,帮助我们分类表。我们可以使用这些来按服务组织表。类似于按服务表模式,每个模式中的表都是基于匹配的微服务需求进行优化的。我们还有机会根据模式调整访问权限和限制,从而减少安全管理的成本。

  • 按服务数据库:每个微服务都有自己的数据库。可以通过将每个数据库放置在其自己的服务器上来进一步减少基础设施依赖。

图 7.1显示了多个服务连接到一个数据库。

图 7.1 – 多个服务共享一个数据库,但为每个服务创建模式以保持隔离和数据自治

图 7.1 – 多个服务共享一个数据库,但为每个服务创建模式以保持隔离和数据自治

按服务实现表和模式具有最低的资源需求,因为这相当于在单个数据库上构建一个应用程序。缺点是我们保留了一个单点故障,并且没有充分地扩展各种服务需求。

数据库按服务的方法中,我们仍然可以使用一个服务器,但为每个微服务提供自己的数据库。这种方法更符合模式的名字,但在基础设施上仍然保持了一个共同的瓶颈,即所有数据库都在同一个服务器上。为了保持服务自治并减少基础设施依赖,最合适的实现是将每个数据库及其依赖的微服务放在自己的故障域中。

从另一个维度来看这个问题,我们可以看到我们有灵活性,为每个服务选择最好的数据库技术。一些服务可能更倾向于关系型数据存储,而其他服务可能更有效地使用文档数据存储。我们将在下一节进一步讨论这个问题。

每个服务使用不同的数据库技术

数据库是任何应用程序的基础。良好的或差的数据库设计可以决定你的应用程序效率如何,它是否容易扩展,以及你编写代码的效率如何。

使用数据库按服务模式允许我们为每个微服务可能完成的操作选择最好的数据库。对于一个更同质化的技术栈,所有开发者都能认同并轻松维护,这将是理想的。然而,试图保持同质化在过去导致了捷径和广泛的集成项目,其中使用一种技术的需求掩盖了使用最佳技术解决问题的机会。

图 7.2 显示了多个服务连接到各自的数据库。

图 7.2 – 可以使用不同的技术构建多个服务,同时使用最合适的发展框架技术

图 7.2 – 可以使用不同的技术构建多个服务,同时使用最合适的发展框架技术

微服务允许我们拥有多个团队,这些团队可以选择最佳技术栈来实现解决方案,并且通过扩展,他们可以使用最佳类型的数据库技术来补充技术和问题。一些开发框架与某些数据库技术配合得最好,这使得选择用于特定微服务的整个堆栈变得更容易。

现在我们已经审查了数据库按服务选项,让我们来回顾一下使用这种开发模式为我们的微服务架构带来的缺点。

这种模式的缺点

我们可以整天歌颂这种模式,指出为什么它在微服务开发过程中是理想的路径。然而,尽管有所有这些优点,我们仍然可以指出潜在的陷阱,我们必须在运行时克服或学会减轻:

  • 额外成本:当我们谈论减少服务之间的基础设施依赖时,我们谈论的是引入更健壮的网络软件和硬件、更多的服务器以及更多支持软件的软件许可证。使用云平台可能更容易抵消一些基础设施和软件成本,但即使是这种方法也会有一个适度的价格标签。

  • 异构开发堆栈:从尽可能适当地满足微服务的业务需求的角度来看,这是一个优势。然而,当我们需要寻找维护可能被使用的技术的人才时,更大的问题出现了。团队之间的交叉培训是推荐的,但并不总是有效的,公司可能会面临由过去员工构建的微服务,而当前员工无法维护的风险。

  • 数据同步:我们已经讨论了最终一致性的问题,这导致我们需要实施应急措施来处理多个数据库之间数据不一致的情况。这需要额外的代码和基础设施开销来正确实现。

  • 事务处理:我们无法确保数据库之间的 ACID 事务,这可能导致数据存储之间出现不一致的数据。这将导致我们需要另一个名为** Saga 模式**的编码模式,我们将在下一章中进一步讨论。

  • 通信失败:由于一个微服务无法直接访问另一个的数据库,我们可能需要引入同步微服务通信来完成操作。这会在当前操作中引入更多的潜在故障点。这些可以通过断路器模式来缓解,我们将在后续章节中讨论。

总是记住,每个模式都有其优缺点。我们绝不应该仅仅因为它是推荐的做法就进行实施。我们应该始终正确评估需要解决的问题,并选择最合适的解决方案和模式,以确保全面覆盖并以最佳价格实现。

既然我们已经讨论了数据库按服务模式的做法和禁忌,让我们继续讨论设计数据库时的最佳实践。

开发数据库

开发一个功能强大的数据库对于开发者的职业生涯至关重要。这个角色曾经由团队中的数据库开发者承担,其唯一目的是处理所有数据库相关的事务。应用程序开发者只需根据应用程序的需求编写代码与数据库进行交互。

最近,典型应用程序开发者的角色演变成了现在所说的 全栈开发者 角色。这意味着现代开发者需要具备与数据库开发知识相当的应用程序开发知识。现在,看到由两到三名开发者组成的团队在微服务团队中工作,他们可以开发和维护用户界面、应用程序代码和数据库是非常常见的。

开发数据库超出了对所使用技术的舒适度。实际上,那只是容易的部分。许多开发者在开始实施技术之前,往往忽视咨询业务和充分理解业务需求。这通常会导致设计不佳、返工以及应用程序生命周期中的额外维护。

由于我们处于微服务领域,我们有独特的机遇来构建更小的、针对整个应用程序的各个片段的数据存储。这使得我们更容易摄入和分析我们关注的服务的存储需求,并减少了构建大型数据库作为万能解决方案的整体复杂性,从而在设计阶段减少了出错的可能性。正如之前讨论的,我们可以做出更好的选择,选择最合适的数据库,这会影响我们做出的设计考虑。

一些服务需要基于他们处理的数据的性质进行关系完整性;其他服务需要快速产生结果,而不仅仅是准确性;有些只需要键值存储。在下一节中,我们将比较关系型和非关系型数据存储的优缺点,以及何时最好使用每种类型。

数据库是应用程序性能的一个重要组成部分,因此对于哪个微服务使用哪种技术做出正确的决策非常重要。在下一节中,首先评估使用关系型数据库的优缺点。

关系型数据库

关系型数据库多年来一直是主流。它们在数据库技术领域占据主导地位一段时间了,而且有很好的理由。它们建立在严格的原则之上,这些原则补充了干净高效的数据存储,同时确保存储内容的准确性。

一些最受欢迎的关系型数据库管理系统包括以下内容:

  • SQL Server: 这是微软的旗舰关系型数据库管理系统,被个人和企业广泛用于应用程序开发。

  • MySQL / MariaDB: MySQL 是一个传统的开源且免费使用的数据库管理系统,主要用于 PHP 开发。MariaDB 是从原始 MySQL 代码库分叉出来的,并由一群开发者维护以保持免费使用政策。

  • PostgreSQL: 这是一个免费且开源的数据库管理系统,足够强大,可以处理从个人项目到数据仓库的各种工作负载。

  • Oracle 数据库:这是 Oracle 公司旗舰数据库管理系统,旨在处理各种操作,从实时事务处理到数据仓库,甚至混合工作负载。

  • IBM DB2:由 IBM 在为高交易量和流量企业设置中最可靠的系统之上开发。该数据库支持关系型和对象关系型结构。

  • SQLite:一个免费且轻量级的数据库存储选项,用于快速便捷的数据库。与大多数需要服务器(有时是专用机器)的替代方案不同,SQLite 数据库可以与它被集成的应用程序位于同一文件系统中,使其成为移动优先应用程序的绝佳选择。

通过称为规范化的过程,数据可以在多个表中有效地共享,每个表之间创建引用或索引。良好的设计原则鼓励你在每个表中都有一个主键列,该列将始终具有唯一值,以唯一标识一条记录。然后,其他表通过外键的形式引用这个唯一值,这有助于减少数据在表间重复的次数。

这些简单的引用可以确保数据在表格之间保持准确。一旦在主键和外键之间建立了这种关联,我们就创建了我们所说的关系,这为外键列中可能存在的值引入了约束或限制。这也被称为引用完整性

关系可以通过它们的基数进一步定义。这指的是主键值将在其他表中如何被引用的性质。最常见的基数如下:

  • 一对一:这意味着主键值在另一个表中作为外键恰好被引用一次。例如,如果一个客户只能有一个记录在案的地址。存储地址的表不能有多个记录指向同一个客户。

  • 一对一:这意味着主键值可以在另一个表中多次引用。例如,如果一个客户下了多个订单,那么在订单表中就会有一个客户的 ID 被引用多次。

  • 多对多:这意味着主键可以在另一个表中多次作为外键被引用,并且那个表的主键也可以在原始表中多次被引用。这可能会令人困惑,并且按照描述实现它将直接违反我们努力维护的引用完整性。这就是引入链接表作为中介来关联来自任一表的不同键值组合的地方。

以我们的医疗管理系统为例,如果我们回顾一下相对于已预约客户的预约跟踪方式,我们会看到我们在客户的 ID、他们的主键和预约之间实现了一个参考点。这确保了每次预约时我们不会重复客户的信息。也不可能为尚未在系统中存在的客户创建预约。

图 7.3 展示了一个典型关系。

图 7.3 – 一个一对多关系的示例,其中一位客户在预约表中被引用多次

图 7.3 – 一个一对多关系的示例,其中一位客户在预约表中被引用多次

多对多关系确实经常发生,并且认识到它们很重要。基于客户及其预约的例子,我们可以扩展并看到我们还需要将客户与预约的房间关联起来。同一个房间并不总是保证,这让我们意识到许多客户会预约,预约可能发生在许多房间中。如果我们尝试直接关联,那么客户或房间的详细信息将需要重复,以反映客户、房间和预约的多种可能组合。

理想情况下,我们会有一个存储房间及其相关细节的表,一个存储客户的表,以及一个位于两者之间的预约表,该表旨在在需要时将它们按日关联起来。

图 7.4 展示了一个典型的多对多关系。

图 7.4 – 一个多对多关系的示例,其中我们使用一个中间链接表将两个不同表中的多个记录关联起来

图 7.4 – 一个多对多关系的示例,其中我们使用一个中间链接表将两个不同表中的多个记录关联起来

我们已经讨论了 ACID 原则及其重要性。关系型数据库被设计来确保这些原则可以作为默认操作模式被观察到。这使得更改布局和表引用相对困难,尤其是如果更改涉及更改表之间的关系。

关系型数据库存储的另一个潜在缺点在于处理大量数据集时的性能。关系型数据库在数据存储、检索和整体速度方面通常效率很高。它们被设计来管理大量工作负载,因此技术本身几乎不会出错,但我们的设计要么会补充速度,要么会阻碍它。

这些是适当的关系型数据库设计和维护关系完整性之间的权衡。设计原则倾向于规范化,其中数据位分散在多个表中,这在我们需要数据并需要跨多个表(可能包含数千条记录)来获取数据时是好的。考虑到这种不足,NoSQL 或文档存储数据库变得越来越受欢迎,因为它们试图将数据存储在一个地方,使检索过程变得更快。

我们将在下一节讨论非关系型数据库的使用。

非关系型数据库

非关系型数据库在数据库世界中掀起了一场风暴。这可能是对它们产生影响的简单评估,但事实仍然是,它们引入了数据存储的一个维度,这个维度以前并不受欢迎或广泛使用。它们出现并提出了一种更加灵活和可扩展的数据存储技术,这种技术更倾向于敏捷的开发风格。

敏捷开发依赖于在项目进行过程中改变项目的能力。这意味着当使用关系型数据存储时,通常会推荐的大量范围和规划在初期是不必要的。相反,我们可以从一个系统的想法开始,并开始使用非关系型数据存储来适应那个小部分,随着系统的演变,数据存储也可以相应演变,从而最大限度地减少数据丢失或衰减的风险。

非关系型数据库也被称为 NoSQL 数据库,或 not only SQL,并且它们倾向于非表格式的数据存储模型。最受欢迎的 NoSQL 数据库使用文档存储方式,其中每条记录存储在一个文档中,包含该记录所需的所有详细信息。这直接违反了 规范化 的原则,该原则会建议我们将数据分散以减少可能的冗余。然而,这种模型的主要优势是我们可以在一个地方写入和检索数据,从而大大提高速度。

以我们的健康护理预约管理系统为例,如果我们以文档的形式存储一个预约,它可能看起来像这样:

{
    "AppointmentId":"5d83d288-6404-4cd5-8526-7af6eabd97b3",
    "Date":"2022-09-01 08:000",
    "Room":{
        "Id":"4fff8260-85ce-45cd-919e-35f0aaf7d51e",
        "Name":"Room 1"
    },
    "Customer": {
        "Id":"4fff8260-85ce-45cd-919e-35f0aaf7d51e",
        "FirstName":"John",
        "LastName":"Higgins"
    }
}

与关系模型不同,其中这些细节分散在多个表中并简单地引用,我们有包括所有评估预约所需细节的机会。

NoSQL 数据库的类型包括以下几种:

  • 文档数据库:以 JavaScript 对象表示法JSON)对象的形式存储数据。这个 JSON 文档概述了字段和值,并可以在一个文档中支持 JSON 对象和集合的层次结构。流行的例子包括 MongoDBCosmosDBCouchDB

  • 键值数据库:以简单的键值对存储数据。值通常存储为字符串值,并且它们不支持存储复杂的数据对象。它们通常用作快速查找存储区域,例如用于应用程序缓存。一个流行的例子是 Redis

  • 图数据库:在节点和边中存储数据。节点存储有关对象的信息,例如人或地点,边表示节点之间的关系。这些关系链接数据点之间的关系,而不是记录之间的关系。一个流行的例子是 Neo4j

使用 NoSQL 文档数据库的明显优势来自于数据存储的方式。与关系模型不同,我们不需要遍历许多关系和表来获取一个完全可读的数据记录。正如前一个示例所示,我们可以将所有细节存储在一个文档中,并简单地从这里进行扩展。当我们可能需要快速读取操作且不想通过复杂的查询来牺牲系统性能时,这非常有用。

然而,一个明显的缺点来自于存储的处理方式。文档数据库在减少数据冗余风险方面做得很少,甚至几乎什么也不做。我们最终会重复相关数据的细节,这意味着从长远来看,这些数据的维护可能会成为一个问题。以我们的客户记录为例,如果我们需要为每个预约过的客户添加一个新的数据点,我们就需要遍历预约记录来做出一个更改。使用关系模型来做这个操作会容易得多,也有效率得多。

现在我们已经发现了一些关系型和非关系型数据库存储的优点和缺点,让我们讨论一下在这些情况下任选其一都是好的选择。

选择数据库技术

当选择任何最佳技术时,我们面临着几个因素:

  • 可维护性:这项技术维护起来有多容易?它是否有过多的基础设施和软件要求?这项技术的更新和安全补丁产生得多频繁?最终,我们希望确保我们在项目开始几周后不会后悔我们的选择。

  • 可扩展性:我可以用这项技术到什么程度来实现我的软件?当需求发生变化,项目需要适应时会发生什么?我们希望确保我们的技术不要太僵化,并且能够支持业务需求的变化。

  • 支持技术:我使用的这个技术栈是否是最合适的?我们常常被迫走捷径,实施一些方法,有时甚至与最佳实践相悖,以方便匹配那些彼此并不最合适的技术。随着更多库的产生来支持异构技术栈的集成,这个问题正在逐渐减少,但仍是我们从第一天开始就需要考虑的事情。

  • 舒适度:您和团队对这项技术的舒适度如何?使用新技术并扩展范围和经验总是令人愉快的,但重要的是要认识到自己的局限性和知识差距。这些问题同样可以通过培训来解决,但我们始终希望确保我们能够处理我们选择的技术,并且不会在长期运行中出现意外。

  • 成熟度:这项技术有多成熟?在某些开发圈子中,技术可能每个月都会更新一次。我们不希望在发布当天就盲目拥抱新技术,而不进行充分的尽职调查。我们应该寻求选择那些经过实践检验、证明有效,并且拥有强大企业或社区支持及文档的成熟技术。

  • 适用性:最后,这项技术对于应用程序的适用性如何?我们希望确保我们使用尽可能好的技术来完成手头的任务。这有时会因更大的背景而受到影响,包括预算限制、项目类型、团队经验以及企业的风险承受能力,但尽可能,我们希望确保我们选择的技术能够充分满足项目的需求。

现在让我们将重点放在数据库开发上。我们已经讨论了不同类型数据库模型和每种技术的利弊。虽然我们不受所列选项的限制,但它们作为指南,帮助我们尽可能从最无偏见的角度进行评估。

在微服务背景下,我们希望为服务的需求选择最佳的数据库。围绕应该使用哪种存储机制没有硬性规定,但有一些建议可以在系统设计过程中帮助您进行指导。

您可以考虑在以下情况下使用关系型数据库:

  • 处理复杂报告和查询:关系型数据库可以在大型数据集上运行高效的查询,并且是生成报告的更好存储选项。

  • 处理高交易应用:关系型数据库非常适合处理重型和复杂的交易操作。它们在确保数据完整性和稳定性方面做得很好。

  • 您需要 ACID 兼容性:关系型数据库基于 ACID 原则,这在保护您的数据、确保准确性和完整性方面可以发挥很大作用。

  • 您的服务不会快速进化:如果您的服务没有变化的需求,那么数据库应该容易设计并长期维护。

您可以考虑在以下情况下使用非关系型数据库:

  • 您的服务不断进化,您需要一个灵活的数据存储库,能够在不造成太多干扰的情况下适应新的需求。

  • 您预计数据可能不会始终干净或达到某个标准。鉴于我们在非关系型数据存储方面的灵活性,我们适应了不同准确性和完整程度的数据。

  • 您需要支持快速扩展:这一点与根据业务需求演变数据存储的需求相辅相成,因此我们可以确保数据库可以在最小化代码更改和低成本的情况下进行更改。

现在我们已经探讨了选择数据库技术的一些主要考虑因素,让我们回顾一下使用代码与数据库交互的选项。

选择 ORM

选择ORM是设计过程中的重要部分。这为我们应用程序如何与数据库通信奠定了基础。ORM的缩写是对象关系映射

每种语言都支持某种形式的 ORM。有时这是内置在语言中的,有时开发者和架构师都会使用外部包或库来支持数据库相关操作。在.NET 的上下文中,我们有几种选择,每种都有其优缺点。最受欢迎的选项如下:

  • Entity Framework Core:这是.NET 开发者最流行和最明显的选择。它是由微软开发和维护的 ORM,与.NET 一起打包。它实现了称为 LINQ 的 C#查询语法,这使得编写在运行时代表开发者执行查询的 C#代码变得容易,并且支持大多数关系型和非关系型数据库技术。

  • Dapper:Dapper 被认为是一个微 ORM,因为它是一个快速、轻量级的.NET ORM。它提供了一个干净且可扩展的接口来构建 SQL 查询并在安全高效的方式下执行它们。其性能始终与 Entity Framework 相媲美。

  • NHibernate:NHibernate 是一个开源 ORM,广泛用作 Entity Framework 的替代品。它对数据库技术的支持广泛,并提供开发者偏好的处理对象映射和查询构造的替代方法。

还有其他 ORM,但这些都是最具争议的、最受欢迎和最广泛使用的选项。

Entity Framework Core 经历了不断的改进,目前是开源的,并提供了优秀的接口和抽象,减少了根据访问的数据库编写的特定代码的需求。这是一个重要的特性,因为它允许我们在不同的数据库技术之间重用代码,并帮助我们根据使用的数据库技术保持灵活性。我们可以轻松地更改数据库技术,而不会影响主要应用程序及其操作。除了任何明显的偏见之外,我们可以在.NET 项目中使用 Entity Framework,这确实有助于我们保持技术栈的一致性。

要将 Entity Framework Core(简称 EF Core)集成到我们的 .NET 应用程序中,我们需要添加为我们首选的数据库技术设计的包。对于这个示例,我们可以使用 SQLite,因为它具有多功能性。添加包的命令如下所示:

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

第一个命令添加了与 SQLite 数据库通信所需的全部核心和辅助 EF Core 库,而 Microsoft.EntityFrameworkCore.Design 包添加了对迁移和其他操作的设计时逻辑的支持。我们将在下一节中研究迁移。

我们接下来需要的是一个数据上下文类,它作为数据库及其表中代码级别的抽象。这个上下文文件将概述我们希望访问和引用的数据库对象。因此,如果我们的 SQLite 数据库应该有一个存储患者信息的表,那么我们需要一个类,该类模仿患者表应该如何看起来。

我们的数据上下文文件看起来像这样:

public class ApplicationDatabaseContext : DbContext
  {
    public DbSet<Patient> Patients { get; set; }
    protected override void OnConfiguring
        (DbContextOptionsBuilder optionsBuilder)
    {
      optionsBuilder.UseSqlite("Data Source=patients.db");
    }
  }

ApplicationDatabaseContext 类继承自 DbContext,这是一个由 EF Core 提供的类,它为我们提供了访问数据库操作和函数的能力。我们还有一个类型为 DbSet 的属性,它需要一个引用类型和一个名称。引用类型将是表示表的类,属性的名称将是表的代码级别引用点。DbSet 代表引用表中记录的集合。我们的 OnConfiguring 方法设置了到数据库的连接字符串。这个连接字符串将根据我们连接到的数据库类型而变化。

现在,我们的患者表将有一些字段,我们需要一个名为 Patient 的类,该类具有尽可能准确地表示列名和数据类型的 C# 属性,相对于它们在数据库中的呈现方式:

public class Patient
  {
    // EF Core will add auto increment and Primary Key
        constraints to the Id property automatically
    public int Id { get; set; }
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public string TaxId { get; set; }
   // Using ? beside a data type indicates to the database
        that the field is nullable
    public DateTime? DateOfBirth { get; set; }
  }

我们的 Patient 类是一个典型的类。它简单地表示我们希望在数据库和我们的应用程序中患者记录看起来是什么样子。这样,我们抽象了大部分数据库特定的代码,并使用标准的 C# 类和标准 C# 代码来与我们的表和数据交互。

一个简单的数据库查询,用于检索并打印患者表中的所有记录,将如下所示:

// initialize a connection to the database
  using var context = new ApplicationDatabaseContext();
// Go to the database a retrieve a list of patients
  var patients = await context.Patients.ToListAsync();
// Iterate through the list and print to the screen
  foreach (var patient in patients)
{
  Console.WriteLine($"{patient.FirstName}
     {patient.LastName}");
}

我们可以看到我们如何干净地使用 C# 中的 LINQ 语法并执行查询。EF Core 将尝试生成最有效的 SQL 语法,执行操作,并以由表示表的类塑造的对象形式返回数据。然后我们可以以标准 C# 对象的方式与记录进行交互。

EF Core 提供了一种完整的方式来与我们的数据库交互并执行我们的操作,而无需从我们的 C# 语法中中断太多。为了超越上面共享的示例,EF Core 完全支持依赖注入,这使得连接管理和垃圾回收几乎不成问题。

上述内容未涉及的一个基本步骤是以数据库开发技术形式出现,这些技术指导我们如何在项目中创建数据库。我们还面临迁移问题。我们将在下一节中讨论这些问题。

选择数据库开发技术

有一些最佳实践和一般指南指导数据库的设计。在本节中,我们不会探讨或质疑这些,但我们将讨论实现数据库以支持我们的应用程序的可能方法。

有两种流行的开发技术:

  • Schema-first:也称为数据库优先,这种技术使我们能够使用常规的数据库管理工具创建数据库。然后我们将数据库scaffold到我们的应用程序中。使用 Entity Framework,我们将获得一个表示数据库及其所有对象的数据库上下文类,以及每个表和视图的类。

  • Code-first:这种技术允许我们在应用程序代码旁边更流畅地建模数据库。我们使用标准的 C#类(如前节所示)创建数据模型,并使用migrations管理数据模型和最终数据库的更改。

不论我们是打算构建数据库还是采用代码优先的方法,我们都可以使用通过Microsoft.EntityFrameworkCore.Tools包提供的*dotnet CLI*命令。你可以使用dotnet add package命令将此包添加到你的项目中。

要构建数据库,命令看起来是这样的:

dotnet ef dbcontext scaffold "DataSource=PATH_TO_FILE"
  Microsoft.EntityFrameworkCore.Sqlite -o Models

这个命令只是表明我们希望根据提供的连接字符串生成一个dbcontext。根据目标数据库的技术(SQL Server、Oracle 等),这个连接字符串将不同,但在这个例子中,我们正在构建一个 SQLite 数据库到我们的应用程序中。然后我们指定最适合目标数据库的数据库提供程序包,并将生成的文件输出到项目中的Models目录。这个命令对使用的 IDE 也是无差别的。

这个命令也可以这样,这种格式在 Visual Studio 中工作时更受欢迎:

Scaffold-DbContext "DataSource=PATH_TO_FILE"
  Microsoft.EntityFrameworkCore.Sqlite --output-dir Models

任何命令都需要在正在工作的项目目录中执行。每次对数据库进行更改时,我们都需要重新运行此命令以确保代码反映了数据库的最新版本。它将相应地覆盖现有文件。

这里的一个限制是我们并不总是能够跟踪数据库中发生的所有更改,并在每次运行此命令时充分跟踪每次更改的内容。因此,代码优先是数据库开发中一个非常受欢迎的选项,其使用迁移帮助我们解决这个问题。

迁移的概念并不仅限于 EF Core,它也存在于其他语言中的几个其他框架中。它是一个简单的流程,它跟踪我们在编写应用程序时对数据模型所做的所有更改,以及表和对象的发展。迁移将评估对数据库应用的变化,并生成命令(最终成为 SQL 脚本)以对数据库执行这些更改。

与数据库优先模型不同,在数据库优先模型中,我们需要使用带有专用数据库项目的源控制,迁移在我们的代码库中是原生的,并讲述了在迁移过程中对数据库所做的每一次调整的故事。

按照使用 EF Core 向我们的应用程序添加数据库的示例,我们已经创建了数据库上下文类以及一个病人类。因此,代码的直接翻译将是我们的数据库上下文就是数据库,而Patient.cs是我们的表。为了将表实体化到数据库中,我们需要创建一个迁移,该迁移将生成所需的资产。我们需要相同的Microsoft.EntityFrameworkCore.Tools包来运行我们的命令行命令,创建第一个迁移的命令将如下所示:

dotnet ef migrations add InitialCreate

或者,它看起来像这样:

Add-Migration InitialCreate

在这里,我们只需生成一个迁移文件,并将其命名为InitialCreate。命名我们的迁移有助于我们直观地跟踪该操作中可能发生了什么变化,并有助于我们的团队。每次我们对数据模型和/或数据库上下文类进行更改时,我们都需要创建一个新的迁移,这将生成概述自上次已知数据库版本以来发生变化的最佳可能解释的命令。

此命令将生成一个具有UpDown方法的类,概述要应用哪些更改,以及如果迁移被撤销,将取消哪些更改。这使得我们能够确保我们打算做出的更改将被执行,我们可以在这些更改应用之前删除迁移并进行更正。迁移只能在应用到数据库之前被删除,该命令如下所示:

dotnet ef migrations remove

或者,它看起来像这样:

Remove-Migration

当我们确信迁移正确地概述了我们打算做出的更改时,我们可以使用简单的数据库更新命令来应用它:

dotnet ef database update

或者,我们可以使用以下命令:

Update-Database

如果您的项目正在使用源代码控制,那么迁移将始终包含在代码库中,以便其他团队成员查看和评估。在做出这些调整时与团队成员协作也很重要。如果不协调变更,可能会发生迁移冲突,如果使用集中式数据库,这可能导致数据库版本不匹配。最终,如果我们需要数据库的最新迁移副本,我们只需更改连接字符串以指向服务器和预期的数据库名称,运行Update-Database命令,EF Core 就会生成反映我们数据库最新版本的数据库。

现在我们已经了解了如何使用 EF Core 将数据库反向工程到我们的应用程序中或使用迁移创建新数据库,让我们来研究如何在 EF Core 提供的接口之上编写可扩展的数据库查询代码。

实现仓库模式

根据定义,仓库是一个数据或项目的中枢存储区域。在数据库开发的背景下,我们使用“仓库”这个词来指代一个广泛使用且方便的开发模式。这个模式帮助我们扩展 ORM(对象关系映射)给我们的默认接口和代码,使其可重用,并针对某些操作更加具体。

这个模式的一个副作用是我们最终会有更多的代码和文件,但总体好处是我们可以将核心操作集中化。因此,我们将业务逻辑和数据库操作从控制器中抽象出来,并在整个应用程序中消除重复的数据库访问逻辑。这有助于我们在编写干净代码的同时,维护代码库中的单一职责原则

在任何应用程序中,我们需要对任何数据库表执行四个主要操作。我们需要创建、读取、更新和删除数据。在 API 的背景下,我们需要为每个数据库表创建一个控制器,我们会在每个控制器中重复我们的CRUD代码,这对于开始来说很好,但随着应用程序代码库的增长,这变得很危险。这意味着如果我们更改从每个表中检索记录的方式,我们就需要在那么多地方更改代码。

不使用仓库模式,我们的患者表GET操作的 API 控制器将看起来像这样:

    [Route("api/[controller]")]
    [ApiController]
    public class PatientsController : ControllerBase
    {
        private readonly ApplicationDatabaseContext
            _context;
        public PatientsController
            (ApplicationDatabaseContext context)
        {
            _context = context;
        }
        // GET: api/Patients
        [HttpGet]
        public async Task<ActionResult<Ienumerable
            <Patient>>> GetPatients()
        {
          if (_context.Patients == null)
          {
              return NotFound();
          }
            return await _context.Patients.ToListAsync();
        }
    }

我们只需将数据库上下文注入到控制器中,并使用此实例来执行我们的查询。这是简单且高效的,因为查询逻辑并不复杂。现在想象一下,您有多个具有类似GET操作的控制器。一切都很顺利,直到我们收到反馈,需要为所有端点的GET操作添加分页。现在,我们有那么多地方需要重构,查询将更加复杂,而且未来可能还会再次更改。

对于这种类型的场景,我们需要尽可能集中化代码,并减少在整个应用程序中变得必要的重工作。这就是仓库模式能帮助我们解决问题的地方。我们通常有两个包含通用模板代码的文件,然后,我们为每个表推导出更具体的仓库,以便我们可以根据需要添加自定义操作。

我们的通用仓库包括一个接口和一个派生类。它们看起来像这样:

    public interface IGenericRepository<T> where T : class
    {
        Task<T> GetAsync(int? id);
        Task<List<T>> GetAllAsync();
        Task<PagedResult<T>> GetAllAsync<T>(QueryParameters
            queryParameters);
    }

在这里,我们只概述了读取操作的方法,但这个接口同样可以轻松扩展以支持所有 CRUD 操作。我们使用泛型,因为我们准备接受任何表示数据模型的类类型。我们还概述了一个用于GET操作的方法,该方法返回分页结果,符合最近的要求。我们的派生类看起来像这样:

public class GenericRepository<T> : IGenericRepository<T>
    where T : class
    {
        protected readonly ApplicationDatabaseContext
            _context;
        public GenericRepository(ApplicationDatabaseContext
           context)
        {
            this._context = context;
       }
        public async Task<List<T>> GetAllAsync()
        {
            return await _context.Set<T>().ToListAsync();
        }
        public async Task<PagedResult<T>> GetAllAsync<T>
            (QueryParameters queryParameters)
        {
            var totalSize = await _context.Set<T>()
                .CountAsync();
            var items = await _context.Set<T>()
                .Skip(queryParameters.StartIndex)
                .Take(queryParameters.PageSize)
                .ToListAsync();
            return new PagedResult<T>
            {
                Items = items,
                PageNumber = queryParameters.PageNumber,
                RecordNumber = queryParameters.PageSize,
                TotalCount = totalSize
            };
        }
        public async Task<T> GetAsync(int? id)
        {
            if (id is null)
            {
                return null;
            }
            return await _context.Set<T>().FindAsync(id);
        }
    }

我们在这里看到,我们将数据库上下文注入到仓库中,然后我们可以在一个地方编写我们的预设查询。现在,这个通用仓库可以被注入到需要实现这些操作的控制器中:

    [Route("api/[controller]")]
    [ApiController]
    public class PatientsController : ControllerBase
    {
        private readonly IGenericRepository<Patient>
            _repository;
        public PatientsController(IGenericRepository<Patient> 
          repository)
        {
            _repository = repository;
        }
        // GET: api/Patients
        [HttpGet]
        public async Task<ActionResult<Ienumerable
            <Patient>>> GetPatients()
        {
            return await _repository.GetAllAsync();
        }
         // GET: api/Patients/?StartIndex=0&pagesize=25
             &PageNumber=1
        [HttpGet()]
        public async Task<ActionResult< PagedResult
            <Patient>>> GetPatients([FromQuery]
                QueryParameters queryParameters)
        {
            return await _repository.GetAllAsync
                (queryParameters);
        }

现在,我们可以简单地从仓库中调用适当的方法。仓库在我们的控制器中相对于其注入中使用的类被实例化,并且生成的查询将应用于相关表。这使得我们在多个表和控制器之间标准化查询变得容易得多。我们需要确保我们在Program.cs文件中像这样注册我们的GenericRepository服务:

builder.Services.AddScoped(typeof(IGenericRepository<>),
    typeof(GenericRepository<>));

现在,我们可能需要实现特定于我们的患者表的操作,这些操作对于其他表不是必需的。这意味着通用的方法将不再是最佳选择,因为我们最终会在其中添加针对我们表的定制逻辑。我们现在可以扩展它,并为我们的表创建一个特定的接口,并编写我们的自定义逻辑:

public interface IPatientsRepository : IGenericRepository
    <Patient>
{
    Task<Patient> GetByTaxIdAsync(string id);
}
public class PatientsRepository : GenericRepository
    <Patient>, IPatientsRepository
{
    public PatientsRepository(ApplicationDatabaseContext
        context) : base(context)
    {}
    public async Task<Patient> GetByTaxIdAsync(string id)
    {
        return await _context.Patients.FirstOrDefaultAsync
            (q => q.TaxId == id);
    }
}

现在,我们可以在Program.cs文件中像这样注册这个新服务:

builder.Services.AddScoped<IPatientsRepository,
    PatientsRepository>();

然后,我们可以将其注入到我们的控制器中,而不是使用GenericRepository,并像这样使用它:

public class PatientsController : ControllerBase
{
    private readonly IPatientsRepository _repository;
    public PatientsController(IPatientsRepository
        repository)
    {
        _repository = repository;
    }
    // GET: api/Patients
    [HttpGet]
    public async Task<ActionResult<IEnumerable<Patient>>>
        GetPatients()
    {
        return await _repository.GetAllAsync();
    }
    // GET: api/Patients/taxid/1234
    [HttpGet("taxid/{id}")]
    public async Task<ActionResult<Patient>>
        GetPatients(string id)
    {
        var patient = await _repository.GetByTaxIdAsync
            (id);
        if (patient is null) return NotFound();
        return patient;
    }
    // GET: api/Hotels/?StartIndex=0&pagesize=25
        &PageNumber=1
    [HttpGet()]
    public async Task<ActionResult<PagedResult<Patient>>>
        GetPatients([FromQuery] QueryParameters
            queryParameters)
    {
        return await _repository.GetAllAsync
            (queryParameters);
    }
}

在这里,我们可以在保持对我们在通用仓库中实现的基函数的访问的同时,干净地调用自定义代码。

关于这种模式是否节省我们时间和精力,或者只是使我们的代码库变得更加复杂,存在争议。这种模式有其优点和缺点,但在选择将其包含到您的项目中之前,请确保您对它的利弊进行公平的评估。

摘要

数据库是应用开发的关键部分,我们需要确保我们尽早做出正确的决策。技术、存储机制的类型以及支持的应用逻辑都在使我们的应用尽可能有效地实现业务需求方面发挥着重要作用。在本章中,我们探讨了开发支持微服务的数据库时涉及的不同考虑因素,最佳数据库类型及其使用时机,以及选择一种有助于我们减少冗余代码的开发模式。

在下一章中,我们将探讨使用saga 模式在多个服务和数据库中实现事务的方案,因为当我们选择按服务选择数据库的方法来构建微服务架构时,这是一个大问题。

第八章:使用 Saga 模式在微服务之间实现事务

我们刚刚讨论了数据库开发以及在使用微服务架构开发应用程序时需要考虑的因素。我们讨论了为每个微服务创建单独数据库的优缺点。这样做确实允许每个微服务拥有更多的自主权,使我们能够选择最适合该服务的最佳技术。虽然这是一种首选且推荐的技术,但在确保数据存储之间的数据一致性方面确实存在显著的缺点。

通常,我们通过事务来确保一致性。正如本书前面所讨论的,事务确保所有数据都提交或都不提交。这样,我们可以确保操作不会部分写入数据,而我们看到的内容确实反映了正在跟踪的数据的状态。

在具有不同数据库的微服务之间强制执行事务是困难的,但这就是我们使用 saga 模式的时候。这种模式帮助我们编排数据库操作并确保我们的操作是一致的。

阅读本章后,我们将了解如何进行以下操作:

  • 使用 Saga 模式在微服务之间实现事务

  • 在微服务之间编排数据操作

  • 实现编排

探索 Saga 模式

我们之前探讨了“每个服务一个数据库”的模式,这鼓励我们为每个服务拥有单独的数据存储。有了这个模式,每个微服务将内部处理自己的数据库和事务。这提出了一个新的挑战,即需要多个服务参与并可能修改其数据的操作存在部分失败的风险,最终可能导致我们的应用程序中的数据不一致。这是这种模式选择的主要缺点,因为我们无法保证我们的数据库始终同步。

这就是我们要使用 saga 模式的地方。你可以把 saga 看作是一系列预定义的步骤,概述了服务应该调用的顺序。saga 模式还将负责对所有服务进行监督,可以说是在观察和监听,任何服务在执行过程中的任何失败迹象。

如果服务报告了故障,saga 还将为每个服务包含一个回滚措施。因此,它将按照特定的顺序进行,提示每个在故障之前可能已经成功的服务撤销其所做的更改。这很有用,因为我们的服务是解耦的,理想情况下不会直接相互通信。

Saga 是一种跨越多个服务的机制,可以在各种数据存储中实现事务。我们有分布式事务选项,如 两阶段提交,这可能要求所有数据存储都提交或回滚。这听起来很完美,但一些 NoSQL 数据库和消息代理与这种模型并不完全兼容。

假设一位新患者在我们医疗中心进行了注册。这个过程将要求患者提供他们的信息和一些基本文件,并预约初步的会诊,这需要支付费用。这些操作需要四个不同的微服务参与,因此将影响四个不同的数据存储。

我们可以将跨越多个服务的操作称为 叙事式。再次强调,叙事式是一系列本地事务。每个事务都会更新数据目标数据库,并产生一个消息或事件,触发叙事式下一个事务操作的执行。如果链中的某个本地事务失败,叙事式将在受先前事务影响的所有数据库中执行回滚操作。

叙事式通常实现三种类型的事务:

  • 可补偿的:这些事务可以通过具有相反效果的其他事务进行撤销。

  • 可重试的:这些事务保证成功,并在枢纽事务之后实现。

  • 枢纽:正如其名所示,这些事务的成功或失败对叙事式的继续至关重要。如果事务提交,则叙事式将继续运行,直到完成。这些事务可以放置为最终的补偿事务或叙事式的第一个可重试事务。它们也可以既不是补偿事务也不是可重试事务。

图 8.1 展示了叙事式模式:

图 8.1 – 每个本地事务都会向叙事式中的下一个服务发送消息,直到叙事式完成

图 8.1 – 每个本地事务都会向叙事式中的下一个服务发送消息,直到叙事式完成

正如我们所知,每个模式都有其优点和缺点,因此考虑所有角度非常重要,以便我们能够充分规划方法。让我们回顾一些已知问题和在实现此模式时需要考虑的事项。

问题与考虑事项

由于在本章之前,我们已将微服务架构中在数据存储中实现 ACID 事务的可能性排除在外,我们可以想象这种模式并不容易实现。它需要绝对的协调和对我们应用程序所有动态部分的良好理解。

这种模式也难以调试。由于我们正在实现跨自主服务的单一功能,我们现在引入了一个新的接触点和潜在的故障点,必须特别努力跟踪和追踪故障可能发生的位置。随着参与叙事式服务的步骤增加,这种复杂性也在增加。

我们需要确保我们的传奇可以处理架构中的短暂故障。这些是在操作过程中发生的可能不是永久性的错误。因此,包括重试逻辑以确保单个尝试中的失败不会过早地结束传奇是明智的。这样做的同时,我们还需要确保我们的数据与每次重试保持一致。

这种模式当然不是没有挑战,它将显著增加我们应用程序代码的复杂性。它并非万无一失,因为它会有其谬误,但它确实会帮助我们确保我们的数据在松散耦合的服务中更加一致,通过回滚或补偿操作失败来实现。

传奇通常使用编排舞蹈来协调。这两种方法都有其优缺点。让我们从探索舞蹈开始。

理解和实现舞蹈

舞蹈是一种协调传奇的方法,其中参与服务使用消息或事件相互通知完成或失败。在这个模型中,事件代理位于服务之间,但不控制消息流或传奇流。这意味着没有中央参考点或控制点,每个服务只是等待一个作为其操作确认触发器的消息。

图 8.2显示了舞蹈流程:

图 8.2 – 应用请求向队列发送消息,通知传奇中的第一个服务开始,消息在所有参与的服务之间流动

图 8.2 – 应用请求向队列发送消息,通知传奇中的第一个服务开始,消息在所有参与的服务之间流动

从舞蹈模型中得出的主要启示是没有中央控制点。每个服务将监听事件并决定是否采取行动。消息的内容将通知服务它应该采取行动,如果它采取行动,它将以消息的形式回复其行动的成功或失败。如果传奇的最后一个服务成功,则不会产生任何消息,传奇将结束。

如果我们使用本章前面提到的用户注册和预约预订示例来可视化此过程,我们将有一个如下所示的流程:

  1. 用户提交注册和预约预订请求(客户端请求)。

  2. 注册服务存储新用户的数据,然后发布包含相关预约和付款详情的事件。例如,这个事件可以称为USER_CREATED

  3. 支付服务监听USER_CREATED事件,并将尝试处理必要的付款。如果成功,它将产生一个PAYMENT_SUCCESS事件。

  4. 预约预订 服务处理 PAYMENT_SUCCESS 事件,并继续按预期添加预约信息。此服务安排预订并产生一个 BOOKING_SUCCESS 事件供下一个服务使用。

  5. 文档上传服务 收到 BOOKING_SUCCESS 事件,并继续上传文档并在文档服务数据存储中添加记录。

这个例子表明我们可以跟踪链中的过程。如果我们想知道每一步和结果,我们可以让注册服务监听所有事件,并在传奇故事中更新状态或记录进度。它还将能够将传奇故事的成功或失败传达给客户。

然而,当服务失败时会发生什么?我们如何减轻或利用传奇模式能够逆转已发生更改的能力?让我们接下来回顾一下。

失败时的回滚

传奇故事是必要的,因为它们允许我们在失败时回滚已经发生的更改。如果一个本地事务失败,服务将发布一个事件声明它未成功。然后我们需要在前面服务中添加额外的代码,以便根据回滚程序做出相应的反应。例如,如果我们的支付服务操作失败,流程可能看起来像这样:

  1. 预约预订服务未能确认预约预订,并发布了一个 BOOKING_FAILED 事件。

  2. 支付服务收到 BOOKING_FAILED 事件,并继续向客户发出退款。这将是一个补救步骤。

  3. 前面的注册服务将看到 BOOKING_FAILED 事件,并通知客户预订未成功。

在这种情况下,我们并没有完全逆转每一步,因为我们保留了用户的注册信息以供将来参考。然而,重要的是,在传奇故事中的下一个服务,即上传文档的服务,并没有配置为监听 BOOKING_FAILED 事件。因此,除非它看到 BOOKING_SUCCESS 事件,否则它将没有任何事情可做。

我们还可以注意,我们的补救步骤与实际执行的操作相关。我们的 支付服务 可能是围绕第三方支付引擎的一个包装,该引擎也会为支付操作写入本地数据库记录。在其补救步骤中,它不会删除支付记录,而是简单地将其标记为已退款支付或取消支付,考虑到传奇故事的未完成。

虽然这并不是在真正意义上符合本地数据库会做的 ACID,并且撤销写入操作对数据库的影响,但每个服务的回滚可能看起来都不同,这取决于业务规则或操作的特性。我们还看到,我们的回滚并没有涵盖每个单一的服务,因为我们的业务规则建议我们保留用户注册信息以供将来参考。

我们还需要考虑我们的回滚操作是否有必要性。鉴于我们服务的基于事件的本性,如果我们想实现一个顺序,那么我们将需要更多服务将专门监听的事件类型。

让我们回顾一下这种编排实现的优缺点。

优缺点

在编排模型中,我们有一种简单的方法来实现叙事。这种方法利用了我们之前在事件溯源异步服务通信中讨论的一些模式。每个服务保持其自主性,回滚操作可能每个服务看起来都不同。这是一种为较小操作、参与者较少和基于成功或失败有较少潜在结果的叙事实现方式。

我们还可以将异步方法视为叙事的一种优势,因为我们可以从每个服务的成功中触发多个同时操作。这对于在客户端等待结果时快速完成操作是有益的。

我们也看到,我们需要不断扩展我们的代码库以适应各种操作及其结果,尤其是如果我们打算实现回滚操作的顺序。鉴于实现此类叙事使用的异步模型,使用一种事件类型同时触发操作可能是危险的。

随着参与者数量的增加,我们面临实施复杂参与者、事件和补救措施的网络的危险。正确监控所有服务并充分追踪故障点变得越来越困难。如果要对操作进行测试,所有服务都必须运行,以便正确排除我们的操作故障。叙事越大,监控就越困难。

因此,我们转向另一种叙事模式,即编排,它实现了一个中央控制点。我们将在下一节中对其进行回顾。

理解和实现编排

当我们想到“编排”这个词时,我们会想到协调。一个管弦乐队是由协调一致的乐手组成的组合,他们共同努力创作出同一种类的音乐。每位乐手都演奏自己的部分,但他们都由一位指挥家引导,沿着相同的路径前进。

实现剧情的编排方法在需要中央控制点(如指挥家)方面并没有很大不同,所有服务都由中央控制点监控,以确保它们很好地扮演自己的角色,或者相应地报告失败。中央控制被称为 编排器,它是一个位于客户端和所有其他微服务之间的微服务。它处理所有事务,根据在剧情期间收到的反馈告诉参与服务何时完成操作。编排器执行请求,跟踪并解释每个任务后的请求状态,并在必要时处理补救操作。

图 8.3 展示了编排器流程:

图 8.3 – 应用程序请求向编排器发送消息,编排器开始协调和监控后续对参与服务的调用

图 8.3 – 应用程序请求向编排器发送消息,编排器开始协调和监控后续对参与服务的调用

让我们从编排器剧情实现的角度回顾我们的预约操作:

  1. 用户提交注册和预约请求(客户端请求)。

  2. 客户端请求传递给 编排器 服务。

  3. 编排器 服务集中存储来自客户端请求的数据。这些数据将在 用户注册 剧情期间使用。

  4. 编排器 服务通过将用户信息传递给 注册 服务开始剧情,该服务将在其数据库中添加一条新记录并返回 201Created HTTP 响应。编排器 将存储用户的 ID,因为它将在剧情期间需要。

  5. 然后 编排器 将用户的支付信息发送给 支付 服务,该服务将返回 200OK HTTP 响应。如果需要回滚并且应该取消支付,编排器 将存储支付响应详情。

  6. 然后 编排器预约 服务发送请求,该服务相应地处理预约并返回 201Created HTTP 响应。

  7. 编排器 最终将触发 文档上传 服务,该服务上传文档并将记录添加到文档服务数据库中。

  8. 然后 编排器 确认剧情已结束,并将更新操作的状态。然后它将向客户端发送整体结果。

我们可以看到,编排器在操作的每一步都处于主导地位,并了解每个服务的输出结果。它作为是否应该进入下一步的主要权威机构。我们还可以看到,在这个剧情模式中实现了更 同步的服务通信 模型。

让我们回顾一下回滚操作可能的样子。

回滚失败

回滚是实施叙事的最重要部分,就像编排模式一样,我们受操作和个别服务操作的业务规则所约束。这里的要点是,服务将向中心点响应失败,然后协调器将在各个服务之间协调回滚操作。重用之前讨论的失败场景,我们的编排看起来可能像这样:

  1. 预约预订服务向协调器发送400BadRequest HTTP 响应。

  2. 协调器继续调用支付服务以取消支付。在叙事过程中,它已经存储了有关支付的相关信息。

  3. 协调器将触发额外的清理操作,例如将用户的注册记录标记为不完整,以及清除操作开始时可能存储的任何其他数据。

  4. 协调器将通知客户端操作失败。

在这里进行回滚可以说是更容易实现——不是因为我们在改变服务和它们的行为方式,而是因为我们能够确保在顺序重要的情况下,补救措施将按照什么顺序发生,并且我们可以在不向流程中引入太多额外复杂性的情况下做到这一点。

让我们更详细地讨论使用此模式的好处。

优点和缺点

使用这种叙事模式实现的明显优势是我们能够确保实施的控制水平。我们可以编排我们的服务调用并接收实时反馈,这可以用来决定并沿着叙事路径有一个可跟踪和监控的固定路径。这使得实现复杂的工作流程并随着时间的推移扩展参与者的数量变得更容易。

如果我们需要控制叙事活动的确切流程并确保我们没有服务被同时触发,并且它们可能认为相关的信息,这种实现对我们来说非常出色。服务只有在被调用时才会行动,配置错误的可能性较小。服务不需要直接相互依赖进行通信,更具有自主性,导致业务逻辑更简单。故障排除也变得更容易,因为我们能够跟踪单个代码库正在做什么,并更容易地识别失败点。

尽管所有这些协调的优点,我们仍需记住,我们只是在创建一个同步服务调用的中心点。如果其中一个服务运行速度比预期慢,这可能会成为叙事中的瓶颈。当然,这可以通过正确实现的重试断路器逻辑来管理,但这仍然是一个值得考虑的风险。

我们还遇到了一个情况,我们最终又需要开发和维护另一个微服务。我们将引入一个新的、更中心的故障点,因为如果协调器停止工作,没有其他微服务会被调用。

让我们回顾一下本章所学的内容。

摘要

到目前为止,我们已经看到了围绕微服务架构和开发的一些模式。每个模式的目的都是为了减少这种架构带来的损耗。

我们在数据库按服务模式的实现中看到了一个潜在的痛点和一个关注点,以及来自不同数据存储的困难。我们无法始终保证所有服务在操作中都能成功,因此,我们无法保证数据存储会反映相同的内容。

为了解决这个问题,我们转向了叙事模式,该模式可以通过基于事件的编排实现或更集中的编排方法来利用。我们已经回顾了围绕这两种实现的优势、劣势和考虑因素,以及它们如何帮助我们更有效地帮助微服务保持数据一致性。

在下一章中,我们将回顾微服务之间通信中可能存在的缺陷,并讨论如何使用断路器模式实现更容错的服务间通信。

第三部分:弹性、安全和基础设施模式

可靠性是 API 设计中最关键的方面之一。本部分讨论了围绕健壮 API 设计、安全和托管的技术。在本部分的结尾,你应该能够设计出高级且安全的 API,这些 API 可以以较低的失败率进行通信,并且可以高效地托管。

本部分包含以下章节:

  • 第九章, 构建弹性微服务

  • 第十章, 对您的服务进行健康检查

  • 第十一章, 实现 API 和 BFF 网关模式

  • 第十二章, 使用令牌保护微服务

  • 第十三章, 微服务容器托管

  • 第十四章, 为微服务实现集中式日志记录

  • 第十五章, 总结

第九章:构建弹性的微服务

在 Saga 模式之后,我们可以欣赏到在我们的微服务应用程序中内置安全措施的价值。我们需要确保我们充分处理不可避免的故障。

我们不能假设我们的分布式微服务始终处于运行状态。我们也不能假设我们的支持性基础设施将是可靠的。这些考虑让我们必须预测失败的发生,无论是长期的还是短暂的。

长时间的中断可能是由服务器或服务的故障,或基础设施的一些通常很重要的部分引起的。这些通常更容易检测和缓解,因为它们对应用程序的运行时间有更明显的影响。瞬态故障的检测要困难得多,因为它们可能持续几秒钟到几分钟,通常与基础设施中的任何明显问题无关。一个服务响应时间多出 5 秒的情况也可以被视为瞬态故障。

非常重要的一点是,我们不仅要编写不会因为瞬态问题而破坏应用程序的代码,还要知道在遇到更严重的故障时何时中断该应用程序。这是衡量用户对我们应用程序体验的重要部分。

在本章中,我们将探讨在微服务架构中导航可能的故障时可以实施的多种场景和对策。

在阅读本章之后,我们将了解如何做以下事情:

  • 构建弹性的微服务通信

  • 实施缓存层

  • 实施重试和断路器策略

技术要求

本章中使用的代码参考可以在项目仓库中找到,该仓库托管在 GitHub 上,网址为:github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch09

服务弹性的重要性

在我们进入技术解释之前,让我们尝试理解什么是弹性。单词resilientresiliency的基础词,它指的是实体对负面因素的抵抗力。它指的是实体对不可避免的故障的反应能力以及实体能够抵抗未来故障的能力。

在我们的微服务架构的背景下,我们的实体是我们的服务,我们知道故障会发生。故障可能很简单,比如内部操作中的超时,通信丢失,或者服务的重要资源意外中断。

可能的故障场景及其处理方法

以我们的医疗预约系统为例,让我们假设我们的预约服务需要检索相关患者的详细信息。预约服务将对患者服务进行同步 HTTP 调用。在这个通信步骤中的步骤可能看起来像这样:

  1. 预约服务患者服务发起 HTTP 请求,传递患者 ID(AppointmentsController.cs)。

  2. 患者服务接收 HTTP 请求并执行查询以查找患者的记录(PatientsController.cs)。

  3. 患者服务以适当的数据响应预约服务

到目前为止,我们已经从我们的同步服务通信流程中期待这种情况。然而,这是一个一切按预期工作的理想流程,根据你的基础设施,你可以保证每次都能达到一定的成功率。我们需要考虑的是平衡——流程可能被中断的少数几次,并且没有成功完成操作链。这可能是因为失败,或者我们可能只需要更多一点时间。也可能是因为我们需要提前结束调用,因为它花费了太长时间。

现在,让我们回顾一下在服务调用过程中出现失败的情况:

  1. 患者服务预约服务发起 HTTP 请求,传递患者 ID。

  2. 预约服务响应了一个BAD GATEWAY (502)错误代码。

  3. 患者服务接收到BAD GATEWAY响应时,会立即抛出异常。

  4. 用户收到错误消息。

在这种情况下,我们收到了预约服务调用提前终止的情况。5xx范围内的 HTTP 响应表明与预约服务相关的资源或服务器存在问题。这些5xx错误可能是临时的,立即的后续请求可能会成功。特别是BAD GATEWAY错误可能是由于服务器配置不良、代理服务器故障或当时对请求响应过多。

在解决这些问题时,有时我们可以重试请求,或者有一个备用的数据源。虽然我们将在本书的后面讨论重试逻辑,但我们探索使用某种形式的缓存层,使我们能够维护一个稳定的数据层,从中我们可以获取所需的信息。

让我们回顾一下如何实现一个缓存层来协助这个问题。

使用缓存和消息代理实现弹性

我们将深入探讨如何使用重试策略断路器模式来使我们的服务具有弹性,但我们的微服务架构并不局限于这些方法。我们可以通过缓存消息代理机制来支持服务的弹性。添加一个缓存层允许我们创建一个临时中间数据存储,这在尝试从当前离线的服务检索数据时非常有用。我们的消息代理有助于确保消息将被投递,这对于写操作尤其有用。

让我们讨论消息代理以及它们如何帮助我们实现弹性。

使用消息代理

消息代理提供了更高的数据交付保证,这增加了弹性。这是建立在消息代理不会长时间不可用的基础上的,一旦消息被放置在消息总线上,即使监听服务(或服务)离线,也不会有任何影响。正如我们之前讨论的,我们可以几乎保证数据将通过异步通信成功发布,因为消息代理被设计为保留信息,直到它被消费。

消息代理也支持重试逻辑,如果由于任何原因消息没有成功处理,它将被返回到队列以稍后处理。我们想要管理消息投递的重试次数,因此我们应该配置我们的消息代理将消息传输到死信队列,在那里我们存储毒化消息。

我们还需要考虑如何处理消息重复。如果由于某种原因我们没有立即处理发送到队列的消息,然后消息再次从重试中发送到队列,这种情况就会发生。这会导致队列中出现两次相同消息,并且不一定按正确顺序排列,或者一个接一个。我们必须确保我们的消息包含足够的信息,以便我们能够在消息消费者中充分开发冗余检查。

我们在前面章节中探讨了与消息代理的集成,因为我们讨论了服务之间的异步通信。现在,让我们探索实现缓存层。

使用缓存层

缓存可以是弹性策略中的一个宝贵部分。我们可以采用一种缓存策略,在服务离线时回退到这个缓存。这意味着我们可以将缓存层用作回退数据源,并给最终用户造成所有服务都在运行中的错觉。这个缓存将定期更新和维护,每次修改源服务数据库中的数据时都会进行。这将有助于保持缓存数据的更新。

当然,采用这种策略,我们需要接受可能存在过时数据的影响。如果源服务离线并且支持数据库正在更新(可能由其他作业进行),那么缓存最终会变成一个过时数据源。我们采取的确保其新鲜度的措施越多,我们引入的应用程序复杂性就越大。

尽管添加缓存层可能有利有弊,但我们能看到它将极大地增强我们的微服务应用程序,并减少用户可能看到的错误数量,这些错误源于短暂甚至长期的故障。

实现缓存层最有效的方法是作为分布式缓存。这意味着架构中的所有系统都将能够访问中央缓存,该缓存作为一个外部独立服务存在。这种实现可以提高速度并支持扩展。我们可以使用 Redis 缓存作为我们的分布式缓存技术,我们将研究如何将其集成到 ASP.NET Core 应用程序中。

使用 Redis 缓存

Redis 缓存 是一种流行的缓存数据库技术。它是一个开源的内存数据存储,也可以用作消息代理。它可以在本地机器上用于本地开发工作,但有时也会部署在中央服务器上以支持更分布式系统。Redis 缓存是一个键值存储,使用唯一的键来索引值,且没有两个值可以具有相同的键。这使得从这种类型的数据存储中存储和检索数据变得非常容易。除此之外,值可以存储在非常简单的数据类型中,如字符串、数字和列表,但 JSON 格式在更复杂的对象类型中更为流行。这样,我们可以在代码中序列化和反序列化这个字符串,并按需进行操作。

在云提供商如 Microsoft Azure 和 Amazon Web ServicesAWS)上对 Redis 缓存也有广泛的支持。对于这个练习,您可以在本地安装 Redis 缓存,或者使用 Docker 容器。要开始在项目中使用 Redis 缓存,我们需要运行以下命令:

dotnet add package Microsoft.Extensions.Caching
  .StackExchangeRedis

然后,我们需要在我们的 Program.cs 文件中注册我们的缓存,如下所示:

// Register the RedisCache service
services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "Configuration.GetSection
        ("Redis")["ConnectionString"]
});

您可以在 appsettings.json 文件或应用程序密钥中配置连接字符串。它可能看起来像这样:

"Redis": {
  "ConnectionString": "CONNECTION_STRING_HERE"
}

这些步骤为我们的应用程序添加了缓存支持。现在,我们可以在应用程序中按需读取和写入缓存。通常,当数据被增强时,我们希望写入缓存——新数据应写入缓存——我们可以删除旧版本并为修改后的数据创建新版本;对于已删除的数据,我们也会从缓存中删除数据。

我们可以创建一个单一的 CacheProvider 接口和实现,作为我们所需缓存操作的包装器。我们的接口看起来像这样:

public interface ICacheProvider
{
    Task ClearCache(string key);
    Task<T> GetFromCache<T>(string key) where T : class;
    Task SetCache<T>(string key, T value,
      DistributedCacheEntryOptions options) where T :
        class;
}

我们的实现看起来像这样:

public class CacheProvider : ICacheProvider
{
    private readonly IDistributedCache _cache;
    public CacheProvider(IDistributedCache cache)
    {
        _cache = cache;
    }
    public async Task<T> GetFromCache<T>(string key) where
        T : class
    {
        var cachedResponse = await
            _cache.GetStringAsync(key);
        return cachedResponse == null ? null :
            JsonSerializer.Deserialize<T>(cachedResponse);
    }
    public async Task SetCache<T>(string key, T value,
        DistributedCacheEntryOptions options) where T :
            class
    {
        var response = JsonSerializer.Serialize(value);
        await _cache.SetStringAsync(key, response,
            options);
    }
    public async Task ClearCache(string key)
    {
        await _cache.RemoveAsync(key);
    }
}

这段代码允许我们与我们的分布式缓存服务交互,并根据其关联的键检索、设置或删除值。此服务可以在我们的 控制反转IoC)容器中注册,并根据需要注入到我们的控制器和存储库中。

这里的想法是,当我们需要从缓存中读取值时,我们可以使用GetFromCache方法。键允许我们缩小到我们感兴趣的条目,而T泛型参数允许我们定义所需的数据类型。如果我们需要更新缓存中的数据,我们可以清除与适当键关联的缓存记录,然后使用SetCache将带有相关键的新数据放置进去。我们将解析新数据到 JSON,以确保我们不违反支持的数据类型,同时保持存储复杂数据的能力。当我们添加新数据时,我们只需调用SetCache并添加新数据。

我们还希望尽可能保持数据的 freshness。一个流行的模式是在数据输入或更新时清除缓存并创建一个新的条目。

我们可以在我们的应用程序中使用这些代码片段并实现一个缓存层来提高性能、弹性和稳定性。我们仍然面临在操作失败初始调用时重试操作的问题。在下一节中,我们将探讨我们如何实现我们的重试逻辑。

实现重试和断路器策略

服务失败有多种原因。对服务失败的典型响应是 HTTP 响应在5xx范围内。这些通常突出显示托管服务器的问题或服务所在网络的暂时中断。在发生故障时,我们不尝试确定故障的确切原因,我们需要添加一些安全措施来确保在发生此类错误时应用程序的连续性。

因此,我们应该在我们的服务调用中使用重试逻辑。如果返回错误代码,这些逻辑将自动重新提交初始请求,这可能足以让短暂错误自行解决,并减少初始错误可能对整个系统和操作产生的影响。在这个策略中,我们通常允许在每次请求尝试之间经过一段时间。这就是我们的重试策略的总结。

我们不希望在我们的重试中继续执行它们而没有某种形式的退出条件。如果目标服务保持无响应,这将类似于实现一个无限循环,并且无意中对我们自己的服务执行拒绝服务DoS)攻击。因此,我们实现断路器模式,它充当我们服务调用的协调器。

我们需要实现一个重试策略,至少在得出明确的失败结论之前进行几次调用。这将使我们的服务更能抵抗潜在的短暂错误,并允许应用程序确保用户体验不会直接受到影响。现在,重试并不总是答案。在这里进行重试是有意义的,因为服务以明确的失败响应,我们决定再次尝试。我们需要决定重试次数过多,并相应地停止。

我们可以使用 断路器模式 来控制重试次数并设置将决定连接应保持打开并监听响应多长时间的参数。这种简单技术有助于减少重试次数,并更好地控制重试发生的方式。

断路器 位于客户端和服务器之间。在我们的微服务应用程序中,发起调用的服务是客户端,而接收请求的目标服务是服务器。最初,它允许所有调用通过。我们称这为 关闭状态。如果检测到错误,可能是错误响应或延迟响应,则 断路器打开。一旦断路器打开,后续调用将更快失败。这将缩短等待响应的时间。它将等待配置的超时时间,然后如果目标服务已恢复,将再次允许调用。如果没有改进,则断路器将中断传输。

使用这两种技术,我们既可以应对瞬时故障,又确保长期故障不会以糟糕的用户体验的形式出现。在 .NET Core 中,我们有 Polly 的优势,这是一个允许我们几乎原生支持重试和断路器策略并实现弹性 Web 服务调用的包。我们将在下一节中探讨将 Polly 集成到我们的应用程序中。

Polly 重试策略

Polly 是一个框架,允许我们为我们的应用程序添加一层新的弹性。它作为两个服务之间的一个层,存储已发起请求的详细信息并监控响应时间及/或响应代码。它可以配置用于确定失败外观的参数,并且我们可以进一步配置我们希望采取的操作类型。这种操作可以是重试或请求的取消。

Polly 在 .NET Core 中方便可用,并且在全球范围内被广泛使用和信任。让我们回顾一下在应用程序中实现此框架并监控 Patients API 将对 Documents API 发起的调用的步骤。

要将其添加到我们的 .NET Core 应用程序中,并允许我们为我们的 HttpClient 对象编写扩展代码,我们首先通过 NuGet 添加这些包:

Install-Package Polly
Install-Package Microsoft.Extensions.Http.Polly

现在,在我们的 Program.cs 文件中,我们可以配置我们的针对 Documents API 的类型化 HTTP 客户端以使用我们为 Polly 定义的策略的扩展代码。在 Program.cs 文件中,我们可以定义类型化客户端的注册如下:

builder.Services.AddHttpClient<IDocumentService,
     DocumentService>()
.AddPolicyHandler(GetRetryPolicy());

我们已经为我们的 HTTP 客户端添加了一个策略处理器,因此它将自动为使用此客户端发出的所有调用调用。我们现在需要定义一个名为 GetRetryPolicy() 的方法来构建我们的策略:

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
        {
            return HttpPolicyExtensions
                .HandleTransientHttpError()
                .OrResult(r => !r.IsSuccessStatusCode)
                .Or<HttpRequestException>()
                .WaitAndRetryAsync(5, retryAttempt =>
                   TimeSpan.FromSeconds(Math.Pow(2,
                     retryAttempt)), (exception, timeSpan,
                         context) => {
                    // Add logic to be executed before each
                    retry, such as logging or
                      reauthentication
                });
        }

由于使用了构建器模式,这可能会显得有些复杂,但它很容易理解,并且足够灵活,可以定制以满足您的需求。首先,我们定义返回类型或方法为IAsyncPolicy<HttpResponseMessage>,这与我们的 HTTP 客户端调用返回的类型相对应。然后,我们允许策略观察瞬态 HTTP 错误,框架可以默认确定这些错误,并且我们可以通过添加更多条件来扩展这种逻辑,例如观察IsSuccessStatusCode的值,它返回操作成功或失败的truefalse,或者如果返回了HttpRequestException

这几个参数涵盖了 HTTP 响应的一般最坏情况。然后,我们设置重试的参数。我们希望最多重试 5 次,并且每次重试都应该在前一次调用后的 2 秒开始,以滚动间隔进行。这样,我们在每次重试之间允许一些时间。这就是退避的概念。

最后,我们可以定义在每次重试之间想要采取的操作。这可能包括一些错误处理或重新认证逻辑。

重试策略可能会对您的系统产生负面影响,尤其是在我们可能存在高并发和高资源竞争的情况下。我们需要确保我们有一个稳固的策略,并有效地定义我们的延迟和重试。回想一下,一个配置不当的重试策略可能会导致对您自己服务的 DoS 攻击,使应用程序面临重大的性能问题。

考虑到有可能实现某种可能以这种方式产生负面影响的方案,我们现在需要一个防御屏障来减轻这种风险,并在错误持续不断的情况下打破重试循环。最佳的防御策略是使用断路器,我们将使用 Polly 来配置它。

使用 Polly 的断路器策略

正如我们在本章中讨论的那样,我们应该处理那些需要较长时间才能解决的问题,并在我们得出结论,服务在比预期更长的时间内无响应时,定义一个放弃对服务重试调用的策略。

我们可以通过定义断路器策略并将其添加为 HTTP 处理程序到我们的客户端中,从使用 Polly 添加重试策略的代码继续。我们修改客户端在Program.cs文件中的注册,如下所示:

builder.Services.AddHttpClient<IDocumentService,
    DocumentService>().AddPolicyHandler(GetRetryPolicy())
.AddPolicyHandler(GetCircuitBreakerPolicy());

现在,我们可以添加GetCircuitBreakerPolicy()方法如下:

static IAsyncPolicy<HttpResponseMessage>
    GetCircuitBreakerPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
}

此策略定义了一个断路器,当连续发生5次重试失败时,它会打开电路。一旦达到这个阈值,电路将断开 30 秒,并自动失败所有后续调用,这些调用将被解释为 HTTP 失败响应。

在这两个策略到位后,您可以编排您的服务重试,并显著减少计划外停机对您的应用程序和最终用户体验的影响。断路器策略还增加了一层保护,以防止重试策略可能产生的任何潜在不利影响。

现在,让我们总结一下我们关于实现弹性网络服务所学到的东西。

摘要

本章内容帮助我们更加关注微服务应用程序中潜在失败的可能性。这些概念不仅帮助我们构建强大且稳定的网络服务,还极大地增强了它们之间的通信机制。

我们看到,服务中断并不总是由于初始网络服务的代码错误或数据库和服务器,我们还促进了服务间的通信,这导致了对网络、第三方服务和一般基础设施正常运行时间的更大依赖。这导致我们实施应急措施,以确保我们的应用程序尽可能为用户提供良好的体验。

我们探讨了提高服务可靠性的几种技术,例如使用 Redis Cache 等技术为我们的GET操作添加缓存层,为我们的写入操作使用消息代理,以及使用 Polly 等框架编写更健壮的代码。使用 Polly,我们探讨了如何自动重试服务调用,并使用断路器来防止这些重试过于宽松并引起其他问题。

由于服务可能会失败,我们需要重试方法,因此我们还需要一种方法来监控服务的健康状态,以便我们能够了解为什么重试不有效。这意味着我们需要引入健康检查,以便在服务基础设施出现故障时提醒我们。我们将在下一章中探讨这一点。

第十章:对您的服务执行健康检查

维护最大正常运行时间是任何系统的一个重要方面。在前一章中,我们看到了我们可以以容错的方式编写代码,这将减少我们基础设施和网络中故障的发生频率。然而,这并不是一个长期解决方案,事情无论采取这些措施与否都会失败。这导致了一个观点,即我们需要知道何时出现故障。

这是我们开始考虑健康检查的地方。健康检查作为一种机制,可以通知我们服务的故障以及我们应用程序中支持数据库和连接的故障。通常,这可以通过对资源发送一个简单的 ping 请求来完成。如果收到响应,则表示资源可用且按预期运行。如果没有响应,我们假设资源已关闭并触发警报。

服务的上线和下线状态之间存在状态,我们将在本章中探讨这些选项。我们还将探索 .NET Core 提供的一些功能,以实现这些检查。

在本章中,我们将探讨在导航我们微服务架构中可能出现的故障时,我们可以实施的各种场景和应对措施。

阅读本章后,我们将能够做到以下几点:

  • 了解为什么健康检查是必要的

  • 了解如何在 ASP.NET Core 中实现健康检查

  • 了解编排器如何监控和响应故障

技术要求

本章中使用的代码参考可以在项目仓库中找到,该仓库托管在 GitHub 上,网址为:github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch10

健康检查与微服务

健康检查使我们能够监控我们服务的健康状况。坦白说,任何暴露 HTTP 端点的另一个服务或资源都成为健康检查的有能力候选人。我们可以简单地向该端点发送请求,并希望得到一个表示成功响应的响应。最简单的健康检查形式可以来自实现一个简单的 GET 请求,它返回一个 200OK HTTP 响应。我们可以向这样的端点添加更多智能,检查与其他关键服务的连接性,并使用这些信息来影响返回的响应代码。

健康检查对于单体和微服务应用程序都是有用的机制。然而,在微服务的情况下,我们面临着一个更大的挑战,那就是监控和维护多个服务。尤其是如果它们被配置为在单个级别上进行扩展。健康检查可以用来监控相互依赖服务的健康和正常运行时间,并在服务出现故障时执行某种形式的纠正操作。

使用 .NET Core,我们可以返回一个成功的响应,并包含一些关于服务健康状况的详细信息。在这种情况下,我们不能仅仅根据 200OK 响应来判断,我们需要检查实际的响应体以确定服务是否健康、降级或不健康。

图 10.1 展示了一个典型的健康检查:

图 10.1 – 展示了在确认所有服务都可用后的健康检查请求和健康响应

图 10.1 – 展示了在确认所有服务都可用后的健康检查请求和健康响应

让我们分解每个状态的含义:

  • 健康:这表示服务是健康的,并且应用程序正在按预期运行

  • 降级:这表示服务正在运行,但某些功能可能不可用

  • 不健康:这表示服务正在失败,并且没有按预期运行

图 10.2 展示了一个失败的健康检查:

图 10.2 – 展示了一个由于某个服务不可用而发送了失败响应的健康检查

图 10.2 – 展示了一个由于某个服务不可用而发送了失败响应的健康检查

服务的健康状态取决于多个因素,包括正确的配置、访问密钥和依赖项、托管平台和基础设施的状态,以及与数据库的连接。它们还可以用于外部应用程序监控和整体应用程序健康。

微服务的一种常见部署模式是使用容器编排器,如 Kubernetes,在生产环境中部署和运行我们的服务。大多数编排器在运行时会对它们的 pod 进行定期的 活跃性健康检查,并在部署期间进行 就绪性健康检查。健康检查有助于编排器确定哪些 pod 处于就绪状态,并且能够处理流量。了解活跃性健康检查和就绪性健康检查之间的区别以及何时使用哪一个非常重要。最容易实现的是活跃性健康检查;我们将在下一节讨论这个问题。

活跃性健康检查

活跃性健康检查端点是专门为实现健康检查目的而实现的特定端点。在这个健康探测中,当服务响应 活跃性健康检查 时,我们认为它是健康的。无法响应此端点表明应用程序存在严重问题。这个问题可能是由于多种原因造成的,例如崩溃或计划外应用程序重启。因此,重启未通过此检查的应用程序是一种常见的做法。

监控基础设施的应用程序,例如监控 Docker 容器的Kubernetes,使用存活健康检查来确定 Pod 的健康状况并在需要时触发重启。云提供商还提供了与负载均衡器一起的健康探测功能,可以通过定期向存活检查端点发送请求来检查已部署应用程序的可用性。这种方法通常对于 Web 应用程序和服务来说是足够的,因为我们不需要复杂的存活检查端点。如果服务可以接受请求并返回响应,那么我们将其视为健康状态。

检查应用程序或服务是否存活很简单,但在应用程序部署和/或升级后,我们也可能需要减轻误报。这可能会发生在应用程序可能尚未完全准备好使用时,而我们却收到了来自存活检查的积极响应。在这种情况下,我们需要考虑实施就绪健康检查

就绪健康检查

就绪健康检查用于需要验证不仅仅是 HTTP 响应的情况。具有多个第三方依赖的应用程序可能需要更长的时间才能准备好使用。因此,尽管它能够运行并能响应简单的 HTTP 请求,但数据库或消息总线服务等可能尚未就绪。我们希望在继续使用它或继续部署活动之前,确保我们对应用程序从启动角度的状态有一个全面的了解。

一个就绪健康检查通常只有在启动任务完成后才会返回健康状态。这些检查将比存活健康检查需要更长的时间来返回健康状态。就绪健康检查到位后,编排器不会尝试重启应用程序,但也不会路由请求流量。Kubernetes可以在应用程序的运行时定期执行就绪探测,但它也可以配置为仅在应用程序启动时执行此探测。一旦应用程序报告其健康,则此探测在整个应用程序的生命周期内将不再执行。

这种就绪健康检查最适合用于那些在应用程序被认为就绪和可操作之前必须完成的长时间运行任务的应用程序。回想一下,在微服务中,我们引入了几个额外的基础设施依赖项,我们需要监控和确认系统的整体健康状况,以确保只有最健康的 Pod 才会被分配流量。因此,正确配置健康检查对于确保我们有我们应用程序健康状况的最佳表示至关重要。

现在我们已经探讨了健康检查的工作原理以及编排器和监控系统如何使用它们,我们可以探索在我们的 ASP.NET Core API 中实现健康检查。

实现 ASP.NET Core 健康检查

ASP.NET Core 有一个内置的健康检查中间件,允许我们原生实现非常健壮的健康检查。此中间件不仅限于 API 项目,而且对我们监控应用程序的健康状况非常有用。原生创建就绪性和存活性健康检查,并支持 UI 仪表板。使用相对简单的存活性健康检查,我们可以实现一个简单的 API 端点,返回预期的简单响应。我们还可以使用更全面的就绪性健康检查来检查应用程序的依赖项的健康状况。

对于这个例子,我们将向我们的预约预订服务添加存活性和就绪性健康检查。此服务有多个依赖项,并且对我们应用程序中的几个操作至关重要。我们需要确保它始终保持健康状态,并在它降级时快速响应。

让我们首先探讨如何为 ASP.NET Core API 配置一个存活性健康检查。

添加存活性健康检查

如前所述,存活性检查是可以实现的最基本的健康检查。在我们的 ASP.NET Core 应用程序中,为此所需的基本配置是注册 AddHealthChecks 服务和添加健康检查中间件,其中我们定义了一个 URL。

我们对 Program.cs 文件进行了以下修改:

var builder = WebApplication.CreateBuilder(args);
// code omitted for brevity
builder.Services.AddHealthChecks();
var app = builder.Build();
// code omitted for brevity
app.MapHealthChecks("/healthcheck ");
app.Run();

任何尝试导航到 /healthcheck 端点的操作都将返回一个简单的纯文本响应,作为 HealthStatus。可能的 HealthStatus 值有 HealthStatus.HealthyHealthStatus.DegradedHealthStatus.Unhealthy

健康检查是通过 IHeathCheck 接口创建的。此接口允许我们扩展默认的健康检查,并为我们的健康检查添加更多逻辑,进一步自定义可能的响应值。我们可以使用以下代码块创建一个健康检查扩展:

 public class HealthCheck : IHealthCheck
{
    public Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context, CancellationToken =
            default)
    {
        var healthy = true;
        if (healthy)
        {
            // additional custom logic when the health is
            confirmed.
            return Task.FromResult(
                HealthCheckResult.Healthy("Service is
                    healthy"));
        }
        // additional custom logic when the api is not
           healthy
        return Task.FromResult(
            new HealthCheckResult(
                context.Registration.FailureStatus,
                    "Service is unhealthy"));
    }
}

IHeathCheck 继承迫使我们必须实现 CheckHealthAsync 方法。当健康检查被触发时,此方法会被调用,我们可以包含额外的代码来检查其他因素,并确定我们是否认为我们的应用程序是健康的。根据 healthy 的值,我们可以返回一个自定义消息。

现在要将 HealthCheack 添加到我们的服务中,我们修改 AddHealthChecks 服务注册如下:

builder.Services.AddHealthChecks()
    .AddCheck<HealthCheack>("ApiHealth");

在这里,我们添加了新的健康检查逻辑,并为它在应用程序的其他部分提供了一个特定的名称作为参考。此 AddCheck 方法允许我们为健康检查定义一个名称、默认失败状态值、映射到自定义健康检查端点的标签以及默认超时值。

现在基于我们的编排器和负载均衡器在执行健康检查时也倾向于看到与健康状况相关的适当响应这一概念,我们可以扩展 app.MapHealthChecks 中间件代码,以返回与健康状况相关的特定 HTTP 响应。在此过程中,我们还可以禁用缓存响应:

app.MapHealthChecks("/healthcheck", new HealthCheckOptions
{
    AllowCachingRepsonses = false,
    ResultStatusCodes =
    {
          [HealthStatus.Unhealthy] =
                 StatusCodes.Status503ServiceUnavailable,
        [HealthStatus.Healthy] = StatusCodes.Status200OK,
        [HealthStatus.Degraded] = StatusCodes.Status200OK,
    }
});

我们接下来可能想要调查的是在我们的响应中返回详细信息。目前,我们只返回带有状态的纯文本响应。我们可以使用System.Text.Json库中的方法创建一个自定义委托方法,如下所示。

我们首先需要向中间件指示我们有一个名为WriteJsonResponse的自定义ResponseWriter。我们需要将其添加到HealthCheckOptions列表中,使用以下方式:

app.MapHealthChecks("/healthcheck", new HealthCheckOptions
{
    // code omitted for brevity
    ResponseWriter = JsonResponse
});

然后我们使用以下方式定义WriteJsonResponse写入器:

private static Task JsonResponse(HttpContext context,
    HealthReport healthReport)
{
    context.Response.ContentType = "application/json;
        charset=utf-8";
   var options = new JsonWriterOptions { Indented = true };
    using var memoryStream = new MemoryStream();
    using (var jsonWriter = new Utf8JsonWriter
        (memoryStream, options))
    {
        jsonWriter.WriteStartObject();
        jsonWriter.WriteString("status",
            healthReport.Status.ToString());
        jsonWriter.WriteStartObject("results");
        foreach (var healthReportEntry in
           healthReport.Entries)
        {
            jsonWriter.WriteStartObject
                (healthReportEntry.Key);
            jsonWriter.WriteString("status",
                healthReportEntry.Value.Status.ToString());
            jsonWriter.WriteString("description",
                healthReportEntry.Value.Description);
            jsonWriter.WriteStartObject("data");
            foreach (var item in
                healthReportEntry.Value.Data)
            {
                jsonWriter.WritePropertyName(item.Key);
                JsonSerializer.Serialize(jsonWriter,
                    item.Value,
                    item.Value?.GetType() ??
                        typeof(object));
            }
            jsonWriter.WriteEndObject();
            jsonWriter.WriteEndObject();
        }
        jsonWriter.WriteEndObject();
        jsonWriter.WriteEndObject();
    }
    return context.Response.WriteAsync(
        Encoding.UTF8.GetString(memoryStream.ToArray()));
}

图 10.3显示了健康检查的结果:

图 10.3 – 显示了服务和数据库都可用且状态良好的健康检查响应

图 10.3 – 显示了服务和数据库都可用且状态良好的健康检查响应

现在我们可以包括有关 API 报告不健康或降级状态的健康状态的详细信息。此外,当我们添加更多健康检查时,此 JSON 响应的内容将用每个检查的详细信息填充。

图 10.4显示了不健康检查的结果:

图 10.4 – 显示了数据库不可用的健康检查响应

图 10.4 – 显示了数据库不可用的健康检查响应

现在我们有了更详细的响应,我们可以添加更详细的检查,例如数据库探测。这将作为检查以验证 API 可以通过配置的数据库与数据库通信。由于我们使用 Entity Framework 进行此连接,我们可以实现一个DbContext检查。我们从Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore NuGet 包开始。然后我们使用以下代码片段修改AddHealthChecks方法注册:

builder.Services.AddHealthChecks()
    .AddCheck<HealthCheack>("ApiHealth")
      .AddDbContextCheck<ApplicationDbContext>
        ("DatabaseHealth");

此上下文健康调用 Entity Framework Core 的内置CanConnectAsync方法,并使用该响应来推断数据库连接健康。

现在我们可以检查我们服务的健康状态及其与数据库的连接性,让我们为就绪性检查进行配置。

添加就绪性健康检查

正如我们讨论过的,就绪性检查指示应用程序及其依赖项是否已成功启动并准备好开始接收请求。我们可以为就绪性检查定义一个单独的端点,并根据使用的 URL 进一步自定义应执行的检查。

要在不同的 URL 上实现活动性和就绪性检查,我们可以在AddHealthChecks方法的扩展中添加一个tags参数。这允许我们传入一个标签名称数组。我们可以这样标记我们的健康检查:

builder.Services.AddHealthChecks()
    .AddCheck<HealthCheack>("ApiHealth", tags: new[] {
      "live"})
 .AddDbContextCheck<ApplicationDbContext>("DatabaseHealth",
    tags: new[] { "ready" });

现在我们已经标记了我们的健康检查,我们可以继续创建我们的特定检查端点并将它们与标签关联:

app.MapHealthChecks("/healthcheck/ready", new
    HealthCheckOptions
{
    Predicate = healthCheck =>
        healthCheck.Tags.Contains("ready"),
    // code omitted for brevity
});
app.MapHealthChecks("/healthcheck/live", new
    HealthCheckOptions
{
    Predicate = healthCheck => false;
    // code omitted for brevity
});

使用这段新代码,/healthcheck/ready端点将仅过滤标记为ready的健康检查。在/health/live端点中,我们将谓词值设置为false以忽略所有标签并执行所有健康检查。

虽然我们不会详细探讨 Kubernetes 或其他编排器,但我们想看看编排器如何与我们的健康检查端点交互。

在编排器中配置健康探针

监控并非仅限于编排器,正如我们之前已经确立的。有一些服务为我们提供监控服务,并允许我们将探针配置到我们的应用程序中。这些服务通常允许我们添加警报并配置响应时间阈值。这些警报在帮助我们根据配置的阈值响应故障或关注的情况时非常有用。

在微服务应用程序中,我们需要一种尽可能高效地监控许多服务的方法。我们需要做的唯一配置越少,越好。我们有几种部署模型可以使用,其中最突出的是由编排器管理的容器。Microsoft Azure 有几种 Web 应用程序部署模型,包括容器 Web 应用程序WAC)、Azure 容器实例ACI)和Azure Kubernetes 服务AKS)。

WAC 是 App Service 的一部分,因此健康检查的工作方式与 Azure Web 应用程序相同。它允许你指定一个健康检查端点,该端点将在 2xx 和 3xx HTTP 响应范围内返回响应。它还应在 1 分钟内返回此健康检查响应,以便服务被认为是健康的。

下一个选项是 ACI,其中健康检查被称为健康探针。这些探针配置了检查周期,它决定了检查的频率。当健康检查成功完成时,容器被认为是健康的;如果不成功,则容器是不健康的或只是不可用。使用 ACI,我们可以配置存活和就绪健康检查。我们的探针可以在容器上执行命令或执行 HTTP GET请求。当我们执行存活探针时,我们验证容器是否健康;如果不健康,ACI 可能会关闭容器并启动一个新的实例。就绪探针旨在确认容器是否可用于请求处理,正如我们之前讨论的,在应用程序启动过程中这一点更为重要。

在 Azure Kubernetes 服务(AKS)中,我们有一个与 ACI 中非常相似的健康检查和探针方法。开箱即用,Kubernetes 支持存活和就绪探针;如之前所见,主要区别是 Kubernetes 建议您为检查应用程序启动时的健康状态有一个单独的探针,这个探针与在应用程序运行期间持续进行的就绪探针是分开的。我们还可以实现 HTTP GET 请求探针以及 TCP 探针来检查我们的容器。

Kubernetes 使用一种名为 spec.template 字段的标记语言进行配置。

以下是一个示例 YAML 配置,它创建了一个执行容器启动、存活和就绪健康检查的部署对象:

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness-api
  name: liveness-http
spec:
  ports:
  - name: api-port
    containerPort: 8080
    hostPort: 8080
  containers:
  - name: liveness-api
    image: registry.k8s.io/liveness
    args:
    - /server
    livenessProbe:
      httpGet:
        path: /healthcheck/live
        port: api-port
      initialDelaySeconds: 3
      failureThreshold: 1
      periodSeconds: 3
    startupProbe:
      httpGet:
        path: /healthcheck/ready
        port: api-port
      failureThreshold: 30
      periodSeconds: 10
    readinessProbe:
      httpGet:
        path: /healthcheck/ready
        port: api-port
      failureThreshold: 30
      periodSeconds: 10

描述健康检查的 YAML 文件部分是 livenessProbestartupProbereadinessProbe。与其它探针定义相比,就绪探针的主要区别在于它执行一个命令,而不是调用一个端点。

通过这种方式,我们获得了一些关于健康检查的基本知识,了解它们是如何工作的,以及为什么我们需要它们。

摘要

健康检查是简单而强大的结构,帮助我们确保应用程序以最大效率运行。我们认识到,对于我们来说,不仅要监控和报告服务的正常运行时间,还要监控和报告依赖项,例如数据库和其他可能需要应用程序正常运行的其它服务。

使用 ASP.NET Core,我们可以访问一个内置的健康检查机制,该机制可以自定义和扩展以实现特定的检查,并将它们与不同的端点关联。这在我们需要区分针对被调用端点的测试类型时特别有用。

我们还探讨了如何配置编排器以轮询我们的健康检查端点。编排器使得监控和响应故障变得更容易,因为它们将处理将流量路由到健康实例,并在需要时重启实例。

健康检查不仅帮助我们监控目标网络服务,我们还可以配置健康检查来报告下游服务。这在我们将微服务通过如 API 网关模式等模式实现依赖关系时特别有用。我们将在下一章中探讨实现此模式的方法。

第十一章:实现 API 和 BFF 网关模式

当使用微服务架构方法构建应用程序时,我们已经意识到我们需要跟踪多个 API 端点。我们实际上已经从通过单体提供的单个端点转变为一系列端点。其中一些端点将由其他 API 调用,而另一些则直接集成到与微服务交互的客户端应用程序中。

这成为一个挑战,因为我们最终将客户端应用程序与用于集成各种服务的自定义逻辑混为一谈,并可能编排服务间的通信。我们希望将客户端应用程序代码保持尽可能简单和可扩展,但与每个服务的集成不支持这种观点。

这就是我们将考虑实现 API 网关模式的地方,它引入了客户端和服务之间的一个中心接触点。我们的 API 网关将记录所有端点,并公开一个单一的 API 地址,其中端点将映射到其他微服务的各种端点。

在本章中,我们将探讨各种场景,这些场景将使 API 网关成为我们应用的理想选择,以及实现方法。

在阅读本章之后,我们将能够做到以下几件事情:

  • 理解 API 网关及其重要性

  • 使用行业领先的技术和方法实现 API 网关

  • 正确实现前端后端BFF)模式

技术要求

本章中使用的代码引用可以在本项目的 GitHub 仓库中找到,该仓库托管在github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch11

什么是 API 网关模式?

为了理解 API 网关模式及其必要性,我们需要回顾面向服务的架构的基本原理以及我们如何构建此类解决方案。

面向服务的架构包括应用的三层主要层:

  • 客户端:也称为前端。这个客户端应用程序是用户看到的,它被设计为从 API 中获取数据。其功能通常限于 API 提供的功能,并且前端开发者可以利用多种技术向最终用户暴露功能。

  • 服务器:也称为后端。这个架构部分包含 API 和业务逻辑。客户端应用程序的智能程度取决于后端。后端可以由一个或多个服务组成,就像微服务的情况一样。

  • 数据库:数据库是整个应用程序的锚点,因为它存储了 API 后端使用的所有数据,并在前端显示。

这种应用程序布局在单体应用中很受欢迎,其中所有前端所需的功能都可以在一个 API 中找到。这是一种有效的开发方法,许多成功和强大的应用程序都以此为主导。然而,我们已经探讨了单体方法的缺点,API 可能会变得臃肿,难以长期维护。在追求微服务方法的过程中,我们似乎放弃的主要优势是,我们有一个客户端应用的单一入口点,而不是每个服务都有自己的要求。

虽然微服务架构引领我们走向实现面向服务的架构的应用程序,但我们需要考虑到我们的客户端将需要跟踪多个后端或 API,并且足够智能,能够为每个用户请求编排调用。根据所提供的描述,这是应用中应该最不智能的部分承担的大量责任。

图 11.1 展示了客户端和微服务:

图 11.1 – 客户端应用需要了解所有微服务的端点,并保留每个微服务如何工作的知识

图 11.1 – 客户端应用需要了解所有微服务的端点,并保留每个微服务如何工作的知识

正是在这里,我们引入了 API 网关。这个网关将位于我们的服务和客户端应用之间,简化两者之间的通信。对于客户端,它将暴露一个单一的基 URL,客户端将乐意与之交互,并视为一个 API 服务;对于微服务,它将充当一个通道,将来自客户端的请求转发到适当的微服务。

让我们回顾一下引入 API 网关的优势。

API 网关的优势

当客户端的请求到来时,它被网关接收,网关解释请求,如果需要,转换数据,然后将它转发到适当的微服务。实际上,对于可能需要调用多个微服务的情况,我们可以实现编排和结果聚合,并根据需要向客户端返回更准确的数据表示。

我们的 API 网关还允许我们为我们的微服务集中以下任务:

  • 集中式日志记录:从 API 网关,我们可以集中记录所有流量到我们的各种端点,并跟踪下游服务的成功和错误响应。这很有优势,因为它免除了我们在每个服务中实现日志记录的需要,并可能产生非常冗长的日志。使用 API 网关允许我们集中实施,并优先考虑写入日志的内容,这有助于我们更好地分类同步操作的结果。我们还可以使用网关来跟踪和记录对下游服务调用的统计信息和响应时间。

  • 缓存:缓存作为一种临时数据存储,在主数据源可能离线或我们需要减少对服务调用次数时非常有用。我们可以在网关中使用缓存层来稳定我们的应用程序,并可能提高应用程序的性能。通过适当的协调和定制,我们可以使用此缓存进行端点的高速读取操作,这些端点流量很大,甚至可以使用它来处理部分失败,即当服务不可用时,我们使用缓存数据作为响应。

  • 安全性:保护微服务可能是一项繁琐且技术性强的任务。每个服务可能都有独特的安全需求,这可能导致在协调安全措施和实施时产生开发开销。使用 API 网关,我们可以在网关级别集中安全措施。这可以减轻微服务在验证和授权对资源访问时的负担,因为网关将管理大多数这些需求。我们还可以在此级别实施 IP 白名单,并限制对批准的 IP 地址列表的访问。

  • 服务监控:我们可以配置我们的 API 网关对下游服务进行健康检查。如前所述,健康检查或探测有助于我们确定服务状态。由于网关需要转发请求,在尝试操作之前确定下游服务的健康状况非常重要。由于网关可以确定服务的健康状况,它可以配置为优雅地处理失败和部分失败。

  • 服务发现:我们的网关需要知道所有服务的地址以及如何根据需要转换和转发请求。为此,网关需要一个所有下游服务的注册表。网关将简单地作为服务端点的包装器,并向客户端应用暴露一个单一地址。

  • 速率限制:有时,我们希望限制来自同一来源的快速连续请求的数量,怀疑这种活动可能是对服务端点的分布式拒绝服务(DDoS)攻击。使用 API 网关,我们可以实施通用规则,以控制端点可以访问的频率。

再次强调,网关实现最重要的方面是它从我们的客户端承担了大部分责任,这使得扩展和多样化客户端代码变得更加容易。

图 11.2 展示了客户端、网关和微服务:

图 11.2 – 引入网关后,客户端应用现在有一个端点,不需要了解底层服务

图 11.2 – 引入网关后,客户端应用现在有一个端点,不需要了解底层服务

现在我们已经看到 API 网关如何帮助我们集中访问多个 API 端点,并使客户端应用更容易集成 API 操作,让我们回顾一下使用这种模式的一些缺点。

API 网关的缺点

虽然优势明显且无可辩驳,但我们还必须意识到引入 API 网关的弊端。API 网关可能以另一个微服务的形式出现,具有讽刺意味的是。处理过多服务的补救措施是构建一个来统治它们。这样,就引入了一个单点故障,因为当这个网关服务离线时,我们的客户端应用将无法向各种服务发送请求。现在,还需要额外的维护,因为我们的网关服务需要随着它所交互的每个服务的变化而变化,以确保请求和响应被准确解释。我们还面临增加请求往返时间的风险,因为这一新层需要足够高效,以接收原始请求,将其转发,然后从微服务检索并转发响应。

虽然我们有明显的优势可以参考,但我们需要确保我们知道、接受并减轻为我们的微服务应用程序实现网关服务所涉及的风险。

正如我们所见,所有 API 都存在一些跨领域和通用问题。在每个服务中实现这些通用需求可能导致冗余,而试图构建一个单一的服务来实现它们可能会导致单体应用程序的创建。使用一个加强了我们所需 API 网关主要功能的第三方应用程序更容易。

现在,让我们回顾一下 API 网关可能实现的方式。

实现 API 网关模式

在实现 API 网关时,应遵循某些指南。鉴于其描述,我们可能会倾向于开发一个新的微服务,将其标记为网关,并自行开发和维护 API 集成。

当然,这是一个可行的方案,并且它确实给了你对实现、规则和特性的完全控制,这些是你认为对应用程序和下游服务必要的。我们还可以通过编排对下游服务的请求和响应以及相应地聚合和转换数据来实现特定的业务逻辑来管理某些操作。然而,这可能导致拥有一个厚 API 网关。我们将在下一节中进一步讨论这一点。

厚 API 网关

当我们意识到我们将过多的业务操作逻辑放入我们的 API 网关时,就创造了厚 API 网关这个表达。我们的网关应该更多地作为客户端和微服务之间的抽象层,而不是业务逻辑的主要中心。我们应该避免在网关中放置业务逻辑,这将增加实现复杂性,并增加网关的维护工作量。

我们也可以称这为过于雄心勃勃的网关,通常应尽量避免将 API 网关作为我们应用程序行为的核心点。我们还有可能实现一个单体架构,并最终在我们的微服务应用程序中回到起点。同时,我们也不应完全避免这种网关实现,因为通过拥有带有一些业务逻辑的网关,我们可以利用一些额外的模式。

在本书的早期部分,我们回顾了Saga 模式和更具体的编排模式。回想一下,编排模式依赖于一个中央服务,该服务对下游服务有监督权,监控服务响应,并相应地决定继续或终止 saga。在这种情况下,一个厚 API 网关将有助于实现这种行为。

最终,我们都在我们的应用程序中有不同的需求,这些是,再次强调,我们在实施时应该遵守的指南。我们应该始终根据我们的需求为我们的应用程序做出最佳决策。

在所有这些因素可能不适用的情况下,我们需要最小化网关实现中的业务逻辑量,我们可能需要考虑现有的工具和服务,这些工具和服务可以帮助我们以更少的维护和开发工作量完成这些任务。在这个时候,我们可以开始考虑Amazon API GatewayMicrosoft Azure API Management和像Ocelot这样的开源解决方案。

在下一节中,我们将回顾使用Microsoft Azure API Management实现 API 网关功能。

使用 Azure API Management 实现 API 网关

Microsoft Azure API Management 是一个基于云的解决方案,可在 Microsoft Azure 开发工具套件中找到。它旨在抽象化、保护、加速和观察后端 API。在执行此操作时,它通过服务发现安全地暴露 API,供内部和外部客户端使用,无论是在 Azure 生态系统内还是外部。

它具有以下多个用途:

  • API 网关:允许对后端服务的受控访问,并允许我们实施节流和访问控制策略。网关作为后端服务的门面,允许 API 提供者减少对后端不断发展的服务套件进行更改所涉及的成本。网关提供了用于安全、节流、监控甚至缓存的持续和强大的配置选项。

  • 虽然这是一个基于云的服务,但 API 网关也可以部署在本地环境中,以满足希望自行托管其 API 以提高性能和合规性的客户。这个 自托管网关 被打包成一个 Docker 容器,通常部署到 Kubernetes。

  • 开发者门户:一个自动生成且完全可定制的网站。第三方开发者可以使用开发者门户来审查 API 文档并了解如何将其集成到他们的应用程序中。

  • 管理平面:Azure API Management 的这部分允许我们配置服务的设置。我们可以从多个来源定义 API 架构,并配置对不同的协议和标准(如 OpenAPI 规范WebSocketsGraphQL)的支持。

现在,让我们探索设置第一个 Azure API 管理服务所需的一些步骤。为此练习,您需要一个 Azure 订阅,如果您还没有,您可以在开始之前创建一个免费的 Microsoft Azure 账户

我们的第一步是登录到 Azure 门户。然后您可以使用搜索功能,输入 API 管理服务 并在搜索结果中选择匹配的选项。结果页面将列出您目前拥有的所有 API 管理服务 实例。为此练习,您可以点击 创建

图 11.3 显示了 Azure API Management 服务的搜索结果:

图 11.3 – 为此练习创建一个新的 API 管理服务

图 11.3 – 为此练习创建一个新的 API 管理服务

在下一屏幕上,我们可以继续填写服务的详细信息并选择以下选项:

  • 订阅:此新服务将配置的订阅。

  • 资源组:与正在配置的服务关联的资源逻辑组。为此练习可以创建一个新的资源组。

  • 区域:这是服务大部分用户将位于的最佳地理表示。

  • 资源名称:您将要配置的实例的唯一名称。您需要修改 图 11.4 中显示的名称才能继续。

  • 组织名称:您的组织名称。这将与 API 服务的所有权相关联。

  • 管理员电子邮件地址:用于所有来自 API 管理的通信和通知的电子邮件地址。

  • 定价层:这决定了我们偏好的服务正常运行水平。对于此实例,我们将使用 开发者 层,它不适用于生产使用。

图 11.4 展示了各种 Azure API 管理选项:

图 11.4 – 创建 API 管理服务所需的最低值

图 11.4 – 创建 API 管理服务所需的最低值

在创建 API 管理服务后,我们可以开始将我们的微服务导入管理门户。现在,我们的 API 管理服务将作为我们服务的前端,允许我们根据需要控制访问和转换数据。如果它们的 API 可以在整个互联网或网络上被发现,我们可以从任何来源导入 API。

API 管理服务将处理客户端与映射到请求端点的目标服务之间的所有通信,无论实现 API 所使用的技术。

图 11.5 展示了添加到 Azure API 管理服务的 API:

图 11.5 – API 管理服务允许您添加 API 并映射自定义路由,当调用时,将请求重定向到映射的资源

图 11.5 – API 管理服务允许您添加 API 并映射自定义路由,当调用时,将请求重定向到映射的资源

图 11.5 中,我们可以看到我们已经将我们的预约和客户 API 映射到 API 管理服务,并基于现在通过服务提供的首选端点定义了一个基本 URL。

图 11.6 中,我们可以看到我们可以管理允许的请求类型,以及为每种请求类型定义我们的策略和转换。

图 11.6 还显示了 Azure API 管理服务中的各种请求处理选项:

图 11.6 – API 管理服务允许您轻松管理每个 API 允许的请求类型,并为请求和响应定义转换策略

图 11.6 – API 管理服务允许您轻松管理每个 API 允许的请求类型,并为请求和响应定义转换策略

使用 Azure API Management,我们能够获得许多标准 API 网关功能,并且当我们有生产级定价层时,我们还获得了可用性和服务正常运行时间的保证。如果我们选择不自行托管此应用程序,我们可以利用其 软件即服务 (SaaS) 模型,其中我们大大减少了与使其上线和运行相关的任何基础设施工作的责任。

我们可能会遇到需要自行托管我们的网关,并且 API 管理不是可选方案的情况。在这种情况下,我们可以考虑提供自己的 API 网关应用程序。对于这种实现,一个很好的候选者是 Ocelot,这是一个轻量级的 API 网关包,可以直接安装到标准的 ASP.NET Core 项目中。我们将在下一节中进一步讨论这一点。

使用 Ocelot 实现 API 网关

Ocelot 是一个基于 .NET Core 平台的开源 API 网关。它是一个简单的网关实现,通过抽象统一了对微服务的通信,正如我们期望从网关中得到的。Ocelot API 网关将转换传入的 HTTP 请求,并根据预设的配置将它们转发到适当的微服务地址。

它是一种流行的广泛使用的 API 网关技术,并且可以很容易地使用 NuGet 包管理器安装到 .NET Core 应用程序中。其配置可以用 JSON 格式概述;在这里,我们定义 upstreamdownstream 路由。upstream 路由是指暴露给客户端的服务地址,而 downstream 路由是映射微服务的真实地址。我们还可以为每个上游服务路由定义允许的协议,从而允许对我们在路由上愿意接受的流量类型进行强大的控制。

让我们一起设置一个 Ocelot API 网关应用程序。我们将使用一个简单的 ASP.NET Web API 项目模板,并首先通过 NuGet 包管理器添加 Ocelot 包:

Install-Package Ocelot

现在我们已经安装了我们的包,我们需要开始概述我们的路由配置。我们可以创建一个新的配置文件,并将其命名为 ocelot.json。在这个 JSON 文件中,我们将概述所有 upstreamdownstream 路由。此配置将类似于以下内容:

{
     "Routes": [
    {
      "DownstreamPathTemplate": "/api/Patients",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5232
        }
      ],
      "UpstreamPathTemplate": "/Patients",
      "UpstreamHttpMethod": [
        "GET",
        "POST"
      ]
    },
    {
      "DownstreamPathTemplate": "/api/Patients/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5232
        }
      ],
      "UpstreamPathTemplate": "/Patients/{id}",
      "UpstreamHttpMethod": [
        "GET",
        "PUT"
      ]
    },
    {
      "DownstreamPathTemplate": "/api/Appointments",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5274
        }
      ],
      "UpstreamPathTemplate": "/Appointments",
      "UpstreamHttpMethod": [
        "POST",
        "PUT",
        "GET"
      ]
    }
  ],
  "GlobalConfiguration": {
    "BaseUrl": http://localhost:5245""
  }
}

此配置文件很简单,一旦我们掌握了模式,我们就可以根据需要扩展它以用于我们其余的服务。各部分在此处解释:

  • Routes: 这是我们的 JSON 配置的父级部分,我们在这里开始定义上游和下游配置。

  • DownstreamPathTemplate: 此部分概述了微服务可以找到的地址。

  • DownstreamScheme: 这概述了我们将与定义的微服务进行通信的协议。

  • DownstreamHostAndPorts: 主机地址和端口在此部分定义。

  • UpstreamPathTemplate: 我们概述了我们将暴露给客户端应用的路径。通过调用此定义的路由,Ocelot 将自动将请求重定向到在 DownstreamPathTemplate 中定义的服务。注意,在前面的示例中,如果我们需要,我们可以重命名路由。下游 API 中原本的 Customers API 端点只能通过 Patients 端点地址访问。

  • UpstreamHttpMethod: 在这里,我们定义我们将接受作为合法请求的方法。

  • 全局配置:我们在配置中概述了 BaseUrl,所有请求流量都应该通过它发送。

现在,让我们配置我们的应用程序以使用这些配置并使用 Ocelot 包。我们将从向 Program.cs 文件中添加以下行开始:

builder.Configuration.AddJsonFile("ocelot.json", optional:
  false, reloadOnChange: true);
builder.Services.AddOcelot(builder.Configuration);

这些行在应用程序启动时将 ocelot.json 文件添加到全局配置中,然后注册 Ocelot 作为服务。然后,我们需要添加 Ocelot 中间件,如下所示:

await app.UseOcelot();

通过这些简单的配置,我们现在可以在客户端应用程序中使用网关 URL 作为 API URL。

Ocelot 文档齐全且可扩展。它支持其他功能,例如以下内容:

  • 内置缓存管理

  • 速率限制器

  • 支持原生 .NET Core 日志集成

  • 支持 JSON Web Token (JWT) 认证

  • 重试和断路器策略(使用 Polly

  • 聚合

  • 预下游和后下游请求转换

现在我们已经学习了如何使用 Ocelot 设置一个简单的网关,让我们来看看如何扩展这个功能。我们将从添加缓存管理开始。

添加缓存管理

缓存作为请求到更可靠的数据存储之间的临时数据存储。这意味着缓存将根据它最后接收的数据集临时存储数据。良好的缓存管理建议我们根据间隔刷新缓存,并用数据的新版本刷新它。

当我们需要减少对主数据库的访问次数时,缓存非常有用,这可以减少数据库调用带来的延迟和读写成本。Ocelot 对缓存有一些支持,这对于在网关应用程序中本地解决小缓存问题很有帮助。

这可以通过相当容易的方式添加。我们将从使用 NuGet 包管理器执行以下命令开始:

Install-Package Ocelot.Cache.CacheManager

这个包为我们提供了所需的缓存扩展,然后我们可以在 Program.cs 文件中引入一个扩展方法。这个扩展方法看起来像这样:

builder.Services.AddOcelot()
    .AddCacheManager(x =>
    {
        x.WithDictionaryHandle();
    });

最后,我们在 ocelot.json 配置文件中添加以下行:

"FileCacheOptions": {
    "TtlSeconds": 20,
    "Region": "SomeRegionName"
  }

现在我们已经引入了一个配置来管理我们的网关中缓存应该如何发生,我们必须概述值应该缓存最多 20 秒。这将为已定义的下游服务添加原生缓存支持。一旦缓存期过期,请求将按预期转发,然后新的响应值将再次缓存,为期定义的时间。

缓存有助于减轻我们对服务的压力,但它合理地只对短期施加限制。如果我们延长这个期限,那么我们就有可能返回过时的数据太长时间。我们还想实施另一层保护,那就是速率限制。让我们接下来探讨这个问题。

添加速率限制

速率限制帮助我们防御 DDoS 攻击的影响。本质上,我们规定了我们的服务端点可以被同一资源访问的频率。当请求频率违反我们的规则时,我们将拒绝其他传入的请求。这有助于防止可能的服务性能下降。我们的服务将不会尝试满足所有请求,尤其是那些可能看起来像攻击的请求。

速率限制通过记录原始请求的 IP 地址来工作。对于来自同一 IP 地址的所有其他请求,我们评估它是否合法,并且是否在控制从同一发送者发出请求频率的约束范围内。当检测到违规规则时,我们发送失败响应,并且不在服务中转发请求。

Ocelot 允许我们为配置的下游服务配置速率限制。这很好,因为它允许我们全局管理这些规则,我们不需要在每个服务中实现这些规则。

首先,让我们修改我们的代码,以实现特定下游服务的速率限制。我们可以在服务的配置文件中添加以下代码:

{
      "DownstreamPathTemplate": "/api/Patients",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5232
        }
      ],
      "UpstreamPathTemplate": "/Patients",
      "UpstreamHttpMethod": [
        "GET",
        "POST"
      ],
      "RateLimitOptions": {
        "ClientWhitelist": [],
        "EnableRateLimiting": true,
        "Period": "5s",
        "PeriodTimespan": 1,
        "Limit": 1
      }
    },

我们在ocelot.json文件中引入了一个新的部分,称为RateLimitingOptions。更具体地说,我们在患者的下游服务配置中添加了此新配置。现在,将对以下如何访问此下游服务施加以下限制:

  • ClientWhiteList: 允许不受速率限制限制的客户端列表。

  • EnableRateLimiting: 一个标志,表示是否应该执行速率限制限制。

  • Period: 此值指定我们用来确定客户端是否正在发出违反限制选项的请求的时间量。我们可以使用以下:

    • s 表示秒

    • m 表示分钟

    • h 表示小时

    • d 表示天

模式相当容易遵循。在我们的例子中,我们对请求有一个 5 秒的限制。

  • PeriodTimeSpan: 这是一个冷却期。在此期间,违反限制限制的客户端后续请求将被拒绝,时钟将重置。一旦这个周期过去,客户端可以继续发出请求。

  • Limit: 客户端在指定时间段内允许发出的请求数量。在此,我们定义每 5 秒只能从客户端接收一个请求。

然后,我们可以定义全局值,以控制网关如何处理速率限制。我们可以在GlobalConfiguration部分添加一个类似的RateLimitingOptions部分:

"GlobalConfiguration": {
    "BaseUrl": http://localhost:5245"",
    "RateLimitOptions": {
      "DisableRateLimitHeaders": false,
      "QuotaExceededMessage": "Too many requests!!!",
      "HttpStatusCode": 429,
      "ClientIdHeader": "ClientId"
    }
  }

现在,我们有一些新的选项,如下所示:

  • DisableRateLimitHeaders: 一个标志,用于确定我们是否禁用或启用速率限制头。这些头值通常如下:

    • X-Rate-Limit: 在指定时间段内可用的最大请求数量

    • Retry-After: 指示客户端在发出后续请求之前应等待多长时间

  • QuotaExceededMessage:允许我们定义一个自定义消息发送给违反限制规则的客户端。

  • HttpStatusCode:这概述了违反规则时要发送的响应代码。429TooManyRequests 是这种情况的标准响应。

  • ClientIdHeader:指定用于识别发起请求的客户端的头部。

通过这些小的改动,我们已经对所有进入 /patients 端点的请求实施了速率限制。如果两个或更多请求在 5 秒内从同一客户端地址进入,我们将以 429TooManyRequests HTTP 响应进行响应。

在使用 Ocelot 时,我们可能还会考虑聚合我们的响应。这允许我们将多个调用串联起来,并减少客户端编排这些调用的需求。我们将学习如何添加这个功能。

添加响应聚合

响应聚合是一种用于合并多个下游服务的响应并相应发送单个响应的方法。本质上,API 网关可以通过接受来自客户端的单个请求,然后向多个下游服务发出分布式并行请求来实现这一点。一旦从下游服务收到所有响应,它将合并数据成一个单一的对象并返回给客户端。

这种方法带来了几个好处。最显著的好处是我们可以减少客户端需要向多个服务请求数据的请求数量。API 网关将自动处理编排。客户端也只需要知道一个模式。因此,几个可能复杂的请求可以合并成一个请求体,这将减少客户端需要跟踪的模式数量。这种方法还将加快涉及调用多个服务的响应时间。由于调用将并行进行,我们不必等待依次进行服务调用所需的全段时间。

Ocelot 允许我们以相当容易的方式配置聚合调用。我们将用键装饰我们的下游服务配置,这些键将作为我们聚合配置的参考点。如果我们想要聚合一个应该返回患者及其所有预约的调用,我们需要进行以下修改:

    {
      "DownstreamPathTemplate": "/api/Patients/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5232
        }
      ],
      "UpstreamPathTemplate": "/Patients/{id}",
      "UpstreamHttpMethod": [
        "GET",
        "PUT"
      ],
      "Key": "get-patient"
    }

我们首先向 api/patients/{id} 下游服务配置中添加一个新的键。这个键充当别名,我们稍后会使用它。我们还将添加一个新的下游服务配置和新的端点。配置看起来像这样:

    {
      "DownstreamPathTemplate":
          "/api/user/Appointments/{id}",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5274
        }
      ],
      "UpstreamPathTemplate": "/Appointments/user/{id}",
      "UpstreamHttpMethod": [
        "GET"
      ],
      "Key": "get-patient-appointments"
    }

将在预约服务中实现的匹配端点看起来像这样:

// GET: api/Appointments/user/{id}
        [HttpGet("user/{id}")]
        public async Task<ActionResult<List<Appointment>>>
            GetAppointmentsByUser(Guid id)
        {
            var appointments = await _context.Appointments
                .Where(q => q.PatientId == id)
                .ToListAsync();
            return appointments;
        }

现在我们已经配置了新的端点并修改了下游服务配置,我们需要为我们的聚合编排添加一个新的配置:

"Aggregates": [
    {
      "RouteKeys": [
        "get-patient",
        "get-patient-appointments"
      ],
      "UpstreamPathTemplate": "/get-patient-details/{id}"
    }
  ],

现在,我们可以使用聚合配置中定义的端点,执行一个调用,该调用将返回患者的记录以及他们所预约的所有预约。这些信息几乎同时来自多个服务。我们的客户端不再需要多次调用以获取这些信息。

这种简单而强大的技术帮助我们更好地协调 API 调用,并精确地呈现客户端应用所需的信息。它促进了在检索数据时的行为驱动型工作流程,并减少了每个客户端应用将需要的开发开销。

现在我们已经看到我们可以如何使用我们的 API 项目或 Azure API 管理来实现 API 网关,我们已经克服了微服务应用中的一个主要障碍。我们不再需要构建需要跟踪我们所有微服务地址的客户端应用。

这又引发了一个新的担忧。不幸的是,不同的设备可能对如何与我们服务交互有不同的要求。移动客户端可能需要特殊的考虑,例如安全性和缓存,而 Web 应用则不需要。这给我们在中央网关中跟踪配置增加了更多的复杂性,相对于托管客户端应用的设备而言。

这些考虑让我们走上了为每种服务客户端实现一个网关的道路。这种实现方法被称为前端后端模式,我们将在下一节中讨论。

前端后端模式

虽然 API 网关解决了几个问题,但它并不是一个万能的解决方案。我们仍然需要应对满足多种设备类型和由此产生的客户端应用的可能性。例如,我们可能需要为移动客户端使用额外的压缩和缓存规则,而网站可能不需要很多特殊考虑。随着能够与 API 交互的设备越来越多,我们越需要确保我们能够支持集成。

图 11.7 展示了多个客户端和一个网关:

图 11.7 – 所有客户端设备访问相同的网关,导致某些设备效率低下

图 11.7 – 所有客户端设备访问相同的网关,导致某些设备效率低下

所有这些考虑都很好地说明了前端后端BFF)模式的好处。这种模式允许我们提供按设备 API 的方法。BFF 模式允许我们根据我们希望用户在特定用户界面上拥有的体验来精确定义我们的 API 功能。这使得我们更容易根据客户端的要求开发和维护 API,并简化了在多个客户端之间交付功能的过程。

图 11.8 展示了一个 BFF 的设置:

图 11.8 – 每个客户端应用程序都有一个端点指向特别配置以优化目标设备类型 API 流量的网关

图 11.8 – 每个客户端应用程序都有一个端点指向特别配置以优化目标设备类型 API 流量的网关

现在,我们可以优化每个网关实例,以最有效的方式处理特定设备的流量。例如,我们的移动应用程序可能需要额外的缓存或压缩设置,我们可能需要重写请求头。我们甚至可能定义额外的头信息,以便从我们的移动设备提供,因为我们可能需要跟踪设备类型和位置。简而言之,我们需要尽可能地为每个可能的设备提供服务。

Azure API Management 具有允许我们在转发请求之前查询传入请求并修改请求,或者在将响应发送到请求客户端之前修改响应的功能。通过定义这些策略,我们可以实现类似 BFF 的机制,其中策略被定义为查找设备类型或通常请求的来源,并尽可能优化以转发或返回。

Ocelot 可能需要一些更复杂的逻辑来支持此类策略。使用 Ocelot 实现此模式更推荐的方式是使用多个 Ocelot 实现。在这种实现风格中,我们会创建多个 Ocelot 项目,每个项目都有其特定的用途,例如移动端、网页和公共网关,并为允许的上游和下游服务添加每个配置。我们还可以为每个实现指定速率限制和缓存选项。

让我们回顾一下如何使用 Ocelot 实现此模式。

使用 Ocelot 的 BFF 模式

我们已经看到我们可以配置 Ocelot 作为我们的 API 网关。这只是一个简单的扩展,我们之前所做的工作,即创建额外的项目并按类似方式配置它们。我们可以保留已经拥有的网关,并专门用于第三方应用程序访问。通过我们定义的上游和下游服务,我们可以限制第三方只能访问那些端点。

然后,我们可以创建一个新的 Ocelot 项目,并专门为我们的网页客户端使用它。假设我们不想在网页客户端上应用速率限制,可以将缓存时间减少到 10 秒而不是 20 秒。鉴于这是我们网页应用程序,我们可以放宽大多数这些限制,允许更宽松的交互。

此配置文件将看起来像这样:

{
  "Routes": [
    {
      "DownstreamPathTemplate": "/api/Patients",
      "DownstreamScheme": "http",
      "DownstreamHostAndPorts": [
        {
          "Host": "localhost",
          "Port": 5232
        }
      ],
      "UpstreamPathTemplate": "/web/Patients",
      "UpstreamHttpMethod": [
        "GET",
        "POST"
      ]
    },
   // omitted for brevity   ],
  "FileCacheOptions": {
    "TtlSeconds": 10,
    "Region": "SomeRegionName"
  },
  "GlobalConfiguration": {
    "BaseUrl": http://localhost:5245""
  }
}

这看起来与我们在之前的网关中已经做过的类似,但请注意,现在我们有一个独特的机会来定义自定义路径,这些路径与我们要实现的网络入口点相匹配,同时根据需要添加/删除配置,以适应网络客户端。此外,请注意,它将从不同的地址广播,这将防止客户端之间出现任何引用冲突。

我们还可能想要实现一个具有较少限制的移动客户端,类似于我们在网络网关中概述的内容,但我们可能还想要自定义聚合操作。因此,对于我们的移动客户端网关,我们可以在 Ocelot 配置中添加以下聚合器定义:

  "Aggregates": [
    {
      "RouteKeys": [
        "get-patient",
        "get-patient-appointments"
      ],
      "UpstreamPathTemplate": "/get-patient-details/{id}",
      "Aggregator": "PatientAppointmentAggregator"
    }
  ],

Program.cs文件中,我们添加以下行以注册聚合器:

builder.Services.AddOcelot().AddSingletonDefinedAggregator<
  PatientAppointmentAggregator>()

现在,我们需要定义一个名为PatientAppointmentAggregator的类,该类将实现我们的自定义聚合逻辑。这个自定义聚合器将拦截来自下游服务器的响应,并允许我们查询和修改返回的内容:

public class PatientAppointmentAggregator :
  IDefinedAggregator
{
    public async Task<DownstreamResponse>
        Aggregate(List<HttpContext> responses)
    {
        var patient = await responses[0].Items.Downstream
          Response().Content.ReadAsStringAsync();
        var appointments = await responses[1]
          .Items.DownstreamResponse()
            .Content.ReadAsStringAsync();
        var contentBuilder = new StringBuilder();
        contentBuilder.Append(patient);
        contentBuilder.Append(appointments);
        var response = new StringContent
          (contentBuilder.ToString())
        {
            Headers = { ContentType = new
              MediaTypeHeaderValue("application/json") }
        };
        return new DownstreamResponse(response,
          HttpStatusCode.OK, new List<KeyValuePair<string,
            IEnumerable<string>>>(), "OK");
    }
}

这个聚合器代码接收一个响应列表,其中每个条目代表在配置中定义的顺序中下游服务的响应。然后我们将响应提取为字符串,并将其追加到一个字符串值中。我们还向最终响应添加了一个ContentType头,该响应与200OK HTTP 响应一起发送。这是一个简单的例子,但它展示了我们如何轻松地自定义默认的聚合行为,以及如何通过扩展为特定的 bff 网关进行自定义。

bff 模式允许我们进一步多样化我们的开发团队及其在维护各种微服务方面的努力。现在,团队可以管理他们的网关并实现针对他们所服务的设备独特的网关方法和功能。

既然我们已经理解了 API 网关、bff 模式以及如何使用行业标准软件实现其中之一,让我们回顾一下本章所学的内容。

摘要

本章回顾了 API 网关的需求。在构建单体应用时,我们有一个单一入口点来访问应用程序的支持 API,并且这个单一入口点可以用于任何类型的客户端。

这种方法的缺点是,我们可能会得到一个 API,随着需求的改变,它变得越来越难以改进和扩展。我们还需要考虑这样一个事实,即不同的设备对 API 有不同的需求,例如缓存、压缩和身份验证等。

我们随后尝试将我们应用程序的功能多样化,扩展到多个服务或微服务中,并且只为每个服务实现所需的功能。这种方法简化了每个服务的代码库,同时却使客户端应用程序的代码库变得更加复杂。原本只有一个服务端点,现在我们有几个需要跟踪。

API 网关将位于所有微服务之上,提供一个单一的入口点,并允许我们实现多个实例,以满足将使用它们的客户端应用程序的直接需求。这种调整被称为 bff,它允许我们针对需要它们的客户端应用程序特别定制后端服务。

这里的主要缺点是,通过提供网关层,我们重新引入了一个单点故障,这可能会引入潜在的性能问题。然而,我们的目标是减少我们的客户端应用程序对它们需要与之交互的复杂服务网络有深入了解的需求,这一层抽象也有助于我们在对客户端应用程序影响较小的情况下维护我们的服务。

我们还了解到,当尝试添加 bff 模式时,我们引入了对更多服务和更多代码进行维护的需求。理想情况下,我们希望有一个单一的实现,可以被配置多次,所有这些配置都是特定的。这正是 Docker 等技术能够帮助的地方,但我们将在此书稍后进行回顾。

现在我们已经看到了 API 网关模式的优缺点,我们需要探索我们 API 的安全性。在下一章中,我们将探讨使用令牌的 API 安全性。

第十二章:使用载体令牌保护微服务

安全性是任何应用最重要的且最繁琐的方面之一。我们需要确保我们的应用是用安全代码构建的,并且始终追求最有效的方法来减少系统中的入侵和漏洞。尽管如此,然而,安全性也以可用性为代价,我们应该始终寻求在这两者之间找到平衡。

基本应用安全始于登录系统。我们应该能够允许用户在系统中注册自己并相应地存储一些标识信息。当用户返回并希望访问应用的部分内容时,我们将查询数据库并通过他们的标识信息验证用户的身份,然后决定相应地授予或限制访问。

在现代应用中,我们发现维护一个作为我们所有用户权威的数据存储变得越来越困难,同时还要考虑到他们可能通过的所有可能的渠道访问我们的应用。我们一直在探索使用微服务架构,这使我们的安全考虑达到了新的水平,我们现在需要为从多个设备访问的不同用户保护应用的不同部分。

在本章中,我们将探讨在保护我们的微服务应用时需要考虑的主要因素,以及最佳配置和技术。

在阅读本章后,我们将完成以下内容:

  • 理解载体令牌的安全性

  • 学习如何在 ASP.NET Core API 中实现载体令牌安全性

  • 学习如何使用身份提供者来保护我们的微服务

技术要求

本章中使用的代码示例可以在 GitHub 上托管的项目存储库中找到:github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch12

用于保护通信的载体令牌

载体令牌是针对我们在开发现代应用时面临的一系列安全、身份验证和授权挑战的相对较新的解决方案。我们已经从处理标准桌面和 Web 应用转变为满足具有类似安全需求的多种互联网设备。在我们开始探索这些现代安全需求之前,让我们回顾一下我们在过去几年中面临的 Web 应用的一些挑战。

在保护 Web 应用时,我们面临着几个挑战:

  • 我们需要一个方法来收集用户信息。

  • 我们需要一个存储用户信息的方法。

  • 我们需要一个验证用户信息的方法。这被称为身份验证

  • 我们需要一个方法来跟踪用户在请求之间的认证状态。

  • 我们需要一个方法来跟踪用户在我们系统中被允许做什么。这被称为授权

  • 我们需要满足用户可能通过各种渠道或设备类型访问 Web 应用程序的需求。

在一个典型的 Web 应用程序中,大多数这些因素可以通过表单认证来实现,我们要求提供唯一标识信息,并在数据库中查找匹配项。

当找到匹配项时,我们实例化一个临时存储机制,该机制将识别用户在我们的系统中已认证。这个临时存储结构可以以下列形式出现:

  • 会话:一种在网站中存储信息的方法,可以在多个请求之间使用该变量。与每个请求都会丢失其值的典型变量不同,会话保留其值一段时间,直到它过期或被销毁。会话变量通常存储在服务器上,每次用户成功认证时都会创建一个或多个会话变量。会话变量可以存储诸如用户名、角色等信息。当有太多用户同时登录时,使用会话变量可能导致在较弱的服务器上出现内存问题。

  • Cookies:会话的替代方案,其中在用户的设备上创建并存储一个小文件。它用于在请求之间存储信息,以及跟踪用户的认证状态。每次从用户的设备发送请求时,都会发送这个 cookie,服务器 Web 应用程序使用这些信息来了解是否可以采取行动,以及如果可以,哪些行动。由于 cookies 可以减少服务器的负载并将更多责任放在用户的设备上,因此有时它们比会话更受欢迎。

当我们确信将要处理的是一个维护状态的 Web 应用程序时,这两种选项都表现得非常出色。状态意味着我们在请求之间保留用户信息,并记住谁登录以及他们在使用网站期间的基本信息——但是当你需要针对 API 进行认证时会发生什么?API 本质上不维护状态。它不会尝试保留访问它的用户的认知,因为 API 是为任何渠道在任何时间点的偶然访问而设计的。因此,我们实现了载体令牌。

载体令牌是一个包含尝试与我们 API 通信的用户信息的编码字符串。它帮助我们实现无状态通信,并促进一般的用户认证和授权场景。

理解载体令牌

承载令牌JSON Web Token(JWT)是一种在认证授权场景中广泛使用的无状态API 的构造。承载令牌基于一个开放的行业标准认证,这使得我们能够在服务器和客户端之间轻松共享认证用户信息。当访问 API 时,会在请求-响应周期内创建一个临时状态。这意味着当收到请求时,我们可以确定请求的来源,并根据需要解码额外的头部信息。一旦返回响应,我们就不再有请求的记录,也不知道它来自哪里,或者是由谁发起的。

承载令牌在成功认证请求后发放。我们收到对认证 API 端点的请求,并使用之前描述的信息来检查我们的数据库。一旦用户得到验证,我们就收集几个数据点,如下所示:

  • 主题:通常是指用户的唯一标识符,例如来自原始数据库的用户 ID。

  • 发行者:通常是与生成令牌以供发放的服务相关联的名称。

  • 受众:通常是与将消费令牌的客户端应用程序相关联的名称。

  • 用户名:用户的唯一系统名称,通常用于登录。

  • 电子邮件地址:用户的电子邮件地址。

  • 角色:用户的系统角色,它决定了他们被授权执行的操作。

  • 声明:关于用户的各个信息片段,可用于在客户端应用程序中辅助授权或信息显示。这可以包括用户的名字、性别,甚至他们个人资料的路径。

  • 过期日期:令牌应该始终有一个相对于其生成的适度过期日期。当它过期时,用户将需要重新认证,所以我们不希望它只有效很短的时间,但也不要永远有效。

最终,客户端应用程序与 API 之间的登录流程如下:

  1. 用户将使用客户端应用程序进行登录

  2. 客户端应用程序将登录表单收集到的信息转发到登录 API 端点进行验证

  3. API 返回一个包含有关用户最相关信息片段的编码字符串或令牌

  4. 客户端应用程序存储这个编码字符串,并用于后续的 API 通信

基于这种流程,客户端应用程序将使用令牌中的信息在用户界面(UI)上显示有关用户的信息,例如用户名或其他可能包含的信息,如名字和姓氏。虽然有一些推荐的信息片段应该包含在令牌中,但没有固定的标准规定应该包含什么。然而,我们确实避免包含敏感信息,例如密码。

载体令牌是编码的但不是加密的。这意味着它们是包含所有我们之前提到的信息的自包含信息块,但一开始并不易读。编码压缩了字符串,通常以base64表示,这是客户端和服务器之间传输以及存储所使用的格式。令牌字符串不是为了安全起见,因为解码字符串并查看其中信息很容易,而且再次强调,这就是为什么我们不将敏感和有罪的数据包含在令牌中。此令牌字符串由三个部分组成。每个部分由句点()分隔,通用格式为aaaa.bbbb.cccc*。每个部分代表以下内容:

  • 头部:令牌的a部分,其中包含有关令牌类型和用于编码的签名算法的信息,例如 HMAC SHA-256 和 RSA。

  • 有效载荷:令牌字符串的b部分,其中包含以声明形式存在的用户信息。我们将在本章稍后更详细地讨论声明。

  • 签名:令牌的c部分,其中包含编码后的头部、编码后的有效载荷以及用于编码的密钥的字符串表示。此签名用于验证令牌自生成以来是否被篡改。

大多数开发框架都包括在应用程序运行时可以解密载体令牌的工具和库。由于载体令牌基于一个开放标准,解码令牌的支持广泛可用。这使我们能够编写通用的和一致的代码来处理由 API 发布的令牌。每个 API 实现都可以根据应用程序的精确需求包含不同的令牌,但总有一些标准是我们始终可以依赖的。

然而,在开发过程中,我们可能想要测试一个令牌以查看我们期望以更易读的形式存在的具体内容。因此,我们转向第三方工具,这些工具可以解码并显示令牌的内容,并允许我们根据需要引用不同的信息。

例如jwt.io之类的工具为我们提供了将令牌粘贴进去并以更易读的格式查看信息的能力。如前所述,每个令牌字符串中有三个部分,我们可以使用此网站或类似工具以明文形式查看每个部分。令牌的有效载荷部分在解码后会产生图 12.1中显示的信息。它显示了一个示例载体令牌及其内容,可在www.jwt.io上查看。

图 12.1 – 我们可以看到编码后的字符串及其内容右侧的明文翻译

图 12.1 – 我们可以看到编码后的字符串及其内容右侧的明文翻译

放入携带令牌中的所有信息都代表一个键值对。每个键值对代表关于用户或令牌本身的信息单元,而键实际上是之前提到的通常存在于令牌中的声明的简短名称:

  • iss:代表发行者值。

  • sub:代表主题值。

  • aud:代表受众值。

  • nonce:代表一个唯一值,该值应始终与每个令牌一起变化,以防止重放攻击。此值始终是新的,这确保了不会向同一用户发行的任何两个令牌是相同的。有时这个值也可以称为jti声明。

  • exp:代表令牌的过期日期。值以 UNIX 纪元的形式表示,这是一个时间的数值表示。

  • iat:代表发行日期和时间。

现在我们已经探讨了为什么我们需要携带令牌以及它们是如何被使用的,让我们回顾一下我们如何在 ASP.NET Core API 应用程序中实现令牌安全。

实现携带令牌安全

ASP.NET Core 通过其Identity Core库提供原生的身份验证和授权支持。这个库与 Entity Framework 直接集成,允许我们在目标数据库中创建标准的用户管理表。我们还可以进一步指定我们偏好的身份验证方法,并定义定义整个应用程序中授权规则的政策。

这个强大的库内置了对以下功能的支持:

  • 用户注册:用户管理器库具有使用户创建和管理变得简单的功能。它包含覆盖大多数常见用户管理操作的功能。

  • 登录、会话和 cookie 管理:登录管理器库具有管理用户身份验证和会话管理场景的功能。

  • 双因素身份验证:Identity Core 允许我们通过电子邮件或短信原生前置多因素身份验证。这可以轻松扩展。

  • Identity Core使得将此功能集成到您的应用程序中变得简单。

使用携带令牌保护 API 确保每个 API 调用都需要在请求的头部部分包含一个有效的令牌。HTTP 头部允许在 HTTP 请求或响应中提供额外的信息。

在我们保护 API 的案例中,我们强制要求每个请求都必须包含一个包含携带令牌的授权头部。我们的 API 将评估传入的请求头部,检索令牌,并对其与预定义配置进行验证。如果令牌不符合标准或已过期,将返回HTTP 401 未授权响应。如果令牌符合要求,则请求将被满足。这种内置机制使得在应用程序中支持广泛的、健壮的身份验证和授权规则变得简单且易于维护。

现在我们已经了解了Identity Core库以及它在 ASP.NET Core 应用程序中的原生支持,我们可以探索使用 bearer tokens 保护 API 所需的必要包和配置。

使用 bearer tokens 保护 API

我们可以使用 NuGet 包管理器安装以下包:

Install-Package Microsoft.AspNetCore.Authentication
  .JwtBearer

第一个包支持 Entity Framework 和Identity Core之间的直接集成。第二个包包含扩展方法,允许我们在 API 配置中实现 token 生成和验证规则。

接下来,我们需要定义常量值,这些值将告知 API 中的 token 生成和验证活动。我们可以将这些常量放在appsettings.json中,它们将如下所示:

  "Jwt": {
    "Issuer": "HealthCare.Appointments.API",
    "Audience": " HealthCare.Appointments.Client",
    "DurationInHours": 8
    "Key": "ASecretEncodedStringHere-Minimum16Charatcters"
  }

我们已经讨论了发行者和受众值如何帮助强制执行。我们还可以为生成的 token 提出一个建议的有效期。这个值应该始终相对于 API 的功能和操作以及您的风险承受能力来考虑。token 的有效期越长,我们提供给潜在攻击者的系统窗口就越大。同时,如果期限太短,那么客户端将需要频繁地重新认证。我们应该始终寻求寻求平衡。

我们在这里的关键值具有示范性的价值,但我们使用这个签名密钥作为生成 token 的加密密钥。密钥应该始终保密,因此我们可以使用应用程序密钥或更安全的密钥存储来存储这个值。

现在我们有了应用程序常量,我们可以继续在Program.cs文件中指定全局身份验证设置:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme =
        JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme =
        JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(o =>
{
    o.TokenValidationParameters = new
        TokenValidationParameters
    {
        ValidateIssuer = true,
        ValidateAudience = true,
        ValidateLifetime = true,
        ValidateIssuerSigningKey = true,
        ValidIssuer = builder.Configuration["Jwt:Issuer"],
        ValidAudience = builder.Configuration
            ["Jwt:Audience"],
        IssuerSigningKey = new SymmetricSecurityKey
        (Encoding.UTF8.GetBytes(builder.Configuration["Jwt:
            Key"]))
    };
});

在这里,我们正在向应用程序添加配置,这将向 API 应用程序声明它应该强制执行特定的身份验证方案。鉴于 Identity Core 支持多种身份验证方案,我们需要指定我们打算强制执行的方案以及扩展到所需的挑战方案类型。挑战方案指的是应用程序将需要的身份验证要求。在这里,我们指定JwtBearerDefaults.AuthenticationScheme作为挑战和身份验证方案。这个JwtBearerDefaults类包含通常可用和使用的 JWT 常量。在这种情况下,AuthenticationScheme将渲染值为 bearer,这是一个关键字。

在我们完成定义身份验证方案后,我们继续设置将强制执行某些规则的配置,这些规则将决定如何验证令牌。通过将 ValidateIssuerValidateAudienceValidateLifetime 设置为 true,我们强制要求传入令牌中的匹配值必须与我们设置在 appsettings.json 配置常量中的值相匹配。您可以根据您想要如何严格检查令牌内容与系统之间的匹配程度来灵活设置验证规则。验证规则越少,有人使用伪造令牌获取系统访问权限的可能性就越高。

我们还需要确保我们的 API 知道我们打算支持授权,因此我们还需要添加以下行:

builder.Services.AddAuthorization();

然后,我们还需要按照以下顺序包含我们的中间件:

app.UseAuthentication();
app.UseAuthorization();

现在我们已经处理了初步配置,我们需要将我们的默认身份用户表包含到数据库中。我们首先将数据库上下文的继承从 DbContext 改为 IdentityDbContext

public class AppointmentsDbContext : IdentityDbContext

我们还将添加代码在数据库上下文中生成一个示例用户。当我们执行下一个迁移时,此用户将被添加到表中,我们可以用它来测试身份验证:

protected override void OnModelCreating(ModelBuilder
    builder)
        {
            base.OnModelCreating(builder);
            var hasher = new PasswordHasher<ApiUser>();
            builder.Entity<ApiUser>().HasData(new ApiUser
            {
                Id = "408aa945-3d84-4421-8342-
                    7269ec64d949",
                Email = "admin@localhost.com",
                NormalizedEmail = "ADMIN@LOCALHOST.COM",
                NormalizedUserName = "ADMIN@LOCALHOST.COM",
                UserName = "admin@localhost.com",
                PasswordHash = hasher.HashPassword(null,
                    "P@ssword1"),
                EmailConfirmed = true
            });
        }

在这些更改之后,我们执行的下一个迁移将生成在执行 update-database 命令时创建的用户表。默认情况下,这些新表将以前缀 AspNet 开头。

我们还需要在我们的应用程序中注册 Identity Core 服务并将其连接到数据库上下文,如下所示:

builder.Services.AddIdentityCore<IdentityUser>()
    .AddRoles<IdentityRole>()
    .AddEntityFrameworkStores<AppointmentsDbContext>();

在这里,我们在应用程序中注册与身份相关的服务,指定我们正在使用默认的用户类型 IdentityUser、默认的角色类型 IdentityRole 以及与 AppointmentsDbContext 关联的数据存储。

现在我们已经指定了 Identity Core 和 JWT 身份验证集成所需的配置,我们可以着手实现一个登录端点,该端点将验证用户的凭据并根据需要生成包含最少识别信息的令牌。我们将在下一节中探讨这个问题。

生成和颁发令牌

ASP.NET Core 支持生成、颁发和验证令牌。为此,我们需要在我们的身份验证流程中实现逻辑,以生成包含已认证用户信息的令牌,并将其作为响应体返回给请求客户端。让我们首先定义一个 Id 值和令牌,并将它们都包装在它们自己的 AuthResponseDto 中:

public class AuthResponseDto
{
   public string UserId { get; set; }
   public string Token { get; set; }
}

我们还将有一个 DTO 接受登录信息。我们可以称这个 DTO 为 LoginDto

public class LoginDto
    {
        [Required]
        [EmailAddress]
        public string Email { get; set; }
        [Required]
        [StringLength(15, ErrorMessage = "Your Password is
            limited to {2} to {1} characters",
                MinimumLength = 6)]
        public string Password { get; set; }
    }

我们的 DTO 将对提交的数据执行验证规则。在这里,我们的用户可以使用他们的电子邮件地址和密码进行身份验证,任何违反验证规则的无效尝试都将使用 400BadRequest HTTP 状态码被拒绝。

我们的认证控制器将实现一个接受此 DTO 作为参数的登录操作:

[Route("api/[controller]")]
[ApiController]
public class AccountController : ControllerBase
{
    private readonly IAuthManager _authManager;
    public AccountController(IAuthManager authManager)
    {
        _authManager = authManager;
    }
    // Other Actions here
    [HttpPost]
    [Route("login")]
    public async Task<IActionResult> Login([FromBody]
        LoginDTO loginDto)
    {
        var authResponse = _authManager.Login(loginDto);
        if (authResponse == null)
        {
            return Unauthorized();
        }
        return Ok(authResponse);
    }
}

我们将IAuthmanager服务注入到控制器中,其中我们抽象了大部分用户验证和令牌生成逻辑。此服务合同如下:

public interface IAuthManager
{
   // Other methods
   Task<AuthResponseDto> Login(LoginDto loginDto);
}

AuthManager的实现中,我们使用由Identity Core提供的UserManager服务来验证提交的用户名和密码组合。验证后,我们将生成并返回一个包含令牌和用户 ID 的AuthResponseDto对象。我们的实现将类似于以下代码块:

public class AuthManager : IAuthManager
{
    private readonly UserManager<IdentityUser>
        _userManager;
    private readonly IConfiguration _configuration;
    private IdentityUser _user;
    public AuthManager(UserManager<IdentityUser>
        userManager, IConfiguration configuration)
    {
        this._userManager = userManager;
        this._configuration = configuration;
    }
    // Other Methods
    public async Task<AuthResponseDto> Login(LoginDto
        loginDto)
    {
        var user = await _userManager.FindByEmailAsync
            (loginDto.Email);
        var isValidUser = await _userManager
            .CheckPasswordAsync(_user, loginDto.Password);
        if(user == null || isValidUser == false)
        {
            return null;
        }
        var token = await GenerateToken();
        return new AuthResponseDto
        {
            Token = token,
            UserId = _user.Id
        };
    }

我们将UserManagerIConfiguration都注入到我们的AuthManager中。在我们的登录方法中,我们尝试根据在LoginDto中提供的电子邮件地址检索用户。如果我们尝试验证提供的密码是否正确。如果没有用户或密码不正确,我们将返回一个 null 值,登录操作将使用此值来指示未找到用户,并将返回401 Unauthorized HTTP 响应。

如果我们可以验证用户,那么我们将生成一个令牌,然后返回包含令牌和用户Id值的AuthResponseDto对象。生成令牌的方法也在AuthManager中,如下所示:

private async Task<string> GenerateToken()
{
        var securitykey = new SymmetricSecurityKey
            (Encoding.UTF8.GetBytes(_configuration["
               Jwt:Key"]));
        var credentials = new SigningCredentials
            (securitykey, SecurityAlgorithms.HmacSha256);
        var roles = await _userManager.GetRolesAsync
            (_user);
        var roleClaims = roles.Select(x => new
            Claim(ClaimTypes.Role, x)).ToList();
        var userClaims = await _userManager.GetClaimsAsync
           (_user);
        var claims = new List<Claim>
        {
            new Claim(JwtRegisteredClaimNames.Sub,
                _user.Email),
            new Claim(JwtRegisteredClaimNames.Jti,
                Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.Email,
                _user.Email),
            new Claim("uid", _user.Id),
        }
        .Union(userClaims).Union(roleClaims);
        var token = new JwtSecurityToken(
            issuer: _configuration[" Jwt:Issuer"],
            audience: _configuration[" Jwt:Audience"],
            claims: claims,
            expires: DateTime.Now.AddMinutes
                (Convert.ToInt32(_configuration["
                    Jwt:DurationInMinutes"])),
            signingCredentials: credentials
            );
        return new JwtSecurityTokenHandler()
            .WriteToken(token);
    }
}

在此方法中,我们首先通过IConfiguration服务从appsettings.json检索我们的安全密钥。然后我们对此密钥进行编码和加密。我们还编译了应该通常包含在令牌中的标准声明,并且我们可以包括其他声明值,无论它们来自数据库中的用户声明还是我们认为必要的自定义声明。

我们最终将所有声明和其他关键值,如这些:

  • SigningCredentials具有加密密钥的值

  • IssuerAudienceappsettings.json中定义

  • jti 声明,它是一个唯一的标识符,或令牌的一次性密码

  • 令牌的过期日期和时间,相对于配置的时间限制

结果是一个充满编码字符的字符串,它被返回到我们的Login方法,然后通过AuthResponseDto返回给控制器。

为了使我们的AuthManager在我们的控制器中可用,我们需要在Program.cs文件中使用此行注册接口和实现:

builder.Services.AddScoped<IAuthManager, AuthManager>();

在这些配置到位后,我们可以使用简单的[Authorize]属性来保护我们的控制器和操作。此属性将直接放置在我们的类实现或操作方法之上。我们的 API 将自动评估每个传入请求的授权头值,并自动拒绝没有令牌或违反在TokenValidationParameters中规定的规则的请求。

现在,当我们使用像Swagger UIPostman这样的工具,使用我们预先设置的测试用户来测试我们的登录端点时,我们将收到一个看起来像这样的令牌响应:

{
  "userId": "408aa945-3d84-4421-8342-7269ec64d949",
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJz
  dWIiOiJhZG1pbkBsb2NhbGhvc3QuY29tIiwianRpIjoiZWU5ZjI4OD
  ItMWFkZC00ZTZkLThlZjktY2Q1ZjFlOWM3ZjMzIiwiZW1haWwiOiJhZ
  G1pbkBsb2NhbGhvc3QuY29tIiwidWlkIjoiNDA4YWE5NDUtM2Q4NC00
  NDIxLTgzNDItNzI2OWVjNjRkOTQ5IiwiZXhwIjoxNjY5ODI4MzMwLCJ
  pc3MiOiJIb3RlbExpc3RpbmdBUEkiLCJhdWQiOiJIb3RlbExpc3Rpbm
  dBUElDbGllbnQifQ.yuYUIFKPTyKKUpsVQhbS4NinGLSF5_XXPEBtAEf
  jO5E"
}

在 API 中实现令牌认证相对简单,但我们不仅在我们的应用程序中考虑一个 API。我们有多个 API 需要被保护,并且最好,一个令牌应该被所有服务接受。如果我们继续沿着这条路走下去,我们可能会为每个服务进行这些配置,然后需要额外的代码来让所有其他服务认可可能由任何其他服务发行的令牌。

我们需要一个更全局的解决方案,更合适的是,为我们的微服务应用中的所有服务提供一个安全和令牌生成及管理的中央权威机构。这就是我们开始探索将令牌管理责任从每个 API 中分离出来,并将它们放在一个实现IdentityServer的 API 中的地方,它是 ASP.NET Core 的OpenID ConnectOAuth 2.0框架。我们将在下一节中研究实现IdentityServer

使用 IdentityServer4 来保护微服务

任何现代应用程序或应用程序套件的关键特性之一是单点登录SSO)的概念。此功能使我们能够一次性提供我们的凭据,并在套件中的多个应用程序之间保持已验证的用户状态。这是可以在谷歌或微软在线产品中观察到的功能,仅举几个例子。

当保护微服务应用程序时,这个概念将非常有用。正如我们所看到的,在应用程序的许多 API 中实现令牌发行逻辑,然后尝试协调对已授予一个 API 的所有 API 的访问,这是不可行的。我们还面临要求用户每次尝试访问需要另一个 API 来完成的功能时都需要重新认证的风险,这不会是一个好的用户体验。

考虑到这些因素,我们需要使用一个中央权威机构,它可以允许我们根据我们服务的所有安全考虑实施更全局的令牌发行和验证规则。在 ASP.NET Core 中,此类服务的最佳候选者是IdentityServer

Identity Core并允许开发者在他们的 Web 应用程序安全实现中支持OpenID ConnectOAuth2.0标准。它符合行业标准,并包含针对基于令牌的认证、SSO 和应用程序中的 API 访问控制的即插即用支持。虽然它是一个商业产品,但社区版可供小型组织或个人项目使用。

IdentityServer推荐的实现方式如下:

  1. 为认证创建一个新的微服务

  2. 为我们的认证相关表创建一个新的数据库(可选)

  3. 配置要包含在令牌信息中的作用域

  4. 配置我们的服务以了解哪些作用域允许访问它们

图 12.2显示了 IdentityServer 认证流程:

图 12.2 – 这描述了 IdentityServer 如何位于客户端和服务之间,并处理身份验证和令牌交换

图 12.2 – 这描述了 IdentityServer 如何位于客户端和服务之间,并处理身份验证和令牌交换的流程

现在,让我们探索创建一个新的服务并将其配置为我们的微服务应用程序中身份验证和授权的中心权威。

配置 IdentityServer

Duende 为我们提供了一些快速入门的 ASP.NET Core 项目模板,这些模板在我们的解决方案中易于创建。这些快速入门模板启动了在 ASP.NET Core 项目中启动 IdentityServer 功能所需的最小要求。设置 IdentityServer 服务的常规步骤如下:

  • 将 Duende IdentityServer 支持添加到标准 ASP.ENT Core 项目

  • 添加数据存储支持,最好使用 Entity Framework 配置

  • 添加对 ASP.NET Identity Core 的支持

  • 为客户端应用程序配置令牌发行

  • 使用 IdentityServer 保护客户端应用程序

要开始,我们需要使用 .NET CLI 并运行以下命令:

dotnet new --install Duende.IdentityServer.Templates

该命令现在将为我们提供访问以 Duende.IdentityServer 为前缀的新项目模板。图 12.3 展示了在 Visual Studio 中安装这些模板后我们可以期待看到的内容。

图 12.3 展示了 Duende IdentityServer 项目模板:

图 12.3 – 我们得到了各种项目模板,这些模板有助于我们加快 IdentityServer 的实现过程

图 12.3 – 我们得到了各种项目模板,这些模板有助于我们加快 IdentityServer 的实现过程

使用我们的医疗微服务应用程序,让我们首先添加一个新的 HealthCare.Auth。现在,我们有一个预配置的包含多个组件的 IdentityServer 项目。我们需要了解主要组件是什么,并欣赏我们如何根据我们的需求操纵它们。让我们对开箱即得的文件和文件夹结构进行高级审查:

  • Wwwroot: 这是与 ASP.NET Core 网络应用程序模板一起提供的标准文件夹。它存储用于网站中的静态资产,如 JavaScript 和 CSS 文件。

  • Migrations: 存储预设迁移,这些迁移将用于用支持表填充数据存储。这很方便,因为它消除了我们创建数据库的需要。

  • Pages: 存储用于支持用户身份验证操作 UI 要求的默认 Razor 页面。开箱即得,我们得到登录、注册、授权和用户数据管理页面。

  • appsettings.json: 这是包含日志和数据库连接配置的标准文件。我们可以更改这个连接字符串以更好地反映我们的需求。

  • buildschema.bat: 包含使用.NET 命令行命令(dotnet ef)的 Entity Framework 命令,这些命令将运行包含在Migrations文件夹中的迁移脚本。我们将使用这些命令来创建我们的数据库。

  • Config.cs: 这个静态类作为配置权威机构。它用于概述IdentityResourcesScopesClients

    • IdentityResources:映射到授予访问身份相关信息的范围。OpenId方法支持预期的主题(或sub-声明)值,而Profile方法支持额外的声明信息,如given_namefamily_name。我们还可以扩展默认提供的内容,并包括用户角色等额外细节。

    • Scopes:可以在发放令牌时包含权限。

    • Clients:我们期望使用 IdentityServer 作为令牌发行权威机构的第三方客户端。

  • HostingExtension.cs: 包含服务和中间件注册扩展方法。这些方法随后在启动时的Program.cs文件中被调用。

  • Program.cs:ASP.NET Core 应用程序中的主要程序执行文件。

  • SeedData.cs: 包含默认方法,确保在应用程序启动时执行数据迁移和种子操作。

IdentityServer 使用两个数据库上下文,一个配置存储上下文和一个操作存储上下文。因此,在HostingExtension.cs文件中创建了两个数据库上下文。使用以下代码注册IdentityServer库:

var isBuilder = builder.Services
                .AddIdentityServer(options =>
                {
                    options.Events.RaiseErrorEvents = true;
                    options.Events.RaiseInformationEvents =
                        true;
                    options.Events.RaiseFailureEvents =
                        true;
                    options.Events.RaiseSuccessEvents =
                        true;
                     options.EmitStaticAudienceClaim =
                        true;
                })
                .AddTestUsers(TestUsers.Users)
                .AddConfigurationStore(options =>
                {
                    options.ConfigureDbContext = b =>
                        b.UseSqlite(connectionString,
                        dbOpts => dbOpts.MigrationsAssembly
                        (typeof(Program).Assembly
                         .FullName));
                })       .AddOperationalStore(options =>
                {
                    options.ConfigureDbContext = b =>
                        b.UseSqlite(connectionString,
                        dbOpts => dbOpts.MigrationsAssembly
                        (typeof(Program).Assembly.FullName
                        ));
                     options.EnableTokenCleanup = true;
                    options.RemoveConsumedTokens = true;
                });

我们将TestUsers添加到配置中,然后添加ConfigurationStoreDbContextOperationalStoreDbContext。其他设置还规定了如何处理警报和令牌。默认设置通常很稳健,但您可以根据具体需求进行修改。

默认连接字符串和 Entity Framework Core 库为我们支持 SQLite 数据库。这可以更改为任何期望的数据存储,但我们将继续使用 SQLite 来完成这个练习。让我们继续生成数据库和表,我们需要以下命令:

Update-Database -context PersistedGrantDbContext
Update-Database -context ConfigurationDbContext

使用这两个命令,我们将看到我们的数据库已经与所有支持表一起构建。在这个阶段,它们都是空的,我们可能希望根据我们的应用程序填充一些默认值。让我们首先配置我们打算在令牌中支持的IdentityResources。我们可以按如下方式修改IdentityResources方法:

public static IEnumerable<IdentityResource>
    IdentityResources =>
            new IdentityResource[]
            {
            new IdentityResources.OpenId(),
            new IdentityResources.Profile(),
            new IdentityResource("roles", "User role(s)",
                new List<string> { "role" })
            };

我们已经将角色列表添加到资源列表中。根据正在处理的声明,我们需要确保我们的用户将包含所有预期的数据,以及他们预期拥有的声明列表。请记住,声明是客户端应用程序将通过令牌拥有的信息,因为这是客户端跟踪哪个用户在线以及他们可以做什么的唯一方式。

现在,我们可以通过修改ApiScopes方法来细化支持的作用域列表:

public static Ienumerable<ApiScope> ApiScopes =>
            new ApiScope[]
            {
                new ApiScope("healthcareApiUser",
                    "HealthCare API User"),
                new ApiScope("healthcareApiClient",
                    "HealthCare API Client "),
            };

在这里,我们支持两种类型的身份验证作用域。这些作用域将被用于支持两种不同场景的身份验证:客户端和用户。客户端身份验证代表一种未经监督的尝试获取资源,通常是通过另一个程序或 API。客户端身份验证意味着用户将使用凭证进行身份验证。

这引出了下一个配置,它是针对客户端的。术语客户端使用得比较宽松,因为任何试图从 IdentityServer 获取授权的实体都被视为客户端。客户端一词也可以指试图获取授权的程序,例如守护进程或后台服务。另一种情况是当用户试图执行需要他们通过 IdentityServer 进行身份验证的操作时。我们如下添加对客户端的支持:

public static IEnumerable<Client> Clients =>
            new Client[]
            {
            // m2m client credentials flow client
            new Client
            {
                ClientId = "m2m.client",
                ClientName = "Client Credentials Client",
                AllowedGrantTypes = GrantTypes
                    .ClientCredentials,
                ClientSecrets = { new Secret("511536EF-
                   F270-4058-80CA-1C89C192F69A ".Sha256())
                       },
                AllowedScopes = { "healthcareApiClient" }
            },
            // interactive client using code flow + pkce
            new Client
            {
                ClientId = "interactive",
                ClientSecrets = { new Secret("49C1A7E1-
                  0C79-4A89-A3D6-A37998FB86B0".Sha256()) },
                AllowedGrantTypes = GrantTypes.Code,
                RedirectUris = {
                    "https://localhost:5001/signin-oidc" },
                FrontChannelLogoutUri =
                    "https://localhost:5001/signout-oidc",
                PostLogoutRedirectUris = {
                   "https://localhost:5001/signout-
                      callback-oidc" },
                AllowOfflineAccess = true,
                AllowedScopes = { "openid", "profile",
                    "healthcareApiUser", "roles" }
            },
            };

现在,我们已经为我们的客户端定义了ClientIdClientSecret值。通过定义多个客户端,我们可以在更细粒度的层面上支持我们期望支持的应用程序,并且我们可以定义特定的AllowedScopesAllowedGrantTypes值。在这个例子中,我们为 API 定义了一个客户端,它可以代表我们的应用程序中可能需要与身份验证服务进行身份验证的微服务。这种类型的身份验证通常在没有用户交互的情况下发生。我们还定义了一个 Web 客户端,它可能是一个面向用户的应用程序。这提出了一个独特的挑战,即我们在配置登录和注销 URL 时,将用户在身份验证或注销流程期间重定向。我们还说明了哪些作用域可以通过生成的令牌访问。由于我们希望在用户身份验证时包含该信息,因此我们将roles值添加到AllowedScopes列表中。

现在我们已经概述了配置值,让我们向Properties文件夹中的launchSettings.json文件添加一个用于初始化的命令行参数。该文件的现在内容如下:

"profiles": {
    "SelfHost": {
      "commandName": "Project",
      "launchBrowser": true,
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      "applicationUrl": "https://localhost:5001",
      "commandLineArgs": "/seed"
    }

如果我们在做出此调整后运行此应用程序,if (args.Contains("/seed"))语句将在Program.cs中评估为true,这将触发SeedData.cs文件中概述的数据库初始化活动。第一次运行后,您可以从launchSettings.json文件中删除"commandLineArgs": "/seed"部分。再次运行它将启动一个带有类似图 12.4所示页面的浏览器应用程序。这是主页着陆页,显示我们的 IdentityServer 正在运行。

图 12.4显示了 Duende IdentityServer 的着陆页:

图 12.4 – 此着陆页显示我们的 IdentityServer 应用程序正在运行状态

图 12.4 – 此着陆页显示我们的 IdentityServer 应用程序正在运行状态

您可以在Pages文件夹中找到TestUsers.cs文件。我们将使用alice作为用户名和密码进行快速测试。您可以使用该文件实例中提供的凭据继续操作。然后,我们可以使用默认添加到上下文中的一个测试用户来测试登录操作,并且当我们尝试访问大多数这些链接时,我们将需要验证身份。

我们需要讨论的最重要链接是通往发现文档的链接。大多数 OAuth2.0 和 OpenID Connect 服务提供商都有一个发现文档的概念,它概述了 API 中的内置路由、支持的声明和令牌类型以及其他有助于我们了解和访问这些复杂信息的要点信息。以下是一些关键信息:

{
    "issuer": "https://localhost:5001",
    "jwks_uri": "
        https://localhost:5001/.well-known/openid-
        configuration/jwks",
    "authorization_endpoint": "
        https://localhost:5001/
        connect/authorize",
    "token_endpoint": "
        https://localhost:5001/connect/
        token",
    "userinfo_endpoint": "
        https://localhost:5001/connect/
        userinfo",
    "end_session_endpoint": "
        https://localhost:5001/connect
        /endsession",
    "check_session_iframe": "
        https://localhost:5001/connect
        /checksession",
    "revocation_endpoint": "
        https://localhost:5001/connect
       /revocation",
    "introspection_endpoint": "
        https://localhost:5001/
        connect/introspect",
    "device_authorization_endpoint": "
        https://localhost:5001/connect/deviceauthorization",
    "backchannel_authentication_endpoint":
        "https://localhost:5001/connect/ciba",
   ...
    "scopes_supported": [
        "openid",
        "profile",
        "roles",
        "healthcareApiUser",
        "healthcareApiClient",
        "offline_access"
    ],
...
}

我们现在对可用于不同常见操作的各个端点有一个清晰的概述。

接下来,我们可以测试我们的HealthCare.Auth应用程序,并验证我们能否检索到一个有效的令牌。让我们尝试使用我们的机器客户端凭据来检索令牌。我们将使用一个名为Postman的 API 测试工具发送请求。图 12.5显示了 Postman 的用户界面以及相应需要添加的信息。

图 12.5 – 在这里,我们在 Postman 中添加客户端 ID、客户端密钥和令牌 URL 值以检索一个承载令牌

图 12.5 – 在这里,我们在 Postman 中添加客户端 ID、客户端密钥和令牌 URL 值以检索一个承载令牌

一旦我们添加了所需的值,我们就点击获取新的访问令牌按钮。这将向我们的 IdentityServer 发送请求,如果数据库中存在相关信息,它将验证请求并返回一个令牌。

我们的令牌响应自动包含一些附加信息,例如令牌类型、过期时间戳和包含的作用域。我们的令牌默认包含几个数据点。由于 IdentityServer 遵循OAuthOpenID Connect标准,我们可以确信我们不需要包含基本声明,例如subexpjtiiss等。

包含的值是作用域和客户端 ID。这些值由每个客户端的配置和认证用户提供的信息确定。在这个例子中,我们允许只有经过认证的用户才能访问的 API。

图 12.6显示了令牌的有效负载:

图 12.6 – 如果没有使用 IdentityServer 生成,我们的令牌将自动包含一些我们手动输入的声明

图 12.6 – 如果没有使用 IdentityServer 生成,我们的令牌将自动包含一些我们手动输入的声明

让我们保存返回的携带令牌值,因为我们将在下一节中使用它。现在让我们回顾一下使用 IdentityServer 保护 API 所必需的更改。

使用 IdentityServer 保护 API

我们现在面临着一个特别的挑战,即在微服务应用程序中实施最佳的安全解决方案。我们有几个需要保护的服务,并且根据你实现的架构模式,你可能还有一个路由流量的网关:

  • 保护每个服务:保护每个服务看起来足够简单,但我们必须记住,每个服务都有不同的需求,可能需要被视为每个请求的不同客户端。当尝试维护与每个服务相关的所有范围和客户端时,这可能会导致维护噩梦。我们还需要考虑服务之间的通信方式,因为服务到服务的调用需要令牌。一个服务的声明和范围可能不足以支持这种通信。这可能会导致用户在访问依赖于不同服务的不同功能时需要多次进行身份验证。

  • 受保护的 API 网关:保护我们的 API 网关最有意义。如果我们实现一个所有应用程序都将与之通信的网关,我们允许网关为客户和客户端之间的身份验证流程进行编排,并管理在服务调用之间共享的令牌。这种支持可以在自定义编写的 API 网关中实现,并且大多数(如果不是所有)第三方网关服务提供商都支持。当与 Backend For Frontend 模式结合使用时,这特别有用。

我们已经看到如何使用 Identity Core 库的功能将 JWT 携带保护添加到我们的 API 中。我们可以利用一些这些配置,并用对 IdentityServer 的支持覆盖原生功能。让我们探索如何使用 IdentityServer 保护我们的 Patients API。我们首先通过 NuGet 包管理器添加 Microsoft.AspNetCore.Authentication.JwtBearer 库:

Install-Package Microsoft.AspNetCore.Authentication
  .JwtBearer

然后,我们修改 Program.cs 文件并添加以下配置:

builder.Services.AddAuthentication(JwtBearerDefaults
     .AuthenticationScheme)
            .AddJwtBearer(options =>
            {
                // base-address of your identityserver
                options.Authority =
                    "https://localhost:5001/";
                // audience is optional, make sure you read
                   the following paragraphs
                // to understand your options
                options.TokenValidationParameters
                    .ValidateAudience = false;
                // it's recommended to check the type
                header to avoid "JWT confusion" attacks
                options.TokenValidationParameters
                    .ValidTypes = new[] { "at+jwt" };
            });

我们还需要在我们的应用程序中注册身份验证中间件,以下是一行代码。我们应该确保将此注册放在授权中间件之上:

app.UseAuthentication();
app.UseAuthorization();

此配置将指示我们的服务现在要将它们指向 Authority 选项中的 URL,以获取身份验证说明。我们现在可以通过实现全局授权策略来保护我们的 API。这将确保没有端点可以访问,除非有一个由我们的 IdentityServer 签发的有效携带令牌:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("RequireAuth", policy =>
    {
        policy.RequireAuthenticatedUser();
    });
});

我们按如下方式修改控制器的中间件:

app.MapControllers().RequireAuthorization("RequireAuth");

现在,任何尝试与我们的 Patients API 端点交互的操作都将返回 401Unauthorized HTTP 响应。API 现在期望我们提供携带令牌作为授权头中的值。在 图 12.7 中,我们看到如何使用在前一节中从我们的机器客户端凭据身份验证中检索到的携带令牌对 Patients API 端点进行授权的 API 调用。

图 12.7 显示了授权的 API 请求:

图 12.7 – 我们的携带令牌包含在请求我们的受保护服务中,我们可以轻松地访问端点

图 12.7 – 我们的携带令牌包含在请求我们的受保护服务中,我们可以轻松地访问端点

现在,我们需要配置我们的 API 以强制进行身份验证并相应地依赖 HealthCare.Auth 服务。如果我们重用我们的预约 API,我们可以在 Program.cs 文件中进行一些修改,并引入对身份验证服务的依赖。

我们首先修改 builder.Services.AddAuthentication() 注册如下:

builder.Services.AddAuthentication(JwtBearerDefaults
    .AuthenticationScheme)
   .AddJwtBearer("Bearer", opt =>
   {
       opt.RequireHttpsMetadata = false;

现在我们已经直接保护了我们的 API,我们可以探索如何在我们的 API 网关中管理这个新的安全要求。回想一下,我们已经实现了聚合方法,并且我们期望客户端应用程序通过网关访问端点。

使用 IdentityServer 保护 Ocelot API 网关

现在,当我们访问通过 IdentityServer 受保护的 API 端点时,我们需要对网关服务进行返工,以支持身份验证并将凭据转发到目标 API。我们首先使用 NuGet 软件包管理器添加 Microsoft.AspNetCore.Authentication.JwtBearer 库:

Install-Package Microsoft.AspNetCore.Authentication
  .JwtBearer

然后,我们修改 ocelot.json 文件并添加一个 AuthenticationOptions 部分。现在,我们的 Patients API 的 GET 方法如下:

{
  "DownstreamPathTemplate": "/api/Patients",
  ...
  "AuthenticationOptions": {
        "AuthenticationProviderKey":
            "IdentityServerApiKey",
        "AllowedScopes": []
      },
…
},

现在,我们修改我们的 Program.cs 文件,并将我们的身份验证服务注册为使用 JWT 携带者身份验证,这与我们在服务本身上所做的方式类似:

builder.Services
    .AddAuthentication()
    .AddJwtBearer(authenticationProviderKey, x =>
    {
        x.Authority = "https://localhost:5001";
        x.TokenValidationParameters = new
            TokenValidationParameters
        {
            ValidateAudience = false
        };
    });

现在,我们已经使用 IdentityServer 保护了我们的网关。这再次可能是一个更好的安全解决方案,用于通过网关访问的我们的微服务套件,并且它可以帮助我们集中访问我们的服务。

现在我们已经详细探讨了 API 安全性,让我们总结一下我们探索的概念。

通过这个简单的更改,我们不再需要担心在我们的预约 API 数据库中包含身份验证表,或者复杂的 JWT 携带者编译逻辑。我们只需将服务指向我们的 Authority(身份验证服务),并包含 Audience 值,以便它可以向身份验证服务进行自我识别。

使用此配置,用户需要提供一个令牌,例如我们检索到的令牌,才能对我们的 API 进行任何调用。任何其他令牌或没有令牌都将遇到 401 未授权 HTTP 响应。

配置 IdentityServer 并不是最困难的任务,但在尝试处理多个场景、配置和客户端时,它可能会变得复杂。在过程中可以做出一些考虑,我们将在下一节中讨论它们。

额外的 API 安全考虑

我们已经配置了一个身份验证服务来保护我们的微服务应用程序。有几个场景可以决定每个服务如何被这个中央权威保护,它们各有优缺点。

我们还需要考虑的是,我们希望承担托管和维护我们自己的OAuth服务的全部责任。有一些第三方服务,例如Auth0Azure Active DirectoryOkta(仅举几例)。它们都提供托管服务,这将抽象化我们部署和维护服务的需求,我们可以简单地订阅它们的服务,并通过一些配置来保护我们的应用程序。

这种选择利用了软件即服务SaaS)提供的服务,这些服务大大减少了我们的基础设施需求,并增加了我们应用程序安全性的可靠性、稳定性和未来保障。

摘要

在本章中,我们回顾了当前行业标准的 API 安全。使用载体令牌,我们可以支持授权的 API 访问尝试,而无需维护状态或会话。

在面向服务的架构中,客户端应用程序可以有多种形式,无论是 Web 应用程序、移动应用程序,甚至是智能电视。我们无法确定正在使用的设备类型,我们的 API 也不会跟踪连接到它的应用程序。因此,当用户登录并验证我们的用户信息数据存储时,我们会选择最重要的信息片段并将它们编译成一个令牌。

这个令牌被称为载体令牌,是一个编码的字符串,应该包含足够关于用户的信息,以便我们的 API 能够确定与令牌关联的用户以及他们在我们系统中的权限。

最终,尝试使用这种方法为每个 API 提供安全保护可能会导致很多脱节和复杂性,因此我们引入了一个集中的身份验证管理平台,例如 IdentityServer。这个中央权威将使用通用配置来保护所有 API,并基于这些全局配置颁发令牌。现在,我们可以使用这些令牌一次,访问多个服务,而无需重新进行身份验证。

在任何应用程序中,安全都不应该被忽视,并且当它得到良好实施时,我们可以在我们的应用程序中在安全性和可用性之间取得平衡。

现在我们已经探讨了我们的微服务应用程序的安全性,我们将在下一章中回顾如何利用容器来部署我们的微服务应用程序。

第十三章:微服务容器托管

一旦我们完成了一定量的开发工作,我们接下来的主要关注点是托管。托管带来了一系列问题,因为有许多选项,而这些选项的优缺点与应用程序的架构和整体需求相关。

对于 Web 应用程序,典型的托管选项可能是一个简单的服务器,以及通过 IP 地址或域名进入该服务器的单一入口点。现在,我们正在构建一个微服务应用程序,我们引以为豪的是我们可以促进松散耦合,并且我们的应用程序的所有部分都可以独立运行,无需直接依赖彼此。现在的挑战是如何满足一个可能异构的应用程序。每个服务都是独立的,可能具有不同的托管和数据库需求。然后我们需要考虑为每种技术创建特定的托管环境,这可能导致巨大的成本影响。

这正是我们可以利用容器托管技术并最小化拥有多台服务器机器的成本超支的地方。我们可以使用容器作为每个正在使用的微服务架构中技术和数据库的最小托管要求的缩小版模仿,并且我们可以配置端点,通过这些端点可以访问每个容器。

在本章中,我们将回顾容器的工作原理,它们如何为我们带来好处,以及为什么它们是高效托管微服务应用程序的必备工具。

我们将涵盖以下主题:

  • 理解 Docker 和容器的作用

  • 学习如何使用 Dockerfile 和命令

  • 学习如何使用docker-compose并编排 Docker 镜像

  • 学习如何在容器中部署微服务应用程序并将其部署到容器注册库

技术要求

本章中使用的代码参考可以在 GitHub 上的项目仓库中找到,网址为:github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch13

在微服务开发中使用容器

容器在开发领域非常流行。它们为我们提供了一个轻量级的应用程序托管选项,使我们能够以干净和可重复的方式部署我们的应用程序。虽然它们不是新事物,但近年来在更商业化和易于访问的空间中的应用使得它们变得更加流行。然而,什么是容器,我们为什么应该关心它是如何工作的呢?让我们在下一节中回顾一下。

容器能为我做什么?

从传统意义上讲,每当我们的应用程序对环境和软件有特定要求时,我们就会求助于使用服务器来满足这些要求。服务器和每个应用程序的服务器的问题是它们带有成本。服务器机器通常不便宜,而且当我们引入新机器时,我们还必须考虑许可和能源成本。此外,考虑到如果一台机器出现故障,我们需要重新配置该机器以符合原始环境规格,并重新配置原始部署的几个方面。

在这一点上,我们开始考虑虚拟化。这意味着我们现在使用虚拟机VMs)来构建新服务器并重用旧基础设施。这将大大减少物理基础设施需求和成本,并使我们更容易扩展机器人。我们还可以使用机器的快照来在失败时保持一个快速的恢复计划。市场上有多款虚拟化解决方案,包括流行的如VMware、VirtualBox 和 Microsoft Hyper-V

图 13.1 展示了一台服务器上有多个虚拟机:

图 13.1 – 一台机器需要支持在虚拟机管理程序上运行多个虚拟机

图 13.1 – 一台机器需要支持在虚拟机管理程序上运行多个虚拟机

这种可视化方法看起来像是我们需要的那根救命稻草,但我们发现这个解决方案还有更多问题,如下所示:

  • 我们仍然需要考虑我们需要非常强大的机器来处理多个虚拟机

  • 我们仍然需要进行手动维护任务,以保持我们的环境和操作系统更新和安全

  • 我们必须记住,在几种情况下,我们试图在不同的安装上配置相同的环境,但每次都会遇到预料之外的不同

  • 我们不能总是依赖机器环境与每个实例保持一致

现在,这带我们来到了这个问题的最新解决方案,即容器。容器建立在为我们提供的虚拟机(VMs)的概念之上,使我们能够减少应用程序可能需要的整体空间和资源需求。使用容器来托管我们的应用程序被称为容器化——这是一种现代软件开发方法,允许我们将应用程序及其所有依赖项打包,创建一个可重复、可测试和可靠的包,称为镜像。然后,这个镜像可以直接以一致的方式部署到多个地方。

这种一致性对于我们在容器和虚拟机之间区分容器的好处非常重要。我们刚刚回顾了,我们并不能总是确信服务器或虚拟机的每个操作系统OS)实例都是相同的。容器去除了我们在部署过程中所面临的许多变量,并为需要部署的应用程序提供了一个专门调优的环境。

图 13.2 展示了一个服务器上有几个容器和应用程序:

图 13.2 – 一台机器可以托管多个容器应用程序并更好地利用其资源

图 13.2 – 一台机器可以托管多个容器应用程序并更好地利用其资源

另一个好处,如图 13.2 所示,是我们现在可以最大化物理服务器的资源利用率,因为我们不再需要为整个操作系统的实例提供固定数量的 RAM、CPU 和存储。

容器还可以在共享操作系统上相互隔离,因此我们无需担心容器之间的异构需求。用简单的话说,在 Linux 环境中运行一个容器的同时,也可以运行需要基于 Windows 环境的容器。使用容器可以使我们的应用程序更具有可扩展性和可靠性。我们可以根据需要轻松地配置新的容器实例,并运行多个相同的实例。

使用需要相互通信的虚拟机存在一个主要的安全风险。它们生成包含关于正在交换的数据及其交换方式非常敏感信息的元数据文件。有了这些信息,攻击者可能会试图重放操作并在其中插入恶意信息。容器不会转移这种风险,并将以更安全的方式支持容器间的通信。

容器并不新鲜,但它们正变得越来越流行,用于托管消费者应用程序中的隔离操作并提高部署到较不强大机器上的应用程序的效率。要使用容器,我们需要一个容器托管解决方案,如 Docker。我们将在下一节中回顾如何开始使用 Docker 和容器。

理解 Docker

在我们深入探讨 Docker 及其工作原理之前,值得注意的是,Docker 并不是唯一处理容器化的应用程序。有几个遵循相同开放容器倡议OCI)标准的替代方案,允许我们容器化我们的应用程序。然而,Docker 革命性地推动了容器化进入主流领域和曝光。它是一个免费(用于开发和开源)的应用程序,可在跨平台上使用,并允许我们通过用户界面UI)或命令行界面CLI)对容器进行版本控制并管理多个容器实例。

Docker 的引擎采用客户端-服务器实现风格,客户端和服务器都运行在同一硬件上。客户端是一个命令行界面(CLI),它通过 REST API 与服务器交互,发送指令并执行功能。Docker 服务器是一个后台任务,或称为守护进程,称为dockerd。它负责跟踪我们容器的生命周期。我们还需要创建和配置一些对象来支持部署。这些对象可以是网络、存储卷和插件等,仅举几例。我们会根据具体情况创建这些对象,并在需要时部署它们。

因此,让我们简要回顾一下。容器是一个自包含的空间,一次可以容纳一个应用程序。容器环境及其依赖的定义被称为镜像。这个镜像是一致的蓝图,描述了环境需要呈现的样子。为多个第三方应用程序也存在镜像,这使得我们能够轻松启动实例。拥有一个中央镜像仓库是件好事,Docker 正是出于这个原因提供了Docker Hub

Docker Hub 是一个容器注册库,用于存储和分发容器镜像。我们可以用它来托管自己的镜像,并且有一个公共空间,用于存储和共享行业领先应用程序的通用和共享镜像。注意——Docker Hub 不是唯一的注册库。还有其他替代方案,例如微软的Azure Container RegistryACR),它允许我们与其他 Azure 服务更无缝地集成。

我们已经多次提到了容器镜像。让我们在下一节中更详细地讨论它们。

理解容器镜像

如前所述,容器镜像是一个容器内容的蓝图。它是一个可携带的包,表现为容器的一个内存实例。一个关键特性是镜像不可变。一旦我们定义了镜像,它就不能被更改。基于该镜像或该镜像特定版本的每个容器都将相同。这有助于我们保证在开发和预发布环境中工作过的环境将在生产环境中存在。在部署过程中,我们不再需要“在我的机器上它工作过”的借口。

基础镜像是一个作为其他镜像基础的镜像。它从 Docker 的 scratch 镜像开始,这是一个不创建文件系统层的空容器镜像。这意味着该镜像假定我们将运行的应用程序将直接从操作系统的内核运行。

父镜像是一个作为其他镜像基础镜像的容器镜像。它通常基于操作系统,并将托管一个设计在操作系统上运行的应用程序。例如,我们可能在我们的机器上需要一个 Redis 缓存实例。这个 Redis 缓存镜像将基于 Linux。这就是父镜像。

在这两种情况下,镜像都是可重用的,但基础镜像使我们能够对最终镜像有更多的控制。我们总是可以向镜像中添加内容,但不能从中删除,因为镜像是不可变的。

Docker Hub 是一个可靠的、安全的容器镜像注册中心,其中包含流行的和需求量大的容器镜像,可以轻松地拉取到您的机器上。Docker CLI 提供了与 Docker Hub 的直接连接,通过几个命令,我们可以将镜像从执行它的主机机器上拉取。

如果我们要设置一个 Redis 缓存容器,我们可以通过几个简单的步骤来完成。首先,我们应该在我们的设备上安装 Docker,您可以从www.docker.com获取。一旦安装完成,我们就可以在我们的命令行界面中运行以下命令:

docker pull redis

这将从 Docker Hub 注册中心拉取最新的 Redis 缓存应用程序镜像,并在您的机器上创建一个容器。现在我们有了镜像,我们可以使用docker run命令根据镜像创建一个容器:

docker run --name my-redis-container -d redis

现在,我们已经在主机机器上运行了一个 Redis 缓存实例,我们可以使用任何 Redis 缓存管理工具进行连接。当我们不再需要这个容器运行时,我们可以简单地使用docker stop命令,如下所示:

docker stop my-redis-container

现在,这是一个我们可以快速为第三方应用程序创建优化环境的例子,但我们使用 Docker 的主要原因之一是我们可以为我们的应用程序提供容器。在我们探索如何做到这一点之前,我们应该寻求欣赏使用容器的优缺点。我们将在下一部分查看这些内容。

容器的优缺点

在我们的应用程序中使用容器化的好处是显而易见的。我们可以利用管理我们的托管环境,软件交付的一致性,更有效地使用系统资源,以及软件的可移植性。

回想一下,容器将仅需要托管应用程序运行所需的精确资源。这意味着我们可以放心,我们不会过度扩展或过度配置资源以适应容器。我们还可以从需要时快速启动新容器的好处中受益。如果我们使用虚拟机,那么每个应用程序可能需要一个完整的机器实例。容器具有更小的占用空间,并且需要更少的资源来托管新的应用程序。

在所有这些优点中,我们需要意识到使用这种托管和部署方法的潜在缺点。容器将共享单个操作系统,这种共享依赖性意味着我们现在有一个单一的故障点或攻击点。这对安全团队来说可能是个问题。由于我们可操作的内容较少,监控我们的应用程序也变得稍微困难一些。容器通常提供日志信息,以让我们了解应用程序的健康状况,但我们并不总是了解正在工作的额外资源和插件,这使得全面的监控操作变得稍微困难一些。

如果我们决定在应用程序中使用容器化,我们需要熟悉如何编写用于仅托管我们的应用程序的镜像。在这种情况下,我们需要了解并理解如何使用基础镜像并将我们的应用程序部署到新的容器中。为此,我们需要一个 Dockerfile,我们将回顾如何创建一个。

编写 Dockerfile

Dockerfile 是一个文本文件,概述了如何构建 Docker 镜像。使用的语言是 另一种标记语言 (YAML),这是一种流行的用于配置文件的标记语言。此文件通常包含以下元素:

  • 基础或父镜像,用于构建新镜像

  • 根据需要更新操作系统和额外的软件和插件

  • 将编译的应用程序资产包含在镜像中

  • 为存储和网络需求提供额外的容器镜像资产

  • 当容器启动时运行应用程序的命令

在我们的案例中,我们正在构建一个基于微服务的应用程序,包含多个 Web 服务。这意味着我们需要为每个 Web 服务编写 Dockerfile。由于所有我们的服务都是基于 ASP.NET Core 的,我们可以使用以下 Dockerfile 的示例作为我们的预约 Web 服务和其他服务的基例。

要将 Dockerfile 添加到我们的项目中,我们可以使用 Visual Studio,只需在 解决方案资源管理器 中右键单击我们的项目,转到 添加,然后点击 Docker 支持…

图 13.3 展示了 Docker 支持… 选项:

图 13.3 – 在 Visual Studio 2022 中添加 Docker 支持

图 13.3 – 在 Visual Studio 2022 中添加 Docker 支持

docker-compose 支持中。一旦我们完成选项的选择,它将开始生成我们的文件。

这两个路径将在目标项目中产生两个新文件。我们得到一个 Dockerfile 和一个.dockerignore文件。在预约预订服务项目的情况下,如果我们完成前面的步骤,我们将得到一个看起来像这样的 Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base
WORKDIR /app
EXPOSE 80
EXPOSE 443
FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build
WORKDIR /src
COPY ["HealthCare.Appointments.Api/HealthCare.Appointments.Api.c
  sproj", "HealthCare.Appointments.Api/"]
COPY ["HealthCare.SharedAssets/HealthCare.SharedAssets.csproj",
  "HealthCare.SharedAssets/"]
RUN dotnet restore "HealthCare.Appointments.Api
  /HealthCare.Appointments.Api.csproj"
COPY . .
WORKDIR "/src/HealthCare.Appointments.Api"
RUN dotnet build "HealthCare.Appointments.Api.csproj" -c
  Release -o /app/build
FROM build AS publish
RUN dotnet publish "HealthCare.Appointments.Api.csproj" -c
  Release -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "HealthCare.Appointments.Api.dll"]

此 Dockerfile 包含创建目标服务镜像和构建容器的指令。这些指令执行以下操作:

  1. 使用 mcr.microsoft.com/dotnet/aspnet:6.0 镜像作为基础,我们将从中派生其余的新镜像。

  2. 定义我们希望从容器中公开的端口,这些是标准 Web 流量端口,即 HTTP (80) 和 HTTPS (443)。

  3. 通过复制我们为应用程序所需的键文件和目录的内容,在镜像中定义我们自己的内容。

  4. 执行 构建还原发布 操作以生成二进制文件,发布操作完成后,运行并从 发布 目录复制文件到容器空间。

  5. ENTRYPOINT 定义为启动我们的应用程序的项目执行二进制文件。

我们还得到一个.dockerignore文件,概述了在创建容器时不应包含的文件和目录。其内容如下:

**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

这个文件很容易理解,通常不需要修改。它只是概述了项目文件组成的不同区域,一旦应用程序构建和部署,它认为不需要将这些区域传输到容器中。

我们的 Dockerfile 是我们自己的容器的起点,该容器将容纳目标 Web 服务。为了回顾一下容器是如何构建的,我们从一个注册表开始,这个注册表包含镜像。在这个镜像集中,我们有基础镜像,它们是所有后续镜像的起点。在这种情况下,我们的第一行引用了一个基础镜像,我们希望从中派生出我们的 Web 服务容器。请注意,我们通过这个过程看不到最终使用的基础镜像的来源和基础镜像。事实是,我们不知道mcr.microsoft.com/dotnet/aspnet:6.0基础镜像背后的层次结构,也不知道这些镜像的层次结构。这种方法帮助我们利用对当前基础镜像做出贡献的各种镜像,而无需直接引用它们或使文件因引用而膨胀。我们只是简单地创建了自己的衍生版本。这与保持我们的容器镜像小的目标是一致的。

现在,让我们探索如何继续使用这个 Dockerfile。

启动容器化应用程序

现在我们已经为我们的一个服务完成了这个过程,我们可以轻松地为其他服务重复它。通过这样做,我们将完全成功地容器化每个 Web 服务,以及我们的微服务应用程序。值得注意的是,Visual Studio 和 Visual Studio Code 将始终生成最适合你正在使用的项目类型的最佳 Dockerfile。现在我们还可以享受新引入的启动功能,其中我们可以在一个 Docker 容器中启动我们的新容器化 Web 服务,并且仍然保留实时分析和调试功能,就像它在正常调试设置中运行一样。

属性中,打开名为launchSettings.json的文件。这是一个 JSON 配置文件,它是每个 ASP.NET Core 项目中的标准配置文件,除非你有特殊原因,否则你通常不会打开或修改此文件。然而,它已被修改并赋予了一个新的启动配置文件,该配置文件通知Visual Studio有新的方式来启动此应用程序进行调试。文件现在看起来是这样的:

  "profiles": {
    "HealthCare.Appointments.Api": {
    // Unchanged Content
    },
    "IIS Express": {
     // Unchanged Content
      }
    },
    "Docker": {
      "commandName": "Docker",
      "launchBrowser": true,
      "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}
         /swagger",
      "publishAllPorts": true,
      "useSSL": true
    }
  },

这个新的Docker部分是在我们介绍 Dockerfile 时创建的,现在它允许我们从 Visual Studio 中选择Docker作为启动选项。这将执行 Dockerfile 中概述的指令,其中将基于基础 Microsoft 镜像创建一个新的镜像,构建、恢复并发布我们的 Web 项目,然后将文件移动到新创建的容器中并执行应用程序。

在这个体验中,我们将看到的唯一主要区别是,Visual Studio 或 Visual Studio Code 中的 UI 将显示有关容器的更多信息,例如它们的健康状态、版本、端口、环境变量、日志,甚至是容器正在使用的文件系统。

图 13.4显示了使用 Docker 进行调试时的 Visual Studio UI:

图 13.4 – 当容器在使用时,我们看到处于调试模式的 Visual Studio;它显示了容器在运行时的信息

图 13.4 – 当容器在使用时,我们看到处于调试模式的 Visual Studio;它显示了容器在运行时的信息

当我们使用 Docker 容器进行调试时,你可能会注意到 Docker 的图形用户界面GUI)中的容器,它们显示了可用于 HTTP 流量的端口。我们将使用这些端口来相应地处理配置的端口映射。如果你想通过 CLI 查看正在运行的容器,可以运行以下命令:

docker ps -a

这将生成一个容器列表,显示它们的名称、端口和状态。Docker 有几个命令可以帮助我们自动化容器操作,例如以下命令:

  • docker run启动或创建一个容器。它接受一个-d参数,这是要启动的容器的名称。

  • docker pause将暂停正在运行的容器并挂起所有服务和活动。

  • docker unpausedocker pause相反。

  • docker restart封装了停止和启动命令,并将重新启动容器。

  • docker stop向容器及其内部运行的进程发送终止信号。

  • docker rm删除容器及其所有相关数据。

这里值得注意的神奇之处在于,使用容器使我们的应用程序变得极其便携和可部署。现在,我们不需要在服务器上进行特殊配置,从而降低一个服务器行为与其他服务器不同的风险。我们可以更容易地通过容器在任何机器上部署相同的环境,并且我们可以始终期待相同的输出。

到.NET 7 为止,我们可以无需 Dockerfile 即可容器化我们的应用程序。我们将回顾可以完成的下一步步骤。

使用本机.NET 容器支持

在.NET 7 中,我们原生支持容器化。这意味着我们可以使用.NET 包将容器支持添加到我们的应用程序中,然后直接将应用程序发布到容器中,而无需使用 Docker。

容器支持通过Microsoft.NET.Build.Containers包提供。我们可以使用以下命令添加此包:

Install-package Microsoft.NET.Build.Containers

现在我们已经添加了包,我们可以将应用程序发布到容器中,然后使用 Docker 运行已发布的应用程序。使用 CLI,我们可以运行以下命令:

dotnet publish --os linux --arch x64 -c Release -
  p:PublishProfile=DefaultContainer
docker run -it --rm -p 5010:80 healthcare-patients-
  api:1.0.0

现在,我们的自托管容器将运行并监听配置的5010端口。

现在我们看到,我们有几种方法可以引导我们容器化我们的应用程序,我们为所有其他服务都这样做。然而,这却带来了一个新的挑战,我们可能需要以特定的顺序启动我们的容器化网络服务,或者使用默认值和设置。为此,我们需要一个编排器。我们之前已经提到了编排器,即 Kubernetes,它是 Docker 镜像(隐喻的手)的行业领先隐喻手套,以便与之匹配。

在我们到达 Kubernetes 之前,Docker 提供了一个由 docker-compose 文件中概述的指令定义的编排引擎。我们将在下一节中探讨它是如何工作的。

理解 docker-compose 和镜像

在我们探索 docker-compose 以及它是如何编译我们的镜像之前,我们需要了解编排的概念以及为什么我们需要它。容器编排是一种自动化的方法,用于启动容器及其相关服务。在上下文中,当我们有一个容器化的应用程序时,我们可能会在我们的容器化应用程序和第三方应用程序之间得到几个容器。

在我们的微服务应用程序的上下文中,我们拥有几个独立的服务,每个服务都是容器化的。我们也可能最终会使用一个容器化的缓存服务器,以及其他支持服务,如电子邮件和日志应用程序。我们现在需要一种方法来组织我们的容器列表,并按特定顺序启动它们,以确保在启动依赖服务容器之前,支持应用程序是可用的。

这个要求引入了相当多的复杂性,可能会使手动操作变得非常困难。使用容器编排,我们可以使这项操作对开发和运维变得可管理。我们现在有了定义需要完成的工作以及容器应启动的顺序及其依赖关系的声明式方法。现在我们将有以下几点:

  • 简化操作:重申一下,容器编排极大地简化了以特定顺序和配置启动容器的重复性工作。

  • 弹性:可以根据容器健康、系统负载或扩展需求执行我们的特定操作。编排将根据需要管理我们的实例,并自动化应采取的最佳利益行动,以确保应用程序的稳定性。

  • 安全性:这种自动化方法有助于我们减少人为错误的可能性,并确保我们应用程序的安全性。

docker-compose是我们能采用的 simplest 形式的编排。其他选择包括 Kubernetes 和 Docker Swarm,它们是容器编排领域的行业领先选项。

docker-compose 是一个帮助我们定义多容器应用程序的工具。我们可以定义一个 YAML 文件,并定义需要启动的容器及其依赖项,然后我们可以使用单个命令启动应用程序。docker-compose 的主要优点是我们可以在一个文件中定义关于我们应用程序堆栈的所有内容,并在应用程序文件夹的根目录中定义它。

这里另一个优点是,如果我们的项目是版本控制的,我们可以轻松地允许通过外部贡献来演变此文件,或者我们可以轻松地与他人共享我们的容器以及我们的应用程序的启动步骤。

现在,让我们回顾一下将 docker-compose 支持添加到我们的微服务应用程序的步骤。

将 docker-compose 添加到项目

docker-compose 支持添加到我们的 ASP.NET Core 微服务应用程序非常简单。在 Visual Studio 中,我们可以简单地右键单击我们的服务项目之一,转到 添加,然后选择 容器编排器支持…。这会打开一个窗口,我们可以确认我们更喜欢 Docker Compose 并选择 确定。我们可以选择 WindowsLinux 作为目标操作系统。由于 ASP.NET Core 是跨平台的,所以任何操作系统选项都可以工作。

图 13.5 显示了 容器编排器支持… 选项:

图 13.5 – 使用 Visual Studio 2022 添加容器编排器支持

图 13.5 – 使用 Visual Studio 2022 添加容器编排器支持

这在我们的解决方案中引入了一个新的项目,其中包含一个 .dockerignore 文件和一个 docker-compose.yml 文件。如果我们检查 docker-compose.yml 文件,我们会看到我们有一个版本号和至少一个服务定义:

version: '3.4'
services:
  healthcare.patients.api:
    image: ${DOCKER_REGISTRY-}healthcarepatientsapi
    build:
      context: .
      dockerfile: HealthCare.Patients.Api/Dockerfile

在我们服务的定义下,我们定义了我们希望启动的容器,并声明了图像的名称和 Dockerfile,它将是图像定义的参考点。按照对其他服务的类似步骤,Visual Studio 将自动根据需要将额外的服务附加到文件中。如果我们继续对额外的服务执行此操作,我们将得到一个像这样的 docker-compose 文件:

version: '3.4'
services:
  healthcare.patients.api:
    image: ${DOCKER_REGISTRY-}healthcarepatientsapi
    build:
      context: .
      dockerfile: HealthCare.Patients.Api/Dockerfile
  healthcare.auth:
    image: ${DOCKER_REGISTRY-}healthcareauth
    build:
      context: .
      dockerfile: HealthCare.Auth/Dockerfile
  healthcare.appointments.api:
    image: ${DOCKER_REGISTRY-}healthcareappointmentsapi
    build:
      context: .
      dockerfile: HealthCare.Appointments.Api/Dockerfile
  healthcare.apigateway:
    image: ${DOCKER_REGISTRY-}healthcareapigateway
    build:
      context: .
      dockerfile: HealthCare.ApiGateway/Dockerfile

现在,我们有一个 docker-compose 文件,它记录了在我们的微服务应用程序中需要启动的每个容器。现在,我们可以根据整个应用程序的需求扩展此文件以包含额外的容器。如果我们需要一个 Redis 缓存实例,我们可以在 docker-compose 文件中添加一个命令来启动 Redis 容器。这将是它的样子:

services:
  redis:
    image: "redis:alpine"
… Other services …

此文件添加将简单地启动一个使用定义的基础镜像的 Redis 缓存容器。请注意,Redis 缓存图像组合可以扩展以使用存储卷,我们可以将特定的配置文件传递给我们的图像,并指示我们希望在数据卷中持久化容器的信息。当我们重新启动此图像时,上一次运行的数据仍然可用。

我们还可能想要指出,某些容器应该在其他容器启动之前可用。如果容器依赖于 Redis 缓存容器或另一个服务,这可能很有用。为此,我们可以添加另一个节点depends_on,这将允许我们指示在尝试启动其他容器之前应该启动的容器名称。例如,我们的预约服务时不时地与我们的患者服务进行通信。我们确保在尝试启动预约服务之前启动患者服务是明智的。我们可以修改预约容器的编排,使其看起来像这样:

healthcare.appointments.api:
    image: ${DOCKER_REGISTRY-}healthcareappointmentsapi
    depends_on:
      - healthcare.patients.api
    build:
      context: .
      dockerfile: HealthCare.Appointments.Api/Dockerfile

我们可以为每个镜像提供更具体的配置,甚至为我们的镜像提供更具体的配置,而无需直接重复执行 Dockerfile。这正是docker-compose.override.yml文件发挥作用的地方。它是一个嵌套的子项,可以在docker-compose.yml文件下找到,其内容如下:

version: '3.4'
services:
  healthcare.patients.api:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - ASPNETCORE_URLS=https://+:443;http://+:80
    ports:
      - "80"
      - "443"
    volumes:
      - ${APPDATA}/Microsoft/UserSecrets:/root/.microsoft/
      usersecrets:ro
      - ${APPDATA}/ASP.NET/Https:/root/.aspnet/https:ro
... Other overrides...

在这里,我们可以看到我们的 API 容器将使用特定的环境变量启动,并具有预设的端口和卷。如果您更喜欢对容器有更明确的控制,可以删除此文件,但您需要确保在 Dockerfile 中配置了相关值。

为了回顾我们在上一章中提到的“后端为前端”模式,我们概述了我们可以配置多个网关项目并为每个实例提供特定的配置。现在有了容器化技术,我们可以移除对额外代码项目的需求,并重用相同的项目,同时为每个项目使用不同的配置。让我们向docker-compose.yml文件中添加以下行,并基于相同的网关镜像创建两个独立的容器:

  mobileshoppingapigw:
    image: ${DOCKER_REGISTRY-}healthcareapigateway
    build:
      context: .
      dockerfile: HealthCare.ApiGateway/Dockerfile
  webshoppingapigw:
    image: ${DOCKER_REGISTRY-}healthcareapigateway
    build:
      context: .
      dockerfile: HealthCare.ApiGateway/Dockerfile

现在我们已经基于相同的ApiGateway镜像定义创建了两个新的容器,我们可以向docker-override.yml文件中添加内容,并指定在两种情况下应使用的特定配置文件的来源:

  mobilegatewaygw:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - IdentityUrl=IDENTITY_URL
    volumes:
      - ./HealthCare.ApiGateway/mobile:/app/configuration
  webhealthcaregw:
    environment:
      - ASPNETCORE_ENVIRONMENT=Development
      - IdentityUrl=IDENTITY_URL
    volumes:
      - ./HealthCare.ApiGateway/web:/app/configuration

我们可以将卷源指定为配置文件的路径,但我们还利用这个机会定义了相对于配置的环境变量值,这些配置可能会变化或在容器创建时需要设置。

通常,我们还会考虑其他依赖项的需求,例如数据库服务器、消息总线提供者(如 RabbitMQ)、支持性 Web 服务和工具等。您的微服务应用的所有组件都可以容器化,并且容器可以根据需要直接相互引用。《docker-compose.yml》文件允许我们根据需要概述所有这些容器及其变量,我们可以轻松地插入相应的值。只需几行代码,我们就可以开始,但还有许多其他途径可以探索和掌握。

现在,我们可以轻松地通过一键或一条命令启动整个微服务应用程序及其所有组件。如果使用 Visual Studio,传统的开始调试按钮会一次引用一个服务的名称,或者我们需要启用多个项目以进行调试。现在,docker-compose.yml文件的存在取代了这种具体的需求,并赋予我们运行compose命令并一次性初始化所有容器的能力。使用 CLI,我们可以简单地运行docker-compose up

现在,我们拥有一个完全容器化的应用程序,我们可以简单地调整用于启动容器的参数,从而在处理应用程序托管和部署时保持敏捷和可扩展。这也使得与我们的开发团队分享应用程序变得更加容易,团队成员只需安装 Docker 作为依赖项即可运行我们的应用程序。

我们还看到,有一些基础镜像允许我们扩展并创建自己的镜像。总有一天,我们会遇到特定的应用程序或我们自己的应用程序版本,我们希望将其保存并重新分享,而不仅仅是我们的项目。我们可以将我们的容器镜像发布到中央仓库。我们将在下一节中回顾这一概念。

将容器发布到容器注册库

容器注册库是一个集中存储多个容器的仓库。它允许远程访问这些容器,并在我们需要为某些应用程序提供一个一致的容器镜像源时,对于一般开发和部署需求非常有用。容器注册库通常直接连接到 Docker 和 Kubernetes 等平台。

注册库可以节省开发者在创建和交付云原生解决方案中的时间和精力。回想一下,容器镜像包含构成应用程序的文件和组件。通过维护注册库,我们可以最大化我们的敏捷开发努力,并有效地交付增量镜像更新,并且通过注册库,我们可以将它们存储在团队的中心区域。

容器注册库可以是私有的或公开的。我们将在下一节讨论我们的选项。

公开与私有容器注册库

注册库可以是公开的或私有的。公开仓库通常被开发者或开发者团队使用,他们要么希望尽快提供注册库,要么希望公开分享他们开发的容器。这些镜像随后被其他人用作基础镜像,有时会进行一些调整。这是为开源容器镜像集合做出贡献的绝佳方式。Docker Hub 是最大的公开仓库和容器社区,也是我们执行docker pull命令时的默认容器镜像源。

私有仓库提供了一种更安全和私密的方式来托管和维护企业容器。这些类型的仓库可以是远程的,通过如Google Container RegistryGCR)、Microsoft ACR 或 Amazon Elastic Container RegistryECR)等已建立的仓库。

当使用私有容器仓库时,我们对安全和配置有更多的控制,但我们也承担了更多管理责任,包括在组织内部进行访问控制和合规性。我们需要维护以下内容:

  • 在我们组织内部支持多种身份验证选项

  • 基于角色的访问控制RBAC)对镜像的支持

  • 镜像版本控制和针对漏洞的维护

  • 用户活动审计和日志记录

我们可以通过更严格的控制和措施来正确控制谁能够上传镜像,并防止对仓库的未授权访问和贡献。

请记住,容器仓库可以本地托管。这意味着我们可以在服务器上部署 Docker Hub 的本地实例,并添加针对我们组织需求和政策的特定配置。我们还可以利用云托管的容器仓库服务。使用云托管解决方案的优势在于,我们可以减少设置本地服务器时伴随的基础设施考虑,同时利用一个完全托管和地理复制的解决方案。

让我们回顾一下如何创建一个自定义版本的图片并将其上传到仓库。

创建和上传自定义图片

我们已经创建了几个镜像来支持我们的应用程序。有时我们可能需要将特定的应用程序以一致和可重复的方式部署,而我们不想冒重新创建 Dockerfile 或docker-compose.yml文件的风险。

在这种情况下,我们可以拉取基本镜像的副本,向其中添加我们自己的配置和变量,然后将带有新且唯一的名称的镜像推送到仓库。现在,其他团队成员——甚至你未来的项目——可以随意拉取这个新镜像,并根据需要利用预设的环境。为此练习,我们需要在 Docker Hub 上有一个账户。

让我们以一个 SQL Server 镜像为例。如果我们需要创建一个以默认数据库为起点的镜像,我们可以通过以下步骤完成。首先,我们使用以下命令从 Docker Hub 拉取基本镜像:

docker pull mcr.microsoft.com/mssql/server

SQL Server 镜像通常较大,因此下载可能需要一些时间。一旦下载完成,我们可以执行以下命令来运行容器:

docker run -e "ACCEPT_EULA=Y" -e
  "MSSQL_SA_PASSWORD=AStrongP@ssword1" -p 1434:1433 -d
    mcr.microsoft.com/mssql/server

第一个命令类似于我们之前看到的,即从我们的本地机器拉取镜像。然后我们运行镜像,基于最新的镜像版本创建 SQL Server 的实例,并通过端口1434使其可用。1433是 SQL Server 的默认端口,因此我们可以使用不同的端口以避免与其他可能存在的 SQL 实例发生冲突。我们还设置了默认的*sa*用户凭据,并接受使用条款协议。

现在我们已经从我们的镜像中启动并运行了 SQL Server 实例,我们可以使用 SQL Server Management StudioSSMS)或 Microsoft Azure Studio 连接,然后运行一个脚本。我们将保持简单,创建一个数据库和一个表:

CREATE DATABASE PatientsDb
GO
USE PatientsDb
CREATE TABLE Patients(
    Id int primary key identity,
    FirstName varchar(50),
    LastName varchar(50),
    DateofBirth datetime
)

现在我们有了更新的数据库,让我们将更新的镜像提交到注册表中。我们可以使用 Docker UI 查看运行中的 SQL Server 容器的名称,或者使用 docker ps 命令,该命令将列出所有正在运行的容器。然后我们使用运行中的 SQL Server 容器的名称运行这个 docker commit 命令:

docker commit -m "Added Patients Database" -a "Your Name"
  adoring_boyd Username/NewImageName:latest

这将在容器当前状态下创建镜像的本地副本。我们添加一个 commit 消息,以便我们可以跟踪所做的更改,并添加一个作者标签,说明基于哪个容器的名称创建镜像,然后添加我们的 Docker Hub 用户名和新的镜像名称。现在,我们可以使用 Docker UI 并点击 docker images 命令来列出系统上当前的所有镜像。您现在将看到原始的 SQL Server 镜像以及我们最近提交的镜像。

现在,我们已经成功地将我们的镜像发布到了我们自己的本地注册表中。如果我们想使这个镜像在 Docker Hub 上可访问,我们需要使用 Docker UI 中可用的 推送到 Hub 选项。或者,我们可以运行以下命令:

docker push Username/NewImageName

在未来,如果我们需要将这个数据库镜像添加到我们的编排中,我们可以像这样修改我们的 docker-compose.yml 文件:

  patients_sql_db:
    image: Username/NewImageName
    restart: always
    ports:
      1434:1433

现在,我们将始终使用这个基础数据库启动 SQL Server 实例。

我们不仅容器化了我们的应用程序,还配置了编排并创建了我们的自定义镜像。让我们回顾一下本章所学到的所有内容。

摘要

在本章中,我们回顾了容器化的优缺点。我们看到了容器如何帮助我们减少应用程序的资源需求,并为开发和部署创建了这些应用程序的便携式版本。

Docker 是一个行业领先的容器化软件,拥有不断增长的贡献者社区。Docker 可以安装在机器上,然后根据需要用于管理镜像和容器。我们还将能够访问 Docker Hub,这是一个流行的公共镜像存储库。

当我们将 Docker 集成到我们的 ASP.NET Core 应用程序中时,我们为构建和托管自己的应用程序开辟了新的维度。我们现在可以保证我们的服务将以更一致的方式运行,无论它们部署在哪个机器上。这是因为我们将创建容器来托管我们的服务,这些容器将根据服务的需求进行优化,并且除非我们调整它们的定义,否则它们永远不会改变。

我们还研究了容器编排,这是我们可以在一个设置中概述所需的容器,并一次性启动它们,或者根据依赖关系以特定顺序启动。这对我们的微服务应用来说非常完美,因为我们的应用由多个服务和依赖关系组成,逐一启动会非常繁琐。

最后,我们回顾了如何创建自己的镜像并将其托管在本地容器注册库中。然后,我们可以将自定义镜像发布到公共注册库,如 Docker Hub,使其对所有用户可访问。现在,我们可以创建具有我们需要与团队共享的应用程序版本的特定容器,并且我们可以更好地控制分发和使用的容器版本。

在下一章中,我们将回顾应用开发中的一个主要横切关注点,即微服务中的日志聚合。

第十四章:实施微服务的集中式日志记录

与 API 相关的一个最大挑战是我们很少得到关于服务中实际发生情况的反馈。我们尽最大努力设计我们的服务,使 HTTP 响应指示每个操作的成败,但这并不总是足够。最令人担忧的是 5xx 范围内的响应,它们后面没有任何有用的信息。

因此,我们需要在我们的微服务应用程序中采用日志记录。日志记录提供了关于服务中发生的操作和事件的实时信息。每条日志消息都帮助我们理解应用程序的行为,并在出现问题时协助我们的调查。因此,日志是应对模糊的 5xx HTTP 响应的第一道防线。

在开发过程中,日志帮助我们理解我们面临的一些问题,并且当实施得当,可以提供被调用函数及其输出的逐点记录。这将帮助我们更容易地发现代码可能出错的地方,或者为什么函数的输出不符合预期。

在分布式应用程序的情况下,我们需要实施特殊措施,帮助我们集中管理由各个服务产生的日志。是的,我们在这种架构中推广自主性,但所有组件仍然组合在一起形成一个应用程序。这使得当需要筛选多个日志文件和来源时,故障点更难找到。

在阅读本章之后,我们将能够做到以下几点:

  • 理解日志聚合的使用

  • 了解如何实现性能监控

  • 了解如何实现分布式跟踪

技术要求

本章中使用的代码示例可以在本项目的 GitHub 仓库中找到,该仓库托管在github.com/PacktPublishing/Microservices-Design-Patterns-in-.NET/tree/master/Ch14

日志及其重要性

日志是大多数应用程序在运行时产生的文本块。它们被设计成关于应用程序中发生情况的易于阅读的迷你报告,并且应该允许我们跟踪和追踪应用程序中发生的错误。

在单体应用程序中,我们通常将日志写入日志文件或数据库。实际上,在.NET Core 中,我们有访问强大的日志提供程序和第三方库的权限,允许我们与多个日志输出目标集成。没有真正的最佳目标,尽管有些比我们的更好,但这取决于项目偏好和整体舒适度。我们的单体日志包含有关一个应用程序中发生的一切信息。

在分布式系统中,这变得更加复杂,因为我们有多个应用中发生活动。第一个倾向是按服务创建日志,这可能会导致多个日志文件,每个文件都包含我们需要查看的整体信息的一部分。想象一下,需要访问多个日志文件来调查下午 5 点发生的一个故障。为了理解这个故障,我们需要审查多个来源来拼凑出任何有意义的线索,这可能是一项艰巨的任务。

如果我们的日志过于冗长,这项调查将变得更加困难。冗长的日志会报告应用中发生的所有事件。即使我们不报告所有内容,我们也需要明确我们记录的内容,以减少噪音并更好地突出需要捕获的关键事件。

我们需要一种干净的方式来集中管理跨服务生成的日志。集中式数据库可能看起来是个好主意,但如果多个服务频繁地写入日志,可能会导致资源和表锁定。我们将在稍后审查最佳集中化技术。现在,让我们专注于决定最佳的信息记录内容以及如何在.NET Core 中实现这一点。

选择记录的内容

在实施日志记录时做出的重要决定是我们想要记录什么。我们是否想要记录应用中发生的所有事情的逐字记录,还是我们只想记录错误?不同的系统有不同的要求,正确的选择取决于该服务对应用整体运行的重要性。

现在我们已经确定了需要通过日志进行更多监控的最关键服务,我们需要确定将记录哪些信息。回想一下,我们不想让日志过于冗长,但我们也不想在日志中遗漏相关信息。过多的信息可能导致大量无用的日志和高存储成本,而信息过少将使我们得到无用的文件。

有用的信息包括但不限于以下内容:

  • 通过 API 请求访问的资源 ID

  • 请求周期中调用的不同功能

我们想要避免记录敏感信息。我们不想记录以下内容,例如:

  • 认证流程中的用户凭据

  • 支付信息

  • 个人可识别信息

尽管我们尽了最大的安全努力,日志仍然是关于我们系统的事实来源,对包含敏感信息的日志文件的安全漏洞可能证明是一个有害的事件。有一些法规和数据保护法规定了我们应该如何存储和保障我们的日志。因此,最好只记录可以按需查询的 ID,而不提前泄露任何信息。

我们还有 日志级别 的概念,它是日志消息严重性的分类。这些级别分为 信息调试警告错误关键。中间可能还有其他类别,但这些都是最常用的。它们代表以下含义:

  • 信息:这是一个标准的日志级别,当发生预期的事情时使用。它们通常用于分享有关操作的信息,并有助于追踪可能导致错误的操作序列。

  • 调试:这是一个非常信息的日志级别,比我们日常使用所需的要多。它主要用于开发,帮助我们跟踪代码中的更复杂操作。生产系统通常不会产生调试日志。

  • 警告:这种日志级别表示发生了不是错误但也不正常的事情。把它想象成黄灯,表明应该关注某种情况,但它可能不是任务关键。

  • 错误:错误就是错误。这种类型的日志条目通常在遇到异常时创建。它可以与异常和堆栈跟踪一起使用,并在调试问题时证明是一种关键的日志类型。

  • 关键:这表示我们遇到了一个无法恢复的错误。这种日志条目可以在应用程序启动失败或无法连接到关键数据源或依赖项时使用。

日志级别是一种通用语言,我们应该确保我们使用适当的日志级别准确地表示正在记录的情况。我们还希望避免错误分类我们的事件,并记录关于我们系统中发生的事情的误导性信息。

再次强调,关于记录什么内容,最终的决定取决于应用程序、开发人员和组织的需要,我们需要确保我们足够彻底,能够捕捉到关于应用程序运行时基本信息的要点。现在,让我们回顾一下如何在 .NET Core 应用程序中实现日志记录。

使用 .NET 日志 API

.NET 有一个内置的日志机制,它被嵌入到我们的应用程序启动操作中。我们得到一个开箱即用的日志库,它会在应用程序启动后立即记录所有发生的事情。这个机制与多个第三方日志提供程序一起工作,使其可扩展且功能强大。通过我们的提供程序,我们可以确定日志的目标目的地。

我们将从 ILogger 接口开始。这个接口是 .NET 伴随的日志 API 的抽象。它通过 Microsoft.Extensions.Logging NuGet 包提供给我们。这个库为我们提供了应用程序日志记录所需的必要类、接口和功能,并为 ConsoleDebugAzure Log StreamEventSourceWindows Event Log 提供了日志记录提供程序:

  • 控制台: 此提供程序将日志输出到控制台。当调试时,会出现一个控制台窗口,大多数 IDE(上下文中,Visual Studio 和 Visual Studio Code)都为运行时日志提供了一个调试控制台窗口。

  • System.Diagnostics.Debug 类。

  • EventSource: 一个跨平台提供程序,可以作为名为 Microsoft-Extensions-Logging 的事件源。

  • Windows 事件日志: 一个仅适用于 Windows 的提供程序,将日志输出发送到 Windows 事件日志。它默认仅记录 Warning 级别的消息,但可以根据需要进行配置。

  • Azure 日志流: Azure 日志流支持在应用程序运行时通过 Azure 门户查看日志。我们可以轻松地将日志写入此提供程序。

要让我们的 .NET 应用程序开始写入日志,我们可以简单地将 ILogger<T> 注入到日志应该起源的类中。T 代表我们注入服务的类的名称。这有助于后续的日志分类和过滤,因为当日志生成时,它们会自动指示类名。ILogger<T> 通常由应用程序代码使用,这些代码可能存在于多个位置。因为类名被用作 类别,这使得我们能够轻松地将日志条目链接回生成它们的类。在以下代码中,我们将 ILogger<T> 注入到我们的预约服务控制器中:

public class AppointmentsController : ControllerBase
    {
        /* Other fields */
        private readonly ILogger<AppointmentsController>
            logger;
        public AppointmentsController(/* Other Services */,
            ILogger<AppointmentsController> logger)
        {
            this.logger = logger;
        }
    }

与我们注入其他服务的方式相比,注入 ILogger<T> 是标准的。现在拥有这个日志记录器的优点是,我们可以写入日志来通知我们的 API 中的活动和错误。如果我们需要记录每次通过 API 调用检索预约列表时,我们可以修改我们的 GET 方法如下:

// GET: api/Appointments
        [HttpGet]
        public async Task<ActionResult<Ienumerable
            <Appointment>>> GetAppointments()
        {
            Logger.LogInformation("Returning
                Appointments");
            return await _context.Appointments
               .ToListAsync();
        }

现在,当我们向此服务的 GET 方法发出请求时,我们将在我们的控制台中看到如下消息。在这里,“控制台”指的是启动并显示有关正在运行的 .NET 应用程序的消息以及我们在使用的 IDE 中的调试输出的控制台窗口:

HealthCare.Appointments.Api.Controllers.AppointmentsController: Information: Retrieving appointments

注意,我们不仅可以看到源调用,还可以看到其命名空间,这也在帮助我们确定哪个确切的类正在生成日志方面发挥了重要作用。我们还得到一个日志级别标志,这样我们就可以一眼看出严重性。您还会注意到有许多其他我们未编排的默认日志条目。我们可以通过 appsettings.json 文件来控制我们希望在应用程序中拥有的全局日志级别和来源。默认情况下,它将具有以下配置:

"Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },

这指定了应输出到目标的最小默认级别是默认日志源的 Information。与我们的应用程序内部工作相关的内容只有在它是 Warning 时才会暴露出来。如果我们将它们都修改并放置在 Information,那么我们的日志从一开始就会变得非常冗长。

我们的 LogLevel 方法有一个标准布局,允许我们根据需要轻松地包含额外的信息。可能的参数如下:

  • eventId: 一个与你的应用程序中的操作相关联的数值。对此没有预定义的标准,你可以根据自己的组织和需求分配自己的值。这是一个可选参数,但在我们需要将日志与特定操作关联时可能很有用。

  • try/catch 块。

  • {username} 应该在 messageArgs 中提供。

  • messageArgs: 这是一个对象数组,它将绑定到消息字符串中概述的占位符。绑定将按照参数出现的顺序进行,因此值也应该按照这个顺序提供。如果没有提供值,占位符将按原样打印在字符串中。

使用所有参数的一个示例可能如下所示:

public async Task<ActionResult<AppointmentDetailsDto>>
    GetAppointment(Guid id)
        {
            try
            {
                var appointment = await
                    _context.Appointments.FindAsync(id);
                if (appointment == null)
                {
                    return NotFound();
                }
                // Other service calls
                var patient = await _patientsApiRepository
                    .GetPatient(appointment
                        .PatientId.ToString());
                var appointmentDto = _mapper.Map
                   <AppointmentDetailsDto>(appointment);
                appointmentDto.Patient =
                    _mapper.Map<PatientDto>(patient);
                return appointmentDto;
            }
            catch (Exception ex)
            {
                logger.LogError(100, ex, "Failure
                    retrieving apointment with Id: {id}",
                        id);
                throw;
            }
        }

在这里,我们为我们的端点添加了异常处理,该端点通过 ID 获取预约记录。如果在执行任何操作时出现异常,我们将捕获它并记录它。我们为这次操作使用 100 作为 eventId 属性,并记录异常,包括一些自定义消息和一些更多信息,以帮助我们确定异常的性质。我们还包含了导致失败的记录的 ID;注意 {id} 占位符将映射到 id 参数。给参数相同的名称不是必需的,但它确实有助于减少任何与值绑定的混淆。

如果我们想扩展每个日志消息应使用的提供者数量,我们可以在应用程序的 Program.cs 文件中配置日志设置。在一个标准的样板 ASP.NET Core 项目中,我们可以添加如下代码:

builder.Logging.ClearProviders();
builder.Logging.AddConsole()
    .AddEventLog(new EventLogSettings { SourceName =
        "Appointments Service" })
    .AddDebug()
    .AddEventSourceLogger();

首先,我们必须清除任何预配置的提供者,然后添加我们希望支持的提供者。现在,一个日志消息将被写入多个目的地。这可以是一种方便的方式来分散我们的日志消息,并为每个目的地附加不同的监控方法。请记住,你应该始终了解你所在国家和公司的信息安全管理规则,并尽量避免在太多地方暴露太多信息。我们还可以通过修改 appsettings.json 文件中的日志配置来为每个提供者提供特定的配置,如下所示:

{
  "Logging": {
    "LogLevel": {
      "Default": "Error",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Warning"
    },
    "Debug": {
      "LogLevel": {
        "Default": "Trace"
      }
    },
    "Console": {
      "LogLevel": {
        "Default": "Information"
      }
    },
    "EventSource": {
      "LogLevel": {
        "Microsoft": "Information"
      }
    },
    "EventLog": {
      "LogLevel": {
        "Microsoft": "Information"
      }
    },
  }
}

我们为每个日志源保留默认的 LogLevel,但随后为每个提供者提供覆盖。如果日志源在提供者的配置下未定义,则它将保留默认行为,但我们保留控制每个提供者应优先考虑哪种日志类型的权利。

如果我们需要扩展对 Azure 系统的支持,我们可以利用 Azure 应用程序的文件存储和/或 Blob 存储。我们可以通过 NuGet 软件包管理器包含 Microsoft.Extensions.Logging.AzureAppServices,然后我们可以使用如下代码配置日志服务:

builder.Logging.AddAzureWebAppDiagnostics();
builder.Services.Configure<AzureFileLoggerOptions>
    (options =>
{
    options.FileName = "azure-log-filename";
    options.FileSizeLimit = 5 * 2048;
    options.RetainedFileCountLimit = 15;
});
builder.Services.Configure<AzureBlobLoggerOptions>
    (options =>
{
    options.BlobName = "appLog.log";
});

这将配置应用程序使用文件系统以及 Azure 中的 Blob 存储。根据 App Services 日志设置,我们可以查找一些默认的日志输出位置。同样,我们可以通过向 appsettings.json 文件中添加具有别名 AzureAppServicesBlobAzureAppServicesFile 的部分来覆盖此提供程序正在输出的默认日志。我们还可以定义 ApplicationInsights,如果我们打算为我们的应用程序使用该服务。为了支持 ApplicationInsights,我们需要 Microsoft.Extensions.Logging.ApplicationInsights NuGet 软件包。Azure Application Insights 是由 Microsoft Azure 提供的强大日志聚合平台,是 Azure 托管解决方案的一个很好的选择。

几个第三方框架扩展了 .NET 内置日志 API 的功能。在下一节中,我们将探讨如何集成一个流行的框架,称为 Serilog

添加 Serilog

存在几个第三方框架,它们扩展了我们 .NET 应用程序中可用的日志记录功能和选项。流行的选项包括 SerilogLoggrElmah.ioNLog,仅举几例。每个都有其优缺点,但在这个部分,我们将探讨 Serilog,我们将如何将其集成到我们的应用程序中,以及它为我们引入了哪些选项。

Serilog 扩展 Microsoft.Extensions.Logging,并提供快速且相对简单的方法来覆盖默认设置,同时保留原始框架的全部功能和灵活性。要开始,我们需要安装 Serilog.AspNetCore 软件包。对于非 Web .NET Core 项目,我们需要使用 Serilog.Extensions.Hosting

Install-package Serilog.AspNetCore

Serilog 有使用输出通道的概念。在概念上,输出通道类似于日志提供程序,代表框架写入的日志的输出通道。我们需要为每个我们希望支持的输出通道添加额外的包。常用的输出通道包括 ConsoleFileSeqSQL ServerAzure Application Insights,仅举几例。您可以从他们的 GitHub wiki 页面获取完整的列表:github.com/serilog/serilog/wiki/Provided-Sinks

对于这个练习,我们将配置 Serilog 以使用文件和控制台输出。我们还将向我们的 appsettings.json 文件中添加参数。我们需要将 expressions 扩展添加到基本库中,以支持将 JSON 文本解析为所需的设置:

Install-package Serilog.Expressions

现在,我们可以从我们的 appsettings.json 文件中删除 Logging 部分,并用以下 JSON 文本替换它:

  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information"
      }
    },
    "WriteTo": [
      {
        "Name": "File",
        "Args": { "path":  "./logs/log-.txt",
           "rollingInterval": "Day" }
      }
    ]
  },

现在,我们有一个类似的结构,可以定义应用程序的日志默认设置,但我们还有一个WriteTo部分,允许我们列出我们想要支持的不同通道。我们只包括了File写入的设置,并指定目标位置为一个名为logs的本地文件夹。文件将每天自动创建,并赋予一个由log-表达式和日期组合而成的名称。这将使我们能够按日轻松检索相关文件,并且每个日志都会显示一个时间戳,使得回顾事件更加容易。

现在,我们可以移除builder.Logging(…)配置,并用这个来替换:

  builder.Host.UseSerilog((ctx, lc) => lc
        .WriteTo.Console()
        .ReadFrom.Configuration(ctx.Configuration));

这将初始化我们的日志记录器以使用Console接收器和读取之前定义的 Serilog 部分的配置对象。这将初始化ConsoleFile接收器。现在,我们可以期待看到为每个实现了 Serilog 文件日志配置的微服务每天创建并填充的文本文件。需要写入日志的代码保持不变。我们只需要重复为ILogger<T>概述的注入步骤;Serilog 会完成其余的工作。

现在,我们已经解决了一个问题,我们不再对我们的应用程序发生的事情视而不见。我们可以轻松地将日志集成到我们的服务中,并以日志文件的形式审查更持久的输出。然而,我们仍然面临着需要审查不同系统中的多个不同日志,以正确追踪可能导致某个点失败的原因的挑战。

这就是我们需要探索汇总日志并使它们从一个界面中可见和可搜索的方法。我们将在下一节中探讨如何实现这一点。

日志聚合及其用途

日志聚合是将来自不同来源的日志捕获并整合到一个集中平台的概念。在分布式系统中,日志由多个来源生成,我们需要对所有消息有一个全面的了解,并需要有效地关联和分析日志。

当我们需要调试应用程序性能和错误或识别瓶颈、故障点或漏洞时,日志聚合充当单一的真实来源。几个平台允许我们聚合我们的日志,它们从免费到付费再到云托管解决方案不等。一些流行的平台包括Azure Application InsightsSeqDataDogELK (Elasticsearch, Logstash, and Kibana)等,仅举几例。在选择平台时,我们必须考虑以下因素:

  • 效率:我们都喜欢并且希望系统高效。我们选择的平台需要符合这一叙述,并尽可能简化日志集成、以各种格式导出日志信息以及快速筛选和排序日志信息的过程。大多数日志聚合器允许我们编写查询,可以智能地筛选日志噪音,并给我们提供更具体的数据。

  • 处理能力:该平台需要能够从多个来源提供舒适的吞吐量,并能够索引、压缩和高效地存储这些日志。我们可能不一定知道它们实现这一点的技术,但我们可以通过我们的查询和数据的整体展示来评估索引功能的准确性。

  • 实时功能:实时监控非常重要,因为我们通常需要日志聚合来监控应用程序中发生的事情。信息提供得越快,我们就能越快地响应故障。

  • 可扩展性:该平台需要能够处理不同的流量周期,并在流量突然变化时不会崩溃。我们需要确保系统在高负载下的性能不会下降。

  • 警报机制:一些平台具有内置的功能,可以提醒我们某些类型的日志事件。即使这不是内置的,我们也应该有通过APIsWebHooks的集成选项,这样我们就可以与我们的第三方应用程序集成,这是我们大部分时间所在的地方。

  • 安全性:安全性对我们日志信息非常重要,正如我们之前提到的。一个理想的平台将在数据静止和传输过程中加密数据。这通常是理所当然的,但我们需要确保。我们可能还需要能够控制用户访问。

  • 成本:我们都喜欢免费和便宜解决方案。我们不可能总是两者兼得,但我们确实可以确信,该平台提供的投资回报率是好的,相对于我们获得的功能而言。确保你进行适当的经济效益分析。

与日志聚合平台集成的最简单方法是使用针对此类集成优化的工具和包。我们需要利用那些针对与这些平台高效集成的库的服务。在下一节中,我们将看到如何利用SerilogSeq集成。

与 Seq 集成

Seq,发音为 seek,是由Datalust开发和维护的一个简洁(看看我做了什么?)日志聚合工具。这个平台是为了支持ASP.NET CoreSerilogNlog输出的日志消息模板而开发的。它也可以根据需要扩展以支持其他开发框架。

它为我们提供了一个功能强大的仪表板,具有领先的数据展示和查询功能。Seq可以免费安装在本地机器上进行个人开发,但如果我们打算在更企业化的环境中使用它,则会产生一些成本。它还提供托管解决方案,这消除了用户本地设置的需求。

对于这个活动,我们将在本地免费使用它在我们的机器上。我们现在有两个选择;我们可以使用一个 Docker 镜像并为应用程序启动一个容器,或者在我们的本地机器上安装它。它适用于 WindowsLinux 操作系统,因此我们将使用 Docker 选项来满足所有场景。

我们将首先使用以下命令下载 Docker 镜像:

docker pull datalust/seq

现在我们有了最新的 Seq 镜像,我们将使用以下命令创建我们的容器:

docker run -–name seq -d --restart unless-stopped -e
ACCEPT_EULA=Y -p 5341:80 datalust/seq:latest

现在,我们有一个容器,它托管了一个 Seq 实例,可以通过端口 5431 访问,这是 Seq 的默认端口。我们现在可以导航到 http://localhost:5341/#/events 来查看我们的聚合器用户界面。它将是空的,因此现在我们需要将我们的 API 与这个新的日志通道集成。

现在我们已经启动并运行了 Seq,我们可以修改我们的服务,使其开始向这个平台发送日志。我们已经有 Serilog 安装了,所以我们可以通过添加这个包将 Seq 源添加到我们的项目中:

Install-Package Serilog.Sinks.Seq

使用这个新包,我们可以修改 appsettings.json 中的 Serilog 部分,并在配置的 WriteTo 部分添加一个新的对象块。它现在看起来像这样:

"WriteTo": [
      {
        //  File Configuration
      },
      {
        "Name": "Seq",
        "Args": { "serverUrl": "http://localhost:5341" }
      }
    ]

我们已经在应用程序启动时读取了配置部分,所以下次应用程序启动时,所有默认日志都将按预期写入我们的本地文件,但现在也将写入 Seq 平台。

图 14.1 显示了 Seq 界面:

图 14.1 – Seq 接收来自微服务的日志的界面

图 14.1 – Seq 接收来自微服务的日志的界面

在这里,我们可以看到用户界面概述了应用程序启动时产生的默认日志。出现在此界面中的内容与我们添加的日志配置以及我们在进行过程中创建的日志条目相关。您还会注意到一些彩色点,这些点表示日志条目的日志级别。我们可以点击一行并展开它以查看日志消息的详细信息。

现在,这些代码修改可以应用于我们希望添加到日志聚合计划的所有服务,我们可以使用这个统一的平台按需查询日志。有了这个,我们需要了解分布式环境中的日志跟踪概念。我们将在下一节讨论这个问题。

分布式日志跟踪

分布式跟踪是监控微服务应用程序日志和跟踪问题的方法。开发人员和 DevOps 工程师都依赖日志来跟踪请求在穿越各种系统和检查点时的路径,然后尝试确定失败点。日志越健壮,他们就越容易定位应用程序中的弱点、错误和瓶颈。

由于微服务被设计成是自主的并且可以独立扩展,通常会有多个服务实例同时运行,这进一步复杂化了请求跟踪过程。我们现在需要回溯哪个服务实例处理了请求,导致更复杂的跟踪情况。

分布式跟踪是一种旨在解决这些问题的技术。它指的是在请求通过分布式系统时观察请求背后的诊断方法。每个跟踪显示了应用程序中单个用户的活动。在一个聚合的日志系统中,我们将得到一个突显对性能影响最大的后端服务和依赖关系的跟踪集合。在分布式跟踪中,我们有三个主要因素帮助我们找到方向:

  • 跟踪:表示用户活动的一个端到端请求。

  • 跨度:表示单个服务在特定时间段内完成的工作。跨度组合形成跟踪。

  • 标签:关于日志跨度元数据的信息,帮助我们正确分类和上下文化日志。

每个跨度是请求整个旅程中的一步,并编码了与操作中执行的过程相关的重要数据。这些信息可能包括以下内容:

  • 服务的名称和地址

  • 可以用于查询和过滤的标签,例如 HTTP 方法、数据库主机和会话 ID 等

  • 栈跟踪和详细错误消息

.NET 多年来已经发展到提供最高级的支持,通过集成 OpenTelemetry 来尽可能无缝地生成这些详细信息的日志。Microsoft Azure 还提供了在 Azure Application Insights 中的优秀分布式跟踪平台,这是我们之前提到过的平台。还有许多其他付费和开源解决方案可以支持我们的分布式跟踪需求。对于本章,我们将使用一个免费且简单的平台,称为 Jaeger。让我们探索如何将遥测增强添加到我们的服务中,并使用 Jaeger 进行可视化。

增强分布式跟踪的日志

OpenTelemetry 是一个流行的开源项目,它负责为分布式和云原生应用程序标准化日志标准。它帮助我们生成和收集包含跟踪和指标的详细日志,也称为遥测数据。鉴于它是一个开放标准,我们可以自由选择合适的可视化和分析工具。

要在我们的 ASP.NET Core 应用程序中安装 OpenTelemetry,我们需要在 dotnet cli 中执行以下命令:

dotnet add package --prerelease
    OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Exporter.Jaeger
dotnet add package --prerelease
    OpenTelemetry.Extensions.Hosting

在这三个包之间,我们正在安装 ASP.NET Core OpenTelemetry 支持,以及将我们的遥测数据导出到一个名为 Jaeger 的分布式跟踪分析平台的支持。Jaeger 是免费的,可以以 ZIP 格式下载或设置为 Docker 容器。您可以在此处了解更多信息:www.jaegertracing.io/download/

现在我们有了这些包,我们可以对我们的 Program.cs 文件进行以下调整:

builder.Services.AddOpenTelemetryTracing((builder) =>
    builder
        .AddAspNetCoreInstrumentation(o =>
        {
            o.EnrichWithHttpRequest = (activity,
                httpRequest) =>
            {
                activity.SetTag("requestProtocol",
                    httpRequest.Protocol);
            };
            o.EnrichWithHttpResponse = (activity,
                httpResponse) =>
            {
                activity.SetTag("responseLength",
                    httpResponse.ContentLength);
            };
            o.EnrichWithException = (activity,
                exception) =>
            {
                activity.SetTag("exceptionType",
                    exception.GetType().ToString());
            };
        })
        .AddJaegerExporter()
    );

使用此配置,我们在应用程序的启动时添加了 OpenTelemetry 支持,并概述了我们希望包含在每个发送到 Jaeger 的消息中的各种增强功能。请注意,OpenTelemetry 支持几个平台,您可以根据自己的需求选择最合适的平台。使用此配置,所有流量到我们的 API 端点都将记录我们指定的尽可能多的增强数据点。

图 14.2 展示了 Jaeger 界面:

图 14.2 – 已生成并存储在 Jaeger 聚合平台上的遥测数据

图 14.2 – 已生成并存储在 Jaeger 聚合平台上的遥测数据

Jaeger 足够简单,我们可以轻松开始使用,如图 图 14.2 所示,我们可以查看所有发送遥测数据的服务,根据我们需要查看的操作进行过滤,并按指定的时间线审查数据。这些都是分布式跟踪平台的一般功能,我们再次需要确保我们选择一个最适合我们需求的平台。

现在我们已经探讨了日志记录和分布式跟踪,让我们总结本章内容。

摘要

日志记录是一个简单的概念,在审查我们的应用程序时可以节省我们大量的时间和麻烦。写入日志的能力是 .NET Core 的组成部分,我们可以轻松利用原生功能来开始生成关于我们应用程序操作日志信息。

我们需要确保我们不会记录敏感信息,并且在编写日志时必须了解公司和安全政策。我们还需要确保我们的日志在传输和静止状态下都得到安全存储。我们还可以将日志记录到多个通道,但在选择这些通道时,我们应该小心,相对于我们的安全指南。

几个 .NET Core 框架增强了内置 API 的自然功能,并引入了更多的集成。一个流行的选择是 Serilog,它有许多称为“sink”的扩展,为我们提供了广泛的并发日志通道选项。有了它,我们可以轻松地创建和管理我们指定的滚动间隔上的日志文件。

理想情况下,我们将拥有多个服务来记录日志,而每个服务都将其日志记录到自己的文件中将会非常繁琐。这将迫使我们检查多个文件来追踪一个可能跨越我们分布式应用多个接触点的请求。因此,我们聘请了聚合器的服务,这将为我们提供中央区域来存放日志,并为我们和我们的团队提供一个集中区域,以便在筛选日志时进行关注。

然后,我们遇到了另一个问题,我们的日志需要包含某些细节,以便我们能够正确地将它们与一个请求关联起来。之后,我们研究了如何通过添加唯一标识符来丰富我们的日志,这些标识符有助于我们将它们与原始点和其他日志关联起来。这被称为分布式追踪。我们还审查了如何在我们的服务中包含OpenTelemetry以及使用可视化工具来协助查询活动。

现在我们已经完成了对分布式系统中日志记录活动和最佳实践的探索,在下一章中,我们将总结到目前为止我们所学到的内容。

第十五章:总结

到目前为止,我们已经发现了围绕微服务设计的一些模式和细微差别。现在,让我们从高层次上探索我们的模式,并将所有概念联系起来。确定哪种模式最适合每种情况是至关重要的。

微服务软件开发方法促进了自主过程的松散耦合和创建处理这些过程的独立软件组件。对这些过程进行范围的一个优秀方法是采用领域驱动设计DDD)模式。在 DDD 中,我们将系统的功能分类到称为领域的子部分,然后使用这些领域来管理需要支持每个领域的服务或独立应用程序。然后我们使用聚合器模式来尝试确定每个服务所需的领域对象。

聚合器模式

我们确定了每个领域所需的数据以及需要在领域之间共享的数据。在这个阶段,我们确实面临在领域之间重复数据点的风险。然而,鉴于需要促进服务及其相应数据库的自主性,这是一个我们接受的条件。

在确定数据需求时,我们使用聚合器模式,这使我们能够定义不同实体将具有的各种数据需求和关系。聚合体代表了一组可以被视为单一单位的领域对象。在这个范围练习中,我们试图找到这个集群的根元素,其他所有实体都被视为与根关联的领域对象。

在按服务确定我们的领域对象时的一般想法是捕捉每个服务操作所需的最小数据量。这意味着我们将尝试避免在多个服务中存储整个领域记录,而是允许我们的服务进行通信以检索可能具有领域特定性并驻留在另一个服务中的详细信息。这就是我们需要我们的服务进行通信的地方。

同步和异步通信

我们的微服务需要不时地进行通信。我们采用的通信类型基于我们最终需要完成的操作类型。同步通信意味着一个服务将直接调用另一个服务并等待响应。然后它将使用这个响应来通知它试图完成的进程。这种方法适用于一个服务可能有一些数据而需要从另一个服务获取其余数据的情况。例如,预约服务知道患者的 ID 号码但没有其他信息。然后它需要向患者服务进行同步 API 调用并获取患者的详细信息。然后它可以携带这些详细信息进行处理。

当我们需要从另一个服务获得即时反馈时,同步通信是非常好的。然而,当必须咨询多个其他服务时,它可能会引入问题并增加响应时间。我们还有每次 API 调用尝试失败的风险,一次失败可能会导致完全失败。我们需要优雅地处理部分或完全失败,并相对于业务流程的规则来处理。为了减轻这种风险,我们必须采用异步通信策略,将其传递给一个更稳定且始终在线的中介,该中介将根据需要将数据传输到其他服务。

异步通信更适合需要其他服务参与但不需要即时反馈的过程。例如,预约过程将需要完成涉及其他微服务和第三方服务的多个操作。例如,该过程将获取并保存预约信息,创建日历条目,并发送多个电子邮件和通知。然后,我们可以使用异步消息系统(如 RabbitMQ 或 Azure Service Bus)作为中介系统,该系统将接收来自微服务的信 息。需要参与的其他服务被配置为监控消息系统并处理任何出现的数据。然后,每个服务都可以在其自己的时间独立地完成其操作。预约服务也可以根据其需求确认成功,而无需担心是否已经完成了一切。

随着我们分离业务流程和范围,并确定哪些操作需要同步通信以及哪些操作需要异步通信,我们发现我们需要更好的方式来格式化我们的代码,并正确地分离应用程序代码的移动部分。这就是我们开始查看更复杂的设计模式,如命令和查询责任分离CQRS)的地方。

CQRS

CQRS 是一种流行的模式,它允许开发者更好地组织应用逻辑。它是最初称为命令查询分离CQS)的模式的改进,该模式旨在为开发者提供一种清晰的方式来分离增强数据库中数据(命令)的逻辑和检索数据的逻辑(查询)。

通过引入这种级别的分离,我们可以引入额外的抽象,并更容易地遵循我们的 SOLID 原则。在这里,我们引入了处理器的概念,它代表要执行的单个工作单元。这些处理器被实现为专门使用最少的资源和最少的依赖关系来完成操作。这使得代码更具可扩展性,并且更容易维护。

引入这种程度分离和抽象的一个缺点是文件和文件夹数量的显著增加。根据推荐方法完全实现基于 CQRS,我们可能还需要几个数据库来支持单个应用程序。这是因为用于查询操作的数据库需要优化,这通常意味着我们需要一个非规范化和高速查找的数据库结构。我们的命令操作可能使用不同的数据库,因为存储数据通常比读取数据有更严格的准则。

使用NuGet包,这些包帮助我们轻松实现此模式并减少整体开发开销。

最终,这种模式应该被用于具有更复杂业务逻辑需求的应用程序。鉴于其复杂性和项目膨胀,它不是推荐用于执行基本创建、读取、更新和删除CRUD)操作的标准应用程序的方法。它还引入了一个新问题:保持我们的读取和写入数据库同步。

让我们采用使用单独的数据库进行查询和命令操作的方法。我们面临的风险是在操作之间,读取操作可能获得过时的数据。解决数据库之间断开连接的最佳解决方案被称为事件溯源。

事件溯源模式

事件溯源模式弥合了需要同步的数据库之间的差距。它们帮助我们跟踪系统中的更改,并作为幕后传输或查找系统,确保我们始终拥有最佳的数据表示。

首先,一个事件代表一个时间点。事件中包含的数据将指示采取的操作类型和结果数据。这些信息可以在系统内用于多个目的:

  • 为需要用于其操作的结果数据的第三方服务完成任务

  • 使用最新副本更新查询操作的数据库

  • 作为版本控制机制添加到事件存储库

事件溯源可以在系统中扮演多个角色,并帮助我们完成多个常规和独特任务。在上下文中,常规任务可能包括更新我们的只读查询数据库,并作为在操作后基于最新数据执行的服务的事实来源。

较少的常规操作将依赖于实现事件存储库,另一个数据库用于跟踪每个事件及其数据副本。这充当了一个版本控制机制,使我们能够轻松地促进审计活动、时间点查找,甚至商业智能和分析操作。通过跟踪随时间推移的每个数据版本,我们可以看到记录的精确演变,并利用它来指导业务决策。

并不令人意外,这种模式与 CQRS 自然地结合在一起,因为我们可以从我们的处理程序中轻松且自然地触发事件。我们甚至可以使用事件存储作为我们的查询数据库查找位置,缓解与读取过时数据相关的紧张关系。然后我们可以扩展我们的查询能力,并利用我们现在可以访问的版本和特定时间点查找。

通过之前提到的允许我们实现 MediatR 模式的NuGet包,我们可以在操作结束时引发事件。我们还可以实现订阅特定事件的处理器,并在事件引发时执行其操作。这使得我们能够轻松扩展每个事件的订阅者数量,并单独且独特地实现每个事件执行的操作。

这些模式是按服务实现的,并且以任何方式都不会统一分散在多个独立应用程序中的代码。确保你选择的模式适用于微服务。在事件溯源和 CQRS 之间,我们已经将作用域数据库的数量从一个增加到可能三个。这可能会引入大量的基础设施需求和成本。

现在,让我们回顾一下我们应该如何处理微服务应用程序中的数据库需求。

每个服务一个数据库模式

微服务架构促进了服务的自主性和松散耦合。这种松散耦合的概念理想情况下应该在整个代码库和基础设施中得到实施。遗憾的是,由于成本原因,这只有在某些情况下才可能实现,尤其是在数据库层面。

数据库的许可、实施、托管和维护可能成本高昂。成本也根据数据库支持的服务需求以及所需存储类型而变化。拥有独立数据库的一个令人信服的理由是我们总是希望为每个微服务选择最佳的技术堆栈。每个都需要保持其独特性,数据库选择对于实现过程至关重要。

我们有不同的数据库类型,重要的是要欣赏它们之间的细微差别,并利用这些知识来为每个微服务可以存储的数据范围最佳数据库解决方案。让我们看看一些更受欢迎的选项。

关系型数据库

关系型数据库以表格格式存储数据,并采取严格的措施确保存储数据按照其标准达到最高可能的质量。它们最适合需要确保数据准确性的系统,可能需要为多个实体存储数据。它们通常依赖于一种称为 SQL 的语言来与数据交互,并通过规范化过程,将迫使我们在多个表中分散数据。这样,我们可以避免数据重复,并在其他表中建立对在一个表中找到的记录的引用。

缺点是严格的规则使得按需扩展变得困难,这导致与多个实体相关的数据读取时间变慢。

非关系型数据库

非关系数据库也被称为 NoSQL 数据库,因为它们与传统的关系数据库在结构上有所不同。它们在数据存储方面不太严格,允许更大的可伸缩性。它们最适合需要灵活数据存储选项的系统,考虑到快速变化的需求和功能。它们也常作为只读数据库使用,因为它们支持数据被精确地结构化以满足系统的需求。这些类型数据库最流行的实现包括文档数据库(如MongoDBAzure Cosmos DB)、键值数据库(如Redis)和图数据库(如Neo4j)。

每种类型都有其优点和缺点。文档数据库选项最常作为关系数据库的替代品使用,因为它提供了一种更灵活的方式来存储所有数据点,但将它们保存在一个地方。然而,如果管理不当,这可能导致数据重复和整体质量的下降。

在考虑为服务选择最佳数据库选项时,我们必须考虑可维护性、技术成熟度和易用性以及任务的一般适用性。一种解决方案肯定不能适用于所有情况,但我们还必须考虑成本和可行性。我们有几种方法来实现支持我们服务的数据库;每种方法都有其优缺点。

所有服务一个数据库

从成本分析和维护角度来看,这是理想的解决方案。数据库引擎功能强大,旨在在重负载下运行,因此多个服务使用相同的数据库并不难实现。团队也不需要多样化的技能集来维护数据库和与该技术合作。

然而,这种方法给所有服务带来了一个故障点。如果这个数据库离线,那么所有服务都会受到影响。我们还放弃了选择最佳数据库技术以支持每个服务最佳实现的技术堆栈的灵活性。虽然大多数技术堆栈都有支持大多数数据库的驱动程序,但事实仍然是,某些语言与某些数据库配合得最好。在选择这种方法时务必非常小心。

每个服务一个数据库

这种解决方案为我们每个服务的实现提供了最大的灵活性。在这里,我们可以使用最适合所使用的编程语言和框架以及微服务的数据存储需求的数据库技术。需要表格数据存储结构的服务可以依赖关系数据库。通过扩展,使用 PHP 技术开发的微服务可能会倾向于 MySQL 数据库,而使用 ASP.NET Core 可能会倾向于 Microsoft SQL Server。这将减轻支持数据库的磨损,因为一种语言可能缺乏足够的工具。另一方面,基于 NodeJS 的微服务可能会倾向于 MongoDB,因为其数据不需要那么结构化,并且可能比其他服务更快地发展。

这里明显的缺点是我们需要能够支持多种数据库技术,并且必须具备进行常规维护和保养活动的技能集。我们还因为许可和托管选项而承担额外的成本,因为数据库可能(理想情况下,将会)需要单独的服务器托管安排。

单独来看,每个服务都需要确保其数据尽可能准确和可靠。因此,我们使用一个称为事务的概念来确保数据要么成功更新,要么不更新。这对于数据可能分布在多个表中的关系数据库来说特别有用。通过强制执行这种全有或全无机制,我们减少了部分成功的情况,并确保数据在所有表中保持一致性。

总是选择最适合你正在构建的微服务的技术来解决问题或领域。这种灵活性是拥有松散耦合应用程序的更多公开宣传的好处之一,在这种应用程序中,不同的部分不需要共享资产或功能。

相反,使用支持独立服务的单独数据库可能会导致严重的数据质量问题。回想一下,某些操作需要几个服务的参与,有时,如果一个服务未能更新其数据存储,就真的没有方法来跟踪失败并采取纠正措施。每个服务将处理其事务,但涉及几个独立数据库的操作将面临部分完成的风险,这是不好的。这就是我们可以求助于悲剧模式来帮助我们管理这种风险的地方。

在服务之间使用悲剧模式

悲剧模式通常用于协助我们在微服务应用程序中实现全有或全无的概念。每个服务都会为自己做这件事,但我们需要机制来允许服务相互通知其成功或失败,并且,通过扩展,在必要时采取行动。

以我们有一个需要四个服务参与的操作为例,每个服务都会在过程中存储一些数据;我们需要一种方式让服务报告其数据库操作是否成功。如果不成功,我们触发回滚操作。我们可以通过编排或编排来实现我们的悲剧模式。

通过编排,我们实现了一个消息系统(如 RabbitMQ 或 Azure 服务总线),其中服务会相互通知其操作完成或失败。虽然没有中央控制消息流,但每个服务都被配置为在接收到某些消息时采取行动,并根据其内部操作的结果发布消息。这是一个很好的模型,我们希望保持每个服务的自主性,并且没有服务需要了解其他服务。

在理论上,编排看起来很简单,但在需要向叙事添加新服务时实现和扩展可能会很复杂。从长远来看,每次叙事需要修改时,都需要关注几个接触点。这些因素促进了编排方法作为一个可行的替代方案。

使用编排方法,我们可以建立一个中央观察者,该观察者将协调与叙事相关的活动。它将编排对每个服务的每个调用的调用,并根据服务的成功或失败响应决定下一步。在编排器中实现的叙事将按照特定顺序沿着成功轨迹和单独的失败轨迹跟踪特定的服务调用。如果在叙事的中间发生失败,编排器将开始调用之前已报告成功的每个服务的回滚操作。

比较而言,编排方法允许更好地控制和对叙事每个步骤发生情况的监督,但可能更难以在长期内实现和维护。随着叙事的发展,我们将有同样多的接触点需要维护。

您选择的方法应与您的系统需求以及您期望的操作行为相匹配。编排促进服务自治,但可能导致大型叙事的意大利面式实现,我们需要跟踪哪个服务消费了哪个消息。这也使得调试变得非常困难。编排方法迫使我们引入一个中心故障点,因为如果编排器失败,其他什么都不会发生。

然而,这两种方法都取决于服务及其依赖项的整体可用性,以完成操作。我们需要确保我们不将第一次失败视为最终响应,并实现逻辑,在放弃之前尝试操作多次。

弹性微服务

构建具有弹性的服务非常重要。这充当了对系统造成破坏并导致糟糕用户体验的暂时性失败的缓冲。没有基础设施是坚不可摧的。每个网络都有故障点,依赖于不完美网络的服务的本质也是不完美的。除了基础设施的不完美之外,我们还需要考虑一般的应用负载以及我们的请求现在可能是一个过多的行为。这并不意味着服务已离线;这只是意味着它压力很大。

并非所有故障原因都在我们的控制之下,但我们可以控制我们的服务如何响应。通过实现重试逻辑,我们可以强制同步调用另一个服务,直到成功调用为止。这有助于我们减少应用程序中的故障数量,并在我们的操作中提供更多积极和准确的结果。典型的重试逻辑包括我们进行初始调用并观察响应。当响应不是预期结果时,我们再次尝试调用。我们继续这样做,直到我们收到可以处理的响应。然而,这种非常简化的重试逻辑存在一些缺陷。

我们应该只重试一段时间,因为我们不确定服务是否正在经历中断。在这种情况下,我们需要实施一个策略,在尝试一定次数后停止重试调用。我们称这种策略为断路器策略。我们还希望考虑在重试尝试之间添加一些时间。

这么复杂的策略可以通过一个名为 Polly 的简单代码实现,这是一个 NuGet 包。这个包允许我们声明全局策略,这些策略可以用来管理我们的 HttpClient 服务如何进行 API 调用。我们还可以为每个 API 调用定义特定的策略。

重试在很大程度上有助于我们保持应用程序健康的外观。然而,预防胜于治疗,我们更喜欢在故障变得严重之前跟踪和缓解故障。为此,我们需要实施健康检查。

健康检查的重要性

正如其名所示,健康检查允许我们跟踪和报告服务的健康状况。每个服务都是应用程序中可能发生故障的点,每个服务都有可能影响其健康状况的依赖项。我们需要一个机制来探测我们服务的整体状态,以便更主动地解决问题。

ASP.NET Core 内置了一种报告服务健康状况的机制,它可以非常简单地告诉我们服务是否健康、退化或不健康。我们可以扩展这个功能,不仅报告服务的健康状况,还要考虑对数据库和缓存等依赖服务的连接健康状况。

我们还可以建立各种端点,用于检查不同的结果,例如一般运行时与启动时的健康状况。当我们想要根据我们使用的工具、现有的监控团队或一般的应用程序启动操作对监控操作进行分类时,这种分类非常有用。

我们可以建立存活性检查,这些检查可以定期进行以报告预期运行的应用程序的整体健康状况。只要出现不健康的结果,我们就会采取行动,这将是我们的日常维护和保养活动的一部分。然而,当分布式应用程序启动时,并且多个服务相互依赖时,我们希望在启动依赖于它的服务之前,准确确定哪个依赖服务是健康和可用的。这类检查被称为就绪性检查。

由于分布式应用中需要跟踪的服务复杂性和数量往往很大,我们尽可能地自动化我们的托管、部署和监控任务。容器化,我们将在稍后讨论,是一种以轻量级和稳定的方式托管我们的应用程序的标准方法,而编排工具如 Kubernetes 使我们能够轻松地对服务和容器进行健康检查,这将告诉我们基础设施的健康状况。最终,我们可以利用几个自动化工具来监控和报告我们的服务和依赖项。

我们花了一些时间探索围绕我们的微服务及其相互关系的细微差别。然而,我们还没有讨论围绕一个或多个需要与多个服务相关联的客户端应用程序的细微差别。

API 网关和前端后端

基于微服务架构的应用程序将有一个用户界面,该界面将与多个网络服务交互。回想一下,我们的服务已经被设计来统治一个业务领域,并且用户完成的许多操作跨越了多个领域。因此,客户端应用程序需要了解服务以及如何与它们交互以完成一个操作。通过扩展,我们可以在 Web 和移动应用程序中拥有多个客户端。

问题在于,我们需要在客户端应用程序中实现过多的逻辑来支持所有服务调用,这可能导致客户端应用程序变得冗长。随着我们引入的新客户端越来越多,维护工作也会变得更加痛苦。这里的解决方案是将我们的微服务的入口点进行整合。这被称为 API 网关,它将位于服务和客户端应用程序之间。

API 网关允许我们将所有服务集中在一个单一端点地址之后,这使得实现 API 逻辑变得更加容易。在请求发送到中央端点之后,它会被路由到适当的微服务,这些微服务存在于不同的端点。API 网关允许我们为应用中的所有端点地址创建一个中央注册表,并在需要时添加中间操作来处理请求和响应数据。为此操作提供便利的技术包括一个轻量级的 ASP.NET Core 应用程序,称为Ocelot。至于云选项,我们可以转向 Azure API 管理。

现在我们有了网关,我们遇到了另一个问题,即我们有多位客户端,每个客户端都有不同的 API 交互需求。例如,移动设备将需要与 Web 和智能设备客户端应用程序不同的缓存和安全权限。在这种情况下,我们可以实现后端为前端模式。这比听起来简单得多,但需要正确实施才能有效,并可能导致额外的托管和维护成本。

这种模式要求我们提供一个专门配置的网关,以满足目标客户端应用程序的需求。如果我们的医疗保健应用程序需要被 Web 和移动客户端访问,我们将实现两个网关。每个网关将公开一个相关的客户端将使用的特定 API 端点。

现在我们正在为各种客户端应用程序和设备提供服务,我们需要考虑便于任何客户端应用程序的安全选项。

携带者令牌安全性

安全性是应用开发中需要正确处理的基本部分之一。发布无法控制用户访问和权限的软件,从长远来看可能会产生不利影响,并允许我们的应用程序被利用。

使用 ASP.NET Core,我们可以访问一个名为 Identity Core 的身份验证库,它支持多种身份验证方法,并允许我们轻松地将身份验证集成到我们的应用程序和支持数据库中。它为我们在 Web 应用程序中实现的多种身份验证方法和授权规则提供了优化的实现,并允许我们轻松地保护应用程序的某些部分。

通常,我们使用身份验证来识别试图访问我们系统的用户。这通常需要用户输入用户名和密码。如果他们的信息可以验证,我们可以检查他们被授权执行的操作,然后使用他们的基本信息创建一个会话。所有这些操作都是为了简化用户体验,使用户能够根据需要自由地使用应用程序的不同部分,而无需在每一步重新进行身份验证。这个会话也被称为状态。

在 API 开发中,我们没有创建会话或维护状态的便利。因此,我们需要用户对受保护的 API 端点进行每次请求的身份验证。这意味着我们需要一种有效的方法来允许用户在每次请求中传递他们的信息,评估它,然后发送适当的响应。

携带者令牌是当前行业标准的支持这种无状态身份验证需求的方法。携带者令牌在初始身份验证尝试之后生成,此时用户分享他们的用户名和密码。一旦信息得到验证,我们检索关于用户的一些信息,这些信息我们称之为声明,并将它们组合成一个编码的字符串值,我们称之为令牌。然后,这个令牌作为 API 响应返回。

触发初始身份验证调用的应用程序需要存储此令牌以供将来使用。

现在用户已经颁发了一个令牌,任何后续的 API 调用都需要包含这个令牌。当 API 收到后续请求以保护端点时,它将检查 API 请求的头部部分是否存在令牌,然后尝试验证令牌以进行以下操作:

  • 受众:这是一个表示预期接收令牌的应用程序的值

  • 发行者:这表明了颁发给令牌的应用程序

  • 过期日期和时间:令牌有生命周期,因此我们确保令牌仍然可用

  • 用户声明:这些信息通常包括用户的角色以及他们被授权执行的操作

我们可以在每次带有令牌的请求到来时评估我们希望验证的所有点;验证规则越严格,就越难有人伪造或重复使用令牌在 API 上。

保护一个 API 足够简单,但当这种努力分散到多个 API 上时,例如在基于微服务的应用程序中,这会变得非常繁琐且难以管理。当用户在访问可能使用不同服务来完成任务的程序的不同部分时需要多次进行身份验证,这不是一个好的体验。我们需要一个中央权威机构来颁发和验证令牌,所有服务都可以利用它。本质上,我们需要能够使用一个令牌并在多个服务中验证用户。

面对这一新的挑战,我们需要使用 OAuth 提供商来集中保护我们的服务,并处理我们的用户信息和验证。OAuth 提供商应用程序的配置和启动可能需要一些时间,因此一些公司提供 OAuth 服务作为 SaaS 应用程序。设置和托管您的 OAuth 提供商实例有几种选择,但这将需要更多的维护和配置工作。自托管的好处是您对系统和您实施的安保措施有更多的控制。

Duende IdentityServer 是 OAuth 提供商更著名的自托管选项。它基于 ASP.NET Core,并利用 Identity Core 功能来提供行业标准的安全措施。对于小型组织是免费的,可以部署为一个简单的 Web 服务和我们的微服务的中央安全权威。他们也有托管模式,可以与其他托管选项进行比较,例如 Microsoft Azure AD 和 Auth0 等。

现在我们已经探讨了保护我们的微服务,我们需要找出最佳方式来托管它们以及它们的各种依赖项。我们是使用一组 Web 服务器,还是存在更高效的选项?

容器和微服务

我们通常可以在一个服务器上托管一个 Web 应用程序或 API 及其支持数据库。这样做是有道理的,因为所有东西都在一个地方,易于访问和维护。但这个服务器也需要非常强大,并配备多个应用程序和进程来支持应用程序的不同部分。

因此,我们应该考虑将托管考虑因素分开,并将 API 和数据库放置在不同的机器上。这会花费更多,但我们可以更好地维护或托管,并确保我们不会因为不需要的应用程序而给机器或环境带来负担。

当处理微服务时,在尝试为多个服务复制这些托管考虑因素时,我们会遇到一个具有挑战性的情况。我们希望每个微服务在功能和托管方面都是自治的。我们的服务应尽可能少地共享基础设施,因此我们不希望在一个机器上放置超过一个服务。我们也不希望让单个设备负担支持多个托管环境的要求,因为每个微服务可能都有不同的需求。

我们转向容器托管作为一种轻量级的替代方案,以配置多个机器。每个容器代表机器资源的一部分,具有为应用程序运行所需的优化存储和性能资源。将这个概念转化为我们的托管需求,我们可以为每个微服务、数据库和所需的第三方服务创建这些优化环境的切片。

这里的优势是,我们仍然可以为每个服务及其支持数据库创建最佳托管环境,所需机器数量远少于支持这项工作。另一个好处是,每个容器都基于一个镜像,代表容器环境的精确需求。这个镜像是可以重复使用的,因此我们在环境转换和尝试为每个服务配置环境时,要担心的事情更少。这个镜像将始终产生相同的容器,并且在部署过程中不会有惊喜。

容器在开发社区中得到广泛使用和支持。首选的容器托管选项是 Docker,这是一家行业领先的容器技术提供商。Docker 提供了一个广泛的容器镜像库,我们可以利用它来获取从流行的第三方应用程序中提取的安全和可维护的镜像,这些应用程序通常在开发过程中被使用。它也是一个开放的社区,因此我们可以为我们的需求创建容器,并将它们添加到社区存储库中,以便以后访问,无论是公共还是私人用途。

当使用 .NET 时,我们可以生成一个 Dockerfile,这是一个包含有关应使用以创建我们希望托管的服务容器的镜像声明的文件。这个 dockerfile 使用一种名为 Yet Another Markup Language (YAML) 的语言编写,概述了一个基础镜像,然后是特殊的构建和部署指令。基础镜像声明我们从现有镜像中借用信息,然后我们声明希望在将现有镜像和此应用程序结合后,将我们的应用程序部署到容器中。

当我们使用容器托管时,为每个服务生成一个 dockerfile,我们需要编排它们启动的顺序及其依赖关系。例如,我们可能不希望在支持数据库的容器启动之前启动服务。为此,我们必须使用一个编排器。行业领先的选项包括 docker-compose 和 Kubernetes。

docker-compose 是容器编排操作简单易懂的选项。docker-compose 将引用每个 dockerfile,并允许我们概述在执行此 dockerfile 时希望包含的任何独特参数。我们还可以概述依赖关系,并为该 dockerfile 的执行和生成的容器提供特定的配置值。现在,我们可以使用一条命令编排容器的供应,以支持我们的网络服务、数据库和其他应用程序。我们甚至可以重用 dockerfile 来创建多个容器,并在不同的端口上拥有具有相同服务但可能具有不同配置的多个容器。在实现前端模式的后端时,我们可以看到这在哪里非常有用。

容器托管是平台无关的 - 我们可以利用多种托管选项,包括云托管选项。主要云托管提供商,如微软 Azure 和亚马逊网络服务,提供容器托管和编排支持。

现在我们已经解决了托管问题,我们需要能够跟踪应用程序中发生的事情。每个服务都应该提供其活动的日志,更重要的是,我们需要能够跨各种服务追踪日志。

集中日志

日志是部署后和维护操作的重要组成部分。一旦我们的应用程序已经部署,我们需要能够跟踪和追踪应用程序中的错误和瓶颈。当我们只有一个应用程序和一个日志源时,这很容易实现。我们总是可以到一个地方检索已经发生的事情的日志。

.NET 对简单到高级的日志选项提供原生支持。我们可以利用原生的日志操作,并支持与多个日志目标强大的集成,如下所示:

  • 控制台:在原生控制台窗口中显示日志输出。通常在开发期间使用。

  • Windows 事件日志:也称为事件查看器,这是在基于 Windows 的机器上查看多个应用程序日志的便捷方式。

  • Azure 日志流:Azure 有一个中央日志服务,支持应用程序的日志记录。

  • Azure 应用洞察:由 Microsoft Azure 提供的强大日志聚合服务。

在编写日志时,我们需要决定我们要记录的信息类型。我们希望避免记录敏感信息,例如可能损害用户或系统信息的信息,因为我们希望尽可能保护我们系统的完整性和用户机密。这将与应用程序运行的环境相关。然而,在这个范围界定练习中,我们必须行使责任、智慧和成熟度。我们还希望考虑我们不想在日志中包含太多杂乱无章的内容。拥有冗长的日志可能和没有日志一样糟糕。

我们还希望确保为每条日志消息选择正确的分类。我们可以将日志消息记录为以下任何级别:

  • 信息:关于操作的通用信息。

  • 调试:通常用于开发目的。不应在实时环境中可见。

  • 警告:表示某事可能没有按预期进行,但不是系统错误。

  • 错误:当操作失败时发生。通常在捕获并/或处理异常时使用。

  • 严重/致命:用于突出显示操作失败并导致系统故障。

为日志消息选择正确的分类对于帮助操作团队监控和跟踪需要优先处理的消息大有裨益。

我们还可以为每个日志目标添加独特的配置,并微调每个目标将接收的消息类型。如果我们只想将信息性消息记录到 Windows 事件日志,并且所有警告、错误和严重消息都应该在 Azure 日志流和应用洞察中可见,这种能力就变得相关了。.NET Core 允许我们进行这些细粒度的调整。

我们可以通过使用扩展包,如 Serilog 来进一步扩展原生日志库的功能。Serilog 是在 .NET 应用程序中使用最广泛的日志扩展库。它支持更多的日志目标,例如滚动文本文件、数据库(SQL Server、MySQL、PostgreSQL 等)和云提供商(Microsoft Azure、Amazon Web Services 和 Google Cloud Platform)等。我们可以通过在应用程序中包含此扩展包,将每个日志消息写入多个目标。

个人应用程序的日志记录可以相对快速地设置起来,但当尝试关联日志时,这个概念会变得复杂。当用户在访问某个功能时遇到困难,我们需要检查几个可能的问题点,考虑到我们的微服务应用程序将在多个服务中触发多个操作。我们需要一种有效的方法来汇总每个服务产生的日志,并且通过扩展,能够追踪和关联与单个操作相关的调用。

现在,我们转向日志聚合平台。简单来说,它们充当日志目的地,旨在存储写入它们的所有日志。它们还提供了一个具有高级查询支持的用户界面。这对于分布式应用程序是必要的,因为我们现在可以将聚合器配置为多个应用程序的中心日志目的地,并且我们可以更容易地查询日志以找到可能相关但来自不同来源的日志。我们还可以配置它们在接收到特定分类的日志时进行监控和警报。

日志聚合的流行选项包括SeqElasticsearch、Logstash 和 KibanaELK)堆栈,以及托管选项,如Azure Application InsightsDataDog。每个平台都有其优势和劣势,并且可以用于从小型到大型应用程序。Seq 是小型到中型应用程序的流行选择,它具有易于使用的工具并支持强大的查询操作。然而,聚合器有一些限制,这些限制在我们需要正确跟踪来自多个来源的日志时会出现。

从多个来源跟踪日志被称为分布式日志。这涉及到我们在日志消息中使用常见信息,并跟踪相关的标签和跟踪 ID,以将日志关联到单个事件。这要求我们编写包含更多详细信息和大头信息的丰富日志,以便日志跟踪工具可以使用并提供关于最佳可能信息的。支持这一概念的新兴技术是OpenTelemetry,它将从我们的各种应用程序中生成更详细和关联的日志。

我们现在可以使用更专业的工具,如Jaeger,来筛选丰富的日志并在日志之间执行更复杂的查询。Jaeger 是一个免费、轻量级且开源的工具,可以帮助我们开始这个概念,但我们还可以再次使用 Microsoft Azure Insights 来处理生产工作负载。

摘要

在本章中,我们探讨了微服务的各个组成部分以及我们如何利用不同的开发模式来确保我们交付一个稳定且可扩展的解决方案。我们看到了微服务架构在引入每个解决方案时都存在问题,我们需要确保我们了解我们做出的每个决定的全部潜在问题。

最终,我们需要确保我们正确评估和界定我们应用程序的需求,并避免在不必要的情况下引入微服务架构。如果我们最终使用了微服务架构,我们必须确保我们充分利用支持我们应用程序的各种技术和技巧。在以高级架构的名义引入复杂性之前,始终寻求采取最必要的措施来解决一个问题。

我希望您喜欢这次旅程,并且有足够的信息来指导您在开始使用 ASP.NET 开发微服务时将涉及到的决策和开发过程。

posted @ 2025-10-23 15:08  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报