C--和-Azure-微服务实践指南-全-

C# 和 Azure 微服务实践指南(全)

原文:zh.annas-archive.org/md5/5e12a3b1c58d7c6044d0681b53a30c48

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

.NET Aspire 是一个提供工具和库的新框架,用于使用 .NET 创建微服务,无论它们是否应在本地、Microsoft Azure 或任何其他云环境中运行。在本书中,你将学习如何在构建解决方案时充分利用 .NET Aspire。

创建 ASP.NET Core 最小 API(创建 REST 服务的一个简单快捷的选项)只是使用基于微服务架构创建应用程序的一部分。本书涵盖了构建成功解决方案所需的所有不同方面。访问数据库,包括关系型数据库和 NoSQL 数据库;使用 Docker 和部署 Docker 镜像;使用 GitHub Actions 进行自动部署;通过日志、指标数据和分布式跟踪来监控解决方案;创建单元测试、集成测试和负载测试;自动将解决方案发布到不同的环境;以及使用二进制、实时和异步通信——本书都涵盖了这些内容。

通过本书提供的代码,你将开发一个运行酷游戏的后端解决方案。从 第二章 开始,你将拥有可用和可测试的功能,并且将逐章增强,涵盖与微服务相关的重要方面。如果你不想按顺序逐章工作,我们为每一章都提供了可以开始的代码。

应用程序可以部署到 Microsoft Azure,并使用多个 Azure 服务,如 Azure 容器应用、容器注册库、Cosmos DB、应用配置、密钥保管库、Redis 和 SignalR 服务。它也可以在本地环境中运行在 Kubernetes 集群上,使用 Kafka、Redis 和其他资源。

到本书结束时,你将能够自信地实现一个稳定、性能良好且可扩展的解决方案,并使用各种非常适合托管此类基于服务的解决方案的 Azure 服务。虽然本书的解决方案是一个游戏,但学到的知识将帮助你创建任何与业务相关的服务架构。

本书面向的对象

本书面向熟悉 C# 和 .NET、对 Microsoft Azure 有基本了解,并希望了解创建现代 .NET 和 Microsoft Azure 微服务所需所有方面的开发人员和软件架构师。

本书涵盖的内容

第一章.NET Aspire 和微服务简介,为你介绍了 .NET Aspire,以及创建微服务时可能非常有用的工具和库。你将开始一个初始的 .NET Aspire 项目,我们将查看它包含的内容以及你可以利用的 .NET Aspire 的部分。你将看到 Codebreaker 应用程序(这是我们将在本书的所有章节中构建的解决方案)由哪些服务组成,并了解与 Microsoft Azure 一起使用的服务。

第二章最小 API – 创建 REST 服务,是创建 Codebreaker 应用程序的起点。你将学习如何使用ASP.NET Core 最小 API技术高效地创建 REST 服务,使用 OpenAPI 描述服务,以及使用 HTTP 文件测试服务。

第三章将数据写入关系型和非关系型数据库,仅使用第二章中的内存存储,并添加使用Azure SQLAzure Cosmos DB的数据库存储,比较关系型数据库和非关系型数据库,并使用EF Core处理两种变体。

第四章为客户端应用程序创建库,添加了客户端库以访问服务,其中一个变体使用 HTTP 客户端工厂,另一个变体则利用Kiota自动生成客户端代码。

第五章微服务的容器化,深入探讨了Docker的所有重要概念以及如何从迄今为止创建的服务中创建 Docker 镜像。在使用.NET CLI 创建 Docker 镜像之前,你将学习 Docker 的概念。将创建一个.NET 原生 AOT版本的服务,允许在不使用.NET 运行时的情况下仅使用原生代码创建 Docker 镜像。

第六章Microsoft Azure 托管应用程序,由于上一章已创建 Docker 镜像,现在将介绍如何将应用程序发布到Azure Container Apps环境。在此之前,将介绍 Azure 的重要概念。然后,将使用 Azure Developer CLI 和.NET Aspire 创建 Azure 资源。

第七章灵活的配置,深入探讨了.NET 配置。你将了解.NET 中的配置提供程序,学习配置如何应用于.NET Aspire 的应用模型,使用 Azure Container Apps 添加配置和秘密,并集成Azure App ConfigurationAzure Key Vault。为了便于访问而无需存储秘密,这里还涵盖了Azure 托管标识

第八章CI/CD – 使用 GitHub Actions 发布,基于持续集成和持续交付是微服务解决方案的重要方面的事实。本章介绍了如何使用GitHub actions自动构建和测试应用程序,以及如何自动更新在 Microsoft Azure 上运行的应用程序。为了支持现代部署模式,本章集成了 Azure App Configuration 中可用的功能标志。

第九章服务和客户端的认证和授权,涵盖了两种认证和授权应用程序和用户的方法:Codebreaker 解决方案的云版本集成Azure Active Directory B2C和本地解决方案的ASP.NET Core identities。为了避免在每次服务中处理认证,创建了一个使用YARP的网关。

第十章关于测试解决方案的所有内容,指出任何更改都不应破坏应用程序,错误应尽早检测。在本章中,您将了解创建单元测试、使用.NET Aspire 的集成测试(这使得测试变得简单得多),以及使用Playwright进行端到端测试。

第十一章日志记录和监控,深入探讨了 Codebreaker 应用程序中正在发生的事情。内存泄漏应尽早检测。在开发过程中,我们应该了解应用程序是如何进行通信的。本章涵盖了高效高性能的日志记录、编写自定义指标数据以及分布式跟踪——包括OpenTelemetry的覆盖范围以及它如何与.NET Aspire 集成。在本章中,我们使用PrometheusGrafana进行本地解决方案,以及Azure 应用程序洞察Azure 日志分析进行云解决方案。

第十二章服务扩展,深入探讨了服务扩展,这是使用微服务架构的重要原因之一。使用Azure 负载测试,我们对应用程序的主要服务创建大量负载,找出瓶颈,决定是进行垂直扩展还是水平扩展,并使用 Redis 添加缓存以提高性能。

第十三章使用 SignalR 进行实时消息传递,涵盖了使用SignalR实时通知客户端。使用 REST API,调用 SignalR 中心,传递关于完成游戏的实时信息,SignalR 中心将此信息传递给一组客户端。Azure SignalR 服务用于减少服务的负载。

第十四章二进制通信的 gRPC,通过将通信改为服务间的gRPC来提高性能。您将学习如何创建协议缓冲区定义,使用这个二进制平台无关的通信平台实现服务和客户端,以及如何将.NET 服务发现和.NET Aspire 与 gRPC 结合使用。

第十五章使用消息和事件进行异步通信,处理了请求发送后不需要立即得到答案的事实——消息队列和事件就派上用场。在这里,用于本地环境的Azure 消息队列Azure 事件中心Kafka被用于使用。

第十六章在本地和云端运行应用程序,讨论了在 Azure 上运行时生产环境所需的内容,生产环境和开发环境之间的区别,以及 Codebreaker 应用如何满足可扩展性、可靠性和安全性方面的要求。到本章为止,应用程序要么在本地开发系统上运行,要么在 Azure 容器应用环境中运行。在本章中,应用程序被部署到 Azure Kubernetes 服务,并且可以使用 Aspir8 工具以类似的方式部署到本地 Kubernetes 集群。

要充分利用本书

您需要了解 C# 和 .NET,并具备一些关于 Microsoft Azure 的基础知识。以下工具和应用程序需要安装:

工具 安装
Visual Studio 2022 (可选) visualstudio.microsoft.com/downloads/
Visual Studio Code code.visualstudio.com/download
Docker Desktop docs.docker.com/desktop/install/windows-install/docs.docker.com/desktop/install/mac-install/docs.docker.com/desktop/install/linux-install/
Microsoft Azure 订阅 azure.microsoft.com/en-us/free/
Azure CLI learn.microsoft.com/en-us/cli/azure/install-azure-cli
Azure 开发者 CLI learn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd
Azure Cosmos DB 模拟器 learn.microsoft.com/en-us/azure/cosmos-db/how-to-develop-emulator
.NET Aspire learn.microsoft.com/en-us/dotnet/aspire/fundamentals/setup-tooling

Visual Studio 2022 是本列表中唯一需要 Windows 的软件。所有其他工具都可以在 Windows、macOS 或 Linux 上使用。在除 Windows 以外的平台上,您可以使用 Visual Studio Code 或其他工具来处理 .NET 和 C#。

要使用 .NET Aspire 与 Visual Studio 配合,至少需要安装版本 17.10.0。您可以使用 Visual Studio 2022 社区版。

第六章开始,您需要一个 Microsoft Azure 订阅。您可以在 azure.microsoft.com/free 免费激活 Microsoft Azure,这将为您的 Azure 账户提供约 200 美元的信用额度,可用于前 30 天,以及在此之后可以免费使用的多项服务。如果您拥有 Visual Studio Professional 或 Enterprise 订阅,您每月还可以获得一定数量的 Azure 资源。您只需使用 Visual Studio 订阅激活即可:visualstudio.microsoft.com/subscriptions/

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

源代码使用 .NET 8。在 .NET 9 发布后,主分支中的源代码将使用 .NET 9,并在 readme 文件中描述了相应的更改。那时,.NET 8 的源代码将在 dotnet8 分支中可用。在代码更新到新版本期间,您可以检查包含更改的额外分支。

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

使用的约定

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

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个示例:“将下载的 WebStorm-10*.dmg 磁盘镜像文件挂载为系统中的另一个磁盘。”

代码块设置如下:

public static class LiveGamesEndpoints
{
  public static void MapLiveGamesEndpoints(this 
    IEndpointRouteBuilder routes, ILogger logger)
  {
    var group = routes.MapGroup("/live")

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddApplicationServices();

任何命令行输入或输出都如下所示:

dotnet new console -o LiveTestClient

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“点击启用实时跟踪复选框,然后点击以收集有关连接日志消息日志HTTP 请求日志的信息。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们欢迎读者提供反馈。

一般反馈:如果你对这本书的任何方面有疑问,请通过 customercare@packtpub.com 给我们发邮件,并在邮件主题中提及书名。对于你与源代码有关的问题和问题,你可以使用这本书的 GitHub 存储库的讨论论坛:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/discussions

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

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

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

分享你的想法

一旦你阅读了《使用 C#和 Azure 的实用微服务》,我们很乐意听听你的想法!请点击此处直接进入此书的亚马逊评论页面并分享你的反馈。

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

下载这本书的免费 PDF 副本

感谢你购买这本书!

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

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

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

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

优惠不会就此结束,你还可以获得独家折扣、时事通讯和每天收件箱中的优质免费内容。

按照以下简单步骤获取好处:

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

packt.link/free-ebook/9781835088296

  1. 提交你的购买证明

  2. 就这样!我们将直接将免费 PDF 和其他好处发送到你的电子邮件。

第一部分:使用.NET 创建微服务

第一部分介绍了微服务应用的基本功能。在深入开发 Codebreaker 应用之前,您将探索.NET Aspire——一种新的适用于服务构建的云就绪堆栈。本节涵盖了该技术的提供内容、基本功能、对 Microsoft Azure 的介绍以及 Codebreaker 应用组成组件的概述。随后,您将使用 ASP.NET Core 最小 API 进行编码,通过Entity Framework (EF) Core 编写与关系型和非关系型数据库的数据交互代码,利用 Azure Cosmos DB 和 SQL Server,并生成客户端库以访问 REST 服务。一种方法涉及利用 HTTP 客户端工厂,另一种则采用 Microsoft Kioata。

本节中的每一章都提供了一个功能应用,该应用随着后续章节的展开而发展,从而增强学习体验。

本部分包含以下章节:

  • 第一章.NET Aspire 和微服务简介

  • 第二章最小化 API – 创建 REST 服务

  • 第三章**,将数据写入关系型和非关系型数据库

  • 第四章**,为客户端应用程序创建库

第一章:.NET Aspire 和微服务简介

欢迎创建由微服务组成的解决方案。第一章提供了本书中将开发的微服务解决方案的基础。

在这里,您将了解.NET Aspire 为微服务提供的哪些功能。在这本书中,我们创建了Codebreaker解决方案。您将了解 Codebreaker 是什么以及它由哪些部分组成。在本章的最后部分,您将了解在创建应用程序的过程中使用了哪些 Azure 服务。

第一章奠定了基础。

在本章中,您将了解.NET Aspire 在创建微服务方面提供的优势,并且您将获得使用这项技术所需的基础知识,包括如何定义应用程序模型,这对开发和部署意味着什么,如何使用服务发现,以及如何在本地调试解决方案时部署 Azure 资源。

您将了解我们在本书中构建的应用程序概述,解决方案的部分以及不同的服务是如何连接的。

在本章中,您将了解以下内容:

  • 创建.NET Aspire 项目

  • Codebreaker 解决方案的部分

  • 使用 Microsoft Azure 与.NET Aspire

  • Codebreaker 解决方案使用的 Azure 服务

技术要求

在本章中,您需要.NET 8 和.NET Aspire 工作负载,无论是 Visual Studio 还是 Visual Studio Code,Docker Desktop,以及 Microsoft Azure 订阅。有关安装的信息在本章和源代码仓库的 readme 文件中解释。

本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch01文件夹中,您将看到本章的项目结果。您将看到以下文件夹:

  • Aspire:此文件夹包含使用.NET Aspire 模板创建的四个项目,用于运行包括一个服务和 Web 应用的.NET Aspire 项目

  • Azure:此文件夹包含与上一个文件夹相同的四个项目,通过使用 Azure 资源进行了增强

从.NET Aspire 开始

.NET Aspire 是一种新的.NET 技术,提供工具和库,帮助创建、调试和部署使用微服务的.NET 解决方案。在这本书的所有章节中,我们将利用.NET Aspire。

注意

在本章中,您将获得对.NET Aspire 如何工作的核心理解。在其他所有章节中,我们将使用.NET Aspire 并深入了解其细节。

您可以使用.NET 命令行界面 (CLI) 或使用 Visual Studio 2022 进行安装。.NET Aspire 的第一个版本基于.NET 8,因此至少需要.NET 8 才能使用.NET Aspire。

.NET Aspire 需要.NET 8,可以通过安装.NET 工作负载来安装:

dotnet workload install aspire

要查看已安装的工作负载和 .NET Aspire 的版本,请使用以下:

dotnet workload list

如果您使用 Visual Studio,请使用 Visual Studio 安装程序,并选择 .NET Aspire SDK 组件来安装 .NET Aspire。

.NET Aspire 应用程序设计为在容器中运行。在本地运行应用程序时,项目直接在系统上运行,无需 Docker 引擎。在部署解决方案时使用 Docker 容器。我们可以(并将)使用可用的 Docker 镜像作为应用程序的一部分。在这里,需要容器运行时才能在本地运行。在这本书中,我们使用最常用的容器运行时 – Docker Desktop。Docker Desktop 对个人使用和小公司免费。.NET Aspire 还支持使用 Podman 运行容器。

在安装 .NET Aspire 后,创建一个新的项目。

创建 .NET Aspire 项目

当 .NET Aspire 安装后,您可以使用以下方式创建一个包含 API 服务和 Blazor 客户端应用程序的新项目:

dotnet new aspire-starter -o AspireSample

使用此模板,将创建四个项目:

  • AspireSample.ApiService:此项目包含一个使用 ASP.NET Core 最小 API 的 REST 服务

  • AspireSample.Web:一个向 API 服务发送请求的 ASP.NET Core Blazor 应用程序

  • AspireSample.ServiceDefaults:一个库项目,包含解决方案中所有服务的共享初始化代码

  • AspireSample.AppHost:应用程序宿主项目定义了解决方案的应用程序模型以及所有资源是如何连接的

让我们接下来构建并启动解决方案。

.NET Aspire 仪表板

当您启动新创建的项目(AppHost 项目需要是启动项目)时,会打开一个控制台,显示 AppHost 的日志,并且浏览器会打开一个仪表板,显示项目的资源,如图 图 1**.1 所示。

图 1.1 – Aspire 仪表板

图 1.1 – Aspire 仪表板

使用 .NET Aspire 仪表板,您可以查看正在运行的资源(如图像中的 apiservicewebfrontend),资源的状态以及端点,并可以访问详细信息和管理日志。在左侧面板中,您可以访问日志、跟踪和指标数据。虽然仪表板通常不在生产环境中使用(我们有 PrometheusGrafanaAzure Application Insights 和其他环境),但在开发期间了解所有这些信息是非常有用的。服务中是否存在内存泄漏?服务之间的交互是如何发生的?瓶颈在哪里?您可以使用仪表板找到这些信息。这将在 第十一章 中详细讨论。

注意

由于 .NET Aspire 仪表板非常出色,它作为 Docker 镜像提供,也可以在生产环境中的小型场景中使用,但它具有开发环境之外的局限性。

当你点击 webfrontend 的链接时,应用程序会打开。如果你已经创建了 Blazor 应用程序,你将已经知道应用程序中的链接,如图 图 1.2 所示。

图 1.2 – webfrontend

图 1.2 – webfrontend

当你点击 webfrontend 时,它会向 apiservice 发送请求以获取随机天气信息。

应用程序正在运行,接下来让我们看看 .NET Aspire 生成的代码。

.NET Aspire 应用程序模型

要深入了解 .NET Aspire,你需要学习应用程序模型:

AspireSample.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
var apiService = builder.AddProject<Projects.AspireSample_ApiService>("apiservice");
// code removed for brevity

如果你习惯了 .NET 应用程序中的应用程序构建器模式和 Host 类来配置 DI 容器、应用程序配置和日志记录,你将看到一些相似之处。在这里,使用 CreateBuilder 方法创建 DistributedApplication 类以生成 IDistributedApplicationBuilder。返回的构建器用于定义解决方案所需的所有资源。使用生成的代码,通过 AddProject 方法映射了两个项目。这些项目通过泛型类型引用,例如 Projects.AspireSample_ApiService。此类型是通过将项目引用添加到 AspireSample.ApiService 项目中创建的。当你打开 AspireSample.AppHost.csproj 项目文件时,你可以看到这个引用。

使用 AddProject 添加项目类型很方便,但这不是必需的。你也可以传递一个字符串,指向项目所在的目录。

除了添加项目外,还可以添加可执行文件(AddExecutable)或 Docker 镜像(AddContainer)。

.NET Aspire 还提供了一大串预定义的资源,例如 RabbitMQ、Kafka、Redis 和 SQL Server,以及运行在 Microsoft Azure 中的资源,如 Azure Cosmos DB、Azure 密钥保管库和 Azure 事件中心。要将资源添加到应用程序模型中,需要在 NuGet 包前缀为 Aspire.Hosting,并且需要添加 Aspire.Hosting.Azure

注意

在本书中,许多新资源被添加到 Codebreaker 解决方案中。第三章 添加了 SQL Server 和 Azure Cosmos DB,第五章 添加了 Docker 容器,第七章 添加了 Azure 应用配置和 Azure 密钥保管库,第十一章 添加了 Azure 日志分析、Prometheus 和 Grafana,第十三章 添加了 Azure SignalR 服务,等等。

作为参数传递给 AddProject 方法的 "apiservice" 名称定义了资源的名称。我们将在 使用服务 发现 部分中使用此名称。

AddProject 返回一个 IResourceBuilder<ProjectResource> 类型的对象。IResourceBuilder 对象可用于在应用模型中连接多个资源。ProjectResource 类型继承自 Aspire.Hosting.ApplicationModel.Resource 基类,并实现了多个资源接口类型,例如 IResourceWithEnvironmentIResourceWithServiceDiscovery

让我们使用此资源对象连接另一个资源:

Aspire/AspireSample.AppHost/Program.cs

// code removed for brevity
builder.AddProject<Projects.AspireSample_Web>("webfrontend")
  .WithExternalHttpEndpoints()
  .WithReference(apiService);
builder.Build().Run();

从第一个 AddProject 方法返回的 apiService 变量使用 WithReference 方法引用第二个项目——一个网络前端。这允许访问网络前端以访问 API 服务。API 服务的 URL 被分配为网络前端的环境变量——这就是 IResourceWithServiceDiscovery 接口所用的。虽然 API 服务不需要外部访问(只有网络前端需要访问),但网络前端应该可以从外部访问。这就是为什么使用 WithExternalHttpEndpoints 方法与网络前端项目一起使用。此配置信息用于指定添加到资源作为代理的 Ingress 控制器的配置。

在查看 AppHost 引用的项目之前,让我们深入了解共享的 AspireSample.ServiceDefaults 项目。

通用配置的共享项目

AspireSample.ServiceDefaults 项目是一个库,包含通用配置,可以被所有资源项目使用:

Aspire/AspireSample.ServiceDefaults/Extensions.cs

public static class Extensions
{
public static IHostApplicationBuilder AddServiceDefaults(this 
    IHostApplicationBuilder builder)
  {
    builder.ConfigureOpenTelemetry();
    builder.AddDefaultHealthChecks();
    builder.Services.AddServiceDiscovery();
    builder.Services.ConfigureHttpClientDefaults(http =>
    {
      http.AddStandardResilienceHandler();
      http.AddServiceDiscovery();
    });
    return builder;
  }
  // code removed for brevity

此共享项目包含 AddServiceDefaults 扩展方法,该方法实现了资源应用的通用配置。通过此实现,将调用 ConfigureOpenTelemetry,这是由 Extensions 类定义的另一个扩展方法。这里实现了日志记录、指标和分布式跟踪的通用部分。这在本章的第十一章中有详细说明。AddDefaultHealthChecks 配置服务的健康检查,这可能包括用于 .NET Aspire 组件的健康检查。

AddServiceDiscovery使用了Microsoft.Extensions.ServiceDiscovery库,这个库也是从.NET Aspire 的第一个版本开始就有的,但也可以独立于.NET Aspire 使用。AddServiceDiscovery方法注册了默认的服务端点解析器。服务发现不仅可以通过 DI 容器进行配置,还可以通过配置 HTTP 客户端,使用ConfigureHttpClientDefaults方法的 lambda 参数进行配置。服务发现将在下一节中讨论。ConfigureHttpClientDefaultsMicrosoft.Extensions.Http库的一部分,即 HTTP 客户端工厂。从ServiceDefaults库引用的包是Microsoft.Extensions.Http.Resiliency。这个库是从.NET 8 开始出现的,为 Polly 库提供了扩展。在分布式应用程序中,调用有时会在短暂问题上失败。对这些资源的调用重试可能会在另一次调用时成功。这种功能内置在.NET Aspire 中,并在AddStandardResilienceHandler中提供了默认的弹性配置。

但现在,让我们进入服务发现。

使用服务发现

webfrontend需要了解apiservice的链接以获取天气信息。这个链接取决于解决方案运行的环境。在开发系统上本地运行应用程序时,我们使用不同端口号的 localhost 链接,并且根据解决方案运行的环境(例如,Azure Container App 环境、Kubernetes 等),需要不同的配置。

使用新的服务发现,可以为服务使用逻辑名称,这些名称使用不同的提供者进行解析。因此,相同的功能可以在不同的环境中工作。

Blazor 客户端应用程序配置HttpClient

Aspire/AspireSample.Web/Program.cs

builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
  client.BaseAddress = new("https+http://apiservice");
});
// code removed for brevity

apiservice的名称来自应用程序模型定义——传递给AddProject方法的名称。在冒号之前,可以指定模式,例如httphttps。使用+分隔模式允许使用多个模式,并且首选第一个。

之前添加到 DI 容器的AddServiceDiscovery方法默认添加了一个基于配置的端点解析器。使用它,可以将配置添加到 JSON 配置文件中,例如如下所示:

{
  "Services": {
    "apiservice": {
      "https": [
        "localhost:8087",
        "10.466.24.90:80"
      ]
    }
  }
}

配置后,该部分需要命名为Services。在Services部分中,查找命名服务(apiservice),然后在该处解析模式名称(https)下的值。端口号是随机生成的,并且会根据您的环境而有所不同。

在 AppHost 中,由于 apiservice 被前端网页引用,API 服务的 URI 被添加为环境变量。打开 .NET Aspire 仪表板,在 webfrontend 中,你可以看到 services__apiservice_http__0services__apiservice_https_0 环境变量,以及 http://localhost:5395https://localhost:7313 的值。URI 在 Properties/launchsettings.json 中指定:

Aspire/AspireSample.ApiService/Properties/launchSettings.json

"profiles": {
  "http": {
    "commandName": "Project",
    "dotnetRunMessages": true,
    "launchBrowser": true,
    "launchUrl": "weatherforecast",
    "applicationUrl": "http://localhost:5395",
    "environmentVariables": {
      "ASPNETCORE_ENVIRONMENT": "Development"
    }
  },
  "https": {
    "commandName": "Project",
    "dotnetRunMessages": true,
    "launchBrowser": true,
    "launchUrl": "weatherforecast",
    "applicationUrl": "https://localhost:7313;http://localhost:5395",
    "environmentVariables": {
      "ASPNETCORE_ENVIRONMENT": "Development"
    }
  }
}

applicationUrl 设置定义了应用程序启动时使用的 URL,这是用于将其添加到环境变量的链接。因为环境变量是 .NET 配置的一部分,所以这些值由服务发现配置提供者检索。

Azure 容器应用和 Kubernetes 提供了服务发现功能,而无需使用服务发现库。在这些应用程序部署后,使用 DnsEndPoint 配置了一个透传提供者。

在本地运行 .NET Aspire 解决方案时,webfrontendapiservice 进程使用随机端口。在这些进程之前自动添加了一个反向代理,并且反向代理可以通过配置的启动设置访问。

这允许通过应用程序模型更改副本的数量:

Aspire/AspireSample.AppHost/Program.cs

var apiService = builder.AddProject<Projects.AspireSample_ApiService>("apiservice")
  .WithReplicas(3);

在 AppHost 中,使用 WithReplicas(3) 通过三个随机端口启动三个服务实例,并且与反向代理中显示的相同端口号,如 图 1**.3 所示。

图 1.3 – 多个副本

图 1.3 – 多个副本

你可以看到三个带有不同后缀的 apiservice- 服务正在运行,以及三个具有相同端口号的进程,如端点所示。从启动设置中定义的端点是反向代理的端点。当你打开详细信息时,你可以看到每个服务都有不同的目标端口。反向代理充当负载均衡器,以选择其中一个副本。

注意

要使用 http 启动配置文件启动解决方案,你需要将 ASPIRE_ALLOW_UNSECURED_TRANSPORT 环境变量添加到 AppHost 项目的启动设置中,并将其设置为 true

这是从 .NET Aspire 的重要核心功能。然而,还有更多。

.NET Aspire 组件

.NET Aspire 组件使得在配置的应用程序中使用 Microsoft 和第三方功能和服务的变得容易。Azure Cosmos DB、Pomelo MySQL Entity Framework Core 和 SQL Server 是用于访问数据库的组件,而 RabbitMQ、Apache Kafka 和 Azure Service Bus 是用于消息传递的组件。有关组件的列表,请参阅 learn.microsoft.com/en-us/dotnet/aspire/fundamentals/components-overview

要使用组件,通常与 AppHost 一起使用,需要通过添加主机 NuGet 包来配置资源,例如,对于 Azure Cosmos DB EF Core 组件,您会添加Aspire.Hosting.Azure.CosmosDB包。然后,通过将Aspire.Microsoft.EntityFrameworkCore.Cosmos包添加到访问数据库的服务中(例如,API 服务),来使用该组件本身。

组件能提供什么?你知道技术用来开启日志度量数据名称是什么吗?Aspire 组件知道这一点,并且配置起来很容易。当 Azure Cosmos DB 资源添加到应用模型中,并被服务项目引用时,连接字符串被配置为环境变量(或存储在秘密存储中),并且可以被需要连接的项目访问。

在本书的许多章节中,我们将添加一些新组件,因此这里不再详细介绍。

创建应用模型清单

AppHost项目中定义的应用模型,我们可以创建一个描述资源的 JSON 清单文件。如果项目仍在运行,则需要停止项目以允许重新构建:

cd ApireSample.AppHost
dotnet run --publisher manifest --output-path aspire-manifest.json

以下代码片段显示了此清单文件的一部分:

Aspire/AspireSample.AppHost/aspire-manifest.json

"webfrontend": {
  "type": "project.v0",
  "path": "../AspireSample.Web/AspireSample.Web.csproj",
  "env": {
    "services__apiservice__http__0": "{apiservice.bindings.http.url}",
    "services__apiservice__https__0": "{apiservice.bindings.https.url}"
  },
  "bindings": {
    "https": {
      "scheme": "https",
      "protocol": "tcp",
      "transport": "http",
      "external": true
    }
  }
}

清单包含有关资源类型、环境变量、绑定等信息。使用应用模型,我们还可以指定使用 Azure 资源。现在,此清单文件可以被工具用于部署解决方案,例如,通过使用 Azure 开发者 CLI 将其部署到 Microsoft Azure)。创建 Azure 资源的内容在第六章中介绍,并在其他章节中继续介绍。

使用 Aspir8(一个开源项目,见github.com/prom3theu5/aspirational-manifests/),可以将解决方案部署到 Kubernetes 集群。这在第十六章中有使用。

应用模型可以根据不同的启动配置文件进行自定义。这样,可以创建不同的清单文件以部署到(例如,Azure 并使用特定的 Azure 资源,以及到本地 Kubernetes 集群)。

注意

在开发过程中启动和调试项目时,使用包含应用模型的 AppHost 项目。对于部署,使用应用模型的清单。当在生产环境中运行解决方案时,应用宿主不再起作用。

.NET Aspire 本书从第一章到最后一章都在使用。让我们看看我们正在构建的内容。

Codebreaker – 解决方案

代码破解解决方案是一个传统的游戏,用于解决一组颜色。使用一种游戏类型,玩家需要从六个不同颜色的列表中选择四个颜色(可以是重复的)。游戏服务随机选择正确的颜色。玩家每走一步,就会得到一个答案:对于每个正确且位置正确的颜色,会返回一个黑色标记。对于每个正确但位置错误的颜色,会返回一个白色标记。现在玩家最多有 12 次移动来找到正确的解决方案。图 1**.4 展示了使用 Blazor 客户端应用程序运行的游戏。

图 1.4 – Blazor 客户端应用程序

图 1.4 – Blazor 客户端应用程序

这种游戏玩法表明,在五步之后找到了解决方案。在这种情况下,正确的结果是黄色 – 黑色 – 红色 – 黑色。第一次选择是红色 – 绿色 – 蓝色 – 黄色,结果是两个白色标记。第五步选择了黄色 – 黑色 – 红色 – 黑色,并返回了四个黑色标记,这意味着这是正确的移动。

注意

创建客户端应用程序不是本书的内容(在第 第四章 中仅完成了一个简单的控制台应用程序,该程序访问 API)。然而,几个客户端应用程序的源代码可在 github.com/codebreakerapp 获取。

创建一个运行某些游戏规则的服务似乎是一个简单的任务,不需要微服务架构。然而,正如 图 1**.5图 1**.6 中的序列图所示,还有更多内容。

图 1.5 – 代码破解玩游戏序列

图 1.5 – 代码破解玩游戏序列

该解决方案需要多个服务。游戏 API 服务不仅被人类玩家使用的 UI 调用;一个可以接收到消息后触发的机器人服务,会自行玩多个游戏,并且游戏 API 服务将有关游戏和每一步的信息写入数据库。

图 1.6 – 代码破解游戏完成序列

图 1.6 – 代码破解游戏完成序列

游戏完成后,游戏 API 服务不仅将此信息写入数据库,还发送事件。这些事件被实时服务和排名服务接收。实时服务由实时客户端使用 ASP.NET Core SignalR 监控正在进行的游戏。排名服务将完成的游戏写入其自己的数据库,客户端可以使用它来获取每日、每周和每月的游戏排名。还使用运行 Microsoft YARP 的服务来验证用户并将请求转发到不同的服务。

代码破解解决方案利用了多个 Azure 服务,如以下所述。

使用 Microsoft Azure

要创建和运行本书中的代码,您还需要拥有一个 Azure 订阅。您可以在azure.microsoft.com/free免费激活 Microsoft Azure,这将为您的账户提供约 200 美元的 Azure 信用额度,这些额度在最初 30 天内可用,之后还可以免费使用一些服务。

许多开发者可能会错过的是,如果您拥有 Visual Studio Professional 或 Enterprise 订阅,您每个月还可以免费获得一定数量的 Azure 资源。您只需使用您的 Visual Studio 订阅激活即可:visualstudio.microsoft.com/subscriptions/

要创建和管理资源,我们使用 Azure Portal、Azure CLI 和 Azure Developer CLI。在 Windows 上,您可以使用以下方式安装它们:

winget install Microsoft.AzureCLI
winget install Microsoft.Azd

在 Mac 和 Linux 上安装这些工具,请查看learn.microsoft.com/en-us/cli/azure/install-azure-clilearn.microsoft.com/en-us/azure/developer/azure-developer-cli/install-azd

让我们来看看使用 Microsoft Azure 的资源。

Codebreaker 使用的 Azure 资源

要查看使用了哪些 Azure 资源,请查看图 1.7

图 1.7 – Codebreaker 的 Azure 资源

图 1.7 – Codebreaker 的 Azure 资源

解决方案运行的计算服务是 Azure Container App 环境。这是一个抽象 Kubernetes 集群的服务。机器人服务、游戏 API、实时服务、排名服务和使用 YARP 的网关都在 Azure Container Apps 中运行。机器人服务使用 Azure Storage 队列:当队列中有消息到达时,机器人服务被触发以玩一系列游戏。机器人服务也可以从所有客户端应用程序中使用 – 通过 YARP 实现的网关间接使用。游戏 API 服务将游戏写入 Azure Cosmos DB,并使用 Redis 集群缓存游戏。游戏完成后,游戏事件会被推送到 Azure Event Hub。实时服务和排名服务是 Event Hub 的订阅者。实时服务使用 ASP.NET Core SignalR,为了减少该服务的负载,使用了 Azure SignalR 服务。

常用的有 Azure App Configuration,用于应用程序配置值和功能管理,Azure Key Vault 用于存储机密,Azure Active Directory B2C 用于用户注册,以及 Log Analytics 和 Application Insights 用于监控应用程序。

注意

从一个小版本的 Codebreaker 开始,不需要很多 Azure 服务即可使用。为了实现灵活和可扩展的解决方案,该解决方案可能被全球访问,并且为了了解微服务的各个方面,所有这些服务都在使用中。在部署服务时,不要担心成本。只要您不创建巨大的负载(我们将在第十二章中这样做),成本就会非常小,并且在使用后删除资源时,您远远不会用完免费订阅中可用的$200。

从开发环境进行 Azure 配置

您的.NET Aspire 解决方案可以轻松集成到 Microsoft Azure 中,并在调试解决方案时部署资源。

通过本地调试解决方案,不需要将所有资源部署到 Azure。服务项目可以在测试时本地运行,无需部署。对于 Azure Cosmos DB,有一个 Docker 容器或本地安装的模拟器可用。并非所有资源都可行,例如 Azure Key Vault 或 Azure Application Insights。

要自动部署这些资源,.NET Aspire 需要访问您的订阅。为此,首先,使用 Azure CLI 登录到您的 Azure 订阅:

az login

这将打开浏览器,您可以使用您的 Azure 订阅登录。

如果您有多个订阅,请检查 Azure CLI 是否设置为当前订阅:

az account show

这显示了当前的活动订阅。如果应该使用不同的订阅,请使用az account list列出所有订阅,并使用az account set –subscription <your subscription id>将当前订阅设置为另一个订阅。记住与id一起列出的值——这是在下一步中需要的订阅 ID。

现在,我们需要将项目连接到订阅并指定一些设置。最好将这些信息放在用户密钥中;这些信息不应该放入源代码存储库中。

如果用户密钥尚未与AppHost配置,请初始化它:

cd AspireSample.AppHost
dotnet user-secrets init

我们需要的配置如下:

dotnet user-secrets set Azure:SubscriptionId <your subscription id>
dotnet user-secrets set Azure:AllowResourceGroupCreation true
dotnet user-secrets set Azure:ResourceGroup rg-firstsample
dotnet user-secrets set Azure:Location westeurope
dotnet user-secrets set Azure:CredentialSource AzureCli

使用SubscriptionId,您指定创建资源的订阅。您使用ResourceGroup的值指定的资源组用于创建所有需要的资源。如果将AllowResourceGroupCreation设置为true,则将创建资源组。否则,您需要首先创建资源组。使用Location设置,指定您首选的位置。要查看您的订阅可用的位置,请使用az account list-locations -``o table

CredentialSource设置设置为AzureCli表示你正在使用与 Azure CLI 相同的账户来创建资源。如果没有此设置,将使用DefaultAzureCredential,它尝试使用预定义列表中的多种账户类型,直到成功。这包括 Visual Studio、Azure CLI、PowerShell、Azure Developer CLI 和其他凭证。在这里,可能使用没有访问订阅的凭证。根据我的经验,最好明确提供凭证。

要查看所有秘密,请使用以下方法:

dotnet user-secrets list

注意

使用 Visual Studio,你可以通过使用解决方案资源管理器将项目连接到 Azure。在 AppHost 项目中,选择连接的服务,打开上下文菜单,并选择Azure 资源配置设置。这将打开一个对话框,用于选择订阅、位置和资源组。

接下来,让我们将Aspire.Hosting.Azure.KeyVault NuGet 包添加到 AppHost 项目中,并更新应用程序模型:

var builder = DistributedApplication.CreateBuilder(args);
var keyVault = builder.AddAzureKeyVault("secrets");
var apiService = builder.AddProject<Projects.AspireSample_ApiService>("apiservice")
  .WithReplicas(3)
  .WithReference(keyVault);

AddAzureKeyVault方法创建了一个名为secrets的密钥保管库。这个密钥保管库在apiservice项目中被引用。

当你现在启动 AppHost 时,密钥保管库将在 Azure 内部创建。打开portal.azure.com的 Azure 门户,你会看到资源组,在资源组内部创建了 Azure Key Vault。如果你再次检查用户密钥,将添加一个Azure:Deployments部分,其中包含到创建的资源链接。这些信息用于再次找到资源,并且它们在下次启动应用程序时不需要再次发布。

当你完成本章内容后,只需从门户中删除整个资源组,这样就不会产生额外的费用。

注意

要将所有资源(包括项目)发布到 Azure,你可以使用 Azure Developer CLI。这将在第六章中介绍。

摘要

在本章中,你学习了.NET Aspire 的核心功能,包括工具、编排和 Aspire 组件。你学习了资源是如何通过 Aspire 应用程序模型连接的,以及服务发现是如何进行的。你看到了如何创建一个描述应用程序模型的清单,该清单可以被工具用于部署解决方案。

通过 Codebreaker 解决方案,你了解了游戏的规则以及从第二章到最后一章创建的应用程序部分。

现在,你知道了 Codebreaker 解决方案在 Azure 中运行时使用的不同 Microsoft Azure 服务。还提供了一个替代方案,以便在本地环境中运行完整的解决方案(这样也可以在 Azure 云中托管)。

从下一章开始,我们将开始开发 Codebreaker 解决方案。在第二章中,我们将使用 ASP.NET Core 最小 API 创建 REST 服务来玩游戏。我们将使用 HTTP 文件测试此 API。

进一步阅读

要了解更多关于本章讨论的主题,您可以参考以下链接:

第二章:最小 API – 创建 REST 服务

自.NET 6 以来,最小 API 是创建 REST API 的新方法。随着.NET 版本的更新,越来越多的增强功能被提供,这使得它们成为使用.NET 创建 REST 服务的首选方式。

在本章中,你将学习如何使用模型类型创建游戏的数据表示,在服务中使用这些类型来实现游戏功能,创建一个最小 API 项目来创建游戏,通过设置游戏移动来更新游戏,并返回有关游戏的信息。

你将实现提供 OpenAPI 描述的功能,以便开发人员访问服务以获取有关服务的信息,并创建客户端应用程序的简单方法。

在本章中,你将探索以下主题:

  • 创建游戏模型

  • 实现内存游戏库

  • 使用最小 API 实现游戏的 REST 服务

  • 使用 OpenAPI 描述服务

  • 使用 HTTP 文件测试服务

  • 启用.NET Aspire

到本章结束时,你将拥有一个运行中的服务,该服务实现了 Codebreaker 游戏 API,并具有内存游戏存储,可以通过 HTTP 请求访问。

技术要求

本章的代码可以在以下 GitHub 存储库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azurech02源代码文件夹包含本章的代码示例。你将找到以下代码:

  • Codebreaker.GamesAPIs – Web API 项目

  • Codebreaker.GamesAPIs.Models – 数据模型库

  • Codebreaker.GameAPIs.Analyzers – 包含游戏移动分析器的库

  • Codebreaker.GamesAPIs.Analyzers.Tests – 游戏移动分析器的单元测试

  • Codebreaker.AppHost – .NET Aspire 的主项目

  • Codebreaker.ServiceDefaults – 由.NET Aspire 配置使用的库

注意

本章中不实现游戏移动分析器。Analyzers项目仅作参考,但你可以直接使用为你提供的分析器 NuGet 包(CNinnovation.Codebreaker.Analyzers)来构建服务。

对于 Visual Studio、Visual Studio Code 和.NET Aspire 的安装,请检查存储库中本章的 README 文件。

游戏模型

在创建 REST API 项目之前,我们从一个包含游戏及其移动的模型的库开始。此模型将包含 Codebreaker 游戏 API 服务解决方案的主要数据部分,该部分将用于读取和写入数据库(在第三章),同时该模型也作为游戏主要功能的实现。

简化版本中的主要类型如图 图 2.1 所示。Game 类实现了 IGame 接口。IGame 接口由 Analyzers 包使用。一个游戏包含一系列移动。单个游戏移动由 Move 类表示。

图 2.1 – 游戏模型

图 2.1 – 游戏模型

探索游戏分析器库

由于本书的重点不是使用 .NET 实现游戏规则,您可以使用现有的项目 Codebreaker.GameAPIs.Analyzers,或者直接引用已发布在 NuGet 服务器上的 NuGet 包。此库包含以下游戏类型的游戏移动分析器:

  • Game6x4 – 六种颜色和四种代码来猜测

  • Game8x5 – 八种颜色和五种代码

  • Game6x4Mini – 六种颜色和四种代码,带有 小型 儿童 模式

  • Game5x5x4 – 五种颜色和五种形状,四种代码

分析器通过通用的 IGame 接口工作,您需要使用游戏模型库来实现它。

IGame 接口指定了 Codebreaker 游戏的一些常见功能,如下面的代码片段所示。请检查存储库以获取完整的接口定义:

Codebreaker.GameAPIs.Analyzers/Contracts/IGame.cs

namespace Codebreaker.GameAPIs.Contracts;
public interface IGame
{
  Guid Id { get; }
  string GameType { get; }
  int NumberCodes { get; }
  int MaxMoves { get; }
  DateTime StartTime { get; }
  // code removed for brevity
  IDictionary<string, IEnumerable<string>> FieldValues { get; }
  string[] Codes { get; }
}

IGame 接口定义了分析器使用的游戏成员,例如必须设置的代码数量和允许的最大移动次数,这些由分析器用于验证正确的输入数据。IDictionary<string, IEnumerable<string>> 类型的 FieldValues 属性定义了可从中选择的可能值。对于颜色游戏类型,这将是一个颜色列表。对于形状游戏类型,这将是一个颜色列表和一个形状列表。Codes 属性包含一个字符串数组。字符串的样式取决于游戏类型。字符串数组包含游戏运行的正确解决方案。

analyzers 库还包含分析器实现支持的记录类型,您可以用作游戏类型的泛型参数。

以下是一个用于泛型参数的此类类型的示例 ColorField

Codebreaker.GameAPIs.Analyzers/Fields/ColorField.cs

public partial record class ColorField(string Color)
{
  public override string ToString() => Color;
  public static implicit operator ColorField(string color) => 
    new(color);
}

ColorField 记录类仅包含一个 color 字符串。此字段类型与所有游戏类型一起使用,除了 Game5x5x4 游戏类型,它使用 ShapeAndColorField 记录。

要指定游戏移动的结果,有三种不同类型可供选择:ColorResultSimpleColorResultShapeAndColorResultSimpleColorResult 为幼儿提供信息(哪个位置有正确的颜色),而 ColorResult 记录结构仅包含有关放置在正确孔中的颜色数量和放置在错误孔中的颜色数量的信息。

以下代码片段显示了 ColorResult 记录结构:

Codebreaker.GameAPIs.Analyzers/Results/ColorResult.cs

public readonly partial record struct ColorResult(int Correct, 
  int WrongPosition)
{
  private const char Separator = ':';
}

此记录部分实现,将实现的部分分离以简化源代码。其他部分定义在其他源文件中,并实现了IParsableIFormattable接口。名为Separatorconst成员与ColorResult类型的其他部分一起使用。

注意

ColorFieldColorResult以及其他表示字段和结果的类仅用于分析移动并返回结果。您在本章中将要实现的GameMove类只是数据持有者,不包含任何逻辑。字段猜测和结果都使用字符串表示,这使得它们适用于每种游戏类型,并且更容易与 JSON 序列化和数据库访问一起使用。将特定字段和结果类型转换为字符串以及从字符串转换的操作是通过使用IParsableISpanParsableIFormattable接口完成的。可解析接口自.NET 7 以来是新的,并基于 C# 11 的一个功能,该功能允许具有接口的静态成员。如果您想创建自己的游戏类型和游戏分析器,这些类型很重要。

您可以阅读csharp.christiannagel.com/2023/04/14/iparsable/上的文章,了解更多关于可解析接口的信息。

探索游戏分析器

游戏分析器的实现是在GameGuessAnalyzer基类和ColorGameGuessAnalyzerSimpleGameGuessAnalyzerShapeGameGuessAnalyzer派生类中完成的。这些分析器的实现与游戏模型类型无关。所有这些分析器都实现了由IGameGuessAnalyzer<Tresult>接口指定的GetResult方法。在创建分析器实例并传递游戏、猜测和移动编号后,只需调用GetResult方法即可计算移动的结果。

如果您对检查分析器感兴趣,请深入到书中源代码仓库中的Codebreaker.GameAPIs.Analyzers项目。通过以下步骤操作,而不是引用此项目,您可以添加CNinnovation.Codebreaker.Analyzers NuGet 包。只需确保使用此包的最新 3.x 版本,因为 4.x 及更高版本可能包含破坏性更改。

如果您选择这样做,您可以自己创建一个分析器,并添加更多游戏类型。请确保阅读关于游戏规则的第一章中的信息。

创建.NET 库

模型类型被添加到.NET library Codebreaker.GameAPIs.Models。拥有一个库允许创建不同的数据访问库(在第三章中),为托管服务提供灵活的数据存储选择。

您可以使用如上所示的.NET CLI 创建类库,或者使用 Visual Studio 创建类库:

dotnet new classlib --framework net8.0 -o Codebreaker.GameAPIs.Models

实现模型类型的类

这里展示了包含游戏所需所有数据的 Game 类(完整的实现请查看 GitHub 仓库):

Codebreaker.GameAPIs.Models/Game.cs

public class Game(
  Guid id,
  string gameType,
  string playerName,
  DateTime startTime,
  int numberCodes,
  int maxMoves) : IGame
{
  public Guid Id { get; } = id;
  public string GameType { get; } = gameType;
  public string PlayerName { get; } = playerName;
  public DateTime StartTime { get; } = startTime;
  // code removed for brevity
  public ICollection<Move> Moves { get; } = []
  public override string ToString() =>   
    $"{Id}:{GameType} – {StartTime}";
}

此类实现了 IGame 接口,以支持分析器检查走法的正确性并设置一些游戏状态。除了接口的成员外,Game 类还包含一个 Move 对象的集合。使用主要构造函数语法来减少所需的代码行数。

主要构造函数

本书创建的几个类都使用了主要构造函数。自 C# 9 以来,主要构造函数一直用于记录。在 C# 12 中,主要构造函数可以用于普通类和结构体。然而,虽然记录中的主要构造函数创建属性,而在类中,这些只是参数。这些参数可以分配给字段和属性,或者仅用于成员内。

Move 类比较简单,因为它只代表游戏中的玩家走法及其结果:

Codebreaker.GameAPIs.Models/Move.cs

public class Move(Guid id, int moveNumber)
{
  public Guid Id { get; private set; } = id;
  public int MoveNumber { get; private set; } = moveNumber;
  public required string[] GuessPegs { get; init; }
  public required string[] KeyPegs { get; init; }
  public override string ToString() => 
    $"{MoveNumber}. {string. Join(':', GuessPegs)}";
}

Move 类包含用于猜测(GuessPegs)和结果(KeyPegs)的字符串数组。字符串数组可以用于每种游戏类型。

定义游戏存储库合约

为了独立于数据存储,IGamesRepository 接口定义了在玩游戏时从数据存储中需要的所有成员。当开始新游戏时,将调用 AddGameAsync 方法。设置走法时,需要通过调用 UpdateGameAsync 方法来更新游戏。接口指定了 GetGameAsyncGetGamesAsync 方法来检索游戏:

Codebreaker.GameAPIs.Models/Data/IGamesRepository.cs

public interface IGamesRepository
{
  Task AddGameAsync(Game game, 
        CancellationToken cancellationToken = default);
     Task AddMoveAsync(Game game, Move move, 
       CancellationToken cancellationToken = default);
     Task<bool> DeleteGameAsync(Guid id, 
       CancellationToken cancellationToken = default);
     Task<Game?> GetGameAsync(Guid id, 
       CancellationToken cancellationToken = default);
     Task<IEnumerable<Game>> GetGamesAsync(GamesQuery gamesQuery, 
       CancellationToken cancellationToken = default);
     Task<Game> UpdateGameAsync(Game game, 
       CancellationToken cancellationToken = default);
}

此接口指定了异步方法。在本章的实现中不需要这些方法。下一章将添加异步实现,因此合约应该为此做好准备。

在指定异步方法时,允许传递 CancellationToken 是一种良好的做法。这允许在网络边界之外取消长时间运行的操作。

IGamesRepository 接口在 Codebreaker.GameAPIs.Models 库中定义。这使得可以从稍后将要实现的所有数据存储库中引用此接口。在本章中,将只实现一个内存中的集合作为下一步的一部分。

最小 API 项目

在建立了模型和存储库合约之后,我们可以转向创建托管 REST API 的项目。

在这里,我们将使用 ASP.NET Core 和最小 API 创建 REST API,并在内存存储库中存储游戏和走法。为了创建运行中的游戏 API,我们需要执行以下操作:

  1. 创建 Web API 项目。

  2. 实现游戏存储库。

  3. 创建游戏工厂。

  4. 创建数据传输对象。

  5. 创建端点以通过 HTTP 请求运行游戏。

  6. 配置 JSON 序列化器。

  7. 添加端点过滤器。

为了更好地理解不同的类在创建游戏和设置移动时的交互,我们需要实现的功能流程在接下来的两个图中展示。

图 2.2 展示了创建新游戏的序列。在调用 API 调用后,GamesService 被调用以启动游戏。这个服务类使用 GamesFactory 根据接收到的参数创建一个新的游戏,并返回随机的代码值。为了持久化,GamesServiceGamesService 调用 GameMemoryRepository 在返回游戏之前添加游戏。

图 2.2 – 创建游戏

图 2.2 – 创建游戏

图 2.3 展示了设置游戏移动的序列。因为客户端没有游戏完整的状态,GamesService 从存储库中检索游戏。然后,GamesFactory 再次使用以根据游戏类型选择分析器,并调用分析器以找到游戏移动的结果。在 GamesService 可用结果后,游戏使用新的移动进行更新,并将结果返回到游戏端点。

图 2.3 – 设置游戏移动

图 2.3 – 设置游戏移动

让我们使用 API 实现这个功能。

创建 Web API 项目

要创建 Web API 项目,你可以使用如下的 .NET CLI 或使用 Visual Studio 中的 Web API 项目模板:

dotnet new webapi --use-minimal-apis --framework net8.0 -o Codebreaker.GameAPIs

使用 --use-minimal-apis 选项创建最小 API 而不是传统的控制器。因为 API 将托管在 Ingress 服务器后面,Ingress 服务器将提供 HTTPS,而后端可以使用 HTTP。为了允许但不要求 HTTPS,从代码中删除 app.UseHttpsRedirection(); 这行代码。

探索 WebApplicationBuilder 和 WebApplication

使用创建的项目,Program.cs 被创建并包含使用 WebApplicationBuilder 类配置依赖注入容器的配置,以及使用 WebApplication 类配置中间件的配置。项目模板创建了一个随机天气服务。然而,由于游戏服务不需要天气信息,一些创建的代码可以被删除。

下一代码片段显示了剩余的代码部分:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
  app.UseSwagger();
  app.UseSwaggerUI();
}
app.Run();

WebApplication.CreateBuilder 返回 WebApplicationBuilder 并配置依赖注入容器、默认配置和默认日志。AddEndpointsApiExplorerAddSwaggerGen 方法向依赖注入容器添加服务。这两个方法注册的服务对于服务的 OpenAPI 描述是必需的。

调用Build方法返回一个WebApplication实例。然后使用此实例来配置中间件。中间件在服务对每个请求进行调用时被调用。UseSwagger方法注册 Swagger 中间件以创建服务的OpenAPI描述。UseSwaggerUI方法注册SwaggerUI中间件以显示一个网页,其中描述了 API 并可对其进行测试。由于 API 尚未实现,因此尚不会生成描述。

Swagger 和 OpenAPI 之间的关系是什么?

Swagger 规范是为了描述 HTTP API 而创建的。2015 年,SmartBear Software 收购了 Swagger API 规范,并在 2016 年与 Google、IBM、Microsoft、PayPal 和其他公司一起在 Linux 基金会的赞助下成立了新的组织——OpenAPI 倡议。此规范的最新版本使用 OpenAPI 定义。

SwaggerXX方法名称源自原始规范,并且自那时起没有更改。这些方法在Swashbuckle.AspNetCore NuGet 包中定义,该包与 Web API 项目模板一起引用。

实现存储库

在较早的定义游戏存储库合约部分中,我们创建了IGamesRepository接口来指定需要由与游戏 API 一起使用的每个数据存储实现的方法。我们现在可以实施此合约。GamesMemoryRepository类实现了IGamesRepository接口:

Codebreaker.GameAPIs/Data/GamesMemoryRepository.cs

namespace Codebreaker.GameAPIs.Data.InMemory;
public class GamesMemoryRepository(ILogger<GamesMemoryRepository> 
   logger) : IGamesRepository
{
  private readonly ConcurrentDictionary<Guid, Game> _games = new();
  private readonly ILogger _logger = logger;
  public Task AddGameAsync(Game game, 
    CancellationToken cancellationToken = default)
  {
    if (!_games.TryAdd(game.Id, game))
    {
      _logger.LogWarning("id {Id} already exists", game.Id);
    }
    return Task.CompletedTask;
  }
  // code removed for brevity
  public Task AddMoveAsync(Game game, Move move, 
    CancellationToken cancellationToken = default)
  {
    _games[game.Id] = game;
    return Task.CompletedTask;
  }
}

在实施过程中,当多个客户端并发访问服务时,使用ConcurrentDictionary类来创建线程安全的集合。通过实现的AddGameAsyncGetGameAsync和类似方法,游戏可以从字典中添加、更新和删除。在这里,所有游戏都仅保存在内存中。

持久状态和多个服务器实例

本章实现的存储库不持久化状态,也不允许多个服务器实例运行,因为状态仅存储在进程的内存中。在下一章中,将使用此接口的其他实现来将游戏存储在数据库中。

创建使用随机值初始化的游戏对象

GamesFactory类使用随机值创建游戏。以下代码片段显示了创建一个具有六种颜色和四个孔的游戏;您可以将此扩展到其他游戏类型:

Codebreaker.GameAPIs/Services/GamesFactory.cs

public static class GamesFactory
{
  private static readonly string[] s_colors6 =
    [ Colors.Red, Colors.Green, Colors.Blue, Colors.Yellow, 
    Colors. Purple, Colors.Orange ];
  // code removed for brevity
  public static Game CreateGame(string gameType, string playerName)
  {
    Game Create6x4Game() =>
      new(Guid.NewGuid(), gameType, playerName, DateTime.Now, 4, 12)
      {
        FieldValues = new Dictionary<string, IEnumerable<string>>()
        {
          { FieldCategories.Colors, s_colors6 }
        },
        Codes = Random.Shared.GetItems(s_colors6, 4)
      };
    // code removed for brevity
    return gameType switch
    {
      GameTypes.Game6x4Mini => Create6x4SimpleGame(),
      GameTypes.Game6x4 => Create6x4Game(),
      GameTypes.Game8x5 => Create8x5Game(),
      GameTypes.Game5x5x4 => Create5x5x4Game(),
      _ => throw new CodebreakerException("
      Invalid game type") { Code = CodebreakerExceptionCodes.
      InvalidGameType }
    };
  }
}

在代码中,使用了模式匹配。对于每种游戏类型,定义了一个局部函数,例如Create6x4Game,它指定了可用的颜色或形状、随机代码和最大移动次数。

创建数据传输对象

BookData类用于将信息写入数据库,以及一个BookDTO类用于通信,实现相同的属性。采用这种设计,对书籍的更改会导致BookDataBookDTO以及转换这些对象的实现发生变化。在每个场景中使用相同的Book类可以减少编程工作量,并且在运行时也可以减少内存和 CPU 的使用。

使用不同类型进行数据访问和通信的原因是数据映射到数据库的要求以及与通信一起使用的序列化器的需求。如今,EF Core 和System.Text.Json序列化器都支持带有参数的构造函数,这可能满足这些需求。

如果有其他需求,DTOs 变得很重要。在使用游戏 API 时,与内部服务相比,通信应使用不同的数据。在创建游戏时,并非所有游戏数据都来自客户端。其中大部分数据,如可用的字段列表以及代码,都是在服务器上生成的。当客户端向服务器发送移动时,服务器上的游戏会更新,但只需要发送从客户端到服务器的一个移动子集。当从服务器向客户端返回信息时,同样,只需要所需的数据子集。这使得创建 DTOs 变得重要。

使用游戏 API,要开始新游戏,我们实现CreateGameRequestCreateGameResponse类记录类型:

Codebreaker.GameAPIs/Models/GameAPIModels.cs

public enum GameType
{
  Game6x4,
  Game6x4Mini,
  Game8x5,
  Game5x5x4
}
public record class CreateGameRequest(GameType GameType, 
  string PlayerName);
public record class CreateGameResponse(Guid id, GameType GameType, 
  string PlayerName, int NumberCodes, int MaxMoves)
{
  public required IDictionary<string, IEnumerable<string>> 
    FieldValues { get; init; }
}

在创建游戏时,客户端只需要发送游戏类型和玩家姓名。这就是客户端需要提供的一切。在后台,仅使用字符串表示游戏类型。使用字符串可以轻松地增强其他游戏类型。使用enum类型的 API 可以显示可用的游戏类型,正如您将在关于 OpenAPI 的部分中看到的那样。

游戏创建后,客户端只需要拥有游戏的标识符。为了方便,游戏类型和玩家姓名也会返回。客户端还应了解可以选定的可能字段。这由FieldValues字典指定。

要设置游戏移动,我们实现UpdateGameRequestUpdateGameResponse类记录类型:

Codebreaker.GameAPIs/Models/GameAPIModels.cs

public record class UpdateGameRequest(Guid Id, GameType GameType, string PlayerName, int MoveNumber, bool End = false)
{
  public string[]? GuessPegs { get; set; }
}
public record class UpdateGameResponse(
  Guid Id,
  GameType GameType,
  int MoveNumber,
  bool Ended,
  bool IsVictory,
  string[]? Results);

在发送移动时,客户端需要发送猜测珠子的列表。使用 API 服务,猜测珠子和关键珠子用字符串表示,例如GuessPegs属性。这使得它与任何游戏类型无关。在分析器中,为每种游戏类型实现不同的类型。使用IParsable接口将字符串值转换为其他类型。

实现游戏服务

为了简化端点的实现,我们创建GamesService类,该类将被注入到端点中:

Codebreaker.GameAPIs/Services/GamesService.cs

public class GamesService(IGamesRepository dataRepository) : IGamesService
{
  public async Task<Game> StartGameAsync(string gameType, 
    string playerName, CancellationToken cancellationToken = default)
  {
    Game game = GamesFactory.CreateGame(gameType, playerName);
    await dataRepository.AddGameAsync(game, cancellationToken);
    return game;
  }
// code removed for brevity

StartGameAsync只是调用带有IGamesRepositoryAddGameAsync方法。这种简单的实现是GamesService的许多其他方法的典型情况。在下一章中,当数据在访问数据库之前在内存中缓存时,这将发生变化。当使用内存存储库时,这并不是必需的。

实现SetMoveAsync更为复杂,因为在这里我们必须决定使用其中一个游戏分析器来计算游戏。对于游戏类型选择和计算,ApplyMove方法在GamesFactory类内部被定义为Game类型的扩展方法:

Codebreaker.GameAPIs/Services/GamesFactory.cs

public static Move ApplyMove(this Game game, string[] guesses, int moveNumber)
{
  static TField[] GetGuesses<TField>(IEnumerable<string> guesses)
    where TField: IParsable<TField> => guesses
      .Select(g => TField.Parse(g, default))
      .ToArray();
  string[] GetColorGameGuessAnalyzerResult()
  {
    ColorGameGuessAnalyzer analyzer = 
      new (game, GetGuesses<ColorField>(guesses), moveNumber);
    return analyzer.GetResult().ToStringResults();
  }
  // code removed for brevity
  string[] results = game.GameType switch
  {
    GameTypes.Game6x4 => GetColorGameGuessAnalyzerResult(),
    GameTypes.Game8x5 => GetColorGameAnalyzerResult(),
    // code removed for brevity
  };
  Move move = new(Guid.NewGuid(), moveNumber)
  {
    GuessPegs = guesses,
    KeyPegs = results
  }
  game.Moves.Add(move);
  return move;
}

此方法实现使用switch表达式进行模式匹配,以调用正确的分析器类来获取游戏移动的结果。

有这个Game扩展方法,我们可以切换回GamesService类的SetMoveAsync方法的实现:

Codebreaker.GameAPIs/Services/GamesService.cs

public async Task<(Game game, string Result)> SetMoveAsync(
  Guid id, string[] guesses, int moveNumber, 
    CancellationToken cancellationToken = default)
{
  Game? game = await dataRepository.GetGameAsync(id, cancellationToken);
  CodebreakerException.ThrowIfNull(game);
  CodebreakerException.ThrowIfEnded(game);
  Move move = game.ApplyMove(guesses, moveNumber);
  await dataRepository.AddMoveAsync(game, move, cancellationToken);
  return (game, move);
}

SetMoveAsync方法在调用ApplyMove方法进行计算之前,会从存储库中检索游戏。

在传输对象和对象模型之间进行转换

在接收创建新游戏的CreateGameRequest请求时,无需进行转换。CreateGameRequest的成员可以直接在GamesService中使用。我们需要创建一个从Game类型到CreateGameResponse类型的转换。这作为Game类型的扩展方法来完成:

Codebreaker.GameAPIs/Extensions/ApiModelExtensions.cs

public static partial class ApiModelExtensions
{
  public static CreateGameResponse ToCreateGameResponse(
    this Game game) =>
    new(game.Id, Enum.Parse<GameType>(game.GameType), game.PlayerName)
    {
      FieldValues = game.FieldValues;
    };
    // code removed for brevity

通过实现,从Game类型所需的数据被传输到CreateGameResponse类型。

为游戏 API 服务创建端点

在创建端点之前,我们创建的服务需要注册到依赖注入容器中,以便在端点中注入它们:

Codebreaker.GameAPIs/Program.cs

builder.Services.AddSingleton<IGamesRepository, GamesMemoryRepository>();
builder.Services.AddScoped<IGamesService, GamesService>();

GamesMemoryRepository被创建来在内存中存储游戏对象。这被注册为单例,以创建一个实例,该实例被注入到端点。游戏应该保持与服务器运行的时间一样长。GamesMemoryRepository实现了IGamesRepository接口。在下一章中,将创建一个 EF Core 上下文来实现相同的接口,这将允许更改GamesService类实现IGamesService接口。此服务类是注册作用域的;因此,每次 HTTP 请求都会创建一个实例。

使用中间件,我们调用一个定义所有游戏 API 端点的扩展方法:

Codebreaker.GameAPIs/Program.cs

app.MapGameEndpoints();
app.Run();

MapGameEndpoints方法是IEndpointRouteBuilder接口的扩展方法,接下来将实现它。

在创建 REST API 时,使用不同的 HTTP 动词来读取和写入资源:

  • GET – 使用 HTTP GET请求,资源从服务返回

  • POST – HTTP POST请求创建一个新的资源

  • PUT – 通常使用PUT来更新一个完整的资源

  • PATCH – 使用 PATCH,可以发送部分资源进行更新

  • DELETE – HTTP 的 DELETE 请求用于删除资源

使用 HTTP POST 创建游戏

让我们从映射 HTTP POST 请求来创建新端点开始:

Codebreaker.GameAPIs/Endpoints/GameEndpoints1.cs

namespace Codebreaker.GameAPIs.Endpoints;
public static class GameEndpoints
{
  public static void MapGameEndpoints(
    this IEndpointRouteBuilder routes)
  {
var group = routes.MapGroup("/games");
    group.MapPost("/", async (
      CreateGameRequest request,
      IGamesService gameService,
      HttpContext context,
      CancellationToken cancellationToken) =>
    {
      Game game;
      try
      {
        game = await gameService.StartGameAsync(request.
          GameType.ToString(), request.PlayerName, cancellationToken);
      }
      catch (CodebreakerException) when (
        ex.Code == CodebreakerExceptionCodes.InvalidGameType)
      {
        GameError error = new(ErrorCodes.InvalidGameType,
          $"Game type {request.GameType} does not exist",
          context.Request.GetDisplayUrl(),
          Enum.GetNames<GameType>());
        return Results.BadRequest(error);
    }
    return Results.Created($"/games/{game.Id}", 
      game.ToCreateGameResponse());
  });

注意

在源代码仓库中,你可以找到 GameEndpoints.csGameEndPoints1.cs 文件。当前端点的状态在 GameEndpoints1.cs 文件中,但稍后当添加 OpenAPI 信息时,这将被更改。根据项目文件定义的文件是 GameEndpoints.cs。如果你想要从仓库中编译具有当前版本的项目的项目,请更改项目文件中 C#文件的设置。

如前所述,MapGameEndpoints 方法是 IEndpointRouteBuilder 接口的一个扩展方法。首先调用的是 MapGroup 方法来定义端点共有的功能,该方法反过来使用返回的组变量(一个 RouteGroupBuilder 对象)。使用这段代码,共有功能是 /games URI,它将被添加前缀。你可以用这个来满足共同的授权需求或共同记录,这将在本书的后续章节中展示。OpenAPI 的共有功能将在本章的 OpenAPI 部分中展示。

创建的组与 MapPost 方法一起使用。MapPost 方法将在 HTTP POST 请求上被调用。同样,MapGetMapPutMapDelete 也是可用的。所有这些方法都提供两种重载,其中包含模式和 Delegate 参数的重载被使用。Delegate 参数允许传递任何参数和任何返回类型的 lambda 表达式 – 这正是最小 API 所利用的。

使用 MapPost 方法指定的参数类型在此列出:

  • CreateGameRequest – 这个请求来自 HTTP 体。

  • IGamesService – 该值从 DI 容器中注入。

  • HttpContextCancellationToken – 这些是与最小 API 绑定在一起的特殊类型。另一个特殊类型是 ClaimsPrincipal,它与身份验证一起使用。

可以使用的其他绑定来源包括路由值、查询字符串、头部和 HTML 表单值。你还可以添加自定义绑定来映射路由、查询或头部绑定到自定义类型。

你可以添加如 FromBodyFromRouteFromServices 等属性,这些属性有助于提高可读性,并在存在冲突时解决问题。

通过使用 MapPost 方法实现 lambda 表达式,注入的 gameService 变量被用来启动游戏。成功启动游戏后,返回一个由 Game 派生的对象,该对象通过 ToCreateGameResponse 扩展方法转换为 CreateGameResponse。该方法在成功时返回 HTTP 201 Created,或者在失败时使用 Results 工厂类返回 HTTP 400 Bad RequestResults 提供了返回不同 HTTP 状态码的方法。

使用 Results.Created,将 URI 分配给方法的第一个参数,第二个参数接收创建的对象。使用 201 Created 状态码返回 HTTP 位置头,包含客户端可以记住的链接,以便稍后检索相同的资源。这里返回的链接可以用作 HTTP GET 请求,稍后检索游戏。

定义一个错误对象

在出现错误的情况下,MapPost 方法返回 Results.BadRequest。在这里,我们可以定义一个对象,向客户端返回详细的错误信息。

以下代码片段展示了 GameError 类:

Codebreaker.GameAPIs/Errors/GameError.cs

public record class GameError(string Code, string Message, 
  string Target, string[]? Details = default);
public class ErrorCodes
{
  public const string InvalidGameType = nameof(InvalidGameType);
  // code removed for brevity
}

GameError 类定义了 CodeMessageTargetDetails 属性。使用 MapPost 方法时,如果请求无效的游戏类型,将返回有效的游戏类型及其详细信息。

通过 HTTP GET 返回游戏

要满足对单个游戏的请求,我们使用 MapGet 方法:

Codebreaker.GameAPIs/Endpoints/GameEndpoints1.cs

group.MapGet("/{id:guid}", async (
  Guid id,
  IGamesService gameService,
  CancellationToken cancellationToken
) =>
{
  Game? game = await gameService.GetGameAsync(id, cancellationToken);
  if (game is null)
  {
    return Results.NotFound();
  }
  return Results.Ok(game);
});

在这里,使用 lambda 参数,从路由参数中接收 Guid。在大括号内,使用了与 Guid 变量匹配的相同变量名。实现简单,仅返回状态码为 OK 的游戏,或者在仓库中找不到游戏 ID 时返回 not found

通过设置移动更新游戏使用 HTTP PATCH

要设置移动,游戏通过不发送完整游戏来更新移动。这是一个部分更新,因此我们使用 HTTP PATCH 动词来调用 MapPatch 方法:

Codebreaker.GameAPIs/Endpoints/GameEndpoints1.cs

group.MapPatch("/{id:guid} ", async (
  Guid id,
  UpdateGameRequest request,
  IGamesService gameService,
  HttpContext context,
  CancellationToken cancellationToken) =>
{
  try
  {
    (Game game, string result) = await gameService.SetMoveAsync(
       id, request.GuessPegs, request.MoveNumber, cancellationToken);
    return Results.Ok(game.AsUpdateGameResponse(result));
  }
  catch (CodebreakerException ex) when (
    ex.Code == CodebreakerExceptionCodes.GameNotFound)
  {
    return Results.NotFound();
  }
  // code removed for brevity
});

此请求的完整路由为 games/{id} – 前缀由组指定的模式。GamesServiceSetMoveAsync 方法执行主要工作。成功时,该方法返回 UpdateGameResponse,它由 Game 对象和结果字符串创建。

配置 JSON 序列化

要成功运行应用程序,需要配置 JSON 序列化。.NET JSON 序列化器有许多配置选项 – 包括多态序列化(返回对象层次结构)。但是,由于 ASP.NET Core 支持所有这些序列化功能,您需要注意您使用的客户端技术,如果那里也有相同支持的话。在这里传输的数据类型,没有太多要做。

仅为了序列化游戏类型的枚举值,我们更愿意使用字符串而不是默认返回的数字。要返回字符串值,需要配置 JSON 选项:

Codebreaker.GameAPIs/Models/GameAPIModels.cs

[JsonConverter(typeof(JsonStringEnumConverter<GameType>))]
public enum GameType
{
    Game6x4,
    Game6x4Mini,
    Game8x5,
    Game5x5x4
}

JsonStringEnumConverter 类的泛型版本自 .NET 8 以来是新的,以支持原生 AOT。此类型的非泛型版本使用反射,这与原生 AOT 不兼容。

注意

除了使用具有类型的属性外,你还可以使用依赖注入容器配置 JSON 序列化行为。可以使用来自Microsoft.AspNetCore.Http.Json命名空间的ConfigureHttpJsonOptions扩展方法和JsonOptions来配置最小 API 的 JSON 序列化。请注意,OpenAPI 生成仍然使用 MVC 序列化配置,因此在这里,你需要使用带有Microsoft.AspNetCore.Mvc.JsonOptions作为泛型参数的Configure方法。

创建端点过滤器

为了简化端点的代码,正如你所见,端点不需要在顶层语句中指定。你可以通过扩展方法创建多个类来将端点分组在一起。在一个扩展方法内部,你也可以使用MapGroup方法将具有共同行为的端点分组。我们还使用了依赖注入,将GameService类中的主要功能放在端点实现之外。

有另一种简化端点实现的方法——通过使用自定义端点过滤器。端点过滤器提供了类似于 ASP.NET Core 中间件的功能,只是作用域不同。通过将端点过滤器添加到端点,每次访问端点时都会调用过滤器代码。添加多个端点过滤器时,添加的顺序很重要:一个过滤器在另一个过滤器之后被调用。你还可以将端点过滤器添加到一组中;因此,该过滤器会与该组中指定的每个端点一起调用。

使用ValidatePlayernameEndpointFilter,我们创建了一个过滤器来验证玩家名字的最小长度:

Codebreaker.GameAPIs/Endpoints/ValidatePlayernameEndpointFilter.cs

public class ValidatePlayernameEndpointFilter : IEndpointFilter
{
  public async ValueTask<object?> 
    InvokeAsync(EndpointFilterInvocationContext context, 
    EndpointFilterDelegate next)
  {
    CreateGameRequest request = 
      context.GetArgument<CreateGameRequest>(0);
    if (request.PlayerName.Length < 4)
    {
       return Results.BadRequest("
         Player name must be at least 4 characters long");
    }
    return await next(context);
  }
}

端点过滤器实现了IEndpointFilter接口。该接口定义了带有EndpointFilterInvocationContextEndpointFilterDelegate参数的InvokeAsync方法。通过使用EndpointFilterInvocationContext,你可以访问HttpContext以获取有关请求的所有信息,并添加响应以及传递给端点的参数。通过实现ValidatePlayernameEndpointFilter,通过访问端点 lambda 表达式的第一个参数、访问CreateGameRequest对象以及访问PlayerName属性来验证玩家的名字。如果这没有成功,将返回400 Bad Request的 HTTP 状态码。要访问不同的参数,需要参数的索引。在验证成功后,使用从第二个参数接收的next变量调用下一个端点。

使用端点过滤器,你还可以删除异常处理代码,并创建在下一个过滤器或端点代码调用之前和之后调用的过滤器代码。我们通过CreateGameExceptionEndointFilter实现此功能:

Codebreaker.GameAPIs/Endpoints/CreateGameExceptionEndpointFilter.cs

public class CreateGameExceptionEndpointFilter : IEndpointFilter
{
  private readonly ILogger _logger;
  public CreateGameExceptionEndpointFilter
    (ILogger<CreateGameExceptionEndpointFilter> logger)
  {
    _logger = logger;
  }
  public async ValueTask<object?> 
    InvokeAsync(EndpointFilterInvocationContext context, 
    EndpointFilterDelegate next)
  {
    CreateGameRequest request = 
      context.GetArgument<CreateGameRequest>(1);
    try
    {
      return await next(context);
    }
    catch (CodebreakerException ex) when (
      ex.Code == CodebreakerExceptionCodes.InvalidGameType)
    {
_logger.LogWarning("game type {gametype} not found", 
        request.GameType);
      return Results.BadRequest("Gametype does not exist");
    }
  }
}

CreateGameExceptionEndpointFilter端点过滤器定义了一个构造函数来注入ILogger接口。所有注册到依赖注入容器的都可以通过端点过滤器注入。使用此过滤器,下一个过滤器(或端点本身,在这种情况下)的调用被一个try/catch块包围。因此,CodebreakerException在端点调用时被捕获,记录,并返回结果。这样,异常处理代码就可以从端点本身中移除。以下代码片段显示了配置了过滤器的端点实现的新代码:

Codebreaker.GameAPIs/Endpoints/GameEndpoints2.cs

group.MapPost("/", async (
  CreateGameRequest request,
  IGamesService gameService,
  CancellationToken cancellationToken) =>
{
  Game game = await gameService.StartGameAsync(request.
    GameType.ToString(), request.PlayerName, cancellationToken);
  return Results.Created($"/games/{game.Id}", 
    game.ToCreateGameResponse());
        }).AddEndpointFilter<ValidatePlayernameEndpointFilter>()
          .AddEndpointFilter<CreateGameExceptionEndpointFilter>();

使用这种实现,该端点的重要主要功能很容易看到。验证和错误处理被移出端点实现之外。将所有代码一起计算,代码并没有变得更小,只是端点的主要功能变得更简单。

端点过滤器真正的威力来自于不同端点之间的共享功能。在第11 章 日志和监控中,我们将创建一个端点过滤器,为配置了此过滤器的每个端点记录信息。

运行服务

在实现了端点之后,你可以运行和测试应用程序,并查看 Open API 用户界面,如图2.4所示:

图 2.4 – 无额外配置的 OpenAPI UI

图 2.4 – 无额外配置的 OpenAPI UI

你现在可以从 Swagger 页面发送一个POST请求来运行应用程序。在获取返回的游戏信息后,复制游戏的唯一 ID,并发送一个PATCH请求来设置移动。

当应用程序运行时,OpenAPI 现在缺少一些信息。返回的 HTTP 结果未列出,应该显示更多描述细节,并且返回类型的模式不可用。这将在下一节中解决。

OpenAPI 信息

由于存在许多.NET 版本,Web API 模板引用了Swashbuckle.AspNetCore NuGet 包来创建 OpenAPI 描述。多年来,随着.NET 版本的更新,越来越多的 OpenAPI 功能被添加到 ASP.NET Core 本身中,例如OpenApiInfo类,现在它是Microsoft.OpenApis.Models命名空间的一部分。Swashbuckle 被修改为使用新的类,并且也被修改为基于System.Text.Json序列化器而不是Newtonsoft.Json

添加 OpenAPI 文档

接下来,让我们使用Microsoft.OpenApis.Models命名空间中的类以及 Swashbuckle 来配置 OpenAPI 文档:

Codebreaker.GameAPIs/Program.cs

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
  options.SwaggerDoc("v3", new OpenApiInfo
  {
    Version = "v3",
    Title = "Codebreaker Games API",
    Description = 
      "An ASP.NET Core minimal APIs to play Codebreaker games",
    TermsOfService = new Uri("https://www.cninnovation.com/terms"),
    Contact = new OpenApiContact
    {
      Name = "Christian Nagel",
      Url = new Uri("https://csharp.christiannagel.com")
    },
    License = new OpenApiLicense
    {
      Name="API usage license",
      Url= new Uri("https://www.cninnovation.com/apiusage")
    }
  });
});

Codebreaker 游戏 API 已经进入第三个主要版本。通过依赖注入容器的 Swagger 配置,AddSwaggerGen方法支持接收配置选项。SwaggerGenOptions选项参数的SwaggerDoc方法允许指定不同的文档值,例如版本号、标题、描述、服务条款、联系信息和许可信息,如代码片段所示。OpenApiInfoOpenApiContactOpenApiLicense类是Microsoft.OpenApi.Models命名空间的一部分。所有这些配置信息都将显示在生成的 OpenAPI 文档中。

中间件配置也需要更新:

Codebreaker.GameAPIs/Program.cs

app.UseSwagger();
app.UseSwaggerUI(options =>
{
  options.SwaggerEndpoint("/swagger/v3/swagger.json", "v3");
});

在这里,版本号包含在调用SwaggerEndpoint方法时使用的SwaggerUIOptions参数中。如果您更喜欢生成的文档的另一种外观,可以创建自己的样式表,并通过调用options.InjectStylesheet方法传递样式表文件。

端点的文档

有几个扩展方法可用于向端点添加 OpenAPI 文档,正如我们将在MapGameEndpoints实现中做的那样:

Codebreaker.GameAPIs/Endpoints/GameEndpoints.cs

public static void MapGameEndpoints(this IEndpointRouteBuilder routes)
{
  var group = routes.MapGroup("/games")
    .WithTags("Games API");

WithTags方法可以添加到每个端点,或者,如这里所示,添加到端点组。WithTags添加一个类别名称——如果 API 应该显示多个类别,则可以添加多个名称。有了这个,对于每个标签名称的文档,都会使用一个标题来显示属于同一类别的所有端点。如果您不提供标签名称,则使用项目名称,并将所有端点列在这个类别中。

接下来,使用RouteHandlerBuilder扩展方法,将文档添加到每个端点:

Codebreaker.GameAPIs/Endpoints/GameEndpoints.cs

group.MapPost("/", async Task<Results<Created<CreateGameResponse>, BadRequest<GameError>>> (
// code removed for brevity
})
.WithName("CreateGame")
.WithSummary("Creates and starts a game")
.WithOpenApi(op =>
{
  op.RequestBody.Description = "
     The game type and the player name of the game to create";
  return op;
});

使用WithNameWithSummaryWithOpenApi方法,可以添加 API 的名称和描述——包括每个参数的描述。

向 OpenAPI 添加返回类型信息

Produces扩展方法可用于定义需要描述的端点返回的类型。从.NET 7 开始,有一个更好的选项:使用TypedResults类而不是之前在实现端点时使用的Results类。TypedResults将指定的类添加到 OpenAPI 文档中。然而,如果有多个类型返回,我们需要通过端点 lambda 表达式的返回类型来指定这一点。

首先需要更改为TypedResults的方法是MapDelete

Codebreaker.GameAPIs/Endpoints/GameEndpoints.cs

group.MapDelete("/{id:guid}", async (
  Guid id,
  IGamesService gameService,
  CancellationToken cancellationToken
) =>
{
  await gameService.DeleteGameAsync(id, cancellationToken);
return TypedResults.NoContent();
})
// code removed for brevity

MapDelete方法的 lambda 表达式实现仅返回 HTTP 状态码204,并且 lambda 表达式不需要返回类型。

这与MapPatch实现不同:

group.MapPatch("/{id:guid}/moves", async Task<Results<Ok<UpdateGameResponse>, NotFound, BadRequest<GameError>>> (
  Guid id,
  UpdateGameRequest request,
  IGamesService gameService,
  HttpContext context,
  CancellationToken cancellationToken) =>
{
  try
  {
    (Game game, string result) = await gameService.SetMoveAsync(id, 
     request.GuessPegs, request.MoveNumber, cancellationToken);
    return TypedResults.Ok(game.AsUpdateGameResponse(result));
  }
  catch (ArgumentException ex) when (ex.HResult >= 4200 && 
    ex.HResult <= 4500)
  {
    string url = context.Request.GetDisplayUrl();
    return ex.HResult switch
    {
      4200 => TypedResults.BadRequest(new GameError(
        ErrorCodes.InvalidGuessNumber, "Invalid number of guesses 
        received", url)),
      4300 => TypedResults.BadRequest(new GameError(
        ErrorCodes.InvalidMoveNumber, "Invalid move number received", 
        url)),
  // code removed for brevity
    };
  }
  catch (GameNotFoundException)
  {
    return TypedResults.NotFound();
  }
})
// code removed for brevity

Lambda 表达式的实现包含对 TypedResults 工厂类的多次调用。调用 BadRequestNotFoundOk 方法。使用 BadRequest 返回 GameError 类型的对象。这是一个简单的记录类,包含 Message 和其他属性,以向客户端返回有用的信息。Ok 方法返回 UpdateGameResponse 类型的对象。当返回两个或更多类型的结果时,lambda 表达式需要一个返回类型。为了使用 lambda 表达式指定返回类型,.NET 7 添加了泛型 Results 类型 – 例如,具有两个泛型参数的类型:Results<TResult1, TResult2>。泛型类型指定了约束,要求实现 IResult 接口。Microsoft.AspNetCore.Http.HttpResults 命名空间包含具有两个到六个泛型参数的泛型 Results 类型。通过使用 Results<Ok<UpdateGameResponse>, NotFound, BadRequest<InvalidGameMoveError>>,定义了该方法要么返回一个包含 SetMoveError 对象的 Ok 结果,要么返回 NotFoundBadRequest,并带有 InvalidGameMoveError 对象。由于 lambda 表达式使用了 async 关键字,完整的结果被放入一个任务中:Task<Results<Ok<UpdateGameResponse>, NotFound, BadRequest<InvalidGameMoveError>>>

在所有这些 OpenAPI 配置就绪后,我们可以启动服务,生成描述 API 的文档,并通过网页界面使用它,如图 图 2.5 所示。

图 2.5 – OpenAPI 文档

图 2.5 – OpenAPI 文档

打开端点,你可以看到返回的不同 HTTP 结果和创建的所有模式。

测试服务

要运行服务,可以使用 OpenAPI 测试页面。然而,有一种更好的方法,无需离开 Visual Studio 或 Visual Studio Code。Visual Studio 提供了 端点探索器 窗口,以显示解决方案中的所有 API 端点,如图 图 2.6 所示。使用 视图 | 其他窗口 | 端点探索器 打开此窗口:

图 2.6 – 端点探索器

图 2.6 – 端点探索器

通过选择一个端点并打开上下文菜单,你可以生成一个请求。这会创建一个 HTTP 文件,你可以使用它发送 HTTP 请求,包括正文,并查看旁边的返回结果。

在 Visual Studio Code 中使用 HTTP 文件

如果你使用 Visual Studio Code,请从华超毛那里安装 REST 客户端 扩展。

首先,通过发送 POST 请求开始游戏:

Codebreaker.GameAPIs/Codebreaker.GameAPIs.http

@HostAddress = http://localhost:9400
@ContentType = application/json
### Create a game
POST {{HostAddress}}/games/
Content-Type: {{ContentType}}
{
  "gameType": "Game6x4",
  "playerName": "test"
}

HTTP 文件中的前两行指定了随后被两个大括号包围的变量。每个请求都需要用三个井号字符###分隔。使用这种方式,通过 Visual Studio 或 Visual Studio Code 扩展,您可以看到一个绿色箭头,点击它就会发送请求。为了将 HTTP 头与正文分开,需要一个空行。创建游戏的正文包含gameTypeplayerName JSON 元素。

在发送请求创建游戏后,您可以设置一步操作:

Codebreaker.GameAPIs/Codebreaker.GameAPIs.http

### Set a move
@id = 1eae1e79-a7fb-41a6-9be8-39f83537b7f3
PATCH {{HostAddress}}/games/{{id}}/moves
Content-Type: {{ContentType}}
{
  "gameType": "Game6x4",
  "playerName": "test",
  "moveNumber": 1,
  "guessPegs": [
    "Red",
    "Green",
    "Blue",
    "Yellow"
  ]
}

在发送操作之前,获取在创建游戏时返回的 ID,并将其粘贴到与 HTTP 文件一起使用的id变量中。无需保存此文件;只需点击链接就会发送PATCH请求。请记住,每次操作都要更新操作编号。

要获取有关游戏的信息,请发送一个GET请求:

Codebreaker.GameAPIs/Codebreaker.GameAPIs.http

### Get game information
GET {{HostAddress}}/games/{{id}}

在发送GET请求时,不提供正文。此请求提供了关于游戏的所有完整信息,包括其操作和结果代码。

通过使用 HTTP 文件,您可以在不离开 Visual Studio 的情况下轻松调试 API。如果您正在进行调试会话,可能需要更改文本编辑器 | REST | 高级设置中的请求超时设置。HTTP 文件还作为项目的一部分提供了良好的文档。

启用.NET Aspire

让我们将.NET Aspire 添加到这个解决方案中。使用 Visual Studio,您可以在解决方案资源管理器中选择游戏 API 项目,并使用上下文菜单选择添加 | .NET Aspire Orchestrator Support…

注意

除了使用 Visual Studio 之外,您还可以使用 dotnet CLI 创建.NET Aspire 项目:dotnet new aspire。创建这两个项目后,您可以使用本节中的说明连接游戏 API 项目。

这将创建两个项目({solution}.AppHost{solution}.ServiceDefaults),并对游戏 API 项目进行一些小的修改。AppHost项目是一个运行仪表板以监控所有配置服务的 Web 应用程序。ServiceDefaults是一个用于指定默认配置的库。此库由游戏 API 引用。接下来,让我们深入了解细节。

探索 Aspire 主机

这里展示了 Aspire 主机启动代码的源代码:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis");
builder.Build().Run();

此 Aspire 主机运行一个仅在开发时间使用的 Web 应用程序。在这里,使用应用程序构建器模式使用了DistributedApplication类。CreateBuilder方法返回IDistributedApplicationBuilder接口,该接口允许配置所有应由分布式应用程序编排的服务。使用此接口,类似于WebApplicationBuilder,可以配置配置和 DI 容器。与WebApplicationBuilder相反,可以添加由应用程序主机编排的资源。

通过调用 AddProject 扩展方法将项目资源添加到构建器的资源中。将项目引用添加到 Codebreaker.GameAPIs 项目中将在 Projects 命名空间中创建 Codebreaker_GameAPIs 类。传递给参数的名称 – gameapis – 可以在某个服务引用另一个服务时使用。目前,我们在 Codebreaker 解决方案中只有一个服务,但随本书的章节推进,这将会改变。

探索 ServiceDefaults 库

Codebreaker.ServiceDefaults 项目是为了被所有使用 .NET Aspire 功能的服务项目引用。此项目添加了对 NuGet 包的引用,用于 HTTP 弹性、服务发现以及用于日志记录、指标和分布式跟踪的几个 OpenTelemetry 包。

这是 Extensions 类定义的配置:

Codebreaker.ServiceDefaults/Extensions.cs

public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
  builder.ConfigureOpenTelemetry();
  builder.AddDefaultHealthChecks();
  builder.Services.AddServiceDiscovery();
  builder.Services.ConfigureHttpClientDefaults(http =>
  {
    http.AddStandardResilienceHandler();
    http.AddServiceDiscovery();
  });
  return builder;
}

AddServiceDefaults 方法使用 OpenTelemetry 配置 DI 容器以进行日志记录、默认健康检查和服务发现,并配置 HttpClient 类以实现弹性。

此完整配置可以通过调用应用程序构建器的 AddServiceDefaults 方法从 Codebreaker Games API 使用。此调用是在添加 Aspire 协调器时从 Visual Studio .NET Aspire 集成中添加的。如果你是从命令行创建的 .NET Aspire 项目,则在配置 DI 容器时需要将调用添加到 AddServiceDefaults 方法中。

服务默认库定义的另一个扩展方法是 MapDefaultEndpoints

Codebreaker.ServiceDefaults/Extensions.cs

public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
  app.MapHealthChecks("/health");
  app.MapHealthChecks("/alive", new HealthCheckOptions
  {
    Predicate = r => r.Tags.Contains("live")
  });
  return app;
}

这是一个配置 ASP.NET Core 中间件的健康检查的扩展方法。需要使用 Games API 的中间件配置调用 MapDefaultEndpoints 方法。

这里提到的所有功能将在以下章节中详细讨论。现在,你可以开始启动 Aspire 宿主项目,它反过来启动 Games API。

运行 .NET Aspire 宿主

当运行 .NET Aspire 宿主时,会显示一个仪表板,显示 codebreaker.gameapis 项目及其资源,你可以访问端点、检查环境变量和打开日志信息,如图 图 2**.7 所示。

图 2.7 – .NET Aspire 仪表板中的资源

图 2.7 – .NET Aspire 仪表板中的资源

当 Aspire 宿主运行时,你可以使用之前创建的 HTTP 文件玩游戏,并现在监控日志、指标信息和跟踪(如图 图 2**.8 所示)。

图 2.8 – .NET Aspire 仪表板中的跟踪

图 2.8 – .NET Aspire 仪表板中的跟踪

关于日志记录、指标、跟踪以及添加自定义信息的详细信息请参阅 第十一章日志记录监控

摘要

在本章中,我们使用 ASP.NET Core 最小 API 创建了一个 Web API。我们涵盖了创建服务和内存中仓库,使用依赖注入容器进行配置,创建了模型,并使用游戏分析类来计算移动。

我们创建了用于创建、读取和更新游戏的端点,指定了与 OpenAPI 文档一起显示的信息,使用 HTTP 文件测试了服务,并最终添加了 .NET Aspire 用于托管和仪表板。

在完成本章内容后,你应休息一下,玩玩游戏。使用 HTTP 文件创建一个游戏并设置移动,直到返回的答案显示你已经赢了。在你找到答案之前,不要通过向游戏发送 GET 请求来作弊!

在下一章中,我们将使用 Entity Framework Core 和 SQL Server 以及 Azure Cosmos DB 来替换仓库,以拥有一个持久的游戏存储。

进一步阅读

要了解更多关于本章讨论的主题,你可以参考以下链接:

第三章:将数据写入关系型和非关系型数据库

在使用最小 API 创建服务的第一个实现之后,我们将在此基础上进行读取和写入数据库的操作。在本章中,我们将用Entity Framework CoreEF Core)替换内存中的存储库,以访问关系型数据库——Microsoft SQL Server——以及使用 EF Core 的 Azure Cosmos DB NoSQL 数据库。

你将创建两个库来访问这些数据库,创建 EF Core 上下文类,指定从模型类到数据库的映射,并配置最小 API 服务以使用其中一个数据库。在添加这些更改后,游戏将被持久化,当服务重启时可以继续游戏运行。

在本章中,你将探索以下主题:

  • 探索要存储在数据库中的数据模型

  • 创建和配置 EF Core 上下文以访问 Microsoft SQL Server

  • 创建迁移以更新数据库架构

  • 创建和配置 EF Core 上下文以访问 Azure Cosmos DB

技术要求

本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch03源代码文件夹包含本章的代码示例。本章最重要的项目如下:

  • Codebreaker.Data.SqlServer – 这是用于访问 Microsoft SQL Server 的新库。

  • Codebreaker.Data.Cosmos – 这是用于访问 Azure Cosmos DB 的新库。

  • Codebreaker.GamesAPIs – 这是上一章创建的 Web API 项目。在本章中,依赖注入DI)容器已更新,以使用.NET Aspire 组件来使用 SQL Server 和 Azure Cosmos DB。

  • Codebreaker.GameAPIs.Models – 本章节中该项目仅进行了最小更改,为Game类添加了一个属性。

  • Codebreaker.AppHost – 该项目已更新,包含 SQL Server 和 Azure Cosmos DB 资源以及转发配置值。

  • Codebreaker.ServiceDefaults – 该项目与上一章保持不变。

上一章中的analyzer库在本章中未包含。在这里,我们仅使用CNinnovation.Codebreaker.Analyzers NuGet 包。

如果你已经完成了上一章的内容,创建了模型并实现了最小 API 项目,你可以从那里继续。如果你没有完成上一章的工作,也可以从ch02文件夹中的文件开始。ch03包含了本章的所有更新。

除了开发环境之外,您还需要 Microsoft SQL Server 和 Azure Cosmos DB。目前您不需要 Azure 订阅。SQL Server 会与 Visual Studio 一起安装。您也可以下载 SQL Server 2022 开发者版。这可以通过 winget 实现很简单(但您也可以下载并安装 Windows 安装程序包):

winget install Microsoft.SQLServer.2022.Developer

如果您使用的是 Mac,您可以使用 SQL Server 的 Docker 镜像。在 第五章 中,您可以阅读有关 Docker 和在 Docker 容器中运行 SQL Server 的更多详细信息。

为了方便使用 SQL Server 和 Azure Cosmos DB,本章使用了 Docker 镜像。您也可以使用与 Visual Studio 一起安装的 SQL Server,以及 Azure Cosmos DB 模拟器。

要运行 Azure Cosmos DB,有一个本地运行的模拟器可用。您可以使用以下命令安装此 NoSQL 数据库模拟器:

winget install Microsoft.Azure.CosmosEmulator

注意

Azure Cosmos 模拟器仅在 Windows 上可用。在 Linux 环境中(以及 Windows 和 Mac 上),您可以使用 Docker 镜像来运行模拟器。有关运行模拟器的信息,请参阅 learn.microsoft.com/en-us/azure/cosmos-db/how-to-develop-emulator。有关 Docker 的更多信息,请参阅 第五章

要读取和写入您的 SQL Server 数据,在 Visual Studio 中,您可以使用 SQL Server 对象资源管理器。在 Visual Studio 之外,并且具有更多功能,请使用 SQL Server Management StudioSSMS),可以使用以下命令安装:

winget install Microsoft.SQLServerManagementStudio

本章的项目及其相互关系在 图 3.1 中以 C4 组件图展示。gamesAPImodels 组件是在 第二章 中创建的。在本章中,将添加两个用于访问 SQL Server 和 Azure Cosmos DB 数据库的项目(sqlDatabasecosmosDatabase)。根据配置,游戏 API 将使用内存中的存储库(在 第二章 中创建)或 IGamesRepository 的其他实现之一:

图 3.1 – 项目

图 3.1 – 项目

让我们在对模型进行小修改的同时,开始探索包含这些模型的工程。

探索数据库映射的模型

在创建服务时,可以使用不同的模型来处理数据库、功能和 API。数据库层可能与其他层有不同的要求。当创建单体应用程序时,通常是这样的,但这也意味着在维护应用程序和添加字段时,所有不同的层都需要被触及并更新。当创建具有较小范围的微服务时,有很大可能可以使用与数据库、应用程序的功能和 API 相同的模型。这不仅降低了维护成本,还提高了性能,因为不是每个层都会创建新的实例并在周围复制值。

在示例应用程序中,GameMove 类型及其在前一章中创建的泛型对应类型并不简单,但它们可以直接与 EF Core 一起使用。

让我们看看模型以及数据库需要映射的内容,从 Game 类型开始:

Codebreaker.GameAPIs.Models/Game.cs

public class Game(
  Guid id,
  string gameType,
  string playerName,
  DateTime startTime,
  int numberCodes,
  int maxMoves)
{
  public Guid Id { get; private set; } = id;
  public string GameType { get; private set; } = gameType;
  public string PlayerName { get; private set; } = playerName;
  public DateTime StartTime { get; private set; } = startTime;
  // code removed for brevity
public required IDictionary<string, IEnumerable<string>> FieldValues 
    { get; init; }
  public required string[] Codes { get; init; }
  public ICollection<Move> Moves { get; } = new List<Move>();
  public override string ToString() => $"{Id}:{GameType} - 
{StartTime}";
}

Game 类包含 GuidstringDateTimeTimeSpanintbool 类型的属性。所有这些属性都可以轻松映射到数据库列。只需配置字符串的大小即可。对于 SQL Server,映射字符串的约定是 nvarchar(max)。这可以减小大小。更有趣的是构造函数。

该类没有定义无参构造函数。虽然一些工具需要无参构造函数,但现在的 JSON 序列化和 EF Core 都不需要。EF Core 支持具有参数的构造函数,只要构造函数映射到简单属性即可——Game 类就是这样。EF Core 映射支持具有 getset 访问器的属性。如果只有一个 get 访问器可用,映射将失败。一种解决方案是使用 private 字段。EF Core 支持显式映射到字段。另一个选项是使用私有的 set 访问器——这在 Game 类中被使用。

Game 类还有一些其他有趣的成员:FieldValues 属性的类型为 IDictionary<string, IEnumerable<string>>。字段值定义了用户可以选择的可能选项。通常,同一游戏类型的所有游戏都具有相同的字段值,但这些都可能随时间而变化。我们不应该期望这些值始终保持不变。应用程序可能会随着时间的推移更改可选择的颜色或形状。因此,我们不能简单地忽略要存储的属性——这个属性应该与游戏一起存储。至于这种类型,没有默认的映射可用,因此我们需要添加一个转换。

Codes 属性是字符串数组类型。EF Core 8.0 支持内置映射原始类型集合;也就是说,数组、整数、字符串等的列表。使用内置功能,集合以 JSON 格式存储在字符串表中。这符合目的。Codes 属性包含一个解决方案的列表。对于基于颜色的游戏类型,这是一个最多包含五种颜色的列表;对于形状游戏类型,集合中的一个字符串由形状和颜色以及分隔符组成。使用低于 EF Core 8 的版本,则需要自定义转换。使用 EF Core 8,这将使用默认功能进行映射。

Moves 属性通常与关系型数据库相关联。使用 SQL Server,我们将使用 Moves 表来存储每个移动。如果需要,可以使用 JSON 列存储移动,但我们将使用单独的表和查询来存储移动。使用 NoSQL 数据库,在游戏中存储移动是一种自然的方式。

让我们来看看 Move 类型:

Codebreaker.GameAPIs.Models/Move.cs

public class Move(Guid id, int moveNumber)
{
  public Guid Id { get; private set; } = id;
  public int MoveNumber { get; private set; } = moveNumber;
  public required string[] GuessPegs { get; init; }
  public required string[] KeyPegs { get; init; }
  public override string ToString() => $"{MoveNumber}. " +
    $"{string.Join('#',GuessPegs)} : " +
    $"{string.Join('#', KeyPegs)}";
}

对于 Move 类,GuessPegs(玩家对移动的猜测)和 KeyPegs(分析器的结果)可以像 Game 类型的 Codes 属性一样进行序列化。更有趣的是这里没有的东西。也就是说,没有像 GameId 这样的外键属性或直接建立 MoveGame 类型之间关系的 Game 属性。到目前为止,在使用 Move 类型时,这种关系不是必需的。在 EF Core 中,也不需要在模型中添加这种关系。EF Core 支持一个名为 影子属性 的功能。这些属性不是模型的一部分,但存储在数据库中,并且在使用 EF Core 上下文时可以访问。

让我们总结一下使用 EF Core 映射 GameMove 类型所需完成的任务:

  1. 对于简单的字符串属性,需要使用 SQL Server 定义数据库字符串的大小。

  2. FieldValues 属性的类型为 IDictionary<string, IEnumerable<string>> 需要一个 值转换器

  3. Moves 属性将集合映射到 Move 类型。在关系型数据库中,Move 对象应该存储在单独的 Moves 表中。因为 Move 类型没有定义主键,所以需要 影子属性

  4. 使用 Azure Cosmos DB,移动应该存储在游戏 JSON 文档中。

注意

EF Core 支持通过约定、注解和流畅 API 进行映射。约定是提供程序特定的。例如,.NET 字符串映射到 nvarchar(max) 是一个约定。使用可空性,不可空属性映射到必需的数据库列,而可空属性不是必需的。约定可以通过注解覆盖。注解是如 [StringLength(20)] 这样的属性,它不仅可以用于验证用户输入,还可以指定列应为 nvarchar(20)。使用流畅 API,可以覆盖注解。流畅 API 提供了大多数选项,并覆盖了所有其他设置。我们将在下一节中使用流畅 API。

让我们定义一个映射来处理这些模型,无论是关系数据库还是 NoSQL。

使用 EF Core 与 SQL Server

让我们从关系数据库开始,在多个表中存储游戏和移动。我们需要做以下事情:

  1. 创建类库项目

  2. 创建 EF Core 上下文

  3. 自定义简单属性的映射

  4. 创建用于映射复杂属性的值转换

  5. 定义游戏和移动之间的关系

  6. Move 类型创建阴影属性

  7. 实现存储库合约

  8. 使用 SQL Server 配置应用程序模型

  9. 使用最小 API 项目配置 DI 容器

使用 SQL Server 创建数据类库

要创建类库项目,您可以使用如下所示的 .NET CLI 或使用 Visual Studio 中的类库项目模板:

dotnet new classlib --framework net8.0 -o Codebreaker.Data.SqlServer

要使用 EF Core 访问 SQL Server,请添加 Microsoft.EntityFrameworkCore.SqlServer NuGet 包。此项目还依赖于 Codebreaker.GameAPIs.Models 项目。

为 SQL Server 创建 EF Core 上下文

如下代码片段所示,使用实现 GamesSqlServerContext 类的 EF Core 上下文指定数据库映射:

Codebreaker.Data.SqlServer/GamesSqlServerContext.cs

public class GamesSqlServerContext(DbContextOptions<GamesSqlServerContext> options) : DbContext(options), IGamesRepository
{
  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    modelBuilder.HasDefaultSchema("codebreaker");
    modelBuilder.ApplyConfiguration(new GameConfiguration());
    modelBuilder.ApplyConfiguration(new MoveConfiguration());
    // code removed for brevity
  }
  public DbSet<Game> Games => Set<Game>();
  public DbSet<Move> Moves => Set<Move>();
  // code removed for brevity
}

EF Core 上下文类需要从 DbContext 基类派生。使用来自 DI 容器的上下文,可以在上下文外部配置连接字符串。这需要使用带有 DbContextOption 参数的构造函数,该参数需要传递给基类。

GamesSqlServerContext 类实现了我们在 第二章 中定义的 IGamesRepository 接口,以便 GamesService 类使用。在 第二章 中,我们使用 GamesMemoryRepository 内存存储库类实现了此接口。EF Core 上下文类通过实现相同的接口支持存储库模式。这样,我们可以通过使用 GamesSqlServerContext 来轻松切换内存存储库。

覆盖的 OnModelCreating 方法允许自定义将模型类型映射到数据库。对于 SQL Server,默认架构名称是 dbo。这可以通过调用 modelBuilder.HasDefaultSchema 来更改。

为了减少OnModelCreating方法的复杂性,创建了GameConfigurationMoveConfiguration类来使用GameMove类型进行自定义映射。

对于上下文类还需要的一件事是,允许使用属性来访问映射的数据库表,对于类型为DbSet<TEntity>的属性。

备注

创建codebreaker解决方案经历了几个迭代。一次,使用了一个抽象基类和泛型派生类来支持所有不同的游戏类型。使用 EF Core,也可以映射继承,这同样适用于 JSON 序列化和 OpenAPI 定义。EF Core 可以将继承树映射到单个表(table-per-hierarchyTpH),每个类型一个表(table-per-typeTpT),以及每个具体类型一个表(table-per-concrete-typeTpC)。

为了规划可能永远不会需要的未来功能,而不是创建一个复杂的类层次结构,通常最好将模型类型保持得尽可能简单,这取决于当前版本所需的内容。复杂的模型设计会在多个地方增加复杂性。

现在定义的Game类作为数据持有者满足了一组不同的Game类型的要求。功能被抽象化,并通过analyzers库执行,该库仅使用合约来访问游戏。

由于微服务被用于较小的范围内,KISS原则(Keep It Simple, Stupid)不仅能帮助减少需要完成的工作,还能提高性能。

自定义简单属性的映射

使用ApplyConfiguration与上下文配置应用时,需要实现IEntityTypeConfiguration泛型接口的配置类来指定Game类的映射。

Codebreaker.Data.SqlServer/Configuration/GameConfiguration.cs

internal class GameConfiguration : IEntityTypeConfiguration<Game>
{
  public void Configure(EntityTypeBuilder<Game> builder)
  {
    builder.HasKey(g => g.Id);
    builder.Property(g => g.GameType).HasMaxLength(20);
    builder.Property(g => g.PlayerName).HasMaxLength(60);
    builder.Property(g => g.Codes).HasMaxLength(120);
    // code removed for brevity

通过实现此类,表的关键字被指定为映射到Id属性。这并不是必需的,因为约定规定,名为Id或以类名前缀 ID 的属性映射到主键。

流畅的 API HasMaxLength属性用于更改GameTypePlayerName属性的数据库类型。Codes属性不是一个简单的属性,但仍可以限制为 120 个字符的大小。

此配置应用于Games表是通过从上下文配置中调用ApplyConfiguration来实现的。

MoveConfiguration类为GuessPegsKeyPegs属性指定了类似的配置:

Codebreaker.Data.SqlServer/Configuration/MoveConfiguration.cs

internal class MoveConfiguration : IentityTypeConfiguration<Move>
{
  public void Configure(EntityTypeBuilder<Move> builder)
  {
    // code removed for brevity
    builder.Property(g => g.GuessPegs).HasMaxLength(120);
    builder.Property(g => g.KeyPegs).HasMaxLength(60);
  }
}

对于Move类型,阴影属性将在定义游戏和移动之间的关系部分中稍后指定。

创建值转换以映射复杂属性

为了允许映射 EF Core 不支持的数据类型,可以使用值转换。类型IDictionary<string, IEnumerable<string>>FieldValues属性不直接支持默认映射。在这个游戏中,这个值的实际内容并不大,也不需要在其中搜索。这使得我们可以将其映射到nvarchar类型的列。

实现有多种选项可用。我们将使用不同的选项与 SQL Server 和 Azure Cosmos DB 一起使用,但这两个选项都可以与这些提供者中的任何一个一起使用。

让我们看看数据看起来像什么的一个例子。让我们想象我们有一个如下所示的包含颜色和形状的字典:

Dictionary<string, IEnumerable<string>> input = new ()
{
  { "colors", ["Red", "Green", "Blue"] },
  { "shapes", ["Rectangle", "Circle"] }
};

这应该产生以下字符串:

var expected = "colors:Red#colors:Green#colors:Blue#shapes:Rectangle#shapes:Circle";

每个值都以前缀键开头。在源代码库中,你可以找到一个单元测试来检查这个转换的实现。

要将这个字典转换为字符串,实现了ToFieldsString扩展方法:

Codebreaker.Data.SqlServer/MappingExtensions.cs

public static class MappingExtensions
{
  public static string ToFieldsString(this IDictionary<string, 
IEnumerable<string>> fields)
  {
    return string.Join('#',
      fields.SelectMany(
        key => key.Value
          .Select(value => $"{key.Key}:{value}")));
  }
  // code removed for brevity
}

在实现中,使用 LINQ 的SelectMany方法,对于字典中的每个键,都会创建一个以键为前缀的值。

反向功能使用FromFieldsString方法将字符串转换为字典:

Codebreaker.Data.SqlServer/MappingExtensions.cs

public static IDictionary<string, IEnumerable<string>> 
FromFieldsString(this string fieldsString)
{
  Dictionary<string, List<string>> fields = new();
  foreach (var pair in fieldsString.Split('#'))
  {
    var index = pair.IndexOf(':');
    if (index < 0)
    {
      throw new ArgumentException($"Field {pair} does not contain ':' 
      delimiter.");
    }
    var key = pair[..index];
    var value = pair[(index + 1)..];
    if (!fields.TryGetValue(key, out List<string>? List))
    {
      list = [];
      fields[key] = list;
    }
    list.Add(value);
  }
  return fields.ToDictionary(
    pair => pair.Key,
    pair => (IEnumerable<string>)pair.Value);
}

在实现中,首先使用#分隔符将完整的字符串分割。每个结果字符串包含一个键和一个用:分隔的值。这些结果被添加到一个对中,最终返回一个字典。

这些方法现在用于Game类的配置:

Codebreaker.Data.SqlServer/Configuration/GameConfiguration.cs

public void Configure(EntityTypeBuilder<Game> builder)
{
  // code removed for brevity
  builder.Property(g => g.FieldValues)
    .HasColumnName("Fields")
    .HasColumnType("nvarchar")
    .HasMaxLength(200)
    .HasConversion(
      convertToProviderExpression: fields => fields.ToFieldsString(),
      convertFromProviderExpression: fields => fields.
        FromFieldsString(),
valueComparer: new ValueComparer<IDictionary<string, 
        Ienumerable<string>>>(
        equalsExpression: (a, b) => a!.SequenceEqual(b!),
hashCodeExpression: a => a.Aggregate(0, (result, next) => 
          HashCode.Combine(result, next.GetHashCode())),
snapshotExpression: a => a.ToDictionary(kv => kv.Key, kv => 
        kv.Value)));
}

使用 fluent API 的HasColumnNameHasColumnTypeHasMaxLength属性指定列名和数据类型。使用HasConversion方法将映射的类型转换为数据库表示。此方法有几个重载,用于不同的用例。在这里,第一个参数引用一个表达式,将.NET 属性类型转换为数据库类型,而第二个参数执行反向操作。在这里,我们调用了之前创建的扩展方法。第三个参数调用ValueComparer类的实例。这用于比较值的相等性。

定义游戏和移动之间的关系

在关系数据库中,Games表与Moves表有关联。一个游戏映射到一个移动列表。为了实现这一点,在Moves表中定义了一个名为GameId的外键,以引用Games表的主键:

Codebreaker.Data.SqlServer/Configuration/MoveConfiguration.cs

internal class MoveConfiguration : IEntityTypeConfiguration<Move>
{
  public void Configure(EntityTypeBuilder<Move> builder)
  {
    builder.Property<Guid>("GameId");
    builder.Property(g => g.GuessPegs).HasMaxLength(120);
    builder.Property(g => g.KeyPegs).HasMaxLength(60);
  }
}

使用EntityTypeBuilderMove类型调用,调用Property方法创建一个没有这个名称属性的Move类型。如果没有相同名称的属性,则需要指定类型,就像这里使用泛型参数所做的那样。

以下代码片段将这个关系映射到数据库表中:

Codebreaker.Data.SqlServer/GameConfiguration.cs

public void Configure(EntityTypeBuilder<Game> builder)
{
  builder.HasKey(g => g.Id);
  builder.HasMany(g => g.Moves)
    .WithOne()
    .HasForeignKey("GameId");
  // code removed for brevity
}

EF Core 支持一对一、一对多和多对多关系。对于游戏和移动,使用 HasManyWithOne 方法定义了一对多关系。HasForeignKey 方法指定 Move 类的 GameId 值以引用游戏记录的 ID。

在定义了类到表的映射后,让我们实现存储库的合约并添加迁移以创建数据库。

实现存储库合约

在上一章中,我们定义了 IGamesRepository 接口,并使用内存表示实现了它。现在,让我们实现这个接口以读取和写入 SQL Server 数据库。

添加和删除游戏

让我们将合约的 AddGameAsyncDeleteGameAsync 方法的实现添加到 GamesSqlServerContext 类中:

Codebreaker.Data.SqlServer/GameSqlServerContext.cs

public async Task AddGameAsync(Game game, CancellationToken cancellationToken = default)
{
  Games.Add(game);
await SaveChangesAsync(cancellationToken);
}
public async Task<bool> DeleteGameAsync(Guid id, CancellationToken 
cancellationToken = default)
{
  var affected = await Games
    .Where(g => g.Id == id)
    .ExecuteDeleteAsync(cancellationToken);
    return affected == 1;
}

使用 AddGameAsync 方法,传递的 Game 对象被添加到 EF Core 上下文的 Games 属性中,这标志着实体被更改跟踪器标记为 已添加SaveChangesAsync 方法在数据库中创建 INSERT 语句。

DeleteGameAsync 方法接收游戏 ID 参数。在这里,对匹配 ID 的记录调用 ExecuteDeleteAsync 方法。自 EF Core 7 以来,ExecuteDeleteAsyncExecuteUpdateAsync 方法不使用跟踪并直接执行 DELETEUPDATE 语句。当不需要更改跟踪时,这提高了性能。如果未找到要删除的记录,此方法返回 false

开始一个 6x4 游戏将创建以下 SQL 语句来存储游戏:

INSERT INTO [codebreaker].[Games] ([Id], [Codes], [Duration], [EndTime], [Fields], [GameType], [LastMoveNumber], [MaxMoves], [NumberCodes], [PlayerName], [StartTime], [Won])
      VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);

让我们使用下一个实现来设置一个移动。

更新游戏

当设置移动时,一些游戏信息,如最后移动编号,也会更新。添加移动和更新游戏的实现如下所示:

Codebreaker.Data.SqlServer/GameSqlServerContext.cs

public async Task AddMoveAsync(Game game, Move move, CancellationToken cancellationToken = default)
{
  Moves.Add(move);
  Games.Update(game);
  await SaveChangesAsync(cancellationToken);
}

使用 Add 方法将 Move 对象添加到上下文中,并使用 Update 方法添加 Game 对象。这样,通过调用 SaveChangesAsync 方法配置了更改跟踪器,从而创建 SQL 的 UPDATEINSERT 语句。

注意

默认情况下,一次 SaveChangesAsync 调用创建一个事务。如果更新游戏失败,则对移动进行回滚。如果您需要在单个事务中执行多个 SaveChangesAsync 实例,最简单的选项是使用环境事务(使用 System.Transactions 命名空间中的 TransactionScope 类)。

查询游戏

要检索游戏,我们需要实现 Getxx 方法。让我们从 GetGameAsync 开始,通过游戏 ID 获取游戏:

Codebreaker.Data.SqlServer/GamesSqlServerContext.cs

public async Task<Game?> GetGameAsync(Guid id, CancellationToken cancellationToken = default)
{
  var game = await Games
    .Include("Moves")
    .TagWith(nameof(GetGameAsync))
    .SingleOrDefaultAsync(g => g.Id == id, cancellationToken);
  return game;
}

GetGameAsync方法使用SingleOrDefaultAsync方法获取一个或零条记录。如果找不到游戏 ID,则返回null。在幕后,创建了一个使用SELECT TOP(2)的查询来检查此查询是否将返回多条记录。如果是这种情况,SingleOrDefaultAsync方法会抛出异常。

使用Include方法创建一个查询,该查询包含与返回查询相关的移动。在这里,使用 SQL 的LEFT JOIN语句来连接多个表。EF Core 将所有查询和更新写入日志输出。为了更好地查看哪些输出映射到哪些 LINQ 方法,可以使用TagWith方法。这个标签在日志输出中显示为一个标题。

注意

TagWith方法在调试和故障排除中非常有帮助。通过检查日志输出以查看发送的 SQL 查询,标签提供了一个快速查看此查询生成位置的方法。

以下代码片段显示了此查询的日志输出,包括标题:

-- GetGameAsync
SELECT [t].[ Id], [t].[Codes], [t].[Duration], [t].[EndTime], [t].[Fields], [t].[GameType], [t].[LastMoveNumber], [t].[MaxMoves], [t].[NumberCodes], [t].[PlayerName], [t].[StartTime], [t].[Won], [m].[Id], [m].[GameId], [m].[GuessPegs], [m].[KeyPegs], [m].[MoveNumber]
FROM (
  SELECT TOP(2) [g].[Id], [g].[Codes], [g].[Duration], [g].[EndTime], [g].[Fields], [g].[GameType], [g].[LastMoveNumber], [g].[MaxMoves], [g].[NumberCodes], [g].[PlayerName], [g].[StartTime], [g].[Won]
  FROM [codebreaker].[Games] AS [g]
  WHERE [g].[Id] = @__Id_0
) AS [t]
LEFT JOIN [codebreaker].[Moves] AS [m] ON [t].[Id] = [m].[GameId]
ORDER BY [t].[Id]

要按日期、玩家名称或其他查询选项进行查询,请将GamesQuery对象传递给GetGamesAsync方法:

Codebreaker.Data.SqlServer/GamesSqlServerContext.cs

public async Task<IEnumerable<Game>> GetGamesAsync(GamesQuery? gamesQuery, CancellationToken cancellationToken = default)
{
  IQueryable<Game> query = Games
    .TagWith(nameof(GetGamesAsync))
    .Include(g => g.Moves);
  if (gamesQuery.Date.HasValue)
  {
    DateTime begin = gamesQuery.Date.Value.ToDateTime(TimeOnly.
        MinValue);
    DateTime end = begin.AddDays(1);
query = query.Where(g => g.StartTime < end && g.StartTime > 
      begin);
  }
  if (gamesQuery.PlayerName != null)
    query = query.Where(g => g.PlayerName == gamesQuery.PlayerName);
  if (gamesQuery.GameType != null)
    query = query.Where(g => g.GameType == gamesQuery.GameType);
  if (gamesQuery.Ended)
  {
    query = query.Where(g => g.EndTime != null)
      .OrderBy(g => g.Duration);
  }
  else
  {
    query = query.OrderByDescending(g => g.StartTime);
  }
  query = query.Take(MaxGamesReturned);
  return await query.ToListAsync(cancellationToken);
}

此方法的实现使用IQueryable变量添加不同的 LINQ 查询方法。根据GamesQuery参数传递的值,除了OrderByOrderByDescending外,还会添加多个Where方法来定义结果的顺序。为了不返回所有玩过的游戏,只返回基于筛选器的第一个 500 个游戏。

通过传递玩家的名称和日期调用此方法会产生以下 SQL 查询:

SELECT [t].[Id], [t].[Codes], [t].[Duration], [t].[EndTime], [t].[Fields], [t].[GameType], [t].[IsVictory], [t].[LastMoveNumber], [t].[MaxMoves], [t].[NumberCodes], [t].[PlayerIsAuthenticated], [t].[PlayerName], [t].[StartTime], [m].[Id], [m].[GameId], [m].[GuessPegs], [m].[KeyPegs], [m].[MoveNumber]
FROM (
  SELECT TOP(@__p_3) [g].[Id], [g].[Codes], [g].[Duration], [g].[EndTime], [g].[Fields], [g].[GameType], [g].[IsVictory], [g].[LastMoveNumber], [g].[MaxMoves], [g].[NumberCodes], [g].[PlayerIsAuthenticated], [g].[PlayerName], [g].[StartTime]
  FROM [codebreaker].[Games] AS [g]
  WHERE [g].[StartTime] < @__end_0 AND [g].[StartTime] > @__begin_1 AND [g].[GameType] = @__gamesQuery_GameType_2
  ORDER BY [g].[StartTime] DESC
) AS [t]
LEFT JOIN [codebreaker].[Moves] AS [m] ON [t].[Id] = [m].[GameId]
ORDER BY [t].[StartTime] DESC, [t].[Id]

Include方法导致执行LEFT JOIN操作以访问Moves表。由于Take方法,使用SELECT TOP。多次调用 LINQ 的Where方法会导致WHERE子句。

配置用户密钥

要访问数据库,我们需要检索一些配置值。其中一些配置值是秘密,不应包含在源代码存储库中。在开发期间,您可以使用用户密钥。用户密钥与用户配置文件一起存储。

要初始化用户密钥,请使用以下.NET CLI 命令:

cd Codebreaker.AppHost
dotnet user-secrets init

这将在项目文件中创建一个UserSecretsId属性。因为所有用户密钥都存储在用户配置文件中,所以这个字符串用于区分多个应用程序的配置。

要使用密钥设置配置值,请使用dotnet user-secrets set命令:

dotnet user-secrets set Parameters:sql-password [enter the password]

使用我们使用的 SQL Server Docker 容器,对密码有一些要求。请注意,您不能使用简单的密码。需要匹配三到四个集合:大写字母、小写字母、十进制数字和符号。您可以通过检查日志输出来查看是否存在密码问题。

您还可以使用 Visual Studio 和一个带有 Visual Studio 的上下文菜单来配置用户密钥。

注意,默认情况下,从用户密钥中读取配置值的提供程序仅在使用配置的密钥 ID 且应用程序在Development环境中运行时使用。

注意

用户密钥在生产环境中不能使用。用户密钥的想法是不将密钥存储在推送到源代码仓库的配置文件中。每个在此项目上工作的开发者都需要配置密钥的配置值。在生产环境中,可以使用其他服务,如 Azure Key Vault。这将在第七章中介绍。

使用 SQL Server 配置应用程序模型

要运行 SQL Server,.NET Aspire 使得运行 Docker 容器变得简单。只需将以下代码添加到Codebreaker.AppHost项目中的应用程序模型中:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
var sqlServer = builder.AddSqlServer("sql", sqlPassword)
  .AddDatabase("CodebreakerSql", "codebreaker");

AddSqlServer方法添加一个 SQL Server 资源。使用此方法,在开发时间,使用 Docker 容器。在第五章中,我们将深入了解 Docker,并使用此 SQL Server Docker 容器添加更多配置。此资源的名称为 sql。可选地,可以将密码传递给AddSqlServer方法。如果使用资源名称后缀为-password(如我们所做)的配置参数值设置,则使用此password。否则,将生成一个随机的passwordAddDatabase方法使用第一个参数(资源名称)将数据库添加到资源中,该资源名称用作连接字符串名称,以及数据库名称。

为了让我们能够动态地在不同的游戏存储库之间进行选择,我们使用DataStore配置在应用程序启动时决定使用内存、SQL Server 还是 Azure Cosmos DB:

Codebreaker.AppHost/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Aspire.Hosting.Dcp": "Warning"
    }
  },
  "DataStore": "SqlServer"
}

根据您想要使用的数据库提供程序,根据需要更改值。

注意

第七章灵活配置,深入探讨了appsettings.json文件及其环境特定对应项的细节,以及存储配置值的其他选项,例如环境变量、程序参数和 Azure App Configuration 实例。在本章中,我们只需要配置appsettings.json中的设置以及受 Azure Cosmos DB 保护的用户密钥。

配置值是在AppHost项目的启动时检索的:

Codebreaker.AppHost/Program.cs

string dataStore = builder.Configuration["DataStore"] ??
  "InMemory";

如果值未配置,则默认使用我们在上一章中创建的内存提供程序。

现在,我们可以更改游戏 API 的依赖项:

Codebreaker.AppHost/Program.cs

builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithEnvironment("DataStore", dataStore)
  .WithReference(sqlServer);
// code removed for brevity

WithEnvironment方法为游戏 API 项目创建一个环境变量,该变量具有DataStore键和从配置中检索的值。WithReference方法引用 SQL Server 资源并创建一个用于连接字符串的环境变量。

接下来,让我们配置最小 API 项目以从AppHost项目检索配置值。

使用最小 API 项目配置 DI 容器

在模型映射到数据库完成,并且使用 Aspire AppHost 项目定义了资源依赖项之后,可以配置 DI 容器以使用 EF Core 上下文。

游戏 API 项目需要引用Codebreaker.Data.SqlServer项目和Aspire.Microsoft.EntityFrameworkCore.SqlServer NuGet 包。

使用以下代码片段检索DataStore的配置:

Codebreaker.GameAPIs/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  // code removed for brevity
  string? dataStore = builder.Configuration.
    GetValue<string>("DataStore");
  switch (dataStore)
  {
    case "SqlServer":
      ConfigureSqlServer(builder);
      break;
    default:
      ConfigureInMemory(builder);
      break;
  }
  builder.Services.AddScoped<IGamesService, GamesService>();
}

根据检索到的DataStore配置值,我们配置 Azure Cosmos DB、SQL Server 或我们在上一章中实现的内存存储库。

以下展示了从上一个switch/case语句中调用的 SQL Server 配置:

Codebreaker.GameAPIs/ApplicationServices.cs

static void ConfigureSqlServer(IHostApplicationBuilder builder)
{
  builder.AddDbContextObjectPool<IGamesRepository, GamesSqlServerContext>(options =>
  {
    var connectionString = builder.Configuration.
    GetConnectionString("CodebreakerSql") ?? throw new 
InvalidOperationException("Could not read SQL Server connection 
string");
    options.UseSqlServer(connectionString);
    options.UseQueryTrackingBehavior(
    QueryTrackingBehavior.NoTracking);
  }
  builder.EnrichSqlServerDbContext<GamesSqlServerContext>();
}

使用 .NET Aspire SqlServer EF Core 组件,我们可以调用AddSqlServerDbContext API 来配置 .NET Aspire 的 EF Core 上下文。然而,此 API 并未提供我们与不同数据库提供程序一起工作所需的灵活性级别。因此,我们改用 EF Core API,如AddDbContextAddDbContextPool来配置 EF Core 上下文,并通过使用EnrichSqlServerDbContext添加 Aspire 功能。AddDbContextObjectPool方法配置为使用 SQL Server EF Core 提供程序,传递连接字符串,该连接字符串通过AppHost项目传递,因此需要与 AppHost 项目中的顶级语句配置名称相匹配。

调用UseQueryTrackingBehavior方法在使用 EF Core 时增加了一个有趣的方面。默认情况下,所有查询都在 EF Core 上下文中跟踪,以便上下文了解更改。在 API 服务中,上下文会随着每个新的 HTTP 请求而新建。因此,对于每个上下文保持此跟踪状态是不必要的。添加和更新实体时,会显式使用AddUpdate方法。将查询跟踪行为设置为QueryTrackingBehavior.NoTracking将禁用所有查询的跟踪(除非使用AsTracking方法覆盖),从而减少了开销。

而不是默认关闭跟踪,您还可以使用AsNoTracking方法通过单个查询来关闭跟踪。

EnrichSqlServerDbContext方法添加了 Aspire 组件提供的健康检查、日志记录和遥测配置。日志记录和遥测配置在第十一章中介绍,健康检查在第十二章中介绍。

随着映射和存储库合约的实现,我们现在可以继续使用迁移创建数据库。

使用 EF Core 创建迁移

使用 EF Core,你可以使用 Database.EnsureCreatedAsync 上下文 API 方法创建数据库。然而,这并不考虑架构更改。随着时间的推移,数据库架构会因为新功能的添加而发生变化——最好是自动进行。

第八章 描述了如何自动将服务发布到测试和生产环境。因此,更新数据库也很重要。当数据库架构发生变化时,应该将更新发布到环境中。EF Core 提供了 migrations 来记录所有架构更改并程序化更新数据库架构。

接下来,让我们执行以下操作:

  1. 添加 .NET EF Core 工具

  2. 添加 EF Core 工具并创建初始迁移

  3. 更新模型并添加迁移

  4. 程序化更新数据库

添加 .NET EF Core 工具

如果你还没有安装 EF Core .NET 命令行工具,你可以使用 dotnet CLI 作为全局或本地工具来安装它。在这里,我们将它作为本地工具安装,以便在 Codebreaker.Data.SqlServer 项目中拥有这个工具的特定版本。

要安装本地工具,首先需要创建一个 tool-manifest 文件:

cd Codebreaker.Data.SqlServer
dotnet new tool-manifest

使用 tool-manifest 模板,dotnet new 命令会创建一个包含 dotnet-tools.json 文件的 .config 目录。这个清单文件将包含在项目工作中应该安装的所有工具。

一旦这个清单文件可用,我们就可以安装 dotnet-ef 工具:

dotnet tool install dotnet-ef

这个命令使用工具清单文件配置此工具并将其本地安装。如果你在项目文件夹内,而命令提示符的当前目录中安装了此工具的另一个版本,你将使用工具清单文件中指定的工具版本。

要使用工具清单文件安装和配置所有工具,可以使用 tool restore 命令:

dotnet tool restore

当你克隆包含工具清单文件的存储库时,可以使用 restore 命令。使用 dotnet tool restore,可以恢复项目中指定的所有工具。

让我们使用这个工具为实际上下文创建一个初始迁移:

dotnet ef migrations add InitGames -s ..\Codebreaker.GameAPIs

migrationsdotnet ef 工具的一个命令。使用 add,可以添加一个新的迁移,其名称跟随 add 命令(这里,InitGames)。-s(或 --startup-project)选项指定了配置了 DI 容器和数据库连接字符串的 EF Core 上下文的项目,这与实现 EF Core 上下文的项目不同(Codebreaker.Data.CosmosCodebreaker.Data.SqlServer);这就是为什么需要这个选项。

注意

如果创建迁移失败,请检查错误信息。可能的一个错误是你未能指定映射,这里的错误信息非常详细。在处理问题时,你可以暂时忽略模型属性以查看错误是否真的基于属性映射。

在这个工具成功运行之后,你将看到与项目一起的 Migrations 文件夹。这个文件夹包含数据库当前状态的快照,包括所有表映射、属性映射和关系。这个类是根据 EF Core 上下文后缀名为 ModelSnapshot 命名的;例如,GameSqlServerContextModelSnapshot

每次添加新的迁移时,快照将被更新,并创建一个新的 Migration 派生类,该类包含基于前一个迁移的所有模式更改。迁移名称以时间前缀命名。生成的类包含一个 Up 方法,当迁移应用到 SQL Server 数据库时将被调用,以及一个 Down 方法,当迁移从数据库中删除时将被调用。

接下来,我们将使用 dotnet ef 工具将迁移应用到数据库上,并在数据库尚不存在时创建数据库。这可以通过使用 dotnet ef database update 命令来完成:

dotnet ef database update -s ..\Codebreaker.GameAPIs.

现在这个命令现在使用启动项目的连接字符串来将迁移应用到数据库上。使用迁移创建数据库时,你会看到所有游戏和移动表被创建——包括 _EFMigrationsHistory 表。阅读这个表的内容,你会看到应用到数据库上的所有迁移名称。当使用迁移对数据库模式进行另一次更新时,会检查这些信息。

注意

有一些情况下,在创建数据库失败的同时,迁移创建却成功了。映射错误也可能是这里的原因。再次检查错误信息可以提供关于失败原因的详细信息。

程序化创建或更新数据库

代替使用命令行来应用迁移,可以通过调用 EF Core 上下文并使用 context.Database.MigrateAsync 来程序化地启动迁移。让我们通过 CreateOrUpdateDatabaseAsync 方法实现这个功能,该方法从应用程序启动代码中被调用,以便易于使用解决方案:

Codebreaker.GameAPIs/ApplicationServices.cs

public static async Task CreateOrUpdateDatabaseAsync(this WebApplication app)
{
  var dataStore = app.Configuration["DataStore"] ?? "InMemory";
  if (dataStore == "SqlServer")
  {
    try
    {
      using var scope = app.Services.CreateScope();
      var repo = scope.ServiceProvider.GetRequiredService<IGamesRepository>();
      if (repo is GamesSqlServerContext context)
      {
        await context.Database.MigrateAsync();
        app.Logger.LogInformation("SQL Server database updated");
      }
    }
    catch (Exception ex)
    {
      app.Logger.LogError(ex, "Error updating database");
      throw;
    }
  }
}

在实施过程中,我们检查解决方案是否配置为使用 SQL Server。在这种情况下,将调用 MigrateAsync 方法来更新数据库到最新版本。

使用 codebreaker 解决方案,这确实很方便 - 运行解决方案,一切就绪,包括数据库。从安全角度来看,在生产环境中运行的服务不应有一个允许更改模式的数据库连接字符串。相反,可以使用一个单独的程序来更新数据库。这可以通过 GitHub 动作自动部署来调用。使用 dotnet ef 工具,你甚至可以创建一个用于更新数据库模式的独立应用程序:dotnet ef migrations bundle 创建了一个包含 .NET 运行时的应用程序,因此你可以从没有安装 .NET 运行时的客户端启动此应用程序。你也可以创建一个 SQL 脚本来启动迁移,如果数据库管理员更喜欢这种方式:dotnet ef migrations script

接下来,让我们对模型进行更改,这将影响数据库模式。

更新数据库模式

第九章 中,我们将区分匿名用户和认证用户。使用这种方式,当游戏由认证用户玩时,游戏信息将被存储。为此,我们将在 Game 类中添加一个 PlayerIsAuthenticated 标志:

Codebreaker.GameAPIs.Models/Game.cs

public class Game(
  Guid id,
  string gameType,
  string playerName,
  DateTime startTime,
  int numberCodes,
  int maxMoves) : IGame
{
  public Guid Id { get; private set; } = id;
  public string GameType { get; private set; } = gameType;
  public string PlayerName { get; private set; } = playerName;
  public bool PlayerIsAuthenticated { get; set; } = false;
  // code removed for brevity

这个新属性没有被定义为从数据库中忽略。为了更新数据库模式,我们添加一个新的迁移:

cd Codebreaker.Data.SqlServer
dotnet ef migrations add AddPlayerIsAuthenticated -s ..\Codebreaker.GameAPIs

新迁移命名为 AddPlayerIsAuthenticated。此更改更新了 Migrations 文件夹中的快照并添加了一个新的迁移,如以下代码片段所示:

Codebreaker.Data.SqlServer/Migrations/ 20231225095931_AddPlayerIsAuthenticated.cs

public partial class AddPlayerIsAuthenticated : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
  {
    migrationBuilder.AddColumn<bool>(
      name: "PlayerIsAuthenticated",
      schema: "codebreaker",
      table: "Games",
      type: "bit",
      nullable: false,
      defaultValue: false);
    }
  protected override void Down(MigrationBuilder migrationBuilder)
  {
    migrationBuilder.DropColumn(
      name: "PlayerIsAuthenticated",
      schema: "codebreaker",
      table: "Games");
  }
}

使用 Up 方法,从上一个版本更新数据库时,会将列添加到数据库模式中(AddColumn),而 Down 方法会移除该列(DropColumn)。

在开发过程中,你可能经常更新模式并创建许多迁移。在发布应用程序的新版本之前,将迁移合并为一个是一个好主意。请注意生产或预发布环境中安装的版本。你应该保留已部署的迁移。仅在你开发环境中使用过的迁移可以使用 dotnet ef migrations remove(可能多次调用以始终删除最后一个迁移)来移除 - 最后,通过一次调用 dotnet ef migrations add <迁移名称>,这将创建一个包含自上次迁移以来所有模式更改的迁移。

现在,让我们使用 SQL Server 运行解决方案。

使用 SQL Server 运行应用程序

现在启动主机应用程序,不仅游戏 API 服务正在运行,而且 SQL Server 在 Docker 容器中运行,如图 图 3.2 所示:

图 3.2 – 与 SQL Server 的 .NET Aspire 资源

图 3.2 – 与 SQL Server 的 .NET Aspire 资源

你可以通过访问 OpenAPI 端点描述来开始游戏。请确保检查游戏 API 服务的详细信息。详细信息提供了关于资源、端点和环境变量的信息,如图 3.3 所示:

图 3.3 – 环境变量

图 3.3 – 环境变量

为此服务设置环境变量后,检查 DataStoreConnectionStrings__CodebreakerSql,这些由 AppHost 项目设置。

尝试使用 HTTP 文件玩游戏。验证记录如何添加到 SQL Server 数据库。然而,当你停止项目并再次运行应用程序时,数据库将从全新状态创建。使用 Docker,我们需要卷来映射 Docker 容器外的存储。这将在 第五章 中介绍。

然后,让我们转向 Azure Cosmos DB。

使用 EF Core 与 Azure Cosmos DB

使用 Azure Cosmos DB,Microsoft 提供了具有多个 API 的不同数据库,这些 API 都使用了相同的底层基础设施。这些数据库产品中的大多数都是针对不同目的的 NoSQL 数据库。Azure Cosmos DB 提供了一个可以与 MongoDB API 一起访问的 JSON 文档存储。Apache Cassandra API 提供了一个宽列存储,其中每一行可以有不同的列。可以使用 Apache Gremlin 查询语言来访问数据库的图版本。这对于使用顶点和边查询关系非常有用。Azure Cosmos DB for PostgreSQL 是一个使用相同基础设施进行全球数据库网络读写的高性能分布式关系数据库。

对于 codebreaker 解决方案,我们将使用 Azure Cosmos DB for NoSQL。在这里,有一个 EF Core 提供器可用。这允许我们使用与 SQL Server 相同的 API,但映射将不同。

将游戏和动作写入 Azure Cosmos DB,我们需要执行以下操作:

  1. 创建一个类库项目

  2. 创建 EF Core 上下文

  3. 创建一个值转换器来映射复杂类型

  4. 创建嵌入式实体

  5. 实现存储库合约

  6. 配置应用程序模型

  7. 配置 DI 容器

我们在第一次应用迁移时创建了一个 SQL Server 数据库。使用 Azure Cosmos DB,迁移不可用且不需要。由于存储的是 JSON 文档,我们在写入数据方面非常灵活。没有表和表之间关系概念——我们只是在容器中存储 JSON 文档。一个容器可以保存不同类型的数据。容器可以用作扩展单元,但你也可以选择指定整个数据库的扩展,并与数据库中的不同容器共享 请求单位RU/s)。

使用容器时,你还需要了解分区。分区用于扩展容器以提升性能。在指定分区之前,你需要了解一些 Azure Cosmos DB 的属性:

  • 分区限制为 20 GB 存储空间。容器的大小限制为 1 TB。

  • 在数据库中写入时,事务只能跨越写入单个分区。如果事务内需要写入不同的数据,这些数据应使用相同的分区键。

  • 分区的最大限制为 10,000 RU/s。使用容器时,限制为 1,000,000 RU/s(在无服务器模式下,容器 RU/s 限制为 20,000)。为了实现数据并行读取的最佳性能,应使用不同的分区键。

  • 分区键的最大长度为 2048 字节。

  • 存储的单个项的最大大小为 2 MB。

  • 分区键的不同值没有限制。

我们将使用游戏 ID 作为分区键。游戏是独立于其他游戏创建和更新的。没有必要在一个事务中写入多个游戏。使用具有多区域写入配置的 Azure Cosmos DB 可以让我们以高性能从不同的 Azure 区域创建游戏。这使得游戏的Id值成为分区键的好候选。

根据这些信息,我们将接下来创建一个类库。

为 EF Core 创建一个 NoSQL 类库项目

与创建 SQL Server 库类似,我们使用库来访问 Azure Cosmos DB:

dotnet new classlib --framework net8.0 -o Codebreaker.Data.Cosmos

该库使用了Microsoft.EntityFrameworkCore.Cosmos NuGet 包——当然,还需要引用Codebreaker.GameAPIs.Models项目。

为 Azure Cosmos DB 创建一个 EF Core 上下文

让我们创建一个上下文类来访问 Azure Cosmos DB,如下面的代码片段所示:

Codebreaker.Data.Cosmos/GamesCosmosContext.cs

public class GamesCosmosContext(DbContextOptions<GamesCosmosContext> optoins) : DbContext(options), IGamesRepository
{
  private const string PartitionKey = nameof(PartitionKey);
  private const string ContainerName = "GamesV3";
  private const string DiscriminatorValue = "GameV3";
  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    modelBuilder.HasDefaultContainer(ContainerName);
    var gameModel = modelBuilder.Entity<Game>();
    gameModel.Property<string>(PartitionKey);
    gameModel.HasPartitionKey(PartitionKey);
    gameModel.HasKey(nameof(Game.Id), PartitionKey);
    gameModel.HasDiscriminator<string>("Discriminator")
      .HasValue<Game>(DiscriminatorValue);
    // code removed for brevity
  }
  public DbSet<Game> Games => Set<Game>();
public static string ComputePartitionKey(Game game) => 
    game.GameId.ToString();
  public void SetPartitionKey(Game game) =>
    Entry(game).Property(PartitionKey).CurrentValue =
      ComputePartitionKey(game);
  // code removed for brevity

与之前类似,自定义上下文类从DbContext基类派生,并定义了一个带有上下文选项的构造函数,这允许我们使用连接字符串配置 DI 容器。差异从现在开始。在 SQL Server 中,我们定义了默认模式名称。在 Azure Cosmos DB 中,这不可用,但我们可以使用HasDefaultContainer方法定义默认容器名称。如果你有不应与默认容器一起存储的实体,可以使用ToContainer方法配置它们使用不同的容器。之前讨论的分区键通过调用HasPartitionKey方法进行配置。使用SetPartitionKeyComputePartitionKey方法,分区键被配置为与游戏 ID 相同的影子属性

虽然Id是分区键的好选择,但可能存储在相同容器中的其他类型可能没有Id值。因此,对于分区键,使用PartitionKey。对于游戏,Id值将被映射到PartitionKey

将不同类型的对象写入单个容器需要使用判别器值。默认情况下,判别器值是类的名称。通过调用 HasDiscriminator 方法,可以通过指定 Discriminator 隐藏属性来覆盖默认的判别器配置。对于 Game 类型,写入 GameV3 值。这使我们能够区分存储不兼容新版本的游戏对象。

Azure Cosmos DB 存储 JSON 文档,因此只需指定 Game 类型具有 DbSet 属性,而不是 Move 类型,就像我们在 SQL Server 中做的那样。定义字符串属性的最大大小也不需要,因为没有模式描述这一点。

创建一个转换复杂类型的值转换器

在 SQL Server 部分,我们将 Idictionary 类型的属性转换为字符串,通过传递表达式到 HasConversion 方法将字典转换为字符串。对于 Azure Cosmos DB 也可以这样做,但现在我们将创建一个从 ValueConverter 派生的类,并将字典转换为 JSON,如下代码片段所示:

Codebreaker.Data.Cosmos/Utilities/FieldValueValueConverter.cs

internal class FieldValueValueConverter : ValueConverter<IDictionary<string, IEnumerable<string>>, string>
{
  static string GetJson(IDictionary<string, IEnumerable<string>> 
values) => return JsonSerializer.Serialize(values);
  static IDictionary<string, IEnumerable<string>> GetDictionary(string 
json) => JsonSerializer.Deserialize<IDictionary<string, 
IEnumerable<string>>>(json) ??
      new Dictionary<string, IEnumerable<string>>();
  public FieldValueValueConverter() : base(
    convertToProviderExpression: v => GetJson(v),
    convertFromProviderExpression: v => GetDictionary(v))
  { }
}

EF Core 值转换器从 ValueConverter 基类派生,并使用泛型参数指定要转换的类型。使用 FieldValues 属性,这是 IDictionary<string, IEnumerable<string>>。基类的构造函数需要参数来转换到数据库数据类型和从数据库数据类型转换。在实现中,使用 System.Text.Json 命名空间中的 JsonSerializer 类来进行序列化和反序列化。

现在将此值转换器的实例传递给具有 FieldValues 属性配置的 HasConversion 方法的重载:

Codebreaker.Data.Cosmos/GamesCosmosContext.cs

public class GamesCosmosContext(DbContextOptions<GamesCosmosContext> options) : DbContext(options), IGamesRepository
{
  private static FieldValueValueConverter s_fieldValueConverter = new();
  private static FieldValueComparer s_fieldValueComparer = new();
  protected override void OnModelCreating(ModelBuilder modelBuilder)
  {
    // code removed for brevity
    gameModel.Property(g => g.FieldValues)
.HasConversion(s_fieldValueConverter, s_fieldValueComparer);
  }

FieldValueValueConverter 类似,创建了一个 FieldValueComparer 实例。创建了这两个类型的实例以传递给 HasConversion 方法。

在创建关系型数据库的映射时,需要配置的内容更多。我们通过为每个映射的表创建配置类来减少数据上下文中的代码。在这里这样做不值得。完整的 EF Core 配置以及仓库接口的实现都是通过上下文类完成的。

创建嵌入式实体

那么,游戏和移动之间的关系如何?EF Core 定义了 OwnsOneOwnsMany 方法来定义一个拥有关系。在关系型数据库中,OwnsOne 将拥有类型的列添加到拥有类型中。在 Azure Cosmos DB 提供程序中,从 gameModel 调用 OwnsMany 并引用 Moves 属性,移动将被存储在游戏中的 JSON 内。

自从 EF Core 7 开始,这是与 Azure Cosmos DB 提供程序相关的实体类型的默认行为。因此,无需配置即可实现此功能。

实现仓库合约

在实现存储库时,与 SQL Server 的实现有许多相似之处,但由于存储方式不同,需要进行一些更改。在这里,我们将专注于差异:

Codebreaker.Data.Cosmos/GamesCosmosContext.cs

public async Task AddGameAsync(Game game, CancellationToken cancellationToken = default)
{
  SetPartitionKey(game);
  Games.Add(game);
  await SaveChangesAsync(cancellationToken);
}

在添加或更新游戏时,需要设置分区键。除此之外,代码与 SQL Server 相同。

在运行时发生的情况有所不同。而不是使用 SQL INSERTUPDATE 语句,Azure Cosmos DB 提供程序执行 CreateItemReplaceItem 函数。当你检查日志输出时,你可以看到每个语句所需的 RUs 数量。

之前定义的 GetGamesAsync 方法也适用于 Cosmos DB 提供程序。这是创建的查询:

SELECT c
FROM root c
WHERE (((c["Discriminator"] = "Game") AND ((c["StartTime"] < @__end_0) AND (c["StartTime"] > @__begin_1))) AND (c["GameType"] = @__gamesQuery_GameType_2))
ORDER BY c["StartTime"] DESC
OFFSET 0 LIMIT @__p_3

将此查询与查询 SQL Server 数据库的查询进行比较,使用 Cosmos DB,它要简单得多:不需要表连接。这个查询的一个有趣部分是对 Discriminator 的过滤。默认情况下,存储在容器中的每个对象都有一个包含类型名称的 Discriminator 过滤器,这允许在容器中存储不同的文档。对特定类型的查询包括 Discriminator 过滤器。

如果你只在一个容器中存储同一类型的对象,你可以使用 HasNoDiscriminator 模型定义方法关闭带有 Discriminator 过滤器的存储。

注意,并非所有 LINQ 查询都能成功从 Cosmos DB 提供程序转换。例如,IncludeJoin 方法无法转换。虽然 Include 方法曾与 SQL Server 一起使用,用于包含游戏查询中的移动,但在存储在游戏中的 JSON 文档中,这并不是必需的。由于没有 NoSQL 的表,因此通常也不需要 Join。如果你想要组合不同对象类型的列表,请创建两个查询并将结果与调用者合并。

配置应用程序模型以使用 Azure Cosmos DB

在 SQL Server 中,我们一直使用 SQL Server 的 Docker 容器。使用 Azure Cosmos DB,也有可用的 Docker 容器。然而,在 Cosmos DB 中,这只是一个模拟器,不应用于生产。在 第五章 中,我们将使用在 Microsoft Azure 上运行的数据库。

要将 Azure 资源添加到 AppHost 项目中,我们需要添加 Aspire.Hosting.Azure NuGet 包。让我们将 Azure Cosmos DB 添加到 Aspire AppHost 应用程序模型中:

Codebreaker.AppHost/Program.cs

var cosmos = builder.AddAzureCosmosDB("codebreakercosmos")
  .AddDatabase("codebreaker");
  .RunAsEmulator();
  builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithEnvironment("DataStore", dataStore)
  .WithReference(cosmos)
  .WithReference(sqlServer);

调用AddAzureCosmosDB方法注册 Azure Cosmos DB 资源。codebreakercosmos是资源名称,需要小写,并用作连接 Azure Cosmos DB 账户的连接字符串。在这里,数据库名称不是连接字符串的一部分。数据库通过调用AddDatabase方法指定,并定义了数据库名称。RunAsEmulator方法指定一个 Docker 镜像,在 Docker 容器内运行数据库,但仅限于开发环境。与之前类似,Cosmos DB 资源从游戏 API 项目引用,并将带有codebreakercosmos键的连接字符串转发到该项目。请注意,它不是传递给AddDatabase的名称(SQL Server 的情况),因为数据库名称不是连接字符串的一部分。

配置 DI 容器

要配置 DI 容器与游戏 API 项目,我们必须添加Aspire.Microsoft.EntityFrameworkCore.Cosmos NuGet 包以使用此 Aspire 组件。DI 容器的配置已经与关系型数据库的配置一起准备好了。现在所需做的就是添加 Cosmos DB EF Core 上下文,如下面的代码片段所示:

Codebreaker.GameApis/ApplicationServices.cs

static void ConfigureCosmos(IHostApplicationBuilder builder)
{
  builder.AddDbContext<IGamesRepository, GamesCosmosContext>(options =>
  {
    var connectionString = builder.Configuration.
      GetConnectionString("codebreakercosmos") ??
      throw new InvalidOperationException("Could not read Cosmos 
      connection string");
    options.UseCosmos(connectionString, "codebreaker");
    options.UseQueryTrackingBehavior(
    QueryTrackingBehavior.NoTracking);
  });
  builder.EnrichCosmosDbContext<GamesCosmosContext>();
}

.NET Aspire Azure Cosmos DB EF Core 组件提供了AddCosmosDbContext方法,但与之前类似,因为我们需要注册IGamesRepository接口,所以我们使用 EF Core 的AddDbContext方法,并通过调用EnrichCosmosDbContext方法添加 Aspire 组件的功能。UseCosmos方法注册使用 EF Core 提供程序来连接 Azure Cosmos DB,并分配从应用程序模型定义传递的连接字符串。

要创建数据库和 Cosmos DB 容器,我们在CreateOrUpdateDatabaseAsync方法中添加else部分:

Codebreaker.GameApis/ApplicationServices.cs

public static async Task CreateOrUpdateDatabaseAsync(this WebApplication app)
{
  // code removed for brevity
  else if (dataStore == "Cosmos")
  {
    try
    {
      using var scope = app.Services.CreateScope();
      var repo = scope.ServiceProvider.
        GetRequiredService<IGamesRepository>();
      if (repo is GamesCosmosContext context)
      {
        bool created = await context.Database.EnsureCreatedAsync();
        app.Logger.LogInformation("Cosmos database created: 
          {created}", created);
      }
    }
    catch (Exception ex)
    {
        app.Logger.LogError(ex, "Error updating database");
      throw;
    }
  }
}

Database.EnsureCreatedAsync方法创建数据库和具有指定分区键的 Azure Cosmos DB 容器。

配置就绪后,让我们像之前一样使用 SQL Server 启动应用程序,并在您设置移动时检查存储的游戏与您的 Azure Cosmos DB 数据库。只需确保DataStore配置设置为正确的数据库类型。使用 HTTP 文件时,不要忘记使用创建游戏后返回的游戏 ID。

摘要

在本章中,我们改用持久化存储,使用 API 服务结合关系型数据库和 NoSQL 数据库。我们创建了数据库上下文,将GameMove类型映射到关系型数据库的表中,以及 NoSQL 数据库的 JSON 文档中——两者都使用 EF Core。

要选择在您的环境中使用哪个数据库,如果您有具有固定模式的关系型数据,请选择 SQL Server。如果您的场景中不需要模式,并且数据经常发生变化,NoSQL 数据库可能是最佳选择。

您学习了如何映射对象以及如何根据对象模型处理特殊的映射要求。使用关系数据库,您还学习了如何创建迁移来更新数据库架构以及最初创建数据库。

您已经学习了如何使用指定了AppHost项目的.NET Aspire 应用程序模型来使用数据库资源。

在开始下一章之前,玩另一轮游戏是当之无愧的。只需使用 HTTP 文件让您的游戏运行。根据当前实现的状态,在您重启服务后,游戏可以继续运行——游戏和移动是持久化的。

在下一章中,我们创建一个库,该库可以被客户端应用程序用来调用 Web API,从而使玩游戏变得更加方便。

进一步阅读

要了解更多关于本章讨论的主题,您可以参考以下链接:

第四章:为客户端应用程序创建库

通过上一章的更新,游戏 API 现在可供使用——包括对数据库的访问。在本章中,我们将创建一个.NET 库,该库可以被所有.NET 客户端应用程序用来访问服务。我们不需要为每个客户端应用程序创建 HTTP 请求,而是创建一个可以共享的库。

在本章中,你将执行以下操作:

  • 创建一个发送 HTTP 请求的库

  • 使用库创建一个客户端控制台应用程序来玩游戏

  • 使用 Microsoft Kiota 工具根据 OpenAPI 文档生成代码

技术要求

本章的代码可以在 GitHub 仓库github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure中找到。源代码文件夹ch04包含本章的代码示例。

上一章的服务实现存储在server文件夹中。与上一章相比,模型只有细微的改动。模型包含注解(RequiredMinLengthMaxLength属性)。这些信息会出现在 OpenAPI 文档中,并在创建客户端时使用。你可以使用Chapter04.server.sln文件打开并运行解决方案。在运行客户端应用程序时,你需要启动服务。根据你的偏好,你需要配置 SQL Server 或 Azure Cosmos DB,如前一章所述。你也可以使用内存中的存储库,这样你就不需要运行数据库。根据你的需求,使用appsettings.json文件更改配置。

新代码在client文件夹中。在这里,你可以找到以下项目:

  • Codebreaker.GameApis.Client:这是一个包含自定义模型和GamesClient类的库,该类向服务发送 HTTP 请求

  • Codebreaker.Client.Console:这是一个新的控制台应用程序,它引用客户端库,可以用来玩游戏

  • Codebreaker.GamesApis.Kiota:这是一个客户端库,可以用作Codebreaker.GameApis.Client的替代品,并使用生成的代码

  • Codebreaker.Kiota.Console:这是一个使用 Kiota 客户端库的控制台应用程序

创建一个创建 HTTP 请求的库

在一个微服务团队中,一个好的做法是团队不仅负责开发包括数据库访问代码在内的完整服务,而且至少负责一个客户端应用程序。在传统的开发团队中,客户端和服务器开发通常分散在不同的团队中。问题在于客户端和服务最好是在协作中创建。创建客户端时,你会发现服务 API 中缺少答案。在这里,客户端和服务开发者之间的快速沟通有助于解决问题。

为客户端创建库允许我们从所有.NET 客户端重用此功能;您可以使用任何.NET 客户端技术创建客户端,例如 Blazor、WinUI、.NET MAUI 以及其他技术。在本章中,我们将仅创建一个控制台应用程序,但您可以在 GitHub 组织github.com/codebreakerapp中找到使用 Blazor、WinUI、.NET MAUI、WPF 和 Platform Uno 的客户端。

要创建供客户端应用程序使用的库,我们执行以下操作:

  1. 创建一个支持多目标平台的库以支持不同.NET 版本的客户端

  2. HttpClient注入与客户端交互的主类中。

  3. 向游戏服务发送 HTTP 请求。

  4. 创建一个 NuGet 包以方便使用。

创建支持多目标平台的库

我们使用dotnet CLI 创建客户端库:

dotnet new classlib --framework net8.0 -o Codebreaker.GameApis.Client

使用这个库,我们需要模型类型以在客户端和服务之间传输数据,以及一个执行 HTTP 请求以调用服务 API 的客户端类。

为了支持使用不同.NET 版本的客户端,库被配置为支持多目标:

Codebreaker.GameAPIs.Client/Codebreaker.GameAPIs.Client.csproj

<PropertyGroup>
  <TargetFrameworks>net7.0;net8.0</TargetFrameworks>
  <!-- code removed for brevity -->
</PropertyGroup>

与默认入口TargetFramework不同,我们附加了一个s以包含框架列表。添加多个框架时,创建 NuGet 包时会添加多个二进制文件。对于您来说,创建一个可以由.NET 7 和.NET 8 客户端使用的.NET 6 库可能是可以的。使用多个框架,您可以基于客户端版本创建优化后的代码。

以下代码片段展示了优化示例:

Codebreaker.GameAPIs.Client/Models/CreateGameRequest.cs

#if NET8_0_OR_GREATER
[JsonConverter(typeof(JsonStringEnumConverter<GameType>))]
#else
[JsonConverter(typeof(JsonStringEnumConverter))]
#endif
public enum GameType
{
    Game6x4,
    Game6x4Mini,
    Game8x5,
    Game5x5x4,
}

泛型属性是 C# 11 中引入的新特性。JsonStringEnumConverter的泛型类型从.NET 8 开始引入。这个泛型版本支持原生 AOT 编译。较旧版本和非泛型的JsonStringEnumConverter使用反射。使用 C#预处理器指令#if和预定义符号NET8_0_OR_GREATER,根据框架版本编译不同的代码。

客户端和服务之间的模型主要相同。在这里,您可能会选择将模型从仅服务库移动到被客户端和服务应用程序共同引用的通用库。然而,基于客户端技术,您可能还有其他基于验证和变更通知的要求。对于客户端库的模型,您可以实现INotifyPropertyChanged接口,该接口被不同的客户端技术用于在变更通知时自动更新用户界面。在本章的后面部分,我们还将从在第二章中创建的 OpenAPI 文档创建库,这可能是不创建共享库的另一个原因。

CreateGameRequest是我们启动游戏时需要发送请求的类:

Codebreaker.GameAPIs.Client/Models/CreateGameRequest.cs

[JsonConverter(typeof(JsonStringEnumConverter<GameType>))]
public enum GameType
{
  Game6x4,
  Game6x4Mini,
  Game8x5,
  Game5x5x4,
}
public record class CreateGameRequest(
  GameType,
  string PlayerName);

CreateGameRequest 包含 GameTypePlayerName 属性,这些属性是开始游戏所必需的。GamesQuery 类用于发送不同的查询参数,以根据查询检索过滤后的游戏列表:

Codebreaker.GameAPIs.Client/Models/GamesQuery.cs

public record class GamesQuery(
  GameType? GameType = default,
  string? PlayerName = default,
  DateOnly? Date = default,
  bool? Ended = false)
{
  public string AsUrlQuery()
  {
    var queryString = "?";
    if (GameType != null)
    {
      queryString += $"gameType={GameType}&";
    }
    if (PlayerName != null)
    {
      queryString += $"playerName={Uri.EscapeDataString(PlayerName)}&";
    }
    // code removed for brevity
    queryString = queryString.TrimEnd('&');
    return queryString;
  }
}

AsUrlQuery 方法将记录的属性转换为根据游戏 API 服务指定的 HTTP 查询参数,并返回组合的查询字符串。你可能考虑将此方法添加到 Game 类中。Game 类仅定义表示游戏的 数据结构。GamesQuery 类控制其数据如何转换为 URL 查询字符串。

此外,CreateGameResponseUpdateGameRequestUpdateGameResponseGameMove 是使用此库所必需的。请使用 GitHub 仓库检查这些类型。

注入 HttpClient

我们接下来创建的 GamesClient 类用于向游戏服务发送请求。要使用 HttpClient 类,可以注入此类的对象。在使用库的应用程序中,此 HttpClient 需要配置基本地址(在 第九章,这将扩展到身份验证)。

以下代码片段显示了 GamesClient 类构造函数的实现:

Codebreaker.GameAPIs.Client/GamesClient.cs

public class GamesClient(HttpClient httpClient)
{
  private readonly HttpClient _httpClient = httpClient;
  private readonly JsonSerializerOptions _jsonOptions = new()
  {
    PropertyNameCaseInsensitive = true
  };
  // code removed for brevity

GamesClient 的构造函数中,注入的 HttpClient 实例被分配给一个变量,并配置了 JsonOptions。ASP.NET Core 将 JSON 序列化时的属性映射到小写。根据这里定义的选项,忽略大小写,因此小写映射被传输到大写属性。

注意

不要在每次请求时创建新的 HttpClient 类实例。相反,注入客户端将创建实例的责任转移到调用应用程序。使用依赖注入容器,我们将配置 HttpClient 以从工厂创建。

发送 HTTP 请求

让我们向服务发送一些请求以检索有关游戏、开始游戏和设置移动的信息。

最初的方法用于检索游戏信息:

Codebreaker.GameAPIs.Client/GamesClient.cs

public async Task<Game?> GetGameAsync(bool id, CancellationToken cancellationToken = default)
{
  Game game = default;
  try
  {
    game = await _httpClient.GetFromJsonAsync<Game>(
      $"/games/{id}", _jsonOptions, cancellationToken);
  }
  catch (HttpRequestException ex) when (ex.StatusCode = 
  HttpStatusCode.NotFound)
  {
    return default;
  }
  return game;
}
public async Task<IEnumerable<Game>> GetGamesAsync(GamesQuery query, CancellationToken cancellationToken = default)
{
  IEnumerable<Game> games = (
    await _httpClient.GetFromJsonAsync<IEnumerable<Game>>(
$"/games/{query.AsUrlQuery()}", _jsonOptions, 
      cancellationToken)) ?? Enumerable.Empty<Game>();
  return games;
}

GetGameAsync 方法通过传递游戏的标识符检索一个游戏。GetGamesAsync 使用先前创建的 GamesQuery 来创建用于发送 HTTP GET 请求的服务 URI。GetFromJsonAsyncHttpClient 类的一个扩展方法,用于发送 HTTP GET 请求,使用 EnsureSuccessStatusCodeHttpResponseMessage 检查成功状态码(如果失败则抛出 HttpRequestException),并使用 System.Text.Json 反序列化器从响应流中反序列化。当传递的 game-id 未找到时,我们希望返回 null 而不是抛出异常,因此捕获此异常。

使用 StartGameAsync 方法实现启动游戏的请求:

Codebreaker.GameAPIs.Client/GamesClient.cs

public async Task<(Guid id, int numberCodes, int maxMoves, IDictionary<string, string[]> FieldValues)>
  StartGameAsync(GameType gameType, string playerName, 
  CancellationToken cancellationToken = default)
{
  CreateGameRequest createGameRequest = new(_gameType, _playerName);
var response = await _httpClient.PostAsJsonAsync("/games", 
    createGameRequest, cancellationToken);
  response.EnsureSuccessStatusCode();
  var gameResponse = await response.Content.
    ReadFromJsonAsync<CreateGameResponse>(
    _jsonOptions, cancellationToken);
  if (gameResponse is null)
    throw new InvalidOperationException();
  return (gameResponse.Id, gameResponse.NumberCodes, gameResponse.
    MaxMoves, gameResponse.FieldValues);
}

StartGamesAsync 方法在创建应该随 HTTP 主体一起发送的数据后发送一个 HTTP POST 请求:CreateGameRequest。在收到成功响应后,ReadFromJsonAsync 扩展方法反序列化返回的 HTTP 主体,并使用元组返回方法的结果。

要发送游戏移动,使用 HTTP PATCH 请求更新游戏:

Codebreaker.GameAPIs.Client/GamesClient.cs

public async Task<(string[] Results, bool Ended, bool IsVictory)> SetMoveAsync(Guid id, string playerName, GameType gameType, int moveNumber, string[] guessPegs, CancellationToken cancellationToken = default)
{
  UpdateGameRequest updateGameRequest = new(id, gameType, playerName, 
    moveNumber)
  {
    GuessPegs = guessPegs
  };
var response = await _httpClient.PatchAsJsonAsync($"/games/{id}", 
    updateGameRequest, _jsonOptions, cancellationToken);
  response.EnsureSuccessStatusCode();
  var moveResponse = await response.Content.ReadFromJsonAsync<UpdateGameResponse>(_jsonOptions, cancellationToken)
    ?? throw new InvalidOperationException();
  (_, _, _, bool ended, bool isVictory, string[] results) = 
    moveResponse;
  return (results, ended, isVictory);
}

发送 HTTP PATCH 请求与发送 POST 请求非常相似:创建 UpdateGameRequest 对象以将此 JSON 序列化的信息发送到服务器。接收结果后,将主体反序列化为 UpdateGameResponse 对象。

注意

使用 REST API,通常使用 HTTP PUT 请求更新资源,而 HTTP PATCH 用于部分更新。在这里,游戏资源被更新,但不是通过发送完整的游戏,而是只发送一些部分数据。

创建 NuGet 包

从库中创建 NuGet 包,您可以使用 dotnet CLI:

cd Codebreaker.GameAPIs.Client
dotnet pack --configuration Release

要查看 NuGet 包的内容,您可以将其重命名为 zip。为了方便使用此包,您可以将其添加到共享文件夹中,并配置 Visual Studio NuGet 包管理器以引用此文件夹。您还可以将包发布到 Azure DevOps Artifacts。通过引用此,您可以创建一个 nuget.config 文件:

dotnet new nugetconfig

使用生成的 nuget.config 文件,您需要指定共享文件夹或您的 Azure DevOps Artifacts 源的链接。

这是一个使用 dotnet new 创建的 nuget.config 文件,并添加了一个自定义源条目:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="nuget" value="https://api.nuget.org/v3/index.json" />
<add key="custom" value="https://pkgs.dev.azure.com/
      MyOrganization/_packaging/MyFeed/nuget/v3/index.json" />
  </packageSources>
</configuration>

使用此 NuGet 配置文件,<clear /> 条目删除了所有默认源。带有第一个 add 元素的 nuget 键引用了 NuGet 服务器的默认源。同样,您可以使用其他键和链接添加自定义源到服务器上的包源。

对于 Codebreaker 解决方案,您可以在 NuGet 服务器上查找 Cninnovation.Codebreaker.Client NuGet 包。在 NuGet 上提供 NuGet 包后,应在包中添加一个说明文件、许可证和一些其他元数据。有关更多信息,请参阅进一步阅读。

创建客户端应用程序

在库已经就绪的情况下,让我们创建一个客户端应用程序。一个简单的控制台应用程序可以满足玩游戏的目的。在使用这些命令之前,请导航到解决方案文件的文件夹:

dotnet new console –framework net8.0 -o Codebreaker.Console
cd Codebreaker.Console
dotnet add package Microsoft.Extensions.Hosting
dotnet add package Microsoft.Extensions.Http.Resilience
dotnet add package Spectre.Console.Cli
dotnet add reference ../Codebreaker.GameAPIs.Client

Microsoft.Extensions.Hosting 将用于依赖注入容器和配置支持,而 Microsoft.Extensions.Http.Resiliency 是提供 HttpClientFactory 的包。当然,之前创建的库也需要被引用。

要与用户交互,可以使用简单的Console.ReadLineConsole.WriteLine语句。在书中 GitHub 仓库提供的示例应用程序中,使用了 NuGet 包Spectre.Console.Cli。只需检查源代码以获取更多信息。

配置依赖注入容器

应用程序的最高级语句如下所示:

Codebreaker.Console/Program.cs

var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHttpClient<GamesClient>(client =>
{
  string gamesUrl = builder.Configuration["GamesApiUrl"] ??throw new 
    InvalidOperationException("GamesApiUrl not found");
  client.BaseAddress = new Uri(gamesUrl);
});
builder.Services.AddTransient<Runner>();
var app = builder.Build();
var runner = app.Services.GetRequiredService<Runner>();
await runner.RunAsync();

Host类的CreateApplicationBuilder配置了依赖注入容器,并为应用程序配置提供者和日志提供者提供了默认配置。AddHttpClient扩展方法使用 HttpClient 工厂实现。在这里,使用泛型方法重载指定了将接收通过configureClientlambda 表达式指定的HttpClient注入的GamesClient类。HttpClientBaseAddress配置为具有GamesApiUrl配置值。

对于配置,我们创建了一个appsettings.json配置文件:

Codebreaker.Console/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "GamesApiUrl": "http://localhost:9400"
}

GamesApiUrl键配置为包含游戏 API 服务的地址。为了不将游戏播放的控制台输出与日志混淆,日志级别配置为仅记录警告、错误和关键错误消息。

与用户交互

与用户交互和服务调用是通过Runner类实现的。在这里,之前创建的GamesClient被注入到主要构造函数中:

Codebreaker.Console/Runner.cs

internal class Runner(GamesClient client)
{
  private readonly CancellationTokenSource _cancellationTokenSource = new();
  public async Task RunAsync()
  {
    bool ended = false;
    while (!ended)
    {
      var selection = Inputs.GetMainSelection();
      switch (selection)
      {
        case MainOptions.Play:
          await PlayGameAsync();
          break;
        case MainOptions.Exit:
          ended = true;
          break;
        case MainOptions.QueryGame:
          await ShowGameAsync();
          break;
        case MainOptions.QueryList:
await ShowGamesAsync();
          break;
        case MainOptions.Delete:
          await DeleteGameAsync();
          break;
        default:
          throw new ArgumentOutOfRangeException();
      }
    }
  }
  // code removed for brevity
}

RunAsync方法首先询问用户下一步要做什么。主要选项是玩游戏、显示单个游戏的状态、显示游戏列表或删除游戏。此代码片段使用了Inputs类,该类又使用了来自提到的 NuGet 包Spectre.Console.CliAnsiConsole类。通过这种方式,您可以得到一个如图 4**.1*所示的漂亮的控制台用户界面,具有简单的选择。根据您用于与用户交互的方式,您的用户界面可能看起来不同。

  1. 要运行游戏,首先在启动客户端之前启动服务器。使用客户端,选择Play图 4**.1)。

图 4.1 – 控制台输出以选择主任务

图 4.1 – 控制台输出以选择主任务

  1. 接下来,选择游戏类型(图 4**.2),例如,Game6x4

图 4.2 – 选择游戏类型

图 4.2 – 选择游戏类型

  1. 输入玩家名称(图 4**.3)并输入单次移动所需的所有颜色。

图 4.3 – 输入名称并根据游戏类型选择颜色

图 4.3 – 输入名称并根据游戏类型选择颜色

  1. 图 4**.4显示了移动的结果(这里用三种颜色正确但位置错误)和下一个移动的开始。重复此操作直到解决代码。

图 4.4 – 移动结果和下一步操作

图 4.4 – 移动结果和下一步操作

成功移动的结果在 图 4.5 中显示:

图 4.5 – 游戏结果

图 4.5 – 游戏结果

从那里,您可以重复此操作来玩另一场游戏或查询游戏列表。从游戏列表中,您可以获取游戏标识符并查询单个游戏,通过传递标识符。

使用 Microsoft Kiota 创建客户端

运行生成 OpenAPI 文档的 API 服务(这是在 第二章 中完成的),我们可以利用这些信息,并自动创建客户端代码。本章的示例代码中,OpenAPI 文档存储在文件 gamesapi-swagger.json 中,您可以在不启动服务的情况下引用它。

使用 Visual Studio 的一个选项是使用 添加 | 连接客户端 并将服务引用添加到 OpenAPI 文档。但这个选项(在撰写本文时)有一些限制:

  • 它仍然使用 Newtonsoft Json 序列化器,而新的 System.Text.Json 序列化器更快且占用内存更少

  • 客户端实现使用字符串而不是流,这可能导致大对象堆中的对象

正如您在本章中看到的,创建一个用于创建 HTTP 请求的自定义库并不难,并且可以针对您的领域进行优化。

但现在还有一个应该考虑的选项:Microsoft Kiota(https://learn.microsoft.com/openapi/kiota/)。Microsoft Kiota 是一个命令行工具,它为包括 Java、PHP、Python、TypeScript、C# 在内的多种语言提供从 OpenAPI 生成代码的功能。让我们试一试。

安装 Kiota

Kiota 作为 dotnet 工具提供。我们将此工具作为新库的一部分安装到另一个类库项目中。

使用以下命令创建库。请在 solution 文件夹中运行这些命令:

dotnet new classlib --framework net8.0 -o Codebreaker.GameApis.KiotaClient
cd Codebreaker.GameApis.KiotaClient
dotnet add package Microsoft.Kiota.Http.HttpClientLibrary
dotnet add package Microsoft.Kiota.Serialization.Json
dotnet add package Microsoft.Kiota.Serialization.Form
dotnet add package Microsoft.Kiota.Serialization.Multipart
dotnet add package Microsoft.Kiota.Serialization.Text

使用 Kiota,我们还需要为不同的序列化器和 Kiota HTTP 客户端库添加一些 Kiota NuGet 包。Kiota 工具与项目一起安装:

dotnet new tool-manifest
dotnet tool install microsoft.openapi.kiota

使用 Kiota 生成代码

在安装 Kiota 工具后,我们可以使用 OpenAPI 文档 gamesapi-swagger.json 生成代码。此文件位于 ch04 文件夹中:

dotnet kiota generate --openapi ..\..\gamesapi-swagger.json --output codebreaker --language CSharp --class-name GamesAPIClient --namespace-name Codebreaker.Client

使用这些选项,将使用引用的 OpenAPI 文档 gamesapi-swagger.json 生成源代码,生成的文件存储在子目录 codebreaker 中,代码生成使用 C# 语言,执行 HTTP 请求的主要类命名为 GamesAPIClient,所有生成的代码的命名空间为 Codebreaker.Client

注意

查看生成的代码,你会发现 Kiota 生成的代码并没有使用与本书或 .NET 团队使用的相同的编码约定。例如,花括号的打开与方法名在同一行,这是许多 JavaScript 程序中使用的约定。如果你使用 Visual Studio,你可以通过在解决方案资源管理器中的上下文菜单中导航到 分析并清理代码 | 运行代码清理 来轻松地使用整个程序更改此约定。你可能需要首先配置代码清理的首选项。

使用 Kiota 创建的类型是模型(使用 OpenAPI 中的 schemas)和请求构建器(使用定义请求的 paths)。请检查书籍仓库以获取生成的代码文件。

探索 Kiota 生成的模型

对于所有请求、响应以及 schemas 中指定的所有类型,Kiota 在 Models 目录中生成类。让我们看看 CreateGameRequest 类:

Codebreaker.GamesAPIs.KiotaClient/codebreaker/Models/CreateGameRequest.cs

public class CreateGameRequest : IParsable
{
  public Codebreaker.Client.Models.GameType? GameType { get; set; }
  public string? PlayerName { get; set; }
  public static CreateGameRequest 
    CreateFromDiscriminatorValue(IParseNode parseNode)
  {
    _ = parseNode ?? throw new ArgumentNullException(nameof(parseNode));
    return new CreateGameRequest();
  }
  public virtual IDictionary<string, Action<IParseNode>> 
    GetFieldDeserializers()
  {
    return new Dictionary<string, Action<IParseNode>> {
      {"gameType", n => { GameType = n.GetEnumValue<GameType>(); } },
      {"playerName", n => { PlayerName = n.GetStringValue(); } },
    };
  }
  public virtual void Serialize(ISerializationWriter writer)
  {
    _ = writer ?? throw new ArgumentNullException(nameof(writer));
    writer.WriteEnumValue<GameType>("gameType", GameType);
    writer.WriteStringValue("playerName", PlayerName);
  }
}

模型类型实现了 IParsable 接口。这不是 System.IParsable 接口,而是来自 Microsoft.Kiota.Abstractions.Serialization 命名空间中的 Kiota 库的版本。此接口定义了实例成员 GetFieldDeserializersSerialize。通过这种方式,Kiota 提供了一个抽象层,允许使用不同的序列化器。

另一个需要提及的重要方面是,所有模型类型的属性都被声明为可空的。虽然 EF Core 支持可空性,可以将非可空成员映射到数据库中的必填项,但在使用最小 API 生成 OpenAPI 文档时,这个注解并未被使用。在服务器上的模型上添加 Required 属性会增加 required。其他注解,如 MaxLengthMinLength,也被映射为 maxLengthminLength,正如你在 gamesapi-swagger.json 中所看到的。

然而,许多 API 并没有注意可空性。在 OpenAPI 定义中,也没有明确指定如何强制执行严格的可空性。根据模型使用的上下文,信息仍然可能从服务器中遗漏,并且数据没有被发送。

这里是关于 Kiota 实现的讨论:github.com/microsoft/kiota/issues/2594

随着下一个主要版本的 OpenAPI 规范,这可能会改变。对于当前 Kiota 实现所做的决策,Kiota 可以安全地将所有模型属性声明为可空的,但这同时也意味着我们需要检查 null

探索 Kiota 生成的请求构建器

请求构建器的目标是轻松创建请求。让我们看看一些生成的代码:

Codebreaker.GamesAPIs.KiotaClient/codebreaker/GamesAPIClient.cs

public class GamesAPIClient : BaseRequestBuilder
{
  public GamesRequestBuilder Games
  {
    get => new GamesRequestBuilder(PathParameters, RequestAdapter);
  }
  public GamesAPIClient(IRequestAdapter requestAdapter) : base(requestAdapter, "{+baseurl}", new Dictionary<string, object>())
  {
     ApiClientBuilder.
       RegisterDefaultSerializer<JsonSerializationWriterFactory>();
     ApiClientBuilder.
       RegisterDefaultSerializer<TextSerializationWriterFactory>();
     ApiClientBuilder.
       RegisterDefaultDeserializer<JsonParseNodeFactory>();
  // code removed for brevity
  }
}

所有请求构建器都继承自基类 BaseRequestBuilder。通过代码生成指定名称的 GamesApiClient 类是需要初始化以与 Games API 通信的请求构建器。在构造函数中,可以看到配置了默认的序列化和反序列化器。在这里,Kiota 提供了更多的灵活性。

GamesApiClientGames 属性返回另一个请求构建器:GamesRequestBuilder。此构建器位于 GamesRequestBuilder.cs 源文件中:

Codebreaker.GamesAPIs.KiotaClient/codebreaker/Games/GamesRequestBuilder.cs

public class GamesRequestBuilder : BaseRequestBuilder
{
  public GamesItemRequestBuilder this[Guid position]
  {
    get
    {
      // code removed for brevity
      return new GamesItemRequestBuilder(urlTplParams, 
        RequestAdapter);
    }
  }
  public async Task<List<Game>?> GetAsync(Action<RequestConfiguration
    <GamesRequestBuilderGetQueryParameters>>? requestConfiguration = 
    default, CancellationToken cancellationToken = default)
  {
    var requestInfo = ToGetRequestInformation(requestConfiguration);
    var collectionResult = await RequestAdapter.
      SendCollectionAsync<Game>(requestInfo, Game.
      CreateFromDiscriminatorValue, default, cancellationToken).
      ConfigureAwait(false);
    return collectionResult?.ToList();
  }
public async Task<CreateGameResponse?> PostAsync(CreateGameRequest 
    body, Action<RequestConfiguration<DefaultQueryParameters>>? 
    requestConfiguration = default, CancellationToken 
    cancellationToken = default)
  {
    // code removed for brevity
  }

此请求构建器随后用于调用游戏 API 的请求。此请求构建器实现的方法有 GetAsyncPostAsyncGetAsync 方法用于通过查询参数检索游戏列表。PostAsync 使用生成的 CreateGameRequest 模型发送 POST 请求。

要获取单个游戏、通过发送游戏移动更新游戏以及删除游戏,游戏 API 需要一个游戏标识符。通过 Kiota,这是通过提供 GamesRequestBuilder 的索引器来解决的,它反过来返回另一个请求构建器 GameItemsRequestBuilder。在这里,可以使用流畅的 API 传递游戏标识符并调用 GetAsyncPutAsync 方法。

在下一节中,我们将实现另一个控制台应用程序以使用此生成的代码。

使用 Kiota 生成的代码

Kiota 生成的代码与控制台应用程序 Codebreaker.KiotaConsole 一起使用。在大部分部分,该应用程序的代码与之前的控制台应用程序类似。主要的变化是,使用 Runner 类对服务的调用现在被替换,并且依赖注入容器配置已更改。

HttpClient 工厂不再注册到 DI 容器中,如下代码片段所示:

Codebreaker.KiotaConsole/Program.cs

var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<RunnerOptions>(options =>
{
options.GamesApiUrl = builder.Configuration["GamesApiUrl"] ??
    throw new InvalidOperationException("GamesApiUrl not found");
});
builder.Services.AddTransient<Runner>();
var app = builder.Build();
var runner = app.Services.GetRequiredService<Runner>();
await runner.RunAsync();

除了移除 HttpClient 配置的代码外,现在基本地址被配置为为 RunnerOptions 类提供值。该类仅定义了 GamesApiUrl 属性以指定游戏服务的基地址。

Runner 类的构造函数,其中传递了选项,将在下一个代码片段中展示:

Codebreaker.KiotaConsole/Runner.cs

internal class Runner
{
  private readonly GamesAPIClient _client;
  private readonly CancellationTokenSource _cancellationTokenSource = new();
  public Runner(IOptions<RunnerOptions> options)
  {
    AnonymousAuthenticationProvider authenticationProvider = new();
    HttpClientRequestAdapter adapter = new(authenticationProvider)
    {
BaseUrl = options.Value.GamesApiUrl ?? throw new 
        InvalidOperationException("Could not read GamesApiUrl")
    };
    _client = new GamesAPIClient(adapter);
  }

在实现 Runner 构造函数时,实例化了 GamesAPIClient 类。该类接收配置了服务基本地址的 HttpClientRequestAdapterHttpClientRequestAdapter 的构造函数接收一个实现 IAuthenticationProvider 接口的对象。在这里,使用 AnonymousAuthenticationProvider 因为不需要认证。Kiota 提供了各种认证提供者。

要发送带有查询参数的 GET 请求以获取游戏列表,你将调用 GamesRequestBuilderGetAsync 方法:

Codebreaker.KiotaConsole/Runner.cs

private async Task ShowGamesAsync()
{
  var games = await _client.Games.GetAsync(config =>
  {
    config.QueryParameters.Date = new Date(DateTime.Today);
  }, _cancellationTokenSource.Token);
  // code removed for brevity

Games属性返回生成的GamesRequestBuilder,这允许我们通过传递查询参数来调用GetAsync方法。Kiota 在Microsoft.Kiota.Abstractions命名空间内提供了自己的Date类型,它代表DateTime的仅日期部分。今天,.NET 提供了DateOnly类型,但这个类型在.NET Standard 2.0 中不可用,而 Kiota 也支持.NET Standard 2.0。

开始游戏是通过发送 POST 请求来完成的,如下面的代码片段所示:

Codebreaker.KiotaConsole/Runner.cs

private async Task PlayGameAsync()
{
  // code removed for brevity
  CreateGameRequest request = new()
  {
    PlayerName = playerName,
    GameType = gameType
  };
var response = await _client.Games.PostAsync(request, 
    cancellationToken: _cancellationTokenSource.Token);

开始游戏时,玩家名称和游戏类型的用户输入被分配给CreateGameRequest对象。然后,通过调用PostAsync方法来启动游戏并接收CreateGameResponse,将此模型类型传递。

以下代码片段展示了通过传递游戏标识符获取单个游戏:

Codebreaker.KiotaConsole/Runner.cs

private async Task ShowGameAsync()
{
  // code removed for brevity
  var game = await _client.Games[id.ToString()].GetAsync(
    cancellationToken: _cancellationTokenSource.Token);
  // code removed for brevity

获取单个游戏、使用 HTTP PATCH 请求更新游戏以及使用 HTTP DELETE 请求删除游戏都需要将游戏标识符作为查询参数。为了使用这个功能,Kiota 提供了一个传递game-id并继续使用流畅 API 的索引器。要获取单个游戏,使用GetAsync方法。修补和删除游戏非常相似。

使用这些信息,你可以使用 Kiota 生成的代码,并编写实现来通过发送游戏移动来使用PostAsync方法更新游戏。

使用新的客户端,你可以以之前展示的方式运行游戏!

摘要

在阅读本章内容的过程中,你将拥有一个运行中的客户端控制台应用程序来运行游戏。我们使用了HttpClient类向游戏服务发送请求。为了能够与不同的客户端技术重用这一功能,我们创建了一个库。为了高效地使用HttpClient类,你学习了如何使用 HttpClient 工厂。

而不是自己实现模型,你学习了使用 Microsoft Kiota 从 OpenAPI 定义创建代码。在你的自己的场景中,你现在可以决定对你来说最好的选项是什么。

在阅读下一章之前,你可以重用这个新创建的库,并创建你选择的客户端,例如 Blazor、WinUI 或.NET MAUI。虽然这些框架超出了本书的范围,但你可以在github.com/codebreakerapp查看更多可用的客户端。

无论你实现什么客户端,在深入下一章之前,玩一次游戏是值得的——这次是用你自己的客户端应用程序。

在下一章中,我们将再次关注服务;我们将使用 Docker 容器托管服务应用程序(以及另一个服务)。这个新的服务也将使用本章创建的 HTTP 客户端。

进一步阅读

要了解更多关于本章讨论的主题,你可以参考以下链接:

第二部分:托管和部署

本部分重点介绍托管和部署微服务的基本方面。你将首先全面了解 Docker 基础知识,例如创建 Dockerfile、使用 .NET CLI 构建 Docker 镜像以及在开发环境中使用 .NET Aspire 运行 Docker 容器。然后,你将继续将 Docker 镜像发布到 Azure 容器注册库,部署到 Azure 容器应用环境(基于 Kubernetes),并整合 Azure 服务,如 Azure 应用配置和 Azure 密钥保管库。

在本部分中,你将利用 Azure 资源进行本地应用程序执行,使用 Azure 开发者 CLI 将应用程序部署到 Azure,并在代码库更新时通过 GitHub Actions 自动部署到 Azure。为确保在本地和 Azure 环境中无缝运行,将实现 Azure Active Directory B2C 和 Microsoft Entra 进行身份验证,同时使用 ASP.NET Core Identities。

本部分包含以下章节:

  • 第五章, 微服务的容器化

  • 第六章, 在 Microsoft Azure 上托管应用程序

  • 第七章**,灵活的配置

  • 第八章**,使用 GitHub Actions 进行 CI/CD 发布

  • 第九章**,使用服务和客户端进行身份验证和授权

第五章:微服务的容器化

在前几章构建了客户端和服务之后,现在是时候让服务为发布做好准备。使用 Docker,我们可以准备包含运行完整解决方案所需所有内容的镜像。

在本章中,你将开始学习 Docker 最重要的部分,包括构建 Docker 镜像、运行容器以及使用.NET Aspire 在本地开发系统上运行由多个服务组成的解决方案,包括在 Docker 容器中运行的 SQL Server,以及利用本地的预编译AOT)创建特定平台的本地应用程序。

在本章中,你将学习以下主题:

  • 使用 Docker

  • 构建 Docker 镜像

  • 使用.NET Aspire 运行解决方案

  • 在 ASP.NET Core 中使用原生 AOT

技术要求

通过本章你需要的是Docker Desktop。Docker Desktop 对个人开发者、教育和开源社区是免费的。你可以从www.docker.com/products/docker-desktop/下载 Docker Desktop,最佳搭配Windows 子系统 LinuxWSL)使用。查看本章的 README 文件以安装 WSL 2 和 Docker Desktop。

注意

dotnet publish命令支持构建和发布 Docker 镜像。虽然dotnet publish的一些功能可以在不安装 Docker Desktop 的情况下使用,但我们直接开始使用 Docker,因为这也有助于理解可以使用.NET CLI 做什么,而且通常你需要比.NET CLI 提供的更多关于 Docker 的功能。

本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure.

ch05源代码文件夹包含本章的代码示例。对于本章的不同部分,有不同的子文件夹可供选择。为了开始,按照说明操作,你可以使用StartXX文件夹。StartDocker文件夹包含在创建 Docker 容器之前添加的项目,而FinalDocker文件夹包含构建 Docker 容器后的最终状态的项目。

StartAspire文件夹包含多个项目,我们在前几章创建的.NET Aspire 特定项目已经包含在内。将其作为本章.NET Aspire 部分的起点。FinalAspire包含完整的结果,你可以将其作为参考。NativeAOT文件夹包含用于编译.NET 原生 AOT 的游戏 API 的代码。

ch05文件夹的子文件夹中,你会看到以下项目:

  • Codebreaker.GameAPIs – 我们在前一章客户端应用程序中使用的游戏 API 项目。在本章中,我们对项目进行了少量更新,以指定连接到 SQL Server 数据库的连接字符串。该项目引用了 NuGet 包,其中实现了IGamesRepository接口的 SQL Server 和 Azure Cosmos DB 实现。

  • Codebreaker.Bot – 这是一个新项目,它实现了 REST API 并调用游戏 API 以自动进行随机游戏操作。该项目利用我们在第四章中创建的客户端库 – 它引用了CNinnovation.Codebreaker.Client NuGet 包来调用游戏 API。

  • Codebreaker.AppHost – 该项目得到了增强,以协调不同的服务。

  • Codebreaker.ServiceDefaults – 在本章中,该项目没有变化。

  • Codebreaker.GameAPIs.NativeAOT – 一个新项目,它提供了经过一些修改以支持.NET 8 原生 AOT 的游戏 API。

使用 Docker

尽管如今,仅使用.NET 工具创建微服务和运行 Docker 容器是可能的,但了解 Docker 仍然很有帮助。因此,在这里,我们查看 Docker 的一些重要概念,包括启动在 Docker 容器中运行的 SQL Server 实例,创建用于构建游戏 API 服务的 Dockerfile,以及在本地系统上运行这些容器。如果你已经对 Docker 了如指掌,你可以跳过并转到.NET Aspire部分,该部分不需要在此处创建的 Docker 容器。

在深入构建 Docker 镜像之前,我们为什么需要容器呢?在部署应用程序时,应用程序经常无法运行。通常,原因是在目标系统上缺少运行时或配置设置错误或缺失。解决这一问题的方法之一是准备虚拟机VM),其中所有内容都已预先安装。这种方法的缺点是虚拟机需要的资源。虚拟机包含操作系统并分配 CPU 和内存资源。Docker 要轻量得多。Docker 镜像可以小到操作系统不是镜像的一部分的程度 – 而多个 Docker 容器可以共享相同的 CPU 和内存。

在深入了解细节之前,这里有一个使用 Docker 时的重要术语简要列表:

  • Docker 镜像 是一个包含运行应用程序所需所有内容的可执行包

  • 一个镜像可能有不同的版本,这些版本通过 Docker 标签来识别

  • Dockerfile 是一个包含构建 Docker 镜像指令的文本文件

  • Docker 容器 是 Docker 镜像的运行实例

  • Docker 仓库 是 Docker 镜像的存储位置

  • Docker 仓库 是在仓库中不同版本的 Docker 镜像的集合

使用 Docker Desktop

Docker Desktop for Windows 提供了一个环境,可以在 Windows 上构建 Docker 镜像和运行 Docker 容器。你可以配置它使用 Windows 或 Linux 容器。在 Docker Desktop for Windows 的早期版本中,需要安装 Hyper-V。Docker Desktop 随后使用 Linux 虚拟机在该虚拟机上运行所有 Linux 容器。因为 Windows 现在通过 WSL 更原生地支持 Linux,所以 Docker Desktop 可以使用 WSL,不需要虚拟机。在 Docker Desktop 配置中,你可以选择使用与 Windows 系统本身相同的 Docker 环境的 WSL 发行版(见图 5.1)。使用这些 Linux 发行版,你可以使用相同的 Docker 命令来管理你的 Docker 环境:

图 5.1 – Docker Desktop 中的 WSL 集成

图 5.1 – Docker Desktop 中的 WSL 集成

与配置使用你分配的 CPU 和内存数量的虚拟机不同,WSL 与 Windows 共享 CPU 和内存——但 WSL 有一些限制。在 Windows 构建版本 20176 及以后的版本中,内存限制为 50%和 8GB(取较小者);在之前的版本中,WSL 可以使用总内存的 80%。

对于逻辑处理器的数量,默认情况下,可以使用所有可用的处理器。你可以为整个子系统全局更改内存和 CPU 限制,也可以为安装的每个 Linux 发行版定义不同的限制。检查 WSL 的设置配置,请参阅learn.microsoft.com/windows/wsl/wsl-config

运行 Docker 容器

在你安装了 Docker Desktop 并且正在运行 Windows 后,你可以选择运行 Windows 或 Linux 容器。虽然 Windows 容器非常适合仅运行在 Windows 上的旧版应用程序(例如,使用.NET Framework),但 Linux 容器提供更多功能,Linux Docker 镜像也较小。我们构建的解决方案将使用 Linux 容器运行。

需要先启动 Docker Desktop 环境,然后要运行第一个容器,请使用以下命令:

docker run hello-world

在第一次运行时,hello-world Docker 镜像从 Docker 仓库下载并启动。这个容器只是将一条消息写入屏幕以验证一切是否正常运行。再次启动它时,你会看到镜像不再下载,而是立即启动。

要查看所有下载的镜像,你可以使用以下命令:

docker images

要查看正在运行的容器,请使用以下命令:

docker container ls

也有一个简写符号来显示所有正在运行的容器:docker ps

你将看不到hello-world容器,因为这个容器在写入输出后立即停止了。

重复运行一个镜像,你就可以从头开始。但同时也保留了一个与运行中的镜像相关的状态。这允许你继续之前停止的容器,并保持之前的状态。docker container ls -a 命令不仅显示了正在运行的容器,还显示了已停止的容器。使用 docker container prune,你可以从所有已停止的容器中删除状态。

在 Docker 容器中运行 SQL Server

第三章 中,我们使用了本地系统上的 SQL Server 和 Azure Cosmos DB 模拟器来从游戏服务访问它。您也可以使用 Docker 镜像,而不是在本地系统上安装这些产品。

让我们开始下载 SQL Server 的 Docker 镜像:

docker pull mcr.microsoft.com/mssql/server:2022-latest

之前,我们使用 docker run 启动容器并隐式地从注册表中下载它。docker pull 只是从注册表中下载镜像。mcr.microsoft.com 是微软存储镜像的 Microsoft 仓库。mssql/server 是镜像的名称。您可以在 hub.docker.com/_/microsoft-mssql-server 上阅读有关此镜像的信息。这是一个基于 Ubuntu 的镜像。2022-latest 是一个标签名称。这是 SQL Server 2022 的实际版本。对于 SQL Server,其他标签是 2019-latest2017-latestlatest。这些对应于 SQL Server 2019 和 2017。latest 标签是 SQL Server 的最新版本。在撰写本文时,该镜像与 2022-latestlatest 标签相同。如果您下载了这两个镜像,则不需要第二次下载,您将看到具有相同镜像 ID 但不同标签的镜像。

注意

使用 Docker 镜像与 SQL Server 的默认配置是 SQL Server 开发者版。您也可以通过设置环境变量来配置使用 Express、标准、企业和企业核心版。请注意非开发者版本的必要许可证。阅读镜像文档以设置不同版本的环境变量。

在 Docker 环境中使用 SQL Server 版本的另一个选项是 Azure SQL Edge。请检查 learn.microsoft.com/en-us/azure/azure-sql-edge/disconnected-deployment 了解如何运行 Azure SQL Edge。

要运行 SQL Server 镜像,您可以使用以下命令:

docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Pa$$w0rd" -p 14333:1433 --name sql1 --hostname sql1 -d mcr.microsoft.com/mssql/server:2022-latest

使用此命令,这些选项被使用:

  • -e 指定环境变量。使用这两个变量,许可证被接受,并为 sa 账户定义了一个密码。sa 是一个配置了权限的账户,是 系统管理员 的简称。

  • -p 选项将主机上的端口映射到容器。在目标主机上,不能为多个应用程序使用相同的端口;例如,如果有一个本地 SQL Server 正在运行,第一个值不能使用 1433。请确保使用一个可用的端口。

  • --name 选项指定容器的名称。默认情况下,使用两个列表的组合生成一个随机名称。

  • --hostname 选项指定容器的主机名。

  • -d 选项在后台运行容器。

要获取有关容器做什么的有用信息,请使用 docker container logs

docker container logs sql1

此命令需要容器的名称。要连接并等待所有日志到来,请添加 -f 选项(表示 跟随)。

要在容器内打开命令提示符并查看内容,请使用 docker exec -it sql1 bash,这将分配一个终端,保持 stdin 打开(交互模式),并在容器内执行 Bash shell。

SQL Server 容器启动后,我们可以发布在 第三章 中创建的数据库。

使用 Docker 容器中的卷

SQL Server 的 Docker 容器包含状态(数据库文件)。我们可以再次启动之前运行的容器(使用 docker start)。当使用 docker run 时,我们会重新开始,并且不会使用之前的状态。使用 docker commit,您可以从容器创建一个新的镜像。这样,数据库和状态就在一起了,Docker 镜像的大小也会增长。更好的做法是将状态保存在 Docker 容器之外。您可以在容器内挂载外部目录、文件和 Docker 卷。Docker 卷完全由 Docker 管理。让我们用这个来为 SQL Server 使用。

首先,创建一个卷:

docker volume create gamessqlstorage

这将创建一个名为 gamessqlstorage 的卷。要检查可用的卷,请使用 docker volume ls。要获取有关卷的更多信息,请执行 docker volume inspect

让我们使用这个卷运行带有数据库的容器:

docker run -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Pa$$w0rd" -p 14333:1433 --name codebreakersql1 --hostname codebreakersql1 -v gamessqlstorage:/var/opt/mssql -d mcr.microsoft.com/mssql/server:2022-latest

-v 选项将容器内的 /var/opt/mssql 文件夹挂载到 gamessqlstorage 卷上。现在,SQL Server 写入此文件夹的所有数据现在都进入这个卷。状态现在保存在容器外部。因此,让我们创建一个数据库。

注意

在创建数据库备份时,也应使用卷。

在 Docker 容器中创建数据库

容器运行时,您可以使用 Visual Studio 中的 SQL Server Object ExplorerSQL Server Management Studio 等工具访问它。您可以使用以下 .NET 配置文件中的连接字符串:

{
  "ConnectionStrings": {
    "GamesSqlServerConnection": "server=host.docker.internal,14333;database=CodebreakerGames;user id=sa;password=Pa$$w0rd;TrustServerCertificate=true"
  }
}

使用 appsettings.json 文件,您还需要将 DataStore 键更改为 SqlServer 值。

使用 Linux 主机系统与 Docker 结合,您可以使用 Docker 容器的 IP 地址和端口号来访问 Docker 容器内的服务。这可能不适用于 Windows。这就是为什么引入了 host.docker.internal 主机名:通过使用本地端口号通过网关映射到服务。在数据库连接字符串中,您需要在主机名后添加端口号,并用逗号分隔。要传递用户名和密码,请使用 user idpassword 键。因为来自 Docker 容器的证书可能不是 Windows 系统上受信任的权威机构颁发的,所以请在连接字符串中添加 TrustServerCertificate 设置。

第三章中,我们使用dotnet ef命令行发布了一个数据库。现在是时候在 Docker 容器中创建这个数据库了。使用以下命令,您的当前目录需要是游戏 API 服务(Codebreaker.GameAPIs)的目录,appsettings中的DataStorage配置值设置为SqlServer,并且连接字符串指定如前所述:

cd Codebreaker.GameAPIs
dotnet ef database update -p ..\Codebreaker.Data.SqlServer -c GamesSqlServerContext

这样,数据库就创建好了——或者迁移到了最新版本。需要-p选项,因为 EF Core 上下文位于与-c不同的项目中。

注意

使用游戏服务项目,您有另一种创建数据库的选择。为了更方便地创建 SQL Server 数据库,现在除了其他 API 之外,还提供了/createsql API。发送POST请求会创建或升级数据库(如果已配置 SQL Server),使用 EF Core 的MigrateAsync方法。

接下来,让我们为游戏 API 服务创建一个自定义 Docker 镜像。图 5**.2显示了 C4 容器图,为您提供一个我们使用的容器的概览图。我们首先创建的是右侧的容器,托管 SQL Server。接下来,我们为游戏 API 创建一个 Docker 镜像,该镜像访问 SQL Server 容器。左侧的容器是新的机器人项目,它调用在游戏 API 容器中运行的服务,以自动玩游戏:

图 5.2 – C4 容器图

图 5.2 – C4 容器图

构建 Docker 镜像

.NET CLI 的dotnet publish命令支持不使用 Dockerfile 创建 Docker 镜像。然而,为了理解 Docker,我们需要了解 Dockerfile。这就是为什么我们首先通过定义 Dockerfile 来构建 Docker 镜像。

在本节中,我们将执行以下操作:

  • 为游戏 API 创建 Dockerfile

  • 使用 Dockerfile 构建 Docker 镜像

  • 使用 Docker 容器运行游戏 API

  • 使用dotnet publish创建 Docker 镜像

创建 Dockerfile

Docker 镜像是通过 Dockerfile 中的指令创建的。使用 Visual Studio,您可以从解决方案资源管理器轻松创建 Dockerfile,使用Codebreaker.GamesAPI项目创建一个多阶段 Dockerfile。多阶段 Dockerfile 为不同的阶段创建临时镜像。

基础阶段

以下代码片段解释了不同的阶段。第一个阶段为生产环境准备 Docker 镜像:

Codebreaker.GameAPIs/Dockerfile

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080

每个 Dockerfile 都以FROM指令开始。FROM指令定义了使用的基镜像。mcr.microsoft.com/dotnet/aspnet是一个针对生产优化的镜像。在.NET 8 中,此镜像基于 Debian 12(Bookworm)。Debian:12-slim镜像由一个包含FROM scratch的 Dockerfile 定义,因此这是指令层次结构的根。dotnet/aspnet镜像包含.NET 运行时和 ASP.NET Core 运行时。此镜像中不包含.NET SDK。AS指令定义了一个名称,允许在另一个阶段中使用此阶段的输出。USER指令定义了应与阶段指令一起使用的用户。WORKDIR指令设置后续指令的工作目录。如果该目录在镜像中不存在,则将其创建。第一阶段中的最后一步是EXPOSE指令。使用EXPOSE,您定义应用程序正在监听的端口。默认情况下使用TCP,但您也可以指定使用UDP接收器。

注意

.NET 8 在 Docker 镜像生成方面有一些变化:默认容器镜像以非 root 用户(应用程序用户)运行,默认端口不再是 80。80 是一个特权端口,需要 root 用户。新的默认端口现在是 8080。

构建阶段

第二阶段构建 ASP.NET Core 应用程序:

Codebreaker.GameAPIs/Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Codebreaker.GameAPIs/Codebreaker.GameAPIs.csproj", "Codebreaker.GameAPIs/"]
RUN dotnet restore "./Codebreaker.GameAPIs/Codebreaker.GameAPIs.csproj"
COPY . .
WORKDIR "/src/Codebreaker.GameAPIs"
RUN dotnet build "./Codebreaker.GameAPIs.csproj" -c $BUILD_CONFIGURATION -o /app/build

在第二阶段,我们暂时忽略第一阶段,并使用不同的基础镜像:dotnet/sdk。这个镜像包含.NET SDK,并用于构建应用程序。首先,创建一个src目录,并将当前目录设置为srcARG指令指定了在调用构建 Docker 镜像时可以传递的参数。如果没有传递此参数,则默认值为Release。接下来,您将看到多个COPY指令,用于将项目文件复制到当前目录下的子文件夹中。项目文件包含包引用。如果在RUN指令启动的dotnet restore命令失败的情况下,无需继续执行下一步。dotnet restore下载 NuGet 包。如果您使用不同的 NuGet 源,Dockerfile 需要一些更改,以复制nuget.config文件。当dotnet restore成功时,使用COPY指令将.目录下的完整源代码复制到当前目录(此时为src)。接下来,将工作目录更改为游戏 API 项目的目录,并调用dotnet build命令来为应用程序创建发布代码。使用dotnet build时,使用ARG指定的BUILD_CONFIGURATION参数。在最后一条命令之后,src/app/build文件夹中的发布构建是中间镜像的结果。

注意

为了确保不会因使用如COPY . .之类的指令而复制不必要的文件,使用了.dockerignore文件。与指定要忽略的文件的.gitignore文件类似,使用.dockerignore文件,你可以指定哪些文件不应该被复制到镜像中。

发布阶段

接下来,第二个阶段被用作第三个阶段的基础:

Codebreaker.GameAPIs/Dockerfile

FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Codebreaker.GameAPIs.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

FROM build指令使用前一个阶段的结果并继续此处。dotnet publish命令生成发布应用程序所需的代码。需要发布的文件被复制到/src/app/publish文件夹。虽然工作目录在之前阶段已配置,但在构建镜像时,工作目录仍然被设置。

最终阶段

使用最终阶段,我们继续使用第一个阶段,该阶段被命名为base

Codebreaker.GameAPIs/Dockerfile

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

在此阶段的第一条指令是将工作目录设置为app。然后,通过使用--from=publish引用第三个状态,将publish阶段中的/app/publish目录复制到当前目录。ENTRYPOINT指令定义了在运行镜像时应执行的操作:dotnet bootstrapper命令启动并接收Codebreaker.GameAPIs.dll作为参数。你也可以从命令行执行此操作:dotnet Codebreaker.GameAPIs.dll启动应用程序的入口点以启动 Kestrel 服务器,应用程序可以接收请求。

在构建 Dockerfile 之前,请确保appsettings.json中的DataStore配置设置为InMemory,以便在启动容器时默认使用内存提供程序。

使用 Dockerfile 构建 Docker 镜像

要构建镜像,将当前目录设置为解决方案目录,并使用以下命令:

docker build . -f Codebreaker.GameAPIs\Dockerfile -t codebreaker/gamesapi:3.5.3 -t codebreaker/gamesapi:latest

对于简单的 Dockerfile,使用docker build可能就足够构建镜像。然而,我们使用的 Dockerfile 包含了其他项目的引用。因此,我们需要注意上下文。对于游戏 API 服务,需要编译多个项目,并且 Dockerfile 中指定的路径使用的是父目录。将当前目录设置为解决方案目录,构建命令后的第一个参数(.)将上下文设置为该目录。-f选项接下来引用 Dockerfile 的位置。使用-t选项对镜像进行标记。仓库名称(codebreaker/gamesapi)需要小写,后跟3.5.3标签名称和latest标签名称。标签名称可以是字符串;没有对版本的要求。只是良好的实践,始终使用latest标签标记最新版本。指定-t选项两次,我们得到两个引用相同镜像且具有相同镜像标识符的镜像名称。

要列出构建的镜像,使用docker images。要限制输出为codebreaker镜像,你可以定义一个过滤器:

docker images codebreaker/*

要检查 Docker 镜像的构建方式,我们可以使用 dockerhistory 命令:

docker history codebreaker/gamesapi:latest

图 5.3 展示了 docker history 命令的结果。这显示了用于构建镜像的 Dockerfile 中的每条指令。当然,您在 codebreaker/gamesapi 镜像中看不到的是 dotnet builddotnet restore 命令。buildpublish 阶段仅用于构建应用程序和创建所需文件的临时镜像。将输出与创建的 Dockerfile 进行比较,您会看到最上面是 ENTRYPOINT,然后是 COPYWORKDIR 等等。对于这些指令中的每一个,您还可以看到指令的大小结果。COPY 命令将 3,441 MB 复制到镜像中。USER 指令是我们 Dockerfile 中的第一条指令;在 USER 指令之前的指令(在 USER 指令下面的行)显示了在创建基础镜像时的指令:

图 5.3 –  命令的结果

图 5.3 – docker history 命令的结果

要查看镜像的暴露端口、环境变量、入口点等信息,请使用以下命令:

docker image inspect codebreaker/gamesapi:latest

结果以 JSON 信息的形式呈现,显示了诸如暴露端口、环境变量、入口点、操作系统以及此镜像基于的架构等信息。在运行镜像时,了解需要映射哪个端口号以及哪些环境变量可能很有用。

使用 Docker 运行游戏 API

您可以使用以下命令启动游戏 API 服务的 Docker 镜像:

docker run -p 8080:8080 -d codebreaker/gamesapi:latest

然后,您可以使用 docker logs <container-id>(使用 docker ps 获取运行容器的 ID)检查日志输出。您可以使用以下 HTTP 地址使用任何客户端与游戏服务交互:http://localhost:8080

在数据存储的默认配置下,游戏仅存储在内存中。要更改此设置,我们需要执行以下操作:

  1. 配置网络以允许多个 Docker 容器直接通信

  2. 启动 SQL Server 实例的 Docker 容器

  3. 将配置值传递给 Docker 容器,以便游戏 API 使用 SQL Server 实例

为 Docker 容器配置网络

要让容器之间相互通信,我们创建一个网络:

docker network create codebreakernet

Docker 支持多种网络类型,可以使用 --driver 选项进行设置。默认情况下,使用 docker network ls 显示所有网络。

以 SQL Server 启动 Docker 容器

要启动已存在数据库的 SQL Server Docker 容器,您可以使用我们提交的 Docker 镜像或访问先前运行容器的状态。在这里,我们后者,我们定义名称 sql1 作为容器的名称:

docker start sql1

使用 docker ps 检查正在运行的容器,并查看之前定义的端口映射。

使用命令将运行中的容器添加到 codebreakernet 网络中:

docker network connect codebreakernet sql1

使用游戏 API 启动 Docker 容器

现在,我们需要启动游戏 API,但覆盖配置值。这可以通过设置环境变量来完成。在 Docker 容器启动时传递环境变量不仅可以通过设置-e选项来完成,还可以使用--env-file并传递包含环境变量的文件。这是gameapis.env文件的内容:

gameapis.env

DataStore=SqlServer
ConnectionStrings__GamesSqlServerConnection=sql1,1433;database=CodebreakerGames;user id=sa;password=<enter your password>;TrustServerCertificate=true

使用默认容器注册创建应用程序的 DI 容器会注册多个配置提供程序。由于WebApplicationBuilder类(或Host类)配置提供程序的顺序,使用环境变量的提供程序会战胜 JSON 文件提供程序。使用DataStore键来选择存储提供程序。GamesSqlServerConnectionConnectionStrings层次结构中的一个键。使用命令行参数传递配置值时,您可以使用:作为分隔符来指定配置值的层次结构;例如,传递ConnectionStrings:GamesSqlServerConnection。使用:在所有地方都不起作用;例如,在 Linux 上的环境变量中。使用__转换为这个层次结构。

使用环境变量文件,连接到 SQL Server Docker 容器时使用 Docker 容器的主机名和 SQL Server 使用的端口。由于在同一个网络中,容器可以直接通信。

启动容器时,使用--env-file传递环境变量文件:

docker run -p 8080:8080 -d --env-file gameapis.env -d --name gamesapi codebreaker/gamesapi:latest
docker network connect codebreakernet gamesapi

注意

如果您收到容器名称已被使用的错误,您可以使用docker container stop <containername>停止正在运行的容器。要删除容器的状态,您可以使用docker container rm <containername>docker rm <containername>简写形式。要删除所有已停止容器的状态,请使用docker container prune。当使用dotnet run启动新容器时,您还可以添加--rm选项,以便在退出后删除容器。

指定gamesapi容器的名称,容器被添加到codebreakernet网络中。现在,我们有两个容器正在运行并相互通信,可以使用游戏服务来玩游戏。

让我们创建另一个 Docker 镜像,但这次不使用 Dockerfile。

使用 dotnet publish 构建 Docker 镜像

在本章中,我们使用多个同时运行的容器。在之前的章节中没有使用的一个项目是Codebreaker.Bot。该项目提供了一个 API,也是游戏服务的客户端——在 API 调用请求后自动在后台玩游戏。在本节中,我们将为该项目构建一个 Docker 镜像——但首先不创建 Dockerfile。

自 .NET 7 以来,dotnet publish 命令直接支持创建 Docker 镜像,无需 Dockerfile。使用 docker build 命令时,我们必须注意一些特定的 .NET 行为,例如需要编译多个项目。因此,在构建镜像时需要指定上下文。.NET CLI 了解解决方案和项目结构。.NET CLI 还了解用于构建 ASP.NET Core 应用程序的默认 Docker 基础镜像。

可以在项目文件中指定配置生成选项,并使用 dotnet publish 的参数。以下是项目文件中指定的一些配置:

<PropertyGroup>
  <ContainerRegistry>codebreaker/bot</ContainerRegistry>
<ContainerImageTags>3.5.3;latest</ContainerImageTags>
</PropertyGroup>
<ItemGroup>
  <ContainerPort Include="8080" Type="tcp" />
</ItemGroup>

ContainerImageTagsContainerPortdotnet publish 使用的元素中的两个。您可以使用 ContainerBaseImage 更改基础镜像,指定容器运行时标识符 (ContainerRuntimeIdentifier),命名注册表 (ContainerRegistry),定义环境变量等。有关详细信息,请参阅 learn.microsoft.com//dotnet/core/docker/publish-as-container

使用 dotnet publish,指定存储库的名称:

cd Codebreaker.Bot
dotnet publish Codebreaker.Bot.csproj --os linux --arch x64 /t:PublishContainer -c Release

机器人 API 服务与游戏服务进行通信,游戏服务使用运行 SQL Server 的容器。现在,我们已经有三个 Docker 容器协同工作。游戏 API 需要连接到数据库,机器人需要连接到游戏 API。

使用 .NET Aspire 运行解决方案

第二章 中,我们将 .NET Aspire 添加到只包含游戏 API 服务的解决方案中。在 第三章 中,我们添加了一个运行在 Docker 容器中的 SQL Server 数据库,没有保留状态。在这里,我们将通过使用在 Docker 容器中运行的 SQL Server 来扩展 .NET Aspire 配置,并配置机器人服务以访问游戏 API。

配置 SQL Server 的 Docker 容器

使用 .NET Aspire 的 Docker 容器可以通过 .NET 代码进行编排 – 使用 IDistributedApplication 接口的扩展方法:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
var sqlPassword = builder.AddParameter("SqlPassword", secret: true);
var sqlServer = builder.AddSqlServer("sql", sqlPassword)
  WithDataVolume("codebreaker-sql-data", isReadOnly: false)
 .AddDatabase("CodebreakerSql");
  var gameAPIs = builder.AddProject<Projects.
Codebreaker_GameAPIs>("gameapis")
    .WithReference(sqlServer);
  // code removed for brevity

AddSqlServer 方法将 SQL Server 添加为 .NET Aspire 资源到应用模型。在开发系统上,使用 Docker 容器来运行 SQL Server。在我们之前在 第三章 中创建容器时,我们使用默认密码配置分配了一个密码。在这里,我们使用 AddParameter 方法显式创建一个应用模型参数。有了这个,参数就从“参数”配置部分检索。因此,从 Parameters:SqlConfiguration 检索名为 SqlConfiguration 的参数。此参数资源通过 AddSqlServer 的第二个参数传递。如果没有分配密码,并且不存在以资源(sql)后缀为 -password 命名的参数,则会创建一个随机密码。如果没有使用卷挂载,并且每次容器启动时都创建新的数据库,这将非常棒。使用持久卷,每次运行容器时都需要使用相同的密码。这是通过提供密码配置来完成的。访问数据库需要数据库连接字符串,因此需要密码。

WithDataVolume 方法定义了 SQL Server 容器使用 Docker 卷。在容器内部,数据库存储在 /var/opt/mssql 文件夹中。记住——不使用卷时,数据库存储在容器本身中,并且当运行新的容器实例时不会保留状态。在调用 WithDataVolume 时,无需知道需要映射容器的哪些目录。这是由 WithDataVolume 方法知道的。我们只需传递卷的可选名称,以及卷是否应该是只读的。使用此卷,数据库文件将被写入,因此它不是只读的。在 第十一章 中,当添加 Grafana 和 Prometheus Docker 容器时,我们将使用只读挂载。

AddDatabase 方法将 SQL Server 数据库添加为 SQL Server 的子资源。这里传递的名称定义了资源的名称和数据库的名称。

为了让 .NET Aspire 添加隐式服务发现,数据库通过 WithReference 方法从游戏 API 项目引用。有了这个,可以使用 CodebreakerSql 数据库名称来引用连接字符串。

使用 AddSqlServer 方法,无需知道 SQL Server 的 Docker 镜像名称或需要指定的环境变量,因为我们之前已经使用 SQL Server Docker 镜像完成了这些操作。所有这些都在此方法的实现中完成。要了解如何将任何 Docker 镜像添加到 .NET Aspire 应用模型中,让我们看看此方法的实现:

public static IResourceBuilder<SqlServerServerResource> 
  AddSqlServer(this IDistributedApplicationBuilder builder, string 
  name, string? password = null, int? port = null)
{
var passwordParameter = password?.Resource 
  ??  ParameterResourceBuilderExtensions.
  CreateDefaultPasswordPArameter(builder, $"{name}-password", 
  minLower: 1, minUpper: 1, minNumeric: 1);
  var sqlServer = new SqlServerServerResource(name, 
    passwordParameter);
  return builder.AddResource(sqlServer)
    .WithEndpoint(1433, port, null, "tcp")
    .WithImage("mssql/server", "2022-latest")
    .WithImageRegistry("mcr.microsoft.com")
    .WithEnvironment("ACCEPT_EULA", "Y")
    .WithEnvironment(context =>)
    {
      context.EnvironmentVariables["MSSQL_SA_PASSWORD"] =
        sqlServer.PasswordParameter;
    });
  }

使用此代码,定义了一个端点,将传递的端口号映射到容器中运行的 SQL Server 使用的端口号 1433;指定了容器注册表、镜像名称和标签值,并创建了环境变量以接受 AddSqlServer 方法。使用 .NET Aspire,我们不需要知道这些信息,因为已有可用组件。

在启动应用程序之前,请确保通过用户密钥设置配置,以在 Codebreaker.AppHost 项目中存储 SQL Server 的密码;例如,使用以下命令:

dotnet user-secrets set Parameters:SqlPassword "Password123!"

现在,我们必须配置 .NET Aspire SQL Server 组件。

配置 .NET Aspire SQL Server 组件

对于游戏 API,使用 .NET Aspire SQL Server EF Core 组件通过 DI 容器配置 EF Core 上下文:

Codebreaker.GameAPIs/ApplicationServices.cs

public static class ApplicationServices
{
  public static void AddApplicationServices(this 
    IHostApplicationBuilder builder)
  {
    static void ConfigureSqlServer(IHostApplicationBuilder builder)
    {
builder.AddDbContextPool<IGamesRepository, 
        GamesSqlServerContext>(options =>
        {
          var connectionString = builder.Configuration.
            GetConnectionString("CodebreakerSql")
            ?? throw new InvalidOperationException("Could not read SQL 
            Server connection string");
          options.UseSqlServer(connectionString);
          options.UseQueryTrackingBehavior(
            QueryTrackingBehavior.NoTracking);
        });
         builder.EnrichSqlServerDbContext<GamesSqlServerContext>();
    }
    // code removed for brevity
    string? dataStore = builder.Configuration.
      GetValue<string>("DataStore");
    switch (dataStore)
    {
      case "SqlServer":
        ConfigureSqlServer(builder);
        break;
      default:
        ConfigureInMemory(builder);
      break;
    }
    builder.Services.AddScoped<IGamesService, GamesService>();
  }
}

配置的第一部分只是通常的 .NET EF Core 配置,用于指定 EF Core 数据库提供者和连接字符串。只需确保使用与应用程序模型定义一起使用的连接字符串密钥。连接字符串从 AppHost 项目转发到游戏 API 服务。值为 CodebreakerSql 的参数与已通过应用程序模型配置的数据库名称相匹配。使用 .NET Aspire 协调器(使用 Microsoft.Extensions.ServiceDiscovery)从协调器配置中获取具有此名称的连接字符串。

EnrichSqlServerDbContext 方法添加了 .NET Aspire 的重试、日志和指标配置。

这样,带有数据库配置的游戏 API 服务就设置好了。接下来,添加机器人服务和游戏 API 服务之间的交互。

配置与多个服务的交互

通过向分布式应用程序中添加一个项目来配置游戏 API 服务和机器人服务:

Codebreaker.AppHost/Program.cs

var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithReference(sqlServer);
builder.AddProject<Projects.CodeBreaker_Bot>("bot")
  .WithReference(gameAPIs);

使用 AddSqlServer 方法添加了 SQL Server Docker 容器。所有应该使用 .NET Aspire 协调的项目都需要使用 AddProject 方法添加。当将项目引用添加到 AppHost 项目时,会从源生成器创建项目类定义。将 Codebreaker.Bot 项目的引用添加后,创建了一个名为 Codebreaker_Bot 的类。所有项目类都定义在 Projects 命名空间内。

机器人服务需要引用游戏 API,因此使用 WithReference 方法生成隐式服务发现。机器人可以使用 http://gameapis 名称来引用游戏 API 服务。

使用 HttpClient 配置配置检索游戏 API 链接的配置:

Codebreaker.Bot/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder 
  builder)
{
  builder.Services.AddHttpClient<GamesClient>(client =>
  {
    client.BaseAddress = new Uri("http://gameapis");
  });
  builder.Services.AddScoped<CodeBreakerTimer>();
  builder.Services.AddScoped<CodeBreakerGameRunner>();
}

HttpClientBaseAddress 配置为游戏 API 的名称,如 gameapis 协调器配置中定义的,前面加上 http://。无需指定 .NET 配置来配置链接。

仅通过这些少数更新,我们就可以在下一步开始 Codebreaker.AppHost 项目。

使用 .NET Aspire 运行解决方案

使用 dotnet run 构建并运行 Codebreaker.AppHost 项目启动 SQL Server 的 Docker 容器、游戏 API、机器人服务和 .NET Aspire 仪表板。图 5.4 显示了 .NET Aspire 仪表板中所有启动的资源。从这里,你可以看到服务是否成功启动,以及访问为服务、日志以及可访问端点配置的环境变量:

图 5.4 – 仪表板中的 .NET Aspire 资源

图 5.4 – 仪表板中的 .NET Aspire 资源

使用资源时,请确保打开 详细信息 列以查看配置的环境变量。当使用 SQL Server Docker 容器检查日志时,你可以看到使用无效密码时的问题 – 例如,不符合要求的密码或与最初设置的卷不匹配的密码。

点击机器人端点以显示 OpenAPI 测试页面,你可以启动多个游戏运行,如图 5.5 所示。指定机器人应按顺序玩的游戏数量、游戏之间的延迟时间和每次移动的延迟时间,然后点击 执行 按钮:

图 5.5 – 机器人服务的 OpenAPI 测试页面

图 5.5 – 机器人服务的 OpenAPI 测试页面

通过机器人服务的日志,你可以监控机器人所玩游戏的实时信息。每当设置一个移动组合时,机器人会记录关于移动结果的好坏以及找到解决方案剩余的可能选项数量:

图 5.6 – 机器人服务的结构化日志

图 5.6 – 机器人服务的结构化日志

在服务运行后,你还可以访问游戏 API 服务,查看今天玩的游戏,并使用上一章创建的客户端玩一些游戏。使用 Docker CLI 监控正在运行的 Docker 容器,并检查创建的卷。

让我们深入了解 .NET 8 的一个令人兴奋的功能 – 原生 AOT – 下一步。

使用 ASP.NET Core 的原生 AOT

比较 Docker 镜像和虚拟机镜像,Docker 镜像要小得多,因为它们不需要包含操作系统。对于 ASP.NET Core 应用程序,Docker 镜像包含应用程序 – 以及 .NET 运行时。在过去几年中,镜像变得越来越小,因为进行了越来越多的优化。拥有更小的镜像意味着应用程序启动速度更快。

自从 .NET 7 以来,使用 C# 创建原生应用程序成为可能,使用 原生 AOT。因此,需要对 .NET 进行许多更改。在 .NET 7 中,原生 AOT 功能非常有限。在 .NET 8 中,我们已可以创建 ASP.NET Core 服务,这导致启动速度更快,内存占用更少。

使用原生 AOT,使用 AOT 编译器来编译 dotnet publish

并非所有应用程序都可以更改为使用本地 AOT:库不能动态加载,运行时代码生成不可行……使用本地 AOT,代码被精简,所有库都需要与本地 AOT 兼容。使用 .NET 8,EF Core 不是支持本地 AOT 的库之一。它位于路线图上,并计划在 EF Core 9 中提供部分支持。

通过创建基于微服务的解决方案,可以区分具有不同服务的不同技术。哪些是最常用的服务,其中本地 AOT 可以提供改进?

codebreaker 解决方案中,游戏服务可以通过更快的启动和更小的内存占用得到增强。这是解决方案中最重要的服务,用户应该在任何时间点都应获得快速响应。然而,由于 EF Core 的支持不足,使用 .NET 8,这仅适用于内存中的游戏提供程序。

要创建支持本地 AOT 的 API 项目,有一个模板可用:

dotnet new webapiaot -o Codebreaker.GameAPIs.NativeAOT

与此项目生成的最重要的区别是项目文件中的 <PublishAot>true</PublishAot> 设置。有了这个设置,使用 dotnet publish 编译应用程序为本地平台特定代码。因为编译器需要更多时间来编译本地代码,所以在开发时间,仍然生成 IL 代码,并使用 .NET 运行时。作为开发期间构建本地代码的帮助,分析器会运行,并在代码可能不兼容本地 AOT 时提供编译错误和警告。

使用 Codebreaker.GameAPIs.NativeAOT 项目,你可以开始从 Codebreaker.GameAPIs 项目复制代码,但需要进行一些更改。我们将重点关注本地区域 AOT 所需的更改。

OpenAPI 文档生成功能已被移除 - 包括增强 OpenAPI 文档的方法。此功能利用反射和动态代码生成,但不受支持。EF Core SQL Server 和 Cosmos 提供程序也从此项目中移除。相反,该项目仅使用内存中的游戏存储库。

注意

.NET 7 包含了非常有限的本地 AOT 功能。.NET 8 带来了更多功能,但许多库尚不支持。使用 .NET 8,你不能使用 ASP.NET Core 控制器,OpenAPI 文档不可用,认证库无法使用,并且大多数 EF Core 提供程序不支持本地 AOT。随着时间的推移,将添加更多功能以支持本地 AOT。

本地 AOT 不允许在运行时动态创建代码。在这里,源生成器非常有用。与使用反射发射在运行时创建代码不同,使用源生成器,代码是在编译时创建的。这不仅对本地 AOT 有优势;即使不使用本地 AOT,源生成器也可以提高运行时性能。

使用精简构建器

本地 AOT 服务使用了一个精简的应用程序构建器,如下面的代码片段所示:

Codebreaker.GameAPIs.NativeAOT/Program.cs

var builder = WebApplication.CreateSlimBuilder(args);

与默认构建器不同,DI 容器中注册的服务数量减少了。日志记录也减少了。仅配置了精简构建器的简单控制台日志提供程序。如果需要更多功能,可以添加额外的服务。为了进一步减少注册的服务数量,可以使用 CreateEmptyBuilder 方法。

使用 JSON 序列化器源生成器

需要将 System.Text.Json 序列化器的使用方式更改为使用源生成器。如果没有源生成器,序列化器将使用反射并在运行时创建代码。这不支持原生 AOT。为了在编译时生成代码,使用源生成器。要使用 System.Text.Json 源生成器,添加 AppJsonSerializerContext 类:

Codebreaker.GameAPIs.NativeAOT/Program.cs

[JsonSerializable(typeof(IEnumerable<Game>))]
[JsonSerializable(typeof(UpdateGameRequest))]
[JsonSerializable(typeof(UpdateGameResponse))]
[JsonSerializable(typeof(CreateGameResponse))]
[JsonSerializable(typeof(CreateGameRequest))]
[JsonSerializable(typeof(Game[]))]
internal partial class AppJsonSerializerContext : JsonSerializerContext
{
}

该类被声明为部分类,以允许源生成器创建额外的源,以扩展类并添加额外的成员。对于每个使用 JSON 序列化的类型,都会添加 JsonSerializable 属性。

此类与 JSON 序列化的 DI 配置一起使用:

Codebreaker.GameAPIs.NativeAOT/Program.cs

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0,
       AppJsonSerializerContext.Default);
});

使用此代码,将上下文类的默认实例添加到 System.Text.Json 序列化器的类型解析器中。

构建 Windows 版本

在删除 OpenAPI 代码、SQL Server 和 Cosmos 库引用,并将 PublishAot 元素添加到项目文件后,在成功构建之后,可以使用 dotnet publish 创建原生应用程序。这是创建 Windows 原生镜像的命令:

cd Codebreaker.GameAPIs.NativeAOT
dotnet publish -r win-x64 -c Release -o pubwin

使用 dotnet publish 并指定 win-x64 运行时标识符将启动原生编译器并将二进制文件写入 pubwin 目录。代码被精简以从二进制文件中删除未使用的类型和成员。结果,你将收到一个精简的原生可执行文件,不需要在目标系统上安装 .NET 运行时。启动应用程序时,可以使用任何客户端与内存提供程序一起玩游戏。

创建 Linux Docker 镜像

这是我们需要用于原生 AOT 游戏服务的新的 Dockerfile:

Codebreaker.GameAPIs.NativeAOT/Dockerfile

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    clang zlib1g-dev
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Codebreaker.GameAPIs.NativeAOT.csproj", "."]
RUN dotnet restore "./Codebreaker.GameAPIs.NativeAOT.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "./Codebreaker.GameAPIs.NativeAOT.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Codebreaker.GameAPIs.NativeAOT.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=true
FROM mcr.microsoft.com/dotnet/runtime-deps:8.0 AS final
WORKDIR /app
EXPOSE 8080
COPY --from=publish /app/publish .
ENTRYPOINT ["./Codebreaker.GameAPIs.NativeAOT"]

要将应用程序编译为原生代码,使用相同的 SDK 包含的基础镜像,需要在 Linux 环境中安装 clangzlib1g-dev 依赖项。这是在复制项目文件之前的第一步。对于生产环境,使用不同的基础镜像:dotnet/runtime-deps。这是包含 .NET 所需原生依赖项的新基础镜像。此镜像不包含 .NET 运行时;相反,它可以用于自包含应用程序。

使用以下命令构建 Docker 镜像:

docker build . -f Codebreaker.GameAPIs.NativeAOT\Dockerfile -t codebreaker/gamesapi-aot:latest -t codebreaker/gamesapi-aot:3.5.6

使用原生 AOT 容器运行解决方案

在构建镜像之后,你可以启动 Docker 容器,并使用不同的客户端(例如,机器人服务、HTTP 文件和上一章中的客户端)来测试服务。通过这种方式,你还可以进行一些性能比较——但请记住,为了与原生 AOT 兼容,已经移除了一些功能。

注意

在 .NET 8 中,原生 AOT 处于早期阶段。我预计许多库将及时更新以支持原生 AOT。在微服务架构中,对于可以从快速启动时间中受益的服务,已经使用原生 AOT 可能是有用的。原生 AOT 服务可以利用 gRPC(在第十四章中介绍,二进制通信的 gRPC),并且通过 gRPC 可访问的服务可以访问数据库。无论如何,非 AOT 服务也可以从你在这里看到的特性中获得改进,例如精简构建器或 JSON 序列化器源生成器。

摘要

在本章中,你学习了 Docker 的基础知识,包括拉取、创建和运行 Docker 镜像。你使用了存储状态的容器,在 Docker 容器中运行数据库,并将环境变量和机密信息传递给正在运行的 Docker 容器,还使用了 .NET Aspire 来一次性运行多个容器。

使用 .NET Aspire,你为多个服务配置了编排,包括 SQL Server Docker 容器的配置。与使用 Docker 所需的工作相比,这是一个简单的任务——但仍然有用了解其基础。

使用原生 AOT,你减少了启动时间和内存占用,这你可能可以用在你的某些服务上。

在进入下一章之前,使用机器人,你现在可以轻松地玩成千上万的游戏。机器人使用一个简单的算法从可能的移动列表中设置随机移动。使用游戏查询,检查机器人需要多少步才能找到结果。尝试使用你在上一章中创建的客户端来访问本章中的 Docker 容器,你能用更少的步骤解决游戏吗?

正如你在本章中看到的,你可以在 Docker 容器中运行数据库。有了这个,你仍然需要以与你在本地安装的数据库相同的方式管理你的数据库。下一章中你将看到的另一个选项是使用 平台即服务PaaS)云服务,例如 Azure Cosmos DB。在下一章中,我们将创建 Azure 资源,并将本章中创建的 Docker 镜像发布到 Azure 容器注册库ACR)和 Azure 容器应用。

进一步阅读

要了解更多关于本章讨论的主题,你可以参考以下链接:

第六章:Microsoft Azure 用于托管应用程序

在使用前几章创建 Docker 镜像并本地使用 Docker 容器运行完整应用程序之后,让我们转向使用 Microsoft Azure 运行解决方案。

在本章中,您将学习如何将 Docker 镜像推送到 Azure 容器注册表,使用 Azure 容器应用运行 Docker 容器,使用 Azure Cosmos DB 访问数据库,以及使用 Azure 容器应用配置环境变量和密钥。

使用 Bicep 脚本,您将学习如何一次性创建多个 Azure 资源。

在本章中,您将了解以下主题:

  • 体验 Microsoft Azure

  • 创建 Azure 资源

  • 创建 Azure Cosmos 数据库

  • 推送镜像到 Azure 容器注册表ACR)实例

  • 创建 Azure 容器应用

  • 使用 .NET Aspire 和 Azure 开发者 CLIazd)创建 Azure 资源

技术要求

对于本章,您需要安装 Docker Desktop。您还需要一个 Microsoft Azure 订阅。您可以在 azure.microsoft.com/free 上免费激活 Microsoft Azure,这将为您提供约 200 美元的 Azure 信用额度,这些信用额度在前 30 天内可用,以及一些在此之后可以免费使用的服务。

许多开发者可能会错过:如果您拥有 Visual Studio Professional 或 Enterprise 订阅,您每月也有免费额度的 Azure 资源。您只需使用您的 Visual Studio 订阅激活此服务:visualstudio.microsoft.com/subscriptions/

要完成本章的示例,除了 Docker Desktop,还需要 Azure CLI 和 azd

要创建和管理资源,请安装 Azure CLI 和 azd

winget install microsoft.azureCLI
winget install microsoft.azd

这些工具也适用于 Mac 和 Linux。要在不同平台上安装 Azure CLI,请参阅 learn.microsoft.com/cli/azure/install-azure-cli,而对于 azd,请参阅 learn.microsoft.com/azure/developer/azure-developer-cli/install-azd

使用 Azure Cloud Shell 的一个简单方法是通过网页浏览器。当您使用 Microsoft Azure 账户登录到portal.azure.com的 Azure 门户时,在顶部的按钮栏上,您会看到一个代表Cloud Shell的图标。点击此按钮,会打开一个终端。在这里,Azure CLI 已经安装好了——以及许多其他工具,如wget用于下载文件,git用于处理仓库,docker,.NET CLI 等。您还可以使用 Visual Studio Code 编辑器(只需在终端中运行code)来编辑文件。您创建和更改的所有文件都保存在一个 Azure 存储账户中,该账户在您启动 Cloud Shell 时自动创建。对于全屏的 Cloud Shell,您可以通过shell.azure.com打开。

本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch06文件夹中,这些是重要的项目:

  • Codebreaker.GameAPIs – 我们在上一章中使用的gamesAPI项目。有一个更改:不再包含带有数据库访问代码和模型的项目的引用,而是使用了 NuGet 包。

  • Codebreaker.Bot – 调用游戏 API 的机器人服务。

  • Codebreaker.AppHost – 本章中此项目包含了一些重要更改,用于使用 Azure 资源定义应用程序模型。

  • Codebreaker.ServiceDefaults – 本章中此项目未做任何更改。

您可以从上一章的结果开始,通过本章继续进行自己的工作。

体验微软 Azure

微软 Azure 提供来自许多不同类别的云服务。您可以创建属于基础设施即服务IaaS)类别的虚拟机VMs),在这里您控制着机器,但同时也需要像在本地环境中一样管理它们,直到可以使用来自软件即服务SaaS)类别的如 Office 365 等现成的软件。介于两者之间的是平台即服务PaaS),在这里您对虚拟机没有完全控制权,但可以轻松获得许多开箱即用的功能。

这里的重点是 PaaS 服务。在 PaaS 类别中,还有一个名为无服务器的类别。这个类别允许从零开始轻松扩展,从没有或低成本的关联,到基于需求的自动扩展的最大量。许多 Azure 服务在这个类别中都有提供。

成本

在云环境中创建资源时,总会有关于成本的问题。许多人害怕需要支付意外金额,但这种恐惧是不必要的。一些订阅(如 Visual Studio 订阅)每月都有可用金额的限制。如果达到这个金额,资源将自动停止(除非您明确允许成本超过限制),因此不会产生额外费用。

使用订阅以及仅使用资源组,您可以指定预算以指定计划支出的金额。为此,请打开 Azure 门户并选择一个资源组。在资源组内,您将看到成本管理类别中的预算选项。通过创建预算(见图 6.1),您可以按月定义限制。在达到此限制之前,您可以指定警报,以便您能够收到通知。通过警报,您可以指定通过电子邮件、短信、推送或语音通知接收通知,并且除此之外,您还可以定义一个应调用的操作,以调用 Azure 函数、逻辑应用、自动化运行手册或其他 Azure 资源,在这些资源中可以实现自定义功能。根据使用情况和需求,停止服务可能是一个选项:

图 6.1 – 指定预算

图 6.1 – 指定预算

要获取有关服务的价格信息,请访问 azure.microsoft.com,您可以选择Azure 价格,搜索产品,或从类别中选择产品以获取不同提供物的详细信息。您还将看到定价计算器,您可以在其中选择多个产品,并根据所做的选择获取完整的价格信息。

命名约定以及更多

在 Azure 中创建资源时,我们应该考虑一些重要的基础,以便根据 IT 和业务组织的需求轻松找到资源。哪些资源用于生产,哪些用于测试环境?公司中不同的组织使用哪些资源?哪些资源由一个产品使用?哪些资源可能受到技术问题的影响?对于所有这些场景,这些功能都有帮助:

  • 每个资源都需要放入一个codebreaker解决方案中,将为测试和生产环境创建资源组。

  • 还应该能够在资源组中轻松找到多个资源。资源标签可以在这里使用。

  • 定义一个命名资源的约定。资源的数量会随着时间的推移而增长!您可能需要创建多个实例进行扩展,在不同地区运行相同的服务以获得更好的延迟,在不同环境中运行服务……有许多原因导致资源数量增加。为了应对这种情况,从一开始就使用一个好的命名策略可以大有帮助!

使用 codebreaker 应用程序,我们可以为开发、测试和生产环境使用 rg-codebreaker-devrg-codebreaker-testrg-codebreaker-prod 资源组。

注意

除了使用不同的资源组来分隔环境外,将开发和生产环境分开到不同的订阅中也是一个好的做法。因为有一个 Azure 订阅,在 Visual Studio 订阅中提供了一些免费额度,这个订阅可以与开发环境一起使用。

一些资源被跨多个资源组使用。例如,你可能使用一个中央 Azure DNS 资源。你也可能在不同应用程序之间共享资源。你可以共享一个托管许多小型网站的 Azure 应用服务。对于每个 Azure 资源,你都可以添加自定义标签,并使用不同的标签及其值来搜索资源。例如,你可以指定一个名为 cc(代表成本中心)的标签,其值指定了成本中心。

为了定义资源的命名规范,Microsoft 不仅有一份指南(可在 learn.microsoft.com/azure/cloud-adoption-framework/ready/azure-best-practices/resource-naming 查找),还提供了一个可用的 Excel 模板(可在 https://raw.githubusercontent.com/microsoft/CloudAdoptionFramework/master/ready/naming-and-tagging-conventions-tracking-template.xlsx 查找),甚至还有一个可以托管在本地(或云端)的 Blazor 应用程序,供管理员使用简单的用户界面来管理命名规范:https://github.com/mspnp/AzureNamingTool。

可以作为资源名称一部分的组件包括以下内容:

  • 资源类型。Microsoft 提供了一组建议的缩写;例如,rg 代表资源组,cosmos 代表 Azure Cosmos DB 数据库,cr 代表 ACR,ca 代表容器应用,以及 cae 代表容器应用环境。

  • 项目、应用程序或服务名称。我们将使用 codebreaker 作为应用程序名称。

  • 资源使用的环境;例如,prod 代表生产,dev 代表开发,test 代表测试。

  • Azure 资源的位置;例如,eastus2 代表第二个东 US 区域,而 westeu 代表西欧。在多个区域创建资源对于故障转移场景、为全球客户提供更好的性能以及遵守数据法规都很有用。

现在,我们已准备好创建 Azure 资源。

创建 Azure 资源

使用 Microsoft Azure,有不同方式来创建和管理 Azure 资源。Azure 资源可以通过 REST API 访问。您可以发送GET请求来读取有关资源的信息,发送POST请求来创建新资源,但当然,还有更简单的方法来做这件事。Azure 门户(portal.azure.com)是学习并查看您拥有的不同选项的好方法。要自动创建 Azure 资源,您可以使用 Azure CLI、PowerShell 脚本以及许多其他选项。在本书中,我们将使用 Azure 门户、Bicep 脚本、.NET Aspire 和azd。Bicep 脚本提供了来自 Microsoft 的简单语法,可以轻松地重新创建 Azure 资源。.NET Aspire 允许使用.NET 代码定义 Azure 资源并直接创建资源。

在公司环境中,有不同方式创建 Azure 资源以及团队的组织方式。.NET Aspire 与azd一起提供了创建 Azure 资源的强大功能,但这可能(目前可能)不适合您的环境。您也可以选择使用适合您公司环境的.NET Aspire 的部分,或者使用.NET Aspire 和azd提供的一切。第二个选项是最简单的。为了更好地理解选项,并让您将其映射到您的环境中,我们将开始使用 Azure CLI 和 Azure 门户。这样,您可以轻松地看到资源提供的配置选项。在本章的后面部分,我们将使用.NET Aspire 和azd。使用.NET 代码指定 Azure 资源只需要几条语句就可以创建解决方案所需的所有资源。

我们创建了哪些资源?在本节中,我们将执行以下操作:

  1. 创建一个资源组,将所有 Azure 资源组合在一起。

  2. 创建一个 Azure Cosmos DB 数据库,该数据库被添加到之前创建的资源组中,并用于我们在第三章中创建的 EF Core 上下文。

  3. 创建一个 Azure 容器注册库来发布我们在第五章中创建的 Docker 镜像。

  4. 创建两个 Azure 容器应用来运行gamesAPI服务和机器人服务。

创建资源组

资源组用于一起管理 Azure 资源。使用资源组,您可以指定允许谁在资源组内创建或管理资源的权限。从价格的角度来看,您可以轻松地看到整个资源组的成本以及该资源组中的哪些资源导致了哪些成本。您还可以删除资源组,这将删除组内的所有资源。

要创建资源组,让我们使用 Azure CLI。

要登录 Azure,请使用以下命令:

az login

此命令将打开默认浏览器以验证用户身份。

如果您有多个 Azure 订阅,您可以使用az account list来检查这些订阅。当前创建资源的活动订阅通过az account show显示。

要创建资源组,请使用 az group 命令:

az group create -l westeurope -n rg-codebreaker-test

create 子命令创建资源组。使用 -l,我们指定此 Azure 资源的位置。在这里,我使用 westeurope 因为这个区域靠近我的位置。使用 -n 值,设置资源组的名称。

资源组的地理位置与资源组内资源的地理位置无关。资源组内的资源可以位于其他区域。资源组只是元数据。资源组的地理位置指定了资源组的主要位置。在位置不可用的情况下,你无法更改资源组。

要获取你的订阅可用的区域,你可以使用 az account list-locations -o table

资源组创建后,我们可以在该资源组内创建资源。

创建 Azure Cosmos DB 账户

第三章 中,我们使用了 Azure Cosmos DB 模拟器来存储游戏和移动。现在,让我们将其更改为 Azure 云中的真实数据库。首先,我们将使用 Azure 门户来创建 Azure Cosmos DB 账户。

在 Azure 门户中,通过点击 Azure Cosmos DB。当你点击 Azure Cosmos DB 资源上的 创建 时,这并不会立即创建资源。相反,你需要在之前进行一些配置。

使用 Azure Cosmos DB 资源时,你首先需要选择可用的 API 之一。请参阅 第三章 了解可用的不同 API 以及它们提供的功能。现在,选择 Azure Cosmos DB for NoSQL,然后点击 创建 按钮。这将打开配置,如图 图 6.2 所示:

图 6.2 – 创建 Azure Cosmos DB 账户

图 6.2 – 创建 Azure Cosmos DB 账户

在你可以点击 cosmos-codebreaker-test 进行测试环境之前,你有一些配置页面,但请注意此名称需要是全球唯一的),以及容量。Azure Cosmos DB 为订阅提供了一级免费层。如果你还没有使用过你的订阅,你可以选择此选项。这为你提供了每秒 1,000 个 请求单位RU/s)和 25 GB 的存储空间。使用预配吞吐量,你可以通过数据库或数据库容器定义 RU/s 的限制,至少 400 RU/s。无服务器选项从更高的最小限制开始,但会自动扩展到所需的 RU/s。使用无服务器时,你需要注意一些限制。使用无服务器时,最大数据库容器大小为 1 TB;预配配置没有限制。无服务器也不支持地理分布,这在预配设置中是可用的。

注意

创建 Azure Cosmos DB 账户会注册一个 DNS 名称,因此该名称需要是全球唯一的。对于您的账户,您可以在账户名称后添加一个数字,并通过点击 审查 + 创建 来检查您选择的名字是否可用。

在下一个配置中,您可以配置数据库的全局分布、网络、自动创建备份的策略、使用服务管理密钥或客户管理密钥的加密,以及标签(每个资源都可用)。您可以使用除基本配置之外的所有设置的默认值。点击 审查 + 创建 按钮,进行最终检查,然后点击最终的 创建 按钮。现在,您只需等待几分钟,直到数据库账户创建完成。

使用 Azure CLI,您可以使用 az cosmosdb create 命令。

数据库账户已创建!接下来,我们将创建 Azure 容器注册库。

注意

第三章 中,我们不仅创建了一个用于写入 Azure Cosmos DB 的库,还用于 SQL Server。使用 Microsoft Azure,您还可以配置 Azure SQL 数据库。只需注意开发环境中的低成本;选择 数据库事务单元DTU)层而不是 vCore 层。有 5 个基本 DTU,每月只需低于 5 美元(在撰写本文时),对于 2 GB 存储空间(与分配有 2 个 vCore 的虚拟机相比,400 美元)。

创建 Azure 容器注册库

在上一章中,我们创建了 Docker 镜像并在本地使用它们。您可以将 Docker 镜像发布到 Docker Hub 或任何容器注册库。ACR 为 Docker 镜像提供了一个注册库,它与 Microsoft Azure 集成得很好。

在创建 ACR 实例时,有三个不同的层可供选择:

  • codebreaker 应用程序,基本层(SKU)适合此用途,并且比其他选项便宜得多。您只需注意限制即可。

  • 标准版:标准层提供更多存储空间(基本层限制为 10 GB 存储)和镜像吞吐量。

  • 高级版:高级版添加了一些功能,例如地理复制可以在不同地区复制镜像和私有访问点。

图 6**.3 展示了如何通过门户创建 ACR 实例。在搜索框中点击 容器注册库。选择 仅 Azure 服务复选框不会显示许多第三方产品:

图 6.3 – 创建容器注册库

图 6.3 – 创建容器注册库

在配置中,我们只需要资源组的名称、注册库的名称、位置和 SKU。可用区域,其中图像存储在同一地区的不同数据中心,仅在 Premium 层中可用。其他更改网络和加密的配置也需要 Premium 层。

在填写此表格后,在点击 Review + create 之前,您仍然可以验证所有选项,然后再点击 Create 来创建资源。

注意

注册表的名称是一个全局可用的 DNS 名称(带有 azurecr.io 扩展),因此需要是唯一的。选择您自己的名称,只要创建资源时使用的是可用的名称,资源就可以成功创建。

现在我们有一个数据库和一个用于运行容器镜像的注册表。在创建了第一个资源之后,我们只需要一个计算服务来运行 Docker 镜像,就可以在云端运行应用程序。我们将使用 Azure 容器应用。

创建 Azure 容器应用环境

Microsoft Azure 提供了多种计算服务来运行 Docker 容器。您可以将 Docker 镜像发布到Azure 应用服务,并使用 Windows 或 Linux 服务器来运行您的 API。另一个选项是使用Azure 容器实例ACI),它允许您托管一组 Docker 容器,包括一个前端容器(API 服务)和多个后端容器。虽然 Azure 应用服务提供自动扩展功能,可以根据规则创建多个实例,但此功能在 ACI 中不可用。ACI 在快速启动方面很出色——您只需启动一个虚拟机,只需上传较小的 Docker 镜像,但它不提供编排和扩展功能。

对于 Docker 容器的全面编排和扩展,Azure 提供 kubectl。为了简化 Kubernetes 的复杂性,定义一个入口控制器只需更改一些设置;Azure 容器应用实例即可使用。此服务在幕后使用 Kubernetes,但去除了许多复杂性。

让我们开始创建 Azure 容器应用。

创建日志分析工作区

在创建 Azure 容器应用时,为日志记录留出空间是一个好主意。在 Azure 容器应用的先前版本中,有一个要求必须有一个日志分析工作区。这不再是要求,因为您还可以使用 Azure Monitor 将日志记录到 Azure 存储帐户、Azure 事件中心或第三方监控解决方案。Azure Monitor 还可以配置为将日志路由到日志分析。

日志分析工作区是用于分析数据和指标的日志数据存储单元。在 第十章 日志记录 中,我们将深入了解微服务的日志记录和指标,并利用日志分析、Azure Monitor 和 Application Insights 获取有关运行服务的详细信息。

要创建日志分析工作区,我们将使用 Azure CLI:

az monitor log-analytics workspace create -g rg-codebreaker-test -n logs-codebreaker-test-westeu

日志分析属于 Azure Monitor,因此使用 az monitor log-analytics 命令来创建和管理日志分析。使用 workspace create 子命令创建一个日志分析工作区。此命令需要资源组和工作区的名称。如果命令中没有提供位置,则工作区将使用与资源组相同的地理位置。

创建容器应用环境

创建容器应用环境在幕后使用一个 Kubernetes 集群。您可以创建此环境来自动创建日志分析工作区。使用现有的工作区(我们在上一步中创建了一个),我们需要客户 ID 和工作区的密钥。使用以下命令获取客户 ID:

az monitor log-analytics workspace show -g rg-codebreaker-test -n logs-codebreaker-test-westeu --query customerId

如果不提供 --query customerId,您将获得关于工作区的更完整信息,包括 customerId 值。使用 --query 命令,我们可以通过查询提供 customerId,仅返回此 id 的唯一标识符(一个 GUID)。将此 GUID 以及下一个命令中的密钥一起复制,因为我们创建环境时需要这些值。

此命令返回连接到日志工作区的密钥:

az monitor log-analytics workspace get-shared-keys -g rg-codebreaker-test -n logs-codebreaker-test-westeu

输出返回主密钥和辅助密钥。复制主密钥。

使用客户 ID 以及日志分析工作区的密钥,我们可以创建一个容器应用环境:

az containerapp env create -g rg-codebreaker-test -n cae-codebreaker-test-westeu --logs-workspace-id <customer-id> --logs-workspace-key <logs-key> --location westeurope

要创建环境,您需要指定资源组、环境名称、连接日志分析的信息以及新创建资源的位置。如果未提供位置,则此命令不使用资源组的位置。请注意,此命令可能需要几分钟。但想想您手动创建 Kubernetes 集群需要多少分钟。

创建一个 hello 容器应用

在创建环境后,让我们在这个环境中创建我们的第一个应用:

az containerapp create -n ca-hello-westeu -g rg-codebreaker-test --environment cae-codebreaker-test-westeu --image mcr.microsoft.com/azuredocs/containerapps-helloworld:latest --ingress external --target-port 80 --min-replicas 0 --max-replicas 2 --cpu 0.5 --memory 1.0Gi

使用 create 命令创建一个新的应用。此应用的名称由 -n 参数指定。环境由资源组(-g)和 --environment 参数指定。--image 参数引用的镜像是从 Microsoft 提供的示例 Docker 镜像,它托管了一个带有静态页面的 Web 服务器。要访问容器内运行在 80 端口的 Web 服务器,需要配置 Ingress 服务,使用 --ingress--target-port 参数。使用 --min-replicas--max-replicas 参数定义扩展,从 0 扩展到 2 个实例。在有 0 个实例的情况下,第一个访问服务的用户需要等待容器启动。根据提供的配置,应用程序扩展到 2 个运行中的容器。一个容器分配了 0.5 个 CPU 和 1.0 GiB 内存。

注意

第九章第十一章 将为您提供有关服务扩展的信息。在 第九章 中,您将创建负载测试以对服务进行压力测试,在 第十章 中,我们将使用这些负载测试来监控指标信息,而在 第十一章 中,我们将利用前两章学到的信息来配置扩展。

当应用创建完成后,会显示应用服务的链接。您也可以使用此命令获取 URL:

az containerapp show -n ca-hello-westeu -g rg-codebreaker-test --query properties.configuration.ingress.fqdn

containerapp show 命令显示 Azure 容器应用的属性。使用 properties.configuration.ingress.fqdn JMESPath 查询返回的 https:// 实例显示了正在运行的应用程序(见 图 6**.4):

图 6.4 – 访问 hello Azure 容器应用

图 6.4 – 访问 hello Azure 容器应用

现在,使用 Azure 门户打开 rg-codebreaker-test 资源组,我们可以看到 Azure Cosmos DB、ACR、Azure 容器应用环境、日志分析工作区和容器应用,如图 图 6**.5 所示。只需检查 资源组 视图左侧的类别选项。概览 视图显示了资源,如这里所示。点击 访问控制,您可以配置谁可以访问此组中的资源。活动日志 显示了在此组内创建、更新和删除资源的人员。资源可视化器 提供了资源及其相互关系的图形视图。成本管理 类别也可能很有兴趣。

您可能需要等待一天才能看到每个资源的详细成本。使用我们使用的层级,成本将在几分钱之内。但您也可以点击 推荐 来查看在生产环境中应该更改和配置的内容。其中一些推荐需要不同的层级,您需要检查成本变化。如果您的公司已经经历过黑客攻击公司网站的情况,那么与黑客攻击的成本相比,使用 Microsoft Azure 启用安全功能的成本非常低:

图 6.5 – 包含 Azure 资源的资源组

图 6.5 – 包含 Azure 资源的资源组

现在,随着 Azure 资源创建完成,让我们将 codebreaker 服务发布到 Microsoft Azure。

让我们从在 Azure Cosmos DB 帐户内创建 Azure Cosmos 数据库开始。

创建 Azure Cosmos 数据库

从 Azure 门户,您可以打开 Azure Cosmos DB 帐户的页面,打开 数据探索器,然后从那里点击 新建数据库 来创建新数据库,以及点击 新建容器 在数据库中创建容器。这里,我们将使用 Azure CLI:

az cosmosdb sql database create --account-name <your cosmos account name> -n codebreaker -g rg-codebreaker-test --throughput 400

此命令在现有帐户中创建一个名为 codebreaker 的数据库。使用此命令设置吞吐量选项定义了数据库的规模。在此,此数据库中的所有容器共享 400 RU/s 的吞吐量。400 是可以设置的最小值。在创建数据库时,除了提供此值外,还可以通过每个容器配置扩展。如果某些容器不应从其他容器中获取扩展,则可以针对每个容器配置 RU/s – 但在此,每个容器使用的最小值也是 400。

创建数据库后,让我们创建一个容器:

az cosmosdb sql container create -g rg-codebreaker-test -a <your cosmos account name> -d codebreaker -n GamesV3, --partition-key-path "/PartitionKey"

gamesAPI 服务的实现使用了一个名为 GamesV3 的容器。该容器是在之前创建的数据库中创建的,使用 /PartitionKey 分区键,正如在 第三章 中使用 EF Core 上下文所指定的。

在此命令完成后,检查 Azure 门户中的 数据探索器,如 图 6.6* 所示:

图 6.6 – 数据探索器

图 6.6 – 数据探索器

您可以看到数据库、容器,以及与容器一起配置的分区键。

配置 Azure Cosmos DB 的复制

Azure Cosmos DB 的一个出色功能是全球数据复制。在 Azure 门户中,在 设置 类别中,单击 全局复制数据图 6.7* 显示了复制视图:

图 6.7 – 使用 Azure Cosmos DB 进行复制

图 6.7 – 使用 Azure Cosmos DB 进行复制

您只需点击您的订阅中可用的 Azure 区域,即可在所选区域内复制数据。您还可以配置将其写入多个区域。

在全球范围内,对于 codebreaker 应用程序,用户可以在全球范围内玩游戏,为了提高美国、欧洲、亚洲和非洲用户的性能,可以配置写入多个区域。为此选项可用,不能配置自动扩展。为了在全球范围内实现最佳可伸缩性,我们还需要考虑分区键。通过为每个存储的游戏使用不同的分区键值,游戏可以存储在不同的分区中。

配置一致性

在 Azure Cosmos DB 的 Azure 门户中的 设置 类别中,我们可以配置默认的一致性级别。结果使用音符、读取和写入多个区域的方式显示,如 图 6.8* 所示:

图 6.8 – 使用音符显示的结果

图 6.8 – 使用音符显示的结果

默认设置是 会话一致性 – 数据在同一个会话中是一致的。使用此设置,写入延迟、可用性和读取吞吐量与 最终一致性 相当。使用 Azure Cosmos DB API,可以在应用程序中创建和分发会话。

如果配置了多个区域,则 强一致性 选项不可用。对于多个区域,可以配置 有界过时性,它指定了在数据一致复制之前的最大延迟时间和最大延迟操作数。

数据库现在已准备好使用,让我们将 Docker 镜像发布到注册表中!

将镜像推送到 ACR 实例

ACR 实例已准备就绪,我们在上一章中创建了 Docker 镜像 – 现在,让我们将镜像发布到这个注册表中。

在您使用 az login 登录到 Microsoft Azure 后,要登录到 ACR 实例,可以使用 az acr login。请确保使用您为 ACR 实例定义的名称:

az login
az acr login -n <the name of your azure container registry>

此命令需要安装并运行 Docker Desktop。

注意

使用 Azure CLI 引用 ACR 实例,只需要注册表的名称(例如 codebreakertest)。dockerdotnet 命令支持不同的注册表,因此使用这些命令时,需要完整的域名,例如 codebreakertest.azurecr.io

接下来,让我们构建镜像。在上一章中,我们为游戏 API 创建了 Dockerfile。使用 Windows 终端,确保将当前目录设置为 ch06 文件夹,并在本地构建游戏镜像:

docker build -f Codebreaker.GameAPIs\Dockerfile . -t codebreaker/gamesapi:3.5.1

此命令——就像上一章中一样——在本地构建 Docker 镜像,引用 Dockerfile,设置 docker build 的上下文,并设置标记。

要将镜像发布到 ACR,我们需要对本地镜像进行标记:

docker tag codebreaker/gamesapi:3.5.1 <full DNS name of your ACR>/codebreaker/gamesapi:3.5.1
docker tag codebreaker/gamesapi:3.5.1 <full DNS name of your ACR>/codebreaker/gamesapi:latest

镜像被标记为指向 ACR 实例的链接。相同的镜像还标记了版本号以及 latest 标签。latest 标签是一种约定,其中存储最新版本,并且总是覆盖存储库中的版本。

接下来,使用 docker push 将镜像推送到注册表:

docker push <full DNS name of your ACR>/codebreaker/gamesapi:3.5.1
docker push <full DNS name of your ACR>/codebreaker/gamesapi:latest

确保您已经登录到 ACR 实例;否则,推送将失败。

成功推送后,您可以在 Azure 门户的 存储库 菜单中看到镜像,如图 图 6.9 所示:

图 6.9 – ACR 中的存储库

图 6.9 – ACR 中的存储库

在上一章中,我们没有为机器人服务创建 Dockerfile,而是使用了 dotnet CLI。使用 dotnet publish,我们只需将此 PropertyGroup 实例添加到项目文件中:

Codebreaker.Bot/Codebreaker.Bot.csproj 项目文件

<PropertyGroup>
<ContainerRegistry>add your registry
  </ContainerRegistry>
  <ContainerRepository>codebreaker/bot
  </ContainerRepository>
  <ContainerImageTags>3.5.3;latest</ContainerImageTags>
</PropertyGroup>

dotnet publish 命令使用 ContainerRegistryContainerRepositoryContainerImageTags 元素来创建镜像并将其发布到注册表。请注意使用 ContainerRegistry 元素配置您自己的注册表。

对于当前目录需要做的所有事情就是设置机器人的项目文件目录并运行 dotnet publish

cd Codebreaker.Bot
dotnet publish --os linux --arch x64 /t:PublishContainer -c Release

此命令构建镜像并将其直接发布到注册表,如 ContainerRegistry 元素中指定。只需确保输入您的注册表链接,并使用 docker login 登录。

随着镜像准备就绪,让我们继续使用 Azure 容器应用来使用它们!

创建 Azure 容器应用

让我们创建一个使用 Azure 容器应用运行的 gamesAPI 服务。这个服务需要一个包含对 Azure Cosmos 数据库的密钥的配置。

在 Azure 门户中,使用 Azure Cosmos 数据库,转到 设置 类别并打开 密钥。从该页面复制主连接字符串或辅助连接字符串。

使用这些密钥时,定期重新生成它们是有用的——这就是为什么有配对的原因。当你使用应用的主密钥时,重新生成辅助密钥。重新生成后,在应用内部使用辅助密钥,并重新生成主密钥。这样,你就有时间配置所有应用的新密钥。

当为游戏 API 创建 Azure 容器应用时,有许多值需要配置。虽然你可以将所有配置值传递给az containerapp create命令,但让我们从 Azure 门户开始创建。打开 Azure 容器应用环境资源,点击应用,创建一个新的应用。图 6**.10显示了基本设置:

图 6.10 – Azure 容器应用的基本设置

图 6.10 – Azure 容器应用的基本设置

在基本设置中,需要配置以下值:

  • 资源订阅。

  • 资源组(rg-codebreaker-test)。

  • 容器应用的名称。我们使用cae-codebreaker-gamesapi-3。后缀 3 表示该 API 的版本 3。你可以并行运行此应用的多个版本。

  • 区域——选择最适合您位置的区域。

  • 容器应用环境。选择之前创建的环境。

容器应用的配置屏幕如图 6.11所示:

图 6.11 – Azure 容器应用的容器设置

图 6.11 – Azure 容器应用的容器设置

在这里,我们通过选择 ACR 实例和镜像名称和标签来选择将要发布的镜像,为单个运行实例分配的 CPU 和内存资源,以及环境变量。将DataStorage环境变量设置为Cosmos将覆盖appsettings.json文件中定义的值。

图 6**.12显示了入口配置:

图 6.12 – Azure 容器应用的入口设置

图 6.12 – Azure 容器应用的入口设置

我们需要启用由.NET 8 镜像定义的8080

点击创建将根据 ACR 实例获取镜像来创建 Azure 容器应用。请注意,启动应用将失败,因为连接到 Cosmos 数据库的连接字符串仍然需要配置。我们将在创建机器人服务应用后进行此操作。

要创建机器人服务的应用,请在 Azure 门户中打开新创建的容器应用,并从概览视图复制应用 URL。此 URL 用于配置机器人。

当为机器人服务创建应用时,你可以像配置游戏 API 一样进行配置。创建一个名为ApiBase的环境变量,其值为游戏 API 的应用 URL。

我们仍然需要添加一些配置值,我们将在下一步进行。

配置密钥和环境变量

在创建应用时,无法直接通过门户定义应用密钥。这可以通过 az containerapp create 命令直接完成。

使用门户,可以在之后配置密钥。当在 Azure 门户中打开容器应用时,在 cosmosconnectionstring,如图 图 6.13 所示,并将从 Azure Cosmos DB 复制的连接字符串复制到值:

注意

图 6.13 中的截图显示了另一个可以存储密钥的选项:密钥保管库引用。在 第七章 中,我们将讨论使用其他选项来使用配置,这些选项包括 Azure 密钥保管库

图 6.13 – 使用 Azure 容器应用的密钥配置

图 6.13 – 使用 Azure 容器应用的密钥配置

要创建一个引用密钥的环境变量,我们可以使用 Azure CLI:

az containerapp update -n cae-codebreaker-gamesapi-3 -g rg-codebreaker-test --set-env-vars ConnectionStrings__GamesCosmosConnection=secretref:cosmosconnectionstring

使用 az containerapp update 命令时,我们需要引用容器应用和资源组,并使用 --set-env-vars 设置环境变量。与通过命令行传递具有 : 作为分隔符的分层配置值不同,例如 ConnectionStrings:GamesCosmosConnection,使用环境变量时不能使用 :。相反,在这里,__ 用于映射值。用于连接到 Azure Cosmos DB 实例的键是 ConnectionStrings__GamesCosmosConnection。这个值的存储在密钥中。密钥通过 secretref 后跟密钥来引用。

应用现在应该正在运行,但让我们确保配置了缩放。

使用 Azure 容器应用配置缩放

Azure 容器应用默认配置的缩放范围是从 0 到 10。如果没有应用负载,它会缩放到 0,此时 CPU 和内存成本降低到零。然而,缩放到 0 也意味着第一个访问服务的用户需要等待几秒钟,服务才能返回结果。对于在后台运行且在第一次调用后不需要用户交互的机器人服务,这可能是足够的。对于从消息或事件触发的应用作业,这也行得通。然而,对于 gamesAPI 服务,对于第一个访问服务的用户,它应该能够快速响应。

将最小缩放设置为 1,如果没有负载,CPU 的价格会降低。在空闲定价中,内存没有价格差异,但与运行中的服务相比,CPU 的成本大约是 10%。

让我们配置gamesAPI服务从 1 个副本扩展到 3 个副本,以及机器人服务从 0 个副本扩展到 3 个副本。在 Azure 门户中,选择容器应用,应用程序类别,以及扩展和副本菜单。点击编辑和部署菜单,选择扩展,根据应用程序更改副本数量为 1 到 3 和 0 到 3。如果 UI 元素不易移动以相应地更改值,你可以使用箭头键逐个更改值。在撰写本文时,最大扩展计数(最大)为 300。

点击创建会重新部署并创建应用程序的新版本。默认情况下,一次只有一个版本是激活的。一旦新版本成功启动,负载均衡器会将 100%的流量转移到新版本。通过应用程序 | 版本菜单,你可以看到激活和未激活的版本。在那里,你也可以配置版本模式。默认版本模式是单版本,其中只有一个版本是激活的。你可以将其更改为多版本,其中多个版本同时运行,你可以配置要将多少百分比的流量分配给哪个版本。这可以用于测试在用户负载下运行的不同版本。

在此设置完成后,让我们尝试运行应用程序。你可以打开机器人的 Swagger 页面,让机器人玩一些游戏。你也可以使用你在 第四章 中创建的客户端,配置 Container App 游戏 API 的 URL,并玩游戏。检查 Cosmos DB 的数据探索器部分以查看存储的游戏。

如你现在所知,所有最初使用的 Azure 服务,你可以在下一节中轻松删除 Azure 资源并重新创建它们。

使用.NET Aspire 和 azd 创建 Azure 资源

在这里,我们将探讨如何轻松地从开发系统创建 Azure 资源。首先,我们使用一些来自 Azure 云的资源,而大多数项目在发布完整解决方案到 Azure 之前都在开发系统上本地运行。

在调试时配置 Azure 资源

当创建 API 服务和使用数据库时,在本地调试应用程序时,你可能不需要任何 Azure 资源。API 可以在本地运行;甚至构建 Docker 镜像也不是必需的。要运行数据库,可以使用 Docker 镜像,正如你在 第五章 中所见。然而,对于你可能也在开发过程中使用的某些 Azure 资源,创建和连接 Azure 资源是必需的。一个例子是 Azure Application Insights(在第 第八章 中有详细说明)。

要在AppHost项目中使用应用程序映射的 Azure 资源,您需要添加A``spire.Hosting.Azure.*包。要使用 Azure 资源来定义应用程序模型,有如Aspire.Hosting.AzureAspire.Hosting.Azure.cosmosDB之类的包可用。

当使用应用程序模型指定 Azure 资源时,Azure 资源的配置会自动发生:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
string dataStore = builder.Configuration["DataStore"] ?? "InMemory";
var cosmos = builder.AddAzureCosmosDB("codebreakercosmos")
 .AddDatabase("codebreaker");
// code removed for brevity

AddAzureProvisioning方法在应用程序启动时创建 Azure 资源或检索连接字符串。在成功运行之前,您需要指定您的订阅 ID 和资源创建的位置:

{
  "Azure": {
    "SubscriptionId": "your subscription id",
    "Location": "westeurope",
    "CredentialSource":"AzureCli"
  }
}

此信息不应成为源代码存储库的一部分,因此将其添加到用户密钥中。需要在Azure类别中指定SubscriptionIdLocation键。添加*CredentialSource*是可选的。用于创建资源的用户是通过 DefaultAzureCredential 选择的(有关详细信息,请参阅第七章)。如果在此环境中不起作用,您可以配置 AzureCli,它使用您通过 Azure CLI 登录的账户。

要获取订阅 ID,您可以使用以下命令:

az account show -–query id

您需要登录到您的订阅,不仅是为了查看订阅 ID,还可以自动部署资源。运行应用程序后,您可以看到已部署的资源已写入用户密钥中。

此应用程序模型定义了使用模拟器之外的方式配置和使用 Azure Cosmos DB 数据库:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
string dataStore = builder.Configuration["DataStore"] ?? "InMemory";
var cosmos = builder.AddAzureCosmosDB("codebreakercosmos")
  .AddDatabase("codebreaker");
var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
.WithExternalHttpEndpoints()
  .WithReference(cosmos)
  .WithEnvironment("DataStore", dataStore);
builder.AddProject<Projects.CodeBreaker_Bot>("bot")
.WithExternalHttpEndpoints()
  .WithReference(gameAPIs);
builder.AddProject<Projects.Codebreaker_CosmosCreate>("cosmoscreate")
  .WithReference(cosmos);
builder.Build().Run();

为了避免安装和运行本地 Azure Cosmos DB 模拟器,并解决在 Docker 镜像中使用 Azure Cosmos DB 时的一些问题,我们可以在云中使用 Azure Cosmos DB。不使用RunAsEmulator方法与AddAzureCosmosDB一起使用,我们使用在 Azure 中运行的资源。AddDatabase方法将codebreaker数据库添加到账户中。Codebreaker.CosmosCreate项目用于运行一次,调用 EF Core 上下文的EnsureCreatedAsync方法来创建一个具有分区键的容器。与gamesAPI服务和CosmosCreate项目一起使用的WithReference方法将这些新创建的 Azure Cosmos DB 连接字符串传递给这些资源。WithExternalEndpoints方法配置 Azure App Configuration 的 Ingress 控制器,使此服务外部可用。

图 6.14显示了运行应用程序的.NET Aspire 仪表板:

图 6.14 – 部署了 Azure 资源的.NET Aspire 仪表板

图 6.14 – 部署了 Azure 资源的.NET Aspire 仪表板

codebreakercosmos 资源显示了一个带有 deployment 链接文本的端点。这是一个部署到 Azure 的资源。点击此链接,你将直接导航到这个云资源,并可以检查数据库和容器名称是否已创建。cosmoscreate 引用处于 完成 状态,因此容器创建已完成。

现在让我们启动机器人并让它运行一些游戏,然后使用 Azure Cosmos DB 打开 数据探索器,你将看到创建的游戏。你只需添加一个 API 方法,就可以在本地调试解决方案的同时使用云中的一些资源。

这里创建的资源组使用名称 rg-aspire-{yourhost}-codebreaker.apphost。如果多个开发者使用相同的 Azure 订阅,资源将独立创建,以避免冲突。确保在不需要时删除资源。

接下来,让我们创建一个完整的解决方案,以便在 Azure 上运行。

使用 azd up 配置完整解决方案

为了做到这一点,我们使用 azd。首先,在解决方案的目录中,使用以下命令:

azd init

这初始化了一个用于与 azd 一起使用的应用程序。你可以使用模板创建一个新的解决方案或分析现有的应用程序。由于我们已经有了一个运行中的应用程序,选择 使用当前目录中的代码 来分析应用程序。由于 azd 也会启动编译,因此需要停止应用程序。在成功扫描后,azd 会通知使用 botgamesAPI 服务托管应用程序。然后,使用章节后缀定义一个环境(例如,codebreaker-06)。

发生了什么?此命令创建了一个 .azure 文件夹和 azure.yaml 以及 next-steps.md 文件。next-steps.md 提供了有关下一步可以做什么的信息。azure.yaml 是一个包含引用 containerapp 中运行的 AppHost 项目的信息的简短文件。最有趣生成的信息可以在 .azure 文件夹中找到。此文件夹被排除在源代码存储库之外,因为它可能包含机密信息。在这个文件夹中,你可以看到配置的环境,以及关于哪些服务应该是公开的配置信息。

要将完整解决方案发布到 Azure,只需使用以下命令:

azd up

在第一次运行时,你需要选择将资源部署到的 Azure 订阅和 Azure 区域的位置。接下来,只需等待几分钟,直到所有资源都已部署。

在配置阶段,这些资源被部署:

  • 资源组

  • 容器注册库

  • 密钥保管库

  • 日志分析工作区

  • 容器应用环境

在配置阶段之后,部署阶段开始,这些操作包括:

  • 将 Docker 镜像推送到 ACR 实例

  • 在容器应用环境中使用 ACR 实例中的镜像创建容器应用

当你对源代码或配置进行任何更改时,你只需再次使用 azd up 来部署更新。由于创建的环境不再需要,请使用 azd down 再次删除所有资源。确保等待直到你被要求验证是否真的应该删除资源数量。

在 Azure 门户中检查资源组,你可以看到所有创建的资源,如图 图 6**.15 所示:

图 6.15 – 从 azd up 创建的资源

图 6.15 – 从 azd up 创建的资源

现在,你可以检查部署的资源、发布到容器注册表的镜像、发布到容器应用环境的应用程序、包含机密的密钥保管库以及包含数据库和配置容器的 Azure Cosmos DB 账户。让机器人玩游戏并验证一切是否正常运行。

接下来,让我们深入了解 azd up 发生了什么。

深入 azd up 阶段

运行 AppHost 项目时,可以通过命令行参数传递来创建一个描述所有资源的清单文件:

dotnet run --project Codebreaker.AppHost/Codebreaker.AppHost.csproj -- --publisher manifest --output-path aspire-manifest.json

当使用 dotnet run 时,可以通过使用 -- 来区分 dotnet run 的参数,将命令行参数传递给应用程序。使用 -–publisher manifest 选项创建一个描述应用程序应用模型的 Aspire 清单。此清单指定所有资源,包括资源类型、绑定、环境变量和项目路径。这些信息由 azd 用于创建 Azure 资源,并且可以从其他工具中使用,例如,将解决方案部署到 Kubernetes。

接下来,使用 azd provision。如果你只想配置 Azure 资源而不推送 Docker 镜像和部署 Azure 容器应用,请使用以下命令:

azd provision

azd provision 使用清单文件在内存中创建 Bicep 文件,并创建 Azure 资源。

当任何 Azure 资源被添加到应用模型时,你可以使用此命令,然后仅创建这些资源。

下一步是以下:

azd deploy

azd deploy 使用 dotnet publish 将容器镜像推送到 ACR 实例,然后使用这些镜像创建或更新 Azure 资源。

azd up 在内存中创建 Bicep 脚本。也可以在磁盘上创建 Bicep 脚本以使用它们来创建 Azure 资源,就像我们接下来要做的那样。

使用 azd 创建 Bicep 文件

Bicep 是一种使用声明性语法的特定领域语言。在 Bicep 可用之前,我们创建 Azure 资源管理器 (ARM) 模板来创建 Azure 资源。ARM 模板使用 JSON 定义。Bicep 比 ARM 模板更容易编写。在部署期间,Bicep 文件被转换为 ARM 模板。

这里,我们使用 azd infra 为解决方案创建 Bicep 文件。

注意

在撰写本文时,azd infra 处于早期阶段。请查看本章的 README 文件以获取更新。

从解决方案目录启动此命令:

azd infra synth

此命令创建一个包含这些文件的 infra 文件夹:

  • main.bicep – 创建资源组并引用模块以创建更多资源的主体 bicep 文件。

  • main.parameters.json – 一个参数文件,用于将环境名称和位置等参数传递给 main.bicep 文件。

  • resources.bicep – 此文件由 main.bicep 引用,并包含创建的资源,如 ACR 实例、日志分析工作区、容器应用环境和 Azure 密钥保管库。

  • codebreakercosmos/codebreakercosmos.bicep – 此文件也被 main.bicep 引用,并包含 Azure Cosmos DB 的资源信息,以及写入 Azure 密钥保管库的 Azure 密钥保管库密钥。密钥本身不是此文件的一部分;密钥在创建此资源时从 Azure Cosmos DB 账户动态检索。

如果你对这些生成的 Bicep 文件进行自定义,则自定义文件在创建 Azure 资源时由 azd upazd provision 使用。

对于 botgamesAPI 项目,azd infra 还在 AppHost 项目中创建一个包含模板文件的 infra 文件夹;例如 gameapis.tmpl.yaml。使用这些文件,可以自定义 Azure 容器应用实例;例如,通过更改 CPU 和内存大小或更改应使用的副本数量。更改这些值,azd upazd deploy 会使用这些文件。

当你使用 azd up 创建的资源组打开时,在设置类别中打开部署。这显示了资源组的部署,如图 图 6**.16 所示:

图 6.16 – 部署

图 6.16 – 部署

部署与使用的 Bicep 文件匹配。当你打开相关事件时,你可以看到使用这些部署所执行的所有步骤。

当你不再需要这些资源时,使用此命令再次删除所有资源:

azd down

此工具检索要删除的 Azure 资源数量,并询问是否应该执行此操作——因此,请确保等待直到你可以回答“是”。资源删除完成后,通常需要超过 10 分钟,接下来会询问是否应该清除密钥保管库中的数据。如果你不回答“是”,则这些数据可以在 90 天内恢复,在此期间,你无法在此恢复时间结束前再次创建具有相同名称的资源。

摘要

在本章中,你学习了使用 Azure CLI、Azure 门户和 .NET Aspire 的 azd 创建 Microsoft Azure 资源。gamesAPI 服务现在正在使用 ACR、Azure 容器应用和 Azure Cosmos DB 数据库运行在 Microsoft Azure 资源上。当使用 azd 与 .NET Aspire 一起时,只需一个命令即可部署所有服务。

在继续下一章之前,让我们配置您在上一章中使用过的客户端应用程序,现在使用 Azure 容器应用的 URL 而不是本地服务,并玩一些游戏。

在本章中,Azure 密钥保管库已经创建。在下一章中,我们将探讨后端服务的配置,包括 Azure 密钥保管库,并使用 Azure 应用配置作为所有 codebreaker 服务的配置中心。

进一步阅读

要了解更多关于本章讨论的主题,您可以参考以下链接:

第七章:灵活配置

.NET 提供了基于提供者模型的灵活配置,可以从不同的来源读取配置。在上章中,我们使用 Azure 容器应用配置环境变量以覆盖 JSON 文件配置。

在本章中,你将学习如何使用 .NET 应用配置,以及如何添加配置提供者以使用中央配置存储:Azure App Configuration。对于秘密,我们还有一个可用的 Azure 服务:Azure Key Vault。在本章中,你还将学习如何将 Azure Key Vault 与 Azure App Configuration 结合使用,并通过使用 Azure 托管标识来减少需要存储的秘密数量。

在本章中,你将学习以下内容:

  • 探索 .NET 配置的功能

  • 使用 Azure App Configuration 存储配置

  • 使用 Azure Key Vault 存储秘密

  • 使用托管标识减少所需的秘密数量

  • 使用 Azure App Configuration 使用环境

技术要求

与上一章类似,需要 Azure 订阅、Azure CLI、Azure 开发者 CLI 和 Docker Desktop。

本章的代码可以在以下 GitHub 存储库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch07 文件夹中,你会看到本章最终结果的这些项目:

  • ConfigurationPrototype – 这是一个新项目,在实现游戏 API 和机器人服务之前,展示了配置的一些概念。

  • Codebreaker.InitializeAppConfig – 这是一个新项目,用于使用 Azure App Configuration 初始化值。

  • Codebreaker.AppHost – 使用此项目定义的应用模型得到了增强,包括 ConfigurationPrototypeCodebreaker.InitializeAppConfig 项目,并将 App Configuration 和 Azure Key Vault 资源添加到应用模型中。

  • Codebreaker.GameAPIs – 我们在上一章中使用过的游戏 API 项目通过 App Configuration 进行了增强。

  • Codebreaker.Bot – 这是实现游戏服务的机器人服务的实现。该项目还通过 App Configuration 进行了增强。

你可以从上一章的结果开始,通过本章进行自己的工作。

要发布 Azure 的解决方案(在本章后面使用托管标识时需要),请使用 Azure 开发者 CLI,并将当前目录设置为 solution 文件夹:

azd init
azd up

使用 azd init,选择分析文件夹中的代码,接受部署 Azure 容器应用,指定一个环境,例如 codebreaker-07,并选择可从 Ingress 控制器访问的游戏 API、机器人服务和配置原型。使用 azd up,资源将部署到配置的环境。

查看存储库中 ch07 文件夹的 README 文件以获取最新更新。

体验 .NET 配置

在本章中,我们将创建一个新的 Web API 项目,在将配置功能添加到游戏 API 和机器人服务之前,尝试使用 .NET 配置功能:

dotnet new webapi -o ConfigurationPrototype

.NET 在读取配置值方面非常灵活。配置值可以从不同的源检索,例如 JSON 文件、环境变量和命令行参数。根据环境(例如,生产环境和开发环境),还可以检索不同的配置值。使用此核心 .NET 功能,很容易添加其他配置源并自定义环境。

在幕后,ConfigurationManager 类用于配置应用程序配置的源。此配置是在调用 WebApplication.CreateBuilder 时在应用程序启动时完成的。

注意

在 .NET 8 中,其他构建器方法,如 CreateSlimBuilderCreateEmptyBuilder,也可用。使用这些构建器,注册的服务数量减少,以提高性能。

使用 WebApplicationBulder.CreateBuilder 完成的默认配置,已添加配置提供程序列表:

  • webroot 键设置为 Web 目录的路径。您可以使用配置键而不是使用其他 API 来检索此信息。

  • ASPNETCORE_DOTNETCORE_ 前缀,以便在处理早期即可使用,这允许所有后续提供程序覆盖这些值。另一个环境变量配置提供程序添加所有其他环境变量。ASPNETCORE_HTTP_PORTSASPNETCORE_HTTPS_PORTS 环境变量是 .NET 8 中新增的,可以轻松更改 Kestrel 服务器的监听端口。.NET Aspire 将环境变量传递给配置的项目。

  • appsettings.jsonappsettings.{environmentName}.json。如果环境名称是 Development,则检索 appsettings.Development.json 中的值。这将覆盖之前加载的 appsettings.json 文件中的设置。

    在您的环境中,如果您更喜欢将所有连接字符串分开,可以使用多个 JSON 文件(例如,connectionstrings.json):

    builder.Configuration.AddJsonFile("connectionstrings.json", optional: true);
    

    AddJsonFile 扩展方法将文件名添加为另一个 JSON 配置提供程序。如果未将 optional 参数配置为 true,则在找不到文件时抛出异常。

  • 命令行配置提供程序:命令行提供程序允许覆盖所有设置(因为它在提供程序列表中最后)。启动应用程序时,您可以通过传递配置值来覆盖其他设置。

    想象一个使用 JSON 指定分层设置的案例,例如以下连接字符串:

    {
    
      "ConnectionStrings": {
    
        "GamesSqlServerConnection": "server=(localdb)\\mssqllocaldb;database=CodebreakerGames;trusted_connection=true"
    
      }
    
    }
    

    在这种情况下,您可以使用冒号 : 分隔符通过命令行参数传递值:

    ConnectionStrings:GamesSqlServerConnection = "the new connection string"
    

    使用 : 在环境变量中是不可能的。如您在上一章中看到的,在传递用于分层配置的环境变量时,使用两个下划线 (__) 作为分隔符。

  • UserSecretsId 在项目文件中设置:

    cd ConfigurationPrototype
    
    UserSecretsId to the project file and uses a unique identifier to reference the corresponding secrets from the user profile.To add a secret, use this command:
    
    

    使用 dotnet user-secrets -h 查看其他可用的命令。

    
    

注意

在开发系统上运行 .NET Aspire 解决方案时,使用包含运行服务进程的引用信息的应用程序模型及其依赖项来创建环境变量。当将解决方案部署到 Microsoft Azure,并使用 Azure Container Apps 时,会创建环境变量和秘密。由于默认情况下环境变量被配置为配置提供者,因此运行服务时无需进行特殊操作。

检索配置值

我们如何访问配置值?要获取自定义配置值,让我们增强 appsettings.json 文件:

ConfigurationPrototype/appsettings.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "Config1": "config 1 value",
  "Service1": {
    "Config1": "config 1 value",
    "Config2": "config 2 value"
  }
}

Config1 键被添加到文件的根元素中。使用 Service1,我们使用父子关系并定义多个子元素,Config1Config2

要检索配置值,我们只需注入 IConfiguration 接口,如下面的代码片段所示。您需要在 app.Run 方法之前添加此代码片段:

ConfigurationPrototype/Program.cs

app.MapGet("/readconfig", (IConfiguration config) =>
{
  string? config1 = config["Config1"];
  return $"config1: {config1}";
});

IConfiguration 接口在 API 实现的 GET 请求中注入。使用 C# 索引器,我们检索 Config1 键的值。要检索子元素,我们可以使用 GetSection 方法并使用返回的节中的索引器。GetSection 返回一个实现 IConfigurationSection 接口的对象。该接口本身继承自 IConfiguration,因此 IConfiguration 接口的成员都是可用的。

尝试一下:启动 ConfigurationPrototype 应用程序,并使用 OpenAPI 测试页面测试 /``readconfig 端点。

要检索子元素,我们将使用不同的方法来使用选项。

使用选项

当需要配置值时,许多 .NET 服务都使用 选项模式。这为获取这些值提供了更多灵活性——这可以是配置,但这些服务配置值也可以通过编程方式分配。

强类型配置值是此模式的另一个特性。将此类添加到映射配置值:

ConfigurationPrototype/Program.cs

internal class Service1Options
{
  public required string Config1 { get; set; }
  public string? Config2 { get; set; }
}

映射配置值的类需要一个无参数构造函数和与配置值匹配的属性。

要填充值,使用 Service1Options 类并通过 builder.Build 方法进行配置:

ConfigurationPrototype/Program.cs

builder.Services.Configure<Service1Options>(
  builder.Configuration.GetSection("Service1"));

IServiceCollectionConfigure扩展方法提供了两种重载。其中一种重载允许通过一个委托程序来程序化地填充Service1Options实例。第二种重载——在这里使用的是——接收一个IConfiguration参数。记住——在之前创建的配置文件中,定义了一个Service1父元素。GetSection方法检索该部分内的值。因为配置键映射到类,所以填充了这些值。

注意

一个新的.NET 8 特性,具有绑定配置的是源生成器。使用原生 AOT(见第五章),此源生成器默认启用。对于非 AOT 项目,可以将EnableConfigurationBindingGenerator添加到项目文件中以关闭此源生成器。

在此配置就绪后,让我们检索这些配置值。在app.Run之前添加此代码以配置端点:

ConfigurationPrototype/Program.cs

app.MapGet("/readoptions", (IOptions<Service1Options> options) =>
{
  return $"options - config1: {options.Value.Config1}; config 2: 
    {options.Value.Config2}";
});

使用具有Service1Options泛型参数的IOptions接口进行注入,并且通过这种方式,可以使用配置的值。

在进行这些代码更改后,再次运行ConfigurationPrototype项目。使用/readoptions端点检索配置的值。

使用环境

由于应用程序在不同的环境中运行(例如,生产、预发布和开发),需要不同的配置值。例如,在开发环境中,您不想使用生产数据库。.NET 配置很容易支持不同的环境。

在默认配置下,加载appsettings.{environment}.json文件以指定特定环境的配置值——例如,在预发布环境中为appsettings.staging.json

除了使用不同的文件名来加载特定环境的配置值外,我们还可以程序化地验证当前环境。

模板生成的代码包含以下代码:

ConfigurationPrototype/Program.cs

if (app.Environment.IsDevelopment())
{
  // code removed for brevity
}

IsDevelopment扩展方法将环境与Development字符串进行比较。EnvironmentWebApplication类的一个属性。其他可用的方法有IsProductionIsStagingIsEnvironment。调用IsEnvironment方法时,可以传递任何字符串来检查应用程序是否在指定的环境中运行。您也可以创建一个自定义扩展方法,扩展IHostEnvironment类型以与环境进行比较,而不是使用IsEnvironment方法。

应用程序运行的环境由前面提到的ASPNETCORE_ENVIRONMENT环境变量定义。在本地调试时,launchsettings.json文件(在Properties文件夹中)将环境定义为Development值。如果没有设置环境变量,则默认环境为Production。对于所有其他环境,您需要设置此环境变量。

使用 Azure Container Apps 配置

Azure Container Apps 支持指定环境变量和秘密。在第六章中,当我们创建容器应用程序时,我们配置了环境变量和秘密。容器应用程序的环境变量可以在创建应用程序时或在更新应用程序后进行配置——例如,使用 az containerapp update

环境变量可能会在日志文件中可见。对于秘密,这可能会成为一个安全问题。安全嗅探器可以捕获配置在环境变量中的秘密,并在发现这些秘密时向系统管理员发出警报。在容器应用程序中,秘密存储在应用程序的作用域内,但与应用程序的版本无关。

为了获得更好的秘密安全性,容器应用程序的秘密可以连接到 Azure Key Vault 服务。Key Vault 服务以及我们为秘密获取的附加功能将在本章后面讨论。

当您使用多个 Azure 服务(例如,Azure App Service、Azure Functions、Azure Container Apps...)时,配置的管理方式因服务而异。如果您仅在容器应用程序中运行大量服务,您可能更喜欢一个集中管理所有配置的地方。Azure App Configuration 提供了这种功能,而无需创建自定义配置服务。

使用 Azure App Configuration 配置

在本章中,我们将 Azure App Configuration 和 Azure Key Vault 添加到解决方案中,如图 图 7**.1 所示:

图 7.1 – Azure 服务

图 7.1 – Azure 服务

这两种服务都可以与任何需要配置值的任何服务一起使用。Key Vault 服务用于存储秘密,并为这一功能提供了许多增强特性。

让我们创建一个 Azure App Configuration 资源。

创建 Azure App Configuration 服务

我们使用 .NET Aspire 创建 Azure App Configuration 服务。要使用来自 ConfigurationPrototype 项目的 .NET Aspire AppHost配置,请添加AppHost` 项目,并使用应用程序模型定义引用该项目):

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
var appConfig = builder.AddAzureAppConfiguration("codebreakerconfig")
  .WithParameter("sku", "Standard");
builder.AddProject<Projects.ConfigurationPrototype>("configurationprototype")
  .WithReference(appConfig);
// code removed for brevity

要使用 AppHost 项目与 Azure App Configuration 资源一起使用,我们还需要添加 Aspire.Hosting.Azure.AppConfiguration NuGet 包。调用 AddAzureAppConfiguration 方法将资源添加到应用程序模型。如果您尚未使用 Azure 订阅中的任何 App Configuration 功能,可以将 sku 值设置为 Free 以使用 App Configuration 服务的免费版本。免费版本不提供任何 SLA,并且每天限制为 1,000 次调用,但对于开发来说,这个限制可能已经足够。App Configuration 服务通过 ConfigurationPrototype 项目使用 WithReference 方法进行引用。

启动AppHost项目,资源将被配置。请记住,需要使用AppHost项目配置用户密钥:

{
  "Azure": {
    "SubscriptionId": "<enter your subscription id>",
    "Location": "westeurope"
    "CredentialSource": "AzureCli"
  }
}

将订阅 ID 更改为您的订阅 ID,并将位置更改为您选择的 Azure 区域。指定用于创建 Azure 资源的凭据来源也可能很有帮助。将值设置为AzureCli,将使用与您使用 Azure CLI 登录相同的帐户。

由于用户密钥存储将配置存储在用户配置文件中,当使用多个项目中的相同UserSecretsId值时,此信息可能已经显示出来。.NET Aspire 还会将创建的资源信息添加到用户密钥中。

当你启动应用程序时,将创建一个额外的 Azure 资源。成功完成后,如.NET Aspire 仪表板所示,让我们添加一些配置值。

使用 Azure App Configuration 配置值

完成 App Configuration 服务的创建过程后,我们可以在 Azure 门户中使用配置探索器定义配置值(见图 7.2):

图 7.2 – 配置探索器

图 7.2 – 配置探索器

使用 App Configuration,键值对被存储。创建一个ConfigurationPrototype:ConnectionStrings:SqlServer键,我们定义了一个数据库连接的字符串值。由于所有Codebreaker服务的配置值都可以在一个地方配置,因此使用键名的前一部分(即服务的名称)作为键名是一个好习惯——这样我们就可以知道哪些配置值属于哪个服务。还可以使用 JSON 内容作为值,就像我们稍后将在游戏 API 中做的那样。这减少了对此服务的请求次数,并可以简化配置。

接下来,让我们从ConfigurationPrototype项目中获取配置。

初始化应用程序配置值

在应用程序部署时,我们也可以通过编程方式添加配置值。为此,让我们创建一个只运行一次的后台服务。

创建一个新的后台工作服务:

dotnet new worker -o Codebreaker.InitializeAppConfig

要将工作项目发布为 Docker 镜像,您还需要启用 SDK 容器支持:

Codebreaker.InitializeAppConfig/Codebreaker.InitializeAppConfig.csproj

<PropertyGroup>
  <IsPublishable>true</IsPublishable>
  <EnableSdkContainerSupport>true</EnableSdkContainerSupport>
</PropertyGroup>

在创建工作项目时,如果没有此设置,则无法使用dotnet publish创建 Docker 镜像。

将此项目添加到.NET Aspire 编排中(使用.NET Aspire 编排支持,或添加对ServiceDefaults项目的引用,并从AppHost项目添加项目引用到此项目)。将由此模板创建的Worker类重命名为AppConfigInitializer

添加 Azure.Data.AppConfigurationMicrosoft.Extensions.Azure NuGet 包。Azure.Data.AppConfiguration 包提供了访问 App Configuration API 以创建、读取和更新设置的函数。Microsoft.Extensions.Azure 提供了与 依赖注入 (DI) 系统的集成。

要写入配置设置,将以下代码添加到 AppConfigInitializer 类中:

Codebreaker.InitializeAppConfig/AppConfigInitializer.cs

public class AppConfigInitializer(ConfigurationClient configurationClient, IHostApplicationLifetime hostApplicationLifetime, ILogger<AppConfigInitializer> logger) : BackgroundService
{
  private Dictionary<string, string> s_6x4Colors = new()
  {
    { "color1", "Red" },
    { "color2", "Green" },
    { "color3", "Blue" },
    { "color4", "Yellow" },
    { "color5", "Orange" },
    { "color6", "Purple" }
  };
  protected override async Task ExecuteAsync(CancellationToken 
    stoppingToken)
  {
    foreach ((string key, string color) in s_6x4Colors)
    {
ConfigurationSetting setting = new($"GameAPIs.Game6x4.{key}", 
        color);
      await configurationClient.AddConfigurationSettingAsync(setting);
logger.LogInformation("added setting for key {key}", key);
    }
  }
}

使用 AppConfigInitializer 类的构造函数,将 ConfigurationClient 类和 IHostApplicationLifetime 接口注入。ConfigurationClient 是用于与 App Configuration 通信的类。我们通过调用 AddConfigurationSettingAsync 方法添加设置。IHostApplicationLifetime 是用于通知后台服务有关启动和停止事件的接口,并用于在结束时停止服务。在设置写入后,应用程序结束,调用 StopApplication 方法。

现在,我们可以使用 DIC 配置来配置 AppConfigInitializer 类:

Codebreaker.InitializeAppConfig/Program.cs

using Codebreaker.InitalizeAppConfig;
using Microsoft.Extensions.Azure;
var builder = Host.CreateApplicationBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddHostedService<AppConfigInitializer>();
builder.Services.AddAzureClients(clients =>
{
  string appConfigUrl = builder.Configuration.
  GetConnectionString("codebreakerconfig") ??
throw new InvalidOperationException("codebreakerconfig not 
    configured");
  clients.AddConfigurationClient(new Uri(appConfigUrl));
});
var host = builder.Build();
host.Run();

AddHostedService 方法需要一个实现 IHostedService 接口的对象。此接口由 AppConfigInitializer 类的基类 BackgroundService 实现。当服务启动时,会调用 BackgroundServiceStartAsync 方法,该方法反过来调用 AppConfigInitializerExecuteAsync 方法,在该方法中设置配置值。

AddAzureClients 是一个扩展方法,允许配置客户端以访问许多 Azure 服务。在这里,我们使用 AddConfigurationClient 扩展方法,传递 App Configuration 资源的 URL。

现在启动此初始化项目会将配置设置添加到 App Configuration 服务。游戏 API 服务现在可以更改以从配置中读取游戏颜色,这允许轻松更改颜色而无需重新编译。

注意

在 .NET Aspire 可用之前,我使用 Azure App Configuration 配置了非秘密配置值,例如不同 Azure 资源的 URL。由于 .NET Aspire 的编排覆盖了这一方面,并使得使用不同环境运行解决方案变得容易,并自动配置这些依赖项,因此 App Configuration 现在主要用于其他特定于应用程序的配置值。

在此初始化设置到位后,让我们继续从应用程序中读取配置值。

在应用程序中使用 Azure App Configuration

要从 .NET 应用程序中使用 Azure App Configuration 服务,我们需要添加 Microsoft.Azure.AppConfiguration.AspNetCore NuGet 包。此 NuGet 包提供了一个配置提供程序。

此提供程序使用以下代码片段进行配置:

ConfigurationPrototype/Program.cs

var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddAzureAppConfiguration(appConfigOptions =>
{
  DefaultAzureCredential cred = new();
  string appConfigUrl = builder.Configuration.
    GetConnectionString("codebreakerconfig") ??
    throw new InvalidOperationException("could not read 
    codebreakerconfig");
  appConfigOptions.Connect(new Uri(appConfigUrl), cred);
});
// the code from the repository also includes the Key Vault configuration added later

AddAzureAppConfiguration 扩展方法将应用程序配置服务添加到配置提供程序。一个重载使用字符串参数传递包含秘密的连接字符串。.NET Aspire 的默认编排配置仅传递应用程序配置服务的 URL,而不包含秘密。DefaultAzureCredential 类。此类使用定义的顺序尝试不同的凭据,包括 Visual Studio 凭据Azure CLI 凭据Azure Developer CLI 凭据。首先成功检索到的凭据用于访问配置服务。应用程序配置服务的 URL 由 .NET Aspire 编排器转发,并通过配置 API 获取。在此之后,调用 AzureAppConfigurationOptions 类的 Connect 方法,使用配置服务的 URL 以及凭据进行连接。添加此配置提供程序后,应用程序配置可以像任何其他配置提供程序一样使用。

注意

当解决方案部署到 Azure 时,不能使用本地凭据。当解决方案在 Azure 中运行时,使用托管标识。这一点将在本章后面介绍。

现在,需要做的只是检索配置值。配置来源没有区别:

ConfigurationPrototype/Program.cs

app.MapGet("/azureconfig", (IConfiguration config) =>
{
  string? connectionString = config.
    GetSection("ConfigurationPrototype")
    .GetConnectionString("SqlServer");
  return $"Configuration value from Azure App Configuration: 
    {connectionString}";
});

再次,IConfiguration 接口被注入。使用应用程序配置配置的键具有层次化名称:ConfigurationPrototype:ConnectionStrings:SqlServer。第一个层次结构是通过 GetSection 方法访问的。接下来,使用 GetConnectionString 方法。这访问名为 ConnectionString 的部分,然后使用 SqlServer 键来获取其值。

通过这个最后的更改,您可以运行应用程序并从应用程序配置服务中检索配置值!

在本地系统上使用环境时使用用户秘密。在生产环境中,您可以从上一章中了解到如何使用 Azure Container Apps 配置秘密,以安全的方式将连接字符串添加到应用程序配置中。接下来要介绍的 Azure Key Vault 服务提供了一个更加安全的环境。

使用 Azure Key Vault 存储秘密

要获取秘密配置值,可以使用 Azure Key Vault 服务。Key Vault 服务可以用来存储诸如密码证书密钥之类的秘密。此服务增加了硬件级别的加密、自动证书续订和细粒度的访问控制。通过预定义的角色,服务决定谁可以读取秘密(Key Vault Secrets User,应用程序),谁可以创建和更新秘密但不能读取秘密(Key Vault Contributor),以及谁可以监控哪些用户使用秘密但不能创建和读取秘密(Key Vault Secrets Officer)。

对于 .NET 应用程序,密钥保管库服务可以作为配置提供程序添加,就像 Azure App 配置一样。另一种使用此服务的方法是将存储在密钥保管库中的秘密链接到 Azure App 配置实例。我们将使用第二种选项。

当您向 App 配置添加密钥时,除了提供密钥和值之外,密钥还可以链接到存储在密钥保管库服务中的秘密。虽然可以与 App 配置使用的相同 API 一起使用秘密,但运行服务的用户需要访问密钥保管库服务。

让我们使用 .NET Aspire 应用程序模型创建一个密钥保管库:

Codebreaker.AppHost/Program.cs

var appConfig = builder.AddAzureAppConfiguration("codebreakerconfig");
var keyVault = builder.AddAzureKeyVault("codebreakervault");
builder.AddProject<Projects.ConfigurationPrototype>("configurationprototype")
  .WithReference(appConfig)
  .WithReference(keyVault);

AddAzureKeyVault 方法将密钥保管库资源添加到应用程序模型。此资源从以下项目配置中引用以传递 URL。与之前的 App 配置一样,秘密信息不是传递的 URL 的一部分。

运行应用程序以创建资源。然后,您可以通过查看访问配置页面(在设置部分)来验证权限模型,如图 7.3 所示:

图 7.3 – 密钥保管库访问配置

图 7.3 – 密钥保管库访问配置

Azure 密钥保管库服务支持两种访问权限模型:保管库访问策略是较旧(遗留)的选项。Azure 基于角色的访问控制是首选配置。定义用户角色以允许对不同的密钥保管库对象(如密钥、秘密和证书)进行读取或写入访问。在此类别中另一个设置是允许Azure 资源管理器ARM)基于的部署(包括 Bicep);对于此特定资源,需要授予访问权限。

在密钥保管库创建成功后,您可以创建和导入秘密密钥证书。在本章中,我们仅使用密钥保管库服务的秘密。创建一个秘密,如图 7.4 所示:

图 7.4 – 创建秘密

图 7.4 – 创建秘密

除了名称和秘密值之外,您还可以设置激活和过期日期。

在创建秘密后,我们可以切换回 Azure App 配置服务。创建一个密钥保管库引用以将配置值映射到 Azure 密钥保管库服务的值(如图 7.5 所示):

图 7.5 – 使用 App 配置映射密钥保管库秘密

图 7.5 – 使用 App 配置映射密钥保管库秘密

从配置资源管理器添加密钥保管库引用时,可以指定与配置键相对应的键值,但对于值,则引用密钥保管库资源和秘密。

将 App 配置服务连接到密钥保管库服务时,需要更新 App 配置服务:

ConfigurationPrototype/Program.cs

builder.Configuration.AddAzureAppConfiguration(appConfigOptions =>
{
  DefaultAzureCredentialOptions credentialOptions = new();
  DefaultAzureCredential cred = new();
  string appConfigUrl = builder.Configuration.
GetConnectionString("codebreakerconfig") ?? throw new 
InvalidOperationException("could not read codebreakerconfig");
  appConfigOptions.Connect(new Uri(appConfigUrl), cred)
    .ConfigureKeyVault(keyVaultOptions =>
    {
      keyVaultOptions.SetCredential(cred);
    });
});

AzureAppConfigurationOptions 类的 Connect 方法是一个流畅的 API,它返回相同的选项类型。因此,现在调用 ConfigureKeyVault 方法将 Key Vault 服务连接到相同的 App Configuration 资源。SetCredential 方法定义了用于访问机密的凭证。在这里,我们使用与 App Configuration 服务相同的凭证,但也可以使用不同的凭证。

在此配置下,机密可以像其他配置值一样访问:

ConfigurationPrototype/Program.cs

app.MapGet("/secret", (IConfiguration config) =>
{
  string? connectionString = config.
GetSection("ConfigurationPrototype").GetConnectionString("Cosmos");
  return $"Configuration value from Azure Key Vault via App 
Configuration: {connectionString}";
});

由于 Key Vault 服务已连接到 App Configuration 服务,我们可以使用之前使用的相同配置 API。幕后,使用了不同的访问机制。

运行应用程序并检查如何使用 DefaultAzureCredential 类型成功检索机密。

在我们将 App Configuration 和 Key Vault 服务与我们的游戏 API 和机器人服务集成之前,我们可以使用管理身份通过配置值去除一些必要的机密。

减少使用管理身份的机密需求

管理身份(现称为Microsoft Entra 管理身份用于 Azure 资源)消除了我们之前与服务主体相关的麻烦。管理身份抽象化了服务主体,并自动创建和删除它们。

使用 Azure 服务(如 Azure Container Apps),可以配置服务的身份以管理身份运行。访问的服务(如 Azure App Configuration)使用角色管理,您可以通过角色管理配置谁可以访问此资源——其中包括选择管理身份的简单选项。

可用的管理身份类型包括系统分配的管理身份用户分配的管理身份

  • 系统分配的管理身份直接关联到 Azure 资源。如果删除 Azure 资源,管理身份及其基于角色的访问权限也会被移除。

  • 用户分配的管理身份是独立于 Azure 服务创建的。与其他 Azure 资源一样,用户分配的管理身份是资源组内的资源。

这两个选项各有优缺点。

系统分配的管理身份的属性和优势包括以下内容:

  • 它们的生命周期与服务的生命周期相同

  • 删除服务也会删除管理身份及其角色分配

用户分配的管理身份的优势包括以下内容:

  • 一个用户分配的管理身份可以被多个服务使用。

  • 删除服务不会删除管理身份 - 它可以被其他服务使用。

  • 多个服务可以使用相同的管理身份。如果多个服务需要相同的权限,您只需使用共享管理身份指定一次即可。

一个服务可以使用多个用户分配的托管标识。这也包括一个缺点:使用用户分配的托管标识需要你配置主体 ID 以指定要使用哪个托管标识。

图 7.6显示了与机器人服务和游戏 API 一起使用的用户分配的托管标识,用于访问应用配置和密钥保管库服务:

图 7.6 – 分配托管标识

图 7.6 – 分配托管标识

让我们创建一个托管标识并为此托管标识分配权限。

创建托管标识并分配角色

在本地系统上运行应用程序时,不使用托管标识。要在 Azure 中使用托管标识,请按照技术要求部分所述部署解决方案。

资源成功部署后,使用 Azure 门户打开游戏 API 的 Azure 容器应用服务,并在设置部分选择标识,如图7.7所示。

图 7.7 – 托管标识

图 7.7 – 托管标识

系统分配的标识已关闭,但已创建用户分配的托管标识。如果你使用其他容器应用打开标识配置,你可以看到相同的托管标识被分配给所有这些应用,这使得定义权限变得容易。

点击此托管标识,选择添加角色分配,如图7.8所示。

图 7.8 – 托管标识的角色访问控制

图 7.8 – 托管标识的角色访问控制

在这里,你可以看到这个托管标识已经分配了几个角色——它可以从 Azure 容器注册服务中拉取 Docker 镜像,这在部署 Azure 容器应用服务时是必需的,它可以访问 Azure 密钥保管库,并且它具有使用应用配置数据所有者角色的访问权限。这允许设置配置值,如果未由运行此标识的应用程序设置配置值,则可以将其更改为读取访问。

注意

你可能会想知道为什么托管标识被分配了密钥保管库服务的管理员角色以及应用配置服务的应用配置数据所有者角色。这个托管标识在部署过程中也被使用。当部署 Azure 容器应用服务时,会向密钥保管库服务添加一个包含 Azure Cosmos DB 数据库连接字符串的秘密。可以指定配置值并将其提供给应用配置服务。

为了支持最小权限原则PoLP),应仅应用必要的权限。您可以为不同的容器应用创建多个托管标识,或使用系统分配的托管标识,这样每个容器应用都有一个不同的标识,并为每个标识指定所需的角色。容器应用可以只有一个系统分配的托管标识,但可以有多个用户分配的托管标识。不同的标识可以用于部署和运行应用程序。

让我们回到一些 C#代码——游戏 API、机器人服务以及配置原型启动代码,以使用托管标识配置 Azure 应用程序配置。

使用托管标识配置 Azure 应用程序配置提供程序

在之前,使用ConfigurationPrototype项目时,我们已经使用了AddAzureAppConfiguration方法重载,该方法不需要包含密钥的连接字符串。调用Connect方法时,我们提供了一个DefaultAzureCredential实例。使用用户分配的托管标识时,这里需要做出更改。一个应用程序可以分配一个系统分配的托管标识,但可以有多个用户分配的托管标识。我们需要指定我们使用的那个。

让我们检查已应用到 Azure 容器应用的配置。在 Azure 门户中打开配置原型容器应用,在设置类别中打开密钥,如图7.10所示:

Figure 7.9 – Azure 容器应用中的密钥

Figure 7.9 – Azure 容器应用中的密钥

连接到 Azure 应用程序配置和 Azure 密钥保管库服务的连接字符串存储在密钥配置中。这实际上并不是必需的,因为密钥不是这些链接的一部分——但如果配置更改不仅包含端点链接,还包含包含端点和密钥的连接字符串,这将有所帮助。

因为密钥不是此密钥配置的一部分,请检查配置的环境变量。此设置在应用程序类别中可用。点击容器并选择环境变量,如图7.10所示:

Figure 7.10 – Azure 容器应用中的环境变量

Figure 7.10 – Azure 容器应用中的环境变量

用户分配的托管 ID 的标识符作为名为AZURE_CLIENT_ID的环境变量传递。此环境变量可以用来选择托管标识。让我们使用它来配置DefaultAzureCredential对象。我们之前使用过这个类,但现在我们需要调查它提供的不同选项。DefaultAzureCredential按照以下顺序使用账户:

  • EnvironmentCredential – 此认证需要设置包含客户端 ID、租户 ID 和密钥的环境变量。我们这里不使用此方法。

  • WorkloadIdentityCredential – 当在 Azure Kubernetes Service (AKS) 上运行时,可以启用 Microsoft Entra 工作负载标识。

  • ManagedIdentityCredential – 这是当应用程序在配置了 Microsoft Azure 中的托管标识时使用的认证方式。

  • SharedTokenCacheCredential – 这是一个已由 VisualStudioCredential 取代的传统机制。

  • VisualStudioCredential – 使用 Visual Studio,在选项对话框中,你可以配置用于 Azure 服务认证的账户。这是与 VisualStudioCredential 一起使用的账户。只需确保在 Visual Studio 中你不需要重新认证 – 否则,通过 DefaultAzureCredential 的认证可能不会成功。

  • VisualStudioCodeCredential – 这与 VisualStudioCredential 类似,是用于 Visual Studio Code 的机制,但与当前版本的 Azure Account 扩展 不兼容。将为 Visual Studio Code 构建一个新的认证机制,但这需要一些时间才能准备就绪。使用 Visual Studio Code 时,请使用下一个选项。

  • AzureCliCredential – 这是 Azure CLI 使用的账户。使用 az account list 命令,你可以看到你登录的 Azure 账户和订阅。az account show 会显示将使用的默认账户和订阅。如果这不是正确的账户,请使用 az account set 来设置当前活动订阅。

注意

如果你在开发环境中使用 DefaultAzureCredential 类时遇到问题,你可以启用诊断信息,并显式启用或禁用特定账户以查找问题。如有错误,请查看此故障排除指南:github.com/Azure/azure-sdk-for-net/blob/main/sdk/identity/Azure.Identity/TROUBLESHOOTING.md

让我们更新配置,以便在应用程序在 Azure 中运行时使用 Azure App Configuration:

ConfigurationPrototype/Program.cs

builder.Configuration.AddAzureAppConfiguration(appConfigOptions =>
{
#if DEBUG
  DefaultAzureCredential credential = new();
#else
  string managedIdentityClientId = builder.Configuration["AZURE_
    CLIENT_ID"] ?? string.Empty;
  DefaultAzureCredentialOptions credentialOptions = new()
  {
    ManagedIdentityClientId = managedIdentityClientId,
    ExcludeEnvironmentCredential = true,
    ExcludeWorkloadIdentitiyCredential = true
  };
  DefaultAzureCredential credential = new(credentialOptions);
#endif
  string appConfigUrl = builder.Configuration.
GetConnectionString("codebreakerconfig") ??
    throw new InvalidOperationException("could not read 
codebreakerconfig");
  appConfigOptions.Connect(new Uri(appConfigUrl), credential)
    .Select("ConfigurationPrototype*")
    .ConfigureKeyVault(keyVaultOptions =>
    {
      keyVaultOptions.SetCredential(cred);
    });
});

DefaultAzureCredential 不仅在开发环境中有效,当应用程序在 Azure 中运行时也有效。使用系统分配的托管标识,不需要进行更改。使用用户分配的托管标识,需要将 ManagedIdentityClientId 属性设置为托管标识的 ID。我们通过读取 AZURE_CLIENT_ID 环境变量并将值传递给此设置来完成此操作。

使用从 Connect 方法返回的 AzureAppConfigurationOptions 类的 Select 方法可以过滤配置值。因为配置值是为解决方案的所有服务指定的,所以我们只需要以 ConfigurationPrototype 键开始的那些值。对于机器人和游戏 API 服务,过滤是通过 botgameapis 键完成的。

运行配置原型,并使用这些更改,然后让我们继续使用 App 配置使用 .NET 环境。

使用 Azure App 配置的环境

Azure 容器应用程序已部署并运行,使用了我们迄今为止创建的所有 Azure 服务。使用 App 配置缺少的是 .NET 配置支持的不同环境。应用程序是在本地开发环境中运行,在 Azure 测试环境中运行,还是在生产服务器上运行?在测试环境中运行时,不应使用生产数据库。

.NET 配置支持不同的环境——根据环境,要么加载 appsettings.development.json,要么加载 appsettings.production.json。使用 Azure App Configuration 也可以实现类似的功能,使用 标签 来区分环境配置。我们可以指定开发、生产和测试标签来区分环境配置。这可以映射到 .NET 环境。

注意

在不同的 Azure 订阅中区分生产环境和开发环境是一个好习惯,可能还会使用不同的 Azure Active Directory 服务。在这里,您也使用单独的 Azure App 配置服务。某些环境可以使用相同的订阅;例如,生产环境和预发布环境可以配置为在同一个订阅中运行。在这种情况下,可以使用标签将不同的配置值映射到环境中。

使用 App 配置标签映射 .NET 环境

在 Azure 门户中,再次打开 Azure App 配置服务。创建一个新的键值对,并再次使用 BotService 键,但这次,将标签设置为 Development。此键的默认设置应包含运行在容器应用程序中的游戏 API 的 ApiBase 配置,而 Development 标签应引用 localhost

使用机器人服务的启动代码,我们现在可以更改过滤代码:

Codebreaker.Bot/Program.cs

builder.Configuration.AddAzureAppConfiguration(options =>
{
  options.Connect(new Uri(endpoint), credential)
    .Select("BotService*", labelFilter: LabelFilter.Null)
    .Select("BotService*", builder.Environment.EnvironmentName);
});

多次调用 Select 方法的方式与您在本章开头看到的使用多个配置提供程序的方式相同。如果一个设置被配置了多次,最后一个配置生效。第一个 Select 方法加载所有以 BotService 开头的配置值,并且没有应用标签过滤器。接下来,加载所有以 BotService 开头的配置值,但这次,只加载与当前环境名称相同的标签的值。所有未被特定环境标签覆盖的配置值保持不变——该值是活动的。对于所有匹配标签的键,新的值现在生效。

这就是使用 Azure App 配置服务映射不同环境配置值所需完成的所有工作。

注意

如果您一段时间内不需要 Azure 资源,请删除资源组。在下一章中,我们将重新创建这些服务。azd up 使得这个过程变得简单!

摘要

这是一次围绕使用 Azure 服务来解决常见需求(如 Azure 应用配置和 Azure Key Vault 与 .NET 配置相关)的旅程。您学习了 .NET 配置如何提供附加不同提供者的功能,并使用 Azure 应用配置来存储大量服务的配置值。Azure Key Vault 服务用于存储机密。除此之外,您还学习了如何使用托管标识,这有助于消除许多机密。

在本章中,我们使用了 Azure 开发者 CLI 来创建 Docker 镜像,将它们发布到 Azure 容器注册表服务,并使用新镜像创建 Azure 容器应用的新副本。虽然 azd up 使得这个过程变得简单,但这可以自动化。这在测试、预生产和生产环境中特别有趣。在下一章中,我们将使用 GitHub Actions 自动化这些活动。此外,Azure 应用配置还可以做更多的事情——使用现代部署模式中的功能标志。这将在第八章中介绍。

进一步阅读

要了解更多关于本章讨论的主题,您可以参考以下链接:

第八章:CI/CD - 使用 GitHub Actions 进行发布

微服务的一个特点是它们能够持续构建和部署服务。在之前的章节中,我们自动创建了服务解决方案使用的基础设施。

在本章中,我们将继续自动构建和更新服务,并在将应用程序部署到预发布和生产环境之前使用保护规则。在这个过程中,你将学习如何使用 Azure App Configuration 与功能标志一起使用。

在本章中,你将学习以下内容:

  • 使用 GitHub Actions

  • 在拉取请求后自动构建和测试应用程序

  • 将应用程序部署到测试环境

  • 在将应用程序部署到生产环境之前,使用部署保护规则

  • 发布 NuGet 包

  • 使用现代部署模式中的功能标志

技术要求

在本章中,与上一章类似,你需要 Azure 订阅、Azure CLI、Azure Developer CLI 和.NET Aspire。你还需要自己的 GitHub 仓库,以便你可以存储机密信息、创建环境并运行 GitHub Actions。这些功能在公共仓库中可用。如果你创建一个私有仓库,则需要 GitHub 团队功能来创建环境(见github.com/pricing)。

本章的源代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch08文件夹包含以下项目,以及本章的输出:

  • Codebreaker.GameAPIs:我们在上一章中使用过的game-apis项目已经通过功能标志进行了增强。

  • Codebreaker.Bot:这是bot-service的实现,用于玩游戏。

  • Codebreaker.GameAPIs.KiotaClient:这是我们第四章中创建的客户端库,用于客户端。

  • Workflows:这个文件夹是新的。在这里,你可以找到所有 GitHub Actions 工作流程。然而,这些工作流程在你将它们复制到仓库中的.github/workflows文件夹之前不会激活。

要与本章中的代码一起工作,你可以使用上一章中的servicebot项目,以及第四章中的Kiota库。

对于本章,你需要 GitHub 权限来运行 GitHub 工作流程,以及创建和使用具有保护规则的 GitHub 环境。最简单的方法是创建一个公共仓库,并将本章中的代码仅复制到其中。在这个新仓库中创建src文件夹,并将源代码复制到这个文件夹中。

查看本书 GitHub 仓库中ch08文件夹中的 README 文件以获取最新更新。

使用 Azure Developer CLI 准备解决方案

首先,让我们使用 Azure Developer CLI 准备解决方案。在初始化解决方案时,将当前文件夹设置为存储库的根文件夹(而不是解决方案文件的文件夹,如我们之前所做的那样):

azd init

选择 botgame-apis 作为要公开到互联网的项目,并输入一个新的环境名称 - 例如,codebreaker-08-dev。包含指向 AppHost 项目文件的链接的生成 azure.yaml 文件需要提交到源代码仓库。生成的 .azure 文件夹可以包含密钥,并且由于生成的 .gitignore 文件,已被排除在源代码仓库之外。

注意

使用稍后使用的 azd pipeline 命令的根目录的原因;在编写本文时,此命令需要 .github/workflows 目录位于同一文件夹中。计划在以后的版本中进行一些更改,因此请检查本章的 README 文件以获取更新。

现在,让我们将资源部署到 Azure:

azd auth login
azd up

使用 azd up,资源将被部署到您配置的环境。选择您希望使用的 Azure 订阅以及您希望部署资源的 Azure 区域。

生成的文件 azure.yaml 引用了 AppHost 项目。生成的文件夹 .azure(由于可能存储密钥而被排除在源代码仓库之外),包含当前环境和与环境同名的一个文件夹。此文件夹包含 config.json 文件,其中列出公开可访问的服务配置,以及包含引用创建的 Azure 资源的 .env 文件。

现在,我们已经准备好使用 GitHub Actions。您可以使用 azd down 再次删除 Azure 资源,因为完整的基础设施应该已经通过 GitHub Actions 部署:

azd down

回答 y 以删除资源,然后再次回答 y 以永久删除已启用软删除的资源。

如果您想永久删除资源,请打开 Azure 门户(portal.azure.com),转到 密钥保管库,然后单击 管理已删除的保管库。已删除的密钥保管库需要被清除,以便您可以再次创建具有相同名称的资源。清除密钥保管库。同样,检查需要清除的 Azure App Configuration 服务。

探索 GitHub Actions

GitHub Actions 是 GitHub 的一个功能,您可以使用它来自动构建、测试和部署源代码。GitHub Actions 是一个由 工作流程事件作业操作运行器 组成的产品:

  • 存储库的 .github/workflows 文件夹。一个工作流程包含事件和作业。

  • 事件指定触发工作流程的内容。工作流程应该在何时启动?

  • 作业由在 运行器机器上执行的步骤组成。

  • 步骤可以运行脚本或操作。

  • 操作 是一个可重用的 GitHub 扩展,它可以减少编写脚本的必要性。许多这些可重用扩展可以用于构建和部署应用程序。

现在我们已经用这些术语奠定了基础,让我们通过使用 Azure 门户创建一个工作流程来深入了解细节。

创建 GitHub Actions 工作流程

有几种选项可以自动创建 GitHub Actions 工作流程以部署服务到 Microsoft Azure。使用 Azure 门户,在打开 容器应用 时,您可以在 设置 下选择 持续部署,如图 图 8.1 所示:

图 8.1 – 从 Azure 门户创建 GitHub Actions 工作流程

图 8.1 – 从 Azure 门户创建 GitHub Actions 工作流程

使用 Azure 门户,您可以选择 GitHub 仓库,配置您希望使用的 Azure 容器注册表,并指定一个 服务主体用户分配的身份 值来发布项目。

另一个选项是使用 Visual Studio。使用 Visual Studio,您可以选择一个项目(例如,game-apis),然后从上下文菜单中选择 发布…。在添加新的发布配置文件时,您可以通过选择 Azure | Azure 容器应用 (Linux),然后选择 容器应用,然后容器注册表,出现以下对话框:

图 8.2 – 通过 Visual Studio 创建 GitHub Actions 工作流程

图 8.2 – 通过 Visual Studio 创建 GitHub Actions 工作流程

在此对话框中,您可以直接发布到 Azure 容器应用或创建一个 GitHub Actions 工作流程。

这些选项的共同点是您可以按服务发布。在这里,您使用了 azd up 来部署完整解决方案。让我们看看 Azure 开发者 CLI 提供了哪些功能来创建 GitHub Actions 工作流程。

首先,您需要在仓库的根目录下创建一个 .github 文件夹。用于特定 GitHub 功能的文件存储在这个文件夹中。向这个文件夹添加一个 workflows 文件夹(.github/workflows)。所有 GitHub Actions 工作流程都需要存储在这个文件夹中。

接下来,创建 codebreaker-deploy.yml 文件。现在,将 azure-deploy.yaml 文件的内容复制到这个文件中。此文件来自 Azure-Samples 仓库:github.com/Azure-Samples/azd-starter-bicep/blob/main/.github/workflows/azure-dev.yml

现在我们已经创建了此工作流程文件,我们可以更仔细地查看它。

使用 YAML 语法的工怍流程文件

工作流程文件的语法使用了 YAML Ain’t Markup LanguageYAML,一个递归缩写)语法。YAML 是一种面向数据的人可读序列化语言,它使用缩进来指定什么属于一起。

有关 YAML 规范和库的链接,请参阅 yaml.org/。您可以通过以下速查表了解语法:yaml.org/refcard.html

在进行一些小修改的同时,让我们更仔细地看看工作流程文件。

触发器

工作流程文件以名称开头,后跟触发器:

workflows/codebreaker-deploy.yml

name: Codebreaker backend workflow
on:
  workflow-dispatch:
push:
    branches:
      - main
    paths:
    - 'src/**'

工作流程的名称显示在工作流程列表中。on 关键字指定触发工作流程的事件。GitHub 提供了许多可以与工作流程一起使用的事件。在此 YAML 文件中,工作流程由 workflow_dispatch 事件触发。这允许您手动触发工作流程。第二个事件 push 在将更改推送到存储库时触发。由于 push 部分中的过滤,触发仅针对使用 path 指定的文件更改推送到 main 分支进行。如果我们不指定分支和路径过滤器,则工作流程将在存储库的每个更改时触发。

无密 Azure 联邦凭据权限

permissions 部分是一个新结构,用于与无密 Azure 联邦凭据一起使用以部署到 Azure:

workflows/codebreaker-deploy.yml

permissions:
  id-token: write
  contents: read

权限用于访问身份令牌和内容。使用 contents read,工作流程可以读取存储库的内容。id-token write 授予对身份令牌的写入访问权限。此令牌用于使用 Azure 认证 GitHub。

Jobs 和 运行者

在工作流程文件中,在定义触发器之后,可以使用 jobs 关键字列出一个或多个应运行的作业:

workflows/codebreaker-deploy.yml

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    env:
      AZURE_CLIENT_ID: ${{ vars.AZURE_CLIENT_ID }}
      AZURE_TENANT_ID: ${{ vars.AZURE_TENANT_ID }}
      AZURE_SUBSCRIPTION_ID: ${{ vars.AZURE_SUBSCRIPTION_ID }}
      AZURE_ENV_NAME: ${{ vars.AZURE_ENV_NAME }}
      AZURE_LOCATION: ${{ vars.AZURE_LOCATION }}

build-and-deploy 是作业的名称。一个作业需要一个运行者。GitHub 提供托管运行者,可以在 Linux、Windows 和 Mac 上运行作业。您可以在以下位置找到可用的运行者及其版本:docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#supportedrunners-and-hardware-resources。如果需要其他硬件或操作系统版本,可以使用自定义运行者。

使用 env 关键字定义环境变量,这些变量可以在本运行者的步骤中使用。这些变量的值来自 GitHub 项目变量,使用 vars 对象。${{ }} 表达式在工作流程执行期间进行评估,检索到的值将在运行时添加到工作流程中。我们将稍后使用 azd pipeline config 指定这些值。

步骤和操作

一个作业由步骤和操作组成:

workflows/codebreaker-deploy.yml

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Install azd
        uses: Azure/setup-azd@v1.0.0
      - name: Install .NET Aspire workload
        run: dotnet workload install aspire
# Code removed for brevity

第一步包括一个操作,actions/checkout@v4。此操作检出源代码以确保它与运行器一起可用。@v4 定义了用于此 GitHub 操作的版本号。操作通过 GitHub Marketplace 提供:github.com/marketplace?category=&query=&type=actions。每个操作都有文档,您可以阅读以了解哪些参数可用。例如,actions/checkout 允许在检出时包含子模块,并允许从其他存储库检出源代码。

下一个操作使用 Azure/setup-azd 安装 Azure 开发者 CLI。

使用托管运行器安装 .NET,但我们需要确保安装 .NET Aspire 工作负载。这可以通过使用 run 字段指定的单行脚本来完成 - 即,dotnet workload install aspire

接下来,使用 azd auth 命令:

workflows/codebreaker-deploy.yml

      - name: Log in with Azure (Federated Credentials)
        if: ${{ env.AZURE_CLIENT_ID != '' }}
        run: |
azd auth login `
            --client-id "$Env:AZURE_CLIENT_ID" `
            --federated-credential-provider "github" `
            --tenant-id «$Env:AZURE_TENANT_ID»
        shell: pwsh

这里,我们需要使用之前定义的权限:使用联合身份进行身份验证。if 指定此步骤是条件性的 - 只有当 AZURE_CLIENT_ID 不为空时。如果 AZURE_CLIENT_ID 为空,我们将有另一个身份验证选项,如下一步所示。此步骤不使用 GitHub 操作;相反,run 字段定义了它将调用一个多行脚本。使用多行是通过行尾的 | 来指定的。PowerShell(通过 shell 字段指定)使用反引号(`)作为行续字符。

使用 PowerShell 运行的命令是 azd auth login,并传递了一些参数。--client-id 使用具有必要 Azure 权限的服务主体的标识符。--federated-credential-provider 使用 GitHub 进行联合身份验证。联合身份验证允许您使用 GitHub 身份访问 Azure 上的资源。--tenant-id 指定用于 Azure 身份验证的 Azure 目录标识符。

下一步使用另一个条件脚本,如果设置了 AZURE_CREDENTIALS 变量:

workflows/codebreaker-deploy.yml

- name: Log in with Azure (Client Credentials)
   if: ${{ env.AZURE_CREDENTIALS != '' }}
   run: |
     $info = $Env:AZURE_CREDENTIALS | ConvertFrom-Json -AsHashtable;
     Write-Host "::add-mask::$($info.clientSecret)"
     azd auth login `
       --client-id "$($info.clientId)" `
       --client-secret "$($info.clientSecret)" `
       --tenant-id "$($info.tenantId)"
   shell: pwsh
   env:
     AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }}

AZURE_CREDENTIALS 环境变量存储为 JSON 脚本。这被转换为一个名为 info 的 PowerShell 哈希表变量,使我们能够访问 JSON 内容的各个部分,例如客户端 ID、客户端密钥和租户 ID。然后,这些部分被传递给 azd auth login 命令。AZURE_CREDENTIALS 本身是通过 secrets.AZURE_CREDENTIALS 获取的。密钥存储在与 GitHub 项目一起加密的旁边,不是存储库源代码的一部分。我们将在下一节中配置这些密钥。

最后,调用两个单行命令:

workflows/codebreaker-deploy.yml

      - name: Provision Infrastructure
        run: azd provision --no-prompt
      - name: Deploy Application
        run: azd deploy --no-prompt

第一个命令 azd provision 根据 AppHost 项目的 app-model 定义创建 Azure 基础设施。如果基础设施已经存在,则会检查是否需要更改,并且仅应用更新。然后 azd deploy 将服务部署到 Azure,从而构建 Docker 镜像,将它们发布到 Azure 容器注册库,并使用创建的镜像创建各种 Azure 容器应用。--no-prompt 选项不会等待用户与此命令交互,而只是使用默认值。

我们之前使用的 azd up 命令使用了 azd provisionazd deploy

在您的仓库中创建 .github/workflows 文件夹,并将 codebreaker-deploy.yml 工作流程文件复制到该文件夹。

GitHub 变量和密钥

密钥不应包含在源代码中,工作流程文件与源代码一起存储。GitHub 有一个保险库,您可以在其中存储源代码仓库之外的密钥。使用 azd,可以自动配置 GitHub 中的密钥和变量。

azd pipeline 命令支持此功能。首先,将当前目录设置为仓库的根文件夹。azd pipeline 需要的 .github/workflows 文件夹必须与您运行 azd pipeline 的目录相同;您还必须在目录中初始化 .NET Aspire 应用程序。此要求可能会更改 - 请检查本章 GitHub 仓库中的 README 文件以获取更多信息。

运行以下命令以配置管道:

azd auth login
azd pipeline config --auth-type federated --principal-name github-codebreaker-dev

azd pipeline config 使用您之前配置的 Azure 订阅来创建 GitHub 变量和密钥,以及创建 Azure 应用注册,这允许 GitHub 将应用程序部署到 Azure。默认情况下,创建的主名称以 az-dev- 开头,并包含创建的日期和时间。在此处,我们指定主名称为 github-codebreaker-dev。创建了 AZURE_ENV_NAMEAZURE_LOCATIONAZURE_SUBSCRIPTION_IDAZURE_TENANT_IDAZURE_CLIENT_ID 的仓库变量。

在浏览器中打开 GitHub 仓库。在门户中,点击 设置。在左侧面板中打开 安全 类别,您将看到 密钥和变量。在此子类别中,当您打开 操作 时,您将看到 操作密钥和变量 页面,其中包含 仓库密钥。这如图 图 8**.3 所示:

图 8.3 – 仓库密钥

图 8.3 – 仓库密钥

AZD_INITIAL_ENVIRONMENT_CONFIG 密钥包含 .azure/[环境]/config.json 文件的内容。此文件包含一个公开可访问的服务列表,并由 azd deploy 读取以配置 Ingress 控制器。所需的环境名称、位置、订阅 ID 和其他详细信息存储在仓库变量中。

因为 azd pipeline config 为 GitHub 创建了联邦身份凭证,所以在默认(联邦)配置下不需要 Azure 的密钥。您可以选择传递 client-credentials,这样配置的凭证将被存储在存储库的密钥中,而不是使用 federated 的值。

注意

第六章 中,您了解了如何使用 Azure Container Apps 将密钥和变量分开,然后在 第七章 中使用 Azure App Configuration 和 Key Vault 进行操作。这里使用 GitHub Actions 的分离原因与此类似。

GitHub 允许您指定不同的存储密钥和变量的级别。当需要在组织内的不同存储库之间共享密钥时,可以使用组织级别。存储库密钥存储在存储库的作用域内,并且不可从其他存储库访问。环境密钥存储在部署环境中。这些将在 使用部署 环境 部分中稍后介绍。

现在运行 GitHub Actions 工作流 – 要么通过将源代码更新推送到 GitHub 存储库,要么通过从 GitHub 门户显式运行工作流。您将看到在工作流完成之前它正在运行:

图 8.4 – 工作流进行中

图 8.4 – 工作流进行中

在这个阶段,您可能需要等待代理可用。当它正在运行时,您可以点击它以查看有关正在进行的操作的信息。图 8**.5 展示了工作流成功完成后出现的步骤:

图 8.5 – 工作流步骤

图 8.5 – 工作流步骤

检查日志后,您将看到所有已完成的步骤。在前面的图中,您可以看到使用了联邦凭证,而不是客户端凭证。您可以点击这些步骤中的每一个以获取更多详细信息。

使用 azd 创建 GitHub Actions 工作流时,只需要几个语句即可部署完整解决方案。对于每次未推送到主分支的源代码更改,都会更新部署。

利用 GitHub Actions 获取更多功能

使用来自 Visual Studio、Azure 门户或通过 azd pipeline 命令的集成来创建 GitHub 动作非常方便。azd pipeline 非常适合部署完整解决方案,但由于其处于早期开发阶段,一些功能尚未实现。我们的解决方案需要更多功能;我们将手动进行定制。

让我们看看我们的一些目标:

  • 所有服务都应该构建、测试和部署

  • NuGet 包应该发布到 GitHub Packages 并在那里提供

  • 我们不想重复代码,所以我们将创建可重用的工作流

  • 应将部署到多个环境,例如开发、测试和生成环境

让我们深入了解细节。

增强 GitHub Actions 工作流

为了构建我们的服务,我们必须创建可重用的工作流。首先,让我们配置这些工作流所需的变量和密钥。

配置变量和密钥

我们之前已经使用az pipeline config配置了变量和密钥。如果您需要更多自定义,您可能需要自己设置这些值。您已经看到了如何使用 GitHub 门户访问仓库密钥和变量。现在,让我们将这些添加到密钥中:

  • AZURE_TENANT_ID

  • AZURE_SUBSCRIPTION_ID

  • AZURE_CLIENT_ID

要获取租户 ID,请使用 Azure CLI:

az account show --query tenantId -o tsv

az account show返回有关已登录 Azure 账户的 JSON 信息。使用 JSONPath --query tenantId查询,返回 Microsoft Entra 租户 ID。-o tsv以制表符分隔的值返回结果。使用AZURE_TENANT_ID仓库密钥设置返回的值。

订阅 ID 也可以使用az account show列出:

az account show --query id -o tsv

在这里,id包含订阅 ID。使用AZURE_SUBSCRIPTION_ID仓库密钥设置此值。

在本章的早期,我们使用了azd pipeline命令来创建联合身份验证的账户。让我们在 Microsoft Entra 门户中查看此账户:entra.microsoft.com。登录后,从左侧栏中,在github-codebreaker-dev内。如果您没有提供名称,azd将创建以az-dev开头的账户。打开此账户,在管理类别中,点击证书和密钥。打开联合凭据。您将看到基于 GitHub 组织和仓库命名的凭据,实体类型为拉取请求分支。将为 GitHub Actions 部署 Azure 资源提供一个预定义的联合凭据场景。

注意

要使用 Azure 门户、Azure CLI 或 Azure PowerShell 使用联合凭据创建新的应用程序注册,请参阅以下文档:learn.microsoft.com/en-us/azure/developer/github/connect-from-azure?tabs=azure-portal%2Clinux#add-federated-credentials

复制AZURE_CLIENT_ID仓库密钥的值。

现在我们已经指定了必要的密钥和变量,让我们回到创建工作流的步骤。

运行单元测试

当通过更新服务的源代码来触发工作流时,第一步应该是运行单元测试。让我们创建一个可重用的工作流:

workflows/shared-test.yml

name: Shared workflow to build and test a .NET project
on:
  workflow_call:
    inputs:
      project-name:
        description: 'The name of the project'
        required: true
        type: string
      solution-path:
        description: 'The solution file of the project to build and run tests'
        required: true
        type: string
      dotnet-version:
        description: 'The version of .NET to use'
        required: false
        type: string
        default: '8.0.x'

通过调用此工作流可以触发一个可重用的工作流。由on指定的触发器使用workflow_call关键字。在此阶段,所需的输入值也已定义。使用此工作流,project-namesolution-path是必需的输入值。dotnet-version输入值已分配默认值,因此不是必需的。

在触发器和输入值之后,定义了一个带有运行器的作业,随后是调用的步骤:

work flows/shared-test.yml

jobs:
  run-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout to the branch
        uses: actions/checkout@v4
      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ inputs.dotnet-version }}
      - name: Install .NET Aspire workload
        run: dotnet workload install aspire
      - name: Restore NuGet Packages
        run: dotnet restore ${{ inputs.solution-path }}
      - name: Run unit tests
        run: dotnet test --logger trx --results-directory "TestResults-${{ inputs.project-name}}" --no-restore ${{ inputs.solution-path }}
      - name: Upload the test results
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ inputs.project-name}}
          path: TestResults-${{ inputs.project-name}}
        if: always()

使用actions/checkout操作检出源代码后,使用actions/setup-dotnet安装.NET SDK。在这里,.NET 版本是从输入值中检索的。由于现在使用此运行器安装了.NET SDK,因此可以使用.NET CLI。在下一步中,不是调用操作,而是使用run关键字执行dotnet restore命令。dotnet restore检索引用解决方案的 NuGet 包。如果失败,则无需继续下一步。下一步使用dotnet test运行单元测试。--logger选项指定使用 TRX 日志格式写入日志输出 – 一个 Visual Studio actions/upload-artifact操作。工件可以用于在运行器之间共享数据,也可以与工作流程运行一起下载。默认情况下,只有前一个步骤成功时,步骤才会运行。在这种情况下,如果测试失败,则希望从工件中下载测试结果 – 这就是为什么在上传工件时添加了if: always()

此共享工作流程从codebreaker-test.yml工作流程启动:

workflows/codebreaker-test.yml

# code removed for brevity
jobs:
  build-and-test:
    uses: ./.github/workflows/shared-test.yml
    with:
      project-name: 'Codebreaker-Backend'
      solution-path: 'src/Chapter08.sln'

定义的工作流程使用名称build-and-test,通过uses关键字引用共享工作流程文件,并使用with关键字设置输入值。

当主分支中指定的文件和文件夹发生更改时,此工作流程会被触发,这是明确的。图 8.6.6 显示了运行工作流程的结果:

图 8.6 – 运行工作流程

图 8.6 – 运行工作流程

通过这个结果,你可以看到用于查看测试结果的可下载工件。

现在我们已经运行了单元测试,让我们将之前创建的构建和部署任务结合起来。

运行多个任务

要从一个工作流程中运行多个作业,我们需要从部署项目中创建一个共享工作流程:

workflows/shared-deploy.yml

name: Shared workflow to deploy a .NET Aspire project
on:
  workflow_call:
    inputs:
# code removed for brevity
    secrets:
      AZURE_CLIENT_ID:
        required: true
      AZURE_TENANT_ID:
        required: true
      AZURE_SUBSCRIPTION_ID:
        required: true

因为这是一个从其他工作流程触发的共享工作流程,所以on指定了workflow_call。此工作流程与之前创建的部署工作流程非常相似,所以这里没有重复代码。查看源代码存储库以获取完整工作流程。这里重要的是,不仅从调用工作流程传递了输入,还传递了秘密信息。这些秘密信息使用$ {{ secrets.<secret> }}表达式引用。

codebreaker-testanddeploy.yml工作流程调用这两个共享工作流程:

workflows/codebreaker-testanddeploy.yml

# code removed for brevity
jobs:
  build-and-test:
    uses: ./.github/workflows/shared-test.yml
    with:
      project-name: Codebreaker-Backend
      solution-path: src/Chapter08.sln
  build-and-deploy:
    needs:  build-and-test
    uses: ./.github/workflows/shared-deploy.yml
    with:
      environment-name: ${{ vars.AZURE_ENV_NAME }}
      location: ${{ vars.AZURE_LOCATION }}
    secrets:
      AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

使用 needs 关键字,build-and-deploy 作业被定义为在运行之前需要 build-and-test 作业。如果 build-and-test 作业没有成功,则 build-and-deploy 不会运行。需要将密钥转发到共享工作流程。有了密钥,你可以指定要传递给调用工作流程的每个密钥,或者将调用工作流程中可用的所有密钥与被调用工作流程共享。在使用环境时(如后续部分所示),需要继承这些密钥。

当你在这一阶段运行工作流程时,你会看到一个图形视图,显示了两个作业是如何连接的,如图 图 8.7 所示。7*:

图 8.7 – 运行多个作业

图 8.7 – 运行多个作业

这两个任务都成功完成了。

接下来,我们将深入了解可以从多个作业中使用的环境,例如,将解决方案部署到预生产和生产环境。

使用部署环境

当在开发者的本地系统上运行解决方案时,项目可以在本地构建和调试。只需要运行少数服务,例如 App Insights 和 Key Vault,这些服务需要在 Azure 云环境中运行。这由 .NET Aspire 自动完成,它在 AppHost 项目中配置了 app-model。你只需确保你已经配置了 Azure:SubscriptionId 使用用户密钥。为了在 Azure 中运行和测试应用程序,并尝试不同的 Azure 产品,团队中的每个开发者都可以使用 azd initazd up 来在个人 Azure 订阅中运行所有服务,该订阅是 Visual Studio Professional 和 Enterprise 提供的一部分。

使用共享环境也很有用,其中解决方案在 Microsoft Azure 中运行的服务由开发团队共同使用。一个例子是客户端应用程序开发者使用新的每日构建来测试访问云中服务的客户端应用程序。这是 开发环境

要运行负载测试,拥有 测试环境 是有用的。这些环境可以在运行负载测试之前按需创建。在负载测试完成后,并记录了结果后,它们可以被删除。有关运行测试的更多详细信息,请参阅 第十章

在将应用程序移入生产之前,使用与 生产环境 相似的 预生产环境 来进行最终测试,如果应用程序表现如预期。

我们可以通过使用 GitHub Actions 将解决方案部署到所有这些环境中。然而,其中一些环境更为限制性,这意味着只有在验证了解决方案在定义的约束下成功运行后,才能进行部署。

让我们更仔细地看看。

使用 Azure 开发者 CLI 创建环境

要使用 Azure 开发者 CLI 创建环境,你可以使用 azd env new 命令:

azd env new codebreaker-08-prod

这不仅创建了一个名为 codebreaker-08-prod 的新环境,还将当前环境设置为这个新环境。要显示所有已配置的环境,请运行以下命令:

azd env list

这显示了所有已配置的环境以及当前选定的环境。要更改当前环境,请运行以下命令:

azd env select codebreaker-dev

使用 azd 创建环境会创建 .azure 子目录。打开此文件夹后,您将看到 config.json 文件。这显示了当前选定的环境。

每创建一个环境,都会创建一个包含环境名称的子目录,其中包含资源组、Azure 区域和 Azure 订阅 ID 的值。在创建新环境时,您可以使用 --subscription 选项更改订阅。要更改资源的位置,请使用 --location

要查看环境的配置值,请运行以下命令:

azd env get-values

在之后更改 Azure 区域,您可以使用 azd env set

azd env set AZURE_LOCATION eastus3

虽然 Azure 开发者 CLI 支持使用多个环境,但与 GitHub 环境结合使用(目前)尚不可直接访问,但可以轻松定制。在撰写本文时,azd pipeline config 命令仅支持每个仓库一个环境。然而,这预计将会改变,并且与 GitHub 环境的集成已经在讨论中。请检查本章仓库中的 README 文件以获取更新。

您仍然可以使用 azd pipeline 为每个环境创建联邦账户:

azd pipeline config --auth-type federated --principal-name github-codebreaker-prod

这将创建我们将与 codebreaker-08-prod 环境一起使用的账户。

到目前为止,我们需要学习如何使用 GitHub 环境。因此,我们将从创建 GitHub 环境开始。

创建 GitHub 环境

在使用 GitHub 环境之前,您需要知道此 GitHub 功能仅适用于免费公共仓库。对于私有仓库,需要团队许可证(见 github.com/pricing)。

在浏览器中打开您的 GitHub 仓库并点击 设置。在左侧面板中,在 代码和自动化 类别下,点击 环境图 8**.8 显示了开发、测试、预发布和生产环境:

图 8.8 – GitHub 环境

图 8.8 – GitHub 环境

您可以通过访问您的仓库使用浏览器创建这些环境。在创建环境的同时,可以应用保护规则。

定义部署保护规则

在发布到另一个环境之前,你可以强制执行部署保护规则。发布到生产环境可能仅允许从受保护的分支、满足命名约定的特定分支以及仅使用特定标签名的提交中进行。最多可以指定六个审查员来批准部署。还有实施自定义保护规则的选择,例如,可能检查不同测试运行的结果(测试将在第十章中介绍)或检查 GitHub 仓库中的问题。第三方保护规则也可用。

注意

在应用的前几个版本中,你将在不同的环境中开始部署,这时添加一些进行手动检查的审查者是良好的实践。在解决方案部署到生产环境之前,它需要先部署到预发布环境。在预发布环境中,使用手动检查。在改进 CI/CD 流程的道路上,你可能需要添加越来越多的自动检查。在进入下一阶段之前,可以进行自动化测试、代码分析、检查问题等。

在生产环境中,添加自己作为必需审查员,并启用部署保护,如图图 8.9所示:

图 8.9 – GitHub 环境下的必需审查员

图 8.9 – GitHub 环境下的必需审查员

除了要求审查员外,你还可以使用 GitHub 合作伙伴应用程序中现有应用程序定义的规则来要求某些源代码或问题检查,并实施自定义保护规则。

注意

当使用分支和标签的部署保护规则时,你应该指定并非每个人都可以创建与规则一起使用的分支和标签。有关更多详细信息,请参阅配置标签保护规则

接下来,我们将使用环境配置密钥和变量。

设置环境密钥和变量

使用环境,你还可以指定仅在这些环境中可用的变量和密钥。我们需要之前创建的联合账户的租户 ID、订阅 ID 和账户 ID。这些信息已在增强 GitHub Actions 工作流程部分配置。

作为提醒,要获取租户 ID,请使用az account show –query tenantId -o tsv。要获取订阅 ID,请使用az account show --query id -o tsv。对于账户,要使用环境,还需要额外的凭证。

打开 Entra 门户(entra.microsoft.com)并选择az-dev<date>。选择repo:<github org/repo>:pull_requestrepo:<github org/repo:refs/heads/main>主题标识符已添加。添加新的凭据并选择GitHub Actions 部署 Azure 资源,如图图 8**.10所示:

图 8.10 – 环境凭据

图 8.10 – 环境凭据

在此对话框中,添加您的 GitHub组织仓库,选择实体类型环境,输入与您的 GitHub 环境匹配的GitHub 环境名称值,并提供凭据详情

要配置密钥,复制此应用程序注册的应用程序(客户端)ID值。

一旦您有了这些值,在 GitHub 门户中打开环境并添加环境密钥环境变量,如图图 8**.11所示:

图 8.11 – 配置环境密钥和环境变量

图 8.11 – 配置环境密钥和环境变量

需要以下变量:

  • AZURE_ENV_NAME: 应使用不带rg-前缀的资源组名称 – 例如,codebreaker-08-prod

  • AZURE_LOCATION: 您首选的 Azure 区域

您将需要以下密钥:

  • AZURE_SUBSCRIPTION_ID

  • AZURE_TENANT_ID

  • AZURE_CLIENT_ID

在此配置就绪后,让我们更新工作流。

使用工作流的环境

要使用工作流中的环境,您只需引用环境名称。将共享工作流shared-deploy.yml复制到shared-deploy-withenvironment.yml,并增强其环境配置:

workflows/shared-deploy-withenvironment.yml

# code removed for brevity
  workflow_call:
inputs:
      environment-name:
        description: 'The environment to deploy to'
        required: true
        type: string
jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment-name }}

在配置作业时,使用environment关键字来引用环境名称。在此实现中,使用必需的输入参数来传递环境名称。无需对密钥和变量进行更改。在环境中运行时,这些值是从环境配置中检索的。

使用各种工作流创建和推送 Docker 镜像以及发布容器应用的codebreaker-production.yml工作流与开发环境不同,如下所示:

workflows/codebreaker- produnction.yml

# code removed for brevity
jobs:
  build-and-deploy:
    uses: ./.github/workflows/shared-deploy-withenvironment.yml
    secrets: inherit
    with:
      environment-name: codebreaker-08-prod

环境参数现在设置为codebreaker-08-prod。这次,密钥没有明确声明,但所有此工作流可以访问的密钥都传递给了被调用的工作流。由于被调用工作流指定的环境,密钥和变量是从 GitHub 环境引用的。

现在,您可以尝试触发工作流。第一阶段运行,但第二阶段必须进行审查,如图图 8**.12所示:

图 8.12 – 工作流请求审查

图 8.12 – 工作流请求审查

查看工作流程的结果并批准它,如图 8.13 所示:

图 8.13 – 批准和部署

图 8.13 – 批准和部署

在这一点上,你需要等待几分钟,直到所有资源都部署到生产环境中。验证部署是否成功。在成功部署后,你可以使用客户端,更新到新环境的链接,并玩游戏。

对于客户端程序员,我们将创建一个 NuGet 包。

发布 NuGet 包

在我们的解决方案中,我们还有客户端应用程序使用的库。拥有 NuGet 包有助于使用这些库。通过创建 GitHub 动作,我们可以自动构建和发布 NuGet 包。如果你想使包公开可用,你可以将其发布到 NuGet 服务器(你已经使用过可用于本书的包)。要使包私有并需要身份验证,GitHub 提供 GitHub Packages

准备库项目

向项目中添加一些元数据,例如描述包的 README Markdown 文件,以及替换默认图标的自定义图标,可以增强可用性。

Codebreaker.GameAPIs.KiotaClient 项目包含一个 readme.md 文件和一个图标 JPG 文件。这些添加需要在项目文件中上传:

Codebreaker.GameAPIs.KioataClient/Codebreaker.GameAPIs.KiotaClient.csproj

  <ItemGroup>
    <None Include="package/readme.md" Pack="true" PackagePath="\" />
    <None Include="package/codebreaker.jpeg" Pack="true" 
     PackagePath="\" />
  </ItemGroup>

README 文件和图标不需要构建到库中,这就是为什么在 ItemGroup 中使用 None 来排除它们从库的构建结果中。将这些项目添加到 NuGet 包中是通过 Pack 属性指定的。PackagePath 指定这些项目可以在包中的哪个文件夹找到。

以下 PropertyGroup 定义指定了 README 文件和包图标的使用,并添加了一些元数据:

Codebreaker.GameAPIs.KioataClient/Codebreaker.GameAPIs.KiotaClient.csproj

  <PropertyGroup>
    <PackageId>
      CNinnovation.Codebreaker.KiotaClient
    </PackageId>
    <PackageTags>
      Codebreaker;CNinnovation;Kiota
    </PackageTags>
    <Description>
      This library contains Kiota-generated classes for communication 
      with the Codebreaker games API service.
      See https://github.com/codebreakerapp for more information on 
        the complete solution.
    </Description>
    <PackageReadmeFile>readme.md</PackageReadmeFile>
    <PackageIcon>codebreaker.jpeg</PackageIcon>
  </PropertyGroup>

使用 PackageIdPackageTagsDescription 元素指定向包添加元数据。

定义包的版本也是一个好主意。与源代码仓库一起,Directory.Build.props 文件中定义的 VersionPrefix 元素指定了子目录中找到的所有项目的版本的第一部分。使用 GitHub 动作,动态添加一个 VersionSuffix 元素,该元素在每次构建时递增。这种版本控制方案用于 alphabetaprerelease 版本。

一旦库发布,就会添加 Version 元素来指定包的完整版本。将 Version 元素添加到项目会覆盖 VersionPrefixVersionSuffix,并且只使用这个版本。发布后,当下一个 beta 版本可用时,Version 元素再次被移除,VersionPrefix 元素递增到下一个迭代。

创建访问令牌

要将包发布到 GitHub Packages,需要一个个人访问令牌(经典版)。在撰写本文时,新的细粒度个人访问令牌不能与 GitHub Packages 一起使用。

你可以通过点击右上角的用户图标,选择设置,然后在左侧面板中点击开发者设置来创建一个个人访问令牌。选择个人访问令牌并点击令牌(经典版)。要创建新令牌,选择生成新令牌(经典版)。选择一个过期日期。发布包所需的范围是write:packages。选择此范围还会添加其他范围,例如读取包和访问仓库。点击生成令牌。你需要复制这个生成的令牌——关闭屏幕后它将不再可见。只需确保将其存储在安全的地方。如果你没有令牌,或者令牌已过期,你可以创建一个新的令牌。

对于你希望使用的 GitHub 动作,将此令牌存储在 PAT_PUBLISHPACKAGE 仓库的秘密中。

现在我们已经存储了这个秘密,让我们使用 GitHub 动作来使用它。

创建一个用于发布 GitHub 包的 GitHub 动作

用于创建 NuGet 包的 GitHub 动作与我们之前创建的 GitHub 动作有相似之处。查看源代码仓库以获取详细信息。

shared-create-nuget.yml 共享工作流程构建 NuGet 包并将其与 GitHub 艺术品一起上传。以下步骤在此工作流程中完成:

  1. 查看源代码。

  2. 设置 .NET。

  3. 计算构建号(使用配置的偏移量到 GitHub 构建号)。

  4. 使用 dotnet build 构建库。

  5. 使用 dotnet test 测试库。

  6. 使用 dotnet pack 创建 NuGet 包。

  7. 使用 GitHub 艺术品上传包。

下一个共享工作流程(shared-githubpackages.yml)通过以下步骤将包上传到 GitHub Packages:

  1. 下载 GitHub 艺术品。

  2. 设置 .NET。

  3. 使用 dotnet nuget add source 设置 NuGet 源。

  4. 使用 dotnet nuget push 将包推送到 GitHub Packages。

推送包时使用配置的访问令牌。

kiota-lib.yml 工作流程连接两个共享工作流程并传递参数。在成功运行此工作流程后,你可以使用 GitHub 仓库的组织验证包,如图 8.14 所示。14*:

图 8.14 – GitHub Packages

图 8.14 – GitHub Packages

使用 GitHub 环境,你可以增强 NuGet 包的创建并定义环境,例如,在成功使用 GitHub Packages 的私有源之后,仅将包发布到公开可用的 NuGet 服务器。

在现代部署中,不仅仅是使用开发、测试和生产环境。我们将在下一节讨论这个问题。

使用现代部署模式

使用开发、预发布和生产环境是“传统”部署模式之一。如今,也使用了其他部署模式:

  • 当使用金丝雀发布时,用户可以选择不同的应用程序版本。这从 Edge 浏览器中很明显,它提供每月更新的 Beta 频道、每周更新的 Dev 频道和每日更新的金丝雀频道。用户可以决定要测试哪个版本。有关更多详细信息,请参阅www.microsoft.com/en-us/edge/download/insider

  • A/B 测试中,用户随机收到两种不同的用户界面之一。当使用此模式时,您可以监控哪种 UI 可以让用户更高效。

  • 蓝绿部署允许您通过安装到预发布服务器,交换预发布与生产环境,快速回滚安装。如果出现问题,可以轻松回滚。

  • 暗启动是一种模式,您可以在确保新功能在激活开关打开之前隐藏的情况下发布应用程序的新版本。一个例子是当功能应在特定时间可用时。此开关可以通过时间事件打开 – 无需重新部署应用程序。

  • 功能开关允许您打开/关闭每个功能。一个选项是为特定用户组启用一些功能,例如早期采用者。用户自己也可以决定他们想要测试的新功能。此类开关在 Microsoft Azure 和 Visual Studio 中可用。

第七章中,您看到了 Azure App Configuration 的实际应用。这项 Azure 服务不仅支持集中式应用程序配置,还提供了功能标志。Azure App Configuration 的这项功能可以通过使用不同的功能标志过滤器与几种现代部署模式一起使用。

配置功能标志

让我们打开使用 Bicep 脚本创建的 Azure App Configuration 服务。在左侧面板中,在操作类别下,打开功能管理器

注意

由于资源是从 Bicep 脚本创建的,您可能无法访问此资源以添加配置数据。使用访问控制(IAM),将您的用户添加到应用程序配置数据所有者贡献者角色。您可能需要等待大约 15 分钟,角色才会更改。

创建一个新的功能标志,如图8.15所示:

图 8.15 – 创建功能标志

图 8.15 – 创建功能标志

将功能标志的名称设置为Feature8x5Game,添加描述,并勾选FeatureGame6x4MiniFeatureGame6x4FeatureGame5x5x4。对于前两个,不要添加过滤器;只需启用这些中的一个。对于最后一个,添加时间过滤器,以便将来可以启用,但不要设置过期日期。

目标过滤器允许您为特定用户组(早期采用者)打开功能。它也可以作为百分比过滤器,因此您可以为此随机百分比的用户打开此功能。另一个内置过滤器是时间窗口过滤器。使用此过滤器,您可以指定此功能应启用的开始和结束时间。您还可以为过滤器创建自定义实现。

现在我们已经配置了这个功能标志,让我们从game-apis服务中使用它。

功能标志的 DI 和中间件配置

要使用功能管理,将Microsoft.FeatureManagement.AspNetCore NuGet 包添加到Codebreaker.GameAPIs项目中。DI 容器需要配置功能标志,如下所示:

Codebreaker.GameAPIs/Program.cs

// code removed for brevity
builder.Services.AddFeatureManagement()
  .AddFeatureFilter<TargetingFilter>()
  .AddFeatureFilter<TimeWindowFilter>();

AddFeatureManagement扩展方法注册了功能标志所需的类型。每个使用的过滤器都通过AddFeatureFilter扩展方法添加。

备注

功能管理 API 也可以在不使用 Azure 的情况下使用。在查看本书 GitHub 仓库中的源代码时,您会看到功能管理 API 也可以不使用 Azure 进行配置。在这种情况下,调用AddFeatureManagement API 的重载以传递IConfiguration对象。有了这个,功能标志可以使用.NET 配置选项进行配置。请参阅第七章以获取有关配置的更多信息。

要将功能管理连接到 Azure App Configuration,您必须更新AddAzureAppConfiguration方法:

Codebreaker.GameAPIs/Program.cs

  builder.Configuration.AddAzureAppConfiguration(options =>
  {
    options.Connect(new Uri(endpoint), credential)
      .Select("GamesAPI*")
      .ConfigureKeyVault(kv =>
      {
        kv.SetCredential(credential);
      })
      .UseFeatureFlags();
  });

UseFeatureFlagsAzureAppConfigurationOptions类的一个方法,用于连接功能标志。

在使用功能标志时,Azure App Configuration 中间件也需要进行配置:

Codebreaker.GameAPIs/Program.cs

var app = builder.Build();
if (solutionEnvironment == "Azure")
{
  app.UseAzureAppConfiguration();
}

设置完成后,我们可以检查功能标志是否已设置。

使用功能标志

现在,我们可以使用功能管理器来检查功能是否可用。我们将首先为IFeatureManager接口创建一个扩展方法:

Codebreaker.GameAPIs/Extensions/FeatureManagerExtensions.cs

public static class FeatureManagerExtensions
{
  private static List<string>? s_featureNames;
  public static async Task<bool> IsGameTypeAvailable(this 
    IFeatureManager featureManager, GameType gameType)
  {
    async Task<List<string>> GetFeatureNamesAsync()
    {
      List<string> featureNames = [];
      await foreach (string featureName in featureManager.
        GetFeatureNamesAsync())
      {
        featureNames.Add(featureName);
      }
      return featureNames;
    }
    string featureName = $"Feature{gameType}";
    if ((s_featureNames ?? await GetFeatureNamesAsync()).
    Contains(featureName))
    {
      return await featureManager.IsEnabledAsync(featureName);
    }
    else
    {
      return true;
    }
  }
}

此方法使用由IFeatureManager接口定义的GetFeatureNamesAsyncIsEnabledAsync方法。在第一次调用此方法时,检索与功能管理器注册的功能列表,并将其添加到_featureNames集合中。并非每种游戏类型都注册为功能。对于未注册为功能的游戏类型,该方法返回true以通知我们此类型可用。对于所有已注册为功能的游戏类型,使用IsEnabledAsync方法来检查功能是否启用。

接下来,让我们用最少的 API 注入IFeatureManager

Codebreaker.GameAPIs/Endpoints/GameEndpoints.cs

group.MapPost("/", async Task<Results<Created<CreateGameResponse>, BadRequest<GameError>>> (
  CreateGameRequest request,
  IGamesService gameService,
  IFeatureManager featureManager,
  HttpContext context,
  CancellationToken cancellationToken) =>
  {
    Game game;
    try
    {
      bool featureAvailable = await featureManager.
        IsGameTypeAvailable(request.GameType);
      if (!featureAvailable)
      {
        GameError error = new(ErrorCodes.
          GameTypeCurrentlyNotAvailable, "Game type currently not 
          available", context.Request.GetDisplayUrl());
        return TypedResults.BadRequest(error);
      }
      game = await gameService.StartGameAsync(request.GameType.
        ToString(), request.PlayerName, cancellationToken);}
// code removed for brevity

在使用 API 启动游戏时,IFeatureManagement接口被注入以检查请求的游戏类型,并使用之前创建的扩展方法IsGameTypeAvailable启用功能。根据结果,会返回错误或创建一个新的游戏。

使用此实现,您可以运行应用程序并测试这些功能标志。game-apis项目包含一个 HTTP 文件,您可以使用它创建所有不同的游戏类型,并查看使用功能标志时返回的结果。您可以在您的开发系统上本地测试。在将更新推送到您的 GitHub 仓库后,一个工作流程已准备好被触发。然后,您只需在 HTTP 文件中配置您的 API 服务的链接,以测试使用 Azure Container Apps 运行的服务。

摘要

第六章中使用 Bicep 脚本创建 Azure 服务之后,在本章中,您学习了如何使用 GitHub Actions 进行持续集成CI)和持续交付CD)。在这里,您更改了源代码,创建并合并了一个拉取请求,测试了代码,并部署了 Azure Container Apps。使用 GitHub Actions,您学习了如何构建 NuGet 包并将它们推送到 GitHub Packages。

使用 GitHub 环境,您创建了多个部署环境,在部署扩展到另一个阶段之前需要执行额外的检查。

之后,您学习了如何配置 Azure App Configuration,以及如何使用功能标志,这些标志对于现代部署模式(如 A/B 测试、蓝绿部署和暗启动)是必需的。

下一章将涵盖另一个重要主题:身份验证和授权。在第七章中,您学习了如何使用托管标识运行 Azure 服务。在第九章中,我们将限制允许调用 API 的应用程序,对匿名用户的功能进行限制,并添加仅允许特定用户组使用的 API。

进一步阅读

若想了解更多关于本章讨论的主题,请参考以下链接:

第九章:使用服务和客户端进行身份验证和授权

并非每个用户和应用都应该被允许访问所有 API 服务。某些 API 应仅从特定应用程序访问,而其他 API 应仅限于特定用户组。

在本章中,你将学习如何使用 企业到消费者 (B2C) 允许用户在我们的应用程序中注册并保护 API。我们将使用 Azure 活动目录 (AD) B2C 来实现这一点。对于本地解决方案(也可以在云中使用),我们将使用 ASP.NET Core Identity。

而不是为每个 API 项目进行安全保护,你将了解 Microsoft Yet Another Reverse Proxy (YARP),这是一个放在可用的 API 前面的代理,用于限制对后端服务的访问。

在本章中,你将学习以下内容:

  • 创建 Azure AD B2C 租户

  • 保护 REST API

  • 使用 Microsoft YARP

  • 使用 ASP.NET Core Identity

技术要求

在本章中,就像前面的章节一样,你需要一个 Azure 订阅、Docker Desktop 和 .NET Aspire。

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch09 文件夹包含以下项目及其输出:

  • Codebreaker.ApiGateway:这是一个新项目,将在 game-apis 服务和 bot-service 前充当应用程序网关,并借助 YARP 保护 API

  • WebAppAuth:这是一个新的客户端项目,专注于使用 Azure AD B2C 创建新用户,从客户端提供身份验证,并通过网关调用 bot-service

  • Codebreaker.ApiGateway.Identities:这是一个新项目,可以用作 Codebreaker.ApiGateway 的替代品,其中不是使用 Azure AD B2C,而是创建和管理本地用户

为了帮助你使用本章的代码,请首先使用上一章的代码。

选择身份解决方案

对于 .NET 解决方案,有多种选项可用于对用户进行身份验证。如果你需要一个可以管理用户的本地数据库,你可以使用 ASP.NET Core Identity,它使用 EF Core(见 第五章)。它允许你存储本地用户并将用户账户(如来自 Microsoft、Facebook 和 Google 的账户)与 OpenID Connect (OIDC) 集成。对于数据库,可以使用 SQL Server 和 MySQL,而数据模式是完全可定制的。

为了减少工作量和提高安全性,不需要在每个服务中实现此功能——在这里,可以使用 Microsoft YARP 来转发请求并发送所需的声明。

如果外部应用程序正在访问身份管理解决方案,应使用OIDC服务器来管理身份。如果无法在云服务中存储用户数据,可以使用第三方服务,如 Duende 的 Identity Server(duendesoftware.com/products/communityedition)。这对于小型公司是免费的。

在云服务中存储用户数据时,许多公司使用Microsoft Entra。这可以轻松地与.NET 应用程序集成。这项服务提供了企业对企业B2B)功能,允许您添加外部用户(Entra External Identities)。微软、Facebook 和谷歌账户都列在支持的外部用户名单中。然而,在撰写本文时,Microsoft Entra不允许用户自行注册。为此,Azure AD B2C是一个很好的选择。此服务还可以与本地运行的并从云中访问身份验证的服务一起使用。

注意

Azure AD B2C 的用户数据居住要求允许您在创建目录时选择一个国家,并显示数据的位置。然而,如果要求将用户数据保留在瑞士,数据将存储在欧洲,这可能不足以满足某些企业的法律要求。

对于 Codebreaker 解决方案,我们将使用 Azure AD B2C 和 ASP.NET Core Identity。

创建 Azure AD B2C 租户

Codebreaker 解决方案应允许用户使用应用程序进行注册并玩不同类型的游戏。一些有限的游戏类型可供匿名用户使用。所有游戏类型和更多功能都可供注册用户使用。解决方案的一些部分应仅对特定用户组可访问——例如,bot-service不应从普通注册玩家用户访问。需要特定的用户权限(或声明)来进行区分。

要创建新的 AAD B2C 租户,请打开 Azure 门户并点击创建资源。从左侧栏中选择身份,然后选择Azure Active Directory B2C。然后,选择创建新的 Azure AD B2C 租户。这将打开如图 9.1 所示的屏幕:

Figure 9.1 – 创建 AAD B2C 租户

图 9.1 – 创建 AAD B2C 租户

要创建新的 AAD B2C 租户,您需要输入组织的名称、域名(一个尚未存在的域名)、用于定义用户数据存储区域的地理位置、订阅和资源组。完成这些操作后,点击审查 + 创建,然后点击创建

您需要等待一段时间以创建目录。要在 Azure 门户中列出您可用的目录,并切换目录,请点击设置按钮。选择新目录并点击切换以更改到它。同样,您也可以切换回运行 Azure 资源的目录。

在接下来的几节中,我们将执行以下操作:

  • 指定身份提供者,以便用户不需要输入另一个密码

  • 配置用户属性以定义应用程序需要从用户处获取的信息

  • 定义用户流程以指定用户信息如何流动

  • 创建应用程序注册以定义提供 API 和客户端应用程序以访问 API 的服务应用程序

指定身份提供者

当您处于 Azure AD B2C 中时,可以打开 Azure AD B2C 配置。B2C 目录支持大量不同的身份提供者。当用户使用身份提供者时,不需要记住另一个密码。在 Azure AD B2C 配置中,在左侧面板的管理类别中,选择身份提供者(见图图 9.2):

图 9.2 – 身份提供者

图 9.2 – 身份提供者

默认情况下,本地账户已配置,以便密码以本地方式与 AAD B2C 一起存储。您可以配置支持 OIDC 的 Microsoft、Google、Facebook 和其他账户。Codebreaker 目录已将 GitHub 配置为提供者,因为大多数开发者已经有了 GitHub 账户。

对于每个提供者,您需要执行的操作来配置它。您只需单击提供者即可获取该信息。例如,对于 GitHub,您需要创建 GitHub OAuth 应用程序以获取配置此提供者所需的所有值。对于使用 AAD B2C 进行身份验证的服务,您可以保留默认设置。

配置用户属性

无论您选择哪个提供者,您都必须有一种方法来识别用户。为了收集此类信息,您必须向您的用户索要详细信息。您还可以创建应在目录中存储的自定义属性。在管理类别中,选择用户属性,如图图 9.3所示:

图 9.3 – 用户属性

图 9.3 – 用户属性

这里,有几个内置属性,例如String类型的Gamer Name

注意

由于通用数据保护条例GDPR),您需要确保您只收集必要的数据并保持其安全,允许用户请求您存储的数据,并允许用户在不需要因法律原因存储时删除该数据。

定义用户流程

使用用户流程,您定义在注册或编辑用户配置文件时应从用户处收集哪些信息,以及应在声明中发送给应用程序哪些信息。

在 AAD B2C 配置中,从左侧面板中,在B2C_1_内添加一个名称(例如,SUSI),并选择电子邮件注册身份提供者。您还可以选择如 GitHub 等社交提供者。关于用户属性和令牌声明类别,选择当此对话框显示时用户应输入的用户属性,以及作为令牌传递给应用程序的声明,如图图 9.4所示:

图 9.4 – 创建用户流程

图 9.4 – 创建用户流程

在定义从用户那里请求的信息时,请记住 GDPR。

Azure AD B2C 允许您通过指定公司品牌、更改页面布局、返回自定义页面以及在用户注册时添加用于自定义验证器的 API 连接器来自定义用户流程对话框。

注意

用户属性可以通过创建用户流程或 自定义策略 来填充。有关更多信息,请参阅 进一步阅读 部分的链接。还可以查看 Codebreaker 后端存储库中的源代码 (github.com/codebreakerapp/Codebreaker.Backend),其中包含用于为特权用户添加组的自定义策略。

一旦注册了应用程序(下一步),您就可以测试用户流程,如图 图 9.5* 所示:

图 9.5 – 测试用户流程

图 9.5 – 测试用户流程

选择应收集的用户属性定义了对话的输入元素。图标、颜色和布局可以自定义。甚至可以创建完整的自定义对话框。

创建应用注册

接下来,我们将学习如何注册应用。在这里,我们将注册提供 API 和客户端应用程序的应用程序网关。其他应用可以类似地注册。

在左侧面板的 管理 类别中,点击 应用注册。这将打开 应用注册 页面,如图 图 9.6* 所示:

图 9.6 – 注册应用

图 9.6 – 注册应用

添加多个应用注册:Codebreaker.GameAPIs 应用提供游戏 API,Codebreaker.BotCodebreaker.Blazor 是需要 API 权限的 Web 应用程序,而 Codebreaker.Client 是需要 API 权限的客户端应用程序。

当您配置应用注册过程时,您指定哪些帐户可以使用此应用程序。在这里,我们将允许所有帐户、外部注册的用户和重定向 URI。要测试从本地开发系统测试 game-apis 服务,请指定本地运行时使用的端口号,例如 http://localhost:5453,然后点击 注册 按钮。稍后需要添加 Azure 容器应用的链接。

定义作用域

您可以通过应用注册过程指定提供 API 的应用程序。在 games。在此范围内,添加 Games.PlayGames.Query 作用域,如图 图 9.7* 所示:

图 9.7 – 定义作用域

图 9.7 – 定义作用域

创建密钥

要仅允许已识别的应用程序,您可以添加证书或密钥。与使用密钥相比,更好的方法可能是使用允许访问服务的用户运行应用程序。在这里,可以使用托管标识。并非所有场景都支持此操作。

使用左侧面板中的证书和密钥选项创建一个客户端密钥。密钥创建后无法再次从门户中读取,只能复制。在离开页面之前复制密钥。

添加 API 权限

对于调用 API 的应用程序注册,你需要配置API 权限,如图 9.8 所示:

图 9.8 – 添加 API 权限

图 9.8 – 添加 API 权限

Codebreaker.BlazorCodebreaker.Client应用程序注册需要Games.PlayGames.Query应用程序权限。添加这些权限后,点击授予 管理员同意

评估应用程序注册过程

完成此配置后,打开bot-service和客户端应用程序。然后,点击评估我的应用程序注册,如图 9.9 所示。

图 9.9 – 集成助手结果

图 9.9 – 集成助手结果

集成助手在点击推荐配置上方的标签时,为开发、测试、发布和监控提供了大量信息。如果你看到一些警告或错误,点击省略号()。从这里,你可以查看文档并打开一个可以更改你配置的页面。

在配置了 Azure AD B2C 之后,让我们实现一些代码,以便我们可以利用 AAD B2C。

保护 API

我们现在可以保护每个 API 项目。然而,我们可以以不同的方式来做这件事,以减少我们需要做的工作。一个选项是使用 Azure Container Apps 来配置身份验证。而不是为每个容器应用配置此设置,让我们创建一个新的项目,该项目将被保护并路由到多个服务。为此,我们将使用YARP

创建带有身份验证的新项目

使用带有-au身份验证选项的.NET 模板创建一个新的 Web API 项目:

dotnet new webapi -minimal -au IndividualB2C -o Codebreaker.ApiGateway

使用.NET CLI,你也可以传递配置 B2C 服务所需的所有值,例如--domain用于域名,--aad-b2c-instance用于传递登录域名链接,--client-id用于应用程序 ID,--susi-policy-id用于注册用户流程(在它被称为用户流程之前,它被称为策略),以及--default-scope用于配置作用域。如果你没有为这些配置分配参数值,你只需在appsettings.json文件中创建后更改它们即可。

已添加到该项目中的与身份验证和授权相关的 NuGet 包如下:

  • Microsoft.AspNetCore.Authentication.JwtBearer:此包支持使用JSON Web 令牌JWT)进行身份验证

  • Microsoft.AspNetCore.Authentication.OpenIdConnect:此包允许使用 OIDC 与身份提供者进行身份验证,例如 Azure AD B2C

  • Microsoft.Identity.Web:此包提供用于身份验证流程和用户授权的实用工具和中间件

  • Microsoft.Identity.Web.DownstreamApi: 此包有助于使用相同的身份验证上下文调用下游 API

接下来,我们将 YARP 添加到这个项目中。

使用 YARP 创建应用程序网关

在创建微服务解决方案时,没有必要为每个服务实现身份验证。相反,你可以创建一个充当反向代理的服务。客户端只调用反向代理。此代理将经过身份验证的请求转发到其他服务。在这里,我们将使用 Microsoft YARP。反向代理位于后端服务之前,并在请求发送到服务之前拦截来自客户端的调用。YARP 代理提供不同的功能,如负载均衡、速率限制、协议切换、根据不同版本选择服务等。基于第 7 层,代理可以读取 HTTP 请求,根据链接和 HTTP 头进行路由,以及更改使用的协议。在这里,我们将使用反向代理来处理身份验证和授权,然后再将请求转发到后端服务。

图 9**.10 展示了与服务通信的新方法:

图 9.10 – 通过 YARP 进行通信

图 9.10 – 通过 YARP 进行通信

反向代理将传入的请求路由到后端服务。已路由的后端服务是game-apisbot-service。客户端应用程序不与这些服务交互;它们只是使用 YARP 网关。

除了我们之前添加的 NuGet 包之外,我们还需要添加Yarp.ReverseProxyMicrosoft.Extensions.ServiceDiscovery.Yarp NuGet 包。第一个是 YARP 的包,而第二个允许我们使用 YARP 进行.NET 服务发现。

使用 YARP 映射路由

代理服务如何与后端 API 通信可以通过编程方式或使用配置文件进行配置。我们将使用appsettings.json文件进行第二种选项。首先,让我们配置game-apis服务和bot-service的地址:

Codebreaker.ApiGatewayIntro/appsettings.json

{
  "ReverseProxy": {
    "Clusters": {
      "gamesapicluster": {
        "Destinations": {
          "gamescluster/destination1": {
            "Address": "http://gameapis"
          }
        }
      }
      "botcluster": {
        "Destinations": {
          "botcluster/destination1": {
            "Address": "http://bot"
          }
        }
      },
    },
    // configuration removed for brevity
  }
}

完整的反向代理配置已添加到ReverseProxy部分,配置部分,称为Clusters,定义了可用于game-apis服务和bot-service的系统列表。对于每个服务,可以添加多个地址。使用服务发现 YARP 包,我们可以使用.NET Aspire 命名端点。

以下代码配置了使用集群配置的路由:

Codebreaker.ApiGatewayIntro/appsettings.json

{
  "ReverseProxy": {
    // configuration removed for brevity
    "Routes": {
      "gamesRoute": {
        "ClusterId": "gamesapicluster",
        "Match": {
          "Path": "/games/{*any}"
        }
      },
      "botRoute": {
        "ClusterId": "botcluster",
        "Match": {
          "Path": "/bot/{*any}"
        }
      }
    }
  }
}

Routes配置包含一个路由列表。gamesRoute引用之前指定的gamesapicluster,而botRoute引用由botcluster定义的主机。Match配置指定用于将请求映射到相应集群的Path

我们只需要对启动代码进行少量更新以激活此反向代理库:

Codebreaker.ApiGateway/Program.cs

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
  .LoadFromConfig(
    builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.MapReverseProxy();
app.Run();

AddReverseProxy 方法将反向代理所需的服务注册到 DI 容器中。LoadFromConfig 方法从之前指定的配置中检索配置值。MapReverseProxy 方法配置中间件以根据配置转发请求。

AppHost 项目中,在添加对网关项目的引用之后,可以将网关添加到应用程序模型中:

Codebreaker.AppHost/Program.cs

var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithReference(cosmos)
  .WithEnvironment("DataStore", dataStore);
var bot = builder.AddProject<Projects.CodeBreaker_Bot>("bot")
  .WithReference(gameAPIs);
builder.AddProject<Projects.Codebreaker_ApiGateway>("gateway")
  .WithReference(gameAPIs)
.WithReference(bot)
  .WithExternalHttpEndpoints();
// code removed for brevity

API 网关需要引用 game-apis 服务和 bot-service。在这里,外部 HTTP 端点不再需要。当网关部署到 Azure Container Apps 环境时,只有网关需要从外部引用,因此只有网关配置使用 WithExternalHttpEndpoints 方法。

在此基础上,您可以启动应用程序并通过网关调用两个服务。请求被转发到特定的服务。

接下来,我们将向网关添加身份验证。

添加网关的身份验证

使用已添加身份验证和授权的 .NET 模板 Web API 已经添加了一些代码。我们现在将增强此代码。

我们可以通过调用 AddAuthentication 方法来配置 DI 容器以通过身份验证用户:

Codebreaker.ApiGateway/Program.cs

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddMicrosoftIdentityWebApi(
    builder.Configuration.GetSection("AzureAdB2C"));
// code removed for brevity

AddAuthentication 方法注册了身份验证所需的服务的服务。JwtBearerDefaults.AuthenticationScheme 参数返回 Bearer 作为身份验证方案。Bearer 令牌在大多数 REST API 中使用,因为它们易于使用且不需要加密,但需要执行 HTTPS 加密以安全地传输。

AddMicrosoftIdentityWebApi 是一个扩展方法,它扩展了 AuthenticationBuilder 并使用 Microsoft Identity 平台保护 API。AzureAdB2C 是一个配置部分,它指定了从 appsettings.json 中的 AADB2C 的值:

Codebreaker.ApiGateway/appsettings.json

{
  // configuration removed for brevity
  «AzureAdB2C»: {
    «Instance»: «https://<domain>.b2clogin.com»,
    «Domain»: «<domain>.onmicrosoft.com»,
    "ClientId": "<app-id>",
    "SignedOutCallbackPath": "/signout/B2C_1_SUSI",
    "SignUpSignInPolicyId": "B2C_1_SUSI"
  }
}

使用 appsettings.json 配置文件,您需要配置您的 Azure AD B2C 域名、应用程序 ID 以及之前配置的用户流程。

AddAuthentication 方法指定了身份验证配置:

Codebreaker.ApiGateway/Program.cs

var builder = WebApplication.CreateBuilder(args);
// code removed for brevity
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddMicrosoftIdentityWebApi(
    builder.Configuration.GetSection("AzureAdB2C"));
builder.Services.AddAuthorization(options =>
{
  options.AddPolicy("playPolicy", config =>
{
    config.RequireScope("Games.Play");
  });
  options.AddPolicy("queryPolicy", config =>
  {
    config.RequireScope("Games.Query");
    config.RequireAuthentication();
  }
});

AddAuthorization 方法允许使用 AuthorizationOptions 代理进行配置。这些选项允许您指定默认策略和命名策略。前面的代码片段定义了 playPolicyqueryPolicy 策略。playPolicy 需要设置 Games.Play 范围,而 queryPolicy 需要设置 Games.Query 范围。queryPolicy 还要求用户必须经过身份验证。您可以通过使用 RequireClaim 方法定义一个与令牌一起传递的声明。

在设置了策略之后,可以将路由限制为所需的策略:

Codebreaker.ApiGateway/appsettings.json

// configuration removed for brevity
  "ReverseProxy": {
    "Routes": {
      "botRoute": {
        "ClusterId": "botcluster",
        "AuthorizationPolicy": "botPolicy",
        "Match": {
          "Path": "/bot/{*any}"
        }
      },

使用 AuthorizationPolicy 配置,与 botRoute 一起,引用 botPolicy 以要求认证用户和应用程序发送正确的范围。

现在我们已经配置了 DI 容器,需要配置中间件:

Codebreaker.GameAPis/Program.cs

var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// code removed for brevity

UseAuthentication 方法添加了身份验证中间件,而 UseAuthorization 方法添加了授权中间件。

注意

如果最小 API 需要直接限制,可以使用 RequireAuthorization 扩展方法。可以将策略作为参数传递以检查策略的要求。将 ClaimsPrincipal 注入为最小 API 方法的参数时,可以以编程方式检索有关用户和声明信息的详细信息。这允许我们根据通过 API 获取的值进行检查限制。

为了测试这一点,我们将更新我们的客户端应用程序。

使用 Microsoft Identity 和 ASP.NET Core 网络应用程序进行身份验证

要使用 Azure AD B2C 进行身份验证,我们将使用 Microsoft Identity 平台。在本节中,我们将重点介绍使用 Azure AD B2C 创建账户、登录以及使用 ASP.NET Core 网络应用程序调用受保护的 REST API。

与我们之前创建的最小 API 一样,可以使用 .NET 模板。运行以下命令以创建新项目:

dotnet new webapp -au IndividualB2C -o WebAppAuth

在创建此项目时,添加了几个用于身份验证和身份的 NuGet 包。这些在我们保护 API 时已经讨论过。一个之前未使用过的附加包是 Microsoft.Identity.Web.UI。此包与 Microsoft.Identity.Web 集成,并提供用于登录、注销和配置文件管理的预构建 UI 元素。

通过 DI 容器配置,添加了身份验证。因此,我们需要对其进行自定义以调用 API:

WebAppAuth/Program.cs

IConfigurationSection scopeSections = builder.Configuration
  .GetSection("AzureAdB2C").GetSection("Scopes");
String[] scopes = scopeSection.Get<string[]>() == [];
builder.Services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
  .AddMicrosoftIdentityWebApp(
    builder.Configuration.GetSection("AzureAdB2C"))
  .EnableTokenAcquisitionToCallDownstreamApi(scopes)
  .AddInMemoryTokenCaches();

要使用 Azure AAD B2C,需要使用 appsettings.json 文件中 AzureAdB2C 部分的配置来调用 AddAuthenticationAddMicrosoftIdentityWeb 是来自 Microsoft.Identity.Web NuGet 包的扩展方法。这配置了支持 cookie 和 OpenIdConnectEnableTokenAcquisitionToCallDownstreamApi 方法允许我们传递从应用程序接收到的令牌,以便我们可以通过 HttpClient 将其转发到应用程序调用的 API。当使用此方法时,ITokenAcquisition 接口在 DI 容器中注册。这可以用来检索令牌并将它们传递给 HttpClient 的 HTTP 头。

对于 Microsoft Identity 用户界面,需要使用 DI 容器配置 AddMicrosoftIdentityUI 方法:

WebAppAuth/Program.cs

builder.Services.AddRazorPages()
  .AddMicrosoftIdentityUI();

此方法使用 MicrosoftIdentity 区域配置 AccountController(基于 ASP.NET Core MVC),并提供了 SignInSignOut 方法。

注意

在创建 Blazor 客户端应用程序时,.NET 7 模板内置了对 AD B2C 的支持,但 .NET 8 中则没有。对于 .NET 9 的支持已被计划。您可以手动添加 AD B2C 集成。

可以通过不同的客户端技术实现身份验证的不同差异。查看“进一步阅读”部分中的链接以获取更多信息。还可以查看 Codebreaker GitHub (github.com/codebreakerapp) 以获取 Blazor、WinUI、.NET MAUI、WPF 和 Uno Platform 的实现。

使用 Azure 容器应用指定身份验证

我们可以直接使用 Azure 容器应用来管理身份验证,而不是需要管理服务本身的身份验证。在 Azure 门户中选择已部署的游戏 API 后,在左侧面板的 设置类别中选择 身份验证。在这里,您可以添加一个 身份提供者。通过选择 Microsoft,您可以配置 WorkforceCustomer 租户类型。Workforce 用于 B2B 场景。在这里,您可以直接在 Microsoft Entra 中创建应用程序注册。对于 B2C,选择 Customer

使用 ASP.NET Core Identity 在本地数据库中存储用户信息

如果 Azure AD B2C 对您来说不是一种选择,您可以使用 .NET 提供的 ASP.NET Core Identity 来在本地数据库中存储用户。我们将使用这种方式作为运行解决方案的替代方法,无需配置 Azure AD B2C。

使用 -au Individual 选项:

dotnet new blazor -au Individual -int Auto -o Codebreaker.ApiGateway.Identities

这创建了两个项目:Codebreaker.ApiGateway.IdentitiesCodebreaker.ApiGateway.Identities.Client。第二个项目是一个库,其中包含可以在客户端运行并具有 交互式 WebAssembly 渲染以及 交互式服务器渲染Razor 组件。这个库在第一个项目中引用,该项目托管 Blazor 应用程序并包含支持交互式服务器渲染的 Razor 组件。该项目包含一个用于注册用户以及帮助用户找回密码的组件列表,以及用于管理用户信息的组件。

让我们探讨这个应用程序的一些重要部分,从数据库开始。

自定义 EF Core 配置

在这个项目中,用户信息通过 EF Core 存储在关系型数据库中。默认情况下,使用 MySQL。这可以轻松地更改为 SQL Server,但在这个场景中使用 MySQL 也是很好的。

存储的用户信息由 ApplicationDbContext 类定义:

Codebreaker.ApiGateway.Identities/Data/ApplicationDbContext.cs

public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) :
  IdentityDbContext<ApplicationUser>(options)
{
}

ApplicationDbContext 是一个具有基类层次结构的 EF Core 上下文。由于是从模板创建的,因此这个类的主体为空。添加自定义 DbSet 属性允许您向数据库添加额外的表。基类 IdentityDbContext 使用 ApplicationUser 类作为泛型参数来定义要存储的用户信息:

Codebreaker.ApiGateway.Identities/Data/ApplicationUser.cs

public class ApplicationUser : IdentityUser
{
}

向此类添加属性允许您使用额外的列自定义users表。要查看定义的属性,需要从IdentityUser基类开始跟踪,IdentityUser继承自IdentityUser<string>。泛型字符串参数指定了使用 GUID 值作为键。泛型IdentiyUser类型定义了UserNameEmailPasswordHashPhoneNumber等属性,以映射到列。

IdentityDbContext<TUser>有一些其他基类,例如IdentityUserContext<TUser, TRole, TKey, TUserClaim, TuserLogin, 和 TUserToken>,用于定义使用的一些表。

EF Core 上下文需要与 DI 容器进行配置:

Codebreaker.ApiGateway.Identities/Program.cs

// code removed for brevity
builder.AddMySqlDbContext<ApplicationDbContext>("usersdb");

在这里,EF Core 配置已更改,以使用Aspire.Pomelo.EntityFrameworkCore.MySql NuGet 包和 MySQL Entity Framework .NET Aspire 组件。

这样,EF Core 上下文已经与 ASP.NET Core 身份验证进行了配置。

配置 ASP.NET Core 身份验证

当配置 ASP.NET Core 身份验证时,EF Core 必须进行映射:

Codebreaker.ApiGateway.Identities/Program.cs

// code removed for brevity
builder.Services.AddIdentityCore<ApplicationUser>(options =>
  options.SignIn.RequireConfirmedAccount = true)
  .AddEntityFrameworkStores<ApplicationDbContext>()
  .AddSignInManager()
  .AddDefaultTokenProviders();

AddIdentityCore方法配置了ApplicationUser类(与 EF Core 模型一起使用的相同类)以用于 ASP.NET Core 身份验证。当用户注册时,在使用账户之前,需要通过设置RequireConfirmedAccount属性(将在下文中讨论)进行确认。通过调用AddEntityFrameworkStores,EF Core 上下文ApplicationDbContext被映射到 ASP.NET Core 身份验证。AddSignInManager方法将SignInManager类注册到 DI 容器中。SignInManager可用于登录和注销用户,检索声明,并处理双因素认证选项。AddDefaultTokenProviders方法通过实现IUserTwoFactorTokenProvider接口来注册令牌提供者,以返回和验证用于双因素认证的令牌,例如电子邮件、电话等。

要确认账户,需要将IEmailSender接口注册到 DI 容器中:

Codebreaker.ApiGateway.Identities/Data/ApplicationUser.cs

builder.Services.AddSingleton<IEmailSender<ApplicationUser>, IdentityNoOpEmailSender>();

在默认配置下,实现了无操作的IdentityNoOpEmailSender类。这对于测试目的来说是实用的,但需要更改以验证用户的电子邮件地址。

现在,让我们使用.NET Aspire AppHost 项目来配置项目:

CodebreakerAppHost/Program.cs

string startupMode = Environment.GetEnvironmentVariable("STARTUP_MODE") ?? "Azure";
bool useAzureADB2C = startupMode == "Azure";
// code removed for brevity
if (startupMode == "OnPremises")
{
  var usersDbName = "usersdb";
  var mySqlPassword = builder.AddParameter("mysql-password", secret: true);
  var usersDb = builder.AddMySql("mysql", password: mySqlPassword)
    .WithEnvironment("MYSQL_DATABASE", usersDbName)
    .WithDataVolume()
    .WithPhpMyAdmin()
    .AddDatabase(usersDbName);
  var gateway = builder.AddProject<Projects.Codebreaker_ApiGateway_
    Identities>("gateway-identities")
    .WithReference(gameAPIs)
    .WithReference(bot)
    .WithReference(usersDb)
    .WithExternalHttpEndpoints();

在这里,AppHost 项目使用多个启动配置文件,要么以 Azure AD B2C(Azure 启动配置文件)启动解决方案,要么以本地数据库(OnPremises 启动配置文件)启动。当涉及到不同的启动配置文件设置时,会配置 STARTUP_MODE 环境变量,然后用于区分要启动的项目以及它们的配置。在启动 OnPremises 模式时,新创建的项目配置为通过 Aspire.Hosting.MySql NuGet 包引用在容器中运行的 MySQL 数据库。WithDataVolume 方法创建一个命名的 Docker 卷(见 第五章)以实现持久性,而 WithPhpMyAdmin 方法添加了管理界面。

如果我们现在运行解决方案,我们可以注册一个新用户,如图 图 9**.11 所示:

图 9.11 – 注册本地用户

图 9.11 – 注册本地用户

在注册用户时,你可能需要应用 EF Core 迁移来创建数据库。在收到注册确认后,选择点击此处确认您的账户以批准电子邮件。然后,点击左侧面板上的登录按钮。登录后,电子邮件将显示在认证 必需页面上。

注意

使用 ASP.NET Core Identity,除了让用户记住另一个密码外,还可以添加外部提供者,例如 Microsoft、Facebook 和 Google 账户,如learn.microsoft.com/en-us/aspnet/core/security/authentication/social所示。

启用 phpMyAdmin 后,您可以打开管理界面并查看已创建的表,如图 图 9**.12 所示:

图 9.12 – MySQL 管理界面

图 9.12 – MySQL 管理界面

使用此管理界面,您可以执行 SQL 查询,并轻松更改和删除记录。

在 ASP.NET Core Identity 就绪的情况下,用户现在可以使用此应用程序注册并管理他们的账户。如果用户数据不存储在托管云服务中并且可以轻松实现,这是一个很好的选择。那么使用桌面客户端应用程序呢?它们可以使用 API 来访问这些数据。我们将在下一节中学习如何添加此 API。

创建身份 API 端点

.NET 8 提供了使用 ASP.NET Core Identity 基础设施的标识 API 端点。

在 ASP.NET Core Identity 的 EF Core 配置就绪后,我们所需做的就是使用 DI 容器和中间件配置身份端点。首先必须配置 DI 容器:

Codebreaker.Gateway.Identity/Program.cs

// code removed for brevity
builder.Services
  .AddIdentityApiEndpoints<ApplicationUser>()
  .AddEntityFrameworkStores<ApplicationDbContext>();

AddIdentityApiEndpoints方法添加了使用 Bearer 令牌和身份 cookie 的认证,以及验证允许的密码和用户名的选项和验证器,注册UserManager,并提供用户声明的工厂。用于验证正确电子邮件的IEmailSender被配置为使用NoOpEmailSender。当您有IEmailSender的真实实现(使用您的电子邮件提供程序)时,您需要确保在调用AddIdentityApiEndpoints之后注册此类,以用您的配置覆盖NoOpEmailSenderAddEntityFrameworkStores方法是对返回的IdentityBuilder对象的扩展方法,并为用户和角色数据添加了 EF Core 存储。

可以使用MapIdentityApi方法配置中间件:

Codebreaker.Gateway.Identity/Program.cs

// code removed for brevity
app.MapGroup("/identity")
  .MapIdentityApi<ApplicationUser>();

MapGroup方法用于为身份 API 添加一个公共前缀。MapIdentityApi本身定义了几个 URI,例如/register,通过 POST 请求正文使用RegisterRequest注册新用户,以及/login,在传递LoginRequest时登录用户,LoginRequest可以包括用户名、密码、双因素代码、重置忘记密码的链接、电子邮件确认等。

一些这些 API 允许匿名访问(例如,在注册或登录时),而其他 API 则需要认证。带有/manage链接的 API 组被配置为需要认证。

当启用 Swagger 时,您将看到所有这些 API,如图图 9.13所示。这意味着您可以在从客户端应用程序使用之前测试它们:

图 9.13 – 身份 API 端点

图 9.13 – 身份 API 端点

尝试调用/register API 并传递两个值来创建新用户。如果没有创建自定义的IEmailSender接口实现,成功时将返回 HTTP 状态码200,并且 HTTP 正文为空。如果情况如此,您可以在登录之前使用 MySQL 管理 UI 批准用户(或更改 ASP.NET Core Identity 配置,使其不需要已确认的账户);否则,登录将被拒绝。

登录成功后,会返回 Bearer 令牌。您将收到访问令牌和刷新令牌以及默认设置为 3,600 秒的过期信息。刷新令牌可以与/refresh API 一起使用,以获取新的访问令牌和刷新令牌。

注意

要了解如何使用 SendGrid 实现IEmailSender,请参阅以下文章:learn.microsoft.com/en-us/aspnet/core/security/authentication/accconfirm

摘要

在本章中,您学习了如何使用 Microsoft Identities 和 ASP.NET Core Identity 通过 Azure AD B2C 进行用户认证。使用 Azure AD B2C,您添加了自定义用户属性,指定了用户流程,并注册了应用程序。

而不是为每个 API 实现保护,您使用 Microsoft YARP 创建了一个反向代理,并通过网关服务保护了 API。使用 YARP,我们定义了路由以映射不同的后端服务,并使用路由配置了策略,要求认证客户端。

您还学习了如何使用 ASP.NET Core Identity 作为内置 ASP.NET Core 功能的替代选项进行身份验证和授权,但功能更简单。

下一章将介绍如何测试微服务解决方案,从单元测试到集成测试,包括使用 Microsoft Playwright 测试服务。

进一步阅读

要了解更多关于本章讨论的主题,请参阅以下链接:

第三部分:故障排除和扩展

在本部分中,重点转向确保应用程序的平稳运行和及时解决任何新出现的问题。强调通过单元测试进行早期问题检测。您将深入了解使用 .NET Aspire 库创建集成测试,以及使用 Microsoft Playwright 实现端到端测试。Open Telemetry 促进的日志、指标和分布式跟踪的重要性将被探讨。.NET Aspire 仪表板将有助于在开发过程中监控服务交互、性能指标、内存消耗等。在 Azure 环境中,将利用 Azure Log Analytics 和 Application Insights,同时还可以使用 PrometheusGrafana 等替代选项,这些选项可以在本地和云环境中部署。在扩展服务时,将利用前几章中获得的经验,并建议在使用 Azure Load Testing 时谨慎行事,以防止超出预算限制。在扩展之前,将识别并实施潜在的性能提升。

本部分包含以下章节:

  • 第十章关于解决方案测试的所有内容

  • 第十一章日志和监控

  • 第十二章扩展服务

第十章:关于测试解决方案的所有内容

当创建微服务并使用持续集成和持续部署CI/CD)时,尽早发现错误是一个重要的部分。在生产环境中出现错误是昂贵的,最好尽早发现它们。测试通过尽早发现错误来帮助降低成本。

本章涵盖了与微服务解决方案一起需要的不同类型的测试。我们开始创建单元测试,这些测试应该是主要使用的测试,因为问题可以快速发现,接着是集成测试,其中解决方案的多个组件在协作中测试。集成测试可以在进程内进行,其中模拟 HTTP 请求,也可以在服务在系统上运行的环境中执行,这允许您在负载下测试环境。

在本章中,您将学习以下内容:

  • 创建单元测试

  • 创建.NET Aspire 集成测试

  • 创建端到端.NET Playwright 测试

技术要求

与前几章一样,您需要一个 Azure 订阅和 Docker Desktop。

本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/

ch10/final文件夹中,您将看到本章的最终结果。

这些项目与之前章节相同,但特别关注测试:

  • Codebreaker.AppHost – .NET Aspire 宿主项目

  • Codebreaker.ServiceDefaults – 通用服务配置

  • Codebreaker.Bot – 运行游戏的机器人服务

这些项目与之前章节相同,但特别关注测试:

  • Codebreaker.Analyzers – 这是包含验证游戏移动并返回结果的分析器的项目

  • Codebreaker.GameApis – 游戏 API 服务项目

这些项目是新的:

  • Codebreaker.Analyzers.Tests – 分析器库的单元测试

  • Codebreaker.Bot.Tests – 机器人服务库的单元测试

  • Codebreaker.GameAPIs.Tests – 游戏服务项目的单元测试

  • Codebreaker.GameAPIs.IntegrationTests – 内存集成测试

  • Codebreaker.GameAPIs.Playwright – 使用 Microsoft Playwright 的测试

通过本章学习代码,您可以开始使用start文件夹,其中包含与测试项目相同的项目。

要轻松地将解决方案部署到 Microsoft Azure,请查看本章源代码仓库中的 README 文件。

创建单元测试

单元测试是测试可测试软件的小部分。这个功能是否按预期工作?这些测试应该快速,直接在开发系统上使用(并且与 CI 一起运行)。使用Visual Studio Live Unit Testing功能(Visual Studio Enterprise 的一部分),单元测试在代码更新时运行,甚至在保存源代码之前。

在软件开发生命周期(SDLC)中,错误成本会增长。当错误发现得晚(例如,在生产环境中)时,成本会呈指数增长。对于早期修复错误(例如,在编写代码时),Visual Studio 可以提供提示并显示错误;因为我们已经在编写代码,所以没有必要花时间去深入研究功能,因为我们已经在处理它了。对于使用其他测试类型(例如,集成或负载测试)查找错误,修复成本更高——但当然,比在生产环境中发现错误要便宜得多。

一个目标应该是降低成本,因此如果某些功能可以通过单元测试和其他测试类型进行验证,则优先选择单元测试。

在我们开始创建单元测试之前,需要单元测试的游戏服务的心脏是什么?它是分析库。游戏规则有一些复杂性,编写代码时容易出错。这也是可以进行一些重构以提高性能和减少内存需求的地方。重构后,应用程序应该以相同的方式运行。

注意

当我最初开发游戏分析库时,我事先创建了单元测试,并在开发算法的过程中增强了单元测试。使用测试驱动开发TDD),单元测试是在功能之前创建的。

在修复错误之前,我也创建了新的单元测试。错误为什么会发生?为什么它没有被测试覆盖?在许多不同的项目中,我看到一些错误被修复,但在稍后的版本中又出现了。如果有单元测试来验证功能,同样的问题就不会在新版本中再次出现。

接下来,让我们深入到Codebreaker代码中,它需要许多单元测试。

探索游戏分析库

让我们探索Codebreaker分析库中的GameGuessAnalyzer类:

Codebreaker.GameAPIs.Analyzers.Tests/Analyzers/GameGuessAnalyzer.cs

public abstract class GameGuessAnalyzer<TField, TResult> :
  IGameGuessAnalyzer<TResult>
  where TResult : struct
{
  protected readonly IGame _game;
  private readonly int _moveNumber;
  protected TField[] Guesses { get; private set; }
  protected GameGuessAnalyzer(IGame game, TField[] guesses, int moveNumber)
  {
    _game = game;
    Guesses = guesses;
    _moveNumber = moveNumber;
  }
  protected abstract TResult GetCoreResult();
  private void ValidateGuess()
  {
    // code removed for brevity
  }
  protected abstract void SetGameEndInformation(TResult result);
  public TResult GetResult()
  {
    ValidateGuess();
    TResult result = GetCoreResult();
    SetGameEndInformation(result);
    return result;
  }
}

GetResult方法是这个类的核心。使用GameGuessAnalyzer抽象基类的构造函数,游戏和猜测通过参数传递。GetResult方法使用游戏的代码并使用猜测来返回结果——正确位置的颜色数量和正确但位置错误的颜色数量。GetResult方法的实现只是调用四个方法。ValidateGuess方法分析猜测的正确性,如果猜测不正确则抛出异常。GetCoreResult方法是抽象的,需要由派生类实现。

GameGuessAnalyzer类派生的一个类是ColorGameGuessAnalyzer类。这个类被Game6x4Game8x5游戏类型(六种颜色和四种代码,八种颜色和五种代码)使用:

Codebreaker.Analyzers.Tests/Analyzers/ColorGameGuessAnalyzer.cs

public class ColorGameGuessAnalyzer(
  IGame game, ColorField[] guesses, int moveNumber) :
  GameGuessAnalyzer<ColorField, ColorResult>(game, guesses, moveNumber)
{
  protected override ValidateGuessValues()
  {
    // code removed for brevity
  }
  protected override ColorResult GetCoreResult()
  {
    // code removed for brevity
  }
}

这个类重写了ValidateGuessValuesGetCoreResult方法。ValidateGuessValues方法验证输入数据,如果数据无效则抛出异常。GetCoreResult方法实现了Codebreaker游戏的算法,检查猜测是否正确放置,以及猜测正确但放置错误的情况,并相应地返回结果。

让我们为这个库创建一个单元测试项目。

创建单元测试项目

使用.NET CLI,我们可以创建一个新的 xUnit 测试项目:

dotnet new xunit -o Codebreaker.Analyzers.Tests
cd Codebreaker.Analyzers.Tests
dotnet add reference ..\Codebreaker.Analyzers

此命令创建了一个包含对 xUnit NuGet 包的引用以及分析器项目引用的Codebreaker.Analyzers.Tests项目。

注意

我主要使用 xUnit 进行单元测试。使用MSTestNUnit还是xUnit是一个选择问题;你可以使用这些框架中的任何一个进行单元测试,并且所有这些框架都很好地集成在.NET 工具中。我自己在 xUnit 可用时,从 MSTest 切换到 xUnit,当时.NET Core 1.0 的早期测试版,而 MSTest 还没有准备好迎接新的.NET。而且,.NET 团队自己完成的多数单元测试都是使用 xUnit。

在创建第一个测试之前,需要进行一些准备工作。

模拟IGame接口

ColorGameGuessAnalyzer类的构造函数中,需要一个实现IGame接口的对象。单元测试应该只测试一小部分功能,而不测试由它们自己的单元测试覆盖的依赖项。在测试ColorGameGuessAnalyzer类时,我们不想在测试分析器时添加对Game类的依赖。ColorGameGuessAnalyzer类需要的是IGame接口。为了允许测试运行,由一个模拟类实现了IGame接口:

Codebreaker.GameAPIs.Analyzers.Tests/MockColorGame.cs

public class MockColorGame : IGame
{
  public Guid Id { get; init; }
  public int NumberCodes { get; init; }
  public int MaxMoves { get; init; }
  public DateTime? EndTime { get; set; }
  public bool IsVictory { get; set; }
  // code removed for brevity
}

MockColorGame类只是一个简单的数据持有者,用于实现IGame接口,因此我们不需要使用任何模拟库。在稍后完成的另一个单元测试实现中,我们将使用模拟库来模拟不应由单元测试测试的功能。

创建测试辅助方法

为了定义多个单元测试所需的通用功能,在ColorGame6x4AnalyzerTests测试类中创建了辅助方法:

Codebreaker.GameAPIs.Analyzers.Tests/Analyzers/ColorGame6x4AnalyzerTests.cs

private static MockColorGame CreateGame(string[] codes) => new()
{
  GameType = GameTypes.Game6x4,
  NumberCodes = 4,
  MaxMoves = 12,
  IsVictory = false,
  FieldValues = new Dictionary<string, IEnumerable<string>>()
  {
    [FieldCategories.Colors] = [.. TestData6x4.Colors6]
  },
  Codes = codes
};
private static ColorResult AnalyzeGame(
  string[] codes,
  string[] guesses,
  int moveNumber = 1)
{
  MockColorGame game = CreateGame(codes);
  ColorGameGuessAnalyzer analyzer = new(game, [.. guesses.
    ToPegs<ColorField>()], moveNumber);
  return analyzer.GetResult();
}

AnalyzeGame方法接收一个表示有效代码的字符串数组,一个表示猜测的字符串数组以及移动次数。这些信息用于创建模拟的游戏实例并调用分析器类的GetResult方法。分析结果以ColorResult类型返回。现在可以使用这个辅助方法轻松创建单元测试。

创建一个简单的单元测试

第一个单元测试是通过GetResult_Should_ReturnThreeWhite方法实现的:

Codebreaker.GameAPIs.Analyzers.Tests/ColorGame6x4AnalyzerTests.cs

[Fact]
public void GetResult_Should_ReturnThreeWhite()
{
  ColorResult expectedKeyPegs = new(0, 3);
  ColorResult? resultKeyPegs = AnalyzeGame(
    [Green, Yellow, Green, Black],
    [Yellow, Green, Black, Blue]
  );
  Assert.Equal(expectedKeyPegs, resultKeyPegs);
}

使用 xUnit,Fact属性声明一个方法为单元测试。一个单元测试由三个部分组成:expectedKeyPegs变量。调用AnalyzeGame方法是行为。将GreenYellowGreenBlack代码作为有效代码传递,将YellowGreenBlackBlue作为猜测。这个猜测中,没有颜色在正确的位置,但三种颜色在错误的位置,因此应该返回三个白色。如果这个结果是正确的,使用Assert.Equal方法进行验证。

将测试数据传递给单元测试

在这种情况下,定义一个用于不同测试数据以验证不同结果的方法是有用的:

Codebreaker.GameAPIs.Analyzers.Tests/ColorGame6x4AnalyzerTests.cs

[InlineData(1, 2, Red, Yellow, Red, Blue)]
[InlineData(2, 0, White, White, Blue, Red)]
[Theory]
public void GetResult_ShouldReturn_InlineDataResults(
  int expectedBlack, int expectedWhite,
  params string[] guessValues)
{
  string[] code = [Red, Green, Blue, Red];
  ColorResult expectedKeyPegs = new (expectedBlack, expectedWhite);
  ColorResult resultKeyPegs = AnalyzeGame(code, guessValues);
  Assert.Equal(expectedKeyPegs, resultKeyPegs);
}

使用 xUnit,使用Theory属性而不是Fact属性允许测试方法被多次调用,传递不同的测试数据。GetResult_ShouldReturn_InlineResults方法使用由InlineData属性指定的参数。每个InlineData属性都传递了定义方法的参数的参数值。在这里,一个实现覆盖了两个测试。这个特性允许通过仅添加新的InlineData属性来快速扩展测试用例。

与使用InlineDataAttribute类不同,可以创建一个实现IEnumerable<object[]>的类来提供测试数据:

Codebreaker.GameAPIs.Analyzers.Tests/ColorGame6x4AnalyzerTests.cs

public class TestData6x4 : IEnumerable<object[]>
{
  public static readonly string[] Colors6 = [Red, Green, Blue, Yellow,
    Black, White];
  public IEnumerator<object[]> GetEnumerator()
  {
    yield return new object[]
    {
      new string[] { Green, Blue,  Green, Yellow },
      new string[] { Green, Green, Black, White },
      new ColorResult(1, 1) // expected
    };
    yield return new object[]
    {
      new string[] { Red,   Blue,  Black, White },
      new string[] { Black, Black, Red,   Yellow },
      new ColorResult(0, 2)
    };
    // code removed for brevity – more test cases here
  }
  IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

object[]定义了一个方法调用所需的所有值。第一个参数定义了游戏的有效代码,第二个参数是猜测数据,第三个参数是预期结果。随着IEnumerable的每次迭代,都会进行一次新的测试运行。下一个代码片段展示了使用数据类实现的测试方法:

Codebreaker.GameAPIs.Analyzers.Tests/ColorGame6x4AnalyzerTests.cs

[Theory]
[ClassData(typeof(TestData6x4))]
public void GetResult_ShouldReturn_UsingClassdata(
  string[] code,
  string[] guess,
  ColorResult expectedKeyPegs)
{
  ColorResult actualKeyPegs = AnalyzeGame(code, guess);
  Assert.Equal(expectedKeyPegs, actualKeyPegs);
}

与使用InlineData属性不同,这里使用ClassData。使用返回测试数据的对象更灵活。InlineData属性需要由编译器存储的常量值。使用ClassData属性,数据也可以动态创建。

使用单元测试期望抛出异常

在下一个代码片段中,展示了另一个我们期望抛出异常的测试用例:

Codebreaker.GameAPIs.Analyzers.Tests/ColorGame6x4AnalyzerTests.cs

[Fact]
public void GetResult_Should_ThrowOnInvalidGuessValues()
{
  Assert.Throws<ArgumentException>(() =>
    AnalyzeGame(
      ["Black", "Black", "Black", "Black"],
      ["Black", "Der", "Blue", "Yellow"] // "Der" is wrong
  ));
}

Assert.Throws定义了在传递测试数据时实现应该抛出的异常类型。如果没有抛出异常,测试失败。

使用模拟库

对于一些需要测试的类,拥有一个模拟库是非常棒的。以下代码片段展示了注入IGamesRepository接口的GamesService类:

Codebreaker.GameAPIs/Services/GamesService.cs

public class GamesService(IGamesRepository dataRepository) : IGamesService
{
  public async Task<(Game Game, Move Move)> SetMoveAsync(
    Guid id, string gameType, string[] guesses,
    int moveNumber,
    CancellationToken cancellationToken = default)
  {
Game? game = await dataRepository.GetGameAsync(id, 
    cancellationToken);
    CodebreakerException.ThrowIfNull(game);
    CodebreakerException.ThrowIfEnded(game);
    CodebreakerException.ThrowIfUnexpectedGameType(game, gameType);
    Move move = game.ApplyMove(guesses, moveNumber);
    await dataRepository.AddMoveAsync(game, move, cancellationToken);
    return (game, move);
  }
  // code removed for brevity
}

使用GamesService类,通过构造函数注入将IGamesRepository接口注入。在测试SetMoveAsync方法时,IGamesRepository接口的实现不应包含在这个测试中。还有一个针对游戏存储库的测试。相反,使用这个类的模拟实现进行单元测试。SetMoveAsync方法调用IGamesRepository接口的GetGameAsync方法。这个方法的实际实现不应包含在测试中,但我们需要一些不同的结果,这些结果可以用后续使用的方法来使用。当这个方法返回null因为它没有找到游戏时,CodebreakerException.ThrowIfNull应该抛出异常。如果方法返回一个已经结束的游戏,下一个方法应该抛出异常,因为不能将新移动设置到已经结束的游戏中。如果传递的游戏类型与检索到的游戏类型不同,ThrowIfUnexpectedGameType方法应该抛出异常。这可以通过使用模拟库轻松解决。

让我们创建另一个名为Codebreaker.GameAPIs.Tests的 xUnit 测试项目来测试GamesService类。为了模拟IGamesRepository接口,添加moq NuGet 包。

下面的代码片段显示了单元测试中使用的游戏和游戏 ID 字段:

Codebreaker.GameAPIs.Tests/GamesServiceTests.cs

public class GamesServiceTests
{
  private readonly Mock<IGamesRepository> _gamesRepositoryMock = new();
  private readonly Guid _endedGameId = Guid.Parse("4786C27B-3F9A-4C47-9947-F983CF7053E6");
  private readonly Game _endedGame;
  private readonly Guid _running6x4GameId = Guid.Parse("4786C27B-3F9A-4C47-9947-F983CF7053E7");
  private readonly Game _running6x4Game;
  private readonly Guid _notFoundGameId = Guid.Parse("4786C27B-3F9A-4C47-9947-F983CF7053E8");
  private readonly Guid _running6x4MoveId1 = Guid.Parse("4786C27B-3F9A-4C47-9947-F983CF7053E9");
  private readonly string[] _guessesMove1 = ["Red", "Green", "Blue", "Yellow"];

通过使用泛型Mock类型创建一个新的实例来模拟IGamesRepository接口。之后,为在存储库中找不到的游戏(_notFoundGameId)、已经结束的游戏(_endedGame)和活跃的正在运行的游戏(_running6x4Game)预定义了游戏。

GamesServiceTests类的构造函数初始化游戏对象:

Codebreaker.GameAPIs.Tests/GamesServiceTests.cs

public GamesServiceTests()
{
  _endedGame = new(_endedGameId, "Game6x4", "Test", DateTime.Now, 4, 12)
  {
    Codes = ["Red", "Green", "Blue", "Yellow"],
    FieldValues = new Dictionary<string, IEnumerable<string>>()
    {
      { FieldCategories.Colors, ["Red", "Green", "Blue", "Yellow", 
"Purple", "Orange"] }
    },
    EndTime = DateTime.Now.AddMinutes(3)
  };
  // code removed for brevity
  _gamesRepositoryMock.Setup(repo => repo.GetGameAsync(_endedGameId, 
     CancellationToken.None)).ReturnsAsync(_endedGame);
  _gamesRepositoryMock.Setup(repo => repo.GetGameAsync
  (_running6x4GameId, CancellationToken.None)).ReturnsAsync
  (_running6x4Game);
  _gamesRepositoryMock.Setup(repo => repo.AddMoveAsync
  (_running6x4Game, It.IsAny<Move>(), CancellationToken.None));
}

使用构造函数,创建了不同游戏类型的实例。已经结束的游戏设置了EndTime属性。为了指定模拟实现的行怍,调用了Setup方法。通过这种方式,如果GetGameAsync方法接收到带有参数的已结束游戏 ID,它将返回已配置的已结束游戏实例。传递正在运行的游戏 ID,将返回相应的实例。在第三次调用Setup方法时,定义了当传递正在运行的游戏时,AddMoveAsync方法包含一个实现。It.IsAny<Move>允许我们使用任何Move实例调用此方法。

现在,我们可以实现单元测试。第一个单元测试是验证如果游戏已经结束,SetMoveAsync方法会抛出异常:

Codebreaker.GameAPIs.Tests/GamesServiceTests.cs

[Fact]
public async Task SetMoveAsync_Should_ThrowWithEndedGame()
{
  GamesService gamesService = new(_gamesRepositoryMock.Object);
  await Assert.ThrowsAsync<CodebreakerException>(async () =>
  {
await gamesService.SetMoveAsync(_endedGameId, "Game6x4", ["Red", 
    "Green", "Blue", "Yellow"], 1, CancellationToken.None);
  });
  _gamesRepositoryMock.Verify(repo => repo.GetGameAsync(_endedGameId, 
CancellationToken.None), Times.Once);
}

安排步骤中,使用IGamesRepository实现的模拟对象实例化了GamesService类。在单元测试操作中——如之前已使用——使用Assert.ThrowAsync来检查在调用带有已结束游戏的指定参数的SetMoveAsync方法时是否抛出了异常。这里进行的另一个检查是使用Mock类上的Verify方法来检查该方法是否恰好被调用了一次。

SetMoveAsync_Should_ThrowWithUnexpcectedGameTypeSetMoveAsync_Should_ThorwWithNotFoundGameType单元测试方法非常相似,因此在此未列出。请检查源代码仓库以获取详细信息。

这里展示了测试正常流程的测试方法:

[Fact]
public async Task SetMoveAsync_Should_UpdateGameAndAddMove()
{
  GamesService gamesService = new(_gamesRepositoryMock.Object);
  var result = await gamesService.SetMoveAsync(_running6x4GameId, 
"Game6x4", ["Red", "Green", "Blue", "Yellow"], 1, 
CancellationToken.None);
  Assert.Equal(_running6x4Game, result.Game);
  Assert.Single(result.Game.Moves);
  _gamesRepositoryMock.Verify(repo => repo.GetGameAsync
  (_running6x4GameId, CancellationToken.None), Times.Once);
  _gamesRepositoryMock.Verify(repo => repo.AddMoveAsync
  (_running6x4Game, It.IsAny<Move>(), CancellationToken.None), Times.
  Once);
}

SetMoveAsync_Should_UpdateGameAndAddMove方法验证了GetGameAsyncAddMoveAsync方法各被调用了一次,并且游戏中的第一个移动使得Moves属性恰好包含一个值。

运行单元测试

要启动单元测试,你可以使用dotnet test .NET CLI 命令运行所有测试。使用 Visual Studio,测试菜单可用于运行所有测试。使用测试资源管理器,如图图 10.1所示,你可以按测试、测试组或所有测试启动测试,查看每个测试的结果,调试测试,运行测试直到它们失败,定义测试播放列表等:

图 10.1 – Visual Studio 测试资源管理器

图 10.1 – Visual Studio 测试资源管理器

当使用 Visual Studio 2022 企业版时,你可以启动实时单元测试。使用实时单元测试,单元测试会在你更改源代码时运行。在这里,你还可以监控哪些代码行被单元测试覆盖,哪些行被遗漏。图 10.2显示了实时单元测试已开启的 Visual Studio 代码编辑器,以及所有单元测试中遗漏的代码行53

图 10.2 – Visual Studio 实时单元测试

图 10.2 – Visual Studio 实时单元测试

在完成一些单元测试后,让我们转向其他测试类型。

创建.NET Aspire 集成测试

虽然单元测试应该是主要的测试,但集成测试不仅测试小的功能,还包括在一个测试中测试多个组件,例如包括基础设施——例如,数据库。

.NET Aspire 包含一个库和测试模板,使用 xUnit,这可以轻松地创建集成测试以直接访问应用程序模型。

让我们使用.NET Aspire 和 xUnit 创建一个名为Codebreaker.IntegrationTests的.NET Aspire 测试项目:

dotnet new aspire-xunit -o Codebreaker.IntegrationTests

此项目包含对Aspire.Hosting.Testing NuGet 包的引用,以及Microsoft.NET.Test.Sdk、xUnit 和xunit.runner.visualstudio。为了允许访问应用程序模型,添加对Codebreaker.AppHost的项目引用。由于我们实现的集成测试需要来自游戏 API 项目中的类型,因此我们还添加了对Codebreaker.GameAPIs的引用。

创建异步初始化

在进行游戏 API 的所有集成测试时,我们需要一个HttpClient实例。xUnit 允许通过实现IAsyncLifetime接口进行异步初始化:

Codebreaker.IntegrationTests/GameAPIsTests.cs

public class GameAPIsTests : IAsyncLifetime
{
  private DistributedApplication? _app;
  private HttpClient? _client;
  public async Task InitializeAsync()
  {
    // code removed for brevity
  }
  public async Task DisposeAsync()
  {
    if (_app is null) throw new InvalidOperationException();
    await _app.DisposeAsync();
  }
  // code removed for brevity

将从IAsyncLifetime接口创建的类重命名定义了InitializeAsyncDisposeAsync方法。将在InitializeAsync方法中初始化的字段成员是DistributedApplicationHttpClient类。您已经从AppHost项目中的应用程序模型中了解了DistributedApplication类。您将看到如何在InitializeAsync方法中使用它。

虽然我们没有在AppHost项目中释放DistributedApplication实例(因为应用程序的生命周期内只有一个实例在运行,资源在应用程序结束时释放),但在单元测试中释放它很重要,因为它初始化了提供者和文件监视器。随着测试数量的增加,INotify实例的用户限制和打开文件描述符的进程限制可能会达到极限——因此,不要忘记在测试项目中释放这个资源。

让我们看看如何创建DistributedApplicationHttpClient类:

Codebreaker.IntegrationTests/GameAPIsTests.cs

public async Task InitializeAsync()
{
  var appHost = await DistributedApplicationTestingBuilder.
    CreateAsync<Projects.Codebreaker_AppHost>();
  _app = await appHost.BuildAsync();
  await _app.StartAsync();
_client = _app.CreateHttpClient("gameapis");
}

使用DistributedApplicationTestingBuilder(在Aspire.Hosting.Testing命名空间中定义),调用CreateAsync方法,返回一个新的DistributedApplicationTestingBuilder实例。泛型参数引用了CodebreakerAppHost项目。类似于在使用AppHost项目中引用的项目时使用泛型参数,这里使用了相同的机制,引用了AppHost项目本身。调用BuildAsync方法返回一个DistributedApplication实例,我们可能会忘记释放它。使用这个实例,我们可以访问应用程序模型定义。在由Codebreaker.AppHost项目指定的应用程序模型中,我们定义了gameapis,这是Codebreaker.GameAPIs项目的名称。CreateHttpClient返回一个HttpClient对象来引用这个服务。返回的HttpClientDistributedApplication对象都分配给了字段成员。现在,我们已准备好创建测试。

创建一个测试来验证 HTTP 错误请求状态

在第一个测试中,让我们验证当发送无效的移动编号时是否返回正确的状态码。首先,我们需要开始一个新的游戏:

Codebreaker.IntegrationTests/GameAPIsTests.cs

[Fact]
public async Task SetMove_Should_ReturnBadRequest_WithInvalidMoveNumber()
{
  if (_client is null) throw new InvalidOperationException();
  CreateGameRequest request = new(GameType.Game6x4, "test");
  var response = await _client.PostAsJsonAsync("/games", request);
  var gameResponse = await response.Content.
    ReadFromJsonAsync<CreateGameResponse>();
  Assert.NotNull(gameResponse);
  // code removed for brevity

开始游戏时,我们已经使用了HttpClient实例并调用了 HTTP POST请求,传递了CreateGameRequest对象。CreateGameRequest在测试项目中可用,因为我们创建测试项目时添加了对Codebreaker.GameAPIs项目的项目引用。

通过设置游戏移动来继续实现此方法:

Codebreaker.IntegrationTests/GameAPIsTests.cs

[Fact]
public async Task SetMove_Should_ReturnBadRequest_WithInvalidMoveNumber()
{
  // code removed for brevity
  int moveNumber = 0;
UpdateGameRequest updateGameRequest = new(gameResponse.Id, 
  gameResponse.GameType, gameResponse.PlayerName, moveNumber)
  {
    GuessPegs = ["Red", "Red", "Red", "Red"]
  };
  string uri = $"/games/{updateGameRequest.Id}";
var updateGameResponse = await _client.PatchAsJsonAsync(uri, 
    updateGameRequest);
  Assert.Equal(HttpStatusCode.BadRequest, updateGameResponse.
    StatusCode);
}

我们再次使用HttpClient - 这次是发送PATCH请求。传递moveNumber时带有0值指定了一个错误的移动。第一个正确的移动从1开始。这样,我们期望收到一个BadRequest结果,这通过Assert.Equal进行验证。

让我们再创建一个测试来玩完整游戏。

创建一个测试来玩完整游戏

以下代码片段显示了设置多个移动的集成测试设置:

Codebreaker.IntegrationTests/GameAPIsTests.cs

[Fact]
public async Task SetMoves_Should_WinAGame()
{
  // code removed for brevity
  int moveNumber = 1;
  UpdateGameRequest updateGameRequest = new(gameResponse.Id, 
gameResponse.GameType, gameResponse.PlayerName, moveNumber)
  {
    GuessPegs = ["Red", "Red", "Red", "Red"]
  };
  string uri = $"/games/{updateGameRequest.Id}";
  response = await _client.PatchAsJsonAsync(uri, updateGameRequest);
  var updateGameResponse = await response.Content.
ReadFromJsonAsync<UpdateGameResponse>();
  Assert.NotNull(updateGameResponse);
  // code removed for brevity

开始游戏与之前相同,因此这里不显示代码。发送第一步稍有不同,我们发送正确的移动编号。从那里,我们继续发送GET请求:

Codebreaker.IntegrationTests/GameAPIsTests.cs

  // code remove for brevity
  if (!updateGameResponse.IsVictory)
  {
    Game? game = await _client.GetFromJsonAsync<Game?>(uri);
    Assert.NotNull(game);
    moveNumber = 2;
updateGameRequest = new UpdateGameRequest(gameResponse.Id, 
      gameResponse.GameType, gameResponse.PlayerName, moveNumber)
    {
      GuessPegs = game.Codes
    };
    response = await _client.PatchAsJsonAsync(uri, updateGameRequest);

在发送GET请求之前,我们检查游戏是否在第一步中获胜。这应该在大约 1,296 次调用中发生一次;因此,在运行测试时经常会发生。我们不希望游戏在第一步中获胜时测试失败。如果游戏尚未获胜,则发送GET请求以找出正确的值,然后使用这些正确的值来做出第二步。

发送正确的移动,我们应该得到一个成功的结果:

Codebreaker.IntegrationTests/GameAPIsTests.cs

    // code removed for brevity
    Assert.True(response.IsSuccessStatusCode);
    updateGameResponse = await response.Content.
ReadFromJsonAsync<UpdateGameResponse>();
    Assert.NotNull(updateGameResponse);
    Assert.True(updateGameResponse.Ended);
    Assert.True(updateGameResponse.IsVictory);
  }
  // delete the game
  response = await _client.DeleteAsync(uri);
  Assert.True(response.IsSuccessStatusCode);
}

发送第二步后,结果得到验证。最后,游戏被删除。在这些调用之间,结果得到验证。

可以使用dotnet test .NET CLI 命令或与 Visual Studio Test Explorer 一起运行所有集成测试,就像之前单元测试一样。只需记住不要使用集成测试与 Live Unit Testing 一起使用。

如第八章所述,使用 CI,所有这些测试都应该运行。这可以通过使用dotnet test简单地完成。

使用.NET Aspire 测试进行集成测试的优点是服务器不需要启动。然而,创建负载测试、在切换到生产环境之前测试解决方案以及直接发送 HTTP 请求也应从测试环境中进行。我们将在下一节中这样做。

创建端到端.NET Playwright 测试

Microsoft Playwright (playwright.dev) 提供了 Microsoft 的用于 Web 测试的工具和库,包括对 Web API 的测试。

Playwright 提供了一些工具(包括通过网页记录动作生成测试、检查网页、生成选择器和查看跟踪),支持跨不同平台进行测试,以及为 TypeScript、JavaScript、Python、.NET 和 Java 提供测试库。通过 UI 自动化,Playwright 可以替代手动测试人员!在这里,我们将使用 Playwright 来测试 API - 使用.NET!

使用 Playwright 创建测试项目

让我们使用 Playwright 开始创建一个测试项目。因为 xUnit 专注于单元测试,并且存在限制并发测试运行的问题,所以 Playwright 支持 NUnit 和 MSTest。在这里,我们使用 NUnit:

dotnet new nunit -o Codebreaker.GameAPIs.Playwright
cd Codebreaker.GameAPIs.Playwright
dotnet add package Microsoft.Playwright.NUnit
dotnet build

使用dotnet new创建一个新的.NET 项目,这次使用 NUnit 作为测试框架。Microsoft.Playwright.NUnit是 NUnit 的 Playwright 包。在dotnet build之后,在bin/debug/net8.0文件夹中创建了一个playwright.ps1 PowerShell 脚本文件,用于安装所需的浏览器:

pwsh bin/debug/net8.0/playwright.ps1 install

创建上下文

Playwright 有自己的 API 用于创建 HTTP 请求。这需要初始化,以及一些维护工作:

Codebreaker.GameAPIs.Playwright/GamesAPITests.cs

[assembly: Category("SkipWhenLiveUnitTesting")]
namespace Codebreaker.APIs.PlaywrightTests;
[Parallelizable(ParallelScope.Self)]
public class GamesApiTests : PlaywrightTest
{
  private IAPIRequestContext? _requestContext;

因为这个测试类不应参与实时单元测试,所以使用Category程序集属性来标记整个程序集,并使用SkipWhenLiveUnitTesting。与使用AssemblyTrait属性的不同,NUnit 使用Category属性。

使用 Playwright 时,测试类需要从PlaywrightTest基类派生。类型为IAPIRequestContext的字段是 Playwright 创建 HTTP 请求的 API。这个字段使用下一个源代码片段进行初始化:

Codebreaker.GameAPIs.Playwright/GamesAPITests.cs

[SetUp]
public async Task SetupApiTesting()
{
  ConfigurationBuilder configurationBuilder = new();
  configurationBuilder.SetBasePath(
    Directory.GetCurrentDirectory());
  configurationBuilder.AddJsonFile("appsettings.json");
  var config = configurationBuilder.Build();
  if (!int.TryParse(config["ThinkTimeMS"], out _thinkTimeMS))
  {
    _thinkTimeMS = 1000;
  }
  Dictionary<string, string> headers = new()
  {
    { "Accept", "application/json" }
  };
  _requestContext = await Playwright.APIRequest.NewContextAsync(new()
  {
    BaseURL = config["BaseUrl"] ?? "http://localhost",
    ExtraHTTPHeaders = headers
  });
}

在每个测试之前都会调用SetupAPITesting方法。在 NUnit 中,这样的初始化方法需要使用Setup属性进行注释。为了初始化IAPIRequestContext,调用Playwright.APIRequest.NewContextAsync方法。在这里,指定了服务的基础地址和 HTTP 头。为了允许使用appsettings.json文件进行配置,使用ConfigurationBuilder类。为了模拟思考时间,从配置中检索_thinkTimeMS,然后在设置每个游戏移动之前使用它。

随着 API 上下文的创建,它也需要被销毁:

Codebreaker.GameAPIs.Playwright/GamesAPITests.cs

[TearDown]
public async Task TearDownAPITesting()
{
  if (_requestContext != null)
  {
    await _requestContext.DisposeAsync();
  }
}

在测试运行之后调用的方法被注释为TearDown属性。使用完毕后,需要销毁上下文。

准备完成后,让我们创建我们的测试。

使用 Playwright 玩游戏

使用 NUnit 创建的测试用例被Test属性注释:

Codebreaker.GameAPIs.Playwright/GamesAPITests.cs

[Test]
[Repeat(20)]
public async Task PlayTheGameToWinAsync()
{
  // code removed for brevity
  string playerName = "test";
  (Guid id, string[] colors) = await CreateGameAsync(playerName);
  int moveNumber = 1;
  bool gameEnded = false;
  while (moveNumber < 10 && !gameEnded)
  {
    await Task.Delay(_thinkTimeMS);
    string[] guesses = [.. Random.Shared.GetItems<string>(colors, 4)];
    gameEnded = await SetMoveAsync(id, playerName, moveNumber++, 
    guesses);
  }
  if (!gameEnded)
  {
    await Task.Delay(_thinkTimeMS);
    string[] correctCodes = await GetGameAsync(id, moveNumber – 1);
    gameEnded = await SetMoveAsync(id, playerName, moveNumber++, 
      correctCodes);
  }
  Assert.That(gameEnded, Is.True);
}

NUnit 使用Test属性来指定一个测试。可以使用Repeat属性来指定测试应该重复运行的次数。这个属性在生成更长的服务器负载时很有用。PlayTheGameToWin方法使用 API 定义流程。首先,通过调用CreateGameAsync方法创建一个新的游戏。之后,最多进行 10 次移动,使用SetMoveAsync方法放置移动。如果——使用随机选择的猜测——游戏已经结束,我们就完成了。否则,使用GetGameAsync检索游戏信息,然后再次调用SetMoveAsync——这次使用正确的移动。

下一个代码片段显示了其中的一种调用方式。对于其他调用方式,请检查源代码仓库:

Codebreaker.GameAPIs.Playwright/GamesAPITests.cs

private async Task<bool> SetMoveAsync(Guid id, string playerName, int moveNumber, string[] guesses)
{
  Dictionary<string, object> request = new()
  {
    ["id"] = id.ToString(),
    ["gameType"] = "Game6x4",
    ["playerName"] = playerName,
    ["moveNumber"] = moveNumber,
    ["guessPegs"] = guesses
  };
var response = await _requestContext.PatchAsync($"/games/{id}", new()
{
    DataObject = request
  });
  Assert.That(response.Ok, Is.True);
  var json = await response.JsonAsync();
  JsonElement results = json.Value.GetProperty("results");
  Assert.Multiple(() =>
  {
Assert.That(results.EnumerateArray().Count(), 
      Is.LessThanOrEqualTo(4));
Assert.That(results.EnumerateArray().All(x => x.ToString() is 
      "Black" or "White"));
  });
  bool hasEnded = bool.Parse(json.Value.GetProperty("ended").
ToString());
  return hasEnded;
}

SetMoveAsync 方法通过使用 IAPIRequestContext 接口的 PatchAsync 方法来设置移动。根据使用的 HTTP 动词,GetAsyncPostAsync… 方法都是可用的。发送到服务的 HTTP 主体由 DataObject 属性指定。PatchAsync 方法返回一个 IAPIResponse 响应。使用此响应,可以使用 JsonAsync 方法检索 JSON 数据。与 Assert 验证一起使用的 Ok 属性在 200 到 299 的状态码范围内返回 true

在此测试到位的情况下,我们可以使用 dotnet test 或在 Visual Studio 中的 Test Explorer 中运行测试。但这次,服务需要正在运行!

创建测试负载

Playwright 测试现在可以用来模拟用户负载,以并发运行多个用户。为此,只需要计算资源来运行所需的负载。通过减少延迟时间,可以使用少量“虚拟用户”来模拟更多真实用户的负载。需要分析真实用户在移动之间的思考时间,这需要监控生产环境中的解决方案。

注意

减少移动之间的延迟,你可以使用更少的计算资源来仅用少量虚拟用户模拟大量真实用户的负载。也有很好的理由增加真实用户所用的时间的延迟时间。在 第十二章 中,我们将通过缓存来增强解决方案。如果用户在移动之间有长时间的延迟,缓存的游戏不可用,应用程序是否仍然表现正确?你还应该运行此类集成测试。

使用 Microsoft Playwright 测试 云服务,计算资源可用于测试 Web 应用程序。在撰写本文时,此服务不可用于测试 REST API。另一个运行负载测试的服务是 Azure Load Testing。使用此工具,你可以编写 JMeter 脚本来运行测试或从 Web 门户指定 Web 请求。在 第十二章 中,我们将使用此服务创建负载测试来增加游戏 API 的副本数量。此工具不仅运行负载,还提供了一个出色的报告,显示了与请求交互的所有资源的信息。

注意

这里涵盖的所有测试也可以与 GitHub Actions 一起使用。在构建 .NET 库和应用程序之后,应触发 dotnet test 以启动所有单元测试。在将服务部署到测试环境之后,在解决方案部署到下一个环境之前——例如,预发布环境——应运行集成测试。自动负载测试应确保解决方案在负载下运行。

持续地——对于基于时间触发的流程——你应该检查依赖项中是否发现新的安全漏洞,并且这些依赖项应该得到更新。为此,使用 GitHub,只需配置 Dependabot 即可。

摘要

在本章中,您学习了如何创建单元测试来测试简单的功能。这些测试可以与实时单元测试一起使用,在开发期间立即显示测试错误。通过单元测试,您学习了如何使用模拟库来替换单元测试范围之外的函数,并由不同的单元测试覆盖。

您学习了如何使用Aspire.Hosting.Testing使集成测试变得简单。无需启动服务,因为HttpClient的处理程序被替换为向服务进程发送请求。

使用 Microsoft Playwright,您创建了一个集成测试,该测试向 API 发送 HTTP 请求,并可用于在负载下测试解决方案。

在本章中,您监控了指标数据,下一章将在此基础上扩展,以便您可以创建自己的指标计数,并将日志记录和分布式跟踪添加到微服务解决方案中。

进一步阅读

要了解更多关于本章讨论的主题,您可以参考以下链接:

第十一章:日志记录和监控

在微服务解决方案中,许多服务可以相互交互。当一个服务失败时,整个解决方案不应该崩溃。在前一章中,我们介绍了不同类型的测试以尽早发现问题。在这里,我们将探讨尽可能早地在生产中查找问题——可能是在用户看到问题之前。

为了在应用程序运行时发现问题和查看应用程序如何成功运行,解决方案需要增强以提供遥测数据。通过日志记录,我们可以看到正在发生什么;根据不同的日志级别,我们可以区分信息日志和错误。通过指标数据,我们可以监控计数器,例如内存和 CPU 消耗,以及 HTTP 请求的数量。我们还将编写自定义计数器以查看游戏玩了多少次以及需要多少次游戏移动才能获胜。分布式跟踪提供了关于服务如何交互的信息。谁在调用此服务?这个错误是从哪里开始的?

OpenTelemetry是一个行业标准 – 一组 API,允许不同的语言和工具进行工具化、生成、收集和导出遥测数据。支持 OpenTelemetry 的.NET API 用于日志记录、指标和分布式跟踪,这正是本章的内容。我们将使用PrometheusGrafana,它们为本地解决方案提供了出色的图形视图,以及Azure Application Insights,以便解决方案可以在 Microsoft Azure 云中运行。

在本章中,您将学习以下内容:

  • 添加日志消息

  • 使用和创建指标数据

  • 使用分布式跟踪

  • 使用 Azure Application Insights 进行监控

  • 使用 Prometheus 和 Grafana 进行监控

技术要求

与前几章一样,您需要一个 Azure 订阅和 Docker Desktop。要创建解决方案的所有 Azure 资源,您可以使用 Azure 开发者 CLI – azd up创建所有资源。请查看存储库中本章的 README 文件以获取详细信息。

本章的代码可以在以下 GitHub 存储库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/

ch11文件夹中,您将看到本章的项目结果。本章在AppHost项目的launchsettings.json文件中添加了 Prometheus 启动配置文件。这也将ASPNETCORE_ENVIRONMENTDOTNETCORE_ENVIRONMENT环境变量设置为Prometheus。默认启动配置文件使用运行在 Microsoft Azure 上的服务。Prometheus 启动配置文件用于运行 Prometheus 和 Grafana,这些工具可以在本地环境中轻松使用。

这些是本章的重要项目:

  • Codebreaker.AppHost – .NET Aspire 宿主项目。

  • Codebreaker.ServiceDefaults – 通用服务配置。该项目增加了用于监控的服务配置。

  • Codebreaker.GamesAPI – 服务项目通过日志记录、指标和分布式跟踪得到增强.* Codebreaker.Bot – 此项目包含监控信息,并将用于玩可监控的游戏.* grafana文件夹包含在 Grafana Docker 容器内使用的配置文件.* prometheus文件夹包含由 Prometheus Docker 容器使用的配置文件。

你可以从上一章的源代码开始,以集成本章的功能。

添加日志消息

为了在运行解决方案时成功或失败地查看正在发生的事情,我们添加日志消息。理解日志概念的重要部分如下:

  • :谁写入日志信息——类别名称是什么?

  • 日志提供程序:日志信息被写入到哪里?

  • 日志级别:日志消息的级别是什么?仅仅是信息还是错误?

  • 过滤:记录了哪些信息?

源是通过使用ILogger<T>泛型接口定义的。使用这个泛型接口,类别名称是从泛型参数类型的类名中获取的。如果你使用ILoggerFactory接口而不是ILogger<T>,类别名称是通过调用CreateLogger方法传递的。.NET 使用的类别名称示例包括Microsoft.EntityFrameworkCore.Database.CommandSystem.Net.Http.HttpClientMicrosoft.Hosting.Lifetime。具有层次结构的名称有助于配置常见的设置。

为了定义日志消息的写入位置,日志提供程序在应用程序启动时进行配置。WebApplication类的CreateBuilder方法配置了多个日志提供程序:

  • ConsoleLogProvider用于将日志消息写入控制台

  • DebugLoggerProvider,仅在附加调试器时将消息写入调试输出窗口

  • EventSourceLoggerProvider,该提供程序在 Windows 上使用Windows 事件跟踪ETW)和在 Linux 上使用Linux 跟踪工具包:下一代LTTng)来写入日志消息

第五章中创建的 AOT ASP.NET Core 应用程序中,使用了CreateSlimBuilder方法。CreateSlimBuilder仅配置控制台提供程序;其他提供程序需要手动添加。

ILogger接口定义了一个带有LogLevel枚举值的Log方法,包含Trace(0)- Debug - Information - Warning - Error - Critical(5)- None(6)值。通过这个,我们可以配置只写入Warning级别或更高级别的消息,或者写入指定Trace级别或更高级别的每条消息。此配置可能因提供程序和源而异。

下面的代码片段显示了使用 JSON 配置文件的定制配置:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "Microsoft.EntityFrameworkCore": "Warning",
      "Codebreaker": "Trace"
    },
    "EventSource": {
      "LogLevel": {
        "Default": "Warning"
      }
    }
  }
}

在启动时,在 CreateBuilder 方法的实现中访问带有配置的 Logging 部分。在这里,我们可以自定义日志配置。使用此配置文件,默认日志级别通过 LogLevel:Default 键指定。在这里,日志设置为 Information,因此 DebugTrace 日志消息不会被写入。此默认配置通过以 Microsoft.AspNetCore 开头的日志类别进行更改。使用此类别,只写入警告、错误和关键消息。作为 Logging 的子键的 LogLevel 键配置了所有日志提供程序,除非覆盖了提供程序的配置。在这里,这是为 EventSource 日志提供程序执行的。默认日志级别设置为 Warning

接下来,让我们为游戏 API 添加日志记录功能。

创建强类型日志消息

游戏 API 服务在编写日志消息时使用了 ILogger 扩展方法,如 LogErrorLogInformation。接下来,我们将展示如何使用自定义日志方法。让我们添加一个 Log 类来定义所有与游戏 API 项目相关的日志消息:

Codebreaker.GameAPIs/Infrastructure/Log.cs

public static partial class Log
{
  [LoggerMessage(
    EventId = 3001,
    Level = LogLevel.Warning,
    Message = "Game {GameId} not found")]
public static partial void GameNotFound(this ILogger logger, 
    Guid gameId);
  // code removed for brevity
  [LoggerMessage(
    EventId = 4001,
    Level = LogLevel.Information,
    Message = "The move {Move} was set for {GameId} with result {Result}")]
  public static partial void SendMove(this ILogger logger, string 
    move, Guid gameId, string result);
  // code removed for brevity
}

LoggerMessage 属性被源生成器使用。对于带有此属性的注解方法,日志源生成器会创建一个实现。该方法需要是 void 类型并带有 ILogger 参数。该方法也可以定义为扩展方法,就像这里的情况一样。参数名称需要与 Message 属性内部使用的表达式匹配,例如 gameIdmoveresult

在写入日志消息之前,生成的日志代码会检查日志级别是否启用。有时,创建使用生成方法的自定义方法可能很有用,如下面的代码片段所示:

Codebreaker.GameAPIs/Infrastructure/Log.cs

[LoggerMessage(
  EventId = 4003,
  Level = LogLevel.Information,
  Message = "Game lost after {Seconds} seconds with game {Gameid}")]
private static partial void GameLost(this ILogger logger, int seconds, Guid gameid);
public static void GameEnded(this ILogger logger, Game game)
{
  if (logger.IsEnabled(LogLevel.Information))
  {
    if (game.IsVictory)
    {
logger.GameWon(game.Moves.Count, game.Duration?.Seconds ?? 0, 
        game.Id);
    }
    else
    {
      logger.GameLost(game.Duration?.Seconds ?? 0, game.Id);
    }
  }
}

GameEnded 方法检查 Game 对象是否为胜利,并据此调用 GameWonGameLost 日志方法。在使用任何 CPU 和内存进行此过程(日志也可能需要枚举集合以生成有用的日志消息)之前,验证是否应该执行此操作——即检查日志级别是否启用。这是通过使用 logger.IsEnabled 方法并传递日志级别来检查的。

注意

写入日志时,不要使用插值字符串,例如 logger.LogInformation($"log message {expression}");。相反,使用 logger.LogInformation("log message {expression}", expression);。第二种形式支持结构化日志。传递的消息字符串是一个模板。使用这个模板,大括号内的内容可以用来创建索引,并且(取决于日志收集器)你可以查询包含此术语的所有日志条目。此外,使用格式化字符串会分配一个新的字符串,该字符串需要被垃圾回收。在第二种版本中,所有写入的日志条目只有一个字符串。

检查 GitHub 仓库以获取使用Log类定义的更多方法。接下来,让我们使用这个类来编写日志消息。

编写日志消息

日志消息主要是由GamesService类编写的,因此我们需要更改构造函数:

Codebreaker.GameAPIs/Services/GamesService.cs

public class GamesService(
  IGamesRepository dataRepository,
  ILogger<GamesService> logger) : IGamesService
{
  // code removed for brevity
}

使用更新的构造函数,注入了ILogger接口的泛型版本。类型参数指定了日志的类别名称。

注意

在本章中,GamesService类通过日志、分布式跟踪和度量功能进行了增强。这就是为什么你在源代码仓库的最终代码中看到所有这些更改的原因。

StartGameAsync方法通过日志进行了增强:

Codebreaker.GameAPIs/Services/GamesService.cs

public async Task<Game> StartGameAsync(
  string gameType,
  string playerName,
  CancellationToken cancellationToken = default)
{
  Game game;
  try
  {
    game = GamesFactory.CreateGame(gameType, playerName);
    await dataRepository.AddGameAsync(game, cancellationToken);
logger.GameStarted(game.Id);
  }
catch (CodebreakerException ex) when (ex.Code is 
    CodebreakerExceptionCodes.InvalidGameType)
  {
    logger.InvalidGameType(gameType);
    throw;
  }
  catch (Exception ex)
  {
    logger.Error(ex, ex.Message);
    throw;
  }
  return game;
}

GamesFactory类可以抛出CodebreakerException类型的异常。这个异常被捕获以写入日志消息并重新抛出异常。异常将由端点实现处理,最终返回特定的 HTTP 结果。在这里,我们只想记录这个信息并重新抛出异常。

对于泛型异常,Log类定义了一个强类型的Error方法,并用于写入此消息。InvalidGameType方法以Warning级别写入日志消息。在这里,客户端可能发送了无效的(或目前不被接受的)游戏类型。虽然这种情况不应该发生,但通常是由于客户端的问题,我们不需要在服务端处理它。了解这样的客户端是有好处的。Error方法以Error级别写入日志消息。检查更具体的错误类型并创建额外的消息可能是有用的。

在成功调用时,通过调用Log类的GameStarted方法写入日志消息,该方法的Informational级别被设置。

让我们使用.NET Aspire 检查日志消息。

使用.NET Aspire 仪表板查看日志

.NET Aspire 生成的Codebreaker.ServiceDefaults库包含日志配置:

Codebreaker.ServiceDefaults/Extensions.cs

public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
{
  builder.Logging.AddOpenTelemetry(logging =>
  {
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
  });
  // code removed for brevity
}

AddOpenTelemetry方法将OpenTelemetry日志记录器添加到日志工厂。此提供程序配置为包括格式化消息和包括日志作用域。将IncludeFormattedMessage设置为true指定,如果使用日志模板(我们确实使用了),则在为 OpenTelemetry 创建日志记录时也会包括格式化消息。默认情况下,情况并非如此。将IncludeScopes设置为true指定在日志中包括日志作用域 ID,这允许我们使用ILogger接口的BeginScope方法定义作用域时看到日志消息的层次结构。ConfigureOpenTelemetry方法在AddServiceDefaults方法内部调用,而AddServiceDefaults方法又从游戏 API 和机器人服务中调用。

在此日志配置就绪后,是时候在本地启动服务,运行 .NET Aspire 仪表板。启动应用程序和解决方案,让机器人服务玩一些游戏。然后,打开 .NET Aspire 仪表板,在 监控 类别下选择 控制台日志。在这里,你会看到已启动游戏的日志输出,如图 图 11.1 所示。你还可以看到来自 Entity Framework CoreEF Core)的日志输出,包括执行的查询和 ASP.NET Core 日志 - 除非将级别设置为不显示信息性消息:

图 11.1 – 带有 .NET Aspire 仪表板的日志

图 11.1 – 带有 .NET Aspire 仪表板的日志

此外,打开来自机器人服务的日志。机器人服务在收到结果后,会记录每次移动集合的日志输出,以显示移动的成功程度和剩余选项的数量,如图 图 11.2 所示:

图 11.2 – 机器人服务的日志

图 11.2 – 机器人服务的日志

当你打开我们用 GameStarted 事件编写的 GameId 占位符时,如图 图 11.3 所示。其他数据,如 RequestPath 占位符,来自 .NET 活动,我们将在 使用分布式 跟踪 部分稍后查看:

图 11.3 – 带有 .NET Aspire 仪表板的日志

图 11.3 – 结构化日志

使用 GameId,将游戏标识符设置为值,并读取与此游戏相关的所有日志。在这里,你可以轻松地跟踪单个游戏玩法,如图 图 11.4 所示:

图 11.4 – 带有 GameId 过滤器的结构化日志

图 11.4 – 带有 GameId 过滤器的结构化日志

在编写日志后,让我们开始处理指标数据。

使用指标数据

指标数据用于监控 CPU 和内存消耗或 HTTP 队列长度等计数。这些信息可用于分析服务所需资源,并相应地扩展服务。

使用指标数据,我们可以得到一些计数。这些计数可以根据内存或 CPU 消耗或 HTTP 队列长度来扩展服务。

在添加自定义指标之前,让我们先检查内置的指标数据。

监控内置 .NET 指标

如前所述,.NET 提供了许多内置的指标数据,可以使用 dotnet counters .NET 工具进行监控(通过 dotnet tool install dotnet-counters -g 作为全局工具安装),许多计数已经通过打开 指标 视图从 .NET Aspire 仪表板中可用。图 11.5 显示了在机器人并行玩了几场游戏时,游戏 API 服务的 .NET 管理堆大小:

图 11.5 – 指标

图 11.5 – 指标

对于许多应用程序,你不需要创建自定义指标数据 - 但一些自定义计数可能很有趣,并且如下一节所示,添加这些指标并不困难。

创建自定义指标数据

使用 Codebreaker 解决方案,我们感兴趣的是了解刚刚玩过的活跃游戏数量、从一个游戏移动到另一个游戏所需的时间、完成游戏所需的时间以及赢得与输掉的游戏数量。

注意

在收集所有数据后,我们需要注意通用数据保护条例GDPR)。不存储任何与用户相关的数据,仅保留日志和指标信息,我们就在安全的一边。

让我们创建一个新的 GamesMetrics 类,它包含所有需要的计数器:

Codebreaker.GameAPIs/Infrastructure/GamesMetrics.cs

public sealed class GamesMetrics : IDisposable
{
  public const string MeterName = "Codebreaker.Games";
  public const string Version = "1.0";
  private readonly Meter _meter;
  private readonly UpDownCounter<long> _activeGamesCounter;
  private readonly Histogram<double> _gameDuration;
  private readonly Histogram<double> _moveThinkTime;
  private readonly Histogram<int> _movesPerGameWin;
  private readonly Counter<long> _invalidMoveCounter;
  private readonly Counter<long> _gamesWonCounter;
  private readonly Counter<long> _gamesLostCounter;
  private readonly ConcurrentDictionary<Guid, DateTime> _moveTimes = new();

GameMetrics 类中定义的字段是为 Meter 类准备的,这是创建所有不同度量仪器所需的。这个类在 System.Diagnostics.Metrics 命名空间内定义。这个类负责创建所有用于监控度量数据的仪器。Meter 类型需要一个名称,用于指定我们感兴趣的度量数据。版本值是可选的。

Counter 类型用于计算赢得和输掉的游戏数量以及所有无效的游戏移动。Counter 可以用于正数值,并且大多数度量查看器显示每秒的计数数量,但也可以显示累积值。UpDownCounter 类型用于正负值。我们用它来表示活跃游戏的数量。每次游戏结束时,都会进行一次递减。Histogram 类型特别有趣。这种度量仪器可以用来显示任意值。在这里,这个仪器用来显示完成游戏所需的时间、用户在游戏移动之间所需的时间以及赢得游戏所需的移动次数。

使用 GamesMetrics 类的构造函数,创建并初始化 Meter 类和仪器:

Codebreaker.GameAPIs/Infrastructure/GamesMetrics.cs

public GamesMetrics(IMeterFactory meterFactory)
{
  _meter = meterFactory.Create(MeterName, Version);
  _activeGamesCounter = _meter.CreateUpDownCounter<long>(
    "codebreaker.active_games",
    unit: "{games}",
description: "Number of games that are currently active on the 
      server.");
  _gameDuration = _meter.CreateHistogram<double>(
"codebreaker.game_duration",
    unit: "s",
    description: "Duration of a game in seconds.");
  // code removed for brevity
}

IMeterFactory 是从 .NET 8 开始的新接口。这允许通过 IMeterFactory 创建度量类型,通过将 GamesMetrics 构造函数注入来创建 Meter 实例和仪器。CreateCounterCreateUpDownCounterCreateHistogram 是创建不同度量仪器的函数。仪器的名称、单位和描述在创建仪器时指定。

在使用这些计数器之前,让我们添加标签。

创建标签

写入度量数据,GameMetrics 类:

Codebreaker.GameAPIs/Infrastructure/GamesMetrics.cs

private static KeyValuePair<string, object?> CreateGameTypeTag(string gameType) =>
  KeyValuePair.Create<string, object?>("GameType", gameType);
private static KeyValuePair<string, object?> CreateGameIdTag(Guid id) =>
  KeyValuePair.Create<string, object?>("GameId", id.ToString());

CreateGameTypeTag 是一个辅助方法,用于创建名为 GameType 的标签,并设置通过方法参数传递的值。同样,CreateGameIdTag 是创建 GameId 标签的方法。

现在,我们准备好使用这些仪器创建方法。

为度量数据创建强类型方法

GameStarted 方法用于在创建新游戏时写入度量数据:

Codebreaker.GameAPIs/Infrastructure/GamesMetrics.cs

public void GameStarted(Game game)
{
  if (_moveThinkTime.Enabled)
  {
     _moveTimes.TryAdd(game.Id, game.StartTime);
  }
  if (_activeGamesCounter.Enabled)
  {
_activeGamesCounter.Add(1, CreateGameTypeTag(game.GameType));
  }
}

当没有人监听度量数据时,无需采取任何行动。在向仪表写入值之前,应验证仪表是否已启用。如果没有人监听仪表,则计数器被禁用。

要写入移动之间的时间差,我们需要记住上一个移动的时间。为此,GameMetrics 类包含 _moveTimes 字典。这个字典使用游戏 ID 作为键,最后移动(或游戏开始)的时间作为最新移动值。只有在使用 _moveThinkTime 仪表时,才需要计算此信息。

在游戏开始时增加计数的计数器是 _activeGamesCounter。使用 UpDownCounterAdd 方法用于更改计数器值。Add 方法的第二个——可选的——参数允许传递标签。在这里,添加了一个游戏类型的标签。这允许我们根据游戏类型检查度量数据。比较不同游戏类型的活跃游戏计数很有趣。

要写入直方图值,我们实现 MoveSet 方法:

Codebreaker.GameAPIs/Infrastructure/GamesMetrics.cs

public void MoveSet(Guid id, DateTime moveTime, string gameType)
{
  if (_moveThinkTime.Enabled)
  {
    _moveTimes.AddOrUpdate(id, moveTime, (id1, prevTime) =>
    {
      _moveThinkTime.Record((moveTime - prevTime).TotalSeconds, 
        [CreateGameIdTag(id1), CreateGameTypeTag(gameType)]);
      return moveTime;
    });
  }
}

在实现 MoveSet 时,对于接收到的游戏 ID,我们从字典中获取之前记录的时间,计算与新时间的差值,使用 Histogram 仪表的 Record 方法写入数据,并将新接收的时间写入字典。

在游戏结束时,实现 GameEnded 方法。在这里,使用了多个仪表,但此方法只需要简单的实现来检查每个仪表是否启用,并相应地写入计数。请检查源代码存储库以获取完整的代码。

接下来,我们可以将 GamesService 类的实现更改为使用 GamesMetrics 实例。

注入和使用度量

让我们更新 GamesService 类以支持度量数据:

Codebreaker.GameAPIs/Services/GamesService.cs

public class GamesService(
  IGamesRepository dataRepository,
  ILogger<GamesService> logger,
  GamesMetrics metrics) : IGamesService
{
  public async Task<Game> StartGameAsync(
    string gameType,
    string playerName,
    CancellationToken cancellationToken = default)
  {
    Game game;
    try
    {
      game = GamesFactory.CreateGame(gameType, playerName);
      await dataRepository.AddGameAsync(game, cancellationToken);
            metrics.GameStarted(game);
            logger.GameStarted(game.Id);
        }
      // code removed for brevity
  return game;
}

所需做的只是注入 GamesMetrics 类并调用 GameStarted 方法。

当然,GamesMetrics 类需要在 DI 容器(DIC)中进行配置:

Codebreaker.GameAPIs/ApplicationServices.cs

builder.Services.AddMetrics();
builder.Services.AddSingleton<GamesMetrics>();
builder.Services.AddOpenTelemetry()
  .WithMetrics(m => m.AddMeter(GamesMetrics.MeterName));

AddMetrics 扩展方法注册了 IMeterFactory 接口的实现。GamesMetrics 类被注册为单例——以创建仪表一次。我们还使用 OpenTelemetry 配置了 GamesMetrics 类——这样,我们就有了监听器,这些度量数据将显示在 .NET Aspire 仪表板上。

使用这个方法,我们可以运行应用程序。然而,由于这个额外的参数,GamesService 类的单元测试不再编译。在我们继续之前,让我们更新它。

更新单元测试以注入度量类型

GamesService 类使用一个具体类型——它注入 GamesMetrics 类型。这不能直接模拟,但我们可以模拟 IMeterFactory 接口以创建 GamesMetrics 实例。

以下代码片段展示了用于单元测试的IMeterFactory接口的实现:

Codebreaker.GameAPIs.Tests/TestMeterFactory.cs

internal sealed class TestMeterFactory : IMeterFactory
{
  public List<Meter> Meters { get; } = [];
  public Meter Create(MeterOptions options)
  {
    Meter meter = new(options.Name, options.Version, Array.
Empty<KeyValuePair<string, object?>>(), scope: this);
    Meters.Add(meter);
    return meter;
  }
  public void Dispose()
  {
    foreach (var meter in Meters)
    {
      meter.Dispose();
    }
    Meters.Clear();
  }
}

要实现IMeterFactory接口,需要实现CreateDispose方法。使用Create方法,通过名称、版本和标签信息创建一个新的Meter实例。

现在,这个TestMeterFactory类可以用来为单元测试创建GamesService类的实例:

Codebreaker.GameAPIs.Tests/GamesServiceTests.cs

private GamesService GetGamesService()
{
  IMeterFactory meterFactory = new TestMeterFactory();
  GamesMetrics metrics = new(meterFactory);
  return new GamesService(
    _gamesRepositoryMock.Object,
    NullLogger<GamesService>.Instance,
    metrics);
}

创建一个新的GamesMetrics实例时,会创建TestMeterFactory类。现在GamesService类的单元测试可以成功构建。

注意

当调用GamesService构造函数时,GitHub 包含一个额外的参数,ActivitySourceActivitySource使用分布式跟踪部分中添加,并需要适配单元测试。

还需要为GamesMetrics类编写一个单元测试,我们将在下一步进行。

创建单元测试以验证指标

指标数据可以轻易地在办公室的显示器上显示为重要的业务信息。应用程序发生了什么?用户活跃度如何?错误率是否上升?虽然指标信息对于正在接收和处理的订单来说并不重要,但如果未记录指标数据,很容易错过某些东西不工作的情况——因此,为指标数据创建单元测试应该是创建自定义指标类型的一部分。

首先,让我们创建一个骨架来返回IMeterFactory实例和GamesMetrics实例。

计数器工厂骨架

以下代码片段定义了GamesMetrics单元测试使用的骨架:

Codebreaker.GameAPIs.Tests/GamesMetricsTests.cs

private static IServiceProvider CreateServiceProvider()
{
  ServiceCollection services = new();
  service.AddMetrics();
  services.AddSingleton<GamesMetrics>();
  return serviceCollection.BuildServiceProvider();
}
private static (IMeterFactory MeterFactory, GamesMetrics Metrics) CreateMeterFactorySkeleton()
{
  var container = CreateServiceProvider();
  GamesMetrics metrics = container.GetRequiredService<GamesMetrics>();
  IMeterFactory meterFactory = container.GetRequiredService<IMeterFactory>();
  return (meterFactory, metrics);
}

这里,我们需要IMeterFactory接口的真实实现。这是通过单元测试的 DIC 配置的——包括GamesMetrics单例。现在CreateMeterFactorySkelton方法从 DIC 中获取IMeterFactoryGameMetrics实例。

单元测试

使用此骨架,我们可以为所有GameMetrics方法创建单元测试:

Codebreaker.GameAPIs.Tests/GamesMetricsTests.cs

public class GamesMetricsTests
{
  private Guid _gameId = Guid.Parse("DBDF4DD9-3A02-4B2A-87F6-FFE4BA1DCE52");
  private DateTime _gameStartTime = new DateTime(2024, 1, 1, 12, 10, 5);
  private DateTime _gameMove1Time = new DateTime(2024, 1, 1, 12, 10, 15);
  [Fact]
  public void MoveSet_Should_Record_ThinkTime()
  {
    // arrange
    (IMeterFactory meterFactory, GamesMetrics metrics) = 
CreateMeterFactorySkeleton();
    MetricCollector<double> collector = new(meterFactory, 
GamesMetrics.MeterName, "codebreaker.move_think_time");
    var game = GetGame();
    metrics.GameStarted(game);
    // act
    metrics.MoveSet(game.Id, _gameMove1Time, "Game6x4");
    // assert
    var measurements = collector.GetMeasurementSnapshot();
    Assert.Single(measurements);
Assert.Equal(10, measurements[0].Value);
  }
  // code removed for brevity

为了方便对指标类进行单元测试,Microsoft.Extensions.Diagnostics.Testing NuGet 包中定义的MetricCollector类可以在Microsoft.Extensions.Diagnostics.Metrics.Testing命名空间中注册为指标数据的监听器并收集这些信息。对于调试目的,启用指标工具也非常有用。

在从骨架返回IMeterFActoryGamesMetrics对象后,创建了一个收集器。您需要为每个需要测试的仪器创建一个收集器。泛型类型参数和仪器的名称需要匹配。GamesMetrics类的MoveSet方法记录了前一个动作(或游戏开始)和当前动作之间的时间。使用Assert.Single,它验证了恰好有一个测量值写入收集器。使用Assert.Equal,它检查这一记录包含值 10。如果您从_gameStartTime_gameMove1Time计算值,这与测试数据传递的时间差相匹配。

随着GameMetrics类测试成功,让我们转到.NET Aspire 仪表板来查看自定义指标数据。

使用.NET Aspire 仪表板查看指标数据

我们在游戏 API 项目中注入了指标,并使用 OpenTelemetry 配置了我们的自定义GamesMetrics类。现在,我们可以使用.NET Aspire 仪表板来查看玩过的游戏了!

运行服务并启动机器人以并行运行多个游戏,我们可以看到有趣的结果。有时,由于机器人输掉了游戏,它无法在 12 步内找到答案,如图 11.6所示:

图 11.6 – 失败游戏的计数器

图 11.6 – 失败游戏的计数器

图 11.7 显示了机器人赢得的游戏数量要高得多。此图还显示了由于指定的标签而可以选择的游戏类型过滤器:

图 11.7 – 胜利游戏的计数器

图 11.7 – 胜利游戏的计数器

虽然胜利和失败使用了简单的计数器,图 11.8 显示了带有活跃游戏数量的上下计数器:

图 11.8 – 活跃游戏的上下计数器

图 11.8 – 活跃游戏的上下计数器

图 11.9 显示了一个直方图,可以检查游戏的持续时间:

图 11.9 – 显示游戏持续时间的直方图

图 11.9 – 显示游戏持续时间的直方图

一个直方图显示了P50P90P99的值。这些名称是百分位数的标记。完成的游戏中有 50%在最低值以下:在整个时间范围内,50%的游戏在 25 秒内完成。下一条更高的线标记了 90%的游戏运行。随着时间的推移,有一些峰值,我们可以看到有时 90%的游戏在 25 秒内完成,但也可能需要 50 秒。为了达到更高的游戏数量,99%的游戏在 50 到 75 秒内完成。在高峰时段,需要 250 秒。如果用户在玩游戏,这是可以预料的。我们需要比机器人更多的时间来解决这个问题。但在这里,只是机器人玩游戏。对于不同的游戏,机器人被配置为在游戏移动之间采取不同的时间。然而,机器人从未被配置为花费这么长时间。因此,这需要是另一个问题,可能是服务负载过高。为了更容易地找到这种行为的原因,下一节将介绍分布式追踪。

使用分布式追踪

如果在服务中发生错误,这个请求是从哪里来的,它起源于哪里?使用分布式追踪,我们可以看到服务和资源的交互,并且可以轻松地跟踪客户端请求如何流向不同的服务,并看到错误何时发生,从错误向上到堆栈。

使用.NET,我们使用ActivitySourceActivity类来指定分布式追踪的信息。

使用 DIC 创建 ActivitySource 类

当编写追踪信息时,你通常在一个项目中只有一个ActivitySource类,该类被所有编写追踪信息的类使用。在游戏客户端库中,ActivitySource类被用作静态成员。使用如游戏 API 这样的可执行项目中的ActivitySource类,我们可以将其注册到 DIC 中:

Codebreaker.GameAPIs/ApplicationServices.cs

public static void AddApplicationTelemetry(this IhostApplicationBuilder builder)
{
  // code removed for brevity
  const string ActivitySourceName = "Codebreaker.GameAPIs";
  const string ActivitySourceVersion = "1.0.0";
builder.Services.AddKeyedSingleton(ActivitySourceName, (services, _) 
  =>
    new ActivitySource(ActivitySourceName,
ActivitySourceVersion));

ASP.NET Core 的初始化已经注册了一个名为Microsoft.AspNetCoreActivitySource类作为ActivitySource类型的单例。我们不希望用我们的名字覆盖这个设置。ASP.NET Core 特性注入这个ActivitySource实例应该仍然得到这个实例,但对我们自己的追踪消息,应该使用Codebreaker.GameAPIs活动源。借助.NET 8 对依赖注入(DIC)的增强,我们可以通过调用AddKeyedSingleton方法并指定名称和版本字符串,在 DIC 中注册一个命名服务。使用由 lambda 表达式定义的工厂创建一个实例。

接下来,我们可以使用GamesService类注入这个单例实例。

编写追踪消息

通过GamesService类的构造函数,我们现在可以注入配置好的ActivitySource类:

Codebreaker.GameAPIs/Services/GamesService.cs

public class GamesService(
  IGamesRepository dataRepository,
  ILogger<GamesService> logger,
  GamesMetrics metrics,
[FromKeyedServices("Codebreaker.GameAPIs")] ActivitySource 
    activitySource) :
    IGamesService
{
  // code removed for brevity

使用FromKeyedServices属性,我们从 DIC 中获取命名实例。

接下来,让我们通过创建一个Activity对象来更新创建新游戏的过程:

Codebreaker.GameAPIs/Services/GamesService.cs

public async Task<Game> StartGameAsync(string gameType, string playerName, CancellationToken cancellationToken = default)
{
  Game game;
using var activity = activitySource.CreateActivity("StartGame", 
    ActivityKind.Server);
  try
  {
    game = GamesFactory.CreateGame(gameType, playerName);
    activity?.AddTag(GameTypeTagName, game.GameType)
.AddTag(GameIdTagName, game.Id.ToString())
      .Start();
    await dataRepository.AddGameAsync(game, cancellationToken);
    metrics.GameStarted(game);
    logger.GameStarted(game.Id);
    activity?.SetStatus(ActivityStatusCode.Ok);
  }
  catch (CodebreakerException ex) when (ex.Code is CodebreakerExceptionCodes.InvalidGameType)
  {
    logger.InvalidGameType(gameType);
    activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
    throw;
  }
  catch (Exception ex)
  {
    logger.Error(ex, ex.Message);
    activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
    throw;
  }
  return game;
}

通过调用 CreateActivity 方法创建 Activity 对象。这里使用的参数是活动的名称和活动类型。服务指定 ActivityKind.Server,而客户端库使用 ActivityKind.Client。其他可用的类型有 ProducerConsumer

创建 Activity 对象的方法可能会返回 null。如果没有人为 ActivitySource 类添加监听器,CreateActivity 方法将返回 null。这减少了开销,但也意味着我们始终需要在使用 Activity 对象之前检查 null 值。使用 null 条件运算符,这很容易做到。

使用 StartActivity 方法而不是 CreateActivity 方法会立即启动活动。在这里,我们希望向活动添加一些数据,这些数据会与日志输出一起显示。使用 AddTag 方法添加游戏类型和游戏 ID。此方法将键值对添加到日志条目中,允许过滤和搜索。SetBaggage 方法允许添加信息不仅限于这个活动输出——这些信息会传递给子活动。行李信息跨进程使用,因此需要可序列化。调用 Start 方法开始活动——这将写入带有标签和行李信息的第一个日志记录。

当调用 Stop 方法时,活动结束。在这里,使用 using 声明在 activity 变量超出作用域时销毁活动。这会隐式地停止活动。

在活动结束之前,调用 SetStatus 方法。此方法指定活动的结果,并在活动结束时写入。在游戏成功启动的情况下,活动的状态是 ActivityStatusCode.Ok。在出现错误的情况下,状态码是 ActivityStatusCode.Error,并写入异常消息。

请检查其他源代码仓库,以获取使用 GamesService 类创建的其他活动。

在这个实现中,我们只需要配置服务默认库来监控自定义的 ActivitySource 类。

使用 .NET Aspire 仪表板查看分布式跟踪

首先,让我们将自定义的 ActivitySource 类添加到配置中:

Codebreaker.ServiceDefaults/Extensions.cs

public static IHostApplicationBuilder ConfigureOpenTelemetry(this IHostApplicationBuilder builder)
{
  // code removed for brevity
  builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
      if (builder.Environment.IsDevelopment())
      {
        tracing.SetSampler(new AlaysOnSampler());
      }
      tracing.AddSource(
        "Codebreaker.GameAPIs.Client",
        "Codebreaker.GameAPIs")
        .AddAspNetCoreInstrumentation()
        .AddGrpcClientInstrumentation()
        .AddHttpClientInstrumentation();

WithTracing 方法配置分布式跟踪设置。TracerProviderBuilder 类的 AddSource 方法设置应订阅的源。Codebreaker.GameAPIs 是配置了游戏 API 服务的活动源名称。Codebreaker.GameAPIs.Client 是客户端库中使用的活动源名称,该库由机器人服务引用。接下来的方法调用配置了 ASP.NET Core、gRPC 和 HttpClient 的内置源。

现在,使用机器人运行几场游戏,你可以在 .NET Aspire 仪表板中看到 跟踪,如图 11.10 所示:

图 11.10 – 跟踪

图 11.10 – 跟踪

向机器人发送POST请求以玩多个游戏,请求立即返回(图中的请求列出了 4,84 毫秒)。该图还显示了从后台任务中启动的此请求的活动。这个任务是为了玩游戏。这与多个活动(或bot POST bot/bots,这是从 ASP.NET Core 创建的活动)有什么关系。下一个活动bot StartGameAsync是从游戏客户端库创建的自定义活动。bot POST是来自HttpClient的下一个活动。从那里,我们切换到gameapis服务。使用游戏 API 创建的自定义活动是gameapis StartGamegameapis SetMove

在进行这些活动中的每一个时,你都可以深入了解并获取数据,包括游戏 ID 和游戏类型的标签,如图11.11所示:

图 11.11 – 跟踪数据

图 11.11 – 跟踪数据

在监控这些数据的同时,也切换到结构化日志。使用这些日志,你可以看到一个跟踪标识符。点击它,你将从日志切换到跟踪信息。反之亦然。在打开跟踪时,你也可以切换到与跟踪相关的日志信息。

在开发环境中使用.NET Aspire 仪表板现在非常棒。对于生产环境,我们有不同的需求。让我们切换到使用.NET Azure 服务进行监控。

使用 Azure Application Insights 进行监控

创建 Azure Container Apps 环境(从第六章开始)也会创建一个Azure Log Analytics资源。在本章中,我们添加了一个Azure Application Insights资源,并显式地将 Log Analytics 资源添加到应用程序模型中。Log Analytics 和 Application Insights 都是Azure Monitor服务的一部分。

Log Analytics 用于监控创建的日志数据量及其相关成本,并在使用量高于预期时提供原因。Application Insights 专注于应用程序遥测数据和用户数据。

配置.NET Aspire 主机以使用 Application Insights

要添加 Application Insights,请添加 NuGet 包Aspire.Hosting.Azure.ApplicationInsights,并更新.NET Aspire AppHost项目的Program.cs文件:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
// code removed for brevity
var logs = builder.AddAzureLogAnalyticsWorkspace("logs");
var appInsights = builder.AddAzureApplicationInsights("insights", logs);
var cosmos = builder.AddAzureCosmosDB("codebreakercosmos")
  .AddDatabase("codebreaker");
var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
    .WithReference(cosmos)
    .WithReference(appInsights);
var bot = builder.AddProject<Projects.CodeBreaker_Bot>("bot")
    .WithReference(gameAPIs)
.WithReference(appInsights);
builder.Build().Run();
// code removed for brevity

通过调用 AddAzureApplicationInsights 方法添加 Application Insights 资源。此方法需要一个名称和一个由调用 AddAzureLogAnalytics 方法创建的日志分析工作区资源。游戏 API 和机器人服务都将使用此资源,因此它通过使用WithReference方法通过 Aspire 编排进行转发。

通过这种方式,我们可以配置服务以使用 Application Insights。

配置服务以使用 Application Insights

要使用 Application Insights,可以更新通用配置项目:

Codebreaker.ServiceDefaults/Extensions.cs

private static IHostApplicationBuilder AddOpenTelemetryExporters(
  this IHostApplicationBuilder builder)
{
  builder.Services.AddOpenTelemetry()
    .UseAzureMonitor(options =>
    {
      options.ConnectionString = builder.Configuration[
        "APPLICATIONINSIGHTS_CONNECTION_STRING"];
    });
  // code removed for brevity
  return builder;
}

AddOpenTelemetryExporters 方法是在同一类中的 AddServiceDefaults 方法中调用的。AddServiceDefaults 方法使用每个服务项目的 WebApplication 配置进行调用。AddOpenTelemetry 方法是 OpenTelementry SDK 的一部分,用于配置服务的日志记录、指标和分布式跟踪。UseAzureMonitor 扩展方法配置提供者将此信息写入 Azure Monitor。

这就是我们需要做的全部。让我们检查从应用程序中获得的信息。

使用 Application Insights 监控解决方案

如前所述,当使用 Aspire 仪表板进行监控时运行应用程序,使用机器人运行多个游戏。由于 Application Insights 已配置,因此不需要将解决方案部署到 Azure;当在本地运行应用程序时,监控信息也将可在 Azure 中获取。使用机器人启动几轮游戏后,在 Azure 门户中打开 Azure Application Insights 资源。

玩了几局游戏后,检查 Azure 门户。使用 Application Insights,在左侧面板的 调查 类别中,选择 应用映射。在这里,你可以看到不同的服务是如何通信的,如 图 11.12* 所示:

图 11.12 – 应用映射

图 11.12 – 应用映射

上一张图显示了机器人服务调用游戏 API 服务,并且它与 Azure Cosmos 数据库进行通信。从这些信息中可以轻松地看到消息计数、使用的时间和错误。点击一个连接,可以通过查看最慢的调用和直接访问日志信息来调查性能。

在 Azure 门户的 调查 类别中,你可以深入了解其他有趣的信息,例如 性能,在这里你可以了解慢速操作,以及 故障,在这里你可以深入了解错误和异常。智能检测可以在服务不按通常的预期值行为时给你发送通知(警报)。

codebreaker.active_gamescodebreaker.game_moves-per-win 中,你可以选择指标名称和聚合类型来计算值并查看图形结果,如 图 11.13* 所示:

图 11.13 – 通过 Application Insights 获取的指标

图 11.13 – 通过 Application Insights 获取的指标

通过这个图表,第一行显示了游戏的平均持续时间(秒),第二行显示了平均所需的移动次数,第三行显示了当前活跃的游戏数量。

点击 customMetrics 表,你可以查询所有指标数据。

使用此 KQL 查询在时间图表上获取平均 EF Core 查询:

customMetrics
| where name == "ec.Microsoft.EntityFrameworkCore.queries-per-second"
| summarize avg(value) by bin(timestamp, 5min)
| render timechart

此查询通过 name 列筛选 customMetrics 表,以获取每秒的 EF Core 查询。此结果(您可以通过点击 结果 来查看符合此查询的每条记录)随后用于显示时间图表上的平均值和时间戳。您的结果可能看起来像 图 11.14 中所示:

图 11.14 – 使用 KQL 的 EF Core 平均查询

图 11.14 – 使用 KQL 的 EF Core 平均查询

如果您的服务可以访问 Microsoft Azure 上的此资源,则无论服务运行在何处,都可以使用 Application Insights 资源。如果整个解决方案需要在本地运行,则可以使用 Prometheus 和 Grafana,正如我们接下来将要做的。

使用 Prometheus 和 Grafana 进行监控

对于监控微服务解决方案,Prometheus 和 Grafana 经常被使用。Prometheus 使用拉模型从服务中收集数据。使用这些数据,PromQL 查询语言分析这些信息。Grafana 可以访问 Prometheus 收集的数据以显示图形视图。

我们将使用运行 Prometheus 和 Grafana 的 Docker 容器。Microsoft Azure 还提供了 Prometheus 和 Grafana 的托管服务,也可以使用。

接下来,让我们为 Prometheus 和 Grafana 配置 Docker 容器。

为 Prometheus 和 Grafana 添加 Docker 容器

要使用 Prometheus 和 Grafana 的解决方案,您需要选择启动配置文件 – 使用 Visual Studio,在项目选择后,在工具栏中选择 OnPremises

使用命令行,使用 --launch-profile 参数:

dotnet run --project Codebreaker.AppHost.csproj --launch-profile OnPremises

要在 Docker 容器内使用 SQL Server,请将 DataStore 配置设置为 SQLServer(有关详细信息,请参阅 第三章);您也可以使用相同的构建配置使用内存或 Cosmos。

要使用 Prometheus Docker 容器,我们需要更改 .NET Aspire AppHost 实现:

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
var sqlServer = builder.AddSqlServer("sql")
  .WithDataVolume()
  .PublishAsContainer()
  .AddDatabase("CodebreakerSql");
var prometheus = builder.AddContainer("prometheus", "prom/prometheus")
  .WithBindMount("../prometheus", "/etc/prometheus", isReadOnly: true)
  .WithHttpEndpoint(9090, hostPort: 9090);
// code removed for brevity

我们最初使用的第一个 Docker 容器是 SQL Server 的容器。要使用 SQL Server 的 Docker 镜像,使用了 AddSqlServerContainer 扩展方法。要使用 Prometheus Docker 镜像,我们需要使用 AddContainer 泛型方法,它允许添加任何 Docker 镜像。Prometheus 的 Docker 镜像可以从 Docker Hub 使用 prom/prometheus 拉取。对于 Prometheus 配置,我们使用存储在容器外部的 prometheus 文件夹。使用 WithBindMount,将 prometheus 主机目录映射到容器内的 /etc/prometheus 文件夹。Prometheus 使用端口 9090 访问其服务。此端口通过 WithHttpEndpoint 方法映射到主机端口 9090 以使 Prometheus 可用。

接下来,添加一个具有相同文件的 Grafana Docker 容器:

Codebreaker.AppHost/Program.cs

var grafana = builder.AddContainer("grafana", "grafana/grafana")
  .WithBindMount("../grafana/config", "/etc/grafana",
    isReadOnly: true)
  .WithBindMount("../grafana/dashboards",
    "/var/lib/grafana/dashboards", isReadOnly: true)
  .WithHttpEndpoint(containerPort: 3000, hostPort: 3000,
    name: "grafana-http");
var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithReference(sqlServer)
  .WithEnvironment("DataStore", dataStore)
  .WithEnvironment("GRAFANA_URL",
    grafana.GetEndpoint("grafana-http"));
  .WithEnvironment("StartupMode", startupMode);
  builder.AddProject<Projects.CodeBreaker_Bot>("bot")
    .WithReference(gameAPIs);
    .WithEnvironment("StartupMode", startupMode);
// code removed for brevity

Grafana 的 Docker 镜像通过 Docker Hub 以 grafana/grafana 的名称拉取。此容器需要在 /etc/grafana/var/lib/grafana/dashboards Docker 容器目录中进行挂载,用于 Grafana 配置和仪表板配置。对于这两个挂载,我们将有一个本地的 grafana 目录。Grafana 服务将在端口 3000 上可用。

在配置 Docker 容器之后,让我们添加 Prometheus 的配置。

配置 Prometheus

Prometheus 使用 prometheus 文件夹中的此 YML 文件进行配置,该文件夹由先前指定的 Docker 容器配置引用:

prometheus/prometheus.yml

global:
  scrape_interval: 1s
scrape_configs:
  - job_name: 'codebreakergames'
    static_configs:
      - targets: ['host.docker.internal:9400']
  - job_name: 'codebreakerbot'
    static_configs:
- targets: ['host.docker.internal:5141']

Prometheus 从服务中拉取遥测数据。Prometheus 从 Prometheus 拉取数据的频率由 scrape_interval 参数定义。为了运行测试和获取快速信息,这里配置了 1 秒。在生产系统中,您可能需要将此值增加到,例如,30 秒以减少对服务的负载。从 Prometheus 访问的服务是游戏 API 和机器人服务。请确保配置到您的服务项目的端口号。您可以使用 Properties/launchsettings.json 文件查看这些值。

要添加一个 Prometheus 用于抓取遥测数据的 API 端点,Extensions 类的 MapDefaultEndpoints 方法需要更新:

Codebreaker.ServiceDefaults/Extensions.cs

public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
  if (Environment.GetEnvironmentVariable("StartupMode") == "OnPremises")
  {
    app.MapPrometheusScrapingEndpoint();
  }
// code removed for brevity
  return app;
}

MapPrometheusScrapingEndpoint 方法配置了一个端点并将 PrometheusExporterMiddleware 中间件映射。

AddOpenTelemetryExporters 方法也需要更新:

Codebreaker.ServiceDefaults/Extensions.cs

private static IHostApplicationBuilder AddOpenTelemetryExporters(this IHostApplicationBuilder builder)
{
  // code removed for brevity
  builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics.AddPrometheusExporter());
  return builder;
}

使用 Azure Application Insights,我们使用了 UseAzureMonitor 方法。对于 Prometheus,我们使用 WithMetrics 方法,而 AddPrometheusExporter 添加了 Prometheus 的导出器。

在配置 Prometheus 之后,让我们配置 Grafana。

配置 Grafana

使用此配置的 Grafana Docker 容器,我们使用 grafana 主文件夹定义了配置。在这里,我们需要创建一个 grafana.ini 配置文件:

grafana/grafana.ini

[auth.anonymous]
enabled = true
org_name = Main Org.
org_role = Admin
hide_version = false
[dashboards]
default_home_dashboard_path = /var/lib/grafana/dashboards/aspnetcore.json
min_refresh_interval = 1s

对于本地的简单测试,我们允许匿名认证并指定非认证用户可以更改设置和自定义仪表板。使用的首页仪表板是 ASP.NET Core 仪表板。

要从 Grafana 访问 Prometheus,需要使用 YML 文件指定数据源:

grafana/config/provisioning/datasources/default.yaml

apiVersion: 1
datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
url: http://host.docker.internal:9090
    uid: PBFA97CFB590B2093

datasources 文件夹包含 Prometheus 数据源的配置文件。它使用 Prometheus 使用的端口 9090

默认仪表板也使用 YML 文件进行配置:

grafana/config/provisioning/dashboards/default.yml

apiVersion: 1
providers:
  - name: Default
    folder: .NET
    type: file
    options:
      path:
        /var/lib/grafana/dashboards

仪表盘本身存储在 grafana/dashboards 文件夹中。您可以在 grafana.com/grafana/dashboards 获取预构建的仪表盘。ASP.NET Core 团队提供了名为 ASP.NET Core Endpoint(ID:19925)和 ASP.NET Core(ID:19924)的仪表盘,用于监控请求持续时间、错误率、当前连接、总请求等 .NET 8 指标。这两个仪表盘都已复制到本章的最终解决方案中。

当此配置就绪时,我们就可以再次启动解决方案了——在本地系统上运行所有服务。

使用 Prometheus 和 Grafana 监控解决方案

当您现在运行应用程序时,有三个 Docker 容器正在运行:SQL Server、Prometheus 和 Grafana,以及机器人和服务 API 项目,如图 图 11.15 所示。Grafana 列出了从主机可访问的端点:

图 11.15 – 使用 Prometheus 和 Grafana 的 Aspire 仪表盘

图 11.15 – 使用 Prometheus 和 Grafana 的 Aspire 仪表盘

通过再次访问机器人服务以让它玩一些游戏,我们可以从 Grafana Docker 容器中访问配置的仪表盘,如图 图 11.16 所示:

图 11.16 – Grafana 仪表盘

图 11.16 – Grafana 仪表盘

此页面是通过在左侧面板中选择 仪表盘 打开的。在那里,您可以打开配置的 ASP.NET Core 仪表盘以查看这些指标。

您还可以看到写入的自定义指标计数,如图 图 11.17 所示:

图 11.17 – 使用 Grafana 的活动游戏

图 11.17 – 使用 Grafana 的活动游戏

前面的图显示了活动游戏数量(所有都是从机器人开始的)。要查看此屏幕,请打开左侧面板中的 探索 菜单。然后,从组合框中选择指标的值并单击 查询 按钮。通过图表,您可以选择不同的显示类型。

在上一节中,您已经看到了用于监控解决方案的 Azure 服务。使用 Prometheus 和 Grafana,您已经看到了可以轻松用于本地环境的服务。如果您在 Microsoft Azure 中运行时更喜欢 Prometheus 和 Grafana,一种使用方式是将这些服务运行在 Azure Container Apps 中。同时,Azure 也提供了管理的服务:Azure 提供了管理 Grafana 和 Azure Monitor 管理服务 Prometheus。使用这些服务,相同的服务可用,但管理需求减少。

摘要

在本章中,您学习了从微服务解决方案中提供遥测数据,包括日志记录、指标和分布式跟踪。对于日志记录,您使用了高性能、强类型的日志记录来编写信息级日志以及错误。对于指标,您使用创建的仪表创建自定义指标数据。对于分布式跟踪,您使用了 ActivitySourceActivity 类。

为了监控所有这些遥测数据,您使用了.NET Aspire 仪表板、Azure Application Insights 和 Prometheus 与 Grafana。

在下一章中,我们将探讨如何使用指标数据来扩展在 Azure Container Apps 中运行的服务。我们将了解使用我们在第十章中创建的负载测试来测量的服务的内存和 CPU 使用情况,结合本章的指标信息,了解如何根据需求增长进行服务扩展,并实现健康检查以在服务不健康时恢复服务。

进一步阅读

要了解更多关于本章讨论的主题,您可以参考以下链接:

第十二章:扩展服务

服务的响应速度有多快?服务是否仅限于 CPU 内核或内存?根据用户负载,何时启动更多服务器实例是有用的?如果你运行过多的计算资源,或者如果它们太大,你将支付比必要的更多费用。如果你使用的资源太小,响应时间会增加,或者应用程序可能根本不可用。这样,你会失去客户,你的收入也会减少。你应该知道如何找到瓶颈,并知道如何调整哪些好的旋钮来按需扩展资源。

第十章 中,我们创建了负载测试来查看服务在负载下的行为,而在 第十一章 中,我们通过添加遥测数据扩展了服务。现在,我们将使用负载测试和遥测数据来找出最佳的扩展选项。

在本章中,我们将借助遥测数据开始减少响应时间,在分析负载之前,这可以通过一个实例运行。最后,我们将定义规则,以便我们可以扩展到多个实例。为了在服务无响应时自动重启实例,我们将添加健康检查。

在本章中,你将学习以下内容:

  • 使用缓存提高性能

  • 使用 Azure Load Testing 模拟用户

  • 扩容和扩展服务

  • 使用缩放规则

  • 实施健康检查

技术要求

在本章中,就像前面的章节一样,你需要一个 Azure 订阅、Azure 开发者 CLI (winget install Microsoft.Azd) 和 Docker Desktop。

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure

ch12 文件夹包含本章所需的各个项目及其输出。要添加本章的功能,你可以从上一章的源代码开始。

本章我们将实现以下项目:

  • Codebreaker.AppHost: .NET Aspire 主项目。该项目通过添加 Redis 资源用于缓存而得到增强。

  • Codebreaker.ServiceDefaults: 在这里,我们为所有服务使用一个通用的健康检查配置。

  • Codebreaker.GameAPIs: 通过这个项目,我们实现了缓存游戏以减少数据库访问,并添加了自定义的健康检查。

要了解如何将资源发布到 Microsoft Azure,请查看本章的 README 文件。

注意

在编写本章时,我们创建了具有许多用户和更改 Azure Cosmos 数据库规模的负载测试。这些测试的持续时间和您可以使用它们的虚拟用户数量取决于您愿意花费的金额。如果您增加数据库的 RU/s,请确保在测试运行后删除资源,或者至少在测试运行后再次减少 RU/s 的数量。您还可以跳过运行具有更多用户数的测试,只需读取结果即可。

使用缓存提高性能

在分析应用程序的 CPU 和内存需求之前,让我们看看在哪里可以轻松获得胜利,以便更快地向客户端返回响应。通过检查遥测信息(如我们在上一章中所做的那样),我们可以看到在使用分布式跟踪发送游戏移动时,会向数据库发出多个请求。图 12**.1 显示了机器人发送 SetMoveAsync 请求:

图 12.1 – 追踪移动集

图 12.1 – 追踪移动集

如前图所示,当接收到 PATCH 请求时,使用游戏 ID 从数据库中检索游戏以验证接收到的数据的正确性。在计算移动后,结果游戏被写入数据库。EF Core 的跟踪信息以 DATA 关键字显示,以及访问所需的时间。

性能可能已经足够好,但这也取决于数据库负载。当使用 SQL Server 数据库时,由于写入操作导致的锁定,大量的写入可能会降低读取性能。在更高的数据库负载下,增加请求单元(RU)的数量或使用更大的机器(这会增加成本)可以是解决更高负载的一种方法。更好的选择是缓存数据。许多数据库读取可以通过从内存缓存中读取对象来替代。

一个初步的想法可能是将游戏存储在进程的内存中。如果不在那里,则从数据库中检索它。然而,如果有多个服务实例正在运行,客户端可能会用服务器 A 调用一个移动,用服务器 B 调用另一个移动。因为游戏包含最后移动的编号,从本地缓存中读取它可能会导致游戏的旧版本,因此请求失败。围绕这个问题的一个选项是使用粘性会话。这样,一个客户端总是获得同一个服务实例来满足请求。通过使用分布式内存缓存,可以轻松避免这个要求。

注意

在粘性会话中,客户端总是连接到同一服务实例。粘性会话的最大缺点是当服务崩溃时。没有粘性会话,客户端可以立即切换到另一个服务实例,并且不会检测到任何停机时间。在有粘性会话的情况下,所有会话数据都会对客户端丢失。这并不是唯一的缺点。如果由于性能低下而启动了另一个实例怎么办?新的服务实例只会接收来自新客户端的流量。现有的客户端会继续与它们已经通信的服务器保持连接。这会导致服务器利用率延迟(仅来自新客户端)。在有粘性会话的情况下,服务实例之间的负载可能会不均匀分布。最好的做法是尽量避免使用粘性会话。

当使用分布式内存缓存时,有多种选项可供选择。使用 Microsoft Azure 时,可以使用 Azure Cache for Redis。这项服务根据您的可用性和内存大小需求提供标准、高级、企业和企业闪存产品。使用 Azure Cosmos DB 时,可以采用集成在 Azure Cosmos DB 网关中的内存缓存。该服务的一项特性是针对点读取的项缓存,在游戏运行期间可以多次读取项,从而降低了使用 Azure Cosmos DB 的成本,因为从缓存中读取所需的 RU/s 为 0。

在这里,我们将使用 Redis 的 Docker 容器,它可以在本地 Docker 环境中使用,也可以用于在 Azure Container Apps 中运行解决方案。

从缓存中读取和写入

IDistributedCache接口的 API 支持写入字节数组和字符串——数据需要通过网络发送到 Redis 集群。为此,我们将创建将Game类转换为字节并从字节转换回Game类的方法:

Codebreaker.GameAPIs/Models/GameExtensions.cs

public static class GameExtensions
{
  public static byte[] ToBytes(this Game game) =>
    JsonSerializer.SerializeToUtf8Bytes(game);
  public static Game? ToGame(this byte[] bytes) =>
    JsonSerializer.Deserialize<Game>(bytes);
}

System.Text.Json序列化器不仅支持将数据序列化为 JSON,还可以将其序列化为字节数组。Game类已经支持使用此序列化器进行序列化,因此不需要对GameMove模型类型进行任何其他更改。

我们可以从GamesService类访问缓存:

Codebreaker.GameAPIs/Services/GamesService.cs

public class GamesService(
  IGamesRepository dataRepository,
  IDistributedCache distributedCache,
  ILogger<GamesService> logger,
  GamesMetrics metrics,
  [FromKeyedServices("Codebreaker.GameAPIs")]
  ActivitySource activitySource) : IGamesService
{

无论使用什么技术实现分布式内存缓存,我们都可以注入IDistributedCache接口。

为了使用缓存更新Game类,我们可以实现以下方法:

Codebreaker.GameAPIs/Services/GamesService.cs

private async Task UpdateGameInCacheAsync(Game game, CancellationToken cancellationToken = default)
{
await distributedCache.SetAsync(game.Id.ToString(), game.ToBytes(), 
  cancellationToken);
}

游戏 ID 用作键,从缓存中检索游戏对象。调用SetAsync方法将对象添加到缓存。如果对象已经缓存,则使用新值更新。通过DistributedEntryCacheOptions类型的附加参数,可以配置对象以指定对象应在缓存中停留的时间。在这里,我们需要使用用户从一个动作到另一个动作所需的典型时间。每次检索和更新时,滑动过期重新开始。我们可以在这里指定此值,也可以配置默认值。

当创建游戏(StartGameAsync)以及设置游戏移动(SetMoveAsync)后,需要从GamesService类调用UpdateGameInCacheAsync方法。

StartGameAsync方法中的实现如下所示:

Codebreaker.GameAPIs/Services/GamesService.cs

game = GamesFactory.CreateGame(gameType, playerName);
activity?.AddTag(GameTypeTagName, game.GameType)
  .AddTag(GameIdTagName, game.Id.ToString())
  .Start();
await Task.WhenAll(
  dataRepository.AddGameAsync(game, cancellationToken),
UpdateGameInCacheAsync(game, cancellationToken));
metrics.GameStarted(game);

可以并行写入数据库和缓存。我们不需要等待数据库写入完成,就可以将游戏对象添加到缓存并返回更快的答案。如果数据库失败,游戏是否缓存并不重要。

要从缓存中读取数据,我们需要实现GetGameFromCacheOrDataStoreAsync

Codebreaker.GameAPIs/Services/GamesService.cs

// code removed for brevity
private async Task<Game?> GetGameFromCacheOrDataStoreAsync(
  Guid id, CancellationToken cancellationToken = default)
{
byte[]? bytesGame = await distributedCache.GetAsync(id.ToString(), 
  cancellationToken);
  if (bytesGame is null)
  {
    return await dataRepository.GetGameAsync(id, cancellationToken);
  }
  else
  {
    return bytesGame.ToGame();
  }
}

缓存中的GetAsync方法返回缓存的字节数组,然后使用ToGame方法进行转换。如果数据在缓存中不可用(可能因为已经分配了太多内存而将项目从缓存中移除,或者如果用户思考下一步棋的时间过长),我们将从数据库中获取游戏。源代码仓库中的代码包括一个标志,您可以通过它关闭从缓存中读取,以便轻松尝试不使用缓存的不同负载,以检查结果。

GetGameFromCacheOrDataStoreAsync需要从SetMoveAsyncGetGameAsync方法中调用。

配置 Aspire Redis 组件

关于game-apis项目,我们需要添加.NET Aspire StackExchange Redis 组件来配置 DI 容器:

Codebreaker.GameAPIs/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  // code removed for brevity
  builder.Services.AddScoped<IGamesService, GamesService>();
  builder.AddRedisDistributedCache("redis");
}

AddRedisDistributedCache方法使用需要与 Aspire App Host 项目配置的缓存名称来获取连接字符串和配置值。使用此方法,还可以以编程方式指定配置值。

最后,在 AppHost 项目中使用app-model配置 Redis 资源的 Docker 容器:

Codebreaker.AppHost/Program.cs

var redis = builder.AddRedis("redis")
  .WithRedisCommander()
  .PublishAsContainer();
var cosmos = builder.AddAzureCosmosDB("cosmos")
    .AddDatabase("codebreaker");
var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithReference(cosmos)
  .WithReference(redis)
  .WithReference(appInsights)
  .WithEnvironment("DataStore", dataStore);

AddRedis方法配置使用redis Docker 镜像为此服务。这需要使用PublishAsAzureRedis API 而不是PublishAsContainer进行配置。此方法配置WithRedisCommander的 PaaS 提供程序,为app-model添加 Redis 的管理 UI。

在此配置下,通过机器人运行游戏提供 图 12.2 中所示的结果。即使在使用本地系统上的低负载时,写入 SQL Server 需要 5.96 毫秒,写入缓存需要 1.83 毫秒。两者都在 Docker 容器中运行:

图 12.2 – 使用分布式缓存设置移动

图 12.2 – 使用分布式缓存设置移动

接下来,让我们给 game-apis 项目添加一些负载,以查看资源消耗。

使用 Azure Load Testing 模拟用户

第十章 中,我们创建了用于创建负载测试的 Playwright 测试。这些 Playwright 测试允许我们使用 .NET 代码轻松创建一个完整的流程,以便我们可以从测试中玩游戏。使用 Microsoft Azure,我们可以使用另一个服务来创建测试,并获取与 Azure 服务的集成分析:Azure Load Testing。

注意

在撰写本文时,Microsoft Playwright 测试云服务非常适合测试 Web 应用程序的负载。然而,它不支持 API 的负载测试,因此我们将在此使用 Azure Load Testing。您仍然可以使用 Azure 计算(例如,Azure 容器实例)来运行 Playwright 测试,但 Azure Load Testing 具有更好的报告配置和报告功能。

在创建负载测试之前,请确保使用 azd up 将解决方案部署到 Microsoft Azure。查看本章节的 README 文件以获取有关不同 azd 版本更多详细信息。

在 Azure 资源创建后,在 Azure 门户中打开 game-apis Azure 容器应用,并从左侧栏选择 应用程序 | 容器。容器的资源分配将显示为 0.5 CPU 核心1 Gi 内存

现在,让我们确保第一次测试只使用一个副本。

缩放到一个副本

缩放和副本可以扩展到 300 个实例。默认配置是从 1 扩展到 10。创建带有许多用户的负载将自动扩展并启动多个实例。要查看单个实例的限制,将缩放更改为 Min 副本和 Max 副本都仅为一个实例,如图 图 12.3 所示。点击创建将创建应用程序的新版本,并在之后取消预配现有版本:

图 12.3 – 使用 Azure 容器应用更改副本

图 12.3 – 使用 Azure 容器应用更改副本

要在部署时指定缩放,创建 YAML 模板以指定 Azure 容器应用的配置。启动一个终端,将当前目录设置为解决方案,并从 Azure 开发者 CLI 中运行以下命令(在您使用 azd init 初始化解决方案之后):

azd infra synth

此工具使用 app-model 清单创建 Bicep 文件以部署 app-model 的 Azure 资源(在根 infra 文件夹中)。AppHost 项目的 infra 文件夹包含描述已创建的每个 Azure 容器应用(从项目和 Docker 镜像)的 YAML 模板。参见 第六章 了解有关 Bicep 文件的更多详细信息。

在 AppHost 项目中,您会看到已生成一个 <app>.tmpl.yaml 文件,用于指定 Azure 容器应用的设置。

默认情况下,最小副本数设置为 1。使用 bot-service,您可以更改配置:

  template:
    containers:
# code removed for brevity
    scale:
minReplicas: 0
      maxReplicas: 1

使用 bot-service,为了降低成本,可以将缩放范围定义为从 01。当最小实例计数设置为 0 时,服务没有成本。只需注意,启动服务需要几秒钟,第一个访问服务的用户需要等待。因为机器人不是由游戏玩家调用的,而且这个服务并不总是需要的,它可以缩小到 0。game-apis 服务应该始终快速返回答案;因此,最小缩放应设置为 1。如果没有服务负载,会有闲置费用。这样,CPU 的成本可以降低到正常成本的约 10%,但内存(应用程序仍然加载在内存中)的价格是正常的。为了测试精确一个副本的负载,将 game-apis 服务的最小和最大值设置为 1。稍后,在扩展时,我们将再次增加最大副本的数量。

在 YAML 文件中更改副本数量后,可以使用 az up 或仅使用 az deploy 重新部署应用程序。

我们还需要确保数据库允许所需的请求。通过负载测试,我们可以预期我们需要超过 400 RU/s。在第一次测试运行之前,将 Azure Cosmos DB 的吞吐量更改为自动缩放,最大值为 1,000 RU/s。

现在,我们已准备好创建测试。

创建基于 Azure URL 的负载测试

要创建新的负载测试,使用 Azure 门户创建 Azure Load Testing 资源。指定资源组名称和资源名称。

一旦资源可用,在门户中打开它,并在左侧栏中选择 测试 | 测试。然后,在选择 创建基于 URL 的测试 后点击 创建。在 基本 选项卡下,指定 测试名称测试描述 的值,并勾选 启用高级设置 复选框,如图 图 12 所示。4*:

图 12.4 – 负载测试 – 基本设置

图 12.4 – 负载测试 – 基本设置

选择 启用高级设置 后,可以创建包含最多五个 HTTP 请求的测试计划。因此,在 测试计划 部分添加五个请求,如图 图 12 所示。5*:

图 12.5 – 负载测试 – 测试计划

图 12.5 – 负载测试 – 测试计划

第一个请求是创建游戏的 POST 请求。第二个是更新游戏移动的 PATCH 请求。接着是一个获取游戏信息的 GET 请求,一个结束游戏的 PATCH 请求,以及一个删除游戏的 DELETE 请求。

这些请求可以很容易地通过 UI 进行配置,如图 图 12.6 所示:

图 12.6 – 压力测试 – 添加请求

图 12.6 – 压力测试 – 添加请求

你可以从 OpenAPI 描述或 HTTP 文件中获取请求信息,也可以将此章节的 README 文件中的请求复制到 Body 区域。请求及其 HTTP 头部信息已列出。确保在指定 URL 时使用指向你的 Azure 容器应用的链接。

在 POST 请求中,不仅指定正文,还要定义响应的使用。JSON 结果返回 id;这可以通过 $.id 表达式访问。将其设置为 gameId 变量。响应变量可以在后续请求中使用——并且所有后续请求都需要游戏 ID。在设置游戏移动时,使用 ${gameId} 将游戏 ID 传递到 URL 字符串和 HTTP 正文。你可以查看此章节的 README 文件以获取有关不同请求应指定哪些值的更多详细信息。

在下一对话框中,如 图 12.7 所示,可以指定负载:

图 12.7 – 压力测试 – 指定负载

图 12.7 – 压力测试 – 指定负载

在这里,我们将从 5 个并发虚拟用户开始测试,并使用更多用户负载和多个引擎实例进行其他测试,起始时间为 0.3 分钟。一个测试引擎实例可以指定多达 250 个虚拟用户,并且使用 10 个实例可以增加到 2,500 个虚拟用户。配置还允许你指定负载模式值,该值会随着时间的推移增加负载。进行多次不同用户数量的测试运行可以很好地指示应使用哪些缩放规则来增加服务实例的数量。

注意在测试时,使用 2,500 个虚拟用户和 10 个后台虚拟机可能产生的成本。与迄今为止我们使用的其他资源不同,使用这个方法,你很容易就会超过 Visual Studio Enterprise Azure 订阅或免费 Azure 订阅的订阅限制。幸运的是,我们只需为测试运行的时间付费,而无需为仅需要短时间的物理机器付费。

注意

不要假设虚拟用户与真实用户相同。与 Azure 负载测试中的虚拟用户相比,真实用户产生的负载要少得多。真实用户在移动之间需要思考。每次移动之间可能需要几秒钟,甚至几分钟。虚拟用户只是连续调用 API。在幕后使用的 JMeter 测试中,虚拟用户的数量配置了要使用的线程数。您可以将虚拟用户与真实用户相比计算出多少真实用户取决于应用程序的类型。您需要找出在监控生产中的应用程序时,真实用户平均思考的时间有多长。在第十章中,我们创建了自定义指标数据来监控移动之间的时间;这是一个很好的值来使用。

在测试标准配置(见图图 12.8)中,您可以指定测试何时应该失败 – 例如,当响应时间过长时。在进行第一次测试运行之前,您可以留空测试标准以查看低负载下达到的值:

图 12.8 – 压力测试 – 测试标准

图 12.8 – 压力测试 – 测试标准

对于最后一个配置,打开监控设置,如图图 12.9所示:

图 12.9 – 指定监控资源

图 12.9 – 指定监控资源

选择参与测试的资源,例如游戏 APIs 和 Redis Azure 容器应用,以及 Azure Cosmos DB 资源。您可以根据资源组轻松过滤资源。

现在,我们已经准备好运行测试。

使用虚拟用户运行负载

在创建和修改测试后,点击保存后,您需要等待 JMeter 脚本创建完成;否则,测试将无法启动。要运行测试,请点击运行按钮并输入测试描述 – 例如,5 个用户 0.5 核心。

测试完成后,您将看到来自测试引擎的客户端指标和来自所选 Azure 容器应用的端点服务的服务器端指标。

当我进行测试运行时,发送了 7,834 个请求(远多于五个人在 2 分钟内会做的请求),并且使用了高达 0.49 个 CPU 核心和 354 MB 的内存。90%的请求响应时间低于 116 毫秒,吞吐量为每秒 67.53 个请求。

注意

不要期望多次运行会得到相同的结果。许多依赖项运行这些测试。使用不同 Azure 服务之间的网络性能和延迟如何?对于我的测试,我在服务运行的同一天 Azure 区域创建了 Azure Load Testing 服务。即使在同一 Azure 区域,不同的资源也可能在相同或不同的数据中心中运行。这些差异不是问题。用户将位于 Azure 数据中心之外。我们需要知道一个实例可以服务多少用户,以及最佳的应用程序设置是什么,例如 CPU 和内存资源(扩展)或运行多个副本(扩展)。我们还需要看到真正的瓶颈是什么,以及什么可以控制。

图 12.10 显示了每次 API 调用的响应时间结果:

图 12.10 – 五个虚拟用户的响应时间

图 12.10 – 五个虚拟用户的响应时间

使用五个虚拟用户时,考虑到所有请求,响应时间是可以接受的。有趣的是,使用 Azure Cosmos DB,删除请求需要最长时间来完成。

五个虚拟用户是一个良好的起点,但让我们增加更多的负载。

在更高负载下达到限制

要更改测试的负载,您可以编辑它。为此,点击 25。点击 应用 并等待 Azure 门户中的 JMeter 脚本创建带有 通知。此时,您可以再次开始测试。

在我的测试运行中,将虚拟用户数量增加到 25 仅导致 11.701 个总请求,每秒 98.39 个请求。创建游戏的请求需要 289 毫秒,90% 分位数。图 12.11 显示了此测试每秒的请求数量:

图 12.11 – 每秒请求数量

图 12.11 – 每秒请求数量

将结果与五个用户的测试运行进行比较,使用 25 个用户仅导致总请求和每秒请求量略有增加。因此,创建游戏的时间从 96 毫秒增加到 498 毫秒。这不是一个好的结果。为什么会这样?服务器端指标没有达到 Azure Container Apps 的 CPU 核心和内存限制。限制不是 Azure Container Apps 的,而是 Azure Cosmos 数据库的,如图 图 12.12 所示:

图 12.12 – Azure Cosmos DB RU 消耗

图 12.12 – Azure Cosmos DB RU 消耗

当使用 Azure Cosmos DB 运行此测试时,RU 被配置为 autoscale 吞吐量,并达到了最大 1,000 RU/s 限制。这可以在前面的图中看到。您还可以在 App Insights 中的 Application Map 中深入了解,并检查不同的 Azure Cosmos DB 指标,如图 图 12.13 所示。13*:

图 12.13 – 使用 Azure Cosmos DB 限制请求

图 12.13 – 使用 Azure Cosmos DB 限制请求

如我们所见,请求已被限制;game-apis服务返回了错误代码 429。您可以使用Kusto 查询语言KQL)来查询这些日志消息(有关 KQL 的更多信息,请参阅第十一章):

ContainerAppConsoleLogs_CL
| where  Log_s contains "Request rate is large"

返回的完整日志消息为“请求速率过高。可能需要更多请求单元,因此没有进行更改。请稍后重试此请求。更多信息:http://aka.ms/cosmosdb-error-429.

在本章早期,我们通过使用缓存删除数据库中不需要的请求来减少 Azure Cosmos DB 的负载。如果没有这个更改,限制会更早地被触及。

虽然错误代码 429 已返回到game-apis服务,但由于.NET Aspire 内置的重试配置,调用结果仍然成功。但当然,请求所需的时间增加了。

在创建测试时,我们确保可以看到测试中所有资源的指标数据。这就是为什么我们可以通过测试运行看到 Cosmos 指标,并且可以轻松修复它。让我们使用 Cosmos DB 来增加 RU/s。最大值为 1,000 RU/s,最小值为 100。将最大值增加到 10,000 将最小值设置为 1,000。确保您在测试运行期间仅将最大值更改为更高的值,并在需要时进行更改。您可以在调整 RU/s 的对话框中查看预期的成本。确保您在不再需要时减少缩放限制。可以将最大值设置为 1,000,000 RU/s,这将最小值设置为 100,000。在点击保存按钮之前,您可以在更改此吞吐量时查看价格范围。请注意,当更改超过 10,000 RU/s 的最大值时,此计算能力可能需要 4 到 6 小时才能可用。

在 25 个虚拟用户的情况下,我们达到了 1,000 RU/s 的限制。因此,让我们将其增加到 10,000 RU/s。如果不需要这么多 RU/s 来满足特定数量的用户,我们将在测试结果中看到这一点,并在测试运行后根据我们的需求调整设置。

在增加 RU/s 限制并再次使用 25 个虚拟用户运行测试后,Azure Cosmos DB 不再是瓶颈。只有 12%的 RU/s 正在使用。因此,让我们将虚拟用户数量增加到 50。

扩展服务或扩展服务

让我们使用 50 个虚拟用户运行测试,并比较在增加 CPU 和内存以及增加副本数量时性能如何不同。

配置扩展

要进行扩展,我们必须增加 CPU 和内存值。

注意

当使用 azd up 创建容器应用时,会创建一个 基于消费的 环境。还有创建带有 专用硬件工作负载配置文件 的选项。当使用专用硬件时,您可以选择将要使用的虚拟机类型。在撰写本书时,虚拟机分为类别 D(通用型,4 – 32 核心,16 – 128 GiB 内存)和 E(内存优化型,4 – 32 核心,32 – 256 GiB 内存,并带有最多 4 个 GPU)。机器类型还定义了可用的网络带宽。根据您的工作负载,有许多不错的选择。

要在 Azure 门户中更改 CPU 和内存,在容器应用中,从左侧栏选择 容器,点击 编辑和部署 按钮,选择容器镜像,然后点击 编辑。这将打开容器编辑器,您可以在其中选择 CPU 核心和内存,如图 图 12**.14 所示:

图 12.14 – 编辑容器的设置

图 12.14 – 编辑容器的设置

在这里,我们将更改 CPU 和内存值。当使用基于消费的环境时,请注意配置需要映射,例如,0.5 核心 1.0 Gi 内存,1.0 核心 2.0 Gi 内存,最高到 2.0 核心 4.0 Gi 内存。在消费工作负载配置文件中,您可以使用一个容器拥有高达 4.0 核心 8.0 Gi 内存。

我们也可以使用 YAML 模板文件进行配置:

template:
  containers:
    - image: {{ .Image }}
      name: gameapis
      resources:
        cpu: 1.0
        memory: 2Gi
# configuration removed for brevity

CPU 和内存资源在 resources 类别中指定。在确定最佳配置后,使用 YAML 文件指定它将创建正确的大小。

对 50 个用户进行 2 分钟的负载测试,基于以下配置显示以下结果:

总请求 吞吐量 创建 游戏响应 10,000 RU/s
0.5 核心,1 Gi 12,015 100.13/s 491 ms 16%
1 核心,2 Gi 20,621 171.84/s 383 ms 24%
2 核心,4 Gi 22,444 187.03/s 381 ms 26%

表 12.1 – 扩展负载测试结果

使用这些配置,我们可以看到将计算资源增加到 1 核心 2 Gi 内存会带来改进,而再次复制计算资源只会带来小幅改进。

现在,让我们更改副本。

配置扩展

您在本章前面学习了如何更改副本数量。在本节中,我们将同时更改最小和最大计数到相同的值,这样我们就可以在不同的实例之间分配负载。

当测试使用 0.5 核心 1 Gi 内存时,我们收到以下计数:

总请求 吞吐量 创建 游戏响应 10,000 RU/s
1 副本 12,015 100.13/s 491 ms 16%
2 副本 16,291 135.76/s 490 ms 20%
4 副本 27,704 230.87/s 299 ms 34%

表 12.2 – 扩展负载测试结果

使用 0.5 核心和 1 Gi 的内存的两个副本与一个核心和 2 Gi 内存的一个副本使用的 CPU 和内存资源相同。一个核心的实例是更好的性能选择,有 20,621 个请求,而另一个有 16,291 个请求。通过添加更多副本,我们可以比仅添加 CPU 资源实现更高的缩放。

使用多个副本的一个大优点是我们可以根据负载动态缩放。我们将在下一节中创建缩放规则。缩放规则不允许我们更改 CPU 和内存资源。

在扩展多个实例时,你需要注意应用程序是否为此而设计。当在内存游戏存储中使用 Codebreaker 应用程序时,实现时考虑了多线程,但没有考虑多台机器。当一个用户开始游戏,而下一个用户设置移动时,第一个请求可能会访问存储在内存中的第一台机器,而第二个请求可能会访问设置移动的游戏不可用的第二台机器。提供分布式内存的 Redis 缓存解决了这个问题。本章 GitHub 仓库中提供的示例应用程序包括DistributedMemoryGamesRepository类,它可以配置为DataStore配置设置为DistributedMemory。为了在本地开发环境中测试此功能,你可以更改 AppHost 项目:

Codebreaker.AppHost/Program.cs

var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithReference(cosmos)
  .WithReference(redis)
  .WithReference(appInsights)
  .WithEnvironment("DataStore", dataStore)
  .WithEnvironment("StartupMode", startupMode)
  .WithReplicas(2);

当在配置项目时添加WithReplicas方法,可以指定副本的数量。当值为2时,在本地运行解决方案时,.NET Aspire 仪表板(图 12**.15)显示运行的两个game-apis服务实例。每个服务都有一个端口号,允许访问特定的实例。公共端口号9400是引用运行在端口号4937949376game-apis服务实例的代理客户端的端口号。代理使用的端口号由launchsettings.json文件定义,而实例的端口号在新的应用程序启动时随机更改:

图 12.15 – game-apis 的两个副本

图 12.15 – game-apis 的两个副本

现在我们已经了解了在运行多个副本时可以进行的改进,让我们进行动态缩放。

使用缩放规则动态缩放

使用 Azure Container Apps,可以根据并发 HTTP 请求、并发 TCP 请求或自定义规则定义缩放规则。使用自定义规则,缩放可以基于 CPU、内存或基于不同数据源的许多事件。

一个微服务不一定基于 HTTP 请求触发。该服务也可以异步触发,例如,当队列中到达消息时(例如,使用 Azure 存储队列或 Azure 服务总线)或当事件发生时(例如,使用 Azure 事件中心或 Apache Kafka)。

Azure 容器应用的缩放规则基于 Kubernetes 事件驱动自动缩放KEDA),提供了一长串的缩放器。您可以在 keda.sh 找到完整的列表。

当使用 Azure Service Bus 队列与 KEDA 缩放器一起使用时,您可以指定在启动另一个副本时应有多少条消息在队列中。所有 KEDA 缩放器共有的特点是轮询间隔的配置 – 检查值的频率(默认为 30 秒),一个用于计算副本数量的缩放算法,以及冷却期(300 秒) – 在副本启动后可以再次停止副本之前的时间。

第十五章 中,我们将使用消息和事件进行通信,自动缩放将基于基于事件的 KEDA 缩放器。

当我们使用固定数量的实例测试负载时,我们看到最佳的服务扩展选项是使用 HTTP 请求的数量。因此,让我们使用 HTTP 规则配置扩展。我们可以通过使用 Azure 门户和由 azd infra synth 生成的模板 YAML 文件来完成此操作。这是 Azure 门户中 JSON 内容的输出:

"template": {
      ...
      "scale": {
        "minReplicas": 1,
        "maxReplicas": 8,
        "rules": [{
          "name": "http-rule",
          "http": {
            "metadata": {
              "concurrentRequests": "30"
            }
          }
        }]
      }
    }

默认的 HTTP 规则与 10 个并发请求成比例。根据测试结果,我们将此值设置为 30。副本的数量在 18 之间。关于 HTTP 缩放的重要信息是,每 15 秒计算一次请求数量。将过去 15 秒内的总请求数除以 15,以便可以将其与 concurrentRequests 值进行比较。据此,计算副本的数量。因此,如果每秒有 140 个请求,实例计数将设置为 5。

当应用此缩放规则且实例处于活动状态时,我们可以配置具有动态模式配置的负载测试,如图 图 12.16 所示:

图 12.16 – 步骤负载模式

图 12.16 – 步骤负载模式

在此步骤负载模式下,使用两个引擎实例启动 200 个虚拟用户。完整的测试持续时间是 4 分钟。爬坡时间定义了达到 200 个虚拟用户所需的时间 – 使用 5 个爬坡步骤以 40 的增量增加用户。

测试运行完成后,您可以看到虚拟用户随时间增加的情况,如图 图 12.17 所示:

图 12.17 – 200 虚拟用户

图 12.17 – 200 虚拟用户

每秒请求数量显示在 图 12.18 中:

图 12.18 – 每秒请求数量

图 12.18 – 每秒请求数量

图 12.19 显示了响应时间,时间在下午 2:17 开始变长。您有什么想法吗?

图 12.19 – 响应时间

图 12.19 – 响应时间

答案是,使用这种负载,我们达到了 Azure Cosmos 数据库的 10,000 RU/s 限制,如图 图 12.20 所示:

图 12.20 – 达到 10,000 RU/s

图 12.20 – 达到 10,000 RU/s

在下午 2:16 之后达到了最大 RU/s。当达到这个限制时,不会立即返回“请求过多”的响应。

看到我们创建的缩放规则的副本计数也很有趣。这显示在 图 12.21 中:

图 12.21 – 副本计数

图 12.21 – 副本计数

副本计数从 1 开始,增加到 8 – 最大配置的副本数。图 12.19 显示了另一个问题,而不是 RU/s 限制。启动后,响应时间有一些峰值。这对应于 图 12.18 中每秒请求量的减少。在这次测试运行之后,我不得不深入调查这个问题。指标数据没有揭示原因,但检查 ContainerAppSystemLogs_CL 表中的日志提供了关于问题是什么的信息。当时,是时候启动一个新的副本了。根据日志,分配了新的副本,拉取了镜像,并创建了一个容器 – 但 启动探测 失败,副本不健康。不会向这样的副本发送请求。因此,对于我们所生成的负载,我们仍然只有一个副本。在第三个副本成功之前,有缺陷的副本启动了两次。这就是为什么增加副本计数比预期花费的时间更长,这也是为什么开始时出现峰值的原因。之后,每个新创建的副本都立即成功。如果你有特定副本的问题,你可以使用副本的名称来查询日志:

ContainerAppSystemLogs_CL
| where ReplicaName_s == "gameapis--tc47v0x-7cb88d64b8-np8cg"
| order  by  time_t asc

接下来,我们将深入了解健康检查。这将帮助你理解启动探测。

实现健康检查

托管平台需要知道服务是否成功启动并且可以处理请求。当服务正在运行时,托管平台会持续检查服务是否正在运行或已损坏并需要重启。这就是健康检查的目的。

使用 Kubernetes,可以配置三个探测:

  • 启动: 容器是否已准备好并且已启动?当这个探测成功时,Kubernetes 将切换到其他探测。

  • 活跃性: 应用程序是否崩溃或死锁?如果失败,则停止 pod,并创建一个新的容器实例。

  • 就绪性: 应用程序是否准备好接收请求?如果失败,则不会向此服务实例发送请求,但 pod 仍然运行。

由于 Azure Container Apps 基于 Kubernetes,因此可以使用此 Azure 服务配置这三个探测。

将健康检查添加到 DI 容器

健康检查可以通过 AddDefaultHealthChecks 扩展方法中的 DI 容器进行配置:

Codebreaker.ServiceDefaults/Extensions.cs

public static IHostApplicationBuilder AddDefaultHealthChecks(this IHostApplicationBuilder builder)
{
  builder.Services.AddHealthChecks()
    .AddCheck(
      "self",
      () => HealthCheckResult.Healthy(),
      tags: ["live"]);
  return builder;
}

AddHealthChecks 方法将 HealthCheckService 类注册到 DI 容器中,该容器将可用于健康检查。AddHealthChecks 方法可以多次调用以访问 IHealthChecksBuilder,它用于注册不同的检查实现。流畅调用的 AddCheck 方法使用委托在调用时返回 HealthCheckResult.Healthy 结果。最后一个参数定义了 "live" 标签。标签与中间件一起使用,以指定应使用哪个路由进行此健康检查。正如其名所示,此标签非常适合 liveness 探针。当服务可访问时,会返回一个结果。如果服务不可用,则不返回任何内容,因此它将被重新启动。名称 self 表示仅使用此健康检查本身,而外部资源仅在就绪健康检查中咨询。

game-apis 服务的启动时,使用 Azure Cosmos DB 创建容器,如果数据库模式已更新,则可以使用 SQL Server 进行数据库迁移。在此完成之前,应用程序尚未准备好接收请求。对于某些应用程序,在接收请求之前需要用参考数据填充缓存。为此,必须使用数据库更新代码定义一个布尔标志,该标志在更新完成后设置。让我们向 game-apis 的 DI 容器配置中添加一个健康检查:

Codebreaker.GameAPIs/Program.cs

builder.Services.AddHealthChecks().AddCheck("dbupdate", () =>
{
  return ApplicationServices.IsDatabaseUpdateComplete ?
    HealthCheckResult.Healthy("DB update done") :
    HealthCheckResult.Degraded("DB update not ready");
});

在此实现中,如果标志(IsDatabaseUpdateComplete 属性)为真,则返回 Healthy,否则返回 Degraded

当你尝试此操作时,数据库迁移可能太快而无法看到降级结果——特别是如果数据库已经创建的话。在 Codebreaker.GameAPIs/ApplicationServices.cs 文件中将数据库迁移添加延迟有助于这里,因为你可以查看不同的健康结果。

注意

健康检查应快速实现。这些检查调用频率很高,不应导致大的开销。通常,这些检查涉及打开连接或检查要设置的标志。

使用 .NET Aspire 组件添加健康检查

所有 .NET Aspire 组件都启用了健康检查功能——如果组件支持健康检查。请查阅有关这些组件的文档以了解更多信息。

与指标和遥测配置类似,健康检查可以通过组件的配置设置启用和禁用。

这可以通过编程方式完成:

Codebreaker.GameAPIs/ApplicationServices.cs

            builder.Services.AddDbContextPool<IGamesRepository, GamesSqlServerContext>(options =>
{
  var connectionString = builder.Configuration.GetConnectionString("CodebreakerSql") ??
    throw new InvalidOperationException("Could not read SQL Server connection string");
  options.UseSqlServer(connectionString);
  options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
});
builder.EnrichSqlServerDbContext<GamesSqlServerContext>(
  static settings =>
  {
    settings.DisableTracing = false;
    settings.DisableRetry = false;
    settings.DisableHealthChecks = false;
  });

.NET Aspire SQL Server EF Core EnrichSqlServerDbContext 组件方法的参数允许我们覆盖组件设置的默认值,例如指标、跟踪和健康检查。

我们还可以使用以下 .NET Aspire 配置来指定此功能:

{
  // code removed for brevity
  "Aspire": {
    "Microsoft": {
      "EntityFrameworkCore": {
        "SqlServer": {
         "DbContextPooling": true,
         "DisableTracing": false,
         "DisableHealthCheck": false,
         "DisableMetrics": false
       }
     }
   },
   "StackExchange": {
     "Redis": {
       "DisableTracing": false,
       "DisableHealthCheck": false
     }
   }
 }

此配置显示了 Redis 和 SQL Server EF Core 组件的设置。这两个组件都与就绪探针集成。这些健康检查做了什么?没有对数据库进行读取或写入。健康检查应该是快速的,并且不会对系统造成高负载。Redis 组件尝试打开连接。SQL Server EF Core 组件调用 EF Core 的 CanConnectAsync 方法。您需要注意,当 Azure Container App 的空闲定价缩放到 1 时,带有自定义健康检查,它可能永远不会空闲。

使用此类配置确保可以在不重新编译项目的情况下进行更改。

现在我们已经实现了和配置了健康检查,让我们将这些映射到 URL 请求。

映射健康检查

将健康链接映射到 URL 允许我们访问它们。共享的 Codebreaker.ServiceDefaults 文件包含使用 MapDefaultEndpoints 方法配置的健康端点:

Codebreaker.ServiceDefaults/Extensions.cs

public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
  // code removed for brevity
  app.MapHealthChecks("/alive", new HealthCheckOptions
  {
    Predicate = r => r.Tags.Contains("live")
  });
  app.MapHealthChecks("/health");
  return app;
}

/alive 探针链接已配置为仅使用带有 live 标签的健康检查,因此健康检查用于检查服务是否存活。此链接应配置为实时探测,如果服务没有返回 Healthy,则应重新启动服务。

/health 探针链接已配置为不根据标签限制健康检查。在这里,所有健康检查都会被调用,并且需要成功。此链接应用于就绪探针:服务是否准备好接收请求?如果返回 UnhealthyDegraded,则服务没有停止,但不会接收请求。

注意

您可能想知道为什么 .NET Aspire 不使用 /healthz 链接进行就绪探针,因为它通常与 Kubernetes 一起使用。/healthz 历史上来自 Google 的内部实践,z-pages,因此不会发生冲突。.NET Aspire 团队对不同的链接进行了多次迭代,包括 /liveness/readiness,最终确定为 /alive/health

现在我们已经将健康检查映射到 URI,让我们使用这些链接。

使用 Azure Container Apps 进行健康检查

使用 Azure Container Apps 生成探针配置后,您可以自动集成健康探针。您还可以使用这些健康探针与 Azure 仪表板一起使用。然而,这不在 .NET Aspire 的第一个版本中提供,计划在以后的版本中提供。但是,通过一点定制,这可以轻松完成。

为了使其工作,您需要使用 azd init 初始化解决方案,您之前已经这样做,然后在将解决方案发布到 Azure 之前。现在,从包含解决方案的文件夹中创建将发布这些项目的代码:

azd infra synth

通过这种方式,AppHost 项目包含一个 infra 文件夹,其中包含 <app>.tmpl.yaml 文件。在 gameapis.tmpl.yaml 文件中,指定 probes 部分:

  template:
    containers:
      - image: {{ .Image }}
        name: gameapis
        probes:
          - type: liveness
            httpGet:
              path: /alive
              port: 8080
            initialDelaySeconds: 3
            periodSeconds: 3
          - type: readiness
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
          - type: startup
            tcpSocket:
              port: 8080
            initialDelaySeconds: 1
            periodSeconds: 1
            timeoutSeconds: 3
            failureThreshold: 30
        env:
        - name: AZURE_CLIENT_ID
# configuration removed for brevity

probes 部分允许配置 livenessreadinessstartup 探测类型。liveness 探测配置为调用 /alive 链接,而 readiness 探测配置为调用带有运行 Docker 容器端口的 /health 链接。Azure Container Apps 有默认设置。然而,一旦指定了探测,就需要配置所有探测类型;否则,未配置的探测将被禁用。因此,当指定 livenessreadiness 探测时,也应配置 startup 探测。此探测使用 TCP 连接连接到服务以验证连接是否成功。

initialDelaySeconds 指定了等待直到第一次探测完成所需的秒数。如果这失败了,将在 periodSeconds 指定的秒数之后进行额外的检查。只有当达到 failureTreshold 时,失败才会计数。

默认启动探测使用 TCP 探测,检查入口目标端口,初始延迟为 1 秒,超时为 3 秒,周期为 1 秒。随着失败阈值的增加,这会乘以,应用可能需要一些时间才能成功启动。一旦 startup 探测成功一次,之后只使用 livenessreadiness 探测。

进行此更改后,从解决方案文件夹中运行以下命令:

azd deploy

这将部署服务到 Azure 并配置健康检查。

打开 Azure 门户,导航到 Azure Container Apps,并从左侧面板中选择 容器。你会看到一个名为 健康探测 的选项卡,如图 图 12.22 所示:

图 12.22 – Azure Container Apps 中的健康探测

图 12.22 – Azure Container Apps 中的健康探测

在这里,我们可以看到门户中配置的 liveness 探测的设置。你还可以验证 readinessstartup 探测。图 12.23 显示了容器的状态:

图 12.23 – 容器应用正在运行但尚未就绪

图 12.23 – 容器应用正在运行但尚未就绪

在这里,有一个副本正在运行,但这个副本尚未就绪。当时的 readiness 探测没有返回成功。如果你配置了 Redis 组件使用 .NET Aspire 提供健康检查,你可以使用 Azure Container Apps 环境停止此容器。你会看到 game-apis 服务尚未就绪。因为 game-apis 服务启用了组件健康检查,所以健康检查返回了错误。

摘要

在本章中,你学习了如何使用遥测数据并实现缓存以减少数据库请求的数量。你创建了健康检查,区分了启动、存活性和就绪性检查。存活性检查用于重启服务,而就绪性检查用于验证服务是否准备好接收请求。关于就绪性检查,你学习了如何集成 .NET Aspire 组件。你还学习了如何从负载测试中获取信息以找到已部署应用程序中的瓶颈,并决定你希望使用的架构。通过这样做,你学习了如何配置应用程序,使其在 CPU 和内存发生变化时进行扩展,以及如何使用扩展规则在运行多个副本时进行横向扩展。

本章揭示了使用微服务的一个重要原因:随着规模的扩大,可以轻松实现极大的灵活性。

下一章将作为一个起点,并实现与微服务不同的通信技术。当向你的应用程序添加更多功能时,你需要考虑在测试环境中对解决方案进行持续负载测试并监控变化。

进一步阅读

要了解更多关于本章讨论的主题,请参阅以下链接:

第四部分:更多通信选项

在本部分,重点转向利用各种通信技术以及整合额外的 Azure 服务来增强应用程序。通过 SignalR 实现实时消息功能,将应用程序到客户端的即时更新传递。使用 gRPC 进行服务间的有效二进制通信,通过队列和事件发布实现无缝的消息交换。Azure 服务如 Azure SignalR 服务、事件中心、Azure 队列存储和 Apache Kafka 被集成到应用程序生态系统中。此外,还提供了针对生产环境考虑因素的详细审查,最终将应用程序部署到 Kubernetes 集群,特别是 Azure Kubernetes 服务,利用 Aspir8

本部分包含以下章节:

  • 第十三章使用 SignalR 进行实时消息传递

  • 第十四章二进制通信的 gRPC

  • 第十五章**,使用消息和事件进行异步通信

  • 第十六章**,在本地和云端运行应用程序

第十三章:使用 SignalR 进行实时消息传递

现在我们已经创建了各种测试,添加了遥测数据,监控了解决方案,并扩展了我们的服务,从本章开始,我们将继续创建服务和使用不同的通信技术。

在本章中,我们将使用 ASP.NET Core SignalR。SignalR 是一种技术,它允许我们从服务向客户端发送实时信息。客户端向服务发起连接,然后保持连接状态,以便在信息可用时向客户端发送消息。

在本章中,你将学习以下内容:

  • 创建 SignalR 服务

  • 向客户端发送实时信息

  • 创建 SignalR 客户端

  • 使用 Azure SignalR 服务

技术要求

在本章中,就像之前的章节一样,你需要 Azure 订阅和 Docker Desktop。

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/

ch13 文件夹包含本章的项目及其结果。要添加本章的功能,你可以从上一章的源代码开始。

我们将考虑的项目如下:

  • Codebreaker.AppHost:这是 .NET Aspire 主项目。通过运行 SignalR 中心并使用 Azure SignalR 服务,应用程序模型得到了增强。

  • Codebreaker.Live:这是一个新项目,它托管由 game-apis 服务和 SignalR 中心调用的最小 API。

  • Codebreaker.GameAPIs:该项目已得到增强,可以将完成的游戏转发到 live-service

  • LiveTestClient:这是一个新的控制台应用程序,它注册到 SignalR 中心以接收完成的游戏。

创建 SignalR 服务

在本章中,我们将创建一个新的服务,该服务为每个连接的客户端提供正在玩的游戏的实时信息。图 13.1 展示了解决方案中服务的协作方式:

图 13.1 – Codebreaker 服务

图 13.1 – Codebreaker 服务

game-apis 服务自第二章以来就存在了。game-apis 的客户端通过 REST API 启动游戏并设置移动。新的内容是 Codebreaker 的 live-service。该服务提供了一个简单的 REST API,每次游戏完成后,game-apis 服务都会调用它。该服务的主要功能是利用 ASP.NET Core SignalR 向所有连接的客户端提供实时信息。客户端需要在接收游戏完成消息之前进行订阅。

SignalR 提供了什么?由于连接永远不能从服务器开始到客户端,SignalR 也是如此,因此客户端需要连接到 SignalR 服务并订阅以接收实时信息。连接保持打开状态,这使得服务可以向客户端发送信息。

ASP.NET Core SignalR 如果这项技术可用,将使用 WebSockets ——但使用 SignalR 的编程模型比直接使用 WebSocket 要简单得多。与 HTTP 相反,使用 WebSocket 时,由客户端发起的连接保持打开,这允许服务在有消息可用时发送消息。

WebSocket 并非总是可用,可能被代理或防火墙禁用,并且并非在所有地方都可用。例如,Azure Front Door 目前不支持 WebSocket。

如果 WebSocket 不可用,SignalR 将切换到其他通信技术,例如 text/event-stream 数据,并要求服务器保持连接活跃(通过 Connection: keep-alive HTTP 头)。现在所有现代浏览器都支持 SSE。使用轮询时,客户端会反复询问服务器是否有新数据可用,一次又一次地打开新的连接。长轮询是一种技术,通过服务器不立即返回信息,声称没有新信息可用来减少客户端的请求数量。相反,服务器等待超时几乎结束时才返回。如果在等待期间有新信息可用,则返回这些新信息。

使用 SignalR,无需更改编程结构来决定使用 WebSocket、SSE 或长轮询。这是由 SignalR 自动完成的。然而,在这些场景中,与简单的 HTTP 请求相比,服务器有更多的开销:与客户端的连接需要保持活跃,因此需要保存在内存中。为了从我们的服务中移除这种开销,我们将在本章后面使用 Azure SignalR 服务。

要创建一个 SignalR 服务,我们只需创建一个空的 ASP.NET Core Web 项目。因为这个服务也提供 REST API,让我们创建一个新的 Web API 项目:

dotnet new webapi --use-minimal-apis -o Codebreaker.Live

这创建了一个最小化的 API 项目,类似于我们为 game-apis 项目所拥有的项目。这个项目需要配置为 .NET Aspire 项目。使用 Visual Studio,使用 Codebreaker.Live 项目。然后,将 ServiceDefaults 项目的项目引用添加到实时项目中。

Codebreaker.Live 服务项目需要配置为 .NET Aspire 应用模型:

Codebreaker.AppHost/Program.cs

// else path using Azure services
var live = builder.AddProject<Projects.Codebreaker_Live>("live")
  .WithExternalHttpEndpoints()
  WithReference(appInsights)
 .WithEnvironment("StartupMode", startupMode);
var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithExternalHttpEndpoints()
  .WithReference(cosmos)
  .WithReference(redis)
  .WithReference(appInsights)
  .WithReference(live)
  .WithEnvironment("DataStore", dataStore)
  .WithEnvironment("StartupMode", startupMode);
  // code removed for brevity

通过这样做,项目被添加到应用模型中,并从 game-apis 服务中引用,因为它需要从 live-service 获取链接来调用游戏完成的 REST API。

接下来,我们将添加一个客户端可以使用的 SignalR 集线器。

创建 SignalR 集线器

SignalR 已经是 ASP.NET Core 的一部分,所以我们不需要添加另一个 NuGet 包。让我们添加 SignalR 集线器类——即 LiveHub

Codebreaker.Live/Endpoints/LiveHub.cs

public class LiveHub(ILogger<LiveHub> logger) : Hub
{
  public async Task SubscribeToGameCompletions(string gameType)
  {
    logger.ClientSubscribed(Context.ConnectionId, gameType);
    await Groups.AddToGroupAsync(Context.ConnectionId, gameType);
  }
  public async Task UnsubscribeFromGameCompletions(string gameType)
  {
    logger.ClientUnsubscribed(Context.ConnectionId, gameType);
    await Groups.RemoveFromGroupAsync(Context.ConnectionId, gameType);
  }
}

SignalR 中心类派生自 Hub 基类(Microsoft.AspNetCore.SignalR 命名空间)。这个类定义了 OnConnectedAsyncOnDisconnectedAsync 方法,这两个方法都可以被重写以响应客户端的连接和断开。在这里,我们定义了一个带有 gameType 参数的 RegisterGameCompletions 方法。此方法由 SignalR 客户端调用。

使用 SignalR,中心可以向所有客户端、单个客户端或一组客户端发送实时信息。在这个实现中,我们允许客户端注册到一个组。游戏类型用于区分不同的组。Hub 类定义了一个 Groups 属性来订阅和取消订阅一个组。AddToGroupAsync 方法将客户端添加到组中,而 RemoveFromGroupAsync 方法则从组中移除客户端。可以通过 ConnectionId 来识别已连接的客户端,这可以通过 Context 属性访问。

要向已连接的客户端发送信息,Hub 类提供了一个 Clients 属性,允许你向所有客户端发送(Clients.All.SendAsync)或一个组(Clients.Group("group-name").SendAsync)。然而,在这种情况下,我们需要从 game-apis 服务(在接收到 REST 调用之后)将信息发送到外部 LiveHub 类。我们将通过实现 LiveGamesEndpoints 类来完成这项工作。

将实时信息返回给客户端

LiveGamesEndpoints 类使用最小化的 API 来实现 REST 端点。特别之处在于它可以向已连接的客户端发送信息:

Codebreaker.Live/Endpoints/LiveGamesEndpoints.cs

public static class LiveGamesEndpoints
{
  public static void MapLiveGamesEndpoints(this IEndpointRouteBuilder routes, ILogger logger)
  {
    var group = routes.MapGroup("/live")
      .WithTags("Game Events API");
group.MapPost("/game", async (GameSummary gameSummary, 
      IHubContext<LiveHub> hubContext) =>
    {
      logger.LogInformation("Received game ended {type} {gameid}", 
        gameSummary.GameType, gameSummary.Id);
      await hubContext.Clients.Group(gameSummary.GameType).
        SendAsync("GameCompleted", gameSummary);
      return TypedResults.Ok();
    })
    .WithName("ReportGameEnded")
    .WithSummary("Report game ended to notify connected clients")
    .WithOpenApi();
  }
}

MapPost 方法接收的 GameSummary 类在 CNinnovation.Codebreaker.BackendModels NuGet 包中实现。这个类包含了一个完成游戏的摘要信息。除了这个 HTTP POST 请求体参数外,MapPost 方法还从 DI 容器接收一个 IHubContext<LiveHub> 实例。当 DI 容器配置为 SignalR 时,此接口被注册,以便检索到已注册中心以向客户端发送信息。使用 Clients.Group 方法,当传递组名时,会返回一个 IClientProxy 对象。然后,使用这个代理来发送带有游戏摘要的 GameCompleted 方法。

现在,我们只需要将 SignalR 和中心注册到 DI 容器和中间件中即可。

注册 SignalR 服务和中心

要使用 SignalR 并使中心作为端点可用,我们必须实现 ApplicationServices 类:

Codebreaker.Live/ApplicationServices.cs

public static class ApplicationServices
{
  public static void AddApplicationServices(this IHostApplicationBuilder builder)
  {
    builder.Services.AddSignalR();
    // code removed for brevity
  }
  public static WebApplication MapApplicationEndpoints(this WebApplication app, ILogger logger)
  {
    app.MapLiveGamesEndpoints(logger);
app.MapHub<LiveHub>("/livesubscribe");
    return app;
  }
}

AddApplicationServices 方法通过调用 AddSignalR 方法扩展 IHostApplicationBuilder,以注册 SignalR 所需的服务类。MapApplicationEndpoints 方法注册 SignalR 端点和最小 API 端点。通过传递具有 MapHub 方法泛型参数的 Hub 类来注册 SignalR 端点。/livesubscribe 是客户端用来连接此服务的链接。

ApplicationServices 类的方法是从 SignalR 项目的顶层语句中调用的:

Codebreaker.Live/Program.cs

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.AddApplicationServices();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapApplicationEndpoints(app.Logger);
app.Run();

通过调用由通用 ServiceDefaults 项目定义的 AddServiceDefaults 方法配置 DI 容器。这为解决方案中所有项目所需添加了 DI 容器注册。AddApplicationServices 方法添加了从 live-service 需要的服务,例如 SignalR。使用 builder.Build 通过 DI 容器得出所需的信息。应用程序实例开始配置中间件,这是 MapDefaultEndpointsMapApplicationEndpoints 被调用的地方。MapDefaultEndpoints 注册了如常见健康检查(在第 第十二章 中介绍)之类的链接。MapApplicationEndpoints 注册了由此服务项目提供的端点。

现在 live-service 已准备就绪,让我们从 game-apis 服务调用 API。

从游戏-apis 服务转发请求

回想一下之前的序列图。我们已更新此图以显示 game-apislive-service 之间的通信方式:

图 13.2 – 对 live-service 的 REST 调用

图 13.2 – 对 live-service 的 REST 调用

游戏完成后,game-apis 服务使用游戏完成信息调用 live-service。要调用 live-service,创建 LiveReportClient 类,并注入 HttpClient

Codebreaker.GameAPIs/Services/LiveReportClient.cs

public class LiveReportClient(HttpClient httpClient, ILogger<LiveReportClient> logger) : ILiveReportClient
{
  private readonly static JsonSerializerOptions s_jsonOptions = new()
  {
    PropertyNameCaseInsensitive = true
  };
public async Task ReportGameEndedAsync(GameSummary gameSummary, 
    CancellationToken cancellationToken = default)
  {
    try
    {
await httpClient.PostAsJsonAsync("/live/game", gameSummary, 
        options: s_jsonOptions, cancellationToken: cancellationToken);
    }
    catch (Exception ex) when (ex is HttpRequestException or 
      TaskCanceledException or JsonException)
    {
      logger.ErrorWritingGameCompletedEvent(gameSummary.Id, ex);
    }
  }
}

ReportGameEndedAsync 方法使用 HttpClient 类向 /live/game 发送 HTTP POST 请求并发送 GameSummary 信息。

让我们通过更新 ApplicationServices 类来配置 HttpClient 类:

Codebreaker.GameAPIs/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  // code removed for brevity
  builder.Services.AddScoped<IGamesService, GamesService>();
builder.Services.AddHttpClient<ILiveReportClient, 
    LiveReportClient>(client =>
  {
    client.BaseAddress = new Uri("https+http://live");
  });
  builder.AddRedisDistributedCache("redis");
}

使用 .NET Aspire 协调,通过使用 https+http://live 表达式的服务发现来检索实时客户端的 URL。此表达式优先选择 https 协议,如果不可用,则使用 http 协议。名称通过应用模型中的服务发现解析,如第一章中所述。

这样,game-apis 已配置为发送游戏摘要信息。现在,我们只需要创建一个从 SignalR 服务接收实时信息的客户端。

创建 SignalR 客户端

作为接收实时信息的简单客户端,我们只需要一个连接到 live-service 的控制台应用程序。通过这样做,可以轻松地将此功能实现到任何其他客户端中。

首先创建一个控制台项目:

dotnet new console -o LiveTestClient

需要添加 Microsoft.AspNetCore.SignalR.Client NuGet 包来调用 SignalR 服务。我们还必须添加 Microsoft.Extensions.Hosting 用于 DI 容器,以及 CNinnovation.Codebreaker.BackendModels 包中的 GameSummary 类型。

创建 LiveClient 类,该类将与 SignalR 服务进行通信:

LiveTestClient/LiveClient.cs

internal class LiveClient(IOptions<LiveClientOptions> options) : IAsyncDisposable
{
  // code removed for brevity
}
public class LiveClientOptions
{
  public string? LiveUrl { get; set; }
}

LiveClient 类指定了一个带有 IOptions<LiveClientOptions> 的构造函数。这将通过 DI 容器进行配置,以便它可以传递来自 live-service 的 URL 字符串。

appsettings.json 添加到配置 URL:

LiveTestClient/appsettings.json

{
  "Codebreaker.Live": {
    "LiveUrl": "http://localhost:5130/livesubscribe"
  }
}

对于本地测试,端口号需要与 launchSettings.json 中指定的端口号相匹配。不要忘记配置,以确保 appsettings.json 被复制到输出目录。

通过 StartMonitorAsync 方法启动对服务的连接:

LiveTestClient/LiveClient.cs

internal class LiveClient(IOptions<LiveClientOptions> options) : IAsyncDisposable
{
  private HubConnection? _hubConnection;
public async Task StartMonitorAsync(CancellationToken 
    cancellationToken = default)
  {
    string liveUrl = options.Value.LiveUrl ??
      throw new InvalidOperationException("LiveUrl not configured");
    _hubConnection = new HubConnectionBuilder()
      .WithUrl(liveUrl)
      .Build();
    _hubConnection.On("GameCompleted", (GameSummary summary) =>
{
      string status = summary.IsVictory ? "won" : "lost";
      Console.WriteLine($"Game {summary.Id} {status} by {summary.
        PlayerName} after " +
        "{summary.Duration:g}  with {summary.NumberMoves} moves");
    });
    await _hubConnection.StartAsync(cancellationToken);
  }
  // code removed for brevity
  public async ValueTask DisposeAsync()
  {
    if (_hubConnection is not null)
    {
      await _hubConnection.DisposeAsync();
    }
  }
}

要连接到 SignalR 集线器,使用 HubConnectionBuilder 设置连接。使用此构建器,可以配置连接 – 例如,设置日志记录、服务器超时和重连行为。然后通过调用 StartAsync 方法来启动连接。

HubConnectionOn 方法配置接收端:当接收到 GameCompleted 消息时,GameSummary 参数指定了接收到的数据,并将关于游戏的消息写入控制台。名称 GameCompleted 需要与通过服务的 SendAsync 方法传递的名称相匹配。

要订阅来自服务的消息,实现 SubscribeToGame 方法:

LiveTestClient/LiveClient.cs

public async Task SubscribeToGame(string gameType, CancellationToken cancellationToken = default)
{
  if (_hubConnection is null) throw new InvalidOperationException("Start a connection first");
await _hubConnection.InvokeAsync("SubscribeToGameCompletions", 
    gameType, cancellationToken);
}

在此实现中,使用 HubConnectionInvokeAsync 方法。SubscribeToGameCompletions 与集线器方法的名称匹配,该方法使用 game-type 参数。

客户端应用程序的顶级语句使用了 LiveClient 类:

LiveTestClient/Program.cs

Console.WriteLine("Test client - wait for service, then press return to continue");
Console.ReadLine();
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<LiveClient>();
builder.Services.Configure<LiveClientOptions>(builder.Configuration.GetSection("Codebreaker.Live"));
using var host = builder.Build();
var client = host.Services.GetRequiredService<LiveClient>();
await client.StartMonitorAsync();
await client.SubscribeToGame("Game6x4");
await host.RunAsync();
Console.WriteLine("Bye...");

在 DI 容器中配置 LiveClient 类后,调用 StartMonitorAsyncSubscribeToGame 方法。

在此基础上,可以启动 AppHost,使其运行所有服务和客户端应用程序的多个实例。使用 bot-service 来玩多个游戏。您将看到来自机器人的成功消息,如图 图 13**.3 所示。

图 13.3 – 接收游戏摘要的实时客户端

图 13.3 – 接收游戏摘要的实时客户端

使用机器人,在游戏移动之间有 0 和 1 秒的思考时间,启动了多个游戏。这些结果显示了在 0.27 到 4.79 秒之间发生的游戏胜利。

更改序列化协议

默认情况下,SignalR 使用 JSON 序列化消息。使用 Microsoft.AspNetCore.SignalR.Protocols.MessagePack NuGet 包,可以使用二进制序列化格式代替。这是一种优化,可以减少发送的数据量。

为了支持这一点,我们只需要在添加 NuGet 包后更新服务的 DI 配置:

Codebreaker.Live/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  builder.Services.AddSignalR()
    .AddMessagePackProtocol();
}
// code removed for brevity

AddMessagePackProtocol 方法将 MessagePack 添加为序列化的另一种选项。JSON 仍然可用。

关于客户端,需要相同的 NuGet 包,但这次需要以下配置:

LiveTestClient/LiveClient.cs

string liveUrl = options.Value.LiveUrl ?? throw new InvalidOperationException("LiveUrl not configured");
_hubConnection = new HubConnectionBuilder()
  .WithUrl(liveUrl)
  .ConfigureLogging(logging =>
  {
    logging.AddConsole();
    logging.SetMinimumLevel(LogLevel.Debug);
  })
  .AddMessagePackProtocol()
  .Build();

与服务器一样,客户端也需要相同的协议 NuGet 包,以及 AddMessagePackProtocol API。对于客户端,现在日志记录也已开启。可以使用 ConfigureLogging 方法配置 SignalR 的日志提供程序。在此,添加了控制台提供程序,并将最小日志级别设置为 LogLevel.Debug。这样,我们可以看到客户端和服务器之间的所有通信,包括使用的消息协议和发送的 ping 消息。

当使用 MessagePack 时,需要注意一个重要的限制:DateTime.Kind 不会被序列化。因此,在发送之前,此类型应转换为 UTC。

在此设置完成后,您可以再次启动解决方案,启动机器人来玩游戏,并启动 SignalR 客户端。当您查看日志信息时,您将看到 WebSockets 和 MessagePack 在实际操作中的使用:

客户端应用程序的日志记录

dbug: Microsoft.AspNetCore.SignalR.Client.HubConnection[40]
  Registering handler for client method 'GameCompleted'.
// some log outputs removed for clarity
dbug: Microsoft.AspNetCore.Http.Connections.Client.HttpConnection[8]
  Establishing connection with server at 'http://localhost:5130/livesubscribe'.
dbug: Microsoft.AspNetCore.Http.Connections.Client.HttpConnection[9]
  Established connection '1YXBdJ3Yi7A_86ZqoMKgiA' with the server.
info: Microsoft.AspNetCore.Http.Connections.Client.Internal.WebSocketsTransport[1]
  Starting transport. Transfer mode: Binary. Url: 'ws://localhost:5130/livesubscribe?id=CHpPUMdrJoxV0zLHsskN1Q'.
dbug: Microsoft.AspNetCore.Http.Connections.Client.HttpConnection[18]
      Transport 'WebSockets' started.
info: Microsoft.AspNetCore.SignalR.Client.HubConnection[24]
      Using HubProtocol 'messagepack v1'.
dbug: Microsoft.AspNetCore.Http.Connections.Client.Internal.WebSocketsTransport[13]
      Received message from application. Payload size: 39.

现在我们已经切换了序列化格式,让我们通过使用 Azure SignalR 服务来减少大量客户端连接时的服务负载。

使用 Azure SignalR 服务

由于服务器与所有 SignalR 客户端都保持开放连接,因此 SignalR 存在一些开销。

为了从我们的服务中移除这种开销,我们可以使用 Azure SignalR 服务。此服务充当客户端和 SignalR 服务之间的中介,如图 图 11**.4 所示:

图 13.4 – 使用 Azure SignalR 服务

图 13.4 – 使用 Azure SignalR 服务

上一图显示了多个监控客户端,每个客户端都向 Azure SignalR 服务开放一个连接。live-service 只需处理单个连接。Azure SignalR 订阅事件并将它们转发到单个客户端、一组客户端或所有客户端,具体取决于 live-service

每个客户端连接的负载由 Azure SignalR 服务处理,而此服务仅作为 SignalR 服务的单个客户端。

此服务的免费版本没有 SLA,限制为每天 20 个连接和 20,000 条消息,可用于开发目的。标准和高级 SKU 可以扩展到每个单元 1,000 个连接、100 个单元和无限消息。

要在应用程序模型中激活 Azure SignalR 服务,我们需要更新 AppHost 项目中的 app-model

Codebreaker.AppHost/Program.cs

var builder = DistributedApplication.CreateBuilder(args);
var signalR = builder.AddAzureSignalR("signalr");
// code removed for brevity
var live = builder.AddProject<Projects.Codebreaker_Live>("live")
  .WithExternalHttpEndpoints()
  .WithReference(appInsights)
  .WithReference(signalR);

使用 .NET Aspire 配置,Azure SignalR 服务在应用程序启动时创建。当使用 WithReference 方法时,URI 被转发到 Codebreaker.Live 服务。在此处,需要 Microsoft.Azure.SignalR NuGet 包来连接此服务:

Codebreaker.Live/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  var signalRBuilder = builder.Services.AddSignalR()
    .AddMessagePackProtocol();
  if (Environment.GetEnvironmentVariable("StartupMode") != "OnPremises")
  {
      signalRBuilder.AddNamedAzureSignalR("signalr");
  }
}

使用 AddNamedAzureSignalR,通过服务发现检索连接字符串,并将 SignalR 网关连接到此 Azure 服务。

现在,重新启动应用程序并检查 Azure 门户,以查看服务已创建。使用 Aspire 仪表板查看分配给 Codebreaker.Live 服务的环境变量,并检查日志以查看已连接到 Azure SignalR 服务的连接。运行机器人,让它玩几场比赛,然后启动几个 SignalR 客户端 (LiveTestClient) 进程。

当您打开 Azure 门户时,打开开发环境资源组(rg-aspire-<yourhost>-codebreaker.apphost)并选择 Azure SignalR 服务。在左侧栏的 监控 类别中,选择 实时跟踪设置。点击 启用实时跟踪 复选框,并选择收集 ConnectivityLogsMessagingLogsHttpRequestLogs 的信息。然后,点击 打开实时跟踪工具 按钮。您将收到以下输出:

图 13.5 – Azure SignalR 服务实时跟踪。此截图仅用于显示输出结果页面;文本可读性不是关键

图 13.5 – Azure SignalR 服务实时跟踪。此截图仅用于显示输出结果页面;文本可读性不是关键

使用 Azure SignalR 服务实时跟踪,您可以查看从 Codebreaker 实时服务发送的所有消息,以及发送给订阅客户端的消息。

要查看指标数据,请返回(或打开新的浏览器窗口)到 Azure SignalR 服务的 概览 页面。在那里,您可以查看已打开的连接数,如图 图 13.6 所示:

图 13.6 – Azure SignalR 服务连接指标

图 13.6 – Azure SignalR 服务连接指标

图 13.7 显示了已发送的消息数量:

图 13.7 – Azure SignalR 服务消息指标

图 13.7 – Azure SignalR 服务消息指标

现在一切运行正常,您已经赢得了应得的休息时间,可以玩一些游戏(您也可以进行监控)。

摘要

在本章中,你学习了如何使用 SignalR 提供实时数据。你创建了一个包含 SignalR 中心的实时服务,该中心提供有关完成游戏的实时信息。客户端可以注册到提供的信息的子集——一个组。你还创建了一个简单的控制台应用程序,作为客户端。同样的功能也可以在其他客户端中实现。你可以在本书 GitHub 仓库中提供的 Blazor 客户端应用程序中查看这些功能,该应用程序包含 SignalR 客户端功能。

然后,你学习了如何使用 Azure SignalR 服务,这减少了托管 SignalR 中心的服务的负载,因为客户端直接与 Azure SignalR 服务交互,而此服务作为 SignalR 的一个客户端。

在本章的实现中,我们创建了一个 REST API,该 API 由game-apis服务调用以发送完成的游戏。REST 非常适合与所有客户端进行简单通信,但它并不提供最佳性能。Codebreaker.Live服务的唯一客户端是game-apis服务。

关于服务间通信,在配合 gRPC 等协议使用二进制序列化时,与使用 REST API 相比,开销更小。这将在下一章中介绍。

进一步阅读

要了解更多关于本章讨论的主题,请参阅以下链接:

第十四章:gRPC 用于二进制通信

服务到服务的通信不需要通过 REST 传递 JSON 数据。当使用gRPC,一种二进制和平台无关的通信技术时,性能和成本是重要的考虑因素。减少传输的数据可以增加性能并降低成本。

在本章中,我们将更改 Codebreaker 解决方案中的某些服务,以便它们提供 gRPC 服务或 REST 服务。您将了解 gRPC 与 REST 的不同之处,以及如何使用这种二进制通信技术创建服务和客户端。

在本章中,您将学习以下内容:

  • 配置服务项目以使用 gRPC

  • 使用 Protobuf 创建平台无关的通信合约

  • 创建 gRPC 服务

  • 创建调用 gRPC 服务的客户端

技术要求

在本章中,就像前几章一样,您需要一个 Azure 订阅和 Docker Desktop。

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/

ch14文件夹包含本章中我们将要查看的项目及其结果。要添加本章的功能,您可以从上一章的源代码开始。

我们将要考虑的项目如下:

  • Codebreaker.AppHost: 这是.NET Aspire 宿主项目。应用模型已更新,使用 HTTPS 与game-apis服务和live-service一起使用,以支持 gRPC。

  • Codebreaker.Live: 我们在上一章中创建的项目已被修改,提供 gRPC 服务而不是 REST 服务。

  • Codebreaker.GameAPIs: 此项目已更新,包括一个用于调用live-service的 gRPC 客户端。除了许多不同客户端使用的 REST 服务外,还添加了一个作为替代方案的 gRPC 服务。这是由机器人服务调用的。

  • Codebreaker.Bot: 机器人服务已更新,使用 gRPC 客户端而不是 REST 来调用game-apis服务。

  • LiveTestClient: 您需要使用上一章中的实时测试客户端来验证 SignalR 服务。

在深入研究 gRPC 之前,让我们将其与表示状态转移REST)进行比较。

比较 REST 与 gRPC

REST 与 gRPC 之间最重要的区别在于,REST 是一个基于 HTTP 的指南,而 gRPC 是一个协议。两者都用于客户端和服务之间的通信。让我们更深入地了解一下。

通信风格

REST 是一种定义服务为无状态的指南,使用 HTTP 动词(GET、POST、PUT、DELETE)来操作资源,通常使用如 JSON 或 XML 等人可读的格式,并且通常与 Web API 一起使用。

gRPC 使用一个严格定义的合约来指定服务中可用的操作。虽然也可以使用其他规范,但大多数服务都使用 Protocol BuffersProtobuf)来指定合约。这样,gRPC 具有紧凑的有效负载大小和高效的序列化。

性能

当涉及到文本表示时,REST 有更高的开销和更高的延迟。gRPC 由于其高效的序列化,延迟较低,二进制序列化减少了有效负载的大小。gRPC 的多路复用允许在单个连接上进行并发请求。

灵活性

REST 通过使用 URI 作为资源来增加灵活性,并且不严格基于 HTTP 和 HTTPS。还可以使用其他满足 REST 原则的协议。

gRPC 严格指定了通信合约。gRPC 基于 HTTP/2,与 HTTP/1 相比,提供了一些优势,例如在单个连接上多路复用并发调用。gRPC-Web 是一个替代方案,允许使用 HTTP/1 使用 gRPC 的子集。

语言支持

REST 只需要 HTTP,并且与支持 HTTP 的任何语言一起工作。gRPC 基于 protobuf 合约创建代码,这需要使用受支持的语言。查看受支持语言的列表,请访问 grpc.io/docs/languages/。它包括 C#、C++、Dart、Java、Go、Python、PHP 以及其他语言。

安全性

REST 依赖于传输安全(HTTPS)。身份验证和授权在应用层完成。gRPC 支持传输安全(TLS/SSL),并且内置了 OAuth 和 JWT 的身份验证。gRPC 支持按消息加密。

用例

REST 允许轻松互操作性。只需要进行 HTTP 调用。它与 Web API 和简单服务以及与现有服务的互操作性一起使用。

使用云服务时,我们为计算和内存资源付费。由于有很多通信进行,可以通过使用内存和 CPU 效率高的技术来减少所需的实例数量。服务之间的通信可以使用 gRPC 完成。

让我们先更新解决方案,使其能够使用 gRPC(日志收集器已经使用 gRPC)。

更新服务项目以使用 gRPC

当使用 .NET 模板创建新项目时,可以通过运行以下命令创建 gRPC 服务:

dotnet new grpc

当使用此类项目时,你可以检查项目文件以查看所需的 NuGet 包和其他配置。

由于我们已经有现有项目,我们将更新这些项目以提供 gRPC 服务。但首先,让我们看看 图 14.1 中所示的 Codebreaker 服务之间的通信:

图 14.1 – Codebreaker 通信技术

图 14.1 – Codebreaker 通信技术

让我们从右侧开始。在 第十三章 中,我们使用 SignalR 集线器实现了 Codebreaker live 服务,该集线器通知 SignalR 客户端。对于 SignalR 客户端,我们创建了一个控制台客户端应用程序。同样,在 第十三章 中,我们使用了最小的 ASP.NET Core API,这使得我们可以调用 game-apis 服务来发送完成的游戏。这是一种服务到服务的通信方式,可以被 gRPC 替换。game-apis 服务本身被客户端和机器人服务调用。使用此服务,任何客户端技术都必须调用 REST API。机器人和 game-apis 服务之间的通信也可以使用 gRPC 完成。因此,当涉及到 game-apis 服务时,我们将提供一种替代的通信技术,以便替换 game-apis 服务和 live-service 之间的通信。因此,在本章中,我们将执行以下操作:

  • 用 gRPC 替换 live-service 的最小 API 实现

  • game-apis 服务添加一个替代选项,以便我们提供 gRPC

  • 使用 game-apis 服务实现一个 gRPC 客户端以调用 live-service

  • 使用 game-apis 服务的客户端实现一个 gRPC 客户端

让我们从为 live-service 创建一个由 game-apis 服务调用的 gRPC 服务合约开始。

创建服务合约

首先,我们需要将 Grpc.AspNetCore NuGet 包添加到 Codebreaker.Live 项目中。然后,我们必须将一个 Protobuf 文件作为合约添加到服务中。合约文件是语言和平台无关的,因此一个 .NET 服务可以使用相同的 Protobuf 文件与 Dart 应用程序通信。

使用 Visual Studio,添加一个 Protos 文件夹,并使用 Visual Studio 模板创建一个 Codebreaker.Live,你可以在 Protos 文件夹内运行以下命令来创建 LiveGame.proto 文件:

dotnet new proto -o Protos -n LiveGame

现在,让我们为 live-servicegame-apis 创建合约

live-service 创建一个 gRPC 服务合约

简单的合约是 live-service 的合约:

Codebreaker.Live/Protos/LiveGame.proto

syntax = "proto3";
option csharp_namespace = "Codebreaker.Live.Grpc";
package ReportGame;
import "google/protobuf/empty.proto";
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
service ReportGameService {
  rpc ReportGameCompleted (ReportGameCompletedRequest)
returns (google.protobuf.Empty);
}
message ReportGameCompletedRequest {
  string id = 1;
  string gameType = 2;
  string playerName = 3;
  bool isCompleted = 4;
  bool isVictory = 5;
  int32 numberMoves = 6;
  google.protobuf.Timestamp startTime = 7;
  google.protobuf.Duration duration = 8;
}

syntax 关键字指定了应该使用的 Protobuf 版本。版本 3 添加了 mapsoneof 字段等特性。option 关键字允许我们添加特定语言的功能。option csharp_namespace 设置了由生成的类使用的 C# 命名空间,后缀为 package 关键字设置的名称。service 关键字描述了 gRPC 服务提供的操作列表。服务中的每个操作都使用 rpc 关键字(远程过程调用)。在我们的代码中,操作被命名为 ReportGameCompleted,使用 ReportGameCompletedRequest 作为参数,并返回 google.protobuf.Emptygoogle.protobuf.Empty 是可用的已知 Protobuf 类型之一。当使用此类型时,必须使用 import 关键字导入。google.protobuf.Durationgoogle.protobuf.Timestamp 类型也被导入。ReportGameCompletedRequest 是使用 message 关键字指定的消息。消息中的每个字段都需要一个唯一的标识符。序列化和反序列化器使用这个数字来匹配。因此,如果你已经指定了一个合约,那么在未来的版本中不要更改这个数字,因为这会破坏现有的客户端或服务。使用的类型需要是平台无关的,因为相同的 Protobuf 文件可以被所有支持 gRPC 的平台使用。stringboolint32 是由 Protobuf 规范定义的类型。在 .NET 中,这些类型分别映射到 stringboolintId 是一个 GUID,但在 Protobuf 中没有 GUID 的表示。可以使用 string 作为标识符。在 .NET 的 GameSummary 类中,StartTime 属性是 DateTime 类型,而 Duration 属性是 TimeSpan 类型。要将这些类型与 Protobuf 映射,可以使用 TimestampDuration。这些类型在 Google.Protobuf.WellKnownTypes .NET 命名空间中定义。TimestampDuration 提供了将它们转换为 DateTimeTimeSpan 的转换方法。

要从 Protobuf 文件创建 .NET 类,需要在项目文件中添加一个 Protobuf 条目,如下所示:

Codebreaker.Live/Codebreaker.Live.csproj

<ItemGroup>
  <Protobuf Include="Protos\LiveGame.proto"
    GrpcServices="Server" />
</ItemGroup>

这个 Protobuf 条目通过 Include 属性引用了 Protobuf 文件,并通过将 GrpcServices 设置为 Server 来指定为服务器生成的代码。有了服务器,就会生成所有定义的消息的类以及每个服务指定的基类的类。在本章的后面部分,我们将使用 Protobuf 元素来生成客户端的类。

在上一章中,我们创建了 GameSummary 类来报告游戏完成情况。要将这个类转换为 gRPC 生成的 ReportGameCompletedRequest 类,我们必须定义一个转换方法:

Codebreaker.Live/Extensions/ReportGameCompletedRequestExtensions.cs

public static class ReportGameCompletedRequestExtensions
{
  public static GameSummary ToGameSummary(
    this ReportGameCompletedRequest request)
  {
    Guid id = Guid.Parse(request.Id);
    DateTime startTime = request.StartTime.ToDateTime();
    TimeSpan duration = request.Duration.ToTimeSpan();
    return new GameSummary(
      id,
      request.GameType,
      request.PlayerName,
      request.IsComleted,
      request.IsVictory,
      request.NumberMoves,
      startTime,
      duration);
  }
}

在这个实现中,我们创建一个新的GameSummary实例并使用转换方法——例如,我们将字符串解析为Guid值,并调用ToDateTimeToTimeSpan方法来转换TimestampDuration值。

注意

要在 Visual Studio 中查看生成的代码,请单击ReportGameCompletedRequest类,使用上下文菜单并选择转到实现。这会直接打开生成的代码。

使用ReportGameService,我们只有一个非常简单的合同。为了允许game-apis被调用并且移动可以通过机器人服务进行,我们需要一个更复杂的合同。

为 game-apis 服务创建 gRPC 服务合同

game-apis服务的 gRPC 合同比live-service的合同更长。在这里,我们将关注合同的一些特定部分。查看本书的 GitHub 存储库以获取完整的定义。

服务合同指定了玩游戏的操作:

Codebreaker.GameAPIs/Protos/GameService.proto

syntax = "proto3";
option csharp_namespace = "Codebreaker.Grpc";
package GamesAPI;
import "google/protobuf/duration.proto";
import "google/protobuf/timestamp.proto";
service GrpcGame {
  rpc CreateGame(CreateGameRequest)
    returns (CreateGameResponse);
  rpc SetMove(SetMoveRequest) returns (SetMoveResponse);
  rpc GetGame(GetGameRequest) returns (GetGameResponse);
}
// code removed for brevity

作为 REST 接口的替代选项,GrpcGameService定义了创建游戏(CreateGame)、设置移动(SetMove)和获取游戏信息(GetGame)的操作。

大多数用于向服务发送请求的消息仅包含标量值。SetMoveRequest消息是不同的。此消息包含了一个猜测销钉的列表:

Codebreaker.GameAPIs/Protos/GameService.proto

message SetMoveRequest {
  string id = 1;
  string gameType = 2;
  int32 moveNumber = 3;
  bool end = 4;
  repeated string guessPegs = 5;
}

列表使用repeated关键字指定。

不仅预定义的类型可以重复,还可以重复内部消息类型:

Codebreaker.GameAPIs/Protos/GameService.proto

message GetGameResponse {
  string id = 1;
  string gameType = 2;
  string playerName = 3;
  // code removed for brevity
  repeated Move moves = 14;
}
message Move {
  string id = 1;
  int32 moveNumber = 2;
  repeated string guessPegs = 3;
  repeated string keyPegs = 4;
}

GetGameResponse消息类型包含了一个Move消息的重复列表。Move消息类型包含了一个字符串列表,用于猜测的销钉和键销钉。

Protobuf 还通过map类型定义了一个键和值的列表:

Codebreaker.GameAPIs/Protos/GameService.proto

message FieldNames {
  repeated string values = 1;
}
message CreateGameResponse {
  string id = 1;
  string gameType = 2;
  string playerName = 3;
  int32 numberCodes = 4;
  int32 maxMoves = 5;
  map<string, FieldNames> fieldValues = 6;
}

使用地图时,指定了键和值类型。fieldValues字段中,键是一个字符串。相应的 REST API 指定了一个字符串数组作为值。在 Protobuf 中,使用repeated关键字与值类型是不可能的。相反,定义了FieldMessage来包含一个repeated string,并且这个用于map值。

消息合同创建了特定于 gRPC 的.NET 类。当涉及到本地服务类时,最好它们与通信技术无关。因此,我们需要创建转换方法。

创建转换方法

live-service的 gRPC 服务接收ReportGameCompletedRequest。这被转发到我们在上一章中创建的 SignalR 服务作为GameSummary方法。因此,我们需要将ReportGameCompletedRequest转换为GameSummary方法:

Codebreaker.Live/Extensions/GrpcExtensions.cs

internal static class GrpcExtensions
{
  public static GameSummary ToGameSummary(
    this ReportGameCompletedRequest request)
  {
    Guid id = Guid.Parse(request.Id);
    DateTime startTime = request.StartTime.ToDateTime();
    TimeSpan duration = request.Duration.ToTimeSpan();
    return new GameSummary(
      id,
      request.GameType,
      request.PlayerName,
      request.IsCompleted,
      request.IsVictory,
      request.NumberMoves,
      startTime,
      duration);
  }
}

这是以扩展方法的形式完成的,我们扩展了ReportGameCompletedRequest类型。通过实现ToGameSummary方法,可以传递简单的标量类型,创建一个GameSummary对象。Google 的TimestampDuration类型提供了ToDateTimeToTimeSpan方法来转换DateTimeTimeSpan

注意

可以使用如AutoMapperMapster等库来自动实现此类转换。虽然这对于简单的属性来说开箱即用,无需添加自定义代码,但在转换不同类型时可能需要一些定制。需要注意的是,使用.NET 反射而不是源生成器的映射库会增加内存和 CPU 使用,并且不能与原生 AOT 一起使用。根据您需要映射的类型,您可能更喜欢自定义扩展方法。

查阅本书的 GitHub 仓库以获取可用于game-apis服务的其他转换方法。

在转换方法就绪后,让我们创建 gRPC 服务的实现。

创建 gRPC 服务

要为Codebreaker.Live项目实现一个 gRPC 服务,创建GRPCLiveGameService类:

Codebreaker.Live/Endpoints/GRPCLiveGameService.cs

using Codebreaker.Grpc;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
namespace Codebreaker.Live.Endpoints;
public class GRPCLiveGameService(
  IHubContext<LiveHub> hubContext,
  ILogger<LiveGameService> logger) :
    ReportGame.ReportGameBase
{
  async public override Task<Empty> ReportGameCompleted(
    ReportGameCompletedRequest request,
    ServerCallContext context)
  {
    logger.LogInformation("Received game ended {type} " +
      "{gameid}", request.GameType, request.Id);
    await hubContext.Clients.Group(request.GameType)
      .SendAsync("GameCompleted", request.ToGameSummary());
    return new Empty();
  }
}

使用GRPCLiveGameService类,通过构造函数注入,将我们在上一章中创建的 hub 上下文注入到发送GameSummary方法到所有连接的客户端中,这些客户端参与名为game-type的组。GRPCLiveGameService类需要从基类派生——即ReportGame.ReportGameBaseReportGameBase是基于 Protobuf 合约实现的ReportGame的内部类。

接下来,使用GRPCLiveGameService类将其映射为 gRPC 端点:

Codebreaker.Live/ApplicationServices.cs

public static WebApplication MapApplicationEndpoints(this WebApplication app)
{
  app.MapGrpcService<GRPCLiveGameService>();
  app.MapHub<LiveHub>("/livesubscribe");
  return app;
 }

您可以移除最小 API 端点的映射,只需使用名为MapGrpcServiceWebApplication类配置 gRPC 端点,并将服务类作为泛型参数传递。

gRPC 需要 HTTP/2。因此,我们需要配置Kestrel服务器:

Codebreaker.Live/appsettings.json

{
  "Kestrel": {
    "EndpointDefaults": {
      "Protocols": "Http1And2"
    }
  }
}

Protocols配置为Http1And2将启动Kestrel服务器并确保它支持 HTTP/1 和 HTTP/2。gRPC 服务需要 HTTP/2。对于live-service,同一服务器提供了 SignalR。为了允许 SignalR 服务通过 HTTP/1 或 HTTP/2 连接,服务器必须配置为提供这两种版本。

game-apis服务的实现有相似之处。在项目文件中,我们需要添加Grpc.AspNetCore包,向项目文件添加一个Protobuf元素,并指定我们希望为服务器创建类:

实现 gRPC 服务的这一方面很简单:我们可以注入IGameService接口。这使用了我们之前用来实现最小 API 服务的相同类:

Codebreaker.GameAPIs/GrpcGameEndpoints.cs

public class GrpcGameEndpoints(
  IGamesService gamesService,
  ILogger<GrpcGameEndpoints> logger) :
  Grpc.GrpcGame.GrpcGameBase
{
  public override async
    Task<Grpc.CreateGameResponse> CreateGame(
    Grpc.CreateGameRequest request,
    ServerCallContext context)
  {
    logger.GameStart(request.GameType);
    Game game = await gamesService.StartGameAsync(
      request.GameType, request.PlayerName);
    return game.ToGrpcCreateGameResponse();
  }
  public override async Task<SetMoveResponse> SetMove(
    SetMoveRequest request, ServerCallContext context)
  {
    Guid id = Guid.Parse(request.Id);
    string[] guesses = request.GuessPegs.ToArray();
    (Game game, Models.Move move) =
      await gamesService.SetMoveAsync(
        id, request.GameType, guesses, request.MoveNumber);
    return game.ToGrpcSetMoveResponse(move);
  }
  public override async Task<GetGameResponse> GetGame(
    GetGameRequest request, ServerCallContext context)
  {
    Guid id = Guid.Parse(request.Id);
    Game? game = await gamesService.GetGameAsync(id);
    if (game is null)
      return new GetGameResponse()
      {
        Id = Guid.Empty.ToString()
      };
    return game.ToGrpcGetGameResponse();
  }
}

在 gRPC 中,我们可以从生成的基类 GrpcGame.GrpcGameBase 继承,并覆盖由服务合同指定的基类方法,并使用转换方法将输入和输出类型转换为相应的 gRPC 表示形式。

live-service 类似,game-apis 服务需要将 gRPC 添加到 DI 容器并映射到端点,并且需要配置 Kestrel 以支持 HTTP/1 和 HTTP/2。

在实现了服务后,让我们考虑 gRPC 客户端。

创建 gRPC 客户端

如果你使用的是 Visual Studio 2022,可以利用其内置支持添加 gRPC 客户端。从解决方案资源管理器中选择项目,打开上下文菜单,然后选择 添加 | 连接服务。这会打开 图 14.2 所示的对话框:

图 14.2 – 添加服务引用

图 14.2 – 添加服务引用

选择 gRPC 并点击 下一步。这会打开 图 14.3 所示的对话框:

图 14.3 – 添加新的 gRPC 服务引用

图 14.3 – 添加新的 gRPC 服务引用

选择 Protobuf 文件,然后从 选择要生成的类的类型 下拉菜单中选择 Client 以创建消息类和客户端代码。

如果你没有使用 Visual Studio,可以使用名为 dotnet 的 .NET 命令行工具。要安装此工具,请运行以下命令:

dotnet tool install -g dotnet-grpc

在全局安装了此工具后,可以使用 dotnet-grpc 命令为 game-apis 客户端创建代理类:

cd Codebreaker.GameAPIs
dotnet-grpc add-file ..\Codebreaker.Live\Protos\LiveGame.proto

对于机器人服务客户端,你可以运行以下命令:

cd ..
cd Codebreaker.Bot
dotnet-grpc add-file ..\Codebreaker.GameAPIs\Protos\GameService.proto

这些命令或 Visual Studio 集成发生了什么?

  • 项目中添加了 Grpc.AspNetCore NuGet 包

  • 项目文件中添加了一个 Protobuf 元素

使用命令行工具,当创建 Protobuf 条目时,会创建客户端和服务器端的代码。如果要仅创建客户端代码,需要添加 GrpcServices="Client" 属性:

Codebreaker.GameAPis/Codebreaker.GameAPIs.csproj

<ItemGroup>
  <Protobuf
    Include="..\Codebreaker.Live\Protos\LiveGame.proto"
    GrpcServices="Client" />
  <Protobuf Include=".\Protos\GameService.proto" GrpcServices="Server" />
</ItemGroup>

关于 game-apis 服务,项目文件现在包括两个 Protobuf 条目。一个用于创建服务部分(我们在上一节中已执行),而新的条目用于客户端代码。

机器人调用 game-apis 服务。因此,需要在机器人项目文件中引用 game-apis 服务的 proto 文件:

Codebreaker.Bot/Codebreaker.Bot.csproj

<ItemGroup>
  <Protobuf Include=
    "..\Codebreaker.GameApis\Protos\GameService.proto"
    GrpcServices="Client" />
</ItemGroup>

再次强调,对于客户端,GrpcServices 设置为 Client

在使用 proto 文件时,会创建客户端代理类,提供使用操作名称调用服务的方法。这些代理类可以被注入。在下面的代码片段中,正在将生成的 ReportGameClient 类注入到 GrpcLiveReportClient 类中:

Codebreaker.GameApis/GrpcLiveReportClient.cs

public class GrpcLiveReportClient(
  ReportGame.ReportGameClient client,
  ILogger<LiveReportClient> logger) : ILiveReportClient
{
  public async Task ReportGameEndedAsync(GameSummary gameSummary, 
    CancellationToken cancellationToken = default)
  {
    try
    {
      ReportGameCompletedRequest request = gameSummary.
        ToReportGameCompletedRequest();
      await client.ReportGameCompletedAsync(request);
    }
    catch (Exception ex) when (ex is RpcException or
      SocketException)
    {
      logger.ErrorWritingGameCompletedEvent(
        gameSummary.Id, ex);
    }
  }
}

ReportGameClient 实现了 ILiveReportClient 接口——这是我们之前章节中定义和实现的相同接口,用于在游戏完成后调用 SignalR 服务。在实现相同接口时,我们只需更改 DI 容器的配置,以便通过 gRPC 而不是使用 REST 接口调用服务。通过实现 ReportGameEndedAsync 方法,我们可以从代理调用生成的方法,并需要使用扩展方法帮助转换参数:

Codebreaker.GameApis/ApplicationServices.cs

builder.Services.AddSingleton<ILiveReportClient,
  GrpcLiveReportClient>()
  .AddGrpcClient<ReportGame.ReportGameClient>(
    grpcClient =>
    {
      grpcClient.Address = new Uri("https://live");
    });
// code removed for brevity

类似地,对于机器人服务,GrpcGamesClient 实现了 IGamesClient 接口并注入了 GrpcGame.GrpcGameClient

Codebreaker.Bot/GrpcGamesClient.cs

public class GrpcGamesClient(
  GrpcGame.GrpcGameClient client,
  ILogger<GrpcGamesClient> logger) : IGamesClient
{
  // code removed for brevity
  public async Task<(string[] Results, bool Ended,
    bool IsVictory)> SetMoveAsync(
    Guid id, string playerName, GameType gameType,
    int moveNumber, string[] guessPegs,
    CancellationToken cancellationToken = default)
  {
    SetMoveRequest request = new()
    {
      Id = id.ToString(),
      GameType = gameType.ToString(),
      MoveNumber = moveNumber,
      End = false
    };
    request.GuessPegs.AddRange(guessPegs);
    var response = await client.SetMoveAsync(request,
      cancellationToken: cancellationToken);
    return (response.Results.ToArray(), response.Ended,
      response.IsVictory);
  }

IGamesClient 接口与 GameClient 实现的相同,它调用 REST API。此接口被注入到 CodebreakerGameRunner 中,因此在切换到 gRPC 时不需要进行任何更改。

SetMoveAsync 方法通过调用 GrpcGame.GrpcGameClientSetMoveAsync 方法向 gRPC 服务发出请求。与之前类似,它的目的是将参数转换为 gRPC 所需的参数。

如需了解接口其他方法的实现,请参阅本书的 GitHub 仓库。请注意,它与之前所做的工作类似。

为了将 IGamesClient 连接到新的实现,并配置 gRPC 客户端,我们需要更新 DI 容器配置:

Codebreaker.Bot/ApplicationServices.cs

builder.Services.AddSingleton<IGamesClient,
GrpcGamesClient>()
  .AddGrpcClient<GrpcGame.GrpcGameClient>(client) =>
  {
    client.Address = new Uri("https://gameapis");
  });

AddGrpcClient 方法配置生成的类以使用 game-apis 服务的地址。

现在,运行应用程序并启动一个或多个实时测试客户端实例,以查看是否显示已完成的游戏。启动机器人服务并向机器人服务发送请求以玩几场游戏。同时,使用另一个客户端玩游戏。机器人运行多少场游戏你才完成一场?当然,这取决于你为机器人配置的思考时间。结果是否显示在实时测试客户端的控制台中?检查日志和 .NET Aspire 仪表板中不同服务的环境变量。

摘要

在本章中,你学习了 REST API 和 gRPC 之间的区别,以及在使用 gRPC 进行服务间通信时的优势。

你使用 Protobuf 语法创建了一个服务合同,以定义服务和消息。与 REST 相比,gRPC 在消息和服务操作方面非常严格。你使用由 proto 文件生成的类创建了服务器和客户端。

为了在云中降低成本,使用较低开销的协议进行服务间通信可能是经济有效的。然而,还有其他选项可供选择。游戏完成后,无需立即通知听众。这也涉及到一个价格方面:根据当前实现,当从游戏 API 服务访问时,实时服务正在运行。如果没有人在监听,这就不需要了。通过使用异步通信,live-service可以在启动时注册接收信息——这是当有听众活跃时。异步通信将在下一章中介绍。

进一步阅读

要了解更多关于本章讨论的主题,请参考以下链接:

第十五章:使用消息和事件进行异步通信

在上一章中,我们使用二进制通信更新了我们的服务。然而,某些服务不需要连接的服务:客户端和服务器不需要同时连接,这意味着通信可以是异步的。这种通信可以通过向队列发送消息或发布事件来完成。

在本章中,我们将使用 Azure 服务进行异步通信——也就是说,Azure 队列存储和 Azure 事件中心。我们还将使用 Apache Kafka 作为替代选项。

你将学习以下内容:

  • 区分消息队列和事件

  • 使用队列发送和接收消息

  • 使用 Azure 事件中心发布和订阅事件

  • 使用 Apache Kafka 进行事件处理

技术要求

在本章,就像前几章一样,你需要一个 Azure 订阅和 Docker Desktop。

本章的代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/

ch15文件夹包含本章的项目及其输出。要添加本章的功能,可以从上一章的源代码开始。

在本章中,我们将考虑以下项目:

  • Codebreaker.AppHost: 这是一个.NET Aspire 宿主项目。通过添加 Azure 存储、Azure 事件中心和 Apache Kafka 服务,增强了应用程序模型。

  • Codebreaker.BotQ: 这是一个包含与Codebreaker.Bot几乎相同代码的新项目。然而,它不是使用 REST API 来触发游戏玩法,而是使用消息队列。

  • Codebreaker.GameAPIs: 该项目已更新,不再直接将完成的游戏转发到live-service。相反,根据启动配置文件启动,它将事件发布到 Azure 事件中心或 Apache Kafka。

  • Codebreaker.Live: 该项目已更改,以便使用异步流订阅 Azure 事件中心的事件。SignalR 实现也已更改,以便使用异步流。

  • Codebreaker.Ranking: 这是一个新的项目,它从 Azure 事件中心或 Kafka 接收事件,将这些信息写入 Azure Cosmos DB 数据库,并提供一个 REST 服务来检索当天的排名。与使用live-service相比,使用事件中心我们有接收事件的不同方式。

比较消息和事件

在上一章中,我们使用了与所有服务连接的网络通信。首先,我们来看看如图图 15.1所示的机器人和游戏 API 之间的通信:

图 15.1 - 机器人和游戏 API 之间的同步通信

图 15.1 - 机器人和游戏 API 之间的同步通信

机器人服务可以通过 REST 访问。机器人服务本身通过 gRPC 调用游戏 API 服务(所有其他客户端都使用 REST 与游戏 API 服务通信)。然后,机器人服务继续与游戏 API 服务通信,发送移动操作直到游戏结束,并继续下一轮游戏,直到达到指定的游戏数量。机器人客户端通过 REST 调用机器人服务,这是(类似于 gRPC)同步通信,具有请求/回复。这里的机器人服务没有同步实现,因为机器人客户端不需要等待所有游戏都进行——在此期间 HTTP 协议会超时。相反,机器人服务返回一个 HTTP ACCEPTED响应(状态码 202)和一个唯一标识符,客户端可以使用该标识符来检查状态。该协议本身是同步的,因为客户端等待响应 202。

当游戏结束时,下一部分通信通过图 15.2展示。

图 15.2 - 从游戏 API 发起的同步通信

图 15.2 - 从游戏 API 发起的同步通信

游戏 API 服务使用 gRPC 通知排名服务和实时服务。实时服务通过 SignalR 继续通信,通知所有已连接的客户端游戏结束。排名服务将在本章实现,将所有结束的游戏写入新的数据库。为了简化此图,未显示在通信中使用的某些服务。游戏 API 和 Azure Cosmos DB 之间存在同步通信,类似于排名服务。

在同步通信中,如果其中一个服务延迟,延迟会返回到原始调用者。如果其中一个服务出现错误,客户端不会收到成功的响应。

Microsoft Azure 提供了一些可以用于创建异步通信的服务:Azure 队列存储、Azure 服务总线、Azure 事件网格和 Azure 事件中心。让我们看看与机器人客户端和机器人服务通信的图 15.3的新版本。

图 15.3 - 机器人客户端与机器人服务之间的异步通信

图 15.3 - 机器人客户端与机器人服务之间的异步通信

在新的实现中,Azure 队列存储开始发挥作用。机器人服务注册到队列以接收消息。机器人客户端,而不是使用 HTTP 与机器人服务通信,向队列发送消息。如果有人开始处理此消息,机器人客户端不需要等待。对于客户端来说,工作已经完成。由于机器人服务注册了接收消息,它从队列中接收消息,并以与之前相同的方式玩游戏,这不会改变。

接下来,我们来看游戏 API 服务在图 15.4中启动的异步通信。

图 15.4 – 从游戏 API 发起的异步通信

图 15.4 – 从游戏 API 发起的异步通信

在这里,Azure 事件中心发挥了作用。游戏 API 服务,而不是与排名和实时服务都进行同步通信,只需与事件中心进行通信。一个游戏结束事件被推送到这个服务。游戏 API 不需要知道谁对这个事件感兴趣,谁接收这个事件。在这里,注册了两个订阅者,排名服务和实时服务,并接收这个事件。从现在开始,通信与之前相同。排名服务将接收到的信息写入数据库(此处未显示),实时服务将此信息转发给订阅实时服务的客户端 – 如果他们订阅了与事件存储相同的游戏类型。

使用消息队列和事件之间的主要区别可以从以下场景中看出。当向队列发送消息时,只有一个接收者处理该消息。可以连接多个读取器到同一个队列(出于性能原因),但只有一个读取器处理消息。如果消息处理成功,它将从队列中删除。使用事件时,多个订阅者接收和处理相同的事件。

让我们来看看 Microsoft Azure 为消息和事件提供的不同选项。

消息队列

Microsoft Azure 提供 Azure 队列存储(Azure 存储账户的一部分)和服务总线队列,可用于消息队列。Azure 队列存储是更简单、更经济的选项,但 Azure 服务总线提供了更多功能,例如顺序保证、原子操作、批量发送消息、重复检测等。有关详细信息,请参阅learn.microsoft.com/en-us/azure/service-bus-messaging/service-bus-azure-and-service-bus-queues-compared-contrasted

事件

要发布和订阅事件,您可以使用 Azure 事件网格和 Azure 事件中心。Azure 事件网格易于使用,可以轻松订阅大多数 Azure 资源的事件。在 Azure 门户中,对于大多数资源,您可以在左侧栏中看到事件菜单。例如,当打开 Azure 存储账户时,点击事件后,点击事件订阅。使用存储账户,您将看到如图 15.5 所示的事件类型,例如Blob 创建Blob 删除Blob 重命名目录创建目录删除目录重命名Blob 层变更等:

图 15.5 – 创建事件订阅

图 15.5 – 创建事件订阅

事件类型由 Azure 资源预定义。使用事件订阅,您可以指定事件应在何处触发。您可以通过端点来选择,这可以是 Azure 函数、存储队列、混合连接、Webhook 等。

事件网格还允许您指定要定义的自定义主题,具有高达每秒 10,000,000 个事件的高吞吐量,并且每月免费提供 100,000 个操作。此服务作为在 Azure 上运行的 PaaS 提供服务,名为 Azure Arc 上的 Kubernetes 事件网格

为了支持更多的事件,使用分区进行大规模扩展,以及一个低延迟的大数据流平台,可以使用 Azure Event Hubs。此服务提供可靠的事件交付,如果事件尚未交付,则事件将存储最多 7 天。事件网格还与流分析有很好的集成。

让我们使用 Azure 队列和 Azure 事件网格更新 Codebreaker 解决方案。

注意

在撰写本文时,.NET Aspire 已计划支持 Azure 事件网格。事件网格、队列存储和 Azure 服务总线已经可用。

让我们开始使用 Codebreaker 机器人服务从 Azure 队列存储读取。

创建一个从 Azure 队列存储读取的服务

我们之前使用的 Codebreaker.Bot 项目提供了一个最小化的 API 服务。随着更新,不再需要 REST API - 一个简单的控制台应用程序就足够了。只需创建一个新的控制台应用程序(dotnet new console -o Codebreaker.BotQ),并将 Codebreaker.Bot 的源代码复制过来。新的机器人也将使用 gRPC 与游戏 API 服务进行通信。因为这不是 ASP.NET Core 应用程序,所以需要以下 NuGet 包来支持 gRPC:

  • Google.Protobuf

  • Grpc.Net.ClientFactory

  • Grpc.Tools

对于依赖注入容器,我们还需要 Microsoft.Extensions.Hosting,对于 .NET Aspire 存储队列组件,我们需要 Aspire.Azure.Storage.Queues

接下来,我们将更新应用程序模型。

定义 Azure 存储的应用程序模型

使用 AppHost 项目,引用新创建的项目 Codebreaker.BotQ,并添加 Aspire.Hosting.Azure.Storage NuGet 包,以便您可以使用 Azure 存储资源。

使用 AddAzureStorage 方法通过应用程序模型指定 Azure 存储:

Codebreaker.AppHost/Program.cs

// code removed for brevity
if (startupMode == "OnPremises")
{
}
else
{
  var storage = builder.AddAzureStorage("storage");
  var botQueue = storage.AddQueues("botqueue");
}

Azure 存储资源支持队列、表和块。这次,我们将使用队列,因此我们调用了 AddQueues 扩展方法。

项目配置引用队列:

Codebreaker.AppHost/Program.cs

string botLoop =
  builder.Configuration.GetSection("Bot")["Loop"] ??
    "false";
string botDelay =
  builder.Configuration.GetSection("Bot")["Delay"] ??
    "1000";
// code removed for brevity
builder.AddProject<Projects.Codebreaker_BotQ>("bot")
  .WithReference(insights)
.WithReference(botQueue)
  .WithReference(gameAPIs)
  .WithEnvironment("Bot__Loop", botLoop)
  .WithEnvironment("Bot__Delay", botDelay);

注意使用新的机器人项目而不是旧的项目。新项目引用队列以传递连接字符串。此外,我们指定了 LoopDelay 参数,这些参数从配置中读取,并在启动 bot-service 时设置为环境变量。

这些值在 AppHost 开发配置中指定:

Codebreaker.AppHost/appsettings.Development.json

{
  // configuration removed for brevity
  "Bot": {
    "Loop": true,
    "Delay": 2000
  }
}

新的 bot-service 可以循环读取存储队列中的值——这在此处进行配置。当与 Azure 发布时,循环就不需要了。这将在后面的 将解决方案部署到 Microsoft Azure 部分中介绍。

现在已经指定了应用程序模型,让我们继续新的机器人项目。

使用存储队列组件

在之前的机器人项目中,我们接收了值以便我们可以开始玩一系列的游戏。新的机器人也需要相同的信息:

Codebreaker.BotQ/Endpoints/BotQueueClient.cs

public record class BotMessage(
  int Count, int Delay, int ThinkTime);

Count 属性用于要玩的游戏数量,Delay 属性用于游戏之间的延迟,ThinkTime 属性用于游戏移动之间的思考时间值。

BotQueueClientOptions 类用于接收从 AppHost 传递的配置值:

Codebreaker.BotQ/Endpoints/BotQueueClient.cs

// code removed for brevity
public class BotQueueClientOptions
{
  public bool Loop { get; set; } = false;
  public int Delay { get; set; } = 1000;
}

BotQueueClient 类的构造函数中,optionslogger、之前使用的 CodebreakerTimerQueueServiceClient 被注入:

Codebreaker.BotQ/Endpoints/BotQueueClient.cs

public class BotQueueClient(
  QueueServiceClient client,
  CodebreakerTimer timer,
  ILogger<BotQueueClient> logger,
  IOptions<BotQueueClientOptions> options)
{
// code removed for brevity

QueueService 客户端类来自 Azure.Storage.Queues 命名空间,并与 Azure 存储队列资源通信,以获取有关队列的信息,以及创建队列。通过实现 CodebreakerTimer,使用计时器来一局接一局地玩游戏。它使用我们从队列中接收到的值。

RunAsync 方法启动工作:

Codebreaker.BotQ/Endpoints/BotQueueClient.cs

public async Task RunAsync()
{
  var queueClient = client.GetQueueClient(«botqueue»);
  await queueClient.CreateIfNotExistsAsync();
  var deadLetterClient = client.GetQueueClient(
    «dead-letter»);
  await deadLetterClient.CreateIfNotExistsAsync();
  bool repeat = options.Value.Loop;
  do
  {
    await ProcessMessagesAsync(
    queueClient, deadLetterClient);
    await Task.Delay(options.Value.Delay);
  } while (repeat);
}
// code removed for brevity

要从队列中读取消息,我们使用 QueueClient 类。QueueServiceClient 方法 GetQueueClient 返回 QueueClient 以与名为 botqueue 的队列通信。根据我们之前指定的应用程序模型,只创建了存储帐户,而不是队列本身。如果它不存在,我们创建队列。然后——在循环中——我们调用 ProcessMessagesAsync。如果循环未设置,则只检索一次消息。这可以在 将解决方案部署到 Microsoft Azure 部分中讨论的发布 Azure 容器应用作业时使用。

注意

可以检查死信队列以确定是否出现消息问题。例如,当消息无法成功处理几次时,例如,当接收者抛出异常时,消息将被写入死信队列。

接下来,ProcessMessageAsync 从队列中读取消息:

Codebreaker.BotQ/Endpoints/BotQueueClient.cs

private async Task ProcessMessagesAsync(
  QueueClient queueClient,
  QueueClient deadLetterClient)
{
  QueueProperties properties =
    await queueClient.GetPropertiesAsync();
  if (properties.ApproximateMessagesCount > 0)
  {
    QueueMessage[] messages =
      await queueClient.ReceiveMessagesAsync();
    foreach (var encodedMessage in messages)
    {
      if (encodedMessage.DequeueCount > 3)
      {
        await deadLetterClient.SendMessageAsync(
          encodedMessage.MessageText);
        await queueClient.DeleteMessageAsync(
          encodedMessage.MessageId,
          encodedMessage.PopReceipt);
        continue;
      }
      byte[] bytes = Convert.FromBase64String(
        encodedMessage.MessageText);
      string message = Encoding.UTF8.GetString(bytes);
      var botMessage =
        JsonSerializer.Deserialize<BotMessage>(message);
      timer.Start(
        botMessage.Delay,
        botMessage.Count,
        botMessage.ThinkTime);
      await queueClient.DeleteMessageAsync(
        encMessage.MessageId, encMessage.PopReceipt);
    }
  }
}
// code removed for brevity

首先,检查队列的属性以查看是否可用消息,使用 ApproximateMessagesCount 属性。如果是这种情况,使用 ReceiveMessagesAsync 获取消息。此方法从队列中读取消息,此时消息将不再对其他人可见。消息不可见的时间可以使用 visibilityTimeout 参数设置。默认值为 30 秒。成功反序列化消息后,使用 DeleteMessageAsync 删除消息。timer.Start 方法启动一个异步玩游戏的任务。因此,如果游戏需要更长的时间来玩(有多个游戏或思考时间更长),这不会影响删除消息。如果消息返回到队列,它可以再次处理。实现检查获取的消息的出队计数。如果读取了三次,消息将进入死信队列,可以手动检查问题。

接下来,让我们配置 DI 容器:

Codebreaker.BotQ/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  builder.AddAzureQueueClient("botqueue");
  builder.Services.AddScoped<BotQueueClient>();
  var botConfig = builder.Configuration.GetSection("Bot");
builder.Services.Configure<BotQueueClientOptions>(
    section);
  builder.Services.AddScoped<CodebreakerTimer>();
  builder.Services.AddScoped<CodebreakerGameRunner>();
  builder.Services.AddSingleton<IGamesClient,
    GrpcGamesClient>()
    .AddGrpcClient<GrpcGame.GrpcGameClient>(
      client =>
      {
        client.Address = new Uri("https://gameapis");
      });
}

AddAzureQueueClient 方法使用 Aspire.Azure.Storage.Queues NuGet 包定义。此方法配置 Aspire 组件并将 QueueService 客户端注册到 DI 容器中。通过 AppHost 传递的环境变量,这些值使用 builder.Configuration.GetSection 获取并使用 BotQueueClientOptions 类进行配置,该类定义了循环的行为。除此之外,计时器、游戏运行器和 gRPC 的配置与之前的机器人服务实现相同。

现在,我们准备运行应用程序并测试队列。

运行应用程序

你可以在新的机器人服务项目中设置断点以验证队列的功能。当你启动应用程序时,会创建一个 Azure 存储帐户。随着 BotQueueClient 的初始化,消息队列被创建。这可以在 Azure 门户中验证,如图 15.6* 所示:

Figure 15.6 – 创建存储队列

图 15.6 – 创建存储队列

botqueuedead-letter 存储队列已经创建。现在,打开 botqueue 并传递一个有效的 JSON 消息,如图 15.7* 所示:

Figure 15.7 – 将消息添加到队列

图 15.7 – 将消息添加到队列

BotMessage 类:

{
  "Count": 3,
  "Delay": 5,
  "ThinkTime": 1
}

使用有效的 JSON 消息,你会看到消息被处理了。发送一个非 JSON 格式的消息后,你会在死信队列中看到该消息——在经过一些重试之后。

由于机器人现在在我们发送消息时开始玩游戏,让我们来看看下一个增强功能——使用 Azure Event Hubs。

向 Azure Event Hubs 发布消息

要使用 Azure Event Hubs,我们将实现游戏 API 服务,以便我们可以发布事件。

定义 Event Hubs 的应用程序模型

要在 AppHost 项目中使用 Azure Event Hubs,需要 Aspire.Hosting.Azure.EventHubs NuGet 包。

在这里,需要将 Event Hubs 添加到应用程序模型中:

Codebreaker.AppHost/Program.cs

var eventHub =
  builder.AddAzureEventHubs("codebreakerevents")
    .AddEventHub("games");
// code removed for brevity

AddAzureEventHubs 方法添加了一个 Azure Event Hubs 命名空间,AddEventHub 作为事件中心。命名空间是一个管理容器,包含网络端点和访问控制。默认创建的事件中心命名空间位于标准层。对于开发,您可以将其更改为基本层。事件中心在命名空间内创建。为了可扩展性,事件中心使用一个或多个分区。默认情况下,事件中心创建时带有四个分区。分区包含一个有序的事件流。分区的数量不会改变价格,但吞吐量单位数会改变。吞吐量单位数指定每秒的事件数。分区的数量应等于或高于吞吐量单位数。吞吐量单位可以根据需要更改;分区的数量只能在高级和专用层中更改。

指定事件中心后,我们可以引用它:

Codebreaker.AppHost/Program.cs

var gameAPIs =
builder.AddProject<Projects.Codebreaker_GameAPIs>(
    "gameapis")
    .WithExternalHttpEndpoints()
    .WithReference(cosmos)
    .WithReference(redis)
    .WithReference(insights)
    .WithReference(eventHub)
    .WithEnvironment("DataStore", dataStore);

使用游戏 API 服务,我们用事件中心替换了引用的实时服务。对实时服务的引用不再需要。

现在,我们可以查看游戏 API 服务。

使用 .NET Aspire Event Hubs 组件生成事件

要使用 .NET Aspire Event Hubs 组件,我们必须添加 Aspire.Azure.Messaging.EventHub NuGet 包。

使用此包,我们可以配置 DI 容器:

Codebreaker.GameAPIs/ApplicationServices.cs

  builder.AddAzureEventHubProducerClient(
    "codebreakerevents",settings =>
    {
settings.EventHubName = "games";
    });

从游戏 API 服务中,为了发送关于完成游戏的详细信息,在之前的章节中,我们创建了 LiveReportClient 类来调用 REST 服务和 GrpcLiveReportClient 类来调用 gRPC 服务。现在,我们可以使用 EventHubReportProducer 类实现我们之前使用的相同接口 – IGameReport

发送事件可以很容易地完成,如下所示:

Codebreaker.GameAPIs/Services/EventHubReportProducer.cs

public class EventHubReportProducer(
  EventHubProducerClient producerClient,
  ILogger<EventHubLiveReportClient> logger) :
  IGameReport
{
  public async Task ReportGameEndedAsync(
    GameSummary game,
    CancellationToken cancellationToken = default)
  {
    var data = BinaryData.FromObjectAsJson(game);
    await producerClient.SendAsync(
      [ new EventData(data) ],
      cancellationToken);
    // code removed for brevity
  }
}

EventHubReportProducer 类注入 EventHubProducerClient 类以向事件中心发送事件。使用 BinaryData.FromObjectAsJsonGameSummary 转换为 BinaryDataAzure.Messaging.EventHubs 命名空间中的 EventData 类允许我们传递一个字符串、BinaryDataReadOnlyMemory<byte>。然后,通过调用 SendAsync 方法发布事件。

现在我们已经发布了一些事件,让我们来订阅它们。

订阅 Azure Event Hubs 事件

Codebreaker.Live 项目之前提供了一种由游戏 API 服务调用的 gRPC 服务,用于通过 SignalR 发布完成的游戏。我们不再提供 gRPC 服务,而是可以订阅事件。

创建一个新的 Codebreaker.Ranking 项目,以便您可以提供最小化的 API。此项目将接收与 Codebreaker.Live 相同的事件,但将它们写入数据库以提供基于日、周和月的游戏排名。

要创建 Codebreaker.Ranking 项目,请使用以下命令:

dotnet new webapi -minimal -o Codebreaker.Ranking

将新创建的项目作为引用添加到 Codebreaker.AppHost,并引用 Codebreaker.ServiceDefaults 以配置服务默认值。现在,我们可以更新应用模型。

定义事件中心的订阅者应用模型

使用 AppHost 项目,直播和排名项目引用事件中心,类似于事件发布项目:

Codebreaker.AppHost/Program.cs

var storage = builder.AddAzureStorage("storage");
var blob = storage.AddBlobs("checkpoints");
var live =
  builder.AddProject<Projects.Codebreaker_Live>("live")
  .WithExternalHttpEndpoints()
  .WithReference(insights)
  .WithReference(eventHub)
  .WithReference(signalR);
builder.AddProject<Projects.Codebreaker_Ranking>("ranking")
  .WithExternalHttpEndpoints()
  .WithReference(cosmos)
  .WithReference(insights)
  .WithReference(eventHub)
  .WithReference(blob);
// code removed for brevity

订阅事件可以通过两种方式完成,要么使用事件中心消费者客户端,要么使用事件处理器客户端。事件中心消费者客户端使用起来更简单,并支持异步流。事件处理器客户端功能更强大,并支持并行接收多个分区。第二种选项需要在 Azure Blob 存储帐户中保存检查点。我们使用与队列相同的帐户。

我们将实现两种版本。Codebreaker 直播服务使用异步流,而具有 EventHubConsumerClient 类的事件中心消费者客户端符合其需求。Codebreaker 排名服务利用事件处理器客户端,使用 EventProcessorClient

使用异步流式传输的 Event Hubs 组件

当使用 Codebreaker.Live 项目时,需要引用 Aspire.Azure.Messaging.EventHubs NuGet 包:

注意

Codebreaker.Live 项目是在 第十三章 中创建的。在这里,我们将创建一个新的 SignalR 中心以提供流式传输。

Codebreaker.Live/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  builder.Services.AddSignalR()
    .AddMessagePackProtocol()
    .AddNamedAzureSignalR("signalr");
   builder.AddAzureEventHubConsumerClient("codebreakerevents",
  settings =>
  {
settings.EventHubName = "games";
  });
}

AddAzureEventHubConsumerClient 方法将 EventHubConsumerClient 类配置为 DI 容器中的单例。

现在,我们必须创建一个新的 SignalR 中心以注入 EventHubConsumerClient

Codebreaker.Live/Endpoints/StreamingLiveHub.cs

public class StreamingLiveHub(
  EventHubConsumerClient consumerClient,
  ILogger<StreamingLiveHub> logger) : Hub
{
  // code removed for brevity

通过使用主构造函数,EventHubConsumerClient 被注入以检索事件。

现在,创建 SubscribeToGameCompletions 方法:

Codebreaker.Live/Endpoints/StreamingLiveHub.cs

public async IAsyncEnumerable<GameSummary>
  SubscribeToGameCompletions(
    string gameType,
    [EnumeratorCancellation] CancellationToken
      cancellationToken)
{
  await foreach (PartitionEvent ev in
    consumerClient.ReadEventsAsync(cancellationToken))
  {
    GameSummary gameSummary;
    try
    {
      logger.ProcessingGameCompletionEvent();
      gameSummary = ev.Data.EventBody
        .ToObjectFromJson<GameSummary>();
    }
    catch (Exception ex)
    {
      logger.ErrorProcessingGameCompletionEvent(
        ex, ex.Message);
      continue;
    }
      if (gameSummary.GameType == gameType)
      {
        yield return gameSummary;
      }
      else
      {
        continue;
      }
    }
  }

SignalR 支持使用返回 IAsyncEnumerable 方法的异步流式传输。SubscribeToGameCompletions 方法接收一个游戏类型参数,仅返回此游戏类型的游戏完成情况。EventHubConsumerClient 通过调用 ReadEventsAsync 方法支持异步流式传输。如果接收到的游戏摘要符合请求的游戏类型,它将通过异步流返回给客户端。

在这一点上,中间件需要配置以引用新的中心:

Codebreaker.Live/ApplicationServices.cs

app.MapHub<LiveHub>("/livesubscribe");
app.MapHub<StreamingLiveHub>("/streaminglivesubscribe");

我们还需要通过异步流和新的链接更新客户端:

LiveTestClient/StreamingLiveClient.cs

public async Task SubscribeToGame(string gameType, CancellationToken cancellationToken = default)
{
  if (_hubConnection is null) throw new InvalidOperationException("Start a connection first!");
  try
  {
    await foreach (GameSummary summary in
      _hubConnection.StreamAsync<GameSummary>(
        "SubscribeToGameCompletions",
        gameType,
        cancellationToken))
    {
      string status = summary.IsVictory ? "won" : "lost";
      Console.WriteLine($"Game {summary.Id} {status} " +
        $"by {summary.PlayerName} after " +
        $"{summary.Duration:g} with " +
        $"{summary.NumberMoves} moves");
    }
  }
  catch (HubException ex)
  {
    logger.LogError(ex, ex.Message);
    throw;
  }
  catch (OperationCanceledException ex)
  {
    logger.LogWarning(ex.Message);
  }
}

使用我们在 第十三章 中创建的相同的 SignalR 初始化配置,客户端现在使用 SignalR HubConnection 类的 StreamAsync 方法异步流式传输服务返回的结果。

通过这些更改,你现在已经可以测试和运行解决方案,从消息队列开始,直到 SignalR 流式客户端,以接收完成的游戏。然而,让我们添加另一个事件中心客户端来处理消息,这次使用事件中心处理器。

使用.NET Aspire 事件中心组件处理消息

Codebreaker.Ranking 项目接收事件,将这些事件写入 Azure Cosmos 数据库,并为玩家提供最小的 API 来获取排名信息。此项目引用了.NET Aspire 的 Aspire.Azure.Messaging.EventHubsAspire.Azure.Storage.Blobs 组件:

Codebreaker.Ranking/ApplicationServices.cs

public static void AddApplicationServices(this IHostApplicationBuilder builder)
{
  // code removed for brevity
  builder.AddKeyedAzureBlobClient("checkpoints");
  builder.AddAzureEventProcessorClient("codebreakerevents",
    settings =>
    {
      settings.EventHubName = "games";
      settings.BlobClientServiceKey = "checkpoints";
    });
  builder.Services.AddDbContextFactory<RankingsContext>(
    options =>
    {
      string connectionString =
        builder.Configuration.GetConnectionString(
          "codebreakercosmos") ??
          throw new InvalidOperationException(
            "Could not read the Cosmos connection-string");
      options.UseCosmos(connectionString, "codebreaker");
    });
  builder.EnrichCosmosDbContext<RankingsContext>();
  builder.Services
    .AddSingleton<IGameSummaryEventProcessor,
      GameSummaryEventProcessor>();}

AddAzureEventProcessorClient 注册了 EventProcessorClient 类的单例实例。我们连接到相同的命名空间和事件中心,因此此配置相同。不同之处在于 AddKeyedAzureBlobClient 是.NET Aspire Blob 存储组件的一个方法。此方法将单例实例注册到 DI 容器中,以读取和写入 blob。通过设置 BlobClientServiceKey 来写入检查点,将存储连接到事件中心。

你也可以通过不注册键配置来简化配置。注册的一个默认存储组件会自动从事件中心组件中使用。

除了事件中心配置之外,还必须配置 EF Core 上下文以将接收到的游戏摘要信息写入 Azure Cosmos DB 数据库。查看第三章以获取更多详细信息。与第三章相反,我们在 DI 容器中注册了一个 EF Core 上下文工厂,这允许我们将它注入到单例对象中,并使用较短的生存周期创建上下文对象。

注册的 GameSummaryEventProcessor 是我们处理事件的实现:

Codebreaker.Ranking/Services/GameSummaryEventProcessor.cs

public class GameSummaryEventProcessor(
  EventProcessorClient client,
  IDbContextFactory<RankingsContext> factory,
  ILogger<GameSummaryEventProcessor> logger)
{
  public async Task StartProcessingAsync(
    CancellationToken = default)
  {
    // code removed for brevity
  }
  public Task StopProcessingAsync(
    CancellationToken cancellationToken = default)
  {
  }
}

该类注入 EventProcessorClient 和 EF Core 上下文工厂。此类实现了启动和停止事件处理的方法。

下面的代码片段显示了 StartProcessingAsync 方法:

Codebreaker.Ranking/Services/GameSummaryEventProcessor.cs

public async Task StartProcessingAsync(CancellationToken cancellationToken = default)
{
  // code removed for brevity
  client.ProcessEventAsync += async (args) =>
  {
    GameSummary summary = args.Data.EventBody
      .ToObjectFromJson<GameSummary1>();
    using var context = await factory.CreateDbContextAsync(
      cancellationToken);
    await context.AddGameSummaryAsync(summary,
      cancellationToken);
    await args.UpdateCheckpointAsync(cancellationToken);
  };
  client.ProcessErrorAsync += (args) =>
  {
    logger.LogError(args.Exception,
      "Error processing event, {error}",
      args.Exception.Message);
    return Task.CompletedTask;
  };
  await client.StartProcessingAsync(cancellationToken);
}

一旦你开始通过调用 StartProcessingAsync 方法处理事件,EventProcessorClient 类就会触发.NET 事件,这些事件在接收到消息时以及发生错误时被调用:ProcessEventAsyncProcessErrorAsync。接收到的消息被从二进制转换为 GameSummary 对象并写入数据库。此外,存储账户中的检查点也被写入,以便我们知道哪个事件消息是最后被处理的。

当这一切就绪后,启动应用程序,打开 Azure 门户向机器人队列发送消息,让机器人玩一些游戏,并监控事件发送情况。图 15.8显示了 Azure 门户显示的事件中心指标:

图 15.8 – 事件中心指标

图 15.8 – 事件中心指标

除了检查事件中心指标外,还要验证写入排名数据库的数据。此外,启动 SignalR 客户端应用程序,以便可以使用异步流监控事件数据。

在 Azure 门户中打开事件中心实例,并在 设置 类别下选择 配置(见 图 15**.9):

图 15.9 – 事件中心配置

图 15.9 – 事件中心配置

在这里,你可以看到配置的分区数量,并且可以禁用中心。关于 ranking-service 在某一天没有运行,之后仍有足够的时间处理游戏。

你还可以配置如何捕获数据(功能 | 捕获)使用 Azure 存储帐户(Avro 序列化格式)或 Azure 数据湖(ParquetDelta Lake 序列化格式)。在配置捕获或其他 SKU 之前,请确保查看定价选项。

提及价格,当你将解决方案部署到 Microsoft Azure 时,你需要注意哪些方面?

将解决方案部署到 Microsoft Azure

当使用低负载时,当它在 Microsoft Azure 上运行时,完整的解决方案并不昂贵。CPU 功率通常会导致更高的成本。Azure 容器应用运行了多少个容器?bot-servicegame-apis 服务、live-serviceranking-service 和 Redis 容器。game-apis 服务应按最小值 1 进行扩展,这为第一个用户提供快速响应,以便他们能够得到快速的第一个答案。如果服务空闲时扩展到 1,则会有一个空闲价格,这可以显著降低 CPU 成本。bot-servicelive-serviceranking-service 可以缩减到 0,这意味着在 CPU 和内存方面没有成本。然而,请注意自定义健康检查(在第 12 章 中介绍),这可能会对缩减到 0 产生不利影响。对于 live-service,如果没有监听器查询正在运行的游戏,则无法订阅事件。因此,只有在客户端连接时才适用成本。

机器人包含一个持续运行的循环,并反复检查队列。在 Azure 容器应用环境中,这并不是必要的。在这里,我们可以创建一个 Azure 容器应用作业 资源。此资源仅基于触发器启动——例如,cron 时间或存储队列中可用的消息等事件。

使用 .NET Aspire 默认情况下不支持创建 Azure 容器应用作业。然而,通过一些定制可以实现这一点。以下是你需要做的:

  1. 使用 azd init 初始化项目。

  2. 使用 azd infra synth 创建 Bicep 文件和清单文件。

  3. 使用 azd provision 创建 Azure 资源。

  4. 更改应作为容器应用作业而不是容器应用部署的项目清单文件。

  5. 使用 azd deploy 部署项目(你可以通过 azd deploy <service> 逐个项目部署或部署所有项目)。

由于.NET Aspire 的快速更新,请查看本章 GitHub 仓库中的 README 文件以获取最新更新。

接下来,让我们看看使用 Azure 服务的替代选项。

使用 Apache Kafka 进行事件处理

Apache Kafka 可以用作 Azure 队列存储和 Azure 事件中心的替代品——尤其是在本地解决方案方面。这项技术被许多公司在他们的本地环境中用于高性能的应用程序到应用程序的消息传递,支持可扩展的多生产者和消费者环境(如事件中心),并支持类似消息队列的只读一次场景。

使用带有OnPremises启动配置文件的 AppHost 启动现在将使用之前创建的Codebreaker.Bot。这使用 REST API 而不是消息队列,替换了game-apis服务的发布事件机制,并使ranking-service的事件订阅使用 Apache Kafka。

首先,我们将更改app-model

配置 app-model 中的 Apache Kafka

要使用app-model中的 Apache Kafka 资源,我们必须添加Aspire.Hosting.Kafka NuGet 包:

Codebreaker.AppHost/Program.cs

var kafka = builder.AddKafka("kafkamessaging");
// code removed for brevity
var gameAPIs = builder.AddProject<Projects.Codebreaker_GameAPIs>("gameapis")
  .WithExternalHttpEndpoints()
  .WithReference(sqlServer)
  .WithReference(redis)
  .WithReference(kafka)
  .WithEnvironment("DataStore", dataStore)
  .WithEnvironment("StartupMode", startupMode);
  builder.AddProject<Projects.Codebreaker_Ranking>("ranking")
  .WithExternalHttpEndpoints()
  .WithReference(cosmos)
  .WithReference(kafka)
  .WithEnvironment("StartupMode", startupMode);

AddKafka方法为本地开发添加了一个 Docker 容器。这个资源被game-apis服务和ranking-service引用,用于转发连接。StartupMode与启动配置文件配置,并作为环境变量转发到这两个项目,以便它们可以在 Azure 事件中心和 Apache Kafka 之间进行选择。

接下来,我们将使用 Aspire 组件来发布事件。

发布 Apache Kafka 事件

当涉及到发布者和订阅者时,使用的是Aspire.Confluent.Kafka NuGet 包。在这个包中,在Confluent.Kafka命名空间内,定义了IProducer接口。通过KafkaGameReportProducer类注入的对象实现了这个接口,用于发布完成的游戏:

Codebreaker.GameAPIs/Services/KafkaGameReportProducer.cs

public class KafkaGameReportProducer(
  IProducer<string, string> producerClient,
  ILogger<KafkaLiveReportProducer> logger)
  : IGameReport
{
  public async Task ReportGameEndedAsync(
    GameSummary game,
    CancellationToken cancellationToken = default)
  {
    Message<string, string> message = new()
    {
      Key = game.Id.ToString(),
      Value = JsonSerializer.Serialize(game)
};
    string[] topics = ["ranking", "live"];
    foreach (var topic in topics)
    {
      _ = producerClient.ProduceAsync(topic, message,
        cancellationToken);
    }
    producerClient.Flush(TimeSpan.FromSeconds(5));
    logger.GameCompletionSent(game.Id, "Kafka");
    return Task.CompletedTask;
  }
}

KafkaGameReportProducer实现了之前使用的相同接口——即IGameReportIProducer接口的泛型参数定义了键和值的类型。使用 Kafka 时,可以指定序列化器,这允许自定义序列化。我们可以使用简单字符串,它们在不同平台上易于工作。使用.NET 时,我们可以使用System.Text.Json序列化器将GameSummary对象序列化为字符串。

IProducer接口定义了ProduceAsync方法来发布消息。第一个参数命名了一个主题。在调用ProduceAsync方法时,消息被发送到 Kafka 代理服务。消息将保留在那里,直到被主题的订阅者读取——直到保留期结束。默认保留期是 1 周。要向多个订阅者(live-serviceranking-service)发送消息,使用一个主题列表。

当消息被发送到代理时,ProduceAsync 方法返回 DeliveryResult。我们不等待消息被发送;相反,我们使用循环发送具有多个主题的相同消息。可以使用 Task.WhenAll 等待所有发送完成,或者使用 Flush 方法等待直到超时。Flush 方法返回队列中的项目数。在生产者被销毁之前,你需要确保所有消息都已发送到代理。因为生产者被配置为使用 DI 容器的单例,我们可以稍后执行刷新操作。

现在,我们必须配置 DI 容器:

Codebreaker.GameAPIs/ApplicationServices.cs

// code removed for brevity
string? mode = builder.Configuration["StartupMode"];
if (mode == "OnPremises")
{
  builder.AddKafkaProducer<string, string>(
    "kafkamessaging", settings =>
  {
    settings.Config.AllowAutoCreateTopics = true;
  });
  builder.Services.AddSingleton<IGameReport,
    KafkaGameReportProducer>();
}

AddKafkaProducer 方法将 IProducer 接口注册为单例。kafkamessaging 是与 app-model 一起使用的字符串,用于获取 Kafka 服务器的连接字符串。使用 KafkaProducerSettings 参数,可以配置遥测配置和生产者设置。在这里,AllowAutoCreateTopics 设置被设置为 true – 这是生产者的默认值。对于消费者,此值默认为 false。之前创建的 KafkaGameReportProducer 类也被注册为单例。IGameReport 接口已经被 GameService 类用于报告完成的游戏,无论这种报告是如何实现的。

现在,让我们使用排名服务订阅这些事件。

订阅 Apache Kafka 事件

Codebreaker.Ranking 这样的订阅应用程序需要引用相同的 .NET Aspire 组件包。当涉及到消费者类时,必须注入 IConsumer 接口:

Codebreaker.Ranking/GameSummaryKafkaConsumer.cs

public class GameSummaryKafkaConsumer(
  IConsumer<string, string> kafkaClient,
  IDbContextFactory<RankingsContext> factory,
  ILogger<GameSummaryEventProcessor> logger)
  : IGameSummaryProcessor
{
  public async Task StartProcessingAsync(
    CancellationToken cancellationToken = default)
  {
    kafkaClient.Subscribe("ranking");
    try
    {
      while (!cancellationToken.IsCancellationRequested)
      {
        try
        {
          var result = kafkaClient.Consume(
            cancellationToken);
          var value = result.Message.Value;
          var summary =
            JsonSerializer.Deserialize<GameSummary>(value);
          // code removed for brevity
          using var context = await
            factory.CreateDbContextAsync(
              cancellationToken);
          await context.AddGameSummaryAsync(
            summary, cancellationToken);
        }
        catch (ConsumeException ex) when
          (ex.HResult == -2146233088)
        {
          logger.LogWarning("Consume exception {Message}",
            ex.Message);
          await Task.Delay(TimeSpan.FromSeconds(10));
        }
      }
    }
  }
}

排名服务使用 Subscribe 方法订阅 ranking 主题的消息。该主题在发布消息时使用过。如果由于尚未写入消息而导致主题不存在,Consume 方法将抛出 ConsumeException 错误。该异常被捕获,并在延迟后重复执行 Consume 方法。当排名服务启动时,可能一个游戏尚未完成,并且 Kafka 服务的 Docker 容器尚未配置为保持状态。

当接收到消息时,它会写入数据库,正如我们之前在 Azure Event Hubs 中看到的。

现在,我们只需要配置 DI 容器:

Codebreaker.Ranking/ApplicationServices.cs

string? mode = builder.Configuration["StartupMode"];
if (mode == "OnPremises")
{
  builder.AddKafkaConsumer<string, string>(
    "kafkamessaging", settings =>
  {
    settings.Config.GroupId = "Ranking";
  };
  builder.Services.AddSingleton<IGameSummaryProcessor,
    GameSummaryKafkaConsumer>();
}

要注册 IConsumer 接口,我们使用 AddKafkaConsumer 方法。GroupId 需要与 Kafka 消费者客户端一起配置。组用于可伸缩性。类似于 Azure Event Hubs,Kafka 使用分区。使用相同组 ID 的多个订阅者从不同的分区接收消息。这允许实现高可伸缩性。

现在,使用 OnPremises 启动配置文件启动解决方案。启动机器人的 Open API 页面,让它玩一些游戏,并调试和监控服务。图 15.10 显示了 game-apis 服务的指标计数,包括传输的字节数:

图 15.10 – Kafka 指标

图 15.10 – Kafka 指标

检查字节数、已发布和订阅的消息以及发布者和订阅者的队列大小。现在也是休息一下,玩几轮 Codebreaker 的好时机。

摘要

在本章中,你学习了如何通过使用异步通信技术、消息和事件来解耦服务。使用 Microsoft Azure,我们使用了 Azure 存储账户中的队列和 Azure Event Hubs 中的事件。除了使用这些 PaaS 服务外,你还可以在 Azure Container Apps 环境中运行 Kafka,但需要使用应用程序模型进行配置。

你还学习了使用消息队列与具有多个订阅者的发布/订阅事件模型之间的区别。

请务必查看“进一步阅读”部分中关于 Azure Service Bus 的 .NET Aspire 组件。此服务提供了更多功能,包括消息队列,你将了解一些你已知的 Apache Kafka 的概念。

在介绍了所有不同的服务之后,在下一章中,我们将探讨在将应用程序部署到生产环境时应考虑的更多因素,并将解决方案部署到 Kubernetes 集群。

进一步阅读

要了解更多关于本章讨论的主题,请参阅以下链接:

第十六章:在本地和云中运行应用程序

到上一章为止,我们在 Codebreaker 应用程序中添加了额外的功能;在第15 章中,我们添加了使用异步通信进行通信的服务。我们使用了 Azure Storage 队列和 Azure Event Hubs 与 Azure Codebreaker 变体;在本地版本中,我们添加了一个 Kafka 容器。

在本章中,我们将探讨将解决方案部署到微软 Azure 和本地环境时需要了解的内容。使用 Azure,我们从第五章开始部署解决方案到 Azure Container Apps 环境。Azure Container Apps 环境在幕后使用 Kubernetes。在本章中,我们直接部署到一个 Kubernetes 集群,它很容易在本地环境和任何云中使用。

在本章中,你将学习以下内容:

  • 使用 C#和 Aspire 自定义部署

  • 使用 Azure 创建 Kubernetes 集群

  • 使用 Aspir8 将应用程序部署到 Kubernetes

技术要求

与前几章一样,你需要一个 Azure 订阅、.NET 8 以及.NET Aspire,以及 Docker Desktop。在本章中,我们将使用一个新的工具,Aspir8,将应用程序部署到 Kubernetes 集群。

本章的代码可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Pragmatic-Microservices-with-CSharp-and-Azure/

ch16文件夹中,你会看到可以部署的项目。本章最重要的项目是Codebreaker.AppHost,它使用 Azure 原生云服务定义了应用程序模型,以及可以与本地环境一起使用的配置。此配置还用于将解决方案部署到 Kubernetes 集群。

考虑生产环境中的部署

Codebreaker 解决方案使用了几种不同的原生 Azure 云服务。在第8 章中,你看到了我们如何使用GitHub Actions通过批准来部署到不同的环境,如开发、测试、预生产和生产环境。随着上一章中添加了越来越多的服务,部署也需要相应更新。

在许多组织中,生产环境部署与开发环境有些脱节。通常,来自开发组织的不同团队使用不同的工具来管理这些部署。

持续集成CI)和持续部署CD)通常用于与源代码分离的仓库中。不同的产品,如 GitHub Actions、Azure DevOps 管道以及许多第三方提供的产品,都被使用。

从管道中,可以触发 Azure Developer CLI(azd),使用 Bicep 脚本,直接使用 Azure CLI 或 PowerShell 脚本,或者使用 Terraform、Ansible、Chef 和 Puppet 等第三方产品。

在决定不同产品之间时,也需要考虑生产环境的要求,以及与开发环境的不同之处。在生产环境中,预计会有不同的负载。对于负载测试,使用与生产环境相同的基础设施是有用的。因此,对于环境的完整基础设施,需要能够轻松创建。

基础设施需要映射业务需求——如果事情没有按预期工作,会损失多少收入?我们需要考虑以下这些话题:

  • 可伸缩性:适应不断变化的需求。需求可能会随着时间的推移略有增加,或者也可能有需求激增。

  • 可靠性:确保服务按预期工作。

  • 可用性:确保服务在客户所在的位置可用。可用性指标是平均故障间隔时间MTBF)——故障发生前的平均时间——和平均修复时间MTTR)——服务再次运行所需的时间。

  • 恢复:如果发生中断,可以使用的恢复指标是恢复点时间RTO)——应用程序不可用的可接受时间——和恢复点目标RPO)——数据损失的最大允许时间。

这些要求需要与业务需求进行比较。通过冗余,资源被复制,并且多个服务正在运行。没有单点故障。数据可以在 Azure 区域内的一个数据中心内复制,在 Azure 区域的不同数据中心之间(Azure 可用区),以及在不同 Azure 区域之间(使用多区域架构)复制。

生产环境的一个要求是增强安全性。数据保护需要确保个人用户数据安全。通过静态加密,数据在数据库中存储时被加密。而不是使用服务管理的密钥,可以使用客户管理的密钥。使用客户管理的密钥在许多 Azure 服务中是可能的,但通常需要不同的(更昂贵的)SKU 来启用客户管理的密钥。虚拟网络是另一个增强安全性的选项。使用子网,可以限制对数据库服务器的访问。私有端点可以用来限制仅对特定服务的访问,防止数据泄露。可以配置 IP 防火墙规则。

我们不能在这里讨论所有不同的要求,但一个重要的启示是,在生产环境中,我们可能需要一些额外的 Azure 资源(如虚拟网络),不同的配置和其他 SKU。参见图 16。1,了解 Codebreaker 应用程序如何利用多个 Azure 区域。

图 16.1 – 带虚拟网络的 Codebreaker

图 16.1 – 带虚拟网络的 Codebreaker

此图显示了美国、欧洲和亚洲的 Azure 区域,以及跨区域复制的 Azure Cosmos DB。数据库由运行在数据库同一区域的 Container Apps 访问。同一区域的前端(Blazor)和后端(游戏 API)可以在一个 Azure Container Apps 环境中运行,而游戏 API 服务仅限内部访问。使用配置了防火墙的 Azure Application Gateway 来访问 Blazor Web 应用程序。Azure Traffic Manager 可以在不同区域之间进行路由。

Codebreaker 解决方案是如何满足所有要求的?具有可扩展性、可靠性和安全性,应用程序使用的所有资源都需要经过验证。在第十二章中,我们向 Codebreaker 服务添加了巨大的负载以测试扩展和扩展。由于开发服务的无状态特性,使用的资源也相应地扩展,我们预计在满足所有要求方面不会有问题。Azure Cosmos DB 数据库可以全球复制,即使进行多区域写入以将游戏存储在用户附近,也能提供最佳性能。我们关注了分区键,它不会阻止其他游戏玩家对数据库的写入。Azure Event Hubs(在第十五章中添加)提供了比所需更多的性能。标准 SKU 支持每个吞吐量单位每秒 1,000 个事件。可以添加额外的吞吐量单位,并且可以切换到提供更多资源的 Premium 层级。一个重要的方面是看到正在发生什么,以便能够及时反应,这在第十一章中有所涉及。

虽然许多组织有独立负责开发和基础设施的团队,但这也有一些缺点。

第六章中,我们介绍了如何使用从应用模型创建的.NET Aspire 清单来创建 Bicep 脚本。这些 Bicep 脚本可以根据生产环境的要求进行定制。使用定制 Bicep 脚本的不利之处在于,应用模型上的更改不会自动反映到 Bicep 脚本中。Bicep 脚本需要手动再次更新。

如果能够使用 C#代码完全定义 Azure 基础设施配置,包括所有需要的不同方面,那将非常棒。当应用模型更新时,基础设施配置也会同时改变。

使用 C#和.NET Aspire 定制部署

在撰写本文时,正在进行增强以实现这一功能。目前,它仅处于实验模式,可用的 API 可能会发生变化,因此我们只会简要地探讨这一点。

要定义 .NET Aspire 应用程序模型,API 有一个带有委托参数的重载。例如,我们迄今为止使用的 AddAzureKeyVault 方法是 IDistributedApplicationBuilder 接口的扩展方法,并使用一个 name 参数。第二个重载指定了一个额外的 Action 委托参数。这个重载应用了 Experimental 属性来标记该 API 可能会更改。与这个委托一起使用的参数是 IResourceBuilder<AzureKeyVaultResource>ResourceModuleConstructKeyVault。这允许我们在创建 Azure Key Vault 时配置从参数检索到的秘密:

#pragma warning disable AZPROVISION001
var aSecret = builder.AddParameter("aSecret", secret: true);
var keyVault = builder.AddAzureKeyVault("keyvault",
  (_, construct, _) =>
  {
    var secret = new KeyVaultSecret(construct,
      name: "secret1");
    secret.AssignProperty(p => p.Properties.Value,
      aSecret);
  });
#pragma warning restore AZPROVISION001

使用这里的方法,委托的第一个和第三个参数被忽略。ResourceModuleConstruct 类型的第二个参数指定了创建 KeyVaultSecret 的范围——它是为这个 Azure Key Vault 创建的。

另一个示例展示了如何使用 Azure 存储帐户配置属性和调用构建器的方法:

var storage = builder.AddAzureStorage("storage",
  (builder, _, account) =>
  {
    builder.AddQueues("botqueue");
    builder.AddBlobs("checkpoints");
    account.AssignProperty(p => p.AccessTier, "Hot");
    account.AssignProperty(p => p.Sku.Name,
      "Standard_LRS");
  });

在创建 Azure 存储帐户时,使用 IResourceBuilder<AzureStorageAccount>StorageAccount 参数与 Action 委托,第二个参数被忽略。IResourceBuilder 用于使用存储帐户创建队列和 blob 容器。我们之前已经使用这些 AddQueuesAddBlobs 方法,而不需要通过实验性 API 使用 AddStorageAccount 的返回值来调用这些方法。AddAzureStorage 方法返回一个构建器。这只是为了方便,在这个代码块中定义它。StorageAccount 参数用于指定属性,将 SKU 设置为本地冗余,并将访问层设置为热,这对于操作来说更便宜,但对于存储来说更昂贵。

许多组织正在改变他们部署和管理基础设施的方式。了解这些发展情况对于决定应该采取什么方向以及哪些工具最适合组织的需求非常有用。

目前,API 很可能将发生变化——所以请谨慎使用。随着 .NET Aspire 的快速发展,新功能可以快速改进,这个功能可能不会太远(在撰写本文时)发布。请查看本章的 README 文件以获取更新。

接下来,我们将探讨如何轻松部署到 Kubernetes。

使用 Microsoft Azure 创建 Kubernetes 集群

虽然 Azure Container Apps 环境基于 Kubernetes,但不能使用 Kubernetes 工具(kubectl);Kubernetes 功能被抽象化以简化。Kubernetes 是一个开源系统,用于扩展和管理容器化应用程序,并被许多公司在其本地环境中使用。有了这个,对于许多公司来说,能够在本地和任何云环境中运行服务非常重要。请参阅 进一步阅读 部分,以获取有关 Kubernetes 的更多信息。

Codebreaker 应用程序已构建了两个启动配置文件。我们将OnPremises启动配置文件发布到 Kubernetes 集群。例如,使用此启动配置文件时,Kafka 代替了 Azure 事件中心。

通过安装 Docker Desktop,您可以启用 Kubernetes。这个单节点集群仅用于小型测试场景。相反,我们将使用 Kubernetes 的托管版本:Azure Kubernetes 服务AKS)。与自行安装的集群相比,安装和管理要容易得多。

在创建集群之前,我们需要一个新的资源组和一个Azure 容器注册库ACR)。

使用 Azure CLI 创建一个新的资源组:

az group create -l westeurope -n rg-codebreaker-kubernetes

指定您选择的 Azure 区域并指定资源组名称。然后,使用az acr create创建一个新的 ACR:

az acr create -g rg-codebreaker-kubernetes --sku Basic -l <yourregion> -n <youracr>

使用之前创建的资源组,指定 SKU(最便宜版本,Basic适合此用途),并为注册库使用一个唯一名称。

使用此方法,在 Azure 门户中创建一个新的 AKS(portal.azure.com)——见图 16.2

图 16.2 – 基本 AKS 配置

图 16.2 – 基本 AKS 配置

使用第一个对话框选择刚刚创建的资源组。在集群详情中,您可以选择以下预设配置之一:生产标准开发/测试生产经济生产企业。虚拟机的大小根据预设而有所不同,并且某些功能配置不同。例如,生产企业有一个私有集群,其中 API 服务器仅可通过内部网络访问。为我们的测试环境选择开发/测试预设。输入集群名称并选择集群所在的区域。所有其他基本设置都可以保持默认设置——包括 AKS 定价层免费。使用免费提供的服务,只有在运行我们的构建 Docker 镜像的节点和其他配置的服务(如托管 Prometheus 和 Grafana)上才会产生费用。请注意,您配置的每个节点实例都是一个需要付费的虚拟机。开发/测试预设设置最适合进行少于 10 个节点的实验和测试。在标准定价层,您可以在集群中运行多达 5,000 个节点。

在配置基本设置后,点击下一步以配置节点池(图 16.3)。

图 16.3 – AKS 节点池

图 16.3 – AKS 节点池

节点池的默认配置为1。系统节点池需要 Linux 作为操作系统。这些节点池运行系统 Pod。要运行应用程序,首选用户节点池。为了更便宜的测试,我们只使用一个节点池——系统节点池。

在选择池的配置时,您可以选择操作系统、虚拟机大小、自动或手动扩展、最小和最大节点数量以及每个节点的最大 Pod 数量。允许的范围是每个节点 30-250 个 Pod。一个 Pod 可以运行一个或多个容器。在大多数 Kubernetes 配置中,一个 Pod 运行一个容器。如果 Pod 或运行 Pod 的节点失败,Kubernetes 将创建一个副本。

节点池 配置中,您还可以启用虚拟节点。虚拟节点利用 Azure 容器实例,如果需要更多负载,可以快速启动容器。

注意

创建用户节点池允许您为节点池选择 Windows。这允许在 Kubernetes 上运行旧版应用程序。这是 AKS 提供的与 Azure 容器应用不同的功能。

节点池 配置之后,点击 下一步 将引导到 网络 配置。保留默认设置。再次点击 下一步 将打开 集成 设置(见 图 16.4)。

图 16.4 – AKS 集成设置

图 16.4 – AKS 集成设置

集成 设置中,选择之前创建的 ACR。使用 AKS,提供了与注册表的直接集成。

点击 OnPremises 启动配置文件,将配置 Grafana 和 Prometheus 的 Docker 容器。或者,可以使用 Azure 服务管理的 Prometheus 和管理的 Grafana。

将剩余的设置保留为默认值。通过点击 审查 + 创建,进行最终检查。如果成功,点击 创建 按钮。创建 AKS 需要几分钟时间——但比手动创建 Kubernetes 集群快得多。

在成功部署到 Kubernetes 集群后,将 Kubernetes 命令行客户端 kubectl 连接到 AKS。使用 Docker Desktop,此工具与其一起安装。要将 kubectl 连接到此 AKS 安装,请使用以下命令:

az aks get-credentials --resource-group <your resource group> --name <your aks name>

这将 AKS 的连接添加到 %HOMEPATH%/.kube/config 配置文件中。现在,您可以使用 kubectl 工具:

kubectl get nodes

这将返回 AKS 服务中的运行节点。

接下来,让我们发布我们的应用程序。

使用 Aspir8 部署到 Kubernetes

使用 .NET Aspire,我们创建了应用程序模型来定义使用不同资源之间的所有依赖关系。首先,在 第一章 中,您看到了从应用程序模型创建的 Aspire 清单。此清单文件与其部署的技术无关。Azure 开发者 CLI 为部署解决方案创建 Bicep 脚本(见 第六章第八章)。开源工具 AspirateAspir8)(见 github.com/prom3theu5/aspirational-manifests)将 Aspire 清单文件转换为 Docker Compose 或 Kubernetes 的 Helm 图表或 kustomize 清单。

您可以为每个启动配置创建一个 Aspire 清单,如下所示:

cd Codebreaker.AppHost
dotnet run --launch-profile OnPremises -- --publisher manifest --output-path onpremises-manifest.json

我们的 app 模型定义了两个不同的版本。一个版本使用云原生 Azure 服务,而另一个选项则独立于任何云环境。第二个选项是通过使用 OnPremises 启动配置启动应用程序来配置的。

使用 dotnet run,我们通过传递 --launch-profile OnPremises 选项来启动应用程序,使用 launchprofiles.json 文件中指定的配置文件。-- 选项是一个分隔符,用于指定运行应用程序的参数。--publisher manifest 选项创建 Aspire 清单文件。

注意

我们与 Codebreaker 应用模型定义有严格的分离。在某种混合模式下也是可能的。例如,您可以使用在本地运行的解决方案,同时使用在 Azure 内运行的 Azure Application Insights 来获得此云服务提供的优势。您还可以使用 Azure Functions 在本地 Kubernetes 集群上运行。有许多选项可供选择最适合您需求的服务。

在使用 aspirate 工具之前,需要安装它:

dotnet tool install -g aspirate --prerelease

在撰写本文时,此工具尚未发布,因此需要设置 --prerelease 选项。-g 选项将此工具安装为全局工具。

注意

在撰写本文时,aspirate 工具处于预发布状态,预计会有所变化。请检查书籍仓库中 第十六章 的 README 文件以获取部署 Codebreaker 应用到 Kubernetes 的最新更新。

可选地,您可以使用 Aspir8 指定初始配置。

cd Codebreaker.AppHost
aspirate init --launch-profile OnPremises

aspirate 工具允许指定一个类似于 .NET CLI 的启动配置,以便相应地自定义配置。通过使用 aspirate init,您可以指定容器构建器并在 Docker Desktop 和 Podman 之间进行选择。默认设置是 Docker Desktop。对于容器注册表的回退值,输入您创建的 ACR 的 URL。aspirate init 会创建一个包含指定配置的 aspirate-state.json 文件。您可以重新运行 aspirate init,这将覆盖此配置文件。

创建 Kubernetes 清单

现在让我们使用带有启动配置的应用模型来生成发布到 Kubernetes 的清单:

aspirate generate --launch-profile OnPremises --output-path ./kustomize-output --skip-build --namespace codebreakerns

aspirate generate 可以创建用于部署的 Kubernetes 清单,以及构建和发布 Docker 镜像。在这里,我们不使用 --skip-build 选项来构建 Docker 镜像。使用 --launch-profile 选项,直接使用具有应用模型的 AppHost 项目。aspirate generate 还可以使用 --aspirate-manifest 选项引用之前生成的 .NET Aspire 清单。通过设置 --output-path,指定一个不同的文件夹来创建输出结果。--namespace 选项与 Kubernetes 相关,用于为部署的服务定义命名空间。这使得在集群上区分不同的服务变得更加容易。

注意

aspirate 支持使用 Helm 和 kustomize 生成清单。Helm 是一个使用名为 kustomize 的打包格式的打包管理器,而 kustomize 是一个配置管理器,它是 kubectl 内置的,采用无模板的方式来修补和合并 YAML 文件。

检查 kustomize-output 文件夹的结果。对于指定的每个项目,都会创建一个文件夹(例如,gameapisbotredis),其中包含 deployment.yamlservice.yamlkustomization.yaml

部署定义了一个 pod 和副本集的声明性配置。pod 的“期望状态”由部署描述。在这个文件中,你可以读取和更改使用的副本数量,以及 pod 中运行的容器。

服务定义了一个网络应用程序。这指定了应用程序使用的端口。服务在一个或多个 pod 中运行。

kustomization.yaml 文件引用了 deployment.yamlservice.yaml,并指定了配置值,例如你已经在 .NET Aspire 仪表板中看到的环境变量。

准备好清单文件后,我们可以创建 Docker 镜像并将它们推送到 ACR。

创建和推送 Docker 镜像

使用 aspirate build,我们可以构建并将 Docker 镜像发布到注册表。使用 aspirate 工具,可以指定用户名和密码值以将镜像推送到私有注册表。当使用 ACR 时,这并不是必需的,因为 Aspir8 使用 dotnet publish。只需确保使用以下方式登录到 ACR:

az acr login –name <yourregistry>

然后,你可以使用 aspirate build

aspirate build --launch-profile OnPremises --container-image-tag 3.8 --container-image-tag latest --container-registry <yourregistry>.azurecr.io

从此命令开始,指定你的注册表名称。指定多个标签将它们添加到仓库中,如 图 16.5 所示。

图 16.5 – AKS 仓库

图 16.5 – AKS 仓库

镜像被推送到 ACR,并显示 latest3.8 标签,如 aspirate build 中指定的。接下来,使用 Kubernetes 清单将镜像部署到集群。

部署到 Kubernetes

现在,我们可以将清单应用到 Kubernetes 集群:

aspirate apply --input-path kustomize-output

aspirate apply 命令使用之前创建的清单文件,通过使用 kubectl apply 命令将服务和部署应用到 Kubernetes 集群。只需确保 AKS 已配置为默认的 Kubernetes 环境(在创建 AKS 后使用之前使用的命令:az aks get-credentials)。

现在,你可以使用以下命令:

kubectl get deployments --namespace codebreakerns

此命令显示了 codebreakerns 命名空间中的部署。你可以看到可用的和准备就绪的部署。

类似地,使用此命令查看服务:

kubectl get services --namespace codebreakerns

在这里,你可以看到正在运行的服务的 IP 地址和注册的端口。

现在,你可以配置一个 aspirate。目前,请查看“进一步阅读”部分以了解如何完成此操作。

注意

Aspir8 除了支持 Kubernetes,还支持 Docker Compose,以及使用 kustomize 和 Helm。通过使用 aspirate generate,你可以为 compose 提供带有 --output-format 选项的参数。这将创建一个简单的 Docker Compose 文件,你可以使用 Docker CLI 启动它。

摘要

在本章中,您学习了在生产环境中使用微服务架构部署应用程序的一些最终考虑因素。您现在对在多个区域运行解决方案以及使用可用性区域有了认识,并且可以讨论这对您组织的影响。

您学习了 AKS 作为托管选项来托管 Kubernetes 集群,并使用 .NET Aspire 清单通过 Aspir8 创建部署。

通过达到本书的 第十六章,您完成了一次令人印象深刻的巡游,从最小的 API 开始,并在每一章中添加更多服务,使用不同的技术。

使用本书的存储库,解决方案计划更新到更新的 .NET 和 .NET Aspire 版本。随着新版本的可用,本书版本将保持在 dotnet8 分支中。

要查看 Codebreaker 的更多开发情况,请查看 github.com/codebreakerapp 组织。在那里,你可以看到解决方案的进一步开发,以及客户端应用程序的列表。此外,请访问 codebreaker.app 玩一些游戏——当然,现在你也可以使用在您的(托管)Kubernetes 集群中运行的版本。

进一步阅读

要了解本章讨论的主题的更多信息,您可以参考以下链接:

posted @ 2025-10-22 10:35  绝不原创的飞龙  阅读(4)  评论(0)    收藏  举报