ASP-NET-Core8-Web-API-开发-全-

ASP.NET Core8 Web API 开发(全)

原文:zh.annas-archive.org/md5/785b89e62f3e90a0cab042a17068bfaa

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到 ASP.NET Core 的世界!

自从 .NET Core 诞生以来,它经历了显著的发展,并已成为构建各种应用(包括 Web、桌面、移动、游戏和 AI 应用)的强大解决方案。随着 2023 年底 .NET 8 的发布,它巩固了自己作为现代应用中最强大和最灵活框架之一的地位。

ASP.NET Core 是建立在 .NET Core 平台之上的,继承了其优势,提供了跨平台兼容性、卓越的性能和模块化架构。它已成为构建可在任何操作系统上无缝运行的云原生应用的流行选择,包括 Windows、macOS 和 Linux,并且可以部署在任何云平台上,如 Azure、AWS 或 GCP。

随着组织越来越多地采用 ASP.NET Core 进行 Web 应用程序开发,对 ASP.NET Core 开发者的需求也在上升。无论您是从传统的 .NET Framework 过渡过来,还是对使用 ASP.NET Core 进行 Web API 开发是新手,本书都旨在满足您的需求。它将指导您使用 ASP.NET Core 构建您的第一个 Web API 应用程序,并为您提供构建强大、可扩展和可维护的 Web API 的知识和技能。

虽然 ASP.NET Core 为前端开发提供了强大的选项,例如 Razor Pages、Blazor 和 MVC,但本书专注于后端开发。你将探索一系列主题,包括基于 REST 的 API、gRPC API、GraphQL API 和实时 API,深入了解使用 ASP.NET Core 构建网络 API 的基本概念和最佳实践。

此外,我们还将深入研究测试方法和技术,如单元测试和集成测试,以确保您的 Web API 的质量和可靠性。我们还将探索现代开发实践,如 CI/CD、容器化、监控和云原生设计模式,这些都是当代 Web API 开发所必需的。

虽然本书是一个基础资源,但它仅仅触及了 ASP.NET Core 所能提供的表面。我鼓励您将其作为进一步探索 ASP.NET Core 广阔领域的垫脚石。尝试提供的示例代码,并参考书中的链接进行更深入的学习。不要忘记探索官方文档以获取最新的更新和功能。

我希望你会觉得这本书有用,并激励你探索 ASP.NET Core 的世界。祝您阅读愉快!

本书面向对象

本书是为想要学习如何使用 ASP.NET Core 8 构建 Web API 并使用 .NET 平台创建灵活、可维护、可扩展应用的开发者而编写的。具备 C#、.NET 和 Git 的基本知识将有所帮助。

本书涵盖内容

第一章, Web API 基础知识,提供了关于 Web API 的概述,包括其历史背景和各种 API 样式,包括基于 REST 的 API、gRPC API、GraphQL API 和实时 API。它还将讨论设计 Web API 的过程。

第二章, 开始使用 ASP.NET Core Web API,探讨了 ASP.NET Core 的基础知识,包括项目设置、依赖注入和最小 API。你还将学习如何使用 ASP.NET Core 创建你的第一个 Web API,以及如何使用各种工具对其进行测试。

第三章, ASP.NET Core 基础知识(第一部分),涵盖了 ASP.NET Core 的基础知识,包括路由、配置和环境。

第四章, ASP.NET Core 基础知识(第二部分),继续讨论 ASP.NET Core 基础知识,涵盖了日志记录和中间件。

第五章, ASP.NET Core 中的数据访问(第一部分:Entity Framework Core 基础知识),探讨了使用Entity Framework CoreEF Core)进行数据库交互的利用。你将深入了解使用 EF Core 实现 CRUD 操作。

第六章, ASP.NET Core 中的数据访问(第二部分:实体关系),介绍了 EF Core 的配置,以支持各种模型关系,包括一对一、一对多和多对多。

第七章, ASP.NET Core 中的数据访问(第三部分:技巧),提供了在 Web API 中使用 EF Core 的最佳实践,例如DbContext池、原始 SQL 查询、批量操作等。

第八章, ASP.NET Core 中的安全和身份,涵盖了围绕 Web API 的安全考虑。你将深入了解使用 ASP.NET Core Identity 实现身份验证和授权机制,以确保你的 Web API 的安全性。

第九章, ASP.NET Core 中的测试(第一部分 - 单元测试),探讨了测试方法和工具,包括 xUnit 和 Moq。你将学习如何实现单元测试以确保你的 Web API 的质量。

第十章, ASP.NET Core 中的测试(第二部分 - 集成测试),介绍了使用 xUnit 和WebApplicationFactory进行集成测试。你将学习如何实现集成测试来测试你的 Web API 组件。

第十一章, 开始使用 gRPC,探讨了 gRPC,一个用于构建高效 API 的现代高性能 RPC 框架。你将学习如何使用 ASP.NET Core 创建 gRPC 服务和客户端。

第十二章, 开始使用 GraphQL,介绍了 GraphQL,一种强大的 API 查询语言。你将学习如何使用 ASP.NET Core 创建 GraphQL API。

第十三章, SignalR 入门,探讨了 SignalR,一个用于 ASP.NET Core 的实时通信框架。您将学习如何使用 ASP.NET Core 创建实时 API 和客户端。

第十四章, 使用 Azure Pipelines 和 GitHub Actions 进行 ASP.NET Core CI/CD,涵盖了使用 Azure DevOps 和 GitHub Actions 构建测试和部署您的 Web API 应用程序的过程。它还介绍了使用 Docker 来容器化您的 Web API 应用程序。

第十五章, ASP.NET Core Web API 常见实践,提供了构建您的 ASP.NET Core Web API 应用程序的最佳实践。它涵盖了异步编程、缓存、HttpClientFactory 等主题。

第十六章, 错误处理、监控和可观察性,涵盖了错误处理、健康检查、监控和可观察性。您将学习如何在您的 Web API 中处理错误,以及如何使用各种平台和 OpenTelemetry 监控和观察您的 Web API。

第十七章, 云原生模式,探讨了现代 Web API 开发所必需的先进架构和模式。您将深入了解云原生设计模式、领域驱动设计DDD)、命令查询责任分离CQRS)、重试模式、断路器模式等。

第十八章利用开源框架,涵盖了各种开源框架,这些框架可用于简化开发和提高生产力,包括 ABP 框架、Clean Architecture、Orchard Core、eShop 和 .NET Aspire。

要充分利用本书

您需要具备使用 .NET 和 C# 进行编程的基本理解,并熟悉面向对象编程(OOP)的概念。如果您是 C# 新手,可以从 Microsoft Learn 和 freeCodeCamp 在 www.freecodecamp.org/learn/foundational-c-sharp-with-microsoft 学习 C#。

本书涵盖的软件/硬件 操作系统要求
.NET 8 SDK (dotnet.microsoft.com/en-us/download/dotnet) Windows, macOS, 或 Linux
Visual Studio Code (code.visualstudio.com/) Windows, macOS 或 Linux
Visual Studio 2022 社区版 (visualstudio.microsoft.com/downloads/) Windows, macOS 或 Linux
Seq (datalust.co/download) Windows, Docker/Linux
Prometheus (prometheus.io/download/) Windows, Docker/Linux
Grafana (grafana.com/oss/grafana/) Windows, Docker/Linux
Jaeger (www.jaegertracing.io/download/) Windows, Docker/Linux
Azure
Azure DevOps
GitHub

在本书中,我们使用 LocalDB,这是 SQL Server 的一个轻量级版本。它仅在 Windows 上可用。如果您使用 Mac 或 Linux,可以使用 Docker 容器运行 SQL Server。您也可以使用 SQLite。要使用 SQLite,您需要更新appsettings.json文件中的连接字符串,并安装 EF Core 的 SQLite 提供程序。有关更多详细信息,请参阅第五章

我们建议您亲自输入代码或通过 GitHub 仓库(下一节中提供链接)访问代码。这样做将帮助您更好地学习并长期保留知识。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8。如果代码有更新,它将在 GitHub 仓库中更新。

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

使用的约定

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

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“让我们使用IEnumerable接口查询数据库。”

代码块设置如下:

using (var serviceScope = app.Services.CreateScope()){    var services = serviceScope.ServiceProvider;    // Ensure the database is created.    var dbContext = services.GetRequiredService<AppDbContext>();    dbContext.Database.EnsureCreated();}

任何命令行输入或输出应如下编写:

cd GrpcDemo.Client dotnet add GrpcDemo.Client.csproj package Grpc.Net.Client

粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的词汇以粗体显示。以下是一个示例:“点击继续按钮。”

小贴士或重要注意事项

看起来像这样。

联系我们

我们欢迎读者的反馈。

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

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

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

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

分享您的想法

一旦您阅读了 使用 ASP.NET Core 8 开发 Web API,我们非常乐意听到您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢随时随地阅读,但无法携带您的印刷书籍吗?您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

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

优惠远不止这些,您还可以获得独家折扣、时事通讯和每日免费内容的每日电子邮件。

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

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

图片

packt.link/free-ebook/9781804610954

  1. 提交您的购买证明

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

第一章:网络 API 基础

在当今世界,网络 API是网络的骨架。每天有成千上万的人使用网络 API 来购买商品、预订航班、获取天气信息等等。在本章中,我们将学习网络 API 的基础知识。你可能想知道为什么我们要从基本概念开始。答案很简单——在我们构建一个 API 之前,我们需要理解网络 API 的基本概念。

本章介绍了几种不同的网络 API 风格,例如基于 REST 的 API、基于远程过程调用RPC)的 API、GraphQL API 和实时 API。我们还将学习如何设计它们。如果你想要开始开发网络 API,请随时跳到下一章。

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

  • 什么是网络 API?

  • 什么是 REST API?

  • 设计基于 REST 的 API

  • RPC 和 GraphQL API 是什么?

  • 什么是实时 API?

在阅读本章后,你将基本了解网络 API,并能够为你的项目选择正确的风格。让我们开始吧!

什么是网络 API?

API代表应用程序编程接口。正如其名所示,网络 API 是一组网络编程接口。例如,当你在一个网站上预订航班时,浏览器通过网络 API 向航空公司的服务器发出请求,以访问航空公司的数据库。然后,航空公司的服务器将航班信息返回给浏览器,让你能够在其中预订航班。

组织已经为几十年提供了 API。随着万维网的兴起,人们需要一种在服务器和客户端之间进行通信的方法。

我们可以使用不同的技术来构建网络 API,例如 Java、Python、Ruby、PHP、.NET 等等。此外,它们还有各种风格。你可能听说过SOAPWeb 服务REST等术语。它们都是基于HTTP协议,但以不同的方式进行通信。

在这本书中,我们将网络 API 视为比 REST 更广泛的概念。在数字世界中,随着需求或基础设施的变化,机器之间交流的方式也在变化。在 20 世纪 90 年代,人们关注的是如何改进使用相同平台的内部网络。TCP/IP 成为了这种通信的标准。几年后,人们需要找到一种方法来优化跨多个平台的通信。Web 服务出现了,它们使用了简单对象访问协议SOAP),这是为企业定义的,并确保基于不同平台构建的程序可以轻松交换数据。

然而,SOAP XML 相当庞大,这意味着它在使用时需要更多的带宽。在 2000 年代初,微软发布了Windows Communication FoundationWCF)。这帮助开发者管理使用 SOAP 的复杂性。WCF 基于 RPC,但仍然使用 SOAP 作为底层协议。随着时间的推移,一些旧标准,如 SOAP,已经过渡到 REST API,这将在下一节中讨论。我们将从 REST API 开始,然后继续讨论其他基于 Web 的 API 风格,例如 gRPC API、GraphQL API 和 SignalR API。

什么是 REST API?

REST,也称为表示状态转移,是由 Roy Fielding 在 2000 年他的博士论文《架构风格和网络软件架构设计》中提出的 Web API 架构风格。今天,一般来说,REST API 基于 HTTP,但实际上,Roy Fielding 的论文只是概述了理解架构风格的核心概念和约束,并不要求基于 REST 的架构有任何特定的协议,如 HTTP。然而,由于 HTTP 是 Web API 中最广泛使用的协议,我们将使用 HTTP 作为 REST API 的协议。

只需记住,REST 只是一种风格,而不是规则。当您构建 Web API 时,您不必遵循 REST 风格。您可以使用您喜欢的任何其他风格。您可以构建一个运行良好的 Web API,但它可能不是足够 REST。REST 是推荐的风格,因为它帮助我们建立约束,这有助于 Web API 的设计。它还帮助开发者如果他们遵循相同的风格,可以轻松地与其他 REST API 集成。

REST 的核心概念是术语表示状态转移。考虑一个 Web 系统,它是一组资源的集合。例如,它可能有一个名为books的资源。书籍的集合是一个资源。一本书也是一个资源。当您请求书籍列表时,您选择一个链接(例如,www.example.com/books),这将返回一个包含所有书籍的 JSON 字符串,从而产生下一个资源的表示,例如特定书籍的链接(例如,www.example.com/books/1)。您可以使用这个链接继续请求书籍。在这个过程中,表示状态被转移到客户端并渲染给用户。

解释 REST 的资源非常多。如果您想了解更多关于 REST 的信息,可以阅读维基百科上的以下文章:REST:表示状态转移的 Web 框架 (en.wikipedia.org/wiki/Representational_state_transfer)。

让我们来看看 REST 的约束,之后我们将向您展示一个简单的 REST API 示例。

REST 的约束

Roy Fielding 的论文为 REST API 定义了以下六个约束:

  • 客户端-服务器:此模式强制执行关注点分离的原则。服务器和客户端独立操作。客户端发送请求,服务器响应,之后客户端接收并解释响应。客户端不需要知道服务器的工作方式,反之亦然。

  • 无状态:服务器不维护任何客户端状态。客户端应在请求中提供必要的信息。这种无状态协议对于扩展服务器的容量很重要,因为它不需要记住客户端的会话状态。

  • 可缓存性:服务器的响应必须隐式或显式地包含有关响应是否可缓存的信息,允许客户端和中间件缓存响应。缓存可以在客户端机器的内存中、浏览器缓存存储中或在 内容分发网络CDN)中执行。这对于提高 Web API 的可扩展性和性能也很重要。

  • 分层系统:客户端不知道它如何连接到服务器。客户端和服务器之间可能存在多个层级。例如,可以在客户端和服务器之间放置一个安全层、代理或负载均衡器,而不会影响客户端或服务器代码。

  • 按需代码(可选):客户端可以从服务器请求用于客户端使用的代码。例如,网络浏览器可以请求 JavaScript 文件以执行某些任务。

  • 统一接口:这对于 RESTful 系统至关重要。它包含资源标识、通过表示进行资源操作、自描述消息以及作为应用程序状态引擎的超媒体。它简化并解耦了系统的架构,使得每个部分可以独立演进。

如果你觉得这些原则有点遥远或理论化,让我们来看一个例子。

REST API 示例

网站 jsonplaceholder.typicode.com/ 是一个生成假 JSON 数据的假 REST API。在您的浏览器中打开以下链接:jsonplaceholder.typicode.com/posts。您将看到一个返回的 JSON 字符串:

[  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  },
...
]

从前面的请求中,我们可以获取帖子集合的资源。

现在,我们可以通过其 ID 请求特定的帖子。例如,我们可以使用以下 URL 请求 ID 为 1 的帖子:jsonplaceholder.typicode.com/posts/1。响应如下:

{  "userId": 1,
  "id": 1,
  "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
  "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
}

就这样!在前面的例子中使用的 URL 是资源的标识符。响应(JSON 字符串)是资源的表示。资源通过在客户端和服务器之间传输的消息中的超文本表示来操作。

重要提示

一些文档使用 URI。一个 httphttpsftp。如今,术语 URL 仍然被广泛使用,因此,我们将在这本书中使用它。然而,我们应该知道它们有不同的范围。URI 是 URL 的超集。

要获取帖子资源,我们发送一个 GET 请求。还有一些其他方法用于操作资源,例如 POSTPUTPATCHDELETE,如下所示:

HTTP 方法 URL 操作 描述
GET /posts 读取 读取帖子集合
GET /posts/1 读取 通过 ID 读取一篇帖子
GET /posts/1/comments 读取 读取帖子的评论
POST /posts 创建 创建一个新的帖子
PUT /posts/1 更新 通过 ID 更新帖子
PATCH /posts/1 更新(部分) 通过 ID 更新帖子的部分内容
DELETE /posts/1 删除 通过 ID 删除帖子

表 1.1 – 操作资源的 HTTP 方法和 URL

还有其他一些不太常用的方法,例如 HEADOPTIONSTRACE

如我们所见,HTTP 方法映射到 创建、更新、读取和删除CURD)操作。但这是否一直如此?

我的 Web API 是否是 RESTful 的?

如前所述,REST 不是一个规则或规范。没有 官方 的 REST API 标准。与普遍看法相反,它不要求使用 JSON。此外,它也不要求使用 CRUD 模式。但 REST 实现确实使用了标准,例如 HTTP、URL、JSON、XML 等。人们应用 HTTP 方法和 JSON 来实现 REST,但他们可能没有有意地应用 Fielding 论文中最初描述的约束。这导致人们对他们的 API 是否是 RESTful 存在分歧。许多开发者描述他们的 API 为 RESTful,即使这些 API 并不满足 Fielding 论文中描述的所有约束。

坦白说,争论一个 Web API 是否 足够 RESTful 并没有好处。目标是让某物工作,而不是浪费时间在这种问题的讨论上。并非每个人都阅读了原始论文。技术也在快速发展。有句中国谚语:不管是白猫还是黑猫,只要能捉老鼠,就是好猫

然而,如果我们从零开始一个新项目,遵循约定将更加理想。通常,基于 REST 的 API 通过以下方面定义:

  • 基础 URL,即 API 的根地址,例如 http://api.example.com

  • HTTP 方法的语义,例如 GETPOSTPUTDELETE 等。

  • 媒体类型,它定义了状态转换数据元素,例如 application/jsonapplication/xml 等。

在这本书中,我们将尝试在开发基于 ASP.NET Core 的 REST API 时遵循这些约定。

现在我们已经对 REST API 有一个概述,让我们看看如何遵循约定来设计一个。

设计基于 REST 的 API

在编写代码之前,构建基于 REST 的 API 有许多步骤要走。开发团队需要与利益相关者沟通并分析需求。然后,他们需要编写用户故事(或工作故事)来定义期望的结果。这需要领域专家或主题专家的见解。本书不会涵盖这部分内容。相反,接下来,我们将专注于 API 设计,这更接近开发者所做的工作。

在过去几年中,API 优先的概念获得了更多的关注。API 优先的方法意味着 API 被视为项目的第一公民。这意味着在编写任何代码之前,API 应该如何表现已经有一个合同。这样,开发团队可以并行工作,因为合同将首先建立。开发者不必等待 API 发布就可以与前端或移动应用集成。他们可以根据合同模拟和测试 API。使用像Swagger这样的工具,构建 API 的过程可以自动化,例如 API 文档、模拟 API、SDK 等。自动化可以显著加快 API 和应用程序的开发速度,有助于提高上市速度。

下面是一些我们可以遵循的步骤来设计基于 REST 的 API:

  1. 确定资源。

  2. 定义资源之间的关系。

  3. 识别操作事件。

  4. 为资源设计 URL 路径。

  5. 将 API 操作映射到 HTTP 方法。

  6. 分配响应代码。

  7. 记录 API。

如果你熟悉前面的步骤,你可以跳过它们。但是,如果你不熟悉,请阅读以下章节。

一种流行的 API 描述格式是OpenAPI 规范OAS)。我们可以用它来描述 API 建模和其他 API 的细节。在这个阶段,我们不需要包括实现细节,因为我们只想制定一个合同。SwaggerHub (app.swaggerhub.com/home) 是一个我们可以用来设计 API 的工具。

确定资源

基于 REST 的 API 以资源为中心。资源是一组数据,例如帖子集合或用户集合。资源通过 URL 进行标识。客户端使用 URL 请求资源,服务器以资源表示的形式响应。资源的表示以超文本格式发送,可以被尽可能广泛的客户端解释。

识别领域范围和资源之间的关系非常重要。例如,如果你正在构建一个博客系统,你可能有一个帖子集合,每个帖子都有一个评论集合。API 的范围可能会随着时间的推移而演变。可能需要向当前领域添加更多资源,或者某些资源将被删除。此外,关系也可能发生变化。

让我们从简单开始。我们可以用博客系统作为例子。在需求分析之后,我们可以确定以下资源:

  • 帖子

  • 分类

  • 注释

  • 用户

  • 标签

您可能希望在这一步包括每个资源的某些属性。例如,一个帖子有一个标题、正文和发布时间。一个评论有一个正文和发布时间。一个用户有一个名字和电子邮件。在开发过程中,您可能会发现更多属性。

定义资源之间的关系

一旦确定了资源,我们就可以定义它们之间的关系。例如,一个帖子有一个评论集合。一个评论属于一个帖子。一个用户有一个帖子集合。

这种关系是通过这些资源如何相互关联来定义的。有时,这些关系在数据库中也存在,但有时,它们仅针对 REST 资源。

我们可以使用一些术语来描述这些关系:

  • 独立资源:这种资源可以独立存在。它不需要另一个资源存在。一个独立资源可以引用其他独立或依赖资源。例如,一个帖子是一个独立资源,它可以引用其作者。作者资源也是一个独立资源。

  • 依赖资源:这种资源需要另一个资源存在。它可以引用其他独立或依赖资源,但不能在没有父资源存在的情况下存在。例如,一个评论需要一个帖子作为其父资源;否则,它不能存在。一个评论可以引用其作者,这是一个独立资源。

  • Id 属性,它可以唯一地识别自身。

  • PostId 属性,它引用了一个帖子。

这些资源可以有以下三种关系类型:

  • 一对多:这是指一个资源有多个相关资源。例如,一个用户有多个帖子,但一个帖子只有一个作者。这也被称为父子(子)关系,这是在基于 REST 的 API 世界中可以看到的最常见的模式。

  • 一对一:这是指一个资源有一个相关资源。例如,一个帖子有一个唯一的作者,一栋房子只有一个地址。一对一关系是一对多关系的特例。

  • 多对多:这是指一个资源有多个相关资源,反之亦然。例如,一个博客有多个标签,一个标签也有多个博客。一部电影可以有多个类型,一个类型也可以有多个电影。在许多系统中,一个用户可以有多个角色,一个角色也可以有多个用户。

识别操作

接下来,我们可以考虑每个资源需要的操作。这些操作可能来自事先定义的用户故事。通常,每个资源都有其 CRUD 操作。请注意,操作可能包括 CRUD 之外的内容。例如,一个帖子可以被发布,或者可以被取消发布。一个评论可以被批准,或者可以被拒绝。在这个过程中,我们可能需要创建一个新的资源或属性来反映这个操作。

考虑域的范围很重要。CRUD 操作容易理解,但对于一些复杂的关系,我们可能需要来自领域专家的帮助。

当我们处理这些操作时,需要包括重要的输入和输出细节。我们将在下一步中使用它们。然而,没有必要包括每个资源的所有细节。我们以后有足够的时间来捕捉完整的设计。

对于博客系统的示例,我们可以为 Post 资源(包括但不限于以下)识别这些操作:

操作名称 资源(s) 输入 输出 描述
createPost() Post, category, user, tag Post detail 创建新帖子
listPosts() Post 一系列帖子 列出所有帖子
listPostsByCategory() Post and category Category ID 一系列帖子 按类别列出帖子
listPostsByTag() Post and tag Tag or Tag ID 一系列帖子 按标签列出帖子
searchPosts() Post Search keyword A list of posts 通过标题、作者和内容搜索帖子
viewPost() Post, category, and user Post ID Post detail 查看帖子详情
deletePost() Post Post ID 删除帖子
updatePost() Post and category Post detail 更新帖子
publishPost() Post Post ID 发布帖子
unpublishPost() Post Post ID 下线帖子

表 1.2 – 帖子资源的操作

对于某些操作,如 createPostdeletePost,输出是操作的结果。这可以用 HTTP 状态码表示。我们将在稍后讨论这个问题。

我们可以为其他资源列出更多操作。

设计资源 URL 路径

下一步是设计每个资源的 URL 路径。客户端使用 URL 来访问资源。尽管 REST 不是一个标准,但在设计 URL 路径方面有一些指南或约定。

使用名词而不是动词

在上一步中我们识别的操作事件是一些动作,例如 CreateListViewDelete 等。然而,URL 路径通常不会用动词表示。因为 HTTP 方法如 GETPOSTPUTDELETE 已经是动词了,所以没有必要在 URL 路径中包含动词。相反,我们应该使用名词来表示资源——例如,/posts

使用复数名词表示集合

如果一个资源是集合,我们应该使用复数名词来表示该资源。例如,/posts 是帖子集合的 URL 路径。要获取单个帖子的 ID,我们可以使用 /posts/{postId}

使用逻辑嵌套表示关系

对于具有关联关系的资源,通常,子资源(即依赖资源)应该嵌套在父资源之下,并且路径应包含父标识符。然而,这并不反映数据库结构。例如,一篇文章可以有一个评论集合;URL 看起来像 /posts/{postId}/comments。这清楚地表明评论与文章相关。

然而,如果关系太深或太复杂,嵌套的 URL 路径可能会太长。在这种情况下,我们可以重新思考如何更好地表示这些资源。例如,如果我们想从一个评论中检索作者的信息,我们可以使用 /posts/{postId}/comments/{commentId}/author。但这太过分了。相反,如果我们知道作者的 UserId,我们可以使用 /users/{userId}。避免在 URL 路径中使用深层嵌套,因为它会使 API 更复杂且不便于阅读。

允许过滤、排序和分页

同时返回所有记录不是一个好主意。我们可以使用过滤、排序和分页来返回客户端需要的记录子集。这些操作可以提高 API 的性能并提供更好的用户体验。

例如,如果我们想搜索特定关键词的文章列表,我们可以使用查询参数,例如 /posts?search=keyword。如果我们想按日期排序文章,我们可以使用 /posts?sort=date。要获取文章的第二页,我们可以使用 /posts?page=2。这些查询参数可以组合使用。

如果我找不到适当的动词来表示 HTTP 方法中的操作怎么办?

通常,HTTP 方法可以表示 CRUD 操作。然而,在现实世界中,有更多复杂性!例如,除了基本的 CRUD 操作外,还有其他操作,如发布或取消发布文章。那么,我们应该使用哪些 HTTP 方法呢?

这里事情可能会变得复杂。这个主题是开放的,但请记住,我们不是在争论 API 是否足够 RESTful。我们只是想让它工作。

对于这些场景,有不同方法:

  • 一种可能的解决方案是将此类操作视为子资源。因此,您可以使用 /posts/{postId}/publish 来发布文章。GitHub 使用以下 URL 来标记一个 gist:/gists/{gist_id}/star。更多信息,请参阅docs.github.com/en/rest/gists/gists#star-a-gist

  • 文章应该有一个 IsPublished 字段来指示它是否已发布。因此,实际上,publish 动作是一个更新动作,它只更新 IsPublished 字段。然后,您可以将其视为与 updatePost() 操作相同。

这里有一些博客系统的资源 URL:

操作名称 URL 输入 输出 描述
createPost() /posts 文章详情 创建一个新的文章
listPosts() /posts 一系列文章 列出所有文章
listPostsByCategory() /posts?categoryId={categoryId} 类别 ID 一系列文章 通过类别列出文章
listPostsByTag() /posts?tag={tagId} 标签或标签 ID 一系列文章 通过标签列出文章
searchPosts() /posts?search={keyword} 搜索关键字 一系列文章 通过标题、作者和内容搜索文章
viewPost() /posts/{postId} 文章 ID 文章详情 查看文章详情
deletePost() /posts/{postId} 文章 ID 删除文章
updatePost() /posts/{postId} 文章详情 更新文章
publishPost() /posts/{postId}/publish 文章 ID 发布文章
unpublishPost() /posts/{postId}/unpublish 文章 ID 取消发布文章

表 1.3 – 文章资源的 URL

一些 URL 是相同的,例如 deletePost()updatePost(),因为我们将通过 HTTP 方法来区分这些操作。

将 API 操作映射到 HTTP 方法

接下来,我们需要确定每个操作适合哪种 HTTP 方法。正如我们之前提到的,有一些常见的 HTTP 方法用于 CRUD 操作。例如,当我们请求资源时,我们应该使用 GET 方法。当我们创建新资源时,我们应该使用 POST 方法。

当我们将 API 操作映射到 HTTP 方法时,我们还需要考虑操作的安全性以及 HTTP 方法的安全性。HTTP 操作有三种安全性类型:

  • /posts/{postId}GET 请求,无论发送多少次相同的请求,它都会返回相同的结果。在某些情况下,资源可能被第三方更新,下一次 GET 请求将返回更新后的结果。但这并非由客户端引起,因此了解状态变化是否由发送请求的客户端引起非常重要。

  • /posts/{postId} 发送 DELETE 请求以删除它。如果请求成功,我们将收到 200 OK204 No Content 响应。如果我们再次向 /posts/{postId} 发送相同的请求,它可能返回 404 Not found 响应,因为资源已经被删除,但这不会引起任何其他副作用。如果一个操作是幂等的,并且客户端知道之前的请求是否失败,那么重新发出请求是安全的,不会产生任何副作用。

  • /posts 发送 POST 请求以创建一篇新文章。如果我们再次发送相同的 POST 请求,它将创建另一篇具有相同标题和内容的新文章,依此类推。

所有安全方法也都是幂等的,但并非所有幂等方法都是安全的。以下表格列出了每个 HTTP 方法的安全性:

HTTP 方法 安全 幂等 常见操作
GET 读取、列出、查看、搜索、显示和检索
HEAD HEAD 用于检查资源的可用性,而不实际下载它。
OPTIONS OPTIONS 用于检索给定资源的可用 HTTP 方法。
TRACE TRACE 用于获取请求/响应周期的诊断信息。
PUT 更新和替换
DELETE 删除、移除和清除
POST 创建、添加和更新
PATCH 更新

表 1.4 – HTTP 方法的安全性

以下表格显示了如何将操作映射到 HTTP 方法:

操作名称 URL HTTP 方法 描述
createPost() /posts POST 创建新帖子
listPosts() /posts GET 列出所有帖子
listPostsByCategory() /posts?categoryId={categoryId} GET 通过类别列出帖子
listPostsByTag() /posts?tag={tagId} GET 通过标签列出帖子
searchPosts() /posts?search={keyword} GET 通过标题、作者和内容搜索帖子
viewPost() /posts/{postId} GET 查看帖子详情
deletePost() /posts/{postId} DELETE 删除帖子
updatePost() /posts/{postId} PUT 更新帖子
publishPost() /posts/{postId}/publish PUT 发布帖子
unpublishPost() /posts/{postId}/unpublish PUT 取消发布帖子

表 1.5 – 为 Post 资源映射 HTTP 方法

你可能见过一些其他情况,例如使用 POST 来更新资源。这可以工作,但它并不遵循 HTTP 标准。一般来说,我们可以陈述以下内容:

  • GET 用于读取资源。

  • POST 用于使用服务器定义的 URL(如 /posts)创建子资源。

  • PUT 用于使用客户端定义的 URL(如 /posts/{postId})创建或替换资源。在许多情况下,PUT 也可以用于更新资源。

  • PATCH 用于使用客户端定义的 URL(如 /posts/{postId})更新资源的部分。

分配响应代码

是时候为操作分配 HTTP 响应代码了。有一些主要的响应代码类别:

  • 2xx 代码 – 成功:客户端请求的操作已被接收、理解并接受。

  • 3xx 代码 – 重定向:客户端必须采取额外操作以完成请求。这通常用于指示客户端应重定向到新位置。

  • 4xx 代码 – 客户端错误:操作未成功,但客户端可以再次尝试。

  • 5xx 代码 – 服务器错误:服务器遇到错误或无法执行请求。客户端可以在将来重试。

一个常见问题是,一些开发者发明了自己的响应代码。例如,如果我们创建一个新帖子,我们期望服务器返回 201 已创建 响应代码。一些开发者可能会使用 200 OK 并在响应体中包含状态代码。这不是一个好主意。服务器和客户端之间有很多层。使用你自己的代码可能会给这些中间件组件带来问题。请确保根据正确的理由使用正确的代码。以下是一些常见的响应代码:

HTTP 响应代码 描述
200 OK 成功 HTTP 请求的标准响应。
201 已创建 请求已得到满足,导致新资源被创建。
202 已接受 请求已被接受处理,但处理尚未完成。
204 无内容 服务器已成功处理请求,但不返回任何内容。这在删除操作中很常见。
400 错误请求 服务器无法理解或处理请求,因为客户端错误,例如语法错误、请求大小过大或无效输入。客户端不应在不修改请求的情况下重复请求。
401 未授权 请求需要用户身份验证。
403 禁止 服务器理解了请求,但拒绝执行。这可能是因为客户端没有必要的权限或尝试了禁止的操作。
404 未找到 请求的资源找不到。
500 内部服务器错误 一个通用的错误消息。服务器遇到意外情况,因此无法处理请求,此时没有更具体的消息是合适的。
503 服务不可用 服务器当前无法处理请求,可能是由于临时过载或服务器维护。如果可能,响应应包含 Retry-After 标头,以便客户端在估计时间后重试,

表 1.6 – 常见 HTTP 响应代码

这里是显示每个操作响应代码的表格:

操作名称 URL HTTP 方法 响应 描述
createPost() /posts POST Post, 201 创建新帖子
listPosts() /posts GET Post[], 200 列出所有帖子
listPostsByCategory() /posts?categoryId={categoryId} GET Post[], 200 按类别列出帖子
listPostsByTag() /posts?tag={tagId} GET Post[], 200 按标签列出帖子
searchPosts() /posts?search={keyword} GET Post[], 200 通过标题、作者和内容搜索帖子
viewPost() /posts/{postId} GET Post, 200 查看帖子详情
deletePost() /posts/{postId} DELETE 204, 404 删除帖子
updatePost() /posts/{postId} PUT 200 更新帖子
publishPost() /posts/{postId}/publish PUT 200 发布帖子
unpublishPost() /posts/{postId}/unpublish PUT 200 取消发布帖子

表 1.7 – Post 资源的响应码

正确使用响应码至关重要,以防止任何误解。这将确保所有通信都是清晰和简洁的,从而避免任何潜在的混淆。

如果我想创建自己的状态码怎么办?

从技术上讲,你可以创建自己的状态码,但在实践中,请尽可能遵循标准。如果你发明了自己的状态码,那将是危险的。你的用户可能会在使用你的 API 时遇到麻烦,因为他们不知道你的状态码。你应该考虑拥有自己的状态码的好处。惯例是尊重 RFC 中定义的 HTTP 状态码。在你创建自己的状态码之前,请确保首先检查 HTTP 状态码列表。除非你有充分的理由,否则不要创建自己的状态码。你可以在这里找到更多信息:en.wikipedia.org/wiki/List_of_HTTP_status_codes

然而,可能存在一些特殊情况,你希望在响应中指示更具体的状态。例如,你可能有一个可以处理任务的 API,但它可能因不同原因而失败。你可能希望在响应中提供更详细的消息,以便让你的用户知道发生了什么,而不是返回常见的4xx代码。你应该仔细考虑业务逻辑,区分 HTTP 状态码和业务状态码。如果你在 HTTP 状态码中找不到合适的代码,而你又想在响应中显示与业务相关的状态,你可以选择 HTTP 状态码来指示响应的类别,然后附加一个包含你的业务状态码的响应体。例如,你可以返回如下所示的响应:

400 Bad Request{ "error_code": 1, "message": "The post is locked and cannot be updated."}

因此,HTTP 状态码表示操作的通用状态,在响应体中,你可以包含一些特定于你系统的信息。我们将在第十六章中讨论如何使用Problem Details对象来处理错误。

记录 API

OpenAPI 是一种流行的 REST API 规范。它是对 REST API 的编程语言无关的接口描述,允许人类和计算机在无需访问源代码的情况下发现和理解服务的功能。类似于接口,它描述了 API 的输入和输出,以及它们应该如何传输。它也被称为 Swagger 规范。

Swagger 与 OpenAPI

有时,SwaggerOpenAPI 可以互换使用。Swagger 项目在 2010 年代初开发,用于定义一个简单的 API 合同,该合同包含生成或消费 API 所需的一切。它在 2015 年被捐赠给了 OpenAPI 倡议。因此,OpenAPI 指的是 API 规范,Swagger 指的是与 OpenAPI 规范一起工作的 SmartBear 的开源和商业项目。简而言之,OpenAPI 是一个规范,Swagger 是使用 OpenAPI 规范的工具。Swagger UI 也是 Swagger 工具之一。在撰写本文时,OpenAPI 的最新版本是 3.1.0。

我们可以使用 SwaggerHub 根据之前的步骤设计 API。以下是一个示例,它定义了一个简单的博客系统 API:

openapi: 3.0.0servers:
  - description: SwaggerHub API Auto Mocking
    url: https://virtserver.swaggerhub.com/yanxiaodi/MyBlog/1.0.0
info:
  description: This is a simple API
  version: '1.0.0'
  title: Sample Blog System API
  contact:
    email: you@your-company.com
  license:
    name: Apache 2.0
    url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
tags:
  - name: admins
    description: Secured Admin-only calls
  - name: developers
    description: Operations available to regular developers
paths:
  /posts:
    get:
      tags:
        - developers
      summary: searches posts
      operationId: searchPost
      description: |
        By passing in the appropriate options, you can search for
        available blogs in the system
      parameters:
        - in: query
          name: searchString
          description: pass an optional search string for looking up post
          required: false
          schema:
            type: string
        - in: query
          name: skip
          description: number of records to skip for pagination
          schema:
            type: integer
            format: int32
            minimum: 0
        - in: query
          name: limit
          description: maximum number of records to return
          schema:
            type: integer
            format: int32
            minimum: 0
            maximum: 50
      responses:
        '200':
          description: search results matching criteria
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Post'
        '400':
          description: bad input parameter

此文件的另一个文件已被省略。

之前的 API 文档是一个 YAML 文件,它定义了两个模型(资源)- PostCategory - 以及两个操作 - GET 用于搜索帖子,POST 用于创建新帖子。对于每个操作,都有关于输入和输出的详细信息,包括预期的响应代码。

在完成 API 设计后,我们可以将 API 文档与其他开发者共享,以便进行集成,如下所示:

图 1.1 – SwaggerHub 用户界面

图 1.1 – SwaggerHub 用户界面

注意,在将 API 文档与其他团队共享之前,你可能需要根据你的用户故事和领域添加更多属性。API 合同应该相当稳定;否则,它将影响消费者。

我们已经解释了如何设计 REST API。如果你想学习如何开始使用 ASP.NET Core 进行开发,你可以继续阅读第二章

REST API 是最受欢迎的 API 风格之一。在下一节中,我们将介绍其他 API 风格,例如 RPC API、GraphQL API 和实时 API。

RPC 和 GraphQL API

虽然基于 REST 的 API 在许多场景中被广泛使用,但它并不是唯一的 Web API 风格。对于某些场景,基于 RPC 的 API 或 GraphQL API 可能更适合。了解每种 API 风格的优缺点非常重要,这样你就可以为你的场景选择正确的风格。

基于 RPC 的 API 是什么?

RPC 已经存在很多年了。它是 Web 交互的最早、最简单的形式。在某些语言中,它就像一个本地调用,但它在网络上执行。客户端被提供一个可用方法的列表。每个方法都接受预定义的、类型化和有序的参数,返回一个结构化的响应结果。因此,客户端可以在不同的机器或不同的进程中运行,但仍然可以与服务器一起工作,例如在同一个应用程序中。

这样,客户端与服务器紧密耦合。如果服务器更改这些方法或任何参数,客户端将受到影响。开发者必须更新客户端的代码以匹配新的服务器方法。这可能是基于 RPC 的 API 的缺点,但它可以提供更好的性能。

远程过程是通过接口定义语言IDL)定义的。IDL 定义了远程过程的方法和参数。通常,一些代码生成器可以根据 IDL 生成客户端和服务器存根。代码是强类型的,这提供了更好的类型安全和错误处理。

要实现基于 RPC 的 API,不同语言有一些规范。例如,WCF 是几年前流行的 RPC 框架。其他一些流行的框架包括 XML-RPC、SOAP PRC、JSON-RPC 和 gRPC。

因为 RPC 就像本地方法调用一样,你经常在方法名中看到动词。与 REST 不同,RPC 支持 CRUD 以外的各种操作。以下是一个 JSON-RPC 请求和响应的示例:

请求:

POST https://rpc.example.com/calculator-service HTTP/1.1Content-Type: application/json
Content-Length: ...Accept: application/json
{"jsonrpc": "2.0", "method": "subtract", "params": [42, 23], "id": 1}

响应:

{ "jsonrpc": "2.0", "result": 19, "id": 3 }

最受欢迎的 RPC 框架之一是 gRPC,我们将在下一节讨论。

什么是 gRPC?

最受欢迎的 RPC 框架之一是 gRPC。它是一个高性能、开源的现代 RPC 框架,用于构建网络服务和分布式应用程序。gRPC 最初由 Google 创建,它使用一个名为 Stubby 的 RPC 框架。2015 年 3 月,Google 决定将其开源,从而产生了 gRPC,现在许多 Google 以外的组织都在使用它。

gRPC 有一些很棒的功能,如下所示:

  • 互操作性:gRPC 使用协议缓冲protobuf)文件来声明服务和消息,这使得 gRPC 能够完全语言和平台无关。你可以找到适用于所有主要编程语言和平台的 gRPC 工具和库。

  • protobuf是一种二进制格式,它比 JSON 具有更小的尺寸和更快的性能。它对人类不可读,但对计算机可读。HTTP/2 还支持在单个连接上多路复用请求。即使在较慢的网络中,它也需要更少的资源。

  • 流式传输:gRPC 基于 HTTP/2 协议,这使得它支持双向流式传输。

  • 使用.proto文件来描述具有输入和输出的服务。然后,他们可以使用.proto文件为不同的语言或平台生成存根。这与 OpenAPI 规范类似。团队可以专注于业务逻辑,并并行工作在相同的服务上。

  • 安全性:gRPC 被设计成是安全的。HTTP/2 建立在传输层安全TLS)端到端加密连接之上。它还支持客户端证书认证。

带着这些好处,gRPC 是微服务架构的一个很好的选择。它可以高效地连接数据中心内和跨数据中心的各个服务。它适用于分布式系统的最后一英里,因为服务到服务的通信需要低延迟和高性能。此外,多语言系统可能包含多种语言或平台,而 gRPC 可以支持不同的语言和平台。

这里是一个 gRPC .proto文件的示例:

syntax = "proto3";option csharp_namespace = "GrpcGreeter";
package greet;
service Greeter {
  // Sends a greeting message.
  rpc SayHello (HelloRequest) returns (HelloReply);
}
// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}
// The response message containing the greeting.
message HelloReply {
  string message = 1;
}

ASP.NET Core 为 gRPC 提供了极大的支持。使用.proto文件,可以在.NET 项目中自动生成服务、客户端和消息的.NET 类型。我们将在第十一章中学习如何开发 gRPC 服务。

然而,gRPC 并非万能的银弹。在选择 gRPC 之前,我们需要考虑几个因素:

  • 由于协议变更导致的紧密耦合:客户端和服务器由于协议而紧密耦合。一旦协议变更,客户端和服务器必须更新,即使是仅仅更改参数的顺序。

  • protobuf是一种非人类可读的格式,因此调试不方便。开发者需要额外的工具来分析有效载荷。

  • grpcweb,它可以提供一个库来在 gRPC 和 HTTP/1.1 之间进行转换。

  • POST方法,对于客户端来说不可缓存。

  • 更陡峭的学习曲线:与人类可读的 REST 不同,许多团队发现 gRPC 难以学习。他们需要学习 protobuf 和 HTTP/2,并寻找适当的工具来处理消息内容。

那么,我们应该选择 gRPC 还是 REST?

我应该使用 gRPC 而不是 REST API 吗?

对于大多数服务器-客户端场景,选择 gRPC 而不是 REST 具有挑战性。基于 REST 的 API 得到了所有浏览器的良好支持,因此现在有更广泛的采用。如果您需要支持浏览器客户端,与 gRPC 相比,REST 是一个更好的选择。然而,gRPC 在某些情况下具有一些有用的功能,例如高性能通信、实时通信、低带宽和多语言环境。因此,它是微服务架构的一个很好的选择。

在微服务架构中,服务是松散耦合的,每个服务都执行特定的任务或处理特定的数据。它们需要能够以简单和高效的方式相互通信,而不必考虑浏览器兼容性。gRPC 适合这种场景,因为它基于高性能协议 HTTP/2,提供了双向流、二进制消息和复用。Dapr是一个可移植的事件驱动运行时,用于微服务,实现了 gRPC API,以便应用程序可以通过 gRPC 相互通信。本书中不会讨论 Dapr,但如果您感兴趣,可以在此处找到更多信息:dapr.io/

总之,使用 gRPC 或 REST 取决于您的用例需求。

gRPC API 设计

gRPC API 设计过程与 REST API 设计过程非常相似。实际上,前三个步骤与 REST API 设计过程相似。我们需要识别资源,定义资源之间的关系,以及识别操作事件。

接下来,使用前三个步骤中的信息来设计和记录 gRPC API。当我们把操作事件转换为 gRPC 操作时,有一些差异。REST API 使用 HTTP 方法来表示操作。在 gRPC 中,这些操作就像服务的方法一样,这意味着我们可以在方法名称中使用动词。例如,获取帖子的方法可以表示为 GetPost()

gRPC 使用 protobuf 作为 IDL。当我们设计 gRPC API 时,实际上我们需要编写 .proto 文件。这些 .proto 文件由两部分组成:

  • gRPC 服务的定义

  • 在服务中使用的消息

这与 REST OpenAPI 定义类似,但语法不同。每个请求都需要一个类型定义的消息,该消息包括排序后的输入参数。每个响应返回一个消息、消息数组或错误状态响应。我们可以为博客系统创建一个 .proto 文件,如下所示:

option csharp_namespace = "GrpcMyBlogs";syntax = "proto3";
package myBlogs;
service Greeter {
  rpc GetPost(GetPostRequest) returns (Post);
  rpc CreatePost(CreatePostRequest) returns (Post);
  rpc UpdatePost(UpdatePostRequest) returns (Post);
  rpc SearchPosts(SearchPostsRequest) returns (SearchPostsResponse);
  // More methods...
  ...
}
message GetPostRequest {
  string post_id = 1;
}
message CreatePostRequest {
  string category_id = 1;
  string title = 2;
  string content = 3;
  // More properties below
  ...
}
message UpdatePostRequest {
  string post_id = 1;
  string category_id = 2;
  string title = 3;
  string content = 4;
  // More properties below
  ...
}
message SearchPostsRequest {
  string keyword = 1;
}
message Post {
  string post_id = 1;
  Category category = 2;
  string title = 3;
  string content = 4;
  // More properties below
  ...
}
message Category {
  string category_id = 1;
  string name = 2;
  // More properties below
  ...
}
message SearchPostsResponse {
  int32 page_number = 1;
  int32 page_size = 2 [default = 10];
  repeated Post posts = 3;
}

就这些!现在,.proto 文件已经有一个基本的 gRPC 服务定义,包括消息定义。接下来,我们可以使用各种工具生成 gRPC 服务和客户端的代码。在开发阶段,我们可能需要频繁地通过更新 .proto 文件来更改 gRPC 协议定义。这些更改将反映在生成的代码中。因此,在发布服务供消费之前,请仔细考虑。我们将在 第十一章 中讨论更多关于 gRPC 的内容。如果您想现在就开始使用 .NET 8 开发 gRPC,请跳转到该章节。

接下来,让我们看看 GraphQL API。

什么是 GraphQL API?

考虑使用 REST API 的场景。我们可能会发现一些问题:

  • /posts 端点,返回帖子的列表。当我们展示帖子列表页面时,我们只需要一些属性,如 TitlePublishDateCategory。但端点返回的帖子可能包含更多关于帖子的信息,如 IsPublished,这对于客户端来说是无用的。

  • /posts/{postId}端点显示帖子信息和/posts/{postId}/related端点显示相关帖子。如果我们想显示帖子详情,客户端将需要调用/posts/{postId}端点,但相关帖子没有包含在响应中。因此,客户端将不得不再次请求/posts/{postId}/related以获取相关帖子。N+1 问题通常指的是父子关系。返回集合资源的端点没有为客户端提供足够关于子资源的信息。例如,/posts端点返回帖子列表,但响应中没有每个帖子的内容摘要。要在帖子列表页面上显示内容摘要,客户端将不得不为每个帖子调用/posts/{postId}端点以获取内容摘要。因此,总请求数将是n + 1,其中n是帖子的数量。

过度和不足获取问题是基于 REST 的 API 中最常见的问题之一。因为基于 REST 的 API 以资源为中心,对于每个端点,响应结构是固定的,并且编码在 URL 中,所以它不灵活地满足客户端的需求。

这些问题可以通过 GraphQL API 来解决。GraphQL API 是另一种 API 风格,它提供了强大的查询能力。它支持根据客户端的需求以灵活的结构获取数据。它可以按资源标识符、分页列表、过滤和排序来获取数据。它还支持数据变更,就像 REST 中的 CRUD 一样。

GraphQL 简介

GraphQL 是一种强大的查询语言,用于执行具有灵活数据结构的查询。它于 2012 年由 Facebook 内部开发,随后于 2015 年公开发布。现在,它是开源的,并由来自世界各地的许多公司和个人维护。

GraphQL 通过提供更大的灵活性和效率来解决过度和不足获取的问题。它不依赖于任何数据库或存储引擎,也不依赖于任何特定的语言。有许多库可以用来实现 GraphQL 服务和客户端。一个 GraphQL 服务定义了资源类型上的类型和字段,然后为每个类型上的每个字段提供函数。

与使用资源作为其核心概念并定义返回每个资源固定数据结构的 URL 的 REST 不同,GraphQL 的概念模型是一个实体图。因此,所有 GraphQL 操作都是通过单个基于 HTTP POSTGET的端点执行的,这通常是一个/graphql。它是完全灵活的,允许客户端决定它需要什么数据结构。GraphQL 服务接收 GraphQL 查询以验证查询是否引用了正确定义的类型和字段,然后执行函数以返回正确的数据结构。请求和响应的格式是 JSON。

除了解决过度和不足获取的问题外,GraphQL 还有一些其他优点:

  • GraphQL 减少了维护 API 版本的复杂性。只有一个端点和一种图版本。它允许 API 在不破坏现有客户端的情况下进行演变。

  • GraphQL 使用强类型系统,通过 SDL 定义模式中的类型和字段。该模式作为合同,减少了客户端和服务器之间的误解。开发者可以通过模拟所需的数据结构来开发前端应用程序。一旦服务器准备就绪,他们可以切换到实际的 API。

  • GraphQL 没有定义特定的应用程序架构,这意味着它可以在现有的 REST API 上运行以重用一些代码。

  • 负载更小,因为客户端得到他们确切请求的内容,而没有过度获取。

然而,使用 GraphQL 也有一些缺点:

  • 对于 REST API 开发者来说,GraphQL 具有较高的学习曲线。

  • 服务器实现更复杂。查询可能很复杂。

  • GraphQL 使用单个端点,这意味着它无法充分利用 HTTP 的全部功能。它不支持除 JSON 之外的多媒体类型的 HTTP 内容协商。

  • 强制授权具有挑战性,因为通常 API 网关根据 URL 执行访问控制。速率限制通常也与路径和 HTTP 方法相关联。因此,您需要更多考虑来采用新样式。

  • 缓存实现复杂,因为服务不知道客户端需要什么数据。

  • 不允许文件上传,因此需要单独的 API 来处理文件。

GraphQL API 设计

GraphQL 查询非常灵活,因此客户端可以根据其需求发送各种查询。为了设计 GraphQL API,我们首先需要定义 GraphQL 模式,这是每个 GraphQL API 的核心。

GraphQL 使用 GraphQL SDL 定义 GraphQL 模式。SDL 具有类型系统,允许我们定义数据结构,就像其他强类型语言一样,例如 C#、Java、TypeScript、Go 等。

我们可以在 GraphQL 模式中定义以下类型和字段:

type Query {  posts: [Post]
  post(id: ID!): Post
}
type Post {
  id: ID!
  title: String
  content: String
  category: Category
  publishDate: String
  isPublished: Boolean
  relatedPosts: [Post]
}
type Category {
  id: ID!
  name: String
}

GraphQL 请求使用查询语言来描述所需的字段并结构化客户端所需的内容。以下是一个简单的查询:

{  posts {
    id
    title
    content
    category {
      id
      name
    }
    publishDate
    // If we do not need the `isPublished` field, we can omit it.
    // isPublished
    relatedPosts {
      id
      title
      category {
        id
        name
      }
      publishDate
    }
  }
}

在前面的查询中,我们可以省略 isPublished 字段并在响应中包含相关帖子,这样客户端就不需要发送更多请求。

要修改数据或执行计算逻辑,GraphQL 建立了一种称为 mutation 的约定。我们可以将 mutation 视为更新数据的一种方式。以下请求是一个 mutation:

mutation {  createPost(
    categoryId: ID!
    title: String!
    content: String!
  ) {
    post {
      id
      title
      content
      category {
        id
        name
      }
      publishDate
      isPublished
    }
  }
}

有一些工具可以生成 GraphQL 文档并测试服务,例如 GraphiQL、GraphQL Playground 等。我们现在不会过多讨论 GraphQL。在 第十二章 中,我们将学习如何使用 ASP.NET Core 8 开发 GraphQL API。

接下来,我们将讨论另一种 API 风格,即实时 API。

实时 API

我们引入了一些 Web API 风格,例如基于 REST 的 API、gRPC API 和 GraphQL API。它们都遵循请求/响应模式——客户端向服务器发送请求,服务器返回响应。这种模式易于理解。然而,这种模式可能不适合某些场景。

假设我们有一个包含两个部分的应用程序——服务器,它是一个消防站,客户端是消防车。我们如何通知消防车发生事故?

如果我们使用请求/响应模式,客户端需要向服务器发送请求以获取有关事故的最新通知。但发送请求的最佳频率是多少?1 分钟,还是 10 秒?考虑事故的紧急程度。如果消防车在 10 秒延迟后收到通知,这可能会成为问题,因为火势可能更加严重和紧急!那么,每秒发送请求怎么样?然后,服务器将会非常繁忙,而大多数时间,它只是返回一个无事故的响应。在请求/响应模式中,服务器无法将通知推送到客户端,因此它不适合这种情况。这导致我们面临 API 轮询的问题。

API 轮询的问题

请求/响应模式存在局限性。服务器无法通知客户端服务器端发生的变化。如果客户端需要获取最新数据,它必须在收到任何更新之前频繁地向服务器发送请求。

例如,如果客户端想知道何时发布新帖子,它需要调用/posts/latest来获取最新帖子。客户端可能设置一个间隔,定期发送请求。这种模式称为 API 轮询,它是客户端需要更新资源变化时的常见解决方案。

API 轮询与常见的 REST API 没有太大区别。它可以基于请求/响应模式实现。然而,对于这种场景来说,它并不是理想的解决方案。通常,资源变化的频率是不可预测的,因此很难决定请求的频率。如果间隔太短,客户端可能会发送过多的不必要请求,服务器将处理过多的查询。然而,如果间隔太长,客户端无法及时获取最新的变化。特别是,如果应用程序需要实时通知客户端,那么系统将会非常繁忙。

当我们使用 API 轮询时,会面临更多挑战:

  • 检查资源变化的逻辑很复杂。它可能需要在服务器上实现逻辑,因此服务器需要检查请求中的时间戳,然后根据时间戳查询数据。或者,客户端查询所有数据,并将集合与之前请求的数据进行比较。这带来了很多复杂性。

  • 检查特定事件是否发生很难——例如,创建资源和更新资源。

  • 速率限制可能会阻止客户端在期望的间隔内发送过多的请求。

解决 API 轮询问题的理想方式是允许服务器实时向客户端发送事件,而不是不断轮询并实现检查变化的逻辑。这与请求/响应模式不同。它支持服务器和客户端之间的实时通信,为应用程序提供了新的可能性。这就是实时 API 所能做到的。

什么是实时 API?

实时 API 超越了传统的 REST API。它提供了一些好处:

  • 应用程序可以实时响应内部事件。例如,如果发布了新的帖子,客户端可以立即收到通知。

  • 它可以通过减少请求数量来提高 API 效率。客户端不需要 API 轮询来检查资源变化。相反,当某些事件发生时,服务器会向客户端发送消息。它减少了通信过程中所需的资源。

一些技术可以实现实时 API,例如长轮询、服务器发送事件SSE)、WebSocket、SignalR 和 gRPC 流。

让我们快速了解一下这些。

长轮询

我们之前描述的 API 轮询问题被称为短轮询或常规轮询,它易于实现但效率较低。客户端无法实时从服务器接收更新。为了克服这个问题,长轮询是另一个选择。

长轮询是短轮询的一种变体,但它基于Comet,这是一种 Web 应用程序模型,其中长时间保持的 HTTPS 请求允许 Web 服务器在不显式请求的情况下向浏览器推送数据。Comet 包含多种技术来实现长轮询。它也有许多名称,如 Ajax 推送、HTTP 流和 HTTP 服务器推送。

要使用长轮询,客户端向服务器发送请求,但预期服务器可能不会立即响应。当服务器收到请求时,如果没有新的数据供客户端,服务器将保持连接活跃。如果有可用的数据,服务器将向客户端发送响应并完成打开的请求。客户端收到响应后,通常会立即或在一个预定义的间隔后发出新的请求以重新建立连接。操作会重复进行。这样,它可以有效地模拟服务器推送功能。

在使用长轮询时有一些考虑因素。服务器需要管理多个连接并保持会话状态。如果架构变得更加复杂(例如,当使用多个服务器或负载均衡器时),则会导致会话粘性问题的出现,这意味着具有相同会话的后续客户端请求必须路由到处理原始请求的同一服务器。这很难扩展应用程序。同时,也难以管理消息顺序。如果浏览器有两个打开的标签页并且同时发送多个请求来写入数据,服务器将不知道哪个请求是最新的。

许多网页浏览器支持长轮询。近年来,SSE 和 WebSocket 已被广泛采用,因此长轮询不再是首选。现在,它通常与其他技术一起使用或作为后备方案。例如,当 WebSocket 和 SSE 不可用时,SignalR 使用长轮询作为后备方案。

SSE

SSE 是一种服务器推送技术,允许服务器向网页浏览器发送事件。SSE 最初于 2004 年作为WHATWG Web Applications 1.0的一部分被提出。它基于 HTML5 的标准 API,即 EventSource API。Opera 网页浏览器在 2006 年实现了这一功能。现在,所有现代浏览器都支持 SSE。

在 SSE 中,客户端的行为像一个订阅者,通过创建一个新的 JavaScript EventSource对象来初始化连接,通过带有text/event-stream媒体类型的常规 HTTP GET请求将端点的 URL 传递给服务器。一旦连接,服务器保持连接打开,并将新事件以换行符分隔推送到客户端,直到没有更多事件要发送,或者直到客户端通过调用EventSource.close()方法显式关闭连接。

如果客户端由于任何原因丢失了连接,它可以重新连接以接收新的事件。为了从故障中恢复,客户端可以向服务器提供一个Last-Event-ID头,以指定客户端接收到的最后一个事件 ID。然后,服务器可以使用这些信息来确定客户端是否错过了任何事件。

当数据从服务器变化时,SSE 适用于需要向客户端发送实时通知的场景,以保持用户界面与最新的数据状态同步。例如包括 Twitter 更新、股票价格更新、新闻源、警报等等。

SSE 的限制在于它是单向的,因此不能用来从客户端向服务器发送数据。一旦客户端连接到服务器,它只能接收响应,但不能在同一个连接上发送新的请求。如果您需要双向通信,WebSocket 可能是一个更好的选择。

WebSocket

WebSocket 是一种协议,它在一个 TCP 连接内提供客户端和服务器之间的全双工通信。它允许客户端向服务器发送请求,同时服务器可以实时地将事件和响应推回客户端。WebSocket 首次在 HTML5 规范中被引用为基于 TCP 的套接字 API。2008 年,WebSocket 协议由 W3C 标准化。2009 年,Google Chrome 成为第一个支持 WebSocket 的浏览器。现在,WebSocket 在大多数现代浏览器中都得到了支持,包括 Google Chrome、Microsoft Edge、Firefox、Safari 和 Opera。

与 HTTP 协议不同,WebSocket 允许客户端和服务器之间进行双向的持续对话。通信通常通过 TCP 端口 443 连接(如果存在不安全的连接,则为 80),因此它可以在防火墙中轻松配置。

从 WebSocket 的角度来看,消息内容是透明的。需要一个子协议来指定客户端和服务器之间的协议。WebSocket 可以支持文本和二进制格式的子协议。作为初始握手过程的一部分,客户端可以指定它支持哪些子协议。然后,服务器必须选择客户端支持的协议之一。这被称为子协议协商。您可以在以下位置找到许多官方注册的子协议:www.iana.org/assignments/websocket/websocket.xml

WebSocket 协议定义了 wswss 作为分别用于未加密和加密连接的 URI 架构。始终建议使用 wss 来确保传输安全层加密数据。例如,我们可以使用以下代码在 JavaScript 中创建 WebSocket 连接:

const socket = new WebSocket('wss://websocket.example.com/ws/updates');

WebSocket 没有定义如何管理连接的事件,例如重新连接、身份验证等。客户端和服务器需要管理这些事件。有各种库用于实现不同语言的 WebSocket。例如,Socket.IO (socket.io) 是一个流行的库,它实现了 JavaScript、Java、Python 等语言的 WebSocket 服务器和客户端。

WebSocket 是实时通信(如在线游戏、销售更新、体育更新、在线聊天、实时仪表盘等)的一个很好的选择。

单向与双向

单向通信就像收音机。SSE 是单向的,因为服务器向客户端广播数据,但客户端不能向服务器发送数据。双向通信支持双向通信。有两种类型的双向通信——半双工和全双工。

半双工通信就像对讲机。服务器和客户端都可以互相发送消息,但一次只能有一方发送消息。

全双工通信就像电话。消息可以从任何一方同时发送。WebSocket 是全双工的。

gRPC 流

我们在上一节中介绍了 gRPC。正如我们提到的,gRPC 基于 HTTP/2 协议,这为长连接的实时通信提供了基础。与需要为每个请求创建新的 TCP 套接字连接的 HTTP/1.1 不同,一个 HTTP/2 连接可以用于一个或多个同时进行的请求,这样就避免了为每个请求创建新连接的开销。此外,HTTP/2 支持在客户端不需要请求的情况下向客户端推送数据。这比 HTTP/1.1 的请求/响应模式有了巨大的改进。

gRPC 利用 HTTP/2 协议来支持双向通信。一个 gRPC 服务支持以下流组合:

  • 单一(无流式传输)

  • 服务器到客户端的流式传输

  • 客户端到服务器的流式传输

  • 双向流式传输

gRPC 和 WebSocket 都支持全双工通信,但与 WebSocket 不同,gRPC 默认使用 protobuf,因此不需要选择子协议。然而,浏览器没有内置对 gRPC 的支持,因此 gRPC 流通常用于服务到服务的通信。

哪种实时通信技术最适合您的应用程序?

对于您的实时应用程序,有几个选择。那么,我们该如何选择?重要的是要注意,这取决于您应用程序的具体情况和限制。例如,您是否需要一个单向推送应用程序或双向通信?您是否希望支持大多数浏览器,或者只是服务器到服务的通信?您是否需要将数据推送到多个客户端,或者只是单个客户端?

幸运的是,Microsoft 在 ASP.NET Core 中提供了 SignalR 来实现实时通信。SignalR 是一个开源库,它使得客户端和服务器之间能够实现实时通信。它能够自动管理连接,并允许服务器向所有已连接的客户端或特定客户端组发送消息。请注意,SignalR 封装了多种技术,包括 WebSocket、SSE 和长轮询。它隐藏了这些协议的细节和复杂的实现。因此,我们不需要担心用于实时通信的技术。SignalR 会自动为您的应用程序选择最佳的传输方法。WebSocket 是默认协议。如果 WebSocket 不可用,SignalR 将回退到 SSE,然后是长轮询。

SignalR 是这些场景下的一个不错的选择:

  • 当客户端需要从服务器获取高频更新或警报/通知时——例如,游戏、社交网络、投票、拍卖、地图等

  • 仪表板和监控应用程序——例如,系统仪表板应用程序、即时绘图应用程序、销售数据监控应用程序等

  • 协作应用程序——例如,聊天应用程序、白板应用程序等

ASP.NET Core 也为 gRPC 提供了良好的支持。那么,下一个问题是,您如何在 gRPC 和 SignalR 之间进行选择?

这里有一些您可能想要考虑的想法:

  • 如果你需要构建一个支持多个客户端(浏览器)的实时应用程序,你可以使用 SignalR,因为它得到了浏览器的良好支持,而 gRPC 则没有。

  • 如果你需要构建一个分布式应用程序或微服务架构应用程序,其中你希望在多个服务器之间进行通信,你可以使用 gRPC,因为它更适合服务器之间的通信,并且在这个场景下比 SignalR 更高效。

摘要

在本章中,我们介绍了一些不同的 API 风格,包括基于 REST 的 API、gRPC API 和 GraphQL API,并探讨了如何设计它们。我们还介绍了几种实现实时通信的不同方法,包括 WebSocket、gRPC 流和 SignalR。到目前为止,我们还没有涉及太多代码,但我们已经回顾了 Web API 的基本概念。

在下一章中,我们将开始学习如何使用 ASP.NET Core 来实现它们。

第二章:ASP.NET Core Web API 入门

ASP.NET Core是一个跨平台、开源的 Web 应用程序框架,用于构建现代、云支持的 Web 应用程序和 API。它主要用于与 C#编程语言一起使用。ASP.NET Core 提供了一些功能,帮助你以各种方式构建 Web 应用程序 - 例如,通过 ASP.NET MVC、Web API、Razor Pages、Blazor 等。本书将主要涵盖 Web API。在本章中,我们将学习如何使用 ASP.NET Core 构建一个简单的 REST Web API。

在本章中,我们将介绍以下主题:

  • 设置开发环境

  • 创建一个简单的 REST Web API 项目

  • 构建和运行项目

  • 理解 MVC 模式

  • 依赖注入(DI)

  • 最小 API 简介

本章将为你提供创建基本 REST Web API 项目所需的必要信息,使用 ASP.NET Core。到本章结束时,你应该对创建第一个 ASP.NET Core Web API 项目所需的步骤有更好的理解。

技术要求

预期你应了解.NET Framework.NET Core的基本概念,以及面向对象编程(OOP)。你还应具备对C#编程语言的基本理解。如果你不熟悉这些概念,可以参考以下资源:

本章中的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter2找到。

设置开发环境

.NET Core 完全跨平台,可以在 Windows、Linux 和 macOS 上运行,因此你可以使用这些平台中的任何一个来开发 ASP.NET Core 应用程序。本书中的代码示例是在 Windows 11 上编写的。然而,你可以在 Linux 和 macOS 上运行相同的代码。

对于 ASP.NET Core,也有几个 IDE 可供选择,例如 Visual Studio、Visual Studio Code (VS Code)、Visual Studio for Mac 和 Rider。在这本书中,我们将主要使用 VS Code。

为什么不使用 Visual Studio?

Visual Studio 是.NET 平台的强大 IDE。它提供了一系列工具和功能,以提升和增强软件开发每个阶段的体验。然而,VS Code 更轻量级,且开源且跨平台。我们将使用 VS Code 来理解 ASP.NET Core 的概念,然后迁移到 Visual Studio 以使用其丰富的功能。如果你熟悉 Visual Studio 或任何其他 IDE,请随意使用。

这里是一份你需要安装的软件、SDK 和工具列表:

VS Code 和 .NET 8 SDK 都是跨平台的,所以请根据你的操作系统选择正确的版本。当你安装 VS Code 时,请确保勾选 添加到 PATH 选项。

如果你使用 Windows,你可能想安装 Windows Terminal 来运行命令行。Windows Terminal 可用于 Windows 10 及以上版本,并提供更好的用户体验。但它是可选的,因为你也可以直接使用命令行。

配置 VS Code

严格来说,VS Code 是一个代码编辑器。它不能识别所有编程语言。因此,你需要安装一些扩展来支持你的开发工作流程。你可以在 VS Code 界面左侧的活动栏中点击 扩展 图标来浏览和安装扩展。然后,你将看到 VS Code 市场中最受欢迎的扩展列表:

图 2.1 – VS Code 的 C# 开发工具包扩展

图 2.1 – VS Code 的 C# 开发工具包扩展概述

你需要安装此扩展以支持 .NET 开发:

  • C# 开发工具包:这是由 Microsoft 提供的官方 VS Code C# 扩展。当你安装 C# 开发工具包时,以下扩展将自动安装:

    • C# 扩展:此扩展通过 OmniSharp 提供了 C# 语言支持

    • IntelliCode for C# 开发工具包:此扩展为 C# 提供了 AI 辅助的 IntelliSense 功能

    • .NET 运行时安装工具:此扩展提供了一种统一的方式来安装本地、私有版本的 .NET 运行时

C# 开发工具包扩展提供了许多功能,帮助你开发 .NET 应用程序。按 Ctrl + Shift + P(在 Windows 上)或 Command + Shift + P(在 macOS 上)打开命令面板,然后输入 .net 以查看 C# 开发工具包扩展提供的命令。你可以使用这些命令创建新项目、生成构建和调试资源、运行测试等。

你还可以安装以下扩展来提高你的生产力:

  • VS Code 的 EditorConfig 扩展:此扩展为 VS Code 提供了 EditorConfig 支持。EditorConfig 帮助多个开发者在各种编辑器和 IDE 中维护一致的编码风格,当他们在同一项目上工作时。

  • GitHub Copilot:GitHub Copilot 是你的 AI 代码伴侣。你可以在 VS Code 中根据上下文和注释实时获取代码建议。此扩展并非免费,但你可以在 30 天内免费试用。如果你是学生、教师或知名开源项目的维护者,你可以免费获得它。

要配置 EditorConfig,您可以在项目的根文件夹中创建一个名为.editorconfig的文件。您可以在learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/code-style-rule-options找到示例 EditorConfig 文件。

检查.NET SDK

一旦安装了.NET SDK,您可以通过运行以下命令来检查版本:

dotnet --version

您应该能够按以下方式看到版本号:

8.0.101-rc.2.23502.2

微软经常发布新的.NET SDK 版本。如果您遇到不同的版本号,这是可以接受的。

您可以通过运行以下命令列出所有可用的 SDK:

dotnet --list-sdks

前面的命令将列出您机器上所有可用的 SDK。例如,如果您安装了多个.NET SDK,它可能会显示以下输出:

6.0.415 [C:\Program Files\dotnet\sdk]7.0.402 [C:\Program Files\dotnet\sdk]
8.0.100 [C:\Program Files\dotnet\sdk]
8.0.101 [C:\Program Files\dotnet\sdk]

可以同时安装多个.NET SDK 版本。我们可以在项目文件中指定.NET SDK 的版本。

我应该使用哪个版本的 SDK?

每个 Microsoft 产品都有其生命周期。.NET 和.NET Core 提供长期支持LTS)版本,这些版本将获得 3 年的补丁和免费支持。当本书编写时,.NET 7 仍在支持中,直到 2024 年 5 月。根据微软的政策,偶数版本是 LTS 版本。因此,.NET 8 是最新 LTS 版本。本书中的代码示例是用.NET 8.0 编写的。

要了解更多关于.NET 支持策略的信息,请访问dotnet.microsoft.com/en-us/platform/support/policy

我们现在已准备好开始开发 ASP.NET Core 应用程序。让我们开始工作吧!

创建简单的 REST Web API 项目

在本节中,我们将使用.NET 命令行界面.NET CLI)创建一个基本的 Web API 项目并查看其工作方式。

.NET CLI 是一个命令行工具,它帮助您创建、开发、构建、运行和发布.NET 应用程序。它包含在.NET SDK 中。

您有多种方式来运行.NET CLI 命令。最常见的方式是在终端窗口或命令提示符中运行命令。此外,您还可以在 VS Code 中直接运行命令。VS Code 提供了一个集成终端,它从工作区的根目录开始。要在 VS Code 中打开终端,您可以执行以下任何一项操作:

  • Ctrl + *(在 Windows 上)或*Command* + *(在 macOS 上)打开终端

  • 使用视图 | 终端菜单项打开终端

  • 从命令面板,使用视图:切换终端命令打开终端

在终端中,导航到您想要创建项目的文件夹,然后通过运行以下命令创建一个 Web API 项目:

dotnet new webapi -n MyFirstApi -controllerscd MyFirstApi
code .

前面的命令创建了一个新的 Web API 项目并在 VS Code 中打开它。dotnet new提供了许多选项来创建各种类型的项目,例如 Web API、控制台应用程序、类库等。

我们可以使用一些选项来指定项目:

  • -n|--name <OUTPUT_NAME>: 创建输出的名称。如果未指定,则使用当前目录的名称。

  • -o|--output <OUTPUT_PATH>: 创建项目的输出路径。如果未指定,则使用当前目录。

  • -controllers|--use-controllers: 指示是否为操作使用控制器。如果未指定,则默认值是 false

  • -minimal|--use-minimal-apis: 指示是否使用最小 API。默认值是 false,但 -controllers 选项会覆盖 -minimal 选项。如果没有指定 -controllers-minimal,则使用 -controllers 选项的默认值 false,因此将创建最小 API。

重要提示

自 .NET 6.0 以来,ASP.NET Core 6.0 提供了一种创建 Web API 项目的全新方式,称为 --use-controllers 选项。

要了解更多关于 dotnet new 命令的信息,请查看此页面:docs.microsoft.com/en-us/dotnet/core/tools/dotnet-new。我们将在接下来的章节中介绍 dotnet 命令的更多细节。

当你使用 VS Code 打开项目时,C# Dev Kit 扩展可以为你创建一个解决方案文件。这个特性使得 VS Code 对 C# 开发者更加友好。你可以在资源管理器视图中看到以下结构:

原因是 VS 2022 会为项目创建一个 sln 文件,但 .NET CLI 不会。当使用 VS Code 打开项目时,C# DevKit 会创建 sln 文件。我认为在这里提一下是有意义的。

C# Dev Kit 扩展提供了一项新功能,即解决方案资源管理器,它位于底部。这个特性在处理一个解决方案中的多个项目时特别有用。你可以将 SOLUTION EXPLORER 拖放到顶部以使其更易于查看。

当你使用 VS Code 打开项目时,C# Dev Kit 扩展可以为你创建一个解决方案文件。这个特性使得 VS Code 对 C# 开发者更加友好。你可以在资源管理器视图中看到以下结构:

图 2.2 – 解决方案资源管理器和文件夹结构

图 2.2 – 解决方案资源管理器和文件夹结构

接下来,我们可以开始构建和运行项目。

构建和运行项目

在本节中,我们将学习如何构建和运行项目,并介绍一些有用的工具来帮助测试 API。为了使其与所有平台兼容,我们将使用 .NET CLI 命令来构建和运行项目。我们还将学习如何在 VS Code 中调试项目。

构建项目

构建和运行项目的最简单方法是使用 dotnet 命令。你可以运行以下命令来构建项目:

dotnet build

前面的命令将构建项目和其依赖项,并生成一组二进制文件。您可以在 bin 文件夹中找到这些二进制文件。bin 文件夹是 dotnet build 命令的默认输出文件夹。您可以使用 --output 选项指定输出文件夹。但是,建议使用默认的 bin 文件夹。这些二进制文件是一些 .dll 扩展名的文件。

当您使用 VS Code 打开项目时,您可能会看到以下弹出窗口:

图 2.3 – VS Code 提示恢复依赖项

图 2.3 – VS Code 提示恢复依赖项

这是因为 VS Code 会检查项目是否为 .NET 项目,并尝试恢复依赖项。您可以点击恢复按钮来恢复依赖项。同样,如果您看到 VS Code 提示添加资产以调试项目,请在对话框中选中

图 2.4 – VS Code 提示添加构建和调试所需的资产

图 2.4 – VS Code 提示添加构建和调试所需的资产

一些命令,如 dotnet builddotnet rundotnet testdotnet publish,将隐式恢复依赖项。所以如果您错过了这些提示,请不要担心。

如果没有显示错误或警告,则表示构建成功。

运行项目

您可以运行以下命令来运行项目:

dotnet run

dotnet run 命令是从源代码运行项目的便捷方式。请注意,它在开发中很有用,但不适用于生产。原因是如果依赖项不在共享运行时之外,dotnet run 命令将从 NuGet 缓存中解析依赖项。要在生产中运行应用程序,您需要使用 dotnet publish 命令创建部署包并将其部署。我们将在未来的章节中探讨部署过程。

您应该能够看到以下输出:

Building...info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7291
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5247
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\example_code\chapter2\MyFirstApi\MyFirstApi
info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...

输出中有一个链接,例如 http://localhost:5247。端口号是在我们创建项目时随机生成的。在浏览器中,导航到 http://localhost:<your_port>/swagger。您将看到带有 Swagger UI 的 Web API 文档,它提供了一个基于 Web 的 UI,以提供信息和工具来与 API 交互。您可以使用 Swagger UI 测试 API:

图 2.5 – Swagger UI

图 2.5 – Swagger UI

API 项目现在正在运行!您可以看到 Web API 模板提供了一个 /WeatherForecast 端点。如果您在浏览器中导航到 http://localhost:5247/WeatherForecast 链接,您将看到 API 响应。

要支持 HTTPS,您可能需要运行以下命令以信任 HTTPS 开发证书:

dotnet dev-certs https --trust

如果证书之前未被信任,您将看到一个对话框。选择以信任开发证书:

图 2.6 – 为本地开发安装证书

图 2.6 – 为本地开发安装证书

请注意,前面的命令在 Linux 上不起作用。有关更多详细信息,请参阅您的 Linux 发行版文档。

更改端口号

端口号定义在Properties文件夹中的launchSettings.json文件中。您可以通过编辑文件来更改端口号。根据惯例,当创建 Web API 项目时,HTTP 将选择从50005300的端口号,HTTPS 则从70007300。以下是launchSettings.json文件的示例:

{  "$schema": "https://json.schemastore.org/launchsettings.json",
  ...
  "profiles": {
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:7291;http://localhost:5247",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    ...
  }
}

您可以在此处更新端口号。但请记住,端口号应在您的机器上唯一,以避免冲突。

热重载

当您使用dotnet run运行项目时,如果更改代码,则需要停止项目并重新启动。如果您的项目很复杂,停止和重新启动需要花费时间。为了加快开发速度,您可以使用dotnet watch命令启用热重载功能。

.NET 热重载是一种允许您在不重新启动应用程序的情况下将代码更改应用于正在运行的应用程序的功能。它最初在.NET 6 中提供。您可以使用dotnet watch而不是dotnet run来在开发中激活热重载。一旦更新代码,网页将自动刷新。但是,热重载不支持所有代码更改。在某些情况下,dotnet watch将询问您是否想要重新启动应用程序。有一些选项:总是从不。根据您想要应用的代码更改选择合适的选项,如下所示:

dotnet watch  File changed: .\Services\IService.cs.dotnet watch  Unable to apply hot reload because of a rude edit.
   Do you want to restart your app - Yes (y) / No (n) / Always (a) / Never (v)?

API 项目现在正在运行,我们可以开始测试 API。

测试 API 端点

浏览器可以轻松发送GET请求,但对于POST端点来说并不简单。有各种方法可以用于测试目的调用 API,例如 Swagger UI、Postman 和其他工具。在本节中,我们将介绍一些您可以在开发阶段使用的工具。

Swagger UI

我们在第一章中介绍了如何使用 SwaggerHub 设计 API。从 5.0 版本开始,ASP.NET Core 默认启用 OpenAPI 支持。它使用Swashbuckle.AspNetCore NuGet 包,该包提供了 Swagger UI 来文档化和测试 API。

我们可以使用 Swagger UI 直接测试 API。在 Swagger UI 中展开第一个/WeatherForecast API,然后点击Try it out按钮。您将看到一个Execute按钮。点击该按钮,您将看到以下响应:

图 2.7 – 在 Swagger UI 中测试端点

图 2.7 – 在 Swagger UI 中测试端点

图 2.7演示了 API 正在正确运行并提供了预期的响应。要了解更多关于 Swagger 和 OpenAPI 的信息,您可以查看以下链接:

Postman

Postman 是一个强大的 API 平台,用于构建和使用 API。它被许多个人开发者和组织广泛使用。您可以从这里下载:www.postman.com/downloads/.

http://localhost:5247/WeatherForecast 作为 URL。然后,点击 发送 按钮。您将看到以下响应:

图 2.8 – 使用 Postman 调用 API

图 2.8 – 使用 Postman 调用 API

Postman 提供了一组丰富的功能来测试 API。要了解更多关于 Postman 的信息,请查看官方文档:learning.postman.com/docs/getting-started/introduction/.

HttpRepl

GETPOSTPUTDELETEHEADOPTIONSPATCH HTTP 动词。

要安装 HttpRepl,您可以使用以下命令:

dotnet tool install -g Microsoft.dotnet-httprepl

安装完成后,您可以使用以下命令连接到我们的 API:

httprepl <ROOT URL>/

<ROOT URL> 是 Web API 的基本 URL,例如以下所示:

httprepl http://localhost:5247/

连接建立后,您可以使用 lsdir 命令列出端点,例如以下所示:

http://localhost:5247/> ls.                 []
WeatherForecast   [GET]

上述命令显示 WeatherForecast 端点支持 GET 操作。然后,我们可以使用 cd 命令导航到端点,例如以下所示:

http://localhost:5247/> cd WeatherForecast/WeatherForecast    [GET]

然后,我们可以使用 get 命令测试端点,例如以下所示:

http://localhost:5247/WeatherForecast> get

输出看起来像这样:

图 2.9 – HttpRepl 的输出

图 2.9 – HttpRepl 的输出

要断开连接,请按 Ctrl + C 退出。

您可以在 docs.microsoft.com/en-us/aspnet/core/web-api/http-repl/ 找到有关 HttpRepl 的更多信息。

Thunder Client

如果您更喜欢在 VS Code 中完成所有操作,Thunder Client 是测试 API 的绝佳工具。Thunder Client 是 VS Code 的一个轻量级 REST API 客户端扩展,允许用户在无需离开 VS Code 的情况下测试他们的 API。这使得它成为希望简化工作流程的开发者的理想选择:

图 2.10 – VS Code 的 Thunder Client 扩展

图 2.10 – VS Code 的 Thunder Client 扩展

安装完成后,点击 动作栏 上的 Thunder Client 图标。从侧边栏中,点击 新建请求 按钮。以下 UI 将显示:

图 2.11 – 使用 Thunder Client 测试 API

图 2.11 – 使用 Thunder Client 测试 API

要了解更多关于 Thunder Client 的信息,请访问他们的 GitHub 页面:github.com/rangav/thunder-client-support.

在 VS 2022 中使用.http 文件

如果您使用 Visual Studio 2022,您可以使用 .http 文件来测试 API。.http 文件是一个包含 HTTP 请求定义的文本文件。最新的 ASP.NET Core 8 模板项目提供了一个默认的 .http 文件。您可以在 MyFirstApi 文件夹中找到它。文件的内容如下:

@MyFirstApi_HostAddress = http://localhost:5247GET {{MyFirstApi_HostAddress}}/weatherforecast/
Accept: application/json
###

第一行定义了一个名为MyFirstApi_HostAddress的变量,其值为 API 的根 URL。第二行定义了一个对/weatherforecast端点的GET请求。第三行定义了一个Accept头。在这种情况下,它接受application/json内容类型。在 Visual Studio 2022 中打开此文件,你将在请求的左侧看到发送请求按钮。点击该按钮,你将看到以下响应:

图 2.12 – 使用 .http 文件在 Visual Studio 2022 中测试 API

图 2.12 – 使用 .http 文件在 Visual Studio 2022 中测试 API

然而,当这本书编写时,.http文件缺少一些功能,例如环境变量。此外,此功能仅在 Visual Studio 2022 中可用,因此我们不会在本书中使用它。但如果你对进一步探索此功能感兴趣,请参阅 Microsoft 文档learn.microsoft.com/en-us/aspnet/core/test/http-files以获取更多信息。

我们介绍了一些用于测试 API 的工具。现在让我们学习如何调试 API。

调试

VS Code 具有内置的调试功能,允许你调试代码。与 Visual Studio 不同,它需要一个launch.json配置来进行调试。当你打开 VS Code 中的 ASP.NET Core 项目时,它将提示你添加一些资产。如果你选择.vscode文件夹中的launch.json文件。

如果你错过了它,你可以从调试视图中手动添加:

图 2.13 – 从调试视图创建 launch.json 文件

图 2.13 – 从调试视图创建 launch.json 文件

如果你没有看到图 2.13 中的按钮,你可以通过按Ctrl + Shift + P(在 Windows 上)或Command + Shift + P(在 macOS 上)打开命令面板,然后输入.net,在.vscode文件夹中选择launch.json文件:

图 2.14 – 从命令面板生成 launch.json 文件

图 2.14 – 从命令面板生成 launch.json 文件

下面显示了默认的launch.json配置内容:

{    "version": "0.2.0",
    "configurations": [
        {
            // Use IntelliSense to find out which attributes exist for C# debugging
            // Use hover for the description of the existing attributes
            // For further information visit https://github.com/dotnet/vscode-csharp/blob/main/debugger-launchjson.md.
            "name": ".NET Core Launch (web)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            // If you have changed target frameworks, make sure to update the program path.
            "program": "${workspaceFolder}/bin/Debug/net8.0/MyFirstApi.dll",
            "args": [],
            "cwd": "${workspaceFolder}",
            "stopAtEntry": false,
            // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
            "serverReadyAction": {
                "action": "openExternally",
                "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
            },
            "env": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            },
            "sourceFileMap": {
                "/Views": "${workspaceFolder}/Views"
            }
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach"
        }
    ]
}

此文件指定了调试的配置。以下是一些重要属性的描述:

  • program属性指定可执行文件的路径

  • args属性指定传递给可执行文件的参数

  • cwd属性指定工作目录

  • env属性指定环境变量

目前我们不需要在这个文件中做任何更改。

在应用程序中设置一个断点。例如,我们可以在WeatherForecastController.cs文件中的Get()方法上设置一个断点,通过点击代码窗口左侧的空白边缘来实现。一旦设置了断点,你将在左侧空白边缘的行号前看到一个红色圆点:

图 2.15 – 在 VS Code 中设置断点

图 2.15 – 在 VS Code 中设置断点

要调试应用程序,通过在左侧菜单中选择调试图标打开调试视图。确保你从下拉菜单中选择正确的调试配置。对于这个案例,请选择.NET Core 启动(Web)。然后,选择面板顶部的绿色箭头:

图 2.16 – 在 VS Code 中调试 API

图 2.16 – 在 VS Code 中调试 API

从上一节中的任何工具发送请求,你将看到程序在到达断点时停止执行,如下所示:

图 2.17 – 在 VS Code 中命中断点

图 2.17 – 在 VS Code 中命中断点

变量窗口的局部部分将显示当前上下文中定义的变量的值。

你也可以在调试控制台窗口中输入一个变量来直接检查其值。要执行下一步,你可以使用 VS Code 窗口顶部的控制工具栏。你可以逐行运行代码以监控其执行。如果我们需要了解程序的工作原理,这会很有帮助。

现在我们已经学习了如何构建、运行和测试 API,是时候看看 API 的代码了。

理解 MVC 模式

ASP.NET Core MVC 是一个丰富的框架,用于使用 模型-视图-控制器MVC)设计模式构建 Web 应用程序。MVC 模式使 Web 应用程序能够将表示层与业务逻辑分离。ASP.NET Core Web API 项目遵循基本的 MVC 模式,但没有视图,因此它只有模型层和控制器层。让我们更详细地看看:

  • 模型:模型是表示应用程序中使用的数据的类。通常,数据存储在数据库中。

  • 控制器文件夹。图 2**.18 展示了一个在 Web API 项目中 MVC 模式的示例。然而,视图层不包括在 Web API 项目中。客户端的请求将被映射到控制器,控制器将执行业务逻辑并将响应返回给客户端。

图 2.18 – MVC 模式

图 2.18 – MVC 模式

接下来,我们将查看 ASP.NET Core Web API 项目中模型和控制器代码。

模型和控制器

在 ASP.NET Core 模板项目中,你可以找到一个名为 WeatherForecast.cs 的文件。这是一个模型。它是一个纯 C# 类,表示数据模型。

控制器位于 Controllers 文件夹中的 WeatherForecastController.cs 文件。它包含业务逻辑。

它看起来像这样:

[ApiController][Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    // Some code is ignored
    private readonly ILogger<WeatherForecastController> _logger;
    public WeatherForecastController(ILogger<WeatherForecastController> logger)
    {
        _logger = logger;
    }
    [HttpGet(Name = "GetWeatherForecast")]
    public IEnumerable<WeatherForecast> Get()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

控制器类的构造函数有一个名为 ILogger<WeatherForecastController> logger 的参数。该参数用于记录消息。它由 ASP.NET Core 框架通过依赖注入(DI)注入。我们将在下一节中讨论 DI。

此类有一个 [ApiController] 属性,表示它是一个 Web API 控制器。它还有一个 [Route("[controller]")] 属性,表示控制器的 URL。

Get() 方法有一个 [HttpGet(Name = "GetWeatherForecast")] 属性,它指示端点的名称,Get() 方法是一个 GET 操作。此方法返回一个包含天气预报的列表作为响应。

注意,[Route("[controller]")] 属性标记在控制器类上。这意味着控制器的路径是 /WeatherForecast。目前,Get() 方法上没有 [Route] 属性。我们将在未来的章节中了解更多关于路由的内容。

我们现在应该对 ASP.NET Core Web API 的工作原理有一个基本的了解。客户端向 Web API 发送请求,请求将被映射到控制器和方法。控制器将执行业务逻辑并返回响应。我们可以在控制器中使用一些方法从数据库中获取、保存、更新和删除数据。

接下来,让我们通过添加新的模型和控制器来创建一个新的 API 端点。

创建新的模型和控制器

第一章,我们展示了 jsonplaceholder.typicode.com/posts 上的一个示例 REST API。它返回一个帖子列表,如下所示:

[  {
    "userId": 1,
    "id": 1,
    "title": "sunt aut facere repellat provident occaecati excepturi optio reprehenderit",
    "body": "quia et suscipit\nsuscipit recusandae consequuntur expedita et cum\nreprehenderit molestiae ut ut quas totam\nnostrum rerum est autem sunt rem eveniet architecto"
  },
  {
    "userId": 1,
    "id": 2,
    "title": "qui est esse",
    "body": "est rerum tempore vitae\nsequi sint nihil reprehenderit dolor beatae ea dolores neque\nfugiat blanditiis voluptate porro vel nihil molestiae ut reiciendis\nqui aperiam non debitis possimus qui neque nisi nulla"
  },
  ...
]

让我们实现一个类似的 API。首先,我们需要创建一个新的模型。在项目中创建一个名为 Models 的新文件夹。然后,在 Models 文件夹中创建一个名为 Post.cs 的新文件:

namespace MyFirstApi.Models;public class Post
{
    public int UserId { get; set; }
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
}

文件作用域命名空间声明

从 C# 10 开始,你可以使用一种新的命名空间声明形式,如前一个代码片段所示,这被称为文件作用域命名空间声明。此文件中的所有成员都在同一个命名空间中。这节省了空间并减少了缩进。

可空引用类型

你可能想知道为什么我们将 TitleBody 属性赋值为空字符串。这是因为这些属性的类型是 string。如果我们不初始化属性,编译器将会报错:

不可为空的属性 'Title' 在构造函数退出时必须包含一个非空值。考虑将属性声明为可空的。

默认情况下,ASP.NET Core Web API 项目模板在 <PropertyGroup> 部分启用了 <Nullable>enable</Nullable>

可空引用类型是在 C# 8.0 中引入的。它们可以最小化导致运行时抛出 System.NullReferenceException 错误的错误可能性。例如,如果我们忘记初始化 Title 属性,当我们尝试访问它的属性时,比如 Title.Length,可能会得到 System.NullReferenceException 错误。

启用此功能后,任何引用类型的变量都被视为不可为空。如果您想允许变量为可空,必须使用 ? 运算符将类型名称附加到变量声明中,以将其声明为可空引用类型;例如,public string Title? { get; set; },这明确地将属性标记为可空。

要了解更多关于此功能的信息,请参阅 docs.microsoft.com/en-us/dotnet/csharp/nullable-references

接下来,在 Controllers 文件夹中创建一个名为 PostController.cs 的新文件。您可以手动添加它,或者安装 dotnet-aspnet-codegenerator 工具来创建它。要安装该工具,请在项目文件夹中运行以下命令:

dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Designdotnet tool install -g dotnet-aspnet-codegenerator

前面的命令安装了用于脚手架的必需的 NuGet 包。dotnet-aspnet-codegenerator 工具是一个脚手架引擎,用于生成代码。

然后,运行以下命令来生成控制器:

dotnet-aspnet-codegenerator controller -name PostsController -api -outDir Controllers

前面的命令生成一个空控制器。-name 选项指定了控制器的名称。-api 选项表示控制器是一个 API 控制器。-outDir 选项指定了输出目录。按照以下内容更新控制器的内容:

using Microsoft.AspNetCore.Mvc;using MyFirstApi.Models;
namespace MyFirstApi.Controllers;
[Route("api/[controller]")]
[ApiController]
public class PostsController : ControllerBase
{
    [HttpGet]
    public ActionResult<List<Post>> GetPosts()
    {
        return new List<Post>
        {
            new() { Id = 1, UserId = 1, Title = "Post1", Body = "The first post." },
            new() { Id = 2, UserId = 1, Title = "Post2", Body = "The second post." },
            new() { Id = 3, UserId = 1, Title = "Post3", Body = "The third post." }
        };
    }
}

目标类型的新表达式

当我们创建一个特定类型的 List 实例时,我们通常会使用如下代码:

var list = new List<Post>

{

new Post() { Id = 1, UserId = 1, Title = "Post1", Body = "The first post." },

};

当列表声明为 List<Post> 时,类型已知,因此在添加新元素时不需要使用 new Post()。对于构造函数,例如 new(),可以省略类型指定。这个特性是在 C# 9.0 中引入的。

控制器的名称为 PostsController。约定是资源名称加上 Controller 后缀。它带有 ApiController 属性,表示该控制器是一个 Web API 控制器。它还有一个 [Route("api/[controller]")] 属性,表示控制器的 URL。[controller] 作为一个占位符,将在路由中替换为控制器的名称。因此,此控制器的路由是 /api/posts

在此控制器中,我们有一个名为 GetPosts() 的方法。该方法返回一个帖子列表作为响应。该方法带有 [HttpGet] 属性,表示这是一个 GET 操作。它没有任何路由模板,因为它将匹配 /api/posts。对于其他方法,我们可以使用 [Route("[action]")] 属性来指定路由模板。

GetPosts() 方法的返回类型是 ActionResult<IEnumerable<Post>>。ASP.NET Core 可以自动将对象转换为 JSON 并将其作为响应消息返回给客户端。它还可以返回其他 HTTP 状态码,例如 NotFoundBadRequestInternalServerError 等。我们将在稍后看到更多示例。

如果你运行dotnet rundotnet watch,然后导航到 Swagger UI,例如https://localhost:7291/swagger/index.html,你将看到列出的新 API。API 可通过/api/posts访问。

目前,/api/posts端点返回一个硬编码的帖子列表。让我们更新控制器以从服务返回帖子列表。

创建服务

在项目中创建一个Services文件夹。然后,在Services文件夹中创建一个名为PostService.cs的新文件,如下所示:

using MyFirstApi.Models;namespace MyFirstApi.Services;
public class PostsService
{
    private static readonly List<Post> AllPosts = new();
    public Task CreatePost(Post item)
    {
        AllPosts.Add(item);
        return Task.CompletedTask;
    }
    public Task<Post?> UpdatePost(int id, Post item)
    {
        var post = AllPosts.FirstOrDefault(x => x.Id == id);
        if (post != null)
        {
            post.Title = item.Title;
            post.Body = item.Body;
            post.UserId = item.UserId;
        }
        return Task.FromResult(post);
    }
    public Task<Post?> GetPost(int id)
    {
        return Task.FromResult(AllPosts.FirstOrDefault(x => x.Id == id));
    }
    public Task<List<Post>> GetAllPosts()
    {
        return Task.FromResult(AllPosts);
    }
    public Task DeletePost(int id)
    {
        var post = AllPosts.FirstOrDefault(x => x.Id == id);
        if (post != null)
        {
            AllPosts.Remove(post);
        }
        return Task.CompletedTask;
    }
}

PostsService类是一个简单的演示服务,用于管理帖子列表。它有创建、更新和删除帖子的方法。为了简化实现,它使用静态字段来存储帖子列表。这只是为了演示目的;请勿在生产环境中使用。

接下来,我们将遵循 API 设计来实现 CRUD 操作。你可以回顾上一章的基于 REST 的 API 设计部分。

实现获取操作

viewPost()操作的实现设计如下:

操作名称 URL HTTP 方法 输入 响应 描述
viewPost() /posts/{postId} GET PostId 帖子,200 查看帖子详情

表 2.1 – viewPost()操作的实现设计

按如下方式更新PostController类:

using Microsoft.AspNetCore.Mvc;using MyFirstApi.Models;
using MyFirstApi.Services;
namespace MyFirstApi.Controllers;
[Route("api/[controller]")]
[ApiController]
public class PostsController : ControllerBase
{
    private readonly PostsService _postsService;
    public PostsController()
    {
        _postsService = new PostsService();
    }
    [HttpGet("{id}")]
    public async Task<ActionResult<Post>> GetPost(int id)
    {
        var post = await _postsService.GetPost(id);
        if (post == null)
        {
            return NotFound();
        }
        return Ok(post);
    }
    // Omitted for brevity
}

在控制器的构造方法中,我们初始化了_postsService字段。请注意,我们使用new()构造函数来创建服务的一个实例。这意味着控制器与PostsService类耦合在一起。我们将在下一章中看到如何解耦控制器和服务。

然后,创建一个名为GetPost()的方法,该方法返回具有指定 ID 的帖子。它有一个[HttpGet("{id}")]属性来指示操作的 URL。URL 将被映射到/api/posts/{id}id是一个占位符,它将被帖子的 ID 替换。然后,id将被作为参数传递给GetPost()方法。

如果找不到帖子,该方法将返回一个NotFound响应。ASP.NET Core 提供了一套内置的响应消息,例如NotFoundBadRequestInternalServerError等。

如果你现在调用 API,它将返回NotFound,因为我们还没有创建帖子。

实现创建操作

createPost()操作的实现设计如下:

操作名称 URL HTTP 方法 输入 响应 描述
createPost() /posts POST 帖子 帖子,201 创建一个新的帖子

表 2.2 – createPost()操作的实现设计

在控制器中创建一个名为CreatePost()的新方法。由于控制器已映射到api/posts,我们不需要指定此方法的路由。该方法的内容如下:

[HttpPost]public async Task<ActionResult<Post>> CreatePost(Post post)
{
    await _postsService.CreatePost(post);
    return CreatedAtAction(nameof(GetPost), new { id = post.Id }, post);
}

当我们调用这个端点时,post 对象将以 JSON 格式序列化,并将其附加到 POST 请求体中。在这个方法中,我们可以从请求中获取帖子,然后调用服务中的 CreatePost() 方法来创建一个新的帖子。然后,我们将返回内置的 CreatedAtAction,它返回一个包含指定操作名称、路由值和帖子的响应消息。对于这种情况,它将调用 GetPost() 操作来返回新创建的帖子。

现在,我们可以测试 API。例如,我们可以在 Thunder Client 中发送一个 POST 请求。

将方法改为 POST。使用以下 JSON 数据作为正文:

{  "userId": 1,
  "id": 1,
  "title": "Hello ASP.NET Core",
  "body": "ASP.NET Core is a cross-platform, high-performance, open-source framework for building modern, cloud-enabled, Internet-connected apps."
}

点击 201 Created

图 2.19 – 发送 POST 请求

图 2.19 – 发送 POST 请求

然后,向 api/posts/1 端点发送一个 GET 请求。我们可以得到如下响应:

图 2.20 – 发送 GET 请求

图 2.20 – 发送 GET 请求

请注意,我们创建的帖子存储在服务的内存中。因为我们没有提供数据库来存储数据,如果我们重新启动应用程序,帖子将会丢失。

接下来,让我们看看如何实现更新操作。

实现 UPDATE 操作

updatePost() 操作的设计如下:

操作名称 URL HTTP 方法 输入 响应 描述
updatePost() /posts/{postId} PUT Post Post, 200 更新一个新的帖子

表 2.3 – updatePost() 操作的设计

在控制器中创建一个新的 UpdatePost() 方法,如下所示:

[HttpPut("{id}")]public async Task<ActionResult> UpdatePost(int id, Post post)
{
    if (id != post.Id)
    {
        return BadRequest();
    }
    var updatedPost = await _postsService.UpdatePost(id, post);
    if (updatedPost == null)
    {
        return NotFound();
    }
    return Ok(post);
}

此方法有一个 [HttpPut("{id}")] 属性,表示这是一个 PUT 操作。同样,id 是一个占位符,它将被帖子的 ID 替换。在 PUT 请求中,我们应该将帖子的序列化内容附加到请求体中。

这次,让我们使用 HttpRepl 测试 API。运行以下命令来连接到服务器:

httprepl https://localhost:7291/api/postsconnect https://localhost:7291/api/posts/1
put -h Content-Type=application/json -c "{"userId": 1,"id": 1,"title": "Hello ASP.NET Core 8","body": "ASP.NET Core is a cross-platform, high-performance, open-source framework for building modern, cloud-enabled, Internet-connected apps."}"

你将看到以下输出:

HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8
Date: Thu, 18 Aug 2022 11:25:26 GMT
Server: Kestrel
Transfer-Encoding: chunked
{
  "userId": 1,
  "id": 1,
  "title": "Hello ASP.NET Core 8",
  "body": "ASP.NET Core is a cross-platform, high-performance, open-source framework for building modern, cloud-enabled, Internet-connected apps."
}

然后,我们可以更新 GetPosts() 方法如下:

[HttpGet]public async Task<ActionResult<List<Post>>> GetPosts()
{
    var posts = await _postService.GetAllPosts();
    return Ok(posts);
}

我们已经实现了 GETPOSTPUT 操作。接下来,你可以尝试自己使用 DELETE 操作实现 DeletePost() 方法。

依赖注入

在前面的控制器示例中,有一个在控制器构造方法中使用 new() 构造函数初始化的 _postsService 字段:

private readonly PostsService _postsService;public PostsController()
{
    _postsService = new PostsService();
}

这意味着 PostsController 类依赖于 PostsService 类,而 PostsService 类是 PostsController 类的依赖。如果我们想用不同的实现来保存数据替换 PostsService,我们必须更新 PostsController 的代码。如果 PostsService 类有自己的依赖项,它们也必须由 PostsController 类初始化。当项目变得更大时,依赖项将变得更加复杂。此外,这种实现不易于测试和维护。

依赖注入(DI)是软件开发世界中最为知名的设计模式之一。它有助于解耦相互依赖的类。你可能发现以下术语被交替使用:依赖倒置原则(DIP)、控制反转(IoC)和 DI。尽管它们之间有关联,但这些术语通常会被混淆。你可以找到许多文章和博客文章来解释它们。有些人说它们是同一件事,但有些人说不是。它们究竟是什么?

理解 DI

依赖倒置原则是面向对象(OO)设计中的SOLID原则之一。它在 Robert C. Martin 的 2002 年出版的《敏捷软件开发:原则、模式和最佳实践》Pearson一书中被定义。该原则指出,“高层模块不应依赖于低层模块;两者都应依赖于抽象。抽象不应依赖于细节。细节应依赖于抽象。”

在前面的控制器中,我们提到PostsController依赖于PostsService。控制器是高层模块,而服务是低层模块。当服务发生变化时,控制器也必须随之改变。请注意,术语反转并不意味着低层模块将依赖于高层。相反,两者都应依赖于暴露给高层模块所需行为的抽象。如果我们通过为服务创建一个接口来反转这种依赖关系,那么控制器和服务都将依赖于该接口。只要服务实现尊重接口,其实现可以改变。

IoC(控制反转)是一种编程原则,它反转了应用程序中的控制流。在传统的编程中,自定义代码负责实例化对象和控制主函数的执行。与传统的控制流相比,IoC 反转了控制流。使用 IoC 时,框架负责实例化,调用自定义或任务特定的代码。

它可以用来区分框架和类库。通常,框架调用应用程序代码,而应用程序代码调用库。这种类型的 IoC 有时被称为好莱坞原则:“别给我们打电话,我们会 给你打电话。”

IoC 与 DIP 相关,但它不是同一件事。DIP 关注通过共享抽象(接口)解耦高层模块和低层模块之间的依赖关系。IoC 用于增加程序的模块化并使其可扩展。有几种技术可以实现 IoC,例如服务定位器、DI、模板方法设计模式、策略设计模式等等。

依赖注入(DI)是一种控制反转(IoC)的形式。这个术语由马丁·福勒(Martin Fowler)在 2004 年提出。它将构建对象和使用对象的关注点分离。当一个对象或函数(客户端)需要依赖项时,它不知道如何构建它。相反,客户端只需要声明依赖项的接口,然后由外部代码(注入器)将依赖项注入到客户端。这使得更改依赖项的实现变得更加容易。它通常类似于策略设计模式。不同之处在于,策略模式可以使用不同的策略来构建依赖项,而 DI 通常只使用依赖项的单个实例。

DI 主要有三种类型:

  • 构造函数注入:依赖项作为客户端构造函数的参数提供

  • 设置器注入:客户端公开一个设置器方法以接受依赖项

  • 接口注入:依赖项的接口提供了一个注入方法,该方法将依赖项注入到传递给它的任何客户端

如您所见,这三个术语是相关的,但也有一些区别。简单来说,DI 是一种在类及其依赖项之间实现 IoC 的技术。ASP.NET Core 将 DI 作为一等公民支持。

ASP.NET Core 中的 DI

ASP.NET Core 使用构造函数注入来请求依赖项。要使用它,我们需要做以下几步:

  1. 定义接口及其实现。

  2. 将接口及其实现注册到服务容器中。

  3. 将服务作为构造函数参数添加以注入依赖项。

您可以从章节的 GitHub 仓库中samples/chapter2/ DependencyInjectionDemo/DependencyInjectionDemo文件夹下载名为DependencyInjectionDemo的示例项目。

按照以下步骤在 ASP.NET Core 中使用 DI:

  1. 首先,我们将创建一个接口及其实现。将Post.cs文件和PostService.cs文件从上一个MyFirstApi项目复制到DependencyInjectionDemo项目中。在Service文件夹中创建一个名为IPostService的新接口,如下所示:

    public interface IPostService{    Task CreatePost(Post item);    Task<Post?> UpdatePost(int id, Post item);    Task<Post?> GetPost(int id);    Task<List<Post>> GetAllPosts();    Task DeletePost(int id);}
    public class PostsService : IPostService
    

    您可能还需要更新Post类和PostService类的命名空间。

  2. 接下来,我们可以将IPostService接口和PostService实现注册到服务容器中。打开Program.cs文件,你会找到一个名为builderWebApplicationBuilder实例,它是通过调用WebApplication.CreateBuilder()方法创建的。CreateBuilder()方法是应用程序的入口点。我们可以使用 builder 实例来配置应用程序,然后调用builder.Build()方法来构建WebApplication。添加以下代码:

    builder.Services.AddScoped<IPostService, PostsService>();
    

    上述代码使用了AddScoped()方法,这表示服务会在每个客户端请求时创建一次,并在请求完成后销毁。

  3. PostsController.cs文件从先前的MyFirstApi项目复制到DependencyInjectionDemo项目。更新命名空间和using语句。然后,更新控制器的构造方法如下:

    private readonly IPostService _postsService;public PostsController(IPostService postService){    _postsService = postService;}
    

    前面的代码使用IPostService接口作为构造函数参数。服务容器将正确的实现注入到控制器中。

DI 有四个角色:服务、客户端、接口和注入器。在这个例子中,IPostService是接口,PostService是服务,PostsController是客户端,而builder.Services是注入器,它是一个用于应用程序组合的服务集合。有时它被称为 DI 容器。

PostsController类从其构造函数请求IPostService的实例。控制器(客户端)不知道服务在哪里,也不知道它是如何构建的。控制器只知道接口。服务已在服务容器中注册,可以将其正确的实现注入到控制器中。我们不需要使用new关键字来创建服务的实例。这意味着客户端和服务是解耦的。

这个依赖注入(DI)功能是由一个名为Microsoft.Extensions.DependencyInjection的 NuGet 包提供的。当创建 ASP.NET Core 项目时,此包会自动添加。如果你创建的是控制台项目,你可能需要手动使用以下命令安装它:

dotnet add package Microsoft.Extensions.DependencyInjection

如果我们想用另一个实现替换IPostService,我们可以通过将新的实现注册到服务容器中来实现。控制器的代码不需要更改。这就是依赖注入(DI)的一个好处。

接下来,让我们讨论服务的作用域。

DI 生命周期

在前面的示例中,服务是通过AddScoped()方法注册的。在 ASP.NET Core 中,当服务注册时,有三个生命周期:

  • Transient: 每次请求时都会创建一个短暂服务,并在请求结束时销毁。

  • Scoped: 在 Web 应用程序中,作用域意味着一个请求(连接)。作用域服务在每次客户端请求时创建,并在请求结束时销毁。

  • Singleton: 单例服务在第一次请求或提供实现实例到服务容器时创建。所有后续请求都将使用相同的实例。

为了演示这些生命周期之间的差异,我们将使用一个简单的演示服务:

Services文件夹中创建一个新的接口IDemoService及其实现DemoService,如下所示:

IDemoService.cs:

namespace DependencyInjectionDemo.Services;public interface IDemoService
{
    SayHello();
}

DemoService.cs:

namespace DependencyInjectionDemo.Services;public class DemoService : IDemoService
{
    private readonly Guid _serviceId;
    private readonly DateTime _createdAt;
    public DemoService()
    {
        _serviceId = Guid.NewGuid();
        _createdAt = DateTime.Now;
    }
    public string SayHello()
    {
        return $"Hello! My Id is {_serviceId}. I was created at {_createdAt:yyyy-MM-dd HH:mm:ss}.
";
    }
}

实现将在调用SayHello()方法时生成一个 ID 和一个创建时间,并将其输出。

  1. 然后,我们可以将接口和实现注册到服务容器中。打开Program.cs文件,并添加以下代码:

    builder.Services.AddScoped<IDemoService, DemoService>();
    
  2. 创建一个名为 DemoController.cs 的控制器。现在,我们可以将服务作为构造函数参数添加以注入依赖项:

    [ApiController][Route("[controller]")]public class DemoController : ControllerBase{ private readonly IDemoService _demoService; public DemoController(IDemoService demoService) { _demoService = demoService; } [HttpGet] public ActionResult Get() { return Content(_demoService.SayHello()); }}
    

对于这个例子,如果你测试 /demo 端点,你将看到输出中的 GUID 值和创建时间每次都会变化:

http://localhost:5147/> get demoHTTP/1.1 200 OK
Content-Length: 91
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:06:46 GMT
Server: Kestrel
Hello! My Id is 6ca84d82-90cb-4dd6-9a34-5ea7573508ac. I was created at 2023-10-21 11:06:46.
http://localhost:5147/> get demo
HTTP/1.1 200 OK
Content-Length: 91
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:07:02 GMT
Server: Kestrel
Hello! My Id is 9bc5cf49-661d-45bb-b9ed-e0b3fe937827\. I was created at 2023-10-21 11:07:02.

我们可以将生命周期更改为 AddSingleton(),如下所示:

builder.Services.AddSingleton<IDemoService, DemoService>();

对于所有请求,GUID 值和创建时间值都将相同:

http://localhost:5147/> get demoHTTP/1.1 200 OK
Content-Length: 91
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:08:57 GMT
Server: Kestrel
Hello! My Id is a1497ead-bff6-4020-b337-28f1d3af7b05\. I was created at 2023-10-21 11:08:02.
http://localhost:5147/> get demo
HTTP/1.1 200 OK
Content-Length: 91
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:09:12 GMT
Server: Kestrel
Hello! My Id is a1497ead-bff6-4020-b337-28f1d3af7b05\. I was created at 2023-10-21 11:08:02.

由于 DemoController 类在每个请求中只对 IDemoService 接口请求一次,因此我们无法区分 scopedtransient 服务之间的行为。让我们来看一个更复杂的例子。

  1. 你可以在 DependencyInjectionDemo 项目中找到示例代码。有三个接口及其实现:

    public interface IService{    string Name { get; }    string SayHello();}public interface ITransientService : IService{}public class TransientService : ITransientService{    private readonly Guid _serviceId;    private readonly DateTime _createdAt;    public TransientService()    {        _serviceId = Guid.NewGuid();        _createdAt = DateTime.Now;    }    public string Name => nameof(TransientService);    public string SayHello()    {        return $"Hello! I am {Name}. My Id is {_serviceId}. I was created at {_createdAt:yyyy-MM-dd HH:mm:ss}.";    }}public interface ISingletonService : IService{}public class SingletonService : ISingletonService{    private readonly Guid _serviceId;    private readonly DateTime _createdAt;    public SingletonService()    {        _serviceId = Guid.NewGuid();        _createdAt = DateTime.Now;    }    public string Name => nameof(SingletonService);    public string SayHello()    {        return $"Hello! I am {Name}. My Id is {_serviceId}. I was created at {_createdAt:yyyy-MM-dd HH:mm:ss}.";    }}public interface IScopedService : IService{}public class ScopedService : IScopedService{    private readonly Guid _serviceId;    private readonly DateTime _createdAt;    private readonly ITransientService _transientService;    private readonly ISingletonService _singletonService;    public ScopedService(ITransientService transientService, ISingletonService singletonService)    {        _transientService = transientService;        _singletonService = singletonService;        _serviceId = Guid.NewGuid();        _createdAt = DateTime.Now;    }    public string Name => nameof(ScopedService);    public string SayHello()    {        var scopedServiceMessage = $"Hello! I am {Name}. My Id is {_serviceId}. I was created at {_createdAt:yyyy-MM-dd HH:mm:ss}.";        var transientServiceMessage = $"{_transientService.SayHello()} I am from {Name}.";        var singletonServiceMessage = $"{_singletonService.SayHello()} I am from {Name}.";        return            $"{scopedServiceMessage}{Environment.NewLine}{transientServiceMessage}{Environment.NewLine}{singletonServiceMessage}";    }}
    
  2. Program.cs 文件中,我们可以将它们注册到服务容器中,如下所示:

    builder.Services.AddScoped<IScopedService, ScopedService>();builder.Services.AddTransient<ITransientService, TransientService>();builder.Services.AddSingleton<ISingletonService, SingletonService>();
    
  3. 然后,创建一个名为 LifetimeController.cs 的控制器。代码如下:

    [ApiController][Route("[controller]")]public class LifetimeController : ControllerBase{    private readonly IScopedService _scopedService;    private readonly ITransientService _transientService;    private readonly ISingletonService _singletonService;    public LifetimeController(IScopedService scopedService, ITransientService transientService,        ISingletonService singletonService)    {        _scopedService = scopedService;        _transientService = transientService;        _singletonService = singletonService;    }    [HttpGet]    public ActionResult Get()    {        var scopedServiceMessage = _scopedService.SayHello();        var transientServiceMessage = _transientService.SayHello();        var singletonServiceMessage = _singletonService.SayHello();        return Content(            $"{scopedServiceMessage}{Environment.NewLine}{transientServiceMessage}{Environment.NewLine}{singletonServiceMessage}");    }}
    

在这个例子中,ScopedService 有两个依赖项:ITransientServiceISingletonService。因此,当 ScopedService 被创建时,它将从服务容器中请求这些依赖项的实例。另一方面,控制器也有依赖项:IScopedServiceITransientServiceISingletonService。当控制器被创建时,它将请求这三个依赖项。这意味着 ITransientServiceISingletonService 在每个请求中都需要两次。但让我们检查以下请求的输出:

http://localhost:5147/> get lifetimeHTTP/1.1 200 OK
Content-Length: 625
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:20:44 GMT
Server: Kestrel
Hello! I am ScopedService. My Id is df87d966-0e86-4f08-874f-ba6ce71de560\. I was created at 2023-10-21 11:20:44.
Hello! I am TransientService. My Id is 77e29268-ad48-423c-94e5-de1d09bd3ba5\. I was created at 2023-10-21 11:20:44\. I am from ScopedService.
Hello! I am SingletonService. My Id is 95a44c5b-8678-48c6-a2f0-cc6b90423773\. I was created at 2023-10-21 11:20:44\. I am from ScopedService.
Hello! I am TransientService. My Id is e77564d1-e146-4d29-b74b-a07f8f6640c1\. I was created at 2023-10-21 11:20:44.
Hello! I am SingletonService. My Id is 95a44c5b-8678-48c6-a2f0-cc6b90423773\. I was created at 2023-10-21 11:20:44.
http://localhost:5147/> get lifetime
HTTP/1.1 200 OK
Content-Length: 625
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:20:57 GMT
Server: Kestrel
Hello! I am ScopedService. My Id is e5f802ed-5e4c-4abd-9213-8f13f97c1008\. I was created at 2023-10-21 11:20:57.
Hello! I am TransientService. My Id is daccb91b-438f-4561-9c86-13b02ad8e358\. I was created at 2023-10-21 11:20:57\. I am from ScopedService.
Hello! I am SingletonService. My Id is 95a44c5b-8678-48c6-a2f0-cc6b90423773\. I was created at 2023-10-21 11:20:44\. I am from ScopedService.
Hello! I am TransientService. My Id is 94e9e6c1-729a-4033-8a27-550ea10ba5d0\. I was created at 2023-10-21 11:20:57.
Hello! I am SingletonService. My Id is 95a44c5b-8678-48c6-a2f0-cc6b90423773\. I was created at 2023-10-21 11:20:44.

我们可以看到,在每个请求中,ScopedService 只被创建了一次,而 ITransientService 被创建了两次。在这两个请求中,SingletonService 只被创建了一次。

组注册

随着项目的增长,我们可能会有越来越多的服务。如果我们把所有服务都注册在 Program.cs 中,这个文件将会变得非常大。在这种情况下,我们可以使用组注册一次注册多个服务。例如,我们可以创建一个名为 LifetimeServicesCollectionExtensions.cs 的服务组:

public static class LifetimeServicesCollectionExtensions{
    public static IServiceCollection AddLifetimeServices(this IServiceCollection services)
    {
        services.AddScoped<IScopedService, ScopedService>();
        services.AddTransient<ITransientService, TransientService>();
        services.AddSingleton<ISingletonService, SingletonService>();
        return services;
    }
}

这是一个针对 IServiceCollection 接口的扩展方法。它用于在 Program.cs 文件中一次性注册所有服务:

// Group registrationbuilder.Services.AddLifetimeServices();

以这种方式,Program.cs 文件将会更小,也更易于阅读。

动作注入

有时候,一个控制器可能需要很多服务,但可能不是所有的动作都需要所有这些服务。如果我们从构造函数中注入所有依赖项,构造函数方法将会很大。在这种情况下,我们可以使用动作注入,只在需要时注入依赖项。以下是一个示例:

[HttpGet]public ActionResult Get([FromServices] ITransientService transientService)
{
  ...
}

[FromServices] 属性允许服务容器在需要时注入依赖项,而不使用构造函数注入。然而,如果你发现某个服务需要很多依赖项,这可能表明该类承担了过多的责任。根据单一职责原则SRP),考虑重构该类,将责任拆分到更小的类中。

请记住,这种动作注入只适用于控制器中的动作。它不支持普通类。此外,自 ASP.NET Core 7.0 以来,可以省略 [FromServices] 属性,因为框架将自动尝试解决 DI 容器中注册的任何复杂类型参数。

键控服务

ASP.NET Core 8.0 引入了一个名为键控服务或命名服务的新特性。这个特性允许开发者使用键来注册服务,从而可以使用该键访问服务。这使得在应用程序中管理实现相同接口的多个服务变得更加容易,因为键可以用来识别和访问服务。

例如,我们有一个名为 IDataService 的服务接口:

public interface IDataService{
    string GetData();
}

这个 IDataService 接口有两个实现:SqlDatabaseServiceCosmosDatabaseService

public class SqlDatabaseService : IDataService{
    public string GetData()
    {
        return "Data from SQL Database";
    }
}
public class CosmosDatabaseService : IDataService
{
    public string GetData()
    {
        return "Data from Cosmos Database";
    }
}

我们可以使用不同的键将它们注册到服务容器中:

builder.Services.AddKeyedScoped<IDataService, SqlDatabaseService>("sqlDatabaseService");builder.Services.AddKeyedScoped<IDataService, CosmosDatabaseService>("cosmosDatabaseService");

然后,我们可以使用 FromKeyedServices 属性来注入服务:

[ApiController][Route("[controller]")]
public class KeyedServicesController : ControllerBase
{
    [HttpGet("sql")]
    public ActionResult GetSqlData([FromKeyedServices("sqlDatabaseService")] IDataService dataService) =>
        Content(dataService.GetData());
    [HttpGet("cosmos")]
    public ActionResult GetCosmosData([FromKeyedServices("cosmosDatabaseService")] IDataService dataService) =>
        Content(dataService.GetData());
}

使用 FromKeyedServices 属性可以通过指定的键注入服务。使用 HttpRepl 测试 API,你会看到以下输出:

http://localhost:5147/> get keyedServices/sqlHTTP/1.1 200 OK
Content-Length: 22
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:48:49 GMT
Server: Kestrel
Data from SQL Database
http://localhost:5147/> get keyedServices/cosmos
HTTP/1.1 200 OK
Content-Length: 25
Content-Type: text/plain; charset=utf-8
Date: Fri, 20 Oct 2023 22:48:54 GMT
Server: Kestrel
Data from Cosmos Database

键控服务也可以用来注册单例或瞬态服务。只需分别使用 AddKeyedSingleton()AddKeyedTransient() 方法;例如:

builder.Services.AddKeyedSingleton<IDataService, SqlDatabaseService>("sqlDatabaseService");builder.Services.AddKeyedTransient<IDataService, CosmosDatabaseService>("cosmosDatabaseService");

重要的是要注意,如果传递了一个空字符串作为键,则必须使用空字符串的键注册服务的默认实现,否则服务容器将抛出异常。

微软经常发布新的 .NET SDK 版本。如果你遇到不同的版本号,这是可以接受的。

上述命令将列出你机器上所有可用的 SDK。例如,如果你安装了多个 .NET SDK,它可能会显示以下输出。

重要提示

每个微软产品都有其生命周期。.NET 和 .NET Core 提供了 长期支持LTS)版本,这些版本将获得 3 年的补丁和免费支持。当这本书编写时,.NET 7 仍然在支持中,直到 2024 年 5 月。根据微软的政策,偶数版本是 LTS 版本。因此,.NET 8 是最新的 LTS 版本。本书中的代码示例是用 .NET 8.0 编写的。

当你使用 VS Code 打开项目时,C# Dev Kit 扩展可以为你创建一个解决方案文件。这个特性使得 VS Code 对 C# 开发者更加友好。你可以在资源管理器视图中看到以下结构:

它使用 Swashbuckle.AspNetCore NuGet 包,该包提供了 Swagger UI 来文档化和测试 API。

按照以下步骤在 ASP.NET Core 中使用依赖注入:

我们可以看到,在每次请求中,ScopedService 只被创建了一次,而 ITransientService 被创建了两次。在这两次请求中,SingletonService 只被创建了一次。

使用主构造函数注入依赖项

从 .NET 8 和 C# 12 开始,我们可以使用主要构造函数来注入依赖。主要构造函数允许我们直接在类声明中声明构造函数参数,而不是使用单独的构造函数方法。例如,我们可以更新 PostsController 类如下:

```csharppublic class PostsController(IPostService postService) : ControllerBase

{

// 无需定义私有字段来存储服务

// 无需定义构造函数方法

}

```cs

你可以在 DependencyInjectionDemo 项目的 Controller 文件夹中找到一个名为 PrimaryConstructorController.cs 的示例。

当在类中使用主要构造函数时,请注意传递给类声明的参数不能用作属性或成员。例如,如果一个类在类声明中声明了一个名为 postService 的参数,则不能使用 this.postService 或外部代码来访问它。有关主要构造函数的更多信息,请参阅 https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/instance-constructors#primary-constructors 的文档。

主要构造函数可以让我们避免编写字段和构造函数方法。因此,我们将在下面的示例中使用它们。

不要使用 new 来创建服务 B,否则服务 A 将与服务 B 紧密耦合。

应用启动时解析服务

如果我们在 Program.cs 文件中需要一个服务,我们不能使用构造函数注入。对于这种情况,我们可以在应用启动时为有限时间解析作用域服务,如下所示:

var app = builder.Build();using (var serviceScope = app.Services.CreateScope())
{
    var services = serviceScope.ServiceProvider;
    var demoService = services.GetRequiredService<IDemoService>();
    var message = demoService.SayHello();
    Console.WriteLine(message);
}

上述代码创建了一个作用域,并从服务容器中解析了 IDemoService 服务。然后,它可以使用该服务进行某些操作。作用域释放后,服务也将被释放。

DI 小贴士

ASP.NET Core 严重依赖 DI。以下是一些使用 DI 的技巧:

  • 在设计服务时,尽量使服务无状态。除非必须,不要使用静态类和成员。如果你需要使用全局状态,考虑使用单例服务。

  • 仔细设计服务之间的依赖关系。不要创建循环依赖。

  • 不要在另一个服务中使用 new 来创建服务实例。例如,如果服务 A 依赖于服务 B,则应使用 DI 将服务 B 的实例注入到服务 A 中。不要使用 new 来创建服务 B,否则服务 A 将与服务 B 紧密耦合。

  • 使用 DI 容器来管理服务的生命周期。如果一个服务实现了 IDisposable 接口,当作用域被释放时,DI 容器将释放该服务。不要手动释放它。

  • 在注册服务时,不要使用 new 来创建服务的实例。例如,services.AddSingleton(new ExampleService()); 会注册一个不由服务容器管理的服务实例。因此,依赖注入框架将无法自动释放该服务。

  • 避免使用服务定位器模式。如果可以使用依赖注入,不要使用 GetService() 方法来获取服务实例。

您可以在 docs.microsoft.com/zh-cn/dotnet/core/extensions/dependency-injection-guidelines 上了解更多关于依赖注入指南的信息。

为什么模板项目中没有为日志提供配置方法?

ASP.NET Core 为日志提供内置的依赖注入实现。当项目创建时,日志是由 ASP.NET Core 框架注册的。因此,模板项目中没有为日志提供配置方法。实际上,ASP.NET Core 框架自动注册了 250 多个服务。

我可以使用第三方依赖注入容器吗?

强烈建议您使用 ASP.NET Core 中的内置依赖注入实现。但如果您需要它不支持的一些特定功能,例如属性注入、Func<T> 支持懒加载等,您可以使用第三方依赖注入容器,例如 Autofac (autofac.org/)。

最小 API 简介

在上一节“创建简单的 Web API 项目”中,我们使用 dotnet new webapi -n MyFirstApi -controllers 命令创建了一个简单的 Web API 项目。-controllers 选项(或 --use-controllers)表示项目将使用基于控制器的路由。或者,可以使用 -minimal--use-minimal-apis 选项来创建一个使用最小 API 的项目。在本节中,我们将介绍最小 API。

最小 API 是 ASP.NET Core 6.0 中引入的新功能。它是一种不使用控制器创建 API 的新方法。最小 API 设计得简单轻量,依赖最少。它们是小型项目或原型以及不需要控制器完整功能的项目的良好选择。

要创建最小 API 项目,我们可以使用以下命令:

dotnet new webapi -n MinimalApiDemo -minimal

项目中没有 Controllers 文件夹。相反,你可以在 Program.cs 文件中找到以下代码:

app.MapGet("/weatherforecast", () =>{
    var forecast =  Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast
        (
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]
        ))
        .ToArray();
    return forecast;
})
.WithName("GetWeatherForecast")
.WithOpenApi();

前面的代码使用 MapGet() 方法将 GET 请求映射到 /weatherforecast 端点。MapGet() 方法是 IEndpointRouteBuilder 接口的一个扩展方法。该接口用于配置应用程序中的端点。其扩展方法 MapGet() 返回一个 IEndpointConventionBuilder 接口,允许我们使用流畅的 API 通过其他扩展方法(如 WithName()WithOpenApi())来配置端点。WithName() 方法用于设置端点的名称。WithOpenApi() 方法用于为端点生成 OpenAPI 文档。

创建一个简单的端点

让我们创建一个新的 /posts 端点,该端点支持 CRUD 操作。首先,将以下代码添加到 Program.cs 文件末尾,以定义一个 Post 类:

public class Post{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
}

将以下代码添加到 Program.cs 文件中:

var list = new List<Post>(){
    new() { Id = 1, Title = "First Post", Content = "Hello World" },
    new() { Id = 2, Title = "Second Post", Content = "Hello Again" },
    new() { Id = 3, Title = "Third Post", Content = "Goodbye World" },
};
app.MapGet("/posts",
    () => list).WithName("GetPosts").WithOpenApi().WithTags("Posts");
app.MapPost("/posts",
    (Post post) =>
    {
        list.Add(post);
        return Results.Created($"/posts/{post.Id}", post);
    }).WithName("CreatePost").WithOpenApi().WithTags("Posts");
app.MapGet("/posts/{id}", (int id) =>
{
    var post = list.FirstOrDefault(p => p.Id == id);
    return post == null ? Results.NotFound() : Results.Ok(post);
}).WithName("GetPost").WithOpenApi().WithTags("Posts");
app.MapPut("/posts/{id}", (int id, Post post) =>
{
    var index = list.FindIndex(p => p.Id == id);
    if (index == -1)
    {
        return Results.NotFound();
    }
    list[index] = post;
    return Results.Ok(post);
}).WithName("UpdatePost").WithOpenApi().WithTags("Posts");
app.MapDelete("/posts/{id}", (int id) =>
{
    var post = list.FirstOrDefault(p => p.Id == id);
    if (post == null)
    {
        return Results.NotFound();
    }
    list.Remove(post);
    return Results.Ok();
}).WithName("DeletePost").WithOpenApi().WithTags("Posts");

前面的代码定义了五个端点:

  • GET /posts: 获取所有帖子

  • POST /posts: 创建一个新的帖子

  • GET /posts/{id}: 通过 ID 获取帖子

  • PUT /posts/{id}: 通过 ID 更新帖子

  • DELETE /posts/{id}: 通过 ID 删除帖子

我们使用 WithTags 扩展方法将这些端点分组到一个名为 Posts 的标签中。在这个例子中,使用列表来存储帖子。在实际应用中,我们应该使用数据库来存储数据。

在最小 API 中使用依赖注入(DI)

最小 API 也支持依赖注入(DI)。您可以在 Services 文件夹中找到 IPostService 接口及其 PostService 实现。以下是在最小 API 中使用依赖注入的示例:

app.MapGet("/posts", async (IPostService postService) =>{
    var posts = await postService.GetPostsAsync();
    return posts;
}).WithName("GetPosts").WithOpenApi().WithTags("Posts");
app.MapGet("/posts/{id}", async (IPostService postService, int id) =>
{
    var post = await postService.GetPostAsync(id);
    return post == null ? Results.NotFound() : Results.Ok(post);
}).WithName("GetPost").WithOpenApi().WithTags("Posts");
app.MapPost("/posts", async (IPostService postService, Post post) =>
{
    var createdPost = await postService.CreatePostAsync(post);
    return Results.Created($"/posts/{createdPost.Id}", createdPost);
}).WithName("CreatePost").WithOpenApi().WithTags("Posts");
app.MapPut("/posts/{id}", async (IPostService postService, int id, Post post) =>
{
    try
    {
        var updatedPost = await postService.UpdatePostAsync(id, post);
        return Results.Ok(updatedPost);
    }
    catch (KeyNotFoundException)
    {
        return Results.NotFound();
    }
}).WithName("UpdatePost").WithOpenApi().WithTags("Posts");
app.MapDelete("/posts/{id}", async (IPostService postService, int id) =>
{
    try
    {
        await postService.DeletePostAsync(id);
        return Results.NoContent();
    }
    catch (KeyNotFoundException)
    {
        return Results.NotFound();
    }
}).WithName("DeletePost").WithOpenApi().WithTags("Posts");

在前面的代码中,IPostService 接口被用作动作方法的参数。DI 容器将正确的实现注入到动作方法中。您可以运行项目并测试端点。它应该与基于控制器的项目具有相同的行为。

最小 API 与基于控制器的 API 之间的区别是什么?

最小 API 比基于控制器的 API 更简单,允许我们直接将端点映射到方法。这使得最小 API 成为快速创建简单 API 或演示项目的良好选择。然而,最小 API 不支持控制器提供的全部功能,例如模型绑定、模型验证等。这些功能可能会在未来添加。因此,我们将主要使用基于控制器的 API,并在本书中不详细讨论最小 API。如果您想了解更多关于最小 API 的信息,请参阅官方文档learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis

摘要

在本章中,我们创建了一个简单的 Web API 项目,并介绍了如何在本地运行项目以及如何使用不同的客户端调用 API。我们使用内存列表实现了基本的 CRUD 操作。此外,我们还解释了如何在 ASP.NET Core 中使用依赖注入(DI)。我们探讨了服务生命周期并学习了一些技巧。另外,我们介绍了最小 API。在下一章中,我们将进一步探讨 ASP.NET Core 的内置组件。

第三章:ASP.NET Core 基础知识(第一部分)

在上一章中,我们学习了如何使用 ASP.NET Core 创建基本的 REST API。ASP.NET Core 提供了许多功能,使得构建 Web API 变得容易。

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

  • 路由

  • 配置

  • 环境

路由用于将传入的请求映射到相应的控制器操作。我们将讨论如何使用属性路由来配置 ASP.NET Core Web API 的路由。配置用于在应用程序启动时提供初始设置,例如数据库连接字符串、API 密钥和其他设置。配置通常与环境一起使用,例如开发、测试和发布。在本章结束时,你将具备创建 ASP.NET Core Web API 的 RESTful 路由以及利用 ASP.NET Core 配置框架管理不同环境配置的技能。

技术要求

本章中的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8找到。

你可以使用 Visual Studio 2022 或VS Code打开解决方案。

路由

在*第二章**中,我们介绍了如何使用默认的基于控制器的模板创建一个简单的 ASP.NET Core Web API 项目。该项目使用一些属性,如 [Route("api/controller")][HttpGet] 等,将传入的请求映射到相应的控制器操作。这些属性用于配置 ASP.NET Core Web API 项目的路由。

路由是一种监控传入请求并确定对这些请求应调用哪个操作方法的机制。ASP.NET Core 提供两种类型的路由:传统路由和属性路由。传统路由通常用于 ASP.NET Core MVC 应用程序,而 ASP.NET Core Web API 使用属性路由。在本节中,我们将更详细地讨论属性路由。

你可以从章节的 GitHub 仓库中的/samples/chapter3/RoutingDemo/下载RoutingDemo示例项目。

属性路由是什么?

打开RoutingDemo项目中的Program.cs文件。你会找到以下代码:

app.MapControllers();

这行代码将控制器操作的端点添加到IEndpointRouteBuilder实例中,而不指定任何路由。要指定路由,我们需要在控制器类和操作方法上使用[Route]属性。以下代码展示了如何在WeatherForecastController类上使用[Route]属性:

[ApiController][Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
  // Omitted for brevity
}

在前面的代码中,[controller]标记是一个用于控制器名称的占位符。在这种情况下,控制器名称是WeatherForecast,因此[controller]路由模板被替换为WeatherForecast。这意味着WeatherForecastController类的路由是/WeatherForecast

ASP.NET Core 有一些内置的路由标记,例如 [controller][action][area][page] 等。这些标记用方括号 [] 包围,并将被替换为相应的值。请注意,这些标记是保留的路由参数名称,不应像 {controller} 一样用作路由参数。

在 ASP.NET Core REST Web API 中,我们通常使用 [Route("api/[controller]")] 模板来表示 API 端点。您可以在 Controllers 文件夹中找到 PostsController 类。以下代码显示了 PostsController 类的路由属性:

[ApiController] [Route("api/[controller]")]
 public class PostsController : ControllerBase
 {
   // Omitted for brevity
 }

PostsController 类的路由是 /api/Posts。这表明端点是 REST API 端点。是否使用 /api 作为路由前缀由您决定。对此没有标准。

一些开发者更喜欢使用小写字母作为路由模板,例如 /api/posts。为了实现这一点,可以显式指定路由值;例如,[Route("api/posts")]。然而,为每个控制器类指定路由值似乎有些繁琐。幸运的是,ASP.NET Core 提供了一种全局配置路由值的方法。将以下代码添加到 Program.cs 文件中:

builder.Services.AddRouting(options => options.LowercaseUrls = true);

上一段代码将所有路由模板转换为小写。实际上,ASP.NET Core 路由中的文本匹配是不区分大小写的。因此,此更改仅影响生成的路径 URL,例如 Swagger UI 中的 URL 和 /api/Posts/api/posts 以访问相同的控制器路由。

可以将多个路由应用于一个控制器类。以下代码显示了如何将多个路由应用于 PostsController 类:

[ApiController] [Route("api/[controller]")]
 [Route("api/some-posts-whatever")]
 public class PostsController : ControllerBase
 {
   // Omitted for brevity
 }

在这种情况下,PostsController 类有两个路由:/api/posts/api/some-posts-whatever。不建议为同一控制器类使用多个路由,因为这可能会导致混淆。如果您需要为同一控制器类使用多个路由,请确保您有充分的理由这样做。

在 ASP.NET Core REST API 中,我们通常不使用 [action] 标记,因为操作名称不包括在路由模板中。同样,也不要为操作方法使用 [Route] 属性。相反,我们使用 HTTP 方法来区分操作方法。我们将在下一节中讨论这一点。

将 HTTP 方法映射到操作方法

REST API 以资源为中心。当我们设计 REST API 时,需要将 CRUD 操作映射到 HTTP 方法。在 ASP.NET Core 中,我们可以使用以下 HTTP 动词属性将 HTTP 方法映射到操作方法:

  • [HttpGet] 将 HTTP GET 方法映射到操作方法

  • [HttpPost] 将 HTTP POST 方法映射到操作方法

  • [HttpPut] 将 HTTP PUT 方法映射到操作方法

  • [HttpDelete] 将 HTTP DELETE 方法映射到操作方法

  • [HttpPatch] 将 HTTP PATCH 方法映射到操作方法

  • [HttpHead] 将 HTTP HEAD 方法映射到操作方法

以下代码展示了如何使用 [HttpGet] 属性将 HTTP GET 方法映射到 GetPosts() 动作方法:

[HttpGet] public async Task<ActionResult<List<Post>>> GetPosts()
 {
   // Omitted for brevity
 }

在 ASP.NET Core REST API 中,每个动作都必须有一个 HTTP 动词属性。如果您没有指定 HTTP 动词属性,框架无法确定应该调用哪个方法来处理传入的请求。在前面的代码中,对 /api/posts 端点的 GET 请求被映射到 GetPosts() 动作方法。

以下代码展示了如何使用 [HttpGet] 属性将 HTTP GET 方法映射到带有路由模板的 GetPost() 动作方法:

[HttpGet("{id}")] public async Task<ActionResult<Post>> GetPost(int id)
 {
   // Omitted for brevity
 }

前面的 HttpGet 属性有一个 {id} 路由模板,这是一个路由参数。路由参数被括号 {} 包围。路由参数用于捕获传入请求的值。例如,对 /api/posts/1 端点的 GET 请求被映射到 GetPost(int id) 动作方法,值 1 被由 {id} 路由参数捕获。

以下代码展示了如何使用 [HttpPut] 属性来发布一个帖子:

[HttpPut("{id}/publish")] public async Task<ActionResult> PublishPost(int id)
 {
   // Omitted for brevity
 }

前面的 HttpPut 属性有一个 {id}/publish 路由模板。publish 文本用于匹配传入请求中的 publish 文本。因此,对 /api/posts/1/publish 端点的 PUT 请求被映射到 PublishPost(int id) 动作方法,值 1 被由 {id} 路由参数捕获。

在定义路由模板时,请确保没有冲突。例如,我们想要添加一个新的动作方法来通过用户 ID 获取帖子。如果我们使用以下代码,它将不会工作:

[HttpGet("{userId}")] // api/posts/user/1 public async Task<ActionResult<List<Post>>> GetPostsByUserId(int userId)

这是因为我们已经有了一个使用 [HttpGet("{id}")]GetPost() 动作方法。当向 /api/posts/1 端点发送 GET 请求时,请求匹配多个动作,因此您将看到一个如下所示的 500 错误:

Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException: The request matched multiple endpoints. Matches:RoutingDemo.Controllers.PostsController.GetPost (RoutingDemo)
 RoutingDemo.Controllers.PostsController.GetPostsByUserId (RoutingDemo)

要修复它,我们需要指定一个不同的模板,例如 [HttpGet("user/{userId}")]

路由约束

在前面的章节中,我们介绍了如何使用路由参数来捕获传入请求的值。一个 [HttpGet("{id}")] 属性可以匹配对 /api/posts/1 端点的 GET 请求。但如果请求是对 /api/posts/abc 端点的 GET 请求呢?

由于 id 参数的类型是 int,框架将尝试将捕获的值转换为 int 值。如果转换失败,框架将返回一个 400 Bad Request 响应。因此,对 /api/posts/abc 端点的 GET 请求将失败并返回一个 400 Bad Request 响应。

我们可以向路由参数添加路由约束来限制路由参数的值。例如,我们可以向 id 参数添加一个路由约束以确保 id 参数是一个整数。以下代码展示了如何向 id 参数添加路由约束:

[HttpGet("{id:int}")] public async Task<ActionResult<Post>> GetPost(int id)
 {
   // Omitted for brevity
 }

现在,id 参数必须是一个整数。对 /api/posts/abc 端点的 GET 请求将返回一个 404 Not Found 响应,因为路由不匹配。

ASP.NET Core 提供了一套内置的路由约束,例如以下内容:

  • int:参数必须是一个整数值。

  • bool:参数必须是一个布尔值。

  • datetime:参数必须是一个 DateTime 值。

  • decimal:参数必须是一个decimal值。同样,还有doublefloatlong等。

  • guid:参数必须是一个 GUID 值。

  • minlength(value):参数必须是一个具有最小长度的字符串;例如,{name:minlength(6)},这意味着name参数必须是一个字符串,且字符串的长度必须至少为 6 个字符。同样,还有maxlength(value)length(value)length(min, max)等。

  • min(value):参数必须是一个具有最小值的整数;例如,{id:min(1)},这意味着id参数必须是一个整数,且其值必须大于或等于 1。同样,还有max(value)range(min, max)等。

  • alpha:参数必须是一个包含一个或多个字母的字符串。

  • regex(expression):参数必须是一个与正则表达式匹配的字符串。

  • required:参数必须在路由中提供;例如,{id:required},这意味着id参数必须在路由中提供。

如果路由参数的值不符合路由约束,则操作方法将不接受请求,并返回一个404 Not Found响应。

可以同时应用多个路由约束。以下代码展示了如何将多个路由约束应用于id参数,这意味着id参数必须是一个整数,且其值必须大于或等于 1 且小于或等于 100:

[HttpGet("{id:int:range(1, 100)}")] public async Task<ActionResult<Post>> GetPost(int id)
 {
   // Omitted for brevity
 }

路由约束可以用来使路由更加具体。然而,它们不应该用来验证输入。如果输入无效,API 应该返回一个400 Bad Request响应,而不是404 Not Found响应。

绑定源属性

我们可以在操作中定义参数。请参见以下操作方法:

[HttpGet("{id}")] public async Task<ActionResult<Post>> GetPost(int id)

GetPost()方法有一个名为id的参数,它与{id}路由模板中的参数相匹配。因此,id的值将来自路由,例如在/api/posts/1 URL 中的 1。这被称为参数推断。

ASP.NET Core 提供了以下绑定源属性:

  • [FromBody]:参数来自请求体

  • [FromForm]:参数来自请求体中的表单数据

  • [FromHeader]:参数来自请求头

  • [FromQuery]:参数来自请求中的查询字符串

  • [FromRoute]:参数来自路由路径

  • [FromServices]:参数来自DI容器

例如,我们可以定义一个分页操作方法如下:

[HttpGet("paged")] public async Task<ActionResult<List<Post>>> GetPosts([FromQuery] int pageIndex, [FromQuery] int pageSize)
 {
     // Omitted for brevity
 }

上述代码意味着pageIndex参数和pageSize参数应来自 URL 中的查询字符串,例如/api/posts/paged?pageIndex=1&pageSize=10

[ApiController] 属性应用于控制器类时,将应用一组默认推断规则,因此我们不需要显式添加这些绑定源属性。例如,以下代码显示了一个 POST 动作方法:

[HttpPost] public async Task<ActionResult<Post>> CreatePost(Post post)
 {
     // Omitted for brevity
 }
The post parameter is a complex type, so [FromBody] inferred that the post should be from the request body. But [FromBody] is not inferred for simple data types, such as int, string, and so on. We will define an action method as follows:
[HttpPost("search")]
 public async Task<ActionResult<Post>> SearchPosts(string keyword)

keyword 参数是简单类型,所以 [FromQuery] 推断 keyword 参数应该来自 URL 中的查询字符串,例如 /api/posts/search?keyword=xyz。如果我们想强制 keyword 参数来自请求体,我们可以使用以下 [FromBody] 属性:

[HttpPost("search")] public async Task<ActionResult<Post>> SearchPosts([FromBody] string keyword)

然后,keyword 参数必须来自请求体。请注意,这是一个不好的例子,因为我们通常不会使用请求体来传递简单类型参数。

这些绑定源属性的默认推断规则如下:

  • 对于复杂类型参数,如果类型已在 DI 容器中注册,则 [FromServices] 是推断的。

  • 对于未在 DI 容器中注册的复杂类型参数, [FromBody] 是推断的。它不支持多个 [FromBody] 参数。

  • 对于 IFormFileIFormFileCollection 等类型, [FromForm] 是推断的。

  • 对于出现在路由中的任何参数, [FromRoute] 是推断的。

  • 对于任何简单类型的参数,例如 intstring 等, [FromQuery] 是推断的。

如果可以根据这些规则推断参数,则可以省略绑定源属性。否则,我们需要显式指定绑定源属性。

路由是 REST API 中一个非常重要的概念。确保路由设计良好、直观且易于理解。这将有助于您的 REST API 的消费者轻松使用它们。

接下来,我们将检查 ASP.NET Core 中的配置。

配置

ASP.NET Core 提供了一个全面的配置框架,使得与配置设置一起工作变得容易。配置被视为键值对。这些配置设置存储在多种来源中,例如 JSON 文件、环境变量、命令行参数,或者在云中,例如 Azure Key Vault。在 ASP.NET Core 中,这些来源被称为 配置提供者。每个配置提供者负责从特定来源加载配置设置。

ASP.NET Core 支持一组配置提供者,例如以下:

  • 文件配置提供者,例如,appsettings.json

  • 用户密钥

  • 环境变量配置提供者

  • 命令行配置提供者

  • Azure 应用配置提供者

  • Azure 密钥保管库配置提供者

ASP.NET Core 的配置由 Microsoft.Extension.Configuration NuGet 包提供。您不需要显式安装此包,因为它已经包含在默认的 ASP.NET Core 模板中,该模板提供了几个内置配置提供程序,例如 appsettings.json。这些配置提供程序按优先级顺序配置。我们将在 理解配置和环境的优先级 部分中详细讨论这一点。首先,让我们看看如何使用 appsettings.json

运行以下命令以创建一个新的 ASP.NET Core Web API 项目:

dotnet new webapi -n ConfigurationDemo -controllers

您可以从章节的 GitHub 仓库中的 /samples/chapter3/ConfigurationDemo 文件夹下载名为 ConfigurationDemo 的示例项目。

使用 appsettings.json

默认情况下,ASP.NET Core 应用程序配置为使用 JsonConfigurationProviderappsettings.json 读取配置设置。appsettings.json 文件位于项目的根目录中,它是一个包含键值对的 JSON 文件。以下代码显示了 appsettings.json 文件的默认内容:

{  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}

您将找到另一个 appsettings.Development.json 文件,它将用于开发环境。我们将在下一节介绍环境。

让我们在 appsettings.json 文件中添加一个 "MyKey": "MyValue" 键值对。这个键值对是我们将在代码中使用 JsonConfigurationProvider 读取的示例配置:

{  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "MyKey": "MyValue"
}

Controllers 文件夹中创建一个名为 ConfigurationController 的新控制器。在这个控制器中,我们将从 appsettings.json 文件中读取配置值并将其作为字符串返回。以下代码显示了 ConfigurationController 类:

using Microsoft.AspNetCore.Mvc;namespace ConfigurationDemo.Controllers;
[ApiController]
[Route("[controller]")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase
{
    [HttpGet]
    [Route("my-key")]
    public ActionResult GetMyKey()
    {
        var myKey = configuration["MyKey"];
        return Ok(myKey);
    }
}

要访问配置设置,我们需要将 IConfiguration 接口注入到控制器的构造函数中。IConfiguration 接口表示一组键值应用配置属性。以下代码显示了如何访问配置设置:

var myKey = configuration["MyKey"];

运行应用程序并向 /Configuration/my-key 端点发送请求。您可以使用任何 HTTP 客户端,例如 Postman、VS Code 中的 Thunder Client 或 HttpRepl。以下代码显示了如何使用 HttpRepl:

httprepl http://localhost:5116cd Configuration
get my-key

您将看到以下响应:

HTTP/1.1 200 OKContent-Type: text/plain; charset=utf-8
Date: Fri, 23 Sep 2022 11:22:40 GMT
Server: Kestrel
Transfer-Encoding: chunked
MyValue

配置支持分层设置。例如,考虑以下配置设置:

{  "Database": {
    "Type": "SQL Server",
    "ConnectionString": "This is the database connection string"
  }
}

要访问 TypeConnectionString 属性,我们可以使用以下代码:

[HttpGet][Route("database-configuration")]
public ActionResult GetDatabaseConfiguration()
{
    var type = configuration["Database:Type"];
    var connectionString = configuration["Database:ConnectionString"];
    return Ok(new { Type = type, ConnectionString = connectionString });
}

注意,我们使用冒号 (:) 来分隔分层设置。

运行应用程序并向 /Configuration/database-configuration 端点发送请求。如果您使用 HttpRepl,可以使用以下命令:

httprepl http://localhost:5116cd Configuration
get database-configuration

以下代码显示了 HttpRepl 的响应:

HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8
Date: Fri, 23 Sep 2022 11:35:55 GMT
Server: Kestrel
Transfer-Encoding: chunked
{
  "type": "SQL Server",
  "connectionString": "This is the database connection string"
}

使用 IConfiguration 接口,我们可以使用 configuration[key] 格式访问配置设置。然而,硬编码键不是一种好的做法。为了避免硬编码,ASP.NET Core 支持选项模式,它可以提供一种强类型的方式来访问分层设置。

使用选项模式

要使用选项模式,我们需要创建一个表示配置设置的类。以下代码显示了如何创建一个名为 DatabaseOption 的类:

namespace ConfigurationDemo;public class DatabaseOption
{
    public const string SectionName = "Database";
    public string Type { get; set; } = string.Empty;
    public string ConnectionString { get; set; } = string.Empty;
}

SectionName 字段用于指定 appsettings.json 文件中的部分名称。此字段不是必需的。但如果我们没有在这里定义它,当我们绑定配置部分时,我们需要传递一个硬编码的字符串作为部分名称。为了更好地利用强类型,我们可以定义一个 SectionName 字段。TypeConnectionString 属性用于表示 appsettings.json 文件中的 TypeConnectionString 字段。

注意,选项类必须是非抽象的,并且具有公共的无参构造函数。

有多种方式可以使用选项模式。让我们继续。

使用 ConfigurationBinder.Bind() 方法

首先,让我们使用 ConfigurationBinder.Bind() 方法,该方法尝试通过递归匹配属性名称与配置键来将给定的对象实例绑定到配置值。

ConfigurationController 类中添加以下代码:

[HttpGet][Route("database-configuration-with-bind")]
public ActionResult GetDatabaseConfigurationWithBind()
{
    var databaseOption = new DatabaseOption();
    // The `SectionName` is defined in the `DatabaseOption` class, which shows the section name in the `appsettings.json` file.
    configuration.GetSection(DatabaseOption.SectionName).Bind(databaseOption);
    // You can also use the code below to achieve the same result
    // configuration.Bind(DatabaseOption.SectionName, databaseOption);
    return Ok(new { databaseOption.Type, databaseOption.ConnectionString });
}

运行应用程序并向 /Configuration/database-configuration-with-bind 端点发送请求。您将看到与上一节中相同的响应,使用 appsettings.json。这样,我们可以使用强类型选项类来访问配置设置,例如 databaseOption.Type

使用 ConfigurationBinder.Get() 方法

我们还可以使用 ConfigurationBinder.Get<TOption>() 方法,该方法尝试将配置实例绑定到类型 T 的新实例。如果此配置部分有值,则使用该值;否则,它尝试通过递归匹配属性名称与配置键来绑定配置实例。以下代码显示了如何实现:

[HttpGet][Route("database-configuration-with-generic-type")]
public ActionResult GetDatabaseConfigurationWithGenericType()
{
    var databaseOption = configuration.GetSection(DatabaseOption.SectionName).Get<DatabaseOption>();
    return Ok(new { databaseOption.Type, databaseOption.ConnectionString });
}

运行应用程序并向 /Configuration/database-configuration-with-generic-type 端点发送请求。您将看到与 使用 appsettings.json 部分相同的响应。

使用 IOptions 接口

ASP.NET Core 为选项模式提供了内置的依赖注入支持。要使用依赖注入,我们需要在 Program.cs 文件的 Services.Configure() 方法中注册 DatabaseOption 类。以下代码显示了如何注册 DatabaseOption 类:

// Register the DatabaseOption class as a configuration object.// This line must be added before the `builder.Build()` method.
builder.Services.Configure<DatabaseOption>(builder.Configuration.GetSection(DatabaseOption.SectionName));
var app = builder.Build();

接下来,我们可以使用依赖注入将 IOptions<DatabaseOption> 接口注入到 ConfigurationController 类中。以下代码显示了如何注入 IOptions<DatabaseOption> 接口:

[HttpGet][Route("database-configuration-with-ioptions")]
public ActionResult GetDatabaseConfigurationWithIOptions([FromServices] IOptions<DatabaseOption> options)
{
    var databaseOption = options.Value;
    return Ok(new { databaseOption.Type, databaseOption.ConnectionString });
}

运行应用程序并向 /Configuration/database-configuration-with-ioptions 端点发送请求。你会看到与 使用 appsettings.json 部分相同的响应。

使用其他选项接口

我们介绍了几种使用选项模式的方法。它们有什么区别?

运行应用程序并测试前面的端点。你会看到所有响应都是相同的,其中包含一个值为 SQL ServerType 属性。

保持应用程序运行。让我们更改 appsettings.json 文件。将 Type 属性从 SQL Server 更改为 MySQL。保存文件并再次向这些端点发送请求。你会发现以下结果:

  • database-configuration 返回新的值 MySQL

  • database-configuration-with-bind 返回新的值 MySQL

  • database-configuration-with-generic-type 返回新的值 MySQL

  • database-configuration-with-ioptions 返回旧的值 SQL Server

让我们尝试使用 IOptionsSnapshot<T> 接口替换 IOptions<TOption> 接口。IOptionsSnapshot<TOption> 接口提供了当前请求的选项快照。以下代码显示了如何使用 IOptionsSnapshot<TOption> 接口:

[HttpGet][Route("database-configuration-with-ioptions-snapshot")]
public ActionResult GetDatabaseConfigurationWithIOptionsSnapshot([FromServices] IOptionsSnapshot<DatabaseOption> options)
{
    var databaseOption = options.Value;
    return Ok(new { databaseOption.Type, databaseOption.ConnectionString });
}

再次运行应用程序。更改 appsettings.json 文件中的 Type 属性。向 /Configuration/database-configuration-with-ioptions-snapshot 端点发送请求。你会发现响应是新的值。

好的,我们现在知道了 IOptions<TOption> 接口和 IOptionsSnapshot<TOption> 接口之间的区别:

  • IOptions<TOption> 接口提供了一种访问选项的方式,但如果在应用程序运行时设置值已更改,则它无法获取最新值。

  • IOptionsSnapshot<TOption> 接口提供了当前请求的选项快照。当我们需要获取当前请求的最新选项时,IOptionsSnapshot<TOption> 接口非常有用。

但为什么呢?

ASP.NET Core 框架使用 JsonConfigurationProvider 内置支持 appsetting.json,它从 appsettings.json 文件中读取配置值。当框架注册 JsonConfigurationProvider 时,代码看起来像这样:

config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true)  .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);

reloadOnChange 参数设置为 true,这意味着如果 appsettings.json 文件已更改,则配置值将被重新加载。因此,ConfigurationBinder.Bind() 方法和 ConfigurationBinder.Get<TOption>() 方法可以获取最新值。

然而,当 ASP.NET Core 框架注册 IOptions<TOption> 接口时,它被注册为一个 单例 服务,这意味着 IOption<TOption> 的实例只会创建一次。你可以将其注入到任何服务生命周期中,但如果设置值已更改,则它无法读取最新值。

相比之下,IOptionsSnapshot<TOption>接口注册为作用域服务,因此不能注入到单例服务中。如果您想为每个请求获取最新的选项,它很有用。

看起来IOptionsSnapshot<TOption>IOptions<TOption>更好。实际上并不是。IOptionsSnapshot<TOption>只能缓存当前请求的选项。因为它会根据请求重新计算,所以可能会引起性能问题。因此,您需要明智地选择要使用的接口。如果选项没有变化,您可以使用IOptions<TOption>接口。如果选项经常变化,并且您想确保应用程序在每个请求中都能获取到最新的值,您可以使用IOptionsSnapshot<TOption>接口。

另一个选项接口叫做IOptionsMonitor<TOption>。它是IOptions<TOption>IOptionsSnapshot<TOption>接口的组合。它提供了以下功能:

  • 它是一个单例服务,可以被注入到任何服务生命周期

  • 它支持可重载的配置

下面是使用IOptionsMonitor<TOption>接口的一个示例:

[HttpGet][Route("database-configuration-with-ioptions-monitor")]
public ActionResult GetDatabaseConfigurationWithIOptionsMonitor([FromServices] IOptionsMonitor<DatabaseOption> options)
{
    var databaseOption = options.CurrentValue;
    return Ok(new { databaseOption.Type, databaseOption.ConnectionString });
}

IOptionsMonitor<TOption>接口提供了CurrentValue属性来获取最新值。它还提供了OnChange(Action<TOption, string> listener)方法来注册一个监听器,该监听器将在选项重新加载时被调用。通常,除非您想在选项重新加载时执行某些操作,否则您不需要使用OnChange()方法。

使用命名选项

有时,我们需要在应用程序中使用多个数据库实例。考虑以下场景:

{  "Databases": {
    "System": {
      "Type": "SQL Server",
      "ConnectionString": "This is the database connection string for the system database."
    },
    "Business": {
      "Type": "MySQL",
      "ConnectionString": "This is the database connection string for the business database."
    }
  }
}

而不是创建两个类来表示两个数据库选项,我们可以使用命名选项功能。以下代码展示了如何为每个部分使用命名选项功能:

public class DatabaseOptions{
    public const string SystemDatabaseSectionName = "System";
    public const string BusinessDatabaseSectionName = "Business";
    public string Type { get; set; } = string.Empty;
    public string ConnectionString { get; set; } = string.Empty;
}

然后,在Program.cs文件中注册命名选项功能:

builder.Services.Configure<DatabaseOptions>(DatabaseOptions.SystemDatabaseSectionName, builder.Configuration.GetSection($"{DatabaseOptions.SectionName}:{DatabaseOptions.SystemDatabaseSectionName}"));builder.Services.Configure<DatabaseOptions>(DatabaseOptions.BusinessDatabaseSectionName, builder.Configuration.GetSection($"{DatabaseOptions.SectionName}:{DatabaseOptions.BusinessDatabaseSectionName}"));

以下代码展示了如何访问命名选项:

[HttpGet][Route("database-configuration-with-named-options")]
public ActionResult GetDatabaseConfigurationWithNamedOptions([FromServices] IOptionsSnapshot<DatabaseOptions> options)
{
    var systemDatabaseOption = options.Get(DatabaseOptions.SystemDatabaseSectionName);
    var businessDatabaseOption = options.Get(DatabaseOptions.BusinessDatabaseSectionName);
    return Ok(new { SystemDatabaseOption = systemDatabaseOption, BusinessDatabaseOption = businessDatabaseOption });
}

运行应用程序并向/Configuration/database-configuration-with-named-options端点发送请求。您会发现响应包含两个数据库选项,如下所示:

{  "systemDatabaseOption": {
    "type": "SQL Server",
    "connectionString": "This is the database connection string for the system database."
  },
  "businessDatabaseOption": {
    "type": "MySQL",
    "connectionString": "This is the database connection string for the business database."
  }
}

现在,让我们总结一下 ASP.NET Core 中的选项功能:

服务器生命周期 可重载 配置 命名选项
IOptions<TOption> 单例
IOptionsSnapshot<TOption> 作用域
IOptionsMonitor<TOption> 单例

表 3.1 – ASP.NET Core 中选项功能的总结

接下来,我们将讨论如何注册一组选项以使Program.cs文件更简洁。

分组选项注册

第二章中,我们介绍了如何在扩展方法中使用分组注册来注册多个服务。分组注册功能也适用于选项功能。以下代码展示了如何使用分组注册功能来注册多个选项:

using ConfigurationDemo;namespace DependencyInjectionDemo;
public static class OptionsCollectionExtensions
{
    public static IServiceCollection AddConfig(this IServiceCollection services, IConfiguration configuration)
    {
        services.Configure<DatabaseOption>(configuration.GetSection(DatabaseOption.SectionName));
        services.Configure<DatabaseOptions>(DatabaseOptions.SystemDatabaseSectionName, configuration.GetSection($"{DatabaseOptions.SectionName}:{DatabaseOptions.SystemDatabaseSectionName}"));
        services.Configure<DatabaseOptions>(DatabaseOptions.BusinessDatabaseSectionName, configuration.GetSection($"{DatabaseOptions.SectionName}:{DatabaseOptions.BusinessDatabaseSectionName}"));
        return services;
    }
}

然后,在 Program.cs 文件中注册选项:

builder.Services.AddConfig(builder.Configuration);

现在,Program.cs 文件变得更加简洁。

其他配置提供者

我们提到 ASP.NET Core 支持多个配置提供者。用于读取 appsettings.json 文件的配置提供者是 JsonConfigurationProvider,它继承自 FileConfigurationProvider 基类。还有一些其他 FileConfigurationProvider 基类的实现,例如 IniConfigurationProviderXmlConfigurationProvider 等。

除了 JsonConfigurationProvider,ASP.NET Core 框架还会自动注册以下配置提供者:

  • 一个 Development 环境

  • 非前缀环境变量配置提供者用于读取没有前缀的环境变量

  • 命令行配置提供者用于读取命令行参数

让我们更详细地了解这些配置提供者。

用户密钥配置提供者

将敏感信息存储在 appsettings.json 文件中不是一种好的做法。例如,如果数据库连接字符串存储在 appsettings.json 文件中,开发者可能会意外地将数据库连接字符串(或其他敏感信息、密钥等)提交到源代码控制系统,这将会引起安全问题。

相反,我们可以使用用户密钥功能将敏感信息存储在本地密钥文件中。用户密钥功能仅在 Development 环境中可用。默认情况下,ASP.NET Core 框架在 JSON 配置提供者之后注册用户密钥配置提供者。因此,用户密钥配置提供者的优先级高于 JSON 配置提供者,如果两个提供者中存在相同的配置键,它将覆盖 JSON 配置提供者。

要使用用户密钥,我们需要使用 Secret Manager 工具将密钥存储在本地密钥文件中。在项目文件夹中运行以下命令以初始化本地密钥文件:

dotnet user-secrets init

上述命令在 .csproj 文件中创建了一个 UserSecretsId 属性。默认情况下,UserSecretsId 属性的值是一个 GUID,例如以下内容:

<PropertyGroup>  <TargetFramework>net8.0</TargetFramework>
  <Nullable>enable</Nullable>
  <ImplicitUsings>enable</ImplicitUsings>
  <UserSecretsId>f3351c6a-2508-4243-8d80-89c27758164d</UserSecretsId>
</PropertyGroup>

然后,我们可以使用 Secret Manager 工具将密钥存储在本地密钥文件中。从项目文件夹中运行以下命令以存储密钥:

dotnet user-secrets set "Database:Type" "PostgreSQL"dotnet user-secrets set "Database:ConnectionString" "This is the database connection string from user secrets"

执行上述命令后,在 %APPDATA%\Microsoft\UserSecrets\<UserSecretsId> 文件夹中创建了一个 secrets.json 文件。该 secrets.json 文件包含以下内容:

{  "Database:Type": "PostgreSQL",
  "Database:ConnectionString": "This is the database connection string from user secrets"
}

注意 JSON 结构已被扁平化。

secrets.json 文件的位置

如果你使用 Linux 或 macOS,secrets.json 文件将创建在 ~/.microsoft/usersecrets/<UserSecretsId> 文件夹中。

运行 dotnet run 来运行应用程序并向 /Configuration/database-configuration 端点发送请求。你会发现响应包含来自用户密钥的数据库选项,它覆盖了 appsettings.json 文件中的数据库选项,并包含 PostgreSQL 数据库类型。

本地密钥文件位于项目文件夹之外,并且未提交到源代码控制系统。请记住,Secret Manager 工具仅用于开发目的。开发者应负责保护本地密钥文件。

有一些命令用于操作本地密钥文件。你需要从项目文件夹中运行以下命令:

# List all the secretsdotnet user-secrets list
# Remove a secret
dotnet user-secrets remove "Database:Type"
# Clear all the secrets
dotnet user-secrets clear

重要提示

如果你下载本节的代码示例,密钥文件不会包含在存储库中。你需要运行 dotnet user-secrets init 命令来在你的本地机器上初始化密钥文件。

环境变量配置提供程序

.NET 和 ASP.NET Core 定义了一些可以用于配置应用程序的环境变量。这些特定的变量具有 DOTNET_DOTNETCORE_ASPNETCORE_ 前缀。具有 DOTNET_DOTNETCORE_ 前缀的变量用于配置 .NET 运行时。具有 ASPNETCORE_ 前缀的变量用于配置 ASP.NET Core。例如,ASPNETCORE_ENVIRONMENT 环境变量用于设置环境名称。我们将在 环境 部分讨论环境。

对于没有 ASPNETCORE_ 前缀的环境变量,ASP.NET Core 也可以使用环境变量配置提供程序来读取它们。环境变量的优先级高于 appsettings.json 文件。例如,我们在 appsettings.json 文件中有以下配置:

{  "Database": {
    "Type": "SQL Server",
    "ConnectionString": "This is the database connection string."
  }
}

如果我们将 Database__Type 环境变量设置为 MySQLappsettings.json 文件中的 Database__Type 值将被环境变量值覆盖。以下代码展示了如何在 PowerShell 中访问环境变量:

$Env:<variable-name>

为了表示环境变量的分层键,建议使用 __(双下划线)作为分隔符,因为它被所有平台支持。请勿使用 :,因为它不被 Bash 支持。

你可以使用以下命令在 PowerShell 中设置环境变量:

$Env:Database__Type="SQLite"

要检查环境变量是否设置正确,请运行以下命令:

$Env:Database__Type

重要提示

如果你使用 Bash,你需要使用以下命令来设置环境变量:

export Database__Type="SQLite"

更多信息,请参阅 linuxize.com/post/how-to-set-and-list-environment-variables-in-linux/

此外,请注意,与 Windows 不同,在 macOS 和 Linux 上环境变量名称是区分大小写的。

您将看到输出是 SQLite。现在,在同一个 PowerShell 会话中,您可以使用 dotnet run 来运行应用程序并向 /Configuration/database-configuration 端点发送请求。您会发现响应包含 SQLite 值,即使 appsettings.json 文件包含 SQL Server 值。这意味着环境变量值覆盖了 appsettings.json 文件值。

命令行配置提供程序

命令行参数的优先级高于环境变量。默认情况下,命令行上设置的配置设置会覆盖其他配置提供程序设置的值。

按照上一节的示例,Database__Type 值在 appsettings.json 文件中设置为 SQL Server。我们还设置了 Database__Type 环境变量为 SQLite。现在,让我们使用以下命令将命令行参数中的值更改为 MySQL

dotnet run Database:Type=MySQL

/Configuration/database-configuration 端点发送请求。您会发现响应包含 MySQL 值,这意味着命令行参数值覆盖了环境变量值。

命令行参数还可以按以下方式设置:

dotnet run --Database:Type MySQLdotnet run /Database:Type MySQL

如果使用 --/ 作为键,则值可以跟在空格后面。否则,值必须跟在 = 符号后面。请勿在同一个命令中混合两种方式。

Azure Key Vault 配置提供程序

Azure Key Vault 是一种基于云的服务,它为密码、证书和密钥等机密提供安全存储。Azure Key Vault 配置提供程序用于从 Azure Key Vault 读取机密。它是运行生产应用程序的好选择。

要使用它,您需要安装以下 NuGet 包:

  • Azure.Extensions.AspNetCore.Configuration.Secrets

  • Azure.Identity

Azure App Configuration 是一种基于云的服务,它为管理应用程序设置和功能标志提供了一个集中的配置存储库。App Configuration 补充了 Azure Key Vault。它的目标是简化处理复杂应用程序设置的任务。您需要安装以下 NuGet 包来使用 Azure App Configuration 提供程序:

  • Microsoft.Azure.AppConfiguration.AspNetCore

我们不会在本书中涵盖 Azure Key Vault 和 Azure App Configuration 提供程序的详细信息。有关更多信息,请参阅以下链接:

环境变量

在上一节中,我们介绍了如何从各种资源中读取配置设置,包括 appsettings.json 文件、用户机密、环境变量和命令行参数。在本节中,我们将更详细地讨论环境变量。

运行以下命令以创建一个新的 ASP.NET Core Web API 项目:

dotnet new webapi -n EnvironmentDemo -controllers

你可以从章节的 GitHub 仓库中/samples/chapter3/EnvironmentsDemo文件夹下载名为EnvironmentDemo的示例项目。

我们已经提到,默认的 ASP.NET Core Web API 模板包含一个appsettings.json文件和一个appsettings.Development.json文件。当我们使用dotnet run运行应用程序时,应用程序在Development环境中运行。因此,appsettings.Development.json文件中的配置设置会覆盖appsettings.json文件中的配置设置。

将以下部分添加到appsettings.Development.json文件中:

{  // Omitted for brevity
  "Database": {
    "Type": "SQL Server",
    "ConnectionString": "This is the database connection string from base appsettings.json."
  }
}

然后,将以下部分添加到appsettings.Development.json文件中:

{  // Omitted for brevity
  "Database": {
    "Type": "LocalDB",
    "ConnectionString": "This is the database connection string from appsettings.Development.json"
  }
}

重要提示

注意,用户密钥高于appsettings.Development.json文件。因此,如果你在上一节中配置了本地用户密钥,请清除密钥。

创建一个名为ConfigurationController.cs的新控制器,并添加以下代码:

using Microsoft.AspNetCore.Mvc;namespace EnvironmentsDemo.Controllers;
[ApiController]
[Route("[controller]")]
public class ConfigurationController(IConfiguration configuration) : ControllerBase
{
    private readonly IConfiguration _configuration;
    public ConfigurationController(IConfiguration configuration)
    {
        _configuration = configuration;
    }
    [HttpGet]
    [Route("database-configuration")]
    public ActionResult GetDatabaseConfiguration()
    {
        var type = configuration["database:Type"];
        var connectionString = configuration["Database:ConnectionString"];
        return Ok(new { Type = type, ConnectionString = connectionString });
    }
}

使用dotnet run运行应用程序。向/Configuration/database-configuration端点发送请求。你会发现响应包含一个LocalDB值,这意味着appsettings.Development.json文件覆盖了appsettings.json文件。

那么,环境名称Development在哪里设置?

理解launchSettings.json文件

Properties文件夹中打开launchSettings.json文件。你会找到ASPNETCORE_ENVIRONMENT环境变量设置为Development

  "profiles": {    "http": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "http://localhost:5161",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:7096;http://localhost:5161",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }

launchSettings.json文件用于配置本地开发环境。它不可部署。默认的launchSettings.json文件包含三个配置文件:httphttpsIIS Express

  • http配置文件用于使用 HTTP 协议运行应用程序

  • https配置文件用于使用 HTTPS 协议运行应用程序

  • IIS Express配置文件用于在 IIS Express 中运行应用程序

httphttps配置文件中的commandName字段是Project,这意味着 Kestrel 服务器被启动以运行应用程序。同样,IISExpressIIS Express配置文件中的值意味着应用程序期望 IIS Express 作为 Web 服务器。

什么是 Kestrel 服务器?

Kestrel 是一个跨平台的 ASP.NET Core Web 服务器。Kestrel 默认包含并启用在 ASP.NET Core 项目模板中。ASP.NET Core 也可以在 IIS(或 IIS Express)中托管,但 IIS 不是跨平台的。因此,Kestrel 是 ASP.NET Core 应用程序的首选 Web 服务器。

当运行dotnet run时,使用具有commandNameProject的第一个配置文件。对于演示项目,使用http配置文件。在http配置文件中,将ASPNETCORE_ENVIRONMENT环境变量设置为Development。因此,应用程序在Development环境中运行。你可以在控制台中看到输出:

Building...info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5161
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\dev\web-api-with-asp-net\example_code\chapter3\EnvironmentsDemo

我们可以使用 --launch-profile 参数指定运行应用程序时要使用的配置文件:

dotnet run --launch-profile https

注意,此方法仅适用于 Kestrel 配置文件。你不能使用此参数在 IIS Express 中运行应用程序。

如果你使用 VS 2022 打开项目,你可以选择要使用的配置文件,如下所示:

图 3.1 – 在 Visual Studio 2022 中选择要使用的配置文件

图 3.1 – 在 Visual Studio 2022 中选择要使用的配置文件

接下来,让我们探索如何配置应用程序以在 Production 环境中运行。

设置环境

有几种方法可以更改环境。让我们创建一个名为 appsettings.Production.json 的新文件。此配置文件将用于 Production 环境。将以下部分添加到文件中:

{  // Omitted for brevity
  "Database": {
    "Type": "PostgreSQL",
    "ConnectionString": "This is the database connection string from appsettings.Production.json"
  }
}

接下来,我们将指定环境为 Production 以应用此配置。

使用 launchSettings.json 文件

对于开发目的,我们可以在 launchSettings.json 文件中创建一个新的配置文件,该文件指定 ASPNETCORE_ENVIRONMENT 变量为 Production。将以下部分添加到 launchSettings.json 文件中:

// Omitted for brevity  "profiles": {
    // Omitted for brevity
    "production": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:7096;http://localhost:5161",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Production"
      }
    }
    // Omitted for brevity
  }

使用以下命令在 Production 环境中运行应用程序:

dotnet run --launch-profile production

你将在控制台中看到应用程序在 Production 环境中运行:

PS C:\dev\web-api-with-asp-net\example_code\chapter3\EnvironmentsDemo> dotnet run --launch-profile productionBuilding...
warn: Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServer[8]
      The ASP.NET Core developer certificate is not trusted. For information about trusting the ASP.NET Core developer certificate, see https://aka.ms/aspnet/https-trust-dev-cert.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7096
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5161
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: C:\dev\web-api-with-asp-net\example_code\chapter3\EnvironmentsDemo

看起来不错。让我们尝试访问 /Configuration/database-configuration 端点。你会看到响应来自 appsettings.Production.json 文件。

使用 ASPNETCORE_ENVIRONMENT 环境变量

你也可以在当前会话中将 ASPNETCORE_ENVIRONMENT 环境变量设置为 Production,如下所示:

$Env:ASPNETCORE_ENVIRONMENT = "Production"dotnet run --no-launch-profile

此外,你可以在系统中全局设置环境变量。使用以下命令全局设置环境变量:

[Environment]::SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Production", "Machine")

Machine 参数设置全局环境变量。你也可以使用 User 参数为当前用户设置环境变量。

使用 --environment 参数

另一种方法是使用 --environment 参数设置环境:

dotnet run --environment Production

使用 VS Code 中的 launch.json 文件

如果你使用 VS Code 打开项目,你可以在 .vscode 文件夹中的 launch.json 文件中设置环境。当你打开一个 ASP.NET Core 项目时,VS Code 将提示你添加调试项目所需资源。将在 .vscode 文件夹中添加一个 launch.json 文件和一个 tasks.json 文件。如果你没有看到提示,你可以打开命令面板并运行 .NET: Generate Assets for Build and Debug 命令。

打开 launch.json 文件,你将看到以下内容:

{  // Omitted for brevity
  "configurations": [
    {
      "name": ".NET Core Launch (web)",
      "type": "coreclr",
      // Omitted for brevity
      "env": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      },
      // Omitted for brevity
    }
  ]
}

你可以按照现有配置添加一个新的配置。将 ASPNETCORE_ENVIRONMENT 字段更改为 Production 并使用 .NET Core Launch (Production) 作为名称。保存文件。你现在可以通过在调试面板中点击绿色箭头来在 Production 环境中运行应用程序:

图 3.2 – 在 VS Code 中运行特定环境中的应用程序

图 3.2 – 在 VS Code 中运行特定环境中的应用程序

重要提示

launch.json文件仅在 VS Code dotnet run命令中使用。

在 Visual Studio 2022 中使用 launchSettings.json 文件

Visual Studio 2022 提供了一个启动配置文件对话框来设置环境变量。你有多种方式打开启动配置文件对话框:

  • 打开调试菜单 | <您的项目名称> 调试属性

  • 点击调试面板中绿色箭头旁边的箭头,并选择 <您的项目名称> 调试属性

  • 右键单击解决方案资源管理器窗口中的项目,并选择属性。在调试/常规选项卡中,单击打开调试启动配置文件 UI链接。

然后,你可以看到启动配置文件对话框:

图 3.3 – Visual Studio 2022 中的启动配置文件对话框

图 3.3 – Visual Studio 2022 中的启动配置文件对话框

如果您在此处进行更改,则需要重新启动应用程序以应用更改。

我们已经学习了如何设置环境。设置环境后,我们可以为不同的环境使用不同的配置。

理解配置和环境变量的优先级

我们介绍了多种读取配置值和环境变量的不同方法。让我们看看配置和环境变量的优先级是如何确定的。

以下表格显示了配置来源的优先级(数字越小,优先级越高):

来源 优先级
命令行参数 1
无前缀的环境变量 2
用户密钥(仅限Development环境) 3
appsettings.{Environment}.json 4
appsettings.json 5

表 3.2 – 配置来源的优先级

如果在Program.cs文件中注册了其他配置提供程序,则较晚注册的提供程序比较早注册的提供程序具有更高的优先级。

ASPNETCORE_ENVIRONMENT等环境变量方面,以下表格显示了优先级:

来源 优先级
命令行参数 1
launchSettings.json(仅限开发目的) 2
当前进程中的环境变量 3
系统环境变量 4

表 3.3 – 环境变量的优先级

注意,还有一些其他配置环境的方法没有在前面的表中列出。例如,如果您将 ASP.NET Core 应用程序部署到 Azure App Service,您可以在 App Service 配置中设置ASPNETCORE_ENVIRONMENT环境变量。对于 Linux 应用程序和容器应用程序,Azure App Service 使用--env标志将这些设置传递到容器中,以在容器中设置环境变量。

检查代码中的环境

生产 环境中继续运行应用程序。让我们尝试访问 Swagger UI 页面:http://localhost:5161/swagger。您会发现它显示了一个 404 Not Found 错误。为什么?

这是因为 Swagger UI 页面仅在 开发 环境中启用。您可以在 Program.cs 文件中查看代码:

if (app.Environment.IsDevelopment()){
    app.UseSwagger();
    app.UseSwaggerUI();
}

上述代码表示 Swagger UI 页面仅对开发环境启用。

ASP.NET Core 框架中预定义了三个环境名称,开发预发布生产,如下所示:

public static class Environments{
    public static readonly string Development = "Development";
    public static readonly string Staging = "Staging";
    public static readonly string Production = "Production";
}

app.Environment.IsDevelopment() 方法检查当前环境。如果当前环境是 开发,则 Swagger UI 页面被启用。否则,它被禁用。

要在代码中设置环境,在创建 WebApplicationBuilder 时使用以下代码:

var builder = WebApplication.CreateBuilder(new WebApplicationOptions{
    EnvironmentName = Environments.Staging
});

环境名称存储在 IHostEnvironment.EnvironmentName 属性中。您可以定义自己的环境名称。例如,您可以定义一个名为 测试 的环境名称。但是,框架提供了内置方法,例如 IsDevelopment()IsStaging()IsProduction(),来检查环境。如果您定义了自己的环境名称,您可以使用 IHostEnvironment.IsEnvironment(string environmentName) 方法来检查环境。

我们可以使用 System.Environment 类在代码中获取环境变量,例如 ASPNETCORE_ENVIRONMENT。将以下代码添加到 Program.cs 文件中:

// Omitted for brevityvar app = builder.Build();
// Read the environment variable ASPNETCORE_ENVIRONMENT
var environmentName = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
Console.WriteLine($"ASPNETCORE_ENVIRONMENT is {environmentName}");

运行应用程序,您可以在控制台看到 ASPNETCORE_ENVIRONMENT 环境变量:

PS C:\dev\web-api-with-asp-net\example_code\chapter3\EnvironmentsDemo> dotnet runBuilding...
ASPNETCORE_ENVIRONMENT is Development
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5161
# Omitted for brevity

对于不同的环境,我们应该做什么?

对于开发环境,我们可以启用 Swagger UI 页面,显示详细错误信息,输出调试信息等。对于生产环境,我们应该配置应用程序以获得最佳性能和最高安全性。以下是为生产环境考虑的要点:

  • 禁用 Swagger UI 页面。

  • 禁用详细错误信息。

  • 显示友好的错误信息。

  • 不要输出调试信息。

  • 启用缓存。

  • 启用 HTTPS。

  • 启用响应压缩。

  • 启用监控和日志记录。

摘要

在本章中,我们探讨了 ASP.NET Core 的三个重要组件:路由、配置和环境。我们讨论了如何为 ASP.NET Core Web API 应用程序配置路由以及如何从请求中读取参数。此外,我们还学习了如何从各种来源读取配置值,例如 appsettings.json、环境变量和命令行参数。我们还探讨了如何根据环境读取配置,使我们能够为不同的环境启用不同的功能。

在下一章中,我们将学习 ASP.NET Core 的两个更多重要组件:日志记录和中间件。

第四章:ASP.NET Core 基础知识(第二部分)

第三章 中,我们学习了 ASP.NET Core 中的三个重要组件:路由、配置和环境。接下来,让我们继续探索 ASP.NET Core 中的其他组件。

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

  • 记录日志

  • 中间件

ASP.NET Core 提供了一个灵活的日志记录 API,它可以与许多日志提供者一起工作。我们可以将日志发送和存储在多种目的地,例如控制台、文本文件、Azure 应用洞察等。当应用程序出现问题时,日志可以帮助我们找出发生了什么。记录日志属于一个更大的主题,称为 可观察性,它是一组帮助我们了解应用程序状态的实践。在本章中,我们将学习如何在 ASP.NET Core 中使用日志记录 API。

中间件是一个可以插入到请求管道中以处理请求和响应的组件。与传统的 ASP.NET 框架相比,这是 ASP.NET Core 中最重要的改进之一。请求管道是一系列中间件组件的链,这些组件中的每一个都可以对请求或响应进行一些操作,或者将其传递给管道中的下一个中间件组件。在本章中,我们将学习如何使用内置的中间件组件以及如何开发自定义中间件组件。

到本章结束时,你将能够使用日志记录 API 记录消息、设置日志级别以及配置日志提供者。此外,你将能够开发自定义中间件组件以处理请求和响应。

技术要求

本章中的代码示例可以在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter4 找到。你可以使用 VS 2022 或 VS Code 打开解决方案。

记录日志

记录日志是任何应用程序的重要组成部分。一个设计良好的日志系统可以捕获帮助您诊断问题和监控生产中应用程序的数据。记录日志可以为您提供有关重要系统事件何时、何地以及为何发生的洞察,以便您了解应用程序的性能以及用户如何与之交互。记录日志可能有助于您识别安全漏洞或潜在的攻击。记录日志还可以帮助您审计用户活动。

记录日志不应影响应用程序的性能。它应该是快速且高效的。它不应影响应用程序的任何逻辑。当您向应用程序添加记录时,您应考虑以下要点:

  • 应该记录哪些信息?

  • 日志消息应该是什么格式?

  • 日志消息应该发送到何处?

  • 日志消息应该保留多长时间?

  • 如何确保日志消息不会影响应用程序的性能?

在本节中,我们将讨论如何使用 ASP.NET Core 中的日志记录系统。

让我们创建一个新的项目来学习如何使用记录 API。使用以下命令创建一个名为 LoggingDemo 的新 ASP.NET Core Web API 项目:

dotnet new webapi -n LoggingDemo -controllers

您还可以从该章节的 GitHub 存储库中 samples/chapter4 文件夹下载名为 LoggingDemo 的源代码。

使用内置记录提供程序

ASP.NET Core 支持与各种记录提供程序一起工作的记录 API,包括内置记录提供程序和第三方记录提供程序。默认的 ASP.NET Core Web API 模板已注册以下记录提供程序:

  • 控制台记录提供程序

  • 调试记录提供程序

  • EventSource 记录提供程序

  • EventLog 记录提供程序(仅限 Windows)

为了清楚地看到这些记录提供程序是如何工作的,让我们先移除所有预先注册的记录提供程序,然后添加控制台记录提供程序。打开 Program.cs 文件并添加以下代码:

var builder = WebApplication.CreateBuilder(args);builder.Logging.ClearProviders();
builder.Logging.AddConsole();

现在,只有控制台记录提供程序被启用。让我们使用控制台记录提供程序来输出日志消息。打开 WeatherForecastController.cs 文件;您可以看到 ILogger<WeatherForecastController> 接口已经注入到构造函数中:

private readonly ILogger<WeatherForecastController> _logger;public WeatherForecastController(ILogger<WeatherForecastController> logger
{
    _logger = logger;
}

在项目中打开 WeatherForecastController.cs 文件。将以下代码添加到 Get() 方法中:

[HttpGet(Name = "GetWeatherForecast")]public IEnumerable<WeatherForecast> Get()
{
    _logger.Log(LogLevel.Information, "This is a logging message.");
    // Omitted for brevity
}

使用 dotnet run 命令运行应用程序。使用浏览器请求 /WeatherForecast 端点。您可以在控制台中看到日志消息:

info: LoggingDemo.WeatherForecastController[0]      This is a logging message.

如果您在 VS 2022 中运行应用程序并使用 F5 键运行应用程序,您可以在控制台窗口中看到日志消息,但您无法在 VS 2022 的 输出 窗口中看到:

图 4.1 – VS 2022 中调试消息的输出窗口

图 4.1 – VS 2022 中调试消息的输出窗口

要将日志消息发送到 Debug 记录提供程序,打开 Program.cs 文件并添加以下代码:

builder.Logging.ClearProviders();builder.Logging.AddConsole();
builder.Logging.AddDebug();

F5 再次在 VS 2022 中运行应用程序。现在,您可以在 输出 窗口中看到日志消息:

图 4.2 – VS 2022 中的调试日志消息

图 4.2 – VS 2022 中的调试日志消息

因此,如果我们想添加更多的其他记录提供程序,我们可以调用 ILoggingBuilder 接口的扩展方法。一些第三方记录提供程序也提供了 ILoggingBuilder 接口的扩展方法。

例如,如果我们需要将日志消息写入 Windows 事件日志,我们可以添加 EventLog 记录提供程序。将以下代码添加到 Program.cs 文件中:

builder.Logging.AddEventLog();

测试应用程序,我们应该能够在 Windows 事件日志中看到日志消息。

等等——为什么我们看不到它在事件日志中?

这是一个针对 EventLog 记录提供程序的特定场景。因为它是一个仅适用于 Windows 的记录提供程序,所以它不会继承默认的记录提供程序设置。我们需要在 appsettings.json 文件中指定记录级别。打开 appsettings.Development.json 文件并更新 Logging 部分:

{  "Logging": {
    "LogLevel": {
      "Default": "Trace",
      "Microsoft.AspNetCore": "Warning"
    },
    "EventLog": {
      "LogLevel": {
        "Default": "Information"
      }
    }
  }
}

我们需要添加一个 EventLog 部分来指定 EventLog 日志提供程序的日志级别。如果没有指定,默认日志级别是 Warning,这比 Information 高。这将导致我们无法看到 Information 日志消息。再次运行应用程序,现在我们可以在事件日志中看到日志消息:

图 4.3 – Windows 事件日志

图 4.3 – Windows 事件日志

我们刚刚介绍了一个新术语——日志级别。它是什么?

日志级别

在前面的示例中,我们使用了一个接受 LogLevel 参数的 Log 方法。LogLevel 参数表示日志消息的严重性。LogLevel 参数可以是以下值之一:

级别 描述
Trace 0 用于最详细的日志消息。这些消息可能包含敏感的应用程序数据。默认情况下,这些消息是禁用的,不应在生产环境中启用。
Debug 1 用于调试信息和开发。在生产环境中使用时请谨慎,因为日志量很大。通常,这些日志不应具有长期价值。
Information 2 用于跟踪应用程序的一般流程。这些日志应具有长期价值。
Warning 3 用于指示潜在问题或意外事件。这些问题通常不会导致应用程序失败。
Error 4 用于指示当前操作或请求中的失败,而不是应用程序级别的失败。这些错误和异常无法处理。
Critical 5 用于指示需要立即注意的严重故障;例如,数据丢失场景。
None 6 用于指定不应写入消息的日志类别。

表 4.1 – 日志级别

为了简化方法调用,ILogger<TCategoryName> 接口提供了以下扩展方法来记录不同日志级别的消息:

  • LogTrace()

  • LogDebug()

  • LogInformation()

  • LogWarning()

  • LogError()

  • LogCritical()

你可以使用 LogInformation() 方法替换前面的示例中的 Log() 方法:

_logger.LogInformation("This is a logging message.");

你将在控制台窗口中看到相同的日志消息。

让我们在 WeatherForecastController.cs 文件中添加一个 LogTrace() 方法,它将发送一个 Trace 日志:

_logger.LogTrace("This is a trace message");

使用 dotnet run 运行应用程序,并再次请求 WeatherForecast 端点。你将在控制台窗口中看不到跟踪消息。为什么?因为跟踪消息默认是禁用的。打开 appsettings.json 文件;我们可以找到以下配置:

"Logging": {  "LogLevel": {
    "Default": "Information",
    "Microsoft.AspNetCore": "Warning"
  }
},

根据配置,默认日志级别是 Information。回顾一下我们之前介绍的日志级别表。Trace 日志级别是 0,小于 Information 日志级别。因此,默认情况下不会输出 Trace 日志级别。要启用 Trace 日志级别,我们需要将 Default 日志级别更改为 Trace。但还有一个问题——我们是否应该为所有环境启用 Trace 日志?

答案是 视情况而定Trace 日志级别用于最详细的消息,这意味着它可能包含敏感的应用程序数据。我们可以在开发环境中启用 Trace 日志,但在生产环境中可能不想启用它。为了实现这一点,我们可以使用 appsettings.Development.json 文件来覆盖 appsettings.json 文件。这就是我们在 第三章 中学到的。打开 appsettings.Development.json 文件并更新以下配置以启用 Trace 日志:

"Logging": {  "LogLevel": {
    "Default": "Trace",
    "Microsoft.AspNetCore": "Warning"
  }
}

现在,再次运行应用程序。你应该能够在开发环境的控制台窗口中看到跟踪消息。

重要提示

要指定生产环境的日志级别,我们可以添加一个 appsettings.Production.json 文件,然后覆盖 Logging 部分的设置。

请记住,TraceDebugInformation 等日志级别会产生大量的日志消息。如果我们需要在生产环境中启用它们进行故障排除,我们需要小心。考虑一下我们希望将日志消息存储在哪里。

您可能会注意到在 appsettings.json 文件中有一个 Microsoft.AspNetCore 日志部分。它用于控制 ASP.NET Core 框架的日志级别。ASP.NET Core 使用类别名称来区分框架和应用产生的日志消息。检查我们向控制器注入 ILogger 服务的代码:

public WeatherForecastController(ILogger<WeatherForecastController> logger){
    _logger = logger;
}

ILogger<TCategoryName> 接口定义在 Microsoft.Extensions.Logging 命名空间中。TCategoryName 类型参数用于对日志消息进行分类。您可以使用任何字符串值作为类别名称,但使用类名作为日志类别名称是一种常见做法。

日志参数

这些 Log{LOG LEVEL}() 方法有一些重载,例如以下所示:

  • Log{LOG LEVEL}(string? message, params object?[] args)

  • Log{LOG LEVEL}(EventId eventId, string? message, params object?[] args)

  • Log{LOG LEVEL}(Exception exception, string message, params object[] args)

  • Log{LOG LEVEL}(EventId eventId, Exception? exception, string? message, params object?[] args)

这些方法的参数在此列出:

  • eventId 参数用于标识日志消息

  • message 参数用作格式化字符串

  • args 参数用于传递格式化字符串的参数

  • exception 参数用于传递异常对象

例如,我们可以定义一个EventIds类来识别日志消息,如下所示:

 public class EventIds{
    public const int LoginEvent = 2000;
    public const int LogoutEvent = 2001;
    public const int FileUploadEvent = 2002;
    public const int FileDownloadEvent = 2003;
    public const int UserRegistrationEvent = 2004;
    public const int PasswordChangeEvent = 2005;
    // Omitted for brevity
}

然后,我们可以使用eventId参数来识别日志消息:

_logger.LogInformation(EventIds.LoginEvent, "This is a logging message with event id.");

一些日志提供者可以使用eventId参数来过滤日志消息。

我们已经介绍了如何使用message参数。你可以使用一个普通字符串作为消息,或者你可以使用格式化字符串并使用args参数为格式化字符串传递参数。以下是一个使用messageargs参数的示例:

_logger.LogInformation("This is a logging message with args: Today is {Week}. It is {Time}.", DateTime.Now.DayOfWeek, DateTime.Now.ToLongTimeString());

如果发生异常,你可以使用带有exception参数的LogError()方法来记录异常:

try{
    // Omitted for brevity
}
catch (Exception ex)
{
    _logger.LogError(ex, "This is a logging message with exception.");
}

当使用LogError()方法记录异常时,将异常对象传递给exception参数非常重要。这是保留堆栈跟踪信息所必需的;仅仅记录异常消息是不够的。

使用第三方日志提供者

ASP.NET Core 的日志系统设计为可扩展。默认日志提供者,包括控制台日志提供者和Debug日志提供者,可以在控制台窗口或调试窗口中输出日志消息,这对于开发来说很方便。但在生产环境中,我们可能希望将日志消息发送到文件、数据库或远程日志服务。我们可以使用第三方日志提供者来实现这一点。

有许多第三方日志框架或库与 ASP.NET Core 一起工作,例如以下这些:

日志提供者 网站 GitHub 仓库
Serilog serilog.net/ github.com/serilog/serilog-aspnetcore
NLog nlog-project.org/ github.com/NLog/NLog.Extensions.Logging
log4net logging.apache.org/log4net/ github.com/huorswords/Microsoft.Extensions.Logging.Log4Net.AspNetCore

表 4.2 – 第三方日志提供者

一些其他平台提供了丰富的功能来收集和分析日志消息,例如以下这些:

这些平台可以提供一个仪表板来查看日志消息。技术上,它们不仅仅是日志提供者,还包含日志、跟踪、指标等在内的可观察性平台。

让我们从简单的例子开始。我们如何将日志消息打印到文件中?

我们可以使用Serilog将日志消息写入文件。Serilog 是一个流行的日志框架,与.NET 一起工作。它提供了一个标准的日志 API 和一组丰富的 sinks,可以将日志事件以各种格式写入存储。这些 sinks 针对各种目的地,例如以下内容:

  • 文件

  • Azure Application Insights

  • Azure Blob Storage

  • Azure Cosmos DB

  • Amazon CloudWatch

  • Amazon DynamoDB

  • Amazon Kinesis

  • Exceptionless

  • Elasticsearch

  • Sumo Logic

  • 邮件

  • PostgreSQL

  • RabbitMQ

Serilog 提供了一个Serilog.AspNetCore NuGet 包,它与 ASP.NET Core 集成。它有一组扩展方法来配置日志系统。要使用它,通过运行以下命令安装Serilog.AspNetCore NuGet 包:

dotnet add package Serilog.AspNetCore

接下来,让我们使用Serilog.Sinks.File sink 将日志消息写入文件。使用以下命令安装Serilog.Sinks.File NuGet 包:

dotnet add package Serilog.Sinks.File

然后,更新Program.cs文件以配置日志系统:

using Serilog;var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
var logger = new LoggerConfiguration().WriteTo.File(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs/log.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 90).CreateLogger();
builder.Logging.AddSerilog(logger);

在前面的代码中,我们首先清除默认的日志提供程序。然后,我们创建一个Serilog.ILogger实例并将其添加到日志系统中。WriteTo.File方法用于配置Serilog.Sinks.File sink。它将日志消息写入logs文件夹中的log.txt文件。rollingInterval参数用于指定滚动间隔。在当前示例中,我们将其设置为每日。retainedFileCountLimit参数用于指定要保留的最大日志文件数。在这种情况下,我们保留了 90 个文件。然后,我们调用CreateLogger方法创建一个Serilog.ILogger实例。最后,我们调用AddSerilog()方法将Serilog.ILogger实例添加到日志系统中。

没有必要更改使用ILogger服务的代码。ILogger服务仍然注入到控制器中。唯一的区别是日志消息将写入文件而不是控制台窗口。

再次运行应用程序并请求/WeatherForecast端点。你应该能在logs/log.txt文件中看到日志消息。

重要提示

如果你将日志消息写入文件,请确保有足够的磁盘空间,并且设置了适当的保留策略。否则,磁盘空间可能会耗尽。此外,建议在生产环境中使用一些专业的日志系统进行监控。例如,如果你的应用程序部署在 Azure 上,你可以轻松地将其与 Azure Application Insights 集成。将日志消息存储在文本文件中不易于管理和分析。

如果日志消息的数量很大,考虑将它们发送到消息队列,例如 RabbitMQ,然后有一个单独的过程来消费消息并将它们写入数据库。请记住,日志系统不应成为应用程序的瓶颈。不要使用异步方法进行日志记录,因为日志记录应该是快速的,频繁地在线程之间切换可能会引起性能问题。

LoggingDemo项目中,我们已经配置了日志系统,将日志消息写入文件。您可以通过查看Program.cs文件来了解它是如何工作的。Serilog 提供了一组丰富的接收器,并支持各种配置。您可以在以下位置找到更多提供的接收器:github.com/serilog/serilog/wiki/Provided-Sinks

结构化日志

日志参数部分,我们展示了如何使用args参数来格式化消息字符串。您可能会想知道我们是否可以使用字符串连接来实现相同的结果;例如,像这样:

logger.LogInformation($"This is a logging message with string concatenation: Today is {DateTime.Now.DayOfWeek}. It is {DateTime.Now.ToLongTimeString()}.");

答案是是和否。如果您不关心参数的值,它似乎可以工作。然而,处理日志的现代方法是使用结构化日志而不是纯字符串消息。结构化日志是一种以结构化格式记录消息的方式。例如,星期几这样的参数被识别并结构化,以便系统可以处理它们,这意味着我们可以对它们执行特殊操作,例如过滤、搜索等。让我们深入了解细节。

当您使用args参数而不是字符串连接时,Serilog 有效地支持结构化日志。让我们更新Program.cs文件,以结构化格式将日志发送到控制台窗口:

var logger = new LoggerConfiguration()    .WriteTo.File(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs/log.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 90)
    .WriteTo.Console(new JsonFormatter())
    .CreateLogger();
builder.Logging.AddSerilog(logger);

Serilog 支持在同一个日志管道中使用多个接收器。在前面代码中,我们在文件接收器之后添加了控制台接收器。控制台接收器是自动与Serilog.AspNetCore NuGet 包一起安装的,因此您不需要手动安装它。JsonFormatter用于以 JSON 格式格式化日志消息。您也可以为文件接收器指定formatter类型。

LoggingController文件中,添加一个新的动作方法来比较结构化日志和字符串连接日志:

[HttpGet][Route("structured-logging")]
public ActionResult StructuredLoggingSample()
{
    logger.LogInformation("This is a logging message with args: Today is {Week}. It is {Time}.", DateTime.Now.DayOfWeek, DateTime.Now.ToLongTimeString());
    logger.LogInformation($"This is a logging message with string concatenation: Today is {DateTime.Now.DayOfWeek}. It is {DateTime.Now.ToLongTimeString()}.");
    return Ok("This is to test the difference between structured logging and string concatenation.");
}

再次运行应用程序并请求api/Logging/structured-logging端点。您应该能够在控制台窗口中看到以 JSON 格式显示的日志消息。

使用结构化日志的日志消息看起来像这样:

{   "Timestamp":"2022-11-22T09:59:44.6590391+13:00",
   "Level":"Information",
   "MessageTemplate":"This is a logging message with args: Today is {Week}. It is {Time}.",
   "Properties":{
      "Week":"Tuesday",
      "Time":"9:59:44 AM",
      "SourceContext":"LoggingDemo.Controllers.LoggingController",
      "ActionId":"9fdba8d6-8997-4cba-a9e1-0cefe36cabd1",
      "ActionName":"LoggingDemo.Controllers.LoggingController.StructuredLoggingSample (LoggingDemo)",
      "RequestId":"0HMMC0D2M1GC4:00000001",
      "RequestPath":"/api/Logging/structured-logging",
      "ConnectionId":"0HMMC0D2M1GC4"
   }
}

使用字符串连接的日志消息看起来像这样:

{   "Timestamp":"2022-11-22T09:59:44.6597035+13:00",
   "Level":"Information",
   "MessageTemplate":"This is a logging message with string concatenation: Today is Tuesday. It is 9:59:44 AM.",
   "Properties":{
      "SourceContext":"LoggingDemo.Controllers.LoggingController",
      "ActionId":"9fdba8d6-8997-4cba-a9e1-0cefe36cabd1",
      "ActionName":"LoggingDemo.Controllers.LoggingController.StructuredLoggingSample (LoggingDemo)",
      "RequestId":"0HMMC0D2M1GC4:00000001",
      "RequestPath":"/api/Logging/structured-logging",
      "ConnectionId":"0HMMC0D2M1GC4"
   }
}

注意,结构化日志具有时间属性,而字符串连接则没有。结构化日志更加灵活且易于处理。因此,建议使用结构化日志而不是字符串连接。

分析结构化日志消息的一个优秀工具是 Seq (datalust.co/seq)。Seq 是一个强大的日志管理工具,它创建了你需要的可见性,以便快速识别和诊断应用程序中的问题。这是一个商业产品,但它提供免费试用。您可以从这里下载:datalust.co/download

在您的本地机器上安装它。当您看到以下窗口时,请注意5341

图 4.4 – 安装 Seq

图 4.4 – 安装 Seq

接下来,我们需要配置 Serilog 以将日志消息发送到 Seq。Serilog 有一个用于 Seq 的接收器,因此我们可以使用以下命令轻松安装它:

dotnet add package Serilog.Sinks.Seq

Program.cs 文件中,更新日志配置以将日志消息发送到 Seq:

var logger = new LoggerConfiguration()    .WriteTo.File(formatter: new JsonFormatter(), Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "logs/log.txt"), rollingInterval: RollingInterval.Day, retainedFileCountLimit: 90)
    .WriteTo.Console(new JsonFormatter())
    .WriteTo.Seq("http://localhost:5341")
    .CreateLogger();

再次运行应用程序并请求 api/Logging/structured-logging 端点。您应该在 Seq 中看到日志消息:

图 4.5 – Seq 中的结构化日志

图 4.5 – Seq 中的结构化日志

我们可以通过搜索 Week 属性来过滤日志消息,如图下所示:

图 4.6 – Seq 中的结构化日志过滤

图 4.6 – 在 Seq 中过滤结构化日志

现在,我们理解了为什么在发送日志时,结构化日志比字符串连接更强大。Serilog 为日志记录提供了许多强大的功能,例如 enrichers。您可以在 Serilog 的官方网站上找到更多信息:serilog.net/

重要提示

Seq 是本地开发的良好选择。然而,您可能需要为您的关键应用程序和服务购买许可证以获得更好的支持,或者您可能希望使用另一个日志系统。在选择日志系统之前,请仔细考虑您的需求。别忘了我们可以使用配置在不同环境中切换不同的日志系统。

我们应该/不应该记录什么?

日志记录是一个强大的工具,可以帮助我们诊断问题、监控应用程序、审计系统等。但日志记录并非免费。它消耗资源并可能减慢应用程序的运行速度。根据日志记录的目的,我们可能需要记录不同的信息。通常,有一些场景是我们应该记录的:

  • 输入/输出验证错误,例如无效的输入参数或无效的响应数据

  • 认证和授权失败

  • 应用程序错误、异常和警告,例如数据库连接错误、网络错误等

  • 应用程序启动和关闭

  • 高风险操作,例如从数据库中删除记录、更改用户密码、转账等

  • 法律合规性,例如审计、服务条款、个人数据同意等

  • 重要的业务事件,例如下订单、新用户注册等

  • 任何可疑活动,例如暴力攻击、账户锁定等

我们应该记录什么信息?如果日志消息中的信息不足以诊断问题,那么它将是无用的。通常,我们应该记录以下信息:

  • 何时:事件发生的时间是什么时候?

  • 哪里:应用程序名称和版本是什么?主机名或 IP 地址是什么?模块或组件名称是什么?

  • :哪些用户或客户端参与了事件?用户名、请求 ID 或客户端 ID 是什么?

  • 内容:事件是什么?事件的严重级别是多少?错误消息或堆栈跟踪是什么?其他描述性信息?

根据需求,我们可能需要包含更多信息。例如,对于 Web API 应用程序,我们还需要记录请求路径、HTTP 方法、头部、状态码等。对于数据库应用程序,我们可能需要记录 SQL 语句、参数等。

有些信息我们不应该记录:

  • 应用程序源代码

  • 敏感的应用程序信息,例如应用程序密钥、密码、加密密钥、数据库连接字符串等

  • 敏感的用户信息,例如个人可识别信息PII);例如,健康状况、政府身份证明等

  • 银行账户信息、信用卡信息等

  • 用户不同意分享的信息

  • 可能违反法律或法规的任何其他信息

我们已经解释了 ASP.NET Core 中的日志记录基础知识。接下来,我们将学习 ASP.NET Core 的另一个重要组件:中间件。

中间件

在本节中,我们将介绍中间件,它是 ASP.NET Core 中最重要的改进之一。

要遵循本节,您可以运行以下命令来创建一个新的 ASP.NET Core Web API 项目:

dotnet new webapi -n MiddlewareDemo -controllers

您可以从本章的 GitHub 仓库下载名为 MiddlewareDemo 的示例项目。

什么是中间件?

ASP.NET Core 是一个基于中间件的框架。ASP.NET Core 应用程序建立在一系列中间件组件之上。中间件是一个负责处理请求和响应的软件组件。多个中间件组件形成一个管道来处理请求并生成响应。在这个管道中,每个中间件组件都可以执行特定的任务,例如身份验证、授权、日志记录等。然后,它将请求传递给管道中的下一个中间件组件。这比基于 HTTP 模块和 HTTP 处理器的传统 ASP.NET 框架有了巨大的改进。这样,ASP.NET Core 框架就更加灵活和可扩展。您可以根据需要添加或删除中间件组件。您还可以编写自己的中间件组件。

以下图显示了中间件管道:

图 4.7 – 中间件管道

图 4.7 – 中间件管道

中间件组件可以在管道中的下一个中间件组件之前和之后执行任务。它还可以选择是否将请求传递给下一个中间件组件,或者停止处理请求并直接生成响应。

创建简单的中间件

让我们看看一个例子。在 VS Code 中打开 MiddlewareDemo 项目。将以下代码添加到 Program.cs 文件中:

var app = builder.Build();app.Run(async context =>
{
    await context.Response.WriteAsync("Hello world!");
});

重要提示

在前面的代码中,builder.Build() 返回一个 WebApplication 实例。它是 Web API 项目的宿主,负责应用程序的启动和生命周期管理。它还管理日志记录、依赖注入、配置、中间件等。

使用 dotnet rundotnet watch 运行应用程序,你将看到所有请求都将返回一个 Hello world! 响应,无论 URL 如何。这是因为 app.Run() 方法处理所有请求。在这种情况下,中间件短路了管道并直接返回响应。我们也可以称之为 终端中间件

要使用多个中间件组件,我们可以使用 app.Use() 方法。app.Use() 方法将中间件组件添加到管道中。按照以下方式更新 Program.cs 文件:

var app = builder.Build();app.Use(async (context, next) =>
{
    var logger = app.Services.GetRequiredService<ILogger<Program>>();
    logger.LogInformation($"Request Host: {context.Request.Host}");
    logger.LogInformation("My Middleware - Before");
    await next(context);
    logger.LogInformation("My Middleware - After");
    logger.LogInformation($"Response StatusCode: {context.Response.StatusCode}");
});

运行应用程序并请求 /weatherforecast 端点。你可以在控制台看到以下输出:

info: Program[0]      Request Host: localhost:5170
info: Program[0]
      My Middleware - Before
info: Program[0]
      My Middleware - After
info: Program[0]
      Response StatusCode: 200

WebApplication 实例使用 app.Use() 方法将一个简单的中间件组件添加到管道中。该中间件组件是一个匿名函数,它接受两个参数:contextnext。让我们更详细地看看这些:

  • context 参数是 HttpContext 类的实例,它包含请求和响应信息。

  • next 参数是一个委托,用于将请求传递给管道中的下一个中间件组件。

  • await next() 语句将请求传递给管道中的下一个中间件组件。

此中间件对请求和响应不做任何处理。它只向控制台输出一些信息。让我们向管道中添加另一个中间件组件。按照以下方式更新 Program.cs 文件:

var app = builder.Build();app.Use(async (context, next) =>
{
    var logger = app.Services.GetRequiredService<ILogger<Program>>();
    logger.LogInformation($"ClientName HttpHeader in Middleware 1: {context.Request.Headers["ClientName"]}");
    logger.LogInformation($"Add a ClientName HttpHeader in Middleware 1");
    context.Request.Headers.TryAdd("ClientName", "Windows");
    logger.LogInformation("My Middleware 1 - Before");
    await next(context);
    logger.LogInformation("My Middleware 1 - After");
    logger.LogInformation($"Response StatusCode in Middleware 1: {context.Response.StatusCode}");
});
app.Use(async (context, next) =>
{
    var logger = app.Services.GetRequiredService<ILogger<Program>>();
    logger.LogInformation($"ClientName HttpHeader in Middleware 2: {context.Request.Headers["ClientName"]}");
    logger.LogInformation("My Middleware 2 - Before");
    context.Response.StatusCode = StatusCodes.Status202Accepted;
    await next(context);
    logger.LogInformation("My Middleware 2 - After");
    logger.LogInformation($"Response StatusCode in Middleware 2: {context.Response.StatusCode}");
});

在此示例中,我们向管道中添加了两个中间件组件。第一个中间件组件向请求中添加一个 ClientName HTTP 头部。第二个中间件组件将响应状态码设置为 202 已接受。运行应用程序并请求 /weatherforecast URL。你将在控制台看到以下输出:

info: Program[0]      ClientName HttpHeader in Middleware 1:
info: Program[0]
      Add a ClientName HttpHeader in Middleware 1
info: Program[0]
      My Middleware 1 - Before
info: Program[0]
      ClientName HttpHeader in Middleware 2: Windows
info: Program[0]
      My Middleware 2 - Before
info: Program[0]
      My Middleware 2 - After
info: Program[0]
      Response StatusCode in Middleware 2: 202
info: Program[0]
      My Middleware 1 - After
info: Program[0]
      Response StatusCode in Middleware 1: 202

注意以下要点:

  • 原始请求不包含 ClientName HTTP 头部。因此,Middleware 1ClientName HTTP 头部的值为空。

  • Middleware 1 将请求传递给 Middleware 2 之前,Middleware 1ClientName HTTP 头部添加到请求中。

  • Middleware 1 在其 await next(context); 代码之后不会向控制台输出 My Middleware 1 - After。相反,Middleware 2ClientName HTTP 头部的值输出到控制台。

  • Middleware 2 将响应状态码更改为 202 已接受。响应将被传递给 Middleware 1。然后,Middleware 1 使用新的响应状态码输出 My Middleware 1 - After

这表明了中间件组件在管道中的工作方式。

如何组装中间件组件。

除了app.Use()方法之外,WebApplication类还提供了Map()MapWhen()UseWhen()Run()方法来向管道中添加中间件组件。以下表格显示了这些方法之间的区别:

方法 描述
app.Map() 将请求路径映射到子请求管道。只有当请求路径与指定的路径匹配时,中间件组件才会执行。
app.MapWhen() 当匹配给定的谓词时,运行子请求管道。
app.Use() 将内联委托添加到应用程序的请求管道中。
app.UseWhen() 当匹配给定的谓词时,将内联委托添加到应用程序的请求管道中。如果没有短路或包含终端中间件,它将重新连接到主管道。
app.Run() 将终端中间件组件添加到管道中。它阻止其他中间件组件处理请求。

表 4.3 – app.Map(), app.MapWhen(), app.Use(), app.UseWhen(), 和 app.Run() 方法之间的区别

为了处理请求,我们使用app.Map()app.MapWhen()来配置哪个请求路径(或谓词)应该由哪个中间件组件处理。app.Use()app.Run()方法用于将中间件组件添加到管道中。一个管道可以有多个app.Use()方法。但一个管道中只允许有一个app.Run()方法,并且app.Run()方法必须是管道中的最后一个方法。以下图表说明了这个过程:

图 4.8 – app.Map(), app.Use(), 和 app.Run() 方法之间的关系

图 4.8 – app.Map(), app.Use(), 和 app.Run() 方法之间的关系

让我们看看一个例子。我们将开发一个抽奖应用程序,允许用户调用 API 来检查他们是否得到了幸运数字。幸运数字是随机生成的。我们将向管道中添加几个中间件组件。

Program.cs文件更新如下:

app.Map("/lottery", app =>{
    var random = new Random();
    var luckyNumber = random.Next(1, 6);
    app.UseWhen(context => context.Request.QueryString.Value == $"?{luckyNumber.ToString()}", app =>
    {
        app.Run(async context =>
        {
            await context.Response.WriteAsync($"You win! You got the lucky number {luckyNumber}!");
        });
    });
    app.UseWhen(context => string.IsNullOrWhiteSpace(context.Request.QueryString.Value), app =>
    {
        app.Use(async (context, next) =>
        {
            var number = random.Next(1, 6);
            context.Request.Headers.TryAdd("number", number.ToString());
            await next(context);
        });
        app.UseWhen(context => context.Request.Headers["number"] == luckyNumber.ToString(), app =>
        {
            app.Run(async context =>
            {
                await context.Response.WriteAsync($"You win! You got the lucky number {luckyNumber}!");
            });
        });
    });
    app.Run(async context =>
    {
        var number = "";
        if (context.Request.QueryString.HasValue)
        {
            number = context.Request.QueryString.Value?.Replace("?", "");
        }
        else
        {
            number = context.Request.Headers["number"];
        }
        await context.Response.WriteAsync($"Your number is {number}. Try again!");
    });
});
app.Run(async context =>
{
    await context.Response.WriteAsync($"Use the /lottery URL to play. You can choose your number with the format /lottery?1.");
});

这是一个有趣的抽奖程序。让我们看看它是如何配置的:

  1. 首先,我们使用app.Map()方法将/lottery请求路径映射到一个子请求管道。在这一部分,有几件事情要做:

    1. 我们使用一个Random实例来生成幸运数字。请注意,这个数字在应用程序启动时只生成一次。

    2. 接下来,我们使用app.UseWhen()方法将一个中间件组件添加到管道中。这个中间件仅在请求包含查询字符串时工作。如果查询字符串与幸运数字相同,它将使用app.Run()来写入响应。这个分支就完成了。

    3. 接下来,当请求没有查询字符串时,我们添加另一个中间件组件。这个中间件由两个子中间件组件组成:

      • 第一个生成一个随机数并将其添加到 HTTP 头中,然后将其传递给第二个子中间件。

      • 第二个使用app.UseWhen()来检查请求的 HTTP 头部。如果 HTTP 头部包含幸运数字,它使用app.Run()来写入响应。这部分分支已完成。这部分展示了我们如何使用app.UseWhen()方法将中间件组件重新连接到主管道。

    4. 接下来,我们使用app.Run()方法向管道添加一个中间件组件。这个中间件组件用于处理所有其他对/lottery URL 的请求。它将响应写入客户端并显示客户端选择的数字。请注意,如果用户已经获得了幸运数字,这部分将不会执行。

  2. 在程序结束时,我们还有一个使用app.Run()方法的中间件组件。这个中间件组件用于处理所有其他请求。它展示了如何玩游戏。

运行应用程序并多次请求/lottery端点。有时,您会看到您赢得了彩票。或者,您可以在 URL 中包含查询字符串,例如/lottery?1。您应该能够注意到所有请求中的幸运数字是相同的。如果您重新启动应用程序,幸运数字可能会改变,因为它是应用程序启动时随机生成的。

有几点需要注意:

  • 中间件组件在应用程序启动时只初始化一次。

  • 您可以在一个管道中混合使用app.Map(), app.MapWhen(), app.Use(), app.UseWhen()app.Run()方法。但请注意,要按正确的顺序使用它们。

  • app.Run()必须是管道中的最后一个方法。

  • 在调用await next();await next.Invoke();之后,您不能更改响应,因为这可能导致协议违规或损坏响应体格式。例如,如果响应已经发送到客户端,如果您更改了头部或状态码,将会抛出异常。如果您想更改响应,请在调用await next();await next.Invoke();之前进行更改。

您可能会想知道app.MapWhen()app.UseWhen()之间的区别。它们都用于配置条件中间件执行。区别如下:

  • app.MapWhen(): 用于根据给定的谓词分支请求管道

  • app.UseWhen(): 用于在请求管道中条件性地添加一个分支,如果不短路或包含终端中间件,则将其重新连接到主管道

为了澄清区别,按照以下方式更新Program.cs文件:

app.UseWhen(context => context.Request.Query.ContainsKey("branch"), app =>{
    app.Use(async (context, next) =>
    {
        var logger = app.ApplicationServices.GetRequiredService<ILogger<Program>>();
        logger.LogInformation($"From UseWhen(): Branch used = {context.Request.Query["branch"]}");
        await next();
    });
});
app.Run(async context =>
{
    await context.Response.WriteAsync("Hello world!");
});

使用dotnet run运行应用程序并请求http://localhost:5170/?branch=1 URL。您将看到控制台窗口输出以下消息:

info: Program[0]      From UseWhen(): Branch used = 1

响应是 Hello world!。如果你请求任何其他 URL,例如 http://localhost:5170/test,你仍然会看到 Hello world! 的响应。但你看不到输出日志消息的控制台窗口。这说明 app.UseWhen() 只在谓词为 true 时起作用。如果谓词为 false,管道将继续执行下一个中间件组件。

接下来,让我们尝试将 app.UseWhen() 改为 app.MapWhen()。更新 Program.cs 文件如下:

app.MapWhen(context => context.Request.Query.ContainsKey("branch"), app =>{
    app.Use(async (context, next) =>
    {
        var logger = app.ApplicationServices.GetRequiredService<ILogger<Program>>();
        logger.LogInformation($"From MapWhen(): Branch used = {context.Request.Query["branch"]}");
        await next();
    });
});

再次运行应用程序并请求 http://localhost:5170/?branch=1 URL。你将在控制台窗口中看到日志消息,但它返回一个 404 错误!为什么?

这是因为使用了 app.MapWhen() 方法根据给定的谓词分支请求管道。如果谓词为 true,请求管道将分支到由本 app.MapWhen() 方法定义的子管道。但当调用 next() 方法时,它没有下一个中间件组件可以执行,尽管在程序末尾定义了 app.Run() 方法。因此,它返回一个 404 错误。

要使其工作,我们需要在子管道中添加另一个 app.Run() 方法。更新 Program.cs 文件如下:

app.MapWhen(context => context.Request.Query.ContainsKey("branch"), app =>{
    app.Use(async (context, next) =>
    {
        var logger = app.ApplicationServices.GetRequiredService<ILogger<Program>>();
        logger.LogInformation($"From MapWhen(): Branch used = {context.Request.Query["branch"]}");
        await next();
    });
    app.Run(async context =>
    {
       var branchVer = context.Request.Query["branch"];
       await context.Response.WriteAsync($"Branch used = {branchVer}");
    });
});

现在,再次运行应用程序并请求 http://localhost:5170/?branch=1 URL。你可以看到日志消息是 From MapWhen(): Branch used = 1,并且响应按预期返回。

中间件机制非常强大。它使 ASP.NET Core 应用程序具有极高的灵活性。但如果你使用错误的顺序,可能会导致问题。请明智地使用它。

在本节中,我们介绍了如何使用委托方法应用中间件组件。ASP.NET Core 提供了许多内置的中间件组件以简化开发。在下一节中,我们将探讨一些内置的中间件组件。

内置中间件

ASP.NET Core 框架提供了许多内置的中间件组件。检查 Program.cs 文件的代码。你可以找到一些像这样的代码:

if (app.Environment.IsDevelopment()){
    app.UseSwagger();
    app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();

有一些中间件组件可以启用 Swagger、HTTPS 重定向、授权等。你可以在这里找到内置中间件组件的完整列表:learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/?view=aspnetcore-8.0#built-in-middleware

这里有一些常见的内置中间件组件。注意顺序列。一些中间件组件必须按特定顺序调用。某些中间件可能会终止请求:

中间件 描述 顺序
认证 启用认证支持。 HttpContext.User 需要之前。OAuth 回调的终止点。
授权 启用授权支持。 在认证中间件之后立即执行。
跨源资源共享 (CORS) 配置 跨源资源 共享 (CORS)。 在使用 CORS 的组件之前。UseCors 目前必须放在 UseResponseCaching 之前。
健康检查 检查应用程序及其依赖项的健康状态。 如果请求匹配健康检查端点,则为终端。
HTTPS 重定向 将所有 HTTP 请求重定向到 HTTPS。 在消耗 URL 的组件之前。
响应缓存 启用响应缓存。 在需要缓存的组件之前。UseCors 必须在 UseResponseCaching 之前。
端点路由 定义和约束请求路由。 匹配路由的终端。

表 4.4 – 常见内置中间件组件

本书不会涵盖所有内置中间件组件。但我们将介绍其中的一些,例如速率限制、请求超时、短路等。

使用速率限制中间件

速率限制中间件是 ASP.NET Core 7 中提供的新内置中间件。它用于限制客户端在给定时间窗口内可以发出的请求数量。它对于防止 分布式拒绝服务 (DDoS) 攻击非常有用。

速率限制中间件定义了四个策略:

  • 固定窗口

  • 滑动窗口

  • 令牌桶

  • 并发

本节仅介绍如何使用中间件,因此我们将不涵盖策略的详细信息。在本节中,我们将使用固定窗口策略。此策略使用固定时间窗口来限制请求数量。例如,我们可以将每 10 秒的请求数量限制为 10。当时间窗口到期时,新的时间窗口开始,计数器重置为 0。

按照以下方式更新 Program.cs 文件:

builder.Services.AddRateLimiter(_ =>    _.AddFixedWindowLimiter(policyName: "fixed", options =>
        {
            options.PermitLimit = 5;
            options.Window = TimeSpan.FromSeconds(10);
            options.QueueProcessingOrder = QueueProcessingOrder.OldestFirst;
            options.QueueLimit = 2;
        }));
// Omitted for brevity
app.UseRateLimiter();
app.MapGet("/rate-limiting-mini", () => Results.Ok($"Hello {DateTime.Now.Ticks.ToString()}")).RequireRateLimiting("fixed");

上述代码将速率限制中间件添加到最小 API 请求管道中。它创建了一个名为 fixed 的固定窗口策略。options 属性表示每个 10 秒窗口最多允许 5 个请求。

运行应用程序并请求 http://localhost:5170/rate-limiting-mini URL 10 次。您将看到响应为 Hello 638005...。但第六次请求将挂起,直到时间窗口到期。如果您想尝试其他策略,可以尝试。为了练习更多,您可以将策略的配置移动到 appsettings.json 文件中。

要将此速率限制中间件应用于基于控制器的 API,我们需要将 EnableRateLimiting 属性添加到控制器或操作中,如下所示:

[HttpGet("rate-limiting")][EnableRateLimiting(policyName: "fixed")]
public ActionResult RateLimitingDemo()
{
    return Ok($"Hello {DateTime.Now.Ticks.ToString()}");
}

接下来,我们将介绍另一个内置中间件组件:请求超时中间件。

使用请求超时中间件

ASP.NET Core 8 引入了请求超时中间件,允许开发者为端点设置超时时间。如果请求在分配的时间内未完成,则触发 HttpContext.RequestAborted 取消令牌,允许应用程序处理超时请求。此功能有助于防止应用程序被长时间运行的请求阻塞。

要将请求超时中间件应用于 ASP.NET Core Web API 项目,请按如下方式更新Program.cs文件:

builder.Services.AddRequestTimeouts(); // Omitted for brevity
 app.UseRequestTimeouts();

请求超时中间件可以用于特定的端点。为此,我们需要将EnableRequestTimeout属性添加到控制器或操作中,如下所示:

[HttpGet("request-timeout")] [RequestTimeout(5000)]
 public async Task<ActionResult> RequestTimeoutDemo()
 {
     var delay = _random.Next(1, 10);
     logger.LogInformation($"Delaying for {delay} seconds");
     try
     {
         await Task.Delay(TimeSpan.FromSeconds(delay), Request.HttpContext.RequestAborted);
     }
     catch
     {
         logger.LogWarning("The request timed out");
         return StatusCode(StatusCodes.Status503ServiceUnavailable, "The request timed out");
     }
     return Ok($"Hello! The task is complete in {delay} seconds");
 }

在上述代码中,我们使用RequestTimeout属性将超时设置为 5 秒。在操作方法中,我们使用Task.Delay()方法来模拟一个长时间运行的任务。延迟时间随机生成。如果请求在 5 秒内未完成,则触发Request.HttpContext.RequestAborted取消令牌。然后,我们可以在catch块中处理超时请求。

使用dotnet run命令运行应用程序,并对/api/request-timeout端点进行几次请求。有时,你会得到一个503响应。请注意,请求超时中间件在调试模式下不工作。要测试此中间件,请确保调试器未附加到应用程序。

类似地,如果您想将此中间件应用于最小 API,可以使用WithRequestTimeout方法,如下所示:

app.MapGet("/request-timeout-mini", async (HttpContext context, ILogger<Program> logger) => {
     // Omited for brevity
 }).WithRequestTimeout(TimeSpan.FromSeconds(5));

超时配置可以使用策略进行配置。然后,我们可以将策略应用于控制器或操作。按如下方式更新Program.cs文件:

builder.Services.AddRequestTimeouts(option => {
     option.DefaultPolicy = new RequestTimeoutPolicy { Timeout = TimeSpan.FromSeconds(5) };
     option.AddPolicy("ShortTimeoutPolicy", TimeSpan.FromSeconds(2));
     option.AddPolicy("LongTimeoutPolicy", TimeSpan.FromSeconds(10));
 });

上述代码定义了三个超时策略。DefaultPolicy在未指定策略时使用。ShortTimeoutPolicy用于超时为 2 秒的短运行请求,而LongTimeoutPolicy用于超时为 10 秒的长运行请求。要将策略应用于控制器或操作,可以使用EnableRequestTimeout属性,如下所示:

[HttpGet("request-timeout-short")] [RequestTimeout("ShortTimeoutPolicy")]
 public async Task<ActionResult> RequestTimeoutShortDemo()

如果操作方法未指定策略,则将使用DefaultPolicy

使用短路中间件

短路中间件是 ASP.NET Core 8 中引入的另一个新的中间件组件。此中间件用于在不需要继续处理请求时短路请求。例如,网络爬虫可能会请求/robots.txt文件以检查网站是否允许爬取。作为一个 Web API 应用程序,我们不需要处理此请求。然而,请求管道的执行仍然会继续。可以使用短路中间件来短路请求并直接返回响应。

要将短路中间件应用于特定的端点,请将以下代码添加到Program.cs文件中:

app.MapGet("robots.txt", () => Results.Content("User-agent: *\nDisallow: /", "text/plain")).ShortCircuit();

上述代码使用ShortCircuit()方法来短路请求。如果请求路径是/robots.txt,它将直接返回一个 text/plain 响应。

使用短路中间件的另一种方法是使用MapShortCircuit如下所示:

app.MapShortCircuit((int)HttpStatusCode.NotFound, "robots.txt", "favicon.ico");

在本例中,当请求/robots.txt/favicon.ico时,将直接返回404 Not Found响应。这确保了服务器不会被不必要的请求所负担。

重要提示

短路中间件应该放置在管道的开始处,以防止其他中间件组件不必要地处理请求。这将确保请求以最有效的方式处理。

ASP.NET Core 提供了一系列内置的中间件组件。这些组件使用AddXXX()UseXXX()等扩展方法将中间件组件添加到管道中。在下一节中,我们将探讨如何创建自定义中间件组件,并使用扩展方法将其应用到管道中。

创建自定义中间件组件

如果内置的中间件无法满足您的需求,您可以创建自己的中间件组件。自定义中间件组件不需要从基类或接口派生。但是,中间件类确实需要遵循一些约定:

  • 它必须有一个接受RequestDelegate参数的公共构造函数。

  • 它必须有一个名为Invoke()InvokeAsync()的公共方法,该方法接受一个HttpContext参数并返回一个TaskHttpContent参数必须是第一个参数。

  • 它可以使用依赖注入(DI)来注入额外的依赖项。

  • 需要一个扩展方法来将中间件添加到IApplicationBuilder实例。

考虑以下场景。为了更好地跟踪,我们希望在应用中使用关联 ID 的概念。关联 ID 是每个请求的唯一标识符。它用于跟踪请求在应用中的路径,尤其是在微服务架构中。ASP.NET Core 提供了一个HttpContext.TraceIdentifier属性来存储唯一标识符。默认情况下,Kestrel 使用{ConnectionId}:{Request number}格式生成 ID;例如,0HML6LNF87PBV:00000001

如果我们有多个服务,例如服务 A服务 B,当客户端调用服务 A时,服务 A将为当前请求生成一个TraceIdentifier实例,然后服务 A将调用服务 B,而服务 B将生成另一个TraceIdentifier实例。在多个服务中跟踪请求是困难的。我们需要为要跟踪的请求使用相同的TraceIdentifier实例。

理念是为每个请求链生成一个关联 ID。然后,将其设置在请求/响应的X-Correlation-Id头中。当我们从服务 A调用服务 B时,将X-Correlation-Id头附加到 HTTP 请求头中,这样我们就可以在日志中附加X-Correlation-Id值,以便未来的诊断。为此,我们需要创建一个自定义中间件组件。在项目文件夹中创建一个名为CorrelationIdMiddleware的新类:

public class CorrelationIdMiddleware(RequestDelegate next, ILogger<CorrelationIdMiddleware> logger){    private const string CorrelationIdHeaderName = "X-Correlation-Id";
    public async Task InvokeAsync(HttpContext context)
    {
        var correlationId = context.Request.Headers[CorrelationIdHeaderName].FirstOrDefault();
        if (string.IsNullOrEmpty(correlationId))
        {
            correlationId = Guid.NewGuid().ToString();
        }
        context.Request.Headers.TryAdd(CorrelationIdHeaderName, correlationId);
        // Log the correlation ID
        logger.LogInformation("Request path: {RequestPath}. CorrelationId: {CorrelationId}", context.Request.Path, correlationId);
        context.Response.Headers.TryAdd(CorrelationIdHeaderName, correlationId);
        await next(context);
    }
}

在前面的代码中,CorrelationIdMiddleware 类有一个接受 RequestDelegate 参数的公共构造函数。它还有一个名为 InvokeAsync() 的公共方法,该方法接受一个 HttpContext 参数并返回一个 Task 实例。InvokeAsync() 方法是中间件的入口点。它从请求头中获取关联 ID。如果未找到,则生成一个新的 ID。然后,它设置 HttpContext.TraceIdentifier 属性并将关联 ID 添加到响应头中。最后,它调用管道中的下一个中间件组件。它还通过 DI 使用记录器记录关联 ID。

接下来,向 IApplicationBuilder 实例添加一个新的扩展方法:

public static class CorrelationIdMiddlewareExtensions{
    public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder builder)
    {
        return builder.UseMiddleware<CorrelationIdMiddleware>();
    }
}

现在,我们可以在 Program.cs 文件中应用关联 ID 中间件:

app.UseCorrelationId();

打开 WeatherForecastController.cs 文件,并将以下代码添加到 Get() 方法中:

[HttpGet(Name = "GetWeatherForecast")]public IEnumerable<WeatherForecast> Get()
{
    // Get the "X-Correlation-Id" header from the request
    var correlationId = Request.Headers["X-Correlation-Id"].FirstOrDefault();
    // Log the correlation ID
    _logger.LogInformation("Handling the request. CorrelationId: {CorrelationId}", correlationId);
    // Call another service with the same "X-Correlation-Id" header when you set up the HttpClient
    //var httpContent = new StringContent("Hello world!");
    //httpContent.Headers.Add("X-Correlation-Id", correlationId);
    // Omitted for brevity

运行应用程序并请求 http://localhost:5170/WeatherForecast URL。您将看到响应头包含 X-Correlation-Id 属性,如下所示:

content-type: application/json; charset=utf-8date: Wed,05 Oct 2022 08:17:55 GMT
server: Kestrel
transfer-encoding: chunked
x-correlation-id: de67a42b-fd95-4ba1-bd2a-28a54c878d4a

您还可以在日志中看到关联 ID,如下所示:

MiddlewareDemo.CorrelationIdMiddleware: Information: Request path: /WeatherForecast. CorrelationId: 795bf955-50a1-4d71-a90d-f859e636775a...
MiddlewareDemo.Controllers.WeatherForecastController: Information: Handling the request. CorrelationId: 795bf955-50a1-4d71-a90d-f859e636775a

通过这种方式,我们可以使用关联 ID 在多个服务中跟踪请求,尤其是在微服务架构中。

在此示例中,我们没有调用任何其他服务。您可以自己尝试。创建另一个服务并从当前服务中调用它。在另一个服务中应用相同的关联 ID 中间件。它可以从请求头中获取 X-Correlation-Id 头信息并继续使用它。然后,您将看到在两个服务的每个请求链中都使用了相同的关联 ID。

重要提示

上述示例纯粹是为了演示目的。Microsoft 提供了一个名为 Microsoft.AspNetCore.HeaderPropagation 的 NuGet 包,可用于将头信息传播到下游服务。您可以在以下位置找到示例代码:github.com/dotnet/aspnetcore/tree/main/src/Middleware/HeaderPropagation/samples/HeaderPropagationSample。您还可以查看 第十六章 以了解更多关于使用 OpenTelemetry 进行分布式跟踪的信息。

摘要

在本章中,我们学习了 ASP.NET Core 中的日志框架,并介绍了一个第三方日志框架 Serilog,帮助我们将日志写入不同的存储,如文件、控制台和 Seq,这是一个使用结构化日志分析日志的工具。我们还学习了什么是中间件,如何使用内置的中间件组件,以及如何创建自定义中间件组件。

在下一章中,我们将实现一些实际的业务逻辑。我们将介绍 Entity Framework Core (EF Core),一个强大的 对象关系映射器 (ORM) 框架,帮助我们访问数据库。

第五章:ASP.NET Core 中的数据访问(第一部分:EF Core 基础)

第二章 中,我们介绍了一个简单的 ASP.NET Core 应用程序来管理博客文章,它使用静态字段在内存中存储数据。在许多实际应用中,数据持久化在数据库中——如 SQL Server、MySQL、SQLite、PostgreSQL 等——因此我们需要访问数据库以实现 CRUD 操作。

本章,我们将学习 ASP.NET Core 中的数据访问。在 ASP.NET Core 中访问数据库有多种方式,例如通过 ADO.NET、Entity Framework Core 和 Dapper 等。在本章中,我们将重点介绍 Entity Framework Core,它是 .NET Core 中最受欢迎的 对象关系映射ORM)框架。

Entity Framework Core,简称 EF Core,是一个开源的 ORM 框架,它允许我们创建和管理数据库模式与对象模型之间的映射配置。它提供了一套使用 LINQ 方法执行 CRUD 操作的 API,就像在内存中操作对象一样。EF Core 支持许多数据库提供程序,如 SQL Server、SQLite、PostgreSQL、MySQL 等。它还支持许多其他功能,如迁移、变更跟踪等。

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

  • 为什么使用 ORM?

  • 配置 DbContext 类

  • 实现 CRUD 控制器

  • 基本 LINQ 查询

  • 配置模型与数据库表之间的映射

到本章结束时,你将能够使用 EF Core 在 ASP.NET Core 应用程序中访问数据库并执行基本的 CRUD 操作。

技术要求

本章的代码示例可以在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter5/ 找到。你可以使用 VS 2022 或 VS Code 打开解决方案。

预期你具备基本的 SQL 查询和 LINQ 知识。如果你不熟悉它们,可以参考以下资源:

为什么使用 ORM?

要操作关系型数据库中的数据,我们需要编写 SQL 语句。然而,SQL 语句不易维护且不安全类型。每次更新数据库模式时,都需要更新 SQL 语句,这容易出错。在许多传统应用中,逻辑与数据库紧密耦合。例如,逻辑可以直接在 SQL 数据库中定义,如存储过程、触发器等。这使得应用难以维护和扩展。

ORM 帮助我们将数据库架构映射到对象模型,这样我们就可以像操作内存中的对象一样操作数据库中的数据。ORM 可以将 CRUD 操作转换为 SQL 语句,这意味着它就像应用程序和数据库之间的一个抽象层。数据访问逻辑与数据库解耦,因此我们可以轻松地更改数据库而无需更改代码。此外,它提供了强类型安全,因此我们可以避免由类型不匹配引起的运行时错误。

请记住,我们并不是说 ORM 是所有场景下的最佳解决方案。有时,我们需要直接编写 SQL 语句以实现最佳性能。例如,如果我们需要生成一个复杂的数据报告,我们可能需要编写 SQL 语句来优化查询性能。然而,对于大多数场景,ORM 提供的好处比缺点更多。

.NET 中有许多 ORM 框架。在这本书中,我们将使用 EF Core,这是 .NET Core 中最受欢迎的 ORM 框架。以下是选择 EF Core 的原因:

  • 开源: EF Core 是一个开源项目,主要由微软维护,因此它得到了良好的支持。贡献也非常活跃。

  • 多数据库支持: EF Core 支持许多数据库提供程序,例如 SQL Server、SQLite、PostgreSQL、MySQL 等等。开发者可以使用相同的 API 访问不同的数据库。

  • 迁移: EF Core 支持数据库迁移,这使得我们能够轻松地更新数据库架构。

  • LINQ 支持: EF Core 提供了对 LINQ 的支持,这使得我们可以使用熟悉的语法来查询数据库。

  • 代码优先方法: EF Core 支持代码优先方法,这意味着我们可以使用 C# 代码定义数据库架构,EF Core 将自动生成数据库架构。

  • 性能: EF Core 被设计成轻量级且性能良好。它支持查询缓存和延迟加载,有助于提高性能。此外,EF Core 提供了异步 API,允许我们异步执行数据库操作,从而提高应用程序的可伸缩性。此外,EF Core 支持原始 SQL 查询,使我们能够直接编写 SQL 语句以实现最佳性能。

总体而言,如果您使用 .NET Core,EF Core 对于大多数场景都是一个不错的选择。因此,在这本书中,我们将使用 EF Core 作为 ORM 框架。

要使用 .NET Core CLI 执行 EF Core 相关任务,我们首先需要安装 dotnet-ef 工具。您可以使用以下命令进行安装:

dotnet tool install --global dotnet-ef

建议将工具安装为全局工具,这样您就可以方便地在任何项目中使用它。

接下来,使用以下命令创建一个新的 Web API 项目:

dotnet new webapi -n BasicEfCoreDemo -controllers

然后,导航到项目文件夹并运行以下命令来安装 EF Core 包:

dotnet add package Microsoft.EntityFrameworkCore.SqlServerdotnet add package Microsoft.EntityFrameworkCore.Design

第一个包是数据库提供程序,用于将应用程序连接到 SQL Server 数据库。对于这个演示应用程序,我们将使用LocalDB,这是 SQL Server 的一个轻量级版本。第二个包包含 EF Core 工具的共享设计时组件,这些组件是执行数据库迁移所必需的。

什么是 LocalDB?

LocalDB 被设计成 SQL Server 完整版本的替代品;它适用于开发和测试,但不适用于生产使用。当我们部署应用程序到生产环境时,我们可以使用 LocalDB 进行开发并替换连接字符串。LocalDB 与 VS 2022 一起安装。如果您默认没有 VS 2022,您可以在learn.microsoft.com/en-us/sql/database-engine/configure-windows/sql-server-express-localdb找到安装包。

LocalDB 仅由 Windows 支持。如果您使用 macOS 或 Linux,可以使用 SQLite 代替 LocalDB,或者使用 Docker 容器来运行 SQL Server。有关 SQLite 的更多信息,请参阅docs.microsoft.com/en-us/ef/core/providers/sqlite/

有关 SQL Server 在 Docker 上的更多信息,请参阅learn.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker。请注意,还有许多其他数据库提供程序,例如 SQLite、PostgreSQL、MySQL 等。您可以在docs.microsoft.com/en-us/ef/core/providers/找到数据库提供程序的完整列表。一些提供程序不是由 Microsoft 维护的。

接下来,让我们探索如何使用 EF Core 访问数据库。

配置 DbContext 类

为了表示数据库,EF Core 使用DbContext类,它允许我们查询和保存数据。DbContext类的一个实例维护数据库连接并将数据库模式映射到对象模型。它还跟踪对象的变化并管理事务。如果您熟悉面向对象编程,可以将DbContext类视为数据库和对象模型之间的桥梁,就像一个接口。当您查询或保存数据时,您通过DbContext类操作对象,EF Core 会将操作转换为相应的 SQL 语句。

在本章中,我们将开发一个简单的应用程序来管理发票。这个应用程序将用于演示如何使用 EF Core 访问数据库,包括如何定义数据库模式,如何执行 CRUD 操作,以及如何使用迁移来更新数据库模式。

您可以参考 第一章 来首先定义 API 合同。API 合同定义了端点和请求/响应模型。当我们定义 API 合同时,请注意我们需要咨询利益相关者以了解需求。例如,我们需要知道发票的字段、字段的数据类型等。我们还需要了解业务规则,例如 发票号应该是唯一的发票金额应该大于 0 等。这意味着我们将在 API 设计阶段花费大量时间。在这里,我们假设我们已经定义了 API 合同,并且可以开始开发应用程序。

创建模型

第一步是定义模型。模型,也称为实体,是一个代表现实世界中对象的类,它将被映射到数据库中的表(或多个表)。在本演示应用程序中,我们需要定义 Invoice 模型。

发票可以定义为以下类:

namespace BasicEfCoreDemo.Models;public class Invoice{
    public Guid Id { get; set; }
    public string InvoiceNumber { get; set; } = string.Empty;
    public string ContactName { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal Amount { get; set; }
    public DateTimeOffset InvoiceDate { get; set; }
    public DateTimeOffset DueDate { get; set; }
    public InvoiceStatus Status { get; set; }
}

InvoiceStatus 是一个自定义枚举类型,其定义如下所示:

public enum InvoiceStatus{
    Draft,
    AwaitPayment,
    Paid,
    Overdue,
    Cancelled
}

您可以在 Models 文件夹中创建一个名为 Invoice.cs 的文件,并将 Invoice 类代码复制到该文件中。

重要提示

我们使用 Guid 类型来表示 Id 属性,这是发票的唯一标识符。您也可以使用 intlong 作为标识符。两种方式都有其优缺点。例如,int 比使用 Guid 更高效,但它不是跨数据库唯一的。当数据库增长时,您可能需要将数据分割到多个数据库中,这意味着 int 标识符可能不再唯一。另一方面,Guid 无论您有多少个数据库都是唯一的,但与使用 intlong 相比,存储、插入、查询和排序记录的成本更高。在某些场景中,具有聚类索引的 Guid 主键可能会导致性能下降。在本演示应用程序中,我们目前使用 Guid 作为标识符。我们将在未来的章节中讨论更多关于优化应用程序性能的技术。

我们还使用 DateTimeOffset 类型来表示 InvoiceDateDueDate 属性,这是 .NET Core 中日期和时间的推荐类型。如果您不关心时区,也可以使用 DateTime 类型。DateTimeOffset 包含从 UTC 时间的时间偏移量,它由 .NET 类型和 SQL Server 支持。如果您想避免时区问题,这将很有帮助。

未来我们可能需要更多的属性,例如联系信息、发票项目等,但我们稍后再添加。现在让我们只关注模型。

创建和配置 DbContext 类

接下来,我们将创建一个 DbContext 类来表示数据库。在 Data 文件夹中创建一个名为 InvoiceDbContext.cs 的文件,并添加以下代码:

using BasicEfCoreDemo.Models;using Microsoft.EntityFrameworkCore;
namespace BasicEfCoreDemo.Data;
public class InvoiceDbContext(DbContextOptions<InvoiceDbContext> options) : DbContext(options)
{
    public DbSet<Invoice> Invoices => Set<Invoice>();
}

在前面的代码中,我们做了以下操作:

  • 继承了 DbContext 类并定义了 InvoiceDbContext 类,该类代表数据库。

  • 定义了 Invoices 属性,它是一个 DbSet<Invoice> 类型。它用于表示数据库中的 Invoices 表。

重要提示

为什么这里不使用 public DbSet<Invoice> Invoices { get; set; }?原因是如果 DbSet<T> 属性未初始化,由于默认启用了可空引用类型功能,编译器会从它们发出警告。因此,我们可以使用 Set<TEntity>() 方法初始化属性以消除警告。另一种修复方法是使用空值忽略运算符 !,它强制关闭编译器警告。DbContext 基类构造函数会为我们初始化 DbSet<T> 属性,所以在这种情况下使用 ! 是安全的。如果你不介意看到警告,使用 public DbSet<Invoice> Invoices { get; set; } 也可以。你可以使用这两种方法中的任何一种。

接下来,让我们配置数据库连接字符串。打开 appsettings.json 文件,并在 ConnectionStrings 部分添加以下代码:

"ConnectionStrings": {    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=BasicEfCoreDemoDb;Trusted_Connection=True;MultipleActiveResultSets=true"
  }

重要提示

你可以使用其他数据库,例如 SQLite 或 PostgreSQL,但你需要安装相应的数据库提供程序并相应地更改连接字符串。有关连接字符串的更多信息,请参阅 learn.microsoft.com/en-us/dotnet/framework/data/adonet/connection-string-syntax。有一个网站叫做 connectionstrings.com/,可以为不同的数据库提供程序生成连接字符串。

在前面的连接字符串中,我们使用 Server=(localdb)\\mssqllocaldb 来指定服务器为一个 LocalDB 实例,并使用 Database=BasicEfCoreDemoDb 来指定数据库名称。你可以将数据库名称更改为你想要的任何名称。Trusted_Connection=True 选项指定连接是受信任的,这意味着你不需要提供用户名和密码。MultipleActiveResultSets=true 选项指定连接可以包含 EF Core 中的 Include() 方法。

打开 Program.cs 文件,并在 builder 创建后添加以下代码:

builder.Services.AddDbContext<InvoiceDbContext>(options =>    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

上一段代码将 InvoiceDbContext 类注册到依赖注入容器中。AddDbContext<TContext>() 方法是一个扩展方法,它接受一个 DbContextOptionsBuilder 参数,该参数调用 UseSqlServer() 方法来配置数据库提供程序以使用 SQL Server 或 LocalDB。请注意,我们为 SQL Server 和 LocalDB 都使用了 UseSqlServer() 方法。区别在于 LocalDB 默认的服务器名称为 (localdb)\\mssqllocaldb。我们还向 UseSqlServer() 方法传递了数据库连接字符串,该字符串应与我们在 appsettings.json 文件中定义的名称相同。

目前,此代码只是将 InvoiceDbContext 类注册到依赖注入容器中,但我们尚未创建数据库。接下来,我们将使用 dotnet ef 命令创建数据库。

创建数据库

我们已定义 InvoiceDbContext 类,并将 InvoiceDbContext 的实例添加到依赖注入容器中。接下来,在我们可以使用它之前,我们需要创建数据库和 Invoices 表。要创建数据库和 Invoices 表,我们需要运行以下命令来应用数据库迁移:

dotnet ef migrations add InitialDb

InitialDb 参数是迁移名称。只要它是有效的 C# 标识符,您可以使用任何喜欢的名称。建议使用有意义的名称,例如 InitialDbAddInvoiceTable 等。

之前的命令创建了一些迁移文件,例如 <timestamp>_InitialDb.cs<timestamp>_InitialDb.Designer.cs,这些文件存储在 Migrations 文件夹中。<timestamp>_InitialDb.cs 迁移文件包含一个 Up() 方法来创建数据库和表。它还有一个 Down() 方法来回滚更改。请注意,此命令不会创建数据库;它只是创建迁移文件。请勿手动修改或删除迁移文件,因为它们是应用或回滚数据库更改所必需的。

这里是迁移文件的示例:

protected override void Up(MigrationBuilder migrationBuilder){
    migrationBuilder.CreateTable(
        name: "Invoices",
        columns: table => new
        {
            Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
            InvoiceNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
            ContactName = table.Column<string>(type: "nvarchar(max)", nullable: false),
            Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
            Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
            InvoiceDate = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
            DueDate = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
            Status = table.Column<int>(type: "int", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Invoices", x => x.Id);
        });
}
// Omitted for brevity

如您所见,Up() 方法创建表、列和约束。Down() 方法删除表。您可以使用 dotnet ef migrations remove 来删除迁移文件。

重要提示

你可能会看到如下警告信息:

Microsoft.EntityFrameworkCore.Model.Validation[30000]

在实体类型‘Invoice’的 decimal 属性‘Amount’上未指定存储类型。如果这些值不适用于默认的精度和刻度,则会导致 > 值被静默截断。在‘OnModelCreating’中使用 > ‘HasColumnType’显式指定可以容纳所有值的 SQL 服务器列类型,使用 > ‘HasPrecision’指定精度和刻度,或使用 > ‘****HasConversion’配置值转换器。

这是因为我们没有指定 Amount 属性的精度和刻度。我们将在稍后修复它。目前,EF Core 将使用 decimal 类型的默认精度和刻度,即 decimal(18,2)

迁移文件已创建,但尚未应用到数据库中。接下来,运行以下命令来创建数据库和 Invoices 表:

dotnet ef database update

如果命令成功,我们应该能在您的用户文件夹中找到数据库文件,例如如果您使用 Windows,则为 C:\Users\{username}\BasicEfCoreDemoDb.mdf。您可以使用 %USERPROFILE% 来获取用户文件夹路径。

重要提示

你可能会遇到一个错误 System.Globalization.CultureNotFoundException:在全球化不变模式中只支持不变文化。有关更多信息,请参阅 https://aka.ms/GlobalizationInvariantMode(参数 'name')。这是因为从 .NET 6 开始,全球化不变模式默认启用。你可以在 csproj 文件中将 InvariantGlobalization 属性设置为 false 来禁用它。

你可以使用几个工具来打开 LocalDB 数据库文件 – 例如,SQL Server Management StudioSSMS),它是微软支持的。你可以从这里下载它:learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms。你也可以使用其他 工具,例如 Dbeaver (dbeaver.io/),一个免费、通用的数据库工具,[或者 JetBrains DataGrip (www.jetbrains.com/datagrip/),一个强大的数据库 IDE。我们将使用 SSMS。

在 SSMS 中打开数据库文件,你会看到已经创建了 BasicEfCoreDemoDb 数据库。它将有两个表 – Invoices__EFMigrationsHistory

图 5.1 – 由 EF Core 迁移创建的数据库

图 5.1 – 由 EF Core 迁移创建的数据库

__EFMigrationsHistory 表用于跟踪迁移。它由 EF Core 自动创建。请不要手动修改它。

现在我们已经创建了数据库和 Invoices 表。接下来,让我们向表中添加一些种子数据。

添加种子数据

打开 InvoiceDbContext.cs 文件,并在 OnModelCreating() 方法中添加以下代码:

protected override void OnModelCreating(ModelBuilder modelBuilder){
    modelBuilder.Entity<Invoice>().HasData(
        new Invoice
        {
            Id = Guid.NewGuid(),
            InvoiceNumber = "INV-001",
            ContactName = "Iron Man",
            Description = "Invoice for the first month",
            Amount = 100,
            InvoiceDate = new DateTimeOffset(2023, 1, 1, 0, 0, 0, TimeSpan.Zero),
            DueDate = new DateTimeOffset(2023, 1, 15, 0, 0, 0, TimeSpan.Zero),
            Status = InvoiceStatus.AwaitPayment
        },
        // Omitted for brevity. You can check the full code in the sample project.
}

我们需要创建一个新的数据库迁移来将更改应用到数据库。运行以下命令:

dotnet ef migrations add AddSeedDatadotnet ef database update

如果你检查 SSMS 中的数据库,你会看到种子数据已经被添加到 Invoices 表中。

数据已经准备好了。接下来,我们将创建控制器来处理 HTTP 请求并使用数据库操作数据。

实现 CRUD 控制器

在本节中,我们将实现控制器来处理 HTTP 请求,这些请求是 GETPOSTPUTDELETE 操作,分别用于检索、创建、更新和删除数据。

创建控制器

如果你已经按照 第二章 安装了 dotnet aspnet-codegenerator 工具,你可以使用以下命令来创建一个具有特定 DbContext 的控制器。不要忘记安装 Microsoft.VisualStudio.Web.CodeGeneration.Design NuGet 包,这是 dotnet aspnet-codegenerator 工具所必需的:

# Install the tool if you have not installed it yet.#dotnet tool install -g dotnet-aspnet-codegenerator
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet-aspnet-codegenerator controller -name InvoicesController -api -outDir Controllers ––model Invoice ––dataContext InvoiceDbContext -async -actions

上述命令有一些参数,如下所示:

  • -name:控制器的名称。

  • -api:表示控制器是一个 API 控制器。

  • -outDir:控制器的输出目录。

  • --model:模型类名。在这种情况下,它是 Invoice 类。

  • --dataContextDbContext 类名。在这种情况下,它是 InvoiceDbContext 类。

  • -async:表示控制器的操作是异步的。

有关 dotnet aspnet-codegenerator 工具的更多信息,请参阅 learn.microsoft.com/en-us/aspnet/core/fundamentals/tools/dotnet-aspnet-codegenerator

dotnet aspnet-codegenerator 工具将创建一个具有以下操作的控制器:

using BasicEfCoreDemo.Data;using BasicEfCoreDemo.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace BasicEfCoreDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class InvoicesController : ControllerBase
    {
        private readonly InvoiceDbContext _context;
        public InvoicesController(InvoiceDbContext context)
        {
            _context = context;
        }
        // GET: api/Invoices
        [HttpGet]
        public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices()
        {
            if (_context.Invoices == null)
            {
                return NotFound();
            }
            return await _context.Invoices.ToListAsync();
        }
        // GET: api/Invoices/5
        [HttpGet("{id}")]
        public async Task<ActionResult<Invoice>> GetInvoice(Guid id)
        {
            if (_context.Invoices == null)
            {
                return NotFound();
            }
            var invoice = await _context.Invoices.FindAsync(id);
            if (invoice == null)
            {
                return NotFound();
            }
            return invoice;
        }
        // PUT: api/Invoices/5
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPut("{id}")]
        public async Task<IActionResult> PutInvoice(Guid id, Invoice invoice)
        {
            if (id != invoice.Id)
            {
                return BadRequest();
            }
            _context.Entry(invoice).State = EntityState.Modified;
            try
            {
                await _context.SaveChangesAsync();
            }
            catch (DbUpdateConcurrencyException)
            {
                if (!InvoiceExists(id))
                {
                    return NotFound();
                }
                else
                {
                    throw;
                }
            }
            return NoContent();
        }
        // POST: api/Invoices
        // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
        [HttpPost]
        public async Task<ActionResult<Invoice>> PostInvoice(Invoice invoice)
        {
            if (_context.Invoices == null)
            {
                return Problem("Entity set 'InvoiceDbContext.Invoices'  is null.");
            }
            _context.Invoices.Add(invoice);
            await _context.SaveChangesAsync();
            return CreatedAtAction("GetInvoice", new { id = invoice.Id }, invoice);
        }
        // DELETE: api/Invoices/5
        [HttpDelete("{id}")]
        public async Task<IActionResult> DeleteInvoice(Guid id)
        {
            if (_context.Invoices == null)
            {
                return NotFound();
            }
            var invoice = await _context.Invoices.FindAsync(id);
            if (invoice == null)
            {
                return NotFound();
            }
            _context.Invoices.Remove(invoice);
            await _context.SaveChangesAsync();
            return NoContent();
        }
        private bool InvoiceExists(Guid id)
        {
            return (_context.Invoices?.Any(e => e.Id == id)).GetValueOrDefault();
        }
    }
}

它非常简单!dotnet aspnet-codegenerator 工具已生成具有基本 CRUD 操作的控制器。您可以运行应用程序并使用 Swagger UI 测试 API 端点。我们将详细解释控制器的代码。

控制器是如何工作的

第二章第三章 中,我们介绍了 HTTP 请求是如何映射到控制器操作的。在本章中,我们重点关注数据访问和数据库操作。

首先,我们使用依赖注入(DI)将 InvoiceDbContext 实例注入到控制器中,该控制器处理数据库操作。作为开发者,我们通常不需要担心数据库连接。InvoiceDbContext 被注册为作用域,这意味着每个 HTTP 请求都会创建一个新的 InvoiceDbContext 实例,并在请求完成后销毁该实例。

一旦我们获取到 InvoiceDbContext 实例,我们可以使用 DbSet 属性来访问实体集。DbSet<Invoice> 属性代表 Invoice 模型类的集合,该集合映射到数据库中的 Invoices 表。我们可以使用 FindAsync()Add()Remove()Update() 来分别检索、添加、删除和更新数据库中的实体。SaveChangesAsync() 方法用于将更改保存到数据库。这样,我们通过 .NET 对象操作数据库,这比使用 SQL 语句要容易得多。这就是 ORM 的力量。

LINQ 是什么?

语言集成查询LINQ)是 .NET 中提供一致且表达性查询和操作来自各种数据源(如数据库、XML 和内存集合)的功能集合。使用 LINQ,你可以以声明性方式编写查询,这比使用 SQL 语句要容易得多。我们将在下一节中展示一些基本的 LINQ 查询。有关 LINQ 的更多信息rmation,请参阅 learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/

让我们看看生成的 SQL 语句。使用 dotnet run 启动应用程序,并使用 Swagger UI 或您喜欢的任何工具测试 api/Invoices API 端点。您可以在 调试 窗口中看到以下 SQL 语句:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (26ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]

日志有助于理解 EF Core 生成的 SQL 语句。EF Core 执行 SQL 查询,然后将结果映射到模型。这显著简化了数据访问和数据库操作。

接下来,让我们学习如何在控制器中使用 LINQ 查询数据。

基本 LINQ 查询

本书并非旨在成为 LINQ 手册。然而,在本节中,我们将向你展示一些基本的 LINQ 查询:

  • 查询数据

  • 过滤数据

  • 排序数据

  • 分页数据

  • 创建数据

  • 更新数据

  • 删除数据

查询数据

InvoiceDbContext 类中的 DbSet<Invoice> Invoices 属性表示 Invoice 实体的集合。我们可以使用 LINQ 方法来查询数据。例如,我们可以使用 ToListAsync() 方法从数据库检索所有发票:

var invoices = await _context.Invoices.ToListAsync();

这就是 GetInvoices 动作方法的工作原理。

要查找特定发票,我们可以使用 FindAsync() 方法,如 GetInvoice() 动作方法中所示:

var invoice = await _context.Invoices.FindAsync(id);

FindAsync() 方法接受主键值作为参数。EF Core 将 FindAsync() 方法转换为 SQL SELECT 语句,如下所示:

Executed DbCommand (15ms) [Parameters=[@__get_Item_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']      SELECT TOP(1) [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE [i].[Id] = @__get_Item_0

我们还可以使用 Single()SingleOrDefault() 方法来查找特定实体。例如,我们可以使用 SingleAsync() 方法来查找具有指定 ID 的发票:

var invoice = await _context.Invoices.SingleAsync(i => i.Id == id);

重要提示

你可能会注意到我们在代码中使用 SingleAsync() 而不是 Single()。EF Core 的许多方法都有同步和异步版本。异步版本以 Async 结尾。建议在控制器操作中使用异步版本,因为它们是非阻塞的,可以提高应用程序的性能。

如果你具有 LINQ 经验,你可能知道还有其他方法,例如 First()FirstOrDefault() 等,可以用来查找特定实体。差异如下:

  • Find()FindAsync() 用于通过主键值查找实体。如果找不到实体,则返回 null。请注意,这两个方法与实体的跟踪状态相关。如果实体已被 DbContext 跟踪,则 Find()FindAsync() 方法将立即返回跟踪的实体,而无需查询数据库。否则,它们将执行 SQL SELECT 语句从数据库检索实体。

  • Single()SingleAsync() 可以接受一个谓词作为参数。它返回满足谓词的单个实体,如果找不到实体或多个实体满足条件,则抛出异常。如果没有提供谓词调用,它将返回集合中的唯一实体,如果集合中存在多个实体,则抛出异常。

  • SingleOrDefault()SingleOrDefaultAsync() 可以接受一个谓词作为参数。它也返回满足谓词的单个实体,如果多个实体满足条件,则抛出异常,如果找不到实体,则返回默认值。如果没有提供谓词,它在集合为空时返回默认值(或指定的默认值),如果集合中存在多个实体,则抛出异常。

  • First()FirstAsync() 可以接受一个谓词作为参数。它返回满足谓词的第一个实体,如果找不到实体或集合为空,则抛出异常。如果没有提供谓词,它返回集合中的第一个实体,如果集合为空,则抛出异常。

  • FirstOrDefault()FirstOrDefaultAsync() 可以接受一个谓词作为参数。它也返回满足谓词的第一个实体。如果找不到实体或集合为空,它返回默认值(或指定的默认值)。如果没有提供谓词,它在集合不为空时返回第一个实体;否则,它返回默认值(或指定的默认值)。如果集合为空,则抛出异常。

这些方法有点令人困惑。建议的做法如下:

  • 如果您想通过主键值查找实体并利用跟踪状态来提高性能,请使用 Find()FindAsync()

  • 如果您确信实体存在且只有一个实体满足条件,请使用 Single()SingleAsync()。如果您希望在找不到实体时指定默认值,请使用 SingleOrDefault()SingleOrDefaultAsync()

  • 如果您不确定实体是否存在,或者可能存在多个满足条件的实体,请使用 First()FirstAsync()。如果您希望在找不到实体时指定默认值,请使用 FirstOrDefault()FirstOrDefaultAsync()

  • 如果您使用 Find()FindAsync()SingleOrDefault()SingleOrDefaultAsync()FirstOrDefault()FirstOrDefaultAsync(),不要忘记检查结果是否为 null

过滤数据

如果表中包含大量记录,我们可能希望根据某些条件过滤数据,而不是返回所有记录。我们可以使用 Where() 方法根据状态过滤发票。更新 GetInvoices 动作方法如下所示:

[HttpGet]public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(InvoiceStatus? status)
{
    // Omitted for brevity
    return await _context.Invoices.Where(x => status == null || x.Status == status).ToListAsync();
}

Where()方法接受一个 lambda 表达式作为参数。lambda 表达式是一种在行内定义委托方法的简洁方式,它在 LINQ 查询中广泛用于定义过滤、排序和投影操作。在前面的示例中,x => status == null || x.Status == status的 lambda 表达式意味着如果status参数不是null,则Invoice实体的Status属性等于status参数。EF Core 会将 lambda 表达式转换为 SQL 的WHERE子句。

运行应用程序并检查 Swagger UI。你会发现/api/Invoices端点现在有一个status参数。你可以使用该参数按状态过滤发票:

图 5.2 – 根据状态过滤发票

图 5.2 – 根据状态过滤发票

/api/Invoices端点发送带有状态参数的请求。你将获得具有指定状态的发票。SQL 查询如下所示:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (44ms) [Parameters=[@__status_0='?' (Size = 16)], CommandType='Text', CommandTimeout='30']
      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE [i].[Status] = @__status_0

你可以看到Where()方法被转换为 SQL 的WHERE子句。

排序和分页

仅过滤数据可能并不总是足够。我们可能还希望根据某些属性对数据进行排序,并使用分页返回数据子集。我们可以使用一些方法,如OrderBy()OrderByDescending()Skip()Take()等,来排序和分页数据。更新GetInvoices操作方法如下所示:

[HttpGet]public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(int page = 1, int pageSize = 10, InvoiceStatus? status = null)
{
    // Omitted for brevity
    return await _context.Invoices.AsQueryable().Where(x => status == null || x.Status == status)
                .OrderByDescending(x => x.InvoiceDate)
                .Skip((page - 1) * pageSize)
                .Take(pageSize)
                .ToListAsync();
}

在前面的代码中,我们使用AsQueryable()方法将DbSet<Invoice>转换为IQueryable<Invoice>。我们可以使用IQueryable来构建查询。Where()OrderByDescending()方法返回一个新的IQueryable对象。因此,我们可以链式调用 LINQ 方法来构建一个新的查询。Where()方法用于过滤数据,OrderByDescending()方法用于根据InvoiceDate属性按降序排序数据,而Skip()Take()方法用于分页数据。Skip()方法跳过前pageSize * (page - 1)条记录,Take()方法返回接下来的pageSize条记录。在语句的末尾,ToListAsync()方法执行查询并返回结果。

实际上,这里不需要AsQueryable()方法,因为DbSet<TEntity>类实现了IQueryable<TEntity>接口,这意味着DbSet<Invoice>属性已经是一个IQueryable对象。我们可以直接链式调用 LINQ 方法。

什么是 IQueryable?

当我们使用一些 LINQ 方法,例如 Where()OrderBy()Skip()Take() 时,EF Core 不会立即执行查询。它将构建一个查询并返回一个新的 IQueryable 对象。IQueryableSystem.Linq 命名空间中的一个接口,它表示可以用于对特定数据源(如数据库)进行查询的实体可查询集合。它允许我们通过链式调用 LINQ 方法来构建复杂的查询,但会推迟查询执行,直到需要结果的那一刻。通常,当我们调用 ToListAsync() 方法时,查询将被转换为特定于服务器的查询语言,如 SQL,并针对数据库执行。这可以提高应用程序的性能,因为我们不需要在过滤和排序数据之前从数据库中检索所有数据。

使用 dotnet run 运行应用程序并检查 Swagger UI,你会看到 /api/Invoices 端点已添加了 pagepageSize 参数。你可以使用这些参数按如下方式分页发票:

图 5.3 – 对发票进行排序和分页

图 5.3 – 对发票进行排序和分页

生成的 SQL 查询如下所示:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (40ms) [Parameters=[@__status_0='?' (Size = 16) (DbType = AnsiString), @__p_1='?' (DbType = Int32), @__p_2='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE [i].[Status] = @__status_0
      ORDER BY [i].[InvoiceDate] DESC
      OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY

注意,SQL 语句使用了 OFFSET/FETCH 子句来分页数据。这些关键字由 SQL Server 支持,但可能不被其他数据库支持。例如,MySQL 使用 LIMIT 子句来分页数据。EF Core 可以消除不同数据库之间的差异。它将 LINQ 查询转换为数据库的正确 SQL 语句。这样,开发者可以以数据库无关的方式编写 LINQ 查询。这就是 EF Core 的美妙之处。

创建实体

接下来,让我们看看如何创建一个新的发票。检查 PostInvoice 动作方法的代码:

[HttpPost]public async Task<ActionResult<Invoice>> PostInvoice(Invoice invoice)
{
    if (_context.Invoices == null)
    {
        return Problem("Entity set 'InvoiceDbContext.Invoices'  is null.");
    }
    _context.Invoices.Add(invoice);
    await _context.SaveChangesAsync();
    return CreatedAtAction("GetInvoice", new { id = invoice.Id }, invoice);

PostInvoice 动作方法接受一个 Invoice 对象作为请求体。它使用 Add() 方法将发票添加到 Invoices 实体集中。请注意,此更改发生在内存中。数据将不会添加到数据库,直到调用 SaveChangesAsync() 方法将更改保存到数据库。CreatedAtAction() 方法返回一个 201 Created 响应,其中包含新创建的发票的位置。你可以返回一个 200 OK 响应,但建议在创建新资源时返回 201 Created 响应。

你可以向 /api/invoices 端点发送 POST 请求来创建一个新的发票,并查看从日志中生成的 SQL 语句。它应该类似于以下内容:

 info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (3ms) [Parameters=[@p0='?' (DbType = Guid), @p1='?' (Precision = 18) (Scale = 2) (DbType = Decimal), @p2='?' (Size = 32), @p3='?' (Size = 256), @p4='?' (DbType = DateTimeOffset), @p5='?' (DbType = DateTimeOffset), @p6='?' (Size = 32) (DbType = AnsiString), @p7='?' (Size = 16) (DbType = AnsiString)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Invoices] ([Id], [Amount], [ContactName], [Description], [DueDate], [InvoiceDate], [InvoiceNumber], [Status])
      VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7);

重要提示

POST 动作的 JSON 请求体不需要包含 Id 属性。EF Core 将为 Id 属性生成一个新的 Guid 值。

更新实体

要更新一个实体,我们使用 Put 请求。PutInvoice 动作方法的代码如下所示:

[HttpPut("{id}")]public async Task<IActionResult> PutInvoice(Guid id, Invoice invoice)
{
    if (id != invoice.Id)
    {
        return BadRequest();
    }
    _context.Entry(invoice).State = EntityState.Modified;
    try
    {
        await _context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        if (!InvoiceExists(id))
        {
            return NotFound();
        }
        else
        {
            throw;
        }
    }
    return NoContent();
}

PutInvoice 动作方法接受 id 参数和作为请求体的 Invoice 对象。如果你检查 Swagger UI,你会看到 id 参数在 URL 中定义,但 Invoice 对象在请求体中定义。这是因为 Invoice 不是一个原始类型,所以 ASP.NET Core 只能从请求体中获取它。我们已经在 绑定源属性 部分中讨论过这一点,见 第三章

接下来,我们使用 _context.Entry() 方法获取发票的 EntityEntry 对象。然后,我们将 State 属性设置为 EntityState.Modified。看起来 EntityState 枚举在这里起着重要的作用。那么,EntityState 枚举是什么?

在 EF Core 中,每个 DbContext 实例都有一个 ChangeTracker 来跟踪实体的变化,这是 EF Core 的一个强大功能。换句话说,EF Core 知道每个实体的状态——是已添加、已删除还是已修改。当我们更新实体时,我们只需在内存中更新实体。EF Core 可以跟踪这些变化。当调用 SaveChangesAsync() 方法时,它将生成更新数据库中数据的 SQL 语句。

EntityState 枚举可以有以下值:

  • Detached:实体没有被上下文跟踪。

  • Unchanged:实体被上下文跟踪,但值没有改变。

  • Deleted:实体正在被跟踪且存在于数据库中,但它已被标记为删除。因此,当调用 SaveChangesAsync() 方法时,EF Core 将生成删除数据库中实体的 SQL 语句。

  • Modified:实体正在被跟踪且存在于数据库中,且在 DbContext 中的值已被修改。当调用 SaveChangesAsync() 方法时,EF Core 将生成更新数据库中实体的 SQL 语句。

  • Added:实体正在被跟踪,但它不存在于数据库中。当调用 SaveChangesAsync() 方法时,EF Core 将生成将实体插入数据库的 SQL 语句。

创建实体 部分中,我们使用了 Add() 方法将实体添加到实体集中。这相当于将 State 属性设置为 Added,如下面的代码所示:

//_context.Invoices.Add(invoice); This is equivalent to the following code_context.Entry(invoice).State = EntityState.Added;
await _context.SaveChangesAsync();

Add() 方法类似,改变实体的状态不会修改数据库中的数据。你必须调用 SaveChangesAsync() 方法来将更改保存到数据库。

让我们尝试调用 PutInvoice 动作方法来更新一个发票。在 Swagger UI 中向 /api/invoices/{id} 端点发送 PUT 请求。请求体如下所示:

{  "id": "0d501380-83d9-44f4-9087-27c8f09082f9",
  "invoiceNumber": "INV-001",
  "contactName": "Spider Man",
  "description": "Invoice for the first month",
  "amount": 100,
  "invoiceDate": "2023-01-01T00:00:00+00:00",
  "dueDate": "2023-01-15T00:00:00+00:00",
  "status": 1
}

请更新 JSON 主体以仅更改 contactName 属性。EF Core 生成的 SQL 语句如下所示:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (39ms) [Parameters=[@p7='?' (DbType = Guid), @p0='?' (Precision = 18) (Scale = 2) (DbType = Decimal), @p1='?' (Size = 32), @p2='?' (Size = 4000), @p3='?' (DbType = DateTimeOffset), @p4='?' (DbType = DateTimeOffset), @p5='?' (Size = 32) (DbType = AnsiString), @p6='?' (Size = 16) (DbType = AnsiString)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      UPDATE [Invoices] SET [Amount] = @p0, [ContactName] = @p1, [Description] = @p2, [DueDate] = @p3, [InvoiceDate] = @p4, [InvoiceNumber] = @p5, [Status] = @p6
      OUTPUT 1
      WHERE [Id] = @p7;

你可以看到 EF Core 在 UPDATE 语句中省略了 Id 列。这是因为 Id 列是 Invoices 表的主键。EF Core 知道它不需要更新 Id 列。但是,无论值是否更改,EF Core 都会更新其他属性,因为实体的 EntityStateModified

有时候我们只想更新已更改的属性。例如,如果我们只想更新 Status 属性,SQL 语句就不需要更新其他列。为了做到这一点,我们可以找到需要更新的实体,然后显式地更新属性。让我们更新 PutInvoice 动作方法来完成这个任务:

var invoiceToUpdate = await _context.Invoices.FindAsync(id);if (invoiceToUpdate == null)
{
    return NotFound();
}
invoiceToUpdate.Status = invoice.Status;
await _context.SaveChangesAsync();

在这个例子中,我们首先通过 FindAsync() 方法找到实体,然后更新 Status 属性。EF Core 将 Status 属性标记为已修改。最后,我们调用 SaveChangesAsync() 方法将更改保存到数据库。你可以看到生成的 SQL 语句只更新了 Status 属性,如下所示:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (2ms) [Parameters=[@__get_Item_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE [i].[Id] = @__get_Item_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[@p1='?' (DbType = Guid), @p0='?' (Size = 16) (DbType = AnsiString)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      UPDATE [Invoices] SET [Status] = @p0
      OUTPUT 1
      WHERE [Id] = @p1;

然而,在实际场景中,通常端点会接收到整个实体,而不仅仅是已更改的属性。我们可能不知道哪些属性已更改。在这种情况下,我们可以在代码中更新所有属性。EF Core 可以跟踪实体的状态,因此它足够智能,可以确定哪些属性已更改。让我们更新 PutInvoice 动作方法以显式更新所有属性:

// Omitted for brevityvar invoiceToUpdate = await _context.Invoices.FindAsync(id);
if (invoiceToUpdate == null)
{
    return NotFound();
}
invoiceToUpdate.InvoiceNumber = invoice.InvoiceNumber;
invoiceToUpdate.ContactName = invoice.ContactName;
invoiceToUpdate.Description = invoice.Description;
invoiceToUpdate.Amount = invoice.Amount;
invoiceToUpdate.InvoiceDate = invoice.InvoiceDate;
invoiceToUpdate.DueDate = invoice.DueDate;
invoiceToUpdate.Status = invoice.Status;
await _context.SaveChangesAsync();
// Omitted for brevity

/api/Invoices/{id} 端点发送一个 PUT 请求,并将 JSON 主体附加到请求中。如果你只更新 StatusDescription 属性,SQL 语句将如下所示:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (17ms) [Parameters=[@p2='?' (DbType = Guid), @p0='?' (Size = 256), @p1='?' (Size = 16) (DbType = AnsiString)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      UPDATE [Invoices] SET [Description] = @p0, [Status] = @p1
      OUTPUT 1
      WHERE [Id] = @p2;

前面的 SQL 语句略微提高了性能,因为它只更新了已更改的属性。对于一个小表来说,这可能不是什么大问题,但如果你的表很大,并且有很多列,那么这是一个好的实践。然而,它需要一个 SELECT 语句来首先获取实体。选择适合你场景的方法。

前面的代码中存在一个问题。如果实体有多个属性,逐个更新所有属性将会很麻烦。在这种情况下,我们可以使用 Entry 方法来获取 EntityEntry 对象,然后设置 CurrentValues 属性为新值。让我们更新 PutInvoice 动作方法以使用 Entry 方法:

// Update only the properties that have changed _context.Entry(invoiceToUpdate).CurrentValues.SetValues(invoice);

SetValues() 方法将设置实体的所有属性为新值。EF Core 可以检测到变化并将已更改的属性标记为 Modified。因此,我们不需要手动设置每个属性。当更新具有许多属性的实体时,这种方式是一种良好的实践。此外,用于更新属性的对象不必与实体具有相同的类型。这在分层应用程序中非常有用。例如,从客户端接收到的实体是一个 数据传输对象DTO)对象,而数据库中的实体是一个领域对象。在这种情况下,EF Core 将更新与 DTO 对象中属性名称匹配的属性。

注意,SetValues() 方法只更新简单的属性,例如 stringintdecimalDateTime 等等。如果实体有一个导航属性,SetValues() 方法将不会更新导航属性。在这种情况下,我们需要显式地更新属性。

通过再次发送 PUT 请求来测试 /api/Invoices/{id} 端点。你可以看到生成的 SQL 语句与之前的一个类似。

删除实体

DeleteInvoice 操作方法生成的代码中,我们可以看到以下代码:

[HttpDelete("{id}")]public async Task<IActionResult> DeleteInvoice(Guid id)
{
    if (_context.Invoices == null)
    {
        return NotFound();
    }
    var invoice = await _context.Invoices.FindAsync(id);
    if (invoice == null)
    {
        return NotFound();
    }
    _context.Invoices.Remove(invoice);
    await _context.SaveChangesAsync();
    return NoContent();
}

逻辑是先找到实体,然后使用 Remove() 方法将其从 DbSet 中移除。最后,我们调用 SaveChangesAsync() 方法将更改保存到数据库。如果你已经理解了 EntityState,你可能会知道 Remove() 方法相当于将 EntityState 设置为 Deleted,如下所示:

_context.Entry(invoice).State = EntityState.Deleted;

生成的 SQL 语句如下:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (2ms) [Parameters=[@__get_Item_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE [i].[Id] = @__get_Item_0
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[@p0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      DELETE FROM [Invoices]
      OUTPUT 1
      WHERE [Id] = @p0;

如你所见,EF Core 生成两个 SQL 语句,这似乎在先找到实体之前有点不必要。当我们删除一个实体时,我们唯一需要的是主键。因此,我们可以这样更新 DeleteInvoice() 操作:

// Omitted for brevityvar invoice = new Invoice { Id = id };
_context.Invoices.Remove(invoice);
await _context.SaveChangesAsync();
// Omitted for brevity

现在,SQL 语句如下:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (2ms) [Parameters=[@p0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      DELETE FROM [Invoices]
      OUTPUT 1
      WHERE [Id] = @p0;

这比之前的一个简单得多。

从 EF Core 7.0 开始,我们有一个名为 ExecuteDeleteAsync() 的新方法,可以用来在不先加载实体的情况下删除实体。代码如下:

await _context.Invoices.Where(x => x.Id == id).ExecuteDeleteAsync();

重要提示

ExecuteDeleteAsync() 方法不涉及更改跟踪器,因此它将 SQL 语句立即执行到数据库。它不需要在最后调用 SaveChangesAsync() 方法。这是从 EF Core 7.0 及以后的版本中删除一个实体(或多个)的推荐方法。然而,如果实体已经在 DbContext 中并且被更改跟踪器跟踪,直接执行 SQL 语句可能会导致 DbContext 和数据库中的数据不一致。在这种情况下,你可能需要使用 Remove() 方法或将 EntityState 属性设置为 Deleted 来从 DbContext 中删除实体。在使用 ExecuteDeleteAsync() 方法之前,请仔细考虑你的场景。

你可能想知道 EF Core 如何知道数据库中列和表的名字。我们将讨论配置并看看 EF Core 如何将模型映射到数据库。

配置模型与数据库之间的映射

如其名所示,ORM 用于将对象映射到关系型数据库。EF Core 使用映射配置将模型映射到数据库。在上一节中,我们看到我们没有配置任何映射;然而,EF Core 仍然可以自动将模型映射到数据库。这是因为 EF Core 有一组内置约定来配置映射。我们也可以显式自定义配置以满足我们的需求。在本节中,我们将讨论 EF Core 中的配置,包括以下内容:

  • 映射约定

  • 数据注释

  • Fluent API

映射约定

EF Core 在映射模型到数据库方面有一些约定:

  • 默认情况下,数据库使用 dbo 架构。

  • 表名是模型名称的复数形式。例如,我们在 InvoiceDbContext 类中有一个 DbSet<Invoice> Invoices 属性,因此表名是 Invoices

  • 列名是属性名。

  • 列的数据类型基于属性类型和数据库提供程序。以下是 SQL Server 中一些常见 C# 类型的默认映射列表:

.NET 类型 SQL Server 数据类型
int int
long bigint
string nvarchar(max)
bool bit
datetime datetime
double float
decimal decimal(18,2)
byte tinyint
short smallint
byte[] varbinary(max)

表 5.1 – SQL Server 中一些常见 C# 类型的默认映射

  • 如果一个属性名为 Id<实体名>Id,EF Core 将将其映射为主键。

  • 如果 EF Core 检测到两个模型之间的关系是一对多,它将自动将导航属性映射到数据库中的外键列。

  • 如果一个列是主键,EF Core 将自动为其创建一个聚集索引。

  • 如果一个列是外键,EF Core 将自动为其创建一个非聚集索引。

  • 枚举类型映射到枚举的底层类型。例如,InvoiceStatus 枚举在数据库中映射到 int 类型。

然而,有时我们需要细化映射。例如,我们可能希望对于 string 属性使用 varchar(100) 列而不是 nvarchar(max) 列。我们可能还希望将枚举作为字符串而不是 int 值保存到数据库中。在这种情况下,我们可以覆盖默认约定以根据我们的需求自定义映射。

有两种方法可以显式配置模型与数据库之间的映射:

  • 数据注释

  • Fluent API

让我们看看如何使用数据注释和 Fluent API 来自定义映射。

数据注释

数据注释是您可以应用于模型类的属性,以自定义映射。例如,您可以使用 Table 属性来指定表名,并使用 Column 属性来指定列名。以下代码展示了如何使用数据注释来自定义映射:

[Table("Invoices")]public class Invoice
{
    [Column("Id")]
    [Key]
    public Guid Id { get; set; }
    [Column(name: "InvoiceNumber", TypeName = "varchar(32)")]
    [Required]
    public string InvoiceNumber { get; set; } = string.Empty;
    [Column(name: "ContactName")]
    [Required]
    [MaxLength(32)]
    public string ContactName { get; set; } = string.Empty;
    [Column(name: "Description")]
    [MaxLength(256)]
    public string? Description { get; set; }
    [Column("Amount")]
    [Precision(18, 2)]
    [Range(0, 9999999999999999.99)]
    public decimal Amount { get; set; }
    [Column(name: "InvoiceDate", TypeName = "datetimeoffset")]
    public DateTimeOffset InvoiceDate { get; set; }
    [Column(name: "DueDate", TypeName = "datetimeoffset")]
    public DateTimeOffset DueDate { get; set; }
    [Column(name: "Status", TypeName = "varchar(16)")]
    public InvoiceStatus Status { get; set; }
}

在前面的代码中,每个属性都有一个或多个数据注释。这些数据注释是您可以应用于模型类的属性,以自定义映射。您可以指定一些映射信息,例如表名、列名、列数据类型等。

这里是一个常用数据注释的列表:

属性 描述
Table 模型类映射到的表名。
Column 属性映射到的表中的列名。您还可以使用 TypeName 属性指定列数据类型。
Key 指定属性作为键。
ForeignKey 指定属性作为外键。
NotMapped 模型或属性未映射到数据库。
Required 属性的值是必需的。
MaxLength 指定数据库中值的最大长度。仅适用于字符串或数组值。
Index 在属性映射到的列上创建索引。
Precision 如果数据库支持精度和刻度特性,指定属性的精度和刻度。
DatabaseGenerated 指定数据库应如何生成属性的值。如果您使用 intlong 作为实体的主键,您可以使用此属性并将 DatabaseGeneratedOption 设置为 Identity,例如 [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
Timestamp 指定属性用于并发管理。该属性将映射到 SQL Server 中的 rowversion 类型。在不同的数据库提供程序中的实现可能有所不同。

表 5.2 – 常用数据注释

以这种方式,映射配置被嵌入到模型类中。它易于理解,但稍微有些侵入性,这意味着模型类被数据库相关的配置所污染。为了将模型类与数据库映射配置解耦,我们可以使用 Fluent API。

重要提示

每次映射更改时,您都需要运行 dotnet ef migrations add <迁移名称> 命令来生成一个新的迁移。然后,运行 dotnet ef database update 命令来更新数据库。

Fluent API

Fluent API 是一组扩展方法,您可以使用它来优雅地配置映射。这是应用映射配置最灵活和最强大的方式,而不会污染模型类。另一个需要注意的重要事项是,Fluent API 的优先级高于数据注释。如果您在数据注释和 Fluent API 中配置了相同的属性,Fluent API 将覆盖数据注释。因此,Fluent API 是配置映射的推荐方式。

Fluent API 按方法调用的顺序应用。如果有两次调用配置相同的属性,则最新调用将覆盖之前的配置。

要使用 Fluent API,我们需要在派生的 DbContext 类中重写 OnModelCreating() 方法。以下代码显示了如何使用 Fluent API 配置映射:

protected override void OnModelCreating(ModelBuilder modelBuilder){
    // Seed data is omitted for brevity
    modelBuilder.Entity<Invoice>(b =>
    {
        b.ToTable("Invoices");
        b.HasKey(i => i.Id);
        b.Property(p => p.Id).HasColumnName("Id");
        b.Property(p => p.InvoiceNumber).HasColumnName("InvoiceNumber").HasColumnType("varchar(32)").IsRequired();
        b.Property(p => p.ContactName).HasColumnName("ContactName").HasMaxLength(32).IsRequired();
        b.Property(p => p.Description).HasColumnName("Description").HasMaxLength(256);
        // b.Property(p => p.Amount).HasColumnName("Amount").HasColumnType("decimal(18,2)").IsRequired();
        b.Property(p => p.Amount).HasColumnName("Amount").HasPrecision(18, 2);
        b.Property(p => p.InvoiceDate).HasColumnName("InvoiceDate").HasColumnType("datetimeoffset").IsRequired();
        b.Property(p => p.DueDate).HasColumnName("DueDate").HasColumnType("datetimeoffset").IsRequired();
        b.Property(p => p.Status).HasColumnName("Status").HasMaxLength(16).HasConversion(
                v => v.ToString(),
                v => (InvoiceStatus)Enum.Parse(typeof(InvoiceStatus), v));
    });
}

在前面的代码中,我们使用 Entity() 方法配置 Invoice 实体。此方法接受一个 Action<EntityTypeBuilder<TEntity>> 参数来指定映射。EntityTypeBuilder<TEntity> 类有很多方法可以配置实体,例如表名、列名、列数据类型等。您可以通过流畅的方式链式调用这些方法来配置实体,因此它被称为 Fluent API。

这里是常用 Fluent API 方法的列表:

方法 描述 等效 数据注释
HasDefaultSchema() 指定数据库模式。默认模式是 dbo N/A
ToTable() 模型类映射到的表名。 Table
HasColumnName() 属性映射到的表中的列名。 Column
HasKey() 指定属性作为键。 Key
Ignore() 忽略模型或属性从映射中。此方法可以在实体级别或属性级别上应用。 NotMapped
IsRequired() 属性的值是必需的。 Required
HasColumnType() 指定属性映射到的列的数据类型。 带有 TypeName 属性的 Column
HasMaxLength() 指定数据库中值的最大长度。仅适用于字符串或数组。 MaxLength
HasIndex() 在特定属性上创建索引。 Index
IsRowVersion() 指定属性用于并发管理。该属性将映射到 SQL Server 中的 rowversion 类型。不同数据库提供者的实现可能有所不同。 TimeStamp
HasDefaultValue() 为列指定默认值。该值必须是常量。 N/A
HasDefaultValueSql() 指定用于生成列默认值的 SQL 表达式,例如 GetUtcDate() N/A
HasConversion() 定义一个值转换器,将属性映射到列数据类型。它包含两个 Func 表达式来转换值。 N/A
ValueGeneratedOnAdd() 指定当添加新实体时数据库生成的属性值。EF Core 在插入记录时会忽略此属性。 使用 DatabaseGenerated 并带有 DatabaseGeneratedOption.Identity 选项的 DatabaseGenerated

表 5.3 – 常用的 Fluent API 方法

使用 Fluent API 配置关系还有一些其他方法,例如 HasOne()HasMany()WithOne()WithMany() 等。我们将在下一章中介绍它们。

分离映射配置

在大型项目中,可能会有很多模型类。如果我们把所有的映射配置都放在 OnModelCreating() 方法中,该方法将会非常长且难以维护。为了使代码更易于阅读和维护,我们可以将映射配置提取到一个类或几个单独的类中。

实现这一点的其中一种方法是为 ModelBuilder 类创建一个扩展方法。在 Data 文件夹中创建一个名为 InvoiceModelCreatingExtensions 的新类。然后,将以下代码添加到该类中:

public static class InvoiceModelCreatingExtensions{
    public static void ConfigureInvoice(this ModelBuilder builder)
    {
        builder.Entity<Invoice>(b =>
        {
            b.ToTable("Invoices");
            // Other mapping configurations are omitted for brevity
        });
    }
}
// You can continue to create the mapping for other entities, or create separate files for each entity.

然后,在 OnModelCreating() 方法中,调用扩展方法:

modelBuilder.ConfigureInvoice();// You can continue to call the extension methods for other entities. such as
// modelBuilder.ConfigureInvoiceItem();

现在,OnModelCreating() 方法更加简洁且易于阅读。

另一种分离映射配置的方法是实现 IEntityTypeConfiguration<TEntity> 接口。在 Data 文件夹中创建一个名为 InvoiceConfiguration 的新类。然后,将以下代码添加到该类中:

public class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>{
    public void Configure(EntityTypeBuilder<Invoice> builder)
    {
        builder.ToTable("Invoices");
        // Other mapping configurations are omitted for brevity
    }
}

然后,在 OnModelCreating() 方法中,有两种应用配置的方式:

  • 如果您使用 ApplyConfiguration() 方法,请将以下代码添加到 OnModelCreating() 方法中:

    modelBuilder.ApplyConfiguration(new InvoiceConfiguration());// You can continue to call the ApplyConfiguration method for other entities. such as// modelBuilder.ApplyConfiguration(new InvoiceItemConfiguration());
    
  • 或者,您可以直接调用 Configure() 方法:

    new InvoiceConfiguration().Configure(modelBuilder.Entity<Invoice>());// You can continue to call the Configure method for other entities. such as// new InvoiceItemConfiguration().Configure(modelBuilder.Entity<InvoiceItem>());
    

随着项目的增长,为每个实体调用映射配置可能会有些繁琐。在这种情况下,EF Core 有一个名为 ApplyConfigurationsFromAssembly() 的方法,可以应用程序集中所有的配置,如下面的代码所示:

modelBuilder.ApplyConfigurationsFromAssembly(typeof(InvoiceDbContext).Assembly);

您可以选择最适合您项目的选项。有一点提醒,如果您使用 ApplyConfigurationsFromAssembly() 方法,请确保所有配置类都与 DbContext 类位于同一程序集中。此外,您无法控制配置的顺序。如果顺序很重要,您需要按照正确的顺序显式调用每个配置。

在您运行 dotnet ef migrations add <迁移名称> 命令后,您会发现生成的迁移文件包含以下代码:

migrationBuilder.AlterColumn<string>(    name: "Status",
    table: "Invoices",
    type: "varchar(16)",
    nullable: false,
    oldClrType: typeof(int),
    oldType: "int");
migrationBuilder.AlterColumn<string>(
    name: "InvoiceNumber",
    table: "Invoices",
    type: "varchar(32)",
    nullable: false,
    oldClrType: typeof(string),
    oldType: "nvarchar(max)");

前面的代码片段显示,Status 属性已从 int 更改为 varchar(16),而 InvoiceNumber 属性已从 nvarchar(max) 更改为 varchar(32)。然后,您可以运行 dotnet ef database update 命令来更新数据库。您将看到 Status 列存储为字符串。

重要提示

在迁移过程中,如果数据类型发生变化,数据可能会丢失。例如,如果数据类型从 nvarchar(max) 更改为 varchar(32),原始数据将被截断为 32 个字符。请在运行迁移之前确保您理解数据类型的变化。

建议明确为每个实体配置映射,以确保最佳性能。例如,nvarchar(max) 比起 varchar 需要更多的存储空间,因此默认的映射配置可能不是最有效的。此外,默认的 dbo 数据库模式可能不适合您的特定场景。因此,明确配置映射是一种推荐的做法。

摘要

在本章中,我们学习了如何使用 EF Core 访问数据库。我们使用 DbContext 类实现了 CRUD 操作。我们介绍了某些基本的 LINQ 查询,例如查询、筛选、排序、创建、更新和删除。我们还学习了如何使用数据注释和 Fluent API 配置映射。通过本章获得的知识,您可以构建一个简单的应用程序来访问数据库。

然而,本章中我们构建的应用程序相当基础,并且只有一个实体。在现实世界的项目中,通常存在多个实体以及它们之间的关系。

在下一章中,我们将学习如何使用 EF Core 配置实体之间的关系。

第六章:ASP.NET Core 中的数据访问(第二部分 - 实体关系)

第五章中,我们介绍了DbContext类的基础知识以及如何使用它来访问数据。

您可以在第一章定义资源之间的关系部分回顾关系的基本概念,其中我们介绍了资源之间的关系。例如,在一个博客系统中,一篇帖子有一系列评论,一个用户有一系列帖子。在一个发票系统中,一个发票有一系列发票项,发票项属于发票。发票还有一个联系,它可以有一个或多个联系人,并且可以有一个地址。

在本章中,我们将继续探索 EF Core 的功能。我们将学习如何使用 Fluent APIs 管理实体之间的关系。最后,我们将讨论如何为具有关系的实体实现 CRUD 操作。

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

  • 理解一对多关系

  • 理解一对一关系

  • 理解多对多关系

  • 理解拥有实体

阅读本章后,您应该能够使用 EF Core 的 Fluent APIs 配置实体之间的关系,并在您的 ASP.NET Core 应用程序中实现具有关系的实体的 CRUD 操作。

技术要求

本章中的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter6找到。您可以使用 VS 2022 或 VS Code 打开解决方案。

预期您对结构化查询语言SQL)查询和语言集成查询LINQ)有基本了解。如果您不熟悉它们,可以参考以下资源:

理解一对多关系

一对多关系是关系型数据库中最常见的关系。它们也被称为父子(子)关系。例如,发票有一系列发票项。在本节中,我们将学习如何在 EF Core 中配置一对多关系,以及如何为具有一对多关系的实体实现 CRUD 操作。

让我们继续使用发票示例应用程序。您可以在chapter6文件夹中找到EfCoreRelationshipsDemo项目的示例代码。如果您想按照书中的说明测试代码,您可以继续在BasicEfCoreDemo项目上工作。请注意,示例代码中的InvoiceDbContext类已被重命名为SampleDbContext

接下来,让我们更新Invoice类并创建一个InvoiceItem类,然后定义它们之间的一对多关系。

一对多配置

为了演示一对多关系,我们需要在Models文件夹中添加一个新的类名为InvoiceItem,并给Invoice类添加一些额外的属性来表示它们之间的关系。

InvoiceItem类的代码如下:

public class InvoiceItem{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal UnitPrice { get; set; }
    public decimal Quantity { get; set; }
    public decimal Amount { get; set; }
    public Guid InvoiceId { get; set; }
    public Invoice? Invoice { get; set; }
}

InvoiceItem类有一组属性来存储发票项数据,例如NameDescriptionUnitPrice等。它还有一个InvoiceId属性来存储发票项所属的发票 ID,以及一个Invoice属性来引用发票。要开始配置过程,请按照以下步骤操作:

  1. 按照以下方式更新Invoice类:

    public class Invoice{    public Guid Id { get; set; }    // Omitted for brevity    // Add a collection of invoice items    public List<InvoiceItem> InvoiceItems { get; set; } = new ();}
    

    在前面的代码中,我们定义了InvoiceInvoiceItem之间的关系。一张发票包含一系列的发票项,而一个发票项属于一个发票。这是一个一对多关系,我们可以识别以下术语:

    • Invoice是主实体。

    • InvoiceItem是依赖实体。它有一个InvoiceId外键属性来识别父实体。

    • Invoice类的Id属性是主键。

    • InvoiceItem类的InvoiceId属性是外键,用于存储父实体的主键值。

    • Invoice类的InvoiceItems属性是一个集合导航属性。

    • InvoiceItem类的Invoice属性是一个引用导航属性。

  2. 因为添加了一个新的模型,我们需要更新DbContext类。打开SampleDbContext类并添加以下代码:

    public DbSet<InvoiceItem> InvoiceItems => Set<InvoiceItem>();
    
  3. 此外,配置新模型的映射也是一个好的实践。在Data文件夹中添加一个新的类,命名为InvoiceItemConfiguration

    public class InvoiceItemConfiguration : IEntityTypeConfiguration<InvoiceItem>{    public void Configure(EntityTypeBuilder<InvoiceItem> builder)    {        builder.ToTable("InvoiceItems");        builder.Property(p => p.Id).HasColumnName(nameof(InvoiceItem.Id));        builder.Property(p => p.Name).HasColumnName(nameof(InvoiceItem.Name)).HasMaxLength(64).IsRequired();        builder.Property(p => p.Description).HasColumnName(nameof(InvoiceItem.Description)).HasMaxLength(256);        builder.Property(p => p.UnitPrice).HasColumnName(nameof(InvoiceItem.UnitPrice)).HasPrecision(8, 2);        builder.Property(p => p.Quantity).HasColumnName(nameof(InvoiceItem.Quantity)).HasPrecision(8, 2);        builder.Property(p => p.Amount).HasColumnName(nameof(InvoiceItem.Amount)).HasPrecision(18, 2);        builder.Property(p => p.InvoiceId).HasColumnName(nameof(InvoiceItem.InvoiceId));    }}
    
  4. 一旦我们为InvoiceInvoiceItem定义了导航属性,EF Core 就可以发现这两个实体之间的关系。让我们使用dotnet ef migrations add AddInvoiceItem命令创建一个迁移。然后,检查生成的迁移文件。你会发现 EF Core 添加了以下代码:

    migrationBuilder.CreateTable(    name: "InvoiceItems",    columns: table => new    {        Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),        // Omitted for brevity        InvoiceId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)    },    constraints: table =>    {        table.PrimaryKey("PK_InvoiceItems", x => x.Id);        table.ForeignKey(            name: "FK_InvoiceItems_Invoices_InvoiceId",            column: x => x.InvoiceId,            principalTable: "Invoices",            principalColumn: "Id",            onDelete: ReferentialAction.Cascade);    });// Omitted for brevitymigrationBuilder.CreateIndex(    name: "IX_InvoiceItems_InvoiceId",    table: "InvoiceItems",    column: "InvoiceId");
    

    EF Core 将创建一个新的InvoiceItems表,并在InvoiceId列上添加一个外键约束。外键约束的名称是FK_<依赖类型名称>_<主类型名称>_<外键属性名称>。它还会在InvoiceId列上创建一个索引。

另一件你需要注意的事情是,onDelete操作被设置为ReferentialAction.Cascade,这意味着如果父实体被删除,所有相关的子实体也将被删除。

让我们思考一个问题——如果我们没有在 InvoiceItem 类中拥有 InvoiceId 属性,EF Core 还能发现这两个实体之间的关系吗?你可以使用 dotnet ef migrations remove 命令来删除最后一个迁移,删除 InvoiceItem 类中的 InvoiceId 属性,然后再次添加迁移。你会看到 EF Core 仍然可以在 InvoiceItems 表中创建一个名为 InvoiceId 的列,并将其应用到外键约束上,这被称为影子外键属性。这是因为 EF Core 有其内置的约定来做这件事。有几个场景下,EF Core 可以发现实体之间的一对多关系:

  • 从属实体有一个到主实体的引用导航属性

  • 主实体有一个到从属实体的集合导航属性

  • 引用导航属性和集合导航属性在两端都包含

  • 引用导航属性和集合导航属性在两端都包含,外键属性包含在从属实体中

如果约定对我们不起作用,我们可以显式配置实体之间的关系来改变 EF Core 的默认行为。按照以下步骤操作:

  1. 要显式配置实体之间的一对多关系,我们可以使用 HasOne()WithMany()HasMany()WithOne() 方法。将以下代码添加到 InvoiceConfiguration 类中:

    builder.HasMany(x => x.InvoiceItems)    .WithOne(x => x.Invoice)    .HasForeignKey(x => x.InvoiceId);
    

    HasMany() 方法用于配置集合导航属性,而 WithOne() 方法用于配置引用导航属性。HasForeignKey() 方法用于配置外键属性。因此,前面的代码明确配置了一个发票可以有多个发票项,并且 InvoiceItem 类的 InvoiceId 属性是外键。如果你现在添加一个迁移,你会发现 EF Core 会生成与约定生成相同的代码。

  2. 也可以为 InvoiceItem 类定义关系。删除 Invoice 类的前置配置代码,并将以下代码添加到 InvoiceItemConfiguration 类中:

    builder.HasOne(i => i.Invoice)    .WithMany(i => i.InvoiceItems)    .HasForeignKey(i => i.InvoiceId)    .OnDelete(DeleteBehavior.Cascade);
    

    现在应该容易理解了。HasOne() 方法用于配置引用导航属性,而 WithMany 方法用于配置集合导航属性。

    注意,我们还明确配置了 OnDelete() 动作到 Cascade,这与约定生成的相同。但如果需要,我们可以将其更改为其他选项。也就是说,Fluent API 比约定更灵活。

  3. 我们只需要在关系的一侧配置关系。因此,请在添加迁移文件并应用迁移到数据库之前清理测试代码。迁移应用后,你可以检查数据库模式,看看是否创建了外键约束,如图下所示:

图 6.1 – 数据库中创建了一个外键约束

图 6.1 – 数据库中创建了一个外键约束

由于一对一关系可以在任一方向上定义,我们应该在哪个方向上配置关系呢?这取决于场景。如果两个实体之间有一对一的关系非常强,那么我们在哪一侧配置关系实际上并不重要。但如果两个实体之间是松散耦合的,我们最好在依赖实体上配置关系。

例如,User 实体被许多其他实体共享,例如 PostCommentInvoice 等。每个 Post 实体可以有一个 Author 属性,它是对 User 实体的引用导航属性,CommentInvoice 也同样如此。然而,User 实体不需要对 PostCommentInvoice 实体有集合导航属性。在这种情况下,我们应该在 PostCommentInvoice 实体上配置关系。

要配置这种关系,我们可以忽略 WithMany 方法的参数,因为 User 实体没有对 Post 实体的集合导航属性,如下面的代码所示:

public void Configure(EntityTypeBuilder<Post> builder){
    // Omitted for brevity
    builder.HasOne(x => x.Author)
        .WithMany()
        .HasForeignKey(x => x.AuthorId);
    // Omitted for brevity
}

接下来,让我们看看我们如何实现具有一对一关系的实体的 CRUD 操作。

一对多 CRUD 操作

具有一对多关系的实体的 CRUD 操作与没有关系的实体不同。例如,当检索一个发票时,我们可能需要查询 Invoices 表和 InvoiceItems 表,以便检索相关的发票项目。此外,当删除一个发票时,我们必须考虑是否也要删除相关的发票项目。

EF Core 可以帮助我们管理各种场景。例如,当我们需要检索一个发票及其发票项目时,EF Core 可以生成一个 LEFT JOIN 查询来连接两个表。为了实现具有一对一关系的实体的 CRUD 操作,让我们探索以下部分。

创建数据

首先,让我们创建一个新的发票,并包含一些发票项目。你不需要更新 PostInvoice 动作的代码:

  1. 使用 dotnet run 运行应用程序。向 /api/Invoices 端点发送一个 POST 请求。JSON 主体如下所示:

    {  "invoiceNumber": "INV-004",  "contactName": "Hulk",  "description": "Invoice for the first month",  "amount": 300,  "invoiceDate": "2022-12-28T01:39:42.915Z",  "dueDate": "2022-12-28T01:39:42.915Z",  "status": 1,  "invoiceItems": [    {      "name": "Invoice Item 1",      "description": "",      "unitPrice": 100,      "quantity": 2,      "amount": 200    },    {      "name": "Invoice Item 2",      "description": "",      "unitPrice": 50,      "quantity": 2,      "amount": 100    }  ]}
    An unhandled exception has occurred while executing the request.System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 32\. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.Invoice.InvoiceItems.
    

    异常被抛出是因为Invoice类有一个到InvoiceItem类的集合导航属性,而InvoiceItem类有一个到Invoice类的引用导航属性。因此,在 JSON 序列化中存在循环。一些序列化框架,如Newtonsoft.JsonSystem.Text.Json,不允许这样的循环。ASP.NET Core 默认使用System.Text.Json进行 JSON 序列化。因此,我们需要配置System.Text.Json框架以忽略循环。

  2. 打开Program.cs文件,并在builder.Services.AddControllers()中添加以下代码:

    builder.Services    .AddControllers()    .AddJsonOptions(options =>    {        options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;    });
    

    另一种修复异常的方法是在InvoiceItem类中用[JsonIgnore]属性装饰Invoice属性。但如果你有许多具有这种关系的实体,装饰所有这些实体会很麻烦。选择你喜欢的做法。

    此异常发生在数据保存到数据库之后。因此,如果你检查数据库,你会发现在数据库中保存了发票和发票项目:

图 6.2 – 发票项目以发票 ID 保存到数据库中

图 6.2 – 发票项目以发票 ID 保存到数据库中

什么是 System.Text.Json?

System.Text.Json是从.NET Core 3.0 开始提供的一个新的 JSON 序列化框架。它比Newtonsoft.Json更快、更高效。它也是 ASP.NET Core 3.0 及以后版本的默认 JSON 序列化框架。建议在新项目中使用System.Text.Json而不是Newtonsoft.Json

从前面的例子中,你可以看到以下这些点:

  • EF Core 如果模型中未定义,将为主实体生成一个Id属性。

  • EF Core 如果模型中未定义,将为从属实体生成一个Id属性。

  • EF Core 在模型中未定义的情况下,为从属实体生成一个外键属性,在这个例子中是InvoiceId

  • 当主实体被添加到数据库时,从属实体也会自动添加到数据库中。你不需要显式添加从属实体。

那么,如果你想要向现有发票中添加一个新的发票项目,你可以通过两种方式来完成:

  • 首先获取发票,然后将新的发票项目添加到发票的InvoiceItems集合中,然后调用SaveChanges()方法将更改保存到数据库。这是发票的Update操作,意味着它应该是一个PUT操作。

  • 创建一个新的发票项目,将InvoiceId属性设置为发票的Id属性,然后调用SaveChanges()方法将更改保存到数据库。这是发票项目的Create操作,意味着它应该是一个POST操作。此外,你需要为发票项目单独提供一个端点。

一张发票项不能脱离发票而存在。因此,通常情况下,您会通过发票与发票项进行交互。从实际的角度来看,如果依赖实体的数量不是很大,第一种方式更为常见。然而,这取决于您的场景。如果主要实体有大量的依赖实体,更新整个主要实体可能既低效又昂贵。在这种情况下,您可以公开一个单独的端点来操作依赖实体。例如,一篇博客文章可能有大量的评论。向博客文章添加新评论是常见的,但更新整个博客文章和其他评论并不是必要的。这与另一个概念有关,即领域驱动设计DDD),它是对领域对象及其关系的建模。我们将在后面的章节中讨论它。

查询数据

现在我们已经在数据库中有了发票和一些发票项,我们可以向/api/Invoices端点发送一个GET请求。您可以看到以下响应:

[  {
    "id": "a224e90a-c01c-499b-7a9b-08dae9f04218",
    "invoiceNumber": "INV-004",
    "contactName": "Hulk",
    "description": "Invoice for the first month",
    "amount": 300,
    "invoiceDate": "2022-12-28T01:39:42.915+00:00",
    "dueDate": "2022-12-28T01:39:42.915+00:00",
    "status": 1,
    "invoiceItems": []
  },
  ...
]

响应包含发票列表。但InvoiceItems属性为空。这是因为InvoiceItems属性是一个集合导航属性。默认情况下,EF Core 不会在查询结果中包含依赖实体,因此您需要显式地将这些包含在查询结果中。按照以下步骤从数据库查询发票和发票项:

  1. 打开InvoicesController.cs文件,并将GetInvoices()方法的代码更新如下:

    // GET: api/Invoices[HttpGet]public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(int page = 1, int pageSize = 10,    InvoiceStatus? status = null){    // Omitted for brevity    return await context.Invoices        .Include(x => x.InvoiceItems)        .Where(x => status == null || x.Status == status)        .OrderByDescending(x => x.InvoiceDate)        .Skip((page - 1) * pageSize)        .Take(pageSize)        .ToListAsync();}
    

    在前面的代码中,我们使用Include方法将依赖实体包含在查询结果中。

  2. 重新启动应用程序并再次发送相同的请求。现在,您将看到结果包括发票项,如下所示:

    [  {    "id": "a224e90a-c01c-499b-7a9b-08dae9f04218",    "invoiceNumber": "INV-004",    "contactName": "Hulk",    "description": "Invoice for the first month",    "amount": 300,    "invoiceDate": "2022-12-28T01:39:42.915+00:00",    "dueDate": "2022-12-28T01:39:42.915+00:00",    "status": 1,    "invoiceItems": [      {        "id": "8cc52722-5b99-4d0c-07ef-08dae9f04223",        "name": "Invoice Item 1",        "description": "",        "unitPrice": 100,        "quantity": 2,        "amount": 200,        "invoiceId": "a224e90a-c01c-499b-7a9b-08dae9f04218",        "invoice": null      },      {        "id": "2d3f739a-2280-424b-07f0-08dae9f04223",        "name": "Invoice Item 2",        "description": "",        "unitPrice": 50,        "quantity": 2,        "amount": 100,        "invoiceId": "a224e90a-c01c-499b-7a9b-08dae9f04218",        "invoice": null      }    ]  },  ...]
    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (35ms) [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']      SELECT [t].[Id], [t].[Amount], [t].[ContactName], [t].[Description], [t].[DueDate], [t].[InvoiceDate], [t].[InvoiceNumber], [t].[Status], [i0].[Id], [i0].[Amount], [i0].[Description], [i0].[InvoiceId], [i0].[Name], [i0].[Quantity], [i0].[UnitPrice]      FROM (          SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]          FROM [Invoices] AS [i]          ORDER BY [i].[InvoiceDate] DESC          OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY      ) AS [t]      LEFT JOIN [InvoiceItems] AS [i0] ON [t].[Id] = [i0].[InvoiceId]      ORDER BY [t].[InvoiceDate] DESC, [t].[Id]
    

    如您所见,当 LINQ 查询使用Include()方法包含依赖实体时,EF Core 将生成一个LEFT JOIN查询。

重要提示

Include()方法是一个方便的方法来包含依赖实体。然而,当依赖实体的集合很大时,它可能会引起性能问题。例如,一个帖子可能有数百或数千条评论。在列表页的查询结果中包含所有评论并不是一个好主意。在这种情况下,没有必要在查询中包含依赖实体。

  1. 注意,查询在结果中的每一行都包含了Invoice数据。对于某些场景,它可能会引起所谓的AsSplitQuery()方法,如下所示:

    [HttpGet]public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(int page = 1, int pageSize = 10,    InvoiceStatus? status = null){    // Omitted for brevity    return await context.Invoices        .Include(x => x.InvoiceItems)        .Where(x => status == null || x.Status == status)        .OrderByDescending(x => x.InvoiceDate)        .Skip((page - 1) * pageSize)        .Take(pageSize)        .AsSplitQuery()        .ToListAsync();}
    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      FROM (          SELECT [i].[Id], [i].[InvoiceDate]      Executed DbCommand (2ms) [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]      FROM [Invoices] AS [i]      ORDER BY [i].[InvoiceDate] DESC, [i].[Id]      OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLYinfo: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (2ms) [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']      SELECT [i0].[Id], [i0].[Amount], [i0].[Description], [i0].[InvoiceId], [i0].[Name], [i0].[Quantity], [i0].[UnitPrice], [t].[Id]      FROM (          SELECT [i].[Id], [i].[InvoiceDate]          FROM [Invoices] AS [i]          ORDER BY [i].[InvoiceDate] DESC          OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY      ) AS [t]      INNER JOIN [InvoiceItems] AS [i0] ON [t].[Id] = [i0].[InvoiceId]      ORDER BY [t].[InvoiceDate] DESC, [t].[Id]
    

    查询包含两个SELECT语句。第一个SELECT语句用于查询发票。第二个SELECT语句用于查询发票项。INNER JOIN查询用于连接这两个查询。

  2. 您也可以通过在DbContext类的OnConfiguring()方法中使用UseQuerySplittingBehavior()方法来全局配置默认查询拆分行为。以下代码显示了如何在SampleDbContext类中将默认查询拆分行为配置为SplitQuery

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder){    base.OnConfiguring(optionsBuilder);    optionsBuilder.UseSqlServer(_configuration.GetConnectionString("DefaultConnection"),        b => b.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));}
    

    在这种情况下,你不需要在你的 LINQ 查询中使用 AsSplitQuery() 方法。如果你想在一个查询中执行特定的查询,你可以使用 AsSingleQuery() 方法,如下所示:

    [HttpGet]public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(int page = 1, int pageSize = 10,    InvoiceStatus? status = null){    // Omitted for brevity    return await _context.Invoices        .Include(x => x.InvoiceItems)        .Where(x => status == null || x.Status == status)        .OrderByDescending(x => x.InvoiceDate)        .Skip((page - 1) * pageSize)        .Take(pageSize)        .AsSingleQuery()        .ToListAsync();}
    

然而,拆分查询可能会导致其他问题。例如,多个查询会增加数据库往返次数的数量。此外,如果另一个线程在两个查询之间修改了数据,结果可能不一致。因此,你应该考虑拆分查询的优缺点,以适应你的场景。

检索数据

接下来,让我们看看如何通过 ID 检索数据。在 GetInvoice 动作中,我们使用 await _context.Invoices.FindAsync(id) 通过其 ID 查找发票。向 /api/Invoices/{id} 端点发送一个有效的 Get 请求。你将看到响应包含一个空的 InvoiceItems 数组。这是因为查询中没有包含 InvoiceItems 属性。要包含 InvoiceItems 属性在查询中,你可以在 LINQ 查询中使用 Include 方法。以下代码展示了如何使用 Include 方法将 InvoiceItems 属性包含在查询中:

[HttpGet("{id}")]public async Task<ActionResult<Invoice>> GetInvoice(int id)
{
    var invoice = await context.Invoices
        .Include(x => x.InvoiceItems)
        .SingleOrDefaultAsync(x => x.Id == id);
    if (invoice == null)
    {
        return NotFound();
    }
    return invoice;
}

生成的 SQL 查询如下:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (4ms) [Parameters=[@__id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
      SELECT [i0].[Id], [i0].[Amount], [i0].[Description], [i0].[InvoiceId], [i0].[Name], [i0].[Quantity], [i0].[UnitPrice], [t].[Id]
      FROM (
          SELECT TOP(1) [i].[Id]
          FROM [Invoices] AS [i]
          WHERE [i].[Id] = @__id_0
      ) AS [t]
      INNER JOIN [InvoiceItems] AS [i0] ON [t].[Id] = [i0].[InvoiceId]
      ORDER BY [t].[Id]

查询包含两个 SELECT 语句,并且使用 INNER JOIN 查询将两个语句连接起来。这样,你可以在单个查询中检索发票和发票项目。

删除数据

一对一配置 部分,我们介绍了如何配置 OnDelete 动作,将 DeleteBehavior 枚举设置为 CascadeDeleteBehavior 枚举还有其他选项。考虑以下一对一关系中的场景:

  • 一个发票有一系列发票项目

  • 用户删除一个发票

在此情况下,当删除发票时,你可能想要删除相关的发票项目,因为一个发票项目不能在没有发票的情况下存在。这种行为被称为级联删除。要删除数据,请按照以下步骤操作:

  1. 运行应用程序并向 /api/Invoices/{id} 端点发送一个有效的 Delete 请求。你将看到发票和相关发票项目从数据库中删除。请注意,如果 OnDelete() 方法配置为 CascadeClientCascade,则在使用 LINQ 查询中的 Include() 方法加载相关实体时不需要。级联删除行为在数据库级别应用。你可以在这里看到生成的 SQL 查询,它仅删除 Invoice 实体:

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (9ms) [Parameters=[@__id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']      DELETE FROM [i]      FROM [Invoices] AS [i]      WHERE [i].[Id] = @__id_0
    

    然而,在某些场景中,你可能希望在删除主实体时保留依赖实体,例如:

    • 一个类别有一系列博客文章

    • 用户删除一个类别

    当删除类别时,没有必要删除博客文章,因为博客文章可以在没有类别的情况下存在,并且可以被分配给另一个类别。然而,如果删除类别,博客文章的外键属性 CategoryId 将不再匹配任何类别的主键。因此,你可能希望在删除类别时将 CategoryId 属性设置为 null。这种行为被称为 CategoryId 属性是可空的。如果博客文章实体的 CategoryId 属性不可空,当你尝试删除类别时,EF Core 将抛出异常,因为它将违反外键约束。

  2. 在示例代码中,有一个此类情况的示例。你可以在 Models 文件夹中找到 CategoryPost 类。与 InvoiceInvoiceItem 类类似,它们有一个一对一的关系。然而,Post 类中的 CategoryId 属性是可空的。因此,当删除类别时,你可以将 DeleteBehavior 设置为 ClientSetNull 以使 CategoryId 属性为空。

    以下代码展示了如何将 DeleteBehavior 配置为 ClientSetNull

    public class PostConfiguration : IEntityTypeConfiguration<Post>{    public void Configure(EntityTypeBuilder<Post> builder)    {        builder.ToTable("Posts");        // Omitted for brevity        builder.Property(p => p.CategoryId).HasColumnName("CategoryId");        builder.HasOne(p => p.Category)            .WithMany(c => c.Posts)            .HasForeignKey(p => p.CategoryId)            .OnDelete(DeleteBehavior.ClientSetNull);    }}
    

    OnDelete() 方法中,你可以传递 DeleteBehavior 枚举来将 DeleteBehavior 设置为 ClientSetNullClientSetNull 的值表示当主实体被删除时,外键属性将被设置为 null

  3. CategoriesController 类中,你可以找到 DeleteCategory() 方法。它与 InvoicesController 类中的 DeleteInvoice() 方法类似。唯一的区别是我们需要在删除类别之前移除类别和博客文章之间的关系。以下代码展示了如何移除类别和博客文章之间的关系:

    var category = await context.Categories.Include(x => x.Posts).SingleOrDefaultAsync(x => x.Id == id);if (category == null){    return NotFound();}category.Posts.Clear();// Or you can update the posts to set the category to null// foreach (var post in category.Posts)// {//     post.Category = null;// }context.Categories.Remove(category);await context.SaveChangesAsync();
    

    你可以清除类别实体的 Posts 属性,或者你可以更新博客文章的 Category 属性将其设置为 null。这样,当删除类别时,博客文章的 CategoryId 属性将被设置为 null。此外,使用 Include 方法加载相关实体是必需的,因为 EF Core 需要跟踪相关实体的更改。

  4. 运行应用程序并向 /api/Categories/{id} 端点发送一个有效的 ID 的 Delete 请求。检查数据库,你会发现该类别已被删除,但博客文章并未被删除。相反,博客文章的 CategoryId 属性被设置为 NULL

图 6.3 – 当类别被删除时,博客文章的 CategoryId 属性被设置为 NULL

图 6.3 – 当类别被删除时,博客文章的 CategoryId 属性被设置为 NULL

  1. 检查生成的 SQL 查询,你会发现 EF Core 执行了两个 SQL 查询。第一个查询是将博客文章的 CategoryId 属性更新为 null。第二个查询是删除类别。生成的 SQL 查询如下:

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (6ms) [Parameters=[@p1='?' (DbType = Guid), @p0='?' (DbType = Guid), @p2='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']      SET NOCOUNT ON;      UPDATE [Posts] SET [CategoryId] = @p0      OUTPUT 1      WHERE [Id] = @p1;      DELETE FROM [Categories]      OUTPUT 1      WHERE [Id] = @p2;
    

    这意味着现在帖子没有分类,也就是说分类和博客文章之间的关系已经被移除。你可以将博客文章分配到另一个分类中,以根据你的业务逻辑重新创建这种关系。

在删除具有关系的实体时,了解其后果是很重要的。请记住,某些数据库可能不支持级联删除。因此,DeleteBehavior 枚举包含很多值,以便你在删除实体时进行微调。通常,建议使用 ClientCascadeClientSetNull,因为 EF Core 可以在数据库不支持级联删除的情况下执行级联删除或置为空操作。

到目前为止,我们已经学习了如何配置一对多关系以及如何为具有一对多关系的实体实现 CRUD 操作。接下来,让我们继续学习另一种类型的关系:一对一关系。

理解一对一关系

一对一关系意味着一个实体只与另一个类型的单个实体有关联。例如,一辆自行车需要一个锁,这个锁只能用于那辆特定的自行车。同样,一个人只能拥有一个驾驶证,这个驾驶证仅供他们使用。在我们的示例代码中,一个 Contact 实体只有一个 Address 实体,一个 Address 实体只属于一个 Contact 实体。在前一节中,你学习了如何使用 HasOne()/WithMany()HasMany()/WithOne() 方法配置一对多关系。在本节中,你将学习如何使用 HasOne()WithOne() 方法配置一对一关系。

一对一配置

在一对一关系中,双方都有一个引用导航属性。技术上,双方处于平等的位置。然而,为了显式配置关系,我们需要指定哪一方是依赖方,哪一方是主方。外键属性通常定义在依赖方。在以下示例中,我们将配置 Contact 类和 Address 类之间的一对一关系:

public class Contact{
    public Guid Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string? Title { get; set; }
    public string Email { get; set; } = string.Empty;
    public string Phone { get; set; } = string.Empty;
    public Address Address { get; set; }
}
public class Address
{
    public Guid Id { get; set; }
    public string Street { get; set; } = string.Empty;
    public string City { get; set; } = string.Empty;
    public string State { get; set; } = string.Empty;
    public string ZipCode { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
    public Guid ContactId { get; set; }
    public Contact Contact { get; set; }
}

在前面的代码中,Address 类中定义了一个 ContactId 外键,这意味着 Address 类是依赖实体,而 Contact 类是主实体。如果你在这里没有定义外键属性,EF Core 将会自动选择一个实体作为依赖实体。然而,由于 ContactAddress 在一对一关系中是平等的,EF Core 可能不会选择我们期望的正确实体。因此,我们需要在依赖实体中显式定义一个外键属性。

一对一关系的配置如下:

public class ContactConfiguration : IEntityTypeConfiguration<Contact>{
    public void Configure(EntityTypeBuilder<Contact> builder)
    {
        builder.ToTable("Contacts");
        builder.HasKey(c => c.Id);
        // Omitted for brevity
        builder.Property(c => c.Phone).IsRequired();
    }
}
public class AddressConfiguration : IEntityTypeConfiguration<Address>
{
    public void Configure(EntityTypeBuilder<Address> builder)
    {
        builder.ToTable("Addresses");
        builder.HasKey(a => a.Id);
        // Omitted for brevity
        builder.Ignore(a => a.Contact);
        builder.HasOne(a => a.Contact)
            .WithOne(c => c.Address)
            .HasForeignKey<Address>(a => a.ContactId);
    }
}

之前的代码使用 HasOne/WithOne 来定义一对一关系。这可以在 Contact 配置或 Address 配置中定义。使用 HasForeignKey 方法来指定外键属性。如果您想在 Contact 配置中定义关系,代码可能如下所示:

builder.HasOne(c => c.Address)    .WithOne(a => a.Contact)
    .HasForeignKey<Address>(a => a.ContactId);

运行以下代码以添加迁移并更新数据库:

dotnet ef migrations add AddContactAndAddressdotnet ef database update

您将看到以下代码在 Addresses 表上创建了一个 ContactId 外键:

migrationBuilder.CreateTable(    name: "Addresses",
    columns: table => new
    {
        Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        // Omitted for brevity
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Addresses", x => x.Id);
        table.ForeignKey(
            name: "FK_Addresses_Contacts_ContactId",
            column: x => x.ContactId,
            principalTable: "Contacts",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
    });

在迁移应用后,联系人地址之间的关系配置成功,如图 6.4 所示。4*:

图 6.4 – 在  表上创建了一个  外键

图 6.4 – 在 Addresses 表上创建了一个 ContactId 外键

接下来,让我们看看如何实现具有一对一关系的实体的 CRUD 操作。

一对一 CRUD 操作

一对一关系的 CRUD 操作与一对多关系的 CRUD 操作类似。EF Core 可以为您简化 CRUD 操作。因此,在本节中,我们不会详细解释所有 CRUD 操作。您将在示例仓库中找到一个名为 ContactsController.cs 的控制器,该控制器实现了 Contact 实体的 CRUD 操作。您可以检查代码以获取详细信息。

ContactAddress 实体之间存在一对一关系,这意味着每个 Contact 实体有一个 Address 属性,每个 Address 实体只属于一个 Contact 属性。为了说明如何查询带有其地址的联系人,我们将使用以下示例:

  1. 要创建一个新的联系人及其地址,您可以向 api/contacts 端点发送 POST 请求。请求体如下所示:

    {    "firstName": "John",    "lastName": "Doe",    "email": "john.doe@example.com",    "phone": "1234567890",    "address": {        "street": "123 Main St",        "city": "Wellington",        "state": "Wellington",        "zipCode": "6011",        "country": "New Zealand"    }}
    

    在 JSON 请求体中,address 对象是 Contact 对象的一个属性。在请求体中不需要发送 ContactId 属性。EF Core 会自动将 ContactId 属性设置为 Contact 对象的 Id 属性。

  2. 同样,当您通过 api/contacts 端点查询联系人时,默认情况下不会在响应体中包含 Address 对象。您需要显式使用 Include 方法将 Address 对象包含在查询中,如下面的代码所示:

    // GET: api/Contacts[HttpGet]public async Task<ActionResult<IEnumerable<Contact>>> GetContacts(){    if (context.Contacts == null)    {        return NotFound();    }    return await context.Contacts.Include(x => x.Address).ToListAsync();}
    

您可以检查 ContactsController.cs 文件中的其他 CRUD 操作并在 Postman 或您喜欢的任何 REST 客户端中测试它们。

我们已经探讨了两种关系类型:一对多和一对一。现在,让我们深入了解另一种关系类型:多对多。

理解多对多关系

多对多关系是指一个实体可以与多个实体相关联,反之亦然。例如,一部电影可以有多个演员,一个演员可以出演多部电影;一篇文章可以有多个标签,一个标签可以有多个文章;一个学生可以报名多门课程,一门课程可以有多个学生,等等。在本节中,我们将介绍如何在 EF Core 中配置多对多关系。

多对多配置

在多对多关系中,我们需要在两边定义一个集合导航属性。以下是一个 Movie 实体和 Actor 实体之间多对多关系的示例:

public class Movie{
    public Guid Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string? Description { get; set; }
    public int ReleaseYear { get; set; }
    public List<Actor> Actors { get; set; } = new List<Actor>();
}
public class Actor
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public List<Movie> Movies { get; set; } = new List<Movie>();
}

EF Core 可以根据约定自动检测多对多关系。如果你运行 dotnet ef migrations add AddMovieAndActor 命令来添加迁移,你将在迁移文件中看到以下代码:

migrationBuilder.CreateTable(    name: "ActorMovie",
    columns: table => new
    {
        ActorsId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        MoviesId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_ActorMovie", x => new { x.ActorsId, x.MoviesId });
        table.ForeignKey(
            name: "FK_ActorMovie_Actors_ActorsId",
            column: x => x.ActorsId,
            principalTable: "Actors",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
        table.ForeignKey(
            name: "FK_ActorMovie_Movies_MoviesId",
            column: x => x.MoviesId,
            principalTable: "Movies",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
    });

除了创建 MoviesActors 表的代码外,迁移文件还创建了一个名为 ActorMovie 的连接表来存储两边的外键。ActorMovie 表有两个外键属性,ActorsIdMoviesId,用于关联 Actor 实体和 Movie 实体。

然而,有时自动检测的多对多关系可能不符合我们的要求。例如,我们可能希望将表名称为 MovieActor 而不是 ActorMovie,我们可能希望指定外键属性为 ActorIdMovieId 而不是 ActorsIdMoviesId,或者我们甚至可能希望向连接表添加一些额外的属性。在这些情况下,我们可以显式配置多对多关系。

首先,我们需要定义一个连接实体来存储两边的键。以下是一个名为 MovieActor 的连接实体的示例:

public class MovieActor{
    public Guid MovieId { get; set; }
    public Movie Movie { get; set; } = null!;
    public Guid ActorId { get; set; }
    public Actor Actor { get; set; } = null!;
    public DateTime UpdateTime { get; set; }
}

此外,我们还需要向 MovieActor 实体添加一个集合导航属性:

public class Movie{
    public Guid Id { get; set; }
    // Omitted other properties
    public List<MovieActor> MovieActors { get; set; } = new ();
}
public class Actor
{
    public Guid Id { get; set; }
    // Omited other properties
    public List<MovieActor> MovieActors { get; set; } = new ();
}

然后,我们使用 HasMany()/WithMany() 方法在 Movie 配置中配置多对多关系,如下所示:

public class MovieConfiguration : IEntityTypeConfiguration<Movie>{
    public void Configure(EntityTypeBuilder<Movie> builder)
    {
        // Omitted for brevity
        builder.HasMany(m => m.Actors)
            .WithMany(a => a.Movies)
            .UsingEntity<MovieActor>(
                j => j
                    .HasOne(ma => ma.Actor)
                    .WithMany(a => a.MovieActors)
                    .HasForeignKey(ma => ma.ActorId),
                j => j
                    .HasOne(ma => ma.Movie)
                    .WithMany(m => m.MovieActors)
                    .HasForeignKey(ma => ma.MovieId),
                j =>
                {
                    // You can add more configuration here
                    j.Property(ma => ma.UpdateTime).HasColumnName("UpdateTime").HasDefaultValueSql("CURRENT_TIMESTAMP");
                    j.HasKey(ma => new { ma.MovieId, ma.ActorId });
                }
            );
    }
}

类似地,配置也可以添加到 Actor 配置中。添加配置后,运行 dotnet ef migrations add AddMovieAndActor 命令来添加迁移;你将在迁移文件中看到以下代码:

migrationBuilder.CreateTable(    name: "MovieActor",
    columns: table => new
    {
        MovieId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        ActorId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
        UpdateTime = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP")
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_MovieActor", x => new { x.MovieId, x.ActorId });
        table.ForeignKey(
            name: "FK_MovieActor_Actors_ActorId",
            column: x => x.ActorId,
            principalTable: "Actors",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
        table.ForeignKey(
            name: "FK_MovieActor_Movies_MovieId",
            column: x => x.MovieId,
            principalTable: "Movies",
            principalColumn: "Id",
            onDelete: ReferentialAction.Cascade);
    });

你可以看到连接表已被重命名为 MovieActor,外键属性也被重命名为 MovieIdActorId。此外,连接表中还添加了 UpdateTime 属性。

迁移应用后,你可以在数据库中看到连接表:

图 6.5 – 数据库中的连接表

图 6.5 – 数据库中的连接表

在 EF Core 5.0 之前,配置多对多关系的另一种方式是使用连接实体来表示两个单独的一对多关系。以下是一个为 MovieActor 实体配置多对多关系的示例:

public class MovieActorsConfiguration : IEntityTypeConfiguration<MovieActor>{
    public void Configure(EntityTypeBuilder<MovieActor> builder)
    {
        builder.ToTable("MovieActors");
        builder.HasKey(sc => new { sc.MovieId, sc.ActorId });
        builder.HasOne(sc => sc.Actor)
            .WithMany(s => s.MovieActors)
            .HasForeignKey(sc => sc.ActorId);
        builder.HasOne(sc => sc.Movie)
            .WithMany(c => c.MovieActors)
            .HasForeignKey(sc => sc.MovieId);
    }
}

在前面的代码中,我们在MovieActor连接实体上为电影演员实体配置了两个一对一关系。每个一对一关系都使用HasMany()WithMany()ForeignKey()方法来配置关系。这种一对一关系的组合创建了一个多对多关系。

你可以使用任何一种方式来配置多对多关系。HasMany()/WithMany()方法更方便且易于使用。

多对多 CRUD 操作

在多对多关系中,例如电影演员,我们可能需要获取电影的演员或获取演员的电影。因此,我们需要通过 REST API 公开这两个实体。你可以使用以下命令创建两个控制器:

dotnet-aspnet-codegenerator controller -name MoviesController -api -outDir Controllers --model Movie --dataContext SampleDbContext -async -actionsdotnet-aspnet-codegenerator controller -name ActorsController -api -outDir Controllers --model Actor --dataContext SampleDbContext -async -actions

运行应用程序并创建一些电影和演员。

在创建电影时,我们可以包括电影的演员。例如,我们可以使用以下 JSON 有效载荷格式创建一个包含几个演员的电影:

{    "title": "The Shawshank Redemption",
    "releaseYear": "1994",
    "actors": [
        {
            "name": "Tim Robbins"
        },
        {
            "name": "Morgan Freeman"
        },
        {
            "name": "Bob Gunton"
        },
        {
            "name": "William Sadler"
        }
    ]
}

你将在数据库中看到以下结果:

图 6.6 – 连接表已填充

图 6.6 – 连接表已填充

同样,你还可以在创建演员时包括演员的电影。然而,如果我们任意包含相关实体,我们可能会得到重复实体。例如,我们使用以下 JSON 有效载荷格式创建一个包含几个电影的演员:

{    "name": "Tim Robbins",
    "movies": [
        {
            "title": "The Shawshank Redemption",
            "releaseYear": "1994"
        },
        {
            "title": "Green Mile",
            "releaseYear": "1999"
        }
    ]
}

因此,数据库中可能会有两个同名电影。为了避免这种情况,有一些选项:

  • 电影实体的标题属性添加唯一索引以确保标题唯一。这是最简单的解决方案,可以防止重复实体被添加到数据库中。

  • 在添加之前检查实体是否已存在于数据库中。

  • 分别添加电影和演员,然后使用其他实体的 ID 更新电影或演员以包括其他实体,而不是整个实体。

你可以使用前面选项的组合来改进实现。要向电影实体的标题属性添加唯一索引,你可以更新MovieConfiguration类的以下代码:

public void Configure(EntityTypeBuilder<Movie> builder){
    builder.ToTable("Movies");
    builder.HasKey(m => m.Id);
    builder.Property(p => p.Title).HasColumnName("Title").HasMaxLength(128).IsRequired();
    // Add a unique index to the Title property
    builder.HasIndex(p => p.Title).IsUnique();
    // Omitted for brevity
}

你可以对演员实体的名称属性进行相同的更改。更改后,你需要创建一个新的迁移并将其应用到数据库中。这有助于在数据库级别防止重复实体。如果请求包含重复实体,数据库将抛出异常。

有可能在同一个请求中添加或更新与主实体相关的实体。但有时,这可能不是必要的。例如,一个演员只参与了一部新电影,你想要创建一个新电影并将演员添加到电影中。你可以更新演员以包括新电影,但必须发送整个演员实体到请求中,包括现有的电影。这会导致不必要的数据传输。

为了使 API 更易于使用,仅暴露一个额外的 API 端点来更新相关实体的集合,而不是更新整个实体,这是一种实用主义的方法。例如,我们可以创建一个 /api/actors/{id}/movies 端点来更新演员的电影。避免在同一个请求中更新相关实体的集合是一种良好的做法。我们只需向 API 端点发送相关实体的 ID 即可。从 API 的角度来看,这种关系被视为一个资源。在 ActorsController.cs 文件中,您将找到以下代码:

[HttpPost("{id}/movies/{movieId}")]public async Task<IActionResult> AddMovie(Guid id, Guid movieId)
{
    if (_context.Actors == null)
    {
        return NotFound("Actors is null.");
    }
    var actor = await _context.Actors.Include(x => x.Movies).SingleOrDefaultAsync(x => x.Id == id);
    if (actor == null)
    {
        return NotFound($"Actor with id {id} not found.");
    }
    var movie = await _context.Movies.FindAsync(movieId);
    if (movie == null)
    {
        return NotFound($"Movie with id {movieId} not found.");
    }
    if (actor.Movies.Any(x => x.Id == movie.Id))
    {
        return Problem($"Movie with id {movieId} already exists for Actor {id}.");
    }
    actor.Movies.Add(movie);
    await _context.SaveChangesAsync();
    return CreatedAtAction("GetActor", new { id = actor.Id }, actor);
}
[HttpGet("{id}/movies")]
public async Task<IActionResult> GetMovies(Guid id)
{
    if (_context.Actors == null)
    {
        return NotFound("Actors is null.");
    }
    var actor = await _context.Actors.Include(x => x.Movies).SingleOrDefaultAsync();
    if (actor == null)
    {
        return NotFound($"Actor with id {id} not found.");
    }
    return Ok(actor.Movies);
}
[HttpDelete("{id}/movies/{movieId}")]
public async Task<IActionResult> DeleteMovie(Guid id, Guid movieId)
{
    if (_context.Actors == null)
    {
        return NotFound("Actors is null.");
    }
    var actor = await _context.Actors.Include(x => x.Movies).SingleOrDefaultAsync();
    if (actor == null)
    {
        return NotFound($"Actor with id {id} not found.");
    }
    var movie = await _context.Movies.FindAsync(movieId);
    if (movie == null)
    {
        return NotFound($"Movie with id {movieId} not found.");
    }
    actor.Movies.Remove(movie);
    await _context.SaveChangesAsync();
    return NoContent();
}

上述代码暴露了一些端点,用于添加、获取和删除演员的电影。您可以使用前面代码片段中显示的 JSON 负载格式测试这些端点:

  • 要向演员添加电影,向 /api/actors/{id}/movies/{movieId} 端点发送一个 POST 请求。AddMovie 动作将检查电影是否已经在数据库中存在。如果存在,它将检查电影是否已经在演员的电影中存在。如果没有,它将电影添加到集合中,并将更改保存到数据库中。

  • 要获取一个演员的电影,向 /api/actors/{id}/movies 端点发送一个 GET 请求。GetMovies 动作将返回该演员的电影。此端点可以更新以支持分页、排序和过滤。

  • 要从演员中删除电影,向 /api/actors/{id}/movies/{movieId} 端点发送一个 DELETE 请求。DeleteMovie 动作将从集合中删除电影,并将更改保存到数据库中。请注意,它不会从数据库中删除电影;它只是删除了电影和演员之间的关系。

还可以将类似的端点添加到 MoviesController.cs 文件中,以更新电影中的演员。您可以使用相同的方法实现端点。试试看吧!

重要提示

当您调用 /api/actors 端点时,您可能会发现响应中包含 MovieActors。这对于客户端来说并不有用。您可以使用 JsonIgnore 属性在序列化响应时忽略该属性。

我们现在已经讨论了三种常见的关系类型:一对一、一对多和多对多。您现在应该很好地理解了如何配置关系以及为具有关系的实体实现 CRUD 操作。让我们继续讨论下一个主题:拥有的实体类型。

理解拥有的实体

在前面的章节中,我们学习了一些关系是可选的,但有些是必需的。例如,一篇文章可以没有分类存在,但学生身份证不能没有学生存在。对于后者,我们可以说学生拥有身份证。同样,联系人拥有地址。我们还可以找到一些一对多关系的例子。例如,发票拥有多个发票项,因为发票项不能没有发票存在。在本节中,我们将介绍拥有的实体的概念。

所属实体类型是所有者的一部分,没有所有者就无法存在。你可以使用常见的单一到单一或单一到多对关系来建模所属实体,但 EF Core 提供了一个更方便的方法,称为所属实体类型。你可以使用OwnsOne()OwnsMany()方法来定义所属实体类型,而不是使用HasOne()HasMany()方法。例如,要将InvoiceItem实体配置为Invoice实体的所属实体类型,你可以使用以下代码:

public class InvoiceConfiguration : IEntityTypeConfiguration<Invoice>{
    public void Configure(EntityTypeBuilder<Invoice> builder)
    {
        // Omitted for brevity
        // Use the owned type to configure the InvoiceItems collection
        builder.OwnsMany(p => p.InvoiceItems, a =>
            {
                a.WithOwner( => x.Invoice).HasForeignKey(x => x.InvoiceId);
                a.ToTable("InvoiceItems");
                // Omitted for brevity
            }
        );
    }
}

如前述代码所示,你可以使用OwnsMany()/WithOwner()方法来配置所属实体类型。OwnsMany()/WithOwner()方法指定所属实体类型的所有者。HasForeignKey()方法指定所属实体类型的外键属性。InvoiceItem实体的配置存储在InvoiceConfiguration类中。

同样,Address实体的配置可以像这样存储在ContactConfiguration类中:

public class ContactConfiguration : IEntityTypeConfiguration<Contact>{
    public void Configure(EntityTypeBuilder<Contact> builder)
    {
        // Omitted for brevity
        // Use owned entity type
        builder.OwnsOne(c => c.Address, a =>
        {
            a.WithOwner(x => x.Contact);
            a.Property(a => a.Street).HasColumnName("Street").HasMaxLength(64).IsRequired();
            // Omitted for brevity
        });
    }
}

当你使用OwnsOne()/WithOwner()方法时,你不需要指定外键属性,因为所属实体类型默认将存储在与所有者相同的表中。你可以使用ToTable方法来指定所属实体类型的表名。

那么,正常的一对一或多对一与所属实体类型之间有什么区别?有一些区别:

  • 你不能为所属实体类型创建DbSet<T>属性。你只能使用所有者的DbSet<T>属性。这意味着你没有直接访问所属实体类型的方法。你必须通过所有者来访问所属实体类型。

  • 当你查询所有者时,所属实体类型将自动包含。你不需要使用Include()方法显式包含所属实体类型。所以,如果所有者有多个所属实体,请务必小心,这可能会引起性能问题。

如果你的实体具有简单的单一到单一或单一到多对关系,并且数据量不大,你可以使用所属实体类型来简化配置。然而,如果关系复杂且数据量较大,使用正常的单一到单一或单一到多对关系会更好,因为你可以显式决定包含哪些相关实体。

摘要

在这一章中,我们深入探讨了 EF Core 中实体之间关系的建模。我们探讨了各种常见的关系类型,包括一对一、一对多和多对多关系。我们学习了如何使用HasOne()/WithMany()HasMany()/WithOne()HasMany()/WithMany()等基本方法来配置这些关系。为了拓宽我们的理解,我们还探讨了使用OwnsOne()/WithOwner()OwnsMany/WithOwner()方法配置所属实体类型。

为了有效地操作具有关系的实体,我们解释了如何为每种关系类型实现 CRUD 操作。特别是,我们解释了级联删除操作,确保数据完整性和相关实体的有效管理。

本章学到的概念将帮助您在 ASP.NET Core 应用程序中建模实体之间的关系。在下一章中,我们将学习 EF Core 的一些高级主题,例如并发控制、性能调整等。

第七章:ASP.NET Core 中的数据访问(第三部分:技巧)

第六章 中,我们学习了如何使用 EF Core Fluent API 管理实体之间的关系。我们介绍了三种类型的关系:一对一、一对多和多对多。我们还学习了如何在相关实体上执行 CRUD 操作。凭借我们从 第六章 中获得的知识,我们现在可以为大多数 Web API 应用程序构建一个简单的数据访问层。然而,还有一些场景我们需要妥善处理。例如,我们如何提高数据访问的性能?如果存在并发冲突,我们应该怎么做?

在本章中,我们将涵盖与 ASP.NET Core 数据访问相关的一些高级主题,包括 DbContext 连接池、性能优化、原始 SQL 查询和并发冲突。我们还将讨论一些技巧和窍门,这些技巧和窍门可以帮助您编写更好的代码。

我们将涵盖以下主题:

  • DbContext 连接池

  • 跟踪与非跟踪查询

  • IQueryable 与 IEnumerable 的区别

  • 客户端与服务器评估

  • 原始 SQL 查询

  • 批量操作

  • 并发冲突

  • 反向工程

  • 其他 ORM 框架

阅读本章后,您将更深入地了解 EF Core,并能够在您的应用程序中更有效地使用它。您将学习如何使用无跟踪查询来提高查询性能,以及如何使用原始 SQL 查询来执行复杂查询。此外,您将了解如何使用批量操作来提高批量数据操作的性能。此外,您将能够处理大规模应用程序的并发冲突,并使用反向工程从现有数据库生成实体类和 DbContext 类。

技术要求

本章中的代码示例可以在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8 找到。您可以使用 VS 2022 或 VS Code 打开解决方案。

理解 DbContext 连接池

在上一章中,我们学习了如何使用 AddDbContext() 扩展方法将 DbContext 实例注册为 DI 容器中的作用域服务。默认情况下,为每个请求创建一个新的 DbContext 实例,这通常不是问题,因为它是一个轻量级对象,不消耗许多资源。然而,在高吞吐量应用程序中,为每个 DbContext 实例设置各种内部服务和对象的成本可能会累积。为了解决这个问题,EF Core 提供了一个名为 DbContext 实例重用的功能。

要启用DbContext池,您可以替换AddDbContext()方法为AddDbContextPool()方法。这将重置DbContext实例的当前状态,将其存储在池中,并在有新请求时重用。通过减少设置DbContext实例的成本,DbContext池可以显著提高您应用程序在高吞吐量场景下的性能。

您可以从本章 GitHub 仓库的/samples/chapter7/EfCoreDemo文件夹中找到本节的示例代码。

EfCoreDemo项目中打开Program.cs文件。以下代码显示了如何启用DbContext池:

services.AddDbContextPool<InvoiceDbContext>(options =>{
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"));
});

AddDbContextPool()方法接受一个poolSize参数,该参数指定可以存储在池中的最大DbContext实例数。默认值是1024,通常对大多数应用程序来说已经足够。如果池已满,EF Core 将根据需要开始创建新的DbContext实例。

为了验证DbContext池是否可以提高应用程序的性能,我们可以运行性能测试。Grafana k6是一个开源的负载测试工具,可以用来测试 Web API 的性能。要使用 k6,您需要在此处安装 NodeJS:nodejs.org/。然后您可以从k6.io/docs/get-started/installation/下载它。k6 为各种平台提供包,包括 Windows、Linux 和 macOS。在您的机器上安装 k6。

您可以在项目的k6文件夹中找到一个script.js文件。script.js文件是一个包含测试场景的 k6 脚本。以下代码显示了script.js文件的内容:

import http from 'k6/http';import { sleep } from 'k6';
export const options = {
    vus: 500,
    duration: '30s',
  };
export default function () {
  http.get('http://localhost:5249/api/Invoices?page=1&pageSize=10');
  sleep(1);
}

这是一个基本的 k6 测试脚本,运行一个 30 秒,500-VU 负载测试。在 30 秒内向/api/Invoices?page=1&pageSize=10端点发送GET请求。

首先,使用AddDbContext()方法注册DbContext,然后使用dotnet run命令运行应用程序。然后,打开一个新的终端并运行以下命令以启动 k6 测试:

k6 run script.js

接下来,使用AddDbContextPool()方法注册DbContext,并使用相同的 k6 脚本再次测试应用程序。你可以比较两次测试的结果,以查看DbContext池是否提高了应用程序的性能。例如,使用AddDbContext()方法的测试结果如下:

图 7.1 – 使用 AddDbContext()方法的测试结果

图 7.1 – 使用 AddDbContext()方法的测试结果

以下是用AddDbContextPool方法得到的结果:

图 7.2 – 使用 AddDbContextPool()方法的测试结果

图 7.2 – 使用 AddDbContextPool()方法的测试结果

当使用AddDbContext()时,平均请求时长为 1.07 秒,完成了 7,145 个请求,而使用AddDbContextPool()时,平均请求时长为 782.36 毫秒,完成了 8,530 个请求。结果显示,DbContext池化可以提高应用程序的性能。请注意,您的结果可能会根据您的机器配置而有所不同。此外,dotnet run命令用于以开发模式运行应用程序,这并不针对性能优化。因此,这个测试只是为了演示目的,并不能反映应用程序的真实性能。然而,它可以给您一个关于DbContext池化工作原理的直观理解。

重要提示

对于大多数应用程序来说,DbContext池化不是必需的。您应该只在有高吞吐量应用程序的情况下启用DbContext池化。因此,在启用DbContext池化之前,重要的是测试您的应用程序在启用和未启用池化时的性能,以查看是否有任何明显的改进。

总结来说,虽然DbContext池化可以提高高吞吐量应用程序的性能,但这并不是万能的解决方案。在决定是否启用DbContext池化之前,请务必评估您应用程序的具体需求。

理解跟踪查询与非跟踪查询的区别

在本节中,我们将讨论跟踪查询与非跟踪查询的区别。什么是跟踪查询和非跟踪查询?让我们从基础开始了解!

在.NET 的早期阶段,术语SqlHelper常用来指代一个提供一组方法来执行 SQL 查询的静态类。虽然 SqlHelper 简化了执行 SQL 查询的过程,但开发者仍然需要管理连接和事务对象,编写样板代码将结果映射到模型对象,并直接与数据库交互。

ORM框架,如 EF Core,是为了解决这些问题而创建的。它们不仅简化了执行 SQL 查询并将结果映射到模型对象的过程,还提供了跟踪查询返回的实体所做更改的能力。当更改被保存时,EF Core 会生成适当的 SQL 查询来更新数据库。这被称为跟踪,并且是使用 EF Core 等 ORM 框架的一个显著优势。

然而,跟踪是有代价的。这可能会增加开销和内存使用,尤其是在处理大量实体时。

我们在第五章中简要介绍了跟踪的相关内容。让我们来看一个跟踪的例子。您可以从本章 GitHub 仓库的/samples/chapter7/EfCoreDemo文件夹中找到本节示例代码。

在示例EfCoreDemo项目中,您可以在InvoicesController类中找到GetInvoice操作。以下代码展示了跟踪是如何工作的:

[HttpGet("{id}")]public async Task<ActionResult<Invoice>> GetInvoice(Guid id)
{
    if (context.Invoices == null)
    {
        return NotFound();
    }
    logger.LogInformation($"Invoice {id} is loading from the database.");
    var invoice = await context.Invoices.FindAsync(id);
    logger.LogInformation($"Invoice {invoice?.Id} is loaded from the database."
    logger.LogInformation($"Invoice {id} is loading from the context.");
    invoice = await context.Invoices.FindAsync(id);
    logger.LogInformation($"Invoice {invoice?.Id} is loaded from the context.")
    if (invoice == null)
    {
        return NotFound();
    }
    return invoice;
}

在前面的代码中,我们添加了一些日志语句来查看 EF Core 如何调用数据库。运行应用程序并调用 GetInvoice 动作。你将在控制台看到如下输出:

info: BasicEfCoreDemo.Controllers.InvoicesController[0]      Invoice e61436dd-0dac-4e8b-7d61-08dae88bb288 is loading from the database.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (30ms) [Parameters=[@__get_Item_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']
      SELECT TOP(1) [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE [i].[Id] = @__get_Item_0
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Invoice e61436dd-0dac-4e8b-7d61-08dae88bb288 is loaded from the database.
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Invoice e61436dd-0dac-4e8b-7d61-08dae88bb288 is loading from the context.
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Invoice e61436dd-0dac-4e8b-7d61-08dae88bb288 is loaded from the context.

当我们第一次调用 context.Invoices.FindAsync(id) 方法时,EF Core 将查询数据库并返回 Invoice 实体。第二次,EF Core 将从上下文中返回 Invoice 实体,因为 Invoice 实体已经存在于上下文中。

Find()Single() 的比较

当我们通过其主键从数据库获取实体时,我们可以使用 Find()FindAsync() 方法。我们还可以使用 Single()SingleOrDefault() 方法。它们很相似,但并不相同。Find()FindAsync() 方法是 DbSet 类的方法。如果具有给定主键值的实体被上下文跟踪,Find()FindAsync() 方法将返回被跟踪的实体,而不会向数据库发出请求。否则,EF Core 将查询数据库以获取实体,将其附加到上下文,并返回它。但是,如果你使用 Single()SingleOrDefault() 方法,EF Core 将始终查询数据库以获取实体。对于 First()FirstOrDefault() 方法也是如此。因此,Find()FindAsync() 方法在通过主键获取实体时更有效。但在罕见情况下,如果实体在加载到上下文后数据库中已更新,Find()FindAsync() 可能会返回过时数据。例如,如果你使用批量更新 ExecuteUpdateAsync() 方法,更新将不会被 DbContext 跟踪。然后,如果你使用 Find()FindAsync()DbContext 获取实体,你将得到过时数据。在这种情况下,你应该使用 Single()SingleOrDefault() 再次从数据库获取实体。在大多数情况下,当你确信实体始终被 DbContext 跟踪时,你可以使用 Find()FindAsync() 方法通过主键获取实体。

实体具有以下 EntityState 值之一:DetachedAddedUnchangedModifiedDeleted。我们介绍了 EntityState 枚举在 第五章。以下是如何改变状态:

  • 查询返回的所有实体(例如 Find()Single()First()ToList() 以及它们的 async 重载)都处于 Unchanged 状态。

  • 如果你更新实体的属性,EF Core 将状态更改为 Modified

  • 如果你在一个实体上调用 Remove() 方法,EF Core 将状态更改为 Deleted

  • 如果你在一个实体上调用 Add() 方法,EF Core 将状态更改为 Added

  • 如果你在一个未跟踪的实体上调用 Attach() 方法,EF Core 将跟踪该实体并将状态设置为 Unchanged

  • 如果你在一个被跟踪的实体上调用 Detach() 方法,EF Core 将不会跟踪该实体,并将状态更改为 Detached

注意,EF Core 可以在属性级别跟踪更改,这意味着如果你更新一个实体的属性,EF Core 只有在你调用 SaveChanges 方法时才会更新该属性。

要获取实体的 EntityEntry 对象,我们可以使用 Entry() 方法,它包含实体的状态和已更改的属性。请使用位于 /samples/chapter7/EfCoreDemo 文件夹中的 EfCoreDemo 示例项目。你可以在 InvoicesController 类中找到 PutInvoice 动作:

context.Entry(invoice).State = EntityState.Modified;await context.SaveChangesAsync();

在前面的代码片段中,我们使用 Entry() 方法获取了 Invoice 实体的 EntityEntry 对象,并将其状态设置为 Modified。当调用 SaveChanges() 时,EF Core 将更改持久化到数据库。

默认情况下,EF Core 启用跟踪。但是,可能存在你不想让 EF Core 跟踪实体更改的场景。例如,在 Get 动作中的只读查询内,DbContext 只存在于请求期间,跟踪是不必要的。禁用跟踪可以提高性能并节省内存。如果你不打算修改实体,你应该通过在查询上调用 AsNoTracking() 方法来禁用跟踪。以下是一个示例:

// To get the invoice without trackingvar invoice = await context.Invoices.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id);
// To return a list of invoices without tracking
var invoices = await context.Invoices.AsNoTracking().ToListAsync();

如果你有很多只读查询,并且觉得每次都调用 AsNoTracking() 方法很麻烦,你可以在配置 DbContext 时全局禁用跟踪。以下代码显示了如何进行此操作:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder){
    base.OnConfiguring(optionsBuilder);
    optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}

对于你想要跟踪的任何其他查询,你可以在查询上调用 AsTracking() 方法,如下面的代码所示:

// To get the invoice with trackingvar invoice = await context.Invoices.AsTracking().FirstOrDefaultAsync(x => x.Id == id);
// To return a list of invoices with tracking
var invoices = await context.Invoices.AsTracking().ToListAsync();

在前面的代码中,我们显式调用 AsTracking() 方法来启用查询的跟踪,这样我们就可以更新实体并将更改保存到数据库中。

重要提示

如果一个实体是无键实体,EF Core 将永远不会跟踪它。无键实体类型上没有定义键。它们通过 [Keyless] 数据注释或 Fluent API 的 HasNoKey() 方法进行配置。无键实体通常用于只读查询或视图。我们不会在本书中详细讨论无键实体。有关更多信息,请参阅官方文档learn.microsoft.com/en-us/ef/core/modeling/keyless-entity-types

使用无跟踪查询是提高只读场景性能的好方法。然而,请注意,如果你禁用跟踪,当你调用 SaveChanges() 方法时将无法更新实体,因为 EF Core 无法检测未跟踪实体的更改。因此,在实施之前考虑使用无跟踪查询的后果是很重要的。

除了非跟踪查询之外,还有其他因素会影响 EF Core 中数据查询的性能。我们将在下一节探讨 IQueryableIEnumerable 之间的差异以及它们如何影响查询性能。

理解 IQueryable 和 IEnumerable 之间的区别

在使用 EF Core 时,你有两个接口可用于查询数据库:IQueryableIEnumerable。尽管这些接口乍一看可能很相似,但它们之间的重要区别可能会影响应用程序的性能。在本节中,我们将讨论 IQueryableIEnumerable 之间的区别,它们的工作方式以及何时使用每个接口。

你可能熟悉 IEnumerable 接口。IEnumerable 接口是一个标准的 .NET 接口,用于表示对象的集合。它用于遍历集合。许多 .NET 集合实现了 IEnumerable 接口,例如 ListArrayDictionary 等。IEnumerable 接口有一个名为 GetEnumerator 的单一方法,它返回一个 IEnumerator 对象。IEnumerator 对象用于遍历集合。

IQueryableIEnumerable 之间的第一个区别是,IQueryable 位于 System.Linq 命名空间中,而 IEnumerable 位于 System.Collections 命名空间中。IQueryable 接口继承自 IEnumerable 接口,因此 IQueryable 可以做 IEnumerable 能做的所有事情。但为什么我们需要 IQueryable 接口呢?

IQueryableIEnumerable 之间的一个关键区别在于,IQueryable 用于从特定的数据源查询数据,例如数据库。IEnumerable 用于在内存中遍历集合。当我们使用 IQueryable 时,查询将被转换成特定的查询语言,例如 SQL,并在我们调用 ToList()(或 ToAway())方法或遍历集合中的项时,针对数据源执行以获取结果。

从章节的 GitHub 仓库 /samples/chapter7/EfCoreDemo 文件夹中下载示例代码。你可以在 InvoicesController 类中找到一个 GetInvoices 动作。

首先,让我们使用 IQueryable 接口查询数据库:

// Use IQueryablelogger.LogInformation($"Creating the IQueryable...");
var list1 = context.Invoices.Where(x => status == null || x.Status == status);
logger.LogInformation($"IQueryable created");
logger.LogInformation($"Query the result using IQueryable...");
var query1 = list1.OrderByDescending(x => x.InvoiceDate)
    .Skip((page - 1) * pageSize)
    .Take(pageSize);
logger.LogInformation($"Execute the query using IQueryable");
var result1 = await query1.ToListAsync();
logger.LogInformation($"Result created using IQueryable");

在前面的代码中,context.Invoices 是一个实现 IQueryable 接口的 DbSet<TEntity> 对象。Where() 方法用于按状态过滤发票,并返回一个 IQueryable 对象。然后,我们使用其他一些方法对发票进行排序和分页。当我们调用 ToListAsync() 方法时,查询将被转换成 SQL 查询并在数据库上执行以获取结果。日志显示了代码的执行顺序:

info: BasicEfCoreDemo.Controllers.InvoicesController[0]      Creating the IQueryable...
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      IQueryable created
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Query the result using IQueryable...
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Execute the query using IQueryable
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (49ms) [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      ORDER BY [i].[InvoiceDate] DESC
      OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Result created using IQueryable

从日志中我们可以看到,当我们调用 ToListAsync() 方法时,查询将在数据库上执行。查询包含 ORDER BYOFFSETFETCH NEXT 子句,这意味着查询是在数据库服务器上执行的。

接下来,让我们使用 IEnumerable 接口查询数据库:

// Use IEnumerablelogger.LogInformation($"Creating the IEnumerable...");
var list2 = context.Invoices.Where(x => status == null || x.Status == status).AsEnumerable();
logger.LogInformation($"IEnumerable created");
logger.LogInformation($"Query the result using IEnumerable...");
var query2 = list2.OrderByDescending(x => x.InvoiceDate)
    .Skip((page - 1) * pageSize)
    .Take(pageSize);
logger.LogInformation($"Execute the query using IEnumerable");
var result2 = query2.ToList();
logger.LogInformation($"Result created using IEnumerable");

在前面的代码中,我们使用 AsEnumerable() 方法将 IQueryable 对象转换为 IEnumerable 对象。然后,我们对发票进行排序和分页,并调用 ToList() 方法以获取结果。日志显示了代码的执行顺序:

info: BasicEfCoreDemo.Controllers.InvoicesController[0]      Creating the IEnumerable...
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      IEnumerable created
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Query the result  using IEnumerable...
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Execute the query using IEnumerable
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (5ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
info: BasicEfCoreDemo.Controllers.InvoicesController[0]
      Result created using IEnumerable

看看日志。生成的 SQL 查询不包含 ORDER BYOFFSETFETCH NEXT 子句,这意味着查询从数据库中检索了所有发票,然后在内存中对发票进行过滤、排序和分页。如果我们数据库中有大量实体,第二个查询将非常慢且效率低下。

现在,我们可以看到两个接口之间的区别。IQueryable 接口是一个延迟执行查询,这意味着当我们向查询添加更多条件时,查询不会执行。当调用 ToList()ToArray() 方法或遍历集合中的项时,查询将对数据库执行。因此,在复杂和重量级的查询中,我们应该始终使用 IQueryable 接口以避免从数据库中检索所有数据。在调用 ToList()ToArray() 方法时要小心,因为 ToList()ToArray()(及其 async 重载)将立即执行查询。

哪些 LINQ 方法会导致查询立即执行?

有一些操作会导致查询立即执行:

  • 使用 forforeach 循环遍历集合中的项

  • 使用 ToList()ToArray()Single()SingleOrDefault()First()FirstOrDefault()Count() 方法,或这些方法的 async 重载

在本节中,我们探讨了 IQueryableIEnumerable 之间的区别。了解为什么在查询数据库的复杂和重量级查询时应该使用 IQueryable 而不是 IEnumerable 非常重要。如果数据库中有大量实体,从数据库中加载数据可能会导致性能问题。

接下来,我们将讨论另一个可能影响性能的因素:客户端评估。

客户端评估与服务器评估

在本节中,我们将讨论客户端评估和服务器评估之间的区别。在 EF Core 的旧版本(早于 EF Core 3.0)中,对具有客户端评估的 LINQ 查询的错误使用可能导致严重的性能问题。让我们看看客户端评估和服务器评估是什么。

当我们使用 EF Core 从数据库查询数据时,我们只需编写 LINQ 查询,EF Core 将 LINQ 查询转换为 SQL 查询并在数据库上执行它们。然而,有时 LINQ 操作必须在客户端执行。检查 InvoicesController 类中的 SearchInvoices 动作方法中的以下代码:

var list = await context.Invoices    .Where(x => x.ContactName.Contains(search) || x.InvoiceNumber.Contains(search))
    .ToListAsync();

当我们使用 Contains() 方法时,EF Core 可以将 LINQ 查询转换为以下 SQL 查询:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (4ms) [Parameters=[@__search_0='?' (Size = 32), @__search_0_1='?' (Size = 32) (DbType = AnsiString)], CommandType='Text', CommandTimeout='30']
      SELECT [i].[Id], [i].[Amount], [i].[ContactName], [i].[Description], [i].[DueDate], [i].[InvoiceDate], [i].[InvoiceNumber], [i].[Status]
      FROM [Invoices] AS [i]
      WHERE (@__search_0 LIKE N'') OR CHARINDEX(@__search_0, [i].[ContactName]) > 0 OR (@__search_0_1 LIKE '') OR CHARINDEX(@__search_0, [i].[InvoiceNumber]) > 0

你可以看到 SQL 查询使用了某些原生 SQL 函数来过滤数据,这意味着 SQL 查询是在数据库服务器上执行的。这被称为服务器评估。EF Core 尽可能多地尝试运行服务器评估。

现在,假设我们想要返回每个发票的 GST 税额。我们可以将实体转换为新对象,并包含 GST 税额。当然,更好的方法是向Invoice实体中添加一个表示税的属性。以下是如何做到这一点的演示。

添加一个用于计算 GST 税额的static方法:

private static decimal CalculateTax(decimal amount){
    return amount * 0.15m;
}

按照以下方式更新代码:

var list = await context.Invoices    .Where(x => x.ContactName.Contains(search) || x.InvoiceNumber.Contains(search))
    .Select(x => new Invoice
    {
        Id = x.Id,
        InvoiceNumber = x.InvoiceNumber,
        ContactName = x.ContactName,
        Description = $"Tax: ${CalculateTax(x.Amount)}. {x.Description}",
        Amount = x.Amount,
        InvoiceDate = x.InvoiceDate,
        DueDate = x.DueDate,
        Status = x.Status
    })
    .ToListAsync();

我们通过添加 GST 税计算更新了Description属性。当我们运行应用程序并调用端点时,我们将看到生成的 SQL 查询与之前的查询相同。但Description属性在结果中已被更新。这意味着转换是在客户端完成的。这被称为客户端评估

这种客户端评估是可以接受的,因为查询确实需要从数据库中获取数据。成本非常低。然而,它可能对某些查询造成问题。例如,我们想要查询 GST 税额大于$10 的发票。按照以下方式更新代码:

var list = await context.Invoices    .Where(x => (x.ContactName.Contains(search) || x.InvoiceNumber.Contains(search)) && CalculateTax(x.Amount) > 10)
    .ToListAsync();

当我们调用端点时,我们将看到以下错误:

fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]      An unhandled exception has occurred while executing the request.
      System.InvalidOperationException: The LINQ expression 'DbSet<Invoice>()
          .Where(i => i.ContactName.Contains(__search_0) || i.InvoiceNumber.Contains(__search_0) && InvoicesController.CalculateTax(i.Amount) > 10)' could not be translated. Additional information: Translation of method 'BasicEfCoreDemo.Controllers.InvoicesController.CalculateTax' failed. If this method can be mapped to your custom function, see https://go.microsoft.com/fwlink/?linkid=2132413 for more information. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

错误信息非常明确。这是因为CalculateTax()方法不受 EF Core 支持。在 EF Core 的旧版本(早于 EF Core 3.0)中,EF Core 将从数据库中获取所有数据,然后在内存中过滤数据。这可能导致性能问题。在 EF Core 3.0 之后,如果查询无法正确转换,EF Core 将抛出异常,以避免潜在的性能问题。

但如果你确定客户端评估是安全的,例如在处理小数据量时,你可以显式使用AsEnumerable()方法(或AsAsyncEnumerable()ToList()ToListAsync())来强制 EF Core 获取所有数据,然后在客户端执行查询。确保你知道你在做什么。

为什么CalculateTax()方法必须是静态的?

由于查询编译的成本很高,EF Core 会缓存编译后的查询。如果CalculateTax()方法不是静态的,EF Core 将需要通过CalculateTax()实例方法维护对InvoicesController的常量表达式的引用,这可能导致内存泄漏。为了防止这种情况,EF Core 如果CalculateTax()方法不是静态的,将抛出异常。将方法设置为静态将确保 EF Core 不会捕获实例中的常量。

EF Core 的最新版本提供了防止由客户端评估引起的潜在性能问题的好处。如果你遇到与之前类似的异常,你可以检查查询以确保它正在被正确转换。

接下来,我们将讨论如何在 EF Core 中使用原始 SQL 查询。在某些场景下,我们需要编写原始 SQL 查询来执行复杂查询。

使用原始 SQL 查询

虽然 EF Core 可以将大多数 LINQ 查询转换为 SQL 查询,这非常方便,但有时如果所需的查询无法用 LINQ 编写,或者生成的 SQL 查询效率不高,我们需要编写原始 SQL 查询。在本节中,我们将探讨如何在 EF Core 中使用原始 SQL 查询。

EF Core 提供了几个方法来执行原始 SQL 查询:

  • FromSql()

  • FromSqlRaw()

  • SqlQuery()

  • SqlQueryRaw()

  • ExecuteSql()

  • ExecuteSqlRaw()

当我们执行原始 SQL 查询时,我们必须小心避免 SQL 注入攻击。让我们看看何时应该使用原始 SQL 查询以及如何正确使用它们。您可以从章节的 GitHub 仓库中的 /samples/chapter7/EfCoreDemo 文件夹下载示例代码。

FromSql() 和 FromSqlRaw()

我们可以使用 FromSql() 方法根据一个插值字符串创建一个 LINQ 查询。FromSql() 方法在 EF Core 7.0 及更高版本中可用。在旧版本中有一个类似的方法称为 FromSqlInterpolated()

要执行原始 SQL 查询,我们只需将插值字符串传递给 FromSql() 方法,如下所示:

var list = await context.Invoices    .FromSql($"SELECT * FROM Invoices WHERE Status = 2")
    .ToListAsync();

我们还可以向原始 SQL 查询传递参数。例如,我们想要查询具有特定状态的发票:

[HttpGet][Route("status")]
public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(string status)
{
    // Omitted for brevity
    var list = await context.Invoices
        .FromSql($"SELECT * FROM Invoices WHERE Status = {status}")
        .ToListAsync();
    return list;
}

等等,直接将字符串插入 SQL 查询是否安全?如果 status 参数是 '; DROP TABLE Invoices; --,会发生什么?它会导致 SQL 注入攻击吗?

这是一个好问题。让我们看看 EF Core 如何处理参数。运行应用程序并调用 /api/invoices/status?status=AwaitPayment 端点。我们将看到生成的 SQL 查询如下:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (41ms) [Parameters=[p0='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SELECT * FROM Invoices WHERE Status = @p0

参数不是直接插入到 SQL 查询中。相反,EF Core 使用 @p0 参数占位符并将参数值传递到 SQL 查询中。这被称为参数化查询。使用参数化查询来避免 SQL 注入攻击是安全的。因此,我们不需要担心 FromSql 方法的安全性。

为什么 FromSql() 是安全的

FromSql() 方法期望一个参数作为 FormattableString 类型。因此,必须使用 $ 前缀来使用插值字符串语法。语法看起来像常规的 C# 字符串插值,但它不是同一回事。FormattableString 类型可以包含插值参数占位符。插值参数值将自动转换为 DbParameter 类型。因此,使用 FromSql() 方法来避免 SQL 注入攻击是安全的。

对于某些场景,我们可能需要构建动态 SQL 查询。例如,我们想要根据用户输入查询发票,该输入指定了属性名称和属性值。在这种情况下,我们不能使用 FromSql,因为不允许对列名进行参数化。我们需要使用 FromSqlRaw。然而,我们必须小心避免 SQL 注入攻击。确保 SQL 查询安全是开发者的责任。以下是一个示例:

[HttpGet][Route("free-search")]
public async Task<ActionResult<IEnumerable<Invoice>>> GetInvoices(string propertyName, string propertyValue)
{
    if (context.Invoices == null)
    {
        return NotFound();
    }
    // Do something to sanitize the propertyName value
    var value = new SqlParameter("value", propertyValue);
    var list = await context.Invoices
        .FromSqlRaw($"SELECT * FROM Invoices WHERE {propertyName} = @value", value)
        .ToListAsync();
    return list;
}

在前面的例子中,列名没有被参数化。因此,我们必须小心避免 SQL 注入攻击。需要清理 propertyName 的值以确保其安全性。也许你可以检查该值是否包含任何特殊字符,例如 ;-- 等。如果值包含任何特殊字符,你可以在执行 SQL 查询之前抛出异常或删除这些特殊字符。此外,如果你允许用户指定列名,这将增加验证列名的努力,因为你需要检查该列名是否存在于数据库中或该列是否有正确的索引。确保你知道你在做什么。

propertyValue 是参数化的,因此使用它是安全的。

在使用 FromSql() 构建了 SQL 查询之后,你可以然后应用 LINQ 查询运算符来过滤数据,正如你想要的。记住,使用 FromSql() 比使用 FromSqlRaw() 更好。

当我们使用 FromSql()FromSqlRaw() 方法时,请记住有一些限制:

  • SQL 查询返回的数据必须包含实体的所有属性,否则 EF Core 无法将数据映射到实体。

  • SQL 查询返回的列名必须与实体属性映射到的列名匹配。

  • SQL 查询只能查询一个表。如果你需要查询多个表,你可以先构建原始查询,然后使用 Include() 方法包含相关实体。

SqlQuery() 和 SqlQueryRaw()

当我们想使用原始 SQL 查询从数据库中查询实体时,FromSql() 方法很有用。在某些情况下,我们想执行原始 SQL 查询并返回标量值或非实体类型。例如,我们想查询具有特定状态的发票的 ID。我们可以使用 SqlQuery() 方法执行原始 SQL 查询并返回 ID 列表。以下是一个示例:

[HttpGet][Route("ids")]
public ActionResult<IEnumerable<Guid>> GetInvoicesIds(string status)
{
    var result = context.Database
        .SqlQuery<Guid>($"SELECT Id FROM Invoices WHERE Status = {status}")
        .ToList();
    return result;
}

翻译后的 SQL 查询如下:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (22ms) [Parameters=[p0='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SELECT Id FROM Invoices WHERE Status = @p0

注意,SqlQuery() 方法是在 DbContext 对象的 Database 属性上使用的。它不可在 DbSet 对象上使用。

SqlQueryRaw() 方法与 SqlQuery() 方法类似,但它允许我们构建类似于 FromSqlRaw() 方法的动态 SQL 查询。同样,你必须承担责任以避免 SQL 注入攻击。

ExecuteSql() 和 ExecuteSqlRaw()

对于某些不需要返回值的情况,我们可以使用 ExecuteSql 方法来执行原始 SQL 查询。通常,它用于更新或删除数据或调用 ExecuteSql() 方法来执行原始 SQL 查询。以下是一个示例:

[HttpDelete][Route("status")]
public async Task<ActionResult> DeleteInvoices(string status)
{
    var result = await context.Database
        .ExecuteSqlAsync($"DELETE FROM Invoices WHERE Status = {status}");
    return Ok();
}

这样做,我们不需要从数据库中加载实体然后逐个删除。使用 ExecuteSql() 方法执行原始 SQL 查询要高效得多。

ExecuteSqlRaw() 方法与 ExecuteSql() 方法类似,但它允许我们构建类似于 FromSqlRaw() 方法的动态 SQL 查询。同样,您必须非常小心地清理 SQL 查询以避免 SQL 注入攻击。

在本节中,我们介绍了如何在 EF Core 中使用原始 SQL 查询。我们讨论了 FromSql()FromSqlRaw()SqlQuery()SqlQueryRaw()ExecuteSql()ExecuteSqlRaw() 之间的区别。我们还讨论了这些方法的局限性。再次强调,当我们使用原始 SQL 查询时,必须非常小心以避免 SQL 注入攻击。

在本节的一个示例中,我们向您展示了如何运行原始 SQL 查询来删除一组实体。EF Core 7.0 引入了一个批量操作功能,可以使这个过程更容易。现在有两个新的批量操作方法可用,即 ExecuteUpdate()ExecuteDelete(),它们提供了一种更有效的方式来更新或删除数据。在接下来的部分中,我们将更详细地讨论这个功能。

使用批量操作

在本节中,我们将探讨如何有效地使用 EF Core 更新/删除数据。EF Core 7.0 或更高版本提供了批量操作的能力,这些操作易于使用,可以提高更新/删除操作的性能。为了利用这个功能,请确保您正在使用 EF Core 的最新版本。

正如我们在上一节中提到的,EF Core 跟踪实体的变化。要更新一个实体,通常情况下,我们需要从数据库中加载实体,更新实体属性,然后调用 SaveChanges() 方法将更改保存到数据库。这是一个非常常见的场景。删除实体的情况类似。然而,如果我们想要更新或删除大量实体,逐个加载实体并更新或删除它们并不高效。对于这些场景,不需要跟踪实体的变化。因此,使用批量操作功能来更新或删除数据会更好。

我们可以使用原始 SQL 查询通过 ExecuteSql() 方法来更新或删除数据。然而,它缺乏强类型支持。在 SQL 查询中硬编码列名不是一种好的做法。从 EF Core 7.0 开始,我们可以使用 ExecuteUpdate()ExecuteDelete() 方法来更新或删除数据。请注意,这两个方法不涉及实体跟踪功能。因此,一旦您调用这两个方法,更改将立即执行。不需要调用 SaveChanges() 方法。

接下来,让我们看看如何使用这两个方法。我们将向您展示如何使用 ExecuteUpdate() 方法以及生成的 SQL 查询。ExecuteDelete() 方法类似。示例代码位于章节的 GitHub 仓库 /samples/chapter7/EfCoreDemo 文件夹中。

ExecuteUpdate()

ExecuteUpdate() 方法用于在不从数据库加载实体的情况下更新数据。您可以通过添加 Where() 子句来更新一个或多个实体。

例如,我们想要更新在特定日期之前创建的发票的状态。代码如下:

[HttpPut][Route("status/overdue")]
public async Task<ActionResult> UpdateInvoicesStatusAsOverdue(DateTime date)
{
    var result = await context.Invoices
        .Where(i => i.InvoiceDate < date && i.Status == InvoiceStatus.AwaitPayment)
        .ExecuteUpdateAsync(s => s.SetProperty(x => x.Status, InvoiceStatus.Overdue));
    return Ok();
}

生成的 SQL 查询如下:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (46ms) [Parameters=[@__p_0='?' (DbType = DateTimeOffset)], CommandType='Text', CommandTimeout='30']
      UPDATE [i]
      SET [i].[Status] = 'Overdue'
      FROM [Invoices] AS [i]
      WHERE [i].[InvoiceDate] < @__p_0 AND [i].[Status] = 'AwaitPayment'

此查询可以同时更新多个发票。它确实受益于强大的类型支持,但效率与原始 SQL 查询相同。如果您需要更新多个属性,可以使用 SetProperty() 方法多次,如下面的代码所示:

var result = await context.Invoices        .Where(i => i.InvoiceDate < date && i.Status == InvoiceStatus.AwaitPayment)
        .ExecuteUpdateAsync(s =>
            s.SetProperty(x => x.Status, InvoiceStatus.Overdue)
            .SetProperty(x => x.LastUpdatedDate, DateTime.Now));

此外,Where() 子句可以引用其他实体。因此,始终推荐使用 ExecuteUpdate() 方法来更新多个实体,而不是使用原始 SQL 查询。

ExecuteDelete()

同样,我们可以使用 ExecuteDelete() 方法来删除数据,而无需从数据库中加载实体。此方法可以通过添加 Where 子句来删除一个或多个实体。例如,我们想要删除在特定日期之前创建的发票。代码如下:

await context.Invoices.Where(x => x.InvoiceDate < date).ExecuteDeleteAsync();

再次强调,这些批量操作不会跟踪实体的变化。如果一个 DbContext 实例已经加载了实体,在批量更新或删除之后,上下文中的实体仍然会保留旧值。因此,在使用这些批量操作时要格外小心。

在本节中,我们讨论了如何在 EF Core 中使用批量操作功能。我们介绍了 ExecuteUpdate()ExecuteDelete() 方法,这些方法可以用来更新或删除数据,而无需从数据库中加载实体。与原始 SQL 查询相比,这两种方法具有强大的类型支持。建议使用这两种方法来更新或删除多个实体。

接下来,我们将学习如何在更新数据时管理并发冲突。

理解并发冲突

一个 API 端点可以同时被多个客户端调用。如果端点更新数据,数据可能在当前客户端完成更新之前被另一个客户端更新。当同一实体被多个客户端更新时,可能会引起并发冲突,这可能导致数据丢失或不一致,甚至可能造成数据损坏。在本节中,我们将讨论如何在 EF Core 中处理并发冲突。您可以从章节的 GitHub 仓库 /samples/chapter7/ConcurrencyConflictDemo 文件夹中下载示例项目 ConcurrencyConflictDemo

处理并发冲突有两种方式:

  • 悲观并发控制:这种方法使用数据库锁来防止多个客户端同时更新同一实体。当客户端尝试更新一个实体时,它将首先对该实体获取一个锁。如果锁获取成功,则只有这个客户端可以更新实体,而所有其他客户端将无法更新该实体,直到锁被释放。然而,当并发客户端数量较多时,这种方法可能会导致性能问题,因为管理锁的成本很高。EF Core 不支持内置的悲观并发控制。

  • 乐观并发控制:这种方法不涉及锁;相反,使用版本列来检测并发冲突。当客户端尝试更新一个实体时,它将首先获取版本列的值,然后在更新实体时将其与旧值进行比较。如果版本列的值与旧值相同,这意味着没有其他客户端已更新该实体。在这种情况下,客户端可以更新实体。但如果版本列的值与旧值不同,这意味着另一个客户端已更新该实体。在这种情况下,EF Core 将抛出一个异常来指示并发冲突。然后客户端可以处理异常并重试更新操作。

让我们看看并发冲突的一个例子。在ConcurrencyConflictDemo项目中,我们有一个具有Inventory属性的Product实体,该属性用于存储库存中的产品数量。我们想要创建一个 API 端点来销售产品。当客户端调用此端点时,它将传递产品 ID 和要销售的产品数量。端点将更新Inventory属性,减去要销售的产品数量。逻辑如下:

  • 客户端调用 API 端点来销售产品。

  • 应用程序从数据库中获取产品。

  • 应用程序检查Inventory属性以确保库存中的产品数量足以销售:

    • 如果库存中的产品数量足够,应用程序将从Inventory属性中减去正在销售的产品数量,然后调用SaveChanges()方法将更改保存到数据库。

    • 如果库存中的产品数量不足,应用程序将向客户端返回错误信息。

重要提示

示例项目使用以下代码在Program.cs文件中启动应用程序时重置数据库:

dbContext.Database.EnsureDeleted();

dbContext.Database.EnsureCreated();

因此,当你运行应用程序时,数据库将被重置,产品 1 的Inventory属性将被设置为15

以下代码显示了 API 端点的实现的第一版本:

[HttpPost("{id}/sell/{quantity}")]public async Task<ActionResult<Product>> SellProduct(int id, int quantity)
{
    if (context.Products == null)
    {
        return Problem("Entity set 'SampleDbContext.Products' is null.");
    }
    var product = await context.Products.FindAsync(id);
    if (product == null)
    {
        return NotFound();
    }
    if (product.Inventory < quantity)
    {
        return Problem("Not enough inventory.");
    }
    product.Inventory -= quantity;
    await context.SaveChangesAsync();
    return product;
}

应该还有一些其他逻辑来处理订单创建和支付等操作。我们在这里不会讨论这些;相反,我们将专注于由产品库存更新引起的并发冲突:

  1. 为了模拟这种并发场景,我们可以在保存更改到数据库之前传递一个delay参数来添加延迟。以下代码显示了如何添加延迟:

    [HttpPost("{id}/sell/{quantity}")]public async Task<ActionResult<Product>> SellProduct(int id, int quantity, int delay = 0){    // Omitted code for brevity    await Task.Delay(TimeSpan.FromSeconds(delay));    product.Inventory -= quantity;    await context.SaveChangesAsync();    return product;}
    
  2. 现在,让我们尝试在短时间内两次调用 API 端点。第一次POST请求将传递一个值为2秒的delay参数:

    http://localhost:5273/api/Products/1/sell/10?delay=2
    

    第二次POST请求将传递一个值为3秒的delay参数:

    http://localhost:5273/api/Products/1/sell/10?delay=3
    
  3. 先发送第一个请求,然后隔2秒发送第二个请求。预期结果是第一个请求成功,第二个请求失败。但实际上,两个请求都成功了。响应显示产品的Inventory属性已更新为5,这是不正确的。Inventory属性的初始值是15,而我们卖出了 20 个产品,那么Inventory属性怎么会被更新为5呢?

让我们看看应用中会发生什么:

  1. 客户端 A 调用 API 端点来销售产品,并希望销售 10 个产品。

  2. 客户端 A 检查Inventory属性,发现库存中的产品数量为 15,这足以销售。

  3. 几乎同时,客户端 B 调用 API 端点来销售产品,并希望销售 10 个产品。

  4. 客户端 B 检查Inventory属性,发现库存中的产品数量为 15,因为客户端 A 尚未更新Inventory属性。

  5. 客户端 A 从Inventory属性中减去 10,结果值为5,并将更改保存到数据库中。现在,库存中的产品数量为5

  6. 客户端 B 也从Inventory属性中减去 10 并保存更改到数据库。问题是客户端 A 已经将库存数量更新为5,但客户端 B 并不知道这一点。因此,客户端 B 也将Inventory属性更新为5,这是不正确的。

这是一个并发冲突的例子。多个客户端试图同时更新同一个实体,结果并非我们所期望的。在这种情况下,客户端 B 不应该能够更新Inventory属性,因为库存中的产品数量不足。然而,如果应用程序没有处理并发冲突,我们可能会在数据库中得到错误的数据。

为了解决这个问题,EF Core 提供了乐观并发控制。有两种方式可以使用乐观并发控制:

  • 原生数据库生成的并发令牌

  • 应用程序管理的并发令牌

让我们看看如何使用这两种方式来处理并发冲突。

原生数据库生成的并发令牌

一些数据库,如 SQL Server,提供原生机制来处理并发冲突。要在 SQL Server 中使用原生数据库生成的并发令牌,我们需要为Product类创建一个新的属性,并给它添加一个[Timestamp]属性。下面的代码显示了更新的Product类:

public class Product{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Inventory { get; set; }
    // Add a new property as the concurrency token
    public byte[] RowVersion { get; set; }
}

在 Fluent API 配置中,我们需要添加以下代码来将RowVersion属性映射到数据库中的rowversion列:

modelBuilder.Entity<Product>()    .Property(p => p.RowVersion)
    .IsRowVersion();

如果你更喜欢使用数据注释配置,你可以在RowVersion属性上添加[Timestamp]属性,EF Core 将自动将其映射到数据库中的rowversion列,如下面的代码所示:

public class Product{
    public int Id { get; set; }
    public string Name { get; set; }
    public int Inventory { get; set; }
    // Add a new property as the concurrency token
    [Timestamp]
    public byte[] RowVersion { get; set; }
}

不要忘记运行dotnet ef migrations add AddConcurrencyControl命令来创建一个新的迁移。这次不需要运行dotnet ef database update命令,因为我们有代码在应用程序启动时重置数据库。

重要提示

如果您想在 Fluent API 中配置映射,可以使用以下代码:

modelBuilder.Entity<Product>()

.Property(p => p.RowVersion)

.``IsRowVersion();

这将生成以下迁移:

migrationBuilder.AddColumn<byte[]>(

name: "RowVersion",

table: "Products",

type: "rowversion",

rowVersion: true,

nullable: false,

defaultValue: new byte[0]);

现在,让我们再次尝试调用 API 端点。使用之前相同的请求,一个带有2delay参数,另一个带有3delay参数。这次,我们应该看到第一个请求将成功,但第二个请求将失败,并抛出DbUpdateConcurrencyException异常:

fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]      An unhandled exception has occurred while executing the request.
      Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See http://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.

检查数据库。产品 1 的Inventory列已更新为5,这是正确的。

如果你检查 EF Core 生成的 SQL 语句,你会发现rowversion列被包含在UPDATE语句的WHERE子句中:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (1ms) [Parameters=[@p1='?' (DbType = Int32), @p0='?' (DbType = Int32), @p2='?' (Size = 8) (DbType = Binary)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      UPDATE [Products] SET [Inventory] = @p0
      OUTPUT INSERTED.[RowVersion]
      WHERE [Id] = @p1 AND [RowVersion] = @p2;

通过使用并发控制,EF Core 不仅检查实体的 ID,还检查rowversion列的值。如果rowversion列的值与数据库中的值不同,这意味着实体已被另一个客户端更新,当前的更新操作应该被中止。

注意,rowversion列类型适用于 SQL Server,但不适用于其他数据库,如 SQLite。不同的数据库可能有不同类型的并发令牌,或者根本不支持并发令牌。请查看您所使用数据库的文档,以了解它是否支持内置的并发令牌。如果不支持,您需要使用应用程序管理的并发令牌,如下一节所示。

应用程序管理的并发令牌

如果数据库不支持内置的并发令牌,我们可以在应用程序中手动管理并发令牌。而不是使用数据库可以自动更新的rowversion列,我们可以使用实体类中的一个属性来管理并发令牌,并在每次实体更新时为其分配一个新的值。

下面是一个使用应用程序管理的并发令牌的示例:

  1. 首先,我们需要在Product类中添加一个新的属性,如下面的代码所示:

    public class Product{    public int Id { get; set; }    public string Name { get; set; }    public int Inventory { get; set; }    // Add a new property as the concurrency token    public Guid Version { get; set; }}
    
  2. 更新 Fluent API 配置以指定Version属性作为并发令牌:

    modelBuilder.Entity<Product>()    .Property(p => p.Version)    .IsConcurrencyToken();
    
  3. 相应的数据注释配置如下:

    public class Product{    public int Id { get; set; }    public string Name { get; set; }    public int Inventory { get; set; }    // Add a new property as the concurrency token    [ConcurrencyCheck]    public Guid Version { get; set; }}
    
  4. 因为这个Version属性不由数据库管理,所以每当实体被更新时,我们需要手动分配一个新的值。

以下代码展示了在实体更新时如何更新Version属性:

[HttpPost("{id}/sell/{quantity}")]public async Task<ActionResult<Product>> SellProduct(int id, int quantity)
{
    // Omitted for brevity.
    product.Inventory -= quantity;
    // Manually assign a new value to the Version property.
    product.Version = Guid.NewGuid();
    await context.SaveChangesAsync();
    return product;
}

您也可以在 SQL Server 中使用应用管理的并发令牌。唯一的区别是每次实体更新时,您需要手动将新值分配给并发令牌属性。但如果你使用 SQL Server 中的内置并发令牌,则不需要这样做。

在并发冲突发生的情况下,采取必要的步骤来解决问题是至关重要的。这将在下一节中讨论。

处理并发冲突

当发生并发冲突时,EF Core 将抛出 DbUpdateConcurrencyException 异常。我们可以捕获这个异常并在应用程序中处理它。例如,我们可以向客户端返回 409 冲突 状态码,并让客户端决定下一步做什么:

[HttpPost("{id}/sell/{quantity}")]public async Task<ActionResult<Product>> SellProduct(int id, int quantity)
{
    // Omitted for brevity.
    product.Inventory -= quantity;
    try
    {
        await context.SaveChangesAsync();
    }
    catch (DbUpdateConcurrencyException)
    {
        // Do not forget to log the error
        return Conflict($"Concurrency conflict for Product {product.Id}.");
    }
    return product;
}

当发生并发冲突时,前面的代码会向客户端返回 409 冲突 状态码。然后客户端可以处理异常并重试更新操作。

重要提示

一些数据库提供了不同的隔离级别来处理并发冲突。例如,SQL Server 提供了四个隔离级别:ReadUncommittedReadCommittedRepeatableReadSerializable。默认隔离级别是 ReadCommitted。当发生并发冲突时,每个隔离级别都有不同的行为,并且各有优缺点。更高的隔离级别提供了更多的一致性,但也会降低并发性。有关更多信息,请参阅隔离级别

在本节中,我们讨论了如何在 EF Core 中处理并发冲突。我们介绍了两种处理并发冲突的方法:原生数据库生成的并发令牌和应用管理的并发令牌。我们还讨论了当并发冲突发生时如何处理异常。并发冲突在高并发环境中是一个常见问题。正确处理它们对于避免数据丢失或不一致非常重要。

反向工程

到目前为止,我们已经学习了如何使用 EF Core 从实体类创建数据库架构。这被称为 代码优先。然而,有时我们需要处理现有的数据库。在这种情况下,我们需要从现有的数据库架构创建实体类和 DbContext。这被称为 数据库优先反向工程。在本节中,我们将讨论如何使用 EF Core 从现有的数据库架构反向工程实体类和 DbContext。当我们想要将现有应用程序迁移到 EF Core 时,这非常有用。

让我们以EfCoreRelationshipsDemoDb数据库为例。如果您还没有创建此数据库,请按照第六章中的步骤创建它。示例代码位于该章节 GitHub 仓库的/samples/chapter7/EfCoreReverseEngineeringDemo文件夹中。

  1. 首先,让我们创建一个新的 Web API 项目。在终端中运行以下命令:

    Microsoft.EntityFrameworkCore.Design NuGet package. Navigate to the EfCoreReverseEngineeringDemo folder, and run the following command in the terminal to install it:
    
    

    Microsoft.EntityFrameworkCore.SqlServer NuGet 包添加到项目中:

    Microsoft.EntityFrameworkCore.Sqlite NuGet package. You can find the list of supported database providers at https://learn.microsoft.com/en-us/ef/core/providers/.
    
    
    
  2. 接下来,我们将使用dbcontext scaffold命令从数据库模式生成实体类和DbContext。此命令需要数据库的连接字符串和数据库提供者的名称。您可以在终端中运行以下命令:

    DbContext class will be the same as the database name, such as EfCoreRelationshipsDemoDbContext.cs. We can also change the name of the DbContext class by using the --context option. For example, we can run the following command to change the name of the DbContext class to AppDbContext:
    
    Data and Models folders.
    
  3. 打开AppDbContext.cs文件,我们将在以下代码中看到一个警告:

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)#warning To protect potentially sensitive information in your connection string, you should move it out of source code. You can avoid scaffolding the connection string by using the Name= syntax to read it from configuration - see https://go.microsoft.com/fwlink/?linkid=2131148\. For more guidance on storing connection strings, see http://go.microsoft.com/fwlink/?LinkId=723263.        => optionsBuilder.UseSqlServer("Server=(localdb)\\mssqllocaldb;Initial Catalog=EfCoreRelationshipsDemoDb;Trusted_Connection=True;");
    

    这个警告告诉我们,我们不应该在源代码中存储连接字符串。相反,我们应该将其存储在配置文件中,例如appsettings.json

OnModelCreating方法中,我们可以看到实体类及其关系已经以 Fluent API 风格配置。如果您更喜欢使用数据注释,可以在运行dbcontext scaffold命令时使用--data-annotations选项。但正如我们在第五章中提到的,Fluent API 比数据注释更强大,建议使用 Fluent API。

EF Core 足够智能,可以检测实体类之间的关系,如果您的数据库模式遵循约定。然而,如果这不是这种情况,您可能会得到意外的结果。请仔细检查生成的代码,以确保关系配置正确。

请记住,生成的代码只是一个起点。一些模型或属性可能无法在数据库中正确表示。例如,如果您的模型具有继承,生成的代码将不会包括基类,因为基类在数据库中没有表示。此外,某些列类型可能无法映射到相应的 CLR 类型。例如,Invoice表中的Status列是nvarchar(16)类型,在生成的代码中将映射到string类型,而不是Status枚举类型。

您可以更新生成的代码以满足您的需求,但请注意,下次您运行dbcontext scaffold命令时,更改将被覆盖。您可以使用部分类向生成的类中添加自己的代码,因为生成的类被声明为partial

在本节中,我们讨论了如何使用 EF Core 从现有的数据库模式反向工程实体类和DbContext。需要注意的是,EF Core 强烈偏好代码优先的方法。除非您正在处理现有的数据库,否则建议使用代码优先的方法以利用 EF Core 迁移功能。

其他 ORM 框架

除了 EF Core 之外,还有许多其他 ORM 框架可用于 .NET。其中一些最受欢迎的包括以下:

  • Dapper (dapperlib.github.io/Dapper/): Dapper 是一个设计为快速和轻量级的微型 ORM 框架。Dapper 不支持变更跟踪,但它易于使用,并且非常快速。正如官方文档所说,“Dapper 的简单性意味着许多 ORM 框架自带的功能都被移除了。它关注 95% 的场景,并为你提供大多数情况下需要的工具。它不试图解决每个问题。”性能是 Dapper 最重要的特性之一。也许将 Dapper 的性能与 EF Core 进行比较并不公平,因为 EF Core 提供了比 Dapper 更多的功能。如果你正在寻找一个简单、快速且易于使用的 ORM 框架,Dapper 是一个好的选择。在某些项目中,Dapper 与 EF Core 结合使用,以提供两者的最佳结合。Dapper 是开源的,最初由 Stack Overflow 开发。

  • NHibernate (nhibernate.info/): 与 NUnit 类似,NHibernate 是 Java 中 Hibernate ORM 框架的 .NET 实现。它是一个成熟、开源的 ORM 框架,已经存在很长时间了。它非常强大且灵活。NHibernate 由一群开发者维护。

  • PetaPoco (github.com/CollaboratingPlatypus/PetaPoco): PetaPoco 是一个小巧、快速、易于使用的微型 ORM 框架,原始版本中只有 1,000+ 行代码。PetaPoco 通过使用动态方法生成(MSIL)将列值分配给属性,其性能与 Dapper 相似。PetaPoco 现在支持 SQL Server、SQL Server CE、MS Access、SQLite、MySQL、MariaDB、PostgreSQL、Firebird DB 和 Oracle。它使用 T4 模板生成代码。PetaPoco 是开源的,目前由几位核心开发者维护。

很难说哪一个是最好的。这取决于你的需求。Dapper 以其速度和性能而闻名,而 EF Core 则功能更丰富,提供了对复杂查询和关系的更好支持。在决定为特定任务使用哪个框架时,考虑每种方法的性能影响,以及框架功能和灵活性之间的权衡。

摘要

在本章中,我们深入探讨了 Entity Framework 的一些高级主题。我们首先探讨了如何通过使用 DbContext 缓存和无跟踪查询来提高我们应用程序的性能。然后我们学习了如何使用参数化查询安全有效地执行原始 SQL 查询,以及如何利用 EF Core 中的新批量操作功能来加快数据操作。

接下来,我们探讨了如何使用乐观并发控制来处理并发场景,这允许多个用户同时访问和修改相同的数据而不发生冲突。我们还介绍了逆向工程,这是一种从现有数据库模式生成实体类和DbContext类的技术,这可以在创建数据访问层时节省时间和精力。

为了拓宽我们的视野,除了 EF Core 之外,我们还简要介绍了其他一些流行的 ORM 框架,例如 Dapper、NHibernate 和 PetaPoco,并讨论了它们的优缺点。到本章结束时,你应该对如何在 Web API 项目中利用 EF Core 高效地访问和操作数据有一个稳固的理解,以及对你可用的其他 ORM 选项的一些见解。

然而,EF Core 是一个非常广泛的话题,我们无法在这本书中涵盖所有内容。有关 EF Core 的更多信息,请参阅官方文档learn.microsoft.com/en-us/ef/

在下一章中,我们将学习如何使用身份验证和授权来确保我们的 Web API 项目安全。

第八章:ASP.NET Core 中的安全和身份

第七章中,我们讨论了 EF Core 的更多高级主题,例如 DbContext 缓存、性能优化和并发控制。到这一点,你应该已经具备创建使用 EF Core 访问数据库的 Web API 应用程序所需的技能。然而,该应用程序并不安全。在没有任何身份验证的情况下,任何知道 URL 的人都可以访问 API,可能会将敏感数据暴露给公众。为了确保 Web API 应用程序的安全性,我们必须采取额外的步骤。

安全是一个广泛的话题,它是任何应用程序的关键方面。在本章中,我们将探讨 ASP.NET Core 提供的一些安全功能,包括身份验证、授权以及保护您的 Web API 应用程序的最佳实践。我们将涵盖以下主题:

  • 开始使用身份验证和授权

  • 深入了解授权

  • 管理用户和角色

  • ASP.NET Core 8 中的新身份验证 API 端点

  • 理解 OAuth 2.0 和 OpenID Connect

  • 其他安全主题

阅读本章后,你将基本了解 ASP.NET Core 中的安全功能。你还将了解如何在 Web API 应用程序中实现身份验证和不同的授权类型,例如基于角色的授权、基于声明的授权和基于策略的授权。

技术要求

本章中的代码示例可以在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8 找到。您可以使用 VS Code 或 VS 2022 打开解决方案。

开始使用身份验证和授权

身份验证和授权是安全性的两个重要方面。尽管这两个术语经常一起使用,但它们是不同的概念。在我们深入代码之前,了解身份验证和授权之间的区别非常重要。

我们已经构建了一些 Web API 应用程序。然而,这些 API 将对任何知道 URL 的人公开。对于某些资源,我们希望仅允许认证用户访问。例如,我们有一个包含一些敏感信息的资源,这些信息不应向每个人开放。在这种情况下,应用程序应该能够识别发起请求的用户。如果用户是匿名的,应用程序不应允许用户访问资源。这就是身份验证发挥作用的地方。

对于某些场景,我们还想限制某些特定用户对某些资源的访问。例如,我们希望允许认证用户读取资源,但只有管理员用户可以更新或删除资源。在这种情况下,应用程序应该能够检查用户是否具有执行操作所需的权限。这就是授权发挥作用的地方。

简而言之,身份验证用于了解用户是谁,而授权用于了解用户可以做什么。这两个过程共同用于确保用户是他们所声称的人,并且他们有权访问资源。

ASP.NET Core 提供了 Identity 框架,该框架具有丰富的身份验证和授权功能。在本章中,我们将探讨如何使用 Identity 框架在 ASP.NET Core 中实现身份验证和授权。我们还将介绍一些第三方身份验证提供者。

想象一下这样一个场景:我们想要构建一个允许用户注册和登录的 Web API 应用程序。对于特定的端点,我们只想允许经过身份验证的用户访问资源。在本节中,我们将探讨如何实现这个场景。通过这个示例,你将学习如何在 ASP.NET Core 中实现基本的身份验证和授权系统;这将帮助你为下一节做准备。

在本示例中,我们将使用以下资源:

  • POST /account/register:此资源将用于注册新用户。用户应在请求体中发送用户名和密码。验证用户名和密码后,应用程序将在数据库中创建新用户并向用户返回 JWT 令牌。此 JWT 令牌将用于在后续请求中验证用户。

  • POST /account/login:此资源将用于登录现有用户。用户发送用户名和密码后,应用程序将验证凭据,如果凭据有效,则向用户返回 JWT 令牌。JWT 令牌将用于在后续请求中验证用户。

  • GET /WeatherForecast:此资源将用于获取天气预报。它只允许经过身份验证的用户访问资源。用户应在 Authorization 头中发送 JWT 令牌以进行用户身份验证。

应该有更多端点来管理用户,例如更新用户资料、删除用户、重置密码等。然而,我们在这个章节中不会构建一个完整的应用程序。为了保持简单,我们只会关注演示 ASP.NET Core 中身份验证和授权功能所需的最小特性。

JWT 是什么?

JWT 代表JSON Web Token。它是在两个当事人之间安全表示声明的行业标准。JWT 的 RFC 是 RFC 7519:www.rfc-editor.org/rfc/rfc7519。JWT 令牌由三部分组成:头部、负载和签名。因此,典型的 JWT 令牌看起来像xxxxx.yyyyy.zzzzz。头部包含用于签名令牌的算法,负载包含声明,签名用于验证令牌的完整性。有关 JWT 的更多信息,请参阅jwt.io/introduction

创建具有身份验证和授权的示例项目

首先,我们必须准备项目并添加任何必要的 NuGet 包。此外,我们需要配置数据库上下文,以便我们能够在数据库中存储用户信息。按照以下步骤操作:

  1. 通过运行以下命令创建一个新的 ASP.NET Core Web API 项目:

    AuthenticationDemo. Open the project in VS Code. You can find the start project in the /samples/chapter8/AuthenticationDemo/BasicAuthenticationDemo/start folder.
    
  2. 现在,是时候添加所需的 NuGet 包了。我们将使用 ASP.NET Core Identity 来实现身份验证。ASP.NET Core Identity 是一个提供身份验证和授权功能的成员系统。它是 ASP.NET Core 框架的一部分。我们需要安装以下 NuGet 包:

    • Microsoft.AspNetCore.Identity.EntityFrameworkCore: 此包用于 ASP.NET Core Identity 的 EF Core 实现。

    • Microsoft.EntityFrameworkCore.SqlServer: 此包用于连接到 SQL Server。

    • Microsoft.EntityFrameworkCore.Tools: 此包用于启用必要的 EF Core 工具。

    • Microsoft.AspNetCore.Authentication.JwtBearer: 此包用于启用 JWT 身份验证。

    ASP.NET Core Identity 包已经包含在默认项目模板中,因此我们不需要安装它。

  3. 接下来,我们将添加数据库上下文。我们将使用 EF Core 来访问数据库。但首先,我们需要一个实体模型来表示用户。创建一个名为 Authentication 的新文件夹,并向其中添加一个名为 AppUser 的新类。AppUser 类继承自 ASP.NET Core Identity 提供的 IdentityUser 类,如下面的代码所示:

    public class AppUser : IdentityUser{}
    

    IdentityUser 类已经包含了我们在大多数场景中表示用户所需的属性,例如 UserNameEmailPasswordHashPhoneNumber 等。

  4. 接下来,我们需要创建一个数据库上下文来访问数据库。在 Authentication 文件夹中添加一个名为 AppDbContext 的新类。AppDbContext 类继承自 ASP.NET Core Identity 提供的 IdentityDbContext 类,如下面的代码所示:

    public class AppDbContext(DbContextOptions<AppDbContext> options, IConfiguration configuration) : IdentityDbContext<AppUser>(options){    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)    {        base.OnConfiguring(optionsBuilder);        optionsBuilder.UseSqlServer(_configuration.GetConnectionString("DefaultConnection"));    }}
    "ConnectionStrings": {    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=AuthenticationDemo;Trusted_Connection=True;MultipleActiveResultSets=true"}
    

    如我们所见,这个 AppDbContext 完全是为 ASP.NET Core Identity 而设计的。如果你的应用程序中还有其他实体,你可以为它们创建一个单独的 DbContext。你可以为这两个 DbContext 使用相同的连接字符串。

  5. 接下来,我们需要创建一些模型来注册和登录用户,因为当我们注册用户时,我们需要发送用户名、密码和电子邮件地址。当我们登录用户时,我们需要发送用户名和密码。如果为这些不同场景创建单独的模型会更好。

  6. Authentication 文件夹中创建一个名为 AddOrUpdateAppUserModel 的新类。此类将用于在注册新用户时表示用户。AddOrUpdateAppUserModel 类应包含以下属性:

    public class AddOrUpdateAppUserModel{    [Required(ErrorMessage = "User name is required")]    public string UserName { get; set; } = string.Empty;    [EmailAddress]    [Required(ErrorMessage = "Email is required")]    public string Email { get; set; } = string.Empty;    [Required(ErrorMessage = "Password is required")]    public string Password { get; set; } = string.Empty;}
    
  7. 类似地,在 Authentication 文件夹中创建一个名为 LoginModel 的新类,如下面的代码所示:

    public class LoginModel{    [Required(ErrorMessage = "User name is required")]    public string UserName { get; set; } = string.Empty;    [Required(ErrorMessage = "Password is required")]    public string Password { get; set; } = string.Empty;}
    public class AppUser : IdentityUser{    public string FirstName { get; set; }    public string LastName { get; set; }    public string ProfilePicture { get; set; }}
    

    如果您向 AppUser 类添加了额外的属性,您还需要为 AddOrUpdateAppUserModel 添加相应的属性。

  8. 接下来,我们需要配置身份验证服务。首先,让我们更新 appsettings.json 文件以提供 JWT 令牌的配置:

    "JwtConfig": {    "ValidAudiences": "http://localhost:5056",    "ValidIssuer": "http://localhost:5056",    "Secret": "c1708c6d-7c94-466e-aca3-e09dcd1c2042"  }
    

    根据您的需求更新配置。因为我们使用相同的 Web API 来颁发和验证 JWT 令牌,所以我们使用相同的 URL 作为 ValidAudiencesValidIssuer 属性。Secret 属性用于签名 JWT 令牌。您可以使用任何字符串作为密钥。在这种情况下,我们可以使用一个 GUID 值。请注意,这只是为了演示目的。在实际应用程序中,您应该将密钥存储在安全的位置,例如 Azure Key Vault。

  9. 如下所示更新 Program.cs 文件中的代码:

    // Omitted for brevitybuilder.Services.AddControllers();builder.Services.AddDbContext<AppDbContext>();builder.Services.AddIdentityCore<AppUser>()    .AddEntityFrameworkStores<AppDbContext>()    .AddDefaultTokenProviders();builder.Services.AddAuthentication(options =>{    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;}).AddJwtBearer(options =>{    var secret = builder.Configuration["JwtConfig:Secret"];    var issuer = builder.Configuration["JwtConfig:ValidIssuer"];    var audience = builder.Configuration["JwtConfig:ValidAudiences"];    if (secret is null || issuer is null || audience is null)    {        throw new ApplicationException("Jwt is not set in the configuration");    }    options.SaveToken = true;    options.RequireHttpsMetadata = false;    options.TokenValidationParameters = new TokenValidationParameters()    {        ValidateIssuer = true,        ValidateAudience = true,        ValidAudience = audience,        ValidIssuer = issuer,        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))    };});// Omitted for brevityapp.UseHttpsRedirection();app.UseAuthentication();app.UseAuthorization();// Omitted for brevity
    

    在前面的代码中,我们配置了身份验证服务以使用 JWT 令牌。AddIdentityCore() 方法为指定的 User 类型添加并配置了身份系统。我们还向服务集合中添加了 AppDbContextAppUser,并指定我们想要使用 EF Core 来存储用户数据。AddDefaultTokenProviders() 方法为应用程序添加了默认的令牌提供者,这些提供者用于生成令牌。Services.AddAuthentication() 方法配置了身份验证服务以使用 JWT 令牌。AddJwtBearer() 方法配置了 JWT 携带者身份验证处理程序,包括令牌验证参数。我们使用 appsettings.json 文件中的某些配置来配置令牌验证参数。

    最后,我们需要调用 UseAuthentication()UseAuthorization() 方法来在应用程序中启用身份验证和授权。

  10. 现在,是时候创建和更新数据库了。我们已经创建了数据库上下文和用户实体。因此,现在我们需要创建数据库。为此,只需运行以下命令:

    dotnet ef migrations add InitialDbdotnet ef database update
    

    如果命令执行成功,您应该看到以下表创建的数据库:

图 8.1 – 由 ASP.NET Core Identity 创建的数据库表

图 8.1 – 由 ASP.NET Core Identity 创建的数据库表

  1. 另一种检查数据库是否已创建的方法是将以下代码添加到 Program.cs 文件中:

    using (var serviceScope = app.Services.CreateScope()){    var services = serviceScope.ServiceProvider;    // Ensure the database is created.    var dbContext = services.GetRequiredService<AppDbContext>();    dbContext.Database.EnsureCreated();}
    

    您可以使用这两种方法中的任何一种来检查数据库是否在开发环境中创建。

    用户数据将存储在这些表中,当使用 ASP.NET Core Identity 提供的默认表时,这很方便。

接下来,让我们将 Authorize 属性应用于 WeatherForecastController 以启用身份验证和授权:

  1. 通过添加 [Authorize] 属性来更新 WeatherForecastController,如下所示:

    [Authorize][ApiController][Route("[controller]")]public class WeatherForecastController : ControllerBase{    // ...}
    

    此属性将确保在访问控制器之前用户已认证。如果用户未认证,则控制器将返回 401 未授权 响应。通过运行应用程序并调用 /WeatherForecast 端点来测试此功能。您应该看到一个 401 响应:

图 8.2 – 当用户未认证时,控制器返回 401 响应

图 8.2 – 当用户未认证时,控制器返回 401 响应

Authorize 属性可以应用于控制器或操作方法。如果属性应用于控制器,则控制器中的所有操作方法都将受到保护。如果属性应用于操作方法,则只有该操作方法将受到保护。

您还可以使用 AllowAnonymous 属性来允许控制器或操作方法进行匿名访问。请注意,AllowAnonymous 属性会覆盖 Authorize 属性。因此,如果您将这两个属性都应用于控制器或操作方法,AllowAnonymous 属性将具有优先权,这意味着控制器或操作方法将对所有用户开放。

  1. 接下来,让我们添加 AccountController 来处理认证请求。例如,我们需要提供一个 /account/register 端点。当用户发送用户名和密码时,应用程序将在数据库中创建用户的记录并生成 JWT 令牌。

    要生成 JWT 令牌,我们需要提供以下信息:

    • appsettings.json 文件,如前所述。

    • 接下来,创建一个新的控制器名为 AccountController 来处理认证请求。在 Controllers 文件夹中创建一个新的类名为 AccountControllerAccountController 类应继承自 ControllerBase 类,如下面的代码所示:

      [ApiController][Route("[controller]")]public class AccountController(UserManager<AppUser> userManager, IConfiguration configuration) : ControllerBase{}
      

      我们使用 UserManager 类来管理用户。UserManager 类由 ASP.NET Core Identity 提供。我们还需要注入 IConfiguration 接口以从 appsettings.json 文件中获取配置值。

    • AccountController 类中创建一个名为 Register() 的新方法。此方法将用于注册新用户。Register() 方法应接受一个 AddOrUpdateAppUserModel 对象作为参数,如下面的代码所示:

      [HttpPost("register")]public async Task<IActionResult> Register([FromBody] AddOrUpdateAppUserModel model){    // Check if the model is valid    if (ModelState.IsValid)    {        var existedUser = await userManager.FindByNameAsync(model.UserName);        if (existedUser != null)        {            ModelState.AddModelError("", "User name is already taken");            return BadRequest(ModelState);        }        // Create a new user object        var user = new AppUser()        {            UserName = model.UserName,            Email = model.Email,            SecurityStamp = Guid.NewGuid().ToString()        };        // Try to save the user        var result = await userManager.CreateAsync(user, model.Password);        // If the user is successfully created, return Ok        if (result.Succeeded)        {            var token = GenerateToken(model.UserName);            return Ok(new { token });        }        // If there are any errors, add them to the ModelState object        // and return the error to the client        foreach (var error in result.Errors)        {            ModelState.AddModelError("", error.Description);        }    }    // If we got this far, something failed, redisplay form    return BadRequest(ModelState);}
      private string? GenerateToken(string userName){    var secret = _configuration["JwtConfig:Secret"];    var issuer = _configuration["JwtConfig:ValidIssuer"];    var audience = _configuration["JwtConfig:ValidAudiences"];    if (secret is null || issuer is null || audience is null)    {        throw new ApplicationException("Jwt is not set in the configuration");    }    var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));    var tokenHandler = new JwtSecurityTokenHandler();    var tokenDescriptor = new SecurityTokenDescriptor    {        Subject = new ClaimsIdentity(new[]        {            new Claim(ClaimTypes.Name, userName)        }),        Expires = DateTime.UtcNow.AddDays(1),        Issuer = issuer,        Audience = audience,        SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)    };    var securityToken = tokenHandler.CreateToken(tokenDescriptor);    var token = tokenHandler.WriteToken(securityToken);    return token;}
      

      在前面的代码中,我们使用 JwtSecurityTokenHandler 类来生成 JWT 令牌。JwtSecurityTokenHandler 类由 System.IdentityModel.Tokens.Jwt NuGet 包提供。首先,我们从 appsettings.json 文件中获取配置值。然后,我们使用密钥创建一个 SymmetricSecurityKey 对象。SymmetricSecurityKey 对象用于签名令牌。

      接下来,我们创建了一个 SecurityTokenDescriptor 对象,它包含以下属性:

      • Subject:令牌的主题。主题可以是任何值,例如用户名、电子邮件地址等。

      • Expires:令牌的过期日期。

      • Issuer:令牌的发行者。

      • Audience:令牌的受众。

      • SigningCredentials:用于签名令牌的凭据。注意,我们使用 HmacSha256Signature 算法来签名令牌。这是一个用于数字签名的 256 位 HMAC 密码学算法。如果你遇到类似 IDX10603: The algorithm: 'HS256' requires the SecurityKey.KeySize to be greater than '128' bits. 的错误,请检查 appsettings.json 文件中的密钥。密钥长度应至少为 16 个字符(16 * 8 = 128)。

      最后,我们使用了 JwtSecurityTokenHandler 类来创建并将令牌写入字符串值。

    • 现在,我们可以测试 Register() 方法。使用 dotnet run 运行应用程序。你可以使用 Swagger UI 或其他任何工具来测试 API。向 http://localhost:5056/account/register 端点发送以下 JSON 数据的 POST 请求:

      {  "userName": "admin",  "email": "admin@example.com",  "password": "Passw0rd!"}
      

      你将看到以下类似的响应:

图 8.3 – 注册新用户

图 8.3 – 注册新用户

如我们所见,Register() 方法返回一个 JWT 令牌。该令牌有效期为 1 天。我们可以使用此令牌在未来对用户进行身份验证。如果你检查数据库,你将看到在 AspNetUsers 表中已创建新用户,密码已散列,如下面的截图所示:

图 8.4 – 新用户已在数据库中创建

图 8.4 – 新用户已在数据库中创建

  1. 复制令牌值并向 /WeatherForecast 端点发送 GET 请求。你需要将 Bearer 令牌附加到请求头中,如下面的截图所示:

图 8.5 – 使用 Bearer 令牌发送请求

图 8.5 – 使用 Bearer 令牌发送请求

重要提示

当你将 Bearer 令牌附加到请求时,请注意令牌值之前有一个 Bearer 前缀。因此,实际格式应该是

Authorization: Bearer <token>

好的,它工作了!你的 API 现在已经安全。下一步是创建一个登录方法来对用户进行身份验证。这相当直接。在 AccountController 类中创建一个名为 Login 的新方法。Login() 方法应该接受一个 AddOrUpdateAppUserModel 对象作为参数,如下面的代码所示:

[HttpPost("login")]public async Task<IActionResult> Login([FromBody] LoginModel model)
{
    // Get the secret in the configuration
    // Check if the model is valid
    if (ModelState.IsValid)
    {
        var user = await _userManager.FindByNameAsync(model.UserName);
        if (user != null)
        {
            if (await _userManager.CheckPasswordAsync(user, model.Password))
            {
                var token = GenerateToken(model.UserName);
                return Ok(new { token });
            }
        }
        // If the user is not found, display an error message
        ModelState.AddModelError("", "Invalid username or password");
    }
    return BadRequest(ModelState);
}

我们使用 UserManager 类通过用户名查找用户。如果找到用户,我们使用 CheckPasswordAsync() 方法来检查密码。如果密码正确,我们生成一个新的令牌并将其返回给客户端。如果未找到用户或密码不正确,我们向客户端返回错误消息。

到目前为止,我们已经创建了一个具有基本身份验证和授权的 Web API 项目。我们还创建了一个控制器来处理与账户相关的操作。注意,在这个例子中,我们没有实现任何特定的授权规则。所有经过身份验证的用户都可以访问 WeatherForecast 端点。

接下来,我们将讨论 JWT 令牌的详细信息。

理解 JWT 令牌结构

JWT 令牌是一个字符串值。它由三部分组成,由点(.)分隔:

  • 头部

  • 有效载荷

  • 签名

头部和有效载荷使用 Base64Url 算法进行编码。我们可以使用 jwt.io 来解码令牌。将响应体中的令牌复制并粘贴到 jwt.io 网站上的 Encoded 字段。您将看到解码后的令牌,如下面的截图所示:

图 8.6 – 解码 JWT 令牌

图 8.6 – 解码 JWT 令牌

头部包含用于签名令牌的算法。在我们的案例中,我们使用 HmacSha256Signature 算法。因此,解码后的头部如下所示:

{  "alg": "HS256",
  "typ": "JWT"
}

有效载荷包含令牌的声明和一些其他附加数据。在我们的案例中,解码后的有效载荷如下所示:

{  "unique_name": "admin",
  "nbf": 1679779000,
  "exp": 1679865400,
  "iat": 1679779000,
  "iss": "http://localhost:5056",
  "aud": "http://localhost:5056"
}

在 RFC7519 中定义了一些推荐(但不是强制性的)已注册的声明名称:

  • sub: sub(主题)声明标识了令牌的主题主体

  • nbf: nbf(不可用之前)声明标识了令牌必须不被接受处理的之前的时间

  • exp: exp(过期时间)声明标识了令牌必须不被接受处理的过期时间或之后的时间

  • iat: iat(发行时间)声明标识了令牌被发行的时间

  • iss: iss(发行者)声明标识了发行令牌的主体

  • aud: aud(受众)声明标识了令牌旨在为其提供的接收者

注意,在我们的案例中,我们使用相同的值用于 issaud 声明,因为我们使用相同的 Web API 发行和验证令牌。在实际应用中,通常有一个单独的认证服务器来发行令牌,这样 issaud 声明就有不同的值。

签名用于验证令牌,以确保令牌未被篡改。有各种算法可以生成签名。在我们的案例中,我们使用 HmacSha256Signature 算法,因此签名是使用以下公式生成的:

HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

因此,令牌通常看起来像 xxxxx.yyyyy.zzzzz,可以轻松地通过 HTTP 请求头传递,或者存储在浏览器的本地存储中。

消费 API

到目前为止,我们有一个安全的 API。您可以在 samples\chapter8\AuthenticationDemo\BasicAuthenticationDemo\end 文件夹中找到一个名为 AuthenticationDemoClient 的示例客户端应用程序。客户端应用程序是一个简单的控制台应用程序。它使用 HttpClient 类向 API 发送 HTTP 请求。主要代码如下:

登录:

var httpClient = new HttpClient();// Create a post request with the user name and password
var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost:5056/authentication/login");
request.Content = new StringContent(JsonSerializer.Serialize(new LoginModel()
{
    UserName = userName,
    Password = password
}), Encoding.UTF8, "application/json");
var response = await httpClient.SendAsync(request);
var token = string.Empty;
if (response.IsSuccessStatusCode)
{
    var content = await response.Content.ReadAsStringAsync();
    var jwtToken = JsonSerializer.Deserialize<JwtToken>(content);
    Console.WriteLine(jwtToken.token);
    token = jwtToken.token;
}

获取天气预报:

request = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5056/WeatherForecast");// Add the token to the request header
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
response = await httpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
    var content = await response.Content.ReadAsStringAsync();
    var weatherForecasts = JsonSerializer.Deserialize<IEnumerable<WeatherForecast>>(content);
    foreach (var weatherForecast in weatherForecasts)
    {
        Console.WriteLine("Date: {0:d}", weatherForecast.Date);
        Console.WriteLine($"Temperature (C): {weatherForecast.TemperatureC}");
        Console.WriteLine($"Temperature (F): {weatherForecast.TemperatureF}");
        Console.WriteLine($"Summary: {weatherForecast.Summary}");
    }
}

首先,客户端应用程序向登录 API 发送请求以获取令牌。然后,它将令牌附加到请求头并发送请求到天气预报 API。如果令牌有效,API 将返回数据。

配置 Swagger UI 以支持授权

你可能更喜欢使用 Swagger UI 来测试 API。Swagger UI 的默认配置不支持授权。我们需要更新 Program 类中的 AddSwaggerGen() 方法以支持授权。更新 Program 类如下:

builder.Services.AddSwaggerGen(c =>{
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"",
        Name = "Authorization",
        In = ParameterLocation.Header,
        Type = SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            new string[] { }
        }
    });
});

之前的代码向 Swagger UI 添加了 Bearer 安全定义。AddSecurityRequirement 方法向 Swagger UI 添加了 Authorization 标头。现在,当你运行应用程序时,你将在 Swagger UI 中看到 授权 按钮。点击 授权 按钮;你将看到一个弹出窗口,允许你输入令牌,如图下所示:

图 8.7 – 在 Swagger UI 中输入令牌

图 8.7 – 在 Swagger UI 中输入令牌

字段中输入令牌。然后,点击 授权 按钮。现在,你可以直接使用 Swagger UI 测试 API:

注意

你需要在令牌前添加 Bearer 前缀,并空一格。

图 8.8 – Swagger UI 已授权

图 8.8 – Swagger UI 已授权

你可以在此处找到有关 Swagger UI 配置的更多信息:github.com/domaindrivendev/Swashbuckle.AspNetCore

在本节中,我们讨论了支持身份验证和授权的 Web API 项目的实现,包括创建控制器来处理登录请求。此外,我们还探讨了如何生成 JWT 令牌并验证它,以及如何使用控制台应用程序访问项目资源以及如何配置 Swagger UI 以授权测试 API。

在下一节中,我们将学习更多关于 ASP.NET Core 中的授权知识。我们将探讨几种授权类型,包括基于角色的授权、基于声明的授权和基于策略的授权。

深入探讨授权

授权是确定用户是否允许执行特定操作的过程。在上一节中,我们实现了一个支持简单身份验证和授权的 Web API 项目。通过使用 Authorize 属性,只有经过身份验证的用户才能访问 API。然而,在许多场景中,我们需要实现细粒度授权。例如,某些资源仅对管理员可访问,而某些资源则对普通用户可访问。在本节中,我们将探讨如何在 ASP.NET Core 中实现细粒度授权,包括基于角色的授权、基于声明的授权和基于策略的授权。

基于角色的授权

你可以在本书的 GitHub 仓库中找到入门应用程序和完成的应用程序,位于 chapter8/AuthorizationDemo/RoleBasedAuthorizationDemo。入门应用程序与我们之前章节中创建的应用程序类似:

  1. 我们将从入门级应用程序开始。别忘了创建数据库并使用以下命令运行迁移:

    AppRoles that is defined as follows:
    
    

    public static class AppRoles{    public const string Administrator = "Administrator";    public const string User = "User";    public const string VipUser = "VipUser";}

    
    
  2. Program 类中,我们需要在 AddIdentityCore() 方法之后显式调用 AddRoles() 方法。更新的代码如下:

    // Use the `AddRoles()` methodbuilder.Services.AddIdentityCore<AppUser>()    .AddRoles<IdentityRole>()    .AddEntityFrameworkStores<AppDbContext>()    .AddDefaultTokenProviders();
    

    如果您使用 AddIdentity() 方法,则不需要调用 AddRoles() 方法。AddIdentity() 方法将内部调用 AddRoles() 方法。

  3. 我们还需要检查角色是否存在于数据库中。如果不存在,我们将创建角色。添加以下代码:

    using (var serviceScope = app.Services.CreateScope()){    var services = serviceScope.ServiceProvider;    var roleManager = app.Services.GetRequiredService<RoleManager<IdentityRole>>();    if (!await roleManager.RoleExistsAsync(AppRoles.User))    {        await roleManager.CreateAsync(new IdentityRole(AppRoles.User));    }    if (!await roleManager.RoleExistsAsync(AppRoles.VipUser))    {        await roleManager.CreateAsync(new IdentityRole(AppRoles.VipUser));    }    if (!await roleManager.RoleExistsAsync(AppRoles.Administrator))    {        await roleManager.CreateAsync(new IdentityRole(AppRoles.Administrator));    }}
    
  4. 使用 dotnet run 运行应用程序。您将看到角色已创建在数据库中:

图 8.9 – 数据库中的角色

图 8.9 – 数据库中的角色

  1. AccountController 类中,我们有一个 Register() 方法,用于注册新用户。让我们更新 Register() 方法,将 User 角色分配给新用户。更新的代码如下:

    // Omitted for brevity// Try to save the uservar userResult = await userManager.CreateAsync(user, model.Password);// Add the user to the "User" rolevar roleResult = await userManager.AddToRoleAsync(user, AppRoles.User);// If the user is successfully created, return Okif (userResult.Succeeded && roleResult.Succeeded){    var token = GenerateToken(model.UserName);    return Ok(new { token });}
    

    同样,我们可以创建一个新的操作来注册管理员或 VIP 用户。您可以在完成的应用程序中查看代码。

  2. 您可以使用您喜欢的任何 HTTP 客户端注册新的管理员。用户创建后,您可以在数据库中查看用户及其角色,如图图 8**.10所示:

图 8.10 – 数据库中的用户及其角色

图 8.10 – 数据库中的用户及其角色

AspNetUserRoles 表的数据用于存储用户和角色之间的关系。UserId 列是 AspNetUsers 表的主键,而 RoleId 列是 AspNetRoles 表的主键。

  1. 接下来,我们需要更新用于生成 JWT 令牌的方法。当我们生成令牌时,我们需要在令牌中包含用户的角色。我们可以使用 GetRolesAsync() 方法获取角色,然后将它们添加到声明中。更新的代码如下:

    var userRoles = await userManager.GetRolesAsync(user);var claims = new List<Claim>{    new(ClaimTypes.Name, userName)};claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role)));var tokenDescriptor = new SecurityTokenDescriptor{    Subject = new ClaimsIdentity(claims),    Expires = DateTime.UtcNow.AddDays(1),    Issuer = issuer,    Audience = audience,    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)};
    
  2. 尝试运行应用程序并注册一个新用户或使用现有用户登录。复制响应中的令牌并将其粘贴到 jwt.io 网站以解码负载。您将看到令牌中包含了角色,如下所示:

    {  "unique_name": "admin",  "role": "Administrator",  "nbf": 1679815694,  "exp": 1679902094,  "iat": 1679815694,  "iss": "http://localhost:5056",  "aud": "http://localhost:5056"}
    
  3. 现在,让我们更新 WeatherForecastController 类以实现基于角色的授权。添加一个新的管理员操作,如下所示:

    [HttpGet("admin", Name = "GetAdminWeatherForecast")][Authorize(Roles = AppRoles.Administrator)]public IEnumerable<WeatherForecast> GetAdmin(){    return Enumerable.Range(1, 20).Select(index => new WeatherForecast    {        Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),        TemperatureC = Random.Shared.Next(-20, 55),        Summary = Summaries[Random.Shared.Next(Summaries.Length)]    })    .ToArray();}
    

    Authorize 属性用于指定允许访问 API 的角色。在上面的代码中,只有具有 Administrator 角色的认证用户可以访问 API。

    现在,您可以测试 API。如果您使用普通用户的令牌访问 /WeatherForecast/admin 端点,您将收到 403 禁止访问的响应。

通常,管理员角色应该有权限访问所有资源。但在我们当前的应用程序中,管理员用户无法访问/WeatherForecast端点。有多种方法可以解决这个问题。

第一种方法是,当我们注册一个新的管理员时,我们可以将Administrator角色分配给用户,并将User角色(或任何其他角色)分配给用户。这样,管理员用户就可以访问所有资源。

我们还可以更新Authorize属性以允许多个角色,如下所示:

[HttpGet(Name = "GetWeatherForecast")][Authorize(Roles = $"{AppRoles.User},{AppRoles.VipUser},{AppRoles.Administrator}")]
public IEnumerable<WeatherForecast> Get()
{
    // Omitted for brevity
}

上述代码表示用户必须至少拥有指定的其中一个角色才能访问 API。

注意,如果你对一个操作应用了多个具有指定角色的Authorize属性,用户必须拥有所有这些角色才能访问 API。例如,考虑以下代码:

[HttpGet("vip", Name = "GetVipWeatherForecast")][Authorize(Roles = AppRoles.User)]
[Authorize(Roles = AppRoles.VipUser)]
public IEnumerable<WeatherForecast> GetVip()
{
    // Omitted for brevity
}

上述代码表示用户必须同时拥有UserVipUser角色才能访问 API。如果用户只有一个角色,用户将收到 403 禁止响应。

此外,我们还可以定义一个策略来指定允许访问 API 的角色。例如,在Program类中,我们可以添加以下代码:

builder.Services.AddAuthorization(options =>{
    options.AddPolicy("RequireAdministratorRole", policy => policy.RequireRole(AppRoles.Administrator));
    options.AddPolicy("RequireVipUserRole", policy => policy.RequireRole(AppRoles.VipUser));
    options.AddPolicy("RequireUserRole", policy => policy.RequireRole(AppRoles.User));
    options.AddPolicy("RequireUserRoleOrVipUserRole", policy => policy.RequireRole(AppRoles.User, AppRoles.VipUser));
});

然后,我们可以更新Authorize属性以使用策略,如下所示:

[HttpGet("admin-with-policy", Name = "GetAdminWeatherForecastWithPolicy")][Authorize(Policy = "RequireAdministratorRole")]
public IEnumerable<WeatherForecast> GetAdminWithPolicy()
{
    // Omitted for brevity
}

如果policy.RequireRole()方法在参数中具有多个角色,用户必须至少拥有其中一个角色才能访问 API。您可以在完成的应用程序中查看代码。

这样,我们就实现了 ASP.NET Core 中的基于角色的授权。在下一节中,我们将学习如何实现基于声明的授权。

基于声明的授权

当用户进行身份验证时,用户将有一组声明,这些声明用于存储有关用户的信息。例如,用户可以有一个指定用户角色的声明。因此,从技术上讲,角色也是声明,但它们是用于存储用户角色的特殊声明。我们可以在声明中存储其他信息,例如用户的名字、电子邮件地址、出生日期、驾照号码等。一旦我们这样做,授权系统就可以检查声明以确定用户是否有权访问资源。基于声明的授权比基于角色的授权提供了更细粒度的访问控制,但实现和管理可能更复杂。

你可以在本书 GitHub 仓库的chapter8/AuthorizationDemo/ClaimBasedAuthorizationDemo文件夹中找到入门应用程序和完成的应用程序:

  1. 我们将从入门应用程序开始。不要忘记使用以下命令创建数据库并运行迁移:

    ClaimTypes class that contains the common claim types, such as NameIdentifier,  DateOfBirth, Email, Gender, GivenName, Name, PostalCode, and others, including Role. This is why we said that roles are also claims. You can also define your own claim types. For example, we can define the following claim types in the AppClaimTypes class:
    
    

    public static class AppClaimTypes{    public const string DrivingLicenseNumber = "DrivingLicenseNumber";    public const string AccessNumber = "AccessNumber";}

    
    
  2. 还可以创建一个新的AppAuthorizationPolicies类来定义授权策略:

    public static class AppAuthorizationPolicies{    public const string RequireDrivingLicenseNumber = "RequireDrivingLicenseNumber";    public const string RequireAccessNumber = "RequireAccessNumber";}
    
  3. 然后,我们可以在用户登录时将声明添加到令牌中。更新 AccountController 类中的 GenerateToken 方法,如下所示:

    // Omitted for brevityvar tokenDescriptor = new SecurityTokenDescriptor{    Subject = new ClaimsIdentity(new[]    {        new Claim(ClaimTypes.Name, userName),        // Suppose the user's information is stored in the database so that we can retrieve it from the database        new Claim(ClaimTypes.Country, "New Zealand"),        // Add our custom claims        new Claim(AppClaimTypes.AccessNumber, "12345678"),        new Claim(AppClaimTypes.DrivingLicenseNumber, "123456789")    }),    Expires = DateTime.UtcNow.AddDays(1),    Issuer = issuer,    Audience = audience,    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)};// Omitted for brevity
    

    我们可以将任何声明添加到令牌中。在前面提到的代码中,我们向令牌中添加了 CountryAccessNumberDrivingLicenseNumber 声明。

  4. 假设我们有一个要求,只有拥有驾驶执照的用户才能访问资源。我们可以通过向 Program 类中添加以下代码来实现这一点:

    builder.Services.AddAuthorization(options =>{    options.AddPolicy(AppAuthorizationPolicies.RequireDrivingLicense, policy => policy.RequireClaim(AppClaimTypes.DrivingLicenseNumber));    options.AddPolicy(AppAuthorizationPolicies.RequireAccessNumber, policy => policy.RequireClaim(AppClaimTypes.AccessNumber));});
    

    因此,基于角色的授权和基于声明的授权之间的区别在于,基于声明的授权使用 policy.RequireClaim() 来检查声明,而基于角色的授权使用 policy.RequireRole() 来检查角色。

  5. 到目前为止,我们可以更新 Authorize 属性,使其使用策略,如下所示:

    [Authorize(Policy = AppAuthorizationPolicies.RequireDrivingLicense)][HttpGet("driving-license")]public IActionResult GetDrivingLicense(){    var drivingLicenseNumber = User.Claims.FirstOrDefault(c => c.Type == AppClaimTypes.DrivingLicenseNumber)?.Value;    return Ok(new { drivingLicenseNumber });}
    
  6. 运行应用程序并测试 /WeatherForecast/driving-license 端点。您将得到一个 401 未授权响应,因为用户没有 DrivingLicenseNumber 声明。注册一个用户或登录以获取令牌。然后,将令牌添加到 Authorization 标头中,再次调用 /WeatherForecast/driving-license 端点。您将得到一个包含 drivingLicenseNumber 的响应体中的 200 OK 响应。

    令牌现在包含声明,如下面的 JSON 响应所示:

    {  "unique_name": "user",  "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/country": "New Zealand",  "AccessNumber": "12345678",  "DrivingLicenseNumber": "123456789",  "nbf": 1679824749,  "exp": 1679911149,  "iat": 1679824749,  "iss": "http://localhost:5056",  "aud": "http://localhost:5056"}
    
  7. 这是最简单的基于声明的授权实现方式。当前方法仅检查令牌是否包含声明;它不检查声明的值。我们也可以检查值。RequireClaim() 方法还有一个接受 allowedValues 作为参数的重载版本。例如,我们有一个只能由新西兰用户访问的资源。我们可以更新 Program 类,如下所示:

    builder.Services.AddAuthorization(options =>{    // Omitted for brevity    options.AddPolicy(AppAuthorizationPolicies.RequireCountry, policy => policy.RequireClaim(ClaimTypes.Country, "New Zealand"));});
    options.AddPolicy(AppAuthorizationPolicies.RequireCountry, policy => policy.RequireClaim(ClaimTypes.Country, "New Zealand", "Australia"));
    

    控制器中的操作如下所示:

    [Authorize(Policy = AppAuthorizationPolicies.RequireCountry)][HttpGet("country")]public IActionResult GetCountry(){    var country = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Country)?.Value;    return Ok(new { country });}
    

    您可以通过调用 /WeatherForecast/country 端点来测试 API。现在,只有具有 Country 声明且值为 New Zealand 的用户才能访问资源。

与角色类似,我们可以将多个策略应用于资源。例如,我们可以要求用户必须同时拥有 DrivingLicenseAccessNumber 声明才能访问资源。就像角色一样,您可以将两个策略添加到 Authorize 属性中,这意味着用户必须同时拥有 DrivingLicenseAccessNumber 声明才能访问资源。以下是一个示例:

[Authorize(Policy = AppAuthorizationPolicies.RequireDrivingLicense)][Authorize(Policy = AppAuthorizationPolicies.RequireAccessNumber)]
[HttpGet("driving-license-and-access-number")]
public IActionResult GetDrivingLicenseAndAccessNumber()
{
    var drivingLicenseNumber = User.Claims.FirstOrDefault(c => c.Type == AppClaimTypes.DrivingLicenseNumber)?.Value;
    var accessNumber = User.Claims.FirstOrDefault(c => c.Type == AppClaimTypes.AccessNumber)?.Value;
    return Ok(new { drivingLicenseNumber, accessNumber });
}

另一种方法是使用 RequireAssertion() 方法,它允许我们执行自定义逻辑来检查声明。更新 Program 类,如下所示:

builder.Services.AddAuthorization(options =>{
    // Omitted for brevity
    options.AddPolicy(AppAuthorizationPolicies.RequireDrivingLicenseAndAccessNumber, policy => policy.RequireAssertion(context =>
    {
        var hasDrivingLicenseNumber = context.User.HasClaim(c => c.Type == AppClaimTypes.DrivingLicenseNumber);
        var hasAccessNumber = context.User.HasClaim(c => c.Type == AppClaimTypes.AccessNumber);
        return hasDrivingLicenseNumber && hasAccessNumber;
    }));
});

在前面的代码中,context 参数包含一个 User 属性,该属性包含声明。我们可以使用 HasClaim() 方法来检查用户是否有声明。然后,如果用户同时拥有 DrivingLicenseNumberAccessNumber 声明,我们可以返回 true;否则,返回 false。您也可以使用 context.User.Claims 属性来获取声明并按照您的要求检查值。

控制器中的操作如下所示:

[Authorize(Policy = AppAuthorizationPolicies.RequireDrivingLicenseAndAccessNumber)][HttpGet("driving-license-and-access-number")]
public IActionResult GetDrivingLicenseAndAccessNumber()
{
    // Omitted for brevity
}

在本节中,我们学习了如何在 ASP.NET Core 中实现基于声明的授权。我们还学习了如何使用 RequireAssertion() 方法来检查声明。如果我们需要更复杂的授权逻辑,我们可以使用策略授权。但首先,让我们学习在 ASP.NET Core 中授权是如何工作的。

理解授权过程

在上一节中,我们学习了如何实现基于角色的授权和基于声明的授权。让我们深入了解细节。您可能已经注意到,当我们使用基于角色的授权或基于声明的授权时,我们需要在 AddAuthorization 方法中调用 AddPolicy() 方法。AddPolicy() 方法的签名如下:

public void AddPolicy(string name, Action<AuthorizationPolicyBuilder> configurePolicy){
    // Omitted for brevity
}

AddPolicy() 方法接受两个参数:

  • 一个 name 参数,它是策略的名称

  • configurePolicy 参数,它是一个接受 AuthorizationPolicyBuilder 参数的委托

您可以按 F12 查看类 AuthorizationPolicyBuilder 的源代码。您会发现它有一些配置策略的方法,例如 RequireRole()RequireClaim() 等。RequireRole 方法的源代码如下:

public AuthorizationPolicyBuilder RequireRole(IEnumerable<string> roles){
    ArgumentNullThrowHelper.ThrowIfNull(roles);
    Requirements.Add(new RolesAuthorizationRequirement(roles));
    return this;
}

RequireClaim() 方法的源代码如下所示:

public AuthorizationPolicyBuilder RequireClaim(string claimType){
    ArgumentNullThrowHelper.ThrowIfNull(claimType);
    Requirements.Add(new ClaimsAuthorizationRequirement(claimType, allowedValues: null));
    return this;
}

RequireRole()RequireClaim() 方法在底层调用 Requirements.Add() 方法。那么,Requirements 对象是什么?

我们正在接近 ASP.NET Core 授权的核心。Requirements 对象的定义如下:

public IList<IAuthorizationRequirement> Requirements { get; set; } = new List<IAuthorizationRequirement>();

AuthorizationPolicyBuilder 类中的 Requirements 对象是一个 IAuthorizationRequirement 对象的列表。IAuthorizationRequirement 接口只是一个标记服务,它没有任何方法。让我们在 RolesAuthorizationRequirement 类和 ClaimsAuthorizationRequirement 类上按 F12。我们将看到它们的源代码:

// RolesAuthorizationRequirementpublic class RolesAuthorizationRequirement : AuthorizationHandler<RolesAuthorizationRequirement>, IAuthorizationRequirement
{
    // Omitted for brevity
}
// ClaimsAuthorizationRequirement
public class ClaimsAuthorizationRequirement : AuthorizationHandler<ClaimsAuthorizationRequirement>, IAuthorizationRequirement
{
    // Omitted for brevity
}

如我们所见,RolesAuthorizationRequirementClaimsAuthorizationRequirement 类都实现了 IAuthorizationRequirement 接口。它们还实现了 AuthorizationHandler<TRequirement> 类,该类定义如下:

public abstract class AuthorizationHandler<TRequirement> : IAuthorizationHandler        where TRequirement : IAuthorizationRequirement
{
    /// <summary>
    /// Makes a decision if authorization is allowed.
    /// </summary>
    /// <param name="context">The authorization context.</param>
    public virtual async Task HandleAsync(AuthorizationHandlerContext context)
    {
        foreach (var req in context.Requirements.OfType<TRequirement>())
        {
            await HandleRequirementAsync(context, req).ConfigureAwait(false);
        }
    }
    /// <summary>
    /// Makes a decision if authorization is allowed based on a specific requirement.
    /// </summary>
    /// <param name="context">The authorization context.</param>
    /// <param name="requirement">The requirement to evaluate.</param>
    protected abstract Task HandleRequirementAsync(AuthorizationHandlerContext context, TRequirement requirement);
}

因此,AuthorizationHandler<TRequirement> 类的每个实现都实现了 HandleRequirementAsync() 方法来检查要求。例如,RolesAuthorizationRequirement 类由以下代码组成:

public RolesAuthorizationRequirement(IEnumerable<string> allowedRoles){
    ArgumentNullThrowHelper.ThrowIfNull(allowedRoles);
    if (!allowedRoles.Any())
    {
        throw new InvalidOperationException(Resources.Exception_RoleRequirementEmpty);
    }
    AllowedRoles = allowedRoles;
}
/// <summary>
/// Gets the collection of allowed roles.
/// </summary>
public IEnumerable<string> AllowedRoles { get; }
/// <summary>
/// Makes a decision if authorization is allowed based on a specific requirement.
/// </summary>
/// <param name="context">The authorization context.</param>
/// <param name="requirement">The requirement to evaluate.</param>
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, RolesAuthorizationRequirement requirement)
{
    if (context.User != null)
    {
        var found = false;
        foreach (var role in requirement.AllowedRoles)
        {
            if (context.User.IsInRole(role))
            {
                found = true;
                break;
            }
        }
        if (found)
        {
            context.Succeed(requirement);
        }
    }
    return Task.CompletedTask;
}

当一个 RolesAuthorizationRequirement 实例被创建时,它从构造函数中接受一组角色。然后,它使用 HandleRequirementAsync() 方法来检查用户是否在角色中。如果用户在角色中,它调用 context.Succeed() 方法将 Succeeded 属性设置为 true。否则,它将 Succeeded 属性设置为 false

如果您检查 ClaimsAuthorizationRequirement 类的实现,您会发现它与 RolesAuthorizationRequirement 类类似。它接受 claimType 和一组 allowValues,并检查用户是否有该声明,以及声明值是否在 allowValues 集合中。

接下来的问题是——谁负责调用这些方法?

让我们回到 Program 类来理解中间件管道。我们在 Program 文件中有 app.UseAuthorization() 方法,它用于添加授权中间件。在 UseAuthorization 方法上按 F12。我们将能够查看其源代码:

public static IApplicationBuilder UseAuthorization(this IApplicationBuilder app){
    // Omitted for brevity
    return app.UseMiddleware<AuthorizationMiddleware>();
}

持续按 F12 检查 AuthorizationMiddleware 的源代码。您将在 Invoke() 方法中看到以下代码:

// Omitted for brevityvar authorizeData = endpoint?.Metadata.GetOrderedMetadata<IAuthorizeData>() ?? Array.Empty<IAuthorizeData>();
var policies = endpoint?.Metadata.GetOrderedMetadata<AuthorizationPolicy>() ?? Array.Empty<AuthorizationPolicy>();
// Omitted for brevity
var policyEvaluator = context.RequestServices.GetRequiredService<IPolicyEvaluator>();
var authenticateResult = await policyEvaluator.AuthenticateAsync(policy, context);
// Omitted for brevity
var authorizeResult = await policyEvaluator.AuthorizeAsync(policy, authenticateResult!, context, resource);
// Omitted for brevity

现在,我们更接近了。AuthorizationMiddleware 类从端点元数据中获取策略,然后调用 IPolicyEvaluator.AuthenticateAsync() 方法来检查用户是否已认证,之后它调用 IPolicyEvaluator.AuthorizeAsync() 方法来检查用户是否被授权。IPolicyEvaluator 接口定义如下:

public interface IPolicyEvaluator{
    Task<AuthenticateResult> AuthenticateAsync(AuthorizationPolicy policy, HttpContext context);
    Task<PolicyAuthorizationResult> AuthorizeAsync(AuthorizationPolicy policy, AuthenticateResult authenticationResult, HttpContext context, object? resource);
}

ASP.NET Core 框架已将 IPolicyEvaluator 的默认实现注入到 DI 容器中。您可以在以下位置找到 PolicyEvaluator 类的源代码:https://source.dot.net/#Microsoft.AspNetCore.Authorization.Policy/PolicyEvaluator.cs。您将看到它注入了一个 IAuthorizationService 对象,该对象定义如下:

public interface IAuthorizationService{
    Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements);
    Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, string policyName);
}

通过这样,我们找到了之前描述的 IAuthorizationRequirement 类!

您可以在以下位置找到 IAuthorizationService 的默认实现源代码:source.dot.net/#Microsoft.AspNetCore.Authorization/DefaultAuthorizationService.cs。它也被框架注入到 DI 容器中。核心代码如下:

public virtual async Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object? resource, IEnumerable<IAuthorizationRequirement> requirements){
    ArgumentNullThrowHelper.ThrowIfNull(requirements);
    var authContext = _contextFactory.CreateContext(requirements, user, resource);
    var handlers = await _handlers.GetHandlersAsync(authContext).ConfigureAwait(false);
    foreach (var handler in handlers)
    {
        await handler.HandleAsync(authContext).ConfigureAwait(false);
        if (!_options.InvokeHandlersAfterFailure && authContext.HasFailed)
        {
            break;
        }
    }
    var result = _evaluator.Evaluate(authContext);
    if (result.Succeeded)
    {
        _logger.UserAuthorizationSucceeded();
    }
    else
    {
        _logger.UserAuthorizationFailed(result.Failure);
    }
    return result;
}

因此,我们得到了以下调用堆栈:

  1. Program 类中定义授权策略(要求)。

  2. 将授权策略应用于端点。

  3. 将授权中间件应用于管道。

  4. 请求带有 Authorization 标头进入,该标头可以从 HttpContext 对象中检索。

  5. AuthorizationMiddleware 调用 IPolicyEvaluator.AuthorizeAsync() 方法。

  6. IPolicyEvaluator.AuthorizeAsync() 方法调用 IAuthorizationService.AuthorizeAsync() 方法。

  7. IAuthorizationService.AuthorizeAsync() 方法调用 IAuthorizationHandler.HandleAsync() 方法来检查用户是否被授权。

一旦我们理解了调用堆栈,我们就可以通过实现 IAuthorizationRequirementIAuthorizationHandlerIAuthorizationService 接口轻松实现授权策略。

基于策略的授权

在上一节中,我们解释了基于角色的授权和基于声明的授权都是在 IAuthorizationRequirementIAuthorizationHandlerIAuthorizationService 接口下实现的。如果我们有更复杂的授权逻辑,我们可以直接使用基于策略的授权,这允许我们定义自定义的授权策略来执行复杂的授权逻辑。

例如,我们有一个需要支持以下授权逻辑的场景:

  • 拥有 Premium 订阅并且基于新西兰的用户可以访问特殊付费内容

  • 拥有 Premium 订阅但不在新西兰的用户无法访问特殊付费内容

在现实世界中,可能还有其他复杂的授权逻辑。让我们使用基于策略的授权来实现上述授权逻辑。你可以在 /samples/chapter8/AuthorizationDemo/PolicyBasedAuthorization 文件夹中找到示例代码:

  1. 首先,向 Authentication 文件夹中添加两个类,如下所示:

    public static class AppClaimTypes{    public const string Subscription = "Subscription";}public static class AppAuthorizationPolicies{    public const string SpecialPremiumContent = "SpecialPremiumContent";}
    

    这些类定义了我们需要的声明类型和授权策略。你也可以直接在代码中使用字符串,但建议使用常量以避免拼写错误。

  2. AccountController 类中,更新 GenerateToken() 方法以添加一个新的声明,如下所示:

    private string? GenerateToken(string userName, string country){    // Omitted for brevity    var tokenDescriptor = new SecurityTokenDescriptor    {        Subject = new ClaimsIdentity(new[]        {            new Claim(ClaimTypes.Name, userName),            new Claim(AppClaimTypes.Subscription, "Premium"),            new Claim(ClaimTypes.Country, country)        }),        Expires = DateTime.UtcNow.AddDays(1),        Issuer = issuer,        Audience = audience,        SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)    };    // Omitted for brevity}
    

    我们向令牌中添加了一个新的声明,AppClaimTypes.Subscription,其值为 Premium。这个声明表示用户的订阅类型。我们还向令牌中添加了一个新的声明,ClaimTypes.Country。这个声明表示用户的国籍。在现实世界中,你可以从数据库中获取用户的订阅类型和国籍信息。为了简化,我们假设令牌中包含了订阅类型和国籍信息。

  3. 接下来,更新 AccountController 类中的 Login() 方法,将国家添加到声明中,并为新西兰用户创建另一个方法,如下所示:

    [HttpPost("login-new-zealand")]public async Task<IActionResult> LoginNewZealand([FromBody] LoginModel model){    if (ModelState.IsValid)    {        var user = await userManager.FindByNameAsync(model.UserName);        if (user != null)        {            if (await userManager.CheckPasswordAsync(user, model.Password))            {                var token = GenerateToken(model.UserName, "New Zealand");                return Ok(new { token });            }        }        // If the user is not found, display an error message        ModelState.AddModelError("", "Invalid username or password");    }    return BadRequest(ModelState);}[HttpPost("login")]public async Task<IActionResult> Login([FromBody] LoginModel model){    if (ModelState.IsValid)    {        var user = await userManager.FindByNameAsync(model.UserName);        if (user != null)        {            if (await userManager.CheckPasswordAsync(user, model.Password))            {                var token = GenerateToken(model.UserName, "Australia");                return Ok(new { token });            }        }        // If the user is not found, display an error message        ModelState.AddModelError("", "Invalid username or password");    }    return BadRequest(ModelState);}
    

    再次强调,这只是为了演示目的的简化实现。在现实世界中,通常只有一个登录端点,国家信息是从数据库或其他来源检索的,例如 IP 地址。

  4. 接下来,我们需要实现授权策略。在 Authorization 文件夹中创建一个名为 SpecialPremiumContentRequirement 的新类,如下所示:

    public class SpecialPremiumContentRequirement : IAuthorizationRequirement{    public string Country { get; }    public SpecialPremiumContentRequirement(string country)    {        Country = country;    }}
    

    这个类实现了 IAuthorizationRequirement 接口。Country 属性表示可以访问付费内容的国家。我们可以使用这个属性来检查用户是否有权访问付费内容。

  5. 接下来,我们需要实现 AuthorizationHandler 接口。在 Authorization 文件夹中创建一个名为 SpecialPremiumContentAuthorizationHandler 的类,如下所示:

    public class SpecialPremiumContentAuthorizationHandler : AuthorizationHandler<SpecialPremiumContentRequirement>{    protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, SpecialPremiumContentRequirement requirement)    {        var hasPremiumSubscriptionClaim = context.User.HasClaim(c => c.Type == "Subscription" && c.Value == "Premium");        if (!hasPremiumSubscriptionClaim)        {            return Task.CompletedTask;        }        var countryClaim = context.User.FindFirst(c => c.Type == ClaimTypes.Country);        if (countryClaim == null || string.IsNullOrWhiteSpace(countryClaim.ToString()))        {            return Task.CompletedTask;        }        if (countryClaim.Value == requirement.Country)        {            context.Succeed(requirement);        }        return Task.CompletedTask;    }}
    

    此处理器用于检查要求是否满足。如果用户拥有 Premium 订阅且位于可以访问高级内容的国家,则满足要求。否则,要求不满足。

  6. 接下来,我们需要注册授权策略和授权处理器。更新 Program 类,如下所示:

    builder.Services.AddAuthorization(options =>{    options.AddPolicy(AppAuthorizationPolicies.SpecialPremiumContent, policy =>    {        policy.Requirements.Add(new SpecialPremiumContentRequirement("New Zealand"));    });});builder.Services.AddSingleton<IAuthorizationHandler, SpecialPremiumContentAuthorizationHandler>();
    

    在前面的代码中,我们使用 SpecialPremiumContentRequirement 要求注册了授权策略 AppAuthorizationPolicies.SpecialPremiumContent。如果用户拥有 Premium 订阅且位于新西兰,则满足 SpecialPremiumContentRequirement 要求。我们还注册了 SpecialPremiumContentAuthorizationHandler 处理器作为单例服务。

  7. 最后,我们需要将授权策略应用到控制器上。打开 WeatherForecastController 类并添加一个新的操作,如下面的代码所示:

    [Authorize(Policy = AppAuthorizationPolicies.SpecialPremiumContent)][HttpGet("special-premium", Name = "GetPremiumWeatherForecast")]public IEnumerable<WeatherForecast> GetPremium(){    // Omitted for brevity}
    

    此操作只能由拥有 Premium 订阅且位于新西兰的用户访问。如果用户没有 Premium 订阅或不在新西兰,授权策略将不会满足,用户将无法访问该操作。

您可以像上一节那样测试应用程序。应用程序有两个登录端点——一个用于新西兰用户,一个用于澳大利亚用户。如果您以新西兰用户身份登录,您可以访问 WeatherForecast/special-premium 端点。否则,您将收到 403 响应。

对于基于策略的授权,有一些需要注意的点:

  • 您可以使用一个 AuthorizationHandler 实例来处理多个要求。在 HandleAsync() 方法中,您可以使用 AuthorizationHandlerContext.PendingRequirements 来获取所有挂起的请求,然后逐个检查它们。

  • 如果您有多个 AuthorizationHandler 实例,它们将以任何顺序被调用,这意味着您不能期望处理器的顺序。

  • 您需要调用 context.Succeed(requirement) 来标记要求已满足。

如果要求不满足会怎样?有两种选择:

  • 通常,您不需要调用 context.Fail() 来标记失败的要求,因为可能有其他处理器来处理相同的要求,这些要求可能已经满足。

  • 如果您想确保要求失败并指示整个授权过程失败,您可以显式调用 context.Fail(),并在 AddAuthorization() 方法中将 InvokeHandlersAfterFailure 属性设置为 false,如下所示:

    builder.Services.AddAuthorization(options =>  {      options.AddPolicy(AppAuthorizationPolicies.PremiumContent, policy =>      {          policy.Requirements.Add(new PremiumContentRequirement("New Zealand"));      });      options.InvokeHandlersAfterFailure = false;  });
    

在本节中,我们探讨了 ASP.NET Core 中可用的三种授权类型:基于角色、基于声明和基于策略。我们检查了源代码以深入了解授权的工作原理。有了这些知识,您现在应该能够自信地使用 ASP.NET Core 的授权功能。接下来,我们将学习如何管理用户和角色。

管理用户和角色

在前面的部分中,我们实现了身份验证和授权功能。通常,应用程序还应提供一种管理用户和角色的方式。ASP.NET Core Identity 提供了一套 API 来管理用户和角色。在本节中,我们将介绍如何使用这些 API。

在前面的章节中,我们学习了IdentityDbContext类用于存储用户和角色信息。因此,我们不需要创建一个新的数据库上下文类。同样,我们可以使用UserManagerRoleManager来管理用户和角色,而无需编写任何代码。

下面是使用UserManager类管理用户的一些常见操作:

方法 描述
CreateAsync(TUser user, string password) 使用给定的密码创建用户。
UpdateUserAsync(TUser user) 更新用户。
FindByNameAsync(string userName) 通过用户名查找用户。
FindByIdAsync(string userId) 通过 ID 查找用户。
FindByEmailAsync(string email) 通过电子邮件查找用户。
DeleteAsync(TUser user) 删除用户。
AddToRoleAsync(TUser user, string role) 将用户添加到角色中。
GetRolesAsync(TUser user) 获取用户的角色列表。
IsInRoleAsync(TUser user, string role) 检查用户是否具有该角色。
RemoveFromRoleAsync(TUser user, string role) 从角色中移除用户。
CheckPasswordAsync(TUser user, string password) 检查密码是否正确。
ChangePasswordAsync(TUser user, string currentPassword, string newPassword) 更改用户的密码。用户必须提供正确的当前密码。
GeneratePasswordResetTokenAsync(TUser user) 生成用于重置用户密码的令牌。您需要在AddIdentityCore()方法中指定options.Token.PasswordResetTokenProvider
GenerateEmailConfirmationTokenAsync(TUser user) 生成用于确认用户电子邮件的令牌。您需要在AddIdentityCore()方法中指定options.Tokens.EmailConfirmationTokenProvider
ConfirmEmailAsync(TUser user, string token) 检查用户是否具有有效的电子邮件确认令牌。如果令牌匹配,此方法将用户的EmailConfirmed属性设置为true

表 8.1 – 管理用户常见操作

下面是使用RoleManager类管理角色的一些常见操作:

方法 描述
CreateAsync(TRole role) 创建角色
RoleExistsAsync(string roleName) 检查角色是否存在
UpdateAsync(TRole role) 更新角色
DeleteAsync(TRole role) 删除角色
FindByNameAsync(string roleName) 通过名称查找角色

表 8.2 – 管理角色常见操作

这些 API 封装了数据库操作,因此我们可以使用它们轻松地管理用户和角色。一些方法返回 Task<IdentityResult> 对象。IdentityResult 对象包含一个 Succeeded 属性,用于指示操作是否成功。如果操作不成功,您可以通过使用 Errors 属性来获取错误消息。

我们不会在本书中涵盖所有 API。您可以在 ASP.NET Core 文档中找到更多信息。接下来,我们将学习关于 ASP.NET Core 8.0 中的新内置身份验证 API 端点。

ASP.NET Core 8 中的新身份验证 API 端点

在前面的章节中,我们学习了如何使用 ASP.NET Core 内置的身份验证 API 实现身份验证和授权。我们开发了一些端点来注册、登录和管理用户和角色。ASP.NET Core 8.0 引入了一组新功能,以简化 Web API 的身份验证。在本节中,我们将介绍这些新端点。

注意,这个新功能仅适用于简单的身份验证场景。由身份验证 API 端点生成的令牌是透明的,不是 JWT 令牌,这意味着它仅适用于同一应用程序。然而,它仍然是一个快速入门的选择。在 ASP.NET Core 8.0 中,我们可以使用新的 MapIdentityApi() 方法来映射身份验证 API 端点,而无需像前几节那样编写任何实现。让我们来学习如何使用它:

  1. 首先,按照 步骤 15创建具有身份验证和授权的示例项目 部分中创建一个名为 NewIdentityApiDemo 的新 Web API 项目。请注意,您不需要安装 Microsoft.AspNetCore.Authentication.JwtBearer 包,因为我们在这个示例项目中不会使用 JWT 令牌。

  2. Program.cs 文件中添加授权策略服务和注册 DbContext,如下所示:

    builder.Services.AddAuthorization();builder.Services.AddDbContext<AppDbContext>();
    
  3. 运行以下命令以创建数据库和迁移:

    dotnet ef migrations add InitialDbdotnet ef database update
    
  4. Program.cs 文件中注册身份验证 API 端点,如下所示:

    builder.Services.AddIdentityApiEndpoints<AppUser>().AddEntityFrameworkStores<AppDbContext>();
    

    AddIdentityApiEndpoints() 方法通过在内部调用 AddIdentityCore<TUser>() 方法向应用程序添加一组常见的身份验证服务。它还配置了身份验证以支持身份令牌和 cookie,因此我们不需要显式调用 AddIdentityCore<AppUser>() 方法。

  5. Program.cs 文件中映射身份验证 API 端点,如下所示:

    app.MapGroup("/identity").MapIdentityApi<AppUser>();
    

    以下代码将身份验证 API 端点映射到 /identity 路径。您可以将其更改为您喜欢的任何路径,例如 api/accounts/users 等。请注意,由于我们使用 AppUser 而不是默认的 IdentityUser,我们必须在 MapIdentityApi() 方法中指定 AppUser 类型。

  6. [Authorize] 属性应用于 WeatherForecastController 类,如下所示:

    [Authorize] [ApiController] [Route("[controller]")] public class WeatherForecastController : ControllerBase {     // Omitted for brevity }
    
  7. 使用 dotnet run 运行应用程序。您将在 Swagger UI 中看到新的身份验证 API 端点:

图 8.11 – Swagger UI 中的身份验证 API 端点

图 8.11 – Swagger UI 中的身份验证 API 端点

  1. 现在,你可以探索新的身份验证 API 端点。向 /identity/register 端点发送以下内容的 POST 请求以注册新用户:

    {   "userName": "admin",   "email": "admin@example.com",   "password": "Passw0rd!" }
    {   "email": "admin@example.com",   "password": "Passw0rd!" }
    

    你将获得一个包含访问令牌和刷新令牌的响应:

    {   "tokenType": "Bearer",   "accessToken": "CfDJ8L-NUxrCjhBJqmxaYaETqK0P0...",   "expiresIn": 3600,   "refreshToken": "CfDJ8L-NUxrCjhBJqmxaYaETqK2U..." }
    

然后,你可以使用访问令牌通过 Authorization 标头请求受保护的 /weatherforecast 端点,正如我们在前面的章节中介绍的那样。

这个新功能还提供了 refreshTokenconfirmEmailresetPassword2fa 等端点。请随意自行探索它们。

理解 OAuth 2.0 和 OpenID Connect

之前,我们学习了如何使用 ASP.NET Core 内置的身份验证 API 来实现身份验证和授权。然而,在实际项目中,你可能会遇到一些术语,例如 OAuth 2.0 和 OpenID Connect。了解它们是什么以及如何在 ASP.NET Core 中使用它们将非常有帮助。撰写一本关于 OAuth 2.0 和 OpenID Connect 的完整书籍是值得的。在本节中,我们将介绍围绕 OAuth 2.0 和 OpenID Connect 的一些基本概念,以及一些第三方身份验证和授权提供商。

什么是 OAuth 2.0?

让我们从真实示例开始。当你使用领英时,你可能会看到一个窗口提示你从 Outlook、Gmail、Yahoo 或其他电子邮件服务同步你的联系人。这是因为领英希望了解你的联系人,以便它可以推荐你邀请你的朋友加入领英或与他们建立联系。这是一个典型的 OAuth 2.0 应用示例:

图 8.12 – 在领英上同步联系人

图 8.12 – 在领英上同步联系人

如果你填写了你的电子邮件地址并点击 继续 按钮,你将被重定向到电子邮件服务提供商的网站。例如,我使用 Outlook,所以我将看到一个像这样的窗口,因为我有多个账户:

图 8.13 – 提示登录 Outlook

图 8.13 – 提示登录 Outlook

注意地址栏中的 URL。它看起来可能像这样:

https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx&redirect_uri=https%3A%2F%2Fwww.linkedin.com%2Fgenie%2Ffinishauth&scope=openid%20email%20People.Read&response_type=code&state=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

URL 包含应用程序的 客户端 ID,用于标识应用程序。它还包含 重定向 URL,以便授权服务器在用户授予权限后可以将用户重定向回应用程序。

你需要登录电子邮件服务提供商的网站并授权领英访问你的联系人。如果你已经登录,你会看到这个窗口:

图 8.14 – 授权领英访问你的联系人

图 8.14 – 授权领英访问你的联系人

在你授权领英后,你将被重定向回领英。领英将从电子邮件服务提供商那里获取联系人信息并展示给你。

我们不希望 LinkedIn 知道我们电子邮件地址的密码。在这种情况下,OAuth 2.0 和 OpenID Connect 被用来授权 LinkedIn 访问我们的联系人,而无需知道我们的密码。

OAuth 2.0 实现了一个委托授权模型。它允许客户端代表用户访问受保护资源。OAuth 2.0 模型中涉及一些实体:

  • 资源所有者:拥有受保护资源的用户。在我们的例子中,资源所有者是拥有电子邮件地址的用户。

  • 客户端:想要访问受保护资源的客户端应用程序。在我们的例子中,客户端是 LinkedIn。请注意,此客户端不是用户的浏览器。

  • 资源服务器:托管受保护资源的服务器。在我们的例子中,资源服务器是电子邮件服务提供商——例如,Outlook。

  • 授权服务器:处理委托授权的服务器。在我们的例子中,授权服务器是 Microsoft 身份平台。一个授权服务器至少有两个端点:

    • 授权端点用于与最终用户交互并获得授权授予

    • 令牌端点用于与客户端交换授权授予以获取访问令牌:

图 8.15 – OAuth 2.0 流程

图 8.15 – OAuth 2.0 流程

注意,客户端(LinkedIn)在能够访问受保护资源之前,必须将其自身注册为授权服务器(Microsoft)的已知客户端。客户端必须向授权服务器提供客户端 ID客户端密钥以证明其身份。这就是为什么我们可以在图 8中看到 LinkedIn 的 Microsoft Graph Connector。14*。

OAuth 2.0 的常见步骤如下:

  1. 客户端请求访问受保护资源。

  2. 客户端将用户重定向到授权服务器,如 Microsoft、Google 等。具体来说,它重定向到授权服务器的授权端点。用户认证后,授权服务器将提示用户,询问类似“嗨,我有一个已知的客户端名为 LinkedIn,它想使用您的权限访问我的 API。具体来说,它想访问您的联系人,以便代表您发送电子邮件。您想授予 LinkedIn 访问权限吗?”的内容。这正是 8.14*所展示的。

  3. 一旦用户接受请求,授权服务器将生成一个授权代码,它只是一个不透明的字符串,确认用户确实授予了客户端(LinkedIn)访问权限。授权服务器将用户重定向回客户端(LinkedIn)。

  4. 授权代码作为查询字符串参数发送到客户端(LinkedIn)。

  5. 客户端(LinkedIn)现在拥有一个授权码。接下来,它将使用授权码、客户端 ID 和客户端密钥从授权服务器的令牌端点请求一个访问令牌。它可能会问:“嗨,我是 LinkedIn。这个用户已经授权我访问这个电子邮件地址的联系人。这是我的客户端凭证(客户端 ID 和客户端密钥)。我还有一个授权码。我能访问吗?”

  6. 授权服务器将验证客户端凭证和授权码。如果一切正常,它将生成一个访问令牌并将其发送回客户端(LinkedIn)。访问令牌是一个可以用来访问受保护资源的字符串。它通常是 JWT 令牌。它也可能包含作用域,这是客户端(LinkedIn)被授予的权限。例如,它可能是Contacts.Read

  7. 客户端(LinkedIn)现在可以使用这个访问令牌来访问受保护的资源。它可能会问:“嗨,我是 LinkedIn。我有一个访问令牌。我能访问这个电子邮件地址的联系人吗?”资源服务器检查访问令牌,如果它是有效的,它将返回受保护的资源给客户端(LinkedIn)。

以这种方式,客户端可以在不知道用户密码的情况下访问受保护的资源。因为访问令牌有一个作用域,它只能访问作用域内的受保护资源。例如,如果作用域是Contacts.Read,客户端只能读取联系人,但不能修改联系人。这种机制在安全性和可用性之间提供了良好的平衡。

OpenID Connect 是什么?

OAuth 最初于 2006 年设计和发布,后来在 2012 年作为 OAuth 2.0 进行修订和标准化。OAuth 2.0 解决了委托授权的问题。然而,还有一些其他场景 OAuth 2.0 无法解决。例如,您的 API 可能需要知道访问 API 的用户的身份,但用户可能不想为您的 API 创建账户。他们可能已经在某些其他服务中拥有账户,例如 Microsoft、Google 等。在这种情况下,如果用户可以使用他们现有的账户来访问您的 API,那就更方便了。然而,OAuth 2.0 并未设计用于实现使用现有账户的登录。这就是新的规范 OpenID Connect 出现的地方。

OpenID Connect 是 OAuth 2.0 之上的一个认证层,由 OpenID 基金会在 2014 年设计。OpenID Connect 类似于 OAuth 2.0 的扩展,它添加并定义了一些新功能来检索用户的身份,包括用户名、电子邮件地址等个人资料信息。OpenID Connect 使用与 OAuth 2.0 相似的术语和概念,如客户端资源所有者授权服务器等。然而,请记住,OpenID Connect 并不是 OAuth 2.0 的替代品。相反,它是一个规范,它扩展了 OAuth 2.0 以支持认证。

许多流行的身份提供者,如 Microsoft、Google、Facebook 等,已实现了 OpenID Connect,以便您可以将您的 API 应用程序与他们的身份提供者集成。然后,用户可以使用他们现有的账户登录到您的 API 应用程序。以下是在Medium.com上 OpenID Connect 工作的一个示例:

图 8.16 – Medium.com 使用多个身份提供者登录

图 8.16 – Medium.com 使用多个身份提供者登录

如果您点击使用 Google 登录,您将被重定向到 Google 进行登录。然后,您将被重定向回 Medium.com,以便您可以使用现有的 Google 账户登录到 Medium.com。这就是 OpenID Connect 所做的事情。

与 OAuth 2.0 类似,OpenID Connect 也会生成一个访问令牌。它还引入了一种新的令牌,称为ID 令牌,这是一个包含用户身份信息的 JWT 令牌。客户端应用程序可以检查和验证 ID 令牌以提取有关用户身份的信息。

与其他身份提供者集成

许多身份提供者支持 OpenID Connect,以便您可以将您的 API 应用程序与这些平台集成。以下是一些流行的身份提供者:

重要提示

2021 年 3 月,Okta 收购了 Auth0。然而,两家公司将继续独立运营。通常,Auth0 面向小型公司,以其对开发者友好的功能而闻名,但 Okta 被认为更专注于大型企业,并提供更多高级功能,如网络集成、单点登录等。

如果你需要自己构建身份提供者,也有一些开源项目你可以使用:

  • IdentityServerIdentityServer 是 ASP.NET Core 中最灵活和符合标准规范的 OpenID Connect 和 OAuth 2.0 框架之一。许多公司使用它来保护他们的应用程序和 API。请注意,IdentityServer 是开源的,但现在不再是免费的。最后一个免费版本是 2021 年发布的 IdentityServer4,但它不再维护。Duende Software 现在提供 IdentityServer 的商业版本。有关更多信息,请参阅 duendesoftware.com/products/identityserver

  • OpenIddictOpenIddict 是一个针对 ASP.NET Core 的开源 OpenID Connect 栈。它提供了一个灵活的解决方案来实现 OpenID Connect 客户端、服务器、令牌验证等。然而,它不是一个即插即用的解决方案。你需要编写一些自定义代码来实现一些业务逻辑,例如授权控制器等。有关更多信息,请参阅 github.com/openiddict/openiddict-core

  • KeyCloakKeyCloak 是一个开源的身份和访问管理解决方案。它提供单点登录、用户联合、强认证、用户管理、细粒度授权等功能。它是基于容器的,因此可以轻松地在容器化环境中部署。有关更多信息,请参阅 www.keycloak.org/

我们不会在本书中详细说明如何将这些身份提供者集成。请参阅文档。

其他安全主题

正如我们在本章开头提到的,安全是一个非常广泛的话题。在本节中,我们将简要介绍一些其他安全主题。

总是使用超文本传输协议安全(HTTPS)

HTTPS 是一种协议,它为客户端和服务器之间提供安全的通信。它是 HTTP 和 安全套接字层/传输层安全性SSL/TLS)协议的结合。HTTPS 用于加密客户端和服务器之间的通信,确保通过互联网传输的敏感数据安全,并且不能被未经授权的第三方截获。如果你尝试访问一个不使用 HTTPS 的网站,Google Chrome 和其他现代浏览器将显示警告。因此,为所有你的网络应用程序使用 HTTPS 非常重要。

默认的 ASP.NET Core Web API 模板可以使用 HTTP 和 HTTPS。建议只使用 HTTPS。因此,我们需要配置项目以将所有 HTTP 请求重定向到 HTTPS。

为了实现这一点,我们需要将以下代码添加到Program.cs文件中:

app.UseHttpsRedirection();

此代码应用了UseHttpsRedirection中间件,将 HTTP 请求重定向到 HTTPS。

当你在本地运行应用程序时,ASP.NET Core 将自动生成一个自签名证书并使用它来加密通信。然而,当你将应用程序部署到生产环境时,你需要使用由受信任的证书颁发机构CA)签发的证书,例如 DigiCert、Comodo、GeoTrust 等。

使用强密码策略

我们在之前章节中实现的默认密码策略不够安全。用户可以使用任何密码,这可能会带来安全风险。强制用户使用强大、唯一的密码,对他人难以猜测或破解,这一点非常重要。通常,一个好的密码应该是由大小写字母、数字和特殊字符的组合。密码长度至少为 8 个字符。我们可以定义一个密码策略来强制执行这些规则。

我们可以在Program类中指定密码策略。在AddAuthentication()方法之后添加以下代码:

builder.Services.Configure<IdentityOptions>(options =>{
    // Password settings
    options.Password.RequireDigit = true;
    options.Password.RequireLowercase = true;
    options.Password.RequireNonAlphanumeric = true;
    options.Password.RequireUppercase = true;
    options.Password.RequiredLength = 8;
    options.Password.RequiredUniqueChars = 1;
    // User settings
    options.User.AllowedUserNameCharacters =
    "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+";
    options.User.RequireUniqueEmail = true;
});

之前的代码易于理解。在这个例子中,我们要求密码至少包含一个大小写字母、一个数字和一个特殊字符,并且密码长度至少为 8 个字符。我们还要求用户的电子邮件必须是唯一的。因此,如果用户尝试使用已存在的电子邮件进行注册,注册将失败。现在,用户的密码应该难以猜测。

我们还可以在用户登录失败时强制执行密码策略。例如,如果用户连续三次登录失败,账户将被锁定 5 分钟。这有助于防止暴力攻击。要启用此功能,在AddAuthentication()方法之后添加以下代码:

builder.Services.Configure<IdentityOptions>(options =>{
    // Omitted for brevity
    // Lockout settings
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
    options.Lockout.MaxFailedAccessAttempts = 3;
    options.Lockout.AllowedForNewUsers = true;
});

当我们使用SignInManager.CheckPasswordSignInAsync()方法进行登录时,此更改将生效。在之前的例子中,我们使用了UserManager。因此,我们需要更新AuthenticationController类中的Login()方法。首先,我们需要将SignInManager注入到控制器中。然后,我们必须更新AuthenticationController类,如下所示:

[HttpPost("login")]public async Task<IActionResult> Login([FromBody] LoginModel model)
{
    // Check if the model is valid
    if (ModelState.IsValid)
    {
        var user = await _userManager.FindByNameAsync(model.UserName);
        if (user != null)
        {
            var result =
                await _signInManager.CheckPasswordSignInAsync(user, model.Password, lockoutOnFailure: true);
            if (result.Succeeded)
            {
                var token = GenerateToken(model.UserName);
                return Ok(new { token });
            }
        }
        // If the user is not found, display an error message
        ModelState.AddModelError("", "Invalid username or password");
    }
    return BadRequest(ModelState);
}

之前代码使用了SignInManager.CheckPasswordSignInAsync()方法进行登录,该方法有一个名为lockoutOnFailure的参数,用于指定当用户登录失败时是否应该锁定账户。默认值是false,因此我们需要使用true来启用锁定功能。

注意,如果你在 Program.cs 中使用 AddIdentityCore<AppUser>(),正如我们在上一节中提到的,SignInManager 默认不可用。在这种情况下,你需要显式地将 SignInManager 服务添加到 ConfigureServices() 方法中,如下所示:

builder.Services.AddIdentityCore<AppUser>()    .AddSignInManager()
    .AddEntityFrameworkStores<AppDbContext>()
    .AddDefaultTokenProviders();

让我们来测试这个应用程序。使用 dotnet run 运行应用程序,并使用 Register API 创建一个新用户。你会发现,如果密码太简单,你会收到一个错误信息。以下是一个示例请求:

{  "userName": "user",
  "email": "user@example.com",
  "password": "123456"
}

你将收到一个 400 响应,并显示以下错误信息:

{  "": [
    "Passwords must be at least 8 characters.",
    "Passwords must have at least one non alphanumeric character.",
    "Passwords must have at least one lowercase ('a'-'z').",
    "Passwords must have at least one uppercase ('A'-'Z')."
  ]
}

如果你尝试使用错误的密码登录超过三次,你将被锁定系统 5 分钟。在此期间,即使你输入正确的密码,也无法访问系统。5 分钟后,你将能够再次登录。

实现双因素认证(2FA)

双因素认证(2FA)是一种需要用户提供两种不同形式的认证以验证其身份的安全流程。除了常见的用户名和密码外,2FA 通过要求用户提供第二个认证因素(如发送到其手机或认证器应用程序的代码、指纹、面部识别等)来增加一个额外的安全层。这使得黑客更难访问用户账户。即使黑客获得了用户的密码,他们仍然无法获取第二个因素。2FA 在银行和金融服务中广泛使用,以保护用户的敏感信息。

多因素认证MFA)是双因素认证(2FA)的超集。它要求用户提供超过两个因素来验证其身份。有两种类型的 MFA:

  • 基于时间的多因素认证一次性密码TOTP):基于时间的多因素认证 TOTP 是一种需要用户通过认证器应用程序(如 Google Authenticator 或 Microsoft Authenticator)生成代码的多因素认证。该代码的有效期很短,通常为 30 秒。代码过期后,用户需要生成一个新的代码。这种类型的多因素认证在银行和金融服务中广泛使用。如果你使用银行应用程序,你可能已经见过这种类型的多因素认证。它要求服务器和认证器应用程序具有准确的时间。

  • 快速身份在线 2FIDO2):基于 FIDO2 的多因素认证要求用户使用硬件密钥(如 USB 密钥或生物识别设备,如指纹扫描仪)进行认证。近年来,它变得越来越受欢迎。然而,ASP.NET Core 目前尚不支持 FIDO2。

  • 基于短信的多因素认证MFA SMS):基于短信的多因素认证不再推荐,因为短信存在许多安全问题。

要了解更多关于多因素认证(MFA)的信息,请参阅learn.microsoft.com/en-us/aspnet/core/security/authentication/mfa

实现速率限制

速率限制是一种安全机制,限制客户端可以向服务器发送的请求数量。它可以防止恶意客户端发送过多的请求,这可能导致 拒绝服务DoS)攻击。ASP.NET Core 提供了内置的速率限制中间件。我们在 第四章 中解释了如何使用它。

使用模型验证

模型验证是一种安全机制,可以防止恶意用户向服务器发送无效数据。我们应该始终验证客户端发送的数据。换句话说,客户端是不可信的。例如,我们期望模型中的一个属性是整数,但客户端发送字符串怎么办?应用程序应该能够处理这种情况,并在执行任何业务逻辑之前直接拒绝请求。

ASP.NET Core 提供了内置的模型绑定和模型验证机制。模型绑定用于将客户端发送的数据转换为相应的模型。客户端发送的数据可以有不同的格式,例如 JSON、XML、表单字段或查询字符串。模型验证用于检查客户端发送的数据是否有效。我们在前面的章节中使用了模型验证。例如,以下是注册新用户时我们使用的代码:

// Create an action to register a new user[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] AddOrUpdateAppUserModel model)
{
    // Check if the model is valid
    if (ModelState.IsValid)
    {
        // Omitted for brevity
    }
    return BadRequest(ModelState);
}

ModelState.IsValid 属性表示模型是否有效。那么,ASP.NET Core 是如何验证模型的呢?看看 AddOrUpdateAppUserModel 类:

public class AddOrUpdateAppUserModel{
    [Required(ErrorMessage = "User name is required")]
    public string UserName { get; set; } = string.Empty;
    [EmailAddress]
    [Required(ErrorMessage = "Email is required")]
    public string Email { get; set; } = string.Empty;
    [Required(ErrorMessage = "Password is required")]
    public string Password { get; set; } = string.Empty;
}

我们使用验证属性来指定验证规则。例如,Required 是一个内置属性注释,指定该属性是必需的。除了 Required 之外,还有一些最常用的属性:

  • CreditCard: 这指定了该属性必须是一个有效的信用卡号

  • EmailAddress: 这指定了该属性必须是一个有效的电子邮件地址

  • Phone: 这指定了该属性必须是一个有效的电话号码

  • Range: 这指定了该属性必须在一个指定的范围内

  • RegularExpression: 这指定了该属性必须匹配指定的正则表达式

  • StringLength: 这指定了该属性必须是一个具有指定长度的字符串

  • Url: 这指定了该属性必须是一个有效的 URL

  • Compare: 这指定了该属性必须与另一个属性相同

如果这些内置属性无法满足您的需求,您也可以创建自定义属性。例如,您可以创建一个 Adult 属性来根据用户的生日验证用户的年龄:

public class AdultAttribute : ValidationAttribute{
    public string GetErrorMessage() => $"You must be at least 18 years old to register.";
    protected override ValidationResult IsValid(object value, ValidationContext validationContext)
    {
        var birthDate = (DateTime)value;
        var age = DateTime.Now.Year - birthDate.Year;
        if (DateTime.Now.Month < birthDate.Month || (DateTime.Now.Month == birthDate.Month && DateTime.Now.Day < birthDate.Day))
        {
            age--;
        }
        if (age < 18)
        {
            return new ValidationResult(GetErrorMessage());
        }
        return ValidationResult.Success;
    }
}

然后,您可以在模型中使用 Adult 属性:

public class AddOrUpdateAppUserModel{
    // Omitted for brevity
    [Required(ErrorMessage = "Birthday is required")]
    [Adult]
    public DateTime Birthday { get; set; }
}

您还可以在控制器中手动验证模型。例如,您可以检查用户的电子邮件是否唯一:

// Create an action to register a new user[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] AddOrUpdateAppUserModel model)
{
    // Check if the email is unique
    if (await _userManager.FindByEmailAsync(model.Email) != null)
    {
        ModelState.AddModelError("Email", "Email already exists");
        return BadRequest(ModelState);
    }
    if (ModelState.IsValid)
    {
        // Omitted for brevity
    }
    return BadRequest(ModelState);
}

在前面的代码中,我们使用AddModelError()方法向模型添加验证错误。如果存在任何验证错误,ModelState.IsValid属性将返回false。在第十六章中,我们将讨论如何使用ProblemDetails类向客户端返回错误信息,以及如何使用FluentValidation对模型进行更复杂场景的验证。您可以参考该章节以获取更多信息。

使用参数化查询

我们在前面章节中解释了如何使用 EF Core 执行 SQL 查询。通常,如果您使用 LINQ 查询数据,EF Core 会为您生成参数化查询。但是,当您使用以下方法时,您需要小心 SQL 注入攻击:

  • FromSqlRaw()

  • SqlQeuryRaw()

  • ExecuteSqlRaw()

这些方法允许您在不清理输入的情况下执行原始 SQL 查询。因此,请在执行之前确保清理查询语句。

使用数据保护

数据保护是一种安全机制,它阻止恶意用户访问敏感数据。例如,如果您在数据库中存储用户的密码,您应该在存储之前对其进行加密。另一个例子是用户的信用卡号,也应该在存储之前进行加密。

这是因为如果数据库遭到破坏,攻击者可以轻易地访问用户的敏感数据。换句话说,数据库不可信,就像客户端一样。数据保护是另一个重要话题,但超出了本书的范围。ASP.NET Core 提供了一个内置的数据保护机制。如果您想了解更多信息,请参阅官方文档:learn.microsoft.com/en-us/aspnet/core/security/data-protection/introduction

保护秘密安全

秘密是敏感数据,不应向公众公开。在我们的应用程序中,我们可能有多个秘密,例如数据库连接字符串、API 密钥、客户端密钥等。在前面的章节中,我们经常将它们存储在appsettings.json文件中。然而,我们需要强调这不是一个好的做法。这些秘密应该存储在安全的地方,例如 Azure Key Vault、AWS Secrets Manager 或kube-secrets。永远不要将它们上传到源代码仓库。

我们将在第十四章中介绍持续集成/持续部署CI/CD)并解释如何安全地存储秘密。

保持框架更新

.NET Core 框架是一个开源项目。它不断在更新。我们应该始终保持框架的最新状态,包括 NuGet 包。注意 .NET Core 框架的生命周期。尽可能使用框架的最新版本。如果你正在使用较旧版本,你应该考虑升级它。你可以在这里找到 .NET Core 框架的生命周期:dotnet.microsoft.com/en-us/platform/support/policy/dotnet-core

检查 Open Web Application Security Project (OWASP) Top 10

OWASP 是一个非营利组织,提供有关 Web 应用程序安全性的信息。它发布了一份最常见的 Web 应用程序安全风险列表,称为 OWASP Top 10。你可以在这里找到 OWASP Top 10:owasp.org/www-project-top-ten/。你应该定期检查列表,以确保你的应用程序不会受到任何风险的威胁。

此外,OWASP 提供了一个名为 DotNet Security Cheat Sheet 的免费资源,其中包含了确保 .NET Core 应用程序安全性的最佳实践。你可以在这里找到它:cheatsheetseries.owasp.org/cheatsheets/DotNet_Security_Cheat_Sheet.html

摘要

在本章中,我们介绍了 ASP.NET Core 的安全和身份功能。我们主要学习了如何使用其内置的认证和授权机制。我们学习了如何使用 Identity 框架来管理用户和角色,并解释了基于角色的授权、基于声明的授权和基于策略的授权。

然后,我们介绍了 OAuth 2.0 和 OpenID Connect,它们是最受欢迎的认证和授权标准。之后,我们解释了几个安全实践,例如使用 HTTPS、强密码、参数化查询等。

再次强调,安全性是一个大主题,我们无法在一章中涵盖所有细节。请将安全性视为一个持续的过程,并始终确保你的应用程序安全。

在下一章中,我们将开始测试,这是任何软件项目的重要部分。我们将学习如何为 ASP.NET Core 应用程序编写单元测试。

第九章:ASP.NET Core 测试(第一部分 – 单元测试)

测试是任何软件开发过程的重要组成部分,包括 ASP.NET Core Web API 开发。测试有助于确保应用程序按预期工作并满足要求。它还有助于确保对代码所做的任何更改都不会破坏现有功能。

在本章中,我们将探讨 ASP.NET Core 中可用的不同测试类型以及如何在 ASP.NET Core Web API 应用程序中实现单元测试。

本章将涵盖以下主题:

  • ASP.NET Core 测试简介

  • 编写单元测试

  • 测试数据库访问层

在本章结束时,你将能够为你的 ASP.NET Core Web API 应用程序编写单元测试,以确保代码单元正确运行。你还将学习如何使用一些库,例如 MoqFluentAssertions,使你的测试更易于阅读和维护。

技术要求

本章中的代码示例可在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter9 找到。你可以使用 VS Code 或 VS 2022 打开解决方案。

ASP.NET Core 测试简介

可以对 ASP.NET Core Web API 应用程序执行不同类型的测试,如下所示:

  • 单元测试:这是测试单个代码单元的过程,例如方法和类,以确保它们按预期工作。单元测试应该是小的、快速的,并且与其他代码单元隔离。可以使用模拟框架来隔离代码单元与其依赖项,例如数据库和外部服务。

  • 集成测试:这涉及测试应用程序不同组件之间的集成,以确保它们按预期一起工作。此类测试有助于识别在应用程序部署到生产环境时可能出现的任何问题。通常,集成测试比单元测试慢。根据场景,集成测试可能使用模拟对象或真实对象。例如,如果集成测试是为了测试应用程序与数据库的集成,则应使用真实数据库实例。但如果集成测试是为了测试应用程序与外部服务(如支付服务)的集成,则应使用模拟对象来模拟外部服务。在微服务架构中,集成测试更复杂,因为它们可能涉及多个服务。除了每个服务的集成测试之外,还应该有针对整个系统的集成测试。

  • 端到端测试:这是从用户的角度测试应用程序的过程,以确保整个系统从开始到结束,包括用户界面、Web API、数据库等,都按预期工作。端到端测试通常涉及模拟用户与应用程序的交互,例如点击按钮和将数据输入表单。

  • 回归测试:这涉及到在添加新功能或修复错误后测试应用程序是否仍然按预期工作。回归测试通常在应用程序部署到生产环境后执行。它有助于确保新功能或错误修复不会破坏现有功能。

  • 负载测试:这涉及到测试应用程序是否能够处理正常负载的用户和请求。它有助于设定应用程序性能的基线。

  • 压力测试:这涉及到测试应用程序是否能够处理极端条件,例如用户数量和请求的突然增加,或者逐渐增加长期负载。它还确定应用程序是否能够从故障中恢复以及恢复所需的时间。

  • 性能测试:这是一种评估应用程序在不同工作负载下性能的测试,包括响应时间、吞吐量、资源使用等。性能测试是负载测试和压力测试的超集。通常,单元测试和集成测试在开发环境和预发布环境中执行,而性能测试则在类似生产环境的环境中执行,例如用户验收测试UAT)环境,该环境在基础设施和配置方面与生产环境非常相似。这确保了性能测试的准确性和可靠性。在某些情况下,可以在计划维护窗口期间在开发环境中进行有限的性能测试,以验证实际性能场景。

单元测试和集成测试是 .NET 开发者编写的最常见的测试类型。在本章中,我们将重点关注单元测试;我们将在第十章中讨论集成测试。

编写单元测试

单元测试是为了测试代码的各个独立单元,例如方法和类。通常由熟悉代码的开发者编写单元测试。当开发者开发新功能或修复错误时,他们也应该编写单元测试以确保代码按预期工作。.NET 提供了许多单元测试框架,包括 NUnit、xUnit 和 MSTest。在本章中,我们将使用 xUnit 编写单元测试,因为它是目前最流行的 .NET 应用程序单元测试框架之一。

准备示例应用程序

示例应用程序,InvoiceApp,是一个简单的 ASP.NET Core Web API 应用程序,它公开了一组用于管理发票的 RESTful API。该示例应用程序使用 EF Core 将数据存储和检索到 SQL Server 数据库中。它具有以下端点:

  • GET /api/invoices: 获取发票列表

  • GET /api/invoices/{id}: 通过 ID 检索发票

  • POST /api/invoices: 创建一个新的发票

  • PUT /api/invoices/{id}: 更新现有发票

  • DELETE /api/invoices/{id}: 删除一张发票

  • PATCH /api/invoices/{id}/status: 更新发票的状态

  • POST /api/invoices/{id}/send: 向联系人发送发票电子邮件

注意,前面的端点不足以构建一个完整的发票管理应用程序。这只是一个示例应用程序,用于演示如何为 ASP.NET Core Web API 应用程序编写单元测试和集成测试。

此外,该示例应用程序包含一个可以用于测试 API 的 Swagger UI。图 9**.1显示了示例应用程序的 Swagger UI:

图 9.1 – 示例应用程序 API 端点

图 9.1 – 示例应用程序 API 端点

使用 dotnet run 命令运行示例应用程序后,您可以通过 localhost:5087/swagger/index.html 访问 Swagger UI。

现在,我们将使用这个示例应用程序来演示如何为 ASP.NET Core Web API 应用程序编写单元测试。

设置单元测试项目

我们将使用 xUnit 为示例应用程序编写单元测试。xUnit 是 .NET 应用程序的流行单元测试框架。它是一个免费、开源的项目,已经存在多年。它也是 .NET Core 和 .NET 5+ 应用程序的默认单元测试框架。您可以在 xunit.net/ 找到更多关于 xUnit 的信息。

要设置测试项目,您可以使用 VS 2022 或 .NET CLI。如果您使用 VS 2022,可以通过在解决方案上右键单击并选择 InvoiceApp.UnitTests 来创建一个新的 xUnit 测试项目,然后点击 创建 以创建项目:

图 9.2 – 在 VS 2022 中创建新的 xUnit 测试项目

图 9.2 – 在 VS 2022 中创建一个新的 xUnit 测试项目

在创建项目后,将项目引用添加到InvoiceApp.WebApi项目,以便测试项目可以访问主 Web API 项目中的类。您可以通过在项目列表中右键单击InvoiceApp.WebApi项目并点击确定来添加项目引用:

图 9.3 – 在 VS 2022 中向测试项目添加项目引用

图 9.3 – 在 VS 2022 中将项目引用添加到测试项目中

如果您使用.NET CLI,您可以在终端中运行以下命令来创建一个新的 xUnit 测试项目:

dotnet new xunit -n InvoiceApp.UnitTests

然后,您可以通过运行以下命令将测试项目添加到解决方案中:

dotnet sln InvoiceApp.sln add InvoiceApp.UnitTests/InvoiceApp.UnitTests.csproj

您还需要通过运行以下命令将引用添加到主项目中:

dotnet add InvoiceApp.UnitTests/InvoiceApp.UnitTests.csproj reference InvoiceApp.WebApi/InvoiceApp.WebApi.csproj

默认的 xUnit 测试项目模板包含一个示例单元测试。您可以删除名为UnitTest1.cs的示例单元测试;我们将在下一节编写自己的单元测试。

如果您从空白.NET 库项目开始创建测试项目,您需要将以下包添加到测试项目中:

  • Microsoft.NET.Test.Sdk:这是运行单元测试所必需的

  • xunit:这是我们用来编写单元测试的 xUnit 框架

  • xunit.runner.visualstudio:这是在 Visual Studio 中运行单元测试所必需的

  • coverlet.collector:这是一个开源项目,为.NET 应用程序提供代码覆盖率分析

当我们编写单元测试时,请记住,一个单元测试应该测试一个代码单元,例如一个方法或一个类。单元测试应该与其他代码单元隔离。如果一个方法依赖于另一个方法,我们应该模拟其他方法以隔离代码单元,确保我们专注于我们正在测试的代码单元的行为。

无依赖项编写单元测试

让我们看看第一个例子。在示例应用程序中,您可以找到一个名为Services的文件夹,其中包含IEmailService接口及其实现EmailServiceEmailService类有一个名为GenerateInvoiceEmail()的方法。该方法是一个简单的函数,根据Invoice实体生成电子邮件。以下代码显示了GenerateInvoiceEmail()方法:

public (string to, string subject, string body) GenerateInvoiceEmail(Invoice invoice){
    var to = invoice.Contact.Email;
    var subject = $"Invoice {invoice.InvoiceNumber} for {invoice.Contact.FirstName} {invoice.Contact.LastName}";
    var body = $"""
        Dear {invoice.Contact.FirstName} {invoice.Contact.LastName},
        Thank you for your business. Here are your invoice details:
        Invoice Number: {invoice.InvoiceNumber}
        Invoice Date: {invoice.InvoiceDate.LocalDateTime.ToShortDateString()}
        Invoice Amount: {invoice.Amount.ToString("C")}
        Invoice Items:
        {string.Join(Environment.NewLine, invoice.InvoiceItems.Select(i => $"{i.Description} - {i.Quantity} x {i.UnitPrice.ToString("C")}"))}
        Please pay by {invoice.DueDate.LocalDateTime.ToShortDateString()}. Thank you!
        Regards,
        InvoiceApp
        """;
    return (to, subject, body);
}

原始字符串字面量

body变量是一个原始字符串字面量,这是 C# 11 中引入的新特性。原始字符串字面量用三重引号(""")包围。它们可以跨越多行,并且可以包含双引号而不需要转义。您可以在learn.microsoft.com/en-us/dotnet/csharp/language-reference/tokens/raw-string找到有关原始字符串字面量的更多信息。原始字符串也可以与插值字符串一起使用,这对于生成包含变量的字符串来说很方便。

GenerateInvoiceEmail()方法中没有依赖项,因此我们可以不模拟任何其他方法就为该方法编写单元测试。在InvoiceApp.UnitTests项目中创建一个名为EmailServiceTests的类。然后,将以下代码添加到该类中:

[Fact]public void GenerateInvoiceEmail_Should_Return_Email()
{
    var invoiceDate = DateTimeOffset.Now;
    var dueDate = invoiceDate.AddDays(30);
    // Arrange
    var invoice = new Invoice
    {
        Id = Guid.NewGuid(),
        InvoiceNumber = "INV-001",
        Amount = 500,
        DueDate = dueDate,
        // Omit other properties for brevity
    };
    // Act
    var (to, subject, body) = new EmailService().GenerateInvoiceEmail(invoice);
    // Assert
    Assert.Equal(invoice.Contact.Email, to);
    Assert.Equal($"Invoice INV-001 for John Doe", subject);
    Assert.Equal($"""
        Dear John Doe,
        Thank you for your business. Here are your invoice details:
        Invoice Number: INV-001
        Invoice Date: {invoiceDate.LocalDateTime.ToShortDateString()}
        Invoice Amount: {invoice.Amount.ToString("C")}
        Invoice Items:
        Item 1 - 1 x $100.00
        Item 2 - 2 x $200.00
        Please pay by {invoice.DueDate.LocalDateTime.ToShortDateString()}. Thank you!
        Regards,
        InvoiceApp
        """, body);
}

Fact 属性表示 GenerateInvoiceEmail_Should_Return_Email() 方法是一个单元测试,这样 xUnit 就可以检测并运行这个方法作为单元测试。在 GenerateInvoiceEmail_Should_Return_Email() 方法中,我们创建了一个 Invoice 对象并将其传递给 GenerateInvoiceEmail() 方法。然后,我们使用 Assert 类验证 GenerateInvoiceEmail() 方法返回预期的电子邮件。

在编写单元测试时,我们遵循 安排-执行-断言 模式:

  • 安排:这是准备数据和设置单元测试环境的地方

  • 执行:这是调用我们想要测试的方法的地方

  • 断言:这是验证方法返回预期结果或方法行为符合预期的地方

要在 VS 2022 中运行单元测试,您可以右键单击 InvoiceApp.UnitTests 项目或 EmailServiceTest.cs 文件,并选择 运行测试。您也可以使用 测试资源管理器 窗口通过点击 测试 菜单并选择 运行 所有测试 来运行单元测试:

图 9.4 – 在 VS 2022 中运行单元测试

图 9.4 – 在 VS 2022 中运行单元测试

VS Code 也支持运行单元测试。点击 VS Code 窗口左侧的 测试 图标以打开 测试 视图;您将看到单元测试,如图 图 9**.5 所示:

图 9.5 – 在 VS Code 中运行单元测试

图 9.5 – 在 VS Code 中运行单元测试

如果您使用 .NET CLI,您可以在终端中运行以下命令来运行单元测试:

dotnet test

您将看到以下输出:

Starting test execution, please wait...A total of 1 test files matched the specified pattern.
Passed!  - Failed:     0, Passed:     1, Skipped:     0, Total:     1, Duration: < 1 ms - InvoiceApp.UnitTests.dll (net8.0)

输出显示单元测试已通过。如果您想查看详细的测试结果,您可以运行以下命令:

dotnet test --verbosity normal

您将看到详细的测试结果,显示测试名称、结果、持续时间和输出。

使用依赖项编写单元测试

让我们看看另一个例子。在 EmailService 类中,有一个名为 SendEmailAsync() 的方法,该方法向收件人发送电子邮件。在实际应用中,我们通常使用第三方电子邮件服务来发送电子邮件。为了使 EmailService 类可测试,我们可以创建一个 IEmailSender 接口及其实现 EmailSenderEmailSender 类是 SmtpClient 类的包装器,用于发送电子邮件。以下代码显示了更新的 EmailService 类:

public async Task SendEmailAsync(string to, string subject, string body){
    // Mock the email sending process
    // In real world, you may use a third-party email service, such as SendGrid, MailChimp, Azure Logic Apps, etc.
    logger.LogInformation($"Sending email to {to} with subject {subject} and body {body}");
    try
    {
        await emailSender.SendEmailAsync(to, subject, body);
        logger.LogInformation($"Email sent to {to} with subject {subject}");
    }
    catch (SmtpException e)
    {
        logger.LogError(e, $"SmtpClient error occurs. Failed to send email to {to} with subject {subject}.");
    }
    catch (Exception e)
    {
        logger.LogError(e, $"Failed to send email to {to} with subject {subject}.");
    }
}

因此,现在,EmailService 依赖于 IEmailSender 接口。为了测试 EmailService 类中 SendEmailAsync() 方法的行为,我们需要模拟 IEmailSender 接口以隔离 EmailService 类和 EmailSender 类。否则,如果单元测试中发生任何错误,我们无法确定错误是由 EmailService 类还是 EmailSender 类引起的。

我们可以使用Moq库来模拟IEmailSender接口。Moq是.NET 中流行的模拟库。它作为一个 NuGet 包提供。要安装Moq,您可以在 VS 2022 中使用NuGet 包管理器,或者运行以下命令:

dotnet add package Moq

然后,我们可以为SendEmailAsync()方法创建单元测试。因为如果邮件发送过程失败,SendEmailAsync()方法可能会抛出异常,所以我们需要编写两个单元测试来测试成功和失败场景。以下代码显示了成功场景的单元测试:

[Fact]public async Task SendEmailAsync_Should_Send_Email()
{
    // Arrange
    var to = "user@example.com";
    var subject = "Test Email";
    var body = "Hello, this is a test email";
    var emailSenderMock = new Mock<IEmailSender>();
    emailSenderMock.Setup(m => m.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
        .Returns(Task.CompletedTask);
    var loggerMock = new Mock<ILogger<IEmailService>>();
    loggerMock.Setup(l => l.Log(It.IsAny<LogLevel>(), It.IsAny<EventId>(), It.IsAny<It.IsAnyType>(),
        It.IsAny<Exception>(), (Func<It.IsAnyType, Exception?, string>)It.IsAny<object>())).Verifiable();
    var emailService = new EmailService(loggerMock.Object, emailSenderMock.Object);
    // Act
    await emailService.SendEmailAsync(to, subject, body);
    // Assert
    emailSenderMock.Verify(m => m.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
    loggerMock.Verify(
        l => l.Log(
            It.IsAny<LogLevel>(),
            It.IsAny<EventId>(),
            It.Is<It.IsAnyType>((v, t) => v.ToString().Contains($"Sending email to {to} with subject {subject} and body {body}")),
            It.IsAny<Exception>(),
            (Func<It.IsAnyType, Exception?, string>)It.IsAny<object>()
        ),
        Times.Once
    );
    loggerMock.Verify(
        l => l.Log(
            It.IsAny<LogLevel>(),
            It.IsAny<EventId>(),
            It.Is<It.IsAnyType>((v, t) => v.ToString().Contains($"Email sent to {to} with subject {subject}")),
            It.IsAny<Exception>(),
            (Func<It.IsAnyType, Exception?, string>)It.IsAny<object>()
        ),
        Times.Once
    );
}

在前面的代码中,我们使用Mock类来创建IEmailSender接口和ILogger接口的模拟对象。我们需要设置模拟对象的方法行为。如果单元测试中使用的方 法没有被设置,单元测试将会失败。例如,我们使用SetUp()方法来模拟IEmailSender接口的SendEmailAsync方法:

emailSenderMock.Setup(m => m.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))    .Returns(Task.CompletedTask);

SetUp()方法接受一个 lambda 表达式作为参数,用于配置SendEmailAsync()方法的行为。在前面的代码中,我们使用It.IsAny<string>()方法指定SendEmailAsync()方法可以接受任何字符串值作为参数。然后,我们使用Returns()方法指定SendEmailAsync()方法的返回值。在这种情况下,我们使用Task.CompletedTask属性指定SendEmailAsync()方法将返回一个完成的任务。如果您需要返回一个特定的值,您也可以使用Returns()方法返回一个特定的值。例如,如果SendEmailAsync()方法返回一个bool值,您可以使用以下代码返回一个true值:

emailSenderMock.Setup(m => m.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))    .ReturnsAsync(true);

模拟ILogger接口

EmailService类使用ILogger接口来记录信息和错误。我们使用LogInformation()方法记录信息,使用LogError()方法记录错误。然而,我们无法直接模拟LogInformation()LogError()方法,因为它们是建立在ILogger接口之上的扩展方法。这些扩展方法,如LogInformation()LogError()LogDebug()LogWarning()LogCritical()LogTrace()等,都调用ILogger接口的Log()方法。因此,为了验证给定的日志消息是否被记录,有必要仅模拟ILogger接口的Log()方法。

如果SendEmailAsync()方法抛出异常,我们需要确保当异常发生时,记录器会记录异常。为了测试失败场景,我们需要模拟SendEmailAsync()方法,使其抛出异常。我们可以使用ThrowsAsync()方法显式地模拟SendEmailAsync()方法抛出异常。以下代码显示了如何模拟SendEmailAsync()方法抛出异常:

emailSenderMock.Setup(m => m.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))  .ThrowsAsync(new SmtpException("Test SmtpException"));

然后,我们可以验证当SendEmailAsync()方法抛出异常时,ILogger接口的LogError()方法是否被调用,如下所示:

// Act + Assertawait Assert.ThrowsAsync<SmtpException>(() => emailService.SendEmailAsync(to, subject, body));
loggerMock.Verify(
    l => l.Log(
        It.IsAny<LogLevel>(),
        It.IsAny<EventId>(),
        It.Is<It.IsAnyType>((v, t) =>
            v.ToString().Contains($"Failed to send email to {to} with subject {subject}")),
        It.IsAny<SmtpException>(),
        (Func<It.IsAnyType, Exception?, string>)It.IsAny<object>()
    ),
    Times.Once
);

这样,我们可以确保当发生异常时,SendEmailAsync()方法将记录异常。

当我们编写单元测试时,请注意测试方法名称应该是描述性的,并且应该表明测试的目的。例如,SendEmailAsync_ShouldLogError_WhenEmailSendingFails()是一个好的名称,因为它表明当电子邮件发送失败时,SendEmailAsync方法应该记录错误。然而,SendEmailAsyncTest()不是一个好的名称,因为它没有表明测试的目的。

有关如何使用Mock库创建模拟对象的更多信息,请参阅github.com/moq/moq

使用 FluentAssertions 验证测试结果

xUnit提供了一套静态断言方法来验证测试结果。例如,我们可以使用Assert.Equal()方法来验证两个对象是否相等。这些方法涵盖了大多数场景,例如验证对象、集合、异常、事件、相等性、类型等。以下是 xUnit 提供的断言方法列表:

xUnit 断言方法 说明
Assert.Equal(expected, actual) 验证expected值等于actual
Assert.NotEqual(expected, actual) 验证expected值不等于actual
Assert.StrictEqual(expected, actual) 验证expected值严格等于actual值,使用类型的默认比较器
Assert.NotStrictEqual(expected, actual) 验证expected值严格不等于actual值,使用类型的默认比较器
Assert.Same(expected, actual) 验证expected对象是与actual对象相同的实例
Assert.NotSame(expected, actual) 验证expected对象不是与actual对象相同的实例
Assert.True(condition) 验证condition为真
Assert.False(condition) 验证condition为假
Assert.Null(object) 验证object是 null
Assert.NotNull(object) 验证object不是 null
Assert.IsType(expectedType, object) 验证object确实是expectedType,而不是派生类型
Assert.IsNotType(unexpectedType, object) 验证object不是确切的unexpectedType
Assert.IsAssignableFrom(expectedType, object) 验证object可以分配给expectedType,这意味着object是给定类型或其派生类型
Assert.Contains(expected, collection) 验证collection包含expected对象
Assert.DoesNotContain(expected, collection) 验证collection不包含expected对象
Assert.Empty(collection) 验证collection为空
Assert.NotEmpty(collection) 验证collection不为空
Assert.Single(collection) 验证collection是否恰好包含一个给定类型的元素
Assert.InRange(actual, low, high) 验证actual值是否在lowhigh(包含)的范围内
Assert.NotInRange(actual, low, high) 验证actual值不在lowhigh(包含)的范围内
Assert.Throws<exceptionType>(action) 验证action抛出指定exceptionType的异常,而不是派生异常类型
Assert.ThrowsAny<exceptionType>(action) 验证action抛出指定exceptionType或派生异常类型的异常

表 9.1 – xUnit 提供的断言方法列表

注意,这个列表并不完整。你可以在 xUnit 的 GitHub 仓库中找到更多断言方法:github.com/xunit/assert.xunit

虽然 xUnit 提供的断言方法对于大多数场景已经足够,但它们并不是非常易读。使单元测试更自然、易读的一个好方法是使用FluentAssertions,这是一个为.NET 提供的开源断言库。它提供了一组扩展方法,允许我们流畅地编写断言。

要安装FluentAssertions,我们可以使用以下.NET CLI 命令:

dotnet add package FluentAssertions

如果你使用 VS 2022,也可以使用 NuGet 包管理器安装FluentAssertions包。

然后,我们可以使用Should()方法来验证测试结果。例如,我们可以使用Should().Be()方法来验证两个对象是否相等。

以下代码展示了如何使用Should().Be()方法来验证GetInvoicesAsync()方法是否返回发票列表:

// Omitted code for brevityreturnResult.Should().NotBeNull();
returnResult.Should().HaveCount(2);
// Or use returnResult.Count.Should().Be(2);
returnResult.Should().Contain(i => i.InvoiceNumber == "INV-001");
returnResult.Should().Contain(i => i.InvoiceNumber == "INV-002");

FluentAssertions方法比Assert.Equal()方法更直观、易读。对于大多数场景,你可以轻松地将 xUnit 提供的断言方法替换为FluentAssertions方法,而无需查阅文档。

让我们看看如何使用FluentAssertions来验证异常。在EmailServiceTests类中,有一个SendEmailAsync_Should_Log_SmtpException()方法。此方法验证当SendEmailAsync()方法抛出异常时,SendEmailAsync()方法是否会记录异常。以下代码展示了如何使用xUnit来验证异常:

await Assert.ThrowsAsync<SmtpException>(() => emailService.SendEmailAsync(to, subject, body));

我们可以使用FluentAssertionsShould().ThrowAsync<>()方法来验证异常,如下面的代码所示:

var act = () => emailService.SendEmailAsync(to, subject, body);await act.Should().ThrowAsync<SmtpException>().WithMessage("Test SmtpException");

使用FluentAssertions比使用 xUnit 的方式更易读、直观。以下是一个表格,比较了 xUnit 和FluentAssertions提供的某些常见断言方法:

xUnit 断言方法 FluentAssertions 断言方法
Assert.Equal(expected, actual) .``Should().Be(expected)
Assert.NotEqual(expected, actual) .``Should().NotBe(expected)
Assert.True(condition) .``Should().BeTrue()
Assert.False(condition) .``Should().BeFalse()
Assert.Null(object) .``Should().BeNull()
Assert.NotNull(object) .``Should().NotBeNull()
Assert.Contains(expected, collection) .``Should().Contain(expected)
Assert.DoesNotContain(expected, collection) .``Should().NotContain(expected)
Assert.Empty(collection) .``Should().BeEmpty()
Assert.NotEmpty(collection) .``Should().NotBeEmpty()
Assert.Throws<TException>(action) .``Should().Throw<TException>()
Assert.DoesNotThrow(action) .``Should().NotThrow()

表 9.2 – xUnit 和 FluentAssertions 提供的常见断言方法的比较

注意,前面的表格并不是一个详尽的列表。你可以在 FluentAssertions 的官方文档中找到更多扩展方法:fluentassertions.com/introduction

除了流畅断言方法之外,如果测试失败,FluentAssertions 还提供了更好的错误信息。例如,如果我们使用 Assert.Equal() 方法来验证 returnResult 是否包含两个发票,代码将如下所示:

Assert.Equal(3, returnResult.Count);

如果测试失败,错误信息将如下所示:

InvoiceApp.UnitTests.InvoiceControllerTests.GetInvoices_ShouldReturnInvoices Source: InvoiceControllerTests.cs line 21
 Duration: 372 ms
  Message:
Assert.Equal() Failure
Expected: 3
Actual:   2
  Stack Trace:
InvoiceControllerTests.GetInvoices_ShouldReturnInvoices() line 34
InvoiceControllerTests.GetInvoices_ShouldReturnInvoices() line 41
--- End of stack trace from previous location ---

如果测试方法中有多个 Assert.Equal() 方法,虽然不推荐但有时不得不这样做,我们无法立即知道哪个 Assert.Equal() 方法失败。我们需要检查错误信息的行号以找到失败的断言。这并不很方便。

如果我们使用 FluentAssertions,断言代码将如下所示:

returnResult.Count.Should().Be(3);

如果测试因相同原因失败,错误信息将如下所示:

InvoiceApp.UnitTests.InvoiceControllerTests.GetInvoices_ShouldReturnInvoices Source: InvoiceControllerTests.cs line 21
 Duration: 408 ms
  Message:
Expected returnResult.Count to be 3, but found 2.

现在,错误信息更加详细和直观,并告诉我们哪个断言失败。这对于我们在测试方法中有多个断言时非常有帮助。

你甚至可以通过向断言方法添加自定义消息来丰富错误信息。例如,我们可以向 Should().Be() 方法添加自定义消息,如下所示:

returnResult.Count.Should().Be(3, "The number of invoices should be 3");

现在,错误信息将如下所示:

Expected returnResult.Count to be 3 because The number of invoices should be 3, but found 2.

因此,强烈建议在测试中使用 FluentAssertions。它可以使你的测试更易于阅读和维护。

测试数据库访问层

在许多 Web API 应用程序中,我们需要访问数据库以执行 CRUD 操作。在本节中,我们将学习如何在单元测试中测试数据库访问层。

我们如何测试数据库访问层?

目前,我们将 InvoiceDbContext 注入到控制器中以访问数据库。这种方法对开发来说很容易,但它将控制器与 InvoiceDbContext 类紧密耦合。当我们测试控制器时,我们需要创建一个真实的 InvoiceDbContext 对象并使用它来测试控制器,这意味着控制器不是独立测试的。这个问题可以通过多种方式解决:

  • 使用 EF Core 的 InMemoryDatabase 提供者创建内存数据库作为模拟数据库

  • 使用 SQLite 内存数据库作为模拟数据库

  • 创建一个单独的仓库层来封装数据库访问代码,将仓库层注入到控制器(或需要访问数据库的服务)中,然后使用Mock对象来模拟仓库层

  • 使用真实数据库进行测试

每种方法都有其优缺点:

  • InMemoryDatabase提供者最初是为 EF Core 的内部测试设计的。然而,它不是测试其他应用程序的好选择,因为它不像真实数据库那样运行。例如,它不支持事务和原始 SQL 查询。因此,它不是测试数据库访问代码的好选择。

  • SQLite 也提供了一个可用于测试的内存数据库功能。然而,它具有与 EF Core 的InMemoryDatabase提供者类似的限制。如果生产数据库是 SQL Server,如果我们使用 SQLite 进行测试,EF Core 无法保证数据库访问代码在 SQL Server 上正确工作。

  • 创建一个单独的仓库层是为了将控制器与DbContext类解耦。在这个模式中,在应用程序代码和DbContext之间创建了一个单独的IRepository接口,并将IRepository接口的实现注入到控制器或服务中。这样,我们可以使用Mock对象来模拟IRepository接口以测试控制器或服务,这意味着控制器或服务可以在隔离状态下进行测试。然而,这种方法需要大量工作来创建仓库层。此外,DbContext类已经是仓库模式,所以如果你不需要更改数据库提供者,创建另一个仓库层是多余的。但这个模式仍然有其优点。测试可以专注于应用程序逻辑,而不必担心数据库访问代码。此外,如果你需要更改数据库提供者,你只需更改IRepository接口的实现,无需更改控制器或服务。

  • 对真实数据库进行测试提供了更多好处。其中最重要的好处之一是它可以确保数据库访问代码在生产数据库上正确工作。使用真实数据库也是快速且可靠的。然而,一个挑战是我们需要确保测试的隔离性,因为其中一些测试可能会更改数据库中的数据。因此,我们需要确保测试完成后数据可以被恢复或重新创建。

在本节中,我们将使用一个单独的本地数据库进行测试,例如 LocalDB 数据库。如果你的应用程序将在 SQL Server 上运行,你可以使用另一个 SQL Server 进行测试而不是 LocalDB,因为 LocalDB 的行为与 SQL Server 不相同。如果你的应用程序将在云中运行,例如 Azure,你可以使用 Azure SQL 数据库。你可以为测试使用另一个 Azure SQL 数据库,但你需要为其分配少量资源以节省成本。请记住,测试数据库应尽可能保持与生产环境相同的环境,以避免在生产中出现意外的行为。

关于控制器,我们将直接使用 InvoiceDbContext 类以简化操作;我们将在未来的章节中学习仓储模式。

创建测试固定点

当我们对数据库进行 CRUD 方法测试时,需要在测试执行之前准备数据库,并在测试完成后清理数据库,以确保测试所做的更改不会影响其他测试。xUnit 提供了 IClassFixture<T> 接口来创建测试固定点,这可以用于为每个测试类准备和清理数据库。

首先,我们需要在 InvoiceApp.UnitTests 项目中创建一个测试固定点类,如下所示:

public class TestDatabaseFixture{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=InvoiceTestDb;Trusted_Connection=True";
}

TestDatabaseFixture 类中,我们定义了一个连接到本地数据库的连接字符串。使用 const 字符串只是为了简化。在实际应用程序中,你可能希望使用配置系统从其他来源读取连接字符串,例如 appsettings.json 文件。

然后,我们添加一个创建数据库上下文对象的方法,如下所示:

public InvoiceDbContext CreateDbContext()    => new(new DbContextOptionsBuilder<InvoiceDbContext>()
            .UseSqlServer(ConnectionString)
            .Options, null);

我们还需要一个初始化数据库的方法,如下所示:

public void InitializeDatabase(){
    using var context = CreateDbContext();
    context.Database.EnsureDeleted();
    context.Database.EnsureCreated();
    // Create a few Contacts
    var contacts = new List<Contact>
    {
        // Omitted the code for brevity
    };
    context.Contacts.AddRange(contacts);
    // Create a few Invoices
    var invoices = new List<Invoice>
    {
        // Omitted the code for brevity
    };
    context.Invoices.AddRange(invoices);
    context.SaveChanges();
}

InitializeDatabase() 方法中,我们创建一个新的 InvoiceDbContext 对象,然后使用 EnsureDeleted() 方法删除数据库(如果存在)。然后,我们使用 EnsureCreated() 方法创建数据库。之后,我们在数据库中填充一些数据。在这个例子中,我们创建了一些 ContactInvoice 对象并将它们添加到数据库中。最后,我们调用 SaveChanges() 方法将更改保存到数据库中。

现在,我们需要在 TestDatabaseFixture 类的构造函数中调用 InitializeDatabase() 方法来初始化数据库,如下所示:

private static readonly object Lock = new();private static bool _databaseInitialized;
public TestDatabaseFixture()
{
    // This code comes from Mirosoft Docs: https://github.com/dotnet/EntityFramework.Docs/blob/main/samples/core/Testing/TestingWithTheDatabase/TestDatabaseFixture.cs
    lock (Lock)
    {
        if (!_databaseInitialized!)
        {
            InitializeDatabase();
            databaseInitialized = true;
        }
    }
}

为了避免多次初始化数据库,我们使用一个静态字段 _databaseInitialized 来指示数据库是否已初始化。我们还定义了一个静态对象 Lock,以确保数据库只初始化一次。InitializeDatabase() 方法用于初始化数据库。它将在测试执行之前只调用一次。

有几点重要的事情需要注意:

  • xUnit 为每个测试创建测试类的新的实例。因此,测试类的构造函数会在每个测试中调用。

  • 对于每个测试运行删除和重新创建数据库可能会减慢测试速度,并且可能不是必要的。如果您不想在每次测试运行时删除和重新创建数据库,您可以取消注释EnsureDeleted()方法以允许数据库重用。然而,如果在开发阶段需要频繁更改数据库模式,您可能需要在每次测试运行时删除和重新创建数据库以确保数据库模式是最新的。

  • 我们使用一个锁对象来确保InitializeDatabase()方法在每个测试运行中只被调用一次。原因是TextDatabaseFixture类可以在多个测试类中使用,xUnit 可以并行运行多个测试类。使用锁可以帮助我们确保种子方法只被调用一次。我们将在下一节中了解更多关于并行测试执行的内容。

现在测试固定装置已经准备好了,我们可以在测试类中使用它。

使用测试固定装置

接下来,我们将在测试类中使用测试固定装置。首先,让我们测试InvoiceController类的GetAll()方法。在InvoiceApp.UnitTests项目中创建一个名为InvoiceControllerTests的新测试类,如下所示:

public class InvoiceControllerTests(TestFixture fixture) : IClassFixture<TestFixture>{
}

我们使用依赖注入将TestDatabaseFixture对象注入到测试类中。然后,我们可以在测试方法中使用文本固定装置来创建InvoiceDbContext对象,如下所示:

[Fact]public async Task GetInvoices_ShouldReturnInvoices()
{
    // Arrange
    await using var dbContext = fixture.CreateDbContext();
    var emailServiceMock = new Mock<IEmailService>();
    var controller = new InvoiceController(dbContext, emailServiceMock.Object);
    // Act
    var actionResult = await controller.GetInvoicesAsync();
    // Assert
    var result = actionResult.Result as OkObjectResult;
    Assert.NotNull(result);
    var returnResult = Assert.IsAssignableFrom<List<Invoice>>(result.Value);
    Assert.NotNull(returnResult);
    Assert.Equal(2, returnResult.Count);
    Assert.Contains(returnResult, i => i.InvoiceNumber =="INV-001");
    Assert.Contains(returnResult, i => i.InvoiceNumber =="INV-002");
}

GetInvoices_ShouldReturnInvoices()方法中,我们使用固定装置创建InvoiceDbContext对象,然后使用一些模拟依赖项创建InvoiceController对象。然后,我们调用GetInvoicesAsync()方法从数据库中获取发票。最后,我们使用Assert类来验证结果。

我们用来验证控制器的数据是我们将数据种入TestDatabaseFixture类数据库中的数据。如果您更改TestDatabaseFixture类中的数据,您也需要更改测试类中的预期数据。

GetInvoices_ShouldReturnInvoices()方法是一个简单的Fact测试方法。我们也可以使用Theory测试方法来测试具有不同参数的GetInvoicesAsync()方法。例如,我们可以测试在传递status参数时,控制器是否可以返回正确的发票。测试方法如下:

[Theory][InlineData(InvoiceStatus.AwaitPayment)]
[InlineData(InvoiceStatus.Draft)]
public async Task GetInvoicesByStatus_ShouldReturnInvoices(InvoiceStatus status)
{
    // Arrange
    await using var dbContext = _fixture.CreateDbContext();
    var emailServiceMock = new Mock<IEmailService>();
    var controller = new InvoiceController(dbContext, emailServiceMock.Object);
    // Act
    var actionResult = await controller.GetInvoicesAsync(status: status);
    // Assert
    var result = actionResult.Result as OkObjectResult;
    Assert.NotNull(result);
    var returnResult = Assert.IsAssignableFrom<List<Invoice>>(result.Value);
    Assert.NotNull(returnResult);
    Assert.Single(returnResult);
    Assert.Equal(status, returnResult.First().Status);
}

在前面的示例中,我们使用Theory属性来指示测试方法是Theory测试方法。一个Theory测试方法可以有一个或多个InlineData属性。每个InlineData属性可以向测试方法传递一个或多个值。在这种情况下,我们使用InlineData属性将InvoiceStatus值传递给测试方法。您可以使用多个InlineData属性向测试方法传递多个值。测试方法将使用不同的值多次执行。

本章中介绍的所有测试都是用来测试只读方法的。它们不会更改数据库,所以我们不需要担心数据库的状态。在下一节中,我们将介绍如何为更改数据库的方法编写测试。

为更改数据库的方法编写测试

如果一个方法更改数据库,我们需要确保在运行测试之前数据库处于已知状态,并且确保在测试后数据库恢复到原始状态,这样更改就不会影响其他测试。

例如,一个方法可能从数据库中删除一条记录。如果测试方法从数据库中删除了一条记录但在测试后没有恢复数据库,那么下一个测试方法可能会失败,因为记录缺失。

让我们为InvoiceController类的CreateInvoiceAsync()方法创建一个测试方法。CreateInvoiceAsync()方法在数据库中创建一个新的发票。测试方法如下:

[Fact]public async Task CreateInvoice_ShouldCreateInvoice()
{
    // Arrange
    await using var dbContext = fixture.CreateDbContext();
    var emailServiceMock = new Mock<IEmailService>();
    var controller = new InvoiceController(dbContext, emailServiceMock.Object);
    // Act
    var contactId = dbContext.Contacts.First().Id;
    var invoice = new Invoice
    {
        DueDate = DateTimeOffset.Now.AddDays(30),
        ContactId = contactId,
        Status = InvoiceStatus.Draft,
        InvoiceDate = DateTimeOffset.Now,
        InvoiceItems = new List<InvoiceItem>
        {
            // Omitted for brevity
        }
    };
    var actionResult = await controller.CreateInvoiceAsync(invoice);
    // Assert
    var result = actionResult.Result as CreatedAtActionResult;
    Assert.NotNull(result);
    var returnResult = Assert.IsAssignableFrom<Invoice>(result.Value);
    var invoiceCreated = await dbContext.Invoices.FindAsync(returnResult.Id);
    Assert.NotNull(invoiceCreated);
    Assert.Equal(InvoiceStatus.Draft, invoiceCreated.Status);
    Assert.Equal(500, invoiceCreated.Amount);
    Assert.Equal(3, dbContext.Invoices.Count());
    Assert.Equal(contactId, invoiceCreated.ContactId);
    // Clean up
    dbContext.Invoices.Remove(invoiceCreated);
    await dbContext.SaveChangesAsync();
}

在这个测试方法中,我们创建了一个新的发票并将其传递给CreateInvoiceAsync()方法。然后,我们使用Assert类来验证结果。最后,我们从数据库中删除发票并保存更改。请注意,CreateInvoiceAsync()方法的结果是一个CreatedAtActionResult对象,它包含创建的发票。因此,我们应该将结果转换为CreatedAtActionResult对象,然后从Value属性中获取创建的发票。此外,在这个测试方法中,我们已经断言创建的发票的Amount属性基于发票项是正确的。

当我们运行测试时,可能会出现错误,因为联系人 ID 不正确,如下所示:

Assert.Equal() FailureExpected: ae29a8ef-5e32-4707-8783-b6bc098c0ccb
Actual:   275de2a8-5e0f-420d-c68a-08db59a2942f

错误信息表明CreateInvoiceAsync()方法的行为不符合预期。我们可以调试应用程序以找出为什么联系人 ID 没有正确保存。原因是当我们创建Invoice时,我们只指定了ContactId属性,而没有指定Contact属性。因此,EF Core 找不到指定 ID 的联系人,然后它创建了一个具有新 ID 的新联系人。为了解决这个问题,我们需要在创建Invoice对象时指定Contact属性。在调用dbContext.Invoices.AddAsync()方法之前,添加以下代码:

var contact = await dbContext.Contacts.FindAsync(invoice.ContactId);if (contact == null)
{
    return BadRequest("Contact not found.");
}
invoice.Contact = contact;

现在,我们可以再次运行测试。这次,测试应该会通过。这就是单元测试如此重要的原因。它们可以帮助我们在将应用程序部署到生产之前及早发现并修复错误。

在前面的示例中,数据是在测试方法中创建的,然后在测试结束后从数据库中删除。还有另一种管理这种场景的方法:使用事务。我们可以使用事务来包装测试方法,然后在测试结束后回滚事务。这样,在测试方法中创建的数据就不会保存到数据库中。这样,我们就不需要手动从数据库中删除数据。

让我们为 InvoiceController 类的 UpdateInvoiceAsync() 方法创建一个测试。UpdateInvoiceAsync() 方法更新数据库中的发票。测试方法如下:

[Fact]public async Task  UpdateInvoice_ShouldUpdateInvoice()
{
    // Arrange
    await using var dbContext = fixture.CreateDbContext();
    var emailServiceMock = new Mock<IEmailService>();
    var controller = new InvoiceController(dbContext, emailServiceMock.Object);
    // Act
    // Start a transaction to prevent the changes from being saved to the database
    await dbContext.Database.BeginTransactionAsync();
    var invoice = dbContext.Invoices.First();
    invoice.Status = InvoiceStatus.Paid;
    invoice.Description = "Updated description";
    invoice.InvoiceItems.ForEach(x =>
    {
        x.Description = "Updated description";
        x.UnitPrice += 100;
    });
    var expectedAmount = invoice.InvoiceItems.Sum(x => x.UnitPrice * x.Quantity);
    await controller.UpdateInvoiceAsync(invoice.Id, invoice);
    // Assert
    dbContext.ChangeTracker.Clear();
    var invoiceUpdated = await dbContext.Invoices.SingleAsync(x => x.Id == invoice.Id);
    Assert.Equal(InvoiceStatus.Paid, invoiceUpdated.Status);
    Assert.Equal("Updated description", invoiceUpdated.Description);
    Assert.Equal(expectedAmount, invoiceUpdated.Amount);
    Assert.Equal(2, dbContext.Invoices.Count());
}

UpdateInvoice_ShouldUpdateInvoice() 方法中,在我们调用 UpdateInvoiceAsync() 方法之前,我们启动一个事务。在测试方法执行后,我们不提交事务,因此事务将回滚。在测试方法中做出的更改将不会保存到数据库中。这样,我们就不需要手动从数据库中删除数据。

我们还使用 ChangeTracker.Clear() 方法来清除更改跟踪器。更改跟踪器用于跟踪对实体的更改。如果我们不清除更改跟踪器,我们将得到跟踪的实体而不是查询数据库。因此,在查询数据库之前,我们需要显式清除更改跟踪器。

当我们测试更改数据库的方法时,这种方法很方便。然而,它可能会导致一个问题:如果控制器(或服务)方法已经启动了一个事务?我们不能在另一个事务中包装一个事务。在这种情况下,我们必须显式清理测试方法执行后对数据库所做的任何更改。

我们可以使用 IDisposable 接口在我们的测试中清理数据库。为此,我们可以创建一个实现 IDisposable 接口的测试类,然后在 Dispose() 方法中清理数据库。为了设置测试上下文,让我们创建一个名为 TransactionalTestDatabaseFixture 的类,如下所示:

public class TransactionalTestDatabaseFixture{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=InvoiceTransactionalTestDb;Trusted_Connection=True";
    public TransactionalTestDatabaseFixture()
    {
        // This code comes from Microsoft Docs: https://github.com/dotnet/EntityFramework.Docs/blob/main/samples/core/Testing/TestingWithTheDatabase/TransactionalTestDatabaseFixture.cs
        using var context = CreateDbContext();
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();
        InitializeDatabase();
    }
    public InvoiceDbContext CreateDbContext()
        => new(new DbContextOptionsBuilder<InvoiceDbContext>()
            .UseSqlServer(ConnectionString)
            .Options, null);
    public void InitializeDatabase()
    {
        using var context = CreateDbContext();
        // Create a few Contacts and Invoices
        // Omitted for brevity
        context.SaveChanges();
    }
    public void Cleanup()
    {
        using var context = CreateDbContext();
        context.Contacts.ExecuteDelete();
        context.Invoices.ExecuteDelete();
        context.SaveChanges();
        InitializeDatabase();
    }
}

在前面的代码中,我们创建了一个名为 InvoiceTransactionalTestDb 的数据库并初始化它。此文件与 InvoiceTestDatabaseFixture 类类似,但它有一个 Cleanup 方法,用于清理数据库。在 Cleanup 方法中,我们从数据库中删除所有联系人和发票,然后初始化数据库以恢复数据。

InvoiceController.cs 文件中,UpdateInvoiceStatusAsync 方法使用事务来更新发票的状态。这不是必需的;这纯粹是为了演示目的。让我们创建一个名为 TransactionalInvoiceControllerTests 的测试类来测试这个方法,如下所示:

public class TransactionalInvoiceControllerTests(TransactionalTestDatabaseFixture fixture) : IClassFixture<TransactionalTestDatabaseFixture>, IDisposable{
    [Fact]
    public async Task UpdateInvoiceStatusAsync_ShouldUpdateStatus()
    {
        // Arrange
        await using var dbContext = _fixture.CreateDbContext();
        var emailServiceMock = new Mock<IEmailService>();
        var controller = new InvoiceController(dbContext, emailServiceMock.Object);
        // Act
        var invoice = await dbContext.Invoices.FirstAsync(x => x.Status == InvoiceStatus.AwaitPayment);
        await controller.UpdateInvoiceStatusAsync(invoice.Id, InvoiceStatus.Paid);
        // Assert
        dbContext.ChangeTracker.Clear();
        var updatedInvoice = await dbContext.Invoices.FindAsync(invoice.Id);
        Assert.NotNull(updatedInvoice);
        Assert.Equal(InvoiceStatus.Paid, updatedInvoice.Status);
    }
    public void Dispose()
    {
        _fixture.Cleanup();
    }
}

在前面的代码中,我们使用 TransactionalTestDatabaseFixture 类创建数据库上下文。此类实现了 IDisposable 接口,并在 Dispose() 方法中调用 Cleanup() 方法。如果我们在一个测试类中有多个测试方法,xUnit 将为每个测试方法创建测试类的实例,并按顺序运行它们。因此,Dispose() 方法将在每个测试方法执行后调用以清理数据库,这将确保测试方法中做出的更改不会影响其他测试方法。

如果我们想在多个测试类中共享TransactionalTestDatabaseFixture,会怎样呢?默认情况下,xUnit 会并行运行测试类。如果其他测试类也需要使用此固定装置来清理数据库,当 xUnit 初始化测试上下文时可能会引起并发问题。为了避免这个问题,我们可以使用Collection属性来指定使用此固定装置的测试类属于同一个测试集合,这样 xUnit 就不会并行运行它们。我们将在下一节讨论 xUnit 的并行性。

xUnit 的并行性

默认情况下,xUnit(v2+)的最新版本会并行运行测试。这是因为并行化可以提高测试的性能。如果我们有很多测试,并行运行它们可以节省大量时间。此外,它还可以利用多核 CPU 来运行测试。然而,我们需要了解 xUnit 如何并行运行测试,以防它引起问题。

xUnit 使用一个称为测试集合的概念来表示一组测试。默认情况下,每个测试类是一个唯一的测试集合。请注意,同一测试类中的测试不会并行运行。

例如,在示例项目中,我们可以找到一个InvoiceControllerTests.cs文件和一个ContactControllerTests.cs文件。因此,xUnit 将并行运行这两个测试类,但同一测试类中的测试不会并行运行。

我们还在创建测试固定装置部分中引入了TestDatabaseFixture类。类固定装置用于在同一个测试类中的所有测试之间共享单个测试上下文。因此,如果我们使用类固定装置来创建数据库上下文,数据库上下文将共享给同一个测试类中的所有测试。目前,我们有两个测试类使用TestDatabaseFixture类来提供数据库上下文。xUnit 将为这两个测试类创建TestDatabaseFixture类的一个实例吗?

答案是否定的。我们可以在TestDatabaseFixture类的构造函数中设置一个断点,然后在 VS 2022 的测试资源管理器窗口中右键单击InvoiceApp.UnitTests,然后点击调试来调试测试,如图图 9.6所示。6*:

图 9.6 – 在 VS 2022 中调试测试

图 9.6 – 在 VS 2022 中调试测试

你会发现TestDatabaseFixture类的构造函数被调用两次(或更多,取决于使用此测试固定装置的测试数量)。因此,我们知道 xUnit 将为每个测试类创建TestDatabaseFixture类的新实例。这就是我们使用锁来确保数据库只创建一次的原因。如果我们不使用锁,多个测试类将同时尝试初始化数据库,这可能会引起潜在的问题。

编写更改数据库的方法的测试部分,我们创建了一个可以清理数据库的TransactionalTestDatabaseFixture类。如果我们将其应用于一个测试类,例如TransactionalInvoiceControllerTests,它将正常工作。但如果我们想将其用于多个测试类呢?默认情况下,xUnit 将并行运行这些测试类,这意味着多个测试类将同时尝试清理数据库。在这种情况下,我们不希望并行运行这些测试类。为了做到这一点,我们可以使用Collection属性将这些测试类分组到一个集合中,这样 xUnit 就不会并行运行它们。这可以帮助我们避免并发问题。

让我们看一个例子。在示例项目中,你会找到一个名为UpdateContactAsync()的方法,它使用事务。这并不是必需的,只是为了演示目的。要使用集合固定件,我们需要为集合创建一个定义。让我们在InvoiceApp.UnitTests项目中创建一个TransactionalTestsCollection类,如下所示:

[CollectionDefinition("TransactionalTests")]public class TransactionTestsCollection : ICollectionFixture<TransactionalTestDatabaseFixture>
{
}

在本课程中,我们声明TransactionalTestDatabaseFixture类是一个使用CollectionDefinition属性的集合固定件。我们还为这个集合指定了一个名称,即TransactionalTests。然后,我们使用ICollectionFixture<T>接口来指定TransactionalTestDatabaseFixture类是一个集合固定件。

之后,我们在测试类中添加了Collection属性,指定TransactionalInvoiceControllerTestsTransactionalContactControllerTests类属于TransactionalTests集合,如下所示:

[Collection("TransactionalTests")]public class TransactionalInvoiceControllerTests : IDisposable
{
    // Omitted for brevity
}
[Collection("TransactionalTests")]
public class TransactionalContactControllerTests : IDisposable
{
    // Omitted for brevity
}

现在,如果我们调试测试,我们会发现TransactionalTestDatabaseFixture类的构造函数只被调用一次,这意味着 xUnit 将为这两个测试类只创建一个TransactionalTestDatabaseFixture类的实例。此外,xUnit 不会并行运行这两个测试类,这意味着TransactionalTestDatabaseFixture类的Cleanup方法不会同时被调用。因此,我们可以使用TransactionalTestDatabaseFixture类为多个测试类中的每个测试方法清理数据库。

让我们总结本节的关键点:

  • 默认情况下,每个测试类都是一个唯一的测试集合

  • 同一测试类中的测试不会并行运行

  • 如果我们想在同一测试类中的所有测试之间共享一个单独的测试上下文,我们可以使用类固定件:

    • xUnit 为每个测试方法创建一个新的测试类实例

    • xUnit 为每个测试类创建一个新的类固定件实例,并在同一测试类中的所有测试之间共享相同的实例

  • 默认情况下,如果测试类不在同一测试集合中,xUnit 将并行运行测试类

  • 如果我们不想并行运行多个测试类,可以使用Collection属性将它们分组到一个测试集合中

  • 如果我们想在几个测试类之间共享单个测试上下文,并在每个测试方法之后清理测试上下文,我们可以使用集合固定实例,并在每个测试类中实现IDisposable接口以清理测试上下文:

    • xUnit 为每个测试方法创建一个新的测试类实例

    • xUnit 只为测试集合创建一个集合固定实例,并在集合中的所有测试之间共享该实例

    • 如果测试类属于同一个测试集合,xUnit 不会并行运行多个测试类

xUnit 提供了许多功能来自定义测试执行。如果您想了解更多关于 xUnit 的信息,可以查看官方文档,网址为xunit.net/

使用存储库模式

到目前为止,您已经学习了如何使用真实数据库来测试数据库访问层。还有另一种测试数据库访问层的方法,即使用存储库模式将控制器与DbContext类解耦。在本节中,我们将向您展示如何使用存储库模式来测试数据库访问层。

存储库模式是一种常用的模式,用于将应用程序和数据库访问层分离。我们可以在控制器中不直接使用DbContext,而是添加一个单独的存储库层来封装数据库访问逻辑。控制器将使用存储库层来访问数据库,如图 9.7 所示:

图 9.7– 使用存储库模式

图 9.7– 使用存储库模式

图 9.7中,我们可以看到应用程序现在没有对 EF Core 的依赖。应用程序(控制器)只依赖于存储库层,而存储库层依赖于 EF Core。因此,在测试中可以模拟存储库层,而无需真实数据库即可测试控制器。

要了解如何使用存储库模式进行测试,您可以查看UnitTestsDemo\UnitTest-v2文件夹中的示例项目。该项目基于v1项目,我们已向项目中添加了存储库层。

IInvoiceRepository接口定义了Invoice存储库的方法,如下所示:

public interface IInvoiceRepository{
    Task<Invoice?> GetInvoiceAsync(Guid id);
    Task<IEnumerable<Invoice>> GetInvoicesAsync(int page = 1, int pageSize = 10, InvoiceStatus? status = null);
    Task<IEnumerable<Invoice>> GetInvoicesByContactIdAsync(Guid contactId, int page = 1, int pageSize = 10, InvoiceStatus? status = null);
    Task<Invoice> CreateInvoiceAsync(Invoice invoice);
    Task<Invoice?> UpdateInvoiceAsync(Invoice invoice);
    Task DeleteInvoiceAsync(Guid id);
}

IInvoiceRepository接口的实现位于InvoiceRepository类中;它使用DbContext类来访问数据库。首先,我们使用构造函数注入将InvoiceDbContext类注入到InvoiceRepository类中,如下所示:

public class InvoiceRepository(InvoiceDbContext dbContext) : IInvoiceRepository{
}

接下来,我们可以在InvoiceRepository类中实现IInvoiceRepository接口。以下是一个GetInvoicesAsync方法的示例:

public async Task<Invoice?> GetInvoiceAsync(Guid id){
    return await dbContext.Invoices.Include(i => i.Contact)
        .SingleOrDefaultAsync(i => i.Id == id);
}

GetInvoiceAsync()方法中,我们使用LINQ查询根据指定的 ID 获取发票。请注意,我们使用Include方法将Contact属性包含在查询结果中。这是因为我们想要获取发票的联系人信息。如果我们不希望在查询结果中包含导航属性,我们可以删除Include()方法,或者向GetInvoiceAsync()方法添加一个参数来指定是否包含导航属性。Include()方法定义在Microsoft.EntityFrameworkCore命名空间中,因此我们需要在InvoiceRepository.cs文件中添加using Microsoft.EntityFrameworkCore;语句。

GetInvoicesAsync()方法的实现如下:

public async Task<IEnumerable<Invoice>> GetInvoicesAsync(int page = 1, int pageSize = 10, InvoiceStatus? status = null){
    return await dbContext.Invoices
        .Include(x => x.Contact)
        .Where(x => status == null || x.Status == status)
        .OrderByDescending(x => x.InvoiceDate)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .ToListAsync();
}

在前面的GetInvoicesAsync()方法中,我们使用了一些LINQ方法,例如Where()OrderByDescending()Skip()Take()来实现分页功能。请注意,ToListAsync()方法定义在Microsoft.EntityFrameworkCore命名空间中,所以不要忘记添加using Microsoft.EntityFrameworkCore;语句。

你可以在UnitTestsDemo\UnitTest-v2文件夹中的InvoiceRepository.cs文件中找到InvoiceRepository类的完整实现。

存储库接口的实现只是一个使用DbContext类来实现 CRUD 操作的类。通常,这一层不包含任何业务逻辑。此外,我们应该注意,GetInvoicesAsync()方法返回IEnumerable<Invoice>而不是IQueryable<Invoice>。这是因为IQueryable接口涉及 EF Core,但使用存储库模式的目的是将应用程序与 EF Core 解耦。因此,我们可以在测试中轻松模拟存储库层。

控制器现在依赖于存储库层,如下所示:

[Route("api/[controller]")][ApiController]
public class InvoiceController(IInvoiceRepository invoiceRepository, IEmailService emailService)
    : ControllerBase
    // GET: api/Invoices
    [HttpGet]
    public async Task<ActionResult<List<Invoice>>> GetInvoicesAsync(int page = 1, int pageSize = 10,
        InvoiceStatus? status = null)
    {
        var invoices = await invoiceRepository.GetInvoicesAsync(page, pageSize, status);
        return Ok(invoices);
    }
    // Omitted for brevity
}

现在,控制器变得更加简洁,不再依赖于 EF Core。我们可以更新测试,使它们使用存储库层而不是DbContext类。类似于之前的InvoiceControllerTests,我们可能需要一个类固定器来管理测试上下文,如下所示:

public class TestFixture{
    public List<Invoice> Invoices { get; set; } = new();
    public List<Contact> Contacts { get; set; } = new();
    public TestFixture()
    {
        InitializeDatabase();
    }
    public void InitializeDatabase()
    {
        // Omited for brevity
    }
}

在这个类固定器中,我们添加了两个列表来模拟数据库表。接下来,我们可以模拟测试,如下所示:

public class InvoiceControllerTests(TestFixture fixture) : IClassFixture<TestFixture>{
    [Fact]
    public async Task GetInvoices_ShouldReturnInvoices()
    {
        // Arrange
        var repositoryMock = new Mock<IInvoiceRepository>();
        repositoryMock.Setup(x => x.GetInvoicesAsync(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<InvoiceStatus?>()))
            .ReturnsAsync((int page, int pageSize, InvoiceStatus? status) =>
                fixture.Invoices.Where(x => status == null || x.Status == status)
                    .OrderByDescending(x => x.InvoiceDate)
                    .Skip((page - 1) * pageSize)
                    .Take(pageSize)
                    .ToList());
        var emailServiceMock = new Mock<IEmailService>();
        var controller = new InvoiceController(repositoryMock.Object, emailServiceMock.Object);
        // Act
        var actionResult = await controller.GetInvoicesAsync();
        // Assert
        var result = actionResult.Result as OkObjectResult;
        Assert.NotNull(result);
        var returnResult = Assert.IsAssignableFrom<List<Invoice>>(result.Value);
        Assert.NotNull(returnResult);
        Assert.Equal(2, returnResult.Count);
        Assert.Contains(returnResult, i => i.InvoiceNumber == "INV-001");
        Assert.Contains(returnResult, i => i.InvoiceNumber == "INV-002");
    }
    // Omited for brevity
}

在这个测试方法中,我们模拟存储库层并将其传递给控制器。这遵循了单元测试的概念:关注被测试的单元并模拟依赖项。你可以检查源代码中的其他测试,并尝试添加更多测试以覆盖其他场景,例如创建发票、更新发票、删除发票等。请注意,我们使用两个List<T>实例来模拟数据库表。如果测试方法更改了数据,不要忘记在测试方法执行后恢复数据。

仓库模式是一种将应用程序与数据访问层解耦的良好实践,它还使得用另一个数据访问层替换现有数据访问层成为可能。它允许我们在测试目的下模拟数据库访问层。然而,它增加了应用程序的复杂性。此外,如果我们使用仓库模式,我们可能会丢失 EF Core 的一些功能,例如IQueryable。最后,模拟行为可能与实际行为不同。因此,在使用它之前,我们应该考虑权衡利弊。

测试快乐路径和悲伤路径

到目前为止,我们已经编写了一些测试来覆盖快乐路径。然而,我们也应该测试悲伤路径。在测试中,术语快乐路径悲伤路径用于描述不同的场景或测试用例:

  • GetInvoiceAsync(Guid id)方法,快乐路径是数据库中存在指定 ID 的发票,并且方法返回该发票。

  • GetInvoiceAsync(Guid id)方法,悲伤路径是数据库中不存在指定 ID 的发票,并且方法返回404 Not Found错误。

通过结合快乐路径和悲伤路径测试,我们可以确保代码单元在不同场景下按预期工作。以下是为GetInvoiceAsync(Guid id)方法的快乐路径示例:

[Fact]public async Task GetInvoice_ShouldReturnInvoice()
{
    // Arrange
    var repositoryMock = new Mock<IInvoiceRepository>();
    repositoryMock.Setup(x => x.GetInvoiceAsync(It.IsAny<Guid>()))
        .ReturnsAsync((Guid id) => fixture.Invoices.FirstOrDefault(x => x.Id == id));
    var emailServiceMock = new Mock<IEmailService>();
    var controller = new InvoiceController(repositoryMock.Object, emailServiceMock.Object);
    // Act
    var invoice = fixture.Invoices.First();
    var actionResult = await controller.GetInvoiceAsync(invoice.Id);
    // Assert
    var result = actionResult.Result as OkObjectResult;
    Assert.NotNull(result);
    var returnResult = Assert.IsAssignableFrom<Invoice>(result.Value);
    Assert.NotNull(returnResult);
    Assert.Equal(invoice.Id, returnResult.Id);
    Assert.Equal(invoice.InvoiceNumber, returnResult.InvoiceNumber);
}

在这个测试方法中,我们将Invoices列表中第一张发票的 ID 传递给GetInvoiceAsync(Guid id)方法。由于指定 ID 的发票在数据库中存在,该方法应返回该发票。

让我们为GetInvoiceAsync(Guid id)方法创建一个悲伤路径测试:

[Fact]public async Task GetInvoice_ShouldReturnNotFound()
{
    // Arrange
    var repositoryMock = new Mock<IInvoiceRepository>();
    repositoryMock.Setup(x => x.GetInvoiceAsync(It.IsAny<Guid>()))
        .ReturnsAsync((Guid id) => _fixture.Invoices.FirstOrDefault(x => x.Id == id));
    var emailServiceMock = new Mock<IEmailService>();
    var coentroller = new InvoiceController(repositoryMock.Object, emailServiceMock.Object);
    // Act
    var actionResult = await controller.GetInvoiceAsync(Guid.NewGuid());
    // Assert
    var result = actionResult.Result as NotFoundResult;
    Assert.NotNull(result);
}

在这个测试方法中,我们将一个新的 GUID 传递给GetInvoiceAsync(Guid id)方法。由于指定 ID 的发票在数据库中不存在,该方法应返回404 Not Found错误。我们还可以为其他方法创建悲伤路径测试。

提示

C#中asis的区别是什么?

as运算符用于在兼容类型之间执行转换。如果转换不可行,则as运算符返回null而不是抛出异常。因此,在前面的测试中,如果result不是null,我们可以看到控制器返回的结果是NotFoundResult,这是预期的结果。

is运算符用于确定一个对象是否与给定的类型兼容。如果对象兼容,则运算符将返回true;否则,它将返回false。在执行操作之前验证对象类型是一个有用的工具。

从 C# 7 开始,我们可以使用is同时检查和转换类型。例如,我们可以使用if (result is NotFoundResult notFoundResult)来检查result是否为NotFoundResult,并同时将其转换为NotFoundResult

通过这样,我们已经学会了如何为控制器编写单元测试。你可以检查源代码中的其他测试,并尝试添加更多测试以覆盖其他场景,例如创建发票、更新发票、删除发票等。

摘要

在本章中,我们探讨了 ASP.NET Web API 应用程序的单元测试基础。我们讨论了使用 xUnit 作为测试框架以及 Moq 作为模拟框架。我们学习了如何使用 xUnit 配置测试固定,以及如何使用测试固定管理测试数据。我们还学习了如何编写单元测试来测试数据访问层和控制器。

单元测试是确保你的代码单元按预期工作的一种很好的方式。这些测试通常使用模拟对象来隔离代码单元与其依赖项,但这并不能保证代码单元与其依赖项能良好地协同工作。因此,我们还需要编写集成测试来测试代码单元是否能够与其依赖项协同工作。例如,控制器能否正确处理请求?

在下一章中,我们将学习如何为 ASP.NET Web API 应用程序编写集成测试。

第十章:ASP.NET Core 中的测试(第二部分 - 集成测试)

第九章中,我们学习了如何为 ASP.NET Core Web API 应用程序编写单元测试。单元测试用于测试独立的代码单元。然而,一个代码单元通常依赖于其他组件,例如数据库、外部服务等。为了彻底测试代码,我们需要在应用程序的上下文中测试代码单元。换句话说,我们需要测试代码单元如何与其他应用程序部分交互。这种测试类型被称为集成测试。

在本章中,我们将主要关注集成测试。我们将涵盖以下主题:

  • 编写集成测试

  • 带有身份验证和授权的测试

  • 理解代码覆盖率

到本章结束时,您应该能够为 ASP.NET Core Web API 应用程序编写集成测试。

技术要求

本章中的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8找到。您可以使用 VS Code 或 VS 2022 打开解决方案。

编写集成测试

在单元测试中,我们直接创建控制器的实例。这种方法没有考虑到 ASP.NET Core 的某些特性,例如路由、模型绑定和验证等。为了彻底测试应用程序,我们需要编写集成测试。在本节中,我们将编写应用程序的集成测试。

与关注独立单元的单元测试不同,集成测试关注组件之间的交互。这些集成测试可能涉及不同的层,例如数据库、文件系统、网络、HTTP 请求/响应管道等。集成测试确保应用程序的组件按预期协同工作。因此,通常集成测试使用实际依赖项而不是模拟。此外,由于涉及更多组件,集成测试比单元测试慢。考虑到集成测试的成本,我们不需要编写太多的集成测试。相反,我们应该关注应用程序的关键部分。大多数时候,我们可以使用单元测试来覆盖其他部分。

您可以在 IntegrationTestsDemo 文件夹中找到本节的示例代码。代码基于我们在第九章中创建的 InvoiceApp 项目。您可以使用 VS 2022 或 VS Code 打开解决方案。我们将使用术语 系统测试对象 (SUT) 来指代我们正在测试的 ASP.NET Core Web API 应用程序。

设置集成测试项目

我们可以继续使用 xUnit 作为集成测试的测试框架。一种好的做法是从单元测试项目中创建一个单独的集成测试项目。这种方法允许我们分别运行单元测试和集成测试,并且也使得为这两种类型的测试使用不同的配置变得更容易。

如果您正在使用 VS 2022,您可以通过右键单击解决方案并选择 InvoiceApp.IntegrationTests 来创建一个新的 xUnit 项目,然后点击 InvoiceApp.WebApi 项目以允许集成测试项目访问 Web API 项目中的类(参见 第九章)。

如果您正在使用 .NET CLI,您可以在终端中运行以下命令来创建一个新的 xUnit 测试项目:

dotnet new xunit -n InvoiceApp.IntegrationTestsdotnet sln InvoiceApp.sln add InvoiceApp.IntegrationTests/InvoiceApp.IntegrationTests.csproj
dotnet add InvoiceApp.IntegrationTests/InvoiceApp.IntegrationTests.csproj reference InvoiceApp.WebApi/InvoiceApp.WebApi.csproj

ASP.NET Core 提供了一个内置的测试 Web 服务器,我们可以用它来托管 SUT 以处理 HTTP 请求。使用测试 Web 服务器的优点是我们可以为测试环境使用不同的配置,并且它还可以节省网络流量,因为 HTTP 请求是在同一个进程中处理的。因此,使用测试 Web 服务器进行的测试比使用真实 Web 服务器进行的测试要快。要使用测试 Web 服务器,我们需要将 Microsoft.AspNetCore.Mvc.Testing NuGet 包添加到集成测试项目中。您可以在 VS 2022 中通过右键单击项目并选择 Microsoft.AspNetCore.Mvc.Testing 来添加包,并安装该包。

您也可以使用以下命令来添加包:

dotnet add InvoiceApp.IntegrationTests/InvoiceApp.IntegrationTests.csproj package Microsoft.AspNetCore.Mvc.Testing

默认的 UnitTest1.cs 文件可以被移除。

如果您想在测试中使用 FluentAssertions,请随意安装,正如我们在 第九章使用 FluentAssertions 验证测试结果 部分中所展示的。

现在,我们可以开始编写集成测试。

使用 WebApplicationFactory 编写基本集成测试

让我们从简单的集成测试开始,检查 SUT 是否可以正确处理 HTTP 请求。示例应用程序有一个由 ASP.NET Core 项目模板提供的 WeatherForecastController.cs 控制器。它返回一组天气预报。我们可以编写一个集成测试来检查控制器是否返回预期的结果。

InvoiceApp.IntegrationTests 项目中创建一个名为 WeatherForecastApiTests 的新文件。然后向该文件添加以下代码:

public class WeatherForecastApiTests(WebApplicationFactory<Program> factory)    : IClassFixture<WebApplicationFactory<Program>>
{
}

在测试类中,我们使用 WebApplicationFactory<T> 类型创建一个测试 Web 主机,并将其用作类固定。这个类固定实例将在类中的所有测试之间共享。WebApplicationFactory<T> 类型由 Microsoft.AspNetCore.Mvc.Testing 包提供。它是一个泛型类型,允许我们为指定的应用程序入口点创建测试 Web 主机。在这种情况下,我们使用 Web API 项目中定义的 Program 类作为入口点。但你会看到一个错误,说 CS0122'Program' is inaccessible due to its protection level。这是因为 Program 类默认定义为 internal

为了解决这个问题,有两种方法:

  • 打开 InvoiceApp.WebApi.csproj 文件,并向文件中添加以下行:

    <ItemGroup>     <InternalsVisibleTo Include="MyTestProject" /></ItemGroup>
    

    MyTestProject 替换为你的测试项目名称,例如 InvoiceApp.IntegrationTests。这种方法允许测试项目访问 Web API 项目的内部成员。

  • 或者,你可以将 Program 类的访问修饰符更改为 public。将以下代码添加到 Program.cs 文件的末尾:

    public partial class Program { }
    

你可以使用这两种方法中的任何一种来解决问题。之后,我们可以编写测试方法,如下所示:

[Fact]public async Task GetWeatherForecast_ReturnsSuccessAndCorrectContentType()
{
    // Arrange
    var client = factory.CreateClient();
    // Act
    var response = await client.GetAsync("/WeatherForecast");
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType.ToString());
    // Deserialize the response
    var responseContent = await response.Content.ReadAsStringAsync();
    var weatherForecast = JsonSerializer.Deserialize<List<WeatherForecast>>(responseContent, new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    });
    weatherForecast.Should().NotBeNull();
    weatherForecast.Should().HaveCount(5);
}

在测试方法中,我们首先使用 WebApplicationFactory<T> 实例创建 HttpClient 类的实例。然后,我们向 /WeatherForecast 端点发送 HTTP GET 请求。EnsureSuccessStatusCode 方法确保响应的状态码在 200-299 范围内。然后我们检查响应的内容类型是否为 application/json; charset=utf-8。最后,我们将响应内容反序列化为 WeatherForecast 对象的列表,并检查列表是否包含五个项目。

由于这个控制器没有任何依赖项,测试很简单。如果控制器有依赖项,比如数据库上下文、其他服务或其他外部依赖项,怎么办?我们将在以下章节中看到如何处理这些场景。

使用数据库上下文进行测试

在示例应用程序中,ContactController 类有依赖项,例如 IContactRepository 接口。ContactRepository 类实现了这个接口,并使用 InvoiceContext 类来访问数据库。因此,如果我们想测试系统单元(SUT)是否能够正确处理 HTTP 请求,我们需要创建一个测试数据库,并配置测试 Web 主机使用测试数据库。类似于单元测试,我们也可以为集成测试使用一个单独的数据库。

WebApplicationFactory<T> 类型提供了一种配置测试 Web 主机的方式。我们可以重写 ConfigureWebHost 方法来配置测试 Web 主机。例如,我们可以用测试数据库上下文替换默认的数据库上下文。让我们创建一个新的测试固定类 CustomIntegrationTestsFixture 并向该类添加以下代码:

public class CustomIntegrationTestsFixture : WebApplicationFactory<Program>{
    private const string ConnectionString = @"Server=(localdb)\mssqllocaldb;Database=InvoiceIntegrationTestDb;Trusted_Connection=True";
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // Set up a test database
        builder.ConfigureServices(services =>
        {
            var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<InvoiceDbContext>));
            services.Remove(descriptor);
            services.AddDbContext<InvoiceDbContext>(options =>
            {
                options.UseSqlServer(ConnectionString);
            });
        });
    }
}

在前面的代码中,我们重写了 ConfigureWebHost() 方法来配置 SUT 的测试 Web 主机。当测试 Web 主机被创建时,Program 类将首先执行,这意味着 Program 类中定义的默认数据库上下文将被创建。然后,CustomIntegrationTestsFixture 类中定义的 ConfigureWebHost() 方法将被执行。因此,我们需要使用 services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<InvoiceDbContext>)) 来找到默认的数据库上下文,并将其从服务集合中移除。然后,我们添加一个新的数据库上下文,它使用测试数据库。这种方法允许我们为集成测试使用单独的数据库。我们还需要在初始化测试固定实例时创建测试数据库并播种一些测试数据。

你也可以在 ConfigureWebHost() 方法中添加更多对测试 Web 主机的自定义设置。例如,你可以配置测试 Web 主机使用不同的配置文件,如下所示:

builder.ConfigureAppConfiguration((context, config) =>{
    config.AddJsonFile("appsettings.IntegrationTest.json");
});

SUT 的测试 Web 主机的默认环境是 Development。如果你想使用不同的环境,可以使用 UseEnvironment() 方法,如下所示:

builder.UseEnvironment("IntegrationTest");

接下来,我们需要一种方法来创建测试数据库并播种一些测试数据。创建一个名为 Utilities 的静态类,并将以下代码添加到类中:

public static class Utilities{
    public static void InitializeDatabase(InvoiceDbContext context)
    {
        context.Database.EnsureDeleted();
        context.Database.EnsureCreated();
        SeedDatabase(context);
    }
    public static void Cleanup(InvoiceDbContext context)
    {
        context.Contacts.ExecuteDelete();
        context.Invoices.ExecuteDelete();
        context.SaveChanges();
        SeedDatabase(context);
    }
    private static void SeedDatabase(InvoiceDbContext context)
    {
        // Omitted for brevity
    }
}

Utilities 类包含一些静态方法,帮助我们管理测试数据库。在运行测试之前,我们需要初始化测试数据库,在运行更改数据库中数据的测试之后,我们需要清理测试数据库。

我们应该在什么时候初始化测试数据库?我们了解到,类固定实例是在测试类初始化之前创建的,并且在整个测试类中的所有测试方法之间共享。因此,我们可以在类固定实例中初始化测试数据库。更新 CustomIntegrationTestsFixture 类中的 ConfigureWebHost() 方法中的以下代码:

builder.ConfigureServices(services =>{
    var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<InvoiceDbContext>));
    services.Remove(descriptor);
    services.AddDbContext<InvoiceDbContext>(options =>
    {
        options.UseSqlServer(ConnectionString);
    });
    using var scope = services.BuildServiceProvider().CreateScope();
    var scopeServices = scope.ServiceProvider;
    var dbContext = scopeServices.GetRequiredService<InvoiceDbContext>();
    Utilities.InitializeDatabase(dbContext);
});

当 SUT 的测试 Web 主机被创建时,我们用测试数据库上下文替换默认的数据库上下文,并初始化测试数据库,这样测试类中的所有测试方法都可以使用相同的测试数据库。

接下来,我们可以创建一个新的测试类,命名为 InvoicesApiTests,用于测试 /api/invoices 端点。将以下代码添加到类中:

public class InvoicesApiTests(CustomIntegrationTestsFixture factory) : IClassFixture<CustomIntegrationTestsFixture>{
}

InvoicesApiTests 测试类将使用 CustomIntegrationTestsFixture 类的实例进行初始化。然后我们可以创建一些测试方法来测试 /api/invoices 端点。一个测试 GET /api/invoices 端点的方法可能如下所示:

[Fact]public async Task GetInvoices_ReturnsSuccessAndCorrectContentType()
{
    // Arrange
    var client = _factory.CreateClient();
    // Act
    var response = await client.GetAsync("/api/invoice");
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    response.Content.Headers.ContentType.Should().NotBeNull();
    response.Content.Headers.ContentType!.ToString().Should().Be("application/json; charset=utf-8");
    // Deserialize the response
    var responseContent = await response.Content.ReadAsStringAsync();
    var invoices = JsonSerializer.Deserialize<List<Invoice>>(responseContent, new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    });
    invoices.Should().NotBeNull();
    invoices.Should().HaveCount(2);
}

由于对 GET /api/invoices 端点的请求没有更改数据库,测试方法很简单。

接下来,让我们看看如何测试更改数据库的 POST /api/invoices 端点。一个测试此端点的方法可能如下所示:

[Fact]public async Task PostInvoice_ReturnsSuccessAndCorrectContentType()
{
    // Arrange
    var client = factory.CreateClient();
    var invoice = new Invoice
    {
        DueDate = DateTimeOffset.Now.AddDays(30),
        ContactId = Guid.Parse("8a9de219-2dde-4f2a-9ebd-b1f8df9fef03"),
        Status = InvoiceStatus.Draft,
        InvoiceItems = new List<InvoiceItem>
        {
            // Omitted for brevity        }
    };
    var json = JsonSerializer.Serialize(invoice);
    var data = new StringContent(json, Encoding.UTF8, "application/json");
    // Act
    var response = await client.PostAsync("/api/invoice", data);
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    response.Content.Headers.ContentType.Should().NotBeNull();
    response.Content.Headers.ContentType!.ToString().Should().Be("application/json; charset=utf-8");
    // Deserialize the response
    var responseContent = await response.Content.ReadAsStringAsync();
    var invoiceResponse = JsonSerializer.Deserialize<Invoice>(responseContent, new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    });
    invoiceResponse.Should().NotBeNull();
    invoiceResponse!.Id.Should().NotBeEmpty();
    invoiceResponse.Amount.Should().Be(500);
    invoiceResponse.Status.Should().Be(invoice.Status);
    invoiceResponse.ContactId.Should().Be(invoice.ContactId);
    // Clean up the database
    var scope = factory.Services.CreateScope();
    var scopedServices = scope.ServiceProvider;
    var db = scopedServices.GetRequiredService<InvoiceDbContext>();
    Utilities.Cleanup(db);
}

由于这个请求到POST /api/invoices端点会更改数据库,我们在运行测试后需要清理数据库。要获取数据库上下文的当前实例,我们需要创建一个新的作用域并从作用域中获取数据库上下文。然后我们可以使用Utilities类的Cleanup方法来清理数据库。

POST /api/invoices端点的悲伤路径的测试可能看起来如下所示:

[Fact]public async Task PostInvoice_WhenContactIdDoesNotExist_ReturnsBadRequest()
{
    // Arrange
    var client = factory.CreateClient();
    var invoice = new Invoice
    {
        DueDate = DateTimeOffset.Now.AddDays(30),
        ContactId = Guid.NewGuid(),
        Status = InvoiceStatus.Draft,
        InvoiceItems = new List<InvoiceItem>
        {
            // Omitted for brevity
        }
    };
    var json = JsonSerializer.Serialize(invoice);
    var data = new StringContent(json, Encoding.UTF8, "application/json");
    // Act
    var response = await client.PostAsync("/api/invoice", data);
    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

根据你的需要添加更多集成测试。请注意,我们需要仔细管理测试数据库。如果你有多个更改数据库的测试类,你可能需要遵循我们在第九章中介绍的[创建测试固定装置]部分中使用的相同模式(使用锁或集合固定装置),以确保在每次测试类运行之前测试数据库是干净的,并在每次测试类运行后清理测试数据库。

例如,如果我们还有一个使用CustomIntegrationTestsFixtureContactsApiTests类,xUnit 将并行运行InvoicesApiTestsContactsApiTests。这可能会导致问题,因为这两个测试类会同时尝试初始化测试数据库。为了避免这个问题,我们可以将这些测试类放在一个集合中运行,以确保它们按顺序执行。

我们可以通过创建一个名为CustomIntegrationTests的测试集合,并将CollectionDefinition属性添加到CustomIntegrationTestsCollection类中来演示这一点。此属性将使我们能够定义集合及其关联的测试,如下所示:

[CollectionDefinition("CustomIntegrationTests")]public class CustomIntegrationTestsCollection : ICollectionFixture<CustomIntegrationTestsFixture>
{
}

然后,我们可以将Collection属性添加到InvoicesApiTestsWithCollectionContactsApiTestsWithCollection类中。例如,InvoicesApiTestsWithCollection类可能看起来如下所示:

[Collection("CustomIntegrationTests")]public class InvoicesApiTestsWithCollection(CustomIntegrationTestsFixture factory) : IDisposable
{
    // Omitted for brevity
}

你可以在示例仓库中找到完整的源代码。请注意,正常的集成测试类InvoicesApiTests没有Collection属性,因此 xUnit 会将其与CustomIntegrationTests集合并行运行。为了避免冲突,我们可以跳过InvoicesApiTests类中的测试方法,如下所示:

[Fact(Skip = "This test is skipped to avoid conflicts with the test collection")]public async Task GetInvoices_ReturnsSuccessAndCorrectContentType()
{
    // Omitted for brevity
}

当你在示例仓库中运行测试时,请根据需要添加或注释掉Skip属性。

使用模拟服务进行测试

正如我们在第九章中解释的,单元测试应该专注于独立的代码单元。因此,单元测试通常使用模拟或存根服务来隔离被测试的代码与其他服务。另一方面,集成测试应该测试不同组件之间的集成。因此,从技术上讲,集成测试应该使用真实服务而不是模拟服务。

然而,在某些情况下,在集成测试中使用模拟依赖项可能会有所帮助。例如,在示例发票应用程序中,我们需要调用第三方服务来发送电子邮件。如果我们使用真实的电子邮件服务进行集成测试,它可能存在以下问题:

  • 实际的电子邮件服务可能在测试环境中不可用。例如,电子邮件服务可能托管在不同的环境中,而测试环境可能无法访问电子邮件服务。

  • 电子邮件服务可能有速率限制、严格的网络策略或其他可能引起集成测试问题或减慢测试执行速度的限制。

  • 电子邮件服务可能在测试环境中造成不必要的成本,尤其是如果该服务基于使用量定价或需要付费订阅。如果我们频繁运行集成测试,可能会产生高额费用。

  • 测试可能会影响生产环境,并为真实用户造成问题。

在这种情况下,在集成测试中使用模拟电子邮件服务可以帮助我们避免这些问题,从而使我们能够更快、更有效地运行测试,避免影响生产,并节省成本。

让我们看看如何在集成测试中使用模拟电子邮件服务。我们用来发送电子邮件的服务是IEmailSender接口。我们可以在集成测试中注入一个实现IEmailSender接口的模拟服务。在测试类中创建一个新的测试方法:

[Theory][InlineData("7e096984-5919-492c-8d4f-ce93f25eaed5")]
[InlineData("b1ca459c-6874-4f2b-bc9d-f3a45a9120e4")]
public async Task SendInvoiceAsync_ReturnsSuccessAndCorrectContentType(string invoiceId)
{
    // Arrange
    var mockEmailSender = new Mock<IEmailSender>();
    mockEmailSender.Setup(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
        .Returns(Task.CompletedTask).Verifiable();
    var client = factory.WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            var emailSender = services.SingleOrDefault(x => x.ServiceType == typeof(IEmailSender));
            services.Remove(emailSender);
            services.AddScoped<IEmailSender>(_ => mockEmailSender.Object);
        });
    }).CreateClient();
    // Act
    var response = await client.PostAsync($"/api/invoice/{invoiceId}/send", null);
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    mockEmailSender.Verify(x => x.SendEmailAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Once);
    var scope = factory.Services.CreateScope();
    var scopedServices = scope.ServiceProvider;
    var db = scopedServices.GetRequiredService<InvoiceDbContext>();
    var invoice = await db.Invoices.FindAsync(Guid.Parse(invoiceId));
    invoice!.Status.Should().Be(InvoiceStatus.AwaitPayment);
}

在前面的代码中,我们使用Moq库创建了一个模拟电子邮件发送服务。SUT 的测试 Web 宿主提供了一个WithWebHostBuilder()方法来配置 Web 宿主构建器。在这个方法中,我们可以使用ConfigureTestServices()方法配置 Web 宿主的服务集合。类似于我们在[使用数据库上下文进行测试]部分中引入的模拟数据库上下文,我们找到已注册的IEmailSender服务并将其从服务集合中移除,然后添加模拟服务到服务集合中。最后,我们创建 HTTP 客户端并向 API 端点发送请求。如果正确使用了模拟服务,测试应该通过。

总结来说,是否在集成测试中使用模拟服务应根据具体情况决定,并取决于测试的具体要求和目标。在某些场景下,模拟可能很有用,但不应过度使用,否则集成测试可能无法反映真实世界的场景。

如果你的 Web API 项目运行在微服务架构中,你可能需要在集成测试中调用其他微服务。在这种情况下,你可以使用相同的方法来模拟 HTTP 客户端和 HTTP 响应。集成测试可能会更复杂。我们在这里停止,并在下一章讨论微服务架构时再进一步探讨。

使用身份验证和授权进行测试

在 Web API 中,一个常见的场景是某些 API 端点需要身份验证和授权。我们在第八章中介绍了如何实现身份验证和授权。在本节中,我们将讨论如何测试需要身份验证和授权的 API 端点。

准备示例应用程序

为了演示带有身份验证和授权的测试,我们将使用我们在第八章中创建的示例应用程序。您可以在示例仓库的 chapter10\AuthTestsDemo\start\ 文件夹中找到源代码。这个示例应用程序使用基于声明的身份验证和授权。您可以在第八章中回顾实现细节。

WeatherForecastController 中,有几个需要身份验证和授权的方法。(请原谅命名方式——我们只是使用了 ASP.NET Core Web API 的默认模板。)

按照在“设置集成测试项目”部分所述创建一个新的集成测试项目。您需要确保集成测试项目已安装以下 NuGet 包:

  • Microsoft.AspNetCore.Mvc.Testing:这是 SUT 的测试 Web 主机

  • xUnit:这是一个测试框架

  • Moq:这是一个模拟库

  • FluentAssertions:这是一个断言库(可选)

如果您想在 VS 2022 中运行测试,您还需要安装 xunit.runner.visualstudio,这是 VS 2022 的测试运行器。

还需将示例应用程序项目添加为引用。为了简单起见,我们将只关注身份验证和授权的集成测试。因此,这个演示不涉及数据库上下文和数据库。

您还需要将 Program 类设置为公共。只需在 Program 文件末尾添加以下代码:

public partial class Program { }

接下来,我们可以开始编写需要身份验证和授权的 API 端点的集成测试。

创建测试夹具

正如我们在第九章中“创建测试夹具”部分所解释的,我们可以创建一个测试夹具来在测试之间共享通用代码。创建一个名为 IntegrationTestsFixture 的新类,并添加以下代码:

public class IntegrationTestsFixture : WebApplicationFactory<Program>{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // This is where you can set up your test server with the services you need
        base.ConfigureWebHost(builder);
    }
}

这是一个简单的测试夹具,继承自 WebApplicationFactory<Program> 类。由于我们在这个演示中不需要设置任何服务,因此在 ConfigureWebHost 方法中没有自定义代码。如果您需要设置服务,可以在该方法中完成。

创建测试类

接下来,我们可以创建测试类。创建一个名为 AuthTests 的新类,并添加以下代码:

public class AuthTests(IntegrationTestsFixture fixture) : IClassFixture<IntegrationTestsFixture>{
}

这与我们在第九章中“使用测试夹具”部分创建的测试类类似。它继承自 IClassFixture<IntegrationTestsFixture> 接口,并有一个接受 IntegrationTestsFixture 实例的构造函数。因此,测试类可以使用 IntegrationTestsFixture 实例来访问 SUT 的测试 Web 主机。到目前为止,测试类中还没有特殊的代码。

测试匿名 API 端点

接下来,让我们测试不需要身份验证和授权的 API 端点。在 WeatherForecastController 类中,复制 Get() 方法并将其粘贴到 Get() 方法下方。将新方法重命名为 GetAnonymous() 并添加 AllowAnonymous 属性。新方法应如下所示:

[AllowAnonymous][HttpGet("anonymous")]
public IEnumerable<WeatherForecast> GetAnonymous()
{
    // Omitted for brevity
}

现在,我们有一个新的 API 端点,它不需要身份验证和授权。在测试类中创建一个新的测试方法:

[Fact]public async Task GetAnonymousWeatherForecast_ShouldReturnOk()
{
    // Arrange
    var client = fixture.CreateClient();
    // Act
    var response = await client.GetAsync("/weatherforecast/anonymous");
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    response.Content.Headers.ContentType.Should().NotBeNull();
    response.Content.Headers.ContentType!.ToString().Should().Be("application/json; charset=utf-8");
    // Deserialize the response
    var responseContent = await response.Content.ReadAsStringAsync();
    var weatherForecasts = JsonSerializer.Deserialize<List<WeatherForecast>>(responseContent, new JsonSerializerOptions
    {
        PropertyNameCaseInsensitive = true
    });
    weatherForecasts.Should().NotBeNull();
    weatherForecasts.Should().HaveCount(5);
}

这个测试方法与我们之前在 第九章使用测试用例 部分中创建的测试方法没有太大区别。当我们使用 CreateClient() 方法时,没有特殊的代码来设置 HttpClient。因此,测试方法可以发送请求到 API 端点,而不需要任何身份验证和授权。因为这个端点允许匿名访问,所以测试应该通过。

测试授权的 API 端点

WeatherForecastController 类有一个 Authorize 属性。因此,没有 AllowAnonymous 属性的 API 端点需要身份验证和授权。让我们测试 Get() 方法的悲伤路径。在测试类中创建一个新的测试方法:

[Fact]public async Task GetWeatherForecast_ShouldReturnUnauthorized_WhenNotAuthorized()
{
    // Arrange
    var client = fixture.CreateClient();
    // Act
    var response = await client.GetAsync("/weatherforecast");
    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

这种测试方法与之前的 GetAnonymousWeatherForecast_ShouldReturnOk() 方法类似,但我们期望状态码为 401 Unauthorized,因为 API 端点需要身份验证和授权。这个测试也应该通过。

接下来,我们需要在测试中设置身份验证和授权。有几种方法可以做到这一点:

  • 在测试中,调用身份验证端点以获取访问令牌。然后将访问令牌添加到 HTTP 请求的 Authorization 头部。然而,这种方法不推荐,因为它需要额外的努力来维护凭证,如用户名、密码、客户端 ID、客户端密钥等。此外,测试可能无法在测试环境中访问身份验证端点。如果测试依赖于身份验证端点,它会增加测试的复杂性。

  • 创建一个辅助方法来生成访问令牌。然后将访问令牌添加到 HTTP 请求的 Authorization 头部。这种方法不需要在测试中调用身份验证端点。然而,这意味着我们需要知道如何生成访问令牌。如果身份验证逻辑由第三方提供者提供,我们可能无法在测试中实现相同的实现。因此,只有在我们对身份验证逻辑有完全控制权的情况下,它才可用。

  • 使用 WebApplicationFactory 来设置身份验证和授权,并创建一个自定义的 AuthenticationHandler 来模拟身份验证和授权过程。这种方法更实用,因为它不需要在测试中调用身份验证端点。此外,它不需要在测试项目中重复身份验证逻辑。

由于我们有包含认证逻辑的示例应用程序的源代码,我们可以演示如何使用第二种方法,然后我们将向您展示如何使用第三种方法。

在测试中生成访问令牌

我们用来生成访问令牌的代码来自AccountController类,这是认证端点。我们可以在AccountController类中找到一个名为GenerateToken的方法。当用户成功登录时,会调用此方法。在IntegrationTestsFixture类中创建一个新的方法:

public string? GenerateToken(string userName){
    using var scope = Services.CreateScope();
    var configuration = scope.ServiceProvider.GetRequiredService<IConfiguration>();
    var secret = configuration["JwtConfig:Secret"];
    var issuer = configuration["JwtConfig:ValidIssuer"];
    var audience = configuration["JwtConfig:ValidAudiences"];
    // Omitted for brevity
    var securityToken = tokenHandler.CreateToken(tokenDescriptor);
    var token = tokenHandler.WriteToken(securityToken);
    return token;
}

在前面的方法中,我们使用IConfiguration服务从配置中获取密钥、发行者和受众。然后,我们将AccountController类中的GenerateToken()方法的代码复制过来以生成访问令牌。请注意,配置来自主 Web API 项目中的appsettings.json文件。由于我们没有更改测试 Web 宿主的配置,因此配置与主 Web API 项目中的配置相同。但如果你需要为测试使用不同的配置,请在IntegrationTestsFixture类中的ConfigureWebHost方法中添加适当的代码以应用任何更改,正如我们在创建测试 fixture部分中介绍的那样。

接下来,我们可以使用AuthTest类中的GenerateToken方法。在测试类中创建一个新的测试方法:

[Fact]public async Task GetWeatherForecast_ShouldReturnOk_WhenAuthorized()
{
    // Arrange
    var token = fixture.GenerateToken("TestUser");
    var client = fixture.CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
    // Act
    var response = await client.GetAsync("/weatherforecast");
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    // Omited for brevity
}

在这个测试方法中,我们调用GenerateToken()方法生成访问令牌,然后将访问令牌添加到 HTTP 请求的Authorization头中。因为生成令牌的逻辑与认证端点相同,所以测试应该通过。

使用自定义认证处理器

测试授权的 API 端点另一种方法是使用自定义认证处理器。自定义认证处理器可以模拟认证和授权过程。因此,我们可以用它来测试授权的 API 端点而不调用认证端点。这是测试授权 API 端点的推荐方法,因为它不需要任何其他依赖项,也不需要在测试项目中重复认证逻辑。

在实际的认证过程中,我们需要生成一个包含已认证用户声明的 JWT 令牌,并将其添加到 HTTP 请求的Authorization头中。如果我们使用自定义认证处理器,我们可以跳过生成 JWT 令牌的过程,但我们需要找到一种方法来定义所需的声明并将它们传递给自定义认证处理器。我们可以在请求头中简单地添加声明,然后在自定义认证处理器中读取这些值以创建ClaimsPrincipal对象。让我们演示如何做到这一点。

要使用自定义认证处理器,首先创建一个名为TestAuthHandler的新类,它继承自AuthenticationHandler类:

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>{
    public const string AuthenticationScheme = "TestScheme";
    public const string UserNameHeader = "UserName";
    public const string CountryHeader = "Country";
    public const string AccessNumberHeader = "AccessNumber";
    public const string DrivingLicenseNumberHeader = "DrivingLicenseNumber";
    public TestAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger,
        UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
    {
    }
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        return Task.FromResult(result);
    }
}

在前面的代码中,我们定义了身份验证方案名称为TestScheme,这是实际方案名称Bearer的一个替代名称。您可以在Program类中找到其定义。我们还定义了一些 HTTP 头名称,我们将使用它们将声明传递给自定义身份验证处理程序。HandleAuthenticateAsync()是我们需要重写以实现身份验证逻辑的方法。我们将在以下代码中实现它。

理念是在测试中创建请求时,我们只需简单地将声明添加到请求头中。这样,自定义身份验证处理程序就可以从请求头中读取值,并将HandleAuthenticateAsync()方法更新如下:

protected override Task<AuthenticateResult> HandleAuthenticateAsync(){
    var claims = new List<Claim>();
    if (Context.Request.Headers.TryGetValue(UserNameHeader, out var userName))
    {
        claims.Add(new Claim(ClaimTypes.Name, userName[0]));
    }
    if (Context.Request.Headers.TryGetValue(CountryHeader, out var country))
    {
        claims.Add(new Claim(ClaimTypes.Country, country[0]));
    }
    if (Context.Request.Headers.TryGetValue(AccessNumberHeader, out var accessNumber))
    {
        claims.Add(new Claim(AppClaimTypes.AccessNumber, accessNumber[0]));
    }
    if (Context.Request.Headers.TryGetValue(DrivingLicenseNumberHeader, out var drivingLicenseNumber))
    {
        claims.Add(new Claim(AppClaimTypes.DrivingLicenseNumber, drivingLicenseNumber[0]));
    }
    // You can add more claims here if you want
    var identity = new ClaimsIdentity(claims, AuthenticationScheme);
    var principal = new ClaimsPrincipal(identity);
    var ticket = new AuthenticationTicket(principal, AuthenticationScheme);
    var result = AuthenticateResult.Success(ticket);
    return Task.FromResult(result);
}

我们不是从 JWT 令牌中获取声明,而是从请求头中获取声明。如果值存在,我们将它们添加到ClaimsIdentity对象中。然后我们创建ClaimsPrincipal对象和AuthenticationTicket对象。最后,我们返回带有Success状态的AuthenticateResult对象。此方法模拟了身份验证过程,避免了生成 JWT 令牌的需要,但它仍然创建了我们需要测试授权 API 端点的ClaimsPrincipal对象。

接下来,我们可以通过使用自定义身份验证处理程序来测试授权的 API 端点。在WeatherForecastController类中,我们可以找到一个GetDrivingLicense方法,这是一个需要DrivingLicenseNumber声明的授权 API 端点。我们可以在AuthTest类中创建一个新的测试方法,如下所示:

[Fact]public async Task GetDrivingLicense_ShouldReturnOk_WhenAuthorizedWithTestAuthHandler()
{
    // Arrange
    var client = fixture.WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = TestAuthHandler.AuthenticationScheme;
                    options.DefaultChallengeScheme = TestAuthHandler.AuthenticationScheme;
                    options.DefaultScheme = TestAuthHandler.AuthenticationScheme;
                })
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.AuthenticationScheme,
                    options => { });
        });
    }).CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.AuthenticationScheme);
    client.DefaultRequestHeaders.Add(TestAuthHandler.UserNameHeader, "Test User");
    client.DefaultRequestHeaders.Add(TestAuthHandler.CountryHeader, "New Zealand");
    client.DefaultRequestHeaders.Add(TestAuthHandler.AccessNumberHeader, "123456");
    client.DefaultRequestHeaders.Add(TestAuthHandler.DrivingLicenseNumberHeader, "12345678");
    // Act
    var response = await client.GetAsync("/weatherforecast/driving-license");
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    response.Content.Headers.ContentType.Should().NotBeNull();
    response.Content.Headers.ContentType!.ToString().Should().Be("application/json; charset=utf-8");
}

在这个测试方法中,我们使用WithWebHostBuilder方法指定 SUT 的测试 Web 宿主,然后调用AddAuthentication方法指定身份验证方案。然后我们调用AddScheme方法将TestAuthHandler身份验证处理程序应用到身份验证服务上。有了这个定制的测试 Web 宿主,我们可以创建一个新的 HTTP 客户端。在我们使用这个 HTTP 客户端发送请求之前,我们需要添加指定身份验证方案的Authorization头。我们还为了简便起见将声明添加到请求头中,这样自定义身份验证处理程序就可以从请求头中读取值并创建ClaimsPrincipal对象。

然后,我们可以调用GetAsync方法向 API 端点发送 HTTP 请求。最后,我们可以验证响应状态码和响应内容类型,以确保请求成功。

前面的测试方法是针对正常路径的。为了测试未授权场景,我们可以创建一个新的测试方法,该方法不向请求添加DrivingLicenseNumberHeader头,并验证响应状态码为401 Unauthorized

[Fact]public async Task GetDrivingLicense_ShouldReturnForbidden_WhenRequiredClaimsNotProvidedWithTestAuthHandler()
{
    // Arrange
    var client = fixture.WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = TestAuthHandler.AuthenticationScheme;
                    options.DefaultChallengeScheme = TestAuthHandler.AuthenticationScheme;
                    options.DefaultScheme = TestAuthHandler.AuthenticationScheme;
                })
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.AuthenticationScheme,
                    options => { });
        });
    }).CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.AuthenticationScheme);
    client.DefaultRequestHeaders.Add(TestAuthHandler.UserNameHeader, "Test User");
    client.DefaultRequestHeaders.Add(TestAuthHandler.CountryHeader, "New Zealand");
    client.DefaultRequestHeaders.Add(TestAuthHandler.AccessNumberHeader, "123456");
    // Act
    var response = await client.GetAsync("/weatherforecast/driving-license");
    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

在前面的测试方法中,我们没有向请求添加DrivingLicenseNumberHeader头。因此,自定义身份验证处理程序找不到DrivingLicenseNumber声明,并将返回Forbidden状态码。

现在,我们发现前述测试方法中存在一些重复的代码。如果我们需要为每个测试方法设置测试 Web 主机并创建 HTTP 客户端,我们可以将这些代码移动到IntegrationTestsFixture类中。在IntegrationTestsFixture类中创建一个名为CreateClientWithAuth的方法:

public HttpClient CreateClientWithAuth(string userName, string country, string accessNumber, string drivingLicenseNumber){
    var client = WithWebHostBuilder(builder =>
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddAuthentication(options =>
                {
                    options.DefaultAuthenticateScheme = TestAuthHandler.AuthenticationScheme;
                    options.DefaultChallengeScheme = TestAuthHandler.AuthenticationScheme;
                    options.DefaultScheme = TestAuthHandler.AuthenticationScheme;
                })
                .AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(TestAuthHandler.AuthenticationScheme,
                    options => { });
        });
    }).CreateClient();
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(TestAuthHandler.AuthenticationScheme);
    client.DefaultRequestHeaders.Add(TestAuthHandler.UserNameHeader, userName);
    client.DefaultRequestHeaders.Add(TestAuthHandler.CountryHeader, country);
    client.DefaultRequestHeaders.Add(TestAuthHandler.AccessNumberHeader, accessNumber);
    client.DefaultRequestHeaders.Add(TestAuthHandler.DrivingLicenseNumberHeader, drivingLicenseNumber);
    return client;
}

CreateClientWithAuth()方法接受声明作为参数,然后使用定制的测试 Web 主机创建HttpClient。这样,我们可以轻松控制每个测试方法的声明。然后我们可以更新测试方法以使用此方法。例如,GetCountry端点的测试方法可以更新如下:

[Fact]public async Task GetCountry_ShouldReturnOk_WhenAuthorizedWithTestAuthHandler()
{
    // Arrange
    var client = fixture.CreateClientWithAuth("Test User", "New Zealand", "123456", "12345678");
    // Act
    var response = await client.GetAsync("/weatherforecast/country");
    // Assert
    response.EnsureSuccessStatusCode(); // Status Code 200-299
    response.Content.Headers.ContentType.Should().NotBeNull();
    response.Content.Headers.ContentType!.ToString().Should().Be("application/json; charset=utf-8");
}
[Fact]
public async Task GetCountry_ShouldReturnForbidden_WhenRequiredClaimsNotProvidedWithTestAuthHandler()
{
    // Arrange
    // As we don't provide the country claim, the request will be forbidden
    var client = fixture.CreateClientWithAuth("Test User", "", "123456", "12345678");
    // Act
    var response = await client.GetAsync("/weatherforecast/country");
    // Assert
    response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

现在我们可以验证测试方法是否仍然按预期工作。

图 10.1 – 在 VS 2022 中测试方法按预期通过

图 10.1 – 在 VS 2022 中测试方法按预期通过

注意,如果你需要,你也可以自定义AuthenticationSchemeOptions类。例如,你可以定义一个继承自AuthenticationSchemeOptions类的TestAuthHandlerOptions类,如下所示:

public class TestAuthHandlerOptions : AuthenticationSchemeOptions{
    public string UserName { get; set; } = string.Empty;
}

然后,你可以在ConfigureTestServices方法中配置TestAuthHandlerOptions

var client = fixture.WithWebHostBuilder(builder =>{
    builder.ConfigureTestServices(services =>
    {
        services.Configure<TestAuthHandlerOptions>(options =>
        {
            options.UserName = "Test User";
        });
        services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = TestAuthHandler.AuthenticationScheme;
                options.DefaultChallengeScheme = TestAuthHandler.AuthenticationScheme;
                options.DefaultScheme = TestAuthHandler.AuthenticationScheme;
            })
            .AddScheme<TestAuthHandlerOptions, TestAuthHandler>(TestAuthHandler.AuthenticationScheme,
                options => { });
    });
}).CreateClient();

TestAuthHandler类现在应该更新如下:

public class TestAuthHandler : AuthenticationHandler<TestAuthHandlerOptions>{
    public readonly string _userName;
    public TestAuthHandler(IOptionsMonitor<TestAuthHandlerOptions> options, ILoggerFactory logger,
    UrlEncoder encoder, ISystemClock clock) : base(options, logger, encoder, clock)
    {
        // Get the user name from the options
        _userName = options.CurrentValue.UserName;
    }
}

TestAuthHandler类现在可以从TestAuthHandlerOptions类中获取用户名。你还可以在TestAuthHandlerOptions类中定义其他属性,然后在使用TestAuthHandler类时使用它们。

如果你的项目不使用基于声明的授权,你也可以定义一个自定义授权处理程序来实现授权逻辑。请查阅官方文档以获取更多信息:learn.microsoft.com/en-us/aspnet/core/security/authentication/

代码覆盖率

现在我们已经涵盖了 ASP.NET Core 中的单元测试和集成测试。在本节中,我们将讨论代码覆盖率,这是一个衡量在测试过程中测试套件覆盖应用程序源代码程度的指标。

代码覆盖率是衡量软件质量的重要指标。如果代码覆盖率低,这意味着代码中有许多部分没有被测试覆盖。在这种情况下,我们无法确信代码按预期工作。此外,当我们对代码进行更改或重构时,我们不确定这些更改是否会破坏现有代码。

代码覆盖率可以提供关于哪些代码部分被测试(或未被测试)的见解。它可以帮助我们识别可能需要额外测试的区域,并确保代码得到彻底测试。

代码覆盖率在评估测试过程的有效性和可靠性方面起着至关重要的作用。通过分析代码覆盖率,我们可以对代码的质量有信心,并识别潜在的薄弱环节或未测试的代码。足够的代码覆盖率对于提高代码质量和降低错误和缺陷的风险至关重要。

需要注意的是,代码覆盖率不是代码质量的唯一指标。虽然高代码覆盖率是可取的,但达到 100%的代码覆盖率并不能保证代码没有错误。代码覆盖率应与其他因素相结合,如有效的测试设计、代码审查、静态分析、手动测试等。此外,代码设计、架构和开发实践等因素也会影响代码质量。我们需要在代码覆盖率和其他因素之间找到平衡。

分析代码覆盖率,我们有两个步骤:

  • 收集测试数据:数据收集器可以在测试运行期间监控测试执行并收集代码覆盖率数据。它可以以不同的格式报告代码覆盖率数据,如 XML 或 JSON。

  • 生成报告:报告生成器可以读取收集的数据并生成代码覆盖率报告,通常以 HTML 格式。

让我们看看如何使用数据收集器和报告生成器。我们将使用InvoiceApp项目作为示例。您可以在chapter10\IntegrationTestsDemo\IntegrationTest-v1文件夹中找到示例项目。

使用数据收集器

要使用数据收集器,我们可以使用Coverlet。Coverlet 是一个跨平台的.NET 代码覆盖率框架,支持行、分支和方法覆盖率。它可以作为.NET Core 全局工具或 NuGet 包使用。更多信息,请查看 GitHub 上的 Coverlet 仓库:github.com/coverlet-coverage/coverlet

xUnit 项目模板已经包含了 Coverlet 包。如果您的测试项目没有包含 Coverlet 包,您可以通过在包管理控制台运行以下命令来安装它:

dotnet add package coverlet.collector

要获取覆盖率数据,导航到测试项目文件夹,并运行以下命令:

dotnet test --collect:"XPlat Code Coverage"

--collect:"XPlat Code Coverage"选项告诉dotnet test命令收集代码覆盖率数据。"XPlat Code Coverage"参数是收集器的友好名称。您可以使用任何喜欢的名称,但请注意,它不区分大小写。代码覆盖率数据将保存在TestResults文件夹中。您可以在coverage.cobertura.xml文件中找到代码覆盖率数据。文件夹结构是/TestResults/{GUID}/coverage.cobertura.xml

这里是coverage.cobertura.xml文件的示例:

<?xml version="1.0" encoding="utf-8"?><coverage line-rate="0.1125" branch-rate="0.1875" version="1.9" timestamp="1685100267" lines-covered="108" lines-valid="960" branches-covered="6" branches-valid="32">
  <sources>
    <source>C:\dev\web-api-with-asp-net\example_code\chapter9\IntegrationTestsDemo\IntegrationTest-v1\InvoiceApp\InvoiceApp.WebApi\</source>
  </sources>
  <packages>
    <package name="InvoiceApp.WebApi" line-rate="0.1125" branch-rate="0.1875" complexity="109">
      <classes>
      ...
        <class name="InvoiceApp.WebApi.Services.EmailService" filename="Services\EmailService.cs" line-rate="1" branch-rate="1" complexity="2">
          <methods>
            <method name="GenerateInvoiceEmail" signature="(InvoiceApp.WebApi.Models.Invoice)" line-rate="1" branch-rate="1" complexity="1">
              <lines>
                <line number="19" hits="1" branch="False" />
                <line number="20" hits="1" branch="False" />
                <line number="21" hits="1" branch="False" />
                <!-- ... -->
                <line number="37" hits="1" branch="False" />
                <line number="38" hits="1" branch="False" />
              </lines>
            </method>
            <method name=".ctor" signature="(Microsoft.Extensions.Logging.ILogger`1&lt;InvoiceApp.WebApi.Interfaces.IEmailService&gt;,InvoiceApp.WebApi.Interfaces.IEmailSender)" line-rate="1" branch-rate="1" complexity="1">
              <lines>
                <line number="12" hits="3" branch="False" />
                <line number="13" hits="3" branch="False" />
                <line number="14" hits="3" branch="False" />
                <line number="15" hits="3" branch="False" />
                <line number="16" hits="3" branch="False" />
              </lines>
            </method>
          </methods>
          <lines>
            <line number="19" hits="1" branch="False" />
            <line number="20" hits="1" branch="False" />
            <line number="21" hits="1" branch="False" />
            <!-- ... -->
            <line number="37" hits="1" branch="False" />
            <line number="38" hits="1" branch="False" />
            <line number="12" hits="3" branch="False" />
            <line number="13" hits="3" branch="False" />
            <line number="14" hits="3" branch="False" />
            <line number="15" hits="3" branch="False" />
            <line number="16" hits="3" branch="False" />
          </lines>
        </class>
        ...
      </classes>
    </package>
  </packages>
</coverage>

在此代码中,我们可以看到以下信息:

  • line-rate:这是被测试覆盖的行数的百分比

  • branch-rate:这是被测试覆盖的分支的百分比

  • lines-covered:这是被测试覆盖的行数

  • lines-valid:这是源代码中的行数

  • branches-covered:这是被测试覆盖的分支数

  • branches-valid:这是源代码中的分支数

你还可以将 Coverlet 用作.NET 全局工具。为此,你可以运行以下命令来安装 Coverlet 作为.NET 全局工具:

dotnet tool install --global coverlet.console

然后你可以这样使用它:

coverlet /path/to/InvoiceApp.UnitTests.dll --target "dotnet" --targetargs "test /path/to/test-project --no-build"

请更新前面命令中的路径以匹配你的项目结构。--no-build选项用于跳过构建测试项目,这在已经构建了测试项目时很有用。

现在我们有了代码覆盖率数据。然而,coverage.cobertura.xml文件不是人类可读的。因此,我们必须生成一个人类可读的报告,我们将在下一节介绍。

生成代码覆盖率报告

为了更好地理解覆盖率数据,我们可以生成一个代码覆盖率报告。为此,我们可以使用ReportGenerator NuGet 包。ReportGenerator是一个可以将 Coverlet 生成的覆盖率数据转换为人类可读报告的工具。它还支持其他覆盖率格式,如OpenCoverdotCoverNCover等。

要安装 ReportGenerator,我们可以运行以下命令:

dotnet tool install -g dotnet-reportgenerator-globaltool

然后我们可以运行以下命令来生成代码覆盖率报告:

reportgenerator "-reports:/path/to/coverage.cobertura.xml" "-targetdir:coveragereport" "-reporttypes:Html;HtmlSummary"

请更新前面命令中的路径以匹配你的项目结构。

如果命令运行成功,你将在coveragereport文件夹中看到生成的 HTML 报告。你可以在浏览器中打开index.html文件来查看报告。报告看起来像这样:

图 10.2 – 代码覆盖率报告

图 10.2 – 代码覆盖率报告概述

你可以检查每个类以查看代码覆盖率详情,如图10.3所示:

图 10.3 – 代码覆盖率详情

图 10.3 – 代码覆盖率详情概述

图 10.4中,我们可以看到一些行没有被测试覆盖:

图 10.4 – 用红色突出显示的未通过测试的行

图 10.4 – 用红色突出显示的未通过测试的行概述

很遗憾,我们样本项目的代码覆盖率很糟糕。但幸运的是,它只是一个样本项目。在实际项目中,我们应该尽力提高代码覆盖率!

在本节中,我们学习了如何使用 Coverlet 和 ReportGenerator 生成代码覆盖率数据和报告。代码覆盖率是有效软件测试的一个重要方面。通过利用这些报告,开发人员和质量保证团队能够深入了解他们的测试质量和代码质量,这最终可以增强应用程序的可靠性和稳定性,并帮助我们自信地重构代码。

摘要

在本章中,我们讨论了如何为 ASP.NET Core 网络 API 应用程序编写集成测试。我们学习了如何创建测试固定装置来设置测试网络主机,以及如何在测试类中使用测试固定装置。我们还学习了如何测试授权端点并生成代码覆盖率数据和报告。

作为一名优秀的开发者,为你的代码编写测试非常重要。编写测试不仅是一种有益的实践,而且是一种有益的习惯。你可能发现你在编写测试上花费的时间比编写功能还要多,但这份努力是值得的。为了确保你的 ASP.NET 网络 API 应用程序能够正确运行,请确保编写单元测试和集成测试。这样做将有助于确保你的代码是可靠和安全的。

在下一章中,我们将探讨网络 API 的另一个方面:gRPC,这是一个高性能、开源的通用 RPC 框架。

第十一章:使用 gRPC 入门

除了 RESTful API 之外,还有其他类型的 API。其中之一是基于远程过程调用RPC)的 API,我们在第一章中介绍了它。gRPC 是由 Google 开发的一个高性能 RPC 框架。现在,它是一个在 Cloud Native Computing FoundationCNCF)下的开源项目,并且越来越受欢迎。

ASP.NET Core 提供了一套 gRPC 工具来帮助我们构建 gRPC 服务。在本章中,我们将介绍 gRPC 和 Protocol BuffersProtobuf)消息的基础知识。首先,我们将学习如何定义 protobuf 消息和 gRPC 服务。然后,我们将学习如何在 ASP.NET Core 中实现 gRPC 服务,使我们能够无缝地在不同的应用程序之间进行通信。本章将涵盖以下主题:

  • gRPC 概述

  • 设置 gRPC 项目

  • 定义 gRPC 服务和消息

  • 实现 gRPC 服务和客户端

  • 在 ASP.NET Core 应用程序中消费 gRPC 服务

到本章结束时,你应该能够理解 protobuf 和 gRPC 的基础知识,并知道如何在 ASP.NET Core 中构建 gRPC 服务。

技术要求

本章中的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter11找到。你可以使用 VS 2022 或 VS Code 打开解决方案。

gRPC 概述

如果你已经阅读了第一章,你应该熟悉 RPC 的概念——它是一种允许程序在远程机器上调用过程的协议。与围绕资源中心的 RESTful API 不同,基于 RPC 的 API 侧重于操作。因此,RPC 方法支持除 CRUD 操作之外的各种类型的操作。

gRPC 是最受欢迎的 RPC 框架之一。它比传统的 RPC 框架提供了许多优势。正如我们在第一章中提到的,gRPC 基于 HTTP/2,比 HTTP/1.1 更高效。gRPC 使用 protobuf 作为默认的数据序列化格式,它是一种比 JSON 更紧凑、更高效的二进制格式。gRPC 的工具支持也非常好。它遵循“契约优先”的方法,这意味着我们可以创建语言中性的服务定义并为不同的语言生成代码。它还支持流,这对于实时通信是一个非常有用的功能。

随着微服务的日益流行,gRPC 越来越受欢迎。虽然 gRPC 在某些方面优于 RESTful API,但它并不被视为完全的替代品。gRPC 是微服务之间高性能、低延迟通信的绝佳选择,但 RESTful API 更适合基于 Web 的应用程序和需要简单性、灵活性和广泛采用的场景。根据你特定用例的需求选择正确的协议非常重要。在下一节中,我们将学习如何在 ASP.NET Core 中设置 gRPC 项目。

设置 gRPC 项目

在本节中,我们将使用 dotnet CLI 构建一个 gRPC 项目。我们还将创建一个客户端项目以消费 gRPC 服务。我们将在本章中使用相同的项目。

创建新的 gRPC 项目

要创建一个新的 gRPC 项目,我们可以使用 dotnet new 命令。dotnet CLI 为 gRPC 项目提供了一个模板,其中包括一个基本的 gRPC 服务。我们可以使用以下命令创建一个新的 gRPC 项目:

dotnet new grpc -o GrpcDemo

-o 选项指定输出目录。运行命令后,我们将看到创建了一个名为 GrpcDemo 的项目。

如果你更喜欢使用 VS 2022,你也可以使用内置的 gRPC 模板在 VS 2022 中创建一个新的 gRPC 项目。创建新项目时,可以选择 ASP.NET Core gRPC Service 模板,如图 11.1* 所示:

图 11.1 – 在 VS 2022 中创建新的 gRPC 项目

图 11.1 – 在 VS 2022 中创建新的 gRPC 项目

创建项目后,你可以使用 VS Code 或 VS 2022 打开项目。接下来,我们将探索项目结构。

理解 gRPC 项目结构

gRPC 项目的项目结构与 RESTful API 项目有所不同。gRPC 项目中没有 Controllers 文件夹。相反,有一个 Protos 文件夹,其中包含 proto 文件。你可以在 Protos 文件夹中找到一个 greet.proto 文件,如下所示:

syntax = "proto3";option csharp_namespace = "GrpcDemo";
package greet;
// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply);
}
// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}
// The response message containing the greetings.
message HelloReply {
  string message = 1;
}

gRPC 使用 protobuf 作为默认的数据序列化格式。greet.proto 文件是定义 gRPC 服务和消息的 proto 文件。如果你熟悉 RESTful API,你可以将此文件视为 Swagger 文件(OpenAPI 规范)。它是 gRPC 服务的契约。在先前的 proto 文件中,我们定义了一个名为 Greeter 的服务,其中包含一个名为 SayHello() 的方法。SayHello() 方法接收一个 HelloRequest 消息作为输入,并返回一个 HelloReply 消息作为输出。HelloRequestHelloReply 消息分别具有名为 namemessage 的字符串属性。

在 proto 文件中,你可以使用 // 来添加注释。要添加多行注释,可以使用 /* ... */

重要提示

VS Code 默认不提供 proto 文件的语法高亮。你可以安装一些扩展,例如 vscode-proto3,以启用语法高亮。

让我们检查项目文件。打开 GrpcDemo.csproj 文件;我们将看到以下内容:

<ItemGroup>  <Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
<ItemGroup>
  <PackageReference Include="Grpc.AspNetCore" Version="2.51.0" />
  <PackageReference Include="Google.Protobuf" Version="3.22.0-rc2" />
</ItemGroup>

您将看到它包含两个包引用:

  • Grpc.AspNetCore: 此包为 ASP.NET Core 提供了 gRPC 服务器库。它还引用了 Grpc.Tools 包,该包提供了代码生成工具。

  • Google.Protobuf: 此包提供了 Protobuf 运行时库。

有一个包含 proto 文件的 Protobuf 项目组。GrpcServices 属性指定了由 proto 文件生成的代码类型。它可以设置为以下值:

  • None: 不生成代码

  • Client: 此选项仅生成客户端代码

  • Server: 此选项仅生成服务器端代码

  • Both: 此选项生成客户端代码和服务器端代码。这是默认值

在模板项目中,GrpcServices 属性设置为 Server,这意味着仅生成服务器端代码。

如果您有多个 proto 文件,您可以在 ItemGroup 元素中添加多个 Protobuf 项目。

接下来,让我们检查 Services 文件夹。您可以在 Services 文件夹中找到 GreeterService.cs 文件,它包含了 Greeter 服务的实现:

public class GreeterService(ILogger<GreeterService> logger) : Greeter.GreeterBase{
    public override Task<HelloReply> SayHello(HelloRequest request, ServerCallContext context)
    {
        return Task.FromResult(new HelloReply
        {
            Message = "Hello " + request.Name
        });
    }
}

GreeterService 类继承自由 proto 文件生成的 GreeterBase 类。它有一个 SayHello() 方法,该方法接收一个 HelloRequest 对象作为输入,并返回一个 HelloReply 对象作为输出。SayHello() 方法的实现非常简单——它匹配 proto 文件中 SayHello() 方法的定义。

如果您将鼠标悬停在 VS Code 中的 HelloRequest 类上,您将看到一个弹出消息,显示 HelloRequest 类的命名空间为 GrpcDemo.HelloRequest,如图 图 11.2 所示。2*:

图 11.2 – HelloRequest 类的命名空间

图 11.2 – HelloRequest 类的命名空间

HelloReply 类也类似。然而,您在项目中找不到 HelloRequest 类和 HelloReply 类。这些类在哪里定义的?

您可以按 F12 键跳转到 VS Code 中 HelloRequest 类的定义。您将被导航到位于 obj\Debug\net8.0\Protos 文件夹中的 Greet.cs 文件。此文件由 proto 文件生成,并包含 HelloRequest 类的定义:

  #region Messages  /// <summary>
  /// The request message containing the user's name.
  /// </summary>
  public sealed partial class HelloRequest : pb::IMessage<HelloRequest>
  {
    private static readonly pb::MessageParser<HelloRequest> _parser = new pb::MessageParser<HelloRequest>(() => new HelloRequest());
    // Omitted for brevity
    public HelloRequest() {
      OnConstruction();
    }
    // Omitted for brevity
    /// <summary>Field number for the "name" field.</summary>
    public const int NameFieldNumber = 1;
    private string name_ = "";
    [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
    [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
    public string Name {
      get { return name_; }
      set {
        name_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
      }
    }
    // Omitted for brevity
  }

HelloRequest 类的定义中,我们可以看到它实现了 IMessage<HelloRequest> 接口,该接口定义在 Google.Protobuf 包中。所有 protobuf 消息都必须实现这个基接口。HelloRequest 类还有一个 Name 属性,它在 proto 文件中定义。你可以在 Name 属性上找到一个 DebuggerNonUserCodeAttribute 属性。这个属性意味着 Name 成员不是应用程序用户代码的一部分。Name 属性还有一个 GeneratedCode 属性,这意味着这个成员是由工具生成的。具体来说,Name 属性是由 protoc 工具生成的,它是 protobuf 编译器。用户不应修改此成员。

你还可以在 Greet.cs 文件中找到 HelloReply 类的定义。在 Greet.cs 文件旁边,在 Protos 文件夹中,你可以找到一个 GreetGrpc.cs 文件,它定义了 GreeterBase 抽象类作为 GreeterService 类的基类。同样,GreeterBase 类也是由 gRPC 工具生成的。它包含了 SayHello() 方法的定义,如下所示:

/// <summary>Base class for server-side implementations of Greeter</summary>[grpc::BindServiceMethod(typeof(Greeter), "BindService")]
public abstract partial class GreeterBase
{
  /// <summary>
  /// Sends a greeting
  /// </summary>
  /// <param name="request">The request received from the client.</param>
  /// <param name="context">The context of the server-side call handler being invoked.</param>
  /// <returns>The response to send back to the client (wrapped by a task).</returns>
  [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
  public virtual global::System.Threading.Tasks.Task<global::GrpcDemo.HelloReply> SayHello(global::GrpcDemo.HelloRequest request, grpc::ServerCallContext context)
  {
    throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
  }
}

GreeterBase 类被标记为 BindServiceMethod 属性,这意味着这个方法是 proto 文件中定义的 SayHello() 方法的实现。SayHello() 方法有一个名为 GeneratedCode 的属性,表示这个类是由 gRPC C# 插件生成的。在 SayHello() 方法内部,你可以看到它默认会抛出一个异常。因为这个方法是 virtual 的,所以我们需要在 GreeterService 类中重写这个方法以提供实际的实现。

接下来,让我们检查 Program.cs 文件。你将在 Program.cs 文件中找到以下代码:

var builder = WebApplication.CreateBuilder(args);// Add services to the container.
builder.Services.AddGrpc();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.MapGrpcService<GreeterService>();

在前面的代码块中,我们可以看到调用了 AddGrpc() 方法向服务容器添加 gRPC 服务。然后,我们使用 MapGrpcService<GreeterService>() 方法将 GreeterService 类映射到 gRPC 服务,这与 RESTful API 项目中的 MapControllers 方法类似。

Program.cs 文件中还有另一行代码,它使用 MapGet() 方法在用户通过网页浏览器访问应用程序的根路径时显示一条消息:

app.MapGet("/", () => "Communication with gRPC endpoints must be made through a gRPC client. To learn how to create a client, visit: https://go.microsoft.com/fwlink/?linkid=2086909");

这是因为 gRPC 服务不能通过网页浏览器访问。所以,我们需要显示一条消息来通知用户他们需要使用 gRPC 客户端来访问 gRPC 服务。

让我们更新 proto 文件并看看会发生什么。打开 greet.proto 文件并更新 HelloRequest,如下所示:

message HelloRequest {  string name = 1;
  string address = 2;
}

保存文件并返回到 GreeterService 类。在 SayHello() 方法中,你可以尝试访问 HelloRequest 对象的 Address 属性。你会发现 Address 属性不可用。这是因为生成的代码没有更新。我们需要通过使用 dotnet build 命令来重新生成代码。或者,你可以删除 obj 文件夹,代码将自动重新生成。

你可能会发现将生成的代码存储在 obj 文件夹中并不方便。我们可以通过在 .csproj 文件中的 Protobuf 项中使用 OutputDir 属性来更改生成的代码的输出目录。例如,你可以将 Protobuf 项更改为以下内容:

<ItemGroup>  <Protobuf Include="Protos\greet.proto" GrpcServices="Server" OutputDir="Generated" />
</ItemGroup>

现在,生成的代码将存储在 Generated\Protos 文件夹中。一个 proto 文件可以生成多个用于服务器端代码的文件。例如,greet.proto 文件将生成以下文件:

  • greet.cs: 此文件包含消息的定义以及序列化和反序列化消息的方法

  • greetGrpc.cs: 此文件包含服务基类的定义以及将服务绑定到服务器的方法

现在,我们已经了解了 gRPC 项目的结构,让我们学习 protobuf 消息背后的概念。

创建 protobuf 消息

在本节中,我们将学习如何创建 protobuf 消息。我们将介绍 protobuf 消息的概念以及如何在 proto 文件中定义它们。

gRPC 是一种以合同为先的框架,这意味着 gRPC 服务和消息必须在 proto 文件中定义。当我们谈论消息时,我们指的是客户端和服务器之间发送的数据。虽然 gRPC 消息可能与 RESTful API 中的数据模型相似,但它们并不相同。RESTful API 围绕资源,数据模型通常是资源模型,可以映射到一个或多个数据库表。相比之下,gRPC 是基于操作的,消息可以是任何其他类型的数据模型或客户端和服务器之间发送的其他消息。因此,gRPC 消息可能无法精确映射到 RESTful API 中的资源模型。

例如,当通过使用 JSON 作为数据格式的 RESTful API 创建发票时,我们需要向服务器发送一个带有 JSON 体的 HTTP POST 请求。JSON 体会反序列化为 .NET 对象,该对象作为发票的数据模型。要检索发票,我们需要向服务器发送一个 HTTP GET 请求,服务器会将数据模型序列化为 JSON 字符串并发送给客户端。我们可能还有其他操作,例如更新发票、删除发票等。所有这些操作都映射到 HTTP 方法。

要使用 gRPC 实现相同的功能,我们需要定义一个包含几个方法的 gRPC 服务:CreateInvoice()GetInvoice()UpdateInvoice()DeleteInvoice()等。对于这些方法中的每一个,我们还必须定义相应的请求和响应消息。例如,CreateInvoice()方法需要一个包含发票属性的CreateInvoiceRequest消息,以及一个包含创建的发票 ID 的CreateInvoiceResponse消息。重要的是要注意,请求和响应消息与发票的数据模型不同,该模型用于在系统中表示发票实体。请求和响应消息用于在客户端和服务器之间发送数据。

注意,gRPC 和 protobuf 不是同一回事。protobuf 是一种语言无关、平台无关的数据序列化格式。gRPC 是一个使用 protobuf 作为默认数据序列化格式的框架。有时,这两个术语可以互换使用,但我们应该了解它们之间的区别。

想想我们之前提到的发票示例。发票有几个属性,例如发票号码、发票日期、客户名称、总金额等等。客户有一个名称和一个地址。地址有一些属性,例如街道、城市、州等等。

接下来,我们将定义第一个消息,该消息用于为发票服务创建地址。本节的源代码可以在chapter11/GrpcDemo-v2文件夹中找到。我们将从一个简单的消息开始,然后介绍有关 protobuf 消息的更多概念,包括字段编号、字段类型以及如何在 protobuf 消息中使用其他.NET 类型。我们还将学习如何使用repeatedmap关键字实现列表和字典类型。

定义 protobuf 消息

Protos文件夹中创建一个新的invoice.proto文件。当您创建新文件时,VS Code 会提供一个 proto 文件模板,如图11.3所示:

图 11.3 – VS Code 中的 proto 文件模板

图 11.3 – VS Code 中的 proto 文件模板

proto 文件模板创建了一个名为Protos.proto的 proto 文件。将其重命名为invoice.proto。proto 文件的内容如下:

syntax = "proto3";option csharp_namespace = "MyApp.Namespace";

proto 文件是一个具有.proto扩展名的文本文件。proto 文件的第一行指定了 proto 文件的语法版本。在撰写本文时,proto 文件的最新版本是 2016 年发布的版本 3。您可以在protobuf.dev/programming-guides/proto3/找到有关 proto 文件语法的更多信息。

option csharp_namespace 行指定了 C# 生成代码的命名空间。你可以根据需要更改命名空间。此选项用于避免不同 proto 文件之间的命名冲突。请注意,尽管 proto 文件是语言中立的,但 option csharp_namespace 属性仅由 C# 代码生成器使用。在这个示例项目中,我们可以将命名空间更改为 GrpcDemo 以匹配现有代码的命名空间。

重要提示

Protobuf 支持使用 package 关键字来避免命名冲突,具体取决于语言。例如,package com.company 在 C# 中相当于 option csharp_namespace = "Com.Company"(名称将被转换为 PascalCase),而在 Java 中 package com.company 相当于 option java_package = "com.company"。然而,在 Python 中 package com.company 将会被忽略,因为 Python 模块是按照文件系统目录组织的。

由于我们使用 C#,我们使用 option csharp_namespace 属性,它可以覆盖 C# 应用程序的 package 关键字。如果你与其他使用其他语言的程序共享 proto 文件,你可以使用 package 关键字或语言特定的选项来避免命名冲突。

一旦创建了 proto 文件,我们需要将其添加到项目文件中。打开 GrpcDemo.csproj 文件,并将以下代码添加到 <ItemGroup> 元素中:

<Protobuf Include="Protos\invoice.proto" GrpcServices="Server"  OutputDir="Generated"/>

现在,当我们在构建项目时,gRPC 工具将生成 invoice.proto 文件的代码。

gRPC proto3 使用与 .NET 类相似的概念来定义消息。然而,也有一些不同之处。例如,proto3 不支持 GUIDdecimal 类型。让我们从一个简单的消息开始。我们可以定义一个 Address 消息如下:

message CreateAddressRequest {  string street = 1;
  string city = 2;
  string state = 3;
  string zip_code = 4;
  string country = 5;
}

如我们所见,它与 .NET 类相似。我们使用 message 关键字来定义 gRPC 消息。在消息体中,我们可以使用 string 来声明一个字符串字段。然而,这里有一些问题需要回答:

  • 为什么我们要给每个属性分配一个数字?它是默认值吗?

  • 为什么数字从 1 开始?我们能否使用 0?

  • 我们是否应该按照特定的顺序使用这些数字?

在我们继续之前,让我们回答这些问题。

理解字段编号

字段名后面的数字被称为 字段编号。字段编号在 proto 文件中扮演着重要的角色。这些字段编号用于识别消息中的字段。使用字段编号而不是字段名称有什么好处?让我们看看一个 XML 文档的例子:

<address>  <street>1 Fake Street</street>
  <city>Wellington</city>
  <state>Wellington</state>
  <zip_code>6011</zip_code>
  <country>New Zealand</country>
</address>

在前面的 XML 文档中,每个字段都被一个标签包裹。我们必须打开和关闭这些标签来包裹字段的值。XML 语法在传输数据时浪费了大量的空间。考虑以下 JSON 文档的例子:

{  "street": "1 Fake Street",
  "city": "Wellington",
  "state": "Wellington",
  "zip_code": "6011",
  "country": "New Zealand"
}

在前面的 JSON 文档中,我们只使用每个字段名称一次。通常,JSON 格式比 XML 格式更紧凑。如果我们去掉字段名称会怎样?这就是为什么我们在 proto 文件中使用字段编号的原因。通过在编码消息时使用字段编号而不是字段名称,我们可以使 gRPC 消息更加紧凑。这是因为数字比字段名称短。此外,protobuf 使用二进制格式,这比 JSON 和 XML 等纯文本格式更紧凑。这进一步有助于减少消息的大小。

根据 protobuf 文档,关于字段编号有一些需要注意的事项:

  • 字段编号的范围是从 1536,870,911。因此,我们不能使用 0 作为字段编号。

  • 字段编号必须在消息内是唯一的。

  • 字段编号 1900019999 是为 protobuf 保留的,因此不能使用它们。

  • 从技术上讲,字段编号的顺序并不重要。建议使用字段编号的升序。较小的字段编号使用更少的字节进行编码。例如,编号在 115 之间的字段仅使用一个字节进行编码,但编号从 162047 的字段则使用两个字节。

  • 一旦将字段编号分配给字段,如果 proto 文件在生产中使用,则不能更改。更改字段编号将破坏 proto 文件的向后兼容性。

通过这些,我们已经学习了字段编号是什么以及为什么使用它们。接下来,让我们了解字段类型。

理解字段类型

与 .NET 类类似,gRPC 消息可以有不同的字段类型。protobuf 提供了一组原生类型,这些类型被称为标量值类型。这些标量值类型在大多数编程语言中都有表示。下表列出了 protobuf 标量值类型与 .NET 类型之间的映射:

Protobuf 类型 .NET 类型 注意事项
double double ±5.0 × 10−324 到 ±1.7 × 10308。
float float ±1.5 x 10−45 到 ±3.4 x 1038。
int32 int 长度可变。如果字段有负数,请使用 sint32
int64 long 长度可变。如果字段有负数,请使用 sint64
uint32 uint 长度可变。无符号整数。0 到 (232-1)。
uint64 ulong 长度可变。无符号整数。0 到 (264-1)。
sint32 int 长度可变。有符号整数。-231 到 (231-1)。
sint64 long 长度可变。有符号整数。-263 到 (263-1)。
fixed32 uint 长度始终为 4 字节。此类型在序列化或反序列化大于 228 的值时比 uint32 更有效。
fixed64 ulong 长度始终为 8 字节。此类型在序列化或反序列化大于 256 的值时比 uint64 更有效。
sfixed32 int 长度始终为 4 字节。
sfixed64 long 长度始终为 8 字节。
bool bool
string string string 字段必须以 UTF-8 或 7 位 ASCII 编码。string 字段的长度最大为 232。
bytes ByteString 此类型定义在 protobuf 运行时中。它可以映射到并从 C# 的 byte[] 类型转换。

表 11.1 – Protobuf 标量值类型和 .NET 类型

让我们创建一个名为 CreateContactRequest 的新消息并添加一些字段到它中:

message CreateContactRequest {  string first_name = 1;
  string last_name = 2;
  string email = 3;
  string phone = 4;
  int32 year_of_birth = 5;
  bool is_active = 6;
}

CreateContactRequest 消息需要 first_namelast_nameemailphoneyear_of_birthis_active 字段。这些字段的类型分别是 stringint32bool

接下来,我们可以运行 dotnet build 来生成代码。或者,您也可以删除 Generated 文件夹中的现有文件,gRPC 工具将根据 proto 文件自动重新生成代码。

生成的代码文件包含一些复杂的代码。然而,我们可以找到 CreateContactRequest 类的定义,如下所示:

public sealed partial class CreateContactRequest : pb::IMessage<CreateContactRequest>{
    private string firstName_ = "";
    public string FirstName {
      get { return firstName_; }
      set {
        firstName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
      }
    }
    private string lastName_ = "";
    public string LastName {
      get { return lastName_; }
      set {
        lastName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
      }
    }
    private string email_ = "";
    public string Email {
      get { return email_; }
      set {
        email_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
      }
    }
    private string phone_ = "";
    public string Phone {
      get { return phone_; }
      set {
        phone_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
      }
    }
    private int yearOfBirth_;
    public int YearOfBirth {
      get { return yearOfBirth_; }
      set {
        yearOfBirth_ = value;
      }
    }
    private bool isActive_;
    public bool IsActive {
      get { return isActive_; }
      set {
        isActive_ = value;
      }
    }
}

在前面的代码块中,为了简洁起见,省略了一些代码。您可以看到 CreateContactRequest 消息已被转换为 .NET 类,其中包含每个字段的属性。

重要提示

Protobuf 为字段和方法的命名提供了一组风格指南。一般规则如下:

  • 字段名使用 lower_snake_case

  • 方法名使用 PascalCase

  • 文件名应使用 lower_snake_case

  • 使用双引号表示字符串字面量比使用单引号更受欢迎

  • 缩进应为两个空格的长度

您可以在 protobuf.dev/programming-guides/style/ 找到更多信息。

通过这样,我们已经学习了如何使用 protobuf 标量值类型。现在,让我们考虑其他类型。

其他 .NET 类型

protobuf 的标量数据类型不支持所有 .NET 类型,例如 GuidDateTimedecimal 等。对于这些类型有一些解决方案。在本节中,我们将学习如何在 protobuf 中使用这些类型。我们还将探索一些其他类型,如 enumrepeated

GUID 值

GUID 类型(在其他平台上可能具有另一个名称,UUID)是一个 128 位的结构,用于标识对象。它在 .NET 应用程序中非常常见。通常,GUID 值可以表示为一个包含 32 个十六进制数字的字符串。例如,31F6E4E7-7C48-4F91-8D33-7A74F6729C8B 是一个 GUID 值。

然而,protobuf 不支持 GUID 类型。在 protobuf 中表示 GUID 值的最佳方式是使用 string 字段。在 .NET 代码中,我们可以使用 Guid.Parse() 将字符串转换为 GUID 值,并使用 Guid.ToString()GUID 值转换为字符串。

日期时间值

.NET 有几种类型来表示日期和时间值,例如 DateTimeDateTimeOffsetTimeSpan。尽管 protobuf 不直接支持这些类型,但它提供了几个扩展来支持它们。

要使用这些扩展类型,我们需要将google/protobuf/xxx.proto文件导入到 proto 文件中。例如,以下是一个包含时间戳和持续时间的消息:

syntax = "proto3";import "google/protobuf/timestamp.proto";
import "google/protobuf/duration.proto";
message UpdateInvoiceDueDateRequest {
  string invoice_id = 1;
  google.protobuf.Timestamp due_date = 2;
  google.protobuf.Duration grace_period = 3;
}

检查Generated文件夹中生成的UpdateInvoiceDueDateRequest消息的代码。你会发现due_date字段被转换为Timestamp类型,而grace_period字段被转换为Duration类型,如下所示:

public const int DueDateFieldNumber = 2;private global::Google.Protobuf.WellKnownTypes.Timestamp dueDate_;
public global::Google.Protobuf.WellKnownTypes.Timestamp DueDate {
  get { return dueDate_; }
  set {
    dueDate_ = value;
  }
}
public const int GracePeriodFieldNumber = 3;
private global::Google.Protobuf.WellKnownTypes.Duration gracePeriod_;
public global::Google.Protobuf.WellKnownTypes.Duration GracePeriod {
  get { return gracePeriod_; }
  set {
    gracePeriod_ = value;
  }
}

Timestamp类型和Duration类型不是原生.NET 类型。它们在Google.Protobuf.WellKnownTypes命名空间中定义,该命名空间包含一些 protobuf 不支持的可知类型。这些类型的源代码可以在github.com/protocolbuffers/protobuf/tree/main/csharp/src/Google.Protobuf/WellKnownTypes找到。

因为这些类型不是原生.NET 类型,所以在使用时需要将它们转换为原生.NET 类型。Google.Protobuf.WellKnownTypes命名空间提供了一些转换方法。以下是将.NET 类型转换为 protobuf 类型的示例:

var updateInvoiceDueDateRequest = new UpdateInvoiceDueDateRequest{
    InvoiceId = Guid.Parse("3193C36C-2AAB-49A7-A0B1-6BDB3B69DEA1"),
    DueDate = Timestamp.FromDateTime(DateTime.UtcNow.AddDays(30)),
    GracePeriod = Duration.FromTimeSpan(TimeSpan.FromDays(10))
};

我们可以使用Timestamp类将DateTimeDateTimeOffset值转换为Timestamp值。Timestamp.FromDateTime()方法用于转换DateTime值,而Timestamp.FromDateTimeOffset()方法用于转换DateTimeOffset值。我们还可以使用Duration.FromTimeSpan()方法将TimeSpan值转换为Duration值。请注意,如果您在应用程序中使用DateTimeOffset类型,则DateTimeOffset值的偏移量始终为 0,并且DateTime.Kind属性始终设置为DateTimeKind.Utc

同样,我们可以将 protobuf 类型转换为.NET 类型:

var dueDate = updateInvoiceDueDateRequest.DueDate.ToDateTime();var gracePeriod = updateInvoiceDueDateRequest.GracePeriod.ToTimeSpan();

Timestamp类提供了几种将它的值转换为其他类型的方法。ToDateTime()方法可以用于将Timestamp值转换为DateTime值,而ToTimeSpan()方法可以用于将Duration值转换为TimeSpan值。此外,ToDateTimeOffset()方法可以用于将Timestamp值转换为DateTimeOffset值。根据您的需求,您可以选择适合您需求的方法。

十进制值

在编写本文时,protobuf 不支持直接使用decimal类型。有一些关于将decimal类型添加到 protobuf 的讨论,但尚未实现。作为解决方案,Microsoft Docs 提供了一个DecimalValue类型,可以用于在 protobuf 中表示decimal值。以下是从 Microsoft Docs 复制的代码,展示了如何在 protobuf 中定义decimal值:

// Example: 12345.6789 -> { units = 12345, nanos = 678900000 }message DecimalValue {
    // Whole units part of the amount
    int64 units = 1;
    // Nano units of the amount (10^-9)
    // Must be same sign as units
    sfixed32 nanos = 2;
}

在本书中,我们将不会深入探讨 DecimalValue 类型的细节。更多信息请参阅learn.microsoft.com/en-us/dotnet/architecture/grpc-for-wcf-developers/protobuf-data-types#decimals

枚举值

.NET 应用程序中,enum` 类型非常常见。protobuf 支持枚举类型。以下是其使用示例:

enum InvoiceStatus {  INVOICE_STATUS_UNKNOWN = 0;
  INVOICE_STATUS_DRAFT = 1;
  INVOICE_STATUS_AWAIT_PAYMENT = 2;
  INVOICE_STATUS_PAID = 3;
  INVOICE_STATUS_OVERDUE = 4;
  INVOICE_STATUS_CANCELLED = 5;
}

前面的枚举定义与 C# 中的枚举定义类似,但我们需要在 proto 文件中定义它。在前面代码中,我们定义了一个包含六个值的 InvoiceStatus 枚举类型。请注意,每个枚举类型都必须包含一个 0 值,这是默认值,并且必须放在第一个位置。InvoiceStatus 枚举类型将被转换为 .NET 枚举类型,如下所示:

public enum InvoiceStatus {  [pbr::OriginalName("INVOICE_STATUS_UNKNOWN")] Unknown = 0,
  [pbr::OriginalName("INVOICE_STATUS_DRAFT")] Draft = 1,
  [pbr::OriginalName("INVOICESTATUS_AWAIT_PAYMENT")] AwaitPayment = 2,
  [pbr::OriginalName("INVOICE_STATUS_PAID")] Paid = 3,
  [pbr::OriginalName("INVOICE_STATUS_OVERDUE")] Overdue = 4,
}

如您所见,原始名称中的 INVOICE_STATUS 前缀被移除,因为前缀与枚举名称相同。在 .NET 代码中,枚举名称被转换为 PascalCase。

除了枚举类型外,.NET 还有一个名为 nullable 的常见类型。我们将在下一节中检查可空类型。

可空值

Protobuf 标量值类型,如 int32sint32fixed32bool,不能为 null。但在 .NET 中,可空值类型非常常见。例如,我们可以使用 int? 来声明一个可以 null 的整数值。为了支持可空值类型,protobuf 提供了一些包装类型,这些类型在 google/protobuf/wrappers.proto 文件中定义,以支持可空类型。我们可以将此文件导入到 proto 文件中,并使用包装类型。例如,我们可以定义一个消息如下:

syntax = "proto3";import "google/protobuf/wrappers.proto";
message AddInvoiceItemRequest {
  string name = 1;
  string description = 2;
  google.protobuf.DoubleValue unit_price = 3;
  google.protobuf.Int32Value quantity = 4;
  google.protobuf.BoolValue is_taxable = 5;
}

在前面代码中,google.protobuf.DoubleValue 类型用于表示可空 double 值,google.protobuf.Int32Value 类型用于表示可空 int32 值,google.protobuf.BoolValue 类型用于定义可空 bool 值。AddInvoiceItemRequest 消息的生成代码如下所示:

private double? unitPrice_;public double? UnitPrice {
  get { return unitPrice_; }
  set {
    unitPrice_ = value;
  }
}
private int? quantity_;
public int? Quantity {
  get { return quantity_; }
  set {
    quantity_ = value;
  }
}
private bool? isTaxable_;
public bool? IsTaxable {
  get { return isTaxable_; }
  set {
    isTaxable_ = value;
  }
}

如您所见,unitPricequantityIsTaxable 字段在 .NET 中被转换为可空类型。

大多数 .NET 可空类型都由 protobuf 支持。除了 google.protobuf.DoubleValuegoogle.protobuf.Int32Valuegoogle.protobuf.BoolValue 类型外,protobuf 还提供了以下包装类型:

  • google.protobuf.FloatValue:此类型用于表示 float? 值。

  • google.protobuf.Int64Value:此类型用于表示 long? 值。

  • google.protobuf.UInt32Value:此类型用于表示 uint? 值。

  • google.protobuf.UInt64Value:此类型用于表示 ulong? 值。

  • google.protobuf.StringValue:此类型用于表示 string 值。

  • google.protobuf.BytesValue:此类型用于表示 ByteString 值。

在上述列表中,有两个特殊类型:google.protobuf.StringValuegoogle.protobuf.BytesValue。对应的 .NET 类型是 stringByteStringByteString 类型是一个表示不可变字节数组的类,它在 protobuf 运行时中定义。这两个类型的默认值是 null

因此,如果 google.protobuf.StringValue 在 .NET 中映射为 string,那么 google.protobuf.StringValue 和 protobuf 中的 string 之间有什么区别?区别在于默认值。我们将在下一节中查看这些类型的默认值。

默认值

以下表格列出了标量值类型的默认值:

Protobuf 类型 默认值
string 一个空字符串
bytes 一个空字节数组
bool false
数值类型 0
enums 第一个枚举值

表 11.2 – Protobuf 标量值类型的默认值

如果你将 string 作为字段的类型,默认值将是一个空字符串。然而,google.protobuf.StringValue 字段的默认值是 null。同样,bytes 字段的默认值是一个空字节数组,而 google.protobuf.BytesValue 字段的默认值是 null。所有其他包装类型也有一个默认值 null

所有数值类型,包括 int32doublefloat,都有一个默认值 0。这适用于所有数值数据类型。protobuf 中的 Enum 类型有一个默认值,即枚举类型中的第一个值,必须是 0。例如,InvoiceStatus 枚举类型的默认值是 INVOICE_STATUS_UNKNOWN,即 0

重复字段

与 .NET 集合类似,protobuf 支持重复字段。重复字段可以包含零个或多个项目。以下代码展示了如何定义重复字段:

message UpdateBatchInvoicesStatusRequest {  repeated string invoice_ids = 1;
  InvoiceStatus status = 2;
}

在上述代码中,我们使用 repeated 关键字定义了一个重复字段。在 UpdateInvoicesStatusRequest 消息中,重复的 invoice_ids 字段的生成代码如下:

private readonly pbc::RepeatedField<string> invoiceIds_ = new pbc::RepeatedField<string>();public pbc::RepeatedField<string> InvoiceIds {
  get { return invoiceIds_; }
}

从生成的代码中,我们可以看到重复的 string 字段被转换为 RepeatedField<string> 类型。RepeatedField<T> 类型在 Google.Protobuf.Collections 命名空间中定义,并实现了 .NET 集合接口,如下所示:

public sealed class RepeatedField<T> : IList<T>, ICollection<T>, IEnumerable<T>, IEnumerable, IList, ICollection, IDeepCloneable<RepeatedField<T>>, IEquatable<RepeatedField<T>>, IReadOnlyList<T>, IReadOnlyCollection<T>{
  // Omitted for brevity
}

RepeatedField<T> 类型可以用作正常的 .NET 集合类型,并且可以对其应用任何 LINQ 方法。这使得它成为数据操作的一个强大且多功能的工具。

你还会发现 InvoiceIds 字段是一个只读属性。要向集合中添加一个或多个项目,可以使用 Add() 方法。以下是一个示例:

var updateInvoicesStatusRequest = new UpdateBatchInvoicesStatusRequest();// Add one item
updateInvoicesStatusRequest.InvoiceIds.Add("3193C36C-2AAB-49A7-A0B1-6BDB3B69DEA1");
// Add multiple items
updateInvoicesStatusRequest.InvoiceIds.Add(new[]
            { "99143291-2523-4EE8-8A4D-27B09334C980", "BB4E6CFE-6AAE-4948-941A-26D1FBF59E8A" });

重复字段的默认值是一个空集合。

映射字段

Protobuf 支持映射字段,它类似于 .NET 字典的键值对集合。以下代码提供了一个如何定义映射字段的示例:

message UpdateInvoicesStatusRequest {  map<string, InvoiceStatus> invoice_status_map = 1;
}

invoice_status_map 字段的生成代码如下:

private readonly pbc::MapField<string, global::GrpcDemo.InvoiceStatus> invoiceStatusMap_ = newpbc::MapField<string, global::GrpcDemo.InvoiceStatus>();public pbc::MapField<string, global::GrpcDemo.InvoiceStatus> InvoiceStatusMap {
  get { return invoiceStatusMap_; }
}

MapField<Tkey, TValue> 类型定义在 Google.Protobuf.Collections 命名空间中,并实现了 IDictionary<TKey, TValue> 接口,如下所示:

public sealed class MapField<TKey, TValue> : IDeepCloneable<MapField<TKey, TValue>>, IDictionary<TKey, TValue>, ICollection<KeyValuePair<TKey, TValue>>, IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable, IEquatable<MapField<TKey, TValue>>, IDictionary, ICollection, IReadOnlyDictionary<TKey, TValue>, IReadOnlyCollection<KeyValuePair<TKey, TValue>>{
  // Omitted for brevity
}

MapField<TKey, TValue> 类型可以用作正常的 .NET 字典类型。此类型提供了与标准字典相同的功能,允许存储和检索键值对。

与重复字段类似,InvoiceStatusMap 字段也是一个只读属性。我们可以使用 Add() 方法向集合中添加一个键值对或多个键值对,如下所示:

var updateInvoicesStatusRequest = new UpdateInvoicesStatusRequest();// Add one key-value pair
updateInvoicesStatusRequest.InvoiceStatusMap.Add("3193C36C-2AAB-49A7-A0B1-6BDB3B69DEA1", InvoiceStatus.AwaitPayment);
// Add multiple key-value pairs
updateInvoicesStatusRequest.InvoiceStatusMap.Add(new Dictionary<string, InvoiceStatus>
{
    { "99143291-2523-4EE8-8A4D-27B09334C980", InvoiceStatus.Paid },
    { "BB4E6CFE-6AAE-4948-941A-26D1FBF59E8A", InvoiceStatus.Overdue }
});

注意,映射字段不能重复。此外,映射字段的键必须是 string 或整数类型。你不能将 enum 类型用作映射字段的键。映射字段的值可以是任何类型,包括消息类型。但值类型不能是另一个映射字段。

我们现在已经对 protobuf 消息有了全面的理解,包括字段编号、字段类型、默认值、重复字段和映射字段。有关 protobuf 消息的更多信息,请参阅 protobuf.dev/programming-guides/proto3/

接下来,我们将检查各种 protobuf 服务。我们将探讨各种 RPC 方法的类型以及如何为服务创建 gRPC 客户端。通过这样做,我们将更好地理解这些服务的工作原理以及如何有效地使用它们。

创建 protobuf 服务

现在我们已经理解了 protobuf 消息的定义,我们可以继续定义 protobuf 服务。这些服务由 RPC 方法组成,每个方法都有一个请求消息和一个响应消息。为了便于实现这些服务,gRPC 工具将生成必要的 C# 代码,然后可以将该代码用作服务的基础类。

gRPC 支持四种类型的 RPC 方法:

  • 单一 RPC:客户端向服务器发送一个单一请求消息,并收到一个单一响应消息。此类方法适用于需要单一请求-响应交换的应用程序。

  • 服务器流式 RPC:客户端向服务器发送一个单一请求消息,然后服务器响应一个响应消息流。此类方法允许客户端和服务器之间进行连续的数据交换。

  • 客户端流式 RPC:客户端向服务器发送一个流请求消息,然后服务器响应一个响应消息。类似于服务器流式 RPC,此类方法也允许进行连续的数据交换,但数据变化是由客户端发起的。

  • 双向流式 RPC:客户端通过发送一个流请求消息来启动过程,服务器随后响应一个流响应消息。此类方法允许客户端和服务器在两个方向上进行通信。

让我们逐一检查这些 RPC 方法。本节的源代码可以在 chapter11/GrpcDemo-v3 文件夹中找到。

定义一个一元服务

一元服务是 RPC 方法中最简单的一种类型。以下代码展示了一元服务:

message CreateContactRequest {  string first_name = 1;
  string last_name = 2;
  string email = 3;
  string phone = 4;
  int32 year_of_birth = 5;
  bool is_active = 6;
}
message CreateContactResponse {
  string contact_id = 1;
}
service ContactService {
  rpc CreateContact(CreateContactRequest) returns (CreateContactResponse);
}

在前面的代码中,我们定义了一个 CreateContactRequest 消息和一个 CreateContactResponse 消息,然后定义了一个 ContactService 服务,其中包含一个 CreateContact() RPC 方法。CreateContact RPC 方法需要一个 CreateContactRequest 请求消息和一个 CreateContactResponse 响应消息。

CreateContact() RPC 方法的生成代码如下:

public abstract partial class ContactServiceBase{
  public virtual global::System.Threading.Tasks.Task<global::GrpcDemo.CreateContactResponse> CreateContact(global::GrpcDemo.CreateContactRequest request, grpc::ServerCallContext context)
  {
    throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
  }
}

ContactServiceBase 类是服务实现的基类。它包含一个 CreateContact() 方法,这是一个 virtual 方法。默认情况下,CreateContact() 方法会抛出异常,因为该方法尚未实现。我们需要在服务实现中重写此方法。

接下来,在 Service 文件夹中创建一个 ContactService.cs 文件。在 ContactService.cs 文件中,我们需要实现 ContactService 类,该类是从 ContactServiceBase 类派生的。ContactService 类如下:

public class ContactService(ILogger<ContactService> logger) : Contact.ContactBase{
    public override Task<CreateContactResponse> CreateContact(CreateContactRequest request, ServerCallContext context)
    {
        // TODO: Save contact to database
        return Task.FromResult(new CreateContactResponse
        {
            ContactId = Guid.NewGuid().ToString()
        });
    }
}

在前面的代码中,我们重写了 CreateContact() 方法并实现了该方法。这个 CreateContact() 方法允许我们执行一些需要的逻辑,例如将联系人保存到数据库中。为了简单起见,我们只是返回一个新的 CreateContactResponse 对象,并带有新的 ContactId 值。实际上,我们可能还有其他逻辑。

接下来,我们需要在 DI 容器中注册 ContactService 类。打开 Program.cs 文件,并在 ConfigureServices() 方法中添加以下代码:

app.MapGrpcService<ContactService>();

我们的新一元服务简化了处理 HTTP 请求的过程,消除了编写任何代码或管理不同 HTTP 方法的需要。所有 RPC 调用都由 gRPC 框架处理,从而实现了一个简化的流程。

要调用 gRPC 服务,必须创建一个 gRPC 客户端,因为当前浏览器不支持此协议。作为替代,可以使用 Postman 等工具来访问服务。在下一节中,我们将演示如何创建控制台应用程序来调用该服务。

创建 gRPC 客户端

gRPC 可以是一个控制台应用程序、一个 Web 应用程序或任何其他类型的应用程序,例如 WPF 应用程序。在本节中,我们将创建一个控制台应用程序作为前一节中创建的一元服务的 gRPC 客户端。您可以在其他类型的应用程序中使用类似的代码。按照以下步骤操作:

  1. 使用 dotnet new 命令创建一个新的控制台项目:

    dotnet new console -o GrpcDemo.Client
    
  2. 现在,我们有两个项目。如果您尚未创建解决方案文件,可以通过运行以下命令创建它:

    dotnet new sln -n GrpcDemo
    
  3. 然后,将两个项目添加到解决方案中:

    GrpcDemo.Client folder and add the Grpc.Net.Client package to the project:
    
    

    cd GrpcDemo.Client

    
    
  4. 要使用 gRPC 工具生成客户端代码,我们还需要添加以下包:

    Grpc.Tools package contains code-generation tooling for gRPC. It is a development-time dependency, which means that it is not required at runtime. So, we need to add the <PrivateAssets>all</PrivateAssets> element to the Grpc.Tools package to ensure that the package is not included in the published application.
    
  5. 接下来,将Protos文件夹从GrpcDemo项目复制到GrpcDemo.Client项目。然后,将以下代码添加到GrpcDemo.Client.csproj文件中:

    <ItemGroup>  <Protobuf Include="Protos\greet.proto" GrpcServices="Client" OutputDir="Generated"/>  <Protobuf Include="Protos\invoice.proto" GrpcServices="Client"  OutputDir="Generated"/>  <Protobuf Include="Protos\demo.proto" GrpcServices="Client"  OutputDir="Generated"/></ItemGroup>
    

    GrpcDemo项目类似,我们使用Protobuf元素来指定 proto 文件和输出目录。GrpcServices属性用于指定生成的代码类型。在这种情况下,我们使用Client,因为我们正在创建一个 gRPC 客户端。

    当您在GrpcDemo项目中对 proto 文件进行更改时,请务必将更改复制到GrpcDemo.Client项目,以确保客户端代码是最新的。

    Generated/Protos文件夹中,您将找到每个 proto 文件的生成代码。例如,invoice.proto文件将生成以下文件:

    • Invoice.cs:此文件包含invoice.proto文件中消息的定义

    • InvoiceGrpc.cs:此文件包含invoice.proto文件中服务器的 gRPC 客户端代码

  6. 接下来,让我们在项目根目录中创建一个InvoiceClient.cs文件,并添加以下代码:

    using Grpc.Net.Client;namespace GrpcDemo.Client;internal class InvoiceClient{    public async Task CreateContactAsync()    {        using var channel = GrpcChannel.ForAddress("http://localhost:5269");        var client = new Contact.ContactClient(channel);        var reply = await client.CreateContactAsync(new CreateContactRequest()        {            Email = "john.doe@abc.com",            FirstName = "John",            LastName = "Doe",            IsActive = true,            Phone = "1234567890",            YearOfBirth = 1980        });        Console.WriteLine("Created Contact: " + reply.ContactId);        Console.ReadKey();    }}
    

    在前面的代码中,我们使用GrpcChannel.ForAddress()方法创建一个 gRPC 通道,它接受 gRPC 服务器的地址。

  7. 要获取 gRPC 服务器的地址,您可以在GrpcDemo项目中使用dotnet run命令来启动 gRPC 服务器。以下输出显示了 gRPC 服务器的地址:

    info: Microsoft.Hosting.Lifetime[14]      Now listening on: http://localhost:5269info: Microsoft.Hosting.Lifetime[0]      Application started. Press Ctrl+C to shut down.info: Microsoft.Hosting.Lifetime[0]      Hosting environment: Development
    
  8. 或者,您可以在Properties/launchSettings.json文件中检查applicationUrl属性。以下代码显示了applicationUrl属性:

    {  "$schema": "http://json.schemastore.org/launchsettings.json",  "profiles": {    "http": {      ...      "applicationUrl": "http://localhost:5269",      ...    },    "https": {      ...      "applicationUrl": "https://localhost:7179;http://localhost:5269",      ...    }  }}
    

    gRPC 通道用于在指定的地址和端口上建立与 gRPC 服务器的连接。一旦我们有了 gRPC 通道,我们就可以创建一个由 proto 文件生成的ContactClient类的实例。然后,我们调用CreateContactAsync()方法来创建一个联系人。CreateContactAsync()方法接受一个CreateContactRequest对象作为参数。CreateContactAsync()方法返回一个包含ContactId值的CreateContactResponse对象。在方法结束时,我们将ContactId值打印到控制台。

    此方法很简单。有一些需要注意的事项:

    • 创建 gRPC 通道是一个昂贵的操作。因此,建议重用 gRPC 通道。然而,gRPC 客户端是一个轻量级对象,因此没有必要重用它。

    • 您可以从一个 gRPC 通道创建多个 gRPC 客户端,并且可以安全地同时使用多个 gRPC 客户端。

  9. 要使用 TLS 保护 gRPC 通道,您需要使用 HTTPS 运行 gRPC 服务。例如,您可以使用以下命令运行 gRPC 服务:

    dotnet run --urls=https://localhost:7179
    
  10. 然后,您可以使用 HTTPS 地址创建 gRPC 通道:

    Program.cs file, call the CreateContactAsync() method, as follows:
    
    

    var contactClient = new InvoiceClient();await contactClient.CreateContactAsync();

    
    
  11. 在不同的终端中运行 gRPC 服务器和 gRPC 客户端。通过这样做,您将在 gRPC 客户端终端看到以下输出:

    Created Contact: 3193c36c-2aab-49a7-a0b1-6bdb3b69dea1
    

这是一个控制台应用程序中 gRPC 客户端的简单示例。在下一节中,我们将创建一个服务器流式服务及其对应的 gRPC 客户端。

定义服务器流式服务

与单一服务类似,服务器流式服务有一个请求消息和一个响应消息。区别在于响应消息是一个流消息。一旦服务器开始发送流响应消息,客户端就不能再向服务器发送任何消息,除非服务器完成发送流响应消息或客户端通过触发ServerCallContext.CancellationToken来取消 RPC 调用。

当我们需要向客户端发送一系列数据时,服务器流式服务非常有用。在这种情况下,服务器可以在单个 RPC 调用中向客户端发送多个消息。以下是一些服务器流式服务有用的场景:

  • 事件流式传输:当服务器需要向客户端发送一系列事件消息,以便客户端可以处理这些事件消息时。

  • 实时数据流:当服务器有一个连续的数据流要发送给客户端,例如股票价格、天气数据等。

  • 文件流式传输:当服务器需要向客户端发送大文件时,服务器可以将文件分割成小块,并逐个作为流响应消息发送。这可以减少服务器和客户端的内存使用,因为服务器和客户端不需要将整个文件加载到内存中。

以下代码展示了具有所需消息类型的服务器流式服务:

message GetRandomNumbersRequest {  int32 min = 1;
  int32 max = 2;
  int32 count = 3;
}
message GetRandomNumbersResponse {
  int32 number = 1;
}
service RandomNumbers {
  rpc GetRandomNumbers(GetRandomNumbersRequest) returns (stream GetRandomNumbersResponse);
}

在前面的 proto 文件中,我们定义了两个名为GetRandomNumbersRequestGetRandomNumbersResponse的消息。然后,我们定义了一个RandomNumbers服务,其中包含一个GetRandomNumbers() RPC 方法。请注意,GetRandomNumbers RPC 方法的响应消息被注解了stream关键字。这意味着响应消息是一个流消息。

GetRandomNumbers() RPC 方法生成的代码如下:

[grpc::BindServiceMethod(typeof(RandomNumbers), "BindService")]public abstract partial class RandomNumbersBase
{
  [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
  public virtual global::System.Threading.Tasks.Task GetRandomNumbers(global::GrpcDemo.GetRandomNumbersRequest request, grpc::IServerStreamWriter<global::GrpcDemo.GetRandomNumbersResponse> responseStream, grpc::ServerCallContext context)
  {
    throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
  }
}

在生成的代码中,我们可以看到响应消息的类型是IServerStreamWriter<GetRandomNumbersResponse>。让我们为RandomNumbers服务添加一个简单的实现。按照以下步骤操作:

  1. Service文件夹中创建一个RandomNumbersService.cs文件,并添加以下代码:

    public class RandomNumbersService(ILogger<RandomNumbersService> logger) : RandomNumbers.RandomNumbersBase{    public override async Task GetRandomNumbers(GetRandomNumbersRequest request,        IServerStreamWriter<GetRandomNumbersResponse> responseStream, ServerCallContext context)    {        var random = new Random();        for (var i = 0; i < request.Count; i++)        {            await responseStream.WriteAsync(new GetRandomNumbersResponse            {                Number = random.Next(request.Min, request.Max)            });            await Task.Delay(1000);        }    }}
    

    GetRandomNumbers()方法的实现中,我们使用for循环生成随机数,并每秒将它们发送给客户端。请注意,我们使用responseStream.WriteAsync()方法将流响应消息发送给客户端。消息发送完成时,循环结束。

  2. 如果我们需要一个连续的流响应消息,我们可以检查context参数的ServerCallContext.CancellationToken属性。如果客户端取消了 RPC 调用,ServerCallContext.CancellationToken属性将被触发。以下代码展示了如何检查ServerCallContext.CancellationToken属性:

    public override async Task GetRandomNumbers(GetRandomNumbersRequest request,    IServerStreamWriter<GetRandomNumbersResponse> responseStream, ServerCallContext context){    var random = new Random();    while (!context.CancellationToken.IsCancellationRequested)    {        await responseStream.WriteAsync(new GetRandomNumbersResponse        {            Number = random.Next(request.Min, request.Max)        });        await Task.Delay(1000, context.CancellationToken);    }}
    

    在前面的代码中,我们使用while循环来检查ServerCallContext.CancellationToken属性。如果客户端取消 RPC 调用,ServerCallContext.CancellationToken属性将被触发,while循环将结束。如果方法中还有其他异步操作,我们可以将ServerCallContext.CancellationToken属性传递给异步操作。这可以确保当客户端取消 RPC 调用时,异步操作将被取消。

  3. 接下来,我们将在依赖注入容器中注册RandomNumbersService类。打开Program.cs文件并添加以下代码:

    app.MapGrpcService<RandomNumbersService>();
    
  4. 接下来,我们将创建一个 gRPC 客户端来调用GetRandomNumbers() RPC 方法。在项目根目录下创建一个RandomNumbersClient.cs文件并添加以下代码:

    internal class ServerStreamingClient{    public async Task GetRandomNumbers()    {        using var channel = GrpcChannel.ForAddress("https://localhost:7179");        var client = new RandomNumbers.RandomNumbersClient(channel);        var reply = client.GetRandomNumbers(new GetRandomNumbersRequest()        {            Count = 100,            Max = 100,            Min = 1        });        await foreach (var number in reply.ResponseStream.ReadAllAsync())        {            Console.WriteLine(number.Number);        }        Console.ReadKey();    }}
    

    创建客户端的代码与我们在创建 gRPC 客户端部分介绍的InvoiceClient类似。唯一的区别在于响应消息的处理,它使用await foreach语句处理。ReadAllAsync()方法返回一个IAsyncEnumerable<T>对象,可以使用await foreach语句遍历。

  5. GrpcDemo.Client项目的Program.cs文件中,调用GetRandomNumbers()方法,如下所示:

    var serverStreamingClient = new ServerStreamingClient();await serverStreamingClient.GetRandomNumbers();
    
  6. 在不同的终端中运行 gRPC 服务器和 gRPC 客户端。您将看到输出包含一系列随机数。

这是一个服务器流式服务和相应的 gRPC 客户端的示例。在下一节中,我们将创建客户端流式服务和相应的 gRPC 客户端。

定义客户端流式服务

客户端流式服务允许客户端通过单个请求将一系列消息发送到服务器。服务器在完成处理流请求消息后,向客户端发送单个响应消息。一旦服务器发送响应消息,客户端流式调用即完成。

这里有一些场景,客户端流式服务非常有用:

  • 文件上传:当客户端将大文件上传到服务器时,客户端可以将文件分割成小块,并逐个作为流请求消息发送,这比在单个请求中发送整个文件更有效率。

  • 实时数据捕获:当客户端需要向服务器发送一系列数据流,例如传感器数据、用户交互或任何连续的数据流时,服务器可以处理这些数据并对这批数据进行响应。

  • 数据聚合:当客户端需要将一批数据发送到服务器进行聚合或分析时。

要定义客户端流式服务,我们需要使用stream关键字来注解请求消息。以下代码展示了具有所需消息类型的客户端流式服务:

message SendRandomNumbersRequest {  int32 number = 1;
}
message SendRandomNumbersResponse {
  int32 count = 1;
  int32 sum = 2;
}
service RandomNumbers {
  rpc SendRandomNumbers(stream SendRandomNumbersRequest) returns (SendRandomNumbersResponse);
}

前面的 .proto 文件定义了两个消息:SendRandomNumbersRequestSendRandomNumbersResponse。客户端向服务器发送一个包含一系列数字的流消息。然后,服务器处理流消息并计算数字的总和。最后,服务器向客户端发送一个响应消息,其中包含数字的数量和总和。需要注意的是,SendRandomNumbers() RPC 方法被 stream 关键字注释,表示请求消息是一个流消息。

与服务器流式服务类似,我们可以实现 SendRandomNumbers() 方法,如下所示:

public override async Task<SendRandomNumbersResponse> SendRandomNumbers(IAsyncStreamReader<SendRandomNumbersRequest> requestStream, ServerCallContext context){
    var count = 0;
    var sum = 0;
    await foreach (var request in requestStream.ReadAllAsync())
    {
        _logger.LogInformation($"Received: {request.Number}");
        count++;
        sum += request.Number;
    }
    return new SendRandomNumbersResponse
    {
        Count = count,
        Sum = sum
    };
}

我们在前面的代码中使用了 IAsyncStreamReader<T>.ReadAllAsync() 方法来读取客户端的所有流请求消息。随后,我们使用 await foreach 来遍历流请求消息。最后,我们计算数字的数量和总和,并返回一个 SendRandomNumbersResponse 对象。

要消费客户端流式服务,我们将从 GrpcDemo 项目复制 proto 文件到 GrpcDemo.Client 项目。然后,我们在 GrpcDemo.Client 项目中创建一个 ClientStreamingClient 类,并添加以下代码:

internal class ClientStreamingClient{
    public async Task SendRandomNumbers()
    {
        using var channel = GrpcChannel.ForAddress("https://localhost:7179");
        var client = new RandomNumbers.RandomNumbersClient(channel);
        // Create a streaming request
        using var clientStreamingCall = client.SendRandomNumbers();
        var random = new Random();
        for (var i = 0; i < 20; i++)
        {
            await clientStreamingCall.RequestStream.WriteAsync(new SendRandomNumbersRequest
            {
                Number = random.Next(1, 100)
            });
            await Task.Delay(1000);
        }
        await clientStreamingCall.RequestStream.CompleteAsync();
        // Get the response
        var response = await clientStreamingCall;
        Console.WriteLine($"Count: {response.Count}, Sum: {response.Sum}");
        Console.ReadKey();
    }
}

SendRandomNumbers() 方法中,我们通过调用 RandomNumbersClient 类的 SendRandomNumbers() 方法创建一个 AsyncClientStreamingCall 对象。请注意,客户端流式调用在调用 SendRandomNumbers() 方法时开始,但客户端不会发送任何消息,直到调用 RequestStream.CompleteAsync() 方法。在一个 for 循环中,我们使用 RequestStream.WriteAsync() 方法将流请求消息发送到服务器。在方法结束时,我们调用 RequestStream.CompleteAsync() 方法来指示流请求消息已完成。流请求消息包含 20 个随机生成的数字。

GrpcDemo.Client 项目的 Program.cs 文件中,我们随后调用 SendRandomNumbers() 方法,如下所示:

var clientStreamingClient = new ClientStreamingClient();await clientStreamingClient.SendRandomNumbers();

在不同的终端中运行 gRPC 服务器和 gRPC 客户端。大约 20 秒后,你将在 gRPC 客户端终端看到以下输出(总和可能不同):

Count: 20, Sum: 1000

通过这样,我们已经学会了如何创建客户端流式服务及其对应的 gRPC 客户端。在下一节中,我们将创建双向流式服务及其对应的 gRPC 客户端。

定义双向流式服务

双向流式服务允许客户端和服务器在单个请求中并发地向对方发送消息流。一旦建立连接,客户端和服务器可以在任何时间以任何顺序相互发送消息,因为两个流是独立的。例如,服务器可以响应客户端的每条消息,或者服务器可以在收到客户端的一系列消息后发送响应消息。

以下是一些双向流服务有用的场景:

  • 聊天应用:当客户端和服务器需要互相发送即时消息时

  • 实时数据仪表板:当客户端持续向服务器发送数据,并且服务器构建实时仪表板以显示数据时

  • 多人游戏:当玩家需要实时交互,并且服务器需要在玩家之间同步游戏状态时

让我们定义一个双向流服务。在这个例子中,客户端向服务器发送一些句子,服务器则对每个句子返回句子的大写版本。以下代码显示了所需的消息类型:

message ChatMessage {  string sender = 1;
  string message = 1;
}
service Chat {
  rpc SendMessage(stream ChatMessage) returns (stream ChatMessage);
}

在前面的 proto 文件中,我们已经定义了一个包含两个字段:sendermessageChatMessage消息。此外,我们还定义了一个具有SendMessage RPC 方法的Chat服务。需要注意的是,此方法的请求和响应都被注解了stream关键字,表示它们都是流消息。

现在,我们可以实现SendMessage()方法。按照以下步骤进行:

  1. Service文件夹中创建一个ChatService.cs文件并添加以下代码:

    public class ChatService(ILogger<ChatService> logger) : Chat.ChatBase{    public override async Task SendMessage(IAsyncStreamReader<ChatMessage> requestStream, IServerStreamWriter<ChatMessage> responseStream, ServerCallContext context)    {        await foreach (var request in requestStream.ReadAllAsync())        {            logger.LogInformation($"Received: {request.Message}");            await responseStream.WriteAsync(new ChatMessage            {                Message = $"You said: {request.Message.ToUpper()}"            });        }    }}
    

    在这里,我们使用await foreach方法遍历流请求消息。对于每个请求消息,我们使用WriteAsync()方法向客户端发送响应消息。这个响应消息包含请求消息的大写版本。

  2. 接下来,在依赖注入容器中注册ChatService类。打开Program.cs文件并添加以下代码:

    GrpcDemo project to the GrpcDemo.Client project. Then, create a BidirectionalStreamingClient class in the GrpcDemo.Client project and add the following code:
    
    

    内部类BidirectionalStreamingClient        });        Console.WriteLine("开始发送消息...");        Console.WriteLine("输入你的消息然后按回车键发送。");        while (true)        {            var message = Console.ReadLine();            if (string.IsNullOrWhiteSpace(message))            {                break;            }            await streamingCall.RequestStream.WriteAsync(new ChatMessage            {                Message = message            });        }        Console.WriteLine("断开连接...");        await streamingCall.RequestStream.CompleteAsync();        await responseReaderTask;    }}

    
    Because we use a console application to call the bidirectional streaming service, we need to use a background task to read the stream response messages. The `ReadAllAsync()` method returns an `IAsyncEnumerable<T>` object, which can be iterated over using the `await foreach` statement. In the background task, we use the `await foreach` statement to iterate over the stream response messages and print them to the console.Additionally, we use a `while` loop to read the input from the console and send the stream request messages to the server in the main thread. The `while` loop ends when the user enters an empty string. At the end of the method, we call the `RequestStream.CompleteAsync()` method to indicate that the stream request message is complete so that the server can finish processing the stream request messages gracefully.
    
  3. GrpcDemo.Client项目的Program.cs文件中,调用SendMessage()方法,如下所示:

    var bidirectionalStreamingClient = new BidirectionalStreamingClient();await bidirectionalStreamingClient.SendMessage();
    
  4. 在不同的终端中运行 gRPC 服务器和 gRPC 客户端。你将在 gRPC 客户端终端看到以下输出:

    Hello, World!Starting background task to receive messages...Starting to send messages...Input your message then press enter to send it.How are you?You said: HOW ARE YOU?What is ASP.NET Core?You said: WHAT IS ASP.NET CORE?Disconnecting...
    

这个示例是一个双向流服务的简单演示以及相应的 gRPC 客户端。双向流服务允许客户端和服务器在任何时间以任何顺序相互发送消息流。在上面的示例中,服务对客户端的每条消息做出响应。然而,使用类似的代码,我们可以根据需求实现更复杂的逻辑。

我们现在已经探讨了四种类型的 gRPC 服务:单一请求、服务器端流、客户端流和双向流。我们还学习了如何创建 gRPC 客户端来调用这些 gRPC 服务。在下一节中,我们将学习如何在 ASP.NET Core 应用程序中使用 gRPC 服务。

在 ASP.NET Core 应用程序中消费 gRPC 服务

在上一节中,我们学习了如何创建控制台应用程序来消费 gRPC 服务。在本节中,我们将集成 gRPC 服务到 ASP.NET Core 应用程序中。我们将重用上一节中创建的 gRPC 服务,并创建一个新的 ASP.NET Core 应用程序来消费这些 gRPC 服务。

要开始本节中概述的步骤,请从源代码的 GrpcDemo-v3 文件夹开始。本节的完整代码可以在 GrpcDemo-v4 文件夹中找到。

在控制台应用程序中,我们使用 GrpcChannel 类创建一个 gRPC 通道,然后使用 gRPC 通道创建一个 gRPC 客户端,如下面的代码所示:

using var channel = GrpcChannel.ForAddress("https://localhost:7179");var client = new Contact.ContactClient(channel);

在 ASP.NET Core 应用程序中,创建 gRPC 客户端的一个更好的方法是使用 IHttpClientFactory 接口和依赖注入。让我们看看如何使用 DI 容器创建 gRPC 客户端:

  1. 首先,我们必须创建一个新的 ASP.NET Core 应用程序。在这个 ASP.NET Core 应用程序中,我们将创建一个 REST API 来消费我们在上一节中创建的 gRPC 服务。使用 dotnet new 命令创建一个新的 ASP.NET Core 应用程序:

    dotnet new webapi -o GrpcDemo.Api -controllers
    
  2. 然后,将此项目添加到解决方案中:

    Grpc.Net.ClientFactory and Grpc.Tools packages to the project:
    
    

    Grpc.Net.ClientFactory 包允许开发人员使用依赖注入容器创建 gRPC 客户端,从而消除了使用 new 关键字的需求。此外,Grpc.Tools 包可以用于从 proto 文件生成 gRPC 客户端代码。

    
    
  3. 然后,将 GrpcDemo 项目的 Protos 文件夹复制到 GrpcDemo.Api 项目中。接下来,将以下代码添加到 GrpcDemo.Api.csproj 文件中:

    <ItemGroup>  <Protobuf Include="Protos\greet.proto" GrpcServices="Client" OutputDir="Generated"/>  <Protobuf Include="Protos\invoice.proto" GrpcServices="Client"  OutputDir="Generated"/>  <Protobuf Include="Protos\demo.proto" GrpcServices="Client"  OutputDir="Generated"/></ItemGroup>
    

    GrpcDemo.Client 项目类似,我们使用 GrpcServices="Client" 属性来指定生成的代码的类型。在这种情况下,我们使用 Client,因为我们将在 ASP.NET Core 应用程序中创建一个 gRPC 客户端来消费 gRPC 服务。

  4. 接下来,我们可以在 DI 容器中注册 gRPC 客户端。打开 Program.cs 文件并添加以下代码:

    ContactController.cs file in the Controllers folder and add the following code:
    
    

    [ApiController][Route("[controller]")]public class ContactController(Contact.ContactClient client, ILogger logger) : ControllerBase{    [HttpPost]    public async Task CreateContact(CreateContactRequest request)    {        var reply = await _client.CreateContactAsync(request);        return Ok(reply);    }}

    
    In the `ContactController` class, we use dependency injection to inject the gRPC client, `ContactClient`, which is generated from the `demo.proto` file. Then, we create a `CreateContact` action method to call the `CreateContactAsync()` method of the `ContactClient` class. The `CreateContactAsync()` method accepts a `CreateContactRequest` object as the parameter, which is also generated from the proto file. The `CreateContactAsync()` method returns a `CreateContactResponse` object, which contains the `ContactId` value. At the end of the method, we return the `ContactId` value to the client.
    
  5. 在不同的终端中运行 gRPC 服务器和 ASP.NET Core 应用程序。请注意,gRPC 服务器地址必须与 AddGrpcClient() 方法中指定的地址匹配。然后,您可以导航到 Swagger UI 页面,例如 http://localhost:5284/swagger/index.html,以测试 CreateContact() 动作方法。例如,您可以使用以下 JSON 对象作为请求体:

    {  "firstName": "John",  "lastName": "Doe",  "email": "john.doe@example.com",  "phone": "1234567890",  "yearOfBirth": 1980,  "isActive": true}
    

    您将看到以下响应(contactId 值可能不同):

    {  "contactId": "8fb43c22-143f-4131-a5f5-c3700b4f3a08"}
    

此简单示例展示了如何在 ASP.NET Core 应用程序中 DI 容器中使用 AddGrpcClient() 方法注册 gRPC 客户端,以及如何使用 gRPC 客户端消费 unary gRPC 服务。对于其他类型的 gRPC 服务,您需要相应地更新代码。

更新 proto 文件

gRPC 是一种以合约为中心的 RPC 框架。这意味着服务器和客户端通过在 proto 文件中定义的合约进行通信。不可避免地,合约会随着时间的推移而变化。在本节中,我们将学习如何更新合约以及如何处理服务器和客户端中的更改。

一旦 proto 文件在生产环境中使用,我们在更新 proto 文件时需要考虑向后兼容性。这是因为现有的客户端可能使用旧版本的 proto 文件,这可能与新版本的 proto 文件不兼容。如果新版本的合约不向后兼容,现有的客户端将无法工作。

以下更改与旧版本兼容:

  • 向请求消息中添加新字段:如果客户端不发送新字段,则服务器可以使用新字段的新默认值。

  • 向响应消息中添加新字段:如果响应消息包含新字段,但客户端不识别新字段,则客户端将在 proto 3 中丢弃新字段。在未来版本的 proto,称为 3.5 的版本中,此行为将改为将新字段作为未知字段保留。

  • 向服务中添加新的 RPC 方法:使用旧版本 proto 文件的客户端将无法调用新的 RPC 方法。但是,旧的 RPC 方法仍然可以工作。

  • 向 proto 文件中添加新服务:类似于添加新的 RPC 方法,新服务将不会对旧客户端可用,但旧服务仍然可以工作。

以下更改可能会导致破坏性更改,需要相应地更新客户端:

  • 从消息中删除字段

  • 在消息中重命名字段

  • 删除或重命名消息

  • 修改字段的数据类型

  • 修改字段编号

  • 删除或重命名服务

  • 从服务中删除或重命名 RPC 方法

  • 重新命名一个包

  • 修改 csharp_namespace 选项

Protobuf 使用字段编号来序列化和反序列化消息。如果我们不更改字段编号和数据类型,仅更改消息中的一个字段名称,则消息仍然可以正确地进行序列化和反序列化,但 .NET 代码中的字段名称将与 proto 文件中的字段名称不同。这可能会让开发者感到困惑。因此,客户端代码需要更新以使用新的字段名称。

从消息中删除字段是一个破坏性更改,因为字段编号不能重复使用。例如,如果我们从 Understanding the field types 部分定义的 CreateContactRequest 消息中删除 year_of_birth 字段,服务器将反序列化字段编号 5 为未知字段。如果开发者后来决定添加一个新的字段,该字段的字段编号为 5,并且数据类型不同,而现有客户端仍然发送字段编号 5 作为整数值,这可能导致序列化/反序列化错误。

要安全地删除一个字段,我们必须确保被删除的字段编号在将来不会被使用。为了避免任何潜在冲突,我们可以通过使用 reserved 关键字来保留被删除的字段编号。例如,如果我们从 CreateContactRequest 消息中删除 year_of_birthis_active 字段,我们可以保留字段编号,如下所示:

message CreateContactRequest {  reserved 5, 6;
  reserved "year_of_birth", "is_active";
  string first_name = 1;
  string last_name = 2;
  string email = 3;
  string phone = 4;
}

在前面的代码中,我们使用 reserved 关键字保留字段编号 5 和 6,以及 year_of_birthis_active 字段名称。保留的字段编号和字段名称不能在 proto 文件中重复使用。如果我们尝试使用保留的字段编号或字段名称,gRPC 工具将报告错误。

注意,应列出保留的字段名称以及保留的字段编号。这确保了 JSON 和文本格式具有向后兼容性。当字段名称被保留时,它们不能与字段编号放在同一个 reserved 语句中。

摘要

在本章中,我们探讨了 gRPC 服务和客户端的基础知识。我们讨论了在 protobuf 中使用的字段类型,包括标量类型和一些其他类型,如 DateTimeenum、重复字段和映射字段。然后,我们学习了四种类型的 gRPC 服务:单一请求、服务器流式传输、客户端流式传输和双向流式传输。我们探讨了如何实现每种类型的 gRPC 服务以及如何创建 gRPC 客户端以消费 gRPC 服务。此外,我们还演示了如何使用 AddGrpcClient() 方法在 ASP.NET Core 应用程序的依赖注入 (DI) 容器中注册 gRPC 客户端,以及如何使用 gRPC 客户端消费单一 gRPC 服务。最后,我们讨论了如何更新 proto 文件以及如何处理服务器和客户端的变化。

为了简化代码示例,我们在 gRPC 服务中没有使用任何数据库访问代码。在实际应用中,我们可能在 gRPC 服务中需要与数据库或其他外部服务进行交互。你可以遵循与 REST API 服务相同的做法。

gRPC 适用于构建高性能的服务间通信。由于本书的内容限制,我们只涵盖了 gRPC 的基础知识。我们没有涵盖高级主题,如身份验证、错误处理、性能调整等。然而,这一章应该足以让你开始使用 gRPC。

在下一章中,我们将探讨 GraphQL,这是一种替代 Web API 的方法。GraphQL 允许客户端仅请求他们所需的数据,这使得随着时间的推移修改 API 更容易,并能够使用强大的开发者工具。

进一步阅读

要了解更多关于 .NET Core 上的 gRPC 的信息,请参考以下资源:

第十二章:开始使用 GraphQL

第十一章 中,我们探讨了如何在 ASP.NET Core 中创建 gRPC 服务。gRPC 是一种高性能 RPC 框架,它促进了服务之间的通信。我们讨论了 protobuf 消息中使用的字段类型,以及如何定义四种类型的 gRPC 服务:单例、服务器端流、客户端端流和双向流。此外,我们还学习了如何在 ASP.NET Core 中配置 gRPC 服务以及如何从客户端应用程序调用 gRPC 服务。

接下来,我们将探讨另一种网络 API 的形状:GraphQL。GraphQL 是一种基于查询的 API,允许客户端指定他们需要的数据,从而解决了过度获取和不足获取数据的问题。此外,GraphQL 支持 变更,允许客户端修改数据。在本章中,我们将学习 GraphQL 的基本概念以及如何在 ASP.NET Core 中创建 GraphQL API。本章将涵盖以下主题:

  • GraphQL 概述

  • 使用 HotChocolate 设置 GraphQL API

  • 添加变更

  • 在查询中使用变量

  • 定义 GraphQL 模式

  • 使用解析器检索相关对象

  • 使用数据加载器

  • 依赖注入

  • 接口和联合类型

  • 过滤、排序和分页

  • 可视化 GraphQL 模式

阅读本章后,您将能够理解 GraphQL 的基本概念以及如何在 ASP.NET Core 中创建 GraphQL API。您还将学习如何使用 Apollo Federation 构建基于微服务的 GraphQL API。

技术要求

本章中的代码示例可以在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter12 找到。您可以使用 VS 2022 或 VS Code 打开解决方案。

GraphQL 概述

GraphQL 提供了一种灵活的方式来查询和变更数据。GraphQL 与 REST 的主要区别在于,GraphQL 允许客户端指定他们需要的数据,而 REST API 返回一组固定的数据。GraphQL 将数据视为图,并使用查询语言来定义数据的形状。这通过允许客户端指定他们的数据需求来解决过度获取和不足获取数据的问题。此外,它还支持变更,使客户端能够根据需要修改数据。

虽然 REST API 为不同的资源提供了多个端点,但 GraphQL 通常通过单个端点提供服务,通常是 /graphql,该端点暴露了一个描述数据的模式。所有查询和变更都发送到这个端点。该模式使用 GraphQL 模式定义语言定义,这是客户端和服务器之间的合同。该模式定义了数据的类型和可以在数据上执行的操作。客户端可以使用该模式来验证查询和变更请求。

GraphQL 可以解决客户端数据过取和不足取的问题。然而,后端开发比 REST API 更复杂。GraphQL 使用解析器从图的不同层级获取数据。如果解析器的实现效率不高,可能会导致性能问题。对于不熟悉 GraphQL 的开发者来说,GraphQL 的学习曲线也很陡峭。

ASP.NET Core 没有内置对 GraphQL 的支持。然而,可以使用几个第三方库来创建 GraphQL API:

  • HotChocolate:HotChocolate 是一个开源的.NET GraphQL 服务器。它建立在 ASP.NET Core 之上,并支持最新的 2021 年 10 月的 GraphQL 规范。它由提供 GraphQL 工具和咨询服务公司的 ChilliCream 支持。ChilliCream 还提供其他产品,如 Banana Cake Pop,这是一个用于创建和测试 GraphQL 查询的 GraphQL IDE,以及 Strawberry Shake,这是一个.NET 的 GraphQL 客户端库。您可以在chillicream.com/docs/hotchocolate/找到有关 HotChocolate 的更多信息。

  • GraphQL.NET:GraphQL.NET 是.NET 的另一个开源 GraphQL 实现。它提供了一组库,可用于创建 GraphQL API 和客户端。您可以在graphql-dotnet.github.io/找到有关 GraphQL.NET 的更多信息。

在本章中,我们将使用 HotChocolate 在 ASP.NET Core 中创建一个 GraphQL API。

使用 HotChocolate 设置 GraphQL API

首先,您可以从chapter12\start文件夹中下载本章名为SchoolManagement的代码示例。这个示例项目包含一个AppDbContext类和一个Teacher类的基本代码,以及一些种子数据。Teacher类具有以下属性:

public class Teacher{
    public Guid Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
    public string? Phone { get; set; }
    public string? Bio { get; set; }
}

您可以使用 VS Code 或 VS 2022 打开项目。我们将按照以下步骤将HotChocolate集成到项目中以创建 GraphQL API:

  1. HotChocolate.AspNetCore NuGet 包添加到项目中。此包包含 HotChocolate 的 ASP.NET Core 集成。它还包含 GraphQL IDE,这是一个可以用于创建和测试 GraphQL 查询的 GraphQL 客户端。您可以使用以下命令将包添加到项目中:

    Query, in the GraphQL/Queries folder, as shown here:
    
    

    public class Query{    public async Task<List<Teacher>> GetTeachers([Service] AppDbContext context) =>        await context.Teachers.ToListAsync();}

    
    The `Query` class will be used to define the queries that can be executed by the client. It has one method named `GetTeachers()`, which returns a list of teachers.
    
  2. 然后,我们需要在Program.cs文件中注册查询根类型。在AddDbContext()方法之后添加以下代码:

    Query type to the schema.
    
  3. 接下来,我们需要将 GraphQL 端点映射以公开 GraphQL 模式。将以下代码添加到Program.cs文件中:

    /graphql URL.
    
  4. 使用dotnet run运行项目,并在https://localhost:7208/graphql/打开 GraphQL IDE。您应该看到以下屏幕:

图 12.1 – Banana Cake Pop GraphQL IDE

图 12.1 – Banana Cake Pop GraphQL IDE

GraphQL IDE 允许你创建和测试 GraphQL 查询。

重要提示

默认启动 URL 是 swagger,用于 ASP.NET Core Web API 项目。你可以在 launchSettings.json 文件中将启动 URL 更改为 graphql 以直接打开 GraphQL IDE。

  1. 点击 浏览模式 按钮,然后点击 模式定义 选项卡来查看 GraphQL 模式。你应该看到以下模式:

    type Query {  teachers: [Teacher!]!}type Teacher {  id: UUID!  firstName: String!  lastName: String!  email: String!  phone: String  bio: String}
    

    前面的模式定义了一个查询根类型 Query 和一个 Teacher 类型。Query 类型有一个名为 teachers 的字段,它返回一个 [Teacher!]! 对象。GraphQL 使用 ! 来表示该字段是非空白的。默认情况下,所有字段都是可空白的。[Teacher!]! 表示该字段是一个非空白的 Teacher 对象数组。当没有数据时,该字段将返回一个空数组。

    Teacher 类型有几个字段:idfirstNamelastNameemailphonebioid 字段是 UUID 类型,它是一个表示 128 位数字的标量类型。firstNamelastNameemailphonebio 字段是 String 类型。客户端可以指定查询中要返回的字段。

  2. 让我们尝试查询数据。点击 创建文档 按钮来创建一个新的查询。你可以使用以下查询来获取所有教师:

    query {    teachers {        id        firstName        lastName        email        phone        bio    }}
    

    前面的查询将返回数据库中的所有教师,如下所示:

图 12.2 – 查询所有教师

图 12.2 – 查询所有教师

  1. 你可以在查询中添加或删除字段以指定要返回的数据。例如,为了在网页上显示教师列表,我们不需要返回 id 字段和 bio 字段。我们可以从查询中移除 bio 字段,如下所示:

    query {    teachers {        firstName        lastName        email        phone    }}
    

    前面的查询将仅返回这四个字段,从而减少了有效负载的大小。

到目前为止,我们已经使用 HotChocolate 创建了一个 GraphQL API。我们还学习了如何使用 GraphQL 查询来查询数据。接下来,我们将学习如何使用突变来修改数据。

添加突变

在前面的部分中,我们学习了如何使用 HotChocolate 创建一个 GraphQL API。我们添加了一个查询根类型来查询数据。在本节中,我们将讨论如何使用突变来修改数据。

在 GraphQL 中,突变(mutations)用于修改数据。一个突变由三个部分组成:

  • 按照惯例,在 Input 后缀之后,例如 AddTeacherInput

  • 按照惯例,在 Payload 后缀之后,例如 AddTeacherPayload

  • AddTeacherAsync

让我们添加一个突变来创建一个新的教师。我们将使用以下步骤:

  1. GraphQL/Mutations 文件夹中创建一个 AddTeacherInput 类,如下所示:

    public record AddTeacherInput(    string FirstName,    string LastName,    string Email,    string? Phone,    string? Bio);
    

    AddTeacherInput 类是一个记录类型,它定义了 AddTeacherAsync 突变的输入数据。Id 属性不包括在输入数据中,因为它将由代码生成。

  2. GraphQL/Mutations 文件夹中添加一个 AddTeacherPayload 类,如下所示:

    public class AddTeacherPayload{    public Teacher Teacher { get; }    public AddTeacherPayload(Teacher teacher)    {        Teacher = teacher;    }}
    

    AddTeacherPayload 类定义了突变执行后返回的数据。它有一个 Teacher 属性,类型为 Teacher

  3. 接下来,我们需要添加实际的突变来执行操作。将 Mutation 类添加到 GraphQL/Mutations 文件夹中,如下所示:

    public class Mutation{    public async Task<AddTeacherPayload> AddTeacherAsync(        AddTeacherInput input,        [Service] AppDbContext context)    {        var teacher = new Teacher        {            Id = Guid.NewGuid(),            FirstName = input.FirstName,            LastName = input.LastName,            Email = input.Email,            Phone = input.Phone,            Bio = input.Bio        };        context.Teachers.Add(teacher);        await context.SaveChangesAsync();        return new AddTeacherPayload(teacher);    }}
    

    Mutation 类有一个名为 AddTeacherAsync 的方法,它接受一个 AddTeacherInput 对象作为输入数据,并返回一个 AddTeacherPayload 对象。AddTeacherAsync() 方法创建一个新的 Teacher 对象并将其添加到数据库中。然后,它返回一个包含新创建的 Teacher 对象的 AddTeacherPayload 对象。

  4. 接下来,我们需要在 Program.cs 文件中注册突变。在 AddQueryType() 方法之后添加 AddMutationType 方法,如下所示:

    builder.Services    .AddGraphQLServer()    .AddQueryType<Query>()    .AddMutationType<Mutation>();
    
  5. 使用 dotnet run 运行项目并打开 GraphQL IDE。检查模式定义,你应该会看到以下突变:

    type Mutation {  addTeacher(input: AddTeacherInput!): AddTeacherPayload!}input AddTeacherInput {  firstName: String!  lastName: String!  email: String!  phone: String  bio: String}type AddTeacherPayload {  teacher: Teacher!}
    

    上述模式定义了一个名为 addTeacher 的突变,它反映了我们在 Mutation 类中定义的类型和方法。请注意,AddTeacherInput 类型是一个输入类型,因此它使用 input 关键字而不是 type

  6. 点击创建文档按钮创建一个新的查询。你可以使用以下突变来创建一个新的教师:

    mutation addTeacher {  addTeacher(    input: {      firstName: "John"      lastName: "Smith"      email: "john.smith@sampleschool.com"      phone: "1234567890"      bio: "John Smith is a math teacher."    }  ) {    teacher {      id    }  }}
    

    上述突变将创建一个新的教师并返回新创建教师的 id 属性,如下所示:

图 12.3 – 创建新的教师

图 12.3 – 创建新的教师

然后,你可以查询数据以验证新教师是否已添加到数据库中。

在查询中使用变量

在上一节中,我们学习了如何使用 GraphQL 查询和突变查询和修改数据。在本节中,我们将讨论如何在查询中使用变量。

GraphQL 允许你在查询中使用变量。当你想要向查询传递参数时,这非常有用。我们可以创建一个接受 id 参数并返回具有指定 ID 的教师的查询。按照以下步骤创建查询:

  1. Query 类中添加一个 GetTeacher() 方法,如下所示:

    public async Task<Teacher?> GetTeacher(Guid id, [Service] AppDbContext context) =>    await context.Teachers.FindAsync(id);
    

    上述代码向 Query 类添加了一个 GetTeacher() 方法。它接受一个 id 参数并返回具有指定 ID 的教师。

  2. 现在,你可以在查询中使用 $ 符号来定义变量。例如,你可以使用以下查询通过 ID 获取教师:

    query getTeacher($id: UUID!) {  teacher(id: $id) {    id    firstName    lastName    email    phone  }}
    

    上述查询定义了一个名为 idUUID! 类型的变量。! 符号表示该变量不可为空。teacher 字段接受 id 变量作为参数,并返回具有指定 ID 的教师。在 id 变量中传递值到查询,如下所示:

    {  "id": "00000000-0000-0000-0000-000000000401"}
    

你可以在查询中定义多个变量。请注意,变量必须是标量、枚举或输入对象类型。

定义 GraphQL 模式

通常,一个系统有多种数据类型。例如,学校管理系统有教师、学生、部门和课程。一个部门有多个课程,一个课程有多个学生。一位教师可以教授多个课程,一个课程也可以由多位教师教授。在本节中,我们将讨论如何定义具有多种数据类型的 GraphQL 模式。

标量类型

标量类型是 GraphQL 中的基本类型。以下表格列出了 GraphQL 中的标量类型:

标量类型 描述 .NET 类型
Int 有符号 32 位整数 int
Float 根据 IEEE 754 指定的有符号双精度浮点值 floatdouble
String UTF-8 字符序列 string
Boolean truefalse bool
ID 一个唯一的标识符,序列化为字符串 string

表 12.1 – GraphQL 中的标量类型

除了前面的标量类型之外,HotChocolate还支持以下标量类型:

  • Byte:无符号 8 位整数

  • ByteArray:编码为 Base64 字符串的字节数组

  • Short:有符号 16 位整数

  • Long:有符号 64 位整数

  • Decimal:有符号十进制值

  • Date:ISO-8601 日期

  • TimeSpan:ISO-8601 时间持续时间

  • DateTime:由社区在www.graphql-scalars.com/定义的 GraphQL 自定义标量。它基于 RFC3339。请注意,此DateTime标量使用偏移量而不是时区来表示 UTC

  • Url:URL

  • Uuid:GUID

  • Any:一个特殊类型,用于表示任何字面量或输出类型

这里没有列出更多标量类型。您可以在chillicream.com/docs/hotchocolate/v13/defining-a-schema/scalars找到有关标量类型的更多信息。

GraphQL 还支持枚举。GraphQL 中的枚举类型是一种特殊的标量类型。它们用于表示一组固定的值。.NET 很好地支持枚举类型,因此您可以直接在 GraphQL 中使用.NET enum类型。您可以如下定义枚举类型:

public enum CourseType{
    Core,
    Elective,
    Lab
}

前面的代码定义了一个名为CourseType的枚举类型,包含三个值:CoreElectiveLab。生成的 GraphQL 模式如下:

enum CourseType {  CORE
  ELECTIVE
  LAB
}

HotChocolate 会自动根据 GraphQL 规范将枚举值转换为大写。

对象类型

对象类型是 GraphQL 中最常见的类型。它可以包含简单的标量类型,如IntStringBoolean,以及其他对象类型。例如,一个Teacher类型可以包含Department类型,如下所示:

public class Teacher{
    public Guid Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public Guid DepartmentId { get; set; }
    public Department Department { get; set; } = default!;
}
public class Department
{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    // other properties
}

前面的代码定义了一个Teacher类型和一个Department类型。Teacher类型有一个Department属性,其类型为Department。HotChocolate 将生成如下模式:

type Teacher {  id: UUID!
  firstName: String!
  lastName: String!
  departmentId: UUID!
  department: Department!
}
type Department {
  id: UUID!
  name: String!
  description: String
}

如前所述,GraphQL 中的所有字段默认都是可空的。如果我们想使一个字段不可空,可以使用 ! 符号。

对象类型可以包含其他对象类型的列表。例如,Department 类型可以包含一个 Teacher 对象的列表,如下所示:

public class Department{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public List<Teacher> Teachers { get; set; } = new();
}

生成的模式如下:

type Department {  id: UUID!
  name: String!
  description: String
  teachers: [Teacher!]!
}

teachers 字段是一个包含不可空 Teacher 对象的不可空数组。如果我们想使 teachers 字段可空,可以使用以下方式使用 ? 符号:

public class Department{
    public Guid Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public List<Teacher>? Teachers { get; set; }
}

生成的模式如下:

type Department {  id: UUID!
  name: String!
  description: String
  teachers: [Teacher!]
}

上述模式意味着 teachers 字段是一个可空的、包含不可空 Teacher 对象的数组。当没有数据时,teachers 字段将返回 null

让我们回顾一下在前几节中定义的 Query 类型与 Mutation 类型:

type Query {  teachers: [Teacher!]!
  teacher(id: UUID!): Teacher
}
type Mutation {
  addTeacher(input: AddTeacherInput!): AddTeacherPayload!
}

这两种类型看起来像常规对象类型,但在 GraphQL 中有特殊含义。Query 类型与 Mutation 类型是 GraphQL 中的两种特殊对象类型,因为它们定义了 GraphQL API 的入口点。每个 GraphQL 服务都必须有一个 Query 类型,但可能有一个或没有 Mutation 类型。所以 teachers 查询实际上是 Query 类型的字段,就像 teacher 类型中的 department 字段一样。变更操作以相同的方式工作。

到目前为止,GraphQL 类型与 C# 类型相似。如果你熟悉面向对象编程,你应该能够轻松理解 GraphQL 类型。类似于 C#,GraphQL 也支持接口。但在我们深入接口之前,让我们讨论一下在查询 Teacher 对象时如何检索 Department 对象。

使用解析器检索相关对象

在前几节中,我们定义了一个 Teacher 类型和一个 Department 类型。Teacher 类型有一个 Department 属性,其类型为 Department。在查询 Teacher 对象时,我们可能还想检索 Department 对象。我们该如何做呢?

你可能会认为我们可以使用 Include() 方法来检索 Department 对象,如下所示:

public async Task<List<Teacher>> GetTeachers([Service] AppDbContext context) =>    await context.Teachers.Include(x => x.Department).ToListAsync();

然后,你可以按照以下方式查询 Department 对象:

query{  teachers{
    id
    firstName
    lastName
    department{
      id
      name
      description
    }
  }
}

这确实可行,你将看到以下结果:

{  "data": {
    "teachers": [
      {
        "id": "00000000-0000-0000-0000-000000000401",
        "firstName": "John",
        "lastName": "Doe",
        "department": {
          "id": "00000000-0000-0000-0000-000000000001",
          "name": "Mathematics",
          "description": "Mathematics Department"
        }
      },
      {
        "id": "00000000-0000-0000-0000-000000000402",
        "firstName": "Jane",
        "lastName": "Doe",
        "department": {
          "id": "00000000-0000-0000-0000-000000000001",
          "name": "Mathematics",
          "description": "Mathematics Department"
        }
      }
    ]
  }
}

但这不是最好的方法。记住,GraphQL 允许客户端指定他们需要的数据。如果查询没有指定 department 字段,Department 对象仍然会从数据库中检索。这并不高效。我们只应在查询中指定了 department 字段时才检索 Department 对象。这引出了解析器的概念。

解析器是一个函数,用于从某个地方检索特定字段的数据。当查询中请求字段时,解析器将被执行。解析器可以从数据库、Web API 或任何其他数据源获取数据。它将钻入图以检索字段的数据。例如,当在teachers查询中请求department字段时,解析器将从数据库中检索Department对象。但是,当查询未指定department字段时,解析器将不会执行。这可以避免不必要的数据库查询。

字段解析器

HotChocolate 支持三种定义模式的方式:

  • Get前缀或Async后缀,这些前缀或后缀将从名称中移除。

  • 代码优先:这种方式允许你使用显式类型和解析器来定义模式。它使用 Fluent API 来定义模式的详细信息。当你需要自定义模式时,这种方法更加灵活。

  • 模式优先:这种方式允许你使用 GraphQL 模式定义语言来定义模式。如果你熟悉 GraphQL,你可以使用这种方法直接定义模式。

由于我们的读者大多是.NET 开发者,我们将在本章的其余部分使用代码优先的方法来定义模式,这样我们可以利用 Fluent API 来微调模式。

让我们回顾一下上一节中定义的teacher查询:

public async Task<Teacher?> GetTeacher(Guid id, [Service] AppDbContext context) =>   await context.Teachers.FindAsync(id);

上面的方法是基于注解的。HotChocolate 自动将GetTeacher()方法转换为名为teacher的解析器。接下来,我们希望在请求department字段时检索Department对象。让我们按照以下步骤进行一些更改:

  1. 首先,我们需要将TeacherType类定义为 GraphQL 对象。在Types文件夹中创建一个TeacherType类。代码如下:

    public class TeacherType : ObjectType<Teacher>{    protected override void Configure(IObjectTypeDescriptor<Teacher> descriptor)    {        descriptor.Field(x => x.Department)            .Name("department")            .Description("This is the department to which the teacher belongs.")            .Resolve(async context =>            {                var department = await context.Service<AppDbContext>().Departments.FindAsync(context.   Parent<Teacher>().DepartmentId);                return department;            });    }}
    

    TeacherType类继承自ObjectType<Teacher>类,该类有一个Configure()方法来配置 GraphQL 对象并指定如何解析字段。在上面的代码中,我们使用代码优先的方法来定义TeacherTypeDepartment字段。Name方法用于指定字段的名称。如果字段的名称与遵循约定的属性名称相同,我们可以省略Name方法。按照约定,Department字段将转换为模式中的department字段。然后,我们使用Description方法来定义字段的描述。描述将在 GraphQL IDE 中显示。

    然后,我们使用Resolve()方法来定义解析器。解析器通过使用Teacher对象的DepartmentId属性从数据库中检索Department对象。请注意,我们使用context.Parent<Teacher>()方法来获取Teacher对象,因为Teacher对象是Department对象的父对象。

  2. 如我们所知,Query 类型是一个特殊对象类型,因此我们也将创建一个 QueryType 类。在 GraphQL 文件夹中创建一个新的 Types 文件夹,并将 Query.cs 文件移动到这个 Types 文件夹中。

  3. 移除 GetTeacher() 方法并添加一个属性,如下所示:

    public class Query{    // Omitted for brevity    public TeacherType? Teacher { get; set; } = new();}
    
  4. 创建一个名为 QueryType 的新类,如下所示:

    public class QueryType : ObjectType<Query>{    protected override void Configure(IObjectTypeDescriptor<Query> descriptor)    {        descriptor.Field(x => x.Teacher)            .Name("teacher")            .Description("This is the teacher in the school.")            .Type<TeacherType>()            .Argument("id", a => a.Type<NonNullType<UuidType>>())            .Resolve(async context =>            {                var id = context.ArgumentValue<Guid>("id");                var teacher = await context.Service<AppDbContext>().Teachers.FindAsync(id);                return teacher;            });    }}
    

    上述代码定义了根查询类型。在这个查询类型中,我们指定字段的类型为 TeacherType。接下来,我们使用 Argument() 方法定义 id 参数,它是一个不可为空的 UUID 类型。然后,我们使用 Resolve() 方法定义解析器。解析器接收 id 参数并从数据库中检索 Teacher 对象。请注意,AppDbContext 是从 context 对象注入到解析器中的。

  5. 接下来,我们需要更新 Program.cs 文件以注册 QueryType。按照以下方式更新 Program.cs 文件:

    builder.Services    .AddGraphQLServer()    .AddQueryType<QueryType>()    .AddMutationType<Mutation>();
    

    我们使用 QueryType 替换了之前定义的 Query 类型,这样我们就可以在请求 department 字段时使用解析器来检索 Department 对象。

  6. 现在,我们可以测试解析器。使用 dotnet run 运行应用程序,并发送以下请求来查询一个教师。

    这是 GraphQL 请求:

    query ($id: UUID!) {  teacher(id: $id) {    firstName    lastName    email    department {      name      description    }  }}
    {  "id": "00000000-0000-0000-0000-000000000401"}
    

    你将在响应中看到部门信息。此外,如果你检查日志,你将看到从数据库中检索了 Department 对象。如果你从查询中移除 department 字段,你将只在日志中看到一个数据库查询,这意味着 GraphQL 不会从数据库中获取 Department 对象。

在这个示例中,我们使用委托方法定义了一个解析器。我们也可以在单独的类中定义解析器。例如,我们可以定义一个 TeacherResolver 类,如下所示:

public class TeacherResolvers{
    public async Task<Department> GetDepartment([Parent] Teacher teacher, [Service] IDbContextFactory<AppDbContext> dbContextFactory)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync();
        var department = await dbContext.Departments.FindAsync(teacher.DepartmentId);
        return department;
    }
}

上述代码定义了一个 GetDepartment() 方法,它接受一个 Teacher 对象作为父对象并返回 Department 对象。然后,我们可以使用 ResolveWith() 方法在 TeacherType 类中定义解析器,如下所示:

descriptor.Field(x => x.Department)    .Description("This is the department to which the teacher belongs.")
    .ResolveWith<TeacherResolvers>(x => x.GetDepartment(default, default));

现在,解析器的逻辑被移动到了一个单独的类中。当解析器复杂时,这种方法更加灵活。但对于简单的解析器,我们可以直接使用委托方法。

到目前为止,一切运行良好。让我们在下一节尝试使用相同的方法更新 GetTeachers 方法。

对象列表的解析器

同样,我们可以使用 ListType<TeacherType> 来定义 teachers 字段,然后使用 Resolve() 方法定义解析器。ListType 类是用于流畅的代码优先 API 的包装类型。它用于定义对象列表。在 Query 类中移除 GetTeachers() 方法,并添加一个属性,如下所示:

public class Query{
    // Omitted for brevity
    public List<TeacherType> Teachers { get; set; } = new();
}

然后,按照以下方式在 QueryType 类中配置 Teachers 字段:

public class QueryType : ObjectType<Query>{
    protected override void Configure(IObjectTypeDescriptor<Query> descriptor)
    {
        descriptor.Field(x => x.Teachers)
            .Name("teachers") // This configuration can be omitted if the name of the field is the same as the name of the property.
            .Description("This is the list of teachers in the school.")
            .Type<ListType<TeacherType>>()
            .Resolve(async context =>
            {
                var teachers = await context.Service<AppDbContext>().Teachers.ToListAsync();
                return teachers;
            });
        // Omitted for brevity
    }
}

上述代码定义了 QueryTypeTeachers 字段。它使用 ListType<TeacherType> 来定义一个 TeacherType 的列表。然后,它使用 Resolve() 方法来定义解析器。解析器从数据库中检索所有 Teacher 对象。这段代码与之前定义的 teacher 字段类似。然而,它检索的是 TeacherType 对象的列表而不是单个 TeacherType 对象。由于 TeacherTypeDepartment 字段有一个解析器,我们可以为每个 TeacherType 对象检索 Department 对象。

现在,我们可以使用以下查询测试 teachers 字段:

query{  teachers{
    id
    firstName
    lastName
    department{
      id
      name
      description
    }
  }
}

然而,你可能会在响应中遇到错误。一些教师可以正确检索,但一些可能无法检索。错误信息如下:

{  "errors": [
    {
      "message": "Unexpected Execution Error",
      "locations": [
        {
          "line": 6,
          "column": 5
        }
      ],
      "path": [
        "teachers",
        7,
        "department"
      ],
      "extensions": {
        "message": "A second operation was started on this context instance before a previous operation completed. This is usually caused by different threads concurrently using the same instance of DbContext. For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.",
        ...
      }
    },
  ]
}

这是因为我们有多個解析器会并发执行数据库查询。然而,AppDbContext 被注册为作用域服务,并且 AppDbContext 类不是线程安全的。当多个解析器尝试并行查询数据库时,它们将使用相同的 AppDbContext 实例,这会导致错误。

为了解决这个问题,我们需要确保解析器不会并发访问相同的 AppDbContext 实例。有两种方法可以实现这一点。一种是将解析器顺序执行,另一种是为每个解析器使用单独的 AppDbContext 实例。HotChocolate 提供了一个 RegisterDbContext<TDbContext>() 方法来管理解析器的 DbContext。为了使用这个功能,我们需要使用以下命令安装一个名为 HotChocolate.Data.EntityFramework 的 NuGet 包:

Program.cs file to register the AppDbContext class, as follows:

builder.Services    .AddGraphQLServer()

.RegisterDbContext()

// 省略以节省篇幅


The preceding code allows HotChocolate to manage the lifetime of `AppDbContext` for resolvers.
The `RegisterDbContext<TDbContext>()` method can specify how `DbContext` should be injected. There are three options:

*   `DbContextKind.Synchronized`: This is to ensure that `DbContext` is never used concurrently. `DbContext` is still injected as a scoped service.
*   `DbContextKind.Resolver`: This way will resolve the scoped `DbContext` for each resolver. This option is the default configuration. From the perspective of the resolver, `DbContext` is a transient service, so HotChocolate can execute multiple resolvers concurrently without any issues.
*   `DbContextKind.Pooled`: This mechanism will create a pool of `DbContext` instances. It leverages the `DbContextPool` feature of EF Core. HotChocolate will resolve `DbContext` from the pool for each resolver. When the resolver is completed, `DbContext` will be returned to the pool. In this way, `DbContext` is also like a transient service for each resolver, so HotChocolate can parallelize the resolvers as well.

To demonstrate how to benefit from the pooled `DbContext`, we will use the `DbContextKind.Pooled` option. This approach requires a couple of additional steps:

1.  First, we need to register `DbContext` using the `AddPooledDbContextFactory()` method instead of the `AddDbContext()` method. Update the `Program.cs` file as follows:

    ```

    builder.Services.AddPooledDbContextFactory<AppDbContext>(options =>    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));// 注册 GraphQL 服务

    ```cs

    The preceding code registers `AppDbContext` as a pooled service using the `AddPooledDbContextFactory()` method. Then, we use the `RegisterDbContext()` method to register `AppDbContext` as a pooled service for HotChocolate resolvers.

     2.  Update the `Configure()` method in the `QueryType` file to use the pooled `DbContext`:

    ```

    descriptor.Field(x => x.Teachers)    // 省略以节省篇幅    .Type<ListType<TeacherType>>()    .Resolve(async context =>    {        var dbContextFactory = context.Service<IDbContextFactory<AppDbContext>>();        await using var dbContext = await dbContextFactory.CreateDbContextAsync();        var teachers = await dbContext.Teachers.ToListAsync();        return teachers;    });

    ```cs

    The preceding code uses `IDbContextFactory<TDbContext>` to create a new `AppDbContext` instance for each resolver. Then, it retrieves the `Teacher` objects from the database using the new `AppDbContext` instance.

    One thing to note is that we need to use the `await using` statement to dispose of the `AppDbContext` instance after the resolver is completed in order to return the `AppDbContext` instance to the pool.

     3.  Update the other resolvers as well. For example, the resolver of the `Teacher` type looks like this:

    ```

    descriptor.Field(x => x.Department)    .Description("这是教师所属的部门。")    .Resolve(async context =>    {        var dbContextFactory = context.Service<IDbContextFactory<AppDbContext>>();        await using var dbContext = await dbContextFactory.CreateDbContextAsync();        var department = await dbContext.Departments            .FindAsync(context.Parent<Teacher>().DepartmentId);        return department;    });

    ```cs

    Now, we can test the `teachers` field again. You will see that all the teachers with the department information can be retrieved correctly.

So, everything looks good. But wait. If you check the logs, you will find that there are many database queries for each `Department` object:
![Figure 12.4 – Database queries for each Department object](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/webapi-dev-aspdn-cr8/img/B18971_12_04.jpg)

Figure 12.4 – Database queries for each Department object
What is the reason behind this? Let us find out in the next section.
Using data loaders
In the previous section, we learned how to integrate HotChocolate with EF Core. We also learned how to use the `DbContextPool` feature to fetch data in multiple resolvers. However, we found that there are many database queries for each `Department` object in the `Teacher` list. That is because the resolvers for each `Department` object are executed separately, querying the database by each `DepartmentId` property in the list. This is similar to the *N+1* problem we discussed in *Chapter 1*. The difference is that the *N+1* problem occurs on the client side in REST APIs, while it occurs on the server side in GraphQL. To solve this problem, we need to find a way to load the batch data efficiently.
HotChocolate provides a `DataLoader` mechanism to solve the *N+1* problem. The data loader fetches data in batches from the data source. Then, the resolver can retrieve the data from the data loader, rather than querying the data source directly. The data loader will cache the data for the current request. If the same data is requested again, the resolver can retrieve the data from the data loader directly. This can avoid unnecessary database queries.
Before we learn how to use the data loader to solve the *N+1* problem, let's prepare the examples. We already have a `Teachers` query to query the list of teachers, and each teacher has a `Department` object. Now, we want to add a `Departments` query to query the list of departments, and each department has a list of teachers. We will use the following steps to add the `Departments` query:

1.  The `Department` type is defined as follows:

    ```

    public class Department{    public Guid Id { get; set; }    public string Name { get; set; } = string.Empty;    public string? Description { get; set; }    public List<Teacher> Teachers { get; set; } = new();}

    ```cs

     2.  The `Department` class has a list of `Teacher` objects. Following the convention, we can define a `DepartmentType` class as follows:

    ```

    public class DepartmentType : ObjectType<Department>{    protected override void Configure(IObjectTypeDescriptor<Department> descriptor)    {        descriptor.Field(x => x.Teachers)            .Description("这是该部门教师列表的描述.")            .Type<ListType<TeacherType>>()            .ResolveWith<DepartmentResolvers>(x => x.GetTeachers(default, default));    }}public class DepartmentResolvers{    public async Task<List<Teacher>> GetTeachers([Parent] Department department,        [Service] IDbContextFactory<AppDbContext> dbContextFactory)    {        await using var dbContext = await dbContextFactory.CreateDbContextAsync();        var teachers = await dbContext.Teachers.Where(x => x.DepartmentId == department.Id).ToListAsync();        return teachers;    }}

    ```cs

    The preceding code is similar to `TeacherType`, which we defined previously. `DepartmentType` has a `Teachers` field of the `ListType<TeacherType>` type. Then, we use the `ResolveWith()` method to define the resolver. The resolver retrieves the `Teacher` objects from the database using the `DepartmentId` property of the `Department` object.

     3.  Add a new field in the `Query` class, as follows:

    ```

    public class Query{    // 省略以节省篇幅    public List<DepartmentType> Departments { get; set; } = new();}

    ```cs

     4.  Then, configure the `Departments` field in the `QueryType` as follows:

    ```

    descriptor.Field(x => x.Departments)    .Description("这是该学校部门列表的描述.")    .Type<ListType<DepartmentType>>()    .Resolve(async context =>    {        var dbContextFactory = context.Service<IDbContextFactory<AppDbContext>>();        await using var dbContext = await dbContextFactory.CreateDbContextAsync();        var departments = await dbContext.Departments.ToListAsync();        return departments;    });

    query{   departments{    id    name    description    teachers{      id      firstName      lastName      bio    }   }}

    ```cs

    If you check the logs, you will find that the following database queries are executed multiple times:

    ```

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (3ms) [Parameters=[@__department_Id_0='?' (DbType = Guid)], CommandType='Text', CommandTimeout='30']      SELECT [t].[Id], [t].[Bio], [t].[DepartmentId], [t].[Email], [t].[FirstName], [t].[LastName], [t].[Phone]      FROM [Teachers] AS [t]      WHERE [t].[DepartmentId] = @__department_Id_0

    ```cs

The preceding query is executed for each `Department` object. This is also an *N+1* problem, as we discussed previously. Next, we will use the data loader to solve these *N+1* problems.
Batch data loader
First, let's optimize the `teachers` query. To retrieve the `teachers` data with the department information, we want to execute two SQL queries only. One is to retrieve the `teachers` data, and the other is to retrieve the department data. Then, HotChocolate should be able to map the `department` data to the teachers in memory, instead of executing a SQL query for each teacher.
Follow these steps to use the data loader:

1.  Create a folder named `DataLoaders` in the `GraphQL` folder, then create a new `DepartmentByTeacherIdBatchDataLoader` class, as follows:

    ```

    public class DepartmentByTeacherIdBatchDataLoader(       IDbContextFactory<AppDbContext> dbContextFactory,       IBatchScheduler batchScheduler,       DataLoaderOptions? options = null)       : BatchDataLoader<Guid, Department>(batchScheduler, options){    protected override async Task<IReadOnlyDictionary<Guid, Department>> LoadBatchAsync(IReadOnlyList<Guid>    keys,        CancellationToken cancellationToken)    {        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);        var departments = await dbContext.Departments.Where(x => keys.Contains(x.Id))            .ToDictionaryAsync(x => x.Id, cancellationToken);        return departments;    }}

    ```cs

    The preceding code defines a data loader to fetch the batch data for the `Department` object. The parent resolver, which is the `teachers` query, will get a list of `Teacher` objects. Each `Teacher` object has a `DepartmentId` property. `DepartmentByTeacherIdBatchDataLoader` will fetch the `Department` objects for the `DepartmentId` values in the list. The list of the `Department` objects will be converted to a dictionary. The key of the dictionary is the `DepartmentId` property and the value is the `Department` object. Then, the parent resolver can map the `Department` object to the `Teacher` object in memory.

     2.  Update the `TeacherResolvers` class as follows:

    ```

    public class TeacherResolvers{    public async Task<Department> GetDepartment([Parent] Teacher teacher,        DepartmentByTeacherIdBatchDataLoader departmentByTeacherIdBatchDataLoader, CancellationToken cancellationToken)    {        var department = await departmentByTeacherIdBatchDataLoader.LoadAsync(teacher.DepartmentId,    cancellationToken);        return department;    }}

    ```cs

    Instead of querying the database directly, the resolver uses `DepartmentByTeacherIdBatchDataLoader` to fetch the `Department` object for the `DepartmentId` property of the `Teacher` object. The `DepartmentByTeacherIdBatchDataLoader` will be injected by HotChocolate automatically.

     3.  Run the application and test the `teachers` query again. Now, you will see only two SQL queries are executed:

    ```

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (108ms) [Parameters=[], CommandType='Text', CommandTimeout='30']      SELECT [t].[Id], [t].[Bio], [t].[DepartmentId], [t].[Email], [t].[FirstName], [t].[LastName], [t].[Phone]      FROM [Teachers] AS [t]info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (73ms) [Parameters=[@__keys_0='?' (Size = 4000)], CommandType='Text',    CommandTimeout='30']      SELECT [d].[Id], [d].[Description], [d].[Name]      FROM [Departments] AS [d]      WHERE [d].[Id] IN (          SELECT [k].[value]          FROM OPENJSON(@__keys_0) WITH ([value] uniqueidentifier '$') AS [k]      )

    ```cs

    As we see, the first query is to get the list of the teachers, and the second query is to use the `IN` clause to query the departments that match the `DepartmentId` values in the list. This is much more efficient than the previous approach.

As it fetches the batch data for the `Department` object, it is called a batch data loader. This data loader is often used for one-to-one relationships, such as one `Teacher` object has one `Department` object. Note that this one-to-one relationship is not the same as the one-to-one relationship in the database. In GraphQL, the one-to-one relationship means that one object has one child object.
Group data loader
Next, let's optimize the `departments` query. In this case, one `Department` object has a list of `Teacher` objects. We can use the group data loader to fetch the `Teacher` objects for each `Department` object. The group data loader is similar to the batch data loader. The difference is that the group data loader fetches a list of objects for each key. The batch data loader fetches a single object for each key.
Follow these steps to use the group data loader:

1.  Create a `TeachersByDepartmentIdDataLoader` class in the `DataLoaders` folder, and add the following code:

    ```

    public class TeachersByDepartmentIdDataLoader(       IDbContextFactory<AppDbContext> dbContextFactory,       IBatchScheduler batchScheduler,       DataLoaderOptions? options = null)       : GroupedDataLoader<Guid, Teacher>(batchScheduler, options){    protected override async Task<ILookup<Guid, Teacher>> LoadGroupedBatchAsync(IReadOnlyList<Guid> keys,        CancellationToken cancellationToken)    {        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);        var teachers = await dbContext.Teachers.Where(x => keys.Contains(x.DepartmentId))            .ToListAsync(cancellationToken);        return teachers.ToLookup(x => x.DepartmentId);    }}

    ```cs

    The preceding code defines a group data loader, which returns an `ILookup<Guid, Teacher>` object in the `LoadGroupedBatchAsync()` method. The `ILookup<Guid, Teacher>` object is similar to a dictionary. The key of the dictionary is the `DepartmentId` property and the value is a list of `Teacher` objects. The parent resolver can map the `Teacher` objects to the `Department` object in memory.

     2.  Update the `DepartmentResolvers` class as follows:

    ```

    public class DepartmentResolvers{    public async Task<List<Teacher>> GetTeachers([Parent] Department department,        TeachersByDepartmentIdDataLoader teachersByDepartmentIdDataLoader, CancellationToken cancellationToken)    {        var teachers = await teachersByDepartmentIdDataLoader.LoadAsync(department.Id, cancellationToken);        return teachers.ToList();    }}

    ```cs

    The preceding code uses `TeachersByDepartmentIdDataLoader` to fetch the `Teacher` objects for the `Department` object. `TeachersByDepartmentIdDataLoader` will be injected by HotChocolate automatically.

     3.  Run the application and test the `departments` query again. Now, you will see only two SQL queries are executed:

    ```

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (38ms) [Parameters=[], CommandType='Text', CommandTimeout='30']      SELECT [d].[Id], [d].[Description], [d].[Name]      FROM [Departments] AS [d]info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (36ms) [Parameters=[@__keys_0='?' (Size = 4000)], CommandType='Text',    CommandTimeout='30']      SELECT [t].[Id], [t].[Bio], [t].[DepartmentId], [t].[Email], [t].[FirstName], [t].[LastName], [t].[Phone]      FROM [Teachers] AS [t]      WHERE [t].[DepartmentId] IN (          SELECT [k].[value]          FROM OPENJSON(@__keys_0) WITH ([value] uniqueidentifier '$') AS [k]      )

    ```cs

    That is exactly what we want. The first query is to get the list of the departments, and the second query is to use the `IN` clause to query the teachers that match the `DepartmentId` values in the list. This approach reduces the number of database queries significantly.

In this case, each `Department` object has a list of `Teacher` objects, so this kind of data loader is called a group data loader. It is often used for one-to-many relationships, such as one `Department` object has a list of `Teacher` objects.
HotChocolate supports cache data loader as well. It also supports using multiple data loaders in a resolver. As they are not used often, we will not discuss them in this chapter. You can refer to the documentation for more details: [`github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter12/start`](https://github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter12/start).
Dependency injection
In the previous code examples, we use `IDbContextFactory<AppDbContext>` and `AppDbContext` directly in the resolvers. In order to encapsulate our data access logic, we can add a service layer to implement our business logic. HotChocolate supports dependency injection for resolvers. In this section, we will learn how to inject other services into the resolvers.
To demonstrate how to use dependency injection in HotChocolate, we will add an interface named `ITeacherService` and a class named `TeacherService`, as follows:

public interface ITeacherService{

Task GetDepartmentAsync(Guid departmentId);

Task<List> GetTeachersAsync();

Task GetTeacherAsync(Guid teacherId);

// 省略以节省篇幅

}

public class TeacherService(IDbContextFactory contextFactory) : ITeacherService

{

public async Task GetDepartmentAsync(Guid departmentId)

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var department = await dbContext.Departments.FindAsync(departmentId);

return department ?? throw new ArgumentException("Department not found", nameof(departmentId));

}

public async Task<List> GetTeachersAsync()

{

等待使用 var dbContext = await contextFactory.CreateDbContextAsync();

var teachers = await dbContext.Teachers.ToListAsync();

return teachers;

}

public async Task GetTeacherAsync(Guid teacherId)

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var teacher = await dbContext.Teachers.FindAsync(teacherId);

return teacher ?? throw new ArgumentException("Teacher not found", nameof(teacherId));

}

// 省略以节省篇幅

}


The preceding code encapsulates the data access logic in the `TeacherService` class. Then, we need to register `ITeacherService` in the `Program.cs` file, as follows:

builder.Services.AddScoped<ITeacherService, TeacherService>();


 HotChocolate uses the same approach to register the services as ASP.NET Core, but injecting the services is a little different. In ASP.NET Core, we can inject the services into the controller constructor, while HotChocolate does not recommend constructor injection. Instead, HotChocolate recommends using the method-level injection. First, the GraphQL type definitions are singleton objects. If we use constructor injection, the services will be injected as singleton objects as well. This is not what we want. Second, sometimes HotChocolate needs to synchronize the resolvers to avoid concurrency issues. If we use constructor injection, HotChocolate cannot control the lifetime of the services. Note that this applies to the HotChocolate GraphQL types and resolvers only. For other services, we can still use constructor injection.
Let us see how to use the method-level injection.
Using the Service attribute
We can use `HotChocolate.ServiceAttribute` to inject services into the resolvers. For example, we can add a `GetTeachersWithDI` method in the `Query` class as follows:

public async Task<List> GetTeachersWithDI([Service] ITeacherService teacherService) =>    await teacherService.GetTeachersAsync();


Note that the `Service` attribute is from the `HotChocolate` namespace, not the `Microsoft.AspNetCore.Mvc` namespace. With this attribute, `ITeacherService` will be injected into the `teacherService` parameter automatically.
If we have many services in the project, using the attribute for each service is tedious. HotChocolate provides a `RegisterServices()` method to simplify the injection. We can update the `Program.cs` file as follows:

builder.Services    .AddGraphQLServer()

.RegisterDbContext(DbContextKind.Pooled)

.RegisterService()

.AddQueryType()

.AddMutationType();


Now, we can remove the `Service` attribute from the `GetTeachersWithDI()` method. HotChocolate can still inject `ITeacherService` automatically, as shown here:

public async Task<List> GetTeachersWithDI(ITeacherService teacherService) =>    await teacherService.GetTeachersAsync();


This will save us a lot of time.
Understanding the lifetime of the injected services
We have learned that, in ASP.NET Core, we can inject the services as singleton, scoped, or transient services. HotChocolate offers more options for the lifetime of the injected services. When we use the `Service` attribute or the `RegisterService()` method to inject the services, we can specify the `ServiceKind` property to control the lifetime of the services. The `ServiceKind` has the following options:

*   `ServiceKind.Default`: This is the default option. The service will be injected as the same lifetime in the registered service in the DI container.
*   `ServiceKind.Synchronized`: This option is similar to the synchronized `DbContext`. The resolver using the service will be executed sequentially. The synchronization only happens in the same request scope.
*   `ServiceKind.Resolver`: This option is to resolve the service for each resolver scope. The service will be disposed of after the resolver is completed.
*   `ServiceKind.Pooled`: This option is similar to the pooled `DbContext`. The service needs to be registered as an `ObjectPool<T>` instance in the ASP.NET Core DI container. The resolver will get the service from the pool and return it to the pool after the resolver is completed.

To specify the `ServiceKind` for the injected services, we can add a `ServiceKind` parameter in the `Service` attribute or the `RegisterService()` method. For example, we can update the `GetTeachersWithDI()` method as follows:

public async Task<List> GetTeachersWithDI([Service(ServiceKind.Resolver)] ITeacherService teacherService) =>    await teacherService.GetTeachersAsync();


The preceding code specifies the `ServiceKind` as `ServiceKind.Resolver`. So, `ITeacherService` will be resolved for each resolver scope.
If we use the `RegisterServices()` method to register the services, we can specify the `ServiceKind` in the `RegisterServices()` method, as follows:

builder.Services    .AddGraphQLServer()

.RegisterDbContext(DbContextKind.Pooled)

.RegisterService(ServiceKind.Resolver)

.AddQueryType()

.AddMutationType();


To inject the services in the `Resolve()` method, we can get the service from the `context` object, as follows:

descriptor.Field(x => x.Teachers)    .Description("This is the list of teachers in the school.")

.Type<ListType>()

.Resolve(async context =>

{

var teacherService = context.Service();

var teachers = await teacherService.GetTeachersAsync();

return teachers;

});


The preceding code uses the `context.Service<T>()` method to get `ITeacherService` from the `context` object, which is similar to injecting `IDbContextFactory<AppDbContext>` in the previous examples.
Interface and union types
HotChocolate supports the use of interfaces and union types in GraphQL. In this section, we will explore how to incorporate these features into your GraphQL schema. Interfaces provide a way to group types that share common fields, while union types allow for the creation of a single type that can return different object types. With HotChocolate, you can easily implement these features to enhance the functionality of your GraphQL schema.
Interfaces
To prepare the examples of GraphQL interfaces, we have an `ISchoolRoom` interface and two classes that implement the interface, as follows:

public interface ISchoolRoom{

Guid Id { get; set; }

string Name { get; set; }

string? Description { get; set; }

public int Capacity { get; set; }

}

public class LabRoom : ISchoolRoom

{

public Guid Id { get; set; }

public string Name { get; set; } = string.Empty;

public string? Description { get; set; }

public int Capacity { get; set; }

public string Subject { get; set; } = string.Empty;

public string Equipment { get; set; } = string.Empty;

public bool HasChemicals { get; set; }

}

public class Classroom : ISchoolRoom

{

public Guid Id { get; set; }

public string Name { get; set; } = string.Empty;

public string? Description { get; set; }

public int Capacity { get; set; }

public bool HasComputers { get; set; }

public bool HasProjector { get; set; }

public bool HasWhiteboard { get; set; }

}


The two classes both implement the `ISchoolRoom` interface, but they have some different properties. You can find the model classes and the model configurations in the sample code.
The service layer is defined in the `ISchoolRoomService` interface and the `SchoolRoomService` class, as follows:

public interface ISchoolRoomService{

异步获取学校教室列表:Task<List<ISchoolRoom>> GetSchoolRoomsAsync();

异步获取实验室教室列表:Task<List<LabRoom>> GetLabRoomsAsync();

异步获取教室列表:Task<List<Classroom>> GetClassroomsAsync();

异步获取实验室教室:Task<LabRoom> GetLabRoomAsync(Guid labRoomId);

异步获取教室:Task<Classroom> GetClassroomAsync(Guid classroomId);

}

public class SchoolRoomService(IDbContextFactory<AppDbContext> contextFactory) : ISchoolRoomService

{

public async Task<List<ISchoolRoom>> GetSchoolRoomsAsync()

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var labRooms = await dbContext.LabRooms.ToListAsync();

var classrooms = await dbContext.Classrooms.ToListAsync();

var schoolRooms = new List<ISchoolRoom>();

schoolRooms.AddRange(labRooms);

schoolRooms.AddRange(classrooms);

return schoolRooms;

}

// 省略以节省篇幅

}


The `GetSchoolRoomsAsync()` method retrieves a list of `LabRoom` objects and a list of `Classroom` objects from the database. Then, it combines the two lists into a single list of `ISchoolRoom` objects.
We also need to register `ISchoolRoomService` in the `Program.cs` file, as follows:

builder.Services.AddScoped<ISchoolRoomService, SchoolRoomService>();


 To define an interface in HotChocolate, we need to use the `InterfaceType<T>` class. The `InterfaceType<T>` class is used to define an interface type in the schema. Follow these steps to define an interface type using the code-first API:

1.  Create a class named `SchoolRoomType` in the `Types` folder:

    ```

    `public class SchoolRoomType : InterfaceType<ISchoolRoom>{    protected override void Configure(IInterfaceTypeDescriptor<ISchoolRoom> descriptor)    {        descriptor.Name("SchoolRoom");    }}`

    ```cs

    The preceding code defines an interface type for the `ISchoolRoom` interface.

     2.  Create two new classes for `LabRoom` and `Classroom`, as follows:

    ```

    `public class LabRoomType : ObjectType<LabRoom>{    protected override void Configure(IObjectTypeDescriptor<LabRoom> descriptor)    {        descriptor.Name("LabRoom");        descriptor.Implements<SchoolRoomType>();    }}public class ClassroomType : ObjectType<Classroom>{    protected override void Configure(IObjectTypeDescriptor<Classroom> descriptor)    {        descriptor.Name("Classroom");        descriptor.Implements<SchoolRoomType>();    }}`

    ```cs

    In the preceding code, we use the `Implements()` method to specify the interface implemented by the object type.

     3.  Add a query field in the `Query` class:

    ```

    `public List<SchoolRoomType> SchoolRooms { get; set; } = new();`

    ```cs

     4.  Configure the `SchoolRooms` field in the `QueryType` class:

    ```

    `descriptor.Field(x => x.SchoolRooms)    .Description("This is the list of school rooms in the school.")    .Type<ListType<SchoolRoomType>>()    .Resolve(async context =>    {        var service = context.Service<ISchoolRoomService>();        var schoolRooms = await service.GetSchoolRoomsAsync();        return schoolRooms;    });`

    ```cs

    In the preceding code, we use the `Service()` method to get `ISchoolRoomService` from the `context` object. Then, we use the `GetSchoolRoomsAsync()` method to retrieve the list of `ISchoolRoom` objects. The result includes both `LabRoom` and `Classroom` objects.

     5.  Next, we need to explicitly register `LabRoomType` and `ClassroomType` in `SchemaBuilder`. Update the `Program.cs` file as follows:

    ```

    `builder.Services    .AddGraphQLServer()    .RegisterDbContext<AppDbContext>(DbContextKind.Pooled)    .RegisterService<ITeacherService>(ServiceKind.Resolver)    .AddQueryType<QueryType>()    .AddType<LabRoomType>()    .AddType<ClassroomType>()    .AddMutationType<Mutation>();`

    ```cs

     6.  Run the application and check the generated schema. You will find the interface definition and its implementations, as shown here:

    ```

    类型查询:`type Query {  """  This is the list of school rooms in the school.  """  schoolRooms: [SchoolRoom]}type LabRoom implements SchoolRoom {  id: UUID!  name: String!  description: String  capacity: Int!  subject: String!  equipment: String!  hasChemicals: Boolean!}type Classroom implements SchoolRoom {  id: UUID!  name: String!  description: String  capacity: Int!  hasComputers: Boolean!  hasProjector: Boolean!  hasWhiteboard: Boolean!}`

    ```cs

     7.  Next, we can use the `SchoolRoom` interface to query both the `LabRoom` and `Classroom` objects. For example, we can use the following query to retrieve the `LabRoom` objects:

    ```

    查询:`query {  schoolRooms {    __typename    id    name    description    capacity    ... on LabRoom {      subject      equipment      hasChemicals    }    ... on Classroom {      hasComputers      hasProjector      hasWhiteboard    }  }}`

    {  "data": {    "schoolRooms": [      {        "__typename": "LabRoom",        "id": "00000000-0000-0000-0000-000000000501",        "name": "Chemistry Lab",        "description": "Chemistry Lab",        "capacity": 20,        "subject": "Chemistry",        "equipment": "Chemicals, Beakers, Bunsen Burners",        "hasChemicals": true      },      {        "__typename": "Classroom",        "id": "00000000-0000-0000-0000-000000000601",        "name": "Classroom 1",        "description": "Classroom 1",        "capacity": 20,        "hasComputers": true,        "hasProjector": false,        "hasWhiteboard": true      },      ...    ]  }}

    ```cs

    In the response, you can see that the `LabRoom` object has the `subject`, `equipment`, and `hasChemicals` properties, while the `Classroom` object has the `hasComputers`, `hasProjector`, and `hasWhiteboard` properties. This can be helpful when we want to query complex objects with different properties.

Although interfaces provide flexibility for querying objects with different properties, we need to note that interfaces can be used for output types only. We cannot use interfaces for input types or arguments.
Union types
Union types are similar to interfaces. The difference is that union types do not need to define any common fields. Instead, union types can combine multiple object types into a single type.
Follow the same approach as the previous section to prepare the models for union types. You can find an `Equipment` class and a `Furniture` class in the `Models` folder, as follows:

public class 设备{

public Guid Id { get; set; }

public string 名称 { get; set; } = string.Empty;

public string? 描述 { get; set; }

public string 状态 { get; set; } = string.Empty;

public string 品牌 { get; set; } = string.Empty;

public int 数量 { get; set; }

}

public class 家具

{

public Guid Id { get; set; }

public string 名称 { get; set; } = string.Empty;

public string? 描述 { get; set; }

public string 颜色 { get; set; } = string.Empty;

public string 材质 { get; set; } = string.Empty;

public int 数量 { get; set; }

}


The `Equipment` class and the `Furniture` class have some different properties. You can find the model configurations in the sample code. We also need to add the services for both classes. You can find the following code in the `Services` folder.
Here is the code for the `IEquipmentService` interface and the `EquipmentService` class:

public interface IEquipmentService{

Task<List<设备>> 获取设备列表 Async();

Task<设备> 获取设备 Async(Guid equipmentId);

}

public class 设备服务(IDbContextFactory contextFactory) : IEquipmentService

{

public async Task<List<设备>> 获取设备列表 Async()

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var equipment = await dbContext.Equipment.ToListAsync();

return equipment;

}

public async Task<设备> 获取设备 Async(Guid equipmentId)

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var equipment = await dbContext.Equipment.FindAsync(equipmentId);

return equipment ?? throw new ArgumentException("设备未找到", nameof(equipmentId));

}

}


Here is the code for the `IFurnitureService` interface and the `FurnitureService` class:

public interface IFurnitureService{

Task<List<家具>> 获取家具列表 Async();

Task<家具> 获取家具 Async(Guid furnitureId);

}

public class 家具服务(IDbContextFactory contextFactory) : IFurnitureService

{

public async Task<List<家具>> 获取家具列表 Async()

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var furniture = await dbContext.Furniture.ToListAsync();

return furniture;

}

public async Task<家具> 获取家具 Async(Guid furnitureId)

{

await using var dbContext = await contextFactory.CreateDbContextAsync();

var furniture = await dbContext.Furniture.FindAsync(furnitureId);

return furniture ?? throw new ArgumentException("家具未找到", nameof(furnitureId));

}

}


The preceding code should be straightforward. Do not forget to register the services in the `Program.cs` file, as follows:

builder.Services.AddScoped<IEquipmentService, 设备服务>();builder.Services.AddScoped<IFurnitureService, 家具服务>();


Next, let's create the union types following these steps:

1.  Create two classes named `EquipmentType` and `FurnitureType`, as follows:

    ```

    公共类 EquipmentType : ObjectType<Equipment>{    protected override void Configure(IObjectTypeDescriptor<Equipment> descriptor)    {        descriptor.Name("Equipment");    }}public class FurnitureType : ObjectType<Furniture>{    protected override void Configure(IObjectTypeDescriptor<Furniture> descriptor)    {        descriptor.Name("Furniture");    }}

    ```cs

    The preceding code defines the `EquipmentType` and `FurnitureType` object types. These two object types are just normal object types.

     2.  Create a new class named `SchoolItemType`, as follows:

    ```

    公共类 SchoolItemType : UnionType{    protected override void Configure(IUnionTypeDescriptor descriptor)    {        descriptor.Name("SchoolItem");        descriptor.Type<EquipmentType>();        descriptor.Type<FurnitureType>();    }}

    ```cs

    The preceding code defines a union type named `SchoolItem`. A union type must inherit from the `UnionType` class. Then, we use the `Type` method to specify the object types that are included in the union type. In this case, the `SchoolItem` union type includes the `EquipmentType` and `FurnitureType` object types.

    As we already registered these two types in the union type, we do not need to register them again in the `Program.cs` file.

     3.  Add a query field in the `Query` class:

    ```

    公共 List<SchoolItemType> SchoolItems { get; set; } = new();

    ```cs

     4.  Configure the resolver for the `SchoolItems` field in the `QueryType` class, as shown here:

    ```

    descriptor.Field(x => x.SchoolItems)    .Description("这是学校中物品列表.")    .Type<ListType<SchoolItemType>>()    .Resolve(async context =>    {        var equipmentService = context.Service<IEquipmentService>();        var furnitureService = context.Service<IFurnitureService>();        var equipmentTask = equipmentService.GetEquipmentListAsync();        var furnitureTask = furnitureService.GetFurnitureListAsync();        await Task.WhenAll(equipmentTask, furnitureTask);        var schoolItems = new List<object>();        schoolItems.AddRange(equipmentTask.Result);        schoolItems.AddRange(furnitureTask.Result);        return schoolItems;    });

    ```cs

    We retrieve a list of `Equipment` and `Furniture` objects from the database. We then combine these two lists into a single list of objects, as the object type is the base type of all types in C#. This allows us to use the object type to effectively combine the two lists.

     5.  Run the application and check the generated schema. You will find the union type defined as follows:

    ```

    联合 SchoolItem = Equipment | Furnituretype Equipment {  id: UUID!  name: String!  description: String  condition: String!  brand: String!  quantity: Int!}type Furniture {  id: UUID!  name: String!  description: String  color: String!  material: String!  quantity: Int!}

    ```cs

    A union type is represented as a union of a list of object types using the `|` symbol. In this case, the `SchoolItem` union type includes the `Equipment` and `Furniture` object types.

     6.  Then, we can query the `SchoolItem` union type, as follows:

    ```

    查询 `{  schoolItems {    __typename    ... on Equipment {      id      name      description      condition      brand      quantity    }    ... on Furniture {      id      name      description      color      material      quantity    }  }}

    {  "data": {    "schoolItems": [      {        "__typename": "Equipment",        "id": "00000000-0000-0000-0000-000000000701",        "name": "Bunsen Burner",        "description": "Bunsen Burner",        "condition": "Good",        "brand": "Bunsen",        "quantity": 10      },      {        "__typename": "Furniture",        "id": "00000000-0000-0000-0000-000000000801",        "name": "Desk",        "description": "Desk",        "color": "Brown",        "material": "Wood",        "quantity": 20      },      ...    ]  }}

    ```cs

    In the response, you can see that the `Equipment` object has the `condition` and `brand` properties, while the `Furniture` object has the `color` and `material` properties. However, even though the `Equipment` and `Furniture` objects have some of the same properties (such as `Id`, `Name`, and so on.), the query must specify the properties for each object type. For example, we cannot use the following query:

    ```

    查询 `{  schoolItems {    __typename    id    name   }}

    ```cs

    The preceding query will cause an error, as follows:

    ```

    {  "errors": [    {      "message": "联合类型不能直接声明字段。请使用内联片段或片段代替。",      "locations": [        {          "line": 2,          "column": 15        }      ],      "path": [        "schoolItems"      ],      "extensions": {        "type": "SchoolItem",        "specifiedBy": "http://spec.graphql.org/October2021/   #sec-Field-Selections-on-Objects-Interfaces-and-Unions-Types"      }    }  ]}

    ```cs

Please note that the `SchoolItem` union type is not a base type of the `Equipment` and `Furniture` object types. If you want to query the common properties of the object types, you can use the interface type instead of the union type.
Filtering, sorting, and pagination
In this section, we will learn how to implement filtering, sorting, and pagination in HotChocolate. These features are very important for a real-world application. We will use the `Student` object as an example to demonstrate how to implement these features. The `Student` class is defined as follows:

公共类 Student{

公共 Guid Id { get; set; }

public string FirstName { get; set; } = string.Empty;

public string LastName { get; set; } = string.Empty;

public string Email { get; set; } = string.Empty;

public string? Phone { get; set; }

public string Grade { get; set; } = string.Empty;

public DateOnly? DateOfBirth { get; set; }

public Guid GroupId { get; set; }

public Group Group { get; set; } = default!;

public List Courses { get; set; } = new();

public List StudentCourses { get; set; } = new();

}


To use filtering, sorting, and pagination, we need to install the `HotChocolate.Data` NuGet package. If you already installed the `HotChocolate.Data.EntityFramework` package following the previous sections, you do not need to install the `HotChocolate.Data` package again. The `HotChocolate.Data` package is a dependency of the `HotChocolate.Data.EntityFramework` package. If not, you can install the `HotChocolate.Data` package using the following command:

dotnet add package HotChocolate.Data


 Let’s begin with filtering!
Filtering
HotChocolate supports filtering on the object type. A question is how we translate the GraphQL filter to the SQL-native queries. If the resolver exposes an `IQueryable` interface, HotChocolate can translate the GraphQL filter to SQL-native queries automatically. But we can also implement the filtering logic in the resolver manually. In this section, we will explore how to use filtering in HotChocolate.
To enable filtering on the `Student` object type, follow these steps:

1.  First, we need to register the `Filtering` middleware in the `Program.cs` file, as follows:

    ```

    builder.Services    .AddGraphQLServer()    // Omitted for brevity    .AddFiltering()    .AddMutationType<Mutation>();

    ```cs

     2.  Add a query field in the `Query` class:

    ```

    public List<Student> Students { get; set; } = new();

    ```cs

     3.  Apply the filtering in the resolver of the `Students` field in the `QueryType`:

    ```

    descriptor.Field(x => x.Students)    .Description("This is the list of students in the school.")    .UseFiltering()    .Resolve(async context =>    {        var dbContextFactory = context.Service<IDbContextFactory<AppDbContext>>();        var dbContext = await dbContextFactory.CreateDbContextAsync();        var students = dbContext.Students.AsQueryable();        return students;    });

    ```cs

    The preceding code uses the `UseFiltering()` method to enable filtering on the `Students` field. Then, we use the `AsQueryable()` method to expose the `IQueryable` interface. This allows HotChocolate to translate the GraphQL filter to SQL-native queries automatically.

     4.  Run the application and check the generated schema. You will find the `students` query has a `StudentFilterInput` filter, as shown here:

    ```

    students(where: StudentFilterInput): [Student!]!

    input StudentFilterInput {  and: [StudentFilterInput!]  or: [StudentFilterInput!]  id: UuidOperationFilterInput  firstName: StringOperationFilterInput  lastName: StringOperationFilterInput  email: StringOperationFilterInput  phone: StringOperationFilterInput  grade: StringOperationFilterInput  dateOfBirth: DateOperationFilterInput}

    ```cs

    HotChocolate automatically inspects the `Student` object type and generates the filter input type. The `StudentFilterInput` filter includes all the properties of the `Student` object type by default.

     5.  Next, we can filter the `students` query, as follows:

    ```

    query {  students(where: { firstName: { eq: "John" } }) {    id    firstName    lastName    email    phone    grade    dateOfBirth  }}

    {  "data": {    "students": [      {        "id": "00000000-0000-0000-0000-000000000901",        "firstName": "John",        "lastName": "Doe",        "email": "",        "phone": null,        "grade": "",        "dateOfBirth": "2000-01-01"      }    ]  }}

    ```cs

    You can find the generated SQL query in the logs, as follows:

    ```

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (36ms) [Parameters=[@__p_0='?' (Size = 32)], CommandType='Text',    CommandTimeout='30']      SELECT [s].[Id], [s].[DateOfBirth], [s].[Email], [s].[FirstName], [s].[Grade], [s].[GroupId], [s].   [LastName], [s].[Phone]      FROM [Students] AS [s]      WHERE [s].[FirstName] = @__p_0

    ```cs

    The preceding SQL query uses the `WHERE` clause to filter the `Student` objects, which means the filtering is done in the database.

     6.  The filtering can be defined in the variable as well. For example, we can use the following query to filter the `Student` objects:

    ```

    query ($where: StudentFilterInput) {  students(where: $where) {    id    firstName    lastName    email    phone    grade    dateOfBirth  }}

    {  "where": {    "firstName": {      "eq": "John"    }  }}

    ```cs

    The result is the same as the previous query. You can also try other operators to filter the `Student` objects. For example, the following variable uses the `in` operator to filter the `Student` objects:

    ```

    {  "where": {    "firstName": {      "in": ["John", "Jane"]    }  }}

    ```cs

    The following variable uses the `gt` operator to filter the students who were born after `2001-01-01`:

    ```

    {  "where": {    "dateOfBirth": {      "gt": "2001-01-01"    }  }}

    ```cs

The generated filter input type contains all the properties of the object type. Sometimes, we do not need to filter all the properties. For example, we may want to allow filtering on a few properties only. In this case, we can create a custom filter input type and specify the properties we want to filter. Follow these steps to create a custom filter input type:

1.  Create a `Filters` folder in the `GraphQL` folder. Then, add a new class named `StudentFilterType`, as follows:

    ```

    public class StudentFilterType : FilterInputType<Student>{    protected override void Configure(IFilterInputTypeDescriptor<Student> descriptor)    {        descriptor.BindFieldsExplicitly();        descriptor.Field(t => t.Id);        descriptor.Field(t => t.GroupId);        descriptor.Field(t => t.FirstName);        descriptor.Field(t => t.LastName);        descriptor.Field(t => t.DateOfBirth);    }}

    ```cs

     2.  Then, we need to specify the filter input type in the resolver. Update the resolver for the `students` query, as follows:

    ```

    descriptor.Field(x => x.Students)    .Description("这是学校中学生的列表.")    .UseFiltering<StudentFilterType>()    // 省略以节省篇幅

    ```cs

     3.  Check the generated schema. You will find `StudentFilterInput` only contains the fields we specified in `StudentFilterType`, as shown here:

    ```

    输入 StudentFilterInput {  and: [StudentFilterInput!]  or: [StudentFilterInput!]  id: UuidOperationFilterInput  groupId: UuidOperationFilterInput  firstName: StringOperationFilterInput  lastName: StringOperationFilterInput  dateOfBirth: DateOperationFilterInput}

    ```cs

     4.  If the model has many properties but we only want to ignore a few properties, we can use the `Ignore()` method to ignore the properties we do not want to filter. For example, we can update `StudentFilterType` as follows:

    ```

    override protected void Configure(IFilterInputTypeDescriptor<Student> descriptor){    descriptor.BindFieldsImplicitly();    descriptor.Ignore(t => t.Group);    descriptor.Ignore(t => t.Courses);}

    public class StudentStringOperationFilterInputType : StringOperationFilterInputType{    protected override void Configure(IFilterInputTypeDescriptor descriptor)    {        descriptor.Operation(DefaultFilterOperations.Equals).Type<StringType>();        descriptor.Operation(DefaultFilterOperations.Contains).Type<StringType>();    }}

    ```cs

    The preceding code defines a custom `StudentStringOperationFilterInputType` filter. The `StudentStringOperationFilterInputType` filter only includes the `eq` and `contains` operations. Then, we can use the `StudentStringOperationFilterInputType` filter in `StudentFilterType`, as follows:

    ```

    override protected void Configure(IFilterInputTypeDescriptor<Student> descriptor){    // 省略以节省篇幅    descriptor.Field(t => t.FirstName).Type<StudentStringOperationFilterInputType>();    descriptor.Field(t => t.LastName).Type<StudentStringOperationFilterInputType>();}

    ```cs

    Now, the `StudentFilterInput` filter only includes the `eq` and `contains` operations for the `FirstName` and `LastName` properties.

     5.  The filter supports `and` and `or` operations. You can find an `and` and `or` property in the `StudentFilterInput` filter. These two fields are used to combine multiple filters. The `and` field means the filter must match all the conditions. The `or` field means the filter must match at least one condition. For example, we can use the following query to filter the `Student` objects whose first name is John and who were born after 2001-01-01 using the `and` operation:

    ```

    查询 {  students(where: { and: [{ firstName: { eq: "John" } }, { dateOfBirth: { gt: "2001-01-01" } }] }) {    id    firstName    lastName    email    phone    grade    dateOfBirth  }}

    查询 ($where: StudentFilterInput) {  students(where: $filter) {    id    firstName    lastName    email    phone    grade    dateOfBirth  }}

    ```cs

    The variables are defined as follows:

    ```

    {  "where": {    "or": [      {        "firstName": {          "eq": "John"        }      },      {        "lastName": {          "eq": "Doe"        }      }    ]  }}

    ```cs

In the preceding examples, we expose the `IQueryable` interface in the resolver, so HotChocolate can translate the GraphQL filter to SQL-native queries automatically. However, sometimes, we cannot expose the `IQueryable` interface in the resolver. In this case, we need to implement the filtering logic in the resolver manually. The code would be more complex. Let us see how to implement the filtering logic in the resolver manually:

1.  The methods to retrieve the list of the `Student` type by the group ID are defined in the `IStudentService` interface, as follows:

    ```

    public interface IStudentService{    // 省略以节省篇幅    Task<List<Student>> GetStudentsByGroupIdAsync(Guid groupId);    Task<List<Student>> GetStudentsByGroupIdsAsync(List<Guid> groupIds);}

    ```cs

    We have two methods for `eq` and `in` operations. The `GetStudentsByGroupIdAsync()` method retrieves the list of `Student` objects by the group ID. The `GetStudentsByGroupIdsAsync()` method retrieves the list of `Student` objects by the list of group IDs. These two methods return the list of `Student` objects instead of the `IQueryable` interface. So, we need to implement the filtering logic in the resolver manually.

     2.  Define a customized filter for the `Student` type as follows:

    ```

    public class CustomStudentFilterType : FilterInputType<Student>{    protected override void Configure(IFilterInputTypeDescriptor<Student> descriptor)    {        descriptor.BindFieldsExplicitly();        descriptor.Name("CustomStudentFilterInput");        descriptor.AllowAnd(false).AllowOr(false);        descriptor.Field(t => t.GroupId).Type<CustomStudentGuidOperationFilterInputType>();    }}public class CustomStudentGuidOperationFilterInputType : UuidOperationFilterInputType{    protected override void Configure(IFilterInputTypeDescriptor descriptor)    {        descriptor.Name("CustomStudentGuidOperationFilterInput");        descriptor.Operation(DefaultFilterOperations.Equals).Type<IdType>();        descriptor.Operation(DefaultFilterOperations.In).Type<ListType<IdType>>();    }}

    ```cs

    In the preceding code, we define a custom filter input type named `CustomStudentFilterInput`. The `CustomStudentFilterInput` filter only includes the `GroupId` property. To make the filter more simple, we disable the `and` and `or` operations. Then, we define a custom filter input type named `CustomStudentGuidOperationFilterInput`. The `CustomStudentGuidOperationFilterInput` filter only includes the `eq` and `in` operations. Note that we need to specify the names of the filter input types. Otherwise, HotChocolate will report name conflicts because we already have a `StudentFilterInput` filter.

     3.  Add a new query type in the `Query` class:

    ```

    public List<Student> StudentsWithCustomFilter { get; set; } = new();

    ```cs

     4.  Configure the resolver and manually filter the data in the `QueryType` class, as follows:

    ```

    descriptor.Field(x => x.StudentsWithCustomFilter)    .Description("这是学校中学生的列表.")    .UseFiltering<CustomStudentFilterType>()    .Resolve(async context =>    {        var service = context.Service<IStudentService>();        // 以下代码使用自定义过滤器。        var filter = context.GetFilterContext()?.ToDictionary();        if (filter != null && filter.ContainsKey("groupId"))        {            var groupFilter = filter["groupId"]! as Dictionary<string, object>;            if (groupFilter != null && groupFilter.ContainsKey("eq"))            {                if (!Guid.TryParse(groupFilter["eq"].ToString(), out var groupId))                {                    throw new ArgumentException("无效的组 ID", nameof(groupId));                }                var students = await service.GetStudentsByGroupIdAsync(groupId);                return students;            }            if (groupFilter != null && groupFilter.ContainsKey("in"))            {                if (groupFilter["in"] is not IEnumerable<string> groupIds)                {                    throw new ArgumentException("无效的组 ID 列表", nameof(groupIds));                }                groupIds = groupIds.ToList();                if (groupIds.Any())                {                    var students =                        await service.GetStudentsByGroupIdsAsync(groupIds                            .Select(x => Guid.Parse(x.ToString())).ToList());                    return students;                }                return new List<Student>();            }        }        var allStudents = await service.GetStudentsAsync();        return allStudents;    });

    ```cs

    The preceding code is a bit complex. We need to get the filter from the `context` object. Then, we check whether the filter contains the `groupId` property. If the filter contains the `groupId` property, we check whether the `eq` or `in` operation is specified. If the `eq` operation is specified, we retrieve the list of `Student` objects by the group ID. If the `in` operation is specified, we retrieve the list of `Student` objects by the list of group IDs. If the `eq` or `in` operation is not specified, we retrieve all the `Student` objects.

     5.  Run the application and check the generated schema. You will find the `studentsWithCustomFilter` query has a `CustomStudentFilterInput` filter, as shown here:

    ```

    input CustomStudentFilterInput {  groupId: CustomStudentGuidOperationFilterInput}input CustomStudentGuidOperationFilterInput {  and: [CustomStudentGuidOperationFilterInput!]  or: [CustomStudentGuidOperationFilterInput!]  eq: ID  in: [ID]}

    query ($where: CustomStudentFilterInput) {  studentsWithCustomFilter(where: $where) {    id    firstName    lastName    email    phone    grade    dateOfBirth  }}

    ```cs

    To filter the `Student` objects by `groupId`, we can define the following variable:

    ```

    {  "where": {    "groupId": {      "eq": "00000000-0000-0000-0000-000000000201"    }  }}

    ```cs

    To filter the `Student` objects by the list of group IDs, we can define the following variable:

    ```

    {  "where": {    "groupId": {      "in": ["00000000-0000-0000-0000-000000000201", "00000000-0000-0000-0000-000000000202"]    }  }}

    ```cs

As the filtering variables may vary in different cases, the logic to filter the data may be different. It is recommended to use the `IQueryable` interface if possible, so that HotChocolate can translate the GraphQL filter to SQL-native queries automatically.
Sorting
In this section, we will learn how to use sorting in HotChocolate. The sorting is similar to filtering. We can use the `UseSorting()` method to enable sorting on the object type. If we use the `IQueryable` interface in the resolver, HotChocolate can translate the GraphQL sorting to SQL-native queries automatically. Otherwise, we need to implement the sorting logic in the resolver manually.
To enable sorting on the `Student` object type, the `HotChocolate.Data` package is required. Follow the step just before the *Filtering* section to install the `HotChocolate.Data` package if you have not installed it yet. Then, follow these steps to enable sorting on the `Student` object type:

1.  Register the `Sorting` middleware in the `Program.cs` file, as follows:

    ```

    builder.Services    .AddGraphQLServer()    // 省略以节省空间    .AddSorting()    .AddMutationType<Mutation>();

    ```cs

     2.  Update the resolver for the `students` query:

    ```

    descriptor.Field(x => x.Students)    .Description("这是学校中学生的列表.")    .UseFiltering<StudentFilterType>()    .UseSorting()    // 省略以节省空间

    ```cs

    Note that `UseSorting()` must be placed after `UseFiltering`.

     3.  Then, run the application and check the generated schema. You will find the `students` query has an `orderBy` argument, as shown here:

    ```

    students(where: StudentFilterInput, order: [StudentSortInput!]): [Student!]!

    input StudentSortInput {  id: SortEnumType  firstName: SortEnumType  lastName: SortEnumType  email: SortEnumType  phone: SortEnumType  grade: SortEnumType  dateOfBirth: SortEnumType  groupId: SortEnumType  group: GroupSortInput}

    ```cs

    The `StudentSortInput` type includes all the properties of the `Student` object type. The `SortEnumType` type is an enum type, as follows:

    ```

    enum SortEnumType {  ASC  DESC}

    ```cs

    The `SortDirection` type includes two values: `ASC` and `DESC`. The `ASC` value means the sorting is ascending. The `DESC` value means the sorting is descending.

     4.  Next, we can query the `Student` type with sorting. The following query will sort the results by first name:

    ```

    query ($order: [StudentSortInput!]) {  students(order: $order) {    id    firstName    lastName    email    phone    grade    dateOfBirth  }}

    {  "order": [    {      "firstName": "ASC"    }  ]}

    ```cs

    The sorting variable supports multiple properties. For example, the following query variable will sort the results by first name and last name:

    ```

    {  "order": [     {        "firstName": "ASC"     },     {        "lastName": "ASC"     }  ]}

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']      SELECT [s].[Id], [s].[DateOfBirth], [s].[Email], [s].[FirstName], [s].[Grade], [s].[GroupId], [s].   [LastName], [s].[Phone]      FROM [Students] AS [s]      ORDER BY [s].[FirstName], [s].[LastName]

    ```cs

    The preceding SQL query uses the `ORDER BY` clause to sort the `Student` objects, which means the sorting is done in the database.

Similar to filtering, the default sorting includes all the properties of the object type. If we want to sort on specific properties only, we can create a custom sort input type and specify the properties we want to sort. Follow these steps to create a custom sort input type:

1.  Create a folder named `Sorts` in the `GraphQL` folder. Add a new class named `StudentSortType`, as follows:

    ```

    public class StudentSortType : SortInputType<Student>{    protected override void Configure(ISortInputTypeDescriptor<Student> descriptor)    {        descriptor.BindFieldsExplicitly();        descriptor.Field(x => x.FirstName);        descriptor.Field(x => x.LastName);        descriptor.Field(x => x.DateOfBirth);    }}

    ```cs

    The preceding code defines a custom sort input type, which only includes the `FirstName`, `LastName`, and `DateOfBirth` properties.

    Similar to the filter input type, you can explicitly specify the properties you want to sort, or you can ignore the properties you do not want to sort.

     2.  Update the resolver to apply the custom sort input type:

    ```

    descriptor.Field(x => x.Students)    .UseFiltering<StudentFilterType>()    .UseSorting<StudentSortType>()    // Omitted for brevity

    ```cs

     3.  Run the application and check the schema. You will see that `StudentSortInput` now has three properties only:

    ```

    input StudentSortInput {  firstName: SortEnumType  lastName: SortEnumType  dateOfBirth: SortEnumType}

    ```cs

The query is similar to the previous example, so we will not repeat it here.
Pagination
Pagination is a common feature in web API development. In this section, we will learn how to use pagination in HotChocolate.
Similar to filtering and sorting, we need to install the `HotChocolate.Data` package to use pagination. HotChocolate supports two types of pagination:

*   **Cursor-based pagination**: This is the default pagination in HotChocolate. It uses a cursor to indicate the current position in the list. The cursor is usually an ID or a timestamp, which is opaque to the client.
*   `skip` and `take` arguments to paginate the list.

Let’s first use cursor-based pagination to paginate the `Student` objects. As we introduced before, if we use the `IQueryable` interface in the resolver, HotChocolate can translate the GraphQL pagination to SQL-native queries automatically. Follow the next steps to use cursor-based pagination for the `students` query:

1.  Update the resolver for the `students` query:

    ```

    descriptor.Field(x => x.Students)    .Description("这是学校学生列表。")    .UsePaging()    .UseFiltering<StudentFilterType>()    .UseSorting<StudentSortType>()    // 省略以节省篇幅

    ```cs

    Note that `UsePaging()` must be placed before `UseFiltering()` and `UseSorting()`.

     2.  Run the application and check the generated schema. You will find that the `students` query now is the `StudentsConnection` type:

    ```

    students(  first: Int  after: String  last: Int  before: String  where: StudentFilterInput  order: [StudentSortInput!]): StudentsConnection

    type StudentsConnection {  pageInfo: PageInfo!  edges: [StudentsEdge!]  nodes: [Student!]}

    ```cs

    In GraphQL, the connection type is a standard way to paginate the list. The `StudentsConnection` type includes three fields: `pageInfo`, `edges`, and `nodes`. The `nodes` field is a list of the `Student` objects. The `edges` and `pageInfo` fields are defined in the `StudentsEdge` and `PageInfo` types as follows:

    ```

    type StudentsEdge {  cursor: String!  node: Student!}type PageInfo {  hasNextPage: Boolean!  hasPreviousPage: Boolean!  startCursor: String  endCursor: String}

    ```cs

     3.  Next, we can query the paginated `Student` objects as follows:

    ```

    query {  students {    edges {      cursor      node {        id        firstName        dateOfBirth      }    }    pageInfo {      hasNextPage      hasPreviousPage    }  }}

    {  "data": {    "students": {      "edges": [        {          "cursor": "MA==",          "node": {            "id": "00000000-0000-0000-0000-000000000901",            "firstName": "John",            "dateOfBirth": "2000-01-01"          }        },        ...        {          "cursor": "OQ==",          "node": {            "id": "00000000-0000-0000-0000-000000000910",            "firstName": "Jack",            "dateOfBirth": "2000-01-10"          }        }      ],      "pageInfo": {        "hasNextPage": true,        "hasPreviousPage": false      }    }  }}

    ```cs

    The result contains a `cursor` field for each `Student` object. The `cursor` field is an opaque string, which is used to indicate the current position in the list. The `pageInfo` field indicates whether there are more pages. In this case, the `hasNextPage` field is `true`, which means there are more pages.

     4.  To query the next page, we need to specify the `after` parameter:

    ```

    query {  students(after: "OQ==") {    edges {      cursor      node {        id        firstName        dateOfBirth      }    }    pageInfo {      hasNextPage      hasPreviousPage    }  }}

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (11ms) [Parameters=[@__p_0='?' (DbType = Int32), @__p_1='?' (DbType = Int32)],    CommandType='Text', CommandTimeout='30']      SELECT [s].[Id], [s].[DateOfBirth], [s].[Email], [s].[FirstName], [s].[Grade], [s].[GroupId], [s].   [LastName], [s].[Phone]      FROM [Students] AS [s]      ORDER BY (SELECT 1)      OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY

    ```cs

    The preceding SQL query uses the `OFFSET` and `FETCH` clauses to paginate the `Student` objects, which means the pagination is handled in the database.

     5.  To query the previous page, we need to specify the `before` parameter, as in this example:

    ```

    query {  students(before: "MA==") {     edges {        cursor        node {          id          firstName          dateOfBirth        }     }     pageInfo {        hasNextPage        hasPreviousPage     }  }}

    ```cs

     6.  We can specify the options for pagination in the `UsePaging()` method. For example, we can specify the default page size and include the total count in the `UsePaging()` method, as follows:

    ```

    descriptor.Field(x => x.Students)    .Description("This is the list of students in the school.")    .UsePaging(options: new PagingOptions()    {        MaxPageSize = 20,        DefaultPageSize = 5,        IncludeTotalCount = true    })    .UseFiltering<StudentFilterType>()    .UseSorting<StudentSortType>()    // Omitted for brevity

    query {  students {    edges {      cursor      node {        id        firstName        dateOfBirth      }    }    totalCount    pageInfo {      hasNextPage      hasPreviousPage    }  }}

    ```cs

    You can see the `totalCount` field in the response. The default page size is `5`.

     7.  We can use pagination with filtering and sorting. The following query will filter the `Student` objects by first name and sort the results by first name and then by last name:

    ```

    query ($where: StudentFilterInput, $order: [StudentSortInput!]) {  students(where: $where, order: $order) {    edges {      cursor      node {        id        firstName        dateOfBirth      }    }    totalCount    pageInfo {      hasNextPage      hasPreviousPage    }  }}

    {   "where":{      "dateOfBirth":{         "gt":"2001-01-01"      }   },   "order":[      {         "firstName":"ASC"      },      {         "lastName":"ASC"      }   ]}

    ```cs

    After querying the first page, we can query the next page, as follows:

    ```

    query ($where: StudentFilterInput, $order: [StudentSortInput!]) {  students(where: $where, order: $order, after: "NA==") {    edges {      cursor      node {        id        firstName        dateOfBirth      }    }    totalCount    pageInfo {      hasNextPage      hasPreviousPage    }  }}

    ```cs

    You can also define the `after` parameter in the query variable, as follows:

    ```

    {   "where":{      "dateOfBirth":{         "gt":"2001-01-01"      }   },   "order":[      {         "firstName":"ASC"      },      {         "lastName":"ASC"      }   ],   "after":"NA=="}

    ```cs

The query language of GraphQL is very flexible. We cannot list all the possible queries here. You can try different queries by yourself.
HotChocolate supports offset-based pagination as well. To use offset-based pagination, we need to use the `UseOffsetPaging()` method instead of the `UsePaging()` method. Follow these steps to use offset-based pagination:

1.  Update the resolver for the `students` query:

    ```

    descriptor.Field(x => x.Students)    .Description("This is the list of students in the school.")    .UseOffsetPaging()    .UseFiltering<StudentFilterType>()    .UseSorting<StudentSortType>()    // Omitted for brevity

    ```cs

    The preceding code uses the `UseOffsetPaging()` method to enable offset-based pagination instead of cursor-based pagination.

     2.  Run the application and check the generated schema. You will find the `students` query is now the `StudentsCollectionSegment` type:

    ```

    students(  skip: Int  take: Int  where: StudentFilterInput  order: [StudentSortInput!]): StudentsCollectionSegmenttype StudentsCollectionSegment {  pageInfo: CollectionSegmentInfo!  items: [Student!]}type CollectionSegmentInfo {  hasNextPage: Boolean!  hasPreviousPage: Boolean!}

    ```cs

    You should be familiar with the `skip` and `take` arguments. The `skip` argument is used to skip the first `n` items. The `take` argument is used to take the first `n` items. We already used these two methods in LINQ to implement the pagination.

     3.  Next, we can query the paginated `Student` objects as follows:

    ```

    query {   students {     items {       id       firstName       dateOfBirth     }     pageInfo {       hasNextPage       hasPreviousPage     }   } }

    ```cs

     4.  To query the next page, we need to specify the `skip` and `take` parameters, as follows:

    ```

    query {  students(skip: 5, take: 5) {    items {      id      firstName      dateOfBirth    }    pageInfo {      hasNextPage      hasPreviousPage    }  }}

    ```cs

     5.  You can define the `skip` and `take` parameters in the query variable as follows:

    ```

    {  "skip": 5,  "take": 5}

    ```cs

     6.  We can specify the pagination options in the `UseOffsetPaging` method:

    ```

    descriptor.Field(x => x.Students)    .Description("这是学校的学生名单。")    .UseOffsetPaging(options: new PagingOptions()    {        MaxPageSize = 20,        DefaultPageSize = 5,        IncludeTotalCount = true    })    .UseFiltering<StudentFilterType>()    .UseSorting<StudentSortType>()    // 省略以节省篇幅

    ```cs

    You can include the `totalCount` field in the response now.

     7.  We can use pagination with filtering and sorting. The following query will filter the `Student` objects by first name and sort the results by first name and then by last name, and then fetch the second page:

    ```

    查询($where: StudentFilterInput, $order: [StudentSortInput!], $skip: Int!, $take: Int!){     students(where: $where, order: $order, skip: $skip, take: $take) {       items {         id         firstName         dateOfBirth       }       totalCount       pageInfo {         hasNextPage         hasPreviousPage       }     }   }

    {   "where":{      "dateOfBirth":{         "gt":"2001-01-01"      }   },   "order":[      {         "firstName":"ASC"      },      {         "lastName":"ASC"      }   ],   "skip":5,   "take":5}

    ```cs

    The generated SQL query is shown here:

    ```

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]      Executed DbCommand (2ms) [Parameters=[@__p_0='?' (DbType = Date), @__p_1='?' (DbType = Int32),    @__p_2='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']      SELECT [s].[Id], [s].[DateOfBirth], [s].[Email], [s].[FirstName], [s].[Grade], [s].[GroupId], [s].   [LastName], [s].[Phone]      FROM [Students] AS [s]      WHERE [s].[DateOfBirth] > @__p_0      ORDER BY [s].[FirstName], [s].[LastName]      OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY

    ```cs

The preceding SQL query shows that the pagination is handled in the database.
Visualizing the GraphQL schema
When the GraphQL API becomes more complex, it is difficult to understand the schema. We can use `GraphQL Voyager` to visualize the GraphQL schema. `GraphQL Voyager` is an open-source project that can visualize the GraphQL schema in an interactive graph. It is a frontend application that can be integrated with the GraphQL API. To use it in our ASP.NET Core application, we can use the `GraphQL.Server.Ui.Voyager` package. This package is part of the GraphQL.NET project.
Follow these steps to use GraphQL Voyager in our application:

1.  Install the `GraphQL.Server.Ui.Voyager` package using the following command:

    ```

    Program.cs 文件:

    ```cs
    app.MapGraphQLVoyager();
    ```

    上述代码添加了一个中间件,将 Voyager UI 映射到默认 URL `ui/voyager`。如果您想指定不同的 URL,可以将 URL 作为参数传递,如下例所示:

    ```cs
    app.MapGraphQLVoyager("/voyager");
    ```

    ```cs

     2.  Run the application and navigate to the `ui/voyager` URL. You will see the following page:

![Figure 12.5 – The GraphQL Voyager UI](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/webapi-dev-aspdn-cr8/img/B18971_12_05.jpg)

Figure 12.5 – Overview of the GraphQL Voyager UI
Visualizing the GraphQL schema can be beneficial for your team. Doing so allows you to share the schema with your team members, making it easier to collaborate and stay on the same page.
Summary
In this chapter, we explored how to use HotChocolate and Entity Framework Core to create a GraphQL API. We discussed how to define object types, queries, and mutations, as well as how to use dependency injection to inject the `DbContext` instance and services into the resolver. We also introduced the data loader, which can reduce the number of queries to the database. Additionally, we discussed interface and union types, which are useful for defining polymorphic types. Finally, we explored how to use filtering, sorting, and pagination in HotChocolate.
In the next chapter, we will discuss SignalR, which is a real-time communication library in ASP.NET Core.
Further reading
It is important to note that GraphQL is a comprehensive query language and there are many features that we were unable to cover in this chapter. For example, GraphQL supports subscriptions, which enable real-time communication with the GraphQL API. To learn more about HotChocolate and GraphQL, please refer to the following resources:

*   [`graphql.org/learn/`](https://graphql.org/learn/)
*   [`chillicream.com/docs/hotchocolate/`](https://chillicream.com/docs/hotchocolate/)

In a microservice architecture, we can use Apollo Federation to create a GraphQL gateway. Apollo Federation can combine multiple GraphQL APIs into a single GraphQL API. We will not cover Apollo Federation here as it is out of the scope of this book. To learn more about Apollo Federation, please refer to the following resources:

*   [`www.apollographql.com/`](https://www.apollographql.com/)
*   [`github.com/apollographql`](https://github.com/apollographql)

第十三章:SignalR 入门

第一章 中,我们介绍了实时 Web API 的概念。可以使用各种技术来实现实时 Web API,例如 gRPC 流、长轮询、服务器端事件SSE)、WebSockets 等。Microsoft 提供了一个名为 SignalR 的开源库,用于简化实时 Web API 的实现。在本章中,我们将介绍 SignalR 的基础知识以及如何使用 SignalR 实现实时 Web API。

本章将涵盖以下主题:

  • 实时 Web API 概述

  • 设置 SignalR

  • 构建 SignalR 客户端

  • 在 SignalR 中使用身份验证和授权

  • 管理用户和组

  • 从其他服务发送消息

  • 配置 SignalR 端点和客户端

到本章结束时,您将能够使用 SignalR 实现实时 Web API。

技术要求

要遵循本章中的步骤,您可以从 GitHub 仓库 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter13 下载源代码。您可以使用 VS 2022 或 VS Code 打开解决方案。

您还需要安装以下软件:

  • Node.js:Node.js 是基于 Chrome 的 V8 JavaScript 引擎构建的 JavaScript 运行时环境。您可以从 nodejs.org/en/ 下载 Node.js 的最新版本。我们将使用它来安装 TypeScript 客户端所需的包。

实时 Web API 概述

第一章 中,我们介绍了几种可以用于实现实时 Web API 的技术。每种技术都有其优缺点。为了简化实时 Web API 的实现,Microsoft 提供了 SignalR,它支持多种传输方式,如 WebSockets、SSE 和长轮询。SignalR 将根据客户端的能力自动选择最佳传输方式。除此之外,SignalR 还提供了一个简单的编程模型来实现实时 Web API。开发者无需担心底层传输细节;相反,他们可以专注于业务逻辑。

SignalR 首次在 2013 年为 ASP.NET 介绍。截至目前,SignalR 已经为 ASP.NET Core 重写,并包含在 ASP.NET Core 框架中。因此,存在两个不同的 SignalR 版本:一个用于 ASP.NET,另一个用于 ASP.NET Core。如果您正在使用 ASP.NET Core,则无需安装任何额外的包即可使用 SignalR。ASP.NET 版本和 ASP.NET Core 版本的 SignalR 之间也有一些差异。例如,ASP.NET Core SignalR 不支持 Microsoft Internet Explorer。然而,大多数现代应用程序都针对现代浏览器,因此这不应成为大问题。在本章中,我们将重点关注 ASP.NET Core 版本的 SignalR。

与 REST API 不同,SignalR 客户端需要安装 SignalR 客户端库才能与 SignalR 服务器通信。SignalR 为不同的平台提供了一些客户端库:

  • JavaScript 客户端:这是最常用的客户端库,因为它可以在浏览器和 Node.js 应用程序中使用

  • .NET 客户端:此客户端可用于 .NET 应用程序,例如 Xamarin、Windows 表面基础WPF)和 Blazor

  • Java 客户端:此客户端支持 Java 8 及更高版本

其他客户端,如 C++ 客户端和 Swift 客户端,不是由 Microsoft 正式支持的。

SignalR 是构建实时 Web API 的好选择。例如,您可以使用 SignalR 构建聊天应用程序、实时仪表板、投票应用程序、白板应用程序等。SignalR 可以将数据推送到特定的客户端或客户端组。它自动管理客户端和服务器之间的连接。在下一节中,我们将介绍 SignalR 的基础知识并构建一个简单的聊天应用程序。

设置 SignalR

在本节中,我们将使用 SignalR 构建一个简单的聊天应用程序。该聊天应用程序将允许用户向公共聊天室发送消息。消息将被广播到所有已连接的客户端。此应用程序包含四个项目:

  • ChatApp.Server:这是提供 SignalR 中心的 ASP.NET Core Web API 项目

  • ChatApp.TypeScriptClient:这是一个用 TypeScript 编写的客户端应用程序

  • ChatApp.BlazorClient:这是一个用 Blazor 编写的客户端应用程序,Blazor 是一个用于使用 C# 构建客户端应用程序的 Web 框架

重要提示

此示例的代码基于 Microsoft 提供的官方 SignalR 示例。您可以在 github.com/aspnet/SignalR-samples/tree/main/ChatSample 找到原始源代码。我们向示例中添加了 Blazor 和 MAUI 客户端。

ChatApp.Server 应用程序是一个简单的 ASP.NET Core Web API 应用程序,用于提供 SignalR 中心。SignalR 中心是一个用于管理客户端和服务器之间连接的类。它是 SignalR 实时通信的高级抽象。SignalR 中心可以用于向客户端发送消息并从客户端接收消息。SignalR 中心还可以管理用户和客户端组。在 ASP.NET Core SignalR 中,中心被定义为中间件组件,因此我们可以轻松将其添加到 ASP.NET Core 管道中。

SignalR 中心有一个 Clients 属性来管理服务器和客户端之间的连接。当用户连接到 SignalR 中心时,会创建一个新的连接。一个用户可以有多个连接。Clients 属性有一些用于管理连接的方法:

  • All 用于在所有已连接的客户端上调用方法。

  • Caller 用于在调用者上调用方法。

  • Others 用于在除调用者之外的所有已连接客户端上调用方法。

  • Client 用于在特定客户端上调用方法。

  • Clients 用于在特定的已连接客户端上调用方法。

  • Group 用于在客户端组上调用方法。

  • Groups 用于在多个客户端组上调用方法。

  • User 用于在特定用户上调用方法。请注意,一个用户可能有多个连接。

  • Users 用于在指定的用户上调用方法,包括所有连接。

  • AllExcept 用于在除了指定客户端之外的所有已连接客户端上调用方法。

  • GroupExcept 用于在除了指定客户端之外的一组客户端上调用方法。

  • OthersInGroup 用于在除了调用者之外的一组所有客户端上调用方法。

在接下来的几节中,我们将探讨这些方法中的一些。您可以在 GitHub 仓库的 chapter13/v1 文件夹中找到示例的完整代码。

接下来,按照以下步骤创建一个新的解决方案并设置 ChatApp.Server 项目:

  1. 使用 dotnet new sln 命令创建一个名为 ChatApp 的新解决方案:

    ChatApp.Server using the dotnet new webapi command and add it to the solution:
    
    

    在 Program.cs 文件中添加以下代码:

    Hubs in the project and add a new class called ChatHub:
    
    

    namespace ChatApp.Server.Hubs;public class ChatHub : Hub{    public Task SendMessage(string user, string message)    {        return Clients.All.SendAsync("ReceiveMessage", username, message);    }}

    
    The preceding code creates a new SignalR hub class called `ChatHub` that inherits from the `Hub` class. The `ChatHub` class contains a method called `SendMessage()`, which is used to send a message to all connected clients. The `SendMessage()` method takes two parameters, `user` and `message`, which are used to identify the username and the message. This method uses the `Clients.All.SendAsync()` method to broadcast the message to all connected clients when the `SendMessage()` method is invoked by clients. Note the first parameter of the `SendAsync()` method (for example, `ReceiveMessage()`) is the name of the method for clients to receive the message.
    
    
    
  2. 接下来,我们需要将 SignalR hub 映射到一个 URL。将以下代码添加到 Program.cs 文件中:

    using ChatApp.Server.Hubs; statement to the top of the file.
    
  3. 检查 Properties 文件夹中的 launchSettings.json 文件。默认的 launchSettings.json 文件包含 httphttps URL。默认情况下,dotnet run 命令将使用第一个 http 配置文件。我们可以指定启动配置文件使用 https URL。使用以下命令运行应用程序:

    https URL to run the application. Take note of the URL (for example, https://localhost:7159). We will use it in the next section.
    

SignalR hub 现在已准备好使用。但是,为了测试它,客户端必须安装 SignalR 客户端库才能与 hub 进行通信。在下一节中,我们将构建客户端应用程序。

构建 SignalR 客户端

本节将通过构建三个由 ChatApp.Server 应用程序提供的相同 SignalR hub 消费者 SignalR 客户端,来演示如何在不同的平台上使用 SignalR。SignalR 的代码在各个平台上基本相同,这使得它在您自己的应用程序中学习和实现变得容易。因此,您可以参考这些应用程序中的任何一个来了解如何在客户端应用程序中消费 SignalR 服务。

构建 TypeScript 客户端

我们将构建的第一个客户端是一个 TypeScript 客户端。这个应用程序只是一个普通的 HTML 页面,它使用 SignalR JavaScript 客户端库与 SignalR hub 进行通信。TypeScript 是 JavaScript 的超集,它提供了静态类型和其他功能,以帮助开发者编写更好的 JavaScript 代码。TypeScript 代码编译成 JavaScript 代码,因此它可以在任何 JavaScript 运行时中运行,例如浏览器和 Node.js。要了解更多关于 TypeScript 的信息,您可以访问官方网站 www.typescriptlang.org/

在应用中使用 TypeScript,我们需要安装它。你可以使用以下命令来安装:

npm install -g typescript

安装 TypeScript 后,你可以使用以下命令来检查版本:

tsc -v

如果你看到了版本号,这意味着 TypeScript 已经成功安装。

接下来,按照以下步骤创建 TypeScript 客户端:

  1. 在解决方案文件夹中创建一个名为 ChatApp.TypeScriptClient 的新文件夹。然后,在 ChatApp.TypeScriptClient 文件夹中创建一个 src 文件夹。src 文件夹用于存储 TypeScript 客户端的源代码。

  2. src 文件夹中创建一个名为 index.html 的新文件,并添加以下代码:

    <!DOCTYPE html><html lang="en"><head>    <meta charset="UTF-8">    <meta name="viewport" content="width=device-width, initial-scale=1.0">    <title>Chat App</title></head><body>    <div id="divChat">        <label for="txtUsername">User Name</label>        <input type="text" id="txtUsername" />        <label for="txtMessage">Message</label>        <input type="text" id="txtMessage" />        <button id="btnSend">Send</button>        <ul id="messages"></ul>    </div></body></html>
    

    上述代码创建了一个简单的 HTML 页面,其中包含一个文本框和一个按钮。ul 元素用于显示消息。

  3. ChatApp.TypeScriptClient 文件夹中创建一个名为 tsconfig.json 的新文件,并添加以下代码:

    {  "compilerOptions": {    "noEmitOnError": true,    "noImplicitAny": true,    "sourceMap": true,    "target": "es6",    "moduleResolution":"node"  },  "files": ["src/app.ts"],  "compileOnSave": true}
    

    上述代码是 TypeScript 编译器的配置文件。它指定了目标 JavaScript 版本、模块系统和其他选项。它还指定了要编译的 TypeScript 文件,例如 app.ts。我们将在 第 6 步 中创建一个 app.ts 文件。

  4. 接下来,我们需要设置 npm,以便我们可以安装所需的包。使用以下命令来初始化 npm

    package.json file in the ChatApp.TypeScriptClient folder. The package.json file is used to manage the dependencies of the project. It also contains other information about the project, such as the name, version, description, and so on.
    
  5. 接下来,我们需要安装所需的包。使用以下命令来安装所需的包:

    @microsoft/signalr package is the official SignalR JavaScript client library. The @types/node package is used to provide type definitions for Node.js.
    
  6. src 文件夹中创建一个名为 app.ts 的新文件,并添加以下代码:

    import * as signalR from "@microsoft/signalr";const txtUsername: HTMLInputElement = document.getElementById(  "txtUsername") as HTMLInputElement;const txtMessage: HTMLInputElement = document.getElementById(  "txtMessage") as HTMLInputElement;const btnSend: HTMLButtonElement = document.getElementById(  "btnSend") as HTMLButtonElement;btnSend.disabled = true;const connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub")  .build();connection.on("ReceiveMessage", (username: string, message: string) => {  const li = document.createElement("li");  li.textContent = `${username}: ${message}`;  const messageList = document.getElementById("messages");  messageList.appendChild(li);  messageList.scrollTop = messageList.scrollHeight;});connection  .start()  .then(() => (btnSend.disabled = false))  .catch((err) => console.error(err.toString()));txtMessage.addEventListener("keyup", (event) => {  if (event.key === "Enter") {    sendMessage();  }});btnSend.addEventListener("click", sendMessage);function sendMessage() {  connection    .invoke("SendMessage", txtUsername.value, txtMessage.value)    .catch((err) => console.error(err.toString()))    .then(() => (txtMessage.value = ""));}
    

    上述代码创建了一个到 SignalR hub 的 SignalR 连接。connection 对象用于向 SignalR hub 发送消息并从 SignalR hub 接收消息。withURL() 方法用于指定 SignalR hub 的 URL。在这种情况下,我们使用 https://localhost:7159/chatHub 作为 URL。如果你的 SignalR hub 部署在不同的 URL 上,你需要相应地更改它。

    当页面加载时,connection 对象有几个在这个示例中使用的函数:

    • on() 方法接受两个参数:第一个参数是方法的名称,即我们在 ChatHub 类中定义的 RecieveMessage(),第二个参数是一个回调函数,当收到消息时会被调用。

    • 当用户点击 invoke() 方法时,会调用 invoke() 方法,它接受三个参数:第一个参数是我们想在 SignalR hub 上调用的方法的名称,即 SendMessage(),正如我们在 ChatHub 类中定义的那样,第二个参数是用户名,第三个参数是消息。

    确保使用正确的方法名称。否则,客户端将无法与 SignalR hub 通信。

  7. 接下来,我们需要将 TypeScript 代码编译成 JavaScript 代码。我们将使用 Gulp 来自动化编译过程。如果你更喜欢使用其他工具,例如 Webpack,你也可以使用它们。使用以下命令来全局安装 Gulp:

    gulp and gulp-typescript in the project:
    
    

    ChatApp.TypeScriptClient 文件夹中的 gulpfile.js 文件中添加以下代码:

    const gulp = require('gulp');const browserify = require('browserify');const source = require('vinyl-source-stream');const buffer = require('vinyl-buffer');const sourcemaps = require('gulp-sourcemaps');const tsify = require('tsify');// Bundle TypeScript with SignalRgulp.task('bundle', () => {  return browserify({    basedir: '.',    debug: true,    entries: ['src/app.ts'], // Replace with your TypeScript entry file    cache: {},    packageCache: {},  })    .plugin(tsify)    .bundle()    .pipe(source('bundle.js'))    .pipe(buffer())    .pipe(sourcemaps.init({ loadMaps: true }))    .pipe(sourcemaps.write('./'))    .pipe(gulp.dest('dist'));});// Copy HTMLgulp.task('copy-html', () => {  return gulp.src('src/**/*.html')    .pipe(gulp.dest('dist'));});// Main build taskgulp.task('default', gulp.series('bundle', 'copy-html'));
    

    gulp 配置文件定义了一些任务,用于将 TypeScript 代码编译成 JavaScript 并生成捆绑文件。此外,它将 HTML 文件复制到 dist 文件夹,该文件夹用于存储编译后的 JavaScript 代码和 HTML 文件。如果需要,可以更改文件夹名称。捆绑文件加载 SignalR JavaScript 客户端库和编译后的 JavaScript 代码。

    
    
  8. package.json 文件中添加一个脚本来运行 gulp 任务:

    "scripts": {  "gulp": "gulp"}
    

    完整的 package.json 文件应如下所示:

    {  "name": "chatapp.typescriptclient",  "version": "1.0.0",  "description": "",  "main": "index.js",  "scripts": {    "gulp": "gulp"  },  "keywords": [],  "author": "",  "license": "ISC",  "dependencies": {    "@microsoft/signalr": "⁸.0.0",    "@types/node": "²⁰.9.0"  },  "devDependencies": {    "@microsoft/signalr": "⁸.0.0",    "browserify": "¹⁷.0.0",    "gulp": "⁴.0.2",    "gulp-sourcemaps": "³.0.0",    "gulp-typescript": "⁶.0.0-alpha.1",    "tsify": "⁵.0.4",    "vinyl-buffer": "¹.0.1",    "vinyl-source-stream": "².0.0"  }}
    
  9. 接下来,更新 src 文件夹中的 index.html 文件以加载捆绑文件:

    <!-- Omitted -->    <script src="img/bundle.js"></script></body></html>
    
  10. 运行以下命令以编译 TypeScript 代码并将 HTML 文件复制到 dist 文件夹:

    dist folder. It will also copy the HTML files to the dist folder. If the command is executed successfully, you should see three files in the dist folder: bundle.js, bundle.js.map, and index.html. In the next sections, if you make any changes to the TypeScript code, you need to run this command again to compile the TypeScript code.The development of the TypeScript client is now complete. To test it, we need to run a web server to host the HTML page. VS Code has some extensions that can be used to run a web server. For example, you can use the `index.html` file in the `dist` folder and select the **Show Preview** menu to run the web server. You will see VS Code opens a new tab and displays the HTML page, as shown next:
    

图 13.1 – 在 VS Code 中运行 TypeScript 客户端

图 13.1 – 在 VS Code 中运行 TypeScript 客户端

您还可以尝试一些其他工具,例如 http-server

  1. 现在,通过运行以下命令启动 SignalR 服务器:

    Program.cs file:
    
    

    // 启用 CORSvar corsPolicy = new CorsPolicyBuilder()    .AllowAnyHeader()    .AllowAnyMethod()    .AllowCredentials()    .WithOrigins("http://127.0.0.1:3000")    .Build();builder.Services.AddCors(options =>{    options.AddPolicy("CorsPolicy", corsPolicy);});

    
    The preceding code allows cross-origin requests from `http://127.0.0.1:3000`, which is the URL of the **Live Preview** web server. You can change it to the URL of your web server if you are using a different web server. Note that this example is a very basic configuration that does not restrict any HTTP headers or HTTP methods. In a real-world application, you may need to restrict HTTP requests to improve the security of the application. For more details about CORS, you can refer to the official documentation at [`learn.microsoft.com/en-us/aspnet/core/security/cors`](https://learn.microsoft.com/en-us/aspnet/core/security/cors).
    
  2. 重新启动 SignalR 服务器并刷新网页。您应该看到 发送 按钮已启用。输入用户名和消息,然后点击 发送 按钮。您应该看到消息显示在列表中,如下所示:

图 13.2 – 从 TypeScript 客户端发送消息

图 13.2 – 从 TypeScript 客户端发送消息

  1. 打开另一个浏览器标签,并输入相同的 URL。输入不同的用户名和消息,然后点击 发送 按钮。您应该看到两个浏览器标签中都显示了消息,如下所示:

图 13.3 – 从另一个浏览器标签发送消息

图 13.3 – 从另一个浏览器标签发送消息

TypeScript 客户端现在已完成。这是一个非常简单的客户端,它不使用任何 JavaScript 框架。前端开发的世界正在迅速变化。如果在测试示例代码时遇到任何问题,可以使用您喜欢的任何其他 JavaScript 框架,例如 React、Angular 或 Vue.js。SignalR 的代码对于不同的 JavaScript 框架基本相同。

构建 Blazor 客户端

我们将要构建的第二个客户端是一个 Blazor 客户端。Blazor 是一个使用 C# 构建客户端应用程序的 Web 框架。Blazor 首次在 2018 年作为 ASP.NET Core 3.0 的一部分被引入。Blazor 支持不同的托管模型:

  • Blazor Server:在这种托管模型中,Blazor 应用程序托管在 ASP.NET Core 服务器上。远程客户端通过 SignalR 连接到服务器。服务器负责处理用户交互并通过 SignalR 连接更新 UI。应用程序可以使用.NET 生态系统的全部功能和所有 ASP.NET Core 特性。这种托管模型还允许客户端下载少量代码,这意味着应用程序加载速度快,但需要持续连接到服务器。如果 SignalR 连接丢失,应用程序将无法工作。

  • Blazor WebAssembly:这种托管模型在浏览器中的 WebAssembly .NET 运行时上运行 Blazor 应用程序。Blazor 应用程序被下载到客户端,这意味着这种模型比 Blazor Server 模型需要更大的下载量。当一个 Blazor WebAssembly 应用程序在 ASP.NET Core 应用程序内托管时,它被称为托管 Blazor WebAssembly。托管 Blazor WebAssembly 应用程序可以与 ASP.NET Core 应用程序共享代码。当一个 Blazor WebAssembly 应用程序在没有服务器端代码的静态网站上托管时,它被称为独立 Blazor WebAssembly。独立的 Blazor WebAssembly 应用程序类似于纯客户端应用程序,例如 React 应用程序,因此它可以托管在任何 Web 服务器或内容分发网络CDN)上。Blazor WebAssembly 应用程序可以离线工作,但性能取决于客户端的硬件。

  • Blazor 混合模式:这种模型允许 Blazor 应用程序在.NET 原生应用程序框架(如 WPF、Windows Forms 和 MAUI)中运行。这种模型结合了 Web 和原生应用程序的力量,并可以使用.NET 平台的全部功能。它适合构建跨平台应用程序,因为 Blazor 代码可以在不同的平台上共享。然而,仍然需要为不同的平台打包应用程序。

在这个示例应用程序中,我们将使用独立的 Blazor WebAssembly 来构建客户端应用程序,因为基于 Web 的应用程序是最常见的场景之一。但也可以使用类似的代码为其他托管模型服务。ASP.NET Core 8 为 Blazor 带来了一些改进。要了解更多关于 Blazor 的信息,您可以访问官方网站learn.microsoft.com/en-us/aspnet/core/blazor/

要创建 Blazor WebAssembly 应用程序,请按照以下步骤操作:

  1. 导航到ChatApp.sln解决方案的根目录。创建一个名为ChatApp.BlazorClient的新 Blazor WebAssembly 应用程序,并使用以下命令将其添加到解决方案中:

    ChatApp.BlazorClient folder and run the following command to install the SignalR client library:
    
    

    /Components/Pages/Home.razor文件中的页面指令:

    @using Microsoft.AspNetCore.SignalR.Client@implements IAsyncDisposable
    

    这个using语句将 SignalR 客户端库导入到Home组件中。implements IAsyncDisposable语句表示Home组件实现了IAsyncDisposable接口。IAsyncDisposable接口用于异步释放资源。我们将用它来在组件不再使用时释放 SignalR 连接。

    
    
  2. 将以下代码添加到Home.razor文件的末尾:

    @code {    private HubConnection? _hubConnection;    private readonly List<string> _messages = new ();    private string? _username;    private string? _message;    private bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;    protected override async Task OnInitializedAsync()    {        _hubConnection = new HubConnectionBuilder()        .WithUrl("https://localhost:7159/chatHub")        .Build();        _hubConnection.On<string, string>("ReceiveMessage", (username, message) =>        {            var encodedMessage = $"{username}: {message}";            _messages.Add(encodedMessage);            StateHasChanged();        });        await _hubConnection.StartAsync();    }    private async Task SendMessage()    {        if (_hubConnection != null && IsConnected)        {            await _hubConnection!.InvokeAsync("SendMessage", _username, _message);            _message = string.Empty;        }    }    public async ValueTask DisposeAsync()    {        if (_hubConnection is not null)        {            await _hubConnection.DisposeAsync();        }    }}
    

    Blazor 利用@code指令将 C#代码集成到组件中。在这个例子中,我们为Home组件定义了一些字段和方法。如果你将此代码与其 TypeScript 对应版本进行比较,你会发现逻辑非常相似。OnInitializedAsync()方法用于设置 SignalR 连接,而SendMessage()方法用于调用 SignalR hub 的SendMessage()方法来发送消息。DisposeAsync()方法用于在组件不再使用时释放 SignalR 连接。此外,StateHasChanged()方法用于通知组件重新渲染 UI。

  3. 接下来,我们需要将这些字段绑定到 UI 上。在@code指令之前添加以下代码:

    <div id="username-group">    <label>User Name</label>    <input type="text" @bind="_username" /></div><div id="message-group">    <label>Message</label>    <input type="text" @bind="_message" /></div><input type="button" value="Send" @onclick="SendMessage" disabled="@(!IsConnected)" /><ul>    @foreach (var message in _messages)    {        <li>@message</li>    }</ul>
    

    Blazor 使用@符号来指示 C#表达式。@bind指令用于将输入元素的值绑定到指定的字段。@onclick指令用于将点击事件绑定到指定的方法。@foreach指令用于遍历消息并在列表中显示它们。如果你熟悉任何现代 JavaScript 框架,如 React、Angular 或 Vue.js,你会在 Blazor 和这些框架之间发现一些相似之处。

  4. 接下来,我们需要为 SignalR 服务器配置 CORS 策略,以便 Blazor 客户端可以连接到 SignalR hub。检查Properties文件夹中的launchSettings.json文件。与 SignalR 服务器应用程序类似,我们可以使用httphttps配置文件来运行 Blazor 客户端应用程序。在这种情况下,我们将使用https配置文件。例如,示例代码的 URL 使用https://localhost:7093在 HTTPS 配置文件上运行 Blazor 客户端应用程序。我们需要更新 SignalR 服务器的 CORS 策略。更新ChatApp.Server项目的Program.cs文件,如下所示:

    var corsPolicy = new CorsPolicyBuilder()    .AllowAnyHeader()    .AllowAnyMethod()    .AllowCredentials()    .WithOrigins("http://127.0.0.1:3000", "https://localhost:7093")    .Build();
    

    现在,SignalR 服务器可以接受来自 Blazor 客户端应用程序的跨源请求。

  5. 使用dotnet run --launch-profile https命令在单独的终端中运行 SignalR 服务器应用程序和 Blazor 客户端应用程序。你可以在浏览器中打开https://localhost:7093 URL 来测试 Blazor 客户端应用程序。Blazor 客户端可以与 TypeScript 客户端进行聊天,如下所示:

图 13.4 – Blazor 客户端和 TypeScript 客户端之间的聊天

图 13.4 – Blazor 客户端和 TypeScript 客户端之间的聊天

SignalR 提供了实时通信的便利性。开发者不需要操作底层传输细节;相反,他们可以使用 SignalR 的 Hub 类轻松发送和接收消息。在下一节中,我们将探讨 SignalR 集线器(hub)的更多功能。

在 SignalR 中使用身份验证和授权

在上一节中,我们使用 Hub 类实现了一个简单的聊天应用。Clients.All.SendAsync 方法用于向所有已连接的客户端发送消息。有时,我们可能想向特定的客户端或一组客户端发送消息。为了管理用户和组,我们需要知道用户的身份。在本节中,我们将探讨如何在 SignalR 中使用身份验证和授权。

默认情况下,SignalR 使用 ClaimTypes.NameIdentifier 断言来区分用户。ClaimTypes.NameIdentifier 断言用于唯一标识用户。我们在 第八章 中介绍了基于断言的授权,因此我们将遵循该章节的步骤将身份验证和授权添加到 SignalR 服务器应用程序。如果您不熟悉 ASP.NET Core 身份验证和授权,您可以参考 第八章 获取更多详细信息。

您可以在 GitHub 仓库的 chapter13/v2 文件夹中找到示例代码的完整代码。

将身份验证和授权添加到 SignalR 服务器

要将身份验证和授权添加到 SignalR 服务器,请按照以下步骤操作:

  1. 使用以下命令安装所需的包:

    Data in the ChatApp.Server project. Then, create a new class called AppbContext in the Data folder. As we introduced DbContext in previous chapters, we will not show the code here. You can find the code in the sample application.
    
  2. appsettings.json 文件中添加一个连接字符串:

    "ConnectionStrings": {  "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=ChatAppDb;Trusted_Connection=True;MultipleActiveResultSets=true"}
    
  3. appsettings.json 文件中添加 JWT 令牌的配置:

    "JwtConfig": {  "ValidAudiences": "http://localhost:7159",  "ValidIssuer": "http://localhost:7159",  "Secret": "c1708c6d-7c94-466e-aca3-e09dcd1c2042"}
    

    我们将使用与身份验证服务器相同的 SignalR 服务器。因此,我们将使用 SignalR 服务器的 URL 作为受众和发行者。如果您使用不同的身份验证服务器,您需要相应地更改受众和发行者。

  4. SignalR 需要一个 IUserIdProvider 接口来获取用户 ID。在 ChatApp.Server 项目中创建一个名为 Services 的新文件夹。然后,在 Services 文件夹中创建一个名为 NameUserIdProvider 的新类:

    using Microsoft.AspNetCore.SignalR;namespace ChatApp.Server.Services;public class NameUserIdProvider : IUserIdProvider{    public string GetUserId(HubConnectionContext connection)    {        return connection.User?.Identity?.Name ?? string.Empty;    }}
    

    上述代码实现了 IUserIdProvider 接口。GetUserId 方法返回当前用户的用户 ID。在这种情况下,我们使用用户名作为用户 ID。您可以使用任何其他唯一值作为用户 ID。例如,如果您想使用电子邮件地址作为用户 ID,您可以创建一个名为 EmailBasedUserIdProvider 的类,如下所示:

    using System.Security.Claims;using Microsoft.AspNetCore.SignalR;namespace ChatApp.Server.Services;public class EmailBasedUserIdProvider : IUserIdProvider{    public string GetUserId(HubConnectionContext connection)    {        return connection.User?.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value ??    string.Empty;    }}
    
  5. 更新 Program.cs 文件以添加身份验证和授权,如下所示:

    builder.Services.AddDbContext<AppDbContext>();builder.Services.AddIdentityCore<IdentityUser>()    .AddEntityFrameworkStores<AppDbContext>()    .AddDefaultTokenProviders();builder.Services.AddAuthentication(options =>{    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;}).AddJwtBearer(options =>{    var secret = builder.Configuration["JwtConfig:Secret"];    var issuer = builder.Configuration["JwtConfig:ValidIssuer"];    var audience = builder.Configuration["JwtConfig:ValidAudiences"];    if (secret is null || issuer is null || audience is null)    {        throw new ApplicationException("Jwt is not set in the configuration");    }    options.SaveToken = true;    options.RequireHttpsMetadata = false;    options.TokenValidationParameters = new TokenValidationParameters()    {        ValidateIssuer = true,        ValidateAudience = true,        ValidAudience = audience,        ValidIssuer = issuer,        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret))    };    // Hook the SignalR event to check for the token in the query string    options.Events = new JwtBearerEvents    {        OnMessageReceived = context =>        {            var accessToken = context.Request.Query["access_token"];            var path = context.HttpContext.Request.Path;            if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/chatHub"))            {                context.Token = accessToken;            }            return Task.CompletedTask;        }    };});// Use the name-based user ID providerbuilder.Services.AddSingleton<IUserIdProvider, NameUserIdProvider>();
    

    上述代码与第八章中的代码类似。一个区别是我们配置了JwtBearerOptions对象的options.Events属性。OnMessageReceived事件用于检查查询字符串中的令牌。原因是 WebSocket API 和 SSE 不支持标准的Authorization头,因此需要将令牌附加到查询字符串。如果查询字符串中找到令牌,它将被用来验证用户。

    我们还向之前创建的NameUserIdProvider类中添加了IUserIdProvider服务。如果您想使用EmailBasedUserIdProvider类,您需要相应地更改代码。请注意,您不能同时使用这两个类。

  6. 创建数据库并使用以下命令运行迁移:

    Authorize attribute to the ChatHub class, as shown next:
    
    

    [授权]public class ChatHub : Hub{    // 省略以节省空间}

    
    The `Authorize` attribute can be applied to the `Hub` class or methods of the `Hub` class. It also supports policy-based authorization. For example, you can use the `Authorize(Policy = "Admin")` attribute to restrict access to the `ChatHub` class to administrators.
    
  7. 运行ChatApp.Server应用程序以及任何其他客户端应用程序。遗憾的是,TypeScript 和 Blazor 客户端将无法连接到 SignalR hub,因为需要用户认证。要访问 SignalR hub,我们需要验证客户端。

添加登录端点

为了验证客户端,我们需要提供一个登录端点。我们在第八章中实现了登录端点。您可以按照第八章中的步骤来实现登录端点或从示例应用程序中复制代码。您需要创建一个包含注册和登录端点的AccountController类。您还需要添加一些模型,例如LoginModelAddOrUpdateUserModel类。有了这些类,我们可以使用account/registeraccount/login端点来注册和登录用户。

这里需要注意的是,在生成 JWT 令牌时,我们需要向令牌中添加一个ClaimTypes.NameIdentifier声明。SignalR 使用此声明来识别用户。以下代码显示了如何向令牌中添加ClaimTypes.NameIdentifier声明:

var tokenDescriptor = new SecurityTokenDescriptor{
    Subject = new ClaimsIdentity(new[]
    {
        // SignalR requires the NameIdentifier claim to map the user to the connection
        new Claim(ClaimTypes.NameIdentifier, userName),
        new Claim(ClaimTypes.Name, userName),
        // If you use the email-based user ID provider, you need to add the email claim from the database
    }),
    Expires = DateTime.UtcNow.AddDays(1),
    Issuer = issuer,
    Audience = audience,
    SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256Signature)
};

现在,我们需要为测试创建一些用户。运行ChatApp.Server应用程序,并使用 Postman 或其他 HTTP 客户端向account/register端点发送POST请求。以下代码显示了如何使用account/register端点创建用户:

{  "userName": "user1",
  "email": "user1@example.com",
  "password": "Passw0rd!"
}

创建更多用户,例如user2user3等。我们将使用这些用户来测试后续的Groups功能。

验证 TypeScript 客户端

现在,我们可以验证 TypeScript 客户端。为此,我们需要更新 UI 以允许用户输入用户名和密码。我们还需要更新 TypeScript 代码以将用户名和密码发送到登录端点。按照以下步骤更新 TypeScript 客户端:

  1. 按照以下方式更新<body>元素中的 HTML 内容:

    <body>    <div id="divLogin">        <label for="txtUsername">User Name</label>        <input type="text" id="txtUsername" />        <label for="txtPassword">Password</label>        <input type="password" id="txtPassword" />        <button id="btnLogin">Login</button>    </div>    <div id="divChat">        <label>User Name</label>        <label id="lblUsername" ></label>        <label for="txtMessage">Message</label>        <input type="text" id="txtMessage" />        <button id="btnSend">Send</button>        <ul id="messages"></ul>    </div>    <script type="module" src="img/bundle.js"></script></body>
    

    以下代码将登录表单添加到 HTML 页面。登录表单包含用户名文本框、密码文本框和登录按钮。divChat 元素现在有一个 lblUsername 元素来显示用户名。divChat 元素默认是隐藏的。用户身份验证后,我们将显示它。

  2. 按照以下方式更新 app.ts 文件:

    import * as signalR from "@microsoft/signalr";divChat.style.display = "none";btnSend.disabled = true;btnLogin.addEventListener("click", login);let connection: signalR.HubConnection = null;async function login() {  const username = txtUsername.value;  const password = txtPassword.value;  if (username && password) {    try {      // Use the Fetch API to login      const response = await fetch("https://localhost:7159/account/login", {        method: "POST",        headers: { "Content-Type": "application/json" },        body: JSON.stringify({ username, password }),      });      const json = await response.json();      localStorage.setItem("token", json.token);      localStorage.setItem("username", username);      txtUsername.value = "";      txtPassword.value = "";      lblUsername.textContent = username;      divLogin.style.display = "none";      divChat.style.display = "block";      txtMessage.focus();      // Start the SignalR connection      connection = new signalR.HubConnectionBuilder()        .withUrl("https://localhost:7159/chatHub", {          accessTokenFactory: () => {           var localToken = localStorage.getItem("token");           // You can add logic to check if the token is valid or expired           return localToken;         },        })        .build();      connection.on("ReceiveMessage", (username: string, message: string) => {        const li = document.createElement("li");        li.textContent = `${username}: ${message}`;        const messageList = document.getElementById("messages");        messageList.appendChild(li);        messageList.scrollTop = messageList.scrollHeight;      });      await connection.start();      btnSend.disabled = false;    } catch (err) {      console.error(err.toString());    }  }}txtMessage.addEventListener("keyup", (event) => {  if (event.key === "Enter") {    sendMessage();  }});btnSend.addEventListener("click", sendMessage);function sendMessage() {  connection    .invoke("SendMessage", lblUsername.textContent, txtMessage.value)    .catch((err) => console.error(err.toString()))    .then(() => (txtMessage.value = ""));}
    

    一些代码被省略了。您可以从书籍的 GitHub 仓库中找到完整的代码。

    在前面的代码中,我们使用 fetch API 向登录端点发送 POST 请求。如果用户已通过身份验证,登录端点将返回 JWT 令牌。然后,我们将令牌存储在本地存储中,并在 divChat 元素中显示用户名。我们还调整了 SignalR 连接的创建。accessTokenFactory 属性用于从本地存储中获取令牌。您可以添加一些逻辑来检查令牌是否有效或已过期。如果令牌已过期,您可以重定向用户到登录页面或使用 dist 文件夹:

    npm run gulp
    
  3. 使用 Live Preview 扩展运行 Web 服务器。同时运行 SignalR 服务器应用程序。您将看到一个登录表单,如下所示:

图 13.5 – 登录表单

图 13.5 – 登录表单

使用您之前创建的用户名和密码登录。您应该看到一个聊天表单,如下所示:

图 13.6 – 已验证聊天

图 13.6 – 已验证聊天

现在,TypeScript 客户端已经进行了身份验证。接下来,我们将验证 Blazor 客户端。

验证 Blazor 客户端

验证 Blazor 客户端的代码与 TypeScript 客户端非常相似,因此我们在此不列出所有代码。您可以在示例应用程序中找到代码。以下代码展示了如何登录并将令牌设置到 SignalR 连接中:

@inject HttpClient Httpprivate async Task Login()
{
    if (!string.IsNullOrWhiteSpace(_username) && !string.IsNullOrWhiteSpace(_password))
    {
        var response = await Http.PostAsJsonAsync("Account/login", new { Username = _username, Password = _password });
        if (response.IsSuccessStatusCode)
        {
            var jsonString = await response.Content.ReadAsStringAsync();
            var data = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(jsonString);
            _token = data["token"];
            if (string.IsNullOrWhiteSpace(_token))
            {
                throw new Exception("Invalid token.");
            }
            else
            {
                _showLogin = false;
                _showChat = true;
                StateHasChanged();
                // Set the token to the hub connection.
                _hubConnection = new HubConnectionBuilder()
                .WithUrl("https://localhost:7159/chatHub", options =>
                {
                    options.AccessTokenProvider = () => Task.FromResult<string?>(_token);
                })
                .Build();
                _hubConnection.On<string, string>("ReceiveMessage", (username, message) =>
                {
                    var encodedMessage = $"{username}: {message}";
                    _messages.Add(encodedMessage);
                    StateHasChanged();
                });
                await _hubConnection.StartAsync();
            }
        }
    }
}

在前面的代码中,我们注入 HttpClient 向登录端点发送 POST 请求。然后,我们将令牌设置到 SignalR 连接中。AccessTokenProvider 属性用于从 _token 字段获取令牌。类似于 TypeScript 客户端,您可以添加一些逻辑来检查令牌是否有效或已过期。

运行三个应用程序。您可以使用不同的用户名登录到两个客户端并发送消息。您应该看到消息在两个客户端中显示,如下所示:

图 13.7 – 不同用户的已验证聊天

图 13.7 – 不同用户的已验证聊天

客户端现在支持身份验证。接下来,我们将向聊天应用添加更多功能。

管理用户和组

在上一节中,我们为 SignalR 服务器实现了基本的身份验证和授权。我们还更新了客户端以验证用户。在本节中,我们将探讨如何在 SignalR 中管理用户和组。我们希望为聊天应用添加以下功能:

  • 允许用户知道谁连接到了聊天应用

  • 允许用户向特定用户发送消息

  • 允许用户加入组

  • 允许用户向特定组发送消息

你可以在 GitHub 仓库的chapter13/v3文件夹中找到示例代码的完整代码。让我们从第一个功能开始。

管理 SignalR 中的事件

SignalR 提供事件来通知客户端当用户连接或断开连接时。我们可以重写OnConnectedAsync()OnDisconnectedAsync()方法来处理这些事件。以下代码展示了如何重写OnConnectedAsync()方法:

public override async Task OnConnectedAsync(){
    await Clients.All.SendAsync("UserConnected", Context.User.Identity.Name);
    await base.OnConnectedAsync();
}

当客户端连接到 SignalR 中心时,将调用OnConnectedAsync()方法。在这种情况下,我们使用Clients.All.SendAsync()方法向所有已连接的客户端发送消息。Context.User.Identity.Name属性用于获取当前用户的用户名。

以下代码展示了如何重写OnDisconnectAsync()方法:

public override async Task OnDisconnectedAsync(Exception? exception){
    await Clients.All.SendAsync("UserDisconnected", Context.User.Identity.Name);
    await base.OnDisconnectedAsync(exception);
}

然后,我们可以更新 TypeScript 客户端以处理UserConnectedUserDisconnected事件。以下代码展示了如何在 TypeScript 客户端中处理UserConnected事件:

connection.on("UserConnected", (username: string) => {  const li = document.createElement("li");
  li.textContent = `${username} connected`;
  const messageList = document.getElementById("messages");
  messageList.appendChild(li);
  messageList.scrollTop = messageList.scrollHeight;
});
connection.on("UserDisconnected", (username: string) => {
  const li = document.createElement("li");
  li.textContent = `${username} disconnected`;
  const messageList = document.getElementById("messages");
  messageList.appendChild(li);
  messageList.scrollTop = messageList.scrollHeight;
});

Blazor 客户端中的代码非常相似:

_hubConnection.On<string>("UserConnected", (username) =>{
    var encodedMessage = $"{username} connected.";
    _messages.Add(encodedMessage);
    StateHasChanged();
});
_hubConnection.On<string>("UserDisconnected", (username) =>
{
    var encodedMessage = $"{username} disconnected.";
    _messages.Add(encodedMessage);
    StateHasChanged();
});

现在,我们可以运行 SignalR 服务器和两个客户端。你应该在聊天窗口中看到用户的连接和断开连接消息。如果你刷新页面或关闭浏览器标签,你应该看到用户断开连接的消息,如下所示:

图 13.8 – 用户连接和断开连接的消息

图 13.8 – 用户连接和断开连接的消息

接下来,我们将添加一个功能,允许用户向特定用户发送消息。

向特定用户发送消息

我们想要添加的下一个功能是允许用户向特定用户发送消息。为此,我们需要知道消息发送给谁。SignalR 使用ClaimTypes.NameIdentifier声明来区分用户。为了简化代码,我们将用户名作为目标用户传递:

public Task SendMessageToUser(string user, string toUser, string message){
    return Clients.User(toUser).SendAsync("ReceiveMessage", user, message);
}

前面的代码使用Clients.User(user)方法来查找指定用户的连接。

接下来,更新 TypeScript 客户端以添加一个文本框来输入目标用户名。以下代码展示了如何更新divChat元素的 HTML 内容:

<label for="txtToUser">To</label><input type="text" id="txtToUser" />

然后,我们可以从 TypeScript 客户端按如下方式调用此方法:

function sendMessage() {  // If the txtToUser field is not empty, send the message to the user
  if (txtToUser.value) {
    connection
      .invoke("SendMessageToUser", lblUsername.textContent, txtToUser.value, txtMessage.value)
      .catch((err) => console.error(err.toString()))
      .then(() => (txtMessage.value = ""));
  } else {
    connection
      .invoke("SendMessage", lblUsername.textContent, txtMessage.value)
      .catch((err) => console.error(err.toString()))
      .then(() => (txtMessage.value = ""));
  }
}

在前面的代码中,当txtToUser字段不为空时,我们使用SendMessageToUser()方法向指定的用户发送消息。否则,我们使用SendMessage()方法向所有已连接的用户发送消息。

Blazor 客户端中的代码非常相似:

private async Task SendMessage(){
    if (_hubConnection != null && IsConnected)
    {
        if (!string.IsNullOrWhiteSpace(_toUser))
        {
            await _hubConnection.InvokeAsync("SendMessageToUser", _username, _toUser, _message);
        }
        else
        {
            await _hubConnection.InvokeAsync("SendMessage", _username, _message);
        }
        _message = string.Empty;
    }
}

请参阅示例应用程序以获取完整代码。

运行三个应用程序。这次,我们需要打开三个浏览器标签进行测试。使用三个不同的用户名登录到三个客户端。然后,我们可以向特定用户发送消息,如下所示:

图 13.9 – 向特定用户发送消息

图 13.9 – 向特定用户发送消息

图 13.9 中,我们从user2用户向user1用户发送了一条消息。您可以看到,消息显示在user1的浏览器标签中,但没有显示在user3的浏览器标签中。

您可以尝试在不同的浏览器标签中登录相同的用户名。您会发现两个浏览器标签都会收到消息。这是因为 SignalR 使用ClaimTypes.NameIdentifier声明来区分用户。每个浏览器标签都有一个不同的 SignalR 连接,但它们使用相同的用户名。因此,SignalR 将它们视为同一用户。

使用强类型 Hub

到目前为止,我们已经向ChatHub类添加了一些方法:

public Task SendMessage(string user, string message){
    await Clients.All.SendAsync("ReceiveMessage", user, message);
}
public Task SendMessageToUser(string user, string toUser, string message)
{
    return Clients.User(toUser).SendAsync("ReceiveMessage", user, message);
}
public override async Task OnConnectedAsync()
{
    await Clients.All.SendAsync("UserConnected", Context.User.Identity.Name);
    await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
    await Clients.All.SendAsync("UserDisconnected", Context.User.Identity.Name);
    await base.OnDisconnectedAsync(exception);
}

每个方法都使用一个字符串参数调用SendAsync()方法。字符串参数是要在客户端上调用的方法名称。SendAsync()方法是一个动态方法,但它不是类型安全的。如果我们拼错了方法名称,编译器将不会报告任何错误。为了提高类型安全性,我们可以使用强类型 Hub。

要使用强类型 Hub,我们需要定义一个包含客户端方法的 Hub 接口。以下代码展示了如何定义 Hub 接口:

public interface IChatClient{
    Task ReceiveMessage(string user, string message);
    Task UserConnected(string user);
    Task UserDisconnected(string user);
}

然后,我们可以更新ChatHub类以实现IChatClient接口:

public class ChatHub : Hub<IChatClient>{
    public Task SendMessage(string user, string message)
    {
        return Clients.All.ReceiveMessage(user, message);
    }
    public Task SendMessageToUser(string user, string toUser, string message)
    {
        return Clients.User(toUser).ReceiveMessage(user, message);
    }
    public override async Task OnConnectedAsync()
    {
        await Clients.All.UserConnected(Context.User.Identity.Name);
        await base.OnConnectedAsync();
    }
    public override async Task OnDisconnectedAsync(Exception? exception)
    {
        await Clients.All.UserDisconnected(Context.User.Identity.Name);
        await base.OnDisconnectedAsync(exception);
    }
}

在前面的代码中,SendAsync()方法不再使用。相反,我们使用在IChatClient接口中定义的RecieveMessage()UserConnected()UserDisconnected()方法。Hub类是泛型的,因此我们需要指定IChatClient接口作为泛型类型参数。现在,ChatHub类是强类型的。请注意,如果您使用强类型 Hub,则SendAsync()方法将不再可用。

接下来,我们将添加一个功能,允许用户加入组。

加入组

SignalR 允许用户加入组。Hub类有一个Groups属性来管理组。Groups属性的类型是IGroupManager接口,它提供了AddToGroupAsync()RemoveFromGroupAsync()等方法。以下代码展示了如何将用户添加到组中以及如何从组中移除用户:

public async Task AddToGroup(string user, string group){
    await Groups.AddToGroupAsync(Context.ConnectionId, group);
    await Clients.Group(group).ReceiveMessage(Context.User.Identity.Name,
        $"{user} has joined the group {group}. Connection Id: {Context.ConnectionId}");
}
public async Task RemoveFromGroup(string user, string group)
{
    await Groups.RemoveFromGroupAsync(Context.ConnectionId, group);
    await Clients.Group(group).ReceiveMessage(Context.User.Identity.Name,
                   $"{user} has left the group {group}. Connection Id: {Context.ConnectionId}");
}

在前面的代码中,我们使用Groups属性来管理组。Context.ConnectionId属性用于获取当前用户的连接 ID。Clients.Group方法用于向指定组中的所有用户发送消息,以便他们知道谁加入了或离开了该组。

接下来,我们需要更新 UI 以允许用户输入组名。将以下代码添加到divChat元素的 HTML 内容中:

<label id="lblToGroup">Group</label><input type="text" id="txtToGroup" />
<button id="btnJoinGroup">Join Group</button>
<button id="btnLeaveGroup">Leave Group</button>

更新 TypeScript 代码以处理JoinGroupLeaveGroup事件。以下代码展示了如何处理JoinGroup事件:

btnJoinGroup.addEventListener("click", joinGroup);btnLeaveGroup.addEventListener("click", leaveGroup);
function joinGroup() {
  if (txtToGroup.value) {
    connection
      .invoke("AddToGroup", lblUsername.textContent, txtToGroup.value)
      .catch((err) => console.error(err.toString()))
      .then(() => {
        btnJoinGroup.disabled = true;
        btnJoinGroup.style.display = "none";
        btnLeaveGroup.disabled = false;
        btnLeaveGroup.style.display = "inline";
        txtToGroup.readOnly = true;
      });
  }
}
function leaveGroup() {
  if (txtToGroup.value) {
    connection
      .invoke("RemoveFromGroup", lblUsername.textContent, txtToGroup.value)
      .catch((err) => console.error(err.toString()))
      .then(() => {
        btnJoinGroup.disabled = false;
        btnJoinGroup.style.display = "inline";
        btnLeaveGroup.disabled = true;
        btnLeaveGroup.style.display = "none";
        txtToGroup.readOnly = false;
      });
  }
}

前面的代码展示了两个事件处理程序,用于JoinGroupLeaveGroup事件,分别在 SignalR Hub 上调用AddToGroup()RemoveFromGroup()方法。

Blazor 客户端中的代码非常相似。这里不再列出代码。您可以在示例应用程序中找到代码。

现在,客户端应该能够加入和离开组。当用户加入或离开组时,组中的其他用户将收到消息,如下所示:

图 13.10 – 加入和离开组

图 13.10 – 加入和离开组

图 13.10 中,user3 加入 group1 然后离开 group1。您可以看到 group1 中的其他用户收到了消息。

接下来,我们将添加一个功能,允许用户向特定组发送消息。

向组发送消息

向组发送消息的代码与向特定用户发送消息的代码非常相似。以下代码展示了在 ChatHub 类中如何向组发送消息:

public async Task SendMessageToGroup(string user, string group, string message){
    await Clients.Group(group).ReceiveMessage(user, message);
}

上述代码使用 Clients.Group(group) 来查找指定组中用户的连接。然后,它使用在 IChatClient 接口中定义的 ReceiveMessage() 方法向组中的用户发送消息。

Blazor 客户端可以按如下方式调用此方法:

private async Task SendMessage(){
    if (_hubConnection != null && IsConnected)
    {
        if (!string.IsNullOrWhiteSpace(_group) && _isJoinedGroup)
        {
            await _hubConnection.InvokeAsync("SendMessageToGroup", _username, _group, _message);
        }
        // Omitted for brevity
    }
}

我们在此处不会列出 TypeScript 客户端的代码。您可以在示例应用程序中找到代码。

现在,客户端应该能够向特定组发送消息。以下图示展示了如何向组发送消息:

图 13.11 – 向组发送消息

图 13.11 – 向组发送消息

您将看到 user1user3 显示了消息,因为他们处于同一个组中。但 user2user4 将不会看到消息,因为他们不在 group1 中。

从其他服务发送消息

到目前为止,我们已经实现了一个聊天应用,允许用户向其他用户或组发送消息。有时,我们需要从其他地方发送消息。例如,当发生事件时,我们可能需要发送消息来通知用户。在本节中,我们将探讨如何从其他服务发送消息。您可以在 GitHub 仓库的 chapter13/v4 文件夹中找到示例的完整代码。

我们将在 ChatApp.Server 应用程序中添加一个 REST API 端点,以允许其他系统向 SignalR 集线器发送消息。按照以下步骤在 ChatApp.Server 应用程序中添加 REST API 端点:

  1. Models 文件夹中创建以下模型:

    public class SendToAllMessageModel{    public string FromUser { get; set; } = string.Empty;    public string Message { get; set; } = string.Empty;}public class SendToUserMessageModel{    public string FromUser { get; set; } = string.Empty;    public string ToUser { get; set; } = string.Empty;    public string Message { get; set; } = string.Empty;}public class SendToGroupMessageModel{    public string FromUser { get; set; } = string.Empty;    public string GroupName { get; set; } = string.Empty;    public string Message { get; set; } = string.Empty;}
    

    这些模型用于向 SignalR 集线器发送消息。

  2. 在示例应用程序中创建一个新的控制器或使用现有的 AccountController 类。我们将在 Controllers 文件夹中创建一个 ChatController 类。

  3. IHubContext<ChatHub, IChatClient> 服务注入到 ChatController 类中:

    [Route("api/[controller]")][ApiController]public class ChatController(IHubContext<ChatHub, IChatClient> hubContext) : ControllerBase{}
    

    IHubContext<ChatHub, IChatClient> 服务用于向客户端发送消息。在这个例子中,我们使用了一个强类型集线器。如果您使用的是普通 SignalR 集线器,您也可以注入 IHubContext<ChatHub> 服务。

  4. 添加以下操作以向所有用户、特定用户和特定组发送消息:

     [HttpPost("/all")] public async Task<IActionResult> SendToAllMessage([FromBody] SendToAllMessageModel model) {     if (ModelState.IsValid)     {         await hubContext.Clients.All.ReceiveMessage(model.FromUser, model.Message);         return Ok();     }     return BadRequest(ModelState); } [HttpPost("/user")] public async Task<IActionResult> SendToUserMessage([FromBody] SendToUserMessageModel model) {     if (ModelState.IsValid)     {         await hubContext.Clients.User(model.ToUser).ReceiveMessage(model.FromUser, model.Message);         return Ok();     }     return BadRequest(ModelState); } [HttpPost("/group")] public async Task<IActionResult> SendToGroupMessage([FromBody] SendToGroupMessageModel model) {     if (ModelState.IsValid)     {         await hubContext.Clients.Group(model.GroupName).ReceiveMessage(model.FromUser, model.Message);         return Ok();     }     return BadRequest(ModelState); }
    

    上一段代码使用了 hubContext.Clients 属性向客户端发送消息。请注意,此端点未进行身份验证。如果需要,您可以为此端点添加身份验证和授权。

    • 运行三个应用程序。使用不同的用户登录并加入群组。然后,您可以使用 Postman 或任何其他 HTTP 客户端测试 chat/allchat/userchat/group 端点。

这就是从外部服务发送消息的方法。在下一节中,我们将探讨如何管理 SignalR 连接。

配置 SignalR 中心和客户端

SignalR 提供了一个 HubOptions 类来配置 SignalR 中心。此外,SignalR 客户端也有一些配置选项。在本节中,我们将探讨如何配置 SignalR 中心和客户端。您可以在 GitHub 仓库的 chapter13/v5 文件夹中找到示例的完整代码。

配置 SignalR 中心

这里是 SignalR 中心的配置选项:

  • KeepAliveInterval:此属性确定发送给客户端的保持连接消息的间隔。如果客户端在此时间段内没有从服务器收到消息,它将向服务器发送 ping 消息以维持连接。更改此值时,还重要的是要调整客户端中的 serverTimeoutInMillisecondsServerTimeout 选项。为了获得最佳结果,建议将 serverTimeoutInMillisecondsServerTimeout 选项设置为 KeepAliveInterval 属性值的两倍。KeepAliveInterval 的默认值是 15 秒。

  • ClientTimeoutInterval:此属性确定服务器在未从客户端收到消息的情况下,将客户端视为断开连接的间隔。建议将 ClientTimeoutInterval 设置为 KeepAliveInterval 属性值的两倍。ClientTimeoutInterval 的默认值是 30 秒。

  • EnableDetailedErrors:此属性确定是否向客户端发送详细错误消息。EnableDetailedErrors 的默认值是 false,因为错误消息可能包含敏感信息。

  • MaximumReceiveMessageSize:此属性确定服务器将接受的消息的最大大小。MaximumReceiveMessageSize 的默认值是 32 KB。不要将此值设置得过大,因为它可能引起 拒绝服务DoS)攻击并消耗大量内存。

  • MaximumParallelInvocationsPerClient:此属性确定每个客户端可以并行执行的 hub 方法调用的最大数量。MaximumParallelInvocationsPerClient 的默认值是 1。

  • StreamBufferCapacity:此属性确定客户端上传流中可以缓存的项的最大数量。StreamBufferCapacity 的默认值是 10。我们将在下一节中介绍流。

配置 SignalR 中心的方式有两种。第一种方式是为所有中心提供一个 HubOptions 对象。以下代码展示了如何配置 ChatHub 类:

builder.Services.AddSignalR(options =>{
    options.KeepAliveInterval = TimeSpan.FromSeconds(10);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(20);
    options.EnableDetailedErrors = true;
});

第二种方式是为每个中心配置 SignalR 中心。以下代码展示了如何配置 ChatHub 类:

builder.Services.AddSignalR().AddHubOptions<ChatHub>(options =>{
    options.KeepAliveInterval = TimeSpan.FromSeconds(10);
    options.ClientTimeoutInterval = TimeSpan.FromSeconds(20);
    options.EnableDetailedErrors = true;
});

如果您有多个中心并且想要分别配置它们,上述代码非常有用。

注意,如果您更改 SignalR 中心的 KeepAliveIntervalClientTimeoutInterval 属性,您还需要在客户端更新 serverTimeoutInMillisecondsServerTimeout 选项。以下代码展示了如何配置 TypeScript 客户端:

connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub", {
    // Omitted for brevity
  })
  .build();
// The following configuration must match the configuration in the server project
connection.keepAliveIntervalInMilliseconds = 10000;
connection.serverTimeoutInMilliseconds = 20000;

HubConnection 对象具有 keepAliveIntervalInMilliseconds 属性和 serverTimeoutInMilliseconds 属性,这些属性可以用于匹配服务器项目中的配置。

类似地,您还可以如下配置 Blazor 客户端:

_hubConnection = new HubConnectionBuilder()    .WithUrl("https://localhost:7159/chatHub", options =>
    {
        // Omitted for brevity
    })
    .Build();
_hubConnection.KeepAliveInterval = TimeSpan.FromSeconds(10);
_hubConnection.ServerTimeout = TimeSpan.FromSeconds(20);
You can also configure these properties on the HubConnectionBuilder object as shown below:
_hubConnection = new HubConnectionBuilder()
    .WithUrl("https://localhost:7159/chatHub", options =>
    {
        // Omitted for brevity
    })
    .WithKeepAliveInterval(TimeSpan.FromSeconds(10))
    .WithServerTimeout(TimeSpan.FromSeconds(20))
    .Build();

确保服务器和客户端中 KeepAliveIntervalClientTimeout/ServerTimeout 属性的值相同。

HTTP 配置选项

SignalR 可以自动与客户端协商传输协议。默认传输协议是 WebSockets。如果客户端不支持 WebSockets,SignalR 将使用 SSE 或长轮询。您可以配置 SignalR 的 HTTP 选项。以下代码展示了如何为 ChatHub 类配置 HTTP 选项:

app.MapHub<ChatHub>("/chatHub", options =>{
    options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
    options.WebSockets.CloseTimeout = TimeSpan.FromSeconds(10);
    options.LongPolling.PollTimeout = TimeSpan.FromSeconds(120);
});

上述代码使用 HttpConnectionDispatcherOptions 对象为 ChatHub 类配置了 HTTP 选项。在此示例中,我们配置了 Transports 属性以使用 WebSockets 和长轮询,但不使用 SSE。此外,我们还配置了 WebSockets 属性的 CloseTimeout 属性为 10 秒,以及 LongPolling 属性的 PollTimeout 属性为 120 秒。CloseTimeout 属性的默认值是 5 秒,这意味着如果客户端在 5 秒内无法关闭连接,连接将被终止。PollTimeout 属性的默认值是 90 秒,这意味着服务器将在等待 90 秒后终止轮询请求,然后创建一个新的轮询请求。

允许的传输可以在客户端进行配置。我们可以如下配置 TypeScript 客户端:

connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub", {
    transport: signalR.HttpTransportType.WebSockets | signalR.HttpTransportType.LongPolling,
  })
  .build();

以下代码展示了如何配置 Blazor 客户端:

_hubConnection = new HubConnectionBuilder()    .WithUrl("https://localhost:7159/chatHub", options =>
    {
        options.Transports = HttpTransportType.WebSockets | HttpTransportType.LongPolling;
    })
    .Build();

HttpTransportType 枚举具有 FlagsAttribute 属性,因此您可以使用位运算符 OR 来组合多个传输协议。

自动重新连接

有时,由于网络问题,SignalR 连接可能会断开。例如,如果用户的设备从 Wi-Fi 切换到蜂窝网络,或者如果用户的设备处于隧道中,SignalR 连接可能会断开。在这种情况下,我们希望客户端自动重新连接到服务器:

  1. SignalR 允许客户端在连接断开时自动重新连接到服务器。以下代码展示了如何配置 TypeScript 客户端以自动重新连接到服务器:

    connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub", {    // Omitted for brevity  })  .withAutomaticReconnect()  .build();
    
  2. 类似地,您可以按以下方式配置 Blazor 客户端:

    _hubConnection = new HubConnectionBuilder()    .WithUrl("https://localhost:7159/chatHub", options =>    {        // Omittted for brevity    })    .WithAutomaticReconnect()    .Build();
    
  3. 默认情况下,当连接断开时,客户端将在 0 秒、2 秒、10 秒和 30 秒后尝试重新连接到 SignalR 服务器。您可以按以下方式配置重试策略:

    connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub", {    // Omittted for brevity  })  .withAutomaticReconnect([0, 5, 20])  .build();
    

    withAutomaticReconnect()方法接受一个数字数组来配置毫秒级的延迟持续时间。在前面代码中,客户端将在 0 秒、5 秒和 20 秒后尝试重新连接到服务器。

  4. 在 Blazor 客户端中,您可以按以下方式配置重试策略:

    _hubConnection = new HubConnectionBuilder()    .WithUrl("https://localhost:7159/chatHub", options =>    {        // Omitted for brevity    })    .WithAutomaticReconnect(new[] { TimeSpan.FromSeconds(0), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(20) })    .Build();
    

    前面的代码配置了与 TypeScript 客户端相同的重试策略。

  5. 为了测试自动重连功能,我们可以在标签上添加一个显示连接状态的标签。将以下代码添加到divChat元素的 HTML 内容中:

    <div class="form-group mb-3">  <label>Status</label>  <label id="lblStatus"></label></div>
    
  6. 然后,更新 TypeScript 代码以显示连接状态:

    connection.onclose(() => {  lblStatus.textContent = "Disconnected.";});connection.onreconnecting((error) => {  lblStatus.textContent = `${error} Reconnecting...`;});connection.onreconnected((connectionId) => {  lblStatus.textContent = `Connected. ${connectionId}`;});await connection.start();lblStatus.textContent = `Connected. ${connection.connectionId}`;
    
  7. 我们还可以启用调试日志以查看连接状态。以下代码展示了如何进行此操作:

    connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub", {    // Omitted for brevity  })  .configureLogging(signalR.LogLevel.Debug)  // Omitted for brevity
    

    您可以在示例应用程序中找到完整的代码。

  8. 运行 SignalR 服务器和 TypeScript 客户端。按F12打开 TypeScript 客户端的开发者工具。点击网络选项卡,您可以更改网络条件以模拟网络问题。例如,您可以将网络更改为离线以模拟网络断开,如下所示:

图 13.12 – 在 Chrome 开发者工具中模拟网络断开

图 13.12 – 在 Chrome 开发者工具中模拟网络断开

  1. 将网络更改为离线后,等待几秒钟(取决于超时配置),您应该会看到客户端自动重新连接到服务器,如下所示:

图 13.13 – 客户端自动重新连接到服务器

图 13.13 – 客户端自动重新连接到服务器

  1. 将网络改回在线,您应该会看到客户端重新连接到服务器,如下所示:

图 13.14 – 网络恢复在线后客户端重新连接到服务器

图 13.14 – 网络恢复在线后客户端重新连接到服务器

重要提示

如果客户端在尝试四次后仍然无法重新连接到服务器,将触发onclose事件。您可以添加onclose事件的处理器来处理连接关闭事件。例如,您可以通知用户连接已关闭,并要求用户刷新页面或手动重新连接到服务器。

ASP.NET Core 8.0 中的 SignalR 支持状态重连,允许服务器在客户端断开连接时临时存储消息。在重新连接后,客户端将使用相同的连接 ID,服务器将回放客户端断开连接期间发送的任何消息。这确保了客户端的状态得到保持,且不会丢失任何消息。

  1. 要启用状态重连,我们需要为 SignalR 端点配置 AllowStatefulReconnects 选项,如下所示:

    app.MapHub<ChatHub>("/chatHub", options =>{    // Omitted for brevity    options.AllowStatefulReconnects = true;});
    
  2. 默认情况下,状态重连的最大缓冲区大小为 100,000 字节。您可以根据以下方式更改缓冲区大小:

    builder.Services.AddSignalR(options =>{    // Omitted for brevity    options.StatefulReconnectBufferSize = 200000;});
    
  3. 然后,我们可以配置 TypeScript 客户端以使用状态重连,如下所示:

    connection = new signalR.HubConnectionBuilder()  .withUrl("https://localhost:7159/chatHub", {    // Omitted for brevity  })  .withAutomaticReconnect()  .withStatefulReconnect({ bufferSize: 200000 })  .build();
    
  4. 类似地,您可以这样配置 Blazor 客户端:

    _hubConnection = new HubConnectionBuilder()    .WithUrl("https://localhost:7159/chatHub", options =>    {        // Omitted for brevity    })    .WithAutomaticReconnect()    .WithStatefulReconnect()    .Build();
    
  5. 要配置 Blazor 客户端的缓冲区大小,您可以配置 HubConnectionOptions 对象,如下所示:

    var builder = new HubConnectionBuilder()    .WithUrl("https://localhost:7159/chatHub", options =>    {        // Omitted for brevity    })    .WithAutomaticReconnect()    .WithStatefulReconnect();builder.Services.Configure<HubConnectionOptions>(options =>{    options.StatefulReconnectBufferSize = 200000;});_hubConnection = builder.Build();
    

除了自动重连功能外,如果连接断开,您还可以手动重新连接到 SignalR 服务器。您可以为 onclose 事件或 Closed 事件添加事件处理器来处理连接关闭事件。

扩展 SignalR

到目前为止,我们已经实现了一个聊天应用,允许用户向其他用户或组发送消息。我们还探讨了如何管理 SignalR 连接。您也可以使用类似的方法构建实时通知系统、实时仪表板等。然而,该应用只能在单个服务器上运行。如果我们想扩展应用,例如,使用负载均衡器将请求分发到多个服务器,服务器 A 就不知道服务器 B 上的连接。

SignalR 需要在客户端和服务器之间建立持久连接。这意味着来自同一客户端的请求必须路由到同一服务器。这被称为 粘性会话会话亲和性。如果您有多个 SignalR 服务器,则需要此要求。除了此要求外,在扩展 SignalR 时还有一些其他考虑因素:

  • 如果您在 Azure 上托管应用,可以使用 Azure SignalR 服务。Azure SignalR 服务是一个完全托管的服务,可以帮助您扩展 SignalR 应用而无需担心基础设施。使用 Azure SignalR 服务时,您无需使用粘性会话,因为所有客户端都连接到 Azure SignalR 服务。此服务承担管理连接和释放 SignalR 服务器资源的责任。有关更多信息,请参阅 learn.microsoft.com/en-us/azure/azure-signalr/signalr-overview

  • 如果你将应用程序托管在自己的基础设施或其他云服务提供商上,你可以使用 Redis 背板来同步连接。Redis 背板是一个使用 pub/sub 功能将消息转发到其他 SignalR 服务器的 Redis 服务器。然而,这种方法在大多数情况下需要粘性会话,并且 SignalR 应用程序实例需要额外的资源来管理连接。还有一些其他的 SignalR 背板提供商,例如 SQL Server、NCache 等。

我们在这本书中不会涵盖如何扩展 SignalR 的细节。你可以在官方文档中找到更多信息。

摘要

SignalR 是一个强大的库,简化了构建实时网络应用程序的过程。在本章中,我们探讨了如何使用 SignalR 来构建聊天应用程序。我们介绍了 SignalR 的基本概念,如中心点、客户端和连接。我们使用 TypeScript 和 Blazor 创建了客户端,这展示了如何使用 TypeScript 和 .NET 来构建 SignalR 客户端。我们还讨论了如何向特定用户或组发送消息,以及如何使用 JWT 认证来保护 SignalR 连接。此外,我们还探讨了如何配置 SignalR 中心点和客户端,例如配置保持活动间隔、配置 HTTP 选项和配置自动重连功能。

虽然我们已经涵盖了众多功能,但仍有许多内容可以探索,例如流式传输。更多详细信息,请参阅官方文档:learn.microsoft.com/en-us/aspnet/core/signalr/introduction。在下一章中,我们将探讨如何部署 ASP.NET Core 应用程序。

第十四章:使用 Azure Pipelines 和 GitHub Actions 进行 ASP.NET Core 的 CI/CD

在前面的章节中,我们已经探讨了构建、测试和运行 ASP.NET Core 应用程序的基础知识。我们还讨论了如何使用 dotnet run 命令从数据库中访问数据以在本地运行我们的应用程序。现在,是我们继续我们的 ASP.NET Core 之旅并学习如何将我们的应用程序部署到云中的时候了。

在本章中,我们将探讨 持续集成和持续交付/部署CI/CD)的概念。本章将重点介绍两个流行的 CI/CD 工具和平台:Azure Pipelines 和 GitHub Actions。

在本章中,我们将讨论以下主题:

  • CI/CD 简介

  • 使用 Docker 容器化 ASP.NET Core 应用程序

  • 使用 Azure Pipelines 进行 CI/CD

  • GitHub Actions

完成本章后,您将基本了解容器化概念,并能够使用这些工具中的任何一个构建和部署您的 ASP.NET Core 应用程序到云中。

技术要求

本章中的代码示例可以在 github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter14/ 找到。

CI/CD 简介

开发者每天都在编写代码——他们可能创建新功能、修复错误,或者重构现有代码。在团队环境中,多个开发者可能正在同一个代码库上工作。一个开发者可能正在创建新功能,而另一个开发者可能正在修复错误。代码库不断变化,确保不同开发者所做的代码更改不会相互冲突,并且不会破坏任何现有功能,这一点非常重要。为了避免此类问题,开发者应频繁地集成他们的代码更改。

此外,当应用程序准备部署时,考虑它可能部署到的不同环境(如开发、测试或生产)也很重要。不同的环境可能有不同的配置,部署过程也可能因环境而异。为了确保应用程序被正确且一致地部署,自动化部署过程是理想的。这就是 CI/CD 发挥作用的地方。

缩写 CI/CD 的含义可能因上下文而异。CI 是一种开发实践,允许开发者定期集成代码更改。CD 可以指 持续交付持续部署,这两个术语通常可以互换使用。在大多数情况下,CD 指的是频繁且自动地将应用程序构建、测试和部署到生产环境(以及可能的其他环境),因此没有必要对这些术语的确切定义进行争论。

CI/CD 流水线是DevOps的关键组件,DevOps 是开发运维两个词的组合。DevOps 在过去几年中不断发展,通常被定义为一系列实践、工具和流程,这些流程能够使最终用户持续获得价值。虽然 DevOps 是一个广泛的话题,但本章将专注于 CI/CD 流水线。

典型的 CI/CD 流程如下所示:

图 14.1 – 典型的 CI/CD 流程

图 14.1 – 典型的 CI/CD 流程

图 14.1 中的步骤描述如下:

  1. 开发者创建新的功能或修复代码库中的错误,然后将更改提交到共享代码仓库。如果团队使用 Git 作为其版本控制系统VCS),开发者将创建一个拉取请求来提交更改。

  2. 拉取请求将启动 CI 流水线,该流水线将构建应用程序并执行测试。如果构建或测试失败,开发者将收到通知,使他们能够及时解决问题。

  3. 如果构建和测试成功,并且拉取请求得到团队的批准,代码更改将被合并到main分支。这确保了代码是最新的,并且与团队的标准保持一致。

  4. 合并操作将触发 CI 流水线构建应用程序并将工件(例如,二进制文件、配置文件、Docker 镜像等)发布到工件存储库。

  5. CD 流水线可以手动或自动触发。然后 CD 流水线将应用程序部署到目标环境(例如,开发、测试或生产环境)。

CI/CD 流程可能比图 14.1 中显示的更复杂。例如,CI 流水线可能需要静态代码分析、代码测试覆盖率和其他质量检查。CD 流水线可能需要为不同的环境应用不同的配置或采用不同的部署策略,例如蓝绿部署或金丝雀部署。随着 CI/CD 流水线复杂性的增加,DevOps 工程师的需求也在增加,因为他们拥有使用各种工具和平台实施这些流水线的技能。

在我们深入探讨 CI/CD 的细节之前,让我们首先介绍一些在 CI/CD 中常用的概念和术语。

CI/CD 概念和术语

理解 CI/CD 中常用的一些关键概念和术语至关重要。以下是一些 CI/CD 中最常用的术语:

  • 流水线:流水线是一种用于构建、测试和部署应用程序的自动化过程。它们可以手动或自动触发,甚至可以设置由其他流水线触发。这有助于简化开发过程,确保应用程序能够快速高效地构建、测试和部署。

  • 构建:构建是一个涉及编译源代码并创建必要的二进制文件或 Docker 镜像的过程。这确保了代码已准备好部署。

  • 测试:管道可能包括自动化测试,如单元测试、集成测试、性能测试或端到端E2E)测试。这些测试可以集成到管道中,以确保代码更改不会破坏任何现有功能。这有助于确保软件的稳定性和可靠性。

  • 工件:工件是一个文件或文件集合——通常是构建过程的输出。工件示例包括二进制文件、Docker 镜像或包含二进制文件的 ZIP 文件。然后,这些工件可以用作部署过程的输入。

  • 容器化:容器化是一种将应用程序及其依赖项打包到容器镜像中的方法,可以在一致的环境中部署和运行,不受主机操作系统的限制。最流行的容器化工具之一是 Docker。容器化提供了许多好处,如提高可伸缩性、便携性和资源利用率。

  • 版本控制系统(VCS):VCS 是软件开发的重要工具,允许开发者跟踪和管理源代码的更改。Git 是最广泛使用的 VCS 之一,为开发者提供了一种有效管理其代码库的方法。

  • 部署:部署是将应用程序部署到目标环境的过程。它涉及配置应用程序以满足环境的要求,并确保其安全且可供使用。

  • main分支可以触发 CI 管道构建和发布工件。成功的 CI 管道可以触发 CD 管道将应用程序部署到非生产环境。然而,为了安全起见,CD 管道可能需要手动触发以将应用程序部署到生产环境。

理解与 CI/CD 相关的根本概念和术语对于成功实施至关重要。由于可以用于实现 CI/CD 管道的工具有很多,我们将在以下章节中详细讨论。

理解 CI/CD 的重要性

CI/CD 在 DevOps 中扮演着重要角色。它帮助团队快速响应变化,并频繁、安全、可靠地向最终用户提供价值。由于 CI/CD 管道是自动化的,它们可以简化软件交付的过程,并减少将应用程序部署到生产环境所需的时间和精力。此外,CI/CD 有助于维护一个稳定可靠的代码库。

为了成功实施 CI/CD 流水线,团队必须遵守某些实践。应进行自动测试以确保代码更改不会破坏任何现有功能。此外,应建立明确的部署策略,例如在将应用程序部署到生产环境之前,在开发环境中进行应用程序的预部署。通过遵循这些实践,团队可以缩短上市时间TTM),更快更频繁地将应用程序交付给最终用户。

CI/CD 实践以以下方式帮助开发团队:

  • 更快地获得反馈:当代码更改提交到共享代码仓库时,CI/CD 流水线可以自动触发。这为开发者提供了关于代码更改的更快反馈,使他们能够在开发过程的早期阶段解决任何问题。

  • 减少手动努力和风险:CI/CD 流水线自动化了部署过程,减少了手动努力和风险。这减少了生产部署所需的时间和精力,消除了手动和容易出错的流程。

  • 一致性:自动构建和部署确保了不同环境之间的一致性。这减少了由于配置问题或“在我的机器上运行正常”问题导致的部署失败的风险。

  • 提高质量:可以将自动测试集成到 CI/CD 流水线中,这有助于确保代码库保持稳定和可靠。CI/CD 流水线还可以运行其他质量检查,如静态代码分析和代码测试覆盖率,从而提高代码质量。

  • 快速交付和敏捷性:CI/CD 流水线使团队能够更快更频繁地向最终用户发布新功能和错误修复。这使企业能够快速响应客户需求和市场需求。

考虑到这些好处,很明显,CI/CD 对于任何开发团队来说都是必不可少的。没有人愿意回到手动构建和部署的时代,因为那既耗时又容易出错。

我们现在已经学习了 CI/CD 的某些概念以及为什么它很重要。在下一节中,我们将讨论如何使用 Docker 容器化 ASP.NET Core 应用程序。

使用 Docker 容器化 ASP.NET Core 应用程序

多年前,当我们向生产环境部署应用程序时,我们需要确保目标环境已安装正确的 .NET Framework 版本。开发者们在与生产环境可能不同的配置中挣扎,包括软件版本、操作系统和硬件。这通常会导致由于配置问题而导致的部署失败。

.NET Core 的引入,这是一个跨平台和开源框架,使我们能够在任何平台上部署我们的应用程序,包括 Windows、Linux 和 macOS。然而,为了成功部署,我们仍然需要确保目标环境已安装正确的运行时。这就是容器化发挥作用的地方。

容器化是什么?

容器是轻量级、隔离和可移植的环境,包含运行应用程序所需的所有依赖项。与虚拟机VMs)不同,它们不需要单独的客户端操作系统,因为它们共享宿主操作系统的内核。这使得它们比 VM 更轻量级和可移植,因为它们可以在支持容器运行时的任何平台上运行。容器还提供隔离,确保应用程序不受环境变化的影响。

容器化是一个强大的工具,使我们能够将应用程序及其依赖项打包到单个容器镜像中。Docker是最受欢迎的容器化解决方案之一,为开发目的提供对 Windows、Linux 和 macOS 的支持,以及许多 Linux 变体,如 Ubuntu、Debian 和 CentOS,用于生产环境。此外,Docker 与云平台兼容,包括 Azure、亚马逊网络服务AWS)和谷歌云平台GCP)。如果我们使用 Docker 作为容器运行时,那么这些容器镜像就被称为Docker 镜像

Docker 镜像是一种方便的方式来打包应用程序及其依赖项。它们包含运行应用程序所需的所有组件,例如应用程序代码(二进制文件)、运行时或 SDK、系统工具和配置。Docker 镜像是不可变的,这意味着一旦创建就无法更改。为了存储这些镜像,它们被放置在注册表中,例如 Docker Hub、Azure 容器注册表ACR)或 AWS 弹性容器注册表ECR)。Docker Hub 是一个公共注册表,提供许多预构建的镜像。或者,可以创建一个私有注册表来存储自定义 Docker 镜像。

一旦创建了一个 Docker 镜像,就可以用它来创建 Docker 容器。Docker 容器是 Docker 镜像的隔离、内存实例,具有自己的文件系统、网络和内存。这使得创建容器比启动虚拟机(VM)要快得多,同时也允许快速销毁和从同一镜像重建容器。此外,可以从同一镜像创建多个容器,这对于扩展应用程序非常有用。如果任何容器失败,可以在几秒钟内将其销毁并从同一镜像重建,这使得容器化成为一个强大的工具。

Docker 镜像中的文件是可堆叠的。图 14.2展示了包含 ASP.NET Core 应用程序及其依赖项的容器文件系统示例:

图 14.2 – Docker 容器文件系统

图 14.2 – Docker 容器文件系统

图 14.2 展示了 Docker 容器的层。在内核层之上是基础镜像层,它是由 Ubuntu 创建的空容器镜像。在基础镜像层之上是 ASP.NET Core 运行时层,然后是 ASP.NET Core 应用层。当创建容器时,Docker 在其他层之上添加一个可写层。这个可写层可以用来存储临时文件,例如日志。然而,正如我们之前提到的,Docker 镜像是不可变的,因此对可写层的任何更改在容器销毁时都会丢失。这就是为什么我们不应该在容器中存储任何持久数据。相反,我们应该将数据存储在卷中,卷是主机机器上的一个目录,该目录被挂载到容器中。

这是一个对 Docker 镜像和容器的非常简化的解释。接下来,让我们安装 Docker 并为我们的 ASP.NET Core 应用程序创建一个 Docker 镜像。

安装 Docker

您可以从以下链接下载 Docker Desktop:

请遵循官方文档在您的机器上安装 Docker。

如果您使用 Windows,请使用 Windows Subsystem for Linux 2WSL 2) 后端而不是 Hyper-V。WSL 2 是一个兼容层,允许 Linux 二进制可执行文件在 Windows 上原生运行。使用 WSL 2 作为 Windows 上 Docker Desktop 的后端比 Hyper-V 后端提供更好的性能。

要在 Windows 上安装 WSL 2,请遵循此链接的说明:learn.microsoft.com/en-us/windows/wsl/install。默认情况下,WSL 2 使用 Ubuntu 发行版。您还可以安装其他 Linux 发行版,如 Debian、CentOS 或 Fedora。

安装 WSL 2 后,您可以在 PowerShell 中运行以下命令来检查 WSL 的版本:

wsl -l -v

如果您看到 VERSION 字段显示 2,这意味着 WSL 2 已正确安装。然后,您可以安装 Docker Desktop 并选择 WSL 2 作为后端。如果您安装了多个 Linux 发行版,您可以选择与 Docker Desktop 一起使用的默认发行版。转到 设置 | 资源 | WSL 集成,并选择您想要与 Docker Desktop 一起使用的发行版,如图 图 14.3 所示:

图 14.3 – 选择与 Docker Desktop 一起使用的默认 Linux 发行版

图 14.3 – 选择与 Docker Desktop 一起使用的默认 Linux 发行版

这是 wsl -l -v 命令的示例输出,它显示了安装在此机器上的两个 Linux 发行版;默认发行版是 Ubuntu-22.04

  NAME                   STATE           VERSION* Ubuntu-22.04           Running         2
  Ubuntu                 Stopped         2
  docker-desktop-data    Running         2
  docker-desktop         Running         2

Docker Desktop 安装了两个内部 Linux 发行版:

  • docker-desktop:用于运行 Docker 引擎

  • docker-desktop-data:用于存储容器和镜像

注意,Docker 可能会在您的机器上消耗大量资源。如果您觉得 Docker 使您的机器变慢或消耗了太多资源,您可以按照以下链接中的说明配置分配给 WSL 2 的资源:learn.microsoft.com/en-us/windows/wsl/wsl-config#configure-global-options-with-wslconfig

在安装 Docker Desktop 之后,我们现在可以为我们的 ASP.NET Core 应用程序创建 Docker 镜像。在下一节中,我们将讨论一些在 Docker 中常用的命令。

理解 Dockerfile

为了演示如何构建和运行 Docker 镜像,我们需要一个示例 ASP.NET Core 应用程序。您可以使用以下命令创建一个新的 ASP.NET Core Web API 项目:

dotnet new webapi -o BasicWebApiDemo -controllers

或者,您可以从书籍 GitHub 仓库中的/samples/chapter14/MyBasicWebApiDemo文件夹克隆示例代码。

Docker 镜像可以使用 Dockerfile 构建,这是一个包含用于构建镜像的指令列表的文本文件。您可以在 ASP.NET Core 项目的根目录中手动创建 Dockerfile,或者使用 VS 2022 为您创建它。要使用 VS 2022 创建 Dockerfile,请在解决方案资源管理器中右键单击项目,然后选择添加 | Docker 支持。您将看到以下对话框:

图 14.4 – 在 VS 2022 中为 ASP.NET Core 项目添加 Docker 支持

图 14.4 – 在 VS 2022 中为 ASP.NET Core 项目添加 Docker 支持

这里有两个选项:LinuxWindows。建议用于开发目的使用 Linux,因为 Linux 镜像比 Windows 镜像小。Docker 最初是为 Linux 设计的,因此在 Linux 上的成熟度高于 Windows。许多云平台,如 Azure、AWS 和 GCP,支持 Linux 容器。但是,并非所有 Windows 服务器都支持 Windows 容器。除非您有强烈的理由在 Windows 服务器上托管您的应用程序,否则您应该在这里选择Linux

一旦您选择了没有任何文件扩展名的Dockerfile。这允许我们使用docker build命令构建 Docker 镜像,而无需指定 Dockerfile 的名称。VS 2022 默认创建的 Dockerfile 将类似于以下内容:

#See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["BasicWebApiDemo.csproj", "."]
RUN dotnet restore "./BasicWebApiDemo.csproj"
COPY . .
WORKDIR "/src/."
RUN dotnet build "BasicWebApiDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
RUN dotnet publish "BasicWebApiDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "BasicWebApiDemo.dll"]

让我们逐行分析 Dockerfile:

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base

FROM指令指定要使用的基镜像。FROM指令必须是 Dockerfile 中的第一个指令。在这种情况下,我们使用的是mcr.microsoft.com/dotnet/aspnet:8.0镜像,这是 ASP.NET Core 运行时镜像。此镜像由 Microsoft 提供。mcr.microsoft.comAS base的域名,我们正在给这个镜像起一个名字,即base。这个名称可以在 Dockerfile 的后续部分用来引用这个镜像。

USER app

接下来,USER 指令指定了用户名或由基本镜像创建的 app 用户。此用户不是超级用户,因此比 root 用户更安全。

WORKDIR /app

WORKDIR 指令为这些指令设置容器内部的工作目录:RUNCMDCOPYADDENTRYPOINT 等。此指令类似于终端中的 cd 命令。它支持绝对路径和相对路径。如果目录不存在,它将被创建。在这个例子中,工作目录被设置为 /app

EXPOSE 8080EXPOSE 8081

EXPOSE 指令在容器运行时将指定的端口(端口)暴露给容器。请注意,此指令实际上并不会将端口发布到主机机器。它只是意味着容器将监听指定的端口(端口)。默认情况下,EXPOSE 指令暴露 TCP 协议上的端口。在这种情况下,容器将监听端口 80808081

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS buildARG BUILD_CONFIGURATION=Release
WORKDIR /src

在前面的行中,我们使用了一个不同的镜像,即 mcr.microsoft.com/dotnet/sdk:8.0 镜像,并将其命名为 build。此镜像包含 .NET SDK,用于构建应用程序。ARG 指令定义了一个可以在 Dockerfile 中稍后使用的变量。在这种情况下,我们定义了一个名为 BUILD_CONFIGURATION 的变量,并将其默认值设置为 ReleaseWORKDIR 指令将工作目录设置为 /src

COPY ["BasicWebApiDemo.csproj", "."]RUN dotnet restore "./BasicWebApiDemo.csproj"

COPY 指令将文件或目录从源(本地机器上的)复制到目标(容器的文件系统)。在这种情况下,我们正在将 .csproj 文件复制到当前目录。RUN 指令在当前镜像上执行指定的命令,创建一个新的层,然后提交结果。新层将被用于 Dockerfile 中的下一步。在这种情况下,我们正在运行 dotnet restore 命令来还原 NuGet 包。

COPY . .WORKDIR "/src/."
RUN dotnet build "BasicWebApiDemo.csproj" -c $BUILD_CONFIGURATION -o /app/build

在前面的行中,我们正在将本地机器上的所有文件复制到容器当前目录。然后,我们将工作目录设置为 /src。最后,我们运行 dotnet build 命令来构建应用程序。请注意,我们正在使用 Dockerfile 中之前定义的 BUILD_CONFIGURATION 变量。

FROM build AS publishRUN dotnet publish "BasicWebApiDemo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false

再次,FROM 指令使用我们之前定义的 build 镜像,并将其命名为 publish。然后,RUN 指令运行 dotnet publish 命令来发布应用程序。再次使用 $BUILD_CONFIGURATION 变量。发布的应用程序将被放置在 /app/publish 目录中。

FROM base AS finalWORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "BasicWebApiDemo.dll"]

接下来,我们将base镜像重命名为final,并将工作目录设置为/app。要运行应用程序,我们只需要运行时,因此不需要 SDK 镜像。然后,COPY指令将发布的应用程序从publish镜像的app/publish目录复制到当前目录。最后,ENTRYPOINT指令指定了容器启动时要运行的命令。在这种情况下,我们运行dotnet BasicWebApiDemo.dll命令来启动 ASP.NET Core 应用程序。

您可以在 Docker 官方提供的文档中找到有关 Dockerfile 指令的更多信息:docs.docker.com/engine/reference/builder/。接下来,让我们继续构建 Docker 镜像。

构建 Docker 镜像

Docker 提供了一套可用于构建、运行和管理 Docker 镜像和容器的命令。要构建一个 Docker 镜像,请转到我们之前创建的 ASP.NET Core 项目的根目录,然后运行以下命令:

docker build -t basicwebapidemo .

-t选项用于给镜像添加一个名称。末尾的.表示当前目录。Docker 期望在当前目录中找到一个名为Dockerfile的文件。如果您已重命名 Dockerfile 或 Dockerfile 不在当前目录中,可以使用-f选项指定 Dockerfile 的名称,例如docker build -t basicwebapidemo -f MyBasicWebApiDemo/MyDockerfile MyBasicWebApiDemo

输出结果显示,Docker 正在逐层构建镜像。Dockerfile 中的每条指令都会在镜像中创建一个层,并在上一层的上方添加更多内容。这些层被缓存,所以如果某个层没有改变,它将不会在下一个构建过程中被重建。但是,如果某个层发生了变化(例如,如果我们更新了源代码),那么复制源代码的那个层将被重建,并且所有随后的层都将受到影响,需要被重建。

现在,让我们回顾一下由 VS 2022 生成的默认 Dockerfile。为什么它会在运行dotnet restore命令后复制所有文件?这是因为如果我们只更新了源代码,但 NuGet 包没有改变,那么由于层被缓存,dotnet restore命令将不会再次执行。这可以提高构建性能。然而,如果我们更新了 NuGet 包,这意味着.csproj文件已更改,那么dotnet restore命令将再次执行。

这里有一些编写 Dockerfile 的技巧:

  • 考虑层的顺序。不太可能改变的层应该放在更可能改变的层之前。

  • 尽可能保持层的大小小。不要复制不必要的文件。您可以通过配置 .dockerignore 文件来排除构建上下文中的文件或目录。如果您使用 VS 2022 创建 Dockerfile,它将为您生成一个 .dockerignore 文件。或者,您可以手动创建一个名为 .dockerignore 的文本文件,然后编辑它。以下是一个示例 .dockerignore 文件:

    # Exclude build results, Npm cache folder, and some other files**/bin/**/obj/**/.git**/.vs**/.vscode**/global.json**/Dockerfile**/.dockerignore**/node_modules
    

    要了解更多关于 .dockerignore 文件的信息,请参阅以下官方文档:docs.docker.com/engine/reference/builder/#dockerignore-file.

  • 尽可能保持层数最少。例如,为了托管 ASP.NET Core 应用程序,我们可以通过使用 mcr.microsoft.com/dotnet/aspnet 镜像来减少层数。这个镜像已经包含了 ASP.NET Core 运行时,消除了在容器中安装 SDK 的需要。您还可以将命令组合成一个单独的 RUN 指令。

  • 使用多阶段构建。多阶段构建允许我们在 Dockerfile 中使用多个 FROM 指令。每个 FROM 指令都可以用来创建一个新的镜像。最终的镜像将只包含最后一个 FROM 指令的层。这可以减小最终镜像的大小。例如,我们可以使用 mcr.microsoft.com/dotnet/sdk 镜像来构建应用程序,然后使用 mcr.microsoft.com/dotnet/aspnet 镜像来运行应用程序。这样,最终的镜像将只包含 ASP.NET Core 运行时,而不会包含 SDK,因为 SDK 对运行应用程序不是必需的。要了解更多关于多阶段构建的信息,请参阅以下官方文档:docs.docker.com/develop/develop-images/multistage-build/.

    要了解更多关于优化 Docker 构建的信息,请参阅以下官方文档:docs.docker.com/build/cache/.

我们可以使用以下命令列出我们机器上的所有 Docker 镜像:

docker images

输出应该类似于以下内容:

REPOSITORY      TAG     IMAGE ID       CREATED              SIZEbasicwebapidemo latest  b0d8d94d219c   About a minute ago   222MB

basicwebapidemo 镜像是我们刚刚构建的。每个镜像都有一个 TAG 值和一个 IMAGE ID 值。TAG 值是镜像的一个可读名称。默认情况下,标签是 latest。我们可以在构建镜像时指定不同的标签。例如,我们可以使用以下命令使用 v1 标签构建镜像:

docker build -t basicwebapidemo:v1 .

在前面的命令中,-t 选项用于给镜像添加一个名称,该名称与标签之间用冒号分隔。

要删除一个镜像,使用 docker rmi <container name or ID> 命令,后跟镜像名称或镜像 ID:

docker rmi basicwebapidemo

注意,如果镜像被容器使用,您需要先停止容器,然后再删除镜像。

现在我们已经有了我们的 ASP.NET Core 应用程序的 Docker 镜像。所有必要的依赖都包含在镜像中。接下来,让我们运行这个 Docker 镜像。

运行 Docker 容器

要在容器中运行 Docker 镜像,我们可以使用 docker run 命令。以下命令将运行我们刚刚构建的 basicwebapidemo 镜像:

docker run -d -p 80:8080 --name basicwebapidemo basicwebapidemo

让我们更仔细地看看这个:

  • -d 选项用于以分离模式运行容器,这意味着容器将在后台运行。您可以省略此选项,然后容器将在前台运行,这意味着如果您退出终端,容器将停止。

  • -p 选项用于将容器的端口(端口)发布到主机。在这种情况下,我们将容器的端口 8080 发布到主机的端口 80。

  • --name 选项用于指定容器的名称。最后一个参数是要运行的镜像的名称。

  • 您还可以使用 -it 选项以交互式模式运行容器。此选项允许您在容器中运行命令。

当我们编写 Dockerfile 时,我们解释了 EXPOSE 指令仅将端口 8080 和 8081 暴露给容器。要将内部容器端口发布到主机,我们需要使用 -p 选项。第一个端口号是主机机的端口,第二个端口号是内部容器端口。在这个例子中,我们将容器端口 8080 暴露给主机端口 80。这可能会让一些人感到困惑。所以,请仔细检查端口号。此外,有时主机机的端口号可能已被另一个进程占用。在这种情况下,您需要使用不同的端口号。

输出应返回容器 ID,它是容器的 UID,例如这样:

5529b0278e5a14452a7049a7c9922797b0c1171423970f99b4481c93cfdc6a38

使用 docker ps 命令列出所有正在运行的容器:

docker ps

输出应类似于这样:

CONTAINER ID   IMAGE             COMMAND                  CREATED         STATUS         PORTS                                 NAMES403eb4952287   basicwebapidemo   "dotnet BasicWebApiD…"   5 minutes ago   Up 5 minutes   8081/tcp, 0.0.0.0:80->8080/tcp   basicwebapidemo

在输出中,我们可以看到容器的端口 8080 已被映射到主机的端口 80。

要列出所有状态的容器,只需添加 -a 选项:

docker ps -a

您可以检查容器的状态。如果容器正在运行,状态应该是 Up

我们可以使用以下命令来管理容器:

  • 要暂停一个容器:docker pause <容器名称 或 ID>

  • 要重启一个容器:docker restart <容器名称 或 ID>

  • 要停止一个容器:docker stop <容器名称 或 ID>

  • 要删除容器:docker rm <容器名称 或 ID>

如果容器正在运行,您可以通过向此 URL 发送请求来测试端点:http://localhost/weatherforecast。您将看到 ASP.NET Core 应用程序的响应。如果您更改主机机的端口号,您需要在 URL 中使用正确的端口号。例如,如果您使用 -p 5000:8080,那么您需要使用 localhost:5000/weatherforecast 来访问端点。

我们可以使用 docker logs <容器名称或 ID> 命令来显示容器的日志:

docker logs basicwebapidemo

您将看到如下日志:

info: Microsoft.Hosting.Lifetime[14]      Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /app

要检查容器的状态,您可以使用 docker stats <容器名称或 ID> 命令:

docker stats basicwebapidemo

您将看到容器的状态如下:

CONTAINER ID   NAME              CPU %     MEM USAGE / LIMIT     MEM %     NET I/O           BLOCK I/O   PIDS403eb4952287   basicwebapidemo   0.01%     24.24MiB / 15.49GiB   0.15%     23.8kB / 2.95kB   0B / 0B     25

你可以通过向/weatherforecast端点发送请求来获取 ASP.NET Core 应用程序的响应。请注意,容器正在生产环境中运行,因此 Swagger UI 不可用。这是因为我们在Program.cs文件中只启用了开发环境中的 Swagger UI。要启用 Swagger UI,我们可以停止并删除当前容器,然后在开发环境中创建一个新的容器。或者,你也可以创建一个具有不同名称的新容器。例如,你可以通过运行以下命令来停止容器:

docker stop basicwebapidemo

然后通过运行以下命令来移除容器:

docker rm basicwebapidemo

接下来,将环境变量添加到docker run命令中,以设置环境为开发模式:

docker run -d -p 80:8080 --name basicwebapidemo -e ASPNETCORE_ENVIRONMENT=Development basicwebapidemo

现在,你可以通过在浏览器中导航到/swagger端点来查看 Swagger UI。

到目前为止,我们已经学会了如何构建和运行 Docker 镜像。Docker 有许多其他命令可以用来管理 Docker 镜像和容器。为了总结,以下是 Docker 官方文档中一些最常用的 Docker 命令:

  • docker build:构建 Docker 镜像

  • docker run:运行 Docker 镜像

  • docker images:列出所有镜像

  • docker ps:列出所有正在运行的容器

  • docker ps -a:列出所有状态的容器

  • docker logs:显示容器的日志

  • docker stats:显示容器的统计信息

  • docker pause:暂停容器

  • docker restart:重启容器

  • docker stop:停止容器

  • docker rm:删除容器

  • docker rmi:删除镜像

  • docker exec:在运行的容器中运行命令

  • docker inspect:显示一个或多个容器或镜像的详细信息

  • docker login:登录到 Docker 注册库

  • docker logout:从 Docker 注册库注销

  • docker pull:从注册库拉取镜像

  • docker push:将镜像推送到注册库

  • docker tag:创建一个指向SOURCE_IMAGETARGET_IMAGE标签

  • docker volume:管理卷

  • docker network:管理网络

  • docker system:管理 Docker

  • docker version:显示 Docker 版本信息

  • docker info:显示 Docker 系统级别的信息

  • docker port:列出端口映射或容器的特定映射

你可以在这里找到更多关于 Docker 命令的信息:docs.docker.com/engine/reference/commandline/cli/.

我们现在已经学会了如何为我们的 ASP.NET Core 应用程序构建 Docker 镜像并在容器中运行它。尽管容器在我们的本地机器上运行,但与在生产环境中运行并没有太大区别。容器与主机机器隔离。容器的便携性使得将应用程序部署到任何环境变得容易。

我们也可以使用 Docker 命令将镜像推送到注册库,例如 Docker Hub、ACR 等。然而,手动部署容易出错且耗时。这就是为什么我们需要 CI/CD 管道来自动化部署过程。

在下一节中,我们将讨论如何使用 Azure DevOps 和 Azure Pipelines 将容器化应用程序部署到云中。

使用 Azure DevOps 和 Azure Pipelines 进行 CI/CD

Azure DevOps 是一个基于云的服务,提供了一套用于管理软件开发流程的工具。它包括以下服务:

  • Azure 板:一个用于管理工作项的服务,例如用户故事、任务和错误。

  • Azure 代码仓库:一个用于托管代码仓库的服务。它支持 Git 和 团队基础版本控制TFVC)。仓库可以是公开的或私有的。

  • Azure 管道:一个用于使用任何语言、平台和云构建、测试和部署应用程序的服务。

  • Azure 测试计划:一个用于手动和探索性测试工具的服务。

  • MavennpmNuGetPython 包。

Azure DevOps 对开源项目和小型团队是免费的。在这本书中,我们不会涵盖 Azure DevOps 的所有功能。让我们专注于 Azure Pipelines。在本节中,我们将讨论如何使用 Azure Pipelines 构建和部署我们的 ASP.NET Core 应用程序到 Azure App Service。

在我们将应用程序部署到 Azure 之前,我们需要以下资源:

准备源代码

GitHub 是最受欢迎的源代码控制解决方案之一。对于公开仓库,它是免费的。我们假设您已经有了 GitHub 账户。

/chapter14/MyBasicWebApiDemo 下载示例代码。这是一个简单的 ASP.NET Core Web API 应用程序,包括其单元测试和集成测试。在 GitHub 上创建一个新的仓库,然后将源代码推送到该仓库。仓库的目录结构应如下所示:

MyAzurePipelinesDemo    ├── src
    │   └──MyBasicWebApiDemo
    ├── tests
    │   ├──MyBasicWebApiDemo.UnitTests
    │   └──MyBasicWebApiDemo.IntegrationTests
    ├── MyBasicWebApiDemo.sln
    ├── README.md
    ├── LICENSE
    └── .gitignore

在上述目录结构中,主要的 ASP.NET Core Web API 项目位于 src 文件夹中,单元测试和集成测试位于 tests 文件夹中。这可以更好地组织解决方案结构。但这只是个人偏好。您也可以将单元测试和集成测试放在与主要项目相同的文件夹中。如果您使用不同的目录结构,请在以下章节中相应地更新管道中的路径。

我们将使用此仓库进行管道。接下来,让我们创建所需的 Azure 资源。

创建 Azure 资源

在当今的技术格局中,云计算已成为现代软件开发的核心。云计算提供了许多好处,例如可扩展性、高可用性HA)和成本效益。在各种云服务提供商中,如 AWS、GCP 和阿里云,Microsoft Azure 作为一种强大且灵活的平台脱颖而出,用于托管和编排您的应用程序。Azure 为托管应用程序提供了许多服务,例如 Azure App Service、Azure Kubernetes ServiceAKS)、Azure Container InstancesACI)、Azure VM 和 Azure Functions。在这本书中,我们将使用 Azure 作为云平台来托管我们的应用程序。对于其他云平台,概念是相似的。

我们将需要以下 Azure 资源:

注意,您还可以选择 Azure App Service 来托管您的应用程序而无需容器化。Azure App Service 支持许多编程语言和框架,例如 .NET、.NET Core、Java、Node.js、Python 等。它还支持容器。您可以在以下链接中了解更多关于 Azure App Service 的信息:docs.microsoft.com/en-us/azure/app-service/overview。在本节中,我们将探讨如何部署容器中的应用程序,因此以下示例我们将使用 Azure Web App for Containers。

为了更好地管理这些资源,建议创建一个资源组将这些资源分组在一起。您可以在 Azure 门户中创建一个新的资源组,或者在创建新资源时创建一个新的资源组。以下是我们需要为管道准备的关键资源信息:

资源组

name: devops-lab

容器注册库

name: devopscrlab

容器实例 Web 应用:

name: azure-pipeline-demo

您可以使用 Azure 门户或 Azure CLI 来创建这些资源。在代码中定义创建资源的脚本是一种良好的实践,这被称为基础设施即代码IaC)。然而,我们在这里不会涵盖 IaC,因为它超出了本书的范围。您可以在以下链接中了解更多关于 IaC 的信息:learn.microsoft.com/en-us/devops/deliver/what-is-infrastructure-as-code

接下来,让我们创建一个 Azure DevOps 项目。

创建 Azure DevOps 项目

由于官方文档提供了如何创建 Azure DevOps 项目的详细说明,我们在这里不会涵盖细节。请参考以下官方文档:learn.microsoft.com/en-us/azure/devops/pipelines/get-started/pipelines-sign-up?view=azure-devops

您需要遵循官方文档来创建 Azure DevOps 账户。您可以使用 Microsoft 账户或 GitHub 账户注册。一旦创建了 Azure DevOps 账户,您就可以创建一个新的组织。组织是项目和团队的一个容器。您可以使用一个 Azure DevOps 账户创建多个组织。

接下来,您可以在 Azure DevOps 中创建一个新项目。点击主页上的 New project 按钮,然后按照以下说明创建新项目:

图 14.5 – 在 Azure DevOps 中创建新项目

图 14.5 – 在 Azure DevOps 中创建新项目

我们将在下一节中创建这些管道:

  • 拉取请求构建管道:当创建拉取请求时构建应用程序并运行测试的管道。当在 GitHub 仓库中创建拉取请求时,此管道将被触发。如果构建失败或测试失败,则无法合并拉取请求。

  • main 分支。

  • 发布管道:将应用程序部署到 Azure Container Apps 的管道。此管道可以在将新镜像发布到 ACR 时触发,也可以手动触发。

创建拉取请求管道

在本节中,我们将创建一个拉取请求构建管道。请按照以下步骤操作:

  1. 导航到 Azure DevOps 门户,点击左侧的 Pipelines 选项卡,然后点击 Create Pipeline 按钮。您将看到一个类似这样的页面:

图 14.6 – 在 Azure DevOps 中创建新管道

图 14.6 – 在 Azure DevOps 中创建新管道

  1. Azure DevOps 管道支持各种源,例如 Azure Repos、GitHub、Bitbucket 和 Subversion。我们已经有了一个 GitHub 仓库,所以我们将使用 GitHub 作为源。点击 GitHub 按钮,然后按照说明授权 Azure DevOps 访问您的 GitHub 账户。一旦您授权 Azure DevOps 访问您的 GitHub 账户,您将看到您 GitHub 账户中的仓库列表。选择我们之前创建的仓库;您将被导航到 GitHub 并看到一个可以安装 Azure Pipelines 到仓库的页面。点击 批准并安装 按钮将 Azure Pipelines 安装到仓库。然后,您将被导航回 Azure DevOps。您将看到一个类似这样的页面:

图 14.7 – 配置管道

图 14.7 – 配置管道

  1. Azure DevOps 管道可以为您的项目提供各种模板。在这种情况下,您可以选择 ASP.NET 模板开始。Azure DevOps 管道可以自动检测源代码并为您生成一个基本管道。默认管道是一个 YAML 文件,可能看起来像这样:

    trigger:- mainpool:  vmImage: 'windows-latest'variables:  solution: '**/*.sln'  buildPlatform: 'Any CPU'  buildConfiguration: 'Release'steps:- task: NuGetToolInstaller@1- task: NuGetCommand@2  inputs:    restoreSolution: '$(solution)'- task: VSBuild@1  inputs:    solution: '$(solution)'    msbuildArgs: '/p:DeployOnBuild=true /p:WebPublishMethod=Package /p:PackageAsSingleFile=true /p:SkipInvalidConfigurations=true /p:PackageLocation="$(build.artifactStagingDirectory)"'    platform: '$(buildPlatform)'    configuration: '$(buildConfiguration)'- task: VSTest@2  inputs:    platform: '$(buildPlatform)'    configuration: '$(buildConfiguration)'
    
  2. 默认管道针对 Windows。我们将在一个 Linux 容器中运行应用程序,因此需要对管道进行一些修改。删除默认内容,我们将从头开始。

  3. 首先,将管道重命名为 pr-build-pipeline。您可以通过点击 .``yml 文件名来重命名管道:

图 14.8 – 重命名管道

图 14.8 – 重命名管道

  1. 然后,我们需要更新触发器,以便在为目标分支之一打开或更新拉取请求时运行管道。使用 pr 关键字表示管道将由 main 分支的拉取请求触发:

    pr:  branches:    include:    - main
    
  2. 接下来,将 pool 设置为使用 ubuntu-latest 镜像。ubuntu-latest 镜像是 Linux 镜像,比 Windows 镜像小。此外,应用程序针对 Linux 容器,因此最好使用 Linux 镜像:

    pool:  vmImage: 'ubuntu-latest'
    
  3. 接下来,为解决方案路径、构建配置等创建一些变量:

    variables:  solution: '**/*.sln'  buildConfiguration: 'Release'
    
  4. 然后,我们需要添加一些任务。添加一个 steps: 部分,然后添加以下任务:

    steps:- task: UseDotNet@2  displayName: 'use dotnet cli'  inputs:    packageType: 'sdk'    version: '8.0.x'    includePreviewVersions: true
    

    UseDotNet@2 任务用于安装 .NET SDK,以便我们可以使用 .NET CLI 执行命令。在这种情况下,我们正在安装 .NET 8.0 SDK。includePreviewVersions 选项用于包含 .NET SDK 的预览版本。我们需要使用预览版本,因为本书编写时 .NET 8.0 SDK 仍在预览中。如果您在 .NET 8.0 SDK 发布后阅读此书,您可以删除 includePreviewVersions 选项。

    在线管道编辑器为 YAML 文件提供了类似于这样的 IntelliSense:

图 14.9 – YAML 文件的 IntelliSense

图 14.9 – YAML 文件的 IntelliSense

  1. 您可以点击任务上方的 设置 链接,在对话框中配置任务:

图 14.10 – 在对话框中配置任务

图 14.10 – 在对话框中配置任务

  1. 接下来,添加一个 DotNetCoreCLI@2 任务来构建应用程序:

    - task: DotNetCoreCLI@2  displayName: 'dotnet build'  inputs:    command: 'build'    arguments: '--configuration $(buildConfiguration)    projects: '$(solution)'
    

    DotNetCoreCLI@2 任务用于运行 dotnet CLI 命令。在这种情况下,我们正在运行 dotnet build 命令来构建应用程序。--configuration 选项用于指定构建配置。--runtime 选项用于指定目标运行时。在这种情况下,我们针对 Linux 运行时。projects 选项用于指定 .csproj 文件或解决方案文件的路径。在这种情况下,我们使用我们之前定义的 $(solution) 变量。

  2. 接下来,添加任务来运行单元测试和集成测试:

    - task: DotNetCoreCLI@2  displayName: 'dotnet test - unit tests'  inputs:    command: 'test'    arguments: '--configuration $(buildConfiguration) --no-build --no-restore --logger trx --collect "Code coverage"'    projects: '**/*.UnitTests.csproj'- task: DotNetCoreCLI@2  displayName: 'dotnet test - integration tests'  inputs:    command: 'test'    arguments: '--configuration $(buildConfiguration) --no-build --no-restore --logger trx --collect "Code coverage"'    projects: '**/*.IntegrationTests.csproj'
    

    在前面的任务中,我们正在运行 dotnet test 命令来运行单元测试和集成测试。--no-build 选项用于跳过构建应用程序。--no-restore 选项用于跳过还原 NuGet 包,因为我们已经在前面的任务中还原了包并构建了应用程序。其他选项用于指定记录器和收集代码覆盖率。

  3. 点击 保存并运行 按钮提交更改并运行管道。你会看到管道正在运行。过了一会儿,管道将完成:

图 14.11 – 管道成功

图 14.11 – 管道成功

  1. 点击 测试 选项卡,然后你会看到测试结果:

图 14.12 – 测试结果

图 14.12 – 测试结果

  1. 你也可以按照以下方式检查代码覆盖率:

图 14.13 – 代码覆盖率

图 14.13 – 代码覆盖率

  1. 我们刚刚手动触发了管道的第一次运行。接下来,让我们创建一个拉取请求并看看管道是如何触发的。

  2. 创建一个新的分支并对源代码进行一些更改。例如,我们可以在 WeatherForecastController 中返回 6 个项目而不是 5 个项目:

    return Enumerable.Range(1, 6).Select(index => new WeatherForecast// Omitted for brevity
    
  3. 然后,提交更改并将更改推送到远程仓库。你会发现管道也会为新分支运行。这是因为 YAML 管道默认对所有分支启用。你可以使用 trigger none 选项禁用此功能。将以下行添加到 YAML 文件的开始部分:

    trigger: none
    

    然后,此管道不会自动为新分支触发,而是由拉取请求触发。

  4. 接下来,在 GitHub 仓库中创建一个新的拉取请求。你会看到管道会自动触发然后失败:

图 14.14 – 拉取请求触发的管道但失败

图 14.14 – 拉取请求触发的管道但失败

  1. 在这种情况下,我们无法合并拉取请求,因为构建管道失败了。你可以检查日志来查看管道哪里出了问题。在这种情况下,管道失败是因为单元测试失败了:

图 14.15 – 单元测试失败

图 14.15 – 单元测试失败

  1. 点击测试选项卡,然后您将看到测试结果:

图 14.16 – 单元测试结果

图 14.16 – 单元测试结果

失败测试用例的详细信息可以帮助我们识别问题。接下来,您可以修复错误并将更改推送到远程仓库。然后,管道将被再次触发。如果管道成功,您可以将提交请求合并。

提交请求构建管道用于确保代码更改不会破坏应用程序。在合并提交请求之前运行测试非常重要。接下来,让我们创建一个 CI 构建管道来构建 Docker 镜像并将其发布到 ACR。

将 Docker 镜像发布到 ACR

在上一节中,我们创建了一个提交请求构建管道来验证提交请求中的代码更改。当创建或更新提交请求时,该管道将被触发。一旦提交请求合并到main分支,我们就可以自动构建 Docker 镜像并将其发布到 ACR。为此,我们将按照以下步骤创建 CI 构建管道

  1. 按照上一节中的步骤创建一个新的管道。在配置步骤中,我们可以选择上一节中创建的pr-build-pipeline.yml文件:

图 14.17 – 选择一个现有的 YAML 文件开始

图 14.17 – 选择一个现有的 YAML 文件开始

  1. 更好的方法是选择Docker - 构建并推送镜像到 Azure 容器注册库模板,这正是我们需要的。您将被提示配置您的 Azure 订阅、ACR 等:

图 14.18 – 配置 ACR、Dockerfile 和镜像名称

图 14.18 – 配置 ACR、Dockerfile 和镜像名称

  1. Azure Pipelines 将自动检测 Dockerfile 并为您生成一个管道。默认管道可能看起来像这样:

    trigger:- mainresources:- repo: selfvariables:  # Container registry service connection established during pipeline creation  dockerRegistryServiceConnection: 'deb345e0-7bdd-4420-ba08-538785d525cd'  imageRepository: 'myazurepipelinesdemo'  containerRegistry: 'devopscrlab.azurecr.io'  dockerfilePath: '$(Build.SourcesDirectory)/src/MyBasicWebApiDemo/Dockerfile'  tag: '$(Build.BuildId)'  # Agent VM image name  vmImageName: 'ubuntu-latest'stages:- stage: Build  displayName: Build and push stage  jobs:  - job: Build    displayName: Build    pool:      vmImage: $(vmImageName)    steps:    - task: Docker@2      displayName: Build and push an image to container registry      inputs:        command: buildAndPush        repository: $(imageRepository)        dockerfile: $(dockerfilePath)        containerRegistry: $(dockerRegistryServiceConnection)        tags: |          $(tag)          latest
    

    Azure Pipelines 已识别前一个管道中 Dockerfile 的路径。此外,必须配置一些变量,例如镜像名称和容器注册库。tag变量用于使用构建 ID 标记镜像。请注意,我们在镜像中添加了一个latest标记。latest标记用于指示镜像的最新版本。

Azure Pipelines 预定义变量

Azure Pipelines 提供了许多预定义变量,可以在管道中使用。例如,$(Build.SourcesDirectory)变量用于获取代理上已下载源代码文件的本地路径。您可以在以下位置找到所有预定义变量:learn.microsoft.com/en-us/azure/devops/pipelines/build/variables?view=azure-devops&tabs=yaml

与拉取请求构建流水线的不同之处在于,我们在流水线中拥有阶段作业图 14.19显示了流水线的结构:

图 14.19 – 流水线的结构

图 14.19 – 流水线的结构

图 14.19中的组件解释如下:

  • 触发器用于指定流水线何时运行。它可以是计划、拉取请求、对特定分支的提交或手动触发。

  • 流水线包含一个或多个阶段。阶段用于组织作业。在拉取请求构建流水线中,阶段被省略。您可以在一个流水线中拥有多个阶段,用于不同的目的。例如,您可以有一个构建阶段、测试阶段和部署阶段。阶段还可以用于设置安全和审批的边界。

  • 每个阶段包含一个或多个作业。作业是一系列步骤的容器。作业可以在一个代理上运行,也可以在没有代理的情况下运行。例如,您可以有一个在 Windows 代理上运行的作业,另一个在 Linux 代理上运行的作业。

  • 每个作业包含一个或多个步骤。步骤是流水线中最小的构建块。步骤通常是一个任务或脚本。Azure Pipelines 提供了许多内置任务,例如我们在这个流水线中使用的Docker@2任务。内置任务是一个预定义的打包脚本,用于执行操作。您也可以编写自己的自定义任务。任务可以是命令行工具、脚本或编译程序。您可以在以下位置找到所有内置任务:learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/?view=azure-pipelines

在前面的流水线中,我们有一个阶段、一个作业和一个步骤。步骤是Docker@2任务。Docker@2任务用于构建和推送 Docker 镜像到容器注册库。使用此任务简化了构建和推送 Docker 镜像的过程,因此我们不需要手动编写docker build命令。

  1. 将此流水线重命名为docker-build-pipeline。类似于pr-build-pipeline,我们需要排除docker-build-pipeline流水线在新分支和拉取请求中的运行。将以下行添加到 YAML 文件的开始部分:

    pr: none
    

    因此,当我们创建一个新的分支或新的拉取请求时,docker-build-pipeline不会自动触发。我们将手动触发流水线或通过合并拉取请求来触发。

  2. 点击docker-build-pipeline将自动触发。

    如果一切正常,您将看到docker-build-pipeline流水线成功,并且 Docker 镜像已发布到 ACR:

图 14.20 – Docker 镜像已发布到 ACR

图 14.20 – Docker 镜像已发布到 ACR

CI 管道是一个良好的实践,以确保代码更改可以在不破坏应用程序的情况下成功构建。然而,某些更改可能不需要运行测试或重新构建 Docker 镜像。例如,如果你只更改了文档(例如,README文件),你不需要运行测试或重新构建 Docker 镜像。在这种情况下,你有几种选择:

  1. 你可以排除一些文件从管道中。例如,你可以排除README.md文件。你可以使用paths选项来指定包含或排除的路径,如下所示:

    trigger:  branches:    include:    - main  paths:    exclude:    - README.md    - .gitignore    - .dockerignore    - *.yml
    
  2. 你可以跳过某些提交的 CI 管道。在提交信息中使用[skip ci]来跳过 CI 管道。例如,你可以在提交信息中使用[skip ci] update README来跳过此提交的 CI 管道。一些变体包括[ci skip][skip azurepipelines][skip azpipelines][skip azp]等等。

在创建管道时可能会遇到一些错误。以下是一些故障排除技巧:

  • 仔细检查日志。日志可以帮助你识别问题。

  • 检查变量。确保变量正确。

  • 检查权限。确保服务连接具有正确的权限。

  • 检查 YAML 语法。确保 YAML 文件有效。YAML 文件使用缩进来表示结构。请确保缩进正确。

  • 检查管道结构。确保管道结构正确。例如,确保steps部分位于jobs部分之下,而jobs部分位于stages部分之下。

  • 检查管道触发器。确保管道由正确的事件触发。你可以使用branchespathsincludeexclude来指定触发管道的分支和路径。

  • 注意,如果你在在线编辑器中编辑管道 YAML 文件,你将直接将更改提交到main分支。你的本地feature分支将不会自动更新。当你将更改推送到 feature 分支时,是否触发管道取决于你 feature 分支中的 YAML 文件设置,而不是main分支。因此,请确保你的 feature 分支与main分支保持同步。

  • 如果你使用 VS 创建 Dockerfile,请仔细检查 Dockerfile 中的路径。有时,如果你使用自定义解决方案结构,VS 可能无法检测到正确的路径。

我们现在已将 Docker 镜像推送到 ACR。接下来,让我们创建一个发布管道,将应用程序部署到 Azure Web App for Containers。

将应用程序部署到 Azure Web App for Containers

在上一节中,我们创建了一个 CI 构建管道来构建 Docker 镜像并将其发布到 ACR。在本节中,我们将创建一个发布管道,从 ACR 拉取 Docker 镜像并将其部署到 Azure Web App for Containers。

按照上一节中的步骤创建一个新的管道。当我们配置管道时,选择入门级管道模板,因为我们将从零开始。默认的入门级管道包含两个脚本作为示例。删除这些脚本,它应该如下所示:

trigger: nonepr: none
pool:
  vmImage: ubuntu-latest

我们可以禁用管道的触发器,因为我们将在需要部署应用程序时手动运行管道。将管道重命名为release-pipeline

接下来,向管道中添加一些变量:

variables:  containerRegistry: 'devopscrlab.azurecr.io'
  imageRepository: 'myazurepipelinesdemo'
  tag: 'latest'

接下来,我们需要配置用户名和密码以验证管道从 ACR 拉取 Docker 镜像。您可以在 Azure 门户中找到您的 ACR 实例的用户名和密码。点击左侧的访问密钥按钮,然后您将看到用户名和密码。

由于密码是秘密,我们无法直接在 YAML 文件中使用它,否则密码将在 GitHub 仓库中暴露。Azure Pipelines 提供了一种在管道中存储秘密的方法。点击右上角的变量按钮,然后点击新建变量按钮以添加新变量:

图 14.21 – 添加新变量以存储密码

图 14.21 – 添加新变量以存储密码

检查$(acrpassword)变量以引用密码。

接下来,添加一个任务来设置 Azure App Service 设置。从任务助手中选择Azure App Service 设置任务。我们需要添加凭据以验证 Azure Web App 从 ACR 拉取 Docker 镜像。Azure Pipelines 将提示您配置任务。请注意,应用设置字段是一个 JSON 字符串。任务应如下所示:

- task: AzureAppServiceSettings@1  displayName: Update settings
  inputs:
    azureSubscription: '<Your Azure subscription>(<guid>)'
    appName: 'azure-pipeline-demo'
    resourceGroupName: 'devops-lab'
    appSettings: |
      [
        {
          "name": "DOCKER_REGISTRY_SERVER_URL",
          "value": "$(containerRegistry)",
          "slotSetting": false
        },
        {
          "name": "DOCKER_REGISTRY_SERVER_USERNAME",
          "value": "devopscrlab",
          "slotSetting": false
        },
        {
          "name": "DOCKER_REGISTRY_SERVER_PASSWORD",
          "value": "$(acrpassword)",
          "slotSetting": false
        }
      ]

appSettings字段中,我们使用$(acrpassword)变量来引用我们之前创建的密码。

接下来,点击右上角的显示助手按钮以打开助手。助手帮助我们轻松使用内置任务。选择用于容器的 Azure Web App任务,该任务用于将 ACR 中的 Docker 镜像部署到 Azure Web App for Containers。Azure Pipelines 将提示您配置任务。按照以下方式配置任务:

- task: AzureWebAppContainer@1  displayName: Deploy to Azure Web App for Container
  inputs:
    azureSubscription: '<Your Azure subscription>'
    appName: 'azure-pipeline-demo'
    containers: '$(containerRegistry)/$(imageRepository):$(tag)'
    containerCommand: 'dotnet MyBasicWebApiDemo.dll'

或者,您可以使用助手中的Azure App Service 部署任务。此任务用于将应用程序部署到支持原生部署或容器部署的 Azure Web App。您需要配置 Azure 订阅、App Service 类型等。当您为App Service 类型选择Web App for Containers (Linux)选项时,您将被提示配置 ACR 名称、Docker 镜像名称、标签等。任务应如下所示:

- task: AzureRmWebAppDeployment@4  displayName: Deploy to Web App for Container
  inputs:
    ConnectionType: 'AzureRM'
    azureSubscription: '<Your Azure subscription>'
    appType: 'webAppContainer'
    WebAppName: 'azure-pipeline-demo'
    DockerNamespace: 'devopscrlab.azurecr.io'
    DockerRepository: 'myazurepipelinesdemo'
    DockerImageTag: 'latest'
    StartupCommand: 'dotnet MyBasicWebApiDemo.dll'

注意,我们还需要指定一个StartupCommand值。在这种情况下,我们使用dotnet MyBasicWebApiDemo.dll来启动应用程序。

现在,我们可以手动触发管道以部署应用程序。如果一切正常,您将看到部署成功。

检查 Azure Web App 的配置。您将看到 应用程序设置 已更新:

图 14.22 – 应用设置已更新

图 14.22 – 应用设置已更新

导航到 Azure 门户并检查我们之前创建的 Azure Web App 的详细信息。您可以在其中找到 Azure Web App 的 URL,例如 azure-pipeline-demo.azurewebsites.net。在浏览器中打开此 URL 并检查控制器端点,例如 https://azure-pipeline-demo.azurewebsites.net/WeatherForecast。您将看到应用程序的响应。

到目前为止,我们已经创建了三个管道:

  • 拉取请求构建管道:此管道用于验证拉取请求中的代码更改。当创建或更新拉取请求时,它将被触发。如果构建失败或测试失败,则无法合并拉取请求。此管道不生成任何工件。

  • main 分支。此管道生成一个 Docker 镜像作为工件。

  • 发布管道:此管道用于从 ACR 拉取 Docker 镜像并将其部署到 Azure Web App for Containers。此管道可以在将新的 Docker 镜像发布到 ACR 时手动或自动触发。此管道不生成任何工件。

配置设置和秘密

在上一节中,我们创建了一个发布管道,用于将应用程序部署到 Azure Web App for Containers。我们可能还需要将应用程序部署到其他环境,例如预发布环境。这些不同的环境可能具有不同的设置,例如数据库连接字符串、API 密钥等。那么,我们如何为不同的环境配置设置呢?

有多种实现方式。一种简单的方法是为不同的环境配置变量。您可以直接在每个管道中定义变量。Azure Pipelines 还提供了一个 来管理变量。您可以将变量分组到变量组中,然后在管道中使用该变量组。

安全地存储机密信息对任何组织都至关重要。为确保您的秘密安全,您可以使用各种密钥保管库,例如 Azure Key Vault 和 AWS Secrets Manager。Azure Pipelines 提供了一个密钥保管库任务来从保管库中检索秘密。使用 Azure Key Vault 任务,您可以轻松地从保管库检索秘密并在管道中使用它们。有关密钥保管库任务的更多信息,请访问 learn.microsoft.com/en-us/azure/devops/pipelines/release/key-vault-in-own-project

本章的目的不是涵盖 Azure Pipelines 的所有细节,但你现在应该对 Azure Pipelines 有一个基本的了解。使用 CI/CD 管道可以帮助你自动化构建、测试和部署过程,从而消除人工工作并减少人为错误的风险。在现代软件开发中,使用 CI/CD 管道变得越来越重要。每个开发者都应该学习如何使用它们。

在下一节中,我们将探讨 GitHub Actions,这是另一个流行的 CI/CD 工具。

GitHub Actions

在上一节中,我们探讨了 Azure Pipelines。接下来,让我们来了解 GitHub Actions。GitHub Actions 是由 GitHub 提供的 CI/CD 工具,它与 Azure Pipelines 非常相似。在本节中,我们将使用 GitHub Actions 来构建和测试应用程序,并将 Docker 镜像推送到 ACR。

准备项目

为了演示如何使用 GitHub Actions,我们将使用与上一节相同的源代码。你可以从这里下载源代码:github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter14/MyBasicWebApiDemo。创建一个新的 GitHub 仓库并将源代码推送到仓库。源代码的目录结构如下:

MyGitHubActionsDemo    ├── src
    │   └──MyBasicWebApiDemo
    ├── tests
    │   ├──MyBasicWebApiDemo.UnitTests
    │   └──MyBasicWebApiDemo.IntegrationTests
    ├── MyBasicWebApiDemo.sln
    ├── README.md
    ├── LICENSE
    └── .gitignore

如果你使用不同的目录结构,请在以下章节中相应地更新管道中的路径。我们将使用这个仓库来演示如何配置 GitHub Actions。

创建 GitHub Actions

我们将重用上一节中创建的 Azure 资源。如果你还没有创建 Azure 资源,请参阅 创建 Azure 资源 部分,以创建资源。

在 GitHub 仓库页面上,点击 操作 选项卡,你可以看到许多针对不同编程语言和框架的模板:

图 14.23 – 为 GitHub Actions 选择模板

图 14.23 – 为 GitHub Actions 选择模板

持续集成 部分,你可以找到 .NET 模板。点击 配置 按钮创建一个新的工作流程。工作流程是一个 YAML 文件,它定义了 CI/CD 管道。默认工作流程可能看起来像这样:

# This workflow will build a .NET project# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
name: .NET
on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 6.0.x
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore
    - name: Test
      run: dotnet test --no-build --verbosity normal

工作流程的语法与 Azure Pipelines 非常相似。工作流程在创建或更新拉取请求时触发,或者当向 main 分支推送提交时触发。

在右侧,你可以找到 Marketplace 面板。Marketplace 与 Azure DevOps Pipelines 的助手类似,提供了许多内置操作,你可以在工作流程中使用:

图 14.24 – Marketplace 面板

图 14.24 – Marketplace 面板

示例应用程序使用.NET 8。因此,我们需要将dotnet-version更新为8.0.x。点击已提交到/.github/workflows目录的dotnet.yml文件。对源代码进行更改或创建一个新的分支,并将更改推送到远程仓库。例如,您可以将控制器更改回返回六个项目而不是五个项目:

public IEnumerable<WeatherForecast> Get(){
    return Enumerable.Range(1, 6).Select(index => new WeatherForecast
    // Omitted for brevity

您还可以创建一个拉取请求。您将看到工作流程会自动触发,但测试失败:

图 14.25 – 工作流程自动触发然后失败

图 14.25 – 工作流程自动触发然后失败

点击日志中的链接以查看导致测试失败的代码的详细信息。在审查拉取请求中的代码更改时,这非常方便。如果一切正常,您可以合并拉取请求。

为了明确它适用于拉取请求构建,我们可以将此文件重命名为pr-build.yml,并更新on部分以使工作流程在拉取请求中运行:

on:  pull_request:
    branches: [ "main" ]

接下来,让我们构建一个 Docker 镜像并将其推送到 ACR。

将 Docker 镜像推送到 ACR

.github\workflows文件夹中创建一个名为docker-build.yml的新 YAML 文件。更新文件内容如下:

name: Pull Request buildon:
  push:
    branches: [ "main" ]

当向main分支推送提交或拉取请求合并到main分支时,此工作流程会被触发。

为了验证操作以访问 ACR,我们需要在 GitHub 密钥中配置 ACR 的用户名和密码。通过在 Azure 门户中点击 ACR 的访问密钥菜单来查找用户名和密码。

前往 GitHub 仓库的设置,然后在安全类别中点击密钥和变量。然后,点击操作,您将看到密钥和变量页面。有两种类型的密钥:环境密钥和仓库密钥。您可以直接在仓库密钥中存储用户名和密码。为了演示如何使用环境密钥,我们将在这个示例中使用环境密钥。点击管理环境按钮以创建一个名为生产的新环境。然后,点击添加密钥按钮以添加用户名和密码:

  • 名称:REGISTRY_USERNAME。值:ACR 的用户名。

  • 名称:REGISTRY_PASSWORD。值:ACR 的密码。

您可以更改环境密钥的名称,但请确保在以下工作流程中使用相同的名称。

现在,环境密钥应该看起来像这样:

图 14.26 – Actions 密钥和变量页面

图 14.26 – Actions 密钥和变量页面

将以下内容添加到docker-build.yml文件中:

jobs:  docker_build_and_push:
    runs-on: ubuntu-latest
    environment: Production
    steps:
    - uses: actions/checkout@v3
    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: 8.0.x
    - name: Restore dependencies
      run: dotnet restore
    - name: Build
      run: dotnet build --no-restore
    - name: Login to Azure Container Registry
      uses: azure/docker-login@v1
      with:
        login-server: devopscrlab.azurecr.io
        username: ${{ secrets.REGISTRY_USERNAME }}
        password: ${{ secrets.REGISTRY_PASSWORD }}
    - name: Push to Azure Container Registry
      run: |
        docker build -f ${{ github.workspace }}/src/MyBasicWebApiDemo/Dockerfile -t devopscrlab.azurecr.io/myazurepipelinesdemo:${{ github.run_id }} -t devopscrlab.azurecr.io/myazurepipelinesdemo:latest .
        docker push devopscrlab.azurecr.io/myazurepipelinesdemo:${{ github.run_id }}
        docker push devopscrlab.azurecr.io/myazurepipelinesdemo:latest

前面的内容与pr-build.yml文件类似,但我们做了一些更改:

  • 我们添加了一个environment部分来指定环境,以便我们可以在以后引用环境密钥。

  • 我们添加了一个新的azure/docker-login@v1步骤来登录到 ACR。在这个步骤中,我们使用环境密钥指定了登录服务器、用户名和密码。

  • 我们添加了一个新步骤来构建 Docker 镜像并将其推送到 ACR。在这个步骤中,我们使用了 github.run_id 变量来使用运行 ID 标记镜像。我们还使用 latest 标记了镜像。

GitHub Actions 上下文

GitHub Actions 支持许多内置上下文变量,例如 github.run_idgithub.run_numbergithub.shagithub.ref 等。你可以在这里找到所有上下文变量:docs.github.com/en/actions/learn-github-actions/contexts

提交更改并将更改推送到远程仓库。下次你合并拉取请求时,你会看到工作流程会自动触发,并将 Docker 镜像推送到 ACR:

图 14.27 – GitHub Actions 工作流程自动触发

图 14.27 – GitHub Actions 工作流程自动触发

由于 Azure Pipelines 和 GitHub Actions 之间有许多相似之处,我们不会涵盖 GitHub Actions 的所有细节。也许轮到你来创建一个 GitHub Actions 工作流程,将 Docker 镜像部署到 Azure Web App for Containers?你可以在这里找到有关 GitHub Actions 的更多信息:docs.github.com/en/actions

摘要

在本章中,我们探讨了 CI/CD 的基础知识。我们讨论了如何使用 Docker 来容器化 ASP.NET Web API 应用程序,包括如何创建 Dockerfile、构建 Docker 镜像以及在本地运行 Docker 容器。然后我们了解了 Azure DevOps Pipelines,这是微软的一个强大的 CI/CD 平台,以及如何在 YAML 文件中创建 CI/CD 管道。我们涵盖了配置触发器、使用内置任务和使用变量。我们创建了三个管道,用于构建、Docker 镜像构建和发布。我们还简要讨论了 GitHub Actions,这是另一个流行的 CI/CD 工具,并创建了一个 GitHub Actions 工作流程来构建和测试应用程序,然后构建 Docker 镜像并将其推送到 ACR。阅读本章后,你应该对 CI/CD 有基本的了解,并能够使用 CI/CD 管道来自动化构建、测试和部署过程。

在下一章中,我们将检查构建 ASP.NET Web API 的一些常见做法,包括缓存、HttpClient 工厂等。

第十五章:ASP.NET Core Web API 常见实践

在前面的章节中,我们介绍了很多概念,包括 ASP.NET web API 的基础知识、RESTful 风格、Entity Framework、单元测试和集成测试、CI/CD 等。你应该能够自己构建一个简单的 ASP.NET Core web API 应用程序。然而,还有很多东西我们需要学习。

你可能之前听说过“没有银弹”这个短语。这意味着通常没有简单、通用或一刀切的解决方案来解决所有问题。无论技术、工具或框架多么强大,它们都不是万能的。这对于 ASP.NET Core Web API 开发也是如此。然而,有一些常见实践可以帮助我们构建更好的 ASP.NET Core Web API 应用程序。

在本章中,我们将总结 ASP.NET Core Web API 开发的常见实践。在本章中,我们将涵盖以下主题:

  • ASP.NET Core Web API 开发的常见实践

  • 通过实现缓存来优化性能

  • 使用 HttpClientFactory 管理 HttpClient 实例

读完本章后,你应该能够扩展你对 ASP.NET Core web API 开发的知识,并构建更好的 Web API 应用程序。

技术要求

本章的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter15找到。

ASP.NET Web API 开发的常见实践

在本节中,我们将介绍一些 ASP.NET web API 开发的常见实践。当然,我们无法在本书中涵盖所有常见实践。但是,我们将尝试涵盖最重要的那些。

使用 HTTPS 而不是 HTTP

在前面的章节中,我们为了简单起见使用了 HTTP 端点;然而,在现实世界中,应该始终使用 HTTPS 而不是 HTTP。HTTPS 是 HTTP 的安全版本,它使用 TLS/SSL 加密 HTTP 流量,从而防止第三方拦截或篡改数据。这确保了传输数据的安仝性和完整性。

对于需要安全数据传输的网站,如在线银行和在线购物,HTTPS 正变得越来越流行。这一趋势体现在许多网络浏览器,如 Google Chrome、Microsoft Edge、Firefox 等,现在将 HTTP 网站标记为 不安全,以鼓励用户切换到 HTTPS。这是使用 HTTPS 进行安全数据传输趋势日益增长的明显迹象。

默认情况下,ASP.NET Core Web API 模板使用 HTTPS。你可以在 Program.cs 文件中找到以下代码:

app.UseHttpsRedirection();

此代码将所有 HTTP 请求重定向到 HTTPS。在开发环境中,ASP.NET Core Web API 应用程序使用自签名证书。当您将 ASP.NET Core Web API 应用程序部署到生产环境时,您需要使用由受信任的证书颁发机构CA)签发的有效证书,例如 Let’s Encrypt、DigiCert、Comodo 等。

正确使用 HTTP 状态码

HTTP 状态码用于指示 HTTP 请求的状态。HTTP 状态码有五个类别:

  • 1xx: 信息性

  • 2xx: 成功

  • 3xx: 重定向

  • 4xx: 客户端错误

  • 5xx: 服务器错误

以下表格提供了 RESTful Web API 中最常用的一些 HTTP 状态码的摘要:

状态码 描述
200 OK
201 已创建
202 已接受
204 无内容
301 永久移动
302 找到
304 未修改
400 错误请求
401 未授权
403 禁止访问
404 未找到
405 不允许的方法
409 冲突
410 已删除
415 不支持媒体类型
422 不可处理的实体
429 请求过多
500 内部服务器错误
501 未实现
503 服务不可用
504 网关超时

表 15.1 – RESTful Web API 中常用的 HTTP 状态码

以下列表显示了 RESTful Web API 中 HTTP 方法和它们对应的状态码:

  • GET: GET方法用于检索单个资源或资源集合。GET请求不应修改服务器状态。它可以返回以下状态码:

    • 200: 资源已找到并返回。

    • 404: 资源未找到。注意,如果集合存在但为空,则GET方法应返回200状态码,而不是404状态码。

  • POST: POST方法用于创建单个资源或资源集合。它也可以用于更新资源。它可以返回以下状态码:

    • 200: 资源已成功更新。

    • 201: 资源创建成功。响应应包含新创建资源的标识符。

    • 202: 资源已接受处理,但处理尚未完成。此状态码通常用于长时间运行的操作。

    • 400: 请求无效。

    • 409: 资源已存在。

  • PUT: PUT方法用于更新单个资源或资源集合。它很少用于创建资源。它可以返回以下状态码:

    • 200: 资源已成功更新。

    • 204: 资源已成功更新,但没有内容可返回。

    • 404: 资源未找到。

  • DELETE: DELETE方法用于删除具有特定标识符的单个资源。它可以用于删除资源集合,但这不是常见场景。它可以返回以下状态码:

    • 200: 资源已成功删除,并且响应中包含已删除的资源。

    • 204: 资源已成功删除,但没有内容返回。

    • 404: 资源未找到。

重要的是要注意,这个列表并不全面,仅适用于 RESTful Web API。在选择合适的 HTTP 状态码时,请考虑具体场景。对于 GraphQL API,通常使用 200 作为大多数响应的状态码,errors 字段指示任何错误。

使用异步编程

ASP.NET Core Web API 框架旨在异步处理请求,因此我们应该尽可能使用异步编程。异步编程允许应用程序并发处理多个任务,从而可以提高应用程序的性能。对于许多 I/O 密集型操作,例如访问数据库、发送 HTTP 请求和操作文件,使用异步编程可以在等待 I/O 操作完成的同时释放线程以处理其他请求。

在 C# 中,你可以使用 asyncawait 关键字来定义和等待异步操作。.NET 中的许多方法都有同步和异步版本。例如,StreamReader 类有以下同步和异步方法来读取流的内容:

// Synchronous methodspublic int Read();
public string ReadToEnd();
// Asynchronous methods
public Task<int> ReadAsync();
public Task<string> ReadToEndAsync();

在这四种方法中,没有 Async 后缀的方法是同步的,它会在操作完成之前阻塞线程。相比之下,带有 Async 后缀的方法是异步的,它立即返回一个 Task 对象并允许线程处理其他请求。当操作完成时,Task 对象将完成,线程将继续处理请求。尽可能使用异步编程以提高应用程序的性能。

对于 I/O 操作,我们应该始终使用异步编程。例如,当访问 HttpRequestHttpResponse 对象时,应使用异步方法。以下是一个示例:

[HttpPost]public async Task<ActionResult<Post>> PostAsync()
{
    // Read the content of the request body
    var jsonString = await new StreamReader(Request.Body).ReadToEndAsync();
    // Do something with the content
    var result = JsonSerializer.Deserialize<Post>(jsonString);
    return Ok(result);
}

在前面的代码中,使用 ReadToEndAsync() 方法读取请求体的内容。对于这种情况,我们不应使用同步的 ReadToEnd() 方法,因为它将阻塞线程直到操作完成。

如果有多个异步操作需要同时执行,我们可以使用 Task.WhenAll() 方法等待所有异步操作完成。以下是一个示例:

[HttpGet]public async Task<ActionResult> GetAsync()
{
    // Simulate a long-running I/O-bound operation
    var task1 = SomeService.DoSomethingAsync();
    var task2 = SomeService.DoSomethingElseAsync();
    await Task.WhenAll(task1, task2);
    return Ok();
}

在前面的代码中,Task.WhenAll() 方法等待 task1task2 任务完成。如果你需要在任务完成后获取任务的结果,可以使用 Task 对象的 Result 属性来获取结果。以下是一个示例:

[HttpGet]public async Task<ActionResult> GetAsync()
{
    // Simulate long-running I/O-bound operations
    var task1 = SomeService.DoSomethingAsync();
    var task2 = SomeService.DoSomethingElseAsync();
    await Task.WhenAll(task1, task2);
    var result1 = task1.Result;
    var result2 = task2.Result;
    // Do something with the results
    return Ok();
}

在前面的代码中,使用了task1task2对象的Result属性来获取任务的结果。因为我们已经使用了await关键字等待任务完成,所以Result属性将立即返回结果。但如果我们没有使用await关键字等待任务完成,Result属性将阻塞线程,直到任务完成。因此,在使用Result属性时请务必小心。同样,Task对象的Wait()方法也会阻塞线程,直到任务完成。如果你想等待任务完成,请使用await关键字而不是Wait方法。

重要提示

注意,Task.WhenAll()方法并不适用于所有场景。例如,EF Core 不支持在同一个数据库上下文中并行执行多个查询。如果你需要在同一个数据库上下文中执行多个查询,你应该使用await关键字等待前一个查询完成,然后再执行下一个查询。

在使用异步编程时,有几个重要的考虑因素需要记住。这些包括但不限于以下内容:

  • 不要在 ASP.NET Core 中使用async void。唯一允许使用async void的场景是在事件处理器中。如果异步方法返回void,则方法中抛出的异常不会被调用者正确捕获。

  • 不要在同一个方法中混合同步和异步方法。如果可能,尽量在整个过程中使用async。这允许整个调用堆栈都是异步的。

  • 如果你需要使用Task对象的Result属性,请确保Task对象已完成。否则,Result属性将阻塞线程,直到Task对象完成。

  • 如果你有一个只返回另一个异步方法结果的函数,就没有必要使用async关键字。只需直接返回Task对象即可。例如,以下代码是不必要的:

    public async Task<int> GetDataAsync(){    return await SomeService.GetDataAsync();}
    

    以下代码没有使用async/await关键字,这更好:

    public Task<int> GetDataAsync(){    return SomeService.GetDataAsync();}
    

这是因为async关键字将创建一个状态机来管理异步方法的执行。在这种情况下,这是不必要的。直接返回Task不会产生额外的开销。

对大型集合使用分页

不建议在单个响应中返回大量资源,因为这可能导致性能问题。这些问题可能包括以下内容:

  • 服务器可能需要大量时间来查询数据库并处理响应。

  • 响应负载可能相当大,从而导致网络拥塞。这可能会对系统的性能产生负面影响,导致延迟增加和吞吐量下降。

  • 客户端可能需要额外的时间和资源来处理大量的响应。对于客户端来说,反序列化一个大的 JSON 对象可能是计算密集型的。此外,在 UI 上渲染大量项目可能会导致客户端无响应。

为了有效地管理大量集合,建议使用分页。第五章介绍了通过使用IQueryable接口的Skip()Take()方法来实现分页和过滤。我们还提到应该使用AsNoTracking()方法来提高只读查询的性能。这将导致返回给客户端的资源集合。然而,客户端可能不知道是否有更多的资源可用。为了解决这个问题,我们可以创建一个自定义类来表示分页响应。以下是一个示例:

public class PaginatedList<T> where T : class{
    public int PageIndex { get; }
    public int PageSize { get; }
    public int TotalPages { get; }
    public List<T> Items { get; } = new();
    public PaginatedList(List<T> items, int count, int pageIndex = 1, int pageSize = 10)
    {
        PageIndex = pageIndex;
        PageSize = pageSize;
        TotalPages = (int)Math.Ceiling(count / (double)pageSize);
        Items.AddRange(items);
    }
    public bool HasPreviousPage => PageIndex > 1;
    public bool HasNextPage => PageIndex < TotalPages;
}

在前面的代码中,PaginatedList<T>类包含一些属性来表示分页信息:

  • PageIndex:当前页索引

  • PageSize:页面大小

  • TotalPages:总页数

  • Items:当前页上的项目集合

  • HasPreviousPage:指示是否存在上一页

  • HasNextPage:指示是否存在下一页

然后,我们可以在控制器中使用这个类来实现分页。以下是一个示例:

[HttpGet]public async Task<ActionResult<PaginatedList<Post>>> GetPosts(int pageIndex = 1, int pageSize = 10)
{
    var posts = _context.Posts.AsQueryable().AsNoTracking();
    var count = await posts.CountAsync();
    var items = await posts.Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();
    var result = new PaginatedList<Post>(items, count, pageIndex, pageSize);
    return Ok(result);
}

在前面的代码中,除了Items属性外,PaginatedList<T>类还包含分页信息,如PageIndexPageSizeTotalPagesHasPreviousPageHasNextPage等。端点的响应将如下所示:

{  "pageIndex": 1,
  "pageSize": 10,
  "totalPages": 3,
  "items": [
    {
      "id": "3c979917-437b-406d-a784-0784170b5dd9",
      "title": "Post 26",
      "content": "Post 26 content",
      "categoryId": "ffdd0d80-3c3b-4e83-84c9-025d5650c6e5",
      "category": null
    },
    ...
  ],
  "hasPreviousPage": false,
  "hasNextPage": true
}

这样,客户端可以轻松实现分页。您还可以在PaginatedList<T>类中包含更多信息,例如上一页和下一页的链接等。

在实现分页时,考虑排序和过滤非常重要。通常,数据应该先进行过滤,然后是排序,最后是分页。例如,以下 LINQ 查询可以用来:

var posts = _context.Posts.AsQueryable().AsNoTracking();posts = posts.Where(x => x.Title.Contains("Post")).OrderBy(x => x.PublishDate).Skip((pageIndex - 1) * pageSize).Take(pageSize).ToListAsync();

应该使用Where()方法首先过滤数据,以减少需要排序的数据量。这很重要,因为排序通常是一个昂贵的操作。一旦数据被过滤,可以使用OrderBy()方法对其进行排序。最后,可以使用Skip()Take()方法对数据进行分页。

指定响应类型

ASP.NET Core Web API 端点可以返回各种类型的响应,例如ActionResultActionResult<T>或对象的特定类型。以下代码返回一个Post对象:

[HttpGet("{id}")]public async Task<Post> GetAsync(Guid id)
{
    var post = await _postService.GetAsync(id);
    return post;
}

上述代码可以正常工作,但如果找不到post怎么办?建议使用ActionResult<T>而不是对象的特定类型。ActionResult<T>类是一个泛型类,可以用来返回各种 HTTP 状态码。以下是一个示例:

public async Task<ActionResult<Post>> GetPost(Guid id){
    var post = await _context.Posts.FindAsync(id);
    if (post == null)
    {
        return NotFound();
    }
    return Ok(post);
}

在前面的代码中,ActionResult<Post> 类被用来返回一个 Post 对象。如果找不到 post,则使用 NotFound 方法返回 404 Not Found 状态码。如果找到 post,则使用 Ok 方法返回 200 OK 状态码。

我们可以将 [ProducesResponseType] 属性添加到指定端点的响应类型。以下是一个完整的示例:

[HttpGet("{id}")][ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Post>> GetPost(Guid id)
{
    var post = await _context.Posts.FindAsync(id);
    if (post == null)
    {
        return NotFound();
    }
    return Ok(post);
}

在前面的代码中,有两个 [ProducesResponseType] 属性。第一个指定了 200 OK 状态码,第二个指定了 404 Not Found 状态码。[ProducesResponseType] 属性是可选的,但建议使用它来指定端点的响应类型。Swagger UI 将使用 [ProducesResponseType] 属性来生成端点的响应类型,如图 图 15.1 所示:

图 15.1 – Swagger UI 使用  属性来生成端点的响应类型

图 15.1 – Swagger UI 使用 [ProducesResponseType] 属性来生成端点的响应类型

我们可以在 Swagger UI 中看到可能的响应。这个端点可以返回 200 OK 状态码或 404 Not Found 状态码。

为了强制使用 [ProducesResponseType] 属性,我们可以使用 OpenAPIAnalyzers。这个分析器可以用来报告缺少 [ProducesResponseType] 属性。在 *.csproj 文件的 <PropertyGroup> 部分添加以下代码:

<IncludeOpenAPIAnalyzers>true</IncludeOpenAPIAnalyzers>

然后,我们可以在 Visual Studio 中看到如果控制器操作没有 [ProducesResponseType] 属性将显示的警告,如图 图 15.2 所示:

图 15.2 – Visual Studio 如果控制器操作没有  属性将显示警告

图 15.2 – Visual Studio 如果控制器操作没有 [ProducesResponseType] 属性将显示警告

Visual Studio 将为你提供快速修复来添加这些属性。这个分析器非常有用,建议使用它。

在端点添加注释

在端点添加 XML 注释可以帮助其他开发者更好地理解它们。这些注释将在 Swagger UI 中显示,提供端点的全面描述。这可以成为开发者使用端点时的宝贵资源。

在端点添加 XML 注释非常简单。我们只需向它们添加 /// 注释。当你输入 /// 时,Visual Studio 将自动生成 XML 注释结构。你需要添加方法的描述、参数、返回值等。以下是一个示例:

/// <summary>/// Get a post by id
/// </summary>
/// <param name="id">The id of the post</param>
/// <returns>The post</returns>
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Post>> GetPost(Guid id)
{
    // Omitted for brevity
}

你也可以向模型类添加注释。以下是一个简单的示例:

/// <summary>/// The post model
/// </summary>
public class Post
{
    /// <summary>
    /// The id of the post
    /// </summary>
    public Guid Id { get; set; }
    /// <summary>
    /// The title of the post
    /// </summary>
    public string Title { get; set; }
    /// <summary>
    /// The content of the post
    /// </summary>
    public string Content { get; set; }
}

然后,我们需要在项目文件中启用 XML 文档文件生成。打开 *.csproj 文件,并在 <PropertyGroup> 元素中添加以下代码:

<GenerateDocumentationFile>true</GenerateDocumentationFile><NoWarn>$(NoWarn);1591</NoWarn>

GenerateDocumentationFile 属性指定是否应生成 XML 文档文件。NoWarn 属性可以用来抑制特定的警告,例如与缺少 XML 注释相关的 1591 警告代码。抑制此警告是有益的,因为它可以防止在构建项目时出现警告。

接下来,我们需要配置 Swagger UI 以使用 XML 文档文件。打开 Program.cs 文件,并更新 builder.Services.AddSwaggerGen() 方法如下:

builder.Services.AddSwaggerGen(c =>{
    // The below line is optional. It is used to describe the API.
    // c.SwaggerDoc("v1", new OpenApiInfo { Title = "MyBasicWebApiDemo", Version = "v1" });
    c.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{Assembly.GetExecutingAssembly().GetName().Name}.xml"));
});

在前面的代码中,使用了 IncludeXmlComments 方法来指定 XML 文档文件。我们可以使用反射 {Assembly.GetExecutingAssembly().GetName().Name}.xml 来获取 XML 文档文件名。AppContext.BaseDirectory 属性用于获取应用程序的基本目录。

要在 Swagger UI 中查看评论,运行应用程序并打开 Swagger UI。如图 15.3 所示,评论将被显示:

图 15.3 – Swagger UI 显示端点的评论

图 15.3 – Swagger UI 显示端点的评论

模型类也在 Swagger UI 中进行了描述,如图 15.4 所示:

图 15.4 – Swagger UI 显示模型类的评论

图 15.4 – Swagger UI 显示模型类的评论

在 Swagger UI 中显示评论是提供开发者友好的 API 文档的绝佳方式。强烈建议为端点和模型类添加注释。

使用 System.Text.Json 替代 Newtonsoft.Json

Newtonsoft.Json 是 .NET 中流行的 JSON 库,在许多项目中得到广泛应用。它是由 James Newton-King 在 2006 年作为一个个人项目创建的,并从此成为 NuGet 上的第一库,下载量超过十亿次。一个有趣的事实是,在 2022 年,NuGet 上 Newtonsoft.Json 的下载量达到了令人印象深刻的 21 亿次,超过了 2,147,483,647 的 Int32.MaxValue。这一里程碑促使 NuGet 进行了修改,以支持 Newtonsoft.Json 的持续下载。

随着 .NET Core 3.0 的发布,Microsoft 引入了一个新的 JSON 库,System.Text.Json。这个库通过利用 Span<T> 来设计,它提供了一个类型安全和内存安全的任意连续内存区域的表示。使用 Span<T> 可以减少内存分配并提高 .NET 代码的性能。System.Text.Json 包含在 .NET Core SDK 中,并且正在积极开发中。尽管它可能没有 Newtonsoft.Json 的所有功能,但它对于新项目来说是一个很好的选择。

最新的 ASP.NET Web API 模板默认使用 System.Text.Json。它提供了一个简单的方式来序列化和反序列化 JSON 数据。以下是一个示例:

var options = new JsonSerializerOptions{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true
};
// Serialize
var json = JsonSerializer.Serialize(post, options);
// Deserialize
var post = JsonSerializer.Deserialize<Post>(json, options);

如果您仍然想使用Newtonsoft.Json,您可以安装Microsoft.AspNetCore.Mvc.NewtonsoftJson NuGet 包,并按照以下方式更新Program.cs文件:

builder.Services.AddControllers()    .AddNewtonsoftJson(options =>
    {
        options.SerializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
        options.SerializerSettings.Formatting = Formatting.Indented;
    });

您可以通过更新options对象来配置Newtonsoft.Json库。再次强调,除非您需要Newtonsoft.Json的一些特定功能,否则建议使用System.Text.Json而不是Newtonsoft.Json,因为System.Text.Json具有更好的性能。

通过实现缓存来优化性能

缓存是一种常用的技术,用于提高应用程序的性能。在 Web API 开发中,缓存可以将频繁访问的数据存储在临时存储中,如内存或磁盘,以减少数据库查询次数并提高应用程序的响应速度。在本节中,我们将介绍 ASP.NET Core web API 开发中的缓存。

缓存是处理不经常更新但计算或从数据库获取成本高昂的数据的有效工具。当多个客户端频繁访问相同的数据时,它也非常有用。例如,考虑一个电子商务应用程序,它显示产品类别列表。产品类别不经常更改,但用户经常查看。为了提高应用程序的性能,我们可以缓存类别。当用户请求类别时,应用程序可以直接返回缓存数据,而无需查询数据库。

在 ASP.NET Core 中,我们有几种实现缓存的方法,每种方法都适用于特定的场景:

  • 内存缓存:这种类型的缓存将数据存储在应用程序的内存中。它快速高效,适用于数据不需要在应用程序的多个实例之间共享的场景。然而,当应用程序重启时,数据将会丢失。

  • 分布式缓存:这种类型的缓存涉及将缓存数据存储在共享存储中,如 Redis 或 SQL Server,可以被应用程序的多个实例访问。它适用于部署了多个实例的应用程序,如 Web 农场、容器编排或无服务器计算。

  • 响应缓存:这种缓存技术基于 HTTP 缓存机制。

在以下章节中,我们将介绍 ASP.NET Core web API 开发中的内存缓存和分布式缓存,以及 ASP.NET Core 7.0 中引入的输出缓存。

内存缓存

内存缓存是将数据存储在应用程序内存中的快速简单方法。ASP.NET Core 提供了IMemoryCache接口来简化此过程。这种类型的缓存非常灵活,因为它可以以键值对的形式存储任何类型的数据。

本节中的示例项目可以在chapter15/CachingDemo文件夹中找到。这是一个简单的 ASP.NET Core web API 应用程序。它包含一个返回产品类别的/categories端点。

为了简化示例,我们使用静态列表来存储类别以模拟数据库。当应用程序查询类别时,它将打印一条日志以指示类别是从数据库中查询的。以下是 CategoryService 类中的代码:

public async Task<IEnumerable<Category>> GetCategoriesAsync(){
    // Simulate a database query
    _logger.LogInformation("Getting categories from the database");
    await Task.Delay(2000);
    return Categories;
}

在前面的代码中,我们使用 Task.Delay() 方法来模拟数据库查询。这个查询需要两秒钟才能完成,速度较慢。由于类别不经常更改,我们可以使用内存缓存来提高应用程序的性能。

要使用内存缓存,我们需要通过运行以下命令添加 Microsoft.Extensions.Caching.Memory NuGet 包:

dotnet add package Microsoft.Extensions.Caching.Memory

然后,我们需要在 Program 类中注册内存缓存:

builder.Services.AddMemoryCache();

接下来,我们可以在其他类中使用 IMemoryCache 接口。将 IMemoryCache 接口注入到 CategoryService 类中:

public class CategoryService(ILogger<CategoryService> logger, IMemoryCache cache)    : ICategoryService
{
    // Omitted for brevity
}

按照以下方式更新 GetCategoriesAsync 方法:

public async Task<IEnumerable<Category>> GetCategoriesAsync(){
    // Try to get the categories from the cache
    if (_cache.TryGetValue(CacheKeys.Categories, out IEnumerable<Category>? categories))
    {
        _logger.LogInformation("Getting categories from cache");
        return categories ?? new List<Category>();
    }
    // Simulate a database query
    _logger.LogInformation("Getting categories from the database");
    await Task.Delay(2000);
    categories = Categories;
    // Cache the categories for 10 minutes
    var cacheEntryOptions = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(TimeSpan.FromMinutes(10));
    _cache.Set(CacheKeys.Categories, categories, cacheEntryOptions);
    return Categories;
}

在更新的代码中,我们首先尝试通过缓存键从缓存中获取类别。如果类别在缓存中找到,我们直接返回它们。否则,我们查询数据库并将类别缓存 10 分钟。使用 SetAbsoluteExpiration() 方法设置缓存条目的绝对过期时间。10 分钟后,缓存条目将从缓存中删除。

运行应用程序并向 /categories 端点发送请求。第一次请求将花费 2 秒钟完成,然后后续请求将立即完成。你可能在控制台看到以下日志:

info: CachingDemo.Services.CategoryService[0]      Getting categories from the database
info: CachingDemo.Services.CategoryService[0]
      Getting categories from cache

以这种方式,内存缓存可以显著提高应用程序的性能。

为了确保缓存不会因过时条目而膨胀,缓存必须应用适当的过期策略。缓存有几种过期选项,其中两个如下:

  • 绝对过期:缓存条目将在指定时间后从缓存中删除。

  • 滑动过期:如果缓存条目在预定时间内未被访问,它将被删除。

当使用 SlidingExpiration 时,如果缓存经常被访问,它可以无限期地保留。为了避免这种情况,我们可以设置 AbsoluteExpiration 属性或 AbsoluteExpirationRelativeToNow 属性以限制缓存条目的最大生存期。以下是一个示例:

var cacheEntryOptions = new MemoryCacheEntryOptions{
    SlidingExpiration = TimeSpan.FromMinutes(10),
    AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
};
cache.Set(CacheKeys.Categories, categories, cacheEntryOptions);

在前面的代码中,将 SlidingExpiration 属性设置为 10 分钟,将 AbsoluteExpirationRelativeToNow 属性设置为 30 分钟。这意味着即使缓存条目经常被访问,30 分钟后缓存条目也将从缓存中删除。

有时我们可能需要手动更新缓存条目。例如,当创建新类别或更新或删除现有类别时,我们可以删除缓存条目以强制应用程序再次查询数据库并刷新缓存条目。将前面的代码移动到一个新方法中:

private async Task RefreshCategoriesCache(){
    // Query the database first
    logger.LogInformation("Getting categories from the database");
    // Simulate a database query
    await Task.Delay(2000);
    var categories = Categories;
    // Then refresh the cache
    cache.Remove(CacheKeys.Categories);
    var cacheEntryOptions = new MemoryCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(10),
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
    };
    cache.Set(CacheKeys.Categories, categories, cacheEntryOptions);
}

注意,在前面的代码中,我们应该先查询数据库,然后删除缓存条目并重置它。否则,如果缓存条目在数据库查询完成之前被删除,应用程序可能会多次查询数据库。

然后,当创建新类别或更新或删除现有类别时,我们可以调用RefreshCategoriesCache()方法。以下是一个示例:

public async Task<Category?> UpdateCategoryAsync(Category category){
    var existingCategory = Categories.FirstOrDefault(c => c.Id == category.Id);
    if (existingCategory == null)
    {
        return null;
    }
    existingCategory.Name = category.Name;
    existingCategory.Description = category.Description;
    await RefreshCategoriesCache();
    return existingCategory;
}

或者,我们可以创建一个后台任务定期更新缓存条目。后台任务是一种在后台运行而不需要用户交互的任务。它适用于执行非时间敏感的任务,例如更新缓存条目。要创建后台任务,我们可以使用BackgroundService类。创建一个名为CategoriesCacheBackgroundService的新类,该类继承自BackgroundService类:

public class CategoriesCacheBackgroundService(    IServiceProvider serviceProvider,
    ILogger<CategoriesCacheBackgroundService> logger,
    IMemoryCache cache)
    : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Remove the cache every 1 hour
        while (!stoppingToken.IsCancellationRequested)
        {
            logger.LogInformation("Updating the cache in background service");
            using var scope = serviceProvider.CreateScope();
            var categoryService = scope.ServiceProvider.GetRequiredService<ICategoryService>();
            var categories = await categoryService.GetCategoriesAsync();
            cache.Remove(CacheKeys.Categories);
            cache.Set(CacheKeys.Categories, categories, TimeSpan.FromHours(1));
            await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
        }
    }
}

在前面的代码中,我们使用while循环每小时重置一次缓存条目。请注意,您不能直接注入ICategoryService,因为BackgroundService类将被注册为单例服务,而ICategoryService被注册为作用域服务。单例服务不能依赖于作用域服务。为了解决这个问题,我们需要使用IServiceProvider接口创建作用域并从作用域中获取ICategoryService

然后,在Program类中注册CacheBackgroundService类:

builder.Services.AddHostedService<CacheBackgroundService>();

当后台任务每小时执行一次时,缓存条目将从缓存中删除。后台任务应首先查询数据库,然后删除缓存条目并重置它。如果首先删除缓存条目,则应用程序可能会多次查询数据库,导致性能问题。

在实现缓存时,考虑无法在数据库中找到记录的场景非常重要。让我们看看这是如何发生的。按照以下方式更新GetCategoryAsync()方法:

public async Task<Category?> GetCategoryAsync(int id){
    if (cache.TryGetValue($"{CacheKeys.Categories}:{id}", out Category? category))
    {
        logger.LogInformation($"Getting category with id {id} from cache");
        return category;
    }
    // Simulate a database query
    logger.LogInformation($"Getting category with id {id} from the database");
    await Task.Delay(2000);
    var result = Categories.FirstOrDefault(c => c.Id == id);
    if (result is not null)
    {
        cache.Set($"{CacheKeys.Categories}:{id}", result);
    }
    return result;
}

在前面的代码中,如果缓存中找不到类别,我们将查询数据库并将类别缓存起来。但这里有一个潜在的问题。如果指定的 ID 没有对应的类别,会发生什么?在这种情况下,应用程序将不会设置缓存,并且每次请求都会查询数据库。缓存根本不会被使用。为了解决这个问题,我们可以使用IMemoryCache接口的GetOrCreateAsync方法。以下是更新后的代码:

public async Task<Category?> GetCategoryAsync(int id){
    var category = await cache.GetOrCreateAsync($"{CacheKeys.Categories}:{id}", async entry =>
    {
        // Simulate a database query
        logger.LogInformation($"Getting category with id {id} from the database");
        await Task.Delay(2000);
        return Categories.FirstOrDefault(c => c.Id == id);
    });
    return category;
}

更新后的代码使用GetOrCreateAsync方法从缓存中检索类别。如果类别不存在,该方法将执行指定的委托从数据库中获取它。成功检索后,类别将被缓存并返回。如果类别未找到,将返回null。因此,应用程序不会每次都查询数据库。为了避免前面提到的问题,建议使用GetOrCreateAsync方法从缓存中获取数据。

在使用内存缓存时,还有更多重要的考虑因素:

  • 考虑缓存条目的过期时间。如果数据不经常更改,我们可以设置较长的过期时间。否则,使用较短的过期时间。此外,您还可以使用 SlidingExpiration 属性和绝对过期时间来在性能和数据新鲜度之间取得平衡。

  • 内存缓存可以缓存任何对象,但在缓存大型对象时要小心。限制缓存条目的大小非常重要。我们可以使用 SetSizeSizeSizeLimit 来限制缓存的大小。请注意,当使用这些方法时,内存缓存必须注册为单例服务。有关更多信息,请参阅learn.microsoft.com/en-us/aspnet/core/performance/caching/memory

  • 定义合适的缓存键。缓存键应该是唯一的且具有描述性。特别是,当使用缓存为用户服务时,确保缓存键对每个用户都是唯一的。否则,一个用户的缓存数据可能会被另一个用户使用。

  • 提供一个当缓存不可用时回退到数据源的方法

这些设置没有硬性规则。您需要考虑特定的场景并相应地调整设置。

内存缓存是提高应用程序性能的一种简单而有效的方法。然而,它不适用于以多个实例部署的应用程序。缓存数据仅适用于当前实例。当客户端从另一个实例请求数据时,原始实例中的缓存数据将不会被使用。为了解决这个问题,一种解决方案是实现会话亲和性,这意味着用户的请求将始终被路由到相同的实例。这可以通过使用支持会话亲和性的负载均衡器,如 Nginx、Azure Application Gateway 等,来实现。这超出了本书的范围。有关更多信息,请参阅负载均衡器的文档。

解决此问题的另一种方法是实现分布式缓存,如下一节所述。

分布式缓存

分布式缓存将缓存从应用程序卸载到共享存储,例如 Redis 或 SQL Server。存储在分布式缓存中的数据可以被应用程序的多个实例访问。如果应用程序重新启动,缓存的数据将不会丢失。使用分布式缓存时,无需实现会话亲和性。

在 ASP.NET Core 中实现分布式缓存有几种选择。以下是最常用的选项:

  • Redis: Redis 是一个开源的内存数据结构存储。它具有许多功能,如缓存、发布/订阅等。

  • SQL Server: SQL Server 也可以用作分布式缓存。

  • Azure Cache for Redis:Azure Cache for Redis 是一个完全托管、开源的内存数据结构存储。它基于流行的开源 Redis 缓存。您可以使用本地 Redis 服务器进行开发和测试,并在生产中使用 Azure Cache for Redis。

  • https://github.com/Alachisoft/NCache

在本节中,我们将使用与上一节相同的示例项目介绍 Redis 缓存。我们使用静态的 Dictionary<int, List<Category>> 来存储用户的收藏类别,这模拟了数据库中存储的数据。当用户请求收藏类别时,应用程序将使用用户 ID 作为键来查询数据库。如果我们使用内存缓存,缓存键应包括用户 ID,例如 1_Favorites_Categories。然而,如果此用户的后续请求被路由到另一个实例,将无法获取缓存数据。这就是为什么我们需要使用分布式缓存。

首先,我们需要准备一个 Redis 服务器。我们可以使用 Docker 来运行 Redis 服务器。在您的机器上启动 Docker Desktop 并运行以下命令以拉取 Redis 镜像:

docker pull redis

然后,运行 Redis 服务器:

docker run --name redis -p 6379:6379 -d redis

Redis 服务器将在端口 6379 上监听。

要在终端中访问 Redis 服务器,我们需要使用 redis-cli 命令。此命令包含在 Redis 镜像中。运行以下命令以访问 Redis 服务器:

docker exec -it redis redis-cli

使用 docker exec 命令在运行中的容器中执行命令。-it 选项用于以交互式方式运行命令。这意味着我们想在容器中执行 redis-cli 命令。您将看到以下输出:

127.0.0.1:6379>

这意味着我们已经成功访问了 Redis 服务器。现在我们可以使用 redis-cli 命令来访问 Redis 服务器。例如,我们可以使用 set 命令来设置键的值:

set my-key "Hello World"

然后,我们可以使用 get 命令来获取键的值:

get my-key

您将在输出中看到 Hello World

现在 Redis 服务器已准备好使用。要在 ASP.NET Core 中使用 Redis 缓存,我们需要安装 Microsoft.Extensions.Caching.StackExchangeRedis NuGet 包:

dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

然后,我们需要在 Program 类中注册 Redis 缓存:

builder.Services.AddStackExchangeRedisCache(options =>    options.Configuration = "localhost:6379";    options.InstanceName = "CachingDemo";
});

在前面的代码中,使用了 AddStackExchangeRedisCache 扩展方法来注册 Redis 缓存。我们指定了 Redis 服务器地址和可选的实例名称,该名称用于为缓存创建一个逻辑分区。请注意,这些配置可以在 appsettings.json 文件或环境变量中定义,允许使用不同的 Redis 实例进行开发和生产。

接下来,我们可以使用 IDistributedCache 接口来操作 Redis 缓存。将 IDistributedCache 接口注入到 CategoryService 类中:

public class CategoryService(ILogger<CategoryService> logger, IMemoryCache cache, IDistributedCache distributedCache) : ICategoryService{
    // Omitted for brevity
}
// Update the GetFavoritesCategoriesAsync() method as follows:
public async Task<IEnumerable<Category>> GetFavoritesCategoriesAsync(int userId)
{
    // Try to get the categories from the cache
    var cacheKey = $"{CacheKeys.FavoritesCategories}:{userId}";
    var bytes = await distributedCache.GetAsync(cacheKey);
    if (bytes is { Length: > 0 })
    {
        logger.LogInformation("Getting favorites categories from distributed cache");
        var serializedFavoritesCategories = Encoding.UTF8.GetString(bytes);
        var favoritesCategories = JsonSerializer.Deserialize<IEnumerable<Category>>(serializedFavoritesCategories);
        return favoritesCategories ?? new List<Category>();
    }
    // Simulate a database query
    logger.LogInformation("Getting favorites categories from the database");
    var categories = FavoritesCategories[userId];
    // Store the result in the distributed cache
    var cacheEntryOptions = new DistributedCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(10),
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
    };
    var serializedCategories = JsonSerializer.Serialize(categories);
    var serializedCategoriesBytes = Encoding.UTF8.GetBytes(serializedCategories);
    await distributedCache.SetAsync(cacheKey, serializedCategoriesBytes, cacheEntryOptions);
    await Task.Delay(2000);
    return FavoritesCategories[userId].AsEnumerable();
}

在前面的代码中,我们首先尝试使用缓存键从缓存中获取收藏的分类。如果收藏的分类在分布式缓存中找到,我们直接返回缓存的数据。否则,我们查询数据库并将结果存储在分布式缓存中。

由于 Redis 缓存将数据存储为 byte[],为了存储缓存数据,我们需要将数据序列化为 JSON 字符串,然后使用 Encoding.UTF8.GetBytes() 方法将 JSON 字符串转换为 byte[] 值。同样,在获取缓存数据时,我们需要使用 Encoding.UTF8.GetString() 方法将 byte[] 值转换为 JSON 字符串,然后使用 JsonSerializer.Deserialize() 方法将 JSON 字符串反序列化为强类型对象。

此外,缓存键必须是一个 string 值。

为了使数据转换为 byte[] 和从 byte[] 转换更容易,IDistributedCache 接口有几个扩展方法,如下所示:

  • SetStringAsyncSetString:这两个方法可以直接保存 string

  • GetStringAsyncGetString:这两个方法可以直接读取 string

要删除缓存条目,我们可以使用 RemoveAsync() 方法或 Remove() 方法。正如我们之前提到的,使用这些方法的异步版本是首选的。

运行应用程序并向 Categories/favorites/1 端点发送一些请求。您将看到日志显示第一次响应来自数据库,后续的响应来自分布式缓存:

info: CachingDemo.Services.CategoryService[0]      Getting favorites categories from the database
info: CachingDemo.Services.CategoryService[0]
      Getting favorites categories from distributed cache

您可以使用 redis-cli 来检查缓存数据。运行以下命令以获取键:

127.0.0.1:6379> keys *

输出应如下所示:

1) "CachingDemo_FavoritesCategories:1"

然后,使用 HGETALL 命令来显示缓存数据:

127.0.0.1:6379> hgetall CachingDemo_FavoritesCategories:1

注意,您不能在这里使用 GET 命令,因为它仅用于检索字符串值。分类数据在 Redis 中存储为 hash,因此我们需要使用 HGETALL 命令。

输出应如下所示,包括缓存条目的所有字段:

1) "absexp"2) "638322378838137428"
3) "sldexp"
4) "6000000000"
5) "data"
6) " [{\"Id\":1,\"Name\":\"Toys\",\"Description\":\"Soft toys, action figures, dolls, and puzzles\"},{\"Id\":2,\"Name\":\"Electronics\",\"Description\":\"Smartphones, tablets, laptops, and smartwatches\"},{\"Id\":3,\"Name\":\"Clothing\",\"Description\":\"Shirts, pants, dresses, and shoes\"}]"

使用分布式缓存可以通过允许缓存数据在多个实例之间共享来帮助使应用程序更具可伸缩性。然而,这也可能带来由于额外的网络 I/O 所需的潜在成本增加的延迟。在决定是否使用分布式缓存时,应仔细考虑。

IDistributedCache 接口没有 GetOrCreateAsync() 方法。如果缓存数据未找到,应用程序仍然需要查询数据库。为了解决这个问题,我们可以实现自己的 GetOrCreateAsync() 方法。为 IDistributedCache 接口创建一个扩展方法:

public static class DistributedCacheExtension{
    public static async Task<T?> GetOrCreateAsync<T>(this IDistributedCache cache, string key, Func<Task<T?>> createAsync, DistributedCacheEntryOptions? options = null)
    {
        // Get the value from the cache.
        // If the value is found, return it.
        var value = await cache.GetStringAsync(key);
        if (!string.IsNullOrWhiteSpace(value))
        {
            return JsonSerializer.Deserialize<T>(value);
        }
        // If the value is not cached, then create it using the provided function.
        var result = await createAsync();
        var json = JsonSerializer.Serialize(result);
        await cache.SetStringAsync(key, json, options ?? new DistributedCacheEntryOptions());
        return result;
    }
}

现在,可以将 GetFavoritesCategoriesAsync 方法更新如下:

public async Task<IEnumerable<Category>?> GetFavoritesCategoriesAsync(int userId){
    var cacheKey = $"{CacheKeys.FavoritesCategories}:{userId}";
    var cacheEntryOptions = new DistributedCacheEntryOptions
    {
        SlidingExpiration = TimeSpan.FromMinutes(10),
        AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
    };
    var favoritesCategories = await distributedCache.GetOrCreateAsync(cacheKey, async () =>
    {
        // Simulate a database query
        logger.LogInformation("Getting favorites categories from the database");
        var categories = FavoritesCategories[userId];
        await Task.Delay(2000);
        return categories;
    }, cacheEntryOptions);
    return favoritesCategories?.AsEnumerable();
}

如果数据库中没有找到该分类,GetOrCreateAsync() 方法将返回 null 并为未来的请求缓存 null 值。这样,应用程序将不再反复查询数据库。

以下表格显示了内存缓存和分布式缓存的区别:

内存缓存 分布式缓存
在应用程序的内存中缓存数据 在共享存储中缓存数据
适用于以单个实例部署的应用程序 适用于以多个实例部署的应用程序
应用程序重启时缓存的数据会丢失 应用程序重启时缓存的数据不会丢失
缓存键可以是任何 object 缓存键必须是 string
缓存的数据值可以是任何强类型对象 缓存的数据以 byte[] 的形式持久化,可能需要序列化和反序列化。

表 15.2 – 内存缓存和分布式缓存的区别

如果你想使用其他分布式缓存,你可以安装以下包等:

  • dotnet add package Microsoft.Extensions.Caching.SqlServer

  • dotnet add package NCache.Microsoft.Extensions.Caching.OpenSource

请参阅它们的官方文档以获取更多详细信息。

响应缓存

响应缓存定义在 RFC 9111 规范中 (www.rfc-editor.org/rfc/rfc9111)。它使用 HTTP 头部 cache-control 来指定缓存行为。客户端(如浏览器)和直接代理(如 CDN 和网关)可以使用 cache-control 头部来确定是否缓存响应以及缓存多长时间。

cache-control 头部有几个指令,如下所示:

  • public: 响应可以被客户端和中间代理缓存。

  • private: 响应只能被客户端缓存。共享缓存(如 CDN)不得缓存响应。

  • no-cache: 对于请求,客户端必须在使用缓存的响应副本之前将请求发送到服务器进行验证。对于响应,客户端必须在服务器上成功验证后才能使用缓存的响应副本。

  • no-store: 对于请求,客户端必须不存储请求的任何部分。对于响应,客户端必须不存储响应的任何部分。

  • max-age: 这是指响应的最大年龄,以秒为单位。如果响应未过期,客户端可以使用缓存的副本。例如,max-age=3600 表示响应可以被缓存一小时。

我们可以使用 ResponseCache 属性来指定端点的缓存行为。以下是一个示例:

[HttpGet][ResponseCache(Duration = 60)]
public async Task<ActionResult<IEnumerable<Category>>> Get()
{
    var result = await categoryService.GetCategoriesAsync();
    return Ok(result);
}

在前面的代码中,我们使用 ResponseCache 属性在控制器上指定端点的缓存行为。Duration = 60 表示响应可以被缓存 60 秒。

运行应用程序并在 Swagger UI 中测试 /Categories 端点。你将看到响应中的 cache-control 头部,如下所示:

cache-control: public,max-age=60content-type: application/json; charset=utf-8
date: Sat,07 Oct 2023 03:56:06 GMT
server: Kestrel

如果您重新提交请求,浏览器将使用缓存的响应版本,而无需将请求发送到服务器。这由 cache-control 标头中的 max-age 指令管理。如果请求在 60 秒后重新提交,浏览器将向服务器发送请求以进行验证。

基于 HTTP 的响应缓存在客户端生效。如果多个客户端向同一端点发送请求,每个请求都会导致服务器处理请求并生成响应。ASP.NET Core 提供了一个服务器端响应缓存中间件,用于在服务器端缓存响应。然而,此中间件有一些限制。

  • 它仅支持 GETHEAD 请求,并且不支持包含 AuthorizationSet-Cookie 标头等内容的请求。

  • 当数据发生变化时,您无法在服务器端使客户端缓存的响应失效。

  • 此外,大多数浏览器,如 Chrome 和 Edge,会自动发送带有 cache-control: max-age=0 标头的请求,这禁用了客户端的响应缓存。因此,服务器也将尊重此标头并禁用服务器端响应缓存。

本书没有涵盖提到的中间件;有关更多信息,请参阅learn.microsoft.com/en-us/aspnet/core/performance/caching/middleware上的文档。然而,我们将介绍输出缓存,这是 ASP.NET Core 7.0 及更高版本中可用的。此中间件解决了服务器端响应缓存中间件的一些限制。

输出缓存

在 ASP.NET Core 7.0 中,Microsoft 引入了输出缓存中间件。此中间件的工作方式与服务器端响应缓存中间件类似,但它有一些优点:

  • 它配置了服务器端的缓存行为,因此客户端 HTTP 缓存配置不会影响输出缓存配置。

  • 当数据发生变化时,它具有在服务器端使缓存的响应失效的能力。

  • 它可以使用外部缓存存储,例如 Redis,来存储缓存的响应。

  • 当缓存的响应未修改时,它可以向客户端返回 304 Not Modified 响应。这可以节省网络带宽。

然而,输出缓存中间件也具有与响应缓存中间件类似的限制:

  • 它仅支持带有 200 OK 状态码的 GETHEAD 请求

  • 它不支持 AuthorizationSet-Cookie 标头

要启用输出缓存,我们需要在 Program 类中注册输出缓存中间件:

builder.Services.AddOutputCache(options =>{
    options.AddBasePolicy(x => x.Cache());
});

然后,我们需要将中间件添加到 HTTP 请求管道中:

app.UseOutputCache();

接下来,将 OutputCache 属性应用于需要缓存的端点。例如,我们可以将 OutputCache 属性应用于 /categories/{id} 端点:

[HttpGet("{id}")][OutputCache]
public async Task<ActionResult<Category?>> Get(int id)
{
    var result = await categoryService.GetCategoryAsync(id);
    if (result is null)
    {
        return NotFound();
    }
    return Ok(result);
}

GetOrCreateAsync() 方法如下所示:

public async Task<Category?> GetCategoryAsync(int id){
    // Simulate a database query
    logger.LogInformation($"Getting category with id {id} from the database");
    await Task.Delay(2000);
    return Categories.FirstOrDefault(c => c.Id == id);
}

同样,我们使用Task.Delay()方法来模拟数据库查询。运行应用程序并在 Swagger UI 中测试/categories/1端点。你会看到控制台日志显示第一个响应来自数据库。响应的头部如下所示:

content-type: application/json; charset=utf-8date: Sat,07 Oct 2023 06:43:02 GMT
server: Kestrel

再次发送请求。你将不会在控制台看到数据库查询日志。响应的头部如下所示:

age: 5content-length: 87
content-type: application/json; charset=utf-8
date: Sat,07 Oct 2023 06:44:39 GMT
server: Kestrel

你会发现响应的头部包含age头部,这表明响应已被缓存。age头部是自响应生成以来的秒数。

默认情况下,缓存的响应过期时间为 60 秒。60 秒后,下一个请求将再次查询数据库。

我们可以为不同的端点定义不同的缓存策略。按照以下方式更新AddOutputCache()方法:

builder.Services.AddOutputCache(options =>{
    options.AddBasePolicy(x => x.Cache());
    options.AddPolicy("Expire600", x => x.Expire(TimeSpan.FromSeconds(600)));
    options.AddPolicy("Expire3600", x => x.Expire(TimeSpan.FromSeconds(3600)));
});

在前面的代码中,我们添加了两种缓存策略。第一个Expire600策略将在 10 分钟后使缓存的响应过期,第二个策略将在 1 小时后使缓存的响应过期。然后,我们可以将OutputCache属性应用到端点上,如下所示:

[HttpGet("{id}")][OutputCache(PolicyName = "Expire600")]
public async Task<ActionResult<Category?>> Get(int id)
{
    // Omitted for brevity
}

现在,缓存的响应将在 10 分钟后过期。

我应该使用哪种缓存策略?

缓存是提高应用程序性能的有用工具。在本节中,我们介绍了几种缓存技术,包括内存缓存、分布式缓存、响应缓存和输出缓存。每种缓存技术都有其适用的场景。我们需要根据具体场景选择合适的缓存技术。

响应缓存相对容易实现;然而,它依赖于客户端的 HTTP 缓存配置。如果客户端的 HTTP 缓存被禁用,响应缓存将无法按预期工作。输出缓存更灵活,可以独立于客户端的 HTTP 缓存配置使用。它不需要太多努力来实现,但有一些限制。

内存缓存是在应用程序的单个实例中缓存数据的一种快速且简单的方式。然而,如果有多个应用程序实例,它需要会话亲和性才能正常工作。分布式缓存支持多个实例,但需要额外的网络 I/O 来访问缓存。因此,我们需要在性能和可扩展性之间权衡。如果从数据库检索数据复杂或需要昂贵的计算,并且数据不经常更改,我们可以使用分布式缓存来减少数据库或计算的负载。此外,我们可以将内存缓存和分布式缓存结合使用,以利用两种缓存技术的优势。例如,我们首先从内存缓存中查询数据,如果找不到数据,然后查询分布式缓存。还要考虑缓存条目的过期时间。你可能需要对不同的数据使用不同的过期策略。

本节仅介绍了 ASP.NET Core 中缓存的基本概念。要了解更多关于缓存的信息,请参阅docs.microsoft.com/en-us/aspnet/core/performance/caching/上的文档。

使用 HttpClientFactory 管理 HttpClient 实例

.NET 提供了HttpClient类来发送 HTTP 请求。然而,在使用它时可能会有些困惑。在过去,许多开发者会误用using语句来创建HttpClient实例,因为它实现了IDisposal接口。这是不建议的,因为HttpClient类旨在用于多个请求的重用。为每个请求创建一个新的实例可能会耗尽本地套接字端口。

为了解决这个问题,Microsoft 在 ASP.NET Core 2.1 中引入了IHttpClientFactory接口。此接口简化了HttpClient实例的管理。它允许我们使用依赖注入将HttpClient实例注入到应用程序中,而无需担心HttpClient实例的生命周期。在本节中,我们将介绍如何使用IHttpClientFactory接口来管理HttpClient实例。

您可以在samples/chapter15/HttpClientDemo文件夹中找到本节的示例应用程序。

为了演示如何使用IHttpClientFactory接口,我们需要有一个作为后端服务的 Web API 应用程序。您可以使用我们在前几章中创建的任何示例应用程序。在本节中,我们将使用一个模拟 API 服务:https://jsonplaceholder.typicode.com/。这是一个免费的在线 REST API 服务,可用于测试和原型设计。它提供了一组端点,例如/posts/comments/albums/photos/todos/users

小贴士

当您从 JSON 数据创建 C#模型时,您可以使用 Visual Studio 中的粘贴 JSON 为类功能。您可以在编辑 | 粘贴 特殊菜单中找到此功能。

创建基本的 HttpClient 实例

IHttpClientFactory接口提供了一个AddHttpClient()扩展方法来注册HttpClient实例。在Program.cs文件中添加以下代码:

builder.Services.AddHttpClient();

然后,我们可以将IHttpClientFactory接口注入到控制器中,并使用它来创建HttpClient实例:

[ApiController][Route("[controller]")]
public class PostsController(IHttpClientFactory httpClientFactory) : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> Get()
    {
        var httpClient = httpClientFactory.CreateClient();
        var httpRequestMessage = new HttpRequestMessage
        {
            Method = HttpMethod.Get,
            RequestUri = new Uri("https://jsonplaceholder.typicode.com/posts")
        };
        var response = await httpClient.SendAsync(httpRequestMessage);
        response.EnsureSuccessStatusCode();
        var content = await response.Content.ReadAsStringAsync();
        var posts = JsonSerializerHelper.DeserializeWithCamelCase<List<Post>>(content);
        return Ok(posts);
    }
    // Omitted for brevity
}

在前面的代码中,我们使用CreateClient()方法创建一个HttpClient实例。然后,我们创建一个HttpRequestMessage实例,并使用SendAsync()方法发送 HTTP 请求。EnsureSuccessStatusCode()方法用于确保响应成功。如果响应失败,将抛出异常。ReadAsStringAsync()方法用于将响应内容读取为字符串。最后,我们使用JsonSerializerHelper类将 JSON 字符串反序列化为Post对象列表。

JsonSerializerHelper类定义如下:

public static class JsonSerializerHelper{
    public static string SerializeWithCamelCase<T>(T value)
    {
        var options = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
        };
        return JsonSerializer.Serialize(value, options);
    }
    public static T? DeserializeWithCamelCase<T>(string json)
    {
        var options = new JsonSerializerOptions
        {
            PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
            DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
        };
        return JsonSerializer.Deserialize<T>(json, options);
    }
}

这是因为 API 返回的 JSON 数据使用的是驼峰命名法。我们需要使用 JsonNamingPolicy.CamelCase 属性将 JSON 字符串反序列化为强类型对象。我们可以将 JsonSerializerOptions 实例传递给 JsonSerializer.Serialize()JsonSerializer.Deserialize() 方法来指定序列化和反序列化选项。使用辅助方法可以简化代码。

HttpRequestMessage 类是一个表示 HTTP 请求消息的低级类。在大多数情况下,我们可以使用 GetStringAsync() 方法发送 GET 请求,并将响应内容作为字符串获取,如下所示:

var content = await httpClient.GetStringAsync("https://jsonplaceholder.typicode.com/posts");var posts = JsonSerializerHelper.DeserializeWithCamelCase<List<Post>>(content);
return Ok(posts);

发送 POST 请求的代码类似:

[HttpPost]public async Task<IActionResult> Post(Post post)
{
    var httpClient = httpClientFactory.CreateClient();
    var json = JsonSerializer.Serialize(post);
    var data = new StringContent(json, Encoding.UTF8, "application/json");
    var response = await httpClient.PostAsync("https://jsonplaceholder.typicode.com/posts", data);
    var content = await response.Content.ReadAsStringAsync();
    var newPost = JsonSerializer.Deserialize<Post>(content);
    return Ok(newPost);
}

要发送 POST 请求,我们需要将 Post 对象序列化为 JSON 字符串,然后将 JSON 字符串转换为 StringContent 实例。然后,我们可以使用 PostAsync() 方法发送请求。

StringContent 类是 HttpContent 类的一个具体实现。HttpContent 类是一个抽象类,表示 HTTP 消息的内容。它有以下具体实现:

  • ByteArrayContent: 表示基于字节数组的 HttpContent 实例

  • FormUrlEncodedContent: 表示使用 application/x-www-form-urlencoded MIME 类型编码的名称/值对集合

  • MultipartContent: 表示使用 multipart/* MIME 类型序列化的 HttpContent 实例集合

  • StreamContent: 表示基于流的 HttpContent 实例

  • StringContent: 表示基于字符串的 HttpContent 实例

HttpClient 类有几个方法和扩展方法来发送 HTTP 请求。以下表格显示了常用的方法:

方法名称 描述
SendAsync() 向指定的 URI 发送 HTTP 请求。此方法可以发送任何 HTTP 请求。
GetAsync() 向指定的 URI 发送 GET 请求。
GetStringAsync() 向指定的 URI 发送 GET 请求。此方法将响应体作为字符串返回。
GetByteArrayAsync() 向指定的 URI 发送 GET 请求。此方法将响应体作为字节数组返回。
GetStreamAsync() 向指定的 URI 发送 GET 请求。此方法将响应体作为流返回。
GetFromJsonAsync<T>() 向指定的 URI 发送 GET 请求。此方法将响应体作为强类型对象返回。
GetFromJsonAsAsyncEnumerable<T>() 向指定的 URI 发送 GET 请求。此方法将响应体作为 IAsyncEnumerable<T> 实例返回。
PostAsync() 向指定的 URI 发送 POST 请求。
PostAsJsonAsync() 向指定的 URI 发送 POST 请求。请求体被序列化为 JSON。
PutAsync() 向指定的 URI 发送 PUT 请求。
PutAsJsonAsync() 向指定的 URI 发送 PUT 请求。请求体被序列化为 JSON。
DeleteAsync() 向指定的 URI 发送DELETE请求。
DeleteFromJsonAsync<T>() 向指定的 URI 发送DELETE请求。此方法将响应体作为强类型对象返回。
PatchAsync() 向指定的 URI 发送PATCH请求。

表 15.3 – HttpClient 类常用的方法

当我们使用由IHttpClientFactory接口创建的HttpClient实例时,我们需要指定请求 URL。我们可以在注册HttpClient实例时设置HttpClient实例的基本地址。更新Program.cs文件中的AddHttpClient()方法:

builder.Services.AddHttpClient(client =>{
    client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
    // You can set more options like the default request headers, timeout, and so on.
});

然后,在发送 HTTP 请求时,我们不需要指定基本地址。但是,如果我们需要向具有不同基本地址的多个端点发送请求怎么办?让我们在下一节中看看如何解决这个问题。

命名的 HttpClient 实例

每次指定HttpClient实例的基本地址或请求 URI 都是一件繁琐的事情。我们可以在注册HttpClient实例时指定一些通用设置。例如,我们可以如下指定HttpClient实例的基本地址:

builder.Services.AddHttpClient("JsonPlaceholder", client =>{
    client.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
    // You can set more options like the default request headers, timeout, etc.
    client.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json");
    client.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "HttpClientDemo");
});

在前面的代码中,我们使用名称JsonPlaceholder注册了HttpClient实例并指定了HttpClient实例的基本地址。我们还可以设置默认请求头,例如AcceptUser-Agent头。然后,我们可以使用JsonPlaceholder名称将HttpClient实例注入到控制器中:

var httpClient = httpClientFactory.CreateClient("JsonPlaceholder");

这被称为命名HttpClient实例,它允许我们使用不同的名称注册多个HttpClient实例。当我们需要具有不同配置的多个HttpClient实例时,这非常有用。通过使用名称,我们可以轻松访问所需的实例。

类型化的 HttpClient 实例

为了更好地封装HttpClient实例,我们可以为特定类型创建一个类型化的HttpClient实例。例如,我们可以为User类型创建一个类型化的HttpClient实例:

public class UserService{
    private readonly HttpClient _httpClient;
    private readonly JsonSerializerOptions _jsonSerializerOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DictionaryKeyPolicy = JsonNamingPolicy.CamelCase
    };
    public UserService(HttpClient httpClient)
    {
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri("https://jsonplaceholder.typicode.com/");
        _httpClient.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json");
        _httpClient.DefaultRequestHeaders.Add(HeaderNames.UserAgent, "HttpClientDemo");
    }
    public Task<List<User>?> GetUsers()
    {
        return _httpClient.GetFromJsonAsync<List<User>>("users", _jsonSerializerOptions);
    }
    public async Task<User?> GetUser(int id)
    {
        return await _httpClient.GetFromJsonAsync<User>($"users/{id}", _jsonSerializerOptions);
    }
    // Omitted for brevity
}

在前面的代码中,我们创建了一个UserService类来封装HttpClient实例。在Program类中注册UserService类:

builder.Services.AddHttpClient<UserService>();

然后,我们可以将UserService类注入到控制器中:

[ApiController][Route("[controller]")]
public class UsersController(UserService usersService) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<User>>> Get()
    {
        var users = await usersService.GetUsers();
        return Ok(users);
    }
    [HttpGet("{id}")]
    public async Task<ActionResult<User>> Get(int id)
    {
        var user = await usersService.GetUser(id);
        if (user == null)
        {
            return NotFound();
        }
        return Ok(user);
    }
    // Omitted for brevity
}

在前面的代码中,控制器不需要知道HttpClient实例的详细信息。它只需要调用UserService类的方 法。代码更加简洁。

IHttpClientFactory接口是管理HttpClient实例的推荐方式。它使我们免于管理HttpClient实例生命周期的繁琐工作。它还允许我们在集中位置配置HttpClient实例。有关更多信息,请参阅learn.microsoft.com/en-us/aspnet/core/fundamentals/http-requests的文档。

摘要

在本章中,我们讨论了 ASP.NET Core Web API 开发中的常见实践,例如 HTTP 状态码、异步编程、分页、响应类型和 API 文档。我们还探讨了多种缓存技术,包括内存缓存、分布式缓存、响应缓存和输出缓存。每种技术都有其自身的优缺点,因此考虑权衡并选择适合特定场景的适当缓存策略非常重要。此外,我们还讨论了 IHttpClientFactory 接口,该接口简化了 HttpClient 实例的管理,并允许我们使用依赖注入将 HttpClient 实例注入到应用程序中,而无需担心其生命周期。

在下一章中,我们将讨论如何在 ASP.NET Core Web API 应用程序中处理错误,以及如何使用 OpenTelemetry 监控应用程序。

第十六章:错误处理、监控和可观察性

第四章中,我们介绍了如何在 ASP.NET Core Web API 应用程序中使用日志记录。日志记录是应用程序开发的关键部分,它帮助开发者了解应用程序中发生的事情。然而,日志记录是不够的——我们需要更多的工具来监控和观察应用程序的运行情况。在本章中,我们将探讨以下主题:

  • 错误处理

  • 健康检查

  • 监控和可观察性

读完本章后,你将能够理解如何监控 ASP.NET Core Web API 应用程序。你将获得关于可观察性和OpenTelemetry的知识,以及如何使用一些工具,如 Prometheus 和 Grafana,来监控应用程序。

技术要求

本章的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter16找到。你可以使用 VS 2022 或 VS Code 打开解决方案。

错误处理

当 ASP.NET Core Web API 应用程序中发生异常时,应用程序将抛出异常。如果这个异常没有被处理,应用程序将崩溃并导致 500 错误。响应体将包含异常的堆栈跟踪。在开发期间向客户端显示堆栈跟踪是可以接受的。然而,我们永远不应该在生产环境中向客户端暴露堆栈跟踪。堆栈跟踪包含有关应用程序的敏感信息,攻击者可以利用这些信息攻击应用程序。

处理异常

让我们来看一个例子。MyWebApiDemo示例应用程序有一个名为UsersController的控制器,该控制器有一个根据用户 ID 获取用户的操作。这个操作如下所示:

[HttpGet("{id:int}")]public ActionResult<User> Get(int id)
{
    var user = Users.First(u => u.Id == id);
    if (user == null)
    {
        return NotFound();
    }
    return Ok(user);
}

在这种情况下不建议使用First,因为如果集合中没有找到用户,它将抛出一个异常。为了说明如何在应用程序中处理异常,我们将使用这个例子。

运行应用程序并向https://localhost:5001/users/100端点发送一个GET请求。你可以在 Swagger UI 中直接测试它。由于找不到 ID 为 100 的用户,应用程序将返回一个 500 错误。响应体将如下所示:

图 16.1 – 响应体包含堆栈跟踪

图 16.1 – 响应体包含堆栈跟踪

无论应用程序是在开发环境中运行,还是生产环境中运行,响应体都包含堆栈跟踪。我们永远不应该在生产环境中显示堆栈跟踪。此外,响应体不是一个有效的 JSON 有效负载,这使得客户端难以解析它。

ASP.NET Core 提供了一个内置的异常处理中间件来处理异常并返回错误负载。异常处理中间件可以向客户端返回有效的 JSON 负载。这种错误和异常的 JSON 负载称为 问题详情,并在 RFC7807 中定义:datatracker.ietf.org/doc/html/rfc7807

问题详情对象可以有以下属性:

  • type: 用于标识问题类型的 URI 引用。此引用以人类可读格式提供有用的文档,可以帮助客户端理解错误。

  • title: 以人类可读格式描述问题类型的摘要。

  • status: 原始服务器生成的 HTTP 状态码,用于指示问题的状态。

  • detail: 对问题的可读描述。

  • instance: 提供问题特定发生的 URI 引用,允许更精确地理解问题。

客户端可以解析问题详情对象,并显示友好的错误信息。此对象可以扩展以包含有关错误的附加信息,尽管现有属性对于大多数情况应该是足够的。

要使用异常处理中间件,我们需要创建一个控制器来显示问题详情。创建一个名为 ErrorController 的新控制器,并添加以下代码:

[ApiController][ApiExplorerSettings(IgnoreApi = true)]
public class ErrorController(ILogger<ErrorController> logger) : ControllerBase
{
    [Route("/error-development")]
    public IActionResult HandleErrorDevelopment(
        [FromServices] IHostEnvironment hostEnvironment)
    {
        if (!hostEnvironment.IsDevelopment())
        {
            return NotFound();
        }
        var exceptionHandlerFeature =
            HttpContext.Features.Get<IExceptionHandlerFeature>()!;
        logger.LogError(exceptionHandlerFeature.Error, exceptionHandlerFeature.Error.Message);
        return Problem(
            detail: exceptionHandlerFeature.Error.StackTrace,
            title: exceptionHandlerFeature.Error.Message);
    }
    [Route("/error")]
    public IActionResult HandleError()
    {
        var exceptionHandlerFeature =
            HttpContext.Features.Get<IExceptionHandlerFeature>()!;
        logger.LogError(exceptionHandlerFeature.Error, exceptionHandlerFeature.Error.Message);
        return Problem();
    }
}

上述代码来自微软的官方文档:learn.microsoft.com/en-us/aspnet/core/web-api/handle-errors

ErrorController 类中有几点需要注意:

  • 控制器被标记为 [ApiExplorerSettings(IgnoreApi = true)] 属性。此属性用于从 OpenAPI 规范和 Swagger UI 中隐藏此端点。

  • 控制器有两个操作。第一个操作用于在开发环境中显示详细的错误信息,因此它提供了显示异常堆栈跟踪的 /error-development 路由。第二个操作用于在生产环境中显示通用的错误信息,因此它提供了没有关于异常的额外信息的 /error 路由。

  • 在操作中,我们使用 IExceptionHandlerFeature 接口来获取异常信息。IExceptionHandlerFeature 接口是一个包含要由异常处理器检查的原请求异常的功能。我们可以记录异常信息或将它返回给客户端。

接下来,我们需要在应用程序中注册异常处理中间件。打开 Program.cs 文件并调用 UseExceptionHandler 方法来添加异常处理中间件:

if (app.Environment.IsDevelopment()){
    app.UseSwagger();
    app.UseSwaggerUI();
    app.UseExceptionHandler("/error-development");
}
else
{
    app.UseExceptionHandler("/error");
}

对于开发环境,我们可以使用 /error-development端点来显示详细的错误消息。对于生产环境,我们可以使用/error 端点来显示通用错误消息。在生产环境中隐藏堆栈跟踪是一个好的做法。

运行应用程序并向 https://localhost:5001/users/100 端点发送一个 GET 请求。应用程序将返回一个 500 错误。响应体将如下所示:

图 16.2 – 响应体包含开发环境中的问题详情和堆栈跟踪

图 16.2 – 响应体包含开发环境中的问题详情和堆栈跟踪

响应体现在包含一个问题详情 JSON 负载。它还包含开发环境中用于故障排除的异常堆栈跟踪。客户端可以解析响应体并显示一个用户友好的错误消息。同时,响应头包含一个值为 application/problem+jsonContent-Type 头。这表明响应体是一个问题详情 JSON 负载。

如果你在生产环境中运行应用程序,响应体将不会包含异常的堆栈跟踪。响应体将如下所示:

图 16.3 – 生产环境中的响应体包含一个通用错误消息

图 16.3 – 生产环境中的响应体包含一个通用错误消息

默认问题详情对象可以被扩展以包含有关错误的附加信息。我们将在下一节中讨论如何自定义问题详情。

模型验证

当客户端向应用程序发送请求时,应用程序需要验证该请求。例如,当用户更新其个人资料时,电子邮件 属性必须是一个有效的电子邮件地址。如果 电子邮件 属性的值无效,应用程序应返回一个包含验证错误消息的问题详情对象的 HTTP 400 响应。

ASP.NET Core 提供了一个内置的模型验证功能来验证请求模型。此功能通过使用在 System.ComponentModel.DataAnnotations 命名空间中定义的验证属性来启用。以下表格概述了一些可用的验证属性:

属性名称 描述
必需 指定数据字段是必需的
范围 指定一个数值字段必须在指定的范围内
字符串长度 指定字符串字段的最低和最高长度
电子邮件地址 指定数据字段必须是一个有效的电子邮件地址
正则表达式 指定数据字段必须匹配指定的正则表达式
URL 指定数据字段必须是一个有效的 URL

表 16.1 – 常见模型验证属性

我们可以如下应用这些验证属性:

public class User{
    public int Id { get; set; }
    [Required]
    [StringLength(50, MinimumLength = 3, ErrorMessage = "The length of FirstName must be between 3 and 50.")]
    public string FirstName { get; set; } = string.Empty;
    [Required]
    [StringLength(50, MinimumLength = 3, ErrorMessage = "The length of LastName must be between 3 and 50.")]
    public string LastName { get; set; } = string.Empty;
    [Required]
    [Range(1, 120, ErrorMessage = "The value of Age must be between 1 and 120.")]
    public int Age { get; set; }
    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;
    [Required]
    [Phone]
    public string PhoneNumber { get; set; } = string.Empty;
}

运行应用程序并向/users端点发送一个无效请求体POST请求,如下所示:

{  "firstName": "ab",
  "lastName": "xy",
  "age": 20,
  "email": "user-example.com",
  "phoneNumber": "abcxyz"
}

应用程序将返回一个包含问题详情对象的 HTTP 400 响应,如下所示:

{  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": [
      "The Email field is not a valid e-mail address."
    ],
    "LastName": [
      "The length of LastName must be between 3 and 50."
    ],
    "FirstName": [
      "The length of FirstName must be between 3 and 50."
    ],
    "PhoneNumber": [
      "The PhoneNumber field is not a valid phone number."
    ]
  },
  "traceId": "00-8bafbe8952051318d15ddb570d2872b0-369effbb9978122b-00"
}

以这种方式,客户端可以解析响应体并显示一个用户友好的错误消息,以便用户可以纠正输入。

使用 FluentValidation 验证模型

上一节讨论了使用内置验证属性的使用。然而,这些属性存在某些限制:

  • 验证属性与模型紧密耦合。模型被验证属性污染。

  • 验证属性无法验证复杂的验证规则。如果一个属性依赖于其他属性,或者验证需要外部服务,验证属性无法处理这种情况。

为了解决这些问题,我们可以使用FluentValidation来验证模型。FluentValidation是一个流行的开源库,用于构建强类型验证规则,允许我们将验证逻辑与模型分离。它还支持复杂的验证规则。

要使用FluentValidation,我们需要安装FluentValidation.AspNetCore NuGet 包。在终端中运行以下命令来安装包:

dotnet add package FluentValidation

重要提示

之前,FluentValidation为 ASP.NET Core 提供了一个名为FluentValidation.AspNetCore的单独包。然而,此包已弃用。建议直接使用FluentValidation包,并使用手动验证而不是使用 ASP.NET Core 验证管道。这是因为 ASP.NET Core 验证管道不支持异步验证。

接下来,我们需要为User模型创建一个验证器。创建一个名为UserValidator的新类,并添加以下代码:

public class UserValidator : AbstractValidator<User>{
    public UserValidator()
    {
        RuleFor(u => u.FirstName)
            .NotEmpty()
            .WithMessage("The FirstName field is required.")
            .Length(3, 50)
            .WithMessage("The length of FirstName must be between 3 and 50.");
        // Omitted other rules for brevity
        // Create a custom rule to validate the Country and PhoneNumber. If the country is New Zealand, the phone number must start with 64.
        RuleFor(u => u)
            .Custom((user, context) =>
            {
                if (user.Country.ToLower() == "new zealand" && !user.PhoneNumber.StartsWith("64"))
                {
                    context.AddFailure("The phone number must start with 64 for New Zealand users.");
                }
            });
    }
}

在前面的代码中,我们使用流畅语法为每个属性指定验证规则。我们还可以为依赖属性创建自定义规则。在这个例子中,我们正在创建一个自定义规则来验证CountryPhoneNumber属性。如果国家是新西兰,我们可以创建一个自定义规则,要求电话号码以 64 开头。这只是一个如何验证依赖于其他属性的属性的示例;内置的验证属性无法处理此类验证。

接下来,我们需要在应用程序中注册验证器。将以下代码添加到Program.cs文件中:

builder.Services.AddScoped<IValidator<User>, UserValidator>();

之前的代码看起来很简单。但如果我们有多个验证者怎么办?我们可以在一个特定的集合中注册所有验证者。为此,我们需要安装FluentValidation.DependencyInjectionExtensions NuGet 包。在终端中运行以下命令来安装包:

dotnet add package FluentValidation.DependencyInjectionExtensions

然后,我们可以注册所有验证器,如下所示:

builder.Services.AddValidatorsFromAssemblyContaining<UserValidator>();

现在,我们可以在控制器中验证模型。更新Post操作,如下所示:

[HttpPost]public async Task<ActionResult<User>> Post(User user)
{
    var validationResult = await _validator.ValidateAsync(user);
    if (!validationResult.IsValid)
    {
        return BadRequest(new ValidationProblemDetails(validationResult.ToDictionary()));
    }
    user.Id = Users.Max(u => u.Id) + 1;
    Users.Add(user);
    return CreatedAtRoute("", new { id = user.Id }, user);
}

在前面的代码中,我们利用 ValidateAsync() 方法来验证模型。如果模型无效,我们返回一个包含包含相关验证错误消息的问题详情对象的 HTTP 400 响应。

/users 端点发送以下有效负载的 POST 请求:

{  "firstName": "ab",
  "lastName": "xy",
  "age": 20,
  "email": "user-example.com",
  "country": "New Zealand",
  "phoneNumber": "12345678"
}

应用程序将返回一个包含以下问题详情对象的 400 错误:

{  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "FirstName": [
      "The length of LastName must be between 3 and 50."
    ],
    "LastName": [
      "The length of LastName must be between 3 and 50."
    ],
    "Email": [
      "The Email field is not a valid e-mail address."
    ],
    "": [
      "The phone number must start with 64 for New Zealand users."
    ]
  }
}

如我们所见,自定义验证规则被执行,并将错误消息返回给客户端。

FluentValidation 除了内置的验证属性外,还有更多功能。如果您有复杂的验证规则,可以考虑使用 FluentValidation。有关更多详细信息,请参阅官方文档:docs.fluentvalidation.net/en/latest/index.html

健康检查

为了监控应用程序,我们需要知道应用程序是否正在正确运行。我们可以执行健康检查来监控应用程序。通常,健康检查是一个返回应用程序健康状态的端点。此状态可以是 Healthy(健康)、Degraded(降级)或 Unhealthy(不健康)。

健康检查是微服务架构的关键部分。在微服务架构中,一个 API 服务可能有多个实例,并且可能依赖于其他服务。可以使用负载均衡器或编排器将流量分发到不同的实例。如果某个实例不健康,负载均衡器或编排器可以停止向该不健康的实例发送流量。例如,Kubernetes – 一个流行的容器编排器 – 可以使用健康检查来确定容器是否健康。如果一个容器没有运行,Kubernetes 将重新启动该容器。

我们不会在本书中讨论 Kubernetes 的细节。相反,我们将专注于如何在 ASP.NET Core Web API 应用程序中实现 Kubernetes 的健康检查。

实现基本健康检查

ASP.NET Core 提供了一种简单直接的方式来配置健康检查。我们可以使用 AddHealthChecks 方法将健康检查添加到应用程序中。打开 Program.cs 文件并添加以下代码:

builder.Services.AddHealthChecks();var app = builder.Build();
app.MapHealthChecks("healthcheck");

前面的代码向应用程序添加了一个基本健康检查。健康检查的端点是 /healthcheck。运行应用程序并向 /healthcheck 端点发送 GET 请求。如果成功,应用程序将返回一个包含纯文本 Healthy200 响应。

然而,这个健康检查过于简单。在现实世界中,一个 Web API 应用程序可能更复杂。它可能有多个依赖项,例如数据库、消息队列和其他服务。我们需要检查这些依赖项的健康状态。如果某些核心依赖项不健康,应用程序应该是不健康的。让我们看看如何实现一个更复杂的健康检查。

实现复杂的健康检查

健康检查实现类实现了IHealthCheck接口。IHealthCheck接口定义如下:

public interface IHealthCheck{
    Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default);
}

我们可以创建一个自定义的健康检查实现来确保我们的 API 正常运行。例如,如果 API 依赖于另一个服务,我们可以创建一个健康检查实现来验证依赖服务的健康状态。如果依赖服务不健康,API 将无法正确运行。以下是一个健康检查实现的示例:

public class OtherServiceHealthCheck(IHttpClientFactory httpClientFactory) : IHealthCheck{
    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        var client = httpClientFactory.CreateClient("JsonPlaceholder");
        var response = await client.GetAsync("posts", cancellationToken);
        return response.IsSuccessStatusCode
            ? HealthCheckResult.Healthy("A healthy result.")
            : HealthCheckResult.Unhealthy("An unhealthy result.");
    }
}

在前面的代码中,我们创建了一个健康检查实现来检查jsonplaceholder.typicode.com/posts端点的健康状态。如果端点返回 200 响应,健康检查返回健康。否则,健康检查返回不健康。

接下来,我们需要在应用程序中注册健康检查实现。打开Program.cs文件并添加以下代码:

builder.Services.AddHealthChecks()    .AddCheck<OtherServiceHealthCheck>("OtherService");
// Omitted other code for brevity
app.MapHealthChecks("/other-service-health-check",
    new HealthCheckOptions() { Predicate = healthCheck => healthCheck.Name == "OtherService" });

此代码与之前的健康检查类似。首先,我们使用AddHealthChecks方法注册强类型健康检查实现。然后,我们使用MapHealthCheck方法将/other-service-health-check端点映射到健康检查实现。我们还使用HealthCheckOptions对象指定健康检查的名称,该名称用于过滤健康检查。如果我们没有指定健康检查的名称,则将执行所有健康检查实现。

运行应用程序并向/other-service-health-check端点发送GET请求。如果依赖服务https://jsonplaceholder.typicode.com/posts是健康的,应用程序将返回一个包含响应体中纯文本Healthy的 200 响应。

有时,我们需要检查多个依赖服务。我们可以使用特定标签注册多个健康检查实现,此时我们可以使用此标签来过滤健康检查。以下代码展示了如何注册多个健康检查实现:

builder.Services.AddHealthChecks()    .AddCheck<OtherServiceHealthCheck>("OtherService", tags: new[] { "other-service" })
    .AddCheck<OtherService2HealthCheck>("OtherService2", tags: new[] { "other-service" });
    .AddCheck<OtherService3HealthCheck>("OtherService3", tags: new[] { "other-service" });

在前面的代码中,我们使用相同的标签other-service注册了三个健康检查实现。现在,我们可以使用这个标签来过滤健康检查。以下代码展示了如何过滤健康检查:

app.MapHealthChecks("/other-services-health-check",    new HealthCheckOptions() { Predicate = healthCheck => healthCheck.Tags.Contains("other-service") });

Name属性类似,我们可以使用Tags属性来过滤健康检查。当我们向/other-services-health-check端点发送GET请求时,如果所有依赖服务都健康,应用程序将返回一个包含响应体中纯文本Healthy200 OK响应。但如果其中一个依赖服务不健康,健康检查将返回一个包含响应体中纯文本Unhealthy503 Service Unavailable响应。

重要提示

如果MapHealthChecks()方法没有使用HealthCheckOptions参数,默认情况下健康检查端点将运行所有已注册的健康检查。

实现数据库健康检查

在前面的章节中,我们讨论了如何实现依赖服务的健康检查。由于数据库是 Web API 应用程序的一个常见组件,本节将重点介绍如何实现数据库健康检查。

实现数据库健康检查的方法与我们前面讨论的类似:我们需要连接到数据库并执行一个简单的查询来检查数据库是否健康。如果您使用 EF Core 访问数据库,可以使用 Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore 包来实现数据库健康检查。此包为 EF Core 提供了健康检查实现,因此我们不需要自己编写健康检查实现。在终端中运行以下命令来安装此包:

dotnet add package Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore

在示例项目中,我们有一个 InvoiceDbContext 类来访问数据库。以下代码显示了如何在应用程序中注册 InvoiceDbContext 类:

builder.Services.AddDbContext<InvoiceDbContext>(options =>    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

完成此操作后,注册 EF Core DbContext 健康检查实现,如下所示:

builder.Services.AddHealthChecks().AddDbContextCheck<InvoiceDbContext>("Database", tags: new[] { "database" });

类似地,为健康检查实现分配一个标签,以便我们可以过滤健康检查。然后,我们可以将健康检查端点映射到健康检查的实现,如下所示:

app.MapHealthChecks("/database-health-checks",    new HealthCheckOptions() { Predicate = healthCheck => healthCheck.Tags.Contains("database") });

运行应用程序并向 /database-health-checks 端点发送一个 GET 请求。如果数据库健康,应用程序将返回一个包含纯文本中的 Healthy200 OK 响应。此外,如果需要,您可以注册多个健康检查以针对不同的数据库。

重要提示

如果您使用其他 ORM 访问数据库,可以按照前面的章节创建一个自定义的健康检查实现。这可以通过执行一个简单的查询,例如 SELECT 1,来确定数据库是否正常运行。

理解就绪性和活跃性

在前面的章节中,我们讨论了如何为 ASP.NET Core Web API 应用程序实现健康检查。在现实世界中,我们可能需要将应用程序部署到容器编排器,例如 Kubernetes。Kubernetes 是一个流行的容器编排器,可以管理容器化应用程序,监控健康状态,并根据工作负载进行扩展或缩减。Kubernetes 使用术语 probe,类似于健康检查,来监控应用程序的健康状态。虽然本书没有涵盖 Kubernetes 的详细信息,但它将讨论如何在 ASP.NET Core Web API 应用程序中实现 Kubernetes 探针。

Kubernetes 有三种类型的探针:就绪性活跃性启动性。让我们更深入地了解一下:

  • liveness:这个探针表示应用程序是否运行正确。Kubernetes 每隔几秒执行一次liveness探针。如果应用程序在指定时间内没有响应liveness探针,容器将被杀死,Kubernetes 将创建一个新的容器来替换它。liveness探针可以执行 HTTP 请求、命令或 TCP 套接字检查。它还支持 gRPC 健康检查。

  • readiness:这个探针用于确定应用程序是否准备好接收流量。一些应用程序在能够接收流量之前需要执行一些初始化任务,例如连接到数据库、加载配置、检查依赖的服务等。在此期间,应用程序不能接收流量,但这并不意味着应用程序不健康。Kubernetes 不应该杀死应用程序并重启它。初始化完成后,所有依赖的服务都健康时,readiness探针将通知 Kubernetes 应用程序已准备好接收流量。

  • startup:这个探针与readiness探针类似。然而,区别在于startup探针只在应用程序启动后执行一次。它用于确定应用程序是否完成了初始化过程。如果配置了这个探针,livenessreadiness探针将不会执行,直到startup探针成功。

配置探针不正确可能会导致级联故障。例如,服务 A 依赖于服务 B,而服务 B 又依赖于服务 C。如果liveness探针配置错误,在服务 C 不健康时检查依赖的服务,服务 A 和服务 B 将会被重启,但这并不能解决问题。这是一个级联故障。在这种情况下,服务 A 和服务 B 不应该被重启。相反,只需重启服务 C 即可。

下面是一个 Kubernetes 中liveness HTTP 探针配置的示例:

livenessProbe:  httpGet:
    path: /liveness
    port: 8080
    httpHeaders:
    - name: Custom-Header
      value: X-Health-Check
  initialDelaySeconds: 3
  periodSeconds: 5
  timeoutSeconds: 1
  successThreshold: 1
  failureThreshold: 3

在配置中,我们定义了以下属性:

  • pathporthttpHeaders:这些属性用于配置 HTTP 请求。在先前的例子中,我们指定了一个名为Custom-Header的自定义 HTTP 头,其值为X-Health-Check。应用程序可以使用这个 HTTP 头来识别请求是否为健康检查请求。如果请求没有这个 HTTP 头,应用程序可以拒绝该请求。

  • initialDelaySeconds:此属性用于指定容器启动后执行第一次探针之前需要等待的秒数。默认值为 0。不要为这个属性使用一个很高的值。你可以使用startup探针来检查应用程序的初始化。

  • periodSeconds: 这个属性用于指定每次探测之间的秒数。默认值是 10,最小值是 1。你可以根据你的场景调整这个值。确保 Kubernetes 能够尽快发现不健康的容器。

  • timeoutSeconds: 这个属性用于指定探测超时的秒数。默认值是 1,最小值也是 1。确保探测足够快。

  • successThreshold: 这个属性用于确定在探测之前失败后,需要连续成功响应的次数,才能认为探测成功。对于存活探测,这个值必须是 1。

  • failureThreshold: 这个属性用于指定探测在成功后连续失败的次数,才能被认为失败。不要为这个属性设置过高的值;否则,Kubernetes 需要等待很长时间才能重启容器。

请记住,liveness 探测不应该依赖于其他服务。换句话说,不要在 liveness 探测中检查其他服务的健康状态。相反,这个探测应该只检查应用程序是否能够响应请求。

下面是一个 readiness HTTP 探测配置的示例:

readinessProbe:  httpGet:
    path: /readiness
    port: 8080
    httpHeaders:
    - name: Custom-Header
      value: X-Health-Check
  initialDelaySeconds: 5
  periodSeconds: 5
  timeoutSeconds: 1
  successThreshold: 3
  failureThreshold: 2

对于 readiness 探测,有几个不同的考虑因素:

  • successThreshold: 默认值是 1。然而,我们可以增加这个值以确保应用程序准备好接收流量。

  • failureThreshold: 在至少有 failureThreshold 次探测失败后,Kubernetes 将停止向容器发送流量。由于应用程序可能存在暂时性问题,我们可以在认为应用程序不健康之前允许几次失败。然而,不要为这个属性设置过高的值。

如果应用程序初始化需要很长时间,我们可以使用 startup 探测来检查应用程序的初始化。这里展示了 startup HTTP 探测配置的一个示例:

startupProbe:  httpGet:
    path: /startup
    port: 8080
    httpHeaders:
    - name: Custom-Header
      value: X-Health-Check
  periodSeconds: 5
  timeoutSeconds: 1
  successThreshold: 1
  failureThreshold: 30

在这个配置中,startup 探测将每 5 秒执行一次,应用程序将有最多 150 秒(5 * 30 = 150 秒)的时间来完成初始化。successThreshold 必须是 1,这样一旦 startup 探测成功,livenessreadiness 探测将被执行。如果 startup 探测在 150 秒后(大约 2 分半钟)失败,Kubernetes 将杀死容器并启动一个新的。因此,确保 startup 探测有足够的时间来完成初始化。

配置 Kubernetes 探针不是一个简单的任务。我们需要考虑许多因素。例如,我们应该在readiness探针中检查依赖服务吗?如果应用程序在没有特定依赖服务的情况下可以部分运行,那么它应该被视为降级而不是不健康。在这种情况下,如果应用程序有处理短暂故障的机制,那么在readiness探针中省略特定依赖服务可能是可以接受的。所以,请仔细考虑您的场景;在配置探针时,您可能需要做出妥协。

本节的目的不是涵盖 Kubernetes 探针的所有细节。有关更多详细信息,请参阅以下官方文档:

监控和可观测性

在现实世界中,构建应用程序只是第一步。我们还需要监控和观察应用程序的性能。这就是可观测性概念出现的地方。在本节中,我们将讨论可观测性以及如何使用 OpenTelemetry 来监控和观察应用程序。

什么是可观测性?

第四章中,我们介绍了 ASP.NET Core Web API 应用程序中的日志记录。我们学习了如何使用内置的日志框架将消息记录到不同的日志提供者。可观测性是一个比日志更全面的概念。除了日志之外,可观测性还允许我们更深入地了解应用程序的性能。例如,我们可以确定在给定小时内处理了多少请求,请求的延迟是多少,以及如何在微服务架构中处理请求。所有这些都是可观测性的组成部分。

通常,可观测性有三个支柱:

  • 日志:日志用于记录应用程序内部发生的事情,例如传入请求、传出响应、重要的业务逻辑执行、异常、错误、警告等等。

  • 指标:指标用于衡量应用程序的性能,例如请求数量、请求延迟、错误率、资源使用情况等等。这些指标可以在应用程序表现不佳时触发警报。

  • 跟踪:跟踪用于跟踪跨多个服务的请求流,以确定时间花费在哪里或错误发生在哪里。这在微服务架构中特别有用。

总结来说,可观测性是通过分析应用程序的日志、指标和跟踪来理解其内部状态和操作特性的实践。在 ASP.NET Core web API 应用程序中实现可观测性有几种不同的方法——我们可以更新源代码以添加日志、指标和跟踪,或者使用工具来监控和观察应用程序而不改变代码。在本节中,我们将通过使用 OpenTelemetry 来实现 ASP.NET Core web API 应用程序的可观测性来讨论第一种方法。这为我们提供了更多的灵活性来自定义可观测性方面。

使用 OpenTelemetry 收集可观测性数据

OpenTelemetry 是一个流行的跨平台、开源标准,用于收集可观测性数据。它提供了一套 API、SDK 和工具,用于对应用程序进行仪器化、生成、收集和导出遥测数据,以便我们可以分析应用程序的性能和行为。它支持许多平台和语言,以及流行的云提供商。您可以在 opentelemetry.io/ 找到有关 OpenTelemetry 的更多详细信息。

.NET OpenTelemetry 实现包括以下组件:

  • 核心 API:核心 API 是一组接口和类,它定义了 OpenTelemetry API。这是一个平台无关的 API,可以用来对应用程序进行仪器化。

  • 仪器化:这是一组库,可以用来从应用程序中收集仪器化数据。该组件包括针对不同框架和平台的多达多个包,例如 ASP.NET Core、gRPC、HTTP 调用、SQL 数据库操作等。

  • 导出器:导出器用于将收集到的遥测数据导出到不同的目标,例如控制台和 应用程序性能监控APM)系统,包括 Prometheus、Zipkin 等。

第四章 中,我们介绍了使用 SerilogSeq 来收集日志。在接下来的几节中,我们将重点介绍如何使用 OpenTelemetry 来收集指标和跟踪。我们将使用 Prometheus 来收集指标,并使用 Grafana 来可视化指标。我们还将使用 Jaeger 来收集跟踪。所有这些工具都是开源的。此外,我们还将探索由微软提供的强大 APM 系统 Azure Application Insights。

将 OpenTelemetry 集成到 ASP.NET Core web API 应用程序中

在本节中,我们将探讨如何在 ASP.NET Core web API 应用程序中使用指标。在示例项目中,我们有一个 InvoiceController 类来管理发票。我们想知道执行了多少个请求以及每个请求的持续时间。我们需要执行以下几个步骤:

  1. 定义这些活动的指标。

  2. 生成和收集仪器化数据。

  3. 在仪表板上可视化数据。

首先,我们需要使用以下命令安装一些 NuGet 包:

dotnet add package OpenTelemetry.Instrumentation.AspNetCore --prereleasedotnet add package OpenTelemetry.Instrumentation.Http --prerelease
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
dotnet add package OpenTelemetry.Exporter.Console
dotnet add package OpenTelemetry.Extensions.Hosting

这些包包括所需的 .NET OpenTelemetry 实现。请注意,在编写本文时,一些包没有提供稳定的版本,因此我们需要使用 --prerelease 选项来安装最新的预览版本。如果您在稳定版本可用时阅读此书,可以省略 --prerelease 选项。

接下来,我们必须定义指标。在这个例子中,我们想知道 /api/Invoices 端点每个操作执行了多少请求。在 \OpenTelemetry\Metrics 文件夹中创建一个新的名为 InvoiceMetrics 的类。以下代码显示了如何定义指标:

public class InvoiceMetrics{
    private readonly Counter<long> _invoiceCreateCounter;
    private readonly Counter<long> _invoiceReadCounter;
    private readonly Counter<long> _invoiceUpdateCounter;
    private readonly Counter<long> _invoiceDeleteCounter;
    public InvoiceMetrics(ImeterFactory meterFactory)
    {
        var meter = meterFactory.Create("MyWebApiDemo.Invoice");
        _invoiceCreateCounter = meter.CreateCounter<long>("mywebapidemo.invoices.created");
        _invoiceReadCounter = meter.CreateCounter<long>("mywebapidemo.invoices.read");
        _invoiceUpdateCounter = meter.CreateCounter<long>("mywebapidemo.invoices.updated");
        _invoiceDeleteCounter = meter.CreateCounter<long>("mywebapidemo.invoices.deleted");
    }
    public void IncrementCreate()
    {
        _invoiceCreateCounter.Add(1);
    }
    public void IncrementRead()
    {
        _invoiceReadCounter.Add(1);
    }
    // Omitted other methods for brevity
}

IMeterFactory 接口默认注册在 ASP.NET Core 的 DI 容器中,并用于创建度量器。这个名为 MyWebApiDemo.Invoice 的度量器用于记录指标。此外,还创建了四个计数器来记录每个操作的请求数量。为此,我们公开了四个公共方法来增加计数器。

每个指标名称必须是唯一的。当我们创建一个指标或计数器时,建议遵循 OpenTelemetry 命名指南:github.com/open-telemetry/semantic-conventions/blob/main/docs/general/metrics.md#general-guidelines

接下来,我们需要在应用程序中注册指标。将以下代码添加到 Program.cs 文件中:

builder.Services.AddOpenTelemetry()    .ConfigureResource(config =>
    {
        config.AddService(nameof(MyWebApiDemo));
    })
    .WithMetrics(b =>
    {
        b.AddConsoleExporter();
        b.AddAspNetCoreInstrumentation();
        b.AddMeter("Microsoft.AspNetCore.Hosting",
            "Microsoft.AspNetCore.Server.Kestrel",
            "MyWebApiDemo.Invoice");
    });
builder.Services.AddSingleton<InvoiceMetrics>();

在前面的代码中,我们使用了 AddOpenTelemetry() 方法来注册 OpenTelemetry 服务。ConfigureResource() 方法用于注册服务名称。在 WithMetrics() 方法内部,我们使用 AddConsoleExporter() 方法添加一个控制台导出器。这个控制台导出器对于本地开发和调试非常有用。我们还添加了三个度量器,包括 ASP.NET Core 托管和 Kestrel 服务器,以便我们可以从 ASP.NET Core Web API 框架收集指标。最后,我们将 InvoiceMetrics 类注册到依赖注入容器中作为一个单例。

接下来,我们可以使用 InvoiceMetrics 类来记录指标。打开 InvoiceController 类,在 Post 动作中调用 IncrementCreate() 方法,如下所示:

[HttpPost]public async Task<ActionResult<Invoice>> Post(Invoice invoice)
{
    // Omitted for brevity
    await dbContext.SaveChangesAsync();
    // Instrumentation
    _invoiceMetrics.IncrementCreate();
    return CreatedAtAction(nameof(Get), new { id = invoice.Id }, invoice);
}

其他操作类似。在我们检查控制台中的指标之前,我们需要安装 dotnet-counters 工具,这是一个用于查看实时指标的命令行工具。在终端中运行以下命令来安装该工具:

dotnet tool install --global dotnet-counters

然后,我们可以使用 dotnet counters 命令来查看指标。我们可以使用以下命令检查 Microsoft.AspNetCore.Hosting 的指标:

dotnet-counters monitor -n MyWebApiDemo --counters Microsoft.AspNetCore.Hosting

运行应用程序并向 /api/Invoices 端点发送一些请求。您将看到以下指标:

Press p to pause, r to resume, q to quit.    Status: Running
[Microsoft.AspNetCore.Hosting]
    http.server.active_requests ({request})
        http.request.method=GET,url.scheme=https                           0
        http.request.method=POST,url.scheme=https                          0
    http.server.request.duration (s)
        http.request.method=GET,http.response.status_code=200,ht           0.006
        http.request.method=GET,http.response.status_code=200,ht           0.006
        http.request.method=GET,http.response.status_code=200,ht           0.006
        http.request.method=POST,http.response.status_code=201,h           0.208
        http.request.method=POST,http.response.status_code=201,h           0.208
        http.request.method=POST,http.response.status_code=201,h           0.208

在这里,你可以看到 HTTP 操作的指标,包括活跃请求和请求持续时间。你可以使用这个工具来观察更多性能指标,例如 CPU 使用率或应用程序中抛出的异常率。有关此工具的更多信息,请参阅官方文档:learn.microsoft.com/en-us/dotnet/core/diagnostics/dotnet-counters

要通过InvoiceMetrics检查自定义指标,你需要在命令中指定--counters选项,如下所示:

dotnet-counters monitor -n MyWebApiDemo --counters MyWebApiDemo.Invoice

输出可能看起来像这样:

Press p to pause, r to resume, q to quit.    Status: Running
[MyWebApiDemo.Invoice]
    mywebapidemo.invoices.created (Count / 1 sec)                    0
    mywebapidemo.invoices.read (Count / 1 sec)                       0

在这里,你可以看到我们在invoiceMetrics类中定义的指标。请注意,你可以在--counters选项中包含多个计数器,用逗号分隔。例如,你可以使用以下命令来检查Microsoft.AspNetCore.HostingMyWebApiDemo.Invoice的指标:

dotnet-counters monitor -n MyWebApiDemo --counters Microsoft.AspNetCore.Hosting,MyWebApiDemo.Invoice

InvoiceMetrics类中,我们定义了四个计数器。OpenTelemetry 中还有更多类型的仪表,例如GaugeHistogram等。以下是一些可用的不同类型仪表的示例:

  • Counter: 计数器用于跟踪随时间只能增加的值——例如,应用程序启动后的请求数量。

  • UpDownCounter: 上下载计数器类似于计数器,但它可以随时间增加或减少。一个例子是活跃请求数量。当请求开始时,计数器增加 1。当请求结束时,计数器减少 1。它还可以用来监控队列的大小。

  • Gauge: 仪表测量在特定时间点的当前值,例如 CPU 使用率或内存使用率。

  • Histogram: 直方图通过聚合测量值的统计分布。例如,直方图可以测量处理时间超过特定持续时间的请求数量。

我们可以定义更多指标来监控应用程序。定义一个UpDownCounter仪表来跟踪/api/Invoices端点有多少活跃请求。更新InvoiceMetrics类,如下所示:

private readonly UpDownCounter<long> _invoiceRequestUpDownCounter;public InvoiceMetrics(IMeterFactory meterFactory)
{
    // Omitted for brevity
    _invoiceRequestUpDownCounter = meter.CreateUpDownCounter<long>("mywebapidemo.invoices.requests");
}
public void IncrementRequest()
{
    _invoiceRequestUpDownCounter.Add(1);
}
public void DecrementRequest()
{
    _invoiceRequestUpDownCounter.Add(-1);
}

然后,更新InvoiceController类,以便它增加和减少计数器。为了简单起见,我们将在控制器中调用IncrementRequest()DecrementRequest()方法。在现实世界中,建议使用 ASP.NET Core 中间件来处理此操作。以下代码展示了如何更新InvoiceController类:

[HttpGet("{id}")]public async Task<ActionResult<Invoice>> Get(Guid id)
{
    _invoiceMetrics.IncrementRequest();
    // Omitted for brevity
    _invoiceMetrics.DecrementRequest();
    return Ok(result);
}

这里展示了Histogram的一个例子:

private readonly Histogram<double> _invoiceRequestDurationHistogram;public InvoiceMetrics(IMeterFactory meterFactory)
{
    // Omitted for brevity
    _invoiceRequestDurationHistogram = meter.CreateHistogram<double>("mywebapidemo.invoices.request_duration");
}
public void RecordRequestDuration(double duration)
{
    _invoiceRequestDurationHistogram.Record(duration);
}

然后,更新InvoiceController类,以便它记录请求的持续时间。同样,我们可以在控制器中仅使用RecordRequestDuration()方法。以下代码展示了如何更新InvoiceController类:

[HttpGet("{id}")]public async Task<ActionResult<Invoice>> Get(Guid id)
{
    var stopwatch = Stopwatch.StartNew();
    // Omitted for brevity
    // Simulate a latency
    await Task.Delay(_random.Next(0, 500));
    // Omitted for brevity
    stopwatch.Stop();
    _invoiceMetrics.RecordRequestDuration(stopwatch.Elapsed.TotalMilliseconds);
    return Ok(result);
}

在这里,我们使用 Task.Delay() 方法来模拟延迟。运行应用程序并向 /api/Invoices 端点发送一些请求。然后,使用 dotnet-counters 工具检查指标。您将看到以下指标:

Press p to pause, r to resume, q to quit.    Status: Running
[Microsoft.AspNetCore.Hosting]
    http.server.active_requests ({request})
        http.request.method=GET,url.scheme=https                           0
        http.request.method=POST,url.scheme=https                          0
    http.server.request.duration (s)
        http.request.method=GET,http.response.status_code=200,ht           0.075
        http.request.method=GET,http.response.status_code=200,ht           0.24
        http.request.method=GET,http.response.status_code=200,ht           0.24
        http.request.method=POST,http.response.status_code=201,h           0.06
        http.request.method=POST,http.response.status_code=201,h           0.06
        http.request.method=POST,http.response.status_code=201,h           0.06
[MyWebApiDemo.Invoice]
    mywebapidemo.invoices.created (Count / 1 sec)                          0
    mywebapidemo.invoices.read (Count / 1 sec)                             0
    mywebapidemo.invoices.request_duration
        Percentile=50                                                     74.25
        Percentile=95                                                    239.5
        Percentile=99                                                    239.5
    mywebapidemo.invoices.requests                                         0

在前面的输出中,直方图仪器显示为 Percentile=50Percentile=95Percentile=99。这是 dotnet-counters 工具的默认配置。我们可以使用其他工具,如 Prometheus 和 Grafana,提供更多的可视化选项。我们将在下一节中讨论这一点。

使用 Prometheus 收集和查询指标

Prometheus 是一个广泛使用的开源监控系统。Prometheus 最初由 SoundCloud (soundcloud.com/) 开发,然后在 2016 年加入了 Cloud Native Computing Foundation (cncf.io/)。Prometheus 能够从各种来源收集指标,包括应用程序、数据库、操作系统等。它还提供了一种强大的查询语言来查询收集的指标,以及一个仪表板来可视化它们。

在本节中,我们将使用 Prometheus 从 ASP.NET Core web API 应用程序收集指标并可视化这些指标。

要安装 Prometheus,请导航到官方网站:prometheus.io/download/。下载适用于您操作系统的最新版本的 Prometheus。

接下来,我们需要配置 ASP.NET web API 应用程序以导出 Prometheus 的指标。使用以下命令在 ASP.NET Core web API 项目中安装 OpenTelemetry.Exporter.Prometheus.AspNetCore 包:

dotnet add package OpenTelemetry.Exporter.Prometheus.AspNetCore --prerelease

然后,在 Program.cs 文件中注册 Prometheus 导出器,如下所示:

builder.Services.AddOpenTelemetry()    .ConfigureResource(config =>
    {
        config.AddService(nameof(MyWebApiDemo));
    })
    .WithMetrics(metrics =>
    {
        metrics.AddAspNetCoreInstrumentation()
            .AddMeter("Microsoft.AspNetCore.Hosting")
            .AddMeter("Microsoft.AspNetCore.Server.Kestrel")
            .AddMeter("MyWebApiDemo.Invoice")
            .AddConsoleExporter()
            .AddPrometheusExporter();
    });
// Omitted for brevity
// Add the Prometheus scraping endpoint
app.MapPrometheusScrapingEndpoint();

现在,我们有两个导出器:控制台导出器和 Prometheus 导出器。如果您不需要控制台导出器,您可以将其删除。我们还在使用 MapPrometheusScrapingEndpoint() 方法将 Prometheus 导出器的 /metrics 端点进行映射。此端点由 Prometheus 用于从应用程序中抓取指标。

接下来,我们需要配置 Prometheus 以从 ASP.NET Core web API 应用程序收集指标。找到 ASP.NET Core web API 应用程序的端口号。在示例项目中,我们使用 HTTP 的端口号 5125。您可以在 launchSettings.json 文件中找到相关的端口号。

在 Prometheus 文件夹中打开 prometheus.yml 文件。在文件末尾添加一个作业,如下所示:

- job_name: 'MyWebApiDemo'  scrape_interval: 5s # Set the scrape interval to 5 seconds so we can see the metrics update immediately.
  static_configs:
]

scrape_interval 属性用于指定收集指标的时间间隔。出于测试目的,这可以设置为 5 秒,以便您可以立即查看指标。然而,在生产场景中,建议将其设置为更高的值,例如 15 秒。此外,在保存文件之前,请确保 targets 属性已设置为正确的端口号。

如果您为 ASP.NET Core web API 应用程序使用 HTTPS,则需要指定 schema 属性,如下所示:

- job_name: 'MyWebApiDemo'  scrape_interval: 5s # Set the scrape interval to 5 seconds so we can see the metrics update immediately.
  scheme: https
  static_configs:
    - targets: ['localhost:7003']

运行应用程序并向 /api/Invoices 端点发送一些请求。导航到 /metrics 端点;你将看到相关的指标:

图 16.4 – Prometheus 的指标

图 16.4 – Prometheus 的指标

现在,我们可以通过执行 prometheus.exe 文件来运行 Prometheus。在输出中,你会找到以下行:

ts=2023-10-14T10:44:55.133Z caller=web.go:566 level=info component=web msg="Start listening for connections" address=0.0.0.0:9090

这意味着 Prometheus 正在 9090 端口上运行。导航到 http://localhost:9090。你将看到 Prometheus 仪表板,如下所示:

图 16.5 – Prometheus 仪表板

图 16.5 – Prometheus 仪表板

Prometheus 将开始从我们在 prometheus.yml 文件中配置的 ASP.NET Core Web API 应用程序中抓取指标。点击状态 | 目标在顶部。你将看到以下页面,其中显示了 ASP.NET Core Web API 应用程序的状态:

图 16.6 – Prometheus 目标

图 16.6 – Prometheus 目标

在顶部菜单中点击图形。你将看到以下页面,其中显示了可用的指标。点击打开指标探索器按钮(如图 16.7 中突出显示),以打开指标探索器

图 16.7 – Prometheus 指标探索器

图 16.7 – Prometheus 指标探索器

选择一个指标,例如 mywebapidemo_invoices_read_total,然后点击执行按钮。接着,点击图形标签;你将看到以下页面,其中显示了指标图形:

图 16.8 – Prometheus 图形

图 16.8 – Prometheus 图形

Prometheus 提供了一个强大的查询语言来查询指标。例如,我们可以使用以下查询来获取每分钟的 mywebapidemo.invoices.read 计数器:

rate(mywebapidemo_invoices_read_total[1m])

你将看到以下图形:

图 16.9 – 每分钟请求数

图 16.9 – 每分钟请求数

以下查询可以获取超过 100 毫秒的请求:

histogram_quantile(0.95, sum(rate(mywebapidemo_invoices_request_duration_bucket[1m])) by (le)) > 100

图 16.10显示了结果:

图 16.10 – 超过 100 毫秒的请求

图 16.10 – 超过 100 毫秒的请求

本节简要介绍了 Prometheus。有关查询语言语法的更多信息,请参阅官方文档:prometheus.io/docs/prometheus/latest/querying/basics/

Prometheus 是收集和查询指标的一个强大工具。为了更好地可视化这些指标,可以使用 Grafana 来创建仪表板。在下一节中,我们将探讨如何使用 Grafana 从 Prometheus 读取指标并创建信息丰富的仪表板。

使用 Grafana 创建仪表板

Grafana是一个流行的开源分析和仪表板工具。它可以可视化来自多个数据源(如 Prometheus、Elasticsearch、Azure Monitor 等)的指标。Grafana 可以创建美观的仪表板,帮助我们理解应用程序的性能和行为。在本节中,我们将使用 Grafana 为 ASP.NET Core Web API 应用程序创建仪表板。

Grafana 还提供了一种名为Grafana Cloud的托管服务。Grafana Cloud 的免费层限制为 10,000 个指标、3 个用户和 50 GB 的日志。你可以在此处查看定价:grafana.com/pricing/。本书中,我们将本地安装 Grafana。从官方网站下载 Grafana 的最新版本:grafana.com/oss/grafana/。然后,选择适合你操作系统的版本。

如果你在使用 Windows,通过执行grafana-server.exe文件来运行 Grafana。你可能看到一个 Windows 安全警报对话框。点击允许按钮以允许 Grafana 在这些网络上进行通信。你将在输出中找到以下行:

INFO [10-15|12:31:31] Validated license token                  logger=licensing appURL=http://localhost:3000/ source=disk status=NotFound

这意味着 Grafana 正在端口3000上运行。导航到http://localhost:3000。默认用户名和密码都是admin。一旦登录,系统会提示你更改密码。

重要提示

Grafana 的默认主题是深色。如果你更喜欢浅色主题,你可以通过访问首选项页面来更改它。本书中使用浅色主题以提高可读性。

点击左上角的汉堡菜单,然后点击连接。此页面显示了 Grafana 支持的数据源:

图 16.11 – Grafana 数据源

图 16.11 – Grafana 数据源

搜索Prometheus并点击它。然后,点击右上角的创建 Prometheus 数据源按钮。在设置页面,我们可以配置数据源,如下所示:

图 16.12 – 配置 Prometheus 数据源

图 16.12 – 配置 Prometheus 数据源

使用http://localhost:9090作为 URL。然后,点击Successfully queried the Prometheus API。此时,我们可以创建仪表板来可视化指标。

导航到仪表板页面并点击新建按钮。从下拉列表中,点击新建仪表板。你将被导航到新的仪表板页面。点击添加可视化按钮,然后选择 Prometheus 作为数据源,如下所示:

图 16.13 – 添加可视化

图 16.13 – 添加可视化

然后,我们可以使用查询语言来查询指标。在mywebapidemo.invoices.read请求/api/Invoices端点:

图 16.14 – 查询指标

图 16.14 – 查询指标

点击运行查询按钮;你将在面板中看到以下输出:

图 16.15 – 查询结果

图 16.15 – 查询结果

然后,点击应用按钮;您将在仪表板中看到图表:

图 16.16 – Grafana 仪表板

图 16.16 – Grafana 仪表板

您可以根据需要调整仪表板的大小。请随意添加更多仪表板面板来可视化指标。在离开仪表板之前,请点击右上角的保存按钮以保存它。您还可以将仪表板导出为 JSON 文件并在以后导入:

图 16.17 – 添加更多面板

图 16.17 – 添加更多面板

为了简化创建 Grafana 仪表板的过程,JSON.NET 的尊敬作者 James Newton-King 提供了一份 ASP.NET Core Web API 应用程序的 Grafana 仪表板模板。您可以在以下位置找到模板:github.com/JamesNK/aspnetcore-grafana/blob/main/README.md。此存储库中有两个仪表板:

  • ASP.NET Core.json:此仪表板显示了 ASP.NET Core Web API 应用程序的概述

  • ASP.NET Core Endpoint.json:此仪表板显示了特定端点的详细信息

创建一个新的仪表板并点击ASP.NET Core.json文件或粘贴文件内容到文本框中,如下所示:

图 16.18 – 导入仪表板

图 16.18 – 导入仪表板

点击加载按钮。在下一页上,选择 Prometheus 数据源并点击导入按钮。您将看到以下仪表板:

图 16.19 – James Newton-King 提供的 ASP.NET Core 仪表板

图 16.19 – James Newton-King 提供的 ASP.NET Core 仪表板的概述

此仪表板提供了 ASP.NET Core Web API 应用程序的概述。在这里,您可以查看请求数量、请求持续时间、活动请求数量等。您还可以看到错误率,这对于监控应用程序非常重要。

图 16.20显示了ASP.NET Core 端点仪表板:

图 16.20 – James Newton-King 提供的 ASP.NET Core 端点仪表板

图 16.20 – James Newton-King 提供的 ASP.NET Core 端点仪表板的概述

您可以从下拉列表中选择端点。一旦完成,您将看到端点的指标。例如,图 16.20显示了/api/Invoices端点的指标。

Grafana 提供了许多选项来自定义仪表板。在任何仪表板面板上,您都可以点击右上角的三个点,然后点击编辑来编辑面板。您可以更改标题、可视化类型、查询等。您还可以使用构建器代码编辑器来编辑查询,如图图 16.21图 16.22所示。以下是构建器编辑器的样子:

图 16.21 – 使用构建器编辑器编辑查询

图 16.21 – 使用 Builder 编辑器编辑查询

这就是代码编辑器的样子:

图 16.22 – 使用代码编辑器编辑查询

图 16.22 – 使用代码编辑器编辑查询

Grafana 提供了更好的指标可视化。你可以通过阅读官方文档了解更多关于 Grafana 的信息:grafana.com/docs/grafana/latest/

在下一节中,我们将探讨如何使用 OpenTelemetry 和 Jaeger 来收集追踪信息。

使用 Jaeger 收集追踪信息

追踪对于理解应用程序如何处理请求非常重要。在微服务架构中,一个请求将由多个服务处理。分布式追踪可以用来跟踪请求在多个服务之间的流动。在本节中,我们将学习分布式追踪的基本概念以及如何使用 OpenTelemetry 和 Jaeger 来收集追踪信息。

例如,在一个微服务架构中,服务 A 调用服务 B,服务 B 调用服务 C 和服务 D。当客户端向服务 A 发送请求时,请求将通过服务 B、服务 C 和服务 D 传递。在这种情况下,如果这些服务中的任何一个无法处理请求,或者请求处理时间过长,我们需要知道哪个服务负责失败,以及请求的哪个部分导致了延迟或错误。分布式追踪可以给我们展示这些服务如何处理请求的全貌。

我们在这本书中没有详细讨论微服务架构。为了演示分布式追踪,我们在示例项目中添加了两个 Web API 项目。你可以在MyWebApiDemo项目中找到一个名为OrdersController的控制器。在这个控制器中,我们可以调用Post操作来创建一个订单。Post操作将调用两个外部服务:

  • CustomerService:一个用于检查客户是否存在的服务

  • ProductService:一个用于检查产品是否存在的服务

要创建一个订单,我们必须通过调用CustomerService/api/customers/{id}端点来确保客户 ID 有效。此外,我们还必须通过调用ProductService/api/products/{id}端点来验证产品是否有效。

注意,这些服务仅用于演示目的,不应用于生产环境。因此,没有真正的数据库访问层;相反,使用静态列表来存储临时数据。此外,没有考虑事务和并发管理。

首先,让我们在MyWebApiDemo项目中启用追踪。打开Program.cs文件,并将以下代码添加到MyWebApiDemo项目中:

builder.Services.AddOpenTelemetry()    .ConfigureResource(config =>
    {
        config.AddService(nameof(MyWebApiDemo));
    })
    .WithMetrics(metrics =>
    {
        // Omitted for brevity
    })
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddConsoleExporter();
    });

在前面的代码中,我们使用WithTracing方法在我们的代码中启用了跟踪。为了进一步对应用程序进行检测,我们添加了 ASP.NET Core 和 HTTP 客户端检测。HTTP 客户端检测用于跟踪对外部服务的 HTTP 调用。最后,我们添加了一个控制台导出器以将跟踪导出到控制台。

运行应用程序并向api/orders端点发送一些请求。您将在终端输出中看到一些跟踪信息:

在控制台跟踪中,您将找到两个重要属性:Activity.TraceIdActivity.SpanIdActivity.TraceId属性用于标识一个跟踪,它是一系列跟踪的集合。跟踪中的一个单元是 span。例如,如果我们向api/Orders端点发送POST请求以创建订单,应用程序将调用ProductServiceCustomerService。每个调用都是一个 span。然而,在控制台输出中搜索特定的 span 并不方便。接下来,我们将使用 Jaeger 来收集和可视化跟踪。

Jaeger是一个开源的分布式追踪平台,用于监控和调试分布式工作流以及识别性能瓶颈。Jaeger 最初由 Uber Technologies 开发(uber.github.io/),并于 2017 年加入云原生计算基金会。

从官方网站安装 Jaeger:www.jaegertracing.io/download/。您可以选择适合您操作系统的版本或使用 Docker 镜像。在本书中,我们将使用 Windows 上的可执行二进制文件。在终端导航到 Jaeger 文件夹,并运行以下命令以启动 Jaeger:

./jaeger-all-in-one --collector.otlp.enabled

jaeger-all-in-one命令用于快速本地测试。它启动 Jaeger 的所有组件,包括 Jaeger UI、jaeger-collectorjaeger-agentjaeger-query和内存存储。--collector.otlp.enabled选项用于指定jaeger-collector应接受 OTLP 格式的跟踪。在输出中,您可以找到以下行,表明 Jaeger 正在接收 OTLP 格式的数据:

{"level":"info","ts":1697354213.8840668,"caller":"otlpreceiver@v0.86.0/otlp.go:83","msg":"Starting GRPC server","endpoint":"0.0.0.0:4317"}

jaeger-collector使用端口4317通过 gRPC 协议接收数据,通过端口4318通过 HTTP 协议接收。这允许jaeger-collector与其他服务之间进行高效通信。

接下来,我们需要配置 ASP.NET Core Web API 项目,以便将其 OTLP 跟踪导出到 Jaeger。打开Program.cs文件并更新WithTracing()方法,如下所示:

.WithTracing(tracing =>{
    tracing.AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddConsoleExporter()
        .AddOtlpExporter(options =>
        {
            options.Endpoint = new Uri("http://localhost:4317");
        });
});

我们使用AddOtlpExporter方法添加 Jaeger 的导出器。作为一个最佳实践,建议使用配置系统来设置 URL,而不是硬编码。例如,您可以在appsettings.json文件中定义它。

重新启动三个应用程序并向/api/Orders端点发送一些POST请求。以下是一个有效载荷示例:

{  "id": 0,
  "orderNumber": "string",
  "contactName": "string",
  "description": "string",
  "amount": 0,
  "customerId": 1,
  "orderDate": "2023-10-15T08:57:54.724Z",
  "dueDate": "2023-10-15T08:57:54.724Z",
  "orderItems": [
    {
      "id": 1,
      "orderId": 0,
      "productId": 1,
      "quantity": 0,
      "unitPrice": 0
    }
  ],
  "status": 0
}

导航到http://localhost:16686/;您将看到 Jaeger UI。在搜索选项卡中,选择服务,然后操作,然后点击查找跟踪按钮。您将看到跟踪,如图所示:

图 16.23 – Jaeger 跟踪

图 16.23 – Jaeger 跟踪

点击一个跟踪以查看其详细信息:

图 16.24 – 跟踪的详细信息

图 16.24 – 跟踪的详细信息

您将看到这个请求包括三个跨度。父级是入站请求,并且它有两个指向其他服务的出站请求。

我们可以在依赖服务中启用跟踪以更好地了解这些请求的处理方式。按照相同的方法配置ProductServiceCustomerService。这些跟踪应发送到 Jaeger 的一个实例,以便 Jaeger 可以关联不同服务之间的请求。

现在检查 Jaeger UI。您会发现一个/api/Orders调用现在有五个跨度:

图 16.25 – 多个服务之间的跟踪

图 16.25 – 多个服务之间的跟踪

我们还可以检查每个跨度的时间延迟,如图 16.26 所示:

图 16.26 – 每个跨度的时间延迟

图 16.26 – 每个跨度的时间延迟

使用跟踪可以帮助我们了解应用程序如何处理请求。我们还可以使用跟踪来查找性能瓶颈。这对于微服务架构特别有用。有关 Jaeger 的更多信息,请参阅官方文档:github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter16/MyWebApiDemo

接下来,我们将回顾日志记录并讨论如何在日志中传播跟踪上下文。

使用 HTTP 日志记录

在第四章章节 4 中,我们讨论了如何使用ILogger接口记录消息。有时,我们想要记录 HTTP 请求和响应以进行故障排除。在本节中,我们将讨论 HTTP 日志记录。

按照第四章章节 4 的说明配置日志系统。您可以使用 Serilog 将日志发送到 Seq。要启用 HTTP 日志记录,我们需要使用 HTTP 日志记录中间件。中间件将记录入站请求和出站响应。更新的代码如下:

var logger = new LoggerConfiguration().WriteTo.Seq("http://localhost:5341").CreateLogger();builder.Logging.AddSerilog(logger);
builder.Services.AddHttpLogging(logging =>
{
    logging.LoggingFields = HttpLoggingFields.All;
});
// Omitted for brevity
app.UseHttpLogging();

在前面的代码中,我们指定了HttpLoggingFields来记录所有字段。在生产环境中使用此选项时要小心,因为它可能会影响性能并记录敏感信息。我们不应记录个人身份信息PII)和任何敏感信息。我们在这里仅用于演示目的。

我们还可以更新appsettings.json文件以指定日志级别。将以下代码添加到appsettings.json文件中的LogLevel部分,以便我们可以看到信息日志:

"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information"

使用相同的方法在 CustomerServiceProductService 项目中配置日志。

运行三个应用程序并向 /api/Orders 端点发送一些请求。您将在 Seq 仪表板中看到以下日志:

图 16.27 – HTTP 日志记录

图 16.27 – HTTP 日志记录

在日志中,您将找到有关 HTTP 请求和响应的详细信息。如果您想更改日志字段,您可以在 AddHttpLogging() 方法中更改 HttpLoggingOptionsLoggingFields 属性。LoggingFields 属性是一个枚举。您可以选择 RequestPathRequestQueryRequestMethodRequestStatusCodeRequestBodyRequestHeadersResponseHeadersResponseBodyDuration 等。HttpLoggingOptions 类还有其他属性,例如 RequestHeadersResponseHeadersRequestBodyLogLimitResponseBodyLogLimit 等。您可以使用这些属性来配置日志系统。

由于我们已为所有请求启用了 HTTP 日志记录,我们可以通过跟踪 ID 过滤日志。检查 Jaeger UI 并点击一个跟踪。您将在 URL 中找到跟踪 ID:

图 16.28 – Jaeger 中的跟踪 ID

图 16.28 – Jaeger 中的跟踪 ID

在前面的屏幕截图中,跟踪 ID 是 8c7ab3bccf13135f27baf11c161e17ca。复制此跟踪 ID 并在 Seq 仪表板中使用以下查询:

@TraceId = '8c7ab3bccf13135f27baf11c161e17ca'

点击绿色的 Go 按钮,以过滤日志。您将看到此跟踪的日志:

图 16.29 – 通过跟踪 ID 过滤日志

图 16.29 – 通过跟踪 ID 过滤日志

图 16.29 提供了关于跟踪的所有日志的全面视图,包括三个服务的 HTTP 请求和响应。这对于故障排除是一个无价资源。

使用 Azure 应用洞察

在前面的章节中,我们探讨了如何使用 OpenTelemetry 收集指标和跟踪信息。我们还讨论了如何利用 Prometheus、Grafana、Jaeger 和 Seq 等开源工具来可视化指标、跟踪和日志。现在,我们将探讨如何使用 Azure 应用洞察为 ASP.NET Core Web API 应用程序创建一个统一的监控仪表板。

Azure 应用洞察是一个可扩展的应用程序性能管理(APM)服务,用于监控应用程序。它可以从多个来源收集和分析日志、指标和跟踪信息。要继续阅读本节,您需要拥有一个 Azure 订阅。如果您还没有,您可以在以下链接创建一个免费账户:azure.microsoft.com/en-us/free/.

前往 Azure 门户并创建一个新的应用洞察资源。您可以在 Monitoring(监控)类别中找到应用洞察服务。选择 Application Insights 服务并点击 Create(创建)按钮。在下一页,您需要指定资源组、名称、区域和定价层,如图 图 16.30 所示:

图 16.30 – 创建应用洞察资源

图 16.30 – 创建 Application Insights 资源

一旦创建了 Application Insights 资源,请导航到概述页面。您将找到** instrumentation key连接字符串值。instrumentation key用于标识 Application Insights 资源,而连接字符串用于连接到 Application Insights 资源。我们将使用连接字符串**来配置 ASP.NET Core Web API 应用程序。

MyWebApiDemo项目中打开appsettings.json文件。添加以下设置:

"APPLICATIONINSIGHTS_CONNECTION_STRING": "InstrumentationKey=xxxxx"

请将连接字符串替换为您自己的值。这仅用于演示目的。建议为不同的环境使用不同的 Application Insights 资源。这将确保指标和跟踪不会混合。

接下来,我们需要使用以下命令安装Azure.Monitor.OpenTelemetry.AspNetCore包:

dotnet add package Azure.Monitor.OpenTelemetry.AspNetCore --prerelease

此包用于将指标和跟踪导出到 Azure Application Insights。在撰写本文时,该包仍在预览中。如果您在包发布后阅读此书,可以省略--prerelease选项。

然后,更新Program.cs文件,如下所示:

builder.Services.AddOpenTelemetry()    // Omitted for brevity
    .UseAzureMonitor()

此方法将从配置系统中读取APPLICATIONINSIGHTS_CONNECTION_STRING设置并将指标和跟踪导出到 Azure Application Insights。

使用相同的方法配置CustomerServiceProductService项目。运行三个应用程序并向/api/Orders端点发送一些POST请求。然后,导航到 Azure 门户中的应用程序洞察资源。您将看到以下输出:

图 16.31 – Azure Application Insights

图 16.31 – Azure Application Insights 概述

您可以找到有关日志、指标和跟踪的更多信息。点击日志选项卡;您将看到以下页面:

图 16.32 – Azure Application Insights 日志

图 16.32 – Azure Application Insights 日志

图 16.32中,我们使用requests | where url !contains "metrics"来查询日志。此查询将过滤掉不包含metrics关键字的日志。您也可以使用traces来查询跟踪。

指标选项卡显示了可用的指标,如下所示:

图 16.33 – Azure Application Insights 指标

图 16.33 – Azure Application Insights 指标

在这里,您可以找到为api/Invoices端点定义的指标。如果您看不到指标,请向api/Invoices端点发送一些请求并等待几分钟。

点击应用程序映射选项卡;您将看到以下页面:

图 16.34 – Azure Application Insights 应用程序映射

图 16.34 – Azure Application Insights 应用程序映射概述

图 16.34显示了跨多个服务的请求流。您还可以找到每个服务的延迟。

在图中点击任何请求,你将看到如下详细信息:响应时间、依赖项数量、性能直方图和依赖项:

图 16.35 – Azure Application Insights 请求详情

图 16.35 – Azure Application Insights 请求详情概述

点击性能选项卡;你将看到应用程序的整体性能:

图 16.36 – Azure Application Insights 性能

图 16.36 – Azure Application Insights 性能概述

点击一个操作,例如POST api/Orders,将允许你查看该操作的性能。要获取更多详细信息,请点击位于屏幕右下角钻入...标签下的xx 样本按钮。你将在屏幕右侧看到该操作的请求列表。点击这些请求之一将允许你查看该请求的详细信息,包括请求和响应体,如图图 16.37 所示:

图 16.37 – Azure Application Insights 端到端事务详情

图 16.37 – Azure Application Insights 端到端事务详情

图 16.37 中,你可以看到请求是如何被多个服务处理的,这与 Jaeger UI 所做的工作类似。

Azure Application Insights 是监控应用程序的超级强大工具。使用 Azure Application Insights 的好处如下:

  • 它是一个托管服务。你不需要维护基础设施。

  • 它提供了一个统一的仪表板来监控应用程序。你不需要使用多个工具。Application Insights 可以提供指标、跟踪和日志的集中视图。

  • 它很容易与你的应用程序集成。配置一个连接字符串比配置多个工具要简单得多。

  • 它提供了一个强大的查询语言来查询指标、跟踪和日志。你可以使用查询语言来创建自定义仪表板。

  • 它提供了更多功能,例如警报、故障分析、漏斗分析、用户流程等。

注意,Azure Application Insights 不是免费的。它是 Azure Monitor 的一部分,是面向企业应用程序的全面监控解决方案。你可以在这里找到其定价信息:azure.microsoft.com/en-us/pricing/details/monitor/

摘要

在本章中,我们讨论了 ASP.NET Core Web API 应用程序中的监控和可观察性。我们探讨了如何处理错误和异常,并返回适当的错误响应。我们还讨论了如何实现健康检查以确定应用程序的状态。然后,我们学习了可观察性的基本概念,包括日志、指标和跟踪,以及如何与 OpenTelemetry 集成并定义自定义指标。我们还探索了一些开源工具,例如 Prometheus、Grafana、Jaeger 和 Seq,用于收集和可视化指标、跟踪和日志。最后,我们介绍了 Azure Application Insights,这是一种用于在单一位置监控应用程序的托管服务。

监控和可观察性是复杂的话题,需要更深入地理解分布式系统和微服务架构。在本章中,我们仅介绍了基本概念。为了更全面地理解这些主题,还需要进一步的学习。

在下一章中,我们将探讨与架构和设计模式相关的先进主题。这些包括领域驱动设计DDD)、清洁架构以及云原生模式,如 CQRS、弹性模式等。这将为您提供一个关于各种架构和设计方法的全面概述。

第十七章:云原生模式

在前面的章节中,我们已经涵盖了使用 ASP.NET Core 开发 Web API 的各种基本技能。我们讨论了不同的 API 开发风格,如 REST、gRPC 和 GraphQL,以及如何使用 Entity Framework Core 实现数据访问层。我们还介绍了如何使用 ASP.NET Core Identity 框架来保护 Web API。此外,我们还学习了如何为 Web API 应用程序编写单元测试和集成测试,以及 API 开发中的常见实践,如测试、缓存、可观察性等。我们还讨论了如何通过 CI/CD 管道将容器化的 Web API 应用程序部署到云端。这些都是 Web API 开发的基本技能。

然而,这仅仅是旅程的开始。随着我们结束对使用 ASP.NET Core 开发 Web API 的基本概念探索,是时候踏上探索更高级主题的旅程了。在本章中,我们将从基础知识过渡到深入探讨对希望掌握 Web API 开发的开发者来说重要的主题。现在,让我们将我们的技能提升到下一个层次。

在本章中,我们将深入探讨以下主题:

  • 领域驱动设计

  • 清洁架构

  • 微服务

  • Web API 设计模式

到本章结束时,你将对这些主题有一个高级的理解,并能够自行进一步探索。

技术要求

本章中的代码示例可以在github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter17找到。

领域驱动设计

术语领域驱动设计,也称为DDD,由埃里克·埃文斯在他的 2003 年出版的书籍《领域驱动设计:软件核心的复杂性处理》中提出。DDD 包含了一系列原则和实践,这些原则和实践专注于领域模型和领域逻辑,帮助开发者管理复杂性并构建灵活且可维护的软件。DDD 不局限于任何特定的技术或框架。你可以在任何软件项目中使用它,包括 Web API 开发。

在埃里克·埃文斯的书中,他定义了 DDD 的三个重要原则:

  • 关注核心领域和领域逻辑

  • 以领域模型为基础进行复杂设计

  • 与技术和领域专家合作,迭代地完善解决领域问题的模型

领域是软件系统所构建的主题区域。领域模型是领域概念模型,它结合了数据和行为。开发者根据领域专家的领域知识构建领域模型。领域模型是软件系统的核心,可以用来解决领域问题。

在以下子节中,我们将介绍 DDD 的基本概念以及如何将其应用于 Web API 开发。请注意,领域驱动设计是一个综合性的主题,无法在一个章节中涵盖。因此,子节的目的不是成为 DDD 的完整指南,而是提供一个 DDD 的高级概述并解释一些 DDD 的关键概念。如果您想了解更多关于 DDD 的信息,可以参考其他资源,例如埃里克·埃文斯的 DDD 书籍。

通用语言

DDD 的一个核心概念是,为了构建一个复杂业务领域的软件系统,我们需要构建通用语言和反映业务领域的领域模型。在领域驱动设计中,软件结构和代码,如类名、类方法等,应与业务领域相匹配。领域术语应嵌入到代码中。当开发者与领域专家交谈时,他们应使用相同的术语。例如,如果我们正在构建一个银行系统的 Web API,我们可能有一个Banking领域。当我们与领域专家讨论需求时,我们可能会听到诸如AccountTransactionDepositWithdrawal等术语。在银行系统中,一个Account对象可以有不同的类型,例如SavingAccountLoanAccountCreditCardAccount等。一个SavingAccount可能有一个Deposit()方法和一个Withdrawal()方法。在系统的代码中,我们应该使用与领域专家相同的术语。

通用语言的运用是 DDD 的一个基本支柱。这种语言在领域专家、开发者和用户之间提供了共同的理解,使他们能够有效地沟通系统需求、设计和实现。通过在代码中自觉地使用通用语言,开发者可以构建一个准确反映业务领域的领域模型。如果没有这一点,代码可能会与业务领域脱节,变得难以管理。

边界上下文

在领域驱动设计(DDD)的领域中,边界上下文的概念至关重要。边界上下文是一个定义领域模型并作为软件系统内责任划分区域的边界。它就像一个语言领土,在这个领土中,特定的模型具有意义和相关性。通过封装对领域的独特理解,边界上下文促进了领域专家和开发者之间沟通的清晰性和精确性。

考虑这样一个场景,我们正在构建一个银行系统的 Web API。如果没有边界上下文,Account这个术语在Banking领域和客户关系管理(CRM)领域可能会有不同的解释。这种歧义可能导致混淆、期望不一致,最终导致对整个系统的理解碎片化。为了避免这种情况,应该使用边界上下文来明确定义领域模型的范围。

在许多情况下,领域由几个子领域组成,每个子领域可能指代业务领域的不同部分,从而创建不同的边界上下文。这些边界上下文通过程序接口相互通信,例如 Web API 和消息队列。

DDD 层

DDD 解决方案通常表示为一个分层架构。每一层都有特定的职责。以下图显示了 DDD 应用的典型层:

图 17.1 – DDD 应用的典型层

图 17.1 – DDD 应用的典型层

在前面的图中,有四个层:

  • 表示层:这一层负责向用户展示数据并接收用户输入。通常,这一层实现为一个用户界面,例如 Web 应用程序、移动应用程序或桌面应用程序。在这本书中,我们主要关注没有用户界面的 Web API 应用程序。在这种情况下,表示层可以是一个消费 Web API 的客户端应用程序。

  • 应用层:这一层负责协调应用程序的活动。它从表示层接收用户输入,调用领域层执行业务逻辑,并将结果返回给表示层。在我们的案例中,应用层是 Web API 应用程序,它从客户端应用程序接收 HTTP 请求,调用领域层执行业务逻辑,并将结果返回给客户端应用程序。

  • SavingAccount类的Deposit方法,领域层中的逻辑并不知道如何将数据保存到数据库中。相反,它只关注使用抽象和接口的Deposit方法业务逻辑。这一层通常包含实体、值对象、聚合、存储库和领域服务。

  • 基础设施层:这一层实现了应用程序的基础设施,例如数据访问、缓存、日志记录、消息传递等。它通常与外部系统作为依赖项集成,例如数据库、消息队列等。在我们的案例中,基础设施层可以包括数据访问层,该层使用 EF Core 来访问数据库。

DDD 主要关注领域和应用层。这是因为 UI 层和基础设施层不是 DDD 特有的,可以使用任何技术或框架来实现。例如,UI 层可以使用 ASP.NET Core MVCBlazorReactWPF 或任何其他平台上的 UI 框架来实现,而核心领域逻辑保持不变。同样,DDD 也不指定数据存储,可以是关系型数据库、NoSQL 数据库或任何其他数据存储。领域层使用仓储模式来访问数据,这与数据存储无关。另一个例子是日志机制,它也不是 DDD 特有的,因为领域层需要记录业务事件,但并不关心所使用的日志系统。

DDD 构建模块

DDD 有一些构建模块,可以用来构建领域模型。这些构建模块包括实体、值对象、聚合、仓储和领域服务。在接下来的小节中,我们将介绍这些构建模块以及如何使用它们来构建领域模型:

实体

如果你已经阅读了前面的章节,你可能对 面向对象编程OOP)和 对象关系映射ORM)有基本的了解。在 OOP 中,一个对象是一个类的实例。对象具有状态和行为。状态由对象属性表示,而行为由对象的方法表示。

在 DDD 中,实体类似于 OOP 中的对象,但不仅如此。实体是一个具有唯一标识符的对象,它由其标识符定义,而不是由其属性定义。通常,实体映射到数据库中的表。

实体的标识符通常由一个 ID 属性表示。ID 属性是不可变的,这意味着一旦设置,就不能更改。ID 属性可以是原始类型,如整数、字符串或 GUID。它也可以是复合键。

如果两个实体具有相同的属性但不同的标识符,它们被认为是不同的实体。

例如,在一个银行系统中,Account 是一个实体。它有一个唯一的标识符,可以用 Id 属性来表示。两个账户不能有相同的 Id 属性。

值对象

值对象是 DDD 中的一种对象类型。它通过其属性来识别,而不是通过唯一的标识符。通常,值对象是不可变的,这意味着一旦创建,其属性就不能被更改。如果两个值对象具有相同的属性,它们被认为是相同的值对象。

例如,Address 是一个值对象。它通过其属性,如 StreetCityStateZipCode 来识别。如果两个地址具有相同的 StreetCityStateZipCode,它们被认为是相同的地址。

聚合

聚合是一组关联的对象,包括实体和值对象,它们被视为数据变更的单位。聚合有一个根实体,这是唯一可以从聚合外部访问的对象。根实体负责维护聚合的一致性和完整性。需要注意的是,如果外部对象需要访问聚合内的对象或修改聚合内的对象,它们必须通过根实体进行。

例如,在一个发票系统中,Invoice 实体是一个聚合根。它包含一个 InvoiceItem 实体的列表,这些是发票的项目。要向发票中添加项目,外部对象必须通过 Invoice 实体,如下面的代码所示:

public class Invoice{
    public int Id { get; private set; } // Aggregate root Id, which should not be changed once it is set
    public DateTime Date { get; set; }
    public InvoiceStatus Status { get; private set; }
    public decimal Total { get; private set; } // The total amount of the invoice, which should be updated when an item is added or removed, but cannot be changed directly
    // Other properties
    public List<InvoiceItem> Items { get; private set; }
    public void AddItem(InvoiceItem item)
    {
        // Add the item to the invoice
        Items.Add(item);
        // Update the invoice total, etc.
        // ...
    }
    public void RemoveItem(InvoiceItem item)
    {
        // Remove the item from the invoice
        Items.Remove(item);
        // Update the invoice total, etc.
        // ...
    }
    public void Close()
    {
        // Close the invoice
        Status = InvoiceStatus.Closed;
    }
}

在前面的示例中,如果我们需要向发票中添加或删除项目,我们必须首先获取 Invoice 实体,然后调用 AddItem()RemoveItem() 方法来添加或删除项目。我们不能直接从 Items 属性添加或删除项目,因为 Items 属性是私有的,并且只能从 Invoice 实体内部访问。这样,领域逻辑就被封装在 Invoice 实体内部,并维护了发票的一致性和完整性。同样,我们也不能直接更改 Total 属性。相反,AddItemRemoveItem 方法可以更新 Total 属性。

存储库

存储库是一个用于访问数据持久层的抽象层。它封装了数据访问逻辑,并提供了一种查询和保存数据的方式。为了确保领域层不依赖于任何特定的数据访问技术,存储库通常实现为一个接口。然后,基础设施层可以使用特定的数据访问技术,如 EF Core 或 Dapper,来实现存储库接口并访问不同的数据源,如关系数据库或 NoSQL 数据库。这种方式将领域层与数据访问技术和数据存储解耦。

以下代码展示了存储库接口的一个示例:

public interface IInvoiceRepository{
    Task<Invoice> GetByIdAsync(Guid id);
    Task<List<Invoice>> GetByCustomerIdAsync(Guid customerId);
    Task AddAsync(Invoice invoice);
    Task UpdateAsync(Invoice invoice);
    Task DeleteAsync(Invoice invoice);
}

我们在第九章中介绍了存储库模式。它不是一个特定的 DDD 模式。然而,它经常在 DDD 中用于解耦领域层和数据访问层。

领域服务

领域服务是一种无状态服务,它包含不属于任何特定实体或值对象的领域逻辑。它通常用于实现涉及多个实体或值对象的复杂领域逻辑。为了访问数据持久层,领域服务可能依赖于一个或多个存储库。此外,它还可能依赖于其他外部服务。这些依赖通过依赖注入机制注入到领域服务中。

例如,在一个银行系统中,TransferService 领域服务负责将资金从一个账户转移到另一个账户的逻辑。为此,它依赖于 AccountRepository 来访问 Account 实体。此外,它可能需要使用外部服务在转账完成后向账户持有人发送通知。如果账户在不同的银行,TransferService 领域服务可能还需要使用外部服务在它们之间转账。

以下代码展示了领域服务的示例:

public class TransferService{
    private readonly IAccountRepository _accountRepository;
    private readonly ITransactionRepository _transactionRepository;
    private readonly INotificationService _notificationService;
    private readonly IBankTransferService _bankTransferService;
    public TransferService(IAccountRepository accountRepository, ITransactionRepository transactionRepository, INotificationService notificationService, IBankTransferService bankTransferService)
    {
        _accountRepository = accountRepository;
        _transactionRepository = transactionRepository;
        _notificationService = notificationService;
        _bankTransferService = bankTransferService;
    }
    public async Task TransferAsync(Guid fromAccountId, Guid toAccountId, decimal amount)
    {
        // Get the account from the repository
        var fromAccount = await _accountRepository.GetByIdAsync(fromAccountId);
        var toAccount = await _accountRepository.GetByIdAsync(toAccountId);
        // Transfer money between the accounts
        fromAccount.Withdraw(amount);
        toAccount.Deposit(amount);
        // Save the changes to the repository
        await _accountRepository.UpdateAsync(fromAccount);
        await _accountRepository.UpdateAsync(toAccount);
        // Create transaction records
        await _transactionRepository.AddAsync(new Transaction
        {
            FromAccountId = fromAccountId,
            ToAccountId = toAccountId,
            Amount = amount,
            Date = DateTime.UtcNow
        });
        await _transactionRepository.AddAsync(new Transaction
        {
            FromAccountId = toAccountId,
            ToAccountId = fromAccountId,
            Amount = -amount,
            Date = DateTime.UtcNow
        });
        // Send a notification to the account holder
        await _notificationService.SendAsync(fromAccount.HolderId, $"You have transferred {amount}to {toAccount.HolderId}");
        await _notificationService.SendAsync(toAccount.HolderId, $"You have received {amount} from{fromAccount.HolderId}");
        // Transfer money between the banks
        // await _bankTransferService.TransferAsync(fromAccount.BankId, toAccount.BankId, amount);
    }
}

上述代码展示了 TransferService 领域服务。它有四个依赖项:IAccountRepositoryITransactionRepositoryINotificationServiceIBankTransferServiceTransferAsync 方法用于将资金从一个账户转移到另一个账户。它首先从 IAccountRepository 获取账户信息,然后在这些账户之间进行转账。之后,它将更改保存到 IAccountRepository 并在 ITransactionRepository 中创建交易记录。最后,它使用 INotificationService 向账户持有人发送通知。

重要注意事项

上述示例为了演示目的而简化了。实际在两个账户之间转账的实现要复杂得多。例如,可能需要检查账户余额、检查每日转账限额等。它还可能需要在不同的银行之间转账,这涉及到处理转账过程中可能发生的任何错误的复杂逻辑。如果发生任何错误,可能需要回滚交易。这是一个典型的实现复杂领域逻辑的领域服务示例。

工作单元

在上述示例中,当在两个账户之间转账时,涉及多个步骤。如果在过程中发生错误怎么办?为了防止在两个账户之间转账过程中任何资金丢失,有必要将这个过程包裹在一个事务中。这将确保在发生错误的情况下,事务将被回滚,资金将保持安全。例如,如果 TransferAsync() 方法在从 fromAccount 提取资金后但在将其存入 toAccount 之前抛出异常,则事务将被回滚,资金不会丢失。

在数据库的上下文中,术语 transaction 经常被使用。这种交易在 DDD 中被称为 工作单元。工作单元是一系列必须作为一个整体执行的操作。工作单元中的所有步骤必须同时成功或失败。如果任何步骤失败,整个工作单元必须回滚。这可以防止数据处于不一致的状态。

工作单元可以以多种方式实现。在许多场景中,工作单元被实现为一个数据库事务。另一个例子是消息队列。当接收到消息时,它作为一个工作单元进行处理。如果处理成功,则从队列中删除消息。否则,消息将保留在队列中,将在稍后时间再次进行处理。

应用服务

应用服务负责管理应用过程。它从表示层接收用户输入,调用领域服务执行业务逻辑,并将结果返回给表示层。在一个 Web API 应用程序中,应用服务可以作为一个 Web API 控制器实现,或者作为一个由 Web API 控制器调用的独立服务。

应用服务应该是薄的,并将大部分工作委托给领域服务。通常,应用服务使用AutoMapper。例如,一个InvoiceDto类可能包含发票的属性,如IdDateStatusTotal等。它没有任何添加或删除发票项目或关闭发票的方法。它纯粹是一个数据容器。如果Invoice实体的某个属性在表示层中不需要,则不应将其包含在InvoiceDto中。

当表示层需要创建或更新实体时,它可以向应用服务发送一个 DTO。然后,应用服务将 DTO 映射到实体,并调用领域服务来执行必要的业务逻辑。最后,应用服务将实体映射回 DTO,并将其返回给表示层。

下面是一个应用服务的简单示例:

[Route("api/[controller]")][ApiController]
public class InvoicesController : ControllerBase
{
    private readonly IInvoiceService _invoiceService;
    public InvoicesController(IInvoiceService invoiceService)
    {
        _invoiceService = invoiceService;
    }
    [HttpPost]
    public async Task<IActionResult> CreateAsync(InvoiceDto invoiceDto)
    {
        var invoice = await _invoiceService.CreateAsync(invoiceDto);
        return Ok(invoice);
    }
    // Omitted other methods
}
public interface IInvoiceService
{
    Task<InvoiceDto> CreateAsync(InvoiceDto invoiceDto);
    // Omitted other methods
}
public class InvoiceService : IInvoiceService
{
    private readonly IInvoiceRepository _invoiceRepository;
    private readonly IMapper _mapper;
    public InvoiceService(IInvoiceRepository invoiceRepository, IMapper mapper)
    {
        _invoiceRepository = invoiceRepository;
        _mapper = mapper;
    }
    public async Task<InvoiceDto> CreateAsync(InvoiceDto invoiceDto)
    {
        var invoice = _mapper.Map<Invoice>(invoiceDto);
        await _invoiceRepository.AddAsync(invoice);
        return _mapper.Map<InvoiceDto>(invoice);
    }
    // Omitted other methods
}

在前面的示例中,IInvoiceService接口定义了应用服务的方法。InvoiceService类实现了IInvoiceService接口。它有两个依赖项:IInvoiceRepositoryIMapperIInvoiceRepository用于访问Invoice实体,而IMapper用于将InvoiceDto映射到Invoice实体,反之亦然。CreateAsync()方法通过控制器从表示层接收InvoiceDto,将其映射到Invoice实体,然后调用IInvoiceRepositoryAddAsync()方法将Invoice实体添加到数据库中。最后,它将Invoice实体映射回InvoiceDto,并将其返回给表示层。

重要注意事项

在前面的示例中,没有领域服务。这是因为创建发票的逻辑很简单。在这种情况下,应用服务层可以直接调用存储库将发票添加到数据库中。然而,如果逻辑更复杂,涉及多个实体或聚合,最好使用领域服务来实现逻辑。

DDD 关注的是如何构建一个反映业务领域的领域模型,以及如何维护领域模型的一致性和完整性。它不是用来生成报告或用户界面的。报告可能需要复杂的查询,这些查询不适合领域模型。在这种情况下,你可能需要使用单独的报表数据库或报表服务。同样,用户界面可能需要以不同于领域模型的方式显示数据。然而,无论数据如何显示,领域模型都应该保持不变。

DDD 可以帮助你管理复杂性并构建一个灵活且可维护的软件系统。但请记住,DDD 不是万能的。通常,DDD 用于复杂的企业领域。开发者必须实现大量的隔离、抽象和封装来维护模型。这可能会导致大量的努力和复杂性。如果你的项目很简单,DDD 可能有点过度。在这种情况下,一个简单的分层架构可能是一个更好的选择。

清洁架构

清洁架构是一种由罗伯特·C·马丁(也称为 Uncle Bob)在其 2017 年出版的书籍《Clean Architecture: A Craftsman’s Guide to Software Structure and Design》中提出的软件架构。它是一种关注关注点分离的分层架构。与 DDD 类似,清洁架构不是一个特定的技术或框架。它是一套可以应用于任何软件项目的原则和实践。

清洁架构也被称为洋葱架构,因为层是以环形排列的,就像洋葱一样。以下图表显示了清洁架构的典型层:

图 17.2 – 清洁架构的典型层

图 17.2 – 清洁架构的典型层

上述图表说明了从外层到内层的依赖关系。在架构的中心是应用核心层,它包含业务逻辑的实体和接口。此外,这一层包含实现接口的领域服务。它不依赖于任何其他层。围绕应用核心层的是基础设施层和 UI 层,它们都依赖于应用核心层。这种架构确保应用核心层不知道数据是如何存储或呈现给用户的。此外,基础设施层和 UI 层可以被替换,而不会影响应用核心层。

清洁架构与领域驱动设计(DDD)有一些相似之处。它们都是关注关注点分离的分层架构。它们都使用依赖注入(或控制反转)来解耦层。DDD 关注领域层,而清洁架构优先考虑将核心业务逻辑从外部依赖中隔离的重要性。关注点分离允许在不影响核心业务逻辑的情况下修改外部组件,使其更容易适应不断变化的需求。

领域驱动设计(DDD)和清洁架构相互补充,可以一起使用。虽然 DDD 指导如何构建领域模型和理解业务领域,但清洁架构提供了一个组织和管理代码库的蓝图。结合这些方法可以导致灵活且易于维护的软件系统。

领域驱动设计(DDD)和清洁架构都是关注业务领域的分层架构。接下来,让我们讨论整个软件系统的架构。在下一节中,我们将介绍微服务,这是一种构建可扩展和可维护软件系统的流行架构。

微服务

许多传统应用程序都是作为单体构建的。单体应用程序作为一个单一单元部署在单个服务器上。单体应用程序易于开发和部署。然而,随着应用程序的增长,维护和扩展变得越来越困难。应用程序中的微小更改可能需要整个应用程序被重建、重新测试和重新部署。此外,如果应用程序的某一部分需要扩展,整个应用程序都必须进行扩展,这并不经济。此外,如果应用程序的某一部分失败,可能会影响整个应用程序。

这就是微服务发挥作用的地方。微服务是一个小型、独立的负责特定业务领域的服务。每个微服务都有自己的数据库和依赖。它可以独立开发、部署和扩展。这些微服务通过程序接口(如 Web API 或消息队列)相互通信。

微服务提供了几个好处:

  • 单一职责:每个微服务负责特定的业务领域。它有自己的依赖和数据库。

  • 弹性和容错性:微服务被设计成具有弹性和容错性。如果一个微服务失败,它不会影响其他微服务。

  • 可扩展性:微服务可以根据需求独立扩展。如果一个微服务有很高的负载,我们可以增加该微服务的实例数量来处理负载。

  • 技术多样性:只要微服务通过标准接口(如 HTTP API 或 gRPC)相互通信,每个微服务都可以使用不同的技术和框架构建。

  • CI/CD:微服务通过允许独立构建、测试和部署单个微服务,简化了 CI/CD 流程,从而最小化对整个系统的干扰。

微服务不是一个新概念;它已经存在了几十年。然而,近年来它变得更加流行,尤其是在云计算兴起之后。云计算为微服务提供了可扩展且成本效益高的基础设施。此外,容器技术,如 Docker 的出现,使得构建和部署微服务变得更加容易。通过使用容器和容器编排工具,如Kubernetes,开发者可以轻松地将微服务构建和部署到云端。编排工具可以根据工作负载自动扩展微服务。这使得构建可扩展且成本效益高的软件系统变得更加容易。

微服务不必局限于其他架构。实际上,它们可以与其他架构结合使用,以创建更健壮和高效的系统。您可以使用层,如 DDD 和清洁架构,来构建每个微服务。通过利用两种架构的优点,组织可以创建一个强大且可靠的系统,以满足其需求。这种方法对于需要高度可扩展性和灵活性的组织尤其有益。

例如,在一个在线购物系统中,我们可能会有以下微服务:

  • 产品服务:这项服务负责管理产品,例如添加新产品、更新产品、删除产品等。它有自己的数据库来存储产品数据。

  • 订单服务:这项服务负责管理订单,例如创建新订单、更新订单、删除订单等。它也有自己的数据库来存储订单数据。

  • 支付服务:这项服务负责处理支付,例如信用卡支付、PayPal 支付等。它有自己的数据库来存储支付数据。它可能还需要与外部支付服务集成,例如 PayPal、Stripe、在线银行服务等。

  • 物流服务:这项服务负责运输产品,例如将产品运送给客户并跟踪运输。它需要与外部物流服务集成,例如联邦快递、联合包裹服务公司等。

  • 通知服务:这项服务负责向客户发送通知,例如发送电子邮件或短信通知等。它需要与外部通知服务集成,例如 SendGrid、Twilio 等。

  • 身份服务:这项服务负责管理用户,例如创建新用户、更新用户、删除用户等。它可能提供第三方身份验证,例如来自微软、谷歌、Facebook 等。

  • 网关服务:这项服务负责将请求路由到适当的微服务。它是系统的入口点。它没有自己的数据库。相反,它根据请求 URL 将请求路由到适当的微服务。它还可以实现速率限制、身份验证、授权等功能。

  • 客户端应用程序:这些是消费微服务的客户端应用程序。它们可以是 Web 应用程序、移动应用程序或桌面应用程序。

每个服务负责特定的业务领域,并且有自己的依赖关系。开发者可以使用不同的技术和框架来构建服务,因为它们通过标准 HTTP API 或 gRPC 进行通信。如果一个服务需要扩展,它可以独立扩展。例如,如果Order服务有很高的负载,我们可以增加Order服务的实例数量来处理负载。这比扩展整个应用程序要经济得多。此外,如果一个服务失败,它不会影响其他服务。例如,如果Payment服务失败,Order服务和Product服务仍然可以工作。它仍然可以接收订单并允许用户查看产品。当Payment服务恢复在线时,它可以处理尚未处理的订单。

微服务在近年来变得越来越流行。然而,它增加了系统的复杂性。在采用微服务之前,你应该仔细考虑它是否适合你的项目。考虑以下微服务面临的挑战:

  • 分布式系统复杂性:微服务是分布式系统。它们比单体应用程序更复杂。例如,如果一个服务需要调用另一个服务,你需要考虑如何处理服务之间的通信以及如何维护数据的一致性。此外,你还需要处理网络故障、部分故障、级联故障等问题。

  • 数据管理:每个微服务都有自己的数据库。这使得维护数据一致性变得困难,因为不支持跨越多个微服务的交易。要从多个微服务查询数据,必须实现分布式查询机制,这可能是一个复杂的过程。

  • 服务发现:在微服务架构中,每个服务都有自己的 URL。它们需要知道其他服务的 URL 才能与之通信。这被称为服务发现。有许多方法可以实现服务发现,例如使用服务注册表、使用服务网格等。容器编排工具,如 Kubernetes,也可以用于实现服务发现,因为它们可以维护微服务的内部服务 URL。

  • 测试:测试微服务架构比测试单体应用程序更复杂。除了单元测试、集成测试和端到端测试之外,你还需要测试微服务之间的通信。

  • 监控:监控微服务架构需要一个精心设计监控系统。你需要监控每个微服务的健康状况以及微服务之间的通信。跟踪机制可以用来跟踪微服务之间的请求。

总结来说,如果你的应用程序很简单,不要通过使用微服务而使其过于复杂。随着应用程序的增长,你可以考虑逐步将其重构为微服务架构。

接下来,让我们讨论一些 Web API 应用程序的常见设计模式。

Web API 设计模式

为了构建一个灵活、可扩展且易于维护的 Web API 应用程序,利用成熟的设计模式是至关重要的。这些模式解决了在 Web API 开发中遇到的常见挑战,并提供了有效的解决方案。Microsoft 的全面指南提供了对这些设计模式的见解,你可以在以下链接中找到更多详细信息:learn.microsoft.com/en-us/azure/architecture/patterns/

这些设计模式不仅限于 ASP.NET Core;它们可以应用于任何 Web API,无论其底层技术或框架。在接下来的子章节中,我们将介绍一些关键的设计模式,概述它们解决的问题、实现细节以及使用时的考虑因素。这些模式涵盖了解决方案设计和实现、消息传递、可靠性等方面,包括以下内容:

  • 命令查询责任 分离CQRS

  • 发布/订阅pub/sub

  • 前端后端BFF

  • 超时

  • 速率限制

  • 重试

  • 电路断路器

CQRS

CQRS 是解决扩展和优化读取和写入操作挑战的有力工具。通过分离处理命令(写入)和查询(读取)的责任,CQRS 使每个操作可以独立优化,从而提高了可扩展性和效率。

传统上,应用程序的数据模型被设计为支持读取和写入操作。然而,读取和写入操作的要求往往不同。读取操作可能执行不同的查询,导致不同的 DTO 模型。写入操作可能需要更新数据库中的多个表。这可能导致一个复杂且难以维护的数据模型。此外,读取操作和写入操作可能具有不同的性能要求。

CQRS 将应用程序的数据模型划分为用于读取和写入的独立模型。这允许使用针对每个操作特定需求定制的不同存储机制和优化。CQRS 使用查询来读取数据,使用命令来更新数据。查询不会改变系统的状态,而命令则会。

为了更好地分离读取和写入操作,CQRS 还可以使用不同的数据存储进行读取和写入。例如,读取存储可以使用多个只读副本的写入存储,这可以提高读取操作的性能。副本必须与写入存储保持同步,这可以通过使用内置的数据库复制功能或事件驱动机制来实现。

以下图展示了典型的 CQRS 架构:

图 17.3 – 典型的 CQRS 架构

图 17.3 – 典型的 CQRS 架构

要在 ASP.NET Core web API 应用程序中实现 CQRS,您可以使用 MediatR 库,这是一个 .NET 中的简单中介者实现。这个库是一个简单的中介者实现,它允许使用中介者模式。中介者模式是一种行为设计模式,它允许对象在不显式引用彼此的情况下进行交互。相反,它们通过中介者进行通信,解耦了对象并提供了更大的灵活性。

以下图展示了使用 MediatR 库的典型 CQRS 架构:

图 17.4 – 使用 MediatR 库的典型 CQRS 架构

图 17.4 – 使用 MediatR 库的典型 CQRS 架构

在前面的图中,中介者负责从业务逻辑层接收命令和查询,然后调用相应的处理器来执行命令和查询。然后,处理器可以使用存储库来访问数据持久层进行读取和写入数据。业务逻辑层不需要知道中介者如何调用处理器。它只需要将命令和查询发送给中介者。这种模式解耦了业务逻辑层和数据持久层。这种模式也使得向多个处理器发送命令和查询变得更加容易。例如,如果我们有一个向客户发送电子邮件通知的命令,并且需要添加文本消息通知,我们只需简单地添加一个新的处理器来处理该命令,而无需更改客户端代码。

您可以在源代码的 /chapter17/CqrsDemo 文件夹中找到一个示例应用程序,该应用程序演示了如何在 ASP.NET Core web API 应用程序中实现 CQRS。

重要提示

示例项目有一个单独的基础设施项目,通过遵循清洁架构来实现数据持久层。当您运行 dotnet ef 命令来添加迁移或更新数据库时,您需要指定启动项目。例如,要添加迁移,您需要导航到 CqrsDemo.Infrastructure 项目并运行以下命令:

dotnet ef migrations add InitialCreate --****startup-project ../CqrsDemo.WebApi

要了解更多关于 dotnet ef 命令的信息,您可以参考以下链接:learn.microsoft.com/en-us/ef/core/cli/dotnet#target-project-and-startup-project

要进行下一步,您可以使用源代码 /chapter17/CqrsDemo/start 文件夹中的项目。此项目包含一个基本的 ASP.NET Core Web API 应用程序,用于管理发票。它包含以下项目:

  • CqrsDemo.WebApi:这是 ASP.NET Core Web API 项目。它包含控制器和应用程序配置。

  • CqrsDemo.Core:这是包含域模型、仓储接口、服务等的核心项目。

  • CqrsDemo.Infrastructure:此项目包含仓储的实现。

实现模型映射

在核心项目中,请注意服务层使用 DTO,如下所示:

public interface IInvoiceService{
    Task<InvoiceDto?> GetAsync(Guid id, CancellationToken cancellationToken = default);
    Task<List<InvoiceWithoutItemsDto>> GetPagedListAsync(int pageIndex, int pageSize, CancellationToken cancellationToken = default);
    Task<InvoiceDto> AddAsync(CreateOrUpdateInvoiceDto invoice, CancellationToken cancellationToken = default);
    Task<InvoiceDto?> UpdateAsync(Guid id, CreateOrUpdateInvoiceDto invoice, CancellationToken cancellationToken = default);
    // Omitted
}

这些方法使用不同的 DTO 类型进行读写。为了将实体映射到 DTO 以及反之亦然,我们可以使用 AutoMapper,这是一个流行的 对象到对象映射器 库。以下代码展示了如何在 InvoiceProfile.cs 文件中配置 AutoMapper

public InvoiceProfile(){
    CreateMap<CreateOrUpdateInvoiceItemDto, InvoiceItem>();
    CreateMap<InvoiceItem, InvoiceItemDto>();
    CreateMap<CreateOrUpdateInvoiceDto, Invoice>();
    CreateMap<Invoice, InvoiceWithoutItemsDto>();
    CreateMap<Invoice, InvoiceDto>();
}

然后,我们可以在 Program.cs 文件中注册 AutoMapper,如下所示:

builder.Services.AddAutoMapper(typeof(InvoiceProfile));

要使用映射器,只需将 IMapper 接口注入到服务层,如下所示:

public class InvoiceService(IInvoiceRepository invoiceRepository, IMapper mapper) : IInvoiceService{
    public async Task<InvoiceDto?> GetAsync(Guid id, CancellationToken cancellationToken = default)
    {
        var invoice = await invoiceRepository.GetAsync(id, cancellationToken);
        return invoice == null ? null : mapper.Map<InvoiceDto>(invoice);
    }
    // Omitted
}

使用 AutoMapper 可以在将实体映射到 DTO 以及反之亦然时节省我们大量时间。接下来,我们可以使用 MediatR 库实现查询和命令。

实现查询

接下来,我们将使用 MediatR 库实现 CQRS 模式。按照以下步骤操作:

  1. 首先,我们需要安装 MediatR NuGet 包。在终端窗口中运行以下命令以安装 MediatR 包:

    MediatR package to the CqrsDemo.Core project and the CqrsDemo.WebApi project.`MediatR` provides the following interfaces:*   `IMediator`: This is the main interface of the `MediatR` library. It can be used to send requests to the handlers. It can also be used to publish events to multiple handlers.*   `ISender`: This interface is used to send a request through the mediator pipeline to be handled by a single handler.*   `IPublisher`: This interface is used to publish a notification or event through the mediator pipeline to be handled by multiple handlers.The `IMediator` interface can be used to send all requests or events. For a clearer indication of the purpose of the request or event, it is recommended to use the `ISender` interface for requests handled by a single handler and the `IPublisher` interface for notifications or events that require multiple handlers.
    
  2. CqrsDemo.Core 项目中创建一个 Queries 文件夹。然后,在 Queries 文件夹中创建一个 GetInvoiceByIdQuery.cs 文件,并包含以下代码:

    public class GetInvoiceByIdQuery(Guid id) : IRequest<InvoiceDto?>{    public Guid Id { get; set; } = id;}
    

    以下代码定义了一个 GetInvoiceByIdQuery 类,该类实现了 IRequest<InvoiceDto?> 接口。此接口用于指示这是一个返回 InvoiceDto 对象的查询。Id 属性用于指定要检索的发票的 ID。

  3. 类似地,在 Queries 文件夹中创建一个 GetInvoiceListQuery.cs 文件,并包含以下代码:

    public class GetInvoiceListQuery(int pageIndex, int pageSize) : IRequest<List<InvoiceWithoutItemsDto>>{    public int PageIndex { get; set; } = pageIndex;    public int PageSize { get; set; } = pageSize;}
    

    注意,GetInvoiceListQuery查询返回一个InvoiceWithoutItemsDto对象列表。这是因为我们在列出发票时不需要发票项。这是一个示例,展示了如何使用不同的 DTO 进行读取和写入。

  4. 接下来,在Queries文件夹中创建一个Handlers文件夹。然后,在Handlers文件夹中创建一个GetInvoiceByIdQueryHandler.cs文件,并包含以下代码:

    public class GetInvoiceByIdQueryHandler(IInvoiceService invoiceService) : IRequestHandler<GetInvoiceByIdQuery, InvoiceDto?>{    public Task<InvoiceDto?> Handle(GetInvoiceByIdQuery request, CancellationToken cancellationToken)    {        return invoiceService.GetAsync(request.Id, cancellationToken);    }}
    

    GetInvoiceByIdQueryHandler类实现了IRequestHandler<GetInvoiceByIdQuery, InvoiceDto?>接口。该接口用于指示此处理程序处理GetInvoiceByIdQuery查询并返回一个InvoiceDto对象。Handle()方法接收GetInvoiceByIdQuery查询并调用IInvoiceServiceGetAsync()方法通过 ID 获取发票。

    IInvoiceService接口可以注入到处理程序中。或者,您可以选择直接将IInvoiceRepository接口注入到处理程序中并在那里实现业务逻辑。最终,这是您的决定在哪里存储逻辑。重要的是要记住,目标是分离业务逻辑和数据持久层。

  5. 同样,在Handlers文件夹中创建一个GetInvoiceListQueryHandler.cs文件,并包含以下代码:

    public class GetInvoiceListQueryHandler(IInvoiceService invoiceService) : IRequestHandler<GetInvoiceListQuery, List<InvoiceWithoutItemsDto>>{    public Task<List<InvoiceWithoutItemsDto>> Handle(GetInvoiceListQuery request, CancellationToken cancellationToken)    {        return invoiceService.GetPagedListAsync(request.PageIndex, request.PageSize, cancellationToken);    }}
    

    现在,我们有两个处理程序来处理GetInvoiceByIdQuery查询和GetInvoiceListQuery查询。接下来,我们需要更新控制器以使用MediatR库。

  6. 使用以下代码更新CqrsDemo.WebApi项目中的InvoicesController.cs文件:

    [Route("api/[controller]")][ApiController]public class InvoicesController(IInvoiceService invoiceService, ISender mediatorSender) : ControllerBase{    // Omitted}
    

    前面的代码将ISender()接口注入到控制器中。您也可以注入IMediator接口。在这个例子中,我们将使用ISender接口向处理程序发送请求。

  7. 使用以下代码更新InvoicesController类的GetInvoice()方法:

    [HttpGet("{id}")]public async Task<ActionResult<InvoiceDto>> GetInvoice(Guid id){    var invoice = await mediatorSender.Send(new GetInvoiceByIdQuery(id));    return invoice == null ? NotFound() : Ok(invoice);}
    

    前面的代码创建了一个包含id参数的GetInvoiceByIdQuery对象。ISender接口将调用GetInvoiceByIdQueryHandler处理程序来处理查询。然后,处理程序将调用IInvoiceServiceGetAsync方法通过 ID 获取发票。因此,控制器与服务层解耦。

  8. 同样,使用以下代码更新InvoicesController类的GetInvoices方法:

    [HttpGet][Route("paged")]public async Task<ActionResultIEnumerableInvoiceWithoutItemsDto>>> GetInvoices(int pageIndex, int pageSize){    var invoices = await mediatorSender.Send(new GetInvoiceListQuery(pageIndex, pageSize));    return Ok(invoices);}
    

    前面的代码创建了一个包含pageIndexpageSize参数的GetInvoiceListQuery对象。ISender接口将调用GetInvoiceListQueryHandler处理程序来处理查询。然后,处理程序将调用IInvoiceServiceGetPagedListAsync()方法来获取发票列表。

  9. 接下来,我们需要在Program.cs文件中按照以下方式注册MediatR

    builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(GetInvoiceByIdQueryHandler).Assembly));
    

    前面的代码在CqrsDemo.Core项目中注册了所有三个MediatR接口和处理程序。

现在,我们使用查询来实现读操作。您可以运行应用程序并测试端点,例如 /api/invoices/{id}/api/invoices/paged。这些端点应该像以前一样工作。

实现命令

接下来,我们将使用命令来实现写操作。按照以下步骤进行:

  1. CqrsDemo.Core 项目中创建一个 Commands 文件夹。然后,在 Commands 文件夹中创建一个 CreateInvoiceCommand.cs 文件,并使用以下代码:

    public class CreateInvoiceCommand(CreateOrUpdateInvoiceDto invoice) : IRequest<InvoiceDto>{    public CreateOrUpdateInvoiceDto Invoice { get; set; } = invoice;}
    

    前面的代码定义了一个实现 IRequest<InvoiceDto> 接口的 CreateInvoiceCommand 类。

  2. Commands 文件夹中创建一个 Handlers 文件夹。然后,在 Handlers 文件夹中创建一个 CreateInvoiceCommandHandler.cs 文件,并使用以下代码:

    public class CreateInvoiceCommandHandler(IInvoiceService invoiceService) : IRequestHandler<CreateInvoiceCommand, InvoiceDto>{    public Task<InvoiceDto> Handle(CreateInvoiceCommand request, CancellationToken cancellationToken)    {        return invoiceService.AddAsync(request.Invoice, cancellationToken);    }}
    
  3. 使用以下代码更新 InvoicesController 类:

    [HttpPost]public async Task<ActionResult<InvoiceDto>> CreateInvoice(CreateOrUpdateInvoiceDto invoice){    var result = await mediatorSender.Send(new CreateInvoiceCommand(invoice));    return CreatedAtAction(nameof(GetInvoice), new { id = result.Id }, result);}
    

    现在,运行应用程序并向 /api/invoices 端点发送 POST 请求。您应该能够创建一个新的发票。

    在这个例子中,我们将不会实现所有命令和查询。您可以作为一个练习来处理剩余的命令和查询。

MediatR 使得在 ASP.NET Core Web API 应用程序中实现 CQRS 模式变得容易。然而,实现 CQRS 的方法不止一种。您也可以在不使用 MediatR 库的情况下实现 CQRS。

使用 MediatR 库的一个好处是它可以向多个处理器发送请求。例如,我们可以创建一个命令来向客户发送电子邮件通知和短信通知。然后,我们可以创建两个处理器来处理该命令。按照以下步骤实现此功能:

  1. 如下所示,将两个属性添加到发票模型中:

    public string ContactEmail { get; set; } = string.Empty;public string ContactPhone { get; set; } = string.Empty;
    

    您需要更新 Invoice 类、CreateOrUpdateInvoiceDto 类、InvoiceWithoutItemsDto 类和 InvoiceDto 类。您还可以定义一个 Contact 类以实现更好的封装。

  2. 添加数据库迁移并更新数据库。您可能还需要更新种子数据。请注意,在运行 dotnet ef 命令时需要指定启动项目。例如,要添加迁移,您需要导航到 CqrsDemo.Infrastructure 项目并运行以下命令:

    dotnet ef migrations add AddContactInfo --startup-project ../CqrsDemo.WebApi
    
  3. 然后,更新数据库:

    dotnet ef database update --startup-project ../CqrsDemo.WebApi
    
  4. CqrsDemo.Core 项目中创建一个 Notification 文件夹。然后,在 Notification 文件夹中创建一个 SendInvoiceNotification 类,并使用以下代码:

    public class SendInvoiceNotification(Guid invoiceId) : INotification{    public Guid InvoiceId { get; set; } = invoiceId;}
    

    前面的代码定义了一个实现 INotification 接口的 SendInvoiceNotification 类。此接口用于指示这是一个不返回任何结果的通知。

  5. Notification 文件夹中创建一个 Handlers 文件夹。然后,在 Handlers 文件夹中创建一个 SendInvoiceEmailNotificationHandler 类,并使用以下代码:

    public class SendInvoiceEmailNotificationHandler(IInvoiceService invoiceService) : INotificationHandler<SendInvoiceNotification>{    public async Task Handle(SendInvoiceNotification notification, CancellationToken cancellationToken)    {        // Send email notification        var invoice = await invoiceService.GetAsync(notification.InvoiceId, cancellationToken);        if (invoice is null || string.IsNullOrWhiteSpace(invoice.ContactEmail))        {            return;        }        // Send email notification        Console.WriteLine($"Sending email notification to {invoice.ContactEmail} for invoice {invoice.Id}");    }}
    

    在前面的代码中,我们使用 IInvocieService 通过 ID 获取发票。然后,我们检查发票是否存在以及是否指定了联系邮箱。如果是这样,我们将向客户发送电子邮件通知。为了简单起见,我们只是在控制台打印一条消息。

  6. 类似地,在Handlers文件夹中创建一个名为SendInvoiceTextMessageNotificationHandler的类,代码如下:

    public class SendInvoiceTextMessageNotificationHandler(IInvoiceService invoiceService) : INotificationHandler<SendInvoiceNotification>{    public async Task Handle(SendInvoiceNotification notification, CancellationToken cancellationToken)    {        // Send text message notification        var invoice = await invoiceService.GetAsync(notification.InvoiceId, cancellationToken);        if (invoice is null || string.IsNullOrWhiteSpace(invoice.ContactPhone))        {            return;        }        // Send text message notification        Console.WriteLine($"Sending text message notification to {invoice.ContactPhone} for invoice {invoice.Id}");    }}
    

    前面的代码与之前的处理器类似。它向客户发送短信通知。

  7. IPublisher接口注入到InvoicesController类中,如下所示:

    public class InvoicesController(IInvoiceService invoiceService, ISender mediatorSender, IPublisher mediatorPublisher) : ControllerBase{    // Omitted}
    

    IPublisher接口用于通过中介管道发布通知或事件,以便由多个处理器处理。

  8. InvoicesController类中更新CreateInvoice方法,代码如下:

    [HttpPost]public async Task<ActionResult<InvoiceDto>> CreateInvoice(CreateOrUpdateInvoiceDto invoiceDto){    //var invoice = await invoiceService.AddAsync(invoiceDto);    var invoice = await mediatorSender.Send(new CreateInvoiceCommand(invoiceDto));    await mediatorPublisher.Publish(new SendInvoiceNotification(invoice.Id));    return CreatedAtAction(nameof(GetInvoice), new { id = invoice.Id }, invoice);}
    

    在前面的代码中,当创建一个新的发票时,我们向IPublisher接口发送一个SendInvoiceNotification通知。IPublisher接口将调用SendInvoiceEmailNotificationHandler处理器和SendInvoiceTextMessageNotificationHandler处理器来处理通知。然后,它们将发送电子邮件通知和短信通知给客户。如果我们需要更多的通知,我们只需简单地添加更多的处理器来处理通知,而无需更改控制器代码。

    运行应用程序,并向/api/invoices端点发送一个POST请求以创建一个新的发票。你应该能够看到控制台中的电子邮件通知和短信通知消息。

这只是一个简单的示例,用于展示如何使用MediatR库来实现 CQRS 模式。CQRS 和MediatR允许我们分离读取和写入关注点,并将业务逻辑层与数据持久化层解耦。你也可以尝试为读取和写入使用不同的数据库,甚至为不同的项目使用不同的数据库。然而,请注意,使用不同的数据库可能会导致数据一致性问题的出现。你可以使用 CQRS 模式与事件源模式结合来维护数据一致性和完整的审计跟踪。我们不会在本书中介绍事件源模式。你可以在以下链接中找到有关事件源模式的更多详细信息:learn.microsoft.com/en-us/azure/architecture/patterns/event-sourcing

接下来,我们将介绍一个用于微服务之间异步通信的流行模式:pub/sub 模式。

Pub/sub

在微服务架构中,微服务通过标准接口,如 HTTP API 或 gRPC,相互通信。有时,一个微服务可能需要以异步方式与其他服务通信。它可能还需要向多个服务广播一个事件。pub/sub 模式可以用来解决微服务之间松散耦合通信的需求。它便于向多个订阅者广播事件或消息,而无需它们直接相互了解。

发布/订阅模式是一种通信模型,它促进了发布者和订阅者之间消息交换,而不需要他们相互了解。它由三个组件组成:发布者、订阅者和消息代理。发布者负责将事件或消息发布到消息代理,然后消息代理将它们分发到订阅者。反过来,订阅者订阅消息代理,接收已发布的事件或消息。这种模式允许发布者和订阅者之间进行异步通信,使他们能够保持相互独立。

许多消息代理都可以用于实现发布/订阅模式。以下是一些流行的消息代理:

  • RabbitMQ:RabbitMQ 是一个开源的、跨平台的消息代理,在微服务架构中广泛使用。它轻量级且易于在本地和云中部署。更多详情,请参阅以下链接:rabbitmq.com/

  • Redis:Redis 是一个开源的内存数据结构存储。它功能多样且性能高。Redis 是各种用例的流行选择,如键值数据库、缓存和消息代理。我们在第十五章中学习了如何将 Redis 用作缓存。它也可以用作消息代理以实现发布/订阅模式。更多详情,请参阅以下链接:redis.io/

  • Apache Kafka:Apache Kafka 是一个开源的、分布式的流事件平台。它是一个可靠且可扩展的消息代理,可用于实现发布/订阅模式。它确保以可扩展、容错和安全的 manner 存储事件流。您可以自行管理,也可以使用各种云提供商提供的托管服务。更多详情,请参阅以下链接:kafka.apache.org/

  • Azure Service Bus:Azure Service Bus 是 Microsoft Azure 提供的一个完全托管的面向企业的消息代理。它支持消息队列和主题。更多详情,请参阅以下链接:learn.microsoft.com/en-us/azure/service-bus-messaging/

发布/订阅模式将微服务彼此解耦。它还提高了可伸缩性和可靠性。所有消息或事件都以异步方式处理。这有助于服务在负载增加或某个服务失败的情况下继续运行。然而,这也增加了系统的复杂性。您需要管理消息排序、消息优先级、消息重复、消息过期、死信队列等。要了解更多关于发布/订阅模式的信息,您可以参考以下链接:learn.microsoft.com/en-us/azure/architecture/patterns/publisher-subscriber

前端后端

前端后端BFFs)解决了高效服务于具有不同要求的多样化客户端接口的挑战。当应用程序需要为多种客户端类型提供服务,如 Web、移动和桌面时,这非常有用。每种客户端类型可能需要不同的数据格式。在这种情况下,单体后端可能难以满足每个客户端的独特需求。具体来说,如果后端包含多个微服务,每个微服务可能需要提供多个端点来服务于不同的客户端类型。这可能导致一个复杂且低效的系统。

BFF 架构是针对需要为多种客户端类型提供服务(如 Web、移动和桌面)的应用程序的有用解决方案。每种客户端类型可能对数据格式有独特的要求,这在使用单体后端时可能难以管理。如果后端包含多个微服务,每个微服务可能需要提供多个端点来服务于不同的客户端类型,从而导致一个复杂且低效的系统。BFF 可以通过高效地为具有不同要求的多样化客户端接口提供服务来帮助解决这一挑战。

BFF 引入了针对特定前端客户端定制的专用后端服务。每个前端客户端都有其对应的后端,这允许对数据检索、处理和展示进行细粒度控制。这使系统更加高效和灵活,能够更好地满足每个客户端的需求。

下图展示了典型的 BFF 架构:

图 17.5 – 典型的 BFF 架构

图 17.5 – 典型的 BFF 架构

图 17.5中,每个 BFF 服务负责特定的前端客户端。它可以从多个微服务中检索数据并将数据合并成一个响应。每个 BFF 服务都经过微调以满足前端客户端的具体需求。它还说明了每个 BFF 服务如何负责特定的前端客户端。每个 BFF 服务都针对前端客户端的具体要求进行定制。它可以从多个微服务中检索数据并将它们合并成一个响应。

BFFs 应该是轻量级的。它们可以包含客户端特定的逻辑,但不应该包含业务逻辑。BFFs 的主要目的是为每个前端客户端定制数据。然而,这可能会导致代码重复。如果多个前端客户端的数据格式相似,可能不需要 BFFs。

弹性模式

在微服务架构中,弹性和可靠性对于成功系统至关重要。Web API 经常面临不可预测的环境,如网络延迟、瞬态故障、服务不可用、高流量等。为了确保这些 API 具有弹性和可靠性,可以实施几种模式。这些包括重试、速率限制、超时、断路器等。在本节中,我们将讨论如何在 ASP.NET Core Web API 应用程序中使用Polly库来实现这些模式。

你可以在/chapter17/PollyDemo文件夹中找到一个示例项目。该项目包含两个基本的 ASP.NET Core Web API 应用程序:

  • PollyServerWebApi,它作为一个服务器

  • PollyClientWebApi,它也是一个 Web API 应用程序,但同时也作为客户端

我们将使用这两个应用程序来演示如何使用Polly库来实现速率限制、重试、超时和断路器。Polly是一个流行的.NET 弹性库和瞬态故障处理库。你可以在以下链接中找到更多关于Polly的详细信息:www.thepollyproject.org/

要在 ASP.NET Core Web API 应用程序中使用Polly,你需要安装Polly NuGet 包。导航到PollyClientWebApi项目,并在终端窗口中运行以下命令来安装Polly包:

dotnet add package Polly.Core

Polly提供了一个弹性管道构建器来构建弹性管道。弹性管道运行一系列弹性策略。每个策略负责处理特定类型的问题。以下代码展示了如何创建弹性管道构建器:

var pipeline = new ResiliencePipelineBuilder();

接下来,我们将探索Polly提供的几个弹性策略。

超时

超时模式是一种常见的模式,用于处理缓慢或无响应的服务。当一个服务缓慢或无响应时,客户端可能会等待很长时间才收到响应。为了避免这种情况,可以为服务设置超时。如果服务在给定的时间框架内无法响应,客户端可以向用户返回错误,从而避免他们不必要的等待。

第四章中,我们介绍了一个RequestTimeout中间件来设置 ASP.NET Core Web API 应用程序的超时。RequestTimeout中间件应用于需要超时的端点或操作。有时,我们可能需要为特定的方法调用设置超时,例如调用 REST API 或查询数据库。让我们探索其他设置超时的方法。

.NET Core 中的 HttpClient 类提供了超时功能。您可以通过设置 Timeout 属性来为 HttpClient 对象设置超时。以下代码展示了如何为 HttpClient 对象设置超时:

var httpClient = httpClientFactory.CreateClient();httpClient.Timeout = TimeSpan.FromSeconds(10);

上述代码创建了一个 HttpClient 对象并将超时设置为 10 秒。如果服务在 10 秒内没有响应,HttpClient 对象将抛出异常。您可以捕获这个异常并向用户返回错误。

HttpClient 对象设置超时对于简单任务很有用,例如调用 REST API。但是,它不适用于不使用 HttpClient 的更复杂任务,例如数据库查询。对于其他任务,例如数据库查询,您可以使用 CancellationToken 来设置超时。以下代码展示了如何为数据库查询设置超时:

var cancellationToken = new CancellationTokenSource(TimeSpan.FromSeconds(10));var invoice = await invoiceRepository.GetAsync(id, cancellationToken.Token);

上述代码创建了一个 CancellationTokenSource 对象并将超时设置为 10 秒。如果数据库查询在 10 秒内未完成,GetAsync() 方法将抛出异常。这防止了客户端在收到响应之前长时间等待。

有时,可能需要调用多个服务。此外,为每个服务调用设置超时可能很繁琐。为了简化这个过程,我们可以使用 Polly 库来实现超时策略。

Polly 提供了一个超时策略,可用于设置服务超时。按照以下步骤实现超时策略:

  1. PollyServerWebApi 应用程序中创建一个端点以模拟慢速服务。打开 Program.cs 文件并添加以下代码:

    app.MapGet("/api/slow-response", async () =>{    var random = new Random();    var delay = random.Next(1, 20);    await Task.Delay(delay * 1000);    return Results.Ok($"Response delayed by {delay} seconds");});
    

    上述代码定义了一个最小的 API 端点,用于模拟慢速服务。它生成 1 到 20 秒之间的随机延迟。此端点将在延迟后返回响应。这只是一个模拟慢速服务的示例。在实际应用中,服务可能由于网络延迟、高流量等原因而变慢。

  2. PollyClientWebApi 应用程序中创建一个控制器以调用慢速服务。在 Controllers 文件夹中添加一个 PollyController 类,代码如下:

    namespace PollyClientWebApi.Controllers;[Route("api/[controller]")][ApiController]public class PollyController(ILogger<PollyController> logger, IHttpClientFactory httpClientFactory) : ControllerBase{    [HttpGet("slow-response")]    public async Task<IActionResult> GetSlowResponse()    {        var client = httpClientFactory.CreateClient("PollyServerWebApi");        var response = await client.GetAsync("api/slow-response");        var content = await response.Content.ReadAsStringAsync();        return Ok(content);    }}
    

    此控制器使用 IHttpClientFactory 创建 HttpClient 对象。然后,它调用慢速服务并将响应返回给客户端。

  3. 运行这两个应用程序并向 PollyClientWebApi 应用程序的 /api/polly/slow-response 端点发送请求。您应该在 1 到 20 秒的随机延迟后看到响应。

  4. 接下来,我们将使用 Polly 实现超时策略。例如,我们可以将超时设置为 5 秒,这意味着如果服务在 5 秒内没有响应,客户端将返回错误给用户而不是长时间等待。更新 PollyController 类的 GetSlowResponse() 方法如下:

    [HttpGet("slow-response")]public async Task<IActionResult> GetSlowResponse(){    var pipeline = new ResiliencePipelineBuilder().AddTimeout(TimeSpan.FromSeconds(5)).Build();    try    {        var response = await pipeline.ExecuteAsync(async cancellationToken =>            await client.GetAsync("api/slow-response", cancellationToken));        var content = await response.Content.ReadAsStringAsync();        return Ok(content);    }    catch (Exception e)    {        logger.LogError(e.Message);        return Problem(e.Message);    }}
    

    前面的代码使用 Polly 创建一个 ResiliencePipelineBuilder 对象。然后,它添加了一个 5 秒的超时策略。ExecuteAsync() 方法用于执行管道。如果服务在 5 秒内没有响应,ExecuteAsync() 方法将抛出异常。catch 块用于捕获异常并向用户返回错误。

  5. 注意,在 ExecuteAsync() 方法中,将取消令牌传递给 HttpClient 对象的 GetAsync() 方法。如果不这样做,即使发生超时,HttpClient 也会继续等待。尊重来自 Polly 弹性管道的取消令牌非常重要。

  6. 运行两个应用程序并向 PollyClientWebApi 应用程序的 /api/polly/slow-response 端点发送请求。你应该能在 5 秒后看到错误信息。

在前面的示例中,我们在控制器中定义了超时策略。为了重用超时策略,我们可以在 Program.cs 文件中定义一个全局超时策略,然后使用依赖注入将策略注入到控制器中。按照以下步骤实现全局超时策略:

  1. 安装 Polly.Extensions NuGet 包。导航到 PollyClientWebApi 项目,并在终端窗口中运行以下命令以安装 Polly.Extensions 包:

    Program.cs file of the PollyClientWebApi application and add the following code:
    
    

    builder.Services.AddResiliencePipeline("timeout-5s-pipeline", configure =>{    configure.AddTimeout(TimeSpan.FromSeconds(5));});

    
    The preceding code defines a global timeout policy with a timeout of 5 seconds. The policy is named `timeout-5s-pipeline`. You can use any name you like. The `AddResiliencePipeline()` method is used to add the timeout policy to the pipeline.
    
  2. ResiliencePipelineProvider<string> 类注入到 PollyController 类中,如下所示:

    public class PollyController(ILogger<PollyController> logger, IHttpClientFactory httpClientFactory, ResiliencePipelineProvider<string> resiliencePipelineProvider) : ControllerBase{    // Omitted}
    

    ResiliencePipelineProvider<string> 类用于检索全局超时策略。string 类型参数指定了策略名称的类型。

  3. 更新 PollyController 类的 GetSlowResponse() 方法如下:

    var pipeline = resiliencePipelineProvider.GetPipeline("timeout-5s-pipeline");// Omitted
    

    这样,我们可以通过名称重用全局超时策略。

Polly 支持许多其他弹性模式。接下来,让我们讨论速率限制。

速率限制

速率限制模式是一种常用的模式,用于限制可以发送到服务的请求数量。速率应设置为合理的值,以避免过载服务。你可以运行性能测试来确定最佳速率限制。服务的性能取决于许多因素,例如硬件、网络和业务逻辑的复杂性。一旦确定了最佳速率限制,就可以将其应用到服务中,以确保它可以处理工作负载。

例如,如果一个服务在请求数量超过 100 时每秒可以处理 100 个请求,那么当请求数量超过这个限制时,服务可能会变慢甚至不可用。客户端可能会遇到超时错误。为了避免这种情况,我们可以为服务设置速率限制。当请求数量超过速率限制时,服务将拒绝请求并向客户端返回错误。这可以防止服务过载。

ASP.NET Core 提供了一个速率限制中间件,可用于配置各种策略中的速率限制,例如fixed window(固定窗口)、sliding window(滑动窗口)、token bucket(令牌桶)和concurrency(并发)。我们已在第四章中介绍了速率限制中间件。您可以在以下链接中找到有关速率限制中间件的更多详细信息:learn.microsoft.com/en-us/aspnet/core/performance/rate-limit

您可以打开位于/chapter17/PollyDemo/end文件夹中的PollyDemo解决方案。在PollyServerWebApi项目的Program.cs文件中,您可以找到以下代码:

builder.Services.AddRateLimiter(options =>{
    options.AddFixedWindowLimiter("FiveRequestsInThreeSeconds", limiterOptions =>
    {
        limiterOptions.PermitLimit = 5;
        limiterOptions.Window = TimeSpan.FromSeconds(3);
    });
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
    options.OnRejected = async (context, _) =>
    {
        await context.HttpContext.Response.WriteAsync("Too many requests. Please try later.", CancellationToken.None);
    };
});
// Omitted
app.UseRateLimiter();

速率限制策略应用于WeatherForecastController类:

[EnableRateLimiting("FiveRequestsInThreeSeconds")][ApiController]
[Route("[controller]")]
public class WeatherForecastController(ILogger<WeatherForecastController> logger) : ControllerBase
{
    // Omitted
}

之前的代码配置了一个固定窗口速率限制器,其速率限制为每 3 秒五个请求。当然,这只是一个用于演示目的的示例。当PollyClientWebApi应用程序在 3 秒内向PollyServerWebApi应用程序发送超过五个请求时,PollyServerWebApi应用程序将向客户端返回429 Too Many Requests错误。使用OnRejected回调来处理被拒绝的请求。在这个例子中,我们只是向客户端返回一条消息。

使用dotnet run命令运行PollyServerWebApi应用程序和PollyClientWebApi应用程序。然后,向PollyClientWebApi应用程序的/weatherforecast端点发送每 3 秒超过五个请求。您应该在PollyClientWebApi应用程序中看到429 Too Many Requests错误。这样,我们可以限制对PollyServerWebApi服务的请求数量,以便它可以在不过载的情况下处理工作负载。

我们还可以使用Polly来实现速率限制模式。按照以下步骤使用Polly实现速率限制模式:

  1. 通过在终端窗口中运行以下命令为PollyClientWebApi项目安装Polly.RateLimiting NuGet 包:

    Polly.RateLimiting package is a wrapper for the System.Threading.RateLimiting package provided by Microsoft. It also depends on the Polly.Core package. So, if you have not installed the Polly.Core package, it will be installed automatically.
    
  2. PollyServerWebApi应用程序中创建一个/api/normal-response端点以模拟正常服务。打开Program.cs文件并添加以下代码:

    app.MapGet("/api/normal-response", async () =>{    var random = new Random();    var delay = random.Next(1, 1000);    await Task.Delay(delay);    return Results.Ok($"Response delayed by {delay} milliseconds");});
    

    此端点将在 1 到 1000 毫秒之间的随机延迟后返回响应,这意味着在最坏的情况下,它可能需要 1 秒钟才能返回响应。为了限制对此端点的请求数量,我们可以使用PollyClientWebApi应用程序的速率限制策略。

  3. 我们将使用依赖注入来注入速率限制策略以方便使用。在Program.cs中定义以下速率限制策略:

    builder.Services.AddResiliencePipeline("rate-limit-5-requests-in-3-seconds", configure =>{    configure.AddRateLimiter(new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions    { PermitLimit = 5, Window = TimeSpan.FromSeconds(3) }));});
    

    之前的代码定义了一个固定窗口速率限制器,其速率限制为每 3 秒 5 个请求。该策略命名为rate-limit-5-requests-in-3-seconds。您可以使用您喜欢的任何名称。

  4. 在这个例子中,我们为速率限制策略创建了一个单独的Polly管道。你还可以将多个策略组合成一个单一的管道。例如,你可以使用以下代码将速率限制策略和超时策略组合成一个单一的管道:

    builder.Services.AddResiliencePipeline("combined-resilience-policy", configure =>{    configure.AddRateLimiter(        // Omitted    );    configure.AddTimeout(        // Omitted    );    // You can add more policies here});
    
  5. ResiliencePipelineProvider<string>类注入到PollyClientWebApi项目的PollyController类中,如下所示:

    [HttpGet("rate-limit")]public async Task<IActionResult> GetNormalResponseWithRateLimiting(){    var client = httpClientFactory.CreateClient("PollyServerWebApi");    try    {        var pipeline = resiliencePipelineProvider.GetPipeline("rate-limit-5-requests-in-3-seconds");        var response = await pipeline.ExecuteAsync(async cancellationToken =>            await client.GetAsync("api/normal-response", cancellationToken));        var content = await response.Content.ReadAsStringAsync();        return Ok(content);    }    catch (Exception e)    {        logger.LogError($"{e.GetType()} {e.Message}");        return Problem(e.Message);    }}
    

    你会发现代码与超时策略非常相似。

  6. 运行两个应用程序,并在 3 秒内向PollyClientWebApi应用程序的/api/polly/rate-limit端点发送超过 5 个请求。有时,你可能在PollyClientWebApi应用程序的控制台窗口中看到如下错误消息:

    Polly.RateLimiting.RateLimiterRejectedException The operation could not be executed because it was rejected by the rate limiter. It can be retried after '00:00:03'.
    
  7. 同样,你可以使用Polly实现其他速率限制策略,例如滑动窗口并发令牌桶。以下是一个滑动窗口速率限制器的示例:

    configure.AddRateLimiter(new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions{ PermitLimit = 100, Window = TimeSpan.FromMinutes(1) }));
    

    上述代码定义了一个每分钟限制 100 个请求的滑动窗口速率限制器。

  8. 由于 Polly 的RateLimiter是一个可丢弃的资源,因此当它不再需要时销毁它是良好的实践。Polly提供了一个OnPipelineDisposed回调,可以用来销毁RateLimiter对象。例如,我们可以在OnPipelineDisposed回调中销毁RateLimiter对象,如下所示:

    builder.Services.AddResiliencePipeline("rate-limit-5-requests-in-3-seconds", (configure, context) =>{    var rateLimiter = new FixedWindowRateLimiter(new FixedWindowRateLimiterOptions    { PermitLimit = 5, Window = TimeSpan.FromSeconds(3) });    configure.AddRateLimiter(rateLimiter);    // Dispose the rate limiter when the pipeline is disposed    context.OnPipelineDisposed(() => rateLimiter.Dispose());});
    

这样,当管道被销毁时,我们可以销毁RateLimiter对象,以避免不必要的资源消耗。

重试

接下来,让我们讨论429 Too Many Requests错误或500 Internal Server Error错误,它可以在延迟后重试请求,因为错误可能是由暂时性问题引起的,例如速率限制或网络故障。当客户端 API 下次发送请求时,它可能成功。这被称为重试。

重试模式是解决微服务之间通信中短暂失败的一种常见方法。这种模式在微服务架构中尤其有用,因为网络故障或服务的暂时不可用可能导致通信失败。通过实现重试机制,这些短暂问题可以得到管理,从而提高系统的整体可靠性。

按照以下步骤使用Polly实现重试模式:

  1. 按照以下方式更新WeatherForecastController类的Get()方法:

    [HttpGet(Name = "GetWeatherForecast")]public async Task<ActionResult<IEnumerable<WeatherForecast>>> Get(){    var httpClient = httpClientFactory.CreateClient("PollyServerWebApi");    var pollyPipeline = new ResiliencePipelineBuilder()    .AddRetry(new Polly.Retry.RetryStrategyOptions()    {        ShouldHandle = new PredicateBuilder().Handle<Exception>(),        MaxRetryAttempts = 3,        Delay = TimeSpan.FromMilliseconds(500),        MaxDelay = TimeSpan.FromSeconds(5),        OnRetry = args =>        {            logger.LogWarning($"Retry {args.AttemptNumber}, due to: {args.Outcome.Exception?.Message}.");            return default;        }    })    .Build();    HttpResponseMessage? response = null;    await pollyPipeline.ExecuteAsync(async _ =>    {        response = await httpClient.GetAsync("/WeatherForecast");        response.EnsureSuccessStatusCode();    });    if (response != null & response!.IsSuccessStatusCode)    {        var result = await response.Content.ReadFromJsonAsync<IEnumerable<WeatherForecast>>();        return Ok(result);    }    return StatusCode((int)response.StatusCode, response.ReasonPhrase);}
    

    上述代码创建了一个ResiliencePipelineBuilder对象来构建一个弹性管道。然后,它向管道中添加了一个重试策略。如果请求失败,重试策略将重试请求三次。重试之间的延迟为 500 毫秒。MaxDelay属性用于指定最大延迟时间。OnRetry回调用于记录重试尝试。最后,它执行管道以将请求发送到PollyServerWebApi应用程序。

  2. 运行两个应用程序,并在 3 秒内向PollyClientWebApi应用程序的/weatherforecast端点发送超过五个请求。有时,你可能看到请求完成所需的时间更长。这是因为如果请求失败,则会重试请求。你也应该能够在PollyClientWebApi应用程序的控制台窗口中看到重试尝试,如下所示:

    warn: PollyClientWebApi.Controllers.WeatherForecastController[0]      Retry 2, due to: Response status code does not indicate success: 429 (Too Many Requests)..
    

    以这种方式,如果请求失败,我们可以自动重试请求。这可以提高系统的可靠性。

  3. 重试策略可以以多种方式配置。例如,我们可以将重试策略配置为仅在响应状态码为429时重试请求,如下所示:

    ShouldHandle = new PredicateBuilder().Handle<Exception>().Or<HttpRequestException>(ex => ex.StatusCode == HttpStatusCode.TooManyRequests),
    

    我们还可以使用指数退避策略延迟重试尝试。这是因为服务可能过载,重试尝试可能会再次失败。在这种情况下,我们可以延迟重试尝试以避免过载服务。

  4. 要使用指数退避策略,我们可以指定RetryStrategyOptions对象的BackoffType属性,如下所示:

    BackoffType = DelayBackoffType.Exponential,
    

    BackoffType属性是一个DelayBackoffType枚举,可以设置为ConstantLinearExponentialConstant策略将以恒定的延迟延迟重试尝试。Linear策略将以线性延迟延迟重试尝试。Exponential策略将以指数延迟延迟重试尝试。默认策略是Constant

在使用重试模式时,有一些考虑因素:

  • 重试模式应仅用于处理短暂故障。如果你想要实现可重复的操作,你应该使用某种调度机制,例如后台服务,或者合适的工具,如Polly重试来实现计划重复操作。

  • 考虑为不同类型的错误使用不同的重试策略。例如,API 调用可能涉及 HTTP 请求、数据库查询和 JSON 反序列化。如果 HTTP 请求因网络故障而失败,你可以重试请求。然而,如果 JSON 反序列化失败,即使重试 JSON 反序列化方法,成功的可能性也很低。在这种情况下,你可以使用ShouldHandle来指定应该重试的错误类型。

断路器

断路器模式是防止服务过载和失败的有用工具。如果服务变得严重过载,客户端应停止发送请求一段时间,以允许服务恢复。这被称为断路器模式,可以帮助避免服务崩溃或完全失败。

我们可以使用Polly来实现断路器模式。因为我们已经学习了如何使用 Polly 来实现超时模式、速率限制模式和重试模式,你应该能够理解以下步骤:

  1. PollyServerWebApi应用程序中创建一个新的/api/random-failure-response端点以模拟服务过载。打开Program.cs文件并添加以下代码:

    app.MapGet("/api/random-failure-response", () =>{    var random = new Random();    var delay = random.Next(1, 100);    return Task.FromResult(delay > 20 ? Results.Ok($"Response is successful.") : Results.StatusCode(StatusCodes.Status500InternalServerError));});
    

    此端点将以大约 80%的概率(大约)返回一个500 内部服务器错误错误。这只是一个模拟服务过载的例子。在实际应用中,服务可能因高流量、网络延迟等原因而过载。

  2. 将以下代码添加到PollyClientWebApi应用程序的Program.cs文件中:

    builder.Services.AddResiliencePipeline("circuit-breaker-5-seconds", configure =>{    configure.AddCircuitBreaker(new CircuitBreakerStrategyOptions    {        FailureRatio = 0.7,        SamplingDuration = TimeSpan.FromSeconds(10),        MinimumThroughput = 10,        BreakDuration = TimeSpan.FromSeconds(5),        ShouldHandle = new PredicateBuilder().Handle<Exception>()    });});
    

    上述代码定义了一个名为circuit-breaker-5-seconds的断路器策略,其故障率为 0.7。这意味着如果故障率大于 0.7,断路器将打开。SamplingDuration属性用于指定计算故障率所使用的采样持续时间。MinimumThroughput属性表示在采样期间至少必须发出 10 个请求。BreakDuration属性表示如果断路器打开,它将保持打开状态 5 秒钟。ShouldHandle属性用于指定应由断路器处理的错误类型。

  3. PollyClientWebApi应用程序的PollyController类中创建一个新的操作以调用过载服务。添加以下代码:

    [HttpGet("circuit-breaker")]public async Task<IActionResult> GetRandomFailureResponseWithCircuitBreaker(){    var client = httpClientFactory.CreateClient("PollyServerWebApi");    try    {        var pipeline = resiliencePipelineProvider.GetPipeline("circuit-breaker-5-seconds");        var response = await pipeline.ExecuteAsync(async cancellationToken =>            {                var result = await client.GetAsync("api/random-failure-response", cancellationToken);                result.EnsureSuccessStatusCode();                return result;            });        var content = await response.Content.ReadAsStringAsync();        return Ok(content);    }    catch (Exception e)    {        logger.LogError($"{e.GetType()} {e.Message}");        return Problem(e.Message);    }}
    

    上述代码使用result.EnsureSuccessStatusCode()来抛出异常,如果响应状态码不是成功的。由于过载服务有 80%的概率返回错误,断路器在几次请求后会打开。然后,断路器将保持打开状态 5 秒钟。在这段时间内,客户端不会向过载服务发送任何请求。5 秒后,断路器将关闭,客户端将再次向过载服务发送请求。

  4. 运行这两个应用程序并向PollyClientWebApi应用程序的/api/polly/circuit-breaker端点发送超过 10 个请求。有时,你会看到如下500 内部服务器错误错误:

    {  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",  "title": "An error occurred while processing your request.",  "status": 500,  "detail": "Response status code does not indicate success: 500 (Internal Server Error).",  "traceId": "00-c5982555dbf0e66d5ca79fd83aa3837c-46cd1cd7f6acb851-00"}
    
  5. 发送更多请求,你会看到断路器打开并返回不同的错误消息,如下所示:

    {  "type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",  "title": "An error occurred while processing your request.",  "status": 500,  "detail": "The circuit is now open and is not allowing calls.",  "traceId": "00-1b6dc3f8912f5ebd4e67a39a89dd605a-495d67559eaf22b7-00"}
    

    你可以看到错误消息与之前的不同,这表明断路器已打开,因此任何对过载服务的请求都将被拒绝。你需要在发送更多请求到过载服务之前等待 5 秒钟。在这 5 秒钟内,所有对/api/polly/circuit-breaker端点的请求都不会发送到过载服务,而是返回相同的错误消息。

断路器与重试模式不同。重试模式期望操作最终会成功。然而,断路器模式会在操作可能失败时阻止其执行,这样可以节省资源并允许外部服务恢复。您可以将这两种模式结合使用。但请注意,重试逻辑应该检查断路器抛出的异常类型。如果断路器指示操作失败不是暂时性问题,则重试逻辑不应重试该操作。

Polly是一个强大的库,实现了许多弹性模式。本节无法涵盖Polly提供的所有模式。您可以在以下链接中找到更多示例:www.pollydocs.org/index.html.

除了本章讨论的设计模式之外,还有更多针对微服务架构的模式。由于许多这些模式超出了本书的范围,我们将不会详细讨论它们。您可以从 Microsoft Learn 中找到更多关于这些模式的信息:learn.microsoft.com/en-us/azure/architecture/patterns/.

摘要

在本章中,我们探讨了微服务架构的几个概念和模式,包括领域驱动设计、清洁架构、CQRS、pub/sub 和 BFF,以及弹性模式,如超时、速率限制、重试和断路器。这些模式可以帮助我们设计和实现可维护、可靠和可扩展的微服务架构。尽管本章没有涵盖微服务架构的所有模式,但它应该提供了对它们是什么以及如何使用它们的基本理解。这些模式对于希望超越 ASP.NET Core Web API 基本知识的开发者至关重要。

在下一章中,我们将讨论一些可以用来构建 ASP.NET Core Web API 应用程序的开源框架。您可以通过以下链接查看该章节:github.com/PacktPublishing/Web-API-Development-with-ASP.NET-Core-8/tree/main/samples/chapter18.

进一步阅读

要了解更多关于微服务架构的信息,强烈推荐以下来自 Microsoft Learn 的资源:

posted @ 2025-10-21 10:43  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报