C--无服务器和微服务实践指南-全-
C# 无服务器和微服务实践指南(全)
原文:
zh.annas-archive.org/md5/a09eb06bbe4ec5925825074b7af78f09译者:飞龙
前言
当我们开始编写这本书时,我们的主要目标是提供关于开发云原生解决方案的主要方法:分布式应用的实战经验。我们决定描述从无服务器实现到 Kubernetes 编排的各种构建微服务架构的选项。
由于我们的主要技术背景是.NET 和 Azure,我们决定专注于这些领域,为开发者提供了解如何以及何时无服务器和微服务是快速且一致地创建企业解决方案的最佳方式的机会,从而使.NET 开发者能够通过进入现代云原生和分布式应用的世界来实现职业跃迁。通过这本书,您将完成以下内容:
-
学习如何在 Azure 中创建无服务器环境进行开发和调试
-
实现可靠的微服务通信和计算
-
使用 Kubernetes 等编排器优化微服务应用
-
深入探索 Azure Functions 及其触发器,包括用于物联网和后台活动的触发器
-
使用 Azure Container Apps 简化创建和管理容器
-
学习如何正确地保护微服务应用
-
严肃对待成本和使用限制,并正确计算它们
我们相信,通过阅读这本书,您将找到许多宝贵的技巧和实用的示例,这些将帮助您编写自己的应用程序。我们希望这份专注的材料能够帮助您充分利用关于这个重要软件开发主题的知识。
这本书面向的对象
这本书是为那些渴望向现代云开发和分布式应用迈进、并希望提升他们对微服务和无服务器知识以充分利用这些架构模型的工程师和高级软件开发人员所写的。
这本书涵盖的内容
第一章,揭秘无服务器应用,介绍了无服务器应用,讨论了它们的优缺点和基础理论。
第二章,揭秘微服务应用,介绍了微服务应用,讨论了它们的优缺点、基本原理、定义和设计技术。
第三章,设置和理论:Docker 和洋葱架构,介绍了实现现代分布式应用所需的前提技术,例如 Docker 和洋葱架构。
第四章,可用的 Azure Functions 和触发器,讨论了与 Azure Functions 相关的可能设置以及可用于创建无服务器应用的触发器。
第五章,实践中的后台函数,实现了 Azure Functions 触发器,这些触发器能够实现后台处理。详细介绍了定时器、Blob 和队列触发器,包括它们的优缺点和使用机会。
第六章, 实践中的 IoT 函数,讨论了 Azure Functions 在 IoT 解决方案中的重要性。
第七章, 实践中的微服务,详细描述了使用.NET 实现微服务的方法。
第八章, 使用 Kubernetes 的实用微服务组织,详细介绍了 Kubernetes 及其如何用于编排微服务应用程序。
第九章, 简化容器和 Kubernetes:Azure Container Apps 和其他工具,描述了简化 Kubernetes 使用的工具,并介绍了 Azure Container Apps 作为简化微服务编排的选项,讨论了其成本、优势和劣势。
第十章, 无服务器和微服务应用程序的安全性和可观察性,讨论了微服务场景中的安全性和可观察性,介绍了现代软件开发这两个重要方面的主要选项和技术。
第十一章, 拼车应用,展示了本书的示例应用,使用无服务器和微服务应用来理解事件驱动应用程序的工作方式。
第十二章, 使用.NET Aspire 简化微服务,将 Microsoft Aspire 描述为在微服务开发过程中进行测试的一个好选择。
要充分利用这本书
为了充分利用这本书,需要具备 C#/.NET 和 Microsoft 堆栈(Entity Framework 和 ASP.NET Core)的先验经验。
下载示例代码文件
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp。我们还有其他丰富的图书和视频的代码包,可在github.com/PacktPublishing找到。查看它们吧!
下载彩色图像
我们还提供了一个包含本书中使用的截图/图表的彩色 PDF 文件。您可以从这里下载:packt.link/gbp/9781836642015。
使用的约定
本书使用了几个文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 X/Twitter 用户名。例如:“执行docker build命令。”
代码块设置如下:
public class TownBasicInfoMessage
{
public Guid Id { get; set; }
public string? Name { get; set; }
public GeoLocalizationMessage? Location { get; set; }
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
FROM eclipse-temurin:11
COPY . /var/www/java
WORKDIR /var/www/java
RUN javac Hello.java
CMD ["java", "Hello"]
任何命令行输入或输出都按照以下方式编写:
docker run --name myfirstcontainer simpleexample
粗体:表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“从管理面板中选择系统信息。”
警告或重要提示如下所示。
技巧和窍门如下所示。
联系我们
我们始终欢迎读者的反馈。
一般反馈:通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果您对此书的任何方面有疑问,请通过questions@packtpub.com与我们联系。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在此书中发现错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com/。
分享您的想法
您已经完成《使用 C#的实用无服务器和微服务》一书,我们非常乐意听到您的想法!如果您从亚马逊购买此书,请点击此处直接转到此书的亚马逊评论页面并分享您的反馈或在该网站上留下评论。
您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。
下载此书的免费 PDF 副本
感谢您购买此书!
您喜欢在路上阅读,但无法携带您的印刷书籍到处走?
您的电子书购买是否与您选择的设备不兼容?
别担心,现在,每本 Packt 书籍都免费提供该书的 DRM 免费 PDF 版本,无需额外费用。
在任何地方、任何地方、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。
优惠不会就此停止,您还可以获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容
按照以下简单步骤获取好处:
- 扫描二维码或访问以下链接

packt.link/free-ebook/9781836642015
-
提交您的购买证明
-
就这样!我们将直接将您的免费 PDF 和其他优惠发送到您的电子邮件
第一章:揭秘无服务器应用
当谈到软件开发时,我们正生活在令人难以置信的时代。随着云平台的发展以及现代技术的兴起,成为一名开发者如今既是一种美好的生活方式,也是一种充满挑战的职业。有如此多的方式来交付应用程序,有如此多的创新技术去探索,我们可能会陷入一个恶性循环,即我们更多地关注技术而不是实际的解决方案。
本章旨在介绍无服务器架构,并探讨您如何使用这种方法来实现微服务应用程序。为了实现这一目标,它涵盖了无服务器背后的理论,并提供了对它如何成为微服务实现可行替代方案的理解。
本章还探讨了微软如何实现函数即服务(FaaS),使用 Azure Functions 作为构建微服务的一种选项。将介绍两种替代开发平台:Visual Studio Code 和 Visual Studio。
到本章结束时,您将了解 Azure Functions 中可用的不同触发器,并准备好创建您的第一个函数。
技术要求
本章需要 Visual Studio 2022 免费版 社区版 或 Visual Studio Code。在章节中,将介绍如何针对每个开发环境调试 Azure Functions 的详细信息。您还需要一个 Azure 账户来创建示例环境。您可以在github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp找到本章的示例代码。
什么是无服务器?
当有人要求您开发一个解决方案时,他们通常最关心的事情不是基础设施将如何工作。事实是,即使是对于开发者来说,关于基础设施最重要的东西就是它简单地工作得很好。
考虑到这一现实情况,拥有一个能够动态管理服务器分配和配置的云提供商,将底层基础设施留给提供商,可能是一个最佳场景。
这正是无服务器架构所承诺的:一个我们可以用来构建和运行应用程序和服务的模型,而无需自己管理底层基础设施!这种方法完全抽象化了服务器管理,使开发者能够专注于他们的代码。
第一个提出这一概念的是亚马逊,2014 年推出了 AWS Lambda。之后,微软和谷歌也提供了类似的服务,分别是 Microsoft Azure Functions 和 Google Cloud Functions。正如我们之前提到的,本书的重点将是 Azure Functions。
我们可以考虑使用无服务器计算的一些优点。你不必担心扩展的事实可以被认为是主要的一个。此外,云解决方案提供商维护环境的可靠性和安全性。除此之外,采用这种方法,你有按需付费的选项,因此你只为所使用的付费,这使可持续增长模式成为可能。
无服务器架构也可以被认为是一种加速软件开发的好方法,因为你只需关注实现该程序所需的代码。另一方面,你可能难以管理大量功能,因此这种组织结构需要妥善处理,以避免在创建具有许多功能的解决方案时出现问题。
自从引入无服务器以来,已经创建了各种类型的函数。这些函数作为触发器,用于启动处理。一旦函数被触发,执行就可以用不同的编程语言完成。
现在,让我们检查一下功能是否可以被认为是微服务。
无服务器是一种提供微服务的方式吗?
如果你查看微服务的定义,你会发现将应用程序作为松散耦合的组件提供,这些组件代表业务能力的实现。你可以用几个函数构建类似的东西,所以是的,无服务器是一种提供微服务的方式。
一些专家甚至认为无服务器架构是微服务架构的一种演变,因为无服务器架构的焦点是在一个安全的环境中提供可伸缩性,使得一组功能可以独立地进行开发、测试和部署,这为软件架构带来了很多灵活性。这正是微服务架构的主要哲学。
让我们以一个例子来想象,比如一个负责用户身份验证的微服务。你可能为注册、登录和重置密码创建特定的功能。考虑到这一组功能可以在单个无服务器项目中创建,你既有创建分离功能的灵活性,也有定义微服务目的的可能性。
无服务器项目将自然支持与数据库、消息队列、OpenAPI 规范和其他 API 的集成,从而实现通常需要的健壮微服务架构的设计模式。同样重要的是要提到,保持微服务隔离、小型且最好是可重用的是一个值得遵循的最佳实践。
现在你已经了解到你可以使用无服务器方法编写微服务,让我们了解微软 Azure 在其平台上如何呈现无服务器。
微软 Azure 如何呈现无服务器?
2016 年,Microsoft 推出了 Azure Functions 作为一种平台即服务(PaaS)产品,旨在提供 FaaS 功能。这一选项为企业转型提供了可扩展的创新。今天,Azure Functions 给我们提供了使用多种编程语言(包括 C#、JavaScript、F#、Java 和 Python)来增强应用程序的机会。
Azure Functions 的一个突出特点是它与其他 Azure 服务和第三方 API 的无缝集成。例如,它可以轻松连接到不同的 Azure 数据库(从 Azure SQL Server 到 Azure Cosmos DB),Azure Event Grid 用于事件驱动架构,以及 Azure Logic Apps 用于工作流自动化。这种连接性简化了构建复杂、企业级应用程序的过程,这些应用程序利用了多种服务。
多年来,Azure Functions 的可能性不断发展。今天,我们甚至可以使用 Azure Durable Functions 来管理有状态的流程和长时间运行的操作。有了这个,您可以编排可以在多个函数执行中执行复杂流程。
但 Microsoft 不仅为编写函数创建了一个环境。他们还创建了一个完整的开发人员管道,遵循现在在企业解决方案中广泛讨论和使用的 DevSecOps 流程。开发者可以使用 Azure Pipelines、GitHub Actions 和其他 CI/CD 服务来自动化部署过程。您还可以使用 Azure Monitor 和 Application Insights 监控和诊断这些函数中的事件,这些工具有助于故障排除和优化。
PaaS 解决方案还允许不同的设置调整可伸缩性和安全性方面。根据您选择的托管计划,您将拥有不同的扩展机会,您可以在此处查看:
-
消费计划:Azure Functions 的基本且最具成本效益的入门选项。适用于具有自动扩展的事件驱动工作负载。
-
弹性消费计划:提供快速、弹性扩展,同时支持私有网络(VNet 集成)。
-
专用计划(App Service 计划):适用于长时间运行的功能和需要更可预测的性能及资源分配的场景。
-
Azure 容器应用程序计划:适用于基于微服务架构,使用多种技术堆栈或需要更高灵活性的场景。
-
专用计划:为高性能场景设计,具有按需扩展的能力,提供对 VNet、更长的执行时间和预热实例等高级功能的支持。
总结来说,Microsoft Azure 通过 Azure Functions 提供无服务器 FaaS,提供了一个强大、灵活且可扩展的平台,增强了无服务器应用程序的开发和部署。通过使用 Azure Functions,开发者可以构建和维护响应迅速、成本效益高的解决方案。现在,让我们来探讨如何在 Azure 门户中创建一个 Azure 函数。
在 Azure 中创建您的第一个无服务器应用程序
在 Azure 中创建您的第一个无服务器应用没有多少步骤。当使用 Azure 门户时,您可以通过直接的过程完成它。按照以下步骤开始:
-
登录到 Azure 门户。为此,打开您的网络浏览器并导航到 Azure 门户
portal.azure.com/。使用您的 Azure 账户凭据登录。 -
在 Azure 门户中,点击位于左上角的创建资源按钮。

图 1.1:在 Azure 门户中创建资源
-
在搜索服务和市场窗口中,搜索功能应用,并从搜索结果中选择它。此服务也将显示在热门 Azure 服务部分。
-
点击创建按钮开始创建过程。

图 1.2:选择功能应用进行创建
一旦选择功能应用,您将被提示选择所需的托管计划。今天,我们使用 Azure Functions 有五种托管计划选项。这些计划根据扩展行为、冷启动、使用虚拟网络的可能性以及显然的价格而有所不同。消费计划正是无服务器所涉及的内容,您不知道代码在哪里以及如何运行,您只需为代码的执行付费。另一方面,当您选择应用服务或容器应用环境计划时,您将拥有更多对硬件和资源消耗的控制权,这意味着您可以在解决方案中使用 Azure Functions 的灵活性,以及大型应用程序所需的相应管理。
当您选择创建 Azure 功能应用时,将立即显示以下屏幕。正如我们之前所描述的,您需要根据您的需求选择托管计划。

图 1.3:功能应用托管计划
为了本章的目的,我们将选择消费计划。一旦选择此选项,您将找到一个向导来帮助您创建服务。在这个服务中,您需要填写以下信息:
-
基础:填写所需的字段,例如订阅、资源组、功能应用名称、区域和操作系统。确保您选择的名称是唯一的。在运行时堆栈中,选择您函数的编程语言。我们将选择.NET 8 Isolated工作模型,但还有其他选项,正如我们之前所展示的。值得注意的是,进程模型将在 2026 年被淘汰,因此不要使用这种方法开始项目。
-
存储:功能应用默认需要 Azure 存储账户。
-
网络:这是您定义 Azure 函数是否可供公共访问的地方。
-
监控:启用 Application Insights 以监控您的函数应用,以获得更好的诊断和性能跟踪。别忘了 Azure Monitor 日志会导致成本增加。
-
部署:还可以启动为函数应用所需的部署设置。这对于使用 GitHub Actions 作为默认方式实现持续部署很有趣。
-
标签:在专业环境中,将函数应用进行标记被认为是便于 FinOps 活动的良好实践。
在第二章“揭秘微服务应用”中,我们将讨论将微服务与外部世界接口的最佳方式。出于安全考虑,不建议您直接向公众提供函数。您可以选择使用应用程序网关,如 Azure 应用网关,或使用 Azure API Management 作为您使用 Azure Functions 开发的 API 的入口。
一旦您点击审查和创建,您将能够检查所有设置。审查您的配置,然后再次点击创建按钮以部署您的函数应用:

图 1.4:审查函数应用设置
部署完成后,通过点击转到资源按钮导航到您的新函数应用。您将看到函数应用正在正常运行:

图 1.5:函数应用运行
现在,是时候了解使用 Azure Functions 进行开发的可能性和开始编码了。
理解 Azure Functions 中可用的触发器
Azure Functions 的基本思想是每个函数都需要一个触发器来启动其执行。一旦触发器被触发,您的代码执行将很快开始。然而,执行开始所需的时间可能会根据所选的托管计划而变化。例如,在消费计划中,函数可能会遇到冷启动——即当平台需要初始化资源时发生的延迟。了解这一点也很重要,即函数可以同时触发多次,这允许并行执行。
Azure Functions 提供了各种触发器,允许开发者在响应不同事件时执行代码。这里我们有最常用的触发器:
-
HTTP 触发器:此触发器允许通过 HTTP 请求执行函数。这对于创建 API 和 webhook 非常有用,其中可以使用标准 HTTP 方法调用函数。
-
Timer 触发器:此触发器根据 NCRONTAB 模型安排运行函数。它非常适合需要定期执行的任务,例如清理操作、数据处理或发送定期报告。重要的是要提到,相同的定时触发器函数在其第一次执行完成后不会再次运行。这种行为有助于防止重叠执行和潜在冲突。
-
Blob Storage 触发器:当在 Azure Blob Storage 容器中创建或更新新的 blob 时,此触发器将运行函数。它适用于处理或转换文件,例如图像或日志,在它们上传时。
-
Queue Storage 触发器:当向 Azure Queue Storage 添加消息时,此触发器将运行函数。它适用于构建可伸缩且可靠的背景处理系统。
-
Event Grid 触发器:此触发器在 Azure Event Grid 发布事件时运行函数。它适用于对来自各种 Azure 服务的事件做出反应,例如资源创建、修改或删除。
-
Service Bus 触发器:当在 Azure Service Bus 队列或主题中接收到消息时,此触发器将运行函数。它非常适合处理应用程序间的消息传递和构建复杂的流程。
-
Cosmos DB 触发器:此触发器在 Azure Cosmos DB 中创建和更新时运行函数。它适用于实时处理数据更改,例如更新搜索索引或触发额外的数据处理。
这些触发器提供了灵活性和可伸缩性,允许开发者构建能够无缝响应不同类型事件的基于事件的应用程序。重要的是要说明,Azure Functions 中还有其他可用的触发器,我们将在下一章中更详细地讨论它们。
使用 Azure Functions 进行编码
本主题的重点是快速介绍一些开发 Azure 函数的方法。在本书的其他章节中,我们将介绍与汽车共享相关的用例。正如您将在 第二章 中详细看到的,揭秘微服务应用程序,每个微服务都必须有一个健康检查端点。让我们开发一个这样的健康检查 API 的示例。
使用 VS Code 编码 Azure 函数
使用 VS Code 创建 HTTP 触发器 Azure 函数涉及几个定义明确的步骤。以下是一个详细的指南,帮助您完成这个过程。
使用 VS Code 开发 Azure 函数有一些先决条件,如下所述:
-
确保您的机器上已安装 VS Code。使用 VS Code 不仅可以帮助您开发所需的 Azure 函数,还可以通过 Azure Tools 扩展来管理您的 Azure 账户。
-
建议您登录到您的 Azure 账户以创建新的函数。C# 开发工具包也可能已安装。
-
可以安装 GitHub Copilot 扩展 来帮助您解决编码问题,同时在进行编码时为您提供指导。
-
安装 VS Code 的 Azure Functions 扩展。这个 VS Code 扩展将方便函数的开发,为每个期望的函数触发器提供向导。
-
安装 VS Code 的 Azurite 扩展。这个 VS Code 扩展是一个开源的与 Azure Storage API 兼容的服务器,用于本地调试 Azure Functions。
-
确保你已经安装了 Azure Functions Core Tools 和 .NET SDK,如果你使用的是 C#。
一旦你设置了你的环境,你将会有以下类似的结构:

图 1.6:VS Code 准备编写 Azure 函数
-
一旦所有先决条件都设置好了,在 Azure 选项卡中,转到 WORKSPACE 并选择 创建函数项目…。接下来,执行以下步骤:
-
选择你的项目位置并选择你首选的编程语言。
-
按提示创建一个新的 HTTP 触发函数。你可以将其命名为
Health并调用命名空间CarShare.Function.。 -
你需要决定这个函数的 访问权限。在这个例子中,你可以选择 匿名。我们稍后会讨论每个安全选项。
-
打开新创建的函数文件。你会看到一个 HTTP 触发函数的模板代码。
-
修改函数以满足你的特定要求,在这种情况下,意味着如果函数运行正常则进行响应。注意这是一个
GET和POST函数。根据我们的定义,你可以将代码更改为仅作为 HTTPGET函数。 -
保存你的更改。
-
对于本地运行和调试,你只需按 F5 或导航到 运行 > 开始调试。VS Code 将启动 Azure Functions 主机,你将在输出窗口中看到函数 URL。然后,你可以使用 Postman 或你的浏览器等工具向你的函数端点发送 HTTP 请求。
值得注意的是,为了在本地运行 Azure Functions,你需要允许 PowerShell 脚本在没有数字签名的情况下运行。这可能会根据你公司提供的安全策略成为一个问题。
一旦函数开始运行,你可以将其视为与其他类型的软件项目工作相同,甚至调试也能正常工作。触发器将取决于你设置的函数。以下图显示了函数程序的代码,你可以看到使用 OkObjectResult 和消息“是的!函数正在运行!”以及 UTC 时间对调用者的响应。

图 1.7:本地运行的 Azure Functions
由于你已经创建了一个与 GitHub 仓库连接的函数应用,并且部署过程由 GitHub Actions 处理,一旦你将代码提交并拉取到 GitHub,GitHub Actions 将自动构建函数并将其作为函数应用部署。

图 1.8:使用 GitHub Actions 部署的函数应用
本书的目的不是讨论 CI/CD 策略,但在专业开发时,你肯定需要考虑它们。
可以在 Azure 门户中检查此部署的结果,其中开发的函数将在函数列表中可用。值得注意的是,一个函数应用可以同时处理多个函数。

图 1.9:函数应用中可用的健康功能
函数一旦发布到 Azure,就可以立即执行。由于这个示例函数是一个 GET HTTP 触发器开发的,我们可以通过在网页浏览器中访问 API 来检查函数是否工作。

图 1.10:健康功能运行正常
由于你没有实时的 CI/CD 管道,你也可以直接从 VS Code IDE 发布你的 Azure 函数。为此,你可以使用 VS Code 提供的 Azure Functions 扩展。
在此情况下,需要遵循几个步骤。第一步是在 VS Code 提示中选中部署函数的操作:

图 1.11:使用 VS Code 部署到 Azure
之后,你需要选择相应的订阅以及你想要部署的新函数应用的名称,考虑到一个新的函数:

图 1.12:创建新的函数应用
扩展程序提出的当前流程是在灵活消耗计划中部署 Azure 函数。有一些特定的位置可以提供此选项:

图 1.13:定义新函数应用的位置
运行时堆栈的定义对于充分利用你的 Azure 函数也很重要。在灵活消耗计划的情况下,你还将被要求输入实例的内存使用量和可用于并行调用的最大实例数。

图 1.14:定义新函数应用的运行时堆栈
一旦定义了这些集合,你的 Azure 函数将正确部署。你还可以使用相同的技术重新部署函数,而不需要每次都重新创建 Azure 函数应用。

图 1.15:函数应用正确部署
最后但同样重要的是,Azure 门户还为您提供了监控和管理已部署函数的可能性。一旦完成此过程,您就可以监控您函数的性能和日志。通过使用您函数应用的 监控 部分,您可以查看执行细节,跟踪失败,并分析性能指标。
使用 Visual Studio 编码 Azure 函数
Visual Studio 是开发 Azure 函数的最佳选择之一。为了做到这一点,您必须设置 Azure 开发工作负载,这将有助于在平台上原生启用 Azure 函数开发。
一旦完成此操作,您使用 VS Code 创建的项目将在 Visual Studio 中可用。在这种情况下,VS Code 和 Visual Studio 的区别在于,Visual Studio 将提供一个更易于设置的环境用于调试,以及许多可以促进您决策的视觉对话框。

图 1.16:为 Function app 创建新的 Azure 函数
这些对话框简化了开发过程,因此如果您有机会使用 Visual Studio,这将是最优选择。

图 1.17:定义 Azure 函数触发类型
再次强调,当您创建一个 Function Apps 项目时,您可以向该项目添加多个函数,这对于微服务解决方案来说非常有用。在下面的示例中,我们添加了一个名为 Status 的第二个 HTTP 触发函数,以帮助您理解这种可能性,并让您看到这些函数如何在单个 Function App 中协同工作。

图 1.18:具有多个函数的 Function app
重要的是要提到,最初使用 VS Code 开发的相同代码可以继续使用 Visual Studio 进行维护,反之亦然。这很好,因为您可以让同一团队中的不同开发者使用这两个环境,而这不会引起问题,至少在 Function Apps 项目中不会。
由于其全面的调试设置环境和集成的视觉对话框,Visual Studio 是开发 Azure 函数的绝佳选择。开发者可以在 VS Code 和 Visual Studio 之间切换,而不会出现兼容性问题,从而促进团队协作。HTTP 触发等多个函数可以位于单个 Function Apps 项目中,支持微服务解决方案。
摘要
本章探讨了云平台的演变和现代技术的兴起,强调了关注解决方案而不是仅仅关注技术的重要性。本章强调了无服务器计算的优势,如可扩展性、可靠性、安全性和成本效益,同时也讨论了潜在的挑战。它讨论了无服务器架构如何交付微服务以及使用 Microsoft Azure Functions 构建和部署无服务器应用程序的好处。本章还提供了使用 VS Code 和 Visual Studio 等工具创建和管理 Azure 函数的实用指南。
在下一章中,我们将讨论如何在企业场景中定义和设计微服务应用程序。
问题
- 本章中提到使用无服务器计算的主要优势是什么?
无服务器计算提供了几个优势,包括自动扩展、按使用付费的模型带来的成本效益以及减少基础设施管理。开发者无需担心提供或维护服务器,这使他们能够更快、更有效地交付解决方案。
它还通过让开发者专注于代码来促进软件开发加速。此外,云服务提供商管理环境的可靠性和安全性,从而在不牺牲性能或安全性的情况下提供可扩展和可持续的解决方案。
- 如何使用无服务器架构来交付微服务?
无服务器架构通过允许开发者创建独立、小型且可重用的函数来支持微服务模型,这些函数代表不同的业务能力。这些函数可以独立部署、测试和扩展,遵循微服务的核心原则。
本章提供了一个用户身份验证微服务的例子,其中注册、登录和密码重置等单独的功能在一个无服务器项目中实现。这种灵活性增强了使用微服务原则构建的应用程序的模块化和可维护性。
- Azure Functions 中可用的关键触发器及其用途是什么?
Azure Functions 可以通过各种事件触发。主要触发器包括 HTTP 触发器(用于 Web 请求)、定时触发器(计划任务)、Blob 存储触发器(文件上传或更改)、队列存储触发器(消息处理)、事件网格触发器(从 Azure 服务处理事件)、服务总线触发器(应用程序之间的消息传递)和 Cosmos DB 触发器(数据库更改处理)。
每个触发器都允许开发者以灵活性和可扩展性构建事件驱动应用程序。例如,定时触发器非常适合重复性任务,而 HTTP 触发器通常用于 API 和 webhooks。这种触发器的多样性支持开发多样化的响应式解决方案。
- 在 Azure 门户中创建无服务器应用需要哪些步骤?
要在 Azure 中创建无服务器应用,开发者必须登录到 Azure 门户并创建一个新的函数应用资源。在设置过程中,他们需要选择托管计划(例如,消费计划),定义项目详情,如区域、运行时堆栈、存储账户和网络选项,并通过应用程序洞察启用监控。
在审查完配置后,开发者点击创建来部署函数应用。一旦部署完成,他们可以直接从门户或通过开发工具如 Visual Studio 或 VS Code 导航到资源,开始编码并直接管理它。
- Azure 函数如何与其他 Azure 服务和第三方 API 集成?
Azure 函数与各种 Azure 服务无缝集成,如 Azure SQL、Cosmos DB、Event Grid、Service Bus 和 Logic Apps。这使得开发者能够利用现有的 Azure 基础设施构建复杂的工作流、自动化任务并创建高度响应的应用程序。
此外,Azure 函数还可以连接到第三方 API 和服务,支持混合架构。这种集成能力允许开发者扩展其应用程序到多个平台,增强云原生解决方案的灵活性和可扩展性。
进一步阅读
-
Azure API 管理文档:
learn.microsoft.com/en-us/azure/api-management/ -
Azure 应用程序网关文档:
learn.microsoft.com/en-us/azure/application-gateway/overview
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第二章:揭秘微服务应用
在过去十年中,微服务架构在现代软件开发中占据了核心地位。在本章中,我们将定义微服务架构是什么。你将了解微服务成功的原因,它们的优缺点,以及何时值得采用它们。从导致它们产生的难题开始,我们将讨论典型的使用场景,采用它们对整体项目成本的影响,以及你可能期望的回报。
你将深入了解微服务的组织结构,发现它如何与通常的单体应用不同,更像是装配线而不是由用户请求驱动的处理。这种新构思的组织带来了新的挑战,需要特定的技术来确保一致性、协调性和可靠性。
此外,还创建了新的模式和最佳实践来应对微服务带来的挑战并优化其优势。在这里,我们将介绍并总结一些基本模式,而它们的实际实施以及更具体的模式将在本书的剩余部分详细阐述。
更具体地说,本章涵盖了以下内容:
-
服务导向架构(SOAs)和微服务的兴起
-
微服务架构的定义和组织
-
何时采用微服务架构是值得的?
-
微服务的常见模式
服务导向架构(SOAs)和微服务的兴起
简单来说,微服务是在计算机网络中部署的软件块,它们通过网络协议进行通信。然而,这还不是全部;它们还必须遵守一系列进一步的约束。
在给出微服务架构的更详细定义之前,我们必须了解微服务理念是如何演变的,以及它被用来解决什么类型的问题。我们将通过两个独立的子节来描述这一演变的两个主要步骤。
SOA 的兴起
微服务方向的第一步是由所谓的服务导向架构(SOAs)所采取的,即基于通信进程网络的架构。最初,SOAs 被实现为类似于你在 ASP.NET Core 中可能已经体验过的 Web 服务。
在 SOA 中,实现软件应用中不同功能或角色的不同宏模块被暴露为相互通信的独立进程,它们通过标准协议进行通信。第一个 SOA 实现是通过基于 XML 的 SOAP 协议进行通信的 Web 服务。然后,大多数 Web 服务架构转向基于 JSON 的 Web API,你可能已经了解,因为 RESTful Web 服务可以作为标准 ASP.NET 项目模板使用。进一步阅读部分包含了一些有用的链接,提供了更多关于 RESTful Web 服务的详细信息。
SOA 是在商业应用程序软件开发热潮期间被构想出来的,作为将不同分支和部门使用的各种现有应用程序集成到一个单一公司信息系统中的多种方法之一。由于现有应用程序是用不同的技术实现的,而且各个分支和部门可用的软件专业知识是异质的,因此 SOA 是以下迫切需求的答案:
-
允许使用不同技术实现的模块之间进行软件通信,并且运行在不同的平台上(Linux + Apache、Linux + NGINX 或 Windows + IIS)。实际上,基于不同技术的软件不是二进制兼容的,但只要每个都作为通过技术无关的标准协议与其他软件通信的 Web 服务实现,它们仍然可以相互合作。其中,值得提及的是基于文本的 HTTP REST 协议和二进制的 gRPC 协议。同样值得提及的是,HTTP REST 协议是一个实际的标准,而 gRPC 目前只是一个由谷歌提出的实际标准。进一步阅读部分包含了一些有用的链接,可以获取更多关于这些协议的详细信息。
-
允许每个宏模块的版本独立于其他模块进行演进。例如,你可能会决定将某些网络服务迁移到新的.NET 9 版本,以利用新的.NET 功能或新的可用库,同时保留其他不需要修改的网络服务,例如.NET 8。
-
推广为其他应用程序提供服务的公共网络服务。例如,想想谷歌提供的各种公共服务,如谷歌地图,或者微软提供的各种人工智能服务,如语言翻译服务。
-
下面是一个总结经典 SOA 的图表。

图 2.1:SOA
- 随着公司信息系统和其他复杂 SOA 应用的不断发展,占领了更多市场和用户,因此出现了新的需求和约束。我们将在下一小节中讨论它们。
向微服务架构迈进
随着应用用户和流量的增加达到不同的数量级,性能优化以及在各个软件模块之间最佳平衡硬件资源成为了一个必须。这导致了一个新的需求:
每个软件模块必须能够独立于其他模块进行扩展,这样我们才能为每个模块分配其所需的最佳资源量。
随着公司信息系统在组织中扮演核心角色,其持续运行,即几乎零停机时间,成为了一个必须,这导致另一个重要约束:
微服务架构必须是冗余的。每个软件模块必须在不同的硬件节点上运行多个副本,以抵抗软件崩溃和硬件故障。
此外,为了适应快速变化的市场,对开发时间的要求变得更加紧迫。因此,需要更多的开发者来开发和维护每个应用程序,并按照给定的严格里程碑进行。
不幸的是,处理涉及四个人以上软件项目的质量要求证明是实质上不可能的。因此,SOA 中增加了一个新的约束:
构成应用程序的服务必须完全独立于彼此,以便它们可以由松散交互的独立团队实现。
然而,维护工作也需要优化,从而产生了另一个重要的约束:
服务的修改不应传播到其他服务。因此,每个服务都必须有一个定义良好的接口,该接口不会随着软件维护(或者至少很少改变)而改变。出于同样的原因,在服务实现中采用的设计选择不应限制任何其他应用程序服务。
通过将每个软件模块作为独立的服务实现,我们可以通过简单地将其复制到 N 个不同实例来分配更多硬件资源,从而满足第一和第二个要求,这样我们就可以优化整体性能并确保冗余。
-
我们还需要一个新的参与者,它决定使用每个服务的副本数量以及将它们放置在什么硬件上。存在类似的实体,称为编排器。值得注意的是,我们可能也有几个编排器,每个编排器负责一组服务,或者根本没有任何编排器!
-
总结来说,我们从由粗粒度耦合的 Web 服务组成的应用程序转变为细粒度和松散耦合的微服务,每个微服务由不同的开发团队实现,如下所示。

图 2.2:微服务架构
-
图表显示了分配给不同松散耦合团队的不同粒度微服务。值得注意的是,虽然松散耦合也是原始 Web 服务架构的初始目标之一,但需要时间才能提升到良好水平,直到微服务技术的出现使其达到顶峰。
-
前面的图表和需求并没有精确地定义微服务是什么;它们只是解释了微服务时代的开始。在下一节中,我们将给出一个更正式的微服务定义,该定义反映了它们当前的进化阶段。
微服务架构的定义和组织
在本节中,我们将给出微服务的定义,并详细说明它们对组织立即产生的影响,区分微服务定义,它预计会随着时间的推移逐渐变化,以及微服务实际组织,它可能随着新技术的出现而更快地发展。
在第一小节中,我们将关注定义及其直接后果。
微服务架构的定义
让我们先列出所有微服务需求。然后,我们将分别讨论每个需求。
微服务架构是基于 SOA 的架构,满足以下所有约束:
-
模块边界是根据它们所需的专业领域定义的。正如我们将在以下小节中讨论的,这应该确保它们是松散耦合的。
-
每个模块都实现为一个可复制的服务,称为微服务,其中可复制的意思是可以创建每个服务的多个实例,以实现可扩展性和冗余。
-
每个服务可以由不同的团队实现和维护,其中所有团队都是松散耦合的。
-
每个服务都有一个所有参与开发项目的团队都了解的明确接口。
-
通信协议在项目开始时决定,并且所有团队都知道。
-
每个服务必须仅依赖于其他服务公开的接口和采用的通信协议。特别是,为某个服务采用的设计选择不能对其他服务的实现施加约束。
鼓励您将上述约束与上一节中讨论的导致微服务架构构思的需求进行比较。实际上,这些约束中的每一个都是前一个或多个需求直接的结果。
让我们详细讨论每个约束。
专业领域和微服务
这个约束的目的是提供一个实际规则来定义每个微服务的边界,以便微服务保持松散耦合,并且可以被松散耦合的团队处理。它基于由 Eric Evans(参见领域驱动设计:www.amazon.com/exec/obidos/ASIN/0321125215/domainlanguag-20)开发的领域驱动设计理论。在这里,我们将简要介绍这个理论的一些基本概念,但如果你有兴趣了解更多,请参阅进一步阅读部分以获取更多详细信息。
基本上,每个专业领域都使用一种典型的语言。因此,在分析过程中,只需检测你与之交谈的专家使用的语言的变化,就可以理解每个微服务包含的内容和排除的内容。
这种技术的合理性在于,紧密互动的人们总是发展出一种被同一领域专家共享的特定语言,而缺乏这种共同语言则是松散互动的信号。
这样,应用域或应用子域被分割成所谓的边界上下文,每个上下文都由使用通用语言的特点所表征。值得注意的是,域、子域和边界上下文都是 DDD 的核心概念。有关它们和 DDD 的更多详细信息,您可以参考进一步阅读部分,但我们的简单描述应该足以开始使用微服务。
因此,我们得到了应用的第一种划分,即边界上下文。每个上下文都分配给一个团队,并为每个上下文定义一个正式的接口。这个接口成为微服务的规范,也是其他团队必须了解的关于微服务的所有信息。
然后,每个被分配微服务的团队可以将它进一步分割成更小的微服务,以便独立于其他微服务对其进行扩展,同时检查每个结果微服务与其他微服务交换的消息量是否可接受(松散耦合)。
第一次划分用于在团队之间分配工作,而第二次划分旨在以各种方式优化性能,我们将在微服务组织子节中详细说明。
可复制的微服务
应该有一种方法来创建同一微服务的多个实例,并将它们放置在可用的硬件上,以便将更多的硬件资源分配给最关键的微服务。对于某些应用程序或单个微服务,这可以手动完成;但更常见的是,采用称为编排器的专用软件工具。在这本书中,我们将描述两个编排器:Kubernetes,在第八章,使用 Kubernetes 进行实用微服务组织,以及Azure Container Apps,在第九章,简化容器和 Kubernetes:Azure Container Apps 和其他工具。
在不同团队之间分割微服务开发
在领域专业知识和微服务子节中已经解释了微服务的定义方式,以便它们可以被分配给不同松散耦合的团队。在这里,值得指出的是,在这个阶段定义的微服务被称为逻辑微服务,然后每个团队可以决定根据各种实际原因将每个逻辑微服务分割成一个或多个物理微服务。
微服务、接口和通信协议
一旦微服务被分配给不同的团队,就是时候定义它们的接口和用于每种消息的通信协议了。这些信息在所有团队之间共享,以便每个团队都知道如何与其他团队处理的微服务进行通信。
只需在所有逻辑微服务的接口和相关的通信协议之间共享,而每个逻辑微服务如何划分为物理微服务的划分只是在每个团队内部共享。
各个团队的协调以及所有服务的文档和监控是通过各种工具实现的。以下是主要使用的工具:
-
上下文映射是表示所有应用程序上下文团队之间组织关系的图形表示。
-
服务目录收集有关所有微服务需求、团队、成本和其他属性的信息。像Datadog(
docs.datadoghq.com/service_catalog/)和Backstage(backstage.io/docs/features/software-catalog/)这样的工具执行各种类型的监控,而像Postman(www.postman.com/)和Swagger(swagger.io/)这样的工具主要关注正式要求,例如测试和自动生成与服务交互的客户端。
只有逻辑微服务的接口是公开的
每个微服务的代码不能对其他所有逻辑微服务的公共接口的实现方式做出任何假设。关于所使用的科技(.NET、Python、Java 等)及其版本,以及其他微服务使用的算法和数据架构,都不能做出任何假设。
分析了微服务架构的定义及其直接后果后,我们可以转向目前最实用的组织方式。
微服务组织
微服务设计选择独立性的第一个后果是,每个微服务都必须拥有私有存储,因为共享数据库会导致使用该数据库的微服务之间产生依赖。假设微服务 A 和 B 都访问同一个数据库表 T。现在,我们正在修改微服务 A 以满足新用户的需求。作为这次更新的部分,A 的解决方案将需要我们用两个新表 T1 和 T2 来替换表 T。
在类似的情况下,我们也必须修改 B 的代码以适应用 T1 和 T2 替换 T。显然,同样的限制不适用于同一微服务的不同实例,因此它们可以共享同一个数据库。为了总结,我们可以陈述以下:
不同微服务的实例不能共享一个公共数据库。
不幸的是,远离单一应用程序数据库不可避免地会导致数据重复和协调挑战。更具体地说,相同的数据块必须在几个微服务中重复,因此当它发生变化时,必须将变化通知所有使用其复制副本的微服务。
因此,我们可以提出另一个组织约束:
微服务必须以最小化数据重复的方式设计,或者换句话说,重复应尽可能涉及最少的微服务。
如前所述,如果我们根据专业领域定义微服务,最后一个约束应该自动得到保证,因为不同的专业领域通常共享的数据很少。
没有其他约束直接从微服务的定义中产生,但只需要在响应时间上添加一个微不足道的性能约束,就可以迫使微服务的组织方式更接近于装配线而不是通常的用户请求驱动的软件。让我们看看原因。
一个用户请求到达微服务 A 可能会引发一系列对其他微服务的请求,如下面的图所示:

图 2.3:同步请求-响应链
消息 1-6 是由对微服务 A 的请求触发的,并且按顺序发送,因此它们的处理时间总和等于响应时间。此外,微服务 A 在发送消息 1 后保持阻塞,等待响应,直到它收到最后一条消息 (6);也就是说,它在整个链式通信过程的整个生命周期内保持阻塞。
微服务 B 两次被阻塞,等待它发出的请求的响应。第一次是在 2-3 通信期间,第二次是在 4-5 通信期间。总的来说,一个简单的请求-响应模式对微服务通信意味着高响应时间和微服务计算时间的浪费。
克服上述问题的唯一方法要么是避免微服务之间的完全依赖,要么是将满足任何用户请求所需的所有信息缓存到第一个微服务,A 中。由于达到完全独立基本上是不可能的,通常的解决方案是在 A 中缓存它需要回答请求而不需要进一步了解其他微服务的任何数据。
为了实现这个目标,微服务是主动的,并采用所谓的 异步数据共享 方法。每当它们更新数据时,它们会将更新的信息发送给所有需要这些信息的其他微服务。简单来说,在上面的例子中,树节点,而不是等待来自父节点的请求,每次它们的私有数据发生变化时,都会将预处理的发送给所有可能的调用者,如图下面的图所示。

图 2.4:数据驱动通信
标记为1的两种通信都是在C/D微服务的数据发生变化时触发的,并且它们可能并行发生。此外,一旦通信被发送,每个微服务就可以返回其工作,而无需等待响应。最后,当请求到达微服务A时,它已经拥有了构建响应所需的所有数据,无需与其他微服务交互。一般来说,基于异步数据共享的微服务会在数据变化后立即预处理数据并将其发送给可能需要它的其他服务。这样,每个微服务已经包含了可以用来立即响应用户请求的预计算数据,无需进行进一步针对特定请求的通信。
这次,我们无法谈论请求和响应,而只能简单地说成是交换的消息。与古典 Web 应用打交道的人会习惯于请求/响应通信,其中客户端发起请求,服务器处理该请求并发送响应。
通常,在请求/响应通信中,涉及的参与者之一,比如A,会发送一个包含请求的消息,要求对另一个参与者,比如B,执行一些特定的处理,然后B执行所需的处理并返回一个结果(响应),这也可以是一个错误通知。
然而,我们可能也有非请求/响应的通信。在这种情况下,我们只需说成是消息。在这种情况下,没有响应,只有确认消息已被最终目标或中间参与者正确接收。与响应不同,确认是在完成消息处理之前发送的。
返回到异步数据共享,当新数据可用时,每个微服务完成其工作后,将结果发送给所有感兴趣的微服务,然后继续执行其工作,而无需等待接收者的响应。
每个发送者只需等待其直接接收者的确认,因此等待时间不会像在最初的链式请求/响应示例中那样累加。
那么,消息确认呢?它们也会引起小的延迟。是否有可能也消除这种较小的低效?当然,借助异步通信可以做到!
在同步通信中,发送者在继续其处理之前等待消息确认。这样,如果确认超时或被错误通知取代,发送者可以执行纠正操作,例如重新发送消息。
在异步通信中,发送者不会等待确认或错误通知,而是在消息发送后立即继续其处理,同时确认或错误通知被发送到回调。
在微服务中,异步通信更有效,因为它完全避免了等待时间。然而,在可能出错的情况下执行纠正措施的需要使得整体的消息发送动作变得复杂。更具体地说,所有发送的消息都必须添加到队列中,每次收到确认时,消息被标记为正确发送并从队列中移除。否则,如果在可配置的timeout时间内没有收到确认,或者发生错误,则根据某些重试策略将消息标记为需要重发。
微服务异步数据共享方法通常伴随着所谓的命令查询责任分离(CQRS)模式。根据 CQRS,微服务被分为更新微服务,执行常规的 CRUD 操作,以及查询微服务,专门回答从多个其他微服务聚合数据的查询,如下图所示:

图 2.5:更新和查询微服务
根据异步数据共享方法,每个更新微服务将其所有修改发送到需要它们的查询服务,而查询微服务预先计算所有查询以确保快速响应时间。值得注意的是,数据驱动更新类似于一个工厂装配线,构建所有可能的查询结果。
更新和查询微服务都被称为前端微服务,因为它们参与了与用户的常规请求-响应模式。然而,它们路径中的数据更新也可能遇到完全不与用户交互的微服务。它们被称为工作微服务。以下图显示了工作微服务和前端微服务之间的关系。

图 2.6:前端和工作微服务
虽然前端微服务通常通过为每个请求创建一个线程来并行响应多个用户请求,但工作微服务仅涉及数据更新,因此它们不需要并行化请求以确保对用户的低响应时间。
因此,它们的操作与组成装配线的各个站点的操作完全类似。它们从输入队列中提取输入消息,并依次处理它们。一旦可用,输出数据就会发送到所有感兴趣的微服务。这种处理方式被称为数据驱动。
有些人可能会反对,认为工作微服务是不必要的,因为他们的工作可能已经被消费他们输出的前端服务处理了。这并不是事实!例如,让我们想象一下需要在一个时间段内合并的会计数据,然后才能用作复杂查询的字段。当然,每个需要合并数据的查询微服务都可能负责合并它。然而,这会导致处理努力和存储部分总和的重复。
此外,将合并处理嵌入到其他微服务中,将使其能够独立扩展,从而更好地优化整体性能。
下一个子节将展示一个示例,该示例展示了迄今为止学到的所有概念。
汽车共享示例
下图显示了汽车共享应用路线处理部分的通信图。虚线包围属于同一逻辑微服务的所有物理微服务。查询微服务位于图像的顶部,更新微服务位于底部,工作微服务位于中间(带有灰色阴影)。

图 2.7:汽车共享应用的路线处理子系统
语言分析检测到两个逻辑微服务。第一个说的是汽车共享者的语言,由六个物理微服务组成。第二个专注于拓扑,因为它在源点和目的地之间找到最佳路线,并将中间源-目的地对与现有路线相匹配。
汽车持有者通过在Car-Holding-Requests更新微服务上执行 CRUD 操作来处理他们的请求,而寻找汽车的用户则以类似的方式与Car-Seeking-Requests更新微服务进行交互。Routes-Listing微服务列出所有有空位的新乘客可用行程,以帮助汽车寻求者选择他们的旅行日期。一旦选择了日期,请求将通过Car-Seeking-Requests微服务提交。
汽车持有者和汽车寻求者都与Route-Choosing更新微服务进行交互。汽车寻求者会从几个可用的路线中选择源点和目的地的路线,而汽车持有者则通过选择适合其源点和目的地的路线来接受汽车寻求者。一旦汽车寻求者选择了一条路线并被汽车持有者接受,所有其他不兼容的选项都将从汽车持有者和汽车寻求者的最佳匹配中删除。
My-Best-Matches 微服务列出了汽车寻求者和车主的所有可用路线。Routes-Planner 工作微服务计算适合车主出发地和目的地的最佳路线,这些路线也包含了一些汽车寻求者的出发地和目的地。它存储未匹配的汽车寻求者请求,直到添加了一个距离它们可接受的路线。当这种情况发生时,Routes-Planner 微服务为相同的行程创建了一条新的替代路线,包含新的出发地-目的地对。所有路线的变化都会发送到My-Best-Matches和Route-Choosing微服务。
Locations-Listing 微服务处理已知位置的数据库,并用于各种用户建议,例如用户来源和目的地的自动完成以及基于用户偏好统计的建议有趣行程。它从所有汽车车主和汽车寻求者的请求中获取输入。
我们已经看到了微服务旨在解决的问题以及它们的采用如何增加了应用程序设计的复杂性。此外,不难想象,测试和维护运行在多个不同机器上并依赖于复杂数据驱动通信模式的应用程序应该是一项复杂且耗时的任务。
因此,评估在我们应用程序中使用微服务架构的影响非常重要,以验证成本是否可承受,以及采用微服务的优势是否超过其劣势和额外成本。在下一节中,我们将介绍一些评估此类评估的标准。
何时采用微服务架构才是值得的?
需要超过五个开发者的应用程序无疑是微服务架构的良好目标,因为逻辑微服务有助于将劳动力分成小型、松散耦合的团队。
具有多个耗时模块的高流量应用程序也是微服务架构的良好目标,因为它需要模块级性能优化。
对于只需要不到五人的小团队实现的应用程序,流量较低,不是微服务架构的良好目标。
在上述两种极端情况之间决定何时采用微服务并不容易。一般来说,这需要详细分析成本和回报。
考虑到成本,采用微服务架构的开发工作量大约是传统单体应用的五倍。我们通过将单体应用转换为微服务架构,平均进行了 7 次重写,得到了这个规模。
这部分原因是处理可靠通信、协调和详细资源管理所需的额外工作量。然而,大部分成本来自测试、调试和监控分布式应用程序的困难。
在本书的后面部分,我们将描述处理上述所有问题的工具和方法,但微服务带来的额外成本仍然存在。
考虑到预期回报,最显著的优势是能够将维护集中在关键模块上,因为如果微服务的接口没有变化,那么其实现中的更剧烈的变化,例如迁移到不同的操作系统,或迁移到不同的开发堆栈,或者简单地迁移到同一堆栈的新版本,也不需要对所有其他微服务进行任何更改。
我们可能会决定将不需要进行多次市场审查更改的模块的维护减少到最低限度,同时专注于仅增加应用程序感知价值或需要更改以适应快速发展的市场的市场审查模块。总之,我们可能会专注于用户所需的重要更改,而将所有不涉及这些更改的模块保持不变。
专注于仅几个模块可以确保快速上市时间,因此我们可以尽快满足市场机会,而不会存在发布新版本过晚的风险。
当某些特定功能的流量增加时,我们也可以通过仅扩展相关的微服务来快速调整性能。值得注意的是,调整每个特定构建块的能力允许更好地利用可用硬件,从而降低整体硬件成本。此外,调整和监控特定微服务的能力简化了实现更好的响应时间和总体性能目标。
在分析了导致微服务架构演变的演变过程,以及其本质和基本组织之后,我们可以继续探讨虽然不是特定于微服务,但在微服务架构中常见的模式。
微服务常见模式
在本节中,我们将分析所有微服务架构中使用的根本模式,这些模式与特定的编程语言或工具无关。其中大部分与微服务通信有关。让我们从常见的重试策略开始。
弹性任务执行
微服务可以从一台机器移动到另一台机器,以实现更好的负载均衡。它们也可以被重新启动,以重置一些可能的内存泄漏或解决其他性能问题。在这些操作过程中,它们可能会错过发送给它们的某些消息,或者可能会中断某些正在进行的计算。此外,由于软件错误或硬件故障也可能发生。
由于微服务架构需要具有可靠性(几乎零停机时间),它们通常是冗余的,并且需要特别注意检测故障和采取纠正措施。因此,所有微服务架构都必须提供机制来检测失败,例如简单的超时,以及纠正失败的操作。
失败是通过检测意外异常或超时来发现的。由于代码总是可以安排成将超时转换为异常,因此失败检测总能被简化为适当的异常处理。
为了解决这个问题,微服务开发者的社区定义了一些有用的重试策略,可以将它们附加到特定的异常上。它们通常通过特定的库以及其他的可靠性模式来实现,但有时云提供商会直接提供这些功能。
下面是微服务架构中使用的标准可靠性模式:
-
指数重试:它被设计用来克服暂时性故障,例如由于微服务实例重启导致的失败。在每次失败后,操作会以指数级增加的延迟重新尝试,直到达到最大尝试次数。例如,首先,我们会在 10 毫秒后重试,如果这次重试操作导致新的失败,那么会在 20 毫秒后进行新的尝试,然后是 40 毫秒,以此类推。如果达到最大尝试次数,则会抛出异常,此时可以找到另一个重试策略或某种其他异常处理策略。
-
断路器:它被设计用来处理长期故障,通常在指数重试达到最大重试次数后触发。当假设存在长期故障时,通过立即抛出异常而不尝试所有必要的操作来禁止对资源的访问。禁止时间必须足够长,以便允许人工干预或任何其他类型的手动修复。
-
隔离舱隔离:隔离舱隔离被设计用来防止故障和拥塞的传播。基本思想是将服务和/或资源组织成隔离的部分,使得来自某个部分的故障或拥塞仅限于该部分,而系统的其余部分继续正常工作。
假设,例如,几个微服务副本使用相同的数据库(这是常见的)。由于一个故障,一个副本可能会开始打开过多的数据库连接,从而也会使需要访问相同数据库的所有其他副本发生拥塞。
在这种情况下,我们认识到数据库连接是关键资源,需要隔离舱隔离。因此,我们计算数据库可以正确处理的连接的最大数量,并将它们分配给所有副本,例如,为每个微服务副本分配最多五个并发连接。
这样,副本的故障不会影响其他副本对数据库的正确访问。此外,如果应用程序组织得当,由于失败的副本而未能得到服务的请求最终将在正常工作的副本上重试,从而使整个应用程序能够继续正常运行。一般来说,如果我们想对共享资源的所有请求进行分区,我们可以按以下步骤进行:
-
允许对共享资源的最大类似待处理同时出站请求数量;比如说 5,就像之前的数据库示例中那样。这就像对线程创建设置一个上限。
-
超过之前上限的请求将被排队。
-
如果达到最大队列长度,任何进一步的请求将引发异常以终止它们。
值得指出的是,之前展示的请求分区和节流模式是应用隔离舱隔离的常见方式,但并非唯一方式。任何分区加隔离策略都可以归类为隔离舱隔离。例如,可以将两个交互式微服务的副本分成两个隔离分区,这样只有属于同一分区的副本才能交互。这样,分区中的故障不会影响另一个分区。
除了上面提到的处理故障的动作和策略,微服务架构还提供了故障预防策略。通过监控硬件资源的异常消耗和定期进行硬件和软件健康检查来实现故障预防。为此,编排器监控内存和 CPU 资源的使用情况,并在它们超出开发者定义的范围时重启微服务实例或添加新的实例。此外,它们还提供了声明周期性软件检查的可能性,编排器可以执行这些检查以验证微服务是否正常运行。最常见的此类健康检查是调用微服务公开的健康 REST 端点。再次强调,如果微服务未能通过健康检查,它将被重启。
当一个硬件节点未能通过健康检查时,其所有微服务将被转移到不同的硬件节点。
高效处理异步通信
与相关的异步确认一起的异步通信导致三个重要问题:
-
由于在通信后,发送微服务会转向处理其他请求而无需等待确认,因此它必须保留所有已发送消息的副本,直到检测到确认或通信故障(如超时),以便它可以重试操作(例如,使用指数重试),或者它可以采取其他类型的纠正措施。
-
由于在超时的情况下,消息可能会被重新发送,因此预期的接收者可能会收到相同消息的多个副本。
-
消息可以以与发送时不同的顺序到达接收者。例如,如果两个指示接收者修改产品名称的消息按顺序 M1, M2 发送,我们期望最终名称是 M2 中包含的名称。然而,如果接收者以错误的顺序接收这两条消息,M2, M1,最终的产品名称将是 M1 中包含的名称,从而造成错误。
第一个问题通过将所有消息保存在队列中解决,如图所示:

图 2.8:输出消息队列
当收到确认时,相关的消息将从队列中移除。相反,如果检测到失败或超时,消息将被添加到队列末尾以重新尝试。如果必须使用指数重试来处理重试,则每个队列条目都必须包含当前尝试的次数和消息可以重新发送的最小时间。
第二个和第三个问题要求每个接收到的消息都有一个唯一的标识符和序列号。唯一的标识符有助于识别和丢弃重复项,而序列号有助于接收者重建正确的消息顺序。以下图示展示了一种可能的实现。

图 2.9:输入消息队列
只有在它们前面的所有序列空缺都被填补并读取之后,才能从输入队列中读取消息,而重复的消息则容易识别并丢弃。
基于事件的通信
假设我们在图 2.7 中的拼车应用中添加一个新的微服务,比如一个计算用户行程统计的工人微服务。我们将被迫修改所有需要从它那里获取输入的微服务,因为这些微服务也必须向新添加的微服务发送一些消息。
微服务架构的主要约束是,对微服务的修改不能传播到其他微服务,但我们通过简单地添加一个新的微服务,已经违反了这个基本原则。
为了克服这个问题,可能会引起新添加的微服务兴趣的消息,使用发布-订阅模式进行处理。也就是说,发送者将消息发送到发布者端点,而不是直接发送给最终接收者。然后,每个对这条消息感兴趣的微服务只需订阅这个端点,这样订阅端点就会自动发送它接收到的所有消息。以下图示展示了发布-订阅模式的工作原理。

图 2.10:发布-订阅模式
一旦发布端点收到一条消息,它就会将其重新发送到所有添加到其订阅队列的订阅者。这样,如果我们添加一个新的微服务,就不需要对所有消息发送者进行任何修改,因为他们只需要继续将他们的消息发送到适当的发布端点。新添加的微服务需要将自己注册到正确的发布端点。
发布端点由称为消息代理的应用程序处理,这些代理提供此服务以及其他消息传递服务。消息代理本身可以作为可复制的微服务部署,但它们通常由所有主要云提供商作为标准服务提供。
其中,值得提及的是RabbitMQ,它必须作为微服务安装,以及Azure Service Bus,它在 Azure 中作为云服务提供。我们将在本书的其余部分详细介绍它们,但感兴趣的读者可以在进一步阅读部分找到更多详细信息。
与外部世界的接口
微服务应用程序通常局限于私有网络,并通过网关、负载均衡器和 Web 服务器通过公共或私有 IP 地址公开其服务。这些组件可以将外部地址路由到内部微服务。然而,很难让用户客户端应用程序选择将每个请求发送到哪个微服务。
通常,输入请求都由一个独特的端点处理,称为API 网关,它分析这些请求并将请求转换为适合内部微服务的请求。这样,用户客户端应用程序不需要了解微服务应用程序的内部组织方式。因此,在维护期间,我们可以自由地更改应用程序的组织,而不会影响使用它的客户端,因为所需的转换是由应用程序 API 网关执行的。这个过程被称为Web API 接口转换。
下图总结了 API 网关的操作:

图 2.11:API 网关
API 网关还可以通过将所有请求发送到属于客户端应用程序所需版本的微服务来处理应用程序版本。
此外,它们通常还处理身份验证令牌;也就是说,它们有解码它们的密钥,并验证它们包含的所有用户信息,例如用户 ID 和其访问权限。
请不要将身份验证与登录混淆。登录是在用户开始与应用程序交互时每次会话中执行一次,并且由一个专门的微服务执行。成功登录的结果是一个身份验证令牌,它编码了有关用户的信息,并且必须在所有后续请求中包含。
总结来说,API 网关提供以下服务:
-
Web API 接口转换
-
版本
-
身份验证
然而,它们通常还提供其他服务,例如:
-
API 文档端点,即提供应用提供的服务正式描述以及如何请求它们的端点。在 REST 通信的情况下,API 文档基于 OpenAPI 标准(参见 进一步阅读)。
-
缓存,即向用户客户端和 Web 中间节点添加适当的 HTTP 头来处理所有响应的缓存。
值得指出的是,上述服务只是商业或开源 API 网关中可用的服务的一般示例,这些网关通常提供广泛的服务。
API 网关可以作为使用 YARP(microsoft.github.io/reverse-proxy/index.html)等库的临时微服务实现,或者它们可以使用现有的可配置应用程序,例如开源的 Ocelot(github.com/ThreeMammals/Ocelot)。所有主要提供商都提供强大的可配置 API 网关,称为 API 管理系统(对于 Azure,请参阅 azure.microsoft.com/en-us/products/api-management)。然而,也存在独立的云原生提供者,如 Kong(docs.konghq.com/gateway/latest/)。
摘要
在本章中,我们介绍了微服务的基础知识,从它们的演变开始,继续到它们的定义、组织以及主要模式。
我们描述了基于微服务应用的主要特性和要求,如何其组织结构更类似于装配线而非用户请求驱动的应用,如何使微服务可靠,以及如何有效地处理故障和由高效异步通信引起的所有问题。
最后,我们描述了如何通过基于发布者-订阅者的通信使所有微服务彼此更加独立,以及如何将微服务应用与外部世界接口。
下一章描述了构建企业级微服务的两个重要构建块:Docker 和洋葱架构。
问题
- 持有式 SOA 和现代微服务架构之间的主要区别是什么?
在微服务架构中,架构是细粒度的。此外,每个微服务不得依赖于其他微服务的架构选择。此外,微服务必须是冗余的、可复制的和有弹性的。
- 为什么松散耦合的团队如此重要?
因为协调松散耦合的团队相当容易。
- 为什么每个逻辑微服务必须有专用的存储?
这是微服务的设计选择与其他所有微服务采用的设计选择独立性的直接后果。事实上,共享一个公共数据库将迫使数据库结构采用共同的设计选择。
- 为什么需要数据驱动的通信?
这是避免造成不可接受的总体响应时间的长链递归请求和响应的唯一方法。
- 为什么事件驱动的通信如此重要?
因为事件驱动的通信完全解耦了微服务,这样开发者就可以在不修改任何现有微服务的情况下添加新的微服务。
- API 网关通常会提供登录服务吗?
由称为身份验证服务器的特定微服务提供的登录服务。
- 什么是指数重试?
一种在每次失败后指数级增加失败和重试之间延迟的重试策略。
进一步阅读
-
埃里克·埃文斯,《领域驱动设计》:
www.amazon.com/exec/obidos/ASIN/0321125215/domainlanguag-20 -
更多关于领域驱动设计的资源可以在这里找到:
www.domainlanguage.com/ddd/ -
可以在这里找到关于 CQRS 设计原则的详细讨论:
udidahan.com/2009/12/09/clarified-cqrs/ -
ASP.NET Core REST API:
docs.microsoft.com/en-US/aspnet/core/web-api/ -
Datadog:
docs.datadoghq.com/service_catalog/ -
OpenAPI(REST API 规范):
swagger.io/docs/specification/v3_0/about/ -
Postman:
www.postman.com/ -
gRPC:
grpc.io/ -
RabbitMQ:
www.rabbitmq.com/ -
Azure API 管理:
azure.microsoft.com/en-us/products/api-management
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:
packt.link/PSMCSharp

第三章:设置和理论:Docker 和洋葱架构
本章讨论了现代微服务架构的两个重要构建块,这些构建块将在本书的大部分示例中使用,如下所示:
-
Docker 容器:Docker 容器是一种虚拟化工具,它使你的微服务能够在广泛的硬件平台上运行,防止兼容性问题。
-
洋葱架构:洋葱架构将用户界面(UI)和部署平台的依赖关系限制在驱动程序中,这样编码整个业务知识的软件模块就完全独立于所选 UI、工具和运行时环境。此外,为了优化领域专家和开发者之间的交互,所有领域实体都以下列方式实现为类:
-
每个实体仅通过表示所有实际领域实体行为的函数与代码的其余部分交互。
-
实体和实体成员的名称来自应用程序领域的词汇表。目的是在开发者和用户之间建立一个称为通用语言的共同语言。
-
虽然 Docker 容器与微服务性能优化大致相关,但洋葱架构并不特定于微服务。然而,这里描述的洋葱架构是专门为与微服务一起使用而设计的,因为它广泛使用了我们在第二章**,揭秘微服务应用程序中描述的一些特定于微服务的模式,例如发布-订阅事件,以最大限度地提高软件模块的独立性,并确保更新和查询软件模块之间的分离。
在本章中,我们将介绍一个基于洋葱架构的 Visual Studio 解决方案模板,以及我们将在这本书的剩余部分中使用的代码片段,用于实现任何类型的微服务。我们将讨论其背后的理论及其优点。
更具体地说,本章涵盖了以下内容:
-
洋葱架构
-
基于洋葱架构的解决方案模板
-
容器和 Docker
到本章结束时,你应该能够创建一个基于洋葱架构的应用程序,并使用 Docker 容器,这些容器是复杂微服务应用程序的构建块。
技术要求
本章需要以下内容:
-
至少需要 Visual Studio 2022 的免费社区版。
-
Docker Desktop for Windows (
www.docker.com/products/docker-desktop) -
Docker Desktop,反过来,需要Windows Subsystem for Linux (WSL),可以通过以下步骤安装:
-
在 Windows 10/11 的搜索栏中输入
powershell。 -
当 Windows PowerShell 作为搜索结果出现时,点击以管理员身份运行。
-
在出现的 Windows PowerShell 管理控制台中,运行
wsl --install命令。
-
您可以在github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp找到本章的示例代码。
洋葱架构
洋葱架构在领域特定代码和处理 UI、存储交互和硬件资源的技术代码之间做出了明确的区分。这使领域特定代码完全独立于技术工具,例如操作系统、网络技术、数据库和数据库交互工具。
整个应用程序组织成层,最外层只有一个目的,即提供所有必要的基础设施(即驱动程序)、UI 和测试套件,如图所示:

图 3.1:基本洋葱架构
相反,应用特定代码组织成几个更嵌套的层。所有层都必须满足以下约束:
每一层可能只能引用内部层。这种约束的实现方式取决于底层语言和堆栈。例如,层可以实施为包、命名空间或库。我们还将使用 .NET 库项目来实现层,这些项目可以轻松地转换为 NuGet 包。
因此,例如,在先前的图中,最外层可能引用所有应用特定库,以及实现所有所需驱动程序的库。
应用特定代码通过接口引用最外层驱动程序中实现的功能,而最外层的主要功能是提供一个依赖注入引擎,将每个接口与其实现的驱动程序耦合:
...
builder.Services.AddScoped<IMyFunctionalityInterface1, MyFunctionalityImplementation1>();
builder.Services.AddScoped<IMyFunctionalityInterface2, MyFunctionalityImplementation2>();
...
应用特定层反过来又由至少两个主要层组成:一个包含所有领域实体定义的层,称为领域层,以及一个包含所有应用操作定义的层,称为应用服务层,如图所示:

图 3.2:完整的洋葱架构
如果需要,应用服务层可以进一步分为更多子层,并且可以在应用服务和领域层之间放置更多层,但这很少发生。
领域层通常分为两个子层:模型层,其中包含实际的领域实体定义,以及领域服务层,其中包含更进一步的业务规则。
在本书的整个过程中,我们将仅使用应用服务和领域层。我们将在单独的小节中讨论每个层。
领域层
领域层包含每个领域实体的类表示,其行为编码在这些类的公共方法中。
此外,域实体可以通过表示实际域操作的方法进行修改。因此,例如,我们无法直接访问和修改采购订单的所有字段;我们仅限于通过表示实际域操作的方法来操作它,例如添加或删除项目、应用折扣或修改交货日期。
所有公共方法和属性的名称必须使用域专家实际使用的语言构建,即之前提到的通用语言。
所有上述约束的目的是优化开发人员与专家之间的通信。这样,域专家和开发人员可以讨论实体的公共接口,因为它使用相同的词汇和实际域操作。
以下是一个假设的PurchaseOrder实体的部分:
public class PurchaseOrder
{
…
#region private members
private IList<PurchaseOrderItem> items;
private DateTime _deliveryTime;
#endregion
public PurchaseOrder(DateTime creationTime, DateTime deliveryTime)
{
CreationTime = creationTime;
_deliveryTime = deliveryTime;
items=new List<PurchaseOrderItem>();
}
public DateTime CreationTime {get; init;}
public DateTime DeliveryTime => _deliveryTime;
public IEnumerable<PurchaseOrderItem> Items => items;
public bool DelayDelyveryTime(DateTime newDeliveryTime)
{
if(_deliveryTime< newDeliveryTime)
{
_deliveryTime = newDeliveryTime;
return true;
}
else return false;
}
public void AddItem (PurchaseOrderItem x)
{ items.Add(x); }
public void RemoveItem(PurchaseOrderItem x)
{ items.Remove(x); }
…
}
一旦从构造函数中取出,CreationTime就无法再修改,因此它被实现为一个 {get; init;} 属性。所有项目的列表可以通过AddItem和RemoveItem方法进行修改,这些方法对所有域专家来说都是可理解的。最后,我们可以延迟交货日期,但不能提前预测。这自动通过强制使用DelayDeliveryTime方法来编码域业务规则。
我们可以通过添加一个返回采购总金额的PurchaseTotal 获取属性以及添加一个ApplyDiscount方法来改进PurchaseOrder实体。
总结起来,我们可以提出以下规则:
域实体状态只能通过编码实际域操作并自动强制执行所有业务规则的方法来更改。
这些实体与我们所习惯的Entity Framework Core实体有很大不同,原因如下:
-
Entity Framework Core 实体是类似记录的类,没有方法。也就是说,它们只是一组属性-值对。
-
每个 Entity Framework Core 实体对应一个与其它实体有关联的单个对象,而域实体通常是嵌套对象的树。这就是为什么域实体通常被称为聚合。
因此,例如,PurchaseOrder聚合包含一个主实体和一个PurchaseOrderItem集合。值得注意的是,PurchaseOrderItem不能被视为一个独立的域实体,因为没有涉及单个PurchaseOrderItem的域操作,但PurchaseOrderItem可以像PurchaseOrder的一部分一样被操作。
这种现象在扁平的 Entity Framework 实体中不会发生,因为它们缺乏域操作的概念。我们可以得出以下结论:
域实体上的域操作可以迫使它们与依赖实体合并,从而成为一个称为聚合的复杂对象树。
在本书的剩余部分,我们将把域实体称为聚合。
到目前为止,我们已经给实体赋予了强大的应用领域语义以及聚合的概念。这些聚合与数据库元组以及 ORMS(如 Entity Framework Core)提供的对象表示有很大不同,因此聚合与用于持久化的结构之间存在不匹配。这种不匹配可以通过几种方式解决,但所有解决方案都必须符合持久化无知原则:
聚合不应受其可能如何持久化的影响。它们必须与持久化代码完全解耦,持久化技术不得对聚合设计施加任何约束。
我们现在观察到另一个现象:没有身份的实体!
两个日期和项目完全相同的采购订单仍然是两个不同的实体;实际上,它们必须为每个实体有不同的交货。
然而,对于包含完全相同字段的两个地址会发生什么?如果我们考虑地址的语义,我们能否说它们是两个不同的实体?
每个地址表示一个地点,如果两个地址具有相同的字段,它们表示的地点也完全相同。因此,地址就像数字一样:即使我们可能复制它们多次,每个副本始终表示相同的抽象实体。
因此,我们可以得出结论,具有相同字段的地址是不可区分的。关系数据库使用主键来验证两个元组是否引用相同的抽象实体,因此我们可以得出结论,地址的主键应该是所有字段的集合。
在领域实体理论中,类似于地址的对象被称为值对象,它们在内存中的表示不得包含显式的主键。应用于它们两个实例的相等运算符必须仅在所有字段都相等时返回true。此外,它们必须是不可变的——也就是说,一旦创建,它们的属性就不能更改,因此修改值对象的唯一方法是用一些属性值更改创建一个新的对象。
在 C#中,值对象可以用记录轻松表示:
public record Address
{
public string Country {get; init;}
public string Town {get; init;}
public string Street {get; init;}
}
init关键字使得记录类型属性不可变,因为它意味着它们只能
初始化。可以创建如下修改后的记录副本:
var modifiedAddress = myAddress with {Street = "new street"};
如果我们在构造函数中传递所有属性而不是使用初始化器,则前面的定义可以简化如下:
public record Address(string Country, string Town, string Street) ;
典型的值对象包括成本(表示为数字和货币符号)、位置(表示为经度和纬度)、地址和联系信息。
在实践中,值对象可以用包含主键(例如,一个自增整数)的常规元组在数据库中表示。然后,可以为每个相同地址的出现创建不同的新元组副本。也可以通过定义复杂的复合键来强制执行唯一的数据库副本。
由于聚合和值对象与所有主要 ORM(如 Entity Framework)使用的实体有很大不同,当我们使用 ORM 与数据库交互时,我们必须在每次与 ORM 交换数据时将 ORM 实体转换为聚合和值对象,反之亦然。
根据通用的洋葱架构规则,领域层通过接口与 ORM 提供的实际实现进行交互。这通常是通过所谓的仓库模式来完成的。
根据仓库模式,必须为每个聚合提供一个单独的接口来提供存储服务。
这意味着领域层必须为每个聚合包含一个不同的接口,该接口负责检索、保存和删除整个聚合。仓库模式有助于保持代码模块化,易于搜索和更新,因为我们知道我们必须为每个聚合只有一个仓库接口,因此我们可以将整个聚合相关代码组织在单个文件夹中。
每个仓库的实际实现包含在洋葱架构的基础设施层中的一种数据库(或持久性)驱动程序中,以及各种其他驱动程序,这些驱动程序虚拟化了与基础设施的交互。
每个聚合仓库接口包含返回聚合、删除聚合以及在对聚合执行任何其他持久性相关操作的方法。
在复杂的应用程序中,将领域层拆分为一个仅包含聚合的模型层和一个外部的领域服务层是最佳实践,其中领域服务层包含仓库接口和定义那些不能作为聚合方法实现的领域操作。
特别是,领域服务接口处理用于编码查询微服务返回结果的元组。这些元组不是聚合,而是来自不同数据表的数据的混合,因此它们符合完全不同的设计模式。它们作为没有方法、只有与数据库元组字段相对应的属性的记录样对象返回。进一步的领域服务接口也在基础设施层持久性驱动程序中实现。
分别处理查询和修改,并使用不同的设计模式,这被称为命令查询责任分离(CQRS)模式。
由于本书中描述的微服务相当简单,在我们的代码示例中,我们不会将领域层拆分为模型和领域服务层。因此,仓库和其他领域服务接口将与聚合混合在同一 Visual Studio 项目中。然而,在实现更复杂的应用程序时,你应该使用将领域层拆分为模型和领域服务层的做法。
让我们看看一些仓库接口的示例。PurchaseOrder聚合可能有一个相关的仓库接口,看起来如下所示:
public interface IPurchaseOrderRepository
{
PurchaseOrder New(DateTime creationTime, DateTime deliveryTime);
Task<PurchaseOrder> GetAsync(long id);
Task DeleteAsync(long id);
Task DeleteAsync(PurchaseOrder order);
Task<IEnumerable<OrderBasicInfoDTO>> GetMany(DateTime? startPeriod,
DateTime? endPeriod, int? customerId
);
...
}
没有更新方法,因为更新是通过直接调用聚合体方法实现的。代码中显示的最后一个方法返回一个名为OrderBasicInfoDTO的记录样式的 DTO 集合。
值得注意的是,由于值对象被像整数、小数或字符串这样的原始类型一样处理,因此与值对象没有关联的存储库接口。
多个不同聚合体的更改可以通过工作单元模式以事务方式处理,该模式将在后面的命令子节中描述。
关于 Entity Framework Core 如何支持实现存储库接口以及如何将领域对象与 Entity Framework Core 实体相互绑定和转换的更多细节将在基于洋葱架构的解决方案模板部分给出。
理解了领域对象的内存表示后,我们可以继续了解面向微服务的洋葱架构如何表示所有业务事务/操作。
应用服务
在第二章的微服务组织子节[揭秘微服务应用]中,我们了解到微服务架构通常使用CQRS模式,其中一些微服务专门处理查询,而另一些则专门处理更新。这就是 CQRS 模式的强版本,但还有一个较弱的版本,它只需将查询和更新组织到不同的模块中,这些模块可能属于同一个微服务。
虽然并不总是方便应用 CQRS 的较强形式,但在实现微服务时,其较弱形式是必须的,因为更新涉及聚合体,而查询仅涉及记录样式的 DTO,因此它们需要完全不同类型的处理。
因此,在微服务的应用服务层中定义的操作被分为两种不同类型:查询和命令。正如我们将看到的,命令的执行可以触发事件,因此与应用服务和查询一起,应用服务还必须处理所谓的领域事件。我们将在接下来的专用子节中讨论所有这些不同的操作。
查询
查询对象代表一个或多个类似的查询,因此它通常有一个或多个方法,这些方法接受一些输入并返回查询结果。大多数查询方法只是调用一个实现所需查询的单个存储库方法,但在某些情况下,它们可能执行多个存储库方法,然后它们可能会以某种方式合并它们的结果。
在系统测试期间,实际的查询实现必须被模拟实现所替代,因此,通常每个查询都有一个与之关联的接口,该接口与依赖注入引擎中的实际实现耦合。这样,UI 可能只需要在某个构造函数中提供该接口,从而可以使用查询的模拟实现进行测试。
以下是一个可能的查询定义,该查询返回在给定日期之后发出的所有采购订单,以及其关联的接口:
public interface IPurchaseOrderByStartDateQuery: IQuery
{
Task<IEnumerable<OrderBasicInfoDTO>> Execute(DateTime startDate);
}
public class PurchaseOrderByStartDateQuery(IPurchaseOrderRepository repo):
IPurchaseOrderByStartDateQuery
{
public async Task<IEnumerable<OrderBasicInfoDTO>> Execute(DateTime startDate)
{
return await repo.GetMany(startDate, null, null);
}
}
该接口继承自一个空接口,其唯一目的是标记接口及其实现为查询。这样,所有查询及其相关实现都可以通过反射自动找到并添加到依赖注入引擎中。我们将在“基于洋葱架构的解决方案模板”部分提供发现所有查询的代码,以及一个完整的解决方案模板。
如前所述,实现只是调用存储库方法并传递足够的参数。存储库的实现是通过与将查询本身注入到表示层对象(在 ASP.NET Core 网站上是控制器)的构造函数相同的依赖注入引擎传递到类的主体构造函数中的。
命令
命令以略有不同的方式工作,因为为了提高代码的可读性,每个命令代表一个单独的应用程序操作。因此,每个命令实例代表抽象操作及其输入。实际的操作实现包含在命令处理程序对象中。以下是一个假设的命令的代码,该命令将折扣应用于采购订单:
public record ApplyDiscountCommand(decimal discount, long orderId): ICommand;
命令必须是不可变的;这就是为什么我们将它们实现为记录。实际上,对它们唯一允许的操作是它们的执行。类似于查询,命令也实现了一个空接口,标记它们为命令(在这种情况下,ICommand)。
命令处理程序是实现以下接口的实现:
public interface ICommandHandler {}
public interface ICommandHandler<T>: ICommandHandler
where T: ICommand
{
Task HandleAsync(T command);
}
正如你所看到的,所有命令处理程序都实现了相同的HandleAsync方法,该方法接受命令作为其唯一的输入。因此,例如,与ApplyDiscountCommand关联的处理程序可能如下所示:
public class ApplyDiscountCommandHandler(
IPackageRepository repo):ICommandHandler<ApplyDiscountCommand>
{
public async Task HandleAsync(ApplyDiscountCommand command)
{
var purchaseOrder = await repo.GetAsync(command.OrderId);
//call adequate aggregate methods to apply the required update
//possibly modify other aggregates by getting them with other
//injected repositories
...
}
}
所有处理程序都必须添加到依赖注入引擎中,如下例所示:
builder.Services.AddScoped<ICommandHandler<ApplyDiscountCommand>,
ApplyDiscountCommandHandler>();
这可以通过使用反射扫描应用程序服务程序集来自动完成。我们将在“基于洋葱架构的解决方案模板”部分提供发现所有命令处理程序的代码。
每个命令处理程序都会获取或创建聚合,通过调用它们的方法来修改它们,然后执行一个保存指令以在底层存储中持久化所有修改。
保存操作必须在存储驱动程序(例如,Entity Framework Core)中实现,因此,对于所有洋葱架构驱动程序的常规操作,它通常通过接口进行中介。执行保存操作和其他事务相关操作的接口通常称为IUnitOfWork。此接口的可能定义如下:
public interface IUnitOfWork
{
Task<bool> SaveEntitiesAsync();
Task StartAsync();
Task CommitAsync();
Task RollbackAsync();
}
让我们分解一下:
-
SaveEntitiesAsync在单个事务中保存迄今为止执行的所有更新器。如果存储引擎在保存操作后实际上发生了变化,则返回true,否则返回false。 -
StartAsync开始一个事务。 -
CommitAsync和RollbackAsync分别提交和回滚一个打开的事务。
所有明确控制事务开始和结束的方法都很有用,可以将获取操作和最终的 SaveEntitiesAsync 保存操作封装在同一事务中,如下面的简化航班预订片段所示:
await unitOfWork.StartAsync();
var flight = await repo.GetFlightAsync(flightId);
flight.Seats--;
if(flight.Seats < 0)
{
await unitOfWork.RollBackAsync();
return;
}
...
await unitOfWork.SaveEntitiesAsync();
await unitOfWork.CommitAsync();
如果没有更多的可用座位,事务将被中止,但如果还有可用座位,我们确信没有其他乘客可以占用这个座位,因为查询和更新都是在同一事务中执行的,从而防止其他预订操作的影响。
当然,如果事务具有足够的隔离级别并且数据库支持该隔离级别,前面的代码是有效的。我们可以为微服务中的所有操作使用足够高的隔离级别;否则,我们被迫将隔离级别作为 StartAsync 参数传递。
现在,我们准备解释为什么需要领域事件,以及它们是如何被处理的。
领域事件
我们可以这样定义领域事件:
领域事件是从微服务领域中发生的事情产生的,并且是在微服务本身的边界内被处理的。这意味着它们涉及同一微服务两段代码之间的基于发布者-订阅者模式的通信。
因此,它们不应与涉及不同微服务之间通信的事件混淆,这些事件被称为集成事件,以区分领域事件。
为什么要在微服务的边界内使用事件?原因始终相同:为了确保部分之间的更好的解耦。在这里,涉及的部件是聚合。每个聚合的代码必须完全独立于其他聚合,以确保模块化和可修改性,因此聚合之间的关系要么由命令处理器中介,要么由某种发布者-订阅者模式中介。
因此,如果两个聚合之间的交互由命令处理器的代码决定,同一个命令处理器可能会负责处理它们的数据,然后以某种方式更新它们。然而,如果交互与聚合方法内的处理相关联,我们被迫使用事件,因为我们无法让聚合意识到所有需要通知其数据更改的其他聚合。总结一下,我们可以提出以下原则:
领域事件仅在聚合方法内部触发,因为其他类型的交互更适合由命令处理器的代码来处理。
另一个重要的原则是以下内容:
在聚合方法内部触发的事件不应干扰正在进行的方法处理,因为这些可能会破坏聚合与操作它的命令处理器之间的契约。
因此,每个聚合将所有事件存储在其内部的事件列表中,然后命令处理器决定何时执行这些处理器。通常,命令处理器处理的所有聚合的事件都在处理器通过调用unitOfWork.SaveEntitiesAsync()保存所有更改之前执行。然而,这并不是一个普遍的规则。
事件的处理方式与命令类似,唯一的区别是每个命令只有一个关联的处理程序,而每个事件可能有多个订阅附加到它。幸运的是,这种困难可以通过.NET 依赖注入引擎的一些高级功能轻松解决。
更具体地说,事件是带有空IEventNotification接口的类,而事件处理器是实现以下接口的:
public interface IEventHandler
{
}
public interface IEventHandler<T>: IEventHandler
where T: IEventNotification
{
Task HandleAsync(T ev);
}
所有的数据结构都与处理命令所需的数据结构完全类似。然而,现在我们必须添加一些增强功能,以将每个事件与其所有处理器关联起来。以下泛型类就做到了这一点:
public class EventTrigger<T>
where T: IEventNotification
{
private readonly IEnumerable<IEventHandler<T>> _handlers;
public EventTrigger(IEnumerable<IEventHandler<T>> handlers)
{
_handlers = handlers;
}
public async Task Trigger(T ev)
{
foreach (var handler in _handlers)
await handler.HandleAsync(ev);
}
}
在这里,IEventNotification是一个空接口,仅用于标记一个类表示事件。
如果我们使用service.AddScoped(typeof(EventTrigger<>))将前面的泛型类添加到依赖注入引擎中,那么每次我们需要这个类的特定实例(比如,对于MyEvent事件泛型参数)时,依赖注入引擎将自动检索所有IEventHandler<MyEvent>实现,并将它们传递给返回的EventTrigger<MyEvent>实例的构造函数。之后,我们可以使用类似以下的方式启动所有已订阅的处理程序:
public class MyCommandHandler(EventTrigger<MyEvent> myEventHandlers): …
{
public async Task HandleAsync(MytCommand command)
{
…
await myEventHandlers.Trigger(myEvent)
…
}
}
值得注意的是,IEventNotification接口必须在领域层中定义,因为它必须使用聚合,而所有其他与事件相关的接口和类都定义在应用程序服务 DLL 中。
作为事件的一个例子,让我们考虑一个电子商务应用程序的采购订单聚合。当通过调用其Finalize方法最终确定采购订单时,如果采购金额超过一个给定的阈值,那么必须创建一个事件来为用户添加一些分数到用户资料中,用户可以用这些分数在后续采购中获得折扣。
下图展示了发生的情况:

图 3.3:领域事件示例
就像命令处理器的情况一样,在应用程序服务 DLL 中定义的所有事件处理器都可以通过反射自动发现并添加到依赖注入引擎中。我们将在下一节中展示如何实现,该节将提出一个用于洋葱架构的通用.NET 解决方案模板。
基于洋葱架构的解决方案模板
在本节中,我们描述了一个基于洋葱架构的解决方案模板,我们将在本书的其余部分使用该模板,您可以在书的 GitHub 仓库的 ch03 文件夹中找到它(github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp)。此模板展示了如何将您所学的洋葱架构知识付诸实践。
该解决方案包含两个 .NET 库项目,分别称为 ApplicationServices 和 DomainLayer,它们分别实现了洋葱架构的应用服务和领域层:

图 3.4:基于洋葱架构的解决方案模板
根据洋葱架构的规定,ApplicationServices 项目引用了 DomainLayer 架构项目。
在 ApplicationServices 中,我们添加了以下文件夹:
-
Queries用于放置所有查询和查询接口 -
Commands用于放置所有命令类 -
CommandHandlers用于放置所有命令处理程序 -
EventHandlers用于放置所有事件处理程序 -
Tools包含了我们在上一节描述的应用服务所使用的所有洋葱架构相关的接口 -
Extensions包含了HandlersDIExtensions.AddApplicationServices()扩展方法,该方法将项目中定义的所有查询、事件处理程序和命令处理程序添加到依赖注入引擎中
所有的前述文件夹都可以组织成子文件夹,以增加代码的可读性。
在 DomainLayer 项目中,我们添加了以下文件夹:
-
Models用于放置所有聚合和值对象 -
Events用于放置所有可能由聚合引发的事件 -
Tools包含了我们在上一节描述的领域所使用的所有洋葱架构相关的接口,以及一些额外的实用类
ApplicationServices 项目的 Extensions 文件夹中只包含一个文件:

图 3.5:ApplicationServices 扩展
HandlersDIExtensions 静态类包含一个扩展方法的两个重载,该扩展方法将所有查询、命令处理程序、事件处理程序和 EventMediator 类添加到依赖注入引擎中:
public static IServiceCollection AddApplicationServices
(this IServiceCollection services, Assembly assembly)
{
AddAllQueries(services, assembly);
AddAllCommandHandlers(services, assembly);
AddAllEventHandlers(services, assembly);
services.AddScoped<EventMediator>();
return services;
}
public static IServiceCollection AddApplicationServices
(this IServiceCollection services)
{
return AddApplicationServices(services,
typeof(HandlersDIExtensions).Assembly);
}
它使用三个不同的私有方法通过反射扫描程序集,分别查找查询、命令处理程序和事件处理程序。完整的代码可以在与本书相关的 GitHub 仓库的 ch03 文件夹中找到。在这里,我们仅分析 AddAllCommandHandlers 以展示所有三种方法所利用的基本思想:
private static IServiceCollection AddAllCommandHandlers
(this IServiceCollection services, Assembly assembly)
{
var handlers = assembly.GetTypes()
.Where(x => !x.IsAbstract && x.IsClass
&& typeof(ICommandHandler).IsAssignableFrom(x));
…
首先,我们收集所有实现 ICommandHandler 空接口的非抽象类。这个接口被特别添加到所有命令处理器中,以便通过反射检索所有这些处理器。然后,对于每个处理器,我们检索它实现的 ICommandHandler<T>:
foreach (var handler in handlers)
{
var handlerInterface = handler.GetInterfaces()
.Where(i => i.IsGenericType &&typeof(
ICommandHandler).IsAssignableFrom(i))
.SingleOrDefault();
最后,如果我们找到这样的接口,我们将该对添加到依赖注入引擎中:
foreach (var handler in handlers)
{
…
if (handlerInterface != null)
{
services.AddScoped(handlerInterface, handler);
}
}
ApplicationServices 项目的 Tools 文件夹包含以下文件:

图 3.6:ApplicationServices 工具
我们已经分析了前面 Tools 文件夹中包含的所有接口和类,除了上一节中的 EventMediator。让我们回顾一下它们:
-
IQuery和ICommand是空接口,分别标记查询和命令 -
ICommandHandler<T>和IEventHandler<T>是必须分别由命令处理器和事件处理器实现的接口 -
EventTrigger<T>是一个收集与同一事件T相关的所有事件处理器的类
EventMediator 是一个实用类,用于解决实际问题。需要触发与事件 T 相关的所有事件处理器的命令处理器必须在构造函数中注入 EventTrigger<T>。然而,关键点是命令发现它需要触发 T 事件,正是在它找到聚合体的事件列表中的 T 事件时,因此它应该在构造函数中注入所有可能的 EventTrigger<T>。
为了克服这个问题,EventMediator 类使用 IServiceProvider 在其 TriggerEvents(IEnumerable<IEventNotification> events) 方法中要求传递给它的与事件列表相关的事件处理器。
因此,只需在每个命令处理器的构造函数中注入 EventMediator,这样每当它在一个聚合体中找到一个非空的事件列表 L 时,它就可以简单地调用以下操作:
await eventMediator.TriggerEvents(L);
一旦 EventMediator 收到前面的调用,它将扫描事件列表以发现其中包含的所有事件,然后对于每个事件,它要求相应的 EventTrigger<T> 获取所有相关的事件处理器,最后,它执行所有检索到的处理器,并将相应的事件传递给它们。
为了执行其工作,EventMediator 类在其构造函数中需要 IServiceProvider:
public class EventMediator
{
readonly IServiceProvider services;
public EventMediator(IServiceProvider services)
{
this.services = services;
}
...
然后,它使用这个服务提供者来要求每个需要的 EventTrigger<T>:
public async Task TriggerEvents(IEnumerable<IEventNotification> events)
{
if (events == null) return;
foreach(var ev in events)
{
var triggerType = typeof(EventTrigger<>).MakeGenericType(
ev.GetType());
var trigger = services.GetService(triggerType);
最后,它通过反射调用 EventTrigger<T>.Trigger 方法:
var task = (Task)triggerType.GetMethod(nameof(
EventTrigger<IEventNotification>.Trigger))
.Invoke(trigger, new object[] { ev });
await task.ConfigureAwait(false);
以下是 EventMediator 类的完整代码:
public class EventMediator
{
readonly IServiceProvider services;
public EventMediator(IServiceProvider services)
{
this.services = services;
}
public async Task TriggerEvents(IEnumerable<IEventNotification> events)
{
if (events == null) return;
foreach(var ev in events)
{
var triggerType = typeof(EventTrigger<>).MakeGenericType(
ev.GetType());
var trigger = services.GetService(triggerType);
var task = (Task)triggerType.GetMethod(nameof(
EventTrigger<IEventNotification>.Trigger))
.Invoke(trigger, new object[] { ev });
await task;
}
}
}
DomainLayer 项目的 Tools 文件夹包含以下文件:

图 3.7:DomainLayer 工具
IEventNotification 和 IRepository 是空接口,分别标记事件和存储库接口。我们已经在上一节中讨论了它们。我们也已经讨论了 IUnitOfWork,这是命令处理器需要用来持久化更改和处理事务的接口。
Entity<T> 是所有聚合必须继承的类:
public abstract class Entity<K>
where K: IEquatable<K>
{
public virtual K Id {get; protected set; } = default!;
public bool IsTransient()
{
return Object.Equals(Id, default(K));
}
>Domain events handling region
>Override Equal region
}
前一个类包含两个最小化代码区域。泛型参数 K 是聚合的 Id 主键的类型。
IsTransient() 方法返回 true 如果聚合尚未分配主键。
Override Equal region 包含覆盖 Equal 方法并定义相等和不等运算符的代码。重新定义的 Equal 方法认为两个实例相等当且仅当它们具有相同的主键。
Domain events handling region 处理在调用聚合方法期间触发的事件列表。展开的代码如下所示:
#region domain events handling
public List<IEventNotification> DomainEvents { get; private set; } = null!;
public void AddDomainEvent(IEventNotification evt)
{
DomainEvents ??= new List<IEventNotification>();
DomainEvents.Add(evt);
}
public void RemoveDomainEvent(IEventNotification evt)
{
DomainEvents?.Remove(evt);
}
#endregion
我们不需要为值对象提供一个抽象类,因为,如前所述,.NET 的 record 类型完美地代表了所有值类型特性。
在更详细地讨论如何将模板的两个库项目与实际的存储驱动程序和实际的 UI 连接之前,我们需要了解如何处理聚合和类似记录的 ORM 类之间的不匹配。我们将在下面的专用子节中这样做。
匹配聚合和 ORM 实体
有几种技术可以将 ORM 实体和聚合匹配起来。最简单的一种是使用 ORM 实体本身实现聚合。这种方法的主要困难是聚合不公开必须与数据库字段匹配的属性作为公共属性。然而,由于它们通常将它们作为私有字段公开,如果选择的 ORM 支持使用私有属性进行映射,我们可以尝试使用这些私有字段进行数据库字段映射。
Entity Framework Core 支持使用私有字段进行映射,但如果我们正在寻找完全独立于数据库驱动程序,我们不能依赖于 Entity Framework Core 的这一特性。此外,这种方法迫使我们定义 ORM 实体在域层中,因为它们也是聚合。这意味着我们不能在定义每个聚合时用 ORM 特定的属性装饰类成员,并且我们需要担心 ORM 如何使用该类,从而破坏了对特定存储驱动程序的独立性。
更好的方法是 状态对象 方法:
-
我们将每个聚合与一个接口关联,该接口将其状态存储在其属性中。这样,而不是使用私有支持字段,聚合使用该接口的属性。
-
状态接口传递给聚合的构造函数,然后存储在一个私有的
readonly属性中。 -
与聚合关联的 ORM 实体实现了此接口。这样,数据库驱动器适应聚合,而不是反过来,从而实现了领域层对数据库驱动器的所需独立性。
-
当领域层需要通过存储库接口方法获取一个新的聚合或已存储在数据库中的聚合时,存储库方法的数据库实现创建或检索相应的 ORM 实体,然后创建一个新的聚合,将其作为状态对象传递给其构造函数。
-
当聚合被修改时,所有修改都会反映在其状态对象上,这些对象作为 ORM 实体被 ORM 跟踪。因此,当我们指示 ORM 保存所有更改时,所有聚合的更改会自动传递到底层数据库,因为这些更改存储在跟踪对象中。
以下图显示了前面的流程:

图 3.8:聚合生命周期
让我们尝试使用以下状态接口修改我们之前的 PurchaseOrder 聚合:
public interface IPurchaseOrderState
{
public DateTime CreationTime { get; set; }
public DateTime DeliveryTime { get; set; }
public ICollection<PurchaseOrderItem> Items { get; set; }
…
}
修改很简单,不会增加代码的复杂性:
public class PurchaseOrder
{
private readonly IPurchaseOrderState _state;
public PurchaseOrder(IPurchaseOrderState state)
{
_state = state;
}
public DateTime CreationTime => _state.CreationTime;
public DateTime DeliveryTime => _state.DeliveryTime;
public IEnumerable<PurchaseOrderItem> Items => _state.Items;
public bool DelayDelyveryTime(DateTime newDeliveryTime)
{
if(_state.DeliveryTime < newDeliveryTime)
{
_state.DeliveryTime = newDeliveryTime;
return true;
}
else return false;
}
public void AddItem (PurchaseOrderItem x)
{ _state.Items.Add(x); }
public void RemoveItem(PurchaseOrderItem x)
{ _state.Items.Remove(x); }
}
现在,我们准备好了解如何将模板中的两个项目与实际的数据库驱动器和实际的 UI 连接起来。
基于洋葱架构的完整解决方案
书籍 GitHub 仓库的 ch03 文件夹(github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp)包含一个完整的解决方案,该解决方案与应用程序服务和领域层库一起,还包含基于 Entity Framework Core 的数据库驱动器和基于 ASP.NET Core Web API 项目的表示层。
此项目的目的是展示如何在实际解决方案中使用本节中描述的通用洋葱架构模板。
以下图显示了完整的解决方案:

图 3.9:基于洋葱架构的完整解决方案
DBDriver 项目是一个 .NET 库项目,我们在其中添加了对以下 Nuget 包的依赖:
-
Microsoft.EntityFrameworkCore.SqlServer:此包加载了 Entity Framework Core 和其 SQL Server 提供程序 -
Microsoft.EntityFrameworkCore.Tools:此包提供了所有用于生成和处理数据库迁移的工具
由于 DBDriver 项目必须提供存储驱动器,它也依赖于领域库项目。
WebApi 项目是一个 ASP.NET Core Web API 项目。它作为洋葱架构的最外层。
洋葱架构的最外层(在我们的例子中,WebApi)必须依赖于应用程序服务目录和所有驱动器项目(在我们的例子中,仅为 DBDriver)。
我们向 DBDriver 项目添加了一些文件夹和类,这些文件夹和类应该用于所有基于 Entity Framework Core 的驱动程序。以下图显示了项目结构:

图 3.10:DBDriver 项目结构
下面是所有文件夹的描述:
-
Entities:将所有你的 Entity Framework Core 实体放在这里,可能组织在子文件夹中。 -
Repositories:将所有仓库实现放在这里,可能组织在子文件夹中。 -
MainDbContext:这是项目实体框架数据库上下文的骨架,同时也包含了IUnitOfWork接口的实现。 -
Extensions:这个文件夹包含两个扩展类。RepositoryExtensions只提供AddAllRepositories扩展方法,该方法发现所有仓库实现并将它们添加到依赖注入引擎中。它的代码类似于我们在前一小节中描述的AddAllCommandHandlers扩展方法之一,所以我们在这里不会描述它。DBExtension只包含AddDbDriver扩展方法,该方法将DBDriver提供的所有实现添加到依赖注入引擎中。
AddDbDriver 扩展方法的实现很简单:
public static IServiceCollection AddDbDriver(
this IServiceCollection services,
string connectionString)
{
services.AddDbContext<IUnitOfWork, MainDbContext>(options =>
options.UseSqlServer(connectionString,
b => b.MigrationsAssembly("DBDriver")));
services.AddAllRepositories(typeof(DBExtensions).Assembly);
return services;
}
它接受数据库连接字符串作为其唯一输入,并使用常规的 AddDbContext Entity Framework Core 扩展方法将 MainDbContext 实体框架上下文作为 IUnitOfWork 接口的实现。然后,它调用 AddAllRepositories 方法来添加 DBDriver 提供的所有仓库实现。
下面是 MainDbContext 类:
internal class MainDbContext : DbContext, IUnitOfWork
{
public MainDbContext(DbContextOptions options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder builder)
{
}
region IUnitOfWork Implementation
}
该类被定义为内部定义,因为它必须在数据库驱动程序外部不可见。所有实体配置都必须像往常一样放在 OnModelCreating 方法内部。
IUnitOfWork 的实现被最小化了。展开的代码如下所示:
#region IUnitOfWork Implementation
public async Task<bool> SaveEntitiesAsync()
{
return await SaveChangesAsync() > 0; ;
}
public async Task StartAsync()
{
await Database.BeginTransactionAsync();
}
public Task CommitAsync()
{
return Database.CommitTransactionAsync();
}
public Task RollbackAsync()
{
return Database.RollbackTransactionAsync();
}
#endregion
IUnitOfWork 的实现很简单,因为它与 DBContext 方法具有一对一的耦合。
由于我们在依赖注入引擎中仅公开 IUnitOfWork,因此所有需要 MainDbContext 来完成其工作的仓库必须在它们的构造函数中要求 IUnitOfWork,然后它们必须将其转换为 MainDbContext。
在讨论了关于 DBDriver 我们需要了解的内容之后,让我们转向 Web API 项目。
连接到洋葱架构的最外层项目很容易。我们只需要调用应用程序服务公开的扩展方法,该方法将所有应用程序服务实现注入到依赖注入引擎中,并且我们需要调用所有驱动程序的扩展方法。
在我们的情况下,我们只需要在 Program.cs 中添加两个调用:
..
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddApplicationServices();
builder.Services.AddDbDriver(
builder.Configuration?.GetConnectionString(
"DefaultConnection") ?? string.Empty);
..
在这一点上,对于 ASP.NET Core 项目来说,剩下的只是获取我们控制器构造函数中需要的命令处理器。之后,每个操作方法只需使用它接收到的输入来构建适当的命令,然后必须调用与每个命令关联的处理程序。
对洋葱架构最外层的处理方法的简要描述完成了我们对这种架构的简要介绍,但我们将在整个书籍的其余部分找到示例,因为我们将在大多数代码示例中使用它们。
让我们继续探讨微服务架构的另一个重要构建块:容器!
容器和 Docker
我们已经讨论了拥有不依赖于运行环境的微服务的优势;微服务可以在没有限制的情况下从繁忙的节点移动到空闲节点,从而实现更好的负载均衡,并因此更好地利用可用硬件。
然而,如果我们需要将遗留软件与较新的模块混合使用,或者如果我们希望为每个模块使用最佳的堆栈,并且能够混合几种开发堆栈实现,我们将面临每个不同的堆栈都有不同的硬件/软件先决条件的问题。在这些情况下,可以通过在每个微服务上部署每个微服务及其所有依赖项来恢复每个微服务与托管环境的独立性。
然而,启动带有其操作系统私有副本的虚拟机需要花费很多时间,而微服务必须快速启动和停止以减少负载均衡和故障恢复成本。幸运的是,微服务可以依赖一种更轻量级的虚拟化技术:容器。容器提供了一种轻量级且高效的虚拟化形式。与传统虚拟机不同,虚拟机虚拟化整个机器,包括操作系统,容器在操作系统的文件系统级别进行虚拟化,位于宿主操作系统内核之上。它们使用宿主机的操作系统(内核、DLL 和驱动程序),并使用操作系统的本地功能来隔离进程和资源,为它们运行的镜像创建一个隔离的环境。
以下图展示了容器的工作原理:

图 3.11:容器基本原理
容器由容器运行时从镜像中运行,这些镜像编码了它们的内容。相同的镜像可以创建多个相同的容器。镜像存储在镜像仓库中,通过镜像名称和版本来识别它们。反过来,镜像是通过文本文件中的命令创建的,这些命令指定了容器的内容和属性。
更具体地说,名称是 URL,其域名部分是注册表域,路径部分由一个包含相关图像的命名空间和存储库名组成。版本附加到该 URL 上,并用冒号分隔,称为 tag,因为它可以是任何字符串。总结来说,名称和版本编码如下所示:
<registry domain>/<namespace>/<repository name>:<tag>
因此,例如,ASP.NET CORE 9.0 运行时 Docker 镜像的完整 URL 如下所示:
mcr.microsoft.com/dotnet/aspnet:9.0
在这里,mcr.microsoft.com 是注册表域,dotnet 是命名空间,asp.net 是存储库名,9.0 是标签。
任何需要创建容器的运行时都会从注册表中下载其图像,可能提供凭证,然后使用下载的图像来创建容器。以下图显示了容器创建的整个过程:

图 3.12:容器/镜像生命周期
在本书的剩余部分,我们将使用 Docker 容器作为事实上的标准。每个 Docker 镜像都是通过指定对另一个现有图像的更改来生成的,这些更改使用 Docker 容器描述语言。创建 Docker 镜像的指令包含在一个必须命名为 Dockerfile(不带任何文件扩展名)的文件中。
每个 Dockerfile 通常以一个 FROM 指令开始,该指令指定要修改的现有图像,如下所示:
FROM mcr.microsoft.com/dotnet/aspnet:9.0
...
在图像 URL 之后指定要使用的 ASP.NET CORE 版本的标签,前面有一个冒号,如前述代码所示。从私有仓库获取的图像必须指定其完整的 URL,该 URL 以注册表的域开始。没有完整 URL 的图像只有在它们托管在 Docker 免费公共注册表 hub.docker.com/r/ 上时才允许。
下图显示了 Docker 镜像的层次组织结构:

图 3.13:图像和容器的层次结构
FROM 语句指定了您所在的环境,称为 构建阶段。之后,您可以像处理文件系统一样处理图像,通过将文件从您的计算机复制到其中并执行 shell 命令:

图 3.14:构建镜像
在所有复制操作中,您可以在计算机上使用相对路径。它们被认为是相对于包含 Dockerfile 文件的目录的相对路径。
这里列出了主要的 Dockerfile 命令:
-
WORKDIR <镜像文件系统中的路径>
此指令定义了图像文件系统中的当前目录。如果目录不存在,则创建它。之后,您也可以在图像文件系统中使用相对路径。
-
COPY <计算机中的路径> <镜像中的路径>
将一个或多个文件复制到图像文件系统中。如果源路径表示一个文件夹,则整个文件夹将被递归复制;否则,将复制单个文件。在任何情况下,复制的目录或文件都采用图像路径中指定的名称。
-
复制
… ./(或 [ , , …, ./] 所有源路径指定的内容都将复制到图像的当前目录。源文件名不会更改。
-
复制 –-from=
:
… 这与之前的复制命令类似,但文件是从
from=之后指定的名称/URL 指定的图像中获取的。只有当图像包含在您的计算机或 Docker 公共仓库中时,才能指定名称而不是 URL。如果没有指定版本,则默认假设为latest版本名称。 -
RUN
... 这将在图像的当前目录中执行指定的 shell 命令及其参数。
-
CMD [
, , , ...] ENTRYPOINT [<command>, <arg1>, <arg2>, ...]这指定了容器执行时会发生什么。更具体地说,它声明了在容器执行时要运行的命令和参数。
-
EXPOSE
这声明了容器支持的所有端口。网络流量应仅通过此处声明的端口重定向到容器,但不会阻止重定向到其他端口的流量。
Dockerfile 也可以作为定义最终图像的步骤来构建中间图像。例如,可以创建包含整个.NET SDK 的图像,其唯一目的是编译.NET 解决方案。然后,最终二进制文件将通过Copy –-from=…指令复制到最终图像中,该图像仅包含.NET 运行时。当讨论 Visual Studio 对 Docker 的支持时,我们将更详细地分析这种可能性。
让我们继续一个非常简单的例子,以便熟悉 Dockerfile 指令以及操作 Docker 图像和容器的 shell 命令。
Docker Desktop:一个简单示例
为了在客户端计算机上使用 Docker,您需要安装Doker Desktop。请参阅技术要求部分中的安装说明。正如技术要求部分所述,所有示例都假设有一个安装了 WSL 并配置了 Linux 容器的 Windows 机器。
安装 Docker Desktop 后,您将拥有以下内容:
-
Docker 运行时,您可以从图像实例化容器,并在您的计算机上运行它们。
-
Docker 客户端,您可以使用它将 Dockerfile 编译成图像,并执行其他与 Docker 相关的 shell 命令。
-
Docker 本地仓库。您在计算机上编译的所有图像都将放置在这里。从这里,您可以将其移动到其他仓库。此外,在您的机器上创建容器之前,您需要在这里下载它们的图像。
为了展示 Docker 的强大功能,我们将从一个简单的 Java 示例开始。你会看到,你不需要 Java 运行时或 Java SDK 来编译和运行一个简单的 Java 程序,因为所有需要的都下载到了正在构建的镜像中。
让我们先创建一个文件夹来放置构建镜像所需的所有文件。让我们称它为 SimpleExample。
在这个文件夹中,放置一个包含以下简单代码的 Hello.java 文件:
class Hello{
public static void main(String[] args){
System.out.println("This program runs in a Docker container");
}
}
现在,在同一个文件夹中,我们只需要一个包含以下内容的 Dockerfile:
FROM eclipse-temurin:11
COPY . /var/www/java
WORKDIR /var/www/java
RUN javac Hello.java
CMD ["java", "Hello"]
eclipse-temurin 是一个 Java SDK。这将使我们能够在我们的镜像和容器中编译和执行 Java 代码。然后,代码将我们的文件夹中的所有内容复制到正在构建的镜像中的新创建的 /var/www/java 路径。请记住,源中的相对路径是根据 Dockerfile 的位置评估的。
最后,我们移动到 var/www/java 文件夹并运行 Java 编译器,它将在同一文件夹中创建一个 .jar 文件。CMD 指令指定在基于此镜像的容器执行时调用之前创建的 .jar 文件中的 Java 命令。
现在,我们需要在 SimpleExample 文件夹中打开一个 Linux shell 来执行 Docker 命令。同时按住 shift 键,右键单击 SimpleExample 文件夹的图标,并从出现的菜单中选择打开 Linux shell 的选项。
作为第一步,我们需要 构建 我们的 Dockerfile 指令来创建一个镜像。这通过 build 命令完成,如下所示:
docker build ./ -t simpleexample
第一个参数指定 Dockerfile 的位置,而 -t 选项指定要附加到镜像的标签(镜像 URL),在我们的例子中是 simpleexample。由于镜像将被放置在我们的本地 Docker Desktop 注册表中,指定 URL 的存储库部分就足够了,但如果你有多个本地镜像,你也可以添加一个命名空间来更好地分类你的镜像。通常,在这个阶段,不会添加版本标签,Docker 假设 latest 默认标签。
记住:所有镜像名称必须是小写!
编译可能需要几秒钟。如果你在编译时查看控制台,你可以看到其他镜像正在递归下载,因为每个镜像都是建立在其他镜像之上的,依此类推。
现在,运行 docker images 命令以查看本地注册表上定义的所有镜像。你应该在其中看到 simpleexample。镜像也列在当你双击桌面上的 Docker Desktop 图标时出现的 UI 中。
现在,让我们基于新创建的镜像创建一个容器。run 命令基于给定的镜像创建一个容器并立即执行它:
docker run --name myfirstcontainer simpleexample
--name 选项指定容器的名称,而其他参数是我们想要用来创建容器的镜像名称。容器打印出我们放在 Java 类中的字符串,然后快速退出。
让我们使用docker ps列出所有正在运行的容器。自从我们的容器完成执行后,没有列出任何容器。然而,我们也可以使用--all选项看到所有非运行容器:
docker ps --all
让我们重新执行我们的容器。如果我们重新执行run命令,我们将创建另一个容器,所以重新执行休眠容器的正确方法是以下这样:
docker restart myfirstcontainer
然而,在这种情况下,控制台没有打印任何字符串,因为restart将容器运行到另一个进程中。你可能觉得这很奇怪,但并不奇怪,因为容器通常运行一个永不结束的循环,这可能会阻塞你的 shell。
永无止境的容器可以用类似以下的方法停止:
docker stop myfirstcontainer
当你完成你的容器后,你可以使用以下命令删除它:
docker rm myfirstcontainer
现在,你也可以使用以下命令删除创建容器时使用的镜像:
docker rmi simpleexample
你已经学到了很多有用的 Docker shell 命令。下一节将专门介绍一些更高级的有用命令。
一些更多的 Docker 命令和选项
在微服务操作期间,Docker 容器会在不同的硬件节点之间移动以平衡负载。不幸的是,当容器被移除以在其他地方创建时,其文件系统中保存的所有文件都会丢失。因此,容器文件系统的一部分被映射到外部存储,通常由网络磁盘单元提供。
这是因为run命令有一个选项可以将主机机器上的目录(比如S)映射到容器内部存储空间中的目录(比如D),这样写入D的文件实际上保存在S中,并且在容器删除后仍然安全。这个操作称为绑定挂载,将其添加到run命令的选项如下:
docker run -v <host machine path>:<container path> ...
另一个选项允许将容器公开的每个端口映射到主机计算机的实际端口:
docker run -p <host machine port>:<container port> ...
此选项可以重复多次以映射多个端口。没有此选项,将无法将网络流量重定向到容器内部。
-e选项将操作系统环境变量传递给容器。容器中运行的代码可以轻松地向操作系统询问这些变量的值,因此它们是配置应用程序的首选方式:
docker run -e mayvariable1=mayvalue1 -e mayvariable2=mayvalue2\. ..
run命令的另一个有用选项是-d选项(d代表detached):
docker run -d ...
当提供此选项时,容器将从当前 shell 提示符分离启动,即在不同的进程中。这样,托管永不结束程序(如 Web 服务器)的容器不会阻塞 shell 提示符。
每个镜像都可以附加到无限数量的标签,这些标签可以用作替代名称:
docker tag <image name> <tag>
标记是将本地镜像推送到公共仓库的第一步。假设我们有一个名为 myimage 的镜像,我们希望将其推送到我们在 Azure 上的私有仓库,例如 myregistry.azurecr.io/。假设我们希望将此镜像放置在仓库的 mypath/mymage 路径中,即 myregistry.azurecr.io/mypath/mymage。
作为第一步,我们使用以下方式对我们的镜像进行标记:
docker tag myimage myregistry.azurecr.io/mypath/mymage
然后,执行一个使用附加到镜像的新标记的 push 操作就足够了:
docker push myregistry.azurecr.io/mypath/mymage:<version>
将公共仓库镜像拉取到我们的本地仓库是直接的:
docker pull myregistry.azurecr.io/mypath/myotherimage:<version>
在与需要登录的仓库交互之前,我们必须执行登录操作。每个仓库都有自己的登录过程。
使用 Azure CLI 登录 Azure 仓库是最简单的方式。您可以从这里下载其安装程序:aka.ms/installazurecliwindows。
作为第一步,使用以下方式登录到您的 Azure 账户:
az login
此命令应启动您的默认浏览器,并引导您通过 Azure 账户的手动登录过程。一旦登录到 Azure 账户,您可以通过输入以下命令登录到您的私有仓库:
az acr login --name <registryname>
在这里,<registryname> 是您 Azure 仓库的唯一名称,而不是其完整 URL。登录后,您可以自由地使用您的 Azure 仓库。
Visual Studio 对 Docker 有原生支持。让我们分析这种支持提供的所有可能性。
Visual Studio 对 Docker 的支持
通过在适当的 Visual Studio 项目选项中简单地选择 启用容器支持 复选框,可以启用 Docker 对 Visual Studio 的支持。让我们用 ASP.NET Core MVC 项目进行实验。在项目选择之后,并在选择了项目名称,例如 DockerTest 之后,你应该到达以下选项页面:

图 3.15:启用 Docker 支持
请检查 启用容器支持 复选框。
如果您在这里忘记了启用 Docker 支持,您始终可以在 Visual Studio 解决方案资源管理器中的项目图标上右键单击,然后选择 添加 -> Docker 支持。
项目包含一个 Dockerfile:

图 3.16:Visual Studio Dockerfile
点击 Dockerfile;它应该包含四个镜像的定义。实际上,最终镜像是在四个阶段中构建的。
第一阶段定义了最终镜像中应用程序使用的 .NET 运行时和端口:
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
在同一文件中,AS 后面的 base 名称将被其他 FROM 指令调用。第二阶段通过使用 dotnet SDK 执行项目构建:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["DockerTest/DockerTest.csproj", "DockerTest/"]
RUN dotnet restore "./DockerTest/DockerTest.csproj"
COPY . .
WORKDIR "/src/DockerTest"
RUN dotnet build "./DockerTest.csproj" -c $BUILD_CONFIGURATION -o /app/build
ARG 指令定义了一个变量,可以在其他指令中以 $BUILD_CONFIGURATION 的形式调用。在这里,它被用来定义构建时选择的配置。您可以用 Debug 替换其值以在调试模式下编译。
第一条Copy指令仅将图像/src/DockerTest目录中的项目文件复制。然后,Nuget 包被还原,并将包含 Dockerfile 的目录中的所有源文件复制到当前图像目录/src。最后,我们进入/src/DockerTest并执行构建。构建输出文件放置在图像中的/app/build目录。
第三阶段是在build图像之上构建的,并且简单地发布/app/publish文件夹中的项目文件:
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./DockerTest.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
我们可以将阶段 2 和 3 合并为一个阶段,但将阶段拆分成更小的阶段更方便,因为中间图像被缓存,所以在后续构建中,当图像输入没有变化时,将使用缓存图像而不是重新计算它们。
最后,第四个也是最后一个阶段是在第一个阶段之上构建的,因为它只需要.NET 运行时,并且简单地从第三阶段创建的图像中复制已发布的文件:
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "DockerTest.dll"]
现在,在HomeController.cs文件的Index方法中设置一个断点并运行解决方案。Visual Studio 会自动构建 Dockerfile 并运行图像。
由于 Visual Studio 能够在容器图像内执行调试,因此断点将被触发!
当应用程序运行时,对于每个容器,Visual Studio 会显示日志、环境变量、绑定挂载以及其他信息:

图 3.17:Visual Studio 容器控制台
你也可以在容器内部获得一个交互式 shell,你可以在其中探索容器的文件系统,执行 shell 命令,并执行诊断和性能测量操作,只需打开一个 Linux shell 并输入以下命令:
docker exec -it <container-name-or-id> /bin/bash
在我们的案例中,让我们使用docker ps列出所有正在运行的容器以获取我们的容器 ID:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
f6ca4537e060 dockertest "dotnet --roll-forwa…" 17 minutes ago Up 17 minutes 0.0.0.0:49154->8080/tcp, 0.0.0.0:49153->8081/tcp DockerTest
然后,运行以下命令:
docker exec -it DockerTest /bin/bash
现在,你已经进入了容器文件系统!让我们尝试一些 shell 命令,例如Is,例如。当你完成容器操作后,只需运行exit命令即可返回到你的主机计算机控制台。
摘要
本章描述了微服务架构的两个重要构建块:洋葱架构和 Docker 容器。本章描述了洋葱架构的基本原则以及应用程序服务和领域层的组织方式。更具体地说,我们描述了命令、查询、事件及其处理程序,以及聚合和值对象。
此外,由于 Visual Studio 提供的解决方案模板,你学会了如何在 Visual Studio 解决方案中使用上述概念。
本章解释了容器的重要性、如何构建 Dockerfile 以及如何在实践中使用 Docker shell 命令。最后,本章描述了 Visual Studio 对 Docker 的支持。
下一章将重点介绍 Azure 函数及其主要触发器。
问题
- 域层项目必须引用数据库驱动程序项目,这是真的吗?
不,这是错误的。必须将驱动程序的引用添加到基础设施层。
- 哪些解决方案项目包含在应用服务的引用中?
只有那些属于域层的项目。
- 哪些解决方案项目包含在洋葱架构最外层项目的引用中?
应用服务、数据库驱动程序以及所有基础设施驱动程序。
- 一个聚合体总是对应一个唯一的数据库表,这是真的吗?
不,这是错误的。
- 为什么需要域事件?
它们被需要来解耦不同聚合的代码。
WORKDIRDockerfile 指令的目的是什么?
设置镜像当前目录。
- 如何将环境变量传递给容器?
通过 docker run 命令的 -e 选项。
- 什么是持久化存储 Docker 容器的正确方法?
卷绑定是持久化存储 Docker 容器的方式。
进一步阅读
-
更多关于查询、命令和域层的信息可以在这里找到:
udidahan.com/2009/12/09/clarified-cqrs/ -
更多关于 Docker 的信息可以在 Docker 的官方网站找到:
docs.docker.com/
加入我们的 Discord 社区。
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第四章:可用的 Azure Functions 和触发器
本书的前三章涵盖了无服务器和微服务背景,重点介绍了如何使用这些技术来设计一个与基于微服务架构的应用程序。本章和接下来的章节将深入探讨您编写代码的选项,使用在第二章“揭秘微服务应用程序”中提出的共享汽车示例。
要做到这一点,在本章中,我们将介绍 Azure Functions 中可用的不同触发器。这里的重点不仅仅是写关于它,还要测试每个触发器。在第一章,“揭秘无服务器应用程序”,我们介绍了其基础知识,但当时没有机会实现它们。
在本章中,我们将重点关注在实现 Azure Functions 时可以使用的三个重要触发器——HTTP、SQL 和 Cosmos DB 触发器。我们将讨论它们的优缺点以及何时是使用它们的良好方法。我们还将通过共享汽车示例来了解每个触发器的目的。让我们开始吧!
技术要求
本章需要免费版的 Visual Studio 2022 社区版 或 Visual Studio Code。您还需要一个 Azure 账户来创建示例环境。您可以在 github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp 找到本章的示例代码。
HTTP 触发器
在 Azure Functions 中,最常用的触发器无疑是 HTTP 触发器。此选项的基础是允许您拥有 HTTP 请求,因此您可以非常快速地构建 API、webhooks 和集成。其理念是 Azure Functions 中的方法在收到 HTTP 请求时立即触发,使适当的函数返回相应的响应。
优势、劣势以及何时使用 HTTP 触发器
HTTP 触发器的最大优势是它的易用性。实现起来简单直接,可以快速设置。因此,即使您是 Azure Functions 的初学者,也可以快速开始使用它。
此外,它支持多种 HTTP 方法,如 GET、POST、PUT 和 DELETE,允许您处理各种网络请求和操作。您还可以在同一个应用程序上运行多个函数,因此这是一种交付微服务的绝佳方式。
HTTP 触发器的另一个巨大优势是它们可以与其他 Azure 服务和第三方 API 集成,因此您可以处理复杂的逻辑。所有这些好处都建立在 Azure Functions 提供的可扩展性和成本效益之上,因此您的应用程序在高流量下将保持响应,并且您只需为执行的代码付费。
当谈到安全性时,HTTP 触发器使我们能够实现不同级别的授权。这些级别从匿名访问到 AuthorizationLevel 枚举中描述的管理员级别不等。

图 4.1:授权级别 – 来源:Microsoft Learn
值得注意的是,这些密钥在 Azure Functions 应用程序内部管理,正如我们在以下图中可以看到的。

图 4.2:Azure Functions – 应用密钥
当谈到 HTTP 触发器的缺点时,有一个被称为冷启动延迟的现象,即在一段时间的不活跃后第一次调用函数时必须有一个延迟。此外,你必须考虑到这种应用程序的理念是提供无状态解决方案,因此仅使用 HTTP 触发器处理有状态操作或长时间运行的过程可能会更具挑战性。为此,你可能考虑使用 Azure Durable Functions。
你可能还会遇到一些资源限制,例如执行超时和使用的内存,但这些限制通常意味着你遇到了设计问题。
考虑到提供的信息,HTTP 触发器最适合在需要创建轻量级、无状态函数以响应用户 Web 请求的场景中使用。这可能包括暴露应用程序功能或微服务的 RESTful API、处理实时通知的 webhooks,甚至驱动集成。HTTP 触发器也可以用于快速测试场景,将其用作原型。
共享汽车 HTTP 触发器示例
正如我们在第二章中讨论的,《揭秘微服务应用程序》,车主的请求可以在 CRUD 操作中被用户调用。本章提供的示例代码将为你提供一个包含四个 HTTP 触发器函数的 Azure Functions 项目,这些函数代表这些 CRUD 操作。此外,重要的是要提到,今天将带有 OpenAPI 文档的 API 交付视为一种良好的实践。为此,此示例将使用 Azure Functions 的 OpenAPI 扩展。结果可以在以下图中看到,其中我们描述了创建的每个 Azure Functions HTTP 触发器。

图 4.3:汽车持有 API 示例
使用这种模式交付 API 的好处是,你将遵循当前行业请求的最常见 API 场景。此外,交付版本化 API 被认为是一种很好的实践,这样你可以确保与其他系统的兼容性。
Azure SQL 触发的优点、缺点以及何时使用
想象一下,当 Azure SQL 数据库发生更改时,立即触发一个函数的可能性。这就是 Azure SQL 触发器能帮助您的地方。有了监控插入、更新或删除的行的可能性,这个函数在事件发生时立即被调用,从而实现实时数据处理和集成。
重要的是要提到,此触发器仅在您的数据库和定义要监控的表中启用了 SQL Server 更改跟踪时才可用。
考虑到这种可能性,使用此功能进行实时处理是一个巨大的优势。由于 Azure Functions 通常只在需要时才是一种实现可伸缩性的好方法,因此此功能也为我们提供了具有高成本效益的出色架构,使我们能够将其与不同的场景和应用集成。
另一方面,您需要注意设置这些触发器的复杂性。您必须考虑什么更容易设计,是一个监控数据的计时器触发器,还是这种触发器提供的选项。延迟也可能成为一个问题,所以请小心。
当然,Azure SQL 触发器在实时数据处理中非常适用,因为数据库的更改可能对某些操作至关重要。如果您想同步、审计甚至转换数据,这也会很有用。
Car-sharing SQL 触发器示例
对于这个演示,创建了一个名为CarShareDB的 Azure SQL 数据库。此外,还创建了一个名为Carholder的表,数据库和表都启用了更改跟踪,如下面的脚本所示:
ALTER DATABASE [CarShareDB]
SET CHANGE_TRACKING = ON
(CHANGE_RETENTION = 2 DAYS, AUTO_CLEANUP = ON);
CREATE TABLE [dbo].Carholder NOT NULL,
CONSTRAINT [PK_Carholder] PRIMARY KEY CLUSTERED
(
[Id] ASC
)WITH (STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Carholder]
ENABLE CHANGE_TRACKING;
这种 Azure 函数背后的想法是能够审计正在跟踪的表中做出的更改。因此,创建了一个具有 SQL 触发的 Azure 函数。
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Extensions.Sql;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AuditService
{
public class Audit
{
private readonly ILogger _logger;
public Audit(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<Audit>();
}
[Function(“Audit”)]
public void Run(
[SqlTrigger(“[dbo].[Carholder]”, “CarShareConnectionString”)] IReadOnlyList<SqlChange<Carholder>> changes,
FunctionContext context)
{
_logger.LogInformation(“SQL Changes: “ + JsonConvert.SerializeObject(changes));
}
}
public class Carholder
{
public int Id { get; set; }
public string Name { get; set; }
}
}
在此代码中,有三件重要的事情需要注意。第一点是,这个 Functions 应用需要一个名为WEBSITE_SITE_NAME的变量。这个变量需要放置在local.settings.json文件中用于本地调试,并在发布时存储在应用的环镜变量中。下面显示的代码块是我们提到的json文件的内容,定义了WEBSITE_SITE_NAME变量:
{
“IsEncrypted”: false,
“Values”: {
“AzureWebJobsStorage”: “UseDevelopmentStorage=true”,
“FUNCTIONS_WORKER_RUNTIME”: “dotnet-isolated”,
“WEBSITE_SITE_NAME”: “AuditApp”
}
}
第二,代码和 SQL Server 之间通过CarShareConnectionString变量建立了连接,该变量存储在本地用户密钥中,如下面的图所示。

图 4.4:本地管理用户密钥
最后要注意的是,您需要定义表示被监控实体的类,以便表中的每个更改都会触发,并且与更改相关的数据将可用于使用。在我们提供的示例中,这个类被命名为Carholder。

图 4.5:函数触发器
每个触发器的结果都可以在上面检查。请注意,插入和更新操作会发送完全填充的对象,而删除操作只返回对象的 ID。
Cosmos DB 触发器的优势、劣势以及何时使用
正如我们讨论了使用 Azure SQL 触发器时的好处和缺点一样,我们也可以讨论 Cosmos DB 触发器。这是一个强大的功能,允许你在 Cosmos DB 数据发生变化时执行无服务器函数。无论在 Cosmos DB 集合中添加、更新还是删除项目,触发器都会自动调用你的函数,从而实现实时数据处理和集成。
考虑到这个场景,重要的是要提到 Azure Cosmos DB 为你提供了处理数据的更多灵活性,因为它支持非结构化数据。例如,假设你想处理共享汽车发送的遥测数据。这种数据在 Azure SQL 数据库中处理可能会有些奇怪。另一方面,在 Cosmos DB 中使用这些数据可能是一个好的方法。
这些显著优势可以与你在使用 Cosmos DB 触发器开发解决方案时可能产生的某些担忧一起分析。其中最重要的一个考虑因素是成本,因为 Cosmos DB 应用程序的成本可能会非常高,具体取决于所开发的解决方案。
汽车共享 Cosmos DB 触发器示例
对于高性能和全球分布式的数据存储,假设汽车共享应用程序使用 Cosmos DB 存储实时汽车遥测数据,以及用户活动日志和位置信息。
下图显示了如何创建一个 Azure Functions 应用程序,以实现与 Azure Cosmos DB 的连接。

图 4.6:创建 Azure Cosmos DB 触发器函数
提到这一点很棒,因为有一个 Azure Cosmos DB 模拟器,你可以用它来测试和调试你的解决方案,从而节省开发这一步骤的成本。为此,你需要安装 Docker。重要的是要记住,这仅是测试的替代方案;生产环境必须使用 Azure Cosmos DB 本身。
然而,也应指出,Visual Studio 也可以帮助你创建 Azure Cosmos DB。正如你在以下图中可以看到的,有一个向导,你可以设置在 Visual Studio 环境中创建 Azure 账户中资源的常用变量。

图 4.7:创建 Azure Cosmos DB
创建 Azure Cosmos DB 需要一些时间。一旦完成这一步,就需要分析函数触发器的工作方式。请注意,它也是基于数据库的连接字符串和你要监控的信息来工作的。在示例中,正在监控 car-telemetry:
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace TemeletryService
{
public class Telemetry
{
private readonly ILogger _logger;
public Telemetry(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<Telemetry>();
}
[Function(“Telemetry”)]
public void Run([CosmosDBTrigger(
databaseName: “carshare-db”,
containerName: “car-telemetry”,
Connection = “CosmosDBConnection”,
LeaseContainerName = “leases”,
CreateLeaseContainerIfNotExists = true)] IReadOnlyList<CarTelemetry>
input)
{
if (input != null && input.Count > 0)
{
_logger.LogInformation(“Documents modified: “ + input.Count);
_logger.LogInformation(“First document Id: “ + input[0].carid);
}
}
}
public class CarTelemetry
{
public string carid { get; set; }
public DateTime Date { get; set; }
public string Data { get; set; }
}
}
要测试该函数,你可以使用 Azure 门户中提供的 Azure Cosmos DB 用户界面。

图 4.8:向 Azure Cosmos DB 插入数据
结果可以通过在 Azure 函数的代码中插入断点来检查,我们可以检查发送的数据是否可以在代码中看到。

图 4.9:Azure Cosmos DB 触发器
尽管 Azure Cosmos DB 触发器与 Azure SQL 触发器非常相似,但重要的是要提到,Azure Cosmos DB 触发器仅监控 Cosmos DB 中的插入和更新操作。因此,如果您需要监控删除操作,这种触发器类型将不会提供此选项。
Azure Service Bus 触发器
在微服务解决方案中,最重要的组件之一是用于在微服务之间启用通信的服务总线。Azure Service Bus 是市场上提供的选项之一。
Azure Functions 提供了两种连接到 Azure Service Bus 的方式。您可以监控特定的队列或通用的主题。Azure Service Bus 队列服务的概念是提供一个解决方案,该解决方案能够确保分布式应用程序和服务之间可靠的通信。它基于先进先出(FIFO)的原则运行,确保消息按照发送的顺序进行处理。如果您需要通过在高峰负载期间缓冲消息来解耦应用程序、增强可伸缩性和保持高可用性,您可能决定使用它。重要的是要记住,发送到队列的消息将被存储,直到它们被接收应用程序检索和处理,即使在短暂故障的情况下也能保证交付。值得提及的是,Service Bus 队列支持诸如按顺序处理的消息会话、处理消息失败时的死信队列以及防止处理重复消息的重复检测等功能。
另一方面,Azure Service Bus 主题是为需要发布/订阅模型的情况而设计的。这个特性使得多个订阅者可以接收相同消息的副本,从而在您的消息基础设施中提供更大的灵活性和可伸缩性。使用主题,您可以基于特定标准过滤消息,确保每个订阅者只接收与他们相关的消息。这在复杂的流程中特别有用,其中不同的组件或服务需要响应不同类型的事件。
Azure Service Bus 触发器同样可以为您的解决方案提供可伸缩性、可靠性、集成和灵活性,因为这是任何 Azure 函数默认提供的服务。作为一个需要注意的点,再次强调,成本必须被考虑。值得注意的是,队列比主题便宜,因此您可能需要分析是否真的需要主题。此外,别忘了检查您所需的应用程序性能不会因为所选的服务总线而降低。
当你在开发事件驱动的解决方案,并希望处理消息或设计工作流自动化时,可以使用 Azure 服务总线触发器。例如,在共享汽车的例子中,我们将使用触发器来表示有人正在寻找汽车。
与 Kafka 触发器和 RabbitMQ 触发器的比较
Azure Functions 服务总线触发器、Kafka 触发器和 RabbitMQ 触发器都服务于类似的目的。然而,根据你正在处理的场景,你可能决定选择不同的总线。
例如,Kafka 因其在需要分布式流处理和实时数据处理的高吞吐量场景中而闻名。
另一方面,RabbitMQ 更易于使用,对于轻量级和灵活的消息来说更好,特别是如果你需要与多个消息协议兼容的话。
Azure 服务总线与 Azure 服务集成良好,尽管它支持各种消息模式。如果你需要可靠的交付和处理,这可能是最佳选择。
如你所见,每个总线都有其优势,适合不同类型的应用。Azure Functions 中为它们提供的触发器非常相似,因此选择正确的触发器更多地取决于你正在设计的应用程序的具体要求。
使用 Azure 服务总线触发器的共享汽车示例
在我们的例子中,Azure 服务总线触发器用于订阅表示寻找汽车请求的消息。在这里使用主题的想法是,解决方案的许多微服务可能都想知道正在发起寻找汽车的请求。我们模拟的示例服务是启动所需汽车路线规划器的服务,如下面的图所示。

图 4.10:共享汽车应用的路由处理子系统
为了做到这一点,创建了一个用于监控主题 car-seeking-requests 中服务总线触发器的 Azure 函数。从这个主题订阅的消息被命名为 routes:
using Azure.Messaging.ServiceBus;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace RoutesPlanner
{
public class CarSeeking
{
private readonly ILogger<CarSeeking> _logger;
public CarSeeking(ILogger<CarSeeking> logger)
{
_logger = logger;
}
[Function(nameof(CarSeeking))]
public async Task Run(
[ServiceBusTrigger(“car-seeking-requests”, “routes”,
Connection = “car-share-bus”)]
ServiceBusReceivedMessage message,
ServiceBusMessageActions messageActions)
{
_logger.LogInformation(“Message ID: {id}”, message.MessageId);
_logger.LogInformation(“Message Body: {body}”, message.Body);
_logger.LogInformation(“Message Content-Type: {contentType}”,
message.ContentType);
// Complete the message
await messageActions.CompleteMessageAsync(message);
}
}
}
}
一旦发送了路线消息,函数会自动触发,消息体中呈现的所有信息,以及关于消息内容类型和其 ID 的信息,都可在 Azure 函数中获取。

图 4.11:Azure 服务总线接收到内容后触发的消息
重要的是要注意,一旦消息被 Azure 函数处理,由于没有其他订阅者,消息将从总线上清除。还需要记住,如果函数没有运行,总线服务将根据在 Azure 服务总线中配置的设置保留它们。
摘要
本章提供了 Azure Functions 中各种触发器的全面概述,重点关注它们的优缺点和实际用例。然后深入探讨了特定的触发器,从易于使用且在处理网络请求方面具有灵活性的 HTTP 触发器开始。本章还提到了支持多种 HTTP 方法以及与其他 Azure 服务集成的优势。
本章还涵盖了 Azure SQL 触发器,强调了它们的实时数据处理能力以及 SQL Server 变更跟踪的要求。同样,Cosmos DB 触发器也得到了解释,其处理非结构化数据和实时处理的优势也得到了展示。
最后,本章比较了 Azure 服务总线、Kafka 和 RabbitMQ 服务,展示了使用 Azure 服务总线触发器为书中提到的拼车应用程序进行演示。
问题
- 在 Azure Functions 中使用 HTTP 触发器的主要优势是什么?
HTTP 触发器提供了一个简单且标准化的方式来将你的函数作为网络端点公开,这使得创建 API 和 webhook 变得容易。它们允许快速开发和与其他网络服务及客户端应用程序的集成,利用熟悉的 HTTP 方法和状态码进行通信。
此外,HTTP 触发器还支持自动扩展,因此你的函数可以高效地处理变化的负载。这有助于确保在流量波动的情况下,你的应用程序保持响应,同时从优化成本的按需付费定价模型中受益。
- 使用 HTTP 触发器的潜在缺点有哪些,以及如何减轻这些缺点?
一个潜在的缺点是冷启动的发生,尤其是在消耗计划中,这可能导致在初始 HTTP 请求期间出现延迟。此外,通过 HTTP 公开函数需要仔细关注安全性,因为配置错误的端点可能会变得容易受到未经授权的访问或滥用。
通过实施策略,如使用高级或专用计划以减少冷启动延迟,添加预热触发器,或强制执行强大的身份验证和授权策略,可以减轻这些担忧。利用 API 管理或其他网关解决方案也可以有效地帮助保护和管理工作通过 HTTP 触发的函数。
- Azure SQL 触发器如何实现实时数据处理,以及它的要求是什么?
尽管 Azure Functions 不包含本机 SQL 触发器,但通过结合数据库变更检测(使用 SQL 变更跟踪或变更数据捕获)与轮询或监听变更的函数,可以实现实时数据处理。这种方法使系统能够几乎立即对数据修改做出反应,一旦检测到变更就触发处理工作流。
为了实现这一点,您的 Azure SQL 数据库必须启用更改跟踪或 CDC,并且您需要配置一个可靠的机制,以便定期查询更改。适当的连接性、高效的查询设计以及处理潜在的延迟问题是确保实时处理既准确又高效的关键要求。
- 使用 Azure Functions 中的 Cosmos DB 触发器有哪些好处和担忧?
Cosmos DB 触发器通过利用 Cosmos DB 变更流提供近实时数据处理。这种集成允许您的函数自动响应新或更新的文档,从而实现事件驱动的流程和可扩展的数据处理,无需手动轮询。
然而,如果吞吐量没有得到适当管理,可能会出现潜在的节流和成本影响。此外,确保数据一致性和处理高容量变更流可能具有挑战性。这些问题可以通过仔细规划请求单位(RUs)、分区策略以及监控 Cosmos DB 账户的性能和负载来解决。
- Azure 服务总线触发器与 Kafka 和 RabbitMQ 触发器相比如何,它们在哪些场景下使用最佳?
Azure 服务总线触发器是完整托管消息服务的一部分,它提供可靠的消息传递、死信队列、会话和自动扩展等功能。它们与 Azure 生态系统无缝集成,使它们成为需要强大、安全和管理消息处理的企业的理想选择。
与之相反,Kafka 和 RabbitMQ 通常因其高吞吐量(Kafka)或轻量级、灵活的消息传递(RabbitMQ)而被选择,在可能需要更多基础设施控制的环境中。Azure 服务总线触发器最适合那些从与 Azure 深度集成的托管服务中受益的场景,尤其是当应用程序需要企业级消息可靠性和可扩展性而不需要管理消息基础设施的开销时。
进一步阅读
-
Azure Function HTTP 触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-http-webhook-trigger -
Azure Function SQL 触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-azure-sql -
SQL Server 更改跟踪:
learn.microsoft.com/en-us/sql/relational-databases/track-changes/about-change-tracking-sql-server -
Azure Functions Cosmos DB 触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-cosmosdb -
Azure Functions 服务总线触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus -
队列设计模式:
learn.microsoft.com/en-us/azure/architecture/patterns/queue-based-load-leveling -
发布者-订阅者设计模式:
learn.microsoft.com/en-us/azure/architecture/patterns/publisher-subscriber -
存储机密:
learn.microsoft.com/en-us/aspnet/core/security/app-secrets
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第五章:实践中的后台函数
当你开始使用云计算时,尤其是在使用平台即服务(PaaS)时,你可能会遇到的一个挑战是如何在基于需要请求处理实例的解决方案中启用后台工作。解决这个问题的一个答案就是使用无服务器来处理这个后台任务。在 Azure 中,你会发现 Azure Functions 触发器可以帮助你完成这项工作。
在本章中,我们将讨论其中三个:定时触发器、Blob 存储触发器和队列存储触发器。重要的是要提到,我们在第一章**,揭秘无服务器应用程序中介绍了它们的基础知识,但现在我们将开始实施它们。
我们将介绍 Azure Functions 在 Visual Studio 内部发布的替代方案,同时也会检查如何监控这些函数。在章节中,我们将讨论这些函数的优点和缺点,以及何时使用这些函数是一个好的方法。我们还将使用拼车示例来展示它们的工作情况,以便更好地理解每个触发器的目的。让我们开始吧!
技术要求
本章需要 Visual Studio 2022 免费社区版或 Visual Studio Code。你还需要一个 Azure 账户来创建示例环境。你可以在这个章节的github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp找到示例代码。
定时触发器
有时需要不时地在一天中的特定时刻处理一个任务,这是很常见的。定时触发器肯定会帮助你完成这项工作。这个函数基于NCRONTAB表达式,类似于CRON表达式:
{second} {minute} {hour} {day} {month} {day-of-week}
如果你考虑这个表达式,你将能够安排不同的时刻来触发函数。让我们查看以下表格以更好地理解它:
| 秒 | 分 | 时 | 日 | 月 | 星期 | 结果 | 含义 |
|---|---|---|---|---|---|---|---|
| * | * | * | * | * | * | * * * * * * | 每秒 |
| 0 | * | * | * | * | * | 0 * * * * * | 每分钟 |
| */5 | * | * | * | * | * | */5 * * * * * | 每五秒 |
| 0 | 0 | 1 | * | * | 1-5 | 0 0 1 * * 1-5 | 在周一至周五的凌晨 1 点 |
| 5,10,20 | * | * | * | * | * | 5,10,20 * * * * * | 在每分钟的 5、10 和 20 秒 |
与NCRONTAB表达式相关的一些重要提示。首先,你可以考虑从星期日(0)到星期六(6)的星期几。*运算符代表在当前定义的所有值,而-是范围运算符。如果你想表示一个间隔,你可以使用/运算符,而如果你想定义一组值,则必须使用,运算符。
以下代码是一个定时器触发示例以及定义其计划的方式:
public class SampleFunction
{
private readonly ILogger _logger;
public SampleFunction(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<SampleFunction>();
}
[Function(“SampleFunction”)]
public void Run([TimerTrigger(“*/5 * * * * *”)] TimerInfo myTimer)
{
_logger.LogInformation($”C# Timer trigger function executed at:
{DateTime.Now}”);
if (myTimer.ScheduleStatus is not null)
{
_logger.LogInformation($”Next timer schedule at:
{myTimer.ScheduleStatus.Next}”);
}
}
}
有一些网站可以帮助你解释你设计的NCRONTAB表达式。你可以在 https://crontab.cronhub.io/ 上查看。
以下图显示了前面定时器触发代码的结果。

图 5.1:执行中的定时器触发功能
这种灵活性使你能够定义一个稳固的工作结构来运行你的微服务。另一方面,如果你想调试特定的函数,你可以使用设置为true的RunOnStartup参数。不过,重要的是要提到,这个参数在生产环境中不应使用。
有可能手动触发非 HTTP 函数。请查看此链接以进行操作:learn.microsoft.com/en-us/azure/azure-functions/functions-manually-run-non-http。
现在你已经了解了定时器触发功能的工作原理,让我们看看如何使用 Visual Studio 发布它们。
发布你的函数
当你在 Visual Studio 中开发 Azure 函数时,了解 IDE 允许你分步骤将代码发布到 Azure 是有用的。让我们看看如何做到这一点。
第一步是右键单击你想要发布的项目。一旦这样做,你将找到发布…操作来启动此过程。

图 5.2:发布 Azure 函数项目
一旦你决定发布,你将需要决定将函数发布到何处。除了 Azure,你可能还希望将函数发布到 Docker 容器注册库或文件夹中。你可能还希望使用预制的配置文件,因此还有一个选项可以导入配置文件。对于这个演示,将选择Azure选项。

图 5.3:在 Azure 上发布
在选择Azure之后,你需要决定你的函数将在 Azure 的哪个位置运行。正如我们在第一章**,揭秘无服务器应用程序中看到的,Azure 函数可以在不同的操作系统和不同的容器解决方案中运行。对于这个演示,我们将选择 Windows 操作系统。

图 5.4:选择 Windows 的 Azure 函数应用
在将 Visual Studio 连接到你的 Azure 账户后,所有可用于部署的函数实例都将呈现在你面前。然而,如果你没有任何实例,你也将有机会通过选择创建新实例按钮来创建一个新的实例。

图 5.5:在 Visual Studio 界面上创建 Azure 函数应用
创建过程需要几分钟,然后您的 Azure 函数就可以在 Azure 上发布了。

图 5.6:准备就绪的 Azure Function App
Visual Studio 中当前可用的向导非常有用。它不仅可以帮助您一步完成发布,还会为您创建一个 YML 文件,以便与 GitHub Actions 一起使用。对于这个演示,我们将使用生成 .pubxml 文件的基本选项,但您可以考虑 GitHub Actions 作为现实场景中的最佳选择。

图 5.7:可用的发布方法
一旦完成向导,您将拥有准备就绪的发布配置文件,可以开始发布应用程序。

图 5.8:发布分析器
通过点击 发布 按钮,过程将开始运行,几分钟后,您的 Azure Function App 将被发布。

图 5.9:Function App 已发布
重要的是要提到,您只需要完成这个完整过程一次。之后,所需的新部署将会容易得多。
监控您的函数
上节中介绍的部署函数的过程不仅限于定时器触发函数。在监控您的函数时也是如此。在 Azure 中,有一些其他方法可以检查您的 Azure 函数是否正常运行。让我们来探索一下。
监控函数是否正常运行的最简单方法是通过检查其调用的次数。调用次数选项卡在 Function App 中可用,它将为您提供关于执行的基本详细信息。

图 5.10:函数调用
然而,您可能希望获取每个执行的详细信息。在这种情况下,获取此类信息的最佳选项是通过访问 Azure Monitor 保留的日志。

图 5.11:Azure Monitor 日志
使用 Azure Monitor 日志会导致成本增加。请检查在 docs.azure.cn/en-us/azure-monitor/logs/cost-logs 存储日志的最佳替代方案。
Azure Monitor 存储的日志也将为您提供另外两个视图。Application Insights 的 性能 视图可以帮助您分析您开发的 Azure 函数中可能发生的性能和错误。

图 5.12:Application Insights 性能视图
函数运行时还有一个 实时指标 视图,这可能对调试或了解生产中的行为很有用。

图 5.13:Application Insights 实时指标视图
这些选项使 Azure Functions 成为处理您后台工作的绝佳替代方案,因为它提供的可观察性非常好。
Azure 定时器触发的优势、劣势以及何时使用
正如我们之前看到的,Azure 定时器触发器提供了一种在无需人工干预的情况下定期执行函数的绝佳方式。它们在设置和配置上的简单性有助于您创建定期运行的函数,例如数据同步、清理操作和计划报告。
然而,由于函数将在您计划的时间运行,如果那时没有工作要做,这种执行将导致资源浪费,这基本上意味着不必要地花钱。因此,您必须正确定义定时器触发函数的执行。
根据前面的信息,不能依赖人工手动干预的预定操作,如备份、常规维护任务和定期数据处理,是这类函数的绝佳用例。尽管定义一种监控和报告这些执行的方法很重要,但您仍然可以充分利用这个选项。
汽车共享定时器触发示例
汽车共享解决方案是一个事件驱动型应用。这意味着在处理其基本工作流程时,不需要为该应用设置定时器触发器。然而,让我们想象一个处理账单的常规流程。考虑到这家公司的业务规则,周日无法处理账单,而考虑到其他日子现金流的情况,账单可以每小时处理一次。
基于这个场景,定时器触发函数可以是一个很好的选择来解决这个问题,如下所示:
public class ProcessBilling
{
private readonly ILogger _logger;
public ProcessBilling(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<ProcessBilling>();
}
/// <summary>
/// Every hour, between 08:00 AM and 05:59 PM, Monday through Saturday
/// </summary>
/// <param name=”myTimer”></param>
[Function(“ProcessBilling”)]
public void Run([TimerTrigger(“0 0 8-17 * * 1-6”)] TimerInfo myTimer)
{
_logger.LogInformation($”Time to process billing!”);
_logger.LogInformation($”Execution started at: {DateTime.Now}.”);
// TODO - Code for processing billing
_logger.LogInformation($”Process billing done: {DateTime.Now}.”);
}
}
注意,无论是否有账单要处理,函数的执行将在每天早上 8:00 至下午 5:59 之间,从周一到周六每小时发生一次。重要的是要提到,Azure Functions 将尊重协调世界时(UTC),因此在定义正确的 CRON 表达式时,您应该考虑您的位置。
Blob 触发器
Azure Blob Storage 是由 Microsoft Azure 提供的一种服务,用于存储大量非结构化数据,如图像、视频、日志和备份。它优化了以高度可扩展和成本效益的方式存储二进制数据。Blob 代表 Binary Large Object,突出了其高效处理大量数据的能力,使其成为需要耐用、可扩展存储的应用的理想解决方案。
这个服务的优点是它具有高度的可扩展性、安全性和全球任何地方通过 HTTP 或 HTTPS 访问的便捷性。此外,它还支持与其他 Azure 服务,如 Azure Functions 的集成。这个连接器通过为特定 Blob 存储中每个更改执行函数,实现了各种可能的自动化流程解决方案。
本书重点不在于深入探讨 Blob 存储选项,但了解该服务提供不同的访问层级是有用的,例如热、冷和存档,这些层级根据访问需求而变化,每个层级都有自己的定价。
当您开始创建 Blob 存储触发函数时,您将被要求定义存储将运行的位置。对于调试,您将有机会使用 Storage Azurite 模拟器,这是一个 Azure 存储的本地模拟器。Azurite 与 Visual Studio 一起提供。根据您的 Visual Studio 版本,它将被放置在特定的文件夹中。找到可执行文件后,您可以使用管理员权限运行它。

图 5.14:Azurite 执行
在创建 Blob 存储触发函数时,另一个重要的工具是 Microsoft Azure Storage Explorer。有了这两个工具,创建 Blob 存储触发函数的过程将会非常简单。以下图示展示了 Visual Studio 如何使您能够将 Azurite 设置为项目的默认模拟器。

图 5.15:将 Blob 存储触发器连接到 Azure
Blob 存储触发器的优缺点及适用场景
当谈到 Blob 存储触发器的使用优势时,能够高效处理大量数据的能力当然可以提及。除此之外,无论传入触发器的数量如何,都有可能进行处理的扩展性也是您应该考虑此类触发器来处理数据的好理由。
另一方面,定价可能成为问题,因为在某些情况下,定价模式基于执行次数和数据处理量,所以不要忘记分析分配此类 Azure 函数的最佳方式。
还需要提到的是,根据您为 Azure 函数定义的应用计划,您可能会在文件上传或更新与函数处理之间经历一些延迟。为了避免这种情况,您可以考虑启用 Always On 的 App Service 计划,尽管这显然会增加解决方案的成本。
最后,重要的是要提到,最初的 Blob 存储触发函数实现是基于池化的。池化指的是对整个容器进行周期性扫描,通常每批处理高达 10,000 个 Blob。在此方法中,每个文件默认有最多五次重试尝试。如果所有重试都失败,函数将创建一个毒丸消息并将其移动到 webjobs-blobtrigger-poison 队列。为了避免此类场景并提高可靠性,您可以使用 Event Grid 实现一个 Blob 存储触发器。我们将在下一节中介绍这一点。
基于这些信息,您可以在需要图像处理、数据分析、实时或批量处理等软件要求的应用程序中使用 Blob 存储触发器。在这种类型的应用中,通常需要快速且自动地对新或更新的 Blob 做出反应。在这些情况下,Azure Functions 的可扩展性和适应性将帮助您满足您的需求。
使用事件网格实现的 Blob 触发实现
使用事件网格实现 Blob 触发事件背后的想法是减少延迟。此外,如果您决定使用 Flex 消费计划来定义您的函数,这将是你唯一的选择。
要做到这一点,在创建函数时,选择 Blob 触发(使用事件网格) 选项。使用此选项,Visual Studio 将为 Azure 函数创建不同的代码。

图 5.16:使用事件网格创建 Blob 触发函数
重要的是要提到,此函数在 Azure 上的运行效果会比本地更好。为此,您需要创建一个 通用 v2 存储账户,这对于事件订阅是强制性的。

图 5.17:存储账户创建的回顾
正如我们在 Azure 存储中有所要求,运行此类触发的函数应用应考虑使用 Flex 消费计划,正如以下图所示。根据微软的说法,这种消费计划的优势在于它通过始终准备好的实例减少了冷启动,支持虚拟网络,并在高负载期间自动扩展。另一方面,在撰写本书时,此选项并非在所有地区都可用。

图 5.18:Flex 消费计划
在创建 Azure 函数应用之后,您可以使用之前提供的步骤来发布该函数。本例中用于函数应用的名称为 flexfunction。值得注意的是,Flex 消费计划适用于基于 Linux 的操作系统。
以下代码显示了已发布的函数。请注意,在此示例中,Connection 参数是 "ConnectionStringName"。同时,请注意函数的名称是 SampleFunction:
public class SampleFunction
{
private readonly ILogger<SampleFunction> _logger;
public SampleFunction(ILogger<SampleFunction> logger)
{
_logger = logger;
}
[Function(nameof(SampleFunction))]
public async Task Run([BlobTrigger(“event-grid-samples/{name}”,
Source = BlobTriggerSource.EventGrid,
Connection = “ConnectionStringName”)] Stream stream, string name)
{
using var blobStreamReader = new StreamReader(stream);
var content = await blobStreamReader.ReadToEndAsync();
_logger.LogInformation($”C# Blob Trigger (using Event Grid) processed
blob\n Name: {name} \n Data: {content}”);
}
}
您需要此信息来设置 Azure 函数。"ConnectionStringName" 需要在函数应用的设置中定义为环境变量,如图所示。此配置的内容是创建的存储账户的连接字符串。

图 5.19:定义函数应用和存储账户之间的连接
之后,您将拥有定义函数应用中将要触发的事件所需的所有信息。请注意,事件发生在存储账户中,Event Grid 触发函数。为此,创建了一个 webhook。webhook URL 的定义可以在此处查看:
| 部分 | 模板 |
|---|---|
| 基础函数应用 URL | https://<FUNCTION_APP_NAME>.azurewebsites.net |
| Blob 特定路径 | /runtime/webhooks/blobs |
| 函数查询字符串 | ?functionName=Host.Functions.<FUNCTION_NAME> |
| Blob 扩展访问密钥 | &code=<BLOB_EXTENSION_KEY> |
Blob 扩展访问密钥可以在函数应用的 App Keys 部分找到。对于 blobs_extension 有一个特定的系统密钥。一旦您有了密钥,您就可以使用它来在 Azure 存储中创建一个新事件,如图中所示。

图 5.20: 在 Blob 存储中订阅事件
重要的一点是提到,您的 Azure 订阅可能尚未启用 Event Grid 资源提供者,并且在此禁用的情况下创建订阅时可能会发生错误。要启用资源提供者,您需要转到您的订阅账户并注册它。


在此配置之后,只需简单地将文件上传到定义的容器中,对于每个上传的文件,函数都会被触发,以低延迟模式。您可以使用函数的 调用 面板监控每个触发器。请注意,在图中,函数在最后几次调用中在同一秒内触发了四次,显示了触发函数处理大量文件的能力。


值得注意的是,此示例需要不同的组件,可能会产生额外的成本。因此,如果您只是尝试此选项,请务必注意不要让此演示在您的 Azure 账户中运行。另一方面,这将减少文件到达和处理之间的延迟,因此您可以考虑将其作为实际应用的不错方法。
共享汽车 Blob 存储触发器示例
考虑到我们在本书中展示的共享汽车用例,值得提到的是,可能包含在此解决方案中的服务之一是分析驾驶执照。为此,在前端应用程序中,将有一个用户界面用于将此重要文件上传到应用程序的业务逻辑。然而,由于此文件很重要,存储此类信息需要精心设计。一个好的选择是仅提取上传图像中所需的信息,然后创建该信息的哈希值,这样您就可以删除用户上传的文件。
要做到这一点,你可以创建一个专门处理驾照照片的函数。使用 Blob 存储触发器来完成这个任务可能是个好主意。
重要的是要提到,这个示例需要更新 Program.cs 文件。在这里,我们不会直接使用 FunctionsApplication 类,而是使用 HostBuilder,通过 ConfigureFunctionsWebApplication 方法配置 Azure Functions 应用程序。值得注意的是,在 .NET 8 的 Azure Functions 中,ConfigureFunctionsWebApplication() 启用了 ASP.NET Core 集成,而默认的 ConfigureFunctionsWorkerDefaults() 用于隔离工作模型,提供了对 .NET 版本和依赖项的更大灵活性和控制:
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration;
using CarShareBackground;
var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.ConfigureAppConfiguration(config =>
{
config.AddUserSecrets<ProcessDriversLicensePhoto>(optional: true,
reloadOnChange: false);
})
.Build();
host.Run();
AddUserSecrets 方法将用户密钥添加到配置中,这对于存储敏感信息(如 API 密钥或连接字符串)非常有用。在这种情况下,我们正在存储与 Blob 存储的连接。ProcessDriversLicensePhoto 类型用于识别包含用户密钥的程序集。optional: true 参数意味着如果找不到用户密钥文件,应用程序不会失败,而 reloadOnChange: false 表示如果用户密钥文件发生变化,配置不会自动重新加载。
一旦你定义了 Program.cs,你就可以创建一个 Azure 函数来处理 Blob 存储。函数本身定义起来相当简单,如下面的代码所示:
[Function(nameof(ProcessDriversLicensePhoto))]
public async Task Run([BlobTrigger(“drivers-license/{name}”,
Connection = “CarShareStorage”)] Stream myBlob, string name)
{
StreamReader reader = new StreamReader(myBlob);
var message = reader.ReadToEnd();
_logger.LogInformation(“File detected”);
}
BlobTrigger 属性定义了文件将在 Blob 存储的哪个位置上传,在本例中是在 drivers-license 文件夹中,其中 {name} 是 blob 名称的占位符,它将被作为字符串传递给 name 参数。文件的流将通过 myBlob 参数获得。
队列存储触发器
队列的原则相当为人所熟知,因为这是一种你想要控制数据以便“先进先出”的数据结构。当我们谈论 Azure Functions 中的队列存储触发器时,我们有机会异步且完全解耦地管理队列,这使得其使用非常强大。
在这个场景中,我们拥有的强大能力是高效处理大量消息的能力。Azure Functions 具有自动扩展的能力,并保证每个任务都将得到可靠和容错的处理。
考虑到这种方法,值得注意的是,无服务器应用程序将始终专注于开发最本质的需求——使该服务工作的业务逻辑。这就是为什么无服务器应用程序是实现微服务的好方法,因为处理基础设施的需求将减少。
队列存储触发器的优缺点及适用场景
如果你有一个必须控制数据队列的使用场景,队列存储触发器将是选择的好选项之一。这种方法能够高效地处理大量消息,这确实是一个优势。在这种情况下,你只需要关注将要实施的服务业务逻辑。
然而,定价模型基于执行次数和数据处理量,因此你必须对此有所了解,不要对解决方案相关的成本感到惊讶。还值得注意的是,可能会出现高负载或瞬态错误,作为开发者,你必须实现重试和错误处理机制,以确保你的解决方案得到良好实施。
考虑到我们讨论的所有内容,队列存储触发器可能是当你必须提供一个可靠且高效的解决方案来处理队列任务时的一个好选择。例如,如果你需要进行订单处理、后台作业调度或事件驱动的通知,这种解决方案可以是一个好的方法。现在,让我们检查一个场景,在汽车共享示例中,队列存储触发器可能是一个好的解决方案。
汽车共享队列存储触发器示例
考虑到汽车共享用例,可能使用队列存储触发器创建的服务之一是 My_Best_Matches 微服务。根据 第二章 中描述的汽车共享示例规范,揭秘微服务应用程序,所有路线的变化都发送到 My_Best_Matches 和 Route-Choosing 微服务。
考虑这个场景,假设路线的变化以 JSON 组件的形式排队在 Azure 存储队列中。这个 JSON 将指示有一个新的匹配项需要由 My_Best_Matches 微服务进行处理:
using Azure.Storage.Queues.Models;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
namespace My_Best_Matches
{
public class NewMatchTrigger
{
private readonly ILogger<NewMatchTrigger> _logger;
public NewMatchTrigger(ILogger<NewMatchTrigger> logger)
{
_logger = logger;
}
[Function(nameof(NewMatchTrigger))]
public void Run([QueueTrigger(“new-match”,
Connection = “CarSharingStorage”)] QueueMessage message)
{
_logger.LogInformation($”C# Queue trigger function processed:
{message.MessageText}”);
}
}
}
一旦你运行了这段代码,使用本地存储模拟器,你可以在 new-match 队列中放置一个消息。

图 5.23:将消息放入队列
放入队列的消息将被函数自动处理,然后从存储中删除。

图 5.24:Azure 函数输出
考虑这个场景,这条消息可以用来向车主和寻车者发送电子邮件,表明他们有一个新的匹配项,并且他们可以与系统交互,以确定是否接受提出的路线。
摘要
本章讨论了实现三个重要的 Azure 触发函数以实现后台服务——定时触发器、Blob 存储触发器和队列存储触发器。可以使用这种无服务器技术轻松实现处理例程、图像、数据和订单等任务。
在介绍这些类型的函数时,本章解释了如何发布和监控函数。它还介绍了一种更有效的方法来实现 blob 触发函数,即以 Event Grid 为基础,并减少文件上传和开始处理之间的延迟。
本章还解释了 Azure Functions 如何成为实现微服务的一个很好的方法。为此,它提供了三个与汽车共享用例相关的示例,其中使用此类解决方案将使开发者能够专注于软件开发真正重要的事情——编写正在开发的解决方案的业务逻辑代码。
现在,让我们进入下一章,本章将讨论如何以 Azure Functions 为基础启用 IoT 解决方案。
问题
- 定时触发函数的目的是什么?
定时触发函数旨在根据使用 NCRONTAB 表达式定义的计划执行代码。它允许开发者定期运行后台作业,而无需手动启动或 HTTP 请求。
这对于数据同步、清理操作、报告生成和定期计费等场景非常有用。它有助于自动化重复性任务,特别是那些不应依赖于人工交互来执行的任务。
- blob 触发函数的目的是什么?
当在特定的 Azure Blob Storage 容器中添加或修改文件时,blob 触发函数会自动响应。它为图像、日志或文档等非结构化数据启用事件驱动处理。
此触发器非常适合自动化涉及数据摄取、文件处理、图像分析或文档转换的工作流。它支持可伸缩性和与 Event Grid 的集成,以减少高性能场景中的延迟。
- 队列触发函数的目的是什么?
当在 Azure Queue Storage 中添加新消息时,队列触发函数会执行。它使分布式系统中的生产者和消费者能够异步处理任务,解耦生产者和消费者。
这种方法确保了可靠和可伸缩地处理队列任务,如后台处理、订单处理或通知,使开发者能够专注于业务逻辑,同时 Azure Functions 处理基础设施问题。
- blob 触发函数和队列触发函数之间有什么区别?
blob 触发函数对 Azure Blob Storage 中的文件更改做出反应,通常处理二进制或非结构化数据。它是事件驱动的,适用于文件上传、媒体处理或文档处理等场景。
相比之下,队列触发函数旨在处理来自 Azure Queue Storage 的基于文本的消息。它更适合于管理工作流、作业调度和基于消息的集成,在这些场景中,您需要显式控制任务顺序和执行。
- 我们如何减少文件上传和 blob 触发函数开始处理之间的延迟?
为了减少 blob 触发函数的延迟,建议使用基于 Event Grid 的 blob 触发器而不是基于轮询的触发器。Event Grid 通过在事件发生时推送事件,实现了近乎实时的处理。
此外,使用 Flex Consumption 计划或启用 Always On 的 App Service 计划有助于最小化冷启动时间。然而,这些方法可能会增加成本,因此应根据应用需求进行评估。
- 列出监控 Azure 函数的不同方法。
Azure 函数可以使用几个内置工具进行监控。Azure 门户中的调用选项卡提供基本指标,例如执行次数和执行状态。
为了获得更深入的见解,Azure Monitor 日志和 Application Insights(性能和实时指标视图)提供高级遥测、性能跟踪和实时诊断。这些工具有助于识别错误、分析趋势和有效地调试运行时行为。
进一步阅读
-
Azure Functions timer 触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer -
Azurite:
learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite -
Microsoft Azure Storage Explorer:
learn.microsoft.com/en-us/azure/storage/storage-explorer/vs-azure-tools-storage-manage-with-storage-explorer -
Azure Functions blob 触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-blob-trigger -
Azure Functions blob 触发器与 Event Grid:
learn.microsoft.com/en-us/azure/azure-functions/functions-event-grid-blob-trigger -
Azure Queue 存储触发器:
learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-storage-queue -
Azure 存储注意事项:
learn.microsoft.com/en-us/azure/azure-functions/storage-considerations
加入我们的 Discord 社区
加入我们的 Discord 空间,与作者和其他读者进行讨论:

第六章:实践中的物联网函数
物联网的实施确实正在改变我们与世界互动的方式。尽管我们提供了许多解决方案,但物联网仍然具有挑战性,尤其是如果您想要关注可扩展的解决方案。
本章的目的是介绍 Event Grid、Event Hubs 和物联网中心触发器,这些将是有益于启动连接到设备的微服务的选项。除此之外,我们还将讨论如何使用 Azure 启用物联网。
本章将帮助您使用 Azure 创建物联网环境。除此之外,它将指导您通过 Azure 物联网函数触发器连接此环境。最后,它将展示物联网的汽车共享示例案例。让我们看看如何操作。
技术要求
本章需要 Visual Studio 2022 免费社区版或 Visual Studio Code。您还需要一个 Azure 账户来创建示例环境。您可以在github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp找到本章的示例代码。
在 Azure 中启用物联网
当我们思考物联网时,最大的担忧之一是解决方案的可扩展性。考虑到我们正在设计一个便于与大量设备连接的解决方案,在 Azure 中启用物联网的最佳方式是通过使用物联网中心。物联网中心为连接、监控和管理您的物联网设备提供了一个良好的环境,提供了一个平台即服务(PaaS)解决方案,这将使您专注于您正在开发的应用程序。
Azure 中的物联网中心有两个定价层,以及其免费版。免费版每天允许发送高达 8,000 条 0.5KB 的消息,并且具有与标准层相同的特性。如果您选择基本或标准层,这可以增加到每天高达 30 亿条 4KB 的消息!标准层还提供设备管理、云到设备消息和物联网边缘。除此之外,标准层还有一个由 Defender 管理的安全层,称为 Defender for IoT。这些信息让我们对平台的可扩展性有了概念。
为了本书的目的以及帮助您理解以下示例,我们建议您创建一个免费的物联网中心组件。接下来的主题将讨论如何从该物联网中心获取消息,以便您可以根据它创建一个微服务。
完成此操作的过程相当简单。您必须转到 Azure 中的创建资源并输入 Azure Marketplace 中的物联网中心。

图 6.1:使用 Azure Marketplace 创建物联网中心
对于免费层,您只需填写与基本选项卡相关的信息,然后您就可以继续到审查 + 创建选项卡。

图 6.2:Azure 物联网中心免费层设置
资源创建后,您将能够在 Azure IoT Hub 的 设备管理 区域创建设备。

图 6.3:Azure IoT Hub 设备管理
首先,设备只需要 设备 ID 信息,它代表了将要处理的设备的唯一性。

图 6.4:在 IoT Hub 中创建设备
IoT Hub 还提供了使用 IoT Edge 设备连接边缘设备的可能性。这不是本书的重点,但您将在 进一步阅读 部分找到相关信息。对于本书的目的,在 Azure 中创建的设备就绪可以使用。
考虑到我们已经创建了设备,我们需要了解如何模拟它们。下面的代码展示了我们如何使用 .NET Microsoft.Azure.Devices.Client 库来实现这一点:
// <summary>
// Simulates a device by creating a DeviceClient and sending a message.
// </summary>
// <param name=”connectionString”>The connection string of the //device.</param>
// <param name=”message”>The message to be sent by the device.</param>
private static async Task SimulateDeviceAsync(string connectionString, string message)
{
var deviceClient = DeviceClient.CreateFromConnectionString(
connectionString, TransportType.Mqtt);
await SendMessageAsync(deviceClient, message);
}
// <summary>
// Sends a message to the IoT hub using the provided DeviceClient.
// </summary>
// <param name=”deviceClient”>The DeviceClient used to send the //message.</param>
/// <param name=”message”>The message to be sent.</param>
private static async Task SendMessageAsync(DeviceClient deviceClient, string message)
{
var messageBytes = Encoding.UTF8.GetBytes(message);
var iotMessage = new Message(messageBytes);
await deviceClient.SendEventAsync(iotMessage);
}
上面的方法中的 connectionString 参数针对每个 IoT 设备都是特定的。您可以使用 Azure 门户获取它,但值得一提的是,有一个非常实用的工具称为 Azure IoT Explorer。
使用 Azure IoT Explorer,我们可以在一个图形工具中管理连接到 IoT Hub 的设备,该工具有助于诊断和测试。例如,要获取特定设备的 连接字符串,您可以检查可用的 设备标识 信息。

图 6.5:获取设备连接字符串
现在我们已经了解了如何模拟设备,让我们学习如何使用 Azure Functions 从这些设备接收数据。
将 IoT Hub 与 Azure Functions 连接
默认情况下,IoT Hub 提供了一个内置服务,该服务将设备到云的消息发送到兼容的 EventHubs 端点,端点为 messages/events。这意味着您可以轻松地将 IoT Hub 设备消息连接到 Event Hubs 触发功能:
[Function(nameof(IoTFunction))]
public void Run([EventHubTrigger(“messages/events”, Connection = “EventHubConnection”)] EventData[] events)
{
foreach (EventData @event in events)
{
_logger.LogInformation(“Event Body: {body}”, @event.EventBody);
}
}
此选项确实非常有用,因为它可以快速开发一个解决方案,其中使用 IoT Hub 和 Azure Functions 连接不同的设备。因此,这可以被认为是直接集成消息处理的最简单方法。
在上面的代码中,我们只是定义了默认端点 messages/events 并定义了将为我们提供 Event Hub 连接字符串的变量。EventHubConnection 变量可以在 IoT Hub 的 内置端点 中找到。这里将只有共享访问策略,使我们能够从设备接收数据(ServiceConnect 权限)。考虑到此连接的目的仅仅是读取信息,建议您以最少的访问权限共享策略。

图 6.6:获取从 IoT Hub 接收数据的 Event Hubs 连接字符串
值得注意的是,根据你在 Azure IoT Hub 中选择的层级,这些消息可以保留最多七天。
虽然内置选项非常容易且快速实现,但你可能希望在可以应用其他替代方案的不同的物联网场景中应用。在 Azure IoT Hub 中使用事件触发来自设备的数据有几种方法,如下面的截图所示。

图 6.7:Azure IoT Hub 事件从设备接收数据的替代方案
每种方法当然都会给你实现事件驱动和可扩展解决方案的灵活性。除此之外,你需要精确分析你将从设备发送到云的数据,以定义最佳替代方案。值得注意的是,只有 IoT Hub 触发器旨在实现 IoT Hub 和 Azure Functions 之间的直接集成。其他触发器在事件选项卡下可见。
| 方法 | 何时使用 |
|---|---|
| IoT Hub 触发器 | 简单直接的消息处理集成。 |
| 事件网格触发器 | 适用于事件驱动系统和可扩展架构。 |
| 服务总线触发器 | 当你需要中间缓冲或消息优先级处理时。 |
| Blob 存储触发器 | 当你想要将遥测数据作为文件存储和处理时。 |
| HTTP 触发器(直接) | 当你需要对函数调用有精细控制时。 |
| 逻辑应用 | 用于与 IoT Hub 和函数的无代码/低代码集成。 |
| 流分析输出 | 当你在调用函数之前需要执行实时分析时。 |
| 队列触发器 | 用于轻量级、简单的基于队列的消息处理。 |
我们已经在过去三章中介绍了如何实现这些替代方案中的一些,因此我们不会再次探讨它们。
汽车共享物联网示例
我们在书中讨论的汽车共享示例允许寻找车辆和拥有车辆的用户之间进行互动。但是,假设我们有向从我们设计的平台申请特定物联网设备的车辆拥有者提供特殊计划的可能性。另一个选择是将汽车共享应用集成到中央汽车驾驶舱中。在这种情况下,用户可以追踪可用车辆的地理位置、速度和状态。还可以监控车辆健康参数,如电池寿命、轮胎压力和油量。
在之前提出的替代方案中,可以实施一个新的车辆跟踪微服务,其数据可能会与现有的路线列表和路线规划器微服务共享。对于前者,可以提供有关汽车可用性和预计到达时间的最新信息。对于规划者,这将有助于决定推荐新藏匿点的最佳汽车。
但考虑到上述场景,哪种架构方法会更好?在第七章“实践中的微服务”中,我们将介绍 RabbitMQ 消息代理,这对于此场景非常有用,以及 Routes-Planner 微服务的完整示例。下面的图示显示了物联网解决方案和 Vehicle-Tracking 微服务如何连接到主解决方案。
Azure IoT Hub 是负责管理多辆汽车(设备)的组件,它将使用 Azure Event Hubs 消息将每辆汽车接收到的跟踪数据发送到 Vehicle-Tracking 微服务。这个微服务将负责处理车辆健康参数,如上所述,并且这些信息将被存储在 Cosmos DB 数据库中,考虑到接收到的数据量。最后,它将仅使用 RabbitMQ 主总线发布 RoutesPlanning 微服务所需的数据。

图 6.8:连接到微服务解决方案的物联网解决方案
从汽车发送的跟踪数据可能具有以下结构。还值得一提的是,如果您从设备运行.NET 到云,如果您在一个专门用于定义 SharedMessages 的类库中工作,这个结构可以被重用:
using SharedMessages.BasicTypes;
using System;
namespace SharedMessages.VehicleTracking
{
public class VehicleTrackingMessage : TimedMessage
{
public Guid VehicleId { get; set; }
public GeoLocalizationMessage? Location { get; set; }
public double Speed { get; set; }
public double CarStatus { get; set; }
public double BatteryLevel { get; set; }
public double FuelLevel { get; set; }
public double TirePressure { get; set; }
}
}
值得注意的是,Location 属性是由另一个共享类定义的,称为 GeoLocalizationMessage:
using System;
using System.Collections.Generic;
using System.Text;
namespace SharedMessages.BasicTypes
{
public class GeoLocalizationMessage
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}
}
考虑到这个场景,以下代码是使用物联网 Hub 作为前门收集数据并发送数据的汽车模拟:
using System.Text;
using System.Text.Json;
using Microsoft.Azure.Devices.Client;
using SharedMessages.BasicTypes;
using SharedMessages.VehicleTracking;
// <summary>
// The main class for the Car Simulator program.
// </summary>
class Program
{
// <summary>
// The connection string for the car device.
// </summary>
private static string carConnectionString = “[device connection string]”;
// <summary>
// The main entry point for the program.
// </summary>
static async Task Main()
{
while (true)
{
// Create a new vehicle tracking message with random data
VehicleTrackingMessage vehicleTrackingMessage = new
VehicleTrackingMessage
{
VehicleId = Guid.NewGuid(),
Location = new GeoLocalizationMessage
{
Latitude = 47.6426,
Longitude = -122.1301
},
Speed = 60 + DateTime.Now.Second,
CarStatus = 1,
BatteryLevel = 100 - DateTime.Now.Second,
FuelLevel = 100,
TirePressure = 32
};
// Simulate sending the device message
await SimulateDeviceAsync(carConnectionString,
vehicleTrackingMessage);
Console.WriteLine(“Vehicle tracking sent!”);
await Task.Delay(new Random().Next(10000, 20000));
}
}
// <summary>
// Simulates sending a device message to the IoT hub.
// </summary>
// <param name=”connectionString”>The connection string for the
//device.</param>
// <param name=”message”>The vehicle tracking message to send.</param>
private static async Task SimulateDeviceAsync(string connectionString,
VehicleTrackingMessage message)
{
var deviceClient = DeviceClient.CreateFromConnectionString(
connectionString, TransportType.Mqtt);
string jsonMessage = JsonSerializer.Serialize(message);
await SendMessageAsync(deviceClient, jsonMessage);
}
// <summary>
// Sends a message to the IoT hub.
// </summary>
// <param name=”deviceClient”>The device client to use for sending the
//message.</param>
// <param name=”message”>The message to send.</param>
private static async Task SendMessageAsync(DeviceClient deviceClient,
string message)
{
var messageBytes = Encoding.UTF8.GetBytes(message);
var iotMessage = new Message(messageBytes);
await deviceClient.SendEventAsync(iotMessage);
}
}
值得注意的是,我们在这里只是使用随机信息创建数据。然而,这个过程本身正好代表了从设备到云的数据输出过程。
根据您拥有的设备,您可能需要更改与 Azure IoT Hub 一起使用的协议。您可以查看 https://learn.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-protocols 以获取更多信息。
另一方面,以下代码表示将处理车辆跟踪消息的功能,将数据存储在 Cosmos DB 中,同时通过 RabbitMQ 向所有微服务发出警报,表明有来自汽车的新的消息,因此其他微服务,如 RoutesPlanning,可以利用它来运行其业务规则:
using System;
using System.Text.Json;
using Azure.Messaging.EventHubs;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using SharedMessages.VehicleTracking;
namespace VehicleTrackingFunction
{
// <summary>
// Azure Function to process vehicle tracking messages from Event Hub.
// </summary>
public class VehicleTracking
{
private readonly ILogger<VehicleTracking> _logger;
// <summary>
// Initializes a new instance of the <see cref=”VehicleTracking”/>
//class.
// </summary>
// <param name=”logger”>The logger instance.</param>
public VehicleTracking(ILogger<VehicleTracking> logger)
{
_logger = logger;
}
// <summary>
// Function triggered by Event Hub messages.
// </summary>
// <param name=”events”>Array of EventData received from Event
//Hub.</param>
[Function(nameof(VehicleTracking))]
public async Task Run([EventHubTrigger(“messages/events”,
Connection = “CarSharingIoTEventHub”)] EventData[] events)
{
foreach (EventData @event in events)
{
var jsonString = @event.EventBody.ToString();
if (!string.IsNullOrEmpty(jsonString))
{
VehicleTrackingMessage? vehicleTrackingMessage = JsonSerializer.Deserialize<VehicleTrackingMessage>(jsonString);
if (vehicleTrackingMessage != null)
{
await SaveDataToDatabase(vehicleTrackingMessage);
await AlertDataToRabbitMQ(vehicleTrackingMessage);
}
}
}
}
// <summary>
// Sends vehicle tracking data to RabbitMQ.
// </summary>
// <param name=”vehicleTrackingMessage”>The vehicle tracking
//message.</param>
private async Task AlertDataToRabbitMQ(
VehicleTrackingMessage vehicleTrackingMessage)
{
// Implementation for alerting data to RabbitMQ
Console.WriteLine($”Vehicle tracking data alerted to RabbitMQ: ID =
{vehicleTrackingMessage.VehicleId};
Speed = {vehicleTrackingMessage.Speed}”);
}
// <summary>
// Saves vehicle tracking data to CosmosDB database.
// </summary>
// <param name=”vehicleTrackingMessage”>The vehicle tracking
//message.</param>
private async Task SaveDataToDatabase(VehicleTrackingMessage
vehicleTrackingMessage)
{
// Implementation for saving data to the database CosmosDB
Console.WriteLine($”Vehicle tracking data saved to database: ID =
{vehicleTrackingMessage.VehicleId};
Speed = {vehicleTrackingMessage.Speed}”);
}
}
}
关于这种方法的一些优点证明了为什么微服务是处理大型产品的良好方式。首先,物联网解决方案的实施与应用程序其他部分的实施完全解耦,这使得开发者能够定义所使用的技术和部署管道。其次,物联网解决方案提供的信息的使用是可选的,并且可以扩展到所需的每个微服务。此外,需要注意的一个点是 Shared Messages 中定义的合约。您必须小心不要在系统之间创建不兼容性。避免这种情况的一个好方法是版本化消息内容。
摘要
本章讨论了如何在 Azure 中处理物联网解决方案,特别是借助 Azure IoT Hub 和 Azure Functions。它还展示了使用物联网服务扩展的汽车共享示例,这展示了微服务架构的实用性。
微服务在大规模应用程序的开发中提供了几个战略优势,尤其是在实施物联网解决方案时。通过将物联网解决方案从应用程序的其他部分解耦,开发者可以独立选择合适的技术和部署管道。这种模块化方法不仅增强了可扩展性和可维护性,还允许不同的团队在没有干扰的情况下共同工作于应用程序的不同部分。
微服务的另一个显著优势是它们可选的分布式信息使用。物联网解决方案提供的数据可以被任何需要它的微服务利用,确保高效的数据处理和加工。然而,通过精心管理合约,保持不同系统间的兼容性至关重要。版本化消息内容是避免不兼容问题的有效策略,确保微服务之间通信的顺畅。在下一章中,我们将开始讨论微服务的实际应用,并更加注重这一点。
问题
- 在物联网应用程序中,从内置端点读取设备到云的消息的目的是什么?
IoT Hub 中的内置端点允许您轻松直接地读取设备到云的消息,使其非常适合设备与后端应用程序之间的快速集成。它简化了将物联网设备连接到如 Azure Functions 等服务使用标准 Event Hub 兼容端点的过程。
这种方法适用于需要快速原型设计或轻量级集成的场景,因为它需要最少的配置并支持可扩展的事件驱动解决方案。
- 您如何从内置端点读取设备到云的消息?
要从内置端点读取消息,您可以使用 Event Hub 触发器创建一个 Azure 函数,并将其指向 IoT Hub 的默认消息/事件端点。使用具有读取权限的连接字符串(通常来自服务策略)来访问消息。
此方法允许快速直接地实现无服务器消息处理,使 Azure Function 能够在设备向 IoT Hub 发送数据时自动执行。
- 使用 Azure IoT explorer 管理物联网设备有哪些优势?
Azure IoT explorer 是一个图形工具,它简化了 IoT Hub 中的设备管理。它允许您注册新设备、查看连接字符串、发送测试消息和监控设备状态,而无需编写任何代码。
此工具在开发和测试阶段特别有用,因为它加速了诊断,并为开发者提供了一个用户友好的界面来交互和配置物联网设备。
- Queue Trigger 如何促进轻量级、简单的基于队列的消息处理?
Queue triggers 使 Azure Functions 能够响应放置在 Azure 存储队列中的消息。此模式提供了一种轻量级且解耦的方式来异步处理任务,使得实现后台作业处理或消息工作流变得容易。
在需要简单性、可扩展性和容错性,而不需要复杂消息基础设施的情况下,这种方法尤其有效。
- IoT Hub 和 Event Hubs 之间有哪些主要区别?
IoT Hub 专门设计用于与物联网设备进行安全且可扩展的通信,提供设备管理、双向消息和与 IoT Edge 的集成。另一方面,Event Hubs 是一个高吞吐量的通用事件摄取服务,主要用于遥测和日志记录。
虽然两者都支持大量数据摄取,但 IoT Hub 提供了以设备为中心的功能,如设备属性、直接方法和每个设备的身份验证凭据,而 Event Hubs 则专注于数据流和集成到分析管道中。
- 将物联网解决方案与应用程序的其他部分解耦有哪些好处?
解耦物联网解决方案允许独立开发、扩展和部署设备通信层。每个微服务只能处理它需要的数据,从而带来更好的性能、灵活性和可维护性。
此外,这种分离还使团队能够根据需要采用不同的技术或部署策略,同时保持核心应用程序架构的清洁和模块化。
- 如何通过版本化消息内容帮助防止共享消息中的不兼容性问题?
版本化消息内容确保对数据结构的更改不会破坏消费这些消息的微服务中的功能。每个服务都可以处理它理解的版本,从而实现系统的平稳演进。
通过保持版本间的兼容性,开发者可以独立更新和部署组件,而不会导致集成失败或服务间数据误解。
- 部署管道在物联网解决方案中微服务实现中扮演什么角色?
一个定义良好的部署管道允许每个微服务,包括与物联网相关的微服务,独立地进行构建、测试和部署。这支持持续集成和交付,缩短上市时间并最小化更新期间的风险。
对于物联网场景,数据摄取和处理至关重要,自动化的管道确保了分布式系统中的可靠性、版本控制和可追溯性,从而增强了整体应用的鲁棒性。
进一步阅读
-
Azurite:
learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite -
Microsoft Azure 存储资源管理器:
learn.microsoft.com/en-us/azure/storage/storage-explorer/vs-azure-tools-storage-manage-with-storage-explorer -
Azure IoT Edge 文档:
learn.microsoft.com/en-us/azure/iot-edge -
从内置端点读取设备到云的消息:
learn.microsoft.com/en-us/azure/iot-hub/iot-hub-devguide-messages-read-builtin -
Azure IoT 探索器:
learn.microsoft.com/en-us/azure/iot/howto-use-iot-explorer -
物联网中心与事件中心之间的比较:
learn.microsoft.com/en-us/azure/iot-hub/iot-hub-compare-event-hubs -
Azure Functions 事件触发器: https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-event-iot
-
Azure Functions IoT 触发器: https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-event-iot-trigger
-
Azure 流分析: https://azure.microsoft.com/en-us/products/stream-analytics/
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第七章:实践中的微服务
本章致力于在通用应用架构设计之后以及所有微服务的所有接口都已定义之后,每个微服务的实际实现。本书剩余章节将详细阐述微服务之间的交互和编排。
所有概念将通过从本书案例研究应用中提取的工人微服务示例进行说明,该示例我们在第二章的“汽车共享示例”子节中介绍,即“揭秘微服务应用”*。
在简要描述示例工人微服务规范后,我们将描述如何设计微服务的输入和输出通信子系统,以及如何组织微服务请求服务逻辑。
最后,我们将讨论如何使用在第三章的“基于洋葱架构的解决方案模板”部分中介绍的洋葱架构项目模板来实现微服务的细节。
更具体地说,本章涵盖了以下内容:
-
汽车共享应用的路线规划微服务
-
微服务基本设计
-
确保与 Polly 的通信具有弹性
-
从抽象到实现细节
技术要求
本章需要以下条件:
-
至少需要 Visual Studio 2022,尤其是免费的社区版。
-
一个接受 TCP/IP 请求和用户/密码身份验证的 SQL 实例,因为它必须与运行在 Docker 容器内的客户端通信。请注意,Visual Studio 安装附带的支持 TCP/IP 的 SQL 实例,因此您需要安装 SQL Express 或使用云实例。对于本地安装,安装程序和说明都可在以下链接找到:
www.microsoft.com/en-US/download/details.aspx?id=104781。您还可以使用以下代码将 SQL Server Developer 版作为 Docker 镜像运行:docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=yourStrong(!)Password" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest -
对应于所选密码的用户名将是
sa。 -
Docker Desktop for Windows (
www.docker.com/products/docker-desktop). -
Docker Desktop,反过来,需要Windows Subsystem for Linux (WSL),可以通过以下步骤安装:
-
在 Windows 10/11 的搜索栏中输入
powershell。 -
当Windows PowerShell作为搜索结果出现时,请单击以管理员身份运行。
-
在出现的 Windows PowerShell 管理控制台中,运行
wsl --install命令。
-
您可以在此处找到本章的示例代码:github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp。
汽车共享应用的路线规划微服务
在本节中,我们描述了我们的示例微服务、如何处理安全以及如何为其实施准备解决方案,并将它们分为三个独立的子节。
微服务规范
路由规划微服务存储和匹配待处理的请求,这些请求需要从一个城镇移动到另一个城镇,并且使用的是仍然对其他参与者开放的路由。
当车主开启的路由被创建时,它会与那些起点和终点城镇靠近车主路线且日期限制相兼容的请求进行匹配。如果找到匹配项,则会创建一个修改路由以包含它们的提案,并将其发送给其他感兴趣的微服务。当插入新的请求时,也会执行对称操作。
当接受扩展路由的提案时,原始路由被扩展。
在初始匹配尝试之后,请求和路由都会被存储起来,以备将来可能的匹配。在以下情况下,请求和路由会被移除或修改:
-
当路由对新参与者关闭或被取消时,它将从可能的匹配中移除。
-
当路由与某些请求合并时,它会扩展。此操作不会尝试进行新的匹配。
-
当请求与路由合并时,请求将从可能的匹配中移除。
-
当请求合并的路由被取消时,请求再次可用。在此操作之后,将尝试进行新的匹配。
-
请求和路由在它们的最大旅行日过期后N天后被删除,其中N是一个需要提供的参数。
当满足以下条件时,进行路由和请求之间的匹配:
-
路由日期在请求关联的最小和最大日期之间。
-
请求的起点和终点城镇与路线足够接近。
我们将使用发布者/订阅者模式来实现大多数微服务之间的通信,以最大化微服务的解耦。这个选择也将最小化整体通信相关的代码,因为消息处理程序及其客户端库负责处理大多数异步通信问题。请参阅第二章的基于事件的通信子节,揭秘微服务应用程序,以获取有关基于事件通信的更多详细信息。
此外,为了最大化应用程序的可移植性,我们将使用RabbitMQ消息代理,它不受特定平台或云的限制,但可以在任何基于 Kubernetes 的网络中安装,并具有可调整的副本数量。RabbitMQ将在下一节的专用子节中描述。
由于共享汽车应用程序不交换大量消息,我们可能避免使用非标准的二进制序列化,如gRPC Protobuf,而选择简单的JSON消息序列化。
大多数 Web 服务器和通信库都可以配置为自动压缩 JSON 数据。Web 服务器与客户端协商压缩。
最后,由于我们的工作微服务入出通信基于消息代理而不是常规的HTTP和gRPC ASP.NET Core 协议,我们可能会考虑基于所谓的托管服务(托管服务将在下一节讨论)的Worker 服务项目模板。然而,微服务最佳实践规定每个微服务都应该公开一个 HTTP 端点以验证其健康状态,因此我们将采用基于最小 API 的 ASP.NET Core Web API 项目,因为它也支持我们需要的基于消息代理的通信所需的托管服务。
在明确了微服务职责后,我们可以继续考虑安全因素。
处理安全和授权
来自实际用户的请求授权通常使用 ASP.NET Web API 的常规技术处理,即使用 Web 令牌(通常是JSON 承载令牌)和Authorize属性。Web 令牌由一个充当授权服务器的专用微服务的登录和令牌续订端点提供。
来自其他服务的请求通常使用 mTLS(即基于证书的客户端身份验证)进行安全保护。客户端证书由底层 TCP/IP 协议与用于加密 HTTPS 通信的服务器证书一起处理。然后,客户端证书提取的信息传递给 ASP.NET Core 身份验证中间件以创建一个ClaimsPrincipal(通常的 ASP.NET Core User对象)。当应用程序在编排器中运行时,也可以使用编排器特定的授权,而当应用程序在云中运行时,可以使用云特定的授权。
幸运的是,如果两个通信微服务都暴露在私有网络中,或者更好,由微服务编排器管理的私有网络中,我们可以用防火墙规则和/或编排器提供的其他通信安全设施来替换用户身份验证。
我们将在第八章“使用 Kubernetes 的实用微服务组织”和第十章“无服务器和微服务应用程序的安全性和可观察性”中分析 Kubernetes 编排器,以及其通信安全设施。即使在私有网络中,也建议使用 mTLS 或其他加密方法加密内部通信以减轻内部威胁和网络攻击,但为了本书的简洁性,我们只将保护与外部世界的通信。
因此,如果我们充分组织我们的私有网络,我们只需要确保与外部世界的通信安全,即与前端微服务的通信。然而,如第二章中“揭秘微服务应用”部分的接口外部世界小节所述,基于微服务的应用使用 API 网关与外部世界通信。在最简单的情况下,与外部世界的接口只是一个负载均衡的 Web 服务器,执行 HTTPS 终止,即从外部世界接收 HTTPS 通信。虽然一些架构在 API 网关终止 HTTPS 并在内部使用 HTTP,但建议在私有网络中使用 mTLS 或重新加密来确保微服务生态系统内的安全性。这样,我们可能只需要为整个应用程序使用一个 HTTPS 证书,从而避免所有组成应用程序的微服务的整个证书颁发和更新流程。
总结来说,如果我们使用任何类型的 HTTPS 终止接口来访问微服务应用,我们可能避免在所有微服务中使用 HTTPS 通信。
现在我们已经准备好准备将托管路由规划微服务的 Visual Studio 解决方案了!
创建 Visual Studio 解决方案
由于我们决定使用 ASP.NET Core Web API 项目来实现工作微服务的最外层,让我们创建一个包含名为RoutesPlanning的 ASP.NET Core Web API 项目的CarSharing Visual Studio 解决方案。ASP.NET Core Web API项目可以通过从 Visual Studio 项目选择窗口的下拉菜单中选择C#、所有平台和Web API来轻松找到,如图所示:

图 7.1:项目选择
如前所述,我们可能避免 HTTPS 通信,并且工作微服务不需要认证。然而,由于微服务通常容器化,我们需要 Docker 支持。
最后,我们不需要控制器,只需要一个最小的 API,因为我们只需要暴露几个简单的端点进行健康检查:

图 7.2:项目设置
我们将使用洋葱架构,因此我们还需要为应用服务和领域层添加一个项目。因此,让我们添加两个额外的类库项目,分别命名为RoutesPlanningApplicationServices和RoutesPlanningDomainLayer。我们将根据第三章中“基于洋葱架构的解决方案模板”部分介绍的洋葱架构模板进行适配。
让我们打开OnionArchitectureComplete项目模板,你可以在书的 GitHub 仓库的ch03文件夹中找到它。在RoutesPlanningDomainLayer项目中,删除Class1.cs文件,选择ch03项目模板中DomainLayer项目的三个文件夹,复制它们,并将它们粘贴到RoutesPlanningDomainLayer项目中。如果你安装了最新的 Visual Studio 2022 版本,你应该能够从 Visual Studio 解决方案资源管理器中执行复制操作。此外,将Microsoft.Extensions.DependencyInjection.Abstractions Nuget 包的引用添加到RoutesPlanningDomainLayer项目中。
然后,在RoutesPlanningApplicationServices和ApplicationServices项目上执行类似的操作。
现在你已经放置了所有的洋葱架构文件,你只需要在RoutesPlanningApplicationServices中添加对RoutesPlanningDomainLayer的引用,并在RoutesPlanning中添加对RoutesPlanningApplicationServices的引用。
在最后一步操作之后,你的解决方案应该可以编译,但我们还没有完成解决方案的准备工作。我们还需要添加一个基于Entity Framework Core的库,以便为我们的领域层提供一个实现驱动程序。
让我们添加一个新的类库项目,并将其命名为RoutesPlanningDBDriver。添加对Microsoft.EntityFrameworkCore.SqlServer和Microsoft.EntityFrameworkCore.Tools Nuget 包的引用,以及对RoutesPlanningDomainLayer项目的引用。
之后,删除Class1.cs文件,并用ch03项目模板中DBDriver项目的所有代码文件和文件夹替换它。
RoutesPlanning Program.cs file:
builder.Services.AddOpenApi();
//Code snippet start
builder.Services.AddApplicationServices();
builder.Services.AddDbDriver(
builder.Configuration?.GetConnectionString("DefaultConnection") ?? string.Empty);
//Code snippet end
RoutesPlanning需要引用RoutesPlanningDBDriver,因为洋葱架构的最外层必须引用所有特定实现的驱动程序。AddApplicationServices将所有查询、命令和事件处理程序添加到依赖注入引擎中,而AddDbDtiver将所有存储库实现和IUnitOfWork实现添加到依赖注入中。
关于我们用来准备解决方案的洋葱架构项目模板的更多信息,请参阅第三章的基于洋葱架构的解决方案模板部分,设置和理论:Docker 和洋葱架构。
现在,我们的解决方案终于准备好了!我们可以开始设计我们的工作微服务了!
微服务基本设计
在本节中,我们将定义所有主要微服务抽象,即整体通信策略、所有洋葱架构命令和事件,以及所需托管服务的顶级循环。我们将从对所选消息代理的描述开始:RabbitMQ。
消息代理:RabbitMQ
RabbitMQ 本地支持 AMQP 异步消息协议,这是最常用的异步协议之一,另一个是 MQTT,它具有特定的发布/订阅模式语法。可以通过插件添加对 MQTT 的支持,但 RabbitMQ 提供了在 AMQP 上轻松实现发布/订阅模式的工具。此外,RabbitMQ 提供了支持可伸缩性、灾难恢复和冗余的几个工具,因此它满足了成为云和微服务环境中一流演员的所有要求。更具体地说,通过定义 RabbitMQ 集群,我们可以实现负载均衡和数据复制,这在大多数 SQL 和 NoSQL 数据库中都是必需的。
在本节中,我们将仅描述 RabbitMQ 的基本操作,而 RabbitMQ 集群在 Kubernetes 中的安装和使用将在 第八章,使用 Kubernetes 的实用微服务组织 中讨论。您可以在 RabbitMQ 官方网站上的教程和文档中找到更多详细信息:www.rabbitmq.com/。
RabbitMQ 消息必须以二进制格式准备,因为 RabbitMQ 消息必须只是一个字节数组。然而,我们将使用 EasyNetQ 客户端,它负责对象序列化和大多数客户端/服务器连接以及错误恢复。EasyNetQ 是一个基于 RabbitMQ 的低级 RabbitMQ.Client NuGet 客户端的 NuGet 包,它使得 RabbitMQ 的使用变得简单,同时减少了通信代码的开销,并增强了其模块化和可修改性。
一旦消息被发送到 RabbitMQ,它们会被放置在 队列 中。更具体地说,它们通过其他实体(称为 交换)传递时,会被放置在一个或多个 队列 中。交换使用依赖于 交换 类型的路由策略将消息路由到 队列。交换是 AMQP 特有的概念,它们是 RabbitMQ 配置复杂通信协议(如发布/订阅协议)的方式,如下面的图所示:

图 7.3:RabbitMQ 交换
通过适当地定义交换路由策略,我们可以实现几种模式。更具体地说,以下适用:
-
当我们使用 默认交换 时,消息会被发送到单个队列,并且我们可以实现异步直接调用。
-
当我们使用 fanout 交换 时,交换会将消息发送到所有订阅该交换的队列。这样,我们可以实现发布/订阅模式。
此外,还有一个 topic 交换,它通过允许匹配名为事件子类的主题来增强发布/订阅模式。接收者和主题之间的匹配也支持通配符字符。我们将在 确保消息按正确顺序处理 子节中描述其在企业微服务中的实际用法。
当多个接收器连接到同一个队列时,消息将根据轮询模式在它们之间平均分配。这是相同微服务的 N 个相同副本的情况。因此,副本由 RabbitMQ 自动负载均衡。
幸运的是,EasyNetQ 直接暴露了发布/订阅协议(可能包含主题),直接调用协议,以及请求/响应异步 RPC 协议,负责创建和连接所有需要的队列和交换。在描述我们的路线规划微服务代码时,将提供如何使用 EasyNetQ 的详细信息。
安装 RabbitMQ 最简单的方法是使用其 Docker 镜像。我们将采用此选项,因为我们的所有微服务也将被容器化,并且在整体应用程序的最终 Kubernetes 版本中,我们将使用容器化的 RabbitMQ 集群。
我们可以在 Linux shell 中运行以下命令:
docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:4.0-management
由于我们提供了 -it 标志,在镜像下载并容器创建并启动后,Linux shell 仍然被阻塞在容器文件系统中。此外,由于我们也添加了 –-rm 选项,容器在停止后立即被销毁,如下所示:
docker stop rabbitmq
为了验证 RabbitMQ 是否正常工作,请导航到 localhost:15672。RabbitMQ 管理控制台应该出现。您可以使用启动凭证登录,用户名和密码都是 guest。
您不需要让容器持续运行;当您需要测试微服务代码时,可以停止它并重新执行 run 命令。
RabbitMQ 需要的磁盘空间作为 Docker 卷挂载,以下卷声明直接插入到 Dockerfile 镜像中:
VOLUME /var/lib/rabbitmq
这意味着当容器被销毁并重新运行时,磁盘内容会被重置。因此,如果您想保留磁盘内容,请避免使用带有 –-rm 选项的容器运行,这样它在停止时不会被销毁。
如果您需要自定义凭证,请将以下环境变量添加到 run 命令中:
-e RABBITMQ_DEFAULT_USER=my_user_name -e RABBITMQ_DEFAULT_PASS=my_password
这在访问 RabbitMQ 时需要从 localhost 外部进行时是必要的,因为在这种情况下,出于安全原因,默认的用户名和密码不被接受。
现在,我们可以继续设计我们的工作微服务的输入和输出消息。
输入通信
由于表示微服务内部消息的类必须为客户端和服务器所知,最佳选项是在初始微服务外部接口设计期间定义它们,并将它们放在一个或多个共享库中。由于我们的项目包含相对较少的微服务,我们可以假设所有消息对所有微服务都是可见的,因此我们可以使用单个共享库。
然而,在包含数百或数千个微服务的更复杂场景中,它们的组织必须是分层的,因此我们将有 0 级消息,所有微服务都知道;1 级消息,仅在 1 级微服务组内知道,依此类推。
让我们在解决方案中添加一个新的类库项目,命名为SharedMessages,并为其选择标准 2.1版本。然后,让我们将此新项目添加到RoutesPlanningApplicationServices项目中。我们将在这里放置所有应用程序消息。
从路线规划微服务的规范来看,我们只有四条消息:
-
新请求:它将包含一个唯一的请求标识符、可接受的旅行日期间隔以及代表出发地和到达地的两个唯一标识符,它们的显示名称以及它们的纬度和经度。此外,它还将包含一个唯一标识符,代表提出请求的用户及其显示名称。
-
新路线:它将包含一个唯一的路线标识符、旅行日期以及代表出发地和到达地的两个唯一标识符,它们的显示名称以及它们的纬度和经度。此外,它还将包含一个唯一标识符,代表提出路线提案的汽车所有者及其显示名称。
-
路线关闭/取消:它将仅包含唯一的路线标识符和一个标志,指定路线是否成功关闭或取消。
-
路线扩展:它通知汽车所有者已接受扩展路线,包括其他请求的出发地和结束地。它包含与新的路线消息相同的信息,以及新的请求消息。
它还包含一个标志,指定在扩展后,路线是否已对其他参与者关闭。
对于路线规划微服务,消息内容可能显得冗余。例如,路线扩展消息中包含的大部分信息,路线规划微服务已经知道。实际上,路线规划微服务只需要请求和路线的唯一标识符来连接。
然而,使用发布者/订阅者模式发送的消息被几个可能未知订阅者使用,因此它们不能假设订阅者具有特定的先验知识。例如,路线扩展消息也将被处理所有不包含有关所有现有路线提案信息的请求的微服务订阅,因此所有关于合并路线所需的信息都必须通过此消息接收。
相反,路线关闭/取消消息不需要传达整个路线信息,因为任何对事件感兴趣的服务必须已经知道此路线,并且必须已经拥有关于它的所有所需数据。如果它从未与此路线交互,它可能缺少这些数据,但在此情况下,由消息表示的事件不能修改其状态,而必须简单地忽略。
我们必须始终对所有微服务输入提出的一个重要问题是:如果消息到达的顺序错误,即与发送的顺序不同,会发生什么?如果消息顺序很重要,我们要么确保所有消息都按正确顺序到达并被处理,要么使用在第二章中“揭秘微服务应用”的有效处理异步通信小节中解释的技术重新排序消息。不幸的是,重新排序输入消息是不够的;我们还必须按正确的顺序处理它们。
如果多个相同微服务的副本并发处理这些输入消息,这并不是一个简单任务。幸运的是,没有应用程序需要为所有输入消息固定排序。但是,一些相关消息,例如,所有包含相同路由的消息,必须按正确顺序处理。因此,我们可以通过将所有相关消息传递给同一个副本来避免仅仅并发处理相关消息。我们将在确保消息按正确顺序处理部分分析实现类似负载均衡策略的技术。
在我们这个案例中,新路由提供和路由请求到达的顺序并不是问题,因为我们可以通过简单的技巧正确处理乱序消息。我们只需要添加一个更新版本号来检测过去的更新。更新版本号必须是唯一的,并且必须与对给定实体应用更新的实际顺序相对应。当实体被创建时,它从版本 0 开始,然后每次新更新都会增加这个数字。
作为一般规则,如果所有修改和创建消息都包含整个实体数据,并且如果所有删除都是逻辑的,即实体只是被标记为已删除,那么消息不需要排序。
事实上,我们只能识别并应用比已应用的更新更近的修改。此外,我们总是可以验证修改消息中提到的实体是否已经被删除,并丢弃该修改。最后,如果修改中提到的实体尚未创建,我们可以始终使用修改消息中包含的数据创建它,因为每个修改都包含整个实体数据。
在我们这个案例中,路由扩展消息的顺序并不重要,因为合并到路由中的请求只是简单相加,并且只需要选择存储在路由中的城镇列表和消息中包含的较新列表。
路由扩展和路由关闭/中止消息的倒置也不会引起问题,因为忽略中止路由的扩展,并合并关闭后到达的先前请求就足够了。
路由创建和扩展的逆操作永远不会发生,因为只有成功创建的路由才能引起请求-路由匹配,进而导致路由扩展。
已删除的路由不会引起问题,因为路线中止和关闭消息实际上是逻辑删除。我们可以在旅行日过去后 N 天内删除它们,因为到那时,之前的延迟消息无法到达(在严重故障的情况下,消息可能会延迟数小时甚至一天)。这可以通过 cron 作业完成。
由于超时和重发导致的消息重复也不会引起问题,因为它们总是可以被识别并忽略。作为一个练习,你可以详细分析所有可能性。
所有必需的消息都可以通过一些基本类型轻松定义,我们将它们放置在 SharedMessages 项目的 BasicTypes 文件夹中。具体如下:
public class GeoLocalizationMessage
{
public double Latitude { get; set; }
public double Longitude { get; set; }
}
public class TimeIntervalMessage
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
}
public class UserBasicInfoMessage
{
public Guid Id { get; set; }
public string? DisplayName { get; set; }
}
public class TownBasicInfoMessage
{
public Guid Id { get; set; }
public string? Name { get; set; }
public GeoLocalizationMessage? Location { get; set; }
}
此外,由于所有消息都必须包含一个更新时间,我们可以让它们都继承以下类:
public class TimedMessage
{
public long TimeStamp { get; set; }
}
让我们将这个类也放置在 BasicTypes 文件夹中。
现在,所有消息都可以定义为以下内容:
-
新请求:
public class RouteRequestMessage: TimedMessage { public Guid Id { get; set; } public TownBasicInfoMessage? Source { get; set; } public TownBasicInfoMessage? Destination { get; set; } public TimeIntervalMessage? When { get; set; } public UserBasicInfoMessage? User { get; set; } } -
新路由:
public class RouteOfferMessage: TimedMessage { public Guid Id { get; set; } public IList<TownBasicInfoMessage>? Path { get; set; } public DateTime? When { get; set; } public UserBasicInfoMessage? User { get; set; } } -
路由关闭/中止:
public class RouteClosedAbortedMessage: TimedMessage { public Guid RouteId { get; set; } public bool IsAborted { get; set; } } -
路由扩展:
public class RouteExtendedMessage: TimedMessage { public RouteOfferMessage? ExtendedRoute { get; set; } public IList<RouteRequestMessage>? AddedRequests { get; set; } public bool Closed { get; set; } }
将它们放置在名为 RouteNegotiation 的 SharedMessages 项目文件夹中。
我们刚刚完成了微服务输入设计!让我们继续进行输出设计。
输出通信
路线规划微服务的输出包括增加匹配请求以增强路线的建议。这些建议必须由拥有这些路线的用户接受。一个单独的路由扩展消息包含路线的唯一标识符以及所有新发现的匹配请求:
public class RouteExtensionProposalsMessage: TimedMessage
{
public Guid RouteId { get; set; }
public IList<RouteRequestMessage>? Proposals { get; set; }
}
让我们将这个类放置在 SharedMessages 项目的 RouteNegotiation 文件夹中。
请注意,与此消息相关的时间戳是此工作微服务接收的路由的最新时间戳。实际上,这个微服务并不执行实际的路线更新,而只是计算更新建议,这些建议可能由另一个微服务转换为实际更新。
作为一条经验法则,对实体的所有更新都必须在一个数据库副本上执行。这样,计算实体版本就变成了一项可行的任务,只需要简单的数据库事务即可完成。否则,每个更新都应该在 N 个不同的微服务之间进行协调,这需要复杂的分布式事务。因此,如果几个微服务在其数据库中具有相同概念实体的不同视图,它们中的每一个都可以更改其使用的实体私有数据,而无需对其进行版本控制。但应该有一个微服务负责更新实体的所有共享属性,对其进行版本控制,并将它们发送给所有感兴趣的微服务。
不幸的是,有时分布式事务是不可避免的,但即使在这些情况下,单个微服务副本也会提出一个新版本号,如果事务成功,所有参与事务的微服务都将接受这个版本号。
输出消息可以在其创建后立即放置在由永久存储实现的内部队列中,如第二章“揭秘微服务应用”中“有效处理异步通信”部分所述。然而,如果我们使用代理,该策略需要稍作修改。在那里,我们应用了指数重试策略,在指数增长的时间后重试失败的消息,同时继续从内部队列发送其他消息。当消息不由消息代理中介时,这种策略是有意义的,因为失败与目的地或源和目的地之间路径上的某些组件有关。因此,如果下一个消息有不同的目的地,它可能会成功。
如果我们使用消息代理,失败取决于消息代理本身,因为确认只是表明消息代理成功接收了消息,而不是消息被接收和确认。因此,立即尝试新的消息传输可能会再次导致失败。
我们可以得出结论,当通信由消息代理中介时,我们不需要延迟单个错误消息;相反,我们必须停止向消息代理发送消息,并应用指数重试和断路器策略。此外,由于保持太多线程等待确认可能会使系统拥塞,我们还必须应用舱壁隔离策略来限制挂起任务的数量。
在这一点上,你可能会问:如果我们已经有了外部队列的消息代理,为什么还需要内部队列?有两个原因;尤其是第一个原因相当有说服力:
-
内部队列是通过数据库表实现的,因此它在触发输出事件的数据库更新同一事务中被填充。因此,如果出现问题,整个事务将被中止,从而为稍后重试提供了可能性。
-
直接使用消息代理队列实现相同结果的性能成本更高:我们应该保持数据库事务打开,直到我们从消息传输到消息代理那里收到确认、错误或超时。如果我们使用指数重试,这个时间会高几个数量级。
-
一旦消息进入内部队列,在出现故障的情况下,我们不需要撤销数据库更新,但需要简单地稍后重试消息传输。
-
由于数据库和消息代理的不同实现方式,以及数据库仅由微服务副本共享的事实,整个数据库事务(所需更新加在内部队列中注册输出消息)的成功执行确认比消息代理确认要快。
现在我们已经明确了如何处理输入和输出消息,无论是总体上还是针对我们的路由规划微服务,我们可以讨论如何恢复和维护正确的消息处理顺序。
确保消息按正确顺序处理
如前一小节所述,我们的路由规划微服务不需要强制执行正确的消息处理顺序。然而,在某些情况下,不可避免地需要按正确顺序处理所有消息,因此在本小节中,我们将讨论它们通常是如何处理的。
值得指出的是,强制执行正确消息处理顺序的策略对性能和可扩展性有不可忽视的影响,因此任何避免使用它们的技巧都受欢迎。
通常,顺序约束必须在同一相关消息组内强制执行,因此只需确保以下内容:
-
所有属于同一相关消息组的消息都由同一微服务副本处理,因此副本之间的并发不会打乱消息处理顺序。
-
每个副本仅在所有之前的消息都成功处理后才会处理消息。
正确操作上述技术需要每个消息在其组中包含其序列号。
通常,组与数据库实体相对应,或者更好,与数据库聚合相对应。也就是说,如果两个消息代表对同一实体的不同操作,则它们属于同一个组。因此,在我们的路由规划服务中,我们可能为每个请求和每条路由都有一个组。
现在假设有 N 个微服务副本,由整数 1 到 N 索引。我们可以定义一个哈希函数,它给定一个组标识符,返回一个介于 1 和 N 之间的数字。这样,如果我们将每个消息路由到由哈希函数应用于消息组的索引的副本,则同一组中的所有消息都将由同一副本处理。以下图例说明了消息路由策略:

图 7.4:消息分片
这种技术称为分片,如果哈希函数是公平的,每个副本将接收相同的平均负载。
因此,如果没有顺序约束,我们可以通过轮询策略实现精确的负载均衡,而在顺序约束的情况下,我们只能通过分片实现平均负载均衡。这意味着概率平衡波动肯定会引起暂时性拥塞。
分片还会导致在扩展副本数量时失去灵活性。实际上,更改副本数量会同时改变哈希函数和每个副本接收的消息组。因此,扩展操作的成本会更高,因此可以更频繁地执行。在实践中,大多数编排器会根据可定制的标准自动扩展非索引副本,但不会为需要索引的副本提供相同的服务。我们将在第八章“使用 Kubernetes 的实用微服务组织”中更详细地分析这些不同副本集之间的差异以及自动扩展。
可以使用单副本微服务来实现分片,该微服务接收来自消息代理的所有消息,并通过将它们发送到特定副本的消息代理队列将它们路由到适当的副本。这种技术更复杂,需要更多的编码,但更灵活。实际上,例如,如果它根据副本数量的变化而得知,它可以动态地根据副本数量调整其行为。
使用 RabbitMQ 主题也可以实现分片。基本上,主题是附加到消息上的一个字符串,并且可以为某些主题启用事件订阅者。因此,如果我们将哈希函数的结果作为主题附加到每条消息上,那么每个副本只需订阅与其索引相等的主题,从而无需额外组件即可实现分片。
基于主题的分片技术的缺点是副本的数量必须为所有发送者所知,并且只能通过重启整个应用程序来更改。此外,由于分配给每条消息的主题既取决于目标微服务如何定义消息组,也取决于目标微服务,因此副本数量技术不能用于消息由多个异构微服务接收的发布/订阅模式。
RabbitMQ 还有一个分片插件(github.com/rabbitmq/rabbitmq-server/tree/main/deps/rabbitmq_sharding),它计算一个模N散列。此插件定义了一种基于分片的路由策略的新类型交换,我们可以在每个单独的订阅者队列之前立即附加。此外,该插件负责将唯一的订阅者队列分割成N个不同的分片队列,并将所有订阅者分配到N个分片队列中。这种技术与单副本路由微服务技术完全类似,但集成在消息代理中需要以降低灵活性换取更好的性能。这种技术解决了基于主题技术的所有问题,但不支持高级EasyNetQ接口,因此增加了代码复杂性和可维护性。此外,它需要一个依赖于所有订阅者确切拓扑结构的代理配置,从而损害了应用程序的可扩展性。
总结来说,当使用发布者/订阅者通信时,最佳选择几乎总是单副本路由微服务技术。
在讨论了微服务的输入和输出之后,我们现在可以继续讨论微服务容器输入参数的设计。
设计 Docker 镜像环境参数
如同在第三章的更多 Docker 命令和选项子节中已经暗示的那样,设置和理论:Docker 和洋葱架构,容器通常通过作为容器虚拟文件系统的环境变量来适应其部署环境。在.NET 环境中,参数可以通过IConfiguration接口获得,以及所有在.NET 配置文件中定义的参数,例如appsettings.json。嵌套的 JSON 路径通过在所有段之间用冒号分隔来表示IConfiguration字典参数,例如IConfiguration[ConnectionStrings:DefaultConnection],它表示通常的默认数据库连接字符串。当嵌套路径由环境变量表示时,冒号被替换为双下划线,以便得到有效的环境变量名称。因此,ConnectionStrings:DefaultConnection必须使用名为ConnectionStrings__DefaultConnection的环境变量来定义。如果环境变量名称以ASPNETCORE_或DOTNET_为前缀,则这些前缀将被移除;因此,可以使用IConfiguration[“ENVIRONMENT”]来访问ASPNETCORE_ENVIRONMENT。这些前缀用于传递 ASP.NET Core 和.NET 特定的设置,例如预发布、生产或开发环境,并且ASPNETCORE_HTTP_PORTS也被使用,它包含 Kestrel 必须监听的所有端口的分号分隔列表。
您还可以定义自己的自定义前缀,并将其应用于所有环境变量以避免名称冲突。然而,由于每个微服务都有一个私有容器,因此不同应用程序使用的环境变量之间的冲突是不可能的。无论如何,可以在应用程序服务定义部分中使用类似于以下代码的代码定义新环境变量的自定义前缀:
builder.Configuration.AddEnvironmentVariables(prefix: "MyCustomPrefix_");
正如我们将在第八章“使用 Kubernetes 的实用微服务组织”中看到的那样,使用环境变量定义配置设置允许轻松地在所选协调器的代码文件中指定它们的值。
在开发过程中,可以在 Onion 架构顶层项目的Properties -> launchSettings.json文件中指定环境变量值,在我们的案例中,是RoutesPlanning项目。以下代码片段显示了放置您的环境变量值的位置:
"Container (Dockerfile)": {
"commandName": "Docker",
"launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}",
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8080"
//place here your application specific environment variables
},
在我们的案例中,我们需要以下内容:
-
数据库连接字符串
-
RabbitMQ 的连接字符串。
-
提出请求与路由之间匹配的最大距离,以及从数据库中检索的最佳匹配数量的最大值。
-
我们所有微服务副本的订阅 ID 前缀。此字符串用作我们微服务副本中所有订阅队列名称的前缀。
您在这个阶段不需要发现所有需要的设置,只需那些在您的微服务中起基本作用的设置即可。进一步的设置可以在稍后轻松添加。
因此,让我们将所有设置定义在launchSettings.json文件中,如下所示:
"environmentVariables": {
"ASPNETCORE_HTTP_PORTS": "8080",
//place here your environment variables
"ConnectionStrings__DefaultConnection": "",
"ConnectionStrings__RabbitMQConnection":
"host=localhost:5672;username=guest;password=guest;publisherConfirms=true;
timeout=10",
"Messages__SubscriptionIdPrefix": "routesPlanning",
"Topology__MaxDistanceKm": "50",
"Topology__MaxMatches": "5"
},
我们将数据库连接字符串留空。一旦我们定义了 SQL Server 开发数据库,我们就会填充它。
RabbitMQ 的连接字符串包含服务器 URL 和默认凭证。请注意,默认凭证仅在从localhost访问 RabbitMQ 时被接受,因此一旦您安装了服务器,就鼓励您更改它们。publisherConfirms=true通知 RabbitMQ 它必须确认消息已被安全接收,而timeout=10指定了连接超时时间(秒)。
微服务主服务
所有基于主机的现代.NET 应用程序都允许定义所谓的托管服务,这些服务类似于在整个应用程序生命周期中运行的 Windows 服务。它们可以通过实现IHostedService接口并将它们添加到应用程序的服务定义部分来定义,如下面的代码所示:
builder.Services.AddHostedService<MyHostedService>();
实际上,托管服务是通过从BackgroundService继承来定义的,它包含服务的一部分实现并公开了一个必须重写的ExecuteAsync方法。
我们的微服务需要三个托管服务。主要的一个监听来自消息代理的所有输入消息并处理它们。另一个托管服务从输出内部队列中提取消息并发送到消息代理。最后,第三个托管服务执行一些维护工作,例如删除过期的请求和路由。
本小节描述了主要托管服务。这个托管服务的任务相当简单,它监听我们定义的所有四个输入消息,一旦收到消息,它将为该消息创建一个特定的命令并调用与该命令关联的命令处理器。命令和命令处理器是 Onion 架构的构建块,这在第三章的命令小节中讨论过,设置和理论:Docker 和 Onion 架构。
让我们在 RoutesPlanning 项目中创建一个 HostedServices 文件夹。然后,向其中添加一个名为 MainService 的类,该类继承自 BackgroundService:
public class MainService() : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
throw new NotImplementedException();
}
}
类名后面跟着一对括号,因为它是我们将添加参数的主要构造函数。实际上,托管服务构造函数的所有参数都自动从依赖引擎容器中获取,因此我们可以将其所有需要执行其工作的服务放在那里:一个 IConfiguration 参数,以及一个我们将用于获取作用域服务的 IServiceProvider 接口。实际上,命令处理器是作用域服务,因此我们需要在要求它们之前创建一个请求作用域。
总结我们的主要构造函数,它看起来如下:
public class MainService(IConfiguration configuration, IServiceProvider services) : BackgroundService
在继续之前,让我们将此托管服务添加到依赖注入容器中,以便它在程序开始时立即执行。我们只需要将以下指令添加到 Program.cs:
builder.Services.AddHostedService<MainService>();
在工作微服务的情况下,消息和命令之间存在一对一的映射,命令所需的所有输入都包含在消息中,因此一个名为 MessageCommand<T> 的唯一泛型命令就足够了。让我们在 RoutesPlanningApplicationServices 项目的 Commands 文件夹中定义它:
public class MessageCommand<T>(T message): ICommand
{
public T Message => message;
}
现在,让我们定义一种方法,该方法给定一个类型为 T 的消息,创建一个作用域,要求适当的命令处理器,并执行它:
protected async Task ProcessMessage<T>(T message)
{
using (var scope = services.CreateScope())
{
var handler=scope.ServiceProvider.GetRequiredService<ICommandHandler<
MessageCommand<T>>>();
await handler.HandleAsync(new MessageCommand<T>(message));
}
}
错误,即在 ProcessMessage<T> 执行期间抛出的异常,通过计算连续错误的数量然后重新抛出异常来处理。正如我们将看到的,重新抛出异常基本上是撤销从消息代理队列中提取消息的操作,以便它可以再次被处理。
错误计数可以使用线程安全的临界区来执行,如下所示:
private readonly Lock _countErrorsLock = new();
private static int _errorCount = 0;
public static int ErrorsCount => _errorCount;
private void DeclareSuccessFailure(bool isFailure=false)
{
using (_countErrorsLock.EnterScope())
{
if (isFailure) _errorCount++;
else _errorCount = 0;
}
}
连续错误计数可以用来定义微服务的健康状态。现在,我们可以定义 ProcessMessage<T> 的错误保护包装器:
protected async Task SafeProcessMessage<T>(T message)
{
try
{
await ProcessMessage(message);
DeclareSuccessFailure();
}
catch
{
DeclareSuccessFailure(true);
throw;
}
}
让我们再定义一个小方法,用于计算每个消息要使用的订阅 ID:
string SubscriptionId<T>()
{
return string.Format("{0}_{1}",
configuration["Messages__SubscriptionIdPrefix"],
typeof(T).Name);
}
现在,我们准备定义我们的主要 ExecuteAsync 方法;但在做之前,我们必须添加对 EasyNetQ NuGet 包的引用。请选择一个大于或等于 8 的版本,如果是预发布版本也行。一旦我们安装了这个包,我们需要通过调用 AddEasyNetQ 扩展方法并将其 RabbitMQ 连接字符串传递给它,将其服务添加到 Program.cs 中的依赖注入:
builder.Services.AddEasyNetQ(
builder.Configuration?.GetConnectionString(
"RabbitMQConnection")??string.Empty)
.UseAlwaysNackWithRequeueConsumerErrorStrategy();;
连接调用定义了如何在接收消息处理程序中处理错误。我们决定重新排队有问题的消息,以便它们可以重试。如果一个微服务副本有问题并且对所有消息都产生错误,那么消息最终将由一个健康的副本处理,而不健康的副本最终将由于我们将暴露在健康端点上的连续错误计数而被发现。所有微服务编排器都会杀死并重新创建不健康的副本。
重排队策略通常是企业微服务最好的错误处理策略。无论如何,还有其他策略可用。如果没有指定策略,有问题的消息,即处理程序抛出异常的消息,将被排队在一个特殊的错误队列中,在那里可以使用管理工具手动处理(见github.com/EasyNetQ/EasyNetQ/wiki/Re-Submitting-Error-Messages-With-EasyNetQ.Hosepipe))。
通过 IBus 接口访问所有 EasyNetQ 通信设施。让我们将其添加到我们的托管服务主构造函数中:
public class MainService(IConfiguration configuration, IBus bus,
IServiceProvider services): BackgroundService
IBus 接口处理与三个属性的所有通信:
-
PubSub:这包含使用发布/订阅模式发送和接收消息的所有方法 -
SendReceive:这包含使用直接通信发送和接收消息的所有方法 -
Rpc:这包含发出异步远程过程调用并返回其响应的所有方法
在这里,我们将描述 PubSub,但 SendReceive 完全类似。唯一的区别是 Send 方法明确指定了目标队列的名称,而 Publish 则没有。Publish RabbitMQ 交换机的名称通过消息的类型隐式定义。
以下是一些发布方法:
Task PublishAsync(T message, CancelationToken cancel = default)
Task PublishAsync(T message, string topic,
CancelationToken cancel = default)
Task PublishAsync(T message, Action<IPublishConfiguration > configuration,
CancelationToken cancel = default)
第二个重载允许您指定消息主题,而第三个允许您指定可能包括消息主题的各种配置设置。
以下是一些订阅方法:
SubscriptionResult Subscribe<T>(string subscriptionId,
Func<T, Task> messageHandler, CancelationToken cancel = default)
SubscriptionResult Subscribe<T>(string subscriptionId,
Func<T, CancelationToken , Task> messageHandler,
Action<IsubscriptionConfiguration> configuration,
CancelationToken cancel = default)
返回的值必须被销毁以取消订阅。第二个重载接受消息处理程序中的 CancelationToken,并且也接受配置操作。接收器的配置包含更多有用的设置,其中以下是一些:
-
conf => conf.WithTopic(“mytopic”).WithTopic(“anothertopic”):消费者将只接收标记为所选主题之一的消息。 -
conf => conf.WithPrefetchCount(N):N是消费者从队列中提取的最大消息数,并等待处理。N默认为 20。 -
Conf => conf.WithDurable(durable):如果durable为true,所有消费者队列消息都将由 RabbitMQ 记录在磁盘上。默认为true。
如果必须按照它们在队列中插入的顺序处理消息,则必须将预取计数设置为1,并且我们还必须应用在确保消息按正确顺序处理子节中描述的一种策略。
如果我们使用Subscribe,所有预取的消息都会放入一个内部内存队列,并在一个独特的线程中处理。然而,也存在一个完全类似的SubscribeAsync,它创建几个并行线程。此外,SubscribeAsync,像往常一样,返回Task<SubscriptionResult>。
我们将使用SubscribeAsync来更好地利用处理器核心,以及磁盘/数据库操作和处理器操作之间的并行性,但使用几个微服务副本的事实已经利用了并行性。使用多个线程的优势在于创建线程的成本低于创建另一个副本,因此每个副本应该使用多个线程来优化性能。
当消息处理程序成功完成任务时,将自动向 RabbitMQ 发送确认,从队列中删除消息。
相反,如果消息处理程序抛出一个未处理的异常,将应用配置的消费者错误策略。在我们的例子中,我们将消息重新入队。
现在,我们终于准备好编写主要的ExecuteAsync方法。在我们的配置和准备方法之后,它变得非常直接:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var routeOfferSubscription = await bus.PubSub.
SubscribeAsync<RouteOfferMessage>(
SubscriptionId<RouteOfferMessage>(),SafeProcessMessage,
stoppingToken);
var routeClosedAbortedSubscription = await bus.PubSub.SubscribeAsync<
RouteClosedAbortedMessage>(
SubscriptionId<RouteClosedAbortedMessage>(), SafeProcessMessage,
stoppingToken);
var routeExtendedSubscription =
await bus.PubSub.SubscribeAsync<RouteExtendedMessage>(
SubscriptionId<RouteExtendedMessage>(), SafeProcessMessage,
stoppingToken);
var routeRequestSubscription = await bus.PubSub.
SubscribeAsync<RouteRequestMessage>(
SubscriptionId<RouteRequestMessage>(), SafeProcessMessage,
stoppingToken);
stoppingToken.WaitHandle.WaitOne();
routeRequestSubscription.Dispose();
routeExtendedSubscription.Dispose();
routeClosedAbortedSubscription.Dispose();
routeOfferSubscription.Dispose();
}
我们仅使用我们独特的泛型消息处理程序订阅所有消息,然后等待等待句柄stoppingToken.WaitHandle上的副本终止。一旦我们通过WaitOne()收到副本正在终止的通知,等待句柄将被解除阻塞,我们通过调用所有SubscriptionResult的Dispose方法来取消订阅所有消息。
在继续实现剩余的两个托管服务之前,为了完整性,我们还将描述 EasyNetQ 的 RPC 功能。
EasyNetQ 的 RPC 功能
可以使用以下方法发出 RPC 请求:
Task<TResponse> bus.Rpc.RequestAsync<TRequest, TResponse>(
TRequest request, CancelationToken cancel = default)
Task<TResponse> bus.Rpc.RequestAsync<TRequest, TResponse>(
TRequest request, Action<IRequestConfiguration> configuration,
CancelationToken cancel = default)
一旦发出请求,返回的任务最终将提供响应。我们可以使用await等待它,或者通过调用Task<T>.ContinueWith指定一个回调。
接收者可以使用以下方式监听请求并提供响应:
Task<IDisposable> bus.Rpc.RequestAsync<TRequest, TResponse>(
Func<TRequest, Task< TResponse >> handler,
CancelationToken cancel = default);
Task<IDisposable> bus.Rpc.RequestAsync<TRequest, TResponse>(
Func<TRequest, Task< TResponse >> handler,
Action<IResponderConfiguration> configuration,
CancelationToken cancel = default);
接收者可以通过处置前面方法返回的IDisposable来停止处理请求。
现在,让我们继续处理剩余的托管服务。
其他必需的托管服务
我们将从家务托管服务开始。让我们称它为HouseKeepingService,并将其与MainService一起放在HostedServices文件夹中:
public class HouseKeepingService(IConfiguration configuration, IBus bus,
IServiceProvider services): BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
throw new NotImplementedException();
}
}
在继续之前,让我们将新的托管服务添加到依赖注入容器中,这样它将在程序启动时立即执行。我们只需要将以下指令添加到Program.cs中:
builder.Services.AddHostedService<HouseKeepingService>();
我们需要一个构造函数指定在删除路由或请求过期后等待多少天才能删除的HouseKeepingCommand。像往常一样,让我们在RoutesPlanningApplicationServices的Commands文件夹中定义它:
public record HouseKeepingCommand(int DeleteDelay): ICommand;
我们还需要在launchSettings.json中定义Timing__HousekeepingIntervalHours和Timing__HousekeepingDelayDays环境变量:
"Topology__MaxDistanceKm": "50",
//new environment variables
"Timing__HousekeepingIntervalHours": "4",
"Timing__HousekeepingDelayDays": "10"
ExecuteAsync方法必须执行一个循环,直到应用程序发出终止信号。在这个循环内部,它执行处理器然后休眠由Timing__HousekeepingIntervalHours指定的时长,或者直到副本终止:
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
//update interval in milliseconds
int updateInterval = configuration.GetValue<int>(
"Timing:HousekeepingIntervalHours")*3600000;
int deleteDelayDays = configuration.GetValue<int>(
"Timing:HousekeepingDelayDays");
while (!stoppingToken.IsCancellationRequested)
{
try
{
using (var scope = services.CreateScope())
{
var handler = scope.ServiceProvider
.GetRequiredService<
ICommandHandler<HouseKeepingCommand>>();
await handler.HandleAsync(new HouseKeepingCommand(
deleteDelayDays));
}
}
catch {
// actual production application should log the error
}
await Task.Delay(updateInterval, stoppingToken);
}
}
在出现错误的情况下,我们简单地什么也不做,并在下一次迭代中重复操作。迭代末尾的Task.Delay指令使线程休眠,直到配置的间隔到期或stoppingToken发出副本终止信号。
让我们继续到最后一个托管服务。让我们重复相同的步骤来创建它并命名为OutputSendingService:
public class OutputSendingService(IConfiguration configuration, IBus bus,
IServiceProvider services) : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
throw new NotImplementedException();
}
}
像往常一样,让我们将新的托管服务添加到依赖注入容器中:
builder.Services.AddHostedService<OutputSendingService>();
这次,我们需要一个命令,它接受Func<RouteExtensionProposalsMessage,Task>作为输入。这个输入操作封装了将RouteExtensionProposalsMessage发送到 RabbitMQ 的代码,因为命令可以包含依赖于特定驱动程序的代码,在我们的例子中是 RabbitMQ 客户端。它还需要一个batchCount参数,该参数指定从输出队列中同时提取多少条输出消息,以及一个requeueDelay参数,该参数指定在消息未成功被消息代理接收后,消息重新入队的整体超时时间。
我们可以定义一个泛型命令,它只接收Func<T,Task>,这样我们就可以将其与其他输出消息一起重用;让我们称它为OutputSendingCommand:
public class OutputSendingCommand<T>(Func<T, Task> sender,
int batchCount, TimeSpan requeueDelay): ICommand
{
public Func<T, Task> Sender => sender;
public int BatchCount => batchCount;
public TimeSpan RequeueDelay => requeueDelay;
public bool OutPutEmpty { get; set; } = false;
}
命令中包含一个标志,其处理器将指示输出队列是否为空。我们将使用这个标志将托管服务线程休眠一段时间,以避免资源浪费。
再次,我们需要一个Timing__OutputEmptyDelayMS环境变量来配置输出队列为空时等待的时间。让我们将它添加到launchSettings.json中:
"Timing__OutputEmptyDelayMS": "500"
我们还需要传递给命令的batchCount和requeueDelay值:
"Timing__OutputBatchCount": "10",
"Timing__OutputRequeueDelayMin": "5"
假设我们需要实现一个SafeInvokeCommand,它也返回输出队列是否为空:
protected Task<bool> SafeInvokeCommand()
{
throw new NotImplementedException();
}
然后,可以实现ExetuteAsync方法如下:
readonly int updateBatchCount =
configuration.GetValue<int>("Timing:OutputBatchCount");
readonly TimeSpan requeueDelay = TimeSpan.FromMinutes(
configuration.GetValue<int>("Timing:OutputRequeueDelayMin"));
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
//update interval in milliseconds
int updateInterval =
configuration.GetValue<int>("Timing:HousekeepingIntervalHours") ;
bool queueEmpty = false;
while (!stoppingToken.IsCancellationRequested)
{
while (!queueEmpty && !stoppingToken.IsCancellationRequested)
{
queueEmpty=await SafeInvokeCommand();
}
await Task.Delay(updateInterval, stoppingToken);
queueEmpty = false;
}
}
一个外层循环仅在副本即将终止时退出,一个内层循环读取内部输出队列并将消息发送到消息代理,直到输出队列为空。当输出队列为空时,服务休眠以等待新消息被插入到内部输出队列中。
在实现SafeInvokeCommand之前,我们必须编写Func<T,Task>包装器以传递给命令:
protected Task SendMessage(RouteExtensionProposalsMessage message)
{
return bus.PubSub.PublishAsync<
RouteExtensionProposalsMessage>(message);
}
现在,实现与MainService的命令调用者类似:
protected async Task<bool> InvokeCommand()
{
using (var scope = services.CreateScope())
{
var handler = scope.ServiceProvider.GetRequiredService<
ICommandHandler<OutputSendingCommand<
RouteExtensionProposalsMessage>>>();
var command = new OutputSendingCommand<
RouteExtensionProposalsMessage>(
SendMessage,updateBatchCount, requeueDelay);
await handler.HandleAsync(command);
return command.OutPutEmpty;
}
}
protected async Task<bool> SafeInvokeCommand()
{
try
{
return await InvokeCommand();
}
catch
{
return true;
};
}
在发生异常的情况下,我们简单地返回true以使线程休眠一段时间。在下一节中,我们将使用 Polly 库定义重试策略。
使用 Polly 确保弹性任务执行
消息发送应该始终使用至少指数重试和我们在第二章的弹性任务执行子节中分析的电路断开策略进行保护,该章节是《揭秘微服务应用》。在本节中,我们将首先描述 Polly 库,它已成为处理弹性任务执行的一种标准,然后我们将将其应用于OutputSendingService的SendMessage方法。
Polly 库
使用名为Polly的.NET 库可以轻松实现弹性通信和一般弹性任务执行,该库的项目是.NET 基金会的成员。Polly 可以通过Polly NuGet 包获得。
在 Polly 中,你首先定义策略,然后在策略的上下文中执行任务,如下所示:
var myPolicy = Policy
.Handle<HttpRequestException>()
.Or<OperationCanceledException>()
.RetryAsync(3);
....
....
await myPolicy.ExecuteAsync(()=>{
//your code here
});
每个策略的第一部分指定了必须处理的异常。然后,你指定当捕获到这些异常之一时应该做什么。在上面的代码中,如果报告失败是由HttpRequestException异常或OperationCanceledException异常引起的,则Execute方法会重试最多三次。
以下是实现指数重试策略的代码:
var retryPolicy= Policy
...
//Exceptions to handle here
.WaitAndRetryAsync(6,retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
WaitAndRetryAsync的第一个参数指定在失败的情况下执行最多六次重试。作为第二个参数传递的 lambda 函数指定在下次尝试之前等待的时间。在这个特定的例子中,这个时间随着尝试次数的增加而指数增长,以 2 的幂次(第一次重试为两秒,第二次重试为四秒,依此类推)。以下是一个简单的电路断开策略:
var breakerPolicy =Policy
.Handle<SomeExceptionType>()
.CircuitBreakerAsync (6, TimeSpan.FromMinutes(1));
经过六次失败后,任务由于返回异常而无法执行一分钟。
以下是实现隔离舱隔离策略的代码:
Policy
.BulkheadAsync(10, 15)
在Execute方法中允许最多 10 个并行执行。进一步的任务将被插入到执行队列中。该队列的容量为 15 个任务。如果队列容量超过限制,将抛出异常。为了使 Bulkhead Isolation 策略正常工作,以及在一般情况下,为了使每个策略正常工作,任务执行必须通过相同的策略实例触发;否则,Polly 无法计算特定任务的活跃执行次数。
策略可以通过Wrap方法结合:
var combinedPolicy = Policy
.WrapAsync(retryPolicy, breakerPolicy);
Polly 提供了更多选项,例如为返回特定类型的任务提供通用方法、超时策略、任务结果缓存、定义自定义策略的能力等等。它还允许在 ASP. NET Core 和.NET 应用程序的依赖注入部分的HttpClient定义中将 Polly 配置为一部分。这样,定义健壮的 HTTP 客户端就变得相当直接。最后,版本 8 还引入了一个基于创建策略管道的新 API。
Polly 的官方文档可以在其 GitHub 存储库中找到:github.com/App-vNext/Polly。
在下一小节中,我们将安装和使用 Polly 来对微服务输出消息进行健壮传输到消息代理。
将 Polly 添加到我们的项目中
在我们的项目中使用 Polly 很简单。首先,您必须在RoutesPlanning项目中添加对 Polly 最新版本的 NuGet 包的引用。然后,您必须修改OutputSendingService类的SendMessage方法,如下所示:
protected Task SendMessage(RouteExtensionProposalsMessage message)
{
var retryPolicy = Policy
.Handle<Exception>()
.WaitAndRetryAsync(4,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(1,
retryAttempt)));
var circuitBreakerPolicy = Policy
.Handle<Exception>()
.CircuitBreakerAsync(4, circuitBreakDelay);
var combinedPolicy = Policy
.WrapAsync(retryPolicy, circuitBreakerPolicy);
return combinedPolicy.ExecuteAsync(
async () => await bus.PubSub.PublishAsync<
RouteExtensionProposalsMessage>(message));
}
我们首先定义一个指数重试策略,然后是一个断路器策略,最后在combinedPolicy.ExecuteAsync中结合它们并执行消息发送。
所有策略的参数都可以通过环境变量指定,但为了简单起见,我们除了circuitBreakDelay之外的所有值都保持为常量,即断路器应该持续的时间。实际上,这是唯一可能需要调整的关键参数。
circuitBreakDelay可以在launchSettings.json环境变量中配置,如下所示:
"Timing:OutputCircuitBreakMin": "4"
然后,它可以定义为OutputSendingService字段,如下所示:
readonly TimeSpan circuitBreakDelay = TimeSpan.FromMinutes(
configuration.GetValue<int>("Timing:OutputCircuitBreakMin"));
从抽象到实现细节
在前面的章节中,我们定义了路线规划微服务的整体组织结构。在本节的最后,我们将通过首先定义领域层和数据库驱动程序,然后定义所有命令来填充所有细节。
领域层
我们将在单独的文件夹中定义每个聚合,该文件夹将包含聚合、定义聚合状态的接口以及与聚合关联的存储库接口。
然而,在开始定义所有聚合之前,我们需要添加一个用于处理几何和 GIS 计算的著名库:NetTopologySuite。它既适用于 Java 也适用于.NET,并且所有类型都符合所有主要数据库认可的标准。
.NET 版本可通过 NetTopologySuite NuGet 包获得。因此,让我们将此包添加到 RoutesPlanningDomainLayer 项目中。GIS 对象坐标的含义在称为空间参考标识符(SRIDs)的整数分类文档中定义。每个文档指定了 x 和 y 坐标的含义,如何计算两点之间的距离,以及它适用的地球表面部分。每个 GIS 对象必须指定其坐标使用的 SRID,并且只有具有相同 SRID 的对象才能在同一计算中使用。
我们将使用 SRID 4326,它适用于地球的整个表面。X 是经度(以度为单位),Y 是纬度(以度为单位);距离通过将地球表面近似为椭球体来计算。使用适用于地球表面较小部分的 SRID 可以获得更精确的结果,但 SRID 4326 被所有主要数据库支持。
让我们在 RoutesPlanningDomainLayer 项目的根目录中定义的静态类中定义我们的整体默认 SRID:
namespace RoutesPlanningDomainLayer
{
public static class GeometryConstants
{
public static int DefaultSRID => 4326;
}
}
就像在消息的情况下,我们需要中间类型。让我们在 RoutesPlanningDomainLayer -> Models -> BasicTypes 文件夹中定义它们:
-
路线状态:
public enum RouteStatus { Open=0, Closed=1, Aborted=2 }; -
时间间隔:
public record TimeInterval { public DateTime Start { get; init; } public DateTime End { get; init; } } -
城市信息:
public record TownBasicInfo { public Guid Id { get; init; } public string Name { get; init; } = null!; public Point Location { get; init; } = null!; } -
用户信息:
public record UserBasicInfo() { public Guid Id { get; init; } public string DisplayName { get; init; } = null!; }
Point 是一个 NetTopologySuite 类型,它指定了地球表面上的一个点。请注意,所有前面的类型都是我们在第三章的域层小节中称为值对象的内容,设置和理论:Docker 和洋葱架构。因此,正如那里所建议的,我们将它们定义为 .NET 记录类型。
现在,我们可以开始定义我们的聚合。对于每一个,我们首先定义其状态接口,然后是聚合,最后是相关的存储库接口。通常,所有这些数据类型的定义是迭代的;也就是说,我们从一个初步草案开始,然后,当我们意识到我们需要另一个属性或方法时,我们添加它。
路线请求聚合
让我们为所有与用户请求相关的类型创建一个 Models -> Request 文件夹。用户请求的状态可以表示如下:
public interface IRouteRequestState
{
Guid Id { get; }
TownBasicInfo Source { get; }
TownBasicInfo Destination { get; }
DateTime WhenStart { get; }
DateTime WhenEnd { get; }
UserBasicInfo User { get; }
Guid? RouteId { get; set; }
public long TimeStamp { get; set; }
}
所有不能由聚合更改的属性都已定义为只读属性。Id 在整个应用程序中唯一标识每个请求。Source 和 Destination 分别是期望出发和到达的城市,而 WhenStart 和 WhenEnd 定义了可接受的旅行日期。然后,我们有关于发起请求的用户和与请求相关联的当前时间戳的信息。最后,RouteId 是请求已添加到的路线的唯一标识符(如果有的话)。如果请求仍然开放,此属性为 null。
聚合可以定义为以下内容:
public class RouteRequestAggregate(IRouteRequestState state):
Entity<Guid>
{
public override Guid Id => state.Id;
public TownBasicInfo Source => state.Source;
public TownBasicInfo Destination => state.Destination;
TimeInterval _When = null!;
public TimeInterval When => _When ??
(_When=new TimeInterval {Start = state.WhenStart, End = state.
WhenEnd });
public UserBasicInfo User => state.User;
public bool Open => state.RouteId == null;
public long TimeStamp => state.TimeStamp;
public void DetachFromRoute() => state.RouteId = null;
public void AttachToRoute(Guid routeId) => state.RouteId = routeId;
}
值得注意的是,一旦请求被创建,只有其 state.RouteId 可以更改。这是因为一旦发出,每个请求都不能修改,只能与现有路由匹配。
仓库接口如下:
public interface IRouteRequestRepository : IRepository
{
RouteRequestAggregate New(
Guid id,
TownBasicInfo source,
TownBasicInfo destination,
TimeInterval when,
UserBasicInfo user
);
Task<RouteRequestAggregate?> Get(Guid id);
Task<IList<RouteRequestAggregate>> Get(Guid[] ids);
Task<IList<RouteRequestAggregate>> GetInRoute(Guid routeId);
Task<IList<RouteRequestAggregate>> GetMatch(IEnumerable<Coordinate>
geometry,
DateTime when, double distance, int maxResults);
Task DeleteBefore(DateTime milestone);
}
New 方法创建聚合的新实例及其数据库附加状态。然后,我们有方法从它们的 Id 获取单个或多个现有聚合,以及由同一路由提供的所有聚合。
GetMatch 方法返回所有与路由最佳匹配的聚合。路由由它通过的城镇的坐标(geometry)和日期(When)指定。Coordinate 是一个 NetTopologySuite 类型,它只包含位置的 X 和 Y 坐标,没有其 SRID(之前定义的默认 SRID 是隐含的)。distance 指定请求和路由之间的最大距离,以便发生匹配。所有结果都根据它们与路由的距离排序,并且返回最多 maxResults 个请求。
DeleteBefore 方法用于通过删除旧的和过期的请求来执行一些维护工作。
路线报价聚合
让我们为所有与用户路线报价相关的类型创建一个 Models -> Route 文件夹。用户请求的状态可以表示如下:
public interface IRouteOfferState
{
Guid Id { get; }
LineString Path { get; set; }
DateTime When { get; }
UserBasicInfo User { get; }
RouteStatus Status { get; set; }
public long TimeStamp { get; set; }
}
LineString 是一个 NetTopologySuite 类型,它表示由地球表面上的连续段组成的路径。基本上,它是一系列带有附加 SRID 的坐标。Status 是路由的状态(对其他参与者开放、关闭或已取消)。
聚合可以定义如下:
public class RouteOfferAggregate
(IRouteOfferState state): Entity<Guid>
{
public override Guid Id => state.Id;
IReadOnlyList<Coordinate>? _Path=null;
public IReadOnlyList<Coordinate> Path => _Path != null ? _Path : (
_Path = state.Path.Coordinates.ToImmutableList());
public DateTime When => state.When;
public UserBasicInfo User => state.User;
public RouteStatus Status => state.Status;
public long TimeStamp => state.TimeStamp;
…
…
}
在这里,我们已经添加了点代替我们很快将要分析的方法。聚合状态中包含的 LineString 路径被公开为一个不可变的坐标列表,这样就不能直接修改它,也不能更改其 SRID。
它包含一个在接收到需要扩展路由的消息时被调用的 Extend 方法。消息中包含的数据作为其参数传递:
public void Extend(long timestamp,
IEnumerable<Guid> addedRequests,
Coordinate[] newRoute, bool closed)
{
if (timestamp > TimeStamp)
{
state.Path = new LineString(newRoute)
{ SRID = GeometryConstants.DefaultSRID };
_Path = null;
state.TimeStamp = timestamp;
}
if(state.Status != RouteStatus.Aborted)
AddDomainEvent(new AttachedRequestEvent {
AddedRequests = addedRequests,
RouteOffer = Id
});
Close();
}
只有当路径比聚合中存储的路径更新时,路径才会更新,而扩展消息中包含的请求始终附加到路线报价,因为每个消息都不包含所有匹配的请求,而只是新添加的请求,所以如果收到旧消息,它们也必须被添加。唯一不需要添加请求的情况是当路线已被取消时,因为已取消的路线会释放所有附加的请求。
将请求附加到聚合的任务留给事件处理器以实现更好的模块化。因此,Extend 方法向聚合事件列表中添加一个 AttachedRequestEvent 事件。事件定义必须放在 Events 文件夹中,并定义如下:
public class AttachedRequestEvent : IEventNotification
{
public IEnumerable<Guid> AddedRequests { get; set; } = new List<Guid>();
public Guid RouteOffer { get; set; }
}
最后,如果扩展消息声明路由已关闭,Extend 方法通过调用以下定义的 Close() 方法来关闭它:
public void Close()
{
state.Status = RouteStatus.Closed;
}
此外,还有一个 Abort 方法,它声明路由已中止:
public void Abort()
{
state.Status = RouteStatus.Aborted;
AddDomainEvent(new ReleasedRequestsEvent
{
AbortedRoute = Id
});
}
它将聚合状态设置为已中止,然后通过 ReleasedRequestsEvent 事件将释放所有附加请求的任务留给事件处理器,以实现更好的模块化:
public class ReleasedRequestsEvent:IEventNotification
{
public Guid AbortedRoute { get; set; }
}
让我们继续到仓储接口:
public interface IRouteOfferRepository : IRepository
{
RouteOfferAggregate New(Guid id, Coordinate[] path, UserBasicInfo
user, DateTime When);
Task<RouteOfferAggregate?> Get(Guid id);
Task<IList<RouteOfferAggregate>> GetMatch(
Point source, Point destination, TimeInterval when,
double distance, int maxResults);
Task DeleteBefore(DateTime milestone);
}
New 方法创建一个新的聚合,然后我们有一个方法可以从其唯一标识符获取聚合。GetMatch 和 DeleteBefore 方法与请求的类似,但在这个情况下,GetMatch 返回所有与给定请求匹配的路由报价。
输出队列项聚合
这个聚合表示一个通用的输出队列项。文件将被放置在 Models -> OutputQueue 文件夹中。聚合状态可以定义如下:
public interface IQueueItemState
{
Guid Id { get; }
int MessageCode { get; }
public string MessageContent { get; }
}
每个队列项都有一个唯一的 ID 和一个消息代码,指定存储在项中的消息类型。而消息内容是输出消息的 JSON 表示。聚合是简单的:
public class QueueItem(IQueueItemState state): Entity<Guid>
{
public override Guid Id => state.Id;
public int MessageCode => state.MessageCode;
public T? GetMessage<T>()
{
if (string.IsNullOrWhiteSpace(state.MessageContent))
return default;
return JsonSerializer.Deserialize<T>(state.MessageContent);
}
}
GetMessage 方法反序列化项中包含的消息。
最后,仓储接口如下:
public interface IOutputQueueRepository: IRepository
{
Task<IList<QueueItem>> Take(int N, TimeSpan requeueAfter);
void Confirm(Guid[] ids);
QueueItem New<T>(T item, int messageCode);
}
每个队列项都附有时间,并且只有在时间过期后,队列项才能被队列提取。此外,队列项按时间顺序提取。
Take 方法从队列中提取前 N 个项,然后立即通过将它们的提取时间替换为提取时间加上 requeueAfter TimeSpan 来重新排队。这样,如果消息在 requeueAfter 之前成功发送,它们将从队列中删除;否则,它们将再次可用于从队列中提取,并且它们的传输将重试。
Confirm 方法删除所有成功发送的消息,而 New 方法将新项添加到输出队列。
现在,我们可以继续使用 Entity Framework 实体实现所有聚合状态,以及实现所有仓储。
数据库驱动
在开始实现 RoutesPlanningDBDriver 驱动之前,我们必须添加对 Microsoft.EntityFrameworkCore.SqlServer.NetTopologySuite NuGet 包的引用,该包为 Entity Framework Core 添加了对所有 NetTopolgySuite 类型的支持。然后,我们必须在 Extensions -> DBExtensions.cs 文件中声明对 NetTopolgySuite 的使用:
options.UseSqlServer(connectionString,
b => {
b.MigrationsAssembly("DBDriver");
// added code
b.UseNetTopologySuite();
}));
现在,我们可以在 Entities 文件夹中定义我们需要的所有实体:
-
路由报价:
internal class RouteOffer: IRouteOfferState { public Guid Id { get; set; } public LineString Path { get; set; } = null!; public DateTime When { get; set; } public UserBasicInfo User { get; set; } = null!; public RouteStatus Status { get; set; } public ICollection<RouteRequest> Requests { get; set; } = null!; public long TimeStamp { get; set; } } -
路由请求:
internal class RouteRequest: IRouteRequestState { public Guid Id { get; set; } public TownBasicInfo Source { get; set; }=null!; public TownBasicInfo Destination { get; set; } = null!; public DateTime WhenStart { get; set; } public DateTime WhenEnd { get; set; } public long TimeStamp { get; set; } public UserBasicInfo User { get; set; } = null!; public Guid? RouteId { get; set; } public RouteOffer? Route { get; set; } } -
队列项:
internal class OutputQueueItem: IQueueItemState { public Guid Id { get; set; } public int MessageCode { get; set; } public string MessageContent { get; set; } = null!; public DateTime ReadyTime { get; set; } }
然后,在 MainDBContext.cs 文件中,我们必须添加相应的集合:
public DbSet<RouteRequest> RouteRequests { get; set; } = null!;
public DbSet<RouteOffer> RouteOffers { get; set; } = null!;
public DbSet<OutputQueueItem> OutputQueueItems { get; set; } = null!;
最后,在同一个文件的 OnModelCreating 方法中,我们必须声明 RouteOffer 和 RouteRequest 之间的关系:
builder.Entity<RouteOffer>().HasMany(m => m.Requests)
.WithOne(m => m.Route)
.HasForeignKey(m => m.RouteId)
.OnDelete(DeleteBehavior.Cascade);
我们还必须使用 OwnsOne 声明一些索引和值对象(及其索引)的使用:
builder.Entity<RouteRequest>().OwnsOne(m => m.Source);
builder.Entity<RouteRequest>().OwnsOne(m => m.Destination);
builder.Entity<RouteRequest>().OwnsOne(m => m.User);
builder.Entity<RouteRequest>().HasIndex(m => m.WhenStart);
builder.Entity<RouteRequest>().HasIndex(m => m.WhenEnd);
builder.Entity<RouteOffer>().OwnsOne(m => m.User);
builder.Entity<RouteOffer>().HasIndex(m => m.When);
builder.Entity<RouteOffer>().HasIndex(m => m.Status);
builder.Entity<OutputQueueItem>().HasIndex(m => m.ReadyTime);
现在让我们继续实现所有仓储。
IOutputQueueRepository 实现
所有仓储实现遵循相同的基本模式:
internal class OutputQueueRepository(IUnitOfWork uow) : IOutputQueueRepository
{
readonly MainDbContext ctx = (uow as MainDbContext)!;
public void Confirm(Guid[] ids)
…
public QueueItem New<T>(T item, int messageCode)
…
public async Task<IList<QueueItem>> Take(int N, TimeSpan requeueAfter)
…
}
}
它们从主构造函数中获取 IUnitOfWork 并将其转换为数据库上下文。
New 方法的实现如下:
public QueueItem New<T>(T item, int messageCode)
{
var entity = new OutputQueueItem()
{
Id = Guid.NewGuid(),
MessageCode = messageCode,
MessageContent = JsonSerializer.Serialize(item)
};
var res = new QueueItem(entity);
ctx.OutputQueueItems.Add(entity);
return res;
}
Confirm 的实现同样简单直接:
public void Confirm(Guid[] ids)
{
var entities = ctx.ChangeTracker.Entries<OutputQueueItem>()
.Where(m => ids.Contains(m.Entity.Id)).Select(m => m.Entity);
ctx.OutputQueueItems.RemoveRange(entities);
}
它使用更改跟踪器获取所有已加载的具有给定 ID 的实体。
Take 的实现稍微复杂一些,因为它需要事务来处理各种微服务副本之间的竞争,因为它们都使用相同的数据库:
public async Task<IList<QueueItem>> Take(int N, TimeSpan requeueAfter)
{
List<OutputQueueItem> entities;
using (var tx =
await ctx.Database.BeginTransactionAsync(IsolationLevel.
Serializable))
{
var now = DateTime.Now;
entities = await ctx.OutputQueueItems.Where(m => m.ReadyTime <=
now)
.OrderBy(m => m.ReadyTime)
.Take(N)
.ToListAsync();
if (entities.Count > 0)
{
foreach (var entity in entities)
{ entity.ReadyTime = now + requeueAfter; }
await ctx.SaveChangesAsync();
await tx.CommitAsync();
}
return entities.Select(m => new QueueItem(m)).ToList();
}
}
一旦所有实体都被提取出来,ReadyTime 就会被移动到未来时间,以防止在其他副本中使用,直到 requeueAfter 过期,如果它们没有被 Confirm 移除,它们将再次变得可用。这样,如果在获取成功传输的过程中所有重试和断路器策略都失败了,可以在 requeueAfter 之后重试相同的操作。读取和更新必须作为同一可序列化事务的一部分,以防止来自其他副本的干扰。
IRouteRequestRepository 的实现
仓库结构与之前仓库的结构完全相同:
internal class RouteRequestRepository(IUnitOfWork uow) : IRouteRequestRepository
{
readonly MainDbContext ctx = (uow as MainDbContext)!;
public async Task DeleteBefore(DateTime milestone)
…
public async Task<RouteRequestAggregate?> Get(Guid id)
…
public async Task<IList<RouteRequestAggregate>> GetInRoute(Guid
routeId)
…
public async Task<IList<RouteRequestAggregate>> GetMatch(
IEnumerable<Coordinate> geometry, DateTime when,
double distance, int maxResults)
…
public RouteRequestAggregate New(Guid id,
TownBasicInfo source, TownBasicInfo destination,
TimeInterval when, UserBasicInfo user)
…
}
使用最近的 ExecuteDeleteAsync Entity Framework Core 扩展,DeleteBefore 方法很容易实现:
public async Task DeleteBefore(DateTime milestone)
{
await ctx.RouteRequests.Where(m => m.WhenEnd < milestone).ExecuteDeleteAsync();
}
在以下代码块中,我们可以看到 New 方法:
public RouteRequestAggregate New(Guid id, TownBasicInfo source,
TownBasicInfo destination, TimeInterval when, UserBasicInfo user)
{
var entity = new RouteRequest()
{
Id = id,
Source = source,
Destination = destination,
WhenStart = when.Start,
WhenEnd = when.End,
User = user
};
var res = new RouteRequestAggregate(entity);
res.AddDomainEvent(new NewMatchCandidateEvent<RouteRequestAggregate>(res));
ctx.RouteRequests.Add(entity);
return res;
}
它创建一个 Entity Framework Core 实体,将其添加到 ctx.RouteRequests 中,并使用它作为状态来创建 RouteRequestAggregate。它还向聚合添加了一个 NewMatchCandidateEvent<RouteRequestAggregate> 事件。关联的事件处理程序将负责找到所有与请求匹配的路由并为每个创建一个输出消息。NewMatchCandidateEvent<T> 在 RoutesPlanningDomainLayer 项目的 Events 文件夹中定义,如下所示:
public class NewMatchCandidateEvent<T>(T matchCandidate):
IEventNotification
{
public T MatchCandidate => matchCandidate;
}
所有其他方法都包含相当标准的 Entity Framework Core 代码,因此我们在这里只描述 GetMatch 方法,因为它使用了 Entity Framework 特殊查询扩展。所有其他方法的代码可以在书籍 GitHub 仓库的 ch07 文件夹中找到 (github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp):
public async Task<IList<RouteRequestAggregate>> GetMatch(
IEnumerable<Coordinate> geometry, DateTime when,
double distance, int maxResults)
{
var lineString = new LineString(geometry.ToArray())
{ SRID = GeometryConstants.DefaultSRID };
var entities = await ctx.RouteRequests.Where(m =>
m.RouteId == null &&
when <= m.WhenEnd && when >= m.WhenStart &&
lineString.Distance(m.Source.Location) < distance &&
lineString.Distance(m.Destination.Location) < distance)
.Select(m => new
{
Distance = lineString.Distance(m.Source.Location),
Entity = m
})
.OrderBy(m => m.Distance)
.Take(maxResults).ToListAsync();
return entities
.Select(m => new RouteRequestAggregate(m.Entity))
.ToList();
}
首先,我们从路线路径创建一个 LineString 几何对象,然后开始查询。Where 子句首先将搜索限制为尚未附加到其他路线的请求。然后,它通过使用 LineString.Distance 方法验证时间兼容性和距离兼容性。所有几何对象都有一个 Distance 方法,因此我们可以执行涉及任何类型几何对象的几何查询。
最后,我们返回一个包含距离和检索到的实体的匿名对象。这样,我们可以按距离排序数据并提取最佳 maxResults 匹配项。
IRouteOfferRepository 的实现
再次,仓库结构与前一个仓库的结构相同:
internal class RouteOfferRepository(IUnitOfWork uow) : IRouteOfferRepository
{
readonly MainDbContext ctx = (uow as MainDbContext)!;
public async Task DeleteBefore(DateTime milestone)
…
public async Task<RouteOfferAggregate?> Get(Guid id)
…
public async Task<IList<RouteOfferAggregate>> GetMatch(
Point source, Point destination, TimeInterval when,
double distance, int maxResults)
…
public RouteOfferAggregate New(Guid id, Coordinate[] path,
UserBasicInfo user, DateTime When)
…
}
DeleteBefore 方法与先前存储库中的方法类似:
public async Task DeleteBefore(DateTime milestone)
{
await ctx.RouteOffers.Where(m => m.When < milestone).ExecuteDeleteAsync();
}
New 方法也与请求存储库中的方法相同,但它生成 NewMatchCandidateEvent< RouteOfferAggregate> 事件,其处理器寻找匹配的请求。
再次强调,我们只描述了 GetMatch 方法,因为所有其他方法都非常标准:
public async Task<IList<RouteOfferAggregate>> GetMatch(
Point source, Point destination,
TimeInterval when, double distance, int maxResults)
{
var entities = await ctx.RouteOffers.Where(m =>
m.Status == RouteStatus.Open &&
m.When <= when.End && m.When >= when.Start &&
source.Distance(m.Path) < distance)
.Select(m => new
{
Distance = source.Distance(m.Path),
Entity = m
})
.OrderBy(m => m.Distance)
.Take(maxResults).ToListAsync();
return entities
.Select(m => new RouteOfferAggregate(m.Entity))
.ToList();
}
Where 子句首先将搜索限制为所有开放路线。然后,它验证时间和距离约束,就像在先前存储库的相同 GetMatch 方法中一样。排序方式也与先前存储库相同。
定义好一切之后,我们现在可以继续进行迁移。
创建迁移和数据库
在生成数据库迁移之前,我们必须在数据库驱动程序内部实现 IDesignTimeDbContextFactory<MainDbContext> 接口。所有迁移工具都会寻找这个实现来创建 MainDbContext 的实例,以便获取数据库配置和数据库连接字符串的信息。因此,让我们向 RoutesPlanningDBDriver 项目的根目录添加一个 LibraryDesignTimeDbContextFactory 类:
internal class LibraryDesignTimeDbContextFactory :
IDesignTimeDbContextFactory<MainDbContext>
{
private const string connectionString =
@"Server=<your sql server instance name>;Database=RoutesPlanning;
User Id=sa;Password=<your password>;Trust Server Certificate=True;
MultipleActiveResultSets=true ";
public MainDbContext CreateDbContext(string[] args)
{
var builder = new DbContextOptionsBuilder<MainDbContext>();
builder.UseSqlServer(
connectionString,
x => x.UseNetTopologySuite());
return new MainDbContext(builder.Options);
}
}
请将我留下的字符串中的占位符替换为您的 SQL Server 实例名称和密码。获取连接字符串的最简单方法是从 Visual Studio 内连接到数据库,然后从属性选项卡中复制连接字符串。请记住,您不能使用与 Visual Studio 一起安装的 SQL 数据库,因为它无法监听 TCP/IP 连接,因此无法从 Docker 镜像内部访问。
现在,我们也可以添加在 launchSettings.json 中留空的 SQL Server 连接字符串:
"ConnectionStrings__DefaultConnection":
"Server=host.docker.internal;Database=RoutesPlanning;User Id=sa;
Password=<our password>;Trust Server Certificate=True;MultipleActiveResultSets=true"
再次提醒,请添加您的密码。host.docker.internal 是您的开发计算机的网络名称,该计算机运行 Docker 或本地 Kubernetes 模拟器。如果您在您的机器上直接安装了它,或者如果您在您的计算机上运行了 SQL Server Docker 镜像,请使用它。如果您使用云或其他网络实例,请将其替换为适当的名称。
现在,让我们将 RoutesPlanningDBDriver 设置为我们的 Visual Studio 启动项目,并在 Visual Studio 的 Package Manager Console 中选择它:

图 7.5:在 Package Manager Console 中选择项目
我们现在可以开始在 Package Manager Console 中发布我们的第一个迁移:
Add-Migration initial
请注意,如果您从与本书相关的 GitHub 存储库中复制了项目,您不需要执行前面的命令,因为那里已经创建了迁移。您只需要使用以下命令创建数据库。
如果前一个命令成功,您可以使用以下命令创建数据库:
Update-Database
完成!我们现在可以继续实现所有命令和事件处理器。
应用程序服务:定义所有命令和事件处理器
在本节中,我们将定义所有必需的命令和事件处理器。在开始之前,我们需要在RoutesPlanningApplicationServices项目中添加对Microsoft.Extensions.Configuration.Abstractions和Microsoft.Extensions.Configuration.Binder NuGet 包的引用。这样,我们就可以通过IConfiguration接口使所有处理器能够从依赖注入引擎接收配置数据。
所有命令处理器构造函数都需要一些存储库接口,IUnitofWork用于最终化修改和处理事务,以及一个EventMediator实例用于触发添加到聚合体的所有事件。
我们不会描述所有处理器,只描述那些具有教学附加值的处理器。你可以在书籍 GitHub 存储库的ch07文件夹中找到完整的代码(github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp)。
我们将所有处理消息的命令处理器放置在CommandHandlers -> Messages文件夹中。
让我们从RouterOfferMessage处理器开始:
internal class RouterOfferMessageHandler(
IRouteOfferRepository repo,
IUnitOfWork uow,
EventMediator mediator
) : ICommandHandler<MessageCommand<RouteOfferMessage>>
{
public async Task HandleAsync(MessageCommand<RouteOfferMessage>
command)
{
var message = command.Message;
var toCreate = repo.New(message.Id,
message.Path!.Select(m =>
new Coordinate(m.Location!.Longitude, m.Location.Latitude)).
ToArray(),
new UserBasicInfo { Id = message.User!.Id,
DisplayName = message.User.DisplayName! },
message.When!.Value
);
if (toCreate.DomainEvents != null && toCreate.DomainEvents.Count >
0)
await mediator.TriggerEvents(toCreate.DomainEvents);
try
{
await uow.SaveEntitiesAsync();
}
catch (ConstraintViolationException) { }
}
}
处理程序从消息中提取创建新聚合体所需的所有数据,然后将它传递给New存储库方法。然后,它验证创建的聚合体是否包含事件,并使用EventMediator实例来触发所有相关的事件处理器。在发生唯一键违反的情况下,ConstraintViolationException由IUnitOdWork实现创建。在我们的情况下,这个异常仅在我们收到重复的RouterOfferMessage时才会抛出。因此,我们只需捕获它并什么都不做,因为重复的消息必须被忽略。
RouteRequestMessageHandler与之完全类似,因此我们不会对其进行描述。
让我们继续到RouteClosedAbortedMessage处理器:
public async Task HandleAsync(MessageCommand<RouteClosedAbortedMessage> command)
{
var message = command.Message;
await uow.StartAsync(System.Data.IsolationLevel.Serializable);
try
{
var route = await repo.Get(message.RouteId);
if (route is not null)
{
if(!message.IsAborted)
{
if(route.Status != RouteStatus.Open)
{
await uow.RollbackAsync();
return;
}
else route.Close();
}
else
{
if(route.Status == RouteStatus.Aborted)
{
await uow.RollbackAsync();
return;
}
else route.Abort();
}
if (route.DomainEvents != null && route.DomainEvents.Count
> 0)
mediator.Equals(route.DomainEvents);
await uow.SaveEntitiesAsync();
await uow.CommitAsync();
}
else
{
await uow.RollbackAsync();
return;
}
}
catch
{
await uow.RollbackAsync();
throw;
}
}
}
整个操作都封装在一个可序列化的事务中,以避免与其他可能接收到有关同一路由报价的较旧或未来消息的微服务副本之间的干扰。实际上,它们可能在读取之后但在修改之前修改相同的实体。可序列化事务防止了这种可能性。
如果我们没有找到实体,我们将不采取任何行动,并简单地终止事务。实际上,这种情况可能只会在路由过期并被删除时发生。然而,如果实体在它们过期后的一段时间内被删除,这应该是一个几乎不可能发生的事件。
如果消息指定路由必须关闭,只有当聚合体仍然处于开启状态时,我们才会通过调用Close()方法将聚合体置于关闭状态。实际上,如果它已经关闭或终止,这将是一条旧消息或重复消息,必须被忽略。
类似地,如果消息指定路由应该被终止,只有在聚合体尚未处于终止状态时才会进行处理。
最后,在出现错误的情况下,我们中止事务并重新抛出异常,这样消息就不会被确认,并且消息将在稍后时间再次被处理,可能由不同的副本处理。
现在,让我们继续到 RouteExtendedMessage 处理器:
internal class RouteExtendedMessageHandler(
IRouteOfferRepository repo,
IUnitOfWork uow,
EventMediator mediator
) : ICommandHandler<MessageCommand<RouteExtendedMessage>>
{
public async Task HandleAsync(MessageCommand<RouteExtendedMessage> command)
{
var message = command.Message;
await uow.StartAsync(System.Data.IsolationLevel.Serializable);
try
{
var route = await repo.Get(message.ExtendedRoute!.Id);
if (route is not null && route.TimeStamp != message.TimeStamp)
{
route.Extend(message.TimeStamp,
message.AddedRequests!.Select(m => m.Id),
message.ExtendedRoute.Path!
.Select(m => new Coordinate(m.Location!.Longitude,
m.Location.Latitude)).ToArray(),message.
Closed);
if (route.DomainEvents != null && route.DomainEvents.Count
> 0)
mediator.Equals(route.DomainEvents);
await uow.SaveEntitiesAsync();
await uow.CommitAsync();
}
else
{
await uow.RollbackAsync();
return;
}
}
catch
{
await uow.RollbackAsync();
throw;
}
}
}
此外,在这种情况下,由于命令处理器既执行了读取又执行了修改,我们需要显式的事务。
再次强调,如果没有找到实体,我们将不采取任何行动,原因与之前处理器的解释相同。如果消息的时间戳与实体中包含的时间戳相同,我们也不会采取任何行动,因为在这种情况下,消息是重复的。否则,我们只需调用聚合的 Extend 方法,然后触发 Extend 方法生成的可能事件。
现在让我们继续到与消息无关的处理器。它们被放置在 CommandHandlers 文件夹的根目录下。
让我们从 HouseKeepingCommandHandler 开始,该处理器删除旧的过期请求和路由:
internal class HouseKeepingCommandHandler(
IRouteRequestRepository requestRepo,
IRouteOfferRepository offerRepo
) : ICommandHandler<HouseKeepingCommand>
{
public async Task HandleAsync(HouseKeepingCommand command)
{
var deleteTrigger = DateTime.Now.AddDays( -command.DeleteDelay );
await offerRepo.DeleteBefore(deleteTrigger);
await requestRepo.DeleteBefore(deleteTrigger);
}
}
它非常简单,因为它只是从当前时间减去延迟或删除所有过期实体的时间,然后调用删除路由和请求的存储库方法。它不需要保存更改,因为每个这些方法已经与数据库进行了交互。
处理输出队列的 OutputSendingCommandHandler 要复杂一些:
internal class OutputSendingCommandHandler(
IOutputQueueRepository repo,
IUnitOfWork uow
): ICommandHandler<
OutputSendingCommand<RouteExtensionProposalsMessage>>
{
public async Task HandleAsync(OutputSendingCommand<
RouteExtensionProposalsMessage> command)
{
var aggregates =await repo.Take
(command.BatchCount, command.RequeueDelay);
if(aggregates.Count==0)
{
command.OutPutEmpty = true;
return;
}
var allTasks = aggregates.Select(
m => (m, command.Sender(m.GetMessage<
RouteExtensionProposalsMessage>()!)))
.ToDictionary(m => m.Item1!, m => m.Item2 );
try
{
await Task.WhenAll(allTasks.Values.ToArray());
}
catch
{
}
repo.Confirm(aggregates
.Where(m =>!allTasks[m].IsFaulted && !allTasks[m].IsFaulted)
.Select(m => m.Id).ToArray());
await uow.SaveEntitiesAsync();
}
它尝试从输出队列中取出 command.BatchCount 个项目。如果没有找到项目,它通知命令队列已为空,这反过来又通知队列处理托管服务它可以稍微休息一下。
然后,它反序列化所有消息并将它们传递给 Sender 委托。然而,它不会等待此方法返回的每个任务,而是收集所有任务,将它们放入一个数组中,并使用 Task.WhenAll 等待整个数组。这样,所有消息都并发发送,从而提高了性能。在出现异常的情况下,它什么也不做,因为未发送的消息在 repo.Confirm 内部的 LINQ 指令中被检测到,并且它们相关的队列项被排除在所有要确认的项目数组之外,因此它们将在稍后时间重试。
我们已经完成了所有命令处理器。让我们继续到事件处理器。
编写所有事件处理器
通常,事件处理器不会创建事务,也不会尝试将修改存储在数据库中,因为它们是由命令处理器调用的,这些处理器会完成这项任务;因此,它们的代码通常要简单一些。我们有四个事件处理器,它们都放置在 EventHandlers 文件夹的根目录下。
让我们从 AttachedRequestEvent 处理器开始:
internal class AttachedRequestEventHandler(
IRouteRequestRepository repo
) : IEventHandler<AttachedRequestEvent>
{
public async Task HandleAsync(AttachedRequestEvent ev)
{
var requests = await repo.Get(ev.AddedRequests.ToArray());
foreach (var request in requests) request.AttachToRoute(
ev.RouteOffer);
}
}
此处理器负责将请求附加到路由上。其代码很简单:它只是检索所有聚合体及其键,然后将它们附加到事件中引用的路由。
ReleasedRequestsEvent 处理器负责释放附加到已中止路由的所有请求。其代码也很简单:
internal class ReleasedRequestsEventHandler(
IRouteRequestRepository repo
) : IEventHandler<ReleasedRequestsEvent>
{
public async Task HandleAsync(ReleasedRequestsEvent ev)
{
var requests=await repo.GetInRoute(ev.AbortedRoute);
foreach(var request in requests) request.DetachFromRoute();
}
}
它检索所有附加到路由的请求,并简单地断开每个请求的连接。
最后,我们有两个事件处理器,用于发现路由请求匹配项并将它们添加到微服务输出队列。第一个在添加新请求时触发,而第二个在添加新报价时触发。由于它们非常相似,我们只描述第一个:
internal class RequestMatchCandidateEventHandler(
IRouteOfferRepository offerRepo,
IOutputQueueRepository queueRepo,
IConfiguration configuration) :
IEventHandler<NewMatchCandidateEvent<RouteRequestAggregate>>
{
private RouteRequestMessage PrepareMessage(RouteRequestAggregate m)
=> new RouteRequestMessage
…
…
public async Task HandleAsync(
NewMatchCandidateEvent<RouteRequestAggregate> ev)
{
double maxDistance = configuration
.GetValue<double>("Topology:MaxDistanceKm") * 1000d;
int maxResults = configuration
.GetValue<int>("Topology:MaxMatches");
var offers = await offerRepo.GetMatch(
ev.MatchCandidate.Source.Location,
ev.MatchCandidate.Destination.Location,
ev.MatchCandidate.When, maxDistance, maxResults);
var proposals = Enumerable.Repeat(ev.MatchCandidate, 1)
.Select(m => PrepareMessage(m)).ToList();
foreach (var offer in offers)
{
var message = new RouteExtensionProposalsMessage
{
RouteId = offer.Id,
Proposals = proposals,
};
queueRepo.New<RouteExtensionProposalsMessage>(message, 1);
}
}
}
PrepareMessage 方法只是使用对应 RouteRequest\regate 中包含的数据填充一个 RouteRequestMessage。我们不会对其进行描述,因为它非常简单。
HandleAsync 方法首先从配置数据中提取搜索所需的参数。然后,它调用存储库的 GetMatch 方法来查找所有匹配项。最后,对于检索到的每个路由,它创建一个输出消息并将其添加到内部队列。由于输出消息需要一个列表,因此请求被转换为一个单例列表。
我们的微服务代码已经完成!在将其与消息源和消息接收者连接后,我们将在下一章对其进行测试。在那里,我们还将实现微服务的健康检查端点并将它们连接到编排器。
摘要
本章详细介绍了如何设计和编码一个容器化的微服务。特别是,它描述了如何设计其输入和输出消息以及端点,以及如何使用消息代理来实现基于事件的通信。它还描述了如何处理乱序和重复的消息,多个微服务副本的并发输出生产,以及使用数据库内部队列的事务性输出。
然后,它描述了工作型服务组织的结构是基于托管服务,以及在这种情况下,命令与所有输入消息一一对应执行。最后,它描述了如何为任何微服务的洋葱架构的所有层级进行编码。
所有概念都是通过书中案例研究应用的路线规划工作型微服务的实际示例进行解释的。你现在应该理解了 RabbitMQ 消息代理和 NetTopologySuite 库在实现空间计算和查询中的实际应用。
下一章将描述编排器,特别关注 Kubernetes。在那里,我们将通过将其与其他微服务连接以及使用编排器来管理所有微服务来测试本章编写的微服务。
问题
- 工作型微服务通常需要身份验证和授权吗?加密通信协议又是如何的呢?
由于它们的处理过程不与特定应用程序用户相关联,因此不需要身份验证。建议使用加密通信,但由于它们在隔离环境中运行,因此并非总是必需。
- 应该在哪里放置所有微服务的输入和输出消息?
在某些类型的队列中。
- 在使用多个微服务副本的同时保持消息正确处理顺序的技术叫什么?
分片。
- 如果修改消息包含整个更新后的实体,并且删除是逻辑的,那么消息的顺序是否真的不重要?
这是真的。
- 在 .NET 中通常使用哪个库来处理具有重试策略的失败?
Polly 在 .NET 中用于处理具有重试策略的失败。
- 领域事件是在哪里创建的?在它们的处理器被触发之前它们在哪里?
在创建它们的聚合体包含的列表中。
- 为什么事件处理器通常不使用事务和
IUnitOfWork.SaveEntitiesAsync?
因为事务是由引起事件的命令处理器创建和处理的。
- 在发送多个并发输出消息时,我们如何发现哪些成功了,哪些失败了,哪些被取消了?
通过确认。
- SRID 是什么?
空间参考标识符。它们命名地理坐标系。
- 所有
NetTopologySuite几何对象的Distance方法能否在 SQL Server 数据库的 LINQ 查询中使用?
是的。
进一步阅读
-
RabbitMQ 官方文档:
www.rabbitmq.com/. -
EasyNetQ 官方文档:
github.com/EasyNetQ/EasyNetQ/wiki/Introduction. -
Polly 文档:
github.com/App-vNext/Polly. -
RabbitMQ 分片插件:
github.com/rabbitmq/rabbitmq-server/tree/main/deps/rabbitmq_sharding. -
Entity Framework Core 的空间数据扩展:
learn.microsoft.com/en-us/ef/core/modeling/spatial. -
NetTopologySuite:
nettopologysuite.github.io/NetTopologySuite/.
加入我们的 Discord 社区
加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

第八章:使用 Kubernetes 实践微服务组织
本章致力于微服务应用程序的基本构建块:编排器!重点是 Kubernetes,但在这里学到的概念对于理解其他编排选项也是基本的。特别是,Azure Container Apps 是 Kubernetes 的无服务器替代方案,它本身是用 Kubernetes 实现的,并使用简化的配置选项,但配置的对象和涉及的概念完全相同。Azure Container Apps 在 第九章 简化容器和 Kubernetes:Azure Container Apps 和其他工具 中进行描述。
所有概念将通过小型示例和汽车共享案例研究应用程序进行说明。在一般描述编排器的角色和功能之后,我们将描述如何在实际中配置和与 Kubernetes 集群交互。本章将使用 Minikube,这是一个 Kubernetes 集群的本地模拟器。然而,我们也会解释如何创建和使用 Kubernetes Azure 集群。
我们将首先描述如何在开发过程中使用 Docker 测试和调试一些微服务的交互,然后是运行在 Kubernetes 集群中的完整应用程序。在开发阶段测试微服务应用程序的 .NET 特定替代方案是 .NET Aspire,它将在 第十二章 使用 .NET Aspire 简化微服务 中进行描述。
更具体地说,本章涵盖:
-
编排器及其配置的介绍
-
Kubernetes 基础
-
与 Kubernetes 交互:Kubectl 和 Minikube
-
在 Kubernetes 中配置您的应用程序
-
在 Kubernetes 上运行您的微服务
-
高级 Kubernetes 配置
技术要求
本章需要:
-
至少 Visual Studio 2022 的免费 社区版。
-
一个接受 TCP/IP 请求和用户/密码认证的 SQL 实例,以及 Windows 的 Docker Desktop,其安装已在 第七章 实践中的微服务 的 技术要求 部分中解释。
-
如果您想与 Azure 上的 Kubernetes 集群交互,则需要 Azure CLI。以下页面包含 32 位和 64 位 Windows 安装程序的链接:
learn.microsoft.com/bs-latn-ba/cli/azure/install-azure-cli-windows?tabs=azure-cli。 -
Minikube:安装 Minikube 的最简单方法是使用您可以在官方安装页面找到的 Windows 安装程序:
minikube.sigs.k8s.io/docs/start/。在安装过程中,您将收到提示选择要使用的虚拟化工具 – 请指定 Docker。前面的链接还提供了一个将minicube.exe添加到 Windows 路径的 PowerShell 命令。 -
Kubectl:首先,通过打开 Windows 控制台并执行此命令来验证它是否已安装:
Kubectl -h。如果响应是所有 Kubectl 命令的列表,则已安装。否则,安装它的最简单方法是通过Chocolatey包安装程序:choco install kubernetes-cli -
如果 Chocolatey 尚未安装,您可以通过以管理员模式启动PowerShell并执行官方 Chocolatey 页面上的建议命令来安装它:
chocolatey.org/install#individual。您可以通过以下方式以管理员模式启动 PowerShell:-
在 Windows 搜索框中搜索PowerShell。
-
右键单击PowerShell链接,并选择以管理员身份执行。
-
您可以在github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp找到本章的示例代码。
编排器和它们的配置简介
编排器主要是为了平衡微服务的负载而设计的。因此,有人可能会问,它们对所有应用程序都是必要的吗?我无法说它们是必要的,但肯定的是,放弃它们并不意味着只是手动配置每个微服务的每个副本的位置。我们还应该找到有效的解决方案来动态重新配置副本的数量和位置,以平衡分配在不同服务器上的多个副本之间的负载,以及平衡每个微服务的各个副本之间的流量。
上述简单考虑表明,一个有效的编排器至少应提供以下服务:
-
接受高级规格并将其转换为在给定集群的不同服务器上实际分配微服务副本。
-
为同一微服务的所有副本提供一个唯一的虚拟地址,并自动在它们之间分配流量。这样,每个微服务的代码只需引用这个唯一的虚拟地址,而无需关心每个副本的位置。
-
识别故障副本,终止它们,并用新创建的副本替换它们。
-
从容器注册表中下载微服务容器镜像。
此外,由于微服务副本是短暂的,可以被销毁并从一个服务器移动到另一个服务器,因此它们不能使用托管它们的服务器的磁盘存储。相反,它们必须使用网络存储。编排器还必须提供简单的方法来分配磁盘存储并在运行微服务的容器内挂载它。一般来说,它们必须提供简单的方法来投影容器内可以投影的所有内容,即:
-
磁盘存储
-
环境变量
-
通信端口
实际上,每个编排器还提供其他服务,但上面列出的七个服务是学习和评估任何编排器的起点。
编排器的行为由来自各种来源的树形设置控制:配置文件、命令参数等。在幕后,所有来源都由一个与编排器 Web API 通信的客户端打包。
所有可能的编排器设置都像 .NET 配置设置一样组织在树形数据结构中。因此,类似于 .NET 设置,它们可以以 JSON 格式或其他等效格式提供。实际上,所有编排器都接受 JSON 或另一种称为 .yaml 的等效格式的设置。一些编排器接受这两种格式;其他可能只接受其中之一。.yaml 格式将在下一小节中描述。
.yaml 文件
与 JSON 文件一样,.yaml 文件可以用来以可读的方式描述嵌套对象和集合,但它们使用不同的语法。您有对象和列表,但对象属性不被 {} 包围,列表也不被 [] 包围。相反,通过简单地使用空格缩进其内容来声明嵌套对象。空格的数量可以自由选择,但一旦选择,就必须始终一致地使用。
列表项可以通过在它们前面加上破折号 (-) 来与对象属性区分开来。下面是一个涉及嵌套对象和集合的示例:
Name: John
Surname: Smith
Spouse:
Name: Mary
Surname: Smith
Addresses:
- Type: home
Country: England # I am a comment
Town: London
Street: My home street
- Type: office
Country: England
Town: London
Street: My home street
在每一行中,所有跟随 # 字符的字符都被认为是注释。
之前的 Person 对象有一个嵌套的 Spouse 对象和一个嵌套的地址集合。在 JSON 中的相同示例将是:
{
Name: John
Surname: Smith
Spouse:
{
Name: Mary
Surname: Smith
}
Addresses:
[
{
Type: home
Country: England
Town: London
Street: My home street
},
{
Type: office
Country: England
Town: London
Street: My home street
}
]
}
如您所见,.yaml 语法更易读,因为它避免了括号的冗余。
.yaml 文件可以包含多个部分,每个部分定义不同的对象,它们通过包含 --- 字符串的行进行分隔。注释由 # 符号开头,每个注释行都必须重复该符号。
由于空格/制表符有助于对象语义,YAML 对空格/制表符敏感,因此必须注意添加正确数量的空格。
小集合或小对象也可以使用通常的 [] 和 {} 语法内联指定,即在属性值的同一行的冒号之后。
在了解了编排器和.yaml文件的基础知识后,我们就可以学习最广泛使用的编排器:Kubernetes。目前,它也是最完整的。因此,一旦你了解了它,学习其他编排器应该非常容易。
Kubernetes 基础知识
Kubernetes 编排器是分布式软件,必须在网络的每个虚拟服务器上安装。大多数 Kubernetes 软件仅安装在称为主节点的一些机器上,而所有其他机器仅运行称为Kubelet的接口软件,该软件与主节点上的软件连接,并在本地执行由主节点决定的任务。Kubernetes 集群中的所有机器都称为节点。
实际上,所有节点还必须运行容器运行时,以便能够运行容器。正如我们稍后将会看到的,所有节点还运行处理虚拟地址的软件。
Kubernetes 配置单元是具有属性、子部分和其他对象引用的抽象对象。它们被称为Kubernetes 资源。我们有描述单个微服务副本的资源,以及其他描述一组副本的资源。资源描述通信设置、磁盘存储、用户、角色以及各种安全约束。
集群节点以及它们所承载的所有资源都由主节点管理,主节点通过 API 服务器与人类集群管理员进行通信,如下所示:

图 8.1:Kubernetes 集群
Kubectl 是通常用于向 API 服务器发送命令和配置数据的客户端。调度器根据管理员的约束将资源分配给节点,而控制器管理器将多个守护进程分组,这些守护进程监控集群的实际状态,并尝试将其移动到通过 API 服务器声明的期望状态。有几个控制器用于 Kubernetes 资源,从微服务副本到通信设施。实际上,每个资源在应用程序运行期间都有一些需要保持的目标目标,控制器会验证这些目标是否真正实现,可能触发纠正操作,例如将运行速度过慢的一些 Pod 移动到更少拥挤的节点上。
部署单元,即可以部署在服务器上、启动、终止和/或移动到另一个服务器的单元,不是一个单独的容器,而是一组称为Pod的容器。
Pod 是一组被限制在同一个服务器上一起运行的容器。
Pod 的概念是基本的,因为它能够实现非常有用且强大的协作模式。例如,我们可能将另一个容器附加到我们的主容器上,该容器的唯一目的是读取主容器创建的日志文件并将它们发送到集中的日志服务。
侧边栏模式由增强主容器并在同一 Pod 上部署的次要容器组成,其唯一目的是为主容器提供一些服务。
通常,当我们需要容器通过它们的节点文件系统进行通信,或者需要每个容器副本与特定副本的其他容器相关联时,我们会将多个容器放在一起同一个 Pod 中。
在 Kubernetes 中,Pod之间的通信由称为服务的资源处理,这些服务由 Kubernetes 基础设施分配虚拟地址,并将它们的通信转发到满足某些约束的一组 Pod。简而言之,服务是 Kubernetes 为 Pod 集合分配固定虚拟地址的方式。
所有 Kubernetes 资源都可以分配名为标签的键值对,这些标签用于通过模式匹配机制引用它们。因此,例如,所有从同一服务接收流量的Pod都可以通过指定它们必须在服务定义中具有的标签来选择。
Kubernetes 集群可以是本地部署的,也就是说,Kubernetes 可以安装在任何私有网络上。但更常见的是,它们作为云服务提供。例如,Azure 提供Azure Kubernetes Service (AKS)。
在本书的剩余部分,我们将使用运行在您的开发机器上的Minikube Kubernetes 模拟器,因为实际的 AKS 服务可能会迅速耗尽您的所有 Azure 免费额度。然而,我们示例中的所有操作都可以在实际集群上复制,并且当存在差异时,我们还将描述如何在 AKS 上执行操作。
让我们从与 Kubernetes 集群交互开始。
与 Kubernetes 交互:kubectl,Minikube,和 AKS
在使用 Kubectl 客户端与 Kubernetes 集群交互之前,我们必须配置 Kubectl 并向其提供集群 URL 和必要的凭据。
安装完成后,kubectl 将为每个计算机用户创建不同的 JSON 配置文件,其中将包含所有 Kubernetes 集群及其用户的配置信息。kubectl 有命令用于插入新的 Kubernetes 集群配置,以及将集群配置设置为当前配置。
由 Kubernetes 集群 API URL 和用户凭据组成的每一对称为上下文。上下文、凭据和集群连接可以使用各种kubectl config子命令定义。以下是最有用的几个:
-
查看整体配置文件:
kubectl config view -
添加新的 Kubernetes 集群:
kubectl config set-cluster my-cluster --server=https://<your cluster API server URL> -
用户凭据基于客户端证书。可以通过创建证书请求并将其提交到 Kubernetes 集群来获得有效的证书,Kubernetes 集群将创建一个批准的证书。详细步骤将在第十章**,无服务器和微服务应用程序的安全性和可观察性中展示。一旦获得批准的证书,就可以使用以下方式创建用户:
Kubectl config set-credentials newusername --client-key= newusername.key --client-certificate=poweruser.crt --embed-certs=true
其中 newusername.key 是你用于创建证书请求的私钥的完整路径,而 newusername.crt 是已批准的证书文件的完整路径。
-
一旦你有了服务器和用户,你可以使用以下命令为该用户到该服务器的连接创建一个上下文:
kubectl config set-context newcontext --cluster= my-cluster --user= newusername -
一旦所有需要的上下文都已被正确定义,你可以使用以下命令切换到指定的上下文:
kubectl config use-context newcontext -
设置了新的当前上下文后,所有 Kubectl 命令都将使用在该上下文中定义的集群和用户。
如果你已经是集群管理员,你的用户已经在系统中存在,因此你不需要创建它。然而,你需要获取管理员用户凭据并将它们添加到配置文件中。每个云服务都有一个登录过程来完成这项工作。例如,在 AKS 的情况下,过程如下:
-
使用 Azure CLI 登录到 Azure:
az login -
默认浏览器应该会打开,并提示你输入你的 Azure 凭据。
-
如果尚未安装,请安装用于与 AKS 交互的包:
az aks install-cli -
请求将你的 AKS 凭据添加到你的 Kubectl 配置文件中:
az aks get-credentials --resource-group <your AKS resource group name> --name <your AKS name> -
如果命令成功,新的集群、新的用户和新的上下文将被添加到你的 Kubectl 配置中,并且新的上下文将成为当前上下文。请运行
kubectl config view来查看所有配置文件修改。
Minikube 默认包含一个用户、一个默认集群名称和一个默认上下文,它们都被称为 minikube。当你使用 minikube start 启动你的 Minikube 集群时,如果尚未定义,上述所有实体都将添加到你的 Kubectl 配置文件中。此外,minikube 上下文将自动成为当前上下文,因此启动集群后无需额外操作。当然,你也可以定义其他用户和其他上下文。
可以使用 minikube stop 停止 Minikube,使用 minikube pause 暂停。停止和暂停都不会删除集群数据和配置。其他有用的命令将在使用 Minikube 的示例中稍后展示。
让我们在 Minikube 上尝试一些 Kubectl 命令(确保 Minikube 已经启动):
kubectl get nodes
它应该显示所有虚拟网络 Kubernetes 节点。默认情况下,Minikube 创建一个名为 minikube 的单节点集群,因此你应该看到类似以下内容:
NAME STATUS ROLES AGE VERSION
minikube Ready control-plane,master 35m v1.22.3
由于我们指定了 Docker 作为虚拟化工具,整个集群都将嵌入到一个 Docker 容器中,你可以通过使用 docker ps 列出所有正在运行的容器来验证(记住,所有 Docker 命令都必须在 Linux shell 中执行)。
默认情况下,这个独特的节点包含 2 个 CPU 和 4 GB 的 RAM,但我们可以修改所有这些参数,我们也可以通过传递一些选项给 minikube start 来创建具有多个节点的集群:
-
--nodes <n>: 指定集群中的节点数量。请考虑节点是同时运行的虚拟机,因此只有具有多个核心和 32-64 GB RAM 的强大工作站才能设置大量节点。默认为 1。 -
--cpus <n or no-limits>: 分配给 Kubernetes 的 CPU 数量,或no-limits以让 Minikube 分配所需的 CPU 数量。默认为 2。 -
--memory <string>: 分配给 Kubernetes 的 RAM 量(格式:[ ],其中 unit = b, k, m 或 g)。使用“max”以使用最大内存量。使用“no-limit”以不指定限制。 -
--profile <string>: Minikube 虚拟机的名称(默认为minikube)。对于拥有多个 Minikube 虚拟机很有用——例如,一个节点和一个有两个节点的另一个。 -
--disk-size <string>: 分配给 Minikube VM 的磁盘大小(格式:[ ],其中 unit = b, k, m 或 g)。默认为“20000mb”。
如果您在创建 Minikube 容器后(使用第一个 minikube start)想要更改上述设置之一,您需要使用 minikube delete 删除之前的容器,或者使用 --profile 选项创建一个具有自定义名称的新 Minikube 容器。
在这个简短的括号之后,让我们回到 Kubectl!让我们输入:
kubectl get all
它列出了所有 Kubernetes 资源。如果您尚未创建任何资源,则集群应仅包含一个类型为 ClusterIP 的单个资源,如下所示:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 87m
它是 Kubernetes 基础设施的一部分。
通常,kubectl get <resource type> 列出给定类型的所有资源。例如,kubectl get pods 列出所有 Pod,kubectl get services 列出所有服务。
如果我们需要有关特定对象的更详细信息,我们可以使用 kubectl describe <object type> <object name>。例如,如果我们需要有关名为 minikube 的 Minikube 单节点更多信息,我们可以执行以下命令:
kubectl describe node minikube
请尝试一下!
在学习如何在本章的其他部分定义 Pod、服务和其他 Kubernetes 资源时,您将看到其他 Kubectl 命令。下一个小节解释了如何创建 Azure Kubernetes 集群,所以如果您目前不打算使用 Azure Kubernetes,请随意跳过。当您需要创建一个时,您可以返回。
创建 Azure Kubernetes 集群
要创建 AKS 集群,请执行以下操作:
-
在 Azure 搜索框中输入
AKS。 -
选择 Kubernetes 服务。
-
然后点击 创建 按钮。
-
选择 Kubernetes 集群。
之后,将出现以下形式:

图 8.2:AKS 创建第一个表单
这里,像往常一样,您可以选择您的 Azure 订阅、现有的资源组,或者您可以创建一个新的。让我们继续到 AKS 特定配置:
-
集群预设配置:在这里,您可以从各种预配置设置中选择,这些设置是各种情况的良好起点。在前面的屏幕截图中,我选择了开发/测试,这是专门针对开发和学习的,因此它提出了最经济的选项。然而,您也可以选择标准生产或经济生产初始设置。
-
Kubernetes 集群名称:在这里,您必须为您的集群选择一个唯一的名称。
-
对于所有其他设置,您可以选择建议的默认值。特别是,区域字段应建议最适合您的区域。AKS 定价层应设置为免费,这意味着您只需为构成集群的虚拟机付费。然而,您也可以选择包括支持和超级大集群(最多 5,000 个节点)在内的付费选项。可用区字段可在最多 3 个不同的地理区域中启用地理冗余。
如果您选择了开发/测试,则集群将包括 2 到 5 个节点,并具有自动扩展功能。也就是说,起始节点数为 2,但如果工作负载增加,它可以自动增加到 5。让我们转到节点池选项卡来自定义节点数量和类型:

图 8.3:AKS 节点池配置
如果您选择了开发/测试,则应该有一个独特的节点池,它将用于 Kubernetes 主节点和标准节点。请注意,开发/测试服务器类型(D4ds-v5)的月费用很高,因此在选择之前,请使用价格计算器(azure.microsoft.com/en-us/pricing/details/virtual-machines/linux/#pricing)验证机器的成本。
相反,标准生产选择将创建两个节点池——一个用于主节点,另一个用于标准节点。
无论如何,您都可以更改节点池并编辑每个节点池。在前面屏幕截图中,让我们点击agentpool。将打开一个新的表单。在这里,您可以更改机器类型和最大节点数。在没有浪费太多信用的情况下进行实验的好选项是选择 3 个节点和一个A系列机器。当您完成这些操作之一后,请点击更新或取消以返回到上一个表单。
最后,您可以通过转到集成选项卡将 Azure 容器注册表与集群关联:

图 8.4:将您的集群连接到 ACR
如果您已经在第三章**的一些更多 Docker 命令和选项*子节中为实验定义了 Azure 容器注册表,请选择该注册表;否则,您可以在新浏览器窗口中创建一个新的注册表并选择它,或者您可以在稍后时间将注册表与您的集群关联。
当你将注册表关联到你的集群时,你使集群能够访问和下载所有其 Docker 镜像。
当你完成时,选择 Review + Create。
一旦你创建了你的集群,你可以使用本节之前解释的登录程序连接到它。
现在你已经学会了如何连接到 Minikube 和 AKS,让我们继续实验 Kubernetes 资源。
在 Kubernetes 中配置你的应用程序
如前所述,最简单的 Kubernetes 资源是 Pod。我们不会创建单个 Pod,因为我们总是要为每个微服务创建多个副本,但能够配置 Pod 对于创建更复杂的资源也是基础性的,所以让我们开始创建一个单个 Pod。
Pod 可以通过以下内容的 .yaml 文件进行定义:
apiVersion: v1
kind: Pod
metadata:
name: my-podname
namespace: mypodnamespace
labels:
labenname1: labelvalue1
labelname2: labelvalue2
spec:
restartPolicy: Always #Optional. Possible values: Always (default), OnFailure. Never.
containers:
…
initContainers:
…
所有 Kubernetes 配置文件都以配置的资源定义的 API 名称及其版本开头。对于 Pod 来说,我们只有版本,因为它们是在 核心 API 中定义的。然后,kind 定义了要配置的资源类型 – 在我们的例子中,是一个 Pod。
与 C# 中的类型一样,Kubernetes 资源也是按命名空间组织的。因此,除了任何资源名称外,我们还必须指定一个命名空间。如果没有指定命名空间,则假定命名空间为 default。
注意!虽然 Kubernetes 和 C# 命名空间的目的相同,但它们之间存在重大差异。具体来说,C# 命名空间是分层的,而 Kubernetes 命名空间不是。此外,命名空间并不适用于所有 Kubernetes 资源,因为有一些集群范围内的资源不属于任何特定的命名空间。
如果在资源定义中使用的命名空间尚不存在,则必须使用以下片段进行定义:
apiVersion: v1
kind: Namespace
metadata:
name: my-namespace
--- row.
名称和命名空间作为 metadata 的子属性指定,以及可选的标签。标签是我们可以用来自分类对象的自由名称值对。通常,它们指定有关资源在应用程序中的作用以及它所属的层或模块的信息。
如前所述,在上一节中,其他资源可以使用标签来选择一组资源。
spec 属性指定了 Pod 的实际内容,即其容器及其重启策略 (restartPolicy)。重启策略指定何时重启 Pod:
-
restartPolicy: Always:这是默认值。当所有容器终止或容器以失败状态终止时,Pod 将被重启。 -
restartPolicy: OnFailure:当至少有一个容器以失败状态退出时,Pod 将被重启。 -
restartPolicy: Never:Pod 从不会被重启。
容器被分为两个列表:containers 和 initContainers。containers 列表中的容器仅在 initContainers 列表中的所有容器都 成功 后启动,并且 initContainers 列表中的每个容器仅在之前的容器 成功 后启动。反过来,initContainers 列表中的容器在以下两种情况下被认为是 成功 的:
-
如果容器配置的
restartPolicy属性设置为Always,则容器被认为是成功的,如果它已成功启动。此选项对于实现 sidecar 容器很有用。这样,我们确保在增强容器的容器启动之前,sidecars 已经就绪。请参阅 Kubernetes 基础知识 部分开头的 Pod 定义,以了解 sidecar 是什么。 -
如果容器配置没有将
restartPolicy属性设置为Always,则容器被认为是成功的,如果它已成功终止。此选项对于执行一些启动初始化很有用——例如,等待数据库或消息代理就绪。在类似的情况下,容器代码是一个循环,持续尝试与数据库/消息代理建立连接,并在成功后终止。
失败的 initContainers 不会导致整个 Pod 重新启动。相反,它会在导致整个 Pod 失败之前进行指数重试几次。因此,它们应该设计为幂等的,因为它们的行为可能会执行多次。
上述两个列表中的每个容器都类似于:
- name: <container name>
image: <container image URL>
command: […] # square bracket contains all strings that compose the OS command
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 250m
memory: 256Mi
ports:
- containerPort: 80
- containerPort: …
…
env:
-name: env-name1
value: env-value1
…
volumeMounts:
- name: data
mountPath: /mypath/mysubpath….
subPath: /vsubpath #optional. If provided the path of data mounted in mountPath
…
我们在容器注册库中指定容器的名称和其镜像的 URL,这符合任何编排器应提供的最小服务中的第 4 点(参见 编排器和其配置简介 部分的开头)。这两个属性是必需的,而所有其他属性都是可选的。当提供 command 属性时,它将覆盖 Docker 文件中的 CMD 指令。
然后,我们还考虑了任何编排器应提供的最小服务中的第 5、6 和 7 点,即磁盘存储、环境变量和通信端口。更具体地说,我们有:
-
volumeMount指定由name指定的虚拟存储卷如何映射到容器文件系统中由mountPath指定的路径。如果提供了可选的subPath,则仅挂载由name指定的卷的该subpath。虚拟存储卷将在本章后面的部分(在 永久磁盘空间的动态分配 子部分中)进行描述,以及其他volumeMounts属性。 -
env指定所有容器的环境变量,以name-value对的形式列出。 -
ports指定了我们希望在应用程序中使用的容器暴露的所有端口的列表。这些端口可能映射到 Pod 之间实际通信中的其他端口。然而,端口映射是在其他资源中指定的,这些资源被称为services,它们提供虚拟 Pod 地址和其他通信相关选项。
最后,resources 部分指定了启动容器所需的最低计算资源(requests)以及它可以浪费的最高计算资源(limits)。
requests属性中的约束用于选择放置 Pod 的虚拟机。相反,limits是由操作系统内核强制执行的,如下所示:
-
CPU 限制通过节流来强制执行。也就是说,超过 CPU 限制的容器会被延迟,使它们进入睡眠模式足够长的时间。
-
当内存限制被超过时,会抛出一个异常来强制执行内存限制。反过来,这个异常会导致 Pod 的重启策略被应用,这通常会导致 Pod 重启。
关于度量单位,典型的内存度量单位是Ti(千兆字节)、Gi(兆字节)、Mi(兆字节)和Ki(千字节)。而 CPU 时间,则可以以毫核(mi)或作为核心数的分数(值后没有单位)来衡量。
让我们尝试一个带有侧边容器 Pod 的例子,这展示了描述的语法的实际用法以及侧边容器如何帮助构建应用级监控。主容器将是一个基于 Alpine Linux 发行版 Docker 镜像的虚构微服务,它只是将日志消息放入与侧边容器共享的目录中的文件。在实际应用中,日志会被组织在几个文件中(例如,每个文件对应一天),旧文件会定期删除。此外,侧边容器会读取这些文件并将它们的内容发送到日志 API。我们的教学侧边容器,相反,只是定期读取文件的最后 10 行,并在其控制台中显示它们。
代码相当简单。首先,我们定义一个命名空间来包围我们的示例:
apiVersion: v1
kind: Namespace
metadata:
name: basic-examples
然后,在---行之后,我们放置实际的 Pod 定义:
---
apiVersion: v1
kind: Pod
metadata:
name: pod-demo
namespace: basic-examples
labels:
app: myapp
spec:
containers:
- name: myapp
image: alpine:latest
command: ['sh', '-c', 'while true; do echo $(date) >> /opt/logs.txt; sleep 1; done']
volumeMounts:
- name: data
mountPath: /opt
initContainers:
- name: logshipper
image: alpine:latest
restartPolicy: Always
command: ['sh', '-c', 'tail -F /opt/logs.txt']
volumeMounts:
- name: data
mountPath: /opt
volumes:
- name: data
emptyDir: {}
两个容器都使用简单的 Alpine Linux 发行版的 Docker 镜像,并将应用程序特定的代码限制在command中,这是一个 Linux 脚本。这种技术用于适应现有的镜像或执行非常简单的任务,例如侧边容器经常执行的任务。我们也为主要的容器使用了同样的技术,因为主要的容器什么都不做,具有纯粹的教学目的。
因此,使用之前暴露的语法,侧边容器在initContainers列表中定义为restartPolicy: Always。
主容器命令执行一个无限循环,它只是在/opt/logs.txt文件中写入当前日期和时间,然后休眠一秒钟。
边车容器命令使用 sh -c 来执行单个 shell 命令,即带有 -f 选项的 tail 命令,针对 /opt/logs.txt 文件。此命令显示容器控制台中文件的最后 10 行,并在添加新行时更新它们,因此控制台始终包含文件的当前最后 10 行。
两个容器处理的是相同的文件,因为两个容器都在它们的文件系统上的相同 /opt 目录中挂载了相同的数据卷:
volumeMounts:
- name: data
mountPath: /opt
数据卷是在 volumes 列表中定义的,它是 spec 属性的直接后代,如下所示:
- name: data
emptyDir: {}
emptyDir 定义并分配了一个特定于其定义的 Pod 的卷。这意味着它不能被任何其他 Pod 访问。该卷使用托管 Pod 的节点的磁盘内存实现。这意味着如果 Pod 被删除或移动到不同的节点,该卷将被销毁,其内容将丢失。EmptyDir 是提供用于 Pod 计算中某种方式使用的临时磁盘存储的首选方式。它有一个可选的 sizeLimit 属性,用于指定 Pod 可以使用的最大磁盘空间。例如,我们可以设置 sizeLimit: 500Mi 以指定 500 兆的最大磁盘空间。
由于我们没有指定任何大小限制,emptyDir 对象没有属性,因此我们被迫添加空对象值 {} 以获得正确的 .yaml 语法(我们不能有冒号后跟空格)。
让我们在 Minikube 中创建一个用于实验 .yaml 文件的文件夹,并将整个示例代码放在该文件夹中名为 SimplePOD.yaml 的文件中。此文件也位于书籍 GitHub 存储库的 ch08 文件夹中。
现在,右键单击新创建的文件夹,在该目录中打开一个 Windows 控制台。在通过执行 kubectl get all 命令确认 Minikube 已启动后,我们可以使用 kubectl apply 命令应用所有定义:
kubectl apply -f SimplePOD.yaml
现在,如果我们执行 kubectl get pods 命令,我们看不到新的 Pod!这是正确的,因为该命令仅列出 default 命名空间中定义的资源,而我们的 Pod 已定义在名为 basic-examples 的新命名空间中,因此如果我们想操作该命名空间中的资源,我们必须在我们的命令中添加 -n basic-examples 选项:
kubectl get pods -n basic-examples
为了访问我们的边车控制台,我们可以使用 Kubectl 的 logs 命令。实际上,所有容器 Pod 的控制台输出都会自动由 Kubernetes 收集,并可以使用此命令进行检查。该命令需要 Pod 名称以及如果不同于 default 的命名空间。此外,如果 Pod 包含多个容器,还需要我们想要检查的容器的名称,这可以通过 -c 选项提供。总结起来,我们的命令是:
kubectl logs -n basic-examples pod-demo -c logshipper
上述命令将仅显示当前控制台内容,然后退出。如果我们希望内容自动更新,以匹配控制台内容的变化,我们必须添加 -f 选项:
kubectl logs -f -n basic-examples pod-demo -c logshipper
这样,我们的窗口会冻结在命令上并自动更新。可以使用 ctrl-c 退出命令。
我们还可以使用 Kubectl 的 exec 命令进入 logshipper 容器的控制台。这需要指定命名空间、Pod 和容器名称,并在 – 字符之后,指定在容器文件系统中要执行的 Linux 命令。如果您需要一个控制台,Linux 命令是 sh,如果我们想与该控制台交互,我们还需要指定 -it 选项,代表“交互式 tty”。总结一下,我们有:
kubectl exec -it -n basic-examples pod-demo -c logshipper -- sh
进入容器后,我们可以使用 cd /opt 命令进入 /opt 目录,并使用 ls 命令验证 logs.txt 文件是否存在。
完成后,您可以通过发出 exit 命令退出容器控制台。
kubectl exec 命令对于调试应用程序非常有用,尤其是在它们已经处于生产或预发布阶段时。
当您完成所有由 .yaml 文件创建的资源后,可以使用 kubectl deleted <file name>.yaml 删除所有这些资源。因此,在我们的例子中,我们可以销毁所有示例实体:
kubectl delete -f SimplePOD.yaml
kubectl apply 也可以用来修改之前创建的资源。只需编辑创建资源时使用的 .yaml 文件,然后重复对该文件执行 apply 命令即可。
我们已经看到了如何使用 emptyDir 创建临时磁盘空间。现在让我们看看分配永久网络磁盘空间并在各种 Pods 之间共享的典型方法。
永久磁盘空间的动态分配
类似于 emptyDir 的卷定义称为树内定义,因为创建卷的指令直接插入到 Pod 定义中。无法将树内定义与其他 Pod 定义共享,因此在不同 Pods 之间共享树内卷并不容易。
实际上,通过适当配置提供磁盘空间的设备,也可以使用树内定义来实现磁盘空间共享。例如,假设我们正在使用连接到我们的 Kubernetes 集群的 NFS 服务器来提供网络磁盘空间。我们可以使用以下指令将 Pod 连接到它:
volumes
- nfs:
server: my-nfs-server.example.com
path: /my-nfs-volume
readOnly: true # optional. If provided the volume is accessible as read-only
其中 server 是服务器名称或 IP 地址,path 是要共享的目录。为了在 Pods 之间共享相同的磁盘空间,它们只需指定相同的服务器和路径即可。
然而,这种技术有两个缺点:
-
共享并未明确声明,但它是间接的,因此它损害了代码的可维护性和可读性。
-
Kubernetes 并不知道使用共享的 Pods,因此无法指示在不再需要时释放共享。
因此,对于不共享 Pod 的临时磁盘空间,树内定义更为合适。幸运的是,问题不在于 NFS 协议本身,而只是树内语法。因此,Kubernetes 还提供了一个基于两个独立对象的树外语法:持久卷声明(PVC),它代表磁盘空间需求,以及持久卷(PV),它代表实际的磁盘空间。
整个技术工作方式如下:
-
我们在 PVC 中定义磁盘空间规范。
-
所有需要共享相同磁盘空间的 Pod 都引用相同的 PVC。
-
Kubernetes,不知何故,试图为每个 PVC 提供一个兼容的 PV,然后将其挂载在所有共享该 PVC 的 Pod 上。
当所有共享相同 PV 的 Pod 都被销毁时,我们可以指示 Kubernetes 保留分配的磁盘空间或删除它。
PVC 获取所需磁盘并返回 PV 的方式取决于为 PVC 提供服务的驱动程序。驱动程序必须安装在 Kubernetes 集群中,但所有云提供商都提供预定义的驱动程序。
驱动程序名称和相关设置组织在称为存储类的资源中(kind: StorageClass)。与预定义的驱动程序一起,所有云提供商也提供基于这些驱动程序的预定义存储类。然而,您可以根据相同的驱动程序定义新的存储类,但具有不同的设置。
您还可以在本地 Kubernetes 集群上安装基于这些驱动程序的驱动程序和存储类(有许多开源驱动程序)。Minikube 也有安装各种存储驱动程序和相关存储类的插件。
仅将 PVC 与用户手动预定义的 PV 匹配的驱动程序称为静态。而动态创建 PV 资源,从可用的磁盘空间池中获取所需磁盘空间的驱动程序称为动态。
在本节中,我们将仅关注动态存储分配,因为它在实际微服务应用中最为相关。您可以在官方 Kubernetes 文档中找到有关存储类及其定义的更多详细信息:kubernetes.io/docs/concepts/storage/storage-classes/。
创建 PVC 的第一步是验证可用的存储类:
Kubectl get storageclasses
然后,可以使用kubectl describe获取特定类的详细信息。在 Minikube 中,我们得到:
NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ...
standard (default) k8s.io/minikube-hostpath Delete Immediate ...
类名后面的“默认”告诉我们standard类是默认存储类,即在未指定存储类时使用的类。
在使用动态预配时,PVC 只需指定:
-
所需的存储
-
存储类
-
访问模式:
ReadWriteOnce(只有单个节点可以在存储上读写),ReadOnlyMany(多个节点可以读取),ReadWriteMany(多个节点可以读写),ReadWriteOncePod(只有单个 Pod 可以在存储上读写)
实际上,获取 PV 所需的所有信息都包含在存储类中。由于 PVC 描述的是 Pod 需求而不是特定的 PV,因此预配的存储将提供至少所需的访问模式,但也可能支持更多的访问。
如果存储类使用的驱动程序不支持所需的模式,操作将失败。因此,在使用存储类之前,您必须验证其驱动程序支持的操作。ReadOnlyMany 与动态预配不搭配,因为分配的存储总是干净的,所以没有东西可以读取。
实际上,支持动态预配的驱动程序总是支持 ReadWriteOnce,其中一些也支持 ReadWriteMany。因此,如果您需要一个在多个 Pod 之间共享的卷,您必须验证所选驱动程序是否支持 ReadWriteMany;否则,所有共享该卷的 Pod 都将分配在同一个节点上,以确保它们都能访问所声明的 ReadWriteOnce 存储空间。
PVC 的定义如下:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: myclaim
namespace: a-namespace
spec:
accessModes:
- ReadWriteOnce # ReadWriteOnce, ReadOnlyMany, ReadWriteMany, ReadWriteOncePod
resources:
requests:
storage: 8Gi
storageClassName: <my storage classname>
需要的存储使用与容器所需的 RAM 相同的语法指定。如果没有提供存储类,Kubernetes 将使用标记为默认存储类的存储类(如果有的话)。
一旦定义了 PVC,Pod 的卷属性需要引用它:
volumes:
- name: myvolume
persistentVolumeClaim:
claimName: myclaim
然而,PVC 和 Pod 必须属于同一个命名空间;否则,操作将失败。
现在我们有了所有构建块,我们可以继续构建更复杂的资源,这些资源建立在这些块之上。单个 Pod 没有用,因为我们总是需要每个微服务的多个副本,但幸运的是,Kubernetes 已经内置了处理不可区分副本和索引副本的资源,后者对于实现分片策略非常有用。
ReplicaSets、Deployments 及其服务
ReplicaSets 是一种资源,它会自动创建 Pod 的 N 个副本。然而,它们很少被使用,因为使用 Deployments 更方便,Deployments 是建立在 ReplicaSets 之上的,并且可以自动处理副本数量或其他参数修改时的平滑过渡。
Deployment 的定义如下:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-deployment-name
namespace: my-namespace
labels:
app: my-app
spec:
replicas: 3
selector:
matchLabels:
my-pod-label-name: my-pod-label-value
...
template:
Deployments 不包含在 API 核心中,因此必须指定它们的 API 名称(apps)。元数据部分与 Pod 的元数据部分相同。spec 部分包含所需的副本数量(replicas)和一个选择器,该选择器指定了 Pod 属于部署的条件:它必须具有所有指定的标签值。
template 指定了为 Deployment 创建 Pod 的方式。如果集群已经包含一些满足选择器条件的 Pod,那么模板将用于创建达到 replicas 目标数量所需的 Pod。
模板是一个完整的 Pod 定义,其语法与我们用于指定单个 Pod 的语法相同。唯一的区别是:
-
Pod 定义没有先前的任何 API 规范
-
Pod 元数据部分不包含 Pod 名称,因为我们提供的是一个用于创建
replicaPod 的模板。Pod 名称由 Deployment 自动创建。 -
Pod 元数据部分不包含 Pod 命名空间,因为 Pod 继承与 Deployment 相同的命名空间。
不言而喻,Pod 模板必须指定与selector条件匹配的标签。下面是一个完整的示例:
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx
namespace: basic-examples
labels:
app: webservers
spec:
selector:
matchLabels:
app: webservers
replicas: 2
template:
metadata:
labels:
app: webservers
spec:
containers:
- image: nginx
name: nginx
ports:
- containerPort: 80
name: web
volumeMounts:
- mountPath: /usr/share/nginx/html
name: website
volumes:
- name: website
persistentVolumeClaim:
claimName: website
Deployment 创建了两个nginx网络服务器的副本,它们共享一个共同的磁盘空间。更具体地说,它们共享映射到共同 PVC 的/usr/share/nginx/html路径。/usr/share/nginx/html是nginx查找静态 Web 内容的文件夹,因此如果我们将其中的index.html文件放置在那里,它应该可以通过两个 Web 服务器访问。
上面的代码实现了两个负载均衡的 Web 服务器,它们提供相同的内容。让我们将 Deployment 放在一个WebServers.yaml文件中。我们将在添加了缺失的代码(即 PVC 定义和将流量从 Kubernetes 集群外部转发并负载均衡到副本的服务)之后不久使用它。
Deployment 可以连接到三种类型的服务:
-
ClusterIP,它将网络内部的流量转发到 Deployment
-
LoadBalancer,它将集群外部的流量转发到 Deployment
-
NodePort,对于应用程序开发者不是基本的,因此将不会进行描述
ClusterIP的定义是:
apiVersion: v1
kind: Service
metadata:
name: my-service
namespace: my-namespace
spec:
selector:
my-selector-label: my-selector-value
...
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
- name: https
protocol: TCP
port: 443
targetPort: 443
selector定义了将接收服务流量的 Pod。Pod 必须属于与服务相同的命名空间。ports列表定义了从外部端口(port)到 Pod 容器内部端口(targetPort)的映射。每个映射还可以指定一个可选的名称和一个可选的协议。如果没有指定协议,所有协议都将转发到 Pod。
被分配了<service name>.<namespace>.svc.cluster.local域名的一个ClusterIP服务,但它也可以通过<service name>.<namespace>(如果命名空间是default,则可以简单地使用<service name>)访问。
总结来说,所有发送到<service name>.<namespace>.svc.cluster.local或<service name>.<namespace>的流量都将转发到由selector选择的 Pod。
LoadBalance服务与它完全类似,唯一的区别是下面spec的两个子属性:
spec:
type: LoadBalancer
loadBalancerIP: <yourpublic ip>
selector:
…
如果你指定了一个 IP 地址,那么这个 IP 地址必须是某种方式购买的静态 IP 地址;否则,在云 Kubernetes 集群的情况下,你可以省略loadBalancerIP属性,服务将由基础设施自动分配一个动态 IP 地址。在 AKS 中,你还必须在注释中指定 IP 地址已分配的资源组:
apiVersion: v1
kind: Service
metadata:
annotations:
service.beta.kubernetes.io/azure-load-balancer-resource-group: <IP resource group name>
此外,您必须将“网络贡献者”角色分配给您定义静态 IP 地址的资源组,并将其分配给与 AKS 集群关联的管理身份(默认情况下,任何新创建的 AKS 集群都会自动分配一个管理身份)。有关执行此操作的详细步骤,请参阅此处:learn.microsoft.com/en-us/azure/aks/static-ip。
您还可以使用标签指定注释:
service.beta.kubernetes.io/azure-dns-label-name: <label >
在这种情况下,Azure 将自动将 <label>.<location>.cloudapp.azure.com 域名与 LoadBalancer 关联。
如果您想在一个自定义域名上发布服务,您需要购买一个域名,然后您需要创建一个带有适当 DNS 记录的 Azure DNS 区域。然而,在这种情况下,使用 Ingress 而不是简单的 LoadBalancer 会更好(见 Ingresses 子节)。
loadBalancerIP 属性已被弃用,将在未来的 Kubernetes 版本中删除。它应该由平台相关的注释替换。在 AKS 的情况下,注释是:service.beta.kubernetes.io/azure-pip-name: <your static IP address>
让我们回到我们的 nginx 示例,并创建一个 LoadBalancer 服务来在互联网上公开我们的负载均衡的 Web 服务器:
apiVersion: v1
kind: Service
metadata:
name: webservers-service
namespace: basic-examples
spec:
type: LoadBalancer
selector:
app: webservers
ports:
- name: http
protocol: TCP
port: 80
targetPort: 80
我们没有指定 IP 地址,因为我们将在 Minikube 中测试此示例,Minikube 是一个模拟器,它使用特定的程序来公开 LoadBalancer 服务。
让我们将服务定义放置在名为 WebServersService.yaml 的文件中。
在 WebServersPVC.yaml 文件中,我们也要放置缺失的 PVC:
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: website
namespace: basic-examples
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi
我们没有指定存储类,因为我们将使用默认的存储类。
让我们再创建一个 BasicExamples.yaml 文件来定义 basic-examples 命名空间:
apiVersion: v1
kind: Namespace
metadata:
name: basic-examples
现在,让我们复制书籍 GitHub 存储库中 ch08 文件夹中包含的 index.html 文件,或者任何其他在同一文件夹中包含所有上述 .yaml 文件的独立 HTML 页面,没有对其他图像/内容的引用。我们将使用该页面作为实验内容,由 Web 服务器显示。
让我们开始我们的实验:
-
在包含所有
.yaml文件的文件夹上打开控制台(右键单击文件夹并选择控制台选项)。 -
确保 Minikube 正在运行,如果不是,请使用
minikube start启动它。 -
按正确的顺序部署所有文件,即确保文件中引用的所有资源都已创建。
kubectl apply -f BasicExamples.yaml kubectl apply -f WebServersPVC.yaml kubectl apply -f WebServers.yaml kubectl apply -f WebServersService.yaml -
现在,我们需要复制两个创建的 Pod 中的任意一个的
/usr/share/nginx/html文件夹中的index.html文件。由于它们共享相同的磁盘存储,它也将被另一个 Pod 所见。为此操作,我们需要一个 Pod 名称。让我们用以下命令获取它:kubectl get pods -n Basic-Examples -
可以使用
kubectl cp命令在 Kubernetes Pod 中复制文件:kubectl cp <source path> <namesapace>/<pod name>:<destination folder> -
在我们的情况下,
cp命令变为:kubectl cp Index.html basic-examples/<pod name>:/usr/share/nginx/html -
在 Minikube 中,您可以通过创建隧道来通过 LoadBalancer 服务访问集群。请执行以下操作:
-
打开一个新的控制台窗口
-
在这个新窗口中,执行
minikube tunnel命令 -
窗口会在命令执行时冻结。只要窗口保持打开状态,
LoadBalancer就可通过localhost访问。无论如何,你可以在上一个窗口中通过执行kubectl get services -n Basic-Examples来验证分配给LoadBalancer的外部 IP。
-
-
打开你喜欢的浏览器并访问
http://localhost。你应该能看到index.html页面的内容。
一旦你完成实验,让我们按相反的顺序(与创建它们的顺序相反)销毁所有资源:
kubectl delete -f WebServersService.yaml
kubectl delete -f WebServers.yaml
kubectl delete -f WebServersPVC.yaml
你可以保留命名空间定义,因为我们将在下一个示例中使用它。
所有的 Deployment 副本都是相同的;它们没有身份,因此无法从你的代码中引用特定的副本。例如,如果一个副本因为节点崩溃而宕机,系统可能会出现轻微的性能问题,但由于副本只是提高性能的一种方式,所以没有副本是不可或缺的。
值得注意的是,一旦 Kubernetes 检测到节点故障,它就会在其他地方重新创建该节点上托管的所有 Pod。然而,由于故障可能不会立即被检测到,此操作可能需要一些时间。在此期间,如果由故障节点托管的 Pod 是不可或缺的,应用程序可能会出现故障,这就是为什么在可能的情况下应优先选择 Deployments。
不幸的是,在某些情况下,相同的副本无法达到所需的并行度,但我们需要非相同的分片副本。如果你不记得什么是分片以及为什么在某些情况下它是必要的,请参阅第七章**,实践中的微服务中的确保消息按正确顺序处理部分。StatefulSets提供了进行分片所需的复制类型。
有状态集和 Headless 服务
一个StatefulSet的所有副本都被分配了从 0 到 N-1 的索引,其中 N 是副本的数量。它们的 Pod 名称也是可预测的,因为它们被构建为<StatefulSet name>-<replica index>。它们的域名也包含 Pod 名称,这样每个 Pod 都有自己的域名:<POD name>.<service name>.<namespace>.svc.cluster.local,或者简单地<POD name>.<service name>.<namespace>。
当创建一个有状态集(StatefulSet)时,所有副本会按照递增的索引顺序创建;而当它被销毁时,所有副本会按照递减的索引顺序销毁。当副本数量发生变化时,情况也是如此。
每个StatefulSet都必须有一个关联的服务,该服务必须在StatefulSet的serviceName属性中声明。StatefulSet的定义几乎与Deployment相同;唯一的区别是kind是StatefulSet,并且在spec部分下面立即有serviceName:”<service name>“属性。
与 StatefulSet 关联的服务必须是一个所谓的 Headless 服务,它被定义为 ClusterIP 服务,但在 spec 下具有 ClusterIP: None 属性:
...
spec:
clusterIP: None
selector:
...
值得指出的是,通常每个副本都有自己的私有存储,因此通常 StatefulSet 定义不引用 PVC,而是使用 PVC 模板,将不同的 PVC 绑定到每个创建的 Pod:
volumeClaimTemplates:
- metadata
...
spec:
...
其中 metadata 和 spec 属性与 PVC 资源中的属性相同。
下面是一个带有其关联的无头服务的 StatefulSet 的示例。Pod 名称通过环境变量传递给每个容器,这样代码就能知道其索引及其在分片算法中可能的角色:
apiVersion: v1
kind: Service
metadata:
name: podname
namespace: basic-examples
labels:
app: podname
spec:
ports:
- port: 80
clusterIP: None
selector:
app: podname
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: podname
namespace: basic-examples
spec:
selector:
matchLabels:
app: podname
serviceName: "podname"
replicas: 3
template:
metadata:
labels:
app: podname
spec:
containers:
- name: test
image: alpine:latest
command: ['sh', '-c', 'while true; do echo $(MY_POD_NAME); sleep 3; done']
ports:
- containerPort: 80
env:
- name: MY_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeClaimTemplates:
- metadata:
name: volumetest
spec:
accessModes: [ "ReadWriteOnce" ]
resources:
requests:
storage: 1Gi
每个 Pod 只包含 Alpine Linux 发行版,实际的代码在 command 中提供,它只是无限循环地打印 MY_POD_NAME 环境变量。反过来,MY_POD_NAME 环境变量设置为:
- name: MY_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
此代码从 Pod 的 metadata.name 字段获取值。实际上,如果我们没有在 Pod 模板元数据部分指定名称,StatefulSet 会自动创建一个名称并将其添加到 Pod 资源内部表示中。使 Pod 字段可用于环境变量定义的 Kubernetes 组件称为 向下 API。
上面的 StatefulSet 没有做任何有用的事情,只是展示了如何将 Pod 名称传递给容器。
将上述代码放入 StateFulSetExample.yaml 文件并应用它!
如果你发出 kubectl get pods -n basic-examples 命令,你可以验证所有 3 个副本都是根据 StatefulSet 名称和你的索引创建的正确的名称。现在让我们通过显示其日志来验证 podname-1 是否正确接收到了其名称:
kubectl logs podname-1 -n basic-examples
你应该会看到几行带有正确 Pod 名称的行。太棒了!
现在让我们验证我们的代码创建了 3 个不同的 PVC:
kubectl get persistentvolume -n basic-examples
你应该会看到三个不同的声明。
当你完成对示例的实验后,你可以使用 kubectl delete -f StateFulSetExample.yaml 删除所有内容。不幸的是,删除所有内容并不会删除由模板创建的 PVC,正如你现在可以验证的那样。删除它们的最简单方法是删除 basic-examples 命名空间:
kubectl delete namespace basic-exampleswhole
然后,如果你想,你可以通过以下方式重新创建它:
kubectl create namespace basic-examples
Statefulsets 用于在 Kubernetes 中部署 RabbitMQ 集群和数据库集群。如果需要主节点,则具有特定索引(通常是 0)的节点会选举自己为主节点。每个副本使用自己的磁盘存储,以便可以强制执行数据分片和数据复制策略。你可能不需要自己这样做,因为最著名的消息代理和数据库集群的集群部署代码已经在网上可用。
在学习了如何创建和维护微服务的多个副本之后,我们必须学习如何设置和更新副本数量,即如何扩展我们的微服务。
扩展和自动扩展
扩展对于应用程序性能调整是基本的。我们必须区分扩展每个微服务的副本数量和扩展整个 Kubernetes 集群的节点数量。
节点数量通常根据平均 CPU 繁忙百分比进行调整。例如,当初始应用程序流量较低时,可能从 50%的百分比开始。然后,随着应用程序流量的增加,我们保持相同的节点数量,直到我们能够保持良好的响应时间,可能调整微服务副本的数量。假设当 CPU 繁忙百分比为 80%时性能开始下降。然后,我们可以将目标设置为 75%的 CPU 繁忙时间。
仅使用云集群即可实现自动集群扩展,每个云服务提供商都提供某种形式的自动扩展。
关于 AKS,在创建 Azure Kubernetes 集群部分,我们看到了我们可以指定最小和最大节点数,并且 AKS 试图为我们优化性能。你还可以微调 AKS 如何决定节点数量。更多关于这种定制的详细信息可以在进一步阅读部分的参考中找到。
此外,还有与各种云提供商集成的自动自动扩展器(kubernetes.io/docs/concepts/cluster-administration/cluster-autoscaling/)。默认情况下,自动扩展器在 Kubernetes 无法满足 Pod 所需资源时增加节点数量,这是所有 Pod 容器resource->request字段的总和。
相反,扩展微服务副本是一个更困难的任务。你可以通过测量平均副本响应时间然后计算:
<number of replicas> = <target throughput (requests per second)><average response time in seconds>
目标吞吐量应该是一个通过简单计算得出的粗略估计。对于前端微服务,它只是你期望应用程序为每个 API 调用接收的请求数量。对于 Worker 服务,它可能取决于预期在几个前端服务上的请求数量,但没有标准的方式来计算它。相反,你需要推理应用程序的工作方式以及指向该 Worker 微服务的请求是如何创建的。
然后,你应该根据以下程序监控系统性能,寻找瓶颈:
-
寻找瓶颈微服务
-
将其副本数量增加到它不再成为瓶颈
-
重复第 1 点,直到没有明显的瓶颈
-
然后优化集群节点数量以实现良好的性能
-
存储所有 Deployments 和 StatefulSets 的平均 CPU 利用率内存占用,以及整个应用程序接收到的平均请求数量。你可以使用这些数据来设置自动扩展器。
虽然 StatefulSets 难以自动扩展,但 Deployments 可以自动扩展而不会引起问题。因此,您可以使用 Kubernetes Pod 自动扩展器自动扩展它们。
Pod 自动扩展目标可以是每个 Pod 的平均资源消耗或与流量相关的度量指标。在第一种情况下,自动扩展器选择使资源消耗最接近指定目标的副本数量。在第二种情况下,副本数量设置为流量度量指标的实际值除以度量指标的目标值,即流量目标被解释为每个 Deployment Pod 持续的目标流量。
如果指定了多个目标类型,则取每个目标类型提出的最大副本数量。
自动扩展器可以定义如下:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myautoscalername
namespace: mynamespace
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: mydeploymentname
minReplicas: 1
maxReplicas: 10
metrics:
- type: <resource or pod or object>
…
我们指定要控制的资源类型及其定义的 API,以及其名称。受控资源和自动扩展器必须在同一命名空间中定义。您还可以将scaleTargetRef->kind设置为StatefulSet,但您需要验证副本数量的变化不会破坏您的分片算法,无论是长期还是在不同副本数量之间的转换过程中。
然后,我们指定副本的最大和最小数量。如果计算出的副本数量超出此范围,则将其裁剪为minReplicas或maxReplicas。
最后,我们有标准列表,其中每个标准可能涉及三种类型的度量指标:resource、pod或object。我们将在单独的小节中描述每个标准。
资源度量指标
资源度量指标基于每个 Pod 浪费的平均内存和 CPU 资源。目标消耗可能是一个绝对值,例如 100Mb 或 20mi(毫核),在这种情况下,副本数量的计算方式为<实际平均消耗>/<目标消耗>。基于绝对值的资源度量指标声明如下:
- type: Resource
resource:
name: <memory or cpu>
target:
type: AverageValue
averageValue: <target memory or cpu>
目标也可以指定为总 Pod resource->request声明的百分比(所有 Pod 容器的总和)。在这种情况下,Kubernetes 首先计算:
<utilization> = 100*<actual average consumption>/<declared resource request>
然后,副本数量计算为<利用率>/<目标利用率>。例如,如果目标 CPU 利用率平均为 50%,则每个 Pod 必须浪费请求中声明的 CPU 毫核的 50%。因此,如果 Deployment 中所有 Pod 的平均 CPU 浪费为 30Mi,而每个 Pod 所需的 CPU 为 20mi,我们计算利用率为 100*30/20= 150。因此,副本数量为 150/50 = 3。
在这种情况下,代码如下:
- type: Resource
resource:
name: <memory or cpu>
target:
type: Utilization
averageUtilization: <target memory or cpu utilization>
Pod 度量指标
Pod 度量指标并非标准化,而是依赖于每个特定云平台或本地安装实际计算的度量指标。Pod 度量指标约束声明如下:
- type: Pods
pods:
metric:
name: packets-per-second
target:
type: AverageValue
averageValue: 1k
假设平台中存在 packets-per-second 指标,并计算每个 Pod 每秒接收的平均通信数据包。副本数量的计算与资源指标中的 averageValue 情况相同。
对象指标
对象指标是指计算在受控 Pod 外部但位于 Kubernetes 集群内部的对象上的指标。像 Pod 指标一样,对象指标也不是标准的,而是取决于每个特定平台实际计算的指标。
在 高级 Kubernetes 配置 部分,我们将描述称为 Ingress 的 Kubernetes 资源,它们将 Kubernetes 集群与外部世界接口。通常,所有 Kubernetes 输入流量都通过单个 Ingress 转移,因此我们可以通过测量该 Ingress 内部的流量来测量总输入流量。一旦集群经过经验优化,并且我们只需要适应临时峰值,最简单的方法是将每个前端微服务和一些 Worker 微服务的副本数量与总应用程序输入流量连接起来。这可以通过引用唯一应用程序 Ingress 的对象指标约束来实现:
- type: Object
object:
metric:
name: requests-per-second
describedObject:
apiVersion: networking.k8s.io/v1
kind: Ingress
name: application-ingress
target:
type: Value
value: 10k
在这种情况下,我们有一个 value,因为我们不是在多个对象上取平均值,但副本数量是按照 Pod 指标的方式计算的。此外,在这种情况下,我们必须确保 requests-per-second 指标实际上是由基础设施在所有 Ingress 上计算的。
个人来说,我总是使用 CPU 和内存指标,因为它们在所有平台上都可用,并且自从使用本小节中概述的流程以来,找到它们的良好目标值相对容易。
虽然所有云服务提供商都提供了有用的 Kubernetes 指标,但也有一些开源的指标服务器可以通过 .yaml 文件安装到本地 Kubernetes 集群中。请参阅 进一步阅读 部分,以获取示例。
Minikube 有一个名为 metrics-server 的指标服务器插件,可以通过 minikube addons enable metrics-server 安装。您还需要它来使用标准资源指标,如 CPU 和内存。
在下一节中,我们将分析如何测试和部署微服务应用程序,并通过在 Minikube 上运行和调试我们在 第七章**,实践中的微服务 中实现的 Worker 微服务来将这些概念付诸实践。
在 Kubernetes 上运行微服务
在本节中,我们将测试 Minikube 中的 routes-matching 工作微服务,但也会描述如何组织微服务应用程序将要部署的各种环境:开发、测试和生产。每个环境都有其独特的特点,例如在开发中轻松测试每个更改,以及在生产中最大化性能。
组织所有部署环境
值得注意的是,Minikube 中最简单的测试也需要相当长的设置时间。因此,大多数开发工作简单地使用 Docker,即几个容器化的微服务组织成一个独特的 Visual Studio 解决方案,当您启动解决方案时,它会启动所有这些服务。
在这个阶段,我们不会测试整个应用程序,而只是测试几个紧密交互的微服务,可能使用存根模拟应用程序的其余部分。如果通信是通过消息代理处理的,那么启动所有微服务和消息代理来测试一切就足够了;否则,如果我们依赖于微服务之间的直接通信,我们必须在虚拟网络中连接所有微服务。
Docker 提供了创建虚拟网络并将运行中的容器连接到它的可能性。Docker 创建的虚拟网络也包括您的开发机器,该机器获得host.docker.internal主机名。因此,所有微服务都可以使用开发机上运行的各种服务,例如 RabbitMQ、SQL Server 和 Redis。
您可以使用以下命令在 Docker 中创建一个测试虚拟网络:
docker network create myvirtualnet
然后,将所有运行的微服务附加到这个网络非常简单。只需修改它们的项目文件如下:
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
…
<DockerfileRunArguments>--net myvirtualnet --name myhostname</DockerfileRunArguments>
</PropertyGroup>
然后,您还可以添加其他docker run参数,例如卷挂载。
您可以在工作日的末尾或简单地在一个功能的完整实现之后在 Minikube 上进行测试。
在接下来的小节中,我们将从以下轴向上比较所有部署环境:
-
数据库引擎和数据库安装
-
容器注册库
-
消息代理安装
-
调试技术
数据库引擎和数据库安装
使用 Docker 或 Minikube 进行开发测试可能都会使用直接在开发机上运行的数据库引擎。您可以使用实际安装或作为 Docker 容器运行的引擎。其优势在于数据库也可以从 Visual Studio 访问,因此您可以在开发它们的同时传递所有迁移。
您还可以使用运行数据库引擎的新鲜 Docker 容器从头开始启动数据库并执行单元测试,或者测试整体迁移集。
如果您使用 Docker 驱动程序安装了 Minikube,您可以通过使用host.minikube.internal或host.docker.internal主机名从您的 Minikube 容器内部访问开发机上的数据库。因此,如果您使用host.docker.internal,您将能够从 Minikube 以及直接由 Visual Studio 启动的容器化应用程序直接访问主机机。
在 staging 和 production 上,您可以使用确保良好性能、可扩展性,并提供集群、复制、地理冗余等功能的数据库云服务。也有可能将数据库部署在您的 Kubernetes 集群内部,但在此情况下,您必须购买许可证,您应该为数据库分配专用的 Kubernetes 节点(确保最佳数据库性能的虚拟机),并且您应该微调数据库配置。因此,如果没有充分的理由选择不同的方案,选择云服务会更方便。
此外,无论是在生产还是在 staging,您都不能配置您的 Deployments 在启动时自动应用迁移;否则,所有副本都将尝试应用它们。更好的做法是从您的迁移中提取数据库脚本,并使用数据库 DBO 用户权限应用它,同时让微服务副本拥有更低的数据库用户权限。
可以使用以下迁移命令从所有迁移中提取数据库脚本:
Script-Migration -From <initial migration> -To <final migration> -Output <name
of output file>
让我们继续讨论容器注册库。
容器注册库
就 staging 和 production 而言,它们都可以使用相同的容器注册库,因为容器是分版本的。例如,生产可以使用 v1.0,而 staging 可以使用 v2.0-beta1。如果注册库属于 Kubernetes 集群的同一云订阅,则可以简化凭证处理。例如,在 AKS 的情况下,只需将注册库与 AKS 集群关联一次,即可授予注册库对集群的访问权限(参见本章的 创建 Azure Kubernetes 集群 小节)。
就开发而言,每个开发者可以使用 staging 环境中用于他们不工作的容器的相同注册库,但每个开发者应该有一个私有注册库用于他们正在工作的容器,这样他们就可以无风险地实验,而不会污染“官方镜像”注册库。因此,最简单的解决方案是在您的 Docker Desktop 中安装一个本地注册库。您可以使用以下方法完成此操作:
docker run -d -p 5000:5000 --name registry registry:2
一旦使用上述指令创建了容器,您就可以从 Docker Desktop 图形用户界面停止和重新启动它。
不幸的是,默认情况下,Docker 和 Minikube 都不接受与不安全的注册库交互,也就是说,与不支持由公共机构签发的证书的 HTTPS 的注册库交互,因此我们必须指导 Docker 和 Minikube 接受与本地注册库的不安全交互。
让我们打开 Docker Desktop 的图形用户界面,并点击右上角的设置图标:

图 8.5:Docker 设置
然后,从左侧菜单中选择 Docker Engine,并编辑包含 Docker 配置信息的文本框,并将以下条目添加到现有的 JSON 内容中:
…….,
"insecure-registries":
"host.docker.internal:5000",
"host.minikube.internal:5000"
上述设置将指向你的主机计算机的 5000 个端口的两个主机名添加到允许的不安全注册表中。结果应该类似于:

图 8.7:桥接
如果输入和输出都由消息代理处理,桥接就很简单了:只需将本地副本连接到集群内副本相同的 RabbitMQ 队列即可。这样,部分流量将自动转发到本地副本。如果 RabbitMQ 集群运行在 Kubernetes 集群内部,您需要按照前一个部分所述转发其 localhost 上的端口。
此外,如果微服务连接到数据库,我们还必须将本地副本连接到相同的数据库。如果您在生产环境中,这可能需要定义防火墙规则以允许您的开发机器访问数据库。
如果某些输入和输出由服务而不是消息代理处理,则桥接变得更加复杂。更具体地说,将输出转发到 Kubernetes 集群内的服务相当简单,因为它只需要在本地主机上使用kubectl port-forward将目标服务端口转发。然而,将流量从服务转发到本地微服务副本需要对该服务进行某种形式的黑客攻击。
服务计算它们必须路由流量的 Pods,然后创建名为EndpointSlice的资源,其中包含它们必须路由流量的 IP 地址。因此,为了将所有服务流量路由到您的本地机器,您需要覆盖该服务的EndpointSlices。这可以通过移除目标服务的选择器来实现,这样所有EndpointSlices都将被删除,然后手动添加一个指向您的开发机器的EndpointSlice。
您可以按照以下步骤操作:
-
使用以下命令获取目标服务定义:
kubectl get service <service name> -n <service namespace> -o yaml -
移除选择器,并应用新的定义。
-
如果您在一个远程集群上工作,请添加以下
EndpointSlice:apiVersion: discovery.k8s.io/v1 kind: EndpointSlice metadata: name: <service name>-1 namespaces: <service namespace> labels: kubernetes.io/service-name: <service name> addressType: IPv4 ports: - name: http # should match with the name of the service port appProtocol: http protocol: TCP port: <target port> endpoints: - addresses: - "<your development machine IP address>" -
如果您在一个 Minikube 本地集群上工作,请添加以下
EndpointSlice:apiVersion: discovery.k8s.io/v1 kind: EndpointSlice metadata: name: <service name>-1 namespaces: <service namespace> labels: kubernetes.io/service-name: <service name> addressType: FQDN ports: - name: http # should match with the name of the service port appProtocol: http protocol: TCP port: <target port> endpoints: - addresses: - "host.minikube.local" -
当您完成调试后,重新应用原始服务定义。您的自定义
EndpointSlice将被自动销毁。
如您所见,使用消息代理简化了大量的调试工作。在实现应用程序时,这是建议的选项。当实现工具时,例如数据库集群或运行在您集群内的消息代理,服务是一个更好的选择。
有一些工具可以自动处理所有需要的服务黑客攻击,例如桥接到 Kubernetes (learn.microsoft.com/en-us/visualstudio/bridge/bridge-to-kubernetes-vs),但不幸的是,微软宣布将停止支持它。微软将建议一个有效的替代方案。
现在我们终于准备好在 Minikube 上测试实际的微服务了。
测试路由匹配工作微服务
我们将测试在第七章**实践中的微服务中实现的路线匹配工作微服务,以及两个存根微服务。第一个将发送测试输入给它,而另一个将收集所有输出并将其写入其控制台,这样我们就可以使用kubectl logs命令访问这些输出。这是一种典型的初步测试方法。然后,更复杂的测试也可能涉及其他应用服务。
让我们创建我们路线匹配工作微服务解决方案的副本,然后添加两个额外的工作服务项目,分别命名为FakeSource和FakeDestination。对于每个项目,如图所示启用 Linux 容器支持:

图 8.8:工作服务项目设置
然后,让我们也添加所有需要的 EasyNetQ 包,以便两个服务能够与 RabbitMQ 集群交互:
-
EasyNetQ -
EasyNetQ.Serialization.NewtonsoftJson -
EasyNetQ.Serialization.SystemTextJson
选择至少版本 8,即使它仍然是预发布版本。
然后你必须将 RabbitMQ 添加到两个项目的 Program.cs 文件中的服务列表中:
builder.Services.AddEasyNetQ(
builder.Configuration?.GetConnectionString("RabbitMQConnection") ??
string.Empty);
RabbitMQ 连接字符串必须添加到在 Properties->launchSettings.json 中定义的环境变量中,如下所示:
"Container (Dockerfile)": {
"commandName": "Docker",
"environmentVariables": {
"ConnectionStrings__RabbitMQConnection":
"host=host.docker.internal:5672;username=guest;password=_myguest;
publisherConfirms=true;timeout=10"
}
最后,从 FakeSource 和 FakeDestination 两个项目中引用 SharedMessages 项目,这样它们就可以使用所有应用程序通信消息。
到目前为止,我们已经准备好编写我们的存根服务。在 FakeDestination 项目中由 Visual Studio 生成的 Worker.cs 文件中,用以下内容替换现有的类:
public class Worker: BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly IBus _bus;
public Worker(ILogger<Worker> logger, IBus bus)
{
_logger = logger;
_bus= bus;
}
protected override async Task ExecuteAsync(CancellationToken
stoppingToken)
{
var routeExtensionProposalSubscription = await _bus.PubSub.
SubscribeAsync<
RouteExtensionProposalsMessage>(
"FakeDestination",
m =>
{
var toPrint=JsonSerializer.Serialize(m);
_logger.LogInformation("Message received: {0}",
toPrint);
},
stoppingToken);
await Task.Delay(Timeout.Infinite, stoppingToken);
routeExtensionProposalSubscription.Dispose();
}
}
托管服务向 RouteExtensionProposalsMessage 事件添加了一个名为 FakeDestination 的订阅。这样,它接收所有现有路线和某些请求之间的匹配提案。一旦订阅处理程序收到一个提案,它只是以 JSON 格式记录消息,这样我们就可以通过检查 FakeDestination 的日志来验证是否生成了正确的匹配提案事件。
在 FakeSource 项目中由 Visual Studio 生成的 Worker.cs 文件中,我们将用以下简单代码替换现有的类,执行以下操作:
-
创建三个城镇消息:凤凰城、圣菲和切伊恩。
-
从凤凰城发送到圣菲的请求。
-
从凤凰城、圣菲和切伊恩发送一条路线出价。一旦这个消息被路线规划工作微服务接收,它应该创建一个提案来匹配这个出价与之前的请求。这个提案应该被
FakeDestination接收并记录。 -
从圣菲发送到切伊恩的请求。一旦这个消息被路线规划工作微服务接收,它应该创建一个提案来匹配这个请求与之前的出价。这个提案应该被
FakeDestination接收并记录。 -
10 秒后,它模拟前两个提案已被接受,并基于之前的出价创建一个路线扩展事件,包含两个匹配的请求。一旦这个消息被路线规划工作微服务接收,它应该更新出价,并将两个请求添加到出价中。结果,两个请求的
RouteId字段都应该指向出价的Id。
Worker.cs 类的代码如下:
public class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
private readonly IBus _bus;
public Worker(ILogger<Worker> logger, IBus bus)
{
_logger = logger;
_bus = bus;
}
protected override async Task ExecuteAsync(CancellationToken
stoppingToken)
{
…
…
/* The code that defines all messages has been omitted */
var delayInterval = 5000;
await Task.Delay(delayInterval, stoppingToken);
await _bus.PubSub.PublishAsync<RouteRequestMessage>(request1);
await Task.Delay(delayInterval, stoppingToken);
await _bus.PubSub.PublishAsync<RouteOfferMessage>(offerMessage);
await Task.Delay(delayInterval, stoppingToken);
await _bus.PubSub.PublishAsync<RouteRequestMessage>(request2);
await Task.Delay(2*delayInterval, stoppingToken);
await _bus.PubSub.PublishAsync<
RouteExtendedMessage>(extendedMessage);
await Task.Delay(Timeout.Infinite, stoppingToken);
}
定义所有消息的代码已被省略。你可以在与本书相关的 GitHub 仓库中的 ch08->CarSharing->FakeSource->Worker.cs 文件中找到完整的代码。
现在,让我们通过执行以下步骤来准备在 Docker 中执行所有微服务:
-
在 Visual Studio 解决方案资源管理器中的解决方案行上右键单击,并选择 配置启动项目…。
-
然后选择 多个启动项目,并将启动选项的名称更改为 AllMicroservices。
-
然后,选择所有三个
FakeDestination、FakeSource和RoutesPlanning项目,并为每个项目选择 操作 下的 启动 和 容器(Docker 文件) 作为 调试目标,如下所示:

图 8.9:启动设置
现在,你可以通过在 Visual Studio 调试启动器中选择 AllMicroservices 来同时启动所有项目。
确保应用程序的 SQL Server 和 RabbitMQ 服务器都在运行。然后,构建项目并启动它。
在出现的容器选项卡中,选择 FakeDestination,这样你就可以检查其日志。几秒钟后,你应该看到以下所示的两个匹配提案消息:

图 8.10:FakeDestination 日志
然后,在 SQL Server 对象资源管理器窗格中,选择应用程序数据库,如果已经存在,否则连接到它,然后显示其表:

图 8.11:应用程序数据库
右键单击 dbo.RouteOffers 和 dbo.RouteRequests,然后选择 查看数据 以查看它们的所有数据。你应该看到出价的 Timestamp 已更改为 2,因为一旦接受两个匹配的提案,出价就被更新了一次:

图 8.12:更新后的出价
此外,你应该看到这两个请求已经与出价相关联:

图 8.13:更新后的请求
现在让我们停止调试并删除 dbo.RouteOffers 和 dbo.RouteRequests 表中的所有记录。
是时候在 Minikube 中部署我们的微服务了!
我们将在开发机上使用相同的 RabbitMQ 和 SQL 服务器。然而,在我们开始在 Minikube 中部署 .yaml 文件之前,有一些初步步骤需要执行:
-
我们必须创建足够的 Docker 映像,因为 Visual Studio 创建的调试映像不能在 Visual Studio 之外运行。它们都有
dev版本。转到 Visual Studio 探索器中三个FakeDestination、FakeSource和RoutesPlanning项目的 Docker 文件,右键单击它们,然后选择 构建 Docker 映像。这些操作将创建三个具有最新版本的 Docker 映像。 -
从 Docker UI 内启动本地注册表容器。如果你还没有创建注册表容器,请参阅 容器注册表 子部分以获取安装说明。
-
将我们在此注册表中创建的新映像推送到 Minikube,以便 Minikube 可以下载它们(记住,你需要一个 Linux 控制台来执行以下命令):
docker tag fakesource:latest localhost:5000/fakesource:latest docker push localhost:5000/fakesource:latest docker tag fakedestination:latest localhost:5000/fakedestination:latest docker push localhost:5000/fakedestination:latest docker tag routesplanning:latest localhost:5000/routesplanning:latest docker push localhost:5000/routesplanning:latest
我们需要为我们的三个微服务中的每一个创建 3 个部署。让我们在 CarSharing 解决方案文件夹中创建一个 Kubernetes 文件夹。我们将把我们的部署定义放在那里。
在 FakeSource.yaml 下方:
apiVersion: apps/v1
kind: Deployment
metadata:
name: fakesource
namespace: car-sharing
labels:
app: car-sharing
classification: stub
role: fake-source
spec:
selector:
matchLabels:
app: car-sharing
role: fake-source
replicas: 1
template:
metadata:
labels:
app: car-sharing
classification: stub
role: fake-source
spec:
containers:
- image: host.docker.internal:5000/fakesource:latest
name: fakesource
resources:
requests:
cpu: 10m
memory: 10Mi
env:
- name: ConnectionStrings__RabbitMQConnection
value:
"host=host.docker.internal:5672;username=guest;password=_myguest;
publisherConfirms=true;timeout=10"
它只包含一个用于 RabbitMQ 连接字符串的环境变量——与我们在 launchSettings.json 中定义的相同。资源请求是最小的。标签也是一个文档工具,因此它们定义了应用程序名称、应用程序中的角色以及这个微服务是一个占位符的事实。
我们设计了 car-sharing 命名空间来托管整个应用程序。
host.docker.internal:5000 是从 Minikube 内部看到的本地仓库的主机名。
我们的部署不需要服务,因为它们通过 RabbitMQ 进行通信。
FakeDestination.yaml 完全类似:
apiVersion: apps/v1
kind: Deployment
metadata:
name: fakedestination
namespace: car-sharing
labels:
app: car-sharing
classification: stub
role: fake-destination
spec:
selector:
matchLabels:
app: car-sharing
role: fake-destination
replicas: 1
template:
metadata:
labels:
app: car-sharing
classification: stub
role: fake-destination
spec:
containers:
- image: host.docker.internal:5000/fakedestination:latest
name: fakedestination
resources:
requests:
cpu: 10m
memory: 10Mi
env:
- name: ConnectionStrings__RabbitMQConnection
value: "host=host.docker.internal:5672;username=guest;password=_myguest;publisherConfirms=true;timeout=10"
RoutesPlanning.yaml 与其他文件的不同之处在于它包含更多的环境变量,并且它暴露了 8080 端口,我们可以利用这个端口来检查服务的健康状态(参见下一节中的 就绪性、活跃性和启动探针 子节)。
apiVersion: apps/v1
kind: Deployment
metadata:
name: routesplanning
namespace: car-sharing
labels:
app: car-sharing
classification: worker
role: routes-planning
spec:
selector:
matchLabels:
app: car-sharing
role: routes-planning
replicas: 1
template:
metadata:
labels:
app: car-sharing
classification: worker
role: routes-planning
spec:
containers:
- image: host.docker.internal:5000/routesplanning:latest
name: routesplanning
ports:
- containerPort: 8080
resources:
requests:
cpu: 10m
memory: 10Mi
env:
- name: ASPNETCORE_HTTP_PORTS
value: "8080"
- name: ConnectionStrings__DefaultConnection
value: "Server=host.docker.internal;Database=RoutesPlanning;User
Id=sa;Password=Passw0rd_;Trust Server
Certificate=True;MultipleActiveResultSets=true"
- name: ConnectionStrings__RabbitMQConnection
value: "host=host.docker.internal:5672;username=guest;password=_
myguest;publisherConfirms=true;timeout=10"
- name: Messages__SubscriptionIdPrefix
value: "routesPlanning"
- name: Topology__MaxDistanceKm
value: "50"
- name: Topology__MaxMatches
value: "5"
- name: Timing__HousekeepingIntervalHours
value: "48"
- name: Timing__HousekeepingDelayDays
value: "10"
- name: Timing__OutputEmptyDelayMS
value: "500"
- name: Timing__OutputBatchCount
value: "10"
- name: Timing__OutputRequeueDelayMin
value: "5"
- name: Timing__OutputCircuitBreakMin
value: "4"
让我们在 Kubernetes 文件夹上打开一个 Windows 控制台,并开始部署我们的应用程序:
-
让我们启动 Minikube:
minikube start。 -
让我们使用
kubectl create namespace car-sharing创建car-sharing命名空间。 -
让我们先部署
FakeDestination.yaml:kubectl apply -f FakeDestination.yaml。 -
现在,让我们使用
kubectl get all -n car-sharing验证所有 Pod 都正常且已就绪。如果它们没有就绪,请重复该命令,直到它们就绪。 -
让我们复制创建的 Pod 的名称。我们需要它来访问其日志。
-
然后,让我们部署
RoutesPlanning.yaml:kubectl apply -f RoutesPlanning.yaml。 -
再次,让我们使用
kubectl get all -n car-sharing验证所有 Pod 都正常且已就绪。 -
然后,让我们部署
FakeSource.yaml:kubectl apply -f FakeSource.yaml。 -
再次,让我们使用
kubectl get all -n car-sharing验证所有 Pod 都正常且已就绪。 -
现在,让我们检查
FakeDestination的日志以验证它是否收到了匹配建议:kubectl logs <FakeDestination POD name> -n car-sharing。其中<FakeDestination POD name>是我们在 步骤 5 中获得的名称。 -
还需检查数据库表以验证应用程序是否正常工作。
-
当你完成实验后,只需删除
car-sharing命名空间即可删除所有内容:kubectl delete namespace car-sharing。 -
还需删除 dbo.RouteOffers 和 dbo.RouteRequests 数据库表中的记录。
-
使用以下命令停止 Minikube:
minikube stop。
现在,如果你想使用网桥技术进行调试实验,重复上述步骤,但将第 6 和第 7 点中部署 RoutePlanning 微服务的步骤替换为在 Visual Studio 中启动单个 RoutePlanning 项目(只需在 Visual Studio 调试窗口中将 AllMicroservices 替换为 RoutePlanning,然后启动调试器)。
由于所有容器都连接到同一个 RabbitMQ 服务器,因此在 Visual Studio 中运行的容器将接收来自 Minikube 内部创建的所有输入消息,并且所有输出消息都将路由在 Minikube 内部。让我们在继续 Kubernetes 部署之前,在任何您想要分析代码的地方放置一个断点。在部署FakeSource.yaml文件几秒钟后,应该会触发断点!
高级 Kubernetes 配置
本节描述了在应用程序设计中起基本作用的高级 Kubernetes 资源。其他与安全和可观察性相关的特定高级资源和配置将在第十章**,无服务器和微服务应用的安全和可观察性中描述。
让我们从 Secrets 开始。
Secrets
Kubernetes 允许各种类型的 Secret。在这里,我们将仅描述generic和tls类型的 Secret,这些是在基于微服务应用的实际开发中使用的。
每个通用的 Secret 包含一组条目名称/条目值对。Secret 可以用.yaml文件定义,但由于将敏感信息与代码混合并不谨慎,它们通常使用kubectl命令定义。
下面是如何定义 Secret,从文件内容中获取条目值:
kubectl create secret generic credentials --from-file=username.txt --from-file=password.txt
文件名成为条目名称(只是文件名及其扩展名——路径信息被移除),而文件内容成为相关的条目值。每个条目使用不同的--from-file=…选项定义。
在目录中创建两个具有上述名称的文件,向其中添加一些内容,然后在该目录上打开控制台,最后尝试上述命令。一旦创建,您可以用以下方式看到它以.yaml格式:
kubectl get secret credentials -o yaml
在数据部分,您将看到两个条目,但条目值看起来是加密的。实际上,它们并没有加密,只是进行了 base64 编码。不用说,您可以通过某些方式防止一些 Kubernetes 用户访问 Secret 资源。我们将在第十章**,无服务器和微服务应用的安全和可观察性中看到如何做。
可以使用以下方式删除 Secret:
kubectl delete secret credentials
代替使用文件,可以在一行中指定条目值:
kubectl create secret generic credentials --from-literal=username=devuser --from-literal=password='$dsd_weew1'
如往常一样,我们可以使用-n选项指定 Secret 命名空间。
一旦定义,通用的 Secret 可以作为卷挂载在 Pod 上:
volumes:
- name: credentialsvolume
secret:
secretName: credentials
每个条目被视为一个文件,其名称是条目名称,其内容是条目值。
不要忘记条目值是 base64 编码的,因此在使用之前必须解码。
Secrets 也可以作为环境变量传递:
env:
- name: USERNAME
valueFrom:
secretKeyRef:
name: credentials
key: username
- name: PASSWORD
valueFrom:
secretKeyRef:
name: credentials
key: password
在这种情况下,Secret 值在作为环境变量传递之前会自动进行 base64 解码。
让我们尝试在路由匹配工作微服务上使用 Secrets。让我们创建一个包含 RabbitMQ 连接字符串以及正确的 FakeDestination.yaml、FakeSource.yaml 和 RoutesPlanning.yaml 的 Kubernetes Secret,以便使用此 Secret。
tls Secrets 是为存储 Web 服务器的证书而设计的。我们将在Ingresses子节中看到如何使用它们。tls secrets 接受输入私钥证书(.key)和经过批准的公钥证书(.crt):
kubectl create secret tls test-tls --key="tls.key" --cert="tls.crt"
下一个重要的话题是,我们的容器代码如何帮助 Kubernetes 验证每个容器是否准备好与应用程序的其余部分交互,以及它是否处于良好状态。
就绪、存活和启动探测
存活探测通知 Kubernetes 容器处于不可恢复的故障状态,因此 Kubernetes 必须杀死并重新启动它们。如果一个容器没有为其定义存活探测,Kubernetes 会重新启动它,以防它因某些不可预测的异常或超出其内存限制而崩溃。存活探测必须精心设计,以便检测实际不可恢复的错误情况;否则,容器可能会陷入无限重启的循环。
相反,临时故障与就绪探测相关联。当就绪探测失败时,它通知 Kubernetes 容器无法接收流量。相应地,Kubernetes 将失败的容器从所有可能向其发送流量的匹配服务列表中移除。这样,流量只分配给就绪的容器。故障容器不会被重新启动,一旦就绪探测再次成功,它就会被重新插入服务列表。
最后,一个启动探测通知 Kubernetes 容器已完成其启动过程。它的唯一目的是避免在启动期间由于存活探测失败而导致 Kubernetes 杀死并重新启动容器。实际上,类似的情况可能会使容器陷入无限重启的循环。
简而言之,Kubernetes 仅在启动探测成功后才会启动存活和就绪探测。由于存活和就绪探测已经具有初始延迟,因此启动探测仅在启动过程非常长的情况下才是必要的。
所有探测都有一个探测操作,它可能失败或成功,以下参数:
-
failureThreshold: 探测操作必须连续失败多少次才被视为失败。如果没有提供,则默认为 3。 -
successThreshold: 仅用于就绪探测。这是探测在失败后被认为是成功的最小连续成功次数。默认值是 1。 -
initialDelaySeconds: Kubernetes 在尝试第一次探测之前必须等待容器启动后的时间(以秒为单位)。默认值是 0。 -
periodSeconds: 两次连续探测之间的时间(以秒为单位)。默认值为 10 秒。 -
timeoutSeconds: 探测超时的秒数。默认是 1 秒。
通常,存活和就绪探测使用相同的探测操作,但存活探测有更高的失败阈值。
探针是容器级别的属性,即它们与容器端口和 name 处于同一级别。
探针操作可能基于 shell 命令、HTTP 请求或 TCP/IP 连接尝试。
基于 shell 命令的探针定义为:
livenessProbe/readinessProbe/startupProbe:
exec:
command:
- cat
- /tmp/healthy
initialDelaySeconds: 10
periodSeconds: 5
...
command 列表包含命令及其所有参数。如果操作以 0 状态码完成,即命令无错误完成,则操作成功。在上面的例子中,如果 /tmp/healthy 文件存在,则命令成功。
基于 TCP/IP 连接的探针定义为:
livenessProbe/readinessProbe/startupProbe:
tcpSocket:
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
如果成功建立 TCP 连接,则操作成功。
最后,基于 HTTP 请求的探针定义为:
livenessProbe/readinessProbe/startupProbe:
httpGet:
path: /healthz
port: 8080
httpHeaders:
-name: Custom-Health-Header
value: Kubernetes-probe
initialDelaySeconds: 10
periodSeconds: 5
path 和 port 指定了端点路径和端口。可选的 httpHeaders 部分列出了 Kubernetes 在其请求中必须提供的所有 HTTP 头。如果响应返回的状态码满足:200<=status<400,则操作成功。
让我们在 测试路由匹配工作微服务 部分的 RoutesPlanning.yaml 部署中添加一个存活探针。我们不需要就绪探针,因为就绪探针仅影响服务,而我们不使用服务,因为所有通信都由 RabbitMQ 处理。
首先,让我们在 RoutesPlanning 项目的 Program.cs 文件中定义以下 API:
app.MapGet("/liveness", () =>
{
if (MainService.ErrorsCount < 6) return Results.Ok();
else return Results.InternalServerError();
})
.WithName("GetLiveness");
如果至少有 6 次连续尝试与 RabbitMQ 通信失败,则代码返回错误状态。
在 RoutesPlanning.yaml 部署中,我们必须添加以下代码:
livenessProbe:
httpGet:
path: /liveness
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
在此更改之后,如果您愿意,可以从 测试路由匹配工作微服务 部分重新尝试整个 Minikube 测试。
下一个部分描述了一种结构化、模块化和高效的方式来处理我们的集群与外部世界的交互。
入口
大多数微服务应用程序都有几个前端微服务,因此使用 LoadBalancer 服务公开它们将需要为每个微服务分配不同的 IP 地址。此外,在我们的 Kubernetes 集群内部,我们不需要为每个微服务承担 HTTPS 和证书的负担,因此最佳解决方案是整个集群具有唯一入口点和一个唯一 IP 地址,该地址负责与外部世界的 HTTPS 通信,同时将 HTTP 通信转发到集群内部的服务。这两种功能都是 Web 服务器的典型特性。
通常,每个 IP 地址都附有几个域名,并且 Web 服务器根据每个域内的域名和请求路径将流量分配给几个应用程序。这种 Web 服务器功能称为 虚拟主机。
HTTPS 和 HTTP 之间的转换也是 Web 服务器的特性之一。这被称为 HTTPS 终止。
最后,Web 服务器提供进一步的服务,例如请求过滤以防止各种类型的攻击。更普遍地说,它们理解 HTTP 协议,并提供与 HTTP 相关的服务,例如对静态文件的访问,以及与客户端的各种协议和内容协商。
另一方面,LoadBalancer 服务仅处理底层的 TCP/IP 协议并执行一些负载均衡。因此,使用实际的 Web 服务器来将我们的 Kubernetes 集群与外部世界接口,而不是使用多个 LoadBalancer 服务,将是非常好的。
Kubernetes 提供了在名为 Ingress 的资源中运行实际 Web 服务器的可能性。Ingress 作为实际 Web 服务器和 Kubernetes API 之间的接口,并允许我们使用一个通用的接口来配置大多数 Web 服务器服务,该接口不依赖于 Ingress 后面的特定 Web 服务器。
以下图例说明了 Ingress 如何在 Kubernetes 集群内部的所有前端微服务之间分割流量:

图 8.14:Ingress
只有在集群中安装了 Ingress 控制器 之后,才能在集群中创建 Ingress。每个 Ingress 控制器安装都提供特定的 Web 服务器,例如 NGINX,以及与 Kubernetes API 交互的代码。
Ingress 控制器和其设置的详细信息包含在一个名为 IngressClass 的资源中,该资源在实际的 Ingress 定义中被引用。然而,通常情况下,Ingress 控制器安装已经定义了一个默认的 IngressClass 类,因此无需在 ingress 定义内部指定其名称。
下面是如何定义 IngressClass:
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
labels:
app.kubernetes.io/component: controller
name: nginx-example
annotations:
ingressclass.kubernetes.io/is-default-class: "true"
spec:
controller: k8s.io/ingress-nginx
parameters: # optional parameters that depend on the installed controller
每个类仅指定控制器的名称(controller),如果是默认类(…/is-default-class 注解),以及一些依赖于特定控制器的可选参数。
下面是如何定义一个 Ingress:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: my-example-ingress
namespace: my-namespace
# annotations used to configure the ingress
spec:
ingressClassName: <IngressClass name> # Sometimes it is not needed
tls: # HTTPS termination data
...
rules: # virtual hosting rules
...
一些控制器,如基于 NGINX 的控制器,使用放置在元数据部分的注解来配置 Web 服务器。
HTTPS 终止规则(tls)是由域名集合和与其关联的 HTTPS 证书组成的对,其中每个证书都必须打包为一个 tls 机密(见 Secrets 子节):
tls:
- hosts:
- www.mydomain.com
secretName: my-certificate1
- hosts:
- my-subdomain.anotherdomain.com
secretName: my-certificate2
在上面的例子中,每个证书仅适用于单个域名,但如果该域名有由相同证书保护的所有子域名,我们可能可以将它们添加到同一个证书列表中。
每个域名都有一个虚拟主机规则,每个规则都有针对各种路径的子规则:
rules:
- host: *.mydomain.com # leave this field empty to catch all domains
http:
paths:
- path: /
pathType: Prefix # or Exact
backend:
service:
name: my-service-name
port:
number: 80
- host: my-subdomain.anotherdomain.com
...
域段可以用通配符(*)替换。每个 path 子规则指定一个服务名称,所有匹配该规则的流量都将被发送到该服务,在规则中指定的端口。该服务反过来将流量转发到所有匹配的 Pods。
如果pathType是前缀,则它将匹配所有具有指定路径作为子段的路由请求。否则,需要完全匹配。在上面的例子中,第一个规则匹配所有路径,因为所有路径都有空段/作为子段。
如果一个输入请求匹配多个路径,则更具体的路径(包含更多段落的路径)会被优先考虑。
在下一个子节中,我们将通过 Minikube 中的一个非常简单的示例来实践我们关于 Ingress 的知识。
使用 Minikube 测试 Ingress
在 Minikube 中安装基于 NGINX 的 Ingress 控制器最简单的方法是启用ingress插件。因此,在启动 Minikube 之后,让我们启用这个插件:
minikube addons enable ingress
因此,在ingress-nginx命名空间中创建了一些 Pod。让我们使用kubectl get pods -n ingress-nginx来检查它!
该插件安装了大多数 Kubernetes 环境使用的相同基于 NGINX 的 Ingress 控制器(github.com/kubernetes/ingress-nginx?tab=readme-ov-file)。安装还会自动创建一个名为nginx的IngressClass。此控制器支持的注释在此列出:kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/。
GitHub 书籍仓库的ch08文件夹包含IngressExampleDeployment.yaml和IngressExampleDeployment2.yaml文件。它们定义了两个部署及其关联的 ClusterIP 服务。它们部署了两个不同版本的非常简单的 Web 应用程序,该应用程序创建了一个简单的 HTML 页面。
像往常一样,让我们将两个.yaml文件复制到一个文件夹中,并在该文件夹上打开一个控制台。作为第一步,让我们应用这些文件:
kubectl apply -f IngressExampleDeployment.yaml
kubectl apply -f IngressExampleDeployment2.yaml
现在我们将创建一个 Ingress,将应用程序的第一个版本连接到/,第二个版本连接到/v2。两个部署的 ClusterIP 服务的名称分别是helloworldingress-service和helloworldingress2-service,并且它们都监听在8080端口。因此,我们需要将helloworldingress-service的8080端口绑定到/,将helloworldingress2-service的8080端口绑定到/v2:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: example-ingress
namespace: basic-examples
spec:
ingressClassName: nginx
rules:
- host:
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: helloworldingress-service
port:
number: 8080
- path: /v2
pathType: Prefix
backend:
service:
name: helloworldingress2-service
port:
number: 8080
值得注意的是,主机属性为空,因此 Ingress 不会根据域名进行任何选择,但微服务选择仅基于路径。这是一个强制选择,因为我们在一个没有 DNS 支持的隔离开发机器上进行实验,所以我们不能将域名关联到 IP 地址。
让我们将上面的代码放入一个名为IngressConfiguration.yaml的文件中,并应用它:
kubectl apply -f IngressConfiguration.yaml
为了与 Ingress 连接,我们需要通过 Minikube 虚拟机打开一个隧道。像往常一样,打开另一个控制台并在其中运行minikube tunnel命令。记住,只要这个窗口保持打开,隧道就会工作。
现在打开浏览器并访问http://localhost。你应该会看到类似的内容:
你好,世界!
版本:1.0.0
主机名:……
然后转到http://localhost/v2。你应该会看到类似的内容:
你好,世界!
版本:2.0.0
主机名:……
我们能够根据请求路径在两个应用程序之间分割流量!
当你完成实验后,让我们用以下命令清理环境:
kubectl delete -f IngressConfiguration.yaml
kubectl delete -f IngressExampleDeployment2.yaml
kubectl delete -f IngressExampleDeployment.yaml
最后,让我们用以下命令停止 Minikube:minikube stop。
下一个子节将解释如何在 AKS 上安装相同的 Ingress 控制器。
在 AKS 中使用基于 NGNIX 的 Ingress
你可以在 AKS 上手动安装基于 NGNIX 的 Ingress,使用.yaml文件或使用名为 Helm 的包管理器。然而,然后,你应该处理与权限相关的复杂配置,将静态 IP 和 Azure DNS 区域关联到你的 AKS 集群。感兴趣的读者可以在此处找到完整步骤:medium.com/@anilbidary/domain-name-based-routing-on-aks-azure-kubernetes-service-using-ingress-cert-manager-and-9b4028d762ed。
幸运的是,你可以让 Azure 为你完成所有这些工作,因为 Azure 有一个 AKS 应用程序路由插件,它会自动为你安装 Ingress,并简化所有权限配置。此插件可以在现有集群上启用:
az aks approuting enable --resource-group <ResourceGroupName> --name <ClusterName>
该插件创建webapprouting.kubernetes.azure.com IngressClass,你必须在所有 Ingress 中引用它。
每次创建新的 Ingress 时,都会创建一个 IP 地址,并且在整个 Ingress 生命周期内保持分配。此外,如果你创建一个 Azure DNS 区域并将其关联到插件,该插件将自动添加所有需要的记录,以覆盖你的 Ingress 规则中定义的所有域名。
你只需要创建一个 Azure DNS 区域,使用以下命令:
az network dns zone create --resource-group <ResourceGroupName> --name <ZoneName>
为了将此区域与插件关联,你需要该区域的唯一 ID,你可以通过以下方式获取:
ZONEID=$(az network dns zone show --resource-group <ResourceGroupName> --name <ZoneName> --query "id" --output tsv)
现在你可以使用以下命令将区域附加上:
az aks approuting zone add --resource-group <ResourceGroupName> --name <ClusterName> --ids=${ZONEID} --attach-zones
执行此命令后,你 Ingress 规则中使用的所有域名将自动添加到区域中,并带有足够的记录。显然,你必须更新你在购买域名时购买的域名提供商中的域名数据。更具体地说,你必须强制它们指向处理你的区域的 Azure DNS 服务器名称。你可以在 Azure 门户中访问新创建的 DNS 区域,轻松获取这些 DNS 服务器名称。
我们已经完成了我们精彩的 Kubernetes 之旅。我们将在剩余的大部分章节中回顾在这里学到的许多概念,特别是在第十一章**,共享汽车应用程序*。
下一章将展示如何借助 Azure Container Apps 以低廉的成本顺利启动新的微服务应用程序。
摘要
在本章中,你学习了编排器的基础知识,然后学习了如何安装和配置 Kubernetes 集群。更具体地说,你学习了如何通过 Kubectl 和 Kubectl 的主要命令与 Kubernetes 集群交互。然后你学习了如何部署和维护微服务应用程序,以及如何借助 Docker 和 Minikube 在本地进行测试。
你还学习了如何将你的 Kubernetes 集群与 LoadBalancer 和 Ingress 进行接口,以及如何微调以优化性能。
所有概念都通过简单的示例和从汽车共享案例研究中取出的更完整的示例进行了实践。
问题
- 为什么 Kubernetes 应用需要网络磁盘存储?
因为 POD 不能依赖于它们运行的节点上的磁盘存储,因为它们可能会被移动到不同的节点。
- 如果一个包含 10 个副本的 Deployment 的 Pod 所在的节点崩溃,你的应用程序是否会继续正常运行?
是的。
- 如果一个包含 10 个副本的 StatefulSet 的 Pod 所在的节点崩溃,你的应用程序是否会继续正常运行?
不一定。
- 如果 Pod 崩溃,它是否总是自动重启?
是的。
- 为什么 StatefulSet 需要持久卷声明模板而不是持久卷声明?
因为每个 StatefulSet 的 POD 需要不同的卷。
- 持久卷声明的效用是什么?
它们使 Kubernetes 用户能够动态地请求和管理存储资源,将存储配置与应用程序部署解耦。
- 对于与三个不同的前端服务接口的应用程序,LoadBalancer 或 Ingress 哪个更合适?
一个 Ingress。当只有一个唯一的 Frontend 服务时,LoadBalancers 是足够的。
- 将连接字符串传递给在 Kubernetes 集群的 Pod 中运行的容器的最合适方式是什么?
通过使用 Kubernetes Secret,因为它包含敏感信息。
- HTTPS 证书是如何安装在 Ingress 中的?
通过一种特定的秘密类型。
- 标准的 Kubernetes 语法是否允许在 LoadBalancer 服务上安装 HTTPS 证书?
不。
进一步阅读
-
Kubernetes 官方文档:
kubernetes.io/docs/home/. -
AKS 官方文档:
learn.microsoft.com/en-us/azure/aks/. -
Minikube 官方文档:
minikube.sigs.k8s.io/docs/. -
AKS 自动扩展:
learn.microsoft.com/en-us/azure/aks/cluster-autoscaler?tabs=azure-cli -
云无关的集群自动扩展器:
kubernetes.io/docs/concepts/cluster-administration/cluster-autoscaling/ -
将静态 Azure IP 地址分配给 LoadBalancer:
learn.microsoft.com/en-us/azure/aks/static-ip -
基于 NGINX 的 Ingress 控制器:
github.com/kubernetes/ingress-nginx?tab=readme-ov-file. -
手动安装基于 NGINX 的 AKS Ingress:
medium.com/@anilbidary/ -
基于域名路由的 AKS Azure Kubernetes 服务使用 ingresscert-manager 和 9b4028d762ed
-
使用 RabbitMQ 集群操作符:
www.rabbitmq.com/kubernetes/operator/using-operator -
在 Kubernetes 上安装 RabbitMQ 集群:
www.rabbitmq.com/kubernetes/operator/install-operator.
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第九章:简化容器和 Kubernetes:Azure Container Apps 和其他工具
虽然 Kubernetes 可能是最完整的编排器,但从单体开发到 Kubernetes 上的微服务的任何过渡都面临两个难题。
第一个困难是 Kubernetes 集群的成本往往不能由应用的初始低流量所证明。事实上,一个生产级的 Kubernetes 集群通常需要多个节点来实现冗余和可靠性。虽然自管理的集群可能至少需要两个主节点和三个工作节点,但像Amazon Elastic Kubernetes Service(Amazon EKS)、Azure Kubernetes Service(AKS)或Google Kubernetes Engine(GKE)这样的托管 Kubernetes 服务通常以较低的成本处理控制平面冗余(Amazon EKS 控制平面成本约为每月 72 美元)。团队可以从较小的实例类型开始,并根据需要扩展,从而减少初始负担。
另一个困难是 Kubernetes 本身的学习曲线。将整个团队转移到离散的 Kubernetes 知识/专业知识可能需要我们没有的时间。此外,如果我们正在过渡现有的单体应用程序,在过渡的开始——当微服务数量仍然很少,它们的组织仍然类似于单体应用程序的组织——我们根本不需要 Kubernetes 提供的所有机会和选项。
前面的考虑导致了Azure Container Apps的概念,它是一种无服务器的 Kubernetes 替代方案。作为一个无服务器选项,你只需为所使用的服务付费,并克服了初始集群大小阈值的问题。Azure Container Apps还通过以下功能降低了学习曲线:
-
虽然 Kubernetes 提供了构建工具和微服务的所有构建块,但Azure Container Apps的构建块本身就是微服务,因此开发者可以专注于业务逻辑,而无需花费太多时间在技术细节上。存储解决方案、消息代理和其他性能和安全工具都是从托管平台——即 Azure——获取的。
-
对于所有内容都有可接受的默认值,因此部署应用程序可能变得像决定要部署的 Docker 镜像一样简单。也可以在以后指定自定义设置。
在简要描述了用于简化 Kubernetes 集群使用和管理的各种工具之后,本章详细介绍了Azure Container Apps及其在实际中的应用。本章依赖于对 Kubernetes 的现有知识,因此请在学习过第八章,使用 Kubernetes 进行实践微服务组织之后阅读。
更具体地说,本章涵盖了以下内容:
-
简化 Kubernetes 集群使用和管理的工具
-
Azure Container Apps的基础和计划
-
使用Azure Container Apps部署您的微服务应用
技术要求
本章需要以下内容:
-
至少是 Visual Studio 2022 的免费社区版。
-
Azure CLI。32 位和 64 位 Windows 安装程序的链接可以在
learn.microsoft.com/bs-latn-ba/cli/azure/install-azure-cli-windows?tabs=azure-cli找到。 -
一个 Azure 订阅。
-
minikube 和 kubectl。请参阅第八章,“使用 Kubernetes 的实用微服务组织”的技术要求部分,Practical Microservices Organization with Kubernetes。
简化 Kubernetes 集群使用和管理的工具
在 Kubernetes 成功之后,出现了许多与之相关的产品、服务和开源项目。在本节中,我们将对它们进行分类并提供一些相关示例。与 Kubernetes 相关的整个产品组合可以按以下方式分类:
-
打包库和应用的工具。
-
Kubernetes 图形用户界面。
-
用于收集和展示各种集群指标、处理警报和执行管理操作的行政工具。
-
处理基于微服务应用(包括 Kubernetes 作为目标部署平台)的整个开发和部署的工具。
-
建立在 Kubernetes 之上的编程环境。这包括垂直应用,如机器学习和大数据工具,以及通用编程环境,如 Azure Container Apps。
当涉及到打包工具时,最相关的是Helm,它已成为打包 Kubernetes 应用程序和库的事实标准。我们将在下一节中分析它。
Helm 和 Helm 图表
Helm是一个包管理器,它管理的包被称为Helm 图表。Helm 图表是组织包含多个.yaml文件的复杂 Kubernetes 应用程序的一种方式。Helm 图表是一组组织到文件夹和子文件夹中的.yaml文件。以下是从官方文档中摘取的一个典型的 Helm 图表文件夹结构:

图 9.1:Helm 图表的文件夹结构
特定于应用的.yaml文件放置在顶层的templates目录中,而charts目录可能包含其他用作辅助库的 Helm 图表。顶层的Chart.yaml文件包含有关包的一般信息(名称和描述),以及应用版本和 Helm 图表版本。以下是一个典型的示例:
apiVersion: v2
name: myhelmdemo
description: My Helm chart
type: application
version: 1.3.0
appVersion: 1.2.0
在这里,type可以是application或library。只有application图表可以部署,而library图表是用于开发其他图表的实用工具。library图表放置在其他 Helm 图表的charts文件夹中。
为了配置每个特定的应用程序安装,Helm 图表.yaml文件包含在安装 Helm 图表时指定的变量。此外,Helm 图表还提供了一种简单的模板语言,允许在某些条件满足的情况下包含一些声明,这些条件取决于输入变量。顶层values.yaml文件声明了输入变量的默认值,这意味着开发者只需指定几个需要不同值的变量。我们不会描述 Helm 图表模板语言,因为它过于广泛,但您可以在进一步阅读部分中找到官方 Helm 文档。
Helm 图表通常以类似于 Docker 镜像的方式组织在公共或私有仓库中。有一个 Helm 客户端,您可以使用它从远程仓库下载软件包并在 Kubernetes 集群中安装图表。Helm 客户端可以通过 Chocolatey 软件包管理器安装在安装了 kubectl 的任何机器上,如下所示:
choco install kubernetes-helm
相应地,您可以在第八章的技术要求部分找到关于 Chocolatey 安装程序的说明,即《使用 Kubernetes 的实用微服务组织》。Helm 与当前的 kubectl Kubernetes 集群和用户一起运行。
在使用其软件包之前,必须先添加远程仓库,如下所示:
helm repo add <my-repo-local-name> https://mycharts.helm.sh/stable
之前的命令使远程仓库的软件包信息在本地可用,并为该远程仓库赋予一个本地名称。可以使用以下命令刷新一个或多个仓库中所有可用图表的信息:
helm repo update <my-repo-local-name 1> <my-repo-local-name 2>…
如果未指定仓库名称,则所有本地仓库都将更新。
之后,可以使用以下类似命令安装远程仓库中的任何软件包:
helm install <instance name> <my-repo-local-name>/<package name> -n <namespace>
在这里,<namespace>是要安装应用程序的 Kubernetes 命名空间。通常,如果没有提供,则假定使用default命名空间。<package name>是要安装的软件包的名称,最后,<instance name>是您为安装的应用程序提供的名称。您需要这个名称来使用以下命令获取有关已安装应用程序的信息:
helm status <instance name>
您还可以使用以下命令获取有关使用 Helm 安装的所有应用程序的信息:
helm ls
删除集群中的应用程序也需要应用程序名称,如下所示:
helm delete <instance name>
当我们安装应用程序时,我们还可以提供一个包含所有要覆盖的默认变量值的.yaml文件。我们还可以指定 Helm 图表的特定版本;否则,将使用最新版本。以下是一个同时覆盖版本和值的示例:
helm install <instance name> <my-repo-local-name>/<package name> -f values.yaml --version <version>
最后,也可以按照--set选项提供默认值覆盖,如下所示:
...--set <variable1>=<value1>,<variable2>=<value2>...
我们也可以使用upgrade命令升级现有安装,如下所示:
helm upgrade <instance name> <my-repo-local-name>/<package name>...
upgrade命令可以使用-f选项或--set选项指定新的值覆盖,也可以使用--version指定要安装的新版本。如果没有指定版本,将安装最新版本。
更多关于 Helm 的详细信息可以在官方文档helm.sh/中找到。我们将在关于 Kubernetes 管理工具的后续小节中展示如何实际使用 Helm。
Kubernetes 图形 UI
还有工具可以帮助通过用户友好的图形界面定义和部署 Kubernetes 资源。其中,值得提及的是 ArgoCD 和 Rancher UI。
ArgoCD管理一个 Kubernetes 资源数据库,并在定义资源的代码更改时自动更新 Kubernetes 集群。ArgoCD 简化了大量的 Kubernetes 集群管理,但资源的自动重新部署可能会在生产环境中导致需要零停机时间的问题。我们在此处不会描述 ArgoCD,但感兴趣的读者可以在进一步阅读部分找到更多详细信息。
Rancher UI允许用户通过基于 Web 的 UI 与多个 Kubernetes 集群交互。它还提供处理整个开发过程(如项目定义)的工具。
Rancher UI Web 应用程序必须可以从它必须处理的每个 Kubernetes 集群内部访问,并且需要在它必须处理的每个 Kubernetes 集群内部安装软件。
Rancher UI 也可以安装在开发者的本地机器上,在那里它可以用来与 minikube 交互。进行本地安装的最简单方法是使用 Docker。打开 Linux shell 并输入以下代码:
docker run -d \
--restart unless-stopped \
-p 80:80 \
-p 443:443 \
--privileged \
--name rancher \
rancher/rancher:stable
安装完成后几分钟,Rancher UI 在https://localhost可用。如果你无法访问它,请等待一分钟然后重试。
第一次出现 Web 界面时,你需要一个临时密码。你可以使用以下 Linux 命令获取此密码:
docker logs rancher 2>&1 | grep "Bootstrap Password:"
在 Rancher UI 初始页面复制临时密码,然后按继续。出现的新页面应该建议为管理员用户提供一个新的最终密码,以及 minikube 访问 Rancher UI 要使用的 URL。按照以下方式填写此页面:

图 9.2:Rancher 初始设置
接受建议的密码,复制它,并将其保存在安全的地方。host.docker.internal主机名使 minikube 能够连接到我们的机器 localhost。
在仪表板上,点击导入现有按钮以开始使用 Rancher UI 连接现有集群的过程:

图 9.3:导入现有集群
在出现的新页面上,选择通用集群选项:

图 9.4:通用集群选项
只需在出现的页面上填写集群名称和描述,如图所示:
![img/B31916_09_5.png]
图 9.5:填写集群信息
然后,点击创建按钮。应该会显示一个包含在您的集群中运行的代码的页面。你应该选择第二个代码选项,因为本地 Rancher 安装使用的是自签名证书,应该类似于以下内容:
curl --insecure -sfL https://host.docker.internal/v3/import/6rd2jg4nntmkkw9z9mjhttrjfjj64cz9vl8zr6pr6tskbt6cc98zfz_c-2p47w.yaml | kubectl apply -f -
然而,此代码必须在 Linux shell 中执行,并且kubectl仅在 Windows 上安装。因此,将前面的指令替换为以下指令:
curl --insecure -sfL https://host.docker.internal/v3/import/6rd2jg4nntmkkw9z9mjhttrjfjj64cz9vl8zr6pr6tskbt6cc98zfz_c-2p47w.yaml > install.yaml
然后,在 Linux shell 中执行它。它将创建包含我们的 Kubernetes 代码的install.yaml文件。
现在,我们可以在 minikube 上安装 Rancher。确保 minikube 正在运行,打开 Windows 控制台,并执行以下命令:
kubectl apply -f install.yaml
当安装完成后,返回仪表板;你应该看到新导入的 minikube 集群:

图 9.6:Minikube 集群已连接
点击minikube链接,享受通过图形 UI 与 Minikube 交互的强大功能!在这里,你可以看到节点、Pods、命名空间以及所有类型的 Kubernetes 资源,还可以定义新的资源。
当你完成实验后,在 Docker UI 中停止 minikube 和 Rancher 容器。如果你不再需要通过 Rancher 与 minikube 交互,只需执行以下操作:
kubectl delete -f install.yaml
Kubernetes 行政工具
每个云服务提供商都提供了与 Kubernetes 服务一起的行政 UI。这些 UI 包括对集群执行操作的可能性,例如检查 Kubernetes 资源、收集各种指标,以及查询和绘制这些指标。我们将在第十章中更详细地分析 Azure 提供的行政工具,无服务器和微服务应用程序的安全性和可观察性。
然而,还有第三方提供的几个工具以及几个开源项目。在开源项目中,值得提及的是名为Prometheus的指标收集器,以及名为Grafana的基于 UI 的行政控制台。通常,它们一起安装,Prometheus 作为 Grafana 的指标源。它们可以安装在任何 Kubernetes 集群上,包括 minikube。
这些工具的详细描述超出了本书的目的,但鉴于它们非常常见,也是其他工具的先决条件,我们将描述如何安装它们。
如果你想在 minikube 上测试这些工具,你需要一个具有更多内存的配置和一些其他自定义设置,因此最佳选项是在启动 minikube 时定义一个新的配置文件,如下所示:
minikube start --memory=6g --extra-config=kubelet.authentication-token-webhook=true --extra-config=kubelet.authorization-mode=Webhook --extra-config=scheduler.bind-address=0.0.0.0 --extra-config=controller-manager.bind-address=0.0.0.0 -p <your profile name>
这里,--extra-config选项允许配置各种 Kubernetes 安装选项。如果您不使用 minikube,您必须确保 Kubernetes 集群已配置为使用前一个指令中通过--extra-config传递的选项。这些设置在 Prometheus 使用的控制器管理器上启用 Webhooks,并强制控制器和调度器在主节点上暴露的 IP 地址与 Prometheus 兼容。
一旦所有这些设置都确定,我们就可以使用 Helm 安装 Prometheus 和 Grafana:
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
helm install prometheus prometheus-community/prometheus --namespace monitoring --create-namespace
helm install grafana grafana/grafana --namespace monitoring
前两个指令添加了包含 Prometheus 和 Grafana 的存储库,第三个指令更新了所有本地存储库目录。第三个指令在创建该命名空间后,在monitoring命名空间中安装 Prometheus,最后,最后一个指令在相同的命名空间中安装 Grafana。
安装完成后,我们可以检查monitoring命名空间以验证所有资源是否就绪:
kubectl get all -n monitoring
最后,可以通过端口转发适当的服务来访问 Prometheus 和 Grafana 的 UI。请记住,为每个端口转发服务使用不同的控制台窗口,因为在端口转发时控制台会冻结:
kubectl --namespace monitoring port-forward service/prometheus-server 9090:80
kubectl --namespace monitoring port-forward service/grafana 3000:80
之后,Prometheus 将在localhost:9090可用,Grafana 在 http://localhost:3000。虽然 Prometheus 不需要登录,但 Grafana 的默认用户是admin,密码必须从 Kubernetes 机密中提取,如下所示:
kubectl get secret --namespace monitoring grafana -o jsonpath="{.data.admin-password}"
复制前一个命令返回的字符串;我们需要将其 Base64 解码以获取实际密码。通常,Base64 解码可以通过打开 Linux 控制台并使用base64命令来完成:
echo -n <string to decode> | base64 -d
登录到 Grafana 后,我们必须声明 Prometheus 为其指标数据源。在 Grafana 左侧菜单中,转到连接 -> 数据源,然后选择添加新数据源。在出现的页面上,选择Prometheus,如图下所示:

图 9.7:选择 Prometheus 作为数据源
我们需要将 Prometheus 配置为默认数据源,并将检索所有指标的 URL 设置为prometheus-server:80,这对应于我们已端口转发的相同 Prometheus 服务的地址和端口,如图所示:

图 9.8:Prometheus 设置
您可以保留所有其他默认设置;只需点击保存并测试按钮。之后,点击仪表板选项卡并导入所有建议的仪表板。
然后,转到 Grafana 左侧菜单中的仪表板并点击链接检查所有导入的仪表板:

图 9.9:可用的仪表板
如果你点击 新建 然后点击 导入,你可以从 grafana.com 导入仪表板。只需遵循 grafana.com/dashboards 链接,选择一个仪表板,获取其 ID,并复制它,如下所示:

图 9.10:从 grafana.com 导入仪表板
你可能需要订阅以获取仪表板 ID。订阅是免费的。仪表板选择页面包含你可能感兴趣的文档链接。
如果你使用 minikube stop -p <profle name> 停止 minikube,minikube 将会停止,但所有数据都将被保存,因此你可以继续使用 Grafana 进行实验。如果你想卸载 Grafana 和 Prometheus,你可以使用 Helm 来完成,如下所示:
helm delete grafana
helm delete prometheus
让我们以剩余的工具结束本节。
基于 Kubernetes 的开发环境
在基于 Kubernetes 的完整开发平台中,值得提及的是 OpenShift (www.redhat.com/en/technologies/cloud-computing/openshift),它包括整个开发过程所需的工具,包括 DevOps 自动化和云服务。
OpenShift 可以安装在本地,也可以作为主云服务(包括 Azure)中可用的 PaaS 服务使用 (azure.microsoft.com/it-it/products/openshift)。
大数据和机器学习框架使用 Kubernetes,但我们将不会讨论它们,因为它们完全超出了本书的目的。
还值得一提的是一些初创公司提供的简单代码生成器,它们通过图形界面将容器与 Kubernetes 应用程序结合来创建应用程序。不用说,类似的工具只是针对创建低成本应用程序的。我们不会描述它们,因为本书的重点是企业级高质量应用程序,目前既没有出现普遍的模式,也没有出现特定的框架。
相反,当涉及到基于 Kubernetes 的高级抽象替代方案时,在本书编写时,最相关的选项是 Azure Container Apps,本章的剩余部分将对其进行描述。
Azure Container Apps 基本和计划
Azure Container Apps 以无服务器产品形式提供,有 消费 计划,但也提供基于虚拟机水平扩展的 专用 计划,称为 工作负载配置文件。一些高级功能仅适用于 工作负载配置文件。我们将在本节稍后详细讨论计划。
虽然 Kubernetes 提供了多种独立的构建块,但 Azure Container Apps 仅基于两种类型的构建块:应用程序/作业和环境。
应用程序与微服务一一对应,而作业对于长时间运行的任务很有用,本章节将不会对其进行讨论。
应用程序自动处理副本——也就是说,每个应用程序可能有几个完全相同的副本,就像 Kubernetes Deployment 一样。应用程序支持与 Kubernetes Deployments 相同的配置选项,如下所示:
-
环境变量
-
体积增加
-
健康检查
-
CPU 和内存资源配置
-
自动日志收集
它们也支持通信配置、密钥和自动扩展,但它们不是作为单独的对象定义,而是在应用程序配置内部。此外,没有 StatefulSets 的等效物——也就是说,没有实现分片算法的方法。
这些选择背后的逻辑是,开发者必须将每个微服务映射到单个资源,而不是几个协调资源,这样他们就可以主要集中精力在业务逻辑上,而不会被编排特定的配置所淹没。
像 StatefulSets 这样的协调工具被简单地省略了,因为它们不包括业务逻辑,只是用于解决协调和并行更新问题。实际上,StatefulSets 主要用于实现存储引擎和消息代理等工具,所以基本思想是开发者应该使用云中已有的资源,而不是实现定制化解决方案,这样他们就可以将所有精力集中在业务逻辑上。
其他资源,如权限、用户和角色,也来自 Azure。这样,您的微服务应用程序可以顺利地集成到托管云中,而不是成为一个与托管云松散耦合的自包含部署环境,例如 Kubernetes。
总结来说,我们可以这样说,Azure Container Apps 以降低其可移植性的代价简化了微服务应用程序的实现。一旦您将应用程序实现为在 Azure Container Apps 中运行并使用 Azure 云资源,迁移到另一个云的唯一选项就是重写整个编排相关代码。
不言而喻,如果容器被精心设计,在迁移的情况下它们不会丢失,但围绕它们的整个逻辑会丢失。
如果您的应用程序很小,只包含几个微服务,这并不是一个大问题,但对于由数百或数千个微服务组成的大型应用程序,迁移可能意味着在时间和金钱方面都不可接受的成本。
因此,Azure Container Apps 对于小型应用程序或当您计划在单个云(Azure)上部署应用程序且不需要太多定制(定制工具、高度定制的工具、复杂的定制分布式算法等)时是一个不错的选择。这使得它成为您开始将单体应用程序转换为分布式计算世界时的一个良好切入点。
微服务应用程序的边界由一个环境定义。在每一个环境中,所有应用程序都可以自由交互,但你也可以决定将一些端点暴露给外部世界。如果你使用消费计划,外部世界必然是互联网,但使用工作负载配置文件时,你可以通过将现有 Azure 虚拟网络的子网关联到你的环境来绕过这种限制。实际上,在这种情况下,外部世界将是虚拟网络的其余部分。
从单个环境入口点路由通信到环境内部所有前端微服务的 Kubernetes 入口等价物不存在,但你可以通过使用应用程序作为 API 网关来实现类似的功能(参见第二章,揭秘微服务应用程序)。对于 HTTP 和 HTTPS 终止,你可以配置任何应用程序使用 HTTPS,而无需创建和处理 HTTPS 证书,因为 Azure 会为你处理这些。
以下图示说明了我们关于应用程序和环境的说法:

图 9.11:Azure Container Apps 组织
注意以下事项:
-
每个环境可以定义为仅消费或工作负载配置文件。
-
每个环境都可以添加配置文件。仅消费环境只能有默认消费配置文件。工作负载配置文件环境有默认消费配置文件,但也可以添加可定制的 workload 配置文件。配置文件将在本节稍后进行讨论。
-
与环境关联的每个应用程序都可以指定在环境中运行哪个与该环境关联的配置文件。
-
每个应用程序都可以通过
http://<application name>URL 从环境中访问。我们还可以决定在消息代理中使用时,应用程序不通过直接链接访问。 -
一些应用程序可以配置为从环境外部访问,在这种情况下,它们将接收
https://<application name>.<environment name>.<zone>.azurecontainerapps.ioURL。在这里,<zone>是你定义环境所在的 Azure 地理区域。HTTP 流量必须通过常规的 80 和 443 端口转发。对于纯 TCP 流量,开发者可以指定不同的端口。 -
每个环境都关联一个虚拟网络。只有当环境有一个工作负载配置文件时,你才能为其分配虚拟网络的自定义子网。
-
如果授予了必要的权限或凭据,环境和应用程序可以访问任何 Azure 资源。
本节的其余部分组织成子节,描述以下主题:
-
仅消费和工作负载配置文件
-
应用程序版本控制
-
与 Azure Container Apps 交互
仅消费和工作负载配置文件
在消耗型配置文件中运行的应用程序的计费方式如下:
Kcpu*<virtual CPU seconds> + Kmem*<Gigabytes seconds> + Kreq*<requests per seconds>
简而言之,应用程序的计费是按其内存、CPU 和请求消耗的比例进行的。各种国家的实际常数可以在以下链接中找到:azure.microsoft.com/en-us/pricing/details/container-apps/。
使用作业配置文件时,你将根据使用的每个虚拟机的 CPU 和 GB 数计费,而不是根据分配给应用程序的 CPU 和内存计费。例如,尽管你只使用了配置文件虚拟机的 10%,但你仍需为整个虚拟机的 CPU 和内存付费。然而,对于作业配置文件,没有与应用程序请求相对应的计费配额。还需要将每小时配置文件处理成本添加到每个配置文件的总成本中。各种国家的实际常数可以在以下链接中找到:azure.microsoft.com/en-us/pricing/details/container-apps/。
每个配置文件都可以被多个应用程序使用,分配给每个配置文件的虚拟机数量是根据在该配置文件中运行的所有应用程序请求的 CPU 和内存来计算的。也就是说,当所有应用程序请求的总 CPU 或内存超过已分配机器的总 CPU 和内存时,就会分配一个新的虚拟机。
不言而喻,可以为每个配置文件指定最大和最小分配的机器数量。由于分配新的虚拟机需要时间,建议将最小实例数设置为至少 1;否则,在一段时间的不活跃之后,第一次请求可能会遇到无法接受的反应时间。
作业配置文件的每小时 CPU 和内存成本低于仅消耗型配置文件的,但作业配置文件有每小时的管理成本。当平均负载超过 3-4 个 CPU 和 16GB 内存时,作业配置文件变得方便。然而,某些功能仅适用于作业配置文件。例如,如果你想通过添加防火墙或使用另一个虚拟网络的子网来自定义环境下的虚拟网络,你需要一个作业配置文件。
所有可用的作业配置文件类型都列在此页面上:learn.microsoft.com/en-us/azure/container-apps/workload-profiles-overview。
让我们继续探讨 Azure Container Apps 的一个有用功能:自动版本支持。
应用程序版本控制
Azure Container Apps 会自动为你的应用程序进行版本控制。每次你修改应用程序的容器或扩展配置时,都会自动创建一个新的版本。
每个版本都有一个名称,被称为应用程序的 版本。默认情况下,只有最后一个版本是活动状态且可以通过应用程序链接访问。
然而,任何应用程序都可以设置为 多版本 模式,在这种情况下,您可以手动决定哪些版本是 活动 的,哪些版本与应用程序链接相关联。
如果多个版本连接到应用程序链接,您必须指定如何在这之间分割流量。如果只有一个版本连接到应用程序链接,但存在多个活动版本,您可以通过以下方式通过版本名称访问每个未连接到应用程序 URL 的活动版本:
<application name>-<revision name>.<environment>.<zone>.azurecontainerapps.io
由于版本名称是自动生成的,且不友好,每个版本都可以附加友好的标签,这些标签可以用于通过以下链接等方式访问版本:
<application name>--<revision label>.<environment>.<zone>.azurecontainerapps.io
Azure 容器应用版本逻辑支持以下几种部署模型:
-
预发布/生产:较新的版本未连接到应用程序链接,但可以通过其版本链接访问,因此可以在预发布中进行测试。一旦新版本获得批准,它将被连接到应用程序链接,而之前的版本将被停用。
-
新功能预览:流量在最后两个版本之间分割。最初,新版本只分配到一小部分总体流量,这样用户就可以尝试新功能。然后,逐渐地,新版本接收更多的流量,直到达到 100%,而之前的版本将被停用。
-
在流量分割过程中,会话亲和性 被启用,这样如果用户请求由版本
r服务,那么所有后续请求将继续由相同的r版本服务。这样,我们避免了用户在两个版本之间随机游走。
版本主要用于前端服务,尤其是如果内部通信依赖于消息代理。测试工作微服务的新版本需要一个完全独立的预发布环境。
我们将在 使用 Azure 容器应用部署您的微服务应用程序 部分的末尾提供关于版本实际使用的更多详细信息。下一小节将解释如何在 Azure 容器应用中与您的微服务应用程序交互。
与 Azure 容器应用交互
在 Azure 容器应用环境中与应用程序交互没有与 kubectl 等效的工具。您可以通过 Azure 门户或使用 Azure CLI 来与它们交互。
应用程序和环境设置可以通过命令选项或 .yaml 或 JSON 文件来指定。我们将专注于命令选项和 .yaml 文件,仅描述最实用的选项。
与 Azure 容器应用交互需要安装 containerapp Azure CLI 扩展。在您使用 az login 登录后,可以使用以下命令进行安装:
az upgrade
az extension add --name containerapp --upgrade
第一个upgrade命令确保您拥有最新的 Azure CLI 版本,而第二个命令中的upgrade选项将扩展更新到最新版本。前面的命令只需要执行一次,或者每次您想要更新到新版本时。
在开始任何新的会话之前,您必须注册几个命名空间。命名空间注册与 C#中的using语句具有相同的语义。以下是所需的注册命令:
az provider register --namespace Microsoft.App
az provider register --namespace Microsoft.OperationalInsights
现在,我们已经准备好与 Azure Container Apps 进行交互。下一节将详细解释如何在 Azure Container Apps 上部署和配置您的微服务应用程序。
使用 Azure Container Apps 部署您的微服务应用程序
在本节中,我们将了解如何在 Azure Container Apps 中定义和配置您的应用程序。在第一个子节中,我们将描述基本命令和操作性,而所有配置选项和.yaml文件配置格式将在后面的子节中描述。
基本命令和操作性
所有 Azure Container Apps 命令都以az containerapp开头。然后是主要命令和各种配置选项。配置选项可以通过不同的命令选项传递,或者组织在.yaml或 JSON 文件中。
在 PowerShell 控制台中,您可以使用反引号字符(`)将命令拆分为多行,就像本节中所有命令所示。
up命令是定义应用程序和新的环境的最简单方法。这对于快速测试容器很有用。唯一必需的参数是应用程序名称和容器镜像 URL。对于所有其他选项,将假定合理的默认值。如果您没有指定资源组和环境,则命令将创建新的:
az containerapp up '
--name <CONTAINER_APP_NAME> '
--image <REGISTRY_SERVER>/<IMAGE_NAME>:<IMAGE TAG> '
--ingress external '
--target-port <PORT NUMBER> '
--registry-server <REGISTRY SERVER URL> '
--registry-username <REGISTRY USERNAME> '
--registry-password <REGISTRY PASSWORD>
让我们分解一下:
-
name是应用程序名称。这是必需的。 -
image是容器镜像 URL。这是必需的。通常,image标签用于镜像版本控制,如果省略,则默认为latest。 -
ingress可以是internal或external。在前一种情况下,应用程序只能从其环境中访问,而在第二种情况下,应用程序将暴露给外部世界。如果省略此参数,则应用程序将无法通过直接链接访问(当内部通信依赖于消息代理时很有用)。 -
target-port指定容器暴露的目标端口(如果有)。应用程序流量将被重定向到这个容器端口。如果有多个容器,应该只有一个接收应用程序流量,并且您必须指定其端口。应用程序 HTTP/S 流量必须发送到常规的80和443端口。 -
registry-server、registry-username和registry-password是参数,用于指定与特定镜像注册表服务器关联的凭据,这些凭据应与image参数中使用的相同。如果指定了这些参数,它们将被添加到应用程序配置中,并在后续的应用程序更新中使用。稍后,我们将看到如何将 Azure 身份分配给应用程序,允许它通过仅授予足够的权限给此身份而无需提供密码来访问 Azure 资源。
可以使用--environment和--resource-group选项指定现有的环境和资源组。
up命令可以用来更新应用程序配置或应用程序容器镜像,但在此情况下,你必须始终使用现有应用程序的值传递--name、--environment和--resource-group参数。
你可以使用我们在第八章的使用 minikube 测试入口小节中使用的简单gcr.io/google-samples/hello-app:1.0镜像来测试up命令。由于仓库是公开的,你不需要指定注册表凭据。容器端口是8080:
az group create '
--name <resource group name> '
--location centralus
az containerapp up --name <CONTAINER_APP_NAME> --image gcr.io/google-samples/hello-app:1.0 '
--resource-group <resource group name> '
--location centralus '
--environment <environment name> '
--ingress external --target-port 8080 '
--query properties.configuration.ingress.fqdn
我们之前创建了一个资源组来决定其名称。我们还指定了要创建的环境的名称。--query properties.configuration.ingress.fqdn选项允许命令返回应用程序 URL,你也可以使用我们在上一节中给出的 URL 格式手动计算。一旦通过你喜欢的浏览器访问应用程序 URL 测试了这个简单的单页 HTML 应用程序,你还可以检查在 Azure 门户主页上创建的所有 Azure 资源。
你可以使用以下命令获取应用程序创建的整个.yaml配置:
az containerapp show '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
-o yaml
一个好的方法是先从默认配置开始,然后使用前面的命令获取.yaml应用程序配置,修改这个.yaml文件,最后,使用update命令提交修改后的.yaml文件,如下所示:
az containerapp update '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
--yaml mymodified.yaml
每个应用程序都通过其名称和资源组唯一标识,因此每个update或delete命令都必须指定这两个信息。
清理实验后所有资源的最简单方法是通过删除整个资源组,如下所示:
az group delete --name <resource group name>
当你需要在同一环境中部署多个应用程序时,最好的做法是首先使用以下命令创建环境:
az containerapp env create '
--name <CONTAINERAPPS_ENVIRONMENT> '
--resource-group <RESOURCE_GROUP> '
--location "<AZURE LOCATION NAME>"
如果你希望在该环境中启用工作负载配置文件,你还必须添加--enable-workload-profiles选项。
如果你希望将你整个微服务应用程序中涉及的所有资源放置在一个新的资源组中,你需要在创建环境之前创建它,如下所示:
az group create '
--name <RESOURCE_GROUP> '
--location "<AZURE LOCATION NAME>"
可以使用以下指令将工作负载配置文件添加到环境中:
az containerapp env workload-profile add '
--resource-group <RESOURCE_GROUP> '
--name <ENVIRONMENT_NAME> '
--workload-profile-type <WORKLOAD_PROFILE_TYPE> '
--workload-profile-name <WORKLOAD_PROFILE_NAME> '
--min-nodes <MIN_INSTANCES> '
--max-nodes <MAX_INSTANCES>
在这里,--workload-profile-name 是您为工作负载配置文件指定的名称,而 --workload-profile-type 是配置文件类型——即您可以从以下列表中选择的一种虚拟机类型:learn.microsoft.com/en-us/azure/container-apps/workload-profiles-overview。--min-nodes 和 --max-nodes 分别是可创建的虚拟机的最小和最大实例数。
可以在稍后使用以下命令删除工作负载配置文件:
az containerapp env workload-profile delete '
--resource-group "<RESOURCE_GROUP>" '
--name <ENVIRONMENT_NAME> '
--workload-profile-name <WORKLOAD_PROFILE_NAME>
当环境设置完成后,您可以在公共注册表中部署所有容器镜像,然后您可以使用以下命令开始创建每个应用程序:
az containerapp create '
--name <CONTAINER_APP_NAME> '
--image <REGISTRY_SERVER>/<IMAGE_NAME>:<TAG> '
--resource-group <RESOURCE_GROUP_NAME> '
--environment <ENVIRONMENT_NAME> '
--ingress <external or internal or omit this option> '
--target-port <PORT_NUMBER> '
--registry-server <REGISTRY SERVER URL> '
--registry-username <REGISTRY USERNAME> '
--registry-password <REGISTRY PASSWORD>
前面的命令使用默认配置创建应用程序。如果您希望应用程序在工作负载配置文件中而不是默认消费配置文件中运行,您必须添加 --workload-profile-name <WORKLOAD_PROFILE_NAME> 选项。
然后,您可以使用以下命令提取其 .yaml 文件并进行修改:
az containerapp show '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
-o yaml
您还需要使用前面的代码与以下代码一起使用:
az containerapp update '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
--yaml mymodified.yaml
您还可以在创建应用程序时立即指定 .yaml 文件,如下所示:
az containerapp create '
--name <CONTAINER_APP_NAME> '
--environment <ENVIRONMENT_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
--yaml myapp.yaml
您可以使用以下命令获取所有应用程序修订版本的列表:
az containerapp revision list '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP>
您可以使用以下命令获取每个修订版本的每个副本:
az containerapp replica list '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP> '
--revision <REVISIONNAME>
您还可以在特定修订版本的特定副本的容器中获得交互式控制台,类似于 Kubernetes 的 exec 命令:
az containerapp exec `
--name <CONTAINER_APP_NAME> `
--resource-group <RESOURCE_GROUP> `
--revision <REVISION_NAME> `
--replica <REPLICA_NAME>
如果有多个容器,您可以使用 --container 选项指定容器名称。
您可以使用以下命令删除应用程序:
az containerapp delete '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP_NAME>
您可以使用以下命令删除整个环境和其中包含的所有应用程序:
az containerapp env delete '
--name <ENVIRONMENT_NAME> '
--resource-group <RESOURCE_GROUP_NAME>
这些命令涵盖了大多数实际使用场景。其他选项和命令可以在官方命令参考中找到,链接为learn.microsoft.com/it-it/cli/azure/containerapp?view=azure-cli-latest。在下一小节中,我们将描述如何使用 .yaml 文件配置您的应用程序。
应用程序配置选项和 .yaml 格式
修改应用程序配置的最简单方法是使用传递给以下命令的 .yaml 文件:
az containerapp update '
--name <CONTAINER_APP_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
--yaml myappconfiguration.yaml
应用程序 .yaml 文件的组织结构如下所示:
identity:
...
properties:
environmentId: "/subscriptions/<subscription_id>/resourceGroups/….."
workloadProfileName: My-GP-01
configuration:
ingress:
…
maxInactiveRevisions: 10
secrets:
- name: <nome>
value: <valore>
registries:
- server: <server URL>
username: <user name>
passwordSecretRef: <name of the secret that contains the password>
- server: <server URL>
identity: <application identity resource id>
template:
containers:
- …
initContainers:
- ...
scale:
minReplicas: 1
maxReplicas: 5
rules:
- ...
volumes:
- ...
让我们分解一下:
-
只有当应用程序已连接到 Azure 身份以处理其无密码访问其他资源时,
identity部分才会存在。 -
environmentId是应用程序所在环境的 Azure 唯一标识符(不要将其与环境名称混淆)。获取此和其他值的最简单方法是创建具有默认值的应用程序,然后显示其.yaml文件。 -
workloadProfileName仅在应用程序与工作负载配置文件相关联时存在,并包含工作负载配置文件名称。 -
ingress部分仅在应用程序必须通过直接链接从其环境内部或外部访问时存在。它包含所有直接通信相关的属性、CORS 设置以及版本之间的流量分割。 -
maxInactiveRevisions表示保存并可激活的先前修订次数。默认值为 100。 -
registries部分包含有关必须使用凭据访问的注册表的信息。不需要凭据且不是私有的注册表不应在此列出。每个条目指定注册表的用户名和密码或具有访问注册表权限的 Azure 身份。该身份必须在identity部分中列出。有关更多详细信息,请参阅 将 Azure 身份关联到您的应用程序 部分。 -
secrets是存储在安全位置中的名称-值对。它们相当于 Kubernetes 通用密钥。 -
与 Kubernetes 一样,我们有
containers和initContainers。initContainers的工作方式与 Kubernetes 中相同,但无法声明sidecar容器,因此sidecar容器必须包含在标准容器中。 -
scale部分包含应用程序副本的最小和最大数量以及决定确切副本数量的规则。最常见的规则是尝试维持每个副本的目标 HTTP 请求或 TCP/IP 连接数:- name: my-http-rule, http: metadata: concurrentRequests: 100 - name: my-tcp-rule, tcp: metadata: concurrentConnections: 100 -
最后,我们有一个
volumes部分声明了容器挂载的所有卷。与 Kubernetes 一样,它们在容器定义中的volumeMounts部分中引用。
所有在先前的 .yaml 文件中没有完全指定的属性将在单独的子部分中描述。让我们从容器开始。
容器配置
每个容器的配置与 Kubernetes 中的类似,但有一些简化。架构在此处显示:
- image: <IMAGE URL>:<TAG>
name: <CONTAINER NAME>
env:
- name: <variable name>
value: <variable name>
- name: <variable name>
secretRef: <secret name>
resources:
cpu: 0.2
memory: 100Mi
probes:
- type: liveness
…
- type: readiness
…
- type: startup
…
volumeMounts:
- mountPath: /mypath
volumeName: myvolume
image 和 name 与 Kubernetes 配置相同。
环境变量可以定义为名称-值对或 name-secretRef 对,其中 secretRef 包含在 secrets 部分中定义的密钥的名称。在第二种情况下,变量值是密钥的值。
volumeMounts 也类似于 Kubernetes。唯一的区别是,在 Kubernetes 中,卷名称称为 name,而在这里,它被称为 volumeName。
Kubernetes 的 resources 属性有两个属性,requests 和 limits,而在这里,我们只有几个与 Kubernetes requests 属性相对应的值。这意味着我们无法像 Kubernetes 那样指定 resources 限制。这种选择背后的原因可能与 Azure Container Apps 的无服务器特性有关。cpu 和 memory 的含义和度量单位与 Kubernetes 相同。
如您所见,活跃性、就绪性和启动探测以略不同的方式定义,但它们的含义与 Kubernetes 中相同。type: liveness/readiness/startup 之后属性的语法和含义与相应的 Kubernetes 配置相同。
让我们继续到 ingress 配置。
入口配置
ingress 配置将一些 Kubernetes Service 和 Ingress 设置与各种修订版之间的流量分割混合在一起,如下所示:
ingress:
external: true
targetPort: 3000
# only for TCP communication. HTTP/S always use 80 and 443 ports
exposedPort: 5000
allowInsecure: false # false or true
clientCertificateMode: accept # accept required or ignore
corsPolicy:
allowCredentials: true
maxAge: 5000 (pre-flight caching time in seconds)
allowedOrigins:
- "https://example.com"
allowedMethods:
- "GET"
- "POST"
…
allowedHeaders: []
exposeHeaders: []
traffic:
- weight: 100
revisionName: testcontainerApp0-ab1234
label: production
stickySessions:
affinity: sticky
让我们分解一下:
-
external必须设置为true以将应用程序暴露给外部世界,否则设置为false。 -
targetPort是要路由应用程序流量的容器端口。 -
仅在非 HTTP/S 流量的情况下使用
exposedPort。它设置应用程序监听端口。所有接收在此端口的流量都将路由到targetPort。暴露给外部世界的应用程序的exposedPort端口必须在环境中是唯一的。 -
相反,HTTP/S 流量始终使用常规的
80和443端口,没有定制可能性。 -
如果
allowInsecure设置为false,HTTP 流量将自动重定向到 HTTPS。默认值为true。 -
clientCertificateMode指定是否接受 TCP/IP 客户端证书进行身份验证。此设置与 Kestrel 提供的类似设置完全类似。如果设置为accept,则接受并处理客户端证书。如果设置为required,则客户端证书是必需的,如果没有提供,则拒绝连接。如果设置为ignore,则完全忽略客户端证书。 -
corsPolicy包含标准的网络服务器 CORS 设置,与 ASP.NET Core 支持的设置相同。为了完整性,我们在这里描述所有 CORS 设置:-
如果
allowCredentials设置为false,则拒绝包含凭证的 CORS 请求。默认值为false。 -
maxAge指定了预检请求的缓存时间。预检请求的唯一目的是在发送实际数据之前验证 CORS 请求是否会被接受。 -
allowedOrigins和allowedMethods分别指定接受 CORS 请求的源域和接受的 HTTP 动词。 -
关于
allowedHeaders,默认情况下,仅允许一些安全的requests头部。此设置添加了额外的requests头部到已接受的那些。 -
关于
exposeHeaders,默认情况下,仅在某些安全的response头部中暴露 CORS 请求的响应。此设置添加了额外的头部到允许的那些。
-
-
traffic指定了各种修订版之间的流量分割。如果一个修订版列出了0分割,它将不会收到任何应用程序流量,但它将被设置为活动状态——也就是说,可以通过其特定修订版的链接访问它。所有添加到活动修订版的标签都必须在这里指定。
虽然可以通过修改 traffic 部分来处理修订版处理,但使用临时命令处理它更为实用。
可以使用以下命令获取给定应用程序的所有修订版的表格列表:
az containerapp revision list '
--name <APPLICATION_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
-o table
可以使用以下方式获取特定修订版的详细信息:
az containerapp revision show '
--name <APPLICATION_NAME> '
--revision <REVISION_NAME> '
--resource-group <RESOURCE_GROUP_NAME>
可以使用以下命令将标签附加或从特定修订版中分离:
az containerapp revision label <add or remove> '
--revision <REVISION_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
--label <LABEL_NAME>
可以使用以下命令将应用程序从单修订版模式切换到多修订版模式,反之亦然:
az containerapp revision set-mode '
--name <APPLICATION_NAME> '
--resource-group <RESOURCE_GROUP_NAME> '
--mode <single or multiple>
可以使用以下命令激活、停用或重启给定修订版:
az containerapp revision <activate or deactivate or restart> '
--revision <REVISION_NAME> '
--resource-group <RESOURCE_GROUP_NAME>
最后,可以使用以下命令更改修订版之间的流量分配:
az containerapp ingress traffic set \
--name <APP_NAME> \
--resource-group <RESOURCE_GROUP> \
--label-weight <LABEL_1>=80 <LABEL_2>=20 …
下一个部分将重点介绍如何在volumes部分中定义卷。
卷定义和分配
卷可以是EmptyDir(与 Kubernetes 的EmptyDir以相同的方式工作)或从 Azure Files 获取的文件共享,如下所示:
volumes:
- name: myempty
storageType: EmptyDir
- name: my-azure-files-volume
storageType: AzureFile
storageName: mystorage
在这里,mystorage是您创建并附加到环境的文件共享的名称。因此,您必须执行以下步骤来获取mystorage:
-
如果您还没有,请定义存储帐户:
az storage account create ' --resource-group <RESOURCE GROUP > ' --name <STORAGE ACCOUNT NAME> ' --location <AZURE LOCATION > ' --kind StorageV2 ' 🡨 type (generic usage type) --sku Standard_LRS ' 🡨 performance level (this is a standard level) --enable-large-file-share ' --query provisioningState 🡨 returns the provisioning state -
定义一个文件共享:
az storage share-rm create ' --resource-group <RESOURCE GROUP> ' --storage-account <STORAGE ACCOUNT NAME>' --name <STORAGE SHARE NAME> ' --quota 1024 ' 🡨 megabyte to share --enabled-protocols SMB ' 🡨 SMB or NFS, SMB is usually better --output table 🡨 return information on the created share in table format -
获取访问存储帐户的凭据:
STORAGE_ACCOUNT_KEY='az storage account keys list -n <STORAGE ACCOUNT NAME> --query "[0].value" -o tsv' -
将文件共享名称添加到环境中:
az containerapp env storage set ' --access-mode ReadWrite ' --azure-file-account-name <STORAGE ACCOUNT NAME> ' --azure-file-account-key $STORAGE_ACCOUNT_KEY ' --azure-file-share-name <STORAGE SHARE NAME> ' --storage-name <STORAGE_MOUNT_NAME> ' --name <ENVIRONMENT NAME> ' --resource-group <RESOURCE GROUP> ' --output table 🡨 return details in table format
现在,您可以使用传递给最后一个命令的--storage-name值在您的应用程序中定义卷,如下所示:
- name: my-azure-files-volume
storageType: AzureFile
storageName: <STORAGE MOUNT NAME>
下一个子节解释了如何将 Azure 标识符关联到应用程序,从而使其能够访问 Azure 资源。
将 Azure 标识符关联到您的应用程序
要与应用程序关联的 Azure 标识符可以由 Azure 自动生成和处理,也可以手动定义。使用用户定义的标识符的主要优势是您可以将其添加到多个应用程序中。
将系统分配的标识符添加到应用程序中非常简单:
az containerapp identity assign '
--name my-container-app '
--resource-group my-container-app-rg '
--system-assigned
前一个命令返回创建的标识符的 Azure 资源 ID。可以通过将type: SystemAssigned添加到应用程序的.yaml文件中的identity部分来将系统分配的标识符与应用程序关联,如下所示:
identity:
type: SystemAssigned
用户定义的标识符必须首先创建,然后分配给应用程序,因此添加用户定义的标识符需要两个步骤。
可以使用以下简单命令创建标识符:
az identity create --resource-group <GROUP_NAME> --name <IDENTITY_NAME> --output json
--output json选项强制命令以 JSON 格式返回有关创建的标识符的信息。返回的 JSON 对象包含创建的标识符的 Azure 资源 ID。您需要它来使用以下命令将标识符与您的应用程序关联:
Az containerapp identity assign --resource-group <GROUP_NAME> --name <APP_NAME> '
--user-assigned <IDENTITY RESOURCE ID>
最后一步可以通过将一个或多个标识符的资源 ID 直接添加到应用程序的.yaml文件中的identity部分来完成,如下所示:
identity:
type: UserAssigned
userAssignedIdentities:
<IDENTITY1_RESOURCE_ID>: {}
<IDENTITY2_RESOURCE_ID>: {}
例如,让我们看看如何使创建的标识符能够访问 Azure 容器注册表。这样,我们可以避免在应用程序的.yaml文件中存储注册表凭据。
首先,我们需要容器注册表资源 ID。我们可以使用以下命令获取它:
az acr show --name <REGISTRY NAME> --query id --output tsv
然后,我们可以使用以下命令在我们的容器注册表中为我们的身份分配 AcrPull 角色:
az role assignment create '
--assignee <IDENTITY RESOURCE ID> '
--role AcrPull '
--scope <ACR_RESOURCE_ID>
最后,我们必须通知应用程序它可以使用其系统分配或用户分配的身份来访问注册表:
az containerapp registry set '
--name my-container-app '
--resource-group my-container-app-rg '
--server <ACR_NAME>.azurecr.io '
--identity system 🡨 system if system assigned or the id of the user defined identity
最后一步也可以通过向应用程序的 .yaml 文件中的 registries 部分添加条目来完成,如下所示:
- server: <server URL>
identity: <application identity resource id>
我们已经完成了 Azure 容器应用的旅程。我们将在 第十二章,使用 .NET Aspire 简化微服务 中返回 Azure 容器应用,我们将看到如何自动创建所有指令以将整个微服务应用程序部署到 Azure 容器应用,并将使用本书案例研究应用程序作为示例。
我们对 Azure 容器应用的描述是根本性的完整,并涵盖了 95% 的实际 Azure 容器应用操作。更多详细信息可以在官方文档中找到,链接为 learn.microsoft.com/en-us/azure/container-apps/。
下一章将重点介绍微服务应用程序的安全性和可观察性。
摘要
本章描述了与 Kubernetes 相关的工具,这些工具有助于分布式应用程序的管理和编码,然后重点介绍了 Azure 容器应用。
我们描述了 Azure 容器应用提供的基本理念,包括其基本概念和原则。然后,我们描述了可用的计划以及如何通过 Azure 门户与 Azure 容器应用交互。
尤其是我们描述了主要命令以及定义整个应用的 .yaml 格式。我们展示了如何在 Azure 容器应用中实现 Kubernetes 中的所有资源,并比较了两种方法。
问题
- 环境是否等同于 Kubernetes 命名空间?
它们相似但不等价。
- Helm 如何简化 Kubernetes 应用程序和工具的部署?
因为它允许同时部署多个 yaml 文件,这些文件可以根据选定的选项和参数进行配置。
- Prometheus 和 Grafana 是什么?
它们是管理工具,用于收集指标和其他信息,并将它们呈现给用户。
- 您能否描述一个暴露给外部世界的 Azure 容器应用 URL 的组成?
<application name>.<Environment name>.<zone>.azurecontainerapps.io
- 环境是否提供对其底层网络的所有属性的访问?
不。
- 哪些类型的 Azure 身份可以与 Azure 容器应用关联?
用户定义和系统分配。
- 在 Azure 容器应用中,Azure 文件存储分配是否自动(如 Kubernetes 所示)并且只需要在应用程序
.yaml文件的volumes部分声明卷?
不。
- 是否可以使用单个 Azure 控制台命令部署 Azure 容器应用应用程序,而无需填写任何配置文件?
是的,有几种方式。
- 在 Azure Container Apps 的
.yaml文件中,你可以在哪个部分定义修订版之间的流量分割?
ingress->traffic
- Azure Container Apps 应用程序监听 HTTP/S 请求的端口可以自定义吗?
不。
进一步阅读
-
更多关于 Helm 和 Helm 图表的信息可以在官方文档中找到。这是一篇写得非常好的文章,包含了一些很好的教程:
helm.sh/. -
Grafana 仪表板:
grafana.com/grafana/dashboards/. -
Rancher UI:
ranchermanager.docs.rancher.com/ -
OpenShift:
www.redhat.com/en/technologies/cloud-computing/openshift. -
Azure OpenShift:
azure.microsoft.com/it-it/products/openshift. -
Azure Container Apps 定价:
azure.microsoft.com/en-us/pricing/details/container-apps/. -
Azure Container Apps 自定义配置文件:
learn.microsoft.com/en-us/azure/container-apps/workload-profiles-overview -
Azure Container Apps 官方文档:
learn.microsoft.com/en-us/azure/container-apps/. -
Azure Container Apps 命令参考:
learn.microsoft.com/it-it/cli/azure/containerapp?view=azure-cli-latest.
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十章:无服务器和微服务应用程序的安全和可观察性
有研究表明,网络犯罪可以被认为是世界上第三大经济体。除此之外,许多公司在网络安全上的投资在过去几年里大幅增加。当我们谈论无服务器和微服务时,我们不能忽视这个话题。事实上,分布式系统的攻击面比简单的单体应用程序要大。
考虑到这个具有挑战性的场景,安全和可观察性不能在开发过程的某个时刻单独讨论。安全性和隐私设计的方法表明,只有在你开始考虑解决方案之后立即开始考虑它时,你才能在网络安全中取得成功并降低风险。
本章的目标是讨论如何确保应用程序的安全,启用性能和安全的监控,并改进事件响应,考虑到我们目前拥有的工具和技术。
应用程序安全最佳实践
在应用程序中考虑安全性的一个良好方法是将其定义为洋葱——具有不同的保护层。任何应用程序最重要的东西就是它存储和处理的数据。考虑到这一点,应用程序的数据库必须设计成具有正确的访问和保护。然而,仅仅保护数据库还不足以提供良好的解决方案,因此你还必须考虑应用程序本身的安全性,为任何将访问它的用户定义身份验证和授权。除此之外,你需要理解你的应用程序可能会使用必须得到保护的第三方组件。基础设施也需要被监控和确保安全,如今有复杂的方法来实现这一点。最后,但同样重要的是,有替代解决方案可以通过拦截到达应用程序的流量来监控我们的应用程序,从而保证另一层安全。让我们详细检查每一层的安全。
网络安全
对于开发者来说,思考在云中管理网络可能会有些困惑,因为你可能会想象任何提供的资源都必须是公开的。重点是正好如此——当我们使用公共云提供商时,我们不能将任何组件视为公开的。为了做到这一点,你必须设计一个能够保护应用程序的适当网络。为此,必须提供虚拟专用云(VPC)。
VPC(虚拟专用云)在公共云中提供了一个逻辑上隔离的部分,你可以在你定义的虚拟网络中启动资源。这种隔离确保了你的资源免受外部威胁和未经授权的访问。重点是减少攻击面。
使用 VPC 配置,您将拥有细粒度的网络控制。通过定义子网、路由表和网络网关,您可以控制流量流向和离开您的无服务器函数和微服务。这样,只有受信任的来源才能访问您的资源,并且只有您希望公开的内容才会暴露给公共互联网。
当您考虑微服务时,没有直接将它们暴露给互联网的需求。因此,这种保护对于敏感数据和关键应用至关重要,最大限度地降低外部攻击的风险。
在 Azure 中,有两个出色的服务可以帮助您设置子系统的私有架构,确保只有真正需要暴露的表面。第一个是 Azure 虚拟网络,这是将使您能够根据您决定的配置设计 VPC 的组件。第二个是 Azure 私有链接,它将使您的服务能够通过虚拟网络中的私有端点进行连接。这将为您提供一个机会,减少将服务暴露给公共互联网的需求,同时使用微软骨干网络来实现这一点。
显然,如果您有一个更好的网络设计,您将能够更有效地监控和保护您的解决方案。例如,您可以通过启用虚拟网络流日志来定义根据组定义的特定规则。您可以选择通过启用 Azure 网络安全组来监控网络流量。您还可以使用 Azure 防火墙定义入站和出站流量以及禁止。总之,Azure 虚拟网络及其组件是确保云中服务之间通信安全的强大工具,确保数据机密性、完整性和可用性。
数据安全
到达数据库的数据通常来自用户或系统。这意味着需要保证这些数据的传输,我们必须考虑保护数据被拦截和最终更改的方法。这样做最好的方式是从客户端到服务器加密数据。超文本传输协议安全(HTTPS)是所有网络服务器通常使用的替代方案来执行此操作。与传输层安全性(TLS)协议一起,我们启用了一个安全通道来传输数据。
例如,在函数应用中,HTTPS 是默认接受的唯一协议。这意味着任何 HTTP(不安全)请求都将被重定向到 HTTPS,从而为数据传输提供更好的安全性。您可以在 App Service 的配置中检查它。

此外,您还可能希望通过为您的服务定义一个特定的证书来提高此传输层的安全性。在 Azure 中,您可以通过为您的应用程序定义一个域来实现这一点。
默认情况下,Azure 会向您提供一个由 Microsoft 创建的证书,其中使用的域是 azurewebsites.net。然而,您可以在 Azure 外部购买一个自定义域名,甚至在其内部,这将更容易管理。
自定义域名将代表您 Azure 账户的成本。您可以在learn.microsoft.com/en-us/azure/app-service/tutorial-secure-domain-certificate了解更多关于自定义域的详细信息。
与您需要保护传输层一样,您必须保护您的环境变量和机密。Azure 提供了三个服务来做到这一点。第一个被称为Azure 管理标识,它将允许您无需凭证即可访问 Azure SQL、Cosmos DB、Azure Storage 等数据。另一方面,如果您需要管理变量和机密,Azure 密钥保管库是存储客户端应用程序机密、连接字符串、密码、共享访问密钥和 SSH 密钥的正确服务。然而,访问 Azure 密钥保管库可能会对应用程序的启动造成性能问题。这就是为什么您应该使用Azure 应用配置来存储非机密,例如客户端 ID、端点和应用程序参数。
在保护数据时,您必须考虑数据库服务中数据加密的选项。例如,在 SQL 数据库中,可以使用透明数据加密设置。

图 10.2:透明数据加密设置
使用此设置,您将防止被盗数据库文件在您的不同服务器上恢复的情况。除此之外,通常数据库服务器也有防火墙规则,这将限制对它们的直接访问,这是防止数据库服务器暴露在公共云中的非常重要的方法。
认证和授权
在创建应用程序时,了解将访问它的角色至关重要。为此,您必须提供一个认证方法,即验证用户或系统身份的过程,确保请求访问的实体确实是其所声称的身份。为此,您必须使用密码、令牌或生物识别数据等凭证。
一旦您识别了用户或系统,还有一个过程将允许这个角色访问您正在设计的系统中的资源或执行活动。使这成为可能的过程被称为授权。
有一些替代方案可以提供认证和授权。我们将在这个主题中讨论其中的三个:JSON Web Tokens (JWTs)、OAuth 2.0 和 OpenID Connect。它们是提供对网站和 API 访问的有用技术,为您的系统设计提供安全保障。
JSON Web Tokens
JSON Web Token (JWT)通过使用一个编码的 JSON 对象(称为令牌),以紧凑和无状态的方式在 HTTP 头中传输,从而在客户端和服务器之间实现安全性。令牌由服务器在验证请求者的身份时创建。授权是为了确保请求者可以访问资源。JWT 符合行业标准 RFC 7519。
本章提供的代码将向您展示如何使用.NET 实现 JWT。值得注意的是,此代码尚未准备好使用,因为认证方法尚未解决。
public class JWT
{
// Private field to store the JWT token
private JwtSecurityToken token;
// Internal constructor to initialize the JWT with a given token
internal JWT(JwtSecurityToken token)
{
this.token = token;
}
// Property to get the expiration date and time of the token
public DateTime ValidTo => token.ValidTo;
// Property to get the string representation of the token
public string Value =>
new JwtSecurityTokenHandler().WriteToken(this.token);
}
internal class JWTBuilder
{
public JWT Build() // Method to build the JWT. JWT is an object
{
var claims = new List<Claim> // Creating a list of claims
{
new Claim(JwtRegisteredClaimNames.Sub,this.subject),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
}.Union(this.claims.Select(item => new Claim(item.Key, item.Value)));
var token = new JwtSecurityToken(
issuer: this.issuer,
audience: this.audience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expiryInMinutes),
signingCredentials: new SigningCredentials(
this.securityKey,
SecurityAlgorithms.HmacSha256)
);
return new JWT(token);
}
}
JWTBuilder类中的Build方法负责根据在构建器中配置的属性和声明构建 JWT。一个List<Claim>初始化为两个默认声明:(1)sub(主题),代表令牌的主题;(2)jti(JWT ID),令牌的唯一标识符,使用Guid.NewGuid()生成。使用Union将声明字典中的额外声明附加。字典中的每个键值对都转换为 Claim 对象。使用以下参数创建一个JwtSecurityToken对象:
-
发行者: 发行令牌的实体。
-
受众: 令牌的预期接收者。
-
声明: 之前创建的声明列表。
-
过期时间: 过期时间,计算方式为当前 UTC 时间加上配置的
expiryInMinutes。 -
签名凭证: 指定如何签名令牌。它使用提供的
securityKey和HmacSha256算法。
该方法将JwtSecurityToken封装在一个自定义的JWT对象中,并返回它。JWT类提供了额外的属性,如ValidTo(过期时间)和Value(令牌的字符串表示)。
一旦客户端请求者收到令牌,它就可以封装在以下对服务器的请求中,作为使用前缀Bearer的授权头信息。当服务器接收到此头信息时,它实现中间件软件来分析请求是否适合请求者。它的好处是,如果请求路径受到 JWT 过程的保护,并且发送的请求没有适当的令牌,则请求不会到达服务器进行处理,只会被中间件处理。
在本章展示的示例中,您将找到两个 API。第一个 API 为您提供令牌用于使用。第二个 API 是当您使用.NET 创建 API 应用时通常可用的 WeatherForecast API。为了更好地使用示例,实现了 Swagger 文档。

图 10.3:JWT Swagger 实现
如果你尝试在没有提供 Bearer 令牌的情况下运行 WeatherForecast API,响应将被拒绝,返回 401 错误代码,这意味着未授权。另一方面,如果你使用 Token API 生成所需的令牌,并使用 Swagger 界面中可用的锁形图标进行授权,API 的结果将被正确地交付。

图 10.4:定义 Bearer 令牌
注意提供的令牌遵守 JWT 标准,可以在 jwt.io 网页上检查,确认你在解决方案中定义的内容。

图 10.5:在 jwt.io 网页上解码 JWT
根据提供的示例,你可以考虑 JWT 作为实现授权标准方法的良好方式。
OAuth 2.0 和 OpenID Connect (OIDC)
OAuth 2.0 是一个开放标准,允许第三方提供者授予应用程序访问用户资源的授权,而不暴露其凭证。有许多优秀的提供者允许你使用这项技术,例如 Google、Microsoft、Facebook 和 GitHub。
现在对于企业来说,使用密码进行登录认证被认为风险太大。除此之外,通过 API 传输这类数据也非常危险,考虑到我们目前需要应对的潜在网络攻击。因此,OpenID Connect (OIDC) 是一个很好的认证选项,因为它允许确认用户的存在而不暴露密码。
要做到这一点,有三个重要的事情需要考虑。首先,这也是一个开放标准,这意味着我们有很多服务器提供这项服务。其次,你需要考虑第三方服务的使用,因此必须考虑良好提供商的定义。第三,虽然不是最重要的,但 OIDC 是在 OAuth 2.0 之上实现的,这意味着,有了它,你将有一个完整的解决方案来认证和授权你的用户。
在.NET 中,我们有使用基于Microsoft 身份验证库(MSAL)的 OAuth 2.0 和 OIDC 的可能性。要使用 Azure 实现这一点,你首先需要在 Microsoft Entra ID 中注册一个应用。

图 10.6:在 Microsoft Entra ID 中注册应用
根据你正在开发的项目类型,你将会有不同的方式来获取你想要的用户的认证。以下代码在一个控制台应用程序中根据一个将用户重定向到浏览器的提示获取用户配置文件。
private static async Task GetUserProfile()
{
IPublicClientApplication clientApp = PublicClientApplicationBuilder
.Create(clientId)
.WithRedirectUri(redirectUri)
.WithAuthority(AzureCloudInstance.AzurePublic, "common")
.Build();
var resultadoAzureAd = await clientApp.AcquireTokenInteractive(scopes)
.WithPrompt(Prompt.SelectAccount)
.ExecuteAsync();
if (resultadoAzureAd != null)
{
// Print the username of the authenticated user
Console.WriteLine("User: " + resultadoAzureAd.Account.Username);
}
}
结果将是需要使用 Microsoft 进行登录。在这种情况下,OIDC 使用 Microsoft Entra ID 作为提供者来识别用户。

图 10.7:使用 Microsoft Entra ID 登录
一旦您登录,Microsoft 会询问您是否允许它与应用程序共享您的账户信息。

图 10.8:授权应用读取您的数据
使用这种方法有两个优点。第一个优点是您无需担心用户管理。这项管理将由 Microsoft Entra ID 负责,这意味着它将采用提供者的专业知识和经验进行集中和定制,甚至在不同的认证方式方面,如多因素认证。第二个优点,也是更重要的一点,是用户无需记住另一个账户,因为他们将使用他们在日常工作中已经使用的账户,这使得 OIDC 成为创建安全且用户友好的认证机制的热门选择。
保护依赖项
开放式网络应用安全项目(OWASP)是一个以非营利方式致力于提高软件安全性的基金会。他们最著名的倡议之一是“十大风险”列表,该列表展示了与您的软件相关的风险最高的情况。该列表指出了诸如注入攻击、认证失败、敏感数据泄露和安全配置错误等情况。
在开发解决方案时,使用有漏洞和过时的组件被认为是十大风险之一。库、框架和 API 在现代 Web 应用程序开发中发挥着重要作用,但如果管理不当,这些组件也可能将漏洞引入应用程序。使用第三方组件的决定可能会为攻击者提供利用应用程序的途径,可能导致数据泄露、未经授权的访问和其他安全事件。
考虑到.NET 环境,组件的使用总是与 NuGet 相关联。由于 NuGet 是包提供者,在 Visual Studio 中,检查您是否正在使用过时的库相当简单。

图 10.9:使用 NuGet 检查过时的库
另一方面,您必须意识到,在解决方案中不仅需要更新.NET 包。当涉及到微服务时,根据决定实施的方法,您将需要处理可能位于容器中或甚至位于管理解决方案容器的基础设施中的组件,并且这些应用程序的部分也必须持续检查,评估是否存在可能损害您的解决方案的漏洞。
如果你使用 GitHub 作为代码仓库,你可以考虑使用 GitHub Dependabot 作为工具,自动扫描你的 GitHub 项目中的过时依赖和已知漏洞,然后打开 PR 来更新它们。Sonar 和 Sync 是你可以在你的管道中考虑的其他工具,以防止第三方安全问题的发生。
CVE 程序([www.cve.org/](https://www.cve.org/))的目的就是帮助我们。CVE 代表通用漏洞和暴露,它是一份公开披露的计算机安全问题列表。
Kubernetes 和 Azure 容器应用安全
编排器的安全有两层含义:一方面,我们有用户访问安全,另一方面,我们有网络安全。在这里,我们指的是编排器的用户,而不是编排器托管的应用程序的用户,即开发者、管理员和其他维护编排器安装及其应用程序的操作员。
应用程序用户的安全由应用程序本身通过通常的 Web 应用程序工具来保障,这些工具并非专为微服务定制,即安全令牌,如认证 cookies 和携带令牌,用户声明,角色和授权策略。
编排器网络安全是指用于隔离同一集群中运行的不同应用程序以及同一应用程序的不同部分的编排器工具。
本节讨论了 Kubernetes 和 Azure 容器应用的编排器用户访问安全和网络安全,每个都在专门的子节中。让我们从 Kubernetes 网络安全开始。
Kubernetes 网络安全
Kubernetes 网络安全通过在更高层次的软件实体(如 Kubernetes Pods 和命名空间)上施加约束,丰富了基于 IP 的防火墙规则。
因此,例如,我们可以通过将它们放置在两个不同的命名空间中并禁止这两个命名空间之间的任何通信,来隔离同一 Kubernetes 集群中运行的两个应用程序。
我们还可以在作为命名空间实现的“军事化区域”中运行敏感的微服务,该命名空间仅暴露少量 过滤 Pods 以进行外部通信。这样,过滤 Pods 可以在将传入通信路由到必须处理它们的微服务之前查找适当的凭证和潜在威胁。
基于 Pods 和命名空间的网络规则比基于 IP 地址的规则更模块化和灵活,因为它们直接约束应用层实体,而不是与硬件相关的实体。
网络安全规则是通过以下 .yaml 定义的 NetworkPolicy 资源来定义的:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: example-network-policy
namespace: example-namespace
spec:
podSelector:
matchLabels:
myLabel: matValue
myLabel1: matValue1
policyTypes: # may be either Ingress, or Egress or both
- Ingress
- Egress
ingress:
- from:
….
egress:
- to:
----
该策略适用于由 podSelector 选定的所有与 NetworkPolicy 资源处于同一命名空间的 Pods。
如果policyType包含Ingress项,则策略通过必须在ingress部分列出的规则来约束输入通信。如果Ingress未列在policyType中,则必须省略ingress部分。
如果policyType包含Egress项,则策略通过必须在egress部分列出的规则来约束输出通信。如果Egress未列在policyType中,则必须省略egress部分。
从/到每个 Pod 的通信必须满足所有选择它的NetworkPolicy资源通过其podSelector指定的约束。
每个from部分选择可能的通信源,这些源的总和等于所有其他from部分选择的通信源。类似地,每个to部分选择可能的通信目的地,这些目的地的总和等于所有其他to部分选择的通信目的地。
每个from和每个to包含一个必须由允许的源或目的地全部满足的约束列表。可以添加三种类型的约束:
-
IP 地址的约束:
- ipBlock: cidr: 172.17.0.0/16 except: - 172.17.1.0/24 -
选择NetworkPolicy资源相同命名空间 Pod 的选择器表达式:
- podSelector: matchLabels: podlabel1: podvalue1 … -
选择器表达式,用于选择其他允许的命名空间:
- namespaceSelector: matchLabels: namespacelabel1: namespacevalue1 …
如果您只想与所选命名空间的一些 Pod 进行通信,您也可以在namespaceSelector基于的项内部嵌套一个podSelector,如下所示:
- namespaceSelector:
matchLabels:
namespacelabel1: namespacevalue1
podSelector:
matchLabels:
podlabel1: podvalue1
每个from和to也可以限制允许的通信到一个端口列表和端口范围,如下所示:
ports:
- protocol: TCP
port: 6379
…
- protocol: TCP
port: 8000
endPort: 9000
如果项包含port和endPort,则指定一个端口范围。否则,如果只包含port,则指定单个端口。
这里是一个策略,它选择mysample命名空间的所有 Pod,并接受来自同一命名空间的所有 Pod 以及来自mysafe命名空间的所有 Pod 的流量:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: example-network-policy
namespace: mysample
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector:{}
- namespaceSelector:
matchExpressions:
- key: namespace
operator: In
values: ["mysafe"]
这里是一个策略,它选择mysample命名空间的所有 Pod,并接受来自同一命名空间的所有 Pod 以及来自mysafe命名空间的所有 Pod 的流量,但仅限于端口 80:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: example-network-policy
namespace: mysample
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- podSelector:{}
- namespaceSelector:
matchExpressions:
- key: namespace
operator: In
values: ["mysafe"]
ports:
- protocol: TCP
port: 80
这里是一个策略,它允许militarized-zone命名空间的所有输入流量通过标记为role: access-control的 Pod:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: access-control
namespace: militarized-zone
spec:
podSelector:
matchLabels:
role: access-control
policyTypes:
- Ingress
ingress:
- from:
- podSelector:{}
- namespaceSelector:{}
我们可以通过添加另一条规则,防止外部命名空间的所有流量到达其他所有 Pod,来强制所有流量仅通过带有role: access-control的 Pod:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: access-control
namespace: militarized-zone
spec:
podSelector:
matchExpression:
- key: role
operator: NotIn
values: ["access-control"]
policyTypes:
- Ingress
ingress:
- from:
- podSelector:{}
NetworkPolicy实体约束 Pod 之间的直接通信,即基于 Kubernetes 服务的通信。然而,由消息代理介导的通信会发生什么?
我们可以为每个我们想要隔离的命名空间使用不同的代理,这样我们就可以使用 NetworkPolicy 实体来限制对各种消息代理的访问。如果消息代理服务器运行在 Kubernetes 集群之外,我们可以使用 NetworkPolicy 规则来过滤消息代理的 IP 地址。否则,我们可以在它服务的相同命名空间中部署每个消息代理,这样它的 Pods 也受到相同的 NetworkPolicy 实体的约束,这些实体限制了微服务之间的直接通信。
如果我们使用单个消息代理集群,我们就被迫使用消息代理的内部授权策略来过滤对各种消息队列的访问。
Azure 容器应用具有更简单但功能较弱的网络安全。
Azure 容器应用网络安全
要在 Azure 容器应用中配置网络安全,您必须使用自定义的 Azure 虚拟网络 (VNET)。这一需求引入了特定的配置和配置文件的需求。设置通常遵循以下步骤:
-
定义一个自定义的 Azure VNet。
-
将 VNet 中的 专用子网 与每个 容器应用环境 相关联。
-
将每个环境的 子网 分配给相应的 应用程序。
-
在 VNet 子网上以 防火墙规则 的形式表达环境与应用之间的通信约束。
有关将自定义子网与环境和应用程序关联的详细指南,请参阅官方文档:learn.microsoft.com/en-us/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli。
然而,这种方法有一些局限性。因为网络规则是使用基于 IP 地址的约束 定义的,而不是显式的软件级策略,结果是 模块化程度降低 和 可扩展性有限。这种模式可能适用于具有少量通信限制的小规模应用程序,但随着您的微服务生态系统的发展,这种方法可能会变得过于复杂。
如果您的系统通信是通过 外部消息代理 处理的,那么通过 代理的授权策略 管理访问将是一个更简单且更可扩展的解决方案,它可以控制哪些服务可以访问特定的消息队列。
Kubernetes 用户安全
Kubernetes 用户安全基于四个概念:
-
用户:这代表使用 Kubectl 登录的用户。每个用户都有一个唯一的用户名,并使用客户端证书进行身份验证。证书和用户名都必须添加到用户的 Kubectl 配置文件中,具体操作请参阅 第八章 “与 Kubernetes 交互:Kubectl、Minikube 和 AKS” 部分的说明,《使用 Kubernetes 的实用微服务组织》。
-
用户组: 每个用户组只是一个名称——一个可能与每个用户关联并插入其客户端证书的字符串。用户组简化了权限分配给用户的过程,因为每个特权可以分配给单个用户或整个用户组。
-
角色: 每个角色代表一组权限。
-
角色绑定: 每个角色绑定将一个角色(即一组权限)关联到多个用户和用户组。简单来说,角色绑定编码了角色与用户和用户组之间的一对多关系。
权限可以作用域到单个命名空间或整个 Kubernetes 集群。表示命名空间作用域权限的角色和角色绑定分别编码在 Role 和 RoleBinding Kubernetes 资源中,而表示集群作用域权限的角色和角色绑定分别编码在 ClusterRole 和 ClusterRoleBinding Kubernetes 资源中。
一个 RoleBinding 只能引用一个 Role,而一个 ClusterRoleBinding 只能引用一个 ClusterRole。以下是 Role 的定义:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: <namespace name>
name: <role name>
rules:
- apiGroups: [""] # "" indicates the core API group
resources: ["pods"]
verbs: ["get", "watch", "list"] # also "create", "update", "patch", "delete"
每个 Role 都通过其名称和它应用的命名空间来识别。权限通过规则列表指定,其中每个规则包含:
-
apiGroups: 包含操作和涉及权限的资源 API。例如,Deployments 的 API 组是 “apps”,而 Pods 的 API 组是表示为空字符串的核心 API。API 组字符串对应于每个资源apiVersion属性中包含的 API 名称。每个规则可以指定多个 API 组。 -
resources: 可以用权限操作的资源名称(Pods、Deployments、Services 等)。 -
verbs: 允许在资源上执行的操作:-
get: 获取特定资源实例的信息。 -
watch: 观察资源实例属性随时间变化。也就是说,在资源上使用–watch标志执行Kubectl get或Kubectl describe。 -
list: 在任何结果列表中列出资源。 -
create: 创建资源的实例。 -
delete: 删除资源的实例。 -
update: 通过提供一个表示实例的新对象来更新资源实例。这是资源通过Kubectl apply更新的一个例子。 -
patch: 使用Kubectl patch更新资源实例。在这种情况下,我们指定一个现有的资源,然后用-p选项中包含的值替换其属性。该属性也可以是一个复杂对象,在这种情况下,对象树中指定的属性将递归地替换现有值,而未在对象树中指定的属性将保持不变。以下是一个示例:kubectl patch pod <pod name> -p '{"spec":{"containers":[{"name":"kubernetes-serve-hostname","image":"new image"}]}}'
-
这里有一个可能适合在 my-app 命名空间中运行的应用程序的开发者的角色示例:
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
namespace: my-app
name: developer-user-role
rules:
- apiGroups: ["", "apps"]
resources: ["pods", "services", "configmaps", "secrets", "deployments", "replicasets"]
verbs: ["get", "list", "watch", "create", "update", "delete"]
所有 apiGroups、resources 和 verbs 都接受通配符字符串 “*”,它可以匹配所有内容。
ClusterRole 定义完全类似,唯一的区别是无需指定命名空间,并且将 type: Role 替换为 type: ClusterRole。
这里是 RoleBinding 的定义:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: <role binding name>
namespace: <reference namespace>
subjects:
- kind: User # specific user
name: jane # "name" is case sensitive
apiGroup: rbac.authorization.k8s.io
- kind: Group #user group
name: namespace:administrators # "name" is case sensitive
apiGroup: rbac.authorization.k8s.io
…
roleRef:
# "roleRef" specifies the binding to a Role
kind: Role #this must be Role
name: <role-name> # this must match the name of the Role you wish to bind to
apiGroup: rbac.authorization.k8s.io
RoleBinding 包含一个名称和引用命名空间,并在其 roleRef 属性中指定它所绑定到的 Role。subjects 属性包含用户和用户组的列表,其中每个项目指定用户或组名称和主体类型。
这里是一个与之前看到的示例 developer-user-role Role 匹配的 RoleBinding,其中所有属于 developers 组的用户:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: developers-binding
namespace: my-app
subjects:
- kind: Group
name: developers
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: Role
name: developer-user-role
apiGroup: rbac.authorization.k8s.io
ClusterBindingRole 定义完全类似,唯一的区别是无需指定命名空间,roleRef 必须引用 ClusterRole,并且将 type: BindingRole 替换为 type: ClusterBindingRole。
客户端证书不需要由公共认证机构颁发,只需经过 Kubernetes 集群的批准。以下是创建批准证书的完整流程:
-
作为第一步,你必须创建证书密钥。这可以通过打开 Linux 控制台并使用 openssl 来完成:
openssl genrsa -out mynewuser.key 2048 -
你必须存储包含证书密钥的
mynewuser.key文件,因为配置 Kubectl 配置文件需要它。 -
现在,让我们提取证书批准请求中的公钥部分
mynewuser.key。同样,我们可以使用 openssl 来完成:openssl req -new -key mynewuser.key -out mynewuser.csr -subj "/CN= mynewuser /O=example:mygroup" -
上述指令生成了
mynewuser.csr文件,其中包含证书批准请求。mynewuser必须替换为实际的用户名,而example:mygroup必须替换为你希望添加用户的用户组名称。 -
现在,你必须将证书请求编码为 base 64:
cat mynewuser.csr | base64 | tr -d "\n" -
上述命令在 Linux 控制台中返回 base-64 编码的证书。请选择并复制它。你必须将其插入一个
.yaml文件中,该文件编码了 Kubernetes 集群的批准请求:apiVersion: certificates.k8s.io/v1 kind: CertificateSigningRequest metadata: name: mynewuserrequest spec: request: <base64 encoded csr> signerName: kubernetes.io/kube-apiserver-client expirationSeconds: <duration in seconds> usages: - client: auth -
你必须更改的唯一字段是
name,即批准请求名称,以及expirationSeconds,它包含证书有效期的秒数。 -
现在让我们打开 Windows 控制台以与 Minikube 交互。如果 Minikube 尚未启动,请先启动它,然后使用以下命令传递之前的 .yaml 文件:
Kubectl apply -f mynewuserrequest.yaml. -
现在,我们可以使用以下命令批准证书:
kubectl certificate approve mynewuserrequest -
批准后,我们可以获取最终的 base 64 格式证书:
kubectl get csr mynewuserrequest -o jsonpath='{.status.certificate}'> mytempfile.txt -
最后,我们必须对
mytempfile.txt进行 base 64 解码以获取二进制格式的证书。我们可以在包含mytempfile.txt的文件夹中打开 Linux 控制台,然后执行以下操作:cat mytempfile.txt | base64 -d > mynewuser.crt -
现在,你可以使用
mynewuser.key和mynewuser.crt来更新新用户的 Kubectl 配置文件,正如在 第八章 “使用 Kubernetes 的实际微服务组织”部分的 与 Kubernetes 交互:Kubectl、Minikube 和 AKS 部分中所述。
作为练习,您可以使用上述过程定义一个属于developers用户组的新的 Minikube 用户,然后您可以使用我们之前定义的示例developer-user-role Role和developers-binding RoleBinding在myapp命名空间中分配开发权限。
就这些了!让我们继续讨论 Azure 容器应用的用户安全。
Azure 容器应用用户安全
Azure 容器应用没有像 Kubernetes 那样的专用用户安全,而是使用 Azure 安全。可以通过 Azure 门户或使用 Azure CLI 通过以下命令将角色分配给特定用户:
az role assignment create `
--assignee <USER IDENTITY RESOURCE ID> `
--role <ROLE NAME> `
--scope <ENVIRONMENT OR APPLICATION_RESOURCE_ID>
所有可用的角色都可以在 Azure 门户的环境和应用程序页面上进行检查。应用程序/环境资源 ID 和用户身份资源 ID 分别可在各自的页面上找到。
威胁检测和缓解
我们需要在应用程序中处理的大量威胁,OWASP,如前所述,帮助我们处理这些威胁。应用程序将需要处理许多常见的攻击,而仅仅保护其网络、数据、入口和依赖关系是不够的,以应对这些攻击。
威胁
在这种场景中最困难的一点是在应用程序运行时即时检测威胁。但为了检测它们,我们需要基本了解它们是什么,所以让我们在以下主题中检查一些常见的攻击。
事件注入
当攻击者操纵输入数据以在应用程序内执行未经授权的操作,导致数据泄露、服务中断或未经授权的访问时,我们面临事件注入攻击。
有几种缓解策略,包括验证和清理输入,确保严格的输入数据;使用强验证库,以定义已建立的库和框架连接;以及将用户权限限制在最低必要范围内。
权限提升
在具有不同访问级别的应用程序中,当攻击者获得超出其所需访问权限时,就会发生权限提升,访问他们未经授权使用的资源或功能。结果可能是灾难性的,可能导致未经授权的数据访问、完全的系统控制以及应用程序的进一步利用。
精细访问控制以限制用户必须得到良好实施。此外,还有可能使用身份和访问管理(IAM)解决方案,这将强制执行用户权限。定期的审计和多因素认证(MFA)也将有助于减轻可能出现的未经授权用户访问相关数据的情况。
拒绝服务(DoS)攻击
假设你的应用程序因攻击者想要破坏你解决方案的可用性,导致网站简单地停止响应,从而造成了大量且过度的流量。这就是我们所说的拒绝服务(DoS)攻击。DoS 攻击通常由单个攻击者从单一地点发起。如果你观察到攻击来源有多个,那么你可能正在经历 DDoS 攻击,这意味着攻击是分布式的。
显然,减轻此类攻击的主要方式是阻止其来源产生的流量,因此流量过滤可能是最佳选择。也有可能限制特定客户端在一定时间内的访问速率,以最小化 DDoS 攻击的影响。
此外,如果你有一个具有高度可用性的解决方案,专注于提供高水平的请求,你可以减少这种攻击的影响,尤其是如果我们谈论 DoS 攻击。因此,自动扩展策略,根据当前负载自动调整服务的活动实例数量,是一种很好的方法。内容分发网络(CDN)的实施,通过在多个地理位置分散的服务器上实现内容,将内容近似到使用它的人,也可以是保护此类攻击的好方法。
中间人(MitM)攻击
当你在系统的两个部分之间拦截通信,篡改数据时,就会发生中间人(MitM)攻击,这可能导致你提供的解决方案中信息不一致。
正如我们之前检查的那样,实施安全的通信通道,使用加密协议来保护传输中的数据,无疑是降低此类威胁风险的最佳方式。认证机制也可以在这方面提供帮助,特别是如果有一种方法可以验证通信方的身份。
代码注入
软件代码当然是在应用程序中造成攻击的一种方式,尤其是如果代码允许注入恶意代码。恶意代码可以添加到 SQL 命令中,这些命令没有正确限制在数据库中执行的内容,从而可能导致泄露、更改或甚至数据排除。在应用程序允许执行脚本的情况下,风险也很高,由于这个原因可能会发生未经授权的操作。你也可能遇到攻击者将代码注入到其他用户查看的网页中的情况。这被称为跨站脚本(XSS)。
在软件对业务至关重要的企业中,实施严格的代码审查流程,应用安全的编码实践是强制性的。为了帮助实现这一点,必须考虑使用静态分析工具,这取决于公司每天生成的代码量。
使用 Web 应用程序防火墙进行检测和缓解
既然您现在了解了可用的威胁数量,那么可以说,没有一种工具可以监控整个流量、根据不同的已知威胁进行检查并在您发现可疑内容时提醒您采取行动,您就无法完全免受这些威胁的保护。这正是Web 应用防火墙(WAF)所做的事情。
SQL 注入、XSS 和其他常见的 Web 攻击可以通过 WAF 处理,因此您必须考虑它们的使用对于确保无服务器和微服务应用程序的安全性至关重要。这仅因为 WAF 通常监控 HTTP/HTTPS 流量,让您有机会阻止来自特定客户端的恶意请求,甚至在它们到达您的应用程序之前。
他们还提供了一个用于监控流量和日志记录的集中式面板,这确实简化了管理并增加了您对所遭受攻击的了解。重要的是要提到,如果您正在运行公共云解决方案,您将不断受到攻击。
微软提供的作为 WAF(Web 应用防火墙)的服务称为 Azure Web 应用防火墙。值得注意的是,Azure WAF 在 OSI 模型第 7 层(应用层)工作,并分析 HTTP(S)流量。为此,需要检查通过通道传输的请求和响应。这个通道的替代方案之一被称为 Azure 应用网关。该组件是一个工作在 OSI 第 7 层的网络流量负载均衡器。它使您能够管理您的 Web 应用的流量。所有被检查的表明存在威胁的流量都会作为警报发送到 Azure Monitor,以便应用程序管理员可以分析它并采取行动。
如您所想象,一个监控您应用程序全部流量的解决方案在预算方面显然是一个问题。因此,这当然是一个关于投资和必须分析的权衡点的讨论。

图 10.10:启用 WAF 的示例解决方案架构
上述图表示的是使用本主题中描述的组件的解决方案架构。如您所见,普通用户通过 HTTPS 访问系统,Azure 应用网关处理流量路由,Azure WAF 保护解决方案免受 Web 威胁。还有使用容器进行工作负载扩展的容器应用环境实现,微服务运行在虚拟网络内部。Azure Monitor 用于系统的日志记录和可观察性,因此管理员访问 Azure Monitor 用于洞察。可观察性正是我们将要讨论的下一个主题。让我们看看它。
无服务器和微服务的可观察性
就像我们在本书的这一部分所看到的那样,分布式系统包含的复杂性带来了一些你无法忽视的问题。单个微服务的实现,使用如无服务器或容器化等技术,通常相当简单,但观察整个解决方案是一项困难的任务,这无疑是这些关注点之一。采用可观测性概念是解决这个问题的好方法。
可观测性由三个主要信号定义:日志、指标和跟踪。日志是一个事件不可变、带时间戳的记录。指标是系统性能随时间变化的数值表示。跟踪代表请求在分布式系统中跨越服务的旅程。这三个信号共同提供了对系统行为的洞察,使得主动维护和快速故障排除成为可能。
与传统的监控方式不同,传统的监控通常关注预定义的指标和系统健康指标,并且通常是反应式的,而可观测性则主张一种主动的方法,监控是持续的,以避免关键问题,快速定位根本原因是目标。
有几种工具可以帮助在分布式系统中实现可观测性。对于日志,Seq 和 ELK Stack 等工具提供了强大的日志聚合和可视化功能。对于指标,Prometheus 是一个广泛使用的开源监控解决方案,通常与 Grafana 配合使用进行可视化。对于分布式跟踪,Jaeger 和 Zipkin 是流行的开源选项。
然而,应用程序性能监控(APM)工具,如 Azure Monitor、Datadog 和 New Relic,允许你将日志、指标和跟踪集中在一个地方,提供系统行为的全面视图。选择它们取决于你的基础设施、云提供商和集成需求。
让我们详细了解可观测性的每个信号,以便更容易理解。
日志
丰富的上下文数据对于理解在分布式系统中问题监控开始的确切位置和时间至关重要。例如,了解服务调用的顺序以及它们之间传递的数据可以揭示错误是否来自特定的服务或来自服务之间的交互。这种详细程度对于有效的调试和确保分布式系统的弹性和可靠性至关重要。
因此,为了提高日志的可使用性,采用一种易于查询和处理的格式至关重要。最常见的方法之一是使用 JSON 作为日志条目的格式,因为它提供了可读性和跨系统的广泛兼容性。然而,结构化日志不仅仅使用结构化格式。它需要有意定义每个日志字段的含义(语义)。这确保了一致性,提高了可观测性,并使得日志之间的过滤、索引和关联更加有效。
此外,有效的日志记录需要使用不同的日志类别来定义日志条目的严重性和重要性。
-
调试:用于技术内部目的的详细信息。
-
信息:关于应用程序的一般信息。
-
警告:可能表明潜在问题,但并未导致应用程序停止的警报。
-
错误:影响应用程序运行并需要分析的问题。
-
致命:导致应用程序终止的严重错误。
正确使用日志级别可以最小化分析问题的努力,以有效和高效的方法关注关键问题。
指标
当涉及到需要监控和评估的无服务器和微服务架构的指标时,有一些特定的指标可以监控。
例如,Azure Functions 测量一个函数从开始到结束的执行时间。这被称为函数执行时间。较短的执行时间通常表明更好的性能。
Azure Functions 还会测量无服务器函数被触发到函数实际上开始运行之间的延迟。这被称为冷启动,减少它会导致用户体验的改善。
调用次数和错误次数也说明了函数的工作情况,有助于性能分析和可能存在问题的代码的分析。
另一方面,当你有容器化环境时,CPU和内存使用可能是需要监控的良好指标。第一个如果过高可能会影响性能,可能需要考虑扩展。第二个也可能影响性能,并且可以解决内存泄漏的原因。
网络流量在容器化环境中也可能是一个关注点,并可能表明与微服务之间通信相关的问题。Pod 健康有助于识别失败的或不健康的 Pod。
这些以及其他指标不仅可以被监控,还可以使用基于阈值的算法和警报进行警报。今天,在 Azure 中,我们也有一些由机器模型执行的异常检测,通常检测某些情况下的行为偏差,如时间响应。
一旦设置了适当的警报,制定一个明确的响应这些警报的协议也很重要。这通常被称为事件响应流程。该流程需要确定如何处理事件(警报),如何沟通,以及如何发现根本原因,以确保事件不再发生。
跟踪
当你有一个分布式应用程序时,理解从请求到其结束的完整路径对于有效地诊断跨链式微服务的情况非常重要。这就是为什么跟踪如此重要的原因,而.NET 应用程序与 Azure 一起提供了一套非常好的库来帮助你完成这项工作。
在这里使用 Azure Monitor 对于成功至关重要。当然,还有其他 APM 系统可以用来观察应用程序的可追溯性,但 Azure Monitor 为我们提供了您可能考虑使用的设施。除此之外,OpenTelemetry库将为您提供企业解决方案所需的灵活性。OpenTelemetry(OTel)是一个跨平台、开放标准,用于收集和发射遥测数据。
在.NET 中,OpenTelemetry 实现使用众所周知的平台 API 进行仪表化:
-
Microsoft.Extensions.Logging.ILogger<TCategoryName>用于日志记录 -
System.Diagnostics.Metrics.Meter用于指标 -
System.Diagnostics.ActivitySource和System.Diagnostics.Activity用于分布式跟踪
这些 API 由 OTel 用于收集遥测数据并将这些数据导出到开发者选择的 APM 服务。
还要注意,使用 OTel 为.NET 和 Azure Monitor 实现跟踪传播是全自动的,这加速了在 Azure Monitor 中观察应用程序行为的进程。
使用 Azure Monitor 实现集中可观察性
以下示例将向您展示 Azure Monitor 作为 APM 系统在集中日志记录、指标和跟踪方面的强大功能,作为一个专业的可观察性工具,加速诊断并允许快速故障排除,实现主动管理。
在启动程序中提供的代码使用 Azure Monitor 来注册由OpenTelemetry库收集的遥测数据,正如我们在这里所看到的:
var builder = WebApplication.CreateBuilder(args);
// Retrieve Application Insights connection string from configuration
string appInsightsConnectionString = builder.Configuration[
"AzureMonitor:ConnectionString"];
builder.Services.AddOpenTelemetry()
.WithTracing(tracerProviderBuilder =>
{
tracerProviderBuilder
// Set resource builder with application name
.SetResourceBuilder(
ResourceBuilder.CreateDefault().AddService(
builder.Environment.ApplicationName))
// Add ASP.NET Core instrumentation
.AddAspNetCoreInstrumentation()
// Add HTTP client instrumentation
.AddHttpClientInstrumentation()
// Add Azure Monitor Trace Exporter with connection string
.AddAzureMonitorTraceExporter(options =>
{
options.ConnectionString = appInsightsConnectionString;
});
});
// Add Application Insights only for logging & metrics
// (without re-adding tracing)
builder.Services.AddApplicationInsightsTelemetry(options =>
{
options.ConnectionString = appInsightsConnectionString;
// Disable AI's automatic trace sampling
options.EnableAdaptiveSampling = false;
// Prevents duplicate dependency tracking
options.EnableDependencyTrackingTelemetryModule = false;
// Prevents duplicate HTTP request tracking
options.EnableRequestTrackingTelemetryModule = false;
});
var app = builder.Build();
同一段代码有两个 API。这些 API 将通过另一条路径获取数据,但其中之一将尝试访问一个未知的 URL。
// Map GET request to /error endpoint
app.MapGet("/error", async (HttpContext context) =>
{
var httpClient = new HttpClient();
var response = await httpClient.GetAsync(
"https://anyhost.sample.com/data");
return "Hello Trace!";
});
注意,与成功端点一起工作的 API 将尝试访问 Packt 网站。
// Map GET request to /success endpoint
app.MapGet("/success", async (HttpContext context) =>
{
var httpClient = new HttpClient();
var response = await httpClient.GetAsync("https://www.packtpub.com/");
return "Hello Trace!";
});
两个结果都令人印象深刻。第一个结果表示带有错误的端点可以在 Azure Monitor 端到端事务视图中完全跟踪。

图 10.11:Azure Monitor 端到端事务视图中带有错误的端点
这种监控对于检测此端点错误,便于修复此错误非常有用。
第二个结果也很有趣,因为它检测到一个可以改善请求性能的重定向。

图 10.12:Azure Monitor 端到端事务视图中成功结果的端点
这里的问题是每个调用只需 67.2 毫秒就能重定向到所需的页面。也许一个替代方案是直接访问正确的 URL。我们需要将这个例子视为一个假设情况,但在现实世界中,这可以提高应用程序的性能。
摘要
在本章中,我们有机会讨论无服务器和微服务应用程序的安全和可观察性策略。我们需要理解,网络犯罪带来的威胁增加促使我们在产品开发的初始阶段就整合安全。为此,我们必须在我们的安全设计方法中应用数据库的安全最佳实践,实施如JSON Web Tokens (JWTs)、OAuth 2.0 和OpenID Connect (OIDC)等身份验证和授权机制,并使用如虚拟私有云 (VPCs)和 Azure Private Link 等网络保护方法。加密、强制 HTTPS 和使用 Azure Key Vault 管理密钥对于现代应用程序开发也很重要。
本章的另一个重点是网络安全,尤其是在 Kubernetes 和 Azure Container Apps 环境中。因此,本章解释了 Kubernetes 网络策略如何通过使用命名空间和基于 Pod 的网络规则来隔离应用程序和服务,从而增强安全性。Azure 的网络安全策略涉及虚拟网络、防火墙和私有链接,以限制对公共威胁的暴露。本章还讨论了用户安全,强调了 Kubernetes 和 Azure 的角色分配中的基于角色的访问控制(RBAC)。它还通过确保第三方组件、库和容器定期更新以防止漏洞来讨论保护依赖项。
本章还强调了威胁检测的重要性,使用网络应用防火墙(WAFs)和主动安全策略来减轻注入攻击、拒绝服务(DoS)攻击和权限提升等威胁。
最后,可观察性是本章提出的另一个关键主题,它通过三个主要信号来定义:日志、指标和跟踪。本章解释了如何通过按严重程度分类的结构化日志来有效地诊断问题。它还涵盖了无服务器函数和容器化应用程序的关键性能指标,例如执行时间、资源消耗和错误率。包括 OpenTelemetry 和 Azure Monitor 在内的跟踪技术被提出作为跟踪分布式事务和增强系统监控的解决方案。
问题
- 为什么与单体应用程序相比,安全在无服务器和微服务架构中是一个关键关注点?
在无服务器和微服务架构中,安全更为关键,因为它们显著扩大了攻击面。与单体应用程序不同,分布式系统涉及多个独立服务通过网络进行通信,这增加了网络攻击的潜在入口点。每个微服务、API 或功能可能会暴露漏洞,而管理它们之间的安全性的复杂性需要更全面和分层的方法。
- 应用程序中的关键安全层有哪些,为什么“洋葱模型”是一个有用的类比?
安全的关键层包括:
-
数据安全(例如,加密、安全数据库访问)
-
应用程序安全(例如,认证和授权)
-
第三方组件(例如,库更新)
-
基础设施和网络安全(例如,VPCs、防火墙)
-
流量拦截和监控(例如,WAFs)
“洋葱模型”之所以有用,是因为它强调安全必须在多个同心层中实现。每一层都加强了其他层,减少了单点故障的可能性。
- 虚拟私有云(VPC)如何提高云环境中的安全性,以及它的主要好处是什么?
VPC 在公共云中创建一个逻辑上隔离的网络,允许您定义自定义子网、路由规则和网关。主要好处包括:
-
减少对公共威胁的暴露
-
精细流量控制
-
与 Azure Private Link 等服务集成
-
通过网络安全组和流量日志增强监控和保护
-
认证和授权之间的区别是什么,以及一些常用的认证机制有哪些?
-
认证是验证用户或系统身份的过程。
-
授权决定了经过身份验证的用户可以执行的操作。
-
常见的机制包括:
-
JSON Web Tokens (JWTs)
-
OAuth 2.0
-
OpenID Connect (OIDC)
-
-
-
JSON Web Token (JWT)如何确保客户端和服务器之间的安全通信?
JWT 通过 HTTP 头部传输一个签名 JSON 对象来编码用户声明。在身份验证成功后,服务器发放一个令牌。然后客户端在后续请求中包含这个令牌。服务器端的中间件在允许访问之前验证令牌。JWT 的无状态和签名特性有助于确保消息完整性和安全的访问控制。
- Kubernetes 中用于处理网络安全的资源有哪些?
Kubernetes 使用以下方式处理网络安全:
-
用于隔离应用程序的命名空间
-
具有特定标签和规则的 Pod
-
根据以下内容定义入站/出站规则的 NetworkPolicy 资源:
-
IP 块
-
Pod 选择器
-
命名空间选择器
-
端口和协议
-
这些策略以模块化、以应用程序为中心的方式约束服务之间的通信。
- Kubernetes 中用于处理用户安全性的资源有哪些?
Kubernetes 中的用户安全通过以下方式管理:
-
用户和组
-
角色和角色绑定(命名空间范围)
-
集群角色和集群角色绑定(集群范围)
通过动词(获取、列出、创建、删除等)定义权限,并通过角色绑定绑定到用户/组。认证通常使用客户端证书。
- Azure 容器应用是否有针对用户和网络安全的特定设施?
是的:
-
网络安全通过 Azure 虚拟网络和子网来处理。
-
用户访问通过 Azure 基于角色的访问控制(RBAC)进行管理,其中角色通过 Azure 门户或 CLI 分配给用户。
-
Azure 没有像 Kubernetes 那样的专用用户安全模型,而是依赖于更广泛的 Azure 身份平台。
- 常见的网络威胁有哪些,例如权限提升和拒绝服务攻击,以及可以用来减轻它们的影响的策略有哪些?
常见威胁:
-
事件注入: 通过输入验证/清理来缓解。
-
权限提升: 通过细粒度访问控制、IAM 解决方案、审计和多因素认证来缓解。
-
DoS/DDoS 攻击: 通过速率限制、流量过滤、自动扩展和 CDNs 来缓解。
-
中间人攻击: 通过 HTTPS/TLS 加密和身份验证来缓解。
-
代码注入(例如,SQL 注入,XSS):通过安全编码实践、静态分析和 WAFs 来缓解。
- Web 应用防火墙(WAFs)在保护微服务应用中扮演什么角色,它们的主要优势是什么?
WAFs 监控和过滤 HTTP/HTTPS 流量,在恶意请求到达应用程序之前将其阻止。优势包括:
-
防御已知的网络漏洞(例如,SQL 注入,XSS)
-
集中式日志记录和警报(例如,通过 Azure Monitor)
-
能够阻止特定客户端
-
简化的安全管理
Azure 的 WAF 与 Application Gateway 集成,并在 OSI 第 7 层运行。
- 可观测性的三个主要信号是什么,它们如何有助于维护一个安全且高效的系统?
三个主要信号是:
-
日志: 不可变的事件记录,有助于调试和审计。
-
指标: 定量性能指标(例如,执行时间,内存使用)。
-
追踪: 可视化服务间的请求路径,以进行根本原因分析。
它们共同允许主动监控,帮助检测异常,并支持快速事件响应——这对于安全且弹性的系统至关重要。
进一步阅读
-
Azure 容器应用网络:
learn.microsoft.com/en-us/azure/container-apps/networking?tabs=workload-profiles-env%2Cazure-cli. -
购买自定义域名:
learn.microsoft.com/en-us/azure/app-service/manage-custom-dns-buy-domain -
存储应用程序密钥:
learn.microsoft.com/en-us/samples/azure/azure-sdk-for-net/app-secrets-configuration/ -
透明数据加密:
learn.microsoft.com/en-us/sql/relational-databases/security/encryption/transparent-data-encryption -
JSON Web Tokens:
jwt.io/ -
OAuth 2.0:
oauth.net/ -
MSAL:
learn.microsoft.com/en-us/entra/identity-platform/msal-overview -
什么是 OIDC?:
www.microsoft.com/en-us/security/business/security-101/what-is-openid-connect-oidc -
OIDC:
openid.net/ -
OWASP:
owasp.org/ -
Azure Private Link:
learn.microsoft.com/en-us/azure/private-link/private-link-overview -
网络安全组:
learn.microsoft.com/en-us/azure/virtual-network/network-security-groups-overview -
虚拟网络流量日志:
learn.microsoft.com/en-us/azure/network-watcher/vnet-flow-logs-overview -
Azure 虚拟网络:
learn.microsoft.com/en-us/azure/virtual-network/virtual-networks-overview -
Azure 管理标识:
learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/overview -
Azure 防火墙:
learn.microsoft.com/en-us/azure/firewall/overview -
Azure Web 应用程序防火墙:
azure.microsoft.com/en-us/products/web-application-firewall -
Azure 应用网关:
learn.microsoft.com/en-us/azure/application-gateway/ -
OpenTelemetry:
learn.microsoft.com/en-us/dotnet/core/diagnostics/observability-with-otel -
GitHub Dependabot:
github.com/dependabot -
Sonar:
www.sonarsource.com/ -
Synk:
snyk.io/ -
Seq:
datalust.co/seq -
ELK Stack:
www.elastic.co/elastic-stack/ -
Prometheus:
prometheus.io/ -
Grafana:
grafana.com/ -
Jaeger:
www.jaegertracing.io/ -
Zipkin:
zipkin.io/ -
Datadog:
www.datadoghq.com/ -
New Relic:
newrelic.com/
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十一章:汽车共享应用
汽车共享应用在第二章,“揭秘微服务应用”中介绍。无论使用什么技术实现,任何微服务要么处理用户界面请求,要么处理来自其他微服务的消息,或者将结果流式传输到为解决方案定义的通信总线。因此,我们决定用一章来提供更多关于它的详细信息。将整个解决方案的描述放入一章的目的是帮助您更好地理解我们在整本书中涵盖的原则。现在让我们了解应用的一般架构。
一般架构描述
在本章中,我们将更详细地描述的应用是汽车共享应用。以下图展示了整个解决方案以及实现该解决方案所涉及的微服务:

图 11.1:汽车共享应用
在第七章,“微服务实践”中,我们描述了此演示中微服务之间交换的一些消息。实现这些消息的所有类都包含在演示代码中展示的 SharedMessages 库项目中。重要的是要提到,所有微服务都必须添加此库以方便服务之间的通信。还值得注意的是,RabbitMQ 是为此次演示定义的消息代理,这已在书中介绍过。
涉及的微服务
如前图所示,有五个微服务旨在演示该解决方案。还有一个服务仅使用 Blazor 作为基础(Blazor UI)部署用户界面。其目的是托管与以下微服务通过 HTTP 和 RabbitMQ(如有适用)交互的用户界面。
授权微服务
在第十章,“无服务器和微服务应用的安全和可观察性”,我们讨论了实施具有不同保护层的安全性的重要性。授权微服务是这些层之一,它处理用户登录和承载令牌的发放。它还包含用户信息。它拦截每个汽车共享者的路由扩展-接受消息,并允许请求被接受的用户访问汽车共享者资料。需要汽车共享的用户可以通过提供接受该请求的路由 ID 来访问接受其请求的汽车共享者的用户资料。
为了实现这一点,使用了 ASP.NET Core Web API。所有端点都需要相同的承载令牌。以下是为此微服务提出的端点:
-
登录 – 接受凭据并返回 JWT
-
续订 – 接受令牌并返回更新的 JWT
-
更改密码 – 接受当前密码和新密码以更新用户凭据
-
重置密码 – 向用户的电子邮件发送临时密码
-
添加用户 – 注册新用户
-
用户资料 – 为匹配的共享汽车行程提供用户的电子邮件和姓名
管理用户登录、密码更新和令牌生成的目的是所有应用程序的共同点。值得注意的是,在现实世界中,许多解决方案将决定由 Microsoft、Google 或 Meta 的身份提供者来完成这项服务。
CarSharer 微服务
CarSharer 微服务与 Blazor UI 交互,并包含实现所有共享汽车操作的 Web API。共享汽车者插入一个包含他们的出发地和目的地城镇以及可能的中间城镇的初始路线。
然后,他们通过 RoutesPlanning 微服务接收可能的共享汽车请求匹配。相应地,它显示了所有可能的扩展,共享汽车者可以拒绝或接受每个扩展。他们还可以关闭路线,这意味着他们达到了可接受的旅行人数。在这里,您可以看到为这个示例场景想象的路线:
-
创建路线 – 使用日期和所有城镇的里程碑创建新的路线
-
删除路线 – 删除特定的路线
-
关闭路线 – 关闭路线以防止进一步的匹配
-
扩展路线 – 接受对现有路线的用户请求
-
获取建议的扩展 – 列出与路线兼容的行程请求
-
获取活跃路线 – 列出特定用户的全部活跃(未过期或删除)路线
考虑到这本质上是一个 CRUD 操作,这个微服务可以使用 Azure Functions 实现,正如我们在 第四章 中讨论的,可用的 Azure Functions 和触发器。
CarRequests 微服务
CarRequests 微服务也与 Blazor UI 交互。它包含实现所有汽车行程请求操作的 Web API。用户插入从起点到目的地的请求。然后,用户可以验证共享汽车者是否在他们的请求中插入了请求。当共享汽车者接受请求时,其他共享汽车者无法选择它,因此只处理一个选项。我们假设用户自动接受共享汽车者的提议。在这里,我们有这个实现的端点:
-
添加新请求 – 插入一个包含起点、目的地和日期的行程请求。确认请求是否已注册非常重要。
-
获取我的请求 – 列出带有匹配共享汽车选项的活跃请求。匹配的路线还包含车主的详细信息,可用于从身份验证服务器获取用户信息。
在这里,Azure Functions 技术再次是一个不错的选择。
RoutesPlanning 微服务
RoutesPlanning 微服务根据最小化距离标准将拼车者的路线与车辆请求相匹配。其行为在 第七章 的 实践中的微服务 中完全描述,这里使用的技术是 ASP.NET Core Web API。为了便于理解,实现它的代码也包含在本章中。
电子邮件微服务
最后,Email 微服务拦截由拼车者发出的路线扩展接受事件,并通过电子邮件通知路线中包含的所有用户。它作为后台工作,正如我们在 第五章 中检查的一些实现,实践中的后台函数。发出的路线扩展接受事件包含 UserBasicInfoMessage,其中示例中的用户 DisplayName 应该是电子邮件。这些是在此微服务中将要执行的功能:
监听 RouteExtensionAccepted 事件并将请求入队以发送电子邮件
处理电子邮件,这是将请求出队并发送电子邮件的常规操作
Azure Functions 技术也将用于此案例。微服务的理念不是将电子邮件处理直接附加到监听事件上。这就是为什么使用队列的原因。
演示代码
您可以在此章节的示例代码在 github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp/tree/main/ch11。此章节至少需要 Visual Studio 2022 的免费 Community Edition。
请注意,提供的代码并非完全功能。作为读者的您被鼓励进一步开发它。其主要目的是为在特定用例中实现不同的微服务方法提供一个基础。
以下表格总结了提出的微服务列表:
| 微服务 | 技术 | 主要责任 | API/事件亮点 |
|---|---|---|---|
Authorization |
ASP.NET Core Web API | 管理用户认证和配置文件 | Login, Renew, AddUser, GetProfile |
CarSharer |
Azure Functions | 管理车主路线 | CreateRoute, ExtendRoute, GetSuggestions |
CarRequest |
Azure Functions | 管理用户出行请求 | AddRequest, GetRequests |
RoutesPlanning |
ASP.NET Core Web API | 建议最佳路线请求匹配 | 事件驱动逻辑,见 第七章 |
Email |
Azure Functions | 通过电子邮件通知用户 | RouteExtensionAccepted → 队列 → 电子邮件 |
由于 SQL 实例必须与运行在 Docker 容器内的客户端进行通信,因此它接受 TCP/IP 请求和用户/密码认证。请注意,随 Visual Studio 安装提供的 SQL 实例不支持 TCP/IP,因此您需要安装 SQL Server Express 或使用云实例。对于本地安装,安装程序和说明文档都可在以下链接找到:www.microsoft.com/en-US/download/details.aspx?id=104781。您还可以使用以下命令将 SQL Server 开发版作为 Docker 镜像运行:
docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=yourStrong(!)Password" -p 1433:1433 -d mcr.microsoft.com/mssql/server:2022-latest
-
对应于所选密码的用户名将是
sa。 -
要运行 Docker,请使用 Windows 的 Docker Desktop (
www.docker.com/products/docker-desktop)。 -
Docker Desktop 又需要 Windows Subsystem for Linux (WSL),可以通过以下步骤安装:
-
在 Windows 10/11 的搜索栏中输入
powershell。 -
当 Windows PowerShell 作为搜索结果出现时,点击 以管理员身份运行。
-
在出现的 Windows PowerShell 管理控制台中,运行
wsl --install命令。
下图显示了代码结构的组织方式:

图 11.2:汽车共享应用代码结构
如您所见,存在一个 Common 库,它将共享将在微服务之间传输的消息。Authorization 和 RoutesPlanning 是使用 Web API 微服务编写的,而 CarRequests、CarSharer 和 Email 是基于 Azure Functions 编写的。这就是为什么我们在本书的演示中展示了这两种可能性。根据我们所展示的,根据微服务的复杂性和业务规则的实际需求,我们可以选择以下这些替代方案之一来创建分布式应用程序。
摘要
在本章中,我们详细演示了使用微服务作为连接从前端到后端传输的每条消息的基础的事件驱动应用程序。我们希望这个演示能帮助您更好地理解本书中提出的所有原则。
进一步阅读
-
云设计模式:
learn.microsoft.com/en-us/azure/architecture/patterns/ -
事件驱动应用程序:
learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/event-driven
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

第十二章:使用 .NET Aspire 简化微服务
.NET Aspire 是为了简化在开发机器上测试交互式微服务而设计的。在第八章“使用 Kubernetes 实践微服务组织”的“在 Kubernetes 上运行您的微服务”部分中,我们列出了我们可以在开发机器上采用的两个测试技术:
-
在调试每个单个微服务的同时,使用桥接技术测试与 minikube 交互的微服务
-
利用 Visual Studio 对 Docker 的原生支持来调试和测试我们通过 Docker 虚拟网络交互的微服务
虽然 minikube 技术完整且更真实,但它耗时较长,因此大多数测试/调试工作都是使用 Docker 虚拟网络完成的。
.NET Aspire 提供了一种更简单的替代方案,可以直接使用 Docker 网络。此外,它还提供了一种简单的方式来配置微服务之间的交互以及每个微服务与其他资源之间的交互。最后,.NET Aspire 项目可以编译生成指令,用于在 Azure 容器应用上部署所有微服务,以及创建它们在 Azure 上使用的某些资源。然而,其主要用途在于开发和测试环境,不应用于自动设置实际的生产环境,因为它不处理所有部署选项。
在本章中,我们将介绍 .NET Aspire 的基础知识,以及它提供的所有服务和机会。具体来说,本章涵盖了以下内容:
-
.NET Aspire 功能和服务
-
配置微服务和资源
-
在实践中使用 .NET Aspire
-
部署 .NET Aspire 项目
技术要求
本章需要以下条件:
-
至少需要 Visual Studio 2022 免费社区版。
-
Docker Desktop for Windows (
www.docker.com/products/docker-desktop),它反过来又需要 Windows Subsystem for Linux (WSL),可以通过以下步骤安装: -
在 Windows 10/11 的搜索栏中输入
powershell。 -
当 Windows PowerShell 作为搜索结果出现时,点击以管理员身份运行。
-
在出现的 Windows PowerShell 管理控制台中,运行
wsl --install命令。
您可以在github.com/PacktPublishing/Practical-Serverless-and-Microservices-with-Csharp找到本章的示例代码。
.NET Aspire 功能和服务
.NET Aspire 负责微服务之间的交互,并提供以下其他服务:
-
它以非常简单的方式处理与环境资源(如数据库和消息代理)的交互。您不需要指定可能在微服务部署时更改的连接字符串;只需声明微服务与资源之间的交互以及一些通用配置即可。这是通过.NET 功能本地服务发现完成的,将在服务发现及其在.NET Aspire 中的作用子节中详细讨论。
-
它提供了云服务的模拟器,以及常见的磁盘和内存数据库以及消息代理。
-
微服务与其他资源的交互在专用的.NET 项目中声明性配置,从而避免了在微服务代码中使用虚拟地址和连接字符串。
-
一旦运行.NET Aspire 项目,所有微服务和资源都会运行,微服务与资源之间的交互将自动处理。
-
当微服务在开发环境中运行时,日志和统计信息都会被收集。
-
一旦运行.NET Aspire 项目,浏览器中就会出现一个智能控制台,显示所有收集的统计信息和日志,以及访问所有微服务端点的链接。
微服务之间的交互以及微服务与其他资源之间的交互在称为应用程序主机的特殊类型的项目中声明。您可以通过在 Visual Studio 搜索框中键入Aspire来找到应用程序主机项目和其他所有 Aspire 模板,如图所示:

图 12.1:Aspire 项目和解决方案模板
另一种 Aspire 特定的项目类型是.NET Aspire 服务默认项目,它提供了扩展方法来配置各种服务。为了确保某些基本服务在所有微服务中以相同的方式配置,我们在该项目中定义它们,然后在所有微服务项目的Program.cs配置中调用它们的扩展方法。因此,所有微服务都必须添加对这个项目的引用。
默认情况下,所有 Aspire 模板配置以下服务默认值:
-
HttpClient服务发现:在应用程序主机配置中,微服务和资源被赋予名称,多亏了这种配置,HttpClient可以使用基于这些名称的虚拟 URL,而不是实际资源 URL,这些 URL 可能取决于资源在各个环境(开发、预发布、生产等)中的部署位置。 -
HttpClient弹性:每个HttpClient调用都会自动应用于所有策略,如第二章中的弹性任务执行小节所述,揭秘微服务应用。更具体地说,重试、断路器、超时和速率限制(舱壁隔离)策略会自动应用,并且可以在.NET Aspire 服务默认值项目中一次性配置完成。 -
将在专用小节中讨论 OpenTelemetry。
-
公开端点用于暴露微服务的健康状态。健康检查既由 App Host 调度器使用,也由 Kubernetes 等预演和生产调度器使用(见第八章中的就绪性、活跃性和启动探测小节,使用 Kubernetes 的实用微服务组织)。提供了两个默认端点:
/health,如果微服务健康,则返回200HTTP 状态码和“健康”测试响应,以及/alive端点,如果微服务正在运行且未崩溃,则返回200HTTP 状态码和“健康”测试响应。 -
默认情况下,出于安全原因,这两个端点仅在开发期间公开。但是,如果微服务对外部用户不可访问,它也可以在生产中安全公开。您只需在 .NET Aspire 服务默认值项目中定义的
MapDefaultEndpoints()扩展中移除对环境的条件即可。 -
如果微服务是前端,则只有当它们同时受到身份验证和防止拒绝服务攻击的节流策略的保护时,这些端点才能公开。
由于这些配置在项目创建时自动添加,因此您无需手动添加所有这些配置。大多数情况下,您只需更改一些参数,例如各种弹性策略的参数。
每个微服务只需要调用 builder.AddServiceDefaults() 和 app.MapDefaultEndpoints() 即可应用所有配置的默认值,如下所示:
var builder = WebApplication.CreateBuilder(args);
// Add service defaults & Aspire client integrations.
**builder.****AddServiceDefaults****();**
// Add application specific services
builder.Services…..
…
// Build application host
var app = builder.Build();
//Configure application
app….
…
//Add default endpoints
**app.****MapDefaultEndpoints****();**
app.Run();
此外,还有基于 xUnit、NUnit 和 MSTest 的 Aspire 特定测试项目。它们都包含创建应用程序宿主、启动应用程序以及通过基于其名称的 URL(服务发现)与微服务通信所需的所有引用。
一旦添加测试项目,它就包含一个初始示例测试,其中包含创建 App Host 和调用微服务的整个代码。此代码已注释,因此您只需将您的 App Host 项目引用添加到代码中,并将假的 App Host 项目名称和微服务名称替换为您的 App Host 项目名称和微服务名称即可:
// Arrange
// var appHost = await DistributedApplicationTestingBuilder
.CreateAsync<Projects.**MyAspireApp_AppHost**>();
// appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
// {
// clientBuilder.AddStandardResilienceHandler();
// }); //
// await using var app = await appHost.BuildAsync();
// var resourceNotificationService = app.Services.
// GetRequiredService<ResourceNotificationService>();
// await app.StartAsync();
// // Act
// var httpClient = app.CreateHttpClient("**webfrontend**");
// await resourceNotificationService
// .WaitForResourceAsync("**webfrontend**",
// KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
// var response = await httpClient.GetAsync("/");
在前面的代码中,必须替换的假名称被突出显示。
还有一个名为 .NET Aspire Empty App 的模板可用,它创建 App Host 和 Service Defaults 项目,以及一个 .NET Aspire Starter App 模板,该模板添加了一些示例微服务和资源,以及它们的 App Host 配置。
.NET Aspire Starter App 模板具有很高的教学价值,因为它立即显示了基本配置,以及如何使用服务发现配置和使用 HttpClient。此外,它是探索当应用启动时在浏览器中出现的控制台(包括其统计信息和日志,以及访问所有微服务端点的链接)的好方法。鼓励您创建、探索和运行此项目。
服务发现不是 Aspire 特有的功能,而是 .NET 的一般功能。它依赖于各种提供者将服务名称映射到实际的 URL。我们将在下一小节中更详细地讨论它。
服务发现及其在 .NET Aspire 中的作用。
服务发现是通过 Microsoft.Extensions.ServiceDiscovery NuGet 包中定义的扩展方法提供的 HttpClient 功能。
服务名称通过提供者定义的映射映射到实际的 URL。默认情况下,仅将 .NET 配置提供者添加到提供者列表中。
此提供者尝试从项目配置的 Service 部分读取这些映射,其中它们必须如下定义:
"Services": {
"myservice": {
"https": [
"10.46.24.91:80"
],
"http": [
"10.46.24.91:443"
]
}
}
当使用 http://myservice 调用服务时,选择 http 子节中指定的端点;否则,如果使用 https://myservice 调用,则选择 https 子节中的端点。
以下是基于配置的提供者添加的内容:
builder.Services.AddServiceDiscovery();
之前的代码还添加了透传提供者,该提供者简单地解析每个服务名称到服务名称本身。换句话说,透传提供者什么都不做!在部署到 Kubernetes 时必须使用它,因为在 Kubernetes 中,名称是通过服务解析的。
因此,当部署到 Kubernetes 时,每个微服务都必须有一个与之关联的服务,其名称与微服务名称相同。
例如,如果我们有一个名为 routes_planning 的微服务,它部署在 Kubernetes 的 routes_planning Deployment 中,那么对 routes_planning 的通信必须通过名为 routes-planning 的 Kubernetes 服务进行。
如果服务名称无法通过基于配置的提供者解析,它将被传递到下一个提供者,即透传提供者。
假设我们想在 Kubernetes 上部署应用,但首先我们需要使用 .NET Aspire 测试我们的应用。对于这两个环境,我们需要设置两种不同的服务发现配置吗?
答案是否定的!实际上,.NET Aspire 不使用配置文件来定义服务映射。相反,当 App Host 项目启动微服务时,它会将所有所需的服务解析规则注入到环境变量中,然后这些变量与其他所有微服务配置信息合并。
当应用程序发布到 Kubernetes 集群时,将没有 App Host,因此不会在配置中注入服务解析映射,所有解析都传递给透传提供程序。
也可以使用 AddServiceDiscoveryCore(),它不添加任何默认提供程序,而不是 AddServiceDiscovery()。在这种情况下,必须通过调用
AddPassThroughServiceEndpointProvider() 和 AddConfigurationServiceEndpointProvider()。
例如,如果我们只想添加基于配置的提供程序,我们可以简单地编写以下代码:
builder.Services.AddServiceDiscovery()
.AddConfigurationServiceEndpointProvider();
通过设置 ConfigurationServiceEndPointResolverOptions 选项对象的属性,也可以自定义服务发现:
builder.Services.Configure<ConfigurationServiceEndPointResolverOptions>(
static options =>
{
options.SectionName = "MyCustomResolverSection"
});
一旦我们添加并配置了服务发现,我们必须指定必须使用它的 HTTP 客户端。以下代码将服务发现应用于所有 HTTP 客户端:
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddServiceDiscovery();
});
ConfigureHttpClientDefaults 也可以用来为所有 HTTP 客户端添加和配置各种弹性策略:
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
上述代码是所有 .NET Aspire Service Defaults 项目中添加的默认 HttpClient 配置。
服务发现也可以添加到特定的 HttpClient,如下所示:
builder.Services.AddHttpClient("myclient", static client =>
{
client.BaseAddress = new("https://routes_planning");
})
.AddServiceDiscovery();
当服务发现就绪时,我们还可以编写如 "https+http://routes_planning" 或 "http+https://routes_planning" 这样的 URI。在这种情况下,服务发现将尝试使用第一个协议(https 或 http)解析 URI,如果失败,将尝试第二个协议。
这在我们开发时使用 http,在预发布和生成环境中使用 https 时很有用。为此,只需在所有微服务项目的启动设置中定义 http 端点就足够了。实际上,App Host 使用每个微服务的启动设置来创建它注入到环境变量中的服务发现映射。因此,在开发期间只会生成 http 映射,所以 https 解析将失败。相反,部署后,仅透传提供程序将工作,因此 https 解析将成功。
到目前为止,我们假设每个微服务只有一个端点,但有时,一些服务可能有多个端点,每个端点位于不同的端口上。当一个微服务有多个端点时,我们必须为除了一个(默认端点)之外的所有端点命名。端点名称在 App Host 中的服务定义和配置中给出。以下是一个具有默认端点和名为 "aux" 的命名端点的微服务定义:
var routesPlanning = builder.AddProject<Projects.RoutesPlanningService>("routes_planning ")
.WithHttpsEndpoint(hostPort: 9999, name: "aux");
在这种情况下,生成的配置映射将把两个 URL 关联到服务名称,一个用于默认端点,另一个用于命名端点,如下所示:
"Services": {
"routes_planning": {
"https": ["https://localhost:8080"],
"aux": ["https://localhost:8090"]
}
}
默认端点可以通过 "https://routes_planning" 访问,而对于命名端点,我们必须将端点名称也添加到 URI 中,如下所示:
https://_aux.routes_planning
当使用 Aspire App Host 时,前面的配置会自动创建并注入到所有需要它的服务中,所以我们不需要担心它。
然而,如果我们部署在 Kubernetes 上,我们必须定义一个正确解析 "https://routes_planning" 和 "https://_aux.routes_planning" 的 Kubernetes 服务。这个结果可以通过命名端口轻松实现,如下所示:
apiVersion: v1
kind: Service
metadata:
name: routes_planning
spec:
selector:
name: routes_planning
ports:
- name: default
port: 8080
- name: aux
port: 8090
与默认端点关联的端口必须命名为 default,而与所有命名端点关联的端口必须与端点名称相同。
现在我们已经了解了实际服务 URL 发现背后的魔法,让我们继续了解资源集成和自动连接字符串处理的魔法。
资源集成和自动资源配置
当解决方案运行时,可以模拟各种微服务项目所需的资源。只需添加相应的 Aspire NuGet 包,并在 App Host 中声明和配置资源即可。对于声明主数据库、Redis 和主要消息代理(如 RabbitMQ、Kafka)以及 Azure Service Bus 模拟器的扩展方法。有关可以添加到 Aspire 项目并在其 App Host 中配置的所有资源的完整列表,请参阅官方文档中的集成概述 learn.microsoft.com/en-us/dotnet/aspire/fundamentals/integrations-overview。
在幕后,所有这些资源都是通过 Docker 镜像实现的,因此大多数扩展方法也允许您选择特定的 Docker 镜像和特定版本。此外,由于 App Host 支持通用 Docker 镜像,因此可以为尚未支持的自定义资源实现扩展方法。然而,支持的资源列表正在迅速增长,因此您应该能够找到所有需要的资源已经实现。
在本章的 使用 .NET Aspire 实践 部分,您将详细了解如何集成和配置 SQL Server 和 RabbitMQ,而在 配置微服务和资源 部分,我们将解释如何在 App Host 中声明和配置微服务和资源。
当您配置资源时,您给它一个名称,如果资源支持连接字符串,则该名称假定是连接字符串的名称。相应地,当 App Host 创建资源时,它会计算其连接字符串,并将其传递给使用该资源的所有微服务的配置中的 ConnectionStrings 部分。这是通过将配置字符串放置在名为 ConnectionStrings__<name> 的环境变量中完成的,其中 <name> 是我们赋予资源的名称。
例如,假设我们的应用程序需要一个包含名为 "mydatabase" 的数据库的 SQL Server 实例。在 App Host 中,我们可能用以下方式声明这些资源:
var builder = DistributedApplication.CreateBuilder(args);
var sql = builder.AddSqlServer("sql");
var db = sql.AddDatabase("mydatabase ");
现在,如果 MyExampleProject 项目中定义的微服务必须使用 "mydatabase" 数据库,它必须声明如下:
builder.AddProject<Projects.MyExampleProject>()
.**WithReference****(db);**
WithReference(db) 调用将导致访问 SQL Server 实例中 "mydatabase" 的连接字符串注入到 MyExampleProject 微服务的 ConnectionStrings__mydatabase 环境变量中。
显然,当我们配置资源时,我们也可以指定访问它的凭据,而不是使用由扩展方法创建的默认凭据。
在下一节中,我们将详细介绍如何在 App Host 中配置资源和微服务。
通常,与连接字符串一起,App Host 会传递一个包含更多资源详细信息的整个配置部分,例如用户名和密码。此辅助数据的格式取决于特定的资源类型。在本章的 使用 .NET Aspire 实践 部分,我们将看到 RabbitMQ 辅助信息格式。所有受支持资源的辅助信息格式可在官方文档中找到。
如果我们想使用已经存在的资源,我们不需要在 App Host 中声明它,但我们需要使用 builder.AddConnectionString 声明其连接字符串,以便 App Host 可以将其注入所有需要的微服务。例如,如果上一个示例中的 SQL Server 数据库在开发环境和部署环境中都已存在,则代码必须按以下方式修改:
var builder = DistributedApplication.CreateBuilder(args);
var db = builder.AddConnectionString("parameterName", "database");
在这里,parameterName 是在 App Host 配置文件中的 "Parameters" 部分包含连接字符串的参数名称,如图所示:
{
"Parameters": {
" parameterName ": " SERVER=XXX.XXX.X.XX;DATABASE=DATABASENAME ……"
}
}
不言而喻,我们可以使用 .NET 环境在不同的环境中提供不同的配置。
代码的其余部分保持不变:
builder.AddProject<Projects.MyExampleProject>()
.WithReference(db);
当应用程序在生产或预发布环境中部署时,所有连接字符串和辅助资源数据将发生什么?
如果部署是手动的,App Host 插入的相同环境变量必须在目标协调器的配置中定义。例如,如果目标协调器是 Kubernetes,则必须在 Deployment 的 env 部分中定义。正如我们将在 部署 .NET Aspire 项目 部分中更详细地看到,当我们使用自动工具配置目标协调器时,有两种可能性:
-
如果自动工具能够配置所需的资源,它也将自动配置所有环境变量,从创建的资源中获取所有必要的信息。
-
如果自动工具没有生成所需资源,而只是生成配置所有微服务的代码,它将要求用户输入环境变量值
下一个子节详细说明了如何在开发期间以及应用部署时处理遥测。
应用遥测
通过连接不同微服务中发生的适当相关事件,遥测使整个微服务应用程序的监控成为可能。更具体地说,它收集以下数据:
-
日志记录:所有微服务和资源的单个日志被收集并按其生成时间和来源进行分类。
-
跟踪:跟踪关联了属于同一逻辑活动的日志事件(例如,处理单个请求),即使它们分布在多个机器或进程上。跟踪是诊断和调试故障的起点。
-
指标:每个正在执行的微服务收集各种微服务指标,并将这些指标发送到收集点。
当应用在开发环境中运行并使用 App Host 作为编排器时,每个微服务的遥测通过在 .NET Aspire Service Defaults 项目中配置的 ConfigureOpenTelemetry() 调用启用。此调用启用指标的收集以及将这些指标与微服务日志一起传输到实现 OpenTelemetry Protocol (OTLP) 的 OpenTelemetry 端点。
在开发期间,当解决方案运行时打开的 Aspire 控制台作为 OpenTelemetry 端点工作,与该端点连接的数据由 App Host 注入为所有微服务的环境变量。因此,我们可以在该控制台中看到的所有数据都来自遥测。
当应用部署时,相同的环境变量必须包含部署环境中可用的 OpenTelemetry 端点的数据。Azure 支持 OTLP,因此,例如,如果应用部署到 Azure Kubernetes,我们必须传递与 Azure Kubernetes 集群一起创建的遥测端点的数据。也可以将 OpenTelemetry 数据传递到 Grafana 等工具,这在 第九章 的 Kubernetes 管理工具 子节中已有描述,即 简化容器和 Kubernetes:Azure 容器应用 以及其他工具。
由 App Host 自动注入到每个微服务中的环境变量,我们必须在部署环境中手动注入的变量如下:
-
OTEL_EXPORTER_OTLP_ENDPOINT,其中包含 OTLP 端点的 URL。 -
OTEL_SERVICE_NAME,其中包含微服务必须添加到其发送的数据中的服务名称。您应使用在 App Host 配置中给定的与微服务相同的名称。 -
OTEL_RESOURCE_ATTRIBUTES,它包含一个唯一 ID,唯一地标识每个服务实例。它必须添加到所有数据中,并且必须具有以下格式:service.instance.id=<unique name>。通常,GUID 用作唯一服务名称。
一旦你明确了 Aspire 提供的服务,你需要学习如何配置 App Host。
配置微服务和资源
App Host 按照以下方式处理服务:
-
.NET 项目:这些可以通过以下方式配置
var myService = builder.AddProject<Projects.MyProjectName>("myservicename"); -
存储在某些注册表中的容器:这些可以通过以下方式配置
var myService = builder.AddContainer("myservicename", "ContainerNameOrUri"); -
可执行文件:这些可以通过以下方式配置
var myService = builder.AddExecutable("myservicename", "<shell command>", "<executable working directory>"); -
需要构建的 Dockerfile:这些可以通过以下方式配置
var myService = builder.AddDockerfile(`"myservicename ", "relative/context/path");`
其中 "relative/context/path" 是包含 Dockerfile 以及构建 Dockerfile 所需的所有文件的文件夹。此路径必须相对于包含 App Host 项目文件的目录。
前面的每个命令都可以跟随着几个配置选项,通过流畅的接口传递,如本例所示:
var cache = builder.AddProject<Projects……
var apiService = builder.AddProject<Projects……
builder.AddProject<Projects.MyAspireProject>("webfrontend")
.WithReference(cache)
.WaitFor(cache)
.WithReference(apiService)
.WaitFor(apiService);
WithReference 声明服务与作为参数传递的资源或服务进行通信。它会导致注入所有包含服务发现所需数据、连接字符串或其他辅助资源信息的环境变量。
WaitFor 声明微服务必须在作为参数传递的服务或资源运行之后启动。
WithReplicas(int n) 是流畅接口配置的重要方法之一。它声明微服务必须复制 n 次。如果我们计划使用自动工具将 App Host 配置编译成 Kubernetes 或 Azure Container Apps 配置代码,那么这一点很重要。
不幸的是,在开发模式下,我们开发机的有限性能通常不允许我们像在生产环境中那样需要相同数量的副本。因此,在这些情况下,我们应该执行不同的配置指令。
当我们在开发机上运行应用程序以及使用 App Host 配置为其他平台生成代码时,都会执行 App Host 配置。在后一种情况下,我们说我们处于发布模式而不是运行模式。幸运的是,builder 对象在 builder.ExecutionContext 属性中包含有关执行环境的信息。特别是,我们可以使用 builder.ExecutionContext.IsPublishMode 和 builder.ExecutionContext.IsRunMode 属性来区分运行模式下的配置和发布模式下的配置。
如前所述,在 服务发现及其在 .NET Aspire 中的作用 子节中,我们还可以使用 WithEndpoint 流式接口方法来声明在其他端口上可用的辅助端点:
var routesPlanning = builder.AddProject<Projects.RoutesPlanningService>("routes_planning ")
.WithEndpoint(hostPort: 9999, name: "aux");
WithEndpoint 可以替换为 WithHttpsEndpoint 和 WithHttpEndpoint,分别声明仅 HTTPS 和仅 HTTP 端点。
WithExternalHttpEndpoints() 流式接口方法声明微服务端点必须在应用程序外部对应用程序客户端可用。当在 Kubernetes 上发布应用程序时,这些端点将通过 Ingress 或 LoadBalancer 服务公开,而在 Azure Container Apps 上发布应用程序时,将通过外部入口公开。
微服务使用的资源可以使用相同的流式接口进行声明和配置。每种资源类型都需要一个专门的 NuGet 包,该包为流式接口提供所需的扩展方法。所有这些扩展方法都是基于 builder.AddContainer 方法构建的,因为它们使用 Docker 镜像来实现资源。因此,如果所需的资源尚未可用,我们可以自己编写所需的扩展方法。然而,如前所述,所有主要数据库、Redis、所有主要消息代理以及大多数 Azure 服务都有资源。一些 Azure 资源配置器提供并使用实际的 Azure 资源,而另一些则使用本地模拟器。Azure 存储和 Azure Service Bus 都有模拟器。
请参阅官方文档以获取所有可用资源集成的列表:learn.microsoft.com/en-us/dotnet/aspire/fundamentals/integrations-overview。
默认情况下,当 App Host 关闭时,由于所有 Docker 镜像都使用临时存储,所有数据库数据都会丢失。然而,我们可以使用 WithDataVolume() 流式接口方法强制使用永久的 Docker 卷存储:
var sql = builder.AddSqlServer("sql")
.WithDataVolume();
var db = sql.AddDatabase("database");
当调用此方法时,会创建一个具有自动生成名称的 Docker 卷。为了对卷名称和容器内挂载的目录有更多控制,可以使用 WithBindMount:
var sql = builder.AddSqlServer("sql")
.WithBindMount("MyVolumeName", "/var/opt/mssql");
var db = sql.AddDatabase("database");
大多数资源使用默认用户名,例如 sa,以及自动生成的密码。这两个凭据都可通过 App Host 浏览器控制台的资源信息链接获取。然而,如果数据没有与卷持久化,这个密码可能会在每次运行时更改。
幸运的是,所有资源都提供了指定一些参数的可能性,而 username 和 password 总是其中之一。
无需多言,参数不会直接在代码中插入,这是显而易见的原因。它们来自 App Host 配置的“参数”部分。因此,它们可以插入到 App Host 配置文件中,这样我们也可以通过使用传统的 .NET 环境基于配置文件覆盖来为每个环境提供不同的值。
第一步是定义一个参数对象,其名称为 "Parameters" 属性,包含实际值:
var password = builder.AddParameter("sqlpassword", secret: true);
通过将 secret 设置为 true,我们在发布模式下运行 Aspire 时启用生成提示以将参数存储在安全位置。
然后,参数被放置在资源扩展方法的正确位置,这是特定于资源的:
var sql = builder.AddSqlServer("sql", password)
.WithBindMount("MyVolumeName", "/var/opt/mssql");
var db = sql.AddDatabase("database");
实际值必须放置在 App Host 项目配置文件中,如下所示:
{
"Parameters": {
"sqlpassword": "my_password_value",
…
},
…
}
下一个子节描述了如何在 .NET Aspire 解决方案中集成 Azure Functions 项目。
Azure Functions 集成
在撰写本书时,.NET Aspire 解决方案中 Azure Functions 项目的集成处于预览阶段。然而,我们将简要描述它,因为它提供了巨大的机会。
目前,仅支持以下触发器的 Azure Functions:Azure Event Hubs、Azure Service Bus、Azure Blob 存储、Azure Queue 存储、Azure CosmosDB、HTTP 和 Timer。
为了配置 Azure Functions 项目,App Host 必须引用 Aspire.Hosting.Azure.Functions NuGet 包。一旦添加了此引用,就可以配置 Azure Functions 项目,如下所示:
var myFunction = builder.AddAzureFunctionsProject<Projects.MyFunctionsProject>(
" MyFunction ");
AddAzureFunctionsProject 调用可以与其他所有项目类型的常规配置方法链式调用,例如 WithExternalHttpEndpoints()。
以这种方式定义后,myFunction 可以通过常规方法被其他项目引用:
builder.AddProject<Projects.MyOtherProject>()
.WithReference(myFunction)
.WaitFor(myFunction);
可以按如下方式添加 Azure 存储账户的本地模拟器:
var storage = builder.AddAzureStorage("storage")
.RunAsEmulator();
var myFunction = builder.AddAzureFunctionsProject<Projects.MyFunctionsProject>(
" MyFunction ")
.WithHostStorage(storage)
模拟器依赖于 Aspire.Hosting.Azure.Storage NuGet 包,必须将其添加到 App Host 项目中。
可以使用 WithReference 添加对其他 Azure 资源的引用,就像往常一样。例如,一个具有在模拟 blob 上的 Blob 存储触发器的 Azure 函数可以定义如下:
var storage = builder.AddAzureStorage("storage")
.RunAsEmulator();
var blob = storage.AddBlobs("blob");
var myFunction = builder.AddAzureFunctionsProject<Projects.MyFunctionsProject>(
" MyFunction ")
.WithHostStorage(storage)
.WithReference(blob);
这就结束了我们对 .NET Aspire 的描述。在下一节中,我们将看到如何将 第八章 的 Kubernetes 示例,使用 Kubernetes 的实用微服务组织,转换为与 Aspire 一起运行。最后,部署 .NET Aspire 项目 节将讨论如何使用 Aspire 为我们的目标编排器生成代码,无论是手动还是使用自动代码生成工具。
在实践中使用 .NET Aspire
在本节中,我们将将 第八章 的 Kubernetes 示例,使用 Kubernetes 的实用微服务组织,适配为与 Aspire 一起运行。作为第一步,让我们将整个解决方案文件夹复制到另一个不同位置,这样我们就可以修改它而不会破坏之前的版本。
然后,让我们执行以下步骤来准备整体解决方案:
-
向解决方案添加一个新的 App Host 项目,并将其命名为
CarSharingAppHost。 -
向解决方案添加一个新的 .NET Aspire Service Defaults 项目,并将其命名为
CarSharingServiceDefaults。 -
将
FakeSource、FakeDestination和RoutesPlanning项目添加到CarSharingAppHost项目中。 -
将
CarSharingServiceDefaults项目添加到FakeSource、FakeDestination和RoutesPlanning项目中。 -
右键单击
CarSharingAppHost项目,然后在出现的菜单中选择设置为启动项目。
上述步骤为.NET Aspire 解决方案做好了准备。现在,让我们开始修改代码。作为第一步,我们必须向所有微服务添加服务默认值。因此,让我们将builder.AddServiceDefaults();添加到FakeSource、FakeDestination和RoutesPlanning项目的program.cs文件中。然后,我们必须添加app.MapDefaultEndpoints(),它仅将健康端点添加到RoutesPlanning项目的program.cs文件中,因为它是我们微服务中唯一的 Web 项目。它必须放置如下所示:
var app = builder.Build();
**app.****MapDefaultEndpoints****();**
现在,让我们记住,我们已经将所有微服务参数作为环境变量添加到它们的Properties/launchSettings.json文件中。我们将它们放置在 Docker 启动设置中。现在,由于这些项目在 Aspire 中运行时将不再使用 Docker,我们必须将这些定义复制到其他启动设置配置文件中。
这是更改后的RoutesPlanning项目的启动设置代码:
{
"profiles": {
"http": {
"commandName": "Project",
"environmentVariables": {
//place here your environment variables
"ConnectionStrings__DefaultConnection": "Server=localhost;
Database=RoutesPlanning;User Id=sa;Password=Passw0rd_;
Trust Server Certificate=True;MultipleActiveResultSets=true",
"ConnectionStrings__RabbitMQConnection": "host=localhost:5672;
username=guest;password=_myguest;
publisherConfirms=true;timeout=10”,
"Messages__SubscriptionIdPrefix": "routesPlanning",
"Topology__MaxDistanceKm": "50",
"Topology__MaxMatches": "5",
"Timing__HousekeepingIntervalHours": "48",
"Timing__HousekeepingDelayDays": "10",
"Timing__OutputEmptyDelayMS": "500",
"Timing__OutputBatchCount": "10",
"Timing__OutputRequeueDelayMin": "5",
"Timing__OutputCircuitBreakMin": "4"
},
"dotnetRunMessages": true,
"applicationUrl": "http://localhost:5212"
},
"Container (Dockerfile)": {
…
…
我们将所有连接字符串中的host.docker.internal替换为localhost,因为在 Aspire 中运行时,我们的微服务将不会从 Docker 容器镜像内部访问 SQL 数据库和 RabbitMQ 消息代理,而是直接从开发机器访问。
同样,FakeSource的启动设置变为以下内容:
{
"profiles": {
"FakeSource": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development",
"ConnectionStrings__RabbitMQConnection":
"host=localhost:5672;username=guest;
password=_myguest;publisherConfirms=true;timeout=10"
},
"dotnetRunMessages": true
},
"Container (Dockerfile)": {
"commandName": "Docker",
"environmentVariables": {
"ConnectionStrings__RabbitMQConnection":
“host=host.docker.internal:5672;
username=guest;password=_myguest;
publisherConfirms=true;timeout=10”
}
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}
最后,FakeDestination的启动设置变为以下内容:
{
"profiles": {
"FakeDestination": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development",
"ConnectionStrings__RabbitMQConnection":
"host=localhost:5672;username=guest;
password=_myguest;publisherConfirms=true;timeout=10"
},
"dotnetRunMessages": true
},
"Container (Dockerfile)": {
"commandName": "Docker",
"environmentVariables": {
"ConnectionStrings__RabbitMQConnection":
“host=host.docker.internal:5672;
username=guest;password=_myguest;
publisherConfirms=true;timeout=10"
}
}
},
"$schema": "https://json.schemastore.org/launchsettings.json"
}
RabbitMQ 和 SQL Server 连接字符串的内容显示,我们决定使用运行在 Aspire 之外的现有 RabbitMQ 和 SQL 实例。这是此解决方案中最简单的选择,因为整个代码已经组织成这种方式运行。然而,当我们从头开始构建解决方案时,通常这也是最佳选择,因为当 App Host 未运行时存在的实例在开发期间更容易处理。
事实上,当我们处理迁移时,我们可以将数据库迁移传递给数据库,而无需启动 App Host。同样,当 App Host 未运行时,我们可以从其浏览器控制台检查 RabbitMQ。
另一个替代方案是将整个 App Host 配置代码分成两个代码区域。第一个代码区域包含我们在应用程序未运行时需要操作的数据库和消息代理,第二个代码区域包含所有其他资源和微服务配置。
当我们需要操作第一代码区定义的资源时,我们注释掉整个第二代码区代码并运行应用主机。完成与迁移的工作和检查 RabbitMQ 队列后,我们取消注释定义和配置所有其他资源和微服务的第二代码区,并运行整个应用程序。
可以通过定义一个布尔型应用主机环境变量来选择第二个配置区,并通过if语句来细化前面的方法。
在这个前提之后,我们可以在应用主机的program.cs文件中编写我们的配置代码。
由于在我们的案例中,每个微服务都有多个启动设置配置文件,我们必须在AddProject流畅接口方法中指定每个微服务要使用的正确配置文件。
此外,由于FakeSource向RoutesPlanning微服务发送数据,而RoutesPlanning微服务向FakeDestination服务发送数据,我们必须确保RoutesPlanning在FakeDestination启动后启动,并且FakeSource只在RoutesPlanning启动后启动。我们不需要WithReference,因为并非所有微服务都是直接通信,而是通过 RabbitMQ 实例进行通信,而WithReference仅用于注入与资源直接通信的信息。我们也不需要声明对 RabbitMQ 的引用,因为我们使用的是运行在应用主机之外的外部 RabbitMQ 实例,所以我们已经有了其连接字符串。
使用以下配置代码很容易满足所有约束:
var builder = DistributedApplication.CreateBuilder(args);
var fakeDestination=builder.AddProject<Projects.FakeDestination>("fakedestination",
"FakeDestination");
var routesPlanning = builder.AddProject<Projects.RoutesPlanning>("routesplanning", "http")
.WaitFor(fakeDestination);
builder.AddProject<Projects.FakeSource>("fakesource", "FakeSource")
.WaitFor(routesPlanning);
builder.Build().Run();
这里,每个AddProject调用的第二个参数是用于每个微服务的启动配置文件名称。
让我们确保 RabbitMQ 和 SQL Server 外部 Docker 容器都在运行,然后启动我们的解决方案。
如果一切运行正常,你应该在 Aspire 浏览器控制台中看到如下所示的图:

图 12.2:应用主机资源列表
让我们点击左侧菜单中的控制台图标来检查所有微服务日志。让我们选择fakedestination;你应该看到如下所示的图:

图 12.3:伪造的目标控制台
日志应包含通过 EasyNetQ 与 RabbitMQ 的连接信息以及工作服务启动信息。最后,你应该看到来自RoutesPlanning微服务的两条消息,声明找到了两个匹配项。
由于所有微服务都使用相同的 RabbitMQ 连接字符串,我们可以通过从每个微服务的启动设置中移除它,并借助AddConnectionString将其提取到应用主机的配置中,从而改进整个代码组织,如下所示:
builder.AddConnectionString("RabbitMQParameterName", "RabbitMQConnection");
这里,RabbitMQParameterName是包含实际连接字符串的应用主机配置参数名称:
{
"Parameters": {
"RabbitMQParameterName": "host=localhost:5672;username=guest;
password=_myguest;
publisherConfirms=true;timeout=10"
}
}
在下一小节中,我们将描述如何修改代码以在 App Host 内运行 RabbitMQ。
RabbitMQ 集成
RabbitMQ 由 Aspire 集成支持,因此我们也可以在 App Host 内运行它。为此,第一步是添加 Aspire.Hosting.RabbitMQ NuGet 包。
然后,我们需要配置 RabbitMQ 实例:
var username = builder.AddParameter("rabbitmqusername", secret: true);
var password = builder.AddParameter("rabbitmqpassword", secret: true);
var rabbitmq = builder.AddRabbitMQ("RabbitMQConnection", username, password)
.WithManagementPlugin()
.WithDataVolume(isReadOnly: false);
在这里,我们在 App Host 关闭后添加了一个卷以持久化数据,并要求安装浏览器管理控制台,这样我们就可以检查所有队列,也可以配置实例。实际的用户名和密码必须在 App Host 配置文件的 "Parameters" 部分提供:
{
"Parameters": {
"rabbitmqusername": "<username>",
"rabbitmqpassword": "<password>"
}
}
之后,我们必须在所有微服务中使用 WithReference(rabbitmq) 声明对 RabbitMQ 实例的引用。
到这一点,我们需要从我们微服务的所有启动设置中删除 RabbitMQ 连接字符串,因为相同的连接字符串现在将由 App Host 注入。
不幸的是,注入的连接字符串的格式不符合 EasyNetQ 所需的格式,而是以下格式:
`amqp://username:password@<host url>:5672`.
解决这个问题的最简单方法是为这个字符串编写一个字符串操作方法,将其转换为字符串并添加所有其他辅助信息。我们可以在 Service Defaults 项目中定义此方法,这样它将对所有微服务可用。
我们只需要提取 URL、用户名和密码,然后我们可以使用它们来构建 EasyNetQ 所需格式的连接字符串。这可以通过在 // 上拆分字符串,然后在 @ 上拆分,最后在 : 上拆分以获取用户名和密码来完成。
在最后一节中,我们将描述如何获取 Aspire 项目所需的目标编排器的配置。
部署 .NET Aspire 项目
.NET Aspire 可以用于在开发机器上测试应用程序或复杂微服务应用程序的小部分,从而取代 minikube 和 Docker 网络。
然而,小型应用程序可以完全在 Aspire 中实现,然后可以使用 Aspire 代码生成目标编排器的配置。这种生成可以是手动的,也可以基于自动工具。
手动生成和自动工具都依赖于一个 JSON 清单,该清单可以自动创建,并描述应用程序配置。可以通过向 App Host 项目的 launchSettings.json 文件添加以下启动配置文件来生成 JSON 清单:
"profiles": {
"generate-manifest": {
"commandName": "Project",
"launchBrowser": false,
"dotnetRunMessages": true,
"commandLineArgs": "--publisher manifest --output-path aspire-
manifest.json"
}
…
一旦添加到 launchSettings.json,此配置文件就会出现在 Visual Studio 配置文件选择组合框中,位于 运行 按钮旁边。只需选择 "generate-manifest" 配置文件并运行解决方案。当解决方案运行时,应用程序会被编译,但不会运行,而是在 App Host 项目的文件夹中创建 JSON 清单。
您可以手动读取此清单并使用其中包含的信息来配置您的编排器,或者您可以使用自动工具生成清单,并使用它来自动配置编排器。
Visual Studio 本地支持 Azure Container Apps 的部署。发布到 Azure Container Apps 非常简单。只需在解决方案的 App Host 项目上右键单击并选择发布。之后,你可以选择 Azure Container Apps 发布目标。该过程将引导你连接到你的 Azure 订阅并提供发布应用程序所需的所有信息,
发布向导将发布所有微服务作为 Azure Container Apps 应用程序,并将 App Host 中定义的所有其他资源(如数据库和其他 Azure 资源)在 Azure 中进行配置。
还有一个名为 Aspir8 的外部工具(prom3theu5.github.io/aspirational-manifests/getting-started.html),它能够将应用程序部署到 Kubernetes 集群。然而,在这种情况下,它只会创建 Kubernetes Deployments 和 Services。
安装完成后,Aspir8 支持以下命令:
-
aspirate init: 在当前目录中初始化 Aspir8 项目 -
aspirate generate: 根据 .NET Aspire 应用程序主机清单生成 Kubernetes 清单 -
aspirate apply: 将生成的 Kubernetes 清单应用到 Kubernetes 集群 -
aspirate destroy: 删除由apply命令创建的资源
对于简单的应用程序,你可以直接在 Kubernetes 集群上部署,而对于更复杂的应用程序,你可以使用 Kubernetes 清单作为设计所需 Kubernetes 配置的起点。
apply 和 destroy 命令需要一个 kubectl 安装,并且所有操作都是使用当前的 kubectl 上下文执行的。请参阅第八章中“与 Kubernetes 交互:kubectl、minikube 和 AKS”部分,了解 kubectl 上下文的定义。
如果你想要手动检查 App Host 生成的清单,请参阅其官方格式文档learn.microsoft.com/en-us/dotnet/aspire/deployment/manifest-format。
摘要
在本章中,我们描述了 .NET Aspire 提供的机会和服务。我们讨论了如何在 App Host 项目中配置由微服务和其它资源组成的复杂应用程序,并详细讨论了服务发现如何在一般情况和特定于 .NET Aspire 的情况下工作。
我们描述了环境变量包含所有微服务和资源之间交互所需的所有信息,这些变量由 App Host 自动注入到所有微服务中。
最后,我们讨论了 Aspire 如何借助遥测实现可观察性,以及如何使用 App Host 配置为目标编排器生成自动配置。
本章结束了我们在现代分布式计算概念和技术中的精彩旅程。我们希望您阅读这本书的乐趣与我们写作的乐趣一样。
问题
- Aspire 特有的 .NET SDK 项目是什么?
.NET Aspire Starter 项目,.NET Aspire Empty 项目,.NET Aspire 应用程序主机,.NET Aspire 服务默认值,以及各种 .NET Aspire 测试项目。
- 服务发现是 Aspire 特有的功能吗?
不,这是一个通用的 .NET 功能。
- 默认设置中包含多少个服务发现提供程序?
只有两个。
- 如何处理未使用应用程序主机定义但由多个微服务共享的现有资源?
AddConnectionString 的用法。
- Aspir8 也提供 Azure 资源吗?
不,目前它只提供 Kubernetes 资源。
WithReference流畅接口方法的目的是什么?
声明一个资源依赖于另一个资源,这意味着它需要该资源的信息,如 URL 和连接字符串。
进一步阅读
-
官方 Aspire 文档:
learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview -
所有可用的 Aspire 集成:
learn.microsoft.com/en-us/dotnet/aspire/fundamentals/integrations-overview -
应用程序主机配置清单格式:
learn.microsoft.com/en-us/dotnet/aspire/deployment/manifest-format -
Aspir8:
prom3theu5.github.io/aspirational-manifests/getting-started.html
加入我们的 Discord 社区
加入我们社区的 Discord 空间,与作者和其他读者进行讨论:


订阅我们的在线数字图书馆,全面访问超过 7,000 本书和视频,以及领先的行业工具,帮助您规划个人发展并推进您的职业生涯。更多信息,请访问我们的网站。
为什么订阅?
-
使用来自 4,000 多位行业专业人士的实用电子书和视频,花更少的时间学习,更多的时间编码。
-
通过为您量身定制的技能计划提高您的学习效果
-
每月免费获得一本电子书或视频
-
完全可搜索,方便快速访问关键信息
-
复制粘贴,打印和收藏内容
在 www.packtpub.com,您还可以阅读一系列免费的技术文章,订阅各种免费通讯,并享受 Packt 书籍和电子书的独家折扣和优惠。
您可能还喜欢的其他书籍
如果你喜欢这本书,你可能对 Packt 出版的以下其他书籍感兴趣:
C# 13 和 .NET 9 – 现代跨平台开发基础
Mark J. Price
ISBN: 978-1-83588-123-1
-
发现 .NET 9 的新功能,包括更灵活的 params 和新的 LINQ 功能,如 CountBy 和 Index
-
利用新的 ASP.NET Core 9 功能以优化静态资产、OpenAPI 文档生成和 HybridCache
-
利用原生的 AOT 发布功能以实现更快的启动速度和更小的内存占用
-
使用 Blazor 在 ASP.NET Core 9 中构建丰富的网络用户界面体验
-
使用 Entity Framework Core 9 模型在应用程序中集成和更新数据库
-
使用 LINQ 查询和操作数据
-
使用 Minimal APIs 构建强大的服务
C# 12 和 .NET 8 软件架构
Gabriel Baptista, Francesco Abbruzzese
ISBN: 978-1-80512-245-6
-
编程和维护 Azure DevOps 并探索 GitHub 项目
-
管理软件需求以设计功能和非功能需求
-
应用分层架构和领域驱动设计等架构方法
-
在基于云的数据存储解决方案之间做出有效的选择
-
实现健壮的前端微服务、工作微服务和分布式事务
-
理解何时使用测试驱动开发 (TDD) 和其他替代方法
-
从 IaaS 到无服务器选择最佳的云开发选项
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问 authors.packtpub.com 并今天申请。我们已与成千上万的开发者和技术专业人士合作,就像你一样,帮助他们将见解分享给全球技术社区。你可以提交一般申请,申请我们正在招募作者的特定热门话题,或者提交你自己的想法。
分享你的想法
一旦你阅读了 Practical Serverless and Microservices with C#,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。
你的评论对我们和整个技术社区都很重要,并将帮助我们确保我们提供高质量的内容。


浙公网安备 33010602011771号