-NET8-应用与服务指南-全-

.NET8 应用与服务指南(全)

原文:zh.annas-archive.org/md5/ffd4a2e6d25614378a968c3ad3cad0d1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

有一些编程书籍长达数千页,旨在成为 C#语言、.NET 库以及网站、服务、桌面和移动应用等应用模型的综合参考。

本书与以往不同。它是学习使用.NET 构建应用和服务的各种技术的分步指南。它简洁明了,旨在成为一本轻松愉快的读物,其中包含了每个主题的实用、动手实践教程。虽然这种广泛的叙述牺牲了一些深度,但你仍会发现许多路标,如果你愿意,可以进一步探索。

根据我的经验,学习新技术最难的部分是入门。一旦我理解了最重要的关键概念,并看到了一些实际代码的运行,我就会感到舒适地通过探索官方文档来深入学习。一旦你看到了基础知识是如何正确工作的,你就可以自信地进行自己的实验。

本书最适合那些已经了解 C#和.NET 基础的人。

如果你已经对 C#语言和.NET 库的旧版本有经验,那么我在第一章使用.NET 介绍应用和服务的末尾有一个仅在线的章节,涵盖了 C# 8 和.NET Core 3.1 以及之后的更新内容。

我将指出构建现代用户界面和实现服务时应用模型和框架最重要的方面,这样你就可以参与关于技术和架构选择的讨论,并快速高效地实现它们。

代码解决方案的查找位置

你可以从以下链接的 GitHub 仓库下载或克隆逐步指导任务和练习的解决方案:github.com/markjprice/apps-services-net8

如果你不知道如何做,那么我在第一章使用.NET 介绍应用和服务的末尾提供了如何做到这一点的说明。

本书涵盖内容

引言

第一章使用.NET 介绍应用和服务,是关于设置你的开发环境和使用 Visual Studio 2022、Visual Studio Code 或 JetBrains Rider。你还将了解一些寻找帮助的好地方,以及如何联系(本书的作者)我以获取帮助或提供反馈以改进本书。仅在线部分回顾了现代 C#和.NET 中添加到语言和库中的新功能,如何对你的代码进行性能基准测试,以及如何在编译过程中与反射类型、属性、表达式树和动态生成源代码进行交互。

数据

第二章使用 SQL Server 管理关系型数据,介绍了如何在 Windows 或 Azure 云中使用 SQL 数据库来设置 SQL Server。(一个仅在网络上可用的部分展示了如何在 Windows、macOS 或 Linux 上的 Docker 容器中设置 SQL Server。)然后,您将为名为 Northwind 的虚构组织设置一个示例数据库。您将学习如何使用 ADO.NET 库(Microsoft.Data.SqlClient)以最低级别进行读写,以实现最佳性能,然后使用名为 Dapper 的对象到数据存储映射技术来简化开发。

第三章使用 EF Core 为 SQL Server 构建实体模型,介绍了使用名为 Entity Framework CoreEF Core)的高级对象到数据存储映射技术。您将为在 第二章 中创建的 Northwind 数据库创建类库来定义 EF Core 模型。这些类库随后将在许多后续章节中使用。

第四章使用 Azure Cosmos DB 管理非关系型数据,介绍了云原生非 SQL 数据存储 Azure Cosmos DB。您将学习如何使用其原生 API 进行读写。一个仅在网络上可用的部分还涵盖了更专业的基于图论的 Gremlin API。

图书馆

第五章多任务和并发,展示了如何通过使用线程和任务允许同时执行多个操作,从而提高性能、可扩展性和用户生产力。

第六章实现流行的第三方库,讨论了允许您的代码执行常见实用任务的类型,例如使用 Humanizer 格式化文本和数字,使用 ImageSharp 操作图像,使用 Serilog 记录,使用 AutoMapper 将对象映射到其他对象,使用 FluentAssertions 进行单元测试断言,使用 FluentValidation 验证数据,以及使用 QuestPDF 生成 PDF。

第七章处理日期、时间和国际化,涵盖了允许您的代码执行常见任务如处理日期和时间、时区以及全球化和本地化数据和应用程序用户界面的类型。为了补充内置的日期和时间类型,我们探讨了使用更好的第三方库 Noda Time 的好处。

服务

第八章使用最小 API 构建和保障 Web 服务,介绍了使用 ASP.NET Core 最小 API 构建 Web 服务的最简单方法。这避免了控制器类的需求。您将学习如何使用原生 AOT 发布来提高启动时间和资源。然后,您将学习如何使用速率限制、CORS、身份验证和授权来保护和保障 Web 服务。您将探索使用 Visual Studio 2022 中的新 HTTP 编辑器和 Visual Studio Code 的 REST 客户端扩展来测试 Web 服务的方法。一个仅在网络上可用的部分介绍了使用 Open Data ProtocolOData)快速公开数据模型的服务构建。

第九章缓存、队列和弹性后台服务,介绍了服务架构设计,向服务添加功能以提高可伸缩性和可靠性,如缓存和队列,如何处理短暂问题,以及通过实现后台服务来实现长运行服务。

第十章使用 Azure Functions 构建无服务器纳米服务,为您介绍了 Azure Functions,它可以在执行时仅需要服务器端资源。当它们被队列中发送的消息、上传到存储的文件或定期计划的时间触发时,它们会执行。

第十一章使用 SignalR 广播实时通信,为您介绍了 SignalR,这是一种使开发者能够创建具有多个客户端并能实时向所有客户端或其子集广播消息的服务的技术。例如,需要即时更新信息(如股价)的通知系统和仪表板。

第十二章使用 GraphQL 组合数据源,介绍了构建提供简单单个端点以从多个源公开数据的服务。您将使用 ChilliCream GraphQL 平台来实现该服务,其中包括 Hot Chocolate。本版新增了如何实现分页、过滤、排序和订阅。

第十三章使用 gRPC 构建高效的微服务,介绍了使用高效的 gRPC 标准构建微服务。您将了解用于定义服务合同的.proto文件格式和用于消息序列化的 Protobuf 二进制格式。您还将学习如何通过 gRPC JSON 转码使网络浏览器能够调用 gRPC 服务。本版新增了如何通过原生 AOT 发布改进 gRPC 服务的启动时间和内存占用,处理自定义数据类型(包括非支持类型如 decimal),以及实现拦截器和处理故障。

应用程序

第十四章使用 ASP.NET Core 构建 Web 用户界面,是关于使用 ASP.NET Core MVC 构建 Web 用户界面。您将学习 Razor 语法、标签助手和 Bootstrap,以快速进行用户界面原型设计。

第十五章使用 Blazor 构建 Web 组件,是关于如何使用.NET 8 中引入的新统一全栈托管 Blazor 来构建用户界面组件。Blazor 组件现在可以单独配置,在同一个项目中以客户端和服务器端执行。在需要与浏览器功能(如本地存储)交互时,您将学习如何执行 JavaScript 互操作。一个可选的仅在网络上提供的部分,利用开源 Blazor 组件库,介绍了 Blazor 组件的一些流行开源库。

第十六章使用 .NET MAUI 构建移动和桌面应用程序,将向您介绍如何为 Android、iOS、macOS 和 Windows 构建跨平台移动和桌面应用程序。您将学习 XAML 的基础知识,它可以用来定义图形应用程序的用户界面。一个仅在网络上提供的部分,为 .NET MAUI 实现模型-视图-视图模型,涵盖了使用模型-视图-视图模型进行图形应用程序架构和实现的最佳实践。您还将看到使用 MVVM Toolkit 和 .NET MAUI 社区工具包的好处。另一个仅在网络上提供的部分,将 .NET MAUI 应用程序与 Blazor 和原生平台集成,涵盖了构建充分利用其运行操作系统的混合原生和 Web 应用程序。您将集成原生平台功能,如系统剪贴板、文件系统、检索设备和显示信息以及弹出通知。对于桌面应用程序,您将添加菜单并管理窗口。

结论

后记,描述了您学习更多关于使用 C# 和 .NET 构建应用程序和服务的选项,以及您应该学习以成为全面的专业 .NET 开发者的工具和技能。一个仅在网络上提供的部分,介绍调查项目挑战,记录了读者可以尝试实现并发布到公共 GitHub 仓库以获得作者和其他读者反馈的调查/投票软件解决方案的产品需求。

附录测试您的知识问题的答案,包含了每章末尾的测试问题的答案。

您可以在以下链接中阅读附录:github.com/markjprice/apps-services-net8/blob/main/docs/B19587_Appendix.pdf

您需要这本书的哪些内容

您可以使用 Visual Studio 2022、Visual Studio Code 以及大多数操作系统上的命令行工具(包括 Windows、macOS 和许多 Linux 变体)开发和部署 C# 和 .NET 应用程序和服务。您只需要支持 Visual Studio Code 的操作系统和互联网连接,就可以完成这本书。如果您更喜欢使用 JetBrains Rider 这样的第三方工具,那么您也可以。

下载本书中的彩色图像

我们还为您提供了一个包含本书中使用的截图和图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。

您可以从 packt.link/gbp/9781837637133 下载此文件。

习惯用法

在这本书中,您将找到几种不同的文本样式,用于区分不同类型的信息。以下是这些样式的示例及其含义的解释。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“ControllersModelsViews文件夹包含 ASP.NET Core 类和用于在服务器上执行的.cshtml文件。”

代码块设置如下:

// storing items at index positions 
names[0] = "Kate";
names[1] = "Jack"; 
names[2] = "Rebecca"; 
names[3] = "Tom"; 

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目会被突出显示:

// storing items at index positions 
names[0] = "Kate";
**names[****1****] =** **"Jack"****;** 
names[2] = "Rebecca"; 
names[3] = "Tom"; 

任何命令行输入或输出都写成如下:

dotnet new console 

粗体: 表示新术语、重要的单词或您在屏幕上看到的单词,例如在菜单或对话框中,这些单词在文本中也会这样显示。例如:“点击下一步按钮将您带到下一个屏幕。”

重要提示和指向外部进一步阅读资源的链接出现在这样的框中。

良好实践: 如何像专家一样编程的建议如下。

联系我们

我们始终欢迎读者的反馈。

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

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告这一点,我们将不胜感激。请访问www.packtpub.com/support/errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。

请通过copyright@packt.com与我们联系,并附上材料的链接。

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

想了解更多关于 Packt 的信息,请访问 packt.com

分享您的想法

读完Apps and Services with .NET 8 - 第二版后,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

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

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?

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

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

优惠远不止这些,您将获得独家折扣、新闻通讯以及每天收件箱中的优质免费内容

按照以下简单步骤获取福利:

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

二维码

packt.link/free-ebook/9781837637133

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件

第一章:使用.NET 介绍应用和服务

在本章的第一部分,目标是设置使用 Visual Studio 2022 和 Visual Studio Code 的开发环境,了解构建应用程序和服务的选项;我们将回顾寻找帮助的好地方。

本书 GitHub 仓库中的解决方案使用了完整的应用程序项目来处理所有代码任务:

github.com/markjprice/apps-services-net8/

前往 GitHub 仓库后,只需在键盘上按下.(点)键或将.com更改为.dev,即可将仓库转换为基于 Visual Studio Code 的实时代码编辑器,使用 GitHub Codespaces。

在网页浏览器中使用 Visual Studio Code 与您选择的代码编辑器一起运行,非常适合在完成本书的编码任务时使用。您可以将自己的代码与解决方案代码进行比较,并在需要时轻松复制和粘贴部分代码。

在整本书中,我使用术语现代.NET来指代.NET 8 及其前身,如.NET 6,它们来自.NET Core。我使用术语传统.NET来指代.NET Framework、Mono、Xamarin 和.NET Standard。现代.NET 是这些传统平台和标准的统一体。

本章涵盖了以下主题:

  • 介绍本书及其内容

  • 应用和服务技术

  • 设置您的开发环境

  • 探索顶级程序、函数和命名空间

  • 充分利用本书的 GitHub 仓库

  • 哪里可以获得帮助

介绍本书及其内容

本书面向两个受众:

  • 完成了我的入门书籍《C# 12 和.NET 8 – 现代跨平台开发基础知识》,并希望进一步学习的读者。

  • 已经具备 C#和.NET 基本技能和知识的读者,希望学习实际技能和知识来构建真实世界的应用程序和服务。

继续学习旅程的配套书籍

本书是三部曲中的第二部,继续通过.NET 8 进行学习之旅:

  1. 第一本书涵盖了 C#语言的基础知识、.NET 库以及用于 Web 开发的 ASP.NET Core。它设计为线性阅读,因为早期章节中的技能和知识会逐步积累,并需要理解后续章节。

  2. 第二本书涵盖了更多专业化的主题,如国际化以及流行的第三方包,包括 Serilog 和 NodaTime。您将学习如何使用 ASP.NET Core Minimal APIs 构建原生 AOT 编译的服务,以及如何通过缓存、队列和后台服务来提高性能、可扩展性和可靠性。您将使用 GraphQL、gRPC、SignalR 和 Azure Functions 实现更多服务。最后,您将学习如何使用 Blazor 和.NET MAUI 为网站、桌面和移动应用程序构建图形用户界面。

  3. 第三本书涵盖了专业 .NET 开发人员应该具备的重要工具和技能。这些包括设计模式和解构架构、调试、内存分析、所有重要的测试类型,无论是单元测试、性能测试还是 Web 和移动测试,以及 Docker 和 Azure Pipelines 等托管和部署主题。最后,我们探讨如何准备面试以获得你想要的 .NET 开发人员职业生涯。

.NET 8 三部曲及其最重要的主题的总结如图 1.1 所示:

图 1.1:学习 C# 12 和 .NET 8 的配套书籍

*《.NET 8 高级开发者工具和技能》计划于 2024 年上半年出版。请在您最喜欢的书店留意它,并完成您的 .NET 8 三部曲。

我们为你提供了一个包含本书中使用的截图和图表彩色图像的 PDF 文件。你可以从 packt.link/gbp/9781837637133 下载此文件。

你将在本书中学到的内容

在这一章之后,本书可以分为四个部分:

  1. 数据管理:如何使用 SQL Server 和 Azure Cosmos DB 在本地和云端存储和管理数据。后续章节将使用你在 第三章使用 EF Core 为 SQL Server 构建实体模型 的结尾处创建的 SQL Server 数据库和实体模型。关于 Cosmos DB 的章节使用 SQL API,还有一个关于 Gremlin 的在线章节,Gremlin 是一个图 API。

  2. 专用库:日期、时间和国际化;使用线程和任务提高性能;以及用于图像处理、数据验证规则等的第三方库。这些章节可以像食谱集一样处理。如果你对任何主题不感兴趣,你可以跳过它,并且可以按任何顺序阅读它们。

  3. 服务技术:如何使用 ASP.NET Core Web API Minimal APIs、GraphQL、gRPC、SignalR 和 Azure Functions 构建和保障服务。为了提高服务的可扩展性和可靠性,我们涵盖了队列、缓存和事件调度。还有一个关于 OData 服务的在线章节。

  4. 用户界面技术:如何使用 ASP.NET Core、Blazor 和 .NET MAUI 构建用户界面。

我的 学习哲学

大多数人通过模仿和重复而不是阅读理论的详细解释来最好地学习复杂主题;因此,我不会在本书的每个步骤中提供详细的解释。目的是让你编写一些代码并看到它运行。

你不需要立即了解所有细节。这些将在你构建自己的应用程序并超越任何书籍所能教授的内容的过程中随着时间的推移而逐渐了解。

纠正我的错误

用 1755 年编写英语词典的 Samuel Johnson 的话说,我已犯下“一些荒谬的错误和可笑的荒谬,任何如此多的作品都无法避免”。我对此承担全部责任,并希望你能欣赏我尝试通过撰写关于快速发展的技术(如 C#和.NET)以及你可以用它们构建的应用和服务这本书的挑战。

如果你在这本书的某个地方遇到问题,请在在亚马逊上发表负面评论之前联系我。作者无法回应亚马逊的评论,所以我无法联系你解决问题。我希望帮助你从我的书中获得最佳体验,我希望听取你的反馈并在下一版中做得更好。请通过电子邮件联系我(我的电子邮件地址可以在本书的 GitHub 仓库中找到),在本书的 Discord 频道中与我聊天(packt.link/apps_and_services_dotnet8),或在以下链接处提出问题:github.com/markjprice/apps-services-net8/issues.

在 GitHub 上找到解决方案代码

本书 GitHub 仓库中的所有代码编辑器的解决方案代码可通过以下链接获取:github.com/markjprice/apps-services-net8/tree/main/code.

项目命名和端口编号约定

如果你完成了本书中的所有编码任务,那么你最终将拥有数十个项目。其中许多将是需要端口号在localhost域上托管网站和服务。

对于大型、复杂的项目,导航整个代码可能很困难。因此,良好的项目结构可以让你更容易找到组件。给你的解决方案起一个反映应用程序或解决方案的总体名称是很好的。

在 20 世纪 90 年代,微软将Northwind注册为一个虚构公司名称,用于数据库和代码示例。它最初用作他们 Access 产品的示例数据库,后来也用于 SQL Server。我们将为这个虚构公司构建多个项目,因此我们将使用名称Northwind作为所有项目名称的前缀。

良好实践:有多种方式来组织和命名项目和解决方案,例如使用文件夹层次结构和命名约定。如果你在一个团队中工作,确保你知道你的团队是如何做的。

在解决方案中为你的项目制定命名约定是很好的,这样任何开发者都可以立即知道每个项目的作用。一个常见的做法是使用项目类型,例如,类库、控制台应用程序、网站等,如表 1.1所示:

名称 描述
Northwind.Common 用于跨多个项目中的常见类型,如接口、枚举、类、记录和结构体的类库项目。
Northwind.Common.EntityModels 一个用于常见 EF Core 实体模型的类库项目。实体模型通常在服务器和客户端上都会使用,因此最好将特定数据库提供程序的依赖项分离。
Northwind.Common.DataContext 一个用于 EF Core 数据库上下文的类库项目,依赖于特定的数据库提供程序。
Northwind.Mvc 一个使用 MVC 模式且易于单元测试的复杂网站 ASP.NET Core 项目。
Northwind.WebApi.Service 一个用于 HTTP API 服务的 ASP.NET Core 项目。由于它可以使用任何 JavaScript 库或 Blazor 与服务交互,因此是一个很好的与网站集成的选择。
Northwind.WebApi.Client.Console 一个 Web 服务的客户端。名称的最后部分表示它是一个控制台应用程序。
Northwind.gRPC.Service 一个 ASP.NET Core gRPC 服务的项目。
Northwind.gRPC.Client.Mvc 一个 gRPC 服务的客户端。名称的最后部分表示它是一个 ASP.NET Core MVC 网站项目。
Northwind.BlazorWasm.Client 一个 ASP.NET Core Blazor WebAssembly 客户端项目。
Northwind.BlazorWasm.Server 一个 ASP.NET Core Blazor WebAssembly 服务器端项目。
Northwind.BlazorWasm.Shared 在客户端和服务器端 Blazor 项目之间共享的类库。

表 1.1:常见项目类型的命名约定示例

为了确保您能够同时运行这些项目中的任何一个,我们必须确保我们不配置重复的端口号。我已经使用了以下约定:

https://localhost:5[chapternumber]1/

http://localhost:5[chapternumber]2/

例如,对于在 第十四章 中构建的网站加密连接,即 使用 ASP.NET Core 构建网络用户界面,我使用了端口 5141,如下面的链接所示:

https://localhost:5141/

将警告视为错误

默认情况下,在首次构建项目时,如果代码中存在潜在问题,编译器可能会显示警告,但它们不会阻止编译,并且在重新构建时会被隐藏。警告给出是有原因的,因此忽略警告会鼓励不良的开发实践。

一些开发者可能更喜欢强制修复警告,因此 .NET 提供了一个项目设置来实现这一点,如下面的标记所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
 **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
  </PropertyGroup> 

我已经启用了将警告视为错误的选项(几乎)在 GitHub 仓库中的所有解决方案中。

例外情况是 gRPC 项目。这是由于多种因素的综合作用。在 .NET 7 或更高版本中,如果编译的源文件中类型的名称只包含小写字母,编译器将会发出警告。例如,如果你定义一个 person 类,如下面的代码所示:

public class person
{
} 

引入此编译器警告是为了确保未来的 C# 版本在安全地添加新关键字时不会与您已使用的类型名称冲突,因为只有 C# 关键字应该只包含小写字母。

不幸的是,Google 用于从.proto文件生成 C#源文件的工具为仅包含小写字母的类名生成别名,如下面的代码所示:

#region Designer generated code
using pb = global::Google.Protobuf; 

如果你将警告视为错误,那么编译器会抱怨并拒绝编译源代码,如下面的输出所示:

Error CS8981 The type name 'pb' only contains lower-cased ascii characters. Such names may become reserved for the language. Northwind.Grpc.Service C:\apps-services-net8\Chapter14\Northwind.Grpc.Service\obj\Debug\net8.0\Protos\Greet.cs 

良好实践:在您的.NET 项目中始终将警告视为错误(除非是 gRPC 项目,直到 Google 更新其代码生成工具)。

如果你发现启用此功能后错误太多,你可以通过使用带有逗号分隔的警告代码列表的<WarningsNotAsErrors>元素来禁用特定的警告,如下面的标记所示:

<WarningsNotAsErrors>0219,CS8981</WarningsNotAsErrors> 

更多信息:您可以在以下链接中了解更多关于如何将警告视为错误的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-options/errors-warnings#warningsaserrors-and-warningsnotaserrors

应用和服务技术

微软将用于构建应用程序和服务的平台称为应用模型工作负载

理解.NET

.NET、.NET Core、.NET Framework 和 Xamarin 是开发人员用于构建应用程序和服务的相关且重叠的平台。如果您不熟悉.NET 的历史,那么我将指向以下链接中的每个.NET 概念,该链接来自《C# 12 和.NET 8 – 现代跨平台开发基础》一书:

github.com/markjprice/cs12dotnet8/blob/main/docs/ch01-dotnet-history.md

使用 ASP.NET Core 构建网站和应用程序

网站由多个网页组成,这些网页可以从文件系统静态加载或由服务器端技术(如 ASP.NET Core)动态生成。网络浏览器使用唯一资源定位符URLs)进行GET请求,这些 URL 标识每个页面,并可以使用POSTPUTDELETE请求来操作服务器上存储的数据。

对于许多网站,网络浏览器被视为一个表示层,几乎所有处理都在服务器端进行。客户端可能会使用一些 JavaScript 来实现一些表示功能,例如轮播图,或者使用POSTPUTDELETE请求来执行数据验证。

ASP.NET Core 提供了多种技术来构建网站:

  • ASP.NET Core Razor Pages可以动态生成简单网站的 HTML。

  • ASP.NET Core MVC模型-视图-控制器MVC)设计模式的实现,它对于开发复杂网站非常流行。您将在第十四章“使用 ASP.NET Core 构建 Web 用户界面”中了解如何使用它来构建用户界面。

  • Razor 类库为 ASP.NET Core 项目提供了一种打包可重用功能的方法,包括用户界面组件。

  • Blazor 允许您使用 C# 和 .NET 构建用户界面组件,然后在 Web 浏览器或嵌入式 Web 组件中运行,而不是像 Angular、React 和 Vue 这样的基于 JavaScript 的 UI 框架。您将在第十五章“使用 Blazor 构建 Web 组件”和仅在线部分“利用开源 Blazor 组件库”中详细了解 Blazor。

Blazor 不仅用于构建网站;当与 .NET MAUI 结合使用时,它还可以用于创建混合移动和桌面应用程序。

本书假设你已经熟悉 ASP.NET Core 开发的 fundamentals,因此尽管本书回顾了基础知识,但它很快就会转向中级主题。

构建 Web 和其他服务

尽管没有正式的定义,但服务有时会根据它们的复杂性来描述:

  • 服务: 客户端应用在一个单体服务中需要的所有功能。

  • 微服务: 每个都专注于较小功能集的多个服务。微服务功能边界的指导原则是每个微服务应拥有自己的数据。只有那个微服务应该读取/写入该数据。如果你有一个多个服务访问的数据存储,那么它们就不是微服务。

  • 纳米服务: 作为服务提供的一个单一功能。与 24/7/365 运行的服务和微服务不同,纳米服务通常在需要时才活跃,以减少资源和成本。因此,它们也被称为无服务器服务。

尽管过去十年左右,微服务和无服务器服务的理论使它们成为一种时尚的选择,但最近,随着开发者发现微服务的现实并不总是与理论相符,单体服务在流行度上有所回升。

您将学习如何构建使用 HTTP 作为底层通信技术并遵循 Roy Field 的 REST 架构设计原则的 ASP.NET Core Web API 和 Minimal API Web 服务。您还将学习如何使用 Web 和其他技术构建服务,这些服务扩展了基本 Web API,包括:

  • gRPC: 用于构建高度高效和性能的微服务,支持几乎任何平台。

  • SignalR: 用于实现组件之间的实时通信。

  • GraphQL: 允许客户端控制从多个数据源检索的数据。尽管 GraphQL 可以使用 HTTP,但它不必这样做,并且它不遵循 Roy Field 在其关于 REST API 的论文中定义的 Web 设计原则。

  • Azure Functions: 用于在云中托管无服务器纳米服务。

  • OData: 用于轻松将 EF Core 和其他数据模型包装成 Web 服务。这是一个仅在线的部分。

Windows Communication Foundation

2006 年,Microsoft 发布了.NET Framework 3.0,其中包含一些主要的新框架,其中之一是Windows Communication Foundation (WCF)。它将服务的业务逻辑实现从通信技术基础设施中抽象出来,这样你就可以轻松地在未来切换到替代方案,甚至拥有多个与服务通信的机制。

WCF 大量使用 XML 配置来声明性地定义端点,包括它们的地址、绑定和合约。这被称为 WCF 端点的 ABC。一旦你了解了如何做这件事,WCF 就是一个强大而灵活的技术。

Microsoft 决定不正式将 WCF 移植到现代.NET,但有一个由.NET 基金会管理的社区拥有的开源项目名为CoreWCF。如果您需要将现有的服务从.NET Framework 迁移到现代.NET 或构建 WCF 服务的客户端,则可以使用 CoreWCF。请注意,它永远不能是一个完整的移植,因为 WCF 的部分是 Windows 特定的。

类似于 WCF 的技术允许构建分布式应用程序。客户端应用程序可以向服务器应用程序进行远程过程调用 (RPC)。而不是使用 WCF 的端口来做这件事,我们应该使用像 gRPC 这样的替代 RPC 技术,这在本书中有介绍。

更多信息:您可以在以下链接的 GitHub 仓库中了解更多关于 CoreWCF 的信息:github.com/CoreWCF/CoreWCF。您可以在以下链接中阅读有关使用 System.ServiceModel 6.0 调用 WCF 或 CoreWCF 客户端支持的公告:devblogs.microsoft.com/dotnet/wcf-client-60-has-been-released/

常见服务原则

最重要的服务架构原则之一是使方法调用变得厚重而不是琐碎。换句话说,尽量将操作所需的所有数据捆绑在一个调用中,而不是需要多个调用来传输所有这些信息。这是因为远程调用的开销是服务最大的负面影响之一。这也是为什么越来越小的服务会极大地负面影响解决方案架构。

服务选择总结

每种服务技术都有其优缺点,这取决于其功能支持,如表 1.2所示:

特性 Web API OData GraphQL gRPC SignalR
客户端可以请求所需的数据
最小 HTTP 版本 1.1 1.1 1.1 2.0 1.1
浏览器支持
数据格式 XML, JSON XML, JSON GraphQL (JSONish) 二进制 多样
服务文档 Swagger Swagger
代码生成 第三方 第三方 第三方 Google Microsoft
缓存 简单 简单 困难 困难 困难

表 1.2:常见服务技术的优缺点

使用这些针对各种场景的建议作为指导,如下所示 表 1.3

场景 建议
公共服务 基于 HTTP/1.1 的服务最适合需要公开访问的服务,特别是如果它们需要从浏览器或移动设备调用。
公共数据服务 OData 和 GraphQL 都是暴露来自不同数据存储的复杂层次数据集的好选择。OData 由微软通过官方.NET 包设计和支持。GraphQL 由 Facebook 设计并由第三方包支持。
服务到服务 gRPC 是为低延迟和高吞吐量通信设计的。gRPC 非常适合轻量级内部微服务,其中效率至关重要。
点对点实时通信 gRPC 对双向流提供了出色的支持。gRPC 服务可以实时推送消息而不需要轮询。SignalR 是为多种实时通信设计的,因此它通常比 gRPC 更容易实现,尽管效率较低。
广播实时通信 SignalR 对向多个客户端广播实时通信提供了很好的支持。
多语言环境 gRPC 工具支持所有流行的开发语言,使 gRPC 成为多语言和平台环境的好选择。
网络带宽受限环境 gRPC 消息使用轻量级消息格式 Protobuf 进行序列化。gRPC 消息始终小于等效的 JSON 消息。
无服务器纳米服务 Azure Functions 不需要 24/7 托管,因此它们是适合通常不需要持续运行的小型服务的良好选择。亚马逊网络服务AWS)Lambdas 是一个替代方案。

表 1.3:服务场景和推荐实现技术

构建仅适用于 Windows 的应用程序

构建仅适用于 Windows 的应用程序的技术,主要用于桌面,包括:

  • Windows 窗体,2002

  • Windows 表现层基础WPF),2006

  • Windows 商店,2012

  • 通用 Windows 平台UWP),2015

  • Windows 应用 SDK(以前称为WinUI 3Project Reunion),2021

理解遗留的 Windows 应用程序平台

1985 年,随着 Microsoft Windows 1.0 的发布,创建 Windows 应用程序的唯一方式是使用 C 语言并调用名为KERNELUSERGDI的三个核心 DLL 中的函数。一旦 Windows 在 Windows 95 中成为 32 位,DLLs 被添加了 32 后缀,并被称为Win32 API

1991 年,微软推出了 Visual Basic,它为开发者提供了一种视觉化的、从控件工具箱中拖放的方式来构建 Windows 应用程序的用户界面。它非常受欢迎,并且 Visual Basic 运行时至今仍然是 Windows 11 的一部分。

在 2002 年首次发布 C# 和 .NET Framework 时,微软提供了名为 Windows 表单 的技术来构建 Windows 桌面应用程序。当时用于 Web 开发的技术被称为 Web 表单,因此名称互补。代码可以用 Visual Basic 或 C# 语言编写。Windows 表单有一个类似的拖放可视化设计器,尽管它生成 C# 或 Visual Basic 代码来定义用户界面,这对于人类来说可能难以理解和直接编辑。

2006 年,微软发布了一种用于构建 Windows 桌面应用程序的更强大技术,名为 Windows 表现基金会 (WPF),作为 .NET Framework 3.0 的关键组件之一,与 WCF 和 Windows 工作流 (WF) 并列。

尽管可以通过仅编写 C# 语句来创建 WPF 应用程序,但它也可以使用 可扩展应用程序标记语言 (XAML) 来指定其用户界面,这对于人类和代码来说都很容易理解。Visual Studio 2022 部分是基于 WPF 构建的。

2012 年,微软发布了 Windows 8,并附带其 Windows Store 应用程序,这些应用程序在受保护的沙盒中运行。

2015 年,微软发布了 Windows 10,并更新了名为 通用 Windows 平台 (UWP) 的 Windows Store 应用程序概念。UWP 应用程序可以使用 C++ 和 DirectX UI、JavaScript 和 HTML 或 C#(使用非跨平台的现代 .NET 的自定义分支)来构建。UWP 应用程序可以访问底层 WinRT API。

UWP 应用程序只能在 Windows 10 或 Windows 11 平台上运行,不能在 Windows 的早期版本上运行,但 UWP 应用程序可以在配备运动控制器的 Xbox 和 Windows 混合现实头戴式设备上运行。

许多 Windows 开发者拒绝了 Windows Store 和 UWP 应用程序,因为他们对底层系统的访问有限。微软最近创建了 Project ReunionWinUI 3,这两个项目协同工作,允许 Windows 开发者将现代 Windows 开发的部分好处带到他们的现有 WPF 应用程序中,并使他们能够享有与 UWP 应用程序相同的利益和系统集成。这一举措现在被称为 Windows App SDK

更多信息:本书不涵盖 Windows App SDK,因为它不是跨平台的。如果您想了解更多信息,可以从以下链接开始:learn.microsoft.com/en-us/windows/apps/windows-app-sdk/

理解现代 .NET 对遗留 Windows 平台的支持

.NET SDK 对于 Linux 和 macOS 的磁盘大小约为 330 MB。.NET SDK 对于 Windows 的磁盘大小约为 440 MB。这是因为它包括 .NET 桌面运行时,它允许遗留 Windows 应用程序平台 Windows Forms 和 WPF 在现代 .NET 上运行。

使用 Windows Forms 和 WPF 构建的许多企业应用程序需要维护或增强新功能,但直到最近,它们都卡在 .NET Framework 上,这是一个现在的遗留平台。随着现代 .NET 和其 .NET Desktop Runtime,这些应用程序现在可以使用 .NET 的全部现代功能。Windows 桌面应用程序开发者还可以选择安装 Windows 兼容包。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/core/porting/windows-compat-pack

构建跨平台移动和桌面应用程序

有两个主要的移动平台,苹果的 iOS 和谷歌的 Android,每个都有自己的编程语言和平台 API。还有两个主要的桌面平台,苹果的 macOS 和微软的 Windows,每个都有自己的编程语言和平台 API,如下所示:

  • iOS:Objective-C 或 Swift 和 UIkit

  • Android:Java 或 Kotlin 和 Android API

  • macOS:Objective-C 或 Swift 和 AppKit 或 Catalyst

  • Windows:C、C++ 或许多其他语言和 Win32 API 或 Windows App SDK

可以一次性为 .NET Multi-platform App UI (MAUI) 平台构建跨平台移动和桌面应用程序,然后可以在许多移动和桌面平台上运行。

.NET MAUI 通过共享用户界面组件以及业务逻辑,使开发这些应用程序变得容易;它们可以针对与控制台应用程序、网站和 Web 服务相同的 .NET API。

应用程序可以独立存在,但它们通常调用服务以提供跨越所有计算设备(从服务器和笔记本电脑到手机和游戏系统)的体验。

.NET MAUI 支持现有的 MVVM 和 XAML 模式。团队还计划在未来添加对 C# 的 Model-View-Update (MVU) 的支持,类似于苹果的 SwiftUI。使用 Comet 的 MVU 仍然只是一个概念验证。它不如 SwiftUI 成熟或得到良好的支持。我不会在本书中介绍它。您可以在以下链接中了解更多信息:github.com/dotnet/Comet

.NET MAUI 替代方案

在微软创建 .NET MAUI 之前,第三方创建了开源项目,以使 .NET 开发者能够使用 XAML 构建跨平台应用程序,名为 UnoAvalonia

理解 Uno 平台

Uno 被称为“第一个真正的单源、跨平台应用程序的 C# & XAML、免费和开源平台”,正如其官方网站上所述,以下链接:platform.uno/

开发者可以在原生移动、Web 和桌面之间重用 99% 的业务逻辑和 UI 层。

Uno 平台使用的是 Xamarin 本地平台,而不是 Xamarin.Forms。对于 WebAssembly,Uno 使用与 Blazor WebAssembly 相同的 mono-wasm 运行时。对于 Linux,Uno 使用 Skia 在画布上绘制用户界面。

可以在以下链接找到一本关于 Uno 平台的书:www.packtpub.com/product/creating-cross-platform-c-applications-with-uno-platform/9781801078498

理解 Avalonia

Avalonia “是一个跨平台的 UI 框架,适用于 .NET。它创建像素级的、原生应用程序”并且“支持所有主要平台。”Avalonia “是复杂应用程序的受信任 UI 框架”,正如其官方网站首页所述,以下链接:avaloniaui.net/

你可以将 Avalonia 视为 WPF 的精神继承者。WPF、Silverlight 和 UWP 开发者可以继续从他们多年的现有知识和技能中受益。

它被 JetBrains 用于现代化他们的基于 WPF 的旧工具并将它们移植到跨平台。这意味着他们的 C# 代码编辑器可以在 Windows、macOS 和 Linux 上运行。

Avalonia 扩展程序适用于 Visual Studio 2022,并与 JetBrains Rider 深度集成,这使得开发更加容易和高效。

现在我们已经回顾了可用于 .NET 8 的应用程序和服务技术理论,让我们来实际操作,看看如何设置你的开发环境。

设置你的开发环境

在开始编程之前,你需要一个用于 C# 的代码编辑器。微软有一系列代码编辑器和集成开发环境IDEs),其中包括:

  • Windows 系统的 Visual Studio 2022

  • Visual Studio Code 适用于 Windows、Mac 或 Linux

  • 网页或 GitHub Codespaces 的 Visual Studio Code

第三方创建了他们自己的 C# 代码编辑器,例如 JetBrains Rider,它适用于 Windows、Mac 或 Linux,但确实需要付费许可。JetBrains Rider 在经验更丰富的 .NET 开发者中很受欢迎。

警告!虽然 JetBrains 是一家出色的公司,拥有优秀的产品,但 Rider 和 Visual Studio 的 ReSharper 扩展都是软件,所有软件都有错误和古怪的行为。例如,它们可能会在你的 Razor 页面、Razor 视图和 Blazor 组件中显示“无法解析符号”等错误。然而,你可以构建和运行这些文件,因为没有真正的问题。如果你安装了 Unity 支持插件,那么它将抱怨装箱操作,这对于 Unity 游戏开发者来说是一个真正的问题。但在这本书中,我们不会创建任何 Unity 项目,因此装箱警告不适用。

在第 1 章到 15 章,您可以使用 Visual Studio 2022 或跨平台的 Visual Studio Code 和 JetBrains Rider 来构建所有应用程序和服务。在第 16 章,使用 .NET MAUI 构建移动和桌面应用程序;以及其 在线部分实现 .NET MAUI 的模型-视图-视图模型将 .NET MAUI 应用程序与 Blazor 和原生平台集成,尽管您可以使用 Visual Studio Code 来构建移动和桌面应用程序,但这并不容易。目前,Visual Studio 2022 对 .NET MAUI 的支持比 Visual Studio Code 更好。

选择合适的工具和应用程序类型进行学习

使用 C# 和 .NET 构建应用程序和服务时,最好的工具和应用程序类型是什么?

我希望您能够自由选择任何 C# 代码编辑器或 IDE 来完成这本书中的编码任务,包括 Visual Studio Code、Visual Studio 2022,甚至是 JetBrains Rider。

在这本书中,我提供了适用于所有工具的通用说明,这样您就可以使用您偏好的任何工具。

使用 Visual Studio 2022 进行通用开发

Visual Studio 2022 可以创建大多数类型的应用程序,包括控制台应用程序、网站、网络服务、桌面和移动应用程序。

虽然您可以使用 Visual Studio 2022 和 .NET MAUI 项目来编写跨平台移动应用程序,但您仍然需要 macOS 和 Xcode 来编译它。

Visual Studio 2022 仅在 Windows 10 版本 1909 或更高版本、Windows Server 2016 或更高版本以及 64 位版本上运行。17.4 版本是第一个支持原生 Arm64 的版本。

警告! Visual Studio 2022 for Mac 并未官方支持 .NET 8,并且它将在 2024 年 8 月达到生命周期的终点。如果您一直在使用 Visual Studio 2022 for Mac,那么您应该切换到 Visual Studio Code for Mac、JetBrains Rider for Mac,或者在使用虚拟机的情况下在本地计算机上使用 Visual Studio 2022 for Windows,或者在云中使用类似 Microsoft Dev Box 的技术。退休公告可以在此处阅读:devblogs.microsoft.com/visualstudio/visual-studio-for-mac-retirement-announcement/

使用 Visual Studio Code 进行跨平台开发

可供选择的最现代和轻量级的代码编辑器,并且是微软唯一一个跨平台的编辑器是 Visual Studio Code。它可以在所有常见的操作系统上运行,包括 Windows、macOS 以及许多 Linux 变体,包括 红帽企业 LinuxRHEL)和 Ubuntu。

Visual Studio Code 是现代跨平台开发的良好选择,因为它拥有广泛且不断增长的扩展集,支持许多超出 C# 的语言。

由于其跨平台和轻量级特性,它可以在您的应用程序将部署到的所有平台上安装,以便快速修复错误等。选择 Visual Studio Code 意味着开发者可以使用跨平台代码编辑器来开发跨平台应用程序。

Visual Studio Code 对 Web 开发有很强的支持,尽管它目前对移动和桌面开发的支持较弱。

Visual Studio Code 支持 ARM 处理器,因此您可以在苹果硅电脑和树莓派上开发。

Visual Studio Code 是迄今为止最受欢迎的集成开发环境,根据 Stack Overflow 2023 调查,超过 73% 的专业开发者选择了它,您可以在以下链接中阅读调查结果:survey.stackoverflow.co/2023/.

在云端使用 GitHub Codespaces 进行开发

GitHub Codespaces 是一个基于 Visual Studio Code 的完全配置的开发环境,可以在云中托管的环境中启动,并通过任何网络浏览器访问。它支持 Git 仓库、扩展和内置的命令行界面,因此您可以从任何设备进行编辑、运行和测试。

更多信息: 您可以在以下链接中了解更多关于 GitHub Codespaces 的信息:github.com/features/codespaces.

我所使用的

为了编写和测试本书的代码,我使用了以下硬件和软件:

  • Windows 上的 Visual Studio 2022:

    • 惠普 Spectre (Intel) 笔记本电脑上的 Windows 11
  • Visual Studio Code 在:

    • 苹果硅 Mac mini (M1) 上的 macOS

    • 惠普 Spectre (Intel) 笔记本电脑上的 Windows 11

  • JetBrains Rider 在:

    • 惠普 Spectre (Intel) 笔记本电脑上的 Windows 11

    • 苹果硅 Mac mini (M1) 桌面上的 macOS

我希望您也能访问各种硬件和软件,因为看到不同平台上的差异可以加深您对开发挑战的理解,尽管上述任何一种组合都足以学习如何构建实用的应用程序和网站。

入门指南: 《C# 12 和 .NET 8 – 现代跨平台开发基础》一书的第一章包含在线部分,展示了如何使用各种代码编辑器(如 Visual Studio 2022、Visual Studio Code 或 JetBrains Rider)开始多个项目的操作。您可以在以下链接中阅读这些部分:github.com/markjprice/cs12dotnet8/blob/main/docs/code-editors/README.md.

JetBrains Rider 及其关于装箱的警告

如果您使用 JetBrains Rider 并且已安装 Unity 支持插件,那么它会对装箱问题抱怨很多。装箱发生的一个常见场景是将像intDateTime这样的值类型作为位置参数传递给string格式。这对于 Unity 项目来说是一个问题,因为它们使用与正常.NET 运行时不同的内存垃圾回收器。对于非 Unity 项目,如本书中的所有项目,您可以忽略这些装箱警告,因为它们与您无关。您可以在以下链接中了解更多关于此 Unity 特定问题的信息:docs.unity3d.com/Manual/performance-garbage-collection-best-practices.html#boxing

部署跨平台

您选择的代码编辑器和操作系统不会限制您的代码部署的位置。

.NET 8 支持以下平台进行部署:

  • Windows: Windows 10 版本 1607 或更高版本。Windows 11 版本 22000 或更高版本。Windows Server 2012 R2 SP1 或更高版本。Nano Server 版本 1809 或更高版本。

  • Mac: macOS Catalina 版本 10.15 或更高版本,并在 Rosetta 2 x64 模拟器中。Mac Catalyst 11.0 或更高版本。

  • Linux: Alpine Linux 3.17 或更高版本。Debian 11 或更高版本。Fedora 37 或更高版本。openSUSE 15 或更高版本。Oracle Linux 8 或更高版本。红帽企业 LinuxRHEL)8 或更高版本。SUSE Enterprise Linux 12 SP2 或更高版本。Ubuntu 20.04 或更高版本。

  • Android: API 21 或更高版本。

  • iOStvOS: 11.0 或更高版本。

警告! .NET 对 Windows 7 和 8.1 的支持已于 2023 年 1 月结束:github.com/dotnet/core/issues/7556

.NET 5 及更高版本对 Windows ARM64 的支持意味着您可以在 Windows Arm 设备上开发并部署,例如微软的 Windows Dev Kit 2023(之前称为 Project Volterra)和 Surface Pro X。

您可以在以下链接中查看最新的支持操作系统和版本:github.com/dotnet/core/blob/main/release-notes/8.0/supported-os.md

下载并安装 Visual Studio 2022

许多专业的微软开发者在日常开发工作中使用 Visual Studio 2022。即使您选择使用 Visual Studio Code 来完成本书中的编码任务,您也可能想熟悉一下 Visual Studio 2022。

如果您没有 Windows 电脑,则可以跳过本节,继续到下一节,在那里您将在 macOS 或 Linux 上下载并安装 Visual Studio Code。

自 2014 年 10 月以来,微软已向学生、开源贡献者和个人免费提供 Visual Studio 2022 的专业质量版。它被称为社区版。任何版本都适合本书。如果您尚未安装它,我们现在就安装它:

  1. 从以下链接下载 Visual Studio 2022 版本 17.8 或更高版本:visualstudio.microsoft.com/downloads/.

  2. 启动安装程序。

  3. 工作负载选项卡中,选择以下内容:

    • ASP.NET 和 Web 开发

    • Azure 开发

    • .NET 多平台应用程序用户界面开发

    • .NET 桌面开发(因为这将包括控制台应用程序)

    • 使用 C++ 进行桌面开发(因为这将启用发布启动更快且内存占用更小的控制台应用程序和 Web 服务)

  4. 单个组件选项卡中,在代码工具部分,选择以下内容:

    • Windows 版 Git
  5. 点击安装并等待安装程序获取所选软件并安装。

  6. 安装完成后,点击启动

  7. 第一次运行 Visual Studio 时,你将需要登录。如果你有微软账户,你可以使用该账户。如果没有,请从以下链接注册一个新账户:signup.live.com/.

  8. 第一次运行 Visual Studio 时,你将需要配置你的环境。对于开发设置,选择Visual C#。对于颜色主题,我选择了蓝色,但你也可以选择任何你喜欢的。

  9. 如果你想要自定义你的键盘快捷键,请导航到工具 | 选项…,然后选择环境 | 键盘选项。

Visual Studio 2022 键盘快捷键

在这本书中,我将避免展示键盘快捷键,因为它们通常都是自定义的。当它们在代码编辑器和常用工具中保持一致时,我会尽量展示它们。

如果你想要识别和自定义你的键盘快捷键,那么你可以,如下链接所示:learn.microsoft.com/en-us/visualstudio/ide/identifying-and-customizing-keyboard-shortcuts-in-visual-studio.

下载和安装 Visual Studio Code

Visual Studio Code 在过去几年中迅速改进,并因其受欢迎程度而让微软感到惊喜。如果你敢于尝试并喜欢走在前沿,那么还有内部版本,这是下一个版本的每日构建。

即使你计划只使用 Visual Studio 2022 进行开发,我也建议你下载并安装 Visual Studio Code,并使用它来完成本章的编码任务,然后决定你是否想在本书的剩余部分只使用 Visual Studio 2022。

现在我们来下载和安装 Visual Studio Code、.NET SDK 和 C# 开发工具包扩展:

  1. 从以下链接下载并安装 Visual Studio Code 的稳定版或内部版本:code.visualstudio.com/.

    更多信息:如果你需要更多帮助在任意操作系统上安装 Visual Studio Code,你可以阅读以下链接中的官方设置指南:code.visualstudio.com/docs/setup/setup-overview

  2. 从以下链接下载并安装 8.0 版本的 .NET SDK:www.microsoft.com/net/download

  3. 要使用用户界面安装 C# Dev Kit 扩展,你必须首先启动 Visual Studio Code 应用程序。

  4. 在 Visual Studio Code 中,点击 扩展 图标或导航到 视图 | 扩展

  5. C# Dev Kit 是最受欢迎的扩展之一,所以你应该在列表顶部看到它,或者你可以在搜索框中输入 C# Dev Kit

    C# Dev Kit 依赖于 C# 扩展版本 2.0 或更高版本,因此你不需要单独安装 C# 扩展。请注意,C# 扩展版本 2.0 或更高版本不再使用 OmniSharp,因为它有一个新的 语言服务协议 (LSP) 主机。C# Dev Kit 还依赖于 .NET 扩展作者安装工具IntelliCode for C# Dev Kit 扩展,因此它们也将被安装。

  6. 点击 安装 并等待支持包下载和安装。

    良好实践:务必阅读 C# Dev Kit 的许可协议。它的许可比 C# 扩展更为严格:aka.ms/vs/csdevkit/license

安装其他扩展

在本书的后续章节中,你将使用更多 Visual Studio Code 扩展。如果你现在想安装它们,我们将使用的所有扩展都显示在 表 1.4 中:

扩展名称和标识符 描述
C# Dev Kitms-dotnettools.csdevkit 来自 Microsoft 的官方 C# 扩展。使用解决方案资源管理器管理你的代码,并通过集成的单元测试发现和执行测试你的代码。包括 C#IntelliCode for C# Dev Kit 扩展。
C#ms-dotnettools.csharp 提供包括语法高亮、IntelliSense、转到定义、查找所有引用、.NET 调试支持以及 Windows、macOS 和 Linux 上的 csproj 项目支持的 C# 编辑支持。
IntelliCode for C# Dev Kitms-dotnettools.vscodeintellicode-csharp 为 Python、TypeScript/JavaScript、C# 和 Java 开发者提供人工智能辅助开发功能。
MSBuild 项目工具tintoy.msbuild-project-tools 为 MSBuild 项目文件提供 IntelliSense,包括 <PackageReference> 元素的自动完成。
SQL Server (mssql) for Visual Studio Codems-mssql.mssql 提供丰富的功能,用于在所有地方开发 SQL Server、Azure SQL 数据库和 SQL 数据仓库。
REST 客户端humao.rest-client 在 Visual Studio Code 中发送 HTTP 请求并直接查看响应。
ilspy-vscodeicsharpcode.ilspy-vscode 反编译 MSIL 程序集 – 支持 .NET、.NET Framework、.NET Core 和 .NET Standard。
vscode-proto3zxh404.vscode-proto3 语法高亮、语法验证、代码片段、代码补全、代码格式化、括号匹配和行与块注释。
Azure Functions for Visual Studio Codems-azuretools.vscode-azurefunctions 直接从 VS Code 创建、调试、管理和部署无服务器应用程序。它依赖于 Azure 账户(ms-vscode.azure-account)和 Azure 资源(ms-azuretools.vscode-azureresourcegroups)扩展。

表 1.4:本书中使用的 Visual Studio Code 扩展

在命令提示符下管理 Visual Studio Code 扩展

你可以在命令提示符或终端中安装 Visual Studio Code 扩展,如 表 1.5 所示:

命令 描述
code --list-extensions 列出已安装的扩展。
code --install-extension <extension-id> 安装指定的扩展。
code --uninstall-extension <extension-id> 卸载指定的扩展。

表 1.5:在命令提示符下使用扩展

例如,要安装 C# 开发工具包 扩展,请在命令提示符下输入以下内容:

code --install-extension ms-dotnettools.csdevkit 

我已经创建了 PowerShell 脚本来安装和卸载前面表格中的 Visual Studio Code 扩展。你可以在以下链接中找到它们:github.com/markjprice/apps-services-net8/tree/main/scripts/extension-scripts

了解 Microsoft Visual Studio Code 版本

微软几乎每个月都会发布一个新的 Visual Studio Code 功能版本,并且更频繁地发布错误修复版本。例如:

  • 版本 1.78.0,2023 年 4 月的功能发布

  • 版本 1.78.1,2023 年 4 月的错误修复发布

本书使用的版本是 1.83.0,2023 年 9 月的功能发布版,但 Microsoft Visual Studio Code 的版本不如你安装的 C# 扩展版本重要。

虽然 C# 扩展不是必需的,但它提供在键入时自动完成、代码导航和调试功能,因此安装并保持其更新以支持最新的 C# 语言特性是非常方便的。

Visual Studio Code 快捷键

在本书中,我将避免展示用于创建新文件等任务的键盘快捷键,因为它们在不同的操作系统上通常不同。我将展示键盘快捷键的情况是当你需要重复按键时,例如在调试时。这些情况在不同操作系统之间也更有可能保持一致。

如果你想要自定义 Visual Studio Code 的键盘快捷键,那么你可以,如下链接所示:code.visualstudio.com/docs/getstarted/keybindings

我建议您从以下列表中下载适用于您操作系统的键盘快捷方式 PDF:

消耗 Azure 资源

本书的一些章节将要求您注册 Azure 账户并创建 Azure 资源。通常,这些服务有免费层或本地开发版本,但有时您可能需要创建一个在存在期间会产生费用的资源。

Packt 书籍使用技术审稿人,他们完成所有编码练习,就像读者一样。以下是这本书第二版2E)的一位技术审稿人关于他们的 Azure 成本的评论:“我收到了我的 Azure 账单。在“付费”账户上运行 2E 的成本是 3.01 美元。”

微软目前表示,“符合条件的全新用户在第一个 30 天内将获得相当于您账单货币的 200 美元的 Azure 信用额度,以及 12 个月的限量免费服务,这些服务与您的 Azure 免费账户相关。”您可以在以下链接中了解更多信息:

learn.microsoft.com/en-us/azure/cost-management-billing/manage/avoid-charges-free-account

良好实践:一旦您不再需要 Azure 资源,请立即删除以保持您的成本较低。

表 1.6展示了哪些章节需要 Azure 资源以及是否有本地开发替代方案:

章节 Azure 资源 免费层 本地开发替代方案
2 到 16 SQL 数据库 作为免费第一年的部分。 Windows 上的 SQL Server Developer Edition 或 Windows、Linux 和 macOS 上的 Docker 容器中的 SQL Edge。
4 Cosmos DB 数据库 1,000 RU/s 和 25 GB 的存储空间。 Windows 上的 Azure Cosmos DB 模拟器或 Linux 上的预览版本。
11 Azure 函数 每月 1000 万个请求和 400,000 GB 的资源消耗。 用于测试 Azure Blob、队列存储和表存储应用程序(如 Azure 函数)的 Azurite 开源模拟器。
12 Azure SignalR 服务 每月 20 万个并发连接和每天 20,000 条消息,99.9%的 SLA。 将 SignalR 添加到任何 ASP.NET Core 项目进行本地开发。

表 1.6:使用 Azure 资源和本地开发替代方案的章节

您可以在以下链接中找到如何检查您对免费 Azure 资源的使用的相关信息:learn.microsoft.com/en-us/azure/cost-management-billing/manage/check-free-service-usage.

使用其他项目模板

当您安装.NET SDK 时,包含了许多项目模板。让我们回顾一下它们:

  1. 在命令提示符或终端中,输入以下命令:

    dotnet new list 
    

    .NET 7 及以后的 SDK 支持dotnet new --listdotnet new list。.NET 6 及以前的 SDK 仅支持dotnet new --list

  2. 如果您在 Windows 上运行,您将看到当前安装的模板列表,包括 Windows 桌面开发的模板,其中最常见的是在表 1.7中展示的:

模板名称 简称 语言
.NET MAUI 应用程序 maui C#
.NET MAUI Blazor 应用程序 maui-blazor C#
ASP.NET Core 空项目 web C#,F#
ASP.NET Core gRPC 服务 grpc C#
ASP.NET Core Web API webapi C#,F#
ASP.NET Core Web API(原生 AOT) webapiaot C#
ASP.NET Core Web 应用程序(模型-视图-控制器) mvc C#,F#
Blazor Web 应用程序 blazor C#
类库 classlib C#,F#,VB
控制台应用程序 console C#,F#,VB
EditorConfig 文件 editorconfig
global.json 文件 globaljson
解决方案文件 sln
xUnit 测试项目 xunit

表 1.7:项目模板的全名和简称

.NET MAUI 项目不支持 Linux。团队表示,他们已经将这项工作留给了开源社区。如果您需要创建一个真正跨平台的图形应用程序,那么请查看以下链接中的 Avalonia:avaloniaui.net/

安装额外的模板包

开发者可以安装许多额外的模板包:

  1. 启动浏览器并导航到www.nuget.org/packages

  2. 搜索包…文本框中输入vue,并注意大约返回 210 个包。

  3. 点击筛选器,选择包类型模板,点击应用,并注意大约 25 个可用的模板列表,包括由 Microsoft 发布的一个。

  4. 点击Vue.Simple.Template,然后点击项目网站,并注意安装和使用此模板的说明,如下所示:

    dotnet new --install "Vue.Simple.Template"
    dotnet new simplevue 
    
  5. 关闭浏览器。

探索顶层程序、函数和命名空间

自.NET 6 以来,控制台应用程序的默认项目模板使用了.NET 5 引入的顶层程序功能。了解它与自动生成的Program类及其<Main>$方法的工作方式非常重要。

让我们探索当您定义函数时,顶层程序功能是如何工作的:

  1. 使用您首选的编码工具创建一个新项目,如下列所示:

    • 项目模板:控制台应用程序 / console

    • 项目文件和文件夹:TopLevelFunctions

    • 解决方案文件和文件夹:Chapter01

    • 不要使用顶层语句:已清除

    • 启用原生 AOT 发布:已清除

  2. Program.cs中,删除现有的语句,在文件底部定义一个局部函数,并调用它,如下所示:

    using static System.Console;
    WriteLine("Top-level functions example");
    WhatsMyNamespace(); // Call the function.
    void WhatsMyNamespace() // Define a local function.
    {
      WriteLine("Namespace of Program class: {0}", 
        arg0: typeof(Program).Namespace ?? "null");
    } 
    
  3. 运行控制台应用程序,并注意 Program 类的命名空间为 null,如下所示,输出中已高亮显示:

    Top-level functions example
    Namespace of Program class: null 
    

自动为局部函数生成了什么?

编译器会自动生成一个带有 <Main>$ 函数的 Program 类,然后将你的语句和函数移动到 <Main>$ 方法中,并重命名局部函数,如下所示,代码中已高亮显示:

using static System.Console;
**partial****class****Program**
**{**
**static****void** **<Main>$(String[] args)**
 **{**
    WriteLine("Top-level functions example");
 **<<Main>$>g__WhatsMyNamespace|****0****_0();** **// Call the function.**
**void** **<<Main>$>g__WhatsMyNamespace|****0****_0()** **// Define a local function.**
    {
      WriteLine("Namespace of Program class: {0}", 
        arg0: typeof(Program).Namespace ?? "null");
    }
 **}**
**}** 

为了让编译器知道哪些语句需要放在哪里,你必须遵循一些规则:

  • 导入语句 (using) 必须放在 Program.cs 文件的顶部。

  • 将要放入 <Main>$ 函数中的语句必须放在 Program.cs 文件的中间。任何函数都将成为 <Main>$ 方法中的 局部函数

最后一点很重要,因为局部函数有一些限制,例如,它们不能有 XML 注释来文档化。

在单独的 Program 类文件中编写静态函数

更好的方法是,将任何函数写入单独的类文件,并将它们定义为 Program 类的 static 成员:

  1. 添加一个名为 Program.Functions.cs 的新类文件。名称没有影响,但这是一个好习惯,这样可以使文件与 Program.cs 类文件相关联。

  2. Program.Functions.cs 文件中,定义一个 partial Program 类,然后将 WhatsMyNamespace 函数剪切并粘贴到 Program.Functions.cs 中,从 Program.cs 移动到 Program.Functions.cs,并最终添加 static 关键字,如下所示,代码中已高亮显示:

    **using****static** **System.Console;**
    **// Do not define a namespace so this class goes in the default empty namespace**
    **// just like the auto-generated partial Program class.**
    **partial****class****Program**
    **{**
      **static** void WhatsMyNamespace() // Define a **static** function.
      {
        WriteLine("Namespace of Program class: {0}",
          arg0: typeof(Program).Namespace ?? "null");
      }
    **}** 
    
  3. Program.cs 中,确认其全部内容现在只是三条语句,如下所示,代码中已高亮显示:

    using static System.Console;
    WriteLine("Top-level functions example");
    WhatsMyNamespace(); // Call the function. 
    
  4. 运行控制台应用程序,并注意它具有与之前相同的行为。

自动为静态函数生成了什么?

当你使用单独的文件来定义带有 static 函数的 partial Program 类时,编译器定义一个带有 <Main>$ 函数的 Program 类,并将你的顶层语句移动到 <Main>$ 方法中,然后将你的函数作为 Program 类的成员合并,如下所示,代码中已高亮显示:

using static System.Console;
partial class Program
{
  static void <Main>$(String[] args)
  {
    WriteLine("Top-level functions example");
    WhatsMyNamespace(); // Call the function.
  }
  static void WhatsMyNamespace() // Define a static function.
  {
    WriteLine("Namespace of Program class: {0}",
      arg0: typeof(Program).Namespace ?? "null");
  }
} 

这样更干净,你可以使用 XML 注释来文档化你的函数,这也会在调用函数时在你的代码编辑器中提供工具提示。

良好实践:在 Program.cs 中调用任何函数时,在单独的文件中创建这些函数,并在 partial Program 类中手动定义它们。这将使它们与自动生成的 Program 类在 <Main>$ 方法的同一级别合并,而不是作为 <Main>$ 方法内部的局部函数。

重要的是要注意缺少命名空间声明。自动生成的 Program 类和显式定义的 Program 类都在默认的 null 命名空间中。如果你在命名空间中定义你的 partial Program 类,那么它将位于不同的命名空间中,因此不会与自动生成的 partial Program 类合并。

良好实践:不要为任何您创建的partial Program类定义命名空间,这样它们将定义在默认的null命名空间中。

可选地,Program类中的所有static方法都可以显式声明为private,但这已经是默认设置。由于所有函数都将在本Program类内部调用,因此访问修饰符并不重要。

充分利用这本书的 GitHub 仓库

Git是一个常用的源代码管理系统。GitHub是一家公司、一个网站和桌面应用程序,它使得管理 Git 变得更加容易。微软在 2018 年收购了 GitHub,因此它将继续与微软工具紧密集成。

我为这本书创建了一个 GitHub 仓库,并用于以下目的:

  • 存储这本书的解决方案代码,这些代码将在印刷出版日期后得到维护。

  • 提供额外的材料,以扩展书籍,如勘误表修正、小改进、有用的链接列表以及无法放入印刷书的更长文章。

  • 为读者提供一个地方,如果他们对书籍有问题可以与我联系。

对书籍提出问题

如果您在遵循这本书中的任何说明时遇到困难,或者如果您在解决方案中的文本或代码中发现了错误,请在 GitHub 仓库中提出问题:

  1. 使用您喜欢的浏览器导航到以下链接:github.com/markjprice/apps-services-net8/issues

  2. 点击新建问题

  3. 请尽可能提供详细信息,以便我能够诊断问题。例如:

    • 具体的章节标题、页码和步骤编号。

    • 您的代码编辑器,例如,Visual Studio 2022、Visual Studio Code 或其他,包括版本号。

    • 您认为相关且必要的代码和配置尽可能多。

    • 对预期行为和实际行为的描述。

    • 截图(您可以将图像文件拖放到问题框中)。

以下内容不太相关,但可能有用:

  • 您的操作系统,例如,Windows 11 64 位,或 macOS Big Sur 版本 11.2.3。

  • 您的硬件,例如,英特尔、苹果硅或 ARM CPU。

我希望所有读者都能通过我的书取得成功,所以如果我可以不费太多力气帮助您(和其他人),我将非常乐意这样做。

给我反馈

如果您想就这本书给我更一般的反馈,您可以给我发电子邮件到markjprice@gmail.com。我的出版商 Packt 为读者设置了 Discord 频道,以便他们与作者和其他读者互动。您可以通过以下链接加入我们:packt.link/apps_and_services_dotnet8

我很高兴听到读者对我书籍的看法,以及改进建议以及他们如何使用 C#和.NET,所以请不要害羞。请与我联系!

提前感谢您深思熟虑且建设性的反馈。

从 GitHub 存储库下载解决方案代码

我使用 GitHub 存储了章节中所有动手、分步编码示例以及每章末尾的特色实践练习的解决方案。您可以在以下链接中找到存储库:github.com/markjprice/apps-services-net8

如果您只想下载所有解决方案文件而不使用 Git,请点击绿色的 Code 按钮,然后选择 Download ZIP

我建议您将前面的链接添加到您的收藏夹或书签中。

良好实践:最好将代码解决方案克隆或下载到短路径文件夹中,例如 C:\cs12dotnet8\C:\book\,以避免构建生成的文件超过最大路径长度。您还应避免使用特殊字符,例如 #。例如,不要使用文件夹名称 C:\C# projects\。这个文件夹名称可能适用于简单的控制台应用程序项目,但一旦您开始添加会自动生成代码的功能,您很可能会遇到奇怪的问题。请保持文件夹名称简短且简单。

去哪里寻求帮助

本节主要介绍如何在网络上找到关于编程的高质量信息。

在 Microsoft Learn 上阅读文档

获取 Microsoft 开发者工具和平台帮助的终极资源是在 Microsoft Learn 的技术文档中,您可以在以下链接中找到它:learn.microsoft.com/en-us/docs

获取 dotnet 工具的帮助

在命令提示符中,您可以请求 dotnet 工具帮助其命令。语法如下:

dotnet help <command> 

这将导致您的网页浏览器打开有关指定命令的文档页面。常见的 dotnet 命令包括 newbuildrun

警告! dotnet help new 命令在 .NET Core 3.1 到 .NET 6 中工作正常,但使用 .NET 7 或更高版本时会返回错误:“指定的命令 'new' 不是一个有效的 SDK 命令。请指定一个有效的 SDK 命令。有关更多信息,请运行 dotnet help.` 希望.NET 团队会尽快修复这个错误!

另一种帮助类型是命令行文档。它遵循以下语法:

dotnet <command> -?|-h|--help 

例如,dotnet new -?dotnet new -hdotnet new --help 会在命令提示符中输出有关 new 命令的文档。

正如您现在所期望的,dotnet help help 命令会在网页浏览器中打开 help 命令,而 dotnet help -h 则在命令提示符中输出 help 命令的文档!

让我们尝试一些示例:

  1. 要在网页浏览器窗口中打开 dotnet build 命令的官方文档,请在命令提示符或 Visual Studio Code 终端中输入以下内容,并注意在您的网页浏览器中打开的页面,如图 图 1.2 所示:

    dotnet help build 
    

计算机屏幕截图  描述由中等置信度自动生成

图 1.2:dotnet 构建命令的网页文档

  1. 要在命令提示符下获取帮助输出,请使用-?-h--help标志,如下面的命令所示:

    dotnet build -? 
    
  2. 你将看到以下部分输出:

    Description:
      .NET Builder
    Usage:
      dotnet build [<PROJECT | SOLUTION>...] [options]
    Arguments:
      <PROJECT | SOLUTION>  The project or solution file to operate on. If a file is not specified, the command will search the current directory for one.
    Options:
      --ucr, --use-current-runtime         Use current runtime as the target runtime.
      -f, --framework <FRAMEWORK>          The target framework to build for. The target framework must also be specified in the project file.
    ...
      -?, -h, --help                       Show command-line help. 
    
  3. 对以下命令重复两种帮助请求:addhelplistnewrun

人工智能工具如 ChatGPT 和 GitHub Copilot

过去一年中,编码和开发领域最大的变化之一是生成式人工智能AI)工具的出现,这些工具可以帮助完成编码任务,如完成代码语句、实现整个函数、编写单元测试以及为现有代码提出调试修复建议。

ChatGPT

ChatGPT 目前有两个模型:3.5(免费)和 4.0(每月 20 美元)。

假设你需要了解更多关于如何设计微服务的知识。你可能输入以下提示:

You are an expert .NET solution architect with experience advising global enterprises. What are the top three most common mistakes when designing a microservice architecture that are serious enough to cause a project to be abandoned? 

它会响应一个总结,详细说明了这三个主要错误,如图 1.3和以下列表所示:

图 1.3:ChatGPT 关于微服务架构设计的建议

“设计微服务架构是一项复杂的任务,存在一些常见的错误可能会导致重大问题,甚至导致项目被放弃。以下是三个最常见的错误:

  • 不恰当的服务边界: 设计微服务架构最具挑战性的方面之一是定义每个服务的边界。如果服务太大(通常被称为“伪装的巨石”),由于不同关注点之间的紧密耦合,你将失去许多微服务的优势。另一方面,如果服务太小(有时被称为“纳米服务”),你可能必须处理过多的服务间通信,从而导致高延迟和复杂性。

  • 缺乏适当的边界也可能导致数据一致性问题的出现。如果多个服务正在与相同的数据库表进行交互,那么维护数据一致性可能会变得困难。

  • 为了避免这个错误,重要的是要彻底理解业务领域,并围绕业务能力设计服务。例如,领域驱动设计DDD)等技术可以帮助更有效地识别服务边界。

  • 对数据管理考虑不足: 每个微服务应拥有自己的数据以确保松散耦合。在服务之间共享数据库会导致紧密耦合,并使得在不影响其他服务的情况下更改模式变得困难。

  • 然而,跨服务管理数据会引入数据一致性和事务管理方面的挑战。开发者往往低估了这些挑战。他们可能会尝试实现分布式事务,这些事务复杂且可能损害性能,而不是使用最终一致性补偿事务(即 Saga 模式)。

  • 忽略操作复杂性:微服务架构增加了操作复杂性。你将从管理单个单体应用程序转变为管理多个服务,每个服务可能使用不同的语言编写,使用不同的数据存储技术,并在不同的环境中运行。

  • 这需要成熟的 DevOps 能力,包括自动化部署、监控和良好的日志记录实践。你需要计划如何监控服务,如何聚合和分析日志,以及如何处理部署和回滚。

  • 忽略微服务的操作挑战可能导致后续出现重大问题。例如,如果没有良好的跟踪和日志记录,调试跨越多个服务的问题可能会变得非常困难。没有自动化部署和良好的测试实践,部署更改可能会变成一场噩梦。

总之,设计微服务架构不是一项简单任务。它需要深入理解业务领域,周密的规划和成熟的 DevOps 能力。然而,如果做得正确,微服务可以提供显著的好处,包括更好的可扩展性、灵活性和可维护性。”

GitHub Copilot

微软有一个专门为程序员提供的服务,名为 GitHub Copilot,可以帮助你在代码编辑器中直接自动完成代码。它正在通过使用 GPT-4 来增强其智能。它为包括 Visual Studio 2022、Visual Studio Code 和基于 JetBrains IntelliJ 的 IDEs 在内的代码编辑器提供了插件。

GitHub Copilot 对学生、教师和一些开源项目维护者是免费的。对于其他人,它提供 30 天的免费试用,然后每月收费 10 美元或每年收费 100 美元。一旦你有了账户,你就可以注册更高级的实验性 GitHub Copilot X 功能。

你应该在线查看各种代码编辑器中可用的 Copilot 功能。正如你可以想象的那样,这是一个快速变化的世界,我今天在书中可能写的大部分内容在你阅读时可能已经过时了:github.com/features/copilot

JetBrains 有自己的类似产品,名为 AI Assistant,你可以在以下链接了解更多信息:blog.jetbrains.com/idea/2023/06/ai-assistant-in-jetbrains-ides/

你可以在以下链接注册 GitHub Copilot:

github.com/github-copilot/signup/

订阅官方 .NET 博客和公告

要保持对 .NET 的了解,一个值得订阅的博客是官方 .NET 博客,由 .NET 工程团队撰写,你可以在以下链接找到它:devblogs.microsoft.com/dotnet/

我还建议你订阅以下链接的官方 .NET 公告仓库:github.com/dotnet/announcements

练习和探索

通过回答一些问题、进行一些动手实践,以及通过更深入的研究探索本章的主题,来测试你的知识和理解。

练习 1.1 – 测试你的知识

使用网络回答以下问题:

  1. 为什么将以下设置添加到你的项目文件中是一种良好的实践?以及在什么情况下不应该设置它?

    <TreatWarningsAsErrors>true</TreatWarningsAsErrors> 
    
  2. 哪种服务技术需要最低的 HTTP 版本为 2?

  3. 在 2010 年,你的组织使用 .NET Framework 和 WCF 创建了一个服务。将其迁移到哪种最佳技术,以及为什么?

  4. 你应该安装哪个代码编辑器或 IDE 用于 .NET 开发?

  5. 在创建 Azure 资源时,你应该注意什么?

练习 1.2 – 复习仅在线部分

为了在印刷版书籍中保留空间,GitHub 仓库中有一些可选的仅在线部分。它们对于本书的其余部分不是必需的,但你将发现它们对于一般知识很有用:

练习 1.3 – 探索主题

使用以下页面上的链接了解更多关于本章涵盖的主题:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-1---introducing-apps-and-services-with-net

摘要

在本章中,你:

  • 我们介绍了你将在本书中学到的应用程序和服务技术。

  • 设置你的开发环境。

  • 学到了在哪里寻找帮助。

在下一章中,你将学习如何使用 SQL Server 存储和管理关系型数据。

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里你可以分享反馈、向作者提问以及了解新版本——请扫描下面的二维码:

packt.link/apps_and_services_dotnet8

二维码

第二章:使用 SQL Server 管理关系数据

本章是关于管理存储在 SQL Server、Azure SQL Database 或 Azure SQL Edge 中的关系数据。首先,您将学习如何使用原生 Transact-SQL 语句管理数据。接下来,您将学习如何使用 ADO.NET 库(Microsoft.Data.SqlClient)以低级别管理数据。最后,您将使用 Dapper 来简化与实体模型的工作。

本章将涵盖以下主题:

  • 理解现代数据库

  • 使用 Transact-SQL 管理数据

  • 使用低级 API 管理 SQL Server 数据

  • 使用 Dapper 管理 SQL Server 数据

  • 清理数据资源

理解现代数据库

存储数据最常见的两个地方是在关系数据库管理系统RDBMS)中,如SQL ServerPostgreSQLMySQLSQLite,或者是在NoSQL数据库中,如Azure Cosmos DBMongoDBRedisApache Cassandra

在本章中,我们将重点关注 Windows 上最受欢迎的 RDBMS,即 SQL Server。此产品也有适用于 Linux 的版本。对于跨平台开发,您可以使用 Azure SQL Database,它将数据存储在云中,或者使用 Azure SQL Edge,它可以在 Windows、macOS 或 Linux 上的 Docker 容器中运行,无论是在 Intel 还是 ARM 架构的 CPU 上。

使用示例关系数据库

要学习如何使用.NET 管理 RDBMS,拥有一个示例数据库会很有用,这样您就可以在一个具有中等复杂性和相当数量的示例记录的数据库上练习。

微软提供了几个示例数据库,其中大多数对我们来说过于复杂,因此,我们将使用一个在 20 世纪 90 年代初首次创建的数据库,称为Northwind

让我们花一分钟时间看看 Northwind 数据库及其八个最重要的表的图表。您可以使用图 2.1 在本书编写代码和查询时进行参考:

图 2.1:Northwind 数据库表和关系

注意:

  • 每个类别都有一个唯一的标识符、名称、描述和图片。图片以 JPEG 格式存储为字节数组。

  • 每个产品都有一个唯一的标识符、名称、单价、库存数量和其他列。

  • 每个产品都与一个类别相关联,通过存储该类别的唯一标识符。

  • CategoriesProducts之间的关系是一对多,这意味着每个类别可以有零个、一个或多个产品。

  • 每个产品由一个供应商公司提供,通过存储供应商的唯一标识符来表示。

  • 每个订单的每个细节都存储了产品的数量和单价。

  • 每个订单都是由客户下单、由员工接收并由物流公司发货的。

  • 每位员工都有一个姓名、地址、联系详情、出生日期和雇佣日期,以及一个指向其经理的引用(除了老板,其ReportsTo字段为null),并且照片以 JPEG 格式存储为字节数组。由于一个员工可以管理许多其他员工,因此该表与其自身具有一对一的关系。

连接到 SQL Server 数据库

要连接到 SQL Server 数据库,我们需要知道以下列表中的多个信息:

  • 服务器(如果它具有默认值以上的名称)的名称。如果通过网络连接,这可能包括协议、IP 地址和端口号。

  • 数据库的名称。

  • 安全信息,例如用户名和密码,或者是否应自动使用 Windows 身份验证传递当前登录用户的凭据。

我们在连接字符串中指定此信息。

为了向后兼容,我们可以在 SQL Server 连接字符串中使用多个可能的关键字来表示各种参数,如下所示列表所示:

  • Data Sourceserveraddr: 这些关键字是服务器(以及可选的实例)的名称。您可以使用点(.)表示本地服务器。

  • Initial Catalogdatabase: 这些关键字是初始将处于活动状态的数据库的名称。可以使用以下命令更改 SQL 语句:USE <databasename>

  • Integrated Securitytrusted_connection: 这些关键字设置为trueSSPI,以使用 Windows 身份验证传递线程的当前用户凭据。

  • User IdPassword: 这些关键字用于使用 SQL Server 的任何版本进行身份验证。这对于 Azure SQL Database 或 Azure SQL Edge 非常重要,因为它们不支持 Windows 身份验证。Windows 上的 SQL Server 完整版支持用户名和密码以及 Windows 身份验证。

  • Authentication: 这个关键字用于使用 Azure AD 身份进行身份验证,可以启用无密码身份验证。值可以是Active Directory IntegratedActive Directory PasswordSql Password

  • Persist Security Info: 如果设置为false,此关键字告诉连接在身份验证后从连接字符串中删除Password

  • Encrypt: 如果设置为true,此关键字告诉连接使用 SSL 加密客户端和服务器之间的传输。

  • TrustServerCertificate: 如果本地托管并且您收到错误“与服务器成功建立了连接,但在登录过程中发生错误。(提供程序:SSL 提供程序,错误:0 - 由不受信任的权威机构签发的证书链。)”,则将其设置为true

  • Connection Timeout: 此关键字默认为 30 秒。

  • MultipleActiveResultSets: 将此关键字设置为true以启用单个连接同时用于处理多个表以提高效率。它用于从相关表懒加载行。

如上列表所述,当您编写代码连接到 SQL Server 数据库时,您需要知道其服务器名称。服务器名称取决于您将要连接的 SQL Server 版本和版本,如 表 2.1 所示:

SQL Server 版本 服务器名称 \ 实例名称
LocalDB 2012 (localdb)\v11.0
LocalDB 2016 或更高版本 (localdb)\mssqllocaldb
Express .\sqlexpress
全功能/开发者版(默认实例) .
全功能/开发者版(命名实例) .\apps-services-book
Azure SQL Edge(本地 Docker) tcp:127.0.0.1,1433
Azure SQL 数据库 tcp:[自定义服务器名称].database.windows.net,1433

表 2.1:SQL Server 各版本的示例服务器名称

良好实践:使用点(.)作为本地计算机名称(localhost)的缩写。请记住,SQL Server 的服务器名称可以由两部分组成:计算机名称和 SQL Server 实例名称。您在自定义安装期间提供实例名称。

本地安装和设置 SQL Server

微软为 Windows、Linux 和 Docker 容器提供了其流行的、功能强大的 SQL Server 产品的各种版本。如果您有 Windows,则可以使用免费的单机运行版本,称为 SQL Server 开发者版。您还可以使用 Express 版本或与 Visual Studio 2022 for Windows 一起安装的免费 SQL Server LocalDB 版本。

如果您没有 Windows 计算机,或者您想使用跨平台的数据库系统,则可以跳到主题 设置 Azure SQL 数据库,或者在线部分 在 Docker 中安装 Azure SQL Edge,该部分可在以下链接中找到:

github.com/markjprice/apps-services-net8/blob/main/docs/ch02-sql-edge.md

如果您想在 Linux 上本地安装 SQL Server,您可以在以下链接中找到说明:learn.microsoft.com/en-us/sql/linux/sql-server-linux-setup

安装 Windows 的 SQL Server 开发者版

在 Windows 上,如果您想使用 SQL Server 的完整版而不是简化的 LocalDB 或 Express 版本,您可以在以下链接中找到所有 SQL Server 版本:www.microsoft.com/en-us/sql-server/sql-server-downloads

执行以下步骤:

  1. 下载 开发者 版本。

  2. 运行安装程序。

  3. 选择 自定义 安装类型。

  4. 选择安装文件的文件夹,然后点击 安装

  5. 等待 1.5 GB 的安装文件下载。

  6. SQL Server 安装中心,点击 安装,然后点击 新建 SQL Server 独立安装或向现有安装添加功能,如图 图 2.2 所示:

图 2.2:安装新的 SQL Server 实例

  1. 选择开发者版作为免费版,然后点击下一步

  2. 接受许可条款,然后点击下一步

  3. 检查Microsoft 更新选项,然后点击下一步

  4. 检查安装规则,修复任何问题,尽管你可能想忽略任何防火墙警告,因为你可能根本不想暴露这些端口,然后点击下一步

  5. 功能选择中,选择数据库引擎服务,然后点击下一步

  6. Azure SQL Server 扩展中,你可以将其关闭。

  7. 实例配置中,选择默认实例,然后点击下一步。如果您已经配置了默认实例,则可以创建一个命名实例,例如 apps-services-book

  8. 服务器配置中,注意SQL Server 数据库引擎已配置为自动启动。如果默认情况下尚未设置,则将SQL Server 浏览器设置为自动启动,然后点击下一步

  9. 数据库引擎配置中,在服务器配置选项卡上,将身份验证模式设置为混合,将sa账户密码设置为强密码,点击添加当前用户,然后点击下一步

  10. 准备安装中,检查将要执行的操作,然后点击安装

  11. 完成中,注意已执行的操作,然后点击关闭

  12. SQL Server 安装中心中,在安装部分,点击安装 SQL Server 管理工具

  13. 在浏览器窗口中,点击下载 SSMS 的最新版本,如图 2.3 所示:

    图 2.3:下载 SQL Server 管理工具 (SSMS)

    下载 SSMS 的直接链接如下:learn.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms

  14. 运行 SSMS 安装程序并点击安装

  15. 当安装程序完成时,如果需要,请点击重启,或者点击关闭

Azure 数据工作室ADS)会自动与 SSMS 一起安装。ADS 是跨平台和开源的,因此您可以使用它在任何桌面操作系统上与 SQL Server 数据库一起工作。

Visual Studio Code 扩展用于与 SQL Server 一起工作

有许多工具可以轻松地与 SQL Server 一起工作。如果您正在使用 Visual Studio Code,则可以安装 SQL Server (mssql) ms-mssql.mssql 扩展。如果您安装了扩展,它会在主侧栏中添加一个新的视图,标题为SQL Server,如图 2.4 所示:

图形用户界面,文本,应用程序  自动生成的描述

图 2.4:SQL Server (mssql) 对 Visual Studio Code 的扩展

在本地创建 Northwind 示例数据库

现在我们可以运行数据库脚本,在 Windows 上使用 SQL Server 管理工具SSMS)本地创建 Northwind 示例数据库:

  1. 如果你之前没有下载或克隆此书的 GitHub 仓库,那么现在使用以下链接进行操作:github.com/markjprice/apps-services-net8/

  2. 在你的 apps-services-net8 文件夹中,创建一个名为 Chapter02 的文件夹。

  3. 将从你本地 Git 仓库以下路径创建 Northwind 数据库的脚本复制到 Chapter02 文件夹中:/scripts/sql-scripts/Northwind4SQLServer.sql

  4. 启动 SQL Server Management Studio

  5. 连接到服务器 对话框中,对于 服务器名称,输入 .(一个点),表示本地计算机名称,然后点击 连接

    警告! 如果你必须创建一个命名实例,如 apps-services-book,则输入 .\apps-services-book。如果你看到有关服务器证书的错误,则点击 选项 >> 按钮,并选择 信任服务器证书 复选框。

  6. 导航到 文件 | 打开 | 文件...

  7. 浏览并选择 Northwind4SQLServer.sql 文件,然后点击 打开

  8. 在工具栏中,点击 执行,并注意 命令(s) 已成功完成 消息。

  9. 对象资源管理器 中,展开 Northwind 数据库,然后展开

  10. 右键点击 产品,点击 选择前 1000 行,并注意返回的结果,如图 图 2.5 所示:

图形用户界面,文本,应用程序  自动生成的描述

图 2.5:SQL Server Management Studio 中的产品表

  1. 对象资源管理器 工具栏中,点击 断开连接 按钮。

  2. 退出 SQL Server Management Studio

我们不必使用 SQL Server Management Studio 来执行数据库脚本。我们也可以使用 Visual Studio 2022 中的工具,包括 SQL Server 对象资源管理器服务器资源管理器,或者跨平台的 SQL Server Visual Studio Code 扩展,或者 Azure Data Studio,你可以从以下链接单独下载和安装:aka.ms/getazuredatastudio

设置 Azure SQL 数据库

如果你没有 Windows 计算机,则可以创建一个云托管的 SQL Server 实例。你需要一个 Azure 账户。你可以在以下链接注册:signup.azure.com。接下来,你需要执行以下步骤:

  1. 登录到你的 Azure 账户:portal.azure.com/

  2. 导航到 portal.azure.com/#create/hub

  3. 搜索 资源组 并然后点击 创建 按钮。

  4. 输入资源组名称为 apps-services-book 并选择一个靠近你的合适区域,然后点击 审查 + 创建 按钮,如图 图 2.6 所示:

图 2.6:在 Azure 门户中创建资源组

  1. 审查你的选择,然后点击 创建 按钮。

  2. 创建另一个资源,搜索SQL 数据库,然后点击创建

  3. 创建 SQL 数据库页面,在基本选项卡中,对于数据库名称,输入Northwind,并选择你之前创建的资源组。

  4. 服务器部分,点击创建新服务器

  5. 按照如图 图 2.7 所示输入以下 SQL 数据库服务器的详细信息:

    • 服务器名称apps-services-book-[你的首字母]或完全不同的名称。服务器名称必须是全局唯一的,因为它将成为公共 URL 的一部分。

    • 位置:靠近你的地区。我选择了(欧洲)英国南部。并非所有地区都支持所有类型的资源。如果你选择的地区不支持 SQL 数据库服务器资源,将会看到错误。

    • 身份验证方法:使用 SQL 身份验证。

    • 服务器管理员登录:[你的电子邮件或另一个用户名],例如,我输入了markjprice

    • 密码/确认密码:[输入一个强密码]。

图片

图 2.7:输入 SQL 数据库实例的服务器详细信息

  1. 点击确定

  2. 是否使用 SQL 弹性池设置为

  3. 对于工作负载环境,选择开发(而不是生产)。

  4. 创建 SQL 数据库页面,在计算 + 存储部分,点击配置数据库

  5. 对于服务层,选择基本(适用于不太繁重的负载)。请注意,最大数据库大小为 2 GB,预计每月费用约为 5 美元(或每小时不到 1 美分)。你可以在完成本章后立即删除资源以进一步降低成本。

  6. 点击应用

  7. 创建 SQL 数据库页面,将备份存储冗余设置为本地冗余备份存储

  8. 点击下一步:网络按钮。

  9. 网络连接部分,选择公共端点

  10. 防火墙规则部分,将添加当前客户端 IP 地址设置为

  11. 点击下一步:安全按钮。

  12. 审查选项,但保留默认设置。

  13. 点击下一步:其他设置按钮。

  14. 审查选项,但保留默认设置。

  15. 点击审查 + 创建按钮。

  16. 点击创建按钮。

  17. 等待部署完成,如图 图 2.8 所示:

图片

图 2.8:SQL 数据库部署进度

  1. 部署完成后,点击转到资源

  2. 点击概览并注意数据库详细信息,如图 图 2.9 所示:

图片

图 2.9:SQL 数据库详细信息

  1. 点击查看连接字符串(或点击左侧导航中的连接字符串)。

  2. ADO.NET (SQL 身份验证)连接字符串复制到剪贴板。

  3. 启动 记事本 或你喜欢的纯文本编辑器,粘贴连接字符串,并在每个分号后添加换行符以分隔每个部分,以便更容易处理,如图下文所示:

    Server=tcp:apps-services-book.database.windows.net,1433;
    Initial Catalog=Northwind;
    Persist Security Info=False;
    User ID=markjprice;
    Password={your_password};
    MultipleActiveResultSets=False;
    Encrypt=True;
    TrustServerCertificate=False;
    Connection Timeout=30; 
    

    你的Server值将不同,因为自定义服务器名称部分,例如,apps-services-book是公开的,并且必须是全局唯一的。

  4. 可选地,保存记事本文件以供将来参考。

JetBrains Rider 用于处理 SQL Server 的工具窗口

如果你使用任何操作系统的 JetBrains Rider,那么你可以使用以下步骤连接到 SQL Server 数据库:

  1. 在 JetBrains Rider 中,选择视图 | 工具窗口 | 数据库

  2. 数据库工具窗口中,点击连接到数据库...

  3. 选择使用连接字符串选项按钮。

  4. 数据库类型设置为Microsoft SQL Server

  5. 字符串框中,输入数据库连接字符串。

  6. {your_password}更改为你选择的密码。

  7. 可选地,点击测试连接并在必要时纠正任何错误。如果你得到一个不一致的语言错误,那么你可以忽略它,因为我们正在使用 SQL Server 作为方言。

  8. 点击连接到数据库

在云中创建 Northwind 示例数据库

现在我们可以运行一个数据库脚本,在 Azure SQL 数据库中创建 Northwind 示例数据库:

  1. 使用你首选的数据库工具连接到 Azure 中的 SQL 服务器:

    • 在 Visual Studio 2022 中,查看服务器资源管理器

    • 在 Windows 上,启动SQL Server Management Studio

    • 在 Visual Studio Code 中,查看SQL Server工具。

    • 在 JetBrains Rider 中,导航到视图 | 工具窗口 | 数据库,然后点击连接到数据库…

  2. 添加数据连接,并在对话框中填写所有必需的连接字符串信息,如图图 2.10所示:

    图 2.10:从 Visual Studio 连接到你的 Azure SQL 数据库

    你可能还会被提示选择****数据源。选择Microsoft SQL Server。你可以选择一个复选框来始终使用此选择。

  3. 右键单击数据连接,选择新建查询

    如果你使用 JetBrains Rider,那么右键单击 SQL Server,在弹出菜单中选择SQL 脚本 | 运行 SQL 脚本…,然后选择Northwind4AzureSQLdatabase.sql文件。

  4. Northwind4AzureSQLdatabase.sql文件的全部内容复制并粘贴到查询窗口中,并执行它。

    Northwind4SQLServer.sql脚本和Northwind4AzureSQLdatabase.sql脚本之间的主要区别是,本地 SQL Server 脚本将删除并重新创建 Northwind 数据库。Azure SQL 数据库脚本不会这样做,因为数据库需要作为 Azure 资源创建。你可以从以下链接下载 SQL 脚本文件:github.com/markjprice/apps-services-net8/tree/main/scripts/sql-scripts

  5. 等待看到命令已成功完成的消息。这可能需要几分钟。

  6. 服务器资源管理器 中,右键单击 并选择 刷新,注意已创建了 13 个表,例如 类别客户产品。还要注意,还创建了数十个视图和存储过程。

你现在在云中有一个正在运行的 Azure SQL 数据库,你可以从 .NET 项目连接到它。

使用 Transact-SQL 管理数据

Transact-SQL (T-SQL) 是 SQL Server 的 结构化查询语言 (SQL) 方言。有些人读作 tee-sequel,有些人读作 tee-es-queue-el

与 C# 不同,T-SQL 不区分大小写;例如,你可以使用 intINT 来指定 32 位整数数据类型,你也可以使用 SELECTselect 来开始一个查询表达式。存储在 SQL Server 表中的文本数据可以被视为区分大小写或不区分大小写,这取决于配置。

T-SQL 的完整参考可以在以下链接找到:learn.microsoft.com/en-us/sql/t-sql/language-reference。从该文档起始页面,使用左侧导航查看 数据类型查询语句 等主题。

T-SQL 数据类型

T-SQL 有用于列、变量、参数等的数据类型,如 表 2.2 所示:

类别 示例
数字 bigint, bit, decimal, float, int, money, numeric, real, smallint, smallmoney, tinyint
日期和时间 date, datetime2, datetime, datetimeoffset, smalldatetime, time
文本 char, nchar, ntext, nvarchar, text, varchar
二进制 binary, image, varbinary
其他 cursor, hierarchyid, sql_variant, table, rowversion, uniqueidentifier, xml

表 2.2:SQL Server 数据类型类别

存在 xml 数据类型但没有 JSON 数据类型。使用 nvarchar 存储 JSON 值。T-SQL 还支持空间 geometrygeography 类型。

使用注释进行文档记录

要注释掉整行剩余部分,使用 --,它等同于 //

要注释掉一个代码块,使用起始的 /* 和结束的 */,就像在 C# 中一样。

声明变量

本地变量名以 @ 为前缀,并使用 SET, SELECT, 或 DECLARE 定义,如下面的代码所示:

DECLARE @WholeNumber INT; -- Declare a variable and specify its type.
SET @WholeNumber = 3; -- Set the variable to a literal value.
SET @WholeNumber = @WholeNumber + 1; -- Increment the variable.
SELECT @WholeNumber = COUNT(*) FROM Employees; -- Set to the number of employees.
SELECT @WholeNumber = EmployeeId FROM Employees WHERE FirstName = 'Janet'; 

全局变量以 @@ 为前缀。例如,@@ROWCOUNT 是一个上下文相关的值,它返回在当前作用域内执行语句影响的行数,例如,更新的或删除的行数。

指定数据类型

大多数类型都有固定的大小。例如,int 使用四个字节,smallint 使用两个字节,tinyint 使用一个字节。

对于文本和二进制类型,您可以指定一个以 varnvar(表示可变大小)前缀的类型,这将根据其当前值自动更改其大小,但不超过最大值,如下例所示:varchar(40);或者您可以指定一个固定数量的字符,这将始终分配,如下例所示:char(40)

对于文本类型,n 前缀表示 Unicode,意味着每个字符将使用两个字节。未使用 n 前缀的文本类型每个字符使用一个字节。

控制流程

T-SQL 有与 C# 类似的流程控制关键字,例如 BREAKCONTINUEGOTOIF...ELSECASETHROWTRY...CATCHWHILERETURN。主要区别是使用 BEGINEND 来指示块的开始和结束,这与 C# 中的花括号等效。

运算符

T-SQL 有与 C# 类似的运算符,例如 =(赋值)、+-*/%<><===!=&|^ 等。它还有逻辑运算符如 ANDORNOT,以及类似 LINQ 的运算符如 ANYALLSOMEEXISTSBETWEENIN

LIKE 用于文本模式匹配。模式可以使用 % 表示任意数量的字符。模式可以使用 _ 表示单个字符。模式可以使用 [] 来指定一个范围和允许的字符集,例如 [0-9A-Z.-,],它看起来像简化的正则表达式语法,但请注意,它不是正则表达式语法。

如果表名或列名包含空格,则必须用方括号括起来,例如 [Order Details]。创建 Northwind 数据库的 SQL 脚本包括命令 set quoted_identifier on,因此您也可以使用双引号,例如 "Order Details"。单引号用于文本字面量,例如 'USA'

数据操纵语言 (DML)

DML 用于查询和更改数据。

DML 中最常用的语句是 SELECT,它用于从一个或多个表中检索数据。SELECT 非常复杂,因为它功能强大。本书不是关于学习 T-SQL 的,所以了解 SELECT 的最快方式是查看一些示例,如 表 2.3 所示:

示例 描述
SELECT * FROM Employees 获取所有员工的全部列。
SELECT FirstName, LastName FROM Employees 获取所有员工的姓名列。
SELECT emp.FirstName, emp.LastName FROM Employees AS emp 为表名提供一个别名。当只有一个表时,不需要表名前缀,但在有多个具有相同名称的列的表时变得有用,例如 Customers.CustomerIdOrders.CustomerId
SELECT emp.FirstName, emp.LastName FROM Employees emp 无需使用 AS 关键字为表名提供别名。
SELECT FirstName, LastName AS Surname FROM Employees 为列名提供一个别名。
SELECT FirstName, LastName FROM Employees WHERE Country = 'USA' 过滤结果,仅包括美国的员工。
SELECT DISTINCT Country FROM Employees 获取 Employees 表中 Country 列作为值的国家的列表,不包含重复项。
SELECT UnitPrice * Quantity AS Subtotal FROM [Order Details] 计算每个订单明细行的子总金额。
SELECT OrderId, SUM(UnitPrice * Quantity) AS Total FROM [Order Details] GROUP BY OrderId ORDER BY Total DESC 计算每个订单的总金额,并按总金额降序排序。
SELECT CompanyName FROM Customers UNION SELECT CompanyName FROM Suppliers 返回所有客户和供应商的公司名称。
SELECT CategoryName, ProductName FROM Categories, Products 使用笛卡尔连接匹配每个类别与每个产品,并输出它们的名称(这不是你通常想要的!)616 行(8 个类别 x 77 个产品)。
SELECT CategoryName, ProductName FROM Categories c, Products p WHERE c.CategoryId = p.CategoryId 使用每个表中的 CategoryId 列的 WHERE 子句匹配每个产品与其类别,并输出类别名称和产品名称。77 行。
SELECT CategoryName, ProductName FROM Categories c INNER JOIN Products p ON c.CategoryId = p.CategoryId 使用每个表中的 CategoryId 列的 INNER JOIN...ON 子句匹配每个产品与其类别,并输出类别名称和产品名称。这是使用 WHERE 的现代替代语法,并允许外连接,这也会包括不匹配项。77 行。

表 2.3:示例 SELECT 语句及其描述

更多信息:您可以在以下链接中阅读关于 SELECT 的完整文档:learn.microsoft.com/en-us/sql/t-sql/queries/select-transact-sql.

使用您喜欢的数据库查询工具,例如 Visual Studio 的 服务器资源管理器 或 Visual Studio Code 的 mssql 扩展,连接到您的 Northwind 数据库并尝试上述查询,如图 2.11 和图 2:12 所示:

图 2.11:使用 Visual Studio 的服务器资源管理器执行 T-SQL 查询

图 2.12:使用 Visual Studio Code 的 mssql 扩展执行 T-SQL 查询

DML 用于添加、更新和删除数据

DML 语句用于添加、更新和删除数据,包括 表 2.4 中所示的内容:

示例 描述
INSERT Employees(FirstName, LastName) VALUES('Mark', 'Price') Employees 表添加新行。EmployeeId 主键值将自动分配。使用 @@IDENTITY 获取此值。
UPDATE Employees SET Country = 'UK' WHERE FirstName = 'Mark' AND LastName = 'Price' 更新我的员工行,将我的 Country 设置为 UK
DELETE Employees``WHERE FirstName = 'Mark'``AND LastName = 'Price' 删除我的员工记录。
DELETE Employees 删除 Employees 表中的所有行,并在事务日志中记录这些删除操作。
TRUNCATE TABLE Employees 更高效地删除 Employees 表中的所有行,因为它不记录单个行删除。

表 2.4:带有描述的示例 DML 语句

上述示例使用 Northwind 数据库中的 Employees 表。该表具有引用完整性约束,这意味着例如,删除表中的所有行是不可能的,因为每个员工在其他表(如 Orders)中都有相关数据。

数据定义语言 (DDL)

DDL 语句更改数据库的结构,包括创建新对象,如表、函数和存储过程。以下表格展示了某些 DDL 语句的示例,以供您参考,但这些示例简单,无法在 表 2.5 中所示的 Northwind 数据库中执行:

示例 描述
CREATE TABLE dbo.Shippers (``ShipperId INT PRIMARY KEY CLUSTERED,``CompanyName NVARCHAR(40)``); 创建一个表来存储承运商信息。
ALTER TABLE Shippers``ADD Country NVARCHAR(40) 向表中添加一个列。
CREATE NONCLUSTERED INDEX IX_Country``ON Shippers(Country) 为表中的列添加一个非聚集索引。
CREATE INDEX IX_FullName``ON Employees(LastName, FirstName DESC)``WITH (DROP_EXISTING = ON) 更改具有多个列的聚合索引并控制排序顺序。
DROP TABLE Employees 删除 Employees 表。如果它不存在,则抛出错误。
DROP TABLE IF EXISTS Employees 如果 Employees 表已存在,则删除该表。这避免了使用上一行语句可能产生的潜在错误。
IF OBJECT_ID(N'Employees', N'U')``IS NOT NULL 检查是否存在一个表。在文本字面量前方的 N 前缀表示 Unicode。'U' 表示用户表,而不是系统表。

表 2.5:带有描述的示例 DDL 语句

使用低级 API 管理数据

Microsoft.Data.SqlClient 包为 .NET 应用程序提供对 SQL Server 的数据库连接。它也被称为 SQL Server 和 Azure SQL 数据库的 ADO.NET 驱动程序

更多信息:您可以在以下链接找到 ADO.NET 的 GitHub 仓库:github.com/dotnet/SqlClient

Microsoft.Data.SqlClient 包支持以下 .NET 平台:

  • .NET Framework 4.6.2 及更高版本。

  • .NET Core 3.1 及更高版本。

  • .NET Standard 2.0 及更高版本。

理解 ADO.NET 中的类型

ADO.NET 定义了代表用于处理数据的最小对象的抽象类型,如 DbConnectionDbCommandDbDataReader。数据库软件制造商可以继承并为其提供特定的实现,这些实现针对其数据库进行了优化,并公开了额外的功能。Microsoft 为 SQL Server 做了这件事。以下是最重要的类型及其最常用的成员,显示在 表 2.6 中:

类型 属性 方法 描述
SqlConnection ConnectionString, State, ServerVersion Open, Close, CreateCommand, RetrieveStatistics 管理与数据库的连接。
SqlConnectionStringBuilder InitialCatalog, DataSource, Encrypt, UserID, Password, ConnectTimeout Clear, ContainsKey, Remove 为 SQL Server 数据库构建一个有效的连接字符串。在设置所有相关单个属性后,获取 ConnectionString 属性。
SqlCommand Connection, CommandType, CommandText, Parameters, Transaction ExecuteReader, ExecuteNonQuery, ExecuteXmlReader, CreateParameter 配置命令以执行。
SqlParameter ParameterName, Value, DbType, SqlValue, SqlDbType, Direction, IsNullable 为命令配置一个参数。
SqlDataReader FieldCount, HasRows, IsClosed, RecordsAffected Read, Close, GetOrdinal, GetInt32, GetString, GetDecimal, GetFieldValue<T> 处理查询执行的结果集。

表 2.6:ADO.NET SqlClient 中的重要类型

SqlConnection 有两个有用的事件:StateChangeInfoMessage

SqlCommand 的所有 ExecuteXxx 方法都将执行任何命令。您使用哪个取决于您期望得到什么:

  • 如果命令包含至少一个返回结果集的 SELECT 语句,那么请调用 ExecuteReader 来执行命令。此方法返回一个派生自 DbDataReader 的对象,用于通过结果集逐行读取。

  • 如果命令不包含至少一个 SELECT 语句,那么调用 ExecuteNonQuery 更有效率。此方法返回受影响的行数。

  • 如果命令包含至少一个 SELECT 语句,该语句返回 XML,因为它使用了 AS XML 命令,那么请调用 ExecuteXmlReader 来执行命令。

创建用于处理 ADO.NET 的控制台应用程序

首先,我们将创建一个用于处理 ADO.NET 的控制台应用程序项目:

  1. 使用您首选的代码编辑器创建控制台应用程序项目,如下列定义:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter02

    • 项目文件和文件夹:Northwind.Console.SqlClient

    • 不要使用顶级语句:已清除。

    • 启用原生 AOT 发布:已清除。

    良好实践:对于您为这本书创建的所有项目,请保持您的根路径短,并避免在文件夹和文件名中使用 #,否则您可能会看到像 RSG002: TargetPath not specified for additional file 这样的编译器错误。例如,请不要使用 C:\My C# projects\ 作为您的根路径!

  2. 在项目文件中,将警告视为错误,添加对最新版本的 Microsoft.Data.SqlClient 的包引用,并静态和全局导入 System.Console,如下面的标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference Include=****"Microsoft.Data.SqlClient"** **Version=****"5.1.2"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<Using Include=****"System.Console"** **Static=****"true"** **/>**
     **</ItemGroup>**
    </Project> 
    

    您可以在以下链接中检查该包的最新版本:www.nuget.org/packages/Microsoft.Data.SqlClient#versions-body-tab

  3. 构建项目以恢复引用的包。

  4. 添加一个名为 Program.Helpers.cs 的新类文件,并修改其内容以定义一个方法来配置控制台以启用特殊字符,如欧元货币符号,并设置当前区域设置,以及一个方法,该方法将以指定的颜色将一些文本输出到控制台,默认颜色为黑色,如下面的代码所示:

    using System.Globalization; // To use CultureInfo.
    partial class Program
    {
      private static void ConfigureConsole(string culture = "en-US",
        bool useComputerCulture = false)
      {
        // To enable Unicode characters like Euro symbol in the console.
        OutputEncoding = System.Text.Encoding.UTF8;
        if (!useComputerCulture)
        {
          CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
        }
        WriteLine($"CurrentCulture: {CultureInfo.CurrentCulture.DisplayName}");
      }
      private static void WriteLineInColor(string value, 
        ConsoleColor color = ConsoleColor.White)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = color;
        WriteLine(value);
        ForegroundColor = previousColor;
      }
    } 
    

    在前面的代码中,默认的前景色为白色,因为我假设大多数读者的默认背景色为黑色。在我的计算机上,我将控制台的默认背景色设置为白色,以便为这本书截图。请根据您的计算机设置最适合的默认颜色。

  5. 添加一个名为 Program.EventHandlers.cs 的新类文件,并修改其内容以定义方法,这些方法将作为数据库连接状态变化的事件处理器,通过显示原始和当前状态,以及当数据库发送 InfoMessage 时执行,如下面的代码所示:

    using Microsoft.Data.SqlClient; // To use SqlInfoMessageEventArgs.
    using System.Data; // To use StateChangeEventArgs.
    partial class Program
    {
      private static void Connection_StateChange(
        object sender, StateChangeEventArgs e)
      {
        WriteLineInColor(
          $"State change from {e.OriginalState} to {e.CurrentState}.",
          ConsoleColor.DarkYellow);
      }
      private static void Connection_InfoMessage(
        object sender, SqlInfoMessageEventArgs e)
      {
        WriteLineInColor($"Info: {e.Message}.", ConsoleColor.DarkBlue);
      }
    } 
    
  6. Program.cs 中,删除现有的语句。添加语句以连接到本地 SQL Server、Azure SQL 数据库或 SQL Edge,使用 SQL 身份验证(带有用户 ID 和密码)或 Windows 认证(不带用户 ID 和密码),如下面的代码所示:

    using Microsoft.Data.SqlClient; // To use SqlConnection and so on.
    ConfigureConsole();
    #region Set up the connection string builder
    SqlConnectionStringBuilder builder = new()
    {
      InitialCatalog = "Northwind",
      MultipleActiveResultSets = true,
      Encrypt = true,
      TrustServerCertificate = true,
      ConnectTimeout = 10 // Default is 30 seconds.
    };
    WriteLine("Connect to:");
    WriteLine("  1 - SQL Server on local machine");
    WriteLine("  2 - Azure SQL Database");
    WriteLine("  3 – Azure SQL Edge");
    WriteLine();
    Write("Press a key: ");
    ConsoleKey key = ReadKey().Key;
    WriteLine(); WriteLine();
    switch (key)
    {
      case ConsoleKey.D1 or ConsoleKey.NumPad1:
        builder.DataSource = ".";
        break;
      case ConsoleKey.D2 or ConsoleKey.NumPad2:
        builder.DataSource = 
          // Use your Azure SQL Database server name.
          "tcp:apps-services-book.database.windows.net,1433";
        break;
      case ConsoleKey.D3 or ConsoleKey.NumPad3:
        builder.DataSource = "tcp:127.0.0.1,1433";
        break;
      default:
        WriteLine("No data source selected.");
        return;
    }
    WriteLine("Authenticate using:");
    WriteLine("  1 – Windows Integrated Security");
    WriteLine("  2 – SQL Login, for example, sa");
    WriteLine();
    Write("Press a key: ");
    key = ReadKey().Key;
    WriteLine(); WriteLine();
    if (key is ConsoleKey.D1 or ConsoleKey.NumPad1)
    {
      builder.IntegratedSecurity = true;
    }
    else if (key is ConsoleKey.D2 or ConsoleKey.NumPad2)
    {
      Write("Enter your SQL Server user ID: ");
      string? userId = ReadLine();
      if (string.IsNullOrWhiteSpace(userId))
      {
        WriteLine("User ID cannot be empty or null.");
        return;
      }
      builder.UserID = userId;
      Write("Enter your SQL Server password: ");
      string? password = ReadLine();
      if (string.IsNullOrWhiteSpace(password))
      {
        WriteLine("Password cannot be empty or null.");
        return;
      }
      builder.Password = password;
      builder.PersistSecurityInfo = false;
    }
    else
    {
      WriteLine("No authentication selected.");
      return;
    }
    #endregion
    #region Create and open the connection
    SqlConnection connection = new(builder.ConnectionString);
    WriteLine(connection.ConnectionString);
    WriteLine();
    connection.StateChange += Connection_StateChange;
    connection.InfoMessage += Connection_InfoMessage;
    try
    {
      WriteLine("Opening connection. Please wait up to {0} seconds...", 
        builder.ConnectTimeout);
      WriteLine();
      connection.Open();
      WriteLine($"SQL Server version: {connection.ServerVersion}");
    }
    catch (SqlException ex)
    {
      WriteLineInColor($"SQL exception: {ex.Message}", 
        ConsoleColor.Red);
      return;
    }
    #endregion
    connection.Close(); 
    

    良好实践:在这个编码任务中,我们提示用户输入连接到数据库的密码。在实际应用中,您更有可能将密码存储在环境变量或像 Azure Key Vault 这样的安全存储中。您绝对不应该在源代码中存储密码!

  7. 运行控制台应用程序,选择与您的 SQL Server 设置兼容的选项,并注意结果,包括以深黄色写入的状态变化事件输出,以便更容易看到,如下面的输出所示:

    Connect to:
      1 - SQL Server on local machine
      2 - Azure SQL Database
      3 - Azure SQL Edge
    Press a key: 1
    Authenticate using:
      1 - Windows Integrated Security
      2 - SQL Login, for example, sa
    Press a key: 1
    Data Source=.;Initial Catalog=Northwind;Integrated Security=True;Multiple Active Result Sets=True;Connect Timeout=10;Encrypt=True;Trust Server Certificate=True
    Opening connection. Please wait up to 10 seconds...
    State change from Closed to Open.
    SQL Server version: 15.00.2101
    State change from Open to Closed. 
    

    以下步骤展示了连接到 Azure SQL 数据库或 Azure SQL Edge 的经验,这些操作需要用户名和密码。如果您使用 Windows 集成安全连接到本地 SQL Server,则不需要输入密码。

  8. 运行控制台应用程序,选择需要用户 ID 和密码的选项,例如使用 Azure SQL 数据库,并注意结果,如下所示的部分输出:

    Enter your SQL Server user ID: markjprice
    Enter your SQL Server password: [censored]
    Data Source=tcp:apps-services-book.database.windows.net,1433;Initial Catalog=Northwind;Persist Security Info=False;User ID=markjprice;Password=[censored];Multiple Active Result Sets=True;Connect Timeout=10;Encrypt=True;Trust Server Certificate=True
    Opening connection. Please wait up to 10 seconds...
    State change from Closed to Open.
    SQL Server version: 12.00.5168
    State change from Open to Closed. 
    
  9. 运行控制台应用程序,选择需要用户 ID 和密码的选项,输入错误的密码,并注意结果,如下所示的部分输出:

    Enter your SQL Server user ID: markjprice
    Enter your SQL Server password: 123456
    Data Source=tcp:apps-services-book.database.windows.net,1433;Initial Catalog=Northwind;Persist Security Info=False;User ID=markjprice;Password=123456;Multiple Active Result Sets=True;Connect Timeout=10;Encrypt=True;Trust Server Certificate=True
    Opening connection. Please wait up to 10 seconds...
    SQL exception: Login failed for user 'markjprice'. 
    
  10. Program.cs中,将服务器名称(DataSource属性)更改为错误的内容。

  11. 运行控制台应用程序并注意结果(根据您的数据库托管位置,异常消息可能略有不同),如下所示:

    SQL exception: A network-related or instance-specific error occurred while establishing a connection to SQL Server. The server was not found or was not accessible. Verify that the instance name is correct and that SQL Server is configured to allow remote connections. (provider: TCP Provider, error: 0 - No such host is known.) 
    

当打开 SQL Server 连接时,对于服务器连接问题,默认超时时间为 30 秒,所以请耐心等待!我们将超时时间更改为 10 秒,以避免等待时间过长。

使用 ADO.NET 执行查询和处理数据读取器

现在我们已经成功连接到 SQL Server 数据库,我们可以运行检索表中的行并使用数据读取器处理结果的命令:

  1. Program.cs中,导入用于处理 ADO.NET 命令类型的命名空间,如下所示:

    using System.Data; // To use CommandType. 
    

    良好实践:为了节省本书的空间,我将使用cmdr来表示 SQL 命令和 SQL 数据读取器。在您的代码中,给变量赋予合适的单词名称,如commandreader

  2. 在关闭连接的语句之前,添加定义选择Products表中的 ID、名称和价格的命令的语句,执行它,并使用数据读取器输出产品 ID、名称和价格,如下所示:

    SqlCommand cmd = connection.CreateCommand();
    cmd.CommandType = CommandType.Text;
    cmd.CommandText = "SELECT ProductId, ProductName, UnitPrice FROM Products";
    SqlDataReader r = cmd.ExecuteReader();
    string horizontalLine = new string('-', 60);
    WriteLine(horizontalLine);
    WriteLine("| {0,5} | {1,-35} | {2,10} |", 
      arg0: "Id", arg1: "Name", arg2: "Price");
    WriteLine(horizontalLine);
    while (r.Read())
    {
      WriteLine("| {0,5} | {1,-35} | {2,10:C} |",
        r.GetInt32("ProductId"), 
        r.GetString("ProductName"),
        r.GetDecimal("UnitPrice"));
    }
    WriteLine(horizontalLine);
    r.Close(); 
    

    我们使用C格式来格式化单价,该格式使用当前文化来格式化货币值。调用ConfigureConsole将当前文化设置为美国英语,因此所有读者的输出都使用$。要测试像使用欧元货币符号的法国这样的替代文化,请修改Program.cs文件顶部的调用,如下所示:ConfigureConsole("fr-FR");

  3. 运行控制台应用程序并注意结果,如下所示的部分输出:

    ----------------------------------------------------------
    |    Id | Name                                |    Price |
    ----------------------------------------------------------
    |     1 | Chai                                |   $18.00 |
    |     2 | Chang                               |   $19.00 |
    ...
    |    76 | Lakkalikööri                        |   $18.00 |
    |    77 | Original Frankfurter grüne Soße     |   $13.00 |
    ---------------------------------------------------------- 
    
  4. Program.cs中,修改 SQL 语句以定义一个用于单价的参数,并使用它来过滤结果,以显示单价高于该值的商品,如下所示的高亮代码:

    **Write(****"Enter a unit price: "****);**
    **string****? priceText = ReadLine();**
    **if****(!****decimal****.TryParse(priceText,** **out****decimal** **price))**
    **{**
     **WriteLine(****"You must enter a valid unit price."****);**
    **return****;**
    **}**
    SqlCommand cmd = connection.CreateCommand();
    cmd.CommandType = CommandType.Text;
    cmd.CommandText = "SELECT ProductId, ProductName, UnitPrice FROM Products" 
     **+** **" WHERE UnitPrice >= @minimumPrice**";
    **cmd.Parameters.AddWithValue(****"minimumPrice"****, price);** 
    
  5. 运行控制台应用程序,输入一个单价如50,并注意结果,如下所示的部分输出:

    Enter a unit price: 50
    ----------------------------------------------------------
    |    Id | Name                                |    Price |
    ----------------------------------------------------------
    |     9 | Mishi Kobe Niku                     |   $97.00 |
    |    18 | Carnarvon Tigers                    |   $62.50 |
    |    20 | Sir Rodney's Marmalade              |   $81.00 |
    |    29 | Thüringer Rostbratwurst             |  $123.79 |
    |    38 | Côte de Blaye                       |  $263.50 |
    |    51 | Manjimup Dried Apples               |   $53.00 |
    |    59 | Raclette Courdavault                |   $55.00 |
    ---------------------------------------------------------- 
    

输出统计数据

ADO.NET 连接在其生命周期内可以跟踪有用的统计数据,包括表 2.7中列出的那些:

描述
BuffersReceived, BuffersSent, BytesReceived, BytesSent 数据作为存储在缓冲区中的字节进行传输。
CursorOpens 游标是一个昂贵的操作,因为它需要在服务器上保持状态,并且在可能的情况下应避免使用。
Prepares, PreparedExecs, UnpreparedExecs 准备(编译)次数、已准备命令的执行次数和未准备命令的执行次数。
SelectCount, SelectRows SELECT 语句的数量和由 SELECT 语句返回的行数。
ServerRoundtrips, SumResultSets, Transactions 服务器往返次数、结果集和事务数。
ConnectionTime, ExecutionTime, NetworkServerTime 连接、执行命令或由于网络花费的毫秒数。

表 2.7:可以跟踪的连接统计信息

让我们启用它并输出一些这些统计信息:

  1. Program.Helpers.cs 中,导入用于处理 ADO.NET 和常见集合的命名空间,如下所示,代码中高亮显示:

    using Microsoft.Data.SqlClient; // To use SqlConnection.
    using System.Collections; // To use IDictionary. 
    
  2. Program.Helpers.cs 中,在部分 Program 类中,添加一个方法来输出有关当前连接的统计信息,使用字符串值数组来控制我们想要输出哪些统计信息,如下所示,代码中高亮显示:

    private static void OutputStatistics(SqlConnection connection)
    {
      // Remove all the string values to see all the statistics.
      string[] includeKeys = { 
        "BytesSent", "BytesReceived", "ConnectionTime", "SelectRows" 
      };
      IDictionary statistics = connection.RetrieveStatistics();
      foreach (object? key in statistics.Keys)
      {
        if (!includeKeys.Any() || includeKeys.Contains(key))
        {
          if (int.TryParse(statistics[key]?.ToString(), out int value))
          {
            WriteLineInColor($"{key}: {value:N0}", ConsoleColor.Cyan);
          }
        }
      }
    } 
    
  3. Program.cs 中,在将 SQL Server 版本写入控制台之后,添加一个语句来启用连接的统计信息,如下所示,代码中高亮显示:

    WriteLine($"SQL Server version: {connection.ServerVersion}");
    **connection.StatisticsEnabled =** **true****;** 
    
  4. Program.cs 中,在关闭连接之前,添加一个语句来输出连接的统计信息,如下所示,代码中高亮显示:

    **OutputStatistics(connection);**
    connection.Close(); 
    
  5. 运行控制台应用程序并注意统计信息,如下所示的部分输出:

    BytesReceived: 3,888
    BytesSent: 336
    SelectRows: 77
    ExecutionTime: 25 
    

异步处理 ADO.NET

您可以通过使其异步来提高数据访问代码的响应性。您将在 第五章多任务和并发 中看到异步操作如何工作的更多细节。现在,只需按照指示输入代码即可。

让我们看看如何将语句改为异步工作:

  1. Program.cs 中,更改打开连接的语句以使其异步,如下所示,代码中高亮显示:

    **await** connection.Open**Async**(); 
    
  2. Program.cs 中,更改执行命令的语句以使其异步,如下所示,代码中高亮显示:

    SqlDataReader r = **await** cmd.ExecuteReader**Async**(); 
    
  3. Program.cs 中,更改读取下一行和获取字段值的语句以使其异步,如下所示,代码中高亮显示:

    while (**await** r.Read**Async**())
    {
      WriteLine("| {0,5} | {1,-35} | {2,8:C} |",
        **await** r.Get**FieldValueAsync<****int****>**("ProductId"),
        **await** r.Get**FieldValueAsync<****string****>**("ProductName"),
        **await** r.Get**FieldValueAsync<****decimal****>**("UnitPrice"));
    } 
    
  4. Program.cs 中,更改语句以关闭数据读取器和连接,使其异步,如下所示,代码中高亮显示:

    **await** r.Close**Async**();
    **await** connection.Close**Async**(); 
    
  5. 运行控制台应用程序并确认它具有与之前相同的结果,但它在多线程系统中运行得更好,例如,在 GUI 应用程序中不会阻塞用户界面,在网站中不会阻塞 I/O 线程。

使用 ADO.NET 执行存储过程

如果您需要多次执行相同的查询或其他 SQL 语句,最好创建一个 存储过程,通常带有参数,以便它可以预先编译和优化。存储过程参数有一个方向来指示它们是输入、输出还是返回值。

让我们看看一个使用所有三种参数方向的示例。首先,我们将在数据库中创建存储过程:

  1. 在您首选的数据库工具中,连接到 Northwind 数据库。

  2. 在您首选的数据库工具中,添加一个新的存储过程:

    • 如果你正在使用 SQL Server Management Studio,则在 Object Explorer 中导航到 Databases | Northwind | Programmability,右键单击 Stored Procedures 并选择 New | Stored Procedure

    • 如果你正在使用 Visual Studio 2022,则在 Server Explorer 中右键单击 Stored Procedures 并选择 Add New Stored Procedure

    • 如果你正在使用 Visual Studio Code,则在 SQL Server 中右键单击你的连接配置文件并选择 New Query

    • 如果你正在使用 JetBrains Rider,则在 Database 工具栏中单击 Jump to Query Console… 按钮,然后删除任何现有语句。以及以下 SQL 语句,以设置活动数据库为 Northwind 的命令开始:USE Northwind GO。这应该可以防止 JetBrains Rider 在 master 数据库中创建存储过程!

  3. 修改 SQL 语句以定义一个名为 GetExpensiveProducts 的存储过程,该存储过程有两个参数:一个用于最小单位价格的输入参数和一个用于匹配产品行数的输出参数,如下面的代码所示:

    CREATE PROCEDURE [dbo].[GetExpensiveProducts]
      @price money,
      @count int OUT
    AS
      PRINT 'Getting expensive products: ' + 
        TRIM(CAST(@price AS NVARCHAR(10)))
      SELECT @count = COUNT(*)
      FROM Products
    	WHERE UnitPrice >= @price
      SELECT * 
      FROM Products
      WHERE UnitPrice >= @price
    RETURN 0 
    

    存储过程使用两个 SELECT 语句。第一个将 @count 输出参数设置为匹配产品行的计数。第二个返回匹配的产品行。它还调用了 PRINT 命令,这将引发 InfoMessage 事件。

  4. 在 SQL 语句中右键单击并选择 ExecuteExecute Query

  5. 右键单击 Stored Procedures 并选择 Refresh。在 JetBrains Rider 中,它被称为 routines

  6. 展开 GetExpensiveProducts 并注意 @price money 输入、@count int 输入/输出和返回值参数,如图 2.13 中的 SQL Server Management Studio 所示:

图 2.13:GetExpensiveProducts 存储过程的参数

  1. 关闭 SQL 查询而不保存更改。

  2. Program.cs 中添加语句以允许用户在运行文本命令和存储过程之间进行选择。添加定义存储过程及其参数的语句,然后执行命令,如下面的代码所示(高亮显示):

    SqlCommand cmd = connection.CreateCommand();
    **WriteLine(****"Execute command using:"****);**
    **WriteLine(****"  1 - Text"****);**
    **WriteLine(****"  2 - Stored Procedure"****);**
    **WriteLine();**
    **Write(****"Press a key: "****);**
    **key = ReadKey().Key;**
    **WriteLine(); WriteLine();**
    **SqlParameter p1, p2 =** **new****(), p3 =** **new****();**
    **if** **(key** **is** **ConsoleKey.D1** **or** **ConsoleKey.NumPad1)**
    **{**
      cmd.CommandType = CommandType.Text;
      cmd.CommandText = "SELECT ProductId, ProductName, UnitPrice FROM Products"
        + " WHERE UnitPrice >= @minimumPrice";
      cmd.Parameters.AddWithValue("minimumPrice", price);
    **}**
    **else****if** **(key** **is** **ConsoleKey.D2** **or** **ConsoleKey.NumPad2)**
    **{**
     **cmd.CommandType = CommandType.StoredProcedure;**
     **cmd.CommandText =** **"GetExpensiveProducts"****;**
     **p1 =** **new****()**
     **{**
     **ParameterName =** **"price"****,**
     **SqlDbType = SqlDbType.Money,**
     **SqlValue = price**
     **};**
     **p2 =** **new****()**
     **{**
     **Direction = ParameterDirection.Output,**
     **ParameterName =** **"count"****,**
     **SqlDbType = SqlDbType.Int**
     **};**
     **p3 =** **new****()**
     **{**
     **Direction= ParameterDirection.ReturnValue,**
     **ParameterName =** **"rv"****,**
     **SqlDbType = SqlDbType.Int**
     **};**
     **cmd.Parameters.AddRange(****new****[] { p1, p2, p3 });**
    **}**
    SqlDataReader r = await cmd.ExecuteReaderAsync(); 
    
  3. 在关闭数据读取器的语句之后,添加输出输出参数和返回值的语句,如下面的代码所示(高亮显示):

    await r.CloseAsync();
    **if** **(key** **is** **ConsoleKey.D2** **or** **ConsoleKey.NumPad2)**
    **{**
     **WriteLine(****$"Output count:** **{p2.Value}****"****);**
     **WriteLine(****$"Return value:** **{p3.Value}****"****);**
    **}**
    await connection.CloseAsync(); 
    

    如果存储过程返回结果集以及参数,则必须在读取参数之前关闭结果集的数据读取器。

  4. 运行控制台应用程序,并注意如果输入的价格是 60,则结果,并注意 InfoMessage 事件处理器以深蓝色写入消息,如下面的输出所示:

    Enter a unit price: 60
    Execute command using:
      1 - Text
      2 - Stored Procedure
    Press a key: 2
    Info: Getting expensive products: 60.00.
    ----------------------------------------------------------
    |    Id | Name                                |    Price |
    ----------------------------------------------------------
    |     9 | Mishi Kobe Niku                     |   $97.00 |
    |    18 | Carnarvon Tigers                    |   $62.50 |
    |    20 | Sir Rodney's Marmalade              |   $81.00 |
    |    29 | Thüringer Rostbratwurst             |  $123.79 |
    |    38 | Côte de Blaye                       |  $263.50 |
    ----------------------------------------------------------
    Output count: 5
    Return value: 0
    State change from Open to Closed. 
    

使用数据读取器输出流

在实际的应用程序或服务中,我们可能不会输出到控制台。更有可能的是,当我们使用数据读取器读取每一行时,我们可能会输出到写入网页内 HTML 标签的流,或者返回服务数据的 XML 和 JSON 等文本格式。

让我们添加生成 JSON 文件的功能:

  1. Program.cs中,导入用于高效处理 JSON 的命名空间,并静态导入EnvironmentPath类,如下所示代码:

    using System.Text.Json; // To use Utf8JsonWriter, JsonSerializer.
    using static System.Environment;
    using static System.IO.Path; 
    
  2. Program.cs中,在处理数据读取器的while语句之前,添加语句以定义 JSON 文件的文件路径,创建文件流,并开始一个 JSON 数组,然后在while块中,写入表示每个产品行的 JSON 对象,最后结束数组并关闭流,如下所示高亮显示的代码:

    **// Define a file path to write to.**
    **string** **jsonPath = Combine(CurrentDirectory,** **"products.json"****);**
    **await****using** **(FileStream jsonStream = File.Create(jsonPath))**
    **{**
     **Utf8JsonWriter jsonWriter =** **new****(jsonStream);**
     **jsonWriter.WriteStartArray();**
      while (await r.ReadAsync())
      {
        WriteLine("| {0,5} | {1,-35} | {2,10:C} |",
          await r.GetFieldValueAsync<int>("ProductId"),
          await r.GetFieldValueAsync<string>("ProductName"),
          await r.GetFieldValueAsync<decimal>("UnitPrice"));
     **jsonWriter.WriteStartObject();**
     **jsonWriter.WriteNumber(****"productId"****,** 
    **await** **r.GetFieldValueAsync<****int****>(****"ProductId"****));**
     **jsonWriter.WriteString(****"productName"****,** 
    **await** **r.GetFieldValueAsync<****string****>(****"ProductName"****));**
     **jsonWriter.WriteNumber(****"unitPrice"****,** 
    **await** **r.GetFieldValueAsync<****decimal****>(****"UnitPrice"****));**
     **jsonWriter.WriteEndObject();**
      }
     **jsonWriter.WriteEndArray();**
     **jsonWriter.Flush();**
     **jsonStream.Close();**
    **}**
    **WriteLineInColor(****$"Written to:** **{jsonPath}****"****, ConsoleColor.DarkGreen);** 
    
  3. 运行控制台应用程序,输入价格60,并注意 JSON 文件的路径,如下所示输出:

    Written to: C:\apps-services-net8\Chapter02\Northwind.Console.SqlClient\bin\Debug\net8.0\products.json 
    
  4. 打开products.json文件并注意,JSON 没有空格,所以它全部显示在一行上,如下所示文件:

    [{"productId":9,"productName":"Mishi Kobe Niku","unitPrice":97.0000},{"productId":18,"productName":"Carnarvon Tigers","unitPrice":62.5000},{"productId":20,"productName":"Sir Rodney\u0027s Marmalade","unitPrice":81.0000},{"productId":29,"productName":"Th\u00FCringer Rostbratwurst","unitPrice":123.7900},{"productId":38,"productName":"C\u00F4te de Blaye","unitPrice":263.5000}] 
    
  5. 如果你正在使用 Visual Studio 2022,那么你可以右键单击并选择格式化文档,并注意现在它更容易阅读,如图图 2.14所示:

![img/B19587_02_14.png]

图 2.14:从数据读取器生成的products.json文件

使用数据读取器生成对象

为了获得最大的灵活性,我们可能希望将数据读取器中的行转换为存储在数组或集合中的对象实例。之后,我们可以按需序列化对象图。ADO.NET 没有内置将数据读取器行映射到对象的能力,因此我们必须手动完成。

让我们看看一个例子:

  1. 添加一个名为Product.cs的新类文件,并修改其内容以定义一个类,仅表示从Products表中的每一行中我们想要的三列,如下所示代码:

    namespace Northwind.Models;
    public class Product
    {
      public int ProductId { get; set; }
      public string? ProductName { get; set; }
      public decimal? UnitPrice { get; set; }
    } 
    

    良好实践:在这个任务中,我们将仅使用此类型来表示只读实例,因此我们可以使用不可变的record。但稍后我们需要在对象创建后更改属性值,因此我们必须定义一个class

  2. Program.cs的顶部,导入Northwind.Models命名空间,以便我们可以使用Product

  3. Program.cs中,在创建文件流之前,实例化一个产品列表,初始存储 77 个项目(但这不是限制),因为当 Northwind 数据库首次创建时,有 77 个产品,如下所示高亮显示的代码:

    **List<Product> products =** **new****(capacity:** **77****);**
    await using (FileStream jsonStream = File.Create(jsonPath)) 
    
  4. while块中,添加语句以针对数据读取器中的每一行实例化Product类型并将其添加到列表中,如下所示高亮显示的代码:

    while (await r.ReadAsync())
    {
     **Product product =** **new****()**
     **{**
     **ProductId =** **await** **r.GetFieldValueAsync<****int****>(****"ProductId"****),**
     **ProductName =** **await** **r.GetFieldValueAsync<****string****>(****"ProductName"****),**
     **UnitPrice =** **await** **r.GetFieldValueAsync<****decimal****>(****"UnitPrice"****)**
     **};**
     **products.Add(product);**
      ...
    } 
    
  5. 在关闭数据读取器之前,添加一个语句以使用JsonSerializer类的静态Serialize方法将产品列表写入控制台,如下所示高亮显示的代码:

    **WriteLineInColor(JsonSerializer.Serialize(products),**
     **ConsoleColor.Magenta);**
    await r.CloseAsync(); 
    
  6. 运行控制台应用程序,输入价格60,并注意从产品列表生成的 JSON,如下所示输出:

    Written to: C:\apps-services-net8\Chapter02\Northwind.Console.SqlClient\bin\Debug\net8.0\products.json
    [{"ProductId":9,"ProductName":"Mishi Kobe Niku","UnitPrice":97.0000},{"ProductId":18,"ProductName":"Carnarvon Tigers","UnitPrice":62.5000},{"ProductId":20,"ProductName":"Sir Rodney\u0027s Marmalade","UnitPrice":81.0000},{"ProductId":29,"ProductName":"Th\u00FCringer Rostbratwurst","UnitPrice":123.7900},{"ProductId":38,"ProductName":"C\u00F4te de Blaye","UnitPrice":263.5000}] 
    

为了进一步简化,我们不需要手动实例化对象,可以使用简单的对象关系映射器ORM)如 Dapper。

使用 Dapper 管理数据

当与 SQL Server 一起工作时,Dapper 在底层使用 ADO.NET。因为它是一种高级技术,所以它不如直接使用 ADO.NET 效率高,但它可能更容易使用。Dapper 是 EF Core 的替代 ORM。它更高效,因为它通过扩展低级的 ADO.NET IDbConnection 接口,提供了非常基本的功能,而没有试图成为所有人的所有东西。

Dapper 连接扩展方法

Dapper 向实现 IDbConnection(如 SqlConnection)的任何类添加了三个扩展方法。它们是 Query<T>QueryExecute。Dapper 将根据需要自动打开和关闭相关的连接。

Query<T> 扩展方法是最常用的,因为它执行任何指定的 SQL 命令,然后以 IEnumerable<T>(对象序列)的形式返回结果。它旨在运行像 SELECT 这样的数据检索命令。它有几个参数,如 表 2.8 所示:

参数 描述
string sql 这是唯一的必需参数。它可以是 SQL 命令的文本或存储过程的名称。
object param = null 用于传递查询中使用的参数的复杂对象。这可以是一个匿名类型。
IDbTransaction transaction = null 用于管理分布式事务。
bool buffered = true 默认情况下,它将在返回时缓冲整个读取器。对于大型数据集,您可以通过将 buffered 设置为 false 来最小化内存,并且只按需加载对象。
int? commandTimeout = null 用于更改默认的命令超时时间。
CommandType? commandType = null) 用于切换到存储过程而不是默认的文本。

表 2.8:Dapper 的 Query 扩展方法参数

Query 扩展方法是一个松散类型等效,因此使用频率较低。

Execute 扩展方法执行任何指定的 SQL 命令,然后以 int 的形式返回受影响的行数。它旨在运行像 INSERTUPDATEDELETE 这样的命令。它具有与 Query<T> 扩展方法相同的参数。

使用 Dapper 查询

让我们看看一个简单的示例,该示例查询 Suppliers 表而不是 Products 表:

  1. Northwind.Console.SqlClient 项目中,添加对 Dapper 的包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.2" />
     **<PackageReference Include=****"Dapper"** **Version=****"2.1.21"** **/>**
    </ItemGroup> 
    

    在撰写本文时,Dapper 的最新版本是 2.1.21,发布于 2023 年 11 月 11 日。您可以通过以下链接检查自那时以来是否已更新:www.nuget.org/packages/Dapper

  2. 构建项目以还原包。

  3. 添加一个名为 Supplier.cs 的新类文件,并修改其内容以定义一个类来表示 Suppliers 表中每一行的四个列,如下面的代码所示:

    namespace Northwind.Models;
    public class Supplier
    {
      public int SupplierId { get; set; }
      public string? CompanyName { get; set; }
      public string? City { get; set; }
      public string? Country { get; set; }
    } 
    
  4. Program.cs 的底部添加语句以检索 Germany 中的 Supplier 实体,枚举输出每个产品的基本信息,然后将集合序列化为 JSON 输出到控制台,如下面的代码所示:

    WriteLineInColor("Using Dapper", ConsoleColor.DarkGreen);
    connection.ResetStatistics(); // So we can compare using Dapper.
    IEnumerable<Supplier> suppliers = connection.Query<Supplier>(
      sql: "SELECT * FROM Suppliers WHERE Country=@Country",
      param: new { Country = "Germany" });
    foreach (Supplier s in suppliers)
    {
      WriteLine("{0}: {1}, {2}, {3}",
        s.SupplierId, s.CompanyName, s.City, s.Country);
    }
    WriteLineInColor(JsonSerializer.Serialize(suppliers),
      ConsoleColor.Green);
    OutputStatistics(connection); 
    
  5. 运行控制台应用程序,在我们在其中使用 Dapper 的部分,注意使用了相同的连接,因此在 Dapper 查询执行时触发了其事件,然后是来自供应商列表的枚举集合输出,以及随后生成的 JSON,如下面的输出所示:

    Using Dapper
    11: Heli Süßwaren GmbH & Co. KG, Berlin, Germany
    12: Plutzer Lebensmittelgroßmärkte AG, Frankfurt, Germany
    13: Nord-Ost-Fisch Handelsgesellschaft mbH, Cuxhaven, Germany
    [{"SupplierId":11,  "CompanyName":"Heli S\u00FC\u00DFwaren GmbH \u0026 Co. KG",
      "City":"Berlin","Country":"Germany"},
     {"SupplierId":12,
      "CompanyName":"Plutzer Lebensmittelgro\u00DFm\u00E4rkte AG",
      "City":"Frankfurt","Country":"Germany"},
     {"SupplierId":13,
      "CompanyName":"Nord-Ost-Fisch Handelsgesellschaft mbH",
      "City":"Cuxhaven","Country":"Germany"}]
    BytesReceived: 1,430
    BytesSent: 240
    SelectRows: 3
    ExecutionTime: 5 
    
  6. Program.cs 的底部添加语句以运行 GetExpensiveProducts 存储过程,传递一个 price 参数值为 100,枚举输出每个产品的基本信息,然后将集合序列化为 JSON 输出到控制台,如下面的代码所示:

    IEnumerable<Product> productsFromDapper = 
      connection.Query<Product>(sql: "GetExpensiveProducts",
      param: new { price = 100M, count = 0 }, 
      commandType: CommandType.StoredProcedure);
    foreach (Product p in productsFromDapper)
    {
      WriteLine("{0}: {1}, {2}",
        p.ProductId, p.ProductName, p.UnitPrice);
    }
    WriteLineInColor(JsonSerializer.Serialize(productsFromDapper),
      ConsoleColor.Green); 
    

警告! 使用 Dapper 时,你必须传递一个包含所有参数的 param 对象,即使它们仅用作输出参数。例如,我们必须定义 count,否则将抛出异常。你还必须记住显式设置命令类型为存储过程!

运行控制台应用程序,在我们在其中使用 Dapper 运行存储过程以获取价格超过 100 的产品的部分,注意使用了相同的连接,因此在 Dapper 查询执行时触发了其事件,然后是来自产品列表的枚举集合输出,以及随后生成的 JSON,如下面的输出所示:

Info: Getting expensive products: 100.00.
29: Thüringer Rostbratwurst, 123.7900
38: Côte de Blaye, 263.5000
[{"ProductId":29,"ProductName":"Th\u00FCringer Rostbratwurst","UnitPrice":123.7900},{"ProductId":38,"ProductName":"C\u00F4te de Blaye","UnitPrice":263.5000}] 

更多信息:你可以在以下链接中了解更多关于 Dapper 的信息:github.com/DapperLib/Dapper/blob/main/Readme.md

清理数据资源

当你完成 SQL Server 数据库的使用后,你可以清理使用的资源。

Northwind 数据库被本书的大部分章节使用,所以如果你计划在阅读完这一章后立即继续阅读更多章节,请不要删除 Northwind!如果你在本地计算机上创建了数据库,那么你可以永远保留它。

移除 Azure 资源

为了移除 SQL 数据库使用的资源以节省成本:

警告! 如果你没有删除 Azure SQL 数据库使用的资源,那么你将产生费用。

  1. 在 Azure 门户中,找到名为 apps-services-book 的资源组。

  2. 点击 删除

  3. 输入资源组的名称。

  4. 点击 删除

练习和探索

通过回答一些问题,进行一些动手实践,并深入研究本章的主题来测试你的知识和理解。

练习 2.1 – 测试你的知识

回答以下问题:

  1. 在 .NET 项目中,你应该引用哪个 NuGet 包以在处理 SQL Server 中的数据时获得最佳性能?

  2. 定义数据库连接字符串最安全的方法是什么?

  3. T-SQL 参数和变量必须以什么前缀开头?

  4. 在读取输出参数之前,你必须做什么?

  5. Dapper 将其扩展方法添加到哪种类型中?

  6. Dapper 提供的最常用的两个扩展方法是什么?

练习 2.2 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-2---managing-relational-data-using-sql-server

练习 2.3 – 存储秘密的替代方案

像密码和其他在数据库连接字符串中使用的值,或用于访问服务的密钥等秘密,通常存储在环境变量中。这些值的其他存储位置包括应用程序秘密。您可以在以下链接中了解更多关于它们的信息:在 ASP.NET Core 中开发中安全存储应用程序秘密

learn.microsoft.com/en-us/aspnet/core/security/app-secrets

关于处理连接字符串的相关指导,您可以阅读以下链接:

learn.microsoft.com/en-us/ef/core/miscellaneous/connection-strings

摘要

在本章中,您学习了:

  • 如何连接到现有的 SQL Server 数据库。

  • 如何使用快速且低级的 ADO.NET 执行简单查询并处理结果。

  • 如何使用 Dapper 执行简单查询并处理结果。

在下一章中,您将学习如何使用微软提供的更强大和复杂的 ORM,即 EF Core。

第三章:使用 EF Core 为 SQL Server 构建实体模型

本章将介绍如何使用名为Entity Framework CoreEF Core)的高级对象到数据存储映射技术来管理存储在 SQL Server、Azure SQL Database 或 Azure SQL Edge 中的关系型数据。然后,你将学习如何使用三种不同的映射策略存储使用继承层次结构的实体模型。最后,你将构建用于本书其余部分代码示例的 SQL Server 数据库的类库。

本章将涵盖以下主题:

  • 使用 EF Core 管理 SQL Server 数据

  • 使用 EF Core 映射继承层次结构

  • 构建可重用的实体数据模型

使用 EF Core 管理数据

EF Core 是一个对象关系映射器ORM),当与 SQL Server 一起工作时,它使用 ADO.NET。因为它是一个高级技术,所以它不如直接使用 ADO.NET 效率高,但它可以使开发人员更容易工作,因为他们可以将数据视为对象而不是多个表中的行。这对于面向对象的开发者来说应该感觉更自然。

EF Core 8 仅针对 .NET 8。EF Core 7 针对的是 .NET 6,因此它可以与 .NET 6 的长期支持LTS)版本和 .NET 7 的标准支持STS)版本一起使用,如图 3.1 所示:

图 3.1:EF Core 7 针对.NET 6 或更高版本

当 EF Core 9 在 2024 年 11 月发布时,我们可以期待它将针对 .NET 8 或更高版本,因此你可以在仍然获得 .NET 8 平台长期支持的同时升级 EF Core。EF Core 团队负责确保你将能够在他们的包版本号中将 8 替换为 9,并且你的代码仍然可以工作。他们通常在这方面做得很好,并且会在 EF Core 9 的官方发布说明中记录任何必要的代码更改。

理解 Entity Framework Core

除了传统的 RDBMS,如 SQL Server 之外,EF Core 还支持现代基于云的、非关系型、无模式的数据库存储,如 Azure Cosmos DB 和 MongoDB,有时需要第三方提供程序。

与 EF Core 一起工作的有两种方法:

  • 数据库优先:已存在数据库,因此你构建一个与其结构和功能相匹配的模型。

  • 代码优先:不存在数据库,因此你构建一个模型,然后使用 EF Core 创建一个与其结构和功能相匹配的数据库。

我们将使用 EF Core 与现有数据库一起使用,因为这是最常见的场景。你将在本章后面看到代码优先的示例,该示例将在运行时创建其数据库。

使用现有数据库生成模型框架

框架生成是通过使用工具创建代表现有数据库模型的类的过程,该过程使用逆向工程。一个好的框架生成工具允许你扩展自动生成的类,然后在不丢失扩展类的情况下重新生成这些类。

如果您知道您永远不会使用此工具重新生成类,那么您可以随意更改自动生成的类的代码。工具生成的代码只是最佳近似。

良好实践:如果您知道更好的方法,不要害怕推翻工具。例如,当使用 SQLite 为 Northwind 数据库时,日期/时间列映射到 string 属性,而 money 列映射到 double 属性。在 Northwind 数据库中,这些列最好分别映射到 DateTimedecimal,但在另一个数据库中,可能需要更多的灵活性。另一个例子是在 Northwind 数据库中,CustomerId 应始终是五个大写字母。工具无法自动推断这一点,因此您可以添加正则表达式来验证它。请记住,工具行为是 .NET 中更易变的部分之一,因此这些示例在您阅读此书时可能不再有效。

设置 dotnet-ef 工具

.NET 有一个名为 dotnet 的命令行工具。它可以扩展用于与 EF Core 一起使用的功能。它可以执行设计时任务,例如从旧模型创建并应用迁移到新模型,以及从现有数据库生成模型的代码。

dotnet ef 命令行工具不是自动安装的。您必须将此包安装为 全局本地 工具。如果您已经安装了此工具的旧版本,那么您应该卸载任何现有版本。

让我们确保您已安装了工具的最新版本:

  1. 在命令提示符或终端中,检查您是否已将 dotnet-ef 作为全局工具安装,如下所示:

    dotnet tool list --global 
    
  2. 在列表中检查是否已安装工具的旧版本,如 .NET 7 的版本,如下所示:

    Package Id                            Version      Commands
    -----------------------------------------------------------------
    dotnet-ef                             7.0.0        dotnet-ef
    microsoft.web.librarymanager.cli      2.1.175      libman
    redth.net.maui.check                  0.5.6        maui-check 
    
  3. 如果已经安装了旧版本,那么请按照以下命令卸载工具:

    dotnet tool uninstall --global dotnet-ef 
    
  4. 按照以下命令安装最新版本:

    dotnet tool install --global dotnet-ef 
    
  5. 如果需要,按照任何特定于操作系统的说明将 dotnet tools 目录添加到您的 PATH 环境变量中,如安装 dotnet-ef 工具的输出中所述。

    如果您想安装预览版本,您可以指定版本通配符,例如,对于 EF Core 9 预览版,如下所示:

    dotnet tool install --global dotnet-ef --version 9-*

  6. 您可以使用以下命令进行更新,而不是卸载然后安装:

    dotnet tool update --global dotnet-ef 
    

定义 EF Core 模型

EF Core 使用 约定注解属性Fluent API 语句的组合在运行时构建 实体模型,以便对类执行的任何操作都可以稍后自动转换为对实际数据库执行的操作。实体类代表表的结构,类的实例代表该表中的一行。

首先,我们将回顾定义模型的三种方法,并附带代码示例,然后我们将创建一些实现这些技术的类。

使用 EF Core 约定来定义模型

我们将要编写的代码将使用以下约定:

  • 假设表的名称与 DbContext 类中 DbSet<T> 属性的名称相匹配,例如 Products

  • 假设列的名称与实体模型类中的属性名称相匹配,例如 ProductId

  • 数据库中 string 类型被假定为 nvarchar 类型。

  • .NET 类型 int 被假定为数据库中的 int 类型。

  • 假设主键是一个名为 IdID 的属性。或者,当实体模型类名为 Product 时,属性可以命名为 ProductIdProductID。如果此属性是整数类型或 Guid 类型,则它也被假定为 IDENTITY 列(一种在插入时自动分配值的列类型)。

良好实践:还有许多其他约定你应该了解,你甚至可以定义自己的约定,但这超出了本书的范围。你可以在以下链接中了解它们:learn.microsoft.com/en-us/ef/core/modeling/

使用 EF Core 注解属性来定义模型

约定通常不足以完全将类映射到数据库对象。例如,一些数据库如 SQLite 使用动态列类型,因此工具必须根据该列当前的数据值来猜测其列应映射到的属性类型。

要为您的模型添加更多智能的一种简单方法是通过应用注解属性,如下表 3.1 所示:

属性 描述
[Required] 确保值不为空。
[StringLength(50)] 确保值的长度不超过 50 个字符。
[RegularExpression(expression)] 确保值匹配指定的正则表达式。
[Column(TypeName = "money", Name = "UnitPrice")] 指定在表中使用的列类型和列名。

表 3.1:常见的 EF Core 注解属性

例如,在数据库中,产品名称的最大长度为 40,且值不能为空,如下面部分 DDL 代码所示,该代码定义了如何创建名为 Products 的表,其中高亮显示的部分如下:

CREATE TABLE Products (
    ProductId       INTEGER       PRIMARY KEY,
    **ProductName     NVARCHAR (****40****)** **NOT****NULL****,**
    SupplierId      "INT",
    ...
); 

Product 类中,我们可以应用属性来指定这一点,如下面的代码所示:

[Required] 
[StringLength(40)]
public string ProductName { get; set; } 

良好实践:如果您启用了可空性检查,那么您不需要像上面那样用 [Required] 属性装饰非可空引用类型。这是因为 C# 的可空性会传播到 EF Core 模型。一个 string 属性将是必需的;一个 string? 属性将是可选的,换句话说,是可空的。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/ef/core/modeling/entity-properties?tabs=data-annotations%2Cwith-nrt#required-and-optional-properties

当 .NET 类型与数据库类型之间没有明显的映射时,可以使用属性。

例如,在数据库中,Products 表的 UnitPrice 列的数据类型是 money。.NET 没有名为 money 的数据类型,因此应该使用 decimal 代替,如下面的代码所示:

[Column(TypeName = "money")]
public decimal? UnitPrice { get; set; } 

另一个例子是针对 Categories 表,如下面的 DDL 代码所示:

CREATE TABLE Categories (
    CategoryId   INTEGER       PRIMARY KEY,
    CategoryName NVARCHAR (15) NOT NULL,
    **Description  "NTEXT",**
    Picture      "IMAGE"
); 

Description 列的长度可以超过可以存储在 nvarchar 变量中的最大 8,000 个字符,因此它需要映射到 ntext,如下面的代码所示:

[Column(TypeName = "ntext")]
public string? Description { get; set; } 

使用 EF Core Fluent API 定义模型

模型定义的最后一種方式是使用 Fluent API。此 API 可以替代属性,也可以与它们一起使用。您可能需要这样做的一个原因是在 .NET Standard 2.0 类库中定义实体模型,以便它们可以在旧平台上使用。在这种情况下,类库不应包含对数据注释库的引用。另一个原因是,您的团队可能有政策将原始数据模型与验证规则分开。

例如,要定义 ProductName 属性,而不是用两个属性装饰该属性,可以在数据库上下文类的 OnModelCreating 方法中编写一个等效的 Fluent API 语句,如下面的代码所示:

modelBuilder.Entity<Product>()
  .Property(product => product.ProductName)
  .IsRequired() // only needed if you have disabled nullability checks
  .HasMaxLength(40); 

这使得实体模型类更加简单。您将在下面的编码任务中看到一个例子。

理解 Fluent API 的数据初始化

Fluent API 的另一个好处是提供初始数据以填充数据库。EF Core 会自动计算出需要执行哪些插入、更新或删除操作。

例如,如果我们想确保新数据库中至少有一行在 Product 表中,那么我们会调用 HasData 方法,如下面的代码所示:

modelBuilder.Entity<Product>()
  .HasData(new Product
  {
    ProductId = 1,
    ProductName = "Chai",
    UnitPrice = 8.99M
  }); 

HasData 的调用在执行 dotnet ef database update 命令的数据迁移期间生效,或者当您调用 Database.EnsureCreated 方法时。

我们的模型将映射到一个已经填充了数据的现有数据库,因此我们不需要在我们的代码中使用这种技术。

定义 Northwind 数据库模型

将使用 Northwind 类来表示数据库。为了使用 EF Core,该类必须从 DbContext 继承。这个类了解如何与数据库通信并动态生成 SQL 语句来查询和操作数据。

你的 DbContext 派生类应该有一个名为 OnConfiguring 的重写方法,它将设置数据库连接字符串。

在你的由 DbContext 派生的类中,你必须定义至少一个 DbSet<T> 类型的属性。这些属性代表表。为了告诉 EF Core 每个表有哪些列,DbSet<T> 属性使用泛型来指定一个代表表中行的类。这个实体模型类具有代表其列的属性。

DbContext 派生的类可以可选地有一个名为 OnModelCreating 的重写方法。这是你可以编写 Fluent API 语句的地方,作为用属性装饰你的实体类的替代方案。这可以增强模型配置的清晰性和可维护性,因为所有这些都可以在一个地方而不是散布在你的代码库中。你还可以混合使用这两种技术,但那样就会失去这个主要好处。

如果你没有创建 Northwind 数据库,或者如果你已经删除了它,那么你现在需要创建它。具体说明在 第二章,使用 SQL Server 管理关系数据

让我们在控制台应用程序中为 Northwind 数据库构建模型:

  1. 使用你喜欢的代码编辑器创建一个控制台应用程序项目,如下列定义:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter03

    • 项目文件和文件夹:Northwind.Console.EFCore

    • 不要使用顶级语句:已清除。

    • 启用原生 AOT 发布:已清除。

  2. Northwind.Console.EFCore 项目中,添加对 SQL Server 的 EF Core 数据提供程序的包引用,并全局和静态导入 System.Console 类,如下所示的高亮标记:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Design" 
        Version="8.0.0" />
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.SqlServer" 
        Version="8.0.0" />
    </ItemGroup>
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    
  3. 构建项目以恢复包。

    下一步假设有一个用于本地 SQL Server 的数据库连接字符串,该 SQL Server 使用 Windows 集成安全性进行身份验证。如果需要,修改它以用于 Azure SQL 数据库或 Azure SQL Edge,并使用用户 ID 和密码。

  4. Northwind.Console.EFCore 文件夹中的命令提示符或终端,在名为 Models 的新文件夹中为所有表生成一个模型,如下所示命令:

    dotnet ef dbcontext scaffold "Data Source=.;Initial Catalog=Northwind;Integrated Security=true;TrustServerCertificate=true;" Microsoft.EntityFrameworkCore.SqlServer --output-dir Models --namespace Northwind.Models --data-annotations --context NorthwindDb 
    

    命令行工具需要将命令全部输入一行中。dotnet-ef工具经常需要输入较长的命令。我建议您从打印书籍中输入或从电子书复制并粘贴此类长命令到纯文本编辑器(如记事本)中。然后确保在复制并粘贴到命令行之前,整个命令格式正确,且间距正确。直接从电子书复制粘贴可能会包含换行符、缺失空格等问题,从而破坏命令。本书中必须输入的所有命令行都可以在以下链接处复制为单行:github.com/markjprice/apps-services-net8/blob/main/docs/command-lines.md

    注意以下:

    • 命令操作:dbcontext scaffold

    • 连接字符串:这取决于您是连接到本地 SQL Server(带或不带实例名称)还是 Azure SQL 数据库。

    • 数据库提供程序:Microsoft.EntityFrameworkCore.SqlServer

    • 输出文件夹:--output-dir Models

    • 命名空间:--namespace Northwind.Models

    • 使用数据注解以及流畅式 API:--data-annotations

    • 将上下文从 [database_name]Context 重命名:--context NorthwindDb

  5. 注意以下输出中的构建消息和警告:

    Build started…
    Build succeeded.
    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. 
    

    良好实践:在修复此警告之前,不要将项目提交到 Git。如果您使用用户名和密码连接到 SQL Server 数据库,那么这些信息现在已包含在您的源代码中!我们将通过用动态生成的连接字符串替换固定的连接字符串,并从环境变量中读取敏感值来解决这个问题。

  6. 打开 Models 文件夹,并注意自动生成的 28 个类文件。

  7. 打开 Category.cs 并注意它代表 Categories 表中的一行,如下面的代码所示:

    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.ComponentModel.DataAnnotations.Schema;
    using Microsoft.EntityFrameworkCore;
    namespace Northwind.Models;
    [Index("CategoryName", Name = "CategoryName")]
    public partial class Category
    {
      [Key]
      public int CategoryId { get; set; }
      [StringLength(15)]
      public string CategoryName { get; set; } = null!;
      [Column(TypeName = "ntext")]
      public string? Description { get; set; }
      [Column(TypeName = "image")]
      public byte[]? Picture { get; set; }
      [InverseProperty("Category")]
      public virtual ICollection<Product> Products { get; set; } 
        = new List<Product>();
    } 
    

    注意以下:

    • 它使用 EF Core 5 中引入的 [Index] 属性装饰实体类。这表示应在表中为该列创建索引的属性。在早期版本中,仅支持使用流畅式 API 定义索引。由于我们正在处理现有数据库,此属性不是必需的。但如果我们想从我们的代码中重新创建一个新的空 Northwind 数据库,那么这些信息将用于在 Categories 表中创建索引。

    • 数据库中的表名为 Categories,但 dotnet-ef 工具使用了 Humanizer 第三方库自动将类名单数化到 Category,这在创建单个实体时是一个更自然的名称。

    • 实体类使用 partial 关键字声明,这样您就可以创建一个匹配的 partial 类来添加额外的代码。这允许您重新运行工具并重新生成实体类,而不会丢失在您的 partial 类中编写的额外代码。

    • CategoryId 属性被装饰了 [Key] 属性,以明确表示它是该实体的主键,尽管其名称也遵循主键约定。

    • Products 属性使用 [InverseProperty] 属性来定义与 Product 实体类上的 Category 属性的外键关系。

  8. 打开 ProductsAboveAveragePrice.cs 并注意它代表的是数据库视图返回的行,而不是表,因此它被装饰了 [Keyless] 属性。

  9. 打开 NorthwindDb.cs 并查看该类,如下面的编辑过的代码所示:

    using System;
    using System.Collections.Generic;
    using Microsoft.EntityFrameworkCore;
    namespace Northwind.Models;
    public partial class NorthwindDb : DbContext
    {
      public NorthwindDb()
      {
      }
      public NorthwindDb(DbContextOptions<NorthwindDb> options)
        : base(options)
      {
      }
      public virtual DbSet<AlphabeticalListOfProduct> 
        AlphabeticalListOfProducts { get; set; }
      public virtual DbSet<Category> Categories { get; set; }
    ...
      public virtual DbSet<Territory> Territories { get; set; }
      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("Data Source=.;Initial Catalog=Northwind;Integrated Security=true;TrustServerCertificate=true;");
      protected override void OnModelCreating(ModelBuilder modelBuilder)
      {
        modelBuilder.Entity<AlphabeticalListOfProduct>(entity =>
        {
          entity.ToView("Alphabetical list of products");
        });
    ...
        OnModelCreatingPartial(modelBuilder);
      }
      partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
    } 
    

    注意以下内容:

    • NorthwindDb 数据上下文类是 partial 的,以便您可以扩展它并在将来重新生成它。我们使用 NorthwindDb 这个名字是因为 Northwind 被用于一个命名空间。

    • NorthwindDb 有两个构造函数:一个默认的无参数构造函数和一个允许传递选项的构造函数。这在需要运行时指定连接字符串的应用程序中很有用。

    • 代表如 Categories 这样的表的 DbSet<T> 属性。

    • OnConfiguring 方法中,如果构造函数中没有指定选项,则默认使用在生成框架时使用的连接字符串。它有一个编译器警告来提醒您不要在这个连接字符串中硬编码安全信息。

    • OnModelCreating 方法中,使用 Fluent API 来配置实体类,然后调用一个名为 OnModelCreatingPartial 的部分方法。这允许您在自己的部分 Northwind 类中实现该部分方法,添加您自己的 Fluent API 配置,这样在重新生成模型类时也不会丢失。

  10. NorthwindDb.cs 文件顶部,导入用于处理 ADO.NET 类型的命名空间,如下面的代码所示:

    using Microsoft.Data.SqlClient; // To use SqlConnectionStringBuilder. 
    
  11. 修改 OnConfiguring 方法以动态设置连接字符串,并使用环境变量设置任何敏感参数,如下面的代码所示:

    protected override void OnConfiguring(
      DbContextOptionsBuilder optionsBuilder)
    {
      if (!optionsBuilder.IsConfigured)
      {
        SqlConnectionStringBuilder builder = new();
        builder.DataSource = "."; // "ServerName\InstanceName" e.g. @".\sqlexpress"
        builder.InitialCatalog = "Northwind";
        builder.TrustServerCertificate = true;
        builder.MultipleActiveResultSets = true;
        // Because we want to fail faster. Default is 15 seconds.
        builder.ConnectTimeout = 3;
        // If using Windows Integrated authentication.
        builder.IntegratedSecurity = true;
        // If using SQL Server authentication.
        // builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");
        // builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");
        optionsBuilder.UseSqlServer(builder.ConnectionString);
      }
    } 
    
  12. 关闭自动生成的类文件。

更多信息:如果您之前没有使用过环境变量,那么您可以从以下链接提供的在线部分了解它们:github.com/markjprice/cs12dotnet8/blob/main/docs/ch09-environment-variables.md

查询 Northwind 模型

现在我们可以查询模型:

  1. Program.cs 中,删除现有的语句。添加语句以创建 NorthwindDb 数据上下文类的实例,并使用它来查询产品表,以获取那些价格高于给定价格的产品,如下面的代码所示:

    using Microsoft.Data.SqlClient; // To use SqlConnectionStringBuilder.
    using Microsoft.EntityFrameworkCore; // ToQueryString, GetConnectionString
    using Northwind.Models; // To use NorthwindDb.
    SqlConnectionStringBuilder builder = new();
    builder.InitialCatalog = "Northwind";
    builder.MultipleActiveResultSets = true;
    builder.Encrypt = true;
    builder.TrustServerCertificate = true;
    builder.ConnectTimeout = 10;
    WriteLine("Connect to:");
    WriteLine("  1 - SQL Server on local machine");
    WriteLine("  2 - Azure SQL Database");
    WriteLine("  3 - Azure SQL Edge");
    WriteLine();
    Write("Press a key: ");
    ConsoleKey key = ReadKey().Key;
    WriteLine(); WriteLine();
    if (key is ConsoleKey.D1 or ConsoleKey.NumPad1)
    {
      builder.DataSource = "."; // Local SQL Server
      // @".\apps-services-book"; // Local SQL Server with an instance name
    }
    else if (key is ConsoleKey.D2 or ConsoleKey.NumPad2)
    {
      builder.DataSource = // Azure SQL Database
        "tcp:apps-services-book.database.windows.net,1433";
    }
    else if (key is ConsoleKey.D3 or ConsoleKey.NumPad3)
    {
      builder.DataSource = "tcp:127.0.0.1,1433"; // Azure SQL Edge
    }
    else
    {
      WriteLine("No data source selected.");
      return;
    }
    WriteLine("Authenticate using:");
    WriteLine("  1 - Windows Integrated Security");
    WriteLine("  2 - SQL Login, for example, sa");
    WriteLine();
    Write("Press a key: ");
    key = ReadKey().Key;
    WriteLine(); WriteLine();
    if (key is ConsoleKey.D1 or ConsoleKey.NumPad1)
    {
      builder.IntegratedSecurity = true;
    }
    else if (key is ConsoleKey.D2 or ConsoleKey.NumPad2)
    {
      Write("Enter your SQL Server user ID: ");
      string? userId = ReadLine();
      if (string.IsNullOrWhiteSpace(userId))
      {
        WriteLine("User ID cannot be empty or null.");
        return;
      }
      builder.UserID = userId;
      Write("Enter your SQL Server password: ");
      string? password = ReadLine();
      if (string.IsNullOrWhiteSpace(password))
      {
        WriteLine("Password cannot be empty or null.");
        return;
      }
      builder.Password = password;
      builder.PersistSecurityInfo = false;
    }
    else
    {
      WriteLine("No authentication selected.");
      return;
    }
    DbContextOptionsBuilder<NorthwindDb> options = new();
    options.UseSqlServer(builder.ConnectionString);
    using (NorthwindDb db = new(options.Options))
    {
      Write("Enter a unit price: ");
      string? priceText = ReadLine();
      if (!decimal.TryParse(priceText, out decimal price))
      {
        WriteLine("You must enter a valid unit price.");
        return;
      }
      // We have to use var because we are projecting into an anonymous type.
      var products = db.Products
        .Where(p => p.UnitPrice > price)
        .Select(p => new { p.ProductId, p.ProductName, p.UnitPrice });
      WriteLine("----------------------------------------------------------");
      WriteLine("| {0,5} | {1,-35} | {2,8} |", "Id", "Name", "Price");
      WriteLine("----------------------------------------------------------");
      foreach (var p in products)
      {
        WriteLine("| {0,5} | {1,-35} | {2,8:C} |",
          p.ProductId, p.ProductName, p.UnitPrice);
      }
      WriteLine("----------------------------------------------------------");
      WriteLine(products.ToQueryString());
      WriteLine();
      WriteLine($"Provider:   {db.Database.ProviderName}");
      WriteLine($"Connection: {db.Database.GetConnectionString()}");
    } 
    
  2. 运行控制台应用程序并注意结果,如下面的部分输出所示:

    Enter a unit price: --
    |    Id | Name                                |    Price--
    |     9 | Mishi Kobe Niku                     |   £97.00 |
    |    18 | Carnarvon Tigers                    |   £62.50 |
    |    20 | Sir Rodney's Marmalade              |   £81.00 |
    |    29 | Thüringer Rostbratwurst             |  £123.79 |
    |    38 | Côte de Blaye                       |  £263.50--
    DECLARE @__price_0 decimal(2) = 60.0;
    SELECT [p].[ProductId], [p].[ProductName], [p].[UnitPrice]
    FROM [Products] AS [p]
    WHERE [p].[UnitPrice] > @__price_0
    Provider:   Microsoft.EntityFrameworkCore.SqlServer
    Connection: Data Source=tcp:apps-services-book.database.windows.net,1433;Initial Catalog=Northwind;Persist Security Info=False;User ID=<censored>;Password=<censored>;Multiple Active Result Sets=False;Encrypt=True;Trust Server Certificate=False;Connection Timeout=10; 
    

你的连接字符串的输出将不同。

控制实体的跟踪

我们需要从实体身份解析的定义开始。EF Core 通过读取其唯一的键值来解析每个实体实例。这确保了关于实体身份或它们之间关系的任何歧义都不会存在。

EF Core 只能跟踪具有键的实体,因为它使用键在数据库中唯一标识实体。无键实体,如视图返回的实体,永远不会被跟踪。

默认情况下,EF Core 假设你想要在本地内存中跟踪实体,以便如果你进行更改,例如添加新实体、修改现有实体或删除现有实体,那么你可以调用SaveChanges,所有这些更改都将应用于底层数据存储。

如果你在一个数据上下文中执行查询,比如获取德国的所有客户,然后在该数据上下文中执行另一个查询,比如获取所有名字以 A 开头的客户,如果其中一个客户实体已经存在于上下文中,它将被识别而不会被替换或加载两次。然而,如果在两次查询执行之间该客户的电话号码在数据库中被更新,那么在数据上下文中被跟踪的实体不会用新的电话号码刷新。

如果你不需要跟踪这些更改,或者你希望在每次查询执行时加载实体的最新数据值的新实例,即使实体已经加载,那么你可以禁用跟踪。

要禁用单个查询的跟踪,在查询中调用AsNoTracking方法,如下面的代码所示:

var products = db.Products
  .AsNoTracking()
  .Where(p => p.UnitPrice > price)
  .Select(p => new { p.ProductId, p.ProductName, p.UnitPrice }); 

要默认禁用数据上下文的跟踪,将更改跟踪器的查询跟踪行为设置为NoTracking,如下面的代码所示:

db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; 

要禁用单个查询的跟踪但保留身份解析,在查询中调用AsNoTrackingWithIdentityResolution方法,如下面的代码所示:

var products = db.Products
  .AsNoTrackingWithIdentityResolution()
  .Where(p => p.UnitPrice > price)
  .Select(p => new { p.ProductId, p.ProductName, p.UnitPrice }); 

要禁用跟踪但默认执行身份解析的数据上下文,将更改跟踪器的查询跟踪行为设置为NoTrackingWithIdentityResolution,如下面的代码所示:

db.ChangeTracker.QueryTrackingBehavior = 
  QueryTrackingBehavior.NoTrackingWithIdentityResolution; 

要为数据上下文的所有新实例设置默认值,在OnConfiguring方法中调用UseQueryTrackingBehavior方法,如下面的代码所示:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
  optionsBuilder.UseSqlServer(connectionString)
    .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
} 

使用默认跟踪的场景

默认是更改跟踪与身份解析。一旦实体被加载到数据上下文中,底层更改不会反映出来,并且只有一个副本存在。实体具有本地更改跟踪,并且调用SaveChanges会更新数据库,如表 3.2所示:

操作 数据上下文中的实体 数据库中的行
德国客户的查询 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-4567
更改数据库中的电话 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
查询以 A 开头的客户 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
查询德国的客户 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
更改实体中的电话 Alfred’s Futterkiste, 123-1928 Alfred’s Futterkiste, 123-9876
保存更改 Alfred’s Futterkiste, 123-1928 Alfred’s Futterkiste, 123-1928

表 3.2:默认跟踪场景

使用无跟踪的相同场景

无跟踪无身份解析。每个查询都会将数据库行的一个新实例加载到数据上下文中,包括底层更改,允许重复和混合过时和更新的数据。没有跟踪本地实体更改,因此SaveChanges不起作用,如表 3.3所示:

操作 数据上下文中的实体 数据库中的行
查询德国的客户 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-4567
更改数据库中的电话 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
查询以 A 开头的客户 Alfred’s Futterkiste, 123-4567Alfred’s Futterkiste, 123-9876 Alfred’s Futterkiste, 123-9876
查询德国的客户 Alfred’s Futterkiste, 123-4567Alfred’s Futterkiste, 123-9876Alfred’s Futterkiste, 123-9876 Alfred’s Futterkiste, 123-9876
更改实体中的电话 Alfred’s Futterkiste, 123-4567Alfred’s Futterkiste, 123-9876Alfred’s Futterkiste, 123-1928 Alfred’s Futterkiste, 123-9876
保存更改 Alfred’s Futterkiste, 123-4567Alfred’s Futterkiste, 123-9876Alfred’s Futterkiste, 123-1928 Alfred’s Futterkiste, 123-9876

表 3.3:无跟踪场景

使用无跟踪和身份解析的相同场景

无跟踪且具有身份解析。一旦实体被加载到数据上下文中,底层更改不会反映出来,并且只有一个副本存在。没有跟踪本地实体更改,因此SaveChanges不起作用,如表 3.4所示:

操作 数据上下文中的实体 数据库中的行
查询德国的客户 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-4567
更改数据库中的电话 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
查询以 A 开头的客户 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
查询德国的客户 Alfred’s Futterkiste, 123-4567 Alfred’s Futterkiste, 123-9876
更改实体中的电话 Alfred’s Futterkiste, 123-1928 Alfred’s Futterkiste, 123-9876
保存更改 Alfred’s Futterkiste, 123-1928 Alfred’s Futterkiste, 123-9876

表 3.4:身份解析场景

跟踪总结

应该选择哪一个?当然,这取决于您的具体场景。

你有时会读到一些博客,兴奋地告诉你,通过调用AsNoTracking可以显著提高你的 EF Core 查询。但如果运行一个返回数千个实体的查询,然后在同一数据上下文中再次运行相同的查询,你现在就有数千个重复项!这浪费了内存并影响了性能。

理解三种跟踪选择的工作方式,并选择最适合你的数据上下文或单个查询的最佳选择。在下一个主题中,你将学习如何映射继承层次结构。

使用 EF Core 映射继承层次结构

假设你有一些 C#类,用于存储有关学生和员工的信息,这些类都是人的类型。所有的人都有一个名字和一个 ID 来唯一标识他们,学生有一个他们正在学习的科目,员工有一个雇佣日期,如下面的代码所示:

public abstract class Person
{
  public int Id { get; set; }
  public string? Name { get; set; }
}
public class Student : Person
{
  public string? Subject { get; set; }
}
public class Employee : Person
{
  public DateTime HireDate { get; set; }
} 

默认情况下,EF Core 将使用表-每层次结构TPH)映射策略将这些映射到单个表中。EF Core 5 引入了对表-每类型TPT)映射策略的支持。EF Core 7 引入了对表-每具体类型TPC)映射策略的支持。让我们来探讨这些映射策略之间的区别。

表-每层次结构(TPH)映射策略

对于Person-Student-Employee层次结构,TPH 将使用一个带有区分列的单表结构,该列用于指示行是哪种类型的人,学生还是员工,以及一些可空列用于存储仅适用于某些类型的额外值,如下面的代码所示,高亮显示的部分:

CREATE TABLE [People] (
  [Id] int NOT NULL IDENTITY,
  [Name] nvarchar(max) NOT NULL,
 **[Discriminator] nvarchar(max)** **NOT****NULL****,**
 **[Subject] nvarchar(max)** **NULL****,**
 **[HireDate] nvarchar(max)** **NULL****,**
  CONSTRAINT [PK_People] PRIMARY KEY ([Id])
); 

表中的某些数据可能看起来如下所示:

Id Name Discriminator Subject HireDate
1 Roman Roy Student History NULL
2 Kendall Roy Employee NULL 02/04/2014
3 Siobhan Roy Employee NULL 12/09/2020

表 3.5:People 表中的示例数据

TPH 需要Discriminator列存储每行的类型类名。TPH 需要派生类型属性的列是可空的,如SubjectHireDate。如果这些属性在类级别上是必需的(非空),这将导致问题。EF Core 默认不处理这种情况。

TPH 映射策略的主要优点是简单性和性能,这就是为什么它被默认使用的原因。

良好实践:如果区分列有许多不同的值,那么通过在区分列上定义索引,你可以进一步提高性能。但如果只有少数不同的值,索引可能会使整体性能更差,因为它会影响更新时间。在这种情况下,只有两个潜在值,StudentEmployee,所以在有 100,000 行记录的表中,索引几乎不会产生影响。

表-每类型(TPT)映射策略

对于Person-Student-Employee层次结构,TPT 将为每种类型使用一个表,如下面的代码所示:

CREATE TABLE [People] (
  [Id] int NOT NULL IDENTITY,
  [Name] nvarchar(max) NOT NULL,
  CONSTRAINT [PK_People] PRIMARY KEY ([Id])
);
CREATE TABLE [Students] (
  [Id] int NOT NULL,
  [Subject] nvarchar(max) NULL,
  CONSTRAINT [PK_Students] PRIMARY KEY ([Id])
  CONSTRAINT [FK_Students_People] FOREIGN KEY ([Id]) REFERENCES [People] ([Id])
);
CREATE TABLE [Employees] (
  [Id] int NOT NULL,
  [HireDate] nvarchar(max) NULL,
  CONSTRAINT [PK_Employees] PRIMARY KEY ([Id])
  CONSTRAINT [FK_Employees_People] FOREIGN KEY ([Id]) REFERENCES [People] ([Id])
); 

表中的某些数据可能看起来如下所示:

Id Name
1 Roman Roy
2 Kendall Roy
3 Siobhan Roy

表 3.6:人员表

Id Subject
1 历史

表 3.7:学生表

Id HireDate
2 02/04/2014
3 12/09/2020

表 3.8:员工表

TPT 映射策略的主要优点是由于数据的完全规范化而减少的存储空间。主要缺点是单个实体分散在多个表中,重建它需要更多的努力,因此降低了整体性能。TPT 通常不是一个好的选择,所以只有在表结构已经规范化且无法重新结构化时才使用它。

表按具体类型映射(TPC)策略

对于 Person-Student-Employee 层次,TPC 将为每个非抽象类型使用一个表,如下面的代码所示:

CREATE TABLE [Students] (
  [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [PersonIds]),
  [Name] nvarchar(max) NOT NULL,
  [Subject] nvarchar(max) NULL,
  CONSTRAINT [PK_Students] PRIMARY KEY ([Id])
  CONSTRAINT [FK_Students_People] FOREIGN KEY ([Id]) REFERENCES [People] ([Id])
);
CREATE TABLE [Employees] (
  [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [PersonIds]),
  [Name] nvarchar(max) NOT NULL,
  [HireDate] nvarchar(max) NULL,
  CONSTRAINT [PK_Employees] PRIMARY KEY ([Id])
  CONSTRAINT [FK_Employees_People] FOREIGN KEY ([Id]) REFERENCES [People] ([Id])
); 

由于没有单个带有 IDENTITY 列的表来分配 Id 值,我们可以使用 (NEXT VALUE FOR [PersonIds]) 命令定义两个表之间共享的序列,这样它们就不会分配相同的 Id 值。

表格中的某些数据可能如下所示:

Id Name Subject
1 Roman Roy 历史

表 3.9:学生表

Id Name HireDate
2 Kendall Roy 02/04/2014
3 Siobhan Roy 12/09/2020

表 3.10:员工表

TPC 映射策略的主要优点是性能,因为当查询单个具体类型时,只需要一个表,因此我们避免了昂贵的连接。它最适合具有许多具体类型的大继承层次结构,每个类型都有许多特定类型的属性。

配置继承层次映射策略

首先,所有类型都必须包含在模型中,如下面的代码所示:

public DbSet<Person> People { get; set; }
public DbSet<Student> Students { get; set; }
public DbSet<Employee> Employees { get; set; } 

对于 TPH,你现在已经完成了,因为它是默认的!如果您想使其明确,那么在数据上下文类的 OnModelCreating 方法中,在层次结构的基类上调用适当的“使用映射策略”方法。Person 是基类,所以您会在该实体类型上调用 UseTphMappingStrategy,如下面的代码所示:

modelBuilder.Entity<Person>().UseTphMappingStrategy(); 

要使用其他两种映射策略之一,请调用适当的方法,如下面的代码所示:

modelBuilder.Entity<Person>().UseTptMappingStrategy();
modelBuilder.Entity<Person>().UseTpcMappingStrategy(); 

接下来,你可以选择性地指定每个实体类要使用的表名,如下面的代码所示:

modelBuilder.Entity<Student>().ToTable("Students");
modelBuilder.Entity<Employee>().ToTable("Employees"); 

TPC 策略应该有一个共享的序列,因此我们也应该配置它,如下面的代码所示:

modelBuilder.HasSequence<int>("PersonIds");
modelBuilder.Entity<Person>().UseTpcMappingStrategy()
  .Property(e => e.Id).HasDefaultValueSql("NEXT VALUE FOR [PersonIds]"); 

示例:层次映射策略

现在,让我们使用一个名为 HierarchyMapping 的新数据库和项目来实际看看这个操作:

  1. 使用您首选的代码编辑器添加一个控制台应用程序项目,如下面的列表所示:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter03

    • 项目文件和文件夹:Northwind.Console.HierarchyMapping

    • 不要使用顶级语句:已清除。

    • 启用原生 AOT 发布:已清除。

  2. 配置启动项目以运行 Northwind.Console.HierarchyMapping

  3. Northwind.Console.HierarchyMapping 项目中,添加对 SQL Server EF Core 数据提供程序的包引用,并全局和静态导入 System.Console 类,如下所示:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Design" 
        Version="8.0.0" />
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.SqlServer" 
        Version="8.0.0" />
    </ItemGroup>
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    
  4. 构建项目以恢复包。

  5. Northwind.Console.HierarchyMapping 项目中,添加一个名为 Models 的新文件夹。

  6. Models 目录下,添加一个名为 Person.cs 的新类文件,并修改其内容,如下所示:

    using System.ComponentModel.DataAnnotations; // To use [Required].
    namespace Northwind.Models;
    public abstract class Person
    {
      public int Id { get; set; }
      [Required]
      [StringLength(40)]
      public string? Name { get; set; }
    } 
    
  7. Models 目录下,添加一个名为 Student.cs 的新类文件,并修改其内容,如下所示:

    namespace Northwind.Models;
    public class Student : Person
    {
      public string? Subject { get; set; }
    } 
    
  8. Models 目录下,添加一个名为 Employee.cs 的新类文件,并修改其内容,如下所示:

    namespace Northwind.Models;
    public class Employee : Person
    {
      public DateTime HireDate { get; set; }
    } 
    
  9. Models 目录下,添加一个名为 HierarchyDb.cs 的新类文件,并修改其内容,如下所示:

    using Microsoft.EntityFrameworkCore; // To use DbSet<T>.
    namespace Northwind.Models;
    public class HierarchyDb : DbContext
    {
      public DbSet<Person>? People { get; set; }
      public DbSet<Student>? Students { get; set; }
      public DbSet<Employee>? Employees { get; set; }
      public HierarchyDb(DbContextOptions<HierarchyDb> options)
          : base(options)
      {
      }
      protected override void OnModelCreating(ModelBuilder modelBuilder)
      {
        modelBuilder.Entity<Person>()
          .UseTphMappingStrategy();
        // Populate database with sample data.
        Student p1 = new() { Id = 1, Name = "Roman Roy", 
          Subject = "History" };
        Employee p2 = new() { Id = 2, Name = "Kendall Roy", 
          HireDate = new(year: 2014, month: 4, day: 2) };
        Employee p3 = new() { Id = 3, Name = "Siobhan Roy", 
          HireDate = new(year: 2020, month: 9, day: 12) };
        modelBuilder.Entity<Student>().HasData(p1);
        modelBuilder.Entity<Employee>().HasData(p2, p3);
      }
    } 
    
  10. Program.cs 中,删除现有的语句。添加语句以配置 HierarchyDb 数据上下文的连接字符串,然后使用它来删除并创建一个名为 HierarchyMapping(不是 Northwind!)的数据库,显示自动生成的 SQL 脚本,然后输出学生、员工和人员,如下所示:

    using Microsoft.Data.SqlClient; // To use SqlConnectionStringBuilder.
    using Microsoft.Extensions.Options;
    using Microsoft.EntityFrameworkCore; // GenerateCreateScript()
    using Northwind.Models; // HierarchyDb, Person, Student, Employee
    DbContextOptionsBuilder<HierarchyDb> options = new();
    SqlConnectionStringBuilder builder = new();
    builder.DataSource = "."; // "ServerName\InstanceName" e.g. @".\sqlexpress"
    builder.InitialCatalog = "HierarchyMapping";
    builder.TrustServerCertificate = true;
    builder.MultipleActiveResultSets = true;
    // Because we want to fail faster. Default is 15 seconds.
    builder.ConnectTimeout = 3;
    // If using Windows Integrated authentication.
    builder.IntegratedSecurity = true;
    // If using SQL Server authentication.
    // builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");
    // builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");
    options.UseSqlServer(builder.ConnectionString);
    using (HierarchyDb db = new(options.Options))
    {
      bool deleted = await db.Database.EnsureDeletedAsync();
      WriteLine($"Database deleted: {deleted}");
    
      bool created = await db.Database.EnsureCreatedAsync();
      WriteLine($"Database created: {created}");
      WriteLine("SQL script used to create the database:");
      WriteLine(db.Database.GenerateCreateScript());
      if (db.Students is null || !db.Students.Any())
      {
        WriteLine("There are no students.");
      }
      else
      {
        foreach (Student student in db.Students)
        {
          WriteLine("{0} studies {1}",
            student.Name, student.Subject);
        }
      }
      if (db.Employees is null || !db.Employees.Any())
      {
        WriteLine("There are no employees.");
      }
      else
      {
        foreach (Employee employee in db.Employees)
        {
          WriteLine("{0} was hired on {1}",
            employee.Name, employee.HireDate);
        }
      }
      if (db.People is null || !db.People.Any())
      {
        WriteLine("There are no people.");
      }
      else
      {
        foreach (Person person in db.People)
        {
          WriteLine("{0} has ID of {1}",
            person.Name, person.Id);
        }
      }
    } 
    
  11. 启动控制台应用程序,并注意结果,包括创建的单个名为 People 的表,如下所示:

    Database deleted: False
    Database created: True
    SQL script used to create the database:
    CREATE TABLE [People] (
        [Id] int NOT NULL IDENTITY,
        [Name] nvarchar(40) NOT NULL,
        [Discriminator] nvarchar(8) NOT NULL,
        [HireDate] datetime2 NULL,
        [Subject] nvarchar(max) NULL,
        CONSTRAINT [PK_People] PRIMARY KEY ([Id])
    );
    GO
    IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Discriminator', N'Name', N'Subject') AND [object_id] = OBJECT_ID(N'[People]'))
        SET IDENTITY_INSERT [People] ON;
    INSERT INTO [People] ([Id], [Discriminator], [Name], [Subject])
    VALUES (1, N'Student', N'Roman Roy', N'History');
    IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Discriminator', N'Name', N'Subject') AND [object_id] = OBJECT_ID(N'[People]'))
        SET IDENTITY_INSERT [People] OFF;
    GO
    IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Discriminator', N'HireDate', N'Name') AND [object_id] = OBJECT_ID(N'[People]'))
        SET IDENTITY_INSERT [People] ON;
    INSERT INTO [People] ([Id], [Discriminator], [HireDate], [Name])
    VALUES (2, N'Employee', '2014-04-02T00:00:00.0000000', N'Kendall Roy'),
    (3, N'Employee', '2020-09-12T00:00:00.0000000', N'Siobhan Roy');
    IF EXISTS (SELECT * FROM [sys].[identity_columns] WHERE [name] IN (N'Id', N'Discriminator', N'HireDate', N'Name') AND [object_id] = OBJECT_ID(N'[People]'))
        SET IDENTITY_INSERT [People] OFF;
    GO
    Roman Roy studies History
    Kendall Roy was hired on 02/04/2014 00:00:00
    Siobhan Roy was hired on 12/09/2020 00:00:00
    Roman Roy has ID of 1
    Kendall Roy has ID of 2
    Siobhan Roy has ID of 3 
    
  12. 在您首选的数据库工具中查看 People 表的内容,如图 3.2 所示:

图片

图 3.2:使用 TPH 映射策略时的人员表

  1. 关闭对 HierarchyMapping 数据库的连接。

  2. HierarchyDb.cs 中,注释掉配置 TPH 的方法调用,并添加一个调用配置 TPT 的方法的调用,如下所示的高亮代码:

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
      modelBuilder.Entity<Person>()
    **// .UseTphMappingStrategy();**
     **.UseTptMappingStrategy();** 
    
  3. 启动控制台应用程序,并注意结果,包括创建的三个名为 PeopleStudentsEmployees 的表,如下所示的部分输出:

    Database deleted: True
    Database created: True
    SQL script used to create the database:
    CREATE TABLE [People] (
        [Id] int NOT NULL IDENTITY,
        [Name] nvarchar(40) NOT NULL,
        CONSTRAINT [PK_People] PRIMARY KEY ([Id])
    );
    GO
    CREATE TABLE [Employees] (
        [Id] int NOT NULL,
        [HireDate] datetime2 NOT NULL,
        CONSTRAINT [PK_Employees] PRIMARY KEY ([Id]),
        CONSTRAINT [FK_Employees_People_Id] FOREIGN KEY ([Id]) REFERENCES [People] ([Id])
    );
    GO
    CREATE TABLE [Students] (
        [Id] int NOT NULL,
        [Subject] nvarchar(max) NULL,
        CONSTRAINT [PK_Students] PRIMARY KEY ([Id]),
        CONSTRAINT [FK_Students_People_Id] FOREIGN KEY ([Id]) REFERENCES [People] ([Id])
    );
    GO 
    
  4. 在您首选的数据库工具中查看表的内容,如图 3.3 所示:

图片

图 3.3:使用 TPT 映射策略时的表

  1. 关闭对 HierarchyMapping 数据库的连接。

  2. HierarchyDb.cs 中,注释掉配置 TPT 的方法调用,并添加一个调用配置 TPC 的方法的调用,因为我们需要始终添加三个示例行,如下所示的高亮代码:

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
      modelBuilder.Entity<Person>()
    **// .UseTphMappingStrategy();**
    **// .UseTptMappingStrategy();**
     **.UseTpcMappingStrategy()**
     **.Property(person => person.Id)**
     **.HasDefaultValueSql(****"****NEXT VALUE FOR [PersonIds]"****);**
     **modelBuilder.HasSequence<****int****>(****"PersonIds"****, builder =>**
     **{**
     **builder.StartsAt(****4****);**
     **});** 
    
  3. 启动控制台应用程序,并注意结果,包括创建的两个名为 StudentsEmployees 的表以及从 4 开始的共享序列,如下所示的部分输出:

    CREATE SEQUENCE [PersonIds] AS int START WITH 4 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;
    GO
    CREATE TABLE [Employees] (
        [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [PersonIds]),
        [Name] nvarchar(40) NOT NULL,
        [HireDate] datetime2 NOT NULL,
        CONSTRAINT [PK_Employees] PRIMARY KEY ([Id])
    );
    GO
    CREATE TABLE [Students] (
        [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [PersonIds]),
        [Name] nvarchar(40) NOT NULL,
        [Subject] nvarchar(max) NULL,
        CONSTRAINT [PK_Students] PRIMARY KEY ([Id])
    );
    GO 
    
  4. 在您首选的数据库工具中查看表的内容,如图 3.4 所示:

图片

图 3.4:使用 TPC 映射策略时的表

  1. 关闭对 HierarchyMapping 数据库的连接。

  2. Program.cs中,在将database create脚本写入控制台后的语句之后,添加一些语句来使用当前数据库上下文添加两个新的人,如下所示的高亮代码:

    WriteLine(db.Database.GenerateCreateScript());
    **if** **((db.Employees** **is****not****null****) && (db.Students** **is****not****null****))**
    **{**
     **db.Students.Add(****new** **Student { Name =** **"Connor Roy"****,** 
     **Subject =** **"Politics"** **});**
     **db.Employees.Add(****new** **Employee { Name =** **"Kerry Castellabate"****,** 
     **HireDate = DateTime.UtcNow });**
    **int** **result = db.SaveChanges();**
     **WriteLine(****$"****{result}** **people added."****);**
    **}** 
    
  3. 启动控制台应用程序,并注意结果,包括使用数据库上下文添加的两个新的人,其 ID 从 4 开始,如下面的部分输出所示:

    2 people added.
    Roman Roy studies History
    Connor Roy studies Politics
    Kendall Roy was hired on 02/04/2014 00:00:00
    Siobhan Roy was hired on 12/09/2020 00:00:00
    Kerry Castellabate was hired on 19/05/2023 10:13:53
    Kendall Roy has ID of 2
    Siobhan Roy has ID of 3
    Kerry Castellabate has ID of 4
    Roman Roy has ID of 1
    Connor Roy has ID of 5 
    

你现在已经看到了对象关系映射器如 EF Core 如何定义一个对象继承层次结构,并以三种不同的方式将其映射到基础数据库结构中的一个或多个相关表中。你还看到了 Code First 如何与这很好地工作,因为每次项目启动时都很容易删除和重新创建数据库。

构建可重用的实体数据模型

实际应用通常需要与关系型数据库或其他数据存储中的数据进行交互。在本章早期,我们定义了 EF Core 模型,这些模型在同一个控制台应用程序项目中使用。

现在,我们将为 Northwind 数据库定义一个实体数据模型,作为一对可重用的类库。这对中的一部分将定义实体,如ProductCustomer。这对的另一部分将定义数据库中的表以及如何连接到数据库的默认配置,并使用 Fluent API 来配置模型的附加选项。这对类库将在后续章节中创建的许多应用程序和服务中使用。

良好实践:你应该为你的实体数据模型创建一个单独的类库项目。这允许后端 Web 服务器和前端桌面、移动和 Blazor 客户端之间更容易共享。

使用 SQL Server 创建实体模型类库

你现在将使用dotnet-ef工具创建实体模型:

  1. 添加一个新项目,如下列所示:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:Northwind.Common.EntityModels.SqlServer

    • 解决方案文件和文件夹:Chapter03

  2. Northwind.Common.EntityModels.SqlServer项目中,将警告视为错误,并为 SQL Server 数据库提供者和 EF Core 设计时支持添加包引用,如下所示的高亮标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference**
     **Include="Microsoft.EntityFrameworkCore.SqlServer" Version="****8.0.0****" />**
     **<PackageReference** 
     **Include="Microsoft.EntityFrameworkCore.Design" Version="****8.0.0****">**
     **<PrivateAssets>all</PrivateAssets>**
     **<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>**
     **</PackageReference>** 
     **</ItemGroup>**
    </Project> 
    

    更多信息:如果你不熟悉如何像Microsoft.EntityFrameworkCore.Design这样的包管理它们的资产,你可以在以下链接了解更多信息:learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#controlling-dependency-assets

  3. 删除Class1.cs文件。

  4. 构建项目Northwind.Common.EntityModels.SqlServer

  5. 打开Northwind.Common.EntityModels.SqlServer文件夹的命令提示符或终端。

    下一个步骤假设有一个用于本地 SQL Server 的 Windows 集成安全认证的数据库连接字符串。如果需要,请将其修改为 Azure SQL 数据库或 Azure SQL Edge,使用用户 ID 和密码。

  6. 在命令行中,为所有表生成实体类模型,如下面的命令所示:

    dotnet ef dbcontext scaffold "Data Source=.;Initial Catalog=Northwind;Integrated Security=true;TrustServerCertificate=True;" Microsoft.EntityFrameworkCore.SqlServer --namespace Northwind.EntityModels --data-annotations 
    

    注意以下内容:

    • 执行的命令:dbcontext scaffold

    • 连接字符串:"``Data Source=.;Initial Catalog=Northwind;Integrated Security=true;TrustServerCertificate=True;"

    • 数据库提供程序:Microsoft.EntityFrameworkCore.SqlServer

    • 生成类的命名空间:--namespace Northwind.EntityModels

    • 要使用数据注释以及 Fluent API:--data-annotations

  7. 注意,生成了 28 个类,从AlphabeticalListOfProduct.csTerritory.cs

  8. NorthwindContext.cs中,删除包括警告和连接字符串的OnConfiguring方法。

  9. Customer.cs中,dotnet-ef工具正确识别出CustomerId列是主键,并且其长度限制为最多五个字符,但我们还希望这些值始终为大写。因此,添加一个正则表达式来验证其主键值,只允许大写西文字符,如下面的代码所示(高亮显示):

    [Key]
    [StringLength(5)]
    **[****RegularExpression(****"[A-Z]{5}"****)****]** 
    public string CustomerId { get; set; } = null!; 
    

使用 SQL Server 创建数据上下文类库

接下来,您将数据库表示的上下文模型移动到单独的类库中:

  1. 添加一个新项目,如下面的列表所示:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:Northwind.Common.DataContext.SqlServer

    • 解决方案文件和文件夹:Chapter03

    • DataContext项目中,将EntityModels项目添加为项目引用,并将 EF Core 数据提供程序添加为 SQL Server 的包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference 
        Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include="..\Northwind.Common.EntityModels
    .SqlServer\Northwind.Common.EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    

    警告!项目引用的路径在项目文件中不应有换行符。

  2. Northwind.Common.DataContext.SqlServer项目中,删除Class1.cs文件。

  3. 构建Northwind.Common.DataContext.SqlServer项目。

  4. NorthwindContext.cs文件从Northwind.Common.EntityModels.SqlServer项目/文件夹移动到Northwind.Common.DataContext.SqlServer项目/文件夹。

  5. Northwind.Common.DataContext.SqlServer项目中,添加一个名为NorthwindContextExtensions.cs的类,并修改其内容以定义一个扩展方法,该方法将 Northwind 数据库上下文添加到依赖服务集合中,如下面的代码所示:

    using Microsoft.Data.SqlClient; // SqlConnectionStringBuilder
    using Microsoft.EntityFrameworkCore; // UseSqlServer
    using Microsoft.Extensions.DependencyInjection; // IServiceCollection
    namespace Northwind.EntityModels;
    public static class NorthwindContextExtensions
    {
      /// <summary>
      /// Adds NorthwindContext to the specified IServiceCollection. Uses the SqlServer database provider.
      /// </summary>
      /// <param name="services">The service collection.</param>
      /// <param name="connectionString">Set to override the default.</param>
      /// <returns>An IServiceCollection that can be used to add more services.</returns>
      public static IServiceCollection AddNorthwindContext(
        this IServiceCollection services,
        string? connectionString = null)
      {
        if (connectionString == null)
        {
          SqlConnectionStringBuilder builder = new();
          builder.DataSource = ".";
          builder.InitialCatalog = "Northwind";
          builder.TrustServerCertificate = true;
          builder.MultipleActiveResultSets = true;
          // If using Azure SQL Edge.
          // builder.DataSource = "tcp:127.0.0.1,1433";
          // Because we want to fail fast. Default is 15 seconds.
          builder.ConnectTimeout = 3;
          // If using Windows Integrated authentication.
          builder.IntegratedSecurity = true;
          // If using SQL Server authentication.
          // builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");
          // builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");
          connectionString = builder.ConnectionString;
        }
        services.AddDbContext<NorthwindContext>(options =>
        {
          options.UseSqlServer(connectionString);
          // Log to console when executing EF Core commands.
          options.LogTo(Console.WriteLine,
            new[] { Microsoft.EntityFrameworkCore
              .Diagnostics.RelationalEventId.CommandExecuting });
        },
        // Register with a transient lifetime to avoid concurrency 
        // issues with Blazor Server projects.
        contextLifetime: ServiceLifetime.Transient, 
        optionsLifetime: ServiceLifetime.Transient);
        return services;
      }
    } 
    
  6. 构建两个类库,并修复任何编译器错误。

良好实践:我们为AddNorthwindContext方法提供了一个可选参数,以便我们可以覆盖 SQL Server 数据库连接字符串。这将使我们更加灵活,例如,可以从配置文件中加载这些值。

实体创建时的计算属性

EF Core 7 添加了一个 IMaterializationInterceptor 接口,允许在创建实体之前和之后以及属性初始化时进行拦截。这对于计算值很有用。

例如,当服务或客户端应用程序请求向用户显示的实体时,它可能想要缓存一段时间内的实体副本。为此,它需要知道实体上次刷新的时间。如果在加载实体时自动生成并存储此信息,将非常有用。

要实现这个目标,我们必须完成四个步骤:

  1. 首先,定义一个具有额外属性的接口。

  2. 接下来,至少必须有一个实体模型类实现该接口。

  3. 然后,定义一个实现拦截器接口的类,该接口有一个名为 InitializedInstance 的方法,它将在任何实体上执行,如果该实体实现了具有额外属性的定制接口,则将设置其值。

  4. 最后,我们必须创建拦截器的实例并将其注册到数据上下文类中。

现在让我们为 Northwind Employee 实体实现这个功能:

  1. Northwind.Common.EntityModels.SqlServer 项目中,添加一个名为 IHasLastRefreshed.cs 的新文件,并修改其内容以定义接口,如下所示:

    namespace Northwind.EntityModels;
    public interface IHasLastRefreshed
    {
      DateTimeOffset LastRefreshed { get; set; }
    } 
    
  2. Northwind.Common.EntityModels.SqlServer 项目中,添加一个名为 EmployeePartial.cs 的新文件,并修改其内容以实现接口,如下所示(高亮部分):

    using System.ComponentModel.DataAnnotations.Schema; // [NotMapped]
    namespace Northwind.EntityModels;
    public partial class Employee **:** **IHasLastRefreshed**
    {
     **[****NotMapped****]**
    **public** **DateTimeOffset LastRefreshed {** **get****;** **set****; }**
    } 
    

    良好实践:将此类扩展代码添加到单独的部分实体类文件中,这样您就可以稍后使用 dotnet-ef 工具重新生成 Employee.cs 文件,而不会覆盖您的附加代码。

  3. Northwind.Common.DataContext.SqlServer 项目中,添加一个名为 SetLastRefreshedInterceptor.cs 的新文件,并修改其内容以定义拦截器,如下所示:

    // IMaterializationInterceptor, MaterializationInterceptionData
    using Microsoft.EntityFrameworkCore.Diagnostics;
    namespace Northwind.EntityModels;
    public class SetLastRefreshedInterceptor : IMaterializationInterceptor
    {
      public object InitializedInstance(
        MaterializationInterceptionData materializationData,
        object entity)
      {
        if (entity is IHasLastRefreshed entityWithLastRefreshed)
        {
          entityWithLastRefreshed.LastRefreshed = DateTimeOffset.UtcNow;
        }
        return entity;
      }
    } 
    
  4. Northwind.Common.DataContext.SqlServer 项目中,在 NorthwindContext.cs 中删除现有的 OnConfiguring 方法。

  5. Northwind.Common.DataContext.SqlServer 项目中,添加一个名为 NorthwindContextPartial.cs 的新文件,然后在 OnConfiguring 方法中声明和注册拦截器,如下所示:

    using Microsoft.Data.SqlClient; // SqlConnectionStringBuilder
    using Microsoft.EntityFrameworkCore; // DbContext
    namespace Northwind.EntityModels;
    public partial class NorthwindContext : DbContext
    {
      private static readonly SetLastRefreshedInterceptor
        setLastRefreshedInterceptor = new();
      protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
      {
        if (!optionsBuilder.IsConfigured)
        {
          SqlConnectionStringBuilder builder = new();
          builder.DataSource = ".";
          builder.InitialCatalog = "Northwind";
          builder.TrustServerCertificate = true;
          builder.MultipleActiveResultSets = true;
          // Because we want to fail fast. Default is 15 seconds.
          builder.ConnectTimeout = 3;
          // If using Windows Integrated authentication.
          builder.IntegratedSecurity = true;
          // If using SQL Server authentication.
          // builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");
          // builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");
          optionsBuilder.UseSqlServer(builder.ConnectionString);
        }
        optionsBuilder.AddInterceptors(setLastRefreshedInterceptor);
      }
    } 
    
  6. 保存更改。

创建测试项目以检查类库的集成

由于我们将在本章中不创建使用 EF Core 模型的客户端项目,因此我们应该创建一个测试项目以确保数据库上下文和实体模型正确集成:

  1. 使用您首选的编码工具向 Chapter03 解决方案添加一个名为 Northwind.Common.EntityModels.Tests 的新 xUnit 测试项目 [C#] / xunit 项目。

  2. Northwind.Common.EntityModels.Tests.csproj 中,修改配置以将警告视为错误,并添加一个包含对 Northwind.Common.DataContext.SqlServer 项目的项目引用的项组,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    警告! 在你的项目文件中,项目参考路径不应该包含换行符。

  3. 构建并还原 Northwind.Common.EntityModels.Tests 项目的依赖项。

为实体模型编写单元测试

一个编写良好的单元测试将包含三个部分:

  • 安排:这部分将声明和实例化输入和输出的变量。

  • 行动:这部分将执行你正在测试的单元。在我们的例子中,这意味着调用我们想要测试的方法。

  • 断言:这部分将对输出进行一个或多个断言。断言是一种信念,如果它不成立,则表明测试失败。例如,当添加 2 和 2 时,我们期望结果是 4。

现在,我们将为 NorthwindContext 和实体模型类编写一些单元测试:

  1. 将文件 UnitTest1.cs 重命名为 NorthwindEntityModelsTests.cs,然后打开它。

  2. 在 Visual Studio Code 中,将类重命名为 NorthwindEntityModelsTests。(当你重命名文件时,Visual Studio 会提示你重命名类。)

  3. 修改 NorthwindEntityModelsTests 类以导入 Northwind.EntityModels 命名空间,并添加一些测试方法以确保上下文类可以连接,确保提供者是 SQL Server,并确保第一个产品命名为 Chai,如下代码所示:

    using Northwind.EntityModels;
    namespace Northwind.Common.EntityModels.Tests
    {
      public class NorthwindEntityModelsTests
      {
        [Fact]
        public void CanConnectIsTrue()
        {
          using (NorthwindContext db = new()) // arrange
          {
            bool canConnect = db.Database.CanConnect(); // act
            Assert.True(canConnect); // assert
          }
        }
        [Fact]
        public void ProviderIsSqlServer()
        {
          using (NorthwindContext db = new())
          {
            string? provider = db.Database.ProviderName;
            Assert.Equal("Microsoft.EntityFrameworkCore.SqlServer", provider);
          }
        }
        [Fact]
        public void ProductId1IsChai()
        {
          using (NorthwindContext db = new())
          {
            Product product1 = db.Products.Single(p => p.ProductId == 1);
            Assert.Equal("Chai", product1.ProductName);
          }
        }
        [Fact]
        public void EmployeeHasLastRefreshedIn10sWindow()
        {
          using (NorthwindContext db = new())
          {
            Employee employee1 = db.Employees.Single(p => p.EmployeeId == 1);
            DateTimeOffset now = DateTimeOffset.UtcNow;
            Assert.InRange(actual: employee1.LastRefreshed,
              low: now.Subtract(TimeSpan.FromSeconds(5)),
              high: now.AddSeconds(5));
          }
        }
      }
    } 
    

使用 Visual Studio 2022 运行单元测试

现在我们准备运行单元测试并查看结果:

  1. 在 Visual Studio 2022 中,导航到 测试 | 运行所有测试

  2. 测试资源管理器 中,注意结果表明运行了四个测试,并且全部通过,如 图 3.5 所示:

图 3.5:所有单元测试通过

使用 Visual Studio Code 运行单元测试

现在我们准备运行单元测试并查看结果:

  1. 在 Visual Studio Code 中,在 Northwind.Common.EntityModels.Tests 项目的 终端 窗口中运行测试,如下命令所示:

    dotnet test 
    

    如果你使用 C# 开发工具包,那么你也可以从 主侧栏测试 部分构建测试项目并运行测试。

  2. 在输出中,注意结果表明运行了四个测试,并且全部通过。

    作为一项可选任务,你能想到其他可以编写的测试来确保数据库上下文和实体模型是正确的吗?

练习和探索

通过回答一些问题、进行一些动手实践,以及通过深入研究本章主题来测试你的知识和理解。

练习 3.1 – 测试你的知识

回答以下问题:

  1. dotnet-ef 工具可以用作什么?

  2. 用于表示表的属性的类型是什么,例如数据上下文的 Products 属性?

  3. 用于表示一对一关系的属性的类型是什么,例如 Category 实体的 Products 属性?

  4. EF Core 的主键约定是什么?

  5. 为什么你可能会选择 Fluent API 而不是属性注解?

  6. 为什么你可能会在实体类型中实现 IMaterializationInterceptor 接口?

练习 3.2 – 练习将 ADO.NET 与 EF Core 进行基准测试

Chapter03 解决方案中,创建一个名为 Ch03Ex02_ADONETvsEFCore 的控制台应用程序,使用 Benchmark.NET 比较使用 ADO.NET (SqlClient) 和使用 EF Core 从 Northwind 数据库检索所有产品。

您可以通过阅读以下链接中的在线部分 Benchmarking Performance and Testing 来了解如何使用 Benchmark.NET:github.com/markjprice/apps-services-net8/blob/main/docs/ch01-benchmarking.md.

练习 3.3 – 审查性能选择

数据层可以对应用程序或服务的整体性能产生不成比例的影响。

文档:learn.microsoft.com/en-us/ef/core/performance/

练习 3.4 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-3---building-entity-models-for-sql-server-using-ef-core

摘要

在本章中,你学习了:

  • 如何使用较慢但更面向对象的 EF Core 执行简单查询并处理结果。

  • 如何配置和决定在类型层次结构中使用三种映射策略。

  • 如何在实体创建时实现计算属性。

在下一章中,你将学习如何使用 Azure Cosmos DB 进行云原生数据存储。

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描以下二维码:

packt.link/apps_and_services_dotnet8

二维码

第四章:使用 Azure Cosmos DB 管理 NoSQL 数据

本章将介绍如何使用 Azure Cosmos DB 管理 NoSQL 数据。您将了解 Cosmos DB 的一些关键概念,如其 API、数据建模方式以及吞吐量配置,这些都会影响成本。您将使用本地模拟器和 Azure 云创建一些 Cosmos DB 资源。然后您将学习如何使用 Core(SQL)API 处理更传统的数据。

在一个可选的在线部分,您可以学习如何使用 Gremlin API 处理图数据。

本章将涵盖以下主题:

  • 理解 NoSQL 数据库

  • 创建 Cosmos DB 资源

  • 使用 Core(SQL)API 操作数据

  • 探索服务器端编程

  • 清理 Azure 资源

理解 NoSQL 数据库

存储数据的两个最常见地方是在关系数据库管理系统(RDBMS)中,如 SQL Server、PostgreSQL、MySQL 和 SQLite,或者是在 NoSQL 数据库中,如 Azure Cosmos DB、Redis、MongoDB 和 Apache Cassandra。

关系数据库是在 20 世纪 70 年代发明的。它们使用结构化查询语言(SQL)进行查询。当时,数据存储成本很高,因此它们通过称为规范化的过程尽可能减少数据冗余。数据存储在具有行和列的表格结构中,一旦在生产中重构就变得复杂。它们可能难以扩展且成本高昂。

NoSQL 数据库不仅仅意味着“非 SQL”,它们也可以意味着“不仅 SQL”。它们是在 2000 年代发明的,在互联网和万维网变得流行并采纳了那个时代软件的许多学习成果之后。

它们被设计用于大规模可扩展性和高性能,通过提供最大灵活性和允许随时进行模式更改(因为它们不强制执行结构)来简化编程。

Cosmos DB 及其 API

Azure Cosmos DB 是一个支持多个 API 的 NoSQL 数据存储。其原生 API 基于 SQL。它还支持 MongoDB、Cassandra 和 Gremlin 等替代 API。

Azure Cosmos DB 以原子记录序列(ARS)格式存储数据。您可以通过在创建数据库时选择的 API 与这些数据交互:

  • MongoDB API支持最新的 MongoDB 线协议版本,允许现有客户端像与实际的 MongoDB 数据库交互一样处理数据。可以使用mongodumpmongorestore等工具将任何现有数据移动到 Azure Cosmos DB。您可以在以下链接中查看最新的 MongoDB 支持:learn.microsoft.com/en-us/azure/cosmos-db/mongodb/mongodb-introduction#how-the-api-works

  • Cassandra API支持 Cassandra 查询语言(CQL)线协议版本 4,允许现有客户端像与实际的 Cassandra 数据库交互一样处理数据。

  • 对于一个新项目,有时被称为“绿地”项目,Microsoft 建议使用Core(SQL)API

  • 对于使用替代 API 的现有项目,您可以选择使用适当的 API,这样您的客户端和工具就不需要更新,同时获得存储在 Azure Cosmos DB 中的数据的好处。这降低了迁移成本。

  • 如果数据项之间的关系具有需要分析的元数据,那么使用Cosmos DB 的 Gremlin API将 Cosmos DB 作为图数据存储来处理是一个不错的选择。

良好实践:如果您不确定要选择哪个 API,请选择默认的 Core(SQL)。

在这本书中,我们将首先使用 Cosmos DB 的本地 Core(SQL)API。这允许开发人员使用像 SQL 这样的语言查询 JSON 文档。Core(SQL)API 使用 JSON 的类型系统和 JavaScript 的函数系统。

文档建模

一个典型的 JSON 文档,代表来自 Northwind 数据库的产品,这是我们用于第二章使用 SQL Server 管理关系数据的示例数据库,当存储在 Azure Cosmos DB 中时可能看起来如下:

{
  "id": "1",
  "productId": "1",
  "productName": "Chai",
  "supplier": {
    "supplierId": 1,
    "companyName": "Exotic Liquids",
    "contactName": "Charlotte Cooper",
    "Address": "49 Gilbert St.",
    "City": "London",
    "Country": "UK",
    "Phone": "(171) 555-2222"
  },
  "category": {
    "categoryId": 1,
    "categoryName": "Beverages",
    "description": "Soft drinks, coffees, teas, beers, and ales",
    "image": "https://myaccount.blob.core.windows.net/categories/beverages.png"
  },
  "quantityPerUnit": "10 boxes x 20 bags",
  "unitPrice": 18.0000,
  "unitsInStock": 39,
  "unitsOnOrder": 0,
  "reorderLevel": 10,
  "discontinued": false
} 

与关系数据库模型不同,嵌入相关数据是常见的,这涉及到在多个产品中重复数据,如类别和供应商信息。如果相关数据是有限的,这是一种良好的实践。

例如,对于一个产品,将只有一个供应商和一个类别,所以这些关系被限制为只有一个,这意味着每个都是有限的。如果我们正在模拟一个类别并决定嵌入其相关产品,那么这可能是不良实践,因为将所有产品细节作为一个数组存储将是无界的。相反,我们可能选择只为每个产品存储一个唯一的标识符,并引用存储在其他地方的产品详情。

您还应该考虑相关数据更新的频率。需要更新的频率越高,您就越应该避免嵌入。如果相关数据是无界的但更新频率较低,那么嵌入可能仍然是一个不错的选择。

故意但谨慎地非规范化数据模型的部分意味着您将需要执行更少的查询和更新以进行常见操作,从而在金钱和性能方面降低成本。

在以下情况下使用嵌入(非规范化数据):

  • 关系是包含的,就像人拥有的财产,或者父母的子女。

  • 关系是一对一或一对少数,即相关数据是有限的。

  • 相关数据需要不频繁的更新。

  • 相关数据通常或总是需要包含在查询结果中。

良好实践:非规范化数据模型提供了更好的读取性能,但写入性能较差。

想象一下,你想要在流行的新闻网站上对一篇文章及其评论进行建模。评论数量是不受限制的,对于一篇引人入胜的文章,评论通常会频繁添加,尤其是在发布后的数小时或数天内,尤其是当它是时事新闻时。或者想象一个进行股票交易的投资者。该股票的当前价格会频繁更新。

在这些场景中,你可能希望完全或部分地标准化相关数据。例如,你可以选择将最被喜欢的评论直接嵌入到文章顶部显示,其他评论可以单独存储并使用它们的主键进行引用。你可以选择嵌入长期投资(如持有多年的投资)的股票信息,如购买时的价格和自那时起每月第一天的价格(但不包括实时当前价格),但对于短期投资(如日内交易)则引用股票信息。

在以下情况下使用引用(标准化数据):

  • 关系是一对多或多对多,且数量不受限制。

  • 相关数据需要频繁更新。

良好实践:标准化数据模型需要更多的查询,这会降低读取性能但提供更好的写入性能。

你可以在以下链接中了解更多关于在 Azure Cosmos DB 中建模文档的信息:learn.microsoft.com/en-us/azure/cosmos-db/sql/modeling-data

一致性级别

Azure Cosmos DB 是全球分布且可弹性扩展的。它依赖于复制来提供全球范围内的低延迟和高可用性。为了实现这一点,你必须接受并选择权衡。

为了让程序员的编程生活更轻松,你希望数据具有完全的一致性。如果数据在世界上的任何地方被修改,那么任何后续的读取操作都应该看到这个变化。最佳的一致性被称为线性化。线性化增加了写入操作的延迟并减少了读取操作的可用性,因为它必须等待全局复制完成。

更宽松的一致性级别可以提高延迟和可用性,但可能会增加程序员的复杂性,因为数据可能不一致。

大多数 NoSQL 数据库只提供两种一致性级别:强一致性和最终一致性。Azure Cosmos DB 提供五种,以提供适合你项目的确切一致性级别。

你选择数据的一致性级别,这将由以下服务级别协议SLA)保证,按从最强到最弱排序:

  • 一致性保证了全球所有区域内的线性化。所有其他一致性级别统称为“宽松”。您可能会问:“为什么不在所有场景中都设置强一致性?”如果您熟悉关系数据库,那么您应该熟悉事务隔离级别。这些在概念上类似于 NoSQL 一致性级别。事务隔离级别的最强级别是SERIALIZABLE。较弱的级别包括READUNCOMMITTEDREPEATABLE READ。您不会想在所有场景中都使用SERIALIZABLE,原因与您不会想在所有场景中都使用强一致性的原因相同。它们都会减慢操作,有时甚至达到无法接受的程度。您的用户会抱怨性能不足,甚至无法执行任务。因此,您需要仔细查看您尝试执行的任务,并确定该任务所需的最小级别。一些开发者更喜欢默认为最强级别,并在“太慢”的场景中降低级别。其他开发者更喜欢默认为最弱级别,并在引入太多不一致性的场景中加强级别。随着您对 NoSQL 开发的熟悉程度提高,您将能够更快地判断不同场景的最佳级别。

  • 有界不新鲜一致性保证了在写入区域中读取自己的写入、区域内的单调读取(意味着值不会增加或减少,就像单调的声音一样,并保持一致顺序),以及一致前缀,并且读取数据的不新鲜度被限制在特定数量的版本上,这些版本在指定的时间间隔内落后于写入。例如,时间间隔可能是十分钟,版本数可能是三个。这意味着在任何十分钟内最多可以进行三次写入,然后读取操作必须反映这些更改。

  • 会话一致性保证了在写入区域中读取自己的写入、单调读取和一致前缀的能力。

  • 一致前缀一致性仅保证写入可以被读取的顺序。

  • 最终一致性不保证写入的顺序将与读取的顺序匹配。当写入暂停时,随着副本同步,读取最终会赶上。客户端可能读取到比之前读取的值更旧的值。概率有界不新鲜PBS)是一个衡量当前一致性最终性的度量。您可以在 Azure 门户中监控它。

    您可以在以下链接中了解更多有关一致性级别的详细信息:learn.microsoft.com/en-us/azure/cosmos-db/consistency-levels.

组件层次结构

Azure Cosmos DB 的组件层次结构如下:

  • 账户:您可以通过 Azure 门户创建最多 50 个账户。

  • 数据库:每个账户可以有无限数量的数据库。我们将创建一个名为Northwind的数据库。

  • 容器:每个数据库可以有无限数量的容器。我们将创建一个名为Products的容器。

  • 分区:这些是在容器内自动创建和管理的,并且您可以有无限多个。分区可以是逻辑的或物理的。一个逻辑分区包含具有相同分区键的项目,并定义了事务的作用域。多个逻辑分区映射到一个物理分区。小型容器可能只需要一个物理分区。您不需要担心物理分区,因为您无法控制它们。关注决定您的分区键应该是什么,因为这定义了存储在逻辑分区中的项目。

  • Item:这是容器中存储的实体。我们将添加代表每个产品的项目,例如奶茶。

“Item”是一个故意通用的术语,由 Core(SQL)API 用来指代 JSON 文档,但也可以用于其他 API。其他 API 也有它们自己的更具体的术语:

  • Cassandra 使用

  • MongoDB 使用文档

  • 类似于 Gremlin 的图数据库使用顶点

带宽配置

带宽以每秒请求单位RU/s)来衡量。单个请求单位RU)大约是使用其唯一标识符对 1KB 文档执行GET请求的成本。创建、更新和删除的成本更高 RU;例如,一个查询可能成本 46.54 RU,或者一个删除操作可能成本 14.23 RU。

带宽必须在事先进行配置,尽管您可以在任何时间以 100 RU/s 的增量或减量进行上下调整。您将按小时计费。

您可以通过获取RequestCharge属性来发现一个请求在 RU(请求单位)中的成本。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/azure/cosmos-db/sql/find-request-unit-charge。在本章中运行的示例代码中,我们将输出此属性。

您必须配置带宽以运行 CRUD 操作(创建、读取、更新和删除)。您必须通过计算您需要支持的年度操作数量来估计带宽。例如,一个电子商务网站可能需要在美国的感恩节或中国的光棍节预期更大的带宽。

大多数带宽设置都是在容器级别应用的,或者您也可以在数据库级别进行,并将设置共享到所有容器中。带宽在分区之间平均分配。

一旦配置的带宽耗尽,Cosmos DB 将开始对访问请求进行速率限制,并且您的代码将不得不等待并稍后重试。幸运的是,我们将使用 Cosmos DB 的.NET SDK,它将自动读取retry-after响应头并在该时间限制之后重试。

使用 Azure 门户,您可以在 400 RU/s 和 250,000 RU/s 之间进行配置。在撰写本文时,400 RU/s 的最低费用约为每月 35 美元。然后您还需要根据您想要存储的 GB 数添加存储费用,例如,存储少量 GB 的费用为 5 美元。

Cosmos DB 的免费层允许最高 1,000 RU/s 和 25 GB 的存储。您可以在以下链接中使用计算器:cosmos.azure.com/capacitycalculator/

影响 RU 的因素:

  • 项目大小:2KB 的文档比 1KB 的文档成本高两倍。

  • 索引属性:索引所有项目属性比索引属性子集的成本更高。

  • 一致性:严格的致性比宽松的致性多花费两倍的 RU。

  • 查询复杂度:谓词(过滤器)的数量、结果的数量、自定义函数的数量、投影、数据集的大小等,都会增加 RU 的成本。

分区策略

一个好的分区策略允许 Cosmos DB 数据库高效地增长并运行查询和事务。一个好的分区策略是选择一个合适的分区键。它为容器设置后不能更改。

分区键应选择以均匀分布在数据库中的操作,以避免热点分区,即处理更多请求、比其他分区更繁忙的分区。

对于一个项目将唯一且经常用于查找项目的属性,可能是一个不错的选择。例如,对于美国公民,一个人的社会保障号码。然而,分区键不必是唯一的。分区键值将与项目 ID 结合,以唯一标识一个项目。

当需要时,Cosmos DB 会自动创建分区。自动创建和删除分区对您的应用程序和服务没有负面影响。每个分区可以增长到最大 20 GB。当需要时,Cosmos DB 会自动拆分分区。

容器应具有以下属性的分区键:

  • 高基数,以便项目在分区之间均匀分布。

  • 在分区之间均匀分布请求。

  • 在分区之间均匀分布存储。

数据存储设计

在关系数据库中,模式是刚性和不灵活的。Northwind 数据库的产品都是与食品相关的,因此模式可能不会改变很多。但如果你正在为一家从服装到电子产品到书籍都销售的公司构建商业系统,那么以下半结构化数据存储会更好:

  • 服装:尺寸如 S、M、L、XL;品牌;颜色。

  • 鞋子:尺寸如 7、8、9;品牌;颜色。

  • 电视:尺寸如 40 英寸、52 英寸;屏幕技术如 OLED、LCD;品牌。

  • 书籍:页数;作者;出版社。

由于 Azure Cosmos DB 是无模式的,它可以简单地通过向容器添加具有该结构的新产品来添加不同结构和属性的新产品类型。你将在本章稍后编写的代码中看到这个示例。

将数据迁移到 Cosmos DB

开源的 Azure Cosmos DB 数据迁移工具可以从许多不同的来源将数据导入 Azure Cosmos DB,包括 Azure 表存储、SQL 数据库、MongoDB、JSON 和 CSV 格式的文本文件、HBase 等。

本书不会使用此迁移工具,所以如果你认为它对你有用,你可以在以下链接学习如何使用它:github.com/Azure/azure-documentdb-datamigrationtool

理论已经足够多了。现在,让我们看看一些更实际的内容,如何创建 Cosmos DB 资源,以便我们可以在代码中与之交互。

创建 Cosmos DB 资源

要查看 Azure Cosmos DB 的实际应用,首先,我们必须创建 Cosmos DB 资源。我们可以通过 Azure 门户手动在云中创建它们,或者使用 Azure Cosmos DB .NET SDK 以编程方式创建它们。在云中创建的 Azure Cosmos DB 资源除非你使用试用或免费账户,否则会产生费用。

你也可以使用模拟器在本地创建 Azure Cosmos DB 资源,这不会花费你任何费用。截至写作时,Azure Cosmos DB 模拟器仅支持 Windows。如果你想使用 Linux 或 macOS,那么你可以尝试使用目前处于预览阶段的 Linux 模拟器,或者你可以在 Windows 虚拟机上托管模拟器。

使用 Windows 上的模拟器创建 Azure Cosmos DB 资源

如果你没有 Windows 计算机,那么只需阅读本节内容,无需亲自完成步骤,然后在下一节中,你将使用 Azure 门户创建 Azure Cosmos DB 资源。

让我们使用 Windows 上的 Azure Cosmos DB 模拟器创建类似数据库和容器的 Azure Cosmos DB 资源:

  1. 从以下链接下载并安装最新版本的 Azure Cosmos DB 模拟器到你的本地 Windows 计算机(直接到 MSI 安装程序文件):aka.ms/cosmosdb-emulator

    写作时的最新模拟器版本是 2.14.12,发布于 2023 年 3 月 20 日。早期版本的模拟器不受开发团队支持。如果你安装了旧版本,请将其删除并安装最新版本。

  2. 确保 Azure Cosmos DB 模拟器正在运行。

  3. Azure Cosmos DB 模拟器的用户界面应该会自动启动,但如果未启动,请打开你喜欢的浏览器并导航到 https://localhost:8081/_explorer/index.html

  4. 注意,Azure Cosmos DB 模拟器正在运行,托管在 localhost8081 端口上,使用你将需要安全连接到服务的主键,如图 4.1 所示:

    图 4.1:Windows 上的 Azure Cosmos DB 模拟器用户界面

    模拟器的默认主键对每个人都是相同的值。您可以通过在命令行中使用/key开关启动模拟器来指定自己的键值。您可以在以下链接中了解如何在命令行中启动模拟器:learn.microsoft.com/en-us/azure/cosmos-db/emulator-command-line-parameters

  5. 在左侧的导航栏中,点击资源管理器,然后点击新建容器

  6. 完成以下信息:

    • 对于数据库 ID,选择创建新并输入Northwind

    • 选择跨容器共享吞吐量复选框。

    • 对于数据库吞吐量,选择自动缩放

    • 数据库最大 RU/s设置为4000。这将使用至少 400 RU/s,并在需要时自动扩展到 4,000 RU/s。

    • 对于容器 ID,输入Products

    • 对于分区键,输入/productId

  7. 点击确定

  8. 在左侧的树中,展开Northwind数据库,展开Products容器,并选择Items,如图4.2所示:

图 4.2:Northwind 数据库中 Products 容器中的空条目

  1. 在工具栏中点击新建条目

  2. 将编辑器窗口的内容替换为表示名为Chai的产品的 JSON 文档,如下所示 JSON:

    {
      "productId": 1,
      "productName": "Chai",
      "supplier": {
        "supplierId": 1,
        "companyName": "Exotic Liquids",
        "contactName": "Charlotte Cooper",
        "Address": "49 Gilbert St.",
        "City": "London",
        "Country": "UK",
        "Phone": "(171) 555-2222"
      },
      "category": {
        "categoryId": 1,
        "categoryName": "Beverages",
        "description": "Soft drinks, coffees, teas, beers, and ales"
      },
      "quantityPerUnit": "10 boxes x 20 bags",
      "unitPrice": 18,
      "unitsInStock": 39,
      "unitsOnOrder": 0,
      "reorderLevel": 10,
      "discontinued": false
    } 
    
  3. 在工具栏中点击保存,并注意自动添加到任何项目中的额外属性,包括id_etag_ts,如图所示高亮显示的以下 JSON:

    {
        "productId": 1,
        "productName": "Chai",
        "supplier": {
            "supplierId": 1,
            ...
        "reorderLevel": 10,
        "discontinued": false,
    **"id"****:****"2ad4c71d-d0e4-4ebd-a146-bcf052f8d7d6"****,**
    **"_rid"****:****"bmAuAJ9o6I8BAAAAAAAAAA=="****,**
    **"_self"****:****"dbs/bmAuAA==/colls/bmAuAJ9o6I8=/docs/bmAuAJ9o6I8BAAAAAAAAAA==/"****,**
    **"_etag"****:****"\"00000000-0000-0000-8fc2-ec4d49ea01d8\""****,**
    **"_attachments"****:****"attachments/"****,**
    **"_ts"****:****1656952035**
    } 
    
  4. 点击新建条目

  5. 将编辑器窗口的内容替换为表示名为Chang的产品的 JSON 文档,如下所示 JSON:

    {
      "productId": 2,
      "productName": "Chang",
      "supplier": {
        "supplierId": 1,
        "companyName": "Exotic Liquids",
        "contactName": "Charlotte Cooper",
        "Address": "49 Gilbert St.",
        "City": "London",
        "Country": "UK",
        "Phone": "(171) 555-2222"
      },
      "category": {
        "categoryId": 1,
        "categoryName": "Beverages",
        "description": "Soft drinks, coffees, teas, beers, and ales"
      },
      "quantityPerUnit": "24 - 12 oz bottles",
      "unitPrice": 19,
      "unitsInStock": 17,
      "unitsOnOrder": 40,
      "reorderLevel": 25,
      "discontinued": false
    } 
    
  6. 点击保存

  7. 点击新建条目

  8. 将编辑器窗口的内容替换为表示名为Aniseed Syrup的产品的 JSON 文档,如下所示 JSON:

    {
      "productId": 3,
      "productName": "Aniseed Syrup",
      "supplier": {
        "supplierId": 1,
        "companyName": "Exotic Liquids",
        "contactName": "Charlotte Cooper",
        "Address": "49 Gilbert St.",
        "City": "London",
        "Country": "UK",
        "Phone": "(171) 555-2222"
      },
      "category": {
        "categoryId": 2,
        "categoryName": "Condiments",
        "description": "Sweet and savory sauces, relishes, spreads, and seasonings"
      },
      "quantityPerUnit": "12 - 550 ml bottles",
      "unitPrice": 10,
      "unitsInStock": 13,
      "unitsOnOrder": 70,
      "reorderLevel": 25,
      "discontinued": false
    } 
    
  9. 点击保存

  10. 点击列表中的第一个条目,并注意所有条目都已自动分配了 GUID 值作为其id属性,如图4.3所示:

图 4.3:Azure Cosmos DB 模拟器中保存的 JSON 文档项

  1. 在工具栏中点击新建 SQL 查询,并注意默认查询文本是SELECT * FROM c

  2. 修改查询文本以返回由Exotic Liquids供应的所有产品;在工具栏中点击执行查询,并注意所有三个产品都包含在结果数组中,如图4.4所示,以及以下查询:

    SELECT * FROM c WHERE c.supplier.companyName = "Exotic Liquids" 
    

    图 4.4:查询以返回由 Exotic Liquids 供应的所有产品

    关键字不区分大小写,因此WHEREWherewhere相同。属性名称区分大小写,因此CompanyNamecompanyName不同,将返回零结果。

  3. 修改查询文本以返回类别 2 中的所有产品,如下所示查询:

    SELECT * FROM c WHERE c.category.categoryId = 2 
    
  4. 执行查询并注意结果数组中包含一个产品。

使用 Azure 门户创建 Azure Cosmos DB 资源

如果您只想使用 Azure Cosmos DB 模拟器以避免任何费用,则可以跳过此部分,或者只是阅读它而不亲自完成步骤。

现在,让我们使用 Azure 门户在云中创建 Azure Cosmos DB 资源,如账户、数据库和容器:

  1. 如果您没有 Azure 账户,则可以在以下链接免费注册一个:azure.microsoft.com/free/

  2. 导航到 Azure 门户并登录:portal.azure.com/

  3. 在 Azure 门户菜单中,点击+ 创建资源

  4. 创建资源页面上,搜索或点击Azure Cosmos DB,然后在Azure Cosmos DB页面上点击创建

  5. 哪个 API 最适合您的工作负载页面上,在Azure Cosmos DB for NoSQL框中,注意描述,Azure Cosmos DB 的核心或本地 API,用于处理文档。支持使用熟悉的 SQL 查询语言和 .NET、JavaScript、Python 和 Java 客户端库进行快速、灵活的开发,然后点击创建按钮。

  6. 基本选项卡上:

    • 选择您的订阅。我的订阅名为 Pay-As-You-Go

    • 选择一个资源组或创建一个新的。我使用了名称 apps-services-book

    • 输入 Azure Cosmos DB 账户名称。我使用了 apps-services-book

    • 选择一个位置。我选择了(欧洲)英国南部,因为它离我最近。

    • 容量模式设置为预配吞吐量

    • 应用免费层折扣设置为不应用

      良好实践:如果您希望此账户成为您订阅中唯一一个处于免费层的账户,请现在仅应用免费层折扣。您可能最好将此折扣保留给另一个您可能用于实际项目的账户,而不是在阅读此书时用作临时学习账户。使用 Azure Cosmos DB 免费层,您将获得免费的前 1,000 RU/s 和 25 GB 存储空间。您只能在每个订阅的账户上启用一个免费层。微软估计这价值每月 64 美元。

    • 选择限制总账户吞吐量复选框。

  7. 点击下一步:全局分发按钮并查看选项,但保留默认设置。

  8. 点击下一步:网络按钮并查看选项,但保留默认设置。

  9. 点击下一步:备份策略按钮并查看选项,但保留默认设置。

  10. 点击下一步:加密按钮并查看选项,但保留默认设置。

  11. 点击审查 + 创建按钮。

  12. 注意验证成功消息,查看摘要,然后点击创建按钮。

  13. 等待部署完成。这可能需要几分钟。

  14. 点击转到资源按钮。请注意,你可能被引导到快速入门页面,其中包含创建容器等步骤,具体取决于这是你第一次创建 Azure Cosmos DB 账户。

  15. 在左侧导航中,点击概览,并注意你的 Azure Cosmos DB 账户信息,如图 4.5 所示:

图 4.5:Azure Cosmos DB 账户概览页面

  1. 在左侧导航中,在设置部分,点击密钥,并注意用于以编程方式使用此 Azure Cosmos DB 账户所需的URI主键,如图 4.6 所示:

    图 4.6:用于以编程方式与 Azure Cosmos DB 账户工作的密钥

    良好实践:与所有 Cosmos DB 模拟器开发者共享的主键不同,你的 Azure Cosmos DB 主键是唯一的,并且必须保密。我删除了用于编写本章的 Cosmos DB 账户,因此上面的截图中的密钥已失效。

  2. 在左侧导航中,点击数据资源管理器,如果弹出视频,请关闭它。

  3. 在工具栏中,点击新建容器

  4. 从模拟器部分列出的步骤开始,在 Windows 上使用模拟器创建 Azure Cosmos DB 资源,从步骤 6开始填写新容器信息,直到该部分的结尾。

使用 .NET 应用创建 Azure Cosmos DB 资源

接下来,我们将创建一个控制台应用程序项目,用于在本地模拟器或云端创建相同的 Azure Cosmos DB 资源,具体取决于你选择的 URI 和主键:

  1. 使用你偏好的代码编辑器创建一个新项目,如下列所示:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter04

    • 项目文件和文件夹:Northwind.CosmosDb.SqlApi

  2. 在项目文件中,将警告视为错误,为 Azure Cosmos 添加一个包引用,将项目引用添加到你在第三章中创建的 Northwind 数据上下文项目,使用 EF Core 为 SQL Server 构建实体模型,并静态和全局地导入Console类,如下所示的高亮标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference Include=****"Microsoft.Azure.Cosmos"** **Version=****"3.37.0"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<ProjectReference Include=****"..\..\Chapter03\Northwind.Common.DataContext**
    **.SqlServer\Northwind.Common.DataContext.SqlServer.csproj"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<Using Include=****"System.Console"** **Static=****"true"** **/>**
     **</ItemGroup>**
    </Project> 
    
  3. 在命令提示符或终端中使用以下命令构建Northwind.CosmosDb.SqlApi项目:dotnet build

    警告!如果你正在使用 Visual Studio 2022 并且引用了当前解决方案之外的项目,那么使用构建菜单会得到以下错误:

    NU1105 无法找到项目信息 'C:\apps-services-net8\Chapter03\Northwind.Common.DataContext.SqlServer\Northwind.Common.DataContext.SqlServer.csproj'。如果你正在使用 Visual Studio,这可能是由于项目未加载或不是当前解决方案的一部分。

    你必须在命令提示符或终端中输入dotnet build命令。在解决方案资源管理器中,你可以右键单击项目并选择在终端中打开

  4. 添加一个名为 Program.Helpers.cs 的类文件,删除任何现有语句,然后添加语句以定义一个部分 Program 类,其中包含一个将部分标题输出到控制台的方法,如下面的代码所示:

    // This is defined in the default empty namespace, so it merges with
    // the SDK-generated partial Program class.
    partial class Program
    {
      static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine("*");
        WriteLine($"* {title}");
        WriteLine("*");
        ForegroundColor = previousColor;
      }
    } 
    
  5. 添加一个名为 Program.Methods.cs 的类文件。

  6. Program.Methods.cs 中,添加语句以导入用于与 Azure Cosmos 一起工作的命名空间。然后,为 Program 类定义一个方法,创建一个 Cosmos 客户端并使用它来创建一个名为 Northwind 的数据库和一个名为 Products 的容器,无论是在本地模拟器还是云端,如下面的代码所示:

    using Microsoft.Azure.Cosmos; // To use CosmosClient and so on.
    using System.Net; // To use HttpStatusCode.
    // This is defined in the default empty namespace, so it merges with
    // the SDK-generated partial Program class.
    partial class Program
    {
      // To use Azure Cosmos DB in the local emulator.
      private static string endpointUri = "https://localhost:8081/";
      private static string primaryKey = "C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHL M+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==";
      /* 
      // To use Azure Cosmos DB in the cloud.
      private static string account = "apps-services-book"; // use your account
      private static string endpointUri = 
        $"https://{account}.documents.azure.com:443/";
      private static string primaryKey = "LGrx7H...gZw=="; // use your key
      */
      static async Task CreateCosmosResources()
      {
        SectionTitle("Creating Cosmos resources");
        try
        {
          using (CosmosClient client = new(
            accountEndpoint: endpointUri,
            authKeyOrResourceToken: primaryKey))
          {
            DatabaseResponse dbResponse = await client
              .CreateDatabaseIfNotExistsAsync(
                "Northwind", throughput: 400 /* RU/s */);
            string status = dbResponse.StatusCode switch
            {
              HttpStatusCode.OK => "exists",
              HttpStatusCode.Created => "created",
              _ => "unknown"
            };
            WriteLine("Database Id: {0}, Status: {1}.",
              arg0: dbResponse.Database.Id, arg1: status);
            IndexingPolicy indexingPolicy = new()
            {
              IndexingMode = IndexingMode.Consistent,
              Automatic = true, // Items are indexed unless explicitly excluded.
              IncludedPaths = { new IncludedPath { Path = "/*" } }
            };
            ContainerProperties containerProperties = new("Products",
              partitionKeyPath: "/productId")
            {
              IndexingPolicy = indexingPolicy
            };
            ContainerResponse containerResponse = await dbResponse.Database
              .CreateContainerIfNotExistsAsync(
                containerProperties, throughput: 1000 /* RU/s */);
            status = dbResponse.StatusCode switch
            {
              HttpStatusCode.OK => "exists",
              HttpStatusCode.Created => "created",
              _ => "unknown",
            };
            WriteLine("Container Id: {0}, Status: {1}.",
              arg0: containerResponse.Container.Id, arg1: status);
            Container container = containerResponse.Container;
            ContainerProperties properties = await container.ReadContainerAsync();
            WriteLine($"  PartitionKeyPath: {properties.PartitionKeyPath}");
            WriteLine($"  LastModified: {properties.LastModified}");
            WriteLine("  IndexingPolicy.IndexingMode: {0}",
              arg0: properties.IndexingPolicy.IndexingMode);
            WriteLine("  IndexingPolicy.IncludedPaths: {0}",
              arg0: string.Join(",", properties.IndexingPolicy
                .IncludedPaths.Select(path => path.Path)));
            WriteLine($"  IndexingPolicy: {properties.IndexingPolicy}");
          }
        }
        catch (HttpRequestException ex)
        {
          WriteLine($"Error: {ex.Message}");
          WriteLine("Hint: If you are using the Azure Cosmos Emulator then please make sure that it is running.");
        }
        catch (Exception ex)
        {
          WriteLine("Error: {0} says {1}",
            arg0: ex.GetType(),
            arg1: ex.Message);
        }
      }
    } 
    

    注意以下代码中的内容:

    • 当使用模拟器时,endpointUriprimaryKey 对每个人都是相同的。

    • CosmosClient 构造函数需要 endpointUriprimaryKey。永远不要将您的 primary key 存储在源代码中,然后将其检查到公共 Git 仓库中!您应该从环境变量或其他安全位置,如 Azure Key Vault 中获取它。

    • 创建数据库时,您必须指定一个名称以及每秒的 RUs 吞吐量。

    • 创建容器时,您必须指定一个名称和分区键路径,并且您可以可选地设置索引策略并覆盖吞吐量,默认为数据库吞吐量。

    • 创建 Azure Cosmos DB 资源请求的响应包括类似于 200 OK 的 HTTP 状态码,如果资源已存在,或者 201 Created,如果资源不存在但现在已成功创建。响应还包括有关资源的信息,如其 Id

  7. Program.cs 中,删除现有语句,然后添加一个调用创建 Azure Cosmos 资源方法的语句,如下面的代码所示:

    await CreateCosmosResources(); 
    
  8. 运行控制台应用程序并注意结果,如下面的输出所示:

    *
    * Creating Cosmos resources
    *
    Database Id: Northwind, Status: exists.
    Container Id: Products, Status: exists.
      PartitionKeyPath: /productId
      LastModified: 04/07/2022 11:11:31
      IndexingPolicy.IndexingMode: Consistent
      IndexingPolicy.IncludedPaths: /* 
    
  9. 在 Azure Cosmos DB 模拟器或 Azure 门户中,使用 数据资源管理器 删除 Northwind 数据库。(您必须将鼠标光标悬停在数据库上,然后单击 省略号按钮。)您将被提示输入其名称以确认删除,因为此操作无法撤销。

    在此阶段删除 Northwind 数据库非常重要。在本章的后面部分,您将程序化地将 SQL Server Northwind 数据库中的 77 个产品添加到 Cosmos DB Northwind 数据库中。如果您仍然在其 Products 容器中有三个示例产品,那么您将遇到问题。

  10. 运行控制台应用程序并注意,因为我们刚刚删除了数据库,所以我们执行的代码已经(重新)创建了数据库,如下面的输出所示:

    *
    * Creating Cosmos resources
    *
    Database Id: Northwind, Status: created.
    Container Id: Products, Status: created.
      PartitionKeyPath: /productId
      LastModified: 04/07/2022 11:11:31
      IndexingPolicy.IndexingMode: Consistent
      IndexingPolicy.IncludedPaths: /* 
    

您不需要再次删除数据库,因为它将没有任何产品。

现在,我们有一个可用于本地模拟器或 Azure 云的 Cosmos DB 数据库资源。现在,让我们学习如何使用 SQL API 在其上执行 CRUD 操作。

使用 Core (SQL) API 操作数据

在 Azure Cosmos DB 中处理数据的最常见 API 是 Core (SQL)。

Core (SQL) API 的完整文档可以在以下链接中找到:learn.microsoft.com/en-us/azure/cosmos-db/sql/.

使用 Cosmos SQL API 执行 CRUD 操作

您可以通过调用 Microsoft.Azure.Cosmos.Container 类实例上的以下最常见方法重载在 Cosmos 中使用 SQL API 对 JSON 文档执行 CRUD 操作:

  • ReadItemAsync<T>(id, partitionKey):其中 T 是要获取的项目类型,id 是其唯一标识符,partitionKey 是其分区键值。

  • ReadManyItemsAsync<T>(idsAndPartitionKeys):其中 T 是要获取的项目类型,idsAndPartitionKeys 是要检索的只读项目列表的唯一标识符和分区键值。

  • CreateItemAsync(object):其中 object 是要插入的项目类型的实例。

  • DeleteItemAsync<T>(id, partitionKey):其中 T 是要删除的项目类型,id 是其唯一标识符,partitionKey 是其分区键值。

  • PatchItemAsync<T>(id, partitionKey, patchOperations):其中 T 是要更新的项目类型,id 是其唯一标识符,partitionKey 是其分区键值,patchOperations 是属性更改的只读列表。

  • ReplaceItemAsync<T>(object, id):其中 T 是要替换的项目类型,id 是其唯一标识符,object 是要替换的项目类型的实例。

  • UpsertItemAsync<T>(object, id):其中 T 是要插入或替换的项目类型,id 是其唯一标识符,object 是要插入或替换现有项目的项目类型实例。

最佳实践:Cosmos DB 使用 HTTP 作为其底层通信协议,因此“补丁”和“替换”操作使用 PATCHPUT 实现。就像那些 HTTP 方法一样,PATCH 更高效,因为只有需要更改的属性才在请求中发送。

每个方法都返回一个包含以下常见属性的响应:

  • Resource:创建/检索/更新/删除的项目。

  • RequestCharge:表示以 RUs 为单位的请求费用的 double 值。

  • StatusCode:HTTP 状态码值;例如,当 ReadItemAsync<T> 请求无法找到项目时,为 404

  • Headers:HTTP 响应头字典。

  • Diagnostics:用于诊断的有用信息。

  • ActivityId:一个用于通过多级服务跟踪此活动的 GUID 值。

让我们将 SQL Server 中的 Northwind 数据库中的所有产品复制到 Cosmos。

由于 EF Core for SQL Server 类库中的实体类是为 Northwind SQL 数据库中的规范化数据结构设计的,因此我们将创建新类来表示 Cosmos 中的项目,这些项目包含嵌入式相关数据。它们将使用 JSON 命名约定,因为它们表示 JSON 文档:

  1. Northwind.CosmosDb.SqlApi 项目中,添加一个名为 Models 的新文件夹。

  2. Models 文件夹中添加一个名为 CategoryCosmos.cs 的类文件。

  3. 修改其内容以定义一个 CategoryCosmos 类,如下面的代码所示:

    namespace Northwind.CosmosDb.Items;
    public class CategoryCosmos
    {
      public int categoryId { get; set; }
      public string categoryName { get; set; } = null!;
      public string? description { get; set; }
    } 
    

    我们必须故意不遵循常规 .NET 命名约定,因为我们不能动态操作序列化,并且生成的 JSON 必须使用驼峰式命名。

  4. Models 文件夹中,添加一个名为 SupplierCosmos.cs 的类文件,并修改其内容以定义一个 SupplierCosmos 类,如下面的代码所示:

    namespace Northwind.CosmosDb.Items;
    public class SupplierCosmos
    {
      public int supplierId { get; set; }
      public string companyName { get; set; } = null!;
      public string? contactName { get; set; }
      public string? contactTitle { get; set; }
      public string? address { get; set; }
      public string? city { get; set; }
      public string? region { get; set; }
      public string? postalCode { get; set; }
      public string? country { get; set; }
      public string? phone { get; set; }
      public string? fax { get; set; }
      public string? homePage { get; set; }
    } 
    
  5. Models 文件夹中,添加一个名为 ProductCosmos.cs 的类文件,并修改其内容以定义一个 ProductCosmos 类,如下面的代码所示:

    namespace Northwind.CosmosDb.Items;
    public class ProductCosmos
    {
      public string id { get; set; } = null!;
      public string productId { get; set; } = null!;
      public string productName { get; set; } = null!;
      public string? quantityPerUnit { get; set; }
      public decimal? unitPrice { get; set; }
      public short? unitsInStock { get; set; }
      public short? unitsOnOrder { get; set; }
      public short? reorderLevel { get; set; }
      public bool discontinued { get; set; }
      public CategoryCosmos? category { get; set; }
      public SupplierCosmos? supplier { get; set; }
    } 
    

    良好实践:Cosmos 中的所有 JSON 文档项都必须有一个 id 属性。为了控制其值,在模型中显式定义该属性是一个好习惯。否则,系统将分配一个 GUID 值,正如您在本章前面使用 数据资源管理器 手动添加新项时所见。

  6. Program.Methods.cs 文件中,添加语句以导入 Northwind 数据上下文和实体类型、Northwind Cosmos 类型以及 EF Core 扩展的命名空间,如下面的代码所示:

    using Northwind.EntityModels; // To use NorthwindContext and so on.
    using Northwind.CosmosDb.Items; // To use ProductCosmos and so on.
    using Microsoft.EntityFrameworkCore; // To use Include extension method. 
    
  7. Program.Methods.cs 文件中,添加语句以定义一个方法,从 Northwind SQL 数据库获取所有产品,包括其相关类别和供应商,然后将它们作为新项插入到 Cosmos 的 Products 容器中,如下面的代码所示:

    static async Task CreateProductItems()
    {
      SectionTitle("Creating product items");
      double totalCharge = 0.0;
      try
      {
        using (CosmosClient client = new(
          accountEndpoint: endpointUri,
          authKeyOrResourceToken: primaryKey))
        {
          Container container = client.GetContainer(
            databaseId: "Northwind", containerId: "Products");
          using (NorthwindContext db = new())
          {
            if (!db.Database.CanConnect())
            {
              WriteLine("Cannot connect to the SQL Server database to " +
                " read products using database connection string: " +
                db.Database.GetConnectionString());
              return;
            }
            ProductCosmos[] products = db.Products
              // Get the related data for embedding.
              .Include(p => p.Category)
              .Include(p => p.Supplier)
              // Filter any products with null category or supplier
              // to avoid null warnings.
              .Where(p => (p.Category != null) && (p.Supplier != null))
              // Project the EF Core entities into Cosmos JSON types.
              .Select(p => new ProductCosmos
              {
                id = p.ProductId.ToString(),
                productId = p.ProductId.ToString(),
                productName = p.ProductName,
                quantityPerUnit = p.QuantityPerUnit,
                // If the related category is null, store null,
                // // else store the category mapped to Cosmos model.
                category = p.Category == null ? null : 
                  new CategoryCosmos
                {
                  categoryId = p.Category.CategoryId,
                  categoryName = p.Category.CategoryName,
                  description = p.Category.Description
                },
                supplier = p.Supplier == null ? null :
                  new SupplierCosmos
                {
                  supplierId = p.Supplier.SupplierId,
                  companyName = p.Supplier.CompanyName,
                  contactName = p.Supplier.ContactName,
                  contactTitle = p.Supplier.ContactTitle,
                  address = p.Supplier.Address,
                  city = p.Supplier.City,
                  country = p.Supplier.Country,
                  postalCode = p.Supplier.PostalCode,
                  region = p.Supplier.Region,
                  phone = p.Supplier.Phone,
                  fax = p.Supplier.Fax,
                  homePage = p.Supplier.HomePage
                },
                unitPrice = p.UnitPrice,
                unitsInStock = p.UnitsInStock,
                reorderLevel = p.ReorderLevel,
                unitsOnOrder = p.UnitsOnOrder,
                discontinued = p.Discontinued,
              })
              .ToArray();
            foreach (ProductCosmos product in products)
            {
              try
              {
                // Try to read the item to see if it exists.
                ItemResponse<ProductCosmos> productResponse =
                  await container.ReadItemAsync<ProductCosmos>(
                  id: product.id, new PartitionKey(product.productId));
                WriteLine("Item with id: {0} exists. Query consumed {1} RUs.",
                  productResponse.Resource.id, productResponse.RequestCharge);
                totalCharge += productResponse.RequestCharge;
              }
              catch (CosmosException ex) 
                when (ex.StatusCode == HttpStatusCode.NotFound)
              {
                // Create the item if it does not exist.
                ItemResponse<ProductCosmos> productResponse =
                  await container.CreateItemAsync(product);
                WriteLine("Created item with id: {0}. Insert consumed {1} RUs.",
                  productResponse.Resource.id, productResponse.RequestCharge);
                totalCharge += productResponse.RequestCharge;
              }
              catch (Exception ex)
              {
                WriteLine("Error: {0} says {1}",
                  arg0: ex.GetType(),
                  arg1: ex.Message);
              }
            }
          }
        }
      }
      catch (HttpRequestException ex)
      {
        WriteLine($"Error: {ex.Message}");
        WriteLine("Hint: If you are using the Azure Cosmos Emulator then please make sure it is running.");
      }
      catch (Exception ex)
      {
        WriteLine("Error: {0} says {1}",
          arg0: ex.GetType(),
          arg1: ex.Message);
      }
      WriteLine("Total requests charge: {0:N2} RUs", totalCharge);
    } 
    
  8. Program.cs 文件中,注释掉创建 Azure Cosmos 资源的调用,然后添加一个调用插入所有产品的语句,如下面的代码所示:

    await CreateProductItems(); 
    
  9. 运行控制台应用程序并注意结果,应该显示已插入 77 个产品项,如下面的部分输出所示:

    *
    * Creating product items
    *
    Created item with id: 1\. Insert consumed 14.29 RUs.
    Created item with id: 2\. Insert consumed 14.29 RUs.
    Created item with id: 3\. Insert consumed 14.29 RUs.
    ...
    Created item with id: 76\. Insert consumed 14.29 RUs.
    Created item with id: 77\. Insert consumed 14.48 RUs.
    Total requests charge: 1,114.58 RUs 
    
  10. 再次运行控制台应用程序并注意结果,应该显示产品项已存在,如下面的部分输出所示:

    *
    * Creating product items
    *
    Item with id: 1 exists. Query consumed 1 RUs.
    Item with id: 2 exists. Query consumed 1 RUs.
    Item with id: 3 exists. Query consumed 1 RUs.
    ...
    Item with id: 76 exists. Query consumed 1 RUs.
    Item with id: 77 exists. Query consumed 1 RUs.
    Total requests charge: 77.00 RUs 
    
  11. 在 Azure Cosmos DB 模拟器或 Azure 门户 数据资源管理器 中,确认 Products 容器中有 77 个产品项。

  12. Program.Methods.cs 文件中,添加语句以定义一个方法来列出 Products 容器中的所有项,如下面的代码所示:

    static async Task ListProductItems(string sqlText = "SELECT * FROM c")
    {
      SectionTitle("Listing product items");
      try
      {
        using (CosmosClient client = new(
          accountEndpoint: endpointUri,
          authKeyOrResourceToken: primaryKey))
        {
          Container container = client.GetContainer(
            databaseId: "Northwind", containerId: "Products");
          WriteLine("Running query: {0}", sqlText);
          QueryDefinition query = new(sqlText);
          using FeedIterator<ProductCosmos> resultsIterator =
            container.GetItemQueryIterator<ProductCosmos>(query);
          if (!resultsIterator.HasMoreResults)
          {
            WriteLine("No results found.");
          }
          while (resultsIterator.HasMoreResults)
          {
            FeedResponse<ProductCosmos> products =
              await resultsIterator.ReadNextAsync();
            WriteLine("Status code: {0}, Request charge: {1} RUs.",
              products.StatusCode, products.RequestCharge);
            WriteLine($"{products.Count} products found.");
            foreach (ProductCosmos product in products)
            {
              WriteLine("id: {0}, productName: {1}, unitPrice: {2}",
                arg0: product.id, arg1: product.productName, 
                arg2: product.unitPrice.ToString());
            }
          }
        }
      }
      catch (HttpRequestException ex)
      {
        WriteLine($"Error: {ex.Message}");
        WriteLine("Hint: If you are using the Azure Cosmos Emulator then please make sure it is running.");
      }
      catch (Exception ex)
      {
        WriteLine("Error: {0} says {1}",
          arg0: ex.GetType(),
          arg1: ex.Message);
      }
    } 
    
  13. Program.cs 文件中,添加语句以导入用于处理文化和编码的命名空间,模拟法语文化,注释掉创建产品项的调用,然后添加一个调用列出产品项的语句,如下面的代码所示:

    using System.Globalization; // To use CultureInfo.
    using System.Text; // To use Encoding.
    OutputEncoding = Encoding.UTF8; // To enable Euro symbol output.
    // Simulate French culture to test Euro currency symbol output.
    Thread.CurrentThread.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
    //await CreateCosmosResources();
    //await CreateProductItems();
    await ListProductItems(); 
    
  14. 运行控制台应用程序并注意结果,应该显示 77 个产品项,如下面的部分输出所示:

    *
    * Listing product items
    *
    Running query: SELECT * FROM c
    Status code: OK, Request charge: 3.93 RUs.
    77 products found.
    id: 1, productName: Chai, unitPrice: 18,00 €
    id: 2, productName: Chang, unitPrice: 19,00 €
    id: 3, productName: Aniseed Syrup, unitPrice: 10,00 €
    ...
    id: 76, productName: Lakkalikööri, unitPrice: 18,00 €
    id: 77, productName: Original Frankfurter grüne Soße, unitPrice: 13,00 € 
    
  15. Program.Methods.cs 文件中,添加语句以定义一个方法来删除 Products 容器中的所有项,如下面的代码所示:

    static async Task DeleteProductItems()
    {
      SectionTitle("Deleting product items");
      double totalCharge = 0.0;
      try
      {
        using (CosmosClient client = new(
          accountEndpoint: endpointUri,
          authKeyOrResourceToken: primaryKey))
        {
          Container container = client.GetContainer(
            databaseId: "Northwind", containerId: "Products");
          string sqlText = "SELECT * FROM c";
          WriteLine("Running query: {0}", sqlText);
          QueryDefinition query = new(sqlText);
          using FeedIterator<ProductCosmos> resultsIterator =
            container.GetItemQueryIterator<ProductCosmos>(query);
          while (resultsIterator.HasMoreResults)
          {
            FeedResponse<ProductCosmos> products =
              await resultsIterator.ReadNextAsync();
            foreach (ProductCosmos product in products)
            {
              WriteLine("Delete id: {0}, productName: {1}",
                arg0: product.id, arg1: product.productName);
              ItemResponse<ProductCosmos> response =
                await container.DeleteItemAsync<ProductCosmos>(
                id: product.id, partitionKey: new(product.id));
              WriteLine("Status code: {0}, Request charge: {1} RUs.",
                response.StatusCode, response.RequestCharge);
              totalCharge += response.RequestCharge;
            }
          }
        }
      }
      catch (HttpRequestException ex)
      {
        WriteLine($"Error: {ex.Message}");
        WriteLine("Hint: If you are using the Azure Cosmos Emulator then please make sure it is running.");
      }
      catch (Exception ex)
      {
        WriteLine("Error: {0} says {1}",
          arg0: ex.GetType(),
          arg1: ex.Message);
      }
      WriteLine("Total requests charge: {0:N2} RUs", totalCharge);
    } 
    
  16. Program.cs 文件中,注释掉列出产品项的调用,然后添加一个调用删除产品项的语句,如下面的代码所示:

    await DeleteProductItems(); 
    
  17. 运行控制台应用程序并注意结果,应该显示已删除 77 个产品项,如下面的部分输出所示:

    *
    * Deleting product items
    *
    Running query: SELECT * FROM c
    Delete id: 1, productName: Chai
    Status code: NoContent, Request charge: 14.29 RUs.
    ...
    Delete id: 77, productName: Original Frankfurter grüne Soße
    Status code: NoContent, Request charge: 14.48 RUs.
    Total requests charge: 1,128.87 RUs 
    
  18. 在 Azure Cosmos DB 模拟器或 Azure 门户 数据探索器 中,确认 Products 容器为空。

理解 SQL 查询

在为 Azure Cosmos DB 编写 SQL 查询时,以下关键字和更多内容可用:

  • 使用 SELECT 从项目属性中选择。支持 * 用于所有和 TOP 用于限制结果为前特定数量的项目。

  • 使用 AS 来定义别名。

  • 使用 FROM 来定义要选择的项目。一些之前的查询使用了 FROM c,其中 c 是容器中项目的隐含别名。由于 SQL 查询是在类似于 Products 的容器上下文中执行的,因此您可以使用任何您喜欢的别名,所以 FROM Items cFROM p 都会同样有效。

  • 使用 WHERE 来定义过滤器。

  • 使用 LIKE 进行模式匹配。% 表示零个、一个或多个字符。_ 表示单个字符。[a-f][aeiou] 表示定义范围内或集合中的单个字符。[^aeiou] 表示不在范围内或集合中。

  • INBETWEEN 是范围和集合过滤器。

  • ANDORNOT 用于布尔逻辑。

  • 使用 ORDER BY 对结果进行排序。

  • 使用 DISTINCT 来删除重复项。

  • COUNTAVGSUM 和其他聚合函数。

要使用 Core(SQL)API 查询 Products 容器,您可能编写以下代码:

SELECT p.id, p.productName, p.unitPrice FROM Items p 

让我们尝试执行一个针对我们的产品项的 SQL 查询:

  1. Program.cs 中,取消注释调用(重新)创建产品项的调用,并将 ListProductItems 的调用修改为传递一个 SQL 查询,该查询过滤产品以仅显示饮料类别的产品及其 ID、名称和单价,如下面的代码所示:

    //await CreateCosmosResources();
    await CreateProductItems();
    await ListProductItems("SELECT p.id, p.productName, p.unitPrice FROM Items p WHERE p.category.categoryName = 'Beverages'");
    //await DeleteProductItems(); 
    
  2. 运行控制台应用程序并注意结果,应该是饮料类别的 12 个产品项,如下面的输出所示:

    *
    * Listing product items
    *
    Running query: SELECT p.id, p.productName, p.unitPrice FROM Items p WHERE p.category.categoryName = 'Beverages'
    Status code: OK, Request charge: 3.19 RUs.
    12 products found.
    id: 1, productName: Chai, unitPrice: 18
    id: 2, productName: Chang, unitPrice: 19
    id: 24, productName: Guaraná Fantástica, unitPrice: 4.5
    id: 34, productName: Sasquatch Ale, unitPrice: 14
    id: 35, productName: Steeleye Stout, unitPrice: 18
    id: 38, productName: Côte de Blaye, unitPrice: 263.5
    id: 39, productName: Chartreuse verte, unitPrice: 18
    id: 43, productName: Ipoh Coffee, unitPrice: 46
    id: 67, productName: Laughing Lumberjack Lager, unitPrice: 14
    id: 70, productName: Outback Lager, unitPrice: 15
    id: 75, productName: Rhönbräu Klosterbier, unitPrice: 7.75
    id: 76, productName: Lakkalikööri, unitPrice: 18 
    
  3. 在 Azure Cosmos DB 模拟器或 Azure 门户 数据探索器 中,创建一个新的 SQL 查询,使用相同的 SQL 文本,并执行它,如图 4.7 所示:

图片

图 4.7:在数据探索器中执行 SQL 查询

  1. 点击 查询统计,并注意请求费用(3.19 RUs)、记录数(12)和输出文档大小(752 字节),如图 4.8 所示:

图片

图 4.8:数据探索器中的查询统计信息

其他有用的查询统计包括:

  • 索引命中文档计数。

  • 索引查找时间。

  • 文档加载时间。

  • 查询引擎执行时间。

  • 文档写入时间。

探索使用 Cosmos DB 的其他 SQL 查询

尝试执行以下查询:

SELECT p.id, p.productName, p.unitPrice FROM Items p 
WHERE p.unitPrice > 50
SELECT DISTINCT p.category FROM Items p
SELECT DISTINCT p.category.categoryName FROM Items p
WHERE p.discontinued = true
SELECT p.productName, p.supplier.city FROM Items p
WHERE p.supplier.country = 'Germany'
SELECT COUNT(p.id) AS HowManyProductsComeFromGermany FROM Items p
WHERE p.supplier.country = 'Germany'
SELECT AVG(p.unitPrice) AS AverageUnitPrice FROM Items p 

虽然使用字符串定义的查询是处理 Cosmos DB 最常见的方式,但您也可以使用服务器端编程创建永久存储的对象。

探索服务器端编程

Azure Cosmos DB 服务器端编程包括用 JavaScript 编写的 存储过程触发器用户定义函数UDFs)。

实现用户定义函数

UDF 只能在查询内部调用,并实现自定义业务逻辑,如计算税。

让我们定义一个 UDF 来计算产品的销售税:

  1. 在 Azure Cosmos DB 模拟器或 Azure 门户 数据探索器 中创建一个新的 UDF,如图 4.9 所示:

![img/B19587_04_09.png]

图 4.9:创建新的 UDF

  1. 对于用户定义函数 ID,输入 salesTax

  2. 对于用户定义函数体,输入 JavaScript 来定义 salesTax 函数,如下面的代码所示:

    function salesTax(unitPrice){
        return unitPrice * 0.2;
    } 
    
  3. 在工具栏中,点击保存

  4. 创建一个新的 SQL 查询,并输入 SQL 文本来返回成本超过 100 的产品的单价和销售税,如下面的查询所示:

    SELECT p.unitPrice cost, udf.salesTax(p.unitPrice) AS tax 
    FROM Items p WHERE p.unitPrice > 100 
    

    注意,使用 AS 来别名一个表达式是可选的。我更喜欢指定 AS 以提高可读性。

  5. 点击保存查询按钮。

    如果您使用的是云资源而不是模拟器,那么出于合规性原因,Microsoft 将查询保存在您的 Azure Cosmos 账户中的单独数据库中,该数据库称为___Cosmos。预计每日额外费用为 0.77 美元。

  6. 执行查询并注意结果,如下面的输出所示:

    [
        {
            "cost": 123.79,
            "tax": 24.758000000000003
        },
        {
            "cost": 263.5,
            "tax": 52.7
        }
    ] 
    

实现存储过程

存储过程是确保ACID原子性、一致性、隔离性、持久性)事务的唯一方式,这些事务将多个离散活动组合成一个单一的操作,该操作可以提交或回滚。您不能使用客户端代码来实现事务。服务器端编程也提供了改进的性能,因为代码在数据存储的地方执行。

我们刚刚看到,您可以使用数据探索器定义一个用户定义函数(UDF)。我们可以以类似的方式定义存储过程,但让我们看看如何使用代码来实现:

  1. Program.Methods.cs 中,导入用于处理服务器端编程对象的命名空间,如下面的代码所示:

    // To use StoredProcedureResponse and so on.
    using Microsoft.Azure.Cosmos.Scripts; 
    
  2. Program.Methods.cs 中,添加语句来定义一个方法,创建一个存储过程,可以通过链式回调函数插入多个产品,直到数组中的所有项目都插入,如下面的代码所示:

    static async Task CreateInsertProductStoredProcedure()
    {
      SectionTitle("Creating the insertProduct stored procedure");
      try
      {
        using (CosmosClient client = new(
          accountEndpoint: endpointUri,
          authKeyOrResourceToken: primaryKey))
        {
          Container container = client.GetContainer(
            databaseId: "Northwind", containerId: "Products");
          StoredProcedureResponse response = await container
            .Scripts.CreateStoredProcedureAsync(new StoredProcedureProperties
            {
              Id = "insertProduct",
              // __ means getContext().getCollection().
              Body = """
    function insertProduct(product) {
      if (!product) throw new Error(
        "product is undefined or null.");
      tryInsert(product, callbackInsert);
      function tryInsert(product, callbackFunction) {
        var options = { disableAutomaticIdGeneration: false };
        // __ is an alias for getContext().getCollection()
        var isAccepted = __.createDocument(
          __.getSelfLink(), product, options, callbackFunction);
        if (!isAccepted) 
          getContext().getResponse().setBody(0);
      }
      function callbackInsert(err, item, options) {
        if (err) throw err;
        getContext().getResponse().setBody(1);
      }
    }
    """
            });
          WriteLine("Status code: {0}, Request charge: {1} RUs.",
            response.StatusCode, response.RequestCharge);
        }
      }
      catch (HttpRequestException ex)
      {
        WriteLine($"Error: {ex.Message}");
        WriteLine("Hint: If you are using the Azure Cosmos Emulator then please make sure it is running.");
      }
      catch (Exception ex)
      {
        WriteLine("Error: {0} says {1}",
          arg0: ex.GetType(),
          arg1: ex.Message);
      }
    } 
    
  3. Program.cs 中,注释掉所有现有的语句,并添加一个运行新方法的语句,如下面的代码所示:

    await CreateInsertProductStoredProcedure(); 
    
  4. 运行控制台应用程序并注意结果,结果应该是存储过程的成功创建,如下面的输出所示:

    *
    * Creating the insertProduct stored procedure
    *
    Status code: Created, Request charge: 6.29 RUs. 
    
  5. Program.Methods.cs 中,添加语句来定义一个执行存储过程的方法,如下面的代码所示:

    static async Task ExecuteInsertProductStoredProcedure()
    {
      SectionTitle("Executing the insertProduct stored procedure");
      try
      {
        using (CosmosClient client = new(
          accountEndpoint: endpointUri,
          authKeyOrResourceToken: primaryKey))
        {
          Container container = client.GetContainer(
            databaseId: "Northwind", containerId: "Products");
          string pid = "78";
          ProductCosmos product = new()
          {
            id = pid, productId = pid,
            productName = "Barista's Chilli Jam",
            unitPrice = 12M, unitsInStock = 10
          };
          StoredProcedureExecuteResponse<string> response = await container.Scripts
            .ExecuteStoredProcedureAsync<string>("insertProduct",
            new PartitionKey(pid), new[] { product });
          WriteLine("Status code: {0}, Request charge: {1} RUs.",
            response.StatusCode, response.RequestCharge);
        }
      }
      catch (HttpRequestException ex)
      {
        WriteLine($"Error: {ex.Message}");
        WriteLine("Hint: If you are using the Azure Cosmos Emulator then please make sure it is running.");
      }
      catch (Exception ex)
      {
        WriteLine("Error: {0} says {1}",
          arg0: ex.GetType(),
          arg1: ex.Message);
      }
    } 
    
  6. Program.cs 中,注释掉创建存储过程的语句,添加一个执行存储过程的语句,然后列出具有 productId78 的产品,如下面的代码所示:

    //await CreateInsertProductStoredProcedure();
    await ExecuteInsertProductStoredProcedure();
    await ListProductItems("SELECT p.id, p.productName, p.unitPrice FROM Items p WHERE p.productId = '78'"); 
    
  7. 运行控制台应用程序并注意结果,结果应该是存储过程的成功执行,如下面的输出所示:

    *
    * Executing the insertProduct stored procedure
    *
    Status code: OK, Request charge: 10.23 RUs.
    *
    * Listing product items
    *
    Running query: SELECT p.id, p.productName, p.unitPrice FROM Items p WHERE p.productId = '78'
    Status code: OK, Request charge: 2.83 RUs.
    1 products found.
    id: 78, productName: Barista's Chilli Jam, unitPrice: €12.00 
    

清理 Azure 资源

当你完成一个 Azure Cosmos DB 账户后,你必须清理使用的资源,否则你将承担这些资源存在的费用。你可以单独删除资源或删除资源组以删除整个资源集。如果你删除了 Azure Cosmos DB 账户,那么它内部的所有数据库和容器也将被删除:

  1. 在 Azure 门户中,导航到所有资源

  2. 在你的apps-services-book资源组中,点击你的 Azure Cosmos DB 账户。

  3. 点击概述,然后在工具栏中点击删除账户

  4. 确认账户名称框中,输入你的账户名称。

  5. 点击删除按钮。

练习和探索

通过回答一些问题、进行一些实际操作练习,并深入研究本章的主题来测试你的知识和理解。

练习 4.1 – 测试你的知识

回答以下问题:

  1. Azure Cosmos DB 支持哪五个 API?

  2. 你在哪个级别选择 API:账户、数据库、容器还是分区?

  3. 在 Cosmos DB 数据建模方面,嵌入意味着什么?

  4. Cosmos DB 的吞吐量测量单位是什么?1 个单位代表什么?

  5. 应该引用哪个包来以编程方式与 Cosmos DB 资源一起工作?

  6. 你使用什么语言编写 Cosmos DB Core (SQL) API 的用户定义函数和存储过程?

练习 4.2 – 练习数据建模和分区

Microsoft 文档中有一个广泛的示例,用于建模和分区 Azure Cosmos DB:

learn.microsoft.com/en-us/azure/cosmos-db/sql/how-to-model-partition-example

练习 4.3 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-4---managing-nosql-data-using-azure-cosmos-db

练习 4.4 – 下载速查表

下载 Azure Cosmos DB API 的查询速查表并查看它们:

learn.microsoft.com/en-us/azure/cosmos-db/sql/query-cheat-sheet

练习 4.5 – 探索 Cosmos DB 的 Gremlin API

如果你对这个感兴趣,那么我写了一个可选的仅在网络上提供的部分,你可以在这里探索使用 Gremlin API 的 Azure Cosmos DB 图 API,具体链接如下:

github.com/markjprice/apps-services-net8/blob/main/docs/ch04-gremlin.md

为了获得更多关于 Gremlin 图 API 的经验,你可以阅读以下在线书籍:

kelvinlawrence.net/book/Gremlin-Graph-Guide.html

练习 4.6 – 探索 NoSQL 数据库

本章重点介绍了 Azure Cosmos DB。如果你希望了解更多关于 NoSQL 数据库(如 MongoDB)以及如何与 EF Core 一起使用它们的信息,那么我推荐以下链接:

摘要

在本章中,你学习了:

  • 如何在 Azure Cosmos DB 中灵活存储结构化数据。

  • 如何在模拟器和 Azure 云中创建 Cosmos DB 资源。

  • 如何使用 Cosmos SQL API 操作数据。

  • 如何使用 JavaScript 在 Cosmos DB 中实现服务器端编程。

在下一章中,你将使用 Task 类型来提高应用程序的性能。

第五章:多任务处理和并发

本章主要介绍允许多个操作同时发生,以提高您构建的应用程序的性能、可扩展性和用户生产力。

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

  • 理解进程、线程和任务

  • 异步运行任务

  • 同步访问共享资源

  • 理解asyncawait

理解进程、线程和任务

进程,例如我们创建的每个控制台应用程序,都分配了如内存和线程等资源。

线程通过逐条执行代码语句来执行您的代码。默认情况下,每个进程只有一个线程,当我们需要同时执行多个任务时,这可能会导致问题。线程还负责跟踪诸如当前认证用户以及应遵循的任何针对当前语言和区域的国际化规则等事项。

Windows 和大多数其他现代操作系统使用抢占式多任务处理,它模拟任务的并行执行。它将处理器时间分配给线程,依次为每个线程分配一个时间片。当前线程的时间片结束时,它将被挂起。然后处理器允许另一个线程运行一个时间片。

当 Windows 从一个线程切换到另一个线程时,它会保存当前线程的上下文,并重新加载线程队列中下一个线程之前保存的上下文。这需要时间和资源来完成。

作为开发者,如果您有一小部分复杂的工作,并且希望完全控制它们,那么您可以创建和管理单个Thread实例。如果您有一个主线程和多个可以在后台执行的小块工作,那么您可以使用ThreadPool类将指向作为方法实现的工作块的委托实例添加到队列中,它们将被自动分配到线程池中的线程。

在本章中,我们将使用Task类型在更高的抽象级别上管理线程。

线程可能需要竞争并等待访问共享资源,例如变量、文件和数据库对象。您将在本章后面看到用于管理这些资源的类型。

根据任务的不同,将执行任务的线程(工作者)数量加倍并不会将完成任务所需的时间减半。实际上,由于资源竞争,这可能会增加任务的持续时间,如图 5.1 所示:

图片

图 5.1:关于现实世界中任务的推文

良好实践:永远不要假设更多的线程会提高性能!在没有多个线程的基线代码实现上运行性能测试,然后再次在具有多个线程的代码实现上运行。您还应在尽可能接近生产环境的预发布环境中进行性能测试。

异步运行任务

要了解如何同时(同时)运行多个任务,我们将创建一个需要执行三个方法的控制台应用程序。

将需要执行三个方法:第一个方法需要 3 秒,第二个方法需要 2 秒,第三个方法需要 1 秒。为了模拟这项工作,我们可以使用 Thread 类来告诉当前线程暂停指定的毫秒数。

同步运行多个操作

在我们使任务同时运行之前,我们将以同步的方式运行它们,也就是说,一个接一个地:

  1. 使用您首选的代码编辑器添加一个控制台应用程序项目,如下列所示:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter05

    • 项目文件和文件夹:WorkingWithTasks

    • 不要使用顶级语句:已清除。

    • 启用原生 AOT 发布:已清除。

  2. WorkingWithTasks 项目中,全局和静态导入 System.Console 类,并将警告视为错误。

  3. WorkingWithTasks 项目中,添加一个名为 Program.Helpers.cs 的新类文件。

  4. Program.Helpers.cs 中,删除任何现有的语句,然后定义一个部分 Program 类,其中包含输出章节标题和任务标题的方法,以及输出关于当前线程的信息,每个方法使用不同的颜色以便在输出中更容易识别,如下面的代码所示:

    partial class Program
    {
      private static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title}");
        ForegroundColor = previousColor;
      }
      private static void TaskTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.Green;
        WriteLine($"{title}");
        ForegroundColor = previousColor;
      }
      private static void OutputThreadInfo()
      {
        Thread t = Thread.CurrentThread;
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkCyan;
        WriteLine(
          "Thread Id: {0}, Priority: {1}, Background: {2}, Name: {3}",
          t.ManagedThreadId, t.Priority, t.IsBackground, t.Name ?? "null");
        ForegroundColor = previousColor;
      }
    } 
    
  5. WorkingWithTasks 项目中,添加一个名为 Program.Methods.cs 的新类文件。

  6. Program.Methods.cs 中,删除任何现有的语句,然后添加三个模拟工作的方法,如下面的代码所示:

    partial class Program
    {
      private static void MethodA()
      {
        TaskTitle("Starting Method A...");
        OutputThreadInfo();
        Thread.Sleep(3000); // Simulate three seconds of work.
        TaskTitle("Finished Method A.");
      }
      private static void MethodB()
      {
        TaskTitle("Starting Method B...");
        OutputThreadInfo();
        Thread.Sleep(2000); // Simulate two seconds of work.
        TaskTitle("Finished Method B.");
      }
      private static void MethodC()
      {
        TaskTitle("Starting Method C...");
        OutputThreadInfo();
        Thread.Sleep(1000); // Simulate one second of work.
        TaskTitle("Finished Method C.");
      }
    } 
    
  7. Program.cs 中,删除现有的语句,然后添加调用辅助方法以输出线程信息、定义并启动计时器、调用三个模拟工作方法以及输出经过的毫秒数的语句,如下面的代码所示:

    using System.Diagnostics; // To use Stopwatch.
    OutputThreadInfo();
    Stopwatch timer = Stopwatch.StartNew();
    SectionTitle("Running methods synchronously on one thread."); 
    MethodA();
    MethodB();
    MethodC();
    WriteLine($"{timer.ElapsedMilliseconds:#,##0}ms elapsed."); 
    
  8. 运行代码,等待所有三个方法完成执行,然后查看结果,注意当只有一个未命名的前台线程执行工作时,所需的总时间略超过 6 秒,如下面的输出所示:

    Thread Id: 1, Priority: Normal, Background: False, Name: null
    *** Running methods synchronously on one thread.
    Starting Method A...
    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Finished Method A.
    Starting Method B...
    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Finished Method B.
    Starting Method C...
    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Finished Method C.
    6,028ms elapsed. 
    

使用任务异步运行多个操作

Thread 类自 2002 年.NET 的第一个版本以来一直可用,可以用来创建新线程并管理它们,但直接与之交互可能会很棘手。

.NET Framework 4.0 在 2010 年引入了 Task 类,它表示一个异步操作。任务是对执行操作的操作系统线程的高级抽象,Task 类使得创建和管理任何底层线程变得更加容易。管理被任务包装的多个线程将允许我们的代码同时执行,即 异步

每个 Task 都有一个 Status 属性和一个 CreationOptions 属性。Task 有一个 ContinueWith 方法,可以使用 TaskContinuationOptions 枚举进行自定义,并且可以使用 TaskFactory 类进行管理。

启动任务

我们将探讨三种使用 Task 实例启动方法的方式。GitHub 仓库中有链接到讨论优缺点的文章。它们的语法略有不同,但它们都定义了一个 Task 并启动了它:

  1. Program.cs 文件中,注释掉调用方法 A 到 C 的前一条语句,然后添加语句创建并启动三个任务,每个方法一个任务,如下面高亮显示的代码所示:

    Stopwatch timer = Stopwatch.StartNew();
    **/***
    SectionTitle("Running methods synchronously on one thread.");
    MethodA();
    MethodB();
    MethodC();
    ***/**
    **SectionTitle(****"Running methods asynchronously on multiple threads."****);** 
    **Task taskA =** **new****(MethodA);**
    **taskA.Start();**
    **Task taskB = Task.Factory.StartNew(MethodB);** 
    **Task taskC = Task.Run(MethodC);**
    WriteLine($"{timer.ElapsedMilliseconds:#,##0}ms elapsed."); 
    

    而不是注释掉前面的语句,你可以让它们运行,但确保在输出新的部分标题后调用 timer.Restart() 方法来重置每个部分的计时。

  2. 运行代码,查看结果,并注意经过的毫秒数几乎立即出现。这是因为现在每个三个方法都由从 线程池 (TP) 分配的三个新的后台工作线程执行,如下面的输出所示:

    *** Running methods asynchronously on multiple threads.
    Starting Method A...
    Thread Id: 4, Priority: Normal, Background: True, Name: .NET TP Worker
    Starting Method C...
    Thread Id: 7, Priority: Normal, Background: True, Name: .NET TP Worker
    Starting Method B...
    Thread Id: 6, Priority: Normal, Background: True, Name: .NET TP Worker
    6ms elapsed. 
    

甚至可能控制台应用程序会在一个或所有任务有机会开始并写入控制台之前结束!

等待任务

有时,在继续之前需要等待一个任务完成。为此,可以在 Task 实例上使用 Wait 方法,或者在任务数组上使用 WaitAllWaitAny 静态方法,如 表 5.1 中所述:

方法 描述
t.Wait() 这将等待名为 t 的任务实例完成执行。
Task.WaitAny(Task[]) 这将等待数组中任何任务的执行完成。
Task.WaitAll(Task[]) 这将等待数组中所有任务的执行完成。

表 5.1:Task 类的 Wait 方法

使用任务等待方法

让我们看看如何使用这些等待方法来解决我们的控制台应用程序的问题:

  1. Program.cs 文件中,在创建三个任务并在输出经过的时间之前,添加语句将三个任务的引用组合成一个数组,并将它们传递给 WaitAll 方法,如下面的代码所示:

    Task[] tasks = { taskA, taskB, taskC };
    Task.WaitAll(tasks); 
    
  2. 运行代码并查看结果,并注意原始线程会在 WaitAll 调用上暂停,等待所有三个任务完成,然后输出经过的时间,大约是 3 秒多,如下面的输出所示:

    Starting Method A...
    Starting Method B...
    Thread Id: 4, Priority: Normal, Background: True, Name: .NET TP Worker
    Thread Id: 6, Priority: Normal, Background: True, Name: .NET TP Worker
    Starting Method C...
    Thread Id: 7, Priority: Normal, Background: True, Name: .NET TP Worker
    Finished Method C.
    Finished Method B.
    Finished Method A.
    3,013ms elapsed. 
    

三个新线程同时执行它们的代码,并且它们可以以任何顺序开始。MethodC应该首先完成,因为它只需要 1 秒,然后是MethodB,它需要 2 秒,最后是MethodA,因为它需要 3 秒。

然而,实际使用的 CPU 对结果有很大影响。是 CPU 为每个进程分配时间片,以便它们可以执行它们的线程。您无法控制方法何时运行。

继续另一个任务

如果所有三个任务可以同时执行,那么等待所有任务完成就是我们需要做的。然而,通常,一个任务依赖于另一个任务的输出。为了处理这种情况,我们需要定义后续任务

我们将创建一些方法来模拟调用返回货币金额的 Web 服务,然后需要使用该金额从数据库中检索出多少产品的价格高于该金额。第一个方法返回的结果需要输入到第二个方法的输入中。这次,我们不会等待固定的时间,而是将使用Random类等待每个方法调用之间的 2 到 4 秒的随机间隔来模拟工作:

  1. Program.Methods.cs中,添加两个方法来模拟调用 Web 服务和数据库存储过程,如下面的代码所示:

    private static decimal CallWebService()
    {
      TaskTitle("Starting call to web service...");
      OutputThreadInfo();
      Thread.Sleep(Random.Shared.Next(2000, 4000));
      TaskTitle("Finished call to web service.");
      return 89.99M;
    }
    private static string CallStoredProcedure(decimal amount)
    {
      TaskTitle("Starting call to stored procedure...");
      OutputThreadInfo();
      Thread.Sleep((Random.Shared.Next(2000, 4000));
      TaskTitle("Finished call to stored procedure.");
      return $"12 products cost more than {amount:C}.";
    } 
    
  2. Program.cs中,注释掉前三个任务的语句,然后添加语句以启动一个任务来调用网络服务,然后将返回值传递给启动数据库存储过程的任务,如下面的代码所示:

    **SectionTitle(****"Passing the result of one task as an input into another."****);** 
    **Task<****string****> taskServiceThenSProc = Task.Factory**
     **.StartNew(CallWebService)** **// returns Task<decimal>**
     **.ContinueWith(previousTask =>** **// returns Task<string>**
     **CallStoredProcedure(previousTask.Result));**
    **WriteLine(****$"Result:** **{taskServiceThenSProc.Result}****"****);**
    WriteLine($"{timer.ElapsedMilliseconds:#,##0}ms elapsed."); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    Starting call to web service...
    Thread Id: 4, Priority: Normal, Background: True, Name: .NET TP Worker
    Finished call to web service.
    Starting call to stored procedure...
    Thread Id: 6, Priority: Normal, Background: True, Name: .NET TP Worker
    Finished call to stored procedure.
    Result: 12 products cost more than £89.99.
    5,463ms elapsed. 
    

货币符号是文化特定的,所以在我的电脑上它使用£。在您的电脑上它将使用您的文化。您将在第七章处理日期、时间和国际化中学习如何控制文化。

您可能会看到两个不同的线程在运行上面的网络服务和存储过程调用(例如,线程 4 和 6),或者相同的线程可能会被重用,因为它不再忙碌。

嵌套和子任务

除了定义任务之间的依赖关系外,您还可以定义嵌套和子任务。嵌套任务是在另一个任务内部创建的任务。子任务是必须在其父任务允许完成之前完成的嵌套任务。

让我们探索这些类型任务的工作方式:

  1. Program.Methods.cs中,添加两个方法,其中一个启动一个任务来运行另一个,如下面的代码所示:

    private static void OuterMethod()
    {
      TaskTitle("Outer method starting...");
      Task innerTask = Task.Factory.StartNew(InnerMethod);
      TaskTitle("Outer method finished.");
    }
    private static void InnerMethod()
    {
      TaskTitle("Inner method starting...");
      Thread.Sleep(2000);
      TaskTitle("Inner method finished.");
    } 
    
  2. Program.cs中,添加语句以启动一个任务来运行外部方法,并在它完成之前等待,如下面的代码所示:

    SectionTitle("Nested and child tasks");
    Task outerTask = Task.Factory.StartNew(OuterMethod);
    outerTask.Wait();
    WriteLine("Console app is stopping."); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    Outer method starting...
    Inner method starting...
    Outer method finished.
    Console app is stopping. 
    

    虽然我们等待外部任务完成,但其内部任务不必也完成。事实上,外部任务可能已经完成,控制台应用程序可能在内部任务甚至开始之前就结束了,如下面的输出所示:

    Outer method starting...
    Outer method finished.
    Console app is stopping. 
    
  4. 要将这些嵌套任务作为父任务和子任务链接起来,我们必须使用一个特殊选项。在Program.Methods.cs中,修改现有的代码以添加一个TaskCreationOption值为AttachedToParent,如下面高亮显示的代码所示:

    private static void OuterMethod()
    {
      TaskTitle("Outer method starting...");
      Task innerTask = Task.Factory.StartNew(InnerMethod**,**
     **TaskCreationOptions.AttachedToParent**);
      TaskTitle("Outer method finished.");
    } 
    
  5. 运行代码,查看结果,并注意内部任务必须在外部任务之前完成,如下面的输出所示:

    Outer method starting...
    Inner method starting...
    Outer method finished.
    Inner method finished.
    Console app is stopping. 
    
  6. 或者,外部方法可以在内部方法开始之前完成,如下面的输出所示:

    Outer method starting...
    Outer method finished.
    Inner method starting...
    Inner method finished.
    Console app is stopping. 
    

OuterMethod可以在InnerMethod之前完成其工作,如它写入控制台所示,但它的任务必须等待,如控制台在内外任务都完成之前不会停止所示。

将任务包装在其他对象周围

有时你可能有一个想要异步执行的方法,但返回的结果本身不是任务。你可以将返回值包装在一个成功完成的任务中,返回一个异常,或者使用Task的静态方法之一来指示任务已取消,如表 5.2所示:

方法 描述
FromResult<TResult>(TResult) 创建一个Task<TResult>对象,其Result属性是非任务结果,其Status属性是RanToCompletion
FromException<TResult>(Exception) 创建一个由于指定异常而完成的Task<TResult>
FromCanceled<TResult>(CancellationToken) 创建一个由于指定取消令牌而完成的Task<TResult>

表 5.2:在各种场景下创建 Task 的方法

当你需要以下情况时,这些方法很有用:

  • 实现一个具有异步方法但实现是同步的接口。这在网站和服务中很常见。

  • 在单元测试期间模拟异步实现。

假设你需要创建一个用于验证 XML 输入的方法,并且该方法必须符合一个要求返回Task<T>的接口,如下面的代码所示:

public interface IValidation
{
  Task<bool> IsValidXmlTagAsync(this string input);
} 

这部分代码仅用于说明。你不需要将其输入到你的项目中。

我们可以使用这些有用的FromX方法来返回包装在任务中的结果,如下面的代码所示:

using System.Text.RegularExpressions;
namespace Packt.Shared;
public static class StringExtensions : IValidation
{
  public static Task<bool> IsValidXmlTagAsync(this string input)
  {
    if (input == null)
    {
      return Task.FromException<bool>(
        new ArgumentNullException($"Missing {nameof(input)} parameter"));
    }
    if (input.Length == 0)
    {
      return Task.FromException<bool>(
        new ArgumentException($"{nameof(input)} parameter is empty."));
    }
    return Task.FromResult(Regex.IsMatch(input,
      @"^<([a-z]+)([^<]+)*(?:>(.*)<\/\1>|\s+\/>)$"));
  }
} 

如果你需要实现的方法返回一个Task(在同步方法中相当于void),那么你可以返回一个预定义的已完成Task对象,如下面的代码所示:

public Task DeleteCustomerAsync()
{
  // ...
  return Task.CompletedTask;
} 

当并行运行任务时,代码通常会需要访问资源,而这些资源有时在任务和线程之间是共享的。因此,我们需要学习如何安全地访问这些共享资源。

同步访问共享资源

当有多个线程同时执行时,存在两个或更多线程可能同时访问相同变量或资源的可能性,这可能导致问题。因此,您应该仔细考虑如何使您的代码线程安全

实现线程安全的最简单机制是使用对象变量作为标志或交通灯,以指示共享资源是否已应用了独占锁。

在威廉·戈尔丁的 《蝇王》 中,皮格和拉尔夫找到一个海螺壳,并用它来召集会议。男孩们对自己实施了一个“海螺规则”,即除非他们拿着海螺,否则没有人可以发言。

我喜欢将用于实现线程安全代码的对象变量命名为“海螺”。当一个线程拥有海螺时,其他任何线程都不应该访问由该海螺表示的共享资源。请注意,我说的是“应该”。只有尊重海螺的代码才能实现同步访问。海螺不是一个锁。

我们将探讨一些可以用来同步访问共享资源的类型:

  • Monitor:一个可以被多个线程用来检查它们是否应该访问同一进程中的共享资源。

  • Interlocked:一个用于在 CPU 级别操作简单数值类型的对象。

从多个线程访问资源

让我们创建一个控制台应用程序来探索多个线程之间的资源共享:

  1. 使用您首选的代码编辑器将一个新的控制台应用程序/ console 项目添加到名为 SynchronizingResourceAccessChapter05 解决方案中。

  2. 全局和静态导入 System.Console 类,并将警告视为错误。

  3. 添加一个名为 SharedObjects.cs 的新类文件。

  4. SharedObjects.cs 中,删除任何现有的语句,然后定义一个静态类,其中包含一个字段用于存储共享资源的消息,如下所示代码:

    public static class SharedObjects
    {
      public static string? Message; // a shared resource
    } 
    
  5. 添加一个名为 Program.Methods.cs 的新类文件。

  6. Program.Methods.cs 中,删除任何现有的语句,然后定义两个方法,这两个方法都循环五次,等待最多两秒的随机间隔,并将 AB 追加到共享消息资源中,如下所示代码:

    partial class Program
    {
      private static void MethodA()
      {
        for (int i = 0; i < 5; i++)
        {
          // Simulate two seconds of work on the current thread.
          Thread.Sleep(Random.Shared.Next(2000));
          // Concatenate the letter "A" to the shared message.
          SharedObjects.Message += "A";
          // Show some activity in the console output.
          Write(".");
        }
      }
      private static void MethodB()
      {
        for (int i = 0; i < 5; i++)
        {
          Thread.Sleep(Random.Shared.Next(2000));
          SharedObjects.Message += "B";
          Write(".");
        }
      }
    } 
    
  7. Program.cs 中,删除现有的语句。添加导入诊断类型(如 Stopwatch)的命名空间语句,以及使用一对任务执行两个方法并在输出经过的毫秒数之前等待它们完成的语句,如下所示代码:

    using System.Diagnostics; // To use Stopwatch.
    WriteLine("Please wait for the tasks to complete.");
    Stopwatch watch = Stopwatch.StartNew();
    Task a = Task.Factory.StartNew(MethodA);
    Task b = Task.Factory.StartNew(MethodB);
    
    Task.WaitAll(new Task[] { a, b });
    WriteLine();
    WriteLine($"Results: {SharedObjects.Message}.");
    WriteLine($"{watch.ElapsedMilliseconds:N0} elapsed milliseconds."); 
    
  8. 运行代码并查看结果,如下所示输出:

    Please wait for the tasks to complete.
    ..........
    Results: BABABAABBA.
    5,753 elapsed milliseconds. 
    

这表明两个线程都在并发地修改消息。在实际应用中,这可能会成为一个问题。但我们可以通过将互斥锁应用于海螺对象,以及向两个方法中添加代码以在修改共享资源之前自愿检查海螺,来防止并发访问,我们将在下一节中这样做。

将互斥锁应用于 conch

现在,让我们使用 conch 来确保一次只有一个线程访问共享资源:

  1. SharedObjects.cs 中,声明并实例化一个 object 变量来作为 conch,如下面的代码所示:

    public static object Conch = new(); // A shared object to lock. 
    
  2. Program.Methods.cs 中,在 MethodAMethodB 中,在 for 语句周围添加一个 lock 语句,如下面 MethodB 的代码所示,高亮显示:

    **lock** **(SharedObjects.Conch)**
    **{**
      for (int i = 0; i < 5; i++)
      {
        Thread.Sleep(Random.Shared.Next(2000));
        SharedObjects.Message += "B";
        Write(".");
      }
    **}** 
    

    良好实践:请注意,由于检查 conch 是自愿的,如果您只在两个方法中的一个中使用 lock 语句,共享资源将继续被两个方法访问。确保所有访问共享资源的方法在尝试使用任何共享资源之前都通过在它上面调用 lock 来尊重 conch。

  3. 运行代码并查看结果,如下面的输出所示:

    Please wait for the tasks to complete.
    ..........
    Results: BBBBBAAAAA.
    10,345 elapsed milliseconds. 
    

虽然经过的时间更长,但一次只能有一个方法访问共享资源。MethodAMethodB 可以先开始。一旦一个方法完成了对共享资源的操作,conch 就会被释放,其他方法就有机会进行其工作。

理解 lock 语句

您可能会想知道 lock 语句在“锁定”对象变量时做了什么(提示:它并没有锁定对象!),如下面的代码所示:

lock (SharedObjects.Conch)
{
  // Work with a shared resource.
} 

C# 编译器将 lock 语句转换为使用 Monitor 类来 进入退出 conch 对象(我喜欢将其视为 拿起放下 conch 对象)的 try-finally 语句,如下面的代码所示:

try
{
  Monitor.Enter(SharedObjects.Conch);
  // Work with a shared resource.
}
finally
{
  Monitor.Exit(SharedObjects.Conch);
} 

当一个线程对一个引用类型调用 Monitor.Enter 时,它会检查是否有其他线程已经拿起了 conch。如果有,线程会等待。如果没有,线程会拿起 conch 并开始对其共享资源进行工作。一旦线程完成了其工作,它会调用 Monitor.Exit,释放 conch。

如果另一个线程正在等待,它现在可以拿起 conch 并进行其工作。这要求所有线程都通过适当地调用 Monitor.EnterMonitor.Exit 来尊重 conch。

良好实践:您不能使用值类型(struct 类型)作为 conch。Monitor.Enter 需要引用类型,因为它锁定内存地址。该对象的任何内部数据结构 不会 被锁定。

避免死锁

了解编译器如何将 lock 语句转换为对 Monitor 类的方法调用也很重要,因为使用 lock 语句可能会导致死锁。

当存在两个或更多共享资源(每个资源都有一个 conch 来监控哪个线程正在对每个共享资源进行工作)时,可能会发生死锁,以下事件序列会发生:

  • 线程 X “锁定” conch A 并开始对共享资源 A 进行工作。

  • 线程 Y “锁定” conch B 并开始对共享资源 B 进行工作。

  • 当线程 X 仍在处理资源 A 时,它还需要与资源 B 一起工作,因此它尝试“锁定”conch B,但由于线程 Y 已经拿起了 conch B,所以它被阻塞了。

  • 当线程 Y 仍在处理资源 B 时,它还需要与资源 A 一起工作,因此它尝试“锁定”海螺 A,但由于线程 X 已经拥有海螺 A,所以它被阻塞了。

防止死锁的一种方法是在尝试获取锁时指定超时。为此,你必须手动使用Monitor类而不是使用lock语句。让我们看看如何:

  1. Program.Methods.cs中,修改你的代码,将lock语句替换为尝试带超时进入海螺的代码,输出错误,然后退出监视器,允许其他线程进入监视器,如下面的代码中突出显示的MethodB所示:

    **try**
    **{**
    **if** **(Monitor.TryEnter(SharedObjects.Conch, TimeSpan.FromSeconds(****15****)))**
     **{**
        for (int i = 0; i < 5; i++)
        {
          Thread.Sleep(Random.Shared.Next(2000));
          SharedObjects.Message += "B";
          Write(".");
        }
     **}**
    **else**
     **{**
     **WriteLine(****"Method B timed out when entering a monitor on conch."****);**
     **}**
    **}**
    **finally**
    **{**
     **Monitor.Exit(SharedObjects.Conch);**
    **}** 
    
  2. 运行代码并查看结果,结果应该与之前相同(尽管 A 或 B 可能先拿到海螺)但代码更好,因为它将防止潜在的死锁。

良好实践:只有在你能够编写避免潜在死锁的代码时才使用lock关键字。如果你无法避免潜在的死锁,那么始终使用Monitor.TryEnter方法而不是lock,并结合try-finally语句,这样你就可以提供一个超时,如果发生死锁,其中一个线程将退出。你可以在以下链接中了解更多关于良好线程实践的信息:learn.microsoft.com/en-us/dotnet/standard/threading/managed-threading-best-practices

同步事件

.NET 事件不是线程安全的,所以在多线程场景中你应该避免使用它们。

在了解到.NET 事件不是线程安全之后,一些开发者在添加和删除事件处理器或引发事件时尝试使用独占锁,如下面的代码所示:

// event delegate field
public event EventHandler? Shout;
// conch
private object eventConch = new();
// method
public void Poke()
{
  lock (eventConch) // bad idea
  {
    // If something is listening...
    if (Shout != null)
    {
      // ...then call the delegate to raise the event.
      Shout(this, EventArgs.Empty);
    }
  }
} 

良好实践:一些开发者在事件处理中使用锁是好事还是坏事?嗯,这很复杂。它取决于复杂的因素,所以我不能给出价值判断。你可以在以下链接中了解更多关于事件和线程安全的信息:learn.microsoft.com/en-us/archive/blogs/cburrows/field-like-events-considered-harmful。但正如 Stephen Cleary 在以下博客文章中解释的那样,这很复杂:blog.stephencleary.com/2009/06/threadsafe-events.html

使 CPU 操作原子化

"Atomic"这个词来自希腊语atomos,意思是不可分割的。理解在多线程中哪些操作是原子的非常重要,因为如果不是原子的,它们在操作过程中可能会被另一个线程中断。下面的代码中 C#的增量操作符是原子的吗?

int x = 3;
x++; // is this an atomic CPU operation? 

这不是原子的! 增加一个整数的值需要以下三个 CPU 操作:

  1. 将实例变量中的值加载到寄存器中。

  2. 增加值。

  3. 将值存储在实例变量中。

一个线程在执行前两个步骤后可能会被中断。然后第二个线程可以执行所有三个步骤。当第一个线程恢复执行时,它将覆盖变量中的值,第二个线程执行的递增或递减的效果将丢失!

有一个名为Interlocked的类型,可以对以下列表中的整数类型执行原子操作,如AddIncrementDecrementExchangeCompareExchangeAndOrRead

  • System.Int32 (int), System.UInt32 (uint)

  • System.Int64 (long), System.UInt64 (ulong)

Interlocked不适用于像bytesbyteshortushortdecimal这样的数值类型。

Interlocked可以在以下类型上执行原子操作,如ExchangeCompareExchange,这些操作在内存中交换值:

  • System.Single (float), System.Double (double)

  • nint, nuint

  • System.Object (object)

让我们看看实际效果:

  1. SharedObjects类中声明另一个字段,该字段将计算发生的操作数量,如下面的代码所示:

    public static int Counter; // Another shared resource. 
    
  2. Program.Methods.cs中,在方法 A 和 B 中,在for语句内修改string值之后,添加一个语句来安全地增加计数器,如下面的代码所示:

    Interlocked.Increment(ref SharedObjects.Counter); 
    
  3. Program.cs中,在输出经过的时间之前,将计数器的当前值写入控制台,如下面的代码所示:

    WriteLine($"{SharedObjects.Counter} string modifications."); 
    
  4. 运行代码并查看结果,如下所示的高亮输出:

    Please wait for the tasks to complete.
    ..........
    Results: BBBBBAAAAA.
    10 string modifications.
    13,531 elapsed milliseconds. 
    

注意力敏锐的读者会意识到现有的 conch 对象保护了在由 conch 锁定的代码块中访问的所有共享资源,因此在这个特定示例中不需要使用Interlocked。但如果我们还没有保护像Message这样的另一个共享资源,那么使用Interlocked将是必要的。

应用其他类型的同步

MonitorInterlocked是互斥锁,简单且有效,但有时你需要更高级的选项来同步对共享资源的访问,如下表 5.3 所示:

类型 描述
ReaderWriterLock, ReaderWriterLockSlim 这些允许多个线程处于读取模式,一个线程处于写入模式并拥有写入锁的独占所有权,还有一个线程具有读取访问权限,可以处于可升级读取模式,从该模式中线程可以升级到写入模式,而无需放弃对资源的读取访问。
Mutex Monitor类似,它提供对共享资源的独占访问,但它用于进程间同步。
Semaphore, SemaphoreSlim 这些通过定义槽位来限制可以并发访问资源或资源池的线程数量。这被称为资源节流而不是资源锁定
AutoResetEvent, ManualResetEvent 事件等待句柄允许线程通过相互信号和等待对方的信号来同步活动。

表 5.3:同步类型

既然我们已经探讨了在多线程应用程序中同步访问共享资源的重要性,那么现在是时候深入了解 C# 5 中引入的一些新关键字如何使编写异步代码变得更加容易。

理解异步和 await

在使用Task类型时,C# 5 引入了两个 C#关键字。它们在以下方面特别有用:

  • 实现图形用户界面(GUI)的多任务处理

  • 提高 Web 应用程序和 Web 服务的可伸缩性

在第十六章使用.NET MAUI 构建移动和桌面应用程序中,我们将看到asyncawait关键字如何实现 GUI 的多任务处理。

但现在,让我们先学习为什么引入这两个 C#关键字的理论,然后稍后你将看到它们在实际中的应用。

提高控制台应用程序的响应性

控制台应用程序的一个限制是,你只能在标记为async的方法中使用await关键字,但 C# 7 及之前版本不允许将Main方法标记为async!幸运的是,C# 7.1 引入了一个新功能,支持在Main中使用async

  1. 使用你喜欢的代码编辑器,将一个新的控制台应用程序/console项目添加到名为Chapter05的解决方案中,命名为AsyncConsole

  2. Program.cs中,删除现有的语句,静态导入Console,然后添加语句创建一个HttpClient实例,对苹果的主页发起请求,并输出其字节大小,如下所示代码:

    using static System.Console;
    HttpClient client = new();
    HttpResponseMessage response =
      await client.GetAsync("http://www.apple.com/");
    WriteLine("Apple's home page has {0:N0} bytes.",
      response.Content.Headers.ContentLength); 
    
  3. 构建项目并注意它构建成功。在.NET 5 及之前版本中,项目模板创建了一个具有非异步Main方法的显式Program类,因此你会看到如下所示的错误消息:

    Program.cs(14,9): error CS4033: The 'await' operator can only be used within an async method. Consider marking this method with the 'async' modifier and changing its return type to 'Task'. [/Users/markjprice/apps-services-net7/Chapter04/AsyncConsole/AsyncConsole.csproj] 
    
  4. 你必须将async关键字添加到Main方法中,并将其返回类型更改为Task。在.NET 6 及以后版本中,控制台应用程序项目模板使用顶级程序功能自动为你定义具有异步<Main>$方法的Program类。

  5. 运行代码并查看结果,由于苹果公司经常更改其主页,因此字节数量可能不同,如下所示输出:

    Apple's home page has 40,252 bytes. 
    

使用异步流

在.NET Core 3 中,微软引入了流的异步处理。

你可以在以下链接中完成关于异步流的教程:learn.microsoft.com/en-us/dotnet/csharp/tutorials/generate-consume-asynchronous-stream

在 C# 8 和.NET Core 3 之前,await关键字仅适用于返回标量值的任务。.NET Standard 2.1 中的异步流支持允许异步方法依次返回一个值。

让我们看看一个模拟的例子,它返回一个异步流,包含三个随机整数:

  1. 使用您首选的代码编辑器向Chapter05解决方案中添加一个名为AsyncEnumerable的新控制台应用程序/ console项目。

  2. 全局和静态导入System.Console类,并将警告视为错误。

  3. 添加一个名为Program.Methods.cs的新文件。

  4. Program.Methods.cs中,删除任何现有的语句,然后定义一个使用yield关键字异步返回三个随机数字序列的方法,如下面的代码所示:

    partial class Program
    {
      private static async IAsyncEnumerable<int> GetNumbersAsync()
      {
        Random r = Random.Shared;
        // Simulate some work that takes 1.5 to 3 seconds.
        await Task.Delay(r.Next(1500, 3000));
        // Return a random number between 1 and 1000.
        yield return r.Next(1, 1001);
        await Task.Delay(r.Next(1500, 3000));
        yield return r.Next(1, 1001);
        await Task.Delay(r.Next(1500, 3000));
        yield return r.Next(1, 1001);
      }
    } 
    
  5. Program.cs中,删除现有的语句,然后添加语句来枚举数字序列,如下面的代码所示:

    // Use async streams to iterate over a collection asynchronously.
    await foreach (int number in GetNumbersAsync())
    {
      WriteLine($"Number: {number}");
    } 
    
  6. 运行代码并查看结果,如下面的输出所示:

    Number: 509
    Number: 813
    Number: 307 
    

提高 GUI 应用程序的响应性

到目前为止,在这本书中,我们只构建了控制台应用程序。当构建 Web 应用程序、Web 服务和具有 GUI(如 Windows 桌面和移动应用程序)的应用程序时,程序员的生涯会变得更加复杂。

原因之一是对于 GUI 应用程序,有一个特殊的线程:用户界面UI)线程。

在 GUI 中工作有两个规则:

  • 不要在 UI 线程上执行长时间运行的任务。

  • 除了 UI 线程之外,不要在任何线程上访问 UI 元素。

为了处理这些规则,程序员过去不得不编写复杂的代码来确保长时间运行的任务由非 UI 线程执行,但一旦完成,任务的结果就会被安全地传递到 UI 线程以展示给用户。这可能会很快变得混乱!

幸运的是,从 C# 5 及以后版本开始,您可以使用asyncawait。它们允许您继续以同步方式编写代码,这使代码保持整洁且易于理解,但底层,C#编译器创建了一个复杂的状态机并跟踪运行中的线程。这有点神奇!这两个关键字的组合使得异步方法在工作者线程上运行,并在完成时将结果返回到 UI 线程。

让我们看看一个例子。我们将使用Windows Presentation FoundationWPF)构建一个 Windows 桌面应用程序,该应用程序使用低级类型如SqlConnectionSqlCommandSqlDataReader从 SQL Server 数据库中的 Northwind 数据库获取员工信息。

Northwind 数据库具有中等复杂性和相当数量的样本记录。您在第二章使用 SQL Server 管理关系数据中广泛使用了它,其中介绍了并设置了该数据库。

警告! 只有在您拥有 Microsoft Windows 和存储在 Microsoft SQL Server 中的 Northwind 数据库的情况下,您才能完成此任务。这是本书中唯一一个不是跨平台和现代(WPF 已经 17 岁了!)的部分。您可以使用 Visual Studio 2022 或 Visual Studio Code。

到目前为止,我们专注于使 GUI 应用程序响应。你将在第十六章“使用 .NET MAUI 构建移动和桌面应用程序”中了解 XAML 和构建跨平台 GUI 应用程序。由于本书没有在其他地方涵盖 WPF,我认为这是一个很好的机会,至少可以看到一个使用 WPF 构建的应用程序示例,即使我们不详细查看它。让我们开始吧!

  1. 如果你使用的是 Visual Studio 2022,请在 Chapter05 解决方案中添加一个名为 WpfResponsive 的新 WPF 应用程序 [C#] 项目。如果你使用的是 Visual Studio Code,请使用以下命令:dotnet new wpf。如果你使用的是 JetBrains Rider,请选择 桌面应用程序,然后选择 类型WPF 应用程序

  2. 向项目中添加 Microsoft.Data.SqlClient 的包引用。

  3. 在项目文件中,请注意输出类型是 Windows EXE,目标框架是 .NET for Windows(它将在其他平台,如 macOS 和 Linux 上运行),并且项目使用 WPF,如下面的标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>WinExe</OutputType>
        <TargetFramework>net8.0-windows</TargetFramework>
        <Nullable>enable</Nullable>
        <UseWPF>true</UseWPF>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Microsoft.Data.SqlClient" Version="5.2.0" />
      </ItemGroup>
    </Project> 
    
  4. 构建 WpfResponsive 项目以恢复包。

  5. MainWindow.xaml 中,在 <Grid> 元素中,添加元素以定义两个按钮、一个文本框和一个列表框,它们在堆叠面板中垂直排列,如下面的标记所示:

    <StackPanel>
      <Button Name="GetEmployeesSyncButton" 
              Click="GetEmployeesSyncButton_Click">
        Get Employees Synchronously</Button>
      <Button Name="GetEmployeesAsyncButton" 
              Click="GetEmployeesAsyncButton_Click">
        Get Employees Asynchronously</Button>
      <TextBox HorizontalAlignment="Stretch" Text="Type in here" />
      <ListBox Name="EmployeesListBox" Height="400" />
    </StackPanel> 
    

    Visual Studio 2022 对构建 WPF 应用程序有良好的支持,并在你编辑代码和 XAML 标记时提供 IntelliSense。Visual Studio Code 不提供。

  6. MainWindow.xaml.cs 中,导入命名空间以使用 ADO.NET 和计时器,如下面的代码所示:

    using Microsoft.Data.SqlClient; // To use SqlConnection and so on.
    using System.Diagnostics; // To use Stopwatch. 
    
  7. MainWindow 类的构造函数中,定义两个 string 字段用于数据库连接字符串和 SQL 语句,如下面的代码所示:

    private string connectionString;
    private string sql = "WAITFOR DELAY '00:00:05';" +
      "SELECT EmployeeId, FirstName, LastName FROM Employees"; 
    

    SQL 有两个语句。第一个等待五秒钟以模拟长时间运行的查询。

  8. MainWindow 类的构造函数中,在调用 InitializeComponent 之后,使用 SqlConnectionStringBuilder 设置数据库连接字符串,如下面的代码所示:

    public MainWindow()
    {
      InitializeComponent();
      // Change as needed to work with your Northwind database.
      SqlConnectionStringBuilder builder = new();
      builder.DataSource = ".";
      builder.InitialCatalog = "Northwind";
      builder.Encrypt = false;
      builder.MultipleActiveResultSets = true;
      builder.ConnectTimeout = 5;
      // To use Windows Integrated authentication.
      builder.IntegratedSecurity = true;
      // To use SQL Server authentication.
      // builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");
      // builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");
      connectionString = builder.ConnectionString;
    } 
    
  9. 创建两个按钮点击的事件处理程序。它们必须使用 string 常量来打开与 Northwind 数据库的连接,然后使用所有员工的 ID 和名称填充列表框,如下面的代码所示:

    private void GetEmployeesSyncButton_Click(object sender, RoutedEventArgs e)
    {
      Stopwatch timer = Stopwatch.StartNew();
      using (SqlConnection connection = new(connectionString))
      {
        try
        {
          connection.Open();
          SqlCommand command = new(sql, connection);
          SqlDataReader reader = command.ExecuteReader();
          while (reader.Read())
          {
            string employee = string.Format("{0}: {1} {2}",
              reader.GetInt32(0), reader.GetString(1),
              reader.GetString(2));
            EmployeesListBox.Items.Add(employee);
          }
          reader.Close();
          connection.Close();
        }
        catch (Exception ex)
        {
          MessageBox.Show(ex.Message);
        }
      }
      EmployeesListBox.Items.Add(
        $"Sync: {timer.ElapsedMilliseconds:N0}ms");
    }
    private async void GetEmployeesAsyncButton_Click(
      object sender, RoutedEventArgs e)
    {
      Stopwatch timer = Stopwatch.StartNew();
      using (SqlConnection connection = new(connectionString))
      {
        try
        {
          await connection.OpenAsync();
          SqlCommand command = new(sql, connection);
          SqlDataReader reader = await command.ExecuteReaderAsync();
          while (await reader.ReadAsync())
          {
            string employee = string.Format("{0}: {1} {2}",
              await reader.GetFieldValueAsync<int>(0), 
              await reader.GetFieldValueAsync<string>(1), 
              await reader.GetFieldValueAsync<string>(2));
            EmployeesListBox.Items.Add(employee);
          }
          await reader.CloseAsync();
          await connection.CloseAsync();
        }
        catch (Exception ex)
        {
          MessageBox.Show(ex.Message);
        }
      }
      EmployeesListBox.Items.Add(
        $"Async: {timer.ElapsedMilliseconds:N0}ms");
    } 
    

    注意以下事项:

    • 定义一个 async void 方法通常是不良实践,因为它“发射并遗忘”。当你完成时,你将不会收到通知,并且无法取消它,因为它不返回一个 TaskTask<T>,这可以用来控制它。

    • SQL 语句使用 SQL Server 的 WAITFOR DELAY 命令来模拟需要五秒钟的处理。然后它从 Employees 表中选择三个列。

    • GetEmployeesSyncButton_Click 事件处理程序使用同步方法打开连接并获取员工行。

    • GetEmployeesAsyncButton_Click 事件处理程序被标记为 async,并使用带有 await 关键字的异步方法打开连接并获取员工行。

    • 两个事件处理器都使用计时器来记录操作所需的毫秒数,并将其添加到列表框中。

  10. 不带调试启动 WPF 应用程序。

  11. 在文本框中点击,输入一些文本,并注意 GUI 仍然响应。

  12. 点击 同步获取员工 按钮。

  13. 尝试在文本框中点击,并注意 GUI 不再响应。

  14. 等待至少五秒钟,直到列表框中填充了员工信息。

  15. 在文本框中点击,输入一些文本,并注意 GUI 再次变得响应。

  16. 点击 异步获取员工 按钮。

  17. 在文本框中点击,输入一些文本,并注意在执行操作时 GUI 仍然响应。继续输入,直到列表框中填充了员工信息,如图 5.2 所示:

图片

图 5.2:同步和异步将员工加载到 WPF 应用程序中

  1. 注意两个操作的时间差异。在同步获取数据时,UI 被阻塞,而在异步获取数据时,UI 保持响应。

  2. 关闭 WPF 应用程序。

提高网络应用程序和网络服务的可扩展性

在构建网站、应用程序和服务时,asyncawait 关键字也可以应用于服务器端。从客户端应用程序的角度来看,没有任何变化(或者他们甚至可能注意到请求返回所需时间的小幅增加)。因此,从单个客户端的角度来看,使用 asyncawait 在服务器端实现多任务处理会使他们的体验变得更差!

在服务器端,创建额外的、成本更低的工作线程来等待长时间运行的任务完成,以便昂贵的 I/O 线程可以处理其他客户端请求而不是被阻塞。这提高了网络应用程序或服务的整体可扩展性。可以同时支持更多的客户端。

支持多任务处理的常见类型

有许多常见的类型具有可以等待的异步方法,如图 5.4 表所示:

类型 方法
DbContext<T> AddAsyncAddRangeAsyncFindAsyncSaveChangesAsync
DbSet<T> AddAsyncAddRangeAsyncForEachAsyncSumAsyncToListAsyncToDictionaryAsyncAverageAsyncCountAsync
HttpClient GetAsyncPostAsyncPutAsyncDeleteAsyncSendAsync
StreamReader ReadAsyncReadLineAsyncReadToEndAsync
StreamWriter WriteAsyncWriteLineAsyncFlushAsync

表 5.4:具有异步方法的常见类型

良好实践:每次看到以 Async 后缀结尾的方法时,检查它是否返回 TaskTask<T>。如果它确实返回 TaskTask<T>,则可以使用它代替同步的非 Async 后缀方法。记得使用 await 调用它,并使用 async 装饰你的方法。

在 catch 块中使用 await

asyncawait首次在 C# 5 中引入时,只能在try块中使用await关键字,但不能在catch块中使用。在 C# 6 及以后版本中,现在可以在trycatch块中同时使用await

练习和探索

通过回答一些问题、进行一些实际操作和深入探索本章的主题来测试你的知识和理解。

练习 5.1 – 测试你的知识

回答以下问题:

  1. 你可以了解到关于一个进程的哪些信息?

  2. Stopwatch类的准确性如何?

  3. 按照惯例,应该给返回TaskTask<T>的方法添加什么后缀?

  4. 要在方法内部使用await关键字,方法声明必须应用什么关键字?

  5. 你如何创建一个子任务?

  6. 为什么你应该避免使用lock关键字?

  7. 你应该在什么时候使用Interlocked类?

  8. 你应该在什么时候使用Mutex类而不是Monitor类?

  9. 在网站或 Web 服务中使用asyncawait的好处是什么?

  10. 你可以取消一个任务吗?如果是的话,应该如何操作?

练习 5.2 – 探索主题

使用以下网页上的链接了解更多关于本章涵盖的主题:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-5---multitasking-and-concurrency

练习 5.3 – 了解更多关于并行编程

Packt 有一本书深入探讨了本章的主题,《使用 C# 10 和.NET 6 进行并行编程和并发:一种现代方法,用于构建更快、更响应和异步的.NET 应用程序》,作者 Alvin Ashcroft:

www.packtpub.com/product/parallel-programming-and-concurrency-with-c-10-and-net-6/9781803243672

概述

在本章中,你学习了:

  • 如何定义和启动一个任务。

  • 如何等待一个或多个任务完成。

  • 如何控制任务完成顺序。

  • 如何同步访问共享资源。

  • asyncawait背后的魔法。

在下一章中,你将学习如何使用一些流行的第三方库。

第六章:使用流行的第三方库

本章介绍了几个流行的 .NET 第三方库,这些库允许你执行一些操作,这些操作要么无法使用核心 .NET 库完成,要么比内置功能更好。这些操作包括使用 ImageSharp 处理图像、使用 Serilog 记录、使用 AutoMapper 将对象映射到其他对象、使用 FluentAssertions 进行单元测试断言、使用 FluentValidation 验证数据以及使用 QuestPDF 生成 PDF。

本章涵盖以下主题:

  • 哪些第三方库最受欢迎?

  • 处理图像

  • 使用 Serilog 记录

  • 对象之间的映射

  • 在单元测试中进行流畅断言

  • 验证数据

  • 生成 PDF

哪些第三方库最受欢迎?

为了帮助我决定在本书中包含哪些第三方库,我研究了在 www.nuget.org/stats/packages 上下载频率最高的库,如 表 6.1 所示,它们是:

排名 下载量
1 newtonsoft.json 167,927,712
2 serilog 42,436,567
3 awssdk.core 36,423,449
4 castle.core 28,383,411
5 newtonsoft.json.bson 26,547,661
6 swashbuckle.aspnetcore.swagger 25,828,940
7 swashbuckle.aspnetcore.swaggergen 25,823,941
8 polly 22,487,368
9 automapper 21,679,921
10 swashbuckle.aspnetcore.swaggerui 21,373,873
12 moq 19,408,440
15 fluentvalidation 17,739,259
16 humanizer.core 17,602,598
23 stackexchange.redis 15,771,377
36 fluentassertions 12,244,097
40 dapper 10,819,569
52 rabbitmq.client 8,591,362
83 hangfire.core 5,479,381
94 nodatime 4,944,830

表 6.1:最受欢迎的 NuGet 包

我书中涵盖的内容

我的书《C# 12 和 .NET 8 – 现代跨平台开发基础》介绍了使用 newtonsoft.json 处理 JSON 以及使用 swashbuckle 记录 Web 服务。

目前,使用 Castle Core 生成动态代理和类型化字典,或将应用程序部署到并与 Amazon Web ServicesAWS)集成,超出了本书的范围。

除了原始下载量外,读者的提问和库的有用性也影响了我将库包含在本章中的决定,如下列总结所示:

  • 最受欢迎的图像处理库:ImageSharp

  • 最受欢迎的文本处理库:Humanizer

  • 最受欢迎的日志库:Serilog

  • 最受欢迎的对象映射库:AutoMapper

  • 最受欢迎的单元测试断言库:FluentAssertions

  • 最受欢迎的数据验证库:FluentValidation

  • 生成 PDF 的开源库:QuestPDF

在第七章,处理日期、时间和国际化 中,我介绍了处理日期和时间的最流行库:Noda Time

在第九章,缓存、队列和弹性后台服务 中,我介绍了一些更受欢迎的库,如下列列表中总结:

  • 最受欢迎的弹性和短暂故障处理库:Polly

  • 最受欢迎的作业调度和实现后台服务的库:Hangfire

  • 最受欢迎的分布式缓存库:Redis

  • 最受欢迎的队列库:RabbitMQ

处理图像

ImageSharp 是一个第三方跨平台 2D 图形库。当 .NET Core 1.0 开发时,社区对缺少用于处理 2D 图像的 System.Drawing 命名空间提出了负面反馈。ImageSharp 项目开始是为了填补现代 .NET 应用程序的这一空白。

在 Microsoft 的 System.Drawing 的官方文档中,微软表示,“System.Drawing 命名空间不建议用于新开发,因为它不支持在 Windows 或 ASP.NET 服务中,并且它不是跨平台的。ImageSharp 和 SkiaSharp 被推荐作为替代方案。”

六个劳动在 2023 年 3 月发布了 ImageSharp 3.0。现在它需要 .NET 6 或更高版本,未来的主要版本将针对 .NET 的 LTS 版本,如 .NET 8。您可以在以下链接中阅读公告:sixlabors.com/posts/announcing-imagesharp-300/

生成灰度缩略图

让我们看看 ImageSharp 能实现什么:

  1. 使用您首选的代码编辑器创建一个控制台应用程序项目,如下列列表中定义:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter06

    • 项目文件和文件夹:WorkingWithImages

    • 不要使用顶级语句:已清除

    • 启用原生 AOT 发布:已清除

  2. WorkingWithImages 项目中,创建一个 images 文件夹,并从以下链接下载九张图片到该文件夹:github.com/markjprice/apps-services-net8/tree/master/images/Categories

  3. 如果你正在使用 Visual Studio 2022,那么 images 文件夹及其文件必须复制到 WorkingWithImages\bin\Debug\net8 文件夹,编译的控制台应用程序将在该文件夹中运行。我们可以配置 Visual Studio 为我们完成此操作,如下所示的操作步骤:

    1. 解决方案资源管理器 中,选择所有九张图片。

    2. 属性 中,将 复制到输出目录 设置为 始终复制,如图 6.1 所示:包含文本、截图、软件、计算机图标  自动生成的描述

    图 6.1:设置图像始终复制到输出目录

    1. 打开项目文件,注意 <ItemGroup> 条目,它们将复制九张图片到正确的文件夹,如下所示的部分标记:

      <ItemGroup>
        <None Update="images\categories.jpeg">
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
        <None Update="images\category1.jpeg">
          <CopyToOutputDirectory>Always</CopyToOutputDirectory>
        </None>
      ... 
      
  4. WorkingWithImages 项目中,将警告视为错误,全局和静态导入 System.Console 类,并为 SixLabors.ImageSharp 添加包引用,如下面的标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup>
     **<ItemGroup>**
     **<Using Include=****"System.Console"** **Static=****"true"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<PackageReference Include=****"SixLabors.ImageSharp"** **Version=****"3.0.2"** **/>**
     **</ItemGroup>**
    ... 
    

    为了节省空间,在本章的其他类似步骤中,我将不会显示将警告视为错误或全局和静态导入 System.Console 类的标记。我将只显示针对特定任务的库的 ItemGroupPackageReference

  5. 构建名为 WorkingWithImages 的项目。

  6. 如果你正在使用 Visual Studio 2022,那么在解决方案资源管理器中,切换显示所有文件

  7. obj\Debug\net8.0 文件夹中,打开 WorkingWithImages.GlobalUsings.g.cs,并注意引用 SixLabors.ImageSharp 包会在 .NET SDK 添加的常规命名空间导入旁边添加三个全局命名空间导入,如下面的代码所示:

    // <auto-generated/>
    global using global::SixLabors.ImageSharp;
    global using global::SixLabors.ImageSharp.PixelFormats;
    global using global::SixLabors.ImageSharp.Processing;
    ... 
    

    如果你引用了 SixLabors.ImageSharp 的旧版本,如 2.0.0,那么它不会这样做,因此你必须在每个代码文件中手动导入这三个命名空间。这是为什么 3.0 及以后的版本对 .NET 6 有最低要求的一个原因。

  8. Program.cs 中,删除现有的语句,然后添加语句将 images 文件夹中的所有文件转换为十分之一大小的灰度缩略图,如下面的代码所示:

    string imagesFolder = Path.Combine(
      Environment.CurrentDirectory, "images");
    WriteLine($"I will look for images in the following folder:\n{imagesFolder}");
    WriteLine();
    if (!Directory.Exists(imagesFolder))
    {
      WriteLine();
      WriteLine("Folder does not exist!");
      return;
    }
    IEnumerable<string> images =
      Directory.EnumerateFiles(imagesFolder);
    foreach (string imagePath in images)
    {
      if (Path.GetFileNameWithoutExtension(imagePath).EndsWith("-thumbnail"))
      {
        WriteLine($"Skipping:\n  {imagePath}");
        WriteLine();
        continue; // This file has already been converted.
      }
      string thumbnailPath = Path.Combine(
        Environment.CurrentDirectory, "images",
        Path.GetFileNameWithoutExtension(imagePath)
        + "-thumbnail" + Path.GetExtension(imagePath));
      using (Image image = Image.Load(imagePath))
      {
        WriteLine($"Converting:\n  {imagePath}");
        WriteLine($"To:\n  {thumbnailPath}");
        image.Mutate(x => x.Resize(image.Width / 10, image.Height / 10));
        image.Mutate(x => x.Grayscale());
        image.Save(thumbnailPath);
        WriteLine();
      }
    }
    WriteLine("Image processing complete.");
    if (OperatingSystem.IsWindows())
    {
      Process.Start("explorer.exe", imagesFolder);
    }
    else
    {
      WriteLine("View the images folder.");
    } 
    
  9. 运行控制台应用程序,并注意图像应该被转换为灰度缩略图,如下面的部分输出所示:

    I will look for images in the following folder:
    C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images
    Converting:
      C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images\categories.jpeg
    To:
      C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images\categories-thumbnail.jpeg
    Converting:
      C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images\category1.jpeg
    To:
      C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images\category1-thumbnail.jpeg
    ...
    Converting:
      C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images\category8.jpeg
    To:
      C:\apps-services-net8\Chapter06\WorkingWithImages\bin\Debug\net8.0\images\category8-thumbnail.jpeg
    Image processing complete. 
    
  10. 在文件系统中,打开适当的 images 文件夹,并注意比之前小得多的字节灰度缩略图,如图 6.2 所示:

图片

图 6.2:处理后的图像

用于绘制和网络的 ImageSharp 包

ImageSharp 还提供了用于程序化绘制图像和在网络中处理图像的 NuGet 包,如下面的列表所示:

  • SixLabors.ImageSharp.Drawing

  • SixLabors.ImageSharp.Web

    更多信息:了解更多详情,请访问以下链接:docs.sixlabors.com/

使用 Humanizer 处理文本和数字

Humanizer 在 string 值、enum 值的名称、日期、时间、数字和数量中操作文本。

处理文本

内置的 string 类型有 SubstringTrim 等方法来操作文本。但还有许多其他常见的文本操作,我们可能想要执行。例如:

  • 你可能有一些代码生成的丑陋字符串,你希望使其看起来更友好,以便向用户显示。这在多词值不能使用空格的 enum 类型中很常见。

  • 你可能正在构建一个内容管理系统,当用户输入文章标题时,你需要将他们输入的内容转换为适合 URL 路径的格式。

  • 你可能有一个需要截断以在移动用户界面有限空间中显示的长 string 值。

Humanizer 的案例转换

可以通过将多个 Humanizer 转换传递给 Transform 方法来按顺序执行复杂转换。转换实现 IStringTransformerICulturedStringTransformer 接口,因此您可以实现自己的自定义转换。

内置的转换都是大小写转换,它们列在 表 6.2 中,以及扩展 string 类型的便捷替代方法:

Transform 描述 示例
To.LowerCase string 中的所有字符转换为小写。 the cat sat on the mat
To.UpperCase string 中的所有字符转换为大写。 THE CAT SAT ON THE MAT
To.TitleCase string 中每个单词的第一个字符转换为大写。 The Cat Sat on the Mat
To.SentenceCase string 中的第一个字符转换为大写。它忽略句点(句号),因此不识别句子! The cat sat on the mat

表 6.2:Humanizer 大小写转换

良好实践:考虑原始文本的大小写非常重要。如果它已经是大写,标题和句子大小写选项将 不会 转换为小写!您可能需要先转换为小写,然后再转换为标题或句子大小写。

除了使用 To.TitleCase 这样的转换对象调用 Transform 方法外,还有便捷的方法来操作文本的大小写,如 表 6.3 所示:

字符串扩展方法 描述
Titleize 等同于使用 To.TitleCase 进行转换
Pascalize 将字符串转换为 upper camel case,同时删除下划线
Camelize Pascalize 相同,除了第一个字符是小写

表 6.3:Humanizer 文本扩展方法

Humanizer 空格转换

有便捷的方法来操作文本的空格,通过添加下划线和破折号,如 表 6.4 所示:

字符串扩展方法 描述
Underscore 使用下划线分隔输入单词
DasherizeHyphenate 在字符串中将下划线替换为破折号(连字符)
Kebaberize 使用破折号(连字符)分隔输入单词

表 6.4:Humanizer 空格转换方法

Humanizer 的单复数转换方法

Humanizer 为 string 类提供了两个扩展方法,用于自动在单词的单数和复数版本之间转换,如 表 6.5 所示:

字符串扩展方法 描述
Singularize 如果 string 包含复数词,则将其转换为单数等价词。
Pluralize 如果 string 包含单数词,则将其转换为复数等价词。

表 6.5:Humanizer 的单复数转换方法

这些方法被 Microsoft Entity Framework Core 用于将实体类及其成员的名称单复数化。

使用控制台应用程序探索文本操作

让我们探索一些使用 Humanizer 进行文本操作示例:

  1. 使用你喜欢的代码编辑器向Chapter06解决方案中添加一个名为HumanizingData的新Console App / console项目。在 Visual Studio 2022 中,将启动项目设置为当前选择。

  2. HumanizingData项目中,将警告视为错误,全局和静态导入System.Console类,并为Humanizer添加包引用,如下所示标记:

    <ItemGroup>
      <PackageReference Include="Humanizer" Version="2.14.1" />
    </ItemGroup> 
    

    我们正在引用一个包含所有语言的Humanizer包。如果你只需要英语,那么你可以引用Humanizer.Core。如果你还需要语言子集,请使用模式Humanizer.Core.<lang>引用特定的语言包,例如,Humanizer.Core.fr用于法语。

  3. 构建项目HumanizingData以恢复包。

  4. 向项目中添加一个名为Program.Functions.cs的新类文件。

  5. Program.Functions.cs中,添加导入用于全球化操作的命名空间的语句,并定义一个方法来配置控制台以启用当前文化的轻松切换并启用特殊字符的使用,如下所示代码:

    using System.Globalization; // To use CultureInfo.
    partial class Program
    {
      private static void ConfigureConsole(string culture = "en-US")
      {
        // To enable special characters like … (ellipsis) as a single character.
        OutputEncoding = System.Text.Encoding.UTF8;
        Thread t = Thread.CurrentThread;
        t.CurrentCulture = CultureInfo.GetCultureInfo(culture);
        t.CurrentUICulture = t.CurrentCulture;
        WriteLine("Current culture: {0}", t.CurrentCulture.DisplayName);
        WriteLine();
      }
    } 
    
  6. Program.cs中,删除现有的语句,然后调用ConfigureConsole方法,如下所示代码:

    ConfigureConsole(); // Defaults to en-US culture. 
    
  7. Program.Functions.cs中,添加导入用于处理 Humanizer 提供的扩展方法的命名空间的语句,如下所示代码:

    using Humanizer; // To use common Humanizer extension methods. 
    
  8. Program.Functions.cs中,添加定义一个方法来输出原始的string,然后是使用内置的大小写转换转换的结果,如下所示代码:

    private static void OutputCasings(string original)
    {
      WriteLine("Original casing: {0}", original);
      WriteLine("Lower casing: {0}", original.Transform(To.LowerCase));
      WriteLine("Upper casing: {0}", original.Transform(To.UpperCase));
      WriteLine("Title casing: {0}", original.Transform(To.TitleCase));
      WriteLine("Sentence casing: {0}", original.Transform(To.SentenceCase));
      WriteLine("Lower, then Sentence casing: {0}", 
        original.Transform(To.LowerCase, To.SentenceCase));
      WriteLine();
    } 
    
  9. Program.cs中,使用三个不同的string值调用OutputCasings方法,如下所示代码:

    OutputCasings("The cat sat on the mat.");
    OutputCasings("THE CAT SAT ON THE MAT.");
    OutputCasings("the cat sat on the mat. the frog jumped."); 
    
  10. 运行代码并查看结果,如下所示输出:

    Current culture: English (United States)
    Original casing: The cat sat on the mat.
    Lower casing: the cat sat on the mat.
    Upper casing: THE CAT SAT ON THE MAT.
    Title casing: The Cat Sat on the Mat.
    Sentence casing: The cat sat on the mat.
    Lower, then Sentence casing: The cat sat on the mat.
    Original casing: THE CAT SAT ON THE MAT.
    Lower casing: the cat sat on the mat.
    Upper casing: THE CAT SAT ON THE MAT.
    Title casing: THE CAT SAT ON THE MAT.
    Sentence casing: THE CAT SAT ON THE MAT.
    Lower, then Sentence casing: The cat sat on the mat.
    Original casing: the cat sat on the mat. the frog jumped.
    Lower casing: the cat sat on the mat. the frog jumped.
    Upper casing: THE CAT SAT ON THE MAT. THE FROG JUMPED.
    Title casing: The Cat Sat on the Mat. the Frog Jumped.
    Sentence casing: The cat sat on the mat. the frog jumped.
    Lower, then Sentence casing: The cat sat on the mat. the frog jumped. 
    
  11. Program.Functions.cs中,添加定义一个方法,该方法使用各种 Humanizer 扩展方法输出一个丑陋的string值,如下所示代码:

    private static void OutputSpacingAndDashes()
    {
      string ugly = "ERROR_MESSAGE_FROM_SERVICE";
      WriteLine("Original string: {0}", ugly);
      WriteLine("Humanized: {0}", ugly.Humanize());
      // LetterCasing is legacy and will be removed in future.
      WriteLine("Humanized, lower case: {0}", 
        ugly.Humanize(LetterCasing.LowerCase));
      // Use Transform for casing instead.
      WriteLine("Transformed (lower case, then sentence case): {0}",
        ugly.Transform(To.LowerCase, To.SentenceCase));
      WriteLine("Humanized, Transformed (lower case, then sentence case): {0}",
        ugly.Humanize().Transform(To.LowerCase, To.SentenceCase));
    } 
    
  12. Program.cs中,注释掉之前的调用方法,然后添加调用新方法的语句,如下所示高亮显示的代码:

    **/***
    OutputCasings("The cat sat on the mat.");
    OutputCasings("THE CAT SAT ON THE MAT.");
    OutputCasings("the cat sat on the mat. the frog jumped.");
    ***/**
    **OutputSpacingAndDashes();** 
    
  13. 运行代码并查看结果,如下所示输出:

    Original string: ERROR_MESSAGE_FROM_SERVICE
    Humanized: ERROR MESSAGE FROM SERVICE
    Humanized, lower case: error message from service
    Transformed (lower case, then sentence case): Error_message_from_service
    Humanized, Transformed (lower case, then sentence case): Error message from service 
    
  14. 向项目中添加一个名为WondersOfTheAncientWorld.cs的新类文件。

  15. 修改WondersOfTheAncientWorld.cs文件,如下所示代码:

    namespace Packt.Shared;
    public enum WondersOfTheAncientWorld : byte
    {
      None                     = 0b_0000_0000, // i.e. 0
      GreatPyramidOfGiza       = 0b_0000_0001, // i.e. 1
      HangingGardensOfBabylon  = 0b_0000_0010, // i.e. 2
      StatueOfZeusAtOlympia    = 0b_0000_0100, // i.e. 4
      TempleOfArtemisAtEphesus = 0b_0000_1000, // i.e. 8
      MausoleumAtHalicarnassus = 0b_0001_0000, // i.e. 16
      ColossusOfRhodes         = 0b_0010_0000, // i.e. 32
      LighthouseOfAlexandria   = 0b_0100_0000  // i.e. 64
    } 
    
  16. Program.Functions.cs中,导入用于使用我们刚刚定义的enum的命名空间,如下所示代码:

    using Packt.Shared; // To use WondersOfTheAncientWorld. 
    
  17. Program.Functions.cs中,定义一个方法来创建WondersOfTheWorld变量并使用各种 Humanizer 扩展方法输出其名称,如下所示代码:

    private static void OutputEnumNames()
    {
      var favoriteAncientWonder = WondersOfTheAncientWorld.StatueOfZeusAtOlympia;
      WriteLine("Raw enum value name: {0}", favoriteAncientWonder);
      WriteLine("Humanized: {0}", favoriteAncientWonder.Humanize());
      WriteLine("Humanized, then Titleized: {0}", 
        favoriteAncientWonder.Humanize().Titleize());
      WriteLine("Truncated to 8 characters: {0}", 
        favoriteAncientWonder.ToString().Truncate(length: 8));
      WriteLine("Kebaberized: {0}", 
        favoriteAncientWonder.ToString().Kebaberize());
    } 
    
  18. Program.cs中,注释掉之前的调用方法,然后添加对OutputEnumNames的调用,如下所示高亮显示的代码:

    **//**OutputSpacingAndDashes();
    **OutputEnumNames();** 
    
  19. 运行代码并查看结果,如下所示输出:

    Raw enum value name: StatueOfZeusAtOlympia
    Humanized: Statue of zeus at olympia
    Humanized, then Titlerized: Statue of Zeus at Olympia
    Truncated to 8 characters: StatueO…
    Kebaberized: statue-of-zeus-at-olympia 
    

注意Truncate方法默认使用单字符(省略号)。如果你要求截断到 8 个字符的长度,它可以返回前七个字符后跟省略号字符。你可以使用Truncate方法的重载指定不同的字符。

处理数字

现在让我们看看 Humanizer 如何帮助我们处理数字:

  1. Program.Functions.cs中,定义一个方法以创建一些数字,然后使用各种 Humanizer 扩展方法输出它们,如下所示代码:

    private static void NumberFormatting()
    {
      int number = 123;
      WriteLine($"Original number: {number}");
      WriteLine($"Roman: {number.ToRoman()}");
      WriteLine($"Words: {number.ToWords()}");
      WriteLine($"Ordinal words: {number.ToOrdinalWords()}");
      WriteLine();
      string[] things = { "fox", "person", "sheep", 
        "apple", "goose", "oasis", "potato", "die", "dwarf",
        "attorney general", "biceps"};
      for (int i = 1; i <= 3; i++)
      {
        for (int j = 0; j < things.Length; j++)
        {
          Write(things[j].ToQuantity(i, ShowQuantityAs.Words));
          if (j < things.Length - 1) Write(", ");
        }
        WriteLine();
      }
      WriteLine();
      int thousands = 12345;
      int millions = 123456789;
      WriteLine("Original: {0}, Metric: About {1}", thousands,
        thousands.ToMetric(decimals: 0));
      WriteLine("Original: {0}, Metric: {1}", thousands,
        thousands.ToMetric(MetricNumeralFormats.WithSpace 
          | MetricNumeralFormats.UseShortScaleWord, 
          decimals: 0));
      WriteLine("Original: {0}, Metric: {1}", millions,
        millions.ToMetric(decimals: 1));
    } 
    
  2. Program.cs中,注释掉之前的方法调用并添加对NumberFormatting的调用,如下所示高亮显示的代码:

    **//**OutputEnumNames();
    **NumberFormatting();** 
    
  3. 运行代码并查看结果,如下所示输出:

    Original number: 123
    Roman: CXXIII
    Words: one hundred and twenty-three
    Ordinal words: hundred and twenty-third
    one fox, one person, one sheep, one apple, one goose, one oasis, one potato, one die, one dwarf, one attorney general, one bicep
    two foxes, two people, two sheep, two apples, two geese, two oases, two potatoes, two dice, two dwarves, two attorney generals, two biceps
    three foxes, three people, three sheep, three apples, three geese, three oases, three potatoes, three dice, three dwarves, three attorney generals, three biceps
    Original: 12345, Metric: About 12k
    Original: 12345, Metric: About 12 thousand
    Original: 123456789, Metric: 123.5M 
    

    Humanizer 的默认词汇表相当不错,但它并没有正确地将“总检察长”(复数形式为attorneys general)或“二头肌”(单数为biceps,复数为bicepses)进行复数化。

  4. Program.Functions.cs中,导入用于处理 Humanizer 词汇的命名空间,如下所示代码:

    using Humanizer.Inflections; // To use Vocabularies. 
    
  5. Program.Functions.cs中,在NumberFormatting方法的顶部添加语句以注册两个不规则单词,如下所示代码:

    Vocabularies.Default.AddIrregular("biceps", "bicepses");
    Vocabularies.Default.AddIrregular("attorney general", "attorneys general"); 
    
  6. 运行代码,查看结果,并注意现在两个不规则单词的输出是正确的。

处理日期和时间

现在让我们看看 Humanizer 如何帮助我们处理日期和时间:

  1. Program.Functions.cs中,定义一个方法以获取当前日期和时间以及一些天数,然后使用各种 Humanizer 扩展方法输出它们,如下所示代码:

    private static void DateTimeFormatting()
    {
      DateTimeOffset now = DateTimeOffset.Now;
      // By default, all Humanizer comparisons are to Now (UTC).
      WriteLine($"Now (UTC): {now}");
      WriteLine("Add 3 hours, Humanized: {0}", 
        now.AddHours(3).Humanize());
      WriteLine("Add 3 hours and 1 minute, Humanized: {0}", 
        now.AddHours(3).AddMinutes(1).Humanize());
      WriteLine("Subtract 3 hours, Humanized: {0}", 
        now.AddHours(-3).Humanize());
      WriteLine("Add 24 hours, Humanized: {0}", 
        now.AddHours(24).Humanize());
      WriteLine("Add 25 hours, Humanized: {0}", 
        now.AddHours(25).Humanize());
      WriteLine("Add 7 days, Humanized: {0}", 
        now.AddDays(7).Humanize());
      WriteLine("Add 7 days and 1 minute, Humanized: {0}", 
        now.AddDays(7).AddMinutes(1).Humanize());
      WriteLine("Add 1 month, Humanized: {0}", 
        now.AddMonths(1).Humanize());
      WriteLine();
      // Examples of TimeSpan humanization.
      int[] daysArray = { 12, 13, 14, 15, 16 };
      foreach (int days in daysArray)
      {
        WriteLine("{0} days, Humanized: {1}",
          days, TimeSpan.FromDays(days).Humanize());
        WriteLine("{0} days, Humanized with precision 2: {1}",
          days, TimeSpan.FromDays(days).Humanize(precision: 2));
        WriteLine("{0} days, Humanized with max unit days: {1}",
          days, TimeSpan.FromDays(days).Humanize(
            maxUnit: Humanizer.Localisation.TimeUnit.Day));
        WriteLine();
      }
      // Examples of clock notation.
      TimeOnly[] times = { new TimeOnly(9, 0),
        new TimeOnly(9, 15), new TimeOnly(15, 30) };
      foreach (TimeOnly time in times)
      {
        WriteLine("{0}: {1}", time, time.ToClockNotation());
      }
    } 
    
  2. Program.cs中,注释掉之前的方法调用并添加对DateTimeFormatting的调用,如下所示高亮显示的代码:

    **//**NumberFormatting();
    **DateTimeFormatting();** 
    
  3. 运行代码并查看结果,如下所示输出:

    Current culture: English (United States)
    Now (UTC): 5/30/2023 8:12:51 AM +01:00
    Add 3 hours, Humanized: 2 hours from now
    Add 3 hours and 1 minute, Humanized: 3 hours from now
    Subtract 3 hours, Humanized: 3 hours ago
    Add 24 hours, Humanized: 23 hours from now
    Add 25 hours, Humanized: tomorrow
    Add 7 days, Humanized: 6 days from now
    Add 7 days and 1 minute, Humanized: 7 days from now
    Add 1 month, Humanized: one month from now
    12 days, Humanized: 1 week
    12 days, Humanized with precision 2: 1 week, 5 days
    12 days, Humanized with max unit days: 12 days
    13 days, Humanized: 1 week
    13 days, Humanized with precision 2: 1 week, 6 days
    13 days, Humanized with max unit days: 13 days
    14 days, Humanized: 2 weeks
    14 days, Humanized with precision 2: 2 weeks
    14 days, Humanized with max unit days: 14 days
    15 days, Humanized: 2 weeks
    15 days, Humanized with precision 2: 2 weeks, 1 day
    15 days, Humanized with max unit days: 15 days
    16 days, Humanized: 2 weeks
    16 days, Humanized with precision 2: 2 weeks, 2 days
    16 days, Humanized with max unit days: 16 days
    9:00 AM: nine o'clock
    9:15 AM: a quarter past nine
    3:30 PM: half past three 
    
  4. Program.cs中,配置控制台时指定法语语言和区域,如下所示高亮显示的代码:

    ConfigureConsole(**"fr-FR"**); // Defaults to en-US culture. 
    
  5. 运行代码,查看结果,并注意文本已本地化为法语。

使用 Serilog 进行日志记录

虽然.NET 包括日志记录框架,但第三方日志提供程序通过使用结构化事件数据提供了更多的功能和灵活性。Serilog 是最受欢迎的。

结构化事件数据

大多数系统将纯文本消息写入其日志。

Serilog 可以指示将序列化的结构化数据写入日志。在参数前加上@符号会告诉 Serilog 序列化传入的对象,而不是仅调用ToString方法的结果。

之后,那个复杂对象可以在日志中进行查询,以提供改进的搜索和排序功能。

例如:

var lineitem = new { ProductId = 11, UnitPrice = 25.49, Quantity = 3 };
log.Information("Added {@LineItem} to shopping cart.", lineitem); 

你可以在以下链接中了解更多关于 Serilog 如何处理结构化数据的信息:github.com/serilog/serilog/wiki/Structured-Data

Serilog 接收器

所有日志系统都需要将日志条目记录到某个地方。这可能是在控制台输出、文件或更复杂的数据存储,如关系数据库或云数据存储。Serilog 将这些称为 sinks

Serilog 为您可能想要记录日志的所有可能位置提供了数百个官方和第三方 sink 包。要使用它们,只需包含适当的包。以下是最受欢迎的列表:

  • serilog.sinks.file

  • serilog.sinks.console

  • serilog.sinks.periodicbatching

  • serilog.sinks.debug

  • serilog.sinks.rollingfile(已弃用;请使用 serilog.sinks.file 代替)

  • serilog.sinks.applicationinsights

  • serilog.sinks.mssqlserver

目前在微软的公共 NuGet 资源库中列出了 470 多个包:www.nuget.org/packages?q=serilog.sinks

使用 Serilog 将日志记录到控制台和滚动文件

让我们开始:

  1. 使用您首选的代码编辑器,向 Chapter06 解决方案中添加一个名为 Serilogging 的新 Console App / console 项目。

  2. Serilogging 项目中,将警告视为错误,全局和静态导入 System.Console 类,并为 Serilog 添加包引用,包括 consolefile(也支持滚动文件)的存储,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Serilog" Version="3.1.1" />
      <PackageReference Include="Serilog.Sinks.Console" Version="5.0.0" />
      <PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
    </ItemGroup> 
    
  3. 构建项目 Serilogging

  4. Serilogging 项目中,添加一个名为 Models 的新文件夹。

  5. Serilogging 项目中,在 Models 文件夹中,添加一个名为 ProductPageView.cs 的新类文件,并修改其内容,如下面的代码所示:

    namespace Serilogging.Models;
    public class ProductPageView
    {
      public int ProductId { get; set; }
      public string? PageTitle { get; set; }
      public string? SiteSection { get; set; }
    } 
    
  6. Program.cs 中,删除现有的语句,然后导入一些用于处理 Serilog 的命名空间,如下面的代码所示:

    using Serilog; // To use Log, LoggerConfiguration, RollingInterval.
    using Serilog.Core; // To use Logger.
    using Serilogging.Models; // To use ProductPageView. 
    
  7. Program.cs 中,创建一个将日志写入控制台并配置滚动间隔的日志配置器,这意味着每天创建一个新文件,并写入各种级别的日志条目,如下面的代码所示:

    // Create a new logger that will write to the console and to
    // a text file, one-file-per-day, named with the date.
    using Logger log = new LoggerConfiguration()
        .WriteTo.Console()
        .WriteTo.File("log.txt", rollingInterval: RollingInterval.Day)
        .CreateLogger();
    // Assign the new logger to the static entry point for logging.
    Log.Logger = log;
    Log.Information("The global logger has been configured.");
    // Log some example entries of differing severity.
    Log.Warning("Danger, Serilog, danger!");
    Log.Error("This is an error!");
    Log.Fatal("Fatal problem!");
    ProductPageView pageView = new() { 
      PageTitle = "Chai", 
      SiteSection = "Beverages", 
      ProductId = 1 };
    Log.Information("{@PageView} occurred at {Viewed}",
      pageView, DateTimeOffset.UtcNow);
    // For a log with a buffer, like a text file logger, you
    // must flush before ending the app.
    Log.CloseAndFlush(); 
    
  8. 运行控制台应用程序,并注意消息,如下面的输出所示:

    [07:09:43 INF] The global logger has been configured.
    [07:09:43 WRN] Danger, Serilog, danger!
    [07:09:43 ERR] This is an error!
    [07:09:43 FTL] Fatal problem!
    [07:09:43 INF] {"ProductId": 1, "PageTitle": "Chai", "SiteSection": "Beverages", "$type": "ProductPageView"} occurred at 09/07/2023 15:08:44 +00:00 
    
  9. 打开 logYYYYMMDD.txt 文件,其中 YYYY 是年份,MM 是月份,DD 是日期,并注意它包含相同的消息:

    • 对于 Visual Studio 2022,日志文件将被写入到 Serilogging\bin\Debug\net8.0 文件夹。

    • 对于 Visual Studio Code 和 dotnet run,日志文件将被写入到 Serilogging 文件夹。

      更多信息:在以下链接中了解更多详情:serilog.net/

良好实践:在不必要时禁用日志记录。日志记录可能会迅速变得昂贵。例如,一个组织每月在云资源上花费 10,000 美元,远超过预期,而且他们不知道原因。结果发现,他们在生产中记录了每个执行的 SQL 语句。通过“切换”停止该日志记录,他们每月节省了 8,500 美元!您可以在以下链接中阅读 Milan Jovanović的故事:www.linkedin.com/posts/milan-jovanovic_i-helped-a-team-save-100k-in-azure-cloud-activity-7109887614664474625-YDiU/.

对象之间的映射

作为程序员最无聊的部分之一就是对象之间的映射。通常需要集成具有概念上相似对象但结构不同的系统或组件。

应用程序的不同部分可能有不同的数据模型。表示存储中数据的模型通常被称为实体模型。表示必须在层之间传递的数据的模型通常称为数据传输对象DTOs)。仅表示必须向用户展示的数据的模型通常称为视图模型。所有这些模型都可能具有共性但结构不同。

AutoMapper是一个流行的映射对象包,因为它具有使工作尽可能简单的约定。例如,如果您有一个名为CompanyName的源成员,它将被映射到具有相同名称CompanyName的目标成员。

AutoMapper 的创造者 Jimmy Bogard 写了一篇关于其设计哲学的文章,值得一读,可在以下链接中找到:jimmybogard.com/automappers-design-philosophy/.

让我们看看 AutoMapper 的实际应用示例。您将创建四个项目:

  • 实体和视图模型的类库。

  • 一个类库,用于创建可在单元测试和实际项目中重用的映射配置。

  • 一个单元测试项目来测试映射。

  • 一个用于执行实时映射的控制台应用程序。

我们将构建一个示例对象模型,它代表一个电子商务网站客户及其包含几个项目的购物车,然后将其映射到摘要视图模型以向用户展示。

定义 AutoMapper 配置的模型

为了测试映射,我们将定义一些record类型。作为提醒,record(或record class)是一个基于值相等性的引用类型。class是一个基于内存地址相等性的引用类型(除了string,它覆盖了这种行为)。

在使用之前始终验证映射配置是一个好习惯,因此我们将首先定义一些模型和它们之间的映射,然后为映射创建一个单元测试:

  1. 使用您首选的代码编辑器将名为MappingObjects.Models的新类库/ classlib项目添加到Chapter06解决方案中。

  2. MappingObjects.Models项目中,删除名为Class1.cs的文件。

  3. MappingObjects.Models项目中,添加一个名为Customer.cs的新类文件,并修改其内容以使用构造函数语法定义一个不可变的记录类型Customer,如下面的代码所示:

    namespace Northwind.EntityModels;
    // This record will only have a constructor with the parameters below.
    // Objects will be immutable after instantiation using this constructor.
    // It will not have a default parameterless constructor.
    public record class Customer(
      string FirstName,
      string LastName
    ); 
    
  4. MappingObjects.Models项目中,添加一个名为LineItem.cs的新类文件,并修改其内容,如下面的代码所示:

    namespace Northwind.EntityModels;
    public record class LineItem(
      string ProductName,
      decimal UnitPrice,
      int Quantity
    ); 
    
  5. MappingObjects.Models项目中,添加一个名为Cart.cs的新类文件,并修改其内容,如下面的代码所示:

    namespace Northwind.EntityModels;
    public record class Cart(
      Customer Customer,
      List<LineItem> Items
    ); 
    
  6. MappingObjects.Models项目中,在Northwind.ViewModels命名空间(不是Northwind.EntityModels)中添加一个名为Summary.cs的新类文件,删除任何现有语句,然后定义一个记录类型,该类型可以使用默认的无参数构造函数实例化,并在之后设置其属性,如下面的代码所示:

    namespace Northwind.ViewModels;
    public record class Summary
    {
      // These properties can be initialized once but then never changed.
      public string? FullName { get; init; }
      public decimal Total { get; init; }
      // This record class will have a default parameterless constructor.
      // The following commented statement is automatically generated:
      // public Summary() { }
    } 
    

对于实体模型,我们使用了使用构造函数语法定义的record class类型来使它们不可变。但Summary的一个实例需要使用默认的无参数构造函数创建,然后通过 AutoMapper 设置其成员。因此,它必须是一个record class,具有可以在初始化时设置但在之后不能设置的公共属性。为此,我们使用init关键字。

定义 AutoMapper 配置的映射器

现在我们可以定义模型之间的映射:

  1. 使用您首选的代码编辑器将名为MappingObjects.Mappers的新类库/ classlib项目添加到Chapter06解决方案中。

  2. MappingObjects.Mappers项目中,将警告视为错误,添加对最新AutoMapper包的引用,并添加对Models项目的引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="AutoMapper" Version="12.0.1" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..\MappingObjects.Models\MappingObjects.Models.csproj" />
    </ItemGroup> 
    
  3. 构建MappingObjects.Mappers项目以恢复包并编译引用的项目。

  4. MappingObjects.Mappers项目中,删除名为Class1.cs的文件。

  5. MappingObjects.Mappers项目中,添加一个名为CartToSummaryMapper.cs的新类文件,并修改其内容以创建一个映射配置,将SummaryFullName映射到CustomerFirstNameLastName的组合,如下面的代码所示:

    using AutoMapper; // To use MapperConfiguration.
    using AutoMapper.Internal; // To use the Internal() extension method.
    using Northwind.EntityModels; // To use Cart.
    using Northwind.ViewModels; // To use Summary.
    namespace MappingObjects.Mappers;
    public static class CartToSummaryMapper
    {
      public static MapperConfiguration GetMapperConfiguration()
      {
        MapperConfiguration config = new(cfg =>
        {
          // To fix an issue with the MaxInteger method:
          // https://github.com/AutoMapper/AutoMapper/issues/3988
          cfg.Internal().MethodMappingEnabled = false;
          // Configure the mapper using projections.
          cfg.CreateMap<Cart, Summary>()
            // Map the first and last names formatted to the full name.
           .ForMember(dest => dest.FullName, opt => opt.MapFrom(src =>
              string.Format("{0} {1}", 
                src.Customer.FirstName,
                src.Customer.LastName)
            ));
        });
        return config;
      }
    } 
    

对 AutoMapper 配置进行测试

现在我们可以为映射器定义单元测试:

  1. 使用您首选的代码编辑器将名为MappingObjects.Tests的新xUnit 测试项目/ xunit添加到Chapter06解决方案中。

  2. MappingObjects.Tests项目文件中,添加对AutoMapper的包引用,如下面的标记所示:

    <ItemGroup>
     **<PackageReference Include=****"AutoMapper"** **Version=****"12.0.1"** **/>**
      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> 
    
  3. MappingObjects.Tests项目文件中,添加对MappingObjects.ModelsMappingObjects.Mappers的项目引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include=
        "..\MappingObjects.Mappers\MappingObjects.Mappers.csproj" />
      <ProjectReference Include=
        "..\MappingObjects.Models\MappingObjects.Models.csproj" />
    </ItemGroup> 
    
  4. 构建 MappingObjects.Tests 项目以恢复包并构建引用的项目。

  5. MappingObjects.Tests项目中,将UnitTest1.cs重命名为TestAutoMapperConfig.cs

  6. 修改TestAutoMapperConfig.cs的内容以获取映射器,然后断言映射已完成,如下所示代码:

    using AutoMapper; // To use MapperConfiguration.
    using MappingObjects.Mappers; // To use CartToSummaryMapper.
    namespace MappingObjects.Tests;
    public class TestAutoMapperConfig
    {  
      [Fact]
      public void TestSummaryMapping()
      {
        MapperConfiguration config = CartToSummaryMapper.GetMapperConfiguration();
        config.AssertConfigurationIsValid();
      }
    } 
    
  7. 运行测试:

    • 在 Visual Studio 2022 中,导航到测试 | 运行所有测试

    • 在 Visual Studio Code 的终端中,输入dotnet test

  8. 注意测试失败是因为Summary视图模型的Total成员未映射,如图 6.3 所示:

图 6.3:测试失败是因为 Total 成员未映射

  1. MappingObjects.Mappers项目中,在映射配置中,在FullName成员的映射之后,添加对Total成员的映射,如下所示代码中突出显示:

    cfg.CreateMap<Cart, Summary>()
      // Map the first and last names formatted to the full name.
      .ForMember(dest => dest.FullName, opt => opt.MapFrom(src =>
        string.Format("{0} {1}", 
          src.Customer.FirstName, src.Customer.LastName)
      )) **// We have removed a semi-colon from here.**
    **// Map the sum of items to the Total member.**
     **.ForMember(dest => dest.Total, opt => opt.MapFrom(**
     **src => src.Items.Sum(item => item.UnitPrice * item.Quantity)));**
    }); 
    
  2. 运行测试并注意,这次它通过了。

在模型之间执行实时映射

现在我们已经验证了映射配置,我们可以在实时控制台应用程序中使用它:

  1. 使用您首选的代码编辑器将一个新的控制台应用程序/ console项目命名为MappingObjects.Console并添加到Chapter06解决方案中。

  2. MappingObjects.Console项目中,将警告视为错误,全局和静态导入System.Console类,为两个类库添加项目引用,并为AutoMapper添加包引用,如下所示标记:

    <ItemGroup>
      <ProjectReference Include=
        "..\MappingObjects.Mappers\MappingObjects.Mappers.csproj" />
      <ProjectReference Include=
        "..\MappingObjects.Models\MappingObjects.Models.csproj" />
    </ItemGroup>
    <ItemGroup>
      <PackageReference Include="AutoMapper" Version="12.0.1" />
    </ItemGroup> 
    
  3. 构建项目MappingObjects.Console

  4. Program.cs中,删除现有的语句,添加一些语句来构建一个表示客户及其购物车中几个项目的示例对象模型,并将其映射到摘要视图模型以向用户展示,如下所示代码:

    using AutoMapper; // To use MapperConfiguration, IMapper.
    using MappingObjects.Mappers; // To use CartToSummaryMapper.
    using Northwind.EntityModels; // To use Customer, Cart, LineItem.
    using Northwind.ViewModels; // To use Summary.
    using System.Text; // To use Encoding.
    // Set the console's output encoding to UTF-8 to support
    // Unicode characters like the Euro currency symbol.
    OutputEncoding = Encoding.UTF8;
    // Create an object model from "entity" model types that
    // might have come from a data store like SQL Server.
    Cart cart = new(
      Customer: new(
        FirstName: "John",
        LastName: "Smith"
      ), 
      Items: new()
      {
        new(ProductName: "Apples", UnitPrice: 0.49M, Quantity: 10),
        new(ProductName: "Bananas", UnitPrice: 0.99M, Quantity: 4)
      }
    );
    WriteLine("*** Original data before mapping.");
    WriteLine($"{cart.Customer}");
    foreach (LineItem item in cart.Items)
    {
      WriteLine($"  {item}");
    }
    // Get the mapper configuration for converting a Cart to a Summary.
    MapperConfiguration config = CartToSummaryMapper.GetMapperConfiguration();
    // Create a mapper using the configuration.
    IMapper mapper = config.CreateMapper();
    // Perform the mapping.
    Summary summary = mapper.Map<Cart, Summary>(cart);
    // Output the result.
    WriteLine();
    WriteLine("*** After mapping.");
    WriteLine($"Summary: {summary.FullName} spent {summary.Total:C}."); 
    
  5. 运行控制台应用程序并注意成功的结果,如下所示代码:

    *** Original data before mapping.
    Customer { FirstName = John, LastName = Smith }
      LineItem { ProductName = Apples, UnitPrice = 0.49, Quantity = 10 }
      LineItem { ProductName = Bananas, UnitPrice = 0.99, Quantity = 4 }
    *** After mapping.
    Summary: John Smith spent £8.86. 
    
  6. 可选地,编写一个单元测试以执行与前面代码类似的检查,断言Summary具有正确的全名和总计。

良好实践:关于何时使用 AutoMapper 的讨论,您可以在以下链接的文章中阅读(文章底部有更多链接):www.anthonysteele.co.uk/AgainstAutoMapper.html

更多信息:了解更多详情,请访问以下链接:automapper.org/

在单元测试中进行流畅的断言

FluentAssertions是一组扩展方法,可以使在单元测试中编写和阅读代码以及失败的测试的错误消息更接近自然语言,如英语。

它与大多数单元测试框架兼容,包括 xUnit。当你为测试框架添加包引用时,FluentAssertions 会自动找到该包并将其用于抛出异常。

在导入 FluentAssertions 命名空间后,对一个变量调用 Should() 扩展方法,然后调用数百种其他扩展方法之一以以人类可读的方式做出断言。您可以使用 And() 扩展方法链式调用多个断言,或者有单独的语句,每个语句都调用 Should()

对字符串做出断言

让我们从对单个 string 值做出断言开始:

  1. 使用您首选的代码编辑器将一个新的 xUnit 测试项目 / xunit 命名为 FluentTests 添加到 Chapter06 解决方案中。

  2. FluentTests 项目中,添加对 FluentAssertions 的包引用,如下所示:

    <ItemGroup>
     **<PackageReference Include=****"FluentAssertions"** **Version=****"6.12.0"** **/>**
      <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> 
    

    FluentAssertions 7.0 应在本书出版时可用。您可以在以下链接中检查:www.nuget.org/packages/FluentAssertions/

  3. 构建项目 FluentTests

  4. UnitTest1.cs 重命名为 FluentExamples.cs

  5. FluentExamples.cs 中,导入命名空间以使 FluentAssertions 扩展方法可用,并编写一个针对 string 值的测试方法,如下所示:

    using FluentAssertions; // To use common fluent assertions extension methods.
    namespace FluentTests;
    public class FluentExamples
    {
      [Fact]
      public void TestString()
      {
        string city = "London";
        string expectedCity = "London";
        city.Should().StartWith("Lo")
          .And.EndWith("on")
          .And.Contain("do")
          .And.HaveLength(6);
         city.Should().NotBeNull()
          .And.Be("London")
          .And.BeSameAs(expectedCity)
          .And.BeOfType<string>();
        city.Length.Should().Be(6);
      }
    } 
    
  6. 运行测试:

    • 在 Visual Studio 2022 中,导航到 测试 | 运行所有测试

    • 在 Visual Studio Code 中,在 终端 中输入 dotnet test

  7. 注意测试通过。

  8. TestString 方法中,对于 city 变量,删除 London 中的最后一个 n

  9. 运行测试并注意它失败,如下所示输出:

    Expected city "Londo" to end with "on". 
    
  10. London 中重新添加 n

  11. 再次运行测试以确认修复。

对集合和数组做出断言

现在让我们继续对集合和数组做出断言:

  1. FluentExamples.cs 中,添加一个测试方法来探索集合断言,如下所示代码:

    [Fact]
    public void TestCollections()
    {
      string[] names = { "Alice", "Bob", "Charlie" };
      names.Should().HaveCountLessThan(4,
        "because the maximum items should be 3 or fewer");
      names.Should().OnlyContain(name => name.Length <= 6);
    } 
    
  2. 运行测试并注意集合测试失败,如下所示输出:

    Expected names to contain only items matching (name.Length <= 6), but {"Charlie"} do(es) not match. 
    
  3. Charlie 改为 Charly

  4. 运行测试并注意它们成功。

对日期和时间做出断言

让我们从对日期和时间值做出断言开始:

  1. FluentExamples.cs 中,导入命名空间以添加更多针对命名月份和其他有用的日期/时间相关功能的扩展方法,如下所示:

    using FluentAssertions.Extensions; // To use February, March extension methods. 
    
  2. 添加一个测试方法来探索日期/时间断言,如下所示:

    [Fact]
    public void TestDateTimes()
    {
      DateTime when = new(
        hour: 9, minute: 30, second: 0,
        day: 25, month: 3, year: 2024);
      when.Should().Be(25.March(2024).At(9, 30));
      when.Should().BeOnOrAfter(23.March(2024));
      when.Should().NotBeSameDateAs(12.February(2024));
      when.Should().HaveYear(2024);
      DateTime due = new(
        hour: 11, minute: 0, second: 0,
        day: 25, month: 3, year: 2024);
      when.Should().BeAtLeast(2.Hours()).Before(due);
    } 
    
  3. 运行测试并注意日期/时间测试失败,如下所示输出:

    Expected when <2024-03-25 09:30:00> to be at least 2h before <2024-03-25 11:00:00>, but it is behind by 1h and 30m. 
    
  4. 对于 due 变量,将小时从 11 改为 13

  5. 运行测试并注意日期/时间测试成功。

更多信息:您可以在以下链接中了解更多关于 FluentAssertions 的详细信息:fluentassertions.com/

验证数据

FluentValidation 允许您以人类可读的方式定义强类型验证规则。

您通过从 AbstractValidator<T> 继承来为类型创建验证器,其中 T 是您想要验证的类型。在构造函数中,您调用 RuleFor 方法来定义一个或多个规则。如果规则应在指定的场景中运行,则调用 When 方法。

理解内置验证器

FluentValidation 随带了许多有用的内置验证器扩展方法,用于定义规则,如下面的部分列表所示,其中一些您将在本节的编码任务中探索:

  • Null, NotNull, Empty, NotEmpty

  • Equal, NotEqual

  • Length, MaxLength, MinLength

  • LessThan, LessThanOrEqualTo, GreaterThan, GreaterThanOrEqualTo

  • InclusiveBetween, ExclusiveBetween

  • ScalePrecision

  • Must(即谓词)

  • Matches(即正则表达式),EmailAddress, CreditCard

  • IsInEnum, IsEnumName

执行自定义验证

创建自定义规则的最简单方法是使用 Predicate 编写自定义验证函数。您还可以调用 Custom 方法以获得最大控制。

自定义验证消息

有一些扩展方法用于在数据未通过规则时自定义验证消息的输出:

  • WithName: 更改消息中使用的属性名称。

  • WithSeverity: 将默认严重性从 Error 更改为 Warning 或其他级别。

  • WithErrorCode: 分配一个错误代码,可以在消息中输出。

  • WithState: 添加一些可以在消息中使用的状态。

  • WithMessage: 自定义默认消息的格式。

定义模型和验证器

让我们看看 FluentValidation 的实际应用示例。您将创建三个项目:

  • 一个用于验证代表客户下单的模型的类库。

  • 用于验证模型的验证器类库。

  • 一个用于执行实时验证的控制台应用程序。

让我们开始:

  1. 使用您首选的代码编辑器,将一个名为 FluentValidation.Models 的新 类库/ classlib 项目添加到 Chapter06 解决方案中。

  2. FluentValidation.Models 项目中,删除名为 Class1.cs 的文件。

  3. FluentValidation.Models 项目中,添加一个名为 CustomerLevel.cs 的新类文件,并修改其内容以定义一个包含三个客户级别 BronzeSilverGoldenum,如下面的代码所示:

    namespace FluentValidation.Models;
    public enum CustomerLevel
    {
      Bronze,
      Silver,
      Gold
    } 
    
  4. FluentValidation.Models 项目中,添加一个名为 Order.cs 的新类文件,并修改其内容,如下面的代码所示:

    namespace FluentValidation.Models;
    public class Order
    {
      public long OrderId { get; set; }
      public string? CustomerName { get; set; }
      public string? CustomerEmail { get; set; } 
      public CustomerLevel CustomerLevel { get; set; }
      public decimal Total { get; set; }
      public DateTime OrderDate { get; set; }
      public DateTime ShipDate { get; set; }
    } 
    
  5. 使用您首选的代码编辑器,将一个名为 FluentValidation.Validators 的新 类库/ classlib 项目添加到 Chapter06 解决方案中。

  6. FluentValidation.Validators 项目中,将 Models 项目添加为项目引用,并将 FluentValidation 包添加为包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="FluentValidation" Version="11.8.1" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..\FluentValidation.Models\FluentValidation.Models.csproj" />
    </ItemGroup> 
    
  7. 构建的 FluentValidation.Validators 项目。

  8. FluentValidation.Validators 项目中,删除名为 Class1.cs 的文件。

  9. FluentValidation.Validators项目中,添加一个名为OrderValidator.cs的新类文件,并修改其内容,如下所示:

    using FluentValidation.Models;
    namespace FluentValidation.Validators;
    public class OrderValidator : AbstractValidator<Order>
    {
      public OrderValidator()
      {
        RuleFor(order => order.OrderId)
          .NotEmpty(); // Not default(long) which is 0.
        RuleFor(order => order.CustomerName)
          .NotNull()
          .WithName("Name"); // Use Name instead of CustomerName in messages.
        RuleFor(order => order.CustomerName)
          .MinimumLength(5)
          .WithSeverity(Severity.Warning);
        RuleFor(order => order.CustomerEmail)
          .NotEmpty()
          .EmailAddress();
        RuleFor(order => order.CustomerLevel)
          .IsInEnum();
        RuleFor(order => order.Total)
          .GreaterThan(0);
        RuleFor(order => order.ShipDate)
          .GreaterThan(order => order.OrderDate);
        When(order => order.CustomerLevel == CustomerLevel.Gold, () =>
        {
          RuleFor(order => order.Total).LessThan(50M);
          RuleFor(order => order.Total).GreaterThanOrEqualTo(20M);
        }).Otherwise(() =>
        {
          RuleFor(order => order.Total).LessThan(20M);
        });
      }
    } 
    

测试验证器

现在我们已经准备好创建一个控制台应用程序来测试模型上的验证器:

  1. 使用您喜欢的代码编辑器,向Chapter06解决方案中添加一个名为FluentValidation.Console的新控制台应用程序/console项目。

  2. FluentValidation.Console项目中,将警告视为错误,全局和静态导入System.Console类,并为FluentValidation.ValidatorsFluentValidation.Models添加项目引用,如下所示:

    <ItemGroup>
      <ProjectReference Include=
        "..\FluentValidation.Models\FluentValidation.Models.csproj" />
      <ProjectReference Include=
        "..\FluentValidation.Validators\FluentValidation.Validators.csproj" />
    </ItemGroup> 
    
  3. 构建项目FluentValidation.Console以构建引用的项目。

  4. Program.cs中删除现有语句,然后添加创建订单并验证的语句,如下所示:

    using FluentValidation.Models; // To use Order.
    using FluentValidation.Results; // To use ValidationResult.
    using FluentValidation.Validators; // To use OrderValidator.
    using System.Globalization; // To use CultureInfo.
    using System.Text; // To use Encoding.
    OutputEncoding = Encoding.UTF8; // Enable Euro symbol.
    // Control the culture used for formatting of dates and currency,
    // and for localizing error messages to local language.
    Thread t = Thread.CurrentThread;
    t.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
    t.CurrentUICulture = t.CurrentCulture;
    WriteLine($"Current culture: {t.CurrentCulture.DisplayName}");
    WriteLine();
    Order order = new()
    {
      // Start with a deliberately invalid order.
    };
    OrderValidator validator = new();
    ValidationResult result = validator.Validate(order);
    // Output the order data.
    WriteLine($"CustomerName:  {order.CustomerName}");
    WriteLine($"CustomerEmail: {order.CustomerEmail}");
    WriteLine($"CustomerLevel: {order.CustomerLevel}");
    WriteLine($"OrderId:       {order.OrderId}");
    WriteLine($"OrderDate:     {order.OrderDate}");
    WriteLine($"ShipDate:      {order.ShipDate}");
    WriteLine($"Total:         {order.Total:C}");
    WriteLine();
    // Output if the order is valid and any rules that were broken.
    WriteLine($"IsValid:  {result.IsValid}");
    foreach (var item in result.Errors)
    {
      WriteLine($"  {item.Severity}: {item.ErrorMessage}");
    } 
    
  5. 运行控制台应用程序,并注意以下输出中显示的失败规则:

    Current culture: English (United States)
    CustomerName:
    CustomerEmail:
    CustomerLevel: Bronze
    OrderId:       0
    OrderDate:     01/01/0001 12:00:00 AM
    ShipDate:      01/01/0001 12:00:00 AM
    Total:         $0.00
    IsValid:  False
      Error: 'Order Id' must not be empty.
      Error: 'Name' must not be empty.
      Error: 'Customer Email' must not be empty.
      Error: 'Total' must be greater than '0'.
      Error: 'Ship Date' must be greater than '01/01/0001 12:00:00 AM'. 
    
  6. 取消注释设置文化的两个语句,以查看您本地语言和区域的输出。例如,如果您在法国(fr-FR),它将如下所示:

    Current culture: français (France)
    CustomerName:
    CustomerEmail:
    CustomerLevel: Bronze
    OrderId:       0
    OrderDate:     01/01/0001 00:00:00
    ShipDate:      01/01/0001 00:00:00
    Total:         0,00 €
    IsValid:  False
      Error: 'Order Id' ne doit pas être vide.
      Error: 'Name' ne doit pas avoir la valeur null.
      Error: 'Customer Email' ne doit pas être vide.
      Error: 'Total' doit être plus grand que '0'.
      Error: 'Ship Date' doit être plus grand que '01/01/0001 00:00:00'. 
    
  7. 设置订单的一些属性值,如下所示(高亮显示的代码):

    Order order = new()
    {
     **OrderId =** **10001****,**
     **CustomerName =** **"Abc"****,**
     **CustomerEmail =** **"abc&example.com"****,**
     **CustomerLevel = (CustomerLevel)****4****,**
     **OrderDate =** **new****(****2022****, month:** **12****, day:** **1****),**
     **ShipDate =** **new****(****2022****, month:** **11****, day:** **5****),**
     **Total =** **49.99****M**
    }; 
    
  8. 将当前文化设置为美国英语,以确保您看到的输出与本书中的输出相同。您可以在以后尝试自己的文化设置。

  9. 运行控制台应用程序,并注意以下输出中显示的失败规则:

    Current culture: English (United States)
    CustomerName:  Abc
    CustomerEmail: abc&example.com
    CustomerLevel: 4
    OrderId:       10001
    OrderDate:     12/1/2022 12:00:00 AM
    ShipDate:      11/5/2022 12:00:00 AM
    Total:         $49.99
    IsValid:  False
      Warning: The length of 'Customer Name' must be at least 5 characters. You entered 3 characters.
      Error: 'Customer Email' is not a valid email address.
      Error: 'Customer Level' has a range of values which does not include '4'.
      Error: 'Ship Date' must be greater than '12/1/2022 12:00:00 AM'.
      Error: 'Total' must be less than '20'. 
    
  10. 修改订单的一些属性值,如下所示(高亮显示的代码):

    Order order = new()
    {
      OrderId = 10001,
      CustomerName = "Abc**def**",
      CustomerEmail = "abc**@**example.com",
      CustomerLevel = **CustomerLevel.Gold**,
      OrderDate = new(2022, month: 12, day: 1),
      ShipDate = new(2022, month: 12, day: 5),
      // CustomerLevel is Gold so Total can be >20.
      Total = 49.99M
    }; 
    
  11. 运行控制台应用程序,并注意订单现在有效,如下所示:

    IsValid:  True 
    

使用 ASP.NET Core 验证数据

对于使用 ASP.NET Core 进行自动数据验证,FluentValidation 支持.NET Core 3.1 及更高版本。

更多信息:在以下链接中了解更多详细信息:cecilphillip.com/fluent-validation-rules-with-asp-net-core/

生成 PDF 文件

在教授 C#和.NET 时,我经常被问到的一个最常见问题是:“有什么开源库可以用来生成 PDF 文件?”

生成 PDF 文件的授权库有很多,但多年来,找到跨平台的开源库一直很困难。

QuestPDF 表示:“如果您作为年收入超过 100 万美元的营利性公司/个人,以直接包依赖项的方式使用 QuestPDF 库在闭源软件中,您必须根据软件开发人员的数量购买 QuestPDF 专业版或企业版许可证。请参阅 QuestPDF 许可和定价网页以获取更多详细信息。(www.questpdf.com/pricing.html

较旧的 2022.12.X 版本将始终在 MIT 许可下可用,免费用于商业用途。如果您想支持库开发,请考虑购买 2023.1.X 或更高版本的 Professional 许可证。

在 Apple 硅 Mac 上使用 QuestPDF

QuestPDF 使用 SkiaSharp,它为 Windows、Mac 和 Linux 操作系统提供了实现。因此,您在本节中创建的用于生成 PDF 的控制台应用程序是跨平台的。但在苹果硅 Mac(如我的 Mac mini M1)上,我必须安装 x64 版本的 .NET SDK,并使用 dotnet new -a x64 启动项目。这告诉 .NET SDK 使用 x64 架构,否则 SkiaSharp 库会报错,因为它们尚未构建以针对 ARM64。

创建用于生成 PDF 文档的类库

让我们看看 QuestPDF 的一个实际例子。您将创建三个项目:

  • 用于表示具有名称和图像的产品类别目录的模型的类库。

  • 用于文档模板的类库。

  • 用于执行实时生成 PDF 文件的控制台应用程序。

让我们开始:

  1. 使用您喜欢的代码编辑器,将一个名为 GeneratingPdf.Models 的新 类库 / classlib 项目添加到 Chapter06 解决方案中。

  2. GeneratingPdf.Models 项目中,删除名为 Class1.cs 的文件。

  3. GeneratingPdf.Models 项目中,添加一个名为 Category.cs 的新类文件,并修改其内容以定义一个包含类别名称和标识符的两个属性的类,如下面的代码所示:

    namespace GeneratingPdf.Models;
    public class Category
    {
      public int CategoryId { get; set; }
      public string CategoryName { get; set; } = null!;
    } 
    

    之后,您将创建一个 images 文件夹,其文件名使用 categoryN.jpeg 的模式,其中 N 是从 1 到 8 的数字,与 CategoryId 值匹配。

  4. GeneratingPdf.Models 项目中,添加一个名为 Catalog.cs 的新类文件,并修改其内容以定义一个包含存储八个类别的属性的类,如下面的代码所示:

    namespace GeneratingPdf.Models;
    public class Catalog
    {
      public List<Category> Categories { get; set; } = null!;
    } 
    
  5. 使用您喜欢的代码编辑器,将一个名为 GeneratingPdf.Document 的新 类库 / classlib 项目添加到 Chapter06 解决方案中。

  6. GeneratingPdf.Document 项目中,添加对 QuestPDF 的包引用和对 Models 类库的项目引用,如下面的标记所示:

    <ItemGroup>
      <!-- The newest version with an MIT license. -->
      <PackageReference Include="QuestPDF" Version="2022.12.6" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..\GeneratingPdf.Models\GeneratingPdf.Models.csproj" />
    </ItemGroup> 
    
  7. 构建 GeneratingPdf.Document 项目。

  8. GeneratingPdf.Document 项目中,删除名为 Class1.cs 的文件。

  9. GeneratingPdf.Document 项目中,添加一个名为 CatalogDocument.cs 的新类文件。

  10. CatalogDocument.cs 中,定义一个实现 IDocument 接口的类,以定义一个包含页眉和页脚的模板,然后输出八个类别,包括名称和图像,如下面的代码所示:

    using GeneratingPdf.Models; // Catalog
    using QuestPDF.Drawing; // DocumentMetadata
    using QuestPDF.Fluent; // Page
    using QuestPDF.Helpers; // Colors
    using QuestPDF.Infrastructure; // IDocument, IDocumentContainer
    namespace GeneratingPdf.Document;
    public class CatalogDocument : IDocument
    {
      public Catalog Model { get; }
      public CatalogDocument(Catalog model)
      {
        Model = model;
      }
      public void Compose(IDocumentContainer container)
      {
        container
          .Page(page =>
          {
            page.Margin(50 /* points */);
            page.Header()
              .Height(100).Background(Colors.Grey.Lighten1)
              .AlignCenter().Text("Catalogue")
              .Style(TextStyle.Default.FontSize(20));
            page.Content()
              .Background(Colors.Grey.Lighten3)
              .Table(table =>
              {
                table.ColumnsDefinition(columns =>
                {
                  columns.ConstantColumn(100);
                  columns.RelativeColumn();
                });
                foreach (var item in Model.Categories)
                {
                  table.Cell().Text(item.CategoryName);
                  string imagePath = Path.Combine(
                    Environment.CurrentDirectory, "images", 
                    $"category{item.CategoryId}.jpeg");
    
                  table.Cell().Image(imagePath);
                }
              });
            page.Footer()
              .Height(50).Background(Colors.Grey.Lighten1)
              .AlignCenter().Text(x =>
              {
                x.CurrentPageNumber();
                x.Span(" of ");
                x.TotalPages();
              });
          });
      }
      public DocumentMetadata GetMetadata() => DocumentMetadata.Default;
    } 
    

创建用于生成 PDF 文档的控制台应用程序

现在我们可以创建一个控制台应用程序项目,它将使用类库生成 PDF 文档:

  1. 使用您喜欢的代码编辑器,将一个名为 GeneratingPdf.Console 的新 控制台应用程序 / console 项目添加到 Chapter06 解决方案中。

  2. GeneratingPdf.Console 项目中,创建一个 images 文件夹,并从以下链接下载 1 到 8 的八个类别图像到该文件夹:github.com/markjprice/apps-services-net8/tree/master/images/Categories

  3. 如果你使用的是 Visual Studio 2022 或 JetBrains Rider,那么 images 文件夹及其文件必须复制到 GeneratingPdf.Console\bin\Debug\net8 文件夹:

    1. 解决方案资源管理器 中,选择所有图像。

    2. 属性 中,将 复制到输出目录 设置为 始终复制

    3. 打开项目文件,注意将八张图片复制到正确文件夹的 <ItemGroup> 条目,如下所示的部分标记:

    <ItemGroup>
      <None Update="images\category1.jpeg">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
    ... 
    
  4. GeneratingPdf.Console 项目中,将警告视为错误,全局和静态导入 System.Console 类,并为 Document 模板类库添加项目引用,如下所示的部分标记:

    <ItemGroup>
      <ProjectReference Include=
        "..\GeneratingPdf.Document\GeneratingPdf.Document.csproj" />
    </ItemGroup> 
    
  5. 构建 GeneratingPdf.Console 项目。

  6. Program.cs 中,删除现有的语句,然后添加语句来创建目录模型,将其传递给目录文档,生成 PDF 文件,然后尝试使用适当的操作系统命令打开文件,如下面的代码所示:

    using GeneratingPdf.Document; // To use CatalogDocument.
    using GeneratingPdf.Models; // To use Catalog, Category.
    using QuestPDF.Fluent; // To use the GeneratePdf extension method.
    using QuestPDF.Infrastructure; // To use LicenseType.
    // For evaluation purposes, feel free to use the QuestPDF Community 
    // License in a non-production environment.
    QuestPDF.Settings.License = LicenseType.Community;
    string filename = "catalog.pdf";
    Catalog model = new()
    {
      Categories = new()
      {
        new() { CategoryId = 1, CategoryName = "Beverages"},
        new() { CategoryId = 2, CategoryName = "Condiments"},
        new() { CategoryId = 3, CategoryName = "Confections"},
        new() { CategoryId = 4, CategoryName = "Dairy Products"},
        new() { CategoryId = 5, CategoryName = "Grains/Cereals"},
        new() { CategoryId = 6, CategoryName = "Meat/Poultry"},
        new() { CategoryId = 7, CategoryName = "Produce"},
        new() { CategoryId = 8, CategoryName = "Seafood"}
      }
    };
    CatalogDocument document = new(model);
    document.GeneratePdf(filename);
    WriteLine("PDF catalog has been created: {0}",
      Path.Combine(Environment.CurrentDirectory, filename));
    try
    {
      if (OperatingSystem.IsWindows())
      {
        System.Diagnostics.Process.Start("explorer.exe", filename);
      }
      else
      {
        WriteLine("Open the file manually.");
      }
    }
    catch (Exception ex)
    {
      WriteLine($"{ex.GetType()} says {ex.Message}");
    } 
    

    Process 类及其 Start 方法也应该能够在 Mac 和 Linux 上启动进程,但正确获取路径可能很棘手,所以我将其留作读者的可选练习。你可以在以下链接中了解更多关于 Process 类及其 Start 方法的知识:learn.microsoft.com/en-us/dotnet/api/system.diagnostics.process.start.

  7. 运行控制台应用程序,并注意生成的 PDF 文件,如图 6.4 所示:

图 6.4:从 C# 代码生成的 PDF 文件

更多信息: 在以下链接中了解更多详情:www.questpdf.com/.

练习和探索

通过回答一些问题、进行一些实际操作练习,并对本章中的主题进行深入研究来测试你的知识和理解。

练习 6.1 – 测试你的知识

使用网络回答以下问题:

  1. 所有时间中最受欢迎的第三方 NuGet 包是什么?

  2. 你在 ImageSharp Image 类上调用哪个方法来执行像调整大小或用灰度替换颜色这样的更改?

  3. 使用 Serilog 进行日志记录的主要好处是什么?

  4. Serilog 沉淀器是什么?

  5. 你是否应该始终使用像 AutoMapper 这样的包来在对象之间进行映射?

  6. 你应该调用哪个 FluentAssertions 方法来开始对值进行流畅断言?

  7. 你应该调用哪个 FluentAssertions 方法来断言序列中的所有项目都符合某个条件,例如一个 string 项目必须少于六个字符?

  8. 你应该从哪个 FluentValidation 类继承来定义自定义验证器?

  9. 使用 FluentValidation,你如何设置仅在特定条件下应用的规则?

  10. 使用 QuestPDF,你必须实现哪个接口来定义 PDF 文档,以及该接口必须实现哪些方法?

练习 6.2 – 探索主题

使用下一页上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-6---implementing-popular-third-party-libraries

摘要

在本章中,您探索了一些受.NET 开发者欢迎的第三方库,用于执行以下功能:

  • 使用微软推荐的第三方库 ImageSharp 来操作图像。

  • 使用 Humanizer 使文本、数字、日期和时间更友好。

  • 使用 Serilog 记录结构化数据。

  • 对象之间的映射,例如,实体模型到视图模型。

  • 在单元测试中进行流畅的断言。

  • 以本地文化语言可读的方式验证数据。

  • 生成 PDF 文件。

在下一章中,我们将学习如何处理日期和时间的国际化以及本地化,包括.NET 8 中的一个新类型,它使得对依赖于当前时间的组件进行单元测试更容易。

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/apps_and_services_dotnet8

二维码

第七章:处理日期、时间和国际化

本章介绍了.NET 中包含的一些常见类型。这些包括用于操作日期和时间以及实现国际化的类型,包括全球化和本地化。

当编写处理时间的代码时,特别重要的是要考虑时区。错误通常是由于没有考虑到这一点,在不同时区比较两个时间而引入的。理解协调世界时UTC)的概念并将时间值转换为 UTC 在进行时间操作之前非常重要。

您还应该注意可能需要的任何夏令时DST)调整。

本章涵盖了以下主题:

  • 处理日期和时间

  • 处理时区

  • 处理文化

  • 处理 Noda Time

处理日期和时间

在数字和文本之后,接下来最常用的数据类型是日期和时间。主要有以下两种类型:

  • DateTime:表示固定时间点的日期和时间组合值。

  • TimeSpan:表示时间的持续时间。

这两种类型通常一起使用。例如,如果您从一个DateTime值减去另一个,结果是TimeSpan。如果您将TimeSpan添加到DateTime,则结果是DateTime值。

指定日期和时间值

创建日期和时间值的一种常见方式是指定日期和时间组件的单独值,如天和小时,如表 7.1中所述:

日期/时间参数 值范围
year 1 到 9,999
month 1 到 12
day 该月的天数到 1
hour 0 到 23
minute 0 到 59
second 0 到 59
millisecond 0 到 999
microsecond 0 到 999

表 7.1:格式化日期和时间值的参数

例如,为了实例化一个表示.NET 9 可能发布为通用可用性时的DateTime,如下面的代码所示:

DateTime dotnet9GA = new(year: 2024, month: 11, day: 12,
  hour: 11, minute: 0, second: 0); 

良好实践:前面的代码示例可能会让您想,“这个值代表的是哪个时区?”这是DateTime的大问题,也是为什么避免使用它而选择包含时区的DateTimeOffset是一个好习惯。我们将在本章后面更详细地探讨这个问题。

另一种方法是提供要解析的string值,但这可能取决于线程的默认文化而误解。例如,在英国,日期指定为 day/month/year,而在美国,日期指定为 month/day/year。

让我们看看您可能想要如何处理日期和时间:

  1. 使用您首选的代码编辑器创建一个新项目,如下列所示:

    • 项目模板:控制台应用程序 / console

    • 项目文件和文件夹:WorkingWithTime

    • 解决方案文件和文件夹:Chapter07

    • 不要使用顶级语句:已清除。

    • 启用原生 AOT 发布:已清除。

  2. 在项目文件中,将警告视为错误,并添加一个元素以静态和全局导入 System.Console 类。

  3. 添加一个名为 Program.Helpers.cs 的新类文件,并替换其内容,如下面的代码所示:

    using System.Globalization; // To use CultureInfo.
    partial class Program
    {
      private static void ConfigureConsole(string culture = "en-US",
        bool overrideComputerCulture = true)
      {
        // To enable special characters like Euro currency symbol.
        OutputEncoding = System.Text.Encoding.UTF8;
        Thread t = Thread.CurrentThread;
        if (overrideComputerCulture)
        {
          t.CurrentCulture = CultureInfo.GetCultureInfo(culture);
          t.CurrentUICulture = t.CurrentCulture;
        }
        CultureInfo ci = t.CurrentCulture;
        WriteLine($"Current culture: {ci.DisplayName}");
        WriteLine($"Short date pattern: {
          ci.DateTimeFormat.ShortDatePattern}");
        WriteLine($"Long date pattern: {
          ci.DateTimeFormat.LongDatePattern}");
        WriteLine();
      }
      private static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title}");
        ForegroundColor = previousColor;
      }
    } 
    
  4. Program.cs 中,删除现有的语句,然后添加语句以初始化一些特殊的日期/时间值,如下面的代码所示:

    ConfigureConsole(); // Defaults to en-US culture.
    SectionTitle("Specifying date and time values");
    WriteLine($"DateTime.MinValue:  {DateTime.MinValue}");
    WriteLine($"DateTime.MaxValue:  {DateTime.MaxValue}");
    WriteLine($"DateTime.UnixEpoch: {DateTime.UnixEpoch}");
    WriteLine($"DateTime.Now:       {DateTime.Now}");
    WriteLine($"DateTime.Today:     {DateTime.Today}");
    WriteLine($"DateTime.Today:     {DateTime.Today:d}");
    WriteLine($"DateTime.Today:     {DateTime.Today:D}"); 
    
  5. 运行代码,并注意结果,如下面的输出所示:

    Current culture: English (United States)
    Short date pattern: M/d/yyyy
    Long date pattern: dddd, MMMM d, yyyy
    *** Specifying date and time values
    DateTime.MinValue:  1/1/0001 12:00:00 AM
    DateTime.MaxValue:  12/31/9999 11:59:59 PM
    DateTime.UnixEpoch: 1/1/1970 12:00:00 AM
    DateTime.Now:       5/30/2023 9:18:05 AM
    DateTime.Today:     5/30/2023 12:00:00 AM
    DateTime.Today:     5/30/2023
    DateTime.Today:     Tuesday, May 30, 2023 
    

    输出的日期和时间格式由控制台应用程序的文化设置决定。我们调用了 ConfigureConsole 方法以确保我们都能看到相同的默认输出(美国英语)。

  6. Program.cs 中,在调用 ConfigureConsole 的语句顶部设置参数,以便不覆盖您的本地计算机文化,如下面的代码所示:

    ConfigureConsole(overrideComputerCulture: false); 
    
  7. 运行代码,并注意输出已本地化为您的计算机文化。

  8. Program.cs 中,设置参数以指定替代语言,如加拿大法语 (fr-CA) 或英国英语 (en-GB),如下面的代码所示:

    ConfigureConsole("fr-CA"); 
    

    更多信息:以下链接提供了一个常见文化代码表:en.wikipedia.org/wiki/Language_localisation#Language_tags_and_codes

  9. 运行代码,并注意输出已本地化为指定的文化。

  10. 将控制台配置重置为默认设置,以便使用美国英语文化,如下面的代码所示:

    ConfigureConsole(); // Defaults to en-US culture. 
    

格式化日期和时间值

您刚刚看到日期和时间有基于当前文化的默认格式。

您可以使用自定义格式代码完全控制日期和时间格式,如下表 7.2 所示:

格式代码 描述
/ 日期部分分隔符。根据文化不同而变化;例如,en-US 使用 /, 但 fr-FR 使用 - (破折号)。
\ 转义字符。如果您想将特殊格式代码作为字面字符使用,则很有用;例如,h \h m \m 将格式化为上午 9:30 的时间为 9 h 30 m
: 时间部分分隔符。根据文化不同而变化;例如,en-US 使用 :, 但 fr-FR 使用 . (点)。
d, dd 月份中的日期,从 131,或带前导零从 0131
ddd, dddd 周几的缩写或全称,例如,MonMonday,根据当前文化本地化。
f, ff, fff 十分之一秒、百分之一秒或毫秒。
g 时期或纪元,例如,A.D.
h, hh 小时,使用 12 小时制从 112,或从 0112
H, HH 小时,使用 24 小时制从 023,或从 0123
K 时区信息。对于未指定的时区为 null,对于 UTC 为 Z,对于从 UTC 调整的本地时间为类似 -8:00 的值。
m, mm 分钟,从 059,或带前导零从 0059
M, MM 月份,从 112,或带前导零从 0112
MMM, MMMM 月份的缩写或全称,例如,JanJanuary,针对当前文化本地化。
s, ss 秒,从 059,或带前导零从 0059
t, tt AM/PM 标识符的第一个或前两个字符。
y, yy 当前世纪的年份,从 099,或带前导零从 0099
yyy 至少三位数的年份,最多所需位数。例如,公元 1 年是 001。罗马城第一次被攻陷是在 410 年。这本书出版的那一年是 2023 年。
yyyy, yyyyy 四位或五位数的年份。
z, zz 从 UTC 偏移的小时,不带前导零,或带前导零。
zzz 从 UTC 偏移的小时和分钟,带前导零,例如,+04:30

表 7.2:日期和时间值的自定义格式代码

更多信息:有关自定义格式代码的完整列表,请参阅以下链接:learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings

您可以使用简单的格式代码应用标准日期和时间格式,就像我们在代码示例中使用的 dD 一样,如 表 7.3 所示:

格式代码 描述
d 短日期模式。因文化而异;例如,en-US 使用 M/d/yyyy,而 fr-FR 使用 dd/MM/yyyy
D 长日期模式。因文化而异;例如,en-US 使用 mmmm, MMMM d, yyyy,而 fr-FR 使用 mmmm, dd MMMM yyyy
f 完整日期/时间模式(短时间 - 小时和分钟)。因文化而异。
F 完整日期/时间模式(长时间 – 小时、分钟、秒和 AM/PM)。因文化而异。
o, O 标准化模式,适用于序列化日期/时间值进行往返,例如,2023-05-30T13:45:30.0000000-08:00
r, R RFC1123 模式。
t 短时间模式。因文化而异;例如,en-US 使用 h:mm tt,而 fr-FR 使用 HH:mm
T 长时间模式。因文化而异;例如,en-US 使用 h:mm:ss tt,而 fr-FR 使用 HH:mm:ss
u 通用可排序日期/时间模式,例如,2009-06-15 13:45:30Z
U 通用完整日期/时间模式。因文化而异;例如,en-US 可能是 Monday, June 15, 2009 8:45:30 PM

表 7.3:日期和时间值的标准格式代码

更多信息:有关格式代码的完整列表,请参阅以下链接:learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings.

让我们运行一些示例:

  1. Program.cs中添加语句来定义 2024 年的圣诞节并以各种方式显示,如下所示代码:

    DateTime xmas = new(year: 2024, month: 12, day: 25);
    WriteLine($"Christmas (default format): {xmas}");
    WriteLine($"Christmas (custom short format): {xmas:ddd d/M/yy}");
    WriteLine($"Christmas (custom long format): {
      xmas:dddd, dd MMMM yyyy}");
    WriteLine($"Christmas (standard long format): {xmas:D}");
    WriteLine($"Christmas (sortable): {xmas:u}");
    WriteLine($"Christmas is in month {xmas.Month} of the year.");
    WriteLine($"Christmas is day {xmas.DayOfYear} of {xmas.Year}.");
    WriteLine($"Christmas {xmas.Year} is on a {xmas.DayOfWeek}."); 
    
  2. 运行代码,并注意结果,如下所示输出:

    Christmas (default format): 12/25/2024 12:00:00 AM
    Christmas (custom short format): Wed, 25/12/24
    Christmas (custom long format): Wednesday, 25 December 2024
    Christmas (standard long format): Wednesday, December 25, 2024
    Christmas (sortable): 2024-12-25 00:00:00Z
    Christmas is in month 12 of the year.
    Christmas is day 360 of 2024.
    Christmas 2024 is on a Wednesday. 
    
  3. 禁用覆盖您的计算机文化或传递特定的文化代码,例如法国的法国文化,如下所示代码:

    ConfigureConsole("fr-FR"); // Defaults to en-US culture. 
    
  4. 运行代码,并注意结果应该本地化为该文化。

  5. 将控制台配置重置为默认的 US English。

日期和时间计算

现在,让我们尝试对日期和时间值进行简单计算:

  1. Program.cs中添加语句来对 2024 年的圣诞节进行加法和减法运算,如下所示代码:

    SectionTitle("Date and time calculations");
    DateTime beforeXmas = xmas.Subtract(TimeSpan.FromDays(12));
    DateTime afterXmas = xmas.AddDays(12);
    WriteLine($"12 days before Christmas: {beforeXmas:d}");
    WriteLine($"12 days after Christmas: {afterXmas:d}");
    TimeSpan untilXmas = xmas - DateTime.Now;
    WriteLine($"Now: {DateTime.Now}");
    WriteLine($"There are {untilXmas.Days} days and {untilXmas.Hours
      } hours until Christmas {xmas.Year.");
    WriteLine("There are {untilXmas.TotalHours:N0} hours " +
      $"until Christmas {xmas.Year}."); 
    
  2. 运行代码,并注意结果,如下所示输出:

    *** Date and time calculations
    12 days before Christmas: 12/13/2024
    12 days after Christmas: 1/6/2025
    Now: 5/30/2023 1:57:01 PM
    There are 574 days and 10 hours until Christmas 2024.
    There are 13,786 hours until Christmas 2024. 
    
  3. 添加语句来定义孩子们(或狗、猫、鬣蜥?)可能会醒来打开礼物的圣诞节时间,并以各种方式显示,如下所示代码:

    DateTime kidsWakeUp = new(
      year: 2024, month: 12, day: 25, 
      hour: 6, minute: 30, second: 0);
    WriteLine($"Kids wake up: {kidsWakeUp}");
    WriteLine($"The kids woke me up at {
      kidsWakeUp.ToShortTimeString()}"); 
    
  4. 运行代码,并注意结果,如下所示输出:

    Kids wake up: 25/12/2024 06:30:00 AM
    The kids woke me up at 06:30 AM 
    

微秒和纳秒

在.NET 的早期版本中,时间测量的最小单位是刻度。一个刻度是 100 纳秒,因此开发者以前必须自己进行纳秒的计算。.NET 7 为构造函数引入了毫秒和微秒参数,并将微秒和纳秒属性添加到DateTimeDateTimeOffsetTimeSpanTimeOnly类型中。

让我们看看一些示例:

  1. Program.cs中添加语句来构造一个比以前更精确的日期和时间值,并显示其值,如下所示代码:

    SectionTitle("Milli-, micro-, and nanoseconds");
    DateTime preciseTime = new(
      year: 2022, month: 11, day: 8,
      hour: 12, minute: 0, second: 0,
      millisecond: 6, microsecond: 999);
    WriteLine($"Millisecond: {preciseTime.Millisecond}, Microsecond: {
      preciseTime.Microsecond}, Nanosecond: {preciseTime.Nanosecond}");
    preciseTime = DateTime.UtcNow;
    // Nanosecond value will be 0 to 900 in 100 nanosecond increments.
    WriteLine($"Millisecond: {preciseTime.Millisecond}, Microsecond: {
      preciseTime.Microsecond}, Nanosecond: {preciseTime.Nanosecond}"); 
    
  2. 运行代码,并注意结果,如下所示输出:

    *** Milli-, micro-, and nanoseconds
    Millisecond: 6, Microsecond: 999, Nanosecond: 0
    Millisecond: 243, Microsecond: 958, Nanosecond: 400 
    

日期和时间的全球化

当前文化控制日期和时间的格式化和解析方式:

  1. Program.cs顶部,导入用于全球化操作的命名空间,如下所示代码:

    using System.Globalization; // To use CultureInfo. 
    
  2. 添加语句来显示用于显示日期和时间值的当前文化,然后解析美国的独立日并以各种方式显示,如下所示代码:

    SectionTitle("Globalization with dates and times");
    // Same as Thread.CurrentThread.CurrentCulture.
    WriteLine($"Current culture: {CultureInfo.CurrentCulture.Name}");
    string textDate = "4 July 2024";
    DateTime independenceDay = DateTime.Parse(textDate);
    WriteLine($"Text: {textDate}, DateTime: {independenceDay:d MMMM}");
    textDate = "7/4/2024";
    independenceDay = DateTime.Parse(textDate);
    WriteLine($"Text: {textDate}, DateTime: {independenceDay:d MMMM}");
    // Explicitly override the current culture by setting a provider.
    independenceDay = DateTime.Parse(textDate,
      provider: CultureInfo.GetCultureInfo("en-US"));
    WriteLine($"Text: {textDate}, DateTime: {independenceDay:d MMMM}"); 
    

    良好实践:虽然您可以使用构造函数创建CultureInfo实例,除非您需要对其进行更改,否则您应该通过调用GetCultureInfo方法来获取只读共享实例。

  3. Program.cs顶部,将文化设置为英国英语,如下所示代码:

    ConfigureConsole("en-GB"); 
    
  4. 运行代码,并注意结果,如下所示输出:

    *** Globalization with dates and times
    Current culture is: en-GB
    Text: 4 July 2024, DateTime: 4 July
    Text: 7/4/2024, DateTime: 7 April
    Text: 7/4/2024, DateTime: 4 July 
    

    当当前文化设置为英语(英国)时,如果给定日期为 2024 年 7 月 4 日,则无论当前文化是英国还是美国,都能正确解析。但如果日期给定为7/4/2024,则解析为 4 月 7 日。在解析时,可以通过指定正确的文化作为提供者来覆盖当前文化,如上面第三个示例所示。

  5. 添加语句从 2023 年循环到 2028 年,显示该年是否是闰年以及二月有多少天,然后显示圣诞节和独立日是否在夏令时期间,如下所示代码:

    for (int year = 2023; year <= 2028; year++)
    {
      Write($"{year} is a leap year: {DateTime.IsLeapYear(year)}. ");
      WriteLine($"There are {DateTime.DaysInMonth(year: year, month: 2)
        } days in February {year}.");
    }
    WriteLine($"Is Christmas daylight saving time? {
      xmas.IsDaylightSavingTime()}");
    WriteLine($"Is July 4th daylight saving time? {
      independenceDay.IsDaylightSavingTime()}"); 
    
  6. 运行代码,并注意结果,如下所示输出:

    2023 is a leap year: False. There are 28 days in February 2023.
    2024 is a leap year: True. There are 29 days in February 2024.
    2025 is a leap year: False. There are 28 days in February 2025.
    2026 is a leap year: False. There are 28 days in February 2026.
    2027 is a leap year: False. There are 28 days in February 2027.
    2028 is a leap year: True. There are 29 days in February 2028.
    Is Christmas daylight saving time? False
    Is July 4th daylight saving time? True 
    

夏令时(DST)的复杂性

夏令时不是所有国家都使用;它也由半球决定,政治也起着作用。例如,美国目前正在辩论是否应该使夏令时永久化。他们可能会决定将决定权留给各州。在接下来的几年里,这可能会让美国人感到更加困惑。

每个国家都有自己的规则来决定夏令时(DST)在什么日子和什么时间开始。这些规则被.NET 编码,以便在需要时自动调整。

在美国的春季,时钟在凌晨 2 点“跳”前一小时。在秋季,它们在凌晨 2 点“退”后一小时。维基百科在以下链接中解释了这一点:en.wikipedia.org/wiki/Daylight_saving_time_in_the_United_States

在英国的春季,时钟在凌晨 1 点“跳”前一小时。在秋季,它们在凌晨 2 点“退”后一小时。英国政府在以下链接中解释了这一点:www.gov.uk/when-do-the-clocks-change

假设你需要设置一个闹钟在凌晨 1:30 AM 醒来,以便从英国的希思罗机场赶飞机。不幸的是,你的航班恰好是在夏令时生效的那天出发。

在英国的春季,时钟显示为凌晨 12:59,然后下一分钟它们会跳到凌晨 2:00。1:30 AM 永远不会发生,你的闹钟不会响,你可能会错过航班!在.NET 中,1:30 AM 是一个无效的时间,如果你尝试将这个值存储在变量中,它将抛出一个异常。

在英国的秋季,时钟显示为 1:59,然后下一分钟,它们会退回到 1:00 并重复那个小时。在这种情况下,1:30 AM 会发生两次。

本地化 DayOfWeek 枚举

DayOfWeek是一个enum,所以它不能像你预期或希望的那样本地化。它的string值是硬编码在英语中的,如下所示代码:

namespace System
{
  public enum DayOfWeek
  {
    Sunday = 0,
    Monday = 1,
    Tuesday = 2,
    Wednesday = 3,
    Thursday = 4,
    Friday = 5,
    Saturday = 6
  }
} 

解决这个问题的有两个方案。首先,你可以将dddd日期格式代码应用到整个日期值上。例如,

WriteLine($"The day of the week is {DateTime.Now:dddd}."); 

其次,你可以使用DateTimeFormatInfo类的辅助方法将DayOfWeek值转换为本地化的string,以便作为文本输出。

让我们看看一个问题和解决方案的例子:

  1. Program.cs中添加语句,显式地将当前文化设置为丹麦语,然后输出该文化中的当前星期几,如下所示代码:

    SectionTitle("Localizing the DayOfWeek enum");
    CultureInfo previousCulture = Thread.CurrentThread.CurrentCulture;
    // Explicitly set culture to Danish (Denmark).
    Thread.CurrentThread.CurrentCulture = 
      CultureInfo.GetCultureInfo("da-DK");
    // DayOfWeek is not localized to Danish.
    WriteLine("Culture: {Thread.CurrentThread.CurrentCulture
      .NativeName}, DayOfWeek: {DateTime.Now.DayOfWeek}";
    // Use dddd format code to get day of the week localized.
    WriteLine($"Culture: {Thread.CurrentThread.CurrentCulture
      .NativeName}, DayOfWeek: {DateTime.Now:dddd}");
    // Use GetDayName method to get day of the week localized.
    WriteLine("Culture: {Thread.CurrentThread.CurrentCulture
      .NativeName}, DayOfWeek: {DateTimeFormatInfo.CurrentInfo
      .GetDayName(DateTime.Now.DayOfWeek)}");
    Thread.CurrentThread.CurrentCulture = previousCulture; 
    
  2. 运行代码,并注意结果,如下所示输出:

    *** Localizing the DayOfWeek enum
    Culture: dansk (Danmark), DayOfWeek: Thursday
    Culture: dansk (Danmark), DayOfWeek: torsdag
    Culture: dansk (Danmark), DayOfWeek: torsdag 
    

仅处理日期或时间

.NET 6 引入了一些新类型,用于仅处理日期值或仅处理时间值,分别命名为DateOnlyTimeOnly

这些比使用具有零时间的 DateTime 值来存储仅日期的值要好,因为它类型安全且避免了误用。DateOnly 也更好地映射到数据库列类型,例如 SQL Server 中的 date 列。TimeOnly 适用于设置闹钟和安排定期会议或组织的营业时间,它映射到 SQL Server 中的 time 列。

让我们使用它们来计划 .NET 9 的发布派对,可能是在 2024 年 11 月 12 日星期二,美国总统选举后一周:

  1. Program.cs 中,添加语句以定义 .NET 9 发布派对及其开始时间,然后将这两个值组合成一个日历条目,以免错过,如下面的代码所示:

    SectionTitle("Working with only a date or a time");
    DateOnly party = new(year: 2024, month: 11, day: 12);
    WriteLine($"The .NET 9 release party is on {party.ToLongDateString()}.");
    TimeOnly starts = new(hour: 11, minute: 30);
    WriteLine($"The party starts at {starts}.");
    DateTime calendarEntry = party.ToDateTime(starts);
    WriteLine($"Add to your calendar: {calendarEntry}."); 
    
  2. 运行代码并注意结果,如下面的输出所示:

    *** Working with only a date or a time
    The .NET 9 release party is on Tuesday, November 12, 2024.
    The party starts at 11:30 AM.
    Add to your calendar: 11/12/2024 11:30:00 AM. 
    

获取日期/时间格式化信息

每个文化都有自己的日期/时间格式化规则。这些规则定义在 CultureInfo 实例的 DateTimeFormat 属性中。

让我们输出一些常用信息:

  1. Program.cs 中,添加语句以获取当前文化的日期/时间格式化信息并输出其中一些最有用的属性,如下面的代码所示:

    SectionTitle("Working with date/time formats");
    DateTimeFormatInfo dtfi = DateTimeFormatInfo.CurrentInfo;
    // Or use Thread.CurrentThread.CurrentCulture.DateTimeFormat.
    WriteLine($"Date separator: {dtfi.DateSeparator}");
    WriteLine($"Time separator: {dtfi.TimeSeparator}");
    WriteLine($"Long date pattern: {dtfi.LongDatePattern}");
    WriteLine($"Short date pattern: {dtfi.ShortDatePattern}");
    WriteLine($"Long time pattern: {dtfi.LongTimePattern}");
    WriteLine($"Short time pattern: {dtfi.ShortTimePattern}");
    Write("Day names:");
    for (int i = 0; i < dtfi.DayNames.Length - 1; i++)
    {
      Write($"  {dtfi.GetDayName((DayOfWeek)i)}");
    }
    WriteLine();
    Write("Month names:");
    for (int i = 1; i < dtfi.MonthNames.Length; i++)
    {
      Write($"  {dtfi.GetMonthName(i)}");
    }
    WriteLine(); 
    
  2. 运行代码,并注意结果,如下面的输出所示:

    *** Working with date/time formats
    Date separator: /
    Time separator: :
    Long date pattern: dddd, MMMM d, yyyy
    Short date pattern: M/d/yyyy
    Long time pattern: h:mm:ss tt
    Short time pattern: h:mm tt
    Day names:  Sunday  Monday  Tuesday  Wednesday  Thursday  Friday
    Month names:  January  February  March  April  May  June  July  August  September  October  November  December 
    
  3. 将文化更改为其他,运行代码并注意结果。

使用时间提供程序进行单元测试

为需要当前时间的组件编写单元测试很棘手,因为时间是不断变化的!

假设你希望电子商务网站的访客在周末下单时获得 20% 的折扣。在工作日,他们需要支付全价。我们如何测试这个功能?

为了控制单元测试中使用的时间,.NET 8 引入了 TimeProvider 类。

让我们定义一个执行此计算的功能:

  1. Chapter07 解决方案中,添加一个名为 TimeFunctionsLib 的新 类库/classlib 项目。

  2. TimeFunctionsLib 项目中,将 Class1.cs 重命名为 DiscountService.cs

  3. DiscountService.cs 中,定义一个执行计算的功能,如下面的代码所示:

    namespace Northwind.Services;
    public class DiscountService
    {
      public decimal GetDiscount()
      {
        // This has a dependency on the current time provided by the system.
        var now = DateTime.UtcNow;
        return now.DayOfWeek switch
        {
          DayOfWeek.Saturday or DayOfWeek.Sunday => 0.2M,
          _ => 0M
        };
      }
    } 
    
  4. Chapter07 解决方案中,添加一个名为 TestingWithTimeProvider 的新 xUnit 测试项目/xunit 项目。

  5. TestingWithTimeProvider 项目中,添加对 TimeFunctionsLib 项目的引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include=
        "..\TimeFunctionsLib\TimeFunctionsLib.csproj" />
    </ItemGroup> 
    
  6. 构建名为 TestingWithTimeProvider 的项目。

  7. TestingWithTimeProvider 项目中,将 Test1.cs 重命名为 TimeTests.cs

  8. TimeTests.cs 文件中,修改语句以导入折扣服务的命名空间,然后定义两个测试,一个用于工作日,一个用于周末,如下面的代码所示:

    using Northwind.Services; // To use DiscountService.
    namespace TestingWithTimeProvider;
    public class TimeTests
    {
      [Fact]
      public void TestDiscountDuringWorkdays()
      {
        // Arrange
        DiscountService service = new();
        // Act
        decimal discount = service.GetDiscount();
        // Assert
        Assert.Equal(0M, discount);
      }
      [Fact]
      public void TestDiscountDuringWeekends()
      {
        DiscountService service = new();
        decimal discount = service.GetDiscount();
        Assert.Equal(0.2M, discount);
      }
    } 
    
  9. 运行两个测试,并注意在任何时候只能成功运行一个测试。如果你在工作日运行测试,周末测试将失败。如果你在周末运行测试,工作日测试将失败!

既然你已经看到了问题,我们该如何解决它?

微软解决这个问题的方法是,每个创建.NET 库的团队定义自己的内部ISystemClock接口,至少包含一个名为UtcNow的属性,有时还有其他成员,以及通常使用内置系统时钟但略有不同的实现。以下是一个典型的示例代码:

using System;
namespace Microsoft.Extensions.Internal
{
  public interface ISystemClock
  {
    DateTimeOffset UtcNow { get; }
  }
  public class SystemClock
  {
    public DateTimeOffset UtcNow 
    {
      return DateTimeOffset.UtcNow;
    }
  }
} 

最后,随着.NET 8 的推出,.NET 核心团队引入了前面代码的适当等效实现,该实现使用系统时钟。不幸的是,他们没有定义一个接口。相反,他们定义了一个名为TimeProvider的抽象类。

让我们使用它:

  1. TimeFunctionsLib项目中,在DiscountService.cs文件中,注释掉使用UtcNow属性的部分,并添加一个语句来添加一个构造函数注入的服务,如下面的代码所示:

    namespace Northwind.Services;
    public class DiscountService
    {
    **private** **TimeProvider _timeProvider;**
    **public****DiscountService****(****TimeProvider timeProvider****)**
     **{**
     **_timeProvider = timeProvider;**
     **}**
      public decimal GetDiscount()
      {
        // This has a dependency on the current time provided by the system.
        **//** var now = DateTime.UtcNow;
    **var** **now = _timeProvider.GetUtcNow();**
        // This has a dependency on the current time provided by the system.
        return now.DayOfWeek switch
        {
          DayOfWeek.Saturday or DayOfWeek.Sunday => 0.2M,
          _ => 0M
        };
      }
    } 
    
  2. TestingWithTimeProvider项目中,在TimeTests.cs文件中,为两个测试添加语句以展示如何使用新的TimeProvider及其System属性(这仍然依赖于系统时钟!),如下面的代码所示:

    // This would use the .NET 8 or later dependency service,
    // but its implementation is still the system clock.
    DiscountService service = new(TimeProvider.System); 
    
  3. TestingWithTimeProvider项目中,添加对Moq的引用,这是一个用于模拟依赖项的包,如下面的标记所示:

    <!-- The newest version before the controversy. -->
    <PackageReference Include="Moq" Version="4.18.4" /> 
    

    Moq 4.18.4 是在开发者添加在构建期间执行的混淆代码后引发争议之前的最后一个版本。你可以在以下链接中了解更多信息:github.com/devlooped/moq/issues/1370。我计划在未来几个月内密切关注这种情况,然后决定是否应该切换到替代方案。

  4. TimeTests.cs文件中,导入命名空间以使用Mock.Of<T>扩展方法,如下面的代码所示:

    using Moq; // To use Mock.Of<T> method. 
    
  5. TestDiscountDuringWorkdays方法中,注释掉使用System提供者的语句,并用以下代码中的语句替换,以模拟一个在工作日总是返回固定日期和时间的时钟提供者:

    TimeProvider timeProvider = Mock.Of<TimeProvider>();
    // Mock the time provider so it always returns the date of
    // 2023-11-07 09:30:00 UTC which is a Tuesday.
    Mock.Get(timeProvider).Setup(s => s.GetUtcNow()).Returns(
      new DateTimeOffset(year: 2023, month: 11, day: 7, 
      hour: 9, minute: 30, second: 0, offset: TimeSpan.Zero));
    DiscountService service = new(timeProvider); 
    
  6. TestDiscountDuringWeekends方法中,注释掉使用System提供者的语句,并用以下代码中的语句替换,以模拟一个在周末总是返回固定日期和时间的时钟提供者:

    TimeProvider timeProvider = Mock.Of<TimeProvider>();
    // Mock the time provider so it always returns the date of
    // 2023-11-04 09:30:00 UTC which is a Saturday.
    Mock.Get(timeProvider).Setup(s => s.GetUtcNow()).Returns(
      new DateTimeOffset(year: 2023, month: 11, day: 4, 
      hour: 9, minute: 30, second: 0, offset: TimeSpan.Zero));
    DiscountService service = new(timeProvider); 
    
  7. 运行单元测试,并注意它们都成功了。

与时区一起工作

在关于.NET 发布派对的代码示例中,使用TimeOnly实际上并不是一个好主意,因为TimeOnly值没有包含时区信息。只有在你处于正确的时区时才有用。因此,TimeOnly对于事件来说是一个较差的选择。对于事件,我们需要理解和处理时区。

理解DateTimeTimeZoneInfo

DateTime类有许多与时区相关的重要成员,如下表 7.4 所示:

成员 描述
Now属性 表示本地时区的当前日期和时间的DateTime值。
UtcNow 属性 表示 UTC 时区的当前日期和时间的 DateTime 值。
Kind 属性 表示 DateTime 值是 UnspecifiedUtc 还是 LocalDateTimeKind 值。
IsDaylightSavingTime 方法 一个 bool,指示 DateTime 值是否在夏令时(DST)。
ToLocalTime 方法 将 UTC DateTime 值转换为等效的本地时间。
ToUniversalTime 方法 将本地 DateTime 值转换为等效的 UTC 时间。

表 7.4:与时区相关的 DateTime 成员

TimeZoneInfo 类具有许多有用的成员,如 表 7.5 所示:

成员 描述
Id 属性 一个 string,唯一标识时区。
Local 属性 表示当前本地时区的 TimeZoneInfo 值。取决于代码执行的地点。
Utc 属性 表示 UTC 时区的 TimeZoneInfo 值。
StandardName 属性 当夏令时不激活时,表示时区名称的 string
DaylightName 属性 当夏令时激活时,表示时区名称的 string
DisplayName 属性 表示时区的通用名称的 string
BaseUtcOffset 属性 表示此时区与 UTC 时区之间的差异的 TimeSpan,忽略任何潜在的夏令时调整。
SupportsDaylightSavingTime 属性 一个 bool 值,指示此时区是否有夏令时调整。
ConvertTime 方法 DateTime 值转换为另一个时区的 DateTime 值。您可以指定源时区和目标时区。
ConvertTimeFromUtc 方法 将 UTC 时区的 DateTime 值转换为指定时区的 DateTime 值。
ConvertTimeToUtc 方法 将指定时区的 DateTime 值转换为 UTC 时区的 DateTime 值。
IsDaylightSavingTime 方法 返回一个 bool,指示 DateTime 值是否在夏令时。
GetSystemTimeZones 方法 返回操作系统注册的时区集合。

表 7.5:TimeZoneInfo 有用成员

一些 EF Core 数据库提供者仅允许您存储使用 Kind 属性确定是否为 UTC 的 DateTime 值,因此如果您需要处理这些值,可能需要将它们转换为 DateTimeOffset

探索 DateTime 和 TimeZoneInfo

使用 TimeZoneInfo 类处理时区:

  1. 使用您首选的代码编辑器将名为 WorkingWithTimeZones 的新 控制台应用程序/ console 项目添加到 Chapter07 解决方案中:

    1. 在 Visual Studio 2022 中,将 启动项目 设置为 当前选择

    2. 将警告视为错误,并静态和全局导入 System.Console 类。

  2. 添加一个名为 Program.Helpers.cs 的新类文件。

  3. 修改其内容以定义一些辅助方法,以视觉上不同的方式输出一个部分标题,输出当前系统中的所有时区列表,以及输出关于DateTimeTimeZoneInfo对象的详细信息,如下面的代码所示:

    using System.Collections.ObjectModel; // To use ReadOnlyCollection<T>
    partial class Program
    {
      private static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title}");
        ForegroundColor = previousColor;
      }
      private static void OutputTimeZones()
      {
        // get the time zones registered with the OS
        ReadOnlyCollection<TimeZoneInfo> zones = 
          TimeZoneInfo.GetSystemTimeZones();
        WriteLine($"*** {zones.Count} time zones:");
        // order the time zones by Id instead of DisplayName
        foreach (TimeZoneInfo zone in zones.OrderBy(z => z.Id))
        {
          WriteLine($"{zone.Id}");
        }
      }
      private static void OutputDateTime(DateTime dateTime, string title)
      {
        SectionTitle(title);
        WriteLine($"Value: {dateTime}");
        WriteLine($"Kind: {dateTime.Kind}");
        WriteLine($"IsDaylightSavingTime: {dateTime.IsDaylightSavingTime()}");
        WriteLine($"ToLocalTime(): {dateTime.ToLocalTime()}");
        WriteLine($"ToUniversalTime(): {dateTime.ToUniversalTime()}");
      }
      private static void OutputTimeZone(TimeZoneInfo zone, string title)
      {
        SectionTitle(title);
        WriteLine($"Id: {zone.Id}");
        WriteLine($"IsDaylightSavingTime(DateTime.Now): {
          zone.IsDaylightSavingTime(DateTime.Now)}");
        WriteLine($"StandardName: {zone.StandardName}");
        WriteLine($"DaylightName: {zone.DaylightName}");
        WriteLine($"BaseUtcOffset: {zone.BaseUtcOffset}");
      }
      private static string GetCurrentZoneName(TimeZoneInfo zone, DateTime when)
      {
        // time zone names change if Daylight Saving time is active
        // e.g. GMT Standard Time becomes GMT Summer Time
        return zone.IsDaylightSavingTime(when) ?
          zone.DaylightName : zone.StandardName;
      }
    } 
    
  4. Program.cs中,删除现有的语句。添加语句以输出本地和 UTC 时区的当前日期和时间,然后输出本地和 UTC 时区的详细信息,如下面的代码所示:

    OutputTimeZones();
    OutputDateTime(DateTime.Now, "DateTime.Now");
    OutputDateTime(DateTime.UtcNow, "DateTime.UtcNow");
    OutputTimeZone(TimeZoneInfo.Local, "TimeZoneInfo.Local");
    OutputTimeZone(TimeZoneInfo.Utc, "TimeZoneInfo.Utc"); 
    
  5. 运行控制台应用程序并注意结果,包括在您的操作系统上注册的时区(在我的 Windows 11 笔记本电脑上有 141 个),以及目前是 2022 年 5 月 31 日下午 4:17 在英格兰,这意味着我处于 GMT 标准时区。然而,由于夏令时正在活跃,它目前被称为 GMT 夏令时,比 UTC 快一个小时,如下面的输出所示:

    *** 141 time zones:
    Afghanistan Standard Time
    Alaskan Standard Time
    ...
    West Pacific Standard Time
    Yakutsk Standard Time
    Yukon Standard Time
    *** DateTime.Now
    Value: 31/05/2022 16:17:03
    Kind: Local
    IsDaylightSavingTime: True
    ToLocalTime(): 31/05/2022 16:17:03
    ToUniversalTime(): 31/05/2022 15:17:03
    *** DateTime.UtcNow
    Value: 31/05/2022 15:17:03
    Kind: Utc
    IsDaylightSavingTime: False
    ToLocalTime(): 31/05/2022 16:17:03
    ToUniversalTime(): 31/05/2022 15:17:03
    *** TimeZoneInfo.Local
    Id: GMT Standard Time
    IsDaylightSavingTime(DateTime.Now): True
    StandardName: GMT Standard Time
    DaylightName: GMT Summer Time
    BaseUtcOffset: 00:00:00
    *** TimeZoneInfo.Utc
    Id: UTC
    IsDaylightSavingTime(DateTime.Now): False
    StandardName: Coordinated Universal Time
    DaylightName: Coordinated Universal Time
    BaseUtcOffset: 00:00:00 
    

    GMT 标准时间时区的BaseUtcOffset为零,因为通常夏令时不活跃。这就是为什么它前面有Base前缀。

  6. Program.cs中,添加语句提示用户输入一个时区(使用东部标准时间作为默认值),获取该时区,输出其详细信息,然后比较用户输入的时间与另一个时区的等效时间,并捕获潜在的异常,如下面的代码所示:

    Write("Enter a time zone or press Enter for US East Coast: ");
    string zoneId = ReadLine()!;
    if (string.IsNullOrEmpty(zoneId))
    {
      zoneId = "Eastern Standard Time";
    }
    try
    {
      TimeZoneInfo otherZone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
      OutputTimeZone(otherZone,
        $"TimeZoneInfo.FindSystemTimeZoneById(\"{zoneId}\")");
      SectionTitle($"What's the time in {zoneId}?");
      Write("Enter a local time or press Enter for now: ");
      string? timeText = ReadLine();
      DateTime localTime;
      if (string.IsNullOrEmpty(timeText) || 
        !DateTime.TryParse(timeText, out localTime))
      {
        localTime = DateTime.Now;
      }
      DateTime otherZoneTime = TimeZoneInfo.ConvertTime(
        dateTime: localTime, sourceTimeZone: TimeZoneInfo.Local,
        destinationTimeZone: otherZone);
      WriteLine($"{localTime} {GetCurrentZoneName(TimeZoneInfo.Local,
        localTime)} is {otherZoneTime} {GetCurrentZoneName(otherZone,
        otherZoneTime)}.");
    }
    catch (TimeZoneNotFoundException)
    {
      WriteLine($"The {zoneId} zone cannot be found on the local system.");
    }
    catch (InvalidTimeZoneException)
    {
      WriteLine($"The {zoneId} zone contains invalid or missing data.");
    }
    catch (System.Security.SecurityException)
    {
      WriteLine("The application does not have permission to read time zone information.");
    }
    catch (OutOfMemoryException)
    {
      WriteLine($"Not enough memory is available to load information on the {zoneId} zone.");
    } 
    
  7. 运行控制台应用程序,按Enter键选择美国东部时间,然后输入12:30pm作为当地时间,并注意以下输出结果:

    Enter a time zone or press Enter for US East Coast:
    *** TimeZoneInfo.FindSystemTimeZoneById("Eastern Standard Time")
    Id: Eastern Standard Time
    IsDaylightSavingTime(DateTime.Now): True
    StandardName: Eastern Standard Time
    DaylightName: Eastern Summer Time
    BaseUtcOffset: -05:00:00
    *** What's the time in Eastern Standard Time?
    Enter a local time or press Enter for now: 12:30pm
    31/05/2023 12:30:00 GMT Summer Time is 31/05/2023 07:30:00 Eastern Summer Time. 
    

    我的本地时区是 GMT 标准时间,因此目前我与美国东部时间之间有五个小时的时差。您的本地时区可能会有所不同。

  8. 运行控制台应用程序,将一个时区复制到剪贴板,在提示符中粘贴它,然后按Enter键输入当地时间。注意以下输出结果:

    Enter a time zone or press Enter for US East Coast: AUS Eastern Standard Time
    *** TimeZoneInfo.FindSystemTimeZoneById("AUS Eastern Standard Time")
    Id: AUS Eastern Standard Time
    IsDaylightSavingTime(DateTime.Now): False
    StandardName: AUS Eastern Standard Time
    DaylightName: AUS Eastern Summer Time
    BaseUtcOffset: 10:00:00
    *** What's the time in AUS Eastern Standard Time?
    Enter a local time or press Enter for now:
    31/05/2023 17:00:04 GMT Summer Time is 01/06/2023 02:00:04 AUS Eastern Standard Time. 
    

澳大利亚的悉尼目前比我们快九个小时,所以对我来说大约是下午 5 点,对他们来说大约是第二天凌晨 2 点。

这需要学习很多关于日期、时间和时区的内容。但我们还没有完成。现在,我们需要关注更广泛的文化主题,这些文化是语言和区域的组合,并不仅仅影响日期和时间格式。

与文化打交道

国际化是使您的代码能够在全球范围内正确运行的过程。它有两个部分,全球化本地化,它们都与处理文化有关。

全球化涉及编写代码以适应多种语言和区域组合。语言和区域的组合称为文化。对于您的代码来说,了解语言和区域都很重要,因为例如,尽管魁北克和巴黎都使用法语,但它们的日期和货币格式是不同的。

对于所有文化组合,都有国际标准化组织(ISO)代码。例如,在代码 da-DK 中,da 表示丹麦语言,DK 表示丹麦地区,而在代码 fr-CA 中,fr 表示法语,CA 表示加拿大地区。

ISO 不仅仅是一个缩写。ISO 是指希腊单词 isos(意为 平等)。您可以在以下链接中查看 ISO 文化代码列表:lonewolfonline.net/list-net-culture-country-codes/

本地化是关于定制用户界面以支持一种语言,例如,将按钮标签更改为 关闭en)或 Fermerfr)。由于本地化更多地涉及语言,因此它通常不需要了解地区,尽管讽刺的是,单词 standardizationen-US)和 standardisationen-GB)似乎暗示了相反的情况。

良好实践:我不是专业软件用户界面翻译员,因此请将本章中的所有示例视为一般性指导。我对法语用户界面标签常见实践的研究使我找到了以下链接,但如果您不是母语人士,最好还是聘请专业人士:french.stackexchange.com/questions/12969/translation-of-it-terms-like-close-next-search-etcwww.linguee.com/english-french/translation/close+button.html

检测和更改当前文化

国际化是一个巨大的主题,有成千上万页的书籍被撰写。在本节中,您将简要了解基础知识,使用 System.Globalization 命名空间中的 CultureInfoRegionInfo 类型。

让我们编写一些代码:

  1. 使用您首选的代码编辑器,将一个名为 WorkingWithCultures 的新 控制台应用程序/ console 项目添加到 Chapter07 解决方案中。

    • 在项目文件中,将警告视为错误,然后全局导入 System.Console 类,并全局导入 System.Globalization 命名空间,以便我们可以使用 CultureInfo 类,如下面的标记所示:
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
      <Using Include="System.Globalization" />
    </ItemGroup> 
    
  2. 添加一个名为 Program.Helpers.cs 的新类文件,并修改其内容以向部分 Program 类添加一个方法,该方法将输出有关用于全球化和本地化的文化信息,如下面的代码所示:

    partial class Program
    {
      private static void OutputCultures(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title}");
        // Get the cultures from the current thread.
        CultureInfo globalization = CultureInfo.CurrentCulture;
        CultureInfo localization = CultureInfo.CurrentUICulture;
        WriteLine($"The current globalization culture is {
          globalization.Name}: {globalization.DisplayName}");
        WriteLine($"The current localization culture is {
          localization.Name}: {localization.DisplayName}");
        WriteLine($"Days of the week: {string.Join(", ",
          globalization.DateTimeFormat.DayNames)}");
        WriteLine($"Months of the year: {string.Join(", ",
          globalization.DateTimeFormat.MonthNames
          // Some have 13 months; most 12, and the last is empty.
          .TakeWhile(month => !string.IsNullOrEmpty(month)))}");
        WriteLine($"1st day of this year: {new DateTime(
          year: DateTime.Today.Year, month: 1, day: 1)
          .ToString("D", globalization)}");
        WriteLine($"Number group separator: {globalization
          .NumberFormat.NumberGroupSeparator}");
        WriteLine($"Number decimal separator: {globalization
          .NumberFormat.NumberDecimalSeparator}");
        RegionInfo region = new(globalization.LCID);
        WriteLine($"Currency symbol: {region.CurrencySymbol}");
        WriteLine($"Currency name: {region.CurrencyNativeName} ({
          region.CurrencyEnglishName})");
        WriteLine($"IsMetric: {region.IsMetric}");
        WriteLine();
        ForegroundColor = previousColor;
      }
    } 
    
  3. Program.cs 文件中,删除现有的语句,并添加语句以设置控制台输出编码以支持 Unicode。然后,输出有关全球化本地化文化的信息。最后,提示用户输入一个新的文化代码,并展示它如何影响常见值(如日期和货币)的格式化,如下面的代码所示:

    // To enable special characters like €.
    OutputEncoding = System.Text.Encoding.UTF8;
    OutputCultures("Current culture");
    WriteLine("Example ISO culture codes:");
    string[] cultureCodes = { 
      "da-DK", "en-GB", "en-US", "fa-IR", 
      "fr-CA", "fr-FR", "he-IL", "pl-PL", "sl-SI" };
    foreach (string code in cultureCodes)
    {
      CultureInfo culture = CultureInfo.GetCultureInfo(code);
      WriteLine($"  {culture.Name}: {culture.EnglishName} / {
        culture.NativeName}");
    }
    
    WriteLine();
    Write("Enter an ISO culture code: ");
    string? cultureCode = ReadLine();
    if (string.IsNullOrWhiteSpace(cultureCode))
    {
      cultureCode = "en-US";
    }  
    CultureInfo ci;
    try
    {
      ci = CultureInfo.GetCultureInfo(cultureCode);
    }
    catch (CultureNotFoundException)
    {
      WriteLine($"Culture code not found: {cultureCode}");
      WriteLine("Exiting the app.");
      return;
    }
    // change the current cultures on the thread
    CultureInfo.CurrentCulture = ci;
    CultureInfo.CurrentUICulture = ci;
    OutputCultures("After changing the current culture");
    Write("Enter your name: ");
    string? name = ReadLine();
    if (string.IsNullOrWhiteSpace(name))
    {
      name = "Bob";
    }
    Write("Enter your date of birth: ");
    string? dobText = ReadLine();
    if (string.IsNullOrWhiteSpace(dobText))
    {
      // If they do not enter a DOB then use
      // sensible defaults for their culture.
      dobText = ci.Name switch
        {
          "en-US" or "fr-CA" => "1/27/1990",
          "da-DK" or "fr-FR" or "pl-PL" => "27/1/1990",
          "fa-IR" => "1990/1/27",
          _ => "1/27/1990"
        };
    }
    Write("Enter your salary: ");
    string? salaryText = ReadLine();
    if (string.IsNullOrWhiteSpace(salaryText))
    {
      salaryText = "34500";
    }
    DateTime dob = DateTime.Parse(dobText);
    int minutes = (int)DateTime.Today.Subtract(dob).TotalMinutes;
    decimal salary = decimal.Parse(salaryText);
    WriteLine($"{name} was born on a {dob:dddd}. {name} is {
      minutes:N0} minutes old. {name} earns {salary:C}."); 
    

    当你运行一个应用程序时,它会自动将线程设置为使用操作系统的文化。我在英国伦敦运行我的代码,所以线程被设置为英语(大不列颠)。

    代码提示用户输入一个替代的 ISO 代码。这允许你的应用程序在运行时替换默认的文化。

    应用程序随后使用标准的格式代码,使用格式代码dddd输出星期几,使用格式代码N0输出带千位分隔符的分钟数,以及使用货币符号的薪水。这些会根据线程的文化自动调整。

  4. 运行代码,输入 ISO 代码en-US(或按Enter键),然后输入一些示例数据,包括一个适用于美国英语的日期格式,如下面的输出所示:

    *** Current culture
    The current globalization culture is en-GB: English (United Kingdom)
    The current localization culture is en-GB: English (United Kingdom)
    Days of the week: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
    Months of the year: January, February, March, April, May, June, July, August, September, October, November, December
    1st day of this year: 01 January 2023
    Number group separator: ,
    Number decimal separator: .
    Currency symbol: £
    Currency name: British Pound (British Pound)
    IsMetric: True
    Example ISO culture codes:
      da-DK: Danish (Denmark) / dansk (Danmark)
      en-GB: English (United Kingdom) / English (United Kingdom)
      en-US: English (United States) / English (United States)
      fa-IR: Persian (Iran) / فارسی (ایران)
      fr-CA: French (Canada) / français (Canada)
      fr-FR: French (France) / français (France)
      he-IL: Hebrew (Israel) / עברית (ישראל)
      pl-PL: Polish (Poland) / polski (Polska)
      sl-SI: Slovenian (Slovenia) / slovenščina (Slovenija)
    Enter an ISO culture code: en-US
    *** After changing the current culture
    The current globalization culture is en-US: English (United States)
    The current localization culture is en-US: English (United States)
    Days of the week: Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday
    Months of the year: January, February, March, April, May, June, July, August, September, October, November, December
    1st day of this year: Sunday, January 1, 2023
    Number group separator: ,
    Number decimal separator: .
    Currency symbol: $
    Currency name: US Dollar (US Dollar)
    IsMetric: False
    Enter your name: Alice
    Enter your date of birth: 3/30/1967
    Enter your salary: 34500
    Alice was born on a Thursday. Alice is 29,541,600 minutes old. Alice earns $34,500.00 
    
  5. 再次运行代码,并尝试在丹麦使用丹麦语(da-DK),如下面的输出所示:

    Enter an ISO culture code: da-DK
    *** After changing the current culture
    The current globalization culture is da-DK: dansk (Danmark)
    The current localization culture is da-DK: dansk (Danmark)
    Days of the week: søndag, mandag, tirsdag, onsdag, torsdag, fredag, lørdag
    Months of the year: januar, februar, marts, april, maj, juni, juli, august, september, oktober, november, december
    1st day of this year: søndag den 1\. januar 2023
    Number group separator: .
    Number decimal separator: ,
    Currency symbol: kr.
    Currency name: dansk krone (Danish Krone)
    IsMetric: True
    Enter your name: Mikkel
    Enter your date of birth: 16/3/1980
    Enter your salary: 65000
    Mikkel was born on a søndag. Mikkel is 22.723.200 minutes old. Mikkel earns 65.000,00 kr.. 
    

    在此示例中,只有日期和薪水被全球化为丹麦语。其余的文本硬编码为英语。稍后,我们将翻译这些英语文本为其他语言。现在,让我们看看文化之间的其他差异。

  6. 再次运行代码,并尝试在波兰使用波兰语(pl-PL)。请注意,波兰的语法规则使得月份名称的日期是所有格,所以月份styczeń变为stycznia,如下面的输出所示:

    The current globalization culture is pl-PL: polski (Polska)
    ...
    Months of the year: styczeń, luty, marzec, kwiecień, maj, czerwiec, lipiec, sierpień, wrzesień, październik, listopad, grudzień
    1st day of this year: niedziela, 1 stycznia 2023
    ...
    Enter your name: Bob
    Enter your date of birth: 1972/4/16
    Enter your salary: 50000
    Bob was born on a niedziela. Bob is 26 886 240 minutes old. Bob earns 50 000,00 zł. 
    
  7. 再次运行代码,并尝试在伊朗使用波斯语(fa-IR)。请注意,伊朗的日期必须指定为年/月/日,并且今年(2023 年)在波斯历中是 1401 年,如下面的输出所示:

    The current globalization culture is fa-IR: فارسی (ایران)
    The current localization culture is fa-IR: فارسی (ایران)
    Days of the week: یکشنبه, دوشنبه, سهشنبه, چهارشنبه, پنجشنبه, جمعه, شنبه
    Months of the year: فروردین, اردیبهشت, خرداد, تیر, مرداد, شهریور, مهر, آبان, آذر, دی, بهمن, اسفند
    1st day of this year: 1401 دی 11, شنبه
    Number group separator: ٬
    Number decimal separator: ٫
    Currency symbol: ریال
    Currency name: ریال ایران (Iranian Rial)
    IsMetric: True
    Enter your name: Cyrus
    Enter your date of birth: 1372/4/16
    Enter your salary: 50000
    Cyrus was born on a چهارشنبه. Cyrus is 15٬723٬360 minutes old. Cyrus earns ریال50٬000. 
    

尽管我试图与一位波斯语读者确认此示例是否正确,但由于在控制台应用程序中处理从右到左的语言很棘手,以及从控制台窗口复制粘贴到文字处理器的因素,如果此示例全部混乱,我提前向我的波斯语读者道歉!

暂时使用不变的文化

有时,你可能需要暂时使用不同的文化,而无需切换当前线程到该文化。例如,当自动生成包含数据值的文档、查询和命令时,你可能需要忽略当前的文化并使用更标准化的文化。为此,你可以使用基于美国英语的不变文化。

例如,你可能需要生成一个带有小数数值的 JSON 文档,并使用两位小数格式化数字,如下面的代码所示:

decimal price = 54321.99M;
string document = $$"""
  {
    "price": "{{price:N2}}"
  }
  """; 

如果你在斯洛文尼亚的计算机上执行此操作,你会得到以下输出:

{
  "price": " 54.321,99"
} 

如果你尝试将此 JSON 文档插入云数据库,它将失败,因为它不会理解使用逗号表示小数点,点表示组的数字格式。

因此,你可以在输出数字作为string值时覆盖当前文化,并指定不变的文化,如下面的代码所示:

decimal price = 54321.99M;
string document = $$"""
  {
    "price": "{{price.ToString("N2", CultureInfo.InvariantCulture)}}"
  }
  """; 

如果你在斯洛文尼亚(或任何其他文化)的计算机上执行此操作,你现在将得到以下输出,这将成功被云数据库识别而不会抛出异常:

{
  "price": " 54,321.99"
} 

现在,让我们看看如何将文本从一种语言翻译成另一种语言,以便标签提示在当前文化中正确显示。

本地化用户界面

本地化应用程序分为两部分:

  • 包含对所有区域都相同的代码和在其他资源文件未找到时使用的资源的程序集。

  • 包含不同区域用户界面资源的一个或多个程序集,这些被称为卫星程序集

此模型允许初始应用程序部署时使用默认的不变资源,并且随着时间的推移,可以部署额外的卫星程序集,因为资源被翻译。在编码任务中,你将创建一个具有嵌入式不变文化的控制台应用程序,以及丹麦语、法语、法语-加拿大、波兰语和伊朗语(波斯语)的卫星程序集。要将来添加更多文化,只需遵循相同的步骤。

用户界面资源包括任何消息、日志、对话框、按钮、标签或甚至图像、视频等文件名的文本。资源文件是具有 .resx 扩展名的 XML 文件。文件名包括文化代码,例如,PacktResources.en-GB.resxPacktResources.da-DK.resx

如果资源文件或单个条目缺失,资源自动文化回退搜索路径从特定文化(语言和区域)到中性文化(仅语言)再到不变文化(理论上独立,但实际上是美式英语)。如果当前线程文化是 en-AU(澳大利亚英语),那么它将按以下顺序搜索资源文件:

  1. 澳大利亚英语:PacktResources.en-AU.resx

  2. 中性英语:PacktResources.en.resx

  3. 不变项:PacktResources.resx

定义和加载资源

要从这些卫星程序集中加载资源,我们使用一些标准的 .NET 类型,名为 IStringLocalizer<T>IStringLocalizerFactory。这些实现的加载来自 .NET 通用宿主作为依赖服务:

  1. WorkingWithCultures 项目中,添加对 Microsoft 扩展的包引用以使用通用托管和本地化,如下所示:

    <ItemGroup>
      <PackageReference Include="Microsoft.Extensions.Hosting" 
                        Version="8.0.0" />
      <PackageReference Include="Microsoft.Extensions.Localization" 
                        Version="8.0.0" />
    </ItemGroup> 
    
  2. 构建具有 WorkingWithCultures 项目的项目以恢复包。

  3. 在项目文件夹中,创建一个名为 Resources 的新文件夹。

  4. Resources 文件夹中,添加一个名为 PacktResources.resx 的新 XML 文件,并修改其内容以包含默认的不变语言资源(通常相当于美式英语),如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>Enter your date of birth: </value>
      </data>
      <data name="EnterYourName" xml:space="preserve">
        <value>Enter your name: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>Enter your salary: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} was born on a {1:dddd}. {0} is {2:N0} minutes old. {0} earns {3:C}.</value>
      </data>
    </root> 
    
  5. WorkingWithCultures 项目文件夹中,添加一个名为 PacktResources.cs 的新类文件,该文件将加载用户界面的文本资源,如下所示:

    using Microsoft.Extensions.Localization; // To use IStringLocalizer and so on.
    public class PacktResources
    {
      private readonly IStringLocalizer<PacktResources> localizer = null!;
      public PacktResources(IStringLocalizer<PacktResources> localizer)
      {
        this.localizer = localizer;
      }
      public string? GetEnterYourNamePrompt()
      {
        string resourceStringName = "EnterYourName";
        // 1\. Get the LocalizedString object.
        LocalizedString localizedString = localizer[resourceStringName];
        // 2\. Check if the resource string was found.
        if (localizedString.ResourceNotFound)
        {
          ConsoleColor previousColor = ForegroundColor;
          ForegroundColor = ConsoleColor.Red;
          WriteLine($"Error: resource string \"{resourceStringName}\" not found."
            + Environment.NewLine
            + $"Search path: {localizedString.SearchedLocation}");
          ForegroundColor = previousColor;
          return $"{localizedString}: ";
        }
        // 3\. Return the found resource string.
        return localizedString;
      }
      public string? GetEnterYourDobPrompt()
      {
        // LocalizedString has an implicit cast to string
        // that falls back to the key if the resource 
        // string is not found.
        return localizer["EnterYourDob"];
      }
      public string? GetEnterYourSalaryPrompt()
      {
        return localizer["EnterYourSalary"];
      }
      public string? GetPersonDetails(
        string name, DateTime dob, int minutes, decimal salary)
      {
        return localizer["PersonDetails", name, dob, minutes, salary];
      }
    } 
    

    对于GetEnterYourNamePrompt方法,我将实现分解为步骤以获取有用的信息,例如检查资源字符串是否找到,如果没有找到则显示搜索路径。其他方法实现如果资源未找到,则使用简化回退到资源字符串的键名。

  6. Program.cs文件顶部,导入用于处理托管和依赖注入的命名空间,然后配置一个启用本地化和PacktResources服务的宿主,如下所示:

    using Microsoft.Extensions.Hosting; // To use IHost, Host.
    // To use AddLocalization, AddTransient<T>.
    using Microsoft.Extensions.DependencyInjection;
    using IHost host = Host.CreateDefaultBuilder(args)
      .ConfigureServices(services =>
      {
        services.AddLocalization(options =>
        {
          options.ResourcesPath = "Resources";
        });
        services.AddTransient<PacktResources>();
      })
      .Build(); 
    

    良好实践:默认情况下,ResourcesPath是一个空字符串,这意味着它将在当前目录中查找.resx文件。我们将通过将资源放入子文件夹来使项目更整洁。

  7. 在更改当前文化后,添加一个获取PacktResources服务的语句,并使用它来输出用户输入姓名、出生日期和薪水的本地化提示。然后,输出他们的详细信息,如下所示:

    OutputCultures("After changing the current culture");
    **PacktResources resources =**
     **host.Services.GetRequiredService<PacktResources>();**
    Write(**resources.GetEnterYourNamePrompt()**);
    string? name = ReadLine();
    if (string.IsNullOrWhiteSpace(name))
    {
      name = "Bob";
    }
    Write(**resources.GetEnterYourDobPrompt()**);
    string? dobText = ReadLine();
    if (string.IsNullOrWhiteSpace(dobText))
    {
      // If they do not enter a DOB then use
      // sensible defaults for their culture.
      dobText = ci.Name switch
        {
          "en-US" or "fr-CA" => "1/27/1990",
          "da-DK" or "fr-FR" or "pl-PL" => "27/1/1990",
          "fa-IR" => "1990/1/27",
          _ => "1/27/1990"
        };
    }
    Write(**resources.GetEnterYourSalaryPrompt()**);
    string? salaryText = ReadLine();
    if (string.IsNullOrWhiteSpace(salaryText))
    {
      salaryText = "34500";
    }
    DateTime dob = DateTime.Parse(dobText);
    int minutes = (int)DateTime.Today.Subtract(dob).TotalMinutes;
    decimal salary = decimal.Parse(salaryText);
    WriteLine(**resources.GetPersonDetails(name, dob, minutes, salary)**); 
    

测试全球化与本地化

现在,我们可以运行控制台应用程序并查看资源正在被加载:

  1. 运行控制台应用程序,并输入da-DK作为 ISO 代码。请注意,提示信息使用的是美式英语,因为我们目前只有不变的文化资源。

    为了节省时间并确保你有正确的结构,你可以复制、粘贴并重命名.resx文件,而不是创建空的新文件。或者,你也可以从本书的 GitHub 仓库中复制这些文件。

  2. Resources文件夹中,添加一个名为PacktResources.da.resx的新 XML 文件,并修改其内容以包含非区域特定的丹麦语资源,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>Indtast din fødselsdato: </value>
      </data>
      <data name="EnterYourName" xml:space="preserve">
        <value>Indtast dit navn: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>Indtast din løn: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} blev født på en {1:dddd}. {0} er {2:N0} minutter gammel. {0} tjener {3:C}.</value>
      </data>
    </root> 
    
  3. Resources文件夹中,添加一个名为PacktResources.fr.resx的新 XML 文件,并修改其内容以包含非区域特定的法语资源,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>Entrez votre date de naissance: </value>
      </data>
      <data name="EnterYourName" xml:space="preserve">
        <value>Entrez votre nom: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>Entrez votre salaire: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} est né un {1:dddd}. {0} a {2:N0} minutes. {0} gagne {3:C}.</value>
      </data>
    </root> 
    
  4. Resources文件夹中,添加一个名为PacktResources.fr-CA.resx的新 XML 文件,并修改其内容以包含加拿大地区的法语资源,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>Entrez votre date de naissance / Enter your date of birth: </value>
      </data>
      <data name="EnterYourName" xml:space="preserve">
        <value>Entrez votre nom / Enter your name: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>Entrez votre salaire / Enter your salary: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} est né un {1:dddd}. {0} a {2:N0} minutes. {0} gagne {3:C}.</value>
      </data>
    </root> 
    
  5. Resources文件夹中,添加一个名为PacktResources.pl-PL.resx的新 XML 文件,并修改其内容以包含波兰地区的波兰语资源,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>Wpisz swoją datę urodzenia: </value>
      </data>
      <data name="EnterYourName" xml:space="preserve">
        <value>Wpisz swoje imię i nazwisko: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>Wpisz swoje wynagrodzenie: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} urodził się na {1:dddd}. {0} ma {2:N0} minut. {0} zarabia {3:C}.</value>
      </data>
    </root> 
    
  6. Resources文件夹中,添加一个名为PacktResources.fa-IR.resx的新 XML 文件,并修改其内容以包含伊朗地区的波斯语资源,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>تاریخ تولد خود را وارد کنید / Enter your date of birth: </value>
      </data>
      <data name="EnterYourName" xml:space="preserve">
        <value>اسمت را وارد کن / Enter your name: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>حقوق خود را وارد کنید / Enter your salary: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} در {1:dddd}به دنیا آمد. {0} {2:N0} دقیقه است. {0} {3:C}.</value>
      </data>
    </root> 
    
  7. 运行代码,并输入da-DK作为 ISO 代码。请注意,提示信息使用的是丹麦语,如下所示:

    The current localization culture is da-DK: dansk (Danmark)
    ...
    Indtast dit navn: Bob
    Indtast din fødselsdato: 3/4/1987
    Indtast din løn: 45449
    Bob blev født på en fredag. Bob er 19.016.640 minutter gammel. Bob tjener 45.449,00 kr. 
    
  8. 运行代码,并输入fr-FR作为 ISO 代码。请注意,提示信息只使用法语,如下所示:

    The current localization culture is fr-FR: français (France)
    ...
    Entrez votre nom: Monique
    Entrez votre date de naissance: 2/12/1990
    Entrez votre salaire: 45000
    Monique est né un dimanche. Monique a 17 088 480 minutes. Monique gagne 45 000,00 €. 
    
  9. 运行代码,并输入fr-CA作为 ISO 代码。请注意,提示信息使用的是法语和英语,因为加拿大可能需要支持这两种官方语言,如下所示:

    The current localization culture is fr-CA: français (Canada)
    ...
    Entrez votre nom / Enter your name: Sophie
    Entrez votre date de naissance / Enter your date of birth: 4/5/2001
    Entrez votre salaire / Enter your salary: 65000
    Sophie est né un jeudi. Sophie a 11 649 600 minutes. Sophie gagne 65 000,00 $ CA. 
    
  10. 运行代码,并输入fa-IR作为 ISO 代码。注意提示为波斯/波斯语和英语,并且存在从右到左的语言的额外复杂性,如下所示:

    The current localization culture is fa-IR: فارسی (ایران)
    ...
    اسمت را وارد کن / Enter your name: Hoshyar
    تاریخ تولد خود را وارد کنید / Enter your date of birth: 1370/3/6
    حقوق خود را وارد کنید / Enter your salary: 90000
    Hoshyar در چهارشنبهبه دنیا آمد. Hoshyar 11٬190٬240 دقیقه است. Hoshyar ریال90٬000. 
    

    如果你需要处理波斯日期,那么有一些 NuGet 包和开源 GitHub 存储库可供尝试,尽管我不能保证它们的正确性,如github.com/VahidN/DNTPersianUtils.Coregithub.com/imanabidi/PersianDate.NET

  11. Resources文件夹中,在PacktResources.da.resx中,修改内容以故意更改提示输入姓名的键,通过附加Wrong,如以下标记所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="EnterYourDob" xml:space="preserve">
        <value>Indtast din fødselsdato: </value>
      </data>
      <data name="EnterYourName**Wrong**" xml:space="preserve">
        <value>Indtast dit navn: </value>
      </data>
      <data name="EnterYourSalary" xml:space="preserve">
        <value>Indtast din løn: </value>
      </data>
      <data name="PersonDetails" xml:space="preserve">
        <value>{0} blev født på en {1:dddd}. {0} er {2:N0} minutter gammel. {0} tjener {3:C}.</value>
      </data>
    </root> 
    
  12. 运行代码,并输入da-DK作为 ISO 代码。注意提示为丹麦语,除了Enter your name提示,它显示错误并使用键名作为最后的-退路回退,如下所示:

    The current localization culture is da-DK: dansk (Danmark)
    ...
    **Enter your name**: Bob
    Indtast din fødselsdato: 3/4/1987
    Indtast din løn: 45449
    Bob blev født på en fredag. Bob er 18.413.280 minutter gammel. Bob tjener 45.449,00 kr. 
    
  13. Resources文件夹中,在PacktResources.resx中,修改内容以故意更改提示输入姓名的键,通过附加Wrong

  14. 运行代码,并输入da-DK作为 ISO 代码。注意提示为丹麦语,除了Enter your name提示,它显示错误并使用键名作为最后的-退路回退,如下所示:

    The current localization culture is da-DK: dansk (Danmark)
    ...
    **Error: resource string "EnterYourName" not found.**
    **Search path: WorkingWithCultures.Resources.PacktResources**
    **EnterYourName:** Bob
    Indtast din fødselsdato: 3/4/1987
    Indtast din løn: 45449
    Bob blev født på en fredag. Bob er 18.413.280 minutter gammel. Bob tjener 45.449,00 kr. 
    
  15. 从两个资源文件中移除Wrong后缀。

  16. 解决方案资源管理器中,切换显示所有文件,并展开bin/Debug/net8.0/da文件夹,如图 7.1 所示:

图 7.1:文化资源卫星组件文件夹

  1. 注意名为WorkingWithCultures.resources.dll的卫星组件,用于中性的丹麦资源。

其他文化资源组件的命名相同,但存储在匹配适当文化代码的文件夹中。你可以使用像ResX 资源管理器(可在以下链接找到:dotnetfoundation.org/projects/resx-resource-manager)这样的工具来创建更多的.resx文件,将它们编译成卫星组件,然后部署给用户,而无需重新编译原始控制台应用程序。

良好实践:考虑你的应用程序是否需要国际化,并在开始编码之前为此做好准备!思考所有需要全球化的数据(日期格式、数字格式和排序文本行为)。写下用户界面中所有需要本地化的文本片段。

微软有一个在线工具(可在以下链接找到:www.microsoft.com/en-us/Language/),可以帮助你翻译用户界面中的文本,如图 7.2 所示:

图 7.2:微软用户界面在线文本翻译工具

我们已经看到了 .NET BCL 提供的许多日期和时间功能。它是否提供了我们处理国际化所需的一切?不幸的是,并没有。这就是为什么你可能会想使用 Noda Time。

使用 Noda Time

Noda Time 是为那些觉得内置的日期/时间处理库不够好的开发者设计的。Noda Time 类似于 Joda Time,是 Java 的替代日期/时间处理库。

Noda Time 3.0 或更高版本支持 .NET Standard 2.0 和 .NET 6 或更高版本。这意味着你可以使用它与旧平台,如 .NET Framework 和 Xamarin,以及现代 .NET。

要了解内置 .NET 日期/时间类型的核心缺陷之一,可以想象,如果 .NET 团队没有为数字定义单独的类型,如 int (System.Int32)、double (System.Double) 和 decimal (System.Decimal),而是只定义了一个名为 System.Number 的类型,并有一个名为 Kind 的属性来指示它是哪种类型的数字,它在内存中的存储方式,如何处理它等等。

那就是团队对 System.DateTime 类型所做的事情。该类型有一个 Kind 属性,指示它是否是本地时间、UTC 时间或未指定。它的行为会根据你如何处理它而有所不同。这使得在 .NET 中实现的日期/时间值在操作和理解上非常复杂。

更多信息: Noda Time 的创建者 Jon Skeet 在一篇 2011 年的博客文章中详细讨论了 .NET 和 DateTime 对日期/时间支持的局限性,具体内容可以在以下链接找到:blog.nodatime.org/2011/08/what-wrong-with-datetime-anyway.html

Noda Time 中的重要概念和默认值

内置的 DateTime 类型存储全局和本地值,或者未指定的值。除非非常小心地处理,否则这会打开细微的错误和误解的大门。

与 Noda Time 的第一个重大区别在于,它强制你在类型级别做出选择。因此,Noda Time 有更多类型,并且一开始可能会显得更复杂。Noda Time 中的类型是全球性的或本地的。世界上任何地方的每个人在同一个瞬间都会共享相同的全局值,而每个人会有不同的本地值,这取决于他们的时区等因素。

.NET 内置的日期/时间类型仅精确到 tick,大约是 100 纳秒。Noda Time 精确到 1 纳秒,比它精确 100 倍。

Noda Time 的“零”基线是 1970 年 1 月 1 日午夜在 UTC 时区开始的时间。Noda Time 的 Instant 是从那时起(如果是一个负值)或到那时(如果是一个正值)的纳秒数,它代表全球时间线上的一个时间点。

Noda Time 的默认日历系统是 ISO-8601 日历,因为它是一个标准,你可以在以下链接中了解更多信息:en.wikipedia.org/wiki/ISO_8601。支持自动转换为其他日历系统,如儒略、科普特和佛教日历。

Noda Time 有一些类似于某些 .NET 日期/时间类型的常见类型,总结如下 表 7.6

Noda Time 类型 描述
Instant 结构体 表示全球时间线上的一个瞬间,具有纳秒级分辨率。
Interval 结构体 两个 Instant 值,一个包含的起始点和一个排除的终点。
LocalDateTime 结构体 在特定日历系统中的日期/时间值,但它不表示全球时间线上的一个固定点。例如,2023 年除夕的午夜对不同时区的人来说是不同的。如果你不知道用户的时区,你可能会不得不使用此类型。
LocalDateLocalTime 结构体 LocalDateTime 结构体类似,但只有日期或时间部分。当提示用户输入日期和时间值时,你通常分别使用它们,然后将它们组合成一个单一的 LocalDateTime 结构体。
DateTimeZone 表示时区。可以使用 BclDateTimeZone 从 .NET 的 TimeZoneInfo 转换,使用 DateTimeZoneProviders.Tzdb 获取时区,基于以下链接中列出的标准名称:en.wikipedia.org/wiki/List_of_tz_database_time_zones
ZonedDateTime 结构体 在特定日历系统和特定时区中的日期/时间值,因此它确实代表全球时间线上的一个固定点。
Offset 结构体 表示偏移量。如果本地时间早于 UTC,则为正值;如果本地时间晚于 UTC,则为负值。
OffsetDateTime 结构体 你可能知道从 UTC 的偏移量,但这并不总是干净地映射到一个单一时区。在这种情况下应使用此类型而不是 ZonedDateTime
Duration 结构体 表示固定数量的纳秒。具有转换为常用时间单位(如 DaysHoursMinutesSeconds)的属性,由于它们返回一个 int,所以会向下或向上舍入到零,以及非舍入属性如 TotalDaysTotalMinutes 等,因为它们返回一个 double。用于对 InstantZonedDateTime 值进行计算。使用此类型代替 .NET 的 TimeSpan 类型。
Period 可变持续时间,因为 2024 年 1 月和 2 月表示的“两个月”与 2024 年 6 月和 7 月的“两个月”或甚至非闰年(2024 年是闰年,因此 2024 年 2 月有 29 天)表示的“两个月”长度不同。

表 7.6:常见的 Noda Time 类型

良好实践:使用 Instant 记录某事发生的时间点。这对于时间戳很有用。然后可以将其表示为用户所在时区的本地时间。用于记录用户输入的日期/时间值的常见类型如下:ZonedDateTimeOffsetDateTimeLocalDateTimeLocalDateLocalTime

在 Noda Time 日期/时间类型之间进行转换

为了总结在 Noda Time 类型之间转换的常见方法,请查看以下非详尽图示:

图片

图 7.3:在 Noda Time 日期/时间类型之间转换的常见方法

探索控制台应用程序中的节点时间

让我们编写一些代码:

  1. 使用你喜欢的代码编辑器将一个新的 控制台应用程序/console 项目命名为 WorkingWithNodaTime 添加到 Chapter07 解决方案中。

    • 在项目文件中,将警告视为错误,然后静态和全局导入 System.Console 类,并添加对 NodaTime 的包引用,如下面的标记所示:

      <ItemGroup>
        <PackageReference Include="NodaTime" Version="3.1.9" />
      </ItemGroup> 
      
  2. 添加一个名为 Program.Helpers.cs 的新类文件,并替换其内容,如下面的代码所示:

    partial class Program
    {
      private static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title}");
        ForegroundColor = previousColor;
      }
    } 
    
  3. Program.cs 文件中,删除现有的语句,添加语句以获取当前时间点,并将其转换为各种 Noda Time 类型,包括 UTC、几个时区和本地时间,如下面的代码所示:

    using NodaTime; // To use SystemClock, Instant and so on.
    SectionTitle("Converting Noda Time types");
    // Get the current instant in time.
    Instant now = SystemClock.Instance.GetCurrentInstant();
    WriteLine($"Now (Instant): {now}");
    WriteLine();
    ZonedDateTime nowInUtc = now.InUtc();
    WriteLine($"Now (DateTimeZone): {nowInUtc.Zone}");
    WriteLine($"Now (ZonedDateTime): {nowInUtc}");
    WriteLine($"Now (DST): {nowInUtc.IsDaylightSavingTime()}");
    WriteLine();
    // Use the Tzdb provider to get the time zone for US Pacific.
    // To use .NET compatible time zones, use the Bcl provider.
    DateTimeZone zonePT = DateTimeZoneProviders.Tzdb["US/Pacific"];
    ZonedDateTime nowInPT = now.InZone(zonePT);
    WriteLine($"Now (DateTimeZone): {nowInPT.Zone}");
    WriteLine($"Now (ZonedDateTime): {nowInPT}");
    WriteLine($"Now (DST): {nowInPT.IsDaylightSavingTime()}");
    WriteLine();
    DateTimeZone zoneUK = DateTimeZoneProviders.Tzdb["Europe/London"];
    ZonedDateTime nowInUK = now.InZone(zoneUK);
    WriteLine($"Now (DateTimeZone): {nowInUK.Zone}");
    WriteLine($"Now (ZonedDateTime): {nowInUK}");
    WriteLine($"Now (DST): {nowInUK.IsDaylightSavingTime()}");
    WriteLine();
    LocalDateTime nowInLocal = nowInUtc.LocalDateTime;
    WriteLine($"Now (LocalDateTime): {nowInLocal}");
    WriteLine($"Now (LocalDate): {nowInLocal.Date}");
    WriteLine($"Now (LocalTime): {nowInLocal.TimeOfDay}");
    WriteLine(); 
    
  4. 运行控制台应用程序,并注意结果,包括“本地”时间不考虑任何夏令时偏移;例如,在我的情况下,居住在英国,我必须使用伦敦时区来获取夏令时(上午 10:21),而不是“本地”时间(上午 9:21),如下面的输出所示:

    *** Converting Noda Time types
    Now (Instant): 2023-06-01T09:21:05Z
    Now (DateTimeZone): UTC
    Now (ZonedDateTime): 2023-06-01T09:21:05 UTC (+00)
    Now (DST): False
    Now (DateTimeZone): US/Pacific
    Now (ZonedDateTime): 2023-06-01T02:21:05 US/Pacific (-07)
    Now (DST): True
    Now (DateTimeZone): Europe/London
    Now (ZonedDateTime): 2023-06-01T10:21:05 Europe/London (+01)
    Now (DST): True
    Now (LocalDateTime): 01/06/2023 09:21:05
    Now (LocalDate): 01 June 2023
    Now (LocalTime): 09:21:05 
    
  5. Program.cs 文件中,添加语句以探索如何使用时间段,如下面的代码所示:

    SectionTitle("Working with periods");
    // The modern .NET era began with the release of .NET Core 1.0
    // on June 27, 2016 at 10am Pacific Time, or 5pm UTC.
    LocalDateTime start = new(year: 2016, month: 6, day: 27, 
      hour: 17, minute: 0, second: 0);
    LocalDateTime end = LocalDateTime.FromDateTime(DateTime.UtcNow);
    WriteLine("Modern .NET era");
    WriteLine($"Start: {start}");
    WriteLine($"End: {end}");
    WriteLine();
    Period period = Period.Between(start, end);
    WriteLine($"Period: {period}");
    WriteLine($"Years: {period.Years}");
    WriteLine($"Months: {period.Months}");
    WriteLine($"Weeks: {period.Weeks}");
    WriteLine($"Days: {period.Days}");
    WriteLine($"Hours: {period.Hours}");
    WriteLine();
    Period p1 = Period.FromWeeks(2);
    Period p2 = Period.FromDays(14);
    WriteLine($"p1 (period of two weeks): {p1}");
    WriteLine($"p2 (period of 14 days): {p2}");
    WriteLine($"p1 == p2: {p1 == p2}");
    WriteLine($"p1.Normalize() == p2: {p1.Normalize() == p2}"); 
    
  6. 运行控制台应用程序并注意结果,包括在 2023 年 6 月 1 日运行控制台应用程序时,现代 .NET 时代已经持续了 6 年 11 个月和 4 天,Period 类型的序列化格式,以及如何比较两个时间段以及比较之前应该进行归一化,如下面的输出所示:

    *** Working with periods
    Modern .NET era
    Start: 27/06/2016 17:00:00
    End: 01/06/2023 09:21:05
    Period: P6Y11M4DT16H21M5S889s9240t
    Years: 6
    Months: 11
    Weeks: 0
    Days: 4
    Hours: 16
    p1 (period of two weeks): P2W
    p2 (period of 14 days): P14D
    p1 == p2: False
    p1.Normalize() == p2: True 
    

更多信息:使用 Normalize 方法归一化 Period 意味着将任何周数乘以 7 并加到天数上,然后将 Weeks 设置为零,以及其他计算。更多信息请参阅以下链接:nodatime.org/3.1.x/api/NodaTime.Period.html#NodaTime_Period_Normalize

使用 Noda Time 进行单元测试和 JSON 序列化

Noda Time 有两个可选包用于编写单元测试(NodaTime.Testing)和与 JSON.NET 一起工作(NodaTime.Serialization.JsonNet)。

Noda Time 的文档可以在以下链接找到:nodatime.org/

练习和探索

通过回答一些问题、进行一些动手实践和更深入地研究本章主题来测试你的知识和理解。

练习 7.1 – 测试你的知识

使用网络回答以下问题:

  1. 本地化、全球化和国际化之间的区别是什么?

  2. .NET 中可用的最小时间度量是什么?

  3. 在 .NET 中,“滴答”的时间是多长?

  4. 在什么场景下你可能使用 DateOnly 值而不是 DateTime 值?

  5. 对于一个时区,它的 BaseUtcOffset 属性告诉你什么?

  6. 你如何获取代码执行所在本地时区的信息?

  7. 对于一个 DateTime 值,它的 Kind 属性告诉你什么?

  8. 你如何控制执行代码的当前文化环境?

  9. 威尔士的 ISO 文化代码是什么?

  10. 本地化资源文件回退是如何工作的?

练习 7.2 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-7---handling-dates-times-and-internationalization

练习 7.3 – 向专家 Jon Skeet 学习

Jon Skeet 是国际化的世界知名专家。观看他在以下链接中展示的 Working with Time is Easywww.youtube.com/watch?v=saeKBuPewcU

摘要

在本章中,你:

  • 探索了日期和时间,包括 .NET 8 的 TimeProvider 以改进单元测试。

  • 学会了如何处理时区。

  • 学习了如何使用全球化和本地化来国际化你的代码。

  • 探索了 Noda Time 的一些功能。

在下一章中,你将学习如何使用 ASP.NET Core Minimal API 构建网络服务,以及如何确保和保护它们。

第八章:使用最小 API 构建和保障 Web 服务

本章介绍使用 ASP.NET Core 最小 API 构建和保障 Web 服务。这包括实施保护 Web 服务免受攻击的技术以及身份验证和授权。

本章将涵盖以下主题:

  • 使用 ASP.NET Core 最小 API 构建 Web 服务

  • 使用 CORS 放宽同源安全策略

  • 使用速率限制预防拒绝服务攻击

  • 使用原生 AOT 改进启动时间和资源

  • 理解身份服务

使用 ASP.NET Core 最小 API 构建 Web 服务

在 ASP.NET Core 的旧版本中,您会使用控制器构建 Web 服务,每个端点都有一个操作方法,有点像使用 ASP.NET Core MVC 和控制器以及模型构建网站,但没有视图。从.NET 6 开始,您有另一个通常更好的选择:ASP.NET Core 最小 API

最小 API 基于 Web 服务的优势

在 ASP.NET Core 的早期版本中,与替代 Web 开发平台相比,实现一个简单的 Web 服务需要大量的样板代码。例如,一个最小的Hello World Web 服务实现,它有一个返回纯文本的单个端点,可以使用Express.js仅用九行代码实现,如下所示:

const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
  res.send('Hello World!')
})
app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
}) 

在 ASP.NET Core 5 或更早版本中,这需要超过五十行代码!

使用 ASP.NET Core 6 或更高版本,现在只需五行代码和六行配置即可,如下所示的两个代码块:

int port = 3000;
var app = WebApplication.Create();
app.MapGet("/", () => "Hello World!");
Console.WriteLine($"Example app listening on port {port}");
await app.RunAsync($"https://localhost:{port}/"); 

平台在项目文件中指定,隐式using语句 SDK 功能执行一些繁重的工作。默认情况下启用,如下所示,在以下标记中突出显示:

<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
 **<ImplicitUsings>enable</ImplicitUsings>**
  </PropertyGroup>
</Project> 

良好实践:最小 API 的另一个好处是它们不使用动态生成的代码,与基于控制器的 Web API 不同。这使得它们可以使用原生 AOT 生成更小、更快的服务,这对于在容器中实现和托管微服务更有利。我们将在本章后面介绍原生 AOT 与最小 API。尽可能使用最小 API 而不是控制器来实现您的 Web 服务。

理解最小 API 路由映射

WebApplication实例具有您可以调用的方法,用于将路由映射到 lambda 表达式或语句:

  • MapGet: 将路由映射到GET请求以检索实体。

  • MapPost: 将路由映射到POST请求以插入实体。

  • MapPut: 将路由映射到PUT请求以更新实体。

  • MapPatch: 将路由映射到PATCH请求以更新实体。

  • MapDelete: 将路由映射到DELETE请求以删除实体。

  • MapMethods: 将路由映射到任何其他 HTTP 方法或方法,例如CONNECTHEAD

例如,您可能希望将相对路径 api/customers 的 HTTP GET 请求映射到由 lambda 表达式或返回包含客户列表的 JSON 文档的函数定义的委托,以及插入和删除的等效映射,如下面的代码所示:

app.MapGet("api/customers", GetCustomers);
app.MapPost("api/customers", InsertCustomer);
app.MapDelete("api/customers/{id}", DeleteCustomer); 

您可能希望将相对路径 api/customers 的 HTTP CONNECT 请求映射到 lambda 语句块,如下面的代码所示:

app.MapMethods("api/customers", new[] { "CONNECT" }, () =>
  { 
    // Do something.
  }); 

如果您有多个共享相同相对路径的端点,则可以定义一个 路由组MapGroup 方法在 .NET 7 中引入:

RouteGroupBuilder group = app.MapGroup("api/customers")
group.MapGet("/", GetCustomers)
  .MapGet("/{id}", GetCustomerById)
  .MapPost("/", InsertCustomer)
  .MapDelete("/{id}", DeleteCustomer); 

更多信息:您可以在以下链接中了解更多关于路由映射的信息:learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/route-handlers.

理解参数映射

委托可以定义参数,这些参数可以自动设置。尽管大多数映射可以在不显式指定的情况下进行配置,但您可以选择使用属性来定义 ASP.NET Core 最小 API 应从何处设置参数值:

  • [FromServices]:参数将从已注册的依赖服务设置。

  • [FromRoute]:参数将从匹配的命名路由段设置。

  • [FromQuery]:参数将从匹配的命名查询字符串参数设置。

  • [FromBody]:参数将从 HTTP 请求体设置。

例如,要更新数据库中的实体,您需要从已注册的依赖服务中检索数据库上下文,将标识符作为查询字符串或路由段传递,以及请求体中的新实体,如下面的代码所示:

app.MapPut("api/customers/{id}", async (
  [FromServices] NorthwindContext db,
  [FromRoute] string id, // or [FromQuery] string id,
  [FromBody] Customer customer) =>
{
  Customer? existingCustomer = await db.Customers.FindAsync(id);
  ...
}); 

理解返回值

最小 API 服务可以以一些常见的格式返回数据,如下表 8.1 所示:

类型 Lambda
纯文本 () => "Hello World!"``() => Results.Text("Hello World!")
JSON 文档 () => new { FirstName = "Bob", LastName = "Jones" }``() => Results.Json(new { FirstName = "Bob", LastName = "Jones" })
带状态码的 IResult () => Results.Ok(new { FirstName = "Bob", LastName = "Jones" })``() => Results.NoContent()``() => Results.Redirect("new/path")``() => Results.NotFound()``() => Results.BadRequest()``() => Results.Problem()``() => Results.StatusCode(405)
文件 () => Results.File("/path/filename.ext")

表 8.1:最小 API 返回值的示例

记录 Minimal APIs 服务

您可以根据需要调用额外的方法多次,以指定可以从端点期望的返回类型和状态码,例如:

  • Produces<T>(StatusCodes.Status200OK):当成功时,此路由返回包含类型 T 和状态码 200 的响应。

  • Produces(StatusCodes.Status404NotFound):当找不到路由匹配项时,此路由返回空响应和状态码 404。

设置 ASP.NET Core Web API 项目

首先,我们将创建一个简单的 ASP.NET Core Web API 项目,稍后我们将使用各种技术来保护它,例如速率限制、CORS、身份验证和授权。

该 web 服务的 API 定义如下所示 表 8.2

方法 路径 请求体 响应体 成功代码
GET / None Hello World! 200
GET /api/products None 库存 Product 对象数组 200
GET /api/products/outofstock None 已售罄 Product 对象数组 200
GET /api/products/discontinued None 已停产的 Product 对象数组 200
GET /api/products/{id} None Product 对象 200
GET /api/products/{name} None 包含名称的 Product 对象数组 200
POST /api/products Product 对象(无 Id 值) Product 对象 201
PUT /api/products/{id} Product 对象 None 204
DELETE /api/products/{id} None None 204

表 8.2:示例项目实现的 API 方法

让我们开始:

  1. 使用您首选的代码编辑器创建一个名为 Chapter08 的新解决方案。

  2. 添加一个如以下列表中定义的 Web API 项目:

    • 项目模板:ASP.NET Core Web API / webapi

    • 解决方案文件和文件夹:Chapter08

    • 项目文件和文件夹: Northwind.WebApi.Service

    • 身份验证类型

    • 配置 HTTPS:已选择。

    • 启用 Docker:已清除。

    • 启用 OpenAPI 支持:已选择。

    • 不使用顶级语句:已清除。

    • 使用控制器:已清除。

    要使用 dotnet new 为预-.NET 8 SDK 创建使用最小 API 的 Web API 项目,您必须使用 -minimal 开关或 --use-minimal-apis 开关。对于 .NET 8 SDK,最小 API 是默认的,要使用控制器,您必须指定 --use-controllers-controllers 开关。

    警告!如果您正在使用 JetBrains Rider,其用户界面可能还没有创建使用最小 API 的 Web API 项目的选项。我建议使用 dotnet new 创建项目,然后将项目添加到您的解决方案中。

  3. 将您在 第三章 中创建的 Northwind 数据库上下文项目(针对 SQL Server)添加为项目引用,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    路径不能有换行符。如果您没有完成在 第三章 中创建类库的任务,那么请从 GitHub 仓库下载解决方案项目。

  4. 在项目文件中,将 invariantGlobalization 更改为 false,并将警告视为错误,如下所示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <InvariantGlobalization>**false**</InvariantGlobalization>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup> 
    

    在 .NET 8 的 ASP.NET Core Web API 项目模板中,显式设置不变量全球化为 true 是新的。它旨在使网络服务不受文化限制,因此可以在世界任何地方部署并具有相同的行为。通过将此属性设置为 false,网络服务将默认为当前托管计算机的文化。你可以在以下链接中了解更多关于不变量全球化模式的信息:github.com/dotnet/runtime/blob/main/docs/design/features/globalization-invariant-mode.md

  5. 在命令提示符或终端中,构建 Northwind.WebApi.Service 项目,以确保当前解决方案外部的实体模型类库项目被正确编译,如下所示(命令):

    dotnet build 
    
  6. Properties 文件夹中,在 launchSettings.json 文件中,将名为 https 的配置文件的 applicationUrl 修改为使用端口 5081,如下所示(高亮显示)的配置:

    "profiles": {
      ...
    **"https"****:****{**
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "launchUrl": "swagger",
    **"applicationUrl"****:****"https://localhost:5081"****,**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        } 
    

    Visual Studio 2022 将读取此设置文件,如果 launchBrowser 设置为 true,则自动运行一个网络浏览器,然后导航到 applicationUrllaunchUrl。Visual Studio Code 和 dotnet run 不会这样做,因此你需要手动运行一个网络浏览器并手动导航到 localhost:5081/swagger

  7. Program.cs 文件中,删除关于天气服务的语句,并用导入命名空间以将 NorthwindContext 添加到配置服务的语句替换,如下所示(高亮显示):

    **using** **Northwind.EntityModels;** **// To use the AddNorthwindContext method.**
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen();
    **builder.Services.AddNorthwindContext();**
    var app = builder.Build();
    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
      app.UseSwagger();
      app.UseSwaggerUI();
    }
    app.UseHttpsRedirection(); 
    
  8. 添加一个名为 WebApplication.Extensions.cs 的新类文件。

    良好实践:不要在 Program.cs 文件中添加数百行代码,而是为在最小 API 中配置的常见类型定义扩展方法,如 WebApplicationIServiceCollection

  9. WebApplication.Extensions.cs 文件中,导入用于控制 HTTP 结果、将参数绑定到依赖服务以及与 Northwind 实体模型一起工作的命名空间,然后定义一个扩展方法为 WebApplication 类配置对所有记录在我们 API 表中的 HTTP GET 请求的响应,如下所示:

    using Microsoft.AspNetCore.Http.HttpResults; // To use Results.
    using Microsoft.AspNetCore.Mvc; // To use [FromServices] and so on.
    using Northwind.EntityModels; // To use NorthwindContext, Product.
    namespace Packt.Extensions;
    public static class WebApplicationExtensions
    {
      public static WebApplication MapGets(this WebApplication app,
        int pageSize = 10)
      {
        app.MapGet("/", () => "Hello World!")
          .ExcludeFromDescription();
        app.MapGet("api/products", (
          [FromServices] NorthwindContext db,
          [FromQuery] int? page) =>
          db.Products
            .Where(p => p.UnitsInStock > 0 && !p.Discontinued)
            .OrderBy(product => product.ProductId)
            .Skip(((page ?? 1) - 1) * pageSize)
            .Take(pageSize)
          )
          .WithName("GetProducts")
          .WithOpenApi(operation =>
          {
            operation.Description =
              "Get products with UnitsInStock > 0 and Discontinued = false.";
            operation.Summary = "Get in-stock products that are not discontinued.";
            return operation;
          })
          .Produces<Product[]>(StatusCodes.Status200OK);
        app.MapGet("api/products/outofstock", 
          ([FromServices] NorthwindContext db) => db.Products
            .Where(p => p.UnitsInStock == 0 && !p.Discontinued)
          )
          .WithName("GetProductsOutOfStock")
          .WithOpenApi()
          .Produces<Product[]>(StatusCodes.Status200OK);
        app.MapGet("api/products/discontinued", 
          ([FromServices] NorthwindContext db) =>
            db.Products.Where(product => product.Discontinued)
          )
          .WithName("GetProductsDiscontinued")
          .WithOpenApi()
          .Produces<Product[]>(StatusCodes.Status200OK);
        app.MapGet("api/products/{id:int}",
          async Task<Results<Ok<Product>, NotFound>> (
          [FromServices] NorthwindContext db,
          [FromRoute] int id) =>
            await db.Products.FindAsync(id) is Product product ?
              TypedResults.Ok(product) : TypedResults.NotFound())
          .WithName("GetProductById")
          .WithOpenApi()
          .Produces<Product>(StatusCodes.Status200OK)
          .Produces(StatusCodes.Status404NotFound);
        app.MapGet("api/products/{name}", (
          [FromServices] NorthwindContext db,
          [FromRoute] string name) =>
            db.Products.Where(p => p.ProductName.Contains(name)))
          .WithName("GetProductsByName")
          .WithOpenApi()
          .Produces<Product[]>(StatusCodes.Status200OK);
        return app;
      }
    } 
    
  10. WebApplication.Extensions.cs 文件中,为 WebApplication 类定义一个扩展方法,以配置对 API 表中记录的 HTTP POST 请求的响应,如下所示:

    public static WebApplication MapPosts(this WebApplication app)
    {
      app.MapPost("api/products", async ([FromBody] Product product, 
        [FromServices] NorthwindContext db) =>
      {
        db.Products.Add(product);
        await db.SaveChangesAsync();
        return Results.Created($"api/products/{product.ProductId}", product);
      }).WithOpenApi()
        .Produces<Product>(StatusCodes.Status201Created);
      return app;
    } 
    
  11. WebApplication.Extensions.cs 文件中,为 WebApplication 类定义一个扩展方法,以配置对 API 表中记录的 HTTP PUT 请求的响应,如下所示:

    public static WebApplication MapPuts(this WebApplication app)
    {
      app.MapPut("api/products/{id:int}", async (
        [FromRoute] int id, 
        [FromBody] Product product, 
        [FromServices] NorthwindContext db) =>
      {
        Product? foundProduct = await db.Products.FindAsync(id);
        if (foundProduct is null) return Results.NotFound();
        foundProduct.ProductName = product.ProductName;
        foundProduct.CategoryId = product.CategoryId;
        foundProduct.SupplierId = product.SupplierId;
        foundProduct.QuantityPerUnit = product.QuantityPerUnit;
        foundProduct.UnitsInStock = product.UnitsInStock;
        foundProduct.UnitsOnOrder = product.UnitsOnOrder;
        foundProduct.ReorderLevel = product.ReorderLevel;
        foundProduct.UnitPrice = product.UnitPrice;
        foundProduct.Discontinued = product.Discontinued;
        await db.SaveChangesAsync();
        return Results.NoContent();
      }).WithOpenApi()
        .Produces(StatusCodes.Status404NotFound)
        .Produces(StatusCodes.Status204NoContent);
      return app;
    } 
    
  12. WebApplication.Extensions.cs 文件中,为 WebApplication 类定义一个扩展方法,以配置对 API 表中记录的 HTTP DELETE 请求的响应,如下所示:

    public static WebApplication MapDeletes(this WebApplication app)
    {
      app.MapDelete("api/products/{id:int}", async (
        [FromRoute] int id, 
        [FromServices] NorthwindContext db) =>
      {
        if (await db.Products.FindAsync(id) is Product product)
        {
          db.Products.Remove(product);
          await db.SaveChangesAsync();
          return Results.NoContent();
        }
        return Results.NotFound();
      }).WithOpenApi()
        .Produces(StatusCodes.Status404NotFound)
        .Produces(StatusCodes.Status204NoContent);
      return app;
    } 
    
  13. Program.cs 文件中,导入你刚刚定义的扩展方法所使用的命名空间,如下所示:

    using Packt.Extensions; // To use MapGets and so on. 
    
  14. Program.cs 中,在调用 app.Run() 之前,调用你的自定义扩展方法来映射 GETPOSTPUTDELETE 请求,注意当你请求所有产品时,你可以覆盖默认的 10 个实体页面大小,如下面的代码所示:

    app.MapGets() // Default pageSize: 10.
      .MapPosts()
      .MapPuts()
      .MapDeletes(); 
    
  15. Program.cs 中,确保文件中的最后一个语句运行了网络应用,如下面的代码所示:

    app.Run(); 
    

使用 Swagger 测试网络服务

现在我们可以启动网络服务,使用 Swagger 查看其文档,并进行基本的手动测试:

  1. 如果你的数据库服务器没有运行(例如,因为你正在 Docker、虚拟机或云中托管它),那么请确保启动它。

  2. 使用 https 配置文件启动网络服务项目:

    • 如果你正在使用 Visual Studio 2022,那么在下拉列表中选择 https 配置文件,然后导航到 Debug | Start Without Debugging 或按 Ctrl + F5。网页浏览器应该会自动导航到 Swagger 文档网页。

    • 如果你正在使用 Visual Studio Code,那么输入命令 dotnet run --launch-profile https,手动启动一个网页浏览器,并导航到 Swagger 文档网页:localhost:5081/swagger

    在 Windows 上,如果需要,你必须将 Windows Defender 防火墙设置为允许访问你的本地网络服务。

  3. 在控制台或终端中,注意有关你的网络服务的信息,如下面的输出所示:

    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: https://localhost:5081
    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:\apps-services-net8\Chapter08\Northwind.WebApi.Service 
    
  4. 在你的网页浏览器中,注意 Swagger 文档,如图 8.1 所示:

图 8.1:Northwind Web API 服务的 Swagger 文档

  1. 点击 GET /api/products 以展开该部分。

  2. 点击 Try it out 按钮,注意可选的查询字符串参数名为 page,然后点击 Execute 按钮。

  3. 注意响应包括库存中且未停售的前十种产品:1234678101112

  4. 对于 page 参数,输入 3,然后点击 Execute 按钮。

  5. 注意响应包括库存中且未停售的第三页的十种产品:25262730323334353637

  6. 点击 GET /api/products 以折叠该部分。

  7. 尝试执行 GET /api/products/outofstock 路径,并注意它返回了一种产品,31 Gorgonzola Telino,库存为零且未停售。

  8. 尝试执行 GET /api/products/discontinued 路径,并注意它返回了八种产品,59172428294253,它们的 Discontinued 属性都设置为 true

  9. 点击 GET /api/products/{id} 以展开该部分。

  10. 点击 Try it out,输入所需的 id 参数为 77,点击 Execute,并注意响应包含名为 Original Frankfurter grüne Soße 的产品,如下面的 JSON 文档所示:

    {
      "productId": 77,
      "productName": "Original Frankfurter grüne Soße",
      "supplierId": 12,
      "categoryId": 2,
      "quantityPerUnit": "12 boxes",
      "unitPrice": 13,
      "unitsInStock": 32,
      "unitsOnOrder": 0,
      "reorderLevel": 15,
      "discontinued": false,
      "category": null,
      "supplier": null,
      "orderDetails": []
    } 
    
  11. 点击 GET /api/products/{id} 以折叠该部分。

  12. 点击 GET /api/products/{name} 来展开该部分。

  13. 点击 尝试一下,输入所需的 name 参数为 man,点击 执行,并注意响应包含名为 Queso Manchego La PastoraManjimup Dried Apples 的产品。

  14. 保持 Web 服务运行。

使用代码编辑器工具测试 Web 服务

使用 Swagger 用户界面测试 Web 服务可能会变得繁琐。更好的工具是名为 REST Client 的 Visual Studio Code 扩展或 Visual Studio 2022 版本 17.6 或更高版本提供的 Endpoints Explorer.http 文件支持。

更多信息:你可以在以下链接了解 Visual Studio 2022 及其 HTTP 编辑器:learn.microsoft.com/en-us/aspnet/core/test/http-files

JetBrains Rider 有一个类似的工具窗口名为 Endpoints

如果你正在使用 JetBrains Rider,你可以在以下链接了解其 HTTP 文件工具:www.jetbrains.com/help/rider/Http_client_in__product__code_editor.html。它与另外两个代码编辑器略有不同。特别是,Rider 处理设置变量的方式更为繁琐,如图所示:www.jetbrains.com/help/rider/Exploring_HTTP_Syntax.html#example-working-with-environment-files。你可能更喜欢使用带有 REST Client 扩展的 Visual Studio Code 来处理这一部分。

让我们看看这些工具如何帮助我们测试 Web 服务:

  1. 确保你已经安装了 Web 服务测试工具:

    • 如果你正在使用 Visual Studio 2022,请确保你安装的版本为 17.6 或更高版本(2023 年 5 月发布)。

    • 如果你正在使用 Visual Studio Code,请确保你已经安装了由 Huachao Mao 开发的 REST Client 扩展(humao.rest-client)。

    • 如果你正在使用 Visual Studio 2022,导航到 视图 | 其他窗口 | Endpoints Explorer,并注意当前项目正在扫描潜在的 Web API 端点,如图 8.2 所示。

  2. 在你偏好的代码编辑器中,使用 https 配置启动 Northwind.WebApi.Service 项目(如果尚未运行),并保持其运行。

  3. apps-services-net8 文件夹中,如果尚不存在,创建一个 HttpRequests 文件夹。

  4. HttpRequests 文件夹中,创建一个名为 webapi-get-products.http 的文件,并修改其内容以声明一个变量来保存 Web API 服务产品端点的基址,以及一个获取前 10 页产品的请求,如下面的代码所示:

    ### Configure a variable for the web service base address.
    @base_address = https://localhost:5081/api/products/
    ### Get first page of 10 products that are in stock and not discontinued.
    GET {{base_address}} 
    

    良好实践:REST 客户端在请求开始时不要求使用 GET,因为它会默认假设为 GET。但截至写作时,Visual Studio 的 HTTP 编辑器需要显式指定 GET。因此,我建议您为所有工具指定 HTTP 方法,并且我将为我的所有 .http 文件这样做。

  5. 点击 发送请求,并注意响应与 Swagger 返回的相同,它是一个包含前十个库存且未停售产品的 JSON 文档响应,如 Visual Studio 2022 中的 图 8.2 和 Visual Studio Code 中的 图 8.3 所示:

图 8.2:Visual Studio 2022 从 Web API 服务获取产品

图 8.3:Visual Studio Code REST 客户端从 Web API 服务获取产品

  1. webapi-get-products.http 文件中,通过 ### 分隔添加更多请求,如下所示:

    ### Get third page of 10 products that are in stock and not discontinued
    GET {{base_address}}?page=3
    ### Get products that are out-of-stock but not discontinued
    GET {{base_address}}outofstock
    ### Get products that are discontinued
    GET {{base_address}}discontinued
    ### Get product 77
    GET {{base_address}}77
    ### Get products that contain "man"
    GET {{base_address}}man 
    

    您可以通过点击绿色三角形“播放”按钮、右键单击并选择 发送请求 或按 Ctrl + Alt + S 在 Visual Studio 2022 中执行 HTTP 请求。在 Visual Studio Code 中,点击每个查询上方的 发送请求,或导航到 视图 | 命令面板 并选择 Rest Client: 发送请求,或使用其快捷键(在 Windows 上为 Ctrl + Alt + R)。

  2. HttpRequests 文件夹中,创建一个名为 webapi-insert-product.http 的文件,并修改其内容以包含一个用于插入新产品的 POST 请求,如下所示:

    POST https://localhost:5081/api/products/
    Content-Type: application/json
    {
      "productName": "Harry's Hamburgers",
      "supplierId": 7,
      "categoryId": 6,
      "quantityPerUnit": "6 per box",
      "unitPrice": 24.99,
      "unitsInStock": 0,
      "unitsOnOrder": 20,
      "reorderLevel": 10,
      "discontinued": false
    } 
    
  3. 点击 发送请求,并注意响应表明新产品已成功添加,因为状态码为 201,其位置包括其产品 ID,如 图 8.4 所示:

    图 8.4:REST 客户端通过调用 Web API 服务插入新产品

    原本,Northwind 数据库中有 77 个产品。下一个产品 ID 将是 78。实际分配的自动产品 ID 将取决于您是否之前添加了其他产品,因此您分配的数字可能更高。

  4. HttpRequests 文件夹中,创建一个名为 webapi-update-product.http 的文件,并修改其内容以包含一个用于更新产品 ID 为 78(或分配给您的 Harry's Hamburgers 的任何数字)的 PUT 请求,其中包含不同的每单位数量、单价和库存单位,如下所示:

    PUT https://localhost:5081/api/products/78
    Content-Type: application/json
    {
      "productName": "Harry's Hamburgers",
      "supplierId": 7,
      "categoryId": 6,
      "quantityPerUnit": "12 per box",
      "unitPrice": 44.99,
      "unitsInStock": 50,
      "unitsOnOrder": 20,
      "reorderLevel": 10,
      "discontinued": false
    } 
    
  5. 发送请求并注意您应该收到一个 204 状态码的响应,表示更新成功。

  6. 通过执行针对产品 ID 的 GET 请求来确认产品是否已更新。

  7. HttpRequests 文件夹中,创建一个名为 webapi-delete-product.http 的文件,并修改其内容以包含针对新产品的 DELETE 请求,如下所示:

    DELETE https://localhost:5081/api/products/78 
    
  8. 注意成功响应,如 图 8.5 所示:

图 8.5:使用 Web API 服务删除产品

  1. 再次发送请求,并注意响应包含 404 状态码,因为产品已经被删除。

  2. 关闭网络服务器。

排除来自 OpenAPI 文档的路径

有时您可能想要一个可以工作但不显示在 Swagger 文档中的路径。让我们看看如何从 Swagger 文档网页中移除返回纯文本 Hello World! 响应的服务基本地址:

  1. WebApplication.Extensions.cs 中,对于返回 Hello World 的根路径,将其从 OpenAPI 文档中排除,如下面的代码所示,高亮显示:

    app.MapGet("/", () => "Hello World!")
     **.ExcludeFromDescription();** 
    
  2. 使用 https 配置启动 Northwind.WebApi.Service 项目,不进行调试,并注意路径现在没有文档记录。

我们使用 ASP.NET Core Minimal APIs 实现了一个工作的网络服务。现在让我们攻击它!(这样我们可以学习如何防止这些攻击。)

Visual Studio 2022 为 Minimal APIs 提供的脚手架

学习如何从头开始使用最小 API 实现服务非常重要,这样您可以正确理解它。但是一旦您知道如何手动操作,这个过程可以自动化,并且可以为您编写样板代码,尤其是如果您正在构建一个包装 EF Core 实体模型的网络 API。

例如,Visual Studio 2022 有一个名为 使用 Entity Framework 的 API 读写端点的项目项模板,允许您选择:

  • 一个实体模型类,例如 Customer

  • 一个端点类,它将包含所有映射方法。

  • 一个由 DbContext 派生的类,例如 NorthwindContext

  • 一个数据库提供程序,例如 SQLite 或 SQL Server。

更多信息:您可以在以下链接中了解更多关于此项目项模板的信息:devblogs.microsoft.com/visualstudio/web-api-development-in-visual-studio-2022/#scaffolding-in-visual-studio/

使用 CORS 放宽同源安全策略

现代网络浏览器支持多个标签页,用户可以高效地同时访问多个网站。如果在一个标签页中执行的代码可以访问另一个标签页中的资源,那么这可能是一个攻击向量。

所有网络浏览器都实现了名为 同源策略 的安全功能。这意味着只有来自同一源头的请求被允许。例如,如果一段 JavaScript 代码是从托管网络服务或 <iframe> 的同一源头提供的,那么该 JavaScript 可以调用该服务并访问 <iframe> 中的数据。如果请求来自不同的源头,则请求失败。但“同源”是什么意思呢?

原因定义为:

  • 方案也称为协议,例如,httphttps

  • 端口,例如,8015081http 的默认端口是 80,而 https 的默认端口是 443

  • 主机/域名/子域名,例如,www.example.comwww.example.netexample.com

如果源是 https://www.example.com/about-us/,则以下不是同一个源:

  • 不同的方案:http://www.example.com/about-us/

  • 不同的主机/域名:https://www.example.co.uk/about-us/

  • 不同的子域名:https://careers.example.com/about-us/

  • 不同的端口:https://www.example.com:444/about-us/

是网页浏览器在发起请求时自动设置 Origin 标头的。这不能被覆盖。

警告!同源策略不适用于来自非网页浏览器的任何请求,因为在那些情况下,程序员仍然可以更改 Origin 标头。如果您创建了一个控制台应用程序或甚至是一个使用 .NET 类如 HttpClient 来发起请求的 ASP.NET Core 项目,则同源策略不适用,除非您明确设置 Origin 标头。

让我们看看从具有不同源的网页和 .NET 应用程序调用 Web 服务的示例。

配置 Web 服务的 HTTP 日志记录

首先,让我们为 Web 服务启用 HTTP 日志记录并配置它以显示请求的源:

  1. Northwind.WebApi.Service 项目中,添加一个名为 IServiceCollection.Extensions.cs 的新类文件。

  2. IServiceCollection.Extensions.cs 文件中,导入用于控制哪些 HTTP 字段被记录的命名空间,然后为 IServiceCollection 接口定义一个扩展方法以添加 HTTP 日志记录,包括 Origin 标头以及包括响应体在内的所有字段,如下所示:

    using Microsoft.AspNetCore.HttpLogging; // To use HttpLoggingFields.
    namespace Packt.Extensions;
    public static class IServiceCollectionExtensions
    {
      public static IServiceCollection AddCustomHttpLogging(
        this IServiceCollection services)
      {
        services.AddHttpLogging(options =>
        {
          // Add the Origin header so it will not be redacted.
          options.RequestHeaders.Add("Origin");
          // By default, the response body is not included.
          options.LoggingFields = HttpLoggingFields.All;
        });
        return services;
      }
    } 
    
  3. Program.cs 文件中,在调用 builder.Build() 之前,添加一条语句以添加自定义 HTTP 日志记录,如下所示:

    builder.Services.AddCustomHttpLogging(); 
    
  4. Program.cs 文件中,在调用 UseHttpsRedirection() 之后,添加一条语句以使用 HTTP 日志记录,如下所示:

    app.UseHttpLogging(); 
    
  5. appsettings.Development.json 文件中,添加一个条目以设置 HTTP 日志记录级别为 Information,如下所示(高亮显示):

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"**,**
    **// To enable logging HTTP requests, this must be**
    **// set to Information (3) or higher.**
    **"Microsoft.AspNetCore.HttpLogging"****:****"Information"**
        }
      }
    } 
    

良好实践:JSON 规范不允许注释,但带有注释的 JSON 格式允许。您可以使用 JavaScript 风格的注释 ///* */。您可以在以下链接中了解更多信息:code.visualstudio.com/docs/languages/json#_json-with-comments。如果您使用的是挑剔的代码编辑器,只需删除我添加的注释即可。

创建网页 JavaScript 客户端

接下来,让我们创建一个网页客户端,它将尝试在不同的端口上使用 JavaScript 调用 Web 服务:

  1. 使用您首选的代码编辑器添加一个新项目,如下列表所示:

    • 项目模板:ASP.NET Core Web 应用程序(模型-视图-控制器) / mvc

    • 解决方案文件和文件夹:Chapter08

    • 项目文件和文件夹:Northwind.WebApi.Client.Mvc

    • 其他 Visual Studio 2022 选项:

      • 认证类型

      • 配置为 HTTPS:已选择。

      • 启用 Docker:已清除。

      • 不要使用顶级语句:已清除。

      • 在 Visual Studio 2022 中,配置启动项目为当前选择。

  2. Northwind.WebApi.Client.Mvc项目中,在Properties文件夹中,在launchSettings.json文件中,将https配置文件的applicationUrl更改为使用端口5082,如下所示:

    "applicationUrl": "https://localhost:5082", 
    
  3. Northwind.WebApi.Client.Mvc项目文件中,将警告视为错误。

  4. Views/Home文件夹中,在Index.cshtml中,将现有的标记替换为下面的标记,该标记包含一个指向尚未定义的路由的链接,用于定义一个文本框和按钮,以及一个 JavaScript 块,该块调用 Web 服务以获取包含部分名称的产品,如下所示代码:

    @{
      ViewData["Title"] = "Products using JavaScript";
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      <div>
          Go to <a href="/home/products">Products using .NET</a>
      </div>
      <div>
        <input id="productName" placeholder="Enter part of a product name" />
        <input id="getProductsButton" type="button" value="Get Products" />
      </div>
      <div>
        <table id="productsTable" class="table">
            <thead>
                <tr>
                    <th scope="col">Product Name</th>
                </tr>
            </thead>
            <tbody id="tableBody">
            </tbody>
        </table>
      </div>
      <script>
        var baseaddress = "https://localhost:5081/";
        function xhr_load() {
            console.log(this.responseText);
            var products = JSON.parse(this.responseText);
            var out = "";
            var i;
            for (i = 0; i < products.length; i++) {
                out += '<tr><td><a href="' + baseaddress + 'api/products/' + 
                    products[i].productId + '">' +
                    products[i].productName + '</a></td></tr>';
            }
            document.getElementById("tableBody").innerHTML = out;
        }
        function getProductsButton_click() {
            xhr.open("GET", baseaddress + "api/products/" + 
              document.getElementById("productName").value);
            xhr.send();
        }
        document.getElementById("getProductsButton")
          .addEventListener("click", getProductsButton_click);
        var xhr = new XMLHttpRequest();
        xhr.addEventListener("load", xhr_load);
      </script>
    </div> 
    
  5. 使用https配置文件启动Northwind.WebApi.Service项目,不进行调试。

  6. 使用https配置文件启动Northwind.WebApi.Client.Mvc项目,不进行调试。

    如果你正在使用 Visual Studio Code,那么浏览器将不会自动启动。启动 Chrome,然后导航到localhost:5082

  7. 在 Chrome 浏览器中,显示开发者工具控制台

  8. 使用 JavaScript 的产品网页中,在文本框中输入man,点击获取产品按钮,并注意错误,如下所示输出和图 8.6

    Access to XMLHttpRequest at 'https://localhost:5081/api/products/man' from origin 'https://localhost:5082' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.
    GET https://localhost:5081/api/products/man net::ERR_FAILED 200 
    

图 8.6:Chrome 开发者工具控制台中的 CORS 错误

  1. Northwind.WebApi.Service项目的命令提示符或终端中,注意请求的 HTTP 日志,以及Host在不同的端口号上,与Origin不同,因此它们不是同源,如下所示突出显示的输出:

    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[1]
          Request:
          Protocol: HTTP/2
          Method: GET
          Scheme: https
          PathBase:
          Path: /api/products/man
          Accept: */*
     **Host: localhost:5081**
          User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36
          Accept-Encoding: gzip, deflate, br
          Accept-Language: en-US,en;q=0.9,sv;q=0.8
     **Origin: https://localhost:5082**
          Referer: [Redacted]
          sec-ch-ua: [Redacted]
          sec-ch-ua-mobile: [Redacted]
          sec-ch-ua-platform: [Redacted]
          sec-fetch-site: [Redacted]
          sec-fetch-mode: [Redacted]
          sec-fetch-dest: [Redacted] 
    
  2. 还请注意输出显示 Web 服务确实执行了数据库查询,并将产品以 JSON 文档响应返回给浏览器,如下所示输出:

    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[2]
          Response:
          StatusCode: 200
          Content-Type: application/json; charset=utf-8
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[4]
          ResponseBody: [{"productId":12,"productName":
          "Queso Manchego La Pastora","supplierId":5,"categoryId":4,
          "quantityPerUnit":"10 - 500 g pkgs.","unitPrice":38.0000,
          "unitsInStock":86,"unitsOnOrder":0,"reorderLevel":0,
          "discontinued":false,"category":null,"supplier":null,
          "orderDetails":[]},
          {"productId":51,"productName":"Manjimup Dried Apples",
          "supplierId":24,"categoryId":7,
          "quantityPerUnit":"50 - 300 g pkgs.","unitPrice":53.0000,
          "unitsInStock":20,"unitsOnOrder":0,"reorderLevel":10,
          "discontinued":false,"category":null,"supplier":null,
          "orderDetails":[]}] 
    

    虽然浏览器收到了包含请求数据的响应,但是浏览器通过拒绝向 JavaScript 显示 HTTP 响应来强制执行同源策略。Web 服务不是通过 CORS“安全”的。

  3. 关闭浏览器和关闭 Web 服务器。

创建.NET 客户端

接下来,让我们创建一个.NET 客户端来访问 Web 服务,以查看同源策略不适用于非 Web 浏览器:

  1. Northwind.WebApi.Client.Mvc项目中,添加对实体模型项目的引用,以便我们可以使用Product类,如下所示标记:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.EntityModels .SqlServer\Northwind.Common.EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    
  2. 在命令提示符或终端中通过输入以下命令构建Northwind.WebApi.Client.Mvc项目:dotnet build

  3. Northwind.WebApi.Client.Mvc项目中,在Program.cs中,导入用于处理 HTTP 头部的命名空间,如下所示代码:

    using System.Net.Http.Headers; // To use MediaTypeWithQualityHeaderValue. 
    
  4. Program.cs中,在调用builder.Build()之前,添加语句来配置 HTTP 客户端工厂以调用 Web 服务,如下所示代码:

    builder.Services.AddHttpClient(name: "Northwind.WebApi.Service",
      configureClient: options =>
      {
        options.BaseAddress = new("https://localhost:5081/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
            "application/json", 1.0));
      }); 
    
  5. Controllers文件夹中,在HomeController.cs中,导入实体模型的命名空间,如下所示代码:

    using Northwind.EntityModels; // To use Product. 
    
  6. HomeController.cs中,添加语句将注册的 HTTP 客户端工厂存储在私有的readonly字段中,如下代码所示高亮显示:

    private readonly ILogger<HomeController> _logger;
    **private****readonly** **IHttpClientFactory _httpClientFactory;**
    public HomeController(ILogger<HomeController> logger,
      **IHttpClientFactory httpClientFactory**)
    {
      _logger = logger;
     **_httpClientFactory = httpClientFactory;**
    } 
    
  7. HomeController.cs中,添加一个名为Products的异步操作方法,该方法将使用 HTTP 工厂请求包含在自定义 MVC 路由中作为可选name参数输入的值的产品的名称,如下代码所示:

    [Route("home/products/{name?}")]
    public async Task<IActionResult> Products(string? name)
    {
      HttpClient client = _httpClientFactory.CreateClient(
        name: "Northwind.WebApi.Service");
      HttpRequestMessage request = new(
        method: HttpMethod.Get, requestUri: $"api/products/{name}");
      HttpResponseMessage response = await client.SendAsync(request);
      IEnumerable<Product>? model = await response.Content
        .ReadFromJsonAsync<IEnumerable<Product>>();
      ViewData["baseaddress"] = client.BaseAddress;
      return View(model);
    } 
    
  8. Views/Home文件夹中,添加一个名为Products.cshtml的新文件。(Visual Studio 2022 项目项模板命名为Razor View - Empty。JetBrains Rider 项目项模板命名为Razor MVC View。)

  9. Products.cshtml中,修改其内容以输出一个表格,显示与在文本框中输入的产品名称部分匹配的产品,如下标记所示:

    @using Northwind.EntityModels
    @model IEnumerable<Product>?
    @{
      ViewData["Title"] = "Products using .NET";
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      <div>
        Go to <a href="/">Products using JavaScript</a>
      </div>
      <form action="/home/products">
        <input name="name" placeholder="Enter part of a product name" />
        <input type="submit" value="Get Products" />
      </form>
      <div>
        <table class="table">
          <thead>
            <tr>
              <th scope="col">Product Name</th>
            </tr>
          </thead>
          <tbody>
            @if (Model is not null)
            {
              @foreach (Product p in Model)
              {
                <tr><td><a href="@(ViewData["baseaddress"])api/products/
    @p.ProductId">@p.ProductName</a></td></tr>
              }
            }
          </tbody>
        </table>
      </div>
    </div> 
    
  10. 使用无调试的https配置启动Northwind.WebApi.Service项目。

  11. 使用无调试的https配置启动Northwind.WebApi.Client.Mvc项目。

  12. 在主页上,点击链接跳转到使用.NET 的产品,并注意表中显示了前十个库存且未停售的产品,从ChaiQueso Manchego La Pastora

  13. 在文本框中输入man,点击Get Products,注意表中显示了两个产品,如图 8.7 所示:

    图 8.7:使用.NET 从 Web 服务获取两个产品

    是.NET HTTP 客户端在调用 Web 服务,因此不适用同源策略。如果你像之前一样在命令行或终端中检查日志,你会看到端口不同,但这并不重要。

  14. 点击产品名称之一,直接向该 Web 服务请求单个产品并注意响应,如下文档所示:

    {"productId":12,"productName":"Queso Manchego La Pastora",
    "supplierId":5,"categoryId":4,
    "quantityPerUnit":"10 - 500 g pkgs.","unitPrice":38.0000,
    "unitsInStock":86,"unitsOnOrder":0,"reorderLevel":0,
    "discontinued":false,"category":null,"supplier":null,"orderDetails":[]} 
    
  15. 关闭浏览器并关闭 Web 服务器。

理解 CORS

跨源资源共享CORS)是一种基于 HTTP 头的功能,它要求浏览器在特定场景下禁用其同源安全策略。HTTP 头指示哪些源应该被允许,除了同源之外。

让我们在 Web 服务中启用 CORS,以便它可以发送额外的头信息,告知浏览器它允许从不同的源访问资源:

  1. Northwind.WebApi.Service项目中,在WebApplication.Extensions.cs中,添加一个扩展方法以向 Web 服务添加 CORS 支持,如下代码所示:

    public static IServiceCollection AddCustomCors(
      this IServiceCollection services)
    {
      services.AddCors(options =>
      {
        options.AddPolicy(name: "Northwind.Mvc.Policy",
          policy =>
          {
            policy.WithOrigins("https://localhost:5082");
          });
      });
      return services;
    } 
    
  2. Program.cs中,在创建builder之后,调用自定义扩展方法以添加 CORS 支持,如下代码所示:

    builder.Services.AddCustomCors(); 
    
  3. Program.cs中,在调用UseHttpLogging之后,在映射GET请求之前,添加一个语句来使用 CORS 策略,如下代码所示:

    app.UseCors(policyName: "Northwind.Mvc.Policy"); 
    
  4. 使用无调试的https配置启动Northwind.WebApi.Service项目。

  5. 使用无调试的https配置启动Northwind.WebApi.Client.Mvc项目。

  6. 显示开发者工具控制台

  7. 在主页上的文本框中输入 man,点击 Get Products,注意控制台显示了来自 web 服务的 JSON 文档,并且表格中填充了两个产品,如图 8.8 所示:

图片

图 8.8:使用 JavaScript 向 web 服务发起成功的跨源请求

  1. 关闭浏览器并关闭 web 服务器。

为特定端点启用 CORS

在上一个示例中,我们为整个 web 服务启用了相同的 CORS 策略。你可能需要在端点级别进行更精细的控制:

  1. Northwind.WebApi.Service 项目中,在 Program.cs 文件中,将 UseCors 调用的策略名称指定改为不指定,如下所示,高亮显示的代码:

    **//** app.UseCors(policyName: "Northwind.Mvc.Policy");
    **// Without a named policy the middleware is added but not active.**
    **app.UseCors();** 
    
  2. WebApplication.Extensions.cs 文件中,在获取包含部分产品名称的产品时 MapGet 调用的末尾,添加一个 RequiresCors 调用,如下所示,高亮显示的代码:

    app.MapGet("api/products/{name}", (
      [FromServices] NorthwindContext db,
      [FromRoute] string name) =>
        db.Products.Where(p => p.ProductName.Contains(name)))
      .WithName("GetProductsByName")
      .WithOpenApi()
      .Produces<Product[]>(StatusCodes.Status200OK)
     **.RequireCors(policyName:** **"Northwind.Mvc.Policy"****);** 
    
  3. 使用 https 配置文件启动 Northwind.WebApi.Service 项目,且不进行调试。

  4. 使用 https 配置文件启动 Northwind.WebApi.Client.Mvc 项目,且不进行调试。

  5. 显示 开发者工具控制台

  6. 在主页上的文本框中输入 cha,点击 Get Products,注意控制台显示了来自 web 服务的 JSON 文档,并且表格中填充了三个产品。

  7. 关闭浏览器并关闭 web 服务器。

理解其他 CORS 策略选项

你可以控制以下内容:

  • 允许的来源,例如,https://*.example.com/

  • 允许的 HTTP 方法,例如,GETPOSTDELETE 等等。

  • 允许的 HTTP 请求头,例如,Content-TypeContent-Languagex-custom-header 等等。

  • 暴露 HTTP 响应头,意味着在响应中包含哪些未加密的头信息(因为默认情况下,响应头会被加密),例如,x-custom-header

你可以在以下链接中了解更多关于 CORS 策略的选项:learn.microsoft.com/en-us/aspnet/core/security/cors#cors-policy-options

既然你知道 CORS 并不能保护 web 服务,那么让我们看看一种有用的技术,它可以防止一种常见的攻击形式。

使用速率限制来防止拒绝服务攻击

拒绝服务DoS)攻击是一种恶意尝试通过大量请求来中断 web 服务的攻击。如果请求都来自同一个地方,例如,同一个 IP 地址,那么一旦检测到攻击,就相对容易切断它们。但这些攻击通常作为来自许多位置的 分布式拒绝服务DDoS)攻击来实施,因此你无法将攻击者与真正的客户端区分开来。

另一种方法是针对所有人应用速率限制,但允许真正的已识别客户端有更多的请求通过。

真实客户端应仅发出它们所需的最低请求。合理的数量将取决于您的服务。防止 DDoS 攻击的一种方法是对任何客户端每分钟允许的请求数量进行限制。

这种技术不仅有助于防止攻击。即使是真正的客户端也可能意外地发出过多的请求,或者对于商业 Web 服务,您可能希望根据不同的速率收取不同的费用,例如在控制订阅时。现在,从 Twitter/X 到 Reddit 的商业 Web 服务现在对访问其 Web API 收取了大量的费用。

当客户端超过设定的请求速率限制时,客户端应收到 429 Too Many Requests503 Service Unavailable 的 HTTP 响应。

良好实践:如果您需要构建一个大规模可扩展的 Web 服务并保护其 API,您应该使用像 Azure API Management 这样的云服务,而不是尝试实现自己的速率限制。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/azure/api-management/

使用 AspNetCoreRateLimit 包进行速率限制

AspNetCoreRateLimit 是一个针对 .NET 6 或更高版本的第三方包,它提供基于 IP 地址或客户端 ID 的灵活速率限制中间件:

  1. Northwind.WebApi.Service 项目中,添加对 AspNetCoreRateLimit 包的引用,如下所示:

    <PackageReference Include="AspNetCoreRateLimit" Version="5.0.0" /> 
    
  2. 构建包含 Northwind.WebApi.Service 项目的解决方案以恢复包。

  3. appsettings.Development.json 中,添加默认速率限制选项和客户端特定策略的配置,如下所示的高亮配置:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning",
          "Microsoft.AspNetCore.HttpLogging": "Information"
        }
      }**,**
    **"ClientRateLimiting"****:****{**
    **"EnableEndpointRateLimiting"****:****false****,**
    **"StackBlockedRequests"****:****false****,**
    **"ClientIdHeader"****:****"X-Client-Id"****,**
    **"HttpStatusCode"****:****429****,**
    **"EndpointWhitelist"****:****[****"get:/api/license"****,****"*:/api/status"****],**
    **"ClientWhitelist"****:****[****"dev-id-1"****,****"dev-id-2"****],**
    **"GeneralRules"****:****[**
    **{**
    **"Endpoint"****:****"*"****,**
    **"Period"****:****"10s"****,**
    **"Limit"****:****2**
    **},**
    **{**
    **"Endpoint"****:****"*"****,**
    **"Period"****:****"12h"****,**
    **"Limit"****:****100**
    **}**
    **]**
    **},**
    **"ClientRateLimitPolicies"****:****{**
    **"ClientRules"****:****[**
    **{**
    **"ClientId"****:****"console-client-abc123"****,**
    **"Rules"****:****[**
    **{**
    **"Endpoint"****:****"*"****,**
    **"****Period"****:****"10s"****,**
    **"Limit"****:****5**
    **},**
    **{**
    **"Endpoint"****:****"*"****,**
    **"Period"****:****"12h"****,**
    **"Limit"****:****250**
    **}**
    **]**
    **}**
    **]**
    **}**
    } 
    

    注意:

    • EnableEndpointRateLimiting 设置为 false,意味着所有端点将共享相同的规则。

    • 如果客户端需要标识自己,它可以设置一个名为 X-Client-Id 的头,并将其设置为唯一的 string 值。

    • 如果客户端达到速率限制,服务将开始向该客户端返回 429 状态码响应。

    • 两个端点将不会被全局速率限制,因为它们位于端点白名单中。一个端点是获取许可证,另一个端点是检查服务状态。我们实际上不会实现这些功能,并且您可能希望对这些端点应用不同的速率限制;否则,有人可能会调用它们来使您的服务器崩溃。

    • 两个客户端 ID,名为 dev-id-1dev-id-2,将不会被速率限制,因为它们位于客户端白名单中。这些可能是仅供内部开发者使用的特殊客户端账户,这些账户不会在组织外部共享。

    • 配置了两个通用(默认)规则:第一个规则每 10 秒设置 2 个请求的速率限制,第二个规则每 12 小时设置 100 个请求的速率限制。

    • 配置了两个比默认规则更宽松的客户端特定规则:对于名为 console-client-abc123 的客户端 ID,允许每 10 秒内最多发送 5 个请求,每 12 小时最多发送 250 个请求。

  4. IServiceCollection.Extensions.cs 中,导入用于处理速率限制选项的命名空间,如下所示:

    using AspNetCoreRateLimit; // To use ClientRateLimitOptions and so on. 
    
  5. IServiceCollection.Extensions.cs 中,定义一个扩展方法,从应用程序设置中加载速率限制配置并设置速率限制选项,如下所示:

    public static IServiceCollection AddCustomRateLimiting(
      this IServiceCollection services, ConfigurationManager configuration)
    {
      // Add services to store rate limit counters and rules in memory.
      services.AddMemoryCache();
      services.AddInMemoryRateLimiting();
      // Load default rate limit options from appsettings.json.
      services.Configure<ClientRateLimitOptions>(
        configuration.GetSection("ClientRateLimiting"));
      // Load client-specific policies from appsettings.json.
      services.Configure<ClientRateLimitPolicies>(
        configuration.GetSection("ClientRateLimitPolicies"));
      // Register the configuration.
      services.AddSingleton
        <IRateLimitConfiguration, RateLimitConfiguration>();
      return services;
    } 
    
  6. Program.cs 中,在创建 builder 之后,添加语句从应用程序设置中加载速率限制配置并设置速率限制选项,如下所示:

    builder.Services.AddCustomRateLimiting(builder.Configuration); 
    
  7. IServiceCollection.Extensions.cs 中,在配置 HTTP 记录的调用中,添加一条语句以允许两个速率限制头不被截断,如下所示(高亮显示):

    services.AddHttpLogging(options =>
    {
      // Add the Origin header so it will not be redacted.
      options.RequestHeaders.Add("Origin");
    **// Add the rate limiting headers so they will not be redacted.**
     **options.RequestHeaders.Add(****"X-Client-Id"****);**
     **options.ResponseHeaders.Add(****"Retry-After"****);**
      // By default, the response body is not included.
      options.LoggingFields = HttpLoggingFields.All;
    }); 
    
  8. WebApplication.Extensions.cs 中,导入用于处理速率限制策略存储的命名空间,如下所示:

    using AspNetCoreRateLimit; // To use IClientPolicyStore and so on. 
    
  9. WebApplication.Extensions.cs 中,添加语句以定义一个扩展方法来初始化客户端策略存储,这意味着从配置中加载策略,然后使用客户端速率限制,如下所示:

    public static async Task UseCustomClientRateLimiting(this WebApplication app)
    {
      using (IServiceScope scope = app.Services.CreateScope())
      {
        IClientPolicyStore clientPolicyStore = scope.ServiceProvider
          .GetRequiredService<IClientPolicyStore>();
        await clientPolicyStore.SeedAsync();
      }
      app.UseClientRateLimiting();
    } 
    
  10. Program.cs 中,在调用 UseHttpLogging 之后,添加一个调用以使用客户端速率限制,如下所示:

    await app.UseCustomClientRateLimiting(); 
    

创建速率限制控制台客户端

现在,我们可以创建一个控制台应用程序,它将成为 Web 服务的客户端:

  1. 使用您首选的代码编辑器将一个新的控制台应用程序添加到 Chapter08 解决方案中,命名为 Northwind.WebApi.Client.Console

  2. Northwind.WebApi.Client.Console 项目中,将警告视为错误,全局和静态导入 System.Console 类,并添加对实体模型项目的引用,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.EntityModels
    .SqlServer\Northwind.Common.EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    
  3. 在命令提示符或终端中构建 Northwind.WebApi.Client.Console 项目,以编译引用的项目并将其实例复制到适当的 bin 文件夹。

  4. Northwind.WebApi.Client.Console 项目中,添加一个名为 Program.Helpers.cs 的新类文件。

  5. Program.Helpers.cs 中,添加语句以定义一个方法,用于 partial Program 类,以便以前景色写入一些文本,如下所示:

    partial class Program
    {
      private static void WriteInColor(string text, ConsoleColor foregroundColor)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = foregroundColor;
        Write(text);
        ForegroundColor = previousColor;
      }
    } 
    
  6. Program.cs 中,删除现有的语句。添加语句提示用户输入客户端名称以识别它,然后创建一个 HTTP 客户端,每秒向 Web 服务发送一次请求以获取产品第一页,直到用户按下 Ctrl + C 停止控制台应用程序,如下所示:

    using Northwind.EntityModels; // To use Product.
    using System.Net.Http.Json; // To use ReadFromJsonAsync<T> method.
    Write("Enter a client name or press Enter: ");
    string? clientName = ReadLine();
    if (string.IsNullOrEmpty(clientName))
    {
      clientName = $"console-client-{Guid.NewGuid()}";
    }
    WriteLine($"X-Client-Id will be: {clientName}");
    HttpClient client = new();
    client.BaseAddress = new("https://localhost:5081");
    client.DefaultRequestHeaders.Accept.Add(new("application/json"));
    // Specify the rate limiting client id for this console app.
    client.DefaultRequestHeaders.Add("X-Client-Id", clientName);
    while (true)
    {
      WriteInColor(string.Format("{0:hh:mm:ss}: ", 
        DateTime.UtcNow), ConsoleColor.DarkGreen);
      int waitFor = 1; // Second.
      try
      {
        HttpResponseMessage response = await client.GetAsync("api/products");
        if (response.IsSuccessStatusCode)
        {
          Product[]? products = 
            await response.Content.ReadFromJsonAsync<Product[]>();
          if (products != null)
          {
            foreach (Product product in products)
            {
              Write(product.ProductName);
              Write(", ");
            }
            WriteLine();
          }
        }
        else
        {
          WriteInColor(string.Format("{0}: {1}", (int)response.StatusCode,
            await response.Content.ReadAsStringAsync()),
            ConsoleColor.DarkRed);
          WriteLine();
        }
      }
      catch (Exception ex)
      {
        WriteLine(ex.Message);
      }
      await Task.Delay(TimeSpan.FromSeconds(waitFor));
    } 
    
  7. 如果您的数据库服务器没有运行(例如,因为您正在 Docker、虚拟机或云中托管它),请确保启动它。

  8. 使用 https 配置不带调试启动 Northwind.WebApi.Service 项目。

  9. 不带调试启动 Northwind.WebApi.Client.Console 项目。

  10. 在控制台应用程序中,按 Enter 键生成基于 GUID 的客户端 ID。

  11. 再次不进行调试地使用 https 配置启动 Northwind.WebApi.Client.Console 项目,以便我们有两个客户端。

  12. 在控制台应用程序中,按 Enter 键生成基于 GUID 的客户端 ID。

  13. 注意,每个客户端在开始接收 429 状态代码之前可以发出两个请求,如下所示,并在 图 8.9 中显示:

    Enter a client name or press Enter:
    X-Client-Id will be: console-client-d54c61ba-66bb-4e39-9c1a-7af6e2bf647e
    07:32:18: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:32:20: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:32:21: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:22: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:23: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:24: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:25: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:26: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:27: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:28: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:32:29: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:32:30: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:31: 429: API calls quota exceeded! maximum admitted 2 per 10s.
    07:32:32: 429: API calls quota exceeded! maximum admitted 2 per 10s. 
    

图 8.9:超出其 Web 服务速率限制的控制台应用程序

  1. 停止两个控制台应用程序。保持 Web 服务运行。

  2. 在 Web 服务命令行中,注意显示每个来自控制台客户端的请求的 HTTP 日志,该请求以名为 X-Client-Id 的标头发送客户端 ID,请求被阻止,因为该客户端已超出配额,以及包含客户端应在重试前等待的秒数的名为 Retry-After 的标头的响应,如下所示,代码中已突出显示:

    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[1]
          Request:
          Protocol: HTTP/1.1
          Method: GET
          Scheme: https
          PathBase:
          Path: /api/products
          Accept: application/json
          Host: localhost:5081
     **X-Client-Id: console-client-d54c61ba-66bb-4e39-9c1a-7af6e2bf647e**
    info: AspNetCoreRateLimit.ClientRateLimitMiddleware[0]
     **Request get:/api/products from ClientId console-client-d54c61ba-66bb-4e39-9c1a-7af6e2bf647e has been blocked, quota 2/10s exceeded by 3\. Blocked by rule *, TraceIdentifier 0HMIKGNJQEK5P:0000000E. MonitorMode: False**
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[2]
          Response:
          StatusCode: 429
          Content-Type: text/plain
          **Retry-After: 6**
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[4]
          ResponseBody: API calls quota exceeded! maximum admitted 2 per 10s. 
    
  3. Northwind.WebApi.Client.Console 项目中,在 Program.cs 文件中,在将错误信息以深红色写入控制台之前,添加语句读取 Retry-After 标头以获取等待的秒数,如下所示,代码中已突出显示:

    **string** **retryAfter = response.Headers**
     **.GetValues(****"Retry-After"****).ToArray()[****0****];**
    **if** **(****int****.TryParse(retryAfter,** **out** **waitFor))**
    **{**
     **retryAfter =** **string****.Format(**
    **"I will retry after {0} seconds."****, waitFor);**
    **}**
    WriteInColor(string.Format("{0}: {1} {2}", (int)response.StatusCode,
      await response.Content.ReadAsStringAsync(), retryAfter),
      ConsoleColor.DarkRed); 
    

    注意 waitFor 变量是从 Retry-After 标头值设置的。这随后用于使用异步延迟暂停控制台应用程序,如下所示,代码中已突出显示:

    await Task.Delay(TimeSpan.FromSeconds(waitFor));

  4. 不进行调试地启动 Northwind.WebApi.Client.Console 项目。

  5. 在控制台应用程序中,按 Enter 键生成基于 GUID 的客户端 ID。

  6. 注意控制台应用程序现在将合理地等待建议的秒数,然后再进行对服务的下一次调用,如下所示,代码中已突出显示:

    Enter a client name:
    X-Client-Id will be: console-client-add7613f-51a9-4c4a-8ec7-0244203d2e19
    07:45:01: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:45:02: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:45:03: 429: API calls quota exceeded! maximum admitted 2 per 10s. I will retry after 8 seconds.
    07:45:11: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:45:12: Chai, Chang, Aniseed Syrup, Chef Anton's Cajun Seasoning, Grandma's Boysenberry Spread, Uncle Bob's Organic Dried Pears, Northwoods Cranberry Sauce, Ikura, Queso Cabrales, Queso Manchego La Pastora,
    07:45:13: 429: API calls quota exceeded! maximum admitted 2 per 10s. I will retry after 8 seconds. 
    
  7. 停止并重新启动 Northwind.WebApi.Client.Console 项目,不进行调试。

  8. 在控制台应用程序中,输入名称 dev-id-1,并注意速率限制不适用于此控制台应用程序客户端。这可能是一个内部开发人员的特殊账户。

  9. 停止并重新启动 Northwind.WebApi.Client.Console 项目,不进行调试。

  10. 在控制台应用程序中,输入名称 console-client-abc123,并注意此控制台应用程序客户端 ID 的速率限制不同,如下所示,代码中已突出显示:

    info: AspNetCoreRateLimit.ClientRateLimitMiddleware[0]
          Request get:/api/products from ClientId console-client-abc123 has been blocked, quota 5/10s exceeded by 1\. Blocked by rule *, TraceIdentifier 0HMIKGS1TPSHJ:00000006\. MonitorMode: False 
    

使用 ASP.NET Core 中间件进行速率限制

ASP.NET Core 7 引入了它自己的基本速率限制中间件,最初作为单独的 NuGet 包分发,但现在包含在 ASP.NET Core 中。它依赖于另一个 Microsoft 包,System.Threading.RateLimiting。它不如第三方包功能丰富,本书中不会介绍它,尽管我已在以下链接处编写了一个仅在线的章节:

github.com/markjprice/apps-services-net8/blob/main/docs/ch08-rate-limiting.md

你可以在以下链接中了解 ASP.NET Core 速率限制器:learn.microsoft.com/en-us/aspnet/core/performance/rate-limit

保护你的网络服务免受攻击很重要。那么,提高你的网络服务性能呢?我们能做些什么?

使用原生 AOT 提高启动时间和资源

原生 AOT 生成的应用和服务具有:

  • 自包含,意味着它们可以在未安装 .NET 运行时系统的计算机上运行。

  • 提前编译 (AOT) 为原生代码,意味着更快的启动时间和可能更小的内存占用。当你有很多实例(例如,在部署大规模可扩展的微服务时)频繁停止和启动时,这可以产生积极的影响。

原生 AOT 在发布时将中间代码IL)编译为原生代码,而不是在运行时使用即时编译器JIT)进行编译。但原生 AOT 应用和服务必须针对特定的运行时环境,如 Windows x64 或 Linux ARM。

由于原生 AOT 在发布时发生,因此在代码编辑器中调试和实时工作在项目上时,它使用运行时 JIT 编译器,而不是原生 AOT,即使你在项目中启用了 AOT!但与原生 AOT 不兼容的一些功能将被禁用或抛出异常,并启用源分析器以显示有关潜在代码不兼容性的警告。

原生 AOT 的限制

原生 AOT 存在限制,以下列出了一些:

  • 不允许动态加载程序集。

  • 不允许运行时代码生成,例如使用 System.Reflection.Emit

  • 它需要修剪,这有其自身的限制。

  • 程序集必须是自包含的,因此它们必须嵌入它们调用的任何库,这增加了它们的大小。

虽然你的应用和服务可能没有使用上述功能,但 .NET 本身的许多主要部分都使用了。例如,ASP.NET Core MVC(包括使用控制器的 Web API 服务)和 EF Core 通过运行时代码生成来实现其功能。

.NET 团队正在努力工作,尽可能快地将尽可能多的 .NET 与原生 AOT 兼容,.NET 8 仅在您使用最小 API 时包括对 ASP.NET Core 的基本支持,并且对 EF Core 没有支持。

我的猜测是 .NET 9 将包括对 ASP.NET Core MVC 和 EF Core 部分的支持,但可能需要到 .NET 10 我们才能自信地使用大多数 .NET,并知道我们可以使用原生 AOT 构建我们的应用和服务以获得这些好处。

原生 AOT 发布过程包括代码分析器,以警告你如果使用了任何不受支持的特性,但并非所有包都已被注释以与该特性良好协作。

最常用的注释来指示类型或成员不支持 AOT 是 [RequiresDynamicCode] 属性。

更多信息:您可以在以下链接中了解更多关于 AOT 警告的信息:learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/fixing-warnings.

反射和本地 AOT

反射经常用于运行时检查类型元数据、成员的动态调用以及代码生成。

本地 AOT 允许一些反射功能,但在本地 AOT 编译过程中进行的剪裁无法静态确定类型具有可能仅通过反射访问的成员。这些成员将被 AOT 移除,这会导致运行时异常。

良好实践:开发者必须使用 [DynamicallyAccessedMembers] 注解他们的类型,以指示仅通过反射动态访问的成员,因此应保留未剪裁。

本地 AOT for ASP.NET Core

.NET 7 仅支持 Windows 或 Linux 上的控制台应用程序和类库的本地 AOT。它不支持 macOS 或 ASP.NET Core。.NET 8 是第一个支持 macOS 和 ASP.NET Core 部分功能版本。

以下 ASP.NET Core 功能完全受支持:gRPCCORSHealthChecksHttpLoggingLocalizationOutputCachingRateLimitingRequestDecompressionResponseCachingResponseCompressionRewriteStaticFilesWebSockets

以下 ASP.NET Core 功能部分受支持:最小 API。

以下 ASP.NET Core 功能目前不受支持:MVC、Blazor Server、SignalR、身份验证(除 JWT 外)、会话和 SPA。

正如您之前所看到的,您通过将 HTTP 请求映射到 lambda 表达式来实现 ASP.NET Core 最小 API 网络服务,例如以下代码所示:

 app.MapGet("/", () => "Hello World!"); 

在运行时,ASP.NET Core 使用 RequestDelegateFactoryRDF)类将您的 MapX 调用转换为 RequestDelegate 实例。但这是动态代码,因此与本地 AOT 不兼容。

在 ASP.NET Core 8 中,当启用本地 AOT 时,运行时使用 RDF 被一个名为 请求委托生成器RDG)的源生成器所取代,该生成器执行类似的工作,但发生在编译时。这确保生成的代码可以被本地 AOT 发布过程静态分析。

更多信息:您可以在以下链接中学习如何创建自己的源生成器:github.com/markjprice/apps-services-net8/blob/main/docs/ch01-dynamic-code.md#creating-source-generators.

本地 AOT 的要求

对于不同的操作系统,还有额外的要求:

  • 在 Windows 上,您必须安装包含所有默认组件的 Visual Studio 2022 桌面开发与 C++ 工作负载。

  • 在 Linux 上,你必须安装.NET 运行时所依赖的库的编译器工具链和开发包。例如,对于 Ubuntu 18.04 或更高版本:sudo apt-get install clang zlib1g-dev

警告!跨平台原生 AOT 发布不受支持。这意味着你必须在你将部署的操作系统上运行发布。例如,你不能在 Linux 上发布原生 AOT 项目,然后将其在 Windows 上运行。

为项目启用原生 AOT

要在项目中启用原生 AOT 发布,请将<PublishAot>元素添加到项目文件中,如下所示,高亮显示的标记:

 <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
 **<PublishAot>****true****</PublishAot>** 

启用与原生 AOT 的 JSON 序列化

使用原生 AOT 进行 JSON 序列化需要使用System.Text.Json源生成器。所有作为参数或返回值传递的模型类型都必须在JsonSerializerContext中注册,如下所示:

[JsonSerializable(typeof(Product)] // A single Product.
[JsonSerializable(typeof(Product[]))] // An array of Products.
public partial class MyJsonSerializerContext : JsonSerializerContext { } 

您必须将自定义 JSON 序列化器上下文添加到服务依赖项中,如下所示:

builder.Services.ConfigureHttpJsonOptions(options =>
{
  options.SerializerOptions.AddContext<MyJsonSerializerContext>();
}); 

构建原生 AOT 项目

现在让我们看看使用新项目模板的一个实际例子:

  1. 在名为Chapter08的解决方案中,添加一个与原生 AOT 兼容的 Web 服务项目,如下所示:

    • 项目模板:ASP.NET Core Web API (native AOT) / webapiaot

      这是.NET 8 引入的新项目模板。它与Web API / webapi项目模板不同。由于原生 AOT 支持目前仅限于最小 API,因此它没有使用控制器选项。它也没有 HTTPS 选项,因为在云原生部署中 HTTPS 通常由反向代理处理。在 JetBrains Rider 中,选择ASP.NET Core Web 应用程序,然后选择类型Web API (native AOT)

    • 解决方案文件和文件夹:Chapter08

    • 项目文件和文件夹:Northwind.MinimalAot.Service

    • 启用 Docker:已清除。

    • 不要使用顶层语句:已清除。

    • Properties文件夹中,在launchSettings.json中,注意只配置了http;删除launchUrl并修改端口号为5083,如下所示,高亮显示的配置:

      {
        "$schema": "http://json.schemastore.org/launchsettings.json",
        "profiles": {
          "http": {
            "commandName": "Project",
            "dotnetRunMessages": true,
            "launchBrowser": true,
            "launchUrl": "",
            "applicationUrl": "http://localhost:**5083**",
            "environmentVariables": {
              "ASPNETCORE_ENVIRONMENT": "Development"
            }
          }
        }
      } 
      
  2. 在项目文件中,将不变全球化设置为false,将警告视为错误,注意原生 AOT 发布已启用,并为 ADO.NET 的 SQL Server 添加一个包引用,如下所示,高亮显示的标记:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <InvariantGlobalization>**false**</InvariantGlobalization>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
     **<PublishAot>****true****</PublishAot>**
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference Include=****"Microsoft.Data.SqlClient"** **Version=****"5.1.2"** **/>**
     **</ItemGroup>**
    </Project> 
    
  3. 添加一个名为Product.cs的新类文件,并修改其内容以定义一个类,该类仅代表从Products表中的每一行中我们想要的三列,如下所示:

    namespace Northwind.Models;
    public class Product
    {
      public int ProductId { get; set; }
      public string? ProductName { get; set; }
      public decimal? UnitPrice { get; set; }
    } 
    
  4. 添加一个名为NorthwindJsonSerializerContext.cs的新类文件。

  5. NorthwindJsonSerializerContext.cs中,定义一个类,该类允许将ProductProduct对象列表序列化为 JSON,如下所示:

    using System.Text.Json.Serialization; // To use JsonSerializerContext.
    using Northwind.Models; // To use Product.
    namespace Northwind.Serialization;
    [JsonSerializable(typeof(Product))]
    [JsonSerializable(typeof(List<Product>))]
    internal partial class NorthwindJsonSerializerContext
      : JsonSerializerContext { } 
    
  6. 删除Northwind.MinimalAot.Service.http文件。

  7. 添加一个名为WebApplication.Extensions.cs的新类文件。

  8. WebApplication.Extensions.cs中,为WebApplication类定义一个扩展方法,将一些 HTTP GET请求映射为返回纯文本响应,以及使用 ADO.NET 从 Northwind 数据库获取所有产品或最低价格产品的列表,如下所示代码:

    using Microsoft.Data.SqlClient; // To use SqlConnection and so on.
    using Northwind.Models; // To use Product.
    using System.Data; // To use CommandType.
    namespace Packt.Extensions;
    public static class WebApplicationExtensions
    {
      public static WebApplication MapGets(this WebApplication app)
      {
        // app.MapGet(pattern, handler);
        app.MapGet("/", () => "Hello from a native AOT minimal API web service.");
        app.MapGet("/products", GetProducts);
        app.MapGet("/products/{minimumUnitPrice:decimal?}", GetProducts);
        return app;
      }
      private static List<Product> GetProducts(decimal? minimumUnitPrice = null)
      {
        SqlConnectionStringBuilder builder = new();
        builder.InitialCatalog = "Northwind";
        builder.MultipleActiveResultSets = true;
        builder.Encrypt = true;
        builder.TrustServerCertificate = true;
        builder.ConnectTimeout = 10; // Default is 30 seconds.
        builder.DataSource = "."; // Local SQL Server
        builder.IntegratedSecurity = true;
        /*
        // To use SQL Server Authentication:
        builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");
        builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");
        builder.PersistSecurityInfo = false;
        */
        SqlConnection connection = new(builder.ConnectionString);
        connection.Open();
        SqlCommand cmd = connection.CreateCommand();
        cmd.CommandType = CommandType.Text;
        cmd.CommandText =
          "SELECT ProductId, ProductName, UnitPrice FROM Products";
        if (minimumUnitPrice.HasValue)
        {
          cmd.CommandText += " WHERE UnitPrice >= @minimumUnitPrice";
          cmd.Parameters.AddWithValue("minimumUnitPrice", minimumUnitPrice);
        }
        SqlDataReader r = cmd.ExecuteReader();
        List<Product> products = new();
        while (r.Read())
        {
          Product p = new()
          {
            ProductId = r.GetInt32("ProductId"),
            ProductName = r.GetString("ProductName"),
            UnitPrice = r.GetDecimal("UnitPrice")
          };
          products.Add(p);
        }
        r.Close();
        return products;
      }
    } 
    

    使用原生 AOT 时,我们无法使用 EF Core,因此我们正在使用较低级别的 ADO.NET SqlClient API。无论如何,这更快、更高效。在未来,也许在.NET 9 或.NET 10 中,我们将能够使用我们的 EF Core 模型来处理 Northwind。

  9. Program.cs中,注意对CreateSlimBuilder方法的调用,这确保默认情况下只启用 ASP.NET Core 的基本功能,因此它最小化了部署的 web 服务大小,如下所示代码:

    var builder = WebApplication.CreateSlimBuilder(args); 
    

    CreateSlimBuilder方法不包括对 HTTPS 或 HTTP/3 的支持,尽管如果你需要,你可以自己添加这些。它支持appsettings.json的 JSON 文件配置和日志记录。

  10. Program.cs中,在调用builder.Build()之后,删除生成一些示例待办事项和映射一些端点的语句,如下所示代码:

    var sampleTodos = new Todo[]
    {
      new(1, "Walk the dog"),
      new(2, "Do the dishes", DateOnly.FromDateTime(DateTime.Now)),
      new(3, "Do the laundry", DateOnly.FromDateTime(DateTime.Now.AddDays(1))),
      new(4, "Clean the bathroom"),
      new(5, "Clean the car", DateOnly.FromDateTime(DateTime.Now.AddDays(2)))
    };
    var todosApi = app.MapGroup("/todos");
    todosApi.MapGet("/", () => sampleTodos);
    todosApi.MapGet("/{id}", (int id) =>
        sampleTodos.FirstOrDefault(a => a.Id == id) is { } todo
            ? Results.Ok(todo)
            : Results.NotFound()); 
    
  11. Program.cs文件底部,删除定义Todo记录和AppJsonSerializerContext类的语句,如下所示代码:

    public record Todo(int Id, string? Title, DateOnly? DueBy = null, bool IsComplete = false);
    [JsonSerializable(typeof(Todo[]))]
    internal partial class AppJsonSerializerContext : JsonSerializerContext
    {
    } 
    
  12. Program.cs中,删除命名空间导入,导入我们的 JSON 序列化上下文和扩展方法的命名空间,修改插入 JSON 序列化上下文的语句以使用 Northwind 的,然后在运行 web app之前调用MapGets方法,如下所示高亮显示的代码:

    **using** **Northwind.Serialization;**
    **using** **Packt.Extensions;** **// To use MapGets().**
    var builder = WebApplication.CreateSlimBuilder(args);
    builder.Services.ConfigureHttpJsonOptions(options =>
    {
      options.SerializerOptions.TypeInfoResolverChain
        .Insert(0, **NorthwindJsonSerializerContext**.Default);
    });
    var app = builder.Build();
    **app.MapGets();**
    app.Run(); 
    
  13. 如果你的数据库服务器没有运行(例如,因为你正在 Docker、虚拟机或云中托管它),那么请确保启动它。

  14. 使用http配置文件启动 web 服务项目:

    • 如果你正在使用 Visual Studio 2022,则在下拉列表中选择http配置文件,然后导航到调试 | 不调试启动或按Ctrl + F5

    • 如果你正在使用 Visual Studio Code,则输入命令dotnet run --launch-profile http,手动启动一个 web 浏览器,并导航到 web 服务:https://localhost:5083/

  15. 在 web 浏览器中,注意纯文本响应:Hello from a native AOT minimal API web service.

  16. 在地址栏中添加 /products,并注意响应中显示的产品数组,如下所示的部分输出:

    [{"productId":1,"productName":"Chai","unitPrice":18.0000},
     {"productId":2,"productName":"Chang","unitPrice":19.0000},
     {"productId":3,"productName":"Aniseed Syrup","unitPrice":10.0000},
     {"productId":4,"productName":"Chef Anton's Cajun Seasoning",
      "unitPrice":22.0000},
     {"productId":5,"productName":"Chef Anton's Gumbo Mix",
      "unitPrice":21.3500},
     {"productId":6,"productName":"Grandma's Boysenberry Spread",
      "unitPrice":25.0000},
     {"productId":7,"productName":"Uncle Bob's Organic Dried Pears",
      "unitPrice":30.0000},
     {"productId":8,"productName":"Northwoods Cranberry Sauce",
      "unitPrice":40.0000},
     {"productId":9,"productName":"Mishi Kobe Niku","unitPrice":97.0000},
     {"productId":10,"productName":"Ikura","unitPrice":31.0000},
     {"productId":11,"productName":"Queso Cabrales","unitPrice":21.0000},
     {"productId":12,"productName":"Queso Manchego La Pastora",
      "unitPrice":38.0000}, 
    
  17. 在地址栏中添加 /products/100,并注意响应中的两个产品数组,如下所示的部分输出:

    [{"productId":29,"productName":"Thüringer Rostbratwurst","unitPrice":123.7900},{"productId":38,"productName":"Côte de Blaye","unitPrice":263.5000}] 
    
  18. 关闭浏览器并关闭 web 服务器。

发布原生 AOT 项目

在开发期间,当服务未经修剪且经过即时编译(JIT)时,功能正常的服务在发布时使用原生 AOT 可能仍然会失败。因此,在假设项目可以工作之前,你应该先进行发布。

如果您的项目在发布时没有产生任何 AOT 警告,那么您可以有信心,您的服务在发布后将会正常工作。

让我们回顾一下源生成的代码并发布我们的网络服务:

  1. Northwind.MinimalAot.Service项目文件中,添加一个元素以输出编译器生成的文件,如下所示突出显示的标记:

    <PropertyGroup>
      <TargetFramework>net8.0</TargetFramework>
      ...
     **<EmitCompilerGeneratedFiles>****true****</EmitCompilerGeneratedFiles>**
    </PropertyGroup> 
    
  2. 构建项目Northwind.MinimalAot.Service

  3. 如果您正在使用 Visual Studio 2022,请在解决方案资源管理器中切换显示所有文件

  4. 展开文件夹obj\Debug\net8.0\generated,并注意源生成器为 AOT 和 JSON 序列化创建的文件夹和文件,并注意您将在接下来的几个步骤中打开其中一些文件,如图 8.10 所示:

图 8.10:AOT 网络服务项目中源生成器创建的文件夹和文件

  1. 打开GeneratedRouteBuilderExtensions.g.cs文件,并注意它包含用于定义最小 API 网络服务的映射路由的代码。

  2. 打开NorthwindJsonSerializerContext.Decimal.g.cs文件,并注意它包含用于将decimal值序列化为作为最小单位价格参数传递给路由之一的代码。

  3. 打开NorthwindJsonSerializerContext.ListProduct.g.cs文件,并注意它包含用于将Product对象列表序列化为响应的代码,这些对象是两条路由之一返回的。

  4. Northwind.MinimalAot.Service文件夹中,在命令提示符或终端中,使用本地 AOT 发布网络服务,如下所示命令:

    dotnet publish 
    
  5. 注意有关生成本地代码以及针对Microsoft.Data.SqlClient等包的裁剪警告的消息,如下部分输出所示:

    Generating native code
    C:\Users\markj\.nuget\packages\microsoft.data.sqlclient\5.1.1\runtimes\win\lib\net6.0\Microsoft.Data.SqlClient.dll : warning IL2104: Assembly 'Microsoft.Data.SqlClient' produced trim warnings. For more information see https://aka.ms/dotnet-illink/libraries [C:\apps-services-net8\Chapter08\Northwind.MinimalAot.Service\Northwind.MinimalAot.Service.csproj]
    /_/src/libraries/System.Data.Common/src/System/Data/DataTable.cs(6704): Trim analysis warning IL2026: System.Data.DataTable.System.Xml.Serialization.IXmlSerializable.ReadXml(XmlReader): Using member 'System.Data.DataTable.ReadXmlSerializableInternal(XmlReader)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming application code. DataTable.ReadXml uses XmlSerialization underneath which is not trimming safe. Members from serialized types may be trimmed if not referenced directly. [C:\apps-services-net8\Chapter08\Northwind.MinimalAot.Service\Northwind.MinimalAot.Service.csproj] 
    

    我们没有使用DataTable.ReadXml方法,该方法调用一个可能被裁剪的成员,因此我们可以忽略前面的警告。

  6. 启动文件资源管理器并打开bin\Release\net8.0\win-x64\publish文件夹,并注意 EXE 文件大约有 30MB。这些以及Microsoft.Data.SqlClient.SNI.dll文件是需要在另一台 Windows 计算机上部署以使网络服务正常工作的唯一文件。appsettings.json文件仅在需要覆盖配置时才需要。PDB 文件仅在调试时需要。

  7. 运行Northwind.MinimalAot.Service.exe,并注意网络服务启动非常快,它默认将使用端口5000,如图 8.11 所示:

图 8.11:文件资源管理器显示已发布的可执行文件和正在运行 AOT 网络服务

  1. 启动一个网页浏览器,导航到http://localhost:5000/,并注意网络服务通过返回纯文本响应来正确工作。

  2. 导航到http://localhost:5000/products/100,并注意网络服务响应包含最小单位价格为 100 的两个产品。

  3. 关闭网页浏览器并关闭网络服务。

  4. Northwind.WebApi.Service项目文件中,在命令提示符或终端中发布网络服务,如下所示命令:

    dotnet publish 
    
  5. 启动 文件资源管理器,打开 bin\Release\net8.0\win-x64\publish 文件夹,并注意 Northwind.WebApi.Service.exe 文件小于 154 KB。这是因为它是框架依赖的,意味着它需要在计算机上安装 .NET 才能工作。此外,还有许多必须与 EXE 文件一起部署的文件,总大小约为 14 MB。

  6. 运行 Northwind.MinimalAot.Service.exe 并注意网络服务启动速度比 AOT 版本慢,并且它将默认使用端口 5000

  7. 启动网页浏览器,导航到 http://localhost:5000/,并注意通过返回纯文本响应,该网络服务运行正常。

  8. 导航到 http://localhost:5000/products/100,并注意网络服务响应了两个最小单位价格为 100 的产品,但响应速度比 AOT 版本慢。

  9. 关闭网页浏览器并关闭网络服务。

许多 .NET 开发者已经期待 AOT 编译很长时间了。微软终于兑现了承诺,并且它将在接下来的几个主要版本中扩展以覆盖更多项目类型,因此这是一个需要关注的科技。

理解身份服务

身份服务用于验证和授权用户。对于这些服务实现开放标准非常重要,这样您就可以集成不同的系统。常见的标准包括 OpenID ConnectOAuth 2.0

微软没有计划正式支持像 IdentityServer4 这样的第三方身份验证服务器,因为“创建和维护一个身份验证服务器是一项全职工作,微软已经在该领域有一个团队和一个产品,即 Azure Active Directory,它允许免费使用 500,000 个对象。”

JWT 承载授权

JSON Web Token (JWT) 是一个标准,它定义了一种紧凑且安全的方法来以 JSON 对象的形式传输信息。该 JSON 对象经过数字签名,因此可以信任。使用 JWT 最常见的场景是授权。

用户使用用户名和密码或生物识别扫描或双因素认证等凭证登录到受信任方,受信任方颁发 JWT。然后,它将与每个请求一起发送到安全的网络服务。

在其紧凑形式中,JWT 由三个部分组成,由点分隔。这些部分是 头部负载签名,如下所示:aaa.bbb.ccc。头部和负载是 Base64 编码的。

使用 JWT 承载身份验证验证服务客户端

在本地开发过程中,使用 dotnet user-jwts 命令行工具来创建和管理本地 JWT。这些值存储在本地机器用户配置文件文件夹中的 JSON 文件中。

让我们使用 JWT 承载身份验证来保护网络服务,并使用本地令牌进行测试:

  1. Northwind.WebApi.Service 项目中,添加对 JWT 承载身份验证包的引用,如下所示:

    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer"
                      Version="8.0.0" /> 
    
  2. 构建 Northwind.WebApi.Service 项目以恢复包。

  3. Program.cs 中,在创建 builder 之后,添加语句以使用 JWT 添加授权和身份验证,如下所示(高亮显示):

    var builder = WebApplication.CreateBuilder(args);
    **builder.Services.AddAuthorization();**
    **builder.Services.AddAuthentication(defaultScheme:** **"Bearer"****)**
     **.AddJwtBearer();** 
    
  4. Program.cs 中,在构建应用程序之后,添加一个语句来使用授权,如下所示(高亮显示):

    var app = builder.Build();
    **app.UseAuthorization();** 
    
  5. WebApplication.Extensions.cs 中,导入安全声明的命名空间,如下所示:

    using System.Security.Claims; // To use ClaimsPrincipal. 
    
  6. WebApplication.Extensions.cs 中,在将根路径的 HTTP GET 请求映射到返回纯文本 Hello World 响应之后,添加一个语句将秘密路径的 HTTP GET 请求映射到如果授权则返回认证用户的名称,如下所示:

    app.MapGet("/", () => "Hello World!")
      .ExcludeFromDescription();
    **app.MapGet(****"/secret"****, (ClaimsPrincipal user) =>** 
    **string****.Format(****"Welcome, {0}. The secret ingredient is love."****,**
     **user.Identity?.Name ??** **"secure user"****))**
     **.RequireAuthorization();** 
    
  7. Northwind.WebApi.Service 项目文件夹中,在命令提示符或终端中,创建一个本地 JWT,如下所示:

    dotnet user-jwts create 
    
  8. 注意自动分配的 IDNameToken,如下所示的部分输出:

    New JWT saved with ID 'd7e22000'.
    Name: markjprice
    Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6Im1hcmtqcHJpY2UiLCJzdWIiOiJtYXJran...lci1qd3RzIn0.pGEbYKRjU98dEjxLSx7GAEm41LXMS0J80iIjuZbqrj4 
    
  9. 在命令提示符或终端中,打印分配的 ID 的所有信息,如下所示:

    dotnet user-jwts print d7e22000 --show-all 
    
  10. 注意方案是 Bearer,因此必须在每次请求中发送令牌,受众列表列出了授权客户端域名和端口号,令牌在三个月后过期,JSON 对象表示头和有效负载,最后是紧凑型令牌,其 Base64 编码的三部分由点分隔,如下所示的部分输出:

    Found JWT with ID 'd7e22000'.
    ID: d7e22000
    Name: markjprice
    Scheme: Bearer
    Audience(s): http://localhost:30225, https://localhost:44344, http://localhost:5080, https://localhost:5081
    Not Before: 2023-09-26T10:58:18.0000000+00:00
    Expires On: 2023-12-26T10:58:18.0000000+00:00
    Issued On: 2023-09-26T10:58:19.0000000+00:00
    Scopes: none
    Roles: [none]
    Custom Claims: [none]
    Token Header: {"alg":"HS256","typ":"JWT"}
    Token Payload: {"unique_name":"markjprice","sub":"markjprice","jti":"d7e22000","aud":["http://localhost:30225","https://localhost:44344","http://localhost:5080","https://localhost:5081"],"nbf":1664189898,"exp":1672052298,"iat":1664189899,"iss":"dotnet-user-jwts"}
    Compact Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6Im1hcmtqcHJpY2UiLCJzdWIiOiJtYXJranByaWNl...uZXQtdXNlci1qd3RzIn0.pGEbYKRjU98dEjxLSx7GAEm41LXMS0J80iIjuZbqrj4 
    
  11. Northwind.WebApi.Service 项目中,在 appsettings.Development.json 中,注意名为 Authentication 的新部分,如下所示(高亮显示):

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning",
          "Microsoft.AspNetCore.HttpLogging": "Information"
        }
      },
    **"Authentication"****:****{**
    **"Schemes"****:****{**
    **"Bearer"****:****{**
    **"ValidAudiences"****:****[**
    **"http://localhost:30225"****,**
    **"https://localhost:44344"****,**
    **"****http://localhost:5080"**
    **"https://localhost:5081"**
    **],**
    **"ValidIssuer"****:****"dotnet-user-jwts"**
    **}**
    **}**
    **}**
    } 
    
  12. 使用无调试的 https 配置启动 Northwind.WebApi.Service 项目。

  13. 在浏览器中,将相对路径更改为 /secret 并注意响应被拒绝,状态码为 401。

  14. 启动 Visual Studio Code 并打开 HttpRequests 文件夹。

  15. HttpRequests 文件夹中,创建一个名为 webapi-secure-request.http 的文件,并修改其内容以包含获取秘密成分的请求,如下所示(但当然使用您的 Bearer 令牌):

    ### Get the secret ingredient.
    GET https://localhost:5081/secret/
    Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1bmlxdWVfbmFtZSI6Im1hcmtqcHJpY2UiLCJzdWIiOiJtYXJranByaWNl...uZXQtdXNlci1qd3RzIn0.pGEbYKRjU98dEjxLSx7GAEm41LXMS0J80iIjuZbqrj4 
    
  16. 点击 发送请求,并注意响应,如下所示:

    Welcome, secure user. The secret ingredient is love. 
    
  17. 关闭浏览器并关闭网络服务。

练习和探索

通过回答一些问题、进行一些实际操作练习,以及更深入地研究本章主题来测试您的知识和理解。

练习 8.1 – 测试您的知识

回答以下问题:

  1. 列出六个可以在 HTTP 请求中指定的方法名。

  2. 列出六个可以返回的 HTTP 响应状态码及其描述。

  3. ASP.NET Core Minimal APIs 服务技术与 ASP.NET Core Web APIs 服务技术有何不同?

  4. 使用 ASP.NET Core Minimal APIs 服务技术,您如何将 HTTP PUT 请求映射到 api/customers 的 lambda 语句块?

  5. 使用 ASP.NET Core 最小 API 服务技术,您如何将方法或 lambda 参数映射到路由、查询字符串或请求体中的值?

  6. 启用 CORS 是否会增加 Web 服务的安全性?

  7. 您已在 Program.cs 中添加了语句以启用 HTTP 日志记录,但 HTTP 请求和响应并未被记录。最可能的原因是什么,以及如何修复它?

  8. 您如何使用 AspNetCoreRateLimit 包限制特定客户端的请求数量?

  9. 您如何使用 Microsoft.AspNetCore.RateLimiting 包限制特定端点的请求数量?

  10. JWT 代表什么?

练习 8.2 – 审查 Microsoft HTTP API 设计策略

微软有内部 HTTP/REST API 设计指南。微软团队在设计他们的 HTTP API 时会参考此文档。它们是您自己 HTTP API 标准的绝佳起点。您可以在以下链接中查看它们:

github.com/microsoft/api-guidelines

指南中有一个专门针对 CORS 的部分,您可以通过以下链接查看它们:

github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md#8-cors

练习 8.3 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-8---building-and-securing-web-services-using-minimal-apis

练习 8.4 – 通过 OData 服务公开数据

在本在线章节中学习如何快速实现一个可以使用 OData 包装 EF Core 实体模型的 Web API 服务:

github.com/markjprice/apps-services-net8/blob/main/docs/ch08-odata.md

练习 8.5 – Auth0 项目模板

如果您需要实现 Auth0 进行身份验证和授权,则可以使用项目模板来生成代码。描述这些项目模板及其使用方法的文章可在以下链接中找到:

auth0.com/blog/introducing-auth0-templates-for-dotnet/

摘要

在本章中,你学习了如何:

  • 构建一个实现 REST 架构风格的 Web 服务,使用最小 API。

  • 使用 CORS 释放特定域名和端口的同源安全策略。

  • 实现两个不同的速率限制包以防止拒绝服务攻击。

  • 使用 JWT 持有者授权来保护服务。

在下一章中,您将学习如何通过添加缓存、队列和自动处理短暂故障等特性,使用 Polly 等库构建可靠和可扩展的服务。

第九章:缓存、队列和弹性后台服务

在本章中,你将了解多种技术和技巧,这些技术和技巧将提高你服务的可扩展性和可靠性,无论你选择哪种服务技术来实现。

本章将涵盖以下主题:

  • 理解服务架构

  • 使用 ASP.NET Core 进行缓存

  • 使用 Polly 实现容错

  • 使用 RabbitMQ 进行队列

  • 实现长时间运行的后台服务

理解服务架构

第八章使用最小 API 构建和保障 Web 服务 中,你学习了如何使用 ASP.NET Core 最小 API 构建一个 Web 服务。在查看构建服务的替代技术之前,值得退一步回顾服务架构以及是什么原因导致服务性能和可扩展性的瓶颈。

系统中最慢的部分是什么?

传统上,系统中最慢的部分是:

  • 网络(最慢)

  • 磁盘

  • 内存

  • CPU 缓存内存(最快)

每一步可能比下一步慢 5 到 10 倍。

然而,网络比以前快得多,系统通常在远程数据中心运行。想象一下,如果你的服务需要一些数据,是直接从本地服务器磁盘读取更快,还是调用另一个服务器更快?

  • 同一数据中心内的服务器到服务器调用:500,000 纳秒(ns)

  • 磁盘寻道:10,000,000 ns

每个开发者都应该知道的数字

谷歌公司高级工程师杰夫·迪恩在他的演讲中引用了各种技术访问或读取数据的实际纳秒(ns)时间。它们显示在 表 9.1 中,并且我添加了一列来将数字换算成更易于人类理解的单位:

技术 实际 人性化
CPU 周期 0.1 ns 1 秒
L1 缓存引用 ½ ns 5 秒
L2 缓存引用 5 ns 1 分钟
锁定/解锁互斥锁 25 ns 4 分钟
主内存引用 100 ns ¼ 小时
在 1 Gbps 网络上发送 1K 字节 10,000 ns 28 小时
从内存中顺序读取 1 MB 250,000 ns 29 天
同一数据中心内的往返 500,000 ns 2 个月
从 SSD 顺序读取 1 MB 1,000,000 ns 4 个月
磁盘寻道 10,000,000 ns 3¼ 年
从磁盘顺序读取 1 MB 20,000,000 ns 6¼ 年
从加拿大到荷兰的 CA 发送数据包 150,000,000 ns 47½ 年

表 9.1:各种技术访问或读取数据的纳秒时间

更多信息:杰夫·迪恩所著的 构建大型分布式系统的设计、经验和建议www.cs.cornell.edu/projects/ladis2009/talks/dean-keynote-ladis2009.pdf

重点是不要争论从驱动器或 SSD 读取是否比网络调用更快,更重要的是要意识到获取近处数据和远处数据的差异。数量级上的差异是相当巨大的。

例如,如果一个 CPU 需要处理一些数据,并且它已经在 L1 缓存中,那么它只需要相当于 1 秒的时间。如果它需要从内存中读取数据,那么它需要相当于四分之一小时的时间。如果它需要从同一数据中心的服务器中获取数据,那么它需要相当于 2 个月的时间。如果它在加利福尼亚,而数据在荷兰,那么它需要相当于 47½ 年的时间!

这就是为什么缓存如此重要的原因。缓存是将数据临时存储在尽可能接近需要的地方。

使用 ASP.NET Core 进行缓存

缓存可以使我们的系统从远程数据中心复制一些数据到本地数据中心,或者从服务器或磁盘到内存。缓存以键值对的形式存储数据。

然而,缓存中最困难的部分之一是找到存储足够数据和保持数据新鲜之间的平衡。我们复制的越多,我们使用的资源就越多。我们需要考虑如何保持副本与原始数据的一致性。

通用缓存指南

缓存最适合成本高昂且不经常更改的数据。

在缓存时遵循以下指南:

  • 你的代码永远不应该依赖于缓存数据。当数据在缓存中找不到时,它应该始终能够从原始源获取数据。

  • 无论你在哪里缓存数据(内存或数据库),它都是有限的资源,因此应通过实现过期和大小限制来故意限制缓存的数据量和缓存时间。你应该监控缓存命中(当数据在缓存中成功找到时)以获得特定场景的正确平衡。

在本节中的编码任务中,你将实现所有这些指南。

让我们先回顾一下 ASP.NET Core 内置的缓存技术。

构建基于控制器的 Web API 服务

为了探索各种缓存技术,让我们构建一个基本的 Web 服务:

  1. 使用你喜欢的代码编辑器创建一个新的基于控制器的 Web API 项目,如下所示:

    • 项目模板:ASP.NET Core Web API / webapi --use-controllers

    • 解决方案文件和文件夹:Chapter09

    • 项目文件和文件夹:Northwind.WebApi.Service

    • 身份验证类型

    • 配置 HTTPS:已选择

    • 启用 Docker:已清除

    • 使用控制器:已选择

    • 启用 OpenAPI 支持:已选择

    • 不使用顶层语句:已清除

    确保选择 使用控制器 复选框或指定 --use-controllers-controllers 开关。我们不会使用最小 API,这是使用 .NET 8 项目模板实现 Web API 的默认方式。如果你使用 JetBrains Rider,你可能想使用 dotnet new 命令,直到 Rider 支持一个 使用控制器 选项。

  2. 将项目引用添加到你在 第三章使用 EF Core 为 SQL Server 构建实体模型 中创建的 Northwind 数据库上下文项目,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    路径不能有换行符。如果你没有完成 第三章 中创建类库的任务,那么请从 GitHub 仓库下载解决方案项目。

  3. 在项目文件中,将不变全球化更改为 false,并将警告视为错误,如下标记所示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <InvariantGlobalization>**false**</InvariantGlobalization>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup> 
    

    显式地将不变全球化设置为 true 是 .NET 8 的 ASP.NET Core Web API 项目模板中的新功能。它旨在使 web 服务不受文化限制,以便可以在世界任何地方部署并具有相同的行为。通过将此属性设置为 false,web 服务将默认为当前托管计算机的文化。你可以在以下链接中了解更多关于不变全球化模式的信息:github.com/dotnet/runtime/blob/main/docs/design/features/globalization-invariant-mode.md

  4. 在命令提示符或终端中,构建 Northwind.WebApi.Service 项目以确保当前解决方案之外的实体模型类库项目被正确编译,如下命令所示:

    dotnet build 
    
  5. Properties 文件夹中,在 launchSettings.json 中,将名为 https 的配置文件的 applicationUrl 修改为使用端口 5091 用于 https 和端口 5092 用于 http,如下配置所示:

    "profiles": {
      ...
    **"****https"****:****{**
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "launchUrl": "swagger",
    **"applicationUrl"****:****"https://localhost:5091;http://localhost:5092"****,**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        } 
    

    Visual Studio 2022 和 JetBrains Rider 将读取此设置文件,如果 launchBrowsertrue,则自动运行一个浏览器,并导航到 applicationUrllaunchUrl。Visual Studio Code 和 dotnet run 不会这样做,因此你需要手动运行一个浏览器并手动导航到 localhost:5091/swagger

  6. 删除名为 WeatherForecast.cs 的文件。

  7. Controllers 文件夹中,删除名为 WeatherForecastController.cs 的文件。

  8. Program.cs 中,导入命名空间以将 NorthwindContext 添加到配置的服务中,如下代码所示:

    **using** **Northwind.EntityModels;** **// To use the AddNorthwindContext method.**
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    **builder.Services.AddNorthwindContext();**
    builder.Services.AddControllers(); 
    
  9. Controllers 文件夹中,添加一个名为 ProductsController.cs 的新类文件。

  10. ProductsController.cs 中,修改其内容以定义一个基于控制器的 Web API,用于与 Northwind 数据库中的产品一起工作,就像我们对最小 API 所做的那样,如下代码所示:

    using Microsoft.AspNetCore.Mvc; // To use [HttpGet] and so on.
    using Northwind.EntityModels; // To use NorthwindContext, Product.
    namespace Northwind.WebApi.Service.Controllers;
    [Route("api/products")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
      private int pageSize = 10;
      private readonly ILogger<ProductsController> _logger;
      private readonly NorthwindContext _db;
      public ProductsController(ILogger<ProductsController> logger, 
        NorthwindContext context)
      {
        _logger = logger;
        _db = context;
      }
      // GET: api/products
      [HttpGet]
      [Produces(typeof(Product[]))]
      public IEnumerable<Product> Get(int? page)
      {
        return _db.Products
          .Where(p => p.UnitsInStock > 0 && !p.Discontinued)
          .OrderBy(product => product.ProductId)
          .Skip(((page ?? 1) - 1) * pageSize)
          .Take(pageSize);
      }
      // GET: api/products/outofstock
      [HttpGet]
      [Route("outofstock")]
      [Produces(typeof(Product[]))]
      public IEnumerable<Product> GetOutOfStockProducts()
      {
        return _db.Products
          .Where(p => p.UnitsInStock == 0 && !p.Discontinued);
      }
      // GET: api/products/discontinued
      [HttpGet]
      [Route("discontinued")]
      [Produces(typeof(Product[]))]
      public IEnumerable<Product> GetDiscontinuedProducts()
      {
        return _db.Products
          .Where(product => product.Discontinued);
      }
      // GET api/products/5
      [HttpGet("{id:int}")]
      public async ValueTask<Product?> Get(int id)
      {
        return await _db.Products.FindAsync(id);
      }
      // GET api/products/cha
      [HttpGet("{name}")]
      public IEnumerable<Product> Get(string name)
      {
        return _db.Products.Where(p => p.ProductName.Contains(name));
      }
      // POST api/products
      [HttpPost]
      public async Task<IActionResult> Post([FromBody] Product product)
      {
        _db.Products.Add(product);
        await _db.SaveChangesAsync();
        return Created($"api/products/{product.ProductId}", product);
      }
      // PUT api/products/5
      [HttpPut("{id}")]
      public async Task<IActionResult> Put(int id, [FromBody] Product product)
      {
        Product? foundProduct = await _db.Products.FindAsync(id);
        if (foundProduct is null) return NotFound();
        foundProduct.ProductName = product.ProductName;
        foundProduct.CategoryId = product.CategoryId;
        foundProduct.SupplierId = product.SupplierId;
        foundProduct.QuantityPerUnit = product.QuantityPerUnit;
        foundProduct.UnitsInStock = product.UnitsInStock;
        foundProduct.UnitsOnOrder = product.UnitsOnOrder;
        foundProduct.ReorderLevel = product.ReorderLevel;
        foundProduct.UnitPrice = product.UnitPrice;
        foundProduct.Discontinued = product.Discontinued;
        await _db.SaveChangesAsync();
        return NoContent();
      }
      // DELETE api/products/5
      [HttpDelete("{id}")]
      public async Task<IActionResult> Delete(int id)
      {
        if (await _db.Products.FindAsync(id) is Product product)
        {
          _db.Products.Remove(product);
          await _db.SaveChangesAsync();
          return NoContent();
        }
        return NotFound();
      }
    } 
    
  11. 如果你的数据库服务器没有运行,例如,因为你正在 Docker、虚拟机或云中托管它,那么请确保启动它。

  12. 使用 https 配置文件不调试启动 web 服务项目。

    • 如果你正在使用 Visual Studio 2022,则在下拉列表中选择 https 配置文件,然后导航到 调试 | 不调试启动 或按 Ctrl + F5。浏览器应自动导航到 Swagger 文档网页。

    • 如果你正在使用 Visual Studio Code,那么输入命令 dotnet run --launch-profile https,手动启动一个网页浏览器,并导航到 Swagger 文档网页:localhost:5091/swagger

    在 Windows 上,如果需要,你必须将 Windows Defender 防火墙设置为允许访问你的本地 Web 服务。

  13. 使用 Swagger 测试各种端点,并注意你这样做时记录到输出的 SQL 语句,例如:

    • 获取 10 个产品中的第一页。

    • 获取 10 个产品中的第 6 页。

    • 获取 ID 为 77 的产品。

    • 获取单个缺货产品。

    • 获取 7 个已停售的产品。

    • 获取名称以 cha 开头的产品。

    • 创建(POST)、更新(PUT)和删除一个产品。有关如何执行这些测试的提示,请阅读以下链接:github.com/markjprice/apps-services-net8/blob/main/docs/ch09-swagger-tests.md

如果你完成了 第八章使用 Minimal APIs 构建和保障 Web 服务,那么你就可以使用我们创建的 .http 文件来测试最小 API Web 服务,而无需手动使用 Swagger。只需将端口从 5081 更改为 5091

现在我们已经有一个基本的 Web 服务,我们可以在其中开始启用缓存。

使用内存缓存缓存对象

IMemoryCache 接口表示一个使用本地服务器内存的缓存。如果你有多个服务器托管你的服务或网站,那么你必须启用“粘性会话”。这意味着来自客户端或访客的传入请求将被定向到与该客户端或访客之前请求相同的服务器,从而使请求能够在该服务器的内存中找到正确的缓存数据。

Microsoft.Extensions.Caching.Memory 包实现了 IMemoryCache 的现代版本。避免使用较旧的 System.Runtime.Caching

大小使用自定义单位定义。如果你存储简单的 string 值,那么你可以使用字符串的长度。如果你不知道大小,你可以为每个条目使用 1 个单位来简单地限制条目数量。

当你将对象添加到缓存中时,你应该设置一个过期时间。有两种类型,绝对和滑动,你可以设置一个或两个,或者都不设置:

  • 绝对过期:这是一个固定的日期/时间,例如,2023 年 12 月 24 日凌晨 1 点。当日期/时间到达时,对象将被移除。要使用此功能,请将缓存条目的 AbsoluteExpiration 属性设置为 DateTime 值。如果你需要保证缓存中的数据在某个时间点被刷新,请选择此选项。

  • 滑动过期:这是一个时间跨度,例如,20 秒。当时间跨度过期时,对象会被移除。然而,每当从缓存中读取对象时,其过期时间会被重置为另一个 20 秒。这就是为什么它被称为 滑动。对于 内容管理系统CMS),其中像网页这样的内容是从数据库加载的,常见的持续时间是 12 小时。被访客频繁查看的内容,如主页,因此很可能保留在内存中。要使用此功能,请将缓存条目的 SlidingExpiration 属性设置为 TimeSpan 值。如果你可以接受数据可能永远不会刷新,请选择此选项。一个好的 CMS 将具有一个额外的机制,在发布新内容时可靠地强制刷新,但此功能不是内置在 .NET 缓存中的。

  • 两种过期方式:如果你只设置了滑动过期,一个对象可能会永远留在缓存中,因此你可能还想将 AbsoluteExpirationRelativeToNow 属性设置为未来的一个 TimeSpan,在此之后对象应该肯定会被移除。如果你想要两者兼得,请选择此选项。

  • 永不:你可以设置缓存条目具有 CacheItemPriority.NeverRemove 的优先级。

你还可以配置一个方法,当对象从缓存中移除时调用。这允许你执行一些业务逻辑来决定是否要将对象重新添加到缓存中,可能是在从原始数据源刷新之后。你可以通过调用 RegisterPostEvictionCallback 方法来完成此操作。

让我们探索内存缓存:

  1. Northwind.WebApi.Service 项目中,在 Program.cs 文件中,导入命名空间以使用内存缓存,如下面的代码所示:

    using Microsoft.Extensions.Caching.Memory; // To use IMemoryCache and so on. 
    
  2. Program.cs 文件中,在调用 CreateBuilder 之后,在配置服务的部分中,注册内存缓存的实现,配置为存储最多 50 个产品,如下面的代码所示:

    builder.Services.AddSingleton<IMemoryCache>(new MemoryCache(
      new MemoryCacheOptions
      {
        TrackStatistics = true,
        SizeLimit = 50 // Products.
      })); 
    
  3. ProductsController.cs 文件中,导入命名空间以使用内存缓存,如下面的代码所示:

    using Microsoft.Extensions.Caching.Memory; // To use IMemoryCache. 
    
  4. ProductsController.cs 文件中,声明一些字段来存储内存缓存和缺货产品的键,如下面的代码所示:

    **private****readonly** **IMemoryCache _memoryCache;**
    **private****const****string** **OutOfStockProductsKey =** **"OOSP"****;**
    public ProductsController(ILogger<ProductsController> logger, 
      NorthwindContext context**,**
     **IMemoryCache memoryCache**)
    {
      _logger = logger;
      _db = context;
     **_memoryCache = memoryCache;**
    } 
    
  5. ProductsController.cs 文件中,在 GetOutOfStockProducts 动作方法中,添加语句尝试获取缓存的缺货产品,如果它们未被缓存,则从数据库中获取并将它们设置在缓存中,使用五秒的滑动过期,如下面的代码所示:

    // GET: api/products/outofstock
    [HttpGet]
    [Route("outofstock")]
    [Produces(typeof(Product[]))]
    public IEnumerable<Product> GetOutOfStockProducts()
    {
    **// Try to get the cached value.**
    **if** **(!_memoryCache.TryGetValue(OutOfStockProductsKey,**
    **out** **Product[]? cachedValue))**
     **{**
    **// If the cached value is not found, get the value from the database.**
     **cachedValue = _db.Products**
     **.Where(p => p.UnitsInStock ==** **0** **&& !p.Discontinued)**
     **.ToArray();**
     **MemoryCacheEntryOptions cacheEntryOptions =** **new****()**
     **{**
     **SlidingExpiration = TimeSpan.FromSeconds(****5****),**
     **Size = cachedValue?.Length**
     **};**
     **_memoryCache.Set(OutOfStockProductsKey, cachedValue, cacheEntryOptions);**
     **}**
     **MemoryCacheStatistics? stats = _memoryCache.GetCurrentStatistics();**
     **_logger.LogInformation(****"Memory cache. Total hits: {stats?**
     **.TotalHits}. Estimated size: {stats?.CurrentEstimatedSize}."****);**
    **return** **cachedValue ?? Enumerable.Empty<Product>();**
    } 
    
  6. 使用 https 配置文件启动 Web 服务项目,不进行调试。

  7. 将窗口排列好,以便你可以在同时看到命令提示符或终端的同时看到网页。

  8. 在 Swagger 网页上,点击 GET /api/product/outofstock 来展开该部分。

  9. 点击 Try it out 按钮。

  10. 点击执行按钮,注意在输出中看到 EF Core 执行一个 SQL 语句来获取产品,总命中计数器为零,现在有一个产品已被缓存,如下所示:

    info: Northwind.WebApi.Service.Controllers.ProductsController[0]
          Memory cache. Total hits: 0\. Estimated size: 1. 
    
  11. 在五秒内点击执行,然后继续点击几次:

    • 注意 EF Core 不需要重新执行 SQL 语句,因为产品被缓存了,如果有人在五秒滑动过期内读取它们,它们将永远保留在内存中。

    • 注意缓存的总命中计数器每次在缓存中找到缺货产品时都会增加,如下所示:

      info: Northwind.WebApi.Service.Controllers.ProductsController[0]
            Memory cache. Total hits: 1\. Estimated size: 1.
      info: Northwind.WebApi.Service.Controllers.ProductsController[0]
            Memory cache. Total hits: 2\. Estimated size: 1.
      info: Northwind.WebApi.Service.Controllers.ProductsController[0]
            Memory cache. Total hits: 3\. Estimated size: 1. 
      
  12. 至少等待五秒。

  13. 点击执行,注意在输出中看到 EF Core 执行一个 SQL 语句来获取产品,因为它们在五秒滑动过期窗口内没有被读取。

  14. 关闭浏览器并关闭 web 服务器。

使用分布式缓存缓存对象

分布式缓存比内存缓存有优势。缓存的对象:

  • 在对多个服务器的请求中保持一致性。

  • 生存服务器重启和服务部署。

  • 不会浪费本地服务器内存。

  • 存储在共享区域,所以在具有多个服务器的服务器农场场景中,你不需要启用粘性会话。

警告!分布式缓存的一个缺点是,内存缓存可以存储任何对象,但分布式缓存只能存储 byte 数组。你的对象需要被序列化并通过网络发送到远程缓存。

微软提供了 IDistributedCache 接口,并预定义了方法来操作任何分布式缓存实现中的项。这些方法包括:

  • SetSetAsync:将对象存储在缓存中。

  • GetGetAsync:从缓存中检索对象。

  • RemoveRemoveAsync:从缓存中删除对象。

  • RefreshRefreshAsync:重置缓存中对象的滑动过期。

有许多分布式缓存的实现可供选择,包括以下:

我们将使用分布式内存缓存,这是微软内置的 IDistributedCache 实现,它将项目存储在运行服务的服务器上的内存中。

这不是一个实际的分布式缓存,但它对于像单元测试这样的场景很有用,你希望移除对另一个外部服务的依赖,或者在学习时使用,就像我们在本书中所做的那样。

之后,你只需要更改配置的分布式缓存,而不是使用它的服务实现代码,因为所有交互都通过注册的 IDistributedCache 实现进行。

让我们开始吧!

  1. Northwind.WebApi.Service 项目中,在 Program.cs 文件中,在调用 CreateBuilder 之后,在配置服务的部分中,注册分布式内存缓存的实现,如下面的代码所示:

    builder.Services.AddDistributedMemoryCache(); 
    
  2. ProductsController.cs 文件中,导入用于处理分布式缓存实现和序列化 JSON 的命名空间,如下面的代码所示:

    using Microsoft.Extensions.Caching.Distributed; // To use IDistributedCache.
    using System.Text.Json; // To use JsonSerializer. 
    
  3. ProductsController.cs 文件中,声明一些字段来存储分布式缓存实现和停售产品的项目键,如下面的代码所示:

    private readonly IMemoryCache _memoryCache;
    private const string OutOfStockProductsKey = "OOSP";
    **private****readonly** **IDistributedCache _distributedCache;**
    **private****const****string** **DiscontinuedProductsKey =** **"DISCP"****;**
    public ProductsController(ILogger<ProductsController> logger,
      NorthwindContext context,
      IMemoryCache memoryCache**,**
     **IDistributedCache distributedCache****)**
    {
      _logger = logger;
      _db = context;
      _memoryCache = memoryCache;
     **_distributedCache = distributedCache;**
    } 
    
  4. ProductsController.cs 文件中,定义一个 private 方法来从数据库获取停售产品,并将它们设置在分布式缓存中,使用 5 秒的滑动过期和 20 秒的绝对过期,如下面的代码所示:

    private Product[]? GetDiscontinuedProductsFromDatabase()
    {
      Product[]? cachedValue = _db.Products
        .Where(product => product.Discontinued)
        .ToArray();
      DistributedCacheEntryOptions cacheEntryOptions = new()
      {
        // Allow readers to reset the cache entry's lifetime.
        SlidingExpiration = TimeSpan.FromSeconds(5),
        // Set an absolute expiration time for the cache entry.
        AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(20),
      };
      byte[]? cachedValueBytes = 
        JsonSerializer.SerializeToUtf8Bytes(cachedValue);
      _distributedCache.Set(DiscontinuedProductsKey,
        cachedValueBytes, cacheEntryOptions);
      return cachedValue;
    } 
    
  5. ProductsController.cs 文件中,在 GetDiscontinuedProducts 动作方法中,添加语句尝试获取缓存的停售产品,如果没有缓存,则从数据库中获取。如果在缓存中找到一个 byte 数组,尝试将其反序列化为产品,但如果这也失败了,则从数据库中获取产品,如下面的代码所示:

    // GET: api/products/discontinued
    [HttpGet]
    [Route("discontinued")]
    [Produces(typeof(Product[]))]
    public IEnumerable<Product> GetDiscontinuedProducts()
    {
    **// Try to get the cached value.**
    **byte****[]? cachedValueBytes = _distributedCache.Get(DiscontinuedProductsKey);**
     **Product[]? cachedValue =** **null****;**
    **if** **(cachedValueBytes** **is****null****)**
     **{**
     **cachedValue = GetDiscontinuedProductsFromDatabase();**
     **}**
    **else**
     **{**
     **cachedValue = JsonSerializer**
     **.Deserialize<Product[]?>(cachedValueBytes);**
    **if** **(cachedValue** **is****null****)**
     **{**
     **cachedValue = GetDiscontinuedProductsFromDatabase();**
     **}**
     **}**
    **return** **cachedValue ?? Enumerable.Empty<Product>();**
    } 
    

    与可以存储任何活动对象的内存缓存不同,存储在分布式缓存实现中的对象必须序列化为 byte 数组,因为它们需要能够在网络上传输。

  6. 使用不带调试的 https 配置启动 web 服务项目。

  7. 调整窗口,以便同时看到命令提示符或终端和网页。

  8. 在 Swagger 网页上,点击 GET /api/product/discontinued 来展开该部分。

  9. 点击 尝试一下 按钮。

  10. 点击 执行 按钮,并在输出中注意 EF Core 执行了一个 SQL 语句来获取产品。

  11. 在五秒内点击 执行,然后继续点击几次,并注意 EF Core 不需要重新执行 SQL 语句,因为产品已经被缓存。如果在五秒的滑动过期时间内有东西读取它们,它们将永远保留在内存中。

  12. 至少等待五秒。

  13. 点击 执行,并在输出中注意 EF Core 执行了一个 SQL 语句来获取产品,因为它们在五秒的滑动过期时间内没有被读取。

  14. 持续点击 执行,并注意在 20 秒后,EF Core 必须执行一个 SQL 语句来刷新产品。

  15. 关闭浏览器并关闭 web 服务器。

分布式缓存的新的抽象

ASP.NET Core 团队正在努力添加一个新的分布式缓存抽象,使其更容易使用。它预计不会在 .NET 8 中准备好。它可能包含在点版本中,如 8.1,但更有可能内置在 .NET 9 中。

一些 GetAsync 扩展方法和支持方法是由 Marc Gravell 编写的。他维护着最受欢迎的将 .NET 与 Redis 集成的包,因此他在分布式缓存方面拥有丰富的经验。

在等待官方实现的同时,您可以在以下链接中阅读或下载他的扩展的源代码:github.com/mgravell/DistributedCacheDemo/blob/main/DistributedCacheExtensions.cs。该文件只有 137 行,因此很容易立即添加到您自己的项目中。

新扩展方法的主要区别在于,您不再需要调用 SetSetAsync 方法,因为它们被抽象在新的 GetAsync 方法中,如下面的代码所示:

// IDistributedCache methods.
objectFromDatabase = GetFromDatabase(...);
cache.Set(key: "ITEM_KEY", value: objectFromDatabase, options: ...);
dataFromCache = cache.Get(key: "ITEM_KEY");
// New extension methods.
dataFromCache = await cache.GetAsync(key: "ITEM_KEY",
  getMethod: GetFromDatabase(...), options: ..., cancellation: ...); 

此外,请注意,新的扩展方法都是异步和泛型的,具有类型 T,默认情况下将序列化为 JSON,但可以覆盖以使用二进制格式 protobuf 等替代方案。

更多信息:您可以在以下链接中了解更多关于这些新扩展方法的计划:devblogs.microsoft.com/dotnet/caching-abstraction-improvements-in-aspnetcore/

使用 HTTP 缓存来缓存网页响应

内存和分布式缓存可以与任何类型的应用程序或服务一起工作,使用任何传输技术,因为所有的魔法都在服务器上发生。

响应,即 HTTP 缓存,与 HTTP GET 请求和响应相关联,因为它基于 HTTP 头部。因此,它仅适用于使用 HTTP 作为其传输技术的应用程序和服务,例如使用 Web API、最小 API 和 OData 构建的 Web 服务。

更多信息:您可以在以下链接中阅读 HTTP 缓存的官方标准:www.rfc-editor.org/rfc/rfc9111

HTTP 缓存(即响应缓存)的要求包括以下内容:

  • 请求必须是 GETHEAD 类型。POSTPUTDELETE 请求等永远不会被 HTTP 缓存。

  • 响应必须有一个 200 OK 状态码。

  • 如果请求有一个 Authorization 头部,则响应不会被缓存。

  • 如果请求有一个 Vary 头部,则当值无效或为 * 时,响应不会被缓存。

服务器设置响应缓存头部,然后中间代理和客户端应尊重这些头部以告知它们如何缓存响应。

良好实践:响应缓存(即 HTTP 缓存)通常对 Web 用户界面没有太大用处,因为 Web 浏览器通常会设置请求头部以防止 HTTP 缓存。对于 Web 用户界面,输出缓存更适合,我们将在第十四章,使用 ASP.NET Core 构建 Web 用户界面中介绍。

请求和响应的 Cache-Control HTTP 头部有一些常见的指令,如下表 9.2 所示:

指令 描述
public 客户端和中间代理可以缓存此响应。
private 只有客户端应缓存此响应。
max-age 客户端不接受超过指定秒数的旧响应。
no-cache 客户端请求的是非缓存的响应。服务器告诉客户端和中间代理不要缓存响应。
no-store 缓存不得存储请求或响应。

表 9.2:常见的 Cache-Control HTTP 头部指令

除了 Cache-Control 之外,还有其他可能影响缓存的头部,如下表 9.3 所示:

头部 描述
Age 响应估计的秒数。
Expires 响应应在绝对日期/时间之后被视为已过期。
Vary 所有字段必须匹配才能发送缓存的响应。否则,将发送新的响应。例如,查询字符串为 color

表 9.3:常见的 HTTP 缓存头部

例如,客户端可以请求一个停产的产品的最新列表,服务不应使用任何缓存版本,如下面的 HTTP 响应所示:

GET api/products/discontinued
Cache-Control: no-cache 

服务可以返回一些产品作为 JSON 数组,并在头部说明中间代理不应缓存响应,但客户端可以,如下面的 HTTP 响应所示:

content-type: application/json; charset=utf-8 
date: Fri,09 Jun 2023 06:05:13 GMT 
server: Kestrel 
cache-control: private
[
  {
    "productId": 5,
    "productName": "Chef Anton's Gumbo Mix",
    ... 

使用 [ResponseCache] 属性装饰控制器或方法以控制来自服务器的缓存响应(控制缓存请求的代码必须放在客户端代码中)。此属性有常用参数,如下表 9.4 所示:

属性 描述
Duration 缓存时间(以秒为单位)。
Location 响应可以缓存的地点。Any (cache-control: public), Client (cache-control: private), None (cache-control: no-cache)。
NoStore 设置 cache-control: no-store
VaryByHeader 设置 Vary 头部。
VaryByQueryKeys 要变化的查询键。

表 9.4:[ResponseCache] 属性的常见参数

让我们将响应缓存应用到 Web 服务中:

  1. Northwind.WebApi.Service 项目中的 Program.cs 文件中,在调用添加分布式内存缓存之后,添加一个语句来添加响应缓存中间件作为依赖服务,如下面的代码所示:

    builder.Services.AddResponseCaching(); 
    
  2. Program.cs 中,在调用使用 HTTPS 重定向之后,添加一个语句来使用响应缓存中间件,如下面的代码所示:

    app.UseResponseCaching(); 
    

    良好实践:如果使用 CORS 中间件,则必须在 UseResponseCaching 之前调用 UseCors

  3. ProductsController.cs中,使用[ResponseCache]属性装饰带有int id参数的Get方法,如下面的代码所示:

    // GET api/products/5
    [HttpGet("{id:int}")]
    **[****ResponseCache(Duration = 5, // Cache-Control: max-age=5**
     **Location = ResponseCacheLocation.Any, // Cache-Control: public**
     **VaryByHeader =** **"User-Agent"** **// Vary: User-Agent**
     **)****]**
    public async ValueTask<Product?> Get(int id)
    {
      return await _db.Products.FindAsync(id);
    } 
    

    [ResponseCache]属性可以应用于 Razor 页面、MVC 控制器类以及 MVC 动作方法,无论是用于 Web 服务还是网站。

  4. 使用https配置文件启动 Web 服务项目,不进行调试。

  5. HttpRequests文件夹中,打开webapi-get-products.http文件。

  6. 将基本地址修改为使用端口5091,然后发送请求以获取特定产品,例如77,如下面的代码所示:

    GET {{base_address}}77 
    
  7. 注意,响应包括用于控制缓存的头信息,如下面的输出所示:

    Response time: 89 ms
    Status code: OK (200)
    Alt-Svc: h3=":5091"; ma=86400
    Transfer-Encoding: chunked
    **Vary: User-Agent**
    **Cache-Control: public, max-age=5**
    Date: Fri, 09 Jun 2023 06:26:45 GMT
    Server: Kestrel
    Content-Type: application/json; charset=utf-8
    Content-Length: 270
    ------------------------------------------------
    Content:
    {
      "productId": 77,
      "productName": "Original Frankfurter grüne Soße",
      "supplierId": 12,
      "categoryId": 2,
      "quantityPerUnit": "12 boxes",
      "unitPrice": 85.0,
      "unitsInStock": 32,
      "unitsOnOrder": 0,
      "reorderLevel": 15,
      "discontinued": false,
      "category": null,
      "orderDetails": [],
      "supplier": null
    } 
    
  8. 关闭浏览器并关闭 Web 服务器。

良好实践:仅应启用匿名请求的响应缓存。认证请求和响应不应被缓存。

缓存是提高您服务性能和可扩展性的最佳方法之一。接下来,我们将学习如何在不可避免地发生故障时提高服务的弹性。

使用 Polly 实现容错

如官方 Polly GitHub 仓库所述,Polly 是“一个.NET 弹性及瞬态故障处理库,允许开发者以流畅且线程安全的方式表达重试、断路器、超时、舱壁隔离和回退等策略,”该仓库的链接如下:github.com/App-vNext/Polly

瞬态故障是由暂时条件引起的错误,例如暂时性服务不可用或网络连接问题。在分布式系统中处理瞬态故障至关重要,否则它们可能会变得几乎无法使用。

理解重试和断路器模式

重试模式使客户端能够自动重试失败的操作,预期在短暂延迟后故障将成功。请注意,如果您天真地实现重试模式,那么它可能会使问题变得更糟!

例如,如果您设置固定的重试时间间隔,那么所有收到故障的客户端将同时尝试重试,从而超载服务。为了避免这个问题,重试通常设置成指数级增加的重试时间间隔,或者它们可能使用抖动(也称为随机化器)算法。

断路器模式在达到错误阈值时阻止调用。实际上,这是一种服务检测错误是否不是瞬时的,或者不足以持续重试的方法。

更多信息:Polly 的 GitHub 仓库中有一个关于弹性策略的很好的总结表格:github.com/App-vNext/Polly#resilience-policies

定义和执行策略

在任何调用不可靠代码的.NET 项目中,你可以引用 Polly 包,然后使用Policy类定义一个策略。Polly 不用于不可靠的代码或服务本身。它被任何调用代码或服务的客户端使用。

例如,你可能需要调用两个可能会抛出算术或自定义异常的方法,并且你希望自动重试最多三次,因此你定义一个策略来处理这种情况,如下面的代码所示:

RetryPolicy policy = Policy
  .Handle<CustomException>().Or<ArithmeticException>()
  .Retry(3); 

然后,你可以使用该策略来执行方法,如下面的代码所示:

policy.Execute(() => GetProducts());
policy.Execute(() => GetCustomers()); 

每次调用Execute都会为其自己的重试计数器,所以如果GetProducts调用需要两次重试,那么GetCustomers调用仍然有它自己的完整三次重试。

对于无限重试,你可以调用RetryForever方法,但这个方法不建议使用。

对于异步方法,存在对应的异步方法;例如,而不是使用Retry,使用RetryAsync

要在重试发生时执行某些语句,例如记录信息,Retry方法可以有一个回调,如下面的代码所示:

RetryPolicy policy = Policy
  .Handle<CustomException>().Or<ArithmeticException>()
  .Retry(3, onRetry: (exception, retryCount) =>
  {
      // Log the current retry count and exception information.
  }); 

定义重试之间的等待间隔

与在故障后立即重试相比,在重试之前等待一段时间是一个好的实践。

例如,等待并重试,如下面的代码所示:

RetryPolicy policy = Policy
  .Handle<CustomException>().Or<ArithmeticException>()
  .WaitAndRetry(new[]
  {
    TimeSpan.FromSeconds(1), // 1 second between 1st and 2nd try.
    TimeSpan.FromSeconds(2), // 2 seconds between 2nd and 3rd try.
    TimeSpan.FromSeconds(5) // 5 seconds between 3rd and 4th try.
  }); 

你也可以定义一个函数来生成它们,而不是使用硬编码的延迟值,如下面的代码所示:

RetryPolicy policy = Policy
  .Handle<CustomException>().Or<ArithmeticException>()
  .WaitAndRetry(3, retryAttempt => 
    TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
//  2 ^ 1 = 2 seconds then
//  2 ^ 2 = 4 seconds then
//  2 ^ 3 = 8 seconds then 

然而,如果我们传递一个固定延迟的数组,即使它们是计算出来的,想象一下当繁忙的网络服务发生故障时会发生什么。所有客户端都会收到一个异常,他们都会等待第一秒,然后他们都会在第二秒后尝试重新调用网络服务。这可能导致洪水,从而使情况变得更糟!

Jittering 是一种在时间延迟中添加少量随机化的想法。你可以在网上找到许多实现,最好的是内置在额外的 Polly 包中。我们将在示例项目中使用它来生成时间延迟。

将策略应用于 HTTP 客户端

当调用网络服务时,定义一个 HTTP 客户端工厂并将其注册到依赖服务集合中是一个好的实践。

在这个场景中,你不会自己调用可能会抛出异常的方法。相反,你必须定义一个策略,并将其附加到一个已注册的 HTTP 客户端上,这样它就会自动遵循该策略。

要这样做,我们将使用名为HttpPolicyExtensions的扩展类来创建专门针对常见 HTTP 请求和失败的策略,如下面的代码所示:

AsyncRetryPolicy<HttpResponseMessage> retryPolicy = HttpPolicyExtensions
  // Handle network failures, 408 and 5xx status codes.
  .HandleTransientHttpError()
  // Define the policy using all the same options as before.
  .RetryAsync(3); 

在定义工厂之后,调用AddPolicyHandler扩展方法将策略附加到 HTTP 客户端上。你将在本节稍后看到如何在实践中这样做。

向网络服务添加随机故障

首先,让我们向网络服务添加随机故障:

  1. Northwind.WebApi.Service 项目中,在 ProductsController.cs 文件中,在具有 name 参数的 Get 动作方法中,添加语句以随机抛出三分之二时间的异常,如下所示:

    // GET api/products/cha
    [HttpGet("{name}")]
    public IEnumerable<Product> Get(string name)
    {
    **// Works correctly 1 out of 3 times.**
    **if** **(Random.Shared.Next(****1****,** **4****) ==** **1****)**
     **{**
        return _db.Products.Where(p => p.ProductName.Contains(name));
     **}**
    **// Throws an exception at all other times.**
    **throw****new** **Exception(****"Randomized fault."****);**
    } 
    
  2. 构建项目。

构建一个 MVC 项目以调用有缺陷的 Web 服务

接下来,让我们创建一个 ASP.NET Core MVC 客户端,该客户端调用随机有缺陷的 Web 服务端点。最初,它将仅接收 Web 服务抛出的异常。稍后,我们将添加使用 Polly 的瞬态故障处理:

  1. 使用您首选的代码编辑器添加一个新项目,如下所示列表定义:

    • 项目模板:ASP.NET Core Web App (Model-View-Controller) / mvc

    • 解决方案文件和文件夹:Chapter09

    • 项目文件和文件夹:Northwind.WebApi.Client.Mvc

    • 其他 Visual Studio 2022 选项:

      • 认证类型: .

      • 配置 HTTPS: 已选择。

      • 启用 Docker: 已清除。

      • 不要使用顶级语句: 已清除。

  2. Northwind.WebApi.Client.Mvc 项目中,在 Properties 文件夹中,在 launchSettings.json 文件中,将 https 配置的 applicationUrl 修改为使用端口 5093 用于 https5094 用于 http,如下所示:

    "applicationUrl": "https://localhost:5093;http://localhost:5094", 
    
  3. Northwind.WebApi.Client.Mvc 项目文件中,将警告视为错误,并添加对实体模型项目的引用,以便我们可以使用 Product 类,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.EntityModels .SqlServer\Northwind.Common.EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    
  4. 在命令提示符或终端中通过输入以下命令构建 Northwind.WebApi.Client.Mvc 项目:dotnet build

  5. Northwind.WebApi.Client.Mvc 项目中,在 Program.cs 文件中,导入用于处理 HTTP 标头的命名空间,如下所示:

    using System.Net.Http.Headers; // To use MediaTypeWithQualityHeaderValue. 
    
  6. Program.cs 文件中,在调用 builder.Build() 之前,添加语句以配置一个 HTTP 客户端工厂以调用 Web 服务,如下所示:

    builder.Services.AddHttpClient(name: "Northwind.WebApi.Service",
      configureClient: options =>
      {
        options.BaseAddress = new("https://localhost:5091/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
            "application/json", 1.0));
      }); 
    
  7. Models 文件夹中,添加一个名为 HomeProductsViewModel.cs 的新类文件。

  8. HomeProductsViewModel.cs 文件中,定义一个类以存储视图所需的信息,例如访客想要搜索的部分产品名称、产品序列和错误消息,如下所示:

    using Northwind.EntityModels; // To use Product.
    namespace Northwind.WebApi.Client.Mvc.Models;
    public class HomeProductsViewModel
    {
      public string? NameContains { get; set; }
      public Uri? BaseAddress { get; set; }
      public IEnumerable<Product>? Products { get; set; }
      public string? ErrorMessage { get; set; }
    } 
    
  9. Controllers 文件夹中,在 HomeController.cs 文件中,导入实体模型的命名空间,如下所示:

    using Northwind.EntityModels; // To use Product. 
    
  10. HomeController.cs 文件中,添加语句以将注册的 HTTP 客户端工厂存储在私有的 readonly 字段中,如下所示:

    private readonly ILogger<HomeController> _logger;
    **private****readonly** **IHttpClientFactory _httpClientFactory;**
    public HomeController(ILogger<HomeController> logger,
      **IHttpClientFactory httpClientFactory**)
    {
      _logger = logger;
     **_httpClientFactory = httpClientFactory;**
    } 
    
  11. HomeController.cs 文件中,添加一个名为 Products 的异步动作方法,该方法将使用 HTTP 工厂请求包含作为可选 name 参数输入的值的名称的产品,在自定义 MVC 路由中,如下所示:

    [Route("home/products/{name?}")]
    public async Task<IActionResult> Products(string? name = "cha")
    {
      HomeProductsViewModel model = new();
      HttpClient client = _httpClientFactory.CreateClient(
        name: "Northwind.WebApi.Service");
      model.NameContains = name;
      model.BaseAddress = client.BaseAddress;
      HttpRequestMessage request = new(
        method: HttpMethod.Get, 
        requestUri: $"api/products/{name}");
      HttpResponseMessage response = await client.SendAsync(request);
      if (response.IsSuccessStatusCode)
      {
        model.Products = await response.Content
          .ReadFromJsonAsync<IEnumerable<Product>>();
      }
      else
      {
        model.Products = Enumerable.Empty<Product>();
        string content = await response.Content.ReadAsStringAsync();
        // Use the range operator .. to start from zero and 
        // go to the first carriage return.
        string exceptionMessage = content[..content.IndexOf("\r")];
        model.ErrorMessage = string.Format("{0}: {1}:",
          response.ReasonPhrase, exceptionMessage);
      }
      return View(model);
    } 
    
  12. Views/Home 文件夹中,添加一个名为 Products.cshtml 的新文件。(Visual Studio 2022 项目项模板命名为 Razor View - Empty。JetBrains Rider 项目项模板命名为 Razor MVC View。)

  13. Products.cshtml 文件中,修改其内容以输出一个表格,显示与在文本框中输入的产品名称部分匹配的产品,如下面的标记所示:

    @using Northwind.EntityModels
    @model HomeProductsViewModel
    @{
      ViewData["Title"] = "Products using Polly";
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      <div class="alert alert-info">
        <p>
          This page calls a web service endpoint that will randomly fail two out of three times. It will use Polly to retry the call automatically.
        </p>
      </div>
      @if (Model is not null)
      {
        if (!string.IsNullOrWhiteSpace(Model.ErrorMessage))
        {
          <div class="alert alert-danger">
            @Model.ErrorMessage
          </div>
        }
        <form action="/home/products">
          <input name="name" placeholder="Enter part of a product name" 
            value="@Model.NameContains" />
          <input type="submit" value="Get Products" />
          @if (!string.IsNullOrWhiteSpace(Model.NameContains))
          {
          <p>
            Searched for product names that start with:
            <span class="badge bg-primary rounded-pill">
              @Model.NameContains</span>
          </p>
          }
        </form>
        <div>
          @if (Model.Products is not null)
          {
            <table class="table">
              <thead>
                <tr>
                  <th scope="col">Product Name</th>
                </tr>
              </thead>
              <tbody>
                @if (Model.Products.Any())
                {
                  @foreach (Product p in Model.Products)
                  {
                    <tr>
                      <td>
                        <a href=
    "@(Model.BaseAddress)api/products/@p.ProductId">
    @p.ProductName</a>
                      </td>
                    </tr>
                  }
                }
                else
                {
                  <tr><td>0 products found.</td></tr>
                }
              </tbody>
            </table>
          }
        </div>
      }
    </div> 
    
  14. Views/Home 目录中的 Index.cshtml 文件中,添加代码以定义到产品页面的链接,如下面的标记所示:

    <p><a href="home/products">Search for products by name</a></p> 
    
  15. 使用不带调试的 https 配置启动 Northwind.WebApi.Service 项目。

  16. 使用不带调试的 https 配置启动 Northwind.WebApi.Client.Mvc 项目。

    如果你正在使用 Visual Studio Code,那么网页浏览器将不会自动启动。启动 Chrome,然后导航到 https://localhost:5093

  17. 在主页上,点击 按名称搜索产品

  18. 如果搜索成功,你将看到 图 9.1 中显示的成功结果:

图 9.1:对故障随机 Web 服务的成功调用

  1. 如果失败,你将看到 图 9.2 中显示的错误消息:

图 9.2:对故障随机 Web 服务的成功调用

  1. 在命令提示符或终端中,当发生故障时,你将看到异常,如下面的部分输出所示:

    fail: Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
          An unhandled exception has occurred while executing the request.
          System.Exception: Randomized fault. 
    
  2. 输入不同的部分名称并点击 获取产品,直到你看到成功搜索和失败搜索。

  3. 关闭浏览器并关闭 Web 服务器。

实现短暂故障处理的重试模式

现在我们有一个带有随机故障的 Web 服务和 MVC 客户端,让我们使用重试模式来添加短暂故障处理:

  1. Northwind.WebApi.Client.Mvc 项目文件中,全局和静态导入 System.Console 类,并添加对 Microsoft 包的包引用以将 Polly 集成到 ASP.NET Core(它依赖于 Polly 包),以及添加抖动到重试时延的库,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Microsoft.Extensions.Http.Polly" 
                        Version="8.0.0" />
      <PackageReference Include="Polly.Contrib.WaitAndRetry" 
                        Version="1.1.1" />
    </ItemGroup> 
    
  2. 构建项目 Northwind.WebApi.Client.Mvc 以恢复包。

  3. Program.cs 文件中,导入常见的 Polly 命名空间以与 ASP.NET Core 一起工作,如下面的代码所示:

    using Polly; // To use AddTransientHttpErrorPolicy method.
    using Polly.Contrib.WaitAndRetry; // To use Backoff.
    using Polly.Extensions.Http; // To use HttpPolicyExtensions.
    using Polly.Retry; // To use AsyncRetryPolicy<T> 
    
  4. Program.cs 文件中,在添加 HTTP 客户端到服务语句之前,添加生成五个抖动和指数增长的时延值语句,将它们输出到控制台,使用它们来定义异步等待和重试策略,然后将重试策略添加到 HTTP 客户端工厂中,如下面的代码所示:

    **// Create five jittered delays, starting with about 1 second.**
    **IEnumerable<TimeSpan> delays = Backoff.DecorrelatedJitterBackoffV2(**
     **medianFirstRetryDelay: TimeSpan.FromSeconds(****1****), retryCount:** **5****);**
    **WriteLine(****"Jittered delays for Polly retries:"****);**
    **foreach** **(TimeSpan item** **in** **delays)**
    **{**
     **WriteLine(****$"** **{item.TotalSeconds:N2}** **seconds."****);**
    **}**
    **AsyncRetryPolicy<HttpResponseMessage> retryPolicy = HttpPolicyExtensions**
    **// Handle network failures, 408 and 5xx status codes.**
     **.HandleTransientHttpError().WaitAndRetryAsync(delays);**
    builder.Services.AddHttpClient(name: "Northwind.WebApi.Service",
      configureClient: options =>
      {
        options.BaseAddress = new("https://localhost:5091/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
            "application/json", 1.0));
      })
      **.AddPolicyHandler(retryPolicy)**; 
    
  5. 如果你的数据库服务器没有运行(例如,因为你正在 Docker、虚拟机或云中托管它),那么请确保启动它。

  6. 使用不带调试的 https 配置启动 Northwind.WebApi.Service 项目。

  7. 使用不带调试的 https 配置启动 Northwind.WebApi.Client.Mvc 项目。

  8. 在 MVC 项目的命令提示符或终端中,注意抖动时延,如下面的输出所示:

    Jittered delays for Polly retries:
      1.38 seconds.
      0.15 seconds.
      2.65 seconds.
      3.06 seconds.
      6.46 seconds. 
    

    你的五个延迟将不同,但它们应该从大约 1 秒开始并增加。

  9. 安排好 Web 服务命令提示符或终端和 MVC 网站浏览器,以便你可以并排看到它们。

  10. 在主页上,点击按名称搜索产品

  11. 注意,MVC 网站可能需要在显示页面之前发出多个请求,这可能会花费大约 15 秒的时间。例如,当我运行我的项目时,MVC 网站在前五次尝试成功之前失败了四次。你将在 Web 服务输出中看到记录的异常。

  12. 输入部分产品名称,点击获取产品,并注意网页可能会成功再次出现,即使在此之前必须发出一个或多个请求。

  13. 有可能你可能会超过五个请求的最大限制,在这种情况下,你将看到之前出现的错误信息。

微软创建了他们自己的包装,将 Polly 包装起来,使其更容易使用。它们是Microsoft.Extensions.Http.ResilienceMicrosoft.Extensions.Resilience包。你可以在以下链接中了解更多信息:devblogs.microsoft.com/dotnet/building-resilient-cloud-services-with-dotnet-8/

现在你已经看到了两种提高服务的技术,缓存和处理短暂故障,让我们看看第三种强大的技术,队列。

使用 RabbitMQ 进行队列

队列可以提高你服务的可伸缩性,就像在物理世界中一样。当太多客户端同时需要调用一个服务时,我们可以使用队列来平滑负载。

对于所有主要开发平台,都有许多可用的队列系统。其中最受欢迎的是 RabbitMQ。它实现了高级消息队列协议AMQP)。

使用 AMQP,消息被发布到交换机,然后根据名为绑定的规则将消息副本分发到队列。然后代理可以将消息传递给订阅了队列(有时称为主题)的消费者,或者消费者可以在需要时从队列中读取。

由于网络和系统经常出现故障,AMQP 使用消息确认来告诉代理消费者何时成功处理了消息,然后代理才会从队列中删除消息。

RabbitMQ 支持四种类型的交换机:

  • 直接:直接交换机根据消息路由键交付消息。多个队列可以绑定到交换机,但只有当消息具有匹配的路由键时,才会将消息传递到队列。它们主要用于单播消息。默认(空名称)交换机是一个直接交换机。它与队列相同的名称的路由键预先绑定。这是我们在这本书中将使用的那种类型。

  • 扇出:扇出交换机将消息发送到所有绑定到它的队列,并且忽略路由键。这些非常适合广播消息。

  • 主题:主题交换基于交换和队列之间的绑定中定义的路由键和标准来传递消息。它们用于发布/订阅模式,其中存在许多消费者,但他们希望根据地理位置、注册兴趣等因素接收不同的消息。

  • 头部信息:头部交换基于消息头中的多个属性而不是路由键来传递消息。

RabbitMQ API 使用以下类型:

  • IConnection:这代表一个 AMQP 连接。

  • ConnectionFactory:它创建IConnection实例。它为常见属性提供默认值,旨在与 Docker 镜像一起使用。例如,UserNameguestPasswordguestVirtualHost/HostNamelocalhostPort5672

  • IModel:这代表 AMQP 通道,并具有执行常见任务的方法,如使用QueueDeclare声明队列或使用BasicPublish发送消息。

  • IBasicConsumer:这代表一个消息消费者。

  • EventBasicConsumer:这是一个与.NET 事件系统集成的消息消费者实现,使得客户端应用程序能够一旦发送和接收消息就立即处理它。

良好实践:队列系统可能会很快变得复杂。在这本书中,我们将介绍基础知识,但如果您决定在生产中实现任何队列系统,那么您将需要学习更多关于如何深入实现它们的知识。

您可以在您的计算机上本地安装 RabbitMQ,但我建议使用 Docker 镜像以获得最大便利性。

要在您的计算机上安装 RabbitMQ,请阅读以下链接中针对您操作系统的说明:www.rabbitmq.com/download.html.

使用 Docker 设置 RabbitMQ

我们将使用的 Docker 镜像具有 RabbitMQ 版本 3.12.0,并设计为用作一次性容器,您只需启动容器,项目就可以使用默认配置开始使用它。

更多信息:您可以在以下链接中了解更多关于 Docker 镜像的信息:registry.hub.docker.com/_/rabbitmq/.

让我们在 Docker 容器中开始使用 RabbitMQ:

  1. 从以下链接安装Dockerdocs.docker.com/engine/install/.

  2. 启动Docker

  3. 在命令提示符或终端中,从 Docker 拉取最新的 RabbitMQ 容器镜像并运行它,将端口567215672打开到容器,这些端口是 AMQP 默认使用的,如下所示:

    docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.12-management 
    
  4. 注意,第一次运行此命令时,您的本地计算机上找不到 RabbitMQ 镜像,如下所示:

    Unable to find image 'rabbitmq:3.12-management' locally 
    
  5. 注意,然后镜像将自动下载,如下所示:

    3.12-management: Pulling from library/rabbitmq
    99803d4b97f3: Pull complete
    8fb904ec525a: Pull complete
    ba4d114a87c0: Pull complete
    c869b027f1e1: Pull complete
    729c8b3166a8: Pull complete
    7de098b90abf: Pull complete
    4f206ad5199f: Pull complete
    1f40437d763f: Pull complete
    f4cbf27a2d68: Pull complete
    5a4db5ea38b2: Pull complete
    99886074092c: Pull complete
    Digest: sha256:da98d468cf2236171da94e34953619ddd01b5db155ee326b653675d1e5017f0f
    Status: Downloaded newer image for rabbitmq:3.12-management 
    
  6. 注意 RabbitMQ 在 Erlang 上运行,并且当容器启动时显示其版权和许可信息,如下所示:

    2023-06-11 14:03:22.785019+00:00 [info] <0.230.0>  Starting RabbitMQ 3.12.0 on Erlang 25.3.2.2 [jit]
    2023-06-11 14:03:22.785019+00:00 [info] <0.230.0>  Copyright (c) 2007-2023 VMware, Inc. or its affiliates.
    2023-06-11 14:03:22.785019+00:00 [info] <0.230.0>  Licensed under the MPL 2.0\. Website: https://rabbitmq.com
      ##  ##      RabbitMQ 3.12.0
      ##  ##
      ##########  Copyright (c) 2007-2023 VMware, Inc. or its affiliates.
      ######  ##
      ##########  Licensed under the MPL 2.0\. Website: https://rabbitmq.com 
    
  7. 注意 RabbitMQ 服务正在端口 5672 上监听并已启动四个插件,如下所示:

    2023-06-11 14:03:27.574844+00:00 [info] <0.744.0> started TCP listener on [::]:5672
     completed with 4 plugins.
    2023-06-11 14:03:27.659139+00:00 [info] <0.599.0> Server startup complete; 4 plugins started.
    2023-06-11 14:03:27.659139+00:00 [info] <0.599.0>  * rabbitmq_prometheus
    2023-06-11 14:03:27.659139+00:00 [info] <0.599.0>  * rabbitmq_management
    2023-06-11 14:03:27.659139+00:00 [info] <0.599.0>  * rabbitmq_web_dispatch
    2023-06-11 14:03:27.659139+00:00 [info] <0.599.0>  * rabbitmq_management_agent 
    
  8. 保持命令提示符或终端运行。

  9. 可选地,在 Docker Desktop 中,请注意 RabbitMQ 的容器正在运行并监听端口 5672(实际的队列服务)和 15672(其管理服务),如图 9.3 所示:

图片

图 9.3:RabbitMQ 在 Docker 容器中运行

使用 MVC 网站向队列发送消息

现在我们已经运行了 RabbitMQ 系统,我们可以将 RabbitMQ 客户端包添加到 MVC 网站项目中,以便它可以向队列发送消息。

但首先,让我们创建一个类库来定义我们将与队列一起使用的模型:

  1. 使用您首选的代码编辑器创建一个新的类库项目,如下所示:

    • 项目模板:类库 / classlib

    • 解决方案文件和文件夹:Chapter09

    • 项目文件和文件夹:Northwind.Queue.Models

  2. 将 Northwind 实体模型项目添加为项目引用,该项目是在 第三章 中创建的,即使用 EF Core 为 SQL Server 构建实体模型,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.EntityModels
    .SqlServer\Northwind.Common.EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    
  3. 在命令提示符或终端中,构建项目以确保当前解决方案之外的实体模型类库项目已正确编译,如下所示:

    dotnet build 
    
  4. 删除名为 Class1.cs 的文件。

  5. 添加一个名为 ProductQueueMessage.cs 的新文件。

  6. ProductQueueMessage.cs 文件中,定义一个类,该类将代表队列中的消息,具有一个简单的纯文本属性和一个复杂的 Product 实体模型类型作为第二个属性,如下所示:

    using Northwind.EntityModels; // To use Product.
    namespace Northwind.Queue.Models;
    public class ProductQueueMessage
    {
      public string? Text { get; set; }
      public Product Product { get; set; } = null!;
    } 
    
  7. Northwind.WebApi.Client.Mvc 项目文件中,添加对队列模型项目的引用,以便我们可以使用 ProductQueueMessage 类,如下所示:

    <ItemGroup>
      <ProjectReference Include=
      "..\Northwind.Queue.Models\Northwind.Queue.Models.csproj" />
    </ItemGroup> 
    
  8. Northwind.WebApi.Client.Mvc 项目文件中,添加 RabbitMQ 客户端的包引用,如下所示:

    <PackageReference Include="RabbitMQ.Client" Version="6.7.0" /> 
    

    您可以在以下链接中检查最新包版本:www.nuget.org/packages/RabbitMQ.Client/

  9. 构建 Northwind.WebApi.Client.Mvc 项目。

  10. Models 文件夹中,添加一个名为 HomeSendMessageViewModel.cs 的新类文件。

  11. 定义一个类来表示需要在视图中显示的发送消息的信息,包括一些属性,用于在消息成功发送和发生错误时向访客显示消息,如下所示:

    using Northwind.Queue.Models; // To use ProductQueueMessage.
    namespace Northwind.WebApi.Client.Mvc.Models;
    public class HomeSendMessageViewModel
    {
      public string? Info { get; set; }
      public string? Error { get; set; }
      public ProductQueueMessage? Message { get; set; }
    } 
    
  12. Views\Home 目录下的 Index.cshtml 文件中,添加一个链接到允许访客向队列发送消息的页面,如下所示:

    <p><a href="home/sendmessage">Send a message</a></p> 
    
  13. HomeControllers.cs 文件中,导入命名空间以使用 RabbitMQ 和序列化 JSON,如下所示:

    using RabbitMQ.Client; // To use ConnectionFactory and so on.
    using System.Text.Json; // To use JsonSerializer. 
    
  14. HomeControllers.cs 中,添加语句以定义一个响应 GET 请求的动作方法,通过显示发送消息的网页表单,如下所示代码:

    public IActionResult SendMessage()
    {
      return View();
    } 
    
  15. HomeControllers.cs 中,添加语句以定义一个响应 POST 请求的动作方法,通过发送表单中的信息发送消息,如下所示代码:

    // POST: home/sendmessage
    // Body: message=Hello&productId=1
    [HttpPost]
    public async Task<IActionResult> SendMessage(
      string? message, int? productId)
    {
      HomeSendMessageViewModel model = new();
      model.Message = new();
      if (message is null || productId is null)
      {
        model.Error = "Please enter a message and a product ID.";
        return View(model);
      }
      model.Message.Text = message;
      model.Message.Product = new() { ProductId = productId.Value };
      HttpClient client = _httpClientFactory.CreateClient(
        name: "Northwind.WebApi.Service");
      HttpRequestMessage request = new(
        method: HttpMethod.Get,
        requestUri: $"api/products/{productId}");
      HttpResponseMessage response = await client.SendAsync(request);
      if (response.IsSuccessStatusCode)
      {
        Product? product = await response.Content.ReadFromJsonAsync<Product>();
        if (product is not null)
        {
          model.Message.Product = product;
        }
      }
      // Create a RabbitMQ factory.
      ConnectionFactory factory = new() { HostName = "localhost" };
      using IConnection connection = factory.CreateConnection();
      using IModel channel = connection.CreateModel();
      string queueNameAndRoutingKey = "product";
      // If the queue does not exist, it will be created.
      // If the Docker container is restarted, the queue will be lost.
      // The queue can be shared with multiple consumers.
      // The queue will not be deleted when the last message is consumer.
      channel.QueueDeclare(queue: queueNameAndRoutingKey, durable: false, 
        exclusive: false, autoDelete: false, arguments: null);
      byte[] body = JsonSerializer.SerializeToUtf8Bytes(model.Message);
      // The exchange is empty because we are using the default exchange.
      channel.BasicPublish(exchange: string.Empty, 
        routingKey: queueNameAndRoutingKey, 
        basicProperties: null, body: body);
      model.Info = "Message sent to queue successfully.";
      return View(model);
    } 
    
  16. Views\Home 中,添加一个名为 SendMessage.cshtml 的新空 Razor 视图。

  17. 定义一个带有表单的网页来发送消息,如下所示标记:

    @model HomeSendMessageViewModel
    @{
      ViewData["Title"] = "Send a Message";
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      @if (Model is not null)
      {
        if (Model.Error is not null)
        {
          <div class="alert alert-danger">
            <h2>Error</h2>
            <p>@Model.Error</p>
          </div>
        }
        if (Model.Info is not null)
        {
          <div class="alert alert-info">
            <h2>Information</h2>
            <p>@Model.Info</p>
          </div>
        }
      }
      <form asp-controller="Home" asp-action="SendMessage" method="post">
        <div>
          <label for="message">Message</label>
          <input id="message" name="message" />
        </div>
        <div>
          <label for="productId">Product ID</label>
          <input id="productId" name="productId" />
        </div>
        <input type="submit" value="Send" />"
      </form>
    </div> 
    

使用控制台应用程序从队列中消费消息

最后,我们可以创建一个将处理队列消息的控制台应用程序:

  1. 使用您首选的代码编辑器创建一个新的控制台应用程序项目,如下所示列表:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter09

    • 项目文件和文件夹:Northwind.Queue.Consumer

  2. 将警告视为错误,添加 RabbitMQ 的包引用,将 Northwind 实体模型项目和队列消息模型项目添加到项目引用中,并静态和全局导入 System.Console 类,如下所示标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference Include=****"RabbitMQ.Client"** **Version=****"6.7.0"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<ProjectReference Include=**
    **"..\..\Chapter03\Northwind.Common.EntityModels.SqlServer\**
     **Northwind.Common.EntityModels.SqlServer.csproj"** **/>**
     **<ProjectReference Include=**
    **"..\Northwind.Queue.Models\Northwind.Queue.Models.csproj"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<Using Include=****"System.Console"** **Static=****"true"** **/>**
     **</ItemGroup>**
    </Project> 
    
  3. 在命令提示符或终端中,构建项目,如下所示命令:

    dotnet build 
    
  4. Program.cs 中,删除任何现有语句,然后添加从 product 队列读取消息的语句,如下所示代码:

    using Northwind.Queue.Models; // To use ProductQueueMessage.
    using RabbitMQ.Client; // To use ConnectionFactory.
    using RabbitMQ.Client.Events; // To use EventingBasicConsumer.
    using System.Text.Json; // To use JsonSerializer.
    string queueName = "product";
    ConnectionFactory factory = new () { HostName = "localhost" };
    using IConnection connection = factory.CreateConnection();
    using IModel channel = connection.CreateModel();
    WriteLine("Declaring queue...");
    QueueDeclareOk response = channel.QueueDeclare(
      queue: queueName,
      durable: false,
      exclusive: false,
      autoDelete: false,
      arguments: null);
    WriteLine("Queue name: {response.QueueName}, Message count: {
      response.MessageCount}, Consumer count: {response.ConsumerCount}.");
    WriteLine("Waiting for messages...");
    EventingBasicConsumer consumer = new(channel);
    consumer.Received += (model, args) =>
    {
      byte[] body = args.Body.ToArray();
      ProductQueueMessage? message = JsonSerializer
        .Deserialize<ProductQueueMessage>(body);
      if (message is not null)
      {
        WriteLine("Received product. Id: {message.Product.ProductId
          }, Name: { message.Product.ProductName}, Message: {
          message.Text}");
      }
      else
      {
        WriteLine($"Received unknown: {args.Body.ToArray()}.");
      }
    };
    // Start consuming as messages arrive in the queue.
    channel.BasicConsume(queue: queueName,
      autoAck: true,
      consumer: consumer);
    WriteLine(">>> Press Enter to stop consuming and quit. <<<");
    ReadLine(); 
    
  5. 如果您的数据库服务器没有运行(例如,因为您在 Docker、虚拟机或云中托管它),那么请确保启动它。

  6. 启动 Northwind.WebApi.Service 项目,使用 https 配置文件且不进行调试。

  7. 启动 Northwind.WebApi.Client.Mvc 项目,使用 https 配置文件且不进行调试。

  8. 启动 Northwind.Queue.Consumer 控制台应用程序项目,带或不带调试:

    • 可选地,您可以配置解决方案以同时启动所有三个项目,如图 9.4 所示(针对 Visual Studio 2022):

    图 9.4:配置三个启动项目以测试消息队列

  9. 安排控制台应用程序和 MVC 网页,以便您可以同时看到它们,然后单击 发送消息,并输入一个简单的文本消息和有效的产品 ID(1 到 77),如图 9.5 所示:

图 9.5:向队列发送消息的 ASP.NET Core MVC 网站

  1. 点击 发送,并注意控制台应用程序中出现的消息,如图 9.6 所示:

图 9.6:从队列中消费消息的控制台应用程序

  1. 在 Docker 的命令提示符或终端中,按 Ctrl + C 关闭容器,并注意结果,如下所示输出:

    2023-06-11 17:42:31.006172+00:00 [info] <0.744.0> stopped TCP listener on [::]:5672
    2023-06-11 17:42:31.008574+00:00 [info] <0.1552.0> Closing all connections in vhost '/' on node 'rabbit@e9014dbbe5f5' because the vhost is stopping
    2023-06-11 17:42:31.017407+00:00 [info] <0.557.0> Stopping message store for directory '/var/lib/rabbitmq/mnesia/rabbit@e9014dbbe5f5/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent'
    2023-06-11 17:42:31.024661+00:00 [info] <0.557.0> Message store for directory '/var/lib/rabbitmq/mnesia/rabbit@e9014dbbe5f5/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_persistent' is stopped
    2023-06-11 17:42:31.024937+00:00 [info] <0.553.0> Stopping message store for directory '/var/lib/rabbitmq/mnesia/rabbit@e9014dbbe5f5/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient'
    2023-06-11 17:42:31.031218+00:00 [info] <0.553.0> Message store for directory '/var/lib/rabbitmq/mnesia/rabbit@e9014dbbe5f5/msg_stores/vhosts/628WB79CIFDYO9LJI6DKMI09L/msg_store_transient' is stopped
    2023-06-11 17:42:31.037584+00:00 [info] <0.489.0> Management plugin: to stop collect_statistics. 
    
  2. Docker Desktop 中,注意容器已从列表中消失,但镜像仍然存在,以便下次更快地使用。

更多信息:您可以在以下链接中了解更多关于在 .NET 中使用 RabbitMQ 的信息:www.rabbitmq.com/dotnet-api-guide.html

缓存、队列和处理短暂故障的组合对于使您的服务更加健壮、可扩展和高效大有裨益。在本章的最后部分,我们将探讨长时间运行的后台服务。

实现长时间运行的后台服务

需要长时间运行的后台服务来执行诸如操作是很常见的:

  • 在定期的时间表上执行任务。

  • 处理队列中的消息。

  • 执行像构建 AI 和 ML 模型或处理视频和图像这样的密集型工作。

在遥远的过去,在 Windows 操作系统中,要在后台运行一些代码意味着构建一个 Windows 服务。例如,SQL Server 的数据库引擎就是作为 Windows 服务实现的。随着跨平台的迁移,.NET 需要一个跨平台的解决方案来在后台运行代码。

后台服务通常没有用户界面,尽管它们可能为服务的配置和管理提供用户界面。

构建工作服务

现在,让我们构建一个工作服务项目,以便我们可以看到如何托管长时间运行的后台服务:

  1. 使用您首选的代码编辑器添加一个新项目,如下面的列表所示:

    • 项目模板:工作服务 / worker

    • 解决方案文件和文件夹:Chapter09

    • 项目文件和文件夹:Northwind.Background.Workers

    • 启用 Docker:已清除

    • 不要使用顶级语句:已清除

    • 启用原生 AOT 发布:已清除

  2. Northwind.Background.Workers 项目文件中,请注意 .NET SDK 是 Microsoft.NET.Sdk.Worker,然后按照以下标记进行以下更改:

    • 将警告视为错误。

    • 添加 RabbitMQ 的包引用。

    • 添加对实体模型和队列模型项目的引用:

      <Project Sdk="**Microsoft.NET.Sdk.Worker**">
        <PropertyGroup>
          <TargetFramework>net8.0</TargetFramework>
          <Nullable>enable</Nullable>
          <ImplicitUsings>enable</ImplicitUsings>
          <UserSecretsId>dotnet-Northwind.Background.Workers-66434cdf-0fdd-4993-a399-ec9581b4b914</UserSecretsId>
       **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
        </PropertyGroup>
        <ItemGroup>
          <PackageReference Include="Microsoft.Extensions.Hosting" 
                            Version="8.0.0" />
       **<PackageReference Include=****"RabbitMQ.Client"** **Version=****"6.7.0"** **/>**
        </ItemGroup>
       **<ItemGroup>**
       **<ProjectReference Include=**
      **"..\..\Chapter03\Northwind.Common.EntityModels**
      **.SqlServer\Northwind.Common.EntityModels.SqlServer.csproj"** **/>**
       **<ProjectReference Include=**
      **"..\Northwind.Queue.Models\Northwind.Queue.Models.csproj"** **/>**
       **</ItemGroup>**
      </Project> 
      
  3. 在命令提示符或终端中通过输入以下命令构建 Northwind.Background.Workers 项目:dotnet build

  4. Program.cs 中,请注意初始化语句类似于 ASP.NET Core 项目,并且它注册了一个名为 Worker 的托管服务然后运行宿主,如下面的代码所示:

    using Northwind.Background.Workers;
    var builder = Host.CreateApplicationBuilder(args);
    builder.Services.AddHostedService<Worker>();
    var host = builder.Build();
    host.Run(); 
    
  5. Worker.cs 中,请注意 Worker 类继承自 BackgroundService 并通过循环直到请求取消、记录当前日期/时间然后暂停一秒钟来实现其 ExecuteAsync 方法,如下面的代码所示:

    namespace Northwind.Background.Workers
    {
      public class Worker : BackgroundService
      {
        private readonly ILogger<Worker> _logger;
        public Worker(ILogger<Worker> logger)
        {
          _logger = logger;
        }
        protected override async Task ExecuteAsync(
          CancellationToken stoppingToken)
        {
          while (!stoppingToken.IsCancellationRequested)
          {
            _logger.LogInformation("Worker running at: {time}", 
              DateTimeOffset.Now);
            await Task.Delay(1000, stoppingToken);
          }
        }
      }
    } 
    
  6. 不进行调试启动项目,注意当前时间每秒输出一次,然后按 Ctrl + C 关闭工作服务,如下面的输出所示:

    info: Northwind.Queue.Worker.Worker[0]
          Worker running at: 06/12/2023 08:25:02 +01:00
    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:\apps-services-net8\Chapter09\Northwind.Queue.Worker
    info: Northwind.Queue.Worker.Worker[0]
          Worker running at: 06/12/2023 08:25:03 +01:00
    info: Northwind.Queue.Worker.Worker[0]
          Worker running at: 06/12/2023 08:25:04 +01:00
    info: Northwind.Queue.Worker.Worker[0]
          Worker running at: 06/12/2023 08:25:05 +01:00
    info: Microsoft.Hosting.Lifetime[0]
          Application is shutting down... 
    

使用工作服务处理队列中的消息

现在,我们可以做一些有用的工作,比如从 RabbitMQ 队列中读取消息:

  1. Worker.cs 重命名为 QueueWorker.cs,并将 Worker 类重命名为 QueueWorker

  2. Program.cs 中,将托管服务类的名称从 Worker 更改为 QueueWorker,如下面的代码所示:

    builder.Services.AddHostedService<QueueWorker>(); 
    
  3. QueueWorker.cs 中导入命名空间以使用 RabbitMQ 队列并实现队列处理器,如下所示代码:

    **using** **Northwind.Queue.Models;** **// To use ProductQueueMessage.**
    **using** **RabbitMQ.Client;** **// To use ConnectionFactory.**
    **using** **RabbitMQ.Client.Events;** **// To use EventingBasicConsumer.**
    **using** **System.Text.Json;** **// To use JsonSerializer.**
    namespace Northwind.Background.Workers;
    public class **Queue**Worker : BackgroundService
    {
      private readonly ILogger<QueueWorker> _logger;
    **// RabbitMQ objects.**
    **private****const****string** **queueNameAndRoutingKey =** **"product"****;**
    **private****readonly** **ConnectionFactory _factory;**
    **private****readonly** **IConnection _connection;**
    **private****readonly** **IModel _channel;**
    **private****readonly** **EventingBasicConsumer _consumer;**
      public QueueWorker(ILogger<QueueWorker> logger)
      {
        _logger = logger;
     **_factory =** **new****() { HostName =** **"localhost"** **};**
     **_connection = _factory.CreateConnection();**
     **_channel = _connection.CreateModel();**
     **_consumer =** **new****(_channel);**
     **_channel.QueueDeclare(queue: queueNameAndRoutingKey, durable:** **false****,** 
     **exclusive:** **false****, autoDelete:** **false****, arguments:** **null****);**
     **_consumer =** **new****(_channel);**
     **_consumer.Received += (model, args) =>**
     **{**
    **byte****[] body = args.Body.ToArray();**
     **ProductQueueMessage? message = JsonSerializer**
     **.Deserialize<ProductQueueMessage>(body);**
    **if** **(message** **is****not****null****)**
     **{**
     **_logger.LogInformation(****$"Received product. Id:** **{**
     **message.Product.ProductId}****, Name:** **{message.Product**
     **.ProductName}****, Message:** **{message.Text}****"****);**
     **}**
    **else**
     **{**
     **_logger.LogInformation(****"Received unknown: {0}."****,** 
     **args.Body.ToArray());**
     **}**
     **};**
    **// Start consuming as messages arrive in the queue.**
     **_channel.BasicConsume(queue: queueNameAndRoutingKey,**
     **autoAck:** **true****, consumer: _consumer);**
      }
      protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
      {
        while (!stoppingToken.IsCancellationRequested)
        {
          _logger.LogInformation("Worker running at: {time}", 
            DateTimeOffset.Now);
          await Task.Delay(3000, stoppingToken);
        }
      }
    } 
    
  4. 启动 RabbitMQ 容器,如下所示命令:

    docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.12-management 
    
  5. 等待消息表明它已准备好在端口 5672 上供客户端连接,如下所示输出:

    2023-06-12 08:50:16.591574+00:00 [info] <0.599.0> Ready to start client connection listeners
    2023-06-12 08:50:16.593090+00:00 [info] <0.744.0> started TCP listener on [::]:5672 
    
  6. 让命令提示符或终端继续运行。

  7. 如果您的数据库服务器没有运行(例如,因为您在 Docker、虚拟机或云中托管它),那么请确保启动它。

  8. 不带调试启动 Northwind.WebApi.Service 项目,以便我们可以查询 Northwind 数据库中的产品。

  9. 不带调试启动 Northwind.WebApi.Client.Mvc 项目,以便我们可以向 RabbitMQ 队列发送消息。

  10. 在 MVC 网站中,点击 发送消息,然后输入消息 apples 和产品 ID 1

  11. bananas2,以及 cherries3 重复操作。

  12. 不带调试启动 Northwind.Background.Workers 项目,并注意从队列中处理了三条消息,如下所示输出:

    info: Northwind.Background.Workers.QueueWorker[0]
          Queue product is waiting for messages.
    info: Northwind.Background.Workers.QueueWorker[0]
          Worker running at: 06/12/2023 09:58:59 +01:00
    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:\apps-services-net8\Chapter09\Northwind.Queue.Worker
    info: Northwind.Background.Workers.QueueWorker[0]
          Received product. Id: 1, Name: Chai, Message: apples
    info: Northwind.Background.Workers.QueueWorker[0]
          Received product. Id: 2, Name: Chang, Message: bananas
    info: Northwind.Background.Workers.QueueWorker [0]
          Received product. Id: 3, Name: Aniseed Syrup, Message: cherries 
    

在定时计划上执行代码

工作进程服务的另一个常见用途是实现定时事件。基于定时器的后台服务可以使用触发 DoWork 方法的 System.Threading.Timer 类。

让我们向后台工作进程项目添加另一个服务:

  1. Northwind.Background.Workers 项目中,添加一个名为 TimerWorker.cs 的新类。

  2. 修改类,如下所示代码:

    namespace Northwind.Background.Workers;
    public class TimerWorker : IHostedService, IAsyncDisposable
    {
      private readonly ILogger<TimerWorker> _logger;
      private int _executionCount = 0;
      private Timer? _timer;
      private int _seconds = 5;
      public TimerWorker(ILogger<TimerWorker> logger)
      {
        _logger = logger;
      }
      private void DoWork(object? state)
      {
        int count = Interlocked.Increment(ref _executionCount);
        _logger.LogInformation(
            "{0} is working, execution count: {1:#,0}",
            nameof(TimerWorker), count);
      }
      public Task StartAsync(CancellationToken cancellationToken)
      {
        _logger.LogInformation("{0} is running.", nameof(TimerWorker));
        _timer = new Timer(callback: DoWork, state: null, 
          dueTime: TimeSpan.Zero, 
          period: TimeSpan.FromSeconds(_seconds));
        return Task.CompletedTask;
      }
      public Task StopAsync(CancellationToken cancellationToken)
      {
        _logger.LogInformation("{0} is stopping.", nameof(TimerWorker));
        _timer?.Change(dueTime: Timeout.Infinite, period: 0);
        return Task.CompletedTask;
      }
      public async ValueTask DisposeAsync()
      {
        if (_timer is IAsyncDisposable asyncTimer)
        {
          await asyncTimer.DisposeAsync();
        }
        _timer = null;
      }
    } 
    
  3. Program.cs 中添加一个语句来注册定时器工作进程服务,如下所示代码:

    builder.Services.AddHostedService<TimerWorker>(); 
    
  4. 不带调试启动 Northwind.Background.Workers 项目,并注意两个工作进程的初始化,如下所示输出:

    info: Northwind.Background.Workers.QueueWorker[0]
          Worker running at: 06/12/2023 12:58:25 +01:00
    info: Northwind.Background.Workers.TimerWorker[0]
          TimerWorker is running.
    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:\apps-services-net8\Chapter09\Northwind.Background.Workers 
    
  5. 让后台工作进程至少运行 10 秒钟,并注意队列工作进程每秒向日志写入一次,定时器工作进程每五秒向日志写入一次,如下所示输出:

    info: Northwind.Background.Workers.TimerWorker[0]
          TimerWorker is working, execution count: 1
    info: Northwind.Background.Workers.QueueWorker[0]
          Worker running at: 06/12/2023 12:58:26 +01:00
    info: Northwind.Background.Workers.QueueWorker[0]
          Worker running at: 06/12/2023 12:58:27 +01:00
    info: Northwind.Background.Workers.QueueWorker[0]
          Worker running at: 06/12/2023 12:58:28 +01:00
    info: Northwind.Background.Workers.QueueWorker[0]
          Worker running at: 06/12/2023 12:58:29 +01:00
    info: Northwind.Background.Workers.TimerWorker[0]
          TimerWorker is working, execution count: 2 
    
  6. Ctrl + C 关闭后台工作进程,并注意定时器工作进程的干净关闭,如下所示输出:

    info: Microsoft.Hosting.Lifetime[0]
          Application is shutting down...
    info: Northwind.Background.Workers.TimerWorker[0]
          TimerWorker is stopping. 
    

如果我们想要使用定时器后台服务以获得更多灵活性,而不是像每五秒那样定期运行,我们可以让它每秒检查一次预定任务,并且只有当任务达到预定时间时才运行该任务。我们需要一个地方来定义任务以及它们何时被预定运行。虽然您可以自己构建这个基础设施,但使用像 Hangfire 这样的第三方库更容易。

构建 Hangfire 的网站以托管

Hangfire 是开源的,并且可以免费用于商业用途。它支持以下使用模式:

  • 一次性执行并忽略结果:执行一次并立即启动的工作。

  • 延迟执行:虽然只执行一次,但会在未来的日期和时间执行的工作。

  • 重复执行:按照常规 CRON 调度重复执行的工作。

  • 延续执行:在父作业完成后执行的工作。

  • 批处理:事务性作业。这些仅在付费版本中可用。

Hangfire 具有持久存储,并可以配置为使用:

  • SQL Server

  • Redis

  • 内存中

  • 社区开发的存储

让我们设置一个空的 ASP.NET Core 项目来托管 Hangfire:

  1. 使用您首选的代码编辑器创建一个新的基于 Web API 控制器的项目,如下面的列表所示:

    • 项目模板:ASP.NET Core Empty / web

    • 解决方案文件和文件夹:Chapter09

    • 项目文件和文件夹:Northwind.Background.Hangfire

    • 配置 HTTPS:已选择。

    • 启用 Docker:已清除。

    • 不要使用顶级语句:已清除。

  2. 在项目文件中,将警告视为错误,并添加包引用以与 Hangfire 一起工作并将数据持久化到 SQL Server,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Hangfire.Core" Version="1.8.6" />
      <PackageReference Include="Hangfire.SqlServer" Version="1.8.6" />
      <PackageReference Include="Hangfire.AspNetCore" Version="1.8.6" />
      <PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.2" />
    </ItemGroup> 
    
  3. 构建项目以还原包。

  4. Properties 文件夹中,在 launchSettings.json 中,修改名为 https 的配置文件的 applicationUrl,使其使用端口 5095 用于 https 和端口 5096 用于 http,如下面的配置所示:

    "profiles": {
      ...
    **"https"****:****{**
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "launchUrl": "swagger",
    **"applicationUrl"****:****"https://localhost:5095;http://localhost:5096"****,**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        } 
    
  5. appsettings.Development.json 中,添加一个条目来设置 Hangfire 日志级别为 Information,如下面的 JSON 所示:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"**,**
    **"Hangfire"****:****"****Information"**
        }
      }
    } 
    
  6. 创建一个名为 Northwind.HangfireDb 的 SQL Server 数据库:

    • 如果您正在使用 Visual Studio 2022,请转到 视图 | 服务器资源管理器,右键单击 数据连接,选择 创建新的 SQL Server 数据库…,输入连接信息和数据库名称,然后单击 确定

    • 如果您正在使用 Visual Studio Code,请转到 SQL Server,右键单击并选择 新建查询,输入连接信息,然后在查询窗口中输入以下 SQL 命令并执行它:

      USE master
      GO
      CREATE DATABASE [Northwind.HangfireDb]
      GO 
      
  7. Program.cs 中,删除现有的语句,然后添加语句来配置 Hangfire 使用 SQL Server 并启用 Hangfire 仪表板,如下面的代码所示:

    using Microsoft.Data.SqlClient; // To use SqlConnectionStringBuilder.
    using Hangfire; // To use GlobalConfiguration.
    SqlConnectionStringBuilder connection = new();
    connection.InitialCatalog = "Northwind.HangfireDb";
    connection.MultipleActiveResultSets = true;
    connection.Encrypt = true;
    connection.TrustServerCertificate = true;
    connection.ConnectTimeout = 5; // Default is 30 seconds.
    connection.DataSource = "."; // To use local SQL Server.
    // To use Windows Integrated authentication.
    connection.IntegratedSecurity = true;
    /*
    // To use SQL Server authentication.
    builder.UserID = "sa";
    builder.Password = "123456";
    builder.PersistSecurityInfo = false;
    */
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddHangfire(config => config
      .SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
      .UseSimpleAssemblyNameTypeSerializer()
      .UseRecommendedSerializerSettings()
      .UseSqlServerStorage(connection.ConnectionString));
    builder.Services.AddHangfireServer();
    var app = builder.Build();
    app.UseHangfireDashboard();
    app.MapGet("/", () => 
      "Navigate to /hangfire to see the Hangfire Dashboard.");
    app.MapHangfireDashboard();
    app.Run(); 
    
  8. 不带调试启动 Northwind.Background.Hangfire 项目,并注意控制台输出的消息,如下所示输出:

    info: Hangfire.SqlServer.SqlServerObjectsInstaller[0]
          Start installing Hangfire SQL objects...
    info: Hangfire.SqlServer.SqlServerObjectsInstaller[0]
          Hangfire SQL objects installed.
    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: https://localhost:5095
    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: http://localhost:5096
    info: Hangfire.BackgroundJobServer[0]
          Starting Hangfire Server using job storage: 'SQL Server: .@Northwind.HangfireDb'
    info: Hangfire.BackgroundJobServer[0]
          Using the following options for SQL Server job storage: Queue poll interval: 00:00:00.
    info: Hangfire.BackgroundJobServer[0]
          Using the following options for Hangfire Server:
              Worker count: 20
              Listening queues: 'default'
              Shutdown timeout: 00:00:15
              Schedule polling interval: 00:00:15
    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:\apps-services-net8\Chapter09\Northwind.Background.Hangfire
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b successfully announced in 140.4628 ms
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b is starting the registered dispatchers: ServerWatchdog, ServerJobCancellationWatcher, ExpirationManager, CountersAggregator, SqlServerHeartbeatProcess, Worker, DelayedJobScheduler, RecurringJobScheduler...
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b all the dispatchers started 
    
  9. 在浏览器中,注意查看纯文本消息,然后在地址栏中追加 hangfire,并注意Hangfire 仪表板用户界面,如图 9.7 所示:

图 9.7:Hangfire 仪表板用户界面

  1. 关闭浏览器窗口,在 Hangfire 服务的命令提示符或终端中,按 Ctrl + C 来干净地关闭服务器,并注意消息,如下面的输出所示:

    info: Microsoft.Hosting.Lifetime[0]
          Application is shutting down...
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b caught stopping signal...
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b All dispatchers stopped
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b successfully reported itself as stopped in 2.8874 ms
    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:14120:c8ea792b has been stopped in total 19.6204 ms 
    

使用 Hangfire 调度作业

接下来,我们将允许客户端通过向 Web 服务 POST 来调度一个作业(在这种情况下,只是将消息写入控制台,在未来的指定秒数后):

  1. Northwind.Background.Hangfire 项目中,添加一个名为 WriteMessageJobDetail.cs 的新类文件。

  2. WriteMessageJobDetail.cs 中,定义一个表示计划作业的类,如下面的代码所示:

    namespace Northwind.Background.Models;
    public class WriteMessageJobDetail
    {
      public string? Message { get; set; }
      public int Seconds { get; set; }
    } 
    
  3. Northwind.Background.Hangfire 项目中,添加一个名为 Program.Methods.cs 的新类文件。

  4. Program.Methods.cs 中,通过一个方法扩展部分 Program 类,该方法可以将消息以绿色颜色写入控制台,如下面的代码所示:

    using static System.Console;
    partial class Program
    {
      public static void WriteMessage(string? message)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.Green;
        WriteLine(message);
        ForegroundColor = previousColor;
      }
    } 
    
  5. Program.cs 中,导入命名空间以处理工作,如下面的代码所示:

    using Northwind.Background.Models; // To use WriteMessageJobDetail.
    using Microsoft.AspNetCore.Mvc; // To use [FromBody]. 
    
  6. Program.cs 中,在映射 GET 请求的语句之后,将 POST 请求映射到相对路径 /schedulejob,从 POST 请求的正文获取工作详情,并使用它来安排一个后台工作,使用 Hangfire 在未来的指定秒数后运行,如下面的代码所示:

    app.MapPost("/schedulejob", ([FromBody] WriteMessageJobDetail job) =>
      {
        BackgroundJob.Schedule(
          methodCall: () => WriteMessage(job.Message),
          enqueueAt: DateTimeOffset.UtcNow + 
            TimeSpan.FromSeconds(job.Seconds));
      }); 
    
  7. 不带调试启动 Northwind.Background.Hangfire 项目。

  8. 在命令提示符或终端中,确认所有调度器都已启动,如下面的输出所示:

    info: Hangfire.Server.BackgroundServerProcess[0]
          Server desktop-j1pqhr7:13916:9f1851b5 all the dispatchers started 
    
  9. 在浏览器中,导航到 /hangfire 以查看 Hangfire Dashboard。

  10. 在你的代码编辑器中,在 HttpRequests 文件夹中,创建一个名为 hangfire-schedule-job.http 的文件。

  11. hangfire-schedule-job.http 中添加语句以向 Hangfire 服务发送 POST 请求,如下面的代码所示:

    ### Configure a variable for the Hangfire web service base address.
    @base_address = https://localhost:5095/
    POST {{base_address}}schedulejob
    Content-Type: application/json
    {
      "message": "Hangfire is awesome!",
      "seconds": 30
    } 
    
  12. 发送请求,并注意成功的响应,如下面的输出所示:

    HTTP/1.1 200 OK
    Content-Length: 0
    Connection: close
    Date: Mon, 12 Jun 2023 16:04:20 GMT
    Server: Kestrel
    Alt-Svc: h3=":5095"; ma=86400 
    
  13. 在浏览器中,在 Hangfire Dashboard 中,点击顶部菜单中的 作业,在左侧菜单中点击 作业,并注意有一个计划任务,如图 9.8 所示:

图片

图 9.8:Hangfire 中的计划任务

  1. 等待 30 秒钟后,然后在左侧菜单中点击 成功,并注意工作已成功,如图 9.9 所示:

图片

图 9.9:Hangfire 中的成功作业

  1. 在命令提示符或终端中,注意写入控制台的消息,如下面的输出所示:

    Hangfire is awesome! 
    
  2. 关闭浏览器并关闭服务器。

更多信息:你可以在以下链接中了解更多关于 Hangfire 的信息:www.hangfire.io/

练习和探索

通过回答一些问题,进行一些动手实践,并深入探索本章的主题来测试你的知识和理解。

练习 9.1 – 测试你的知识

回答以下问题:

  1. 与内存相比,从 SSD 读取 1 MB 数据需要多长时间?

  2. 绝对过期和滑动过期有什么区别?

  3. Size 在内存缓存中使用的测量单位是什么?

  4. 你已经编写了以下语句来获取内存缓存的信息,但 statsnull。你必须做什么来解决这个问题?

    MemoryCacheStatistics? stats = _memoryCache.GetCurrentStatistics(); 
    
  5. (a) 内存缓存和 (b) 分布式缓存可以存储哪些数据类型?

  6. 重试模式和断路器模式有什么区别?

  7. 当使用 RabbitMQ 默认的直接交换时,名为 product 的队列的路由键必须是什么?

  8. 广播交换和主题交换有什么区别?

  9. RabbitMQ 默认监听哪个端口?

  10. 当从 BackgroundService 类继承时,你必须覆盖哪个方法,该方法是主机自动调用来运行你的服务的?

练习 9.2 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-9---caching-queuing-and-resilient-background-services

练习 9.3 – 用另一个分布式缓存实现替换分布式内存缓存

在本章中,我们使用了分布式内存缓存实现来探索如何使用分布式缓存。

作为一项可选练习,如果你还没有注册 Azure 账户,请注册一个,创建一个名为 Azure Cache for Redis 的资源,并将你的 Web 服务项目配置更改为使用它。

Northwind.WebApi.Service 中,你需要引用 Redis 包,注释掉之前注册的分布式缓存实现,然后调用扩展方法将 Redis 注册为分布式缓存实现:

// builder.Services.AddDistributedMemoryCache();
builder.Services.AddStackExchangeRedisCache(options =>
{
  options.Configuration = builder.Configuration
    .GetConnectionString("MyRedisConStr");
  options.InstanceName = "SampleInstance";
}); 

在以下链接中了解更多信息:

练习 9.4 – 用 Quartz.NET 替换 Hangfire

Quartz.NET 是与 Hangfire 类似的库。阅读官方文档,然后创建一个名为 Northwind.Background.Quartz 的项目,该项目的功能与 Northwind.Background.Hangfire 相同:

www.quartz-scheduler.net/

练习 9.5 – 审查可靠的 Web 应用模式

可靠的 Web 应用RWA)模式是一套包含指导性建议的最佳实践,帮助开发者成功地将本地 Web 项目迁移到云端。它包括一个参考实现,并展示了如何利用 Azure 云服务以可靠、安全、高性能、成本效益的方式,通过现代的设计、开发和运营实践来现代化关键业务负载:

learn.microsoft.com/en-us/azure/architecture/web-apps/guides/reliable-web-app/dotnet/plan-implementation

以下链接包含关于 .NET RWA 模式的视频集合:

www.youtube.com/playlist?list=PLI7iePan8aH54gIDJquV61dE3ENyaDi3Q

摘要

在本章中,你学习了:

  • 关于服务架构以及系统的不同部分如何影响性能。

  • 如何使用内存和分布式缓存将数据缓存得更靠近操作。

  • 如何控制客户端和中间件对 HTTP 缓存的访问。

  • 如何使用 Polly 实现容错。

  • 如何使用 RabbitMQ 实现队列。

  • 如何使用 BackgroundService 和 Hangfire 实现长时间运行的后台服务。

在下一章中,你将学习如何使用 Azure Functions 来实现纳米服务,也就是无服务器服务。

在 Discord 上了解更多

要加入这本书的 Discord 社区——在那里你可以分享反馈、向作者提问,并了解新版本——请扫描下面的二维码:

packt.link/apps_and_services_dotnet8

第十章:使用 Azure 函数构建无服务器纳米服务

在本章中,你将了解 Azure 函数,它可以配置为在执行时仅需要服务器端资源。它们在触发活动时执行,例如向队列发送消息或将文件上传到 Azure 存储时,或者在定期的时间间隔内。

本章将涵盖以下主题:

  • 理解 Azure 函数

  • 构建 Azure 函数项目

  • 响应定时器和资源触发器

  • 将 Azure 函数项目发布到云端

  • 清理 Azure 函数资源

理解 Azure 函数

Azure 函数是一个事件驱动的无服务器计算平台。您可以在本地构建和调试,然后部署到微软 Azure 云。Azure 函数可以使用多种语言实现,而不仅仅是 C# 和 .NET。它支持 Visual Studio 2022 和 Visual Studio Code 的扩展,以及一个命令行工具。

但首先,你可能想知道,“没有服务器如何提供服务?”

无服务器字面上并不意味着没有服务器。无服务器真正意味着的是一个没有永久运行服务器的服务,通常这意味着大部分时间不运行或资源使用低,并在需要时动态扩展。这可以节省很多成本。

例如,组织通常有一些只需要每小时运行一次、每月运行一次或按需运行的业务功能。也许组织在月底打印支票(在英国称为 cheques)来支付员工的工资。这些支票可能需要将工资金额转换为文字以打印在支票上。可以将将数字转换为文字的功能实现为一个无服务器服务。

例如,使用内容管理系统,编辑器可能会上传新的图片,这些图片可能需要以各种方式处理,如生成缩略图和其他优化。这项工作可以添加到队列中,或者当文件上传到 Blob 存储时触发 Azure 函数。

Azure 函数可以远不止是一个单一的功能。它们支持复杂的状态化工作流和事件驱动的解决方案,使用Durable Functions

我在这本书中不涉及 Durable Functions,如果您感兴趣,可以在此链接中了解更多关于使用 C# 和 .NET 实现它们的信息:learn.microsoft.com/en-us/azure/azure-functions/durable/durable-functions-overview?tabs=csharp

Azure 函数基于触发器和绑定的编程模型,使你的无服务器服务能够响应事件并连接到其他服务,如数据存储。

Azure 函数触发器和绑定

触发器绑定是 Azure 函数的关键概念。

触发器是导致函数执行的原因。每个函数必须有一个,且只有一个触发器。以下列出了最常见的触发器:

  • HTTP: 此触发器响应传入的 HTTP 请求,通常是 GETPOST

  • Azure SQL: 此触发器在检测到 SQL 表上的更改时响应。

  • Cosmos DB: 此触发器使用 Cosmos DB 变更流来监听插入和更新。

  • Timer: 此触发器响应预定时间的到来。如果函数失败,则不会重试。直到下一次预定时间,函数不会被再次调用。

  • SignalR: 此触发器响应来自 Azure SignalR 服务的消息。

  • 队列RabbitMQ: 这些触发器响应队列中到达的消息,准备进行处理。

  • Blob Storage: 此触发器响应新的或更新的 二进制大对象 (Blob)。

  • Event GridEvent Hub: 这些触发器在预定义的事件发生时响应。

绑定允许函数有输入和输出。每个函数可以有零个、一个或多个绑定。以下列出了一些常见的绑定:

  • Azure SQL: 在 SQL Server 数据库中读取或写入表。

  • Blob Storage: 读取或写入存储为 BLOB 的任何文件。

  • Cosmos DB: 读取或写入云规模数据存储中的文档。

  • SignalR: 接收或执行远程方法调用。

  • HTTP: 发起 HTTP 请求并接收响应。

  • 队列RabbitMQ: 向队列写入消息或从队列读取消息。

  • SendGrid: 发送电子邮件消息。

  • Twilio: 发送短信消息。

  • IoT hub: 向互联网连接的设备写入。

你可以在以下链接中查看支持的触发器和绑定的完整列表:learn.microsoft.com/en-us/azure/azure-functions/functions-triggers-bindings?tabs=csharp#supported-bindings

触发器和绑定对于不同的语言配置方式不同。对于 C# 和 Java,你需要在方法和参数上使用属性进行装饰。对于其他语言,你需要配置一个名为 function.json 的文件。

NCRONTAB 表达式

Timer 触发器使用 NCRONTAB 表达式来定义计时器的频率。默认时区是 协调世界时 (UTC)。这可以被覆盖,但你真的应该使用 UTC,原因你已经在 第七章处理日期、时间和国际化 中了解到了。

如果你是在 App Service 计划中托管,那么你可以使用 TimeSpan 作为替代,但我推荐学习 NCRONTAB 表达式以获得灵活性。

NCRONTAB 表达式由五个或六个部分组成(如果包含秒数):

`* * * * * *`
`- - - - - -`
`| | | | | |`
`| | | | | +--- day of week (0 - 6) (Sunday=0)`
`| | | | +----- month (1 - 12)`
`| | | +------- day of month (1 - 31)`
`| | +--------- hour (0 - 23)`
`| +----------- min (0 - 59)`
`+------------- sec (0 - 59)` 

上面的值字段中的星号 * 表示该列的所有合法值,就像括号中的那样。你可以使用破折号指定范围,并使用 / 指定步长值。以下是一些如何以这种格式指定值的示例:

  • 0 表示该值。例如,对于小时,表示午夜。

  • 0,6,12,18 表示那些列出的值。例如,对于小时,表示午夜、上午 6 点、中午 12 点和下午 6 点。

  • 3-7 表示该值的包含范围。例如,对于小时,表示上午 3 点、上午 4 点、上午 5 点、上午 6 点和上午 7 点。

  • 4/3 表示起始值为 4,步长值为 3。例如,对于小时,表示上午 4 点、上午 7 点、上午 10 点、下午 1 点、下午 4 点、下午 7 点和晚上 10 点。

表 10.1 显示了一些更多示例:

表达式 描述
0 5 * * * * 每小时一次,在每小时的第 5 分钟。
0 0,10,30,40 * * * * 每小时四次 – 在每个小时的第 0 分钟、第 10 分钟、第 30 分钟和第 40 分钟。
* * */2 * * * 每 2 小时一次。
0,15 * * * * * 每分钟的第 0 秒和第 15 秒。
0/15 * * * * * 每分钟的第 0 秒、第 15 秒、第 30 秒和第 45 秒,也称为每 15 秒一次。
0-15 * * * * * 在每分钟的 0 秒、1 秒、2 秒、3 秒,等等,直到 15 秒之后,但不是每分钟的 16 秒到 59 秒。
0 30 9-16 * * * 每天八次 – 在上午 9:30、上午 10:30,等等,直到下午 4:30。
0 */5 * * * * 每小时 12 次 – 在每小时的第 5 分钟的每 0 秒。
0 0 */4 * * * 每天六次 – 在每天每 4 小时的第 0 分钟。
0 30 9 * * * 每天上午 9:30。
0 30 9 * * 1-5 每个工作日上午 9:30。
0 30 9 * * Mon-Fri 每个工作日上午 9:30。
0 30 9 * Jan Mon 1 月每周一上午 9:30。

表 10.1:NCRONTAB 表达式的示例

现在让我们构建一个简单的控制台应用程序来测试您对 NCRONTAB 表达式的理解:

  1. 使用您首选的代码编辑器将名为 NCrontab.Console 的新控制台应用程序添加到 Chapter10 解决方案中。

  2. NCrontab.Console 项目中,将警告视为错误,全局和静态导入 System.Console 类,并为 NCrontab.Signed 添加包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="NCrontab.Signed" Version="3.3.3" />
    </ItemGroup> 
    

    NCRONTAB 库仅用于解析表达式。它本身不是一个调度器。您可以在以下链接的 GitHub 仓库中了解更多信息:github.com/atifaziz/NCrontab

  3. 构建项目 NCrontab.Console 以恢复包。

  4. Program.cs 文件中,删除现有的语句。添加语句以定义 2023 年的日期范围,输出 NCRONTAB 语法的摘要,并构建一个 NCRONTAB 计划,然后使用它来输出 2023 年将发生的下一个 40 个事件,如下面的代码所示:

    using NCrontab; // To use CrontabSchedule and so on.
    DateTime start = new(year: 2023, month: 1, day: 1);
    DateTime end = start.AddYears(1);
    WriteLine($"Start at:   {start:ddd, dd MMM yyyy HH:mm:ss}");
    WriteLine($"End at:     {end:ddd, dd MMM yyyy HH:mm:ss}");
    WriteLine();
    string sec = "0,30";
    string min = "*";
    string hour = "*";
    string dayOfMonth = "*";
    string month = "*";
    string dayOfWeek = "*";
    string expression = string.Format(
      "{0,-3} {1,-3} {2,-3} {3,-3} {4,-3} {5,-3}",
      sec, min, hour, dayOfMonth, month, dayOfWeek);
    WriteLine($"Expression: {expression}");
    WriteLine(@"            \ / \ / \ / \ / \ / \ /");
    WriteLine($"             -   -   -   -   -   -");
    WriteLine($"             |   |   |   |   |   |");
    WriteLine($"             |   |   |   |   |   +--- day of week (0 - 6) (Sunday=0)");
    WriteLine($"             |   |   |   |   +------- month (1 - 12)");
    WriteLine($"             |   |   |   +----------- day of month (1 - 31)");
    WriteLine($"             |   |   +--------------- hour (0 - 23)");
    WriteLine($"             |   +------------------- min (0 - 59)");
    WriteLine($"             +----------------------- sec (0 - 59)");
    WriteLine();
    CrontabSchedule schedule = CrontabSchedule.Parse(expression, 
      new CrontabSchedule.ParseOptions { IncludingSeconds = true });
    IEnumerable<DateTime> occurrences = schedule.GetNextOccurrences(start, end);
    // Output the first 40 occurrences.
    foreach (DateTime occurrence in occurrences.Take(40))
    {
      WriteLine($"{occurrence:ddd, dd MMM yyyy HH:mm:ss}");
    } 
    

    注意以下内容:

    • 发生事件的默认潜在时间跨度是 2023 年全年。

    • 默认表达式是 0,30 * * * * *,意味着在每个月每个工作日每天每小时的每分钟的第 0 秒和第 30 秒。

    • 语法帮助的格式化假设每个组件将占用三个字符宽,因为用于输出格式的 -3。你可以编写一个更聪明的算法来动态调整箭头指向可变宽度的组件,但我很懒惰。我将把这个留给你作为练习。

    • 我们的表达式包括秒,因此在解析时必须将其设置为附加选项。

    • 定义计划后,计划调用其 GetNextOccurrences 方法以返回所有计算出的发生序列。

    • 循环只输出前 40 个发生次数。这应该足以理解大多数表达式的工作方式。

  5. 不带调试启动控制台应用程序,并注意发生次数为每 30 秒一次,如下部分输出所示:

    Start at:   Sun, 01 Jan 2023 00:00:00
    End at:     Mon, 01 Jan 2024 00:00:00
    Expression: 0,30 *   *   *   *   *
                \ / \ / \ / \ / \ / \ /
                 -   -   -   -   -   -
                 |   |   |   |   |   |
                 |   |   |   |   |   +--- day of week (0 - 6) (Sunday=0)
                 |   |   |   |   +------- month (1 - 12)
                 |   |   |   +----------- day of month (1 - 31)
                 |   |   +--------------- hour (0 - 23)
                 |   +------------------- min (0 - 59)
                 +----------------------- sec (0 - 59)
    Sun, 01 Jan 2023 00:00:30
    Sun, 01 Jan 2023 00:01:00
    Sun, 01 Jan 2023 00:01:30
    ...
    Sun, 01 Jan 2023 00:19:30
    Sun, 01 Jan 2023 00:20:00 
    

    注意,尽管开始时间是 Sun, 01 Jan 2023 00:00:00,但该值不包括在发生次数中,因为它不是一个“下一个”发生。

  6. 关闭控制台应用程序。

  7. Program.cs 中,修改表达式的组件以测试表中的某些示例,或者创建自己的示例。尝试表达式 0 0 */4 * * *,并注意它应该有如下部分输出:

    Start at:   Sun, 01 Jan 2023 00:00:00
    End at:     Mon, 01 Jan 2024 00:00:00
    Expression: 0   0   */4 *   *   *
                \ / \ / \ / \ / \ / \ /
                 -   -   -   -   -   -
                 |   |   |   |   |   |
                 |   |   |   |   |   +--- day of week (0 - 6) (Sunday=0)
                 |   |   |   |   +------- month (1 - 12)
                 |   |   |   +----------- day of month (1 - 31)
                 |   |   +--------------- hour (0 - 23)
                 |   +------------------- min (0 - 59)
                 +----------------------- sec (0 - 59)
    Sun, 01 Jan 2023 04:00:00
    Sun, 01 Jan 2023 08:00:00
    Sun, 01 Jan 2023 12:00:00
    Sun, 01 Jan 2023 16:00:00
    Sun, 01 Jan 2023 20:00:00
    Mon, 02 Jan 2023 00:00:00
    Mon, 02 Jan 2023 04:00:00
    Mon, 02 Jan 2023 08:00:00
    Mon, 02 Jan 2023 12:00:00
    Mon, 02 Jan 2023 16:00:00
    Mon, 02 Jan 2023 20:00:00
    Tue, 03 Jan 2023 00:00:00
    ...
    Sat, 07 Jan 2023 12:00:00
    Sat, 07 Jan 2023 16:00:00 
    

注意,尽管开始时间是 Sun, 01 Jan 2023 00:00:00,但该值不包括在发生次数中,因为它不是一个“下一个”发生。因此,星期日只有五个发生次数。从星期一开始,每天应有预期的六个发生次数。

Azure Functions 版本和语言

运行时主机 Azure Functions 的版本 4 是唯一仍然普遍可用的版本。所有旧版本都已达到生命周期的终点。

良好实践:Microsoft 建议在所有语言中使用 v4。v1、v2 和 v3 处于维护模式,应避免使用。

Azure Functions v4 支持的语言和平台包括:

  • C#F#:.NET 8, .NET 7, .NET 6, 和 .NET Framework 4.8。注意,.NET 6 和 .NET 7(以及未来的 .NET 9)仅支持在隔离工作模型中,因为它们是 标准支持期STS)版本,或者,在 .NET 6 的情况下,它们是较旧的长期支持(LTS)版本。.NET 8 支持隔离和进程内工作模型,因为它是一个 长期支持LTS)版本。

  • JavaScript:Node 14, 16, 和 18。TypeScript 通过转换(转换/编译)到 JavaScript 支持。

  • Java 8, 11, 和 17。

  • PowerShell 7.2。

  • Python 3.7, 3.8, 3.9, 和 3.10。

更多信息:您可以在以下链接中查看整个语言表:learn.microsoft.com/en-us/azure/azure-functions/functions-versions?tabs=v4&pivots=programming-language-csharp#languages

在这本书中,我们将只查看使用 C# 和 .NET 8 实现 Azure Functions,这样我们就可以使用进程内和隔离工作模型并获得 LTS。

对于高级用途,您甚至可以注册一个自定义处理程序,这样您就可以使用您喜欢的任何语言来实现 Azure 函数。您可以在以下链接中了解更多关于 Azure Functions 自定义处理程序的信息:learn.microsoft.com/en-us/azure/azure-functions/functions-custom-handlers

Azure Functions 工作模型

Azure Functions 有两种工作模型,进程内和隔离,如下列表所述:

  • 进程内:您的函数在一个类库中实现,必须在与宿主相同的进程中运行,这意味着您的函数必须在 .NET 最新的 LTS 版本上运行。最新的 LTS 版本是 .NET 8。下一个 LTS 版本将在 2025 年 11 月的 .NET 10 中发布,但 .NET 8 将是支持进程内托管的最后一个版本。在 .NET 8 之后,将只支持所有版本的隔离工作模型。

  • 隔离型:您的函数在一个控制台应用程序中实现,该应用程序在其自己的进程中运行。因此,您的函数可以在任何支持的 .NET 版本上执行,完全控制其 Main 入口点,并具有调用中间件等附加功能。从 .NET 9 开始,这将是唯一的工作模型。您可以在以下链接中了解更多关于此决定的信息:techcommunity.microsoft.com/t5/apps-on-azure-blog/net-on-azure-functions-august-2023-roadmap-update/ba-p/3910098

最佳实践:新项目应使用隔离工作模型。

Azure Functions 托管计划

在本地测试后,您必须将 Azure Functions 项目部署到 Azure 托管计划。您可以从以下列表中选择三种 Azure Functions 计划:

  • 消费型:在此方案中,根据活动动态添加和删除托管实例。此方案与 无服务器 方案最为接近。在负载高峰期间自动扩展。成本仅针对运行时的计算资源。您可以配置函数执行时间的超时,以确保您的函数不会永远运行。

  • 高级:此方案支持弹性上下文扩展,始终处于预热状态的实例以避免冷启动,无限制的执行时长,多核实例大小最多可达四个核心,成本可能更具可预测性,并为多个 Azure Functions 项目提供高密度应用分配。成本基于实例间分配的核心秒数和内存。必须始终分配至少一个实例,因此即使该月没有执行,也始终会有一个最低的每月成本。

  • 专用:在云中的服务器农场等效环境中执行。托管由控制分配服务器资源的 Azure App Service 计划提供。Azure App Service 计划包括基本、标准、高级和隔离。如果你已经有一个用于其他项目(如 ASP.NET Core MVC 网站、gRPC、OData 和 GraphQL 服务等)的 App Service 计划,那么这个计划可能是一个特别好的选择。费用仅限于 App Service 计划。你可以在这个计划中托管尽可能多的 Azure Functions 和其他 Web 应用。

    警告!高级和专用计划都运行在 Azure App Service 计划上。你必须仔细选择与你的 Azure Functions 托管计划兼容的正确 App Service 计划。例如,对于高级计划,你应该选择一个弹性高级计划,如 EP1。如果你选择一个像 P1V1 这样的 App Service 计划,那么你选择的是一个不会弹性扩展的专用计划!

你可以在以下链接中了解更多关于你的选择:learn.microsoft.com/en-us/azure/azure-functions/functions-scale

Azure 存储要求

Azure Functions 需要一个 Azure 存储账户来存储某些绑定和触发器的信息。这些 Azure 存储服务也可以由你的代码用于其实施:

  • Azure 文件存储:在消费或高级计划中存储和运行你的函数应用代码。

  • Azure Blob 存储:存储绑定和函数键的状态。

  • Azure 队列存储:某些触发器用于故障和重试处理。

  • Azure 表存储:Durable Functions 中的任务中心使用 Blob、队列和表存储。

使用 Azurite 本地测试

Azurite 是一个开源的本地环境,用于测试 Azure Functions 及其相关的 Blob、队列和表存储。Azurite 在 Windows、Linux 和 macOS 上都是跨平台的。Azurite 取代了旧的 Azure 存储模拟器。

要安装 Azurite:

  • 对于 Visual Studio 2022,Azurite 已包含在内。

  • 对于 Visual Studio Code,搜索并安装 Azurite 扩展。

  • 对于 JetBrains Rider,安装 Azure Toolkit for Rider 插件,该插件包括 Azurite。

  • 对于在命令提示符下的安装,你必须安装 Node.js 8 或更高版本,然后可以输入以下命令:

    npm install -g azurite 
    

一旦你在本地测试了 Azure 函数,你可以切换到云中的 Azure 存储账户。

你可以在以下链接中了解更多关于 Azurite 的信息:learn.microsoft.com/en-us/azure/storage/common/storage-use-azurite

Azure Functions 授权级别

Azure Functions 有三个授权级别,用于控制是否需要 API 密钥:

  • 匿名:不需要 API 密钥。

  • 函数:需要函数级别的密钥。

  • 管理员:需要主密钥。

API 密钥可通过 Azure 门户获取。

Azure Functions 支持依赖注入

Azure Functions 中的依赖注入建立在标准的 .NET 依赖注入功能之上,但具体实现方式取决于你选择的工人模型。

要注册依赖服务,创建一个继承自 FunctionsStartup 类的类,并重写其 Configure 方法。添加 [FunctionsStartup] 程序集属性以指定注册的启动类名。将服务添加到传递给方法的 IFunctionsHostBuilder 实例中。你将在本章后面的任务中这样做。

通常,实现 Azure Functions 函数的类是 static 的,并且有一个 static 方法。static 类不使用构造函数进行实例化。依赖注入使用构造函数注入,这意味着你必须使用实例类来注入服务以及你的函数类实现。你将在编码任务中看到如何做到这一点。

安装 Azure Functions 核心工具

Azure Functions 核心工具提供了创建函数的核心运行时和模板,这使你能够在 Windows、macOS 和 Linux 上使用任何代码编辑器进行本地开发。

Azure Functions 核心工具包含在 Visual Studio 2022 的 Azure 开发 工作负载中,因此你可能已经安装了它。

你可以从以下链接安装最新版本的 Azure Functions 核心工具

www.npmjs.com/package/azure-functions-core-tools

前一个链接中找到的页面提供了在 Windows 上使用 Microsoft 软件安装程序 (MSI)winget、在 Mac 上使用 Homebrew、在任何操作系统上使用 npm 以及常见 Linux 发行版上安装的说明。

如果你使用 JetBrains Rider,那么通过 Rider 安装 Azure Functions 核心工具。

构建 Azure Functions 项目

现在,我们可以创建一个 Azure Functions 项目。虽然它们可以通过 Azure 门户在云端创建,但开发者最好先在本地创建和运行它们,以便获得更好的体验。一旦你在自己的电脑上测试了函数,你就可以将其部署到云端。

每个代码编辑器在开始 Azure Functions 项目时都有略微不同的体验,因此让我们逐一查看,从 Visual Studio 2022 开始。

使用 Visual Studio 2022

如果你更喜欢使用 Visual Studio 2022,以下是如何创建 Azure Functions 项目的步骤:

  1. 在 Visual Studio 2022 中,创建一个新项目,如下列所示:

    • 项目模板:Azure Functions

    • 解决方案文件和文件夹:Chapter10

    • 项目文件和文件夹:Northwind.AzureFunctions.Service

    • 函数工作者.NET 8.0 Isolated (长期支持)

    • 函数Http 触发器

    • 使用 Azurite 作为运行时存储账户 (AzureWebJobsStorage): 已选择

    • 禁用 Docker:已清除

    • 授权级别匿名

  2. 点击 创建

  3. 配置解决方案的启动项目为当前选择。

使用 Visual Studio Code

如果您更喜欢使用 Visual Studio Code,以下是如何创建 Azure Functions 项目的步骤:

  1. 在 Visual Studio Code 中,导航到 扩展 并搜索 Azure Functions (ms-azuretools.vscode-azurefunctions)。它依赖于两个其他扩展:Azure 账户 (ms-vscode.azure-account) 和 Azure 资源 (ms-azuretools.vscode-azureresourcegroups),因此这些也将被安装。点击 安装 按钮来安装扩展。

  2. 创建一个名为 Northwind.AzureFunctions.Service 的文件夹。(如果您之前使用 Visual Studio 2022 创建了相同的项目,那么请创建一个名为 Chapter10-vscode 的新文件夹,并将此新项目文件夹放在那里。)

  3. 在 Visual Studio Code 中,打开 Northwind.AzureFunctions.Service 文件夹。

  4. 导航到 视图 | 命令面板,输入 azure f,然后在 Azure Functions 命令列表中,点击 Azure Functions:创建新项目…

  5. 选择包含您的函数项目的 Northwind.AzureFunctions.Service 文件夹。

  6. 在提示时,选择以下选项:

    • 选择语言:C#

    • 选择一个 .NET 运行时:.NET 8 Isolated LTS

    • 选择项目第一个函数的模板:HTTP trigger

    • 提供一个函数名称:NumbersToWordsFunction

    • 提供一个命名空间:Northwind.AzureFunctions.Service

    • 选择授权级别:匿名

  7. 导航到 终端 | 新建终端

  8. 在命令提示符下,构建项目,如下所示:

    dotnet build 
    

使用 func CLI

如果您更喜欢使用命令行和一些其他代码编辑器,以下是如何创建和启动 Azure Functions 项目的步骤:

  1. 创建一个名为 Chapter10-cli 的文件夹,并在其中创建一个名为 Northwind.AzureFunctions.Service 的子文件夹。

  2. 在命令提示符或终端中,在 Northwind.AzureFunctions.Service 文件夹中,使用以下命令创建一个新的 Azure Functions 项目,使用 C#:

    func init --csharp 
    
  3. 在命令提示符或终端中,在 Northwind.AzureFunctions.Service 文件夹中,使用以下命令创建一个新的 Azure Functions 函数,使用 HTTP trigger 可以匿名调用:

    func new --name NumbersToWordsFunction --template "HTTP trigger" --authlevel "anonymous" 
    
  4. 可选地,您可以在本地启动函数,如下所示:

    func start 
    

如果您在命令提示符或终端中找不到 func,那么请尝试使用 Chocolatey 安装 Azure Functions Core Tools,如以下链接所述:community.chocolatey.org/packages/azure-functions-core-tools

检查 Azure Functions 项目

在我们编写函数之前,让我们回顾一下构成 Azure Functions 项目的要素:

  1. 打开项目文件,并注意 Azure Functions 版本和实现响应 HTTP 请求的 Azure 函数所需的包引用,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <AzureFunctionsVersion>v4</AzureFunctionsVersion>
        <OutputType>Exe</OutputType>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Version="1.19.0" 
          Include="Microsoft.Azure.Functions.Worker" />
        <PackageReference Version="3.0.13" 
          Include="Microsoft.Azure.Functions.Worker.Extensions.Http" />
        <PackageReference Version="1.14.0"
          Include="Microsoft.Azure.Functions.Worker.Sdk" />
      </ItemGroup>
      <ItemGroup>
        <None Update="host.json">
          <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        </None>
        <None Update="local.settings.json">
          <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
          <CopyToPublishDirectory>Never</CopyToPublishDirectory>
        </None>
      </ItemGroup>
      <ItemGroup>
        <Using Include="System.Threading.ExecutionContext" 
               Alias="ExecutionContext" />
      </ItemGroup>
    </Project> 
    
  2. host.json 中,请注意已启用将日志记录到 Application Insights,但排除了 Request 类型,如下所示:

    {
      "version": "2.0",
      "logging": {
        "applicationInsights": {
          "samplingSettings": {
            "isEnabled": true,
            "excludedTypes": "Request"
          },
          "enableLiveMetricsFilters": true
        }
      }
    } 
    

    Application Insights 是 Azure 的监控和日志服务。我们将在本章中不使用它。

  3. local.settings.json中,确认在本地开发期间,你的项目将使用本地开发存储和隔离的工作模型,如下所示:

    {
      "IsEncrypted": false,
      "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
        "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
      }
    } 
    
  4. 如果AzureWebJobsStorage设置是空的或缺失的,这可能发生在你使用 Visual Studio Code 的情况下,那么添加它,将其设置为UseDevelopmentStorage=true,然后保存更改。

    FUNCTIONS_WORKER_RUNTIME是你项目使用的语言。dotnet表示.NET 类库;dotnet-isolated表示.NET 控制台应用程序。其他值包括javanodepowershellpython

  5. Properties文件夹中,在launchSettings.json中,注意为网络服务随机分配的端口号,如下面的配置所示:

    {
      "profiles": {
        "Northwind.AzureFunctions.Service": {
          "commandName": "Project",
          "commandLineArgs": "--port 7274",
          "launchBrowser": false
        }
      }
    } 
    
  6. 将端口号更改为5101并将更改保存到文件中。

实现一个简单的函数

让我们使用Humanizer包实现将数字转换为文字的函数:

  1. 在项目文件中,添加对 Humanizer 的包引用,如下所示:

    <PackageReference Include="Humanizer" Version="2.14.1" /> 
    
  2. 构建项目以恢复包。

    如果你使用 Visual Studio 2022,在Northwind.AzureFunctions.Service项目中,右键单击Function1.cs并将其重命名为NumbersToWordsFunction.cs

  3. NumbersToWordsFunction.cs中,修改内容以实现将金额作为数字转换为文字的 Azure 函数,如下面的代码所示:

    using Humanizer; // To use ToWords extension method.
    using Microsoft.Azure.Functions.Worker; // To use [HttpTrigger].
    using Microsoft.Azure.Functions.Worker.Http; // To use HttpResponseData.
    using Microsoft.Extensions.Logging; // To use ILogger.
    namespace Northwind.AzureFunctions.Service;
    public class NumbersToWordsFunction
    {
      private readonly ILogger _logger;
      public NumbersToWordsFunction(ILoggerFactory loggerFactory)
      {
        _logger = loggerFactory.CreateLogger<NumbersToWordsFunction>();
      }
      [Function(nameof(NumbersToWordsFunction))]
      public HttpResponseData Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, 
          "get", "post", Route = null)] HttpRequestData req)
      {
        _logger.LogInformation("C# HTTP trigger function processed a request.");
        string? amount = req.Query["amount"];
        HttpResponseData response;
        if (long.TryParse(amount, out long number))
        {
          response = req.CreateResponse(System.Net.HttpStatusCode.OK);
          response.WriteString(number.ToWords());
        }
        else
        {
          response = req.CreateResponse(System.Net.HttpStatusCode.BadRequest);
          response.WriteString($"Failed to parse: {amount}");
        }
        return response;
      }
    } 
    

测试一个简单的函数

现在我们可以在我们本地开发环境中测试这个函数:

  1. 启动Northwind.AzureFunctions.Service项目:

    • 如果你使用 Visual Studio Code,你需要导航到运行和调试面板,确保附加到.NET 函数被选中,然后点击运行按钮。

    • 在 Windows 上,如果你看到来自Windows Defender 防火墙Windows 安全警报,那么点击允许访问

  2. 注意Azure Functions Core Tools托管你的函数,如下面的输出和图 10.1所示:

    Azure Functions Core Tools
    Core Tools Version:       4.0.5390 Commit hash: N/A  (64-bit)
    Function Runtime Version: 4.25.3.21264
    [2023-10-01T11:42:05.319Z] Found C:\apps-services-net8\Chapter10\Northwind.AzureFunctions.Service\Northwind.AzureFunctions.Service.csproj. Using for user secrets file configuration.
    Functions:
            NumbersToWordsFunction: [GET,POST] http://localhost:5101/api/NumbersToWordsFunction
    For detailed output, run func with --verbose flag.
    [2023-06-05T11:42:14.265Z] Host lock lease acquired by instance ID '00000000000000000000000011150C3D'. 
    

    图 10.1:Azure Functions Core Tools 托管一个函数

    Host lock lease消息可能需要几分钟才能出现,所以如果它没有立即显示,请不要担心。

  3. 选择你的函数的 URL 并将其复制到剪贴板。

  4. 启动 Chrome。

  5. 将 URL 粘贴到地址框中,追加查询字符串?amount=123456,并在浏览器的十二万三千四百五十六中注意成功的响应,如图图 10.2所示:

图 10.2:对本地运行的 Azure 函数的成功调用

  1. 在命令提示符或终端中,注意函数调用成功,如下面的输出所示:

    [2023-101-01T11:32:51.574Z] Executing 'NumbersToWordsFunction' (Reason='This function was programmatically called via the host APIs.', Id=234d3122-ff3d-4896-94b3-db3c8b5013d8)
    [2023-10-01T11:32:51.603Z] C# HTTP trigger function processed a request.
    [2023-10-01T11:32:51.629Z] Executed 'NumbersToWordsFunction' (Succeeded, Id=234d3122-ff3d-4896-94b3-db3c8b5013d8, Duration=96ms) 
    
  2. 尝试调用函数时在查询字符串中不包含金额,或者金额为非整数值,如apples,并注意函数返回一个400状态码,表示有自定义消息的无效请求,Failed to parse: apples

  3. 关闭 Chrome 并关闭 Web 服务器(或在 Visual Studio Code 中停止调试)。

响应定时器和资源触发

现在你已经看到了一个响应 HTTP 请求的 Azure Functions 函数,让我们构建一些响应其他类型触发器的函数。

内置了对 HTTP 和定时器触发的支持。其他绑定的支持作为扩展包实现。

实现定时器触发的函数

首先,我们将创建一个每小时运行一次的函数,请求amazon.com上我书籍第八版(C# 12 和.NET 8 – 现代跨平台开发基础)的页面,以便我可以跟踪其在美国的最佳卖家排名。

函数需要执行 HTTP GET请求,因此我们应该注入 HTTP 客户端工厂。为此,我们需要添加一些额外的包引用并创建一个特殊的启动类:

  1. Northwind.AzureFunctions.Service项目中,添加用于处理 Azure Functions 扩展和计时器的包引用,如下面的标记所示:

    <PackageReference Include="Microsoft.Azure.Functions.Extensions" 
                      Version="1.1.0" />
    <PackageReference Version="4.2.0" 
      Include="Microsoft.Azure.Functions.Worker.Extensions.Timer" /> 
    
  2. 构建项目Northwind.AzureFunctions.Service以恢复包。

  3. Program.cs中,导入用于处理依赖注入和 HTTP 媒体头的命名空间,如下面的代码所示:

    using Microsoft.Extensions.DependencyInjection; // To use AddHttpClient().
    using System.Net.Http.Headers; // To use MediaTypeWithQualityHeaderValue. 
    
  4. Program.cs中添加语句以配置一个 HTTP 客户端工厂,用于向亚马逊发送请求,就像它是 Chrome 浏览器作为依赖服务一样,如下面的高亮代码所示:

    var host = new HostBuilder()
      .ConfigureFunctionsWorkerDefaults()
     **.ConfigureServices(services =>**
     **{** 
     **services.AddHttpClient(name:** **"Amazon"****,** 
     **configureClient: options =>**
     **{**
     **options.BaseAddress =** **new** **Uri(****"https://www.amazon.com"****);**
    **// Pretend to be Chrome with US English.**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"****text/html"****));**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"application/xhtml+xml"****));**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"application/xml"****,** **0.9****));**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"image/avif"****));**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"image/webp"****));**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"image/apng"****));**
     **options.DefaultRequestHeaders.Accept.Add(**
    **new** **MediaTypeWithQualityHeaderValue(****"*/*"****,** **0.8****));**
    
     **options.DefaultRequestHeaders.AcceptLanguage.Add(**
    **new** **StringWithQualityHeaderValue(****"en-US"****));**
     **options.DefaultRequestHeaders.AcceptLanguage.Add(**
    **new** **StringWithQualityHeaderValue(****"en"****,****0.8****));**
     **options.DefaultRequestHeaders.UserAgent.Add(**
    **new****(productName:** **"Chrome"****, productVersion:** **"114.0.5735.91"****));**
     **});**
     **})**
      .Build();
    host.Run(); 
    

    2023 年 6 月 5 日的 Chrome 版本是114.0.5735.91。主要版本号通常每月递增,因此到 2023 年 11 月,它可能为119,到 2024 年 11 月,它可能为131

  5. 添加一个名为ScrapeAmazonFunction.cs的类文件。

  6. 修改其内容以实现一个函数,该函数请求亚马逊网站上我第七版书的页面,并处理使用 GZIP 压缩的响应,以提取书籍的最佳卖家排名,如下面的代码所示:

    using Microsoft.Azure.Functions.Worker; // To use [Function].
    using Microsoft.Extensions.Logging; // To use ILogger.
    using System.IO.Compression; // To use GZipStream, CompressionMode.
    namespace Northwind.AzureFunctions.Service;
    public class ScrapeAmazonFunction
    {
      private const string relativePath = 
        "12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/";
      private readonly IHttpClientFactory _clientFactory;
      private readonly ILogger _logger;
      public ScrapeAmazonFunction(IHttpClientFactory clientFactory,
        ILoggerFactory loggerFactory)
      {
        _clientFactory = clientFactory;
        _logger = loggerFactory.CreateLogger<ScrapeAmazonFunction>();
      }
      [Function(nameof(ScrapeAmazonFunction))]
      public async Task Run( // Every hour.
        [TimerTrigger("0 0 * * * *")] TimerInfo timer)
      {
        _logger.LogInformation($"C# Timer trigger function executed at {
          DateTime.UtcNow}.");
        _logger.LogInformation(
          $"C# Timer trigger function next three occurrences at: {
            timer.ScheduleStatus?.Next}.");
        HttpClient client = _clientFactory.CreateClient("Amazon");
        HttpResponseMessage response = await client.GetAsync(relativePath);
        _logger.LogInformation(
          $"Request: GET {client.BaseAddress}{relativePath}");
        if (response.IsSuccessStatusCode)
        {
          _logger.LogInformation("Successful HTTP request.");
          // Read the content from a GZIP stream into a string.
          Stream stream = await response.Content.ReadAsStreamAsync();
          GZipStream gzipStream = new(stream, CompressionMode.Decompress);
          StreamReader reader = new(gzipStream);
          string page = reader.ReadToEnd();
          // Extract the Best Sellers Rank.
          int posBsr = page.IndexOf("Best Sellers Rank");
          string bsrSection = page.Substring(posBsr, 45);
          // bsrSection will be something like:
          //   "Best Sellers Rank: </span> #22,258 in Books ("
          // Get the position of the # and the following space.
          int posHash = bsrSection.IndexOf("#") + 1;
          int posSpaceAfterHash = bsrSection.IndexOf(" ", posHash);
          // Get the BSR number as text.
          string bsr = bsrSection.Substring(
            posHash, posSpaceAfterHash - posHash);
          bsr = bsr.Replace(",", null); // remove commas
          // Parse the text into a number.
          if (int.TryParse(bsr, out int bestSellersRank))
          {
            _logger.LogInformation(
              $"Best Sellers Rank #{bestSellersRank:N0}.");
          }
          else
          {
            _logger.LogError(
              $"Failed to extract BSR number from: {bsrSection}.");
          }
        }
        else
        {
          _logger.LogError("Bad HTTP request.");
        }
      }
    } 
    

测试定时器触发的函数

可以通过以下格式的 HTTP GET请求检索函数信息:

http://locahost:5101/admin/functions/<functionname>

现在我们可以测试本地开发环境中的定时器函数:

  1. 启动Northwind.AzureFunctions.Service项目:

    • 如果你使用 Visual Studio Code,你需要确保已安装 Azurite 扩展并且 Azurite 服务正在运行。导航到运行和调试面板,确保已选择附加到.NET Functions,然后点击运行按钮。
  2. 注意现在有两个函数,如下面的部分输出所示:

    Functions:
            NumbersToWordsFunction: [GET,POST] http://localhost:5101api/NumbersToWordsFunction
            ScrapeAmazonFunction: timerTrigger
    For detailed output, run func with --verbose flag. 
    
  3. HttpRequests文件夹中,添加一个名为azurefunctions-scrapeamazon.http的新文件。

  4. 修改其内容以定义一个全局变量和两个请求到本地托管的 Azure Functions 服务,如下代码所示:

    ### Configure a variable for the Azure Functions service base address.
    @base_address = http://localhost:5101/
    ### Get information about the NumbersToWordsFunction function.
    {{base_address}}admin/functions/NumbersToWordsFunction
    ### Get information about the ScrapeAmazonFunction function.
    {{base_address}}admin/functions/ScrapeAmazonFunction 
    
  5. 发送第一个请求并注意返回了一个包含NumbersToWordsFunction函数信息的 JSON 文档,如下所示:

    HTTP/1.1 200 OK
    Content-Length: 918
    Connection: close
    Content-Type: application/json; charset=utf-8
    Date: Mon, 05 Jun 2023 13:32:11 GMT
    Server: Kestrel
    {
      "name": "NumbersToWordsFunction",
      "script_root_path_href": null,
      "script_href": "http://localhost:5101/admin/vfs/bin/Northwind.AzureFunctions.Service.dll",
      "config_href": null,
      "test_data_href": null,
      "href": "http://localhost:5101/admin/functions/NumbersToWordsFunction",
      "invoke_url_template": "http://localhost:5101/api/numberstowordsfunction",
      "language": "dotnet-isolated",
      "config": {
        "name": "NumbersToWordsFunction",
        "entryPoint": "Northwind.AzureFunctions.Service.NumbersToWordsFunction.Run",
        "scriptFile": "Northwind.AzureFunctions.Service.dll",
        "language": "dotnet-isolated",
        "functionDirectory": null,
        "bindings": [
          {
            "name": "req",
            "direction": "In",
            "type": "httpTrigger",
            "authLevel": "Anonymous",
            "methods": [
              "get",
              "post"
            ],
            "properties": {}
          },
          {
            "name": "$return",
            "type": "http",
            "direction": "Out"
          }
        ]
      },
      "files": null,
      "test_data": null,
      "isDisabled": false,
      "isDirect": true,
      "isProxy": false
    } 
    
  6. 发送第二个请求并注意返回了一个包含ScrapeAmazonFunction函数信息的 JSON 文档。对于这个函数,最有趣的信息是绑定类型和计划,如下部分响应所示:

    "bindings": [
      {
        "name": "timer",
        "direction": "In",
        "type": "timerTrigger",
        "schedule": "0 0 * * * *",
        "properties": {}
      }
    ], 
    
  7. 添加一个第三个请求,通过发送一个包含空 JSON 文档的POST请求到其admin端点,手动触发计时器函数,而无需等待小时标记,如下代码所示:

    ### Make a manual request to the Timer function.
    POST {{base_address}}admin/functions/ScrapeAmazonFunction
    Content-Type: application/json
    {} 
    
  8. 发送第三个请求并注意它被成功接受,如下所示:

    HTTP/1.1 202 Accepted 
    
  9. 在请求体中移除{},再次发送,并注意从客户端错误响应中我们可以推断出需要一个空 JSON 文档,如下响应所示:

    HTTP/1.1 400 Bad Request 
    
  10. 将空 JSON 文档添加回去。

  11. 在 Azure Functions 服务的命令提示符或终端中,注意函数是由我们的调用触发的。它输出了触发时间(下午 1:49)和其正常计时计划中的下一次发生时间(如果服务继续运行,时间为下午 2 点),如下所示:

    [2023-10-01T13:49:53.939Z] Executing 'Functions.ScrapeAmazonFunction' (Reason='This function was programmatically called via the host APIs.', Id=1df349a1-79c5-4b52-a7f1-d0f8f0d5cd9c)
    [2023-10-01T13:49:54.095Z] C# Timer trigger function executed at 01/10/2023 13:49:54.
    [2023-10-01T13:49:54.095Z] C# Timer trigger function next occurrence at: 01/10/2023 14:00:00.
    [2023-10-01T13:49:54.105Z] Start processing HTTP request GET https://www.amazon.com/12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/
    [2023-10-01T13:49:54.106Z] Sending HTTP request GET https://www.amazon.com/12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/
    [2023-10-01T13:49:54.520Z] Received HTTP response after 407.4353ms - OK
    [2023-10-01T13:49:54.521Z] End processing HTTP request after 420.1273ms - OK
    [2023-10-01T13:49:56.212Z] Successful HTTP request.
    [2023-10-01T13:49:56.212Z] Request: GET https://www.amazon.com/12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/
    [2023-10-01T13:49:56.251Z] Best Sellers Rank #384,269.
    [2023-10-01T13:49:56.275Z] Executed 'Functions.ScrapeAmazonFunction' (Succeeded, Id=1df349a1-79c5-4b52-a7f1-d0f8f0d5cd9c, Duration=2362ms) 
    
  12. 可选地,等待直到小时标记,并注意下一次触发,如下所示:

    [2023-10-01T14:00:00.023Z] Executing 'Functions.ScrapeAmazonFunction' (Reason='Timer fired at 2023-10-01T15:00:00.0220351+01:00', Id=aa9f7495-6066-4b0a-ba81-42582d677321)
    [2023-10-01T14:00:00.027Z] C# Timer trigger function executed at 01/10/2023 14:00:00.
    [2023-10-01T14:00:00.028Z] Start processing HTTP request GET https://www.amazon.com/12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/
    [2023-10-01T14:00:00.027Z] C# Timer trigger function next occurrence at: 01/10/2023 15:00:00.
    [2023-10-01T14:00:00.028Z] Sending HTTP request GET https://www.amazon.com/12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/
    [2023-10-01T14:00:00.337Z] Received HTTP response after 305.1877ms - OK
    [2023-10-01T14:00:00.339Z] End processing HTTP request after 305.5222ms - OK
    [2023-10-01T14:00:01.899Z] Successful HTTP request.
    [2023-10-01T14:00:01.899Z] Request: GET https://www.amazon.com/12-NET-Cross-Platform-Development-Fundamentals/dp/1837635870/
    [2023-10-01T14:00:01.931Z] Best Sellers Rank #387,339.
    [2023-10-01T14:00:01.931Z] Executed 'Functions.ScrapeAmazonFunction' (Succeeded, Id=aa9f7495-6066-4b0a-ba81-42582d677321, Duration=1909ms) 
    
  13. 如果我停止运行服务,等待超过一小时,然后启动服务,它将立即运行函数,因为已经过期,如下所示(高亮显示):

    [2023-10-01T16:19:31.369Z] Trigger Details: UnscheduledInvocationReason: IsPastDue, OriginalSchedule: 2023-10-01T15:00:00.0000000+01:00 
    
  14. 关闭 Azure Functions 服务。

实现与队列和 Blob 一起工作的函数

HTTP 触发的函数直接以纯文本形式响应了GET请求。我们现在将定义一个类似的功能,将其绑定到队列存储,并向队列中添加一条消息,表示需要生成并上传到 Blob 存储的图像。然后可以将其打印出来作为检查。

当在本地运行服务时,我们希望在本地文件系统中生成支票 BLOB 的图像,以便更容易确保其正确工作。我们将在本地设置中设置一个自定义环境变量来检测该条件。

我们需要一个看起来像手写的字体。谷歌有一个有用的网站,你可以在这里搜索、预览和下载字体。我们将使用的是 Caveat 字体,如下链接所示:

fonts.google.com/specimen/Caveat?category=Handwriting&preview.text=one%20hundred%20and%20twenty%20three%20thousand,%20four%20hundred%20and%20fifty%20six&preview.text_type=custom#standard-styles

让我们开始:

  1. 从上面的链接下载字体,解压 ZIP 文件,并将文件复制到名为 fonts 的文件夹中,如图 10.3 所示的 Visual Studio 2022:

图 10.3:Visual Studio 2022 中带有 Caveat 字体文件的字体文件夹

  1. 选择 Caveat-Regular.ttf 字体文件。

  2. 属性 中,将 复制到输出目录 设置为 始终复制,如图 10.3 所示。这将在项目文件中添加一个条目,如下所示高亮显示的标记:

    <ItemGroup>
     **<None Update=****"fonts\Caveat\static\Caveat-Regular.ttf"****>**
     **<CopyToOutputDirectory>Always</CopyToOutputDirectory>**
     **</None>**
      <None Update="host.json">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      </None>
      <None Update="local.settings.json">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
        <CopyToPublishDirectory>Never</CopyToPublishDirectory>
      </None>
    </ItemGroup> 
    

    如果你使用的是 Visual Studio Code,请手动将前面的条目添加到项目文件中。

  3. Northwind.AzureFunctions.Service 项目中,添加用于处理 Azure 队列和 Blob 存储扩展以及使用 ImageSharp 绘图的包引用,如下所示标记:

    <PackageReference Version="5.2.0" Include=
      "Microsoft.Azure.Functions.Worker.Extensions.Storage.Queues" />
    <PackageReference Version="6.2.0" Include=
      "Microsoft.Azure.Functions.Worker.Extensions.Storage.Blobs" />
    <PackageReference Include="SixLabors.ImageSharp" Version="3.0.2" />
    <PackageReference Include="SixLabors.ImageSharp.Drawing" 
                      Version="2.0.0" /> 
    
  4. 构建项目以还原包。

  5. Northwind.AzureFunctions.Service 项目中,添加一个名为 NumbersToChecksFunction.cs 的新类。

  6. NumbersToChecksFunction.cs 中添加语句以将函数与用于队列存储的输出绑定注册,以便它可以写入命名队列,并且当金额成功解析后返回到队列,如下所示代码:

    using Humanizer; // To use ToWords extension method.
    using Microsoft.Azure.Functions.Worker; // To use [Function] and so on.
    using Microsoft.Azure.Functions.Worker.Http; // To use HttpRequestData.
    using Microsoft.Extensions.Logging; // To use ILogger.
    namespace Northwind.AzureFunctions.Service;
    public class NumbersToChecksFunction
    {
      private readonly ILogger _logger;
      public NumbersToChecksFunction(ILoggerFactory loggerFactory)
      {
        _logger = loggerFactory.CreateLogger<NumbersToChecksFunction>();
      }
      [Function(nameof(NumbersToChecksFunction))]
      [QueueOutput("checksQueue")] // Return value is written to this queue.
      public string Run(
        [HttpTrigger(AuthorizationLevel.Anonymous,
          "get", "post", Route = null)] HttpRequestData request)
      {
        _logger.LogInformation("C# HTTP trigger function processed a request.");
        string? amount = request.Query["amount"];
        if (long.TryParse(amount, out long number))
        {
          return number.ToWords();
        }
        else
        {
          return $"Failed to parse: {amount}";
        }
      }
    } 
    
  7. local.settings.json 中,添加一个名为 IS_LOCAL 的环境变量,其值为 true,如图所示高亮显示的配置:

    {
      "IsEncrypted": false,
      "Values": {
        "AzureWebJobsStorage": "UseDevelopmentStorage=true",
        "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"**,**
    **"IS_LOCAL"****:****true**
      }
    } 
    
  8. 添加一个名为 CheckGeneratorFunction.cs 的类文件。

  9. 修改其内容,如下所示代码:

    using Microsoft.Azure.Functions.Worker; // To use [Function] and so on.
    using Microsoft.Extensions.Logging; // To use ILogger.
    using SixLabors.Fonts; // To use Font.
    using SixLabors.ImageSharp.Drawing; // To use IPath.
    using SixLabors.ImageSharp.Drawing.Processing; // To use Brush, Pen.
    namespace Northwind.AzureFunctions.Service;
    public class CheckGeneratorFunction
    {
      private readonly ILogger _logger;
      public CheckGeneratorFunction(ILoggerFactory loggerFactory)
      {
        _logger = loggerFactory.CreateLogger<NumbersToWordsFunction>();
      }
      [Function(nameof(CheckGeneratorFunction))]
      [BlobOutput("checks-blob-container/check.png")]
      public byte[] Run(
        [QueueTrigger("checksQueue")] string message)
      {
        _logger.LogInformation("C# Queue trigger function executed.");
        _logger.LogInformation($"Body: {message}.");
        // Create a new blank image with a white background.
        using (Image<Rgba32> image = new(width: 1200, height: 600,
          backgroundColor: new Rgba32(r: 255, g: 255, b: 255, a: 100)))
        {
          // Load the font file and create a large font.
          FontCollection collection = new();
          FontFamily family = collection.Add(
            @"fonts\Caveat\static\Caveat-Regular.ttf");
          Font font = family.CreateFont(72);
          string amount = message.Body.ToString();
          DrawingOptions options = new()
          {
            GraphicsOptions = new()
            {
              ColorBlendingMode = PixelColorBlendingMode.Multiply
            }
          };
          // Define some pens and brushes.
          Pen blackPen = Pens.Solid(Color.Black, 2);
          Pen blackThickPen = Pens.Solid(Color.Black, 8);
          Pen greenPen = Pens.Solid(Color.Green, 3);
          Brush redBrush = Brushes.Solid(Color.Red);
          Brush blueBrush = Brushes.Solid(Color.Blue);
          // Define some paths and draw them.
          IPath border = new RectangularPolygon(
            x: 50, y: 50, width: 1100, height: 500);
          image.Mutate(x => x.Draw(options, blackPen, border));
          IPath star = new Star(x: 150.0f, y: 150.0f, 
            prongs: 5, innerRadii: 20.0f, outerRadii: 30.0f);
          image.Mutate(x => x.Fill(options, redBrush, star)
                             .Draw(options, greenPen, star));
          IPath line1 = new Polygon(new LinearLineSegment(
            new PointF(x: 100, y: 275), new PointF(x: 1050, y: 275)));
    
          image.Mutate(x => x.Draw(options, blackPen, line1));
          IPath line2 = new Polygon(new LinearLineSegment(
            new PointF(x: 100, y: 365), new PointF(x: 1050, y: 365)));
          image.Mutate(x => x.Draw(options, blackPen, line2));
          RichTextOptions textOptions = new(font)
          {
            Origin = new PointF(100, 200),
            WrappingLength = 1000,
            HorizontalAlignment = HorizontalAlignment.Left
          };
          image.Mutate(x => x.DrawText(
            textOptions, amount, blueBrush, blackPen));
          string blobName = $"{DateTime.UtcNow:yyyy-MM-dd-hh-mm-ss}.png";
          _logger.LogInformation($"Blob filename: {blobName}.");
          try
          {
            if (Environment.GetEnvironmentVariable("IS_LOCAL") == "true")
            {
              // Create blob in the local filesystem.
              string folder = $@"{System.Environment.CurrentDirectory}\blobs";
              if (!Directory.Exists(folder))
              {
                Directory.CreateDirectory(folder);
              }
              log.LogInformation($"Blobs folder: {folder}");
              string blobPath = $@"{folder}\{blobName}";
              image.SaveAsPng(blobPath);
            }
            // Create BLOB in Blob Storage via a memory stream.
            MemoryStream stream = new();
            image.SaveAsPng(stream);
            stream.Seek(0, SeekOrigin.Begin);
            return stream.ToArray();
          }
          catch (System.Exception ex)
          {
            log.LogError(ex.Message);
          }
          return Array.Empty<byte>();
        }
      }
    } 
    

注意以下事项:

  • [QueueTrigger("checksQueue")] string message 参数表示函数由添加到 checksQueue 的消息触发,并且队列项自动传递给名为 message 的参数。

  • 我们使用 ImageSharp 创建一个 1200x600 的支票图像。

  • 我们使用当前的 UTC 日期和时间来命名 BLOB 以避免重复。在实际实现中,你可能需要更健壮的机制,如 GUID。

  • 如果将 IS_LOCAL 环境变量设置为 true,则我们将图像作为 PNG 格式保存到本地文件系统的 blobs 子文件夹中。

  • 我们将图像保存为 PNG 格式到内存流中,然后作为字节数组返回并上传到由 [BlobOutput("checks-blob-container/check.png")] 属性定义的 BLOB 容器。

测试与队列和 BLOB 一起工作的函数

现在我们可以测试在本地开发环境中与队列和 BLOB 一起工作的函数:

  1. 启动 Northwind.AzureFunctions.Service 项目:

    如果你使用的是 Visual Studio Code,你需要导航到 运行和调试 面板,确保已选择 附加到 .NET Functions,然后点击 运行 按钮。

  2. 注意现在有四个函数,如下所示部分输出:

    Functions:
            NumbersToChecksFunction: [GET,POST] http://localhost:5101/api/NumbersToChecksFunction
            NumbersToWordsFunction: [GET,POST] http://localhost:5101/api/NumbersToWordsFunction
            CheckGeneratorFunction: queueTrigger
            ScrapeAmazonFunction: timerTrigger 
    
  3. HttpRequests 文件夹中,添加一个名为 azurefunctions-numberstochecks.http 的新文件。

  4. 修改其内容,如下所示代码:

    ### Configure a variable for the Azure Functions base address.
    @base_address = http://localhost:5101/
    ### Trigger the NumbersToChecksFunction function.
    GET {{base_address}}api/NumbersToChecksFunction?amount=123456 
    
  5. 发送请求并注意返回了一个包含 NumbersToWordsFunction 函数信息的 JSON 文档,如下所示响应:

    Response time: 2524 ms
    Status code: OK (200)
    Transfer-Encoding: chunked
    Date: Mon, 05 Jun 2023 13:53:11 GMT
    Server: Kestrel
    Content-Type: text/plain; charset=utf-8
    Content-Length: 64
    ------------------------------------------------
    Content:
    one hundred and twenty-three thousand four hundred and fifty-six 
    
  6. 在命令提示符或终端中,注意函数调用成功,并将消息发送到队列中,从而触发了 CheckGeneratorFunction,如下所示输出:

    [2023-06-05T13:53:12.175Z] Executing 'NumbersToWordsFunction' (Reason='This function was programmatically called via the host APIs.', Id=b6a49d34-edbf-4c2a-97f2-195f8d06cd13)
    [2023-06-05T13:53:12.195Z] C# HTTP trigger function processed a request.
    [2023-06-05T13:53:12.262Z] Executed 'NumbersToWordsFunction' (Succeeded, Id=b6a49d34-edbf-4c2a-97f2-195f8d06cd13, Duration=104ms)
    [2023-06-05T13:53:14.302Z] Executing 'CheckGeneratorFunction' (Reason='New queue message detected on 'checksqueue'.', Id=2697ddc0-46dd-4c06-b960-fb5a443ec929)
    [2023-06-05T13:53:14.305Z] Trigger Details: MessageId: 229e4961-bfaf-46da-bb17-040ffc2bbf91, DequeueCount: 1, InsertedOn: 2023-06-05T13:53:12.000+00:00
    [2023-06-05T13:53:14.313Z] C# Queue trigger function executed.
    [2023-06-05T13:53:14.314Z] MessageId: 229e4961-bfaf-46da-bb17-040ffc2bbf91.
    [2023-06-05T13:53:14.316Z] InsertedOn: 05/06/2023 13:53:12 +00:00.
    [2023-06-05T13:53:14.317Z] ExpiresOn: 12/06/2023 13:53:12 +00:00.
    [2023-06-05T13:53:14.318Z] Body: one hundred and twenty-three thousand four hundred and fifty-six.
    [2023-06-05T13:53:14.845Z] Blob name: 2023-06-05-01-53-14.png.
    [2023-06-05T13:53:14.848Z] Blobs folder: C:\apps-services-net8\Chapter10\Northwind.AzureFunctions.Service\bin\Debug\net8.0\blobs
    [2023-06-05T13:53:15.057Z] Blob sequence number: 0.
    [2023-06-05T13:53:15.060Z] Executed 'CheckGeneratorFunction' (Succeeded, Id=2697ddc0-46dd-4c06-b960-fb5a443ec929, Duration=776ms)
    [2023-06-05T13:53:20.979Z] Host lock lease acquired by instance ID '00000000000000000000000011150C3D'. 
    
  7. Northwind.AzureFunctions.Service\bin\Debug\net8.0\blobs 文件夹中,注意在 blobs 文件夹中创建的本地图像,如图 图 10.4 所示:

图片

图 10.4:在项目 blob 文件夹中生成的验证图像

  1. 注意验证图像,如图 图 10.5 所示:

图片

图 10.5:在 Windows Paint 中打开的验证图像

将 Azure Functions 项目发布到云端

现在,让我们在 Azure 订阅中创建一个函数应用和相关资源,然后将您的函数部署到云端并运行。

如果您还没有 Azure 账户,那么您可以在以下链接处免费注册:azure.microsoft.com/en-us/free/

使用 Visual Studio 2022 发布

Visual Studio 2022 提供了一个用于发布到 Azure 的图形用户界面:

  1. 解决方案资源管理器 中,右键单击 Northwind.AzureFunctions.Service 项目并选择 发布

  2. 选择 Azure 然后点击 下一步

  3. 选择 Azure Function App (Linux) 并点击 下一步

  4. 登录并输入您的 Azure 凭据。

  5. 选择您的订阅;例如,我选择了名为 Pay-As-You-Go 的订阅。

  6. 函数实例 部分,点击 + 创建新 按钮。

  7. 完成对话框,如图 图 10.6 所示:

    • 名称:这必须是全局唯一的。建议根据项目名称和当前日期时间命名。

    • 订阅名称:选择您的订阅。

    • 资源组:选择或创建一个新的资源组,以便稍后更容易删除所有内容。我选择了 apps-services-book

    • 计划类型消费(仅为您使用的付费)。

    • 位置:您最近的数据中心。我选择了 UK South

    • Azure 存储:在您最近的数据中心中创建一个名为 northwindazurefunctions(或任何其他全局唯一的名称)的新账户,并为账户类型选择 Standard – 本地冗余存储

    • 应用程序洞察

图片

图 10.6:创建新的 Azure 函数应用

  1. 点击 创建。此过程可能需要一分钟或更长时间。

  2. 发布 对话框中,点击 完成 然后点击 关闭

  3. 发布 窗口中,点击 发布 按钮,如图 图 10.7 所示:

图片

图 10.7:使用 Visual Studio 2022 准备发布的 Azure Function App

  1. 查看输出窗口,如图以下发布输出所示:

    Build started...
    1>------ Build started: Project: Northwind.AzureFunctions.Service, Configuration: Release Any CPU ------
    1>Northwind.AzureFunctions.Service -> C:\apps-services-net8\Chapter10\Northwind.AzureFunctions.Service\bin\Release\net8.0\Northwind.AzureFunctions.Service.dll
    2>------ Publish started: Project: Northwind.AzureFunctions.Service, Configuration: Release Any CPU ------
    2>Northwind.AzureFunctions.Service -> C:\apps-services-net8\Chapter10\Northwind.AzureFunctions.Service\bin\Release\net8.0\Northwind.AzureFunctions.Service.dll
    2>Northwind.AzureFunctions.Service -> C:\apps-services-net8\Chapter10\Northwind.AzureFunctions.Service\obj\Release\net8.0\PubTmp\Out\
    2>Publishing C:\apps-services-net8\Chapter10\Northwind.AzureFunctions.Service\obj\Release\net8.0\PubTmp\Northwind.AzureFunctions.Service - 20230605152148071.zip to https://northwindazurefunctionsservice20230605151137.scm.azurewebsites.net/api/zipdeploy...
    2>Zip Deployment succeeded.
    ========== Build: 1 succeeded, 0 failed, 0 up-to-date, 0 skipped ==========
    ========== Build started at 3:21 PM and took 03:34.100 minutes ==========
    ========== Publish: 1 succeeded, 0 failed, 0 skipped ==========
    ========== Publish started at 3:21 PM and took 03:34.100 minutes ==========
    Waiting for Function app to be ready...
    Function app is ready 
    
  2. 发布 窗口中,点击 打开站点 并注意您的 Azure Functions v4 主站已准备就绪。

  3. 通过将以下相对 URL 添加到地址框中,在浏览器中测试函数,如图 图 10.8 所示:

    /api/NumbersToWordsFunction?amount=987654321

    图片

    图 10.8:调用 Azure 云中托管的功能

    使用 Visual Studio Code 进行发布

    您可以在以下链接中学习如何使用 Visual Studio Code 进行发布:

learn.microsoft.com/en-us/azure/azure-functions/functions-develop-vs-code?tabs=csharp#sign-in-to-azure

现在您已成功将 Azure Functions 项目发布到云端,了解如何高效地管理资源变得很重要。让我们探讨如何清理我们的 Azure Functions 资源,以避免不必要的成本并确保资源管理整洁。

清理 Azure Functions 资源

您可以使用以下步骤删除函数应用及其相关资源,以避免产生更多成本:

  1. 在您的浏览器中,导航到 portal.azure.com/

  2. 在 Azure 门户中,在您的函数应用的 概览 选项卡中,选择 资源组

  3. 确认它只包含您想要删除的资源;例如,应该有一个 存储账户、一个 函数应用 和一个 应用服务计划

  4. 如果您确定要删除组中的所有资源,请单击 删除资源组 并接受任何其他确认。或者,您可以逐个删除每个资源。

练习和探索

通过回答一些问题、进行一些动手实践,以及更深入地研究本章的主题来测试您的知识和理解。

练习 10.1 – 测试您的知识

回答以下问题:

  1. Azure Functions 的进程内和隔离工作模型之间有什么区别?

  2. 您使用什么属性来使函数在队列中收到消息时触发?

  3. 您使用什么属性来使队列可用于发送消息?

  4. 以下 NCRONTAB 表达式定义了什么计划?

0 0 */6 * 6 6

  1. 您如何配置依赖服务以在函数中使用?

练习 10.2 – 探索主题

使用以下页面上的链接获取本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-10---building-serverless-nanoservices-using-azure-functions

摘要

在本章中,您学习了:

  • Azure Functions 的一些概念

  • 如何使用 Azure Functions 构建无服务器服务

  • 如何响应 HTTP、定时器和队列触发器

  • 如何绑定到队列和 Blob 存储

  • 如何将 Azure Functions 项目部署到云端

在下一章中,您将了解 SignalR,这是一种在客户端和服务器之间进行实时通信的技术。

第十一章:使用 SignalR 进行实时通信广播

在本章中,您将了解 SignalR,这是一种技术,它使开发者能够创建一个可以拥有多个客户端,并能实时向所有客户端或其中一部分客户端广播消息的服务。典型的例子是群聊应用。其他例子包括需要即时更新信息的系统,如股票价格。

本章将涵盖以下主题:

  • 理解 SignalR

  • 使用 SignalR 构建实时通信服务

  • 使用 SignalR JavaScript 库构建 Web 客户端

  • 构建一个.NET 控制台应用程序客户端

  • 使用 SignalR 进行数据流

理解 SignalR

要理解 SignalR 解决的问题,我们需要了解没有它时 Web 开发是什么样的。Web 的基础是 HTTP,超过 30 年来,它对于构建通用网站和服务来说一直很好。然而,Web 并没有为需要网页即时更新新信息的特定场景而设计。

网络上实时通信的历史

要了解 SignalR 的好处,了解 HTTP 的历史以及组织如何努力使其更适合客户端和服务器之间的实时通信是有帮助的。

在 20 世纪 90 年代 Web 的早期,浏览器必须向 Web 服务器发送完整的 HTTP GET请求,以获取新鲜信息向访客展示。

在 1999 年底,微软发布了带有名为XMLHttpRequest组件的 Internet Explorer 5,该组件可以在后台进行异步 HTTP 调用。这,连同动态 HTMLDHTML),使得网页的部分可以平滑地用新鲜数据更新。

这种技术的优势很明显,很快,所有浏览器都添加了相同的组件。

AJAX

Google 充分利用这一功能构建了像 Google Maps 和 Gmail 这样的聪明 Web 应用。几年后,这项技术被普遍称为异步 JavaScript 和 XMLAJAX)。

虽然 AJAX 仍然使用 HTTP 进行通信,但它有一些局限性:

  • 首先,HTTP 是一个请求-响应通信协议,这意味着服务器不能向客户端推送数据。它必须等待客户端发起请求。

  • 其次,HTTP 请求和响应消息具有包含大量可能不必要的开销的头部。

WebSocket

WebSocket是全双工的,这意味着客户端或服务器都可以发起新的通信数据。WebSocket 在整个连接生命周期中使用相同的 TCP 连接。它发送的消息大小也更有效率,因为它们以 2 个字节的最小帧格式发送。

WebSocket 通过 HTTP 端口80443工作,因此它与 HTTP 协议兼容,WebSocket 握手使用 HTTP 的Upgrade头从 HTTP 协议切换到 WebSocket 协议。

现代网络应用程序预期提供最新的信息。实时聊天是典型的例子,但还有许多潜在的应用,从股价到游戏。

无论何时你需要服务器将更新推送到网页,你都需要一种兼容网页的、实时通信技术。WebSocket 可以使用,但并非所有客户端都支持它。您可以使用以下链接中的网页检查哪些客户端支持 WebSocket:caniuse.com/websockets

WebSocket 或 WebSockets? “WebSocket 协议于 2011 年由 IETF 标准化为 RFC 6455。允许网络应用程序使用此协议的当前 API 规范被称为 WebSockets。” 有关更多信息,请参阅以下链接中的维基百科页面:en.wikipedia.org/wiki/WebSocket

介绍 SignalR

ASP.NET Core SignalR 是一个开源库,通过在多个底层通信技术之上提供抽象,简化了向应用程序添加实时 Web 功能的过程,允许您使用 C# 代码添加实时通信功能。

开发者不需要理解或实现底层技术,SignalR 将根据访问者的 Web 浏览器支持的底层技术自动切换。例如,当 WebSocket 可用时,SignalR 将使用 WebSocket,当不可用时,它将优雅地回退到其他技术,如 AJAX 长轮询,而您的应用程序代码保持不变。

SignalR 是一个服务器到客户端的 远程过程调用(RPCs)API。RPCs 从服务器端的 .NET 代码调用客户端的 JavaScript 函数。SignalR 有中心点来定义管道,并自动使用两个内置中心点协议:JSON 和基于 MessagePack 的二进制协议来处理消息分发。

在服务器端,SignalR 在 ASP.NET Core 运行的任何地方都可以运行:Windows、macOS 或 Linux 服务器。SignalR 支持以下客户端平台:

  • 包括 Chrome、Firefox、Safari 和 Edge 在内的当前浏览器的 JavaScript 客户端。

  • .NET 客户端,包括 Blazor、.NET MAUI 和用于 Android 和 iOS 移动应用的 Xamarin。

  • Java 8 及以后版本。

Azure SignalR 服务

之前我提到,将 SignalR 服务托管项目与使用 JavaScript 库作为客户端的 Web 项目分开是一个好的实践。这是因为 SignalR 服务可能需要处理大量的并发客户端请求,并且需要快速响应对所有请求。

一旦您将 SignalR 托管分离出来,您就可以利用 Azure SignalR 服务。这提供了全球覆盖和世界级的数据中心和网络,并且可以扩展到数百万个连接,同时满足 SLA,如提供合规性和高安全性。

您可以在以下链接中了解更多关于 Azure SignalR 服务的相关信息:learn.microsoft.com/en-us/azure/azure-signalr/signalr-overview

设计方法签名

当为 SignalR 服务设计方法签名时,定义具有单个消息参数的方法而不是多个简单类型参数是良好实践。这种良好实践不是由 SignalR 技术强制执行的,因此您必须自律。

例如,而不是传递多个 string(或其他类型)值,定义一个具有多个属性的类型,用作单个 Message 参数,如下面的代码所示:

// Bad practice: RPC method with multiple parameters.
public void SendMessage(string to, string body)
// Good practice: single parameter using a complex type.
public class Message
{
  public string To { get; set; }
  public string Body { get; set; }
}
public void SendMessage(Message message) 

这种良好实践的原因是它允许未来的更改,例如为消息 Title 添加第三个属性。对于不良实践的例子,需要添加一个名为 title 的第三个 string 参数,并且现有的客户端会因为它们没有发送额外的 string 值而得到错误。但是,使用良好实践的例子不会破坏方法签名,因此现有的客户端可以像更改之前一样继续调用它。在服务器端,额外的 Title 属性将只有一个 null 值,可以进行检查,也许可以将其设置为默认值。

SignalR 方法参数被序列化为 JSON,因此如果需要,所有嵌套对象在 JavaScript 中都是可访问的。

现在我们已经探讨了 SignalR 的基础知识及其各个方面,如方法签名设计的好实践,让我们来看看如何使用 SignalR 构建实时通信服务。

使用 SignalR 构建实时通信服务

SignalR 的 服务器 库包含在 ASP.NET Core 中,但 JavaScript 的 客户端 库不是自动包含在项目中的。记住,SignalR 支持多种客户端类型,而使用 JavaScript 的网页只是其中之一。

我们将使用 Library Manager CLIunpkg 获取客户端库,unpkg 是一个可以交付 Node.js 包管理器中找到的任何内容的 内容分发网络CDN)。

让我们在 ASP.NET Core MVC 项目中添加一个 SignalR 服务器端中心和一个客户端 JavaScript,以实现一个允许访客向以下地址发送消息的聊天功能:

  • 当前正在使用网站的每个人。

  • 动态定义的组。

  • 指定单个用户。

良好实践:在生产解决方案中,最好将 SignalR 中心托管在单独的 Web 项目中,以便它可以独立于网站的其他部分进行托管和扩展。实时通信往往会对网站造成过大的负载。

定义一些共享模型

首先,我们将定义两个可以在服务器端和客户端 .NET 项目中使用的共享模型,这些项目将与我们的聊天服务一起工作:

  1. 使用您首选的代码编辑器创建一个新项目,如下列所示:

    • 项目模板:类库 / classlib

    • 解决方案文件和文件夹:Chapter11

    • 项目文件和文件夹:Northwind.Common

  2. Northwind.Common项目中,将Class1.cs文件重命名为UserModel.cs

  3. 修改其内容以定义一个用于注册用户姓名、唯一连接 ID 以及他们所属的组别的模型,如下面的代码所示:

    namespace Northwind.Chat.Models;
    public class UserModel
    {
      public string Name { get; set; } = null!;
      public string ConnectionId { get; set; } = null!;
      public string? Groups { get; set; } // comma-separated list
    } 
    

    良好实践:在实际应用中,您可能希望为Groups属性使用string值的集合,但这个编码任务并不是关于如何提供编辑多个string值的 Web 用户体验。我们将提供一个简单的文本框,并专注于学习 SignalR。

  4. Northwind.Common项目中,添加一个名为MessageModel.cs的类文件。修改其内容以定义一个消息模型,包含消息接收者、消息发送者以及消息正文等属性,如下面的代码所示:

    namespace Northwind.Chat.Models;
    public class MessageModel
    {
      public string From { get; set; } = null!;
      public string To { get; set; } = null!;
      public string? Body { get; set; }
    } 
    

启用服务器端 SignalR 中心

接下来,我们将在 ASP.NET Core MVC 项目中在服务器端启用一个 SignalR 中心:

  1. 使用您喜欢的代码编辑器添加一个新项目,如下面的列表所示:

    • 项目模板:ASP.NET Core Web App (Model-View-Controller) / mvc

    • 解决方案文件和文件夹:Chapter11

    • 项目文件和文件夹:Northwind.SignalR.Service.Client.Mvc

    • 认证类型:无。

    • 配置 HTTPS:已选择。

    • 启用 Docker:已清除。

    • 不要使用顶级语句:已清除。

  2. Northwind.SignalR.Service.Client.Mvc项目中,将警告视为错误,并添加对Northwind.Common项目的项目引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference
        Include="..\Northwind.Common\Northwind.Common.csproj" />
    </ItemGroup> 
    
  3. Properties文件夹中,在launchSettings.json中,在https配置文件中,修改applicationUrl以使用端口5111进行https连接和5112进行http连接,如下面高亮显示的配置所示:

    "**https**": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
    **"applicationUrl"****:****"https://localhost:5111;http://localhost:5112"****,**
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      } 
    
  4. Northwind.SignalR.Service.Client.Mvc项目中,添加一个名为ChatHub.cs的类文件。

  5. ChatHub.cs中,修改其内容以继承自Hub类并实现两个可以被客户端调用的方法,如下面的代码所示:

    using Microsoft.AspNetCore.SignalR; // To use Hub.
    using Northwind.Chat.Models; // To use UserModel, MessageModel.
    namespace Northwind.SignalR.Service.Hubs;
    public class ChatHub : Hub
    {
      // A new instance of ChatHub is created to process each method so we
      // must store user names, connection IDs, and groups in a static field.
      private static Dictionary<string, UserModel> Users = new();
      public async Task Register(UserModel newUser)
      {
        UserModel user;
        string action = "registered as a new user";
        // Try to get a stored user with a match on new user.
        if (Users.ContainsKey(newUser.Name))
        {
          user = Users[newUser.Name];
          // Remove any existing group registrations.
          if (user.Groups is not null)
          {
            foreach (string group in user.Groups.Split(','))
            {
              await Groups.RemoveFromGroupAsync(user.ConnectionId, group);
            }
          }
          user.Groups = newUser.Groups;
          // Connection ID might have changed if the browser 
          // refreshed so update it.
          user.ConnectionId = Context.ConnectionId;
          action = "updated your registered user";
        }
        else
        {
          if (string.IsNullOrEmpty(newUser.Name))
          {
            // Assign a GUID for name if they are anonymous.
            newUser.Name = Guid.NewGuid().ToString();
          }
          newUser.ConnectionId = Context.ConnectionId;
          Users.Add(key: newUser.Name, value: newUser);
          user = newUser;
        }
        if (user.Groups is not null)
        {
          // A user does not have to belong to any groups
          // but if they do, register them with the Hub.
          foreach (string group in user.Groups.Split(','))
          {
            await Groups.AddToGroupAsync(user.ConnectionId, group);
          }
        }
        // Send a message to the registering user informing of success.
        MessageModel message = new() 
        { 
          From = "SignalR Hub", To = user.Name, 
          Body = string.Format(
            "You have successfully {0} with connection ID {1}.",
            arg0: action, arg1: user.ConnectionId)
        };
        IClientProxy proxy = Clients.Client(user.ConnectionId);
        await proxy.SendAsync("ReceiveMessage", message);
      }
      public async Task SendMessage(MessageModel message)
      {
        IClientProxy proxy;
        if (string.IsNullOrEmpty(message.To))
        {
          message.To = "Everyone";
          proxy = Clients.All;
          await proxy.SendAsync("ReceiveMessage", message);
          return;
        }
        // Split To into a list of user and group names.
        string[] userAndGroupList = message.To.Split(',');
        // Each item could be a user or group name.
        foreach (string userOrGroup in userAndGroupList)
        {
          if (Users.ContainsKey(userOrGroup))
          {
            // If the item is in Users then send the message to that user
            // by looking up their connection ID in the dictionary.
            message.To = $"User: {Users[userOrGroup].Name}";
            proxy = Clients.Client(Users[userOrGroup].ConnectionId);
          }
          else // Assume the item is a group name to send the message to.
          {
            message.To = $"Group: {userOrGroup}";
            proxy = Clients.Group(userOrGroup);
          }
          await proxy.SendAsync("ReceiveMessage", message);
        }
      }
    } 
    

    注意以下内容:

    • ChatHub有一个私有字段用于存储已注册用户列表。它是一个以他们的名字作为唯一键的字典。

    • ChatHub有两个客户端可以调用的方法:RegisterSendMessage

    • Register方法接受一个类型为UserModel的单个参数。用户的姓名、连接 ID 和组别被存储在静态字典中,以便以后可以使用用户姓名查找连接 ID 并直接向该用户发送消息。在注册新用户或更新现有用户的注册信息后,会向客户端发送一条消息,告知他们操作成功。

    • SendMessage 有一个类型为 MessageModel 的单个参数。该方法根据 To 属性的值进行分支。如果 To 没有值,它调用 All 属性以获取一个将与每个客户端通信的代理。如果 To 有值,则使用逗号分隔符将 string 分割成一个数组。检查数组中的每个项是否与 Users 中的用户匹配。如果匹配,它调用 Client 方法以获取一个将仅与该客户端通信的代理。如果不匹配,该项可能是一个组,因此它调用 Group 方法以获取一个将仅与该组成员通信的代理。最后,它使用代理异步发送消息。

  6. Program.cs 中,导入你的 SignalR 中心的命名空间,如下面的代码所示:

    using Northwind.SignalR.Service.Hubs; // To use ChatHub. 
    
  7. 在配置服务的部分,添加一个语句以向服务集合添加对 SignalR 的支持,如下面的代码所示:

    builder.Services.AddSignalR(); 
    
  8. 在配置 HTTP 管道的部分,在调用映射控制器路由之前,添加一个语句将相对 URL 路径 /chat 映射到你的 SignalR 中心,如下面的代码所示:

    app.MapHub<ChatHub>("/chat"); 
    

使用 SignalR JavaScript 库构建 Web 客户端

接下来,我们将添加 SignalR 客户端 JavaScript 库,以便我们可以在网页上使用它:

  1. 打开 Northwind.SignalR.Service.Client.Mvc 项目/文件夹的命令提示符或终端。

  2. 按照以下命令安装库管理器 CLI 工具:

    dotnet tool install -g Microsoft.Web.LibraryManager.Cli 
    

    此工具可能已经全局安装。要更新到最新版本,重复命令,但将 install 替换为 update

  3. 输入命令将 signalr.jssignalr.min.js 库添加到项目,从 unpkg 源,如下面的命令所示:

    libman install @microsoft/signalr@latest -p unpkg -d wwwroot/js/signalr --files dist/browser/signalr.js --files dist/browser/signalr.min.js 
    

    从 PDF 中复制长命令并直接粘贴到命令提示符是不推荐的。始终在基本的文本编辑器中清理它们,以删除多余的换行符等,然后再重新复制。为了更容易输入长命令行,你可以从以下链接复制它们:github.com/markjprice/apps-services-net8/blob/main/docs/command-lines.md

  4. 注意成功消息,如下面的输出所示:

    wwwroot/js/signalr/dist/browser/signalr.js written to disk
    wwwroot/js/signalr/dist/browser/signalr.min.js written to disk
    Installed library "@microsoft/signalr@latest" to "wwwroot/js/signalr" 
    

Visual Studio 2022 还有一个用于添加客户端 JavaScript 库的 GUI。要使用它,右键单击一个 Web 项目,然后导航到 添加 | 客户端库

向 MVC 网站添加聊天页面

接下来,我们将向主页添加聊天功能:

  1. Views/HomeIndex.cshtml 中,修改其内容,如下面的标记所示:

    @using Northwind.Chat.Models
    @{
      ViewData["Title"] = "SignalR Chat";
    }
    <div class="container">
      <h1>@ViewData["Title"]</h1>
      <hr />
      <div class="row">
        <div class="col">
          <h2>Register User</h2>
          <div class="mb-3">
            <label for="myName" class="form-label">My name</label>
            <input type="text" class="form-control" 
                   id="myName" value="Alice" required />
          </div>
          <div class="mb-3">
            <label for="myGroups" class="form-label">My groups</label>
            <input type="text" class="form-control" 
                   id="myGroups" value="Sales,IT" />
          </div>
          <div class="mb-3">
            <input type="button" class="form-control" 
                   id="registerButton" value="Register User" />
          </div>
        </div>
        <div class="col">
          <h2>Send Message</h2>
          <div class="mb-3">
            <label for="from" class="form-label">From</label>
            <input type="text" class="form-control" 
                   id="from" value="Alice" readonly />
          </div>
          <div class="mb-3">
            <label for="to" class="form-label">To</label>
            <input type="text" class="form-control" id="to" />
          </div>
          <div class="mb-3">
            <label for="body" class="form-label">Body</label>
            <input type="text" class="form-control" id="body" />
          </div>
          <div class="mb-3">
            <input type="button" class="form-control" 
                   id="sendButton" value="Send Message" />
          </div>
        </div>
      </div>
      <div class="row">
        <div class="col">
          <hr />
          <h2>Messages received</h2>
          <ul id="messages"></ul>
        </div>
      </div>
    </div>
    <script src="img/signalr.js"></script>
    <script src="img/chat.js"></script> 
    

    注意以下内容:

    • 页面上有三个部分:注册用户发送消息收到的消息

    • 注册用户部分有两个输入框用于访客的姓名,一个逗号分隔的列表,列出他们想要成为成员的组,以及一个点击以注册的按钮。

    • 发送消息部分有三个输入框,分别用于输入消息发送者的用户名、消息接收者的用户名和群组名,以及消息正文,还有一个点击按钮来发送消息。

    • 接收到的消息部分有一个项目符号列表元素,当收到消息时,会动态填充一个列表项。

    • 有两个脚本元素用于 SignalR JavaScript 客户端库和聊天客户端的 JavaScript 实现。

  2. wwwroot/js中添加一个名为chat.js的新 JavaScript 文件,并修改其内容,如下面的代码所示:

    "use strict";
    var connection = new signalR.HubConnectionBuilder()
      .withUrl("/chat").build();
    document.getElementById("registerButton").disabled = true;
    document.getElementById("sendButton").disabled = true;
    document.getElementById("myName").addEventListener("input",
      function () {
        document.getElementById("from").value = 
          document.getElementById("myName").value;
      }
    );
    connection.start().then(function () {
      document.getElementById("registerButton").disabled = false;
      document.getElementById("sendButton").disabled = false;
    }).catch(function (err) {
      return console.error(err.toString());
    });
    connection.on("ReceiveMessage", function (received) {
      var li = document.createElement("li");
      document.getElementById("messages").appendChild(li);
      li.textContent =
        // This string must use backticks ` to enable an interpolated 
        // string. If you use single quotes ' then it will not work!
        `To ${received.to}, From ${received.from}: ${received.body}`;
    });
    document.getElementById("registerButton").addEventListener("click",
      function (event) {
        var registermodel = {
          name: document.getElementById("myName").value,
          groups: document.getElementById("myGroups").value
        };
        connection.invoke("Register", registermodel).catch(function (err) {
          return console.error(err.toString());
        });
        event.preventDefault();
      });
    document.getElementById("sendButton").addEventListener("click",
      function (event) {
        var messagemodel = {
          from: document.getElementById("from").value,
          to: document.getElementById("to").value,
          body: document.getElementById("body").value
        };
        connection.invoke("SendMessage", messagemodel).catch(function (err) {
          return console.error(err.toString());
        });
        event.preventDefault();
    }); 
    

    注意以下内容:

    • 脚本创建了一个 SignalR 中心连接构建器,指定服务器上聊天中心的相对 URL 路径/chat

    • 脚本在成功连接到服务器端中心之前禁用注册发送按钮。

    • 我的名字文本框添加了一个input事件处理器,以保持它与来自文本框同步。

    • 当连接从服务器端中心接收到ReceiveMessage调用时,它会在messages项目符号列表中添加一个列表项元素。列表项的内容包含消息的详细信息,如fromtobody。对于我们在 C#中定义的两个模型,请注意,JavaScript 使用 camelCase,而 C#使用 PascalCase。

    消息使用 JavaScript 插值string格式化。此功能需要在string值的开始和结束处使用反引号`,并使用花括号${}来表示动态占位符。

    • 注册用户按钮添加了一个click事件处理器,该处理器创建一个包含用户名和其群组的注册模型,然后在服务器端调用Register方法。

    • 发送消息按钮添加了一个click事件处理器,该处理器创建一个包含fromtobody的消息模型,然后在服务器端调用SendMessage方法。

测试聊天功能

现在我们已经准备好尝试在多个网站访客之间发送聊天消息:

  1. 使用https配置文件启动Northwind.SignalR.Service.Client.Mvc项目网站:

    • 如果你使用 Visual Studio 2022,则在工具栏中选择https配置文件,然后在不进行调试的情况下启动Northwind.SignalR.Service.Client.Mvc项目。

    • 如果你使用 Visual Studio Code,则在命令提示符或终端中输入以下命令:

      dotnet run --launch-profile https 
      
    • 在 Windows 上,如果 Windows Defender 防火墙阻止访问,则点击允许访问

  2. 启动 Chrome 并导航到https://localhost:5111/

  3. 注意,Alice的名字已经输入,Sales,IT群组也已经输入。点击注册用户,注意SignalR 聊天返回的响应,如图图 11.1所示:

图 11.1:在聊天中注册新用户

  1. 打开一个新的 Chrome 窗口或启动另一个浏览器,如 Firefox 或 Edge。

  2. 导航到https://localhost:5111/

  3. 输入Bob作为姓名,Sales作为他的组,然后点击注册用户

  4. 打开一个新的 Chrome 窗口或启动另一个浏览器,如 Firefox 或 Edge。

  5. 导航到https://localhost:5111/

  6. 输入Charlie作为姓名,IT作为他的组,然后点击注册用户

  7. 调整浏览器窗口,以便可以同时看到所有三个窗口。

    PowerToys 及其 FancyZones 功能是管理窗口的绝佳工具。更多信息请访问以下链接:learn.microsoft.com/en-us/windows/powertoys/

  8. 在 Alice 的浏览器中输入以下内容:

    • 收件人Sales

    • 正文卖更多!

  9. 点击发送消息

  10. 注意,Alice 和 Bob 收到了消息,如图 11.2 所示:

图 11.2:Alice 向 Sales 组发送消息

  1. 在 Bob 的浏览器中输入以下内容:

    • 收件人IT

    • 正文修复更多错误!

  2. 点击发送消息

  3. 注意,Alice 和 Charlie 收到了消息,如图 11.3 所示:

图 11.3:Bob 向 IT 组发送消息

  1. 在 Alice 的浏览器中输入以下内容:

    • 收件人Bob

    • 正文你好,Bob!

  2. 点击发送消息

  3. 注意,只有 Bob 收到了消息。

  4. 在 Charlie 的浏览器中输入以下内容:

    • 收件人:留空。

    • 正文现在大家都跳舞吧!

  5. 点击发送消息

  6. 注意,每个人都收到了消息,如图 11.4 所示:

图 11.4:Charlie 向所有人发送消息

  1. 在 Charlie 的浏览器中输入以下内容:

    • 收件人HR,Alice

    • 正文有人正在听 HR 吗?

  2. 点击发送消息

  3. 注意,Alice 收到了直接发送给她的消息,但由于 HR 组不存在,没有收到发送给该组的消息,如图 11.5 所示:

图 11.5:Charlie 向 Alice 和一个不存在的组发送消息

  1. 关闭浏览器并关闭 Web 服务器。

构建.NET 控制台应用程序客户端

您刚刚看到了一个.NET 服务托管 SignalR 中心,以及一个 JavaScript 客户端通过该 SignalR 中心与其他客户端交换消息。现在,让我们创建一个.NET 客户端用于 SignalR。

创建 SignalR 的.NET 客户端

我们将使用控制台应用程序,尽管任何.NET 项目类型都需要相同的包引用和实现代码:

  1. 使用您首选的代码编辑器添加一个新项目,如下所示:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter11

    • 项目文件和文件夹:Northwind.SignalR.Client.Console

  2. 为 ASP.NET Core SignalR 客户端添加包引用,并为Northwind.Common添加项目引用,将警告视为错误,并在全局和静态导入System.Console类,如下所示:

    <ItemGroup>
      <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" 
                        Version="8.0.0" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference 
        Include="..\Northwind.Common\Northwind.Common.csproj" />
    </ItemGroup> 
    
  3. 构建项目以恢复包并构建引用的项目。

  4. Program.cs 中,删除现有的语句,导入用于作为客户端处理 SignalR 和聊天模型的命名空间,然后添加语句提示用户输入用户名和要注册的组,创建中心连接,并最终监听接收到的消息,如下面的代码所示:

    using Microsoft.AspNetCore.SignalR.Client; // To use HubConnection.
    using Northwind.Chat.Models; // To use UserModel, MessageModel.
    Write("Enter a username (required): ");
    string? username = ReadLine();
    if (string.IsNullOrEmpty(username))
    {
      WriteLine("You must enter a username to register with chat!");
      return;
    }
    Write("Enter your groups (optional): ");
    string? groups = ReadLine();
    HubConnection hubConnection = new HubConnectionBuilder()
      .WithUrl("https://localhost:5111/chat")
      .Build();
    hubConnection.On<MessageModel>("ReceiveMessage", message =>
    {
      WriteLine($"To {message.To}, From {message.From}: {message.Body}");
    });
    await hubConnection.StartAsync();
    WriteLine("Successfully started.");
    UserModel registration = new()
    {
      Name = username,
      Groups = groups
    };
    await hubConnection.InvokeAsync("Register", registration);
    WriteLine("Successfully registered.");
    WriteLine("Listening... (press ENTER to stop.)");
    ReadLine(); 
    

测试 .NET 控制台应用程序客户端

让我们启动 SignalR 服务,并在控制台应用程序中调用它:

  1. 使用 https 配置文件(不进行调试)启动 Northwind.SignalR.Service.Client.Mvc 项目的网站。

  2. 启动 Chrome 并导航到 https://localhost:5111/

  3. 点击 注册用户

  4. 启动 Northwind.SignalR.Client.Console 项目。

  5. 输入你的名字和组:Sales,Admins

  6. 将浏览器和控制台应用程序窗口排列好,以便你可以同时看到它们。

  7. 在 Alice 的浏览器中输入以下内容:

    • 发送到Sales

    • 正文Go team!

  8. 点击 发送消息,并注意 Alice 和你都能收到消息,如图 11.6 所示:

图 11.6:Alice 在控制台应用程序中向销售团队发送消息,包括一个用户

  1. 在控制台应用程序中,按 Enter 键停止监听。

  2. 关闭 Chrome 并关闭 Web 服务器。

使用 SignalR 进行数据流

到目前为止,我们已经看到了 SignalR 如何向一个或多个客户端广播结构化消息。这对于相对较小且结构化的数据,并且完全存在于某个时间点的情况效果很好。但是,对于随着时间的推移分批到达的数据怎么办呢?

可以用于这些场景。SignalR 支持服务到客户端(从流中下载数据)和客户端到服务(将数据上传到流)。

要启用下载流,中心方法必须返回 IAsyncEnumerable<T>(仅支持 C# 8 或更高版本)或 ChannelReader<T>

要启用上传流,中心方法必须接受类型为 IAsyncEnumerable<T>(仅支持 C# 8 或更高版本)或 ChannelReader<T> 的参数。

定义用于流的中心

让我们添加一些流方法来看看它们在实际操作中的工作情况:

  1. Northwind.Common 项目中,添加一个名为 StockPrice.cs 的新文件,并修改其内容以定义股票价格数据的 record,如下面的代码所示:

    namespace Northwind.SignalR.Streams;
    public record StockPrice(string Stock, double Price); 
    
  2. 构建 Northwind.SignalR.Service.Client.Mvc 项目以更新其引用的项目。

  3. Northwind.SignalR.Service.Client.Mvc 项目中,添加一个名为 StockPriceHub.cs 的新类,并修改其内容以定义一个具有两个流方法的中心,如下面的代码所示:

    using Microsoft.AspNetCore.SignalR; // To use Hub.
    using System.Runtime.CompilerServices; // To use [EnumeratorCancellation].
    using Northwind.SignalR.Streams; // To use StockPrice.
    namespace Northwind.SignalR.Service.Hubs;
    public class StockPriceHub : Hub
    {
      public async IAsyncEnumerable<StockPrice> GetStockPriceUpdates(
        string stock,
        [EnumeratorCancellation] CancellationToken cancellationToken)
      {
        double currentPrice = 267.10; // Simulated initial price.
        for (int i = 0; i < 10; i++)
        {
          // Check the cancellation token regularly so that the server will stop
          // producing items if the client disconnects.
          cancellationToken.ThrowIfCancellationRequested();
          // Increment or decrement the current price by a random amount.
          // The compiler does not need the extra parentheses but it
          // is clearer for humans if you put them in.
          currentPrice += (Random.Shared.NextDouble() * 10.0) - 5.0;
          StockPrice stockPrice = new(stock, currentPrice);
         Console.WriteLine("[{0}] {1} at {2:C}",
           DateTime.UtcNow, stockPrice.Stock, stockPrice.Price);
          yield return stockPrice;
          await Task.Delay(4000, cancellationToken); // milliseconds
        }
      }
      public async Task UploadStocks(IAsyncEnumerable<string> stocks)
      {
        await foreach (string stock in stocks)
        {
          Console.WriteLine($"Receiving {stock} from client...");
        }
      }
    } 
    
  4. Northwind.SignalR.Service.Client.Mvc 项目的 Program.cs 中,在注册聊天中心的语句之后注册股票价格中心,如下面的代码所示:

    `app.MapHub<StockPriceHub>("/stockprice");` 
    

创建用于流传输的 .NET 控制台应用程序客户端

现在,我们可以创建一个简单的客户端来从 SignalR 中心下载数据流并将其上传到 SignalR 中心:

  1. 使用你喜欢的代码编辑器添加一个新项目,如下面的列表所示:

    • 项目模板:控制台应用程序 / console

    • 解决方案文件和文件夹:Chapter11

    • 项目文件和文件夹:Northwind.SignalR.Client.Console.Streams

  2. Northwind.SignalR.Client.Console.Streams 项目文件中,将警告视为错误,添加 ASP.NET Core SignalR 客户端包引用,添加对 Northwind.Common 的项目引用,并全局和静态导入 System.Console 类,如下所示突出显示的标记:

    <ItemGroup>
      <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" 
                        Version="8.0.0" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference 
        Include="..\Northwind.Common\Northwind.Common.csproj" />
    </ItemGroup> 
    
  3. Northwind.SignalR.Client.Console.Streams 项目中,添加一个名为 Program.Methods.cs 的新类文件,并修改其内容以在部分 Program 类中定义静态方法,异步生成十个四字母的随机股票代码,如下所示代码:

    // Defined in the empty default namespace to merge with the auto-
    // generated partial Program class.
    partial class Program
    {
      static async IAsyncEnumerable<string> GetStocksAsync()
      {
        for (int i = 0; i < 10; i++)
        {
          // Return a random four-letter stock code.
          yield return $"{AtoZ()}{AtoZ()}{AtoZ()}{AtoZ()}";
          await Task.Delay(TimeSpan.FromSeconds(3));
        }
      }
      static string AtoZ()
      {
        return char.ConvertFromUtf32(Random.Shared.Next(65, 91));
      }
    } 
    
  4. Northwind.SignalR.Client.Console.Streams 项目中,在 Program.cs 中删除现有语句。导入用于作为客户端处理 SignalR 的命名空间,然后添加提示用户输入股票、创建 Hub 连接、监听接收到的股票价格流,并将异步股票流发送到服务的语句,如下所示代码:

    using Microsoft.AspNetCore.SignalR.Client; // To use HubConnection.
    using Northwind.SignalR.Streams; // To use StockPrice.
    Write("Enter a stock (press Enter for MSFT): ");
    string? stock = ReadLine();
    if (string.IsNullOrEmpty(stock))
    {
      stock = "MSFT";
    }
    HubConnection hubConnection = new HubConnectionBuilder()
      .WithUrl("https://localhost:5111/stockprice")
      .Build();
    await hubConnection.StartAsync();
    try
    {
      CancellationTokenSource cts = new();
      IAsyncEnumerable<StockPrice> stockPrices = 
        hubConnection.StreamAsync<StockPrice>(
          "GetStockPriceUpdates", stock, cts.Token);
      await foreach (StockPrice sp in stockPrices)
      {
        WriteLine($"{sp.Stock} is now {sp.Price:C}.");
        Write("Do you want to cancel (y/n)? ");
        ConsoleKey key = ReadKey().Key;
        if (key == ConsoleKey.Y)
        {
          cts.Cancel();
        }
        WriteLine();
      }
    }
    catch (Exception ex)
    {
      WriteLine($"{ex.GetType()} says {ex.Message}");
    }
    WriteLine();
    WriteLine("Streaming download completed.");
    await hubConnection.SendAsync("UploadStocks", GetStocksAsync());
    WriteLine("Uploading stocks to service... (press ENTER to stop.)");
    ReadLine();
    WriteLine("Ending console app."); 
    

测试流服务客户端

最后,我们可以测试流数据功能:

  1. 使用 https 配置不带调试启动 Northwind.SignalR.Service.Client.Mvc 项目网站。

  2. 不带调试启动 Northwind.SignalR.Client.Console.Streams 控制台应用程序。

  3. 将 ASP.NET Core MVC 网站和客户端控制台应用程序的控制台窗口排列在一起,以便你可以并排看到它们。

  4. 在客户端控制台应用程序中,按 Enter 使用微软股票代码,如下所示输出:

    Enter a stock (press Enter for MSFT):
    MSFT is now £265.00.
    Do you want to cancel (y/n)? 
    
  5. 在网站控制台窗口中等待大约十秒钟,并注意在服务中已生成但尚未发送给客户端的几个股票价格,如下所示输出:

    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: https://localhost:5131
    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: http://localhost:5132
    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:\apps-services-net7\Chapter13\Northwind.SignalR.Service.Client.Mvc
    [12/09/2022 17:52:26] MSFT at £265.00
    [12/09/2022 17:52:30] MSFT at £260.78
    [12/09/2022 17:52:34] MSFT at £264.86
    [12/09/2022 17:52:38] MSFT at £262.10 
    
  6. 在客户端控制台应用程序中,按 n 接收下一个更新的价格。继续按 n 直到服务发送价格并由客户端读取,然后按 y,注意 SignalR 服务收到一个取消令牌所以停止,客户端现在开始上传股票,如下所示输出:

    MSFT is now £260.78.
    Do you want to cancel (y/n)? n
    MSFT is now £264.86.
    Do you want to cancel (y/n)? n
    MSFT is now £262.10.
    Do you want to cancel (y/n)? y
    System.Threading.Tasks.TaskCanceledException says A task was canceled.
    Streaming download completed.
    Uploading stocks to service... (press ENTER to stop.) 
    
  7. 在网站控制台窗口中,注意接收到了随机股票代码,如下所示输出:

    Receiving PJON from client...
    Receiving VWJD from client...
    Receiving HMOJ from client...
    Receiving QQMQ from client... 
    
  8. 关闭两个控制台窗口。

练习和探索

通过回答一些问题、进行一些实际操作和深入探索本章主题来测试你的知识和理解。

练习 11.1 – 测试你的知识

回答以下问题:

  1. SignalR 使用哪些传输方式,默认的是哪种?

  2. RPC 方法签名设计的好习惯是什么?

  3. 你可以使用什么工具下载 SignalR JavaScript 库?

  4. 如果你向一个连接 ID 不存在的客户端发送 SignalR 消息会发生什么?

  5. 将 SignalR 服务与其他 ASP.NET Core 组件分离有什么好处?

练习 11.2 – 探索主题

使用以下页面上的链接了解更多关于本章涵盖主题的详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-11---broadcasting-real-time-communication-using-signalr

摘要

在本章中,你学习了以下内容:

  • SignalR 之前的技术历史。

  • 支撑 SignalR 的概念和技术。

  • 使用 SignalR 实现聊天功能,包括在网站项目中构建一个 hub,以及使用 JavaScript 和 .NET 控制台应用程序的客户端。

  • 使用 SignalR 下载和上传数据流。

在下一章中,你将了解 GraphQL,这是另一个允许客户端控制从服务返回的数据的标准。

第十二章:使用 GraphQL 组合数据源

在本章中,你将了解 GraphQL,这是一种服务技术,它提供了一种更现代的方法来组合来自各种来源的数据,并提供了一种查询这些数据的标准方式。

本章将涵盖以下主题:

  • 理解 GraphQL

  • 构建支持 GraphQL 的服务

  • 定义 EF Core 模型的 GraphQL 查询

  • 为 GraphQL 服务构建.NET 客户端

  • 实现 GraphQL 突变

  • 实现 GraphQL 订阅

理解 GraphQL

第八章使用最小 API 构建和保障 Web 服务中,你学习了如何通过将请求路径端点映射到 lambda 表达式或返回响应的方法来定义 Web API 服务。任何参数和响应格式都由服务控制。客户端无法请求他们确切需要的内容或使用更有效的数据格式。

如果你完成了仅在网络上提供的部分,通过 OData 在 Web 上公开数据,那么你知道 OData 有一个内置的查询语言,客户端可以用来控制他们想要返回的数据。然而,OData 有一个相当过时的方法,并且与 HTTP 标准相关联,例如,在 HTTP 请求中使用查询字符串。

如果你希望使用一种更现代且灵活的技术来组合并公开你的数据作为服务,那么一个不错的选择是GraphQL

与 OData 一样,GraphQL 是一个描述你的数据并查询它的标准,它给了客户端控制权,让他们确切知道他们需要什么。它于 2012 年由 Facebook 内部开发,并于 2015 年开源,现在由 GraphQL 基金会管理。

与 OData 相比,GraphQL 的一些关键优势包括:

  • GraphQL 不需要 HTTP,因为它与传输无关,因此你可以使用替代的传输协议,如 WebSockets 或 TCP。

  • GraphQL 比 OData 拥有更多针对不同平台的客户端库。

  • GraphQL 有一个单一端点,通常简单地是/graphql

GraphQL 查询文档格式

GraphQL 使用自己的文档格式进行查询,这与 JSON 有点相似,但 GraphQL 查询不需要在字段名之间使用逗号,如下面的查询所示,该查询请求了 ID 为23的产品的一些字段和相关数据:

{
  product (productId: 23) {
    productId
    productName
    unitPrice
    supplier {
      companyName
      country
    }
  }
} 

GraphQL 查询文档的官方媒体类型是application/graphql

请求字段

最基本的 GraphQL 查询从一个类型请求一个或多个字段,例如,为每个customer实体请求三个字段,如下面的代码所示:

# The query keyword is optional. Comments are prefixed with #.
query {
  customer {
    customerId
    companyName
    country
  }
} 

响应以 JSON 格式,例如,一个customer对象的数组,如下面的文档所示:

{
  "data": [
    {
      "customerId": "ALFKI",
      "companyName": "Alfreds Futterkiste",
      "country": "Germany"
    },
  ...
  ]
} 

指定过滤器和参数

使用 HTTP 或 REST 风格的 API 时,调用者仅限于在 API 预定义的情况下传递参数。在 GraphQL 中,你可以在查询的任何位置设置参数,例如,通过订单日期和下订单的客户的国籍来过滤订单,如下面的代码所示:

query GetOrdersByDateAndCountry {
  order(orderDate: "23/04/1998") {
    orderId
    orderDate
    customer(country: "UK") {
      companyName
      country
    }
  }
} 

注意,尽管 GraphQL 使用 camelCase 作为实体、属性和参数名称,但您应该使用 PascalCase 作为查询名称。

您可能希望传递命名参数的值而不是将它们硬编码,如下面的代码所示:

query GetOrdersByDateAndCountry($country: String, $orderDate: String) {
  order(orderDate: $orderDate) {
    orderId
    orderDate
    customer(country: $country) {
      companyName
      country
    }
  }
} 

您可以在以下链接了解更多关于 GraphQL 查询语言的信息:graphql.org/learn/queries/

理解其他 GraphQL 能力

除了查询之外,其他标准的 GraphQL 特性还包括突变和订阅:

  • 突变使您能够创建、更新和删除资源。

  • 订阅允许客户端在资源发生变化时收到通知。它们与 WebSocket 等附加通信技术配合使用效果最佳。

理解 ChilliCream GraphQL 平台

GraphQL.NET 是实施 GraphQL 的.NET 中最受欢迎的平台之一。在我看来,即使是基本示例,GraphQL.NET 也需要太多的配置,而且文档令人沮丧。我有一种感觉,尽管它功能强大,但它以一对一的方式实现了 GraphQL 规范,而不是重新思考.NET 平台如何使其更容易上手。

您可以在以下链接了解 GraphQL.NET:graphql-dotnet.github.io/

对于这本书,我寻找了替代方案,并找到了我想要的。ChilliCream 是一家创建了一个整个平台来与 GraphQL 一起工作的公司:

  • 热巧克力使您能够为.NET 创建 GraphQL 服务。

  • 草莓奶昔使您能够为.NET 创建 GraphQL 客户端。

  • 香蕉蛋糕棒使您能够使用基于 Monaco 的 GraphQL IDE 运行查询并探索 GraphQL 端点。

  • 绿色甜甜圈在加载数据时提供更好的性能。

与一些其他可以用来添加 GraphQL 支持的包不同,ChilliCream 包旨在尽可能容易实现,使用约定和简单的 POCO 类而不是复杂类型和特殊模式。它的工作方式类似于微软可能为.NET 构建的 GraphQL 平台,有合理的默认值和约定,而不是大量的样板代码和配置。

正如 ChilliCream 在其主页上所说:“我们在 ChilliCream 构建终极 GraphQL 平台。我们的大部分代码是开源的,并将永远保持开源。”

Hot Chocolate 的 GitHub 仓库链接如下:github.com/ChilliCream/hotchocolate

构建支持 GraphQL 的服务

由于 GraphQL 不需要托管在 Web 服务器上,因为它不依赖于 HTTP,因此选择ASP.NET Core Empty项目模板作为起点是合理的。然后我们将添加一个用于 GraphQL 支持的包引用:

  1. 使用您首选的代码编辑器添加一个新项目,如下列所示:

    • 项目模板:ASP.NET Core Empty / web

    • 解决方案文件和文件夹:Chapter12

    • 项目文件和文件夹:Northwind.GraphQL.Service

    • 其他 Visual Studio 2022 选项:

      • 配置 HTTPS:已选择

      • 启用 Docker:已清除

      • 不要使用顶级语句:已清除

  2. 在项目文件中,添加一个对托管在 ASP.NET Core 中的 Hot Chocolate 的包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="HotChocolate.AspNetCore" Version="13.5.1" />
    </ItemGroup> 
    

    写作时,版本 13.6.0 正在预览中。要使用最新的预览版本,您可以将其设置为 13.6-*。但我建议您访问以下链接,然后引用最新的 GA 版本:www.nuget.org/packages/HotChocolate.AspNetCore/

  3. 在项目文件中,将警告视为错误并禁用警告 AD0001,如下面的标记所示:

    <PropertyGroup>
      <TargetFramework>net8.0</TargetFramework>
      <Nullable>enable</Nullable>
      <ImplicitUsings>enable</ImplicitUsings>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
     **<NoWarn>AD0001</NoWarn>**
    </PropertyGroup> 
    
  4. Properties 文件夹中,在 launchSettings.json 中,对于 https 配置文件,将 applicationUrl 修改为使用 https 的端口 5121http 的端口 5122。然后,添加一个 launchUrlgraphql,如下面的配置所示:

    **"https"****:****{**
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
    **"launchUrl"****:****"****graphql"****,**
    **"applicationUrl"****:****"https://localhost:5121;http://localhost:5122"****,**
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      } 
    
  5. 构建 Northwind.GraphQL.Service 项目。

定义 Hello World 的 GraphQL 模式

第一项任务是定义我们希望在 Web 服务中公开的 GraphQL 模型。

让我们定义一个 GraphQL 查询,用于最基本的 Hello World 示例,当请求问候时将返回纯文本:

  1. Northwind.GraphQL.Service 项目/文件夹中,添加一个名为 Query.cs 的类文件。

  2. 修改类以包含一个名为 GetGreeting 的方法,该方法返回纯文本 "Hello, World!",如下面的代码所示:

    namespace Northwind.GraphQL.Service;
    public class Query
    {
      public string GetGreeting() => "Hello, World!";
    } 
    
  3. Program.cs 中,导入我们定义 Query 类的命名空间,如下面的代码所示:

    using Northwind.GraphQL.Service; // To use Query. 
    
  4. 在配置服务的部分,在调用 CreateBuilder 之后,添加一个语句以添加 GraphQL 服务器端支持,并将查询类型添加到已注册的服务集合中,如下面的代码所示:

    builder.Services
      .AddGraphQLServer()
      .AddQueryType<Query>(); 
    
  5. 修改将 GET 请求映射为返回更有用的纯文本消息的语句,如下面的代码所示:

    app.MapGet("/", () => "Navigate to: https://localhost:5121/graphql"); 
    
  6. 在配置 HTTP 管道的部分,在调用 Run 之前,添加一个语句将 GraphQL 映射为一个端点,如下面的代码所示:

    app.MapGraphQL(); 
    
  7. 启动 Northwind.GraphQL.Service 网络项目,使用 https 配置文件且不进行调试:

    • 如果你正在使用 Visual Studio 2022,请选择 https 配置文件,不进行调试启动项目,并注意浏览器会自动启动。

    • 如果你正在使用 Visual Studio Code,请输入命令 dotnet run --launch-profile https,手动启动 Chrome,并导航到 https://localhost:5121/graphql

  8. 注意 BananaCakePop 用户界面,然后点击 创建文档 按钮。

  9. 在右上角,点击 连接设置 按钮,如图 12.1 所示:

图 12.1:一个新的 BananaCakePop 文档和连接设置按钮

  1. 连接设置 中,确认 模式端点 是正确的,然后点击 取消,如图 图 12.2 所示:

图片

图 12.2:审查 BananaCakePop 连接设置

  1. untitled 1 文档的顶部,点击 模式引用 选项卡。

  2. 模式引用 选项卡中,注意“Query 类型是一个特殊类型,它定义了每个 GraphQL 查询的入口点”,它有一个名为 greeting 的字段,该字段返回一个 String! 值。感叹号表示该值将 不会null

  3. 点击 模式定义 选项卡,并注意只有一个类型被定义,即特殊的 Query 对象,它有一个 greeting 字段,该字段是一个非空 String 值,如下面的代码所示:

    type Query {
      greeting: String!
    } 
    

编写和执行 GraphQL 查询

现在我们知道了模式,我们可以编写并运行一个查询:

  1. Banana Cake Popuntitled 1 文档中,点击 操作 选项卡。

  2. 在左侧,输入一个开括号 {,并注意为你自动写入了闭括号 }

  3. 输入字母 g,并注意自动完成显示它识别了 greeting 字段,如图 图 12.3 所示:

图片

图 12.3:greeting 字段的自动完成

  1. Enter 键接受自动完成建议。

  2. 点击 运行 按钮并注意响应,如图下面的输出所示:

    {
      "data": {
        "greeting": "Hello, World!"
      }
    } 
    
  3. 关闭 Chrome 浏览器,并关闭 web 服务器。

命名 GraphQL 查询即操作

我们编写的查询没有命名。我们也可以将其创建为命名查询,如下面的代码所示:

query QueryNameGoesHere {
  greeting
} 

命名查询允许客户端识别用于遥测目的的查询和响应,例如,当在 Microsoft Azure 云服务中托管并使用 Application Insights 进行监控时。

理解字段约定

我们在 Query 类中创建的 C# 方法名为 GetGreeting,但在查询它时,我们使用了 greeting。表示 GraphQL 中字段的命名方法名上的 Get 前缀是可选的。让我们看看更多示例:

  1. Query.cs 中添加两个不带 Get 前缀的方法,如下面的代码所示:

    namespace Northwind.GraphQL.Service;
    public class Query
    {
      public string GetGreeting() => "Hello, World!";
    **public****string****Farewell****()** **=>** **"Ciao! Ciao!"****;**
    **public****int****RollTheDie****()** **=> Random.Shared.Next(****1****,** **7****);**
    } 
    
  2. 使用 https 配置文件启动 Northwind.GraphQL.Service 项目,不进行调试。

  3. 点击 模式定义 选项卡,并注意更新的模式,如下面的代码所示:

    type Query {
      greeting: String!
      farewell: String!
      rollTheDie: Int!
    } 
    

    C# 方法使用 PascalCase。GraphQL 字段使用 camelCase。

  4. 点击 操作 选项卡,并将查询修改为指定名称并请求 rollTheDie 字段,如下面的代码所示:

    query GetNumber {
      rollTheDie
    } 
    
  5. 多次点击 运行 按钮。注意,响应包含介于 1 和 6 之间的随机数字,以及当前浏览器会话中请求和响应的历史记录,如图 图 12.4 所示:

图片

图 12.4:执行命名查询和请求/响应历史记录

  1. 关闭 Chrome 浏览器,并关闭 web 服务器。

定义 EF Core 模型的 GraphQL 查询

现在我们已经成功运行了一个基本的 GraphQL 服务,让我们扩展它以支持查询 Northwind 数据库。

添加对 EF Core 的支持

我们必须添加另一个 Hot Chocolate 包,以便允许轻松地将我们的 EF Core 数据库上下文与 GraphQL 查询类集成依赖项服务:

  1. 添加对 Hot Chocolate 与 EF Core 集成的包引用,以及 Northwind 数据库上下文项目的项目引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="HotChocolate.AspNetCore" Version="13.5.1" />
     **<PackageReference Include=****"HotChocolate.Data.EntityFramework"**
     **Version=****"13.5.1"** **/>**
    </ItemGroup>
    **<ItemGroup>**
     **<ProjectReference Include=****"..\..\Chapter03\Northwind.Common.DataContext**
    **.SqlServer\Northwind.Common.DataContext.SqlServer.csproj"** **/>**
    **</ItemGroup>** 
    

    项目的路径不能有换行符。所有 Hot Chocolate 包应具有相同的版本号。

  2. 在命令提示符或终端中使用 dotnet build 命令构建 Northwind.GraphQLService 项目。

    当你引用当前解决方案之外的项目时,你必须在命令提示符或终端中至少构建一次项目,然后才能使用 Visual Studio 2022 构建菜单来编译它。

  3. Program.cs 中,导入命名空间以使用 Northwind 数据库的 EF Core 模型,如下面的代码所示:

    using Northwind.EntityModels; // To use AddNorthwindContext method. 
    
  4. CreateBuilder 方法之后添加一个语句以注册 Northwind 数据库上下文类,并在添加 GraphQL 服务器支持之后添加一个语句以注册 NorthwindContent 类进行依赖注入,如下面的代码所示:

    **builder.Services.AddNorthwindContext();**
    builder.Services
      .AddGraphQLServer()
     **.RegisterDbContext<NorthwindContext>()**
      .AddQueryType<Query>(); 
    
  5. Query.cs 中添加语句以定义一个对象图类型,该类型具有一些查询类型,可以返回类别列表、单个类别、类别的产品、具有最低单位价格的产品和所有产品,如下面的代码所示:

    **using** **Microsoft.EntityFrameworkCore;** **// To use Include method.**
    **using** **Northwind.EntityModels;** **// To use NorthwindContext.**
    namespace Northwind.GraphQL.Service;
    public class Query
    {
      public string GetGreeting() => "Hello, World!";
      public string Farewell() => "Ciao! Ciao!";
      public int RollTheDie() => Random.Shared.Next(1, 7);
    **public** **IQueryable<Category>** **GetCategories****(****NorthwindContext db****)** **=>** 
     **db.Categories.Include(c => c.Products);**
    **public** **Category? GetCategory(NorthwindContext db,** **int** **categoryId)**
     **{**
     **Category? category = db.Categories.Find(categoryId);**
    **if** **(category ==** **null****)** **return****null****;**
     **db.Entry(category).Collection(c => c.Products).Load();**
    **return** **category;**
     **}**
    **public** **IQueryable<Product>** **GetProducts****(****NorthwindContext db****)** **=>** 
     **db.Products.Include(p => p.Category);**
    **public** **IQueryable<Product>** **GetProductsInCategory****(**
     **NorthwindContext db,** **int** **categoryId****)** **=>**
     **db.Products.Where(p => p.CategoryId == categoryId);**
    **public** **IQueryable<Product>** **GetProductsByUnitPrice****(**
     **NorthwindContext db,** **decimal** **minimumUnitPrice****)** **=>**
     **db.Products.Where(p => p.UnitPrice >= minimumUnitPrice);**
    } 
    

探索使用 Northwind 的 GraphQL 查询

现在我们可以测试为 Northwind 数据库中的类别和产品编写 GraphQL 查询:

  1. 如果你的数据库服务器没有运行,例如,因为你正在 Docker、虚拟机或云端托管它,那么请确保启动它。

  2. 使用 https 配置文件(无调试)启动 Northwind.GraphQL.Service 项目。

  3. Banana Cake Pop 中,点击 + 打开一个新标签页。

  4. 点击 Schema Definition 选项卡,并注意 Category 的查询和类型定义,部分内容如图 12.5 所示:

图 12.5:使用 GraphQL 查询 Northwind 类别和产品的模式

  1. 注意以下代码中的完整定义:

    type Query {
      greeting: String!
      farewell: String!
      rollTheDie: Int!
      categories: [Category!]!
      category(categoryId: Int!): Category
      products: [Product!]!
      productsInCategory(categoryId: Int!): [Product!]!
      productsByUnitPrice(minimumUnitPrice: Decimal!): [Product!]!
    }
    type Category {
      categoryId: Int!
      categoryName: String!
      description: String
      picture: [Byte!]
      products: [Product!]!
    }
    type Product {
      productId: Int!
      productName: String!
      supplierId: Int
      categoryId: Int
      quantityPerUnit: String
      unitPrice: Decimal
      unitsInStock: Short
      unitsOnOrder: Short
      reorderLevel: Short
      discontinued: Boolean!
      category: Category
      orderDetails: [OrderDetail!]!
      supplier: Supplier
    } 
    
  2. 点击 Operations 选项卡,编写一个命名查询以请求所有类别的 ID、名称和描述字段,如下面的标记所示:

    query AllCategories {
      categories {
        categoryId
        categoryName
        description
      }
    } 
    
  3. 点击 Run,并注意响应,如图 12.6 和以下部分输出所示:

    {
      "data": {
        "categories": 
          {
            "categoryId": 1,
            "categoryName": "Beverages",
            "description": "Soft drinks, coffees, teas, beers, and ales"
          },
          {
            "categoryId": 2,
            "categoryName": "Condiments",
            "description": "Sweet and savory sauces, relishes, spreads, and seasonings"
          },
          ... 
    

![

图 12.6:获取所有类别

  1. 点击 + 打开一个名为 untitled 2 的新文档标签页,并编写一个查询以请求 ID 为 2 的类别,包括其产品的 ID、名称和价格,如下面的标记所示:

    query Condiments {
      category (categoryId: 2) {
        categoryId
        categoryName
        products {
          productId
          productName
          unitPrice
        }
      }
    } 
    

    确保 categoryId 中的 I 是大写。

  2. 点击 Run,并注意响应,如下面的部分输出所示:

    {
      "data": {
        "category": {
          "categoryId": 2,
          "categoryName": "Condiments",
          "products": [
            {
              "productId": 3,
              "productName": "Aniseed Syrup",
              "unitPrice": 10
            },
            {
              "productId": 4,
              "productName": "Chef Anton's Cajun Seasoning",
              "unitPrice": 22
            },
            ... 
    
  3. 在 GraphQL web 服务命令提示符或终端中,注意为此查询执行的 SQL 语句,如下所示的部分输出:

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
          Executed DbCommand (68ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
          SELECT TOP(1) [c].[CategoryId], [c].[CategoryName], [c].[Description], [c].[Picture]
          FROM [Categories] AS [c]
          WHERE [c].[CategoryId] = @__p_0
    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
          Executed DbCommand (5ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
          SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder]
          FROM [Products] AS [p]
          WHERE [p].[CategoryId] = @__p_0 
    

    虽然 GraphQL 查询不需要每个类别的图片,只需要 ID、名称和单价,但 EF Core 动态生成的查询返回了所有属性。

  4. 点击+标签打开一个新标签页,并编写一个查询以请求 ID、名称和库存单位的产品,其类别 ID 为1,如下所示:

    query BeverageProducts {
      productsInCategory (categoryId: 1) {
        productId
        productName
        unitsInStock
      }
    } 
    
  5. 点击运行,并注意响应,如下所示的部分输出:

    {
      "data": {
        "productsInCategory": 
          {
            "productId": 1,
            "productName": "Chai",
            "unitsInStock": 39
          },
          {
            "productId": 2,
            "productName": "Chang",
            "unitsInStock": 17
          },
          ... 
    
  6. 点击+标签打开一个新标签页,并编写一个查询以请求产品的 ID、名称、库存单位和其类别名称,如下所示:

    query ProductsWithCategoryNames {
      products {
        productId
        productName
        category {
          categoryName
        }
        unitsInStock
      }
    } 
    
  7. 点击运行,并注意响应,如下所示的部分输出:

    {
      "data": {
        "products": [
          {
            "productId": 1,
            "productName": "Chai",
            "category": {
              "categoryName": "Beverages"
            },
            "unitsInStock": 39
          },
          {
            "productId": 2,
            "productName": "Chang",
            "category": {
              "categoryName": "Beverages"
            },
            "unitsInStock": 17
          },
          ... 
    
  8. 点击+标签打开一个新标签页,并编写一个查询以请求类别的 ID 和名称,通过指定类别 ID 选择该类别,并包括其每个产品的 ID 和名称。类别 ID 将使用变量设置,如下所示:

    query CategoryAndItsProducts($id: Int!){
      category(categoryId: $id) {
        categoryId
        categoryName
        products {
          productId
          productName
        }
      }
    } 
    
  9. 变量部分,定义一个变量的值,如下所示,并在图 12.7中:

    {
      "id": 1
    } 
    

![图片

图 12.7:执行带有变量的 GraphQL 查询

  1. 点击运行,并注意响应,如下所示的部分输出:

    {
      "data": {
        "category": {
          "categoryId": 1,
          "categoryName": "Beverages",
          "products": [
            {
              "productId": 1,
              "productName": "Chai"
            },
            {
              "productId": 2,
              "productName": "Chang"
            },
            ... 
    
  2. 点击+标签打开一个新标签页,编写一个查询以请求类别的 ID 和名称,通过指定类别 ID 选择该类别,并包括其每个产品的 ID 和名称。类别 ID 将使用变量设置,如下所示:

    query ProductsWithMinimumPrice($unitPrice: Decimal!){
      productsByUnitPrice(minimumUnitPrice: $unitPrice) {
          productId
          productName
          unitPrice
      }
    } 
    
  3. 变量部分,定义一个变量的值,如下所示:

    {
      "unitPrice": 100
    } 
    
  4. 点击运行,并注意响应,如下所示的部分输出:

    {
      "data": {
        "productsByUnitPrice": [
          {
            "productId": 29,
            "productName": "Thüringer Rostbratwurst",
            "unitPrice": 123.79
          },
          {
            "productId": 38,
            "productName": "Côte de Blaye",
            "unitPrice": 263.5
          }
        ]
      }
    } 
    
  5. 关闭 Chrome,并关闭 Web 服务器。

实现分页支持

当我们使用GetProducts方法(products查询)请求产品时,返回所有 77 个产品。让我们添加分页支持:

  1. Query.cs中添加语句以定义一个查询,用于返回所有产品,使用分页,并注意其实现与不带分页的产品查询相同,但它被装饰了[UsePaging]属性,如下所示:

    [UsePaging]
    public IQueryable<Product> GetProductsWithPaging(NorthwindContext db) =>
      db.Products.Include(p => p.Category); 
    

    良好实践[UsePaging][UseFiltering][UseSorting]属性必须装饰到返回IQueryable<T>的查询方法上,允许 GraphQL 在执行数据存储之前动态配置 LINQ 查询。

  2. 使用https配置文件(无调试)启动Northwind.GraphQL.Service项目。

  3. 香蕉蛋糕棒中,点击+以打开一个新标签页。

  4. 点击模式定义标签,并注意名为products(不带分页)和productsWithPaging的查询,这些查询说明了如何使用查询请求产品的一页,如下所示的部分输出:

    products: [Product!]!
    productsInCategory(categoryId: Int!): [Product!]!
    productsByUnitPrice(minimumUnitPrice: Decimal!): [Product!]!
    productsWithPaging(
      """
      Returns the first _n_ elements from the list.
      """
      first: Int
      """
      Returns the elements in the list that come after the specified cursor.
      """
      after: String
      """
      Returns the last _n_ elements from the list.
      """
      last: Int
      """
      Returns the elements in the list that come before the specified cursor.
      """
      before: String
    ): ProductsWithPagingConnection 
    
  5. 点击 操作选项卡,并编写一个命名查询以请求第 1 页的 10 个商品,如下所示的部分标记:

    query FirstTenProducts {
      productsWithPaging(first: 10) {
        pageInfo {
          hasPreviousPage
          hasNextPage
          startCursor
          endCursor
        }
        nodes {
          productId
          productName
        }
      }
    } 
    
  6. 点击 运行,注意响应,包括 pageInfo 部分,它告诉我们还有另一页的产品,并且此页的游标范围从 MA==OQ==,如下所示的部分输出:

    {
      "data": {
        "productsWithPaging": {
          "pageInfo": {
            "hasPreviousPage": false,
            "hasNextPage": true,
            "startCursor": "MA==",
            "endCursor": "OQ=="
          },
          "nodes": [
            {
              "productId": 1,
              "productName": "Chai"
            },
    ...
    {
              "productId": 10,
              "productName": "Ikura"
            }
          ]
        }
      }
    } 
    
  7. 点击 + 打开一个新标签页,并编写一个命名查询以请求第 2 页的 10 个商品,指定我们想要一个从 OQ== 开始的游标,如下所示的部分标记:

    query SecondTenProducts {
      productsWithPaging(after: "OQ==") {
        pageInfo {
          hasPreviousPage
          hasNextPage
          startCursor
          endCursor
        }
        nodes {
          productId
          productName
        }
      }
    } 
    
  8. 点击 运行,注意响应,包括 pageInfo 部分,它告诉我们还有另一页的产品,并且此页的游标范围从 MTA=MTk=,如下所示的部分输出:

    {
      "data": {
        "productsWithPaging": {
          "pageInfo": {
            "hasPreviousPage": true,
            "hasNextPage": true,
            "startCursor": "MTA=",
            "endCursor": "MTk="
          },
          "nodes": [
            {
              "productId": 11,
              "productName": "Queso Cabrales"
            },
    ...
    {
              "productId": 20,
              "productName": "Sir Rodney's Marmalade"
            }
          ]
        }
      }
    } 
    
  9. 关闭 Chrome,并关闭 web 服务器。

实现过滤支持

当我们在本章前面探索查询时,我们预先定义了一些带有参数的查询,例如,通过传递 categoryId 参数返回一个类别中所有商品的查询。

然而,如果你事先不知道要执行什么过滤操作怎么办?

让我们向我们的 GraphQL 查询添加过滤支持:

  1. Program.cs 文件中,在调用 AddGraphQLServer 之后添加对 AddFiltering 的调用,如下代码所示:

    builder.Services
      .AddGraphQLServer()
     **.AddFiltering()**
      .RegisterDbContext<NorthwindContext>()
      .AddQueryType<Query>(); 
    
  2. Query.cs 文件中,使用 [UseFiltering] 属性装饰 GetProducts 方法,如下代码所示:

    **[****UseFiltering****]**
    public IQueryable<Product> GetProducts(NorthwindContext db) =>
      db.Products.Include(p => p.Category); 
    
  3. 使用不带调试的 https 配置启动 Northwind.GraphQL.Service 项目。

  4. 香蕉蛋糕棒 中,点击 + 打开一个新标签页。

  5. 点击 方案定义,注意 products 查询现在接受一个过滤输入,如下所示的部分标记:

    products(where: ProductFilterInput): [Product!]! 
    
  6. 将方案定义向下滚动以找到 ProductFilterInput,并注意过滤选项包括布尔运算符如 andor,以及字段过滤器如 IntOperationFilterInputStringOperationFilterInput,如下所示的部分标记:

    input ProductFilterInput {
      and: [ProductFilterInput!]
      or: [ProductFilterInput!]
      productId: IntOperationFilterInput
      productName: StringOperationFilterInput
      supplierId: IntOperationFilterInput
      categoryId: IntOperationFilterInput
      quantityPerUnit: StringOperationFilterInput
      unitPrice: DecimalOperationFilterInput
      unitsInStock: ShortOperationFilterInput
      unitsOnOrder: ShortOperationFilterInput
      reorderLevel: ShortOperationFilterInput
      discontinued: BooleanOperationFilterInput
      category: CategoryFilterInput
      orderDetails: ListFilterInputTypeOfOrderDetailFilterInput
      supplier: SupplierFilterInput
    } 
    
  7. 将方案定义向下滚动以找到 IntOperationFilterInputStringOperationFilterInput,并注意你可以与它们一起使用的操作,如等于 (eq)、不等于 (neq)、在数组中 (in)、大于 (gt)、包含 (contains) 和以...开头 (startsWith),如下所示的部分标记:

    input IntOperationFilterInput {
      eq: Int
      neq: Int
      in: [Int]
      nin: [Int]
      gt: Int
      ngt: Int
      gte: Int
      ngte: Int
      lt: Int
      nlt: Int
      lte: Int
      nlte: Int
    }
    input StringOperationFilterInput {
      and: [StringOperationFilterInput!]
      or: [StringOperationFilterInput!]
      eq: String
      neq: String
      contains: String
      ncontains: String
      in: [String]
      nin: [String]
      startsWith: String
      nstartsWith: String
      endsWith: String
      nendsWith: String
    } 
    
  8. 点击 操作,然后编写一个命名查询以请求库存超过 120 单位的商品,如下所示的部分标记:

    query ProductsWithMoreThan40InStock {
      products(where: { unitsInStock: { gt: 120 } }) {
        productId
        productName
        unitsInStock
      }
    } 
    
  9. 点击 运行,注意响应,如下所示的部分输出:

    {
      "data": {
        "products": [
          {
            "productId": 40,
            "productName": "Boston Crab Meat",
            "unitsInStock": 123
          },
          {
            "productId": 75,
            "productName": "Rhönbräu Klosterbier",
            "unitsInStock": 125
          }
        ]
      }
    } 
    
  10. 点击 + 打开一个新标签页,点击 操作,然后编写一个命名查询以请求名称以 Cha 开头的商品,如下所示的部分标记:

    query ProductNamesCha {
      products(where: { productName: { startsWith: "Cha" } }) {
        productId
        productName
      }
    } 
    
  11. 点击 运行,注意响应,如下所示的部分输出:

    {
      "data": {
        "products": [
          {
            "productId": 1,
            "productName": "Chai"
          },
          {
            "productId": 2,
            "productName": "Chang"
          },
          {
            "productId": 39,
            "productName": "Chartreuse verte"
          }
        ]
      }
    } 
    
  12. 在 GraphQL 服务命令提示符或终端中,注意 EF Core 生成的使用参数的 SQL 过滤器,如下所示的部分输出:

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
          Executed DbCommand (2ms) [Parameters=[@__p_0_rewritten='?' (Size = 40)], CommandType='Text', CommandTimeout='30']
          SELECT [p].[ProductId], [p].[CategoryId], [p].[Discontinued], [p].[ProductName], [p].[QuantityPerUnit], [p].[ReorderLevel], [p].[SupplierId], [p].[UnitPrice], [p].[UnitsInStock], [p].[UnitsOnOrder], [c].[CategoryId], [c].[CategoryName], [c].[Description], [c].[Picture]
          FROM [Products] AS [p]
          LEFT JOIN [Categories] AS [c] ON [p].[CategoryId] = [c].[CategoryId]
          WHERE [p].[ProductName] LIKE @__p_0_rewritten ESCAPE N'\' 
    
  13. 关闭 Chrome,并关闭 web 服务器。

实现排序支持

要启用 GraphQL 服务的排序,调用 AddSorting 方法,如下面的代码所示:

builder.Services
  .AddGraphQLServer()
  .AddFiltering()
 **.AddSorting()**
  .RegisterDbContext<NorthwindContext>()
  .AddQueryType<Query>(); 

然后,使用 [UseSorting] 属性装饰返回 IQueryable<T> 的查询方法,如下面的代码所示:

[UseFiltering]
**[****UseSorting****]**
public IQueryable<Product> GetProducts(NorthwindContext db) =>
  db.Products.Include(p => p.Category); 

在查询中应用一个或多个排序顺序,如下面的代码所示:

query ProductsSortedByMostExpensive {
  products(order: [ { unitPrice: DESC } ]) {
    productId
    productName
    unitPrice
  }
} 

SortEnumType 有两个值,如下面的代码所示:

enum SortEnumType {
  ASC
  DESC
} 

我将把添加排序功能到你的 GraphQL 服务中留给你。

为 GraphQL 服务构建 .NET 客户端

现在我们已经使用 Banana Cake Pop 工具探索了一些查询,让我们看看客户端如何调用 GraphQL 服务。虽然 Banana Cake Pop 工具很方便,但它运行在与服务相同的域中,因此一些问题可能直到我们创建一个单独的客户端时才变得明显。

选择 GraphQL 请求格式

大多数 GraphQL 服务以 application/graphqlapplication/json 媒体格式处理 GETPOST 请求。一个 application/graphql 请求将只包含一个查询文档。使用 application/json 的好处是,除了查询文档外,你还可以在有多种操作时指定操作,并定义和设置变量,如下面的代码所示:

{
  "query": "...",
  "operationName": "...",
  "variables": { "variable1": "value1", ... }
} 

我们将使用 application/json 媒体格式,这样我们就可以传递变量及其值。

理解 GraphQL 响应格式

一个 GraphQL 服务应该返回一个包含预期数据对象和可能包含一些错误数组的 JSON 文档,其结构如下:

{
  "data": { ... },
  "errors": [ ... ]
} 

errors 数组只有在文档中有错误时才应出现在文档中。

使用 REST Client 作为 GraphQL 客户端

在我们编写客户端代码向 GraphQL 服务发送请求之前,最好使用你的代码编辑器的 .http 文件支持对其进行测试。这样,如果我们的 .NET 客户端应用不起作用,我们就知道问题出在我们的客户端代码而不是服务上:

  1. 如果你正在使用 Visual Studio Code 并且尚未安装 Huachao Mao 的 REST Client (humao.rest-client),那么现在就安装它。

  2. 在你偏好的代码编辑器中,启动 Northwind.GraphQL.Service 项目网络服务,使用 https 配置文件,不进行调试,并保持运行。

  3. 在你的代码编辑器中,在 HttpRequests 文件夹中,创建一个名为 graphql-queries.http 的文件,并修改其内容以包含获取海鲜类别产品的请求,如下面的代码所示:

    ### Configure a variable for the GraphQL service base address.
    @base_address = https://localhost:5121/graphql
    ### Get all products in the specified category.
    POST {{base_address}}
    Content-Type: application/json
    {
      "query" : "{productsInCategory(categoryId:8){productId productName unitsInStock}}"
    } 
    
  4. 发送查询请求,并注意响应,如图 图 12.8 所示:

图 12.8:使用 REST Client 请求海鲜产品

  1. 添加一个查询来获取所有类别的 ID、名称和描述,如下面的代码所示:

    ### Get all categories.
    POST {{base_address}}
    Content-Type: application/json
    {
      "query" : "{categories{categoryId categoryName description}}"
    } 
    
  2. 发送查询请求,并注意响应包含 data 属性中的八个类别。

  3. 在查询文档中,将 categoryId 改为 id

  4. 发送查询请求,并注意响应包含一个 errors 数组,如下面的响应所示:

    Response time: 60 ms
    Status code: BadRequest (400)
    Alt-Svc: h3=":5121"; ma=86400
    Transfer-Encoding: chunked
    Date: Tue, 06 Jun 2023 16:35:18 GMT
    Server: Kestrel
    Content-Type: application/graphql-response+json; charset=utf-8
    Content-Length: 338
    ------------------------------------------------
    Content:
    {
      "errors": [
        {
          "message": "The field `id` does not exist on the type `Category`.",
          "locations": [
            {
              "line": 1,
              "column": 13
            }
          ],
          "path": [
            "categories"
          ],
          "extensions": {
            "type": "Category",
            "field": "id",
            "responseName": "id",
            "specifiedBy": "http://spec.graphql.org/October2021/#sec-Field-Selections-on-Objects-Interfaces-and-Unions-Types"
          }
        }
      ]
    } 
    
  5. 在查询文档中,将 id 重新改为 categoryId

  6. 添加一个查询以请求获取通过参数指定的类别 ID 及其名称,以及每个产品的 ID 和名称,如下面的代码所示:

    ### Get a category and its products using a variable.
    POST {{base_address}}
    Content-Type: application/json
    {
      "query": "query categoryAndItsProducts($id: Int!){category(categoryId: $id){categoryId categoryName products{productId productName}}}",
      "variables": {"id":1}
    } 
    
  7. 发送查询请求,并注意响应包含类别1Beverages,以及其产品在data属性中。

  8. 将 ID 更改为4,发送请求,并注意响应包含类别4Dairy Products,以及其产品在data属性中。

现在我们已经对服务及其对我们想要运行的查询的响应进行了一些基本的测试,我们可以构建一个客户端来执行这些查询并处理 JSON 响应。

使用 ASP.NET Core MVC 项目作为 GraphQL 客户端

我们将创建一个模型类,以便轻松反序列化响应:

  1. 使用您首选的代码编辑器添加一个新项目,如下列列表中定义的:

    1. 项目模板:ASP.NET Core Web App (Model-View-Controller) / mvc

    2. 解决方案文件和文件夹:Chapter12

    3. 项目文件和文件夹:Northwind.GraphQL.Client.Mvc

    4. 其他 Visual Studio 2022 选项:

      • 身份验证类型:无

      • 配置为 HTTPS:已选择

      • 启用 Docker:已清除

      • 不要使用顶级语句:已清除

  2. 在 Visual Studio 2022 中,设置启动项目为当前选择。

  3. Northwind.GraphQL.Client.Mvc项目中,添加对 Northwind 实体模型项目的项目引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.EntityModels
    .SqlServer\Northwind.Common.EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    

    项目的路径不得包含换行符。

  4. 在命令提示符或终端中构建Northwind.GraphQL.Client.Mvc项目。

  5. Properties文件夹中的launchSettings.json中,修改applicationUrl以使用端口5123进行https和端口5124进行http,如下面的配置中突出显示:

    **"https"****:****{**
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
    **"applicationUrl"****:****"https://localhost:5123;http://localhost:5124"****,**
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      } 
    
  6. Northwind.GraphQL.Client.Mvc项目的Models文件夹中,添加一个名为ResponseErrors.cs的新类文件,如下面的代码所示:

    namespace Northwind.GraphQL.Client.Mvc.Models;
    public class ResponseErrors
    {
      public Error[]? Errors { get; set; }
    }
    public class Error
    {
      public string Message { get; set; } = null!;
      public Location[] Locations { get; set; } = null!;
      public string[] Path { get; set; } = null!;
    }
    public class Location
    {
      public int Line { get; set; }
      public int Column { get; set; }
    } 
    
  7. Models文件夹中,添加一个名为ResponseProducts.cs的新类文件,如下面的代码所示:

    using Northwind.EntityModels; // To use Product.
    namespace Northwind.GraphQL.Client.Mvc.Models;
    public class ResponseProducts
    {
      public class DataProducts
      {
        public Product[]? ProductsInCategory { get; set; }
      }
      public DataProducts? Data { get; set; }
    } 
    
  8. Models文件夹中,添加一个名为ResponseCategories.cs的新类文件,如下面的代码所示:

    using Northwind.EntityModels; // To use Category.
    namespace Northwind.GraphQL.Client.Mvc.Models;
    public class ResponseCategories
    {
      public class DataCategories
      {
        public Category[]? Categories { get; set; }
      }
      public DataCategories? Data { get; set; }
    } 
    
  9. Models文件夹中,添加一个名为IndexViewModel.cs的新类文件,它将具有存储我们可能在视图中显示的所有数据的属性,如下面的代码所示:

    using Northwind.EntityModels; // To use Product.
    using System.Net; // To use HttpStatusCode.
    namespace Northwind.GraphQL.Client.Mvc.Models;
    public class IndexViewModel
    {
      public HttpStatusCode Code { get; set; }
      public string? RawResponseBody { get; set; }
      public Product[]? Products { get; set; }
      public Category[]? Categories { get; set; }
      public Error[]? Errors { get; set; }
    } 
    
  10. Program.cs中,导入命名空间以设置 HTTP 头,如下面的代码所示:

    using System.Net.Http.Headers; // To use MediaTypeWithQualityHeaderValue. 
    
  11. Program.cs中,在CreateBuilder方法调用之后,添加注册 GraphQL 服务的 HTTP 客户端的语句,如下面的代码所示:

    builder.Services.AddHttpClient(name: "Northwind.GraphQL.Service",
      configureClient: options =>
      {
        options.BaseAddress = new Uri("https://localhost:5121/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
          "application/json", 1.0));
      }); 
    
  12. Controllers文件夹中的HomeController.cs中,导入命名空间以处理文本编码以及本地项目模型,如下面的代码所示:

    using Northwind.Mvc.GraphQLClient.Models; // To use IndexViewModel.
    using System.Text; // To use Encoding. 
    
  13. 定义一个字段以存储已注册的 HTTP 客户端工厂,并在构造函数中设置它,如下面的代码所示:

    **protected****readonly** **IHttpClientFactory _clientFactory;**
    public HomeController(ILogger<HomeController> logger**,** 
     **IHttpClientFactory clientFactory**)
    {
      _logger = logger;
     **_clientFactory = clientFactory;**
    } 
    
  14. Index 动作方法中,将方法修改为异步。然后,添加调用 GraphQL 服务的语句,并注意 HTTP 请求是 POST,媒体类型是包含 GraphQL 查询的 application/json 文档,该查询请求给定类别中所有产品的 ID、名称和库存数量,通过名为 id 的参数传递,如下面的代码所示:

    public async Task<IActionResult> Index(string id = "1")
    {
      IndexViewModel model = new();
      try
      {
        HttpClient client = _clientFactory.CreateClient(
          name: "Northwind.GraphQL.Service");
        // First, try a simple GET request to service root.
        HttpRequestMessage request = new(
          method: HttpMethod.Get, requestUri: "/");
        HttpResponseMessage response = await client.SendAsync(request);
        if (!response.IsSuccessStatusCode)
        {
          model.Code = response.StatusCode;
          model.Errors = new[] { new Error { Message = 
            "Service is not successfully responding to GET requests." } };
          return View(model);
        }
        // Next, make a request to the GraphQL endpoint.
        request = new(
          method: HttpMethod.Post, requestUri: "graphql");
        request.Content = new StringContent(content: $$$"""
    {
      "query": "{productsInCategory(categoryId:{{{id}}}){productId productName unitsInStock}}"
    }
          """,
          encoding: Encoding.UTF8,
          mediaType: "application/json");
        response = await client.SendAsync(request);
        model.Code = response.StatusCode;
        model.RawResponseBody = await response.Content.ReadAsStringAsync();
        if (response.IsSuccessStatusCode)
        {
          model.Products = (await response.Content
            .ReadFromJsonAsync<ResponseProducts>())?.Data?.ProductsInCategory;
        }
        else
        {
          model.Errors = (await response.Content
            .ReadFromJsonAsync<ResponseErrors>())?.Errors;
        }
      }
      catch (Exception ex)
      {
        _logger.LogWarning(
          $"Northwind.GraphQL.Service exception: {ex.Message}");
        model.Errors = new[] { new Error { Message = ex.Message } };
      }
      return View(model);
    } 
    

    良好实践:为了设置我们请求的内容,我们将使用 C# 11 或更高版本的三个美元符号和三个双引号的原始插值字符串字面量语法。这允许我们使用三个大括号嵌入 id 变量,不应与 unitsInStock 后面的两个大括号混淆,后者结束查询本身。

  15. Views/Home 文件夹中的 Index.cshtml 文件中,删除其现有的标记,然后添加标记以渲染海鲜产品,如下面的标记所示:

    @using Northwind.EntityModels
    @using Northwind.GraphQL.Client.Mvc.Models @* for VS Code only *@
    @model IndexViewModel
    @{
      ViewData["Title"] = "Products from GraphQL service";
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      <div class="card card-body">
        <form>
          Enter a category id
          <input name="id" value="1" />
          <input type="submit" />
        </form>
      </div>
      @if (Model.Errors is not null)
      {
        <div class="alert alert-danger" role="alert">
          <table class="table table-striped">
            <thead>
            <tr>
              <td>Message</td>
              <td>Path</td>
              <td>Locations</td>
            </tr>
            </thead>
            <tbody>
              @foreach (Error error in Model.Errors)
              {
                <tr>
                  <td>@error.Message</td>
                  <td>
                    @if (error.Path is not null)
                    {
                      @foreach (string path in error.Path)
                      {
                        <span class="badge bg-danger">@path</span>
                      }
                    }
                  </td>
                  <td>
                    @if (error.Locations is not null)
                    {
                      @foreach (Location location in error.Locations)
                      {
                        <span class="badge bg-danger">
                          @location.Line, @location.Column
                        </span>
                      }
                    }
                  </td>
                </tr>
              }
            </tbody>
          </table>
        </div>
      }
      @if (Model.Categories is not null)
      {
        <div>
          <p class="alert alert-success" role="alert">
            There are @Model.Categories.Count() products.</p>
          <p>
            @foreach (Category category in Model.Categories)
            {
              <span class="badge bg-dark">
                @category.CategoryId
                @category.CategoryName
              </span>
            }
          </p>
        </div>
      }
      @if (Model.Products is not null)
      {
        <div>
          <p class="alert alert-success" role="alert">
            There are @Model.Products.Count() products.</p>
          <p>
            @foreach (Product p in Model.Products)
            {
              <span class="badge bg-dark">
                @p.ProductId
                @p.ProductName
                -
                @(p.UnitsInStock is null ? "0" : p.UnitsInStock.Value) in stock
              </span>
            }
          </p>
        </div>
      }
      <p>
        <a class="btn btn-primary" data-bs-toggle="collapse" 
           href="#collapseExample" role="button" 
           aria-expanded="false" aria-controls="collapseExample">
          Show/Hide Details
        </a>
      </p>
      <div class="collapse" id="collapseExample">
        <div class="card card-body">
          Status code @((int)Model.Code): @Model.Code
          <hr />
          @Model.RawResponseBody
        </div>
      </div>
    </div> 
    

测试 .NET 客户端

现在,我们可以测试我们的 .NET 客户端:

  1. 如果您的数据库服务器没有运行,例如,因为您正在 Docker、虚拟机或云中托管它,那么请确保启动它。

  2. 使用不带调试的 https 配置启动 Northwind.GraphQL.Service 项目。

  3. 使用不带调试的 https 配置启动 Northwind.GraphQL.Client.Mvc 项目。

  4. 注意,使用 GraphQL 成功检索了产品,如图 图 12.9 所示:

图片

图 12.9:来自 GraphQL 服务的饮料类别产品

  1. 输入另一个存在的类别 ID,例如 4

  2. 输入一个超出范围的类别 ID,例如 13,并注意返回了 0 个产品。

  3. 关闭 Chrome,并关闭 Northwind.GraphQL.Client.Mvc 项目的 Web 服务器。

  4. HomeController.cs 中,修改查询以故意犯一个错误,例如将 productId 改为 productid

  5. 使用不带调试的 https 配置启动 Northwind.GraphQL.Client.Mvc 项目。

  6. 点击 显示/隐藏详细信息 按钮,并注意错误信息和响应详细信息,如下面的输出所示:

    {"errors":[{"message":"The field \u0060productid\u0060 
    does not exist on the type \u0060Product\u0060.",
     "locations":[{"line":1,"column":35}],
     "path":["productsInCategory"],
     "extensions":{"type":"Product","field":"productid",
     "responseName":"productid",
     "specifiedBy":"http://spec.graphql.org/October2021/
    #sec-Field-Selections-on-Objects-Interfaces-and-Unions-Types"}}]} 
    
  7. 关闭 Chrome,并关闭两个 Web 服务器。

  8. 修复查询中的错误!

使用 Strawberry Shake 创建控制台应用程序客户端

与使用普通 HTTP 客户端不同,ChilliCream 有一个 GraphQL 客户端库,可以更轻松地构建用于 GraphQL 服务的 .NET 客户端。

更多信息:您可以在以下链接中了解更多关于 Strawberry Shake 的信息:chillicream.com/docs/strawberryshake

现在,让我们使用 Strawberry Shake 创建另一个客户端,以便您可以看到其好处:

  1. 使用您首选的代码编辑器添加一个新的 控制台应用程序 / console 项目,命名为 Northwind.GraphQL.Client.Console

  2. 在项目文件夹的命令提示符或终端中,创建一个工具清单文件,如下面的命令所示:

    dotnet new tool-manifest 
    
  3. 在命令行或终端中,安装 Strawberry Shake 工具,如下面的命令所示:

    dotnet tool install StrawberryShake.Tools --local 
    
  4. 注意 Strawberry Shake 已安装,如下所示:

    You can invoke the tool from this directory using the following commands:
    'dotnet tool run dotnet-graphql' or 'dotnet dotnet-graphql'.
    Tool 'strawberryshake.tools' (version '13.5.1') was successfully installed.
    Entry is added to the manifest file C:\apps-services-net8\Chapter12\
    Northwind.GraphQL.Client.Console\.config\dotnet-tools.json. 
    
  5. 在项目中,将警告视为错误,添加对 Microsoft 扩展依赖注入、处理 HTTP 和 Strawberry Shake 代码生成的 NuGet 包的引用,然后全局和静态导入Console类,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference Version=****"****8.0.0"**
     **Include=****"Microsoft.Extensions.DependencyInjection"** **/>**
     **<PackageReference Version=****"8.0.0"**
     **Include=****"Microsoft.Extensions.Http"** **/>**
     **<PackageReference Version=****"13.5.1"**
     **Include=****"StrawberryShake.Server"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<Using Include=****"System.Console"** **Static=****"true"** **/>**
     **</ItemGroup>**
    </Project> 
    

    你需要为不同类型的.NET 项目使用不同的 Strawberry Shake 包。对于控制台应用程序和 ASP.NET Core 应用程序,引用StrawberryShake.Server。对于 Blazor WebAssembly 应用程序,引用StrawberryShake.Blazor。对于.NET MAUI 应用程序,引用StrawberryShake.Maui

  6. 构建项目Northwind.GraphQL.Client.Console以还原包。

  7. 启动Northwind.GraphQL.Service项目,使用https配置文件且不进行调试,并让它运行,以便 Strawberry Shake 工具可以与其通信。

  8. Northwind.GraphQL.Client.Console项目中,在命令提示符或终端中添加一个用于 GraphQL 服务的客户端,如下所示:

    dotnet graphql init https://localhost:5121/graphql/ -n NorthwindClient 
    
  9. 注意以下输出结果:

    Download schema started.
    Download schema completed in 189 ms
    Client configuration started.
    Client configuration completed in 83 ms 
    
  10. Northwind.GraphQL.Client.Console项目中,在.graphqlrc.json文件中添加一个条目来控制代码生成期间使用的 C#命名空间,如下所示:

    {
      "schema": "schema.graphql",
      "documents": "**/*.graphql",
      "extensions": {
        "strawberryShake": {
          "name": "NorthwindClient",
    **"namespace"****:****"Northwind.GraphQL.Client.Console"****,**
          "url": "https://localhost:5111/graphql/",
          "records": {
            "inputs": false,
            "entities": false
          },
          "transportProfiles": [
            {
              "default": "Http",
              "subscription": "WebSocket"
            }
          ]
        }
      }
    } 
    
  11. Northwind.GraphQL.Client.Console项目中,添加一个名为seafoodProducts.graphql的新文件,该文件定义了一个获取海鲜产品的查询,如下所示:

    query SeafoodProducts {
      productsInCategory(categoryId:8) {
        productId 
        productName 
        unitsInStock
      }
    } 
    

    Strawberry Shake 使用的 GraphQL 查询必须命名。

  12. 如果你正在使用 Visual Studio 2022,它可能会自动修改项目文件以显式地从构建过程中删除此文件,因为它不认识它。如果是这样,则删除或注释掉该元素,如下所示:

    **<!-- An element like this will remove the file from the build process.**
    <ItemGroup>
      <GraphQL Remove="seafoodProducts.graphql" />
    </ItemGroup>
    **-->** 
    

    良好实践:必须至少有一个.graphql文件,以便 Strawberry Shake 工具能够自动生成其代码。像下面这样的元素将阻止 Strawberry Shake 工具生成代码,你稍后将会遇到编译错误。你应该删除或注释掉该元素。

  13. 构建项目Northwind.GraphQL.Client.Console,以便 Strawberry Shake 处理 GraphQL 查询文件并生成代理类。

  14. 注意自动生成的obj\Debug\net8.0\berry文件夹,名为NorthwindClient.Client.cs的文件,以及它定义的十几个类型,包括INorthwindClient接口,如图12.10所示:

图片

图 12.10:Northwind GraphQL 服务生成的类文件

  1. Program.cs中,删除现有的语句。添加语句以创建一个新的服务集合,将自动生成的NorthwindClient添加到其中,并使用服务正确的 URL,然后获取并使用依赖服务来获取海鲜产品,如下所示:

    using Microsoft.Extensions.DependencyInjection; // To use ServiceCollection.
    using Northwind.GraphQL.Client.Console; // To use INorthwindClient.
    using StrawberryShake; // To use EnsureNoErrors extension method.
    ServiceCollection serviceCollection = new();
    serviceCollection
      .AddNorthwindClient() // Strawberry Shake extension method.
      .ConfigureHttpClient(client => 
        client.BaseAddress = new Uri("https://localhost:5121/graphql"));
    IServiceProvider services = serviceCollection.BuildServiceProvider();
    INorthwindClient client = services.GetRequiredService<INorthwindClient>();
    var result = await client.SeafoodProducts.ExecuteAsync();
    result.EnsureNoErrors();
    if (result.Data is null)
    {
      WriteLine("No data!");
      return; 
    }
    foreach (var product in result.Data.ProductsInCategory)
    {
      WriteLine("{0}: {1}",
        product.ProductId, product.ProductName);
    } 
    
  2. 运行控制台应用程序并注意以下输出结果:

    10: Ikura
    13: Konbu
    18: Carnarvon Tigers
    30: Nord-Ost Matjeshering
    36: Inlagd Sill
    37: Gravad lax
    40: Boston Crab Meat
    41: Jack's New England Clam Chowder
    45: Rogede sild
    46: Spegesild
    58: Escargots de Bourgogne
    73: Röd Kaviar 
    

实现 GraphQL 突变

大多数服务都需要修改数据以及查询数据。GraphQL 将这些称为 突变。一个突变有三个相关组件:

  • 突变本身,它定义了对图所做的更改。它应该使用动词、名词并用驼峰式命名,例如,addProduct

  • 输入 是突变的输入,并且应该与突变具有相同的名称,后缀为 Input,例如,AddProductInput。尽管只有一个输入,但它是一个对象图,因此可以像你需要的那样复杂。

  • 负载 是突变的返回文档,并且应该与突变具有相同的名称,后缀为 Payload,例如,AddProductPayload。尽管只有一个负载,但它是一个对象图,因此可以像你需要的那样复杂。

将突变添加到 GraphQL 服务中

让我们定义添加的突变,稍后,我们将定义一些用于更新和删除产品的突变:

  1. Northwind.GraphQL.Service 项目/文件夹中,添加一个名为 Mutation.cs 的类文件。

  2. 在类文件中,定义一个记录和两个类来表示执行 addProduct 突变所需的三个类型,如下面的代码所示:

    using Northwind.EntityModels; // To use Product.
    namespace Northwind.GraphQL.Service;
    // Inputs are readonly so we will use a record.
    public record AddProductInput(
      string ProductName,
      int? SupplierId,
      int? CategoryId,
      string QuantityPerUnit,
      decimal? UnitPrice,
      short? UnitsInStock,
      short? UnitsOnOrder,
      short? ReorderLevel,
      bool Discontinued);
    public class AddProductPayload
    {
      public AddProductPayload(Product product)
      {
        Product = product;
      }
      public Product Product { get; }
    }
    public class Mutation
    {
      public async Task<AddProductPayload> AddProductAsync(
        AddProductInput input, NorthwindContext db)
      {
        // This could be a good place to use a tool like AutoMapper,
        // but we will do the mapping between two objects manually.
        Product product = new()
        {
          ProductName = input.ProductName,
          SupplierId = input.SupplierId,
          CategoryId = input.CategoryId,
          QuantityPerUnit = input.QuantityPerUnit,
          UnitPrice = input.UnitPrice,
          UnitsInStock = input.UnitsInStock,
          UnitsOnOrder = input.UnitsOnOrder,
          ReorderLevel = input.ReorderLevel,
          Discontinued = input.Discontinued
        };
        db.Products.Add(product);
        int affectedRows = await db.SaveChangesAsync();
        // We could use affectedRows to return an error
        // or some other action if it is 0.
        return new AddProductPayload(product);
      }
    } 
    
  3. Program.cs 中,添加对 AddMutationType<T> 方法的调用以注册你的 Mutation 类,如下面的代码所示:

    builder.Services
      .AddGraphQLServer()
      .AddFiltering()
      .AddSorting()
      .RegisterDbContext<NorthwindContext>()
      .AddQueryType<Query>()
     **.AddMutationType<Mutation>();** 
    

探索添加产品突变

现在,我们可以使用 Banana Cake Pop 探索突变:

  1. 使用 https 配置文件启动 Northwind.GraphQL.Service 项目,不进行调试。

  2. Banana Cake Pop 中,点击 + 打开一个新的标签页。

  3. 点击 模式定义 选项卡,并注意突变类型,如图 12.11 部分所示:

图 12.11:使用 GraphQL 突变修改产品模式

  1. 注意 addProduct 突变及其相关类型的完整模式定义,如下面的代码所示:

    type Mutation {
      addProduct(input: AddProductInput!): AddProductPayload!
    }
    type Product {
      productId: Int!
      productName: String!
      supplierId: Int
      categoryId: Int
      quantityPerUnit: String
      unitPrice: Decimal
      unitsInStock: Short
      unitsOnOrder: Short
      reorderLevel: Short
      discontinued: Boolean!
      category: Category
      supplier: Supplier
      orderDetails: [OrderDetail!]!
    }
    ...
    type AddProductPayload {
      product: Product!
    }
    input AddProductInput {
      productName: String!
      supplierId: Int
      categoryId: Int
      quantityPerUnit: String!
      unitPrice: Decimal
      unitsInStock: Short
      unitsOnOrder: Short
      reorderLevel: Short
      discontinued: Boolean!
    } 
    
  2. 点击 操作 选项卡,如果需要,创建一个新的空白文档,然后输入一个突变来添加一个名为 Tasty Burgers 的新产品。然后,从返回的 product 对象中,只需选择 ID 和名称,如下面的代码所示:

    mutation AddProduct {
      addProduct(
        input: {
          productName: "Tasty Burgers"
          supplierId: 1
          categoryId: 2
          quantityPerUnit: "6 per box"
          unitPrice: 40
          unitsInStock: 0
          unitsOnOrder: 0
          reorderLevel: 0
          discontinued: false
        }
      )
      {
        product {
          productId
          productName
        }
      }
    } 
    
  3. 点击 运行,注意新产品已成功添加,并由 SQL Server 数据库分配了下一个连续编号,这可能是任何大于 77 的数字,具体取决于你是否已经添加了一些其他产品,如下面的输出和 图 12.12 所示:

    {
      "data": {
        "addProduct": {
          "product": {
            "productId": 79,
            "productName": "Tasty Burgers",
          }
        }
      }
    } 
    

    警告! 请注意分配给新添加产品的 ID。在下一节中,你将更新此产品然后删除它。你不能删除任何 ID 在 1 到 77 之间的现有产品,因为它们与其他表相关联,这样做会引发引用完整性异常!

图 12.12:使用 GraphQL 突变添加新产品

  1. 关闭浏览器,并关闭 web 服务器。

将更新和删除作为突变实现

接下来,我们将定义突变来更新产品的单价,所有“单位”字段,以及删除一个产品:

  1. Mutation.cs中,定义三个record类型来表示执行两个updateProduct和一个deleteProduct突变所需的输入,如下所示代码:

    public record UpdateProductPriceInput(
      int? ProductId,
      decimal? UnitPrice);
    public record UpdateProductUnitsInput(
      int? ProductId,
      short? UnitsInStock,
      short? UnitsOnOrder,
      short? ReorderLevel);
    public record DeleteProductInput(
      int? ProductId); 
    
  2. Mutation.cs中,定义两个类类型来表示从updatedelete突变返回结果所需的类型,包括操作是否成功,如下所示代码:

    public class UpdateProductPayload
    {
      public UpdateProductPayload(Product? product, bool updated)
      {
        Product = product;
        Success = updated;
      }
      public Product? Product { get; }
      public bool Success { get; }
    }
    public class DeleteProductPayload
    {
      public DeleteProductPayload(bool deleted)
      {
        Success = deleted;
      }
      public bool Success { get; }
    } 
    
  3. Mutation.cs文件中,在Mutation类中,定义三个方法来实现两个updateProduct和一个deleteProduct突变,如下所示代码:

    public async Task<UpdateProductPayload> UpdateProductPriceAsync(
      UpdateProductPriceInput input, NorthwindContext db)
    {
      Product? product = await db.Products.FindAsync(input.ProductId);
      int affectedRows = 0;
      if (product is not null)
      {
        product.UnitPrice = input.UnitPrice;
        affectedRows = await db.SaveChangesAsync();
      }
      return new UpdateProductPayload(product, 
        updated: affectedRows == 1);
    }
    public async Task<UpdateProductPayload> UpdateProductUnitsAsync(
      UpdateProductUnitsInput input, NorthwindContext db)
    {
      Product? product = await db.Products.FindAsync(input.ProductId);
      int affectedRows = 0;
      if (product is not null)
      {
        product.UnitsInStock = input.UnitsInStock;
        product.UnitsOnOrder = input.UnitsOnOrder;
        product.ReorderLevel = input.ReorderLevel;
        affectedRows = await db.SaveChangesAsync();
      }
      return new UpdateProductPayload(product,
        updated: affectedRows == 1);
    }
    public async Task<DeleteProductPayload> DeleteProductAsync(
      DeleteProductInput input, NorthwindContext db)
    {
      Product? product = await db.Products.FindAsync(input.ProductId);
      int affectedRows = 0;
      if (product is not null)
      {
        db.Products.Remove(product);
        affectedRows = await db.SaveChangesAsync();
      }
      return new DeleteProductPayload(
        deleted: affectedRows == 1);
    } 
    
  4. 如果你的数据库服务器没有运行,例如,因为你正在 Docker、虚拟机或云中托管它,那么请确保启动它。

  5. 启动Northwind.GraphQL.Service项目,使用https配置文件而不进行调试。

  6. 香蕉蛋糕棒中,点击+来打开一个新标签页。

  7. 编写一个命名查询来请求你添加的产品,例如,使用productId大于77,如下所示标记:

    query NewProducts {
      products(where: { productId: { gt: 77 } }) {
        productId
        productName
        unitPrice
        unitsInStock
        unitsOnOrder
        reorderLevel
      }
    } 
    
  8. 点击运行,并注意响应包括你之前添加的新产品,单价为40,如下所示输出:

    {
      "data": {
        "products": [
          {
            "productId": 79,
            "productName": "Tasty Burgers",
            "unitPrice": 40,
            "unitsInStock": 0,
            "unitsOnOrder": 0,
            "reorderLevel": 0
          }
        ]
      }
    } 
    
  9. 记录你添加的产品的productId。在我的情况下,它是79

  10. 香蕉蛋糕棒中,点击+来打开一个新标签页。

  11. 输入一个突变来更新你新产品的单价为75,然后从返回的product对象中,仅选择 ID、名称、单价和库存单位,如下所示代码:

    mutation UpdateProductPrice {
      updateProductPrice(
        input: {
          productId: 79
          unitPrice: 75
        }
      ) 
      {
        product {
          productId
          productName
          unitPrice
          unitsInStock
        }
      }
    } 
    
  12. 点击运行,并注意响应,如下所示输出:

    {
      "data": {
        "updateProductPrice": {
          "product": {
            "productId": 79,
            "productName": "Tasty Burgers",
            "unitPrice": 75,
            "unitsInStock": 0
          }
        }
      }
    } 
    
  13. 点击+来打开一个新标签页,输入一个突变来更新现有产品的单位,然后从返回的product对象中仅选择 ID、名称、单价和库存单位,如下所示代码:

    mutation UpdateProductUnits {
      updateProductUnits(
        input: {
          productId: 79
          unitsInStock: 20
          unitsOnOrder: 0
          reorderLevel: 10
        }
      ) 
      {
        success
      }
    } 
    
  14. 点击运行,并注意响应,如下所示输出:

    {
      "data": {
        "updateProductUnits": {
          "success": true
        }
      }
    } 
    
  15. 在查询标签页中请求新产品,点击运行,并注意响应,如下所示输出:

    {
      "data": {
        "products": [
          {
            "productId": 79,
            "productName": "Tasty Burgers",
            "unitPrice": 75,
            "unitsInStock": 20,
            "unitsOnOrder": 0,
            "reorderLevel": 10
          }
        ]
      }
    } 
    
  16. 点击+来打开一个新标签页,并输入一个突变来删除产品并显示是否成功,如下所示代码:

    mutation DeleteProduct {
      deleteProduct(
        input: {
          productId: 79
        }
      ) 
      {
        success
      }
    } 
    

    警告!你将无法删除在其他表中引用的产品。ID 1 到 77 将抛出引用完整性异常。

  17. 点击运行,并注意响应,如下所示输出:

    {
      "data": {
        "deleteProduct": {
          "success": true
        }
      }
    } 
    
  18. 确认产品已被删除,可以通过重新运行查询新产品的查询来验证,并注意你将得到一个空数组,如下所示输出:

    {
      "data": {
        "products": []
      }
    } 
    
  19. 关闭浏览器,并关闭 Web 服务器。

实现 GraphQL 订阅

GraphQL 订阅默认通过 WebSockets 工作,但也可以通过服务器发送事件SSE)、SignalR 或甚至 gRPC 工作。

想象一下,一个客户端应用程序希望在产品单价降低时收到通知。如果客户端能够订阅一个在单价降低时被触发的事件,而不是必须查询单价的变化,那就太好了。

向 GraphQL 服务添加订阅和主题

让我们将此功能添加到我们的 GraphQL 服务中,使用订阅:

  1. 添加一个名为 ProductDiscount.cs 的新类文件。

  2. 修改内容以定义一个模型,通知客户端关于产品单价降低的信息,如下面的代码所示:

    namespace Northwind.GraphQL.Service;
    public class ProductDiscount
    {
      public int? ProductId { get; set; }
      public decimal? OriginalUnitPrice { get; set; }
      public decimal? NewUnitPrice { get; set; }
    } 
    
  3. 添加一个名为 Subscription.cs 的新类文件。

  4. 修改内容以定义一个名为 OnProductDiscounted 的订阅事件(也称为主题),客户端可以订阅,如下面的代码所示:

    namespace Northwind.GraphQL.Service;
    public class Subscription
    {
      [Subscribe]
      [Topic]
      public ProductDiscount OnProductDiscounted(
        [EventMessage] ProductDiscount productDiscount)
          => productDiscount;
    } 
    
  5. Mutation.csUpdateProductPriceAsync 方法中,添加语句,在产品单价降低时通过主题发送消息,如下面的代码所示:

    public async Task<UpdateProductPayload> UpdateProductPriceAsync(
      UpdateProductPriceInput input, NorthwindContext db**,**
     **ITopicEventSender eventSender**)
    {
      Product? product = await db.Products.FindAsync(input.ProductId);
      int affectedRows = 0;
      if (product is not null)
      {
    **if** **(input.UnitPrice < product.UnitPrice)**
     **{**
    **// If the product has been discounted,**
    **// send a message to subscribers.**
     **ProductDiscount productDiscount =** **new****()**
     **{**
     **ProductId = input.ProductId,**
     **OriginalUnitPrice = product.UnitPrice,**
     **NewUnitPrice = input.UnitPrice**
     **};**
    **await** **eventSender.SendAsync(topicName:**
    **nameof****(Subscription.OnProductDiscounted),**
     **message: productDiscount);**
     **}**
        product.UnitPrice = input.UnitPrice;
        affectedRows = await db.SaveChangesAsync();
      }
      return new UpdateProductPayload(product,
        updated: affectedRows == 1);
    } 
    
  6. Program.cs 中,配置 GraphQL 服务以注册 Subscription 类,并将活动订阅存储在内存中,如下面的代码所示:

    builder.Services
      .AddGraphQLServer()
      .AddFiltering()
      .AddSorting()
     **.AddSubscriptionType<Subscription>()**
     **.AddInMemorySubscriptions()**
      .RegisterDbContext<NorthwindContext>()
      .AddQueryType<Query>()
      .AddMutationType<Mutation>(); 
    

    除了内存中,您还可以使用 Redis 和其他数据存储来跟踪活动订阅。

  7. 可选地,在构建app后,配置 WebSocket 的使用,如下面的代码所示:

    app.UseWebSockets(); // For subscriptions. 
    

    这是个可选步骤,因为 GraphQL 服务可以回退到使用 https 上的 SSE。

探索订阅主题

让我们订阅一个主题并查看结果:

  1. 使用https配置文件启动Northwind.GraphQL.Service项目,不进行调试。

  2. 香蕉蛋糕棒中,点击+以打开新标签页。

  3. 点击模式参考标签页,点击订阅类型,并注意名为onProductDiscounted的主题,如图12.13所示:

图 12.13:带有主题的订阅

  1. 点击操作,输入对主题的订阅,并选择要在结果中显示的所有字段,如下面的代码所示:

    subscription {
      onProductDiscounted {
        productId
        originalUnitPrice
        newUnitPrice
      }
    } 
    
  2. 点击运行,并注意订阅开始但尚未显示任何结果。

  3. 点击+以打开新标签页,并输入一个更新现有产品 1 的单价为 8.99 的变异。然后从返回的 product 对象中选择 ID 和单价,并显示更新是否成功,如下面的代码所示:

    mutation UpdateProductPrice {
      updateProductPrice(
        input: {
          productId: 1
          unitPrice: 8.99
        }
      ) 
      {
        product {
          productId
          unitPrice
        }
        success
      }
    } 
    
  4. 点击运行,并注意以下输出中的响应:

    {
      "data": {
        "updateProductPrice": {
          "product": {
            "productId": 1,
            "unitPrice": 8.99
          },
          "success": true
        }
      }
    } 
    
  5. 切换回包含订阅的标签页,并注意响应以及订阅仍然处于活动状态,这可以通过标签页上的旋转器和取消按钮来指示,如图12.14所示:

图 12.14:活动订阅显示旋转器

  1. 切换回包含更新变异的标签页,将单价更改为 7.99,然后点击运行。然后切换回包含订阅的标签页,并注意它也收到了该更新通知。

  2. 切换回更新变体的标签页,将单价更改为 9.99,然后点击 运行。然后,切换回订阅的标签页,并注意它没有收到任何通知,因为单价是增加的,而不是减少的。

  3. 关闭浏览器,并关闭 web 服务器。

练习和探索

通过回答一些问题、进行一些实际操作练习,以及通过深入研究本章主题来测试你的知识和理解。

练习 12.1 – 测试你的知识

回答以下问题:

  1. GraphQL 服务使用什么传输协议?

  2. GraphQL 使用什么媒体类型进行其查询?

  3. 你如何参数化 GraphQL 查询?

  4. 使用 Strawberry Shake 而不是常规 HTTP 客户端进行 GraphQL 查询有哪些好处?

  5. 你如何将新产品插入 Northwind 数据库?

练习 12.2 – 探索主题

使用下一页上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-12---combining-data-sources-using-graphql

练习 12.3 – 练习构建 .NET 客户端

HomeController.cs 中,添加一个名为 Categories 的操作方法,并实现它以查询 categories 字段,其中包含一个用于 id 的变量。在页面上,允许访问者提交 id,并注意类别信息和其产品列表。

摘要

在本章中,你学习了以下内容:

  • 一些 GraphQL 的概念。

  • 如何构建一个 Query 类,其中包含表示可查询实体的字段。

  • 如何使用 Banana Cake Pop 工具探索 GraphQL 服务架构。

  • 如何使用 REST 客户端扩展向 GraphQL 服务 POST 数据。

  • 如何为 GraphQL 服务创建 .NET 客户端。

  • 如何实现 GraphQL 变更。

  • 如何实现 GraphQL 订阅。

在下一章中,你将了解可以用来实现高效微服务的 gRPC 服务技术。

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新书发布——请扫描下面的二维码:

packt.link/apps_and_services_dotnet8

二维码

第十三章:使用 gRPC 构建高效的微服务

在本章中,您将了解 gRPC,它使开发者能够构建可以在大多数平台上高效通信的服务。

然而,网络浏览器并不完全支持对 HTTP/2 所有功能的程序性访问,而 gRPC 需要这些功能。这使得 gRPC 在实现中间层到层服务和微服务时最为有用,因为它们必须在多个微服务之间进行大量通信以完成完整任务。提高这种通信的效率对于微服务的可扩展性和性能的成功至关重要。

模块化单体、两层、客户端到服务风格的服务天生更有效率,因为模块之间的通信是在进程内进行的,整个服务与客户端之间只有一层网络通信。

微服务架构有更多层级,因此许多微服务之间有更多的网络通信层。在这些层之间拥有高度高效的通信变得尤为重要,gRPC 就是为了实现网络通信的超高效而设计的,如图 13.1所示:

图 13.1:比较两层模块化单体服务和多层微服务

良好实践:在过去的十年左右,人们普遍认为微服务对所有场景都是最佳选择,因此对于一个新的系统,最好是立即使用酷炫的微服务而不是作为传统的单体来实施。最近,人们开始对这个假设进行反驳。行业似乎已经达成共识,建议首先将系统作为一个模块化单体来实施。只有在必要时,才应该将模块拆分成实际的微服务。由于微服务之间额外的网络通信导致其本质上较慢,您还需要考虑额外的微服务部署和编排的协调和复杂性是否值得。

本章将涵盖以下主题:

  • 理解 gRPC

  • 构建 gRPC 服务和客户端

  • 为 EF Core 模型实现 gRPC

  • 将 gRPC 进一步发展

  • 处理日期、时间和十进制数

  • 实现拦截器和处理故障

  • 实现 gRPC JSON 转换

理解 gRPC

gRPC 是一个现代、开源、高性能的远程过程调用(RPC)框架,可以在任何环境中运行。RPC 是指一台计算机通过网络调用另一台计算机上的过程或服务,就像调用本地过程一样。它是一个客户端-服务器架构的例子。

您可以在以下链接中了解更多关于 RPC 的信息:en.wikipedia.org/wiki/Remote_procedure_call

gRPC 的工作原理

gRPC 服务开发者为可以远程调用的方法定义一个服务接口,包括定义方法参数和返回类型。服务实现此接口并运行 gRPC 服务器以处理客户端调用。

在客户端,强类型 gRPC 客户端提供与服务器上相同的方法。

使用 .proto 文件定义 gRPC 合约

gRPC 使用以合约优先的 API 开发,支持语言无关的实现。在这种情况下,合约是一份协议,表示一个服务将公开一系列具有指定参数和返回类型的方法,以实现预定的行为。希望调用服务的客户端可以确信服务将随着时间的推移继续遵守合约。例如,尽管可能会添加新方法,但现有方法永远不会更改或删除。

使用 .proto 文件编写合约,这些文件有自己的语言语法,然后使用工具将它们转换为各种语言,如 C#。服务器和客户端都使用 .proto 文件以正确的格式交换消息。

这里是一个使用 proto3 语法定义消息请求的 .proto 文件示例,该请求使用自定义 enum

// Setting the syntax must be first non-comment line.
syntax = "proto3"; // proto2 is the default.
/* When this .proto file is used in a .NET project, it will use the
   following C# namespace for the auto-generated code files. */
option csharp_namespace = "Northwind.Grpc.Service";
enum SearchType {
  SEARCHTYPE_UNSPECIFIED = 0;
  SEARCHTYPE_STARTSWITH = 1;
  SEARCHTYPE_CONTAINS = 2;
  SEARCHTYPE_ENDSWITH = 3;
}
message SearchRequest {
  string query = 1; // Fields must have order numbers.
  SearchType search_type = 2;
  int32 page = 3;
  int32 page_size = 4;
}
message SearchResponse {
  /* Message types can be nested and/or repeated to create the 
     equivalent of collections or arrays. */
  repeated SearchResult results = 1;
}
message SearchResult {
  string url = 1;
  string title = 2;
  repeated string authors = 3;
}
service Searcher {
  rpc PerformSearch (SearchRequest) returns (SearchResponse);
} 

更多信息:Protobuf 风格指南建议使用全部小写并带有下划线的字段名称,全部大写并带有下划线的 enum 值等。C# 工具将自动将自动为您创建的自动生成的类型转换为 .NET 风格。您可以在以下链接中阅读更多建议:protobuf.dev/programming-guides/style/

字段必须赋予一个介于 1 和 536,870,911 之间的唯一编号。您不能使用 19,000 到 19,999 的范围,因为这些是为 Protocol Buffers 实现保留的。这些数字在序列化期间代替字段名称使用,以在二进制格式中节省空间。

良好实践:一旦开始使用消息,字段编号就不能更改,因为它们与 gRPC 使用的非常高效的线格式紧密绑定。更改字段编号相当于删除并创建一个新的字段。您也不应重复使用字段编号。您可以在以下链接中了解误用字段编号的后果:protobuf.dev/programming-guides/proto3/#consequences

字段数据类型不能为空,因此所有数字类型默认为零 (0)。数字和其他字段数据类型在 表 13.1 中显示:

类型 描述
string 文本值。默认为空字符串。
bool 布尔值。默认为 false
int32, int64 可变长度编码的 32 位和 64 位整数值。尽管它们可以用于负值,但使用 sint32sint64 更有效。C# 中 intlong 的等效值。
sint32sint64uint32uint64 可变长度编码的 32 位和 64 位有符号和无符号整数值。C# 中 intlong 的等效物,以及 uintulong
fixed32fixed64sfixed32sfixed64 32 位始终为 4 个字节,64 位始终为 8 个字节。C# 中 uintulong 以及 intlong 的等效物。
floatdouble 浮点实数。
bytes 最大 2³² 字节(4,294,967,296)。使用 ByteString.CopyFrom(byte[] data) 创建一个新实例。使用 ToByteArray() 获取字节数组。默认为空的 ByteString 值。

表 13.1:Protobuf 中的数字和其他字段数据类型

更多信息:官方指南可在以下链接找到:protobuf.dev/programming-guides/proto3/

gRPC 优点

gRPC 通过使用不适用于人类阅读的二进制序列化 Protobuf 来最小化网络使用,与用于 Web 服务的 JSON 或 XML 不同。

gRPC 需要 HTTP/2,这比早期版本(如二进制帧和压缩,以及 HTTP/2 调用的多路复用)提供了显著的性能优势。

二进制帧表示客户端和服务器之间如何传输 HTTP 消息。HTTP/1.x 使用换行符分隔的纯文本。HTTP/2 将通信分割成更小的消息(帧),并以二进制格式编码。多路复用意味着将来自不同来源的多个消息组合成一个消息,以更有效地使用共享资源,如网络传输。

更多信息:如果您想了解更多关于 HTTP/2 以及它是如何使 gRPC 更高效的信息,您可以在以下链接中阅读:grpc.io/blog/grpc-on-http2/

gRPC 限制

gRPC 的主要限制是它不能在 Web 浏览器中使用,因为没有浏览器提供支持 gRPC 客户端所需级别的控制。例如,浏览器不允许调用者要求使用 HTTP/2。

对于开发人员来说,另一个限制是由于消息的二进制格式,诊断和监控问题更困难。许多工具不理解该格式,无法以人类可读的格式显示消息。

有一个名为 gRPC-Web 的倡议,它添加了一个额外的代理层,代理将请求转发到 gRPC 服务器。然而,由于列出的限制,它只支持 gRPC 的一个子集。

gRPC 方法的类型

gRPC 有四种方法类型。

第一种方法是最常见的:

  • 单一 方法具有结构化的请求和响应消息。单一方法在返回响应消息时完成。在不要求流的所有场景中应选择单一方法。

当必须交换大量数据时,使用流式方法,它们通过使用字节流来这样做。它们具有 stream 关键字前缀,可以是输入参数、输出参数或两者。

三个流式方法如下:

  • 服务器流式方法从客户端接收请求消息并返回一个流。可以通过流返回多个消息。服务器流式调用在服务器端方法返回时结束,但服务器端方法可能运行直到从客户端收到取消令牌。

  • 客户端流式方法仅从客户端接收流,不包含任何消息。服务器端方法处理流,直到准备好返回响应消息。一旦服务器端方法返回消息,客户端流式调用完成。

  • 双向流式方法仅从客户端接收流,不包含任何消息,并且仅通过第二个流返回数据。调用在服务器端方法返回时完成。一旦调用双向流式方法,客户端和服务可以在任何时间互相发送消息。

在本书中,我们将仅查看一元方法的细节。如果您希望下一版涵盖流式方法,请告知我。

微软的 gRPC 包

微软投资构建了一套用于 .NET 与 gRPC 一起工作的包,自 2021 年 5 月以来,它是微软推荐的 .NET gRPC 实现。

微软的 .NET gRPC 包含:

  • Grpc.AspNetCore 用于在 ASP.NET Core 中托管 gRPC 服务。

  • Grpc.Net.Client 通过在 HttpClient 上构建为任何 .NET 项目添加 gRPC 客户端支持。

  • Grpc.Net.ClientFactory 通过在 HttpClientFactory 上构建为任何 .NET 代码库添加 gRPC 客户端支持。

您可以在以下链接了解更多信息:github.com/grpc/grpc-dotnet

构建一个 gRPC 服务和客户端

让我们看看一个示例服务客户端,用于发送和接收简单消息。

构建一个 Hello World gRPC 服务

我们将首先使用提供的标准项目模板之一构建 gRPC 服务:

  1. 使用您首选的代码编辑器创建一个新项目,如下列所示:

    • 项目模板:ASP.NET Core gRPC 服务 / grpc

    • 解决方案文件和文件夹:Chapter13

    • 项目文件和文件夹:Northwind.Grpc.Service

    • 启用 Docker:已清除。

    • 不要使用顶层语句:已清除。

    • 启用原生 AOT 发布:已选择。

    良好实践:请确保选择启用原生 AOT 发布。从 .NET 8 及以后版本开始,gRPC 项目可以针对原生平台提前编译AOT),这提供了改进的性能和缩短的启动时间,这对于频繁重新部署和扩展时上下文切换的微服务来说非常重要。

  2. Protos 文件夹中的 greet.proto 中,请注意它定义了一个名为 Greeter 的服务,以及一个名为 SayHello 的方法,该方法交换名为 HelloRequestHelloReply 的消息,如下面的代码所示:

    syntax = "proto3";
    option csharp_namespace = "Northwind.Grpc.Service";
    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;
    } 
    

    在 Visual Studio Code 中处理.proto文件时,你可以安装扩展vscode-proto3zxh404.vscode-proto3)。对于 Rider,你可以从 JetBrains 安装 Protocol Buffers 插件,如下链接所示:plugins.jetbrains.com/plugin/14004-protocol-buffers

  3. Northwind.Grpc.Service.csproj中,注意这个项目启用了原生 AOT 发布,.proto文件已注册用于服务器端使用,并且包含了实现托管在 ASP.NET Core 中的 gRPC 服务的包引用,如图中高亮显示的标记所示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <InvariantGlobalization>true</InvariantGlobalization>
     **<PublishAot>****true****</PublishAot>**
      </PropertyGroup>
      <ItemGroup>
     **<Protobuf Include=****"Protos\greet.proto"** **GrpcServices=****"Server"** **/>**
      </ItemGroup>
      <ItemGroup>
     **<PackageReference Include=****"Grpc.AspNetCore"** **Version=****"2.59.0"** **/>**
      </ItemGroup>
    </Project> 
    

    对于 JetBrains Rider,如果缺少,请手动添加<PublishAot>true</PublishAot>

  4. 将不变的全局化设置为false,如下所示:

    <InvariantGlobalization>false</InvariantGlobalization> 
    
  5. Services文件夹中的GreeterService.cs中,注意它继承自一个名为GreeterBase的类,并且它通过一个接受HelloRequest输入参数并返回HelloReplySayHello方法异步实现Greeter服务合同,如下所示:

    using Grpc.Core;
    using Northwind.Grpc.Service
    namespace Northwind.Grpc.Service.Services
    {
      public class GreeterService : Greeter.GreeterBase
      {
        private readonly ILogger<GreeterService> _logger;
        public GreeterService(ILogger<GreeterService> logger)
        {
          _logger = logger;
        }
        public override Task<HelloReply> SayHello(
          HelloRequest request, ServerCallContext context)
        {
          return Task.FromResult(new HelloReply
          {
            Message = "Hello " + request.Name
          });
        }
      }
    } 
    
  6. 如果你使用的是 Visual Studio 2022,在解决方案资源管理器中,点击显示所有文件。如果你使用的是 JetBrains Rider,那么将鼠标悬停在解决方案面板上,并点击眼睛图标。

  7. obj\Debug\net8.0\Protos文件夹中,注意从greet.proto文件自动生成的两个名为Greet.csGreetGrpc.cs的类文件,如图 13.2 所示:

图片

图 13.2:从.proto 文件自动生成的 gRPC 服务类文件

  1. GreetGrpc.cs中,注意Greeter.GreeterBase类,这是GreeterService类继承的。你不需要了解这个基类的实现细节,但你应该知道它是处理 gRPC 高效通信所有细节的部分。

  2. 如果你使用的是 Visual Studio 2022,在解决方案资源管理器中,展开依赖项,展开,展开Grpc.AspNetCore,并注意它依赖于 Google 的Google.Protobuf包,以及 Microsoft 的Grpc.AspNetCore.Server.ClientFactoryGrpc.Tools包,如图 13.3 所示:图片

    图 13.3:Grpc.AspNetCore 包引用了 Grpc.Tools 和 Google.Protobuf 包

    Grpc.Tools包从注册的.proto文件生成 C#类文件,这些类文件使用 Google 包中定义的类型来实现对 Protobuf 序列化格式的序列化。Grpc.AspNetCore.Server.ClientFactory包在一个.NET 项目中包含了 gRPC 的服务端和客户端支持。

  3. Program.cs中,在配置服务的部分,注意调用将 gRPC 添加到Services集合,如下所示:

    builder.Services.AddGrpc(); 
    
  4. Program.cs中,在配置 HTTP 管道的部分,注意调用映射Greeter服务,如下所示:

    app.MapGrpcService<GreeterService>(); 
    
  5. Properties 文件夹中,打开 launchSettings.json 并修改 applicationUrl 设置,以使用端口 5131 进行 https 连接和端口 5132 进行 http 连接,如下所示(高亮显示):

    {
      "$schema": "http://json.schemastore.org/launchsettings.json",
      "profiles": {
        "http": {
          "commandName": "Project",
          "dotnetRunMessages": true,
          "launchBrowser": false,
    **"****applicationUrl"****:****"http://localhost:5132"****,**
          "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
          }
        },
        "https": {
          "commandName": "Project",
          "dotnetRunMessages": true,
          "launchBrowser": false,
    **"applicationUrl"****:****"https://localhost:5131;http://localhost:5132"****,**
          "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
          }
        }
      }
    } 
    
  6. 构建 Northwind.Grpc.Service 项目。

项目文件项目配置

在我们继续之前,让我们快速回顾一下常见的项目文件项目配置语法。

Visual Studio 2022 生成的项目配置通常使用属性来表示项目属性,如下所示:

<Protobuf Include="Protos\greet.proto" GrpcServices="Client" /> 

未显式设置的属性将使用其默认值。

由其他工具如 JetBrains Rider 生成的项目配置通常使用子元素来表示项目属性,如下所示:

<Protobuf>
  <Include>Protos\greet.proto</Include>
  <GrpcServices>Client</>
  <Access>Public</Access>
  <ProtoCompile>True</ProtoCompile>
  <CompileOutputs>True</CompileOutputs>
  <OutputDir>obj\Debug\net8.0\</OutputDir>
  <Generator>MSBuild:Compile</Generator>
<Protobuf> 

它们通常都能达到相同的目的。第一个更简洁,推荐使用。

构建 Hello World gRPC 客户端

我们将添加一个 ASP.NET Core MVC 网站项目,然后添加 gRPC 客户端包以使其能够调用 gRPC 服务:

  1. 使用您首选的代码编辑器添加一个新项目,如下列表所示:

    • 项目模板:ASP.NET Core Web App (Model-View-Controller) / mvc

    • 解决方案文件和文件夹:Chapter13

    • 项目文件和文件夹:Northwind.Grpc.Client.Mvc

    • 认证类型:无。

    • 配置为 HTTPS:已选中。

    • 启用 Docker:已清除。

    • 不要使用顶级语句:已清除。

  2. Northwind.Grpc.Client.Mvc 项目中,将警告视为错误,添加 Microsoft 的 gRPC 客户端工厂和工具以及 Google 的 .NET Protocol Buffers 库的包引用,如下所示:

    <ItemGroup>
      <PackageReference Include="Google.Protobuf" Version="3.24.4" />
      <PackageReference Include="Grpc.Net.ClientFactory" Version="2.57.0" />
      <PackageReference Include="Grpc.Tools" Version="2.58.0">
        <PrivateAssets>all</PrivateAssets>
        <IncludeAssets>runtime; build; native; contentfiles; 
          analyzers; buildtransitive</IncludeAssets>
      </PackageReference>
    </ItemGroup> 
    

    良好实践Grpc.Net.ClientFactory 包引用实现 .NET 项目中 gRPC 客户端支持的 Grpc.Net.Client 包,但它不引用其他包如 Grpc.ToolsGoogle.Protobuf。我们必须显式引用这些包。Grpc.Tools 包仅在开发期间使用,因此被标记为 PrivateAssets=all 以确保工具不会与生产网站一起发布。

  3. Properties 文件夹中,打开 launchSettings.json,并为 https 配置文件修改 applicationUrl 设置,以使用端口 5133 进行 https 连接和端口 5134 进行 http 连接,如下所示(部分高亮显示):

    "profiles": {
      ...
    **"https"****:****{**
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
    **"applicationUrl"****:****"https://localhost:5133;http://localhost:5134"****,**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        } 
    
  4. Protos 文件夹从 Northwind.Grpc.Service 项目/文件夹复制到 Northwind.Grpc.Client.Mvc 项目/文件夹。

    在 Visual Studio 2022 中,您可以拖放进行复制。在 Visual Studio Code 或 JetBrains Rider 中,按住 CtrlCmd 键进行拖放。

  5. Northwind.Grpc.Client.Mvc 项目中,在 Protos 文件夹中,在 greet.proto 中,修改命名空间以匹配当前项目的命名空间,以便自动生成的类将在同一命名空间中,如下所示:

    option csharp_namespace = "Northwind.Grpc.Client.Mvc"; 
    
  6. Northwind.Grpc.Client.Mvc 项目文件中,添加或修改注册 .proto 文件的项组,以指示它在客户端侧使用,如下所示高亮显示的标记:

    <ItemGroup>
      <Protobuf Include="Protos\greet.proto" GrpcServices="**Client**" />
    </ItemGroup> 
    

    Visual Studio 2022 将为您创建项目组,但默认将 GrpcServices 设置为 Server,因此您必须手动将其更改为 Client。对于其他代码编辑器,您可能需要手动创建整个 <ItemGroup>。JetBrains Rider 有更多配置,但您可以忽略它。

  7. 构建 Northwind.Grpc.Client.Mvc 项目以确保创建自动生成的类。

  8. Northwind.Grpc.Client.Mvc 项目中,在 obj\Debug\net8.0\Protos 文件夹中的 GreetGrpc.cs 文件中,注意 Greeter.GreeterClient 类,如下部分代码所示:

    public static partial class Greeter
    { 
      ...
      public partial class GreeterClient : grpc::ClientBase<GreeterClient>
      { 
    
  9. Program.cs 中,导入 Greeter.GreeterClient 的命名空间,如下所示:

    using Northwind.Grpc.Client.Mvc; // To use Greeter.GreeterClient. 
    
  10. Program.cs 中,在配置服务的部分,编写一个语句将 GreeterClient 添加为名为 gRPC 客户端,该客户端将与监听端口 5131 的服务通信,如下所示:

    builder.Services.AddGrpcClient<Greeter.GreeterClient>("Greeter",
      options =>
      {
        options.Address = new Uri("https://localhost:5131");
      }); 
    
  11. Models 文件夹中,添加一个名为 HomeIndexViewModel.cs 的新类。

  12. HomeIndexViewModel.cs 中,定义一个类来存储问候语和错误消息,如下所示:

    namespace Northwind.Grpc.Client.Mvc.Models;
    public class HomeIndexViewModel
    {
      public string? Greeting { get; set; }  
      public string? ErrorMessage { get; set; }
    } 
    
  13. Controllers 文件夹中的 HomeController.cs 文件中,导入用于与 gRPC 客户端工厂一起工作的命名空间,如下所示:

    using Grpc.Net.ClientFactory; // To use GrpcClientFactory. 
    
  14. Controller 类中,声明一个用于存储 Greeter Client 实例的字段,并在构造函数中使用客户端工厂设置它,如下所示高亮显示的代码:

    public class HomeController : Controller
    {
      private readonly ILogger<HomeController> _logger;
    **private****readonly** **Greeter.GreeterClient _greeterClient;**
      public HomeController(ILogger<HomeController> logger**,**
     **GrpcClientFactory factory**)
      {
        _logger = logger;
        _**greeterClient = factory.CreateClient<Greeter.GreeterClient>(****"Greeter"****);**
      } 
    
  15. Index 动作方法中,使方法异步,添加一个名为 namestring 参数,默认值为 Henrietta,然后添加语句使用 gRPC 客户端调用 SayHelloAsync 方法,传递一个 HelloRequest 对象,并将 HelloReply 响应存储在 ViewData 中,同时捕获任何异常,如下所示高亮显示的代码:

    public **async** **Task<**IActionResult**>** Index(**string** **name =** **"Henrietta"**)
    {
     **HomeIndexViewModel model =** **new****();**
    **try**
     **{**
     **HelloReply reply =** **await** **_greeterClient.SayHelloAsync(**
    **new** **HelloRequest { Name = name });**
     **model.Greeting =** **"Greeting from gRPC service: "** **+ reply.Message;**
     **}**
    **catch** **(Exception ex)**
     **{**
     **_logger.LogWarning(****$"Northwind.Grpc.Service is not responding."****);**
     **model.ErrorMessage = ex.Message;**
     **}**
     return View(model);
    } 
    
  16. Views/Home 中的 Index.cshtml 文件中,在 欢迎 标题之后,删除现有的 <p> 元素,然后添加标记以渲染一个表单供访客输入他们的名字,然后如果他们提交并且 gRPC 服务响应,则输出问候语,如下所示高亮显示的标记:

    **@using Northwind.Grpc.Client.Mvc.Models**
    **@model HomeIndexViewModel**
    @{
      ViewData["Title"] = "Home Page";
    }
    <div class="text-center">
      <h1 class="display-4">Welcome</h1>
     **<div** **class****=****"alert alert-secondary"****>**
     **<form>**
     **<input name=****"name"** **placeholder=****"Enter your name"** **/>**
     **<input type=****"submit"** **/>**
     **</form>**
     **</div>**
     **@if (Model.Greeting** **is****not****null****)**
     **{**
     **<p** **class****=****"alert alert-primary"****>@Model.Greeting</p>**
     **}**
     **@if (Model.ErrorMessage** **is****not****null****)**
     **{**
     **<p** **class****=****"alert alert-danger"****>@Model.ErrorMessage</p>**
     **}**
    </div> 
    

    如果您清理 gRPC 项目,那么您将丢失自动生成的类型并看到编译错误。要重新创建它们,只需对 .proto 文件进行任何更改或关闭并重新打开项目/解决方案。

测试 gRPC 服务和客户端

现在我们可以启动 gRPC 服务并查看 MVC 网站是否可以成功调用它:

  1. 不带调试启动 Northwind.Grpc.Service 项目。

  2. 启动 Northwind.Grpc.Client.Mvc 项目。

  3. 如果需要,启动浏览器并导航到主页:https://localhost:5133/

  4. 注意主页上的问候语,如图 13.4 所示:

图片

图 13.4:调用 gRPC 服务获取问候后的主页

  1. 查看 ASP.NET Core MVC 项目的命令提示符或终端,注意指示 HTTP/2 POST在大约 41ms 内由greet.Greeter/SayHello端点处理的 info 消息,如下所示:

    info: System.Net.Http.HttpClient.Greeter.LogicalHandler[100]
          Start processing HTTP request POST https://localhost:5131/greet.Greeter/SayHello
    info: System.Net.Http.HttpClient.Greeter.ClientHandler[100]
          Sending HTTP request POST https://localhost:5131/greet.Greeter/SayHello
    info: System.Net.Http.HttpClient.Greeter.ClientHandler[101]
          Received HTTP response headers after 60.5352ms - 200
    info: System.Net.Http.HttpClient.Greeter.LogicalHandler[101]
          End processing HTTP request after 69.1623ms - 200 
    
  2. 在页面上输入并提交你自己的名字。

  3. 关闭浏览器并关闭 Web 服务器。

为 EF Core 模型实现 gRPC

现在我们将向 gRPC 项目中添加一个用于处理 Northwind 数据库的服务。

实现 gRPC 服务

我们将引用你在第三章中创建的 EF Core 模型,即使用 EF Core 为 SQL Server 构建实体模型,然后使用.proto文件定义 gRPC 服务的合约,并最终实现该服务。

我们将从简单的Shippers表开始,因为它包含的属性较少。每个承运商只有三个属性,一个int类型和一个两个string类型的值,表中只有三条记录。让我们开始吧:

  1. Northwind.Grpc.Service项目中,添加一个项目引用到 Northwind 数据库上下文项目,如下所示(高亮显示):

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    Include路径不能有换行符。

  2. 在命令提示符或终端中,构建Northwind.Grpc.Service项目,如下所示:dotnet build

  3. Northwind.Grpc.Service项目中,在Protos文件夹中,添加一个新文件(在 Visual Studio 2022 中,项目模板命名为Protocol Buffer File),命名为shipper.proto,如下所示:

    syntax = "proto3";
    option csharp_namespace = "Northwind.Grpc.Service";
    package shipper;
    service Shipper {
      rpc GetShipper (ShipperRequest) returns (ShipperReply);
    }
    message ShipperRequest {
      int32 shipper_id = 1;
    }
    message ShipperReply {
      int32 shipper_id = 1;
      string company_name = 2;
      string phone = 3;
    } 
    
  4. 打开项目文件,添加一个条目以包含shipper.proto文件,如下所示(高亮显示):

    <ItemGroup>
      <Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
     **<Protobuf Include=****"Protos\shipper.proto"** **GrpcServices=****"Server"** **/>**
    </ItemGroup> 
    
  5. 构建Northwind.Grpc.Service项目。

  6. Services文件夹中,添加一个名为ShipperService.cs的新类文件,并修改其内容以定义一个使用 Northwind 数据库上下文返回承运商的承运商服务,如下所示:

    using Grpc.Core; // To use ServerCallContext.
    using Northwind.EntityModels; // To use NorthwindContext.
    using ShipperEntity = Northwind.EntityModels.Shipper;
    namespace Northwind.Grpc.Service.Services;
    public class ShipperService : Shipper.ShipperBase
    {
      private readonly ILogger<ShipperService> _logger;
      private readonly NorthwindContext _db;
      public ShipperService(ILogger<ShipperService> logger,
        NorthwindContext context)
      {
        _logger = logger;
        _db = context;
      }
      public override async Task<ShipperReply?> GetShipper(
        ShipperRequest request, ServerCallContext context)
      {
        ShipperEntity? shipper = await _db.Shippers
          .FindAsync(request.ShipperId);
        return shipper is null ? null : ToShipperReply(shipper);
      }
      // A mapping method to convert from a Shipper in the
      // entity model to a gRPC ShipperReply.
      private ShipperReply ToShipperReply(ShipperEntity shipper)
      {
        return new ShipperReply
        {
          ShipperId = shipper.ShipperId,
          CompanyName = shipper.CompanyName,
          Phone = shipper.Phone
        };
      }
    } 
    

    .proto文件生成代表发送到和从 gRPC 服务发送的消息的类。因此,我们不能使用为 EF Core 模型定义的实体类。我们需要一个像ToShipperReply这样的辅助方法,可以将实体类的实例映射到.proto生成的类,如ShipperReply。这可能是使用 AutoMapper 的好用途,尽管在这种情况下,映射很简单,可以手动编码。

  7. Program.cs中,导入 Northwind 数据库上下文的命名空间,如下所示:

    using Northwind.EntityModels; // To use AddNorthwindContext method. 
    
  8. 在配置服务的部分,添加一个调用以注册 Northwind 数据库上下文,如下所示:

    builder.Services.AddNorthwindContext(); 
    
  9. 在配置 HTTP 管道的章节中,在调用注册GreeterService之后,添加一个语句以注册ShipperService,如下所示:

    app.MapGrpcService<ShipperService>(); 
    

实现 gRPC 客户端

现在我们可以向 Northwind MVC 网站添加客户端功能:

  1. shipper.proto文件从Northwind.Grpc.Service项目的Protos文件夹复制到Northwind.Grpc.Client.Mvc项目的Protos文件夹。

  2. Northwind.Grpc.Client.Mvc项目中,在shipper.proto文件中,修改命名空间以匹配当前项目的命名空间,以便自动生成的类将在同一命名空间中,如下所示,代码中高亮显示:

    option csharp_namespace = "Northwind.Grpc.**Client.Mvc**"; 
    
  3. Northwind.Grpc.Client.Mvc项目文件中,修改或添加条目以注册.proto文件作为客户端端使用,如下所示,代码中高亮显示:

    <ItemGroup>
      <Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
      <Protobuf Include="Protos\shipper.proto" GrpcServices="**Client**" />
    </ItemGroup> 
    

    如果你使用的是像 JetBrains Rider 这样的代码编辑器,它会添加额外的配置,我建议你简化前面的标记中的元素。如果不这样做,那么你可能会在接下来的编码任务中遇到错误。

  4. Northwind.Grpc.Client.Mvc项目文件中的Program.cs文件中,添加一个语句来注册ShipperClient类以连接到监听端口5131的 gRPC 服务,如下所示,代码中高亮显示:

    builder.Services.AddGrpcClient<Shipper.ShipperClient>("Shipper",
      options =>
      {
        options.Address = new Uri("https://localhost:5131");
      }); 
    
  5. Models文件夹中的HomeIndexViewModel.cs文件中,添加一个属性来存储发货的摘要,如下所示,代码中高亮显示:

    public string? ShipperSummary { get; set; } 
    
  6. Controllers文件夹中的HomeController.cs文件中,声明一个字段来存储一个发货客户端实例,并在构造函数中使用客户端工厂来设置它,如下所示,代码中高亮显示:

    public class HomeController : Controller
    {
      private readonly ILogger<HomeController> _logger;
      private readonly Greeter.GreeterClient _greeterClient;
    **private****readonly** **Shipper.ShipperClient _shipperClient;**
      public HomeController(ILogger<HomeController> logger,
        GrpcClientFactory factory)
      {
        _logger = logger;
        _greeterClient = factory.CreateClient<Greeter.GreeterClient>("Greeter");
        _**shipperClient = factory.CreateClient<Shipper.ShipperClient>(****"Shipper"****);**
      } 
    
  7. HomeController.cs文件中的Index动作方法中,添加一个名为id的参数,并添加调用Shipper gRPC 服务以获取匹配的ShipperId的语句,如下所示,代码中高亮显示:

    public async Task<IActionResult> Index(
      string name = "Henrietta"**,** **int** **id =** **1**)
    {
      HomeIndexViewModel model = new();
      try
      {
        HelloReply reply = await greeterClient.SayHelloAsync(
          new HelloRequest { Name = name });
        model.Greeting = "Greeting from gRPC service: " + reply.Message;
     **ShipperReply shipperReply =** **await** **_shipperClient.GetShipperAsync(**
    **new** **ShipperRequest { ShipperId = id });**
     **model.ShipperSummary =** **"Shipper from gRPC service: "** **+** 
    **$"ID:** **{shipperReply.ShipperId}****, Name:** **{shipperReply.CompanyName}****,"**
     **+** **$" Phone:** **{shipperReply.Phone}****."****;**
      }
      catch (Exception ex)
      {
        _logger.LogWarning($"Northwind.Grpc.Service is not responding.");
        model.ErrorMessage = ex.Message;
      }
      return View();
    } 
    
  8. Views/Home文件夹中的Index.cshtml文件中,添加代码以渲染一个表单供访客输入发货 ID,并在问候语之后渲染发货详情,如下所示,代码中高亮显示:

    @using Northwind.Grpc.Client.Mvc.Models
    @model HomeIndexViewModel
    @{
      ViewData["Title"] = "Home Page";
    }
    <div class="text-center">
      <h1 class="display-4">Welcome</h1>
      <div class="alert alert-secondary">
        <form>
          <input name="name" placeholder="Enter your name" />
          <input type="submit" />
        </form>
    **<****form****>**
    **<****input****name****=****"id"****placeholder****=****"****Enter a shipper id"** **/>**
    **<****input****type****=****"submit"** **/>**
    **</****form****>**
      </div>
      @if (Model.Greeting is not null)
      {
        <p class="alert alert-primary">@Model.Greeting</p>
      }
      @if (Model.ErrorMessage is not null)
      {
        <p class="alert alert-danger">@Model.ErrorMessage</p>
      }
     **@if (Model.ShipperSummary is not null)**
     **{**
    **<****p****class****=****"alert alert-primary"****>****@Model.ShipperSummary****</****p****>**
     **}**
    </div> 
    
  9. 如果你的数据库服务器没有运行,例如,因为你正在 Docker、虚拟机或云中托管它,那么请确保启动它。

  10. 不带调试启动Northwind.Grpc.Service项目。

  11. 启动Northwind.Grpc.Client.Mvc项目。

  12. 如果需要,启动浏览器并导航到 MVC 网站主页:https://localhost:5133/

  13. 注意在 gRPC 服务中抛出了异常,因为GetShipper方法使用了 EF Core,它尝试动态编译 LINQ 查询,而这在原生 AOT 编译中是不支持的,如下所示,代码中部分输出高亮显示:

    fail: Grpc.AspNetCore.Server.ServerCallHandler[6]
          Error when executing service method 'GetShipper'.
          System.PlatformNotSupportedException: Dynamic code generation is not supported on this platform.
             at System.Reflection.Emit.AssemblyBuilder.ThrowDynamicCodeNotSupported()
    ...
             at Microsoft.EntityFrameworkCore.Storage.Database.CompileQueryTResult
    ...
             at Northwind.Grpc.Service.Services.ShipperService.GetShipper(ShipperRequest request, ServerCallContext context) in C:\apps-services-net8\Chapter13\Northwind.Grpc.Service\Services\ShipperService.cs:line 22
    ... 
    
  14. 关闭浏览器并关闭 Web 服务器。

  15. 在项目文件中,注释掉发布 AOT 选项,如下所示,代码中高亮显示:

    <!--<PublishAot>true</PublishAot>--> 
    

    你可能想知道当我们创建项目并选择使用 EF Core 实现服务的一部分时,启用 AOT 的意义何在,如果我们最终不得不禁用 AOT。两个原因:我想让你看到错误,这样你如果在自己的 gRPC 项目中尝试类似操作时能识别它,并且我们能够在.NET 9 或.NET 10 中使用 EF Core。

  16. 不带调试启动Northwind.Grpc.Service项目。

  17. 启动Northwind.Grpc.Client.Mvc项目。

  18. 注意服务页面上的发货信息,如图13.5所示:

![img/B19587_13_05.png]

图 13.5:调用 gRPC 服务获取承运人后的主页

  1. Northwind 数据库中有三个具有 1、2 和 3 ID 的承运人。尝试输入他们的 ID 以确保它们都可以检索,并尝试输入一个不存在的 ID,比如 4。

  2. 关闭浏览器并关闭 Web 服务器。

将 gRPC 推向更远

现在让我们看看一些更高级的主题,比如原生 AOT 编译支持、获取元数据、添加截止日期、处理日期、时间和十进制类型、添加拦截器,以及处理异常和短暂故障。

使用原生 AOT 发布改进 gRPC 服务

.NET 8 引入了原生 AOT 对 gRPC 的支持。但正如您刚刚看到的,它目前还不兼容 .NET 的某些部分,比如 EF Core。

让我们将我们的 gRPC 服务更改为使用 SQL 客户端而不是 EF Core。我们将保留项目中的大部分 EF Core 代码,这样您可以在将来切换回来,例如,如果您升级到 EF Core 9 并且它支持原生 AOT:

  1. Northwind.Grpc.Service 项目中,取消注释发布 AOT 的选项,并添加 SQL 客户端的包引用,如下所示的高亮标记:

    <PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.2" /> 
    
  2. Services 文件夹中,在 ShipperService.cs 中,导入用于与 SqlClient 一起工作的命名空间,如下所示:

    using Microsoft.Data.SqlClient; // To use SqlConnection and so on.
    using System.Data; // To use CommandType. 
    
  3. GetShipper 方法中,注释掉从 Northwind 数据上下文获取承运人的语句,并用代码替换为使用 SqlClient 获取承运人的代码,如下所示的高亮标记:

    public override async Task<ShipperReply?> GetShipper(
      ShipperRequest request, ServerCallContext context)
    {
    **// We cannot use EF Core in a native AOT compiled project.**
      **//** ShipperEntity? shipper = await _db.Shippers
      **//**   .FindAsync(request.ShipperId);
     **SqlConnectionStringBuilder builder =** **new****();**
     **builder.InitialCatalog =** **"Northwind"****;**
     **builder.MultipleActiveResultSets =** **true****;**
     **builder.Encrypt =** **true****;**
     **builder.TrustServerCertificate =** **true****;**
     **builder.ConnectTimeout =** **10****;** **// Default is 30 seconds.**
     **builder.DataSource =** **"."****;** **// To use local SQL Server.**
     **builder.IntegratedSecurity =** **true****;**
    **/***
     **// To use SQL Server Authentication:**
     **builder.UserID = Environment.GetEnvironmentVariable("MY_SQL_USR");**
     **builder.Password = Environment.GetEnvironmentVariable("MY_SQL_PWD");**
     **builder.PersistSecurityInfo = false;**
     ***/**
     **SqlConnection connection =** **new****(builder.ConnectionString);**
    **await** **connection.OpenAsync();**
     **SqlCommand cmd = connection.CreateCommand();**
     **cmd.CommandType = CommandType.Text;**
     **cmd.CommandText =** **"SELECT ShipperId, CompanyName, Phone"**
     **+** **" FROM Shippers WHERE ShipperId = @id"****;**
     **cmd.Parameters.AddWithValue(****"id"****, request.ShipperId);**
     **SqlDataReader r =** **await** **cmd.ExecuteReaderAsync(**
     **CommandBehavior.SingleRow);**
     **ShipperReply? shipper =** **null****;**
    **// Read the expected single row.**
    **if** **(****await** **r.ReadAsync())**
     **{**
     **shipper =** **new****()**
     **{**
     **ShipperId = r.GetInt32(****"ShipperId"****),**
     **CompanyName = r.GetString(****"CompanyName"****),**
     **Phone = r.GetString(****"Phone"****)**
     **};**
     **}**
    **await** **r.CloseAsync();**
    **return** **shipper;**
    } 
    
  4. 请再次确认您已重新启用发布 AOT 选项。

  5. Program.cs 中,我们可以修改一个语句来使用精简构建器为 Web 应用程序,如下所示:

    // Use the slim builder to reduce the size of the application
    // when using the publish AOT project option.
    // var builder = WebApplication.CreateSlimBuilder(args); 
    

    CreateSlimBuilder 方法不包括对 HTTPS 或 HTTP/3 的支持,尽管如果您需要,可以自行添加这些功能。如果我们切换到精简构建器,那么我们也必须从使用 HTTPS 切换到 HTTP 来与 gRPC 服务通信。在这个任务中,我们将继续使用“完整”构建器,这样我们就可以继续使用 HTTPS。

  6. Northwind.Grpc.Service 项目文件中,添加一个元素以生成编译器生成的文件,如下所示的高亮标记:

    <PropertyGroup>
      <TargetFramework>net8.0</TargetFramework>
      ...
     **<EmitCompilerGeneratedFiles>****true****</EmitCompilerGeneratedFiles>**
    </PropertyGroup> 
    
  7. 构建 Northwind.Grpc.Service 项目。

  8. 如果您正在使用 Visual Studio 2022,在 解决方案资源管理器 中切换 显示所有文件。如果您正在使用 JetBrains Rider,则将鼠标悬停在其上,然后单击眼球图标。

  9. 展开文件夹 obj\Debug\net8.0\generated,然后注意源生成器为 AOT 和 JSON 序列化创建的文件夹和文件,如图 13.6 所示:

图片

图 13.6:在 AOT gRPC 项目中由源生成器创建的文件夹和文件

  1. 在命令提示符或终端中,使用原生 AOT 发布 gRPC 服务,如下所示:

    dotnet publish 
    
  2. 注意关于生成原生代码和为 Microsoft.Data.SqlClient 等包生成修剪警告的消息,如下所示的部分输出:

    Generating native code
    ...
    C:\Users\markj\.nuget\packages\microsoft.data.sqlclient\5.1.1\runtimes\win\lib\net6.0\Microsoft.Data.SqlClient.dll : warning IL2104: Assembly 'Microsoft
    .Data.SqlClient' produced trim warnings. For more information see https://aka.ms/dotnet-illink/libraries [C:\apps-services-net8\Chapter13\Northwind.Grpc.Service\Northwind.Grpc.Service.csproj]
    ... 
    
  3. 启动 文件资源管理器 并打开 bin\Release\net8.0\win-x64\publish 文件夹,并注意 EXE 文件大约有 45 MB。这是以及 Microsoft.Data.SqlClient.SNI.dll 文件是唯一需要部署到另一台 Windows 计算机上的文件,以便 web 服务能够工作。appsettings.json 文件仅在需要覆盖配置时需要。PDB 文件仅在调试时需要,无论如何,其中两个文件仅因为我们保留了 EF Core 代码在项目中作为参考,以便更容易切换回非 AOT 发布。

  4. 在命令提示符或终端中打开 bin\Release\net8.0\win-x64\publish 文件夹。

  5. 在命令提示符或终端中运行 Northwind.Grpc.Service.exe 并显式指定要使用的 URL 和端口号,如下面的命令所示:

    Northwind.Grpc.Service.exe --urls "https://localhost:5131" 
    

launchSettings.json 文件仅由代码编辑器(如 Visual Studio 2022)使用,因此那里指定的端口被忽略,并且不会与生产中的服务一起部署。

  1. 启动 Northwind.Grpc.Client.Mvc 项目。

  2. 注意网页显示了一个 ID 为 1 的发货人,并且您可以搜索其他发货人。

  3. 关闭浏览器并关闭 web 服务器。

更多信息:您可以在以下链接中了解更多关于 gRPC 和原生 AOT 的信息:learn.microsoft.com/en-us/aspnet/core/grpc/native-aot

获取请求和响应元数据

正式定义的请求和响应消息作为合同的一部分,并不是使用 gRPC 在客户端和服务之间传递数据的唯一机制。您还可以使用作为头和尾发送的元数据。这两者都是与消息一起传递的简单字典。

让我们看看您如何获取 gRPC 调用的元数据:

  1. Northwind.Grpc.Client.Mvc 项目中,在 Controllers 文件夹中,在 HomeController.cs 中,导入命名空间以使用 AsyncUnaryCall<T> 类,如下面的代码所示:

    using Grpc.Core; // To use AsyncUnaryCall<T>. 
    
  2. Index 方法中,注释掉调用 gRPC 发货服务器的语句。添加获取底层 AsyncUnaryCall<T> 对象的语句,然后使用它来获取头信息,输出到日志中,然后获取响应,如下面的代码所示:

    **//** ShipperReply shipperReply = await _shipperClient.GetShipperAsync(
    **//**   new ShipperRequest { ShipperId = id });
    **// The same call as above but not awaited.**
    **AsyncUnaryCall<ShipperReply> shipperCall = _shipperClient.GetShipperAsync(**
    **new** **ShipperRequest { ShipperId = id });**
    **Metadata metadata =** **await** **shipperCall.ResponseHeadersAsync;**
    **foreach** **(Metadata.Entry entry** **in** **metadata)**
    **{**
    **// Not really critical, just doing this to make it easier to see.**
     **_logger.LogCritical(****$"Key:** **{entry.Key}****, Value:** **{entry.Value}****"****);**
    **}**
    **ShipperReply shipperReply =** **await** **shipperCall.ResponseAsync;**
    ViewData["shipper"] = "Shipper from gRPC service: " + 
      $"ID: {shipperReply.ShipperId}, Name: {shipperReply.CompanyName},"
      + $" Phone: {shipperReply.Phone}."; 
    
  3. 不带调试启动 Northwind.Grpc.Service 项目。

  4. 启动 Northwind.Grpc.Client.Mvc 项目。

  5. 如果需要,启动浏览器并导航到主页:https://localhost:5133/

  6. 注意客户端成功向 gRPC 的 GreeterShipper 服务发送 POST 请求,以及输出两个条目的红色关键消息,这些条目是 GetShipper 调用的 gRPC 元数据,键为 dateserver,如下所示 图 13.7

图片

图 13.7:从 gRPC 调用记录元数据

  1. 关闭浏览器并关闭 web 服务器。

ResponseHeadersAsync 属性的等价物是 GetTrailers 方法。它有一个返回值为 Metadata 的值,其中包含跟踪器的字典。跟踪器在调用结束时可用。

添加截止日期以提高可靠性

为 gRPC 调用设置截止日期是推荐的做法,因为它控制了 gRPC 调用可以运行的最长时间上限。它防止 gRPC 服务可能消耗过多的服务器资源。

截止日期信息被发送到服务中,因此服务一旦截止日期过去就有机会放弃其工作,而不是永远继续。即使服务器在截止日期内完成了其工作,客户端也可能在响应到达客户端之前放弃,因为通信开销导致截止日期已过。

让我们来看一个例子:

  1. Northwind.Grpc.Service 项目中,在 Services 文件夹中,在 ShipperService.cs 中,在 GetShipper 方法中,添加记录截止日期并暂停五秒的语句,如下所示高亮代码:

    public override async Task<ShipperReply> GetShipper(
      ShipperRequest request, ServerCallContext context)
    {
     **_logger.LogCritical(****$"This request has a deadline of** **{**
     **context.Deadline:T}****. It is now** **{DateTime.UtcNow:T}****."****);**
    **await** **Task.Delay(TimeSpan.FromSeconds(****5****));**
      ...
    } 
    
  2. Northwind.Grpc.Service 项目中,在 appsettings.Development.json 中,将 ASP.NET Core 的日志级别从默认的 Warning 修改为 Information,如下所示高亮配置:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "**Information**"
        }
      }
    } 
    
  3. Northwind.Grpc.Client.Mvc 项目中,在 Controllers 文件夹中,在 HomeController.cs 中,在 Index 方法中,调用 GetShipperAsync 方法时设置三秒的截止日期,如下所示高亮代码:

    AsyncUnaryCall<ShipperReply> shipperCall = shipperClient.GetShipperAsync(
      new ShipperRequest { ShipperId = id }**,**
    **// Deadline must be a UTC DateTime.**
      **deadline: DateTime.UtcNow.AddSeconds(****3****)**); 
    
  4. HomeController.cs 中,在 Index 方法中,在现有的 catch 块之前,添加一个 catch 块来捕获当异常的状态码与截止日期超出的代码匹配时的 RpcException,如下所示高亮代码:

    **catch** **(RpcException rpcex)** **when** **(rpcex.StatusCode ==** 
    **global****::Grpc.Core.StatusCode.DeadlineExceeded)**
    **{**
     **_logger.LogWarning(****"Northwind.Grpc.Service deadline exceeded."****);**
     **model.ErrorMessage = rpcex.Message;**
    **}**
    catch (Exception ex)
    {
      _logger.LogWarning($"Northwind.Grpc.Service is not responding.");
      model.ErrorMessage = ex.Message;
    } 
    
  5. Northwind.Grpc.Client.Mvc 项目中,在 appsettings.Development.json 中,将 ASP.NET Core 的日志级别从默认的 Warning 修改为 Information,如下所示高亮配置:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "**Information**"
        }
      }
    } 
    
  6. 不带调试启动 Northwind.Grpc.Service 项目。

  7. 启动 Northwind.Grpc.Client.Mvc 项目。

  8. 如果需要,启动浏览器并导航到主页:https://localhost:5133/

  9. 在 gRPC 服务的命令提示符或终端中,注意请求有一个三秒的截止日期,如下所示输出:

    crit: Northwind.Grpc.Service.Services.ShipperService[0]
          This request has a deadline of 14:56:30\. It is now 14:56:27. 
    
  10. 在浏览器中,注意在三个秒后,主页显示了一个截止日期超出的异常,如图 图 13.8 所示:

图片

图 13.8:截止日期已过

  1. 在 ASP.NET Core MVC 客户端的命令提示符或终端中,注意从请求 GetShipper 方法在 gRPC 服务上的点开始记录的日志,但截止日期已过,如下所示输出:

    info: System.Net.Http.HttpClient.Shipper.LogicalHandler[100]
          Start processing HTTP request POST https://localhost:5131/shipper.Shipper/GetShipper
    info: System.Net.Http.HttpClient.Shipper.ClientHandler[100]
          Sending HTTP request POST https://localhost:5131/shipper.Shipper/GetShipper
    warn: Grpc.Net.Client.Internal.GrpcCall[7]
          gRPC call deadline exceeded.
    info: Grpc.Net.Client.Internal.GrpcCall[3]
          Call failed with gRPC error status. Status code: 'DeadlineExceeded', Message: ''. 
    
  2. 关闭浏览器并关闭 web 服务器。

  3. ShipperService.cs 中,注释掉导致五秒延迟的语句,如下所示代码:

    **//** await Task.Delay(TimeSpan.FromSeconds(5)); 
    

良好实践:默认情况下没有截止日期。在客户端调用中始终设置截止日期。在你的服务实现中,获取截止日期并使用它来自动放弃超过截止日期的工作。将取消令牌传递给任何异步调用,以便服务器上的工作快速完成并释放资源。

处理日期、时间和十进制数字

你可能已经注意到 gRPC 中没有内置的日期/时间类型。为了存储这些值,你必须使用已知类型扩展,例如google.protobuf.Timestamp(相当于DateTimeOffset)和google.protobuf.Duration(相当于TimeSpan)。

要将它们用作消息中的字段类型,它们必须被导入,如下面的代码所示:

syntax = "proto3";
import "google/protobuf/duration.proto";  
import "google/protobuf/timestamp.proto";
message Employee {
  int32 employeeId = 1;
  google.protobuf.Timestamp birth_date = 2;
  google.protobuf.Duration earned_vacation_time = 3;
  ...
} 

生成的类将不会直接使用.NET 类型。相反,存在中间类型,如下面的代码所示:

public class Employee
{
  public int EmployeeId;
  public Timestamp BirthDate;
  public Duration EarnedVacationTime;
} 

类型FromDateTimeOffsetToDateTimeOffsetFromTimeSpanToTimeSpan上有转换方法,如下面的代码所示:

Employee employee = new()
{
  EmployeeId = 1,
  BirthDate = Timestamp.FromDateTimeOffset(new DateTimeOffset(
    year: 1998, month: 11, day: 30, hour: 0, minute: 0, second: 0,
    offset: TimeSpan.FromHours(-5)),
  EarnedVacationTime = Duration.FromTimeSpan(TimeSpan.FromDays(15))
};
DateTimeOffset when = employee.BirthDate.ToDateTimeOffset();
TimeSpan daysoff = employee.EarnedVacationTime.ToTimeSpan(); 

gRPC 也不原生支持decimal值。将来可能会添加该支持,但到目前为止,你必须创建一个自定义消息来表示它。如果你选择这样做,请记住,其他平台上的开发人员将必须理解你的自定义格式并实现自己的处理方式。

定义自定义的 decimal 类型和使用日期/时间类型

让我们添加用于处理产品(具有UnitPrice属性,该属性是decimal类型)和员工(具有HireDate属性,该属性是DateTime值)的 gRPC 服务:

  1. Northwind.Grpc.Service项目的Protos文件夹中,添加一个名为decimal.proto的新文件,并添加定义安全存储decimal值的消息格式的语句,如下面的代码所示:

    syntax = "proto3";
    option csharp_namespace = "Northwind.Grpc.Service";
    package decimal;
    // Example: 12345.6789 -> { units = 12345, nanos = 678900000 }
    message DecimalValue {
        // To store the whole units part of the amount.
        int64 units = 1;
        // To store the nano units of the amount (10^-9).
        // Must be same sign as units.
        sfixed32 nanos = 2;
    } 
    
  2. 添加一个名为product.proto的新文件,并添加定义获取单个产品、所有产品或最低价格产品的消息和服务方法的语句,如下面的代码所示:

    syntax = "proto3";
    option csharp_namespace = "Northwind.Grpc.Service";
    import "Protos/decimal.proto";
    package product;
    service Product {
      rpc GetProduct (ProductRequest) returns (ProductReply);
      rpc GetProducts (ProductsRequest) returns (ProductsReply);
      rpc GetProductsMinimumPrice (ProductsMinimumPriceRequest) 
          returns (ProductsReply);
    }
    message ProductRequest {
      int32 product_id = 1;
    }
    message ProductsRequest {
    }
    message ProductsMinimumPriceRequest {
      decimal.DecimalValue minimum_price = 1;
    }
    message ProductReply {
      int32 product_id = 1;
      string product_name = 2;
      int32 supplier_id = 3;
      int32 category_id = 4;
      string quantity_per_unit = 5;
      decimal.DecimalValue unit_price = 6;
      int32 units_in_stock = 7;
      int32 units_on_order = 8;
      int32 reorder_level = 9;
      bool discontinued = 10;
    }
    message ProductsReply {
      repeated ProductReply products = 1;
    } 
    
  3. 添加一个名为employee.proto的新文件,并修改它以定义获取单个员工或所有员工的消息和服务方法,注意我们必须导入timestamp.proto的 Google 扩展,如下面的代码所示:

    syntax = "proto3";
    option csharp_namespace = "Northwind.Grpc.Service";
    import "google/protobuf/duration.proto";
    import "google/protobuf/timestamp.proto";
    package employee;
    service Employee {
      rpc GetEmployee (EmployeeRequest) returns (EmployeeReply);
      rpc GetEmployees (EmployeesRequest) returns (EmployeesReply);
    }
    message EmployeeRequest {
      int32 employee_id = 1;
    }
    message EmployeesRequest {
    }
    message EmployeeReply {
      int32 employee_id = 1;
      string last_name = 2;
      string first_name = 3;
      string title = 4;
      string title_of_courtesy = 5;
      google.protobuf.Timestamp birth_date = 6;
      google.protobuf.Timestamp hire_date = 7;
      string address = 8;
      string city = 9;
      string region = 10;
      string postal_code = 11;
      string country = 12;
      string home_phone = 13;
      string extension = 14;
      bytes photo = 15;
      string notes = 16;
      int32 reports_to = 17;
      string photo_path = 18;
    }
    message EmployeesReply {
      repeated EmployeeReply employees = 1;
    } 
    
  4. 在项目文件中添加元素以告诉 gRPC 工具处理新的.proto文件,如下面的标记所示:

    <ItemGroup>
      <Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
      <Protobuf Include="Protos\shipper.proto" GrpcServices="Server" />
     **<Protobuf Include=****"Protos\decimal.proto"** **GrpcServices=****"Server"** **/>**
     **<Protobuf Include=****"Protos\product.proto"** **GrpcServices=****"Server"** **/>**
     **<Protobuf Include=****"Protos\employee.proto"** **GrpcServices=****"Server"** **/>**
    </ItemGroup> 
    
  5. 重新构建项目以确保 gRPC 工具已在obj\Debug\net8.0\Protos文件夹中创建了 C#类,如图13.9所示:

图 13.9:gRPC 工具为具有 Units 属性的自定义 decimal 类型生成的类

  1. Northwind.Grpc.Service项目中,添加一个名为Converters的新文件夹。

  2. Converters 文件夹中,添加一个名为 DecimalValue.Converters.cs 的新类文件,并修改其内容以扩展由 gRPC 工具创建的局部类,添加一个构造函数和一对运算符,用于在自定义的 DecimalValue 类型与内置的 .NET decimal 类型之间进行转换,如下面的代码所示:

    namespace Northwind.Grpc.Service;
    // This will merge with the DecimalValue type generated by the
    // gRPC tools in the obj\Debug\net8.0\Protos\Decimal.cs file.
    public partial class DecimalValue
    {
      private const decimal NanoFactor = 1_000_000_000;
      public DecimalValue(long units, int nanos)
      {
        Units = units;
        Nanos = nanos;
      }
      public static implicit operator decimal(DecimalValue grpcDecimal)
      {
        return grpcDecimal.Units + (grpcDecimal.Nanos / NanoFactor);
      }
      public static implicit operator DecimalValue(decimal value)
      {
        long units = decimal.ToInt64(value);
        int nanos = decimal.ToInt32((value - units) * NanoFactor);
        return new DecimalValue(units, nanos);
      }
    } 
    

实现产品和员工 gRPC 服务

现在我们需要实现并注册服务:

  1. Northwind.Grpc.Service 项目中,在 Services 文件夹中,添加一个名为 ProductService.cs 的新类文件,并修改其内容以实现产品服务。我将把这个作为一项可选练习留给你,或者你也可以从以下链接复制代码:github.com/markjprice/apps-services-net8/blob/main/code/Chapter13/Northwind.Grpc.Service/Services/ProductService.cs

  2. Services 文件夹中,添加一个名为 EmployeeService.cs 的新类文件,并修改其内容以实现产品服务。我将把这个作为一项可选练习留给你,或者你也可以从以下链接复制代码:github.com/markjprice/apps-services-net8/blob/main/code/Chapter13/Northwind.Grpc.Service/Services/EmployeeService.cs

  3. Program.cs 文件中,注册两个新服务,如下面的代码所示:

    app.MapGrpcService<ProductService>();
    app.MapGrpcService<EmployeeService>(); 
    

添加产品和员工 gRPC 客户端

接下来,我们需要向 MVC 项目添加客户端以调用两个新的 gRPC 服务:

  1. Northwind.Grpc.Client.Mvc 项目中,将服务项目中的三个 .proto 文件复制到 MVC 项目的 Protos 文件夹中。

  2. 在三个 .proto 文件中,修改命名空间,如下面的代码所示:

    option csharp_namespace = "Northwind.Grpc.Client.Mvc"; 
    
  3. 在项目文件中,注册这三个文件以创建客户端表示,如下面的标记所示:

    <ItemGroup>
      <Protobuf Include="Protos\greet.proto" GrpcServices="Client" />
      <Protobuf Include="Protos\shipper.proto" GrpcServices="Client" />
     **<Protobuf Include=****"Protos\decimal.proto"** **GrpcServices=****"Client"** **/>**
     **<Protobuf Include=****"Protos\employee.proto"** **GrpcServices=****"Client"** **/>**
     **<Protobuf Include=****"Protos\product.proto"** **GrpcServices=****"Client"** **/>**
    </ItemGroup> 
    

    如果你使用的是像 JetBrains Rider 这样的代码编辑器,它会添加额外的配置,我建议你简化前面的标记中的元素。如果不这样做,那么在接下来的编码任务中可能会遇到错误。

  4. Converters 文件夹从 gRPC 项目复制到 MVC 项目。

  5. Converters 文件夹中的 DecimalValue.Converters.cs 文件中,修改命名空间以使用客户端,如下面的代码所示:

    namespace Northwind.Grpc.**Client.Mvc**; 
    
  6. Northwind.Grpc.Client.Mvc 项目中,在 Program.cs 文件中,添加语句以注册两个新服务的客户端,如下面的代码所示:

    builder.Services.AddGrpcClient<Product.ProductClient>("Product",
      options =>
      {
        options.Address = new Uri("https://localhost:5131");
      });
    builder.Services.AddGrpcClient<Employee.EmployeeClient>("Employee",
      options =>
      {
        options.Address = new Uri("https://localhost:5131");
      }); 
    
  7. Controllers 文件夹中,在 HomeController.cs 文件中,为两个新客户端添加两个字段并在构造函数中设置它们。(提示:遵循 greeter 和 shipper 的相同模式。)

  8. HomeController.cs 文件中,为产品和员工添加两个操作方法,如下面的代码所示:

    public async Task<IActionResult> Products(decimal minimumPrice = 0M)
    {
      ProductsReply reply = await _productClient.GetProductsMinimumPriceAsync(
        new ProductsMinimumPriceRequest() { MinimumPrice = minimumPrice });
      return View(reply.Products);
    }
    public async Task<IActionResult> Employees()
    {
      EmployeesReply reply = await _employeeClient.GetEmployeesAsync(
        new EmployeesRequest());
      return View(reply.Employees);
    } 
    
  9. Views\Shared文件夹中,在_Layout.cshtml中,在导航到主页面的菜单项之后,添加导航到产品和员工的菜单项,如下所示标记高亮显示:

    <li class="nav-item">
      <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a>
    </li>
    **<****li****class****=****"nav-item"****>**
    **<****a****class****=****"nav-link text-dark"****asp-area****=****""****asp-controller****=****"Home"****asp-action****=****"Products"****>****Products****</****a****>**
    **</****li****>**
    **<****li****class****=****"nav-item"****>**
    **<****a****class****=****"****nav-link text-dark"****asp-area****=****""****asp-controller****=****"Home"****asp-action****=****"Employees"****>****Employees****</****a****>**
    **</****li****>** 
    
  10. Views\Home文件夹中,添加一个名为Products.cshtml的新 Razor 视图文件,并将其修改为显示产品表,如下所示标记:

    @using Google.Protobuf.Collections
    @using Northwind.Grpc.Client.Mvc
    @model RepeatedField<ProductReply>
    @{
      ViewData["Title"] = "Products";
      decimal price = 0;
    }
    <h1>@ViewData["Title"]</h1>
    <table class="table table-primary table-bordered">
      <thead>
        <tr>
          <th>Product ID</th>
          <th>Product Name</th>
          <th>Unit Price</th>
          <th>Units In Stock</th>
          <th>Units On Order</th>
          <th>Reorder Level</th>
          <th>Discontinued</th>
        </tr>
      </thead>
      <tbody>
        @foreach (ProductReply p in Model)
        {
          <tr>
            <td>@p.ProductId</td>
            <td>@p.ProductName</td>
            @{ price = p.UnitPrice; }
            <td>@price.ToString("C")</td>
            <td>@p.UnitsInStock</td>
            <td>@p.UnitsOnOrder</td>
            <td>@p.ReorderLevel</td>
            <td>@p.Discontinued</td>
          </tr>
        }
      </tbody>
    </table> 
    
  11. Views\Home文件夹中,添加一个名为Employees.cshtml的新 Razor 视图文件,并将其修改为显示员工表,如下所示标记:

    @using Google.Protobuf.Collections
    @using Northwind.Grpc.Client.Mvc
    @model RepeatedField<EmployeeReply>
    @{
      ViewData["Title"] = "Employees";
    }
    <h1>@ViewData["Title"]</h1>
    <table class="table table-primary table-bordered">
      <thead>
        <tr>
          <th>Employee ID</th>
          <th>Full Name</th>
          <th>Job Title</th>
          <th>Address</th>
          <th>Birth Date</th>
          <th>Photo</th>
        </tr>
      </thead>
      <tbody>
        @foreach (EmployeeReply e in Model)
        {
          <tr>
            <td>@e.EmployeeId</td>
            <td>@e.TitleOfCourtesy @e.FirstName @e.LastName</td>
            <td>@e.Title</td>
            <td>@e.Address<br />@e.City<br />@e.Region<br />
                @e.PostalCode<br />@e.Country</td>
            <td>@e.BirthDate.ToDateTimeOffset().ToString("D")</td>
            <td><img src="data:image/jpg;base64,
              @Convert.ToBase64String(e.Photo.ToByteArray())" />
            </td>
          </tr>
        }
      </tbody>
    </table> 
    

测试十进制、日期和字节处理

最后,我们可以测试我们实现的专用类型处理:

  1. 不带调试启动Northwind.Grpc.Service项目。

  2. 启动Northwind.Grpc.Client.Mvc项目。

  3. 在主页面上,在顶部导航栏中,点击产品,并注意所有产品都包含在表中,如图13.10所示:

图片

图 13.10:使用自定义十进制实现的包含单价的产品

  1. 输入一个最低价格,例如100,点击过滤产品,注意只有单价为该金额或更高的产品包含在表中。

  2. 在顶部导航栏中,点击员工,并注意员工及其出生日期和照片包含在表中,如图13.11所示:

图片

图 13.11:包含出生日期和照片的员工使用时间戳和字节

  1. 关闭浏览器并关闭 Web 服务器。

你现在已经看到了如何使用 gRPC 构建与数据一起工作的几个服务。现在让我们看看 gRPC 的一些更高级的功能。

实现拦截器和处理故障

gRPC 拦截器是在请求和响应期间执行额外处理的一种方式,并且它们可以在客户端或服务中注入。它们通常用于日志记录、监控和验证。

添加客户端拦截器

让我们添加一个客户端 gRPC 拦截器用于日志记录:

  1. Northwind.Grpc.Client.Mvc项目中,添加一个名为Interceptors的新文件夹。

  2. Interceptors文件夹中,添加一个名为ClientLoggingInterceptor.cs的新类文件,然后添加定义拦截器的语句,如下所示代码:

    using Grpc.Core.Interceptors; // To use Interceptor and so on.
    using Grpc.Core; // To use AsyncUnaryCall<T>.
    namespace Northwind.Grpc.Client.Mvc.Interceptors;
    public class ClientLoggingInterceptor : Interceptor
    {
      private readonly ILogger _logger;
      public ClientLoggingInterceptor(ILoggerFactory loggerFactory)
      {
        _logger = loggerFactory.CreateLogger<ClientLoggingInterceptor>();
      }
      public override AsyncUnaryCall<TResponse> 
        AsyncUnaryCall<TRequest, TResponse>(TRequest request,
        ClientInterceptorContext<TRequest, TResponse> context,
        AsyncUnaryCallContinuation<TRequest, TResponse> continuation)
      {
        _logger.LogWarning("Starting call. Type: {0}. Method: {1}.",
          context.Method.Type, context.Method.Name);
        return continuation(request, context);
      }
    } 
    

    拦截器形成一个管道,因此在你的拦截器中,你必须调用管道中的下一个拦截器,由continuation委托表示,并传递requestcontext

  3. Northwind.Grpc.Client.Mvc项目中,在Program.cs中,在添加 gRPC 服务的任何调用之前,添加一个调用以将拦截器注册为单例服务,如下所示代码:

    // Register the interceptor before attaching it to a gRPC client.
    builder.Services.AddSingleton<ClientLoggingInterceptor>(); 
    
  4. Program.cs中,在注册产品客户端的语句末尾添加拦截器,如下所示高亮显示的代码:

    builder.Services.AddGrpcClient<Product.ProductClient>("Product",
      options =>
      {
        options.Address = new Uri("https://localhost:5131");
      })
      **.AddInterceptor<ClientLoggingInterceptor>()**; 
    

    你可以将日志拦截器附加到任意多的客户端。

  5. 不带调试启动Northwind.Grpc.Service项目。

  6. 启动Northwind.Grpc.Client.Mvc项目。

  7. 在主页面上,在顶部导航栏中,点击产品

  8. 在 MVC 网站项目命令提示符或终端中,请注意警告,它将与信息消息不同,因为默认情况下它是黄色背景黑色文字,如下所示:

    warn: Northwind.Grpc.Client.Mvc.Interceptors.ClientLoggingInterceptor[0]
          Starting call. Type: Unary. Method: GetProductsMinimumPrice. 
    
  9. 关闭浏览器并关闭 Web 服务器。

更多信息:你可能认为,“拦截器听起来很像 ASP.NET Core 中间件!”你可以在以下链接中阅读一个有用的比较:learn.microsoft.com/en-us/aspnet/core/grpc/interceptors#grpc-interceptors-versus-middleware

异常和瞬态故障处理

gRPC 内置了对自动重试失败调用的支持,这对于处理瞬态故障(如临时网络断开、服务不可用或繁忙的服务)是一种很好的方法。

在客户端,可能会抛出一个包含错误详细信息的 RpcException

首先,让我们向 gRPC 服务添加一个瞬态故障,看看客户端是如何处理的:

  1. Northwind.Grpc.Service 项目中,在 Services 文件夹中,在 GreeterService.cs 文件中,修改 SayHello 方法,使其等待一秒钟,然后随机地,三分之一的时间应该工作,而三分之二的时间应该抛出服务不可用异常,如下所示:

    public override **async** Task<HelloReply> SayHello(
      HelloRequest request, ServerCallContext context)
    {
    **await** **Task.Delay(****1000****);**
    **if** **(Random.Shared.Next(****1****,** **4****) ==** **1****)**
     **{**
        return new HelloReply
        {
          Message = "Hello " + request.Name
        };
     **}**
    **else**
     **{**
    **throw****new** **RpcException(****new** **Status(StatusCode.Unavailable,**
    **"Service is temporarily unavailable. Try again later."****));**
     **}**
    } 
    
  2. 不带调试启动 Northwind.Grpc.Service 项目。

  3. 启动 Northwind.Grpc.Client.Mvc 项目。

  4. 在主页上,注意异常,如图 13.12 所示:

图片

图 13.12:服务不可用异常

  1. 如果你没有收到异常,请刷新页面,直到收到为止。

  2. 关闭浏览器并关闭 Web 服务器。

现在,让我们看看如何向 MVC 网站客户端添加瞬态故障处理:

  1. Northwind.Grpc.Client.Mvc 项目中,在 Program.cs 文件中,在将问候客户端添加到服务集合之前,添加语句来定义一个具有重试策略的 MethodConfig,该策略对于表示服务不可用的状态码重试最多五次,然后在配置问候客户端地址后应用 method config,如下所示(代码高亮显示):

    **MethodConfig configForAllMethods =** **new****()** 
    **{**
     **Names = { MethodName.Default },**
     **RetryPolicy =** **new** **RetryPolicy**
     **{**
     **MaxAttempts =** **5****,**
     **InitialBackoff = TimeSpan.FromSeconds(****1****),**
     **MaxBackoff = TimeSpan.FromSeconds(****5****),**
     **BackoffMultiplier =** **1.5****,**
     **RetryableStatusCodes = { StatusCode.Unavailable }**
     **}**
    **};**
    builder.Services.AddGrpcClient<Greeter.GreeterClient>("Greeter",
      options =>
      {
        options.Address = new Uri("https://localhost:5131");
      })
     **.ConfigureChannel(channel =>**
     **{**
     **channel.ServiceConfig =** **new** **ServiceConfig**
     **{**
     **MethodConfigs = { configForAllMethods }**
     **};**
     **})**; 
    
  2. 不带调试启动 Northwind.Grpc.Service 项目。

  3. 启动 Northwind.Grpc.Client.Mvc 项目。

  4. 在主页上,请注意主页可能需要几秒钟才能出现,但最终会成功出现,显示来自 gRPC 服务的 Hello Henrietta 消息,如果你查看 gRPC 服务输出,它将包括在最终成功之前多次尝试调用 SayHello,如下所示:

    info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
          Request starting HTTP/2 POST https://localhost:5131/greet.Greeter/SayHello - application/grpc -
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
          Executing endpoint 'gRPC - /greet.Greeter/SayHello'
    info: Grpc.AspNetCore.Server.ServerCallHandler[7]
          Error status code 'Unavailable' with detail 'Service is temporarily unavailable. Try again later.' raised.
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
          Executed endpoint 'gRPC - /greet.Greeter/SayHello'
    info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
          Request finished HTTP/2 POST https://localhost:5131/greet.Greeter/SayHello - 200 0 application/grpc 1039.4626ms
    info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
          Request starting HTTP/2 POST https://localhost:5131/greet.Greeter/SayHello - application/grpc -
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
          Executing endpoint 'gRPC - /greet.Greeter/SayHello'
    info: Grpc.AspNetCore.Server.ServerCallHandler[7]
          Error status code 'Unavailable' with detail 'Service is temporarily unavailable. Try again later.' raised.
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
          Executed endpoint 'gRPC - /greet.Greeter/SayHello'
    info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
          Request finished HTTP/2 POST https://localhost:5131/greet.Greeter/SayHello - 200 0 application/grpc 1008.1375ms
    info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
          Request starting HTTP/2 POST https://localhost:5131/greet.Greeter/SayHello - application/grpc -
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
          Executing endpoint 'gRPC - /greet.Greeter/SayHello'
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
          Executed endpoint 'gRPC - /greet.Greeter/SayHello'
    info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
          Request finished HTTP/2 POST https://localhost:5131/greet.Greeter/SayHello - 200 - application/grpc 1016.4590ms 
    

实现 gRPC JSON 转码

JSON 是向浏览器或移动设备返回数据的服务的最流行格式。如果能创建一个 gRPC 服务并通过非 HTTP/2 使用 JSON 神奇地调用它,那就太好了。

幸运的是,有一个解决方案。

微软有一种他们称之为gRPC JSON 转换的技术。它是一个 ASP.NET Core 扩展,基于 Google 的HttpRule类为他们的 gRPC 转换创建带有 JSON 的 HTTP 端点。

更多信息:你可以在以下链接中了解 Google 的HttpRule类:cloud.google.com/dotnet/docs/reference/Google.Api.CommonProtos/latest/Google.Api.HttpRule

启用 gRPC JSON 转换

让我们看看如何在我们的 gRPC 服务中启用 gRPC JSON 转换:

  1. Northwind.Grpc.Service项目中,添加一个 gRPC JSON 转换的包引用,如下所示高亮显示的标记:

    <ItemGroup>
      <PackageReference Include="Grpc.AspNetCore" Version="2.59.0" />
      <PackageReference Include="Microsoft.Data.SqlClient" Version="5.1.2" />
     **<PackageReference Include=****"Microsoft.AspNetCore.Grpc.JsonTranscoding"**
     **Version=****"8.0.0"** **/>**
    </ItemGroup> 
    
  2. 构建项目以恢复包。

  3. appsettings.json中,修改Protocols选项以启用 HTTP/1.1 以及 HTTP/2,如下所示高亮显示的标记:

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"
        }
      },
      "AllowedHosts": "*",
      "Kestrel": {
        "EndpointDefaults": {
          "Protocols": "**Http1AndHttp2**"
        }
      }
    } 
    

    良好实践:默认情况下,gRPC 项目将配置为仅允许 HTTP/2 请求。为了支持你的代码编辑器中的.http文件或 Unity 等客户端,启用 HTTP/1.1 和 HTTP/2。在同一端口上允许 HTTP/1.1 和 HTTP/2 需要 TLS 进行协议协商,这也是在 gRPC 服务中保持 HTTPS 启用、因此不使用CreateSlimBuilder的另一个好理由。

  4. Program.cs中,在添加 gRPC 调用之后添加一个调用以添加 JSON 转换,如下所示高亮显示的代码:

    builder.Services.AddGrpc()**.AddJsonTranscoding()**; 
    
  5. Northwind.Grpc.Service项目/文件夹中,添加一个名为google的文件夹。

  6. google文件夹中,添加一个名为api的文件夹。

  7. api文件夹中,添加两个名为http.protoannotations.proto.proto文件。

  8. 从以下链接中找到的文件复制并粘贴两个文件的原始内容:github.com/dotnet/aspnetcore/tree/main/src/Grpc/JsonTranscoding/test/testassets/Sandbox/google/api

  9. Protos文件夹中,在employee.proto中导入注释.proto文件,并使用它添加一个选项来公开一个端点,以便向GetEmployee方法发出 HTTP 请求,如下面的代码所示:

    syntax = "proto3";
    option csharp_namespace = "Northwind.Grpc.Service";
    import "google/protobuf/duration.proto";
    import "google/protobuf/timestamp.proto";
    **import** **"google/api/annotations.proto"****;**
    package employee;
    service Employee {
      rpc GetEmployee (EmployeeRequest) returns (EmployeeReply) **{**
     **option (google.api.http) = {**
    **get****:** **"/v1/employee/{employee_id}"**
     **};**
     **}**;
      rpc GetEmployees (EmployeesRequest) returns (EmployeesReply);
    } 
    

测试 gRPC JSON 转换

现在我们可以启动 gRPC 服务并直接从任何浏览器调用它:

  1. 启动Northwind.Grpc.Service项目。

  2. 打开任何浏览器,显示开发者工具,并点击网络选项卡以开始记录网络流量。

  3. 导航到 URL 以发出调用GetEmployee方法的GET请求,https://localhost:5131/v1/employee/1,并注意 gRPC 服务返回的 JSON 响应,如图13.13所示:

图片

图 13.13:向 gRPC 服务发出 HTTP 1.1 GET 请求并接收 JSON 响应

  1. 在你的代码编辑器中,在HttpRequests文件夹中,创建一个名为grpc-json-transcoding.http的新文件,并添加使用 HTTP/1.1 请求员工的语句,如下面的代码所示:

    ### Configure a variable for the gRPC service base address.
    @base_address = https://localhost:5131/
    ### Get Nancy Davolio.
    GET {{base_address}}v1/employee/1
    ### Get Andrew Fuller Davolio.
    GET {{base_address}}v1/employee/2 
    
  2. 发送两个请求,确认响应正确,然后查看 gRPC 服务命令提示符或终端,确认请求是使用 HTTP/1.1 发送的,如下所示:

    info: Microsoft.AspNetCore.Hosting.Diagnostics[1]
          Request starting HTTP/1.1 GET https://localhost:5131/v1/employee/2 - - -
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
          Executing endpoint 'gRPC - /v1/employee/{employee_id}'
    info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
          Executed endpoint 'gRPC - /v1/employee/{employee_id}'
    info: Microsoft.AspNetCore.Hosting.Diagnostics[2]
          Request finished HTTP/1.1 GET https://localhost:5131/v1/employee/2 - 200 - application/json;+charset=utf-8 7.9328ms 
    
  3. 关闭 .http 文件,关闭浏览器,并关闭 web 服务器。

与 gRPC-Web 比较

gRPC-Web 是 gRPC JSON transcoding 的替代方案,允许从浏览器调用 gRPC 服务。gRPC-Web 通过在浏览器中执行 gRPC-Web 客户端来实现这一点。这具有优势,即浏览器和 gRPC 服务之间的通信使用 Protobuf,因此获得真实 gRPC 通信的所有性能和可扩展性优势。

正如你所见,gRPC JSON transcoding 允许浏览器像使用 HTTP API 一样调用 gRPC 服务,而浏览器无需了解 gRPC。gRPC 服务负责将这些 HTTP API 调用转换为对实际 gRPC 服务实现的调用。

为了简化并总结:

  • gRPC JSON transcoding 在服务器端发生。

  • gRPC-Web 在客户端发生。

良好实践:将 gRPC JSON transcoding 支持添加到所有托管在 ASP.NET Core 中的 gRPC 服务。这提供了两全其美的效果。无法使用原生 gRPC 的客户端可以调用 Web API。可以使用原生 gRPC 的客户端可以直接调用它。

练习和探索

通过回答一些问题、进行一些动手实践,并深入研究本章的主题来测试你的知识和理解。

练习 13.1 – 测试你的知识

回答以下问题:

  1. gRPC 有哪些三个优点使其成为实现服务的良好选择?

  2. gRPC 中的合约是如何定义的?

  3. 以下哪种 .NET 类型需要导入扩展:intdoubleDateTime

  4. 为什么在调用 gRPC 方法时应该设置一个截止日期?

  5. 启用 gRPC JSON transcoding 到托管在 ASP.NET Core 中的 gRPC 服务的优势是什么?

练习 13.2 – 比较 gRPC 服务与 HTTP API

查阅以下链接中的文章:

learn.microsoft.com/en-us/aspnet/core/grpc/comparison

练习 13.3 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-13---building-efficient-microservices-using-grpc

摘要

在本章中,你:

  • 了解了一些 gRPC 服务的概念、它们的工作原理以及它们的优点。

  • 实现了一个简单的 gRPC 服务。

  • 实现了一个使用 EF Core 模型(尚不能使用 AOT 发布)的 gRPC 服务。

  • 实现了一个使用 SqlClient 库的 gRPC 服务,这些库可以使用 AOT 发布,因此体积更小、速度更快。

  • 学习了如何设置截止日期以及读取作为报头和报尾发送的元数据。

  • 实现了一个自定义的 decimal 类型,并使用了扩展的日期/时间类型。

  • 实现了一个客户端拦截器。

  • 扩展了一个 gRPC 服务,以支持作为带有 JSON 的 HTTP 服务被调用,以支持无法原生使用 gRPC 的客户端。

在下一章中,你将回顾如何使用 ASP.NET Core MVC 构建网站用户界面。

第十四章:使用 ASP.NET Core 构建 Web 用户界面

本章是关于使用 ASP.NET Core 构建 Web 用户界面。您将了解 ASP.NET Core MVC 视图、Razor 语法、HTML 和标签助手、网站国际化以及如何使用 Bootstrap 进行快速用户界面原型设计。

本章将涵盖以下主题:

  • 设置 ASP.NET Core MVC 网站

  • 使用 Razor 视图定义 Web 用户界面

  • 使用 ASP.NET Core 进行本地化和全球化

  • 使用标签助手定义 Web 用户界面

  • 输出缓存

设置 ASP.NET Core MVC 网站

模型-视图-控制器MVC)设计模式允许在以下列表中看到的技术关注点之间进行清晰的分离:

  • 模型:代表网站中使用的实体数据和视图模型的类。

  • 视图:Razor 文件,即 .cshtml 文件,将视图模型中的数据渲染成 HTML 网页。Blazor 使用 .razor 文件扩展名,但不要将它们与 Razor 文件混淆!

  • 控制器:当 HTTP 请求到达 Web 服务器时执行代码的类。控制器方法通常创建一个包含实体模型的可视模型,并将其传递给视图以生成 HTTP 响应发送回 Web 浏览器或其他客户端。

ASP.NET Core 有许多 Razor 文件类型,由于它们都使用“Razor”这个术语,可能会让人感到困惑,因此我现在将提醒您它们,并突出显示它们的重要相似性和差异,如 表 14.1 所示:

技术 特殊文件名 文件扩展名 指令
Razor 组件(用于 Blazor) .razor
Razor 组件(用于 Blazor 和页面路由) .razor @page
Razor 页面 .cshtml @page
Razor 视图(用于 MVC) .cshtml
Razor 布局 _{customname} .cshtml
Razor 视图(部分) _{customname} .cshtml
Razor 视图开始 _ViewStart .cshtml
Razor 视图导入 _ViewImports .cshtml

表 14.1:Razor 文件之间的重要相似性和差异

警告!请务必在文件顶部使用正确的文件扩展名和指令,否则您可能会遇到意外的行为。

Razor 视图文件在技术上与 Razor 布局或 Razor 视图(部分)相同。这就是为什么遵循在布局或部分视图中使用下划线前缀的约定如此重要的原因。

将 Razor 视图转换为 Razor 布局的是将 Razor 文件名称设置为另一个 Razor 文件或 _ViewStart.cshtml 文件中的默认布局的 Layout 属性,如下面的代码所示:

@{
  Layout = "_LayoutWithAdverts";
} 

将 Razor 视图转换为 Razor 视图(部分)的是在页面上的 <partial> 组件中使用的 Razor 视图名称,如下面的代码所示:

<partial name="_Product" model="product" /> 

良好实践:对于布局和部分视图等特殊和共享 Razor 文件,命名约定是使用下划线_作为前缀,例如_ViewStart.cshtml_Layout.cshtml_Product.cshtml(这可能是用于渲染产品的部分视图)。

创建 ASP.NET Core MVC 网站

你将使用项目模板来创建一个具有本地数据库的 ASP.NET Core MVC 网站项目,用于认证和授权用户。

Visual Studio 2022 默认使用 SQL Server LocalDB 作为账户数据库。

Visual Studio Code(或更准确地说,dotnet CLI 工具)默认使用 SQLite,你可以指定一个选项来使用 SQL Server LocalDB。

让我们看看实际效果:

  1. 使用你喜欢的代码编辑器创建一个 ASP.NET Core MVC 网站项目,其中认证账户存储在数据库中,如下所示列表:

    • 项目模板:ASP.NET Core Web App (Model-View-Controller) [C#] / mvc

    • 项目文件和文件夹:Northwind.Mvc

    • 解决方案文件和文件夹:Chapter14

    • 认证类型:个人账户 / --auth Individual

    • 配置 HTTPS:已选择。

    • 启用 Docker:已清除。

    • 不要使用顶级语句:已清除。

  2. 构建项目Northwind.Mvc

    • 如果你使用 Visual Studio 2022 创建了 MVC 项目,那么认证和授权的数据库将存储在 SQL Server LocalDB 中。但是数据库尚不存在。在命令提示符或终端中,在Northwind.Mvc文件夹中,输入以下命令以运行数据库迁移,以便创建用于存储认证凭证的数据库,如下所示命令:

      dotnet ef database update 
      
    • 如果你使用dotnet new创建了 MVC 项目,那么认证和授权的数据库将存储在 SQLite 中,并且文件已经创建,命名为app.db

  3. 在 MVC 网站项目的根目录中,在appsettings.json文件中,注意名为DefaultConnection的认证数据库的连接字符串,如下所示配置:

    • 使用 SQL Server LocalDB:

      {
        "ConnectionStrings": {
          "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-Northwind.Mvc-...;Trusted_Connection=True;MultipleActiveResultSets=true"
        }, 
      
    • 使用 SQLite:

      {
        "ConnectionStrings": {
          "DefaultConnection": "DataSource=app.db;Cache=Shared"
        }, 
      

探索默认的 ASP.NET Core MVC 网站

让我们回顾默认的 ASP.NET Core MVC 网站项目模板的行为:

  1. Northwind.Mvc项目中,展开Properties文件夹,打开launchSettings.json文件,对于https配置,将其applicationUrl设置中的端口号更改为https5141http5142,如下所示设置:

    "applicationUrl": "https://localhost:5141;http://localhost:5142", 
    
  2. 将更改保存到launchSettings.json文件。

  3. 在你喜欢的代码编辑器或命令行中,使用https配置启动Northwind.Mvc项目,并使用 Chrome 浏览器:

    • 如果你使用 Visual Studio 2022,则选择https配置作为启动项目,并选择Google Chrome作为网络浏览器,然后不进行调试启动Northind.Mvc网站项目。

    • 如果您正在使用 Visual Studio Code,那么在命令提示符或终端中,输入以下命令:dotnet run --launch-profile https。启动 Chrome 并导航到:https://localhost:5141/

    • 在 Windows 上,如果Windows Defender 防火墙显示Windows 安全警报,因为它“已阻止此应用的一些功能”,则点击允许访问

  4. 在 Chrome 中打开开发者工具

  5. 刷新主页并注意以下内容,如图 14.1 所示:

    • 顶部导航菜单包含链接到主页隐私注册登录。如果视口宽度为 575 像素或更小,则导航会折叠成汉堡菜单。

    • 网站标题Northwind.Mvc,显示在页眉和页脚中。

图 14.1:ASP.NET Core MVC 项目模板网站主页

理解访客注册

默认情况下,密码必须至少包含一个非字母数字字符,至少一个数字(0-9),以及至少一个大写字母(A-Z)。在这种情况下,我使用Pa$$w0rd进行探索。

MVC 项目模板遵循双重确认DOI)的最佳实践,这意味着在填写电子邮件地址和密码进行注册后,会向该电子邮件地址发送一封电子邮件,访客必须点击该电子邮件中的链接以确认他们想要注册。

我们尚未配置电子邮件提供商来发送该电子邮件,因此我们必须模拟这一步骤:

  1. 关闭开发者工具面板。

  2. 在顶部导航菜单中,点击注册

  3. 输入电子邮件和密码,然后点击注册按钮。(我使用了test@example.comPa$$w0rd。)

  4. 点击带有文本点击此处确认您的账户的链接,并注意您被重定向到一个可以定制的确认电子邮件网页。

  5. 在顶部导航菜单中,点击登录,输入您的电子邮件地址和密码(注意,有一个可选的复选框用于记住您,如果访客忘记了密码或想注册为新访客,则还有链接),然后点击登录按钮。

  6. 点击顶部导航菜单中的您的电子邮件地址。这将导航到一个账户管理页面。注意,您可以设置电话号码,更改电子邮件地址,更改密码,启用双因素认证(如果您添加了认证器应用程序),以及下载和删除您的个人数据。最后一个功能对于符合欧洲 GDPR 等法律法规很有用。

  7. 关闭 Chrome,然后在 MVC 网站的命令提示符或终端中,按Ctrl + C干净地关闭 Web 服务器。

查看 MVC 网站项目结构

在您的代码编辑器中,在 Visual Studio 2022 解决方案资源管理器(打开显示所有文件)或 Visual Studio Code 资源管理器 – 解决方案资源管理器中,查看 MVC 网站项目的结构,如图 14.2 所示:

图 14.2:VS Code 和 VS 2022 解决方案资源管理器中的 ASP.NET Core MVC 项目

我们将在稍后更详细地查看这些部分,但就目前而言,请注意以下内容:

  • Areas:此文件夹包含用于将您的网站项目与 ASP.NET Core Identity 集成的嵌套文件夹和文件,该 Identity 用于身份验证。

  • binobj:这些文件夹包含在构建过程中需要的临时文件和项目的编译程序集。Visual Studio Code + C# Dev Kit 的解决方案资源管理器不显示此类隐藏文件夹,但您可以在文件夹视图中看到它们(在 图 14.2 中标记为 CHAPTER14)。

  • Controllers:此文件夹包含具有方法(称为操作)的 C# 类,这些方法获取模型并将其传递到视图中,例如 HomeController.cs

  • Data:此文件夹包含 ASP.NET Core Identity 系统使用的 Entity Framework Core 迁移类,用于提供身份验证和授权的数据存储,例如 ApplicationDbContext.cs

  • Models:此文件夹包含代表由控制器收集并传递到视图的所有数据的 C# 类,例如 ErrorViewModel.cs

  • Properties:此文件夹包含一个用于在开发期间启动网站的 Kestrel(或 Windows 上的 IIS 或 IIS Express)配置文件,名为 launchSettings.json。此文件仅在本地开发机器上使用,不会部署到您的生产网站。

  • Views:此文件夹包含将 HTML 和 C# 代码结合在一起以动态生成 HTML 响应的 .cshtml Razor 文件。_ViewStart.cshtml 文件设置默认布局,_ViewImports.cshtml 导入所有视图中使用的通用命名空间,如标签助手:

    • Home:此子文件夹包含用于主页和隐私页面的 Razor 文件。

    • Shared:此子文件夹包含用于共享布局、错误页面和两个用于登录和验证脚本的局部视图的 Razor 文件。

  • wwwroot:此文件夹包含网站使用的静态内容,例如用于样式的 CSS、JavaScript 库、此网站项目的 JavaScript 和 favicon.ico 文件。您还可以在此处放置图像和其他静态文件资源,如 PDF 文档。项目模板包括 Bootstrap 和 jQuery 库。

  • appsettings.jsonappsettings.Development.json:这些文件包含在运行时网站可以加载的设置,例如,ASP.NET Core Identity 系统的数据库连接字符串和日志级别。

  • Northwind.Mvc.csproj:此文件包含项目设置,例如使用 Web .NET SDK、SQLite 的条目以确保 app.db 文件被复制到网站的输出文件夹,以及项目所需的 NuGet 包列表,例如为所选数据库提供程序提供的 EF Core,包括:

    • Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore

    • Microsoft.AspNetCore.Identity.EntityFrameworkCore

    • Microsoft.AspNetCore.Identity.UI

    • Microsoft.EntityFrameworkCore.SqliteMicrosoft.EntityFrameworkCore.SqlServer

    • Microsoft.EntityFrameworkCore.Tools

  • Program.cs:此文件定义了一个自动生成的Program类,其中包含<Main>$入口点。它构建了一个处理传入 HTTP 请求的管道,并使用默认选项(如配置 Kestrel Web 服务器和加载appsettings)托管网站。它添加并配置了网站需要的服务,例如,用于身份验证的 ASP.NET Core Identity,用于身份数据存储的 SQLite 或 SQL Server 等,以及为你的应用程序的路由。

    如果你选择使用 SQLite 而不是 SQL Server 作为 ASP.NET Core Identity 数据库,那么你也会看到一个名为app.db的文件。这是存储已注册访客的 SQLite 数据库。

引用 EF Core 类库并注册数据上下文

我们将参考你在第三章中创建的 EF Core 模型,即使用 EF Core 为 SQL Server 构建实体模型

  1. Northwind.Mvc.csproj项目文件中,将警告视为错误,并将对 Northwind 数据库上下文项目的项目引用添加到项目中,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    Include路径不能有换行符。

  2. 在命令提示符或终端中,构建Northwind.Mvc项目,如下面的命令所示:dotnet build

  3. Program.cs中,导入命名空间以使用AddNorthwindContext扩展方法,如下面的代码所示:

    using Northwind.EntityModels; // To use AddNorthwindContext method. 
    
  4. 在添加服务到容器的作用域中,添加一个将NorthwindContext注册为服务的语句,如下面的代码所示:

    builder.Services.AddNorthwindContext(); 
    

使用 Razor 视图定义 Web 用户界面

让我们回顾一下如何在现代 ASP.NET Core MVC 网站中构建网页的用户界面。

理解 Razor 视图

在 MVC 中,V 代表视图。视图的职责是将模型转换为 HTML 或其他格式。

有多个视图引擎可以用来做这件事。默认视图引擎称为Razor,它使用@符号来指示服务器端代码执行。

让我们回顾主页视图以及它是如何使用共享布局的:

  1. Views/Home文件夹中,打开Index.cshtml文件,注意被@{ }包裹的 C#代码块。这将首先执行,可以用来存储需要传递到共享布局文件中的数据,例如网页的标题,如下面的代码所示:

    @{
      ViewData["Title"] = "Home Page";
    } 
    
  2. 注意<div>元素中使用的静态 HTML 内容,它使用了 Bootstrap 类如text-centerdisplay-4进行样式化,如下面的标记所示:

    <div class="text-center">
      <h1 class="display-4">Welcome</h1>
      <p>Learn about <a href="https://learn.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
    </div> 
    
  3. Views文件夹中,打开_ViewImports.cshtml文件,注意它导入了项目命名空间和项目Models文件夹的命名空间,然后添加了 ASP.NET Core Tag Helpers,我们将在本章后面了解更多关于它们的信息,如下面的代码所示:

    @using Northwind.Mvc 
    @using Northwind.Mvc.Models
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 
    
  4. Views 文件夹中,打开 _ViewStart.cshtml 文件。它在控制器类中调用 View 方法时执行。它用于设置适用于所有视图的默认值。例如,注意它将所有视图的 Layout 属性设置为共享布局文件(不带文件扩展名),如下所示:

    @{
      Layout = "_Layout";
    } 
    

    当渲染部分视图时,例如调用 PartialView 方法而不是 View 方法时,此文件不会执行。

  5. Shared 文件夹中,打开 _Layout.cshtml 文件。

  6. 注意 <title> 是从之前在 Index.cshtml 视图中设置的 ViewData 字典中设置的,如下所示:

    <title>@ViewData["Title"] – Northwind.Mvc</title> 
    

    标题显示在当前页面的浏览器标签或浏览器窗口中。

  7. 注意支持 Bootstrap 和网站样式的链接渲染,其中 ~ 表示项目中的 wwwroot 文件夹,如下所示:

    <link rel="stylesheet" 
          href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
    <link rel="stylesheet" href="~/Northwind.Mvc.styles.css" 
          asp-append-version="true" /> 
    
  8. 注意在页眉中渲染导航栏,如下所示:

    <body>
      <header>
        <nav class="navbar ..."> 
    
  9. 注意渲染一个包含名为 _LoginPartial 的部分视图的可折叠 <div>,用于登录,以及允许用户使用具有 asp-controllerasp-action 等属性的 ASP.NET Core 标签助手在页面之间导航的超链接,如下所示:

    <div class="navbar-collapse collapse d-sm-inline-flex
                justify-content-between">
      <ul class="navbar-nav flex-grow-1">
        <li class="nav-item">
          <a class="nav-link text-dark" asp-area=""
            asp-controller="Home" asp-action="Index">Home</a>
        </li>
        <li class="nav-item">
          <a class="nav-link text-dark" asp-area="" 
            asp-controller="Home" asp-action="Privacy">Privacy</a>
        </li>
      </ul>
      <partial name="_LoginPartial" />
    </div> 
    

    <a> 元素使用名为 asp-controllerasp-action 的标签助手属性来指定当点击链接时将执行的控制器名称和操作名称。可以使用 asp-area 属性对大型、复杂的 MVC 网站中的页面进行组织和分组。如果您想导航到 Razor 类库中的功能,则也可以使用 asp-area 来指定功能名称。

  10. 注意在 <main> 元素内渲染的主体,如下所示:

    <div class="container">
      <main role="main" class="pb-3">
        @RenderBody()
      </main>
    </div> 
    

    RenderBody 方法在该点注入特定 Razor 视图的内容,例如 Index.cshtml 文件在共享布局中的内容。

  11. 注意在页面底部渲染 <script> 元素,以避免减慢页面显示速度,并且可以将自己的脚本块添加到名为 Scripts 的可选定义部分中,如下所示:

    <script src="img/jquery.min.js"></script>
    <script src="img/bootstrap.bundle.min.js">
    </script>
    <script src="img/site.js" asp-append-version="true"></script> 
    @await RenderSectionAsync("Scripts", required: false) 
    
  12. Shared 文件夹中,打开 _LoginPartial.cshtml 文件,并注意 if 语句,如果访问者已登录,则渲染指向其账户和注销的链接,或者如果他们未登录,则渲染指向注册或登录的链接。

  13. Shared 文件夹中,打开 _ValidationScriptsPartial.cshtml 文件,并注意它包含两个脚本块,用于通过 JavaScript 在客户端浏览器中添加验证,如下所示:

    <script src="img/jquery.validate.min.js"></script>
    <script src="img/jquery.validate.unobtrusive.min.js"></script> 
    

    良好实践:如果您想在像 Index.cshtml 这样的 Razor 视图中启用验证,则需要将此部分视图添加到 Scripts 部分中,如下所示:

    @section Scripts {
      <partial name="_ValidationScriptsPartial" />
    } 
    
  14. Shared 文件夹中,打开 Error.cshtml 文件,并注意它包含用于渲染异常的标记。

使用 Bootstrap 进行原型设计

Bootstrap 是构建响应式、移动优先网站的世界最流行框架。它结合 CSS 样式表和 JavaScript 库来实现其功能。

它是原型化网站用户界面的好选择,尽管在公开之前你可能需要雇佣一个网页设计师来构建定制的 Bootstrap 主题或用完全定制的 CSS 样式表替换它,以给你的网站带来独特的品牌。

Bootstrap 就像 Marmite。一些开发者喜欢它;一些则讨厌它。

使用 Bootstrap 的合理理由包括:

  • 它节省了时间。

  • 它是可定制的。

  • 它是开源的。

  • 它有官方的详细文档,并在像 Stack Overflow 这样的网站上有很多关于它的答案。

但如果不加注意地实现 Bootstrap,会有以下负面影响:

  • 你的网站看起来会很普通。

  • Bootstrap 主题与 ASP.NET Core Identity 内置的默认视图不太兼容。

  • 与手工制作的解决方案相比,它较重。

在本书的前一版中,我包括了大约五页的内容,回顾了 Bootstrap 最常用的功能。但 Bootstrap 不是.NET,第二版已经内容满满,所以我将 Bootstrap 内容移到了在线资源部分。你可以在以下链接中阅读它:github.com/markjprice/apps-services-net8/blob/main/docs/ch14-bootstrap.md

良好实践:除了定义自己的样式外,基于实现响应式设计的通用库,如 Bootstrap,来构建样式。然而,如果你正在构建一个需要独特身份或品牌的网站,请确保使用 Bootstrap 的主题支持。不要只是接受默认设置。

理解 Razor 语法和表达式

在我们自定义主页视图之前,让我们回顾一个示例 Razor 文件。该文件有一个初始的 Razor 代码块,它实例化了一个带有价格和数量的订单,然后在网页上输出订单信息,如下面的标记所示:

@{
  Order order = new()
  {
    OrderId = 123,
    Product = "Sushi",
    Price = 8.49M,
    Quantity = 3
  };
}
<div>Your order for @order.Quantity of @order.Product has a total cost of $@ order.Price * @order.Quantity</div> 

前面的 Razor 文件会导致以下不正确的输出:

Your order for 3 of Sushi has a total cost of $8.49 * 3 

尽管 Razor 标记可以采用@object.property语法包含任何单个属性的值,但你应该将表达式放在括号中,如下面的标记所示:

<div>Your order for @order.Quantity of @order.Product has a total cost of $@ (order.Price * order.Quantity)</div> 

前面的 Razor 表达式会产生以下正确的输出:

Your order for 3 of Sushi has a total cost of $25.47 

理解 HTML Helper 方法

当为 ASP.NET Core MVC 创建视图时,你可以使用Html对象及其方法来生成标记。当微软在 2009 年首次引入 ASP.NET MVC 时,这些 HTML Helper 方法是通过编程渲染 HTML 的方式。

现代 ASP.NET Core 保留了这些 HTML Helper 方法以实现向后兼容性,并提供了标签助手,在大多数情况下它们通常更容易阅读和编写。但有一些明显的情况中标签助手不能使用,比如在 Razor 组件中。

你将在本章后面学习到标签助手。

一些有用的Html对象方法包括以下内容:

  • ActionLink: 使用此功能来生成一个包含指定控制器和动作的 URL 路径的锚点<a>元素。例如,Html.ActionLink(linkText: "绑定", actionName: "ModelBinding", controllerName: "Home")将生成<a href="/home/modelbinding">绑定</a>

  • AntiForgeryToken: 在<form>中使用此功能来插入一个包含抗伪造令牌的<hidden>元素,该令牌在表单提交时可以进行验证。

  • DisplayDisplayFor: 使用此功能来生成相对于当前模型的表达式的 HTML 标记,使用显示模板。对于.NET 类型有内置的显示模板,可以在DisplayTemplates文件夹中创建自定义模板。在大小写敏感的文件系统中,文件夹名称是大小写敏感的。

  • DisplayForModel: 使用此功能来生成整个模型的 HTML 标记,而不是单个表达式。

  • EditorEditorFor: 使用此功能来生成相对于当前模型的表达式的 HTML 标记,使用编辑模板。对于.NET 类型有内置的编辑模板,使用<label><input>元素,可以在EditorTemplates文件夹中创建自定义模板。在大小写敏感的文件系统中,文件夹名称是大小写敏感的。

  • EditorForModel: 使用此功能来生成整个模型的 HTML 标记,而不是单个表达式。

  • Encode: 使用此功能来安全地将对象或字符串编码为 HTML。例如,字符串值"<script>"将被编码为"&lt;script&gt;"。这通常不是必需的,因为 Razor 的@符号默认会编码字符串值。

  • Raw: 使用此功能来渲染一个字符串值,进行 HTML 编码。

  • PartialAsyncRenderPartialAsync: 使用这些功能来生成部分视图的 HTML 标记。您可以可选地传递模型和视图数据。

定义一个强类型 Razor 视图

为了在编写视图时提高 IntelliSense,您可以使用顶部的@model指令来定义视图可以期望的类型。让我们修改主页以显示来自 Northwind 数据库的订单表:

  1. Controllers文件夹中,在HomeController.cs中,导入 Northwind 实体模型和 EF Core 功能的命名空间,如下所示,代码中已突出显示:

    using Northwind.EntityModels; // To use Northwind entity models.
    using Microsoft.EntityFrameworkCore; // To use Include method. 
    
  2. 在控制器类中,定义一个字段来存储 Northwind 数据上下文,并在构造函数中设置它,如下所示,代码中已突出显示:

    **private****readonly** **NorthwindContext _db;**
    public HomeController(ILogger<HomeController> logger
      **, NorthwindContext db**)
    {
      _logger = logger;
     **_db = db;**
    } 
    
  3. Index动作方法中,添加语句来创建一个包含所有订单及其相关订单详情的视图模型,如下所示,代码中已突出显示:

    public IActionResult Index()
    {
     **IEnumerable<Order> model = _db.Orders**
     **.Include(order => order.Customer)**
     **.Include(order => order.OrderDetails)**
     **.OrderByDescending(order => order.OrderDetails**
     **.Sum(detail => detail.Quantity * detail.UnitPrice))**
     **.AsEnumerable();**
      return View(**model**);
    } 
    
  4. Views文件夹中,在_ViewImports.cshtml中,添加一个语句来导入所有 Razor 视图和页面的 EF Core 实体模型,如下所示,代码中已突出显示:

    @using Northwind.EntityModels 
    
  5. Views\Home文件夹中,在Index.cshtml文件顶部,添加一个语句来设置要使用的模型类型为订单集合,如下所示,代码中已突出显示:

    @model IEnumerable<Order> 
    

    现在,每次我们在视图中键入 Model 时,我们的代码编辑器都将知道模型的正确类型,并提供智能感知。

    在视图中输入代码时,请记住以下事项:

    • 使用 @model(带小写 m)声明模型类型。

    • 使用 @Model(带大写 M)与模型实例交互。

  6. Index.cshtml 中,在初始 Razor 代码块中,将现有内容替换为以下标记中的订单 HTML 表格:

    @model IEnumerable<Order>
    @{
      ViewData["Title"] = "Orders";
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      <table class="table table-bordered table-striped">
        <thead>
          <tr>
            <th>Order ID</th>
            <th>Order Date</th>
            <th>Company Name</th>
            <th>Country</th>
            <th>Item Count</th>
            <th>Order Total</th>
          </tr>
        </thead>
        <tbody>
          @foreach (Order order in Model)
          {
            <tr>
              <td>@order.OrderId</td>
              <td>@order.OrderDate?.ToString("D")</td>
              <td>@order.Customer?.CompanyName</td>
              <td>@order.Customer?.Country</td>
              <td>@order.OrderDetails.Count()</td>
              <td>@order.OrderDetails.Sum(detail => detail.Quantity * detail.UnitPrice).ToString("C")</td>
            </tr>
          }
        </tbody>
      </table>
    </div> 
    

    让我们看看我们自定义的主页的结果:

  7. 如果你的数据库服务器没有运行,例如,因为你正在 Docker、虚拟机或云中托管它,那么请确保启动它。

  8. 不带调试启动 Northwind.Mvc 网站项目。

  9. 注意,主页现在显示了一个订单表,其中显示价值最高的订单在最前面,如图 14.3 所示:

    图 14.3:更新后的 Northwind MVC 网站主页

    我在我的本地笔记本电脑上运行我的 web 服务器,其操作系统 Windows 11 已配置为使用英国文化来格式化日期、时间和货币值。接下来,我们将看到如何为访客的首选文化本地化网页。

  10. 关闭 Chrome 并关闭 web 服务器。

现在,你已经提醒了如何构建一个基本的 MVC 网站来显示数据,让我们看看在构建万维网网站时经常被忽视的一个重要中级主题:支持世界上所有的语言和文化。

使用 ASP.NET Core 本地化和全球化

第七章处理日期、时间和国际化中,你学习了如何处理日期、时间和时区,以及如何全球化本地化 .NET 代码库。

在本节中,我们将具体探讨如何本地化使用 ASP.NET Core 的网站。

除了使用 IStringLocalizerstring 值本地化为法语和西班牙语等语言外,你还可以使用 IHtmlLocalizer 本地化 HTML 内容,但应谨慎使用。通常,HTML 标记应适用于所有区域。对于视图,你可以使用 IViewLocalizer

请求本地化意味着浏览器可以通过以下方式请求它偏好的文化:

  • 添加一个查询字符串参数,例如,?culture=en-US&ui-culture=en-US

  • 在请求中发送一个 cookie,例如,c=en-US|uic=en-US

  • 设置一个 HTTP 头,例如,Accept-Language: en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7,en-GB;q=0.6

要启用请求本地化,请在 Program.cs 中配置 HTTP 请求管道时调用 UseRequestLocalization 方法。这告诉 ASP.NET Core 查找这些请求,并自动将处理该请求的当前线程(仅此请求,不涉及其他人的请求)更改为使用适当的语言文化来格式化数据和加载资源值。

让我们创建一些资源文件,将网络用户界面本地化为美国英语、英国英语和法语,然后全球化数据,如日期和货币值:

  1. Northwind.Mvc 项目中,添加一个名为 Resources 的新文件夹。这是本地化服务默认查找 *.resx 资源文件的文件夹名称。

  2. Resources 中添加一个名为 Views 的新文件夹。

  3. Views 中添加一个名为 Home 的新文件夹。

创建资源文件

创建资源文件 (*.resx) 的方式取决于您的代码编辑器。

为了节省时间,您可以直接从以下链接中找到的 GitHub 仓库复制 .resx 文件:github.com/markjprice/apps-services-net8/tree/main/code/Chapter14/Northwind.Mvc/Resources/Views/Home

如果您正在使用 Visual Studio 2022

您可以使用特殊的项目项类型和编辑器:

  1. Home 中添加一个名为 Index.en-US.resx资源文件 类型。

  2. 使用编辑器定义名称和值,如图 14.4 所示:

    图 14.4:使用资源文件编辑器定义本地化标签

    JetBrains Rider 有自己的资源文件编辑器,它将所有 .resx 文件合并为一个网格体验。每种语言都有自己的列,并排显示。这比在 Visual Studio 2022 中逐个编辑每个文件要更有用得多。

  3. 关闭编辑器。

  4. 复制并粘贴文件,并将其重命名为 Index.en-GB.resx

    警告! 您不得更改 名称 列中的任何条目,因为这些条目用于查找所有语言的本地化值!您只能更改 注释 列中的条目。

  5. Index.en-GB.resx 中,将 Orders (USA) 修改为 Orders (UK)。这样我们可以看到差异。

  6. 关闭编辑器。

  7. 复制并粘贴文件,并将其重命名为 Index.fr-FR.resx

  8. Index.fr-FR.resx 中,修改 列以使用法语。(有关 Visual Studio Code 中翻译的逐步说明,请参阅下一节。)

  9. 复制并粘贴文件,并将其重命名为 Index.fr.resx

  10. Index.fr.resx 中,将最后一个值修改为 Commandes (Neutral French)

如果您正在使用 Visual Studio Code

您将不得不在没有特殊编辑器的情况下编辑文件:

  1. Home 中添加一个名为 Index.en-US.resx 的新文件。

  2. 修改内容以包含美国英语语言资源,如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="Company Name" xml:space="preserve">
        <value>Company Name</value>
      </data>
      <data name="Country" xml:space="preserve">
        <value>Country</value>
      </data>
      <data name="Item Count" xml:space="preserve">
        <value>Item Count</value>
      </data>
      <data name="Order Date" xml:space="preserve">
        <value>Order Date</value>
      </data>
      <data name="Order ID" xml:space="preserve">
        <value>Order ID</value>
      </data>
      <data name="Order Total" xml:space="preserve">
        <value>Order Total</value>
      </data>
      <data name="Orders" xml:space="preserve">
        <value>Orders (USA)</value>
      </data>
    </root> 
    
  3. 复制并粘贴文件,并将其重命名为 Index.en-GB.resx

  4. Index.en-GB.resx 中,将 Orders (USA) 修改为 Orders (UK)。这样我们可以看到差异。

  5. 复制并粘贴文件,并将其重命名为 Index.fr-FR.resx

  6. Index.fr-FR.resx 中,将 列修改为使用法语:

    <?xml version="1.0" encoding="utf-8"?>
    <root>
      <data name="Company Name" xml:space="preserve">
        <value>Nom de l'entreprise</value>
      </data>
      <data name="Country" xml:space="preserve">
        <value>Pays</value>
      </data>
      <data name="Item Count" xml:space="preserve">
        <value>Nombre d'éléments</value>
      </data>
      <data name="Order Date" xml:space="preserve">
        <value>Date de commande</value>
      </data>
      <data name="Order ID" xml:space="preserve">
        <value>Numéro de commande</value>
      </data>
      <data name="Order Total" xml:space="preserve">
        <value>Total de la commande</value>
      </data>
      <data name="Orders" xml:space="preserve">
        <value>Commandes (France)</value>
      </data>
    </root> 
    
  7. 复制并粘贴文件,并将其重命名为 Index.fr.resx

  8. Index.fr.resx 中,将最后一个值修改为 Commandes (Neutral French)

使用注入视图本地化器本地化 Razor 视图

现在我们可以继续使用这些步骤来设置代码编辑器:

  1. Views/Home文件夹中的Index.cshtml中,导入用于本地化的命名空间,注入IViewLocalizer服务,并修改使用视图模型中的标签,如下面的标记所示:

    **@using Microsoft.AspNetCore.Mvc.Localization**
    @model IEnumerable<Order>
    **@inject IViewLocalizer Localizer**
    @{
      ViewData["Title"] = **Localizer[****"Orders"****]**;
    }
    <div class="text-center">
      <h1 class="display-4">@ViewData["Title"]</h1>
      <table class="table table-bordered table-striped">
        <thead>
          <tr>
            <th>@**Localizer[****"Order ID"****]**</th>
            <th>@**Localizer[****"Order Date"****]**</th>
            <th>@**Localizer[****"Company Name"****]**</th>
            <th>@**Localizer[****"Country"****]**</th>
            <th>@**Localizer[****"Item Count"****]**</th>
            <th>@**Localizer[****"Order Total"****]**</th>
          </tr>
        </thead> 
    

    良好实践:像"订单 ID"这样的关键值用于查找本地化值。如果缺少值,则默认返回键。因此,使用也作为良好回退的键是一个好习惯。这就是为什么我在上面的.resx文件中使用带有空格的美国英语正确标题作为键的原因。

  2. Program.cs中,在调用AddControllersWithViews之前,添加一个语句来添加本地化并设置查找资源文件的路径为Resources文件夹,并在调用AddControllersWithViews之后,添加一个调用以添加视图本地化,如下面的代码所示:

    **builder.Services.AddLocalization(**
     **options => options.ResourcesPath =** **"Resources"****);**
    builder.Services.AddControllersWithViews()
      **.AddViewLocalization()**; 
    
  3. Program.cs中,在调用Build方法后的app对象中,添加声明我们将支持的四种文化的语句:美国英语、英国英语、中性的法语和法国法语。然后,创建一个新的本地化选项对象,并将这些文化添加为支持本地化用户界面(UICultures)和数据值(如日期和货币)的全局化(Cultures),如下面的代码所示:

    string[] cultures = { "en-US", "en-GB", "fr", "fr-FR" };
    RequestLocalizationOptions localizationOptions = new();
    // cultures[0] will be "en-US"
    localizationOptions.SetDefaultCulture(cultures[0])
      // Set globalization of data formats like dates and currencies.
      .AddSupportedCultures(cultures)
      // Set localization of user interface text.
      .AddSupportedUICultures(cultures);
    app.UseRequestLocalization(localizationOptions); 
    
  4. 启动Northwind.Mvc网站项目。

  5. 在 Chrome 中,导航到设置

  6. 搜索设置框中输入lang,注意您将找到首选语言部分,如图 14.5 所示:

    图 14.5:在 Chrome 设置中搜索首选语言部分

    警告!如果您正在使用本地化的 Chrome 版本,换句话说,其用户界面是您的本地语言,如法语,那么您需要用您的本地语言搜索“语言”这个词。(尽管法语中的“语言”是“langue”,所以输入“lang”仍然可以工作。但在西班牙语中,您需要搜索“idioma”。)

  7. 点击添加语言,搜索french,选择法语 - francais法语(法国) – francais (France),然后点击添加

    警告!如果您正在使用本地化的 Chrome 版本,那么您需要用您的本地语言搜索“法语”。例如,在西班牙语中,它将是“Francés”,在威尔士语中,它将是“Ffrangeg”。

  8. 如果列表中还没有,请添加英语(美国)英语(英国)

  9. 法语(法国)右侧的省略号菜单中,点击移动到顶部,并确认它位于您语言列表的顶部。

  10. 关闭设置标签页。

  11. 在 Chrome 中,执行强制重新加载/刷新(例如,按住Ctrl并点击刷新按钮),并注意现在主页现在使用本地化标签和日期及货币的法国格式,如图 14.6 所示:

图 14.6:将订单表本地化和全球化到法国的法语

  1. 对其他语言重复上述步骤,例如,英语(英国)

  2. 查看 开发者工具,并注意请求头部已设置英国英语(en-GB)为首选,如图 14.7 所示:

图 14.7:由于 Accept-Language: en-GB 头部信息,订单被本地化和全球化为英国英语

  1. 关闭浏览器并关闭网络服务器。

理解 Accept-Language 头部信息

你可能会想知道 Accept-Language 头部信息是如何工作的:

Accept-Language: en-US,en;q=0.9,fr-FR;q=0.8,fr;q=0.7,en-GB;q=0.6 

Accept-Language 头部信息使用逗号作为文化代码之间的分隔符。每个文化代码可以是中性的(仅语言)或特定的(语言和区域),每个都可以有一个 质量值 (q),介于 0.0 和 1.0 之间(默认)。因此,前面的 Accept-Language 头部信息示例应如下阅读:

  • en-US: 美国英语语言,排名第一,为 1.0(如果未显式设置 q)。

  • en;q=0.9: 世界各地英语语言,排名为 0.9。

  • fr-FR;q=0.8: 法国法语语言,排名为 0.8。

  • fr;q=0.7: 世界各地法语语言,排名为 0.7。

  • en-GB;q=0.6: 英国英语语言,排名最低,为 0.6。

使用标签助手定义网络用户界面

标签助手使使 HTML 元素动态化变得更容易。标记更干净,更容易阅读、编辑和维护,比使用 HTML 助手更好。

然而,标签助手并不能完全替代 HTML 助手,因为有些事情只能通过 HTML 助手实现,比如渲染包含多个嵌套标签的输出。标签助手也不能在 Razor 组件中使用。因此,你必须学习 HTML 助手,并将标签助手视为一种可选的选择,在某些场景下可能更好。

标签助手对于主要使用 HTML、CSS 和 JavaScript 的前端(FE)开发者特别有用,因为前端开发者不需要学习 C# 语法。标签助手仅使用看起来像正常 HTML 属性的元素。如果您的代码编辑器支持,您还可以从 IntelliSense 中选择属性名称和值;Visual Studio 2022 和 Visual Studio Code 都支持。

比较 HTML 助手和标签助手

例如,要渲染一个可链接的超链接到控制器操作,你可以使用一个 HTML 助手方法,如下面的标记所示:

@Html.ActionLink("View our privacy policy.", "Privacy", "Index") 

为了更清楚地说明其工作原理,你可以使用命名参数,如下面的代码所示:

@Html.ActionLink(linkText: "View our privacy policy.", 
  action: "Privacy", controller: "Index") 

但是,对于更擅长 HTML 而不是 C# 的人来说,使用标签助手会更清晰、更简洁,如下面的标记所示:

<a asp-action="Privacy" asp-controller="Home">View our privacy policy.</a> 

上面的所有三个示例都生成相同的渲染 HTML 元素,如下面的标记所示:

<a href="/home/privacy">View our privacy policy.</a> 

在接下来的几节中,我们将回顾一些更常见的标签助手:

  • 锚点标签助手

  • 缓存标签助手

  • 环境标签助手

  • 图片标签助手

  • 与表单相关的标签助手

探索锚点标签助手

首先,我们将创建三个可点击的按钮样式的超链接,以查看包含所有订单的首页、单个客户的订单以及单个国家的订单。这将使我们能够看到创建链接到控制器和操作的基本方法,以及使用路由参数和任意查询字符串参数传递参数。

让我们探索这些 Anchor Tag Helper 的示例:

  1. Views 文件夹中,在 _ViewImports.cshtml 文件中,注意 @addTagHelper 指令,它添加了 ASP.NET Core Tag Helper,如下所示,高亮显示的代码:

    @using Northwind.Mvc 
    @using Northwind.Mvc.Models
    **@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers**
    @using Northwind.EntityModels 
    

    你可以创建自己的 Tag Helper,并且你必须以相同的方式注册它们。但这超出了本书的范围。如果你想了解如何,你可以阅读以下文档:learn.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/authoring

  2. Views/Home 文件夹中,在 Privacy.cshtml 文件中,添加标记来定义一个段落,使用 <a> 标签创建可点击的按钮样式的超链接,如下所示,高亮显示的标记:

    <p>
      <a asp-controller="Home" asp-action="Index" 
         class="btn btn-primary" role="button">Orders</a>
      <a asp-controller="Home" 
         class="btn btn-outline-primary" role="button">This Page</a>
      <a asp-controller="Home" asp-action="Index" asp-route-id="ALFKI" 
         class="btn btn-outline-primary" role="button">
         Orders for Alfreds Futterkiste</a>
      <a asp-controller="Home" asp-action="Index" asp-route-country="Brazil" 
         class="btn btn-outline-primary" role="button">Orders in Brazil</a>
    </p> 
    

    如果你设置了一个没有操作名称的控制器名称,那么它默认为当前操作,在这种情况下,Privacyasp-route-{parametername} 属性可以使用任何任意的参数名称。在上面的代码示例中,我们使用了 idcountryid 将映射到具有相同名称的路由参数。country 不是一个路由参数,所以它将作为查询字符串传递。

  3. Controllers 文件夹中,在 HomeController.cs 文件中,修改 Index 动作方法以定义两个可选参数,用于传递客户 ID 和国家名称,然后修改 LINQ 查询以使用它们来过滤订单(如果已设置),如下所示,高亮显示的代码:

    public IActionResult Index(
      **string****? id =** **null****,** **string****? country =** **null**)
    {
      // Start with a simplified initial model.
      IEnumerable<Order> model = db.Orders
        .Include(order => order.Customer)
        .Include(order => order.OrderDetails)**;**
      // Add filtering based on parameters.
    **if** **(id** **is****not****null****)**
     **{**
     **model = model.Where(order => order.Customer?.CustomerId == id);**
     **}**
    **else****if** **(country** **is****not****null****)**
     **{**
     **model = model.Where(order => order.Customer?.Country == country);**
     **}**
      // Add ordering and make enumerable.
     **model = model**
        .OrderByDescending(order => order.OrderDetails
          .Sum(detail => detail.Quantity * detail.UnitPrice))
        .AsEnumerable();
      return View(model);
    } 
    
  4. 启动 Northwind.Mvc 网站项目。

  5. 查看 开发者工具 并点击 元素 选项卡。

  6. 在首页上,点击 隐私 以导航到该页面,并注意按钮,包括它们的原始 HTML,它显示了由 Anchor Tag Helper 生成的 href 属性路径,如图 14.8 所示:

图 14.8:由 Anchor Tag Helper 生成的三个按钮样式的超链接

  1. 点击每个按钮,然后返回到 隐私政策 页面,以确保它们能正确工作。

  2. 关闭浏览器并关闭 web 服务器。

  3. Views/Home 文件夹中,在 Index.cshtml 文件中,在订单表的末尾添加一个锚点标签,以指示订单表的结束,如下所示,高亮显示的标记:

     </table>
    **<****a****id****=****"endOfTable"** **/>**
    </div> 
    
  4. Views/Home 文件夹中,在 Privacy.cshtml 文件中,在现有的锚点标签之后,添加另一个标签,通过设置 asp-fragment 属性链接到具有 idendOfTable 的锚点,如下所示,高亮显示的标记:

    <a asp-controller="Home" asp-action="Index" asp-fragment="endOfTable"
       class="btn btn-outline-primary">Orders (end of table)</a> 
    
  5. 修改第二个锚点标签,明确设置要使用的协议为 https,如下所示,高亮显示的标记:

    <a asp-controller="Home" **asp-protocol****=****"https"**
        class="btn btn-outline-primary">This Page</a> 
    
  6. Controllers 文件夹中,在 HomeController.cs 文件中,添加一个名为 Shipper 的操作方法。给它一个参数来接收一个承运实体,并将其传递给视图,如下面的代码所示:

    public IActionResult Shipper(Shipper shipper)
    {
      return View(shipper);
    } 
    

    此操作方法可以响应任何请求方法,例如,GETPOST。使用 GET 请求,承运商实体将作为查询字符串键值对传递。使用 POST 请求,承运商实体将传递在体中。

  7. Views/Home 文件夹中,添加一个名为 Shipper.cshtml 的空 Razor 视图。

  8. 修改内容,如下面的标记所示:

    @model Shipper
    @{
      ViewData["Title"] = "Shippers";
    }
    <h1>@ViewData["Title"]</h1>
    <div>
      <div class="mb-3">
        <label for="shipperIdInput" class="form-label">Shipper Id</label>
        <input type="number" class="form-control" id="shipperIdInput" 
               value="@Model.ShipperId">
      </div>
      <div class="mb-3">
        <label for="companyNameInput" class="form-label">Company Name</label>
        <input class="form-control" id="companyNameInput" 
               value="@Model.CompanyName">
      </div>
      <div class="mb-3">
        <label for="phoneInput" class="form-label">Phone</label>
        <input class="form-control" id="phoneInput" value="@Model.Phone">
      </div>
    </div> 
    
  9. Views/Home 文件夹中,在 Privacy.cshtml 文件中,在文件顶部添加代码和标记以注入 Northwind 数据库上下文。然后,使用它来定义一个 Razor 函数,创建一个字典,其键和值都是字符串,并从承运商表填充,如下面的代码所示:

    **@inject NorthwindContext db**
    @{
      ViewData["Title"] = "Privacy Policy";
    }
    **@functions {**
    **public****async** **Task<IDictionary<****string****,** **string****>> GetShipperData()**
     **{**
    **// Find the shipper with ID of 1.**
     **Shipper? shipper =** **await** **db.Shippers.FindAsync(****1****);**
     **Dictionary<****string****,** **string****> keyValuePairs =** **new****();**
    **if** **(shipper !=** **null****)**
     **{**
     **keyValuePairs =** **new****()**
     **{**
     **{** **"ShipperId"****, shipper.ShipperId.ToString() },**
     **{** **"CompanyName"****, shipper.CompanyName },**
     **{** **"Phone"****, shipper.Phone ??** **string****.Empty }**
     **};**
     **}**
    **return** **keyValuePairs;**
     **}**
    **}** 
    
  10. 在现有的锚点标签之后,添加另一个标签来将字典传递到当前页面,如下面的标记所示:

    <a asp-controller="Home" asp-action="Shipper" 
        asp-all-route-data="await GetShipperData()"
        class="btn btn-outline-primary">Shipper</a> 
    

    将复杂对象作为查询字符串传递,如这样,会迅速达到 URL 大约 1,000 个字符的限制。要发送更大的对象,你应该使用 POST 而不是 GET,通过使用 <form> 元素而不是锚点标签 <a> 来实现。

  11. 如果你的数据库服务器没有运行,例如,因为你正在使用 Docker、虚拟机或云来托管它,那么请确保启动它。

  12. 启动 Northwind.Mvc 网站项目。

  13. 查看 开发者工具 并点击 元素

  14. 在主页上,点击 隐私 以导航到该页面,并注意按钮,包括它们的原始 HTML,它显示了由锚点标签助手生成的 href 属性路径,如图 14.9 所示:

    图 14.9:使用片段和通过查询字符串参数传递复杂对象

    指定协议的一个副作用是生成的 URL 必须包含协议、域名以及任何端口号,以及相对路径,因此这是一种方便的方法来获取绝对 URL 而不是默认的相对路径 URL,如上面第二个链接所示。

  15. 点击 订单(表格末尾) 按钮,并注意浏览器导航到主页,然后跳转到订单表的末尾。

  16. 返回到 隐私 页面,点击 承运商 按钮,并注意承运商详情已预先输入到承运商表单中。

  17. 关闭浏览器并关闭 web 服务器。

探索缓存标签助手

缓存和分布式缓存标签助手通过使用内存或注册的分布式缓存提供者分别缓存其内容,从而提高了你的网页性能。我们在 第九章缓存、队列和弹性后台服务 中介绍了对这些缓存进行读写操作。现在我们将看到如何将 HTML 片段存储在视图中的。

作为提醒,内存缓存最适合单个 web 服务器或启用了会话亲和力的 web 服务器群。会话亲和力意味着来自同一浏览器的后续请求由同一 web 服务器提供服务。分布式缓存最适合 web 服务器群或在 Azure 等云服务提供商中。你可以注册 SQL Server、Redis 或 NCache 的提供程序,或者创建自己的自定义提供程序。

可以应用于 Cache 标签助手的属性包括:

  • enabled: 默认值为 true。这存在是为了让你可以在标记中包含 <cache> 元素,但可以在运行时决定是否启用它。

  • expires-after: 一个 TimeSpan 值,用于指定过期时间。默认值为 00:20:00,即 20 分钟。

  • expires-on: 一个用于过期的 DateTimeOffset 值。没有默认值。

  • expires-sliding: 一个 TimeSpan 值,如果在此时间内没有访问值,则过期。这在存储创建成本高且受欢迎程度不同的数据库实体时很有用。受欢迎的实体如果继续被访问,将保留在缓存中。不受欢迎的实体将退出。没有默认值。

  • vary-by-{type}: 这些属性允许基于 HTTP header 值、用户、路由、cookiequery 字符串值或自定义值的差异,有多个不同的缓存版本。

让我们看看 Cache 标签助手的示例:

  1. Views/Home 文件夹中的 Index.cshtml 文件中,在标题和表格之间添加 <div> 元素,以定义一个包含两个列的 Bootstrap 行,显示当前的 UTC 日期和时间两次,一次是实时,一次是缓存,如下所示的高亮标记:

    <div class="row">
      <div class="col">
        <h2>Live</h2>
        <p class="alert alert-info">
        UTC: @DateTime.UtcNow.ToLongDateString() at 
             @DateTime.UtcNow.ToLongTimeString()
        </p>
      </div>
      <div class="col">
        <h2>Cached</h2>
        <p class="alert alert-secondary">
          <cache>
            UTC: @DateTime.UtcNow.ToLongDateString() at 
                 @DateTime.UtcNow.ToLongTimeString()
          </cache>
        </p>
      </div>
    </div> 
    
  2. 启动 Northwind.Mvc 网站项目。

  3. 在几秒钟内多次刷新主页,并注意左侧的时间总是刷新以显示实时时间,而右侧的时间缓存(默认为 20 分钟),如图 14.10 所示:

图 14.10:实时和缓存的 UTC 时间

  1. 关闭浏览器并关闭 web 服务器。

  2. Views/Home 文件夹中的 Index.cshtml 文件中,将 <cache> 元素修改为在 10 秒后过期,如下所示的高亮标记:

    <cache **expires-after=****"@TimeSpan.FromSeconds(10)"**> 
    
  3. 启动 Northwind.Mvc 网站项目。

  4. 在几秒钟内多次刷新主页,并注意左侧的时间总是刷新以显示实时时间,而右侧的时间在刷新前缓存了 10 秒。

  5. 关闭浏览器并关闭 web 服务器。

探索环境标签助手

环境标签助手仅在当前环境与逗号分隔的名称列表中的一个值匹配时才渲染其内容。如果你想在预发布环境中渲染一些内容,如向测试人员提供说明,或者在生产环境中渲染开发人员和测试人员不需要看到的内容,如特定客户的信息,这很有用。

除了设置逗号分隔的环境列表的 names 属性外,您还可以使用 include(与 names 作用相同)和 exclude(为除列表中的环境之外的所有环境渲染)。

让我们看看一个例子:

  1. Views/Home 文件夹中,在 Privacy.cshtml 中,注入网络主机环境的依赖服务,如下面的代码所示:

    @inject IWebHostEnvironment webhost 
    
  2. 在标题之后,添加两个 <environment> 元素,第一个仅显示开发人员和测试人员的输出,第二个仅显示产品访客的输出,如下面的标记所示:

    <environment names="Development,Staging">
      <div class="alert alert-warning">
        <h2>Attention developers and testers</h2>
        <p>
          This is a warning that only developers and testers will see.
          Current environment: 
          <span class="badge bg-warning">@webhost.EnvironmentName</span>
        </p>
      </div>
    </environment>
    <environment names="Production">
      <div class="alert alert-info">
        <h2>Welcome, visitor!</h2>
        <p>
          This is information that only a visitor to the production website 
          will see. Current environment: 
          <span class="badge bg-info">@webhost.EnvironmentName</span>
        </p>
      </div>
    </environment> 
    
  3. 启动 Northwind.Mvc 网站项目。

  4. 导航到 隐私 页面,并注意开发人员和测试人员的消息,如图 14.11 所示:

图 14.11:开发环境中的隐私页面

  1. 关闭浏览器并关闭网络服务器。

  2. Properties 文件夹中,在 launchSettings.json 中,对于 https 配置文件,将环境设置更改为 Production,如下面的 JSON 所示高亮显示:

    "https": {
      ...
      "environmentVariables": {
    **"ASPNETCORE_ENVIRONMENT"****:****"Production"**
      }
    }, 
    
  3. 启动 Northwind.Mvc 网站项目。

  4. 导航到 隐私 页面,并注意公共访客的消息,如图 14.12 所示:

图 14.12:生产环境中的隐私页面

  1. 关闭浏览器并关闭网络服务器。

  2. Properties 文件夹中,在 launchSettings.json 中,对于 https 配置文件,将环境设置改回 Development

理解如何使用标签辅助器进行缓存破坏

当在 <link><img><script> 元素中指定 asp-append-versiontrue 值时,将调用该标签类型的标签辅助器。

它们通过自动附加一个名为 v 的查询字符串值来实现,该值是从引用源文件的 SHA256 哈希中生成的,如下面的示例生成输出所示:

<script src="img/site.js? v=Kl_dqr9NVtnMdsM2MUg4qthUnWZm5T1fCEimBPWDNgM"></script> 

您可以在当前项目中亲自看到这一点,因为 _Layout.cshtml 文件具有 <script src="img/site.js" asp-append-version="true"></script> 元素。

如果 site.js 文件中的任何单个字节发生变化,则其哈希值将不同,因此如果浏览器或 CDN 缓存脚本文件,则将破坏缓存的副本并替换为新版本。

src 属性必须设置为存储在本地网络服务器上的静态文件,通常在 wwwroot 文件夹中,但您可以配置其他位置。不支持远程引用。

探索与表单相关的标签辅助器

表单标签辅助器为 MVC 控制器操作或命名路由生成 <form> 元素的 action 属性。与锚点标签辅助器类似,您可以使用 asp-route-<parametername> 属性传递参数。它还生成一个隐藏的验证令牌,以防止跨站请求伪造。您必须将 [ValidateAntiForgeryToken] 属性应用于 HTTP POST 动作方法,以正确使用此功能。

标签和输入辅助器将标签和输入绑定到模型上的属性。然后它们可以自动生成idnamefor属性,以及添加验证属性和消息。

让我们看看一个用于输入运输信息的表单示例:

  1. Views/Home文件夹中,在Shipper.cshtml中,复制现有的输出运输详情的标记,将其包裹在一个使用表单辅助器的<form>元素中,并修改<label><input>元素以使用标签和输入辅助器,如下面的高亮标记所示:

    @model Shipper
    @{
      ViewData["Title"] = "Shippers";
    }
    <h1>@ViewData["Title"]</h1>
    **<****h2****>****Without Form Tag Helper****</****h2****>**
    <div>
      <div class="mb-3">
        <label for="shipperIdInput" class="form-label">Shipper ID</label>
        <input type="number" class="form-control" id="shipperIdInput"
               value="@Model.ShipperId">
      </div>
      <div class="mb-3">
        <label for="companyNameInput" class="form-label">Company Name</label>
        <input class="form-control" id="companyNameInput"
               value="@Model.CompanyName">
      </div>
      <div class="mb-3">
        <label for="phoneInput" class="form-label">Phone</label>
        <input class="form-control" id="phoneInput" value="@Model.Phone">
      </div>
    </div>
    **<****h2****>****With Form Tag Helper****</****h2****>**
    **<****form****asp-controller****=****"Home"****asp-action****=****"ProcessShipper"**
    **class****=****"form-horizontal"****role****=****"form"****>**
    **<****div****>**
    **<****div****class****=****"mb-3"****>**
    **<****label****asp-for****=****"ShipperId"****class****=****"****form-label"** **/>**
    **<****input****asp-for****=****"ShipperId"****class****=****"form-control"****>**
    **</****div****>**
    **<****div****class****=****"mb-3"****>**
    **<****label****asp-for****=****"CompanyName"****class****=****"form-label"** **/>**
    **<****input****asp-for****=****"CompanyName"****class****=****"form-control"****>**
    **</****div****>**
    **<****div****class****=****"****mb-3"****>**
    **<****label****asp-for****=****"Phone"****class****=****"form-label"** **/>**
    **<****input****asp-for****=****"****Phone"****class****=****"form-control"****>**
    **</****div****>**
    **<****div****class****=****"mb-3"****>**
    **<****input****type****=****"submit"****class****=****"form-control"****>**
    **</****div****>**
    **</****div****>**
    **</****form****>** 
    
  2. Controllers文件夹中,在HomeController.cs中添加一个名为ProcessShipper的操作方法。给它一个参数来接收一个运输实体,然后使用Json方法将其作为 JSON 文档返回,如下面的代码所示:

    [HttpPost]
    [ValidateAntiForgeryToken]
    public IActionResult ProcessShipper(Shipper shipper)
    {
      return Json(shipper);
    } 
    
  3. 启动Northwind.Mvc网站项目。

  4. 导航到隐私页面,然后点击运输者按钮。

  5. 运输者页面,右键单击,选择查看页面源代码,并注意由表单、输入和标签辅助器生成的不同 HTML 输出,包括一个名为__RequestVerificationToken的隐藏元素,如下面的标记所示:

    <h2>With Form Tag Helper</h2>
    <form class="form-horizontal" role="form" action="/Home/ProcessShipper" method="post">
      <div>
        <div class="mb-3">
          <label class="form-label" for="ShipperId" />
          <input class="form-control" type="number" data-val="true" data-val-required="The ShipperId field is required." id="ShipperId" name="ShipperId" value="1">
        </div>
        <div class="mb-3">
          <label class="form-label" for="CompanyName" />
          <input class="form-control" type="text" data-val="true" data-val-length="The field CompanyName must be a string with a maximum length of 40." data-val-length-max="40" data-val-required="The CompanyName field is required." id="CompanyName" maxlength="40" name="CompanyName" value="Speedy Express">
        </div>
        <div class="mb-3">
          <label class="form-label" for="Phone" />
          <input class="form-control" type="text" data-val="true" data-val-length="The field Phone must be a string with a maximum length of 24." data-val-length-max="24" id="Phone" maxlength="24" name="Phone" value="(503) 555-9831">
        </div>
        <div class="mb-3">
          <input type="submit" class="form-control">
        </div>
      </div>
    <input name="__RequestVerificationToken" type="hidden" value="CfDJ8NTt08jabvBCqd1P4J-HCq3X9CDrTPjBphdDdVmG6UT0GFBJk1w7F1OLmNT-jEGjlGIjfV3kmNUaofOAxlGgiZJwbAR73g-QgFw8oFV_0vjlo45t9dL9E1l1hZzjLXtj8B7ysDkCYcm8W9zS0T7V3R0" /></form> 
    
  6. 在表单中,更改运输 ID 和公司名称,注意像maxlength="40"这样的属性防止公司名称超过 40 个字符,而type="number"属性只允许运输 ID 为数字。

  7. 点击提交按钮并注意返回的 JSON 文档,如下面的输出所示:

    {"shipperId":1,"companyName":"Speedy Express","phone":"(503) 555-9831","orders":[]} 
    
  8. 关闭浏览器并关闭 Web 服务器。

输出缓存

在某些方面,输出缓存类似于我们已在第九章缓存、队列和弹性后台服务中讨论的响应缓存。输出缓存可以在服务器上存储动态生成的响应,这样它们就不必为另一个请求再次生成。这可以提高性能。与响应缓存不同,输出缓存不依赖于客户端和中间件执行 HTTP 响应头中指示的操作。

输出缓存端点

让我们用一个非常简单的例子来看看将输出缓存应用于某些端点以确保其正常工作的实际效果:

  1. Northwind.Mvc项目中,在Program.cs中,在调用AddNorthwindContext之后,添加一个语句来添加输出缓存中间件并覆盖默认的过期时间跨度,使其仅为 10 秒,如下面的代码所示:

    builder.Services.AddOutputCache(options =>
    {
      options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(10);
    }); 
    

    良好实践:默认过期时间跨度为 1 分钟。仔细考虑持续时间应该是多少。

  2. Program.cs中,在调用映射控制器路由之前,添加一个语句来使用输出缓存,如下面的代码所示:

    app.UseOutputCache(); 
    
  3. Program.cs中,在调用映射 Razor 页面之后,添加语句来创建两个简单的端点,一个不缓存,一个使用输出缓存,如下面的代码所示:

    app.MapGet("/notcached", () => DateTime.Now.ToString());
    app.MapGet("/cached", () => DateTime.Now.ToString()).CacheOutput(); 
    
  4. appsettings.Development.json 中,为输出缓存中间件添加 Information 级别的日志,如下所示(配置高亮):

    {
      "Logging": {
        "LogLevel": {
          "Default": "Information",
          "Microsoft.AspNetCore": "Warning"**,**
    **"****Microsoft.AspNetCore.OutputCaching"****:****"Information"**
        }
      }
    } 
    
  5. 启动 Northwind.Mvc 网站项目,并调整浏览器窗口和命令提示符或终端窗口,以便你可以看到两者。

  6. 在浏览器中导航到 https://localhost:5141/notcached,并注意没有内容被写入命令提示符或终端。

  7. 在浏览器中,多次点击 刷新 按钮,并注意时间总是更新,因为它不是从输出缓存中提供的。

  8. 在浏览器中导航到 https://localhost:5141/cached,并注意消息被写入命令提示符或终端,告诉你你已经请求了一个缓存的资源,但它没有在输出缓存中找到任何内容,因此现在已缓存了输出,如下所示:

    info: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[7]
          No cached response available for this request.
    info: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[9]
          The response has been cached. 
    
  9. 在浏览器中,多次点击 刷新 按钮,并注意时间没有更新,并且输出缓存消息告诉你值是从缓存中提供的,如下所示:

    info: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[5]
          Serving response from cache. 
    
  10. 持续刷新直到 10 秒钟过去,并注意消息被写入命令行或终端,告诉你缓存的输出已被更新。

  11. 关闭浏览器并关闭网络服务器。

输出缓存 MVC 视图

现在我们来看看如何输出缓存 MVC 视图:

  1. Program.cs 中,在调用映射控制器之后,添加对 CacheOutput 方法的调用,如下所示(代码高亮):

    app.MapControllerRoute(
        name: "default",
        pattern: "{controller=Home}/{action=Index}/{id?}")
      **.CacheOutput()**; 
    
  2. 启动 Northwind.Mvc 网站项目,并调整浏览器窗口和命令提示符或终端窗口,以便你可以看到两者。

  3. 在命令提示符或终端中,注意主页及其订单表不在输出缓存中,因此执行 SQL 命令以获取数据,然后一旦 Razor 视图生成页面,它就被存储在缓存中,如下所示(输出高亮):

    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: https://localhost:5141
    info: Microsoft.Hosting.Lifetime[14]
          Now listening on: http://localhost:5142
    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:\apps-services-net8\Chapter14\Northwind.Mvc
    **info: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[7]**
     **No cached response available for this request.**
    dbug: 6/16/2023 10:40:15.252 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
          SELECT [o].[OrderId], [o].[CustomerId], [o].[EmployeeId], [o].[Freight], [o].[OrderDate], [o].[RequiredDate], [o].[ShipAddress], [o].[ShipCity], [o].[ShipCountry], [o].[ShipName], [o].[ShipPostalCode], [o].[ShipRegion], [o].[ShipVia], [o].[ShippedDate], [c].[CustomerId], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region], [o0].[OrderId], [o0].[ProductId], [o0].[Discount], [o0].[Quantity], [o0].[UnitPrice]
          FROM [Orders] AS [o]
          LEFT JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[CustomerId]
          LEFT JOIN [Order Details] AS [o0] ON [o].[OrderId] = [o0].[OrderId]
          ORDER BY [o].[OrderId], [c].[CustomerId], [o0].[OrderId]
    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
          Executed DbCommand (32ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
          SELECT [o].[OrderId], [o].[CustomerId], [o].[EmployeeId], [o].[Freight], [o].[OrderDate], [o].[RequiredDate], [o].[ShipAddress], [o].[ShipCity], [o].[ShipCountry], [o].[ShipName], [o].[ShipPostalCode], [o].[ShipRegion], [o].[ShipVia], [o].[ShippedDate], [c].[CustomerId], [c].[Address], [c].[City], [c].[CompanyName], [c].[ContactName], [c].[ContactTitle], [c].[Country], [c].[Fax], [c].[Phone], [c].[PostalCode], [c].[Region], [o0].[OrderId], [o0].[ProductId], [o0].[Discount], [o0].[Quantity], [o0].[UnitPrice]
          FROM [Orders] AS [o]
          LEFT JOIN [Customers] AS [c] ON [o].[CustomerId] = [c].[CustomerId]
          LEFT JOIN [Order Details] AS [o0] ON [o].[OrderId] = [o0].[OrderId]
          ORDER BY [o].[OrderId], [c].[CustomerId], [o0].[OrderId]
    **info: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[8]**
     **The response has been cached.** 
    
  4. 在主页上,注意当前时间,然后刷新页面,并注意整个页面,包括时间和订单表,都是来自输出缓存,如下所示(输出高亮):

    info: Microsoft.AspNetCore.OutputCaching.OutputCacheMiddleware[5]
          Serving response from cache. 
    
  5. 持续刷新直到 10 秒钟过去,并注意页面随后从数据库重新生成,并显示当前时间。

  6. 关闭浏览器并关闭网络服务器。

有许多其他方法可以改变输出缓存的缓存结果,ASP.NET Core 团队打算在未来添加更多功能。

练习和探索

通过回答一些问题、进行一些实际操作练习,以及更深入地研究本章主题来测试你的知识和理解。

练习 14.1 – 测试你的知识

回答以下问题:

  1. 声明强类型 Razor 视图的优点是什么?以及如何实现它?

  2. 你如何在视图中启用标签助手?

  3. 与标签助手相比,HTML 辅助方法有哪些优缺点?

  4. 浏览器如何请求一个首选语言进行本地化?

  5. 你如何在视图中本地化文本?

  6. 标签辅助器识别的属性的名称前缀是什么?

  7. 你如何将复杂对象作为查询字符串参数传递?

  8. 你如何控制 <cache> 元素的内容缓存多长时间?

  9. <environment> 元素用于什么?

  10. 标签辅助器的缓存失效是如何工作的?

练习 14.2 – 使用 Bootstrap 练习构建用户界面

创建一个名为 Ch14Ex02_ExploringBootstrap 的新 ASP.NET Core MVC 项目。添加实现以下 Bootstrap 功能的视图:

练习 14.3 – 探索主题

使用以下页面上的链接了解本章涵盖的主题:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-14---building-web-user-interfaces-using-aspnet-core

摘要

在本章中,你学习了如何使用 ASP.NET Core MVC 构建用户界面。你学习了以下内容:

  • ASP.NET Core Razor 视图和 Razor 语法

  • 一些常见的 Bootstrap 样式

  • 本地化和全球化 ASP.NET Core 网站

  • HTML 辅助器和标签辅助器

  • 输出缓存端点和视图

在下一章中,你将学习如何使用 Blazor 构建网络用户界面组件。

第十五章:使用 Blazor 构建 Web 组件

本章介绍了使用 Blazor 构建 Web 组件。这些可以是丰富且交互式的用户界面,它们以 HTML 和 CSS 的形式渲染,以提供跨平台的浏览器支持。

使用.NET 进行客户端 Web 开发有许多优势。你可以用 99%的代码使用 C#而不是 JavaScript,并且可以通过 JavaScript 模块与 JavaScript 进行交互,其余 1%。你可以在服务器和客户端之间共享业务逻辑。Blazor 实现了.NET Standard 以及最新的.NET 8 库,因此你可以使用广泛的旧版.NET 库,包括来自 Microsoft 和第三方的库。

在本书的前一版中,本章介绍了Blazor WebAssembly,这是一种托管模型,其中整个 Blazor 应用程序和.NET 运行时都下载到浏览器并执行。Blazor WebAssembly 的一个问题是访客的初始启动体验较慢,因为需要下载和执行大量内容。

许多.NET 开发者在不得不在构建 Web 应用程序的不同技术之间进行选择时感到沮丧,因为它们都不完美,都有优点和缺点。

在本书的这一版中,本章涵盖了随着.NET 8 引入的新统一 Blazor Full Stack 模型。这使得你可以在单个项目中混合所有最佳功能,包括以下内容:

  • 使用 WebAssembly 在客户端执行的 Blazor 组件。这取代了 Blazor WebAssembly 项目所能实现的功能。

  • 在服务器端执行并与浏览器中的文档对象模型DOM)实时通信的 Blazor 组件,使用 SignalR 进行更新。这取代了 Blazor Server 项目所能实现的功能。

  • 提供静态服务器渲染SSR)并返回 HTTP 响应的 Blazor 组件,其中静态内容不与服务器进行实时交互。这取代了在传统 ASP.NET Core 网站中使用 Razor Pages 或 Razor Views 所能实现的功能。

  • 提供服务器端流式的 Blazor 组件,以便尽快向访客展示部分内容,其余内容则在后台流式传输到浏览器。这是一个全新的功能。

  • 未来版本将使 Blazor 能够在任何.NET 进程中执行,如控制台应用程序,因此它可以作为一个静态网站生成器SSG)使用。

本章将涵盖以下主题:

  • 理解 Blazor

  • 构建 Blazor 组件

  • 构建 Blazor 数据组件

  • 使用本地存储实现缓存

理解 Blazor

Blazor 是建立在.NET 之上的 Microsoft 的 Web 组件开发框架。

Blazor 托管模型

Blazor 提供了多种托管模型可供选择:

  • Blazor Server: 所有组件都在 Web 服务器上执行,用户界面更新通过 SignalR 发送到浏览器。Blazor Server 的特性提供了一些关键优势,包括完整的 .NET API 支持、直接访问所有服务器端资源(如数据库)、快速初始加载时间,以及您的代码受到保护,因为它永远不会离开服务器。这种托管模型是在 2019 年 11 月的 .NET Core 3.0 中引入的。

  • Blazor WebAssembly: 所有组件都在 Web 浏览器中执行,就像其他 单页应用程序(SPA)框架一样,例如 React 和 Angular。您的 .NET 程序集和 .NET 运行时会被下载到浏览器并缓存以供将来使用。Blazor WebAssembly 的特性提供了一些关键优势,包括在网络断开连接时能够离线运行应用程序、在静态网站上托管应用程序或从 内容分发网络(CDN)提供应用程序,以及将处理任务卸载到客户端,从而提高可扩展性。这种托管模型是在 2020 年 5 月作为 .NET Core 3.1 的扩展引入的,并在 2020 年 11 月的 .NET 5 中内置。

  • Blazor Hybrid/.NET MAUI Blazor App: 所有组件都在本地 Web 视图中执行,该视图由原生客户端应用程序托管。如果应用程序需要跨平台,可以使用 .NET MAUI 构建,或者如果您仅针对 Windows,则可以使用 Windows Presentation Foundation 或 Windows Forms。与前面两种托管模型相比,Blazor Hybrid 的主要优势是访问原生客户端功能,这可以提供更好的用户体验。这种托管模型是在 2022 年 11 月的 .NET 7 中引入的。

  • Blazor Full Stack: 组件可以在服务器上执行并生成静态标记,但每个单独的组件可以被切换到以下任何一种:流式渲染、交互式服务器端使用 SignalR 进行 COM 的实时更新,或者交互式客户端使用 WebAssembly。这种新的托管模型在 .NET 8 预览期间被称为 Blazor United。它在 2023 年 11 月的 .NET 8 中以 Blazor Full Stack 的形式引入。在未来版本中,我预计它将简单地被称为 Blazor。

良好实践:对于新项目,Blazor Web App 应该是您选择的项目模板。如果您需要一个可以托管在 Azure Static Web Apps 或 CDN 上的纯 SPA 项目,那么 Blazor WebAssembly Standalone App 将是您的最佳选择,因为 Blazor Web App 需要一个 Web 服务器。对于静态网站,Blazor WebAssembly 仍然是正确的解决方案,而不是新的 Blazor Full Stack。

与多个托管模型不同,Blazor Full Stack 具有多个等效的渲染模式。托管并执行其代码在服务器端的 Blazor Server 项目模板现在被交互式服务器渲染模式所取代。可以在静态网站上托管并执行其代码在客户端的 Blazor WebAssembly 项目模板现在可以被交互式 WebAssembly 渲染模式所取代。

Blazor 支持所有四个主要网络浏览器的最新版本——Chrome、Firefox、Edge 和 Safari,在移动和桌面平台上。Blazor Hybrid 支持三个主要平台上的最新 WebView 组件——Android 上的 Chrome、iOS 和 macOS 上的 Safari 以及 Windows 上的 Edge WebView2。

更多信息:官方 Blazor 文档提供了一个有用的表格,可以帮助您在托管模型之间进行选择。您可以在以下链接中找到它:learn.microsoft.com/en-us/aspnet/core/blazor/hosting-models#which-blazor-hosting-model-should-i-choose

Blazor 组件

Blazor 的一切都是关于组件。组件是 Web 应用的一部分,如按钮、网格、用于收集访客输入的表单,甚至是一个完整的页面。组件可以被重用和嵌套以构建更复杂的组件。

Blazor 组件通常由一个具有.razor文件扩展名的 Razor 文件组成。与 ASP.NET Core MVC 或 Razor Pages 中的 Razor 视图一样,Blazor 组件使用的 Razor 文件可以轻松混合 HTML 和 C#代码。除了构成用户界面部分的 HTML 元素和用于样式的 CSS 之外,Razor 文件还有一个代码块来实现事件处理、属性和其他语句,以提供组件的功能。

例如,一个名为ProgressBar.razor的 Blazor 组件可以使用 Bootstrap 实现进度条。它可能定义了进度条的最小值、最大值和当前值参数,并具有布尔参数以启用动画样式和将当前值以文本形式显示,如下面的标记所示:

<div class="progress">
  <div class="progress-bar progress-bar-striped bg-info
              @(IsAnimated ? " progress-bar-animated" : "")"
       role="progressbar" aria-label="@LabelText" style="width: @Value%"
       aria-valuenow="@Value" aria-valuemin="@Minimum" aria-valuemax="@Maximum">
    @(ShowValue ? Value + "%" : "")
  </div>
</div>
@code {
  [Parameter]
  public int Value { get; set; } = 0;
  [Parameter]
  public int Minimum { get; set; } = 0;
  [Parameter]
  public int Maximum { get; set; } = 100;
  [Parameter]
  public bool IsAnimated { get; set; } = false;
  [Parameter]
  public bool ShowValue { get; set; } = false;
  [Parameter]
  public string? LabelText { get; set; } = "Progress bar";
} 

要在页面上嵌入组件实例,您可以使用组件名称,就像它是 HTML 元素一样,并使用 HTML 属性设置其参数,如下面的标记所示:

<ProgressBar Value="25" IsAnimated="true" ShowValue="true" 
             LabelText="Progress of database deletion" /> 

Blazor 路由到页面组件

App.razor文件中的Router组件使路由到组件成为可能,如下面的标记所示:

<Router AppAssembly="@typeof(Program).Assembly">
  <Found Context="routeData">
    <RouteView RouteData="@routeData" 
               DefaultLayout="@typeof(Layout.MainLayout)" />
    <FocusOnNavigate RouteData="@routeData" Selector="h1" />
  </Found>
</Router> 

Router组件在其AppAssembly参数中特别扫描带有[Route]属性的组件,注册它们的 URL 路径。

如果找到路由匹配项,则请求的上下文将存储在名为routeData的变量中,并将其传递给匹配的 Razor 文件。默认布局设置为使用名为MainLayout.razor的文件中定义的类。

FocusOnNavigate 组件有一个 Selector 属性,必须设置为有效的 CSS 选择器。这可以是一个标签选择器,如默认的 h1,或者一个更具体的 CSS 选择器,它使用 CSS 类或 ID。该设置适用于您应用中的所有组件,因此您需要设置一个适用于所有组件的选择器。在 Razor 文件中,焦点设置在第一个 <h1> 元素上。如果 Razor 文件包含表单,那么您可能希望将第一个表单输入元素(如文本框)设置为具有焦点。

例如,在一个典型的 ASP.NET Core MVC 项目中,MVC 控制器可以装饰 [Route] 属性,如下面的代码所示:

[Route("customers")]
public class CustomersController
{ 

对相对路径 /customers 的 HTTP GET 请求将与该路由匹配。

要创建一个等效的路由页面组件,将 @page 指令添加到组件的 .razor 文件顶部,如下所示,高亮显示的标记:

@page "customers" 

页面组件可以包含多个 @page 指令以注册多个路由。

如果您编写使用反射来查找从 Razor 标记文件为您生成的 component 类的代码,那么您会发现它由于 @page 指令而被 [Route] 属性装饰。

在运行时,页面组件将与您指定的任何特定布局合并,就像 MVC 视图或 Razor 页面一样。默认情况下,Blazor 项目模板定义一个名为 MainLayout.razor 的文件作为页面组件的布局。

良好实践:按照惯例,将可路由的页面 Blazor 组件放在 Components\Pages 文件夹中,将非页面组件放在 Components 文件夹中。

如何传递路由参数

Blazor 路由可以包含不区分大小写的命名参数,并且您可以通过将参数绑定到代码块中的属性,使用 [Parameter] 属性最轻松地访问传递的值,如下面的标记所示:

@page "/employees/{country}"
<div>Country parameter as the value: @Country</div>
@code {
  [Parameter]
  public string Country { get; set; }
} 

处理缺失默认值的参数的推荐方法是,在路由参数后缀加上 ?,在 OnParametersSet 方法中使用空合并运算符,如下所示,高亮显示的标记:

@page "/employees/{country**?**}"
<div>Country parameter as the value: @Country</div>
@code {
  [Parameter]
  public string**?** Country { get; set; }
**protected****override****void****OnParametersSet****()**
 **{**
**// if the automatically set property is null**
**// set its value to USA**
 **Country = Country ?? "USA";**
 **}**
} 

从查询字符串设置参数

您还可以使用查询字符串中的参数设置组件属性,如下面的代码所示:

[Parameter]
[SupplyParameterFromQuery(Name = "country")]
public string? Country { get; set; } 

参数的路由约束

路由约束验证传递的参数的数据类型是否正确。如果具有参数值的潜在请求违反了约束,则不会对该路由进行匹配,而是评估其他路由。如果没有路由匹配,则返回 404 状态码。

如果您不设置约束,则任何值都可作为路由匹配接受,但在将值转换为 C# 方法的预期数据类型时可能会引发数据类型转换异常。一些路由约束示例在 表 15.1 中显示:

约束示例 描述
{isanimated:bool} IsAnimated属性必须设置为有效的布尔值,例如,TRUEtrue
{hiredate:datetime} HireDate属性必须是一个有效的日期/时间值。
{price:decimal} UnitPrice属性必须是一个有效的decimal值。
{shipweight:double} ShipWeight属性必须是一个有效的double值。
{shipwidth:float} ShipWidth属性必须是一个有效的float值。
{orderid:guid} OrderId属性必须是一个有效的Guid值。
{categoryid:int} CategoryId属性必须是一个有效的int值。
{nanoseconds:long} Nanoseconds属性必须是一个有效的long值。

表 15.1:路由约束示例

良好实践:路由约束假设文化不变,因此你的 URL 不得本地化。例如,始终使用不变文化格式传递日期和时间参数值。

基组件类

基类定义了OnParametersSet方法,这是组件默认继承的,名为ComponentBase,如下面的代码所示:

using Microsoft.AspNetCore.Components;
public abstract class ComponentBase : IComponent, IHandleAfterRender, IHandleEvent
{
  // members not shown
} 

ComponentBase 包含一些你可以调用和重写的有用方法,如表 15.2所示:

方法(s) 描述
InvokeAsync 调用此方法在关联渲染器的同步上下文中执行函数。这避免了在访问共享资源时编写线程同步代码的需求。不允许多个线程同时访问渲染过程。使用InvokeAsync意味着在任何给定时刻只有一个线程将访问组件,这消除了编写线程锁定和同步代码以共享状态的需求。
OnAfterRender,OnAfterRenderAsync 重写这些方法以在组件每次渲染时执行代码。
OnInitialized,OnInitializedAsync 重写这些方法以在组件从渲染树中的父组件接收其初始参数后执行代码。
OnParametersSet,OnParametersSetAsync 重写这些方法以在组件收到参数并将值分配给属性后执行代码。
ShouldRender 重写此方法以指示组件是否应该渲染。
StateHasChanged 调用此方法使组件重新渲染。

表 15.2:ComponentBase 的有用方法

Blazor 布局

Blazor 组件可以像 MVC 视图和 Razor 页面一样拥有共享布局。你需要创建一个.razor组件文件,并显式地从LayoutComponentBase继承,如下面的标记所示:

@inherits LayoutComponentBase
<div>
  ...
  @Body
  ...
</div> 

基类有一个名为Body的属性,你可以在布局的适当位置渲染它。

你可以在App.razor文件及其Router组件中为组件设置默认布局。要为组件显式设置布局,请使用@layout指令,如下面的标记所示:

@page "/employees"
@layout AlternativeLayout
<div>
  ...
</div> 

如何导航 Blazor 路由到页面组件

微软提供了一个名为 NavigationManager 的依赖服务,它理解 Blazor 路由和 NavLink 组件。NavigateTo 方法用于跳转到指定的 URL。

在 HTML 中,您使用 <a> 元素来定义导航链接,如下面的标记所示:

<a href="/employees">Employees</a> 

在 Blazor 中,使用 <NavLink> 组件,如下面的标记所示:

<NavLink href="/employees">Employees</NavLink> 

NavLink 组件比锚点元素更好,因为它会自动将其类设置为 active,如果其 href 与当前位置 URL 匹配。如果您的 CSS 使用不同的类名,则可以在 NavLink.ActiveClass 属性中设置类名。

默认情况下,在匹配算法中,href 是路径 前缀,因此如果 NavLinkhref/employees,如前面的代码示例所示,则它将匹配以下所有路径并将它们全部设置为具有 active 类样式:

/employees
/employees/USA
/employees/UK/London 

为了确保匹配算法只对路径中的所有文本进行匹配(换句话说,只有当整个完整文本匹配时才进行匹配,而不是路径的任何部分匹配时),请将 Match 参数设置为 NavLinkMatch.All,如下面的代码所示:

<NavLink href="/employees" Match="NavLinkMatch.All">Employees</NavLink> 

如果您设置了其他属性,例如 target,它们将被传递到生成的底层 <a> 元素。

CSS 和 JavaScript 隔离

Blazor 组件通常需要提供自己的 CSS 以应用样式或 JavaScript 以执行纯 C# 无法执行的活动,例如访问浏览器 API。为了确保这不会与站点级别的 CSS 和 JavaScript 冲突,Blazor 支持 CSS 和 JavaScript 隔离。

如果您有一个名为 Home.razor 的组件,只需创建一个名为 Home.razor.css 的 CSS 文件。在此文件中定义的样式将覆盖项目中此组件的所有其他样式,但不会覆盖网站的其他部分。

对于 JavaScript 隔离,您不使用与 CSS 相同的命名约定。相反,Blazor 通过使用 JavaScript 模块启用 JavaScript 隔离,这些模块通过 Blazor 的 JavaScript 互操作功能导入,您将在本章后面看到。

您可以在以下链接中了解更多关于 JavaScript 隔离的信息:learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability/call-javascript-from-dotnet#javascript-isolation-in-javascript-modules

构建 Blazor 组件

使用 ASP.NET Core 8,Blazor 引入了一个新的项目模板,以启动一个支持最灵活托管模型和所有渲染模式的程序。它提供了一个基本模板以运行,以及一个 Weather 组件,该组件显示一个包含五行随机温度的表格,使用流式渲染。

检查新的 Blazor 项目模板

首先,我们将创建一个 Blazor Web App 项目并回顾其重要部分:

  1. 使用你喜欢的代码编辑器创建一个新的项目和解决方案,使用 Blazor Web App 项目模板,如下列表所示:

    • 项目模板:Blazor Web App / blazor --interactivity None

    • 项目文件和文件夹:Northwind.Blazor

    • 解决方案文件和文件夹:Chapter15

    • 身份验证类型:无

    • 配置 HTTPS:已选中

    • 交互式渲染模式:无

    • 交互位置:每页/组件

    • 包含示例页面:已选中

    • 不使用顶级语句:已清除

    如果你正在使用 Visual Studio Code 或 JetBrains Rider,请在 Chapter15 文件夹中的命令提示符或终端中输入以下命令:dotnet new blazor --interactivity None -o Northwind.Blazor

    良好实践:我们没有选择使用交互式 WebAssembly 或服务器组件的选项,以便我们可以逐步构建你对 Blazor 的工作原理的知识。在实际项目中,你可能会从一开始就选择这些选项。我们还选择了示例页面,你可能会在实际项目中清除这些页面。

  2. 构建 Northwind.Blazor 项目。

  3. Northwind.Blazor.csproj 中,请注意它与使用 Web SDK 并针对 .NET 8 的 ASP.NET Core 项目相同。

  4. Northwind.Blazor 项目中,在 Program.cs 中,请注意这些语句启用了 ASP.NET Core 服务集合和 HTTP 管道,并添加了具有 Blazor 特定语句的 Razor 组件,然后使用它们,如下所示(代码高亮):

    using Northwind.Blazor.Components;
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    **builder.Services.AddRazorComponents();**
    var app = builder.Build();
    // Configure the HTTP request pipeline.
    if (!app.Environment.IsDevelopment())
    {
      app.UseExceptionHandler("/Error", createScopeForErrors: true);
      // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
      app.UseHsts();
    }
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseAntiforgery();
    **app.MapRazorComponents<App>();**
    app.Run(); 
    
  5. Northwind.Blazor 项目中,展开 Properties 文件夹,打开 launchSettings.json 文件,并将 https 配置的 applicationUrl 设置的端口号更改为 5151 用于 https5152 用于 http,如下所示(设置):

    "applicationUrl": "https://localhost:5151;http://localhost:5152", 
    
  6. 将更改保存到 launchSettings.json 文件。

  7. Northwind.Blazor 项目中,在 Components 文件夹中打开 App.razor,如下所示:

    <!DOCTYPE html>
    <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0" />
      <base href="/" />
      <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
      <link rel="stylesheet" href="app.css" />
      <link rel="stylesheet" href="Northwind.Blazor.styles.css" />
      <link rel="icon" type="image/png" href="favicon.png" />
      <HeadOutlet />
    </head>
    <body>
      <Routes />
      <script src="img/blazor.web.js"></script>
    </body>
    </html> 
    

    注意以下内容:

    • 一个 <HeadOutlet /> Blazor 组件,用于将额外内容注入 <head> 部分。这是所有 Blazor 项目中可用的内置组件之一。

    • 一个 <Routes /> Blazor 组件,用于定义此项目中的自定义路由。此组件可以完全由开发者自定义,因为它当前项目的一部分,在名为 Routes.razor 的文件中。

    • 一个用于 blazor.web.js 的脚本块,该脚本块管理 Blazor 的动态功能与服务器之间的通信,例如在后台下载 WebAssembly 组件,并在稍后从服务器端组件执行切换到客户端组件执行。

  8. Components 文件夹中,在 Routes.razor 文件中,注意 <Router> 为当前程序集中找到的所有 Blazor 组件启用路由,如果找到匹配的路由,则执行 RouteView,这将设置组件的默认布局为 MainLayout 并将任何路由数据参数传递给组件。对于该组件,第一个 <h1> 元素将获得焦点,如下所示代码:

     <Router AppAssembly="@typeof(Program).Assembly">
        <Found Context="routeData">
          <RouteView RouteData="@routeData" 
                     DefaultLayout="@typeof(Layout.MainLayout)" />
          <FocusOnNavigate RouteData="@routeData" Selector="h1" />
        </Found>
      </Router> 
    
  9. Components 文件夹中,在 _Imports.razor 文件中,注意该文件导入了一些有用的命名空间,以便在所有自定义 Blazor 组件中使用。

  10. Components\Layout 文件夹中,注意 MainLayout.razor 定义了用于侧边栏的 <div>,其中包含一个由本项目的 NavMenu.razor 组件文件实现的导航菜单,以及 <main><article> 等 HTML5 元素用于内容,如下所示标记:

    @inherits LayoutComponentBase
    <div class="page">
      <div class="sidebar">
        <NavMenu />
      </div>
      <main>
        <div class="top-row px-4">
          <a href="https://learn.microsoft.com/aspnet/core/" 
             target="_blank">About</a>
        </div>
        <article class="content px-4">
            @Body
        </article>
      </main>
    </div> 
    
  11. Components\Layout 文件夹中,打开 NavMenu.razor,如下所示标记:

    <div class="top-row ps-3 navbar navbar-dark">
      <div class="container-fluid">
        <a class="navbar-brand" href="">Northwind.Blazor</a>
      </div>
    </div>
    <input type="checkbox" title="Navigation menu" class="navbar-toggler" />
    <div class="nav-scrollable" 
         onclick="document.querySelector('.navbar-toggler').click()">
      <nav class="flex-column">
        <div class="nav-item px-3">
          <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
            <span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
          </NavLink>
        </div>
        <div class="nav-item px-3">
          <NavLink class="nav-link" href="weather">
            <span class="bi bi-list-nested-nav-menu" aria-hidden="true">
            </span> Weather
          </NavLink>
        </div>
      </nav>
    </div> 
    

    注意以下事项:

    • NavMenu 组件没有 @page 指令,因为它不使用共享布局或作为页面渲染。

    • 它使用 Bootstrap 提供一个选择菜单,该菜单能够响应式地适应视口的宽度。当没有足够的水平空间时,它将折叠成汉堡菜单,然后访客可以切换导航的开启和关闭。

    • 目前有两个菜单项:首页天气。在本章中我们将添加更多。

  12. Components\Pages 文件夹中,在 Home.razor 文件中,注意 @page 指令配置了根路径的路由以跳转到此页面组件,然后将标题从 world 更改为 Blazor Full Stack,如下所示高亮标记:

    @page "/"
    <PageTitle>Home</PageTitle>
    <h1>Hello, **Blazor Full Stack**!</h1>
    Welcome to your new app. 
    
  13. 启动 Northwind.Blazor 项目,使用其 https 配置文件且不进行调试:

    • 如果你正在使用 Visual Studio 2022,那么在 解决方案资源管理器 中,选择 Northwind.Blazor 项目以使其处于活动状态。在 Visual Studio 2022 工具栏中,选择 https 配置文件作为 启动项目,并选择 Google Chrome 作为 Web 浏览器

    • 如果你正在使用 Visual Studio Code,那么在命令行或终端中,输入以下命令:

      dotnet run --launch-profile https 
      
  14. 在 Chrome 中,注意左侧导航和主页组件,如图 图 15.1 所示:

图 15.1:作为 Blazor 页面组件实现的简单网页

  1. 最后,关闭浏览器并关闭 web 服务器。

使用 Bootstrap 图标

较旧的 Blazor 项目模板包含了所有 Bootstrap 图标。在新的项目模板中,仅使用 可缩放矢量图形 (SVG) 定义了三个图标。让我们看看团队是如何定义这些图标的,然后添加一些供我们使用:

  1. Components\Layout 文件夹中,在名为 NavMenu.razor.css 的 CSS 样式表文件中,找到文本 bi-house,并注意使用 SVG 定义的三个图标,如下所示代码部分显示:

    .bi-house-door-fill-nav-menu {
        background-image: url("data:image/svg+xml,...");
    }
    .bi-plus-square-fill-nav-menu {
        background-image: url("data:image/svg+xml,...");
    }
    .bi-list-nested-nav-menu {
        background-image: url("data:image/svg+xml,...");
    } 
    
  2. 在您喜欢的浏览器中,导航到:icon-sets.iconify.design/bi/,并注意 Bootstrap 图标 拥有 MIT 许可证,并包含超过 2,000 个图标。

  3. 搜索 Bootstrap 图标 框中输入 globe,并注意找到了六个地球图标。

  4. 点击第一个地球图标,滚动到页面底部,并点击 SVG 作为数据:URI 按钮。注意,您可以复制并粘贴此图标的定义以在 CSS 样式表中使用,但您不需要这样做,因为我已经为您创建了一个包含五个图标定义的 CSS 文件,您可以在您的 Blazor 项目中使用这些图标。

  5. 在您喜欢的浏览器中,导航到:github.com/markjprice/apps-services-net8/blob/main/code/Chapter15/Northwind.Blazor/wwwroot/icons.css,下载文件,并将其保存在您自己的项目的 wwwroot 文件夹中。

  6. Components 文件夹中,在 App.razor 组件中,在 <head> 中,添加一个 <link> 元素以引用 icons.css 样式表,如下面的标记所示:

    <link rel="stylesheet" href="icons.css" /> 
    
  7. 保存并关闭文件。

引用 EF Core 类库并注册数据上下文

我们将引用您在 第三章 中创建的 EF Core 模型,使用 EF Core 为 SQL Server 构建实体模型

  1. Northwind.Blazor.csproj 项目文件中,将警告视为错误,并将 Northwind 数据库上下文项目添加为项目引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    Include 路径不得有换行符。

  2. 在命令提示符或终端中,使用 dotnet build 命令构建 Northwind.Blazor 项目。

  3. Components 文件夹中,在 _Imports.razor 中,导入命名空间以使用 EF Core 的异步方法以及与 Northwind 实体模型一起使用,如下面的代码所示:

    @using Microsoft.EntityFrameworkCore @* To use ToListAsync method. *@
    @using Northwind.EntityModels @* To use NorthwindContext and so on. *@ 
    

    在此处导入命名空间意味着我们不需要在 .razor 文件顶部导入它们。_Imports.razor 文件仅适用于 .razor 文件。如果您使用代码背后的 .cs 文件来实现组件代码,那么它们必须单独导入命名空间,或者使用全局使用来隐式导入命名空间。注意静态导入渲染模式类型的语句:@using static Microsoft.AspNetCore.Components.Web.RenderMode

  4. Program.cs 中,导入命名空间以使用 AddNorthwindContext 扩展方法,如下面的代码所示:

    using Northwind.EntityModels; // To use AddNorthwindContext method. 
    
  5. 在添加服务到容器部分的区域,添加一个语句将 NorthwindContext 注册为服务,如下面的代码所示:

    builder.Services.AddNorthwindContext(); 
    

构建用于数据的静态服务器端渲染组件

接下来,我们将添加一个组件,它可以完成与传统 ASP.NET Core 网站中的 Razor 页面或 Razor 视图相同的工作。它不会有任何需要组件在服务器或客户端上执行的交互性。

这将允许访问者查看来自 Northwind 数据库的产品表:

  1. Components\Pages文件夹中,添加一个名为Products.razor的新文件。在 Visual Studio 2022 中,项目项模板命名为Razor Component。在 JetBrains Rider 中,项目项模板命名为Blazor Component

    良好实践:组件文件名必须以大写字母开头,否则将出现编译错误!

  2. Products.razor中,设置路由到/products,注入 Northwind 数据上下文,定义一个表格以渲染产品,并编写代码块以在页面初始化时获取产品,如下所示,代码标记:

    @page "/products"
    @inject NorthwindContext db
    <h1>Products</h1>
    <table class="table">
      <thead>
        <tr>
          <th>Product ID</th>
          <th>Product Name</th>
          <th>Unit Price</th>
        </tr>
      </thead>
      <tbody>
        @if ((products is null) || (products.Count == 0))
        {
          <tr><td colspan="4">No products found.</td></tr>
        }
        else
        {
          @foreach (Product p in products)
          {
            <tr>
              <td>@p.ProductId</td>
              <td>@p.ProductName</td>
              <td>@(p.UnitPrice.HasValue ? 
                p.UnitPrice.Value.ToString("C") : "n/a")</td>
            </tr>
          }
        }
      </tbody>
    </table>
    @code {
      private List<Product>? products;
      protected override async Task OnInitializedAsync()
      {
        products = await db.Products.ToListAsync();
      }
    } 
    
  3. Components\Layout文件夹中的NavMenu.razor,在导航到主页的菜单项之后,添加一个导航到产品页面的菜单项,如下所示,代码标记:

    <div class="nav-item px-3">
      <NavLink class="nav-link" href="products">
        <span class="bi bi-globe" aria-hidden="true"></span> Products
      </NavLink>
    </div> 
    
  4. 如果您的数据库服务器没有运行(例如,因为您正在 Docker、虚拟机或云中托管它),那么请确保启动它。

  5. 使用不带调试的https配置启动Northwind.Blazor项目。

  6. 在左侧导航中点击Products,并注意产品表。

  7. 关闭浏览器并关闭 Web 服务器。

构建具有服务器交互性的组件

接下来,我们将添加一个需要一些交互性的组件,因此我们将启用 Blazer 与 SignalR 一起动态更新浏览器 DOM,实时运行时:

  1. Components文件夹中,添加一个名为Counter.razor的新文件。

  2. Counter.razor中,定义一个标签以渲染计数器数字的当前值,一个按钮以增加它,以及一个代码块以存储当前计数器值和点击事件处理程序,以增加数字,如下所示,代码标记:

    <h3>Counter: @CounterValue</h3>
    <button id="buttonIncrement" @onclick="IncrementCounter"
      class="btn btn-outline-primary">Increment</button>
    @code {
      public int CounterValue { get; set; } = 0;
      public void IncrementCounter()
      {
        CounterValue++;
      }
    } 
    

    注意,此组件不会作为页面使用,因此我们不会用@page指令装饰它或定义组件的路由。它将仅用于嵌入到其他组件中。

  3. Components\Pages文件夹中的Home.razor,在页面底部,渲染计数器组件,如下所示,代码标记:

    <Counter /> 
    
  4. 使用不带调试的https配置启动Northwind.Blazor项目。

  5. 在主页上点击按钮,注意没有任何操作。当您使用带有--interactivity None开关的 Blazor Web App 模板创建项目或将交互渲染模式设置为None时,在 Blazor Web App 项目中不会启用组件交互性。

  6. 关闭浏览器并关闭 Web 服务器。

  7. Program.cs中,在添加 Razor 组件的语句末尾,添加一个调用方法以添加交互式服务器组件的调用,如下所示,代码中高亮显示:

    builder.Services.AddRazorComponents()
     **.AddInteractiveServerComponents();** 
    
  8. Program.cs中,在映射 Razor 组件的语句末尾,添加一个调用方法以添加交互式服务器渲染模式,如下所示,代码中高亮显示:

    app.MapRazorComponents<App>()
     **.AddInteractiveServerRenderMode();** 
    
  9. Components文件夹中的Counter.razor,在文件顶部,添加一个指令以设置渲染模式为交互式服务器,如下所示,代码标记:

    @rendermode InteractiveServer 
    
  10. 使用不带调试的https配置启动Northwind.Blazor项目。

  11. 开发者工具 中,点击 控制台 选项卡,并注意 blazor.web.js 文件建立了一个 WebSocket 连接,如下面的输出所示:

    [2023-10-20T11:25:52.498Z] Information: Normalizing '_blazor' to 'https://localhost:5151/_blazor'.
    [2023-10-20T11:25:52.675Z] Information: WebSocket connected to wss://localhost:5151/_blazor?id=j6Fc0Mbay_jWkZTWfIqs_w. 
    
  12. 开发者工具 中,点击 网络 选项卡,点击 WS 以通过 WebSockets 过滤,然后刷新主页。

  13. 在主页上,点击 Increment 按钮,注意计数器的增加,注意 _blazor?id=... 请求,选择该请求的 _blazor?id=...,然后点击 Initiator 选项卡,并注意发起者是添加到所有页面中的 blazor.web.js 文件,如图 15.2 所示:

图 15.2:Blazor.web.js 向服务器上的 SignalR 发送请求以更新 DOM 以实现实时交互

  1. 关闭浏览器并关闭 web 服务器。

构建 Blazor 进度条组件

在本节中,我们将构建一个组件以提供进度条。它将使用 Bootstrap 类设置浅蓝色调,并提供选项来动画化进度条并显示进度值的当前百分比:

  1. Northwind.Blazor 项目的 Components 文件夹中,添加一个名为 ProgressBar.razor 的新文件。

  2. ProgressBar.razor 中,添加语句以渲染使用 Bootstrap 类定义的进度条 <div> 元素,这些元素具有可绑定参数,设置各种属性,如下所示的高亮标记:

    @rendermode InteractiveServer
    <div class="progress">
      <div class="progress-bar progress-bar-striped bg-info
                  @(IsAnimated ? " progress-bar-animated" : "")"
           role="progressbar" aria-label="@LabelText" 
           style="width: @Value%" aria-valuenow="@Value" 
           aria-valuemin="@Minimum" aria-valuemax="@Maximum">
        @(ShowValue ? Value + "%" : "")
      </div>
    </div>
    @code {
      [Parameter]
      public int Value { get; set; } = 0;
      [Parameter]
      public int Minimum { get; set; } = 0;
      [Parameter]
      public int Maximum { get; set; } = 100;
      [Parameter]
      public bool IsAnimated { get; set; } = false;
      [Parameter]
      public bool ShowValue { get; set; } = false;
      [Parameter]
      public string? LabelText { get; set; } = "Progress bar";
    } 
    
  3. Components\Pages 文件夹中,在 Home.razor 文件的底部,添加语句以定义一个 Bootstrap 行,包含两个等宽的列,并添加一个设置为 25% 的 <ProgressBar> 组件,如下所示的高亮标记:

    <div class="row">
      <div class="col">
        <div class="alert alert-info">
          <h4>Progress of database deletion</h4>
          <ProgressBar Value="25" IsAnimated="true" ShowValue="true" 
                       LabelText="Progress of database deletion" />
        </div>
      </div>
      <div class="col">
        More components coming soon.
      </div>
    </div> 
    
  4. 使用 Northind.Blazor 项目的 https 配置文件启动项目,不进行调试。

  5. 注意显示(模拟!)数据库删除进度的进度条。

  6. 关闭浏览器并关闭 web 服务器。

构建 Blazor 对话框组件

在本节中,我们将构建一个组件,为与网站访客的交互提供一个弹出对话框。它将使用 Bootstrap 类来定义一个按钮,当点击时,会显示一个带有可配置标签的两个按钮的对话框。

默认情况下,Blazor Web App 项目模板使用 Bootstrap 5.1 的本地副本,但仅包含 CSS 部分。我们需要添加一个脚本标签来添加 Bootstrap 的 JavaScript 部分。我们也可以升级到 Bootstrap 的最新版本并使用 CDN 版本。

您可以在以下链接中找到最新的 CDN 链接:getbootstrap.com/docs/5.3/getting-started/introduction/#cdn-links

该组件还将定义两个事件回调,父组件可以处理这些回调以自定义在两个按钮被点击时执行的代码。

  1. App.razor 中,注释掉指向本地 CSS 文件的 <link>,并添加对最新 CDN 版本的引用,如下所示的高亮标记:

    **@***<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />***@**
    **<link rel="stylesheet" href="****https****://cdn.jsdelivr.net/npm/bootstrap@****5.3****.****2****/dist/css/bootstrap.min.css" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">** 
    
  2. App.razor 中,在 Blazor 的 <script> 标签之后,添加一个指向最新 CDN 版本的 <script>,并抑制错误 RZ/BL9992,如下所示:

    <script src="img/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous" suppress-error="BL9992"></script> 
    

    我们抑制了错误 BL9992(也称为 RZ9992),该错误警告说:“脚本标签不应放置在组件内部,因为它们无法动态更新。” 更多信息,请参阅 aka.ms/AAe3qu3

  3. Northwind.Blazor 项目中,在 Components 文件夹中,添加一个名为 DialogBox.razor 的新文件。

  4. DialogBox.razor 文件中,添加语句以渲染使用 Bootstrap 类定义按钮和模态对话框的 <div> 元素,并设置可绑定参数,以及各种属性,如下所示:

    @rendermode InteractiveServer
    <!-- Button to show the dialog box. -->
    <button type="button" class="btn btn-primary" 
            data-bs-toggle="modal" data-bs-target="#dialogBox">
      @DialogTitle
    </button>
    <!-- Dialog box to popup. -->
    <div class="modal fade" id="dialogBox"
         data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" 
         aria-labelledby="dialogBoxLabel" aria-hidden="true">
      <div class="modal-dialog">
        <div class="modal-content">
          <div class="modal-header">
            <h5 class="modal-title" id="dialogBoxLabel">@DialogTitle</h5>
            <button type="button" class="btn-close" 
                    data-bs-dismiss="modal" aria-label="Close"></button>
          </div>
          <div class="modal-body">
            @ChildContent
          </div>
          <div class="modal-footer">
            <button type="button" class="btn btn-primary" 
                    @onclick="OnClickPrimary">
                @PrimaryButtonText
            </button>
            <button type="button" class="btn btn-secondary" 
                    data-bs-dismiss="modal" @onclick="OnClickSecondary">
                @SecondaryButtonText
            </button>
          </div>
        </div>
      </div>
    </div>
    @code {
      [Parameter]
      public string? DialogTitle { get; set; }
      // ChildContent is a special name that is set automatically by any 
      // markup content within the component begin and end elements.
      [Parameter]
      public RenderFragment? ChildContent { get; set; }
      [Parameter]
      public string? PrimaryButtonText { get; set; } = "OK";
      [Parameter]
      public EventCallback<MouseEventArgs> OnClickPrimary { get; set; }
      [Parameter]
      public string? SecondaryButtonText { get; set; } = "Cancel";
      [Parameter]
      public EventCallback<MouseEventArgs> OnClickSecondary { get; set; }
    } 
    

    注意到两个按钮的默认文本值为 OKCancel,它们都有事件回调参数,这些参数将包含作为事件参数传递的鼠标指针信息。此外,注意具有 class="btn-close" 的按钮,它在右上角以 X 按钮的形式视觉上出现,用于关闭对话框。

  5. Components\Pages 文件夹中,在 Home.razor 文件中,靠近文件顶部,添加语句以设置渲染模式为交互式服务器,如下所示:

    @rendermode InteractiveServer 
    
  6. Components\Pages 文件夹中,在 Home.razor 文件中,靠近文件底部,将文本 更多组件即将推出 替换为添加 <DialogBox> 组件的语句,设置两个按钮标签为 ,然后在文件底部,添加一个 Razor 代码块以定义两个点击事件的处理器,输出被点击的按钮和鼠标指针的当前位置,如下所示:

     <div class="col">
     **<DialogBox DialogTitle="Delete Database"** 
     **PrimaryButtonText="Yes" OnClickPrimary="Yes_Click"**
     **SecondaryButtonText="No" OnClickSecondary="No_Click">**
     **Are you sure you want to delete the entire database? Really?**
     **</DialogBox>**
      </div>
    </div>
    **@code {**
    **private****void****Yes_Click****(****MouseEventArgs e****)**
     **{**
     **Console.WriteLine("User clicked 'Primary'** **button** **at** **(****{****0****}, {****1****}****).",**
     **arg0: e.ClientX, arg1: e.ClientY)****;**
     **}**
    **private****void****No_Click****(****MouseEventArgs e****)**
     **{**
     **Console.WriteLine("User clicked 'Secondary'** **button** **at** **(****{****0****}, {****1****}****).",**
     **arg0: e.ClientX, arg1: e.ClientY)****;**
     **}**
    **}** 
    

    <DialogBox></DialogBox> 元素之间的任何内容都将自动设置为 ChildContent 属性。

  7. 使用其 https 配置文件启动 Northwind.Blazor 项目,不进行调试。

  8. 点击 删除数据库 按钮,并注意弹出的模态对话框,如图 15.3 所示:

图 15.3:使用 Bootstrap 构建的 Blazor 组件的弹出模态对话框

  1. 将命令提示符或终端窗口和浏览器窗口排列,以便您可以看到两者。

  2. 删除数据库 对话框中,点击 按钮和 按钮几次(点击 按钮或 x 按钮将关闭对话框,因此再次点击 删除数据库 按钮以重新显示对话框),并注意写入控制台的消息,如图 15.4 所示:

    图 15.4:写入服务器控制台的对话框组件

    客户端的 JavaScript 显示 Bootstrap 对话框。按钮点击将触发通过 WebSocket 的 SignalR 连接以执行服务器端组件事件处理代码。

  3. 关闭浏览器,并关闭 web 服务器。

你可以在以下链接中了解更多关于支持的事件参数:learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling#event-arguments

构建 Blazor 警报组件

在本节中,我们将构建一个组件,用于向网站访客显示消息警报。它将使用 Bootstrap 类定义一个多彩的区域来显示消息,可以取消显示。消息、标题、图标和颜色主题可以配置:

  1. Northwind.Blazor 项目中,在 Components 文件夹中,添加一个名为 Bootstrap.Constants.cs 的新文件。

  2. Bootstrap.Constants.cs 文件中,添加语句以定义一些具有 string 常量值的静态类,用于常见的 Bootstrap 颜色主题和图标,如下所示,高亮显示的以下代码:

    namespace Northwind.Blazor.Components;
    public static class BootstrapColors
    {
      public const string Primary = "primary";
      public const string Secondary = "secondary";
      public const string Danger = "danger";
      public const string Warning = "warning";
      public const string Success = "success";
      public const string Info = "info";
    }
    public static class BootstrapIcons
    {
      public const string Globe = "bi bi-globe";
      public const string GlobeEmea = "bi bi-globe-europe-africa";
      public const string Pencil = "bi bi-pencil";
      public const string Trash = "bi bi-trash";
      public const string PlusSquare = "bi bi-plus-square";
      public const string InfoCircle = "bi bi-info-circle";
      public const string ExclamationTriangleFill =
        "bi bi-exclamation-triangle-fill";
    } 
    
  3. Components 文件夹中,添加一个名为 Alert.razor 的新文件。

  4. Alert.razor 文件中,添加语句以渲染使用 Bootstrap 类定义的 <div> 元素,这些元素具有可绑定参数,设置各种属性,如下所示,高亮显示的以下标记:

    @rendermode InteractiveServer
    <div class="alert alert-@ColorTheme d-flex align-items-center
         @(IsDismissable ? " alert-dismissible fade show" : "")" role="alert">
      <div>
        <h4 class="alert-heading"><span class="@Icon" aria-hidden="true">
          </span> @Title</h4>
        @Message
        @if (IsDismissable)
        {
          <button type="button" class="btn-close" 
                  data-bs-dismiss="alert" aria-label="Close"></button>
        }
      </div>
    </div>
    @code {
      [Parameter]
      public bool IsDismissable { get; set; } = true;
      [Parameter]
      public string ColorTheme { get; set; } = BootstrapColors.Primary;
      [Parameter]
      public string Icon { get; set; } = BootstrapIcons.InfoCircle;
      [Parameter]
      public string? Title { get; set; }
      [Parameter]
      public string? Message { get; set; }
    } 
    
  5. Components\Pages 文件夹中,在 Home.razor 文件中,在 <DialogBox> 元素下方添加一个 Alert 元素,如下所示,高亮显示的以下标记:

    <Alert IsDismissable="true" 
           Icon="@(BootstrapIcons.ExclamationTriangleFill)" 
           ColorTheme="@(BootstrapColors.Warning)" 
           Title="Warning" 
           Message="Deleting the database cannot be undone." /> 
    
  6. 启动 Northwind.Blazor 项目,使用其 https 配置文件,不进行调试。

  7. 在主页上,注意警告警报,如图 15.5 所示:

15.5:具有取消按钮的警报组件

  1. 点击关闭按钮以取消警告。

  2. 关闭浏览器,并关闭网络服务器。

构建 Blazor 数据组件

在本节中,我们将构建一个组件,该组件将列出、创建和编辑 Northwind 数据库中的员工。

我们将分几个步骤来构建它:

  1. 创建一个 Blazor 组件,用于渲染作为参数设置的员工名称。

  2. 使其既作为一个可路由页面,也作为一个组件。

  3. 构建并调用 ASP.NET Core 最小 API 网络服务。

  4. 在组件中调用网络服务。

制作组件

我们将向现有的 Blazor 项目添加新组件:

  1. Northwind.Blazor 项目中,在 Components\Pages 文件夹中,添加一个名为 Employees.razor 的新文件。

  2. 添加语句以输出 Employees 组件的标题并定义一个代码块,定义一个属性来存储国家名称,如下所示,高亮显示的以下标记:

    @rendermode InteractiveServer
    <h1>Employees **@(****string****.IsNullOrWhiteSpace(Country)**
     **? "Worldwide" : "****in** **" + Country)**</h1>
    @code {
     **[****Parameter****]**
    **public****string****? Country {** **get****;** **set****; }**
    } 
    
  3. Components\Pages 文件夹中,在 Home.razor 文件中,在欢迎信息之后,实例化 Employees 组件两次:一次将 USA 设置为 Country 参数,一次不设置国家,如下所示,高亮显示的以下标记:

    <h1>Hello, Blazor Full Stack!</h1>
    Welcome to your new app.
    **<****Employees****Country****=****"USA"** **/>**
    **<****Employees** **/>** 
    
  4. 启动 Northwind.Blazor 项目,使用其 https 配置文件,不进行调试。

  5. 启动 Chrome,导航到 https://localhost:5151/,并注意 Employees 组件,如图 15.6 所示:

15.6:设置国家参数为 USA 和未设置参数的 Employees 组件

  1. 关闭浏览器,并关闭 Web 服务器。

将组件转换为可路由页面组件

将此组件转换为具有国家路由参数的可路由页面组件很简单:

  1. Components\Pages文件夹中的Home.razor组件中,删除两个<Employee>元素,因为我们现在将它们用作页面。

  2. Components\Pages文件夹中的Employees.razor组件中,在文件顶部添加一个语句将/employees注册为其路由,带有可选的国家路由参数,如下所示:

    @rendermode InteractiveServer
    **@page "/employees/{country?}"** 
    
  3. Components\Layout文件夹中的NavMenu.razor中,在Products之后添加列表项元素以导航显示全球员工以及在美国或英国,如下所示:

    <div class="nav-item px-3">
      <NavLink class="nav-link" href="employees" Match="NavLinkMatch.All">
        <span class="bi bi-globe" aria-hidden="true"></span> Worldwide
      </NavLink>
    </div>
    <div class="nav-item px-3">
      <NavLink class="nav-link" href="employees/USA">
        <span class="bi bi-people" aria-hidden="true"></span> Employees in USA
      </NavLink>
    </div>
    <div class="nav-item px-3">
      <NavLink class="nav-link" href="employees/UK">
        <span class="bi bi-person" aria-hidden="true"></span> Employees in UK
      </NavLink>
    </div> 
    
  4. 启动Northwind.Blazor项目,使用其https配置文件且不进行调试。

  5. 启动 Chrome 并导航到https://localhost:5151/

  6. 在左侧导航菜单中,点击Employees in USA。注意国家名称正确传递给了页面组件,并且该组件使用与其他页面组件相同的共享布局,如Home.razor。还要注意 URL:https://localhost:5151/employees/USA

  7. 关闭 Chrome,并关闭 Web 服务器。

通过构建 Web 服务将实体放入组件中

现在您已经看到了实体组件的最小实现,我们可以添加从服务器获取实体功能。在这种情况下,我们将使用 Northwind 数据库上下文从数据库中获取员工并将其作为 ASP.NET Core Minimal APIs Web 服务公开:

  1. 使用您喜欢的代码编辑器将项目添加到Chapter15解决方案中,如下列表所示:

    • 项目模板: ASP.NET Core Web API / webapi

    • 解决方案文件和文件夹: Chapter15

    • 项目文件和文件夹: Northwind.MinimalApi.Service

    • 认证类型: 无

    • 配置为 HTTPS: 已选择

    • 启用 Docker: 已清除

    • 启用 OpenAPI 支持: 已选择

    • 不要使用顶级语句: 已清除

    • 使用控制器: 已清除

  2. 将项目引用添加到您在第三章中创建的 Northwind 数据库上下文项目,用于 SQL Server,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\..\Chapter03\Northwind.Common.DataContext
    .SqlServer\Northwind.Common.DataContext.SqlServer.csproj" />
    </ItemGroup> 
    

    路径不能有换行符。如果您没有完成第三章中创建类库的任务,请从 GitHub 存储库下载解决方案项目。

  3. 在项目文件中,将不变全球化设置为false,并将警告视为错误,如下所示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <InvariantGlobalization>**false**</InvariantGlobalization>
     **<TreatWarningsAsErrors>****true****</TreatWarningsAsErrors>**
      </PropertyGroup> 
    
  4. 在命令提示符或终端中,构建Northwind.MinimalApi.Service项目以确保当前解决方案之外的实体模型类库项目被正确编译,如下所示:

    dotnet build 
    
  5. Properties文件夹中的launchSettings.json中,将名为https的配置文件的applicationUrl修改为使用端口5153,将http修改为使用端口5154,如下配置中高亮显示:

    "profiles": {
      ...
     **"https"****:****{**
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "launchUrl": "swagger",
     **"applicationUrl"****:** **"https****:****//localhost:5153;http://localhost:5154",**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        } 
    
  6. Program.cs 文件中,导入用于处理 Minimal APIs 属性的命名空间,注册 Northwind 数据库上下文扩展方法,并序列化 JSON,如下面的代码所示:

    using Microsoft.AspNetCore.Mvc; // To use [FromServices].
    using Northwind.EntityModels; // To use AddNorthwindContext.
    using System.Text.Json.Serialization; // To use ReferenceHandler.
    // Define an alias for the JsonOptions class.
    using HttpJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; 
    
  7. Program.cs 文件中,在配置服务的部分末尾,在调用 Build 之前,添加一条语句来配置 Northwind 数据库上下文和已注册的依赖服务 JSON 选项,设置其引用处理器以保留引用,这样就不会因为循环引用而导致运行时异常,如下面的代码所示:

    builder.Services.AddNorthwindContext();
    builder.Services.Configure<HttpJsonOptions>(options =>
    {
      // If we do not preserve references then when the JSON serializer
      // encounters a circular reference it will throw an exception.
      Options.SerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
    }); 
    

    请注意配置 Microsoft.AspNetCore.Http.Json.JsonOptions 而不是 Microsoft.AspNetCore.Mvc.JsonOptions!我已经创建了一个别名来明确这一点,因为我们还需要导入其他类型的 Microsoft.AspNetCore.Mvc 命名空间。

  8. Program.cs 文件中,在调用 app.Run() 方法之前,添加语句来定义一些用于 GETPOST 员工的端点,如下面的代码所示:

    app.MapGet("api/employees", (
      [FromServices] NorthwindContext db) => 
        Results.Json(db.Employees))
      .WithName("GetEmployees")
      .Produces<Employee[]>(StatusCodes.Status200OK);
    app.MapGet("api/employees/{id:int}", (
      [FromServices] NorthwindContext db,
      [FromRoute] int id) =>
      {
        Employee? Employee = db.Employees.Find(id);
        if (employee == null)
        {
          return Results.NotFound();
        }
        else
        {
          return Results.Json(employee);
        }
      })
      .WithName("GetEmployeesById")
      .Produces<Employee>(StatusCodes.Status200OK)
      .Produces(StatusCodes.Status404NotFound);
    app.MapGet("api/employees/{country}", (
      [FromServices] NorthwindContext db,
      [FromRoute] string country) =>
        Results.Json(db.Employees.Where(employee => 
        employee.Country == country)))
      .WithName("GetEmployeesByCountry")
      .Produces<Employee[]>(StatusCodes.Status200OK);
    app.MapPost("api/employees", async ([FromBody] Employee employee,
      [FromServices] NorthwindContext db) =>
      {
        db.Employees.Add(employee);
        await db.SaveChangesAsync();
        return Results.Created($"api/employees/{employee.EmployeeId}", employee);
      })
      .Produces<Employee>(StatusCodes.Status201Created); 
    
  9. 可选地,删除设置 weather 端点的语句。

    由于 {id:int} 约束,对类似 api/employees/3 的路径的 GET 请求将映射到 GetEmployeesById 端点,而对类似 api/employess/USA 的路径的 GET 请求将映射到 GetEmployeesByCountry 端点。当向 api/employees 端点 POST 时,响应将包括一个指向新创建的员工及其数据库分配的 ID 的 URL。

通过调用 web 服务将实体获取到组件中

现在,我们可以向实体组件添加功能以调用 web 服务:

  1. Northwind.Blazor 项目的项目文件中,添加一个对 QuickGrid 的包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include=
        "Microsoft.AspNetCore.Components.QuickGrid" Version="8.0.0" />
    </ItemGroup> 
    
  2. 构建项目以还原包。

  3. Program.cs 文件中,导入用于处理 HTTP 标头的命名空间,如下面的代码所示:

    using System.Net.Http.Headers; // To use MediaTypeWithQualityHeaderValue. 
    
  4. Northwind.Blazor 项目中,在 Program.cs 文件中,在调用 builder.Build() 之前,添加语句来配置一个 HTTP 客户端工厂以调用 web 服务,如下面的代码所示:

    builder.Services.AddHttpClient(name: "Northwind.Blazor.Service",
      configureClient: options =>
      {
        options.BaseAddress = new("https://localhost:5153/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
            "application/json", 1.0));
      }); 
    
  5. Components 文件夹中,在 _Imports.razor 文件中,导入与 QuickGrid 和序列化 JSON 相关的命名空间,确保我们构建的 Blazor 组件不需要单独导入这些命名空间,如下面的标记所示:

    @using Microsoft.AspNetCore.Components.QuickGrid
    @using System.Text.Json @* To use JsonSerializerOptions. *@
    @using System.Text.Json.Serialization @* To use ReferenceHandler. *@ 
    
  6. Components\Pages 文件夹中,在 Employees.razor 文件中,添加语句以注入 HTTP 客户端工厂,然后使用它来输出所有员工或特定国家的员工网格,如下面高亮显示的代码所示:

    @rendermode InteractiveServer
    @page "/employees/{country?}"
    `@inject IHttpClientFactory httpClientFactory`
    <h1>
      Employees @(string.IsNullOrWhiteSpace(Country) ? "Worldwide" : "in " + Country)
    </h1>
    **<QuickGrid Items="@employees" Class="table table-striped table-bordered">**
     **<PropertyColumn Property="@(emp => emp.EmployeeId)"** 
     **Title="ID" />**
     **<PropertyColumn Property="@(emp => emp.FirstName)" />**
     **<PropertyColumn Property="@(emp => emp.LastName)" />**
     **<PropertyColumn Property="@(emp => emp.City)" />**
     **<PropertyColumn Property="@(emp => emp.Country)" />**
     **<PropertyColumn Property="@(emp => emp.HireDate)"** 
     **Format="yyyy-MM-dd" />**
    **</QuickGrid>**
    @code {
      [Parameter]
      public string? Country { get; set; }
     **// QuickGrid works best if it binds to an IQueryable<T> sequence.**
     **private** **IQueryable<Employee>? employees;**
      **protected override async** **Task** **OnParametersSetAsync**()
      {
     **Employee[]? employeesArray =** **null**;
    **// Employee entity has circular reference to itself so**
     **// we must control how references are handled.**
     **JsonSerializerOptions jsonOptions =** **new****()**
     **{**
     **ReferenceHandler = ReferenceHandler.Preserve,**
     **PropertyNameCaseInsensitive =** **true**
     **};**
     **HttpClient client = httpClientFactory.CreateClient(**
     **"Northwind.Blazor.Service");**
    **string** **path = "api/employees";**
    **try**
     **{**
     **employeesArray =** (**await** **client.GetFromJsonAsync<Employee[]?>(**
     **path, jsonOptions));**
     **}**
    **catch** **(Exception ex)**
     **{**
     **Console.WriteLine($"{ex.GetType()}: {ex.Message}");**
     **}**
    **if** **(employeesArray** **is** **not****null**)
     **{**
     **employees = employeesArray.AsQueryable();**
    **if** **(!****string****.IsNullOrWhiteSpace(Country))**
     **{**
     **employees = employees.Where(emp => emp.Country == Country);**
     **}**
     **}**
     **}**
    **}** 
    

    虽然 web 服务有一个端点允许你只返回指定国家的员工,但稍后我们将为员工添加缓存,因此在这个实现中,我们将请求所有员工并使用客户端过滤来处理绑定的数据网格。

  7. 如果你的数据库服务器没有运行(例如,因为你正在 Docker、虚拟机或云中托管它),那么请确保启动它。

  8. 使用不带调试的 https 配置启动 Northwind.MinimalApi.Service 项目。

  9. 使用不带调试的 https 配置启动 Northwind.Blazor 项目。

  10. 启动 Chrome,并导航到 https://localhost:5151/

  11. 在左侧导航菜单中,点击 美国员工,并注意员工网格是从网络服务加载并在网页中渲染的,如图 图 15.7 所示:

图 15.7:美国员工网格

  1. 在左侧导航菜单中,点击 英国员工,并注意员工网格被过滤以仅显示英国员工。

  2. 关闭 Chrome,并关闭网络服务器。

练习和探索

通过回答一些问题、进行一些动手实践,以及更深入地研究本章主题来测试你的知识和理解。

练习 15.1 – 测试你的知识

回答以下问题:

  1. 与 Blazor Server 等传统托管模型相比,.NET 8 中的新 Blazor 全栈托管模型有什么好处?

  2. Blazor WebAssembly 是否支持最新 .NET API 的所有功能?

  3. Blazor 组件的文件扩展名是什么?

  4. 你如何设置所有 Blazor 页面组件的默认布局?

  5. 你如何为 Blazor 页面组件注册路由?

  6. 你会在什么情况下将 <NavLink> 组件的 Match 属性设置为 NavLinkMatch.All

  7. 你在 _Imports.razor 文件中导入了一个自定义命名空间,但当你尝试在 Blazor 组件的后台代码文件中使用该命名空间中的类时,找不到该类。为什么?如何修复这个问题?

  8. 你必须对组件类中的属性执行什么操作才能使其自动设置为查询字符串参数?

  9. 什么是 QuickGrid?

  10. Blazor 组件如何访问浏览器功能,如本地存储?

练习 15.2 – 练习构建 Blazor 组件

创建一个名为 Carousel 的 Blazor 组件,它包装 Bootstrap 类以作为组件使用轮播图,然后使用它来显示 Northwind 数据库中的八个类别,包括图片。

你可以在以下链接中了解有关 Bootstrap 轮播图的更多信息:getbootstrap.com/docs/5.3/components/carousel/

练习 15.3 – 练习构建 IndexedDB 互操作服务

浏览器本地和会话存储适用于存储少量数据,但如果你需要在浏览器中拥有更强大和功能丰富的存储,则可以使用 IndexedDB API。

创建一个名为 IndexedDbService 的 Blazor 服务,使用 JavaScript 模块进行互操作,使用 IndexedDB API,然后使用它来缓存员工。

你可以在以下链接中了解更多关于 window.indexedDB 对象的方法:developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API

练习 15.4 – 探索主题

使用以下页面上的链接了解更多关于本章涵盖主题的详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-15---building-web-components-using-blazor

练习 15.5 – 探索 Blazor WebAssembly 主题

要了解更多关于特定于 Blazor WebAssembly 项目的主题,我已编写了一个仅在网络上可用的部分,可在以下链接找到:github.com/markjprice/apps-services-net8/blob/main/docs/ch15-blazor-webassembly.md.

练习 15.6 – 使用 Blazor 探索渐进式 Web 应用

要了解更多关于特定于 Blazor PWA 支持的主题,我已编写了一个仅在网络上可用的部分,可在以下链接找到:github.com/markjprice/apps-services-net8/blob/main/docs/ch15-blazor-pwa.md.

练习 15.7 – 利用开源 Blazor 组件库

要了解如何使用一些常见的 Blazor 开源组件,我已编写了一个仅在网络上可用的部分,可在以下链接找到:github.com/markjprice/apps-services-net8/blob/main/docs/ch15-blazor-libraries.md.

摘要

在本章中,你学习了:

  • 关于 Blazor 的一些重要概念,如托管模型、组件、路由以及如何传递参数。

  • 如何使用可设置参数、子内容和自定义事件构建 Blazor 组件。

  • 如何构建从网络服务获取数据的 Blazor 组件。

在下一章中,你将学习如何使用 .NET MAUI 为移动和桌面设备构建跨平台应用。

在 Discord 上了解更多

要加入本书的 Discord 社区 – 在那里你可以分享反馈、向作者提问,并了解新版本 – 请扫描下面的二维码:

packt.link/apps_and_services_dotnet8

第十六章:使用.NET MAUI 构建移动和桌面应用程序

本章是关于通过构建适用于 iOS 和 Android、macOS Catalyst 和 Windows 的跨平台移动和桌面应用程序来学习如何制作图形用户界面(GUI)应用程序。根据 MAUI 团队的说法,.NET 7 和.NET 8 之间没有破坏性 API 更改。他们主要专注于修复错误和改进性能。

您将看到可扩展应用程序标记语言(XAML)如何使定义图形应用程序的用户界面(UI)变得容易。XAML 发音为“zamel”。

跨平台 GUI 开发不能仅在一百多页中学会,但我希望向您介绍一些可能的内容。想想这个.NET MAUI 章节和仅在线提供的附加部分,它们将为您提供一个入门介绍,以激发您的兴趣,然后您可以从专门针对移动或桌面开发的书籍中学习更多。

该应用程序将允许列出和管理 Northwind 数据库中的客户。您创建的移动应用程序将调用 ASP.NET Core Minimal APIs Web 服务。我们将从本章开始构建它,然后在仅在线提供的部分,即“实现.NET MAUI 的模型-视图-视图模型(MVVM)”,继续构建应用程序,您可以在本章末尾的练习中找到它。

拥有 Visual Studio 2022 版本 17.8 或更高版本的 Windows 计算机,或者任何具有 Visual Studio Code 和dotnet CLI 或 JetBrains Rider 的操作系统,都可以用来创建.NET MAUI 项目。但您需要一个装有 Windows 的计算机来编译 WinUI 3 应用程序,并且您需要一个装有 macOS 和 Xcode 的计算机来编译 macOS Catalyst 和 iOS。

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

  • 理解 XAML

  • 理解.NET MAUI

  • 使用.NET MAUI 构建移动和桌面应用程序

  • 使用共享资源

  • 使用数据绑定

理解 XAML

让我们从.NET MAUI 使用的标记语言开始看起。

在 2006 年,微软发布了Windows Presentation FoundationWPF),这是第一个使用 XAML 的技术。随后,用于网页和移动应用的 Silverlight 也迅速推出,但微软已不再支持它。WPF 至今仍被用于创建 Windows 桌面应用程序;例如,Visual Studio 2022 就是部分使用 WPF 构建的。

XAML 可以用于构建以下应用的某些部分:

  • .NET MAUI 应用程序适用于移动和桌面设备,包括 Android、iOS、Windows 和 macOS。它是名为Xamarin.Forms的技术的一种演变。

  • WinUI 3 应用程序适用于 Windows 10 和 11。

  • 通用 Windows 平台(UWP)应用适用于 Windows 10 和 11、Xbox、混合现实和 Meta Quest VR 头戴式设备。

  • WPF 应用程序用于 Windows 桌面,包括 Windows 7 及更高版本。

  • 使用跨平台第三方技术的AvaloniaUno Platform应用。

使用 XAML 简化代码

XAML 简化了 C#代码,尤其是在构建用户界面(UI)时。

假设你需要两个或更多水平排列的粉色按钮来创建工具栏,当点击时执行它们的实现方法。

在 C# 中,你可能编写以下代码:

HorizontalStackPanel toolbar = new();
Button newButton = new();
newButton.Content = "New";
newButton.Background = new SolidColorBrush(Colors.Pink);
newButton.Clicked += NewButton_Clicked;
toolbar.Children.Add(newButton);
Button openButton = new();
openButton.Content = "Open";
openButton.Background = new SolidColorBrush(Colors.Pink);
openButton.Clicked += OpenButton_Clicked;
toolbar.Children.Add(openButton); 

在 XAML 中,这可以简化为以下几行代码。当处理此 XAML 时,等效的属性会被设置,并调用方法以实现与前面 C# 代码相同的目标:

<HorizontalStackPanel x:Name="toolbar">
  <Button x:Name="newButton" Background="Pink" 
          Clicked="NewButton_Clicked">New</Button>
  <Button x:Name="openButton" Background="Pink" 
          Clicked="OpenButton_Clicked">Open</Button>
</StackPanel> 

你可以将 XAML 视为一个替代且更简单的声明和实例化 .NET 类型的途径,尤其是在定义 UI 及其使用的资源时。

XAML 允许在不同级别声明资源,如 UI 元素或页面,或全局应用于应用程序以实现资源共享。

XAML 允许在 UI 元素之间或 UI 元素与对象和集合之间进行数据绑定。

如果你选择在编译时使用 XAML 定义你的 UI 和相关资源,那么代码后文件必须在页面构造函数中调用 InitializeComponent 方法,如下面高亮显示的代码所示:

public partial class MainPage : ContentPage
{
**public****MainPage****()**
 **{**
 **InitializeComponent();** **// Process the XAML markup.**
 **}**
  private void NewButton_Clicked(object sender, EventArgs e)
  {
    ...
  }
  private void OpenButton_Clicked(object sender, EventArgs e)
  {
    ...
  }
} 

调用 InitializeComponent 方法告诉页面读取其 XAML,创建其中定义的控件,并设置它们的属性和事件处理器。

.NET MAUI 命名空间

.NET MAUI 有几个重要的命名空间,其中定义了其类型,如 表 16.1 所示:

命名空间 描述
Microsoft.Maui FlowDirectionIButtonIImageThickness 之类的实用类型。
Microsoft.Maui.Controls 常用控件、页面和相关类型,如 ApplicationBrushButtonCheckBoxContentPageImageVerticalStackPanel
Microsoft.Maui.Graphics 用于图形的类型,如 ColorFontImageFormatPathBuilderPointSize

表 16.1:重要的 MAUI 命名空间

要使用 XAML 导入命名空间,在根元素中添加 xmlns 属性。一个命名空间被导入为默认值,其他命名空间必须使用前缀命名。

例如,.NET MAUI 类型默认导入,因此元素名称不需要前缀;通用 XAML 语法使用 x 前缀导入,用于执行命名控件或 XAML 将编译成的类名等常见操作。你的项目类型通常使用 local 前缀导入,如下面的标记所示:

<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
             xmlns:local="clr-namespace:MyMauiApp.Controls"
             x:Class="MyMauiApp.MainPage"
             ...>
  <Button x:Name="NewFileButton" ...>New File</Button>
  <local:CustomerList x:Name="CustomerList" ... />
  ...
</ContentPage> 

在上面的示例中,项目名为 MyMauiApp,其控件如 CustomerList 控件定义在名为 MyMauiApp.Controls 的命名空间中。此命名空间已使用前缀 local 进行注册,因此当需要 CustomerList 控件的实例时,它使用 <local:CustomerList> 进行声明。

你可以根据需要导入带有不同前缀的命名空间。

类型转换器

类型转换器将必须设置为 string 类型的 XAML 属性值转换为其他类型。例如,以下按钮的 Background 属性设置为 string"Pink"

<Button x:Name="newButton" Background="Pink" ... 

这通过类型转换器转换为 SolidColorBrush 实例,如下面的等效代码所示:

newButton.Background = new SolidColorBrush(Colors.Pink); 

.NET MAUI 提供了许多类型转换器,你可以创建并注册自己的。这些对于自定义数据可视化特别有用。

在 .NET MAUI 控件之间进行选择

有许多预定义的控件可供选择,用于常见的 UI 场景。.NET MAUI(以及大多数 XAML 方言)支持这些控件,如 表 16.2 所示:

Controls 描述
Button, ImageButton, MenuItem, ToolbarItem 执行操作
CheckBox, RadioButton, Switch 选择选项
DatePicker, TimePicker 选择日期和时间
CollectionView, ListView, Picker, TableView 从列表和表中选择项目
CarouselView, IndicatorView 每次显示一个项目的滚动动画视图
AbsoluteLayout, BindableLayout, FlexLayout, Grid, HorizontalStackLayout, StackLayout, VerticalStackLayout 以不同方式影响其子项的布局容器
Border, BoxView, Frame, ScrollView 视觉元素
Ellipse, Line, Path, Polygon, Polyline, Rectangle, RoundRectangle 图形元素
ActivityIndicator, Label, ProgressBar, RefreshView 显示只读文本和其他只读显示
Editor, Entry 编辑文本
GraphicsView, Image 嵌入图像、视频和音频文件
Slider, Stepper 在数字范围内选择
SearchBar 添加搜索功能
BlazorWebView, WebView 嵌入 Blazor 和 Web 组件
ContentView 构建自定义控件

表 16.2:MAUI 用户界面控件

.NET MAUI 在 Microsoft.Maui.Controls 命名空间中定义其控件。它还有一些专门的控件:

  • Application: 表示一个跨平台的图形应用程序。它设置根页面,管理窗口、主题和资源,并提供应用级别的事件,如 PageAppearingModalPushingRequestedThemeChanged。它还具有你可以重写的方法来挂钩应用事件,如 OnStartOnSleepOnResumeCleanUp

  • Shell: 一个提供大多数应用程序所需的 UI 功能的 Page 控件,如飞出或标签栏导航、导航跟踪和管理以及导航事件。

大多数 .NET MAUI 控件都从 View 派生。View 派生类型的一个重要特性是它们可以进行嵌套。这允许你构建复杂的自定义用户界面。

标记扩展

为了支持一些高级功能,XAML 使用标记扩展。其中一些最重要的使元素和数据绑定以及资源重用成为可能,如下列所示:

  • {Binding} 将一个元素链接到另一个元素或数据源中的值。

  • {OnPlatform} 根据当前平台设置不同的属性值。

  • {StaticResource}{DynamicResource} 将元素链接到共享资源。

  • {AppThemeBinding} 将元素链接到在主题中定义的共享资源。

.NET MAUI 提供了 OnPlatform 标记扩展,允许您根据平台设置不同的标记。例如,iPhone X 及以后的型号在手机显示屏顶部引入了缺口,占据了额外的空间。我们可以为适用于所有设备的应用程序添加额外的填充,但如果我们能只为 iOS 添加额外的填充,那就更好了,如下面的标记所示:

<VerticalStackLayout>
  <VerticalStackLayout.Padding>
    <OnPlatform x:TypeArguments="Thickness">
      <On Platform="iOS" Value="30,60,30,30" />
      <On Platform="Android" Value="30" />
      <On Platform="WinUI" Value="30" />
    </OnPlatform>
  </VerticalStackLayout.Padding> 

也有简化的语法,如下面的标记所示:

<VerticalStackLayout Padding"{OnPlatform iOS='30,60,30,30', Default='30'}"> 

理解 .NET MAUI

要创建一个仅需要在 iPhone 上运行的应用程序,您可能会选择使用 Objective-C 或 Swift 语言以及 UIKit 库,并通过 Xcode 开发工具来构建它。

要创建一个仅需要在 Android 手机上运行的应用程序,您可能会选择使用 Java 或 Kotlin 语言以及 Android SDK 库,并通过 Android Studio 开发工具来构建它。

但如果您需要创建一个可以在 iPhone 和 Android 手机上运行的应用程序呢?如果您只想使用您已经熟悉的编程语言和开发平台创建一次该移动应用程序怎么办?还有,如果您意识到通过稍微增加一些代码来适应桌面尺寸的 UI,您还可以针对 macOS 和 Windows 桌面呢?

.NET MAUI 使开发者能够使用 Catalyst 为 Apple iOS(iPhone)、iPadOS、macOS 构建跨平台移动应用程序,使用 WinUI 3 为 Windows 构建应用程序,以及使用 C# 和 .NET 为 Google Android 构建应用程序,这些应用程序随后被编译成原生 API 并在原生手机和桌面平台上执行。

业务逻辑层代码可以一次编写并在所有平台上共享。UI 交互和 API 在各种移动和桌面平台上有所不同,因此 UI 层有时需要针对每个平台进行自定义。

与 WPF 和 UWP 应用程序一样,.NET MAUI 使用 XAML 为所有平台定义一次 UI,使用特定于平台的 UI 组件的抽象。使用 .NET MAUI 构建的应用程序使用原生平台小部件绘制 UI,因此应用程序的外观和感觉与目标移动平台自然契合。

使用 .NET MAUI 构建的用户体验可能不会像使用针对该平台原生工具自定义构建的应用程序那样完美地适应特定平台,但对于不会拥有数百万用户的移动和桌面应用程序来说,这已经足够好了。通过一些努力,您可以构建出美丽的应用程序,正如微软挑战所展示的那样,您可以在以下链接中了解更多信息:

devblogs.microsoft.com/dotnet/announcing-dotnet-maui-beautiful-ui-challenge/

.NET MAUI 和 Xamarin 支持

.NET MAUI 的主要版本从.NET 7 开始与.NET 一起发货,但作为可选工作负载。这意味着.NET MAUI 不遵循与主要.NET 平台相同的短期支持STS)/长期支持LTS)。每个.NET MAUI 版本只有 18 个月的支持,因此.NET MAUI 实际上始终是一个 STS 版本,这包括随.NET 8 一起作为工作负载发货的.NET MAUI 版本。

.NET MAUI 依赖于其他操作系统,如 iOS 和 macOS,因此会变得复杂。iOS 的主要版本通常在 9 月发布,而 iPadOS 和 macOS 的主要版本通常在 10 月或 11 月稍晚发布。这并不给.NET MAUI 团队太多时间在 11 月初.NET 发布前确保其平台与这些操作系统良好兼容。

警告!Xamarin 将于 2024 年 5 月 1 日达到其生命终结EOL),因此任何 Xamarin 和 Xamarin.Forms 项目都应该在此之前迁移到.NET MAUI 或 Avalonia 或 Uno 等替代方案。

移动优先、云优先的开发工具

移动应用程序通常由云中的服务支持。

微软首席执行官萨蒂亚·纳德拉(Satya Nadella)曾著名地说以下内容:

对我来说,当我们说“移动优先”时,并不是指设备的移动性,而是指个人体验的移动性。[...] 你唯一能够协调这些应用程序和数据移动性的方式是通过云。

在安装 Visual Studio 2022 时,你必须选择位于桌面和移动部分的.NET 多平台应用程序 UI 开发工作负载,如图图 16.1所示:

图 16.1:为 Visual Studio 2022 选择.NET MAUI 工作负载

手动安装.NET MAUI 工作负载

安装 Visual Studio 2022 应该会安装你选择的所需.NET MAUI 工作负载。如果没有,那么你可以手动确保工作负载已安装。

要查看当前安装的工作负载,请输入以下命令:

dotnet workload list 

当前安装的工作负载将显示在表格中,如下所示输出:

Installed Workload Ids      Manifest Version      Installation Source
-----------------------------------------------------------------------
maui-maccatalyst            6.0.486/6.0.400       SDK 7.0.100 

要查看可安装的工作负载,请输入以下命令:

dotnet workload search 

当前可用的工作负载将显示在表格中,如下所示输出:

Workload ID                 Description
------------------------------------------------------------------------------------
android                     .NET SDK Workload for building Android applications.
ios                         .NET SDK Workload for building iOS applications.
maccatalyst                 .NET SDK Workload for building MacCatalyst applications.
macos                       .NET SDK Workload for building macOS applications.
maui                        .NET MAUI SDK for all platforms
maui-android                .NET MAUI SDK for Android
maui-desktop                .NET MAUI SDK for Desktop
maui-ios                    .NET MAUI SDK for iOS
maui-maccatalyst            .NET MAUI SDK for Mac Catalyst
maui-mobile                 .NET MAUI SDK for Mobile
maui-tizen                  .NET MAUI SDK for Tizen
maui-windows                .NET MAUI SDK for Windows
tvos                        .NET SDK Workload for building tvOS applications.
wasi-experimental           workloads/wasi-experimental/description
wasm-experimental           workloads/wasm-experimental/description
wasm-experimental-net7      .NET WebAssembly experimental tooling for net7.0
wasm-tools                  .NET WebAssembly build tools
wasm-tools-net6             .NET WebAssembly build tools for net6.0
wasm-tools-net7             .NET WebAssembly build tools for net7.0 

要安装所有平台的.NET MAUI 工作负载,请在命令行或终端中输入以下命令:

dotnet workload install maui 

要更新所有现有工作负载安装,请输入以下命令:

dotnet workload update 

要添加项目中缺少的工作负载安装,请在包含项目文件的文件夹中输入以下命令:

dotnet workload restore <projectname> 

随着时间的推移,你可能会安装与不同版本的.NET SDK 相关的多个工作负载版本。在.NET 8 之前,开发者尝试手动删除工作负载文件夹,这可能会引起问题。随着.NET 8 的引入,有一个新功能可以删除遗留和不需要的工作负载,如下所示命令:

dotnet workload clean 

更多信息:如果您想使用 Visual Studio 2022 创建 iOS 移动应用程序或 macOS Catalyst 桌面应用程序,那么您可以通过网络连接到Mac 构建主机。有关说明,请参阅以下链接:learn.microsoft.com/en-us/dotnet/maui/ios/pair-to-mac

.NET MAUI 用户界面组件类别

.NET MAUI 包括一些用于构建用户界面的常见控件。它们可以分为四个类别:

  • 页面代表跨平台应用程序屏幕,例如ShellContentPageNavigationPageFlyoutPageTabbedPage

  • 布局代表其他 UI 组件组合的结构,例如GridStackLayoutFlexLayout

  • 视图代表单个用户界面组件,例如CarouselViewCollectionViewLabelEntryEditorButton

  • 单元格代表列表或表格视图中的单个项目,例如TextCellImageCellSwitchCellEntryCell

Shell 控件

Shell控件旨在通过提供标准化的导航和搜索功能来简化应用程序开发。在您的项目中,您将创建一个从Shell控件类继承的类。您的派生类定义了如TabBar之类的组件,它包含Tab项、FlyoutItem实例和ShellContent,它们包含每个页面的ContentPage实例。当只有四到五页需要导航时,应使用TabBar。当有更多项时,应使用FlyoutItem导航,因为它们可以表示为垂直可滚动的列表。您可以使用两者,其中TabBar显示项目的一个子集。Shell将它们保持同步。

Flyout 导航是指从移动设备屏幕的左侧或桌面应用程序主窗口的左侧飞出(或滑动)项目列表。用户通过点击一个带有三个水平线堆叠的“汉堡”图标来调用它。当用户点击飞出项时,其页面在需要时实例化,因为用户在 UI 中导航。

顶部栏在需要时自动显示返回按钮,允许用户导航回上一页。

列表视图控件

ListView控件用于长列表的数据绑定值,这些值类型相同。它可以有标题和页脚,其列表项可以分组。

它包含用于容纳每个列表项的单元格。有两种内置的单元格类型:文本和图像。开发者可以定义自定义单元格类型。

单元格可以具有上下文操作,当在 iPhone 上滑动单元格、在 Android 上长按或桌面操作系统上右键单击时会出现。破坏性的上下文操作可以以红色显示,如下面的标记所示:

<TextCell Text="{Binding CompanyName}" Detail="{Binding Location}">
  <TextCell.ContextActions>
    <MenuItem Clicked="Customer_Phoned" Text="Phone" />
    <MenuItem Clicked="Customer_Deleted" Text="Delete" IsDestructive="True" />
  </TextCell.ContextActions>
</TextCell> 

Entry 和 Editor 控件

EntryEditor控件用于编辑文本值,通常与实体模型属性数据绑定,如下面的标记所示:

<Editor Text="{Binding CompanyName, Mode=TwoWay}" /> 

良好实践:对于单行文本使用Entry。对于多行文本使用Editor

.NET MAUI 处理器

在.NET MAUI 中,XAML 控件在Microsoft.Maui.Controls命名空间中定义。称为处理器的组件将这些常用控件映射到每个平台的原生控件。在 iOS 上,处理器将.NET MAUI 的Button映射到由 UIkit 定义的 iOS 原生UIButton。在 macOS 上,Button映射到由 AppKit 定义的NSButton。在 Android 上,Button映射到 Android 原生AppCompatButton

处理器有一个NativeView属性,它公开了底层的原生控件。这允许你使用平台特定的功能,如属性、方法和事件,并自定义原生控件的所有实例。

编写特定平台的代码

如果你需要编写仅针对特定平台(如 Android)执行的代码语句,则可以使用编译器指令。

例如,默认情况下,Android 上的Entry控件显示下划线字符。如果你想隐藏下划线,你可以编写一些 Android 特定的代码来获取Entry控件的处理器,使用其NativeView属性来访问底层的原生控件,然后将控制该功能的属性设置为false,如下面的代码所示:

#if __ANDROID__
  Handlers.EntryHandler.EntryMapper[nameof(IEntry.BackgroundColor)] = (h, v) =>
  {
    (h.NativeView as global::Android.Views.Entry).UnderlineVisible = false;
  };
#endif 

预定义的编译器常量包括以下内容:

  • __ANDROID__

  • __IOS__

  • WINDOWS

编译器的#if语句语法与 C#的if语句语法略有不同,如下面的代码所示:

#if __IOS__
  // iOS-specific statements
#elif __ANDROID__
  // Android-specific statements
#elif WINDOWS
  // Windows-specific statements
#endif 

现在你已经了解了 MAUI 应用程序的一些重要概念,并且已经设置了 MAUI 所需的附加组件,让我们来实际操作并构建一个 MAUI 项目。

使用.NET MAUI 构建移动和桌面应用程序

我们将为 Northwind 的客户管理构建一个移动和桌面应用程序。

良好实践:如果你有一台 Mac,并且你从未在上面运行过 Xcode,那么现在就运行它,直到你看到开始窗口。这将确保所有必需的组件都已安装并注册。如果你不这样做,那么你的项目可能会出现错误。

创建用于本地应用程序测试的虚拟 Android 设备

要针对 Android,你必须安装至少一个 Android SDK。Visual Studio 2022 的默认安装(已包含移动开发工作负载)已经包含了一个 Android SDK,但它通常是针对尽可能多的 Android 设备设计的较旧版本。

要使用.NET MAUI 的最新功能,你必须配置一个更近期的 Android 虚拟设备:

  1. 在 Windows 中,启动Visual Studio 2022。如果你看到模态对话框欢迎体验,则单击无代码继续

  2. 导航到工具 | Android | Android 设备管理器。如果你被用户账户控制提示允许此应用程序对你的设备进行更改,请单击

  3. Android 设备管理器中,单击+ 新建按钮以创建一个新设备。

  4. 在对话框中,按照图 16.2所示进行以下选择:

    • 基础设备: Pixel 5

    • 处理器: x86_64

    • 操作系统: Android 13.0 – API 33

    • Google APIs: 已选择

    • Google Play Store: 已清除

图 16.2:选择虚拟 Android 设备的硬件和操作系统

  1. 点击 创建

  2. 接受任何许可协议。

  3. 等待任何必要的下载。

  4. Android 设备管理器 中,在设备列表中,在您刚刚创建的设备的行中,点击 启动

  5. 请耐心等待!模拟器启动可能需要几分钟。

  6. 当 Android 设备启动完成后,点击 Chrome 浏览器,通过导航到 www.bbc.co.uk/news 测试它是否有网络访问权限。

  7. 关闭模拟器。

  8. 关闭 Android 设备管理器

  9. 重新启动 Visual Studio 2022 以确保它知道新的模拟器。

启用 Windows 开发者模式

要为 Windows 创建应用程序,您必须启用开发者模式:

  1. 导航到 开始 | 设置 | 隐私和安全 | 开发者,然后开启 开发者模式。(您也可以搜索“开发者”。)

  2. 接受有关它“可能使您的设备和个人信息面临安全风险或损害您的设备”的警告。

  3. 关闭 设置 应用。

创建 .NET MAUI 项目

现在我们将创建一个跨平台移动和桌面应用程序的项目:

  1. 在 Visual Studio 2022 中,添加一个新项目,如下列表中定义:

    • 项目模板:.NET MAUI App / maui

    您可以选择 C# 作为语言,并选择 MAUI 作为项目类型以过滤并仅显示适当的模板。

    • 项目文件和文件夹:Northwind.Maui.Client

    • 解决方案文件和文件夹:Chapter16

  2. 在 Windows 上,如果您看到 Windows 安全警报,表明 Windows Defender 防火墙已阻止所有公共和私人网络上的 Broker 的某些功能,那么选择 私人网络 并清除 公共网络,然后点击 允许访问 按钮。

  3. 在项目文件中,注意针对 iOS、Android 和 Mac Catalyst 的元素,以及如果操作系统是 Windows,则启用 Windows 目标的元素,以及将项目设置为单个 MAUI 项目的元素,如下部分标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
     **<TargetFrameworks>net8****.0****-ios;net8****.0****-android;net8****.0****-maccatalyst</TargetFrameworks>**
     **<TargetFrameworks Condition=****"$([MSBuild]::IsOSPlatform('windows'))"****>$(TargetFrameworks);net8****.0****-windows10****.0.19041.0****</TargetFrameworks>**
        ...
        <OutputType>Exe</OutputType>
        <RootNamespace>Northwind.Maui.Client</RootNamespace>
     **<UseMaui>****true****</UseMaui>**
     **<SingleProject>****true****</SingleProject>** 
    

    如果您看到错误 Error NU1012 Platform version is not present for one or more target frameworks, even though they have specified a platform: net8.0-ios, net8.0-maccatalyst,那么在命令提示符或终端中,在项目文件夹中,如以下命令所示恢复项目工作负载:

    dotnet workload restore 
    
  4. 在工具栏中 运行 按钮的右侧,将 框架 设置为 net8.0-android,并选择您之前创建的 Pixel 5 - API 33 (Android 13.0 - API 33) 模拟器映像,如图 16.3 所示:

图 16.3:选择 Android 设备作为启动目标

  1. 在工具栏中点击 运行 按钮并等待设备模拟器启动 Android 操作系统,然后部署并启动您的移动应用程序。这可能需要超过五分钟,尤其是第一次构建新的 MAUI 项目时。请关注 Visual Studio 2022 的状态栏,如下所示:

    图 16.4:状态栏显示 .NET MAUI 应用程序部署的进度

    如果您是第一次这样做,可能会有另一个 Google 许可协议需要确认。

  2. 在 .NET MAUI 应用程序中,点击 点击我 按钮三次以增加计数器,如下所示:图 16.5

图 16.5:在 Android 的 .NET MAUI 应用程序中增加计数器三次

  1. 关闭 Android 设备模拟器。您不需要关闭模拟器电源。

  2. 在工具栏中 运行 按钮的右侧,将 框架 设置为 net8.0-windows10.0.19041.0

  3. 确保已选择 调试 配置,然后点击标有 Windows Machine 的实心绿色三角形 启动 按钮。您可能会看到一个关于缺少应安装的包的警告。只需再次点击 启动 按钮即可,它们现在应该已安装并正常工作。

  4. 几分钟后,注意 Windows 应用程序显示与 图 16.6 中相同的 点击我 按钮和计数器功能:

图 16.6:Windows 上的相同 .NET MAUI 应用程序

  1. 关闭 Windows 应用程序。

良好实践:您应该在所有可能运行的设备上测试您的 .NET MAUI 应用程序。在本章中,即使我没有明确要求您这样做,我也建议您在添加新功能后,通过在模拟的 Android 设备和 Windows 上运行应用程序来尝试应用程序。这样,您至少可以看到它在主要为高而窄的纵向尺寸的移动设备上的外观,以及在更大横向尺寸的桌面设备上的外观。如果您使用的是 Mac,那么我建议您在 iOS 模拟器、Android 模拟器和作为 Mac Catalyst 桌面应用程序中测试它。

添加外壳导航和更多内容页面

现在,让我们回顾现有的 .NET MAUI 应用程序结构,然后向项目中添加一些新页面和导航:

  1. Northwind.Maui.Client 项目中,在 MauiProgram.cs 中,注意 builder 对象调用 UseMauiApp 并指定 App 作为其泛型类型,如下面的代码所示,高亮显示:

    using Microsoft.Extensions.Logging;
    namespace Northwind.Maui.Client;
    public static class MauiProgram
    {
      public static MauiApp CreateMauiApp()
      {
        var builder = MauiApp.CreateBuilder();
        builder
     **.UseMauiApp<App>()**
          .ConfigureFonts(fonts =>
          {
            fonts.AddFont("OpenSans-Regular.ttf", "OpenSansRegular");
            fonts.AddFont("OpenSans-Semibold.ttf", "OpenSansSemibold");
          });
    #if DEBUG
        builder.Logging.AddDebug();
    #endif
        return builder.Build();
      }
    } 
    
  2. 解决方案资源管理器 中,展开 App.xaml,打开 App.xaml.cs,并注意 AppMainPage 属性被设置为 AppShell 的一个实例,如下面的代码所示,高亮显示:

    namespace Northwind.Maui.Client;
    public partial class App : Application
    {
      public App()
      {
        InitializeComponent();
     **MainPage =** **new** **AppShell();**
      }
    } 
    
  3. AppShell.xaml 中,注意外壳禁用了飞出模式,并且只有一个名为 MainPage 的内容页面,如下面的代码所示,高亮显示:

    <?xml version="1.0" encoding="UTF-8" ?>
    <Shell
      x:Class="Northwind.Maui.Client.AppShell"
      xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
      xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
      xmlns:local="clr-namespace:Northwind.Maui.Client"
      **Shell.FlyoutBehavior****=****"Disabled"**
      Title="Northwind.Maui.Client">
      <ShellContent
        Title="Home"
        **ContentTemplate****=****"{DataTemplate local:MainPage}"**
      Route="MainPage" />
    </Shell> 
    

    只包含一个内容页面的外壳不会显示任何导航。您至少需要两个外壳内容项。

  4. Resources文件夹中的Images文件夹中,添加我们将用于即将添加的导航中飞出项的一些图标图片。

    你可以从以下链接下载图片:github.com/markjprice/apps-services-net8/tree/main/code/Chapter16/Northwind.Maui.Client/Resources/Images

  5. AppShell.xaml中,启用飞出模式,将背景设置为浅蓝色,添加MainPage内容的图标,添加飞出标题,然后添加一些包含更多外壳内容的飞出项,如下面的标记所示:

    <?xml version="1.0" encoding="UTF-8" ?>
    <Shell
      x:Class="Northwind.Maui.Client.AppShell"
      xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
      xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
      xmlns:local="clr-namespace:Northwind.Maui.Client"
      Shell.FlyoutBehavior="**Flyout"**
      Title="Northwind.Maui.Client"
      **FlyoutBackgroundColor****=****"AliceBlue"****>**
    ** <Shell.FlyoutHeader****>**
    **<****HorizontalStackLayout****Spacing****=****"10"****HorizontalOptions****=****"Start"****>**
    **<****Image****Source****=****"wind_face_3d.png"**
    **WidthRequest****=****"80"****HeightRequest****=****"80"** **/>**
    **<****Label****Text****=****"Northwind"****FontFamily****=****"OpenSansSemibold"**
    **FontSize****=****"32"****VerticalOptions****=****"Center"** **/>**
    **</****HorizontalStackLayout****>**
    **</****Shell.FlyoutHeader****>**
      <ShellContent Title="Home"
     **Icon****=****"file_cabinet_3d.png"**
        ContentTemplate="{DataTemplate local:MainPage}"
        Route="MainPage" />
     **<ShellContent****Title****=****"Categories"**
    **Icon****=****"delivery_truck_3d.png"**
    **ContentTemplate****=****"{DataTemplate local:CategoriesPage}"**
    **Route****=****"Categories"** **/>**
    **<****ShellContent****Title****=****"Products"**
    **Icon****=****"cityscape_3d.png"**
    **ContentTemplate****=****"{DataTemplate local:ProductsPage}"**
    **Route****=****"Products"** **/>**
    **<****ShellContent****Title****=****"Customers"**
    **Icon****=****"card_index_3d.png"**
    **ContentTemplate****=****"{DataTemplate local:CustomersPage}"**
    **Route****=****"Customers"** **/>**
    **<****ShellContent****Title****=****"Employees"**
    **Icon****=****"identification_card_3d.png"**
    **ContentTemplate****=****"{DataTemplate local:EmployeesPage}"**
    **Route****=****"Employees"** **/>**
    **<****ShellContent****Title****=****"Settings"**
    **Icon****=****"gear_3d.png"**
    **ContentTemplate****=****"{DataTemplate local:SettingsPage}"**
    **Route****=****"Settings"** **/>**
    
    </Shell> 
    

    你会在一些ContentTemplate行上看到关于缺失页面的警告,因为我们还没有创建它们。在浅色模式下AliceBlue看起来不错,但如果你的操作系统使用深色模式,你可能更喜欢像#75858a这样的替代颜色。

  6. 在 Visual Studio 2022 中,右键单击Northwind.Maui.Client项目文件夹,选择添加 | 新建项...或按Ctrl + Shift + A,在模板类型树中选择.NET MAUI,选择.NET MAUI ContentPage (XAML),输入名称SettingsPage,然后点击添加

    Visual Studio Code 和 JetBrains Rider 没有 MAUI 的项目项模板。你可以使用 CLI 创建此项目,如下面的命令所示:

    dotnet new maui-page-xaml --name SettingsPage.xaml 
    
  7. 重复之前的步骤以添加以下命名的内容页面:

    • CategoriesPage

    • CustomersPage

    • CustomerDetailPage

    • EmployeesPage

    • ProductsPage

  8. 解决方案资源管理器中,双击CategoriesPage.xaml文件以打开它进行编辑。请注意,Visual Studio 2022 还没有 XAML 的图形设计视图。

  9. <ContentPage>元素中,将Title更改为Categories,在<Label>元素中,将Text更改为Categories,如下面的标记所示:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 x:Class="Northwind.Maui.Client.CategoriesPage"
                 Title="**Categories**">
        <VerticalStackLayout>
            <Label 
                Text="**Categories**"
                VerticalOptions="Center" 
                HorizontalOptions="Center" />
        </VerticalStackLayout>
    </ContentPage> 
    
  10. 导航到视图 | 工具箱或按Ctrl + WX。请注意,工具箱有控件布局单元格通用等部分。如果你使用的是没有工具箱的代码编辑器,你可以直接输入标记而不是使用工具箱。

  11. 在工具箱的顶部是一个搜索框。输入字母b,然后注意控件列表被过滤以显示ButtonProgressBarAbsoluteLayout等控件。

  12. 将工具箱中的Button控件拖放到现有的<Label>控件之后,在VerticalStackLayout的关闭元素之前,并将它的Text属性更改为Hello!,如下面的标记所示:

    <Button Text="Hello!" /> 
    
  13. 将启动设置为Windows Machine,然后以调试模式启动Northwind.Maui.Client项目。请注意,Visual Studio 的状态栏显示XAML 热重载已连接。

  14. 在应用左上角,点击飞出菜单(“汉堡”图标),并注意飞出项中使用的标题和图标图片,如图 16.7 所示:

图片

图 16.7:带有图像图标的飞出菜单

  1. 在飞出菜单中,点击类别,注意按钮上的文本为Hello!并且它横跨应用程序窗口的宽度。

  2. 保持应用程序运行,然后在 Visual Studio 2022 中,将 Text 属性更改为 Click Me,添加一个属性来设置 WidthRequest 属性为 100,并注意XAML 热重载功能会自动在应用程序本身中反映更改,如图 图 16.8 所示:

图片

图 16.8:XAML 热重载自动更新实时应用程序中的 XAML 更改

  1. 关闭应用程序。

  2. Button 元素修改为名为 ClickMeButton,并为它的 Clicked 事件添加一个新的事件处理器,如图 图 16.9 所示:

图片

图 16.9:向控件添加事件处理器

  1. 右键单击事件处理器名称,选择转到定义或按 F12

  2. 在事件处理器方法中添加一个语句,将按钮的内容设置为当前时间,如图中高亮显示的以下代码所示:

    private void ClickMeButton_Click(object sender, EventArgs e)
    {
      **ClickMeButton.Text = DateTime.Now.ToString(****"hh:mm:ss"****);**
    } 
    
  3. 在至少一个移动设备和至少一个桌面设备上启动 Northwind.Maui.Client 项目。

    良好实践:当部署到 Android 模拟器或 iOS 模拟器时,旧版本的应用程序可能仍在运行。在与新版本的应用程序交互之前,请确保等待新版本的应用程序部署完成。您可以通过查看 Visual Studio 状态栏来跟踪部署进度,或者只需等待您看到消息XAML 热重载已连接

  4. 导航到类别,点击按钮,注意其文本标签变为当前时间。

  5. 关闭应用程序。

实现更多内容页面

现在,让我们实现一些新页面:

  1. EmployeesPage.xaml 中,将 Title 修改为 Employees,并添加标记来定义简单计算器的 UI,如图中高亮显示的以下标记所示:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 x:Class="Northwind.Maui.Client.EmployeesPage"
                 Title="**Employees**">
    
      <VerticalStackLayout>
    **<****Grid****Background****=****"DarkGray"****Margin****=****"10"**
    **Padding****=****"5"****x:Name****=****"GridCalculator"**
    **ColumnDefinitions****=****"****Auto,Auto,Auto,Auto"**
    **RowDefinitions****=****"Auto,Auto,Auto,Auto"****>**
    **<****Button****Grid.Row****=****"0"****Grid.Column****=****"0"****Text****=****"****X"** **/>**
    **<****Button****Grid.Row****=****"0"****Grid.Column****=****"1"****Text****=****"/"** **/>**
    **<****Button****Grid.Row****=****"0"****Grid.Column****=****"2"****Text****=****"+"** **/>**
    **<****Button****Grid.Row****=****"****0"****Grid.Column****=****"3"****Text****=****"-"** **/>**
    **<****Button****Grid.Row****=****"1"****Grid.Column****=****"****0"****Text****=****"7"** **/>**
    **<****Button****Grid.Row****=****"1"****Grid.Column****=****"1"****Text****=****"****8"** **/>**
    **<****Button****Grid.Row****=****"1"****Grid.Column****=****"2"****Text****=****"9"** **/>**
    **<****Button****Grid.Row****=****"1"****Grid.Column****=****"3"****Text****=****"0"** **/>**
    **<****Button****Grid.Row****=****"****2"****Grid.Column****=****"0"****Text****=****"4"** **/>**
    **<****Button****Grid.Row****=****"2"****Grid.Column****=****"****1"****Text****=****"5"** **/>**
    **<****Button****Grid.Row****=****"2"****Grid.Column****=****"2"****Text****=****"****6"** **/>**
    **<****Button****Grid.Row****=****"2"****Grid.Column****=****"3"****Text****=****"."** **/>**
    **<****Button****Grid.Row****=****"3"****Grid.Column****=****"0"****Text****=****"1"** **/>**
    **<****Button****Grid.Row****=****"****3"****Grid.Column****=****"1"****Text****=****"2"** **/>**
    **<****Button****Grid.Row****=****"3"****Grid.Column****=****"****2"****Text****=****"3"** **/>**
    **<****Button****Grid.Row****=****"3"****Grid.Column****=****"3"****Text****=****"****="** **/>**
    **</****Grid****>**
    **<****Label****x:Name****=****"Output"****FontSize****=****"24"**
    **VerticalOptions****=****"****Center"**
    **HorizontalOptions****=****"Start"** **/>**
      </VerticalStackLayout>
    </ContentPage> 
    
  2. 为页面的 Loaded 事件添加一个语句,如图中高亮显示的以下标记所示:

    Title="Employees"
    **Loaded=****"ContentPage_Loaded"**> 
    
  3. EmployeesPage.xaml.cs 中,添加语句来调整网格中每个按钮的大小,并为 Clicked 事件连接事件处理器,如图中以下代码所示:

    private void ContentPage_Loaded(object sender, EventArgs e)
    {
      foreach (Button button in GridCalculator.Children.OfType<Button>())
      {
        button.FontSize = 24;
        button.WidthRequest = 54;
        button.HeightRequest = 54;
        button.Clicked += Button_Clicked;
      }
    } 
    
  4. 添加一个 Button_Clicked 方法,包含处理点击按钮的语句,将按钮的文本连接到输出标签,如图中以下代码所示:

    private void Button_Clicked(object sender, EventArgs e)
    {
      string operationChars = "+-/X=";
      Button button = (Button)sender;
      if (operationChars.Contains(button.Text))
      {
        Output.Text = string.Empty;
      }
      else
      {
        Output.Text += button.Text;
      }
    } 
    

    这不是一个合适的计算器实现,因为操作尚未实现。我们现在只是模拟一个,因为我们专注于如何使用.NET MAUI 构建 UI。您可以通过 Google 搜索如何实现一个简单的计算器作为可选练习。

  5. 使用至少一个桌面设备和至少一个移动设备启动 Northwind.Maui.Client 项目。

  6. 导航到员工,点击一些按钮,注意标签更新以显示所点击的内容,如图 图 16.10 所示:

图片

图 16.10:模拟器上的模拟计算器

  1. 关闭应用程序。

到目前为止,我们已经使用 XAML 和 MAUI 构建了一些简单的 UI。接下来,让我们看看一些改进应用的技术,比如定义和共享资源。

使用共享资源

当构建图形用户界面时,你通常会想要使用资源,例如画笔来绘制控件背景或类的实例以执行自定义转换。资源可以在以下级别定义,并与该级别或以下级别的所有内容共享:

  • 应用程序

  • 页面

  • 控件

定义跨应用共享的资源

定义共享资源的好地方是在应用级别,那么让我们看看如何做到这一点:

  1. Resources 文件夹中,在 Styles 文件夹中,添加一个名为 Northwind.xaml 的新 .NET MAUI 资源字典 (XAML) 项目项。

    Visual Studio Code 和 JetBrains Rider 没有 MAUI 的项目项模板。你可以使用 CLI 创建此项目项,如下所示:

    dotnet new maui-dict-xaml --name Northwind.xaml 
    
  2. 在现有的 ResourceDictionary 元素内部添加标记,以定义具有 Rainbow 键的线性渐变画笔,如下所示,高亮显示的标记:

    <?xml version="1.0" encoding="utf-8" ?>
    <ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 x:Class="Northwind.Maui.Client.Resources.Styles.Northwind">
    **<****LinearGradientBrush****x:Key****=****"Rainbow"****>**
    **<****GradientStop****Color****=****"Red"****Offset****=****"0"** **/>**
    **<****GradientStop****Color****=****"Orange"****Offset****=****"****0.1"** **/>**
    **<****GradientStop****Color****=****"Yellow"****Offset****=****"0.3"** **/>**
    **<****GradientStop****Color****=****"****Green"****Offset****=****"0.5"** **/>**
    **<****GradientStop****Color****=****"Blue"****Offset****=****"0.7"** **/>**
    **<****GradientStop****Color****=****"Indigo"****Offset****=****"0.9"** **/>**
    **<****GradientStop****Color****=****"Violet"****Offset****=****"****1"** **/>**
    **</****LinearGradientBrush****>**
    </ResourceDictionary> 
    
  3. App.xaml 中,向合并的资源字典中添加一个条目以引用 Styles 文件夹中名为 Northwind.xaml 的资源文件,如下所示,高亮显示的标记:

    <?xml version = "1.0" encoding = "UTF-8" ?>
    <Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 xmlns:local="clr-namespace:Northwind.Maui.Client"
                 x:Class="Northwind.Maui.Client.App">
      <Application.Resources>
        <ResourceDictionary>
          <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
            <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
    **<****ResourceDictionary****Source****=****"Resources/Styles/Northwind.xaml"** **/>**
          </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
      </Application.Resources>
    </Application> 
    

引用共享资源

现在我们可以引用共享资源:

  1. CategoriesPage.xaml 中,修改 ContentPage 以将其背景设置为具有 Rainbow 键的画笔资源,如下所示,高亮显示的标记:

    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 x:Class="Northwind.Maui.Client.CategoriesPage"
    **Background****=****"{StaticResource Rainbow}"**
               Title="Categories"> 
    

    StaticResource 表示资源在应用首次启动时只读取一次。如果之后资源发生变化,引用它的任何元素都不会更新。

  2. 以调试模式启动 Northwind.Maui.Client 项目。

  3. 导航到 类别 并注意页面的背景是一个彩虹。

  4. 关闭应用。

动态更改共享资源

现在我们可以实现一个设置页面,允许用户在运行时在浅色模式、深色模式或 UI 中使用的系统模式之间切换:

  1. Resources 文件夹中,在 Styles 文件夹中,添加一个名为 LightDarkModeColors.xaml 的新 .NET MAUI 资源字典 (XAML) 项目项。

  2. 在现有的 ResourceDictionary 元素内部添加标记,以定义浅色模式和深色模式适用的颜色集,如下所示,高亮显示的标记:

    <?xml version="1.0" encoding="utf-8" ?>
    <ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
      xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
      x:Class="Northwind.Maui.Client.Resources.Styles.LightDarkModeColors">
      <Color x:Key="LightPageBackgroundColor">White</Color>
      <Color x:Key="LightNavigationBarColor">AliceBlue</Color>
      <Color x:Key="LightPrimaryColor">WhiteSmoke</Color>
      <Color x:Key="LightSecondaryColor">Black</Color>
      <Color x:Key="LightPrimaryTextColor">Black</Color>
      <Color x:Key="LightSecondaryTextColor">White</Color>
      <Color x:Key="LightTertiaryTextColor">Gray</Color>
      <Color x:Key="DarkPageBackgroundColor">Black</Color>
      <Color x:Key="DarkNavigationBarColor">Teal</Color>
      <Color x:Key="DarkPrimaryColor">Teal</Color>
      <Color x:Key="DarkSecondaryColor">White</Color>
      <Color x:Key="DarkPrimaryTextColor">White</Color>
      <Color x:Key="DarkSecondaryTextColor">White</Color>
      <Color x:Key="DarkTertiaryTextColor">WhiteSmoke</Color>
    </ResourceDictionary> 
    
  3. Resources 文件夹中,在 Styles 文件夹中,添加一个名为 DarkModeTheme.xaml 的新 .NET MAUI 资源字典 (XAML) 项目项。

  4. 在现有的 ResourceDictionary 元素内部添加标记,以定义用于深色模式的样式,如下所示,高亮显示的标记:

    <?xml version="1.0" encoding="utf-8" ?>
    <ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
      xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
      x:Class="Northwind.Maui.Client.Resources.Styles.DarkModeTheme">
      <Style TargetType="Shell">
        <Setter Property="FlyoutBackgroundColor" 
                Value="{StaticResource DarkNavigationBarColor}" />
      </Style>
      <Style TargetType="ContentPage">
        <Setter Property="BackgroundColor" 
                Value="{StaticResource DarkPageBackgroundColor}" />
      </Style>
      <Style TargetType="Button">
        <Setter Property="BackgroundColor"
                Value="{StaticResource DarkPrimaryColor}" />
        <Setter Property="TextColor"
                Value="{StaticResource DarkSecondaryColor}" />
        <Setter Property="HeightRequest" Value="45" />
        <Setter Property="WidthRequest" Value="190" />
        <Setter Property="CornerRadius" Value="18" />
      </Style>
    </ResourceDictionary> 
    
  5. Resources 文件夹中,在 Styles 文件夹中,添加一个名为 LightModeTheme.xaml 的新 .NET MAUI 资源字典 (XAML) 项目项。

  6. 在现有的 ResourceDictionary 元素内部添加标记,以定义用于浅色模式的样式,如下所示,高亮显示的标记:

    <?xml version="1.0" encoding="utf-8" ?>
    <ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
      xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
      x:Class="Northwind.Maui.Client.Resources.Styles.LightModeTheme">
      <Style TargetType="Shell">
        <Setter Property="FlyoutBackgroundColor" 
                Value="{StaticResource LightNavigationBarColor}" />
      </Style>
      <Style TargetType="ContentPage">
        <Setter Property="BackgroundColor" 
                Value="{StaticResource LightPageBackgroundColor}" />
      </Style>
      <Style TargetType="Button">
        <Setter Property="BackgroundColor"
                Value="{StaticResource LightPrimaryColor}" />
        <Setter Property="TextColor"
                Value="{StaticResource LightSecondaryColor}" />
        <Setter Property="HeightRequest" Value="45" />
        <Setter Property="WidthRequest" Value="190" />
        <Setter Property="CornerRadius" Value="18" />
      </Style>
    </ResourceDictionary> 
    
  7. Resources 文件夹中,在 Styles 文件夹中,添加一个名为 SystemModeTheme.xaml 的新 .NET MAUI 资源字典 (XAML) 项目项。

  8. 在现有的ResourceDictionary元素内添加标记,以定义根据操作系统选项设置来使用的样式,如下面的标记所示:

    <?xml version="1.0" encoding="utf-8" ?>
    <ResourceDictionary xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
      xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
      x:Class="Northwind.Maui.Client.Resources.Styles.SystemModeTheme">
      <Style TargetType="Shell">
        <Setter Property="FlyoutBackgroundColor" 
                Value="{AppThemeBinding Light={StaticResource LightNavigationBarColor}, Dark={StaticResource DarkNavigationBarColor}}" />
      </Style>
      <Style TargetType="ContentPage">
        <Setter Property="BackgroundColor" 
                Value="{AppThemeBinding Light={StaticResource LightPageBackgroundColor}, Dark={StaticResource DarkPageBackgroundColor}}" />
      </Style>
      <Style TargetType="Button">
        <Setter Property="BackgroundColor"
                Value="{AppThemeBinding Light={StaticResource LightPrimaryColor}, Dark={StaticResource DarkPrimaryColor}}" />
        <Setter Property="TextColor"
                Value="{AppThemeBinding Light={StaticResource LightSecondaryColor}, Dark={StaticResource DarkSecondaryColor}}" />
        <Setter Property="HeightRequest" Value="45" />
        <Setter Property="WidthRequest" Value="190" />
        <Setter Property="CornerRadius" Value="18" />
      </Style>
    </ResourceDictionary> 
    

    注意使用AppThemeBinding扩展来动态绑定到两个预定义的特殊过滤器LightDark。这些绑定到系统模式。

  9. App.xaml中添加浅色和暗色模式颜色以及系统主题资源,如下面的标记所示:

    <ResourceDictionary.MergedDictionaries>
      <ResourceDictionary Source="Resources/Styles/Colors.xaml" />
      <ResourceDictionary Source="Resources/Styles/Styles.xaml" />
      <ResourceDictionary Source="Resources/Styles/Northwind.xaml" />
    **<****ResourceDictionary****Source****=****"Resources/Styles/LightDarkModeColors.xaml"** **/>**
    **<****ResourceDictionary****Source****=****"Resources/Styles/SystemModeTheme.xaml"** **/>**
    </ResourceDictionary.MergedDictionaries> 
    

    良好实践SystemModeTheme.xaml资源文件引用了在LightDarkModelColors.xaml文件中定义的颜色,因此顺序很重要。

  10. 在项目中创建一个名为Controls的文件夹。

  11. Controls文件夹中,添加一个名为ThemeEnum.cs的类文件,并定义一个包含三个值SystemLightDarkenum类型,用于选择主题,如下面的代码所示:

    namespace Northwind.Maui.Client.Controls;
    public enum Theme
    {
      System,
      Light,
      Dark
    } 
    
  12. Controls文件夹中,添加一个名为EnumPicker.cs的类文件,并定义一个从Picker控件继承的类,该类可以绑定到任何enum类型并显示其值的下拉列表,如下面的代码所示:

    using System.Reflection; // To use GetTypeInfo method.
    namespace Northwind.Maui.Client.Controls;
    public class EnumPicker : Picker
    {
      public Type EnumType
      {
        set => SetValue(EnumTypeProperty, value);
        get => (Type)GetValue(EnumTypeProperty);
      }
      public static readonly BindableProperty EnumTypeProperty =
        BindableProperty.Create(
          propertyName: nameof(EnumType), 
          returnType: typeof(Type), 
          declaringType: typeof(EnumPicker),
          propertyChanged: (bindable, oldValue, newValue) =>
        {
          EnumPicker picker = (EnumPicker)bindable;
          if (oldValue != null)
          {
            picker.ItemsSource = null;
          }
          if (newValue != null)
          {
            if (!((Type)newValue).GetTypeInfo().IsEnum)
              throw new ArgumentException(
                "EnumPicker: EnumType property must be enumeration type");
            picker.ItemsSource = Enum.GetValues((Type)newValue);
          }
        });
    } 
    
  13. SettingsPage.xaml中,导入一个local命名空间以使用我们的自定义EnumPicker控件,一个ios命名空间以添加仅适用于 iOS 应用程序的特殊属性,将Title更改为设置,并创建一个EnumPicker实例以选择主题,如下面的标记所示:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
    **xmlns:local****=****"clr-namespace:Northwind.Maui.Client.Controls"**
    **xmlns:ios****=****"clr-namespace:Microsoft.Maui.Controls.PlatformConfiguration.iOSSpecific;assembly=Microsoft.Maui.Controls"**
                 x:Class="Northwind.Maui.Client.SettingsPage"
                 Title="**Settings**">
      <VerticalStackLayout **HorizontalOptions****=****"Center"**>
      **<****local:EnumPicker****ios:Picker.UpdateMode****=****"WhenFinished"**
    **EnumType****=****"{x:Type local:Theme}"**
    **Title****=****"Select Theme"**
    **SelectedIndexChanged****=****"ThemePicker_SelectionChanged"**
    **Loaded****=****"ThemePicker_Loaded"**
    **x:Name****=****"ThemePicker"** **/>**
    </VerticalStackLayout>
    </ContentPage> 
    
  14. SettingsPage.xaml.cs中添加处理选择器事件的语句,如下面的代码所示:

    **using** **Northwind.Maui.Client.Controls;** **// To use enum Theme.**
    **using** **Northwind.Maui.Client.Resources.Styles;** **// To use DarkModeTheme.**
    namespace Northwind.Maui.Client;
    public partial class SettingsPage : ContentPage
    {
      public SettingsPage()
      {
        InitializeComponent();
      }
    **private****void****ThemePicker_SelectionChanged****(****object** **sender, EventArgs e****)**
     **{**
     **Picker picker = sender** **as** **Picker;**
     **Theme theme = (Theme)picker.SelectedItem;**
     **ICollection<ResourceDictionary> resources =** 
     **Application.Current.Resources.MergedDictionaries;**
    **if** **(resources** **is****not****null****)**
     **{**
     **resources.Clear();**
     **resources.Add(****new** **Resources.Styles.Northwind());**
     **resources.Add(****new** **LightDarkModeColors());**
     **ResourceDictionary themeResource = theme** **switch**
     **{**
     **Theme.Dark  =>** **new** **DarkModeTheme(),**
     **Theme.Light =>** **new** **LightModeTheme(),**
     **_           =>** **new** **SystemModeTheme()**
     **};**
     **resources.Add(themeResource);**
     **}**
     **}**
    **private****void****ThemePicker_Loaded****(****object** **sender, EventArgs e****)**
     **{**
     **ThemePicker.SelectedItem = Theme.System;**
     **}**
    **}** 
    
  15. 使用至少一个桌面和移动设备启动Northwind.Maui.Client项目。

  16. 注意,主页上按钮的颜色和形状处于浅色模式,如图 16.11 中的Window机器所示:

图 16.11:Windows 上的浅色模式按钮

  1. 保持应用程序运行,启动 Windows设置应用程序,导航到个性化|颜色,在选择你的模式部分选择暗色,如图 16.12 所示:

图 16.12:在 Windows 设置中切换系统颜色模式

  1. 保持设置打开,切换回应用程序,并注意它已动态切换到暗色模式颜色,如图 16.13 所示:

图 16.13:我们应用程序中的暗色模式

  1. 关闭应用程序。

  2. 设置中,切换回浅色模式。

良好实践:资源可以是任何对象的实例。为了在应用程序中共享它,请在App.xaml文件中定义它并给它一个唯一的键。为了在应用程序首次启动时一次性设置元素的属性,请使用{StaticResource key}。为了在应用程序的生命周期内资源值变化时设置元素的属性,请使用{DynamicResource key}。为了使用代码加载资源,请使用Resources属性的TryGetValue方法。如果你将Resources属性视为字典并使用数组样式语法,如Resources[key],它将只找到在字典中直接定义的资源,而不是在任何合并的字典中。

资源可以在 XAML 的任何元素中定义和存储,而不仅仅是应用程序级别。例如,如果一个资源只在MainPage上需要,那么它可以在那里定义。您还可以在运行时动态加载 XAML 文件。

更多信息:您可以在以下链接中了解更多关于.NET MAUI 资源字典的信息:learn.microsoft.com/en-us/dotnet/maui/fundamentals/resource-dictionaries。特别是注意关于资源查找行为的章节。

使用数据绑定

当构建图形用户界面时,你通常会想要将一个控件的一个属性绑定到另一个控件,或者绑定到某些数据。

绑定到元素

最简单的绑定类型是两个元素之间的绑定。一个元素作为值的源,另一个元素作为目标。

让我们采取以下步骤:

  1. CategoriesPage.xaml中,在现有的垂直堆叠布局中的按钮下方,添加一个用于说明的标签,另一个用于显示当前旋转角度的标签,一个用于选择旋转的滑动条,以及一个彩虹方块用于旋转,如下面的标记所示:

    <?xml version="1.0" encoding="utf-8" ?>
    <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
                 xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
                 x:Class="Northwind.Maui.Client.CategoriesPage"
                 Background="{StaticResource Rainbow}"
                 Title="Categories">
    
      <VerticalStackLayout>
    
        <Button Text="Click Me" WidthRequest="100" 
                x:Name="ClickMeButton" 
                Clicked="ClickMeButton_Clicked" />
    
    **<****Label****Margin****=****"10"****>**
     **Use the slider to rotate the square:**
    **</****Label****>**
    **<****Label****BindingContext****=****"{x:Reference Name=SliderRotation}"**
    **Text****=****"{Binding Path=Value, StringFormat='{0:N0} degrees'}"**
    **FontSize****=****"30"****HorizontalTextAlignment****=****"****Center"** **/>**
    **<****Slider****Value****=****"0"****Minimum****=****"0"****Maximum****=****"180"**
    **x:Name****=****"****SliderRotation"****Margin****=****"10,0"** **/>**
    
    **<****Rectangle****HeightRequest****=****"200"****WidthRequest****=****"200"**
    **Fill****=****"****{StaticResource Rainbow}"**
    **BindingContext****=****"{x:Reference Name=SliderRotation}"**
    **Rotation****=****"{Binding Path=Value}"** **/>**
      </VerticalStackLayout>
    
    </ContentPage> 
    

    注意,标签的文本和矩形的旋转角度都是通过绑定上下文和{Binding}标记扩展绑定到滑动条的值。

  2. 启动Northwind.Maui.Client项目。

  3. 导航到类别页面。

  4. 点击并拖动滑动条以更改彩虹方块的旋转,如图图 16.14所示:

图片

图 16.14:一个与标签绑定并旋转的矩形在 Windows 上的滑动数据

  1. 关闭应用程序。

练习和探索

通过回答一些问题,进行一些实际操作练习,并更深入地研究本章的主题来测试你的知识和理解。

练习 16.1 – 测试你的知识

回答以下问题:

  1. .NET MAUI UI 组件的四个类别是什么,它们分别代表什么?

  2. Shell组件的好处是什么,它实现了哪些类型的 UI?

  3. 你如何让用户能够在列表视图中的一个单元格上执行操作?

  4. 你会在什么情况下使用Entry而不是Editor

  5. 将菜单项的IsDestructive设置为true对单元格上下文操作中的菜单项有什么影响?

  6. 你已经定义了一个包含内容页的 Shell,但没有显示导航。这可能是为什么?

  7. 对于像 Button 这样的元素,MarginPadding 之间的区别是什么?

  8. 如何使用 XAML 将事件处理器附加到对象?

  9. XAML 样式的作用是什么?

  10. 你在哪里可以定义资源?

练习 16.2 – 探索主题

使用下一页上的链接了解更多关于本章涵盖主题的详细信息:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md#chapter-16---building-mobile-and-desktop-apps-using-net-maui

练习 16.3 – 实现 .NET MAUI 的模型-视图-视图模型

在这个仅在线的部分,你将学习如何使用 .NET MAUI 应用实现 MVVM 设计模式:

github.com/markjprice/apps-services-net8/blob/main/docs/ch16-mvvm.md

练习 16.4 – 集成 .NET MAUI 应用与 Blazor 和原生平台

在这个仅在线的部分,你将学习如何将 .NET MAUI 应用与原生移动功能集成:

github.com/markjprice/apps-services-net8/blob/main/docs/ch16-maui-blazor.md

摘要

在本章中,你学习了:

  • 如何使用 .NET MAUI 构建跨平台的移动和桌面应用程序。

  • 如何定义共享资源并引用它们。

  • 如何使用常见控件进行数据绑定。

结语 中,你将学习如何通过 .NET 的应用程序和服务继续你的学习之旅。

第十七章:结语

我希望这本书与市场上的其他书籍有所不同。我希望您发现它读起来轻松愉快,内容丰富,包含了每个主题的实用、动手实践教程。

这篇结语包含以下简短章节:

  • 使用.NET Aspire 进行云原生开发

  • 介绍调查项目挑战

  • 第三版将于 2025 年 12 月推出

  • C#和.NET 学习之旅的下一步

  • 祝好运!

使用.NET Aspire 进行云原生开发

在这本书中,您看到了许多用于构建云原生应用的技术,例如 gRPC 用于服务之间的高效通信、各种类型的缓存以及使用 Polly 的容错。

随着您越来越多地实施这些技术,管理您的开发环境变得越来越困难。连接字符串、密钥、端口号、速率限制和缓存配置:如果任何一项出错,事情就会出错,或者以意想不到的方式工作,并且很难识别和修复问题。

在 2023 年 11 月 14 日的.NET Conf 2023 上,.NET 团队宣布了一款新产品:.NET Aspire。它目前处于预览版,团队计划在 2024 年春季发布 1.0 版本。

如果您想尝试预览版,那么在安装 Visual Studio 2022 版本 17.9 预览版 1 或更高版本时,请确保在单个组件选项卡下选择.NET Aspire SDK(预览版)

如官方公告博客文章所述,.NET Aspire“是一个基于.NET 构建具有弹性、可观察性和可配置性的云原生应用的定制的堆栈。”

它提供了以下内容:

  • Aspire 组件:基于现有的成熟技术如 Redis 和 OpenTelemetry,但被封装在 Aspire 特定的包中,这些包提供了快速和简单的配置,Aspire 组件提供了标准化的功能,包括服务发现、遥测、弹性和健康检查。

  • Aspire 入门和空项目模板:这些模板将帮助您开始使用一个可工作的解决方案来尝试 Aspire 功能。入门解决方案包括几个项目:一个用于监控您构建的所有服务和应用的 Blazor 仪表板前端、后端 Web API、一个用于设置默认值的共享项目,这些默认值将适用于所有项目,以及一个用于管理一切的 Aspire 宿主。

  • Aspire 编排:这提供了运行和连接复杂多项目应用及其依赖项的功能。为您编写了自定义扩展方法来配置项目中所有组件,遵循最佳实践,但如果有特殊要求,您可以更改它们。

如果您想了解更多关于.NET Aspire 的信息,您可以在以下链接中阅读文档:

learn.microsoft.com/en-us/dotnet/aspire/

我计划在我的.NET 8 三部曲的第三本书《.NET 8 专业人士的工具和技能》中写一个关于.NET Aspire 的章节,我们计划在 2024 年上半年出版。

介绍调查项目挑战

为了帮助我们学习当今.NET 开发者需要了解的所有不同技术,尝试实施一个需要混合技术和技能的项目将会非常棒。它应该尽可能真实,酷炫、有趣且实用,并且已经被其他人通过公开产品实现,我们可以从中获得灵感。

调查项目挑战是一个可选的完整项目,包含一组常见问题,这将为您提供一组真实世界的项目来构建。您可以在以下链接中了解更多信息:

github.com/markjprice/apps-services-net8/blob/main/docs/ch17-survey-project.md

第三版将于 2025 年 12 月推出

我已经开始着手确定下一版的改进领域,我们计划在 2025 年 11 月.NET 10 的通用可用性GA)发布后大约一个月发布。虽然我不期望在 Blazor 托管模型统一层面有重大新功能,但我确实期待.NET 9 和.NET 10 会在.NET 的各个方面做出有价值的改进。

如果您有希望看到涵盖或扩展的主题建议,或者您在文本或代码中发现了需要修复的错误,请在本书的 Discord 频道中找到我进行实时互动,或者通过以下链接的 GitHub 仓库提供详细信息:

github.com/markjprice/apps-services-net8

您的 C#和.NET 学习之旅的下一步

为印刷书籍留出的空间永远不够,无法包含一个人可能想要了解的所有内容。对于您想要了解更多关于的内容,我希望 GitHub 仓库中的笔记、良好实践技巧和链接能为您指明正确的方向:

github.com/markjprice/apps-services-net8/blob/main/docs/book-links.md

伴随书籍继续您的学习之旅

很快,我将完成一部关于.NET 8 的系列书籍,以继续您的学习之旅。其他两本书作为本书的补充:

  1. 第一本书涵盖了 C#、.NET、ASP.NET Core 和 Blazor 网络开发的 fundamentals。

  2. 第二本书(您现在正在阅读的这本书)涵盖了更多专业化的库、服务和网站以及桌面和移动应用(使用 Blazor 和.NET MAUI)的图形用户界面。

  3. 第三本书涵盖了您应该学习的重要工具和技能,以成为一名全面发展的专业.NET 开发者。这些包括设计模式和解构架构、调试、内存分析、从单元到性能、Web 和移动的所有重要测试类型,以及像 Docker 和 Azure Pipelines 这样的托管和部署主题。最后,它探讨了如何准备面试,以获得您想要的.NET 开发者职位。

.NET 8 三部曲及其最重要的主题的总结如图 17.1 所示:

一组带有文字和图像的蓝色横幅  自动生成的描述

图 17.1:学习 C#和.NET 的配套书籍

*《.NET 8 专业人员的工具和技能》计划于 2024 年上半年度出版。请在您最喜欢的书店留意它,以完成您的.NET 8 三部曲。

要查看我通过 Packt 出版的所有书籍列表,您可以使用以下链接:

subscription.packtpub.com/search?query=mark+j.+price

其他书籍可以帮助您进一步学习

如果您正在寻找我出版商出版的其他相关主题的书籍,有很多可供选择,如图 17.2 所示:

图 17.2:帮助您进一步学习.NET 应用和服务的 Packt 书籍

祝您好运!

我祝愿您在所有.NET 项目中一切顺利!

分享您的想法

现在您已经完成了《.NET 8 - 第二版:应用和服务》,我们非常想听听您的想法!如果您在亚马逊购买了这本书,请点击此处直接进入该书的亚马逊评论页面,分享您的反馈或在该购买网站上留下评论。

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

posted @ 2025-10-23 15:07  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报