C-13-和--NET9-现代跨平台开发基础知识-全-

C#13 和 .NET9 现代跨平台开发基础知识(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

本书与众不同。它简洁明了,旨在成为一本轻松愉快的读物,其中包含了每个主题的实用动手实践指南。广泛的叙述范围是以牺牲一些深度为代价的,但您会发现许多路标,如果您愿意,可以进一步探索。

本书同时是一本学习现代 C#和跨平台.NET 的实用指南,以及现代 Web 开发基础和用这些技术构建网站和服务的简要介绍。本书最适合 C#和.NET 的初学者,以及过去使用过 C#但可能觉得过去几年变化让自己落后于时代的程序员。

如果您已经熟悉 C#语言的旧版本,那么在第二章说 C#的第一主题中,您可以在在线部分回顾新语言特性的表格。

如果您已经熟悉旧版本的.NET 库,那么在第七章打包和分发.NET 类型的第一节中,您可以在在线部分回顾新库特性的表格。

我将指出 C#和.NET 的酷点和注意事项,以便您能迅速给同事留下深刻印象并提高工作效率。我并不会通过解释每一个小细节来放慢速度并让一些读者感到无聊,而是假设您足够聪明,能够通过 Google 为与主题相关但不是必需包含在有限篇幅的印刷书中的入门到中级指南中的主题找到解释。

一些章节为那些希望了解更多细节的读者提供了指向额外相关在线内容的链接。例如,第一章你好,C#! 欢迎使用.NET!有一个关于.NET 历史和背景的在线部分。

代码解决方案的查找位置

您可以从以下链接的 GitHub 仓库下载逐步引导任务和练习的解决方案:

github.com/markjprice/cs13net9

如果您不知道如何下载或克隆 GitHub 仓库,那么我将在第一章你好,C#! 欢迎使用.NET!的末尾提供说明。

本书涵盖的内容

第一章你好,C#! 欢迎使用.NET!,是关于设置你的开发环境以使用 Visual Studio 或 VS Code 与 C#开发工具包。然后你将学习如何使用它们用 C#和.NET 创建可能的最简单的应用程序。对于简化的控制台应用程序,你将看到 C# 9 中引入的顶级程序功能的用法,该功能随后在 C# 10 及以后的模板中默认使用。你还将了解一些寻找帮助的好地方,包括 ChatGPT 和 GitHub Copilot 等 AI 工具,以及如何通过其 GitHub 存储库和未来的印刷版本来联系我以获取帮助或提供改进书籍的反馈。

第二章C#入门,介绍了 C#的版本,并在在线部分有表格显示哪些版本引入了新功能。我将解释你将每天使用的语法和词汇,以编写应用程序的源代码。特别是,你将学习如何声明和使用不同类型的变量。

第三章控制流程、类型转换和异常处理,涵盖了使用运算符对变量执行简单操作,包括比较、编写做出决策的代码、模式匹配、重复语句块以及类型之间的转换。本章还涵盖了编写防御性代码以处理不可避免发生的异常,包括在.NET 8 中引入的ArgumentOutOfRangeException类上使用ThrowIfLessThan等守卫子句。

第四章编写、调试和测试函数,是关于通过编写可重用函数来遵循不要重复自己DRY)原则,这些函数使用命令式和函数式实现风格。你还将学习如何使用调试工具追踪和消除错误,使用热重载在应用程序运行时进行更改,在代码执行时监控代码以诊断问题,以及严格测试你的代码以消除错误,确保在部署到生产之前稳定性和可靠性。

第五章使用面向对象编程构建自己的类型,讨论了类等类型可以拥有的所有不同成员类别,包括用于存储数据的字段和执行操作的函数。你将使用面向对象编程(OOP)的概念,如聚合和封装,以及如何管理类型的命名空间,包括使用 C# 12 引入的能力来别名任何引入的类型。你将学习语言特性,如元组语法支持、out变量、局部函数和默认文字以及推断的元组名称。你还将学习如何使用record关键字、init-only 属性和with表达式定义和操作不可变类型,这些都是在 C# 9 中引入的。最后,我们来看看 C# 11 如何引入required关键字来帮助避免过度使用构造函数来控制初始化,C# 12 如何为非记录类型引入主构造函数,以及 C# 13 如何扩展对params参数的支持类型,以及你现在如何定义部分属性以及部分方法。

第六章实现接口和继承类,解释了如何使用面向对象编程(OOP)从现有类型派生出新类型。你将学习如何定义运算符、委托和事件,如何实现关于基类和派生类的接口,如何覆盖类型的成员,如何使用多态,如何创建扩展方法,如何在继承层次结构中的类之间进行类型转换,以及 C# 8 中引入可空引用类型带来的重大变化,以及 C# 10 及以后将其作为默认设置的转换。在可选的在线部分,你可以了解分析器如何帮助你编写更好的代码。

第七章打包和分发.NET 类型,介绍了.NET 的版本,并在在线部分包含显示哪个版本引入了新库功能的表格。然后,我将介绍符合.NET Standard 的.NET 类型,并解释它们如何与 C#相关。在整个章节中,你将学习如何在任何支持的操作系统上编写和编译代码,包括 Windows、macOS 和 Linux 变体。你将学习如何打包、部署和分发你自己的应用程序和库。在三个可选的在线部分,你可以了解如何在.NET 库中使用遗留.NET Framework 库,关于将遗留.NET Framework 代码库迁移到现代.NET 的可能性,以及关于源生成器和如何创建它们。

第八章使用常见的.NET 类型,讨论了允许你的代码执行常见实用任务的类型,例如操作数字和文本、在集合中存储项,以及在可选的仅限在线章节中,使用低级类型与网络进行操作。你还将了解正则表达式以及使编写它们更简单的改进,以及如何使用源生成器来提高它们的性能。随着.NET 9 的引入,出现了新的类,用于 Base64 编码的 URL、在大型整数上执行乘法、有序字典、只读集、改进字符串搜索以及能够在部分属性上生成正则表达式。

第九章处理文件、流和序列化,涵盖了与文件系统交互、读写文件和流、文本编码以及 JSON 和 XML 等序列化格式,包括System.Text.Json类改进的功能和性能。如果你使用 Linux,那么你可能会对如何使用tar存档进行编程操作感兴趣,你可以在一个仅限在线的章节中了解这些内容。.NET 9(终于!)移除了危险的遗留BinaryFormatter序列化类,并添加了一个用于导出 JSON 模式的类。

第十章使用 Entity Framework Core 处理数据,解释了如何使用名为Entity Framework CoreEF Core)的对象关系映射ORM)技术,对关系型数据库(如 SQL Server 和 SQLite)进行读写操作。你将学习如何使用Database First模型定义实体模型,这些模型映射到数据库中现有的表。在三个仅限在线的章节中,你还可以学习如何定义Code First模型,这些模型可以在运行时创建表和数据库,如何插入、更新和删除数据,以及如何使用事务将多个更改组合在一起。

第十一章使用 LINQ 查询和操作数据,介绍了语言集成查询LINQ)——语言扩展,它添加了处理项目序列并过滤、排序和将它们投影到不同输出的能力。本章包括.NET 6 中引入的 LINQ 方法,如TryGetNonEnumeratedCountDistinctBy,.NET 7 中的OrderOrderDescending,以及.NET 9 中的CountByAggregateByIndex。可选的仅限在线章节涵盖了使用并行 LINQ 的多线程、使用 LINQ to XML 以及创建自己的 LINQ 扩展方法。

第十二章使用.NET 介绍现代 Web 开发,介绍了可以使用 C#和.NET 构建的 Web 项目类型。你还将构建一个 EF Core 模型来表示虚构组织Northwind的数据库,该数据库将在本书的其余章节中使用。你将学习如何使用中央包管理来高效地管理 NuGet 包版本。最后,你将了解 HTML 和 CSS 等常见 Web 技术。

第十三章使用 ASP.NET Core 构建网站,是关于学习使用 ASP.NET Core 在服务器端构建具有现代 HTTP 架构的网站的基础。您将学习如何实现 ASP.NET Core 的 Blazor 静态服务器端渲染(SSR)功能,该功能取代了用于创建数据驱动 Web 页面的旧 Razor Pages 技术。您将学习如何使用.NET 9 引入的新方法MapStaticAssets来优化静态资源,如 JavaScript 和样式表。在两个仅在线提供的可选部分中,您将了解如何自定义 HTTP 请求和响应管道,并深入了解在网站项目中启用 HTTP/3 的方法。

第十四章使用 Blazor 构建交互式 Web 组件,介绍了如何使用 Blazor 构建可以在服务器端或浏览器客户端内部执行的 Web 用户界面组件。您将看到如何构建易于在客户端和服务器之间切换的组件,这是随着.NET 8 引入的新托管模型。

第十五章构建和消费 Web 服务,解释了使用 ASP.NET Core Minimal APIs 构建后端 REST 架构 Web 服务的方法。我们将介绍如何使用新的 Microsoft 实现的 OpenAPI 文档生成器来文档化它们。随着.NET 9 的引入,我们将学习如何使用混合缓存来获得内存和分布式缓存的最佳效果。然后我们将看到如何使用工厂实例化的 HTTP 客户端来正确消费它们。在两个仅在线提供的可选部分中,您将了解高级功能,如健康检查和添加安全 HTTP 头,以及 Minimal APIs 项目如何在发布过程中使用本机预编译AOT)编译来提高启动时间和内存占用。

后记描述了您进一步学习 C#和.NET 的选项。

附录测试你的知识问题答案,包含了每章末尾的测试问题的答案。您可以通过以下链接阅读附录:packt.link/LsQtz

您需要为本书准备的内容

您可以使用 VS Code 和大多数操作系统上的命令行工具来开发和部署 C#和.NET 应用程序,包括 Windows、macOS 和许多种类的 Linux。您只需要一个支持 VS Code 的操作系统和互联网连接,就可以跟随本书学习。

如果您更喜欢其他选择,那么您可以选择使用 Visual Studio 或 JetBrains Rider 等第三方工具。

下载本书中的彩色图像

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

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

规范

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

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 Publishing 购买这本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花一点时间留下一个Amazon 评价;这只需一分钟,但对像您这样的读者来说意义重大。

扫描下面的二维码,免费获得您选择的电子书。

带有黑色方块的二维码,自动生成描述

https://packt.link/NzOWQ

下载这本书的免费 PDF 副本

感谢您购买这本书!

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

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

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

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

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

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

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

https://packt.link/free-ebook/9781835881224

  1. 提交您的购买证明。

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

第一章:嗨,C#! 欢迎使用 .NET!

在本章中,第一个目标是设置您的开发环境以使用 Visual Studio 2022、Visual Studio Code 或 JetBrains Rider。

在本书的整个过程中,我将使用 Visual StudioVS CodeRider 这三个名称分别指代这三个代码编辑器。

其他目标包括理解现代 .NET、.NET Core、.NET Framework、Mono、Xamarin 和 .NET Standard 之间的相似之处和不同之处;使用 C# 13 和 .NET 9 创建可能的最简单应用程序,使用各种代码编辑器;然后发现寻找帮助的好地方。

我的写作风格确保您可以从我的书中开始,然后切换到在线文档和其他在线资源,如视频、博客和论坛,这些是开发者喜欢学习的主要方式,如下链接所示:survey.stackoverflow.co/2024/developer-profile#learning-to-code

本章节涵盖了以下主题:

  • 介绍本书及其内容

  • 设置您的开发环境

  • 理解 .NET

  • 使用 Visual Studio 构建控制台应用程序

  • 使用 VS Code 构建控制台应用程序

  • 充分利用本书的 GitHub 仓库

  • 寻找帮助

介绍本书及其内容

让我们从介绍这本书的代码解决方案、结构和内容开始。

获取本书的代码解决方案

本书 GitHub 仓库提供了使用完整应用程序项目解决所有代码任务和练习的解决方案,如下链接所示:

github.com/markjprice/cs13net9

在您的网络浏览器中导航到 GitHub 仓库后,按键盘上的 .(点)键,或手动将链接中的 .com 更改为 .dev,将仓库转换为基于 VS Code 的实时代码编辑器,称为 GitHub Codespaces,如图 1.1 所示:

图 1.1:GitHub Codespaces 在实时编辑本书的 GitHub 仓库

警告! 当您使用 GitHub Codespaces 访问仓库时,其 GitHub Repositories 扩展将尝试下载仓库的索引。默认情况下,最大大小可达 50MB,但仓库超过了此限制,因此您将看到以下警告信息:“仓库超过下载大小限制!继续使用部分文本服务。”您可以忽略此信息,因为您没有使用完整功能。

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

在网络浏览器中运行 VS Code 是在您完成本书的编码任务时与您选择的本地代码编辑器并行运行的好方法。您可以将您的代码与解决方案代码进行比较,并在需要时轻松复制和粘贴部分内容。

你不需要使用或了解 Git 就能获取本书的解决方案代码。你可以通过以下直接链接下载包含所有代码解决方案的 ZIP 文件,然后将 ZIP 文件提取到你的本地文件系统中:github.com/markjprice/cs13net9/archive/refs/heads/main.zip

本书使用的.NET 术语

在这本书中,我使用术语现代.NET来指代.NET 9 及其前身,如.NET 6,它们源自.NET Core。我使用术语传统.NET来指代.NET Framework、Mono、Xamarin 和.NET Standard。

现代.NET 是那些传统平台和标准的统一体。

本书的结构和风格

在第一章之后,本书将分为三个部分:语言、库和 Web 开发。

首先,我们将涵盖 C#语言的语法和词汇;其次,我们将涵盖.NET 库中可用于构建应用程序功能的类型;第三,我们将涵盖使用 C#和.NET 构建的跨平台网站、服务和浏览器应用程序的基础。

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

你不需要立即知道所有细节。这将是随着你构建自己的应用程序并超越任何书籍所能教授的内容而逐渐出现的事情。

用 1755 年编写英语词典的塞缪尔·约翰逊的话来说,我犯了一些“一些荒谬的错误和可笑的谬误,任何如此繁多的作品都无法避免。”我对此承担全部责任,并希望你能欣赏我尝试通过撰写关于快速发展的技术(如 C#和.NET)以及你可以用它们构建的应用程序的书所带来的挑战。

如果你对这本书有任何投诉,请在撰写亚马逊上的负面评论之前联系我。作者无法回应亚马逊评论,所以我无法联系你解决问题并帮助你或倾听你的反馈并试图在下一版中做得更好。请在本书的 Discord 频道中提出问题packt.link/csharp13dotnet9,通过markjprice@gmail.com给我发邮件,或在以下链接的 GitHub 存储库中提出问题:github.com/markjprice/cs13net9/issues

本书涵盖的主题

本书涵盖了以下主题:

  • 语言基础:C#语言的基本特性,从声明变量到编写函数和面向对象编程。

  • 库基础:.NET 基础类库的基本功能,以及一些用于数据库访问等常见任务的某些重要可选包。

  • 现代 Web 开发基础:ASP.NET Core 框架的现代功能,用于服务器端和客户端网站及 Web 服务开发。这包括 Blazor 和最小 API,不包括基于控制器的功能,如 MVC 和 Web API,或遗留功能,如 Razor Pages。

这本书,《C# 13 和 .NET 9 – 现代跨平台开发基础》,最好按章节顺序线性阅读,因为它逐步构建基本技能和知识。

.NET 8 应用和服务涵盖的主题

以下主题可在配套书籍《.NET 8 应用和服务》中找到:

  • 数据:SQL Server 和 Azure Cosmos DB。

  • 专用库:日期、时间、时区和国际化;用于图像处理、日志记录、映射和生成 PDF 的常见第三方库;多任务和并发;等等。

  • 服务:缓存、队列、后台服务、gRPC、GraphQL、Azure Functions、SignalR 和最小 API。

  • 用户界面:ASP.NET Core、Blazor 和 .NET MAUI。

这本书更像是一本食谱书,所以如果你特别感兴趣于构建 gRPC 服务,那么你可以直接阅读那章,而无需阅读关于最小 API 服务的 preceding chapters。

.NET 8 工具和技能涵盖的主题

以下主题可在配套书籍《.NET 8 工具和技能》中找到:

  • 调试和内存分析。

  • 从单元和集成测试到性能和 Web UI 测试的所有重要测试类型。

  • Docker 和 .NET Aspire 用于本地分布式开发和测试。

  • 设计模式和解决方案架构。

  • 准备面试以获得你想要的 .NET 开发者职业。

我为 .NET 8 编写的书籍同样适用于 .NET 9。你可以让你的项目针对 .NET 9,所有代码示例都将正常工作。由于向后兼容性,你不需要等待包升级以也针对 .NET 9,因此无需等待。

查找所有我的书籍

要查看我通过 Packt 出版的所有书籍的列表,你可以使用以下链接:

subscription.packtpub.com/search?query=mark+j.+price

在亚马逊上也有类似的列表:

www.amazon.com/Mark-J-Price/e/B071DW3QGN/

你也可以在其他图书销售网站上搜索我的书籍。

设置你的开发环境

在开始编程之前,你需要一个用于 C# 的代码编辑器,无论是来自 Microsoft 还是第三方。

Microsoft 拥有一系列代码编辑器和 集成开发环境IDEs),包括:

  • Visual Studio for Windows

  • VS Code for Windows、Mac 或 Linux

  • VS Code for the Web 或 GitHub Codespaces

第三方已经创建了他们自己的 C#代码编辑器;例如,JetBrains 有跨平台的 Rider,它适用于 Windows、Mac 或 Linux,并且自 2024 年 10 月起对非商业用途免费。Rider 在经验更丰富的.NET 开发者中很受欢迎。

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

大多数读者使用 Visual Studio,这是一个庞大而复杂的工具,可以做很多事情。但 Visual Studio 喜欢提供自己的机制来做尽可能多的事情,使用它的.NET 开发者可能会很容易地认为 Visual Studio 是完成.NET 相关任务(如修改项目配置或编辑代码文件)的唯一方式。

总是试图记住,Visual Studio 和所有其他代码编辑器只是为你做工作的工具,你可以手动完成这些工作。它们只是显示了你正在工作的文件(如项目文件和所有 C#代码文件)中真正发生的事情的视图。

你可以使用纯文本编辑器手动编辑项目和代码文件。最终,你使用dotnet命令行界面来编译(即构建)项目文件,将其打包成可运行的程序集,作为.dll.exe文件,如图图 1.2所示:

图片

图 1.2:所有代码编辑器最终只是更改底层文件

选择适合学习的适当工具和应用类型

学习 C#和.NET 的最佳工具和应用类型是什么?

在学习时,最好的工具是那些帮助你编写代码和配置,但不会隐藏真正发生的事情的工具。IDEs 提供了友好的图形用户界面,但它们在底层为你做了什么?一个更基本的代码编辑器,在提供编写代码帮助的同时更接近操作,在学习过程中可能会更好。

话虽如此,你可以提出这样的论点:最好的工具是你已经熟悉或者你或你的团队将用作日常开发工具的工具。因此,我希望你能自由选择任何 C#代码编辑器或 IDE 来完成这本书中的编码任务,包括 VS Code、Visual Studio,甚至是 Rider。

在本书中,我在本章中提供了详细的分步指导,说明如何在 Visual Studio 和 VS Code 中创建多个项目。同时,还有链接到其他代码编辑器的在线指导,如下所示:github.com/markjprice/cs13net9/blob/main/docs/code-editors/README.md

在随后的章节中,我只会给出项目的名称以及一般性说明,这样你可以使用你喜欢的任何工具。

对于学习 C#语言结构和许多.NET 库来说,最佳的应用类型是不受不必要的应用程序代码干扰的类型。例如,没有必要创建一个完整的 Windows 桌面应用程序或网站,只是为了学习如何编写switch语句。

因此,我相信学习第一章到第十一章的 C#和.NET 主题的最佳方法是构建控制台应用程序。然后,在第十二章到第十五章,关于 Web 开发的部分,你将使用 ASP.NET Core 的现代部分构建网站和服务,包括 Blazor 和 Minimal APIs。

用于跨平台开发的 VS Code

从中选择最现代和轻量级的代码编辑器,并且是微软唯一一款跨平台的编辑器,就是 VS Code。它可以在所有常见的操作系统上运行,包括 Windows、macOS 以及许多 Linux 版本,例如红帽企业 LinuxRHEL)和 Ubuntu。

VS Code 是现代跨平台开发的良好选择,因为它拥有广泛且不断增长的扩展集,支持 C#以外的许多语言。对于 C#和.NET 开发者来说,最重要的扩展是于 2023 年 6 月发布的C#开发工具包C# Dev Kit),它将 VS Code 从通用代码编辑器转变为针对 C#和.NET 开发者优化的工具。

更多信息:你可以在以下链接的官方公告中了解关于C#开发工具包扩展的更多信息:devblogs.microsoft.com/visualstudio/announcing-csharp-dev-kit-for-visual-studio-code/.

由于 VS Code 及其扩展跨平台且轻量级,可以安装到你的应用程序将部署的所有平台上,以便快速修复错误等。选择 VS Code 意味着开发者可以使用跨平台的代码编辑器来开发跨平台的应用程序。VS Code 支持 ARM 处理器,因此你可以在苹果硅电脑和树莓派电脑上开发。

VS Code 对 Web 开发有强大的支持,尽管目前对移动和桌面开发的支持较弱。

VS Code 迄今为止是最受欢迎的代码编辑器或集成开发环境(IDE),在 Stack Overflow 的调查中,超过 73%的专业开发者选择了它,你可以通过以下链接阅读调查结果:survey.stackoverflow.co/2024/.

用于云上开发的 GitHub Codespaces

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

但请注意,为了使您的 GitHub Codespaces 体验完全功能化和实用,它确实有一定的许可费用。

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

Visual Studio 通用开发

Visual Studio 可以创建大多数类型的应用程序,包括控制台应用程序、网站、网络服务和桌面应用程序。尽管您可以使用 Visual Studio 编写跨平台的移动应用程序,但您仍然需要 macOS 和 Xcode 来编译它。

Visual Studio 仅在 Windows 10 版本 1909 或更高版本、家庭版、专业版、教育版或企业版;或 Windows 11 版本 21H2 或更高版本、家庭版、专业版、专业教育版、工作站专业版、企业版或教育版上运行。Windows Server 2016 及更高版本也受支持。32 位操作系统和 Windows S 模式不支持。

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

我所使用的

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

  • 在 Surface Laptop 7 Copilot+ PC 上使用 Visual Studio、VS Code 和 Rider 运行 Windows 11。

  • 在配备 Apple Silicon Mac mini (M1) 台式机的 macOS 上使用 VS Code 和 Rider。

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

部署跨平台

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

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

  • Windows: Windows 10 版本 1607 或更高版本、Windows 11 版本 22000 或更高版本、Windows Server 2012 R2 SP1 或更高版本,以及 Nano Server 版本 2019 或 2022。

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

  • Linux: Alpine Linux 3.19 或 3.20、CentOS Stream 9、Debian 12、Fedora 40、openSUSE 15.5 或 15.6、RHEL 8 或 9、SUSE Enterprise Linux 15.5 或 15.6、以及 Ubuntu 20.04、22.04 或 24.04。

  • Android: 最低 SDK 目标是 API 21 或更高版本。版本 12、12.1、13 和 14。

  • iOSiPadOS: 15、16 或 17。iOS 12.2 作为最低 SDK 目标。

  • Mac 催化剂: 12、13 或 14。

警告! .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 11 以及 Surface Laptop 7。

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

所有受支持的.NET 版本都可以通过 Windows 的 Microsoft Update 自动更新。

下载并安装 Visual Studio

许多专业的.NET 开发者在日常开发工作中使用 Visual Studio。即使你选择使用 VS Code 来完成本书中的编码任务,你也可能想熟悉一下 Visual Studio。只有当你用某个工具编写了一定数量的代码后,你才能真正判断它是否符合你的需求。

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

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

  1. 从以下链接下载 Visual Studio 的最新版本:visualstudio.microsoft.com/downloads/

Visual Studio vNext:在撰写本文时,Visual Studio 的版本是 17.12,并命名为 Visual Studio 2022。我预计下一个主要版本的 Visual Studio 将是 18.0,并命名为 Visual Studio 2025。它可能将在本书发布后的 2025 年上半年发布。Visual Studio 2025 将主要具有与 2022 版相同的特性,尽管用户界面可能会做一些调整。

  1. 运行安装程序以开始安装。

  2. 工作负载选项卡上,选择以下:

    • ASP.NET 和 Web 开发

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

    • 使用 C++进行桌面开发(包含所有默认组件,因为这样可以让你发布启动更快且内存占用更小的控制台应用程序和 Web 服务)。

  3. 点击安装,等待安装程序获取所选软件,然后进行安装。

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

  5. 第一次运行 Visual Studio 时,您将被提示登录。如果您有 Microsoft 账户,可以使用该账户。如果没有,可以在以下链接注册一个新账户:signup.live.com/

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

  7. 如果您想自定义您的键盘快捷键,请导航到 工具 | 选项…,然后选择 键盘 部分。

Visual Studio 的键盘快捷键

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

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

下载并安装 VS Code

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

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

现在我们来下载并安装 VS Code、.NET SDK 以及 C# 开发工具包 扩展:

  1. 从以下链接下载并安装 VS Code 的稳定版或 Insiders 版本:code.visualstudio.com/

更多信息:如果您需要更多帮助安装 VS Code,可以阅读以下链接中的官方安装指南:code.visualstudio.com/docs/setup/setup-overview

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

在现实生活中,您极不可能只在您的计算机上安装一个 .NET SDK 版本。为了了解如何控制用于构建项目的 .NET SDK 版本,我们需要安装多个版本。.NET 8 和 .NET 9 是截至 2024 年 11 月出版时唯一受支持的版本。您可以安全地并行安装多个 SDK。最新版本的 SDK 将用于构建您的项目。

  1. 要使用用户界面安装 C# 开发工具包 扩展,您必须首先启动 VS Code 应用程序。

  2. 在 VS Code 中,点击 扩展 图标或导航到 视图 | 扩展

  3. C# 开发工具包 是最受欢迎的扩展之一,因此您应该能在列表顶部看到它,或者您可以在搜索框中输入 C#

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

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

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

安装其他扩展

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

扩展名称和标识符 描述
C# 开发工具包ms-dotnettools.csdevkit 来自微软的官方 C# 扩展。帮助您使用解决方案资源管理器管理代码,并通过集成的单元测试发现和执行测试您的代码,无论您在哪里开发(Windows、macOS、Linux,甚至是在代码空间中),都能提升您的 C# 开发体验。
C#ms-dotnettools.csharp 提供了丰富的 C# 语言支持,并随 C# 开发工具包一起提供。由一个 语言服务器协议 (LSP) 服务器提供支持,此扩展与开源组件如 Roslyn 和 Razor 集成,以提供丰富的类型信息和更快、更可靠的 C# 体验。
IntelliCode for C# 开发工具包ms-dotnettools.vscodeintellicode-csharp 为 Python、TypeScript/JavaScript、C# 和 Java 开发者提供 AI 辅助开发功能。
MSBuild 项目工具tintoy.msbuild-project-tools 为 MSBuild 项目文件提供 IntelliSense,包括 <PackageReference> 元素的自动完成。
Markdown All in Oneyzhang.markdown-all-in-one 提供了 Markdown 所需的一切(键盘快捷键、目录、自动预览等)。
多语言笔记本ms-dotnettools.dotnet-interactive-vscode 此扩展增加了在笔记本中使用 .NET 和其他语言的支持。它依赖于 Jupyter 扩展 (ms-toolsai.jupyter),该扩展本身也有依赖项。
ilspy-vscodeicsharpcode.ilspy-vscode 反编译 MSIL 程序集 – 支持 .NET、.NET Framework、.NET Core 和 .NET Standard。
REST 客户端humao.rest-client 在 VS Code 中发送 HTTP 请求并直接查看响应。

表 1.1:VS Code 的 .NET 开发扩展

你可以在命令提示符或终端中安装 VS Code 扩展,如下 表 1.2 所示:

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

表 1.2:在命令提示符中管理 VS Code 扩展

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

code --install-extension ms-dotnettools.csdevkit 

我已经创建了 PowerShell 脚本来安装和卸载前面表格中的 VS Code 扩展。你可以在以下链接中找到它们:github.com/markjprice/cs13net9/tree/main/scripts/extension-scripts/。PowerShell 脚本是跨平台的,你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/powershell/scripting/overview

理解 VS Code 版本

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

  • 版本 1.93.0,2024 年 8 月的功能发布

  • 版本 1.93.1,2024 年 8 月的错误修复发布

本书使用的版本是 1.93.0,2024 年 8 月的功能发布版,但 VS Code 的版本不如你安装的 C# 开发工具包C# 扩展的版本重要。我建议使用 C# 开发工具包 v1.10.18 或更高版本,以及 C# 扩展 v2.45.20 或更高版本。

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

VS Code 的快捷键

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

我建议你从以下列表中下载适用于你操作系统的 VS Code 快捷键 PDF:

理解 .NET

“那些不能记住过去的人注定要重蹈覆辙。”

– 乔治·桑塔亚那

.NET、.NET Core、.NET Framework、.NET Standard 和 Xamarin 是开发者常用的相关且重叠的平台,用于构建应用程序和服务。

如果你不太熟悉 .NET 的历史,那么我将在以下链接中为你介绍这些 .NET 概念:

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

随着时间的推移,越来越多的读者已经了解了.NET 的历史,所以在书中继续包含它将会是空间的浪费。但如果你是新手,那么请确保阅读我在网上提供的所有额外内容,如前面的内容。

理解.NET 支持

.NET 版本可以是长期支持LTS)、标准支持期(STS)(以前称为当前)或预览,如下面的列表中所述:

  • LTS版本是对于那些不打算频繁更新的应用程序的好选择,尽管你必须每月更新你的生产代码的.NET 运行时。LTS 版本在通用可用性GA)后的 3 年内或下一个 LTS 版本发布后的 1 年内得到微软的支持,以较长者为准。

  • STS版本包含可能根据反馈而更改的特性。这些版本对于你正在积极开发的应用程序来说是个不错的选择,因为它们提供了访问最新改进的机会。STS 版本在 GA 后的 18 个月内或下一个 STS 或 LTS 版本发布后的 6 个月内得到微软的支持,以较长者为准。

  • 预览版本是供公众测试的。这些版本对于想要走在技术前沿的冒险程序员或需要提前访问新语言特性、库以及应用和服务平台的编程书籍作者来说是个不错的选择。预览版本通常不得到微软的支持,但一些预览或发布候选RC)版本可能会被宣布为上线,这意味着它们在生产中得到微软的支持。

STS 和 LTS 版本在其生命周期内会收到针对安全和可靠性的关键补丁。

良好实践:你必须保持与最新补丁的同步以获得支持。例如,如果一个系统正在运行.NET 运行时版本 9.0.0,然后版本 9.0.1 发布,你必须安装版本 9.0.1 以获得支持。这些更新在每个月的第二个星期二发布,也就是所谓的补丁星期二

为了更好地理解你关于 STS 和 LTS 版本的选择,看到它们以可视化方式呈现是有帮助的,LTS 版本用 3 年长的黑色条表示,STS 版本用 1½年长的灰色条表示,如图图 1.3所示:

图 1.3:最近和计划中的 STS 和 LTS 版本的支持时长

在.NET 9 的生命周期内,.NET 8 仍将得到支持,而.NET 10 将发布。我已经尽力考虑到你可能选择使用本书中的.NET 8 或.NET 10;然而,显然,本书无法涵盖.NET 10 的新特性,因为我不知道它们会是什么!

如果你需要来自微软的长周期支持(LTS),那么请将你的 .NET 项目设置为针对 .NET 8,并在 2025 年 11 月发布后迁移到 .NET 10。这是因为 .NET 9 是一个短期支持(STS)版本,因此它将在 2026 年 5 月之前失去支持,而 .NET 8 将在 2026 年 11 月失去支持。一旦 .NET 10 发布,立即开始将你的 .NET 8 项目升级到它。在 .NET 8 达到其生命周期的结束之前,你将有整整一年的时间来完成这项工作。

良好实践:记住,对于所有发布版本,你必须升级到错误修复版本,如 .NET 运行时 9.0.1 和 .NET SDK 9.0.101,这些版本预计将在 2024 年 12 月发布,因为更新每月都会发布。

在 2024 年 11 月发布时,所有现代 .NET 版本都已达到其 EOL,除了以下列表中显示的版本,它们按其 EOL 日期排序:

  • .NET 9 将在 2026 年 5 月达到 EOL。

  • .NET 8 将在 2026 年 11 月达到 EOL。

  • .NET 10 将从 2025 年 11 月开始提供,并将在 2028 年 11 月达到 EOL。

你可以通过以下链接检查哪些 .NET 版本目前正在得到支持以及它们何时将达到 EOL:github.com/dotnet/core/blob/main/releases.md

理解生命周期的结束(EOL)

支持结束生命周期的结束(EOL)意味着在此日期之后,微软将不再提供错误修复、安全更新或技术支持。

例如,现在 .NET 6 在 2024 年 11 月 12 日达到支持结束,你可以期待以下情况:

  • 使用 .NET 6 的项目将继续运行。

  • 对于 .NET 6,将不再发布新的安全更新,因此继续使用不受支持的版本将越来越容易暴露你于安全漏洞。

  • 你可能无法访问你继续使用的任何 .NET 6 应用程序的技术支持。

  • 当你从像 .NET 9 SDK 这样的后续 SDK 靶向 .NET 6 时,你会得到 NETSDK1138 构建警告。

  • 当你针对 .NET 6 时,你将在 Visual Studio 中收到警告。

理解 .NET 支持阶段

.NET 版本的寿命会经过几个阶段,在这些阶段中,它们的支持水平各不相同,如下列所示:

  • 预览版:这些版本根本不受支持。.NET 9 预览版 1 到预览版 7 从 2024 年 2 月到 2024 年 8 月处于此支持阶段。

  • 上市:这些版本在 GA 之前得到支持,然后立即不再支持。一旦可用,你必须立即升级到最终发布版本。.NET 9 发布候选版本 1 和发布候选版本 2 分别在 2024 年 9 月和 10 月处于此支持阶段。

  • 活跃状态:.NET 9 将从 2024 年 11 月到 2025 年 11 月处于此支持阶段。

  • 维护:在其生命周期的最后 6 个月中仅提供安全修复。.NET 9 将从 2025 年 11 月到 2026 年 5 月处于此支持阶段。

  • EOL:不受支持。.NET 9 将在 2026 年 5 月达到其 EOL。

理解 .NET 运行时和 .NET SDK 版本

如果您尚未构建独立应用程序,那么您至少需要安装 .NET 运行时,以便操作系统可以运行 .NET 应用程序。.NET SDK 包含 .NET 运行时,以及构建 .NET 代码和应用程序所需的编译器和其他工具。

.NET 运行时版本化遵循语义版本化——也就是说,主版本号的增加表示破坏性更改,次版本号的增加表示新功能,补丁版本号的增加表示错误修复。

.NET SDK 版本化不遵循语义版本化。主版本号和次版本号与它们匹配的运行时版本相关联。第三个数字遵循一个约定,表示 SDK 的次版本和补丁版本。第三个数字从 100 开始,对于初始版本(相当于次版本和补丁号为 0.0)。第一个数字随着次版本的增加而增加,其他两个数字随着补丁版本的增加而增加。

您可以在 表 1.3 中看到一个例子:

变更 运行时 SDK
首次发布 9.0.0 9.0.100
SDK 错误修复 9.0.0 9.0.101
运行时和 SDK 错误修复 9.0.1 9.0.102
SDK 新功能 9.0.1 9.0.200

表 1.3:.NET 运行时和 SDK 的更改和版本示例

列出和删除 .NET 版本

.NET 运行时更新与 9.x 等主版本兼容,.NET SDK 的更新版本保持构建针对先前运行时版本的应用程序的能力,这允许安全地删除旧版本。

您可以使用以下命令查看当前已安装的 SDK 和运行时:

dotnet --list-sdks
dotnet --list-runtimes
dotnet --info 

良好实践:为了使在命令提示符或终端中输入命令更容易,以下链接列出了本书中可以轻松复制和粘贴的所有命令:github.com/markjprice/cs13net9/blob/main/docs/command-lines.md.

在 Windows 上,使用 应用和功能 部分来删除 .NET SDK。

在 Linux 上,没有单一机制,但您可以在以下链接中了解更多信息:

learn.microsoft.com/en-us/dotnet/core/install/remove-runtime-sdk-versions?pivots=os-linux

您可以使用第三方工具,如位于以下链接的友好 .NET SDK 管理器 Dots:johnnys.news/2023/01/Dots-a-dotnet-SDK-manager。在撰写本文时,您必须从其 GitHub 仓库中构建应用程序,因此我只推荐高级开发者使用。

理解中间语言

dotnet CLI 工具使用的 C#编译器(命名为Roslyn)将你的 C#源代码转换为中间语言IL)代码,并将 IL 存储在程序集(DLL 或 EXE 文件)中。IL 代码语句类似于汇编语言指令,由.NET 的虚拟机(称为 CoreCLR,是现代.NET 中公共语言运行时(CLR)的新名称)执行。传统的.NET Framework 有一个仅限 Windows 的 CLR,而现代.NET 为每个操作系统(如 Windows、macOS 和 Linux)都有一个。如今,它们通常都被称为 CLRs。

在运行时,CoreCLR 从程序集加载 IL 代码,即时编译器将其编译成本地 CPU 指令,然后由你的机器上的 CPU 执行。

这种两步编译过程的优点是,微软可以为 Linux 和 macOS 以及 Windows 创建CLRs。由于第二步骤生成了适用于本地操作系统和 CPU 指令集的代码,因此相同的 IL 代码可以在任何地方运行。

不论源代码是用哪种语言编写的(例如,C#、Visual Basic 或 F#),所有.NET 应用程序都使用存储在程序集中的 IL 代码作为它们的指令。微软和其他人提供了可以打开程序集并揭示此 IL 代码的反汇编工具,例如 ILSpy .NET 反汇编器扩展。你将在第七章打包和分发.NET 类型中了解更多信息,该章节可在以下链接找到:github.com/markjprice/cs13net9/blob/main/docs/ch07-decompiling.md

因此,编译过程通常涉及将源代码转换为 IL,然后由 CLR 使用 JIT 编译在运行时将其编译成机器代码。提前编译(AOT)是这种方法的替代方案,你将在第七章打包和分发.NET 类型中了解它。

比较.NET 技术

我们可以总结并比较当前的.NET 技术,如表 1.4所示:

技术 描述 宿主操作系统
现代 NET 包含从 C# 8 到 C# 13 的完整语言支持的现代功能集。它可以用于移植现有应用程序或创建新的桌面、移动和 Web 应用程序和服务。 Windows、macOS、Linux、Android、iOS、tvOS 和 Tizen
.NET Framework 具有限制 C# 8 支持且没有 C# 9 或更高版本支持的旧功能集。它仅应用于维护现有应用程序。 仅限 Windows
Xamarin 仅适用于移动和桌面应用程序。 Android、iOS 和 macOS

表 1.4:.NET 技术比较

Visual Studio、Rider 以及甚至安装了C#开发工具包扩展的 VS Code 都有一个称为解决方案的概念,它允许你同时打开和管理多个项目。我们将使用解决方案来管理本章中你将创建的两个项目。

使用 Visual Studio 构建控制台应用程序

本节的目标是展示如何使用 Visual Studio 构建控制台应用程序。

如果你没有 Windows 计算机或想使用 VS Code,则可以跳过此部分,因为代码将是相同的;只是工具体验不同。然而,我建议你查看此部分,因为它确实解释了一些代码和顶级程序的工作方式,这些信息适用于所有代码编辑器。

本节也可在 GitHub 仓库中找到(如果需要,发布后可以更新)以下链接:

github.com/markjprice/cs13net9/blob/main/docs/code-editors/vs.md

如果你想看到使用 Rider 的类似说明,它们可以在以下链接的 GitHub 仓库中找到:

github.com/markjprice/cs13net9/blob/main/docs/code-editors/rider.md

使用 Visual Studio 编写代码

让我们开始编写代码:

  1. 启动 Visual Studio。

  2. 创建新项目对话框中,选择 C#语言以过滤项目模板,在搜索模板框中输入console,然后选择控制台应用程序

确保你选择了跨平台项目模板,而不是仅适用于 Windows 的.NET Framework 模板,以及C#项目模板而不是其他语言,例如 Visual Basic 或 TypeScript。

  1. 点击下一步

  2. 配置新项目对话框中,将项目名称输入为HelloCS,将位置输入为C:\cs13net9,将解决方案名称输入为Chapter01

在 GitHub 仓库的以下链接中可以找到创建新项目时 Visual Studio 的截图:github.com/markjprice/cs13net9/blob/main/docs/ch01-project-options.md

  1. 点击下一步

  2. 附加信息对话框中,在框架下拉列表中,注意你的.NET SDK 选择表示该版本是标准支持期限长期支持预览还是已停用,然后选择.NET 9.0(标准支持期限)

你可以安装你喜欢的任何.NET SDK 版本。如果你缺少一个.NET SDK 版本,则可以从以下链接安装它:dotnet.microsoft.com/en-us/download/dotnet

  1. 保持标记为不使用顶级语句的复选框为空。(在本章的后面,你将创建一个选择此选项的控制台应用程序,因此你会看到差异。)

  2. 保持标记为启用原生 AOT 发布的复选框为空。你将在第七章打包和分发.NET 类型中了解此选项的功能。

  3. 点击创建

  4. 如果你看不到解决方案资源管理器,则导航到视图 | 解决方案资源管理器

  5. 如果代码没有显示,那么在 解决方案资源管理器 中,双击名为 Program.cs 的文件以打开它,并注意 解决方案资源管理器 显示 HelloCS 项目,如图 1.4 所示:

图 1.4:在 Visual Studio 中编辑 Program.cs

  1. Program.cs 中,请注意代码仅由一个注释和一条语句组成,如下所示代码:

    // See https://aka.ms/new-console-template for more information
    Console.WriteLine("Hello, World!"); 
    

    此模板使用了 C# 9 中引入的顶级程序功能,我将在本章后面解释。正如代码中的注释所说,您可以在以下链接中了解更多关于此模板的信息:aka.ms/new-console-template

  2. Program.cs 中,修改第 2 行,以便写入控制台的文字显示为 Hello, C#!

    所有必须审查或键入的代码示例和命令都以纯文本形式显示,因此您永远不需要从截图(如图 1.4 所示)中读取代码或命令,因为截图可能太小或太淡。

使用 Visual Studio 编译和运行代码

下一个任务是编译和运行代码:

  1. 在 Visual Studio 中,导航到 调试 | 不调试启动

良好实践:当您在 Visual Studio 中启动项目时,可以选择是否附加调试器。如果您不需要调试,那么最好不要附加,因为附加调试器需要更多资源,并且会减慢一切速度。附加调试器还会限制您只能启动一个项目。如果您想运行多个项目,每个项目都附加了调试器,那么您必须启动多个 Visual Studio 实例。在工具栏中,单击顶栏中 HelloCS 右侧的绿色轮廓三角形按钮(如图 1.5 所示),以不调试启动,而不是顶栏中 HelloCS 左侧的绿色实心三角形按钮(如图 1.5 所示),除非您需要调试。

  1. 控制台窗口中的输出将显示应用程序运行的结果,如图 1.5 所示:

图 1.5:在 Windows 上运行控制台应用程序

  1. 按任意键关闭控制台应用程序窗口并返回到 Visual Studio。

  2. 可选地,关闭 属性 选项卡以为 解决方案资源管理器 提供更多垂直空间。

  3. 双击 HelloCS 项目,并注意 HelloCS.csproj 项目文件显示,该项目已将其目标框架设置为 net9.0,如图 1.6 所示。

  4. 解决方案资源管理器 工具栏中,切换开启 显示所有文件 按钮,并注意编译器生成的 binobj 文件夹可见,如图 1.6 所示:

图 1.6:显示编译器生成的文件夹和文件

理解编译器生成的文件夹和文件

根据以下列表,创建了两个由编译器生成的文件夹,分别命名为 objbin

  • obj文件夹包含每个源代码文件的一个编译的对象文件。这些对象还没有被链接成一个最终的可执行文件。

  • bin文件夹包含应用程序或类库的二进制可执行文件。我们将在第七章打包和分发.NET 类型中更详细地探讨这一点。

你目前不需要查看这些文件夹或理解它们的文件(但如果你好奇,可以随意浏览)。

请注意,编译器需要创建临时文件夹和文件来完成其工作。你可以删除这些文件夹及其文件,它们将在你下次“构建”或运行项目时自动重新创建。开发者通常会删除这些临时文件夹和文件以“清理”项目。Visual Studio 甚至有一个名为清理解决方案的命令,它会为你删除一些这些临时文件。CLI 中的等效命令是dotnet clean

理解顶层程序

如果你之前见过较老的.NET 项目,那么你可能期望有更多的代码,甚至只是输出一个简单的消息。这个项目有最少的语句,因为当你针对.NET 6 或更高版本时,一些必需的代码是由编译器为你编写的。

如果你使用.NET SDK 5 或更早版本创建项目,或者如果你选择了标记为不使用顶层语句的复选框,那么Program.cs文件将包含更多的语句,如下面的代码所示:

using System;
namespace HelloCS
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Hello, World!");
    }
  }
} 

在使用.NET SDK 6 或更高版本进行编译时,所有用于定义Program类及其Main方法的样板代码都会生成并围绕你编写的语句包装。

这使用了.NET 5 中引入的一个特性,称为顶层程序,但直到.NET 6,微软才更新了控制台应用程序的项目模板,使其默认使用顶层语句。然后,在.NET 7 及以后的版本中,微软添加了选项,如果你更喜欢旧风格,可以使用:

  • 如果你正在使用 Visual Studio,请选择标记为不使用顶层语句的复选框。

  • 如果你正在命令提示符下使用dotnet CLI,请添加一个开关:

    dotnet new console --use-program-main 
    

警告!一个功能上的差异是,自动生成的代码没有定义命名空间,因此Program类在没有任何名称的空命名空间中隐式定义,而不是与项目名称匹配的命名空间。

顶层程序的要求

关于顶层程序需要记住的关键点包括以下内容:

  • 在一个项目中,只能有一个类似于用于顶层程序代码的文件。

  • 任何using语句都必须位于文件的顶部。

  • 如果你声明了任何类或其他类型,它们必须位于文件的底部。

  • 尽管你应该显式定义入口点方法为Main,但编译器创建的方法名为<Main>$

隐式导入的命名空间

文件顶部的using System;语句导入System命名空间。这使Console.WriteLine语句能够工作。但为什么我们不需要在我们的项目中导入它?

技巧在于我们仍然需要导入System命名空间,但现在它是通过 C# 10 和.NET 6 中引入的功能组合来完成的。让我们看看它是如何做到的:

  1. 解决方案资源管理器中,展开objDebugnet9.0文件夹,并打开名为HelloCS.GlobalUsings.g.cs的文件。

  2. 注意,此文件是由编译器自动为针对.NET 6 或更高版本的项目创建的,并使用 C# 10 中引入的功能,称为全局命名空间导入,该功能导入一些常用命名空间,如System,以便在所有代码文件中使用,如下面的代码所示:

    // <autogenerated />
    global using global::System;
    global using global::System.Collections.Generic;
    global using global::System.IO;
    global using global::System.Linq;
    global using global::System.Net.Http;
    global using global::System.Threading;
    global using global::System.Threading.Tasks; 
    
  3. 解决方案资源管理器中,单击显示所有文件按钮以隐藏binobj文件夹。

我将在下一章中解释更多关于隐式导入功能的内容。现在,只需注意,.NET 5 和.NET 6 之间发生的重大变化之一是,许多项目模板,如控制台应用程序模板,使用新的 SDK 和语言功能来隐藏实际发生的事情。

通过抛出异常来揭示隐藏的代码

现在我们来探究隐藏的代码是如何编写的:

  1. Program.cs中,在输出消息的语句之后,添加一个抛出新异常的语句,如下面的代码所示:

    throw new Exception(); 
    
  2. 在 Visual Studio 中,导航到调试 | 不调试启动。(不要以调试方式启动项目,否则异常将被调试器捕获!)

  3. 控制台窗口中的输出将显示应用程序的运行结果,包括编译器定义了一个隐藏的Program类,其中有一个名为<Main>$的方法,该方法有一个名为args的参数来传递参数,如图图 1.7和以下输出所示:

    Hello, C#!
    Unhandled exception. System.Exception: Exception of type 'System.Exception' was thrown.
       at Program.<Main>$(String[] args) in C:\cs13net9\Chapter01\HelloCS\Program.cs:line 3 
    

图片

图 1.7:抛出异常以揭示隐藏的 Program.

$方法

  1. 按任意键关闭控制台应用程序窗口并返回 Visual Studio。

揭示Program类的命名空间

现在,让我们探究Program类是在哪个命名空间中定义的:

  1. Program.cs中,在抛出异常的语句之前,添加语句以获取Program类的命名空间名称,并将其写入控制台,如下面的代码所示:

    string name = typeof(Program).Namespace ?? "<null>";
    Console.WriteLine($"Namespace: {name}"); 
    

??空合并运算符。第一个语句的意思是,“如果Program的命名空间是null,则返回<null>;否则,返回名称。”你将在本书的其余部分看到这些关键字和运算符的更多解释。现在,只需输入代码并运行它,看看它会做什么。

良好实践:代码编辑器有一个名为代码片段的功能。这允许您通过输入快捷键并按两次Tab来插入常用代码片段。例如,在 Visual Studio 中,要输入Console.WriteLine()并将光标置于括号中间以便您输入要输出的内容,请输入cw,然后按TabTab。阅读您代码编辑器的文档以了解如何使用快捷键插入代码片段。

  1. 在 Visual Studio 中,导航到调试 | 不调试启动

  2. 控制台窗口的输出将显示应用程序的运行结果,包括隐藏的Program类在没有命名空间的情况下被定义,如下面的输出所示:

    Namespace: <null> 
    
  3. 按任意键关闭控制台应用程序窗口并返回 Visual Studio。

使用 Visual Studio 添加第二个项目

让我们向我们的解决方案添加第二个项目以探索如何处理多个项目:

  1. 在 Visual Studio 中,导航到文件 | 添加 | 新建项目…

    警告!上述步骤将向现有解决方案添加一个新项目。请勿导航到文件 | 新建 | 项目…,因为这实际上是为了创建一个新项目和解决方案(尽管对话框中有一个下拉菜单可以选择添加到现有解决方案)。

  2. 添加新项目对话框中,在最近的项目模板中,选择控制台应用程序 [C#],然后点击下一步

  3. 配置新项目对话框中,对于项目名称,输入AboutMyEnvironment,将位置保留为C:\cs13net9\Chapter01,然后点击下一步

  4. 附加信息对话框中,选择.NET 9.0(标准支持条款)并选择不使用顶级语句复选框。

警告!请确保您已选中不使用顶级语句复选框,以便我们能够看到较旧的Program.cs风格。

  1. 点击创建

  2. AboutMyEnvironment项目中,在Program.cs中,注意定义与项目名称匹配的命名空间、名为Program的内部类以及名为Main的静态方法,该方法有一个名为args的参数,返回值为空(void),如下面的代码所示:

    namespace AboutMyEnvironment
    {
      internal class Program
      {
        static void Main(string[] args)
        {
          Console.WriteLine("Hello, World!");
        }
      }
    } 
    
  3. Program.cs文件中,在Main方法中,将现有的Console.WriteLine语句替换为输出当前目录、操作系统版本和Program类的命名空间的语句,如下面的代码所示:

    Console.WriteLine(Environment.CurrentDirectory);
    Console.WriteLine(Environment.OSVersion.VersionString);
    Console.WriteLine("Namespace: {0}",
      typeof(Program).Namespace ?? "<null>"); 
    
  4. 解决方案资源管理器中,右键单击Chapter01解决方案,然后选择配置启动项目…

  5. “Chapter01”解决方案属性页对话框中,将启动项目设置为当前选择,然后点击确定

  6. 解决方案资源管理器中,单击AboutMyEnvironment项目(或其内的任何文件或文件夹),并注意 Visual Studio 通过使项目名称加粗来指示AboutMyEnvironment现在是启动项目。

良好实践:我推荐这种方式来设置启动项目,因为这样通过简单地点击一个项目(或项目中的任何文件)就可以轻松地切换启动项目。尽管您可以通过右键单击项目将其设置为启动项目,但如果您想运行不同的项目,则必须手动再次更改它。在项目中任何地方点击都更简单。在大多数章节中,您只需一次运行一个项目。在第十五章,构建和消费 Web 服务中,我将向您展示如何配置多个启动项目。

  1. 导航到调试|不调试启动来运行 AboutMyEnvironment 项目,并注意结果,如下面的输出和图 1.8所示:

    C:\cs13net9\Chapter01\AboutMyEnvironment\bin\Debug\net9.0
    Microsoft Windows NT 10.0.26100.0
    Namespace: AboutMyEnvironment 
    

图片

图 1.8:在 Visual Studio 解决方案中运行具有两个项目的控制台应用程序

Windows 11 只是品牌名称。它的官方名称是 Windows NT,其主要版本号仍然是 10!但它的补丁版本是 22000 或更高。

  1. 按任意键关闭控制台应用程序窗口并返回 Visual Studio。

    当 Visual Studio 运行控制台应用程序时,它将从 <projectname>\bin\Debug\net9.0 文件夹中执行它。在后续章节中处理文件系统时,这一点非常重要。当使用 VS Code(更准确地说,是 dotnet CLI)时,它有不同的行为,您很快就会看到。

使用 VS Code 构建控制台应用程序

本节的目标是展示如何使用 VS Code 和 dotnet CLI 构建控制台应用程序。

如果您从未想尝试 VS Code 或 dotnet 命令行工具,那么请随意跳过本节,然后继续阅读充分利用本书 GitHub 仓库的部分。

本节中的说明和截图均适用于 Windows,但相同的操作在 macOS 和 Linux 的 VS Code 版本上同样适用。

主要区别将是原生命令行操作,例如删除文件;在 Windows、macOS 和 Linux 上,命令和路径可能都不同。幸运的是,dotnet CLI 工具本身及其命令在所有平台上都是相同的。

使用 VS Code 编写代码

让我们开始编写代码!

  1. 启动您喜欢的文件系统操作工具,例如 Windows 上的文件资源管理器或 Mac 上的查找器

  2. 在 Windows 上导航到您的 C: 驱动器,在 macOS 或 Linux 上导航到您的用户文件夹(我的文件夹名为 markjpricehome/markjprice),或任何您想要保存项目的目录或驱动器。

  3. 创建一个名为 cs13net9 的新文件夹。(如果您完成了 Visual Studio 的部分,那么这个文件夹已经存在。)

  4. cs13net9 文件夹中,创建一个名为 Chapter01-vscode 的新文件夹。

如果您没有完成 Visual Studio 的部分,那么您可以命名这个文件夹为 Chapter01,但我会假设您将完成这两个部分,因此需要使用一个不冲突的名称。

  1. Chapter01-vscode 文件夹中,打开命令提示符或终端。例如,在 Windows 上,右键单击文件夹,然后选择 在终端中打开

  2. 在命令提示符或终端中,使用 dotnet CLI 创建一个名为 Chapter01 的新解决方案,如下所示:

    dotnet new sln --name Chapter01 
    

您可以使用 -n--name 作为开关来指定名称。如果您不使用这些开关中的任何一个显式指定解决方案名称,则默认名称将与文件夹名称匹配,例如,Chapter01-vscode

  1. 注意以下输出结果,如图所示:

    The template "Solution File" was created successfully. 
    
  2. 在命令提示符或终端中,使用 dotnet CLI 创建一个名为 HelloCS 的控制台应用程序的新子文件夹和项目,如下所示:

    dotnet new console --output HelloCS 
    

您可以使用 -o--output 作为开关来指定文件夹和项目名称。dotnet new console 命令默认针对您最新的 .NET SDK 版本。要针对不同的版本,请使用 -f--framework 开关来指定目标框架。例如,要针对 .NET 8,请使用以下命令:dotnet new console -f net8.0

  1. 在命令提示符或终端中,使用 dotnet CLI 将项目添加到解决方案中,如下所示:

    dotnet sln add HelloCS 
    
  2. 注意以下输出结果,如图所示:

    Project `HelloCS\HelloCS.csproj` added to the solution. 
    
  3. 在命令提示符或终端中,启动 VS Code 并打开当前文件夹,用 .(点)表示,如下所示:

    code . 
    
  4. 如果您被提示 您是否信任此文件夹中文件的作者?,请选择 信任父文件夹‘cs13net9’中的所有文件作者 复选框,然后点击 是,我信任作者

  5. 在 VS Code 中,在 EXPLORER 中,在 CHAPTER01-VSCODE 文件夹视图中展开 HelloCS 文件夹,您将看到 dotnet 命令行工具创建了两个文件,HelloCS.csprojProgram.cs,以及 binobj 文件夹,如图 图 1.9 所示:

图 1.9:EXPLORER 显示已创建了两个文件和临时文件夹。

  1. 导航到 视图 | 输出

  2. 输出 面板中,选择 C# 开发工具包,并注意工具已识别并处理了解决方案。

  3. EXPLORER 的底部,注意 SOLUTION EXPLORER

  4. SOLUTION EXPLORER 拖到 EXPLORER 面板的顶部并展开它。

  5. SOLUTION EXPLORER 中,展开 HelloCS 项目,然后单击名为 Program.cs 的文件以在编辑器窗口中打开它。

  6. Program.cs 中,修改第 2 行,使写入控制台的文字为 Hello, C#!

良好实践:导航到 文件 | 自动保存。此切换将避免每次重建应用程序前记得保存的烦恼。

在前面的步骤中,我向您展示了如何使用dotnet CLI 创建解决方案和项目。最后,随着 2024 年 8 月或之后的 C# 开发工具包的发布,VS Code 提供了改进的项目创建体验,它提供了与通过dotnet CLI 创建新项目时相同的选项。

要启用此功能,您必须更改设置,如下配置所示:

"csharp.experimental.dotnetNewIntegration": true 

在 VS Code 中,导航到文件 | 首选项 | 设置,搜索dotnet new,然后选择Csharp > Experimental: Dotnet New Integration复选框。

您可以在以下链接中了解更多信息:

devblogs.microsoft.com/dotnet/whats-new-in-csharp-dev-kit-august-2024/#create-new-project-configuration-options

使用 dotnet CLI 编译和运行代码

下一个任务是编译和运行代码:

  1. 解决方案资源管理器中,右键单击HelloCS项目中的任何文件,然后选择在集成终端中打开

  2. 终端中,输入以下命令:dotnet run

  3. 终端窗口中的输出将显示应用程序运行的结果。

  4. Program.cs中,在输出消息的语句之后,添加获取Program类命名空间名称的语句,将其写入控制台,然后抛出一个新的异常,如下代码所示:

    string name = typeof(Program).Namespace ?? "<null>";
    Console.WriteLine($"Namespace: {name}");
    throw new Exception(); 
    
  5. 终端中,再次输入以下命令:dotnet run

终端中,您可以按上箭头和下箭头键循环查看之前的命令,然后按左右箭头键编辑命令,在按下Enter键运行它们之前。

  1. 终端窗口中的输出将显示应用程序运行的结果,包括编译器定义了一个隐藏的Program类,该类有一个名为<Main>$的方法,该方法有一个名为args的参数用于传递参数,并且它没有命名空间,如下输出所示:

    Hello, C#!
    Namespace: <null>
    Unhandled exception. System.Exception: Exception of type 'System.Exception' was thrown.
       at Program.<Main>$(String[] args) in C:\cs13net9\Chapter01-vscode\HelloCS\Program.cs:line 7 
    

使用 VS Code 添加第二个项目

让我们添加第二个项目来探索如何处理多个项目:

  1. 终端中,切换到Chapter01-vscode目录,如下命令所示:

    cd .. 
    
  2. 终端中,创建一个名为AboutMyEnvironment的新控制台应用程序项目,使用较旧的顶级程序样式,如下命令所示:

    dotnet new console -o AboutMyEnvironment --use-program-main 
    

良好实践:在终端中输入命令时请小心。在输入可能具有破坏性的命令之前,请确保您位于正确的文件夹中!

  1. 终端中,使用dotnet CLI 将新项目文件夹添加到解决方案中,如下命令所示:

    dotnet sln add AboutMyEnvironment 
    
  2. 注意以下结果,如下输出所示:

    Project `AboutMyEnvironment\AboutMyEnvironment.csproj` added to the solution. 
    
  3. 解决方案资源管理器 中,在 AboutMyEnvironment 项目中打开 Program.cs,然后在 Main 方法中,将现有语句更改为输出当前目录、操作系统版本字符串和 Program 类的命名空间,如下所示代码:

    Console.WriteLine(Environment.CurrentDirectory);
    Console.WriteLine(Environment.OSVersion.VersionString);
    Console.WriteLine("Namespace: {0}",
      typeof(Program).Namespace ?? "<null>"); 
    
  4. 解决方案资源管理器 中,右键单击 AboutMyEnvironment 项目中的任何文件,然后选择 在集成终端中打开

  5. 终端 中输入运行项目的命令,如下所示命令:dotnet run

  6. 注意查看 终端 窗口中的输出,如下所示:

    C:\cs13net9\Chapter01-vscode\AboutMyEnvironment
    Microsoft Windows NT 10.0.26100.0
    Namespace: AboutMyEnvironment 
    

一旦打开多个终端窗口,您可以通过点击 终端 右侧面板上的名称在它们之间切换。默认情况下,名称将是常见的壳之一,如 pwshpowershellzshbash。右键单击并选择 重命名 以设置其他名称。

当 VS Code 或更准确地说,dotnet CLI 运行控制台应用程序时,它将从 <projectname> 文件夹中执行它。Visual Studio 将从 <projectname>\bin\Debug\net9.0 文件夹中执行应用程序。在后续章节中处理文件系统时,这一点将非常重要。

如果你在 macOS Ventura 上运行程序,操作系统环境将不同,如下所示输出:

Unix 13.5.2 

良好实践:尽管源代码,如 .csproj.cs 文件,是相同的,但由编译器自动生成的 binobj 文件夹可能会有不匹配,导致错误。如果您想在 Visual Studio 和 VS Code 中打开相同的项目,请在其他代码编辑器中打开项目之前删除临时的 binobj 文件夹。这就是为什么我要求您在本章中为 VS Code 项目创建不同文件夹的原因。

VS Code 步骤总结

按照以下步骤使用 VS Code 创建解决方案和项目,如 表 1.5 所示:

步骤描述 命令
1. 为解决方案创建文件夹。 mkdir <solution_folder_name>
2. 切换到文件夹。 cd <solution_folder_name>
3. 在文件夹中创建解决方案文件。 dotnet new sln
4. 使用模板创建文件夹和项目。 dotnet new console -o <project_folder_name>
5. 将文件夹及其项目添加到解决方案中。 dotnet sln add <project_folder_name>
6. 重复步骤 4 和 5 以创建和添加任何其他项目。
7. 使用 VS Code 打开包含解决方案的当前文件夹路径(.)。 code .

表 1.5:使用 VS Code 创建解决方案和项目的步骤总结

本书使用其他项目类型总结

控制台应用程序 / console 项目只是项目模板的一种类型。在本书中,您还将使用以下项目模板创建项目,如 表 1.6 所示:

Visual Studio dotnet new Rider – 类型
控制台应用程序 console 控制台应用程序
类库 classlib 类库
xUnit 测试项目 xunit 单元测试项目 – xUnit
ASP.NET Core 空项目 web ASP.NET Core Web 应用程序 – 空项目
Blazor Web App blazor ASP.NET Core Web 应用程序 – Blazor Web 应用程序
ASP.NET Core Web API webapi ASP.NET Core Web 应用程序 – Web API
ASP.NET Core Web API(原生 AOT) webapiaot ASP.NET Core Web 应用程序 – Web API(原生 AOT)

表 1.6:各种代码编辑器的项目模板名称

将任何类型的新项目添加到解决方案中的步骤是相同的。只有项目模板的类型名称不同,有时还需要一些命令行开关来控制选项。如果它们与默认值不同,我总是会指定那些开关和选项应该是什么。

可以在这里找到项目模板默认值、选项和开关的摘要:github.com/markjprice/cs13net9/blob/main/docs/ch01-project-options.md

充分利用这本书的 GitHub 仓库

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

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

  • 为了存储在印刷出版日期之后可以维护的这本书的解决方案代码。

  • 为了提供扩展书籍的额外材料,如勘误表修正、小改进、有用链接列表以及关于无法放入印刷书的主题的可选部分。

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

良好实践:我强烈建议大家在尝试这本书中的任何编码任务之前,先审查勘误表、改进、出版后更改和常见错误页面。您可以在以下链接中找到它们:github.com/markjprice/cs13net9/blob/main/docs/errata/README.md

理解 GitHub 上的解决方案代码

这本书的 GitHub 仓库中的解决方案代码包括每个章节的文件夹,可以使用以下任何一种代码编辑器打开:

  • Visual Studio 或 Rider:打开 .sln 解决方案文件。

  • VS Code:打开包含解决方案文件的文件夹。

第一章第十一章 每章都有自己的解决方案文件,命名为 ChapterXX.sln,其中 XX 是章节号 0111第十二章第十五章 共享一个名为 ModernWeb.sln 的单个解决方案文件。

.sln解决方案文件格式是微软专有的文件格式,冗长、难以阅读,并使用全局唯一标识符GUIDs)来引用解决方案和其他组件。一个基于 XML 的新格式即将推出,设计得更加简单易读,并将使用.slnx文件扩展名。您可以在以下链接中了解更多信息:github.com/dotnet/sdk/issues/40913

所有代码解决方案都可以在以下链接找到:

github.com/markjprice/cs13net9/tree/main/code

良好实践:如果您需要,请返回本章以提醒自己如何在您选择的代码编辑器中创建和管理多个项目。GitHub 仓库提供了三个代码编辑器(Visual Studio、VS Code 和 Rider)的逐步说明,以及额外的截图:github.com/markjprice/cs13net9/tree/main/docs/code-editors/

提出关于本书的问题

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

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

  2. 点击新建问题

  3. 提供尽可能多的细节,这将有助于我诊断问题。例如:

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

    • 提供尽可能多的与您的代码和配置相关的信息。

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

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

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

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

  • 您的操作系统,例如,Windows 11 64 位或 macOS Ventura 版本 13.5.2

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

我不能总是立即回应问题。但我希望所有读者都能通过我的书取得成功,所以如果我能帮助您(和其他人)而不会带来太多麻烦,我将非常乐意这样做。

给我反馈

如果您想就本书提供更一般的反馈,请通过以下邮箱地址给我发送邮件 markjprice@gmail.com 或在 Discord 的书频道中问我一个问题。您可以匿名提供反馈,或者如果您希望得到我的回复,可以提供电子邮件地址。我只会使用这个电子邮件地址来回答您的反馈。

请使用此邀请链接加入我及您的其他读者在 Discord 上的讨论:packt.link/csharp13dotnet9

我很高兴听到读者们关于他们喜欢我的书的内容,以及改进建议以及他们如何使用 C# 和 .NET,所以请不要害羞。请与我联系!

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

避免常见错误

在完成本书中的逐步任务后,读者们通常会尝试自己编写类似的代码,但有时会遇到问题,要么在 GitHub 仓库中提出问题,要么在 Discord 频道中发布问题。

从这些中,我注意到了一些常见的错误,因此我在仓库中维护了一个页面来突出显示并解释这些潜在的陷阱以及如何修复它们:

github.com/markjprice/cs13net9/blob/main/docs/errata/common-mistakes.md

从 GitHub 仓库下载解决方案代码

如果您只想下载所有解决方案文件而不使用 Git,请点击绿色 <> 代码 按钮,然后选择 下载 ZIP,如图 图 1.10 所示:

图 1.10:将仓库作为 ZIP 文件下载

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

使用 VS Code 和命令提示符中的 Git

VS Code 与 Git 集成,但它将使用您的操作系统中的 Git 安装,因此您必须首先安装 Git 2 或更高版本才能获得这些功能。

您可以从以下链接安装 Git:git-scm.com/download

如果您喜欢使用图形用户界面,您可以从以下链接下载 GitHub Desktop:desktop.github.com

克隆书籍解决方案代码仓库

让我们克隆书籍解决方案代码仓库。在接下来的步骤中,您将使用 VS Code 终端,但您可以在任何命令提示符或终端窗口中输入命令:

  1. 在您的 userDocuments 文件夹中创建一个名为 Repos-vscode 的文件夹,或者您想存储 Git 仓库的任何位置。

  2. 在命令提示符或终端中打开 Repos-vscode 文件夹,然后输入以下命令:

    git clone https://github.com/markjprice/cs13net9.git 
    

注意,克隆所有章节的解决方案可能需要一分钟或更长时间,所以请耐心等待。

寻求帮助

这一节全部关于如何在网络上找到关于编程的高质量信息。

Microsoft Learn 文档和 Ask Learn

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

“我们工程师最近构建的最雄心勃勃且影响最大的项目之一是 Ask Learn,这是一个 API,它为 Microsoft Q&A 提供了生成式 AI 功能。” - Bob Tabor,Microsoft 的 Skilling 组织

您可以在以下链接中了解更多信息:

devblogs.microsoft.com/engineering-at-microsoft/how-we-built-ask-learn-the-rag-based-knowledge-service/

本书中的文档链接

.NET 的官方 Microsoft 文档需要涵盖所有版本。文档中显示的默认版本始终是最新的 GA 版本。

例如,在 2024 年 11 月到 2025 年 11 月之间,文档页面中显示的.NET 默认版本将是.NET 9。在 2025 年 11 月到 2026 年 11 月之间,.NET 的默认版本将是.NET 10。以下链接将根据当前日期自动跳转到当前版本:

learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.stringsyntaxattribute

在 2025 年 11 月之后,要查看.NET 9 的特定文档页面,请将?view=net-9.0追加到链接的末尾。例如,使用以下链接:

learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.stringsyntaxattribute?view=net-9.0

本书中的所有文档链接都没有指定版本。如果您想强制文档显示.NET 8 的版本,因为您需要针对 LTS 版本(三年)而不是 STS 版本(18 个月)的.NET 9,那么请将?view=net-8.0追加到链接的末尾。

您可以通过在链接末尾追加#applies-to来检查.NET 功能支持哪些版本,例如:

learn.microsoft.com/en-us/dotnet/api/system.diagnostics.codeanalysis.stringsyntaxattribute#applies-to

因此,我们可以看到StringSyntax属性仅在.NET 7 或更高版本中可用。

获取 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 命令的官方文档,请在命令提示符或 VS Code 终端中输入以下内容,并注意在您的网页浏览器中打开的页面:

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

    dotnet build -? 
    
  3. 您将看到以下部分输出:

    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. 
    
  4. 对以下命令重复两种帮助请求:addhelplistnewrun,记住 new 可能不会显示其网页,因为 .NET 7 中引入了一个错误。

获取类型及其成员的定义

代码编辑器最有用的功能之一是 转到定义 (F12)。它在 VS Code、Visual Studio 和 Rider 中可用。它将通过读取编译的程序集中的元数据来显示类型或成员的公共定义看起来是什么样子。

一些工具,如 ILSpy .NET Decompiler,甚至可以将元数据和 IL 代码逆向工程回 C# 或其他语言。

一个类似且相关的功能名为 转到实现 (Ctrl + F12)。它不会读取元数据或反编译,如果源代码已嵌入,它将使用可选的源链接功能显示实际的源代码。

警告! 转到定义 应该跳转到成员或类型的反编译元数据。但如果您之前已经查看过源链接,那么它会跳转到那里。转到实现 应该跳转到成员或类型的源链接实现。但如果您已禁用源链接,那么它会跳转到反编译元数据。

让我们看看如何使用 转到定义 功能:

  1. 在您首选的代码编辑器中,打开名为 Chapter01 的解决方案/文件夹。

如果您正在使用 Visual Studio:

  • 导航到 工具 | 选项

  • 在搜索框中输入 navigation to source

  • 选择 文本编辑器 | C# | 高级

  • 取消选择 启用导航到源链接和嵌入源 复选框,然后单击 确定,如图 图 1.11 所示:

图 1.11:禁用“转到定义”功能的源链接

定义可以是反向工程自元数据,或者如果启用了原始源代码,则可以从原始源代码中加载。我个人发现来自元数据的代码更有用,正如您即将看到的。在本节末尾,尝试将 Source Link 选项切换回开启,以查看差异。

  1. HelloCS项目中,在Program.cs的底部,输入以下语句以声明一个名为z的整数变量:

    int z; 
    
  2. 在 Visual Studio 或 VS Code 中,单击int,右键单击int,然后选择转到定义。在 Rider 中,选择转到 | 转到声明或用法

  3. 在出现的代码窗口中,您可以看到int数据类型的定义,如下面的代码所示:

    #region Assembly System.Runtime, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    // C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\9.0.0\ref\net9.0\System.Runtime.dll
    #endregion
    #nullable enable
    using System.Diagnostics.CodeAnalysis;
    using System.Globalization;
    using System.Numerics;
    using System.Runtime.CompilerServices;
    namespace System
    {
      public readonly struct Int32 : IComparable,
        IComparable<Int32>, IConvertible, ...
      {
        public const Int32 MaxValue = 2147483647;
        public const Int32 MinValue = -2147483648;
        ...
      }
    } 
    

您可以看到int

  • 使用struct关键字定义。

  • 位于System.Runtime程序集中。

  • 位于System命名空间中。

  • 被命名为Int32

  • 因此,是System.Int32类型的别名。

  • 实现了IComparable等接口。

  • 有其最大值(2147483647)和最小值(-2147483648)的常量值。

  • 包含Parse(在前面代码中不可见)等方法。

目前,转到定义功能对您来说并不那么有用,因为您还不知道所有这些信息意味着什么。在本书的第一个部分结束时,这部分包括第 2章到第 6章,并教授您 C#语言,您将了解足够的信息,使此功能变得非常实用。

  1. 在代码编辑器窗口中,向下滚动以找到具有单个string参数的Parse方法,如下面的代码所示:

    public static Int32 Parse(string s) 
    
  2. 展开代码并查看文档此方法的注释,如图1.12所示:

1.12:具有单个字符串参数的 Parse 方法的注释

在注释中,您将看到 Microsoft 已记录以下内容:

  • 描述方法的摘要。

  • 可以传递给方法的参数,例如string值。

  • 该方法返回值及其数据类型。

  • 如果您调用此方法可能会发生的三个异常,包括ArgumentNullExceptionFormatExceptionOverflowException。现在,我们知道我们可以选择在try语句中包装对此方法的调用,并捕获哪些异常。

配置内联提示,也称为内联提示。

在本书中的代码中,在调用方法时,我经常明确指定命名参数以帮助您了解正在发生的事情。例如,我在以下代码中指定了参数formatarg0的名称:

Console.WriteLine(format: "Value is {0}.", arg0: 19.8); 

内联提示,也称为内联提示,显示参数名称,而无需您输入它们,如图1.13所示:

1.13:配置内联提示,也称为内联提示

大多数代码编辑器都有此功能,您可以选择永久启用或仅在按下像Alt + F1Ctrl这样的键组合时启用:

  • 在 Visual Studio 中,导航到OK

  • 在 VS Code 中,导航到文件 | 首选项 | 设置,搜索inlay,选择C#过滤器,然后选择显示内联参数名称提示复选框。

  • 在 Rider 中,在设置中,导航到编辑器 | 内联提示 | C# | 参数名称提示

希望你现在迫不及待地想了解这一切的含义!

再耐心一点。你几乎就要结束这一章了,在下一章中,你将深入探讨 C#语言的细节。但首先,让我们看看你还可以在哪里寻求帮助。

在 Stack Overflow 上寻找答案

Stack Overflow 是获取困难编程问题答案最受欢迎的第三方网站。让我们看看一个例子:

  1. 启动您喜欢的网络浏览器。

  2. 导航到stackoverflow.com;在搜索框中输入securestring,并注意搜索结果。

使用 Google 搜索寻找答案

你可以使用高级搜索选项来搜索 Google,以增加找到所需信息的机会:

  1. 通过以下链接导航到 Google:www.google.com/.

  2. 使用简单的 Google 查询搜索有关垃圾回收的信息,并注意在你看到计算机科学中垃圾回收的维基百科定义之前,你可能会看到很多当地垃圾回收服务的广告!

  3. 通过限制到有用的网站(如 Stack Overflow),移除我们可能不关心的语言(如 C++、Rust 和 Python),或显式添加 C#和.NET,来改进搜索,如下面的搜索查询所示:

    garbage collection site:stackoverflow.com +C# -Java 
    

在 Discord 和其他聊天论坛上寻求帮助

在编程论坛和 Discord 频道中提问是一门艺术,也是一种科学。为了最大限度地提高获得有用回答的机会,你应该追求清晰、具体和社区意识。

这里有一些提问的技巧:

  • 在公共频道提问,而不是在私人频道。请不要直接给作者发消息提问或好友请求。 记住,每个提问和回答都在构建整个社区的集体知识和资源。在公共频道提问也允许其他读者帮助你,而不仅仅是作者。Packt 和我围绕我的书籍建立的社区既友好又聪明。让我们所有人来帮助你。

  • 提问前先进行调研:在向社区求助之前,自己寻找答案是很重要的。使用搜索引擎、官方文档以及论坛或 Discord 服务器内的搜索功能。这不仅尊重了社区的时间,也有助于你更有效地学习。首先查找的地方还包括书籍的错误和改进部分,链接如下:github.com/markjprice/cs13net9/blob/main/docs/errata/README.md.

  • 具体且简洁:清楚地说明你想要达到的目标、你已经尝试过的方法以及你遇到的问题。一个简洁的问题更有可能得到快速的回复。

  • 指定书籍位置:如果你在书的某个特定部分遇到困难,请指定页码和章节标题,这样其他人就可以查找你问题的上下文。

  • 展示你的工作过程:展示你已经努力自己解决问题,这不仅提供了上下文,还有助于他人理解你的思考过程以及你可能走错的道路。

  • 准备你的问题:避免过于宽泛或模糊的问题。错误截图或代码片段(格式正确)可能非常有帮助。

    奇怪的是,我越来越多地看到读者拍照屏幕并发布这些照片。这些更难阅读,并且展示的内容有限。最好复制粘贴你的代码文本或错误信息,这样其他人也可以自己复制粘贴。或者,至少用手机摄像头以一个斜角度拍摄高分辨率截图!

  • 正确格式化你的代码:大多数论坛和 Discord 服务器都支持使用 Markdown 语法进行代码格式化。使用格式化使你的代码更易于阅读。例如,用单行反引号包围代码关键字,如`public void`,并用三行反引号包围代码块,可选地包含语言代码,如下面的代码所示:

    ```cs
    
    using static System.Console;
    
    ```WriteLine("这是 C#格式的代码。");
    
    ```cs 
    

    良好实践:在 Markdown 中开始代码块的三行反引号之后,指定一个语言简称,如cs csharpjs,** javascriptjsonhtmlcsscppxmlmermaidpythonjavarubygosqlbash,或shell

更多信息:要了解如何在 Discord 频道消息中格式化文本,请参阅以下链接:support.discord.com/hc/en-us/articles/210298617-Markdown-Text-101-Chat-Formatting-Bold-Italic-Underline**。

  • 礼貌且耐心:记住,你是在向那些自愿奉献时间的人寻求帮助。礼貌的语气和在等待回复时的耐心会大有帮助。频道参与者通常在不同的时区,所以你可能要到第二天才能看到有人回答你的问题。

  • 准备好积极参与:在提问后,保持参与。你可能会收到一些后续问题以澄清。迅速且清晰地回答可以提高你得到有用答案的机会。当我提问时,我会设置一个三小时后的闹钟,回来查看是否有人回答。如果还没有人回答,那么我会再设置一个 24 小时后的闹钟。

在提问时采用这些方法不仅可以增加你获得有用回复的可能性,而且还可以积极贡献于社区,通过尊重他人的时间和努力。

良好实践:在任何聊天系统中,永远不要只说“Hello”作为消息。你可以在以下链接中阅读原因:nohello.net/。同样,不要询问是否可以询问:dontasktoask.com/

在 .NET 源代码中搜索

有时,你可以从看到微软团队如何实现 .NET 中学到很多东西。.NET 整个代码库的源代码可在公共 GitHub 存储库中找到。例如,你可能知道有一个内置的属性可以验证电子邮件地址。

让我们搜索存储库中的单词“email”,看看我们能否找出它是如何工作的:

  1. 使用你喜欢的网络浏览器导航到 github.com/search

  2. 点击 高级搜索

  3. 在搜索框中,输入 email

  4. 这些存储库 框中,输入 dotnet/runtime。 (你可能还想搜索的其他存储库包括 dotnet/coredotnet/aspnetcoredotnet/wpfdotnet/winforms。)

  5. 用此语言编写 框中,选择 C#

  6. 在页面右上角,注意高级查询是如何为你编写的。点击 搜索,然后点击 代码 过滤器,并注意结果包括 EmailAddressAttribute,如图 图 1.14 所示:

图 1.14:在 dotnet/runtime 存储库中高级搜索 email

  1. 点击源文件,并注意它通过检查 string 值是否包含一个 @ 符号(但不是作为第一个或最后一个字符)来实现电子邮件验证,如下面的代码所示:

    // only return true if there is only 1 '@' character
    // and it is neither the first nor the last character
    int index = valueAsString.IndexOf('@');
    return
        index > 0 &&
        index != valueAsString.Length - 1 &&
        index == valueAsString.LastIndexOf('@'); 
    
  2. 关闭浏览器。

为了方便起见,你可以通过替换以下链接中的搜索词 email 来快速搜索其他术语:github.com/search?q=%22email%22+repo%3Adotnet%2Fruntime+language%3AC%23&type=code&ref=advsearch

文档中的源代码

当你阅读 API 参考文档时,你通常会想查看实际的源代码。对于启用了 Source Link、具有可访问的 PDB 并且托管在公共 GitHub 存储库中的 .NET API,源代码链接包含在定义元数据中。例如,String 类的文档页面现在有这个新的 链接,其 IndexOf 方法有一个指向另一个源文件的 链接,如图 图 1.15 所示:

计算机屏幕截图  自动生成的描述

图 1.15:包含指向源文件链接的文档

https://devblogs.microsoft.com/dotnet/dotnet-docs-link-to-source-code/.

官方 .NET 博客、站立会议和新闻

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

要观看 .NET 团队成员介绍新功能的预览,你可以观看每月的“站立会议”,链接如下:

dotnet.microsoft.com/en-us/live/community-standup

您可以在以下链接订阅最新的 .NET 新闻:

github.com/dotnet/core/discussions/categories/news

观看 Scott Hanselman 的视频

微软的 Scott Hanselman 拥有一个关于计算机知识的优秀 YouTube 频道,这些知识在学校里没有教过:computerstufftheydidntteachyou.com/.

我推荐所有与计算机工作的人订阅。

AI 工具如 ChatGPT 和 GitHub Copilot

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

你可以阅读开发者关于 2023 年 Stack Overflow 开发者调查中 AI 工具的看法:“44% 的开发者现在在他们的开发过程中使用 AI 工具,26% 计划很快使用” (stackoverflow.blog/2023/06/14/hype-or-not-developers-have-something-to-say-about-ai/):

“从研究到调试再到文档,开发者正在使用生成式 AI 来节省时间,完成工作中各种繁琐的任务。最大的用例是编写代码。这是最近 Stack Overflow 开发者调查中 82.55% 的开发者报告使用生成式 AI 的方式。”

ChatGPT 目前为个人提供几个模型:4o mini(免费)、4o(免费但有限)和 4o with 5x 更多的请求以及其他好处,如新功能的早期访问和 DALL·E 图像生成(每月 20 美元)。它还为企业提供了定价。你可以在以下链接查看他们的定价:openai.com/chatgpt/pricing/.

ChatGPT 示例

假设你需要编写一个 C# 函数来验证电子邮件地址。你可能会去 ChatGPT 并输入以下提示:

write a c# function to validate an email address 

它会响应一个包含方法的完整类,如图 1.16 所示:

图 1.16:ChatGPT 编写验证电子邮件地址的函数

然后它提供了代码的解释和如何调用函数的示例,如下面的代码所示:

bool isValid = EmailValidator.IsValidEmail("test@example.com");
Console.WriteLine(isValid ? "Valid" : "Invalid"); 

但像 ChatGPT 这样的通用生成式 AI 是否是 C# 程序员的最佳伴侣?

GitHub Copilot 为程序员

微软有一个专门为程序员提供的服务,名为 GitHub Copilot,它可以帮助你在代码编辑器中直接自动完成代码。它为代码编辑器提供了插件,包括 Visual Studio、VS Code 和基于 JetBrains IntelliJ 的 IDE。

个人来说,我非常喜欢 Copilot 的品牌。它清楚地表明你是飞行员。你最终负责“驾驶飞机”。但对于简单或无聊的部分,你可以暂时交给你的副驾驶,同时保持随时准备接管控制。

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

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

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

那么,GitHub Copilot 今天能为你做什么?

想象一下,你刚刚添加了一个名为 Product.cs 的新类文件。你点击 Product 类内部,按 Enter 键插入一个空行,然后暂停一秒钟,思考你需要输入什么……然后 GitHub Copilot 以灰色生成了一些示例代码,如图 图 1.17 所示:

图 1.17:GitHub Copilot 建议如何定义一个 Product 类

在这个阶段,你可以浏览一下代码,如果它接近你想要的样子,只需按 Tab 键插入全部内容,或者按 Alt + . (点) 在其他建议之间切换。

有时候,它离你需要的功能太远,你最好完全忽略它的建议,自己写出来。但通常,其中会有一些可用的内容,或者能让你想起你需要使用的语法。有时,它感觉就像魔法一样,能写出几十行你正好需要的代码。

微软用来自公共 GitHub 仓库的代码为其 AI 工具提供数据,包括我从 2016 年以来为本书所有版本创建的所有仓库。这意味着它可以对本书的读者提出代码补全建议,这些建议是惊人的准确预测,包括我在代码中频繁使用的流行文化引用。这就像我,马克·J·普莱斯,是“机器中的幽灵”,引导你的编码。

很容易想象一个已经吸收了所有官方 Microsoft .NET 文档、所有关于.NET 的公开博客文章,甚至可能是数百本关于.NET 的书籍的定制 ChatGPT,然后与之交谈以找到错误或提出解决编程问题的建议。

您可以在以下链接处注册 GitHub Copilot:github.com/github-copilot/signup/

良好实践:在以下链接中了解更多关于如何将 Copilot 作为您的编码 GPS 使用的信息:devblogs.microsoft.com/visualstudio/using-github-copilot-as-your-coding-gps/

这里有一些关于使用 AI 进行编码的更多链接:

关闭阻碍工作的工具

虽然这些工具可能很有帮助,但它们也可能阻碍您,尤其是在学习时,因为它们有时会为您完成工作而不通知您。如果您自己至少不这样做几次,您就不会完全学会。

要在 Visual Studio 中配置 C#的 IntelliSense:

  1. 导航到工具 | 选项

  2. 选项对话框的树视图中,导航到文本编辑器 | C# | IntelliSense

  3. 点击标题栏中的按钮以查看文档。

要在 Visual Studio 中配置 GitHub Copilot:

  1. 导航到工具 | 选项

  2. 选项对话框的树视图中,导航到GitHub | Copilot

  3. 全局启用设置为TrueFalse,然后点击确定

要在 VS Code 中禁用 GitHub Copilot:

  1. 在状态栏的右侧,在通知图标左侧,点击 GitHub Copilot 图标。

  2. 在弹出窗口中,点击全局禁用

  3. 要启用它,再次点击 GitHub Copilot 图标,然后点击全局启用

如需有关 Rider IntelliSense 的帮助,请参阅以下链接:www.jetbrains.com/help/rider/Auto-Completing_Code.html

练习和探索

让我们现在通过尝试回答一些问题、进行一些实际操作练习,并更深入地探讨本章涵盖的主题来测试您的知识和理解。

练习 1.1 – 在线材料

在线材料可以是我为本书编写的额外内容,也可以是引用微软或第三方创建的内容。

.NET 当前版本

您可以在以下链接中检查不同平台上的最新 .NET 版本以及官方支持的内容:versionsof.net

如果您需要了解更多关于对旧版 .NET Framework 的支持信息,您可以在以下链接中阅读:learn.microsoft.com/en-us/lifecycle/products/microsoft-net-framework

升级到新的 .NET 版本

.NET 每年都会发布新版本。一些开发者在新版本发布后立即开始升级,而另一些开发者则更喜欢等到当前版本达到生命周期的结束支持阶段。升级涉及几个重要的考虑因素,您可以在以下链接中了解更多信息:

learn.microsoft.com/en-us/dotnet/core/install/upgrade

freeCodeCamp 和 C# 认证

多年来,微软为 C# 5 开设了一门考试,即 Exam 70-483: Programming in C#。我教授了数百名开发者获得资格并通过该考试的技能。遗憾的是,该考试几年前已被取消。

2023 年 8 月,微软宣布推出一个新的 C# 基础认证,并附带一个免费的 35 小时在线课程。您可以在以下链接中了解更多关于如何获得认证的信息:

www.freecodecamp.org/learn/foundational-c-sharp-with-microsoft/

C# 认证注册:aka.ms/learningseries/csharp/signup

探索 Polyglot Notebooks

完成以下仅在线部分,以探索如何使用 Polyglot Notebooks 和其 .NET Interactive 引擎:

github.com/markjprice/cs13net9/blob/main/docs/ch01-polyglot-notebooks.md

Windows 开发

本书是关于现代跨平台开发的。但对于 Windows 的 图形用户界面(GUI) 开发又该如何呢?

您可以在以下链接中阅读一篇讨论此问题的文章:

visualstudiomagazine.com/articles/2024/02/13/desktop-dev.aspx

C# 与其他语言比较

即使是微软也不使用 C# 和 .NET 来满足其所有自身开发需求。您可以在以下链接中阅读有关 C# 和 Rust 的有趣讨论:

www.reddit.com/r/dotnet/comments/1aezqmg/comment/ko8lnf2/

之前 Reddit 链接中的关键引言是:

“关于 Rust 的炒作很多,而且有充分的理由。但它是系统语言。微软并不是要重写数百万行代码并丢弃 C#(对于任何感到紧张的人来说!![img/1.png])他们只是在做实用的事情,使用有效的工具来完成工作。”

免费计算机科学课程

哈佛 CS50:计算机科学导论:pll.harvard.edu/course/cs50-introduction-computer-science

.NET 新闻通讯

有电子邮件新闻通讯你可以订阅,以获取每日或每周的更新、提示和技巧。其中一些较好的包括以下内容:

练习 1.2 – 实践练习

以下实践练习将更深入地探讨本章的主题。

在任何地方使用浏览器练习 C#

你不需要下载和安装 VS Code 或甚至 Visual Studio 来编写 C#。你可以在以下链接中的任何一个开始在线编码:

.NET 的 Alpha 版本

你可以(但可能不应该)从以下链接下载.NET 的未来版本,包括 alpha 版本:

github.com/dotnet/sdk/blob/main/documentation/package-table.md

警告! Alpha 版本仅设计供微软员工内部使用。Beta 版本(官方预览版)设计供外部使用,并从每年 2 月中旬开始提供。

更多关于使用本书中的.NET 10 的信息,请参阅以下链接:github.com/markjprice/cs13net9/blob/main/docs/dotnet10.md

练习 1.3 – 测试你的知识

尝试回答以下问题,记住尽管大多数答案可以在本章找到,但你应该进行一些在线研究或编写代码来回答其他问题:

  1. Visual Studio 是否比 VS Code 更好?

  2. .NET 5 及以后的版本是否比.NET Framework 更好?

  3. .NET 标准是什么,为什么它仍然很重要?

  4. 为什么程序员可以使用不同的语言(例如,C#和 F#)来编写在.NET 上运行的应用程序?

  5. 什么是顶级程序,您如何访问任何命令行参数?

  6. .NET 控制台应用程序的入口点方法叫什么名字,如果您不使用顶级程序功能,应该如何显式声明?

  7. 使用顶级程序时,Program 类定义在哪个命名空间中?

  8. 您会去哪里寻找 C# 关键字的帮助?

  9. 你会首先在哪里寻找常见编程问题的解决方案?

  10. 在使用 AI 为您编写代码之后,您应该做什么?

附录测试你的知识问题的答案,可以从 GitHub 仓库中的 README 文件中的链接下载:github.com/markjprice/cs13net9

练习 1.4 – 探索主题

一本印刷书是一种精选的体验。我试图找到合适的主题平衡,包括在这本书中。其他我写的相关内容可以在本书的 GitHub 仓库中找到。

我认为这本书涵盖了 C# 和 .NET 开发者应该具备或了解的所有基本知识和技能。一些较长的示例最好作为链接包含在 Microsoft 文档或第三方文章作者的链接中。

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-1---hello-c-welcome-net

摘要

在本章中,我们:

  • 设置你的开发环境。

  • 在一篇在线文章中讨论了现代 .NET、.NET Core、.NET Framework、Xamarin 和 .NET Standard 之间的相似之处和不同之处。

  • 使用 Visual Studio 和 VS Code 以及 .NET SDK CLI 创建了一些简单的控制台应用程序,并将它们分组在一个解决方案中。

  • 学习了如何从其 GitHub 仓库下载这本书的解决方案代码。

  • 学习了如何寻找帮助。这可以通过传统方式,例如使用帮助命令开关、文档和文章,或者现代方式,通过与编码专家 AI 进行对话或使用基于 AI 的工具执行“苦力工作”来实现。

在下一章中,您将学习如何“说”C#。

在 Discord 上了解更多信息

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

packt.link/csharp13dotnet9

二维码

留下评论!

感谢您从 Packt Publishing 购买这本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花一点时间在亚马逊上留下评论;这只需要一分钟,但对像您这样的读者来说意义重大。

扫描二维码或访问链接以获取您选择的免费电子书。

packt.link/NzOWQ

带有黑色方块的二维码  自动生成的描述

第二章:使用 C#

本章主要介绍 C# 编程语言的基础知识。在本章中,您将学习如何使用 C# 的语法编写语句,以及介绍一些您将每天使用的常用词汇。此外,到本章结束时,您将自信地了解如何在计算机内存中临时存储和处理信息。

本章涵盖了以下主题:

  • 介绍 C# 语言

  • 查找你的 C# 编译器版本

  • 理解 C# 语法和词汇

  • 使用变量

  • 探索更多关于控制台应用程序的内容

介绍 C# 语言

本书本部分是关于 C# 语言——您将每天使用它来编写应用程序源代码的语法和词汇。

编程语言与人类语言有许多相似之处,只是在编程语言中,您可以自己创造词汇,就像苏斯博士一样!

在苏斯博士于 1950 年所著的《如果我管理动物园》一书中,他这样说道:

“然后,为了向他们展示,我将航行到 Ka-Troo 并带回一个 It-Kutch,一个 Preep,一个 Proo,一个 Nerkle,一个 Nerd,还有一个 Seersucker!”

C# 语言版本和特性

本书本部分涵盖了 C# 编程语言,主要面向初学者,因此涵盖了所有开发者都需要了解的基本主题,包括声明变量、存储数据和如何定义您自己的自定义数据类型。

本书涵盖了从版本 1 到最新版本 C# 13 的 C# 语言特性。您可以在以下链接中阅读 C# 13 的新特性总结:

learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-13

如果您已经对较老的 C# 版本有所了解,并且对最新版本的新特性感到兴奋,我为您列出以下语言版本及其重要新特性,包括您可以在其中了解它们的章节编号和主题标题,以便更容易地跳转:

您可以在以下链接的 GitHub 仓库中阅读此信息:github.com/markjprice/cs13net9/blob/main/docs/ch02-features.md

理解 C# 标准

几年来,微软向 ECMA 标准机构提交了几种 C# 版本。微软在 2014 年将 C# 开源。您可以在以下链接中阅读最新的 C# 标准文档:learn.microsoft.com/en-us/dotnet/csharp/specification/。比 ECMA 标准更有实用价值的是公开的 GitHub 仓库,它使 C# 和相关技术的开发尽可能开放,如表 2.1所示:

描述 链接
C# 语言设计 github.com/dotnet/csharplang
编译器实现 github.com/dotnet/roslyn
标准来描述语言 github.com/dotnet/csharpstandard

表 2.1:C# 的公共 GitHub 仓库

发现您的 C# 编译器版本

.NET 语言编译器用于 C# 和 Visual Basic,也称为 Roslyn,以及用于 F# 的单独编译器,作为 .NET SDK 的一部分进行分发。要使用特定的 C# 版本,您必须至少安装该版本的 .NET SDK,如表 2.2 所示:

.NET SDK Roslyn 编译器 默认 C# 语言
1.0.4 2.0–2.2 7.0
1.1.4 2.3–2.4 7.1
2.1.2 2.6–2.7 7.2
2.1.200 2.8–2.10 7.3
3.0 3.0–3.4 8.0
5.0 3.8 9.0
6.0 4.0 10.0
7.0 4.4 11.0
8.0 4.8 12.0
9.0 4.12 13.0

表 2.2:.NET SDK 版本及其 C# 编译器版本

当您创建类库时,可以选择针对 .NET 标准以及现代 .NET 的版本。它们具有默认的 C# 语言版本,如表 2.3 所示:

.NET Standard C#
2.0 7.3
2.1 8.0

表 2.3:.NET 标准版本及其默认 C# 编译器版本

虽然您必须安装 .NET SDK 的最低版本才能访问特定的编译器版本,但您创建的项目可以针对较旧的 .NET 版本,同时仍然使用现代编译器版本。例如,如果您安装了 .NET 9 SDK 或更高版本,那么您可以在针对 .NET 8 的控制台应用程序中使用 C# 13 语言功能。

如何输出 SDK 版本

让我们看看您可用的 .NET SDK 和 C# 语言编译器版本:

  1. 在 Windows 上,启动 Windows Terminal命令提示符。在 macOS 上,启动 终端

  2. 要确定您可用的 .NET SDK 版本,请输入以下命令:

    dotnet --version 
    
  3. 注意,发布时的版本是 9.0.100,这表明它是 SDK 的初始版本,尚未修复任何错误或添加新功能,如下面的输出所示:

    9.0.100 
    

启用特定语言的版本编译器

开发工具如 Visual Studio 和 dotnet 命令行界面默认假设您想要使用 C# 语言编译器的最新主要版本。在 C# 8 发布之前,C# 7 是最新的主要版本,并且默认使用。

要使用 C# 点发布版(如 7.1、7.2 或 7.3)的改进,您必须在项目文件中添加 <LangVersion> 配置元素,如下面的标记所示:

<LangVersion>7.3</LangVersion> 

在 C# 13 与 .NET 9 一起发布后,如果微软发布了 C# 13.1 编译器,并且您想使用其新的语言功能,那么您将不得不在项目文件中添加一个配置元素,如下面的标记所示:

<LangVersion>13.1</LangVersion> 

<LangVersion> 的潜在值如表 2.4 所示:

描述
7, 7.1, 7.2, 7.3, 8, 9, 10, 11, 12, 13 输入特定版本号将使用已安装的该编译器。
latestmajor 使用最高主版本号,例如,2019 年 8 月是 7.0,2019 年 10 月是 8,2020 年 11 月是 9,2021 年 11 月是 10,2022 年 11 月是 11,2023 年 11 月是 12,2024 年 11 月是 13。
latest 使用最高主版本和最高次版本号,例如,2017 年是 7.2,2018 年是 7.3,2019 年是 8,也许 2025 年上半年是 13.1。
preview 使用可用的最高预览版本,例如,在 2025 年 7 月中旬,当安装.NET 10 预览版 6 时,版本号为 14。

表 2.4:项目文件中的 LangVersion 设置

使用预览版 C#编译器版本

2025 年 2 月,微软可能会发布带有 C# 14 编译器的.NET 10 的第一个公共预览版。您可以从以下链接安装其 SDK:

dotnet.microsoft.com/en-us/download/dotnet/10.0

警告! 该链接将直到 2025 年 2 月出现404 缺少资源错误,所以在此期间请不要使用它!

安装.NET 10 SDK 预览版后,您将能够使用它来创建新项目并探索 C# 14 中的新语言功能。

创建新项目后,您必须编辑.csproj文件并添加一个设置为preview<LangVersion>元素,以使用预览版 C# 14 编译器,如下所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
 **<LangVersion>preview</LangVersion>**
  </PropertyGroup>
</Project> 

良好实践:您应该只为探索而将<LangVersion>设置为preview,而不是用于生产项目,因为它不受微软支持,并且可能存在更多错误。微软提供预览版本是因为他们想听取像您这样的开发者的反馈。这样,您就可以成为 C#开发和改进的一部分。

将.NET 9 的 C#编译器切换到未来版本

.NET 9 附带 C# 13 编译器,但这并不意味着您必须使用 C# 13 编译器。一旦.NET 10 SDK 在 2025 年 11 月普遍可用,您将能够获得两者的最佳结合。

您可以在项目继续针对.NET 9 的情况下使用.NET 10 SDK 及其 C# 14 编译器。为此,将目标框架设置为net9.0并添加一个设置为14<LangVersion>元素,如下所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
 **<TargetFramework>net9****.0****</TargetFramework>**
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
 **<LangVersion>****14****</LangVersion>**
  </PropertyGroup>
</Project> 

前一个项目针对net9.0,因此在运行在.NET 9 运行时每月修补版本上时,它将得到支持,直到 2026 年 5 月。如果前一个项目使用.NET 10 SDK 构建,则可以将<LangVersion>设置为14,这意味着 C# 14。

如果您针对net10.0,并且已安装.NET 10 SDK,那么新项目将默认使用 C# 14,因此不需要显式设置。

在 2026 年 2 月,微软可能会发布.NET 11 的第一个预览版,并在 2026 年 11 月,它可能会发布.NET 11 以供生产环境通用。您可以从以下链接安装其 SDK,并像上面描述的 C# 14 和.NET 10 一样探索 C# 15:

dotnet.microsoft.com/en-us/download/dotnet/11.0

再次提醒,前面的链接是供将来使用的!它将直到 2026 年 2 月才会显示404 资源未找到错误,所以在此期间请不要使用前面的链接。

警告! 一些 C#语言特性依赖于底层.NET 库的变化。即使您使用最新的 SDK 和最新的编译器,在针对较老的.NET 版本时,您可能无法使用所有的新语言特性。例如,C# 11 引入了required关键字,但在针对.NET 6 的项目中无法使用,因为该语言特性需要仅在.NET 7 中可用的新属性。幸运的是,如果您尝试使用不支持的功能,编译器会发出警告。只需为此做好准备即可。

显示编译器版本

我们将首先编写显示编译器版本的代码:

  1. 如果您已经完成了第一章你好,C#! 欢迎使用.NET!,那么您将已经有一个cs13net9文件夹。如果没有,那么您需要创建它。

  2. 使用您首选的代码编辑器创建一个新项目,如下列所示:

    • 项目模板:控制台应用程序 [C#] / console

    • 项目文件和文件夹:Vocabulary

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

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

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

良好实践:如果您忘记了如何操作,或者没有完成上一章,那么在第一章你好,C#! 欢迎使用.NET!中提供了创建具有多个项目的解决方案的逐步说明。

  1. Vocabulary项目中,在Program.cs中,在注释之后添加一个语句来显示 C#版本作为错误,如下面的代码所示:

    #error version 
    
  2. 运行控制台应用程序:

    • 如果您正在使用 Visual Studio,那么导航到调试 | 不调试启动。当提示继续并运行最后一个成功的构建时,点击

    • 如果您正在使用 VS Code,那么在Vocabulary文件夹的终端中,输入dotnet run命令。请注意,我们预期会出现编译器错误,所以当您看到它时不要慌张!

  3. 注意,编译器版本和语言版本作为编译器错误消息编号CS8304出现,如图2.1所示:

图 2.1:显示 C#语言版本的编译器错误

VS Code 的问题窗口或 Visual Studio 的错误列表窗口中的错误信息显示为编译器版本:'4.12.0...',语言版本为默认(13.0)

  1. 注释掉导致错误的语句,如下面的代码所示:

    // #error version 
    
  2. 注意,编译器错误信息消失了。

理解 C# 语法和词汇

让我们从查看 C# 语法和词汇的基础开始。在本章中,你将创建多个控制台应用程序,每个应用程序都展示了 C# 语言的相应功能。

理解 C# 语法

C# 的语法包括语句和代码块。要记录代码,你可以使用注释。

良好实践:注释不应该是记录代码的唯一方式。为变量和函数选择合理的名称、编写单元测试以及创建实际文档都是记录代码的其他方法。

语句

在英语中,我们用句号 . 来表示句子的结束。一个句子可以由多个单词和短语组成,单词的顺序是语法的一部分。例如,在英语中,我们说“the black cat。”

形容词,黑色,在名词,之前。然而,法语语法有不同的顺序;形容词在名词之后:“le chat noir。”重要的是要记住,顺序很重要。

C# 使用分号 ; 来表示语句的结束。一个语句可以由多个 类型变量 和由 标记 组成的 表达式 组成。每个标记由空白或某些其他可识别的不同标记(例如运算符)分隔,例如 =+

例如,在以下语句中,decimal 是一种类型,totalPrice 是一个变量,而 subtotal + salesTax 是一个表达式:

decimal totalPrice = subtotal + salesTax; 

表达式由名为 subtotal 的操作数、运算符 + 和另一个名为 salesTax 的操作数组成。操作数和运算符的顺序很重要,因为顺序会影响意义和结果。

注释

注释是记录你的代码以增强其他开发者或你自己在几个月后阅读时对其工作理解的主要方法。

第四章编写、调试和测试函数 中,你将学习关于以三个斜杠 /// 开头的 XML 注释,并使用工具生成网页来记录代码。

你可以使用双斜杠 // 添加注释来解释你的代码。编译器将忽略 // 之后直到行尾的所有内容,如下面的代码所示:

// Sales tax must be added to the subtotal.
decimal totalPrice = subtotal + salesTax; 

要写多行注释,请在注释开头使用 /*,并在注释结尾使用 */,如下面的代码所示:

/*
This is a
multi-line comment.
*/ 

虽然 /* */ 主要用于多行注释,但它也可以用于在语句中间注释,如下面的代码所示:

decimal totalPrice = subtotal /* for this item */ + salesTax; 

良好实践:设计良好的代码,包括具有良好命名的参数的函数签名和类封装,可以具有一定的自文档性。当你发现自己需要在代码中添加太多注释和解释时,问问自己:我能否重写,即重构,这段代码,使其在没有长注释的情况下更容易理解?

你的代码编辑器有命令可以让你更容易地添加和删除注释字符,如下面的列表所示:

  • Visual Studio:导航到编辑 | 高级 | 注释选择取消注释选择

  • VS Code:导航到编辑 | 切换行注释切换块注释

  • Rider:导航到代码 | 使用行注释注释使用块注释注释

良好实践:您通过在代码语句上方或之后添加描述性文本来注释代码。通过在语句之前或周围添加注释字符来取消注释代码,使其失效。取消注释意味着移除注释字符。

区块

在英语中,我们通过开始新行来表示新段落。C#使用大括号{ }来表示区块代码。

区块以声明开始,以指示正在定义的内容。例如,一个区块可以定义许多语言结构的开始和结束,包括命名空间、类、方法或如foreach之类的语句。

您将在本章和随后的章节中了解更多关于命名空间、类和方法的内容,但现在简要介绍一些这些概念:

  • 命名空间包含类等类型,以将它们分组在一起。

  • 包含对象的成员,包括方法。

  • 方法包含实现对象可以执行的动作的语句。

类似于 Visual Studio、Rider 和 VS Code 这样的代码编辑器提供了一个方便的功能,通过在代码左侧边缘移动鼠标光标时切换指向下或向右的箭头符号来折叠和展开区块,如图2.2所示:

图 2.2:展开和折叠的代码编辑器

区域

您可以在任何您想要的语句周围定义自己的标记区域,然后大多数代码编辑器将允许您以与区块相同的方式折叠和展开它们,如下面的代码所示:

#region Three variables that store the number 2 million.
int decimalNotation = 2_000_000;
int binaryNotation = 0b_0001_1110_1000_0100_1000_0000;
int hexadecimalNotation = 0x_001E_8480;
#endregion 

以这种方式,区域可以被当作注释块处理,可以折叠以显示区块所执行的操作摘要。

我将在 GitHub 仓库的解决方案代码中始终使用#region区块,尤其是在我们开始定义作为自然折叠区域的函数之前,但我不会在印刷书中展示它们以节省空间。请根据自己的判断来决定是否想在您的代码中使用区域。

语句和区块的示例

在一个不使用顶级程序功能的简单控制台应用程序中,我在语句和区块中添加了一些注释,如下面的代码所示:

using System; // A semicolon indicates the end of a statement.
namespace Basics
{ // An open brace indicates the start of a block.
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Hello World!"); // A statement.
    }
  }
} // A close brace indicates the end of a block. 

注意,C#使用一种大括号样式,其中开括号和闭括号都位于自己的行上,并且处于相同的缩进级别,如下面的代码所示:

if (x < 3)
{
  // Do something if x is less than 3.
} 

其他语言如 JavaScript 使用花括号,但格式不同。它们将开括号放在声明语句的末尾,如下面的代码所示:

if (x < 3) {
  // Do something if x is less than 3.
} 

您可以使用您喜欢的任何样式,因为编译器并不关心。

有时,为了在印刷书籍中节省垂直空间,我会使用 JavaScript 花括号风格,但大多数情况下我坚持使用 C#花括号风格。我使用两个空格而不是更常见的四个空格进行缩进,因为我的代码将被打印在书中,因此有较窄的宽度可用。

更多信息:官方编码风格规范可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/csharp/fundamentals/coding-style/coding-conventions.

不论任何官方指南,我建议您遵守您开发团队已采纳的标准,除非您是独立开发者,在这种情况下,只要您的代码可以编译,您可以使用任何您喜欢的约定。但请考虑您未来的自己,保持一致性,无论哪种方式都可以!

良好实践:在 Microsoft 官方文档中使用的花括号风格是 C#中最常用的。例如,查看以下链接中的for语句:learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/iteration-statements.

使用空白格格式化代码

空白格包括空格、制表符和换行符。您可以使用空白格以您喜欢的任何方式格式化代码,因为额外的空白格对编译器没有影响。

警告!除非有逐步指导读者输入代码的说明,否则代码示例是编写来阅读和理解的,而不是直接输入到代码编辑器中。

以下四个语句都是等效的。由于所有四个语句都是等效的,它们都使用相同的变量名,因此不能在同一个代码块中声明所有这些,所以请不要尝试输入此代码,除非您也更改了三个sum变量名:

int sum = 1 + 2; // Most developers would prefer this format.
int
sum=1+
2; // One statement over three lines.
int       sum=    1    +2;int sum=1+2; // Two statements on one line. 

在前面的语句中,唯一必需的空白字符是在intsum之间,以告诉编译器它们是不同的标记。任何单个空白字符(例如,空格、制表符或换行符)都是可接受的。

更多信息:您可以在以下链接中阅读 C#空白格的正式定义:learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/lexical-structure#634-white-space.

理解 C#词汇

C#词汇由关键字符号字符类型组成。

在本书中您将看到并频繁使用的预定义保留关键字包括 usingnamespaceclassstaticintstringdoubleboolifswitchbreakwhiledoforforeachthistrue

警告! this 关键字可以以多种方式使用,包括引用对象的当前实例、在当前对象实例上调用构造函数以及定义索引器。这三个示例将在 第五章,使用面向对象编程构建自己的类型 中介绍。

你将看到的某些符号字符包括 ", ', +, -, *, /, %, @, 和 $

“括号”符号包括以下内容:

  • () 被称为 括号。它们用于调用函数、定义表达式或条件,以及在不同类型之间进行转换。

  • {} 被称为 花括号(也称为大括号)。它们用于定义代码块和执行对象和集合的初始化。

  • [] 被称为 方括号(也称为中括号)。它们用于访问数组或集合中的项目,以及围绕装饰代码元素的属性。

  • <> 被称为 尖括号。它们用于泛型类型、XML 和 HTML 文件,以及作为表达式中的 小于大于 符号。

还有其他仅在特定上下文中具有特殊意义的上下文关键字,如 andornotrecordinit

然而,这仍然意味着语言中只有大约 100 个实际的 C# 关键字。

良好实践:C# 关键字使用全部小写。尽管你可以使用全部小写为自己的类型命名,但你不应这样做。从 C# 11 及以后的版本开始,编译器会发出警告,如下所示:警告 CS8981 类型名 'person' 只包含小写 ascii 字符。此类名称可能被保留为语言的一部分。

如果你想要使用 C# 关键字来命名一个变量,那么你可以通过在前面加上 @ 符号来实现,如下面的代码所示:

// Prefix with @ to use a C# keyword as a variable name.
strin0g @class = "A poorly named variable!";
int @if = 5; 

良好实践:尽管你可以使用 C# 关键字作为变量名,但你应该避免这样做,因为这是一种不好的做法。对于多个 C# 版本,微软一直想向语言中添加一个新的关键字 field,但他们不愿意这样做,因为一些开发者可能已经使用了名为 field 的变量,这可能会对那些项目造成破坏性更改。

将编程语言与人类语言进行比较

英语语言有超过 250,000 个不同的单词,那么 C# 如何只拥有大约 100 个关键字就能应付自如?此外,如果 C# 只有英语单词数量的 0.0416%,那么它为什么如此难以学习?

人类语言和编程语言之间的一个关键区别是,开发者需要能够用新的含义定义新的“单词”。除了 C# 语言中的(大约)100 个关键字之外,这本书还将教你关于其他开发者定义的数十万个“单词”,但你还将学习如何定义你自己的“单词”。

全世界的程序员都必须学习英语,因为大多数编程语言使用英语单词,例如“if”和“break”。也有一些编程语言使用其他人类语言,例如阿拉伯语,但它们很少见。如果你对了解更多感兴趣,这个 YouTube 视频展示了阿拉伯语编程语言的演示:www.youtube.com/watch?v=EwYIZBL4sAU

更改 C# 语法颜色方案

默认情况下,Visual Studio 和 VS Code 用蓝色显示 C# 关键字,以便更容易地将它们与其他代码区分开来,其他代码默认为黑色。这两个工具都允许你自定义颜色方案。

在 Visual Studio 中:

  1. 导航到 工具 | 选项

  2. 选项 对话框中,在 环境 部分中选择 字体和颜色,然后选择你想要定制的显示项。你也可以通过搜索而不是浏览来查找该部分。

在 VS Code 中:

  1. 导航到 文件 | 首选项 | 主题 | 颜色主题。在 macOS 上,它在 代码 菜单中。

  2. 选择一个颜色主题。为了参考,我将使用 Light+(默认浅色) 颜色主题,这样截图在打印的书中看起来会更好。

在 Rider 中,导航到 文件 | 设置 | 编辑器 | 颜色方案

编写正确代码的帮助

纯文本编辑器,如记事本,不能帮助你写正确的英语。同样,记事本也不能帮助你写正确的 C#。

Microsoft Word 可以通过用红色波浪线突出显示拼写错误来帮助你写英语,Word 会说“icecream”应该是 ice-cream 或 ice cream,以及用蓝色波浪线突出显示语法错误,例如句子应该以大写字母开头。

同样,Visual Studio 和 VS Code 的 C# 扩展程序通过突出显示拼写错误(例如方法名需要是大写的 WriteLine)和语法错误(例如必须以分号结束的语句)来帮助你编写 C# 代码。

C# 扩展程序会持续监视你输入的内容,并通过用彩色波浪线突出显示问题来给你反馈,就像 Microsoft Word 一样。

让我们看看实际效果:

  1. Program.cs 中,将 WriteLine 方法中的 L 改为小写。

  2. 删除语句末尾的分号。

  3. 在 VS Code 中,导航到 视图 | 问题;在 Visual Studio 中,导航到 视图 | 错误列表;或在 Rider 中,导航到 视图 | 工具窗口 | 问题,注意代码错误下方会出现红色波浪线,并显示详细信息,如 图 2.3 所示:

图 2.3:错误列表窗口显示两个编译错误

  1. 修复两个编码错误。

导入命名空间

System 是一个命名空间,它类似于类型的地址。为了精确地找到某人的位置,你可能使用 Oxford.HighStreet.BobSmith,这告诉我们去牛津市的高街寻找名叫 Bob Smith 的人。

System.Console.WriteLine 告诉编译器在名为 ConsoleSystem 命名空间中查找名为 WriteLine 的方法。

为了简化我们的代码,.NET 6.0 之前每个版本的 Console App 项目模板在代码文件顶部添加了一个语句,告诉编译器始终在 System 命名空间中查找未带命名空间前缀的类型,如下面的代码所示:

using System; // Import the System namespace. 

我们称这种操作为 导入命名空间。导入命名空间的效果是,该命名空间中所有可用的类型都将对程序可用,而无需输入命名空间前缀。当你编写代码时,该命名空间中所有可用的类型都将显示在 IntelliSense 中。

隐式和全局导入命名空间

传统上,每个需要导入命名空间的需要导入命名空间的 .cs 文件都必须以 using 语句开始,以导入这些命名空间。例如,SystemSystem.Linq 命名空间几乎在所有 .cs 文件中都需要,因此每个 .cs 文件的最初几行通常至少包含几个 using 语句,如下面的代码所示:

using System;
using System.Linq;
using System.Collections.Generic; 

当使用 ASP.NET Core 创建网站和服务时,每个文件通常都需要导入数十个命名空间。

C# 10 引入了一个新的关键字组合,.NET SDK 6 引入了一个新的项目设置,这两个设置协同工作以简化导入常用命名空间。

global using 关键字组合意味着你只需在一个 .cs 文件中导入一个命名空间,它将在所有 .cs 文件中可用,而无需在每个需要该命名空间的文件顶部导入该命名空间。你可以在 Program.cs 文件中放置 global using 语句,但我建议创建一个名为 GlobalUsings.cs 的单独文件,其中包含所有 global using 语句,如下面的代码所示:

global using System;
global using System.Linq;
global using System.Collections.Generic; 

良好实践:随着开发人员习惯于这种新的 C# 功能,我预计这个文件的命名约定将成为事实上的标准。正如你即将看到的,相关的 .NET SDK 功能使用类似的命名约定。

任何目标 .NET 6 或更高版本的项目,因此使用 C# 10 或更高版本编译器,将在 obj\Debug\net9.0 文件夹中生成一个 <ProjectName>.GlobalUsings.g.cs 文件,以隐式全局导入一些常用命名空间,如 System。隐式导入的命名空间的具体列表取决于你针对哪个 SDK,如 表 2.5 所示:

SDK 隐式导入的命名空间
Microsoft.NET.Sdk System System.Collections.Generic System.IO System.Linq System.Net.Http System.Threading System.Threading.Tasks
Microsoft.NET.Sdk.Web Microsoft.NET.Sdk,並增加:System.Net.Http.JsonMicrosoft.AspNetCore.BuilderMicrosoft.AspNetCore.HostingMicrosoft.AspNetCore.HttpMicrosoft.AspNetCore.RoutingMicrosoft.Extensions.ConfigurationMicrosoft.Extensions.DependencyInjectionMicrosoft.Extensions.HostingMicrosoft.Extensions.Logging
Microsoft.NET.Sdk.Worker Microsoft.NET.Sdk,並增加:Microsoft.Extensions.ConfigurationMicrosoft.Extensions.DependencyInjectionMicrosoft.Extensions.HostingMicrosoft.Extensions.Logging

表 2.5:.NET SDKs 及其隱式導入的命名空間

讓我們看看當前的自動生成的隱式導入文件:

  1. 解決方案探索器 中,啟用 顯示所有文件 按鈕,並注意編譯器生成的 binobj 文件夾現在是可見的。

  2. Vocabulary 專案中,展開 obj 文件夾,展開 Debug 文件夾,展開 net9.0 文件夾,然後打開名為 Vocabulary.GlobalUsings.g.cs 的文件。

此文件的命名規範為 <ProjectName>.GlobalUsings.g.cs。注意 g 代表 generated,以與開發者編寫的代碼文件區分。

  1. 請記住,此文件是編譯器為目標為 .NET 6 及更高版本的專案自動創建的,並導入了一些常用命名空間,包括 System.Threading,如下面的代碼所示:

    // <autogenerated />
    global using global::System;
    global using global::System.Collections.Generic;
    global using global::System.IO;
    global using global::System.Linq;
    global using global::System.Net.Http;
    global using global::System.Threading;
    global using global::System.Threading.Tasks; 
    
  2. 关闭 Vocabulary.GlobalUsings.g.cs 文件。

  3. 解決方案探索器 中,打開 Vocabulary.csproj 專案文件,然後向專案文件添加更多條目以控制哪些命名空間被隱式導入,如下面的標記中突出顯示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
     **<ItemGroup>**
     **<Using Remove=****"System.Threading"** **/>**
     **<Using Include=****"System.Numerics"** **/>**
     **<Using Include=****"System.Console"** **Static=****"true"** **/>**
     **<Using Include=****"System.Environment"** **Alias=****"Env"** **/>**
     **</ItemGroup>**
    </Project> 
    

注意 <ItemGroup><ImportGroup> 的不同。請確保使用正確的一個!還要注意,項目組或項目組中元素的順序不重要。例如,<Nullable> 可以在 <ImplicitUsings> 之前或之後。

  1. 將更改保存到專案文件。

  2. 展開 obj 文件夾,展開 Debug 文件夾,展開 net9.0 文件夾,然後打開名為 Vocabulary.GlobalUsings.g.cs 的文件。

  3. 注意,此文件現在導入 System.Numerics 而不是 System.Threading,已導入 Environment 類並將其別名為 Env,並靜態導入了 Console 類,如下面的代碼中突出顯示:

    // <autogenerated />
    global using global::System;
    global using global::System.Collections.Generic;
    global using global::System.IO;
    global using global::System.Linq;
    global using global::System.Net.Http;
    **global****using****global****::System.Numerics;**
    global using global::System.Threading.Tasks;
    **global****using** **Env =** **global****::System.Environment;**
    **global****using****static****global****::System.Console;** 
    
  4. Program.cs 中添加一條輸出計算機信息的語句,並注意由於我們靜態導入了 Console 類,我們可以無預先添加 Console 前綴來調用其方法,例如 WriteLine,並可以使用其別名 Env 引用 Environment 類,如下面的代碼所示:

    WriteLine($"Computer named {Env.MachineName} says \"No.\""); 
    
  5. 运行專案,并注意信息,如下面的輸出所示:

    Computer named DAVROS says "No." 
    

您的计算机名称将不同,除非您像我一樣將您的計算機命名為《神秘博士》中的角色名稱。

你可以通过从项目文件中完全删除<ImplicitUsings>元素或将它的值更改为disable来为所有 SDK 禁用隐式导入命名空间的功能,如下面的标记所示:

<ImplicitUsings>disable</ImplicitUsings> 

良好实践:如果你想要手动创建一个包含所有global using语句的单个文件,而不是可能自动生成一个并手动创建其他文件,你可以选择这样做。但我的建议是保持该功能启用,并修改项目文件以更改obj文件夹层次结构中自动生成的类文件中包含的内容。

动词是方法

在英语中,动词是进行或动作词,如“跑”和“跳”。在 C#中,进行或动作词被称为方法。C#有数十万个方法可用。在英语中,动词根据动作发生的时间改变它们的书写方式。例如,Amir 过去正在跳,Beth 现在,他们过去跳过,Charlie 将来将跳

在 C#中,例如WriteLine这样的方法会根据动作的具体情况改变它们的调用或执行方式。这被称为重载,我们将在第五章“使用面向对象编程构建自己的类型”中更详细地介绍。但就目前而言,请考虑以下示例:

// Outputs the current line terminator.
// By default, this is a carriage-return and line feed.
Console.WriteLine();
// Outputs the greeting and the current line terminator.
Console.WriteLine("Hello Ahmed");
// Outputs a formatted number and date and the current line terminator.
Console.WriteLine(
  "Temperature on {0:D} is {1}°C.", DateTime.Today, 23.4); 

当我展示没有编号的逐步说明的代码片段时,我不期望你将其作为代码输入,因此它们不会在没有上下文的情况下执行。

一个不同但并不完全精确的类比是,有些动词拼写相同,但根据上下文有不同的含义,例如,你可以输掉一场游戏,失去书中的位置,或者丢失钥匙。

名词是类型、变量、字段和属性

在英语中,名词是指出事物的名称。例如,Fido 是一只狗的名字。“狗”这个词告诉我们 Fido 是什么类型的事物,因此要命令 Fido 去捡球,我们会使用他的名字。

在 C#中,它们的对应物是类型变量字段属性。例如:

  • AnimalCar是类型;它们是用于分类事物的名词。

  • HeadEngine可能是字段或属性;它们是属于AnimalCar的名词。

  • FidoBob是变量;它们是用于指代特定对象的名词。

C#有数万个类型可用,尽管你有没有注意到我没有说“在 C#中有数万个类型”?这种区别很微妙但很重要。C#语言只有几个关键字用于类型,如stringint,严格来说,C#并没有定义任何类型。看起来像类型的关键字,如string,是别名,它们代表 C#运行的平台提供的类型。

重要的是要知道 C#不能独立存在;毕竟,它是一种运行在.NET 变体上的语言。理论上,有人可以为 C#编写一个编译器,使用不同的平台,具有不同的底层类型。在实践中,C#的平台是.NET,它为 C#提供了数万个类型,包括System.Int32,这是int映射到的 C#关键字别名,以及许多更复杂的类型,如System.Xml.Linq.XDocument

值得注意的是,术语类型经常与混淆。你有没有玩过聚会游戏“二十个问题”,也称为“动物、植物或矿物”?在这个游戏中,一切都可以归类为动物、植物或矿物。在 C#中,每个类型都可以归类为classstructenuminterfacedelegate。你将在第六章“实现接口和继承类”中了解这些的含义。例如,C#关键字string是一个class,但int是一个struct。因此,最好使用术语类型来指代两者。

揭示 C#词汇的广度

我们知道 C#有超过 100 个关键字,但有多少个类型呢?让我们编写一些代码来找出在简单的控制台应用程序中 C#可以访问多少个类型(及其方法)。

目前不必担心代码的具体工作原理,但要知道它使用了一种称为反射的技术:

  1. Program.cs中注释掉所有现有的语句。

  2. 我们首先在Program.cs文件的顶部导入System.Reflection命名空间,这样我们就可以使用该命名空间中的某些类型,如AssemblyTypeName,如下面的代码所示:

    using System.Reflection; // To use Assembly, TypeName, and so on. 
    

良好实践:我们可以使用隐式导入和global using功能将此命名空间导入到项目中所有.cs文件中,但由于只有一个文件,最好在需要此命名空间的文件中导入该命名空间。

  1. 编写语句以获取编译后的控制台应用程序,并遍历它可访问的所有类型,输出每个类型的名称和方法数量,如下面的代码所示:

    // Get the assembly that is the entry point for this app.
    Assembly? myApp = Assembly.GetEntryAssembly();
    // If the previous line returned nothing then end the app.
    if (myApp is null) return;
    // Loop through the assemblies that my app references.
    foreach (AssemblyName name in myApp.GetReferencedAssemblies())
    {
      // Load the assembly so we can read its details.
      Assembly a = Assembly.Load(name);
      // Declare a variable to count the number of methods.
      int methodCount = 0;
      // Loop through all the types in the assembly.
      foreach (TypeInfo t in a.DefinedTypes)
      {
        // Add up the counts of all the methods.
        methodCount += t.GetMethods().Length;
      }
      // Output the count of types and their methods.
      WriteLine("{0:N0} types with {1:N0} methods in {2} assembly.",
        arg0: a.DefinedTypes.Count(),
        arg1: methodCount,
        arg2: name.Name);
    } 
    

N0是大写字母N后面跟着数字零。它不是大写字母N后面跟着大写字母O。它的意思是“用零(0)位小数格式化数字(N)。”

  1. 运行项目。当你在操作系统OS)上运行时,你会看到最简单应用中可用的类型和方法的实际数量。显示的类型和方法数量将根据你使用的操作系统而有所不同,如下面的 Windows 输出所示:

    0 types with 0 methods in System.Runtime assembly.
    41 types with 639 methods in System.Console assembly.
    112 types with 1,190 methods in System.Linq assembly. 
    

为什么System.Runtime程序集包含零个类型?这个程序集是特殊的,因为它只包含类型前向器而不是实际类型。类型前向器代表一个在.NET 之外或由于某些其他高级原因实现的类型。

  1. 在文件顶部(在导入 System.Reflection 命名空间之后)添加语句以声明一些变量,如下面的代码所示:

    // Declare some unused variables using types in
    // additional assemblies to make them load too.
    System.Data.DataSet ds = new();
    HttpClient client = new(); 
    
  2. 再次运行控制台应用程序并查看结果,结果应该如下所示:

    0 types with 0 methods in System.Runtime assembly.
    406 types with 7,139 methods in System.Data.Common assembly.
    425 types with 4,681 methods in System.Net.Http assembly.
    41 types with 639 methods in System.Console assembly.
    112 types with 1,190 methods in System.Linq assembly. 
    

现在,你更有理由理解为什么学习 C# 是一项挑战——因为有很多类型和方法需要学习。方法只是类型可以拥有的成员类别之一,你和其他程序员不断定义新的类型和成员!

示例:请求 ChatGPT 解释代码

我们在 Vocabulary 项目中编写的代码是当你可能想要请求 ChatGPT 解释某些内容时的一个很好的例子。如果你输入提示“解释以下代码:”然后是代码,这是它的响应:

“这段 C# 代码对当前应用程序引用的程序集(本质上相当于库或模块)进行分析。它统计了每个引用程序集中的类型和方法的总数。”

让我们更详细地分解代码:

  1. 使用 System.Reflection 命名空间在运行时处理程序集、模块、类型、方法等。

  2. 声明了 System.Data.DataSetHttpClient 实例,但未使用。注释暗示这些实例是为了确保它们各自的程序集在后续分析中已加载。从某些程序集创建类的实例可能会导致这些程序集被加载到内存中。

  3. Assembly.GetEntryAssembly() 获取应用程序的入口点程序集。

  4. 如果 myApp 为空(表示没有入口程序集,这在正常运行的 .NET 应用程序中应该是不可行的),则应用程序退出。

  5. foreach 循环遍历入口程序集引用的程序集中的每个程序集名称。

  6. Assembly.Load(name) 根据指定的程序集名称加载程序集。这允许您以编程方式与程序集交互。

  7. 声明并初始化了一个计数变量 methodCount0。这用于计算程序集中方法的总数。

  8. 另一个嵌套的 foreach 循环遍历程序集中的每个类型。

  9. 对于每种类型,它获取方法的数量 (t.GetMethods().Length) 并将其添加到 methodCount

  10. 最后,使用 WriteLine() 将程序集中的类型及其方法数量输出到控制台。字符串中的 {0:N0} 格式说明符是一个用于数字的占位符(0 指的是参数索引,N 表示数字格式,N 后面的 0 指定小数位数)。

因此,本质上,这个脚本统计了应用程序引用的每个程序集中存在多少类型和方法。这可能有助于了解应用程序的依赖项的复杂度或大小。”

现在回到现实世界,马克。这是一个相当不错的解释,没有错误或幻觉!

良好实践:GitHub Copilot 和 ChatGPT 等工具对于程序员在学习新事物时特别有用。一个优秀的程序员总是在学习新事物!这些工具不仅可以帮助您编写代码,还可以解释他人编写的代码,甚至提出改进建议。

与变量一起工作

所有应用程序都处理数据。数据进来,数据处理,然后数据出去。

数据通常来自文件、数据库或用户输入,可以暂时存储在变量中,这些变量将存储在运行程序的内存中。当程序结束时,内存中的数据就会丢失。数据通常输出到文件、数据库、屏幕或打印机。使用变量时,您应该首先考虑变量在内存中占用的空间大小,其次考虑其处理速度的快慢。

我们通过选择合适的数据类型来控制这个过程。您可以将简单的常见类型,如 intdouble,视为不同大小的存储盒,较小的盒子占用较少的内存,但可能处理速度不快;例如,在 64 位操作系统上,添加 16 位数字可能不如添加 64 位数字处理得快。这些盒子中的一些可能堆叠得很近,而另一些可能被扔到更远的大堆里。

命名事物和赋值

对于事物有命名约定,遵循它们是一个好习惯,如表 2.6所示:

命名约定 示例 用于
驼峰命名法 cost, orderDetail, 和 dateOfBirth 本地变量和私有字段
标题大小写,也称为帕斯卡大小写 String, Int32, Cost, DateOfBirth, 和 Run 类型、非私有字段和其他成员,如方法

表 2.6:命名约定及其应用

一些 C#程序员喜欢在私有字段名称前加下划线,例如,_dateOfBirth 而不是 dateOfBirth。所有类型的私有成员的命名没有正式定义,因为它们在类外是不可见的,所以无论是带下划线前缀还是不带都是有效的。

良好实践:遵循一致的命名约定将使您的代码容易被其他开发者(以及未来的您!)理解。

下面的代码块展示了使用 = 符号声明一个命名本地变量并将其赋值的示例。请注意,您可以使用 C# 6 中引入的关键字 nameof 输出变量的名称:

// Let the heightInMetres variable become equal to the value 1.88.
double heightInMetres = 1.88;
Console.WriteLine($"The variable {nameof(heightInMetres)} has the value
{heightInMetres}."); 

警告!前述代码中双引号内的消息换行是因为打印页面的宽度太窄。当在代码编辑器中输入此类语句时,请将其全部输入一行。

在 C# 12 及更高版本中,nameof 现在可以从静态上下文访问实例数据。您将在 第五章使用面向对象编程创建自己的类型 中学习实例和静态数据之间的区别。

字面值

当您向变量赋值时,您通常会,但不总是,分配一个 字面值。但什么是字面值?字面值是一种表示固定值的符号。数据类型有不同的符号来表示它们的字面值,在接下来的几节中,您将看到使用字面值符号将值赋给变量的示例。

更多信息:您可以在 C# 语言规范中阅读字面值的正式定义:learn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/lexical-structure#645-literals

存储文本

对于文本,单个字母,如 A,存储为 char 类型。

良好实践:实际上,这可能会更复杂。埃及象形文字 A002 (U+13001) 需要两个 System.Char 值(称为代理对)来表示它:\uD80C\uDC01。不要总是假设一个 char 等于一个字母,否则您可能会在代码中引入难以察觉的错误。

char 的赋值使用单引号包围字面值,或分配函数调用的返回值,如下面的代码所示:

char letter = 'A'; // Assigning literal characters.
char digit = '1';
char symbol = '$';
char userChoice = GetChar(); // Assigning from a fictitious function. 

对于文本,多个字母,如 Bob,存储为 string 类型,并使用双引号包围字面值或通过分配函数调用或构造函数的返回值来赋值,如下面的代码所示:

string firstName = "Bob"; // Assigning literal strings.
string lastName = "Smith";
string phoneNumber = "(215) 555-4256";
// Assigning a string returned from the string class constructor.
string horizontalLine = new('-', count: 74); // 74 hyphens.
// Assigning a string returned from a fictitious function.
string address = GetAddressFromDatabase(id: 563);
// Assigning an emoji by converting from Unicode.
string grinningEmoji = char.ConvertFromUtf32(0x1F600); 

输出表情符号

要在 Windows 的命令提示符中输出表情符号,您必须使用 Windows Terminal,因为命令提示符不支持表情符号,并将控制台输出编码设置为使用 UTF-8,如下面的代码所示:

Console.OutputEncoding = System.Text.Encoding.UTF8;
string grinningEmoji = char.ConvertFromUtf32(0x1F600);
Console.WriteLine(grinningEmoji); 

文本字面值

转义字符是编程和文本处理中的一种特殊字符,用于引入 转义序列。转义序列允许表示难以或无法直接在字符串中包含的字符。这些序列通常以反斜杠 \ 开头,后跟一个或多个字符。

当在 string 变量中存储文本时,您可以包含转义序列,这些序列使用反斜杠表示特殊字符,如制表符和换行符,如下面的代码所示:

string fullNameWithTabSeparator = "Bob\tSmith"; 

但如果您在 Windows 上存储文件的路径,并且其中一个文件夹名称以 T 开头,如下面的代码所示?

string filePath = "C:\televisions\sony\bravia.txt"; 

编译器会将 \t 转换为制表符,你将得到错误!

您必须使用 @ 符号作为前缀来使用文本字面值 string,如下面的代码所示:

string filePath = @"C:\televisions\sony\bravia.txt"; 

在 C# 13 及更高版本中,您可以使用字符字面值转义序列 \e 来表示 ESC 字符(Unicode U+001B),如下面的代码所示:

// C# 13 or later.
char esc = '\e';
// C# 12 or earlier.
char esc = '\u001b'; 

在过去,你可能使用过 \u001b\x1b。然而,使用 \x1b 并不推荐,因为任何在 1b 后面的有效十六进制数字都会被错误地解释为转义序列的一部分。

原始字符串字面量

从 C# 11 开始引入的原始字符串字面量,方便输入任何任意文本,而无需对内容进行转义。它们使得定义包含其他语言(如 XML、HTML 或 JSON)的字面量变得容易。

原始字符串字面量以三个或更多双引号字符开始和结束,如下面的代码所示:

string xml = """
             <person age="50">
               <first_name>Mark</first_name>
             </person>
             """; 

为什么需要三个或更多双引号字符?这是因为在内容本身需要包含三个双引号字符的情况下;这时你可以使用四个双引号字符来表示内容的开始和结束。如果内容需要包含四个双引号字符,那么你可以使用五个双引号字符来表示内容的开始和结束。依此类推。

在之前的代码中,XML 被缩进 13 个空格。编译器查看最后三个或更多双引号字符的缩进,然后自动从原始字符串字面量内的所有内容中移除该级别的缩进。因此,之前代码的结果将不会像定义代码那样缩进,而是与左边界对齐,如下面的标记所示:

<person age="50">
  <first_name>Mark</first_name>
</person> 

如果最后的三个双引号字符与左边界对齐,如下面的代码所示:

string xml = """
             <person age="50">
               <first_name>Mark</first_name>
             </person>
"""; 

那么在下面的标记中,13 个空格的缩进将不会被移除:

 <person age="50">
               <first_name>Mark</first_name>
             </person> 

原始插值字符串字面量

你可以将使用花括号 {} 的插值字符串与原始字符串字面量混合使用。你通过在字面量开头添加相应数量的美元符号来指定表示替换表达式的花括号数量。任何少于该数量的花括号都被视为原始内容。

例如,如果我们想定义一些 JSON,单花括号将被视为普通花括号,但两个美元符号告诉编译器任何两个花括号都表示一个替换表达式值,如下面的代码所示:

var person = new { FirstName = "Alice", Age = 56 };
string json = $$"""
              {
                "first_name": "{{person.FirstName}}",
                "age": {{person.Age}},
                "calculation": "{{{ 1 + 2 }}}"
              }
              """;
Console.WriteLine(json); 

之前的代码将生成以下 JSON 文档:

{
  "first_name": "Alice",
  "age": 56,
  "calculation": "{3}"
} 

美元符号的数量告诉编译器需要多少个花括号才能被识别为插值表达式。

注意围绕 3 的花括号 {}。这是故意的。在这个例子中,JSON 文档必须生成一个包含花括号的计算。为了展示这一点,代码使用了三个花括号:第一个开括号将输出为一个字面字符。接下来的两个花括号将被解释为表达式的开始。前两个闭括号将被解释为表达式的结束。最后一个闭括号将是一个字面字符。

如果代码只使用了两个花括号,那么这些花括号被视为表达式 1 + 2 的分隔符,并且不会出现在输出中:

var person = new { FirstName = "Alice", Age = 56 };
string json = $$"""
              {
                "first_name": "{{person.FirstName}}",
                "age": {{person.Age}},
                "calculation": "{{ 1 + 2 }}"
              }
              """;
Console.WriteLine(json); 

现在它生成了以下输出:

{
  "first_name": "Alice",
  "age": 56,
  "calculation": "3"
} 

总结存储文本的选项

总结:

  • 字面字符串:用双引号括起来的字符。它们可以使用像 \t 这样的转义字符。要表示反斜杠,使用两个:\\

  • 原始字符串字面量:用三个或更多双引号括起来的字符。

  • 字面字符串:以 @ 为前缀的字符串,用于禁用转义字符,因此反斜杠 \ 就是字面意义上的反斜杠。它还允许 string 值跨越多行,因为空白字符被视为自身,而不是传递给编译器的指令。

  • 插值字符串:以 $ 为前缀的字面字符串,用于启用嵌入的格式化变量。你将在本章后面了解更多关于此内容。

存储数字

数字是我们想要进行算术运算的数据,例如乘法。电话号码不是一个可量化的数字。要决定一个变量是否应该存储为数字,问问自己你是否需要对数字执行算术运算,或者数字是否包含非数字字符,如括号或破折号,用于格式化数字,例如 (414) 555-1234。在这种情况下,“数字”是一系列字符,因此应该将其存储为 string

数字可以是自然数,例如 42,用于计数(也称为整数);它们也可以是负数,例如 -42(称为整数);或者它们可以是实数,例如 3.9(有分数部分),在计算机中称为单精度或双精度浮点数。

让我们探索数字:

  1. 使用你喜欢的代码编辑器将一个新的 Console App / console 项目命名为 Numbers 并添加到 Chapter02 解决方案中:

    • 对于 Visual Studio,将启动项目配置为当前选择。
  2. Program.cs 中,删除现有代码,然后输入语句来声明一些使用各种数据类型的数字变量,如下面的代码所示:

    // An unsigned integer is a positive whole number or 0.
    uint naturalNumber = 23;
    // An integer is a negative or positive whole number or 0.
    int integerNumber = -23;
    // A float is a single-precision floating-point number.
    // The F or f suffix makes the value a float literal.
    // The suffix is required to compile.
    float realNumber = 2.3f;
    // A double is a double-precision floating-point number.
    // double is the default for a number value with a decimal point.
    double anotherRealNumber = 2.3; // A double literal value. 
    

存储整数

你可能知道计算机将所有内容存储为位。位的值是 01。这被称为二进制数制。人类使用十进制数制

十进制数制,也称为十进制,以 10 为其基数,这意味着有 10 个数字,从 0 到 9。尽管它是人类文明中最常用的数制,但在科学、工程和计算机科学中,其他数制系统也很流行。二进制数制,也称为二进制,以 2 为其基数,这意味着有两个数字,0 和 1。

以下图像显示了计算机如何存储十进制数字 10。注意 8 和 2 列中值为 1 的位:8 + 2 = 10。

图 2.4:计算机如何存储十进制数字 10

因此,十进制中的 10 在二进制字节(8 位)中是 00001010

通过使用数字分隔符提高可读性

C# 7 及以后的版本中看到的两项改进是使用下划线字符 _ 作为数字分隔符以及支持二进制字面量。

你可以在数字字面量的数字中插入下划线,包括十进制、二进制或十六进制表示法,以提高可读性。

例如,你可以将一百万的十进制表示法,即基 10,写成 1_000_000

你甚至可以使用印度常见的 2/3 分组:10_00_000

使用二进制或十六进制表示法

要使用二进制表示法,即基 2,仅使用 1 和 0,在数字字面量前加上 0b。要使用十六进制表示法,即基 16,使用 0 到 9 和 A 到 F,在数字字面量前加上 0x

探索整数

让我们输入一些代码来查看一些示例:

  1. Numbers 项目中的 Program.cs 文件中,输入语句以使用下划线分隔符声明一些数字变量,如下面的代码所示:

    int decimalNotation = 2_000_000;
    int binaryNotation = 0b_0001_1110_1000_0100_1000_0000;
    int hexadecimalNotation = 0x_001E_8480;
    // Check the three variables have the same value.
    Console.WriteLine($"{decimalNotation == binaryNotation}");
    Console.WriteLine(
      $"{decimalNotation == hexadecimalNotation}");
    // Output the variable values in decimal.
    Console.WriteLine($"{decimalNotation:N0}");
    Console.WriteLine($"{binaryNotation:N0}");
    Console.WriteLine($"{hexadecimalNotation:N0}");
    // Output the variable values in hexadecimal.
    Console.WriteLine($"{decimalNotation:X}");
    Console.WriteLine($"{binaryNotation:X}");
    Console.WriteLine($"{hexadecimalNotation:X}"); 
    
  2. 运行项目并注意结果显示三个数字都是相同的,如下面的输出所示:

    True
    True
    2,000,000
    2,000,000
    2,000,000
    1E8480
    1E8480
    1E8480 
    

计算机可以使用 int 类型或其兄弟类型(如 longshort)精确地表示整数。

存储实数

计算机不能总是精确地表示实数,即十进制或非整数数。floatdouble 类型使用单精度和双精度浮点数来存储实数。

大多数编程语言实现了电气和电子工程师协会(IEEE)的浮点算术标准。IEEE 754 是 IEEE 于 1985 年建立的一个关于浮点算术的技术标准。

以下图像显示了计算机如何简化表示数字 12.75 的二进制表示法。注意 8、4、½和¼列中的值为 1 的位:

8 + 4 + ½ + ¼ = 12¾ = 12.75

图 2.5:计算机以二进制表示法表示数字 12.75

因此,十进制表示法中的 12.75 在二进制表示法中是 00001100.1100。正如你所见,数字 12.75 可以使用位精确表示。然而,大多数数字不能,这是我们很快将要探讨的。

编写代码以探索数字大小

C# 有一个名为 sizeof() 的运算符,它返回类型在内存中使用的字节数。某些类型有名为 MinValueMaxValue 的成员,它们返回可以存储在该类型变量中的最小和最大值。我们现在将使用这些功能来创建一个控制台应用程序以探索数字类型:

  1. Program.cs 文件底部,输入语句以显示三种数字数据类型的大小,如下面的代码所示:

    Console.WriteLine($"int uses {sizeof(int)} bytes and can store numbers in the range {int.MinValue:N0} to {int.MaxValue:N0}.");
    Console.WriteLine($"double uses {sizeof(double)} bytes and can store numbers in the range {double.MinValue:N0} to {double.MaxValue:N0}.");
    Console.WriteLine($"decimal uses {sizeof(decimal)} bytes and can store numbers in the range {decimal.MinValue:N0} to {decimal.MaxValue:N0}."); 
    

警告!本书中打印页面的宽度使得字符串值(双引号内)会换行。你必须将它们输入在同一行上,否则你会得到编译错误。

  1. 运行代码并查看输出,如图 2.6 所示:

图 2.6:常见数字数据类型的尺寸和范围信息

int 变量使用 4 个字节的内存,可以存储高达约 20 亿的正负数。double 变量使用 8 个字节的内存,可以存储更大的值!decimal 变量使用 16 个字节的内存,可以存储大数字,但不如 double 类型大。

但你可能想知道,为什么 double 变量能够存储比 decimal 变量更大的数字,而它只使用了内存空间的一半?好吧,现在我们就来找出答案!

比较 doubledecimal 类型

你现在将编写一些代码来比较 doubledecimal 值。虽然不难理解,但现在不必担心理解语法:

  1. 输入类型声明来声明两个 double 变量,将它们相加,并将它们与预期结果进行比较。然后,将结果写入控制台,如下面的代码所示:

    Console.WriteLine("Using doubles:");
    double a = 0.1;
    double b = 0.2;
    if (a + b == 0.3)
    {
      Console.WriteLine($"{a} + {b} equals {0.3}");
    }
    else
    {
      Console.WriteLine($"{a} + {b} does NOT equal {0.3}");
    } 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Using doubles:
    0.1 + 0.2 does NOT equal 0.3 
    

在使用逗号作为小数分隔符的文化中,结果将略有不同,如下面的输出所示:0,1 + 0,2 does NOT equal 0,3

double 类型不能保证准确性,因为大多数数字,如 0.10.20.3,实际上不能精确地表示为浮点值。

如果你尝试不同的值,比如 0.1 + 0.3 == 0.4,它可能会返回 true,因为对于 double 类型的值,一些不精确的值在当前表示中恰好相等,尽管它们在数学上可能并不相等。所以,有些数字可以直接比较,但有些则不能。我特意选择了 0.10.2 来与 0.3 比较,因为它们不能进行比较,正如结果所证明的。

你可以比较存储在 float 类型的实数,它比 double 类型更不精确,但由于这种较低的精度,比较实际上会显示为 true

float a = 0.1F;
float b = 0.2F;
if (a + b == 0.3F) // True because float is less "accurate" than double.
... 

作为一般规则,你应该只在精度,尤其是比较两个数字的相等性不重要时使用 double。一个例子可能是当你测量一个人的身高时;你将只使用大于或小于来比较值,但永远不会使用等于。

前面代码的问题可以通过计算机存储数字 0.1 或其倍数的方式来说明。为了在二进制表示法中表示 0.1,计算机在 1/16 列存储 1,在 1/32 列存储 1,在 1/256 列存储 1,在 1/512 列存储 1,依此类推。

十进制表示法中的数字 0.1 在二进制表示法中是 0.00011001100110011…,无限循环,如 图 2.7 所示:

图 2.7:十进制表示法中数字 0.1 在二进制表示法中无限循环

良好实践:永远不要使用 == 比较双精度值。在 1990-1991 年的海湾战争中,一个美国爱国者导弹营在其计算中使用了双精度值。这种不精确导致它未能追踪和拦截一枚来袭的伊拉克飞毛腿导弹,造成 28 名士兵死亡;您可以在www.ima.umn.edu/~arnold/disasters/patriot.html上了解更多信息。自那时起,爱国者导弹系统已经得到了改进。“在服役 40 年后,爱国者防空系统终于做到了它被设计要做的。” “没有人 100%确信爱国者能够摧毁一枚 Kh-47 高超音速导弹,”基辅第 96 防空导弹旅指挥官谢尔盖·亚列缅科上校说。“乌克兰人证明了这一点。”:archive.ph/2023.06.11-132200/https://www.wsj.com/amp/articles/u-s-patriot-missile-is-an-unsung-hero-of-ukraine-war-db6053a0

现在让我们看看使用 decimal 数字类型的相同代码:

  1. 复制并粘贴您之前编写的语句(使用了 double 变量)。

  2. 修改语句以使用 decimal 并将变量重命名为 cd,如下面的代码所示:

    Console.WriteLine("Using decimals:");
    decimal c = 0.1M; // M suffix means a decimal literal value
    decimal d = 0.2M;
    if (c + d == 0.3M)
    {
      Console.WriteLine($"{c} + {d} equals {0.3M}");
    }
    else
    {
      Console.WriteLine($"{c} + {d} does NOT equal {0.3M}");
    } 
    
  3. 运行代码并查看结果,如下面的输出所示:

    Using decimals:
    0.1 + 0.2 equals 0.3 
    

decimal 类型是准确的,因为它将数字存储为一个大整数,并移动小数点。例如,0.1 被存储为 1,并标记为将小数点向左移动一位。12.75 被存储为 1275,并标记为将小数点向左移动两位。

良好实践:对于整数使用 int。对于不会与其他值进行比较的实数使用 double;比较 double 值是否小于或大于等是可以的。对于金钱、CAD 图纸、通用工程以及任何需要实数精度的场合使用 decimal

特殊实数值

floatdouble 类型有一些有用的特殊值:NaN 表示“不是一个数字”(例如,零除以零的结果),Epsilon 表示可以存储在 floatdouble 中的最小正数,PositiveInfinityNegativeInfinity 表示无限大的正数和负数。它们还有用于检查这些特殊值的方法,如 IsInfinityIsNaN

让我们编写一些代码语句来查看这些特殊值的外观:

  1. 向输出中添加语句以输出一些特殊的 double 值,如下面的代码所示:

    #region Special float and double values
    Console.WriteLine($"double.Epsilon: {double.Epsilon}");
    Console.WriteLine($"double.Epsilon to 324 decimal places: {double.Epsilon:N324}");
    Console.WriteLine($"double.Epsilon to 330 decimal places: {double.Epsilon:N330}");
    const int col1 = 37; // First column width.
    const int col2 = 6; // Second column width.
    string line = new string('-', col1 + col2 + 3);
    Console.WriteLine(line);
    Console.WriteLine($"{"Expression",-col1} | {"Value",col2}");
    Console.WriteLine(line);
    Console.WriteLine($"{"double.NaN",-col1} | {double.NaN,col2}");
    Console.WriteLine($"{"double.PositiveInfinity",-col1} | {double.PositiveInfinity,col2}");
    Console.WriteLine($"{"double.NegativeInfinity",-col1} | {double.NegativeInfinity,col2}");
    Console.WriteLine(line);
    Console.WriteLine($"{"0.0 / 0.0",-col1} | {0.0 / 0.0,col2}");
    Console.WriteLine($"{"3.0 / 0.0",-col1} | {3.0 / 0.0,col2}");
    Console.WriteLine($"{"-3.0 / 0.0",-col1} | {-3.0 / 0.0,col2}");
    Console.WriteLine($"{"3.0 / 0.0 == double.PositiveInfinity",-col1} | {3.0 / 0.0 == double.PositiveInfinity,col2}");
    Console.WriteLine($"{"-3.0 / 0.0 == double.NegativeInfinity",-col1} | {-3.0 / 0.0 == double.NegativeInfinity,col2}");
    Console.WriteLine($"{"0.0 / 3.0",-col1} | {0.0 / 3.0,col2}");
    Console.WriteLine($"{"0.0 / -3.0",-col1} | {0.0 / -3.0,col2}");
    Console.WriteLine(line);
    #endregion 
    
  2. 运行代码并查看结果,如下面的输出所示:

    double.Epsilon: 5E-324
    double.Epsilon to 324 decimal places: 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005
    double.Epsilon to 330 decimal places: 0.000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004940656
    ----------------------------------------------
    Expression                            |  Value
    ----------------------------------------------
    double.NaN                            |    NaN
    double.PositiveInfinity               |      8
    double.NegativeInfinity               |     -8
    ----------------------------------------------
    0.0 / 0.0                             |    NaN
    3.0 / 0.0                             |      8
    -3.0 / 0.0                            |     -8
    3.0 / 0.0 == double.PositiveInfinity  |   True
    -3.0 / 0.0 == double.NegativeInfinity |   True
    0.0 / 3.0                             |      0
    0.0 / -3.0                            |     -0
    ---------------------------------------------- 
    

注意以下内容:

  • NaN 输出为 NaN。(NaN 表示“不是一个数字”。) 它可以由零除以零的表达式生成。

  • PositiveInfinity值输出为一个8,看起来像其侧面的无穷符号。它可以由任何正实数除以零的表达式生成。

  • NegativeInfinity值输出为-8,看起来像其侧面的无穷符号,前面有一个负号。它可以由任何负实数除以零的表达式生成。

  • 零除以任何正实数是零。

  • 零除以任何负实数是负零。

  • Epsilon略小于使用科学记数法表示的5E-324en.wikipedia.org/wiki/Scientific_notation

新数字类型和不安全代码

System.Half类型是在.NET 5 中引入的。像floatdouble一样,它可以存储实数。它通常使用两个字节的内存。System.Int128System.UInt128类型是在.NET 7 中引入的。像intuint一样,它们可以存储有符号(正负)和无符号(只有零和正)的整数值。它们通常使用 16 字节的内存。

对于这些新数字类型,sizeof运算符仅在非安全代码块中有效,你必须使用启用非安全代码的选项编译项目。让我们探索它是如何工作的:

  1. Program.cs文件底部,输入语句以显示HalfInt128数字数据类型的大小,如下所示代码:

    unsafe
    {
      Console.WriteLine($"Half uses {sizeof(Half)} bytes and can store numbers in the range {Half.MinValue:N0} to {Half.MaxValue:N0}.");
      Console.WriteLine($"Int128 uses {sizeof(Int128)} bytes and can store numbers in the range {Int128.MinValue:N0} to {Int128.MaxValue:N0}.");
    } 
    
  2. Numbers.csproj中,添加一个元素以启用不安全代码,如下所示突出显示的标记:

    <PropertyGroup>
      <OutputType>Exe</OutputType>
      <TargetFramework>net9.0</TargetFramework>
      <ImplicitUsings>enable</ImplicitUsings>
      <Nullable>enable</Nullable>
     **<AllowUnsafeBlocks>True</AllowUnsafeBlocks>**
    </PropertyGroup> 
    
  3. 运行Numbers项目,并注意两种新数字类型的大小,如下所示输出:

    Half uses 2 bytes and can store numbers in the range -65,504 to 65,504.
    Int128 uses 16 bytes and can store numbers in the range -170,141,183,460,
    469,231,731,687,303,715,884,105,728 to 170,141,183,460,469,231,731,687,
    303,715,884,105,727. 
    

    更多信息sizeof运算符需要非安全代码块,除了常用的类型如intbyte之外。你可以在以下链接了解更多关于sizeof的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/sizeof。不安全代码无法验证其安全性。你可以在以下链接了解更多关于不安全代码块的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/unsafe-code

存储布尔值

布尔值只能包含两个文字值之一truefalse,如下所示代码:

bool happy = true;
bool sad = false; 

他们最常用于分支和循环。你目前不需要完全理解它们,因为它们在第三章控制流程、转换类型和处理异常中介绍得更为详细。

存储任何类型的对象

有一种特殊类型名为object,可以存储任何类型的数据,但它的灵活性是以代码更混乱和可能性能较差为代价的。出于这两个原因,你应该尽可能避免使用它。以下步骤展示了如果你需要使用对象类型时如何使用它们,因为你必须使用使用它们的 Microsoft 或第三方库:

  1. 使用你喜欢的代码编辑器,将名为 Variables 的新 控制台应用程序 / console 项目添加到 Chapter02 解决方案中。

  2. Program.cs 中,删除现有的语句,然后输入语句来声明和使用一些使用 object 类型的变量,如下所示:

    object height = 1.88; // Storing a double in an object.
    object name = "Amir"; // Storing a string in an object.
    Console.WriteLine($"{name} is {height} metres tall.");
    int length1 = name.Length; // This gives a compile error!
    int length2 = ((string)name).Length; // Cast name to a string.
    Console.WriteLine($"{name} has {length2} characters."); 
    
  3. 运行代码并注意第四个语句无法编译,因为编译器不知道 name 变量的数据类型,如下 图 2.8 所示:

图 2.8:object 类型没有 Length 属性

  1. 在无法编译的语句开头添加双斜杠来注释掉该语句,使其失效。

  2. 再次运行代码,并注意编译器可以访问 stringlength 属性,前提是程序员明确告诉编译器 object 变量包含一个 string,通过在 name 变量前加上类型转换表达式如 (string)name。结果可以成功写入控制台,如下所示:

    Amir is 1.88 meters tall.
    Amir has 4 characters. 
    

你将在 第三章控制流程、类型转换和异常处理 中学习关于类型转换表达式的内容。

object 类型自 C# 的第一个版本以来就可用,但 C# 2 及以后的版本有一个更好的替代品,称为 泛型,我们将在 第六章实现接口和继承类 中介绍。这将为我们提供所需的灵活性,但不会带来性能开销。

良好实践:避免将变量声明为 System.Object,即 object 类型。通常有更好的替代方案可以使用,比如泛型。

存储动态类型

还有一个名为 dynamic 的特殊类型,也可以存储任何类型的数据,但比 object 更灵活,其灵活性是以性能为代价的。dynamic 关键字是在 C# 4 中引入的。然而,与 object 不同,存储在变量中的值可以调用其成员,而无需显式转换。让我们使用一个 dynamic 类型:

  1. 添加语句来声明一个 dynamic 变量。分配一个 string 文字值,然后是一个整数值,然后是一个整数值数组。最后,添加一个语句来输出 dynamic 变量的长度,如下所示:

    dynamic something;
    // Storing an array of int values in a dynamic object.
    // An array of any type has a Length property.
    something = new[] { 3, 5, 7 };
    // Storing an int in a dynamic object.
    // int does not have a Length property.
    something = 12;
    // Storing a string in a dynamic object.
    // string has a Length property.
    something = "Ahmed";
    // This compiles but might throw an exception at run-time.
    Console.WriteLine($"The length of something is {something.Length}");
    // Output the type of the something variable.
    Console.WriteLine($"something is a {something.GetType()}"); 
    

你将在 第三章控制流程、类型转换和异常处理 中学习关于数组的内容。

  1. 运行代码并注意它之所以能正常工作,是因为最后分配给 something 的值是一个具有 Length 属性的 string 值,如下所示:

    The length of something is 5
    something is a System.String 
    
  2. 通过在语句前加上两个斜杠 // 来注释掉将 string 值赋给 something 变量的语句。

  3. 运行代码并注意运行时错误,因为分配给 something 的最后一个值是一个没有 Length 属性的 int,如下所示:

    Unhandled exception. Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: 'int' does not contain a definition for 'Length' 
    
  4. 注释掉将 int 赋给 something 变量的语句。

  5. 运行代码并注意输出,因为包含三个 int 值的数组确实有一个 Length 属性,如下面的输出所示:

    The length of something is 3
    something is a System.Int32[] 
    

dynamic 的一个限制是代码编辑器无法显示 IntelliSense 来帮助你编写代码。这是因为编译器在构建时无法检查类型。相反,CLR (公共语言运行时) 在运行时检查成员,如果成员缺失则抛出异常。

异常是表示在运行时出现错误的一种方式。你将在第三章,控制流程、转换类型和处理异常中了解更多关于它们以及如何处理它们的信息。

动态类型在与非 .NET 系统交互时最有用。例如,你可能需要与用 F#、Python 或某些 JavaScript 编写的类库一起工作。你也可能需要与像 组件对象模型 (COM) 这样的技术进行交互,例如在自动化 Excel 或 Word 时。

声明局部变量

局部变量是在方法内部声明的,并且它们只在该方法的执行期间存在。一旦方法返回,分配给任何局部变量的内存就会被释放。

严格来说,值类型在释放时会被释放,而引用类型必须等待垃圾回收。你将在第六章,实现接口和继承类中学习值类型和引用类型之间的区别以及如何确保在释放非托管资源时只需要一次垃圾回收而不是两次。

指定局部变量的类型

让我们探索使用特定类型声明的局部变量和使用类型推断的情况。

使用特定类型声明并分配值给一些局部变量的类型语句,如下面的代码所示:

int population = 67_000_000; // 67 million in UK.
double weight = 1.88; // in kilograms.
decimal price = 4.99M; // in pounds sterling.
string fruit = "Apples"; // string values use double-quotes.
char letter = 'Z'; // char values use single-quotes.
bool happy = true; // Booleans can only be true or false. 

根据你的代码编辑器和配色方案,它将在每个变量名下方显示绿色波浪线,并淡化它们的文本颜色以警告你该变量已被分配但其值从未被使用。

推断局部变量的类型

你可以使用 var 关键字从 C# 3 及以后的版本声明局部变量。编译器将从赋值运算符 = 后分配的值推断类型。这发生在编译时,因此使用 var 对运行时性能没有影响。

没有小数点的文字数字被推断为 int 变量,即除非你添加了后缀,如下面的列表中所述:

  • L: 编译器推断为 long

  • UL: 编译器推断为 ulong

  • M: 编译器推断为 decimal

  • D: 编译器推断为 double

  • F: 编译器推断为 float

带有小数点的文字数字被推断为 double 类型,除非你添加了 M 后缀(在这种情况下编译器推断为 decimal 变量),或者 F 后缀,在这种情况下它推断为 float 变量。

双引号表示 string 变量,单引号表示 char 变量,而 truefalse 值推断为 bool 类型:

  1. 将前面的语句修改为使用 var,如下面的代码所示:

    var population = 67_000_000; // 67 million in UK.
    var weight = 1.88; // in kilograms.
    var price = 4.99M; // in pounds sterling.
    var fruit = "Apples"; // string values use double-quotes.
    var letter = 'Z'; // char values use single-quotes.
    var happy = true; // Booleans can only be true or false. 
    
  2. 将鼠标悬停在每个 var 关键字上,并注意你的代码编辑器会显示一个包含推断出的类型信息的工具提示。

  3. Program.cs 的顶部,导入用于处理 XML 的命名空间,以便我们能够使用该命名空间中的类型声明一些变量,如下面的代码所示:

    using System.Xml; // To use XmlDocument. 
    
  4. Program.cs 的底部,添加创建一些新对象的语句,如下面的代码所示:

    // Good use of var because it avoids the repeated type
    // as shown in the more verbose second statement.
    var xml1 = new XmlDocument(); // Works with C# 3 and later.
    XmlDocument xml2 = new XmlDocument(); // Works with all C# versions.
    // Bad use of var because we cannot tell the type, so we
    // should use a specific type declaration as shown in
    // the second statement.
    var file1 = File.CreateText("something1.txt");
    StreamWriter file2 = File.CreateText("something2.txt"); 
    

良好实践:虽然使用 var 很方便,但一些开发者为了避免使代码读者更难理解正在使用的类型,会避免使用它。我个人只在类型明显时才使用它。例如,在先前的代码语句中,第一条语句在说明 xml 变量的类型方面与第二条语句一样清晰,但更简洁。然而,第三条语句在显示 file 变量的类型方面并不清晰,所以第四条更好,因为它显示了类型是 StreamWriter。如果有疑问,就明确写出!

注意,当你构建项目时,编译器会将 var 转换为实际类型。使用 var 声明的变量具有特定、已知、固定的数据类型。这与 dynamic 不同,编译器不会改变它。它保持为 System.Dynamic 类型,可以引用任何数据类型的任何对象。实际类型仅在运行时检查,因此,如果你访问它不存在成员时可能会抛出异常。

您可以使用 Visual Studio 的重构功能自动将 var 的使用转换为特定类型,具体请参阅以下链接:learn.microsoft.com/en-us/visualstudio/ide/reference/convert-var-to-explicit-type

new 是做什么的?

在本节之前,已经有一些使用 C# new 关键字的例子,但到目前为止,我还没有明确解释它做什么。C# 的 new 关键字用于分配和/或初始化内存。要了解何时需要使用 new,你需要对类型了解更多。

值类型和引用类型及其与内存的关系在 第六章,实现接口和继承类 中有更详细的解释,所以现在我只介绍最基本的内容。

类型分为两类:值类型引用类型

值类型很简单,不需要使用 new 关键字来显式分配内存。但值类型可以使用 new 关键字来初始化它们的值。这在无法使用字面量设置值时很有用。

引用类型更复杂,需要使用 new 关键字来显式分配内存。同时,它们可以使用 new 关键字来初始化它们的状态。

例如,当你声明变量时,只有值类型如 intDateTime 在内存中分配空间,而引用类型如 Person 则不会。

考虑以下声明一些局部变量的代码:

/* Value types have memory allocated on the stack automatically. */
short age; // Allocates 2 bytes of memory on the stack to store a System.Int16 value.
long population; // Allocates 8 bytes of memory on the stack to store a System.Int64 value.
DateTime birthdate; // Allocates 8 bytes of memory on the stack to store a System.DateTime value.
Point location; // Allocate 8 bytes of memory on the stack to store a System.Drawing.Point value.
/* Reference types will only have memory allocated on the heap when new is used (but they automatically have some memory allocated on the stack to store information about themselves including the memory address of where they are on the heap). */
Person bob; // Allocates memory in the stack that can point to a Person object in the heap. Initially, bob will have the value null. 

注意以下关于前面代码的说明:

  • age 的值为 0,并且在栈内存中已分配了 2 个字节的内存。

  • population 的值为 0,并且在栈内存中已分配了 8 个字节的内存。

  • birthdate 的值为 0001-01-01,并且在栈内存中已分配了 8 个字节的内存。

  • location 的值为 0, 0,并且在栈内存中已分配了 8 个字节的内存。

  • bob 的值为 null,并且在栈内存中已分配了 4 个字节的内存。没有为该对象分配堆内存。

现在我们来看看何时可能选择使用 new

age = 45; // Initialize this variable to 45 using a literal value.
population = 68_000_000; // Initialize this variable to 68 million using a literal value.
birthdate = new(1995, 2, 23); // Initialize this variable to February 23, 1995\. C# does not support literal values for date/time values so we must use new.
location = new(10, 20); // Initialize the X and Y coordinates of this value type.
bob = new(); // Allocate memory on the heap to store a Person. Any state will have default values. bob is no longer null.
bob = new("Bob", "Smith", 45); // Allocate memory on the heap to store a Person and initialize state. bob is no longer null.
// Older syntax with explicit types
birthdate = new DateTime(1995, 2, 23); // Initialize this variable to February 23, 1995.
location = new Point(10, 20); // Initialize the X and Y coordinates of this value type.
bob = new Person(); // Allocate memory on the heap to store a Person. bob is no longer null.
bob = new Person("Bob", "Smith", 45); // Allocate memory on the heap to store a Person and initialize state. bob is no longer null. 

注意以下关于前面代码的说明:

  • agepopulationbirthdatelocation 已经在栈上为它们分配了内存。如果我们想要它们的值与默认值不同,我们只需要使用 new 来初始化它们的值。

  • bob 必须使用 new 来为对象分配堆内存。= 赋值操作将分配的内存地址存储在栈上。引用类型如 Person 通常有多个构造函数,它们通过 new 被调用。默认构造函数将默认值分配给对象中的任何状态。带有参数的构造函数可以将其他值分配给对象中的任何状态。

构造函数在 第五章,使用面向对象编程构建自己的类型 中有更详细的介绍,所以我现在只介绍了最基本的概念。

使用目标类型 new 实例化对象

使用 C# 9,Microsoft 引入了一种新的实例化对象的语法,称为 目标类型 new。在实例化对象时,你可以首先指定类型,然后使用 new 而不必重复类型,如下面的代码所示:

XmlDocument xml3 = new(); // Target-typed new in C# 9 or later. 

如果你有一个需要设置字段或属性的类型,那么类型可以被推断,如下面的代码所示:

// In Program.cs.
Person kim = new();
kim.BirthDate = new(1967, 12, 26); // i.e. new DateTime(1967, 12, 26)
// In a separate Person.cs file or at the bottom of Program.cs.
class Person
{
  public DateTime BirthDate;
} 

这种实例化对象的方式在数组集合中特别有用,因为它们有多个对象,通常是同一类型,如下面的代码所示:

List<Person> people = new() // Instead of: new List<Person>()
{
  new() { FirstName = "Alice" }, // Instead of: new Person() { ... }
  new() { FirstName = "Bob" },
  new() { FirstName = "Charlie" }
}; 

你将在 第三章控制流程、转换类型和处理异常 中学习数组,以及在 第八章使用常见的 .NET 类型 中学习集合。

良好实践:使用目标类型的新实例化对象,因为它需要的字符更少;当从左到右阅读一个语句,就像英语一样,你立即就知道变量的类型,而且它不仅限于像 var 这样的局部变量,在我看来,不使用目标类型的新实例化的唯一原因是你必须使用版本 9 之前的 C# 编译器。我承认我的观点并不被整个 C# 社区接受。我在本书的其余部分都使用了目标类型的新实例化。如果您发现我遗漏了任何情况,请告诉我!Visual Studio 有一个重构功能,可以用来使用目标类型的新实例化,具体请参阅以下链接:learn.microsoft.com/en-us/visualstudio/ide/reference/use-new

获取和设置类型的默认值

除了 string 之外的大多数原始类型都是 值类型,这意味着它们必须有一个值。你可以通过使用 default() 运算符并将类型作为参数传递来确定类型的默认值。你可以使用 default 关键字来分配类型的默认值。

string 类型是一个 引用类型。这意味着 string 变量包含一个值的内存地址,而不是值本身。引用类型变量可以有一个 null 值,这是一个表示变量尚未引用任何内容的字面量。null 是所有引用类型的默认值。

你将在 第六章实现接口和继承类 中了解更多关于值类型和引用类型的内容。

让我们探索默认值:

  1. 添加语句以显示 intboolDateTimestring 的默认值,如下所示代码:

    Console.WriteLine($"default(int) = {default(int)}");
    Console.WriteLine($"default(bool) = {default(bool)}");
    Console.WriteLine($"default(DateTime) = {
      default(DateTime)}");
    Console.WriteLine($"default(string) = {
      default(string) ?? "<NULL>"}"); 
    

?? 运算符的意思是,如果为 null,则返回以下值。所以如果 default(string)null,则返回的文本将是 <NULL>

  1. 运行代码并查看结果。请注意,如果你的输出日期和时间格式不同,那是因为日期和时间值是使用你电脑的当前文化格式化的,如下所示输出:

    default(int) = 0
    default(bool) = False
    default(DateTime) = 01/01/0001 00:00:00
    default(string) = <NULL> 
    
  2. 添加语句以声明一个数字,分配一个值,然后将其重置为其默认值,如下所示代码:

    int number = 13;
    Console.WriteLine($"number set to: {number}");
    number = default;
    Console.WriteLine($"number reset to its default: {number}"); 
    
  3. 运行代码并查看结果,如下所示输出:

    number set to: 13
    number reset to its default: 0 
    

探索更多关于控制台应用程序的内容

我们已经创建并使用了一些基本的控制台应用程序,但现在我们应该更深入地研究它们。

控制台应用程序是基于文本的,在命令提示符下运行。它们通常执行需要脚本的简单任务,例如编译文件或加密配置文件的一部分。

同样,它们也可以传递参数来控制其行为。

例如,可以创建一个新的控制台应用程序,使用 F# 语言并指定名称,而不是使用当前文件夹的名称,如下所示命令:

dotnet new console -lang "F#" --name "ExploringConsole" 

向用户显示输出

控制台应用程序执行的最常见的两个任务是写入和读取数据。我们已经使用了 WriteLine 方法来输出,但如果我们不想在行尾添加换行符,例如,如果我们稍后想在行尾继续写入更多文本,我们可以使用 Write 方法。

如果你想在控制台写入三个字母而不在其后添加换行符,那么请调用 Write 方法,如下面的代码所示:

Console.Write("A");
Console.Write("B");
Console.Write("C"); 

这将在单行上写入三个字符,并将光标留在行尾,如下面的输出所示:

ABC 

如果你想在控制台写入三个字母并在其后添加换行符,那么请调用 WriteLine 方法,如下面的代码所示:

Console.WriteLine("A");
Console.WriteLine("B");
Console.WriteLine("C"); 

这将写入三行,并将光标留在第四行:

A
B
C 

使用编号位置参数进行格式化

生成格式化字符串的一种方法是通过使用编号位置参数。

该功能由 WriteWriteLine 等方法支持。对于不支持该功能的方法,可以使用 string 参数的 Format 方法进行格式化。

让我们开始格式化:

  1. 使用你喜欢的代码编辑器向 Chapter02 解决方案添加一个名为 Formatting 的新 Console App / console 项目。

  2. Program.cs 文件中,删除现有的语句,然后输入语句来声明一些数字变量并将它们写入控制台,如下面的代码所示:

    using System.Globalization; // To use CultureInfo.
    // Set current culture to US English so that all readers
    // see the same output as shown in the book.
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
    int numberOfApples = 12;
    decimal pricePerApple = 0.35M;
    Console.WriteLine(
      format: "{0} apples cost {1:C}",
      arg0: numberOfApples,
      arg1: pricePerApple * numberOfApples);
    string formatted = string.Format(
      format: "{0} apples cost {1:C}",
      arg0: numberOfApples,
      arg1: pricePerApple * numberOfApples);
    //WriteToFile(formatted); // Writes the string into a file. 
    

    警告! 我们使用了 CultureInfo 类来强制文化设置为美国英语,以便对所有读者来说货币将是美元。在 第四章,编写、调试和测试函数 中,你将学习如何编写一个函数来控制文化,以便他们可以默认看到美国英语,或者覆盖它以显示你本地计算机的文化或指定的文化。

WriteWriteLineFormat 方法最多可以有三个编号参数,分别命名为 arg0arg1arg2。如果你需要传递超过三个值,那么你无法命名它们。WriteToFile 方法是一个不存在的用于说明该想法的方法。

  1. Program.cs 中,输入语句来写入三个和五个参数到控制台,如下面的代码所示:

    // Three parameter values can use named arguments.
    Console.WriteLine("{0} {1} lived in {2}.",
      arg0: "Roger", arg1: "Cevung", arg2: "Stockholm");
    // Four or more parameter values cannot use named arguments.
    Console.WriteLine(
      "{0} {1} lived in {2} and worked in the {3} team at {4}.",
      "Roger", "Cevung", "Stockholm", "Education", "Optimizely"); 
    

良好实践:一旦你更熟悉格式化字符串,你应该停止命名参数,例如,停止使用 format:, arg0:, 和 arg1:。前面的代码使用非规范化的风格来展示在学习过程中 01 的来源。

Rider 及其关于装箱的警告

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

使用插值字符串进行格式化

C# 6 及以后版本有一个方便的功能,称为插值字符串。以$为前缀的字符串可以使用大括号包围变量或表达式的名称来输出该变量或表达式在该位置在字符串中的当前值,如下所示:

  1. Program.cs文件的底部输入一条语句,如下面的代码所示:

    // The following statement must be all on one line when using C# 10
    // or earlier. If using C# 11 or later, we can include a line break
    // in the middle of an expression but not in the string text.
    Console.WriteLine($"{numberOfApples} apples cost {pricePerApple
      * numberOfApples:C}"); 
    
  2. 运行代码并查看结果,如下面的部分输出所示:

    12 apples cost $4.20 
    

简而言之,对于短且格式化的字符串值,插值字符串可能更容易阅读。但对于书中需要跨多行包裹的代码示例,这可能会变得复杂。在这本书的许多代码示例中,我将使用编号的位置参数。避免插值字符串的另一个原因是它们不能从资源文件中读取以进行本地化。

以下代码示例不打算在您的项目中输入。

在 C# 10 之前,字符串常量只能通过使用+运算符进行连接来组合,如下面的代码所示:

private const string firstname = "Omar";
private const string lastname = "Rudberg";
private const string fullname = firstname + " " + lastname; 

在 C# 10 中,插值字符串(以$为前缀)现在可以使用了,如下面的代码所示:

private const string fullname = $"{firstname} {lastname}"; 

这仅适用于组合字符串常量值。它不能与其他类型(如数字)一起使用,这需要运行时数据类型转换。你无法在顶级程序(如Program.cs)中输入private const声明。你将在第五章使用面向对象编程构建自己的类型中看到如何使用它们。

良好实践:如果你正在编写的代码将成为 Unity 项目的一部分,那么插值字符串格式是一种避免装箱的简单方法。

理解格式字符串

变量或表达式可以使用格式字符串在逗号或冒号之后进行格式化。

N0格式字符串表示带有千位分隔符且没有小数位的数字,而C格式字符串表示货币。货币格式将由当前线程决定。

例如,如果你在英国的 PC 上运行使用数字或货币格式的代码,你会得到带有逗号作为千位分隔符的英镑,但如果你在德国的 PC 上运行它,你会得到带有点作为千位分隔符的欧元。

格式项的完整语法是:

{ index [, alignment ] [ : formatString ] } 

每个格式项都可以有一个对齐方式,这在输出值表时很有用,其中一些值可能需要在字符宽度内左对齐或右对齐。对齐值是整数。正整数表示右对齐,负整数表示左对齐。

例如,要输出一个水果及其数量的表格,我们可能希望将名称左对齐在 10 个字符宽的列中,并将计数格式化为没有小数位的数字,右对齐在 6 个字符宽的列中:

  1. Program.cs 的底部输入以下语句:

    string applesText = "Apples";
    int applesCount = 1234;
    string bananasText = "Bananas";
    int bananasCount = 56789;
    Console.WriteLine();
    Console.WriteLine(format: "{0,-10} {1,6}",
      arg0: "Name", arg1: "Count");
    Console.WriteLine(format: "{0,-10} {1,6:N0}",
      arg0: applesText, arg1: applesCount);
    Console.WriteLine(format: "{0,-10} {1,6:N0}",
      arg0: bananasText, arg1: bananasCount); 
    
  2. 运行代码并注意对齐和数字格式的影响,如下面的输出所示:

    Name          Count
    Apples        1,234
    Bananas      56,789 
    

自定义数字格式

您可以使用自定义格式代码完全控制数字格式,如 表 2.7 所示:

格式代码 描述
0 零占位符。如果存在,则用相应的数字替换零;否则,使用零。例如,0000.00 格式化值 123.4 将给出 0123.40
# 数字占位符。如果存在,则用相应的数字替换井号;否则,不使用任何内容。例如,####.## 格式化值 123.4 将给出 123.4
. 小数点。设置数字中小数点的位置。尊重文化格式,因此在美式英语中是 .(点),在法语中是 ,(逗号)。
, 分组分隔符。在每个组之间插入本地化的分组分隔符。例如,0,000 格式化值 1234567 将给出 1,234,567。还用于通过每个逗号除以 1,000 的倍数来缩放数字。例如,0.00,, 格式化值 1234567 将给出 1.23,因为两个逗号意味着除以 1,000 两次。
% 百分比占位符。将值乘以 100 并添加一个百分比符号。
\ 转义字符。使下一个字符成为字面值而不是格式代码。例如,\##,###\# 格式化值 1234 将给出 #1,234#
; 部分分隔符。为正数、负数和零定义不同的格式字符串。例如,[0];(0);Zero 格式化:13 将给出 [13]-13 将给出 (13),而 0 将给出 Zero
其他 所有其他字符在输出中按原样显示。

表 2.7:自定义数字格式代码

更多信息:完整的自定义数字格式代码列表可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings.

您可以使用更简单的格式代码应用标准数字格式,例如 CN。它们支持一个精度数字来指示您想要多少位精度。默认值为两位。最常见的一些格式代码在 表 2.8 中展示:

格式代码 描述
Cc 货币。例如,在美国文化中,C 格式化值 123.4 给出 $123.40,而 C0 格式化值 123.4 给出 $123
Nn 数字。整数数字,可选带负号和分组字符。
Dd 小数。整数数字,可选带负号但没有分组字符。
Bb 二进制。例如,B 格式化值 13 给出 1101,而 B8 格式化值 13 给出 00001101
Xx 十六进制。例如,X 格式化值 255 给出 FF,而 X4 格式化值 255 给出 00FF
Ee 指数表示法。例如,E 格式化值 1234.567 将给出 1.234567000E+003,而 E2 格式化值 1234.567 将给出 1.23E+003

表 2.8:标准数字格式代码

更多信息:标准数字格式代码的完整列表可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/standard/base-types/standard-numeric-format-strings.

你可以使用自定义格式代码完全控制日期和时间格式化,如下表 2.9 所示:

格式代码 描述
/ 日期部分分隔符。因文化而异;例如,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 使用从 112 的 12 小时制的小时,或从 0112
H, HH 小时,使用从 023 的 24 小时制,或从 0123
K 时区信息。null 表示未指定时区,Z 表示 UTC,以及从 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 年。本书第一版出版于 2016 年。
yyyy, yyyyy 四位或五位数的年份。
z, zz UTC 偏移的小时,无前导零,或带前导零。
zzz UTC 偏移的小时和分钟,带前导零,例如,+04:30

表 2.9:日期和时间值的自定义格式代码

更多信息:完整的自定义格式代码列表可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings

您可以使用更简单的格式代码来应用标准的日期和时间格式,例如我们在代码示例中使用的 dD,如下表 2.10 所示:

格式代码 描述
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)。因文化而异。
m, M 月份/日期模式。因文化而异。
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

表 2.10:日期和时间值的标准格式代码

更多信息:完整的格式代码列表可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings

警告!注意 "m"(月份/日期格式的缩写,如美国的 7 月 4 日或英国的 4 July,省略年份并以文化特定方式呈现月份和日期)与 "d m"(自定义格式字符串,显示日期编号,然后是月份编号,中间有空格,如任何国家的 4 7 表示 7 月 4 日)之间的区别。

让我们看看如何使用多种格式输出值:

  1. 输入语句以输出两次十进制值,首先使用当前文化作为货币,然后作为百分比,如下面的代码所示:

    decimal value = 0.325M;
    WriteLine("Currency: {0:C}, Percentage: {0:0.0%}", value); 
    
  2. 运行代码并注意结果,如下面的输出所示:

    Currency: $0.33, Percentage: 32.5% 
    

从用户获取文本输入

我们可以使用ReadLine方法从用户获取文本输入。此方法等待用户输入一些文本。然后,当用户按下Enter时,用户输入的内容将作为string值返回。

让我们获取用户的输入:

  1. 输入语句以询问用户他们的姓名和年龄,然后输出他们输入的内容,如下面的代码所示:

    Console.Write("Type your first name and press ENTER: ");
    string firstName = Console.ReadLine();
    Console.Write("Type your age and press ENTER: ");
    string age = Console.ReadLine();
    Console.WriteLine($"Hello {firstName}, you look good for {age}."); 
    

默认情况下,在.NET 6 及更高版本中,启用空值检查,因此 C#编译器会给出两个警告,因为ReadLine方法可能会返回一个null值而不是string值。但实际上,这种方法返回null的场景并不实用,所以我们将在这个场景中了解如何关闭这些特定的警告。

  1. 对于firstName变量,在string后面添加一个?,如下面的代码所示:

    string? firstName = Console.ReadLine(); 
    

    这告诉编译器我们期望一个可能的null值,因此它不需要警告我们。如果变量是null,那么当它稍后通过WriteLine输出时,它将只是一个空白,所以在这种情况下是可行的。如果我们打算访问firstName变量的任何成员,那么我们就需要处理它可能是null的情况。

  2. 对于age变量,在语句末尾的分号前添加一个!,如下面的代码所示:

    string age = Console.ReadLine()!; 
    

这被称为null-forgiving operator,因为它告诉编译器在这种情况下ReadLine不会返回null,因此它可以停止显示警告。现在,我们负责确保这一点。幸运的是,Console类型的ReadLine实现总是返回一个string,即使它只是一个空的string值。

  1. 运行代码,然后输入一个姓名和年龄,如下面的输出所示:

    Type your name and press ENTER: Gary
    Type your age and press ENTER: 34
    Hello Gary, you look good for 34. 
    

您现在已经看到了处理编译器空值警告的两种常见方法。我们将在第六章实现接口和继承类中更详细地介绍空值和如何处理它。

ReadLine何时返回null

ReadLine方法从标准输入流中读取下一行输入。如果用户按下Enter而没有输入任何内容,ReadLine将返回一个空字符串,而不是null

在典型的控制台应用程序中,ReadLine仅在达到流末尾EOF)时返回null。这通常不是在标准控制台环境中通过用户输入实现的,因为 EOF 通常由控制台关闭或重定向输入被完全消耗来表示。

在以下代码的上下文中:

string? name = ReadLine(); // Prompt user to enter their name. 

这里是name可能为null的条件:

  • 如果标准输入流被重定向并达到 EOF

  • 如果您在一个可以模拟 EOF 的环境中进行测试,例如某些开发环境或自动化测试设置

然而,在正常用户输入条件下,ReadLine 方法“永远不会”返回 null

简化控制台的使用

在 C# 6 及更高版本中,using 语句不仅可以用于导入命名空间,还可以通过导入静态类进一步简化我们的代码。然后,我们就不需要在代码中输入 Console 类型名称。

为单个文件导入静态类型

您可以使用代码编辑器的 查找和替换 功能来删除我们之前写过的 Console

  1. Program.cs 文件顶部添加一个语句以 静态导入 System.Console 类,如下面的代码所示:

    using static System.Console; 
    
  2. 在您的代码中选择第一个 Console.,确保您也选择了 Console 单词后面的点。

  3. 在 Visual Studio 中,导航到 编辑 | 查找和替换 | 快速替换;在 VS Code 中,导航到 编辑 | 替换;或者在 Rider 中,导航到 编辑 | 查找 | 替换,并注意会出现一个覆盖对话框,供您输入要替换 Console. 的内容,如图 2.9 所示:

图 2.9:使用 Visual Studio 中的替换功能简化代码

  1. 保持 替换 框为空,点击 替换所有 按钮(位于 替换 框右侧的两个按钮中的第二个),然后通过点击右上角的叉号关闭 替换 框。

  2. 运行控制台应用程序并注意行为与之前相同。

为项目中的所有代码文件导入静态类型

而不是只为单个代码文件静态导入 Console 类,可能更好的做法是为项目中的所有代码文件全局导入:

  1. 删除静态导入 System.Console 的语句。

  2. 打开 Formatting.csproj 文件,在 <PropertyGroup> 部分之后,添加一个新的 <ItemGroup> 部分以全局和静态地导入 System.Console,使用隐式的 .NET SDK 功能,如下面的标记所示:

    <ItemGroup Label="Simplify console app">
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    

良好实践:在 .NET 项目中的 <ItemGroup> 内部的 Label 属性是可选的但非常有用,主要原因围绕组织、定制和构建过程中的清晰度。当使用多个 <ItemGroup> 部分时,标签提供了对每个组包含内容的明确指示。使用标签,您可以根据构建配置或其他条件有条件地包含或排除某些项目。在这种情况下,我们只是用它来编写文档。

  1. 运行控制台应用程序并注意行为与之前相同。

良好实践:将来,对于您为本书创建的所有控制台应用程序项目,添加上述部分以简化所有 C# 文件中与 Console 类一起工作所需编写的代码。

从用户获取键输入

我们可以使用ReadKey方法从用户那里获取键输入。此方法等待用户按下键或键组合,然后将其作为ConsoleKeyInfo值返回。

让我们来探索如何读取按键:

  1. 输入语句以提示用户按下任何键组合,然后输出有关该组合的信息,如下面的代码所示:

    Write("Press any key combination: ");
    ConsoleKeyInfo key = ReadKey();
    WriteLine();
    WriteLine("Key: {0}, Char: {1}, Modifiers: {2}",
      arg0: key.Key, arg1: key.KeyChar, arg2: key.Modifiers); 
    
  2. 运行代码,按下K键,注意结果,如下面的输出所示:

    Press any key combination: k
    Key: K, Char: k, Modifiers: 0 
    
  3. 运行代码,按住Shift键并按下K键,注意结果,如下面的输出所示:

    Press any key combination: K
    Key: K, Char: K, Modifiers: Shift 
    
  4. 运行代码,按下F12键,注意结果,如下面的输出所示:

    Press any key combination:
    Key: F12, Char: , Modifiers: 0 
    

警告! 当在 VS Code 中的终端内运行控制台应用程序时,一些键盘组合会在它们被你的控制台应用程序处理之前被代码编辑器捕获。例如,在 VS Code 中Ctrl + Shift + X组合会激活侧边栏中的扩展视图。要完全测试此控制台应用程序,请在项目文件夹中打开命令提示符或终端,并从那里运行控制台应用程序。

将参数传递给控制台应用程序

当你运行控制台应用程序时,你通常希望通过传递参数来改变其行为。例如,使用dotnet命令行工具,你可以传递新项目模板的名称,如下面的命令所示:

dotnet new console
dotnet new mvc 

你可能想知道如何获取传递给控制台应用程序的任何参数。

在.NET 6 之前的每个版本中,控制台应用程序项目模板都使其非常明显,如下面的代码所示:

using System;
namespace Arguments
{
  class Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Hello World!");
    }
  }
} 

string[] args参数在Program类的Main方法中声明并传递。它们是一个用于将参数传递到控制台应用程序的数组。但在顶级程序中,如.NET 6 及以后的.NET 控制台应用程序项目模板所使用的,Program类及其Main方法以及args数组的声明都是隐藏的。诀窍是您必须知道它仍然存在。

命令行参数由空格分隔。其他字符,如连字符和冒号,被视为参数值的一部分。

要在参数值中包含空格,请将参数值用单引号或双引号括起来。

假设我们希望在命令行中输入一些颜色的名称,用于前景和背景,以及终端窗口的尺寸。我们可以通过从args数组中读取它们来读取颜色和数字,该数组总是传递给Main方法,也就是控制台应用程序的入口点:

  1. 使用您首选的代码编辑器,向Chapter02解决方案中添加一个名为Arguments的新Console App / console项目。

  2. 打开Arguments.csproj,在<PropertyGroup>部分之后,添加一个新的<ItemGroup>部分,使用.NET SDK 的隐式使用功能静态导入所有 C#文件中的System.Console,如下面的标记所示:

    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    

良好实践:请记住使用隐式使用 .NET SDK 功能,在所有未来的控制台应用程序项目中静态导入 System.Console 类型,以简化你的代码,因为这些说明不会每次都重复。

  1. Program.cs 文件中,删除现有的语句,然后添加一个语句来输出传递给应用程序的参数数量,如下面的代码所示:

    WriteLine($"There are {args.Length} arguments."); 
    
  2. 运行控制台应用程序并查看结果,如下面的输出所示:

    There are 0 arguments. 
    

如果你正在使用 Visual Studio:

  1. 导航到 项目 | 参数 属性

  2. 选择 调试 选项卡,点击 打开调试启动配置文件 UI,在 命令行参数 框中,输入以下参数:firstarg second-arg third:arg "fourth arg",如图 2.10 所示:

图片

图 2.10:在 Visual Studio 项目属性中输入命令行参数

  1. 关闭 启动配置文件 窗口。

  2. 解决方案资源管理器 中,在 Properties 文件夹中,打开 launchSettings.json 文件并注意它定义了当你运行项目时的命令行参数,如图中高亮显示的以下配置所示:

    {
      "profiles": {
        "Arguments": {
          "commandName": "Project",
          **"commandLineArgs"****:** **"firstarg second-arg third:arg \"fourth arg\""**
        }
      }
    } 
    

launchSettings.json 文件也可以被 Rider 使用。VS Code 的等效文件是 .vscode/launch.json 文件。

  1. 运行控制台应用程序项目。

如果你正在使用 VS Code,那么在 终端 中,在 dotnet run 命令之后输入一些参数,如下面的命令所示:

dotnet run firstarg second-arg third:arg "fourth arg" 

对于所有代码编辑器:

  1. 注意到结果指示有四个参数,如下面的输出所示:

    There are 4 arguments. 
    
  2. Program.cs 中,为了枚举或迭代(即,遍历)这四个参数的值,在输出数组长度之后添加以下语句:

    foreach (string arg in args)
    {
      WriteLine(arg);
    } 
    
  3. 再次运行代码并注意结果显示了四个参数的详细信息,如下面的输出所示:

    There are 4 arguments.
    firstarg
    second-arg
    third:arg
    fourth arg 
    

使用参数设置选项

我们现在将使用这些参数来允许用户选择输出窗口的背景、前景和光标大小。光标大小可以是 1 到 100 的整数,其中 1 表示光标单元格底部的线条,100 表示光标单元格高度的百分比。

我们已经静态导入了 System.Console 类。它具有 ForegroundColorBackgroundColorCursorSize 等属性,我们现在可以通过使用它们的名称来设置,而不需要用 Console 前缀。

System 命名空间已经导入,以便编译器了解 ConsoleColorEnum 类型:

  • 添加语句以警告用户如果没有输入三个参数,然后解析这些参数并使用它们来设置控制台窗口的颜色和尺寸,如下面的代码所示:

    if (args.Length < 3)
    {
      WriteLine("You must specify two colors and cursor size, e.g.");
      WriteLine("dotnet run red yellow 50");
      return; // Stop running.
    }
    ForegroundColor = (ConsoleColor)Enum.Parse(
      enumType: typeof(ConsoleColor),
      value: args[0], ignoreCase: true);
    BackgroundColor = (ConsoleColor)Enum.Parse(
      enumType: typeof(ConsoleColor),
      value: args[1], ignoreCase: true);
    CursorSize = int.Parse(args[2]); 
    

注意到编译器警告,设置 CursorSize 只在 Windows 上受支持。目前,不要担心大多数此代码,如 (ConsoleColor)Enum.Parsetypeof,因为所有这些将在接下来的几章中解释。

  • 如果你使用 Visual Studio,将参数更改为red yellow 50。运行控制台应用程序并注意光标大小减半,窗口中的颜色已更改,如图 2.11所示:

图 2.11:在 Windows 上设置颜色和光标大小

  • 如果你使用 VS Code,则使用以下命令运行代码,将前景色设置为红色,背景色设置为黄色,光标大小设置为 50%:

    dotnet run red yellow 50 
    

在 macOS 或 Linux 上,你会看到一个未处理的异常,如图 2.12所示:

图 2.12:在不受支持的 macOS 上未处理的异常

尽管编译器没有给出错误或警告,但在运行时,某些 API 调用可能在某些平台上失败。尽管在 Windows 上运行的控制台应用程序可以更改其光标大小,但在 macOS 上则不行,如果尝试这样做,它会报错。

处理不支持 API 的平台

那我们如何解决这个问题呢?我们可以通过使用异常处理程序来解决。你将在第三章控制流程、转换类型和处理异常中了解更多关于try-catch语句的详细信息,所以现在只需输入代码:

  1. 修改代码,将改变光标大小的行包裹在try语句中,如下所示:

    try
    {
      CursorSize = int.Parse(args[2]);
    }
    catch (PlatformNotSupportedException)
    {
      WriteLine("The current platform does not support changing the size of the cursor.");
    } 
    
  2. 如果你在 macOS 上运行此代码,你会看到异常被捕获,并向用户显示一个更友好的消息。

处理操作系统差异的另一种方法是使用System命名空间中的OperatingSystem类,如下所示:

if (OperatingSystem.IsWindowsVersionAtLeast(major: 10))
{
  // Execute code that only works on Windows 10 or later.
}
else if (OperatingSystem.IsWindows())
{
  // Execute code that only works earlier versions of Windows.
}
else if (OperatingSystem.IsIOSVersionAtLeast(major: 14, minor: 5))
{
  // Execute code that only works on iOS 14.5 or later.
}
else if (OperatingSystem.IsBrowser())
{
  // Execute code that only works in the browser with Blazor.
} 

OperatingSystem类为其他常见操作系统提供了等效方法,如 Android、iOS、Linux、macOS,甚至浏览器,这对于 Blazor Web 组件非常有用。

处理不同平台的第三种方法是使用条件编译语句。

有四个预处理器指令控制条件编译:#if#elif#else#endif

你可以使用#define定义符号,如下所示:

#define MYSYMBOL 

已经为你自动定义了许多符号,如下所示表 2.11

目标框架 符号
.NET 标准 NETSTANDARD2_0, NETSTANDARD2_1
现代 NET NET7_0, NET7_0_ANDROID, NET7_0_IOS, NET7_0_WINDOWS

表 2.11:预定义编译器符号

你可以编写仅针对指定平台编译的语句,如下所示:

#if NET7_0_ANDROID
// Compile statements that only work on Android.
#elif NET7_0_IOS
// Compile statements that only work on iOS.
#else
// Compile statements that work everywhere else.
#endif 

练习和探索

通过回答一些问题、进行一些实际操作,以及更深入地研究本章涵盖的主题来测试你的知识和理解。

练习 2.1 – 在线材料

在线材料可以是为我这本书编写的额外内容,也可以是引用由 Microsoft 或第三方创建的内容。

理解 async 和 await

C# 5 在处理Task类型时引入了两个 C#关键字,使得多线程变得容易。你可以在以下链接中阅读有关内容并查看代码示例:

github.com/markjprice/cs13net9/blob/main/docs/ch02-async-await.md

探索 Spectre

不,不是来自詹姆斯·邦德电影的邪恶组织!Spectre是一个增强控制台应用程序的包。你可以在以下链接中了解它:https://spectreconsole.net/

练习 2.2 – 练习

练习题深入探讨了本章的主题。

探索数字的大小和范围

Chapter02解决方案中,创建一个名为Exercise_Numbers的控制台应用程序项目,该程序输出以下数字类型在内存中使用的字节数以及它们可以有的最小和最大值:sbytebyteshortushortintuintlongulongInt128UInt128Halffloatdoubledecimal

运行你的控制台应用程序的结果应该类似于图 2.13

计算机屏幕截图  自动生成的描述

图 2.13:输出数字类型大小的结果

所有练习的代码解决方案都可以从以下链接的 GitHub 仓库下载或克隆:github.com/markjprice/cs13net9/tree/main/code

练习 2.3 – 测试你的知识

为了得到一些问题的最佳答案,你需要自己进行研究。我希望你“跳出书本的思维”,所以我故意没有在书中提供所有答案。

我希望鼓励你养成寻找其他帮助的好习惯,遵循“授人以鱼不如授人以渔”的原则。

  1. 你可以在 C#文件中输入什么语句来发现编译器和语言版本?

  2. C#中有哪两种类型的注释?

  3. 什么是字面字符串和插值字符串的区别?

  4. 为什么在使用floatdouble值时要小心?

  5. 你如何确定像double这样的类型在内存中占用多少字节?

  6. 何时应该使用var关键字?

  7. 创建像XmlDocument这样的类的实例的最新语法是什么?

  8. 为什么在使用dynamic类型时要小心?

  9. 你如何右对齐格式字符串?

  10. 哪个字符分隔控制台应用程序的参数?

附录测试你的知识问题的答案,可以从 GitHub 仓库中的 README 链接下载:github.com/markjprice/cs13net9

测试你对数字类型的知识

你会选择哪种类型来表示以下“数字”?

  • 人的电话号码

  • 人的身高

  • 人的年龄

  • 人的薪水

  • 一本书的 ISBN

  • 一本书的价格

  • 一本书的运输重量

  • 一个国家的人口

  • 宇宙中的星星数量

  • 英国每个小型或中型企业的员工人数(每个企业最多约 50,000 名员工)

练习 2.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-2---speaking-c

摘要

在本章中,你学习了如何:

  • 声明具有指定或推断类型的变量。

  • 使用一些内置的数字、文本和布尔类型。

  • 选择数字类型。

  • 在控制台应用程序中控制输出格式。

在下一章中,你将学习关于运算符、分支、循环、类型转换以及如何处理异常。

第三章:控制流程、转换类型和处理异常

本章主要讲解编写对变量执行简单操作、做出决策、执行模式匹配以及重复语句或代码块的代码。你还将学习如何使用数组存储多个值,如何将变量或表达式值从一种类型转换为另一种类型,如何处理异常,以及如何在数字变量中检查溢出。

本章涵盖以下主题:

  • 操作变量

  • 理解选择语句

  • 理解迭代语句

  • 在数组中存储多个值

  • 类型转换和类型间转换

  • 处理异常

  • 检查溢出

操作变量

运算符操作数(如变量和字面值)执行简单的操作,如加法和乘法。运算符返回一个新值,它是操作的结果,可以赋给变量,并且它们也可以影响操作数。

理解二元运算符

大多数运算符是 二元 的,意味着它们作用于两个操作数,如下面的伪代码所示:

var resultOfOperation = firstOperand operator secondOperand; 

二元运算符的例子包括加法和乘法,如下面的代码所示:

int x = 5;
int y = 3;
int resultOfAdding = x + y;
int resultOfMultiplying = x * y; 

理解一元运算符

一些运算符是 一元 的,意味着它们作用于单个操作数,可以在操作数之前或之后应用,如下面的伪代码所示:

var resultOfOperationAfter = onlyOperand operator;
var resultOfOperationBefore = operator onlyOperand; 

一元运算符的例子包括增量运算符和检索类型或其字节大小,如下面的代码所示:

int x = 5;
int postfixIncrement = x++;
int prefixIncrement = ++x;
Type theTypeOfAnInteger = typeof(int);
string nameOfVariable = nameof(x);
int howManyBytesInAnInteger = sizeof(int); 

理解三元运算符

三元 运算符作用于三个操作数,如下面的伪代码所示:

var resultOfOperation = firstOperand firstOperator
  secondOperand secondOperator thirdOperand; 

三元运算符的一个例子是条件运算符 ?:,它像一个简化的 if 语句。第一个操作数是一个布尔表达式,第二个操作数是在它为 true 时返回的值,第三个操作数是在它为 false 时返回的值,如下面的代码所示:

// Syntax of conditional operator.
var result = boolean_expression ? value_if_true : value_if_false;
// Example of conditional operator.
string result = x > 3 ? "Greater than 3" : "Less than or equal to 3";
// Equivalent using an if statement.
string result;
if (x > 3)
{
  result = "Greater than 3";
}
else
{
  result = "Less than or equal to 3";
} 

更有经验的 C# 开发者尽可能多地使用三元运算符,因为它们简洁,一旦习惯了阅读,就可以使代码更清晰。

探索一元运算符

两个常用的 一元 运算符用于增加,++,和减少,--,一个数字。让我们写一些示例代码来展示它们是如何工作的:

  1. 如果你已经完成了前面的章节,那么你将已经有一个 cs13net9 文件夹。如果没有,那么你需要创建它。

  2. 使用你喜欢的代码编辑器创建一个新的解决方案和项目,如下所示列表:

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

    • 项目文件和文件夹:Operators

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

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

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

  3. Operators.csproj 文件中,添加一个新的 <ItemGroup> 部分,以静态导入 System.Console,以便所有使用 .NET SDKimplicit usings 功能的 C# 文件,如下所示:

    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    
  4. Program.cs中,删除现有的语句,然后声明两个名为ab的整数变量;将a设置为3,在赋值时递增a并将结果赋给b,然后输出它们的值,如下所示代码:

    #region Exploring unary operators
    int a = 3;
    int b = a++;
    WriteLine($"a is {a}, b is {b}");
    #endregion 
    

良好实践:我建议将每个部分的语句包裹在#region#endregion(如前述代码所示)中,这样你可以轻松地折叠这些部分。但我在未来的代码任务中不会展示这一点以节省空间。

  1. 在运行控制台应用程序之前,问问自己一个问题:你认为b的值在输出时会是多少?一旦你思考过这个问题,运行代码并将你的预测与实际结果进行比较,如下所示输出:

    a is 4, b is 3 
    

变量b的值是3,因为++运算符是在赋值之后执行的;这被称为后缀运算符。如果你需要在赋值之前递增,那么请使用前缀运算符

  1. 复制并粘贴语句,然后修改它们以重命名变量并使用前缀运算符,如下所示代码:

    int c = 3;
    int d = ++c; // Prefix means increment c before assigning it.
    WriteLine($"c is {c}, d is {d}"); 
    
  2. 重新运行代码并注意结果,如下所示输出:

    a is 4, b is 3
    c is 4, d is 4 
    

良好实践:由于增量++和减量--运算符与赋值运算符=结合使用时前缀和后缀之间的混淆,Swift 编程语言的设计者决定从版本 3 开始不支持此运算符。我对于 C#的使用建议是永远不要将++--运算符与赋值运算符=结合使用。将操作作为单独的语句执行。

探索二进制算术运算符

增量和减量是单目算术运算符。其他算术运算符通常是双目运算符,允许你对两个数字执行算术运算,如下所示:

  1. 添加语句来声明并给两个名为ef的整数变量赋值,然后应用五个常见的二进制算术运算符到这两个数字上,如下所示代码:

    int e = 11;
    int f = 3;
    WriteLine($"e is {e}, f is {f}");
    WriteLine($"e + f = {e + f}");
    WriteLine($"e - f = {e - f}");
    WriteLine($"e * f = {e * f}");
    WriteLine($"e / f = {e / f}");
    WriteLine($"e % f = {e % f}"); 
    
  2. 运行代码并注意结果,如下所示输出:

    e is 11, f is 3
    e + f = 14
    e - f = 8
    e * f = 33
    e / f = 3
    e % f = 2 
    

要理解整数除法/和取模%运算符,你需要回想起小学。想象你有十一个糖果和三个朋友。

你如何将糖果分给你的朋友们?你可以给每个朋友三个糖果,然后会剩下两个。这两个糖果就是余数,也称为除法后的余数。如果你有十二个糖果,那么每个朋友会得到四个,就不会剩下,所以余数会是零。

  1. 添加语句来声明并给一个名为gdouble变量赋值,以显示整数除法和实数除法之间的差异,如下所示代码:

    double g = 11.0;
    WriteLine($"g is {g:N1}, f is {f}");
    WriteLine($"g / f = {g / f}"); 
    
  2. 重新运行代码并注意结果,如下所示输出:

    g is 11.0, f is 3
    g / f = 3.6666666666666665 
    

如果第一个操作数是一个浮点数,例如 g 的值为 11.0,那么除法运算符返回一个浮点值,例如 3.6666666666665,而不是一个整数。

赋值运算符

你已经使用过最常见的赋值运算符 =

要使你的代码更简洁,你可以将赋值运算符与其他运算符(如算术运算符)结合使用,如下面的代码所示:

int p = 6;
p += 3; // Equivalent to: p = p + 3;
p -= 3; // Equivalent to: p = p - 3;
p *= 3; // Equivalent to: p = p * 3;
p /= 3; // Equivalent to: p = p / 3; 

空合并运算符

与赋值运算符相关的运算符是空合并运算符。有时,你想要将变量赋给结果,或者如果变量为 null,则赋给一个替代值。

你可以使用空合并运算符 ????= 来做到这一点,如下面的代码所示:

string? authorName = GetAuthorName(); // A fictional function.
// The maxLength variable will be the length of authorName if it is
// not null, or 30 if authorName is null.
int maxLength = authorName?.Length ?? 30;
// The authorName variable will be "unknown" if authorName was null.
authorName ??= "unknown"; 

探索逻辑运算符

逻辑运算符作用于布尔值,因此它们返回 truefalse。让我们探索作用于两个布尔值(在数学中传统上命名为 pq)的二进制逻辑运算符:

  1. Program.cs 中添加语句来声明两个布尔变量 pq,它们的值分别为 truefalse,然后输出应用 AND、OR 和 XOR(异或)逻辑运算符的结果的真值表,如下面的代码所示:

    bool p = true;
    bool q = false;
    WriteLine($"AND  | p     | q    ");
    WriteLine($"p    | {p & p,-5} | {p & q,-5} ");
    WriteLine($"q    | {q & p,-5} | {q & q,-5} ");
    WriteLine();
    WriteLine($"OR   | p     | q    ");
    WriteLine($"p    | {p | p,-5} | {p | q,-5} ");
    WriteLine($"q    | {q | p,-5} | {q | q,-5} ");
    WriteLine();
    WriteLine($"XOR  | p     | q    ");
    WriteLine($"p    | {p ^ p,-5} | {p ^ q,-5} ");
    WriteLine($"q    | {q ^ p,-5} | {q ^ q,-5} "); 
    

记住,,-5 表示在五个宽度列内左对齐。

  1. 运行代码并注意结果,如下面的输出所示:

    AND  | p     | q
    p    | True  | False
    q    | False | False
    OR   | p     | q
    p    | True  | True
    q    | True  | False
    XOR  | p     | q
    p    | False | True
    q    | True  | False 
    

对于 & 逻辑运算符,两个操作数都必须为 true,结果才为 true。对于 | 逻辑运算符,任一操作数可以为 true,结果才为 true。对于 ^ 逻辑运算符,任一操作数可以为 true(但不能同时为 true!),结果才为 true

探索条件逻辑运算符

条件逻辑运算符类似于逻辑运算符,但使用两个符号而不是一个,例如,使用 && 而不是 &,或使用 || 而不是 |

第四章编写、调试和测试函数 中,你将更详细地了解函数,但我现在需要介绍函数来解释条件逻辑运算符,也称为短路布尔运算符。

函数执行语句然后返回一个值。这个值可以是用于布尔操作的布尔值,例如 true。让我们利用条件逻辑运算符:

  1. Program.cs 的底部,编写语句来声明一个函数,该函数将消息写入控制台并返回 true,如下面的代码所示:

    static bool DoStuff()
    {
      WriteLine("I am doing some stuff.");
      return true;
    } 
    

局部函数可以在 Program.cs 中使用顶级程序功能的任何语句中,但良好的实践是将它们放在文件的底部。

  1. 在之前的 WriteLine 语句之后,对 pq 变量以及函数调用的结果执行 AND & 操作,如下面的代码所示:

    WriteLine();
    // Note that DoStuff() returns true.
    WriteLine($"p & DoStuff() = {p & DoStuff()}");
    WriteLine($"q & DoStuff() = {q & DoStuff()}"); 
    
  2. 运行代码,查看结果,并注意函数被调用了两次,一次是 p,一次是 q,如下面的输出所示:

    I am doing some stuff.
    p & DoStuff() = True
    I am doing some stuff.
    q & DoStuff() = False 
    
  3. 复制并粘贴这三个语句,然后将&运算符更改为&&运算符,如下面的代码所示:

    WriteLine();
    WriteLine($"p && DoStuff() = {p && DoStuff()}");
    WriteLine($"q && DoStuff() = {q && DoStuff()}"); 
    
  4. 运行代码,查看结果,并注意当与p变量结合时函数会运行。当与q变量结合时不会运行,因为q变量是false,所以结果无论如何都会是false,因此不需要执行函数,如下面的输出所示:

    I am doing some stuff.
    p && DoStuff() = True
    q && DoStuff() = False // DoStuff function was not executed! 
    

    良好实践:现在你可以看到为什么条件逻辑运算符被描述为短路。它们可以使你的应用程序更高效,但它们也可能在假设函数总是会调用的情况下引入微妙的错误。当与引起副作用的功能一起使用时,最安全的方法是避免使用它们。

探索位运算和二进制移位运算符

位运算符比较一个数字的二进制表示中的位。每个位,无论是0(零)还是1(全)的值,都会与同一列中的位单独比较。

二进制移位运算符可以比传统运算符更快地执行一些常见的算术计算,例如,任何乘以 2 的因子。

让我们探索位运算和二进制移位运算符:

  1. Program.cs中添加语句以声明两个名为xy的整型变量,其值分别为106,然后输出应用 AND、OR 和 XOR 位运算符的结果,如下面的代码所示:

    WriteLine();
    int x = 10;
    int y = 6;
    WriteLine($"Expression | Decimal |   Binary");
    WriteLine($"-------------------------------");
    WriteLine($"x          | {x,7} | {x:B8}");
    WriteLine($"y          | {y,7} | {y:B8}");
    WriteLine($"x & y      | {x & y,7} | {x & y:B8}");
    WriteLine($"x | y      | {x | y,7} | {x | y:B8}");
    WriteLine($"x ^ y      | {x ^ y,7} | {x ^ y:B8}"); 
    

记住,,7表示在七宽度列中右对齐,而:B8`表示以八位二进制格式进行格式化。

  1. 运行代码并注意结果,如下面的输出所示:

    Expression | Decimal |   Binary
    -------------------------------
    x          |      10 | 00001010
    y          |       6 | 00000110
    x & y      |       2 | 00000010
    x | y      |      14 | 00001110
    x ^ y      |      12 | 00001100 
    

    对于x & y,只有 2 位列被设置。对于x | y,8 位、4 位和 2 位列被设置。对于x ^ y,8 位和 4 位列被设置。

  2. Program.cs中添加语句以输出将变量x的位左移三列、将x乘以 8 以及将变量y的位右移一列的结果,如下面的代码所示:

    // Left-shift x by three bit columns.
    WriteLine($"x << 3     | {x << 3,7} | {x << 3:B8}");
    // Multiply x by 8.
    WriteLine($"x * 8      | {x * 8,7} | {x * 8:B8}");
    // Right-shift y by one bit column.
    WriteLine($"y >> 1     | {y >> 1,7} | {y >> 1:B8}"); 
    
  3. 运行代码并注意结果,如下面的输出所示:

    x << 3     |      80 | 01010000
    x * 8      |      80 | 01010000
    y >> 1     |       3 | 00000011 
    

80的结果是因为其中的位向左移动了三列,所以 1 位移动到了 64 位和 16 位列,64 + 16 = 80。这相当于乘以 8,但 CPU 可以更快地执行位移操作。3的结果是因为y中的 1 位向右移动了一列进入了 2 位和 1 位列。

良好实践:记住,在操作整型值时,&|符号是位运算符,而在操作类似truefalse的布尔值时,&|符号是逻辑运算符。

杂项运算符

nameofsizeof是处理类型时方便的运算符:

  • nameof返回变量、类型或成员的短名称(不带命名空间)作为string值,这在输出异常消息时很有用。

  • sizeof 返回简单类型的大小(以字节为单位),这对于确定数据存储的效率很有用。技术上,sizeof 运算符需要一个不安全代码块,但具有 C# 别名的值类型的大小,如 intdouble,由编译器硬编码为常量,因此不需要不安全块。

例如:

int age = 50;
WriteLine($"The {nameof(age)} variable uses {sizeof(int)} bytes of memory."); 

还有许多其他运算符;例如,变量和其成员之间的点称为 成员访问运算符,函数或方法名称末尾的圆括号称为 调用运算符,如下面的代码所示:

int age = 50;
// How many operators in the following statement?
char firstDigit = age.ToString()[0];
// There are four operators:
// = is the assignment operator
// . is the member access operator
// () is the invocation operator
// [] is the indexer access operator 

理解选择语句

每个应用程序都需要能够从选项中进行选择,并沿着不同的代码路径分支。C# 中的两个选择语句是 ifswitch。您可以使用 if 为所有代码,但 switch 可以在某些常见场景中简化您的代码,例如当有一个变量可以具有多个值,每个值都需要不同的处理时。

使用 if 语句进行分支

if 语句通过评估布尔表达式来确定要遵循的分支。如果表达式为 true,则执行该块。else 块是可选的,如果 if 表达式为 false,则执行该块。if 语句可以嵌套。

if 语句可以与其他 if 语句结合使用,作为 else if 分支,如下面的代码所示:

if (expression1)
{
  // Executes if expression1 is true.
}
else if (expression2)
{
  // Executes if expression1 is false and expression2 is true.
}
else if (expression3)
{
  // Executes if expression1 and expression2 are false
  // and expression3 is true.
}
else
{
  // Executes if all expressions are false.
} 

每个 if 语句的布尔表达式都是独立的,并且与 switch 语句不同,不需要引用单个值。

让我们编写一些代码来探索 if 等选择语句:

  1. 使用您首选的代码编辑器,将名为 SelectionStatements 的新 Console App / console 项目添加到 Chapter03 解决方案中。

记得在项目文件中静态导入 System.Console。如果您正在使用 Visual Studio,则将启动项目配置为当前选择。

  1. Program.cs 文件中,删除现有的语句,然后添加语句来检查密码是否至少有八个字符长,如下面的代码所示:

    string password = "ninja";
    if (password.Length < 8)
    {
      WriteLine("Your password is too short. Use at least 8 chars.");
    }
    else
    {
      WriteLine("Your password is strong.");
    } 
    
  2. 运行代码并注意结果,如下面的输出所示:

    Your password is too short. Use at least 8 chars. 
    

为什么您始终应该与 if 语句一起使用大括号

由于每个块内只有一个语句,前面的代码可以不使用大括号编写,如下面的代码所示:

if (password.Length < 8)
  WriteLine("Your password is too short. Use at least 8 chars.");
else
  WriteLine("Your password is strong."); 

应该避免这种 if 语句的样式,因为它可能会引入严重的错误。一个臭名昭著的例子是苹果公司 iPhone iOS 操作系统中的 #gotofail 错误。在苹果公司 iOS 6 发布后的 18 个月内,即 2012 年 9 月,由于在 安全套接字层 (SSL) 加密代码中缺少大括号的 if 语句,存在一个错误。这意味着任何尝试连接到受保护网站(如他们的银行)的用户,使用 Safari 浏览器(该设备的网络浏览器)时,都没有得到适当的保护,因为一个重要的检查被意外地跳过了。

虽然您可以省略花括号,但这并不意味着您应该这样做。没有它们,您的代码并不“更高效”;相反,它更难以阅读,维护性更差,并且可能更危险。

使用 if 语句进行模式匹配

C# 7 及以后版本引入的一个特性是模式匹配。if 语句可以使用 is 关键字结合声明局部变量来使您的代码更安全。重要的是要注意,单个表达式,例如 o is int i,执行了两个操作:

  1. 检查名为 o 的变量中的数据类型。

  2. 如果类型检查为 true,则将值赋给名为 i 的变量。

更多信息:您可以在以下链接中了解更多关于 is 操作符的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/is。您将在后面的章节中使用这项技术,所以请记住这一点。

让我们看看它是如何工作的:

  1. 添加语句,以便如果名为 o 的变量中存储的值是 int 类型,则将该值赋给名为 i 的局部变量,这样就可以在 if 语句内部使用它。这样做比使用名为 o 的变量更安全,因为我们确信 i 是一个 int 类型的变量,而不是其他类型,如下面的代码所示:

    // Add and remove the "" to change between string and int.
    object o = "3";
    int j = 4;
    if (o is int i)
    {
      WriteLine($"{i} x {j} = {i * j}");
    }
    else
    {
      WriteLine("o is not an int so it cannot multiply!");
    } 
    
  2. 运行代码并查看结果,如下所示:

    o is not an int so it cannot multiply! 
    
  3. 删除围绕 "3" 值的双引号字符,以便名为 o 的变量中存储的值是 int 类型,而不是 string 类型。

  4. 重新运行代码以查看结果,如下所示:

    3 x 4 = 12 
    

使用 switch 语句进行分支

switch 语句与 if 语句不同,因为 switch 将单个表达式与多个可能的 case 语句列表进行比较。每个 case 语句都与单个表达式相关。每个 case 部分必须以以下之一结束:

  • break 关键字(如下面代码中的 case 1)。

  • goto case 关键字(如下面代码中的 case 2)。

  • 它们应该没有语句(如以下代码中的 case 3)。

  • 引用命名标签的 goto 关键字(如下面代码中的 case 5)。

  • 使用 return 关键字离开当前函数(代码中未显示)。

让我们编写一些代码来探索 switch 语句:

  1. switch 语句输入以下代码。您应该注意,倒数第二个语句是一个可以跳转到的标签,第一个语句生成一个介于 1 和 6 之间的随机数(代码中的数字 7 是一个排他性上限)。switch 语句的分支基于这个随机数的值,如下面的代码所示:

    // Inclusive lower bound but exclusive upper bound.
    int number = Random.Shared.Next(minValue: 1, maxValue: 7);
    WriteLine($"My random number is {number}");
    switch (number)
    {
      case 1:
        WriteLine("One");
        break; // Jumps to end of switch statement.
      case 2:
        WriteLine("Two");
        goto case 1;
      case 3: // Multiple case section.
      case 4:
        WriteLine("Three or four");
        goto case 1;
      case 5:
        goto A_label;
      default:
        WriteLine("Default");
        break;
    } // End of switch statement.
    WriteLine("After end of switch");
    A_label:
    WriteLine($"After A_label"); 
    

良好实践:你可以使用 goto 关键字跳转到另一个 case 或标签。大多数程序员都不赞成使用 goto 关键字,但在某些场景下它可能是一个好的解决方案。然而,如果你确实需要使用它,应该尽量少用。要查看 Microsoft 在 .NET 基类库中使用了多少次 goto,请使用以下链接:github.com/search?q=%22goto%20%22+repo%3Adotnet%2Fruntime+language%3AC%23&type=code&ref=advsearch

  1. 运行代码多次,以查看在随机数的各种情况下会发生什么,如下面的示例输出所示:

    // First random run.
    My random number is 4
    Three or four
    One
    After end of switch
    After A_label
    // Second random run.
    My random number is 2
    Two
    One
    After end of switch
    After A_label
    // Third random run.
    My random number is 6
    Default
    After end of switch
    After A_label
    // Fourth random run.
    My random number is 1
    One
    After end of switch
    After A_label
    // Fifth random run.
    My random number is 5
    After A_label 
    

良好实践:我们用来生成随机数的 Random 类有一个 Next 方法,允许你指定一个包含的较低界限和一个排除的较高界限,并将生成一个伪随机数。由于 .NET 6,你不再需要创建一个非线程安全的 Random 实例,你可以使用一个线程安全的 Shared 实例,这样它就可以在任何线程中并发使用。

现在,让我们暂时从 switch 关键字转向,回顾一下如何在 Visual Studio 中添加新项,因为在下一个代码任务中,你需要将一个新类文件添加到你的项目中,而我还没有向你展示如何做。

使用 Visual Studio 向项目添加新项

Visual Studio 版本 17.6 及更高版本提供了一个用于向项目添加新项的可选简化对话框。在导航到 项目 | 添加新项…,或在 解决方案资源管理器 中右键单击项目并选择 添加 | 新项… 后,你会看到传统的对话框,如图 图 3.1 所示:

图 3.1:正常视图中的“添加新项”对话框

如果你点击 显示紧凑视图 按钮,那么它将切换到一个简化的对话框,如图 图 3.2 所示:

图 3.2:紧凑视图中的“添加新项”对话框

要返回到正常对话框,请点击 显示所有模板 按钮。

使用 switch 语句进行模式匹配

if 语句一样,switch 语句在 C# 7 及更高版本中支持模式匹配。case 值不再需要是字面值;它们可以是模式。

在 C# 7 及更高版本中,你的代码可以根据类的子类型更简洁地进行分支,并且你可以声明和分配一个局部变量来安全地使用它。此外,case 语句可以包含一个 when 关键字以执行更具体的模式匹配。

让我们通过一个使用自定义动物类层次结构(具有不同属性)的 switch 语句模式匹配的例子来看一下:

你将在 第五章 中学习更多关于定义类的细节,使用面向对象编程构建自己的类型。现在,你应该能够通过阅读代码来理解这个概念。

  1. SelectionStatements 项目中,添加一个名为 Animals.cs 的新类文件:

    • 在 Visual Studio 中,导航到 项目 | 添加新项… 或按 Ctrl + Shift + A,输入名称,然后单击 添加

    • 在 VS Code 中,单击 新建文件… 按钮,并输入名称。

    • 在 Rider 中,右键单击项目并选择 添加 | 类/接口…

  2. Animals.cs 文件中,删除任何现有的语句,然后定义三个类,一个基类 Animal 和两个继承类 CatSpider,如下面的代码所示:

    class Animal // This is the base type for all animals.
    {
      public string? Name;
      public DateTime Born;
      public byte Legs;
    }
    class Cat : Animal // This is a subtype of animal.
    {
      public bool IsDomestic;
    }
    class Spider : Animal // This is another subtype of animal.
    {
      public bool IsVenomous;
    } 
    

由于本节是关于模式匹配,而不是定义具有字段的类的最常见方式,因此数据类型的声明方式并不重要。Name 属性已被声明为可空,而其他两个属性则不是。您可以将 Name 声明为非可空,并将其他两个声明为可空,模式匹配示例不受影响。

  1. Program.cs 文件中,添加语句来声明一个可空动物数组,然后根据每个动物的类型和属性显示一条消息,如下面的代码所示:

    var animals = new Animal?[]
    {
      new Cat { Name = "Karen", Born = new(year: 2022, month: 8,
        day: 23), Legs = 4, IsDomestic = true },
      null,
      new Cat { Name = "Mufasa", Born = new(year: 1994, month: 6,
        day: 12) },
      new Spider { Name = "Sid Vicious", Born = DateTime.Today,
        IsVenomous = true},
      new Spider { Name = "Captain Furry", Born = DateTime.Today }
    };
    foreach (Animal? animal in animals)
    {
      string message;
      switch (animal)
      {
        case Cat fourLeggedCat when fourLeggedCat.Legs == 4:
          message = $"The cat named {fourLeggedCat.Name} has four legs.";
          break;
        case Cat wildCat when wildCat.IsDomestic == false:
          message = $"The non-domestic cat is named {wildCat.Name}.";
          break;
        case Cat cat:
          message = $"The cat is named {cat.Name}.";
          break;
        default: // default is always evaluated last.
          message = $"{animal.Name} is a {animal.GetType().Name}.";
          break;
        case Spider spider when spider.IsVenomous:
          message = $"The {spider.Name} spider is venomous. Run!";
          break;
        case null:
          message = "The animal is null.";
          break;
      }
      WriteLine($"switch statement: {message}");
    } 
    

良好实践: 总是在 switch 语句的末尾放置 default。我故意将前面的 switch 语句中的 default 放在分支集合的中间,以显示编译器将接受这一点,并且会始终将其移动到分支集合的末尾。

下面的代码中显示的 case 语句:

case Cat fourLeggedCat when fourLeggedCat.Legs == 4:

也可以使用更简洁的属性模式匹配语法来编写,如下面的代码所示:

case Cat { Legs: 4 } fourLeggedCat:

  1. 运行代码并注意,名为 animals 的数组被声明为包含 Animal? 类型,因此它可以包含 Animal 的任何子类型,例如 CatSpider,或者一个 null 值。在此代码中,我们创建了四个不同类型的 Animal 实例,具有不同的属性,以及一个 null 实例,因此结果将是五条描述每个动物的消息,如下面的输出所示:

    switch statement: The cat named Karen has four legs.
    switch statement: The animal is null.
    switch statement: The non-domestic cat is named Mufasa.
    switch statement: The Sid Vicious spider is venomous. Run!
    switch statement: Captain Furry is a Spider. 
    

使用 switch 表达式简化 switch 语句

在 C# 8 或更高版本中,您可以使用 switch 表达式简化 switch 语句。

大多数 switch 语句都非常简单,但它们需要大量的输入。switch 表达式旨在简化您需要输入的代码,同时在所有情况都返回一个值以设置单个变量的场景中,仍然表达相同的意图。switch 表达式使用 lambda 表达式 => 来指示返回值。

让我们实现之前使用 switch 语句的代码,使用 switch 表达式,以便您可以比较两种风格:

  1. Program.cs 文件中,在 foreach 循环底部内部添加语句,根据动物的类型和属性设置消息,使用 switch 表达式,如下面的代码所示:

    message = animal switch
    {
      Cat fourLeggedCat when fourLeggedCat.Legs == 4
        => $"The cat named {fourLeggedCat.Name} has four legs.",
      Cat wildCat when wildCat.IsDomestic == false
        => $"The non-domestic cat is named {wildCat.Name}.",
      Cat cat
        => $"The cat is named {cat.Name}.",
      Spider spider when spider.IsVenomous
        => $"The {spider.Name} spider is venomous. Run!",
      null
        => "The animal is null.",
      _
        => $"{animal.Name} is a {animal.GetType().Name}."
    };
    WriteLine($"switch expression: {message}"); 
    

主要区别是移除了casebreak关键字。下划线字符_用于表示默认返回值。它被称为丢弃,你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/discards

  1. 运行代码,注意结果与之前相同,如下所示:

    switch statement: The cat named Karen has four legs.
    switch expression: The cat named Karen has four legs.
    switch statement: The animal is null.
    switch expression: The animal is null.
    switch statement: The non-domestic cat is named Mufasa.
    switch expression: The non-domestic cat is named Mufasa.
    switch statement: The Sid Vicious spider is venomous. Run!
    switch expression: The Sid Vicious spider is venomous. Run!
    switch statement: Captain Furry is a Spider.
    switch expression: Captain Furry is a Spider. 
    

理解迭代语句

迭代语句会重复执行一个语句块,要么是在条件为true时(whilefor语句),要么是在集合中的每个项目上(foreach语句)。选择使用哪个语句取决于解决逻辑问题的理解难度和个人偏好。

使用while语句进行循环

while语句评估一个布尔表达式,并在表达式为真时继续循环。让我们来探索迭代语句:

  1. 使用你喜欢的代码编辑器,向Chapter03解决方案中添加一个名为IterationStatements的新控制台应用程序/ console项目。

  2. Program.cs中,删除现有的语句,然后添加语句来定义一个while语句,该语句在整数变量的值小于 10 时循环,如下所示:

    int x = 0;
    while (x < 10)
    {
      WriteLine(x);
      x++;
    } 
    
  3. 运行代码并查看结果,结果应该是从 0 到 9 的数字,如下所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9 
    

使用do语句进行循环

do语句类似于while,除了布尔表达式是在块的底部而不是顶部进行检查,这意味着块至少执行一次,如下所示:

  1. 输入语句以定义do循环,如下所示:

    string? actualPassword = "Pa$$w0rd";
    string? password;
    do
    {
      Write("Enter your password: ");
      password = ReadLine();
    }
    while (password != actualPassword);
    WriteLine("Correct!"); 
    
  2. 运行代码,注意你会被提示重复输入密码,直到输入正确,如下所示:

    Enter your password: password
    Enter your password: 12345678
    Enter your password: ninja
    Enter your password: correct horse battery staple
    Enter your password: Pa$$w0rd
    Correct! 
    
  3. 作为一项可选挑战,添加语句,以便用户只能在显示错误消息之前尝试三次。

  4. 在这一点上,你可能想要注释掉这一部分的代码,这样你就不必每次运行控制台应用程序时都输入密码!

使用for语句进行循环

for语句类似于while,但它更简洁。它结合了:

  • 一个可选的初始化表达式,它在循环开始时执行一次。

  • 一个可选的条件表达式,它在循环的每次迭代开始时执行,以检查是否应该继续循环。如果表达式返回true或它缺失,则循环将再次执行。

  • 一个可选的迭代表达式,它在语句底部的每个循环中执行。这通常用于递增计数器变量。

for语句通常与整数计数器一起使用。让我们看看一些代码:

  1. 输入一个for语句以输出从 1 到 10 的数字,如下所示:

    for (int y = 1; y <= 10; y++)
    {
      WriteLine(y);
    } 
    
  2. 运行代码以查看结果,结果应该是从 1 到 10 的数字。

  3. 添加另一个 for 语句以输出 0 到 10 的数字,每次增加 3,如下面的代码所示:

    for (int y = 0; y <= 10; y += 3)
    {
      WriteLine(y);
    } 
    
  4. 运行代码以查看结果,结果应该是数字 0、3、6 和 9。

  5. 可选地,尝试更改初始化器表达式、条件表达式或迭代器表达式以查看它们的效果。一次只更改一个,以便您可以清楚地看到产生的效果。

使用 foreach 语句进行循环

foreach 语句与前面的三个迭代语句略有不同。

它用于对序列中的每个项目执行一组语句,例如数组或集合。每个项目通常是只读的,如果在迭代过程中修改了序列结构,例如添加或删除一个项目,那么将抛出异常。

尝试以下示例:

  1. 输入语句以创建一个字符串变量的数组,然后输出每个变量的长度,如下面的代码所示:

    string[] names = { "Adam", "Barry", "Charlie" };
    foreach (string name in names)
    {
      WriteLine($"{name} has {name.Length} characters.");
    } 
    
  2. 运行代码并查看以下输出结果:

    Adam has 4 characters.
    Barry has 5 characters.
    Charlie has 7 characters. 
    

理解 foreach 在内部是如何工作的

定义表示多个项目(如数组或集合)的类型的设计师应确保程序员可以使用 foreach 语句遍历类型的项。

技术上,foreach 语句将在遵循以下规则的所有类型上工作:

  • 类型必须有一个名为 GetEnumerator 的方法,该方法返回一个对象。

  • 返回的对象必须有一个名为 Current 的属性和一个名为 MoveNext 的方法。

  • MoveNext 方法必须更改 Current 的值,并在还有更多要枚举的项目时返回 true,如果没有更多项目则返回 false

有名为 IEnumerableIEnumerable<T> 的接口正式定义了这些规则,但从技术上讲,编译器不要求类型实现这些接口。

编译器将前面示例中的 foreach 语句转换为以下伪代码:

IEnumerator e = names.GetEnumerator();
while (e.MoveNext())
{
  string name = (string)e.Current; // Current is read-only!
  WriteLine($"{name} has {name.Length} characters.");
} 

由于使用了迭代器和其只读的 Current 属性,因此 foreach 语句中声明的变量不能用来修改当前项的值。

在数组中存储多个值

当你需要存储相同类型的多个值时,你可以声明一个数组。例如,当你需要在一个 string 数组中存储四个名字时,你可能这样做。

与一维数组一起工作

你接下来要编写的代码将为存储四个 string 值的数组分配内存。然后它将在索引位置 0 到 3 存储字符串值(数组通常具有零的下限,因此最后一个项目的索引比数组的长度少一)。

我们可以将数组可视化如下:

0 1 2 3
凯特 杰克 丽贝卡 汤姆

表 3.1:四个字符串值数组的可视化

良好实践:不要假设所有数组都是从零开始的。在 .NET 中最常见的数组类型是 szArray,这是一个单维零索引数组,并且它们使用正常的 [] 语法。但是 .NET 还有一个 mdArray,这是一个多维数组,它们不必有零的下限。这些很少使用,但您应该知道它们存在。

最后,它将使用 for 语句遍历数组中的每个项目。

让我们看看如何使用数组:

  1. 使用您首选的代码编辑器,向 Chapter03 解决方案中添加一个名为 Arrays 的新 Console App / console 项目。

  2. Program.cs 文件中,删除现有的语句,然后输入语句来声明和使用一个 string 类型的数组,如下面的代码所示:

    string[] names; // This can reference any size array of strings.
    // Allocate memory for four strings in an array.
    names = new string[4];
    // Store items at these index positions.
    names[0] = "Kate";
    names[1] = "Jack";
    names[2] = "Rebecca";
    names[3] = "Tom";
    // Loop through the names.
    for (int i = 0; i < names.Length; i++)
    {
      // Output the item at index position i.
      WriteLine($"{names[i]} is at position {i}.");
    } 
    
  3. 运行代码并注意结果,如下面的输出所示:

    Kate is at position 0.
    Jack is at position 1.
    Rebecca is at position 2.
    Tom is at position 3. 
    

数组在内存分配时总是固定大小的,因此您需要在实例化之前决定要存储多少项。

与上述分三步定义数组的方法不同,可以使用数组初始化语法:

  1. for 循环之前,添加一个语句来声明、分配内存并实例化一个类似数组的值,如下面的代码所示:

    // Alternative syntax for creating and initializing an array.
    string[] names2 = { "Kate", "Jack", "Rebecca", "Tom" }; 
    
  2. for 循环改为使用 names2,运行控制台应用程序,并注意结果相同。

处理多维数组

如果我们想要存储一个值的网格,或者一个立方体,甚至更高的维度,而不是存储一行的字符串值(或任何其他数据类型)的单一维度数组,那会怎么样呢?

我们可以将字符串值的二维数组,也就是网格,可视化如下:

0 1 2 3
0 Alpha Beta Gamma Delta
1 Anne Ben Charlie Doug
2 Aardvark Bear Cat Dog

表 3.2:二维数组的可视化

让我们看看如何使用多维数组:

  1. Program.cs 文件的底部添加语句来声明和实例化一个 string 类型的二维数组,如下面的代码所示:

    string[,] grid1 = // Two dimensional array.
    {
      { "Alpha", "Beta", "Gamma", "Delta" },
      { "Anne", "Ben", "Charlie", "Doug" },
      { "Aardvark", "Bear", "Cat", "Dog" }
    }; 
    
  2. 我们可以使用有用的方法来发现这个数组的上下限,如下面的代码所示:

    WriteLine($"1st dimension, lower bound: {grid1.GetLowerBound(0)}");
    WriteLine($"1st dimension, upper bound: {grid1.GetUpperBound(0)}");
    WriteLine($"2nd dimension, lower bound: {grid1.GetLowerBound(1)}");
    WriteLine($"2nd dimension, upper bound: {grid1.GetUpperBound(1)}"); 
    
  3. 运行代码并注意结果,如下面的输出所示:

    1st dimension, lower bound: 0
    1st dimension, upper bound: 2
    2nd dimension, lower bound: 0
    2nd dimension, upper bound: 3 
    
  4. 然后,我们可以使用这些值在嵌套的 for 语句中循环遍历 string 值,如下面的代码所示:

    for (int row = 0; row <= grid1.GetUpperBound(0); row++)
    {
      for (int col = 0; col <= grid1.GetUpperBound(1); col++)
      {
        WriteLine($"Row {row}, Column {col}: {grid1[row, col]}");
      }
    } 
    
  5. 运行代码并注意结果,如下面的输出所示:

    Row 0, Column 0: Alpha
    Row 0, Column 1: Beta
    Row 0, Column 2: Gamma
    Row 0, Column 3: Delta
    Row 1, Column 0: Anne
    Row 1, Column 1: Ben
    Row 1, Column 2: Charlie
    Row 1, Column 3: Doug
    Row 2, Column 0: Aardvark
    Row 2, Column 1: Bear
    Row 2, Column 2: Cat
    Row 2, Column 3: Dog 
    

在实例化时,您必须为每一行和每一列提供一个值,否则您将得到编译错误。如果您需要表示缺失的 string 值,则使用 string.Empty。或者,如果您通过使用 string?[] 声明数组为可空 string 值,那么您也可以使用 null 表示缺失的值。

如果您不能使用数组初始化语法,可能是因为您正在从文件或数据库中加载数值,那么您可以将数组的声明和内存分配与值的赋值分开,如下面的代码所示:

// Alternative syntax for declaring and allocating memory
// for a multi-dimensional array.
string[,] grid2 = new string[3,4]; // Allocate memory.
grid2[0, 0] = "Alpha"; // Assign values.
grid2[0, 1] = "Beta";
// And so on.
grid2[2, 3] = "Dog"; 

在声明维度的尺寸时,你指定长度,而不是上界。表达式 new string[3,4] 表示数组在其第一个维度(0)中可以有 3 个项目,上界为 2,并且数组在其第二个维度(1)中可以有 4 个项目,上界为 3。

使用交错数组

如果你需要一个多维数组,但每个维度中存储的项目数量不同,那么你可以定义一个数组数组,也称为交错数组。

我们可以将交错数组可视化如图 图 3.3 所示:

图 3.3:交错数组的可视化

让我们看看如何使用交错数组:

  1. Program.cs 的底部,添加语句来声明和实例化一个 string 值的数组数组,如下面的代码所示:

    string[][] jagged = // An array of string arrays.
    {
      new[] { "Alpha", "Beta", "Gamma" },
      new[] { "Anne", "Ben", "Charlie", "Doug" },
      new[] { "Aardvark", "Bear" }
    }; 
    
  2. 我们可以找到数组数组的上下界,然后是每个与之相关的数组,如下面的代码所示:

    WriteLine("Upper bound of the array of arrays is: {0}",
      jagged.GetUpperBound(0));
    for (int array = 0; array <= jagged.GetUpperBound(0); array++)
    {
      WriteLine("Upper bound of array {0} is: {1}",
        arg0: array,
        arg1: jagged[array].GetUpperBound(0));
    } 
    
  3. 运行代码并注意结果,如下面的输出所示:

    Upper bound of the array of arrays is: 2
    Upper bound of array 0 is: 2
    Upper bound of array 1 is: 3
    Upper bound of array 2 is: 1 
    
  4. 然后,我们可以使用这些值在嵌套的 for 语句中使用,以遍历 string 值,如下面的代码所示:

    for (int row = 0; row <= jagged.GetUpperBound(0); row++)
    {
      for (int col = 0; col <= jagged[row].GetUpperBound(0); col++)
      {
        WriteLine($"Row {row}, Column {col}: {jagged[row][col]}");
      }
    } 
    
  5. 运行代码并注意结果,如下面的输出所示:

    Row 0, Column 0: Alpha
    Row 0, Column 1: Beta
    Row 0, Column 2: Gamma
    Row 1, Column 0: Anne
    Row 1, Column 1: Ben
    Row 1, Column 2: Charlie
    Row 1, Column 3: Doug
    Row 2, Column 0: Aardvark
    Row 2, Column 1: Bear 
    

使用数组进行列表模式匹配

在本章的早期部分,你看到了单个对象如何支持与其类型和属性的模式匹配。模式匹配也适用于数组和集合。

从 C# 11 开始引入,列表模式匹配与任何具有公共 LengthCount 属性并且使用 intSystem.Index 参数的索引器的类型一起工作。你将在 第五章使用面向对象编程构建自己的类型 中了解索引器。

当你在同一个 switch 表达式中定义多个列表模式时,你必须按顺序排列它们,以便更具体的模式先出现,否则编译器会抱怨,因为更通用的模式也会匹配更具体的模式,从而使更具体的模式不可达。

表 3.3 显示了列表模式匹配的示例,假设有一个 int 值的列表:

示例 描述
[] 与空数组或集合匹配。
[..] 与包含任何数量(包括零)的项的数组或集合匹配,因此如果需要同时切换,则 [..] 必须在 [] 之后。
[_] 与包含任何单个项目的列表匹配。
[int item1][var item1] 与包含任何单个项目的列表匹配,并且可以通过引用 item1 使用返回表达式中的值。
[7, 2] 与具有这些值的顺序的恰好两个项目的列表匹配。
[_, _] 与包含任何两个项目的列表匹配。
[var item1, var item2] 与包含任何两个项目的列表匹配,并且可以通过引用 item1item2 在返回表达式中使用这些值。
[_, _, _] 与包含任何三个项目的列表匹配。
[var item1, ..] 与包含一个或多个项目的列表匹配。可以通过引用 item1 在返回表达式中引用第一个项目的值。
[var firstItem, .., var lastItem] 匹配包含两个或更多项目的列表。可以通过引用firstItemlastItem来引用其返回表达式中第一个和最后一个项目的值。
[.., var lastItem] 匹配包含一个或多个项目的列表。可以通过引用lastItem来引用其返回表达式中最后一个项目的值。

表 3.3:列表模式匹配的示例

让我们看看一些代码示例:

  1. Program.cs的底部,添加语句来定义一些int值的数组,然后将它们传递给一个方法,该方法根据最佳匹配模式返回描述性文本,如下所示代码:

    int[] sequentialNumbers = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
    int[] oneTwoNumbers = { 1, 2 };
    int[] oneTwoTenNumbers = { 1, 2, 10 };
    int[] oneTwoThreeTenNumbers = { 1, 2, 3, 10 };
    int[] primeNumbers = { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29 };
    int[] fibonacciNumbers = { 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89 };
    int[] emptyNumbers = { }; // Or use Array.Empty<int>()
    int[] threeNumbers = { 9, 7, 5 };
    int[] sixNumbers = { 9, 7, 5, 4, 2, 10 };
    WriteLine($"{nameof(sequentialNumbers)}: {CheckSwitch(sequentialNumbers)}");
    WriteLine($"{nameof(oneTwoNumbers)}: {CheckSwitch(oneTwoNumbers)}");
    WriteLine($"{nameof(oneTwoTenNumbers)}: {CheckSwitch(oneTwoTenNumbers)}");
    WriteLine($"{nameof(oneTwoThreeTenNumbers)}: {CheckSwitch(oneTwoThreeTenNumbers)}");
    WriteLine($"{nameof(primeNumbers)}: {CheckSwitch(primeNumbers)}");
    WriteLine($"{nameof(fibonacciNumbers)}: {CheckSwitch(fibonacciNumbers)}");
    WriteLine($"{nameof(emptyNumbers)}: {CheckSwitch(emptyNumbers)}");
    WriteLine($"{nameof(threeNumbers)}: {CheckSwitch(threeNumbers)}");
    WriteLine($"{nameof(sixNumbers)}: {CheckSwitch(sixNumbers)}");
    static string CheckSwitch(int[] values) => values switch
    {
      [] => "Empty array",
      [1, 2, _, 10] => "Contains 1, 2, any single number, 10.",
      [1, 2, .., 10] => "Contains 1, 2, any range including empty, 10.",
      [1, 2] => "Contains 1 then 2.",
      [int item1, int item2, int item3] =>
        $"Contains {item1} then {item2} then {item3}.",
      [0, _] => "Starts with 0, then one other number.",
      [0, ..] => "Starts with 0, then any range of numbers.",
      [2, .. int[] others] => $"Starts with 2, then {others.Length} more numbers.",
      [..] => "Any items in any order.", // <-- Note the trailing comma for easier re-ordering.
      // Use Alt + Up or Down arrow to move statements.
    }; 
    

在 C# 6 中,Microsoft 添加了对表达式主体函数成员的支持。上面的CheckSwitch函数使用了这种语法。在 C#中,lambda 是使用=>字符来表示函数的返回值。我将在第四章,编写、调试和测试函数中适当介绍这一点。

  1. 运行代码并注意结果,如下所示输出:

    sequentialNumbers: Contains 1, 2, any range including empty, 10.
    oneTwoNumbers: Contains 1 then 2.
    oneTwoTenNumbers: Contains 1, 2, any range including empty, 10.
    oneTwoThreeTenNumbers: Contains 1, 2, any single number, 10.
    primeNumbers: Starts with 2, then 9 more numbers.
    fibonacciNumbers: Starts with 0, then any range of numbers.
    emptyNumbers: Empty array
    threeNumbers: Contains 9 then 7 then 5.
    sixNumbers: Any items in any order. 
    

你可以在以下链接中了解更多关于列表模式匹配的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/patterns#list-patterns

尾随逗号

switch表达式的最后一个项目后面的逗号是可选的,编译器不会对此提出异议。

大多数语言,包括 C#,都允许使用尾随逗号的代码风格。当多个项目通过逗号分隔(例如,在声明匿名对象、数组、集合初始化器、枚举和switch表达式时),C# 允许你在最后一个项目后面使用尾随逗号。这使得在不添加和删除逗号的情况下重新排列项目顺序变得容易。

你可以在以下链接中回顾 2018 年关于允许switch表达式尾随逗号的讨论:github.com/dotnet/csharplang/issues/2098

即使 JSON 序列化器也有一个选项允许这样做,因为它如此常见,如以下链接中讨论的:learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions.allowtrailingcommas

理解内联数组

内联数组是在 C# 12 中引入的;它们是.NET 运行时团队用来提高性能的高级功能。除非你是公共库的作者,否则你不太可能自己使用它们,但你将自动从其他人使用它们中受益。

更多信息:你可以在以下链接中了解更多关于内联数组的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/inline-arrays

总结数组

我们使用略微不同的语法来声明不同类型的数组,如表 3.4所示:

数组类型 声明语法
一维 datatype[], 例如,string[]
二维 string[,]
三维 string[,,]
十维 string[,,,,,,,,,]
数组数组,也称为二维交错数组 string[][]
数组数组的数组,也称为三维交错数组 string[][][]

表 3.4:数组声明语法总结

数组用于临时存储多个项目,但在动态添加和删除项目时,集合是一个更灵活的选项。您现在不需要担心集合,因为我们将在第八章“使用常见的.NET 类型”中介绍它们。

您可以使用ToArray扩展方法将任何项目序列转换为数组,我们将在第十一章“使用 LINQ 查询和操作数据”中介绍。

良好实践:如果您不需要动态添加和删除项目,那么您应该使用数组而不是像List<T>这样的集合,因为数组在内存使用上更高效,并且项目是连续存储的,这可以提高性能。

类型之间的转换和转换

您经常需要在不同类型之间转换变量值。例如,数据输入通常在控制台以文本形式输入,因此最初存储在string类型的变量中,但它随后需要转换为日期/时间、数字或其他数据类型,具体取决于如何存储和处理。

有时您需要在执行计算之前在数字类型之间进行转换,例如在整数和浮点数之间。

转换也称为转换,它有两种类型:隐式显式。隐式转换是自动发生的,并且是安全的,这意味着您不会丢失任何信息。

显式转换必须手动执行,因为它可能会丢失信息,例如数字的精度。通过显式转换,您正在告诉 C#编译器您了解并接受风险。

隐式和显式转换数字

int变量隐式转换为double变量是安全的,因为不会丢失任何信息,如下所示:

  1. 使用您首选的代码编辑器将名为CastingConverting的新控制台应用程序/ console项目添加到Chapter03解决方案中。

  2. Program.cs中,删除现有的语句,然后输入语句来声明和分配一个int变量和一个double变量,然后在将整数值赋给double变量时隐式转换整数,如下面的代码所示:

    int a = 10;
    double b = a; // An int can be safely cast into a double.
    WriteLine($"a is {a}, b is {b}"); 
    
  3. 输入语句来声明和分配一个double变量和一个int变量,然后在将double值赋给int变量时隐式转换,如下面的代码所示:

    double c = 9.8;
    int d = c; // Compiler gives an error if you do not explicitly cast.
    WriteLine($"c is {c}, d is {d}"); 
    
  4. 运行代码并注意错误信息,如下面的输出所示:

    Error: (6,9): error CS0266: Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?) 
    

此错误消息也将出现在 Visual Studio 错误列表、VS Code 问题窗口或 Rider 问题窗口中。

你不能隐式地将double变量转换为int变量,因为这可能是不安全的,并且可能会丢失数据,就像小数点后的值一样。你必须使用一对圆括号明确地将double变量转换为你要转换的int类型。这对圆括号是转换运算符。即使如此,你也必须意识到小数点后的部分将被截断,而不会发出警告,因为你选择了执行显式转换,因此理解了后果。

  1. d变量的赋值语句修改为显式地将变量c转换为int,并添加注释来解释将会发生什么,如下所示高亮显示的代码:

    double c = 9.8;
    int d = (int)c; // Compiler gives an error if you do not explicitly cast.
    WriteLine($"c is {c}, d is {d}"); **// d loses the .8 part.** 
    
  2. 运行代码以查看结果,如下所示输出:

    a is 10, b is 10
    c is 9.8, d is 9 
    

在将大整数和小整数之间的值进行转换时,我们必须执行类似的操作。再次提醒,请注意,你可能会丢失信息,因为任何太大的值都会将其位复制过来,然后以你可能无法预料的方式解释!

  1. 输入语句以声明和分配一个long(64 位)整数变量到一个int(32 位)整数变量,同时使用一个可以工作的小值和一个太大无法工作的大值,如下所示代码:

    long e = 10;
    int f = (int)e;
    WriteLine($"e is {e:N0}, f is {f:N0}");
    e = long.MaxValue;
    f = (int)e;
    WriteLine($"e is {e:N0}, f is {f:N0}"); 
    
  2. 运行代码以查看结果,如下所示输出:

    e is 10, f is 10
    e is 9,223,372,036,854,775,807, f is -1 
    
  3. e的值修改为 50 亿,如下所示代码:

    e = 5_000_000_000; 
    
  4. 运行代码以查看结果,如下所示输出:

    e is 5,000,000,000, f is 705,032,704 
    

五十亿无法放入 32 位整数中,因此它溢出(回绕)到大约 705 百万。这完全与整数的二进制表示有关。你将在本章后面看到更多关于整数溢出及其处理方法的示例。

如何表示二进制中的负数

你可能想知道为什么在之前的代码中f的值是-1。负数,也称为有符号数,使用第一个位来表示负值。如果这个位是0(零),那么它是一个正数。如果这个位是1(全),那么它是一个负数。

让我们编写一些代码来演示这一点:

  1. 输入语句以输出int的最大值,以十进制和二进制数格式,然后输出值8-8,每次递减一个,最后输出int的最小值,如下所示代码:

    WriteLine("{0,12} {1,34}", "Decimal", "Binary");
    WriteLine("{0,12} {0,34:B32}", int.MaxValue);
    for (int i = 8; i >= -8; i--)
    {
      WriteLine("{0,12} {0,34:B32}", i);
    }
    WriteLine("{0,12} {0,34:B32}", int.MinValue); 
    

注意,,12,34表示在这些列宽内右对齐。:B32表示以二进制格式填充,前导零的宽度为 32。

  1. 运行代码以查看结果,如下所示输出:

     Decimal                             Binary
      2147483647   01111111111111111111111111111111
               8   00000000000000000000000000001000
               7   00000000000000000000000000000111
               6   00000000000000000000000000000110
               5   00000000000000000000000000000101
               4   00000000000000000000000000000100
               3   00000000000000000000000000000011
               2   00000000000000000000000000000010
               1   00000000000000000000000000000001
               0   00000000000000000000000000000000
              -1   11111111111111111111111111111111
              -2   11111111111111111111111111111110
              -3   11111111111111111111111111111101
              -4   11111111111111111111111111111100
              -5   11111111111111111111111111111011
              -6   11111111111111111111111111111010
              -7   11111111111111111111111111111001
              -8   11111111111111111111111111111000
     -2147483648   10000000000000000000000000000000 
    
  2. 注意,所有正二进制数表示都以 0 开头,所有负二进制数表示都以 1 开头。十进制值 -1 在二进制中表示为全 1。这就是为什么当整数太大而无法放入 32 位整数时,它变成 -1。但这类转换的结果并不总是 -1。当从更宽的整数数据类型转换为更窄的整数数据类型时,最高有效位会被截断。例如,如果你从 32 位整数转换为 16 位整数,32 位整数的 16 个最高有效位(MSBs)将被截断。最低有效位(LSBs)代表转换的结果。例如,如果你转换到 16 位整数,原始值的 16 个最低有效位将代表转换后的结果。

  3. 输入语句以展示一个 long 整数的例子,当将其转换为 int 时,会被截断为一个非负一值,如下面的代码所示:

    long r = 0b_101000101010001100100111010100101010;
    int s = (int) r;
    Console.WriteLine($"{r,38:B38} = {r}");
    Console.WriteLine($"{s,38:B32} = {s}"); 
    
  4. 运行代码以查看结果,如下面的输出所示:

    00101000101010001100100111010100101010 = 43657622826
          00101010001100100111010100101010 = 707949866 
    

更多信息:如果你对在计算机系统中如何表示有符号数字感兴趣,可以阅读以下文章:en.wikipedia.org/wiki/Signed_number_representations

使用 System.Convert 类型进行转换

你只能进行类似类型的转换,例如,在整数类型如 byteintlong 之间,或者在一个类及其子类之间。你不能将 long 转换为 string 或将 byte 转换为 DateTime

使用转换运算符的替代方法是使用 System.Convert 类型。System.Convert 类型可以将所有 C# 数字类型以及布尔值、字符串和日期时间值进行转换。

让我们编写一些代码来观察这一过程:

  1. Program.cs 的顶部,静态导入 System.Convert 类,如下面的代码所示:

    using static System.Convert; // To use the ToInt32 method. 
    

或者,将条目添加到 CastingConverting.csproj 中,如下面的标记所示:<Using Include="System.Convert" Static="true" />

  1. Program.cs 的底部,输入语句以声明并分配一个 double 变量的值,将其转换为整数,然后将这两个值写入控制台,如下面的代码所示:

    double g = 9.8;
    int h = ToInt32(g); // A method of System.Convert.
    WriteLine($"g is {g}, h is {h}"); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    g is 9.8, h is 10 
    

转换和转换之间的重要区别在于,转换会将 double9.8 向上舍入到 10,而不是截断小数点后的部分。另一个区别是,转换可以允许溢出,而转换会抛出异常。

数字四舍五入和默认的四舍五入规则

你现在已经看到,转换运算符会截断实数的十进制部分,而 System.Convert 方法会向上或向下舍入。然而,四舍五入的规则是什么?

在英国小学(5 至 11 岁儿童就读的学校)中,学生被教导如果小数部分是 .5 或更高,则向上四舍五入,如果小数部分小于,则向下四舍五入。当然,这些术语之所以有意义,是因为在那个年龄,学生只处理正数。对于负数,这些术语会变得令人困惑,应该避免使用。这就是为什么 .NET API 使用 enumAwayFromZeroToZeroToEvenToPositiveInfinityToNegativeInfinity 来提高清晰度。

让我们探索 C# 是否遵循相同的学校规则:

  1. 编写语句来声明并分配一个 double 值数组,将每个值转换为整数,然后将结果写入控制台,如下面的代码所示:

    double[,] doubles = {
      { 9.49, 9.5, 9.51 },
      { 10.49, 10.5, 10.51 },
      { 11.49, 11.5, 11.51 },
      { 12.49, 12.5, 12.51 } ,
      { -12.49, -12.5, -12.51 },
      { -11.49, -11.5, -11.51 },
      { -10.49, -10.5, -10.51 },
      { -9.49, -9.5, -9.51 }
    };
    WriteLine($"| double | ToInt32 | double | ToInt32 | double | ToInt32 |");
    for (int x = 0; x < 8; x++)
    {
      for (int y = 0; y < 3; y++)
      {
        Write($"| {doubles[x, y],6} | {ToInt32(doubles[x, y]),7} ");
      }
      WriteLine("|");
    }
    WriteLine(); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    | double | ToInt32 | double | ToInt32 | double | ToInt32 |
    |   9.49 |       9 |    9.5 |      10 |   9.51 |      10 |
    |  10.49 |      10 |   10.5 |      10 |  10.51 |      11 |
    |  11.49 |      11 |   11.5 |      12 |  11.51 |      12 |
    |  12.49 |      12 |   12.5 |      12 |  12.51 |      13 |
    | -12.49 |     -12 |  -12.5 |     -12 | -12.51 |     -13 |
    | -11.49 |     -11 |  -11.5 |     -12 | -11.51 |     -12 |
    | -10.49 |     -10 |  -10.5 |     -10 | -10.51 |     -11 |
    |  -9.49 |      -9 |   -9.5 |     -10 |  -9.51 |     -10 | 
    

我们已经表明,C# 的四舍五入规则与小学规则微妙地不同:

  • 如果小数部分小于中点 .5,它总是会向零四舍五入。

  • 如果小数部分大于中点 .5,它总是会远离零进行四舍五入。

  • 如果小数部分是中点 .5 且非小数部分是奇数,则它会远离零四舍五入,但如果非小数部分是偶数,则它会向零四舍五入。

这个规则被称为银行家四舍五入法,因为它通过交替向零或远离零进行四舍五入来减少偏差,因此更受欢迎。遗憾的是,其他语言如 JavaScript 使用的是小学规则。

控制四舍五入规则

您可以通过使用 Math 类的 Round 方法来控制四舍五入规则:

  1. 使用“远离零”四舍五入规则(也称为向上四舍五入)编写语句来四舍五入每个 double 类型的值,然后将结果写入控制台,如下面的代码所示:

    foreach (double n in doubles)
    {
      WriteLine(format:
        "Math.Round({0}, 0, MidpointRounding.AwayFromZero) is {1}",
        arg0: n,
        arg1: Math.Round(value: n, digits: 0,
                mode: MidpointRounding.AwayFromZero));
    } 
    

    您可以使用 foreach 语句来枚举多维数组中的所有项。

  2. 运行代码并查看结果,如下面的部分输出所示:

    Math.Round(9.49, 0, MidpointRounding.AwayFromZero) is 9
    Math.Round(9.5, 0, MidpointRounding.AwayFromZero) is 10
    Math.Round(9.51, 0, MidpointRounding.AwayFromZero) is 10
    Math.Round(10.49, 0, MidpointRounding.AwayFromZero) is 10
    Math.Round(10.5, 0, MidpointRounding.AwayFromZero) is 11
    Math.Round(10.51, 0, MidpointRounding.AwayFromZero) is 11
    ... 
    

良好实践:对于您使用的每种编程语言,检查其四舍五入规则。它们可能不会按您预期的那样工作!您可以在以下链接中了解更多关于 Math.Round 的信息:learn.microsoft.com/en-us/dotnet/api/system.math.round

从任何类型转换为字符串

最常见的转换是将任何类型转换为 string 变量,以便作为可读文本输出,因此所有类型都有一个名为 ToString 的方法,它们从 System.Object 类继承。

ToString 方法将任何变量的当前值转换为文本表示形式。某些类型无法合理地表示为文本,因此它们返回它们的命名空间和类型名称。

让我们将一些类型转换为 string

  1. 编写语句来声明一些变量,将它们转换为它们的 string 表示形式,并将它们写入控制台,如下面的代码所示:

    int number = 12;
    WriteLine(number.ToString());
    bool boolean = true;
    WriteLine(boolean.ToString());
    DateTime now = DateTime.Now;
    WriteLine(now.ToString());
    object me = new();
    WriteLine(me.ToString()); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    12
    True
    08/28/2024 17:33:54
    System.Object 
    

将任何对象传递给WriteLine方法会隐式地将其转换为string,因此不需要显式调用ToString。我们在这里这样做只是为了强调正在发生的事情。显式调用ToString可以避免装箱操作,所以如果你正在使用 Unity 开发游戏,那么这可以帮助你避免内存垃圾回收问题。

从二进制对象转换为字符串

当你有一个像图像或视频这样的二进制对象,你想要存储或传输时,有时你不想发送原始位,因为你不知道这些位可能会被误解,例如,由传输它们的网络协议或读取存储的二进制对象的另一个操作系统。

做最安全的事情是将二进制对象转换为安全字符的字符串。程序员称这种编码为Base64编码。Base64 是一种编码方案,它使用一组特定的 64 个字符将任意字节转换为文本。它在数据传输中得到了广泛的应用,并且长期以来通过各种方法得到了支持。

Convert类型有一对方法,ToBase64StringFromBase64String,为你执行这种转换。让我们看看它们在实际中的应用:

  1. 使用类型语句创建一个随机填充字节值的字节数组,将每个字节格式化地写入控制台,然后将相同的字节转换为 Base64 写入控制台,如下面的代码所示:

    // Allocate an array of 128 bytes.
    byte[] binaryObject = new byte[128];
    // Populate the array with random bytes.
    Random.Shared.NextBytes(binaryObject);
    WriteLine("Binary Object as bytes:");
    for (int index = 0; index < binaryObject.Length; index++)
    {
      Write($"{binaryObject[index]:X2} ");
    }
    WriteLine();
    // Convert the array to Base64 string and output as text.
    string encoded = ToBase64String(binaryObject);
    WriteLine($"Binary Object as Base64: {encoded}"); 
    

默认情况下,int值会以十进制表示输出,即 Base10。你可以使用如:X2这样的格式代码来使用十六进制表示格式化值。

  1. 运行代码并查看结果,如下面的输出所示:

    Binary Object as bytes:
    EB 53 8B 11 9D 83 E6 4D 45 85 F4 68 F8 18 55 E5 B8 33 C9 B6 F4 00 10 7F CB 59 23 7B 26 18 16 30 00 23 E6 8F A9 10 B0 A9 E6 EC 54 FB 4D 33 E1 68 50 46 C4 1D 5F B1 57 A1 DB D0 60 34 D2 16 93 39 3E FA 0B 08 08 E9 96 5D 64 CF E5 CD C5 64 33 DD 48 4F E8 B0 B4 19 51 CA 03 6F F4 18 E3 E5 C7 0C 11 C7 93 BE 03 35 44 D1 6F AA B0 2F A9 CE D5 03 A8 00 AC 28 8F A5 12 8B 2E BE 40 C4 31 A8 A4 1A
    Binary Object as Base64: 61OLEZ2D5k1FhfRo+BhV5bgzybb0ABB/y1kjeyYYFjAAI+aPqRCwqebsVPtNM+FoUEbEHV+xV6Hb0GA00haTOT76CwgI6ZZdZM/lzcVkM91IT+iwtBlRygNv9Bjj5ccMEceTvgM1RNFvqrAvqc7VA6gArCiPpRKLLr5AxDGopBo= 
    

URL 中的 Base64

Base64 很有用,但它使用的某些字符,如+/,对于某些用途来说是有问题的,例如 URL 中的查询字符串,在这些字符中具有特殊含义。

为了解决这个问题,创建了 Base64Url 方案。它与 Base64 类似,但使用了一组略微不同的字符,使其适合像 URL 这样的上下文。

更多信息:你可以在以下链接中了解更多关于 Base64Url 方案的信息:base64.guru/standards/base64url

.NET 9 引入了新的Base64Url类,它提供了一系列针对使用Base64Url方案进行编码和解码数据的优化方法。例如,你可以将一些任意的字节转换为 Base64Url,如下面的代码所示:

ReadOnlySpan<byte> bytes = ...;
string encoded = Base64Url.EncodeToString(bytes); 

从字符串解析到数字或日期和时间

第二种最常见的转换是从字符串到数字或日期和时间值。

ToString的反向操作是Parse。只有少数类型有Parse方法,包括所有数字类型和DateTime

让我们看看Parse的实际应用:

  1. Program.cs的顶部,导入用于处理文化的命名空间,如下面的代码所示:

    using System.Globalization; // To use CultureInfo. 
    
  2. Program.cs的底部添加语句,从字符串中解析整数和日期时间值,然后将结果写入控制台,如下面的代码所示:

    // Set the current culture to make sure date parsing works.
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
    int friends = int.Parse("27");
    DateTime birthday = DateTime.Parse("4 June 1980");
    WriteLine($"I have {friends} friends to invite to my party.");
    WriteLine($"My birthday is {birthday}.");
    WriteLine($"My birthday is {birthday:D}."); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    I have 27 friends to invite to my party.
    My birthday is 6/4/1980 12:00:00 AM.
    My birthday is Wednesday, June 4, 1980. 
    

默认情况下,日期和时间值以短日期时间格式输出。你可以使用如D之类的格式代码,仅使用长日期格式输出日期部分。

良好实践:使用标准的日期和时间格式说明符,如下面的链接所示:learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings#table-of-format-specifiers

通过使用TryParse方法避免解析异常

Parse方法的一个问题是,如果string无法转换,它会报错:

  1. 输入一个尝试将包含字母的字符串解析为整型变量的语句,如下面的代码所示:

    int count = int.Parse("abc"); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Unhandled Exception: System.FormatException: Input string was not in a correct format. 
    

除了前面的异常信息,你还会看到一个堆栈跟踪。我没有在这本书中包含堆栈跟踪,因为它们会占用太多空间。

为了避免错误,你可以使用TryParse方法。TryParse尝试将输入的string转换为其他类型,如果可以转换则返回true,如果不能转换则返回false。异常操作相对昂贵,因此应尽可能避免。

out关键字是必需的,以便在转换成功时允许TryParse方法设置count变量。

让我们看看TryParse的实际应用:

  1. int count声明替换为使用TryParse方法的语句,并提示用户输入鸡蛋数量的计数,如下面的代码所示:

    Write("How many eggs are there? ");
    string? input = ReadLine();
    if (int.TryParse(input, out int count))
    {
      WriteLine($"There are {count} eggs.");
    }
    else
    {
      WriteLine("I could not parse the input.");
    } 
    
  2. 运行代码,输入12,查看结果,如下面的输出所示:

    How many eggs are there? 12
    There are 12 eggs. 
    
  3. 运行代码,输入twelve,查看结果,如下面的输出所示:

    How many eggs are there? twelve
    I could not parse the input. 
    

你还可以使用System.Convert类型的方法将string值转换为其他类型;然而,与Parse方法一样,如果无法转换,它也会报错。

理解Try方法命名约定

.NET 为遵循Try命名约定的所有方法使用标准签名。对于任何名为Something且返回特定类型值的方法,其对应的TrySomething方法必须返回bool以指示成功或失败,并使用out参数代替返回值。例如:

// A method that might throw an exception.
int number = int.Parse("123");
// The Try equivalent of the method.
bool success = int.TryParse("123", out int number);
// Trying to create a Uri for a Web API.
bool success = Uri.TryCreate("https://localhost:5000/api/customers",
  UriKind.Absolute, out Uri serviceUrl); 

处理异常

你已经看到了在类型转换过程中发生错误的一些场景。一些语言在出错时会返回错误代码。.NET 使用更丰富且仅用于错误报告的异常。当这种情况发生时,我们说抛出了一个运行时异常

其他系统可能会使用具有多种用途的返回值。例如,如果返回值是正数,它可能代表表中的行数;如果返回值是负数,它可能代表某些错误代码。

一些第三方库使得定义可以指示错误以及成功的“结果”类型变得更加容易。许多.NET 开发者更喜欢使用它们而不是抛出异常。你可以在本章末尾的选读在线部分了解更多关于此内容。

当抛出异常时,线程将被挂起,如果调用代码已定义try-catch语句,则它将有机会处理该异常。如果当前方法没有处理它,则其调用方法将有机会,依此类推,沿着调用堆栈向上。

正如你所看到的,控制台应用程序的默认行为是输出有关异常的消息,包括堆栈跟踪,然后停止运行代码。应用程序被终止。这比允许代码在可能损坏的状态下继续执行要好。你的代码应该只捕获并处理它理解并能正确修复的异常。

良好实践:尽可能避免编写会抛出异常的代码,可能通过执行if语句检查来实现。有时你无法这样做,有时最好让调用你的代码的高级组件捕获异常。你将在第四章编写、调试和测试函数中学习如何做到这一点。

在.NET 9 中,异常处理使用基于 NativeAOT 异常处理模型的新实现。这通过.NET 团队的基准测试将异常处理性能提高了 2 到 4 倍。

try块中包裹容易出错的代码

当你知道一个语句可能会引发错误时,你应该将该语句包裹在try块中。例如,从文本解析到数字可能会引发错误。只有当try块中的语句抛出异常时,catch块中的任何语句才会执行。

我们不需要在catch块内部做任何事情。让我们看看它是如何实际工作的:

  1. 使用你喜欢的代码编辑器,将一个新的控制台应用程序/console项目命名为HandlingExceptions添加到Chapter03解决方案中。

  2. Program.cs中,删除任何现有语句,然后输入提示用户输入他们的年龄并将他们的年龄写入控制台的语句,如下面的代码所示:

    WriteLine("Before parsing");
    Write("What is your age? ");
    string? input = ReadLine();
    try
    {
      int age = int.Parse(input);
      WriteLine($"You are {age} years old.");
    }
    catch
    {
    }
    WriteLine("After parsing"); 
    

你将看到以下编译器消息:警告 CS8604 在 'int int.Parse(string s)' 中参数 's' 的可能为 null 的引用参数

默认情况下,在.NET 6 或更高版本的项目中,Microsoft 启用了可空引用类型,因此你会看到更多这样的编译器警告。在生产代码中,你应该添加代码来检查null并适当地处理这种情况,如下面的代码所示:

if (input is null)
{
  WriteLine("You did not enter a value so the app has ended.");
  return; // Exit the app.
} 

在这本书中,我不会每次都给出添加这些null检查的指令,因为代码示例不是设计成生产质量的,到处都有null检查会使得代码杂乱无章,并占用宝贵的页面。

你可能会在这本书的代码示例中看到数百个可能为null的变量的例子。对于书中的代码示例,这些警告可以安全忽略。你只需要在你自己的生产代码中注意类似的警告。你将在第六章实现接口和继承类中了解更多关于null处理的内容。

在这种情况下,input不可能为null,因为用户必须按Enter键,ReadLine才会返回,如果他们当时没有输入任何字符,则ReadLine方法将返回一个空字符串。让我们告诉编译器它不需要显示这个警告:

  1. 要禁用编译器警告,将input改为input!,如下所示高亮代码:

    int age = int.Parse(input**!**); 
    

表达式后的感叹号!称为null- forgiving operator,它禁用编译器警告。null- forgiving operator在运行时没有效果。如果表达式在运行时可能评估为null,可能是因为我们以另一种方式分配了它,那么将抛出异常。

此代码包含两个消息,用于指示在解析之前和解析之后,以使代码的流程更清晰。当示例代码变得更加复杂时,这些将特别有用。

  1. 运行代码,输入49,查看以下输出结果:

    Before parsing
    What is your age? 49
    You are 49 years old.
    After parsing 
    
  2. 运行代码,输入Kermit,查看以下输出结果:

    Before parsing
    What is your age? Kermit
    After parsing 
    

当代码执行时,捕获了错误异常,没有输出默认消息和堆栈跟踪,控制台应用程序继续运行。这比默认行为要好,但可能需要查看发生的错误类型。

良好实践:你永远不应该在生产代码中使用这样的空catch语句,因为它“吞没”异常并隐藏潜在问题。如果你不能或不想正确处理它,至少记录异常,或者重新抛出它,以便高级代码可以决定。你将在第四章编写、调试和测试函数中学习关于日志记录的内容。

捕获所有异常

要获取有关可能发生的任何类型异常的信息,你可以在catch块中声明一个类型为System.Exception的变量:

  1. catch块中添加一个异常变量声明,并使用它将异常信息写入控制台,如下所示代码:

    catch (Exception ex)
    {
      WriteLine($"{ex.GetType()} says {ex.Message}");
    } 
    
  2. 运行代码,再次输入Kermit,查看以下输出结果:

    Before parsing
    What is your age? Kermit
    System.FormatException says Input string was not in a correct format.
    After parsing 
    

捕获特定异常

现在我们知道了发生了哪种具体的异常类型,我们可以通过只捕获这种类型的异常并自定义显示给用户的消息来改进我们的代码。您可以将此视为一种测试形式:

  1. 留下现有的catch块,在其上方添加一个新的catch块用于格式异常类型,如下所示的高亮代码:

    **catch** **(FormatException)**
    **{**
     **WriteLine(****"****The age you entered is not a valid number format."****);**
    **}**
    catch (Exception ex)
    {
      WriteLine($"{ex.GetType()} says {ex.Message}");
    } 
    
  2. 运行代码,再次输入Kermit,查看结果,如下所示输出:

    Before parsing
    What is your age? Kermit
    The age you entered is not a valid number format.
    After parsing 
    

我们想要留下更通用的catch块的原因是可能还有其他类型的异常会发生。

  1. 运行代码,输入9876543210,查看结果,如下所示输出:

    Before parsing
    What is your age? 9876543210
    System.OverflowException says Value was either too large or too small for an Int32.
    After parsing 
    

让我们为这种类型的异常添加另一个catch块。

  1. 留下现有的catch块,并添加一个新的catch块用于溢出异常类型,如下所示的高亮代码:

    **catch** **(OverflowException)**
    **{**
     **WriteLine(****"Your age is a valid number format but it is either too big or small."****);**
    **}**
    catch (FormatException)
    {
      WriteLine("The age you entered is not a valid number format.");
    } 
    
  2. 运行代码,输入9876543210,查看结果,如下所示输出:

    Before parsing
    What is your age? 9876543210
    Your age is a valid number format but it is either too big or small.
    After parsing 
    

捕获异常的顺序很重要。正确的顺序与异常类型的继承层次结构相关。您将在第五章使用面向对象编程创建自己的类型中学习关于继承的内容。然而,不必过于担心这一点——如果您的异常顺序错误,编译器会给出构建错误。

良好实践:避免过度捕获异常。它们通常应该允许向上传播调用堆栈,以便在更了解可能导致它们处理逻辑变化的情境的级别进行处理。您将在第四章编写、调试和测试函数中学习这一点。

带过滤器的捕获

您还可以使用when关键字向catch语句添加过滤器,如下所示代码:

Write("Enter an amount: ");
string amount = ReadLine()!;
if (string.IsNullOrEmpty(amount)) return;
try
{
  decimal amountValue = decimal.Parse(amount);
  WriteLine($"Amount formatted as currency: {amountValue:C}");
}
catch (FormatException) when (amount.Contains('$'))
{
  WriteLine("Amounts cannot use the dollar sign!");
}
catch (FormatException)
{
  WriteLine("Amounts must only contain digits!");
} 

良好实践string类型的Contains方法对使用双引号传递的string值和使用单引号传递的char值都有重载。当您想检查一个字符,如美元符号时,使用前面代码中的char重载会更有效。

检查溢出

之前我们了解到,在数字类型之间进行转换时可能会丢失信息,例如,将long变量转换为int变量时。如果存储在类型中的值太大,就会发生溢出。

使用checked语句抛出溢出异常

checked语句告诉.NET 在发生溢出时抛出异常,而不是允许它静默发生,这是默认的做法,出于性能考虑。

我们将一个int变量的初始值设置为最大值减一。然后,我们将它增加几次,每次输出其值。一旦它超过最大值,它就会溢出到最小值,并从那里继续增加。

让我们看看实际操作:

  1. Program.cs中,输入语句来声明并分配一个整数,其值小于其可能的最大值,然后递增它并将它的值写入控制台三次,如下面的代码所示:

    int x = int.MaxValue - 1;
    WriteLine($"Initial value: {x}");
    x++;
    WriteLine($"After incrementing: {x}");
    x++;
    WriteLine($"After incrementing: {x}");
    x++;
    WriteLine($"After incrementing: {x}"); 
    
  2. 运行代码并查看以下输出结果,它显示了值静默溢出并环绕到大的负值,如图所示:

    Initial value: 2147483646
    After incrementing: 2147483647
    After incrementing: -2147483648
    After incrementing: -2147483647 
    
  3. 现在,让我们通过使用checked语句块包裹语句来让编译器警告我们关于溢出,如下面的代码所示:

    **checked**
    **{**
      int x = int.MaxValue - 1;
      WriteLine($"Initial value: {x}");
      x++;
      WriteLine($"After incrementing: {x}");
      x++;
      WriteLine($"After incrementing: {x}");
      x++;
      WriteLine($"After incrementing: {x}");
    **}** 
    
  4. 运行代码并查看以下输出结果,它显示了溢出被检查并导致抛出异常,如图所示:

    Initial value: 2147483646
    After incrementing: 2147483647
    Unhandled Exception: System.OverflowException: Arithmetic operation resulted in an overflow. 
    
  5. 就像任何其他异常一样,我们应该将这些语句包裹在try语句块中,并为用户提供更友好的错误消息,如下面的代码所示:

    try
    {
      // previous code goes here
    }
    catch (OverflowException)
    {
      WriteLine("The code overflowed but I caught the exception.");
    } 
    
  6. 运行代码并查看以下输出结果:

    Initial value: 2147483646
    After incrementing: 2147483647
    The code overflowed but I caught the exception. 
    

使用unchecked语句禁用编译器溢出检查

前一节是关于运行时默认溢出行为以及如何使用checked语句来改变这种行为。本节是关于编译时溢出行为以及如何使用unchecked语句来改变这种行为。

一个相关的关键字是unchecked。这个关键字关闭了编译器在代码块内执行的溢出检查。让我们看看如何做到这一点:

  1. 在上一条语句的末尾输入以下语句。编译器将不会编译此语句,因为它知道它将溢出:

    int y = int.MaxValue + 1; 
    
  2. 将鼠标指针悬停在错误上,注意编译时检查会显示为错误消息,如图3.4所示:

图 3.4:整数溢出的编译时检查

  1. 要禁用编译时检查,将语句包裹在unchecked块中,将y的值写入控制台,递减它,并重复,如下面的代码所示:

    **unchecked**
    **{**
      int y = int.MaxValue + 1;
     **WriteLine(****$"Initial value:** **{y}****"****);**
     **y--;**
     **WriteLine(****$"After decrementing:** **{y}****"****);**
     **y--;**
     **WriteLine(****$"After decrementing:** **{y}****"****);**
    **}** 
    
  2. 运行代码并查看以下输出结果:

    Initial value: -2147483648
    After decrementing: 2147483647
    After decrementing: 2147483646 
    

当然,你很少会想明确地关闭这种检查,因为这会导致溢出发生。但也许你可以想到一个你可能想要这种行为的情况。

练习和探索

通过回答一些问题、进行一些实际操作,并深入探索本章的主题来测试你的知识和理解。

练习 3.1 – 网络材料

网络材料可以是我为这本书写的额外内容,也可以是引用微软或第三方创建的内容。

返回结果类型与抛出异常

在.NET 编程中,错误处理通常围绕两个主要机制:异常和结果类型。通过阅读以下在线部分了解更多关于结果类型的信息:

github.com/markjprice/cs13net9/blob/main/docs/ch03-result-types.md

C# 101 笔记本

使用以下链接中的笔记本和视频查看使用 Polyglot Notebooks 的 C# 交互式示例:

github.com/dotnet/csharp-notebooks#c-101

练习 3.2 – 实践练习

实践练习深入探讨了本章的主题。

循环和溢出

如果此代码执行,会发生什么?

int max = 500;
for (byte i = 0; i < max; i++)
{
  WriteLine(i);
} 

Chapter03 中创建一个名为 Exercise_LoopsAndOverflow 的控制台应用程序并输入前面的代码。运行控制台应用程序并查看输出。会发生什么?

你可以添加什么代码(不要更改前面的任何代码)来警告我们问题?

实践循环和运算符

FizzBuzz 是一个儿童分组游戏,用于教授他们除法。玩家轮流递增计数,将任何能被 3 整除的数字替换为单词 fizz,任何能被 5 整除的数字替换为单词 buzz,同时能被两者整除的替换为 fizzbuzz

Chapter03 中创建一个名为 Exercise_FizzBuzz 的控制台应用程序,输出一个模拟的 FizzBuzz 游戏,该游戏计数到 100。输出应类似于 图 3.5

计算机屏幕截图  描述自动生成

图 3.5:模拟的 FizzBuzz 游戏输出

实践异常处理

Chapter03 中创建一个名为 Exercise_Exceptions 的控制台应用程序,要求用户输入 0-255 范围内的两个数字,然后除以第二个数字:

Enter a number between 0 and 255: 100
Enter another number between 0 and 255: 8
100 divided by 8 is 12 

编写异常处理程序来捕获任何抛出的错误,如下所示输出:

Enter a number between 0 and 255: apples
Enter another number between 0 and 255: bananas
FormatException: Input string was not in a correct format. 

练习 3.3 – 测试你的知识

回答以下问题:

  1. 当你将一个 int 变量除以 0 时会发生什么?

  2. 当你将一个 double 变量除以 0 时会发生什么?

  3. 当你溢出一个 int 变量时(即将其设置为超出其范围的值)会发生什么?

  4. x = y++;x = ++y; 之间的区别是什么?

  5. 当在循环语句中使用时,breakcontinuereturn 之间的区别是什么?

  6. for 语句的三个部分是什么,哪部分是必需的?

  7. === 运算符之间的区别是什么?

  8. 以下语句能否编译?

    for ( ; ; ) ; 
    
  9. 下划线 (_) 在 switch 表达式中代表什么?

  10. 为了使用 foreach 语句枚举对象,该对象必须“实现”什么接口?

测试你对运算符的知识

执行以下语句后,xy 的值是什么?在 Chapter03 中创建一个名为 Ch03Ex03Operators 的控制台应用程序来测试你的假设:

  1. 增量和加法运算符:

    x = 3;
    y = 2 + ++x; 
    
  2. 二进制移位运算符:

    x = 3 << 2;
    y = 10 >> 1; 
    
  3. 位运算符:

    x = 10 & 8;
    y = 10 | 7; 
    

练习 3.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-3---controlling-flow-converting-types-and-handling-exceptions

概述

在本章中,你学习了以下内容:

  • 使用运算符执行简单任务。

  • 使用分支和循环语句实现逻辑。

  • 处理单维和多维数组。

  • 在不同类型之间进行转换。

  • 捕获异常并处理整数溢出。

现在,你已准备好学习如何通过定义函数来重用代码块,如何将值传递给它们并从中获取值,以及如何使用调试和测试工具追踪代码中的错误并将它们消除!

第四章:编写、调试和测试函数

本章是关于编写函数以重用代码,在开发过程中调试逻辑错误,对代码进行单元测试以移除错误,并提高稳定性和可靠性。

在一个可选的在线部分,你将学习如何在运行时记录异常。你可以在本章末尾找到相关信息。

本章涵盖了以下主题:

  • 编写函数

  • 开发过程中的调试

  • 开发过程中的热重载

  • 单元测试

  • 在函数中抛出和捕获异常

编写函数

编程的一个基本原则是不要重复自己DRY)。

在编程过程中,如果你发现自己反复编写相同的语句,那么将这些语句转换成一个函数。函数就像微小的程序,完成一项小任务。例如,你可能编写一个函数来计算销售税,然后在财务应用的许多地方重用该函数。

与程序一样,函数通常有输入和输出。它们有时被描述为黑盒,你在一边输入一些原材料,在另一边就出现了一个制造品。一旦创建并彻底调试和测试,你就不需要考虑它们是如何工作的。

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

第一章Hello, C#! 欢迎使用 .NET!,我们了解到自 C# 10 和 .NET 6 以来,控制台应用的默认项目模板使用了 C# 9 中引入的顶级程序功能。

一旦开始编写函数,了解它们如何与自动生成的 Program 类及其 <Main>$ 方法协同工作就很重要。

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

  1. 使用你喜欢的代码编辑器创建一个新的解决方案和项目,如下列表所示:

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

    • 项目文件和文件夹TopLevelFunctions

    • 解决方案文件和文件夹Chapter04

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

    • 启用原生 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");
    } 
    

    良好实践:函数不需要放在文件的底部,但将它们与其他顶级语句混合并不是一个好的实践。类型,如类,必须Program.cs 文件的底部声明,而不是在文件中间,否则你将看到编译器错误 CS8803,如下链接所示:learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/cs8803。最好在单独的文件中定义类型,如类。

  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 注释来文档化它们。

如果包含代码的文件是.NET SDK 或其他生成此代码的工具,则该代码需要在一个编译器会在文件系统中找到并编译的源代码文件中。因为此代码是由编译器本身生成的,所以不需要源代码文件。发现编译器做了什么唯一的方法是使用反编译器对程序集进行逆向工程以获取原始代码。您也可以在函数和方法中抛出异常以查看一些信息,正如我在第一章你好,C#! 欢迎使用,.NET!中展示的那样。

您将看到一些 C#关键字,如staticpartial,这些将在第五章使用面向对象编程构建自己的类型中正式介绍。

定义具有静态函数的partial Program

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

  1. 添加一个名为Program.Functions.cs的新类文件。这个文件名实际上并不重要,但使用这种命名约定是合理的。您可以将文件命名为Gibberish.cs,它会有相同的行为。

  2. Program.Functions.cs中,删除任何现有语句,然后添加语句来定义一个partial Program类。将WhatsMyNamespace函数剪切并粘贴到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类,并将您的函数作为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");
  }
**}** 

解决方案资源管理器显示,您的Program.Functions.cs类文件将其partial Program与自动生成的partial Program类合并,如图4.1所示:

图 4.1:解决方案资源管理器显示合并的 partial Program

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

重要的是要注意缺少命名空间声明。自动生成的 Program 类和显式定义的 Program 类都在默认的 null 命名空间中。

警告!不要为你的 partial Program 类定义命名空间。如果你这样做,它将位于不同的命名空间中,因此不会与自动生成的 partial Program 类合并。

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

乘法表示例

假设你想帮助你的孩子学习乘法表,因此你想使其容易生成一个数字的乘法表,例如 7 的乘法表:

1 x 7 = 7
2 x 7 = 14
3 x 7 = 21
...
10 x 7 = 70
11 x 7 = 77
12 x 7 = 84 

大多数乘法表都有 10、12 或 20 行,这取决于孩子的学习程度。

你在本章中已经学习了 for 语句,所以你知道它可以在存在常规模式时用于生成重复的输出行,例如 12 行的 7 乘法表,如下所示:

for (int row = 1; row <= 12; row++)
{
  Console.WriteLine($"{row} x 7 = {row * 7}");
} 

然而,我们不想总是输出 12 行的 7 乘法表,我们希望使其更加灵活,以便可以输出任何大小和任何数字的乘法表。我们可以通过创建一个函数来实现这一点。

让我们通过创建一个函数来探索函数,该函数可以输出任何大小(最多 255 行,但默认为 12 行)的 0 到 255 的任意数字的乘法表:

  1. 使用你喜欢的代码编辑器创建一个新项目,如下所示:

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

    • 项目文件和文件夹:WritingFunctions

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

    • 在 Visual Studio 中,将解决方案的启动项目设置为当前选择。

  2. WritingFunctions.csproj 中,在 <PropertyGroup> 部分之后,添加一个新的 <ItemGroup> 部分以静态导入 System.Console,为所有使用隐式使用 .NET SDK 功能的 C# 文件,如下所示:

    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    
  3. 向项目中添加一个名为 Program.Functions.cs 的新类文件。

  4. Program.Functions.cs 中,用以下代码替换任何现有的代码,以在 partial Program 类中定义一个名为 TimesTable 的函数,如下所示:

    partial class Program
    {
      static void TimesTable(byte number, byte size = 12)
      {
        WriteLine($"This is the {number} times table with {size} rows:");
        WriteLine();
        for (int row = 1; row <= size; row++)
        {
          WriteLine($"{row} x {number} = {row * number}");
        }
        WriteLine();
      }
    } 
    

在前面的代码中,请注意以下内容:

  • TimesTable 必须有一个名为 numberbyte 值作为参数传递。

  • TimesTable 可选地可以有一个名为 sizebyte 值作为参数传递。如果没有传递值,则默认为 12

  • TimesTable是一个static方法,因为它将由static方法<Main>$调用。

  • TimesTable不向调用者返回值,因此它使用void关键字在名称之前声明。

  • TimesTable使用for语句输出传递给它的number的乘法表,其行数等于size

  1. Program.cs中,删除现有的语句,然后调用该函数。为number参数传递一个byte值,例如7,如下面的代码所示:

    TimesTable(7); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    This is the 7 times table with 12 rows:
    1 x 7 = 7
    2 x 7 = 14
    3 x 7 = 21
    4 x 7 = 28
    5 x 7 = 35
    6 x 7 = 42
    7 x 7 = 49
    8 x 7 = 56
    9 x 7 = 63
    10 x 7 = 70
    11 x 7 = 77
    12 x 7 = 84 
    
  3. size参数设置为20,如下面的代码所示:

    TimesTable(7, 20); 
    
  4. 运行控制台应用程序并确认现在的时间表有 20 行。

良好实践:如果一个函数有一个或多个参数,仅传递值可能不足以提供足够的意义,那么你可以选择性地指定参数的名称以及其值,如下面的代码所示:TimesTable(number: 7, size: 10)

  1. 将传递给TimesTable函数的数字更改为0255之间的其他byte值,并确认输出的乘法表是正确的。

  2. 注意,如果你尝试传递一个非byte的数字,例如intdoublestring,将会返回一个错误,如下面的输出所示:

    Error: (1,12): error CS1503: Argument 1: cannot convert from 'int' to 'byte' 
    

关于参数和参数的简要说明

在日常使用中,大多数开发者会将术语参数参数互换使用。严格来说,这两个术语有具体和细微的区别。但就像一个人可以是父母和医生一样,这两个术语通常适用于同一事物。

参数是函数定义中的一个变量。例如,startDateHire函数的参数,如下面的代码所示:

void Hire(DateTime startDate)
{
  // Function implementation.
} 

当调用一个方法时,参数是你传递给方法参数的数据。例如,when是一个作为参数传递给Hire函数的变量,如下面的代码所示:

DateTime when = new(year: 2024, month: 11, day: 5);
Hire(when); 

你可能更喜欢在传递参数时指定参数名称,如下面的代码所示:

DateTime when = new(year: 2024, month: 11, day: 5);
Hire(startDate: when); 

当谈论对Hire函数的调用时,startDate是参数,而when是参数。

如果你阅读官方的 Microsoft 文档,他们会交替使用命名和可选参数命名和可选参数这两个短语,如下面的链接所示:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/named-and-optional-arguments

这变得复杂,因为单个对象可以根据上下文同时作为参数和参数。例如,在Hire函数的实现中,startDate参数可以作为参数传递给另一个函数,如SaveToDatabase,如下面的代码所示:

void Hire(DateTime startDate)
{
  ...
  SaveToDatabase(startDate, employeeRecord);
  ...
} 

命名事物是计算中最困难的部分之一。一个经典的例子是 C# 中最重要的函数 Main 的参数。它定义了一个名为 args 的参数,代表参数,如下所示:

static void Main(String[] args)
{
  ...
} 

总结来说,参数定义了函数的输入;当调用函数时,将参数传递给函数。

良好实践:根据上下文尽量使用正确的术语,但如果其他开发者“误用”术语,不要过于拘泥。我在这本书中可能已经使用了成千上万次的“参数”和“参数”这两个术语。我确信有些时候我可能不够精确。请不要因此@我。

编写返回值的函数

前一个函数执行了操作(循环和写入控制台),但没有返回值。假设你需要计算销售额或增值税VAT)。在欧洲,增值税率可以从瑞士的 8% 到匈牙利的 27% 不等。在美国(US),州销售税率可以从俄勒冈州的 0% 到加利福尼亚州的 8.25% 不等。

税率随时变化,并且根据许多因素而有所不同。本例中使用的值不需要非常准确。

让我们实现一个用于计算世界各地税收的函数:

  1. Program.Functions.cs 文件中,在 Program 类中,编写一个名为 CalculateTax 的函数,如下所示:

    static decimal CalculateTax(
      decimal amount, string twoLetterRegionCode)
    {
      decimal rate = twoLetterRegionCode switch
      {
        "CH" => 0.08M, // Switzerland
        "DK" or "NO" => 0.25M, // Denmark, Norway
        "GB" or "FR" => 0.2M, // UK, France
        "HU" => 0.27M, // Hungary
        "OR" or "AK" or "MT" => 0.0M, // Oregon, Alaska, Montana
        "ND" or "WI" or "ME" or "VA" => 0.05M,
        "CA" => 0.0825M, // California
        _ => 0.06M // Most other states.
      };
      return amount * rate;
    } 
    

在前面的代码中,请注意以下内容:

  • CalculateTax 有两个输入:一个名为 amount 的参数,它将是花费的金额,以及一个名为 twoLetterRegionCode 的参数,它将是花费的区域。

  • CalculateTax 将使用 switch 表达式执行计算,然后以 decimal 类型的值返回应缴纳的销售额税或增值税;因此,在函数名称之前,我们已声明返回值的类型为 decimal

  1. Program.Functions.cs 文件的顶部,导入用于处理文化的命名空间,如下所示:

    using System.Globalization; // To use CultureInfo. 
    
  2. Program.Functions.cs 文件中,在 Program 类中,编写一个名为 ConfigureConsole 的函数,如下所示:

    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}");
    } 
    

此函数为控制台输出启用 UTF-8 编码。这是输出一些特殊符号(如欧元货币符号)所必需的。此函数还控制用于格式化日期、时间和货币值的当前文化。

  1. Program.cs 文件中,注释掉任何 TimesTable 方法调用,然后调用 ConfigureConsole 方法以及 CalculateTax 方法,传递金额值,例如 149,以及有效的区域代码,例如 FR,如下所示:

    // TimesTable(number: 7, size: 10);
    ConfigureConsole();
    decimal taxToPay = CalculateTax(amount: 149, twoLetterRegionCode: "FR");
    WriteLine($"You must pay {taxToPay:C} in tax.");
    // Alternatively, call the function in the interpolated string.
    // WriteLine($"You must pay {CalculateTax(amount: 149,
    //   twoLetterRegionCode: "FR"):C} in tax."); 
    
  2. 运行代码,查看结果,并注意它使用的是美国英语文化,这意味着货币为美元,如下所示:

    CurrentCulture: English (United States)
    You must pay $29.80 in tax. 
    
  3. Program.cs 文件中,将 ConfigureConsole 方法更改为使用您本地的计算机文化,如下所示:

    ConfigureConsole(useComputerCulture: true); 
    
  4. 运行代码,查看结果,并注意货币现在应显示为您的本地货币。例如,对我来说在英国,我会看到 £29.80,如下所示输出:

    CurrentCulture: English (United Kingdom)
    You must pay £29.80 in tax. 
    
  5. Program.cs 中,将 ConfigureConsole 方法更改为使用法语文化,如下所示代码:

    ConfigureConsole(culture: "fr-FR"); 
    
  6. 运行代码,查看结果,并注意货币现在应显示为欧元,这是在法国使用的,如下所示输出:

    CurrentCulture: French (France)
    You must pay 29,80 € in tax. 
    

你能想到 CalculateTax 函数编写中的任何问题吗?如果用户输入了如 frUK 这样的代码会发生什么?你如何重写该函数以改进它?使用 switch 语句 而不是 switch 表达式 会更清晰吗?

将数字从基数转换为序数

用于计数的数字称为 基数,例如,1、2 和 3,而用于排序的数字称为 序数,例如,1st、2nd 和 3rd。让我们创建一个函数将基数转换为序数:

  1. Program.Functions.cs 中,编写一个名为 CardinalToOrdinal 的函数,该函数将基数 uint 值转换为序数 string 值;例如,它将 uint1 转换为字符串值 "1st"uint2 转换为字符串值 "2nd",依此类推,如下所示代码:

    static string CardinalToOrdinal(uint number)
    {
      uint lastTwoDigits = number % 100;
      switch (lastTwoDigits)
      {
        case 11: // Special cases for 11th to 13th.
        case 12:
        case 13:
          return $"{number:N0}th";
        default:
          uint lastDigit = number % 10;
          string suffix = lastDigit switch
          {
            1 => "st",
            2 => "nd",
            3 => "rd",
            _ => "th"
          };
          return $"{number:N0}{suffix}";
      }
    } 
    

从前面的代码中,注意以下内容:

  • CardinalToOrdinal 有一个输入,一个名为 numberuint 类型参数,因为我们不希望允许负数,并且有一个输出:返回 string 类型的值。

  • 使用 switch 语句 处理 111213 的特殊情况。

  • 然后,使用 switch 表达式 处理所有其他情况:如果最后一位数字是 1,则使用 st 作为后缀;如果最后一位数字是 2,则使用 nd 作为后缀;如果最后一位数字是 3,则使用 rd 作为后缀;如果最后一位数字是其他任何数字,则使用 th 作为后缀。

  1. Program.Functions.cs 中,编写一个名为 RunCardinalToOrdinal 的函数,该函数使用 for 循环从 1 到 150,对每个数字调用 CardinalToOrdinal 函数,并将返回的 string 写入控制台,用空格字符分隔,如下所示代码:

    static void RunCardinalToOrdinal()
    {
      for (uint number = 1; number <= 150; number++)
      {
        Write($"{CardinalToOrdinal(number)} ");
      }
      WriteLine();
    } 
    
  2. Program.cs 中,注释掉 CalculateTax 语句并调用 RunCardinalToOrdinal 方法,如下所示代码:

    RunCardinalToOrdinal(); 
    
  3. 运行控制台应用程序并查看结果,如下所示输出:

    1st 2nd 3rd 4th 5th 6th 7th 8th 9th 10th 11th 12th 13th 14th 15th 16th 17th 18th 19th 20th 21st 22nd 23rd 24th 25th 26th ... 139th 140th 141st 142nd 143rd 144th 145th 146th 147th 148th 149th 150th 
    
  4. RunCardinalToOrdinal 函数中,将最大数更改为 1500

  5. 运行控制台应用程序并查看结果,如下所示输出:

    ... 1,480th 1,481st 1,482nd 1,483rd 1,484th 1,485th 1,486th 1,487th 1,488th 1,489th 1,490th 1,491st 1,492nd 1,493rd 1,494th 1,495th 1,496th 1,497th 1,498th 1,499th 1,500th 
    

使用递归计算阶乘

5 的阶乘是 120,因为阶乘是通过将起始数字乘以比它小一的数字,然后再乘以再小一的数字,依此类推,直到数字减少到 1 来计算的。一个例子如下:5 x 4 x 3 x 2 x 1 = 120。

阶乘函数仅对非负整数定义,即对 0、1、2、3 等等,其定义为:

0! = 1
n! = n × (n − 1)!, for n  { 1, 2, 3, ... } 

我们可以将拒绝负数的任务留给编译器,就像我们在 CardinalToOrdinal 函数中所做的那样,将输入参数声明为 uint,但这次,让我们看看另一种处理方式:抛出参数异常。

阶乘的表示方式为 5!,其中感叹号读作“bang”,所以 5! = 120,或者说 五 bang 等于一百二十。在阶乘的上下文中,“bang”是一个很好的术语,因为它们增长得非常快,就像爆炸一样。

我们将编写一个名为 Factorial 的函数;这将计算传递给它的 int 参数的阶乘。我们将使用一种称为 递归 的巧妙技术,它指的是在其实现中直接或间接调用自身的函数:

  1. Program.Functions.cs 文件中,编写一个名为 Factorial 的函数,如下面的代码所示:

    static int Factorial(int number)
    {
      if (number < 0)
      {
         throw new ArgumentOutOfRangeException(message:
           $"The factorial function is defined for non-negative integers only. Input: {number}",
           paramName: nameof(number));
      }
      else if (number == 0)
      {
        return 1;
      }
      else
      {
        return number * Factorial(number - 1);
      }
    } 
    

如前所述,前面的代码中有几个值得注意的元素,包括以下内容:

  • 如果输入参数 number 是负数,Factorial 会抛出异常。

  • 如果输入参数 number0Factorial 返回 1

  • 如果输入参数 number 大于 0(在其他所有情况下都会是这样),Factorial 会将数字乘以调用自身并传递 number 减一的返回结果。这使得函数是递归的。

更多信息:递归很巧妙,但它可能会导致问题,例如由于函数调用过多而导致的栈溢出,因为每次函数调用都会使用内存来存储数据,最终会使用太多。在 C# 等语言中,迭代是一个更实际、但不太简洁的解决方案。您可以在以下链接中了解更多信息:en.wikipedia.org/wiki/Recursion_(computer_science)#Recursion_versus_iteration

  1. Program.Functions.cs 文件中,编写一个名为 RunFactorial 的函数,该函数使用 for 循环输出从 1 到 15 的数字的阶乘,在其循环内部调用 Factorial 函数,然后输出结果,使用代码 N0 格式化,该代码指的是使用千位分隔符且没有小数位的数字格式,如下面的代码所示:

    static void RunFactorial()
    {
      for (int i = 1; i <= 15; i++)
      {
        WriteLine($"{i}! = {Factorial(i):N0}");
      }
    } 
    
  2. RunCardinalToOrdinal 方法的调用注释掉,并调用 RunFactorial 方法。

  3. 运行项目并查看结果,如下面的部分输出所示:

    1! = 1
    2! = 2
    3! = 6
    4! = 24
    ...
    12! = 479,001,600
    13! = 1,932,053,504
    14! = 1,278,945,280
    15! = 2,004,310,016 
    

在前面的输出中并不立即明显,但 13 及以上的阶乘会超出 int 类型的范围,因为它们非常大。12! 是 479,001,600,大约是五亿。可以存储在 int 变量中的最大正数值大约是二十亿。13! 是 6,227,020,800,大约是六十亿,当存储在 32 位整数中时,它会静默溢出而不会显示任何问题。

你应该做什么才能在溢出发生时收到通知?当然,我们可以通过使用 long(64 位整数)而不是 int(32 位整数)来解决 13!14! 的问题,但我们很快就会再次达到溢出限制。

本节的目的在于理解和展示数字可能会溢出,而不是具体讲解如何计算大于 12! 的阶乘。让我们来看一看:

  1. 修改 Factorial 函数,以便在自我调用的语句中检查溢出,如下所示,代码中已突出显示:

    **checked** **// for overflow**
    **{**
      return number * Factorial(number - 1);
    **}** 
    
  2. 修改 RunFactorial 函数,将起始数字改为 -2,并在调用 Factorial 函数时处理溢出和其他异常,如下所示,代码中已突出显示:

    static void RunFactorial()
    {
      for (int i = **-2**; i <= 15; i++)
      {
    **try**
     **{**
          WriteLine($"{i}! = {Factorial(i):N0}");
     **}**
    **catch** **(OverflowException)**
     **{**
     **WriteLine(****$"****{i}****! is too big for a 32-bit integer."****);**
     **}**
    **catch** **(Exception ex)**
     **{**
     **WriteLine(****$"****{i}****! throws** **{ex.GetType()}****:** **{ex.Message}****"****);**
     **}**
      }
    } 
    
  3. 运行代码并查看结果,如下所示的部分输出:

    -2! throws System.ArgumentOutOfRangeException: The factorial function is defined for non-negative integers only. Input: -2 (Parameter 'number')
    -1! throws System.ArgumentOutOfRangeException: The factorial function is defined for non-negative integers only. Input: -1 (Parameter 'number')
    0! = 1
    1! = 1
    2! = 2
    ...
    12! = 479,001,600
    13! is too big for a 32-bit integer.
    14! is too big for a 32-bit integer.
    15! is too big for a 32-bit integer. 
    

使用 XML 注释记录函数

默认情况下,当调用 CardinalToOrdinal 等函数时,代码编辑器会显示包含基本信息的工具提示。

让我们通过添加额外信息来改进工具提示:

  1. 如果你使用带有 C# 扩展的 VS Code,你应该导航到 视图 | 命令面板 | 首选项:打开设置(UI),然后搜索 formatOnType 并确保它已启用。C# XML 文档注释是 Visual Studio 和 Rider 的内置功能,因此您不需要做任何事情就可以使用它们。

  2. CardinalToOrdinal 函数上方的一行中,输入三个反斜杠 ///,并注意它们被扩展为识别函数有一个名为 number 的单个参数的 XML 注释,如下所示,代码中已突出显示:

    /// <summary>
    ///
    /// </summary>
    /// <param name="number"></param>
    /// <returns></returns> 
    
  3. CardinalToOrdinal 函数输入合适的 XML 文档注释信息。添加摘要并描述输入参数和返回值,如下所示,代码中已突出显示:

    /// <summary>
    /// P**ass a 32-bit unsigned integer and it will be converted into its**
    **ordinal equivalent.**
    /// </summary>
    /// <param name="number">N**umber as a cardinal value e.g. 1, 2, 3, and so**
    **on.**</param>
    /// <returns>N**umber as an ordinal value e.g. 1st, 2nd, 3rd, and so on.**</
    returns> 
    
  4. 现在,当调用函数时,你会看到更多详细信息,如下所示 图 4.2

图 4.2:显示更详细的方法签名的工具提示

值得强调的是,此功能主要是为了与将注释转换为文档的工具一起使用而设计的,例如 Sandcastle,你可以在以下链接中了解更多信息:github.com/EWSoftware/SHFB。在输入代码或悬停在函数名称上时出现的工具提示是次要功能。

局部函数不支持 XML 注释,因为局部函数不能在它们声明的成员之外使用,因此从它们生成文档没有意义。遗憾的是,这也意味着没有工具提示,虽然这仍然很有用,但 Visual Studio 和 VS Code 都没有识别出来。

良好实践:除了局部函数外,为所有函数添加 XML 文档注释。

在函数实现中使用 lambda 表达式

F# 是微软的强类型函数式编程语言,与 C# 类似,它编译成 中间语言IL)以由 .NET 执行。函数式语言起源于 lambda 演算,这是一种仅基于函数的计算系统。代码看起来更像数学函数,而不是食谱中的步骤。

以下列表中定义了函数式语言的一些重要属性:

  • 模块化:在 C# 中定义函数的相同好处也适用于函数式语言。这将一个大而复杂的代码库分解成更小的部分。

  • 不可变性:在 C# 的意义上,变量不存在。函数内部的数据值不能改变。相反,可以从现有数据值创建一个新的数据值。这减少了错误。

  • 可维护性:函数式代码更干净、更清晰(对于数学倾向的程序员来说)。

自 C# 6 以来,微软一直在努力添加语言特性以支持更函数式的方法,例如,在 C# 7 中添加 元组模式匹配,在 C# 8 中添加 非空引用类型,并在 C# 9 中改进模式匹配和添加记录,即潜在的 不可变对象

在 C# 6 中,微软添加了对 表达式主体函数成员 的支持。我们现在将看看这个例子。在 C# 中,lambda 是使用 => 字符来表示函数的返回值。

数字的 斐波那契数列 总是以 0 和 1 开始。然后,其余的序列使用将前两个数字相加的规则生成,如下所示数列:

0 1 1 2 3 5 8 13 21 34 55 ... 

序列的下一个项将是 34 + 55,即 89。

我们将使用斐波那契数列来说明命令式和声明式函数实现之间的区别:

  1. Program.Functions.cs 中,编写一个名为 FibImperative 的函数,它将以命令式风格编写,如下所示代码:

    static int FibImperative(uint term)
    {
      if (term == 0)
      {
        throw new ArgumentOutOfRangeException();
      }
      else if (term == 1)
      {
        return 0;
      }
      else if (term == 2)
      {
        return 1;
      }
      else
      {
        return FibImperative(term - 1) + FibImperative(term - 2);
      }
    } 
    
  2. Program.Functions.cs 中,编写一个名为 RunFibImperative 的函数,该函数在从 1 到 30 的循环语句中调用 FibImperative,如下所示代码:

    static void RunFibImperative()
    {
      for (uint i = 1; i <= 30; i++)
      {
        WriteLine("The {0} term of the Fibonacci sequence is {1:N0}.",
          arg0: CardinalToOrdinal(i),
          arg1: FibImperative(term: i));
      }
    } 
    
  3. Program.cs 中,注释掉其他方法调用并调用 RunFibImperative 方法。

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

    The 1st term of the Fibonacci sequence is 0.
    The 2nd term of the Fibonacci sequence is 1.
    The 3rd term of the Fibonacci sequence is 1.
    The 4th term of the Fibonacci sequence is 2.
    The 5th term of the Fibonacci sequence is 3.
    ...
    The 29th term of the Fibonacci sequence is 317,811.
    The 30th term of the Fibonacci sequence is 514,229. 
    
  5. Program.Functions.cs 中,编写一个名为 FibFunctional 的函数,采用声明式风格,如下所示代码:

    static int FibFunctional(uint term) => term switch
      {
        0 => throw new ArgumentOutOfRangeException(),
        1 => 0,
        2 => 1,
        _ => FibFunctional(term - 1) + FibFunctional(term - 2)
      }; 
    
  6. Program.Functions.cs 中,编写一个函数,在从 1 到 30 的循环语句中调用它,如下所示代码:

    static void RunFibFunctional()
    {
      for (uint i = 1; i <= 30; i++)
      {
        WriteLine("The {0} term of the Fibonacci sequence is {1:N0}.",
          arg0: CardinalToOrdinal(i),
          arg1: FibFunctional(term: i));
      }
    } 
    
  7. Program.cs 中,注释掉 RunFibImperative 方法调用并调用 RunFibFunctional 方法。

  8. 运行代码并查看结果(将与之前相同)。

现在你已经看到了一些函数的示例,让我们看看当它们有错误时如何修复它们。

开发期间的调试

在本节中,您将学习如何在开发时间调试问题。您必须使用具有调试工具的代码编辑器,例如 Visual Studio 或 VS Code。

创建包含故意错误的代码

让我们通过创建一个包含故意错误的控制台应用程序来探索调试,然后我们将使用代码编辑器中的调试工具来追踪并修复:

  1. 使用您首选的代码编辑器将一个新的 Console App / console 项目命名为 Debugging 并添加到 Chapter04 解决方案中。

  2. Debugging.csproj 修改为静态导入所有代码文件的 System.Console

  3. Program.cs 中,删除任何现有的语句,然后在文件底部添加一个包含故意错误的函数,如下所示代码:

    double Add(double a, double b)
    {
      return a * b; // Deliberate bug!
    } 
    
  4. Add 函数上方编写语句以声明和设置一些变量,然后使用有错误的函数将它们相加,如下所示代码:

    double a = 4.5;
    double b = 2.5;
    double answer = Add(a, b);
    WriteLine($"{a} + {b} = {answer}");
    WriteLine("Press Enter to end the app.");
    ReadLine(); // Wait for user to press Enter. 
    
  5. 运行控制台应用程序并查看结果,如下所示输出:

    4.5 + 2.5 = 11.25
    Press Enter to end the app. 
    

但等等,这里有错误!4.5 加上 2.5 应该是 7,而不是 11.25!

我们将使用调试工具来寻找并修复错误。

设置断点和开始调试

断点允许我们标记我们想要暂停以检查程序状态和查找错误的代码行。

使用 Visual Studio

让我们设置一个断点,然后使用 Visual Studio 开始调试:

  1. 在第 1 行点击,这是声明名为 a 的变量的语句。

  2. 导航到 调试 | 切换断点 或按 F9。在左侧边缘栏上会出现一个红色圆圈,并且语句将以红色突出显示以指示已设置断点,如图 图 4.3 所示:

图 4.3:使用 Visual Studio 切换断点

断点可以通过相同的操作关闭。您还可以在边缘处左键单击以切换断点的开/关,或右键单击断点以查看更多选项,例如删除、禁用或编辑条件或现有断点的操作。

  1. 导航到 调试 | 开始调试 或按 F5。Visual Studio 启动控制台应用程序,然后在遇到断点时暂停。这被称为 断点模式。可能会出现标题为 局部变量(显示局部变量的当前值)、监视 1(显示您定义的任何监视表达式)、调用堆栈异常设置立即窗口 的额外窗口。将出现 调试 工具栏。将要执行的下一行以黄色突出显示,并且从边缘栏指向该行的黄色箭头,如图 图 4.4 所示:

图 4.4:Visual Studio 中的断点模式

如果您不想了解如何使用 VS Code 开始调试,则可以跳过 使用 VS Code 部分,并继续到标题为 使用调试工具栏导航 的部分。

使用 VS Code

让我们设置一个断点,然后使用 VS Code 开始调试:

  1. 在第 1 行点击,这是声明名为 a 的变量的语句。

  2. 导航到运行 | 切换断点或按F9。在左侧边栏上会出现一个红色圆圈,以指示已设置断点。

断点可以通过相同的操作切换关闭。您还可以在边栏中左键单击以切换断点的开启和关闭;右键单击以查看更多选项,例如删除、编辑或禁用现有断点;或者在没有断点时添加断点、条件断点或 Logpoint。Logpoints,也称为Tracepoints,表示您希望在代码执行到该点时记录一些信息,而无需在该点停止执行代码。

  1. 导航到视图 | 运行,或在左侧导航栏中,您可以单击运行和调试图标(三角形“播放”按钮和“虫子”)或按Ctrl + Shift + D(在 Windows 上)。

  2. RUN AND DEBUG窗口的顶部,单击运行和调试按钮,然后选择调试项目,如图图 4.5所示:

图 4.5:使用 VS Code 选择要调试的项目

如果您首先被提示选择调试器,请选择C#,而不是.NET 5+或.NET Core。

  1. VS Code 启动控制台应用程序,并在遇到断点时暂停。这被称为断点模式。即将执行的行将以黄色突出显示,并且从边栏栏中指向该行的黄色方块,如图图 4.6所示:

图 4.6:VS Code 中的断点模式

使用调试工具栏进行导航

Visual Studio 在其标准工具栏中有两个与调试相关的按钮,用于启动或继续调试以及快速重新加载运行代码的更改,还有一个单独的调试工具栏用于其他工具。

VS Code 显示一个浮动工具栏,其中包含按钮,便于访问调试功能。

两者均在图 4.7中展示:

图 4.7:Visual Studio 和 VS Code 中的调试工具栏

以下列表描述了工具栏中最常见的按钮:

  • 开始/继续/F5:此按钮是上下文相关的。它将启动运行项目或从当前位置继续运行项目,直到结束或遇到断点。

  • 热重载:此按钮将在无需重新启动应用程序的情况下重新加载编译的代码更改。

  • 断点全部:此按钮将在运行的应用程序中进入下一个可用的代码行。

  • 停止调试/停止/Shift + F5(红色方块):此按钮将停止调试会话。

  • 重启/CtrlCmd + Shift + F5(圆形箭头):此按钮将停止程序,然后立即重新启动程序,并再次附加调试器。

  • 显示下一语句:此按钮将当前光标移动到即将执行的下一语句。

  • 进入/F11跳过/F10,和跳出/Shift + F11(蓝色箭头覆盖点):这些按钮以各种方式逐步执行代码语句,您将在稍后看到。

  • 显示源中的线程:此按钮允许你检查和操作你正在调试的应用程序中的线程。

调试窗口

在调试过程中,Visual Studio 和 VS Code 都会显示额外的窗口,允许你在执行代码时监控有用的信息,例如变量。

以下列表中描述了最有用的窗口:

  • 变量,包括局部变量,显示任何局部变量的名称、值和类型。在执行代码时,请注意此窗口。

  • WATCH,或Watch 1,显示你手动输入的变量和表达式的值。

  • 调用堆栈,显示函数调用的堆栈。

  • 断点,显示所有断点并允许更精细地控制它们。

在断点模式下,编辑区域底部还有一个有用的窗口:

  • 调试控制台,或即时窗口,允许你与代码进行实时交互。例如,你可以通过输入变量的名称来查询程序状态。例如,你可以通过输入1+2并按Enter来询问“1+2 等于多少?”。

逐行执行代码

让我们探索一些使用 Visual Studio 或 VS Code 逐行执行代码的方法:

调试的菜单命令位于 Visual Studio 的调试菜单或 VS Code 和 Rider 的运行菜单中。

  1. 导航到运行调试 | 进入,点击工具栏中的进入按钮,或按F11。黄色高亮显示向前移动一行。

  2. 导航到运行调试 | 进入,点击工具栏中的进入按钮,或按F11。黄色高亮显示向前移动一行。目前,你可以看到使用进入跳过没有区别,因为我们正在执行单行语句。

  3. 你现在应该位于调用Add方法的行上。

当你即将执行方法调用时,可以看到进入跳过之间的区别。

如果你点击进入,调试器将进入方法,以便你可以逐行执行该方法中的每一行。

如果你点击跳过,整个方法将一次性执行;它不会跳过方法而不执行。

  1. 点击进入以进入Add方法。

  2. 将鼠标指针悬停在代码编辑窗口中的ab参数上,并注意会弹出一个显示它们当前值的工具提示。

  3. 选择表达式a * b,右键单击表达式,并选择添加到监视添加监视。表达式被添加到WATCHWatch 1窗口中,显示该运算符正在将a乘以b得到结果11.25

  4. WATCHWatch 1窗口中,右键单击表达式并选择移除表达式删除监视

  5. 通过将Add函数中的*改为+来修复错误。

  6. 通过点击圆形箭头的重启按钮或按CtrlCmd + Shift + F5来重启调试。

  7. 跳过函数,花一分钟注意它现在是如何正确计算的,然后点击继续按钮或按F5

  8. 使用 VS Code 时,注意在调试期间写入控制台时,输出出现在DEBUG CONSOLE窗口而不是TERMINAL窗口,如图4.8所示:

图片

图 4.8:调试期间写入 DEBUG CONSOLE

使用 VS Code 集成终端进行调试

默认情况下,控制台在调试期间设置为使用内部DEBUG CONSOLE,这不允许像从ReadLine方法中输入文本这样的交互。

为了提高体验,我们可以将设置更改为使用集成终端。首先,让我们修改代码以要求与用户交互:

  1. Program.cs的顶部,添加提示用户输入数字并将该数字解析为变量a中的double类型的语句,如下面的代码所示,高亮显示:

    **Write(****"Enter a number: "****);**
    **string** **number = ReadLine()!;**
    double a = **double****.Parse(number)**; 
    
  2. 在第 1 行设置断点,写入提示Enter a number

  3. 运行和调试窗口的顶部,点击运行和调试按钮,然后选择Debugging项目。

  4. 注意,Enter a number提示既没有写入TERMINAL也没有写入DEBUG CONSOLE,并且两个窗口都没有等待用户输入数字并按Enter

  5. 停止调试。

  6. 运行和调试窗口的顶部,点击创建 launch.json 文件链接,然后,当提示选择调试器时,选择C#,如图4.9所示:

图片

图 4.9:为 launch.json 文件选择调试器

  1. launch.json文件编辑器中,点击添加配置...按钮,然后选择.NET: Launch .NET Core Console App,如图4.10所示:

图片

图 4.10:为.NET 控制台应用程序添加启动配置

  1. launch.json中,进行以下添加和更改,如下面的配置所示,高亮显示:

    • 注释掉preLaunchTask设置。

    • program路径中,在workspaceFolder变量之后添加Debugging项目文件夹。

    • program路径中,将<target-framework>更改为net9.0

    • program路径中,将<project-name.dll>更改为Debugging.dll

    • console设置从internalConsole更改为integratedTerminal

    {
      // Use IntelliSense to learn about possible attributes.
      // Hover to view descriptions of existing attributes.
      // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
      "version": "0.2.0",
      "configurations": [
        {
          "name": ".NET Core Launch (console)",
          "type": "coreclr",
          "request": "launch",
          **//**"preLaunchTask": "build",
          "program": "${workspaceFolder}/**Debugging/**bin/Debug/**net9.0**/**Debugging.dll**",
          "args": [],
          "cwd": "${workspaceFolder}",
          "stopAtEntry": false,
          "console": "**integratedTerminal**"
        }
      ]
    } 
    

记住,使用 VS Code 时,我们打开Chapter04文件夹来处理解决方案文件,因此工作区文件夹是Chapter04,而不是Debugging项目。

  1. 运行和调试窗口的顶部,注意启动配置的下拉列表,并点击开始调试按钮(绿色三角形),如图4.11所示。

  2. 导航到视图 | 终端,并注意终端窗口附加到Debugging.dll,如图4.11所示。

  3. 跳过将Enter a number:写入控制台的语句。

  4. 跳过调用ReadLine的语句。

  5. 输入5.5并按Enter

  6. 继续逐句执行或按F5或点击继续,并注意写入集成终端的输出,如图图 4.11所示:

图 4.11:设置为使用集成终端进行用户交互的启动配置

自定义断点

创建更复杂的断点很容易:

  1. 如果您仍在调试中,请点击调试工具栏中的停止按钮,导航到运行调试 | 停止调试,或按Shift + F5

  2. 导航到运行 | 移除所有断点调试 | 删除所有断点

  3. 点击输出答案的WriteLine语句。

  4. 通过按F9或导航到运行调试 | 切换断点来设置断点。

  5. 右键单击断点并选择适合您的代码编辑器的适当菜单:

    • 在 VS Code 中,选择编辑断点...

    • 在 Visual Studio 中,选择条件...

  6. 输入一个表达式,例如answer变量必须大于9,然后按Enter键接受它,并注意表达式必须评估为true,断点才能激活,如图图 4.12所示:

图 4.12:使用 VS Code 自定义带有表达式的断点

  1. 开始调试并注意断点未被触发。

  2. 停止调试。

  3. 编辑断点或其条件,并将其表达式更改为小于9

  4. 开始调试并注意断点被触发。

  5. 停止调试。

  6. 编辑断点或其条件(在 Visual Studio 中,点击添加条件),选择触发次数,然后输入一个数字,例如3,这意味着您必须触发断点三次才能激活它,如图图 4.13所示:

图 4.13:使用 Visual Studio 自定义带有表达式和触发次数的断点

  1. 将鼠标悬停在断点的红色圆圈上,可以看到摘要,如图图 4.14所示:

图 4.14:VS Code 中自定义断点的摘要

您现在已使用一些调试工具修复了一个错误,并看到了设置断点的先进可能性。

开发中的热重载

热重载是一个允许开发者在应用程序运行时应用代码更改并立即看到效果的特性。这对于快速修复错误非常有用。热重载也称为编辑并继续。您可以在以下链接中找到支持热重载的更改类型列表:aka.ms/dotnet/hot-reload

在.NET 6 发布之前,一位高级微软员工试图使该功能仅限于 Visual Studio,引发了争议。幸运的是,微软内部的开放源代码阵营成功地推翻了这一决定。使用命令行工具仍然可以使用 Hot Reload。

让我们看看实际操作:

  1. 使用您喜欢的代码编辑器,将名为 HotReloading 的新 控制台应用程序 / console 项目添加到 Chapter04 解决方案中。

  2. 修改 HotReloading.csproj 以静态导入所有代码文件的 System.Console

  3. Program.cs 中,删除现有的语句,然后每两秒向控制台写入一条消息,如下所示代码:

    /* Visual Studio: run the app, change the message, click Hot Reload.
     * VS Code: run the app using dotnet watch, change the message. */
    while (true)
    {
      WriteLine("Hello, Hot Reload!");
      await Task.Delay(2000);
    } 
    

使用 Visual Studio 进行热重载

如果您使用的是 Visual Studio,热重载功能已集成到用户界面中:

  1. 在 Visual Studio 中启动项目,并注意每两秒输出一条消息。

  2. 保持项目运行。

  3. Program.cs 中,将 Hello 改为 Goodbye

  4. 导航到 调试 | 应用代码更改 或在工具栏中点击 热重载 按钮,如图 4.15 所示,并注意更改已应用而无需重新启动控制台应用程序。

  5. 展开热重载按钮菜单,并选择 文件保存时热重载,如图 4.15 所示:

图 4.15:更改热重载选项

  1. 再次更改消息,保存文件,并注意控制台应用程序自动更新。

使用 VS Code 和 dotnet watch 进行热重载

如果您使用 VS Code,在启动控制台应用程序时必须发出一个特殊命令以激活热重载:

  1. 在 VS Code 中,在 终端 中使用 dotnet watch 启动控制台应用程序,并注意显示热重载已激活的输出,如下所示输出:

    dotnet watch  Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
       Press "Ctrl + R" to restart.
    dotnet watch  Building...
      Determining projects to restore...
      All projects are up-to-date for restore.
      HotReloading -> C:\cs13net9\Chapter04\HotReloading\bin\Debug\net9.0\HotReloading.dll
    dotnet watch  Started
    Hello, Hot Reload!
    Hello, Hot Reload!
    Hello, Hot Reload! 
    
  2. 在 VS Code 中,将 Hello 改为 Goodbye,并注意几秒钟后更改已应用而无需重新启动控制台应用程序,如下所示输出:

    Hello, Hot Reload!
    dotnet watch  File changed: .\Program.cs.
    Hello, Hot Reload!
    Hello, Hot Reload!
    dotnet watch  Hot reload of changes succeeded.
    Goodbye, Hot Reload!
    Goodbye, Hot Reload! 
    
  3. Ctrl + C 停止其运行,如下所示输出:

    Goodbye, Hot Reload!
    dotnet watch  Shutdown requested. Press Ctrl+C again to force exit. 
    

现在您已经看到了在开发过程中查找和删除错误的方法,让我们看看如何通过编写测试来追踪开发和生产过程中可能出现的更不明显的问题。

单元测试

修复代码中的错误代价高昂。在开发过程中越早发现错误,修复成本就越低。

单元测试是开发过程中早期发现错误的好方法,因为它们在集成到一起或被用户验收测试人员看到之前测试了一个小的单元。一些开发者甚至遵循程序员应该在编写代码之前创建单元测试的原则,这被称为 测试驱动开发 (TDD)。

微软有一个名为 MSTest 的专有单元测试框架。还有一个名为 NUnit 的框架。然而,我们将使用免费且开源的第三方框架 xUnit.net。这三个框架基本上做的是同一件事。xUnit 是由构建 NUnit 的同一团队创建的,但他们修正了他们认为之前犯下的错误。xUnit 更易于扩展,并且拥有更好的社区支持。

如果你好奇各种测试系统的优缺点,那么有数百篇文章是由每个系统的支持者撰写的。只需在谷歌上搜索:www.google.com/search?q=xunit+vs+nunit

理解测试类型

单元测试只是许多测试类型中的一种,如 表 4.1 所述:

类型 描述
单元 测试代码的最小单元,通常是方法或函数。单元测试是在通过模拟(如果需要)将其依赖项隔离的代码单元上进行的。每个单元应具有多个测试:一些具有典型输入和预期输出,一些具有极端输入值以测试边界,还有一些具有故意错误的输入以测试异常处理。
集成 测试较小的单元和较大的组件是否作为一个单一的软件组件一起工作。有时涉及与外部组件的集成,而这些组件你没有源代码。
系统 测试软件将运行的整个系统环境。
性能 测试软件的性能;例如,你的代码必须在 20 毫秒内向访客返回一个充满数据的网页。
负载 测试软件可以同时处理多少请求,同时保持所需性能,例如,一个网站有 10,000 个并发访客。
用户验收 测试用户是否可以使用你的软件愉快地完成他们的工作。

表 4.1:测试类型

创建需要测试的类库

首先,我们将创建一个需要测试的函数。我们将在一个与控制台应用程序项目分开的类库项目中创建它。类库是一组代码,可以分发并由其他 .NET 应用程序引用:

  1. 使用你喜欢的代码编辑器将一个名为 CalculatorLib 的新 类库 / classlib 项目添加到 Chapter04 解决方案中。

到目前为止,你将创建大约十几个新的控制台应用程序项目并将它们添加到解决方案中。添加 类库 / classlib 项目时唯一的区别是选择不同的项目模板。其余步骤与添加 控制台应用程序 / console 项目相同。

如果你使用 Visual Studio:

  1. 导航到 文件 | 添加 | 新项目

  2. 添加新项目 对话框中,搜索并选择 类库 [C#],然后点击 下一步

  3. 配置新项目 对话框中,对于 项目名称,输入 CalculatorLib,保留位置为 C:\cs13net9\Chapter04,然后点击 下一步

  4. 附加信息 对话框中,选择 .NET 9.0 (标准术语支持),然后点击 创建

如果你使用 VS Code:

  1. 终端 中,切换到 Chapter04 文件夹下的终端。

  2. 使用 dotnet CLI 创建一个名为 CalculatorLib 的新类库项目,如下命令所示:dotnet new classlib -o CalculatorLib

  3. 使用 dotnet CLI 将新的项目文件夹添加到解决方案中,如下所示命令:dotnet sln add CalculatorLib

  4. 注意结果,如下所示输出:项目 `CalculatorLib\CalculatorLib.csproj` 已添加到解决方案

  5. 对于所有代码编辑器,在 CalculatorLib 项目中,将名为 Class1.cs 的文件重命名为 Calculator.cs

  6. Calculator.cs 中,修改文件以定义一个 Calculator 类(故意包含一个错误),如下所示代码:

    namespace CalculatorLib;
    public class Calculator
    {
      public double Add(double a, double b)
      {
        return a * b;
      }
    } 
    
  7. 编译您的类库项目:

    • 在 Visual Studio 中,导航到 构建 | 构建 CalculatorLib

    • 在 VS Code 中,在 CalculatorLib 文件夹的 终端 窗口中,输入命令 dotnet build。(您也可以在 Chapter04 文件夹中运行此命令,但那样会构建整个解决方案,在这种情况下是不必要的。)

  8. 使用您首选的代码编辑器将新的 xUnit 测试项目 [C#] / xunit 项目 CalculatorLibUnitTests 添加到 Chapter04 解决方案中。例如,在命令提示符或 Chapter04 文件夹中的终端中,输入以下命令:

    dotnet new xunit -o CalculatorLibUnitTests
    dotnet sln add CalculatorLibUnitTests 
    
  9. CalculatorLibUnitTests 项目中,将 CalculatorLib 项目添加为项目引用:

    • 如果您正在使用 Visual Studio,在 解决方案资源管理器 中选择 CalculatorLibUnitTests 项目,导航到 项目 | 添加项目引用…,勾选复选框以选择 CalculatorLib 项目,然后点击 确定

    • 如果您正在使用 VS Code,使用 dotnet add reference 命令,或在名为 CalculatorLibUnitTests.csproj 的文件中修改配置以添加一个项目组,其中包含对 CalculatorLib 项目的项目引用,如下所示突出显示的标记:

      <ItemGroup>
        <ProjectReference
          Include="..\CalculatorLib\CalculatorLib.csproj" />
      </ItemGroup> 
      

项目引用的路径可以使用前导斜杠 (/) 或反斜杠 (\),因为路径由 .NET SDK 处理,并在必要时根据当前操作系统进行更改。

  1. 构建项目 CalculatorLibUnitTests

编写单元测试

一个编写良好的单元测试将包含三个部分:

  • 准备:这一部分将声明和实例化输入和输出的变量。

  • 执行:这一部分将执行您正在测试的单元。在我们的例子中,这意味着调用我们想要测试的方法。

  • 断言:这一部分将对输出进行一个或多个断言。断言是一种信念,如果为假,则表明测试失败。例如,当将 2 和 2 相加时,我们期望结果是 4。

现在,我们将为 Calculator 类编写一些单元测试:

  1. 将文件 UnitTest1.cs 重命名为 CalculatorUnitTests.cs 然后打开它。

  2. 在 VS Code 中,将类重命名为 CalculatorUnitTests。(Visual Studio 在您重命名文件时将提示您重命名类。)

  3. CalculatorUnitTests 中导入 CalculatorLib 命名空间,然后修改 CalculatorUnitTests 类以包含两个测试方法,一个用于将 2 和 2 相加,另一个用于将 2 和 3 相加,如下所示代码:

    using CalculatorLib; // To use Calculator.
    namespace CalculatorLibUnitTests;
    public class CalculatorUnitTests
    {
      [Fact]
      public void TestAdding2And2()
      {
        // Arrange: Set up the inputs and the unit under test.
        double a = 2;
        double b = 2;
        double expected = 4;
        Calculator calc = new();
        // Act: Execute the function to test.
        double actual = calc.Add(a, b);
        // Assert: Make assertions to compare expected to actual results.
        Assert.Equal(expected, actual);
      }
      [Fact]
      public void TestAdding2And3()
      {
        double a = 2;
        double b = 3;
        double expected = 5;
        Calculator calc = new();
        double actual = calc.Add(a, b);
        Assert.Equal(expected, actual);
      }
    } 
    

Visual Studio 仍然使用一个较旧的项模板,该模板使用嵌套命名空间。前面的代码显示了dotnet new和 Rider 使用的现代项目项模板,它使用文件作用域命名空间。

  1. 构建 CalculatorLibUnitTests 项目。

使用 Visual Studio 运行单元测试

现在我们已经准备好运行单元测试并查看结果:

  1. 在 Visual Studio 中,导航到测试 | 运行所有测试

  2. 测试资源管理器中,注意结果指示运行了两个测试,一个测试通过,一个测试失败,如图图 4.16所示:

图 4.16:Visual Studio 测试资源管理器中的单元测试结果

使用 VS Code 运行单元测试

现在我们已经准备好运行单元测试并查看结果:

  1. 如果你最近没有构建测试项目,那么构建 CalculatorLibUnitTests 项目以确保 C#开发工具包扩展中的新测试功能能够识别你编写的单元测试。

  2. 在 VS Code 中,导航到查看 | 测试,注意测试窗口有一个迷你工具栏,带有刷新测试运行测试调试测试等按钮。

  3. 测试窗口中,展开CalculatorLibUnitTests项目以显示两个测试。

  4. 将鼠标指针悬停在CalculatorUnitTests上,然后点击该类中定义的运行测试按钮(黑色三角形图标)。

  5. 点击测试结果选项卡,注意结果指示运行了两个测试,一个测试通过,一个测试失败,如图图 4.17所示:

图 4.17:VS Code 的测试结果中的单元测试结果

修复错误

现在你可以修复错误:

  1. 修复Add方法中的错误。

  2. 再次运行单元测试以查看错误是否已修复并且两个测试都通过。

现在我们已经编写、调试、记录和单元测试了函数,让我们通过查看如何在函数中抛出和捕获异常来结束本章。

指定多个参数值

我们创建了两个单元测试方法:第一个用于测试 2 加 2,第二个用于测试 2 加 3。我们不必编写两个分别用[Fact]装饰的方法,我们可以编写一个用[Theory]装饰的方法,并通过[InlineData]传递不同的参数值,如下面的代码所示:

using CalculatorLib; // To use Calculator.
namespace CalculatorLibUnitTests;
public class CalculatorUnitTests
{
  [Theory]
  [InlineData(2, 2, 4)]
  [InlineData(2, 3, 5)]
  public void TestAdding(double a, double b, double expected)
  {
    // Arrange: Set up the unit under test.
    Calculator calc = new();
    // Act: Execute the function to test.
    double actual = calc.Add(a, b);
    // Assert: Make assertions to compare expected to actual results.
    Assert.Equal(expected, actual);
  }
} 

[InlineData]属性中传递的参数顺序必须与测试方法中参数的顺序匹配。

现在我们来看看当出现错误时如何抛出和捕获异常。

函数中的抛出和捕获异常

第三章,“控制流程,转换类型和处理异常”中,你被介绍了异常以及如何使用try-catch语句来处理它们。但你应该只在有足够信息来缓解问题的情况下捕获和处理异常。如果没有,你应该允许异常通过调用堆栈向上传递到更高层次。

理解使用错误和执行错误

使用错误发生在程序员误用函数时,通常是通过传递无效的参数值。他们可以通过更改代码以传递有效值来避免这些错误。当一些程序员刚开始学习 C#和.NET 时,他们有时认为异常总是可以避免的,因为他们假设所有错误都是使用错误。在使用生产运行之前,应该修复所有使用错误。

执行错误发生在运行时发生的事情,无法通过编写“更好的”代码来修复。执行错误可以分为程序错误系统错误。如果你尝试访问网络资源但网络断开,你需要能够通过记录异常来处理该系统错误,并且可能需要暂时退后并再次尝试。然而,某些系统错误,如内存不足,根本无法处理。如果你尝试打开一个不存在的文件,你可能能够捕获该错误并通过创建新文件来程序化地处理它。程序错误可以通过编写智能代码来程序化修复。系统错误通常无法通过程序化修复。

函数中常见的抛出异常

很少应该定义新的异常类型来指示使用错误。.NET 已经定义了许多你应该使用的异常。

当定义带有参数的自定义函数时,你的代码应该检查参数值,并在它们具有将阻止函数正常工作的值时抛出异常。

例如,如果一个函数的参数不应该为null,则抛出ArgumentNullException。对于其他问题,抛出ArgumentExceptionNotSupportedExceptionInvalidOperationException

对于任何异常,包括一个描述问题的消息,以便任何需要阅读它的人(通常是面向类库和函数的开发者受众,或者如果是在 GUI 应用程序的最高级别,则是最终用户),如下面的代码所示:

static void Withdraw(string accountName, decimal amount)
{
  if (string.IsNullOrWhiteSpace(accountName))
  {
    throw new ArgumentException(paramName: nameof(accountName));
  }
  if (amount <= 0)
  {
    throw new ArgumentOutOfRangeException(paramName: nameof(amount),
      message: $"{nameof(amount)} cannot be negative or zero.");
  }
  // process parameters
} 

良好实践:如果一个函数无法成功执行其操作,你应该将其视为函数失败,并通过抛出异常来报告。

使用保护子句抛出异常

你可以使用异常本身的静态方法而不是使用new来实例化异常。当在函数实现中用于检查参数值时,它们被称为保护子句。其中一些是在.NET 6 中引入的,并在.NET 8 中添加了更多。

常见的保护子句在表 4.2中展示:

异常 保护子句方法
ArgumentException ThrowIfNullOrEmptyThrowIfNullOrWhiteSpace
ArgumentNullException ThrowIfNull
ArgumentOutOfRangeException ThrowIfEqualThrowIfGreaterThanThrowIfGreaterThanOrEqualThrowIfLessThanThrowIfLessThanOrEqualThrowIfNegativeThrowIfNegativeOrZeroThrowIfNotEqualThrowIfZero

表 4.2:常见保护子句

我们可以简化前面的示例,而不是编写 if 语句然后抛出 new 异常,如下所示:

static void Withdraw(string accountName, decimal amount)
{
  ArgumentException.ThrowIfNullOrWhiteSpace(accountName,
    paramName: nameof(accountName));
  ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount,
    paramName: nameof(amount));
  // process parameters
} 

更多信息:为了了解 Microsoft 源代码使用其自己的守卫子句的频率,您可以阅读以下在线博客文章的以下部分:devblogs.microsoft.com/dotnet/performance-improvements-in-net-8/#exceptions。例如,ThrowIfNulldotnet/runtime 的核心库中的 src 内部被调用了 4,795 次。

理解调用栈

.NET 控制台应用程序的入口点是 Main 方法(如果你明确定义了此类)或 Program 类中的 <Main>$(如果它是通过顶级程序功能为你创建的)。

Main 方法将调用其他方法,这些方法又调用其他方法,依此类推;这些方法可能位于当前项目或引用的项目和 NuGet 包中,如图 4.18 所示:

图 4.18:创建调用栈的方法链

让我们创建一个类似的方法链,以探索我们可以捕获和处理异常的地方:

  1. 使用您喜欢的代码编辑器,将名为 CallStackExceptionHandlingLib 的新 类库 / classlib 项目添加到 Chapter04 解决方案中。

  2. Class1.cs 文件重命名为 Processor.cs

  3. Processor.cs 中,修改其内容,如下所示:

    using static System.Console;
    namespace CallStackExceptionHandlingLib;
    public class Processor
    {
      public static void Gamma() // public so it can be called from outside.
      {
        WriteLine("In Gamma");
        Delta();
      }
      private static void Delta() // private so it can only be called internally.
      {
        WriteLine("In Delta");
        File.OpenText("bad file path");
      }
    } 
    
  4. 使用您喜欢的代码编辑器,将名为 CallStackExceptionHandling 的新 控制台应用程序 / console 项目添加到 Chapter04 解决方案中。

  5. CallStackExceptionHandling 控制台应用程序项目中,添加对 CallStackExceptionHandlingLib 类库项目的引用,如下所示:

    <ItemGroup>
      <ProjectReference Include="..\CallStackExceptionHandlingLib\
    CallStackExceptionHandlingLib.csproj" />
    </ItemGroup> 
    
  6. 构建名为 CallStackExceptionHandling 的控制台应用程序项目,以确保依赖项目被编译并复制到本地的 bin 文件夹。

  7. Program.cs 中,删除现有语句,添加定义两个方法的语句,并将它们以及类库中的方法进行链式调用,如下所示:

    using CallStackExceptionHandlingLib; // To use Processor.
    using static System.Console;
    WriteLine("In Main");
    Alpha();
    void Alpha()
    {
      WriteLine("In Alpha");
      Beta();
    }
    void Beta()
    {
      WriteLine("In Beta");
      Processor.Gamma();
    } 
    
  8. 在没有附加调试器的情况下运行控制台应用程序,并注意结果,如下所示的部分输出:

    In Main
    In Alpha
    In Beta
    In Gamma
    In Delta
    Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\cs13net9\Chapter04\CallStackExceptionHandling\bin\Debug\net9.0\bad file path'.
    File name: 'C:\cs13net9\Chapter04\CallStackExceptionHandling\bin\Debug\net9.0\bad file path'
       at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options)
       at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(String fullPath, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize)
       at System.IO.Strategies.OSFileStreamStrategy..ctor(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize)
       at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(String path, FileMode mode, FileAccess access, FileShare share, FileOptions options, Int64 preallocationSize)
       at System.IO.StreamReader.ValidateArgsAndOpenPath(String path, Encoding encoding, Int32 bufferSize)
       at System.IO.File.OpenText(String path)
       at CallStackExceptionHandlingLib.Calculator.Delta() in C:\cs13net9\Chapter04\CallStackExceptionHandlingLib\Processor.cs:line 16
       at CallStackExceptionHandlingLib.Calculator.Gamma() in C:\cs13net9\Chapter04\CallStackExceptionHandlingLib\Processor.cs:line 10
       at Program.<<Main>$>g__Beta|0_1() in C:\cs13net9\Chapter04\CallStackExceptionHandling\Program.cs:line 16
       at Program.<<Main>$>g__Alpha|0_0() in C:\cs13net9\Chapter04\CallStackExceptionHandling\Program.cs:line 10
       at Program.<Main>$(String[] args) in C:\cs13net9\Chapter04\CallStackExceptionHandling\Program.cs:line 5 
    

注意调用栈是倒置的。从底部开始,你看到:

  • 第一次调用是自动生成的 Program 类中的 <Main>$ 入口点函数。这是将参数作为 String 数组传递的地方。

  • 第二次调用是 <<Main>$>g__Alpha|0_0 函数。(当 C# 编译器将其添加为局部函数时,会将其重命名为 Alpha。)

  • 第三次调用是 Beta 函数。

  • 第四次调用是 Gamma 函数。

  • 第五次调用是到 Delta 函数。此函数尝试通过传递一个错误的文件路径来打开文件。这导致抛出异常。任何带有 try-catch 语句的函数都可以捕获此异常。如果它没有捕获,异常将自动向上传递到调用堆栈,直到达到顶部,此时.NET 将输出异常(以及此调用堆栈的详细信息)。

良好实践:除非你需要逐步通过代码来调试它,否则你应该始终在没有附加调试器的情况下运行你的代码。在这种情况下,特别重要的是不要附加调试器,因为如果你这样做,它将捕获异常并在 GUI 对话框中显示它,而不是像书中所示那样输出它。

在哪里捕获异常

程序员可以决定他们是否希望在失败点附近或调用堆栈更高处捕获异常。这允许你的代码简化并标准化。你可能知道调用一个函数可能会抛出一个或多个类型的异常,但你不需要在当前调用堆栈点处理它们。

重新抛出异常

有时,你可能想要捕获一个异常,记录它,然后重新抛出它。例如,如果你正在编写一个将被应用程序调用的低级类库,你的代码可能没有足够的信息以编程方式以智能的方式修复错误,但调用应用程序可能拥有更多信息并能做到这一点。你的代码应该记录错误,以防调用应用程序没有记录,然后将其重新抛出到调用堆栈,以防调用应用程序选择更好地处理它。

catch 块内部重新抛出异常有三种方法,如下所示列表所示:

  • 要以原始调用堆栈抛出捕获的异常,请调用 throw

  • 要将捕获的异常作为如果它在调用堆栈的当前级别抛出一样抛出,请使用捕获的异常调用 throw,例如,throw ex。这通常是一种不好的做法,因为你已经失去了可能对调试有用的信息,但在你想要故意删除包含敏感数据的信息时,这可能是有用的。

  • 要将捕获的异常包装在另一个异常中,该异常可以包含更多可能帮助调用者理解问题的信息,请抛出一个新的异常,并将捕获的异常作为 innerException 参数传递。

如果我们在调用 Gamma 函数时发生错误,那么我们可以捕获异常并执行以下代码中所示的三种重新抛出异常的技术之一:

这段代码只是为了说明。你永远不会在同一个 catch 块中使用所有三种技术!

try
{
  Gamma();
}
catch (IOException ex)
{
  LogException(ex);
  // Throw the caught exception as if it happened here
  // this will lose the original call stack.
  throw ex;
  // Rethrow the caught exception and retain its original call stack.
  throw;
  // Throw a new exception with the caught exception nested within it.
  throw new InvalidOperationException(
    message: "Calculation had invalid values. See inner exception for why.",
    innerException: ex);
} 

让我们通过我们的调用堆栈示例来看看这个动作:

  1. CallStackExceptionHandling 项目的 Program.cs 文件中,在 Beta 函数中,在调用 Gamma 函数周围添加一个 try-catch 语句,如下所示,代码中已突出显示:

    void Beta()
    {
      WriteLine("In Beta");
      **try**
     **{**
     **Processor.Gamma();**
     **}**
    **catch** **(Exception ex)**
     **{**
     **WriteLine(****$"Caught this:** **{ex.Message}****"****);**
    **throw** **ex;**
     **}**
    } 
    

注意你的代码编辑器将在throw ex下显示一个波浪线来警告你,你将丢失调用堆栈信息,如代码分析器消息“重新抛出捕获的异常会更改堆栈信息”中所述;更多详细信息可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca2200

  1. 运行控制台应用程序并注意输出排除了一些调用堆栈的详细信息,如下面的输出所示:

    Caught this: Could not find file 'C:\cs13net9\Chapter04\CallStackExceptionHandling\bin\Debug\net9.0\bad file path'.
    Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\cs13net9\Chapter04\CallStackExceptionHandling\bin\Debug\net9.0\bad file path'.
    File name: 'C:\cs13net9\Chapter04\CallStackExceptionHandling\bin\Debug\net9.0\bad file path'
       at Program.<<Main>$>g__Beta|0_1() in C:\cs13net9\Chapter04\CallStackExceptionHandling\Program.cs:line 23
       at Program.<<Main>$>g__Alpha|0_0() in C:\cs13net9\Chapter04\CallStackExceptionHandling\Program.cs:line 10
       at Program.<Main>$(String[] args) in C:\cs13net9\Chapter04\CallStackExceptionHandling\Program.cs:line 5 
    
  2. 通过将throw ex;语句替换为throw;来移除ex

  3. 运行控制台应用程序并注意输出包括调用堆栈的所有详细信息。

实现测试执行者和尝试模式

测试执行者模式可以避免一些抛出的异常(但并不能完全消除)。这种模式使用一对函数:一个用于执行测试,另一个用于执行如果测试未通过就会失败的操作。

.NET 本身实现了这个模式。例如,在通过调用Add方法向集合中添加项之前,你可以测试它是否只读,这会导致Add失败并因此抛出异常。

例如,在从银行账户取款之前,你可能需要测试账户是否没有透支,如下面的代码所示:

if (!bankAccount.IsOverdrawn())
{
  bankAccount.Withdraw(amount);
} 

测试执行者模式可能会增加性能开销,因此你也可以实现尝试模式,实际上,这种模式将测试和执行部分合并为一个单独的函数,就像我们在TryParse中看到的那样。

测试执行者模式的一个问题是当你使用多个线程时。在这种情况下,一个线程调用测试函数,并返回一个表示可以继续执行的状态值。但然后另一个线程执行,改变了状态。然后原始线程继续执行,假设一切正常,但实际上并不正常。这被称为竞争条件。这个话题过于复杂,本书中不会涵盖如何处理它。

良好实践:优先使用尝试模式而不是测试执行者模式。

如果你实现了自己的尝试模式函数并且它失败了,请记住将out参数设置为该类型的默认值,然后返回false,如下面的代码所示:

static bool TryParse(string? input, out Person value)
{
  if (someFailure)
  {
    value = default(Person);
    return false;
  }
  // Successfully parsed the string into a Person.
  value = new Person() { ... };
  return true;
} 

更多信息:现在你已经了解了异常的基本知识,你可以通过阅读以下链接中的官方文档来了解更多细节:learn.microsoft.com/en-us/dotnet/standard/exceptions/

练习和探索

通过回答一些问题、进行一些实际操作练习,并深入探索本章涵盖的主题来测试你的知识和理解。

练习 4.1 – 在线材料

在线材料可以是为我这本书写的额外内容,也可以是引用由微软或第三方创建的内容。

开发和运行时记录

你应该在应用程序中添加代码来记录正在发生的事情,尤其是在异常发生时,这样你就可以查看日志并使用它们来追踪问题并解决问题。在一个在线部分,你将学习如何实现基本的记录;这可以在以下链接中找到:

github.com/markjprice/cs13net9/blob/main/docs/ch04-logging.md

练习 4.2 – 练习题

练习题深入探讨了本章的主题。

编写带有调试和单元测试的函数

质因数是几个最小的质数的组合,当它们相乘时会产生原始数字。考虑以下示例:

  • 4 的质因数是 2 x 2

  • 7 的质因数是 7

  • 30 的质因数是 5 x 3 x 2

  • 40 的质因数是 5 x 2 x 2 x 2

  • 50 的质因数是 5 x 5 x 2

创建三个项目:

  • 一个名为 Exercise_PrimeFactorsLib 的类库,其中包含一个静态类和一个名为 PrimeFactors 的静态方法,该方法接受一个 int 类型的参数,并返回一个显示其质因数的 string

  • 一个名为 Exercise_PrimeFactorsTests 的单元测试项目,包含一些合适的单元测试

  • 一个名为 Exercise_PrimeFactorsApp 的控制台应用程序来使用它

为了保持简单,你可以假设输入的最大数字将是 1,000。

使用调试工具和编写单元测试来确保你的函数能够正确处理多个输入并返回正确的输出。

练习 4.3 – 测试你的知识

回答以下问题。如果你卡住了,可以尝试在谷歌上搜索答案,如果需要的话,同时记住,如果你完全卡住了,答案可以在 附录 中找到:

  1. C# 关键字 void 的含义是什么?

  2. 指令式编程和函数式编程风格之间有哪些不同之处?

  3. 在 VS Code 或 Visual Studio 中,按下 F5CtrlCmd + F5Shift + F5CtrlCmd + Shift + F5 有什么区别?

  4. Trace.WriteLine 方法将输出写入到何处?

  5. 有哪五个跟踪级别?

  6. Debug 类和 Trace 类之间的区别是什么?

  7. 当编写单元测试时,三个“A”指的是什么?

  8. 当使用 xUnit 编写单元测试时,你必须用哪个属性来装饰测试方法?

  9. 执行 xUnit 测试的 dotnet 命令是什么?

  10. 要重新抛出名为 ex 的捕获异常而不丢失堆栈跟踪,你应该使用哪个语句?

练习 4.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-4---writing-debugging-and-testing-functions

概述

在本章中,你学习了:

  • 如何使用输入参数和返回值编写可重用函数,无论是命令式还是函数式风格

  • 如何使用 Visual Studio 和 VS Code 的调试和诊断功能,如日志和单元测试,来识别和修复其中的任何错误

  • 如何在函数中抛出和捕获异常,并理解调用栈

在下一章中,您将学习如何使用面向对象编程技术构建自己的类型。

在 Discord 上了解更多信息

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

packt.link/csharp13dotnet9

留下您的评价!

感谢您从 Packt Publishing 购买这本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花一点时间在亚马逊上留下评价;这只需一分钟,但对像您这样的读者来说意义重大。

扫描二维码或访问链接,免费获取您选择的电子书。

packt.link/NzOWQ

带有黑色方块的二维码  自动生成的描述

第五章:使用面向对象编程创建自己的类型

本章是关于使用面向对象编程OOP)创建自己的类型。你将了解一个类型可以拥有的所有不同成员类别,包括用于存储数据的字段和执行动作的方法。你将使用 OOP 概念,如聚合和封装。你还将了解语言特性,如元组语法支持、out变量、推断的元组名称和默认字面量。最后,你将学习模式匹配和定义记录,以使变量的相等性和不可变性更容易实现。

本章将涵盖以下主题:

  • 讨论面向对象编程

  • 构建类库

  • 在字段中存储数据

  • 使用方法和元组进行操作

  • 使用属性和索引器控制访问

  • 使用对象进行模式匹配

  • 使用记录类型

讨论面向对象编程

世界上一个对象是一个东西,比如汽车或人,而在编程中,一个对象通常代表现实世界中的某个东西,比如产品或银行账户,但它也可以是更抽象的东西。

在 C#中,我们使用 C#关键字classrecordstruct来定义对象的类型。你将在第六章实现接口和继承类中学习struct类型。你可以将类型视为对象的蓝图或模板。

这里简要描述了 OOP 的概念:

  • 封装是指与对象相关的数据和动作的组合。例如,BankAccount(银行账户)类型可能包含数据,如Balance(余额)和AccountName(账户名称),以及动作,如Deposit(存款)和Withdraw(取款)。在封装时,你通常想要控制可以访问那些动作和数据的内容,例如,限制从外部访问或修改对象内部状态的方式。

  • 组合是指对象由什么组成。例如,Car(汽车)由不同的部分组成,如四个Wheel(轮子)对象、几个Seat(座位)对象和一个Engine(引擎)。

  • 聚合是指可以与对象结合的内容。例如,Person(人)不是Car(汽车)对象的一部分,但它们可以坐在司机的Seat(座位)上,然后成为汽车的Driver(驾驶员)——两个独立的对象聚合在一起形成一个新的组件。

  • 继承是通过让子类基类超类派生来重用代码。基类中的所有功能都被继承,并在派生类中可用。例如,基类或超Exception(异常)类有一些成员在所有异常中具有相同的实现,而子类或派生SqlException(SQL 异常)类继承了这些成员,并具有仅在 SQL 数据库异常发生时相关的额外成员,例如数据库连接的属性。

  • 抽象是指捕捉一个对象的核心思想,忽略细节或具体内容。C#有abstract关键字来正式化这个概念,但不要将抽象的概念与abstract关键字的使用混淆,因为抽象不仅仅是这个。抽象的概念也可以通过接口实现。如果一个类没有明确地声明为抽象,那么它可以被描述为具体的。基类或超类通常是抽象的;例如,超类Stream是抽象的,它的子类,如FileStreamMemoryStream,是具体的。只有具体的类才能用来创建对象;抽象类只能用作其他类的基类,因为它们缺少一些实现。抽象是一个棘手的平衡。如果你使一个类更抽象,更多的类将能够从它继承,但与此同时,将会有更少的功能可以共享。抽象的一个现实世界例子是汽车制造商对电动汽车EVs)采取的方法。他们创建了一个共同的“平台”(基本上就是电池和轮子),这是所有电动汽车所需内容的抽象,然后在此基础上构建不同的车辆,如汽车、卡车、面包车等等。这个平台本身不是一个完整的产品,就像一个抽象类。

  • 多态是指允许派生类覆盖继承的动作以提供自定义行为。

    在接下来的两章中,有很多关于面向对象编程(OOP)的内容要介绍,其中一些部分是难以学习的。在第六章的结尾,实现接口和继承类,我编写了一个关于自定义类型类别及其功能的总结,并附有示例代码。这将帮助你回顾最重要的事实,并突出不同选择之间的差异,例如abstract类或interface,以及何时使用它们。

构建类库

类库程序集将类型组合成易于部署的单元(DLL 文件)。除了你学习了单元测试之外,你只创建了控制台应用程序来包含你的代码。为了使你编写的代码可以在多个项目中重用,你应该将其放入类库程序集中,就像微软所做的那样。

创建类库

第一个任务是创建一个可重用的.NET 类库:

  1. 使用你喜欢的代码编辑器创建一个新项目,如下列所示:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:PacktLibraryNet2

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

  2. 打开PacktLibraryNet2.csproj文件,并注意,默认情况下,由.NET 9 SDK 创建的类库针对.NET 9,因此只能被其他.NET 9 兼容的程序集引用,如下面的标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        **<TargetFramework>net9****.0****</TargetFramework>**
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    </Project> 
    
  3. 修改框架以针对 .NET Standard 2.0,添加一个条目以显式使用 C# 13 编译器,并静态导入所有 C# 文件中的 System.Console 类,如下面的标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
     **<!--.NET Standard** **2.0****class****library****can****be****used****by****:**
     **.****NET****Framework****,** **Xamarin****,** **modern** **.****NET****. -->**
     **<****TargetFramework****>****netstandard2.0****</****TargetFramework****>**
     **<!--****Compile****this****library****using****C****# 13** **so****we****can****use****most**
    **modern****compiler****features****. -->**
     **<****LangVersion****>13</****LangVersion****>**
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
     **<****ItemGroup****>**
     **<****Using****Include****="****System.Console****"** **Static****="****true****" />**
     **</****ItemGroup****>**
    </Project> 
    

虽然我们可以使用 C# 13 编译器,但一些现代编译器功能需要现代 .NET 运行时。例如,我们不能在接口中使用默认实现(C# 8 中引入),因为它需要 .NET Standard 2.1。我们不能使用 required 关键字(C# 11 中引入),因为它需要 .NET 7 中引入的属性。但许多有用的现代编译器功能,如原始字面量字符串,将对我们可用。

  1. 保存并关闭文件。

  2. 删除名为 Class1.cs 的文件。

  3. 编译项目以便其他项目以后可以引用它:

    • 在 Visual Studio 中,导航到 构建 | 构建 PacktLibraryNet2

    • 在 VS Code 中,输入以下命令:dotnet build

良好实践:为了使用最新的 C# 语言和 .NET 平台功能,将类型放在 .NET 9 类库中。为了支持旧版 .NET 平台,如 .NET Core、.NET Framework 和 Xamarin,将可能重用的类型放在 .NET Standard 2.0 类库中。默认情况下,针对 .NET Standard 2.0 使用 C# 7 编译器,但可以覆盖它,以便即使你受限于 .NET Standard 2.0 API,也能获得较新 SDK 和编译器的优势。

理解文件作用域命名空间

传统上,你定义像在命名空间中嵌套的类这样的类型,如下面的代码所示:

namespace Packt.Shared
{
  public class Person
  {
  }
} 

如果你在同一个代码文件中定义多个类型,那么它们可以位于不同的命名空间中,因为类型必须显式地位于每个命名空间的括号内。

如果你使用 C# 10 或更高版本,可以通过在命名空间声明末尾添加分号并删除花括号来简化你的代码,这样类型定义就不需要缩进,如下面的代码所示:

// All types in this file will be defined in this file-scoped namespace.
namespace Packt.Shared;
public class Person
{
} 

这被称为 文件作用域命名空间 声明。每个文件只能有一个文件作用域命名空间。这个特性对于空间有限的书籍作者特别有用。

良好实践:将你创建的每个类型放在其自己的代码文件中,或者至少将同一命名空间中的类型放在同一代码文件中,这样你就可以使用文件作用域命名空间声明。

在命名空间中定义类

下一个任务是定义一个将代表人的类:

  1. PacktLibraryNet2 项目中,添加一个名为 Person.cs 的新类文件。

  2. Person.cs 中,删除任何现有语句并将命名空间设置为 Packt.Shared,对于 Person 类,将访问修饰符设置为 public,如下面的代码所示:

    // All types in this file will be defined in this file-scoped namespace.
    namespace Packt.Shared;
    public class Person
    {
    } 
    

    良好实践:我们这样做是因为将您的类放在一个逻辑命名的命名空间中很重要。更好的命名空间名称应该是特定领域的,例如,对于与高级数字相关的类型,可以使用System.Numerics。在这种情况下,我们将创建的类型是PersonBankAccountWondersOfTheWorld,它们没有典型的领域,因此我们将使用更通用的Packt.Shared

理解类型访问修饰符

注意,C#关键字publicclass之前使用。这个关键字是一个访问修饰符,它允许任何其他代码在类库外部访问这个类。

如果您没有明确应用public关键字,那么它只能在定义它的程序集内部访问。这是因为类的隐式访问修饰符是internal。我们需要这个类在程序集外部可访问,所以我们必须确保它是public

如果您有嵌套类,即在一个类中定义的类,那么内部类可以具有private访问修饰符,这意味着它在其父类之外不可访问。

从.NET 7 开始引入,应用于类型的file访问修饰符意味着该类型只能在它的代码文件中使用。这只有在您在同一个代码文件中定义多个类时才有用,这很少是好的实践,但与源生成器一起使用。

更多信息:您可以在以下链接中了解更多关于file访问修饰符的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/file.

良好实践:对于类来说,最常见的访问修饰符是publicinternal(如果未指定,则为类的默认访问修饰符)。始终明确指定类的访问修饰符,以便使其清晰。其他访问修饰符包括privatefile,但它们很少使用。

理解成员

Person类型目前还没有封装任何成员。我们将在接下来的几页中创建一些。成员可以是字段、方法或两者的特殊版本。您可以在以下内容中找到它们的描述:

  • 字段用于存储数据。您可以将字段视为属于类型的变量。还有三个专门的字段类别,如下所示:

    • 常量:数据永远不会改变。编译器将数据字面量地复制到任何读取它的代码中。例如,byte.MaxValue始终是255。常量必须设置为字面量值。字面量值只能是一个由""包围的string,一个由单引号包围的char,一个布尔值如truefalse,或一个数字如33L3.03.0F3.0M。不支持非字面量值,因此您不能有常量日期/时间值。

    • 只读:在类实例化后,数据不能更改,但在实例化时可以从外部源计算或加载数据。例如,String.Empty""Guid.Empty是一个GUID全局唯一标识符)所有位都是零,TimeSpan.Zero是零时间间隔,DateTime.MinValue是格里高利历中的00:00:00.0000000 UTC,0001 年 1 月 1 日,而Path.DirectorySeparatorChar在 Windows 上是\

    DateTime.UnixEpoch在格里高利历中是00:00:00.0000000 UTC,1970 年 1 月 1 日,但这个字段是在.NET Standard 2.0 之后引入的,所以你无法在当前的类库中尝试它。

    • 事件:当发生某些事情时,数据引用一个或多个你想要执行的方法,例如点击按钮或响应其他代码的请求。事件将在第六章实现接口和继承类中介绍。例如,Console.CancelKeyPress会在控制台应用程序中按下Ctrl + CCtrl + Break时发生。
  • 方法用于执行语句。当你学习第四章编写、调试和测试函数时,你看到了一些例子。还有四种特殊的方法类别:

    • 构造函数:当你使用new关键字为实例化类分配内存时执行语句。例如,要实例化 2023 年圣诞节,你可以编写以下代码:new DateTime(2023, 12, 25)

    • 属性:当你获取或设置数据时执行语句。数据通常存储在字段中,但也可以在外部存储或在运行时计算。除非需要暴露字段的内存地址,否则属性是封装字段的首选方式;例如,Console.ForegroundColor用于设置控制台应用程序中文本的颜色。

    • 索引器:当你使用“数组”语法[]获取或设置数据时执行语句。例如,使用name[0]获取name变量中的第一个字符,它是一个string

    • 运算符:当你对你的类型操作数应用运算符(如+/)时执行语句。例如,使用a + b将两个变量相加。

导入命名空间以使用类型

在本节中,我们将创建Person类的实例。

在我们实例化一个类之前,我们需要从另一个项目中引用包含它的程序集。我们将在控制台应用程序中使用这个类:

  1. 使用你喜欢的代码编辑器将一个新的控制台应用程序/ console命名为PeopleApp添加到Chapter05解决方案中。确保你添加新的项目到现有的Chapter05解决方案中,因为你即将从控制台应用程序项目引用现有的类库项目,所以两个项目必须在同一个解决方案中。

  2. 如果你使用 Visual Studio:

    1. 配置解决方案的启动项目为当前选择。

    2. 解决方案资源管理器 中,选择 PeopleApp 项目,导航到 项目 | 添加项目引用…,勾选复选框以选择 PacktLibraryNet2 项目,然后单击 确定

    3. PeopleApp.csproj 中添加一个条目以静态导入 System.Console 类,如下所示标记:

      <ItemGroup>
      <Using Include="System.Console" Static="true" />
      </ItemGroup> 
      
    4. 导航到 构建 | 构建 PeopleApp

  3. 如果你使用 VS Code:

    1. 编辑 PeopleApp.csproj 以添加对 PacktLibraryNet2 的项目引用,并添加一个条目以静态导入 System.Console 类,如下所示标记:

      <Project Sdk="Microsoft.NET.Sdk">
        <PropertyGroup>
          <OutputType>Exe</OutputType>
          <TargetFramework>net9.0</TargetFramework>
          <Nullable>enable</Nullable>
          <ImplicitUsings>enable</ImplicitUsings>
        </PropertyGroup>
      **<****ItemGroup****>**
      **<****ProjectReference****Include****=**
      **"../PacktLibraryNet2/PacktLibraryNet2.csproj"** **/>**
      **</****ItemGroup****>**
      **<****ItemGroup****>**
      **<****Using****Include****=****"System.Console"****Static****=****"true"** **/>**
      **</****ItemGroup****>**
      </Project> 
      
    2. 在终端中,编译 PeopleApp 项目及其依赖项 PacktLibraryNet2 项目,如下所示命令:

      dotnet build 
      
  4. PeopleApp 项目中添加一个名为 Program.Helpers.cs 的新类文件。

  5. Program.Helpers.cs 中删除任何现有语句,并定义一个 partial Program 类,其中包含一个配置控制台以启用特殊符号(如欧元货币)和控制当前文化的方法,如下所示代码:

    using System.Globalization; // To use CultureInfo.
    partial class Program
    {
      private static void ConfigureConsole(
        string culture = "en-US",
        bool useComputerCulture = false,
        bool showCulture = true)
      {
        OutputEncoding = System.Text.Encoding.UTF8;
        if (!useComputerCulture)
        {
          CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(culture);
        }
        if (showCulture)
        {
          WriteLine($"Current culture: {CultureInfo.CurrentCulture.DisplayName}.");
        }
      }
    } 
    

到本章结束时,你将了解前面提到的方法如何使用 C# 的特性,如部分类、可选参数等。如果你想了解更多关于与语言和文化以及日期、时间和时区工作的内容,那么在我的配套书籍 Apps and Services with .NET 8 中有一个关于全球化和本地化的章节。

实例化一个类

现在,我们已经准备好编写语句来实例化 Person 类:

  1. PeopleApp 项目中,在 Program.cs 文件中删除现有语句,然后添加语句导入我们的 Person 类的命名空间,然后不带任何参数调用 ConfigureConsole 方法,以便将其设置为美国英语当前文化,使所有读者都能看到相同的输出,如下所示代码:

    using Packt.Shared; // To use Person.
    ConfigureConsole(); // Sets current culture to US English.
    // Alternatives:
    // ConfigureConsole(useComputerCulture: true); // Use your culture.
    // ConfigureConsole(culture: "fr-FR"); // Use French culture. 
    

虽然我们可以全局导入 Packt.Shared 命名空间,但如果 import 语句位于文件顶部,那么阅读此代码的人将更清楚地知道我们从哪里导入使用的类型,并且 PeopleApp 项目将只有一个需要导入命名空间的 Program.cs 文件。

  1. Program.cs 中添加语句:

    • 创建 Person 类型的实例。

    • 使用实例自身的文本描述输出实例。

new 关键字为对象分配内存并初始化任何内部数据,如下所示代码:

// Person bob = new Person(); // C# 1 or later.
// var bob = new Person(); // C# 3 or later.
Person bob = new(); // C# 9 or later.
WriteLine(bob); // Implicit call to ToString().
// WriteLine(bob.ToString()); // Does the same thing. 
  1. 运行 PeopleApp 项目并查看结果,如下所示输出:

    Current culture: English (United States).
    Packt.Shared.Person 
    

你可能会想知道,“为什么 bob 变量有一个名为 ToString 的方法?Person 类是空的!”别担心,我们很快就会找到答案!

继承自 System.Object

虽然我们的 Person 类没有明确选择继承自某个类型,但所有类型最终都直接或间接地继承自一个名为 System.Object 的特殊类型。System.Object 类型中 ToString 方法的实现会输出完整的命名空间和类型名称。

在原始的 Person 类中,我们可以明确地告诉编译器 Person 继承自 System.Object 类型,如下面的代码所示:

public class Person : System.Object 

当类 B 从类 A 继承时,我们说 A 是基类或超类,B 是派生类或子类。在这种情况下,System.Object 是基类或超类,Person 是派生类或子类。你也可以使用 C# 关键字 object

让我们的类显式继承自 object,然后回顾所有对象都具有哪些成员:

  1. 将你的 Person 类修改为显式继承自 object,如下面的代码所示:

    public class Person : object 
    
  2. object 关键字内单击并按 F12,或者右键单击 object 关键字并选择 转到定义

你将看到微软定义的 System.Object 类型及其成员。这目前你不需要了解其细节,但请注意,该类位于 .NET Standard 2.0 类库程序集,如图 5.1 所示:

图 5.1:.NET Standard 2.0 中的 System.Object 类定义

如果你向下滚动类代码文件,请注意 Object 类有一个名为 ToString 的方法。

良好实践:假设其他程序员知道,如果没有指定继承,则类将继承自 System.Object

避免使用 using 别名与命名空间冲突

我们需要了解更多关于命名空间及其类型的知识。可能存在两个包含相同类型名称的命名空间,导入这两个命名空间会导致歧义。例如,JsonOptions 存在于多个微软定义的命名空间中。如果你使用错误的一个来配置 JSON 序列化,那么它将被忽略,你可能会困惑为什么会出现这种情况!

让我们回顾一个虚构的例子:

// In the file, France.Paris.cs
namespace France
{
  public class Paris
  {
  }
}
// In the file, Texas.Paris.cs
namespace Texas
{
  public class Paris
  {
  }
}
// In the file, Program.cs
using France;
using Texas;
Paris p = new(); 

如果我们构建这个项目,编译器会报以下错误:

Error CS0104: 'Paris' is an ambiguous reference between 'France.Paris' and 'Texas.Paris' 

我们可以为其中一个命名空间定义一个别名以区分它,如下面的代码所示:

using France; // To use Paris.
using Tx = Texas; // Tx becomes alias for the namespace, and it is not imported.
Paris p1 = new(); // Creates an instance of France.Paris.
Tx.Paris p2 = new(); // Creates an instance of Texas.Paris. 

使用 using 别名重命名类型

你可能还想使用别名的另一种情况是,如果你想重命名一个类型。例如,如果你在 System 命名空间中大量使用 Environment 类,你可以使用别名将其重命名以使其更短,如下面的代码所示:

using Env = System.Environment;
WriteLine(Env.OSVersion);
WriteLine(Env.MachineName);
WriteLine(Env.CurrentDirectory); 

从 C# 12 开始,你可以对任何类型进行别名。这意味着你可以重命名现有的类型,或者给未命名的类型(如元组)命名,就像你将在本章后面看到的那样。

你可以在以下链接中学习如何使用别名任何类型重构你的代码:devblogs.microsoft.com/dotnet/refactor-your-code-using-alias-any-type/

在字段中存储数据

在本节中,我们将定义类中的一系列字段来存储有关个人的信息。

定义字段

假设我们已经决定一个人由姓名和出生日期组成。我们将这两个值封装在一个人中,并且这些值将对外可见:

  • Person 类内部,编写语句声明两个公共字段以存储一个人的姓名和他们出生的日期,如下面的代码所示:

    public class Person : object
    {
    **#****region** **Fields: Data or state for this person.**
    **public****string****? Name;** **// ? means it can be null.**
    **public** **DateTimeOffset Born;**
    **#****endregion**
    } 
    

对于 Born 字段的数据类型,我们有多种选择。.NET 6 引入了 DateOnly 类型。这将仅存储日期而没有时间值。DateTime 存储出生人的日期和时间,但它在本地时间和 协调世界时UTC)之间有所不同。最佳选择是 DateTimeOffset,它存储日期、时间和从 UTC 的偏移量,这与时区相关。选择取决于您需要存储多少细节。

字段类型

自 C# 8 开始,编译器有了警告您如果引用类型(如 string)可能具有 null 值并因此可能抛出 NullReferenceException 的能力。自 .NET 6 开始,SDK 默认启用这些警告。您可以在 string 类型后缀一个问号 ? 来表示您接受这一点,警告就会消失。您将在 第六章实现接口和继承类 中了解更多关于可空性和如何处理它的信息。

您可以使用任何类型作为字段,包括数组集合,如列表和字典。如果您需要在一个命名字段中存储多个值,就会使用这些类型。在这个例子中,一个人只有一个姓名和一个出生日期和时间。

成员访问修饰符

封装的一部分是选择其他代码可以访问成员的程度。

注意,就像我们对类所做的那样,我们明确地应用了 public 关键字到这些字段上。如果我们没有这样做,那么它们将隐式地成为类的 private,这意味着它们只能在类内部访问。

有四个 成员访问修饰符 关键字,以及两种可以应用于类成员(如字段或方法)的访问修饰符关键字组合。成员访问修饰符应用于单个成员。它们与应用于整个类型的类型访问修饰符类似,但却是分开的。六种可能的组合在 表 5.1 中显示:

成员访问修饰符 描述
private 该成员仅可在类型内部访问。这是默认设置。
internal 该成员可在类型内部及其同一程序集的任何类型内部访问。
protected 该成员可在类型内部及其继承类型的任何类型内部访问。
public 该成员可在任何地方访问。
internal protected 该成员可在类型内部、同一程序集的任何类型以及继承自该类型的任何类型内部访问。等同于一个虚构的访问修饰符 internal_or_protected
private protected 成员在类型内部及其继承自该类型的任何类型中是可访问的,并且在该类型所在的程序集内。等同于一个虚构的访问修饰符名称 internal_and_protected。这种组合仅在 C# 7.2 或更高版本中可用。

表 5.1:六个成员访问修饰符

良好实践:明确地将一个访问修饰符应用于所有类型成员,即使你想为成员使用隐式访问修饰符,即 private。此外,字段通常应该是 privateprotected,然后创建 public 属性来获取或设置字段值。这是因为属性控制访问。你将在本章的后面这样做。

设置和输出字段值

现在,我们将在你的代码中使用这些字段:

  1. Program.cs 中,在实例化 bob 之后,添加语句设置他的姓名和出生日期和时间,然后格式化输出这些字段,如下所示:

    bob.Name = "Bob Smith";
    bob.Born = new DateTimeOffset(
      year: 1965, month: 12, day: 22,
      hour: 16, minute: 28, second: 0,
      offset: TimeSpan.FromHours(-5)); // US Eastern Standard Time.
    WriteLine(format: "{0} was born on {1:D}.", // Long date.
      arg0: bob.Name, arg1: bob.Born); 
    

arg1 的格式代码是标准日期和时间格式之一。D 表示长日期格式,而 d 则表示短日期格式。你可以在以下链接中了解更多有关标准日期和时间格式代码的信息:learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings

  1. 运行 PeopleApp 项目并查看结果,如下所示:

    Bob Smith was born on Wednesday, December 22, 1965. 
    

如果你将调用ConfigureConsole改为使用你本地的计算机文化或指定的文化,例如法国的法国文化("fr-FR"),那么你的输出将看起来不同。

使用对象初始化器语法设置字段值

你还可以使用花括号简写对象初始化器语法来初始化字段,这是从 C# 3.0 开始引入的。让我们看看如何:

  1. 在现有代码下方添加语句以创建另一个名为 Alice 的新人物。注意在写入控制台时,她的出生日期和时间的标准格式代码与以下代码所示的不同:

    Person alice = new()
    {
      Name = "Alice Jones",
      Born = new(1998, 3, 7, 16, 28, 0,
        // This is an optional offset from UTC time zone.
        TimeSpan.Zero)
    };
    WriteLine(format: "{0} was born on {1:d}.", // Short date.
      arg0: alice.Name, arg1: alice.Born); 
    

我们可以使用字符串插值来格式化输出,但对于长字符串,它将在多行中换行,这在打印的书中可能更难阅读。在本书的代码示例中,请记住 {0}arg0 的占位符,依此类推。

  1. 运行 PeopleApp 项目并查看结果,如下所示:

    Alice Jones was born on 3/7/1998. 
    

良好实践:使用命名参数传递参数,这样更清楚地知道这些值的含义,特别是对于像DateTimeOffset这样的类型,其中有一系列数字连续出现。

使用枚举类型存储值

有时,一个值需要是有限选项集中的一个。例如,有七个古代世界奇迹,一个人可能有一个最喜欢的。

在其他时候,一个值可能需要是有限选项集的组合。例如,一个人可能有他们想要参观的古代世界奇迹的愿望清单。我们可以通过定义enum类型来存储这些数据。

enum类型是一种非常高效的存储一个或多个选择的方法,因为它在内部使用整数值与string描述的查找表的组合。让我们看看一个例子:

  1. PacktLibraryNet2项目添加一个名为WondersOfTheAncientWorld.cs的新文件。

  2. 修改WondersOfTheAncientWorld.cs的内容,如下所示:

    namespace Packt.Shared;
    public enum WondersOfTheAncientWorld
    {
      GreatPyramidOfGiza,
      HangingGardensOfBabylon,
      StatueOfZeusAtOlympia,
      TempleOfArtemisAtEphesus,
      MausoleumAtHalicarnassus,
      ColossusOfRhodes,
      LighthouseOfAlexandria
    } 
    
  3. Person.cs中,定义一个字段来存储一个人的最喜欢的古代世界奇迹,如下所示:

    public WondersOfTheAncientWorld FavoriteAncientWonder; 
    
  4. Program.cs中,设置 Bob 最喜欢的古代世界奇迹并输出,如下所示:

    bob.FavoriteAncientWonder = WondersOfTheAncientWorld.StatueOfZeusAtOlympia;
    WriteLine(
      format: "{0}'s favorite wonder is {1}. Its integer is {2}.",
      arg0: bob.Name,
      arg1: bob.FavoriteAncientWonder,
      arg2: (int)bob.FavoriteAncientWonder); 
    
  5. 运行PeopleApp项目并查看结果,如下所示:

    Bob Smith's favorite wonder is StatueOfZeusAtOlympia. Its integer is 2. 
    

enum值在内部以int的形式存储以提高效率。int值自动分配,从0开始,所以我们在enum中的第三个世界奇迹有一个值为2。你可以分配enum中没有列出的int值。它们将输出为int值而不是名称,因为找不到匹配项。

使用enum类型存储多个值

对于愿望清单,我们可以创建一个enum实例的数组或集合,集合作为字段将在本章后面介绍,但针对这种情况有一个更好的方法。我们可以使用enum标志将多个选择组合成一个值。让我们看看如何:

  1. 通过使用[Flags]属性装饰enum,并显式为每个奇迹设置代表不同位列的byte值,修改enum,如下所示:

    namespace Packt.Shared;
    **[****Flags****]**
    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**
    } 
    

enum可以继承的整数类型有ByteSByteInt16Int32Int64UInt16UInt32UInt64。新的整数类型Int128UInt128不受支持。

我们为每个选择分配显式值,这些值在查看内存中存储的位时不会重叠。我们还应该使用System.Flags属性装饰enum类型,以便当返回值时,它可以自动与多个值匹配,作为以逗号分隔的string,而不是返回int值。

通常,enum类型在内部使用int变量,但由于我们不需要那么大的值,我们可以通过将其告知使用byte变量来减少内存需求,即每个值 1 字节而不是 4 字节。例如,如果你想要定义一个表示一周中每一天的enum,那么将只有七个值。

如果我们想表明我们的愿望清单包括了巴比伦空中花园哈利卡纳苏斯的陵墓这些古代世界的奇迹,那么我们希望将162位设置为1。换句话说,我们将存储值18,如表 5.2所示:

64 32 16 8 4 2 1
0 0 1 0 0 1 0

表 5.2:在枚举中用位存储 18

  1. Person.cs 中,保留现有的字段以存储单个最喜欢的古代世界奇迹,并添加以下语句到您的字段列表中,以存储多个古代世界奇迹:

    public WondersOfTheAncientWorld BucketList; 
    
  2. Program.cs 中,添加语句使用 | 运算符(位逻辑或)来组合枚举值。我们也可以使用将数字 18 强制转换为枚举类型的值来设置该值,如注释所示,但我们不应该这样做,因为这会使代码更难以理解,如下面的代码所示:

    bob.BucketList =
      WondersOfTheAncientWorld.HangingGardensOfBabylon
      | WondersOfTheAncientWorld.MausoleumAtHalicarnassus;
    // bob.BucketList = (WondersOfTheAncientWorld)18;
    WriteLine($"{bob.Name}'s bucket list is {bob.BucketList}."); 
    
  3. 运行 PeopleApp 项目并查看以下输出结果:

    Bob Smith's bucket list is HangingGardensOfBabylon, MausoleumAtHalicarnassus. 
    

良好实践:使用枚举值来存储离散选项的组合。如果有最多八个选项,则从 byte 派生枚举类型;如果有最多 16 个选项,则从 ushort 派生;如果有最多 32 个选项,则从 uint 派生;如果有最多 64 个选项,则从 ulong 派生。

现在我们已经用 [Flags] 属性装饰了 enum,值的组合可以存储在一个单独的变量或字段中。现在,当应该只存储一个值时,程序员也可以在 FavoriteAncientWonder 中存储值的组合。为了强制执行这一点,我们应该将字段转换为属性,这样我们就可以控制其他程序员如何获取和设置值。您将在本章后面看到如何做到这一点。

改变枚举基类型以提高性能

前一节是关于使用枚举类型存储多个值。这是关于带有 [Flags] 属性的枚举类型,它们使用位操作有效地存储这些多个值。在代码示例中,我们定义了一个用于古代世界七大奇迹的枚举,因此只需要七个可组合的值(以及 0 表示 None)。

前一节并不是关于将所有枚举类型派生为 byte 以使您的代码更快,因为这将是糟糕的建议。

在 2024 年 3 月 18 日,尼克·查帕斯(Nick Chapsas)发布了一个 YouTube 视频,标题为 Turn All Your Enums Into Bytes Now! | Code Cop #014,您可以通过以下链接观看:www.youtube.com/watch?v=1gWzE9SIGkQ。他批评了一些博客文章,这些文章建议将枚举类型的默认基整数类型从 int 改为 byte 以提高性能。

C#语言的原始设计者花费了努力来实现枚举类型可以从除了默认的 int 之外的其他整数类型派生的能力。例如,您可以通过使用正整数如 byteushort 来使用更少的字节,或者使用正整数如 uintulong 来使用相同或更多的字节。他们实现了这个特性,因为有时.NET 开发者将需要这种能力。

我认为让我的读者知道在必要时他们可以这样做是很重要的。微软的官方指南指出,“尽管你可以更改此基础类型,但在大多数情况下并不必要或推荐这样做。使用小于 Int32 的数据类型并不会带来显著的性能提升。”,正如你可以在以下链接中阅读的那样:learn.microsoft.com/en-us/dotnet/fundamentals/code-analysis/quality-rules/ca1028

对于那些反对将任何 enumint 更改为其他整数的开发者,编译器代码分析警告如上所述。如果启用,当将 enum 设置为除 int 之外的内容时,它将触发:“CA1028: enum 存储应该是 Int32。”此警告默认未启用,因为微软知道开发者可能有合法的理由需要使用它。

让我们看看一些现实生活中的例子,说明你为什么需要将 enum 从派生自 int 改为派生自其他整数类型:

  • 你想增加整数的尺寸,以便在标志 enum 中存储超过 16 个选项。默认的 int 只允许 16 个选项:0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 和 16384。改为 uint 会将选择数量加倍到 32,而不会占用更多内存空间。改为 ulong 会提供 64 个选项。改为 ushort 会允许在半字节的相同 16 个选项。

  • 你需要通过串行连接将数据作为二进制流传输到嵌入式设备,并且必须仔细遵循协议,或者你正在定义自己的串行消息协议,并且你希望减小数据包大小以充分利用可用带宽。

  • 你有包含数百万条记录的 SQL 表,其中一些列是 enum 值。将这些列设置为 tinyint,并在 C# 实体类中匹配 enum : byte 属性,可以使索引通过变得更小并减少从磁盘读取的页面数来提高性能。一些开发者会处理 30 年或更老的系统,这些系统使用旋转金属磁盘。并不是每个人都部署到现代 64 位操作系统和现代硬件上。

  • 你需要减小 struct 的大小,因为它将在资源受限的硬件上每秒创建 100,000 次,或者你有游戏代码设置为使用 byteshort,因为你有为游戏数据在连续数组中有数百万个它们。这样做可以获得相当大的性能提升,尤其是在缓存方面。

现在,让我们看看如何使用集合存储多个值。

使用集合存储多个值

现在,让我们添加一个字段来存储一个人的孩子。这是一个聚合的例子,因为孩子是相关于当前人的类的实例,但它们不是人本身的一部分。我们将使用可以存储任何类型有序集合的泛型 List<T> 集合类型。你将在 第八章使用常见的 .NET 类型 中了解更多关于集合的内容。现在,只需按照以下步骤操作:

  • Person.cs 文件中,声明一个新的字段来存储代表此人的多个 Person 实例,如下面的代码所示:

    public List<Person> Children = new(); 
    

List<Person> 读作“Person 的列表”,例如,“名为 Children 的属性类型是 Person 实例的列表。”

在我们向集合中添加项目之前,必须确保集合被初始化为一个新的实例;否则,该字段将为 null,并在我们尝试使用其任何成员(如 Add)时抛出运行时异常。

理解泛型集合

List<T> 类型中的尖括号是 C# 中称为 泛型 的特性,该特性于 2005 年随 C# 2.0 引入。这是一个用于创建 强类型 集合的术语,即编译器知道可以存储在集合中的特定类型的对象。泛型可以提高代码的性能和正确性。

强类型静态类型 有不同的含义。旧的 System.Collection 类型是静态类型,用于包含弱类型的 System.Object 项目。较新的 System.Collection.Generic 类型是静态类型,用于包含强类型的 <T> 实例。

具有讽刺意味的是,术语 泛型 意味着我们可以使用更具体的静态类型!

  1. Program.cs 中,添加语句为 Bob 添加三个孩子,然后显示他有多少孩子以及他们的名字,如下面的代码所示:

    // Works with all versions of C#.
    Person alfred = new Person();
    alfred.Name = "Alfred";
    bob.Children.Add(alfred);
    // Works with C# 3 and later.
    bob.Children.Add(new Person { Name = "Bella" });
    // Works with C# 9 and later.
    bob.Children.Add(new() { Name = "Zoe" });
    WriteLine($"{bob.Name} has {bob.Children.Count} children:");
    for (int childIndex = 0; childIndex < bob.Children.Count; childIndex++)
    {
      WriteLine($"> {bob.Children[childIndex].Name}");
    } 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Bob Smith has 3 children:
    > Alfred
    > Bella
    > Zoe 
    

我们也可以使用 foreach 语句遍历集合。作为一个可选挑战,将 for 语句改为使用 foreach 输出相同的信息。

使字段成为静态

我们迄今为止创建的字段都是 实例成员,这意味着每个创建的类的每个字段都有一个不同的值。alicebob 变量有不同的 Name 值。

有时,你想要定义一个字段,该字段只有一个值,这个值在所有实例之间共享。

这些被称为 静态成员,因为字段不是唯一可以静态的成员。让我们以银行账户为例,看看使用 static 字段可以实现什么。BankAccount 的每个实例都将有自己的 AccountNameBalance 值,但所有实例将共享一个单一的 InterestRate 值。

让我们动手做:

  1. PacktLibraryNet2 项目中,添加一个名为 BankAccount.cs 的新类文件。

  2. 修改类,使其具有三个字段——两个实例字段和一个静态字段——如下面的代码所示:

    namespace Packt.Shared;
    public class BankAccount
    {
      public string? AccountName; // Instance member. It could be null.
      public decimal Balance; // Instance member. Defaults to zero.
      public static decimal InterestRate; // Shared member. Defaults to zero.
    } 
    
  3. Program.cs 文件中,添加语句来设置共享利率,然后创建两个 BankAccount 类型的实例,如下面的代码所示:

    BankAccount.InterestRate = 0.012M; // Store a shared value in static field.
    BankAccount jonesAccount = new();
    jonesAccount.AccountName = "Mrs. Jones";
    jonesAccount.Balance = 2400;
    WriteLine(format: "{0} earned {1:C} interest.",
      arg0: jonesAccount.AccountName,
      arg1: jonesAccount.Balance * BankAccount.InterestRate);
    BankAccount gerrierAccount = new();
    gerrierAccount.AccountName = "Ms. Gerrier";
    gerrierAccount.Balance = 98;
    WriteLine(format: "{0} earned {1:C} interest.",
      arg0: gerrierAccount.AccountName,
      arg1: gerrierAccount.Balance * BankAccount.InterestRate); 
    
  4. 运行 PeopleApp 项目并查看附加输出:

    Mrs. Jones earned $28.80 interest.
    Ms. Gerrier earned $1.18 interest. 
    

记住 C 是一个格式代码,它告诉 .NET 使用当前文化的货币格式来格式化十进制数字。

字段不是唯一可以静态的成员。构造函数、方法、属性和其他成员也可以是静态的。

静态方法不需要对象实例即可调用。例如,Console.WriteLine 不需要对象实例;方法直接从类名调用。静态方法在 第六章,实现接口和继承类 中介绍。

将字段设置为常量

如果一个字段的值永远不会改变,你可以使用 const 关键字并在编译时分配一个字面量值。任何更改该值的语句都会导致编译时错误。让我们看看一个简单的例子:

  1. Person.cs 文件中,添加一个表示人的物种的 string 常量,如下面的代码所示:

    // Constant fields: Values that are fixed at compilation.
    public const string Species = "Homo Sapiens"; 
    
  2. 要获取常量字段的值,你必须写出类的名称,而不是类的实例名称。在 Program.cs 文件中,添加一个语句将鲍勃的名字和物种写入控制台,如下面的代码所示:

    // Constant fields are accessible via the type.
    WriteLine($"{bob.Name} is a {Person.Species}."); 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Bob Smith is a Homo Sapiens. 
    

Microsoft 类型中 const 字段的示例包括 System.Int32.MaxValueSystem.Math.PI,因为这两个值永远不会改变,如 图 5.2 所示:

图 5.2:System.Math 类中常量的示例

良好实践:常量并不总是最佳选择,有两个重要原因:值必须在编译时已知,并且必须可以用字面量 stringBoolean 或数字值表示。对 const 字段的每个引用在编译时都会被替换为字面量值,因此,如果值在未来版本中更改,并且你没有重新编译引用它的任何程序集以获取新值,则将不会反映出来。

将字段设置为只读

对于不应更改的字段,通常更好的选择是将它们标记为只读:

  1. Person.cs 文件中,添加一个语句来声明一个实例只读字段以存储一个人的家乡星球,如下面的代码所示:

    // Read-only fields: Values that can be set at runtime.
    public readonly string HomePlanet = "Earth"; 
    
  2. Program.cs 文件中,添加一个语句将鲍勃的名字和家乡星球写入控制台,如下面的代码所示:

    // Read-only fields are accessible via the variable.
    WriteLine($"{bob.Name} was born on {bob.HomePlanet}."); 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Bob Smith was born on Earth. 
    

良好实践:与常量字段相比,使用只读字段有两个重要原因:值可以在运行时计算或加载,并且可以使用任何可执行语句表示。因此,只读字段可以使用构造函数或字段赋值来设置。对只读字段的每个引用都是一个活动引用,因此任何未来的更改都将被调用代码正确地反映出来。

你也可以声明static readonly字段,其值将在类型的所有实例之间共享。

在实例化时要求字段必须设置

C# 11 引入了required修饰符。如果你在字段或属性上使用它,编译器将确保你在实例化时将该字段或属性设置为值。它需要目标.NET 7 或更高版本,因此我们需要首先创建一个新的类库:

  1. Chapter05解决方案中,添加一个名为PacktLibraryModern的新类库项目,目标为.NET 9。(required修饰符支持的最旧版本是.NET 7。)

  2. PacktLibraryModern项目中,将Class1.cs重命名为Book.cs

  3. 修改代码文件内容,给类添加四个字段,其中两个设置为required,如下面的代码所示:

    namespace Packt.Shared;
    public class Book
    {
      // Needs .NET 7 or later as well as C# 11 or later.
      public required string? Isbn;
      public required string? Title;
      // Works with any version of .NET.
      public string? Author;
      public int PageCount;
    } 
    

注意所有三个字符串属性都是可空的。将属性或字段设置为required并不意味着它不能是null。它只是意味着它必须显式设置为null

  1. PeopleApp控制台应用程序项目中,添加对PacktLibraryModern类库项目的引用:

    • 如果你使用 Visual Studio,那么在解决方案资源管理器中,选择PeopleApp项目,导航到项目 | 添加项目引用…,勾选复选框以选择PacktLibraryModern项目,然后点击确定

    • 如果你使用 VS Code,那么编辑PeopleApp.csproj以添加对PacktLibraryModern的引用,如下面的标记所示:

      <ItemGroup>
        <ProjectReference Include=
      "..\PacktLibraryNet2\PacktLibraryNet2.csproj" />
       **<ProjectReference Include=**
      **"..\PacktLibraryModern\PacktLibraryModern.csproj"** **/>**
      </ItemGroup> 
      
  2. 构建项目PeopleApp。这将编译其引用的依赖项,并将类库.dll复制到本地bin文件夹的正确子目录中。

  3. PeopleApp项目中的Program.cs中,尝试不设置IsbnTitle字段来实例化Book,如下面的代码所示:

    Book book = new(); 
    
  4. 注意你将看到编译器错误,如下面的输出所示:

    C:\cs13net9\Chapter05\PeopleApp\Program.cs(137,13): error CS9035: Required member 'Book.Isbn' must be set in the object initializer or attribute constructor. [C:\cs13net9\Chapter05\PeopleApp\PeopleApp.csproj]
    C:\cs13net9\Chapter05\PeopleApp\Program.cs(137,13): error CS9035: Required member 'Book.Title' must be set in the object initializer or attribute constructor. [C:\cs13net9\Chapter05\PeopleApp\PeopleApp.csproj]
        0 Warning(s)
        2 Error(s) 
    
  5. Program.cs中,修改语句以使用对象初始化语法设置两个必需的属性,如下面的代码所示:

    Book book = new()
    **{**
     **Isbn =** **"978-1803237800"****,**
     **Title =** **"C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals"**
    **}**; 
    
  6. 注意现在这个语句可以无错误地编译。

  7. Program.cs中,添加语句以输出有关书籍的信息,如下面的代码所示:

    WriteLine("{0}: {1} written by {2} has {3:N0} pages.",
      book.Isbn, book.Title, book.Author, book.PageCount); 
    

在我们运行项目并查看输出之前,让我们讨论一种初始化类型字段(或属性)的替代方法。

使用构造函数初始化字段

字段通常需要在运行时进行初始化。你可以在使用new关键字创建类的实例时调用的构造函数中这样做。构造函数在代码设置任何字段之前执行:

  1. Person.cs中,在现有的只读HomePlanet字段之后添加语句以定义第二个只读字段,然后在构造函数中设置NameInstantiated字段,如下面的代码所示:

    // Read-only fields: Values that can be set at runtime.
    public readonly string HomePlanet = "Earth";
    **public****readonly** **DateTime Instantiated;**
    #endregion
    **#****region** **Constructors: Called when using new to instantiate a type.**
    **public****Person****()**
    **{**
    **// Constructors can set default values for fields**
    **// including any read-only fields like Instantiated.**
     **Name =** **"Unknown"****;**
     **Instantiated = DateTime.Now;**
    **}**
    **#****endregion** 
    
  2. Program.cs中,添加语句以实例化一个新的Person对象,然后输出其初始字段值,如下面的代码所示:

    Person blankPerson = new();
    WriteLine(format:
      "{0} of {1} was created at {2:hh:mm:ss} on a {2:dddd}.",
      arg0: blankPerson.Name,
      arg1: blankPerson.HomePlanet,
      arg2: blankPerson.Instantiated); 
    
  3. 运行PeopleApp项目并查看关于书籍以及空白人物的代码结果,如下所示输出:

    978-1803237800: C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals written by  has 0 pages.
    Unknown of Earth was created at 11:58:12 on a Sunday 
    

定义多个构造函数

你可以在一个类型中拥有多个构造函数。这特别有用,可以鼓励开发者为字段设置初始值:

  1. Person.cs中,添加定义第二个构造函数的语句,允许开发者为人的姓名和家园行星设置初始值,如下所示代码:

    public Person(string initialName, string homePlanet)
    {
      Name = initialName;
      HomePlanet = homePlanet;
      Instantiated = DateTime.Now;
    } 
    
  2. Program.cs中,添加使用两个参数的构造函数创建另一个人的语句,如下所示代码:

    Person gunny = new(initialName: "Gunny", homePlanet: "Mars");
    WriteLine(format:
      "{0} of {1} was created at {2:hh:mm:ss} on a {2:dddd}.",
      arg0: gunny.Name,
      arg1: gunny.HomePlanet,
      arg2: gunny.Instantiated); 
    
  3. 运行PeopleApp项目并查看结果:

    Gunny of Mars was created at 11:59:25 on a Sunday 
    

使用构造函数设置必需字段

现在,让我们回到具有required字段的Book类示例:

  1. PacktLibraryModern项目中,在Book.cs中,添加定义一对构造函数的语句,一个支持对象初始化器语法,另一个用于设置两个必需属性,如下所示代码:

    public class Book
    {
    **// Constructor for use with object initializer syntax.**
    **public****Book****()** **{ }**
    **// Constructor with parameters to set required fields.**
    **public****Book****(****string****? isbn,** **string****? title****)**
     **{**
     **Isbn = isbn;**
     **Title = title;**
     **}** 
    
  2. Program.cs中,注释掉使用对象初始化器语法实例化书籍的语句,添加一个使用构造函数实例化书籍的语句,然后设置书籍的非required属性,如下所示代码:

    **/***
    **// Instantiate a book using object initializer syntax.**
    Book book = new()
    {
      Isbn = "978-1803237800",
      Title = "C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals"
    };
    ***/**
    **Book book =** **new****(isbn:** **"978-1803237800"****, title:**
    **"C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals"****)**
    **{**
     **Author =** **"Mark J. Price"****,**
     **PageCount =** **821**
    **};** 
    
  3. 注意,你将看到与之前相同的编译器错误,因为编译器无法自动判断调用构造函数是否会设置这两个required属性。

  4. PacktLibraryModern项目中,在Book.cs中,导入命名空间以执行代码分析,然后使用属性装饰构造函数,告诉编译器它设置了所有必需属性和字段,如下所示代码:

    **using** **System.Diagnostics.CodeAnalysis;** **// To use [SetsRequiredMembers].**
    namespace Packt.Shared;
    public class Book
    {
      public Book() { } // For use with initialization syntax.
     **[****SetsRequiredMembers****]**
      public Book(string isbn, string title) 
    
  5. Program.cs中,注意现在调用构造函数的语句可以无错误地编译。

  6. 可选地,运行PeopleApp项目以确认其行为符合预期,如下所示输出:

    978-1803237800: C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals written by Mark J. Price has 821 pages. 
    

更多信息:你可以在以下链接中了解更多关于required字段以及如何使用构造函数设置它们的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/required

构造函数是方法的一种特殊类别。让我们更详细地看看方法。

与方法和元组一起工作

方法是类型的成员,执行一组语句。它们是属于类型的函数。

从方法返回值

方法可以返回单个值或无返回值:

  • 执行某些操作但不返回值的函数通过在方法名称之前使用void类型来表示。

  • 执行某些操作并返回值的函数通过在方法名称之前返回值的类型来表示。

例如,在下一个任务中,你将创建两个方法:

  • WriteToConsole:这将执行一个操作(将一些文本写入控制台),但方法将不返回任何内容,这由void关键字表示。

  • GetOrigin:这将返回一个由 string 关键字指示的文本值。

让我们编写代码:

  1. Person.cs 文件中,添加语句来定义我之前描述的两个方法,如下面的代码所示:

    #region Methods: Actions the type can perform.
    public void WriteToConsole()
    {
      WriteLine($"{Name} was born on a {Born:dddd}.");
    }
    public string GetOrigin()
    {
      return $"{Name} was born on {HomePlanet}.";
    }
    #endregion 
    
  2. Program.cs 文件中,添加语句来调用两个方法,如下面的代码所示:

    bob.WriteToConsole();
    WriteLine(bob.GetOrigin()); 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Bob Smith was born on a Wednesday.
    Bob Smith was born on Earth. 
    

定义和传递方法参数

方法可以通过传递参数来改变其行为。参数的定义有点像变量声明,但位于方法声明的括号内,就像你在本章前面看到的构造函数一样。让我们看看更多的例子:

  1. Person.cs 文件中,添加语句来定义两个方法,第一个不带参数,第二个带一个参数,如下面的代码所示:

    public string SayHello()
    {
      return $"{Name} says 'Hello!'";
    }
    public string SayHelloTo(string name)
    {
      return $"{Name} says 'Hello, {name}!'";
    } 
    
  2. Program.cs 文件中,添加语句来调用两个方法,并将返回值写入控制台,如下面的代码所示:

    WriteLine(bob.SayHello());
    WriteLine(bob.SayHelloTo("Emily")); 
    
  3. 运行 PeopleApp 项目并查看结果:

    Bob Smith says 'Hello!'
    Bob Smith says 'Hello, Emily!' 
    

当你键入调用方法的语句时,IntelliSense 会显示一个工具提示,显示方法名称、任何参数的类型以及方法的返回类型。

重载方法

而不是有两个不同的方法名称,我们可以给这两个方法相同的名称。这是允许的,因为每个方法都有不同的签名。

方法签名是一组可以在调用方法时传递的参数类型。重载方法必须在参数类型列表上有所不同。两个重载方法不能有相同的参数类型列表,并且仅在返回类型上有所不同。让我们通过一个示例来编写代码:

  1. Person.cs 文件中,将 SayHelloTo 方法的名称更改为 SayHello

  2. Program.cs 文件中,将方法调用更改为使用 SayHello 方法,并注意方法快速信息告诉你它有一个额外的重载,1 of 2,以及 2 of 2,在 Visual Studio 中,尽管其他代码编辑器可能不同,如图 5.3 所示:

图 5.3:一个重载方法的 IntelliSense 工具提示

良好实践:使用重载方法通过使类看起来有更少的方法来简化你的类。

传递可选参数

简化方法的另一种方法是使参数可选。你通过在方法参数列表中分配默认值来使参数可选。可选参数必须始终位于参数列表的末尾。

我们现在将创建一个有三个可选参数的方法:

  1. Person.cs 文件中,添加语句来定义方法,如下面的代码所示:

    public string OptionalParameters(string command = "Run!",
      double number = 0.0, bool active = true)
    {
      return string.Format(
        format: "command is {0}, number is {1}, active is {2}",
        arg0: command,
        arg1: number,
        arg2: active);
    } 
    
  2. Program.cs 文件中,添加语句来调用方法,并将返回值写入控制台,如下面的代码所示:

    WriteLine(bob.OptionalParameters()); 
    
  3. 当你键入代码时,IntelliSense 会显示出来。你会看到一个工具提示,显示三个带有默认值的可选参数。

  4. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    command is Run!, number is 0, active is True 
    
  5. Program.cs 文件中,添加一条语句以传递 command 参数的 string 值和 number 参数的 double 值,如下面的代码所示:

    WriteLine(bob.OptionalParameters("Jump!", 98.5)); 
    
  6. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    command is Jump!, number is 98.5, active is True 
    

commandnumber 参数的默认值已被替换,但 active 的默认值仍然是 true

调用方法时命名参数值

当调用方法时,可选参数通常与命名参数结合使用,因为命名参数允许以不同于声明时的顺序传递值:

  1. Program.cs 文件中,添加一条语句以传递 command 参数的 string 值和 number 参数的 double 值,但使用命名参数,这样它们传递的顺序可以互换,如下面的代码所示:

    WriteLine(bob.OptionalParameters(number: 52.7, command: "Hide!")); 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    command is Hide!, number is 52.7, active is True 
    

你甚至可以使用命名参数来跳过可选参数。

  1. Program.cs 文件中,添加一条语句以使用位置顺序传递 command 参数的 string 值,跳过 number 参数,并使用命名的 active 参数,如下面的代码所示:

    WriteLine(bob.OptionalParameters("Poke!", active: false)); 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    command is Poke!, number is 0, active is False 
    

    良好实践:虽然可以混合命名和位置参数值,但大多数开发者更喜欢在同一个方法调用中只使用一种或另一种方式来阅读代码。

混合可选和必需参数

目前,OptionalParameters 方法中的所有参数都是可选的。如果其中一个参数是必需的怎么办?

  1. Person.cs 文件中,将 OptionalParameters 方法添加一个不带默认值的第四个参数,如下面的代码所示:

    public string OptionalParameters(string command = "Run!",
      double number = 0.0, bool active = true**,** **int** **count**) 
    
  2. 构建项目并注意编译器错误:

    Error CS1737 Optional parameters must appear after all required parameters. 
    
  3. OptionalParameters 方法中,将 count 参数移动到可选参数之前,如下面的代码所示:

    public string OptionalParameters(**int** **count,**
      string command = "Run!",
      double number = 0.0, bool active = true) 
    
  4. Program.cs 文件中,修改所有对 OptionalParameters 方法的调用,将 int 值作为第一个参数传递,例如,如下面的代码所示:

    WriteLine(bob.OptionalParameters(3));
    WriteLine(bob.OptionalParameters(3, "Jump!", 98.5));
    WriteLine(bob.OptionalParameters(3, number: 52.7, command: "Hide!"));
    WriteLine(bob.OptionalParameters(3, "Poke!", active: false)); 
    

记住,如果你命名了参数,那么你可以改变它们的顺序,例如:bob.OptionalParameters(number: 52.7, command: "Hide!", count: 3)

  1. 当你调用 OptionalParameters 方法时,注意 Visual Studio 中显示的工具提示,它显示了必需的一个参数、三个可选参数及其默认值,如图 5.4 所示:

图 5.4:IntelliSense 显示了你键入代码时必需和可选参数

控制参数的传递方式

当一个参数传递给方法时,它可以以几种方式之一传递:

  • 通过 (这是默认方式):将这些视为单向的。虽然值可以被更改,但这只会影响方法中的参数。

  • 作为 out 参数:将这些视为只写out 参数在其声明中不能有默认值,也不能未初始化。它们必须在方法内部设置;否则,编译器会报错。

  • 通过引用作为 ref 参数:将这些视为读写。与 out 参数一样,ref 参数也不能有默认值,但由于它们可以在方法外部设置,因此不需要在方法内部设置。

  • 作为 in 参数:将这些视为只读的引用参数。in 参数的值不能被更改,如果你尝试更改,编译器会报错。

让我们看看一些在方法中传递参数进出的示例:

  1. Person.cs 文件中,添加语句来定义一个具有三个参数的方法,一个 in 参数,一个 ref 参数和一个 out 参数,如下面的方法所示:

    public void PassingParameters(int w, in int x, ref int y, out int z)
    {
      // out parameters cannot have a default and they
      // must be initialized inside the method.
      z = 100;
      // Increment each parameter except the read-only x.
      w++;
      // x++; // Gives a compiler error!
      y++;
      z++;
      WriteLine($"In the method: w={w}, x={x}, y={y}, z={z}");
    } 
    
  2. Program.cs 文件中,添加语句来声明一些 int 变量并将它们传递到方法中,如下面的代码所示:

    int a = 10;
    int b = 20;
    int c = 30;
    int d = 40;
    WriteLine($"Before: a={a}, b={b}, c={c}, d={d}");
    bob.PassingParameters(a, b, ref c, out d);
    WriteLine($"After: a={a}, b={b}, c={c}, d={d}"); 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Before: a=10, b=20, c=30, d=40
    In the method: w=11, x=20, y=31, z=101
    After: a=10, b=20, c=31, d=101 
    

注意以下关于前面输出的内容:

  • 当默认地以参数的形式传递变量时,传递的是变量的当前值,而不是变量本身。因此,wa 变量的值的副本。即使 w 增加到 11,a 变量仍然保留其原始值 10

  • 当将变量作为 in 参数传递时,变量的引用被传递到方法中。因此,xb 的引用。如果在方法执行过程中 b 变量被其他过程增加,则 x 参数会显示这一点。

  • 当将变量作为 ref 参数传递时,变量的引用被传递到方法中。因此,yc 的引用。当 y 参数增加时,c 变量也会增加。

  • 当将变量作为 out 参数传递时,变量的引用被传递到方法中。因此,zd 的引用。d 变量的值会被方法内部执行的任何代码替换。

我们可以通过不将值 40 赋给 d 变量来简化 Main 方法中的代码,因为它的值无论如何都会被替换。在 C# 7 及以后的版本中,我们可以简化使用 out 参数的代码。

  1. Program.cs 文件中,添加语句来声明一些额外的变量,包括一个名为 hout 参数,该参数是内联声明的,如下面的代码所示:

    int e = 50;
    int f = 60;
    int g = 70;
    WriteLine($"Before: e={e}, f={f}, g={g}, h doesn't exist yet!");
    // Simplified C# 7 or later syntax for the out parameter.
    bob.PassingParameters(e, f, ref g, out int h);
    WriteLine($"After: e={e}, f={f}, g={g}, h={h}"); 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Before: e=50, f=60, g=70, h doesn't exist yet!
    In the method: w=51, x=60, y=71, z=101
    After: e=50, f=60, g=71, h=101 
    

传递可变数量的参数

C# 中的 params 关键字用于指定一个可以接受可变数量参数的方法参数。它允许你将一个参数数组传递给方法,或者传递一个编译器会自动转换为数组的参数列表。

在 C# 13 及更高版本中,除了传递数组外,还可以传递任何可以通过集合表达式构造的类型,例如 List<T>IEnumerable<T>。集合表达式在 第八章,使用常见的 .NET 类型 中有详细说明,但基本上,你可以通过在方括号 [ ] 中包围逗号分隔的项列表来定义一个项集合。

使用 params 的好处包括灵活性。它允许方法接受任意数量的参数,并且易于使用,因为它通过避免在调用方法之前显式创建数组来简化方法签名。

在使用 params 关键字时有一些考虑事项:

  • params 关键字只能用于方法中的一个参数,如果方法有多个参数,它必须是最后一个参数。

  • 你可以传递单个参数,编译器会自动将它们打包成一个数组。或者,你可以传递一个数组或集合表达式。

  • 当使用 params 时,你可以不传递任何参数调用该方法,并将传递一个空数组。

让我们看看一些代码示例:

  1. Person.cs 中,添加语句定义一个方法,包含两个参数,一个普通参数和一个 params 参数,如下所示的方法:

    public void ParamsParameters(
      string text, params int[] numbers)
    {
      int total = 0;
      foreach(int number in numbers)
      {
        total += number;
      }
      WriteLine($"{text}: {total}");
    } 
    

    在 C# 12 及更早版本中,params int[] 必须是一个数组,尽管项目可以是任何数据类型,而不仅仅是 int。在 C# 13 及更高版本中,params int[] 可以是集合表达式支持的任何数据类型,如 params List<int>IEnumerable<int>,尽管项目可以是任何数据类型,而不仅仅是 int

  2. Program.cs 中,添加语句以各种方式将字符串和一些数字传递给方法,如下面的代码所示:

    bob.ParamsParameters("Sum using commas",
      3, 6, 1, 2);
    bob.ParamsParameters("Sum using collection expression",
      [3, 6, 1, 2]);
    bob.ParamsParameters("Sum using explicit array",
      new int[] { 3, 6, 1, 2 });
    bob.ParamsParameters("Sum (empty)"); 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Sum using commas: 12
    Sum using collection expression: 12
    Sum using explicit array: 12
    Sum (empty): 0 
    

params 关键字是 C# 中一个强大的功能,它通过允许方法接受可变数量的参数,增强了代码的灵活性和可读性。

理解 ref 返回值

在 C# 7 及更高版本中,ref 关键字不仅用于将参数传递给方法;它还可以应用于 return 值。这允许外部变量引用内部变量,并在方法调用后修改其值。这在高级场景中可能很有用,例如,将占位符传递给大数据结构,但这超出了本书的范围。如果你有兴趣了解更多,可以阅读以下链接中的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/ref#reference-return-values

现在,让我们回到查看方法返回值的更高级场景。

使用元组组合多个返回值

每个方法只能返回一个具有单个类型的单一值。该类型可以是简单类型,如前例中的string;复杂类型,如Person;或集合类型,如List<Person>

假设我们想要定义一个名为GetTheData的方法,该方法需要返回一个string值和一个int值。我们可以定义一个名为TextAndNumber的新类,其中包含一个string字段和一个int字段,并返回该复杂类型的实例,如下代码所示:

public class TextAndNumber
{
  public string Text;
  public int Number;
}
public class LifeTheUniverseAndEverything
{
  public TextAndNumber GetTheData()
  {
    return new TextAndNumber
    {
      Text = "What's the meaning of life?",
      Number = 42
    };
  }
} 

但仅仅为了组合两个值而定义一个类是不必要的,因为在现代版本的 C#中,我们可以使用元组。元组是将两个或更多值组合成一个单元的高效方式。我读作tuh-ples,但我听说其他开发者读作too-ples。To-may-toe, to-mah-toe, po-tay-toe, po-tah-toe,我想。

元组是某些语言的一部分,如 F#,自其第一个版本以来就有,但.NET 直到 2010 年的.NET 4 才添加了对它们的支持,使用System.Tuple类型。

直到 2017 年的 C# 7 版本,C#才添加了对元组的语言语法支持,使用括号字符(),同时.NET 添加了一个新的System.ValueTuple类型,在某些常见场景中比旧的.NET 4 System.Tuple类型更高效。C#的元组语法使用更高效的那个。

让我们探索元组:

  1. Person.cs中,添加语句定义一个返回组合stringint的元组的方法,如下代码所示:

    // Method that returns a tuple: (string, int).
    public (string, int) GetFruit()
    {
      return ("Apples", 5);
    } 
    
  2. Program.cs中,添加语句调用GetFruit方法,然后输出元组的字段,这些字段自动命名为Item1Item2,如下代码所示:

    (string, int) fruit = bob.GetFruit();
    WriteLine($"{fruit.Item1}, {fruit.Item2} there are."); 
    
  3. 运行PeopleApp项目并查看结果,如下所示输出:

    Apples, 5 there are. 
    

为元组的字段命名

要访问元组的字段,默认名称是Item1Item2等。

你可以显式指定字段名称:

  1. Person.cs中,添加语句定义一个返回具有命名字段的元组的方法,如下代码所示:

    // Method that returns a tuple with named fields.
    public (string Name, int Number) GetNamedFruit()
    {
      return (Name: "Apples", Number: 5);
    } 
    
  2. Program.cs中,添加语句调用该方法并输出元组的命名字段,如下代码所示:

    var fruitNamed = bob.GetNamedFruit();
    WriteLine($"There are {fruitNamed.Number} {fruitNamed.Name}."); 
    

我们使用var来缩短以下完整语法:

`(string Name, int Number) fruitNamed = bob.GetNamedFruit();` 
  1. 运行PeopleApp项目并查看结果,如下所示输出:

    There are 5 Apples. 
    

如果你从一个对象构造元组,你可以使用 C# 7.1 中引入的一个特性,称为元组名称推断

  1. Program.cs中,创建两个元组,每个元组由一个string值和一个int值组成,如下代码所示:

    var thing1 = ("Neville", 4);
    WriteLine($"{thing1.Item1} has {thing1.Item2} children.");
    var thing2 = (bob.Name, bob.Children.Count);
    WriteLine($"{thing2.Name} has {thing2.Count} children."); 
    

在 C# 7 中,两者都会使用Item1Item2命名方案。在 C# 7.1 及以后版本中,thing2可以推断出NameCount名称。

别名元组

在 C# 12 中引入了为元组别名的功能,以便你可以命名类型,并在声明变量和参数时使用该类型名,例如,如下代码所示:

using UnnamedParameters = (string, int); // Aliasing a tuple type.
// Aliasing a tuple type with parameter names.
using Fruit = (string Name, int Number); 

在别名元组时,使用标题大小写命名约定为其参数命名,例如,NameNumberBirthDate

让我们看看一个例子:

  1. Program.cs 文件顶部,定义一个命名元组类型,如下所示代码:

    using Fruit = (string Name, int Number); // Aliasing a tuple type. 
    
  2. Program.cs 中,复制并粘贴调用 GetNamedFruit 方法的语句,并将 var 更改为 Fruit,如下所示代码:

    // Without an aliased tuple type.
    //var fruitNamed = bob.GetNamedFruit();
    // With an aliased tuple type.
    Fruit fruitNamed = bob.GetNamedFruit(); 
    
  3. 运行 PeopleApp 项目并注意结果相同。

分解元组

您还可以将元组分解到单独的变量中。分解声明具有与命名字段元组相同的语法,但没有为元组命名变量,如下所示代码:

// Store return value in a tuple variable with two named fields.
(string name, int number) namedFields = bob.GetNamedFruit();
// You can then access the named fields.
WriteLine($"{namedFields.name}, {namedFields.number}");
// Deconstruct the return value into two separate variables.
(string name, int number) = bob.GetNamedFruit();
// You can then access the separate variables.
WriteLine($"{name}, {number}"); 

分解的效果是将元组拆分为其部分,并将这些部分分配给新的变量。让我们看看它是如何工作的:

  1. Program.cs 中,添加语句来分解 GetFruit 方法返回的元组,如下所示代码:

    (string fruitName, int fruitNumber) = bob.GetFruit();
    WriteLine($"Deconstructed tuple: {fruitName}, {fruitNumber}"); 
    
  2. 运行 PeopleApp 项目并查看结果,如下所示输出:

    Deconstructed tuple: Apples, 5 
    

使用元组分解其他类型

元组不是唯一可以分解的类型。任何类型都可以有特殊方法,名为 Deconstruct,将对象分解为部分。只要它们的签名不同,您就可以有任意多的 Deconstruct 方法。让我们为 Person 类实现一些:

  1. Person.cs 中,添加两个具有 out 参数的 Deconstruct 方法,这些参数定义了我们想要分解的部分,如下所示代码:

    // Deconstructors: Break down this object into parts.
    public void Deconstruct(out string? name,
      out DateTimeOffset dob)
    {
      name = Name;
      dob = Born;
    }
    public void Deconstruct(out string? name,
      out DateTimeOffset dob,
      out WondersOfTheAncientWorld fav)
    {
      name = Name;
      dob = Born;
      fav = FavoriteAncientWonder;
    } 
    
  2. Program.cs 中,添加语句来分解 bob,如下所示代码:

    var (name1, dob1) = bob; // Implicitly calls the Deconstruct method.
    WriteLine($"Deconstructed person: {name1}, {dob1}");
    var (name2, dob2, fav2) = bob;
    WriteLine($"Deconstructed person: {name2}, {dob2}, {fav2}"); 
    

您不需要显式调用 Deconstruct 方法。当您将对象分配给元组变量时,它会隐式调用。

  1. 运行 PeopleApp 项目并查看结果,如下所示输出:

    Deconstructed person: Bob Smith, 12/22/1965 4:28:00 PM -05:00
    Deconstructed person: Bob Smith, 12/22/1965 4:28:00 PM -05:00,
    StatueOfZeusAtOlympia 
    

使用局部函数实现功能

C# 7 中引入的一项语言特性是定义 局部函数 的能力。

局部函数是方法的局部变量的等效。换句话说,它们是仅在定义它们的包含方法内部可访问的方法。在其他语言中,它们有时被称为 嵌套内部函数

局部函数可以在方法内部定义任何位置:顶部、底部,甚至在中间的某个位置!

我们将使用局部函数来实现阶乘计算:

  1. Person.cs 中,添加语句来定义一个使用内部局部函数来计算结果的 Factorial 函数,如下所示代码:

    // Method with a local function.
    public static int Factorial(int number)
    {
      if (number < 0)
      {
        throw new ArgumentException(
          $"{nameof(number)} cannot be less than zero.");
      }
      return localFactorial(number);
      int localFactorial(int localNumber) // Local function.
      {
        if (localNumber == 0) return 1;
        return localNumber * localFactorial(localNumber - 1);
      }
    } 
    
  2. Program.cs 中,添加语句来调用 Factorial 函数,并将返回值写入控制台,带有异常处理,如下所示代码:

    // Change to -1 to make the exception handling code execute.
    int number = 5;
    try
    {
      WriteLine($"{number}! is {Person.Factorial(number)}");
    }
    catch (Exception ex)
    {
      WriteLine($"{ex.GetType()} says: {ex.Message} number was {number}.");
    } 
    
  3. 运行 PeopleApp 项目并查看结果,如下所示输出:

    5! is 120 
    
  4. 将数字更改为 -1 以便我们可以检查异常处理。

  5. 运行 PeopleApp 项目并查看结果,如下所示输出:

    System.ArgumentException says: number cannot be less than zero. number was -1. 
    

使用部分拆分类

当在具有多个团队成员的大型项目上工作,或者与特别大且复杂的类实现一起工作时,能够将类的定义拆分到多个文件中非常有用。你可以使用 partial 关键字来完成此操作。

假设我们想要向 Person 类添加由工具自动生成的语句,例如从数据库读取模式信息的对象关系映射器。如果类被定义为 partial,则可以将类拆分为自动生成的代码文件和手动编辑的代码文件。

让我们编写一些模拟此示例的代码:

  1. Person.cs 中添加 partial 关键字,如下所示代码中突出显示:

    public **partial** class Person 
    
  2. PacktLibraryNet2 项目/文件夹中,添加一个名为 PersonAutoGen.cs 的新类文件。

  3. 按照以下代码所示,向新文件中添加语句:

    namespace Packt.Shared;
    // This file simulates an auto-generated class.
    public partial class Person
    {
    } 
    
  4. 构建项目 PacktLibraryNet2。如果你看到 CS0260 Missing partial modifier on declaration of type 'Person'; another partial declaration of this type exists 错误,请确保你已将 partial 关键字应用于两个 Person 类。

本章剩余的代码将编写在 PersonAutoGen.cs 文件中。

局部方法

局部方法于 2007 年在 .NET Framework 上的 C# 3 中引入。这是一个允许在 partial classstruct 的一个部分中定义方法签名,并在另一部分提供实际实现的功能。局部方法在代码生成和手动代码共存的情况下特别有用,例如在由 Entity Framework Core 或源代码生成器等工具生成的代码中。

如下所示列表中列出了 partial 方法的几个关键特性:

  • 局部方法使用 partial 关键字声明。声明提供了方法签名,而实现提供了方法体。

  • 局部方法的实现是可选的。如果声明了局部方法但没有实现,编译器将删除对方法的调用,并且不会抛出错误。

  • 局部方法是隐式私有的,不能有访问修饰符。它们还必须返回 void,并且不能有 out 参数。

  • 局部方法不能是 virtual

局部方法通常用于涉及代码生成的场景,其中提供了一个基本结构,并且可以在不修改生成的代码的情况下添加自定义逻辑。

假设你有一个类文件,如下所示:

// MyClass1.cs
public partial class MyClass
{
  // No method body in the declaration.
  partial void OnSomethingHappened();
  public void DoWork()
  {
    // Some work here.
    // Call the partial method.
    OnSomethingHappened();
  }
} 

现在,假设你还有一个类文件,如下所示:

// MyClass2.cs
public partial class MyClass
{
  partial void OnSomethingHappened()
  {
    Console.WriteLine("Something happened.");
  }
} 

在前面的示例中,OnSomethingHappened 是在 MyClass1.cs 中声明并在 MyClass2.cs 中实现的局部方法。DoWork 方法调用局部方法,如果提供了实现,则会打印一条消息。

如果在示例中,OnSomethingHappened 已声明但未实现,则 C# 编译器将删除 DoWork 中的 OnSomethingHappened 调用,并且不会抛出错误。

部分方法通常用于自动生成的代码中,开发者可以挂钩到该过程而无需修改生成的代码。如果 MyClass1.cs 文件是自动生成的,那么就会是这样的情况。

C# 中的部分方法提供了一种强大的方式来扩展和自定义生成的代码,而无需直接修改它。它们提供了一种干净的机制来插入自定义行为,确保代码生成和自定义逻辑可以共存。通过利用部分方法,开发者可以在生成的代码和自定义代码之间保持清晰的分离,从而提高可维护性和可读性。现在你已经看到了许多字段和方法的示例,我们将探讨一些可以用来访问字段并提供控制和改进开发者体验的专用方法类型。

使用属性和索引器控制访问

之前,你创建了一个名为 GetOrigin 的方法,该方法返回一个包含人名和出身的 string。像 Java 这样的语言经常这样做。C# 有一个更好的方法,它被称为属性。

属性简单地说是一个方法(或一对方法),当你想要获取或设置值时,它表现得像一个字段,但当你设置和获取值时,它表现得像一个方法,从而简化了语法,并使功能(如验证和计算)成为可能。

字段和属性之间的基本区别在于,字段提供了一个数据内存地址。你可以将这个内存地址传递给外部组件,比如 Windows API C 风格的函数调用,然后它可以修改数据。属性不提供其数据的内存地址,这提供了更多的控制。你所能做的就是要求属性获取或设置数据。然后属性执行语句,并可以决定如何响应,包括拒绝请求!

定义只读属性

readonly 属性仅有一个 get 实现:

  1. PersonAutoGen.cs 文件中,在 Person 类中添加语句以定义三个属性:

    • 第一个属性将执行与 GetOrigin 方法相同的功能,使用与所有版本 C# 兼容的属性语法。

    • 第二个属性将返回一个问候消息,使用 C# 6 及以后的 lambda 表达式体 => 语法。

    • 第三个属性将计算人的年龄。

这里是代码:

#region Properties: Methods to get and/or set data or state.
// A readonly property defined using C# 1 to 5 syntax.
public string Origin
{
  get
  {
    return string.Format("{0} was born on {1}.",
      arg0: Name, arg1: HomePlanet);
  }
}
// Two readonly properties defined using C# 6 or later
// lambda expression body syntax.
public string Greeting => $"{Name} says 'Hello!'";
public int Age => DateTime.Today.Year - Born.Year;
#endregion 

良好实践:这不是计算某人年龄的最佳方式,但我们不是在学习如何从出生日期和时间计算年龄。如果你需要正确地这样做,请阅读以下链接的讨论:stackoverflow.com/questions/9/how-do-i-calculate-someones-age-in-c

  1. Program.cs 文件中,添加获取属性的语句,如下面的代码所示:

    Person sam = new()
    {
      Name = "Sam",
      Born = new(1969, 6, 25, 0, 0, 0, TimeSpan.Zero)
    };
    WriteLine(sam.Origin);
    WriteLine(sam.Greeting);
    WriteLine(sam.Age); 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Sam was born on Earth
    Sam says 'Hello!'
    54 
    

输出显示 54,因为我是在 2023 年 7 月 5 日运行控制台应用程序时,Sam 已经 54 岁了。

定义可设置属性

要创建一个可设置的属性,你必须使用较旧的语法并提供一对方法——不仅是一个get部分,还包括一个set部分:

  1. PersonAutoGen.cs中,添加语句来定义一个具有getset方法(也称为gettersetter)的string属性,如下所示代码:

    // A read-write property defined using C# 3 auto-syntax.
    public string? FavoriteIceCream { get; set; } 
    

尽管你没有手动创建一个字段来存储人的最喜欢的冰淇淋,但它确实在那里,由编译器自动为你创建。

有时,你需要对属性设置时发生的事情有更多的控制。在这种情况下,你必须使用更详细的语法并手动创建一个private字段来存储属性的值。

  1. PersonAutoGen.cs中,添加语句来定义一个名为后置字段private string字段,如下所示代码:

    // A private backing field to store the property value.
    private string? _favoritePrimaryColor; 
    

良好实践:尽管没有正式的标准来命名私有字段,但最常见的是使用带下划线前缀的驼峰式命名法。

  1. PersonAutoGen.cs中,添加语句来定义一个具有getset以及验证逻辑的string属性,如下所示代码:

    // A public property to read and write to the field.
    public string? FavoritePrimaryColor
    {
      get
      {
        return _favoritePrimaryColor;
      }
      set
      {
        switch (value?.ToLower())
        {
          case "red":
          case "green":
          case "blue":
            _favoritePrimaryColor = value;
            break;
          default:
            throw new ArgumentException(
              $"{value} is not a primary color. " +
              "Choose from: red, green, blue.");
        }
      }
    } 
    

良好实践:避免在 getter 和 setter 中添加过多的代码。这可能表明你的设计存在问题。考虑添加私有方法,然后在setget方法中调用这些方法以简化你的实现。

  1. Program.cs中,添加语句来设置 Sam 的最喜欢的冰淇淋和颜色,然后将其写入,如下所示代码:

    sam.FavoriteIceCream = "Chocolate Fudge";
    WriteLine($"Sam's favorite ice-cream flavor is {sam.FavoriteIceCream}.");
    string color = "Red";
    try
    {
      sam.FavoritePrimaryColor = color;
      WriteLine($"Sam's favorite primary color is {sam.FavoritePrimaryColor}.");
    }
    catch (Exception ex)
    {
      WriteLine("Tried to set {0} to '{1}': {2}",
        nameof(sam.FavoritePrimaryColor), color, ex.Message);
    } 
    

打印的书籍限制在大约 820 页。如果我在所有代码示例中添加异常处理代码,就像我们在这里所做的那样,那么我可能不得不从书中删除至少一章来腾出足够的空间。在将来,我不会明确告诉你添加异常处理代码,但我会养成在需要时自己添加它的习惯。

  1. 运行PeopleApp项目并查看结果,如下所示输出:

    Sam's favorite ice-cream flavor is Chocolate Fudge.
    Sam's favorite primary color is Red. 
    
  2. 尝试将颜色设置为除了红色、绿色或蓝色以外的任何值,例如黑色。

  3. 运行PeopleApp项目并查看结果,如下所示输出:

    Tried to set FavoritePrimaryColor to 'Black': Black is not a primary color. Choose from: red, green, blue. 
    

良好实践:当你想在读取或写入字段时执行语句,而不使用方法对,如GetAgeSetAge时,请使用属性而不是字段。

部分属性

在上一节中,你学习了关于部分类和部分方法的内容。在.NET 9 的 C# 13 中,你可以定义部分属性,并且它们的工作方式类似。主要好处是支持源生成的类型。部分属性使得源生成器设计者更容易创建自然感觉的 API,正如你在第八章,使用常见的.NET 类型中将会看到的,当我们看到正则表达式源生成器时。

限制标志枚举值

在本章的早期,我们定义了一个字段来存储一个人的最喜欢的古代奇迹。但后来我们使enum能够存储值的组合。现在,让我们将最喜欢的限制为一个:

  1. Person.cs 中,注释掉 FavoriteAncientWonder 字段并添加注释说明它已移动到 PersonAutoGen.cs 代码文件中,如下代码所示:

    // This has been moved to PersonAutoGen.cs as a property.
    // public WondersOfTheAncientWorld FavoriteAncientWonder; 
    
  2. PersonAutoGen.cs 中,为 FavoriteAncientWonder 添加一个 private 字段和一个 public 属性,如下代码所示:

    private WondersOfTheAncientWorld _favoriteAncientWonder;
    public WondersOfTheAncientWorld FavoriteAncientWonder
    {
      get { return _favoriteAncientWonder; }
      set
      {
        string wonderName = value.ToString();
        if (wonderName.Contains(','))
        {
          throw new ArgumentException(
            message: "Favorite ancient wonder can only have a single enum value.",
            paramName: nameof(FavoriteAncientWonder));
        }
        if (!Enum.IsDefined(typeof(WondersOfTheAncientWorld), value))
        {
          throw new ArgumentException(
            $"{value} is not a member of the WondersOfTheAncientWorld enum.",
            paramName: nameof(FavoriteAncientWonder));
        }
        _favoriteAncientWonder = value;
      }
    } 
    

    我们可以通过仅检查值是否定义在原始 enum 中来简化验证,因为 IsDefined 对多个值和未定义的值返回 false。然而,我想展示一个针对多个值的不同的异常,所以我将使用事实,即格式化为字符串的多个值将包括名称列表中的逗号。这也意味着我们必须在检查值是否定义之前检查多个值。逗号分隔列表是表示多个 enum 值作为 string 的方式,但您不能使用逗号来设置多个 enum 值。您应该使用 |(位或)。

  3. Program.cs 中,在 使用枚举类型存储值 区域,将鲍勃的最爱奇迹设置为超过一个 enum 值,如下代码所示:

    bob.FavoriteAncientWonder =
      WondersOfTheAncientWorld.StatueOfZeusAtOlympia |
      WondersOfTheAncientWorld.GreatPyramidOfGiza; 
    
  4. 运行 PeopleApp 项目并注意异常,如下所示输出:

    Unhandled exception. System.ArgumentException: Favorite ancient wonder can only have a single enum value. (Parameter 'FavoriteAncientWonder')
       at Packt.Shared.Person.set_FavoriteAncientWonder(WondersOfTheAncientWorld value) in C:\cs13net9\Chapter05\PacktLibraryNet2\PersonAutoGen.cs:line 67
       at Program.<Main>$(String[] args) in C:\cs13net9\Chapter05\PeopleApp\Program.cs:line 57 
    
  5. Program.cs 中,将鲍勃的最爱奇迹设置为一个无效的 enum 值,如 128,如下代码所示:

    bob.FavoriteAncientWonder = (WondersOfTheAncientWorld)128; 
    
  6. 运行 PeopleApp 项目并注意异常,如下所示输出:

    Unhandled exception. System.ArgumentException: 128 is not a member of the WondersOfTheAncientWorld enum. (Parameter 'FavoriteAncientWonder') 
    
  7. Program.cs 中,将鲍勃的最爱奇迹设置回一个有效的单个 enum 值。

定义索引器

索引器允许调用代码使用数组语法来访问属性。例如,string 类型定义了一个索引器,以便调用代码可以访问 string 中的单个字符,如下代码所示:

string alphabet = "abcdefghijklmnopqrstuvwxyz";
char letterF = alphabet[5]; // 0 is a, 1 is b, and so on. 

您可以重载索引器,以便可以使用不同的类型作为它们的参数。例如,除了传递一个 int 值外,您还可以传递一个 string 值。

我们将定义一个索引器来简化对一个人子代的访问:

  1. PersonAutoGen.cs 中,添加语句来定义一个索引器,通过子代的索引来获取和设置一个子代,如下代码所示:

    #region Indexers: Properties that use array syntax to access them.
    public Person this[int index]
    {
      get
      {
        return Children[index]; // Pass on to the List<T> indexer.
      }
      set
      {
        Children[index] = value;
      }
    }
    #endregion 
    

注意使用 this[] 来定义一个索引器。它不要与使用 this 关键字来引用对象的当前实例,或者使用 this() 来引用当前类型的构造函数混淆。

  1. PersonAutoGen.cs 中,添加语句来定义一个索引器,通过子代的名称来获取和设置一个子代,如下代码所示:

    // A read-only string indexer.
    public Person this[string name]
    {
      get
      {
        return Children.Find(p => p.Name == name);
      }
    } 
    

您将在 第八章使用常见的 .NET 类型 中学习更多关于 List<T> 等集合的知识,以及如何在 第十一章使用 LINQ 查询和操作数据 中使用 => 编写 lambda 表达式。

  1. Program.cs 中,添加语句向 Sam 添加两个子代,然后使用较长的 Children 字段和较短的索引器语法访问第一个和第二个子代,如下代码所示:

    sam.Children.Add(new() { Name = "Charlie",
      Born = new(2010, 3, 18, 0, 0, 0, TimeSpan.Zero) });
    sam.Children.Add(new() { Name = "Ella",
      Born = new(2020, 12, 24, 0, 0, 0, TimeSpan.Zero) });
    // Get using Children list.
    WriteLine($"Sam's first child is {sam.Children[0].Name}.");
    WriteLine($"Sam's second child is {sam.Children[1].Name}.");
    // Get using the int indexer.
    WriteLine($"Sam's first child is {sam[0].Name}.");
    WriteLine($"Sam's second child is {sam[1].Name}.");
    // Get using the string indexer.
    WriteLine($"Sam's child named Ella is {sam["Ella"].Age} years old."); 
    
  2. 运行 PeopleApp 项目并查看结果,如下所示输出:

    Sam's first child is Charlie.
    Sam's second child is Ella.
    Sam's first child is Charlie.
    Sam's second child is Ella.
    Sam's child named Ella is 3 years old. 
    

sam[0].Name 调用了 Person 类的索引器或 this,因为 [] 是调用索引器的符号。sam.Children[0].Name 调用了名为 Children 的集合的索引器,它是一个 List<Person>。我们已经在 Person 上实现了索引器以执行相同的功能,这样我们就可以使用更短的语法。

对于 string 值也是类似的。如果我有一个 string name = "Mark";,那么想象它有一个名为 Characters 的属性,它是一个 List<char>。我可以用 name.Characters[0] 返回字符 M。但由于 string 已经定义了索引器,我可以用 name[0] 做同样的事情。

大多数类型没有定义索引器。它只是定义一个使用 [] 而不是名称来调用的属性的一种方式。实际上它可以做任何你想做的事情。但是,它应该用来定义数组/集合风格的获取器/设置器。如果你的类型不需要这个,那么它不需要索引器。

对于你定义的任何类型,只需想象另一个开发者正在使用你的类型。他们会期望索引器做什么?例如,如果你获取第一个索引,如下面的代码所示:

Car car = new("Ford");
var thing1 = car[0]; // What type is thing1? What is its value?
Database db = new("Commerce");
var thing2 = db[0]; // What type is thing2? What is its value?
string name = "Mark";
var thing3 = name[0]; // thing3 is a char. Its value is "M".
List<int> numbers = new() { 27, 95, 13 };
var thing4 = numbers[0]; // thing4 is an int. Its value is 27. 

thing1 可能是车上的第一个乘客或车的第一个轮子。thing2 可能是数据库中的第一个表或第一个用户。对于自定义类型,由你来决定什么最适合你的类型。

对象的模式匹配

第三章控制流程、转换类型和处理异常 中,你被介绍了基本的模式匹配。在本节中,我们将更详细地探讨模式匹配。

模式匹配飞行乘客

在这个例子中,我们将定义一些代表航班上各种类型乘客的类,然后我们将使用带有模式匹配的 switch 表达式来确定他们的飞行费用:

  1. PacktLibraryNet2 项目/文件夹中,添加一个名为 FlightPatterns.cs 的新文件。

  2. 如果你使用 Visual Studio,在 FlightPatterns.cs 中删除现有的语句,包括名为 FlightPatterns 的类,因为我们将会定义多个类,并且没有类名与代码文件名匹配。

  3. FlightPatterns.cs 文件中,添加语句来定义三种具有不同属性的乘客类型,如下面的代码所示:

    // All the classes in this file will be defined in the following namespace.
    namespace Packt.Shared;
    public class Passenger
    {
      public string? Name { get; set; }
    }
    public class BusinessClassPassenger : Passenger
    {
      public override string ToString()
      {
        return $"Business Class: {Name}";
      }
    }
    public class FirstClassPassenger : Passenger
    {
      public int AirMiles { get; set; }
      public override string ToString()
      {
        return $"First Class with {AirMiles:N0} air miles: {Name}";
      }
    }
    public class CoachClassPassenger : Passenger
    {
      public double CarryOnKG { get; set; }
      public override string ToString()
      {
        return $"Coach Class with {CarryOnKG:N2} KG carry on: {Name}";
      }
    } 
    

你将在 第六章实现接口和继承类 中学习如何重写 ToString 方法。

  1. Program.cs 文件中,添加语句来定义一个包含五种不同类型和属性值的乘客对象数组,然后枚举它们,输出他们的飞行费用,如下面的代码所示:

    // An array containing a mix of passenger types.
    Passenger[] passengers = {
      new FirstClassPassenger { AirMiles = 1_419, Name = "Suman" },
      new FirstClassPassenger { AirMiles = 16_562, Name = "Lucy" },
      new BusinessClassPassenger { Name = "Janice" },
      new CoachClassPassenger { CarryOnKG = 25.7, Name = "Dave" },
      new CoachClassPassenger { CarryOnKG = 0, Name = "Amit" },
    };
    foreach (Passenger passenger in passengers)
    {
      decimal flightCost = passenger switch
      {
        FirstClassPassenger p when p.AirMiles > 35_000 => 1_500M,
        FirstClassPassenger p when p.AirMiles > 15_000 => 1_750M,
        FirstClassPassenger _                         => 2_000M,
        BusinessClassPassenger _                      => 1_000M,
        CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M,
        CoachClassPassenger _                         => 650M,
        _                                             => 800M
      };
      WriteLine($"Flight costs {flightCost:C} for {passenger}");
    } 
    

在审查前面的代码时,请注意以下内容:

  • 大多数代码编辑器都不会像上面那样对齐 lambda 符号 =>。

  • 要对对象的属性进行模式匹配,你必须命名一个局部变量,例如 p,然后可以在表达式中使用它。

  • 要仅对类型进行模式匹配,可以使用 _ 来丢弃局部变量;例如,FirstClassPassenger _ 表示你匹配类型,但你不在乎其任何属性的值,因此不需要命名变量如 p。稍后,你将看到我们如何进一步改进代码。

  • switch 表达式也使用 _ 来表示其默认分支。

  1. 运行 PeopleApp 项目并查看结果,如下所示输出:

    Flight costs $2,000.00 for First Class with 1,419 air miles: Suman
    Flight costs $1,750.00 for First Class with 16,562 air miles: Lucy
    Flight costs $1,000.00 for Business Class: Janice
    Flight costs $650.00 for Coach Class with 25.70 KG carry on: Dave
    Flight costs $500.00 for Coach Class with 0.00 KG carry on: Amit 
    

现代 C# 中模式匹配的增强

之前的示例使用的是 C# 8,现在我们将看看 C# 9 及以后的增强功能。首先,你不再需要使用下划线来丢弃局部变量以进行类型匹配:

  1. Program.cs 中,注释掉 C# 8 语法,并添加 C# 9 及以后的语法来修改一等舱乘客的分支,使用嵌套的 switch 表达式和新的条件支持,如以下代码中突出显示:

    decimal flightCost = passenger switch
    {
      **/* C# 8 syntax**
      FirstClassPassenger p when p.AirMiles > 35_000 => 1_500M,
      FirstClassPassenger p when p.AirMiles > 15_000 => 1_750M,
      FirstClassPassenger _                          => 2_000M, ***/**
    **// C# 9 or later syntax**
     **FirstClassPassenger p => p.AirMiles** **switch**
     **{**
     **>** **35****_000 =>** **1****_500M,**
     **>** **15****_000 =>** **1****_750M,**
     **_       =>** **2****_000M**
     **},**
      BusinessClassPassenger                        => 1_000M,
      CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M,
      CoachClassPassenger                           => 650M,
      _                                             => 800M
    }; 
    
  2. 运行 PeopleApp 项目以查看结果,并注意它们与之前相同。

你也可以将关系模式与属性模式结合使用,以避免嵌套的 switch 表达式,如下所示代码:

FirstClassPassenger { AirMiles: > 35000 } => 1500M,
FirstClassPassenger { AirMiles: > 15000 } => 1750M,
FirstClassPassenger                       => 2000M, 

与记录类型一起工作

在我们深入探讨新的 record 语言特性之前,让我们看看 C# 9 及以后的其它相关新特性。

仅初始化属性

你在本章中使用了对象初始化语法来实例化对象并设置初始属性。这些属性也可以在实例化后更改。

有时,你希望将属性视为 readonly 字段,以便它们可以在实例化时设置,但不能在之后设置。换句话说,它们是不可变的。init 关键字可以实现这一点。它可以在属性定义中替代 set 关键字。

由于这是一个不受 .NET Standard 2.0 支持的语言特性,我们无法在 PacktLibraryNet2 项目中使用它。我们必须在现代项目中使用它:

  1. PacktLibraryModern 项目中,添加一个名为 Records.cs 的新文件。

  2. Records.cs 中,定义一个具有两个不可变属性的人员类,如下所示代码:

    namespace Packt.Shared;
    public class ImmutablePerson
    {
      public string? FirstName { get; init; }
      public string? LastName { get; init; }
    } 
    
  3. Program.cs 中,添加语句以实例化一个新的不可变人员,然后尝试更改其属性之一,如下所示代码:

    ImmutablePerson jeff = new()
    {
      FirstName = "Jeff",
      LastName = "Winger"
    };
    jeff.FirstName = "Geoff"; 
    
  4. 编译控制台应用程序并注意编译错误,如下所示输出:

    C:\cs13net9\Chapter05\PeopleApp\Program.cs(404,1): error CS8852: Init-only property or indexer 'ImmutablePerson.FirstName' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor. [/Users/markjprice/Code/Chapter05/PeopleApp/PeopleApp.csproj] 
    
  5. 注释掉在实例化后尝试设置 FirstName 属性的尝试。

即使你在对象初始化器中没有设置 FirstName,你仍然无法在初始化后设置它。如果你需要强制设置属性,那么请应用本章早些时候学到的 required 关键字。

定义记录类型

仅初始化属性为 C# 提供了一些不可变性。你可以通过使用 record 类型 来进一步扩展这个概念。这些类型是通过使用 record 关键字而不是(或以及)class 关键字来定义的。这样可以使整个对象不可变,并且在比较时表现得像一个值。我们将在 第六章实现接口和继承类 中更详细地讨论类、记录和值类型的相等性和比较。

不可变记录不应该有任何在实例化后改变的状态(属性和字段)。相反,想法是从现有的记录中创建新的记录。新的记录具有改变后的状态。这被称为非破坏性变异。为此,C# 9 引入了 with 关键字:

  1. Records.cs 类中,在 ImmutablePerson 类之后添加一个名为 ImmutableVehicle 的记录,如下面的代码所示:

    public record ImmutableVehicle
    {
      public int Wheels { get; init; }
      public string? Color { get; init; }
      public string? Brand { get; init; }
    } 
    
  2. Program.cs 中,添加语句来创建一个 car 对象,然后创建它的一个变异副本,如下面的代码所示:

    ImmutableVehicle car = new()
    {
      Brand = "Mazda MX-5 RF",
      Color = "Soul Red Crystal Metallic",
      Wheels = 4
    };
    ImmutableVehicle repaintedCar = car
      with { Color = "Polymetal Grey Metallic" };
    WriteLine($"Original car color was {car.Color}.");
    WriteLine($"New car color is {repaintedCar.Color}."); 
    
  3. 运行 PeopleApp 项目以查看结果,并注意变异副本中汽车颜色的变化,如下面的输出所示:

    Original car color was Soul Red Crystal Metallic.
    New car color is Polymetal Grey Metallic. 
    

你也可以释放 car 变量的内存,而重新喷漆的 repaintedCar 仍然完全存在。

记录类型的相等性

record 类型最重要的行为之一是它们的相等性。具有相同属性值的两个记录被认为是相等的。这可能听起来并不令人惊讶,但如果你使用的是普通类而不是记录,那么它们就不会被认为是相等的。让我们看看:

  1. PacktLibraryModern 项目中,添加一个名为 Equality.cs 的新文件。

  2. Equality.cs 中,定义一个 class 和一个 record 类型,如下面的代码所示:

    namespace Packt.Shared;
    public class AnimalClass
    {
      public string? Name { get; set; }
    }
    public record AnimalRecord
    {
      public string? Name { get; set; }
    } 
    
  3. Program.cs 中,添加语句来创建 AnimalClass 的两个实例和 AnimalRecord 的两个实例,然后比较它们的相等性,如下面的代码所示:

    AnimalClass ac1 = new() { Name = "Rex" };
    AnimalClass ac2 = new() { Name = "Rex" };
    WriteLine($"ac1 == ac2: {ac1 == ac2}");
    AnimalRecord ar1 = new() { Name = "Rex" };
    AnimalRecord ar2 = new() { Name = "Rex" };
    WriteLine($"ar1 == ar2: {ar1 == ar2}"); 
    
  4. 运行 PeopleApp 项目以查看结果,并注意即使两个类的实例具有相同的属性值,它们也不相等,而两个记录实例如果具有相同的属性值则相等,如下面的输出所示:

    ac1 == ac2: False
    ar1 == ar2: True 
    

类实例只有在它们确实是同一个对象时才相等。当它们的内存地址相等时这是正确的。你将在 第六章实现接口和继承类 中了解更多关于类型相等性的内容。

其他类型的相等性

使用 ==!= 运算符比较两个变量是很常见的。这两个运算符的行为对于引用类型和值类型是不同的。

当你检查两个值类型变量的相等性时,.NET 实际上会比较这两个变量在栈上的 ,如果它们相等则返回 true

  1. Program.cs 中,添加语句来声明两个具有相等值的整数,然后比较它们,如下面的代码所示:

    int number1 = 3;
    int number2 = 3;
    WriteLine($"number1: {number1}, number2: {number2}");
    WriteLine($"number1 == number2: {number1 == number2}"); 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    number1: 3, number2: 3
    number1 == number2: True 
    

当你检查两个引用类型变量的相等性时,.NET 会比较这两个变量的内存地址,如果它们相等则返回true

  1. Program.cs中,添加语句以声明两个具有相同名称的Person实例,然后比较变量及其名称,如下面的代码所示:

    Person p1 = new() { Name = "Kevin" };
    Person p2 = new() { Name = "Kevin" };
    WriteLine($"p1: {p1}, p2: {p2}");
    WriteLine($"p1.Name: {p1.Name}, p2.Name: {p2.Name}");
    WriteLine($"p1 == p2: {p1 == p2}"); 
    
  2. 运行PeopleApp项目并查看结果,如下面的输出所示:

    p1: Packt.Shared.Person, p2: Packt.Shared.Person
    p1.Name: Kevin, p2.Name: Kevin
    p1 == p2: False 
    

这是因为它们不是同一个对象。如果两个变量实际上都指向堆上的同一个对象,那么它们就是相等的。

  1. 添加语句以声明第三个Person对象并将p1分配给它,如下面的代码所示:

    Person p3 = p1;
    WriteLine($"p3: {p3}");
    WriteLine($"p3.Name: {p3.Name}");
    WriteLine($"p1 == p3: {p1 == p3}"); 
    
  2. 运行PeopleApp项目并查看结果,如下面的输出所示:

    p3: Packt.Shared.Person
    p3.Name: Kevin
    p1 == p3: True 
    

    引用类型这种行为的一个例外是string类型。它是一个引用类型,但相等运算符已被重写,使其表现得像值类型一样。

  3. 添加语句以比较两个Person实例的Name属性,如下面的代码所示:

    // string is the only class reference type implemented to
    // act like a value type for equality.
    WriteLine($"p1.Name: {p1.Name}, p2.Name: {p2.Name}");
    WriteLine($"p1.Name == p2.Name: {p1.Name == p2.Name}"); 
    
  4. 运行PeopleApp项目并查看结果,如下面的输出所示:

    p1.Name: Kevin, p2.Name: Kevin
    p1.Name == p2.Name: True 
    

你可以用与string相同的方式在你的类中重写相等运算符==,使其返回true,即使两个变量不是引用同一个对象(堆上的相同内存地址),而是它们的字段具有相同的值。然而,这超出了本书的范围。

良好实践:或者,使用record class,因为它的一个好处是它为你实现了这种相等行为。

记录中的位置数据成员

使用位置数据成员定义记录的语法可以大大简化。与使用花括号的对象初始化语法相比,有时你可能更喜欢提供一个具有位置参数的构造函数,正如你在这章前面所看到的。你还可以将此与析构函数结合使用,将对象拆分为单独的部分,如下面的代码所示:

public record ImmutableAnimal
{
  public string Name { get; init; }
  public string Species { get; init; }
  public ImmutableAnimal(string name, string species)
  {
    Name = name;
    Species = species;
  }
  public void Deconstruct(out string name, out string species)
  {
    name = Name;
    species = Species;
  }
} 

属性、构造函数和析构函数可以为你生成:

  1. Records.cs中,添加语句以使用简化语法定义另一个记录,这种语法称为位置记录,如下面的代码所示:

    // Simpler syntax to define a record that auto-generates the
    // properties, constructor, and deconstructor.
    public record ImmutableAnimal(string Name, string Species); 
    
  2. Program.cs中,添加语句以构造和析构不可变的动物,如下面的代码所示:

    ImmutableAnimal oscar = new("Oscar", "Labrador");
    var (who, what) = oscar; // Calls the Deconstruct method.
    WriteLine($"{who} is a {what}."); 
    
  3. 运行PeopleApp项目并查看结果,如下面的输出所示:

    Oscar is a Labrador. 
    

当我们查看第六章“实现接口和继承类”中 C# 10 或更高版本的struct记录支持时,你将再次看到记录。

更多信息:在你的项目中使用记录的方式还有很多。我建议你查看以下链接中的官方文档:learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/records

定义类的主构造函数

在 C# 12 中引入的功能允许你将一个构造函数作为类定义的一部分来定义。这被称为主构造函数。其语法与记录中位置数据成员的语法相同,但行为略有不同。

传统上,我们将类定义与任何构造函数分开,如下面的代码所示:

public class Headset // Class definition.
{
  // Constructor.
  public Headset(string manufacturer, string productName)
  {
    // You can reference manufacturer and productName parameters in
    // the constructor and the rest of the class.
  }
} 

使用类的主构造函数,你可以将两者结合成一个更简洁的语法,如下面的代码所示:

public class Headset(string manufacturer, string productName); 

让我们看看一个例子:

  1. PacktLibraryModern 项目中,添加一个名为 Headset.cs 的类文件。

  2. 修改代码文件内容,为类提供两个参数,分别用于制造商和产品名称,如下面的代码所示:

    namespace Packt.Shared;
    public class Headset(string manufacturer, string productName); 
    
  3. Program.cs 中,添加语句来实例化一个头盔,如下面的代码所示:

    Headset vp = new("Apple", "Vision Pro");
    WriteLine($"{vp.ProductName} is made by {vp.Manufacturer}."); 
    

record 类型与具有主构造函数的 class 类型之间的一个区别是,其参数不会自动成为公共属性,因此你会看到 CS1061 编译器错误。ProductNameproductName 都无法在类外访问。

  1. Headset.cs 中,添加语句来定义两个属性并使用传递给主构造函数的参数来设置它们,如下面的代码所示:

    namespace Packt.Shared;
    public class Headset(string manufacturer, string productName)
    **{**
    **public****string** **Manufacturer {** **get****;** **set****; } = manufacturer;**
    **public****string** **ProductName {** **get****;** **set****; } = productName;**
    **}** 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Vision Pro is made by Apple. 
    
  3. Headset.cs 中,添加一个默认的无参数构造函数,如下面的代码所示:

    namespace Packt.Shared;
    public class Headset(string manufacturer, string productName)
    {
      public string Manufacturer { get; set; } = manufacturer;
      public string ProductName { get; set; } = productName;
    **// Default parameterless constructor calls the primary constructor.**
    **public****Headset****() :** **this****(****"Microsoft"****,** **"HoloLens"****)** **{ }**
    } 
    

注意使用 this() 调用基类的构造函数,并在调用 Headset 的默认构造函数时向其传递两个参数。

  1. Program.cs 中,创建一个未初始化的头盔实例和一个 Meta Quest 3 的实例,如下面的代码所示:

    Headset holo = new();
    WriteLine($"{holo.ProductName} is made by {holo.Manufacturer}.");
    Headset mq = new() { Manufacturer = "Meta", ProductName = "Quest 3" };
    WriteLine($"{mq.ProductName} is made by {mq.Manufacturer}."); 
    
  2. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Vision Pro is made by Apple.
    HoloLens is made by Microsoft.
    Quest 3 is made by Meta. 
    

更多信息:你可以在以下链接中了解更多关于类和结构的主构造函数的信息:learn.microsoft.com/en-us/dotnet/csharp/whats-new/tutorials/primary-constructorsdevblogs.microsoft.com/dotnet/csharp-primary-constructors-refactoring/。特别是,值得阅读第二个链接底部的评论,以了解为什么许多开发者不喜欢类中的主构造函数。

良好实践:只有当主构造函数仅用于初始化非只读私有字段,并且不需要执行其他语句时,才在类中使用主构造函数。

练习和探索

通过回答一些问题、进行一些实际操作练习,并深入研究本章的主题来测试你的知识和理解。

练习 5.1 – 在线材料

在线材料可以是我为这本书写的额外内容,也可以是引用 Microsoft 或第三方创建的内容。

模式匹配

在你的项目中使用模式匹配有很多种方法。我建议你查看以下链接中的官方文档:learn.microsoft.com/en-us/dotnet/csharp/fundamentals/functional/pattern-matching.

练习 5.2 – 练习

练习将更深入地探讨本章的主题。

练习访问修饰符

想象一下你是编译器。在构建以下项目时,你会显示哪些错误?需要更改什么才能修复它?

在类库项目中,在 Car.cs

class Car
{
  int Wheels { get; set; }
  public bool IsEV { get; set; }
  internal void Start()
  {
    Console.WriteLine("Starting...");
  }
} 

在一个引用类库项目的控制台应用程序项目中,在 Program.cs

Car fiat = new() { Wheels = 4, IsEV = true };
fiat.Start(); 

练习 5.3 – 测试你的知识

回答以下问题:

  1. 有七个访问修饰符关键字和关键字的组合,它们分别是什么,以及它们的作用?

  2. 当应用于类型成员时,staticconstreadonly 关键字之间的区别是什么?

  3. 构造函数的作用是什么?

  4. 当你想存储组合值时,为什么应该将 [Flags] 属性应用于 enum 类型?

  5. 为什么 partial 关键字很有用?

  6. 元组是什么?

  7. record 关键字的作用是什么?

  8. 载荷过载是什么意思?

  9. 以下两个语句之间的区别是什么?(不要只是说 a > 字符!)

    public List<Person> Children = new();
    public List<Person> Children => new(); 
    
  10. 你如何使方法参数成为可选的?

  11. 为什么 DateTime 字段不能是一个常量?

练习 5.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-5---building-your-own-types-with-object-oriented-programming

摘要

在本章中,你学习了以下内容:

  • 使用面向对象编程(OOP)创建自己的类型。

  • 类型可以拥有的不同成员类别,包括用于存储数据的字段和执行操作的函数。

  • 面向对象编程(OOP)概念,例如聚合和封装

  • 如何使用现代 C# 功能,如关系和属性模式匹配增强、仅初始化属性和记录类型。

在下一章中,你将通过定义运算符、委托和事件、实现接口以及从现有类继承来进一步探讨这些概念。

第六章:实现接口和继承类

本章介绍如何使用面向对象编程(OOP)从现有类型派生出新类型。您将学习如何使用运算符作为实现简单功能的一种替代方法,以及如何使用泛型使代码更安全、性能更高。您将了解委托和事件在类型之间交换消息。您将实现接口以实现常见功能。您将创建一个派生类从基类继承功能,重写继承的类型成员,并使用多态。最后,您将学习如何创建扩展方法和在继承层次结构中类之间的转换。

在仅在线部分,您将看到引用类型和值类型之间的区别以及它们如何在内存中存储。您将在本章末尾找到在线材料的链接。

本章涵盖以下主题:

  • 设置类库和控制台应用程序

  • 静态方法和运算符重载

  • 使用泛型安全地使类型可重用

  • 抛出和处理事件

  • 实现接口

  • 处理 null

  • 从类继承

  • 在继承层次结构中进行转换

  • 继承和扩展 .NET 类型

  • 总结自定义类型选择

设置类库和控制台应用程序

我们将首先定义一个包含两个项目的解决方案,就像在 第五章使用面向对象编程构建自己的类型 中创建的那样。即使您完成了该章节的所有练习,也请遵循以下说明,以便您以全新的工作项目开始本章:

  1. 使用您首选的代码编辑器创建一个新项目,如下面的列表所示:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:PacktLibrary

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

    • 框架:.NET 9.0(标准支持期)

  2. 添加一个新项目,如下面的列表所示:

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

    • 项目文件和文件夹:PeopleApp

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

    • 框架:.NET 9.0(标准支持期)

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

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

在本章中,两个项目都针对 .NET 9,因此默认使用 C# 13 编译器。

  1. PacktLibrary 项目中,将名为 Class1.cs 的文件重命名为 Person.cs

  2. 在两个项目中,添加 <ItemGroup> 以全局和静态导入 System.Console 类,如下面的标记所示:

    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    
  3. Person.cs 中,删除任何现有的语句并定义一个 Person 类,如下面的代码所示:

    namespace Packt.Shared;
    public class Person
    {
      #region Properties
      public string? Name { get; set; }
      public DateTimeOffset Born { get; set; }
      public List<Person> Children { get; set; } = new();
      #endregion
      #region Methods
      public void WriteToConsole()
      {
        WriteLine($"{Name} was born on a {Born:dddd}.");
      }
      public void WriteChildrenToConsole()
      {
        string term = Children.Count == 1 ? "child" : "children";
        WriteLine($"{Name} has {Children.Count} {term}.");
      }
      #endregion
    } 
    
  4. PeopleApp 项目中,添加对 PacktLibrary 的项目引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference
        Include="..\PacktLibrary\PacktLibrary.csproj" />
    </ItemGroup> 
    
  5. Program.cs 中,删除现有的语句,编写语句创建 Person 实例,然后将相关信息写入控制台,如下面的代码所示:

    using Packt.Shared;
    Person harry = new()
    {
      Name = "Harry",
      Born = new(year: 2001, month: 3, day: 25,
        hour: 0, minute: 0, second: 0,
        offset: TimeSpan.Zero)
    };
    harry.WriteToConsole(); 
    
  6. 如果您使用 Visual Studio,请将解决方案的启动项目配置为当前选择。

  7. 运行PeopleApp项目并注意结果,如下所示输出:

    Harry was born on a Sunday. 
    

静态方法和运算符重载

本节专门讨论适用于同一类型两个实例的方法。它不是关于更一般的情况,即适用于零个、一个或多个实例的方法。请记住,静态方法不需要对象实例。它们可以直接使用类名来调用。

我想想出一些适用于两个Person实例的方法,这些方法也可以成为二元运算符,如+*。将两个人相加代表什么?将两个人相乘代表什么?明显的答案就是结婚和生孩子。

我们将设计我们的方法,以便我们可以模拟拉麦和他的两位妻子及其子女的故事,如下链接所述:

www.kingjamesbibleonline.org/Genesis-4-19/

我们可能希望两个Person实例能够结婚并生育。我们可以通过编写方法和重载运算符来实现这一点。实例方法是对象对自己执行的操作;静态方法是类型执行的操作。

你选择哪种取决于哪种方式对操作来说最有意义。

良好实践:拥有静态和实例方法来执行类似操作通常是有意义的。例如,string既有Compare静态方法,也有CompareTo实例方法。这把如何使用功能的选择权交给了使用您类型的程序员,给了他们更多的灵活性。

使用方法实现功能

让我们先通过使用静态和实例方法来实现一些功能:

  1. Person.cs中,添加具有私有后备存储字段的属性,以指示该人是否已婚以及与谁结婚,如下所示代码:

    // Allow multiple spouses to be stored for a person.
    public List<Person> Spouses { get; set; } = new();
    // A read-only property to show if a person is married to anyone.
    public bool Married => Spouses.Count > 0; 
    
  2. Person.cs中,添加一个实例方法和一个静态方法,允许两个Person对象结婚,如下所示代码:

    // Static method to marry two people.
    public static void Marry(Person p1, Person p2)
    {
      ArgumentNullException.ThrowIfNull(p1);
      ArgumentNullException.ThrowIfNull(p2);
      if (p1.Spouses.Contains(p2) || p2.Spouses.Contains(p1))
      {
        throw new ArgumentException(
          string.Format("{0} is already married to {1}.",
          arg0: p1.Name, arg1: p2.Name));
      }
      p1.Spouses.Add(p2);
      p2.Spouses.Add(p1);
    }
    // Instance method to marry another person.
    public void Marry(Person partner)
    {
      Marry(this, partner); // "this" is the current person.
    } 
    

注意以下内容:

  • static方法中,Person对象作为名为p1p2的参数传递,并使用保护子句来检查null值。如果其中任何一个已经与对方结婚,则抛出异常;否则,它们各自被添加到对方的配偶列表中。如果您想允许两个人有多次婚礼,您可以采取不同的建模方式。在这种情况下,您可能选择不抛出异常,而是什么都不做。他们的婚姻状态将保持不变。如果他们已婚或未婚,额外的Marry调用不会改变。在这个场景中,我想让您看到代码通过抛出异常来识别他们已经结婚。

  • 在实例方法中,调用static方法,传递当前的人(this)和他们想要结婚的伴侣。

    警告!尽管实例方法可以调用静态方法,但反之则不然。静态方法不能调用实例方法,因为没有对象实例可以调用它。

  1. Person.cs 中,向 Person 类添加一个实例方法,该方法可以输出已婚的人的配偶,如下面的代码所示:

    public void OutputSpouses()
    {
      if (Married)
      {
        string term = Spouses.Count == 1 ? "person" : "people";
        WriteLine($"{Name} is married to {Spouses.Count} {term}:");
        foreach (Person spouse in Spouses)
        {
          WriteLine($"  {spouse.Name}");
        }
      }
      else
      {
        WriteLine($"{Name} is a singleton.");
      }
    } 
    
  2. Person.cs 中,向 Person 类添加一个实例方法和一个静态方法,使得两个 Person 对象如果彼此结婚,就可以繁殖,如下面的代码所示:

    /// <summary>
    /// Static method to "multiply" aka procreate and have a child together.
    /// </summary>
    /// <param name="p1">Parent 1</param>
    /// <param name="p2">Parent 2</param>
    /// <returns>A Person object that is the child of Parent 1 and Parent 2.</returns>
    /// <exception cref="ArgumentNullException">If p1 or p2 are null.</exception>
    /// <exception cref="ArgumentException">If p1 and p2 are not married.</exception>
    public static Person Procreate(Person p1, Person p2)
    {
      ArgumentNullException.ThrowIfNull(p1);
      ArgumentNullException.ThrowIfNull(p2);
      if (!p1.Spouses.Contains(p2) && !p2.Spouses.Contains(p1))
      {
        throw new ArgumentException(string.Format(
          "{0} must be married to {1} to procreate with them.",
          arg0: p1.Name, arg1: p2.Name));
      }
      Person baby = new()
      {
        Name = $"Baby of {p1.Name} and {p2.Name}",
        Born = DateTimeOffset.Now
      };
      p1.Children.Add(baby);
      p2.Children.Add(baby);
      return baby;
    }
    // Instance method to "multiply".
    public Person ProcreateWith(Person partner)
    {
      return Procreate(this, partner);
    } 
    

注意以下事项:

  • 在名为 staticProcreate 方法中,将要繁殖的 Person 对象作为名为 p1p2 的参数传递。

  • 创建了一个名为 baby 的新 Person 类,其名称由繁殖的两个人的名字组合而成。这可以通过设置返回的 baby 变量的 Name 属性来稍后更改。虽然我们可以在 Procreate 方法中添加一个用于婴儿名字的第三个参数,但我们将在稍后定义一个二元运算符,它们不能有第三个参数。因此,为了保持一致性,我们只返回婴儿引用,并让调用代码设置它的名字。

  • baby 对象被添加到父母的 Children 集合中,然后返回。类是引用类型,这意味着在内存中存储的 baby 对象的引用被添加,而不是 baby 对象的克隆。

  • 在名为 ProcreateWith 的实例方法中,要繁殖的 Person 对象作为名为 partner 的参数传递,并且与 this 一起传递给静态 Procreate 方法以重用方法实现。this 是一个关键字,引用类的当前实例。使用不同的方法名对于相关的静态和实例方法是一种约定,但不要到无法匹配标准英语意义的地步。

因此,这可能意味着将两个方法命名为相同,例如,静态方法名为 Marry(x, y),实例方法名为 x.Marry(y)。更常见的是,这意味着使用不同的方法名,例如,静态方法名为 Procreate(x, y),实例方法名为 x.ProcreateWith(y),或者静态方法名为 Compare(x, y),实例方法名为 x.CompareTo(y)

良好实践:创建新对象或修改现有对象的方法应该返回对该对象的引用,以便调用者可以访问结果。

  1. Program.cs 中,创建三个人,让他们结婚并相互繁殖,注意要在 string 中添加一个双引号字符,必须使用反斜杠字符作为前缀,如下面的代码所示:
// Implementing functionality using methods.
Person lamech = new() { Name = "Lamech" };
Person adah = new() { Name = "Adah" };
Person zillah = new() { Name = "Zillah" };
// Call the instance method to marry Lamech and Adah.
lamech.Marry(adah);
// Call the static method to marry Lamech and Zillah.
Person.Marry(lamech, zillah);
lamech.OutputSpouses();
adah.OutputSpouses();
zillah.OutputSpouses();
// Call the instance method to make a baby.
Person baby1 = lamech.ProcreateWith(adah);
baby1.Name = "Jabal";
WriteLine($"{baby1.Name} was born on {baby1.Born}");
// Call the static method to make a baby.
Person baby2 = Person.Procreate(zillah, lamech);
baby2.Name = "Tubalcain";
adah.WriteChildrenToConsole();
zillah.WriteChildrenToConsole();
lamech.WriteChildrenToConsole();
for (int i = 0; i < lamech.Children.Count; i++)
{
  WriteLine(format: "  {0}'s child #{1} is named \"{2}\".",
    arg0: lamech.Name, arg1: i,
    arg2: lamech.Children[i].Name);
} 

我使用 for 循环而不是 foreach 语句,这样我就可以使用 i 变量通过索引器访问每个孩子。

  1. 运行 PeopleApp 项目并查看结果,如下面的输出所示:
Lamech is married to 2 people:
  Adah
  Zillah
Adah is married to 1 person:
  Lamech
Zillah is married to 1 person:
  Lamech
Jabal was born on 05/07/2023 15:17:03 +01:00
Adah has 1 child.
Zillah has 1 child.
Lamech has 2 children:
  Lamech's child #0 is named "Jabal".
  Lamech's child #1 is named "Tubalcain". 

正如你所看到的,对于适用于对象类型两个实例的功能,很容易提供静态和实例方法来实现相同的功能。在所有情况下,静态或实例方法都不是最好的,你无法预测你的类型可能会如何被使用。最好两者都提供,以便开发者能够以最适合他们需求的方式使用你的类型。

现在我们来看看如何为同一类型的两个实例添加第三种提供相同功能的方法。

使用操作符实现功能

System.String 类有一个名为 Concatstatic 方法,它将两个 string 值连接起来并返回结果,如下面的代码所示:

string s1 = "Hello ";
string s2 = "World!";
string s3 = string.Concat(s1, s2);
WriteLine(s3); // Hello World! 

调用一个像 Concat 这样的方法是可以的,但可能对程序员来说,使用 + 符号操作符将两个 string 值“相加”会更自然,如下面的代码所示:

string s3 = s1 + s2; 

一个著名的圣经短语是 Go forth and multiply,意味着繁殖。让我们编写代码,使 *(乘法)符号允许两个 Person 对象繁殖。我们将使用 + 操作符来结婚两个人。

我们通过为 * 符号定义一个 static 操作符来实现这一点。语法相当像方法,因为实际上操作符 * 确实是一个方法,但它使用符号而不是方法名,这使得语法更简洁:

  1. Person.cs 中,为 + 符号创建一个 static 操作符,如下面的代码所示:

    #region Operators
    // Define the + operator to "marry".
    public static bool operator +(Person p1, Person p2)
    {
      Marry(p1, p2);
      // Confirm they are both now married.
      return p1.Married && p2.Married;
    }
    #endregion 
    

操作符的返回类型不需要与操作符参数的类型匹配,但返回类型不能是 void

  1. Person.cs 中,为 * 符号创建一个 static 操作符,如下面的代码所示:

    // Define the * operator to "multiply".
    public static Person operator *(Person p1, Person p2)
    {
      // Return a reference to the baby that results from multiplying.
      return Procreate(p1, p2);
    } 
    

良好实践:与方法不同,当你输入点号(.)时,操作符不会出现在类型或类型实例的 IntelliSense 列表中。对于你定义的每个操作符,都要创建一个方法,因为程序员可能不会明显地意识到操作符是可用的。然后操作符的实现可以调用该方法,重用你编写的代码。提供方法的第二个原因是,并非所有语言编译器都支持操作符;例如,尽管 * 这样的算术操作符在 Visual Basic 和 F# 中受支持,但并没有要求其他语言支持 C# 支持的所有操作符。你必须阅读类型定义或文档来发现是否实现了操作符。

  1. Program.cs 中,注释掉调用静态 Marry 方法的语句,并用一个使用 + 操作符将他们结婚的 if 语句替换它,如下面的代码所示:

    // Person.Marry(lamech, zillah);
    if (lamech + zillah)
    {
      WriteLine($"{lamech.Name} and {zillah.Name} successfully got married.");
    } 
    
  2. Program.cs 中,在调用 Procreate 方法并在将子代写入控制台语句之前,使用 * 操作符让拉麦和他的妻子亚达和西拉再有两个孩子,如下面的代码所示:

    // Use the * operator to "multiply".
    Person baby3 = lamech * adah;
    baby3.Name = "Jubal";
    Person baby4 = zillah * lamech;
    baby4.Name = "Naamah"; 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    Lamech and Zillah successfully got married.
    Lamech is married to 2 people:
      Adah
      Zillah
    Adah is married to 1 person:
      Lamech
    Zillah is married to 1 person:
      Lamech
    Jabal was born on 05/07/2023 15:27:30 +01:00
    Adah has 2 children.
    Zillah has 2 children.
    Lamech has 4 children:
      Lamech's child #0 is named "Jabal".
      Lamech's child #1 is named "Tubalcain".
      Lamech's child #2 is named "Jubal".
      Lamech's child #3 is named "Naamah". 
    

更多信息:要了解更多关于运算符重载的信息,你可以阅读以下链接中的文档:

learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/operator-overloading

使用泛型安全地重用类型

在 2005 年,随着 C# 2 和.NET Framework 2 的推出,微软引入了一个名为泛型的功能,这使得你的类型可以更安全地重用和高效。它是通过允许程序员传递类型作为参数来实现的,就像你可以传递对象作为参数一样。

本主题仅关于需要为其所处理的类型提供灵活性的类型。例如,集合类型需要能够存储任何类型的多个实例。这种灵活性可以通过使用System.Object类型或泛型来实现。对于不需要类型灵活性的其他场景,使用非泛型类型是良好的实践。

使用非泛型类型

首先,让我们看看使用非泛型类型的示例,以便你可以理解泛型旨在解决的问题,例如弱类型参数和值,以及使用System.Object引起的性能问题。

System.Collections.Hashtable可用于存储多个键值对,每个键值对都有一个唯一的键,稍后可以用来快速查找其值。键和值都可以是任何对象,因为它们被声明为System.Object。尽管这提供了灵活性,但它速度较慢,并且更容易引入错误,因为在添加项目时没有进行类型检查。

让我们编写一些代码:

  1. Program.cs中,创建一个非泛型集合System.Collections.Hashtable的实例,并向其中添加四个项目,如下面的代码所示:

    // Non-generic lookup collection.
    System.Collections.Hashtable lookupObject = new();
    lookupObject.Add(key: 1, value: "Alpha");
    lookupObject.Add(key: 2, value: "Beta");
    lookupObject.Add(key: 3, value: "Gamma");
    lookupObject.Add(key: harry, value: "Delta"); 
    

注意,有三个项目具有唯一的整数键以查找它们。最后一个项目使用Person对象作为其键来查找。这在非泛型集合中是有效的。

  1. 添加语句定义一个值为2key,并使用它来在哈希表中查找其值,如下面的代码所示:

    int key = 2; // Look up the value that has 2 as its key.
    WriteLine(format: "Key {0} has value: {1}",
      arg0: key,
      arg1: lookupObject[key]); 
    
  2. 添加语句使用harry对象来查找其值,如下面的代码所示:

    // Look up the value that has harry as its key.
    WriteLine(format: "Key {0} has value: {1}",
      arg0: harry,
      arg1: lookupObject[harry]); 
    
  3. 运行PeopleApp项目,并注意它按如下所示正常工作:

    Key 2 has value: Beta
    Key Packt.Shared.Person has value: Delta 
    

尽管代码可以工作,但由于可以使用任何类型作为键或值,因此存在出错的可能性。如果另一位开发者使用了你名为lookupObject的变量,并期望所有项目都是某种类型,他们可能会将它们转换为该类型并引发异常,因为某些值可能是不同类型。拥有大量项目的查找对象也会导致性能不佳。

良好实践:避免使用System.Collections命名空间中的类型。使用System.Collections.Generics和相关命名空间中的类型。如果你需要使用使用非泛型类型的库,那么当然你必须使用非泛型类型。这通常被称为技术债务的例子。

与泛型类型一起工作

System.Collections.Generic.Dictionary<TKey, TValue>可以用来存储多个值,每个值都有一个唯一的键,稍后可以用来快速查找其值。键和值都可以是任何对象,但你必须在首次实例化集合时告诉编译器键和值的类型。你通过指定尖括号<>中的泛型参数类型TKeyTValue来完成此操作。

良好实践:当泛型类型有一个可定义的类型时,它应该命名为T,例如,List<T>,其中T是列表中存储的类型。当泛型类型有多个可定义的类型时,它应该使用T作为名称前缀,并有一个合理的名称,例如,Dictionary<TKey, TValue>

泛型提供了灵活性,运行更快,并且使错误更容易避免,因为类型检查是在编译时添加项目时进行的。我们不需要显式指定包含Dictionary<TKey, TValue>System.Collections.Generic命名空间,因为它默认隐式和全局导入。

让我们编写一些代码来使用泛型解决问题:

  1. Program.cs中,创建一个泛型查找集合Dictionary<TKey, TValue>的实例,然后向其中添加四个项目,如下面的代码所示:

    // Define a generic lookup collection.
    Dictionary<int, string> lookupIntString = new();
    lookupIntString.Add(key: 1, value: "Alpha");
    lookupIntString.Add(key: 2, value: "Beta");
    lookupIntString.Add(key: 3, value: "Gamma");
    lookupIntString.Add(key: harry, value: "Delta"); 
    
  2. 注意使用harry作为键时的编译错误,如下面的输出所示:

    /Users/markjprice/Code/Chapter06/PeopleApp/Program.cs(98,32): error CS1503: Argument 1: cannot convert from 'Packt.Shared.Person' to 'int' [/Users/markjprice/Code/Chapter06/PeopleApp/PeopleApp.csproj] 
    
  3. harry替换为4

  4. 添加语句将key设置为3,并使用它来查找字典中的值,如下面的代码所示:

    key = 3;
    WriteLine(format: "Key {0} has value: {1}",
      arg0: key,
      arg1: lookupIntString[key]); 
    
  5. 运行PeopleApp项目,并注意它按预期工作,如下面的输出所示:

    Key 3 has value: Gamma 
    

你现在已经看到了非泛型类型和需要存储任何类型的泛型类型之间的区别。你知道如果可能的话,始终使用泛型集合类型。除非你非常不幸,不得不使用遗留的非泛型库,否则你再也不需要编写使用可以存储任何类型的非泛型类型的代码了。

虽然使用泛型集合类型而不是非泛型集合类型是良好实践,但这并不意味着更一般的情况也是正确的。非泛型非集合类型和其他不需要与任何类型交互的类型的类型被经常使用。集合类型只是碰巧是最常见的从泛型中受益的类型。

触发和处理事件

方法通常被描述为对象可以执行的操作,无论是对自己还是对相关对象。例如,List<T>可以向自身添加项目或清除自身,而File可以在文件系统中创建或删除文件。

事件通常被描述为发生在对象上的动作。例如,在用户界面中,Button有一个Click事件,点击是发生在按钮上的事情。同样,FileSystemWatcher监听文件系统以获取更改通知,并引发如CreatedDeleted等事件,这些事件在目录或文件更改时触发。

另一种思考事件的方式是,它们提供了一种在对象之间交换消息的方法。

事件建立在委托的基础上,所以让我们先看看什么是委托以及它们是如何工作的。

使用委托调用方法

你已经看到了调用或执行方法最常见的方式:使用.运算符通过其名称来访问方法。例如,Console.WriteLine告诉Console类型调用其WriteLine方法。

另一种调用或执行方法的方式是使用委托。如果你使用过支持函数指针的语言,那么可以将委托视为类型安全的函数指针

换句话说,一个委托包含了一个必须与委托具有相同签名的方法的内存地址,使其能够以正确的参数类型安全地调用。

本节中的代码是说明性的,并不打算将其输入到项目中。你将在下一节中探索这样的代码,所以现在只需阅读代码并尝试理解其含义。

例如,想象一下在Person类中有一个方法,它必须以一个string类型的参数传入,并返回一个int类型,如下面的代码所示:

public class Person
{
  public int MethodIWantToCall(string input)
  {
    return input.Length; // It doesn't matter what the method does.
  } 

我可以像这样在名为p1Person实例上调用此方法:

Person p1 = new();
int answer = p1.MethodIWantToCall("Frog"); 

或者,我可以定义一个与签名匹配的委托来间接调用方法。请注意,参数的名称不必匹配。只有参数的类型和返回值必须匹配,如下面的代码所示:

delegate int DelegateWithMatchingSignature(string s); 

良好实践delegate是一个类似于class的引用类型,所以如果你在Program.cs中定义了一个,那么它必须位于文件的底部。最好将其定义在其自己的类文件中,例如,Program.Delegates.cs。如果你在Program.cs的中间定义了一个委托,那么你会看到以下编译器错误:CS8803: Top-level statements must precede namespace and type declarations

现在,我可以创建一个代表,将其指向该方法,最后调用代表(它调用该方法),如下面的代码所示:

// Create a delegate instance that points to the method.
DelegateWithMatchingSignature d = new(p1.MethodIWantToCall);
// Call the delegate, which then calls the method.
int answer2 = d("Frog"); 

委托使用的示例

你可能正在想,“那有什么用呢?”

它提供了灵活性。例如,我们可以使用委托创建一个需要按顺序调用的方法的队列。在服务中排队执行的操作是常见的,以提高可伸缩性。

另一个例子是允许并行执行多个操作。委托内置了对在另一个线程上运行的异步操作的支持,这可以提高响应性。

最重要的例子是,委托允许我们在不需要相互了解的不同对象之间实现事件以发送消息。事件是组件之间松散耦合的一个例子,因为它们不需要相互了解;它们只需要知道事件签名。

状态:很复杂

委托和事件是 C#中最令人困惑的特性之一,可能需要尝试几次才能理解,所以当你跟随我们了解它们的工作原理时,如果你感到迷茫,请不要担心!继续学习其他主题,并在你的大脑有机会在睡眠中处理这些概念之后再回来。

定义和处理委托

微软为用作事件提供了两个预定义的委托。它们都有两个参数:

  • object? sender:此参数是对引发事件或发送消息的对象的引用。?表示此引用可以是null

  • EventArgs eTEventArgs e:此参数包含有关事件的附加相关信息。例如,在一个 GUI 应用程序中,你可能定义MouseMoveEventArgs,它具有鼠标指针的XY坐标属性。一个银行账户可能有一个WithdrawEventArgs,它有一个用于Amount的属性。

它们的签名简单,但灵活,如下面的代码所示:

// For methods that do not need additional argument values passed in.
public delegate void EventHandler(object? sender, EventArgs e);
// For methods that need additional argument values passed in as
// defined by the generic type TEventArgs.
public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e); 

良好实践:当你想在自定义类型中定义一个事件时,你应该使用这两个预定义的委托之一。

一些类型在需要实例但不需要特定值时提供“空”值。例如:

  • string.Empty是一个空字符串值""。与使用""相比,使用string.Empty更好,因为每次使用""时,编译器都必须实例化一个新的空字符串实例。你可以将string.Empty视为一个全局的单例空字符串。共享它比每次需要引用空字符串值时创建自己的新实例更有效。

  • EventArgs.Empty是一个空的EventArgs值。当必须符合需要将EventArgs实例作为参数传递的内置事件委托,但不需要任何特定值,因为无论如何它都不会被读取或使用在方法中时,使用它。

你可能已经定义了一个委托,如下面的代码所示:

public EventHandler? Shout; // This field could be null. 

在那种情况下,有多种方式可以调用委托,如下面的代码示例所示:

  • 使用其变量名,Shout

    Shout(this, EventArgs.Empty); 
    
  • 使用它的Invoke方法来同步调用它:

    Shout.Invoke(this, EventArgs.Empty); 
    
  • 使用它的BeginInvoke方法来异步调用它,无需回调函数或任何状态:

    IAsyncResult result = Shout.BeginInvoke(
      this, EventArgs.Empty, null, null); 
    

BeginInvoke方法超出了本书的范围,但我包括它,以便你知道它的存在。

委托及其方法处理程序可能存在多对多的关系。一个委托可以有一个方法处理程序。但一个委托也可以有多个方法处理程序(当你将Shout连接到Harry_ShoutHarry_Shout_2时,你会这样做)。许多委托可以引用一个方法处理程序,或者这些组合中的任何组合。让我们来探索委托和事件:

  1. Person类中添加语句,并注意以下要点,如下面的代码所示:

    • 它定义了一个名为ShoutEventHandler委托字段。

    • 它定义了一个int字段来存储AngerLevel

    • 它定义了一个名为Poke的方法。

    • 每次有人被戳时,他们的AngerLevel都会增加。一旦他们的AngerLevel达到三,他们就会引发Shout事件,但前提是至少有一个事件委托指向代码中其他地方定义的方法;也就是说,它不是null

    #region Events
    // Delegate field to define the event.
    public EventHandler? Shout; // null initially.
    // Data field related to the event.
    public int AngerLevel;
    // Method to trigger the event in certain conditions.
    public void Poke()
    {
      AngerLevel++;
      if (AngerLevel < 3) return;
      // If something is listening to the event...
      if (Shout is not null)
      {
        // ...then call the delegate to "raise" the event.
        Shout(this, EventArgs.Empty);
      }
    }
    #endregion 
    

在调用对象的一个方法之前检查该对象是否不是null是非常常见的。C# 6 及以后版本允许使用?符号在.操作符之前简化行内null检查,如下面的代码所示:

Shout?.Invoke(this, EventArgs.Empty);

  1. PeopleApp项目中,添加一个名为Program.EventHandlers.cs的新类文件。

  2. Program.EventHandlers.cs中,删除任何现有的语句,然后添加一个具有匹配签名的函数,该函数从sender参数获取Person对象的引用,并输出一些关于它们的信息,如下面的代码所示:

    using Packt.Shared; // To use Person.
    // No namespace declaration so this extends the Program class
    // in the null namespace.
    partial class Program
    {
      // A method to handle the Shout event received by the harry object.
      private static void Harry_Shout(object? sender, EventArgs e)
      {
        // If no sender, then do nothing.
        if (sender is null) return;
        // If sender is not a Person, then do nothing and return; else assign sender to p.
        if (sender is not Person p) return;
        WriteLine($"{p.Name} is this angry: {p.AngerLevel}.");
      }
    } 
    

    良好实践:Microsoft 对处理事件的方法定义的约定是ObjectName_EventName。在本项目中,sender始终是一个Person实例,因此不需要进行null检查,并且事件处理程序可以仅使用WriteLine语句变得更加简单。然而,重要的是要知道,这些类型的null检查可以使你的代码在事件误用的情况下更加健壮。

你可以拥有任意多的方法作为事件处理程序,命名任意,只要方法签名与委托签名匹配。这意味着你可以有 50 个Person实例,每个实例都有自己的方法,或者有一个它们都共享的方法。这些方法可以在任何对场景有意义的级别声明,并且与设置的访问级别相匹配(如protectedprivatepublic等)。委托和事件的一个关键好处是组件之间的松散绑定,因此希望具有最大的灵活性。

  1. Program.cs中,添加一个语句将方法分配给委托字段,然后添加语句调用Poke方法四次,如下面的代码所示:

    // Assign the method to the Shout delegate.
    harry.Shout = Harry_Shout;
    // Call the Poke method that eventually raises the Shout event.
    harry.Poke();
    harry.Poke();
    harry.Poke();
    harry.Poke(); 
    
  2. 运行PeopleApp项目并查看结果。注意,哈里在前两次被戳时没有说话,而且只有在他至少被戳了三次之后,他才足够生气而大喊,如下面的输出所示:

    Harry is this angry: 3.
    Harry is this angry: 4. 
    

在第 3 步中,请注意检查 sender 是否是一个 Person 实例,如果是,则将其分配给名为 p 的局部变量,如下所示:

// If sender is not a Person, then do nothing and return; else assign sender to p.
if (sender is not Person p) return;
WriteLine($"{p.Name} is this angry: {p.AngerLevel}."); 

第一条语句同时做了两件事,需要更多的解释。名为 sender 的参数被声明为 object 类型。这意味着我们无法直接说 sender.Namesender.AngerLevel。我们需要将 sender 强制转换为显式定义为 Person 的局部变量。我们还需要检查 sender 是否实际上是一个 Person

我们可以在单个表达式中同时完成这两件事:sender is not Person p。如果 sender 不是一个 Person,则此表达式将返回 true,因此执行 return 语句,方法立即返回。或者,如果 sender 是一个 Person,则 sender 将被存储在名为 p 的局部变量中,该变量是 Person 类型。之后,我们可以使用 p.Namep.AngerLevel 这样的表达式。

定义和处理事件

你现在已经看到了代理如何实现事件最重要的功能:定义一个可以由完全不同的代码块实现的方法签名,调用该方法以及任何连接到代理字段的其他方法。

但事件又如何呢?它们比你想象的要简单。

当将方法分配给代理字段时,你不应该使用我们在前一个示例中使用的简单赋值运算符。

代理是多播的,这意味着你可以将多个代理分配给单个代理字段。我们本可以使用 += 操作符,而不是 = 赋值,以便向同一个代理字段添加更多方法。当调用代理时,所有分配的方法都会被调用,尽管你无法控制它们调用的顺序。不要使用事件来实现购票排队系统;否则,数百万 Swifties 的愤怒将降临到你身上。

如果 Shout 代理字段已经引用了一个或多个方法,通过分配另一个方法,该方法将替换所有其他方法。对于用于事件的代理,我们通常希望确保程序员只使用 +=-= 操作符来分配和删除方法:

  1. 为了强制执行此操作,在 Person.cs 文件中,将 event 关键字添加到代理字段声明中,如下所示:

    public **event** EventHandler? Shout; 
    
  2. 构建名为 PeopleApp 的项目,并注意编译器错误信息,如下所示:

    Program.cs(41,13): error CS0079: The event 'Person.Shout' can only appear on the left hand side of += or -= 
    

这就是(几乎)event 关键字所做的一切!如果你永远不会将多个方法分配给代理字段,那么从技术上讲,你不需要事件,但仍然是一个好习惯,表明你的意图,并期望代理字段被用作事件。

  1. Program.cs 文件中,修改注释和方法赋值,使用 += 而不是仅仅使用 =, 如以下代码所示:

    // Assign the method to the Shout **event** delegate.
    harry.Shout **+**= Harry_Shout; 
    
  2. 运行 PeopleApp 项目并注意它具有与之前相同的行为。

  3. Program.EventHandlers.cs 中,为 Harry 的 Shout 事件创建第二个事件处理程序,如下面的代码所示:

    // Another method to handle the event received by the harry object.
    private static void Harry_Shout_2(object? sender, EventArgs e)
    {
      WriteLine("Stop it!");
    } 
    
  4. Program.cs 中,在将 Harry_Shout 方法分配给 Shout 事件的语句之后,添加一个语句将新的事件处理程序附加到 Shout 事件,如下面的代码所示(高亮显示):

    // Assign the method**(s)** to the Shout event delegate.
    harry.Shout += Harry_Shout;
    **harry.Shout += Harry_Shout_2;** 
    
  5. 运行 PeopleApp 项目,查看结果。注意,每当引发事件时,这两个事件处理程序都会执行,这仅发生在愤怒水平达到三或更高时,如下面的输出所示:

    Harry is this angry: 3.
    Stop it!
    Harry is this angry: 4.
    Stop it! 
    

在 Windows 桌面开发中,想象你有三个按钮:AddButtonSaveButtonDeleteButton。每个按钮都有非常不同的功能。良好的实践是创建三个方法来处理它们的 Click 事件,分别命名为 AddButton_ClickSaveButton_ClickDeleteButton_Click。每个方法都会有不同的实现代码。

但现在,想象你有 26 个按钮:AButtonBButtonCButton 等等,直到 ZButton。每个按钮都有相同的功能:通过姓名的第一个字母过滤人员列表。良好的实践是创建一个方法来处理它们的 Click 事件,可能命名为 AtoZButtons_Click。此方法将包含一个实现代码,该代码将使用 sender 参数来确定哪个按钮被点击,因此知道如何应用过滤,但除此之外对所有按钮都是相同的。

事件就到这里。现在,让我们看看接口。

实现接口

接口是实现标准功能并将不同类型连接起来以创建新事物的一种方式。想想看,它们就像 LEGO™ 砖块顶部的凸起,允许它们“粘”在一起,或者插头和插座的标准电气标准。

如果一个类型实现了接口,那么它向 .NET 的其余部分承诺它支持特定的功能。因此,它们有时被描述为合同。

常见接口

表 6.1 展示了一些你的类型可能实现的常见接口:

接口 方法(s) 描述
IComparable CompareTo(other) 这定义了一个类型实现以对或排序其实例的比较方法。
IComparer Compare(first, second) 这定义了一个二级类型实现以对或排序主类型实例的比较方法。
IDisposable Dispose() 这定义了一个释放非托管资源的销毁方法,比等待终结器更有效。有关更多详细信息,请参阅本章后面的 释放非托管资源 部分。
IFormattable ToString(format, culture) 这定义了一个文化感知的方法,将对象的值格式化为字符串表示形式。
IFormatter Serialize(stream, object) Deserialize(stream) 这定义了将对象转换为和从字节流进行存储或传输的方法。
IFormatProvider GetFormat(type) 这定义了一个基于语言和区域格式化输入的方法。

表 6.1:你可能希望你的数据类型实现的常见接口

排序时比较对象

在你的数据类型中,你将想要实现的最常见的接口之一是 IComparable。如果一个类型实现了 IComparable 接口之一,那么包含该类型实例的数组和集合可以被排序。

这是排序概念的抽象示例。要排序任何类型,最小功能就是能够比较两个项目并决定哪个在前。如果一个类型实现了这种最小功能,那么排序算法可以使用它以任何排序算法想要的方式对那个类型的实例进行排序。

IComparable 接口有一个名为 CompareTo 的方法。这个方法有两种变体,一种与可空 object 类型一起工作,另一种与可空泛型类型 T 一起工作,如下面的代码所示:

namespace System
{
  public interface IComparable
  {
    int CompareTo(object? obj);
  }
  public interface IComparable<in T>
  {
    int CompareTo(T? other);
  }
} 

in 关键字指定类型参数 T 是协变的,这意味着你可以使用比指定的更不派生的类型。例如,如果 EmployeePerson 继承,那么两者都可以相互比较。

例如,string 类型通过在比较之前返回 -1(如果 string 应该在比较的 string 之前排序),返回 1(如果它应该在比较的 string 之后排序),以及返回 0(如果它们相等)来实现 IComparableint 类型通过在比较的 int 小于被比较的 int 时返回 -1,在比较的 int 大于时返回 1,以及相等时返回 0 来实现 IComparable

CompareTo 返回值可以总结如下 表 6.2

此对象在其它对象之前 此对象等于其它对象 此对象在其它对象之后
-1 0 1

表 6.2:CompareTo 返回值摘要

在我们为 Person 类实现 IComparable 接口及其 CompareTo 方法之前,让我们看看当我们尝试对未实现此接口的 Person 实例数组进行排序时会发生什么,包括一些 nullName 属性具有 null 值的实例:

  1. PeopleApp 项目中,添加一个名为 Program.Helpers.cs 的新类文件。

  2. Program.Helpers.cs 中,删除任何现有的语句。然后为 partial Program 类定义一个方法,该方法将输出作为参数传递的一个人名集合的所有名称,并在之前添加一个标题,如下面的代码所示:

    using Packt.Shared;
    partial class Program
    {
      private static void OutputPeopleNames(
        IEnumerable<Person?> people, string title)
      {
        WriteLine(title);
        foreach (Person? p in people)
        {
          WriteLine("  {0}",
            p is null ? "<null> Person" : p.Name ?? "<null> Name");
          /* if p is null then output: <null> Person
             else output: p.Name
             unless p.Name is null then output: <null> Name */
        }
      }
    } 
    
  3. Program.cs 中,添加创建 Person 实例数组的语句,调用 OutputPeopleNames 方法将项目写入控制台,然后尝试对数组进行排序并将项目再次写入控制台,如下面的代码所示:

    Person?[] people =
    {
      null,
      new() { Name = "Simon" },
      new() { Name = "Jenny" },
      new() { Name = "Adam" },
      new() { Name = null },
      new() { Name = "Richard" }
    };
    OutputPeopleNames(people, "Initial list of people:");
    Array.Sort(people);
    OutputPeopleNames(people,
      "After sorting using Person's IComparable implementation:"); 
    
  4. 运行 PeopleApp 项目,将会抛出异常。正如消息所解释的,为了修复这个问题,我们的类型必须实现 IComparable,如下面的输出所示:

    Unhandled Exception: System.InvalidOperationException: Failed to compare two elements in the array. ---> System.ArgumentException: At least one object must implement IComparable. 
    
  5. Person.cs 文件中,在继承自 object 之后,添加一个逗号并输入 IComparable<Person?>,如下面的代码所示:

    public class Person :**IComparable****<****Person****?>** 
    

    您的代码编辑器将在新代码下方绘制一个红色波浪线以警告您尚未实现您承诺的方法。您的代码编辑器可以为您编写骨架实现。

  6. 点击灯泡图标,然后点击 实现接口

  7. 滚动到 Person 类的底部以找到为您编写的代码,如下面的代码所示:

    public int CompareTo(Person? other)
    {
      throw new NotImplementedException();
    } 
    
  8. 删除抛出 NotImplementedException 错误的语句。

  9. 添加处理输入值变体的语句,包括 null。调用 Name 字段的 CompareTo 方法,该方法使用 string 类型的 CompareTo 实现方式。如以下代码所示,返回结果:

    int position;
    if (other is not null)
    {
      if ((Name is not null) && (other.Name is not null))
      {
        // If both Name values are not null, then
        // use the string implementation of CompareTo.
        position = Name.CompareTo(other.Name);
      }
      else if ((Name is not null) && (other.Name is null))
      {
        position = -1; // this Person precedes other Person.
      }
      else if ((Name is null) && (other.Name is not null))
      {
        position = 1; // this Person follows other Person.
      }
      else // Name and other.Name are both null.
      {
        position = 0; // this and other are at same position.
      }
    }
    else if (other is null)
    {
      position = -1; // this Person precedes other Person.
    }
    else // this and other are both null.
    {
      position = 0; // this and other are at same position.
    }
    return position; 
    

我们选择通过比较 Name 字段来比较两个 Person 实例。因此,Person 实例将按名称字母顺序排序。null 值将被排序到集合的底部。在返回之前存储计算出的 position 对于调试很有用。我还使用了比编译器需要的更多圆括号,以便代码更容易阅读。如果您更喜欢较少的括号,那么请随意删除它们。

此外,请注意,最终的 else 语句永远不会执行,因为 ifelse if 子句的逻辑意味着它只有在 this(当前对象实例)为 null 时才会执行。在这种情况下,方法无论如何都无法执行,因为对象根本不存在!我编写了 if 语句来详尽地涵盖 otherthisnull 和非 null 所有可能的组合,但实际上最后一种组合可能永远不会发生。

  1. 运行 PeopleApp 项目。注意这次它应该按名称字母顺序排序,如下面的输出所示:

    Initial list of people:
      Simon
      <null> Person
      Jenny
      Adam
      <null> Name
      Richard
    After sorting using Person's IComparable implementation:
      Adam
      Jenny
      Richard
      Simon
      <null> Name
      <null> Person 
    

    良好实践:如果您想对您的类型的数组或集合进行排序,那么实现 IComparable 接口。

使用单独的类比较对象

有时,您可能无法访问类型的源代码,并且它可能没有实现 IComparable 接口。幸运的是,还有另一种对类型实例进行排序的方法。您可以创建一个实现略微不同接口的单独类型,该接口名为 IComparer

  1. PacktLibrary 项目中,添加一个名为 PersonComparer.cs 的新类文件,其中包含一个实现 IComparer 接口的类,该类将比较两个人,即两个 Person 实例。通过比较它们的 Name 字段的长度来实现它,如果名称长度相同,则按字母顺序比较名称,如下面的代码所示:

    namespace Packt.Shared;
    public class PersonComparer : IComparer<Person?>
    {
      public int Compare(Person? x, Person? y)
      {
        int position;
        if ((x is not null) && (y is not null))
        {
          if ((x.Name is not null) && (y.Name is not null))
          {
            // If both Name values are not null...
            // ...then compare the Name lengths...
            int result = x.Name.Length.CompareTo(y.Name.Length);
            // ...and if they are equal...
            if (result == 0)
            {
              // ...then compare by the Names...
              return x.Name.CompareTo(y.Name);
            }
            else
            {
              // ...otherwise compare by the lengths.
              position = result;
            }
          }
          else if ((x.Name is not null) && (y.Name is null))
          {
            position = -1; // x Person precedes y Person.
          }
          else if ((x.Name is null) && (y.Name is not null))
          {
            position = 1; // x Person follows y Person.
          }
          else // x.Name and y.Name are both null.
          {
            position = 0; // x and y are at same position.
          }
        }
        else if ((x is not null) && (y is null))
        {
          position = -1; // x Person precedes y Person.
        }
        else if ((x is null) && (y is not null))
        {
          position = 1; // x Person follows y Person.
        }
        else // x and y are both null.
        {
          position = 0; // x and y are at same position.
        }
        return position;
      }
    } 
    
  2. Program.cs 文件中,添加语句以使用替代实现来对数组进行排序,如下面的代码所示:

    Array.Sort(people, new PersonComparer());
    OutputPeopleNames(people,
      "After sorting using PersonComparer's IComparer implementation:"); 
    
  3. 运行 PeopleApp 项目,查看按名称长度然后按字母顺序对人员进行排序的结果,如下面的输出所示:

    After sorting using PersonComparer's IComparer implementation:
      Adam
      Jenny
      Simon
      Richard
      <null> Name
      <null> Person 
    

这次,当我们对 people 数组进行排序时,我们明确要求排序算法使用 PersonComparer 类型,以便按名字长度从短到长对人员进行排序,例如 Adam,以及从长到短排序,例如 Richard。当两个或更多名字的长度相等时,它们将按字母顺序排序,例如 JennySimon

隐式和显式接口实现

接口可以隐式和显式实现。隐式实现更简单,更常见。显式实现仅在类型必须具有具有相同名称和签名的多个方法时才是必要的。就我个人而言,我记得唯一一次需要显式实现接口是在编写这本书的代码示例时。

例如,IGamePlayerIKeyHolder 都可能有一个名为 Lose 的方法,具有相同的参数,因为游戏和钥匙都可能丢失。接口的成员始终且自动是 public 的,因为它们必须可访问,以便其他类型可以实现它们!

在必须实现两个接口的类型中,只有一个 Lose 的隐式方法可以实现。如果两个接口可以共享相同的实现,则没有问题,但如果不行,则其他 Lose 方法必须以不同的方式实现并显式调用,如下面的代码所示:

public interface IGamePlayer // Defaults to internal.
{
  void Lose(); // Defaults to public. Could be set to internal.
}
public interface IKeyHolder
{
  void Lose();
}
public class Human : IGamePlayer, IKeyHolder
{
  // Implicit implementation must be public.
  public void Lose() // Implicit implementation.
  {
    // Implement losing a key.
    WriteLine("Implementation for losing a key.");
  }
  // Explicit implementation can be any access modifier.
  void IGamePlayer.Lose() // Defaults to private.
  {
    // Implement losing a game.
    WriteLine("Implementation for losing a game.");
  }
}
Human human = new();
human.Lose(); // Calls implicit implementation of losing a key.
// Outputs: Implementation for losing a key.
((IGamePlayer)human).Lose(); // Calls explicit implementation of losing a game.
// Outputs: Implementation for losing a game.
// Alternative way to do the same.
IGamePlayer player = human as IGamePlayer;
player.Lose(); // Calls explicit implementation of losing a game.
// Outputs: Implementation for losing a game. 

虽然在 HumanIGamePlayer.Lose 的实现是 private 的,但 IGamePlayer.Lose 成员本身有一个 public 访问修饰符,所以如果我们将 Human 实例转换为接口类型,那么那个 Lose 实现是可访问的。

警告!实现类型中的方法访问修饰符必须与接口中的方法定义匹配。例如,接口中的 Lose 方法是 public,所以类中的方法实现也必须是 public

定义具有默认实现的接口

C# 8 中引入的一项语言特性是接口的默认实现。这允许接口包含实现。这打破了定义契约的接口和实现它们的类以及其他类型之间的清晰分离。一些 .NET 开发者认为这是对语言的扭曲。

让我们看看实际效果:

  1. PacktLibrary 项目中,添加一个名为 IPlayable.cs 的新文件,并修改语句以定义一个公共 IPlayable 接口,包含两个方法 PlayPause,如下面的代码所示:

    namespace Packt.Shared;
    public interface IPlayable
    {
      void Play();
      void Pause();
    } 
    
  2. PacktLibrary 项目中,添加一个名为 DvdPlayer.cs 的新类文件,并修改文件中的语句以实现 IPlayable 接口,如下面的代码所示:

    namespace Packt.Shared;
    public class DvdPlayer : IPlayable
    {
      public void Pause()
      {
        WriteLine("DVD player is pausing.");
      }
      public void Play()
      {
        WriteLine("DVD player is playing.");
      }
    } 
    

这很有用,但如果我们决定添加一个名为 Stop 的第三个方法呢?在 C# 8 之前,一旦在原始接口中实现了一个类型,这将是无法实现的。接口的一个主要特征是它是一个固定的契约。

C# 8 允许在发布后向接口添加新成员,如果这些新成员具有默认实现。C# 纯粹主义者不喜欢这个想法,但出于实际原因,例如避免破坏性更改或需要定义全新的接口,这很有用,并且其他语言如 Java 和 Swift 也启用了类似的技术。

支持默认接口实现需要对底层平台进行一些基本更改,因此它们仅在目标框架为 .NET 5 或更高版本、.NET Core 3 或更高版本或 .NET Standard 2.1 时才受支持。因此,.NET Framework 不支持它们。

让我们在接口中添加一个默认实现:

  1. IPlayable 接口修改为添加一个具有默认实现的 Stop 方法,如下代码所示:

    namespace Packt.Shared;
    public interface IPlayable
    {
      void Play();
      void Pause();
    **void****Stop****()** **// Default interface implementation.**
     **{**
     **WriteLine(****"Default implementation of Stop."****);**
     **}**
    } 
    
  2. 构建 PeopleApp 项目,并注意尽管 DvdPlayer 类没有实现 Stop,项目仍然编译成功。在未来,我们可以在 DvdPlayer 类中实现 Stop 方法来覆盖默认实现。

虽然有争议,但在接口中添加默认实现可能在定义接口时已知最常见实现的情况下很有用。因此,最好是在接口中定义一次该实现,然后大多数实现该接口的类型可以继承它而无需实现自己的。然而,如果接口定义者不知道成员应该如何或甚至能否实现,那么添加默认实现是徒劳的,因为它总会被替换。

想想你在本章前面看到的 IComparable 接口。它定义了一个 CompareTo 方法。那个方法的默认实现可能是什么?我个人认为,显然没有默认实现会具有任何实际意义。我能想到的最不糟糕的实现是将两个对象上调用 ToString 返回的 string 值进行比较。然而,每个类型实际上都应该实现自己的 CompareTo 方法。你很可能会在使用的 99.9% 的接口中找到相同的情况。

现在让我们看看如何处理空值。

处理空值

如果一个变量还没有值怎么办?我们如何表示这一点?C# 有 null 值的概念,可以用来表示变量尚未设置。

如果你不确定 .NET 中引用类型和值类型之间的区别,那么我建议你在继续阅读本节之前先阅读以下仅在线的章节:github.com/markjprice/cs13net9/blob/main/docs/ch06-memory.md

使值类型可空

默认情况下,值类型intDateTime必须始终有一个,因此得名。有时,例如,当读取允许空、缺失或null值的数据库中存储的值时,允许值类型为null是很方便的。我们称这种类型为可空值类型

你可以通过在声明变量时将问号作为后缀来启用此功能。

让我们看看一个例子。我们将创建一个新的项目,因为一些 null 处理选项是在项目级别设置的:

  1. 使用您首选的代码编辑器将一个新的控制台应用程序/console项目命名为NullHandling添加到Chapter06解决方案中。

  2. NullHandling.csproj中,添加一个<ItemGroup>以全局和静态导入System.Console类。

  3. Program.cs中,删除现有的语句,然后添加声明和赋值的语句,包括null,两个int变量,一个带有?后缀,一个不带,如下面的代码所示:

    int thisCannotBeNull  = 4;
    thisCannotBeNull = null; // CS0037 compiler error!
    WriteLine(thisCannotBeNull);
    int? thisCouldBeNull = null;
    WriteLine(thisCouldBeNull);
    WriteLine(thisCouldBeNull.GetValueOrDefault());
    thisCouldBeNull = 7;
    WriteLine(thisCouldBeNull);
    WriteLine(thisCouldBeNull.GetValueOrDefault()); 
    
  4. 构建项目并注意编译错误,如下面的输出所示:

    Cannot convert null to 'int' because it is a non-nullable value type 
    
  5. 注释掉导致编译错误的语句,如下面的代码所示:

    //thisCannotBeNull = null; // CS0037 compiler error! 
    
  6. 运行项目并查看结果,如下面的输出所示:

    4
    0
    7
    7 
    

第二行是空的,因为它输出了null值。

  1. 添加语句以使用替代语法,如下面的代码所示:

    // The actual type of int? is Nullable<int>.
    Nullable<int> thisCouldAlsoBeNull = null;
    thisCouldAlsoBeNull = 9;
    WriteLine(thisCouldAlsoBeNull); 
    
  2. 点击Nullable<int>并按F12,或右键单击并选择转到定义

  3. 注意,泛型值类型Nullable<T>必须有一个类型T,它是一个struct或值类型。它具有如HasValueValueGetValueOrDefault等有用的成员,如图 6.1 所示:

图 6.1:揭示Nullable<T>成员

良好实践:当你在struct类型后追加一个?时,你将其转换为不同的类型。例如,DateTime?变为Nullable<DateTime>

理解与 null 相关的缩写

在我们查看一些代码之前,让我们回顾一下表 6.3 中的一些常用缩写:

缩写 含义 描述
NRT 可空引用类型 这是 C# 8 中引入的编译器功能,在 C# 10 的新项目中默认启用,它在设计时对您的代码进行静态分析,并显示对引用类型null值潜在误用的警告。
NRE NullReferenceException 取消引用一个null值时,在运行时抛出此异常,也称为访问一个null对象上的变量或成员。
ANE ArgumentNullException 当方法、属性或索引器调用抛出异常时,如果参数或值为null,并且业务逻辑确定它无效,则会在运行时抛出此异常。

表 6.3:常用缩写

理解可空引用类型

null值的用法在许多语言中都很常见,以至于许多经验丰富的程序员从未质疑其存在的必要性。然而,有许多场景,如果我们不允许变量有null值,我们可以编写更好、更简单的代码。

C# 8 语言编译器最显著的变化是引入了对可空和不可空引用类型的检查和警告。“但是等等!”,你可能正在想。“引用类型已经是可空的!”

你是对的,但在 C# 8 及以后的版本中,可以通过设置文件或项目级别的选项来配置引用类型,以警告null值,从而启用这个有用的新功能。由于这是 C#的一个重大变化,微软决定将此功能作为可选功能。

由于数千个现有的库包和应用程序将期望旧的行为,这个新的 C#语言编译器功能要产生影响可能需要几年时间。甚至微软也没有时间在所有主要的.NET 包中完全实现这个新功能,直到.NET 6。像Microsoft.Extensions这样的重要库,用于日志记录、依赖注入和配置,直到.NET 7 才进行注解。

在过渡期间,你可以为你的项目选择几种方法:

  • 默认:对于使用.NET 5 或更早版本创建的项目,不需要进行任何更改。不可为空的引用类型不会被检查。对于使用.NET 6 或更高版本创建的项目,默认启用 nullability 检查,但可以通过删除项目文件中的<Nullable>条目或将它设置为disable来禁用。

  • 选择加入项目和选择退出文件:在项目级别启用功能,对于需要保持与旧行为兼容的任何文件,选择退出。这是微软在更新其自己的包以使用此新功能时内部使用的做法。

  • 选择加入文件:仅对单个文件启用 NRT 功能。

警告! 此 NRT 功能并不能阻止null值,它只是警告你有关它们的信息,而且警告可以被禁用,所以你仍然需要小心!例如,使用?字符声明一个变量为可空并不允许null值,它只是禁用了警告,如下面的代码所示:

string firstName; // Allows null but gives warning when
potentially null.
string? lastName; // Allows null and does not give warning if
null. 

控制 nullability 警告检查功能

要在项目级别启用 nullability 警告检查功能,请在项目文件中将<Nullable>元素设置为enable,如下面的标记所示:

<PropertyGroup>
  ...
 **<Nullable>enable</Nullable>**
</PropertyGroup> 

要在项目级别禁用 nullability 警告检查功能,请在项目文件中将<Nullable>元素设置为disable,如下面的标记所示:

<PropertyGroup>
  ...
 **<Nullable>disable</Nullable>**
</PropertyGroup> 

你也可以完全删除<Nullable>元素,因为默认情况下如果没有明确设置,则禁用。

要在文件级别禁用此功能,请在代码文件顶部添加以下内容:

#nullable disable 

要在文件级别启用此功能,请在代码文件顶部添加以下内容:

#nullable enable 

禁用 null 和其他编译器警告

你可以选择在项目或文件级别启用可空性功能,但随后可能需要禁用与它相关的 50 多个警告。一些常见的可空性警告如下表 6.4 所示:

代码 描述
CS8600 将空字面量或可能的空值转换为非可空类型
CS8601 一个可能的空引用赋值
CS8602 可能的空引用解引用
CS8603 一个可能的空引用返回
CS8604 参数的一个可能的空引用参数
CS8618 当退出构造函数时,非可空字段<field_name>必须包含一个非空值。考虑将字段声明为可空的
CS8625 无法将空字面量转换为非可空引用类型
CS8655 switch 表达式没有处理一些空输入(它不是详尽的)

表 6.4:常见的可空性警告

你可以禁用整个项目的编译器警告。为此,添加一个NoWarn元素,其中包含用分号分隔的编译器警告代码列表,如下面的标记所示:

<NoWarn>CS8600;CS8602</NoWarn> 

要在语句级别禁用编译器警告,你可以禁用并恢复指定的编译器警告,以暂时抑制一组语句,如下面的代码所示:

#pragma warning disable CS8602
WriteLine(firstName.Length);
WriteLine(lastName.Length);
#pragma warning restore CS8602 

这些技术可以用于任何编译器警告,而不仅仅是与可空性相关的警告。

声明非可空变量和参数

如果你启用了 NRTs 并且想要将null值分配给引用类型,那么你必须使用相同的语法来使值类型可空,即在类型声明后添加一个?符号。

那么,NRTs 是如何工作的呢?让我们看一个例子。当存储关于地址的信息时,你可能想要强制为街道、城市和地区设置一个值,但建筑可以留空,即null

  1. NullHandling项目中,添加一个名为Address.cs的类文件。

  2. Address.cs中,删除任何现有的语句,然后添加语句来声明一个具有四个字段的Address类,如下面的代码所示:

    namespace Packt.Shared;
    public class Address
    {
      public string? Building;
      public string Street;
      public string City;
      public string Region;
    } 
    
  3. 几秒钟后,注意关于非可空字段的警告,例如Street未初始化,如下表 6.2 所示:

图 6.2:错误列表窗口中的非可空字段警告信息

  1. 将空string值分配给Street字段,并定义构造函数来设置其他非空字段,如下面的代码所示:

    public string Street **=** **string****.Empty**;
    public string City;
    public string Region;
    **public****Address****()**
    **{**
     **City =** **string****.Empty;**
     **Region =** **string****.Empty;**
    **}**
    **// Call the default parameterless constructor**
    **// to ensure that Region is also set.**
    **public****Address****(****string** **city****) :** **this****()**
    **{**
     **City = city;**
    **}** 
    
  2. Program.cs中,导入命名空间以使用Address,如下面的代码所示:

    using Packt.Shared; // To use Address. 
    
  3. Program.cs中,添加语句来实例化一个Address并设置其属性,如下面的代码所示:

    Address address = new(city: "London")
    {
      Building = null,
      Street = null,
      Region = "UK"
    }; 
    
  4. 注意设置Street时的Warning CS8625,但没有Building,如下面的输出所示:

    CS8625 Cannot convert null literal to non-nullable reference type. 
    
  5. 在设置Street时,在null后添加一个感叹号,如下面的代码所示:

    Street = null**!**, // null-forgiving operator. 
    
  6. 注意警告消失了。

  7. 添加将取消引用 BuildingStreet 属性的语句,如下面的代码所示:

    WriteLine(address.Building.Length);
    WriteLine(address.Street.Length); 
    
  8. 注意设置 Building 但没有设置 Street 时的 Warning CS8602,如下面的输出所示:

    CS8602 Dereference of a possibly null reference. 
    

在运行时,当与 Street 一起工作时,仍然可能会抛出异常,但编译器应该继续警告你与 Building 一起工作时可能出现的潜在异常,这样你就可以更改代码以避免它们。

  1. 使用空条件运算符返回 null 而不是访问 Length,如下面的代码所示:

    WriteLine(address.Building?.Length); 
    
  2. 运行控制台应用程序,注意访问 BuildingLength 输出 null 值(空白行),但当我们访问 StreetLength 时,会抛出运行时异常,如下面的输出所示:

    Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object. 
    
  3. 将访问 Street 长度的语句包裹在空检查中,如下面的代码所示:

    if (address.Street is not null)
    {
      WriteLine(address.Street.Length);
    } 
    

值得提醒的是,NRTs 只关于请求编译器提供有关可能引起问题的潜在 null 值的警告。它实际上并不改变你的代码的行为。它是在编译时对代码进行静态分析。

这就是为什么新的语言特性被命名为 可空引用类型NRTs)。从 C# 8 开始,未修饰的引用类型可以变为不可空,并且使用与值类型相同的语法来使引用类型变为可空。

在引用类型后添加 ? 并不会改变类型。这与在值类型后添加 ? 不同,后者将其类型更改为 Nullable<T>。引用类型已经可以具有 null 值。你通过 NRTs 做的只是告诉编译器你期望它为 null,因此编译器不需要警告你。然而,这并不消除在整个代码中执行 null 检查的需要。

现在,让我们看看与 null 值一起工作的语言特性,这些特性会改变你的代码的行为,并且作为 NRTs 的良好补充。

检查空值

检查一个可空引用类型或值类型变量当前是否包含 null 是很重要的,因为如果你不这样做,可能会抛出 NullReferenceException 异常,从而导致错误。在使用可空变量之前,你应该检查 null 值,如下面的代码所示:

// Check that the variable is not null before using it.
if (thisCouldBeNull != null)
{
  // Access a member of thisCouldBeNull.
  int length = thisCouldBeNull.Length;
  ...
} 

C# 7 引入了 is!(非)操作符的组合作为 != 的替代方案,如下面的代码所示:

if (!(thisCouldBeNull is null))
{ 

C# 9 引入了 is not 作为 != 的一个更清晰的替代方案,如下面的代码所示:

if (thisCouldBeNull is not null)
{ 

良好实践:虽然你传统上会使用 (thisCouldBeNull != null) 这样的表达式,但这已经不再被认为是良好的实践,因为开发者可能已经重载了 != 操作符来改变其工作方式。使用 is nullis not null 的模式匹配是检查 null 的唯一保证方法。对于许多开发者来说,使用 != 仍然是一种本能,所以如果我发现我还在使用它,我提前向你道歉!

由于 is 运算符不能被开发者重载,其行为是保证的。==!= 运算符可以被开发者重载,因此它们的行为可能会改变。.NET 团队现在已经一致地使用 is 来实现它们的空值检查,所以我建议你也这样做,如下面的链接所示:

twitter.com/Dave_DotNet/status/1764995927868076428

“重写”这个术语是指在面向对象继承的上下文中改变或扩展继承方法的行为。“重载”发生在你为现有的运算符或方法提供新的实现时。

如果你尝试使用可能为 null 的变量的成员,请使用 空条件运算符 ?.,如下面的代码所示:

string authorName = null;
int? authorNameLength;
// The following throws a NullReferenceException.
authorNameLength = authorName.Length;
// Instead of throwing an exception, null is assigned.
authorNameLength = authorName?.Length; 

有时,你可能想要将变量分配给结果,或者如果变量为 null,则使用替代值,例如 3。你可以使用 空合并运算符 ?? 来完成这个操作,如下面的代码所示:

// Result will be 25 if authorName?.Length is null.
authorNameLength = authorName?.Length ?? 25; 

在方法参数中检查 null

即使你启用了 NRTs,在定义带有参数的方法时,检查 null 值也是良好的实践。

在 C# 的早期版本中,你必须编写 if 语句来检查 null 参数值,然后为任何 null 参数抛出 ArgumentNullException 异常,如下面的代码所示:

public void Hire(Person manager, Person employee)
{
  if (manager is null)
  {
    throw new ArgumentNullException(paramName: nameof(manager));
  }
  if (employee is null)
  {
    throw new ArgumentNullException(paramName: nameof(employee));
  }
  ...
} 

C# 10 引入了一个便利方法,如果参数为 null,则会抛出异常,如下面的代码所示:

public void Hire(Person manager, Person employee)
{
  ArgumentNullException.ThrowIfNull(manager);
  ArgumentNullException.ThrowIfNull(employee);
  ...
} 

C# 11 预览版提出了并引入了一个新的 !! 运算符,它可以为你完成这个操作,如下面的代码所示:

public void Hire(Person manager!!, Person employee!!)
{
  ...
} 

if 语句和异常的抛出将由系统自动完成。代码会在你编写的任何语句之前注入并执行。

这个提议在 C# 开发者社区中引起了争议。有些人可能更愿意使用属性来装饰参数,而不是一对字符。.NET 产品团队表示,他们通过使用这个特性将 .NET 库减少了超过 10,000 行代码。

这听起来像是一个使用它的好理由!而且,如果他们选择不使用,没有人必须使用它。不幸的是,团队最终决定删除这个特性,所以我们现在都必须手动编写空值检查。如果你对这个故事感兴趣,你可以在以下链接中了解更多信息:

devblogs.microsoft.com/dotnet/csharp-11-preview-updates/#remove-parameter-null-checking-from-c-11

我将这个故事包含在这本书中,因为我认为它是微软透明度的一个有趣例子,通过在公开环境中开发 .NET 并倾听并回应社区的反馈。

良好实践:始终记住,可空性是一个警告检查,而不是强制执行。你可以在以下链接中了解更多有关与 null 相关的编译器警告的信息:

learn.microsoft.com/en-us/dotnet/csharp/language-reference/compiler-messages/nullable-warnings

关于“无”的讨论已经足够多了!让我们看看本章的重点,继承。

从类中继承

我们之前创建的 Person 类型是从 System.Object 继承(继承)的。现在,我们将创建一个从 Person 继承的子类:

  1. PacktLibrary 项目中,添加一个名为 Employee.cs 的新类文件。

  2. 修改其内容以定义一个名为 Employee 的类,该类从 Person 继承,如下面的代码所示:

    namespace Packt.Shared;
    public class Employee : Person
    {
    } 
    
  3. PeopleApp 项目中,在 Program.cs 文件中添加语句以创建 Employee 类的一个实例,如下面的代码所示:

    Employee john = new()
    {
      Name = "John Jones",
      Born = new(year: 1990, month: 7, day: 28,
        hour: 0, minute: 0, second: 0, offset: TimeSpan.Zero)
    };
    john.WriteToConsole(); 
    
  4. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    John Jones was born on a Saturday. 
    

注意,Employee 类继承了 Person 类的所有成员。

扩展类以添加功能

现在,我们将添加一些特定于员工的新成员以扩展这个类:

  1. Employee.cs 文件中,添加语句以定义两个属性,一个用于员工代码,另一个用于他们被雇佣的日期(我们不需要知道开始时间,因此可以使用 DateOnly 类型),如下面的代码所示:

    public string? EmployeeCode { get; set; }
    public DateOnly HireDate { get; set; } 
    
  2. Program.cs 文件中,添加语句以设置约翰的员工代码和雇佣日期,如下面的代码所示:

    john.EmployeeCode = "JJ001";
    john.HireDate = new(year: 2014, month: 11, day: 23);
    WriteLine($"{john.Name} was hired on {john.HireDate:yyyy-MM-dd}."); 
    
  3. 运行 PeopleApp 项目并查看结果,如下面的输出所示:

    John Jones was hired on 2014-11-23. 
    

隐藏成员

到目前为止,WriteToConsole 方法是从 Person 继承的,并且它只输出员工的名字和出生日期和时间。我们可能想要改变这个方法对员工的行为:

  1. Employee.cs 文件中,添加语句以重新定义 WriteToConsole 方法,如下面的代码所示:

    namespace Packt.Shared;
    public class Employee : Person
    {
      public string? EmployeeCode { get; set; }
      public DateOnly HireDate { get; set; }
    **public****void****WriteToConsole****()**
     **{**
     **WriteLine(format:**
    **"{0} was born on {1:dd/MM/yy} and hired on {2:dd/MM/yy}."****,**
     **arg0: Name, arg1: Born, arg2: HireDate);**
     **}**
    } 
    
  2. 运行 PeopleApp 项目,查看结果,并注意输出中的第一行是在员工被雇佣之前;因此,它有一个默认日期,如下面的输出所示:

    John Jones was born on 28/07/90 and hired on 01/01/01.
    John Jones was hired on 2014-11-23. 
    

你的代码编辑器会警告你,你的方法现在通过在方法名下画波浪线来隐藏 Person 中的方法,问题/错误列表窗口包含更多详细信息,并且在构建和运行控制台应用程序时,编译器会输出警告,如图 6.3 所示:

图 6.3:隐藏方法警告

如警告所述,你应该通过应用 new 关键字来隐藏这个消息,这表示你故意替换了旧方法,如下面的代码所示:

public **new** void WriteToConsole() 

现在立即修复这个问题。

理解 this 和 base 关键字

有两个特殊的 C# 关键字可以用来引用当前对象实例或它继承的基类:

  • this:它代表当前对象实例。例如,在 Person 类的实例成员(但不在静态成员中),你可以使用表达式 this.Born 来访问当前对象实例的 Born 字段。你很少需要使用它,因为表达式 Born 也会起作用。只有当存在一个也命名为 Born 的局部变量时,你才需要使用 this.Born 来明确表示你正在引用字段,而不是局部变量。

  • base:它代表当前对象继承的基类。例如,在 Person 类的任何地方,你可以使用表达式 base.ToString() 来调用该方法的基类实现。

你应该(希望)从 第五章使用面向对象编程构建自己的类型 中记住,要访问静态成员,必须使用类型名称。

覆盖成员

与隐藏方法相比,通常更好的做法是 覆盖 它。只有当基类选择允许覆盖时,你才能覆盖它,通过将 virtual 关键字应用于应允许覆盖的任何方法。

让我们看看一个例子:

  1. Program.cs 中,添加一个语句将 john 变量的值使用其 string 表示形式写入控制台,如下面的代码所示:

    WriteLine(john.ToString()); 
    
  2. 运行 PeopleApp 项目并注意,ToString 方法是从 System.Object 继承的,因此其实现返回命名空间和类型名称,如下面的输出所示:

    Packt.Shared.Employee 
    
  3. Person.cs(不在 Employee 类中!)中,通过添加一个 ToString 方法来覆盖此行为,输出人员的名称以及类型名称,如下面的代码所示:

    #region Overridden methods
    public override string ToString()
    {
      return $"{Name} is a {base.ToString()}.";
    }
    #endregion 
    

    base 关键字允许子类访问其超类的成员,即它继承或派生的 基类

良好实践:一些代码编辑器,如 Visual Studio,在覆盖成员时将自动添加对基类成员的调用。为了决定是否需要保留此调用,请查看成员的提示或定义。例如,在 第十章使用 Entity Framework Core 处理数据 中,你将覆盖 DbContext 类的一个名为 OnConfiguring 的方法。如果你将鼠标悬停在方法名称上,其提示会告诉你“基类实现不执行任何操作。”如果你 转到定义 (F12) 方法,你会看到它确实不执行任何操作,如下面的代码所示:

protected internal virtual void OnConfiguring(
DbContextOptionsBuilder optionsBuilder)
{
} 

该方法仅存在于基类中,这样子类就可以覆盖它,然后当 EF Core 需要配置数据上下文时可以调用你的代码。覆盖方法实现调用基类实现将是一种浪费。

  1. 运行 PeopleApp 项目并查看结果。现在,当调用 ToString 方法时,它将输出人员的名称,同时返回基类的 ToString 实现如下所示:

    John Jones is a Packt.Shared.Employee. 
    

良好实践:许多现实世界的 API,例如微软的 Entity Framework Core、Castle 的 DynamicProxy 和 Optimizely CMS 的内容模型,要求你在类中定义的属性被标记为 virtual,这样它们才能被重写。仔细决定哪些方法和属性成员应该被标记为 virtual

从抽象类继承

在本章的早期部分,你学习了可以定义一组成员的类型,这些成员必须满足基本功能级别。这些非常有用,但它们的主要限制是,直到 C# 8,它们不能提供自己的任何实现。

这是一个特别的问题,如果你仍然需要创建将与 .NET Framework 和不支持 .NET Standard 2.1 的其他平台一起工作的类库。

在那些早期的平台上,你可以使用一个 abstract class 作为纯接口和完全实现类之间的某种中间解决方案。

当一个类被标记为 abstract 时,这意味着它不能被实例化,因为你已经表明这个类是不完整的。在它可以被实例化之前,它需要更多的实现。

例如,System.IO.Stream 类是 abstract 的,因为它实现了所有流都需要但又不完整的通用功能。因此,如果没有更多针对特定流类型的特定实现,它就毫无用处,所以你不能使用 new Stream() 来实例化它。

让我们比较以下代码中显示的两种接口类型和两种类类型:

public interface INoImplementation // C# 1 and later.
{
  void Alpha(); // Must be implemented by derived type.
}
public interface ISomeImplementation // C# 8 and later.
{
  void Alpha(); // Must be implemented by derived type.
  void Beta()
  {
    // Default implementation; can be overridden.
  }
}
public abstract class PartiallyImplemented // C# 1 and later.
{
  public abstract void Gamma(); // Must be implemented by derived type.
  public virtual void Delta() // Can be overridden.
  {
    // Implementation.
  }
}
public class FullyImplemented : PartiallyImplemented, ISomeImplementation
{
  public void Alpha()
  {
    // Implementation.
  }
  public override void Gamma()
  {
    // Implementation.
  }
}
// You can only instantiate the fully implemented class.
FullyImplemented a = new();
// All the other types give compile errors.
PartiallyImplemented b = new(); // Compile error!
ISomeImplementation c = new(); // Compile error!
INoImplementation d = new(); // Compile error! 

在接口和抽象类之间进行选择

你现在已经看到了使用接口或 abstract 类实现抽象概念的示例。你应该选择哪一个?现在,由于接口可以为它的成员提供默认实现,类中的 abstract 关键字是否已经过时了?

好吧,让我们考虑一个真实的例子。Stream 是一个 abstract 类。.NET 团队今天会使用接口来处理它吗?

接口的所有成员都必须是 public(或者至少与接口的访问级别相匹配,如果它应该只在其定义的类库中使用,则可以是 internal)。抽象类在其成员的访问修饰符方面有更多的灵活性。

与接口相比,抽象类的一个优点是,序列化通常不适用于接口。所以,不,我们仍然需要能够定义抽象类。

防止继承和重写

你可以通过将 sealed 关键字应用于其定义来防止其他开发者从你的类继承。例如,正如以下代码所示,没有人可以从 Scrooge McDuck 继承。

public sealed class ScroogeMcDuck
{
} 

.NET 中 sealed 的一个例子是 string 类。微软在 string 类内部实现了一些可能因你的继承而受到负面影响的极端优化,所以微软阻止了这种情况。

你可以通过在方法上应用sealed关键字来防止你的类中进一步重写virtual方法。例如,没有人可以改变 Lady Gaga 的唱歌方式,如下面的代码所示:

namespace Packt.Shared;
public class Singer
{
  // Virtual allows this method to be overridden.
  public virtual void Sing()
  {
    WriteLine("Singing...");
  }
}
public class LadyGaga : Singer
{
  // The sealed keyword prevents overriding the method in subclasses.
  public sealed override void Sing()
  {
    WriteLine("Singing with style...");
  }
} 

你只能密封重写的方法。

理解多态性

你现在已经看到了两种改变继承方法行为的方法。我们可以使用new关键字(称为非多态继承)来隐藏它,或者我们可以重写它(称为多态继承)。

两种方式都可以通过使用base关键字来访问基类或超类的成员,那么它们之间有什么区别呢?

这一切都取决于持有对象引用的变量的类型。例如,一个Person类型的变量可以持有Person类的引用,或者任何从Person派生的类型。

让我们看看这可能会如何影响你的代码:

  1. Employee.cs中,添加语句来重写ToString方法,以便将员工的名字和代码写入控制台,如下面的代码所示:

    public override string ToString()
    {
      return $"{Name}'s code is {EmployeeCode}.";
    } 
    
  2. Program.cs中,编写语句创建一个新的员工 Alice,并将其存储在类型为Employee的变量中。然后,将 Alice 存储在另一个类型为Person的变量中,然后调用两个变量的WriteToConsoleToString方法,如下面的代码所示:

    Employee aliceInEmployee = new()
      { Name = "Alice", EmployeeCode = "AA123" };
    Person aliceInPerson = aliceInEmployee;
    aliceInEmployee.WriteToConsole();
    aliceInPerson.WriteToConsole();
    WriteLine(aliceInEmployee.ToString());
    WriteLine(aliceInPerson.ToString()); 
    
  3. 运行PeopleApp项目并查看结果,如下面的输出所示:

    Alice was born on 01/01/01 and hired on 01/01/01
    Alice was born on a Monday
    Alice's code is AA123
    Alice's code is AA123 
    

当一个方法被new关键字隐藏时,编译器并不足够智能,无法知道该对象是一个Employee,因此它会调用Person中的WriteToConsole方法。

当一个方法使用virtualoverride关键字被重写时,编译器足够智能,知道尽管变量被声明为Person类,而对象本身是Employee类,因此调用的是Employee类的ToString实现。

成员修饰符及其影响总结在表 6.5中:

变量类型 成员修饰符 执行的方法 在类中
Person WriteToConsole Person
Employee new WriteToConsole Employee
Person virtual ToString Employee
Employee override ToString Employee

表 6.5:成员修饰符及其影响

在我看来,多态性对大多数程序员来说都是学术性的。如果你理解了这个概念,那很好;但如果不懂,我建议你不要担心。有些人喜欢通过说理解多态性对所有 C#程序员都很重要来让别人感到自卑,但在我看来,这并不重要。还有成千上万的其他主题,你的时间和精力将更好地花在这些主题上。

你可以用 C#成功地进行职业生涯,而无需能够解释多态性,就像赛车手不需要解释燃油喷射的工程原理一样。

良好实践:在可能的情况下,你应该使用virtualoverride而不是new来更改继承方法的实现。

在继承层次结构中进行类型转换

转换在类型之间是微妙不同的,与类型之间的转换不同。转换是在相似类型之间,比如在 16 位整数和 32 位整数之间,或者在超类和它的子类之间。转换是在不相似类型之间,例如在文本和数字之间。

例如,如果你需要处理多种类型的stream,那么你不需要声明特定的流类型,如MemoryStreamFileStream,你可以声明一个Stream类型的数组,它是MemoryStreamFileStream的超类型。

隐式转换

在前面的例子中,你看到了如何将派生类型的实例存储在其基类型(或其基的基类型,等等)的变量中。当我们这样做时,这被称为隐式转换

显式转换

隐式转换的相反是显式转换,你必须使用括号将你想要转换到的类型作为前缀来执行:

  1. Program.cs中,添加语句将aliceInPerson变量赋值给一个新的Employee变量,如下代码所示:

    Employee explicitAlice = aliceInPerson; 
    
  2. 你的代码编辑器显示了一个红色波浪线和编译错误,如图 6.4 所示:

图片

图 6.4:缺少显式转换的编译错误

  1. 将语句改为在分配的变量名前加上Employee类型的转换,如下代码所示:

    Employee explicitAlice = **(Employee)**aliceInPerson; 
    

避免转换异常

编译器现在很高兴;然而,因为aliceInPerson可能是一个不同的派生类型,比如Student而不是Employee,我们需要小心。在一个具有更复杂代码的真实应用中,这个变量的当前值可能被设置为Student实例,然后这个语句在运行时会抛出InvalidCastException错误。

使用is关键字检查类型

我们可以通过编写try语句来处理这个问题,但有一个更好的方法。我们可以使用is关键字来检查对象的类型:

  1. 将显式转换语句包裹在if语句中,如下代码所示:

    **if** **(aliceInPerson** **is** **Employee)**
    **{**
     **WriteLine(****$"****{****nameof****(aliceInPerson)}** **is an Employee."****);**
      Employee explicitAlice = (Employee)aliceInPerson;
    **// Safely do something with explicitAlice.**
    **}** 
    
  2. 运行PeopleApp项目并查看结果,如下输出所示:

    aliceInPerson is an Employee. 
    

良好实践:你可以进一步简化代码,使用声明模式,这将避免执行显式转换,如下代码所示:

if (aliceInPerson is Employee explicitAlice)
{
  WriteLine($"{nameof(aliceInPerson)} is an Employee.");
  // Safely do something with explicitAlice.
} 

这种检查和转换的技术在第三章控制流程,转换类型和处理异常中有所介绍。

如果你想在 Alice 不是员工的情况下执行一组语句怎么办?

在过去,你将不得不使用!(非)运算符,如下代码所示:

if (!(aliceInPerson is Employee)) 

在 C# 9 及以后版本中,你可以使用not关键字,如下代码所示:

if (aliceInPerson is not Employee) 

使用as关键字转换类型

或者,你可以使用as关键字来转换类型。如果类型不能转换,as关键字返回null而不是抛出异常:

  1. Program.cs中,添加语句使用as关键字将 Alice 转换,然后检查返回值是否不为 null,如下代码所示:

    Employee? aliceAsEmployee = aliceInPerson as Employee;
    if (aliceAsEmployee is not null)
    {
      WriteLine($"{nameof(aliceInPerson)} as an Employee.");
      // Safely do something with aliceAsEmployee.
    } 
    

由于访问null变量的成员将抛出NullReferenceException错误,因此在使用结果之前,您应该始终检查null

  1. 运行PeopleApp项目并查看以下输出结果:

    aliceInPerson as an Employee. 
    

良好实践:在派生类型之间进行转换时,使用isas关键字以防止抛出异常。如果您不这样做,您必须为InvalidCastException编写try-catch语句。

继承和扩展.NET 类型

.NET 包含预构建的类库,包含数十万个类型。您不必创建自己的完全新的类型,您通常可以通过从微软的类型中派生来获得先机,以继承其部分或全部行为,然后对其进行重写或扩展。

继承异常

作为继承的一个例子,我们将派生一种新的异常类型:

  1. PacktLibrary项目中,添加一个名为PersonException.cs的新类文件。

  2. 修改文件内容以定义一个名为PersonException的类,具有三个构造函数,如下所示:

    namespace Packt.Shared;
    public class PersonException : Exception
    {
      public PersonException() : base() { }
      public PersonException(string message) : base(message) { }
      public PersonException(string message, Exception innerException)
        : base(message, innerException) { }
    } 
    

与普通方法不同,构造函数不是继承的,因此我们必须显式声明并显式调用System.Exception(或您从中派生的任何异常类)中的base构造函数实现,以便它们可供可能希望使用这些构造函数与我们的自定义异常的程序员使用。

  1. Person.cs中添加语句以定义一个方法,如果日期/时间参数早于一个人的出生日期和时间,则抛出异常,如下所示:

    public void TimeTravel(DateTime when)
    {
      if (when <= Born)
      {
        throw new PersonException("If you travel back in time to a date earlier than your own birth, then the universe will explode!");
      }
      else
      {
        WriteLine($"Welcome to {when:yyyy}!");
      }
    } 
    
  2. Program.cs中添加语句以测试当员工 John Jones 尝试穿越到太远的时间时会发生什么,如下所示:

    try
    {
      john.TimeTravel(when: new(1999, 12, 31));
      john.TimeTravel(when: new(1950, 12, 25));
    }
    catch (PersonException ex)
    {
      WriteLine(ex.Message);
    } 
    
  3. 运行PeopleApp项目并查看以下输出结果:

    Welcome to 1999!
    If you travel back in time to a date earlier than your own birth, then the universe will explode! 
    

良好实践:在定义自己的异常时,给它们与在System.Exception中显式调用内置构造函数相同的三个构造函数。您可能继承的其他异常可能有更多。

当无法继承时扩展类型

之前,我们看到了如何使用sealed修饰符来防止继承。

微软已将sealed关键字应用于System.String类,这样就没有人可以继承并可能破坏字符串的行为。

我们是否还能向字符串添加新方法?是的,如果我们使用名为扩展方法的语言特性,该特性是在 C# 3.0 中引入的。为了正确理解扩展方法,我们需要首先回顾静态方法。

使用静态方法重用功能

自从 C#的第一个版本以来,我们能够创建static方法来重用功能,例如验证一个string是否包含电子邮件地址的能力。实现将使用您将在第八章与常见.NET 类型一起工作中了解更多信息的正则表达式。

让我们编写一些代码:

  1. PacktLibrary项目中,添加一个名为StringExtensions.cs的新类文件。

  2. 修改 StringExtensions.cs,如下所示代码,并注意以下内容:

    • 类导入一个命名空间来处理正则表达式。

    • IsValidEmail 方法是 static 的,它使用 Regex 类型来检查与简单电子邮件模式匹配,该模式查找 @ 符号前后有效的字符:

      using System.Text.RegularExpressions; // To use Regex.
      namespace Packt.Shared;
      public class StringExtensions
      {
        public static bool IsValidEmail(string input)
        {
          // Use a simple regular expression to check
          // that the input string is a valid email.
          return Regex.IsMatch(input,
            @"[a-zA-Z0-9\.-_]+@[a-zA-Z0-9\.-_]+");
        }
      } 
      
  3. Program.cs 中添加语句以验证两个电子邮件地址示例,如下所示代码:

    string email1 = "pamela@test.com";
    string email2 = "ian&test.com";
    WriteLine("{0} is a valid e-mail address: {1}",
      arg0: email1,
      arg1: StringExtensions.IsValidEmail(email1));
    WriteLine("{0} is a valid e-mail address: {1}",
      arg0: email2,
      arg1: StringExtensions.IsValidEmail(email2)); 
    
  4. 运行 PeopleApp 项目并查看结果,如下所示输出:

    pamela@test.com is a valid e-mail address: True
    ian&test.com is a valid e-mail address: False 
    

这可行,但扩展方法可以减少我们必须键入的代码量,并简化此函数的使用。

使用扩展方法重用功能

static 方法转换为扩展方法很容易:

  1. StringExtensions.cs 中,在类之前添加 static 修饰符,然后在 string 类型之前添加 this 修饰符,如下所示代码中突出显示:

    public **static** class StringExtensions
    {
      public static bool IsValidEmail(**this** string input)
      { 
    

    良好实践:这两个更改告诉编译器将此方法视为扩展 string 类型的方法。

  2. Program.cs 中添加语句以使用需要检查有效电子邮件地址的 string 值的扩展方法,如下所示代码:

    WriteLine("{0} is a valid e-mail address: {1}",
      arg0: email1,
      arg1: email1.IsValidEmail());
    WriteLine("{0} is a valid e-mail address: {1}",
      arg0: email2,
      arg1: email2.IsValidEmail()); 
    

注意调用 IsValidEmail 方法的语法中的微妙简化。较旧、较长的语法仍然有效。

  1. IsValidEmail 扩展方法现在看起来就像 string 类型的所有实际实例方法一样,例如 IsNormalized,只是在方法图标上有一个小向下箭头,以表示这是一个扩展方法,如图 图 6.5 所示:

图 6.5:扩展方法出现在 IntelliSense 中,与实例方法并列

  1. 运行 PeopleApp 项目并查看结果,这将与之前相同。

良好实践:扩展方法不能替换或覆盖现有的实例方法。例如,你不能重新定义 Insert 方法。扩展方法将作为重载出现在 IntelliSense 中,但将优先调用具有相同名称和签名的实例方法。

虽然扩展方法可能看起来没有带来很大的好处,但在 第十一章使用 LINQ 查询和操作数据 中,你将看到一些非常强大的扩展方法用法。

方法链或流畅式

方法链,也称为流畅式,是一种编程技术,其中多个方法调用在一个语句中链接在一起。这是通过让每个方法返回相同对象的实例(通常是方法被调用的对象)来实现的,从而允许对同一对象进行一系列方法调用。这种风格在 C# 中很受欢迎,用于创建可读性和简洁的代码,尤其是在配置对象或构建复杂查询时。

例如,考虑这个 Person 类:

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public Person SetName(string name)
    {
        Name = name;
        return this;
    }
    public Person SetAge(int age)
    {
        Age = age;
        return this;
    }
} 

要构建 Person 实例,你可以链式调用方法,如下所示代码:

Person person = new()
  .SetName("John Doe")
  .SetAge(30); 

扩展方法允许在不修改其源代码或创建新的派生类型的情况下向现有类型添加新方法。当与链式调用结合使用时,这可以增强现有类的可读性和功能性。

假设您有一个Car类,如下面的代码所示:

public class Car
{
  public string Model { get; set; }
  public string Color { get; set; }
} 

您可以创建扩展方法来为Car类启用方法链式调用:

public static class CarExtensions
{
  public static Car SetModel(this Car car, string model)
  {
    car.Model = model;
    return car;
  }
  public static Car SetColor(this Car car, string color)
  {
    car.Color = color;
    return car;
  }
} 

现在,您可以使用这些扩展方法在Car对象上链式调用方法:

Car car = new()
  .SetModel("Tesla Model S")
  .SetColor("Red"); 

链式调用的好处包括:

  • 可读性:代码更易读,更接近自然语言

  • 简洁性:减少了重复代码的需求

  • 可配置性:允许灵活且易于配置对象

但链式调用的潜在缺点包括调试困难,因为长链可能更难调试,以及误导性的代码,如果不小心使用,可能会导致代码不够直观,尤其是在处理可空对象或复杂逻辑时。

良好实践:在链式调用时,使用清晰的方法名称以确保它们清楚地表明其目的,返回this或对象本身以保持链式调用,并仔细考虑如何在链式方法中处理潜在的错误或异常。

链式调用和流畅风格,尤其是当与 C#中的扩展方法结合使用时,可以提供干净、易读且易于维护的代码。

总结自定义类型选择

现在我们已经涵盖了面向对象编程和 C#中使您能够定义自己的类型的特性,让我们总结一下您所学的知识。

自定义类型的类别及其功能

自定义类型的类别及其功能总结在表 6.6中:

Type Instantiation Inheritance Equality Memory
class Yes Single Reference Heap
sealed class Yes None Reference Heap
abstract class No Single Reference Heap
recordrecord class Yes Single Value Heap
structrecord struct Yes None Value Stack
interface No Multiple Reference Heap

表 6.6:自定义类型的类别及其功能

最好通过从“正常”情况开始思考,然后找出其他情况中的差异来考虑这些不同之处。例如,一个“正常”的class可以通过new来实例化,它支持单一继承,它使用内存引用相等性,并且其状态存储在堆内存中。

现在,让我们突出显示更专业化的类类型的不同之处:

  • 一个sealed类不支持继承。

  • 一个abstract类不允许使用new进行实例化。

  • 一个record类使用值相等性而不是引用相等性。

我们可以对与其他“正常”类相比的其他类型做同样的事情:

  • 一个structrecord struct不支持继承,它使用值相等性而不是引用相等性,并且其状态存储在栈内存中。

  • interface不允许使用new关键字进行实例化,并支持多重继承。

可变性和记录

一个常见的误解是认为record类型是不可变的,这意味着它们的实例属性和字段值在初始化后不能被更改。然而,record类型的可变性实际上取决于record是如何定义的。让我们来探讨可变性:

  1. PacktLibrary项目中,添加一个名为Mutability.cs的新类文件。

  2. 修改Mutability.cs,如下面的代码所示,并注意以下内容:

    namespace Packt.Shared;
    // A mutable record class.
    public record class C1
    {
      public string? Name { get; set; }
    }
    // An immutable record class.
    public record class C2(string? Name);
    // A mutable record struct.
    public record struct S1
    {
      public string? Name { get; set; }
    }
    // Another mutable record struct.
    public record struct S2(string? Name);
    // An immutable record struct.
    public readonly record struct S3(string? Name); 
    
  3. PeopleApp项目中的Program.cs文件里,为每种类型创建一个实例,将初始Name值设置为Bob,然后修改Name属性为Bill。你会看到两种在初始化后不可变的类型,因为它们会给出编译器错误CS8852,如下面的代码所示:

    C1 c1 = new() { Name = "Bob" };
    c1.Name = "Bill";
    C2 c2 = new(Name: "Bob");
    c2.Name = "Bill"; // CS8852: Init-only property.
    S1 s1 = new() { Name = "Bob" };
    s1.Name = "Bill";
    S2 s2 = new(Name: "Bob");
    s2.Name = "Bill";
    S3 s3 = new(Name: "Bob");
    s3.Name = "Bill"; // CS8852: Init-only property. 
    
  4. 注意,记录C1是可变的,而C2是不可变的。注意S1S2是可变的,而S3是不可变的。

  5. 注释掉导致编译器错误的两个语句。

    微软在记录方面做出了一些有趣的设计选择。确保你记住在结合记录、类和结构体时的行为上的微妙差异,并使用每种类型的不同声明方式。

比较继承和实现

对于我来说,“继承”和“实现”这两个术语是不同的,在 C#和.NET 的早期,你可以严格地将它们应用于类和接口。例如,FileStream类继承自Stream类,而Int32 struct实现了IComparable接口。

“继承”意味着子类通过从其基类超类继承而获得一些“免费”的功能。而“实现”意味着一些不是继承的而是必须由子类提供的功能。这就是为什么我选择将本章标题为“实现接口和继承类”。

在 C# 8 之前,接口始终是纯契约。接口中没有你可以继承的功能。在那些日子里,你可以严格地使用“实现”这个词来表示你的类型必须实现的一组成员,而“继承”用于表示你的类型可以继承并可能重写的类。

在 C# 8 中,接口现在可以包含默认实现,这使得它们更像抽象类,并且对于具有默认实现的接口来说,使用“继承”这个词是有意义的。但许多其他.NET 开发者和我一样,对这个功能感到不舒服,因为它破坏了曾经干净的语言设计。默认接口还需要对底层.NET 运行时进行更改,因此它们不能与像.NET Standard 2.0 类库和.NET Framework 这样的遗留平台一起使用。

类也可以有抽象成员,例如,没有实现的方法或属性,就像接口可以有。当一个子类从此类继承时,它必须提供这些抽象成员的实现,并且基类必须用abstract关键字装饰,以防止使用new关键字实例化,因为它缺少一些功能。

回顾示例代码

让我们回顾一些示例代码,说明类型之间的一些重要区别。

注意以下内容:

  • 为了简化代码,我省略了privatepublic等访问修饰符。

  • 为了节省垂直空间,我没有使用常规的花括号格式,而是将所有方法实现放在一个语句中,例如:

    void M1() { /* implementation */ } 
    
  • 使用“I”作为接口的前缀是一种约定,而不是强制要求。使用这个前缀来突出接口是有用的,因为只有接口支持多重继承。

这里是代码:

// These are both "classic" interfaces in that they are pure contracts.
// They have no functionality, just the signatures of members that
// must be implemented.
interface IAlpha
{
  // A method that must be implemented in any type that implements
  // this interface.
  void M1();
}
interface IBeta
{
  void M2(); // Another method.
}
// A type (a struct in this case) implementing an interface.
// ": IAlpha" means Gamma promises to implement all members of IAlpha.
struct Gamma : IAlpha
{
  void M1() { /* implementation */ }
}
// A type (a class in this case) implementing two interfaces.
class Delta : IAlpha, IBeta
{
  void M1() { /* implementation */ }
  void M2() { /* implementation */ }
}
// A sub class inheriting from a base aka super class.
// ": Delta" means inherit all members from Delta.
class Episilon : Delta
{
  // This can be empty because this inherits M1 and M2 from Delta.
  // You could also add new members here.
}
// A class with one inheritable method and one abstract method
// that must be implemented in sub classes. A class with at least
// one abstract member must be decorated with the abstract keyword
// to prevent instantiation.
abstract class Zeta
{
  // An implemented method would be inherited.
  void M3() { /* implementation */ }
  // A method that must be implemented in any type that inherits
  // this abstract class.
  abstract void M4();
}
// A class inheriting the M3 method from Zeta but it must provide
// an implementation for M4.
class Eta : Zeta
{
  void M4() { /* implementation */ }
}
// In C# 8 and later, interfaces can have default implementations
// as well as members that must be implemented.
// Requires: .NET Standard 2.1, .NET Core 3.0 or later.
interface ITheta
{
  void M3() { /* implementation */ }
  void M4();
}
// A class inheriting the default implementation from an interface
// and must provide an implementation for M4.
class Iota : ITheta
{
  void M4() { /* implementation */ }
} 

练习和探索

通过回答一些问题、进行一些实际操作练习,以及更深入地研究本章主题来测试你的知识和理解。

练习 6.1 – 在线材料

在线材料可以是为我这本书编写的额外内容,也可以是 Microsoft 或第三方创建的内容的引用。

使用引用和值类型管理内存

阅读以下仅在网络上提供的部分,了解如何使用引用和值类型管理内存:

github.com/markjprice/cs13net9/blob/main/docs/ch06-memory.md

编写更好的代码

阅读以下仅在网络上提供的部分,了解如何使用分析器编写更好的代码:

github.com/markjprice/cs13net9/blob/main/docs/ch06-writing-better-code.md

练习 6.2 – 练习创建继承层次结构

按照以下步骤探索继承层次结构:

  1. Chapter06解决方案中添加一个名为Exercise_Inheritance的新控制台应用程序。

  2. 创建一个名为Shape的类,具有名为HeightWidthArea的属性。

  3. 添加三个从它派生的类——RectangleSquareCircle——以及你认为合适的任何附加成员,并正确地重写和实现Area属性。

  4. Program.cs中添加语句创建每个形状的一个实例,如下面的代码所示:

    Rectangle r = new(height: 3, width: 4.5);
    WriteLine($"Rectangle H: {r.Height}, W: {r.Width}, Area: {r.Area}");
    Square s = new(5);
    WriteLine($"Square H: {s.Height}, W: {s.Width}, Area: {s.Area}");
    Circle c = new(radius: 2.5);
    WriteLine($"Circle H: {c.Height}, W: {c.Width}, Area: {c.Area}"); 
    
  5. 运行控制台应用程序,确保结果看起来像以下输出:

    Rectangle H: 3, W: 4.5, Area: 13.5
    Square H: 5, W: 5, Area: 25
    Circle H: 5, W: 5, Area: 19.6349540849362 
    

练习 6.3 – 测试你的知识

回答以下问题:

  1. 什么是委托?

  2. 什么是事件?

  3. 基类和派生类是如何关联的,派生类如何访问基类?

  4. isas运算符的区别是什么?

  5. 哪个关键字用于防止类被派生或方法被进一步重写?

  6. 哪个关键字用于防止使用new关键字实例化类?

  7. 哪个关键字用于允许成员被重写?

  8. 析构函数和解构方法的区别是什么?

  9. 所有异常都应该有的构造函数的签名是什么?

  10. 扩展方法是什么,以及如何定义一个?

练习 6.4 – 探索主题

使用下一页上的链接了解本章涵盖的主题:

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-6---implementing-interfaces-and-inheriting-classes

摘要

在本章中,你学习了以下内容:

  • 运算符

  • 泛型类型

  • 委托和事件

  • 实现接口

  • 引用类型和值类型之间的内存使用差异

  • 与空值一起工作

  • 使用继承派生和转换类型

  • 基类和派生类,如何重写类型成员,以及使用多态

在下一章中,你将学习.NET 是如何打包和部署的,在随后的章节中,它为你提供的类型以实现常见功能,例如文件处理和数据库访问。

第七章:打包和分发.NET 类型

本章介绍了 C#关键字与.NET 类型之间的关系以及命名空间和程序集之间的关系。你将熟悉如何打包和发布你的.NET 应用程序和库以实现跨平台使用。

本章有几个在线部分,你将在章节末尾找到所有链接。

在仅在线的部分,反编译.NET 程序集,我们介绍了如何为了学习目的反编译.NET 程序集,以及为什么你无法阻止他人反编译你的代码。在另一个仅在线的部分,从.NET Framework 迁移到现代.NET,你可以学习如何在.NET 库中使用遗留的.NET Framework 库,以及如何将遗留的.NET Framework 代码库迁移到现代.NET。最后,在第三个仅在线的部分,创建源生成器,你将学习如何创建可以动态向你的项目添加源代码的源生成器——这是一个非常强大的功能。

本章涵盖了以下主题:

  • .NET 9 之路

  • 理解.NET 组件

  • 发布你的应用程序以进行部署

  • 原生提前编译

  • 打包你的库以进行 NuGet 分发

  • 使用预览功能

.NET 9 之路

这本书的这一部分介绍了.NET 提供的基类库BCL)API 中的功能,以及如何使用.NET Standard 跨所有不同的.NET 平台重用功能。

.NET Standard 兼容的框架

从.NET Core 2.0 开始,对至少.NET Standard 2.0 的支持很重要,因为它提供了许多在.NET Core 的第一个版本中缺失的 API。对于现代开发相关的 15 年积累的库和应用程序,.NET Framework 开发者现在可以迁移到.NET,并且它们可以在 macOS 和 Linux 变体以及 Windows 上跨平台运行。

.NET Standard 2.1 添加了大约 3,000 个新 API。其中一些 API 需要运行时更改,这可能会破坏向后兼容性。总结如下:

  • .NET Framework 4.8 仅实现了.NET Standard 2.0。

  • .NET Core 3.0、Xamarin、Mono 和 Unity 实现了.NET Standard 2.1。

.NET 5 移除了.NET Standard 的需求,因为所有项目类型现在都可以针对.NET 的单个版本。同样的情况也适用于.NET 6 及以后的版本。从.NET 5 开始的每个版本都与之前的版本向后兼容。这意味着一个针对.NET 5 的类库可以被任何类型的.NET 5 或更高版本的任何项目使用。现在,随着.NET 版本发布了对使用.NET MAUI 构建的移动和桌面应用程序的全面支持,对.NET Standard 的需求进一步减少。

由于你可能仍然需要为遗留的.NET Framework 项目或遗留的 Xamarin 移动应用程序创建类库,因此仍然需要创建.NET Standard 2.0 类库。

正式来说,即使它是一个相对较新的功能,您也必须使用 .NET Standard 2.0 类库来创建源生成器。

为了总结自 2016 年 .NET Core 首个版本以来 .NET 所取得的进展,我已将主要的 .NET Core 和现代 .NET 版本与以下列表中的等效 .NET Framework 版本进行了比较:

  • .NET Core 1.x:与 .NET Framework 4.6.1 相比,API 小得多,这是 2016 年 3 月的当前版本。

  • .NET Core 2.x:由于它们都实现了 .NET Standard 2.0,因此与现代 API 相比,API 与 .NET Framework 4.7.1 相等。

  • .NET Core 3.x:与 .NET Framework 相比,API 更大,因为 .NET Framework 4.8 没有实现 .NET Standard 2.1。

  • .NET 5:与 .NET Framework 4.8 相比,API 更大,特别是对于现代 API,性能有了显著提升。

  • .NET 6:继续改进性能和扩展 API,并在 2022 年 5 月添加了对 .NET MAUI 的可选支持。

  • .NET 7:与支持移动应用的支持最终统一,.NET MAUI 作为可选工作负载可用。本书不涵盖 .NET MAUI 开发。Packt 有多本专注于 .NET MAUI 的书籍,您可以通过搜索他们的网站找到它们。

  • .NET 8:继续改进平台,如果您需要长期支持,则应用于所有新的开发。

  • .NET 9:继续改进平台,特别是在性能方面,如果您对标准期限支持感到舒适,则应使用它。您可以在以下链接中查看每个预览版本的 .NET 9 发布说明:github.com/dotnet/core/discussions/9234

您可以在以下链接的 GitHub 仓库中阅读更多详细信息:github.com/markjprice/cs13net9/blob/main/docs/ch07-features.md

更多信息:比较两个 .NET 版本的一个有用方法是 .NET 网站的 API。例如,您可以在以下链接中比较 .NET 9 与 .NET 8 的新功能:apisof.net/catalog?diff=net8.0-vs-net9.0

检查您的 .NET SDKs 是否有更新

微软在 .NET 6 中引入了一个命令来检查您已安装的 .NET SDKs 和运行时的版本,如果任何需要更新,它会向您发出警告。例如,输入以下命令:

dotnet sdk check 

您将看到结果,包括可用更新的状态,如下所示的部分输出:

.NET SDKs:
Version            Status
-------------------------------------------------------
8.0.400            Patch 8.0.401 is available.
9.0.100            Up to date. 

良好实践:为了保持微软的支持,您必须确保您的 .NET SDKs 和 .NET 运行时始终更新到最新的补丁。

一个相关的第三方工具是 dotnet-outdated,它允许您快速报告 .NET 项目中任何过时的 NuGet 包。您可以在以下链接中了解更多信息:github.com/dotnet-outdated/dotnet-outdated

理解 .NET 组件

.NET 由几个部分组成,以下列表显示了这些部分:

  • 语言编译器:这些将您使用 C#、F# 和 Visual Basic 等语言编写的源代码转换为存储在组件中的中间语言IL)代码。从 C# 6 及以后版本开始,微软切换到了一个开源重写的编译器,称为 Roslyn,它也被 Visual Basic 使用。

  • 公共语言运行时CLR):此运行时加载组件,将存储在其中的 IL 代码编译成计算机 CPU 的本地代码指令,并在管理资源(如线程和内存)的环境中执行代码。

  • 基类库BCL):这些是使用 NuGet 打包和分发的预构建组件集合,用于在构建应用程序时执行常见任务。您可以使用它们快速构建任何您想要的东西,就像组合乐高™ 瓦片一样。

组件、NuGet 包和命名空间

组件是类型在文件系统中存储的地方。组件是部署代码的机制。例如,System.Data.dll 组件包含用于管理数据的类型。要使用其他组件中的类型,它们必须被引用。组件可以是静态的(预先创建)或动态的(在运行时生成)。动态组件是本书不会涉及的高级功能。组件可以编译为单个文件,作为 DLL(类库)或 EXE(控制台应用程序)。

组件以 NuGet 包的形式分发,这些是从公共在线源中可下载的文件,可以包含多个组件和其他资源。您还会听到关于 项目 SDK工作负载平台的内容,这些都是 NuGet 包的组合。

微软的 NuGet 源位于此处:www.nuget.org/

命名空间是什么?

命名空间是类型的地址。命名空间是一种机制,通过要求完整的地址来唯一标识一个类型,而不是仅仅一个短名称。在现实世界中,34 桉树街的鲍勃12 橡树巷的鲍勃是不同的。

在 .NET 中,System.Web.Mvc 命名空间中的 IActionFilter 接口与 System.Web.Http.Filters 命名空间中的 IActionFilter 接口不同。

依赖组件

如果一个组件被编译为类库并提供其他组件使用的类型,那么它具有文件扩展名 .dll动态链接库),并且不能独立执行。

同样,如果一个组件被编译为应用程序,那么它具有文件扩展名 .exe可执行文件)并且可以独立执行。在 .NET Core 3 之前,控制台应用程序被编译为 .dll 文件,并且必须通过 dotnet run 命令或宿主可执行文件来执行。

任何程序集都可以将一个或多个类库程序集作为依赖项引用,但不能有循环引用。因此,如果程序集 A 已经引用了程序集 B,则程序集 B 不能引用程序集 A。如果尝试添加会导致循环引用的依赖项引用,编译器将发出警告。循环引用通常是代码设计不佳的警告信号。如果你确定你需要循环引用,那么请使用接口来解决这个问题。

Microsoft .NET 项目 SDK

默认情况下,控制台应用程序依赖于 Microsoft .NET 项目 SDK。此平台包含成千上万的类型,这些类型几乎所有应用程序都需要,例如 System.Int32System.String 类型。

在使用 .NET 时,你需要在项目文件中引用应用程序需要的依赖程序集、NuGet 包和平台。

让我们探索程序集和命名空间之间的关系:

  1. 使用你偏好的代码编辑器创建一个新项目,如下列表所示:

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

    • 项目文件和文件夹:AssembliesAndNamespaces

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

  2. 打开 AssembliesAndNamespaces.csproj,注意它是一个典型的 .NET 应用程序项目文件,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
    </Project> 
    
  3. <PropertyGroup> 部分之后,添加一个新的 <ItemGroup> 部分以静态导入所有 C# 文件中的 System.Console,使用 .NET SDK 的隐式使用功能,如下所示:

    <ItemGroup Label="To simplify use of Console methods.">
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    

PropertyGroup 元素

PropertyGroup 元素用于定义控制构建过程的配置属性。这些属性可以是任何东西,从编译二进制的输出路径到版本信息。PropertyGroup 中的每个属性都定义为简单的名称-值对,如下所示:

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Packt.Shared</RootNamespace>
</PropertyGroup> 

在前面的示例中:

  • OutputType 指定输出类型,例如可执行文件 (Exe) 或库 (Library)。

  • TargetFramework 指定项目的目标框架,例如 net9.0

  • RootNamespace 设置项目的默认命名空间。

ItemGroup 元素

ItemGroup 元素用于定义包含在构建过程中的项目集合。项目可以是源文件、对其他程序集的引用、包引用和其他资源。

你经常想为不同的目的定义多个 ItemGroup 元素。它们将在构建时合并。例如,你可能有一个 ItemGroup 用于所有项目引用,还有一个单独的 ItemGroup 用于所有包引用。

ItemGroup 元素可以有一个 Label 属性,以便你可以记录每个部分应该用于什么,如下所示:

<ItemGroup Label="Additional files to include during build.">
  <Compile Include="Utils.cs" />
  <None Include="readme.txt" />
</ItemGroup>
<ItemGroup Label="NuGet packages for this project.">
  <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> 

标签和条件属性

PropertyGroupItemGroup 都可以有一个 Label 属性,可以用来记录元素的目的,以及一个 Condition 属性,可以用来定义它们何时应用。例如,一个典型的条件可能是构建 DEBUG 版本时,如下面的标记所示:

<PropertyGroup Condition="'$(CompileConfig)' == 'DEBUG'" >
  <Optimization>false</Optimization>
  <Obfuscate>false</Obfuscate>
  <OutputPath>$(OutputPath)\debug</OutputPath>
</PropertyGroup> 

警告! PropertyGroupItemGroup 元素都在根 Project 元素中的同一级别。不要在 PropertyGroup 元素内部嵌套 ItemGroup 元素,反之亦然!

程序集中的命名空间和类型

许多常见的 .NET 类型都包含在 System.Runtime.dll 程序集中。程序集和命名空间之间不一定是一对一映射。单个程序集可以包含多个命名空间,而一个命名空间也可以定义在多个程序集中。您可以在 表 7.1 中查看一些程序集及其为它们提供类型的命名空间之间的关系:

程序集 示例命名空间 示例类型
System.Runtime.dll System, System.Collections, System.Collections.Generic Int32, String, IEnumerable<T>
System.Console.dll System Console
System.Threading.dll System.Threading Interlocked, Monitor, Mutex
System.Xml.XDocument.dll System.Xml.Linq XDocument, XElement, XNode

表 7.1:程序集及其命名空间的示例

NuGet 包

.NET 被分割成一系列包,使用名为 NuGet 的微软支持的包管理技术进行分发。这些包中的每一个都代表一个同名的单个程序集。例如,System.Collections 包包含 System.Collections.dll 程序集。

以下是一些包的好处:

  • 包可以轻松地分发到公共源。

  • 包可以被重用。

  • 包可以按照自己的时间表进行分发。

  • 包可以在不依赖其他包的情况下独立测试。

  • 包可以通过包含为不同操作系统和 CPU 构建的同一程序集的多个版本来支持不同的操作系统和 CPU。

  • 包可以具有仅针对单个库的特定依赖项。

  • 应用程序更小,因为未引用的包不是分发的一部分。表 7.2 列出了一些重要的包及其重要类型:

重要类型
System.Runtime Object, String, Int32, Array
System.Collections List<T>, Dictionary<TKey, TValue>
System.Net.Http HttpClient, HttpResponseMessage
System.IO.FileSystem File, Directory
System.Reflection Assembly, TypeInfo, MethodInfo

表 7.2:一些重要的包及其重要类型

包源

包源是 NuGet 可以查找包的位置,例如 www.nuget.org、本地目录或其他包存储库。nuget.config 文件允许您指定、优先级排序和配置这些源,以及其他相关设置。

nuget.config 文件使用 XML 定义包源,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org"
         value="https://api.nuget.org/v3/index.json" />
    <add key="MyCustomSource"
         value="https://mycustomsource.com/nuget" />
  </packageSources>
</configuration> 

在前面的配置中:

  • <packageSources>:此部分列出了 NuGet 将用于查找包的所有源。

  • <add key="sourceName" value="sourceUrl"/>:每个 <add> 元素定义一个包源。

NuGet 可以使用位于不同目录中的多个 nuget.config 文件。设置将按特定顺序应用,最具体的设置具有优先权:

  1. 项目目录:项目目录中的 nuget.config 文件。

  2. 解决方案目录:解决方案目录中的 nuget.config 文件。

  3. 用户目录:用户配置文件目录中的 nuget.config 文件——例如,Windows 上的 %AppData%\nuget\nuget.config

  4. 系统级配置:系统级设置中的 nuget.config 文件——例如,Windows 上的 %ProgramFiles(x86)%\nuget\config

NuGet 合并这些配置,更具体的文件中的设置将覆盖不那么具体的文件中的设置。

如果找不到 nuget.config 文件,NuGet 将使用默认的包源,这通常是官方的 NuGet.org 仓库。这意味着默认情况下,Visual Studio、dotnetnuget.exe 等工具将查找 NuGet.org 的包,除非进行其他配置。

理解框架

框架和包之间存在双向关系。包定义了 API,而框架将包分组。没有包的框架不会定义任何 API。

.NET 包支持一组框架。例如,System.IO.FileSystem 包版本 4.3.0 支持以下框架:

  • .NET 标准版,版本 1.3 或更高版本

  • .NET 框架,版本 4.6 或更高版本

  • 六个 Mono 和 Xamarin 平台(例如,Xamarin.iOS)

更多信息:您可以在以下链接中阅读详细信息:www.nuget.org/packages/System.IO.FileSystem/#supportedframeworks-body-tab

导入命名空间以使用类型

让我们探索命名空间与程序集和类型之间的关系:

  1. AssembliesAndNamespaces 项目中,在 Program.cs 中删除现有的语句,然后输入以下代码:

    XDocument doc = new(); 
    

近期版本的代码编辑器通常会自动添加一个命名空间导入语句来修复您想要看到的问题。请删除您的代码编辑器为您编写的 using 语句。

  1. 构建项目并注意编译器错误信息,如下所示:

    CS0246 The type or namespace name 'XDocument' could not be found (are you missing a using directive or an assembly reference?) 
    

XDocument 类型没有被识别,因为我们没有告诉编译器该类型的命名空间。尽管此项目已经引用了包含该类型的程序集,但我们还需要在类型名称前加上其命名空间(例如,System.Xml.Linq.XDocument)或导入命名空间。

  1. XDocument 类名内点击。您的代码编辑器显示一个灯泡,表示它识别了该类型,并且可以自动为您修复问题。

  2. 点击灯泡图标,从菜单中选择 using System.Xml.Linq;

这将通过在文件顶部添加一个 using 语句来导入命名空间。一旦在代码文件的顶部导入了命名空间,该命名空间内的所有类型都可以通过仅输入其名称来在该代码文件中使用,无需通过在其名称前加上命名空间前缀来完全限定类型名称。

我喜欢在导入命名空间后添加一条注释,以提醒我为什么需要导入它,如下所示:

using System.Xml.Linq; // To use XDocument. 

如果你不对你的命名空间进行注释,你或其他开发者将不知道为什么需要导入它们,可能会删除它们,导致代码出错。相反,他们可能永远不会删除导入的命名空间“以防万一”它们可能需要,这可能会不必要地使代码变得杂乱。这就是为什么大多数现代代码编辑器都有移除未使用命名空间的功能。这种技术也在你学习的同时无意识地训练你,记住你需要导入哪个命名空间来使用特定的类型或扩展方法。

将 C# 关键字关联到 .NET 类型

我经常收到新 C# 程序员提出的一个常见问题是,“小写 s 的 string 和大写 S 的 String 之间有什么区别?”

简短的回答是:没有。长一点的回答是,所有代表类型如 stringint 的 C# 关键字都是类库程序集中的一个 .NET 类型的别名。

当你使用 string 关键字时,编译器将其识别为 System.String 类型。当你使用 int 类型时,编译器将其识别为 System.Int32 类型。

让我们通过一些代码来看看这个操作:

  1. Program.cs 文件中,声明两个变量来存储 string 值,一个使用小写 string,一个使用大写 String,如下所示:

    string s1 = "Hello";
    String s2 = "World";
    WriteLine($"{s1} {s2}"); 
    
  2. 运行 AssembliesAndNamespaces 项目,并注意 stringString 都可以正常工作,并且字面上意味着同一件事。

  3. AssembliesAndNamespaces.csproj 文件中,添加一个条目以防止 System 命名空间被全局导入,如下所示:

    <ItemGroup>
      <Using Remove="System" />
    </ItemGroup> 
    
  4. Program.cs 文件中,以及 错误列表问题 窗口中,注意编译器错误信息,如下所示:

    CS0246 The type or namespace name 'String' could not be found (are you missing a using directive or an assembly reference?) 
    
  5. Program.cs 文件顶部,使用 using 语句导入 System 命名空间,这将修复错误,如下所示:

    using System; // To use String. 
    

良好实践:当你有选择时,使用 C# 关键字而不是实际类型,因为关键字不需要导入命名空间。

将 C# 别名映射到 .NET 类型

表 7.3 展示了 18 个 C# 类型关键字及其实际的 .NET 类型:

关键字 .NET 类型 关键字 .NET 类型
string System.String char System.Char
sbyte System.SByte byte System.Byte
short System.Int16 ushort System.UInt16
int System.Int32 uint System.UInt32
long System.Int64 ulong System.UInt64
nint System.IntPtr nuint System.UIntPtr
float System.Single double System.Double
decimal System.Decimal bool System.Boolean
object System.Object dynamic System.Dynamic.DynamicObject

表 7.3:C# 类型关键字及其实际的 .NET 类型

其他 .NET 编程语言编译器也可以做到同样的事情。例如,Visual Basic .NET 语言有一个名为Integer的类型,它是System.Int32的别名。

理解原生大小整数

C# 9 引入了nintnuint关键字别名,用于原生大小整数,这意味着整数值的存储大小是平台特定的。它们在 32 位进程中存储 32 位整数,sizeof()返回 4 字节;它们在 64 位进程中存储 64 位整数,sizeof()返回 8 字节。这些别名代表内存中整数值的指针,这就是为什么它们的 .NET 名称是IntPtrUIntPtr。实际的存储类型将是System.Int32System.Int64,具体取决于进程。

在 64 位进程中,以下代码:

WriteLine($"Environment.Is64BitProcess = {Environment.Is64BitProcess}");
WriteLine($"int.MaxValue = {int.MaxValue:N0}");
WriteLine($"nint.MaxValue = {nint.MaxValue:N0}"); 

产生以下输出:

Environment.Is64BitProcess = True
int.MaxValue = 2,147,483,647
nint.MaxValue = 9,223,372,036,854,775,807 

揭示类型的定位

大多数代码编辑器都为 .NET 类型提供了内置文档。让我们首先确保你通过检查是否正确设置了相关选项,来确保你在 Visual Studio 中的预期体验,然后探索:

  1. 如果你正在使用 Visual Studio,请确保你已经禁用了源链接功能:

    1. 导航到工具 | 选项

    2. 在搜索框中输入导航到源

    3. 文本编辑器 | C#部分中选择高级

    4. 清除启用导航到源链接和嵌入源复选框,然后点击确定

  2. XDocument上右键单击并选择转到定义

  3. 导航到代码文件顶部,展开折叠区域,并注意程序集文件名是System.Xml.XDocument.dll,但类在System.Xml.Linq命名空间中,如下面的代码和图 7.1所示:

    #region Assembly System.Runtime, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    // C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\9.0.0\ref\net9.0\System.Runtime.dll
    #endregion 
    

图 7.1:包含 XDocument 类的程序集和命名空间

  1. 关闭XDocument [来自元数据]选项卡。

  2. stringString内部右键单击并选择转到定义

  3. 导航到代码文件顶部,展开折叠区域,并注意程序集文件名是System.Runtime.dll,但类在System命名空间中。

你的代码编辑器实际上在对你撒谎。如果你记得我们在第二章讲 C#中编写代码时,当我们揭示了 C# 词汇量的范围,我们发现System.Runtime.dll程序集包含零个类型。

System.Runtime.dll程序集包含的是类型前向器。这些是在程序集中看似存在但实际上在其他地方实现的特殊类型。在这种情况下,它们使用高度优化的代码在 .NET 运行时深处实现。

如果你需要将一个类型从其原始程序集移动到另一个程序集,你可能需要使用类型前向器。如果没有定义类型前向器,任何引用原始程序集的项目将无法在其中找到该类型,并且会抛出运行时异常。你可以在以下链接中了解更多关于这个虚构示例的信息:learn.microsoft.com/en-us/dotnet/standard/assembly/type-forwarding

使用.NET Standard 与遗留平台共享代码

在.NET Standard 之前,存在可移植类库PCLs)。使用 PCLs,你可以创建一个代码库,并明确指定你希望库支持的平台,例如 Xamarin、Silverlight 和 Windows 8。然后,你的库可以使用由指定平台支持的 API 交集。

微软意识到这是不可持续的,因此他们创建了.NET Standard——一个所有未来的.NET 平台都将支持的单一 API。有.NET Standard 的旧版本,但.NET Standard 2.0 是一次尝试统一所有重要的近期.NET 平台。.NET Standard 2.1 于 2019 年底发布,但只有.NET Core 3.0 和那年的 Xamarin 版本支持其新功能。在这本书的其余部分,我将使用.NET Standard 来指代.NET Standard 2.0。

.NET Standard 就像 HTML5 一样,它们都是平台应该支持的标准。就像谷歌的 Chrome 浏览器和微软的 Edge 浏览器实现了 HTML5 标准一样,.NET Core、.NET Framework 和 Xamarin 都实现了.NET Standard。如果你想创建一个可以在旧.NET 变体之间工作的类型库,你可以使用.NET Standard 最简单地做到这一点。

良好实践:由于.NET Standard 2.1 中的许多 API 新增都需要运行时更改,而.NET Framework 是微软的遗留平台,需要尽可能保持不变,因此.NET Framework 4.8 仍然停留在.NET Standard 2.0 上,而不是实现.NET Standard 2.1。如果你需要支持.NET Framework 客户,那么你应该在.NET Standard 2.0 上创建类库,即使它不是最新的,也不支持所有最新的语言和 BCL 新功能。

你选择的目标.NET Standard 版本取决于在最大化平台支持和可用功能之间的平衡。较旧的版本支持更多平台,但 API 集较小。较新的版本支持较少的平台,但 API 集较大。通常,你应该选择支持你所需所有 API 的最低版本。

理解不同 SDK 的类库默认设置

当使用dotnet SDK 工具创建类库时,了解默认将使用哪个目标框架可能很有用,如下表 7.4 所示:

SDK 新类库的默认目标框架
.NET Core 3.1 netstandard2.0
.NET 6 net6.0
.NET 7 net7.0
.NET 8 net8.0
.NET 9 net9.0

表 7.4:.NET SDK 及其针对新类库的默认目标框架

当然,一个类库默认针对特定的 .NET 版本并不意味着你不能在创建类库项目后更改它。使用默认模板创建类库项目后,你可以更改它。

你可以手动设置目标框架到一个支持需要引用该库的项目值的值,如 表 7.5 所示:

类库目标框架 可以被以下项目使用
netstandard2.0 .NET Framework 4.6.1 或更高版本,.NET Core 2 或更高版本,.NET 5 或更高版本,Mono 5.4 或更高版本,Xamarin.Android 8 或更高版本,以及 Xamarin.iOS 10.14 或更高版本
netstandard2.1 .NET Core 3 或更高版本,.NET 5 或更高版本,Mono 6.4 或更高版本,Xamarin.Android 10 或更高版本,以及 Xamarin.iOS 12.16 或更高版本
net6.0 .NET 6 或更高版本
net7.0 .NET 7 或更高版本
net8.0 .NET 8 或更高版本
net9.0 .NET 9 或更高版本

表 7.5:类库目标框架及其可使用的项目

良好实践:始终检查类库的目标框架,并在必要时手动更改它到一个更合适的目标。有意识地决定它应该是什么,而不是接受默认值。

创建 .NET Standard 类库

我们将使用 .NET Standard 2.0 创建一个类库,以便它可以在所有重要的 .NET 遗留平台上使用,并在 Windows、macOS 和 Linux 操作系统上跨平台,同时还可以访问广泛的 .NET API:

  1. 使用你喜欢的代码编辑器添加一个新的 类库 / classlib 项目,命名为 SharedLibrary,针对 Chapter07 解决方案的目标为 .NET Standard 2.0:

    • 如果你使用 Visual Studio,当提示选择 目标框架 时,选择 .NET Standard 2.0,然后配置解决方案的启动项目为当前选择。

    • 如果你使用 VS Code,包括一个目标为 .NET Standard 2.0 的开关,如下所示命令:

      dotnet new classlib -f netstandard2.0 
      

良好实践:如果你需要创建使用 .NET 9 的新功能的类型,以及仅使用 .NET Standard 2.0 功能的类型,那么你可以创建两个独立的类库:一个针对 .NET Standard 2.0,另一个针对 .NET 0。

  1. 构建 SharedLibrary 项目。

手动创建两个类库的替代方法是创建一个支持 多目标 的类库。如果你希望我在下一版中添加关于多目标的内容,请告知我。你可以在这里了解多目标:learn.microsoft.com/en-us/dotnet/standard/library-guidance/cross-platform-targeting#multi-targeting

控制 .NET SDK

默认情况下,执行 dotnet 命令会使用已安装的最高版本的 .NET SDK。有时你可能想控制使用哪个 SDK。

例如,一旦 .NET 10 在 2025 年 2 月开始预览版,或者最终版本在 2025 年 11 月发布,您可能会安装它。但您可能希望您的体验与书中步骤相匹配,这些步骤使用 .NET 9 SDK。但一旦您安装了 .NET 10 SDK,它将默认使用。

您可以通过使用包含要使用版本的 global.json 文件来控制默认使用的 .NET SDK,dotnet 命令会依次搜索当前文件夹及其父文件夹,查找 global.json 文件,以确定是否应使用不同的 .NET SDK 版本。

您不需要完成以下步骤,但如果您想尝试并且尚未安装 .NET 8 SDK,则可以从以下链接安装它:

dotnet.microsoft.com/download/dotnet/8.0

  1. Chapter07 文件夹中创建一个名为 ControlSDK 的子目录/文件夹。

  2. 在 Windows 上,启动 命令提示符Windows 终端。在 macOS 上,启动 终端。如果您正在使用 VS Code,则可以使用集成终端。

  3. ControlSDK 文件夹中,在命令提示符或终端中输入命令以列出已安装的 .NET SDK,如下所示:

    dotnet --list-sdks 
    
  4. 注意以下输出中突出显示的最新安装的 .NET 8 SDK 的结果和版本号:

    **8.0.400** [C:\Program Files\dotnet\sdk]
    **9.0.100** [C:\Program Files\dotnet\sdk] 
    
  5. 创建一个 global.json 文件,强制使用您已安装的最新 .NET 8 SDK(可能晚于我的版本),如下所示:

    dotnet new globaljson --sdk-version 8.0.400 
    
  6. 注意以下输出中的结果:

    The template "global.json file" was created successfully. 
    
  7. 使用您首选的代码编辑器打开 global.json 文件并查看其内容,如下所示:

    {
      "sdk": {
        "version": "8.0.400"
      }
    } 
    

例如,要使用 VS Code 打开它,请输入命令 code global.json

  1. ControlSDK 文件夹中,在命令提示符或终端中输入命令以创建类库项目,如下所示:

    dotnet new classlib 
    
  2. 如果您没有安装 .NET 8 SDK,则将看到错误,如下所示:

    Could not execute because the application was not found or a compatible .NET SDK is not installed. 
    
  3. 如果您已安装 .NET 8 SDK,则将创建一个默认针对 .NET 8 的类库项目,如下所示:

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

混合 SDK 和框架目标

许多组织决定针对 .NET 的长期支持版本,以从微软获得长达三年的支持。这样做并不意味着您会失去在 .NET 运行时生命周期内 C# 语言改进的好处。

您可以在安装和使用未来的 C# 编译器的同时轻松地继续针对 .NET 9 运行时,如图 7.2 所示,以下列表中也有说明:

  1. 2024 年 11 月:安装 .NET SDK 9.0.100,并使用它来构建默认使用 C# 13 编译器的针对 .NET 9 的项目。每月,更新开发计算机上的 .NET 9 SDK 补丁,并更新任何部署计算机上的 .NET 9 运行时补丁。

  2. 2025 年 2 月:可选地安装 .NET SDK 10 预览版 1,以探索新的 C# 14 语言和 .NET 10 库功能。请注意,当针对 .NET 9 时,您将无法使用新的库功能。预览版每年 2 月至 10 月每月发布一次。请阅读每月公告,了解以下链接中该预览版的新功能:github.com/dotnet/Announcements/issues

  3. 2025 年 11 月:安装 .NET SDK 10.0.100,并使用它来构建继续针对 .NET 9 的项目,并使用 C# 14 编译器来利用其新功能。您将使用完全受支持的 SDK 和完全受支持的运行时。

  4. 2026 年 2 月:可选地安装 .NET 11 预览版,以探索新的 C# 15 语言和 .NET 11 库功能。当您准备好迁移时,开始规划任何新的库和 ASP.NET Core 功能,这些功能可以应用于您的 .NET 9 和 .NET 11 项目。

  5. 2026 年 11 月:安装 .NET 11.0.100 SDK,并使用它来构建针对 .NET 10 的项目,并使用 C# 15 编译器。

图片

图 7.2:针对长期支持的 .NET 8 同时使用最新的 C# 编译器

当决定安装 .NET SDK 时,请记住,默认情况下使用最新版本来构建任何 .NET 项目。一旦您安装了 .NET 9 SDK 预览版,它将默认用于所有项目,除非您强制使用较旧且完全受支持的 SDK 版本,如 8.0.100 或更高版本的补丁。

为部署发布您的代码

如果您写了一本小说,并且希望其他人阅读它,您必须将其出版。

大多数开发者为其他开发者在他们自己的项目中使用或为用户作为应用程序运行而编写代码。为此,您必须将您的代码发布为打包的类库或可执行应用程序。

有三种方式可以发布和部署 .NET 应用程序。它们是:

  • 框架依赖部署FDD

  • 框架依赖可执行文件FDE

  • 自包含

如果您选择部署您的应用程序及其包依赖项,但不是 .NET 本身,那么您依赖于 .NET 已经在目标计算机上。这对于部署到服务器的 Web 应用程序来说效果很好,因为 .NET 和许多其他 Web 应用程序可能已经存在于服务器上。

FDD 表示您部署一个必须由 dotnet 命令行工具执行的 DLL。FDE 表示您部署一个可以直接从命令行运行的 EXE。两者都需要在系统上安装适当的 .NET 运行时版本。

有时,您可能希望能够给某人一个包含为他们的操作系统构建的应用程序,并知道它可以在他们的计算机上执行。您将想要执行自包含部署。虽然部署文件的尺寸会更大,但您会知道它将工作。

创建用于发布的控制台应用程序

让我们探索如何发布一个控制台应用程序:

  1. 使用您喜欢的代码编辑器将一个新的控制台应用程序/ console项目命名为DotNetEverywhere添加到Chapter07解决方案中。确保您针对.NET 9。

  2. 修改项目文件,将System.Console类静态导入到所有 C#文件中。

  3. Program.cs中删除现有的语句,然后添加一条输出消息的语句,说明控制台应用程序可以在任何地方运行,以及一些关于操作系统的信息,如下所示代码:

    WriteLine("I can run everywhere!");
    WriteLine($"OS Version is {Environment.OSVersion}.");
    if (OperatingSystem.IsMacOS())
    {
      WriteLine("I am macOS.");
    }
    else if (OperatingSystem.IsWindowsVersionAtLeast(
      major: 10, build: 22000))
    {
      WriteLine("I am Windows 11.");
    }
    else if (OperatingSystem.IsWindowsVersionAtLeast(major: 10))
    {
      WriteLine("I am Windows 10.");
    }
    else
    {
      WriteLine("I am some other mysterious OS.");
    }
    WriteLine("Press any key to stop me.");
    ReadKey(intercept: true); // Do not output the key that was pressed. 
    
  4. 运行DotNetEverywhere项目,并注意在 Windows 11 上运行的结果,如下所示输出:

    I can run everywhere!
    OS Version is Microsoft Windows NT 10.0.22000.0.
    I am Windows 11.
    Press any key to stop me. 
    
  5. DotNetEverywhere.csproj中,在<PropertyGroup>元素内添加运行时标识符RIDs),以针对三个操作系统进行目标定位,如下所示高亮显示的标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<RuntimeIdentifiers>**
     **win-x64;osx-arm64;linux-x64**
     **</RuntimeIdentifiers>**
      </PropertyGroup>
    </Project> 
    
    • win-x64 RID 值表示 x64 兼容 CPU 上的 Windows。使用win-arm64 RID 值将应用程序部署到 Windows ARM 设备。

    • osx-arm64 RID 值表示 Apple Silicon 上的 macOS。

    • linux-x64 RID 值表示大多数桌面 Linux 发行版,如 Ubuntu、CentOS、Debian 或 Fedora 在 x64 兼容 CPU 上。对于 Raspbian 或 Raspberry Pi OS 32 位,请使用linux-arm。对于运行 Ubuntu 64 位的 Raspberry Pi,请使用linux-arm64

您可以在以下链接中检查最新的允许的 RID 值:learn.microsoft.com/en-us/dotnet/core/rid-catalog#known-rids

警告!您可以使用两个元素来指定运行时标识符。如果您只需要指定一个,请使用<RuntimeIdentifier>。如果您需要指定多个,如前例所示,请使用<RuntimeIdentifiers>。如果您使用错误的一个,那么编译器将给出错误,并且仅凭一个字符的差异可能难以理解原因!

理解 dotnet 命令

当您安装.NET SDK 时,它包括一个名为dotnet命令行界面CLI)。

.NET CLI 有在当前文件夹中创建新项目的命令,使用模板:

  1. 在 Windows 上,启动命令提示符Windows 终端。在 macOS 上,启动终端。如果您更喜欢使用 Visual Studio 或 VS Code,则可以使用集成终端。

  2. 输入dotnet new list(或对于较旧的 SDK,使用dotnet new -ldotnet new --list)命令以列出您当前安装的模板,其中最常见的模板如表 7.6所示:

模板名称 简称 语言
.NET MAUI App maui C#
.NET MAUI Blazor App maui-blazor C#
ASP.NET Core Empty web C#和 F#
ASP.NET Core gRPC Service grpc C#
ASP.NET Core Web API webapi C#和 F#
ASP.NET Core Web API (native AOT) webapiaot C#
ASP.NET Core Web App (Model-View-Controller) mvc C#和 F#
Blazor Web App blazor C#
Class Library classlib C#、F#和 VB
Console App console C#、F#和 VB
EditorConfig File editorconfig
全局.json 文件 globaljson
解决方案文件 sln
xUnit 测试项目 xunit

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

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

获取有关 .NET 及其环境的详细信息

查看当前安装的 .NET SDK 和运行时,以及有关操作系统的信息很有用,如下所示命令:

dotnet --info 

注意以下部分输出中的结果:

.NET SDK:
 Version:   9.0.100
 Commit:    81a714c6d3
 Workload version:  9.0.100-manifests.bbb3781c
 MSBuild version:   17.12.0
Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.26100
 OS Platform: Windows
 RID:         win-arm64
 Base Path:   C:\Program Files\dotnet\sdk\9.0.100\
.NET workloads installed:
Configured to use loose manifests when installing new manifests.
 [aspire]
   Installation Source: VS 17.12.35309.182, VS 17.11.35303.130
   Manifest Version:    9.0.0/0.0.100
   Manifest Path:       C:\Program Files\dotnet\sdk-manifests\9.0.100\microsoft.net.sdk.aspire\9.0.0\WorkloadManifest.json
   Install Type:        FileBased
Host (useful for support):
  Version: 9.0.0
  Architecture: arm64
  Commit:  static
.NET SDKs installed:
  8.0.400 [C:\Program Files\dotnet\sdk]
  9.0.100 [C:\Program Files\dotnet\sdk]
.NET runtimes installed:
  Microsoft.AspNetCore.App 8.0.8 [...\dotnet\shared\Microsoft.AspNetCore.App]
... 

使用 dotnet CLI 管理项目

.NET CLI 有以下命令在当前文件夹中的项目上工作,使您能够管理项目:

  • dotnet help: 这显示命令行帮助。

  • dotnet new: 这创建一个新的 .NET 项目或文件。

  • dotnet tool: 这安装或管理扩展 .NET 体验的工具。

  • dotnet workload: 这管理可选的工作负载,如 .NET MAUI。

  • dotnet restore: 这为项目下载依赖项。

  • dotnet build: 这将构建,即编译,一个 .NET 项目。.NET 8 中引入的新开关是 --tl(表示终端记录器),它提供了一种现代的输出方式。例如,它提供了有关构建正在做什么的实时信息。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/core/tools/dotnet-build#options

  • dotnet build-server: 这与由构建启动的服务器交互。

  • dotnet msbuild: 这将运行 MS Build 引擎命令。

  • dotnet clean: 这从构建中删除临时输出。

  • dotnet test: 这将构建并运行项目的单元测试。

  • dotnet run: 这将构建并运行项目。

  • dotnet pack: 这为项目创建一个 NuGet 包。

  • dotnet publish: 这将构建并发布项目,可以是带依赖项的,也可以是自包含的应用程序。在 .NET 7 及更早版本中,默认发布 Debug 配置。在 .NET 8 及更高版本中,现在默认发布 Release 配置。

  • dotnet add: 这将包或类库的引用添加到项目中。

  • dotnet remove: 这将从项目中移除对包或类库的引用。

  • dotnet list: 这列出项目的包或类库引用。

  • dotnet package search: 这允许您搜索一个或多个包源,以查找与搜索词匹配的包。命令格式为 dotnet package search [search term] [options]。您可以在以下链接中了解更多信息:devblogs.microsoft.com/nuget/announcing-nuget-6-9/#support-for-dotnet-search-command

发布自包含应用程序

现在您已经看到了一些示例 dotnet 工具命令,我们可以发布我们的跨平台控制台应用程序:

  1. 在命令提示符或终端中,确保您位于 DotNetEverywhere 文件夹中。

  2. 输入命令以构建和发布 Windows 的控制台应用程序的单文件发布版本,如下所示:

    dotnet publish -c Release -r win-x64 --self-contained 
    

    发布过程可能需要一段时间,请耐心等待。

  3. 注意,构建引擎会恢复任何需要的包,将项目源代码编译成程序集 DLL,并创建一个 publish 文件夹,如下所示:

    MSBuild version 17.11.0+14c24b2d3 for .NET
      Determining projects to restore...
      All projects are up-to-date for restore.
      DotNetEverywhere -> C:\cs13net9\Chapter07\DotNetEverywhere\bin\Release\net9.0\win-x64\DotNetEverywhere.dll
      DotNetEverywhere -> C:\cs13net9\Chapter07\DotNetEverywhere\bin\Release\net9.0\win-x64\publish\ 
    
  4. 输入以下命令以构建和发布 macOS 和 Linux 变体的发布版本,如下所示:

    dotnet publish -c Release -r osx-arm64 --self-contained
    dotnet publish -c Release -r linux-x64 --self-contained 
    

良好实践:您可以使用 PowerShell 等脚本语言自动化这些命令,并在任何操作系统上执行脚本文件,使用跨平台的 PowerShell Core。我已经为您在以下链接中完成了这项工作:github.com/markjprice/cs13net9/tree/main/scripts/publish-scripts

  1. 打开 Windows 文件资源管理器或 macOS 查找器窗口,导航到 DotNetEverywhere\bin\Release\net9.0,并注意五个操作系统对应的输出文件夹。

  2. win-x64 文件夹中,打开 publish 文件夹,并注意所有支持程序集,如 Microsoft.CSharp.dll

  3. 选择 DotNetEverywhere 可执行文件,并注意它的大小为 154 KB,如图 7.3 所示:

图 7.3:Windows 64 位 DotNetEverywhere 可执行文件

  1. 如果您使用的是具有 x64 兼容芯片的 Windows,则双击程序以执行,并注意结果,如下所示:

    I can run everywhere!
    OS Version is Microsoft Windows NT 10.0.26100.0.
    I am Windows 11.
    Press any key to stop me. 
    

我在我的 Surface Laptop 7 上执行了程序,因此针对并构建了 win-arm64 架构。前面的输出显示了在我的计算机上的结果。

  1. 按任意键关闭控制台应用程序及其窗口。

  2. 注意,publish 文件夹及其所有文件的总大小为 68.3 MB。

  3. osx-arm64 文件夹中,选择 publish 文件夹,注意所有支持程序集,然后选择 DotNetEverywhere 可执行文件。请注意,可执行文件大小为 125 KB,而 publish 文件夹大约为 73.9 MB。在 macOS 上发布的应用程序没有 .exe 文件扩展名,因此文件名不会有扩展名。

如果您将任何这些 publish 文件夹复制到适当的 操作系统OS),控制台应用程序将运行;这是因为它是一个自包含、可部署的 .NET 应用程序。例如,在 macOS 上就是这样:

I can run everywhere!
OS Version is Unix 13.5.2
I am macOS.
Press any key to stop me. 

此示例使用的是控制台应用程序,但您同样可以轻松创建 ASP.NET Core 网站、网络服务或 Windows Forms 或 WPF 应用程序。当然,您只能将 Windows 桌面应用程序部署到 Windows 计算机,而不是 Linux 或 macOS。

发布单文件应用程序

如果你知道你想要运行你的应用的计算机上已经安装了 .NET,那么你可以在发布你的应用时使用额外的标志来说明它不需要自包含,并且你希望将其发布为单个文件(如果可能),如下所示(必须在单行中输入):

dotnet publish -r win-x64 -c Release --no-self-contained
/p:PublishSingleFile=true 

这将生成两个文件:DotNetEverywhere.exeDotNetEverywhere.pdb.exe 文件是可执行文件。.pdb 文件是一个 程序调试数据库 文件,用于存储调试信息。

如果你希望 .pdb 文件嵌入到 .exe 文件中(例如,以确保它与程序集一起部署),那么请将 <DebugType> 元素添加到你的 .csproj 文件中的 <PropertyGroup> 元素,并将其设置为 embedded,如下所示(以下标记已高亮显示):

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RuntimeIdentifiers>
win-x64;osx-arm64;linux-x64
</RuntimeIdentifiers>
**<****DebugType****>****embedded****</****DebugType****>**
</PropertyGroup> 

如果你不能假设计算机上已经安装了 .NET,那么尽管 Linux 也只生成两个文件,但对于 Windows,你还需要期待以下额外的文件:coreclr.dllclrjit.dllclrcompression.dllmscordaccore.dll

让我们以 Windows 为例:

  1. 在命令提示符或终端中,在 DotNetEverywhere 文件夹中,输入构建 Windows 控制台应用自包含发布版本的命令,如下所示:

    dotnet publish -c Release -r win-x64 --self-contained /p:PublishSingleFile=true 
    
  2. 导航到 DotNetEverywhere\bin\Release\net9.0\win-x64\publish 文件夹,并选择 DotNetEverywhere 可执行文件。请注意,可执行文件现在为 62.6 MB,还有一个 11 KB 的 .pdb 文件。这些文件在你的系统中的大小可能会有所不同。

使用应用裁剪减小应用大小

将 .NET 应用作为自包含应用部署的一个问题是 .NET 库占用大量空间。最大的需求之一是减少 Blazor WebAssembly 组件的大小,因为所有 .NET 库都需要下载到浏览器。

幸运的是,你可以通过不将未使用的程序集打包到你的部署中来减小这个大小。从 .NET Core 3 开始引入的应用裁剪系统可以识别你的代码需要的程序集,并删除那些不需要的程序集。这被称为 copyused 裁剪模式。

在 .NET 5 中,裁剪进一步扩展,通过删除未使用的单个类型,甚至成员(例如,方法),如果它们在程序集中未被使用。例如,对于一个 Hello World 控制台应用,System.Console.dll 程序集从 61.5 KB 裁剪到 31.5 KB。这被称为 link 裁剪模式,但默认情况下并未启用。

在 .NET 6 中,微软向他们的库添加了注释,以指示它们如何安全地进行裁剪,因此类型和成员的裁剪被设置为默认选项。

在 .NET 7 中,微软将 link 重命名为 full,将 copyused 重命名为 partial

但问题是裁剪如何准确地识别未使用的程序集、类型和成员。如果你的代码是动态的,可能使用了反射,那么它可能无法正确工作,因此微软也允许手动控制。

有两种方式来启用类型级别和成员级别,也就是所谓的full级别的修剪。由于这种修剪级别在.NET 6 或更高版本中是默认的,我们只需要启用修剪,而不需要设置修剪级别或模式。

第一种方式是在项目文件中添加一个元素,如下所示:

<PublishTrimmed>true</PublishTrimmed> <!--Enable trimming.--> 

第二种方式是在发布时添加一个标志,如下所示高亮显示的命令:

dotnet publish ... **-p:PublishTrimmed=True** 

有两种方式来启用程序集级别,也就是所谓的partial级别的修剪。

第一种方式是在项目文件中添加两个元素,如下所示:

<PublishTrimmed>true</PublishTrimmed> <!--Enable trimming.-->
<TrimMode>partial</TrimMode> <!--Set assembly-level trimming.--> 

第二种方式是在发布时添加两个标志,如下所示高亮显示的命令:

dotnet publish ... **-p:PublishTrimmed=True -p:TrimMode=partial** 

控制构建工件创建的位置

传统上,每个项目都有自己的binobj子文件夹,在构建过程中会创建临时文件。当你发布时,文件会创建在bin文件夹中。

你可能希望将这些临时文件和文件夹放在其他地方。随着.NET 8 的引入,现在可以控制构建工件创建的位置。让我们看看如何:

  1. Chapter07文件夹的命令提示符或终端中,输入以下命令:

    dotnet new buildprops --use-artifacts 
    
  2. 注意成功消息,如下所示输出:

    The template "MSBuild Directory.Build.props file" was created successfully. 
    

    我们可以在cs13net9文件夹中创建此文件,然后它将影响所有章节的所有项目。

  3. Chapter07文件夹中,打开Directory.Build.props文件,如下所示:

    <Project>
    <!-- See https://aka.ms/dotnet/msbuild/customize for more details on
    customizing your build -->
    <PropertyGroup>
        <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
    </PropertyGroup>
    </Project> 
    
  4. 构建任何项目或整个解决方案。

  5. Chapter07文件夹中,请注意现在有一个artifacts文件夹,其中包含任何最近构建项目的子文件夹。

  6. 你可以删除此文件,或者将其重命名为类似Directory.Build.props.disabled的名称,这样它就不会通过在你不期望的文件夹中构建这些工件来影响本章的其余部分。这是可选的,但建议这样做。

警告!如果你保持此构建配置启用,请记住你的构建工件现在将创建在这个新的文件夹结构中。

原生提前编译

原生 AOT 生成的程序集是:

  • 自包含,意味着它们可以在没有安装.NET 运行时的系统上运行。

  • 提前编译(AOT)为原生代码,这意味着启动时间更快,内存占用可能更小。

原生 AOT 在编写时将 IL 代码编译为原生代码,而不是在运行时使用即时(JIT)编译器。但原生 AOT 程序集必须针对特定的运行时环境,如 Windows x64 或 Linux Arm。

由于原生 AOT 发生在发布时,你应该记住,当你在你代码编辑器中调试和实时工作在项目上时,它仍然使用运行时 JIT 编译器,即使你在项目中启用了 AOT!

然而,一些与原生 AOT 不兼容的功能将被禁用或抛出异常,并启用源分析器来显示有关潜在代码不兼容性的警告。

本地 AOT 的限制

本地 AOT 对启用它的项目有一些限制,以下列出了一些:

  • 不允许动态加载程序集。

  • 不允许运行时代码生成,例如使用 System.Reflection.Emit

  • 这需要裁剪,正如我们在上一节中提到的,它有其自身的限制。

  • 项目必须是自包含的,因此它们必须嵌入它们所调用的任何库,这增加了它们的大小。

虽然您自己的程序集可能没有使用上面列出的功能,但 .NET 本身的主要部分确实使用了。例如,ASP.NET Core MVC(包括使用控制器的 Web API 服务)和 EF Core 都会进行运行时代码生成以实现其功能。

.NET 团队正在努力使尽可能多的 .NET 与本地 AOT 兼容,并且尽快实现。但如果你使用 Minimal APIs,.NET 9 只包括对 ASP.NET Core 的基本支持,并且没有对 EF Core 的支持。

我的猜测是,.NET 10 将包括对 ASP.NET Core MVC 和 EF Core 部分的支持,但可能需要到 .NET 11 或 .NET 12 我们才能自信地使用大多数 .NET,并且知道我们可以使用本地 AOT 构建我们的程序集以获得这些好处。

本地 AOT 发布过程包括代码分析器来警告您是否使用了不受支持的功能,但并非所有包都已注释以与这些分析器良好协作。

最常用的注释来指示类型或成员不支持 AOT 是 [RequiresDynamicCode] 属性。

更多信息:您可以在以下链接中了解更多关于 AOT 警告的信息:learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/fixing-warnings

反射和本地 AOT

反射常用于运行时检查类型 元数据、成员的动态调用和代码生成。

本地 AOT 允许一些反射功能,但在本地 AOT 编译过程中进行的裁剪无法静态确定类型是否有可能仅通过反射访问的成员。这些成员将被 AOT 移除,这会导致运行时异常。

良好实践:开发者必须使用 [DynamicallyAccessedMembers] 注释来标记仅通过反射动态访问的成员,因此应保留未裁剪。

本地 AOT 的要求

对于不同的操作系统,有一些额外的要求:

  • 在 Windows 上,您必须安装包含所有默认组件的 Visual Studio 桌面开发与 C++ 工作负载。

  • 在 Linux 上,您必须安装 .NET 运行时所依赖的库的编译器工具链和开发包。例如,对于 Ubuntu 18.04 或更高版本:sudo apt-get install clang zlib1g-dev

  • 警告! 不支持跨平台原生 AOT 发布。这意味着您必须在您将要部署的操作系统上运行发布操作。例如,您不能在 Linux 上发布用于在 Windows 上运行的本地 AOT 项目,反之亦然。

启用项目的原生 AOT

要在项目中启用原生 AOT 发布,请将 <PublishAot> 元素添加到项目文件中,如下所示标记突出显示:

 <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
 **<PublishAot>****true****</PublishAot>** 

构建原生 AOT 项目

现在,让我们通过使用控制台应用程序的新 AOT 选项来查看一个实际示例:

  1. 在名为 Chapter07 的解决方案中,添加一个与以下列表定义的本地 AOT 兼容的控制台应用程序项目:

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

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

    • 项目文件和文件夹:AotConsole

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

    • 启用原生 AOT 发布:已选择

如果您的代码编辑器尚未提供 AOT 选项,请创建一个传统的控制台应用程序,然后您将需要手动启用 AOT,如步骤 2 所示,或使用 dotnet CLI。

  1. 在项目文件中,请注意已启用原生 AOT 发布以及不变全球化,如下所示标记突出显示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<PublishAot>****true****</PublishAot>**
     **<InvariantGlobalization>****true****</InvariantGlobalization>**
      </PropertyGroup>
    </Project> 
    

在 .NET 8 的 控制台应用程序 项目模板中,显式设置不变全球化为 true 是新的。它旨在使控制台应用程序不受文化限制,以便可以在世界任何地方部署并具有相同的行为。如果您将此属性设置为 false,或者如果元素缺失,则控制台应用程序将默认为当前托管计算机的文化。您可以在以下链接中了解更多关于不变全球化模式的信息:github.com/dotnet/runtime/blob/main/docs/design/features/globalization-invariant-mode.md

  1. 修改项目文件以在所有 C# 文件中静态导入 System.Console 类。

  2. Program.cs 中,删除任何现有语句,然后添加语句以输出当前文化和操作系统版本,如下所示代码:

    using System.Globalization; // To use CultureInfo.
    WriteLine("This is an ahead-of-time (AOT) compiled console app.");
    WriteLine("Current culture: {0}", CultureInfo.CurrentCulture.DisplayName);
    WriteLine("OS version: {0}", Environment.OSVersion);
    Write("Press any key to exit.");
    ReadKey(intercept: true); // Do not output the key that was pressed. 
    
  3. 运行控制台应用程序项目,并注意文化是不变的,如下所示输出:

    This is an ahead-of-time (AOT) compiled console app.
    Current culture: Invariant Language (Invariant Country)
    OS version: Microsoft Windows NT 10.0.22621.0 
    

    警告! 实际上,控制台应用程序尚未进行 AOT 编译;它仍然是当前 JIT 编译的,因为我们尚未发布它。

发布原生 AOT 项目

在开发期间代码未裁剪且 JIT 编译时功能正确的控制台应用程序,在您使用原生 AOT 发布后仍可能失败,因为代码此时被裁剪并 JIT 编译,因此它是不同的代码,具有不同的行为。因此,在假设项目将正常工作之前,您应该执行发布操作。

如果在发布时您的项目没有产生任何 AOT 警告,那么您可以有信心在发布后 AOT 将正常工作。

让我们发布我们的控制台应用程序:

  1. AotConsole项目的命令提示符或终端中,使用原生 AOT 发布控制台应用程序,如下面的命令所示:

    dotnet publish 
    
  2. 注意关于生成原生代码的消息,如下面的输出所示:

    MSBuild version 17.8.0+4ce2ff1f8 for .NET
      Determining projects to restore...
      Restored C:\cs13net9\Chapter07\AotConsole\AotConsole.csproj (in 173 ms).
      AotConsole -> C:\cs13net9\Chapter07\AotConsole\bin\Release\net9.0\win-x64\AotConsole.dll
      Generating native code
      AotConsole -> C:\cs13net9\Chapter07\AotConsole\bin\Release\net9.0\win-x64\publish\ 
    
  3. 启动文件资源管理器,打开bin\Release\net9.0\win-x64\publish文件夹,并注意AotConsole.exe文件大约有 1.2 MB。AotConsole.pdb文件仅用于调试。

  4. 运行AotConsole.exe,注意控制台应用程序的行为与之前相同。

  5. Program.cs中,导入命名空间以处理动态代码程序集,如下面的代码所示:

    using System.Reflection; // To use AssemblyName.
    using System.Reflection.Emit; // To use AssemblyBuilder. 
    
  6. Program.cs中,创建一个动态程序集构建器,如下面的代码所示:

    AssemblyBuilder ab = AssemblyBuilder.DefineDynamicAssembly(
      new AssemblyName("MyAssembly"), AssemblyBuilderAccess.Run); 
    
  7. AotConsole项目的命令提示符或终端中,使用原生 AOT 发布控制台应用程序,如下面的命令所示:

    dotnet publish 
    
  8. 注意关于调用带有[RequiresDynamicCode]属性装饰的DefineDynamicAssembly方法的警告,如下面的输出所示:

    C:\cs13net9\Chapter07\AotConsole\Program.cs(9,22): warning IL3050: Using member 'System.Reflection.Emit.AssemblyBuilder.DefineDynamicAssembly(AssemblyName, AssemblyBuilderAccess)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Defining a dynamic assembly requires dynamic code. [C:\cs13net9\Chapter07\AotConsole\AotConsole.csproj] 
    
  9. 将我们无法在 AOT 项目中使用的语句注释掉。

更多信息:你可以在以下链接中了解更多关于原生 AOT 的信息:learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/

为 NuGet 分发打包你的库

在我们学习如何创建和打包自己的库之前,我们将回顾一个项目如何使用现有的包。

引用 NuGet 包

假设你想添加一个第三方开发者创建的包,例如,Newtonsoft.Json,这是一个用于处理JavaScript 对象表示法JSON)序列化格式的流行包:

  1. AssembliesAndNamespaces项目中,可以通过 Visual Studio 的 GUI 或使用 CLI 的dotnet add package命令添加对Newtonsoft.Json NuGet 包的引用。

在 C#开发工具包的 4 月版本中,你现在可以使用命令面板中的某些命令直接从 VS Code 管理 NuGet 包,如以下链接所述:devblogs.microsoft.com/nuget/announcing-nuget-commands-in-c-dev-kit/

  1. 打开AssembliesAndNamespaces.csproj文件,注意已经添加了一个包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Newtonsoft.Json"
                        Version="13.0.3" />
    </ItemGroup> 
    

如果你有一个更新的Newtonsoft.Json包版本,那么自本章编写以来它已经被更新。

解决依赖关系

为了一致地还原包并编写可靠的代码,解决依赖关系非常重要。解决依赖关系意味着你正在使用为特定版本的.NET 发布的同一系列包,例如,SQLite 用于.NET 9,如下面的标记所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Version="9.0.0"
      Include="Microsoft.EntityFrameworkCore.Sqlite" />
  </ItemGroup>
</Project> 

为了解决依赖关系,每个包都应该有一个单一的版本,没有额外的限定符。额外的限定符包括测试版(beta1)、发布候选版(rc4)和通配符(*)。

通配符允许自动引用和使用未来的版本,因为它们始终代表最新的发布。因此,通配符是危险的,因为它们可能导致使用未来不兼容的包,从而破坏您的代码。

在编写书籍时,这可能值得承担风险,因为每个月都会发布新的预览版本,您不想像我在 2024 年那样不断更新预览包引用,如下所示:

<PackageReference Version="9.0.0-preview.*"
  Include="Microsoft.EntityFrameworkCore.Sqlite" /> 

要自动使用每年 9 月和 10 月到达的发布候选版本,您可以使模式更加灵活,如下所示:

<PackageReference Version="9.0-*"
  Include="Microsoft.EntityFrameworkCore.Sqlite" /> 

如果您使用dotnet add package命令或 Visual Studio 的管理 NuGet 包,则默认情况下将使用包的最新特定版本。但如果您从博客文章中复制粘贴配置或手动添加引用,您可能会包含通配符限定符。

以下是一些 NuGet 包引用的示例,它们不是固定的,因此除非您知道其影响,否则应避免使用:

<PackageReference Include="System.Net.Http" Version="4.1.0-*" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2-beta1" /> 

良好实践:Microsoft 保证,如果您将依赖项固定到与特定.NET 版本(例如9.0.0)一起提供的版本,那么所有这些包都将一起工作。几乎总是固定依赖项,尤其是在生产部署中。

打包库以用于 NuGet

现在,让我们打包您之前创建的SharedLibrary项目:

  1. SharedLibrary项目中,请注意类库的目标是.NET Standard 2.0,因此默认情况下使用 C# 7.3 编译器。如以下标记所示,明确指定 C# 12 编译器:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>12</LangVersion>
      </PropertyGroup>
    </Project> 
    
  2. SharedLibrary项目中,将Class1.cs文件重命名为StringExtensions.cs

  3. 修改其内容,以提供一些有用的扩展方法来验证各种文本值,使用正则表达式,如下所示:

    using System.Text.RegularExpressions; // To use Regex.
    namespace Packt.Shared;
    public static class StringExtensions
    {
      public static bool IsValidXmlTag(this string input)
      {
        return Regex.IsMatch(input,
          @"^<([a-z]+)([^<]+)*(?:>(.*)<\/\1>|\s+\/>)$");
      }
      public static bool IsValidPassword(this string input)
      {
        // Minimum of eight valid characters.
        return Regex.IsMatch(input, "^[a-zA-Z0-9_-]{8,}$");
      }
      public static bool IsValidHex(this string input)
      {
        // Three or six valid hex number characters.
        return Regex.IsMatch(input,
          "^#?([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$");
      }
    } 
    

您将在第八章使用常见.NET 类型中学习如何编写正则表达式。

  1. SharedLibrary.csproj中,修改其内容,如下所示,并注意以下内容:

    • PackageId必须是全局唯一的,因此如果您想将此 NuGet 包发布到www.nuget.org/公共源供他人引用和下载,您必须使用不同的值。

    • PackageLicenseExpression必须是来自spdx.org/licenses/的值,或者您可以指定自定义许可。

    警告!如果你依赖 IntelliSense 来编辑文件,那么它可能会误导你使用已弃用的标签名称。例如,<PackageIconUrl>已被<PackageIcon>取代。有时,你不能完全信任自动化工具来帮助你正确操作!推荐的标签名称在以下链接中找到的表格的MSBuild 属性列中有记录:learn.microsoft.com/en-us/nuget/reference/msbuild-targets#pack-target

    • 所有其他元素都是自解释的:

      <Project Sdk="Microsoft.NET.Sdk">
        <PropertyGroup>
          <TargetFramework>netstandard2.0</TargetFramework>
          <LangVersion>12</LangVersion>
          **<****GeneratePackageOnBuild****>****true****</****GeneratePackageOnBuild****>**
      **<****PackageId****>****Packt.CSdotnet.SharedLibrary****</****PackageId****>**
      **<****PackageVersion****>****9.0.0.0****</****PackageVersion****>**
      **<****Title****>****C# 13 and .NET 9 Shared Library****</****Title****>**
      **<****Authors****>****Mark J Price****</****Authors****>**
      **<****PackageLicenseExpression****>**
       **MS-PL**
      **</****PackageLicenseExpression****>**
      **<****PackageProjectUrl****>**
       **https://github.com/markjprice/cs13net9**
      **</****PackageProjectUrl****>**
      **<****PackageReadmeFile****>****readme.md****</****PackageReadmeFile****>**
      **<****PackageIcon****>****packt-csdotnet-sharedlibrary.png****</****PackageIcon****>**
          **<****PackageRequireLicenseAcceptance****>****true****</****PackageRequireLicenseAcceptance****>**
      **<****PackageReleaseNotes****>**
       **Example shared library packaged for NuGet.**
      **</****PackageReleaseNotes****>**
      **<****Description****>**
       **Three extension methods to validate a string value.**
      **</****Description****>**
      **<****Copyright****>**
       **Copyright © 2016-2023 Packt Publishing Limited**
      **</****Copyright****>**
      **<****PackageTags****>****string extensions packt csharp dotnet****</****PackageTags****>**
        </PropertyGroup>
      **<****ItemGroup****>**
      **<****None****Include****=****"packt-csdotnet-sharedlibrary.png"**
      **PackagePath****=****"\"****Pack****=****"true"** **/>**
      **<****None****Include****=****"readme.md"**
      **PackagePath****=****"\"****Pack****=****"true"** **/>**
      **</****ItemGroup****>**
      </Project> 
      

      <None>表示一个不参与构建过程的文件。Pack="true"表示该文件将被包含在指定包路径位置创建的 NuGet 包中。你可以通过以下链接了解更多信息:learn.microsoft.com/en-us/nuget/reference/msbuild-targets#packing-an-icon-image-file

良好实践:配置属性值如果是truefalse,则不能有任何空白字符。

  1. 从以下链接下载图标文件,并将其保存到SharedLibrary项目文件夹中:github.com/markjprice/cs13net9/blob/main/code/Chapter07/SharedLibrary/packt-csdotnet-sharedlibrary.png.

  2. SharedLibrary项目文件夹中,创建一个名为readme.md的文件,其中包含有关包的一些基本信息,如下所示:

    # README for C# 13 and .NET 9 Shared Library
    This is a shared library that readers build in the book,
    *C# 13 and .NET 9 - Modern Cross-Platform Development Fundamentals*. 
    
  3. 构建发布版本:

    • 在 Visual Studio 中,在工具栏中选择发布,然后导航到生成 | 生成 SharedLibrary

    • 在 VS Code 中的终端中,输入dotnet build -c Release

如果我们没有在项目文件中将<GeneratePackageOnBuild>设置为true,那么我们就必须手动使用以下额外步骤创建一个 NuGet 包:

  • 在 Visual Studio 中,导航到生成 | 打包 SharedLibrary

  • 在 VS Code 中的终端中,输入dotnet pack -c Release

将包发布到公共 NuGet 源

如果你希望每个人都能下载和使用你的 NuGet 包,那么你必须将其上传到公共 NuGet 源,如 Microsoft 的:

  1. 打开你喜欢的浏览器并导航到以下链接:www.nuget.org/packages/manage/upload

  2. 如果你想上传一个 NuGet 包供其他开发者作为依赖项引用,你需要注册并登录到www.nuget.org/的 Microsoft 账户。

  3. 点击浏览...按钮,选择由生成 NuGet 包创建的.nupkg文件。文件夹路径应该是cs13net9\Chapter07\SharedLibrary\bin\Release,文件名为Packt.CSdotnet.SharedLibrary.9.0.0.nupkg

  4. 确认您在SharedLibrary.csproj文件中输入的信息已正确填写,然后点击提交

  5. 等待几秒钟,然后您将看到一个成功消息,显示您的包已上传,如图7.4所示:

图 7.4:NuGet 包上传消息

良好实践:如果您遇到错误,请检查项目文件中的错误,或阅读有关PackageReference格式的更多信息,请参阅learn.microsoft.com/en-us/nuget/reference/msbuild-targets

  1. 点击框架选项卡,并注意,因为我们针对.NET Standard 2.0,我们的类库可以被每个.NET 平台使用,如图7.5所示:

图 7.5:.NET Standard 2.0 类库包可以被所有.NET 平台使用

将包发布到私有 NuGet 源

组织可以托管自己的私有 NuGet 源。这对于许多开发团队来说是一种方便的共享工作方式。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/nuget/hosting-packages/overview

使用工具探索 NuGet 包

一个名为NuGet 包浏览器的实用工具,用于打开和查看 NuGet 包的更多详细信息,由 Uno Platform 创建。它不仅是一个网站,还可以作为跨平台应用程序安装。让我们看看它能做什么:

  1. 启动您喜欢的浏览器并导航到以下链接:nuget.info

  2. 在搜索框中输入Packt.CSdotnet.SharedLibrary

  3. 选择由马克·J·普莱斯发布的v9.0.0版本包,然后点击打开按钮。

  4. 内容部分,展开lib文件夹和netstandard2.0文件夹。

  5. 选择SharedLibrary.dll,并注意详细信息,如图7.6所示:

图 7.6:使用 Uno Platform 的 NuGet 包浏览器探索我的包

  1. 如果您想将来在本地使用此工具,请点击浏览器中的安装按钮。

  2. 关闭浏览器。

并非所有浏览器都支持安装此类 Web 应用。我建议使用 Chrome 进行测试和开发。

测试您的类库包

您现在将通过在AssembliesAndNamespaces项目中引用它来测试您上传的包:

  1. AssembliesAndNamespaces项目中,添加对您的(或我的)包的引用,如下所示,高亮显示的标记:

    <ItemGroup>
      <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
     **<PackageReference Include=****"Packt.CSdotnet.SharedLibrary"**
     **Version=****"9.0.0"** **/>**
    </ItemGroup> 
    
  2. 构建项目AssembliesAndNamespaces

  3. Program.cs中,导入Packt.Shared命名空间。

  4. Program.cs中,提示用户输入一些string值,然后使用包中的扩展方法进行验证,如下所示:

    Write("Enter a color value in hex: ");
    string? hex = ReadLine();
    WriteLine("Is {0} a valid color value? {1}",
      arg0: hex, arg1: hex.IsValidHex());
    Write("Enter a XML element: ");
    string? xmlTag = ReadLine();
    WriteLine("Is {0} a valid XML element? {1}",
      arg0: xmlTag, arg1: xmlTag.IsValidXmlTag());
    Write("Enter a password: ");
    string? password = ReadLine();
    WriteLine("Is {0} a valid password? {1}",
      arg0: password, arg1: password.IsValidPassword()); 
    
  5. 运行AssembliesAndNamespaces项目,根据提示输入一些值,并查看结果,如下所示:

    Enter a color value in hex: 00ffc8
    Is 00ffc8 a valid color value? True
    Enter an XML element: <h1 class="<" />
    Is <h1 class="<" /> a valid XML element? False
    Enter a password: secretsauce
    Is secretsauce a valid password? True 
    

使用预览功能

对于微软来说,交付一些具有跨.NET 多个部分影响的新功能是一个挑战,比如运行时、语言编译器和 API 库。这是一个典型的“先有鸡还是先有蛋”的问题。你先做什么?

从实际角度来看,这意味着尽管微软可能已经完成了实现一个功能所需的大部分工作,但整个功能可能直到他们现在每年一次的.NET 发布周期的最后阶段才完全准备好,这对于在“野外”进行适当的测试来说太晚了。

因此,从.NET 6 开始,微软将在通用可用性GA)发布中包含预览功能。开发者可以选择加入这些预览功能并向微软提供反馈。在后续的 GA 发布中,它们可以为所有人启用。

需要注意的是,这个主题是关于 预览功能。这与.NET 或 Visual Studio 的预览版不同。在开发过程中,微软会发布 Visual Studio 和.NET 的预览版以获取开发者的反馈,然后进行最终的 GA(通用可用性)发布。GA 发布后,功能对所有用户可用。在 GA 发布之前,获取新功能唯一的方式是安装预览版。"预览功能"不同之处在于它们与 GA 发布一起安装,并且必须选择性地启用。

例如,当微软在 2022 年 2 月发布.NET SDK 6.0.200 时,它将 C# 11 编译器作为一个预览功能包含在内。这意味着.NET 6 开发者可以选择将语言版本设置为preview,然后开始探索 C# 11 功能,如原始字符串字面量和required关键字。

一旦在 2022 年 11 月发布了.NET SDK 7.0.100,任何想要继续使用 C# 11 编译器的.NET 6 开发者就需要使用.NET 7 SDK 来处理他们的.NET 6 项目,并将目标框架设置为net6.0,同时设置<LangVersion>11。这样,他们就可以使用受支持的.NET 7 SDK 和受支持的 C# 11 编译器来构建.NET 6 项目。

到 2025 年 11 月,微软可能会发布带有 C# 14 编译器的.NET 10 SDK。然后你可以安装并使用.NET 10 SDK 来获得 C# 14 中任何新功能的益处,同时仍然针对.NET 9,如下面的Project文件所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
 **<TargetFramework>net9****.0****</TargetFramework>**
 **<LangVersion>****14****</LangVersion> <!--Requires .NET** **10** **SDK GA-->**
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project> 

良好实践:预览功能不支持在生产代码中使用。预览功能在最终发布前可能会出现破坏性更改。启用预览功能存在风险。切换到 GA 发布的未来 SDK,如.NET 11,以使用新的编译器功能,同时仍然针对旧版但长期支持的.NET 版本,如.NET 8 或 10。

需要预览功能

[RequiresPreviewFeatures] 属性用于指示使用预览功能的程序集、类型或成员,因此需要关于预览功能的警告。代码分析器可以扫描此属性,并在需要时生成警告。如果你的代码没有使用任何预览功能,你将不会看到任何警告。如果你的代码使用了任何预览功能,那么你将看到警告。你的代码也应该带有此属性,以警告其他开发者你的代码使用了预览功能。

启用预览功能

Project 文件中,添加一个元素以启用预览功能,并添加一个元素以启用预览语言功能,如下所示,高亮显示的标记:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
**<****EnablePreviewFeatures****>****true****</****EnablePreviewFeatures****>**
**<****LangVersion****>****preview****</****LangVersion****>**
  </PropertyGroup>
</Project> 

方法拦截器

拦截器是一种方法,它用一个对可拦截方法的调用替换成对自身的调用。这是一个在源生成器中最常用的高级功能。如果你感兴趣,我可能会在第 9 版中添加一个关于它们的章节。

更多信息:您可以在以下链接中了解更多关于拦截器的信息:learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#interceptors.

练习和探索

通过回答一些问题、进行一些实际操作练习以及进一步研究本章的主题,来测试你的知识和理解。

练习 7.1 – 在线材料

在线材料可以是我为这本书编写的额外内容,也可以是引用 Microsoft 或第三方创建的内容。

实验性 MSBuild 编辑器

MSBuild 是 .NET 开发体验的一个基本组件,但对于新开发者和经验丰富的开发者来说,它可能很难导航。为了帮助开发者更好地理解他们的项目文件并利用需要直接编辑项目文件的先进功能,Microsoft 团队推出了实验性的 MSBuild 编辑器。这个新编辑器提供了比当前 XML 架构提供的更深入的理解 MSBuild 文件。

如果你想尝试这个扩展,请在 Visual Studio 扩展管理器中搜索MSBuild 编辑器。目前,该扩展仅适用于 Visual Studio,但正在进行将其移植到 VS Code 的工作。

你可以在以下链接中了解更多关于新的 MSBuild 编辑器体验:

devblogs.microsoft.com/visualstudio/experimental-msbuild-editor/

提高 .NET 性能

在过去几年中,Microsoft 对 .NET 性能进行了重大改进。你应该阅读 Stephen Toub 编写的博客文章,以了解团队做了哪些更改以及为什么。他的文章以其长篇、详细和精彩而闻名!

你可以在以下链接中找到关于改进的帖子:

反编译 .NET 程序集

要了解如何反编译 .NET 程序集,您可以阅读以下链接:

github.com/markjprice/cs13net9/blob/main/docs/ch07-decompiling.md

从 .NET Framework 迁移到现代 .NET

如果您对将遗留项目从 .NET Framework 迁移到现代 .NET 感兴趣,那么我在以下链接中编写了一个仅在线的部分:

github.com/markjprice/cs13net9/blob/main/docs/ch07-porting.md

练习 7.2 – 练习题

练习题以深入了解本章主题。

创建源生成器

如果您对创建源生成器感兴趣,那么我在以下链接中编写了一个仅在线的部分:

github.com/markjprice/cs13net9/blob/main/docs/ch07-source-generators.md

您可以在以下链接中找到源生成器的示例:

github.com/amis92/csharp-source-generators

探索 PowerShell

PowerShell 是 Microsoft 用于在所有操作系统上自动化任务的脚本语言。Microsoft 推荐使用带有 PowerShell 扩展的 VS Code 来编写 PowerShell 脚本。

由于 PowerShell 是一种广泛使用的语言,本书中没有足够的空间来涵盖它。您可以从以下链接中的 Microsoft 培训模块中了解一些关键概念:learn.microsoft.com/en-us/training/modules/introduction-to-powershell/.

你可以在以下链接中阅读官方文档:learn.microsoft.com/en-us/powershell/.

练习 7.3 – 测试你的知识

回答以下问题:

  1. 命名空间和程序集之间的区别是什么?

  2. 你如何在 .csproj 文件中引用另一个项目?

  3. 类似 ILSpy 这样的工具有什么好处?

  4. C# 中的 float 别名代表的是哪种 .NET 类型?

  5. <PropertyGroup><ItemGroup> 元素在 .NET 项目文件中用于什么?

  6. .NET 应用程序的框架依赖型部署和自包含部署之间的区别是什么?

  7. RID 是什么?

  8. dotnet packdotnet publish 命令之间的区别是什么?

  9. 你如何控制构建过程使用的源以下载 NuGet 包?

  10. 使用 AOT 编译有什么局限性?

练习 7.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-7---packaging-and-distributing-net-types

摘要

在本章中,我们:

  • 回顾了 .NET 为 BCL 功能所经历的旅程。

  • 探索了程序集和命名空间之间的关系。

  • 看到了发布适用于多个操作系统的应用程序的选项。

  • 学会了如何发布原生 AOT 以实现更快的启动和更小的内存占用。

  • 打包并分发了一个类库。

  • 学会了如何激活预览功能。

在下一章中,你将了解一些现代 .NET 中包含的常见 BCL 类型。

第八章:操作常见的 .NET 类型

本章介绍了一些包含在 .NET 中的常见类型。这些类型包括用于操作数字、文本和集合的类型;改进对 spans、indexes 和 ranges 的处理;以及在可选的在线部分,处理网络资源。

本章涵盖了以下主题:

  • 操作数字

  • 操作文本

  • 使用正则表达式进行模式匹配

  • 在集合中存储多个对象

  • 操作 spans、indexes 和 ranges

操作数字

最常见的数据类型之一是数字。在 .NET 中用于操作数字的最常见类型如 表 8.1 所示:

命名空间 示例类型 描述
System SByte, Int16, Int32, Int64, Int128 整数;即零和正负整数。
System Byte, UInt16, UInt32, UInt64, UInt128 基数;即零和正整数。
System Half, Single, Double 实数;即浮点数。
System Decimal 准确的实数;即用于科学、工程或金融场景。
System.Numerics BigInteger, Complex, Quaternion 随意大的整数、复数和四元数。

表 8.1:常见的 .NET 数字类型

自从 2002 年 .NET Framework 1.0 发布以来,.NET 就有了 32 位的 float 和 64 位的 double 类型。IEEE 754 规范还定义了一个 16 位的浮点数标准。机器学习和其他算法将受益于这种更小、精度更低的数字类型;因此,Microsoft 在 .NET 5 及以后的版本中引入了 System.Half 类型。目前,C# 语言没有定义 half 别名,所以你必须使用 .NET 类型 System.Half。这可能在将来会改变。

System.Int128System.UInt128 是在 .NET 7 中引入的,它们也没有 C# 别名关键字。

操作大整数

可以存储在 .NET 类型中的最大整数是大约十八亿五千万,使用无符号 64 位整数 ulong 存储。但如果你需要存储比这更大的数字怎么办?

让我们探索数值:

  1. 使用您首选的代码编辑器创建一个新项目,如下面的列表所示:

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

    • 项目文件和文件夹:WorkingWithNumbers

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

  2. 在项目文件中,添加一个元素以静态和全局导入 System.Console 类。

  3. Program.cs 中,删除现有的语句,然后添加一个语句来导入 System.Numerics,如下面的代码所示:

    using System.Numerics; // To use BigInteger. 
    
  4. 添加语句以输出 ulong 类型的最大值,以及使用 BigInteger 的 30 位数字,如下面的代码所示:

    const int width = 40;
    WriteLine("ulong.MaxValue vs a 30-digit BigInteger");
    WriteLine(new string('-', width));
    ulong big = ulong.MaxValue;
    WriteLine($"{big,width:N0}");
    BigInteger bigger =
      BigInteger.Parse("123456789012345678901234567890");
    WriteLine($"{bigger,width:N0}"); 
    

格式代码中值40width常量表示“右对齐 40 个字符”,因此两个数字都排列在右侧边缘。N0表示“使用千位分隔符和零小数位。”

  1. 运行代码并查看结果,如下所示输出:

    ulong.MaxValue vs a 30-digit BigInteger
    ----------------------------------------
                  18,446,744,073,709,551,615
     123,456,789,012,345,678,901,234,567,890 
    

BigInteger可以表示几乎无限长度的整数值。然而,在现实中,这受到可用内存和计算时间等因素的限制。如果生成的值过大,一些 API 也可能失败。因此,在.NET 9 及以后版本中,对BigInteger的最大长度进行了强制限制,限制为(2³¹) - 1位(约 21.4 亿位)。这相当于大约 256 MB 和约 646.5 百万位。这个限制确保 API 保持可靠和一致,同时仍然支持远大于大多数实际需求的数字。

乘以大整数

当你乘以两个 32 位整数时,结果可能会超出 32 位整数的范围。例如,32 位整数(int)的最大值是 2,147,483,647。如果你将这个值乘以另一个大整数,结果可能会超过这个限制,导致溢出,如果只使用 32 位来存储结果,可能会导致计算错误。

自从.NET 的最早版本以来,System.Math类有一个BigMul方法,它执行两个 32 位整数的乘法,并将完整的 64 位结果作为long返回。这个方法是必要的,因为使用*运算符乘以两个 32 位整数可能会导致 64 位整数,这可能会超出典型的 32 位整数类型。

自从.NET 5 以来,BigMul方法增加了对两个有符号或无符号long值进行乘法的重载。在.NET 9 及以后版本中,数字类型本身,如intlonguintulong,现在也获得了BigMul方法来乘以它们的值,并将结果以下一个最大的类型返回。例如,long.BigMul返回System.Int128

现在,让我们看看如何乘以大整数:

  1. Program.cs中,添加语句以使用旧的和新的方法乘以一些大整数,如下所示代码:

    WriteLine("Multiplying big integers");
    int number1 = 2_000_000_000;
    int number2 = 2;
    WriteLine($"number1: {number1:N0}");
    WriteLine($"number2: {number2:N0}");
    WriteLine($"number1 * number2: {number1 * number2:N0}");
    WriteLine($"Math.BigMul(number1, number2): {
      Math.BigMul(number1, number2):N0}");
    WriteLine($"int.BigMul(number1, number2): {
      int.BigMul(number1, number2):N0}"); 
    
  2. 运行代码并查看结果,如下所示输出:

    Multiplying big integers
    number1: 2,000,000,000
    number2: 2
    number1 * number2: -294,967,296
    Math.BigMul(number1, number2): 4,000,000,000
    int.BigMul(number1, number2): 4,000,000,000 
    

处理复数

一个复数可以表示为a + bi,其中ab是实数,i是虚数单位,其中i² = −1。如果实部a为零,它是一个纯虚数。如果虚部b为零,它是一个实数。

复数在许多科学、技术、工程和数学STEM)研究领域有实际应用。它们是通过分别添加加数的实部和虚部来相加的;考虑以下:

(a + bi) + (c + di) = (a + c) + (b + d)i 

让我们探索复数:

  1. Program.cs中,添加语句以添加两个复数,如下所示代码:

    Complex c1 = new(real: 4, imaginary: 2);
    Complex c2 = new(real: 3, imaginary: 7);
    Complex c3 = c1 + c2;
    // Output using the default ToString implementation.
    WriteLine($"{c1} added to {c2} is {c3}");
    // Output using a custom format.
    WriteLine("{0} + {1}i added to {2} + {3}i is {4} + {5}i",
      c1.Real, c1.Imaginary,
      c2.Real, c2.Imaginary,
      c3.Real, c3.Imaginary); 
    
  2. 运行代码并查看结果,如下所示输出:

    <4; 2> added to <3; 7> is <7; 9>
    4 + 2i added to 3 + 7i is 7 + 9i 
    

.NET 6 及更早版本使用不同的默认复数格式:(4, 2) 加上 (3, 7)(7, 9)。在 .NET 7 及以后版本中,默认格式已更改为使用尖括号和分号,因为某些文化使用圆括号表示负数,并使用逗号表示小数。在撰写本文时,官方文档尚未更新以使用新格式,如下列链接所示:learn.microsoft.com/en-us/dotnet/api/system.numerics.complex.tostring

与张量一起工作

张量是 人工智能AI)中的基本数据结构。你可以把它们看作是专门的多维数组。

张量用于表示和编码各种形式的数据,包括文本序列(标记)、图像、视频和音频。张量以最优效率对高维数据进行计算,并在神经网络中存储权重信息和中间计算。

.NET 9 引入了一个新的 Tensor<T> 类型,它使与 AI 库(如 ML.NET、TorchSharp 和 ONNX Runtime)的高效交互成为可能,并在可能的情况下最小化数据复制。它通过直观的索引和切片操作简化了数据操作,确保易用性和效率。

为游戏和类似应用程序生成随机数

在不需要真正随机数的情况,如游戏中,你可以创建一个 Random 类的实例,如下面的代码示例所示:

Random r = new(); 

Random 有一个构造函数,用于指定用于初始化其伪随机数生成器的种子值,如下面的代码所示:

Random r = new(Seed: 46378); 

如你在 第二章 讲 C# 中所学,参数名称应使用 驼峰命名法。定义 Random 类构造函数的开发者打破了这一惯例。参数名称应该是 seed,而不是 Seed

良好实践:共享的种子值充当密钥;因此,如果你在两个应用程序中使用相同的随机数生成算法和相同的种子值,那么它们可以生成相同的“随机”数字序列。有时这是必要的,例如,当同步 GPS 接收器与卫星时,或者当游戏需要随机生成相同关卡时。但通常,你希望保持你的种子值保密。

使用 Random 类生成随机数

为了避免分配比必要的更多内存,.NET 6 引入了一个共享的静态 Random 实例,你可以访问它而不是创建自己的实例。

Random 类有常用的生成随机数的方法,如下列所示:

  • Next:此方法返回一个随机int(整数),它接受两个参数,minValuemaxValue,但maxValue不是方法返回的最大值!它是一个排他性上限,意味着maxValue比返回的最大值多 1。使用NextInt64方法返回一个long整数。

  • NextDouble:此方法返回一个大于或等于0.0且小于且从不等于1.0的数字。使用NextSingle方法返回一个float

  • NextBytes:此方法使用随机byte0255)值填充任何大小的数组。通常将byte值格式化为十六进制,例如,00FF

让我们看看一些生成伪随机数的示例:

  1. Program.cs中,添加语句以访问共享的Random实例,然后调用其方法来生成随机数,如下面的代码所示:

    Random r = Random.Shared;
    // minValue is an inclusive lower bound i.e. 1 is a possible value.
    // maxValue is an exclusive upper bound i.e. 7 is not a possible value.
    int dieRoll = r.Next(minValue: 1, maxValue: 7); // Returns 1 to 6.
    WriteLine($"Random die roll: {dieRoll}");
    double randomReal = r.NextDouble(); // Returns 0.0 to less than 1.0.
    WriteLine($"Random double: {randomReal}");
    byte[] arrayOfBytes = new byte[256];
    r.NextBytes(arrayOfBytes); // Fills array with 256 random bytes.
    Write("Random bytes: ");
    for (int i = 0; i < arrayOfBytes.Length; i++)
    {
      Write($"{arrayOfBytes[i]:X2} ");
    }
    WriteLine(); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Random die roll: 1
    Random double: 0.06735275453092382
    Random bytes: D9 38 CD F3 5B 40 2D F4 5B D0 48 DF F7 B6 67 C1 95 A1 2C 58 42 CF 70 6C C3 BE 82 D7 EC 61 0D D2 2D C4 49 7B C7 0F EA CC B3 41 F3 04 5D 29 25 B7 F7 99 8A 0F 56 20 A6 B3 57 C4 48 DA 94 2B 07 F1 15 64 EA 8D FF 79 E6 E4 9A C8 65 C5 D8 55 3D 3C C0 2B 0B 4C 3A 0E E6 A5 91 B7 59 6C 9A 94 97 43 B7 90 EE D8 9A C6 CA A1 8F DD 0A 23 3C 01 48 E0 45 E1 D6 BD 7C 41 C8 22 8A 81 82 DC 1F 2E AD 3F 93 68 0F B5 40 7B 2B 31 FC A6 BF BA 05 C0 76 EE 58 B3 41 63 88 E5 5C 8B B5 08 5C C3 52 FF 73 69 B0 97 78 B5 3B 87 2C 12 F3 C3 AE 96 43 7D 67 2F F8 C9 31 70 BD AD B3 9B 44 53 39 5F 19 73 C8 43 0E A5 5B 6B 5A 9D 2F DF DC A3 EE C5 CF AF A4 8C 0F F2 9C 78 19 48 CE 49 A8 28 06 A3 4E 7D F7 75 AA 49 E7 4E 20 AF B1 77 0A 90 CF C1 E0 62 BC 4F 79 76 64 98 BF 63 76 B4 F9 1D A4 C4 74 03 63 02 
    

在确实需要真正随机数的情况下,例如在密码学中,有专门的数据类型,比如RandomNumberGenerator。我在配套书籍《.NET 8 工具与技能》中涵盖了这一点以及其他密码学类型,在标题为《使用密码学保护数据和应用程序》的章节中进行了介绍,该章节于 2024 年 7 月出版。

.NET 8 及以后版本的新随机方法

.NET 8 引入了两个新的Random方法,如下列所示:

  • GetItems<T>:此方法接受一个类型为T的数组或只读 span 的选择,以及您想要生成的项目数量,然后从选择中随机返回这些数量的项目。

  • Shuffle<T>:此方法接受一个类型为T的数组或 span,并随机化项目的顺序。

让我们看看每个示例:

  1. Program.cs中,添加语句以访问共享的Random实例,然后调用其方法来生成随机数,如下面的代码所示:

    string[] beatles = r.GetItems(
      choices: new[] { "John", "Paul", "George", "Ringo" },
      length: 10);
    Write("Random ten beatles:");
    foreach (string beatle in beatles)
    {
      Write($" {beatle}");
    }
    WriteLine();
    r.Shuffle(beatles);
    Write("Shuffled beatles:");
    foreach (string beatle in beatles)
    {
      Write($" {beatle}");
    }
    WriteLine(); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Random ten beatles: Paul Paul John John John John Paul John George Ringo
    Shuffled beatles: George John Paul Paul John John John Ringo Paul John 
    

生成 GUID

全局唯一标识符(GUID)是一个 128 位的文本字符串,它代表一个用于标识的唯一值。作为开发者,当需要生成用于标识信息的唯一引用时,您将需要生成 GUID。传统上,数据库和计算机系统可能使用递增的整数值,但 GUID 更有可能在多任务系统中避免冲突。

System.Guid类型是一个值类型(struct),它表示 GUID 值。它有ParseTryParse方法,可以将表示为string的现有 GUID 值转换为Guid类型。它有一个NewGuid方法来生成新的值。

NewGuid方法实现了通用唯一标识符(UUID)规范的第四版。您可以在以下链接中了解 UUID 规范:datatracker.ietf.org/doc/rfc9562/

.NET 9 引入了对 UUID 规范版本 7 的支持,这允许你控制自 Unix 纪元以来的前 48 位时间戳毫秒数,从而可以创建可排序的 GUID。这对于数据库的唯一 ID 来说要好得多。

让我们分解一个示例值:0191a398-12ec-7d5e-a8d3-8a629eae8be1

  • 前导 48 位是一个十六进制的时间戳:0191a39812ec

    • 0191a398:前 32 位代表时间戳的高位。

    • 12ec:接下来的 16 位继续时间戳。

  • 接下来的 12 位是十六进制的版本和变体:7d5e

    • 7表示这是一个版本 7 的 UUID。

    • 剩余的三位(d5e)结合了时间戳和变体的一部分,用于识别 UUID 的布局。

  • 最后的 68 位是一个十六进制的随机部分:a8d3-8a629eae8be1

    • 这确保了 GUID 的唯一性。

让我们看看我们如何生成 GUID 值并输出它们:

  1. Program.cs中添加语句以输出空Guid的值,生成一个新的随机Guid并输出其值,最后输出随机Guid值的每个单独的字节,如下面的代码所示:

    WriteLine($"Empty GUID: {Guid.Empty}.");
    Guid g = Guid.NewGuid();
    WriteLine($"Random GUID: {g}.");
    byte[] guidAsBytes = g.ToByteArray();
    Write("GUID as byte array: ");
    for (int i = 0; i < guidAsBytes.Length; i++)
    {
      Write($"{guidAsBytes[i]:X2} ");
    }
    WriteLine();
    WriteLine("Generating three v7 GUIDs:");
    for (int i = 0; i < 3; i++)
    {
      Guid g7 = Guid.CreateVersion7(DateTimeOffset.UtcNow);
      WriteLine($"  {g7}.");
    } 
    
  2. 运行代码,查看结果,并注意三个 GUID 值的前部分(0191a398-12ec等)是可排序的,因为它们基于相同的DateTimeOffset值,如下面的输出所示:

    Empty GUID: 00000000-0000-0000-0000-000000000000.
    Random GUID: c7a11eea-45a5-4619-964a-a9cce1e4220c.
    GUID as byte array: EA 1E A1 C7 A5 45 19 46 96 4A A9 CC E1 E4 22 0C
    Generating three v7 GUIDs:
      0191a398-12ec-7d5e-a8d3-8a629eae8be1.
      0191a398-12ed-7913-8096-c6f70b5edd8f.
      0191a398-12ed-7475-8284-0588b573080e. 
    

    EF Core 团队有一个提案,允许在数据模型中使用版本 7 的 GUID。您可以在以下链接中了解更多关于这个想法:github.com/dotnet/efcore/issues/34158

处理文本

变量数据类型中最常见的其他类型之一是文本。在.NET 中用于处理文本的最常见类型如表 8.2所示:

命名空间 类型 描述
System Char 存储单个文本字符的存储空间
System String 存储多个文本字符的存储空间
System.Text StringBuilder 高效地操作字符串
System.Text.RegularExpressions Regex 高效地匹配字符串模式

表 8.2:用于处理文本的常见.NET 类型

获取字符串的长度

让我们探索一些处理文本时的常见任务;例如,有时你需要找出存储在string变量中的文本片段的长度:

  1. 使用您首选的代码编辑器,将一个新的控制台应用程序/ console项目命名为WorkingWithText添加到Chapter08解决方案中。

  2. WorkingWithText项目中,在Program.cs中删除现有的语句,然后添加语句来定义一个变量以存储城市名称伦敦,并将其名称和长度写入控制台,如下面的代码所示:

    string city = "London";
    WriteLine($"{city} is {city.Length} characters long."); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    London is 6 characters long. 
    

获取字符串的字符

string 类内部使用 char 数组来存储文本。它还有一个索引器,这意味着我们可以使用数组语法来读取其字符。数组索引从 0 开始,因此第三个字符将在索引 2 处。

让我们看看这个动作:

  1. 添加语句写入 string 变量中的第一个和第四个位置的字符,如下所示:

    WriteLine($"First char is {city[0]} and fourth is {city[3]}."); 
    
  2. 运行代码并查看以下输出结果:

    First char is L and fourth is d. 
    

分割字符串

有时候,你需要将文本分割成任意字符,例如逗号:

  1. 添加语句定义一个包含以逗号分隔的城市名称的单一 string 变量,然后使用 Split 方法并指定你想要将逗号作为分隔符,然后枚举返回的 string 值数组,如下所示:

    string cities = "Paris,Tehran,Chennai,Sydney,New York,Medellín";
    string[] citiesArray = cities.Split(',');
    WriteLine($"There are {citiesArray.Length} items in the array:");
    foreach (string item in citiesArray)
    {
      WriteLine($"  {item}");
    } 
    
  2. 运行代码并查看以下输出结果:

    There are 6 items in the array:
      Paris
      Tehran
      Chennai
      Sydney
      New York
      Medellín 
    

在本章的后面部分,你将学习如何使用正则表达式处理更复杂的字符串分割场景。

获取字符串的一部分

有时候,你需要获取文本的一部分。IndexOf 方法有九种重载,可以返回指定 charstringstring 中的索引位置。Substring 方法有两种重载,如下所示:

  • Substring(startIndex, length): 这将返回从 startIndex 开始并包含下一个 length 个字符的字符串部分。

  • Substring(startIndex): 这将返回从 startIndex 开始并包含直到字符串末尾的所有字符的字符串部分。

让我们来看一个简单的例子:

  1. 添加语句将一个人的全名存储在一个以空格字符分隔的 string 变量中,第一个和最后一个名字之间,找到空格的位置,然后提取名字和姓氏作为两部分,以便它们可以以不同的顺序重新组合,如下所示:

    string fullName = "Alan Shore";
    int indexOfTheSpace = fullName.IndexOf(' ');
    string firstName = fullName.Substring(
      startIndex: 0, length: indexOfTheSpace);
    string lastName = fullName.Substring(
      startIndex: indexOfTheSpace + 1);
    WriteLine($"Original: {fullName}");
    WriteLine($"Swapped: {lastName}, {firstName}"); 
    
  2. 运行代码并查看以下输出结果:

    Original: Alan Shore
    Swapped: Shore, Alan 
    

如果初始全名的格式不同,例如 "LastName, FirstName",则代码需要不同。作为一个可选练习,尝试编写一些语句,将输入 "Shore, Alan" 转换为 "Alan Shore"

检查字符串内容

有时候,你需要检查一段文本是否以某些字符开始或结束,或者是否包含某些字符。你可以使用名为 StartsWithEndsWithContains 的方法来实现这一点:

  1. 添加语句存储一个 string 值,然后检查它是否以或包含几个不同的 char 值,如下所示:

    string company = "Microsoft";
    WriteLine($"Text: {company}");
    WriteLine("Starts with M: {0}, contains an N: {1}",
      arg0: company.StartsWith('M'),
      arg1: company.Contains('N')); 
    

    良好实践StartsWithEndsWithContains 可以传递 stringchar 值。单引号表示前述代码中的 char 值。搜索类似 'M'char 比搜索类似 "M"string 值更高效。

  2. 运行代码并查看以下输出结果:

    Text: Microsoft
    Starts with M: True, contains an N: False 
    

比较字符串值

两个常见的字符串值任务是排序(也称为整理)和比较。例如,当用户输入他们的用户名或密码时,你需要比较他们输入的内容与存储的内容。

string类实现了IComparable接口,这意味着你可以通过使用CompareTo实例方法轻松比较两个字符串值,它将返回-101,具体取决于值是“小于”、“等于”还是“大于”另一个。当你为Person类实现IComparable接口时,你看到了这个例子,如第六章实现接口和继承类

然而,字符的大小写可能会影响排序,文本的排序规则是文化相关的。例如,在传统的西班牙语中,双 L 被视为一个字符,如表 8.3所示:

文化 描述 示例字符串值
西班牙语 在 1994 年,西班牙皇家学院发布了一条新的字母排序规则,将 LL 和 CH 视为拉丁字母字符,而不是单独的字符。 现代:llegar排在lugar之前。传统:llegar排在lugar之后。
瑞典语 在 2006 年,瑞典学院发布了一条新规则。在 2006 年之前,V 和 W 是同一个字符。自 2006 年以来,它们被视为不同的字符。 瑞典语单词大多只使用 V。包含 W 的借词(从其他语言借来的单词)现在可以保留那些 W,而不是用 Vs 替换 W。
德语 电话簿排序与字典排序不同,例如,重音符号被视为字母的组合。 在电话簿排序中,MüllerMueller是同一个名字。
德语 字符ß被排序为SS。这是地址中常见的问题,因为“街道”这个词是Straße StraßeStrasse有相同的意思。

表 8.3:欧洲语言排序规则示例

为了保持一致性和性能,有时你想以文化不变的方式进行比较。因此,最好使用static方法Compare

让我们看看一些例子:

  1. Program.cs的顶部,导入用于处理文化的命名空间,并启用特殊字符,如欧元货币符号,如下面的代码所示:

    using System.Globalization; // To use CultureInfo.
    OutputEncoding = System.Text.Encoding.UTF8; // Enable Euro symbol. 
    
  2. Program.cs中,定义一些文本变量,并在不同的文化中进行比较,如下面的代码所示:

    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
    string text1 = "Mark";
    string text2 = "MARK";
    WriteLine($"text1: {text1}, text2: {text2}");
    WriteLine("Compare: {0}.", string.Compare(text1, text2));
    WriteLine("Compare (ignoreCase): {0}.",
      string.Compare(text1, text2, ignoreCase: true));
    WriteLine("Compare (InvariantCultureIgnoreCase): {0}.",
      string.Compare(text1, text2,
      StringComparison.InvariantCultureIgnoreCase));
    // German string comparisons
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
    text1 = "Strasse";
    text2 = "Straße";
    WriteLine($"text1: {text1}, text2: {text2}");
    WriteLine("Compare: {0}.", string.Compare(text1, text2,
      CultureInfo.CurrentCulture, CompareOptions.IgnoreNonSpace));
    WriteLine("Compare (IgnoreCase, IgnoreNonSpace): {0}.",
      string.Compare(text1, text2, CultureInfo.CurrentCulture,
      CompareOptions.IgnoreNonSpace | CompareOptions.IgnoreCase));
    WriteLine("Compare (InvariantCultureIgnoreCase): {0}.",
      string.Compare(text1, text2,
      StringComparison.InvariantCultureIgnoreCase)); 
    
  3. 运行代码,查看结果,并注意小写字母“a”是“小于”(-1)大写字母“A”,所以比较返回-1。但我们可以设置一个选项来忽略大小写,或者更好的是,进行文化和大小写不变的比较,将两个字符串值视为相等(0),如下面的输出所示:

    text1: Mark, text2: MARK
    Compare: -1.
    Compare (ignoreCase): 0.
    Compare (InvariantCultureIgnoreCase): 0.
    text1: Strasse, text2: Straße
    Compare: 0.
    Compare (IgnoreCase, IgnoreNonSpace): 0.
    Compare (InvariantCultureIgnoreCase): -1. 
    

更多信息:你可以在以下链接中了解更多关于string比较的信息:learn.microsoft.com/en-us/globalization/locale/sorting-and-string-comparison

连接、格式化和其他字符串成员

表 8.4所示,还有很多其他的string成员:

成员 描述
Trim, TrimStart, TrimEnd 这些方法从开始和/或结束处删除空白字符,如空格、制表符和回车符。
ToUpper, ToLower 这些将所有字符转换为大写或小写。
Insert, Remove 这些方法用于插入或删除一些文本。
Replace 这将一些文本替换为其他文本。
string.Empty 这可以用来代替每次使用空的双引号("")分配内存的文本字面量字符串值。
string.Concat 这将两个字符串变量连接起来。当在字符串操作数之间使用时,+运算符执行等效操作。
string.Join 这将一个或多个字符串变量连接起来,每个字符串之间用一个字符隔开。
string.IsNullOrEmpty 这用于检查一个字符串变量是否为 null 或空。
string.IsNullOrWhiteSpace 这用于检查一个字符串变量是否为 null 或空白;也就是说,是任意数量的水平或垂直间隔字符的混合,例如,制表符、空格、回车、换行等。
string.Format 这是一个用于输出格式化字符串值的替代方法,它使用位置参数而不是命名参数进行字符串插值。

表 8.4:连接、格式化和其他字符串成员

前面的某些方法是static方法。这意味着该方法只能从类型调用,而不能从变量实例调用。在前面的表中,我通过在方法前加上string.前缀来指示静态方法,例如string.Format

让我们探索一些这些方法:

  1. 使用Join方法添加语句来接收一个string值数组,并使用分隔符将它们组合回单个string变量,如下面的代码所示:

    string recombined = string.Join(" => ", citiesArray);
    WriteLine(recombined); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Paris => Tehran => Chennai => Sydney => New York => Medellín 
    
  3. 添加语句以使用位置参数和插值string格式化语法来输出相同的三个变量两次,如下面的代码所示:

    string fruit = "Apples";
    decimal price =  0.39M;
    DateTime when = DateTime.Today;
    WriteLine($"Interpolated:  {fruit} cost {price:C} on {when:dddd}.");
    WriteLine(string.Format("string.Format: {0} cost {1:C} on {2:dddd}.",
      arg0: fruit, arg1: price, arg2: when)); 
    

    一些代码编辑器,如 Rider,会警告你关于装箱操作。这些操作虽然慢,但在这种情况下并不是问题。为了避免装箱,请在pricewhen上调用ToString方法。

  4. 运行代码并查看结果,如下面的输出所示:

    Interpolated:  Apples cost $0.39 on Friday.
    string.Format: Apples cost $0.39 on Friday. 
    

注意,我们可以简化第二个语句,因为Console.WriteLine支持与string.Format相同的格式代码,如下面的代码所示:

WriteLine("WriteLine: {0} cost {1:C} on {2:dddd}.",
  arg0: fruit, arg1: price, arg2: when); 

高效构建字符串

你可以使用String.Concat方法或简单地使用+运算符将两个字符串连接起来以创建一个新的string。但是,当组合超过几个值时,这两种选择都是不好的做法,因为.NET 必须在内存中创建一个全新的string

如果你只添加两个 string 值,这可能不会引起注意,但如果你在循环中多次连接字符串,它可能会对性能和内存使用产生显著的负面影响。你可以使用 StringBuilder 类型更有效地连接 string 变量。

我为配套书籍 Apps and Services with .NET 8 编写了一个仅在网络上可用的部分,关于使用 string 连接作为主要示例的性能基准测试。你可以选择在以下链接完成该部分及其实际编码任务:github.com/markjprice/apps-services-net8/blob/main/docs/ch01-benchmarking.md

更多信息:你可以在以下链接中看到使用 StringBuilder 的示例:learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder#examples

处理字符

有时,你需要处理单个字符。System.Char 类型(也称为 char)有一些有用的方法,如下表 8.5 所示:

方法 描述
char.IsDigit(char), char.IsDigit(string, int) 如果字符是十进制数字,则返回 true
char.IsLetter(char), char.IsLetter(string, int) 如果字符是字母,则返回 true
char.IsLower(char), char.IsLower(string, int) 如果字符是小写字母,则返回 true
char.IsUpper(char), char.IsUpper(string, int) 如果字符是大写字母,则返回 true
char.IsSeparator(char), char.IsSeparator(string, int) 如果字符是分隔符,则返回 true
char.IsSymbol(char), char.IsSymbol(string, int) 如果字符是符号,则返回 true
char.IsWhiteSpace(char), char.IsWhiteSpace(string, int) 如果字符是空白字符,如空格或制表符,则返回 true

表 8.5:处理字符的方法

字符串中的搜索

.NET 8 引入了 SearchValues 类型,该类型实现了一种在 spans 中搜索字符或字节集合的优化方法。例如,SearchValues 有助于在文本值中找到第一个元音实例,如下面的代码所示:

string vowels = "AEIOUaeiou";
// .NET 8 and later.
SearchValues<char> vowelsSearchValues = SearchValues.Create(vowels);   
ReadOnlySpan<char> text = "Fred";
WriteLine($"vowels: {vowels}");
WriteLine($"text: {text}");
WriteLine($"text.IndexOfAny(vowelsSearchValues): {
  text.IndexOfAny(vowelsSearchValues)}"); 

文本 Fred 中的第一个元音位于索引位置 2,如下面的输出所示:

vowels: AEIOUaeiou
text: Fred
text.IndexOfAny(vowelsSearchValues): 2 

在 .NET 9 或更高版本中,SearchValues 现在不仅支持搜索子字符串,还支持在较长的字符串中搜索字符,如下面的代码所示:

string[] names = [ "Cassian", "Luthen", "Mon Mothma",
  "Dedra", "Syril", "Kino" ];
// .NET 9 and later.
SearchValues<string> namesSearchValues = SearchValues.Create(
  names, StringComparison.OrdinalIgnoreCase);
ReadOnlySpan<char> sentence = "In Andor, Diego Luna returns as the titular character, Cassian Andor, to whom audiences were first introduced in Rogue One.";
WriteLine($"names: {string.Join(' ', names)}");
WriteLine($"sentence: {sentence}");
WriteLine($"sentence.IndexOfAny(vowelsSearchValues): {
  sentence.IndexOfAny(namesSearchValues)}"); 

Andor 字符列表中的第一个名字从索引位置 55 开始,如下面的输出所示:

names: Cassian Luthen Mon Mothma Dedra Syril Kino
sentence: In Andor, Diego Luna returns as the titular character, Cassian Andor, to whom audiences were first introduced in Rogue One.
sentence.IndexOfAny(vowelsSearchValues): 55 

使用正则表达式进行模式匹配

正则表达式对于验证用户输入非常有用。它们非常强大,也可能变得非常复杂。几乎所有的编程语言都支持正则表达式,并使用一组常见的特殊字符来定义它们。

让我们尝试一些示例正则表达式:

  1. 使用你喜欢的代码编辑器添加一个名为WorkingWithRegularExpressions的新控制台应用程序/console项目到Chapter08解决方案中。

  2. Program.cs中,删除现有的语句,然后导入以下命名空间:

    using System.Text.RegularExpressions; // To use Regex. 
    

检查作为文本输入的数字

我们将首先实现验证数字输入的常见示例:

  1. Program.cs中,添加语句提示用户输入他们的年龄,然后使用查找数字字符的正则表达式来检查其有效性,如下所示:

    Write("Enter your age: ");
    string input = ReadLine()!; // Null-forgiving operator.
    Regex ageChecker = new(@"\d");
    WriteLine(ageChecker.IsMatch(input) ? "Thank you!" :
      $"This is not a valid age: {input}"); 
    

注意以下关于代码的说明:

  • @字符关闭了在string中使用转义字符的能力。转义字符以反斜杠为前缀。例如,\t表示制表符,\n表示换行符。在编写正则表达式时,我们需要禁用此功能。用电视剧《白宫风云》的话来说,“让反斜杠回归反斜杠。”

  • 一旦使用@禁用转义字符,它们就可以被正则表达式解释。例如,\d表示数字。你将在本主题的后面学习到更多以反斜杠为前缀的正则表达式符号。

  1. 运行代码,输入一个整数,例如34作为年龄,并查看以下输出结果:

    Enter your age: 34
    Thank you! 
    
  2. 再次运行代码,输入carrots,并查看以下输出结果:

    Enter your age: carrots
    This is not a valid age: carrots 
    
  3. 再次运行代码,输入bob30smith,并查看以下输出结果:

    Enter your age: bob30smith
    Thank you! 
    

我们使用的正则表达式是\d,它表示一个数字。然而,它并没有指定在这个数字前后可以输入什么。这个正则表达式可以用英语描述为:“输入任何你想要的字符,只要至少输入一个数字字符。”

在正则表达式中,你使用插入符^符号来指示输入的开始,使用美元符$来指示输入的结束。让我们使用这些符号来表示我们期望输入的开始和结束之间除了一个数字之外没有其他任何内容。

  1. 添加一个^和一个$将正则表达式更改为^\d$,如下所示(高亮显示):

    Regex ageChecker = new(@"**^**\d**$**"); 
    
  2. 再次运行代码并注意它拒绝除单个数字之外的所有输入。

  3. \d表达式后添加一个+来修改其含义为“一个或多个数字”,如下所示(高亮显示):

    Regex ageChecker = new(@"^\d**+**$"); 
    
  4. 再次运行代码并注意正则表达式只允许零个或多个任意长度的整数。

正则表达式性能改进

用于处理正则表达式的.NET 类型在整个.NET 平台以及许多使用它构建的应用程序中都被使用。因此,它们对性能有重大影响。然而,直到.NET 5,它们都没有得到微软太多的优化关注。

在 .NET 5 及更高版本中,System.Text.RegularExpressions 命名空间中的类型已重写实现以挤出最大性能。使用 IsMatch 等方法进行的常见正则表达式基准测试现在快五倍。最好的是,你不需要更改代码就能获得这些好处!

在 .NET 7 及更高版本中,Regex 类的 IsMatch 方法现在有一个针对 ReadOnlySpan<char> 的重载,作为其输入,这提供了更好的性能。

理解正则表达式的语法

你可以在正则表达式中使用的某些常见符号在 表 8.6 中显示:

符号 含义 符号 含义
^ 输入的开始 ` 符号
--- --- --- ---
输入的结束
\d 单个数字 \D 单个 数字
\s 空白字符 \S 空白字符
\w 单词字符 \W 单词字符
[A-Za-z0-9] 字符范围 \^ ^ (上箭头) 字符
[aeiou] 字符集合 [^aeiou] 不在字符集合中
. 任意单个字符 \. . (点) 字符

表 8.6:常见的正则表达式符号

此外,一些影响正则表达式中先前符号的常见正则表达式量词在 表 8.7 中显示:

符号 含义 符号 含义
+ 一个或多个 ? 一个或没有
{3} 精确三个 {3,5} 三个到五个
{3,} 至少三个 {,3} 最多三个

表 8.7:常见的正则表达式量词

正则表达式示例

一些正则表达式的示例及其含义的描述在 表 8.8 中显示:

表达式 含义
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
`Bob 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
输入末尾的单词“Bob”
`^\d 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
精确两个数字
`[1] 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
精确两个数字
`[2] 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
仅 ASCII 字符集中至少有四个大写英文字母
`[3] 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
仅 ASCII 字符集中至少有四个大写或小写英文字母
`[4]{2}\d 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
仅 ASCII 字符集中的两个大写英文字母和三个数字
`[5]+ 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
至少有一个 ASCII 字符集中的大写或小写英文字母或 Unicode 字符集中的欧洲字母,如下所示:ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ``Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿıŒœŠšŸŽž
`^d.g 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
字母 d,然后是任意字符,然后是字母 g,因此它可以匹配 digdogdg 之间的任意单个字符
`^d.g 表达式
--- ---
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词“Bob”
字母 d,然后是一个点 .,然后是字母 g,因此它只会匹配 d.g

表 8.8:带有其含义描述的正则表达式示例

良好实践:使用正则表达式验证用户输入。相同的正则表达式可以在其他语言中重用,例如 JavaScript 和 Python。

可以在以下链接找到一个方便的网站来构建和测试正则表达式:regex101.com/

分割复杂的以逗号分隔的字符串

在本章的早期部分,你学习了如何分割一个简单的以逗号分隔的字符串变量。但关于以下电影标题的例子又该如何处理呢?

"Monsters, Inc.","I, Tonya","Lock, Stock and Two Smoking Barrels" 

string值在每个电影标题周围使用双引号。我们可以使用这些引号来识别我们是否需要在逗号(或不是)上分割。Split方法不够强大,因此我们可以使用正则表达式。

良好实践:你可以在以下链接中阅读关于这个任务的 Stack Overflow 文章的更完整解释:stackoverflow.com/questions/18144431/regex-to-split-a-csv

为了在string值中包含双引号,我们在它们前面加上反斜杠,或者我们可以在 C# 11 或更高版本中使用原始字符串字面量功能。

  1. 添加语句以存储一个复杂的以逗号分隔的string变量,然后使用Split方法以愚蠢的方式分割它,如下面的代码所示:

    // C# 1 to 10: Use escaped double-quote characters \"
    // string films = "\"Monsters, Inc.\",\"I, Tonya\",\"Lock, Stock and Two Smoking Barrels\"";
    // C# 11 or later: Use """ to start and end a raw string literal
    string films = """
    "Monsters, Inc.","I, Tonya","Lock, Stock and Two Smoking Barrels"
    """;
    WriteLine($"Films to split: {films}");
    string[] filmsDumb = films.Split(',');
    WriteLine("Splitting with string.Split method:");
    foreach (string film in filmsDumb)
    {
      WriteLine($"  {film}");
    } 
    
  2. 添加语句以定义一个正则表达式来智能地分割并写入电影标题,如下面的代码所示:

    Regex csv = new(
      "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)");
    MatchCollection filmsSmart = csv.Matches(films);
    WriteLine("Splitting with regular expression:");
    foreach (Match film in filmsSmart)
    {
      WriteLine($"  {film.Groups[2].Value}");
    } 
    

在后面的章节中,你将看到如何获取一个源生成器来自动生成用于正则表达式的 XML 注释,以解释其工作原理。这对于你可能从网站上复制过来的正则表达式来说非常有用。

  1. 运行代码并查看结果,如下面的输出所示:

    Splitting with string.Split method:
      "Monsters
       Inc."
      "I
       Tonya"
      "Lock
       Stock and Two Smoking Barrels"
    Splitting with regular expression:
      Monsters, Inc.
      I, Tonya
      Lock, Stock and Two Smoking Barrels 
    

激活正则表达式语法着色

如果你使用 Visual Studio 作为代码编辑器,那么你可能已经注意到,当将string值传递给Regex构造函数时,你会看到颜色语法高亮显示,如图 8.1所示:

图 8.1:使用 Regex 构造函数时正则表达式颜色语法高亮显示

这将是提醒打印书籍读者的大好时机,他们只能看到前面的灰度图,他们可以在以下链接中看到所有图的全彩 PDF:packt.link/gbp/9781837635870

为什么这个string在大多数string值中不会进行语法着色,而它会呢?让我们找出答案:

  1. 右键点击new构造函数,选择转到实现,注意名为patternstring参数被一个名为StringSyntax的属性装饰,该属性传递了具有Regex常量值的string,如下面的代码所示,高亮显示:

    public Regex(**[StringSyntax(StringSyntaxAttribute.Regex****)]** **string** **pattern**) :
      this(pattern, culture: null)
    {
    } 
    
  2. 右键点击StringSyntax属性,选择转到实现,注意有 12 种已识别的string语法格式可供选择,以及Regex,如下面的部分代码所示:

    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    public sealed class StringSyntaxAttribute : Attribute
    {
      public const string CompositeFormat = "CompositeFormat";
      public const string DateOnlyFormat = "DateOnlyFormat";
      public const string DateTimeFormat = "DateTimeFormat";
      public const string EnumFormat = "EnumFormat";
      public const string GuidFormat = "GuidFormat";
      public const string Json = "Json";
      public const string NumericFormat = "NumericFormat";
      public const string Regex = "Regex";
      public const string TimeOnlyFormat = "TimeOnlyFormat";
      public const string TimeSpanFormat = "TimeSpanFormat";
      public const string Uri = "Uri";
      public const string Xml = "Xml";
      …
    } 
    
  3. WorkingWithRegularExpressions 项目中,添加一个名为 Program.Strings.cs 的新类文件,删除任何现有语句,然后在部分 Program 类中定义一些 string 常量,如下所示:

    partial class Program
    {
      private const string DigitsOnlyText = @"^\d+$";
      private const string CommaSeparatorText =
        "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)";
    } 
    

注意,这两个 string 常量还没有任何颜色语法高亮。

  1. Program.cs 文件中,将字面量 string 替换为仅包含数字的正则表达式的 string 常量,如下所示,代码中已高亮显示:

    Regex ageChecker = new(**DigitsOnlyText**); 
    
  2. Program.cs 文件中,将字面量 string 替换为逗号分隔的正则表达式的 string 常量,如下所示,代码中已高亮显示:

    Regex csv = new(**CommaSeparatorText**); 
    
  3. 运行 WorkingWithRegularExpressions 项目并确认正则表达式的行为与之前相同。

  4. Program.Strings.cs 文件中,导入 [StringSyntax] 属性的命名空间,然后将其装饰在两个 string 常量上,如下所示,代码中已高亮显示:

    **using** **System.Diagnostics.CodeAnalysis;** **// To use [StringSyntax].**
    partial class Program
    {
     **[****StringSyntax(StringSyntaxAttribute.Regex)****]**
      private const string DigitsOnlyText = @"^\d+$";
     **[****StringSyntax(StringSyntaxAttribute.Regex)****]**
      private const string CommaSeparatorText =
        "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)";
    } 
    
  5. Program.Strings.cs 文件中,添加另一个用于格式化日期的 string 常量,如下所示:

    [StringSyntax(StringSyntaxAttribute.DateTimeFormat)]
    private const string FullDateTime = ""; 
    
  6. 在空字符串中单击,输入字母 d,并注意 IntelliSense 说明,如图 8.2 所示:

图片

图 8.2:由于 StringSyntax 属性激活的 IntelliSense

  1. 完成输入日期格式,并随着您输入,注意 IntelliSense 值:dddd, d MMMM yyyy

  2. 在内部,在 DigitsOnlyText 字面量字符串的末尾输入 \,并注意 IntelliSense 帮助您编写有效的正则表达式,如图 8.3 所示:

图片

图 8.3:编写正则表达式的 IntelliSense

  1. 删除您输入的 \ 以触发 IntelliSense。

[StringSyntax] 属性是在 .NET 7 中引入的功能。它是否被识别取决于您的代码编辑器。.NET BCL 现在有超过 350 个参数、属性和字段被这个属性装饰。

使用源生成器提高正则表达式性能

当您将字符串字面量或字符串常量传递给 Regex 构造函数时,该类会解析字符串并将其转换为内部树结构,以优化方式表示表达式,以便正则表达式解释器可以高效地执行。

您也可以通过指定 RegexOptions 来编译正则表达式,如下所示:

Regex ageChecker = new(DigitsOnlyText, RegexOptions.Compiled); 

不幸的是,编译会有负面影响,会减慢正则表达式初始创建的速度。在创建出将被解释器执行的树结构之后,编译器必须将树转换为 IL 代码,然后该 IL 代码需要被 JIT 编译成原生代码。如果你只运行正则表达式几次,那么编译它并不值得,这也是为什么它不是默认行为的原因。如果你运行正则表达式超过几次,例如,因为它将被用来验证网站每个传入的 HTTP 请求的 URL,那么编译它是有意义的。但即使如此,你也应该只在必须使用.NET 6 或更早版本的情况下使用编译。

.NET 7 引入了一个用于正则表达式的源生成器,它可以识别你是否使用[GeneratedRegex]属性装饰了一个返回Regex的部分方法。它生成一个实现该方法的实现,该实现实现了正则表达式的逻辑。.NET 9 允许你使用部分属性,它具有更自然的语法。

让我们看看实际效果:

  1. WorkingWithRegularExpressions项目中,添加一个名为Program.Regexs.cs的新类文件,并修改其内容以定义一些partial属性,如下面的代码所示:

    using System.Text.RegularExpressions; // To use [GeneratedRegex].
    partial class Program
    {
      [GeneratedRegex(DigitsOnlyText, RegexOptions.IgnoreCase)]
      private static partial Regex DigitsOnly { get; }
      [GeneratedRegex(CommaSeparatorText, RegexOptions.IgnoreCase)]
      private static partial Regex CommaSeparator { get; }
    } 
    
  2. Program.cs中,将new构造函数替换为调用返回仅数字的正则表达式的partial方法,如下面高亮显示的代码所示:

    Regex ageChecker = **DigitsOnly**; 
    
  3. Program.cs中,将新的构造函数替换为调用返回逗号分隔正则表达式的partial方法,如下面高亮显示的代码所示:

    Regex csv = **CommaSeparator**; 
    
  4. 将鼠标指针悬停在partial方法上,注意工具提示描述了正则表达式的行为,如图图 8.4所示:

图片

图 8.4:部分方法的工具提示显示了正则表达式的描述

  1. 右键点击DigitsOnly partial方法,选择转到定义,并注意你可以查看自动生成的部分方法的实现,如图图 8.5所示:

图片

图 8.5:正则表达式的自动生成源代码

  1. 运行项目并确认功能与之前相同。

你可以在以下链接中了解更多关于.NET 7 对正则表达式改进的信息:devblogs.microsoft.com/dotnet/regular-expression-improvements-in-dotnet-7

在集合中存储多个对象

另一种最常见的数据类型是集合。如果你需要在一个变量中存储多个值,那么你可以使用集合。

集合是在内存中可以以不同方式管理多个项的数据结构,尽管所有集合都有一些共享的功能。

在.NET 中用于处理集合的最常见类型如表表 8.9所示:

命名空间 示例类型 描述
System .Collections IEnumerable,IEnumerable<T> 集合使用的接口和基类。
System .Collections .Generic List<T>,Dictionary<T>,Queue<T>,Stack<T> 在 C# 2.0 和 .NET Framework 2.0 中引入。这些集合允许你使用泛型类型参数指定你想要存储的类型(这更安全、更快、更高效)。
System .Collections .Concurrent BlockingCollection,ConcurrentDictionary,ConcurrentQueue 这些集合在多线程场景中是安全的。
System .Collections .Immutable ImmutableArray,ImmutableDictionary,ImmutableList,ImmutableQueue 设计用于原始集合的内容永远不会更改的场景,尽管它们可以创建作为新实例的修改后的集合。

表 8.9:常见的 .NET 集合类型

所有集合的共同特性

所有集合都实现了 ICollection 接口;这意味着它们必须有一个 Count 属性来告诉你它们中有多少对象,以及三个其他成员,如下面的代码所示:

namespace System.Collections;
public interface ICollection : IEnumerable
{
  int Count { get; }
  bool IsSynchronized { get; }
  object SyncRoot { get; }
  void CopyTo(Array array, int index);
} 

例如,如果我们有一个名为 passengers 的集合,我们可以这样做:

int howMany = passengers.Count; 

如你可能推测的那样,CopyTo 将集合复制到数组中。IsSynchronizedSyncRoot 用于多线程场景,因此我在这本书中没有涵盖它们。

所有集合都实现了 IEnumerable 接口,这意味着它们可以使用 foreach 语句进行迭代。它们必须有一个 GetEnumerator 方法,该方法返回一个实现 IEnumerator 的对象;这意味着返回的 object 必须有 MoveNextReset 方法来遍历集合,以及一个包含集合中当前项目的 Current 属性,如下面的代码所示:

namespace System.Collections;
public interface IEnumerable
{
  IEnumerator GetEnumerator();
}
public interface IEnumerator
{
  object Current { get; }
  bool MoveNext();
  void Reset();
} 

例如,要对 passengers 集合中的每个对象执行操作,我们可以编写以下代码:

foreach (Passenger p in passengers)
{
  // Perform an action on each passenger.
} 

除了基于 object 的集合接口之外,还有一个泛型集合接口,其中泛型类型定义了集合中存储的类型。它具有额外的成员,如 IsReadOnlyAddClearContainsRemove,如下面的代码所示:

namespace System.Collections.Generic;
public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
  int Count { get; }
  bool IsReadOnly { get; }
  void Add(T item);
  void Clear();
  bool Contains(T item);
  void CopyTo(T[] array, int index);
  bool Remove(T item);
} 

与列表一起工作

列表,即实现 IList<T> 的类型,是有序集合,有一个 int 索引来显示项目在列表中的位置,如下面的代码所示:

namespace System.Collections.Generic;
[DefaultMember("Item")] // aka "this" indexer.
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
  T this[int index] { get; set; }
  int IndexOf(T item);
  void Insert(int index, T item);
  void RemoveAt(int index);
} 

[DefaultMember] 属性允许你在未指定成员名称时指定哪个成员被默认访问。要将 IndexOf 设置为默认成员,你可以使用 [DefaultMember("IndexOf")]。要指定索引器,你使用 [DefaultMember("Item")]

IList<T>ICollection<T> 派生,因此它有一个 Count 属性,一个将项目添加到集合末尾的 Add 方法,以及一个将项目插入到列表中指定位置的 Insert 方法,还有一个从指定位置删除项目的 RemoveAt 方法。

当你想要手动控制集合中项目的顺序时,列表是一个不错的选择。列表中的每个项目都有一个唯一的索引(或位置),它是自动分配的。项目可以是T定义的任何类型,并且项目可以重复。索引是int类型,从0开始,因此列表中的第一个项目位于索引0,如表 8.10所示:

索引 项目
0 伦敦
1 巴黎
2 伦敦
3 悉尼

表 8.10:具有索引的列表中的城市

如果在伦敦和悉尼之间插入一个新的项目(例如,圣地亚哥),那么悉尼的索引将自动增加。因此,你必须意识到在插入或删除项目后,项目的索引可能会发生变化,如表 8.11所示:

索引 项目
0 伦敦
1 巴黎
2 伦敦
3 圣地亚哥
4 悉尼

表 8.11:插入项目后的城市列表

良好实践:一些开发者可能会养成在应该使用数组时使用List<T>和其他集合的坏习惯。如果数据在实例化后不会改变大小,请使用数组而不是集合。在你添加和删除项目时,也应该最初使用列表,但一旦你完成对项目的操作,就将其转换为数组。

让我们探索列表:

  1. 使用你喜欢的代码编辑器将一个新的控制台应用程序/ console 项目命名为WorkingWithCollections添加到Chapter08解决方案中。

  2. 添加一个名为Program.Helpers.cs的新类文件。

  3. Program.Helpers.cs中,定义一个部分Program类,其中包含一个泛型方法,用于输出带有标题的T值集合,如下面的代码所示:

    partial class Program
    {
      private static void OutputCollection<T>(
        string title, IEnumerable<T> collection)
      {
        WriteLine($"{title}:");
        foreach (T item in collection)
        {
          WriteLine($"  {item}");
        }
      }
    } 
    
  4. Program.cs中,删除现有的语句,然后添加一些语句来展示一些定义和操作列表的常见方法,如下面的代码所示:

    // Simple syntax for creating a list and adding three items.
    List<string> cities = new();
    cities.Add("London");
    cities.Add("Paris");
    cities.Add("Milan");
    /* Alternative syntax that is converted by the compiler into
       the three Add method calls above.
    List<string> cities = new()
      { "London", "Paris", "Milan" }; */
    /* Alternative syntax that passes an array
       of string values to AddRange method.
    List<string> cities = new();
    cities.AddRange(new[] { "London", "Paris", "Milan" }); */
    OutputCollection("Initial list", cities);
    WriteLine($"The first city is {cities[0]}.");
    WriteLine($"The last city is {cities[cities.Count - 1]}.");
    WriteLine($"Are all cities longer than four characters? {
      cities.TrueForAll(city => city.Length > 4)}.");
    WriteLine($"Do all cities contain the character 'e'? {
      cities.TrueForAll(city => city.Contains('e'))}.");
    cities.Insert(0, "Sydney");
    OutputCollection("After inserting Sydney at index 0", cities);
    cities.RemoveAt(1);
    cities.Remove("Milan");
    OutputCollection("After removing two cities", cities); 
    
  5. 运行代码并查看结果,如下面的输出所示:

    Initial list:
      London
      Paris
      Milan
    The first city is London.
    The last city is Milan.
    Are all cities longer than four characters? True.
    Do all cities contain the character 'e'? False.
    After inserting Sydney at index 0:
      Sydney
      London
      Paris
      Milan
    After removing two cities:
      Sydney
      Paris 
    

良好实践:LINQ 有名为All()Count()的扩展方法,分别像List<T>类的TrueForAll()方法和Count属性一样工作。通常,使用类提供的方法而不是更通用的 LINQ 方法可以获得更好的性能。

使用字典

当每个(或对象)都有一个唯一的子值(或一个虚构的值)可以用作来快速在集合中查找值时,字典是一个不错的选择。键必须是唯一的。例如,如果你正在存储人员列表,你可以选择使用政府颁发的身份证号码作为键。在其他语言如 Python 和 Java 中,字典被称为哈希表

将键想象成现实世界字典中的索引条目。它允许你快速找到单词的定义,因为单词(换句话说,键)是按顺序排列的;如果我们知道我们正在寻找海牛的定义,我们将跳到字典的中间开始查找,因为字母m在字母表的中间。

在编程中,字典在查找内容时同样智能。它们必须实现IDictionary<TKey, TValue>接口,如下面的代码所示:

namespace System.Collections.Generic;
[DefaultMember("Item")] // aka "this" indexer.
public interface IDictionary<TKey, TValue>
  : ICollection<KeyValuePair<TKey, TValue>>,
    IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable
{
  TValue this[TKey key] { get; set; }
  ICollection<TKey> Keys { get; }
  ICollection<TValue> Values { get; }
  void Add(TKey key, TValue value);
  bool ContainsKey(TKey key);
  bool Remove(TKey key);
  bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value);
} 

字典中的项目是struct类型的实例,也称为值类型KeyValuePair<TKey, TValue>,其中TKey是键的类型,TValue是值的类型,如下面的代码所示:

namespace System.Collections.Generic;
public readonly struct KeyValuePair<TKey, TValue>
{
  public KeyValuePair(TKey key, TValue value);
  public TKey Key { get; }
  public TValue Value { get; }
  [EditorBrowsable(EditorBrowsableState.Never)]
  public void Deconstruct(out TKey key, out TValue value);
  public override string ToString();
} 

一个Dictionary<string, Person>示例使用string作为键,Person实例作为值。Dictionary<string, string>使用string作为两个值,如下表 8.12 所示:

BSA Bob Smith
MW Max Williams
BSB Bob Smith
AM Amir Mohammed

表 8.12:具有键和值的字典

让我们探索字典:

  1. Program.cs的顶部,为Dictionary<TKey, TValue>类定义一个别名,其中TKeyTValue都是string,如下面的代码所示:

    // Define an alias for a dictionary with string key and string value.
    using StringDictionary = System.Collections.Generic.Dictionary<string, string>; 
    
  2. Program.cs中添加一些语句来展示一些与字典一起工作的常见方式,例如查找单词定义,如下面的代码所示:

    // Declare a dictionary without the alias.
    // Dictionary<string, string> keywords = new();
    // Use the alias to declare the dictionary.
    StringDictionary keywords = new();
    // Add using named parameters.
    keywords.Add(key: "int", value: "32-bit integer data type");
    // Add using positional parameters.
    keywords.Add("long", "64-bit integer data type");
    keywords.Add("float", "Single precision floating point number");
    /* Alternative syntax; compiler converts this to calls to Add method.
    Dictionary<string, string> keywords = new()
    {
      { "int", "32-bit integer data type" },
      { "long", "64-bit integer data type" },
      { "float", "Single precision floating point number" },
    }; */
    /* Alternative syntax; compiler converts this to calls to Add method.
    Dictionary<string, string> keywords = new()
    {
      ["int"] = "32-bit integer data type",
      ["long"] = "64-bit integer data type",
      ["float"] = "Single precision floating point number",
    }; */
    OutputCollection("Dictionary keys", keywords.Keys);
    OutputCollection("Dictionary values", keywords.Values);
    WriteLine("Keywords and their definitions:");
    foreach (KeyValuePair<string, string> item in keywords)
    {
      WriteLine($"  {item.Key}: {item.Value}");
    }
    // Look up a value using a key.
    string key = "long";
    WriteLine($"The definition of {key} is {keywords[key]}."); 
    

在字典中添加到第三个项目的尾随逗号是可选的,编译器不会对此提出异议。这样做很方便,这样你就可以在不删除和添加逗号的情况下更改三个项目的顺序。

  1. 运行代码并查看结果,如下面的输出所示:

    Dictionary keys:
      int
      long
      float
    Dictionary values:
      32-bit integer data type
      64-bit integer data type
      Single precision floating point number
    Keywords and their definitions:
      int: 32-bit integer data type
      long: 64-bit integer data type
      float: Single precision floating point number
    The definition of long is 64-bit integer data type 
    

在第十一章中,你将学习如何使用 LINQ 方法,如ToDictionaryToLookup,从现有数据源创建字典和查找,例如数据库中的表。这比手动向字典中添加项目更为常见,如本节所示。

集合、栈和队列

集合是在你想要在两个集合之间执行集合操作时的一个好选择。例如,你可能有两个城市名称的集合,并且想知道哪些名称同时出现在两个集合中(称为集合的交集)。集合中的元素必须是唯一的。

常见集合方法如表 8.13 所示:

方法 描述
Add 如果项目尚未存在于集合中,则将其添加。如果项目已添加,则返回true,如果项目已在集合中,则返回false
ExceptWith 这将从参数传递的集合中删除项目。
IntersectWith 这将从参数传递的集合和集合中删除不在集合中的项目。
IsProperSubsetOf, IsProperSupersetOf, IsSubsetOf, IsSupersetOf 子集是一个包含其他集合中所有元素的集合。真子集是一个包含其他集合中所有元素但至少有一个元素不在该集合中的集合。超集是一个包含其他集合中所有元素的集合。真超集是一个包含其他集合中所有元素并且至少有一个元素不在其他集合中的集合。
Overlaps 该集合与另一个集合至少共享一个共同的项目。
SetEquals 该集合与另一个集合包含完全相同的项。
SymmetricExceptWith 这将从集合中移除作为参数传递的集合中不存在的项,并添加任何缺失的项。
UnionWith 这会将作为参数传递给集合的任何不在集合中的项添加到集合中。

表 8.13:集合方法

让我们探索集合的示例代码:

  1. Program.cs中,添加一些语句来向集合中添加项,如下面的代码所示:

    HashSet<string> names = new();
    foreach (string name in
      new[] { "Adam", "Barry", "Charlie", "Barry" })
    {
      bool added = names.Add(name);
      WriteLine($"{name} was added: {added}.");
    }
    WriteLine($"names set: {string.Join(',', names)}."); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Adam was added: True.
    Barry was added: True.
    Charlie was added: True.
    Barry was added: False.
    names set: Adam,Barry,Charlie. 
    

你将在第十一章中看到更多的集合操作,查询和操作数据使用 LINQ

是当你想要实现后进先出LIFO)行为时的一个不错的选择。使用栈,你只能直接访问或移除栈顶的一个项,尽管你可以枚举来读取整个栈中的项。例如,你不能直接访问栈中的第二个项。

例如,文字处理器使用栈来记住你最近执行的操作序列,然后,当你按下Ctrl + Z时,它将撤销栈中的最后一个操作,然后是倒数第二个操作,依此类推。

队列是当你想要实现先进先出FIFO)行为时的一个不错的选择。使用队列,你只能直接访问或移除队列前端的项,尽管你可以枚举来读取整个队列中的项。例如,你不能直接访问队列中的第二个项。

例如,后台进程使用队列按到达顺序处理工作项,就像人们在邮局排队一样。

.NET 6 引入了PriorityQueue,其中队列中的每个项都有一个优先级值,以及其在队列中的位置。

让我们探索队列的示例代码:

  1. Program.cs中,添加一些语句来展示一些与队列一起工作的常见方式,例如处理咖啡店中的排队顾客,如下面的代码所示:

    Queue<string> coffee = new();
    coffee.Enqueue("Damir"); // Front of the queue.
    coffee.Enqueue("Andrea");
    coffee.Enqueue("Ronald");
    coffee.Enqueue("Amin");
    coffee.Enqueue("Irina"); // Back of the queue.
    OutputCollection("Initial queue from front to back", coffee);
    // Server handles next person in queue.
    string served = coffee.Dequeue();
    WriteLine($"Served: {served}.");
    // Server handles next person in queue.
    served = coffee.Dequeue();
    WriteLine($"Served: {served}.");
    OutputCollection("Current queue from front to back", coffee);
    WriteLine($"{coffee.Peek()} is next in line.");
    OutputCollection("Current queue from front to back", coffee); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Initial queue from front to back:
      Damir
      Andrea
      Ronald
      Amin
      Irina
    Served: Damir.
    Served: Andrea.
    Current queue from front to back:
      Ronald
      Amin
      Irina
    Ronald is next in line.
    Current queue from front to back:
      Ronald
      Amin
      Irina 
    
  3. Program.Helpers.cs中,在部分Program类中,添加一个名为OutputPQ的静态方法,如下面的代码所示:

    private static void OutputPQ<TElement, TPriority>(string title,
      IEnumerable<(TElement Element, TPriority Priority)> collection)
    {
      WriteLine($"{title}:");
      foreach ((TElement, TPriority) item in collection)
      {
        WriteLine($"  {item.Item1}: {item.Item2}");
      }
    } 
    

注意,OutputPQ方法是一个泛型方法。你可以指定作为collection传入的元组中使用的两个类型。

  1. Program.cs中,添加一些语句来展示一些与优先队列一起工作的常见方式,如下面的代码所示:

    PriorityQueue<string, int> vaccine = new();
    // Add some people.
    // 1 = High priority people in their 70s or poor health.
    // 2 = Medium priority e.g. middle-aged.
    // 3 = Low priority e.g. teens and twenties.
    vaccine.Enqueue("Pamela", 1);
    vaccine.Enqueue("Rebecca", 3);
    vaccine.Enqueue("Juliet", 2);
    vaccine.Enqueue("Ian", 1);
    OutputPQ("Current queue for vaccination", vaccine.UnorderedItems);
    WriteLine($"{vaccine.Dequeue()} has been vaccinated.");
    WriteLine($"{vaccine.Dequeue()} has been vaccinated.");
    OutputPQ("Current queue for vaccination", vaccine.UnorderedItems);
    WriteLine($"{vaccine.Dequeue()} has been vaccinated.");
    WriteLine("Adding Mark to queue with priority 2.");
    vaccine.Enqueue("Mark", 2);
    WriteLine($"{vaccine.Peek()} will be next to be vaccinated.");
    OutputPQ("Current queue for vaccination", vaccine.UnorderedItems); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Current queue for vaccination:
      Pamela: 1
      Rebecca: 3
      Juliet: 2
      Ian: 1
    Pamela has been vaccinated.
    Ian has been vaccinated.
    Current queue for vaccination:
      Juliet: 2
      Rebecca: 3
    Juliet has been vaccinated.
    Adding Mark to queue with priority 2
    Mark will be next to be vaccinated.
    Current queue for vaccination:
      Mark: 2
      Rebecca: 3 
    

.NET 9 引入了一个Remove方法到PriorityQueue。该方法在堆中的每个元素上执行线性时间扫描,移除与指定元素参数匹配的第一个出现。如果有重复条目,该方法将以非确定性的方式移除其中一个,而不考虑任何优先级。

集合添加和移除方法

每个集合都有不同的方法来“添加”和“移除”项目,如表 8.14 所示:

集合 “添加”方法 “移除”方法 描述
List Add, Insert Remove, RemoveAt 列表是有序的,因此项目具有整数索引位置。Add将在列表末尾添加新项目。Insert将在指定的索引位置添加新项目。
Dictionary Add Remove 字典是无序的,因此项目没有整数索引位置。您可以通过调用ContainsKey方法来检查是否使用了键。
Stack Push Pop 栈始终使用Push方法在栈顶添加新项目。第一个项目位于栈底。始终使用Pop方法从栈顶移除项目。调用Peek方法可以查看此值而不移除它。栈是 LIFO(后进先出)。
Queue Enqueue Dequeue 队列始终使用Enqueue方法在队列末尾添加新项目。第一个项目位于队列前端。始终使用Dequeue方法从队列前端移除项目。调用Peek方法可以查看此值而不移除它。队列是 FIFO(先进先出)。

表 8.14:集合“添加”和“移除”方法

排序集合

List<T>类可以通过手动调用其Sort方法进行排序(但请记住,每个项目的索引将发生变化)。手动对字符串值或其他内置类型的列表进行排序不需要您额外努力,但如果您创建了自己的类型集合,那么该类型必须实现名为IComparable的接口。您在第六章实现接口和继承类中学到了如何做到这一点。

Stack<T>Queue<T>集合无法排序,因为您通常不希望有这种功能;例如,您可能永远不会对酒店入住的客人队列进行排序。但有时,您可能想要对字典或集合进行排序。

有时,拥有一个自动排序的集合会很有用,即一个在添加和移除项目时保持排序顺序的集合。

可供选择的自动排序集合有多种。这些排序集合之间的差异通常很微妙,但可能会影响您应用程序的内存需求和性能,因此值得花精力选择最适合您需求的选项。

自从.NET 的早期版本以来,OrderedDictionary类型就支持这一点,但它不是泛型的,键和值的数据类型为System.Object。.NET 9 引入了备受期待的OrderedDictionary<TKey, TValue>集合,为这些场景提供了一个高效且泛型的解决方案。

一些其他常见的自动排序集合如表 8.15 所示:

集合 描述
SortedDictionary<TKey, TValue> 这代表了一个按键排序的键值对集合。内部,它维护一个二叉树来存储项目。
SortedList<TKey, TValue> 这代表了一个按键排序的键值对集合。名称具有误导性,因为这实际上不是一个列表。与 SortedDictionary<TKey, TValue> 相比,检索性能相似,它使用的内存更少,对于未排序数据,插入和删除操作较慢。如果它从排序数据中填充,则速度更快。内部,它使用二分搜索来维护一个排序数组以查找元素。
SortedSet<T> 这代表了一个按顺序维护的唯一对象集合。

表 8.15:常见的自动排序集合

专用集合

对于一些特殊的情况,还有一些其他的集合。

System.Collections.BitArray 集合管理一个紧凑的位值数组,这些位值以布尔值表示,其中 true 表示位处于开启状态(值为 1),而 false 表示位处于关闭状态(值为 0)。

System.Collections.Generics.LinkedList<T> 集合表示一个双向链表,其中每个项目都有一个对其前一个和下一个项目的引用。与 List<T> 相比,在需要频繁从列表中间插入和删除项目的情况下,它们提供了更好的性能。在 LinkedList<T> 中,项目不需要在内存中进行重新排列。

只读、不可变和冻结的集合

当我们查看泛型集合接口时,我们看到它有一个名为 IsReadOnly 的属性。当我们想要将一个集合传递给一个方法但不允许它进行更改时,这很有用。

例如,我们可能定义一个如下所示的方法:

void ReadCollection<T>(ICollection<T> collection)
{
  // We can check if the collection is read-only.
  if (collection.IsReadOnly)
  {
    // Read the collection.
  }
  else
  {
    WriteLine("You have given me a collection that I could change!");
  }
} 

泛型集合,如 List<T>Dictionary<TKey, TValue>,有一个 AsReadOnly 方法来创建一个 ReadOnlyCollection<T>,该集合引用原始集合。尽管 ReadOnlyCollection<T> 必须实现 ICollection<T> 接口,因此必须有一个 Add 和一个 Remove 方法,但它抛出一个 NotImplementedException 以防止更改。

如果原始集合有项目被添加或删除,ReadOnlyCollection<T> 将看到这些更改。你可以将 ReadOnlyCollection<T> 视为一个集合的保护视图。

让我们看看我们如何确保一个集合是只读的:

  1. WorkingWithCollections 项目中,在 Program.Helpers.cs 文件中,添加一个方法,该方法应仅接受一个只读字典,其键和值的类型为 string,但这个淘气的函数试图调用 Add,如下面的代码所示:

    private static void UseDictionary(
      IDictionary<string, string> dictionary)
    {
      WriteLine($"Count before is {dictionary.Count}.");
      try
      {
        WriteLine("Adding new item with GUID values.");
        // Add method with return type of void.
        dictionary.Add(
          key: Guid.NewGuid().ToString(),
          value: Guid.NewGuid().ToString());
      }
      catch (NotSupportedException)
      {
        WriteLine("This dictionary does not support the Add method.");
      }
      WriteLine($"Count after is {dictionary.Count}.");
    } 
    

    注意参数的类型是 IDictionary<TKey, TValue>。使用接口提供了更多的灵活性,因为我们可以传递 Dictionary<TKey, TValue>ReadOnlyDictionary<TKey, TValue> 或实现该接口的任何其他东西。

  2. Program.cs 中,添加语句将 keywords 字典传递给这个淘气的函数,如下面的代码所示:

    UseDictionary(keywords); 
    
  3. 运行代码,查看结果,并注意这次,淘气的方法能够添加一个新的键值对,所以计数增加了,如下所示输出:

    Count before is 3.
    Adding new item with GUID values.
    Count after is 4. 
    
  4. Program.cs 中,注释掉 UseDictionary 语句,然后添加一个语句将转换后的字典传递给只读集合,如下所示代码:

    //UseDictionary(keywords);
    UseDictionary(keywords.AsReadOnly()); 
    
  5. 运行代码,查看结果,并注意这次,方法无法添加项目,所以计数没有变化,如下所示输出:

    Count before is 3.
    Adding new item with GUID values.
    This dictionary does not support the Add method.
    Count after is 3. 
    
  6. Program.cs 的顶部,导入 System.Collections.Immutable 命名空间,如下所示代码:

    using System.Collections.Immutable; // To use ImmutableDictionary<T, T>. 
    
  7. Program.cs 中,注释掉 AsReadOnly 语句,然后添加一个语句将转换后的关键字转换为不可变字典,如下所示代码高亮显示:

    //UseDictionary(keywords.AsReadOnly());
    UseDictionary(keywords.ToImmutableDictionary()); 
    
  8. 运行代码,查看结果,并注意这次,方法也无法添加默认值,所以计数没有变化——这与使用只读集合的行为相同,那么不可变集合有什么用呢?

如果你导入 System.Collections.Immutable 命名空间,那么任何实现 IEnumerable<T> 的集合都将获得六个扩展方法来将其转换为不可变集合,如列表、字典、集合等。

虽然不可变集合将有一个名为 Add 的方法,但它不会将项目添加到原始不可变集合中!相反,它返回一个新的包含新项目的不可变集合。原始不可变集合仍然只包含原始项目。

让我们看看一个例子:

  1. Program.cs 中,添加语句将关键字字典转换为不可变字典,然后通过随机生成 GUID 值向其中添加新的关键字定义,如下所示代码:

    ImmutableDictionary<string, string> immutableKeywords =
      keywords.ToImmutableDictionary();
    // Call the Add method with a return value.
    ImmutableDictionary<string, string> newDictionary =
      immutableKeywords.Add(
        key: Guid.NewGuid().ToString(),
        value: Guid.NewGuid().ToString());
    OutputCollection("Immutable keywords dictionary", immutableKeywords);
    OutputCollection("New keywords dictionary", newDictionary); 
    
  2. 运行代码,查看结果,并注意不可变关键字字典在调用其上的 Add 方法时不会被修改;相反,它返回一个新的字典,包含所有现有关键字加上新添加的关键字,如下所示输出:

    Immutable keywords dictionary:
      [float, Single precision floating point number]
      [long, 64-bit integer data type]
      [int, 32-bit integer data type]
    New keywords dictionary:
      [d0e099ff-995f-4463-ae7f-7b59ed3c8d1d, 3f8e4c38-c7a3-4b20-acb3-01b2e3c86e8c]
      [float, Single precision floating point number]
      [long, 64-bit integer data type]
      [int, 32-bit integer data type] 
    

新增的项目并不总是出现在字典的顶部,如上输出所示。内部,顺序是由键的哈希值定义的。这就是为什么字典有时被称为哈希表。

良好实践:为了提高性能,许多应用程序在中央缓存中存储常用对象的共享副本。为了安全地允许多个线程使用这些对象,并知道它们不会改变,你应该使它们不可变或使用并发集合类型,你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent

泛型集合在设计上存在一些潜在的性能问题。

首先,由于是泛型的,字典中项的类型或用于键和值的类型对性能有很大影响,具体取决于它们是什么。由于它们可以是任何类型,.NET 团队无法优化算法。在现实生活中,stringint 类型是最常用的。如果 .NET 团队能够依赖这些类型始终被使用,那么他们可以大大提高性能。

其次,集合是动态的,这意味着可以在任何时候添加新项,也可以删除现有项。如果 .NET 团队知道集合将不再进行更改,那么可以进行更多的优化。

.NET 8 引入了一个新概念:冻结集合。嗯,我们已经有不可变集合了,那么冻结集合有什么不同呢?它们像冰淇淋一样美味吗?想法是,95% 的时间,集合被填充后就不会再改变。所以,如果我们能在创建时优化它们,那么这些优化就可以实现,这需要一些前期的时间和精力,但之后,读取集合的性能可以得到显著提高。

在 .NET 8 中,只有两种冻结集合:FrozenDictionary<TKey, TValue>FrozenSet<T>。未来版本的 .NET 可能会有更多,但这些都是最常见且能从冻结概念中受益的场景。

让我们开始:

  1. Program.cs 的顶部,导入 System.Collections.Frozen 命名空间,如下所示:

    using System.Collections.Frozen; // To use FrozenDictionary<T, T>. 
    
  2. Program.cs 的底部,添加语句将关键字字典转换为冻结字典,输出其项,然后查找 long 的定义,如下所示:

    // Creating a frozen collection has an overhead to perform the
    // sometimes complex optimizations.
    FrozenDictionary<string, string> frozenKeywords =
      keywords.ToFrozenDictionary();
    OutputCollection("Frozen keywords dictionary", frozenKeywords);
    // Lookups are faster in a frozen dictionary.
    WriteLine($"Define long: {frozenKeywords["long"]}"); 
    
  3. 运行代码并查看结果,如下所示:

    Frozen keywords dictionary:
      [int, 32-bit integer data type]
      [long, 64-bit integer data type]
      [float, Single precision floating point number]
    Define long: 64-bit integer data type 
    

Add 方法的行为取决于类型,以下列表进行了总结:

  • List<T>: 这会在现有列表的末尾添加一个新项。

  • Dictionary<TKey, TValue>: 这会在由其内部结构确定的位位置向现有字典中添加一个新项。

  • ReadOnlyCollection<T>: 这会抛出一个不支持异常。

  • ImmutableList<T>: 这会返回一个新的列表,其中包含新项。这不会影响原始列表。

  • ImmutableDictionary<TKey, TValue>: 这会返回一个新的字典,其中包含新项。这不会影响原始字典。

  • FrozenDictionary<TKey, TValue>: 这不存在。

更多信息:有关冻结集合的文档可以在以下链接找到:learn.microsoft.com/en-us/dotnet/api/system.collections.frozen.

你已经看到你可以创建只读列表和字典,但集合呢?在 .NET 9 中,现在有一个 ReadOnlySet<T> 来解决这个问题。

使用集合表达式初始化集合

C# 12 引入了一种新的初始化数组、集合和 span 变量的统一语法。

在 C# 11 及之前版本中,您必须使用以下代码声明并初始化一个int值的数组、集合或跨度:

int[] numbersArray11 = { 1, 3, 5 };
List<int> numbersList11 = new() { 1, 3, 5 };
Span<int> numbersSpan11 = stackalloc int[] { 1, 3, 5 }; 

从 C# 12 开始,您现在可以一致地使用方括号,编译器将执行正确的事,如下面的代码所示:

int[] numbersArray12 = [ 1, 3, 5 ];
List<int> numbersList12 = [ 1, 3, 5 ];
Span<int> numbersSpan12 = [ 1, 3, 5 ]; 

更多信息:您可以在以下链接中了解更多关于集合表达式的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/collection-expressions

集合表达式不适用于所有集合。例如,它们不适用于字典或多维数组。文档列出了集合表达式可以转换的类型:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/collection-expressions#conversions

使用展开元素

展开元素..可以加在任何可枚举的表达式之前,以便在集合表达式中对其进行评估。例如,任何可以使用foreach枚举的类型,如数组或集合,都可以使用展开元素进行评估。

在集合表达式中使用展开元素..将它的参数替换为该集合中的元素。您可以将展开元素与集合表达式中的单个元素组合使用。

微软官方文档同时使用展开元素展开运算符来指代同一语言特性。我更喜欢元素,因为它在集合表达式中用于表示定义的集合内的元素。

让我们通过以下代码示例看看如何使用展开元素:

int[] row0 = [1, 2, 3];
int[] row1 = [4, 5];
int[] row2 = [6, 7, 8, 9];
// Use the spread element to combine the three arrays and an integer into a single array.
int[] combinedRows = [..row0, ..row1, ..row2, 10];
foreach (int number in combinedRows)
{
  Console.Write($"{number}, ");
} 

输出结果如下:

1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 

更多信息:您可以在以下链接中了解更多关于展开元素的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/collection-expressions#spread-element

警告!请注意不要混淆必须应用于可枚举表达式之前的展开元素..与用于定义Range的范围运算符..。您将在本章下一节中学习关于范围的内容,处理跨度、索引和范围。关于展开元素的设计决策的讨论可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/collection-expressions#drawbacks

集合的良好实践

让我们通过回顾一些你应该遵循的良好实践来结束关于集合的这一部分。

预先调整集合大小

自从.NET 1.1 以来,像StringBuilder这样的类型就有一个名为EnsureCapacity的方法,可以将内部存储数组预先调整为string预期最终大小的数组。这提高了性能,因为它不需要在追加更多字符时反复增加数组的大小。

自从.NET Core 2.1 以来,像Dictionary<T>HashSet<T>这样的类型也拥有了EnsureCapacity

在.NET 6 及以后的版本中,像List<T>Queue<T>Stack<T>这样的集合现在也有一个EnsureCapacity方法,如下面的代码所示:

List<string> names = new();
names.EnsureCapacity(10_000);
// Load ten thousand names into the list. 

将集合传递给方法

假设你需要创建一个处理集合的方法。为了最大灵活性,你可以声明输入参数为IEnumerable<T>并使方法泛型,如下面的代码所示:

void ProcessCollection<T>(IEnumerable<T> collection)
{
  // Process the items in the collection,
  // perhaps using a foreach statement.
} 

我可以将包含任何类型(如intstringPerson或任何实现了IEnumerable<T>的其他类型)的数组、列表、队列或堆栈传递到这个方法中,并且它将处理这些项。然而,将任何集合传递到这个方法的灵活性是以性能成本为代价的。

IEnumerable<T>的性能问题之一也是它的一个优点:延迟执行,也称为懒加载。实现此接口的类型不必实现延迟执行,但许多类型确实实现了。

IEnumerable<T>最糟糕的性能问题是迭代必须在堆上分配一个对象。为了避免这种内存分配,你应该使用具体类型定义你的方法,如下面高亮显示的代码所示:

void ProcessCollection<T>(**List<T>** collection)
{
  // Process the items in the collection,
  // perhaps using a foreach statement.
} 

这将使用List<T>.Enumerator GetEnumerator()方法,它返回一个struct,而不是返回引用类型的IEnumerator<T> GetEnumerator()方法。你的代码将快两到三倍,并且需要的内存更少。与所有与性能相关的建议一样,你应该通过在实际的产品环境中运行性能测试来确认这种好处。

从成员返回集合

集合是引用类型,这意味着它们可以是null。你可能会定义返回null的方法或属性,如下面的代码所示:

public class Vehicle
{
  public List<Person>? GetPassengers()
  {
    ICollection<Person> passengers = GetFromDatabase();
    if (passengers.Count > 0)
    {
      return passengers;
    }
    else
    {
      return null;
    }
  }
  public List<Person>? Passengers
  {
    get
    {
      ICollection<Person> passengers = GetFromDatabase();
      if (passengers.Count > 0)
      {
        return passengers;
      }
      else
      {
        return null;
      }
    }
  }
} 

如果开发者调用返回集合的方法和属性而没有检查null,这可能会导致问题,如下面的代码所示:

var people = car.GetPassengers();
// Accessing people could throw a NullReferenceException!
WriteLine($"There are {people.Count} people.");
foreach (Person p in car.Passengers)
{
  // Process each person.
} 

在实现返回集合的方法和属性时,返回一个空集合或数组,而不是null,如下面的代码所示:

// Return an empty sequence instead.
return Enumerable.Empty<Person>();
// Or an empty array.
return Array,Empty<Person>();
// Or an empty collection expression.
return []; 

与 span、索引和范围一起工作

微软在.NET Core 2.1 中的一个目标是通过Span<T>类型来提高性能和资源使用。

使用 span 有效地使用内存

当操作数组时,你通常会创建现有数组的新的副本或子集,以便只处理子集。这并不高效,因为必须在内存中创建重复的对象。

如果你需要处理数组的子集,请使用 span,因为它就像原始数组的一个窗口。这在内存使用方面更加高效,并提高了性能。范围仅适用于数组,不适用于集合,因为内存必须是连续的。

在我们更详细地查看范围之前,我们需要了解一些相关对象:索引和范围。

使用索引类型识别位置

C# 8 引入了两个特性,用于使用两个索引来识别数组中项的索引位置和项的范围。

你在上一个章节中了解到,列表中的对象可以通过传递一个整数到它们的索引器来访问,如下面的代码所示:

int index = 3;
Person p = people[index]; // Fourth person in array.
char letter = name[index]; // Fourth letter in name. 

Index 值类型是识别位置的一种更正式的方式,它支持从末尾开始计数,如下面的代码所示:

// Two ways to define the same index, 3 in from the start.
Index i1 = new(value: 3); // Counts from the start
Index i2 = 3; // Using implicit int conversion operator.
// Two ways to define the same index, 5 in from the end.
Index i3 = new(value: 5, fromEnd: true);
Index i4 = ⁵; // Using the caret ^ operator. 

使用范围类型识别范围

Range 值类型使用 Index 值来指示其范围的起始和结束,使用其构造函数、C# 语法或其静态方法,如下面的代码所示:

Range r1 = new(start: new Index(3), end: new Index(7));
Range r2 = new(start: 3, end: 7); // Using implicit int conversion.
Range r3 = 3..7; // Using C# 8.0 or later syntax.
Range r4 = Range.StartAt(3); // From index 3 to last index.
Range r5 = 3..; // From index 3 to last index.
Range r6 = Range.EndAt(3); // From index 0 to index 3.
Range r7 = ..3; // From index 0 to index 3. 

已经为 string 值(内部使用 char 数组)、int 数组和范围添加了扩展方法,以便更容易地处理范围。这些扩展方法接受一个范围作为参数,并返回一个 Span<T>。这使得它们非常节省内存。

警告! 范围运算符 ..(随 C# 8 引入)和扩展元素 ..(随 C# 12 引入)看起来相同。在阅读代码时要小心区分它们。范围运算符应始终出现在整数旁边,无论是之前、之后还是两者都有。扩展元素应始终出现在可枚举的命名变量之前。

使用索引、范围和范围

让我们探索使用索引和范围来返回范围:

  1. 使用你喜欢的代码编辑器,向 Chapter08 解决方案中添加一个名为 WorkingWithRanges 的新 Console App / console 项目。

  2. Program.cs 中,删除现有的语句,然后添加语句使用 string 类型的 Substring 方法与范围来提取某人姓名的部分,如下面的代码所示:

    string name = "Samantha Jones";
    // Getting the lengths of the first and last names.
    int lengthOfFirst = name.IndexOf(' ');
    int lengthOfLast = name.Length - lengthOfFirst - 1;
    // Using Substring.
    string firstName = name.Substring(
      startIndex: 0,
      length: lengthOfFirst);
    string lastName = name.Substring(
      startIndex: name.Length - lengthOfLast,
      length: lengthOfLast);
    WriteLine($"First: {firstName}, Last: {lastName}");
    // Using spans.
    ReadOnlySpan<char> nameAsSpan = name.AsSpan();
    ReadOnlySpan<char> firstNameSpan = nameAsSpan[0..lengthOfFirst];
    ReadOnlySpan<char> lastNameSpan = nameAsSpan[^lengthOfLast..];
    WriteLine($"First: {firstNameSpan}, Last: {lastNameSpan}"); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    First: Samantha, Last: Jones
    First: Samantha, Last: Jones 
    

使用范围进行高效的文本处理

在处理文本时,常见的一个问题是 string 值的副本在处理过程中经常在内存中分配。如果可能的话,重用原始值将更加高效。

范围通过创建指向字符串原始部分的指针数据结构来实现这一点。

例如,考虑一个包含整数并用加号分隔的 string 值,需要将其求和:

在.NET 9 及更高版本中,我们现在可以创建一个跨越string值的跨度,然后使用范围来有效地分割整数值并将它们传递给int.Parse方法,如下所示代码:

ReadOnlySpan<char> text = "12+23+456".AsSpan();
int sum = 0;
foreach (Range r in text.Split('+'))
{
  sum += int.Parse(text[r]);
}
WriteLine($"Sum using Split: {sum}"); 

如果你在循环中设置断点,那么请注意名为r的范围对象首先为[0..2],如图8.6所示,然后为{3..5},最后为{6..9}

图 8.6:用于高效分割字符串的跨度范围

范围值有效地定义了进入原始字符串的窗口,而不分配新的内存资源。

练习和探索

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

练习 8.1 – 在线材料

在线材料可能由 Microsoft 或第三方创建,或为本书的额外内容。

与网络资源一起工作

如果你感兴趣于一些用于处理网络资源的低级类型,那么你可以阅读以下链接中找到的在线仅有的部分:

github.com/markjprice/cs13net9/blob/main/docs/ch08-network-resources.md

有一个有用的在线书籍,由 Chris Woodruff 编写,名为超越边界 – 使用 C# 12 和.NET 8 进行网络编程,你可以通过以下链接找到:csharp-networking.com

集合表达式

你可以在以下链接中了解更多关于如何重构现有代码以充分利用集合表达式的信息:

devblogs.microsoft.com/dotnet/refactor-your-code-with-collection-expressions/

练习 8.2 – 练习题

练习题深入探讨了本章的主题。

正则表达式

第八章解决方案中,创建一个名为Exercise_RegularExpressions的控制台应用程序,提示用户输入一个正则表达式,然后提示用户输入一些输入,并比较这两个以匹配,直到用户按下Esc,如下所示输出:

The default regular expression checks for at least one digit.
Enter a regular expression (or press ENTER to use the default): ^[a-z]+$
Enter some input: apples
apples matches ^[a-z]+$? True
Press ESC to end or any key to try again.
Enter a regular expression (or press ENTER to use the default): ^[a-z]+$
Enter some input: abc123xyz
abc123xyz matches ^[a-z]+$? False
Press ESC to end or any key to try again. 

扩展方法

第八章解决方案中,创建一个名为Exercise_NumbersAsWordsLib的类库和测试它的项目。它应该定义扩展方法,这些方法通过名为ToWords的方法扩展数字类型,如BigIntegerint,该方法返回一个描述数字的string

例如,18,000,000将是十八百万,而18,456,002,032,011,000,007将是十八千兆,四百五十六千兆,两万亿,三十二亿,一千一百万和七。

你可以在以下链接中了解更多关于大数命名的内容:en.wikipedia.org/wiki/Names_of_large_numbers

创建项目以使用 xUnit 测试你的类库,并将其作为一个交互式控制台应用程序。

练习 8.3 – 测试你的知识

使用网络回答以下问题:

  1. 可以在 string 变量中存储的最大字符数是多少?

  2. 在什么情况下以及为什么你应该使用 SecureString 类型?

  3. 在什么情况下使用 StringBuilder 类是合适的?

  4. 你应该在什么情况下使用 LinkedList<T> 类?

  5. 你应该在什么情况下使用 SortedDictionary<T> 类而不是 SortedList<T> 类?

  6. 在正则表达式中,$ 代表什么?

  7. 在正则表达式中,如何表示数字?

  8. 为什么你不应该使用官方电子邮件地址标准来创建一个用于验证用户电子邮件地址的正则表达式?

  9. 当以下代码运行时,会输出哪些字符?

    string city = "Aberdeen";
    ReadOnlySpan<char> citySpan = city.AsSpan()[⁵..⁰];
    WriteLine(citySpan.ToString()); 
    
  10. 你如何在调用它之前检查一个网络服务是否可用?

练习 8.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-8---working-with-common-net-types

摘要

在本章中,您探索了:

  • 存储和操作数字的类型选择。

  • 处理文本,包括使用正则表达式验证输入。

  • 用于存储多个项目的集合。

  • 与索引、范围和跨度一起工作。

在下一章中,我们将管理文件和流,编码和解码文本,以及执行序列化。

在 Discord 上了解更多

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

packt.link/csharp13dotnet9

留下评论!

感谢您从 Packt Publishing 购买本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花点时间留下亚马逊评论;这只需一分钟,但对像您这样的读者来说意义重大。

扫描二维码或访问链接,以获得您选择的免费电子书。

packt.link/NzOWQ

一个带有黑色方块的二维码,描述自动生成

第九章:处理文件、流和序列化

本章主要介绍文件和流的读写操作、文本编码以及序列化。不与文件系统交互的应用程序非常罕见。作为一名.NET 开发者,你构建的几乎每个应用程序都需要管理文件系统,并创建、打开、读取和写入文件。大多数这些文件将包含文本,因此了解文本的编码方式非常重要。最后,在内存中处理对象之后,你还需要将它们永久存储起来以供以后重用。你可以使用一种称为序列化的技术来完成这项工作。

本章将涵盖以下主题:

  • 管理文件系统

  • 使用流进行读写操作

  • 文本编码和解码

  • 序列化对象图

管理文件系统

您的应用程序通常需要在不同的环境中对文件和目录执行输入和输出操作。SystemSystem.IO 命名空间包含用于此目的的类。

处理跨平台环境和文件系统

让我们探索如何处理跨平台环境以及 Windows、Linux 和 macOS 之间的差异。Windows、macOS 和 Linux 的路径不同,因此我们将首先探讨 .NET 如何处理这个问题:

  1. 使用您首选的代码编辑器创建一个新项目,如下所示:

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

    • 项目文件和文件夹:WorkingWithFileSystems

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

  2. 在项目文件中,添加对 Spectre.Console 的包引用,然后添加元素以静态和全局方式导入以下类:System.ConsoleSystem.IO.DirectorySystem.IO.PathSystem.Environment,如下所示:

    <ItemGroup>
      <PackageReference Include="Spectre.Console" Version="0.47.0" />
    </ItemGroup>
    <ItemGroup>
      <Using Include="System.Console" Static="true" />
      <Using Include="System.IO.Directory" Static="true" />
      <Using Include="System.IO.Path" Static="true" />
      <Using Include="System.Environment" Static="true" />
    </ItemGroup> 
    
  3. 构建用于恢复包的 WorkingWithFileSystems 项目。

  4. 添加一个名为 Program.Helpers.cs 的新类文件。

  5. Program.Helpers.cs 中,添加一个部分 Program 类,包含一个 SectionTitle 方法,如下所示:

    // null namespace to merge with auto-generated Program.
    partial class Program
    {
      private static void SectionTitle(string title)
      {
        WriteLine();
        ConsoleColor previousColor = ForegroundColor;
        // Use a color that stands out on your system.
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title} ***");
        ForegroundColor = previousColor;
      }
    } 
    
  6. Program.cs 中,添加语句以使用 Spectre.Console 表格执行以下操作:

    • 输出路径和目录分隔符。

    • 输出当前目录的路径。

    • 输出系统文件、临时文件和文档的一些特殊路径:

    using Spectre.Console; // To use Table.
    #region Handling cross-platform environments and filesystems
    SectionTitle("Handling cross-platform environments and filesystems");
    // Create a Spectre Console table.
    Table table = new();
    // Add two columns with markup for colors.
    table.AddColumn("[blue]MEMBER[/]");
    table.AddColumn("[blue]VALUE[/]");
    // Add rows.
    table.AddRow("Path.PathSeparator", PathSeparator.ToString());
    table.AddRow("Path.DirectorySeparatorChar",
      DirectorySeparatorChar.ToString());
    table.AddRow("Directory.GetCurrentDirectory()",
      GetCurrentDirectory());
    table.AddRow("Environment.CurrentDirectory", CurrentDirectory);
    table.AddRow("Environment.SystemDirectory", SystemDirectory);
    table.AddRow("Path.GetTempPath()", GetTempPath());
    table.AddRow("");
    table.AddRow("GetFolderPath(SpecialFolder", "");
    table.AddRow("  .System)", GetFolderPath(SpecialFolder.System));
    table.AddRow("  .ApplicationData)",
      GetFolderPath(SpecialFolder.ApplicationData));
    table.AddRow("  .MyDocuments)",
      GetFolderPath(SpecialFolder.MyDocuments));
    table.AddRow("  .Personal)",
      GetFolderPath(SpecialFolder.Personal));
    // Render the table to the console
    AnsiConsole.Write(table);
    #endregion 
    

    Environment 类型还有许多其他有用的成员,我们在这段代码中没有使用,包括 OSVersionProcessorCount 属性。

  7. 运行代码并查看结果,如图 9.1 所示,使用 Windows 上的 Visual Studio:

图 9.1:使用 Windows 上的 Visual Studio 显示文件系统信息

更多信息:您可以在以下链接中了解更多关于使用 Spectre Console 表格的信息:spectreconsole.net/widgets/table

当在 Mac 上使用dotnet run运行控制台应用程序时,路径和目录分隔符字符不同,CurrentDirectory将是项目文件夹,而不是bin文件夹内的文件夹,如图 9.2所示:

图片

图 9.2:在 macOS 上使用 CLI 显示文件系统信息

良好实践:Windows 使用反斜杠(\)作为目录分隔符字符。macOS 和 Linux 使用正斜杠(/)作为目录分隔符字符。在组合路径时,不要假设使用哪个字符,请使用Path.DirectorySeparatorChar

在本章的后续部分,我们将在Personal特殊文件夹中创建目录和文件,因此请记下在您的操作系统中的位置。例如,如果您使用 Linux,它应该是$USER/Documents

管理驱动器

要管理驱动器,请使用DriveInfo类型,它有一个静态方法,可以返回连接到您的计算机的所有驱动器的信息。每个驱动器都有一个驱动器类型。

让我们探索驱动器:

  1. Program.cs中编写语句以获取所有驱动器并输出它们的名称、类型、大小、可用空间和格式,但仅当驱动器已准备好时,如下代码所示:

    SectionTitle("Managing drives");
    Table drives = new();
    drives.AddColumn("[blue]NAME[/]");
    drives.AddColumn("[blue]TYPE[/]");
    drives.AddColumn("[blue]FORMAT[/]");
    drives.AddColumn(new TableColumn(
      "[blue]SIZE (BYTES)[/]").RightAligned());
    drives.AddColumn(new TableColumn(
      "[blue]FREE SPACE[/]").RightAligned());
    foreach (DriveInfo drive in DriveInfo.GetDrives())
    {
      if (drive.IsReady)
      {
        drives.AddRow(drive.Name, drive.DriveType.ToString(),
          drive.DriveFormat, drive.TotalSize.ToString("N0"),
          drive.AvailableFreeSpace.ToString("N0"));
      }
      else
      {
        drives.AddRow(drive.Name, drive.DriveType.ToString(),
          string.Empty, string.Empty, string.Empty);
      }
    }
    AnsiConsole.Write(drives); 
    

良好实践:在读取TotalSize等属性之前,请检查驱动器是否已准备好,否则您将看到在可移动驱动器上抛出的异常。

在 Linux 上,默认情况下,当以普通用户身份运行时,您的控制台应用程序只能读取NameDriveType属性。对于DriveFormatTotalSizeAvailableFreeSpace将抛出UnauthorizedAccessException。以超级用户身份运行控制台应用程序以避免此问题,如下命令所示:sudo dotnet run。在开发环境中使用sudo是可以的,但在生产环境中,建议编辑您的权限以避免以提升权限运行。在 Linux 上,名称和驱动器格式列可能也需要更宽,例如,分别为 55 和 12 个字符宽。

  1. 运行代码并查看结果,如图 9.3所示:

图片图 9.3:在 Windows 和 macOS 上显示驱动器信息

管理目录

要管理目录,请使用DirectoryPathEnvironment静态类。这些类型包括许多用于与文件系统交互的成员。

当构建自定义路径时,您必须小心编写代码,以确保它不针对平台做出假设,例如,使用什么作为目录分隔符字符:

  1. Program.cs中编写语句以执行以下操作:

    • 在用户主目录下通过创建一个包含目录名称的字符串数组来定义一个自定义路径,然后使用Path类型的Combine方法正确地组合它们。

    • 使用Directory类的Exists方法检查自定义目录路径是否存在。

    • 使用Directory类的CreateDirectoryDelete方法创建然后删除目录,包括其内的文件和子目录:

      SectionTitle("Managing directories");
      string newFolder = Combine(
        GetFolderPath(SpecialFolder.Personal), "NewFolder");
      WriteLine($"Working with: {newFolder}");
      // We must explicitly say which Exists method to use
      // because we statically imported both Path and Directory.
      WriteLine($"Does it exist? {Path.Exists(newFolder)}");
      WriteLine("Creating it...");
      CreateDirectory(newFolder);
      // Let's use the Directory.Exists method this time.
      WriteLine($"Does it exist? {Directory.Exists(newFolder)}");
      Write("Confirm the directory exists, and then press any key.");
      ReadKey(intercept: true);
      WriteLine("Deleting it...");
      Delete(newFolder, recursive: true);
      WriteLine($"Does it exist? {Path.Exists(newFolder)}"); 
      

      在.NET 6 及之前版本中,只有Directory类有Exists方法。在.NET 7 或更高版本中,Path类也有Exists方法。两者都可以用来检查路径是否存在。

  2. 运行代码,查看结果,并使用你喜欢的文件管理工具确认在按下Enter键删除之前已创建目录,如下所示输出:

    Working with: C:\Users\markj\OneDrive\Documents\NewFolder
    Does it exist? False
    Creating it...
    Does it exist? True
    Confirm the directory exists, and then press any key.
    Deleting it...
    Does it exist? False 
    

管理文件

当处理文件时,你可以静态导入文件类型,就像我们为目录类型所做的那样。然而,对于下一个示例,我们不会这样做,因为它有一些与目录类型相同的方法,它们可能会冲突。文件类型有足够短的名字,在这种情况下不会造成影响。步骤如下:

  1. Program.cs中编写以下语句:

    1. 检查文件是否存在。

    2. 创建一个文本文件。

    3. 向文件中写入一行文本。

    4. 关闭文件以释放系统资源和解锁文件(这通常在try-finally语句块内完成,以确保即使在写入时发生异常,文件也能被关闭)。

    5. 将文件复制到备份中。

    6. 删除原始文件。

    7. 读取备份文件的 内容然后关闭它:

      SectionTitle("Managing files");
      // Define a directory path to output files starting
      // in the user's folder.
      string dir = Combine(
        GetFolderPath(SpecialFolder.Personal), "OutputFiles");
      CreateDirectory(dir);
      // Define file paths.
      string textFile = Combine(dir, "Dummy.txt");
      string backupFile = Combine(dir, "Dummy.bak");
      WriteLine($"Working with: {textFile}");
      WriteLine($"Does it exist? {File.Exists(textFile)}");
      // Create a new text file and write a line to it.
      StreamWriter textWriter = File.CreateText(textFile);
      textWriter.WriteLine("Hello, C#!");
      textWriter.Close(); // Close file and release resources.
      WriteLine($"Does it exist? {File.Exists(textFile)}");
      // Copy the file, and overwrite if it already exists.
      File.Copy(sourceFileName: textFile,
        destFileName: backupFile, overwrite: true);
      WriteLine(
        $"Does {backupFile} exist? {File.Exists(backupFile)}");
      Write("Confirm the files exist, and then press any key.");
      ReadKey(intercept: true);
      // Delete the file.
      File.Delete(textFile);
      WriteLine($"Does it exist? {File.Exists(textFile)}");
      // Read from the text file backup.
      WriteLine($"Reading contents of {backupFile}:");
      StreamReader textReader = File.OpenText(backupFile);
      WriteLine(textReader.ReadToEnd());
      textReader.Close(); 
      
  2. 运行代码并查看结果,如下所示输出:

    Working with: C:\Users\markj\OneDrive\Documents\OutputFiles\Dummy.txt
    Does it exist? False
    Does it exist? True
    Does C:\Users\markj\OneDrive\Documents\OutputFiles\Dummy.bak exist? True
    Confirm the files exist, and then press any key.
    Does it exist? False
    Reading contents of C:\Users\markj\OneDrive\Documents\OutputFiles\Dummy.bak:
    Hello, C#! 
    

管理路径

有时,你需要处理路径的一部分;例如,你可能只想提取文件夹名、文件名或扩展名。有时,你需要生成临时文件夹和文件名。你可以使用Path类的静态方法来完成这些操作:

  1. Program.cs中添加以下语句:

    SectionTitle("Managing paths");
    WriteLine($"Folder Name: {GetDirectoryName(textFile)}");
    WriteLine($"File Name: {GetFileName(textFile)}");
    WriteLine("File Name without Extension: {0}",
      GetFileNameWithoutExtension(textFile));
    WriteLine($"File Extension: {GetExtension(textFile)}");
    WriteLine($"Random File Name: {GetRandomFileName()}");
    WriteLine($"Temporary File Name: {GetTempFileName()}"); 
    
  2. 运行代码并查看结果,如下所示输出:

    Folder Name: C:\Users\markj\OneDrive\Documents\OutputFiles
    File Name: Dummy.txt
    File Name without Extension: Dummy
    File Extension: .txt
    Random File Name: u45w1zki.co3
    Temporary File Name:
    C:\Users\markj\AppData\Local\Temp\tmphdmipz.tmp 
    

GetTempFileName创建一个零字节的文件并返回其名称,供你使用。GetRandomFileName仅返回一个文件名;它不会创建文件。

获取文件信息

要获取有关文件或目录的更多信息,例如大小或最后访问时间,你可以创建FileInfoDirectoryInfo类的实例。

FileInfoDirectoryInfo都继承自FileSystemInfo,因此它们都有LastAccessTimeDelete等成员,以及一些特定于自身的额外成员,如表 9.1所示:

成员
FileSystemInfo 字段:FullPathOriginalPath属性:AttributesCreationTimeCreationTimeUtcExistsExtensionFullNameLastAccessTimeLastAccessTimeUtcLastWriteTimeLastWriteTimeUtcName方法:DeleteGetObjectDataRefresh
DirectoryInfo 属性:ParentRoot方法:CreateCreateSubdirectoryEnumerateDirectoriesEnumerateFilesEnumerateFileSystemInfosGetAccessControlGetDirectoriesGetFilesGetFileSystemInfosMoveToSetAccessControl
FileInfo 属性:Directory, DirectoryName, IsReadOnly, 和 Length 方法:AppendText, CopyTo, Create, CreateText, Decrypt, Encrypt, GetAccessControl, MoveTo, Open, OpenRead, OpenText, OpenWrite, Replace, 和 SetAccessControl

表 9.1:获取文件和目录信息的类

让我们编写一些使用FileInfo实例来高效地对文件执行多个操作的代码:

  1. Program.cs中,添加语句以创建备份文件的FileInfo实例,并将其信息写入控制台,如下面的代码所示:

    SectionTitle("Getting file information");
    FileInfo info = new(backupFile);
    WriteLine($"{backupFile}:");
    WriteLine($"  Contains {info.Length} bytes.");
    WriteLine($"  Last accessed: {info.LastAccessTime}");
    WriteLine($"  Has readonly set to {info.IsReadOnly}."); 
    
  2. 运行代码并查看以下输出结果:

    C:\Users\markj\OneDrive\Documents\OutputFiles\Dummy.bak:
      Contains 12 bytes.
      Last accessed: 13/07/2023 12:11:12
      Has readonly set to False. 
    

在您的操作系统上,字节数可能不同,因为操作系统可以使用不同的行结束符。

控制如何处理文件

当处理文件时,您通常需要控制它们的打开方式。File.Open方法有重载,可以使用enum值指定附加选项。

enum类型如下:

  • FileMode:这控制了您想要对文件执行的操作,如CreateNewOpenOrCreateTruncate

  • FileAccess:这控制您需要的访问级别,如ReadWrite

  • FileShare:这控制对文件的锁定,以允许其他进程以指定的级别访问,如Read

您可能想要打开一个文件并从中读取,同时允许其他进程读取它,如下面的代码所示:

FileStream file = File.Open(pathToFile,
  FileMode.Open, FileAccess.Read, FileShare.Read); 

同样还有一个enum用于文件的属性,FileAttributes,它会检查FileSystemInfo派生类型的Attributes属性,以获取如ArchiveEncrypted之类的值。例如,您可以检查文件或目录的属性,如下面的代码所示:

FileInfo info = new(backupFile);
WriteLine("Is the backup file compressed? {0}",
  info.Attributes.HasFlag(FileAttributes.Compressed)); 

File类中管理文件的所有方法都有一个参数,用于指定文件路径作为string值。File类、其方法和它们的返回值的附加参数如下所示表 9.2

方法 特殊参数 返回值 说明
Open FileMode, FileAccess, FileShare FileStream 以字节级别访问文件。
OpenWrite FileStream 从开始覆盖但不截断。
Create FileOptions FileStream 覆盖并截断。
OpenText StreamReader 用于读取文本文件。
CreateText StreamWriter 覆盖并截断。
AppendText StreamWriter 如果文件不存在,则创建文件。
ReadAllLines Encoding string[] 警告!对于大文件,这会使用大量内存。
ReadAllText Encoding string 警告!对于大文件,这会使用大量内存。
WriteAllText string, Encoding void
AppendAllText string, Encoding void
WriteAllLines string[], IEnumerable<string>, Encoding void
AppendAllLines string[], IEnumerable<string>, Encoding void

表 9.2:文件类及其方法

现在你已经学习了在文件系统中处理目录和文件的一些常见方法,接下来我们需要学习如何读取和写入存储在文件中的数据,即如何使用流。

使用流进行读写

第十章使用 Entity Framework Core 处理数据 中,你将使用一个名为 Northwind.db 的文件,但你不会直接与文件交互。相反,你将与 SQLite 数据库引擎交互,它反过来会读取和写入文件。在没有其他系统“拥有”文件并为你进行读写操作的情况下,你将使用文件流直接与文件交互。

是一系列可以读取和写入的字节。虽然文件可以像数组一样处理,通过知道文件中字节的定位来提供随机访问,但将文件作为流处理更有效,其中字节可以按顺序访问。当人类进行处理时,他们往往需要随机访问,以便可以在数据中跳跃,进行更改,然后返回到之前工作的数据。当自动化系统进行处理时,它往往可以按顺序工作,并且只需要“接触”数据一次。

流也可以用来处理终端输入输出和网络资源,例如套接字和端口,这些资源不提供随机访问且不能进行定位(即移动)到某个位置。你可以编写代码来处理一些任意的字节,而不必知道或关心它们来自何处。你的代码只需简单地读取或写入流,而另一段代码则处理字节存储的位置。

理解抽象和具体流

有一个名为 Stream 的抽象类,代表任何类型的流。请记住,抽象类不能使用 new 实例化;它只能被继承。这是因为它只部分实现了。

有许多具体类继承自这个基类,包括 FileStreamMemoryStreamBufferedStreamGZipStreamSslStream。它们的工作方式相同。所有流都实现了 IDisposable,因此它们都有一个 Dispose 方法来释放非托管资源。

Stream 类的一些常见成员在 表 9.3 中进行了描述:

成员 描述
CanReadCanWrite 这些属性确定你是否可以从流中读取和写入。
LengthPosition 这些属性确定流中的总字节数和当前位置。对于某些类型的流,这些属性可能会抛出 NotSupportedException,例如,如果 CanSeek 返回 false
CloseDispose 此方法关闭流并释放其资源。你可以调用任一方法,因为 Dispose 的实现调用了 Close
Flush 如果流有一个缓冲区,则此方法将缓冲区中的字节写入流,并清除缓冲区。
CanSeek 此属性确定是否可以使用Seek方法。
Seek 此方法将当前位置移动到其参数中指定的位置。
ReadReadAsync 这些方法将指定数量的字节从流中读取到字节数组中并前进位置。
ReadByte 此方法从流中读取下一个字节并前进位置。
WriteWriteAsync 这些方法将字节数组的内容写入流。
WriteByte 此方法将一个字节写入流。

表 9.3:Stream 类的常见成员

理解存储流

一些表示字节存储位置的存储流在表 9.4中描述:

命名空间 描述
System.IO FileStream 存储在文件系统中的字节
System.IO MemoryStream 存储在当前进程中的内存中的字节
System.Net.Sockets NetworkStream 存储在网络位置的字节

表 9.4:存储流类

FileStream在.NET 6 中已重写,以在 Windows 上提供更高的性能和可靠性。您可以在以下链接中了解更多信息:devblogs.microsoft.com/dotnet/file-io-improvements-in-dotnet-6/

理解函数流

函数流不能独立存在,只能“插入”到其他流中以添加功能。其中一些在表 9.5中描述:

命名空间 描述
System.Security.Cryptography CryptoStream 此流进行加密和解密。
System.IO.Compression GZipStream, DeflateStream 这些压缩和解压缩流。
System.Net.Security AuthenticatedStream 此方法通过流发送凭据。

表 9.5:函数流类

理解流辅助

尽管有时您需要以低级别处理流,但大多数情况下,您可以将辅助类插入链中,使事情变得更容易。所有流辅助类型都实现IDisposable,因此它们有一个Dispose方法来释放非托管资源。

一些用于处理常见场景的辅助类在表 9.6中描述:

命名空间 描述
System.IO StreamReader 此以纯文本形式从底层流中读取。
System.IO StreamWriter 此以纯文本形式将底层流写入。
System.IO BinaryReader 此读取流作为.NET 类型。例如,ReadDecimal方法从底层流中读取下一个 16 字节作为decimal值,而ReadInt32方法读取下一个 4 字节作为int值。
System.IO BinaryWriter 此将.NET 类型写入流。例如,具有decimal参数的Write方法将 16 字节写入底层流,而具有int参数的Write方法写入 4 字节。
System.Xml XmlReader 这使用 XML 格式从底层流中读取。
System.Xml XmlWriter 这使用 XML 格式将数据写入底层流。

表 9.6:流辅助类

构建流管道

将辅助器,如 StreamWriter,多个功能流,如 GZipStreamCryptoStream,以及存储流,如 FileStream,组合成管道是很常见的,如图 9.4 所示:

图 9.4:将纯文本写入,然后将其加密并压缩到文件流中

你的代码只需调用一个简单的辅助方法,如 WriteLine,将一个 string 值,如 "Hello",通过管道发送,直到它到达最终目的地,经过压缩和加密后,被写入文件作为 "``G7x"(或任何其他值)。

良好实践:“良好的加密将生成相对不可压缩的数据。如果你改变操作的顺序,先压缩再加密,不仅最终文件会更小,而且加密很可能花费更少的时间,因为它将处理更少的数据。” 来自 Stephen Toub 的文章:learn.microsoft.com/en-us/archive/msdn-magazine/2008/february/net-matters-stream-pipeline

写入文本流

当你打开一个文件进行读取或写入时,你使用 .NET 之外的资源。这些被称为 未托管资源,在完成与它们的操作后必须释放。

为了确定性地控制这些资源何时被释放,我们可以调用 Dispose 方法。当 Stream 类最初设计时,所有清理代码都预期放在 Close 方法中。但后来,.NET 中添加了 IDisposable 的概念,Stream 必须实现 Dispose 方法。后来,.NET 中添加了 using 语句,它可以自动调用 Dispose。所以今天,你可以调用 CloseDispose,实际上它们做的是同一件事。

让我们输入一些代码来将文本写入流:

  1. 使用你喜欢的代码编辑器,向 Chapter09 解决方案中添加一个名为 WorkingWithStreams 的新 控制台应用程序/ console 项目:

  2. 在项目文件中,添加一个元素以导入 System.ConsoleSystem.EnvironmentSystem.IO.Path 类,静态和全局地。

  3. 添加一个名为 Program.Helpers.cs 的新类文件。

  4. Program.Helpers.cs 中,添加一个部分 Program 类,包含 SectionTitleOutputFileInfo 方法,如下面的代码所示:

    // null namespace to merge with auto-generated Program.
    partial class Program
    {
      private static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title} ***");
        ForegroundColor = previousColor;
      }
      private static void OutputFileInfo(string path)
      {
        WriteLine("**** File Info ****");
        WriteLine($"File: {GetFileName(path)}");
        WriteLine($"Path: {GetDirectoryName(path)}");
        WriteLine($"Size: {new FileInfo(path).Length:N0} bytes.");
        WriteLine("/------------------");
        WriteLine(File.ReadAllText(path));
        WriteLine("------------------/");
      }
    } 
    
  5. 添加一个名为 Viper.cs 的新类文件。

  6. Viper.cs 中,定义一个名为 Viper 的静态类,其中包含一个名为 Callsigns 的静态 string 值数组,如下面的代码所示:

    namespace Packt.Shared;
    public static class Viper
    {
      // Define an array of Viper pilot call signs.
      public static string[] Callsigns = new[]
      {
        "Husker", "Starbuck", "Apollo", "Boomer",
        "Bulldog", "Athena", "Helo", "Racetrack"
      };
    } 
    
  7. Program.cs 文件中,删除现有的语句,然后导入命名空间以使用 Viper 类,如下面的代码所示:

    using Packt.Shared; // To use Viper. 
    
  8. Program.cs 中添加语句以枚举 Viper 呼号,将每个呼号单独写入一个文本文件的一行中,如下面的代码所示:

    SectionTitle("Writing to text streams");
    // Define a file to write to.
    string textFile = Combine(CurrentDirectory, "streams.txt");
    // Create a text file and return a helper writer.
    StreamWriter text = File.CreateText(textFile);
    // Enumerate the strings, writing each one to the stream
    // on a separate line.
    foreach (string item in Viper.Callsigns)
    {
      text.WriteLine(item);
    }
    text.Close(); // Release unmanaged file resources.
    OutputFileInfo(textFile); 
    

在流写入辅助工具上调用 Close 将会调用底层流的 Close。这反过来会调用 Dispose 以释放非托管文件资源。

  1. 运行代码并查看结果,如下面的输出所示:

    **** File Info ****
    File: streams.txt
    Path: C:\cs13net9\Chapter09\WorkingWithStreams\bin\Debug\net9.0
    Size: 68 bytes.
    /------------------
    Husker
    Starbuck
    Apollo
    Boomer
    Bulldog
    Athena
    Helo
    Racetrack
    ------------------/ 
    
  2. 打开创建的文件,并确认它包含呼号列表以及一个空行,因为我们实际上调用了两次 WriteLine:一次是在将最后一个呼号写入文件时,另一次是在读取整个文件并将其写入控制台时。

记住,如果您使用 dotnet run 在命令提示符下运行项目,则路径将是项目文件夹。它不会包括 bin\Debug\net9.0

写入 XML 流

有两种方式来编写 XML 元素,如下所示:

  • WriteStartElementWriteEndElement:当元素可能有子元素时使用此对。

  • WriteElementString:当元素没有子元素时使用此方法。

现在,让我们尝试将 Viper 飞行员的呼号数组(string 类型的值)存储在一个 XML 文件中:

  1. Program.cs 的顶部导入 System.Xml 命名空间,如下面的代码所示:

    using System.Xml; // To use XmlWriter and so on. 
    
  2. Program.cs 的底部添加语句以枚举呼号,将每个呼号作为单个 XML 文件中的一个元素写入,如下面的代码所示:

    SectionTitle("Writing to XML streams");
    // Define a file path to write to.
    string xmlFile = Combine(CurrentDirectory, "streams.xml");
    // Declare variables for the filestream and XML writer.
    FileStream? xmlFileStream = null;
    XmlWriter? xml = null;
    try
    {
      xmlFileStream = File.Create(xmlFile);
      // Wrap the file stream in an XML writer helper and tell it
      // to automatically indent nested elements.
      xml = XmlWriter.Create(xmlFileStream,
        new XmlWriterSettings { Indent = true });
      // Write the XML declaration.
      xml.WriteStartDocument();
      // Write a root element.
      xml.WriteStartElement("callsigns");
      // Enumerate the strings, writing each one to the stream.
      foreach (string item in Viper.Callsigns)
      {
        xml.WriteElementString("callsign", item);
      }
      // Write the close root element.
      xml.WriteEndElement();
    }
    catch (Exception ex)
    {
      // If the path doesn't exist the exception will be caught.
      WriteLine($"{ex.GetType()} says {ex.Message}");
    }
    finally
    {
      if (xml is not null)
      {
        xml.Close();
        WriteLine("The XML writer's unmanaged resources have been disposed.");
      }
      if (xmlFileStream is not null)
      {
        xmlFileStream.Close();
        WriteLine("The file stream's unmanaged resources have been disposed.");
      }
    }
    OutputFileInfo(xmlFile); 
    
  3. 可选地,在 xmlFileStreamClose 方法上右键单击,选择 转到实现,并注意 DisposeCloseDispose(bool) 方法的实现,如下面的代码所示:

    public void Dispose() => Close();
    public virtual void Close()
    {
      // When initially designed, Stream required that all cleanup logic
      // went into Close(), but this was thought up before IDisposable
      // was added and never revisited. All subclasses
      // should put their cleanup now in Dispose(bool).
      Dispose(true);
      GC.SuppressFinalize(this);
    }
    protected virtual void Dispose(bool disposing)
    {
      // Note: Never change this to call other virtual methods on Stream
      // like Write, since the state on subclasses has already been
      // torn down.  This is the last code to run on cleanup for a stream.
    } 
    

Stream 类中的 CloseDispose(bool) 方法是 virtual 的,因为它们被设计为在派生类(如 FileStream)中重写,以执行释放非托管资源的工作。

  1. 运行代码并查看结果,如下面的输出所示:

    **** File Info ****
    The XML writer's unmanaged resources have been disposed.
    The file stream's unmanaged resources have been disposed.
    File: streams.xml
    Path: C:\cs13net9\Chapter09\WorkingWithStreams\bin\Debug\net9.0
    Size: 320 bytes.
    /------------------
    <?xml version="1.0" encoding="utf-8"?>
    <callsigns>
      <callsign>Husker</callsign>
      <callsign>Starbuck</callsign>
      <callsign>Apollo</callsign>
      <callsign>Boomer</callsign>
      <callsign>Bulldog</callsign>
      <callsign>Athena</callsign>
      <callsign>Helo</callsign>
      <callsign>Racetrack</callsign>
    </callsigns>
    -------------------/ 
    

良好实践:在调用 Dispose 方法之前,请检查对象是否不是 null

使用 using 语句简化释放

您可以使用 using 语句简化需要检查 null 对象并调用其 Dispose 方法的代码。除非您需要更高程度的控制,否则我通常会推荐使用 using 而不是手动调用 Dispose,因为它更简洁。

令人困惑的是,using 关键字有两种用法:导入命名空间和生成一个调用实现 IDisposable 接口的对象的 Dispose 方法的 finally 语句。

编译器将 using 语句块转换为没有 catch 语句的 try-finally 语句。您可以使用嵌套的 try 语句,因此如果您确实想捕获任何异常,您可以,如下面的代码示例所示:

using (FileStream file2 = File.OpenWrite(
  Path.Combine(path, "file2.txt")))
{
  using (StreamWriter writer2 = new StreamWriter(file2))
  {
    try
    {
      writer2.WriteLine("Welcome, .NET!");
    }
    catch(Exception ex)
    {
      WriteLine($"{ex.GetType()} says {ex.Message}");
    }
  } // Automatically calls Dispose if the object is not null.
} // Automatically calls Dispose if the object is not null. 

您甚至可以通过不显式指定 using 语句的大括号和缩进来进一步简化代码,如下面的代码所示:

using FileStream file2 = File.OpenWrite(
  Path.Combine(path, "file2.txt"));
using StreamWriter writer2 = new(file2);
try
{
  writer2.WriteLine("Welcome, .NET!");
}
catch(Exception ex)
{
  WriteLine($"{ex.GetType()} says {ex.Message}");
} 

为了使这更清晰,让我们回顾一个更简单的例子。您可以使用using块确保在作用域结束时调用Dispose方法,如下面的代码所示:

using (ObjectWithUnmanagedResources thing = new())
{
  // Statements that use thing.
} 

更多信息:您可以在以下链接中了解前面的代码是如何转换为try-catch块的:github.com/markjprice/cs13net9/blob/main/docs/ch06-memory.md#ensuring-that-dispose-is-called。如果您还没有阅读,您也应该阅读以下在线部分:github.com/markjprice/cs13net9/blob/main/docs/ch06-memory.md#releasing-unmanaged-resources

您还可以使用不带花括号的简化语法,如下面的代码所示:

using ObjectWithUnmanagedResources thing = new();
// Statements that use thing.
// Dispose called at the end of the container scope e.g. method. 

在前面的代码示例中,没有显式定义由花括号定义的块,因此定义了一个隐式块,该块在包含作用域的末尾结束。

更多信息:您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-8.0/usinglearn.microsoft.com/en-us/dotnet/csharp/language-reference/language-specification/statements#1314-the-using-statement

压缩流

XML 相对冗长,因此它占用的字节数比纯文本多。让我们看看如何使用常见的压缩算法 GZIP 来压缩 XML。

在.NET Core 2.1 中,Microsoft 引入了 Brotli 压缩算法的实现。在性能上,Brotli 类似于 DEFLATE 和 GZIP 中使用的算法,但输出密度大约高 20%。

让我们比较两种压缩算法:

  1. 添加一个名为Program.Compress.cs的新类文件。

  2. Program.Compress.cs中,编写语句以使用GZipStreamBrotliStream的实例创建包含与之前相同的 XML 元素的压缩文件,然后在其读取并输出到控制台的同时对其进行解压缩,如下面的代码所示:

    using Packt.Shared; // To use Viper.
    using System.IO.Compression; // To use BrotliStream, GZipStream.
    using System.Xml; // To use XmlWriter, XmlReader.
    partial class Program
    {
      private static void Compress(string algorithm = "gzip")
      {
        // Define a file path using the algorithm as file extension.
        string filePath = Combine(
          CurrentDirectory, $"streams.{algorithm}");
        FileStream file = File.Create(filePath);
        Stream compressor;
        if (algorithm == "gzip")
        {
          compressor = new GZipStream(file, CompressionMode.Compress);
        }
        else
        {
          compressor = new BrotliStream(file, CompressionMode.Compress);
        }
        using (compressor)
        {
          using (XmlWriter xml = XmlWriter.Create(compressor))
          {
            xml.WriteStartDocument();
            xml.WriteStartElement("callsigns");
            foreach (string item in Viper.Callsigns)
            {
              xml.WriteElementString("callsign", item);
            }
          }
        } // Also closes the underlying stream.
        OutputFileInfo(filePath);
        // Read the compressed file.
        WriteLine("Reading the compressed XML file:");
        file = File.Open(filePath, FileMode.Open);
        Stream decompressor;
        if (algorithm == "gzip")
        {
          decompressor = new GZipStream(
            file, CompressionMode.Decompress);
        }
        else
        {
          decompressor = new BrotliStream(
            file, CompressionMode.Decompress);
        }
        using (decompressor)
    
        using (XmlReader reader = XmlReader.Create(decompressor))
    
        while (reader.Read())
        {
          // Check if we are on an element node named callsign.
          if ((reader.NodeType == XmlNodeType.Element)
            && (reader.Name == "callsign"))
          {
            reader.Read(); // Move to the text inside element.
            WriteLine($"{reader.Value}"); // Read its value.
          }
          // Alternative syntax with property pattern matching:
          // if (reader is { NodeType: XmlNodeType.Element,
          //   Name: "callsign" })
        }
      }
    } 
    

使用decompressor对象的代码不使用简化的using语法。相反,它利用了using块可以省略其花括号以表示单个“语句”的事实,就像if语句一样。记住,即使一个块内只执行了一个语句,if语句也可以有显式的花括号,如下面的代码所示:

if (c = 1)
{
// Execute a single statement.
}
if (c = 1)
// Execute a single statement.
using (someObject)
{
// Execute a single statement.
}
using (someObject)
// Execute a single statement 

在前面的代码中,using (XmlReader reader = XmlReader.Create(decompressor))和整个while (reader.Read()) { ... }块相当于单个语句,因此我们可以省略花括号,代码按预期工作。

  1. Program.cs中,添加调用Compress的语句,使用gzipbrotli算法,如下面的代码所示:

    SectionTitle("Compressing streams");
    Compress(algorithm: "gzip");
    Compress(algorithm: "brotli"); 
    
  2. 运行代码,并使用gzipbrotli算法比较 XML 文件和压缩 XML 文件的大小,如下面的输出所示:

    **** File Info ****
    File: streams.gzip
    Path: C:\cs13net9\Chapter09\WorkingWithStreams\bin\Debug\net9.0
    Size: 151 bytes.
    /------------------
    ­?
    z?{??}En?BYjQqf~???????Bj^r~Jf^??RiI??????MrbNNqfz¹?i?QZ??Zd?@H?$%?&gc?t,
    ?????*????H?????t?&?d??%b??H?aUPbrjIQ"??b;????9
    ------------------/
    Reading the compressed XML file:
    Husker
    Starbuck
    Apollo
    Boomer
    Bulldog
    Athena
    Helo
    Racetrack
    **** File Info ****
    File: streams.brotli
    Path: C:\cs13net9\Chapter09\WorkingWithStreams\bin\Debug\net9.0
    Size: 117 bytes.
    /-------------------
     ??d?&?_????\@?Gm????/?h>?6????? ??^?__???wE?'?t<J??]??
    ???b?\fA?>?+??F??]
    ?T?\?~??A?J?Q?q6 ?-??
    ???
    --------------------/
    Reading the compressed XML file:
    Husker
    Starbuck
    Apollo
    Boomer
    Bulldog
    Athena
    Helo
    Racetrack 
    

总结文件大小:

  • 未压缩:320 字节

  • GZIP 压缩:151 字节

  • Brotli 压缩:117 字节

除了选择压缩模式外,您还可以选择压缩级别。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/api/system.io.compression.compressionlevel

使用随机访问句柄进行读写

在.NET 生命的头 20 年里,直接与文件工作的唯一 API 是流类 API。这些对于只需要按顺序处理数据的自动化任务非常出色。但当人类与数据交互时,他们通常会跳来跳去,多次返回到同一位置。

在.NET 6 及以后版本中,有一个新的 API 用于以随机访问方式处理文件,无需使用文件流。让我们看看一个简单的例子:

  1. 使用您首选的代码编辑器,将名为WorkingWithRandomAccess的新控制台应用程序/console项目添加到Chapter09解决方案中:

  2. 在项目文件中,添加一个元素以静态和全局方式导入System.Console类。

  3. Program.cs中删除现有语句,然后获取名为coffee.txt的文件句柄,如下面的代码所示:

    using Microsoft.Win32.SafeHandles; // To use SafeFileHandle.
    using System.Text; // To use Encoding.
    using SafeFileHandle handle =
      File.OpenHandle(path: "coffee.txt",
        mode: FileMode.OpenOrCreate,
        access: FileAccess.ReadWrite); 
    
  4. 将一些编码为字节数组的文本写入,然后将其存储到文件中的只读内存缓冲区中,如下面的代码所示:

    string message = "Café £4.39";
    ReadOnlyMemory<byte> buffer = new(Encoding.UTF8.GetBytes(message));
    await RandomAccess.WriteAsync(handle, buffer, fileOffset: 0); 
    
  5. 要从文件中读取,获取文件长度,使用该长度分配内存缓冲区以存储内容,然后读取文件,如下面的代码所示:

    long length = RandomAccess.GetLength(handle);
    Memory<byte> contentBytes = new(new byte[length]);
    await RandomAccess.ReadAsync(handle, contentBytes, fileOffset: 0);
    string content = Encoding.UTF8.GetString(contentBytes.ToArray());
    WriteLine($"Content of file: {content}"); 
    
  6. 运行代码,并注意文件内容,如下面的输出所示:

    Content of file: Café £4.39 
    

文本编码和解码

文本字符可以用不同的方式表示。例如,字母表可以用摩尔斯电码编码成一系列点和划,以便在电报线上传输。

类似地,计算机内的文本以位(一和零)的形式存储,代表代码空间内的代码点。大多数代码点代表单个字符,但它们也可以有其他含义,如格式化。

例如,ASCII 有 128 个代码点。.NET 使用一个称为Unicode的标准来内部编码文本。Unicode 有超过一百万个代码点。

有时,你需要将文本移出.NET,以便由不使用 Unicode 或其变体的系统使用,因此学习如何在编码之间进行转换是很重要的。

计算机使用的常见文本编码如下表所示:

编码 描述
ASCII 使用字节的低 7 位编码字符的有限范围。
UTF-8 这表示每个 Unicode 代码点为一个 1 到 4 字节的序列。
UTF-7 这是为了在 7 位通道上比 UTF-8 更有效,但它存在安全和健壮性问题,因此建议使用 UTF-8 而不是 UTF-7。
UTF-16 这表示每个 Unicode 代码点为一个或两个 16 位整数的序列。
UTF-32 这表示每个 Unicode 代码点为一个 32 位整数,因此是一个固定长度编码,与所有其他 Unicode 编码不同,它们都是可变长度编码。
ANSI/ISO 编码 这提供了对各种代码页的支持,这些代码页用于支持特定的语言或语言组。

表 9.7:常见文本编码

良好实践:在当今大多数情况下,UTF-8 是一个好的默认编码,这也是为什么它是字面上的默认编码,即 Encoding.Default。您应该避免使用 Encoding.UTF7,因为它不安全。因此,当您尝试使用 UTF-7 时,C# 编译器会警告您。当然,您可能需要使用该编码生成文本以与其他系统兼容,因此它需要在 .NET 中保留为一个选项。

将字符串编码为字节数组

让我们探索文本编码:

  1. 使用您首选的代码编辑器,将一个新的 Console App / console 项目,命名为 WorkingWithEncodings,添加到 Chapter09 解决方案中。

  2. 在项目文件中,添加一个元素以静态和全局导入 System.Console 类。

  3. Program.cs 中,删除现有的语句,导入 System.Text 命名空间,添加语句以使用用户选择的编码编码一个 string,循环遍历每个字节,然后将编码的值解码回 string 并输出,如下面的代码所示:

    using System.Text; // To use Encoding.
    WriteLine("Encodings");
    WriteLine("[1] ASCII");
    WriteLine("[2] UTF-7");
    WriteLine("[3] UTF-8");
    WriteLine("[4] UTF-16 (Unicode)");
    WriteLine("[5] UTF-32");
    WriteLine("[6] Latin1");
    WriteLine("[any other key] Default encoding");
    WriteLine();
    Write("Press a number to choose an encoding.");
    ConsoleKey number = ReadKey(intercept: true).Key;
    WriteLine(); WriteLine();
    Encoding encoder = number switch
    {
      ConsoleKey.D1 or ConsoleKey.NumPad1 => Encoding.ASCII,
      ConsoleKey.D2 or ConsoleKey.NumPad2 => Encoding.UTF7,
      ConsoleKey.D3 or ConsoleKey.NumPad3 => Encoding.UTF8,
      ConsoleKey.D4 or ConsoleKey.NumPad4 => Encoding.Unicode,
      ConsoleKey.D5 or ConsoleKey.NumPad5 => Encoding.UTF32,
      ConsoleKey.D6 or ConsoleKey.NumPad6 => Encoding.Latin1,
      _  => Encoding.Default
    };
    // Define a string to encode
    string message = "Café £4.39";
    WriteLine($"Text to encode: {message}  Characters: {message.Length}.");
    // Encode the string into a byte array.
    byte[] encoded = encoder.GetBytes(message);
    // Check how many bytes the encoding needed.
    WriteLine("{0} used {1:N0} bytes.",
      encoder.GetType().Name, encoded.Length);
    WriteLine();
    // Enumerate each byte.
    WriteLine("BYTE | HEX | CHAR");
    foreach (byte b in encoded)
    {
      WriteLine($"{b,4} | {b,3:X} | {(char)b,4}");
    }
    // Decode the byte array back into a string and display it.
    string decoded = encoder.GetString(encoded);
    WriteLine($"Decoded: {decoded}"); 
    
  4. 运行代码,按 1 选择 ASCII,注意当输出字节时,英镑符号 (£) 和带重音的 e (é) 无法在 ASCII 中表示,因此它使用问号代替:

    Text to encode: Café £4.39  Characters: 10
    ASCIIEncodingSealed used 10 bytes.
    BYTE | HEX | CHAR
      67 |  43 |    C
      97 |  61 |    a
     102 |  66 |    f
      63 |  3F |    ?
      32 |  20 |
      63 |  3F |    ?
      52 |  34 |    4
      46 |  2E |    .
      51 |  33 |    3
      57 |  39 |    9
    Decoded: Caf? ?4.39 
    
  5. 重新运行代码并按 3 选择 UTF-8。注意 UTF-8 需要为每个需要 2 个字节的两个字符额外使用 2 个字节(总共 12 个字节而不是 10 个字节),但它可以编码和解码 é£ 字符:

    Text to encode: Café £4.39  Characters: 10
    UTF8EncodingSealed used 12 bytes.
    BYTE | HEX | CHAR
      67 |  43 |    C
      97 |  61 |    a
     102 |  66 |    f
     195 |  C3 |    Ã
     169 |  A9 |    ©
      32 |  20 |
     194 |  C2 |    Â
     163 |  A3 |    £
      52 |  34 |    4
      46 |  2E |    .
      51 |  33 |    3
      57 |  39 |    9
    Decoded: Café £4.39 
    
  6. 重新运行代码并按 4 选择 Unicode(UTF-16)。注意 UTF-16 为每个字符需要 2 个字节,因此总共需要 20 个字节,并且它可以编码和解码 é£ 字符。此编码由 .NET 内部用于存储 charstring 值。

文件中的文本编码和解码

当使用流辅助类,例如 StreamReaderStreamWriter 时,您可以指定您想要使用的编码。当您向辅助类写入时,文本将被自动编码;当您从辅助类读取时,字节将被自动解码。

要指定一个编码,将编码作为辅助类型构造函数的第二个参数传递,如下面的代码所示:

StreamReader reader = new(stream, Encoding.UTF8);
StreamWriter writer = new(stream, Encoding.UTF8); 

良好实践:通常,你不会选择使用哪种编码方式,因为你将生成一个文件供其他系统使用。然而,如果你有选择,请选择使用字节最少的编码方式,但可以存储你需要的所有字符。

序列化对象图

对象图是一组多个相互关联的对象的结构,这些对象通过直接引用或间接引用链相互关联。

序列化是将活动对象图转换为使用指定格式的一系列字节的过程。反序列化是相反的过程。

你可以使用序列化来保存活动对象的状态,以便将来可以重新创建它,例如,保存游戏当前状态以便明天可以继续,在相同的地方继续。从序列化对象产生的流通常存储在文件或数据库中。

你可以选择数十种格式进行序列化,但最常用的两种基于文本的、可读性强的格式是 可扩展标记语言 (XML) 和 JavaScript 对象表示法 (JSON)。还有更高效的二进制格式,如 Protobuf,它被 gRPC 所使用。

良好实践:JSON 更紧凑,更适合网页和移动应用程序。XML 更冗长,但在更多遗留系统中支持得更好。使用 JSON 来最小化序列化对象图的大小。当将对象图发送到网页应用程序和移动应用程序时,JSON 也是一个不错的选择,因为它是 JavaScript 的原生序列化格式,并且移动应用程序通常在有限的带宽下进行调用,因此字节数很重要。

.NET 有多个类可以将 XML 和 JSON 序列化和反序列化。我们将首先看看 XmlSerializerJsonSerializer

将对象序列化为 XML

让我们先看看 XML,可能是世界上使用最广泛的序列化格式(目前)。为了展示一个典型的例子,我们将定义一个自定义类来存储有关人员的信 息,然后创建一个对象图,使用 Person 实例的列表进行嵌套:

  1. 使用你喜欢的代码编辑器,将一个名为 WorkingWithSerialization 的新 Console App / console 项目添加到 Chapter09 解决方案中。

  2. 在项目文件中,添加元素以静态和全局导入以下类:

    • 使用 System.ConsoleForegroundColorWriteLine

    • 使用 System.EnvironmentCurrentDirectory

    • 使用 System.IO.PathCombineGetFileNameGetDirectoryName

  3. 添加一个名为 Program.Helpers.cs 的新类文件。

  4. Program.Helpers.cs 中,添加一个部分 Program 类,包含 SectionTitleOutputFileInfo 方法,如下面的代码所示:

    // null namespace to merge with auto-generated Program.
    partial class Program
    {
      private static void SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title} ***");
        ForegroundColor = previousColor;
      }
      private static void OutputFileInfo(string path)
      {
        WriteLine("**** File Info ****");
        WriteLine($"File: {GetFileName(path)}");
        WriteLine($"Path: {GetDirectoryName(path)}");
        WriteLine($"Size: {new FileInfo(path).Length:N0} bytes.");
        WriteLine("/------------------");
        WriteLine(File.ReadAllText(path));
        WriteLine("------------------/");
      }
    } 
    
  5. 添加一个名为 Person.cs 的新类文件,用于定义一个具有 Salary 属性的 Person 类,该属性是 protected 的,这意味着它只能由自身和派生类访问。为了填充薪水,该类有一个带有单个参数的构造函数,用于设置初始薪水,如下面的代码所示:

    namespace Packt.Shared;
    public class Person
    {
      public Person(decimal initialSalary)
      {
        Salary = initialSalary;
      }
      public string? FirstName { get; set; }
      public string? LastName { get; set; }
      public DateTime DateOfBirth { get; set; }
      public HashSet<Person>? Children { get; set; }
      protected decimal Salary { get; set; }
    } 
    
  6. Program.cs 文件中,删除现有的语句,然后导入命名空间以使用 XML 序列化和 Person 类,如下面的代码所示:

    using System.Xml.Serialization; // To use XmlSerializer.
    using Packt.Shared; // To use Person. 
    
  7. Program.cs 文件中添加语句以创建 Person 实例的对象图,如下面的代码所示:

    List<Person> people = new()
    {
      new(initialSalary: 30_000M)
      {
        FirstName = "Alice",
        LastName = "Smith",
        DateOfBirth = new(year: 1974, month: 3, day: 14)
      },
      new(initialSalary: 40_000M)
      {
        FirstName = "Bob",
        LastName = "Jones",
        DateOfBirth = new(year: 1969, month: 11, day: 23)
      },
      new(initialSalary: 20_000M)
      {
        FirstName = "Charlie",
        LastName = "Cox",
        DateOfBirth = new(year: 1984, month: 5, day: 4),
        Children = new()
        {
          new(initialSalary: 0M)
          {
            FirstName = "Sally",
            LastName = "Cox",
            DateOfBirth = new(year: 2012, month: 7, day: 12)
          }
        }
      }
    };
    SectionTitle("Serializing as XML");
    // Create serializer to format a "List of Person" as XML.
    XmlSerializer xs = new(type: people.GetType());
    // Create a file to write to.
    string path = Combine(CurrentDirectory, "people.xml");
    using (FileStream stream = File.Create(path))
    {
      // Serialize the object graph to the stream.
      xs.Serialize(stream, people);
    } // Closes the stream.
    OutputFileInfo(path); 
    
  8. 运行代码,查看结果,并注意会抛出一个异常,如下面的输出所示:

    Unhandled Exception: System.InvalidOperationException: Packt.Shared.Person cannot be serialized because it does not have a parameterless constructor. 
    
  9. Person.cs 文件中添加一个定义无参构造函数的语句,如下面的代码所示:

    // A parameterless constructor is required for XML serialization.
    public Person() { } 
    

构造函数不需要做任何事情,但它必须存在,以便 XmlSerializer 可以在反序列化过程中调用它以实例化新的 Person 实例。

  1. 运行代码并查看结果,并注意对象图被序列化为 XML 元素,如 <FirstName>Bob</FirstName>,并且 Salary 属性没有包含,因为它不是 public 属性,如下面的输出所示:

    **** File Info ****
    File: people.xml
    Path: C:\cs13net9\Chapter09\WorkingWithSerialization\bin\Debug\net9.0
    Size: 793 bytes.
    /------------------
    <?xml version="1.0" encoding="utf-8"?>
    <ArrayOfPerson  >
      <Person>
        <FirstName>Alice</FirstName>
        <LastName>Smith</LastName>
        <DateOfBirth>1974-03-14T00:00:00</DateOfBirth>
      </Person>
      <Person>
        <FirstName>Bob</FirstName>
        <LastName>Jones</LastName>
        <DateOfBirth>1969-11-23T00:00:00</DateOfBirth>
      </Person>
      <Person>
        <FirstName>Charlie</FirstName>
        <LastName>Cox</LastName>
        <DateOfBirth>1984-05-04T00:00:00</DateOfBirth>
        <Children>
          <Person>
            <FirstName>Sally</FirstName>
            <LastName>Cox</LastName>
            <DateOfBirth>2012-07-12T00:00:00</DateOfBirth>
          </Person>
        </Children>
      </Person>
    </ArrayOfPerson>
    ------------------/ 
    

生成紧凑的 XML

我们可以使用属性而不是元素来使某些字段更紧凑的 XML:

  1. Person.cs 文件的顶部导入 System.Xml.Serialization 命名空间,以便可以使用 [XmlAttribute] 属性装饰一些属性,如下面的代码所示:

    using System.Xml.Serialization; // To use [XmlAttribute]. 
    
  2. Person.cs 文件中,使用 [XmlAttribute] 属性装饰姓名、姓氏和出生日期属性,并为每个属性设置一个简短名称,如下面的代码所示:

    **[****XmlAttribute(****"fname"****)****]**
    public string? FirstName { get; set; }
    **[****XmlAttribute(****"lname"****)****]**
    public string? LastName { get; set; }
    **[****XmlAttribute(****"dob"****)****]**
    public DateTime DateOfBirth { get; set; } 
    
  3. 运行代码,并注意文件大小从 793 字节减少到 488 字节,节省了超过三分之一的存储空间。这种减少是通过将属性值作为 XML 属性输出实现的,如下面的输出所示:

    **** File Info ****
    File: people.xml
    Path: C:\cs13net9\Chapter09\WorkingWithSerialization\bin\Debug\net9.0
    Size: 488 bytes.
    /------------------
    <?xml version="1.0" encoding="utf-8"?>
    <ArrayOfPerson  >
      <Person fname="Alice" lname="Smith" dob="1974-03-14T00:00:00" />
      <Person fname="Bob" lname="Jones" dob="1969-11-23T00:00:00" />
      <Person fname="Charlie" lname="Cox" dob="1984-05-04T00:00:00">
        <Children>
          <Person fname="Sally" lname="Cox" dob="2012-07-12T00:00:00" />
        </Children>
      </Person>
    </ArrayOfPerson>
    ------------------/ 
    

反序列化 XML 文件

现在,让我们尝试将 XML 文件反序列化回内存中的活动对象:

  1. Program.cs 文件中添加语句以打开 XML 文件,然后反序列化它,如下面的代码所示:

    SectionTitle("Deserializing XML files");
    using (FileStream xmlLoad = File.Open(path, FileMode.Open))
    {
      // Deserialize and cast the object graph into a "List of Person".
      List<Person>? loadedPeople =
        xs.Deserialize(xmlLoad) as List<Person>;
      if (loadedPeople is not null)
      {
        foreach (Person p in loadedPeople)
        {
          WriteLine("{0} has {1} children.",
            p.LastName, p.Children?.Count ?? 0);
        }
      }
    } 
    
  2. 运行代码,并注意人员已成功从 XML 文件中加载并枚举,如下面的输出所示:

    Smith has 0 children.
    Jones has 0 children.
    Cox has 1 children. 
    

更多信息:在 System.Xml.Serialization 命名空间中定义了许多其他属性,可以用来控制生成的 XML。一个好的起点是找到 XmlAttributeAttribute 类的官方文档,该文档位于此处:learn.microsoft.com/en-us/dotnet/api/system.xml.serialization.xmlattributeattribute。不要将此类与 System.Xml 命名空间中的 XmlAttribute 类混淆。后者用于在读取和写入 XML 时表示 XML 属性,使用 XmlReaderXmlWriter

如果不使用任何注解,XmlSerializer 在反序列化时会使用属性名进行不区分大小写的匹配。

良好实践:当使用 XmlSerializer 时,请记住,只有公共字段和属性会被包含,并且类型必须有一个无参构造函数。您可以使用属性来自定义输出。

使用 JSON 序列化

最受欢迎的 .NET 库之一用于处理 JSON 序列化格式是 Newtonsoft.Json,也称为 Json.NET。它成熟且功能强大。

Newtonsoft.Json 非常受欢迎,以至于它溢出了 NuGet 包管理器中用于下载计数的 32 位整数的界限,如下所示 图 9.5

图 9.5:2022 年 8 月 Newtonsoft.Json 的下载量达到负 20 亿

让我们看看实际效果:

  1. WorkingWithSerialization 项目中,添加对最新版本 Newtonsoft.Json 的包引用,如下所示:

    <ItemGroup>
      <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
    </ItemGroup> 
    
  2. 构建项目 WorkingWithSerialization 以恢复包。

  3. Program.cs 中,添加创建文本文件的语句,然后将人员序列化为 JSON 文件,如下所示:

    SectionTitle("Serializing with JSON");
    // Create a file to write to.
    string jsonPath = Combine(CurrentDirectory, "people.json");
    using (StreamWriter jsonStream = File.CreateText(jsonPath))
    {
      Newtonsoft.Json.JsonSerializer jss = new();
      // Serialize the object graph into a string.
      jss.Serialize(jsonStream, people);
    } // Closes the file stream and release resources.
    OutputFileInfo(jsonPath); 
    
  4. 运行代码,注意 JSON 相比于具有元素的 XML 所需的字节数更少。它甚至比使用属性的 XML 文件更小(366 与 488 相比),如下所示:

    **** File Info ****
    File: people.json
    Path: C:\cs13net9\Chapter09\WorkingWithSerialization\bin\Debug\net9.0
    Size: 366 bytes.
    /------------------
    [{"FirstName":"Alice","LastName":"Smith","DateOfBirth":"1974-03-14T00:00:00","Children":null},{"FirstName":"Bob","LastName":"Jones","DateOfBirth":"1969-11-23T00:00:00","Children":null},{"FirstName":"Charlie","LastName":"Cox","DateOfBirth":"1984-05-04T00:00:00","Children":[{"FirstName":"Sally","LastName":"Cox","DateOfBirth":"2012-07-12T00:00:00","Children":null}]}]
    ------------------/ 
    

高性能 JSON 处理

.NET Core 3 引入了一个新的命名空间来处理 JSON,System.Text.Json,它通过利用 Span<T> 等 API 来优化性能。

此外,像 Json.NET 这样的旧库是通过读取 UTF-16 实现的。使用 UTF-8 读取和写入 JSON 文档将更高效,因为大多数网络协议,包括 HTTP,都使用 UTF-8,你可以避免将 UTF-8 转换为和从 Json.NET 的 Unicode string 值转换。

使用新的 API,Microsoft 在不同场景下实现了 1.3x 到 5x 的性能提升。

Json.NET 的原始作者 James Newton-King 加入微软,并与他们一起开发新的 JSON 类型。正如他在讨论新 JSON API 的注释中所说,“Json.NET 不会消失”,如下所示 图 9.6

图 9.6:Json.NET 原作者的注释

反序列化 JSON 文件

让我们看看如何使用现代 JSON API 来反序列化一个 JSON 文件:

  1. WorkingWithSerialization 项目中,在 Program.cs 的顶部,导入新的 JSON 类以执行序列化,使用别名以避免与之前使用的 Json.NET 名称冲突,如下所示:

    using FastJson = System.Text.Json.JsonSerializer; 
    
  2. Program.cs 中,添加打开 JSON 文件、反序列化它并输出人员的姓名和子女数量的语句,如下所示:

    SectionTitle("Deserializing JSON files");
    await using (FileStream jsonLoad = File.Open(jsonPath, FileMode.Open))
    {
      // Deserialize object graph into a "List of Person".
      List<Person>? loadedPeople =
        await FastJson.DeserializeAsync(utf8Json: jsonLoad,
          returnType: typeof(List<Person>)) as List<Person>;
      if (loadedPeople is not null)
      {
        foreach (Person p in loadedPeople)
        {
          WriteLine("{0} has {1} children.",
            p.LastName, p.Children?.Count ?? 0);
        }
      }
    } 
    
  3. 运行代码并查看结果,如下所示:

    Smith has 0 children.
    Jones has 0 children.
    Cox has 1 children. 
    

    良好实践:选择 Json.NET 以提高开发效率和功能集,或选择 System.Text.Json 以提高性能。您可以在以下链接中查看差异列表:learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to#table-of-differences-between-newtonsoftjson-and-systemtextjson

JSON 架构导出器

.NET 9 引入了一个类,用于轻松为 .NET 类型生成 JSON 架构。因此,序列化简化了,数据符合预期的格式。

让我们看看一个工作示例:

  1. WorkingWithSerialization 项目中,在 Program.cs 文件的顶部,导入执行 JSON 序列化和处理 JSON 架构所需的命名空间,如下面的代码所示:

    using System.Text.Json; // To use JsonSerializerOptions.
    using System.Text.Json.Schema; // To use JsonSchemaExporter. 
    
  2. Program.cs 文件中,添加导出 Person 类 JSON 架构的语句,如下面的代码所示:

    SectionTitle("JSON schema exporter");
    WriteLine(JsonSchemaExporter.GetJsonSchemaAsNode(
      JsonSerializerOptions.Default, typeof(Person))); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    {
      "type": [
        "object",
        "null"
      ],
      "properties": {
        "FirstName": {
          "type": [
            "string",
            "null"
          ]
        },
        "LastName": {
          "type": [
            "string",
            "null"
          ]
        },
        "DateOfBirth": {
          "type": "string",
          "format": "date-time"
        },
        "Children": {
          "type": [
            "array",
            "null"
          ],
          "items": {
            "$ref": "#"
          }
        }
      }
    } 
    

控制 JSON 处理

有许多选项可以控制 JSON 的处理方式,如下面的列表所示:

  • 包含和排除字段

  • 设置大小写策略

  • 选择大小写敏感策略

  • 选择紧凑格式和格式化空格

让我们看看一些实际操作:

  1. 使用您首选的代码编辑器向 Chapter09 解决方案中添加一个名为 ControllingJson 的新 Console App / console 项目。

  2. 在项目文件中,添加元素以静态和全局导入 System.ConsoleSystem.EnvironmentSystem.IO.Path 类。

  3. ControllingJson 项目中,添加一个名为 Book.cs 的新类文件。

  4. Book.cs 文件中,定义一个名为 Book 的类,如下面的代码所示:

    using System.Text.Json.Serialization; // To use [JsonInclude].
    namespace Packt.Shared;
    public class Book
    {
      // Constructor to set non-nullable property.
      public Book(string title)
      {
        Title = title;
      }
      // Properties.
      public string Title { get; set; }
      public string? Author { get; set; }
      // Fields.
      [JsonInclude] // Include this field.
      public DateTime PublishDate;
      [JsonInclude] // Include this field.
      public DateTimeOffset Created;
      public ushort Pages;
    } 
    
  5. Program.cs 文件中,删除现有的语句,然后导入处理高性能 JSON 和 Book 的命名空间,如下面的代码所示:

    using Packt.Shared; // To use Book.
    using System.Text.Json; // To use JsonSerializer. 
    
  6. Program.cs 文件中,添加创建 Book 类实例并将其序列化为 JSON 的语句,如下面的代码所示:

    Book csharpBook = new(title:
      "C# 13 and .NET 9 - Modern Cross-Platform Development Fundamentals")
    {
      Author = "Mark J Price",
      PublishDate = new(year: 2024, month: 11, day: 12),
      Pages = 823,
      Created = DateTimeOffset.UtcNow,
    };
    JsonSerializerOptions options = new()
    {
      IncludeFields = true, // Includes all fields.
      PropertyNameCaseInsensitive = true,
      WriteIndented = true,
      PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };
    string path = Combine(CurrentDirectory, "book.json");
    using (Stream fileStream = File.Create(path))
    {
      JsonSerializer.Serialize(
        utf8Json: fileStream, value: csharpBook, options);
    }
    WriteLine("**** File Info ****");
    WriteLine($"File: {GetFileName(path)}");
    WriteLine($"Path: {GetDirectoryName(path)}");
    WriteLine($"Size: {new FileInfo(path).Length:N0} bytes.");
    WriteLine("/------------------");
    WriteLine(File.ReadAllText(path));
    WriteLine("------------------/"); 
    
  7. 运行代码并查看结果,如下面的输出所示:

    **** File Info ****
    File: book.json
    Path: C:\cs13net9\Chapter09\ControllingJson\bin\Debug\net9.0
    Size: 221 bytes.
    /------------------
    {
      "title": "C# 13 and .NET 9 - Modern Cross-Platform Development Fundamentals",
      "author": "Mark J Price",
      "publishDate": "2024-11-12T00:00:00",
      "created": "2024-07-13T14:29:07.119631+00:00",
      "pages": 823
    }
    ------------------/ 
    

注意以下内容:

  • JSON 文件大小为 221 字节。

  • 成员名称使用驼峰式大小写,例如,publishDate。这对于在浏览器中使用 JavaScript 进行后续处理最佳。

  • 由于设置了选项,所有字段都被包含在内,包括 pages

  • JSON 被格式化以方便人类阅读。

  • DateTimeDateTimeOffset 值存储为单个标准 string 格式。

  1. Program.cs 文件中,当设置 JsonSerializerOptions 时,取消注释大小写策略的设置,使用缩进并包含字段。

  2. 运行代码并查看结果,如下面的输出所示:

    **** File Info ****
    File: book.json
    Path: C:\cs13net9\Chapter09\ControllingJson\bin\Debug\net9.0
    Size: 184 bytes.
    /------------------
    {"Title":"C# 13 and .NET 9 - Modern Cross-Platform Development Fundamentals","Author":"Mark J Price","PublishDate":"2024-11-12T00:00:00","Created":"2024-07-13T14:30:29.2205861+00:00"}
    ------------------/ 
    

注意以下内容:

  • JSON 文件大约减少了 20%。

  • 成员名称使用常规大小写,例如,PublishDate

  • Pages 字段缺失。其他字段由于 PublishDateCreated 字段上的 [JsonInclude] 属性而被包含。

关于使用 BinaryFormatter 进行二进制序列化的警告

BinaryFormatter 类是一个序列化类,可以将活动对象或连接对象图转换为可以存储或传输的二进制格式,并且可以在以后重建。这个类自 .NET 的早期版本以来一直是其一部分,位于 System.Runtime.Serialization.Formatters.Binary 命名空间中。但是,BinaryFormatter 存在几个问题,主要与安全和健壮性相关。

例如,BinaryFormatter 易受反序列化攻击的影响,恶意输入可以在反序列化过程中执行任意代码。这使得任何使用 BinaryFormatter 的应用程序如果攻击者可以控制序列化数据,就会容易受到远程代码执行RCE)的影响。BinaryFormatter 对其反序列化的数据不执行任何验证或检查,因此在处理不受信任的数据时固有不安全。

BinaryFormatter 不支持许多现代序列化功能,如 JSON 或 XML 序列化能力,这些功能更安全且更灵活。

良好实践:微软建议使用 System.Text.JsonSystem.Xml.SerializationGoogle.Protobuf 等序列化库。

在 .NET Core 的第一个版本中,由于已知的风险,微软团队完全移除了 BinaryFormatter,但因为没有明确的路径使用更安全的东西,客户要求将其恢复,团队在 .NET Core 1.1 中实现了这一点。从那时起,团队默认禁用了它,但允许开发者通过设置标志明确重新启用它,如果他们接受这些风险。

在 .NET 9 中,启用 BinaryFormatter 的标志已被移除,任何使用它的尝试都会抛出异常。但仍然有一种方法可以重新启用它,通过引用标记为永久性易受攻击的 NuGet 包并设置一些配置。

更多信息:您可以在以下链接中了解更多关于为什么以及如何在 .NET 9 中移除 BinaryFormatter 类的信息:https://devblogs.microsoft.com/dotnet/binaryformatter-removed-from-dotnet-9/

练习和探索

通过回答一些问题、进行一些实际操作练习,并更深入地研究本章的主题来测试您的知识和理解。

练习 9.1 – 在线材料

在线材料可以是我为这本书写的额外内容,也可以是引用微软或第三方创建的内容。

与 tar 存档一起工作

如果您使用 Linux,那么您可能对如何以编程方式处理 tar 存档感兴趣。我编写了一个仅在线的部分,介绍这些内容,您可以在以下链接中找到:https://github.com/markjprice/cs13net9/blob/main/docs/ch09-tar-archives.md

github.com/markjprice/cs13net9/blob/main/docs/ch09-tar-archives.md

从 Newtonsoft 迁移到新的 JSON

如果你现有的代码使用了 Newtonsoft Json.NET 库,并且你想迁移到新的 System.Text.Json 命名空间,那么微软有针对这一点的特定文档,你可以在以下链接中找到:

learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to

练习 9.2 – 练习题

练习题深入探讨了本章的主题。

以 XML 格式序列化

Chapter09 解决方案中,创建一个名为 Exercise_SerializingShapes 的控制台应用程序,该程序创建一个形状列表,使用序列化将其保存到文件系统中以 XML 格式,然后将其反序列化回来:

// Create a list of Shapes to serialize.
List<Shape> listOfShapes = new()
{
  new Circle { Colour = "Red", Radius = 2.5 },
  new Rectangle { Colour = "Blue", Height = 20.0, Width = 10.0 },
  new Circle { Colour = "Green", Radius = 8.0 },
  new Circle { Colour = "Purple", Radius = 12.3 },
  new Rectangle { Colour = "Blue", Height = 45.0, Width = 18.0 }
}; 

形状应该有一个名为 Area 的只读属性,这样在反序列化时,你可以输出一个包含其面积的形状列表,如下所示:

List<Shape> loadedShapesXml =
  serializerXml.Deserialize(fileXml) as List<Shape>;
foreach (Shape item in loadedShapesXml)
{
  WriteLine("{0} is {1} and has an area of {2:N2}",
    item.GetType().Name, item.Colour, item.Area);
} 

当你运行你的控制台应用程序时,你的输出应该看起来像这样:

Loading shapes from XML:
Circle is Red and has an area of 19.63
Rectangle is Blue and has an area of 200.00
Circle is Green and has an area of 201.06
Circle is Purple and has an area of 475.29
Rectangle is Blue and has an area of 810.00 

练习 9.3 – 测试你的知识

回答以下问题:

  1. 使用 File 类和 FileInfo 类有什么区别?

  2. 流的 ReadByte 方法与 Read 方法的区别是什么?

  3. 你会在什么情况下使用 StringReaderTextReaderStreamReader 类?

  4. DeflateStream 类型的作用是什么?

  5. UTF-8 编码每字符使用多少字节?

  6. 对象图是什么?

  7. 选择哪种序列化格式以最小化空间需求最好?

  8. 对于跨平台兼容性,选择哪种序列化格式最好?

  9. 为什么使用像 "\Code\Chapter01" 这样的 string 值来表示路径是糟糕的,你应该做什么?

  10. 你在哪里可以找到有关 NuGet 包及其依赖项的信息?

练习 9.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-9---working-with-files-streams-and-serialization

摘要

在本章中,你学习了如何:

  • 从文本文件中读取和写入。

  • 从 XML 文件中读取和写入。

  • 压缩和解压缩文件。

  • 编码和解码文本。

  • 将对象图序列化为 JSON 和 XML。

  • 从 JSON 和 XML 中反序列化对象图。

在下一章中,你将学习如何使用 Entity Framework Core 与数据库一起工作。

第十章:使用 Entity Framework Core 处理数据

本章介绍了如何通过使用名为Entity Framework CoreEF Core)的对象到数据存储映射技术,从关系型数据存储(如 SQLite 和 SQL Server)中读取和写入数据。

本章将涵盖以下主题:

  • 理解现代数据库

  • 在.NET 项目中设置 EF Core

  • 定义 EF Core 模型

  • 查询 EF Core 模型

理解现代数据库

存储数据最常见的两个地方是在关系型数据库管理系统RDBMS)中,例如 SQL Server、PostgreSQL、MySQL 和 SQLite,或者是在NoSQL数据库中,例如 Azure Cosmos DB、Redis、MongoDB 和 Apache Cassandra。

关系型数据库是在 20 世纪 70 年代发明的。它们使用结构化查询语言SQL)进行查询。当时,数据存储成本很高,因此它们尽可能地减少了数据冗余。数据存储在具有行和列的表格结构中,一旦在生产中重构,这些行和列就变得难以重构。它们可能难以扩展且成本高昂。

NoSQL 数据库不仅仅意味着“没有 SQL”;它们也可以意味着“不仅限于 SQL”。它们是在 21 世纪初互联网和万维网变得流行之后发明的,并采纳了那个软件时代的许多经验教训。它们被设计用于大规模可扩展性、高性能,并通过提供最大灵活性和允许随时更改模式来简化编程,因为它们不强制执行结构。

如果你对关系型数据库一无所知,那么你应该阅读我以下链接中写的数据库入门指南:

github.com/markjprice/cs13net9/blob/main/docs/ch10-database-primer.md

理解遗留的 Entity Framework

Entity FrameworkEF)最初于 2008 年底作为.NET Framework 3.5 Service Pack 1 的一部分发布。从那时起,随着微软观察到程序员在实际世界中如何使用对象关系映射ORM)工具,EF 已经发展演变。

ORMs 使用映射定义将表中的列与类中的属性关联起来。然后,程序员可以以他们熟悉的方式与不同类型的对象进行交互,而无需知道如何将值存储在关系型表或其他 NoSQL 数据存储提供的数据结构中。

随.NET Framework 一起提供的 EF 版本是Entity Framework 6EF6)。它成熟稳定,支持使用 EDMX(XML 文件)定义模型的方式,以及复杂的继承模型和其他一些高级功能。

EF 6.3 及更高版本已从 .NET Framework 中提取为单独的包,因此可以在 .NET Core 3 及更高版本上支持。这使得像 Web 应用程序和服务这样的现有项目可以移植并在跨平台上运行。然而,EF6 应被视为旧技术,因为它在跨平台运行时有一些限制,并且不会为其添加新功能。

使用旧版 Entity Framework 6.3 或更高版本

要在 .NET Core 3 或更高版本的项目中使用旧版 Entity Framework,你必须在你项目文件中添加对其的包引用,如下所示标记:

<PackageReference Include="EntityFramework" Version="6.5.1" /> 

最佳实践:只有在你必须使用旧版 EF6 的情况下才使用它;例如,你可能使用它将使用 EF6 在 .NET Framework 上运行的 Windows Presentation Foundation(WPF)应用程序迁移到现代 .NET。这本书是关于现代跨平台开发的,因此在本章的其余部分,我将只介绍现代 EF Core。你不需要在项目文件中引用上述所示的旧版 EF6 包。

理解 Entity Framework Core

真正的跨平台版本,EF Core,与旧版 Entity Framework 不同。尽管 EF Core 有一个类似的名字,但你应该意识到它与 EF6 的不同之处。最新的 EF Core 版本是 9,以匹配 .NET 9。

EF Core 9 面向 .NET 8 或更高版本,因为 EF Core 团队希望尽可能多的开发者能够从未来版本的新功能中受益,即使你只需要针对 .NET 的长期支持版本。这意味着你可以使用 .NET 8 或 .NET 9 中的所有 EF Core 9 的新功能。但是,当 2025 年 11 月发布 EF Core 10 时,你的项目将需要针对 .NET 10 才能使用它。

EF Core 3 及更高版本仅与支持 .NET Standard 2.1 的平台一起工作,这意味着 .NET Core 3 及更高版本。EF Core 3 及更高版本不支持 .NET Standard 2.0 平台,如 .NET Framework 4.8。

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

EF Core 在每个版本中都进行了许多改进,以至于本章无法涵盖所有内容。在本章中,我将重点介绍所有 .NET 开发者都应该了解的基本知识以及一些最有用的新功能。你可以在我的配套书籍《.NET 8 应用和服务》中了解更多关于 EF Core 的信息以及如何使用 SQL Server,或者通过阅读以下链接中的官方文档:

learn.microsoft.com/en-us/ef/core/

你可以通过以下链接了解最新的 EF Core 新闻:

aka.ms/efnews

理解数据库优先和代码优先

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

  • 数据库优先:已经存在一个数据库,因此你将构建一个与其结构和功能相匹配的模型。这在现实生活中是最常见的场景。你将在本章中看到这个示例。

  • 代码优先:不存在数据库,因此你构建一个模型,然后使用 EF Core 创建一个与其结构和功能相匹配的数据库。如果你完成了本章末尾练习中链接的在线部分,你将看到这个示例。

EF Core 的性能改进

EF Core 团队继续努力提高 EF Core 的性能。例如,如果 EF Core 在调用SaveChanges时确定数据库将只执行一条语句,那么它就不会像早期版本那样创建显式事务。这为常见场景提供了 25%的性能提升。

本章中关于所有最近性能改进的信息太多,无法涵盖,而且你无需了解它们是如何工作的就可以获得所有好处。如果你感兴趣(而且他们研究的内容以及如何特别利用一些酷的 SQL Server 功能是非常迷人的),那么我建议你阅读 EF Core 团队发布的以下帖子:

使用示例关系型数据库

要学习如何使用.NET 管理 RDBMS,拥有一个示例数据库会很有用,这样你就可以在一个具有中等复杂度和相当数量的示例记录的数据库上练习。微软提供了几个示例数据库,其中大多数对于我们来说过于复杂,因此,我们将使用一个最初在 20 世纪 90 年代初创建的数据库,称为Northwind

让我们花一分钟时间看看Northwind数据库的图示。你可以使用图 10.1中的图示来参考我们在本书编写代码和查询时的内容:

图 10.1:Northwind数据库表和关系

你将在本章后面编写与CategoriesProducts表交互的代码,以及后续章节中的其他表。但在我们这样做之前,请注意以下内容:

  • 每个类别都有一个唯一的标识符、名称、描述和图片。

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

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

  • CategoriesProducts之间的关系是一对多,这意味着每个类别可以有零个或多个产品。这在图 10.1中通过一端的一个无穷符号(表示多个)和另一端的一个黄色钥匙(表示一个)来表示。

使用 SQLite

SQLite 是一个小型、快速、跨平台、自包含的关系型数据库管理系统,可在公共领域获得。它是 iOS(iPhone 和 iPad)和 Android 等移动平台最常见的 RDBMS。SQLite 是世界上使用最广泛的数据库引擎,有超过一千亿个 SQLite 数据库正在使用中。您可以在www.sqlite.org/mostdeployed.html了解更多信息。

我决定在这本书中使用 SQLite 来演示数据库,因为其重要主题是跨平台开发和只需要基本数据库功能的基本技能。我建议您最初使用 SQLite 完成本书的代码任务。如果您也想尝试使用 SQL Server 的代码任务,那么我在 GitHub 存储库的在线部分提供了相应的文档。

使用 SQL Server 或其他 SQL 系统

习惯于使用 Windows 的企业通常也会使用 SQL Server 作为其数据库。如果您希望使用 SQL Server,请参阅以下链接中的在线说明:

github.com/markjprice/cs13net9/blob/main/docs/sql-server/README.md

如果您希望使用不同的 SQL 系统,那么我提供的 SQL 脚本应该适用于大多数 SQL 系统,例如 PostgreSQL 或 MySQL。然而,我并没有为它们编写逐步说明,也不保证它们会工作。

我的建议是使用 SQLite 完成这本书,这样您可以专注于学习书中关于 EF Core 的内容,而不是尝试使用不同的数据库系统带来的复杂性。学习本身就很难;不要贪多,也不要给自己增加不必要的难度。一旦您学到了书中的内容,您总是可以用不同的数据库系统重复学习。

设置 Windows 的 SQLite CLI 工具

在 Windows 上,我们需要下载 SQLite CLI 工具并将 SQLite 文件夹添加到系统路径中,这样在命令提示符或终端中输入命令时就可以找到它:

  1. 打开您喜欢的浏览器并导航到www.sqlite.org/download.html

  2. 将页面向下滚动到Windows 的预编译二进制文件部分。

  3. 点击sqlite-tools-win32-x86-3460100.zip(文件可能具有更高的版本号),如下面的截图所示:

图片

图 10.2:下载 Windows 版的 SQLite

  1. 将 ZIP 文件解压到名为C:\Sqlite\的文件夹中。请确保包括sqlite3.exe在内的三个解压文件直接位于C:\SQLite文件夹内,否则在您尝试使用它时可能会找不到可执行文件。

  2. 在 Windows 的开始菜单中,导航到设置

  3. 在搜索框中搜索环境并选择编辑系统环境变量。在非英文版本的 Windows 中,请搜索您当地语言中的等效词汇以找到设置。

  4. 点击 环境变量 按钮。

  5. 系统变量 中,从列表中选择 Path,然后点击 编辑…

  6. 如果 C:\SQLite 还未在路径中,则点击 新建,输入 C:\Sqlite,然后按 Enter

  7. 点击 确定,然后 确定,再次点击 确定,最后关闭 设置

  8. 要确认 SQLite 的路径已正确配置,在任何命令提示符或终端中,输入以下命令以启动 SQLite:

    sqlite3 
    
  9. 注意结果,如下所示:

    SQLite version 3.42.0 2023-05-16 12:36:15
    Enter ".help" for usage hints.
    Connected to a transient in-memory database.
    Use ".open FILENAME" to reopen on a persistent database.
    sqlite> 
    
  10. 要退出 SQLite 命令提示符,请执行以下操作:

    • 在 Windows 上,按 Ctrl + C 两次。

    • 在 macOS 上,按 Ctrl + D

为 macOS 和 Linux 设置 SQLite

在 macOS 上,SQLite 包含在 /usr/bin/ 目录中,作为名为 sqlite3 的命令行应用程序。

在 Linux 上,您可以使用以下命令设置 SQLite:

sudo apt-get install sqlite3 

SQLite 可以从以下链接下载和安装到其他操作系统:

www.sqlite.org/download.html

在 .NET 项目中设置 EF Core

现在我们已经设置了一个数据库系统,我们可以创建一个使用它的数据库和 .NET 项目。

创建用于处理 EF Core 的控制台应用程序

首先,我们将为本章创建一个控制台应用程序项目。

使用您首选的代码编辑器创建一个新项目,如下列所示:

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

  • 项目文件和文件夹:WorkingWithEFCore

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

为 SQLite 创建 Northwind 示例数据库

现在,我们可以使用 SQL 脚本创建 SQLite 的 Northwind 示例数据库:

  1. 如果您之前尚未克隆或下载此书的 GitHub 仓库的 ZIP 文件,请现在使用以下链接进行操作:github.com/markjprice/cs13net9

  2. 将创建 Northwind 数据库的脚本从您本地 Git 仓库或解压 ZIP 文件的位置复制到 WorkingWithEFCore 文件夹中:/scripts/sql-scripts/Northwind4SQLite.sql

  3. WorkingWithEFCore 项目文件夹中启动一个命令提示符或终端:

    • 在 Windows 上,启动 文件资源管理器,右键单击 WorkingWithEFCore 文件夹,然后选择 在此文件夹中新建命令提示符在 Windows 终端中打开

    • 在 macOS 上,启动 Finder,右键单击 WorkingWithEFCore 文件夹,然后选择 在此文件夹中新建终端

  4. 输入命令以使用 SQLite 执行 SQL 脚本并创建 Northwind.db 数据库,如下所示:

    sqlite3 Northwind.db -init Northwind4SQLite.sql 
    
  5. 请耐心等待,因为此命令可能需要一段时间来创建数据库结构。最终,您将看到 SQLite 命令提示符,如下所示:

    -- Loading resources from Northwind4SQLite.sql
    SQLite version 3.42.0 2023-05-16 12:36:15
    Enter ".help" for usage hints.
    sqlite> 
    
  6. 要退出 SQLite 命令提示符,请执行以下操作:

    • 在 Windows 上,按 Ctrl + C 两次。

    • 在 macOS 或 Linux 上,按 Ctrl + D

  7. 您可以保持命令提示符或终端窗口打开,因为您很快还会再次使用它。

如果您正在使用 Visual Studio

如果你使用 VS Code 和 dotnet run 命令,编译的应用程序将在 WorkingWithEFCore 文件夹中执行,允许它找到存储在该文件夹中的数据库文件。但如果你使用 Visual Studio 或 Rider,则编译的应用程序将在 WorkingWithEFCore\bin\Debug\net9.0 文件夹中执行,因此它将找不到数据库文件,因为它不在该目录中。

让我们告诉 Visual Studio 将数据库文件复制到它运行代码的目录中,以便它可以找到该文件,但只有当数据库文件较新或缺失时,它才不会覆盖我们在运行时所做的任何数据库更改:

  1. 解决方案资源管理器 中,右键单击 Northwind.db 文件并选择 属性

  2. 属性 中,将 复制到输出目录 设置为 如果较新则复制

  3. WorkingWithEFCore.csproj 中,注意以下标记中的新元素:

    <ItemGroup>
      <None Update="Northwind.db">
        <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
      </None>
    </ItemGroup> 
    

如果你更喜欢每次启动项目时覆盖数据更改,则将 CopyToOutputDirectory 设置为 始终

你可以手动修改项目文件,而不是使用 属性 窗口进行上述更改。实际上,任何工具对项目文件所做的更改都可以通过编辑 XML 来手动完成。像 属性 窗口这样的工具只是读取项目中的文件并显示一个替代视图。这就是为什么在 第一章 中,我强调在学习 .NET 时使用多个代码编辑器。危险在于,如果你只使用 Visual Studio,那么你可能会开始认为 Visual Studio 就是 .NET 开发。事实并非如此。

真正的 .NET 开发是源代码文件的内容,如 .cs 文件和项目文件 .csproj,然后通过命令行界面 dotnet 进行编译。你使用的任何其他工具都只是在这个基础上添加的一个额外层。

有趣的是,.NET 的一个未来特性,微软正在积极考虑的是“隐式项目文件”。这意味着有一个只包含一个或多个 .cs 文件和 .csproj 文件的文件夹,而 .csproj 文件不需要存在,因为其内容可以通过默认值隐式表示。例如,它可能默认为当前的 SDK 和目标 .NET 版本等。但这很复杂,所以我们将不得不等待并看看这是否会真的发生。

使用 SQLiteStudio 管理 Northwind 示例数据库

你可以使用一个名为 SQLiteStudio 的跨平台图形数据库管理器轻松管理 SQLite 数据库:

  1. 导航到 sqlitestudio.pl,然后下载并安装应用程序。

  2. 启动 SQLiteStudio

  3. 导航到 数据库 | 添加数据库

  4. 数据库 对话框中,在 文件 部分,单击黄色文件夹按钮以在本地计算机上浏览现有的数据库文件,在 WorkingWithEFCore 项目文件夹中选择 Northwind.db 文件,然后单击 确定,如图 10.3 所示:

图片

图 10.3:将 Northwind.db 数据库文件添加到 SQLiteStudio

  1. 如果您看不到数据库,请转到视图 | 数据库

  2. 数据库窗口中,右键单击Northwind数据库并选择连接到数据库(或直接双击Northwind)。您将看到由脚本创建的 10 个表。(SQLite 的脚本比 SQL Server 的简单;它不会创建那么多表或其他数据库对象。)

  3. 右键单击产品表并选择编辑表,或直接双击表。

  4. 在表编辑器窗口中,注意Products表的结构,包括列名、数据类型、键和约束,如图 10.4 所示:

图片

图 10.4:SQLiteStudio 中的表编辑器显示产品表的结构

  1. 在表编辑器窗口中,单击数据标签,您将看到 77 个产品,如图 10.5 所示:

图片

图 10.5:显示产品表中 77 行的数据标签

  1. 数据库窗口中,右键单击Northwind并选择从数据库断开连接

  2. 退出 SQLiteStudio。

使用轻量级 ADO.NET 数据库提供程序

在 Entity Framework 之前,有ADO.NET。与 EF 相比,这是一个更简单、更高效的数据库操作 API。它提供了如DbConnectionDbCommandDbReader之类的抽象类,以及特定提供程序的实现,如SqlConnectionSqlCommand

在本章中,如果您选择使用 SQL Server,那么您应该使用SqlConnectionStringBuilder类来帮助编写有效的连接字符串。这是因为它具有所有可能数据库连接字符串部分的属性,您可以单独设置它们,然后它返回完整的字符串。您还应该从环境变量或秘密管理系统获取敏感信息,如密码,而不是将它们写入源代码。

对于 SQLite,连接字符串非常简单,您不需要使用SqliteConnectionStringBuilder类。

EF Core 的 SQLite 和 SQL Server 数据库提供程序建立在 ADO.NET 库之上,因此 EF Core 始终比 ADO.NET 本身慢。

如果您想使用原生的提前编译AOT)发布,请注意 EF Core 目前还不支持它。这意味着如果您计划编译为原生代码,则只能使用 ADO.NET 库。EF Core 团队正在调查他们如何支持原生 AOT,但这很具挑战性,因此今年 EF Core 9 没有实现。希望它将在 2025 年的 EF Core 10 中实现。

除了SqlConnectionStringBuilder之外,本书不涵盖使用 ADO.NET 库,但在配套书籍《.NET 8 中的应用程序和服务》中,我涵盖了如何使用 ADO.NET for SQL Server 库发布原生 AOT 最小 API Web 服务的示例。

您可以在以下链接中了解更多关于 ADO.NET for SQLite 库的信息:

learn.microsoft.com/en-us/dotnet/standard/data/sqlite/

您可以在以下链接中了解更多关于 SQL Server 的 ADO.NET 库信息:

learn.microsoft.com/en-us/sql/connect/ado-net/microsoft-ado-net-sql-server

警告! 所有 System.Data.SqlClient 用户都鼓励过渡到 Microsoft.Data.SqlClient。您可以在以下链接中阅读有关 System.Data.SqlClient 包现在已弃用的公告:

techcommunity.microsoft.com/t5/sql-server-blog/announcement-system-data-sqlclient-package-is-now-deprecated/ba-p/4227205

选择 EF Core 数据库提供者

在我们深入探讨使用 EF Core 管理数据的实际操作之前,让我们简要地谈谈选择 EF Core 数据库提供者。为了管理特定数据库中的数据,我们需要知道如何高效地与该数据库通信的类。

EF Core 数据库提供者是一组针对特定数据存储进行优化的类。甚至还有一个提供者可以将数据存储在当前进程的内存中,这对于高性能单元测试非常有用,因为它避免了访问外部系统。

它们作为 NuGet 包分发,如 表 10.1 所示:

管理此数据存储 参考此 NuGet 包
SQL Server 2012 或更高版本 Microsoft.EntityFrameworkCore.SqlServer
SQLite 3.7 或更高版本 Microsoft.EntityFrameworkCore.SQLite
内存中存储 Microsoft.EntityFrameworkCore.InMemory
Azure Cosmos DB SQL API Microsoft.EntityFrameworkCore.Cosmos
MySQL MySQL.EntityFrameworkCore
Oracle 数据库 11.2 Oracle.EntityFrameworkCore
PostgreSQL Npgsql.EntityFrameworkCore.PostgreSQL

表 10.1:常见 EF Core 数据库提供者的 NuGet 包

您可以在同一个项目中引用您需要的任何 EF Core 数据库提供者。每个包都包括常见的共享类型,以及特定提供者的类型。

连接到命名的 SQLite 数据库

要连接到 SQLite 数据库,我们只需要知道数据库路径和文件名,可以使用传统的参数 Filename 或现代的等效参数 Data Source 来设置。路径可以是相对于当前目录的相对路径或绝对路径。我们将在 连接字符串 中指定这些信息。

定义 Northwind 数据库上下文类

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

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

我们将创建一个使用 SQLite 的项目,但如果你更熟悉 SQL Server 或其他数据库系统,也可以使用:

  1. WorkingWithEFCore 项目中,添加对 SQLite EF Core 提供程序的包引用,并在所有 C# 文件中全局和静态导入 System.Console 类,如下面的标记所示:

    <ItemGroup>
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <PackageReference Version="9.0.0"
        Include="Microsoft.EntityFrameworkCore.Sqlite" />
    </ItemGroup> 
    
  2. 构建 WorkingWithEFCore 项目以恢复包。

    在 2025 年 2 月之后,你可以通过指定版本 10.0-* 来尝试 EF Core 10 的预览版,因此你也必须安装 .NET 10 SDK 的预览版。你的项目目标框架必须是 net10.0。通过使用通配符,当你恢复项目的包时,将自动下载最新的月度预览版。一旦 2025 年 11 月发布 EF Core 10 GA 版本,将包版本更改为 10.0.0。在 2026 年 2 月之后,你将能够以相同的方式使用 EF Core 11,并且这将继续需要以 net10.0 为目标的项目。

  3. 在项目文件夹中,添加一个名为 NorthwindDb.cs 的新类文件。

  4. NorthwindDb.cs 文件中,导入 EF Core 的主命名空间,定义一个名为 NorthwindDb 的类,并使该类继承自 DbContext。然后,在 OnConfiguring 方法中,配置选项构建器以使用 SQLite 和适当的数据库连接字符串,如下面的代码所示:

    using Microsoft.EntityFrameworkCore; // To use DbContext and so on.
    namespace Northwind.EntityModels;
    // This manages interactions with the Northwind database.
    public class NorthwindDb : DbContext
    {
      protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
      {
        string databaseFile = "Northwind.db";
        string path = Path.Combine(
          Environment.CurrentDirectory, databaseFile);
        string connectionString = $"Data Source={path}";
        WriteLine($"Connection: {connectionString}");
        optionsBuilder.UseSqlite(connectionString);
      }
    } 
    

    良好实践:一些代码编辑器,如 Visual Studio,会在你重写成员时自动添加对基类成员的调用。尽管这通常是良好实践,但在这种情况下是不必要的,因为基实现没有任何作用。要决定是否需要保留调用,请查看成员的提示或定义。如果你将鼠标悬停在 OnConfiguring 方法名称上,其提示会告诉你“基实现没有任何作用。”如果你 转到定义 (F12) 该方法,你会看到它没有任何作用,如下面的代码所示:

    protected internal virtual void OnConfiguring(
    DbContextOptionsBuilder optionsBuilder)
    {
    } 
    

    该方法仅存在于基类中,以便子类可以重写它,然后 EF Core 在需要配置数据上下文时调用你的代码。如果你的重写方法实现调用了基实现,那将是一种浪费。如果你的代码编辑器自动添加了对基类的调用,那么你应该删除该语句以使你的代码与书中的代码匹配。在书中稍后重写 OnModelCreating 方法时,这也适用。

  5. Program.cs 文件中,删除现有的语句。然后,导入 Northwind.EntityModels 命名空间,并输出数据库提供程序,如下面的代码所示:

    using Northwind.EntityModels; // To use Northwind.
    using NorthwindDb db = new();
    WriteLine($"Provider: {db.Database.ProviderName}");
    // Disposes the database context. 
    
  6. 运行控制台应用程序,并注意显示数据库连接字符串和所使用的数据库提供程序的输出,如下面的输出所示:

    Connection: Data Source=C:\cs13net9\Chapter10\WorkingWithEFCore\bin\Debug\net9.0\Northwind.db
    Provider: Microsoft.EntityFrameworkCore.Sqlite 
    

现在,你已经知道了如何通过定义 EF Core 数据上下文来连接数据库。接下来,我们需要定义一个表示数据库中表的模型。

定义 EF Core 模型

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

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

使用 EF Core 约定定义模型

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

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

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

  • .NET 中的 string 类型在数据库中假定是 nvarchar 类型。

  • .NET 中的 int 类型在数据库中假定是 int 类型。

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

良好实践:还有许多其他约定您应该了解,您甚至可以定义自己的约定,但这超出了本书的范围。您可以在以下链接中了解它们:

learn.microsoft.com/en-us/ef/core/modeling/

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

约定通常不足以完全将类映射到数据库对象。使您的模型更智能的一种简单方法是应用注解属性。EF Core 识别的一些常见属性在 表 10.2 中显示:

属性 描述
[Required] 这确保值不是 null。在 .NET 8 中,它有一个 DisallowAllDefaultValues 参数,以防止值类型具有其默认值。例如,int 不能是 0
[StringLength(50)] 这确保值长度不超过 50 个字符。
[Column(TypeName = "money", Name = "UnitPrice")] 这指定了表中使用的列类型和列名。

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

警告! [StringLength] 并不是所有 EF Core 数据提供程序都支持的。例如,虽然 SQL Server 支持,但 SQLite 不支持。对于 SQLite,请使用 [Column(TypeName = "text(50)")] 代替。

一些可以用于验证实体并受 ASP.NET Core 和 Blazor 等平台认可的附加属性在 表 10.3 中显示:

属性 描述
[RegularExpression(expression)] 这确保值与指定的正则表达式匹配。
[EmailAddress] 这确保值包含一个@符号,但不能作为第一个或最后一个字符。它不使用正则表达式。
[Range(1, 10)] 这确保doubleintstring值在指定的范围内。在.NET 8 中新增了MinimumIsExclusiveMaximumIsExclusive参数。
[Length(10, 20)] 这确保字符串或集合在指定的长度范围内,例如,至少 10 个字符或项目,最多 20 个字符或项目。
[Base64String] 这确保值是一个格式良好的 Base64 字符串。
[AllowedValues] 这确保值是对象params数组中的其中一个项。例如,"alpha""beta""gamma"123
[DeniedValues] 确保值不是对象params数组中的任何一个项。例如,"alpha""beta""gamma"123

表 10.3:验证注解属性

为什么EmailAddress属性看起来如此基础?根据 ajcvickers 的评论,他也是 GitHub 问题的关闭者,“检查是有意为之的简单,因为做到无误是非常困难的。电子邮件实际上应该通过其他方式验证,例如通过发送电子邮件的确认流程。验证属性仅设计用于捕获像 U.I.这样的明显错误值。”您可以在以下链接中阅读辩论:

github.com/dotnet/runtime/issues/27592

例如,在数据库中,产品名称的最大长度为 40,且值不能为空,如下面从Northwind4SQLite.sql脚本文件中高亮显示的数据定义语言DDL)代码所示,该代码定义了如何创建名为Products的表及其列、数据类型、键和其他约束:

CREATE TABLE Products (
    ProductId       INTEGER       PRIMARY KEY,
 **ProductName     NVARCHAR (****40****)** **NOT****NULL****,**
    SupplierId      "INT",
    CategoryId      "INT",
    QuantityPerUnit NVARCHAR (20),
    UnitPrice       "MONEY"       CONSTRAINT DF_Products_UnitPrice DEFAULT (0),
    UnitsInStock    "SMALLINT"    CONSTRAINT DF_Products_UnitsInStock DEFAULT (0),
    UnitsOnOrder    "SMALLINT"    CONSTRAINT DF_Products_UnitsOnOrder DEFAULT (0),
    ReorderLevel    "SMALLINT"    CONSTRAINT DF_Products_ReorderLevel DEFAULT (0),
    Discontinued    "BIT"         NOT NULL
                                  CONSTRAINT DF_Products_Discontinued DEFAULT (0),
    CONSTRAINT FK_Products_Categories FOREIGN KEY (
        CategoryId
    )
    REFERENCES Categories (CategoryId),
    CONSTRAINT FK_Products_Suppliers FOREIGN KEY (
        SupplierId
    )
    REFERENCES Suppliers (SupplierId),
    CONSTRAINT CK_Products_UnitPrice CHECK (UnitPrice >= 0),
    CONSTRAINT CK_ReorderLevel CHECK (ReorderLevel >= 0),
    CONSTRAINT CK_UnitsInStock CHECK (UnitsInStock >= 0),
    CONSTRAINT CK_UnitsOnOrder CHECK (UnitsOnOrder >= 0)
); 

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

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

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

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

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

使用 EF Core Fluent API 定义模型

模型可以定义的最后一种方式是使用 Fluent API。此 API 可以用作属性替代,也可以与属性一起使用。例如,要定义ProductName属性,而不是用两个属性装饰该属性,可以在数据库上下文类的OnModelCreating方法中编写等效的 Fluent API 语句,如下面的代码所示:

modelBuilder.Entity<Product>()
  .Property(product => product.ProductName)
  .IsRequired()
  .HasMaxLength(40); 

这使得实体模型类更加简单。

使用 Fluent API 理解数据初始化

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

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

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

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

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

构建 Northwind 表的 EF Core 模型

现在您已经了解了定义 EF Core 模型的方法,让我们构建模型来表示 Northwind 数据库中的两个表。为了重用,我们将在一个单独的类库项目中完成此操作。

这两个实体类将相互引用,为了避免编译错误,我们首先创建没有任何成员的类:

  1. 使用您首选的代码编辑器创建一个新项目,如下面的列表所示:

    • 项目模板类库 / classlib

    • 项目文件和文件夹Northwind.EntityModels

    • 解决方案文件和文件夹Chapter10

  2. Northwind.EntityModels 项目中,删除名为 Class1.cs 的文件,然后添加两个名为 Category.csProduct.cs 的类文件。

  3. Category.cs 文件中,定义一个名为 Category 的类,如下面的代码所示:

    namespace Northwind.EntityModels;
    public class Category
    {
    } 
    
  4. Product.cs 文件中,定义一个名为 Product 的类,如下面的代码所示:

    namespace Northwind.EntityModels;
    public class Product
    {
    } 
    
  5. WorkingWithEFCore 项目中,将 Northwind.EntityModels 项目添加为项目引用,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include="..\Northwind.EntityModels\
    Northwind.EntityModels.csproj" />
    </ItemGroup> 
    

项目引用路径和文件名必须都在一行上。

  1. 构建名为 WorkingWithEFCore 的项目。

定义类别和产品实体类

Category 类,也称为实体模型,将用于表示 Categories 表中的一行。该表有四个列,如下面的 DDL 所示,该 DDL 来自 Northwind4SQLite.sql 脚本文件:

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

我们将使用约定来定义以下内容:

  • 四个属性中的三个(我们不会映射 Picture 列)

  • 主键

  • Products 表的一对多关系

为了将 Description 列映射到正确的数据库类型,我们需要用 Column 属性装饰 string 属性。

在本章的后面部分,我们将使用 Fluent API 定义 CategoryName 不能为空且最多为 15 个字符的限制。

让我们开始吧:

  1. Northwind.EntityModels 项目中,修改 Category 实体模型类,如下面的代码所示(已突出显示):

    **using** **System.ComponentModel.DataAnnotations.Schema;** **// To use [Column].**
    namespace Northwind.EntityModels;
    public class Category
    {
    **// These properties map to columns in the database.**
    **public****int** **CategoryId {** **get****;** **set****; }** **// The primary key.**
    **public****string** **CategoryName {** **get****;** **set****; } =** **null****!;**
     **[****Column(TypeName =** **"ntext"****)****]**
    **public****string****? Description {** **get****;** **set****; }**
    **// Defines a navigation property for related rows.**
    **public****virtual** **ICollection<Product> Products {** **get****;** **set****; }**
    **// To enable developers to add products to a Category, we must**
    **// initialize the navigation property to an empty collection.**
    **// This also avoids an exception if we get a member like Count.**
     **=** **new** **HashSet<Product>();**
    } 
    

注意以下内容:

  • Category 类将位于 Northwind.EntityModels 命名空间中。

  • CategoryId 属性遵循主键命名约定,因此它将被映射到一个标记为主键并带有索引的列。

  • CategoryName 属性映射到一个不允许数据库 NULL 值的列,因此它是一个不可为空的字符串。为了禁用可空性警告,我们已分配了 null-forgiving 操作符。

  • Description属性映射到一个具有ntext数据类型的列,而不是默认将string值映射到nvarchar

  • 我们将Product对象集合初始化为一个新的空HashSet。哈希集比列表更有效,因为它是无序的。如果你不初始化Products,那么它将是 null,如果你尝试获取它的Count,那么你会得到一个异常。

  1. 修改Product类,如下所示:

    **using** **System.ComponentModel.DataAnnotations;** **// To use [Required].**
    **using** **System.ComponentModel.DataAnnotations.Schema;** **// To use [Column].**
    namespace Northwind.EntityModels;
    public class Product
    {
    **public****int** **ProductId {** **get****;** **set****; }** **// The primary key.**
     **[****Required****]**
     **[****StringLength(40)****]**
    **public****string** **ProductName {** **get****;** **set****; } =** **null****!;**
    **// Property name is different from the column name.**
     **[****Column(****"UnitPrice"****, TypeName =** **"money"****)****]**
    **public****decimal****? Cost {** **get****;** **set****; }**
     **[****Column(****"UnitsInStock"****)****]**
    **public****short****? Stock {** **get****;** **set****; }**
    **public****bool** **Discontinued {** **get****;** **set****; }**
    **// These two properties define the foreign key relationship**
    **// to the Categories table.**
    **public****int** **CategoryId {** **get****;** **set****; }**
    **public****virtual** **Category Category {** **get****;** **set****; } =** **null****!;**
    } 
    

注意以下内容:

  • Product类将用于表示Products表中的一行,该表有 10 列。

  • 你不需要将表中的所有列都作为类的属性。我们将只映射六个属性:ProductIdProductNameUnitPriceUnitsInStockDiscontinuedCategoryId

  • 没有映射到属性的列无法使用类实例进行读取或设置。如果你使用该类创建一个新对象,那么表中的新行将为未映射的列值设置NULL或其他默认值。你必须确保那些缺失的列是可选的或者数据库已经设置了默认值,否则在运行时将抛出异常。在这种情况下,行已经存在数据值,我决定在这个应用程序中不需要读取这些值。

  • 我们可以通过定义一个具有不同名称的属性来重命名一个列,例如Cost,然后使用[Column]属性装饰该属性并指定其列名,例如UnitPrice

  • 最后一个属性CategoryId与一个Category属性相关联,该属性将用于将每个产品映射到其父类别。

与两个实体相关的两个属性,Category.ProductsProduct.Category,都被标记为virtual。这允许 EF Core 继承并重写属性以提供额外功能,例如懒加载。

向 Northwind 数据库上下文类添加表

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

DbContext派生的类可以有一个可选的名为OnModelCreating的重写方法。在这里,你可以编写 Fluent API 语句,作为用属性装饰你的实体类的替代方案。

让我们编写代码:

  1. WorkingWithEFCore项目中,修改NorthwindDb类以添加定义两个表属性和一个OnModelCreating方法的语句,如下所示:

    public class NorthwindDb : DbContext
    {
    **// These two properties map to tables in the database.**
    **public****DbSet****<****Category****>? Categories** **{** **get****;** **set****; }**
    **public****DbSet****<****Product****>? Products** **{** **get****;** **set****; }**
      protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
      {
        ...
      }
    **protected****override****void****OnModelCreating****(**
     **ModelBuilder modelBuilder****)**
     **{**
    **// Example of using Fluent API instead of attributes**
    **// to limit the length of a category name to 15.**
     **modelBuilder.Entity<Category>()**
     **.Property(category => category.CategoryName)**
     **.IsRequired()** **// NOT NULL**
     **.HasMaxLength(****15****);**
    **// Some SQLite-specific configuration.**
    **if** **(Database.ProviderName?.Contains(****"Sqlite"****) ??** **false****)**
     **{**
    **// To "fix" the lack of decimal support in SQLite.**
     **modelBuilder.Entity<Product>()**
     **.Property(product => product.Cost)**
     **.HasConversion<****double****>();**
     **}**
     **}**
    } 
    

decimal类型不支持 SQLite 数据库提供程序进行排序和其他操作。我们可以通过告诉模型在使用 SQLite 数据库提供程序时可以将decimal值视为double值来修复这个问题。这实际上在运行时不会执行任何转换。

现在你已经看到了一些手动定义实体模型的示例,让我们看看一个可以为你做些工作的工具。

设置 dotnet-ef 工具

命名为dotnet的.NET CLI 工具可以扩展用于与 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 
    
  3. 如果已经安装了旧版本,请按照以下命令更新工具:

    dotnet tool update --global dotnet-ef 
    
  4. 如果尚未安装,请按照以下命令安装最新版本:

    dotnet tool install --global dotnet-ef 
    

如果需要,遵循任何特定于操作系统的说明将dotnet tools目录添加到你的PATH环境变量中,如安装dotnet-ef工具的输出所述。

默认情况下,将使用最新的 GA 版本安装工具。要显式设置版本,例如,使用预览版,请添加--version开关。作为另一个例子,要更新到 2024 年 2 月至 10 月可用的最新.NET 9 预览版本,请使用以下带有版本通配符的命令:

dotnet tool update --global dotnet-ef --version 9.0-* 

一旦在 2024 年 11 月.NET 9 GA 版本发布,你只需使用不带--version开关的命令即可升级。

你也可以按照以下命令删除工具:

dotnet tool uninstall --global dotnet-ef 

使用现有数据库构建模型框架

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

如果你确定永远不会使用该工具重新生成类,那么你可以随意修改自动生成的类的代码,想改多少就改多少。工具生成的代码只是最佳近似。

良好实践:当你知道得更多时,不要害怕推翻工具。

本节中的说明适用于任何数据提供者,而不仅仅是 SQLite。无论您使用哪种数据提供者,您都需要添加对 Microsoft.EntityFrameworkCore.Design 包的引用。

让我们看看工具生成的模型是否与我们手动生成的相同:

  1. Microsoft.EntityFrameworkCore.Design 包的最新版本添加到 WorkingWithEFCore 项目中,如下所示高亮显示的标记:

    <ItemGroup>
     **<PackageReference Version=****"9.0.0"**
     **Include=****"Microsoft.EntityFrameworkCore.Design"****>**
     **<PrivateAssets>all</PrivateAssets>**
     **<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>**
     **</PackageReference>**
      <PackageReference Version="9.0.0"
                        Include="Microsoft.EntityFrameworkCore.Sqlite" />
    </ItemGroup> 
    

更多信息:如果您不熟悉像 Microsoft.EntityFrameworkCore.Design 这样的包如何管理其资产,您可以在以下链接中了解更多信息:

learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#controlling-dependency-assets

  1. 构建 WorkingWithEFCore 项目以还原包。

  2. WorkingWithEFCore 项目文件夹中启动命令提示符或终端。以下是一个示例:

    • 如果您使用的是 Visual Studio,在 解决方案资源管理器 中,右键单击 WorkingWithEFCore 项目,然后选择 在终端中打开

    • 在 Windows 上,启动 文件资源管理器,右键单击 WorkingWithEFCore 文件夹,然后选择 在此文件夹中新建命令提示符在 Windows 终端中打开

    • 在 macOS 上,启动 Finder,右键单击 WorkingWithEFCore 文件夹,然后选择 在文件夹中新建终端

    • 如果您使用的是 Rider,在 解决方案资源管理器 中,右键单击 WorkingWithEFCore 项目,然后选择 | 终端 中打开。

警告! 当我说 WorkingWithEFCore 项目文件夹时,我指的是包含 WorkingWithEFCore.csproj 项目文件的文件夹。如果您在不含项目文件的文件夹中输入命令,则会看到以下错误:未找到项目。更改当前工作目录或使用 --project 选项

良好实践:您即将输入一个长命令。我建议您从打印书籍中输入,或者从电子书复制并粘贴此类长命令到纯文本编辑器(如记事本)。然后确保整个命令作为一个单行正确格式化,并具有正确的间距。只有在这种情况下,您才应该将其复制并粘贴到命令提示符或终端。如果直接从电子书复制粘贴,很可能会包含换行符和缺失的空格,这可能会破坏命令,如果您不小心的话。另外,请记住,所有命令都可以在 github.com/markjprice/cs13net9/blob/main/docs/command-lines.md 复制。警告! 如果您从 PDF 中复制,命令链接中的双短横线有时会丢失,所以请小心!

  1. 在命令提示符或终端中,使用 dotnet-ef 工具在名为 AutoGenModels 的新文件夹中为 CategoriesProducts 表生成模型,如下所示命令:

    dotnet ef dbcontext scaffold "Data Source=Northwind.db" Microsoft.EntityFrameworkCore.Sqlite --table Categories --table Products --output-dir AutoGenModels --namespace WorkingWithEFCore.AutoGen --data-annotations --context NorthwindDb 
    

注意以下事项:

  • 命令操作:dbcontext scaffold

  • 连接字符串:"Data Source=Northwind.db"

  • 数据库提供程序:Microsoft.EntityFrameworkCore.Sqlite

  • 要生成模型表的:--table Categories --table Products

  • 输出文件夹:--output-dir AutoGenModels

  • 命名空间:--namespace WorkingWithEFCore.AutoGen

  • 要使用数据注释以及 Fluent API:--data-annotations

  • 要重命名上下文从[database_name]Context--context NorthwindDb

    如果您更喜欢使用 SQL Server,则等效命令可在以下链接中找到:

    github.com/markjprice/cs13net9/blob/main/docs/sql-server/README.md#scaffolding-models-using-an-existing-database

  1. 注意以下构建信息和警告,如下面的输出所示:

    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.
    Skipping foreign key with identity '0' on table 'Products' since principal table 'Suppliers' was not found in the model. This usually happens when the principal table was not included in the selection set. 
    

检查生成的代码

现在让我们来检查生成的代码:

  1. AutoGenModels文件夹中,注意自动生成的三个类文件:Category.csNorthwindDb.csProduct.cs

  2. AutoGenModels文件夹中,在Category.cs中,注意与您手动创建的版本之间的差异。我没有包含命名空间导入以节省空间,如下面的代码所示:

    namespace WorkingWithEFCore.AutoGen;
    [Index("CategoryName", Name = "CategoryName")]
    public partial class Category
    {
      [Key]
      public int CategoryId { get; set; }
      [Column(TypeName = "nvarchar (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]属性装饰实体类,这表示在运行时使用 Code First 方法生成数据库时应具有索引的属性。由于我们使用的是具有现有数据库的 Database First,因此这不需要。但如果我们要从我们的代码中重新创建一个新的空数据库,则需要这些信息。

  • 数据库中的表名为Categories,但dotnet-ef工具使用Humanizer第三方库自动将类名单数化到Category,这在创建表示表中行的单个实体时是一个更自然的名称。

  • 实体类使用partial关键字声明,这样您就可以创建一个匹配的partial类来添加额外的代码。这允许您重新运行工具并重新生成实体类,而不会丢失那些额外的代码。

  • CategoryId属性被[Key]属性装饰,以指示它是此实体的主键。此属性的数据库类型对于 SQLite 是int,对于 SQL Server 是long。我们不需要在我们的代码中装饰属性,因为我们遵循了命名主键约定。

  • CategoryName属性被[Column(TypeName = "nvarchar (15)")]属性装饰,这只有在您想从模型生成数据库时才需要。

  • 我们选择不包含Picture列作为属性,因为这是一个二进制对象,我们不会在我们的控制台应用程序中使用。

  • Products 属性使用 [InverseProperty] 属性来定义与 Product 实体类上的 Category 属性的外键关系,并将其初始化为一个新的空列表。

  1. AutoGenModels 文件夹中,在 Product.cs 中,注意与你自己手动创建的版本相比的不同之处。

  2. AutoGenModels 文件夹中,在 NorthwindDb.cs 中,注意与你自己手动创建的版本相比的不同之处,如下面的编辑后的代码所示:

    using Microsoft.EntityFrameworkCore;
    namespace WorkingWithEFCore.AutoGen;
    public partial class NorthwindDb : DbContext
    {
      public NorthwindDb()
      {
      }
      public NorthwindDb(DbContextOptions<NorthwindDb> options)
          : base(options)
      {
      }
      public virtual DbSet<Category> Categories { get; set; }
      public virtual DbSet<Product> Products { 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.UseSqlite("Data Source=Northwind.db");
      protected override void OnModelCreating(ModelBuilder modelBuilder)
      {
        modelBuilder.Entity<Category>(entity =>
        {
          entity.Property(e => e.CategoryId).ValueGeneratedNever();
        });
        modelBuilder.Entity<Product>(entity =>
        {
          entity.Property(e => e.ProductId).ValueGeneratedNever();
          entity.Property(e => e.Discontinued).HasDefaultValueSql("0");
          entity.Property(e => e.ReorderLevel).HasDefaultValueSql("0");
          entity.Property(e => e.UnitPrice).HasDefaultValueSql("0");
          entity.Property(e => e.UnitsInStock).HasDefaultValueSql("0");
          entity.Property(e => e.UnitsOnOrder).HasDefaultValueSql("0");
        });
        OnModelCreatingPartial(modelBuilder);
      }
      partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
    } 
    

注意以下内容:

  • NorthwindDb 数据上下文类是 partial 的,以便你可以扩展它并在将来重新生成它。

  • 它有两个构造函数:一个默认的无参数构造函数和一个允许传递选项的构造函数。这在需要运行时指定连接字符串的应用程序中很有用。

  • OnConfiguring 方法中,如果构造函数中没有指定选项,则默认使用一个连接字符串,该字符串在当前文件夹中查找数据库文件。它有一个编译器警告来提醒你,你不应该在连接字符串中硬编码安全信息。

  • OnModelCreating 方法中,使用 Fluent API 配置两个实体类,然后调用一个名为 OnModelCreatingPartialpartial 方法。这允许你在自己的 partial Northwind 类中实现该 partial 方法,以便添加你自己的 Fluent API 配置,这样在重新生成模型类时就不会丢失。

  1. 关闭自动生成的类文件。

定制反向工程模板

EF Core 7 引入的一个特性是能够自定义由 dotnet-ef 框架工具自动生成的代码。这是一个高级技术,所以我在这本书中没有涵盖它。通常,直接修改默认生成的代码会更简单。

如果你想要了解如何修改 dotnet-ef 框架工具使用的 T4 模板,那么你可以在此链接中找到相关信息:

learn.microsoft.com/en-us/ef/core/managing-schemas/scaffolding/templates

配置预约定模型

除了支持与 SQLite 数据库提供程序一起使用的 DateOnlyTimeOnly 类型外,EF Core 6 引入的一个特性是配置预约定模型。

随着模型变得更加复杂,依赖于约定来发现实体类型及其属性并将它们成功映射到表和列变得更加困难。如果在它们被用于分析和构建模型之前配置这些约定,将会很有用。

例如,你可能想定义一个约定,即所有 string 属性默认最大长度为 50 个字符,或者任何实现自定义接口的属性类型不应进行映射,如下面的代码所示:

protected override void ConfigureConventions(
  ModelConfigurationBuilder configurationBuilder)
{
  configurationBuilder.Properties<string>().HaveMaxLength(50);
  configurationBuilder.IgnoreAny<IDoNotMap>();
} 

在本章的其余部分,我们将使用你手动创建的类。

查询 EF Core 模型

现在我们有一个映射到 Northwind 数据库及其两个表的模型,我们可以编写一些简单的 语言集成查询LINQ)查询来获取数据。你将在 第十一章使用 LINQ 查询和操作数据 中学习更多关于编写 LINQ 查询的内容。

LINQ to Entities(即 LINQ to EF Core)是一个 LINQ 提供程序,它将 LINQ 查询转换为 SQL 以在数据库上执行。你可以编写一个由多个 C# 语句构建的 LINQ 查询。

你可以通过调用 ToQueryString 来发现等效的 SQL 语句,而无需在数据库上执行查询。这被称为延迟执行。只有当使用 foreach 枚举查询,或者当你在 LINQ 查询上调用 ToArrayToList 等方法时,才会触发对数据库的查询执行,并将结果返回到你的代码中。这被称为 实体化

现在,只需编写代码并查看结果:

  1. WorkingWithEFCore 项目中,添加一个名为 Program.Helpers.cs 的新类文件。

  2. Program.Helpers.cs 中,添加一个部分 Program 类和一些方法,如下面的代码所示:

    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 text, ConsoleColor color)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = color;
        WriteLine(text);
        ForegroundColor = previousColor;
      }
      private static void SectionTitle(string title)
      {
        WriteLineInColor($"*** {title} ***", ConsoleColor.DarkYellow);
      }
      private static void Fail(string message)
      {
        WriteLineInColor($"Fail > {message}", ConsoleColor.Red);
      }
      private static void Info(string message)
      {
        WriteLineInColor($"Info > {message}", ConsoleColor.Cyan);
      }
    } 
    
  3. 添加一个名为 Program.Queries.cs 的新类文件。

  4. Program.Queries.cs 中,定义一个部分 Program 类和一个 QueryingCategories 方法,并添加执行这些任务的语句,如下面的代码所示:

    • 创建一个 Northwind 类的实例来管理数据库。数据库上下文实例是为工作单元中的短生命周期设计的。它们应该尽快被销毁。因此,我们将我们的实例包裹在一个 using 语句中。在 第十三章使用 ASP.NET Core 构建网站 中,你将学习如何使用依赖注入获取数据库上下文。

    • 创建一个查询,包括所有相关产品的所有类别。Include 是一个扩展方法,需要你导入 Microsoft.EntityFrameworkCore 命名空间。

    • 遍历类别,输出每个类别的名称和产品数量:

      using Microsoft.EntityFrameworkCore; // To use Include method.
      using Northwind.EntityModels; // To use Northwind, Category, Product.
      partial class Program
      {
        private static void QueryingCategories()
        {
          using NorthwindDb db = new();
          SectionTitle("Categories and how many products they have");
          // A query to get all categories and their related products.
          // This is a query definition. Nothing has executed against the database.
          IQueryable<Category>? categories = db.Categories?
            .Include(c => c.Products);
          // You could call any of the following LINQ methods and nothing will be executed against the database:
          // Where, GroupBy, Select, SelectMany, OfType, OrderBy, ThenBy, Join, GroupJoin, Take, Skip, Reverse.
          // Usually, methods that return IEnumerable or IQueryable support deferred execution.
          // Usually, methods that return a single value do not support deferred execution.
          if (categories is null || !categories.Any())
          {
            Fail("No categories found.");
            return;
          }
          // Enumerating the query converts it to SQL and executes it against the database.
          // Execute query and enumerate results.
          foreach (Category c in categories)
          {
            WriteLine($"{c.CategoryName} has {c.Products.Count} products.");
          }
        }
      } 
      

注意 if 语句中子句的顺序很重要。我们必须首先检查 categories 是否为 null。如果是 true,则代码将永远不会执行第二个子句,因此,在访问 Any() 成员时不会抛出 NullReferenceException

  1. Program.cs 中,注释掉创建 Northwind 实例并输出数据库提供程序名称的两个语句,然后调用 ConfigureConsoleQueryingCategories 方法,如下面的代码所示:

    ConfigureConsole();
    QueryingCategories(); 
    
  2. 运行代码并查看结果,如下面的部分输出所示:

    Beverages has 12 products.
    Condiments has 12 products.
    Confections has 13 products.
    Dairy Products has 10 products.
    Grains/Cereals has 7 products.
    Meat/Poultry has 6 products.
    Produce has 5 products.
    Seafood has 12 products. 
    

警告!如果你看到以下异常,最可能的问题是Northwind.db文件没有被复制到输出目录:未处理的异常。Microsoft.Data.Sqlite.SqliteException (0x80004005):SQLite 错误 1:'没有这样的表:Categories'。请确保已设置复制到输出目录,即使如此,一些代码编辑器也不总是应该在复制文件时复制文件。你可能需要手动将Northwind.db文件复制到适当的目录。

过滤包含的实体

EF Core 5 引入了过滤包含,这意味着你可以在Include方法调用中指定一个 lambda 表达式,以过滤返回结果中的哪些实体:

  1. Program.Queries.cs中定义一个FilteredIncludes方法,并添加执行以下任务的语句,如下所示代码:

    • 创建一个Northwind类的实例,该实例将管理数据库。

    • 提示用户输入库存单位的最小值。

    • 创建一个查询,用于查找具有该最小库存单位数量的产品的类别。

    • 遍历类别和产品,输出每个产品的名称和库存单位:

      private static void FilteredIncludes()
      {
        using NorthwindDb db = new();
        SectionTitle("Products with a minimum number of units in stock");
        string? input;
        int stock;
        do
        {
          Write("Enter a minimum for units in stock: ");
          input = ReadLine();
        } while (!int.TryParse(input, out stock));
        IQueryable<Category>? categories = db.Categories?
          .Include(c => c.Products.Where(p => p.Stock >= stock));
        if (categories is null || !categories.Any())
        {
          Fail("No categories found.");
          return;
        }
        foreach (Category c in categories)
        {
          WriteLine(
            "{0} has {1} products with a minimum {2} units in stock.",
            arg0: c.CategoryName, arg1: c.Products.Count, arg2: stock);
          foreach(Product p in c.Products)
          {
            WriteLine($"  {p.ProductName} has {p.Stock} units in stock.");
          }
        }
      } 
      
  2. Program.cs中调用FilteredIncludes方法,如下所示代码:

    FilteredIncludes(); 
    
  3. 运行代码,输入库存单位的最小值,例如100,并查看以下部分输出结果:

    Enter a minimum for units in stock: 100
    Beverages has 2 products with a minimum of 100 units in stock.
      Sasquatch Ale has 111 units in stock.
      Rhönbräu Klosterbier has 125 units in stock.
    Condiments has 2 products with a minimum of 100 units in stock.
      Grandma's Boysenberry Spread has 120 units in stock.
      Sirop d'érable has 113 units in stock.
    Confections has 0 products with a minimum of 100 units in stock.
    Dairy Products has 1 products with a minimum of 100 units in stock.
      Geitost has 112 units in stock.
    Grains/Cereals has 1 products with a minimum of 100 units in stock.
      Gustaf's Knäckebröd has 104 units in stock.
    Meat/Poultry has 1 products with a minimum of 100 units in stock.
      Pâté chinois has 115 units in stock.
    Produce has 0 products with a minimum of 100 units in stock.
    Seafood has 3 products with a minimum of 100 units in stock.
      Inlagd Sill has 112 units in stock.
      Boston Crab Meat has 123 units in stock.
      Röd Kaviar has 101 units in stock. 
    

Windows 控制台中的 Unicode 字符:在 Windows 10 Fall Creators Update 之前的 Windows 版本中,Microsoft 提供的控制台存在一个限制。默认情况下,控制台无法显示 Unicode 字符,例如在名称 Rhönbräu 中的字符。

如果你有这个问题,那么你可以在运行应用程序之前,在提示符下输入以下命令临时更改代码页(也称为字符集)为 Unicode UTF-8:

chcp 65001 

过滤和排序产品

让我们探索一个更复杂的查询,该查询将过滤和排序数据:

  1. Program.Queries.cs中定义一个QueryingProducts方法,并添加执行以下任务的语句,如下所示代码:

    • 创建一个Northwind类的实例,该实例将管理数据库。

    • 提示用户输入产品的价格。

    • 使用 LINQ 创建一个查询,以查找价格高于指定价格的产品。

    • 遍历结果,输出 ID、名称、成本(以美元格式化)和库存单位数量:

      private static void QueryingProducts()
      {
        using NorthwindDb db = new();
        SectionTitle("Products that cost more than a price, highest at top");
        string? input;
        decimal price;
        do
        {
          Write("Enter a product price: ");
          input = ReadLine();
        } while (!decimal.TryParse(input, out price));
        IQueryable<Product>? products = db.Products?
          .Where(product => product.Cost > price)
          .OrderByDescending(product => product.Cost);
        if (products is null || !products.Any())
        {
          Fail("No products found.");
          return;
        }
        foreach (Product p in products)
        {
          WriteLine(
            "{0}: {1} costs {2:$#,##0.00} and has {3} in stock.",
            p.ProductId, p.ProductName, p.Cost, p.Stock);
        }
      } 
      
  2. Program.cs中调用QueryingProducts方法。

  3. 运行代码,当提示输入产品价格时,输入50,查看结果,并注意成本的降序排列,如下所示部分输出结果:

    Enter a product price: 50
    38: Côte de Blaye costs $263.50 and has 17 in stock.
    29: Thüringer Rostbratwurst costs $123.79 and has 0 in stock.
    9: Mishi Kobe Niku costs $97.00 and has 29 in stock.
    20: Sir Rodney's Marmalade costs $81.00 and has 40 in stock.
    18: Carnarvon Tigers costs $62.50 and has 42 in stock.
    59: Raclette Courdavault costs $55.00 and has 79 in stock.
    51: Manjimup Dried Apples costs $53.00 and has 20 in stock. 
    
  4. 运行代码,当提示输入产品价格时,输入500,并查看以下输出结果:

    Fail > No products found. 
    

获取生成的 SQL

你可能想知道我们编写的 C#查询生成的 SQL 语句编写得如何。EF Core 5 引入了一种快速简单的方法来查看生成的 SQL:

  1. QueryingProducts方法中,在使用foreach语句枚举查询之前,添加一个输出生成的 SQL 的语句,如下面的代码所示:

    // Calling ToQueryString does not execute against the database.
    // LINQ to Entities just converts the LINQ query to an SQL statement.
    Info($"ToQueryString: {products.ToQueryString()}"); 
    

警告! ToQueryString方法只能用于实现IQueryable的对象。这意味着如果您使用WhereGroupBySelectOrderByJoinTakeSkipReverse等延迟方法编写 LINQ 查询,那么ToQueryString可以在您运行查询之前显示 SQL。但是,返回非IQueryable值并立即执行查询的方法,如单个标量结果Count()First(),不支持ToQueryString

  1. 运行代码,输入库存单位的最低值,例如95,并查看结果,如下面的部分输出所示:

    Enter a minimum for units in stock: 95
    Connection: Data Source=C:\cs13net9\Chapter10\WorkingWithEFCore\bin\Debug\net9.0\Northwind.db
    Info > ToQueryString: .param set @__stock_0 95
    SELECT "c"."CategoryId", "c"."CategoryName", "c"."Description", "t"."ProductId", "t"."CategoryId", "t"."UnitPrice", "t"."Discontinued", "t"."ProductName", "t"."UnitsInStock"
    FROM "Categories" AS "c"
    LEFT JOIN (
        SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
        FROM "Products" AS "p"
        WHERE "p"."UnitsInStock" >= @__stock_0
    ) AS "t" ON "c"."CategoryId" = "t"."CategoryId"
    ORDER BY "c"."CategoryId"
    Beverages has 2 products with a minimum of 95 units in stock.
      Sasquatch Ale has 111 units in stock.
      Rhönbräu Klosterbier has 125 units in stock.
    ... 
    

注意,SQL 参数@__stock_0已被设置为最低库存值95

如果您使用 SQL Server,生成的 SQL 将略有不同。例如,它使用方括号而不是双引号来包围对象名称,如下面的输出所示:

Info > ToQueryString: DECLARE @__stock_0 smallint = CAST(95 AS smallint);
SELECT [c].[CategoryId], [c].[CategoryName], [c].[Description], [t].[ProductId], [t].[CategoryId], [t].[UnitPrice], [t].[Discontinued], [t].[ProductName], [t].[UnitsInStock]
FROM [Categories] AS [c]
LEFT JOIN (
    SELECT [p].[ProductId], [p].[CategoryId], [p].[UnitPrice], [p].[Discontinued], [p].[ProductName], [p].[UnitsInStock]
    FROM [Products] AS [p]
    WHERE [p].[UnitsInStock] >= @__stock_0
) AS [t] ON [c].[CategoryId] = [t].[CategoryId]
ORDER BY [c].[CategoryId] 

记录 EF Core

要监控 EF Core 与数据库之间的交互,我们可以启用日志记录。日志记录可以是到控制台、到DebugTrace,或到文件。启用 EF Core 的日志记录将显示实际针对数据库执行的 SQL 命令。ToQueryString不会针对数据库执行。

良好实践:默认情况下,EF Core 日志将排除任何敏感数据。您可以通过调用EnableSensitiveDataLogging方法包括此数据,尤其是在开发期间。您应该在部署到生产之前禁用它。您还可以调用EnableDetailedErrors

让我们看看这个动作的例子:

  1. NorthwindDb.cs中,在OnConfiguring方法的底部添加语句以记录到控制台,并在编译调试配置时包括敏感数据,如发送到数据库的命令的参数值,如下面的代码所示:

    optionsBuilder.LogTo(WriteLine) // This is the Console method.
    #if DEBUG
      .EnableSensitiveDataLogging() // Include SQL parameters.
      .EnableDetailedErrors()
    #endif
    ; 
    

    LogTo需要一个Action<string>委托。EF Core 将调用此委托,为每个日志消息传递一个string值。因此,传递Console类的WriteLine方法,因此,告诉记录器将每个方法写入控制台。

  2. 注意,当解决方案配置为调试时,对EnableSensitiveDataLoggingEnableDetailedErrors方法的调用包含在编译中,但如果将解决方案配置更改为发布,则方法调用将以灰色显示,表示它们未编译,如图图 10.6所示:

图 10.6:在调试配置中记录日志时包括 SQL 参数

  1. 运行代码并查看日志消息,如下面的部分输出所示:

    warn: 7/16/2023 14:03:40.255 CoreEventId.SensitiveDataLoggingEnabledWarning[10400] (Microsoft.EntityFrameworkCore.Infrastructure)
          Sensitive data logging is enabled. Log entries and exception messages may include sensitive application data; this mode should only be enabled during development.
    ...
    dbug: 05/03/2023 12:36:11.702 RelationalEventId.ConnectionOpening[20000] (Microsoft.EntityFrameworkCore.Database.Connection)
          Opening connection to database 'main' on server 'C:\cs13net9\Chapter10\WorkingWithEFCore\bin\Debug\net9.0\Northwind.db'.
    dbug: 05/03/2023 12:36:11.718 RelationalEventId.ConnectionOpened[20001] (Microsoft.EntityFrameworkCore.Database.Connection)
          Opened connection to database 'main' on server 'C:\cs13net9\Chapter10\WorkingWithEFCore\bin\Debug\net9.0\Northwind.db'.
    dbug: 05/03/2023 12:36:11.721 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
          SELECT "c"."CategoryId", "c"."CategoryName", "c"."Description", "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
          FROM "Categories" AS "c"
          LEFT JOIN "Products" AS "p" ON "c"."CategoryId" = "p"."CategoryId"
          ORDER BY "c"."CategoryId"
    ... 
    

你的日志可能与上面显示的日志不同,这取决于你选择的数据库提供程序和代码编辑器,以及 EF Core 的未来改进。目前请注意,不同的事件,如打开连接或执行命令,有不同的事件 ID,如下面的列表所示:

  • 20000 RelationalEventId.ConnectionOpening:包括数据库文件路径

  • 20001 RelationalEventId.ConnectionOpened:包括数据库文件路径

  • 20100 RelationalEventId.CommandExecuting:包括 SQL 语句

通过提供程序特定的值过滤日志

事件 ID 值及其含义将特定于 EF Core 提供程序。如果我们想了解 LINQ 查询是如何被转换为 SQL 语句并执行的,那么输出的事件 ID 应具有 Id 值为 20100

  1. NorthwindDb.cs 的顶部,导入用于 EF Core 诊断工作的命名空间,如下面的代码所示:

    // To use RelationalEventId.
    using Microsoft.EntityFrameworkCore.Diagnostics; 
    
  2. LogTo 方法调用修改为仅输出具有 Id20100 的事件,如下面的代码中突出显示所示:

    optionsBuilder.LogTo(WriteLine**,** // This is the Console method.
    **new****[] { RelationalEventId.CommandExecuting }**)
    #if DEBUG
      .EnableSensitiveDataLogging()
      .EnableDetailedErrors()
    #endif
    ; 
    
  3. 运行代码并注意以下已记录的 SQL 语句,如下面的输出所示,其中已编辑以节省空间:

    dbug: 05/03/2022 12:48:43.153 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
          SELECT "c"."CategoryId", "c"."CategoryName", "c"."Description", "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
          FROM "Categories" AS "c"
          LEFT JOIN "Products" AS "p" ON "c"."CategoryId" = "p"."CategoryId"
          ORDER BY "c"."CategoryId"
    Beverages has 12 products.
    Condiments has 12 products.
    Confections has 13 products.
    Dairy Products has 10 products.
    Grains/Cereals has 7 products.
    Meat/Poultry has 6 products.
    Produce has 5 products.
    Seafood has 12 products. 
    

使用查询标签进行日志记录

当记录 LINQ 查询时,在复杂场景中关联日志消息可能会很棘手。EF Core 2.2 引入了查询标签功能,通过允许你向日志添加 SQL 注释来提供帮助。

你可以使用 TagWith 方法对 LINQ 查询进行注释,如下面的代码所示:

IQueryable<Product>? products = db.Products?
  .TagWith("Products filtered by price and sorted.")
  .Where(product => product.Cost > price)
  .OrderByDescending(product => product.Cost); 

这将在日志中添加一个 SQL 注释,如下面的输出所示:

-- Products filtered by price and sorted. 

获取单个实体

有两种 LINQ 方法可以获取单个实体:FirstSingle。在使用 EF Core 数据库提供程序时,理解它们之间的区别非常重要。让我们看一个例子:

  1. Program.Queries.cs 中定义一个 GettingOneProduct 方法,并添加以下语句,如下面的代码所示:

    • 创建一个 Northwind 类的实例,该实例将管理数据库。

    • 提示用户输入产品 ID。

    • 使用 FirstSingle 方法创建一个具有该产品 ID 的产品查询。

    • 为每个查询向控制台编写一个 SQL 语句:

      private static void GettingOneProduct()
      {
        using NorthwindDb db = new();
        SectionTitle("Getting a single product");
        string? input;
        int id;
        do
        {
          Write("Enter a product ID: ");
          input = ReadLine();
        } while (!int.TryParse(input, out id));
        // This query is not deferred because the First method does not return IEnumerable or IQueryable.
        // The LINQ query is immediately converted to SQL and executed to fetch the first product.
        Product? product = db.Products?
          .First(product => product.ProductId == id);
        Info($"First: {product?.ProductName}");
        if (product is null) Fail("No product found using First.");
        product = db.Products?
          .Single(product => product.ProductId == id);
        Info($"Single: {product?.ProductName}");
        if (product is null) Fail("No product found using Single.");
      } 
      

获取单个实体(FirstFirstOrDefaultSingleSingleOrDefaultElementAtElementAtOrDefault)或返回单个标量值或实体的 LINQ 方法(如聚合方法 CountSumMaxMinAverageAllAny 等)不是延迟执行的。当使用 LINQ to Entities 提供程序时,任何以调用这些方法之一结束的 LINQ 查询将立即转换为 SQL 语句并在数据库上执行。

  1. Program.cs 中调用 GettingOneProduct 方法。

  2. 运行代码,当提示输入产品 ID 时输入 1,查看结果,并注意 FirstSingle 使用的 SQL 语句,如下面的输出所示:

    Enter a product ID: 1
    Connection: Data Source=C:\cs13net9\Chapter10\WorkingWithEFCore\bin\Debug\net9.0\Northwind.db
    dbug: 9/17/2023 18:04:14.210 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[@__id_0='1'], CommandType='Text', CommandTimeout='30']
          SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
          FROM "Products" AS "p"
          WHERE "p"."ProductId" > @__id_0
          **LIMIT 1**
    Info > First: Chang
    dbug: 9/17/2023 18:04:14.286 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[@__id_0='1'], CommandType='Text', CommandTimeout='30']
          SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
          FROM "Products" AS "p"
          WHERE "p"."ProductId" > @__id_0
          **LIMIT 2**
    Info > Single: Chang 
    

注意,这两种方法执行相同的 SQL 语句,除了前面代码中突出显示的 LIMIT 子句。对于 First,它设置 LIMIT 1,而对于 Single,它设置 LIMIT 2。为什么?

对于 First,查询可以匹配一个或多个实体,并且只返回第一个。如果没有匹配项,将抛出异常,但你可以在没有匹配项时调用 FirstOrDefault 返回 null

对于 Single,查询必须仅匹配一个实体,并将返回。如果有多个匹配项,必须抛出异常。但 EF Core 知道是否有多个匹配项的唯一方法就是请求多个并检查。因此,它必须设置 LIMIT 2 并检查是否有第二个实体匹配。

良好实践:如果你不需要确保只有一个实体匹配,请使用 First 而不是 Single,以避免检索两个记录。

使用 Like 进行模式匹配

EF Core 支持常见的 SQL 语句,包括用于模式匹配的 Like

  1. Program.Queries.cs 中添加一个名为 QueryingWithLike 的方法,如下面的代码所示,并注意以下内容:

    • 我们已启用日志记录。

    • 我们提示用户输入产品名称的一部分,然后使用 EF.Functions.Like 方法在 ProductName 属性中的任何位置进行搜索。

    • 对于每个匹配的产品,我们输出其名称、库存以及是否已停售:

      private static void QueryingWithLike()
      {
        using NorthwindDb db = new();
        SectionTitle("Pattern matching with LIKE");
        Write("Enter part of a product name: ");
        string? input = ReadLine();
        if (string.IsNullOrWhiteSpace(input))
        {
          Fail("You did not enter part of a product name.");
          return;
        }
        IQueryable<Product>? products = db.Products?
          .Where(p => EF.Functions.Like(p.ProductName, $"%{input}%"));
        if (products is null || !products.Any())
        {
          Fail("No products found.");
          return;
        }
        foreach (Product p in products)
        {
          WriteLine("{0} has {1} units in stock. Discontinued: {2}",
            p.ProductName, p.Stock, p.Discontinued);
        }
      } 
      
  2. Program.cs 中,注释掉现有的方法并调用 QueryingWithLike

  3. 运行代码,输入部分产品名称,例如 che,查看结果,如下面的编辑输出所示:

    Enter part of a product name: che
    dbug: 07/16/2023 13:03:42.793 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[@__Format_1='%che%' (Size = 5)], CommandType='Text', CommandTimeout='30']
          SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
          FROM "Products" AS "p"
          WHERE "p"."ProductName" LIKE @__Format_1
    Chef Anton's Cajun Seasoning has 53 units in stock. Discontinued: False
    Chef Anton's Gumbo Mix has 0 units in stock. Discontinued: True
    Queso Manchego La Pastora has 86 units in stock. Discontinued: False 
    

更多信息:你可以在 learn.microsoft.com/en-us/dotnet/framework/data/adonet/ef/language-reference/like-entity-sql 了解有关 Like 通配符的更多信息。

在查询中生成随机数

EF Core 6 引入了一个有用的函数,EF.Functions.Random,它映射到返回介于 0.01.0 之间(不包括 1.0)的伪随机实数的数据库函数。

例如,假设你正在开发一个需要每次用户参与时显示随机顺序问题的测验或调查应用。你可以使用 EF.Functions.Random() 直接从数据库中获取随机选择的问题,如下面的代码所示:

var randomQuestions = await db.Questions
  .OrderBy(q => EF.Functions.Random())
  .Take(10); // Select 10 random questions. 

在开发和测试阶段,生成随机数据可以用于模拟各种场景。例如,创建用于性能测试的随机数据集或通过随机选择大约一半的产品来生成用于单元测试的随机行集,如下面的代码所示:

var randomDataSample = await db.Products
  .Where(d => EF.Functions.Random() > 0.5); 

定义全局过滤器

Northwind 产品可能会停售,因此确保停售的产品永远不会在结果中返回,即使程序员在查询中未使用 Where 过滤它们,可能也是有用的:

  1. NorthwindDb.cs 文件中,在 OnModelCreating 方法的底部,添加一个全局过滤器以移除已停售的产品,如下面的代码所示:

    // A global filter to remove discontinued products.
    modelBuilder.Entity<Product>()
      .HasQueryFilter(p => !p.Discontinued); 
    
  2. Program.cs 文件中,取消注释对 QueryingWithLike 的调用,并注释掉所有其他方法调用。

  3. 运行代码,输入部分产品名称 che,查看结果,并注意 Chef Anton's Gumbo Mix 现在已缺失。这是因为生成的 SQL 语句包含一个针对 Discontinued 列的过滤器,如下面的输出所示(已突出显示):

    Enter part of a product name: che
    dbug: 05/03/2022 13:34:27.290 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[@__Format_1='%che%' (Size = 5)], CommandType='Text', CommandTimeout='30']
          SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
          FROM "Products" AS "p"
          WHERE **NOT ("p"."Discontinued") AND** ("p"."ProductName" LIKE @__Format_1)
    Chef Anton's Cajun Seasoning has 53 units in stock. Discontinued? False
    Queso Manchego La Pastora has 86 units in stock. Discontinued? False
    Gumbär Gummibärchen has 15 units in stock. Discontinued? False 
    

SQL SELECT 查询

通常,你可以使用 LINQ 表达所有需要的查询。但在无法使用 LINQ 的情况下,你可以使用 FromSql 及其相关方法。

FromSql 方法是在 EF Core 7 中引入的。如果你需要使用 EF Core 6 或更早版本执行原始 SQL,则必须使用 FromSqlInterpolated 方法。

FromSql 方法允许你在数据库上执行原始 SQL 查询并将结果映射到你的实体类。FromSql 可以用于执行返回实体类型或不是 EF Core 模型一部分的类型的选择查询。

FromSql 方法在以下情况下特别有用:

  • 它允许你运行可能无法使用 LINQ 实现的复杂 SQL 查询。

  • 有时,对于某些类型的查询,原始 SQL 可能比 LINQ 更高效。

  • 如果你正在使用需要执行特定 SQL 查询的遗留系统。

  • 你可以执行返回实体的存储过程。

FromSql 的参数必须是 FormattableString,而不仅仅是普通的 string 值。这是为了强制执行安全的参数化。使用插值 string 格式传递参数值。

让我们看看一些示例:

  1. Program.Queries.cs 文件中,添加一个名为 GetProductUsingSql 的方法,如下面的代码所示:

    private static void GetProductUsingSql()
    {
      using NorthwindDb db = new();
      SectionTitle("Get product using SQL");
      int? rowCount = db.Products?.Count();
      if (rowCount is null)
      {
        Fail("Products table is empty.");
        return;
      }
      int productId = 1;
      Product? p = db.Products?.FromSql(
        $"SELECT * FROM Products WHERE ProductId = {
        productId}").FirstOrDefault();
      if (p is null)
      {
        Fail("Product not found.");
        return;
      }
      WriteLine($"Product: {p.ProductId} - {p.ProductName}");
    } 
    
  2. Program.cs 文件中,添加对 GetProductUsingSql 的调用。

  3. 运行代码并查看输出,如下所示:

    dbug: 7/27/2024 14:47:07.515 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
          SELECT COUNT(*)
          FROM "Products" AS "p"
          WHERE NOT ("p"."Discontinued")
    dbug: 7/27/2024 14:47:07.582 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
          Executing DbCommand [Parameters=[p0='1'], CommandType='Text', CommandTimeout='30']
          SELECT "n"."ProductId", "n"."CategoryId", "n"."UnitPrice", "n"."Discontinued", "n"."ProductName", "n"."UnitsInStock"
          FROM (
              SELECT * FROM Products WHERE ProductId = @p0
          ) AS "n"
          WHERE NOT ("n"."Discontinued")
          LIMIT 1
    Product: 1 – Chai 
    

FromSql 只能在 DbSet<T> 上调用,不能在 LINQ 查询上调用。

你可以使 SQL 更加动态,以满足需要这种情况。例如,如果列的名称可能会改变,以及值,那么你可以使用 FromSqlRaw。但请注意!你必须确保这个 string 值是安全的,特别是如果它来自不受信任的来源。这涉及到识别特殊字符,如分号、注释和其他 SQL 构造,然后正确地转义或拒绝这些输入,以防止潜在的安全风险。

更多信息:你可以在 learn.microsoft.com/en-us/ef/core/querying/sql-queries#dynamic-sql-and-parameters 了解更多关于动态 SQL 和参数的信息。

你现在已经看到了使用 EF Core 查询数据的许多常见方法。在某些在线部分,你可以查看数据是如何加载和跟踪的,以及为什么你可能想要控制 EF Core 如何执行这些操作。

练习和探索

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

练习 10.1 – 在线材料

在线材料可以是专为这本书编写的额外内容,也可以是引用 Microsoft 或第三方创建的内容。

使用 EF Core 加载和跟踪模式

在以下链接中了解如何使用 EF Core 加载数据和跟踪:

github.com/markjprice/cs13net9/blob/main/docs/ch10-loading-tracking.md

使用 EF Core 修改数据

在以下链接中了解如何使用 EF Core 修改数据:

github.com/markjprice/cs13net9/blob/main/docs/ch10-modifying.md

使用事务

在修改代码中添加事务:

github.com/markjprice/cs13net9/blob/main/docs/ch10-transactions.md

探索 Code First EF Core 模型

通过一个示例 Code First 模型来操作,该模型生成一个空数据库,用示例数据填充它,然后查询数据:

github.com/markjprice/cs13net9/blob/main/docs/ch10-code-first.md

探索应用程序密钥

当连接到数据库时,你通常需要包括敏感的密钥值,如用户名或密码。这些值绝不应该存储在源代码中,甚至不应该存储在可能被添加到代码库的单独文件中。

在开发期间,密钥应存储在本地,在生产环境中存储在安全系统中。在本地开发期间,你可以使用 Secret Manager,而在云生产系统中使用 Azure Key Vault。有关应用程序密钥的更多信息,我已编写了一个仅在以下链接中可读的在线部分:

github.com/markjprice/cs13net9/blob/main/docs/ch10-app-secrets.md

NoSQL 数据库

本章重点介绍了如 SQL Server 和 SQLite 之类的 RDBMS。如果你希望了解更多关于 NoSQL 数据库(如 Cosmos DB 和 MongoDB)以及如何使用 EF Core 与它们一起使用的信息,那么我推荐以下链接:

练习 10.2 – 实践练习

实践练习深入探讨了本章的主题。

使用不同的序列化格式导出数据

Chapter10 解决方案中,创建一个名为 Exercise_DataSerialization 的控制台应用程序,查询 Northwind 数据库中的所有类别和产品,然后使用至少三种 .NET 可用的序列化格式进行数据序列化。哪种序列化格式使用的字节数最少?

练习 10.3 – 测试你的知识

回答以下问题:

  1. 用于表示表的属性的类型是什么,例如数据库上下文的 Products 属性?

  2. 用于表示一对一关系的属性的类型是什么,例如 Category 实体的 Products 属性?

  3. EF Core 的主键约定是什么?

  4. 在实体类中何时可能使用注解属性?

  5. 为什么你可能会选择 Fluent API 而不是注解属性?

  6. Serializable 事务隔离级别意味着什么?

  7. DbContext.SaveChanges() 方法返回什么?

  8. 预加载和显式加载之间有什么区别?

  9. 如何定义一个 EF Core 实体类以匹配以下表?

    CREATE TABLE Employees(
      EmpId INT IDENTITY,
      FirstName NVARCHAR(40) NOT NULL,
      Salary MONEY
    ) 
    
  10. 声明实体导航属性为 virtual 有什么好处?

练习 10.4 – 探索主题

使用此链接了解本章涵盖的主题:

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-10---working-with-data-using-entity-framework-core

摘要

在本章中,你学习了以下内容:

  • 连接到现有数据库并构建实体数据模型。

  • 执行一个简单的 LINQ 查询并处理结果。

  • 使用过滤的包含。

  • 直接执行 SQL 查询。

在下一章中,你将学习如何编写更高级的 LINQ 查询以选择、过滤、排序、连接和分组。

第十一章:使用 LINQ 查询和操作数据

本章是关于语言集成查询LINQ)表达式。LINQ 是一组语言扩展,使您能够处理数据序列,然后过滤、排序并将它们投影到不同的输出中。

本章将涵盖以下主题:

  • 编写 LINQ 表达式

  • LINQ 实践

  • 排序等更多功能

  • 使用 LINQ 与 EF Core

  • 连接、分组和查找

编写 LINQ 表达式

我们需要回答的第一个基本问题是:为什么 LINQ 存在?

比较命令式和声明式语言特性

LINQ 于 2008 年随 C# 3 和.NET Framework 3.0 一起推出。在此之前,如果 C#和.NET 程序员想要处理一系列项目,他们必须使用过程式,即命令式的代码语句。例如,一个循环:

  1. 将当前位置设置为第一个项目。

  2. 通过将一个或多个属性与指定的值进行比较来检查项目是否应该被处理。例如,单价是否大于 50,或者国家是否等于比利时?

  3. 如果有匹配项,处理该项目。例如,将一个或多个属性输出给用户,将一个或多个属性更新为新值,删除项目,或执行聚合计算,如计数或求和值。

  4. 移动到下一个项目。重复,直到所有项目都已处理。

过程式代码告诉编译器如何实现目标。这样做,然后那样做。由于编译器不知道你试图实现什么,因此它无法提供太多帮助。你必须 100%负责确保每个如何做步骤都是正确的。

LINQ 使这些常见任务变得更加容易,减少了引入微妙错误的可能。不再需要明确地声明每个单独的操作,如移动、读取、更新等,LINQ 使程序员能够使用声明性即函数式风格的语句编写。

声明性,即函数式,代码告诉编译器要实现什么目标。编译器会找出实现它的最佳方式。这些语句通常也更简洁。

良好实践:如果你不完全理解 LINQ 的工作原理,那么你编写的语句可能会引入自己的微妙错误!2022 年流传的一个代码谜题涉及一系列任务和了解它们何时执行(twitter.com/amantinband/status/1559187912218099714)。大多数经验丰富的开发者都答错了!公平地说,是 LINQ 行为与多线程行为的组合让大多数人感到困惑。但到本章结束时,你将更好地了解为什么代码因为 LINQ 行为而危险。

尽管我们在第十章使用 Entity Framework Core 处理数据中编写了一些 LINQ 表达式,但它们并不是重点,因此我没有正确解释 LINQ 是如何工作的。现在让我们花时间正确理解它们。

LINQ 组件

LINQ 有几个部分;一些是必需的,一些是可选的:

  • 扩展方法必需):这些包括 WhereOrderBySelect 等示例。这些提供了 LINQ 的功能。

  • LINQ 提供程序必需):这些包括用于处理内存中对象的 LINQ to Objects,用于处理存储在外部数据库中并由 EF Core 模型的 LINQ to Entities,以及用于处理存储为 XML 的数据的 LINQ to XML。这些提供程序是 LINQ 的组成部分,以针对不同类型数据的方式执行 LINQ 表达式。

  • Lambda 表达式可选):这些可以用作替代命名方法来简化 LINQ 查询,例如,用于 Where 方法的条件逻辑进行过滤。

  • LINQ 查询理解语法可选):这些包括 frominwhereorderbydescendingselect 等 C# 关键字。这些是某些 LINQ 扩展方法的别名,它们的使用可以简化你编写的查询,特别是如果你已经熟悉其他查询语言,如 结构化查询语言SQL)。

当程序员第一次接触 LINQ 时,他们常常认为 LINQ 查询理解语法就是 LINQ,但讽刺的是,这正是 LINQ 中可选的部分之一!

使用 Enumerable 类构建 LINQ 表达式

LINQ 扩展方法,如 WhereSelect,由 Enumerable 静态类附加到任何实现 IEnumerable<T> 的类型上,称为 序列。一个序列包含零个、一个或多个项。

例如,任何类型的数组都实现了 IEnumerable<T> 类,其中 T 是数组中项的类型。这意味着所有数组都支持 LINQ 进行查询和操作。

所有泛型集合,如 List<T>Dictionary<TKey, TValue>Stack<T>Queue<T>,都实现了 IEnumerable<T>,因此它们也可以使用 LINQ 进行查询和操作。

Enumerable 定义了 50 多个扩展方法,总结在 表 11.1表 11.2 中。

这些表格将对你未来的参考很有用,但就目前而言,你可能想简要地浏览它们,以了解存在哪些扩展方法,稍后再回来仔细审查。这些表格的在线版本可在以下链接找到:github.com/markjprice/cs13net9/blob/main/docs/ch11-linq-methods.md

首先,这里有一些返回新的 IEnumerable<T> 项序列的延迟方法:

方法 描述
Where 返回与指定过滤器匹配的项的序列。
索引 返回一个包含项及其索引的序列。从 .NET 9 开始引入。
SelectSelectMany 将项投影到不同的形状,即不同的类型,并扁平化项的嵌套层次结构。
Skip 跳过一定数量的项。
SkipWhile 在表达式为 true 时跳过项目。
SkipLast 返回一个新的可枚举集合,包含从源中获取的元素,但省略了源集合的最后 count 个元素。
Take 获取一定数量的项目。.NET 6 引入了一个可以传递 Range 的重载,例如,Take(range: 3..⁵),意味着从开始处取三个项目,并在结束处取五个项目,或者而不是 Skip(4),你可以使用 Take(4..)
TakeWhile 在表达式为 true 时获取项目。
TakeLast 返回一个新的可枚举集合,包含从源中获取的最后一个 count 个元素。
OrderBy, OrderByDescending, ThenBy, 和 ThenByDescending 根据指定的字段或属性对项目进行排序。
OrderOrderDescending 根据项目本身进行排序。
Reverse 反转项目的顺序。
GroupBy, GroupJoin, 和 Join 对两个序列进行分组和/或连接。
AggregateBy, CountBy, DistinctBy, ExceptBy, IntersectBy, UnionBy, MinBy, 和 MaxBy 允许在项目的一个子集上而不是所有项目上执行比较。例如,而不是通过比较整个 Person 对象来使用 Distinct 移除重复项,你可以使用 DistinctBy 通过比较它们的 LastNameDateOfBirth 属性来移除重复项。CountByAggregateBy 扩展方法是在 .NET 9 中引入的。
AsEnumerable 返回输入序列作为 IEnumerable<T> 类型。这在类型有自己的 Where 等 LINQ 扩展方法实现时很有用,而你想要调用标准的 LINQ Where 方法。
DefaultIfEmpty 返回 IEnumerable<T> 的元素,如果序列为空,则返回默认值的单例集合。例如,如果序列是一个空的 IEnumerable<int>,它将返回一个包含单个项目 0IEnumerable<int>
Cast<T> 将项目转换为指定的类型。在编译器会报错的情况下,将非泛型对象转换为泛型类型很有用。
OfType<T> 移除不匹配指定类型的项。
Distinct 移除重复项。
Except, Intersect, 和 Union 执行返回集合的操作。集合不能有重复的项目。尽管输入可以是任何序列,因此输入可以有重复,但结果始终是集合。
Chunk 将序列分割成固定大小的批次。size 参数指定每个批次中的项目数量。最后一个批次将包含剩余的项目,并且可能小于 size
Append, Concat, 和 Prepend 执行序列组合操作。
Zip 根据项目位置在两个或三个序列上执行匹配操作;例如,第一个序列位置 1 的项目与第二个序列位置 1 的项目匹配。

表 11.1:延迟的 LINQ 扩展方法

接下来,这里是一些返回单个标量值的非延迟方法,例如单个 TSource 项目、一个数字或一个 bool

First, FirstOrDefault, Last, 和 LastOrDefault 获取序列中的第一个或最后一个项目,如果没有则抛出异常,或返回该类型的默认值,例如对于 int 类型是 0,对于引用类型是 null
SingleSingleOrDefault 返回匹配特定过滤器的项目,如果没有则抛出异常,或返回该类型的默认值。
ElementAtElementAtOrDefault 返回指定索引位置的项目,如果没有则抛出异常,或返回该类型的默认值。.NET 6 引入了可以传递 Index 而不是 int 的重载,这在处理 Span<T> 序列时更有效。
Aggregate, Average, Count, LongCount, Max, Min, 和 Sum 计算聚合值。
TryGetNonEnumeratedCount Count() 检查序列是否实现了 Count 属性并返回其值,或者枚举整个序列以计数其项目。在 .NET 6 中引入,此方法仅检查 Count;如果它不存在,则返回 false 并将 out 参数设置为 0 以避免潜在的较差性能操作。
SequenceEqual 根据相等比较器判断两个序列是否相等,返回 truefalse
All, Any, 和 Contains 如果所有或任何项目匹配过滤器,或者序列包含指定的项目,则返回 true。如果序列是 List<T>,则它们使用其本地的 TrueForAll 方法而不是 LINQ 的 All 方法。
ToArray, ToList, ToDictionary, ToHashSet, 和 ToLookup 将序列转换为数组或集合。这些是唯一强制立即执行 LINQ 表达式而不是延迟执行的扩展方法,你将在稍后了解。

表 11.2:非延迟 LINQ 扩展方法

良好实践:确保你理解并记住以 AsTo 开头的 LINQ 扩展方法之间的区别。AsEnumerable 方法将序列转换为不同的类型但不分配内存,因此该方法很快。以 To 开头的方法,如 ToList,为新的项目序列分配内存,因此它们可能较慢,并且总是使用更多的内存资源。

Enumerable 类还有一些不是扩展方法的方法,如表 11.3 所示:

方法 描述
Empty<T> 返回指定类型 T 的空序列。当需要传递空序列给需要 IEnumerable<T> 的方法时很有用。
Range 返回从 start 值开始的整数序列,包含 count 个项目。例如,Enumerable.Range(start: 5, count: 3) 将包含整数 5、6 和 7。
Repeat 返回一个包含重复 count 次相同元素的序列。例如,Enumerable.Repeat(element: "5", count: 3) 将包含 string"5""5""5"

表 11.3:Enumerable 非扩展方法

实践中的 LINQ

现在,我们可以构建一个控制台应用程序来探索使用 LINQ 的实际示例。

理解延迟执行

LINQ 使用延迟执行。重要的是要理解,调用上述大多数扩展方法并不会执行查询并获取结果。这些扩展方法中的大多数返回一个表示问题而不是答案的 LINQ 表达式。让我们来探索:

  1. 使用您喜欢的代码编辑器创建一个新项目,如下列列表中定义:

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

    • 项目文件和文件夹:LinqWithObjects

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

  2. 在项目文件中,全局和静态导入 System.Console 类。

  3. 添加一个名为 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;
      }
    } 
    
  5. 添加一个名为 Program.Functions.cs 的新类文件。

  6. Program.Functions.cs 中,删除任何现有语句,定义一个部分 Program 类,其中包含一个名为 DeferredExecution 的方法,该方法接受一个 string 值数组作为参数,然后定义两个查询,如下面的代码所示:

    partial class Program
    {
      private static void DeferredExecution(string[] names)
      {
        SectionTitle("Deferred execution");
        // Question: Which names end with an M?
        // (using a LINQ extension method)
        var query1 = names.Where(name => name.EndsWith("m"));
        // Question: Which names end with an M?
        // (using LINQ query comprehension syntax)
        var query2 = from name in names where name.EndsWith("m") select name;
      }
    } 
    
  7. Program.cs 中,删除现有语句,添加定义一个包含在办公室工作的人的 string 值序列的语句,然后将它作为参数传递给 DeferredExecution 方法,如下面的代码所示:

    // A string array is a sequence that implements IEnumerable<string>.
    string[] names = { "Michael", "Pam", "Jim", "Dwight",
      "Angela", "Kevin", "Toby", "Creed" };
    DeferredExecution(names); 
    
  8. Program.Functions.csDeferredExecution 方法中,要获取答案(换句话说,要执行查询),您必须通过调用 To 方法之一,如 ToArrayToDictionaryToLookup,或通过枚举查询来实现它。添加执行此操作的语句,如下面的代码所示:

    // Answer returned as an array of strings containing Pam and Jim.
    string[] result1 = query1.ToArray();
    // Answer returned as a list of strings containing Pam and Jim.
    List<string> result2 = query2.ToList();
    // Answer returned as we enumerate over the results.
    foreach (string name in query1)
    {
      WriteLine(name); // outputs Pam
      names[2] = "Jimmy"; // Change Jim to Jimmy.
      // On the second iteration Jimmy does not
      // end with an "m" so it does not get output.
    } 
    
  9. 运行控制台应用程序并注意结果,如下面的输出所示:

    *** Deferred execution ***
    Pam 
    

由于延迟执行,在输出第一个结果 Pam 后,如果原始数组值发生变化,那么当我们循环回来时,将没有更多匹配项,因为 Jim 变成了 Jimmy 并且不以 m 结尾,所以只有 Pam 被输出。

在我们深入探讨之前,让我们放慢速度,看看一些常见的 LINQ 扩展方法和如何逐个使用它们。

使用 Where 过滤实体

使用 LINQ 最常见的原因是使用 Where 扩展方法来过滤序列中的项目。让我们通过定义一个名称序列并对其应用 LINQ 操作来探索过滤:

  1. 在项目文件中,添加一个元素以防止 System.Linq 命名空间自动全局导入,如下面高亮显示的标记所示:

    <ItemGroup>
      <Using Include="System.Console" Static="true" />
     **<Using Remove=****"****System.Linq"** **/>**
    </ItemGroup> 
    
  2. Program.Functions.cs 中,添加一个名为 FilteringUsingWhere 的新方法,如下面的代码所示:

    private static void FilteringUsingWhere(string[] names)
    {
    } 
    
  3. 如果你正在使用 Visual Studio,导航到 工具 | 选项。在 选项 对话框中,导航到 文本编辑器 | C# | IntelliSense,清除 显示未导入命名空间中的项 复选框,然后点击 确定

  4. FilteringUsingWhere 中,尝试在名称数组上调用 Where 扩展方法,如下面的代码所示:

    SectionTitle("Filtering entities using Where");
    var query = names.W 
    
  5. 当你输入 W 时,注意在较老的代码编辑器(或者禁用了显示未导入命名空间项的选项的代码编辑器)中,Where 方法在 string 数组的 IntelliSense 成员列表中缺失,如图 11.1 所示:

图 11.1:缺少 Where 扩展方法的 IntelliSense

这是因为 Where 是一个扩展方法。它不存在于数组类型上。为了使 Where 扩展方法可用,我们必须导入 System.Linq 命名空间。在新的 .NET 6 及以后的项目中,它默认隐式导入,但我们移除了它以说明这一点。代码编辑器的最新版本足够智能,会建议使用 Where 方法,并指示它们将自动为你导入 System.Linq 命名空间。

  1. 如果你正在使用 Visual Studio,导航到 工具 | 选项。在 选项 对话框中,导航到 文本编辑器 | C# | IntelliSense,选择 显示未导入命名空间中的项 复选框,然后点击 确定

  2. 在项目文件中,注释掉移除 System.Linq 的元素,如下面的代码所示:

    <!--<Using Remove="System.Linq" />--> 
    
  3. 保存更改并构建项目。

  4. 重新输入 Where 方法的 W,注意 IntelliSense 列表现在包括由 Enumerable 类添加的扩展方法,如图 11.2 所示:

图 11.2:导入 System.Linq 时 IntelliSense 显示 LINQ 扩展方法

有趣的是,正如你在我的计算机上 Visual Studio 的截图中所见,GitHub Copilot 甚至建议使用 lambda 表达式自动完成,这与我们最终将编写的表达式非常相似。但在我们到达那里之前,有一些重要的中间步骤你需要看到,所以如果你启用了该功能,请不要插入任何 GitHub Copilot 建议。

  1. 当你输入 Where 方法的括号时,IntelliSense 告诉我们,要调用 Where,我们必须传递一个 Func<string, bool> 委托实例。

  2. 输入一个表达式来创建一个 Func<string, bool> 委托的新实例,目前请注意,我们尚未提供方法名,因为我们将在下一步定义它,如下面的代码所示:

    var query = names.Where(new Func<string, bool>( )) 
    
  3. 目前先不要完成该语句。

Func<string, bool> 委托告诉我们,对于传递给方法的每个 string 变量,方法必须返回一个 bool 值。如果方法返回 true,则表示我们应该将 string 包含在结果中,如果方法返回 false,则表示我们应该排除它。

针对命名方法的定位

让我们定义一个只包含长度超过四个字符的名称的方法:

  1. Program.Functions.cs 中,添加一个方法,该方法仅对长度超过四个字符的名称返回 true,如下面的代码所示:

    static bool NameLongerThanFour(string name)
    {
      // Returns true for a name longer than four characters.
      return name.Length > 4;
    } 
    
  2. FilteringUsingWhere 方法中,将方法名称传递给 Func<string, bool> 委托,如下面的代码所示(高亮显示):

    var query = names.Where(
      new Func<string, bool>(**NameLongerThanFour**)); 
    
  3. FilteringUsingWhere 方法中,添加使用 foreach 遍历 names 数组的语句,如下面的代码所示:

    foreach (string item in query)
    {
      WriteLine(item);
    } 
    
  4. Program.cs 中,注释掉对 DeferredExecution 的调用,然后将 names 作为参数传递给 FilteringUsingWhere 方法,如下面的代码所示:

    // DeferredExecution(names);
    FilteringUsingWhere(names); 
    
  5. 运行代码并查看结果,注意只列出了长度超过四个字母的名称,如下面的输出所示:

    Michael
    Dwight
    Angela
    Kevin
    Creed 
    

通过移除显式委托实例化简化代码

我们可以通过删除 Func<string, bool> 委托的显式实例化来简化代码,因为 C# 编译器可以为我们实例化委托:

  1. 为了帮助你通过查看逐步改进的代码来学习,在 FilteringUsingWhere 方法中,注释掉查询并添加关于其工作方式的注释,如下面的代码所示:

    // Explicitly creating the required delegate.
    // var query = names.Where(
    //   new Func<string, bool>(NameLongerThanFour)); 
    
  2. 再次输入查询,但这次,不要显式实例化委托,如下面的代码所示:

    // The compiler creates the delegate automatically.
    var query = names.Where(NameLongerThanFour); 
    
  3. 运行代码,并注意它具有相同的行为。

针对 lambda 表达式

我们可以使用 lambda 表达式 代替命名方法来进一步简化我们的代码。

虽然一开始看起来可能很复杂,但 lambda 表达式实际上是一个无名的函数。它使用 =>(读作“走向”)符号来表示返回值:

  1. 将第二个查询注释掉,然后添加一个使用 lambda 表达式的查询的第三个版本,如下面的代码所示:

    // Using a lambda expression instead of a named method.
    var query = names.Where(name => name.Length > 4); 
    

注意,lambda 表达式的语法包括了 NameLongerThanFour 方法的重要部分,没有更多。lambda 表达式只需要定义以下内容:

  • 输入参数的名称:name

  • 返回值表达式:name.Length > 4

name 输入参数的类型是根据序列包含 string 值这一事实推断出来的,并且返回类型必须是委托定义的 bool 值,以便 Where 方法能够工作;因此,=> 符号后面的表达式必须返回一个 bool 值。编译器为我们做了大部分工作,所以我们的代码可以尽可能简洁。

  1. 运行代码,并注意它具有相同的行为。

带有默认参数值的 lambda 表达式

从 C# 12 开始引入,你现在可以为 lambda 表达式中的参数提供默认值,如下面的代码所示:

var query = names.Where((string name = "Bob") => name.Length > 4); 

使用此 lambda 表达式的目的是,设置默认值不是必要的,但稍后你将看到更多有用的示例。

排序及其他

其他常用扩展方法有 OrderByThenBy,用于对序列进行排序。

使用 OrderBy 按单个属性排序

如果前一个方法返回另一个序列,即实现 IEnumerable<T> 接口的类型,则可以链式调用扩展方法。

让我们继续使用当前项目来探索排序:

  1. FilteringUsingWhere 方法中,将 OrderBy 方法的调用附加到现有查询的末尾,如下所示代码:

    var query = names
      .Where(name => name.Length > 4)
      .OrderBy(name => name.Length); 
    

良好实践:格式化 LINQ 语句,使每个扩展方法调用都单独一行,这样更容易阅读。

  1. 运行代码,并注意现在名字是按最短的先排序,如下所示输出:

    Kevin
    Creed
    Dwight
    Angela
    Michael 
    

要将最长的名字放在第一位,可以使用 OrderByDescending

使用 ThenBy 按后续属性排序

我们可能想要按多个属性排序,例如,按相同长度的名字按字母顺序排序:

  1. FilteringUsingWhere 方法中,将 ThenBy 方法的调用附加到现有查询的末尾,如下所示代码高亮显示:

    var query = names
      .Where(name => name.Length > 4)
      .OrderBy(name => name.Length)
      **.ThenBy(name => name)**; 
    
  2. 运行代码,注意以下排序顺序中的细微差别。在相同长度的名字组中,名字按字符串的完整值进行字母排序,因此Creed排在Kevin之前,而Angela排在Dwight之前,如下所示输出:

    Creed
    Kevin
    Angela
    Dwight
    Michael 
    

按项目本身排序

.NET 7 引入了 OrderOrderDescending 扩展方法。这些简化了按项目本身的排序。例如,如果您有一个 string 值的序列,那么在 .NET 7 之前,您必须调用 OrderBy 方法并传递一个选择项目的 lambda 表达式,如下所示代码:

var query = names.OrderBy(name => name); 

在 .NET 7 或更高版本中,我们可以简化语句,如下所示代码:

var query = names.Order(); 

OrderDescending 做类似的事情,但按降序排列。

记住 names 数组包含 string 类型的实例,该类型实现了 IComparable 接口。这就是为什么它们可以被排序,也就是排序。如果数组包含 PersonProduct 等复杂类型的实例,那么这些类型必须实现 IComparable 接口,以便它们也可以被排序。

使用 var 或指定类型声明查询

在编写 LINQ 表达式时,使用 var 声明查询对象很方便。这是因为返回类型在编写 LINQ 表达式时经常变化。例如,我们的查询最初是 IEnumerable<string>,现在是 IOrderedEnumerable<string>

  1. 将鼠标悬停在 var 关键字上,并注意其类型为 IOrderedEnumerable<string>,如图 11.3 所示:

图 11.3:将鼠标悬停在 var 上,查看查询表达式的实际隐含类型

图 11.3 中,我在 names.Where 之间添加了额外的垂直空间,这样工具提示就不会覆盖查询。

  1. var 替换为实际类型,如下所示代码高亮显示:

    **IOrderedEnumerable<****string****>** query = names
      .Where(name => name.Length > 4)
      .OrderBy(name => name.Length)
      .ThenBy(name => name); 
    

良好实践:一旦你完成对一个查询的工作,你可以将声明的类型从 var 改为实际类型,以使类型更清晰。这很容易,因为你的代码编辑器可以告诉你它是什么。这样做只是为了清晰。它对性能没有影响,因为 C# 在编译时会将所有 var 声明转换为实际类型。

  1. 运行代码,注意它具有相同的行为。

按类型过滤

Where 扩展方法非常适合按值过滤,例如文本和数字。但如果序列包含多个类型,并且你想按特定类型过滤,同时尊重任何继承层次结构,该怎么办呢?

假设你有一个异常序列。有数百种异常类型构成了一个复杂的继承层次结构,部分如图 11.4 所示:

图 11.4

图 11.4:部分异常继承层次结构

让我们探索按类型过滤:

  1. Program.Functions.cs 中,定义一个新的方法来列出,然后使用 OfType<T> 扩展方法过滤异常派生对象,以移除非算术异常的异常,只将算术异常写入控制台,如下面的代码所示:

    static void FilteringByType()
    {
      SectionTitle("Filtering by type");
      List<Exception> exceptions = new()
      {
        new ArgumentException(), new SystemException(),
        new IndexOutOfRangeException(), new InvalidOperationException(),
        new NullReferenceException(), new InvalidCastException(),
        new OverflowException(), new DivideByZeroException(),
        new ApplicationException()
      };
      IEnumerable<ArithmeticException> arithmeticExceptionsQuery =
        exceptions.OfType<ArithmeticException>();
      foreach (ArithmeticException exception in arithmeticExceptionsQuery)
      {
        WriteLine(exception);
      }
    } 
    
  2. Program.cs 中,注释掉对 FilteringUsingWhere 的调用,然后添加对 FilteringByType 方法的调用,如下面的代码所示:

    // FilteringUsingWhere(names);
    FilteringByType(); 
    
  3. 运行代码,注意结果只包括 ArithmeticException 类型或 ArithmeticException 派生类型的异常,如下面的输出所示:

    System.OverflowException: Arithmetic operation resulted in an overflow.
    System.DivideByZeroException: Attempted to divide by zero. 
    

使用集合和包

集合是数学中最基本的概念之一。集合是一组一个或多个独特的对象。多集,也称为,是一组一个或多个可以重复的对象。

你可能记得在学校学过关于文氏图的内容。常见的集合操作包括集合之间的交集并集

让我们编写一些代码来定义三个代表学徒群体的 string 值数组,然后我们将对它们执行一些常见的集合和多集操作:

  1. Program.Functions.cs 中,添加一个方法,该方法将任何 string 变量的序列输出为逗号分隔的单个 string 到控制台输出,并可选地包含一个描述,如下面的代码所示:

    static void Output(IEnumerable<string> cohort,
      string description = "")
    {
      if (!string.IsNullOrEmpty(description))
      {
        WriteLine(description);
      }
      Write(" ");
      WriteLine(string.Join(", ", cohort.ToArray()));
      WriteLine();
    } 
    
  2. Program.Functions.cs 中,添加一个方法,该方法定义三个名称数组,输出它们,然后对它们执行各种集合操作,如下面的代码所示:

    static void WorkingWithSets()
    {
      string[] cohort1 =
        { "Rachel", "Gareth", "Jonathan", "George" };
      string[] cohort2 =
        { "Jack", "Stephen", "Daniel", "Jack", "Jared" };
      string[] cohort3 =
        { "Declan", "Jack", "Jack", "Jasmine", "Conor" };
      SectionTitle("The cohorts");
      Output(cohort1, "Cohort 1");
      Output(cohort2, "Cohort 2");
      Output(cohort3, "Cohort 3");
      SectionTitle("Set operations");
      Output(cohort2.Distinct(), "cohort2.Distinct()");
      Output(cohort2.DistinctBy(name => name.Substring(0, 2)),
        "cohort2.DistinctBy(name => name.Substring(0, 2)):");
      Output(cohort2.Union(cohort3), "cohort2.Union(cohort3)");
      Output(cohort2.Concat(cohort3), "cohort2.Concat(cohort3)");
      Output(cohort2.Intersect(cohort3), "cohort2.Intersect(cohort3)");
      Output(cohort2.Except(cohort3), "cohort2.Except(cohort3)");
      Output(cohort1.Zip(cohort2,(c1, c2) => $"{c1} matched with {c2}"),
        "cohort1.Zip(cohort2)");
    } 
    
  3. Program.cs 中,注释掉对 FilteringByType 的调用,然后添加对 WorkingWithSets 方法的调用,如下面的代码所示:

    // FilteringByType();
    WorkingWithSets(); 
    
  4. 运行代码并查看结果,如下面的输出所示:

    Cohort 1
      Rachel, Gareth, Jonathan, George
    Cohort 2
      Jack, Stephen, Daniel, Jack, Jared
    Cohort 3
      Declan, Jack, Jack, Jasmine, Conor
    cohort2.Distinct()
      Jack, Stephen, Daniel, Jared
    cohort2.DistinctBy(name => name.Substring(0, 2)):
      Jack, Stephen, Daniel
    cohort2.Union(cohort3)
      Jack, Stephen, Daniel, Jared, Declan, Jasmine, Conor
    cohort2.Concat(cohort3)
      Jack, Stephen, Daniel, Jack, Jared, Declan, Jack, Jack, Jasmine, Conor
    cohort2.Intersect(cohort3)
      Jack
    cohort2.Except(cohort3)
      Stephen, Daniel, Jared
    cohort1.Zip(cohort2)
      Rachel matched with Jack, Gareth matched with Stephen, Jonathan matched with Daniel, George matched with Jack 
    

使用 Zip,如果两个序列中的项目数量不相等,则某些项目将没有匹配的伙伴。那些没有伙伴的,比如 Jared,将不会包含在结果中。

对于 DistinctBy 示例,我们不是通过比较整个名称来移除重复项,而是定义了一个 lambda 键选择器,通过比较前两个字符来移除重复项,因此 Jared 被移除,因为 Jack 已经是一个以 Ja 开头的名字。

获取索引以及项目。

.NET 9 引入了 Index LINQ 扩展方法。在 .NET 的早期版本中,如果你想获取每个项目的索引位置以及项目本身,你可以使用 Select 方法,但这有点混乱。

让我们看看旧方法和新方法的示例:

  1. Program.Functions.cs 中,添加一个方法,定义一个包含名称的数组,并使用旧方法(Select 方法)和新技术(Index 方法)输出它们及其索引位置,如下所示:

    static void WorkingWithIndices()
    {
      string[] theSeven = { "Homelander",
        "Black Noir", "The Deep", "A-Train",
        "Queen Maeve", "Starlight", "Stormfront" };
      SectionTitle("Working With Indices (old)");
      foreach (var (item, index) in
        theSeven.Select((item, index) => (item, index)))
      {
        WriteLine($"{index}: {item}");
      }
      SectionTitle("Working With Indices (new)");
      foreach (var (index, item) in theSeven.Index())
      {
        WriteLine($"{index}: {item}");
      }
    } 
    

警告! 注意两个声明变量的顺序,这两个变量将保存索引和项目。当使用 Select 方法时,你必须先声明 item,然后是 index。当使用 Index 方法时,你必须先声明 index,然后是 item

  1. Program.cs 中,注释掉对 WorkingWithSets 的调用,然后添加对 WorkingWithIndices 方法的调用,如下所示:

    // WorkingWithSets();
    WorkingWithIndices(); 
    
  2. 运行代码并查看结果,如下所示:

    *** Working With Indices (old) ***
    0: Homelander
    1: Black Noir
    2: The Deep
    3: A-Train
    4: Queen Maeve
    5: Starlight
    6: Stormfront
    *** Working With Indices (new) ***
    0: Homelander
    1: Black Noir
    2: The Deep
    3: A-Train
    4: Queen Maeve
    5: Starlight
    6: Stormfront 
    

到目前为止,我们已经使用了 LINQ to Objects 提供程序来处理内存中的对象。接下来,我们将使用 LINQ to Entities 提供程序来处理存储在数据库中的实体。

使用 EF Core 中的 LINQ。

我们已经看到了过滤和排序的 LINQ 查询,但没有改变序列中项目形状的查询。这种操作称为 投影,因为它涉及到将一个形状的项目投影到另一个形状。要了解投影,最好有一些更复杂的数据类型来处理,因此,在下一个项目中,我们不会使用 string 序列,而是使用你在 第十章 中介绍的 Northwind 示例数据库中的实体序列。

我将给出使用 SQLite 的说明,因为它跨平台,但如果你更喜欢使用 SQL Server,那么请随意这样做。如果你选择使用 SQL Server,我已经包含了一些注释代码来启用 SQL Server。

创建用于探索 LINQ to Entities 的控制台应用程序。

首先,我们必须创建一个控制台应用程序和 Northwind 数据库来工作:

  1. 使用你喜欢的代码编辑器添加一个新的 Console App / console 项目,命名为 LinqWithEFCore,到 Chapter11 解决方案中。

  2. 在项目文件中,全局和静态导入 System.Console 类。

  3. LinqWithEFCore 项目中,添加对 SQLite 和/或 SQL Server 的 EF Core 提供程序的包引用,如下所示:

    <ItemGroup>
      <!--To use SQLite-->
      <PackageReference Version="9.0.0"
        Include="Microsoft.EntityFrameworkCore.Sqlite" />
      <!--To use SQL Server-->
      <PackageReference Version="9.0.0"
        Include="Microsoft.EntityFrameworkCore.SqlServer" />
    </ItemGroup> 
    
  4. 构建用于恢复包的 LinqWithEFCore 项目。

  5. Northwind4Sqlite.sql 文件复制到 LinqWithEFCore 文件夹。

  6. LinqWithEFCore 文件夹的命令提示符或终端中,通过执行以下命令创建 Northwind 数据库:

    sqlite3 Northwind.db -init Northwind4Sqlite.sql 
    
  7. 请耐心等待,因为这个命令可能需要一段时间来创建数据库结构。最终,您将看到如下所示的 SQLite 命令提示符:

     -- Loading resources from Northwind4Sqlite.sql
    SQLite version 3.38.0 2022-02-22 15:20:15
    Enter ".help" for usage hints.
    sqlite> 
    
  8. 要退出 SQLite 命令模式,在 Windows 上按 Ctrl + C 两次,在 macOS 或 Linux 上按 Cmd + D

  9. 如果您更喜欢使用 SQL Server,那么您应该已经从上一章创建了 SQL Server 中的 Northwind 数据库。

构建 EF Core 模型

我们必须定义一个 EF Core 模型来表示我们将要工作的数据库和表。我们将手动定义模型以完全控制,并防止在 CategoriesProducts 表之间自动定义关系。稍后,您将使用 LINQ 来连接这两个实体集:

  1. LinqWithEFCore 项目中,添加一个名为 EntityModels 的新文件夹。

  2. EntityModels 文件夹中,向项目添加三个类文件,分别命名为 NorthwindDb.csCategory.csProduct.cs

  3. 修改名为 Category.cs 的类文件,如下所示:

    // To use [Required] and [StringLength].
    using System.ComponentModel.DataAnnotations;
    namespace Northwind.EntityModels;
    public class Category
    {
      public int CategoryId { get; set; }
      [Required]
      [StringLength(15)]
      public string CategoryName { get; set; } = null!;
      public string? Description { get; set; }
    } 
    
  4. 修改名为 Product.cs 的类文件,如下所示:

    // To use [Required] and [StringLength].
    using System.ComponentModel.DataAnnotations;
    // To use [Column].
    using System.ComponentModel.DataAnnotations.Schema;
    namespace Northwind.EntityModels;
    public class Product
    {
      public int ProductId { get; set; }
      [Required]
      [StringLength(40)]
      public string ProductName { get; set; } = null!;
      public int? SupplierId { get; set; }
      public int? CategoryId { get; set; }
      [StringLength(20)]
      public string? QuantityPerUnit { get; set; }
      // Required for SQL Server provider.
      [Column(TypeName = "money")]
      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; }
    } 
    

我们故意没有定义 CategoryProduct 之间的任何关系,以便我们可以在稍后使用 LINQ 手动将它们关联起来。

  1. 修改名为 NorthwindDb.cs 的类文件,如下所示:

    using Microsoft.Data.SqlClient; // To use SqlConnectionStringBuilder.
    using Microsoft.EntityFrameworkCore; // To use DbContext, DbSet<T>.
    namespace Northwind.EntityModels;
    public class NorthwindDb : DbContext
    {
      public DbSet<Category> Categories { get; set; } = null!;
      public DbSet<Product> Products { get; set; } = null!;
      protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
      {
        #region To use SQLite
        string database = "Northwind.db";
        string dir = Environment.CurrentDirectory;
        string path = string.Empty;
        // The database file will stay in the project folder.
        // We will automatically adjust the relative path to
        // account for running in Visual Studio or CLI.
        if (dir.EndsWith("net9.0"))
        {
          // Running in the <project>\bin\<Debug|Release>\net9.0 directory.
          path = Path.Combine("..", "..", "..", database);
        }
        else
        {
          // Running in the <project> directory.
          path = database;
        }
        path = Path.GetFullPath(path); // Convert to absolute path.
        WriteLine($"SQLite database path: {path}");
        if (!File.Exists(path))
        {
          throw new FileNotFoundException(
            message: $"{path} not found.", fileName: path);
        }
        // To use SQLite.
        optionsBuilder.UseSqlite($"Data Source={path}");
        #endregion
        #region To use SQL Server
        SqlConnectionStringBuilder builder = new();
        builder.DataSource = ".";
        builder.InitialCatalog = "Northwind";
        builder.IntegratedSecurity = true;
        builder.Encrypt = true;
        builder.TrustServerCertificate = true;
        builder.MultipleActiveResultSets = true;
        string connection = builder.ConnectionString;
        // WriteLine($"SQL Server connection: {connection}");
        // To use SQL Server.
        // optionsBuilder.UseSqlServer(connection);
        #endregion
      }
      protected override void OnModelCreating(
        ModelBuilder modelBuilder)
      {
        if (Database.ProviderName is not null &&
          Database.ProviderName.Contains("Sqlite"))
        {
          // SQLite data provider does not directly support the
          // decimal type so we can convert to double instead.
          modelBuilder.Entity<Product>()
            .Property(product => product.UnitPrice)
            .HasConversion<double>();
        }
      }
    } 
    

如果您想使用 SQL Server,那么请注释掉调用 UseSqlite 的语句,并取消注释调用 UseSqlServer 的语句。

  1. 构建项目并修复任何编译错误。

过滤和排序序列

现在,让我们编写语句来过滤和排序来自表的行序列:

  1. LinqWithEFCore 项目中,添加一个名为 Program.Helpers.cs 的新类文件。

  2. Program.Helpers.cs 中,定义一个部分 Program 类,其中包含一个配置控制台以支持特殊字符(如欧元货币符号)、控制当前区域设置以及输出部分标题的函数,如下所示:

    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 SectionTitle(string title)
      {
        ConsoleColor previousColor = ForegroundColor;
        ForegroundColor = ConsoleColor.DarkYellow;
        WriteLine($"*** {title} ***");
        ForegroundColor = previousColor;
      }
    } 
    
  3. LinqWithEFCore 项目中,添加一个名为 Program.Functions.cs 的新类文件。

  4. Program.Functions.cs 中定义一个部分 Program 类,并添加一个过滤和排序产品的函数,如下所示:

    using Northwind.EntityModels; // To use NorthwindDb, Category, Product.
    using Microsoft.EntityFrameworkCore; // To use DbSet<T>.
    partial class Program
    {
      private static void FilterAndSort()
      {
        SectionTitle("Filter and sort");
        using NorthwindDb db = new();
        DbSet<Product> allProducts = db.Products;
        IQueryable<Product> filteredProducts =
          allProducts.Where(product => product.UnitPrice < 10M);
        IOrderedQueryable<Product> sortedAndFilteredProducts =
          filteredProducts.OrderByDescending(product => product.UnitPrice);
        WriteLine("Products that cost less than $10:");
        foreach (Product p in sortedAndFilteredProducts)
        {
          WriteLine("{0}: {1} costs {2:$#,##0.00}",
            p.ProductId, p.ProductName, p.UnitPrice);
        }
        WriteLine();
      }
    } 
    

注意以下关于前面代码的内容:

  • DbSet<T> 实现 IEnumerable<T>,因此 LINQ 可以用于查询和操作为 EF Core 构建的模型中的实体序列。(实际上,我应该说是 TEntity 而不是 T,但这个泛型类型的名称没有功能上的影响。唯一的要求是这个类型是一个 class。这个名称只是表明这个类预期是一个实体模型。)

  • 序列实现 IQueryable<T>(或调用排序 LINQ 方法后的 IOrderedQueryable<T>),而不是 IEnumerable<T>IOrderedEnumerable<T>。这表明我们正在使用一个 LINQ 提供程序,该提供程序使用表达式树构建查询。它们代表以树状数据结构中的代码,并允许创建动态查询,这对于构建针对外部数据提供程序(如 SQLite)的 LINQ 查询非常有用。

  • LINQ 表达式将被转换为另一种查询语言,例如 SQL。使用 foreach 遍历查询或调用 ToArray 等方法将强制执行查询并使结果具体化。

  1. Program.cs 中,删除任何现有语句,然后调用 ConfigureConsoleFilterAndSort 方法,如下所示代码:

    ConfigureConsole(); // Sets US English by default.
    FilterAndSort(); 
    
  2. 运行项目并查看结果,如下所示输出:

    CurrentCulture: English (United States)
    *** Filter and sort ***
    SQLite database path: C:\cs13net9\Chapter11\LinqWithEFCore\Northwind.db
    Products that cost less than $10:
    41: Jack's New England Clam Chowder costs $9.65
    45: Rogede sild costs $9.50
    47: Zaanse koeken costs $9.50
    19: Teatime Chocolate Biscuits costs $9.20
    23: Tunnbröd costs $9.00
    75: Rhönbräu Klosterbier costs $7.75
    54: Tourtière costs $7.45
    52: Filo Mix costs $7.00
    13: Konbu costs $6.00
    24: Guaraná Fantástica costs $4.50
    33: Geitost costs $2.50 
    

虽然此查询输出了我们想要的信息,但它效率低下,因为它从 Products 表中获取所有列,而不是我们需要的三个列。让我们记录生成的 SQL。

  1. FilterAndSort 方法中,在用 foreach 遍历结果之前,添加一个输出 SQL 的语句,如下所示的高亮代码:

    WriteLine("Products that cost less than $10:");
    **WriteLine(sortedAndFilteredProducts.ToQueryString());** 
    
  2. 运行代码,查看显示在产品详情之前执行的 SQL 的结果,如下所示的部分输出:

    Products that cost less than $10:
    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"."UnitPrice" < 10.0
    ORDER BY "p"."UnitPrice" DESC
    41: Jack's New England Clam Chowder costs $9.65
    ... 
    

将序列投影到新类型

在我们查看投影之前,我们应该回顾对象初始化语法。如果您有一个类定义,则可以使用类名、new() 和花括号来设置字段和属性的初始值,如下所示代码:

// Person.cs
public class Person
{
  public string Name { get; set; }
  public DateTime DateOfBirth { get; set; }
}
// Program.cs
Person knownTypeObject = new()
{
  Name = "Boris Johnson",
  DateOfBirth = new(year: 1964, month: 6, day: 19)
}; 

C# 3 及以后的版本允许使用 var 关键字实例化匿名类型实例,如下所示代码:

var anonymouslyTypedObject = new
{
  Name = "Boris Johnson",
  DateOfBirth = new DateTime(year: 1964, month: 6, day: 19)
}; 

虽然我们没有指定类型,但编译器可以从两个属性的设置中推断出匿名类型,这两个属性分别命名为 NameDateOfBirth。编译器可以从分配的值推断出两个属性的类型:一个字面量 string 和一个日期/时间值的新实例。

当编写 LINQ 查询将现有类型投影到新类型时,此功能特别有用,无需显式定义新类型。由于类型是匿名的,这只能与 var 声明的局部变量一起工作。

通过添加对 Select 方法的调用,使针对数据库表的 SQL 命令更高效,将 Product 类的实例投影到仅具有三个属性的新匿名类型实例:

  1. Program.Functions.cs 文件中,在 FilterAndSort 方法中,添加一条语句以扩展 LINQ 查询,使用 Select 方法仅返回我们需要的三个属性(即表列),修改对 ToQueryString 的调用以使用新的 projectedProducts 查询,并修改 foreach 语句以使用 var 关键字和新的 projectedProducts 查询,如下所示(代码高亮):

    IOrderedQueryable<Product> sortedAndFilteredProducts =
      filteredProducts.OrderByDescending(product => product.UnitPrice);
    **var** **projectedProducts = sortedAndFilteredProducts**
     **.Select(product =>** **new****// Anonymous type.**
     **{**
     **product.ProductId,**
     **product.ProductName,**
     **product.UnitPrice**
     **});**
    WriteLine("Products that cost less than $10:");
    WriteLine(**projectedProducts**.ToQueryString());
    foreach (**var** p in **projectedProducts**)
    { 
    
  2. 将鼠标悬停在 Select 方法调用中的 new 关键字或 foreach 语句中的 var 关键字上,并注意它是一个匿名类型,如图 图 11.5 所示:

图片

图 11.5:在 LINQ 投影期间使用的匿名类型

  1. 运行项目,并确认输出与之前相同,生成的 SQL 更高效,如下所示(输出):

    SELECT "p"."ProductId", "p"."ProductName", "p"."UnitPrice"
    FROM "Products" AS "p"
    WHERE "p"."UnitPrice" < 10.0
    ORDER BY "p"."UnitPrice" DESC 
    

更多信息:您可以在以下链接中了解更多关于使用 Select 方法进行投影的信息:learn.microsoft.com/en-us/dotnet/csharp/programming-guide/concepts/linq/projection-operations

让我们继续通过学习如何连接、分组和执行查找来查看常见的 LINQ 查询。

加入、分组和查找

有三个用于连接、分组和创建分组查找的扩展方法:

  • Join:此方法有四个参数:您想要与之连接的序列、在 左侧 序列上匹配的属性或属性、在 右侧 序列上匹配的属性或属性,以及一个投影。

  • GroupJoin:此方法具有相同的参数,但它将匹配项组合成一个具有 Key 属性的组对象,该属性用于匹配值,以及用于多个匹配项的 IEnumerable<T> 类型。

  • ToLookup:此方法创建一个新的数据结构,该结构按键对序列进行分组。

连接序列

让我们探索在处理两个表 CategoriesProducts 时这些方法:

  1. Program.Functions.cs 中,添加一个选择类别和产品、将它们连接并输出的方法,如下所示(代码):

    private static void JoinCategoriesAndProducts()
    {
      SectionTitle("Join categories and products");
      using NorthwindDb db = new();
      // Join every product to its category to return 77 matches.
      var queryJoin = db.Categories.Join(
        inner: db.Products,
        outerKeySelector: category => category.CategoryId,
        innerKeySelector: product => product.CategoryId,
        resultSelector: (c, p) =>
          new { c.CategoryName, p.ProductName, p.ProductId });
      foreach (var p in queryJoin)
      {
        WriteLine($"{p.ProductId}: {p.ProductName} in {p.CategoryName}.");
      }
    } 
    

在连接中,有两个序列,outerinner。在上面的示例中,categories 是外部序列,products 是内部序列。

  1. Program.cs 中,注释掉对 FilterAndSort 的调用,然后调用 JoinCategoriesAndProducts 方法,如下所示(代码高亮):

    ConfigureConsole(); // Sets US English by default.
    **//** FilterAndSort();
    **JoinCategoriesAndProducts();** 
    
  2. 运行代码并查看结果。注意,对于每个 77 个产品,都有一行输出,如下所示(输出,仅包括前四项):

    1: Chai in Beverages.
    2: Chang in Beverages.
    3: Aniseed Syrup in Condiments.
    4: Chef Anton's Cajun Seasoning in Condiments.
    ... 
    
  3. Program.Functions.cs 中,在 JoinCategoriesAndProducts 方法中,在现有查询的末尾调用 OrderBy 方法按 CategoryName 排序,如下所示(代码高亮):

    var queryJoin = db.Categories.Join(
      inner: db.Products,
      outerKeySelector: category => category.CategoryId,
      innerKeySelector: product => product.CategoryId,
      resultSelector: (c, p) =>
        new { c.CategoryName, p.ProductName, p.ProductId })
      **.OrderBy(cp => cp.CategoryName)**; 
    
  4. 运行代码并查看结果。请注意,每个 77 个产品都有一行输出,结果首先显示 Beverages 类别中的所有产品,然后是 Condiments 类别,依此类推,如下面的部分输出所示:

    1: Chai in Beverages.
    2: Chang in Beverages.
    24: Guaraná Fantástica in Beverages.
    34: Sasquatch Ale in Beverages.
    ... 
    

分组连接序列

让我们探索使用与探索连接时相同的两个表(CategoriesProducts),以便我们可以比较细微的差异:

  1. Program.Functions.cs 中,添加一个分组和连接的方法,显示组名,然后显示每个组中的所有项目,如下面的代码所示:

    private static void GroupJoinCategoriesAndProducts()
    {
      SectionTitle("Group join categories and products");
      using NorthwindDb db = new();
      // Group all products by their category to return 8 matches.
      var queryGroup = db.Categories.AsEnumerable().GroupJoin(
        inner: db.Products,
        outerKeySelector: category => category.CategoryId,
        innerKeySelector: product => product.CategoryId,
        resultSelector: (c, matchingProducts) => new
        {
          c.CategoryName,
          Products = matchingProducts.OrderBy(p => p.ProductName)
        });
      foreach (var c in queryGroup)
      {
        WriteLine($"{c.CategoryName} has {c.Products.Count()} products.");
        foreach (var product in c.Products)
        {
          WriteLine($"  {product.ProductName}");
        }
      }
    } 
    

如果我们没有调用 AsEnumerable 方法,那么将抛出一个运行时异常,如下面的输出所示:

Unhandled exception. System.ArgumentException:  Argument type 'System.Linq.IOrderedQueryable`1[Packt.Shared.Product]' does not match the corresponding member type 'System.Linq.IOrderedEnumerable`1[Packt.Shared.Product]' (Parameter 'arguments[1]') 

这是因为并非所有 LINQ 扩展方法都可以从表达式树转换为其他查询语法,如 SQL。在这些情况下,我们可以通过调用 AsEnumerable 方法将 IQueryable<T> 转换为 IEnumerable<T>,这强制查询处理仅使用 LINQ to EF Core 将数据带入应用程序,然后使用 LINQ to Objects 在内存中执行更复杂的处理。但是,这通常效率较低。

  1. Program.cs 中,调用 GroupJoinCategoriesAndProducts 方法。

  2. 运行代码,查看结果,并注意每个类别内的产品已按查询中定义的名称排序,如下面的部分输出所示:

    Beverages has 12 products.
      Chai
      Chang
      ...
    Condiments has 12 products.
      Aniseed Syrup
      Chef Anton's Cajun Seasoning
      ... 
    

查询分组

而不是编写一个 LINQ 查询表达式来连接和分组,然后运行一次,您可能希望使用 LINQ 扩展方法来创建,然后存储一个可重用的内存集合,该集合包含已分组的实体。

在 Northwind 数据库中有一个名为 Products 的表,其中包含一个列,表示它们所在的类别,部分如下所示 表 11.4

产品名称 分类 ID
奶茶 1
长颈瓶 1
八角糖浆 2
安东大厨的卡真调味料 2
安东大厨的 gumbo 混合料 2
... ...

表 11.4:产品表的前五行

您可能希望在内存中创建一个数据结构,可以按类别对 Product 实体进行分组,然后提供一个快速的方法来查找特定类别中的所有产品。

您可以使用 ToLookup LINQ 方法创建此内容,如下面的代码所示:

ILookup<int, Product>? productsByCategoryId =
  db.Products.ToLookup(keySelector: category => category.CategoryId); 

当您调用 ToLookup 方法时,您必须指定一个 键选择器 来选择您想要按什么值分组。然后,此值可以稍后用于查找组和其项目。

ToLookup 方法在内存中创建一个类似于字典的数据结构,其中包含键值对,键是唯一的类别 ID,值是 Product 对象的集合,部分如下所示 表 11.5

值(每个都是一个 Product 对象的集合)
1 [奶茶], [长颈瓶],等等
2 [八角糖浆], [安东大厨的卡真调味料], [安东大厨的 gumbo 混合料],等等
... ...

表 11.5:查找的前两行

注意,方括号中的产品名称,如 [Chai],代表一个完整的 Product 对象。

我们可以使用相关类别表中的类别名称,而不是使用 CategoryId 值作为查找的关键。

让我们在代码示例中这样做:

  1. Program.Functions.cs 中添加一个方法,将产品与类别名称连接起来,然后将其转换为查找,使用 IGrouping<string, Product> 枚举整个查找,以表示查找字典中的每一行,并查找特定类别的单个产品集合,如下面的代码所示:

    private static void ProductsLookup()
    {
      SectionTitle("Products lookup");
      using NorthwindDb db = new();
      // Join all products to their category to return 77 matches.
      var productQuery = db.Categories.Join(
        inner: db.Products,
        outerKeySelector: category => category.CategoryId,
        innerKeySelector: product => product.CategoryId,
        resultSelector: (c, p) => new { c.CategoryName, Product = p });
      ILookup<string, Product> productLookup = productQuery.ToLookup(
        keySelector: cp => cp.CategoryName,
        elementSelector: cp => cp.Product);
      foreach (IGrouping<string, Product> group in productLookup)
      {
        // Key is Beverages, Condiments, and so on.
        WriteLine($"{group.Key} has {group.Count()} products.");
        foreach (Product product in group)
        {
          WriteLine($" {product.ProductName}");
        }
      }
      // We can look up the products by a category name.
      Write("Enter a category name: ");
      string categoryName = ReadLine()!;
      WriteLine();
      WriteLine($"Products in {categoryName}:");
      IEnumerable<Product> productsInCategory = productLookup[categoryName];
      foreach (Product product in productsInCategory)
      {
        WriteLine($"  {product.ProductName}");
      }
    } 
    

选择器参数是用于选择不同目的的 lambda 表达式。例如,ToLookup 有一个 keySelector 用于选择每个项的部分,该部分将成为键,还有一个 elementSelector 用于选择每个项的部分,该部分将成为值。你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/api/system.linq.enumerable.tolookup

  1. Program.cs 中调用 ProductsLookup 方法。

  2. 运行代码,查看结果,输入一个类别名称,例如 Seafoods,并注意该类别下的产品已经被查找并列出,如下面的部分输出所示:

    Beverages has 12 products.
      Chai
      Chang
      ...
    Condiments has 12 products.
      Aniseed Syrup
      Chef Anton's Cajun Seasoning
      ...
    Enter a category name: Seafood
    Products in Seafood:
      Ikura
      Konbu
      Carnarvon Tigers
      Nord-Ost Matjeshering
      Inlagd Sill
      Gravad lax
      Boston Crab Meat
      Jack's New England Clam Chowder
      Rogede sild
      Spegesild
      Escargots de Bourgogne
      Röd Kaviar 
    

LINQ 有很多内容,在最后一节中,你将有机会通过一些在线材料进一步探索。

练习和探索

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

练习 11.1 – 在线材料

在线材料可以是为我这本书写的额外内容,也可以是引用 Microsoft 或第三方创建的内容。

聚合和分页序列

你可以在以下链接中学习如何使用 LINQ 聚合方法和实现数据分页:

github.com/markjprice/cs13net9/blob/main/docs/ch11-aggregating.md

使用并行 LINQ 进行多线程操作

你可以通过使用多线程运行 LINQ 查询来提高性能和可伸缩性。通过完成以下链接中找到的仅在线部分来学习如何操作:

github.com/markjprice/cs13net9/blob/main/docs/ch11-plinq.md

使用 LINQ to XML 进行操作

如果你想要使用 LINQ 处理或生成 XML,那么你可以通过完成以下链接中找到的在线部分来学习其基础知识:

github.com/markjprice/cs13net9/blob/main/docs/ch11-linq-to-xml.md

创建自己的 LINQ 扩展方法

如果您想创建自己的 LINQ 扩展方法,那么您可以通过完成以下链接中仅在线部分来学习基础知识:

github.com/markjprice/cs13net9/blob/main/docs/ch11-custom-linq-methods.md

.NET 9 中新 LINQ 方法的设计

您可以在以下链接中阅读有关 .NET 9 中引入的新 LINQ 扩展方法的设计:

练习 11.2 – 使用 LINQ 进行查询练习

Chapter11 解决方案中,创建一个名为 Exercise_LinqQueries 的控制台应用程序,提示用户输入一个城市,然后列出在该城市中 Northwind 客户的公司名称,如下面的输出所示:

Enter the name of a city: London
There are 6 customers in London:
  Around the Horn
  B's Beverages
  Consolidated Holdings
  Eastern Connection
  North/South
  Seven Seas Imports 

然后,通过在用户输入首选城市之前显示所有已居住的独特城市列表来增强应用程序,如下面的输出所示:

Aachen, Albuquerque, Anchorage, Århus, Barcelona, Barquisimeto, Bergamo, Berlin, Bern, Boise, Bräcke, Brandenburg, Bruxelles, Buenos Aires, Butte, Campinas, Caracas, Charleroi, Cork, Cowes, Cunewalde, Elgin, Eugene, Frankfurt a.M., Genève, Graz, Helsinki, I. de Margarita, Kirkland, Kobenhavn, Köln, Lander, Leipzig, Lille, Lisboa, London, Luleå, Lyon, Madrid, Mannheim, Marseille, México D.F., Montréal, München, Münster, Nantes, Oulu, Paris, Portland, Reggio Emilia, Reims, Resende, Rio de Janeiro, Salzburg, San Cristóbal, San Francisco, Sao Paulo, Seattle, Sevilla, Stavern, Strasbourg, Stuttgart, Torino, Toulouse, Tsawassen, Vancouver, Versailles, Walla Walla, Warszawa 

练习 11.3 – 测试您的知识

回答以下问题:

  1. LINQ 的两个必需部分是什么?

  2. 您会使用哪个 LINQ 扩展方法来返回类型的一个子集属性?

  3. 您会使用哪个 LINQ 扩展方法来过滤序列?

  4. 列出五个执行聚合操作的 LINQ 扩展方法。

  5. SelectSelectMany 扩展方法之间的区别是什么?

  6. IEnumerable<T>IQueryable<T> 之间的区别是什么?您如何在这两者之间切换?

  7. 泛型 Func 委托(如 Func<T1, T2, T>)中的最后一个类型参数 T 代表什么?

  8. OrDefault 结尾的 LINQ 扩展方法有什么好处?

  9. 为什么查询理解语法是可选的?

  10. 您如何创建自己的 LINQ 扩展方法?

练习 11.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-11---querying-and-manipulating-data-using-linq

摘要

在本章中,您学习了如何编写 LINQ 查询以执行常见任务,如:

  • 仅选择您需要的项目属性。

  • 根据条件过滤项目。

  • 排序项目。

  • 将项目投影到不同的类型中。

  • 连接和分组项目。

在下一章中,您将了解如何使用 ASP.NET Core 进行 Web 开发。在剩余的章节中,您将学习如何实现 ASP.NET Core 的现代功能,如 Blazor 和 Minimal APIs。

第十二章:使用 .NET 引入现代 Web 开发

本书第三部分和最后一部分是关于使用 .NET 进行现代 Web 开发,这意味着 ASP.NET Core、Blazor 和 Minimal APIs。您将学习如何构建跨平台的项目,例如网站和 Web 服务。

微软将用于构建应用程序的平台称为 app modelsworkloads

我建议您按顺序阅读本章和后续章节,因为后续章节将引用早期章节中的项目,并且您将积累足够的知识和技能来应对后续章节中更复杂的问题。

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

  • 理解 ASP.NET Core

  • ASP.NET Core 的新功能

  • 项目结构

  • 为本书其余部分构建实体模型

  • 理解 Web 开发

理解 ASP.NET Core

由于本书是关于 C# 和 .NET 的,我们将学习用于构建本书剩余章节中我们将遇到的实际应用程序的应用程序模型。

更多信息:微软在其 .NET 架构指南 文档中提供了广泛的关于实现应用程序模型的指导,您可以通过以下链接阅读:dotnet.microsoft.com/en-us/learn/dotnet/architecture-guides

ASP.NET Core 是微软用于构建与数据交互的网站和服务的演变技术历史的一部分:

  • ActiveX 数据对象 (ADO) 于 1996 年发布,是微软尝试提供一套单一的 组件对象模型 (COM) 组件以处理数据。随着 .NET 的发布,创建了一个名为 ADO.NET 的等效产品,它仍然是 .NET 中处理数据更快的方法,其核心类包括 DbConnectionDbCommandDbDataReader。像 EF Core 这样的 ORM(对象关系映射器) 在内部使用 ADO.NET。

  • Active Server Pages (ASP) 于 1996 年发布,是微软首次尝试的平台,用于在服务器端动态执行网站代码。ASP 文件包含 HTML 和代码的混合体,这些代码在服务器上执行,使用 VBScript 语言编写。

  • ASP.NET Web Forms 于 2002 年与 .NET Framework 一起发布,旨在使熟悉 Visual Basic 等非 Web 开发者能够通过拖放视觉组件和在 Visual Basic 或 C# 中编写事件驱动代码来快速创建网站。在新的 .NET Framework Web 项目中应避免使用 Web Forms,而应使用 ASP.NET MVC。

  • Windows Communication Foundation (WCF) 于 2006 年发布,使开发者能够构建 SOAP 和 REST 服务。SOAP 功能强大但复杂,因此除非您需要高级功能,例如分布式事务和复杂消息拓扑,否则应避免使用。

  • ASP.NET MVC 于 2009 年发布,旨在在 Web 开发者之间清晰分离 模型(临时存储数据)、视图(在 UI 中使用各种格式展示数据)和 控制器(获取模型并将其传递给视图)的职责。这种分离使得重用和单元测试得到改进。

  • ASP.NET Web API 于 2012 年发布,使开发者能够创建比 SOAP 服务更简单、更可扩展的 HTTP 服务(也称为 REST 服务)。

  • ASP.NET SignalR 于 2013 年发布,通过抽象底层技术和技术,如 WebSockets 和长轮询,为网站提供实时通信功能。这使得网站功能,如实时聊天,以及更新对时间敏感的数据,如股票价格,在广泛的 Web 浏览器中成为可能,即使它们不支持底层技术,如 WebSockets。

  • ASP.NET Core 于 2016 年发布,结合了 .NET Framework 技术的现代实现,如 MVC、Web API 和 SignalR,以及替代技术,如 Razor Pages、gRPC 和 Blazor,所有这些都在现代 .NET 上运行。因此,ASP.NET Core 可以跨平台执行。ASP.NET Core 提供了许多项目模板,以帮助您开始使用其支持的技术。

    良好实践:选择 ASP.NET Core 开发网站和 Web 服务,因为它包括现代且跨平台的 Web 相关技术。

经典 ASP.NET 与现代 ASP.NET Core 对比

直到现代 .NET,ASP.NET 都是基于 .NET Framework 中的一个大型程序集构建的,名为 System.Web.dll,并且它与微软仅适用于 Windows 的 Web 服务器 Internet Information ServicesIIS)紧密耦合。多年来,这个程序集积累了大量功能,其中许多不适合现代跨平台开发。

ASP.NET Core 是 ASP.NET 的重大重构。它移除了对 System.Web.dll 程序集和 IIS 的依赖,并由模块化轻量级包组成,就像现代 .NET 的其余部分一样。ASP.NET Core 仍然支持使用 IIS 作为 Web 服务器,但有一个更好的选择。

您可以在 Windows、macOS 和 Linux 等平台上开发和运行 ASP.NET Core 应用程序。微软甚至创建了一个跨平台、高性能的 Web 服务器,名为 Kestrel,整个堆栈都是开源的。

ASP.NET Core 2.2 或更高版本的项目默认使用新的进程内托管模型。当在 Microsoft IIS 中托管时,这提供了 400% 的性能提升,但微软仍然推荐使用 Kestrel 以获得更好的性能。

使用 ASP.NET Core 构建网站

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

在许多网站上,网页浏览器被视为一个表示层,几乎所有处理都在服务器端完成。客户端可能会使用一些 JavaScript 来实现表单验证警告和一些展示功能,例如轮播图。

ASP.NET Core 提供了多种技术来构建网站的用户界面:

  • ASP.NET Core Razor Pages 是一种简单的方法,可以动态生成简单网站的 HTML。我建议将其视为一种遗留技术,并使用 Blazor 代替。

  • ASP.NET Core MVC 是一种流行的 模型-视图-控制器MVC)设计模式的实现,适用于开发复杂的网站。

  • Blazor 允许您使用 C#和.NET 构建用户界面组件,而不是像 Angular、React 和 Vue 这样的基于 JavaScript 的 UI 框架。Blazor 的早期版本要求开发者选择一个 托管模型Blazor WebAssembly 托管模型像基于 JavaScript 的框架一样在浏览器中运行您的代码。Blazor Server 托管模型在服务器上运行您的代码,并动态更新网页。.NET 8 引入了一个统一的、全栈的托管模型,允许单个组件在服务器或客户端上执行,甚至可以在运行时动态适应。您将在第十四章“使用 Blazor 构建交互式 Web 组件”中详细了解 Blazor。

那么,您应该选择哪一个?

“Blazor 现在是我们的推荐方法,用于使用 ASP.NET Core 构建 Web UI,但 MVC 和 Razor Pages 现在并没有过时。MVC 和 Razor Pages 都是成熟、全面支持且广泛使用的框架,我们计划在未来一段时间内继续支持它们。也没有要求或指导将现有的 MVC 或 Razor Pages 应用程序迁移到 Blazor。对于基于 MVC 的现有、成熟的项目,继续使用 MVC 进行开发是一个完全合理且可行的方法。”

– 丹·罗斯

您可以在以下链接中查看丹的原始评论帖子:github.com/dotnet/aspnetcore/issues/51834#issuecomment-1913282747

丹·罗斯是 ASP.NET 的首席产品经理,因此他对 ASP.NET Core 的未来比任何人都了解:devblogs.microsoft.com/dotnet/author/danroth27/

我同意丹·罗斯的引言。对我来说,在 Web 开发中有两个主要选择:

  • 对于使用现代 Web 开发的网站或 Web 服务:选择 Blazor 作为 Web 用户界面,并使用 Minimal APIs 作为 Web 服务。这些技术在本书中及其配套书籍《使用.NET 8 构建应用程序和服务》中有详细说明。

  • 对于使用成熟和经过验证的 Web 开发的网站或 Web 服务:选择基于控制器的 ASP.NET Core MVC 用于 Web 用户界面,Web API 用于 Web 服务。为了获得更高的生产力,你可以在这些之上添加第三方平台,例如,一个像 Umbraco 这样的 .NET CMS。这些技术在我的新书 Real-World Web Development with .NET 9 中有所介绍。

在这些选择中,ASP.NET Core 的许多部分都是共享的,所以你只需要学习这些共享组件一次,如图 12.1 所示:

图 12.1:基于现代或控制器和共享的 ASP.NET Core 组件

ASP.NET Core 中使用的文件类型比较

总结这些技术使用的文件类型是有用的,因为它们相似但不同。如果你不理解一些微妙但重要的差异,在尝试实现自己的项目时可能会造成很多困惑。请注意 表 12.1 中的差异:

技术 特殊文件名 文件扩展名 指令
Razor 组件(Blazor) .razor
Razor 组件(Blazor 与页面路由) .razor @page "<path>"
Razor 组件导入(Blazor) _Imports .razor
Razor 页面 .cshtml @page
Razor 视图(MVC) .cshtml
Razor 布局 .cshtml
Razor 视图启动 _ViewStart .cshtml
Razor 视图导入 _ViewImports .cshtml

表 12.1:ASP.NET Core 中使用的文件类型比较

指令如 @page 被添加到文件内容的顶部。

如果一个文件没有特殊文件名,那么它可以被命名为任何东西。例如,你可能会为 Blazor 项目创建一个名为 Customer.razor 的 Razor 组件,或者你可能会为 MVC 或 Razor Pages 项目创建一个名为 _MobileLayout.cshtml 的 Razor 布局。

共享 Razor 文件的命名约定,例如布局和部分视图,是在文件名前加上下划线 _。例如,_ViewStart.cshtml_Layout.cshtml_Product.cshtml(这可能是一个用于渲染产品的部分视图)。

一个像 _MyCustomLayout.cshtml 这样的 Razor 布局文件与一个 Razor 视图相同。使文件成为布局的是将其设置为另一个 Razor 文件的 Layout 属性,如下面的代码所示:

@{
  Layout = "_MyCustomLayout"; // File extension is not needed.
} 

警告! 请注意在文件顶部使用正确的文件扩展名和指令;否则,你将得到意外的行为。

使用内容管理系统构建网站

大多数网站都有大量的内容,如果每次需要更改某些内容时都需要开发者介入,那么这不会很好地扩展。

内容管理系统CMS)使开发者能够定义内容结构和模板,以提供一致性和良好的设计,同时使非技术内容所有者轻松管理实际内容。他们可以创建新页面或内容块,并更新现有内容,知道它将为访客提供极小的努力。

所有 Web 平台都提供了多种 CMS,如用于 PHP 的 WordPress 或用于 Python 的 Django。支持现代.NET 的 CMS 包括 Optimizely Content Cloud、Umbraco、Piranha 和 Orchard Core。

使用 CMS 的关键好处是它提供了一个友好的内容管理用户界面。内容所有者登录到网站并自行管理内容。然后使用 ASP.NET Core MVC 控制器和视图,或通过称为无头 CMS的 Web 服务端点,将内容渲染并返回给访客,以将内容提供给作为移动或桌面应用程序、店内触摸点或使用 JavaScript 框架或 Blazor 构建的客户端。

本书不涵盖.NET CMS,因此我在 GitHub 存储库中包含了链接,您可以在其中了解更多关于它们的信息:github.com/markjprice/cs13net9/blob/main/docs/book-links.md#net-content-management-systems。我还在我新书《Real-World Web Development with .NET 9》中涵盖了 Umbraco CMS。

使用 SPA 框架构建 Web 应用程序

Web 应用程序通常使用被称为单页应用程序SPA)框架的技术构建,例如 Blazor、Angular、React、Vue 或专有的 JavaScript 库。

当需要更多数据时,它们可以向后端 Web 服务发出请求,并使用常见的序列化格式,如 XML 和 JSON,发布更新后的数据。典型的例子是 Google 的 Web 应用程序,如 Gmail、地图和文档。

在 Web 应用程序中,客户端使用 JavaScript 框架或 Blazor 来实现复杂的用户交互,但大多数重要的处理和数据访问仍然在服务器端进行,因为 Web 浏览器对本地系统资源的访问有限。

JavaScript 是弱类型且不是为复杂项目设计的,因此如今大多数 JavaScript 库都使用 TypeScript,它为 JavaScript 添加了强类型,并设计了许多现代语言特性来处理复杂实现。

.NET SDK 为基于 JavaScript 和 TypeScript 的 SPA 提供了项目模板,但我们在本书中不会花费时间学习如何构建基于 JavaScript 和 TypeScript 的 SPA。尽管这些 SPA 通常与 ASP.NET Core 作为后端一起使用,但本书的重点是 C#而不是其他语言。

总结来说,C#和.NET 可以在服务器端和客户端上使用来构建网站,如图12.2所示:

图片

图 12.2:使用 C# 和 .NET 在服务器端和客户端构建网站

构建网页和其他服务

尽管我们不会学习基于 JavaScript 和 TypeScript 的 SPA,但我们将学习如何使用 ASP.NET Core Minimal API 构建一个网络服务,然后从 Blazor 组件中调用该网络服务。

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

  • 服务: 一个统一的服务中包含客户端应用所需的所有功能。

  • 微服务: 多个服务,每个服务专注于更小的功能集。

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

在本书第一部分的开头,我们简要回顾了 C# 语言特性及其引入的版本。在本书第二部分的开头,我们简要回顾了 .NET 库特性及其引入的版本。现在,在本书的第三和最后一部分,我们将简要回顾 ASP.NET Core 特性及其引入的版本。

您可以在以下链接的 GitHub 仓库中阅读此信息:github.com/markjprice/cs13net9/blob/main/docs/ch12-features.md

为了总结 ASP.NET Core 9 的新特性,让我们以 Dan Roth 的另一段话结束本节:

“我们正在优化 WebAssembly 上的 .NET 运行时初始化方式,以便您启动更快;我们通过利用源生成进行 JSON 序列化来提高 Blazor 初始化逻辑的效率。我们还优化了处理所有 ASP.NET Core 应用中的静态网页资源的方式,以便您的文件在发布应用时作为预压缩文件。对于 API 开发者,我们提供了内置的 OpenAPI 文档生成支持。”

– Dan Roth

构建桌面和移动应用

由于本书是关于使用 C# 和 .NET 进行现代跨平台开发的,因此它不包括使用 Windows FormsWindows Presentation Foundation (WPF) 或 WinUI 3 应用构建桌面应用的内容,因为它们仅适用于 Windows。

如果您需要为 Windows 构建应用,以下链接将很有帮助:

移动应用程序平台

有两个主要的移动平台,苹果的 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 MAUI

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

.NET MAUI 通过共享用户界面组件以及业务逻辑,使开发这些应用程序变得容易。它们可以针对与控制台应用程序、网站和 Web 服务相同的 .NET API。这些应用程序将在移动设备上的 Mono 运行时和桌面设备上的 CoreCLR 运行时上执行。与正常的 .NET CoreCLR 运行时相比,Mono 运行时在移动设备上进行了更好的优化。Blazor WebAssembly 也使用 Mono 运行时,因为它像移动应用程序一样,资源受限。

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

我在我的配套书籍《使用 .NET 8 开发应用程序和服务》中介绍了 .NET MAUI,Packt 还有许多其他深入探讨 .NET MAUI 的书籍,所以如果你对学习 MAUI 严肃认真,请查看以下 Packt 书籍:

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

警告! 我自己没有尝试过任何真实世界的项目使用 Uno 或 Avalonia,因此我无法为它们中的任何一个提供基于证据的建议。我在这本书中提到它们只是为了让你了解它们。

Uno 平台

如其在网站platform.uno/上所述,Uno 是一个“快速构建单一代码库原生移动、Web、桌面和嵌入式应用程序的开源平台”

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

Uno 平台使用 Xamarin 原生平台,但不使用 Xamarin.Forms。对于 WebAssembly,Uno 使用 Mono-WASM 运行时。对于 Linux,Uno 使用 Skia 在画布上绘制用户界面。

Avalonia

如其在网站avaloniaui.net/上所述,Avalonia 是一个“从单个.NET 代码库构建美丽、跨平台应用程序的开源框架”

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

它被 JetBrains 用来现代化他们的基于 WPF 的工具,并使它们跨平台。

Avalonia 的 Visual Studio 扩展和与 Rider 的深度集成使开发更加容易和高效。

构建项目

你应该如何构建你的项目?到目前为止,我们主要构建了小型个体控制台应用程序来展示语言或库功能,偶尔会有类库和单元测试项目来支持它们。在这本书的其余部分,我们将使用不同的技术构建多个项目,这些技术协同工作以提供单一解决方案。

对于大型、复杂的项目,导航所有代码可能很困难。因此,构建项目的首要原因是为了更容易地找到组件。有一个反映应用程序或解决方案的解决方案整体名称是好的。

我们将为一家名为Northwind的虚构公司构建多个项目。我们将解决方案命名为ModernWeb,并将Northwind用作所有项目名称的前缀。

有许多方法可以构建和命名项目和解决方案,例如,使用文件夹层次结构和命名约定。如果你在一个团队中工作,确保你知道你的团队是如何做的。

在解决方案中构建项目结构

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

由于你可能需要同时运行多个网络项目,并且它们将托管在本地网络服务器上,我们需要通过为它们的端点分配不同的端口号来区分每个项目,无论是 HTTP 还是 HTTPS。

常用的本地端口号码为 HTTP 的 5000 和 HTTPS 的 5001。我们将使用 5<chapter>0 作为 HTTP 的编号约定,5<chapter>1 作为 HTTPS 的编号约定。例如,对于我们在 第十三章 中创建的网站项目,我们将分配 5130 给 HTTP,5131 给 HTTPS。

因此,我们将使用以下项目名称和端口号,如表 12.2 所示:

名称 端口 描述
Northwind.Common N/A 用于跨多个项目的常见类型(如接口、枚举、类、记录和结构体)的类库项目。
Northwind.EntityModels N/A 用于常见 EF Core 实体模型的类库项目。实体模型通常在服务器端和客户端都使用,因此最好将特定数据库提供程序的依赖项分开。
Northwind.DataContext N/A 用于 EF Core 数据库上下文的类库项目,具有对特定数据库提供程序的依赖。
Northwind.UnitTests N/A 用于解决方案的 xUnit 测试项目。
Northwind.Web http 5130https 5131 一个用于简单网站(混合使用静态 HTML 文件和 Blazor 静态 服务器端渲染SSR))的 ASP.NET Core 项目。
Northwind.Blazor http 5140https 5141 一个 ASP.NET Core Blazor 项目。
Northwind.WebApi http 5150https 5151 一个用于 Web API(即 HTTP 服务)的 ASP.NET Core 项目。它是与网站集成的良好选择,因为它可以使用任何 JavaScript 库或 Blazor 与服务交互。

表 12.2:各种项目类型的示例项目名称

中央包管理

在本书的所有先前项目中,如果我们需要引用 NuGet 包,我们直接在项目文件中包含对包名称和版本的引用。

中央包管理(CPM)是一个简化解决方案内多个项目之间 NuGet 包版本管理的功能。这对于包含许多项目的大型解决方案尤其有用,在这些解决方案中,单独管理包版本可能会变得繁琐且容易出错。

CPM 的关键功能和优势包括:

  • 集中控制:CPM 允许您在单个文件中定义包版本,通常是 Directory.Packages.props 文件,该文件位于您解决方案的根目录中。此文件集中了您解决方案中所有项目使用的所有 NuGet 包的版本信息。

  • 一致性:它确保多个项目之间包版本的一致性。通过拥有包版本的单一真实来源,CPM 消除了不同项目指定相同包的不同版本时可能出现的差异。

  • 简化更新:在大型解决方案中更新包版本变得简单直接。您只需在中央文件中更新版本,所有引用该包的项目将自动使用更新后的版本。这显著降低了维护开销。

  • 减少冗余:它消除了在单个项目文件(.csproj)中指定包版本的需求。这使得项目文件更干净,更容易管理,因为它们不再包含重复的版本信息。

让我们为本书其余章节中将要使用的一个解决方案设置 CPM:

  1. cs13net9 文件夹中,创建一个名为 ModernWeb 的新文件夹。

  2. ModernWeb 文件夹中,创建一个名为 Directory.Packages.props 的新文件。

  3. Directory.Packages.props 中,修改其内容,如下面的标记所示:

    <Project>
      <PropertyGroup>
        <ManagePackageVersionsCentrally>true</Man
    agePackageVersionsCentrally>
      </PropertyGroup>
      <ItemGroup Label="For EF Core 9." >
        <PackageVersion
          Include="Microsoft.EntityFrameworkCore.Sqlite"
          Version="9.0.0" />
        <PackageVersion
          Include="Microsoft.EntityFrameworkCore.Design"
          Version="9.0.0" />
      </ItemGroup>
      <ItemGroup Label="For unit testing.">
        <PackageVersion Include="coverlet.collector"
          Version="6.0.2" />
        <PackageVersion Include="Microsoft.NET.Test.Sdk"
          Version="17.12.0" />
        <PackageVersion Include="xunit"
          Version="2.9.0" />
        <PackageVersion
          Include="xunit.runner.visualstudio"
          Version="3.0.0" />
      </ItemGroup>
      <ItemGroup Label="For Blazor.">
        <PackageVersion Include=
          "Microsoft.AspNetCore.Components.WebAssembly.Server"
          Version="9.0.0" />
        <PackageVersion Include=
          "Microsoft.AspNetCore.Components.WebAssembly"
          Version="9.0.0" />
        <PackageVersion Include=
    "Microsoft.AspNetCore.Components.WebAssembly.DevServer"
          Version="9.0.0" />
      </ItemGroup>
      <ItemGroup Label="For web services.">
        <PackageVersion
          Include="Microsoft.AspNetCore.OpenApi"
          Version="9.0.0" />
        <PackageVersion
          Include="Microsoft.Extensions.Caching.Hybrid"
          Version="9.0.0" />
      </ItemGroup>
    </Project> 
    

警告! <ManagePackageVersionsCentrally> 元素及其 true 值必须全部位于一行上。此外,您不能使用在单个项目中可以使用的浮点通配符版本号,如 10.0-*,以在预览期间自动获取 EF Core 10 的最新补丁版本。

对于我们添加到包含此文件的文件夹下的任何项目,我们可以引用这些包,而无需明确指定版本,如下面的标记所示:

<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup> 

您应该定期审查和更新 Directory.Packages.props 文件中的包版本,以确保您使用的是最新的稳定版本,其中包含重要的错误修复和性能改进。

我建议你在日历中为每个月的第二个星期三设置一个事件。这将在每个月第二个星期二之后发生,即补丁星期二,届时微软会发布 .NET 和相关包的错误修复和补丁。

例如,在 2024 年中旬,可能会有新版本,因此你可以访问所有包的 NuGet 页面,并在必要时更新版本,如下面的标记所示:

<ItemGroup>
  <PackageVersion
    Include="Microsoft.EntityFrameworkCore.Sqlite"
    Version="9.0.1" />
  <PackageVersion
    Include="Microsoft.EntityFrameworkCore.Design"
    Version="9.0.1" />
</ItemGroup> 

在更新包版本之前,检查包的发布说明中是否有任何破坏性更改。更新后彻底测试您的解决方案以确保兼容性。

教育您的团队,并记录 Directory.Packages.props 文件的目的和用法,以确保每个人都了解如何集中管理包版本。

您可以通过在 <PackageReference /> 元素上使用 VersionOverride 属性来覆盖单个包版本,如下面的标记所示:

<ItemGroup>
  <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
  <PackageReference Include="Microsoft.EntityFrameworkCore.Design"
    VersionOverride="9.0.0" />
</ItemGroup> 

这在引入回归错误的新版本中可能很有用。

更多信息:您可以在以下链接中了解更多关于 CPM 的信息:learn.microsoft.com/en-us/nuget/consume-packages/central-package-management

为本书其余部分构建实体模型

网站和 Web 服务通常需要与关系数据库或其他数据存储中的数据进行交互。在本节中,我们将为存储在 SQL Server 或 SQLite 中的 Northwind 数据库定义一个实体数据模型。它将用于我们在后续章节中创建的大多数应用程序。

创建 Northwind 数据库

创建 Northwind 数据库用于 SQLite 和 SQL Server 的脚本文件不同。SQL Server 的脚本创建 13 个表以及相关的视图和存储过程。SQLite 的脚本是一个简化版本,仅创建 10 个表,因为 SQLite 不支持那么多功能。本书中的主要项目只需要这 10 个表,因此你可以使用任一数据库完成本书中的所有任务。

SQL 脚本可以在以下链接找到:github.com/markjprice/cs13net9/tree/main/scripts/sql-scripts

如下列表所述,有多种 SQL 脚本可供选择:

  • Northwind4Sqlite.sql 脚本:在本地 Windows、macOS 或 Linux 计算机上使用 SQLite。这个脚本可能也可以用于其他 SQL 系统,如 PostgreSQL 或 MySQL,但尚未测试过这些系统的使用!

  • Northwind4SqlServer.sql 脚本:在本地 Windows 计算机上使用 SQL Server。该脚本检查 Northwind 数据库是否已存在,如果数据库已存在,则在重新创建之前将其删除(即删除)。

  • Northwind4AzureSqlDatabaseCloud.sql 脚本:在 Azure 云中创建的 Azure SQL 数据库资源上使用 SQL Server。只要这些资源存在,就会产生费用!该脚本不会删除或创建 Northwind 数据库,因为您应该使用 Azure 门户用户界面手动创建 Northwind 数据库。

  • Northwind4AzureSqlEdgeDocker.sql 脚本:在 Docker 中的本地计算机上使用 SQL Server。该脚本创建 Northwind 数据库。如果数据库已存在,则不会删除它,因为 Docker 容器应该始终为空,因为每次都会启动一个新的容器。

第十章使用 Entity Framework Core 处理数据 中可以找到安装 SQLite 的说明。在该章中,你还可以找到安装 dotnet-ef 工具的说明,你将使用它从现有数据库生成实体模型。

在本地 Windows 计算机上安装 SQL Server Developer Edition(免费版)的说明可以在本书的 GitHub 仓库中找到,链接如下:github.com/markjprice/cs13net9/blob/main/docs/sql-server/README.md

在 Docker for Windows、macOS 或 Linux 上设置 Azure SQL Edge 的说明可以在本书的 GitHub 仓库中找到,链接如下:github.com/markjprice/cs13net9/blob/main/docs/sql-server/sql-edge.md

使用 SQLite 创建实体模型类库

你现在将在类库中定义实体数据模型,以便它们可以在其他类型的项目中重用,包括客户端应用程序模型。

良好实践:您应该为您的实体数据模型从数据上下文类库中创建一个单独的类库项目。这允许在后台 Web 服务器和前端桌面、移动和 Blazor 客户端之间更容易地共享实体模型,并且只有后台需要引用数据上下文类库。

我们将使用 EF Core 命令行工具自动生成一些实体模型:

  1. 使用您首选的代码编辑器创建一个新的项目和解决方案,如下面的列表所示:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:Northwind.EntityModels.Sqlite

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

  2. Northwind.EntityModels.Sqlite项目中,添加对 SQLite 数据库提供程序和 EF Core 设计时支持的包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Sqlite" />
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Design">
        <PrivateAssets>all</PrivateAssets>
        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      </PackageReference>
    </ItemGroup> 
    
  3. 删除Class1.cs文件。

  4. 构建项目Northwind.EntityModels.Sqlite以恢复包。

  5. Northwind4Sqlite.sql文件复制到ModernWeb解决方案文件夹中(不是项目文件夹!)。

  6. ModernWeb文件夹中的命令提示符或终端中,输入一个命令来创建 SQLite 的Northwind.db文件,如下面的命令所示:

    sqlite3 Northwind.db -init Northwind4SQLite.sql 
    

请耐心等待,因为这个命令可能需要一段时间来创建数据库结构。

  1. 要退出 SQLite 命令模式,在 Windows 上请按两次Ctrl + C,在 macOS 或 Linux 上请按Cmd + D

  2. ModernWeb文件夹中的命令提示符或终端中,输入一个命令来列出当前目录中的文件,如下面的命令所示:

    dir 
    
  3. 您应该看到已创建一个名为Northwind.db的新文件,如下面的输出所示:

     Directory: C:\cs13net9\ModernWeb
    Length Name
    ------ ----
           Northwind.EntityModels.Sqlite
       382 Directory.Packages.props
      1193 ModernWeb.sln
    557056 Northwind.db
    480790 Northwind4SQLite.sql 
    
  4. 切换到项目文件夹:

    cd Northwind.EntityModels.Sqlite 
    
  5. Northwind.EntityModels.Sqlite项目文件夹中(包含.csproj项目文件的文件夹),为所有表生成实体类模型,如下面的命令所示:

    dotnet ef dbcontext scaffold "Data Source=../Northwind.db" Microsoft.EntityFrameworkCore.Sqlite --namespace Northwind.EntityModels --data-annotations 
    

注意以下事项:

  • 执行的命令:dbcontext scaffold

  • 连接字符串指的是解决方案文件夹中的数据库文件,位于当前项目文件夹的上一个文件夹中:"数据源=../Northwind.db"

  • 数据库提供程序:Microsoft.EntityFrameworkCore.Sqlite

  • 命名空间:--namespace Northwind.EntityModels

  • 要使用数据注释以及 Fluent API:--data-annotations

    警告! dotnet-ef命令必须在一行中输入,并且在一个包含项目的文件夹中;否则,您将看到以下错误:"未找到项目。更改当前工作目录或使用--project 选项。"请记住,所有命令行都可以在以下链接中找到并复制:github.com/markjprice/cs13net9/blob/main/docs/command-lines.md

    如果您使用 SQLite,您将看到有关实体类模型中的表列和属性之间不兼容类型映射的警告。例如,Employees 表上的列 'BirthDate' 应映射到类型为 'DateOnly' 的属性,但其值处于不兼容的格式。使用不同的类型`。这是由于 SQLite 使用动态类型。我们将在下一节中修复这些问题。

使用 SQLite 创建数据库上下文类库

您现在将定义一个数据库上下文类库:

  1. 根据以下列表添加一个新的项目到解决方案中:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:Northwind.DataContext.Sqlite

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

  2. Northwind.DataContext.Sqlite 项目中,静态和全局导入 Console 类,添加对 SQLite EF Core 数据提供程序的包引用,并添加对 Northwind.EntityModels.Sqlite 项目的项目引用,如下所示:

    <ItemGroup Label="To simplify use of WriteLine.">
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup Label="Versions are set at solution-level.">
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Sqlite" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..\Northwind.EntityModels.Sqlite
    \Northwind.EntityModels.Sqlite.csproj" />
    </ItemGroup> 
    

警告!项目引用的路径在项目文件中不应有换行符。

  1. Northwind.DataContext.Sqlite 项目中,删除 Class1.cs 文件。

  2. 构建北 wind.DataContext.Sqlite 项目以还原包。

  3. Northwind.DataContext.Sqlite 项目中,添加一个名为 NorthwindContextLogger.cs 的类。

  4. 修改其内容以定义一个名为 WriteLine 的静态方法,该方法将字符串追加到桌面上的 book-logs 文件夹中名为 northwindlog-<date_time>.txt 的文本文件的末尾,如下所示:

    using static System.Environment;
    namespace Northwind.EntityModels;
    public class NorthwindContextLogger
    {
      public static void WriteLine(string message)
      {
        string folder = Path.Combine(GetFolderPath(
          SpecialFolder.DesktopDirectory), "book-logs");
        if (!Directory.Exists(folder))
          Directory.CreateDirectory(folder);
        string dateTimeStamp = DateTime.Now.ToString(
          "yyyyMMdd_HHmmss");
        string path = Path.Combine(folder,
          $"northwindlog-{dateTimeStamp}.txt");
        StreamWriter textFile = File.AppendText(path);
        textFile.WriteLine(message);
        textFile.Close();
      }
    } 
    
  5. NorthwindContext.cs 文件从 Northwind.EntityModels.Sqlite 项目/文件夹移动到 Northwind.DataContext.Sqlite 项目/文件夹。

在 Visual Studio 解决方案资源管理器中,如果您在项目之间拖放文件,它将被复制。如果您在拖放时按住 Shift 键,它将被移动。在 VS Code 资源管理器中,如果您在项目之间拖放文件,它将被移动。如果您在拖放时按住 Ctrl 键,它将被复制。

  1. NorthwindContext.cs 文件中,注意第二个构造函数可以接受 options 作为参数,这允许我们在任何项目中覆盖默认的数据库连接字符串,例如需要与 Northwind 数据库一起工作的网站,如下所示:

    public NorthwindContext(DbContextOptions<NorthwindContext> options)
      : base(options)
    {
    } 
    
  2. NorthwindContext.cs 文件中的 OnConfiguring 方法中,移除关于连接字符串的编译器 #warning,然后添加语句以检查当前目录的末尾,以便在 Visual Studio 中运行时与使用 VS Code 的命令提示符相比,如下所示:

    protected override void OnConfiguring(
      DbContextOptionsBuilder optionsBuilder)
    {
      if (!optionsBuilder.IsConfigured)
      {
        string database = "Northwind.db";
        string dir = Environment.CurrentDirectory;
        string path = string.Empty;
        if (dir.EndsWith("net9.0"))
        {
          // In the <project>\bin\<Debug|Release>\net9.0 directory.
          path = Path.Combine("..", "..", "..", "..", database);
        }
        else
        {
          // In the <project> directory.
          path = Path.Combine("..", database);
        }
        path = Path.GetFullPath(path); // Convert to absolute path.
        try
        {
          NorthwindContextLogger.WriteLine($"Database path: {path}");
        }
        catch (Exception ex)
        {
          WriteLine(ex.Message);
        }
        if (!File.Exists(path))
        {
          throw new FileNotFoundException(
            message: $"{path} not found.", fileName: path);
        }
        optionsBuilder.UseSqlite($"Data Source={path}");
        optionsBuilder.LogTo(NorthwindContextLogger.WriteLine,
          new[] { Microsoft.EntityFrameworkCore
            .Diagnostics.RelationalEventId.CommandExecuting });
      }
    } 
    

抛出异常是很重要的,因为如果数据库文件缺失,那么 SQLite 数据库提供者将创建一个空数据库文件,因此如果你测试连接到它,它将正常工作。但是如果你查询它,你将看到与缺失表相关的异常,因为它没有任何表!在将相对路径转换为绝对路径后,你可以在调试时设置断点,以便更容易地看到数据库文件预期所在的位置,或者添加一条记录该路径的语句。

自定义模型和定义扩展方法

现在,我们将简化 OnModelCreating 方法。我将简要解释各个步骤,然后展示完整的最终方法。你可以尝试执行各个步骤,或者直接使用最终方法代码:

  1. OnModelCreating 方法中,删除所有调用 ValueGeneratedNever 方法的 Fluent API 语句,如下代码所示。这将配置主键属性,如 CategoryId,以从不自动生成值:

    modelBuilder.Entity<Category>(entity =>
    {
      entity.Property(e => e. CategoryId).ValueGeneratedNever();
    }); 
    

如果我们不删除上述类似配置的语句,那么当我们添加新的供应商时,CategoryId 的值将始终是 0,我们只能添加一个具有该值的供应商;所有其他尝试都将抛出异常。你可以将你的 NorthwindContext.cs 与以下链接中的 GitHub 仓库中的版本进行比较:github.com/markjprice/cs13net9/blob/main/code/ModernWeb/Northwind.DataContext.Sqlite/NorthwindContext.cs

  1. OnModelCreating 方法中,对于 Product 实体,告诉 SQLite 将 UnitPricedecimal 转换为 double,如下代码所示:

    entity.Property(product => product.UnitPrice)
      .HasConversion<double>(); 
    
  2. OnModelCreating 方法中,对于 Order 实体,将十进制值 0.0M 传递给 HasDefaultValue 方法,如下代码所示:

    modelBuilder.Entity<Order>(entity =>
    {
      entity.Property(e => e.Freight).HasDefaultValue(0.0M);
    }); 
    
  3. OnModelCreating 方法中,对于 Product 实体,将十进制值 0.0M 传递给 HasDefaultValue 方法,如下代码所示:

    modelBuilder.Entity<Product>(entity =>
    {
      ...
      entity.Property(e => e.UnitPrice).HasDefaultValue(0.0M); 
    
  4. 如下代码所示,OnModelCreating 方法现在应该更简单:

    protected override void OnModelCreating(
      ModelBuilder modelBuilder)
    {
      modelBuilder.Entity<Order>(entity =>
      {
        entity.Property(e => e.Freight).HasDefaultValue(0.0M);
      });
      modelBuilder.Entity<OrderDetail>(entity =>
      {
        entity.Property(e =>
          e.Quantity).HasDefaultValue((short)1);
        entity.HasOne(d => d.Order)
          .WithMany(p => p.OrderDetails)
          .OnDelete(DeleteBehavior.ClientSetNull);
        entity.HasOne(d => d.Product)
          .WithMany(p => p.OrderDetails)
          .OnDelete(DeleteBehavior.ClientSetNull);
      });
      modelBuilder.Entity<Product>(entity =>
      {
        entity.Property(e => e.Discontinued)
          .HasDefaultValue((short)0);
        entity.Property(e => e.ReorderLevel)
          .HasDefaultValue((short)0);
        entity.Property(e => e.UnitPrice)
          .HasDefaultValue(0.0M);
        entity.Property(e => e.UnitsInStock)
          .HasDefaultValue((short)0);
        entity.Property(e => e.UnitsOnOrder)
          .HasDefaultValue((short)0);
        entity.Property(product => product.UnitPrice)
          .HasConversion<double>();
      });
      OnModelCreatingPartial(modelBuilder);
    } 
    
  5. Northwind.DataContext.Sqlite 项目中,添加一个名为 NorthwindContextExtensions.cs 的类。修改其内容以定义一个扩展方法,将 Northwind 数据库上下文添加到依赖服务集合中,如下代码所示:

    using Microsoft.EntityFrameworkCore; // To use UseSqlite.
    using Microsoft.Extensions.DependencyInjection; // To use IServiceCollection.
    namespace Northwind.EntityModels;
    public static class NorthwindContextExtensions
    {
      /// <summary>
      /// Adds NorthwindContext to the specified IServiceCollection. Uses the Sqlite database provider.
      /// </summary>
      /// <param name="services">The service collection.</param>
      /// <param name="relativePath">Default is ".."</param>
      /// <param name="databaseName">Default is "Northwind.db"</param>
      /// <returns>An IServiceCollection that can be used to add more services.</returns>
      public static IServiceCollection AddNorthwindContext(
        this IServiceCollection services, // The type to extend.
        string relativePath = "..",
        string databaseName = "Northwind.db")
      {
        string path = Path.Combine(relativePath, databaseName);
        path = Path.GetFullPath(path);
        NorthwindContextLogger.WriteLine($"Database path: {path}");
        if (!File.Exists(path))
        {
          throw new FileNotFoundException(
            message: $"{path} not found.", fileName: path);
        }
        services.AddDbContext<NorthwindContext>(options =>
        {
          // Data Source is the modern equivalent of Filename.
          options.UseSqlite($"Data Source={path}");
          options.LogTo(NorthwindContextLogger.WriteLine,
            new[] { Microsoft.EntityFrameworkCore
              .Diagnostics.RelationalEventId.CommandExecuting });
        },
        // Register with a transient lifetime to avoid concurrency
        // issues in Blazor server-side projects.
        contextLifetime: ServiceLifetime.Transient,
        optionsLifetime: ServiceLifetime.Transient);
        return services;
      }
    } 
    
  6. 构建这两个类库,并修复任何编译器错误。

HasDefaultValueHasDefaultValueSql

这两个方法的区别是什么?

当你需要一个作为列默认值的常量、静态值,并且该值不依赖于任何条件或不需要在插入时动态计算时,你应该使用 HasDefaultValue()。此常量值在模型级别设置,并由 EF Core 在没有提供其他值时用于向数据库插入。

对于前面示例的等效操作,你会使用entity.Property(e => e.Freight).HasDefaultValue(0M);,因为0M使用了十进制后缀M。将其视为在客户端设置默认值。

当默认值应该在插入时由数据库计算,特别是如果它涉及到数据库应该评估的 SQL 函数或动态数据时,你应该使用HasDefaultValueSql()。默认值是一个字符串"0",因为它将被连接到 SQL 语句中,如下面的代码所示:

`CREATE TABLE "Orders" ( ... "Freight" "money" NULL CONSTRAINT "DF_Orders_Freight" DEFAULT (0), ... );` 

将其视为配置数据库以在服务器端设置默认值。

EF Core 8 及更早版本的 SQLite 数据库反向工程使用HasDefaultValueSql。EF Core 9 数据库反向工程使用HasDefaultValue

注册依赖服务的作用域

默认情况下,使用Scope生命周期注册DbContext类,这意味着多个线程可以共享同一个实例。但是DbContext不支持多线程。如果有多个线程同时尝试使用同一个NorthwindContext类实例,那么你将看到以下运行时异常被抛出:在完成之前的操作之前,在此上下文中启动了第二个操作。这通常是由不同的线程使用同一个 DbContext 实例引起的,然而实例成员不一定保证是线程安全的

这种情况发生在 Blazor 项目中,当组件被设置为在服务器端运行时,因为每当客户端发生交互时,都会向服务器发起一个 SignalR 调用,在服务器端,多个客户端之间共享单个数据库上下文实例。如果组件被设置为在客户端运行,则不会出现此问题。

使用 SQL Server 创建实体模型的类库

如果你想使用 SQL Server 而不是 SQLite,那么以下链接中有相应的说明:

github.com/markjprice/cs13net9/blob/main/docs/sql-server/README.md#chapter-12---introducing-web-development-using-aspnet-core

改进类到表的映射

dotnet-ef命令行工具为 SQL Server 和 SQLite 生成不同的代码,因为它们支持不同的功能级别,并且 SQLite 使用动态类型。例如,在 EF Core 7 中,SQLite 中的所有整数列都被映射为可空的long属性,以实现最大的灵活性。

使用 EF Core 8 及更高版本时,会检查实际存储的值,如果它们都可以存储在int中,那么 EF Core 8 及更高版本会将映射属性声明为int。如果存储的值都可以存储在short中,那么 EF Core 8 及更高版本会将映射属性声明为short

在这一版中,我们需要做更少的工作来改进映射。太好了!

作为另一个例子,SQL Server 的文本列可以限制字符数。SQLite 不支持这一点。因此,dotnet-ef将为 SQL Server 生成验证属性以确保string属性限制在指定的字符数内,但不适用于 SQLite,如下面的代码所示:

// SQLite database provider-generated code.
[Column(TypeName = "nvarchar (15)")]
public string CategoryName { get; set; } = null!;
// SQL Server database provider-generated code.
[StringLength(15)]
public string CategoryName { get; set; } = null!; 

我们将对 SQLite 的实体模型映射和验证规则进行一些小的修改。SQL Server 的类似修改可在在线说明中找到。

请记住,所有代码都可在本书的 GitHub 仓库中找到。虽然您通过亲自输入代码会学到更多,但您不必这样做。访问以下链接并按.以在浏览器中获得实时代码编辑器:github.com/markjprice/cs13net9

首先,我们将添加一个正则表达式来验证CustomerId值正好是五个大写字母。其次,我们将添加字符串长度要求来验证实体模型中的多个属性知道其文本值允许的最大长度:

  1. 激活您的代码编辑器的查找和替换功能:

    • 在 Visual Studio 中,导航到编辑 | 查找和替换 | 快速替换,然后切换使用正则表达式
  2. 查找框中输入正则表达式,如图 12.3和以下表达式所示:

    \[Column\(TypeName = "(nchar|nvarchar) \((.*)\)"\)\] 
    
  3. 替换框中,输入替换正则表达式,如下面的表达式所示:

    $0\n    [StringLength($2)] 
    

在换行符\n之后,我包含了四个空格字符,以便在我的系统中正确缩进,每级缩进使用两个空格字符。您可以插入任意多个。

  1. 查找和替换设置为在当前项目中搜索文件。

  2. 执行查找和替换以替换所有文件,如图 12.3所示:

图 12.3:在 Visual Studio 中使用正则表达式查找和替换所有匹配项

  1. 将任何日期/时间列,例如在Employee.cs中,更改为使用可空的DateTime而不是字符串,如下面的代码所示:

    // Before:
    [Column(TypeName = "datetime")]
    public string? BirthDate { get; set; }
    // After:
    [Column(TypeName = "datetime")]
    public DateTime? BirthDate { get; set; } 
    

    使用您的代码编辑器的查找功能搜索"datetime"以查找所有需要更改的属性。在Employee.cs中应有两个,在Order.cs中应有三个。

  2. 将任何money列,例如在Order.cs中,更改为使用可空的decimal而不是double,如下面的代码所示:

    // Before:
    [Column(TypeName =  "money")]
    public double? Freight { get; set; }
    // After:
    [Column(TypeName = "money")]
    public decimal? Freight { get; set; } 
    

使用您的代码编辑器的查找功能搜索"money"以查找所有需要更改的属性。在Order.cs中应有一个,在Orderdetail.cs中应有一个,在Product.cs中应有一个。

  1. Category.cs中,使CategoryName属性成为必填项,如下所示,代码中已高亮显示:

    **[****Required****]**
    [Column(TypeName = "nvarchar (15)")]
    [StringLength(15)]
    public string CategoryName { get; set; } 
    
  2. Customer.cs中,添加一个正则表达式来验证其主键CustomerId,只允许大写西文字符,并使CompanyName属性成为必填项,如下所示,代码中已高亮显示:

    [Key]
    [Column(TypeName = "nchar (5)")]
    [StringLength(5)]
    **[****RegularExpression(****"[A-Z]{5}"****)****]**
    public string CustomerId { get; set; } = null!;
    **[****Required****]**
    [Column(TypeName = "nvarchar (40)")]
    [StringLength(40)]
    public string CompanyName { get; set; } 
    
  3. Order.cs中,用正则表达式装饰CustomerId属性以强制五个大写字母。

  4. Employee.cs中,将FirstNameLastName属性设置为必需。

  5. Product.cs中,将ProductName属性设置为必需。

  6. Shipper.cs中,将CompanyName属性设置为必需。

  7. Supplier.cs中,将CompanyName属性设置为必需。

测试类库

现在,让我们构建一些单元测试以确保类库正常工作。

警告!如果你使用 SQLite 数据库提供程序,那么当你使用错误或缺失的数据库文件调用CanConnect方法时,提供程序会创建一个 0 字节的Northwind.db!这就是为什么在我们的NorthwindContext类中,我们明确检查数据库文件是否存在,并在不存在时抛出异常,以防止这种行为。

让我们编写测试:

  1. 使用你喜欢的编码工具将新的xUnit 测试项目 [C#] / xunit项目,命名为Northwind.UnitTests,添加到ModernWeb解决方案中。

  2. Northwind.UnitTests项目中,为 SQLite 或 SQL Server 添加对Northwind.DataContext项目的项目引用,如下面配置中突出显示:

    <ItemGroup>
     **<!-- change Sqlite to SqlServer** **if** **you prefer -->**
     **<ProjectReference Include=****"..\Northwind.DataContext**
    **.Sqlite\Northwind.DataContext.Sqlite.csproj"** **/>**
    </ItemGroup> 
    

警告!项目引用必须全部在一行中,不能有换行符。

  1. 如有必要,删除项目文件中指定的测试包版本号。(如果你有应该使用 CPM 的项目但指定了它们自己的包版本,而没有使用VersionOverride属性,Visual Studio 和其他代码编辑器将给出错误。)

  2. 构建项目Northwind.UnitTests以构建引用的项目。

  3. UnitTest1.cs重命名为EntityModelTests.cs

  4. 修改文件内容以定义两个测试,第一个测试连接到数据库,第二个测试确认数据库中有八个类别,如下面的代码所示:

    using Northwind.EntityModels; // To use NorthwindContext.
    namespace Northwind.UnitTests
    {
      public class EntityModelTests
      {
        [Fact]
        public void DatabaseConnectTest()
        {
          using NorthwindContext db = new();
          Assert.True(db.Database.CanConnect());
        }
        [Fact]
        public void CategoryCountTest()
        {
          using NorthwindContext db = new();
          int expected = 8;
          int actual = db.Categories.Count();
          Assert.Equal(expected, actual);
        }
        [Fact]
        public void ProductId1IsChaiTest()
        {
          using NorthwindContext db = new();
          string expected = "Chai";
          Product? product = db.Products.Find(keyValues: 1);
          string actual = product?.ProductName ?? string.Empty;
          Assert.Equal(expected, actual);
        }
      }
    } 
    
  5. 运行单元测试:

    • 如果你使用 Visual Studio,请导航到测试 | 运行所有测试,然后在测试资源管理器中查看结果。

    • 如果你使用 VS Code,在Northwind.UnitTests项目的终端窗口中,使用以下命令运行测试:dotnet test。或者,如果你已安装 C#开发工具包,可以使用测试窗口。

  6. 注意,结果应显示三个测试已运行且全部通过,如图12.4所示:

图 12.4:运行了三个成功的单元测试

如果任何测试失败,请修复问题。例如,如果你使用 SQLite,那么请检查Northwind.db文件是否位于解决方案目录中(位于项目目录之上)。检查你桌面上的book-logs文件夹中的northwindlog-<date_time>.txt文件中的数据库路径,它应该为三个测试输出三次使用的数据库路径,如下面的日志所示:

Database path: C:\cs13net9\ModernWeb\Northwind.db
Database path: C:\cs13net9\ModernWeb\Northwind.db
dbug: 18/09/2024 14:20:16.712 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
      Executing DbCommand [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"."ProductId" = @__p_0
      LIMIT 1
Database path: C:\cs13net9\ModernWeb\Northwind.db
dbug: 18/09/2024 14:20:16.832 RelationalEventId.CommandExecuting[20100] (Microsoft.EntityFrameworkCore.Database.Command)
      Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT COUNT(*)
      FROM "Categories" AS "c" 

最后,在本章中,让我们回顾一些关于 Web 开发的关键概念,以便我们为下一章深入探讨 ASP.NET Core Razor Pages 做好更好的准备。

理解 Web 开发

为 Web 开发意味着使用 超文本传输协议HTTP)进行开发,因此我们将从回顾这项重要的基础技术开始。

理解超文本传输协议

为了与 Web 服务器通信,客户端(也称为用户代理)通过网络使用 HTTP 进行调用。因此,HTTP 是 Web 的技术基础。所以当我们谈论网站和 Web 服务时,我们是指它们使用 HTTP 在客户端(通常是 Web 浏览器)和服务器之间进行通信。

客户端向由 URL 唯一标识的资源(如页面)发出 HTTP 请求,服务器随后返回 HTTP 响应,如图 12.5 所示:

图 12.5:HTTP 请求和响应

您可以使用 Google Chrome 和其他浏览器来记录请求和响应。

良好实践:目前全球大约三分之二的网站访客使用的是 Google Chrome,它拥有强大的内置开发者工具,因此它是尝试您的网站时的一个很好的首选。请使用 Chrome 和至少另外两种浏览器来测试您的网站,例如,对于 macOS 和 iPhone,分别是 Firefox 和 Safari。Microsoft Edge 在 2019 年从使用微软自己的渲染引擎切换到使用 Chromium,因此使用它进行测试的重要性较低,尽管有些人认为 Edge 拥有最好的开发者工具。如果使用 Microsoft 的 Internet Explorer,则通常是在组织内部用于内联网。

理解 URL 的组件

URL 由几个组件组成:

  • 方案http(明文)或 https(加密)。

  • 域名:对于生产网站或服务,顶级域名TLD)可能是 example.com。您可能有子域名,如 wwwjobsextranet。在开发过程中,您通常使用 localhost 来表示所有网站和服务。

  • 端口号:对于生产网站或服务,使用 80 作为 http 的端口号,以及 443 作为 https 的端口号。这些端口号通常从方案中推断出来。在开发过程中,通常使用其他端口号,例如 50005001 等,以区分使用共享域名 localhost 的网站和服务。

  • 路径:到资源的相对路径,例如,/customers/germany

  • 查询字符串:传递参数值的一种方式,例如,?country=Germany&searchtext=shoes

  • 片段:使用其 id 对网页上的元素进行引用,例如,#toc

URL 是 统一资源标识符URI)的一个子集。URL 指定了资源的位置以及如何获取它。URI 通过 URL 或 URN统一资源名称)来标识资源。

使用 Google Chrome 进行 HTTP 请求

让我们探索如何使用 Google Chrome 来进行 HTTP 请求:

  1. 启动 Google Chrome。

  2. 导航到 更多工具 | 开发者工具

  3. 点击 网络 选项卡,Chrome 应立即开始记录浏览器与任何 Web 服务器之间的网络流量(注意红色圆圈),如图 图 12.6 所示:

图 12.6:Chrome 开发者工具记录网络流量

  1. 在 Chrome 的地址框中,输入 Microsoft 学习 ASP.NET 网站的地址,该地址如下:

dotnet.microsoft.com/en-us/learn/aspnet

  1. 开发者工具 中,在记录的请求列表中滚动到顶部并点击第一个条目,即 类型document 的那一行,如图 图 12.7 所示:

图 12.7:开发者工具中记录的请求

  1. 在右侧,点击 选项卡,你将看到 请求头响应头 的详细信息,如图 图 12.8 所示:

图 12.8:请求和响应头

注意以下方面:

  • 请求方法GET。你在这里可能看到的其他 HTTP 方法包括 POSTPUTDELETEHEADPATCH

  • 状态码200 OK。这意味着服务器找到了浏览器请求的资源,并将其返回在响应体中。对 GET 请求的响应中可能看到的其他状态码包括 301 永久移动400 错误请求401 未授权404 未找到

  • 浏览器发送给 Web 服务器的 请求头 包括:

    • accept,列出了浏览器接受的格式。在这种情况下,浏览器表示它理解 HTML、XHTML、XML 和一些图像格式,但它将接受所有其他文件 (*/*)。默认权重,也称为质量值,是 1.0。XML 使用质量值 0.9 指定,因此不如 HTML 或 XHTML 更受欢迎。所有其他文件类型都赋予质量值 0.8,因此最不受欢迎。

    • accept-encoding,列出了浏览器理解的压缩算法 – 在这种情况下,GZIP、DEFLATE 和 Brotli。

    • accept-language,列出了它希望内容使用的人类语言 – 在这种情况下,美国英语,其默认质量值为 1.0;任何英语方言,其明确指定的质量值为 0.9;然后是任何瑞典方言,其明确指定的质量值为 0.8

  • 响应头 (content-encoding) 告诉我,服务器已使用 gzip 算法压缩发送回的 HTML 网页响应,因为它知道客户端可以解压缩该格式。(这在 图 12.8 中不可见,因为空间不足以展开 响应头 部分。)

  1. 关闭 Chrome。

理解客户端 Web 开发技术

在构建网站时,开发者需要了解的不仅仅是 C#和.NET。在客户端(即在网页浏览器中),你将使用以下技术的组合:

  • HTML5:用于网页的内容和结构。

  • CSS3:用于网页上元素的样式。

  • JavaScript:用于在网页上编写任何需要的业务逻辑,例如验证表单输入或调用网络服务以获取网页所需的数据。

虽然 HTML5、CSS3 和 JavaScript 是前端网站开发的基本组件,但还有许多其他技术可以使前端网站开发更加高效,包括:

  • Bootstrap,世界上最受欢迎的前端开源工具包

  • SASSLESS,用于样式的 CSS 预处理器

  • 用于编写更健壮代码的 Microsoft 的TypeScript语言

  • AngularjQueryReactVue等 JavaScript 库

所有这些高级技术最终都会转换或编译为底层三个核心技术,因此它们可以在所有现代浏览器中工作。

作为构建和部署过程的一部分,你可能会使用以下技术:

  • Node.js,一个用于服务器端开发的 JavaScript 框架

  • Node Package Managernpm)和Yarn,都是客户端包管理器

  • webpack,一个流行的模块打包器,也是用于编译、转换和打包网站源文件的工具

练习和探索

通过回答一些问题并深入探讨本章的主题来测试你的知识和理解。

练习 12.1 – 在线材料

在线材料可以是我为这本书写的额外内容,也可以是 Microsoft 或第三方创建的内容的引用。

W3Schools 是学习客户端网站开发的最佳网站之一,链接如下:www.w3schools.com/

可以在以下链接中找到关于 ASP.NET Core 9 的新功能的总结:

learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-9.0

可以在这里找到 Microsoft 官方推荐的如何选择 ASP.NET Core Web UI 的建议:

learn.microsoft.com/en-us/aspnet/core/tutorials/choose-web-ui

可以在这里找到 Microsoft 官方推荐的 ASP.NET Core 最佳实践的指南:

learn.microsoft.com/en-us/aspnet/core/fundamentals/best-practices

练习 12.2 – 实践练习

实践练习将深入探讨本章的主题。

网站开发故障排除

由于网站开发中有许多动态部分,因此出现临时问题是很常见的。有时,经典的“关闭再打开”方法可以解决这些问题!

  1. 删除项目的 binrelease 文件夹。

  2. 重新启动 Web 服务器以清除其缓存。

  3. 重新启动计算机。

练习 12.3 – 测试你的知识

回答以下问题:

  1. 微软的第一个动态服务器端执行网页技术叫什么名字,为什么今天仍然需要了解这段历史?

  2. 两个 Microsoft 网络服务器的名字是什么?

  3. 微服务和纳米服务之间有哪些区别?

  4. 什么是 Blazor?

  5. 第一个不能在 .NET Framework 上托管的 ASP.NET Core 版本是什么?

  6. 用户代理是什么?

  7. HTTP 请求-响应通信模型对 Web 开发者有什么影响?

  8. 描述 URL 的四个组成部分。

  9. 开发者工具提供了哪些功能?

  10. 三种主要的客户端 Web 开发技术是什么,它们做什么?

了解你的网络缩写

以下网络缩写代表什么,它们做什么?

  1. URI

  2. URL

  3. WCF

  4. TLD

  5. API

  6. SPA

  7. CMS

  8. Wasm

  9. SASS

  10. REST

练习 12.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-12---introducing-web-development-using-aspnet-core

摘要

在本章中,您已经:

  • 已介绍了一些可用于使用 C# 和 .NET 构建网站和 Web 服务的应用程序模型

  • 创建类库以定义用于与 Northwind 数据库一起工作的实体数据模型,使用 SQLite、SQL Server 或两者兼用

在以下章节中,您将学习如何构建以下内容的详细信息:

  • 使用静态 HTML 页面和动态生成的 Blazor 静态 SSR 页面的简单网站

  • 可以托管在 Web 服务器上、浏览器中或混合 Web 原生移动和桌面应用程序上的 Blazor 用户界面组件

  • 可以由任何可以发出 HTTP 请求的平台调用的 Web 服务,以及调用这些 Web 服务的客户端网站

在 Discord 上了解更多

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

packt.link/csharp13dotnet9

二维码图片

留下评论!

感谢您从 Packt Publishing 购买这本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花一点时间在亚马逊上留下评论;这只需一分钟,但对像您这样的读者来说意义重大。

扫描二维码或访问链接以获得您选择的免费电子书。

packt.link/NzOWQ

带有黑色方块的二维码,描述自动生成

第十三章:使用 ASP.NET Core 构建网站

本章是关于在服务器端使用现代 HTTP 架构构建网站。

ASP.NET Core 是一个由微软开发的免费、开源和跨平台的 Web 框架,用于构建现代、基于云和互联网连接的应用程序。它是先前 ASP.NET 框架的重大重构,侧重于灵活性、模块化和性能。

ASP.NET Core 提供了一个统一的编程模型,用于构建 Web 用户界面UI)和 Web API,这意味着您可以使用相同的框架进行服务器端和客户端逻辑。

ASP.NET Core 是一个由微软开发的免费、开源和跨平台的 Web 框架,用于构建现代、基于云和互联网连接的应用程序。它是先前 ASP.NET 框架的重大重构,侧重于灵活性、模块化和性能。

您将了解如何使用 ASP.NET Core 8 中引入的 Blazor 静态 服务器端渲染SSR)功能构建简单的网站。

本章涵盖了以下主题:

  • 探索 ASP.NET Core

  • Blazor 的历史

  • 探索 Blazor 静态 SSR

  • 使用 Entity Framework Core 与 ASP.NET Core

探索 ASP.NET Core

在我们探索示例 ASP.NET Core 项目之前,让我们回顾其架构。

ASP.NET Core 的架构

ASP.NET Core 的架构由几个关键组件组成:

  • 中间件:中间件组件用于处理请求和响应。它们在管道中串联在一起,其中每个中间件处理请求并将其传递给链中的下一个组件。在本章的可选在线部分,您可以学习如何自定义此管道。

  • 路由:路由组件负责将传入的请求映射到适当的端点和 Blazor 组件。

  • 依赖注入(DI)容器:这是一个内置的容器,用于管理服务对象的生命周期和依赖关系。

  • 配置:ASP.NET Core 使用一个灵活的配置系统,支持各种来源,如 JSON 文件、环境变量和命令行参数。

  • 日志记录:框架有一个日志 API,支持各种日志提供程序,允许您以一致的方式捕获和存储日志数据。

现在我们将创建一个空的 ASP.NET Core 项目,并探索如何使其能够服务简单的网页。

创建一个空的 ASP.NET Core 项目

我们将创建一个 ASP.NET Core 项目,该项目将展示来自 Northwind 数据库的供应商列表。

dotnet 工具提供了许多项目模板,这些模板为您做了很多工作,但可能很难知道哪个最适合特定情况,因此我们将从空网站项目模板开始,然后逐步添加功能,以便您了解所有组件:

  1. 使用您首选的代码编辑器打开 ModernWeb 解决方案,然后添加一个新项目,如下列所示:

    • 项目模板ASP.NET Core Empty [C#] / web。对于 Rider,选择名为ASP.NET Core Web Application的项目模板,然后将类型设置为Empty

    • 项目文件和文件夹Northwind.Web

    • 解决方案文件和文件夹ModernWeb

    • 对于 Visual Studio,保留所有其他选项的默认设置。

    例如:

    • 框架:.NET 9.0 (标准术语支持**)

    • 配置 HTTPS:已选择

    • 启用 Docker:已清除

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

    • 对于 VS Code 和 dotnet new web 命令,默认选项是我们想要的选项。在未来的项目中,如果您想从顶层语句更改为旧的 Program 类样式,请指定开关 --use-program-main

创建新项目时,Visual Studio 和 dotnet new 选项的摘要可以在以下 GitHub 仓库链接中找到:github.com/markjprice/cs13net9/blob/main/docs/ch01-project-options.md

  1. 构建 Northwind.Web 项目。

  2. Northwind.Web.csproj 中,注意项目类似于类库,除了 SDK 是 Microsoft.NET.Sdk.Web,如下所示,高亮显示的标记:

    <Project **Sdk=****"Microsoft.NET.Sdk.Web"**>
      <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
    </Project> 
    
  3. 添加一个元素以全局和静态地导入 System.Console 类,如下所示,高亮显示的标记:

    <ItemGroup Label="To simplify use of WriteLine.">
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    
  4. 如果您正在使用 Visual Studio,在 解决方案资源管理器 中,切换 显示所有文件。如果您正在使用 Rider,则将光标移至 解决方案 窗格,然后单击“眼睛”图标。

  5. 展开文件夹 obj,展开文件夹 Debug,展开文件夹 net9.0,并选择文件 Northwind.Web.GlobalUsings.g.cs。注意隐式导入的命名空间包括控制台应用程序或类库的所有命名空间,以及一些 ASP.NET Core 命名空间,例如 Microsoft.AspNetCore.Builder,如下所示,代码如下:

    // <autogenerated />
    global using global::Microsoft.AspNetCore.Builder;
    global using global::Microsoft.AspNetCore.Hosting;
    global using global::Microsoft.AspNetCore.Http;
    global using global::Microsoft.AspNetCore.Routing;
    global using global::Microsoft.Extensions.Configuration;
    global using global::Microsoft.Extensions.DependencyInjection;
    global using global::Microsoft.Extensions.Hosting;
    global using global::Microsoft.Extensions.Logging;
    global using global::System;
    global using global::System.Collections.Generic;
    global using global::System.IO;
    global using global::System.Linq;
    global using global::System.Net.Http;
    global using global::System.Net.Http.Json;
    global using global::System.Threading;
    global using global::System.Threading.Tasks;
    global using static global::System.Console; 
    
  6. 关闭文件并折叠 obj 文件夹。

  7. Northwind.Web 项目/文件夹中,展开名为 Properties 的文件夹,打开名为 launchSettings.json 的文件,并注意名为 httphttps 的配置文件。它们具有随机分配的端口号,您将在下一步中更改,因此现在只需注意它们的位置,如下所示,高亮显示的配置:

    {
      "$schema": "http://json.schemastore.org/launchsettings.json",
      "iisSettings": {
        "windowsAuthentication": false,
        "anonymousAuthentication": true,
        "iisExpress": {
          "applicationUrl": "http://localhost:14842",
          "sslPort": 44352
        }
      },
      "profiles": {
        "**http**": {
          "commandName": "Project",
          "dotnetRunMessages": true,
          "launchBrowser": true,
          **"applicationUrl"****:** **"http://localhost:5122"**,
          "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
          }
        },
        "**https**": {
          "commandName": "Project",
          "dotnetRunMessages": true,
          "launchBrowser": true,
          **"applicationUrl"****:** **"https://localhost:7155;http://localhost:5122"**,
          "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
          }
        },
        "IIS Express": {
          "commandName": "IISExpress",
          "launchBrowser": true,
          "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
          }
        }
      }
    } 
    

launchSettings.json 文件仅在开发期间使用。它对构建过程没有影响。它不会与编译的网站项目一起部署,因此对生产运行时没有影响。它仅由代码编辑器(如 Visual Studio 和 Rider)处理,以设置环境变量并定义当项目由代码编辑器启动时,网络服务器要监听的 URL。

  1. 对于 https 配置文件,将其 applicationUrl 中分配的 http 端口号更改为 5130https 端口号更改为 5131,并交换顺序,使 http 在列表中排在第一位,默认使用,如下所示,高亮显示的标记:

    "applicationUrl": "**http://localhost:5130;https://localhost:5131**", 
    

httphttps 启动配置文件具有 commandNameProject,这意味着它们使用项目中配置的 web 服务器来托管网站,默认情况下是 Kestrel。还有一个针对 IIS 的配置文件和设置,这是一个仅适用于 Windows 的 web 服务器。在这本书中,我们将只使用 Kestrel 作为 web 服务器,因为它支持跨平台。为了清理你的 launchSettings.json 文件,你甚至可以删除 iisSettingsIIS Express 部分。

  1. 将更改保存到 launchSettings.json 并关闭文件。

  2. Program.cs 中,请注意以下内容:

    • 一个 ASP.NET Core 项目就像一个顶级控制台应用程序,它有一个隐藏的 <Main>$ 方法作为其入口点,该入口点通过名称 args 传递参数。

    • 它调用 WebApplication.CreateBuilder,这使用默认的 web 服务器创建网站宿主,然后构建。

    • 网站将对所有指向网站根路径的 HTTP GET 请求做出响应,显示以下纯文本:Hello World!

    • Run 方法的调用是一个阻塞调用,因此隐藏的 <Main>$ 方法不会在 web 服务器停止运行之前返回。

Program.cs 的内容如下所示:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/", () => "Hello World!");
app.Run(); 
  1. 在现有的 MapGet 调用之后,添加两个调用以定义一个返回匿名 C# 对象的 JSON 的端点,以及一个返回网页的端点,如下面的代码所示:

    app.MapGet("/data", () => Results.Json(new
    {
      firstName = "John",
      lastName = "Doe",
      age = 30
    }));
    app.MapGet("/welcome", () => Results.Content(
      content: $"""
      <!doctype html>
      <html lang="en">
      <head>
        <title>Welcome to Northwind Web!</title>
      </head>
      <body>
        <h1>Welcome to Northwind Web!</h1>
      </body>
      </html>
      """,
      contentType: "text/html")); 
    

除了 JsonContentResults 类还有许多其他方法,如 BadRequestBytesCreatedFileNoContentNotFoundOkProblemRedirect 等。你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.results

  1. Program.cs 的底部,添加一个注释来解释 Run 方法,并在 Run 之后、因此是在 web 服务器停止后,向控制台写入一条消息,如下面的代码所示(高亮显示):

    **// Start the web server, host the website, and wait for requests.**
    app.Run(); **// This is a thread-blocking call.**
    **WriteLine(****"This executes after the web server has stopped!"****);** 
    

测试和保障网站安全

我们现在将测试 ASP.NET Core 空项目网站的功能。我们还将通过从 HTTP 切换到 HTTPS 来启用浏览器和 web 服务器之间所有流量的加密,以保障隐私。HTTPS 是 HTTP 的安全加密版本。

  • 对于 Visual Studio:

    1. 在工具栏中,确保选择了 https 配置文件(而不是 httpIIS ExpressWSL),然后将 Web Browser 更改为 Google Chrome,如图 13.1 所示:

    图 13.1:在 Visual Studio 中选择具有 Kestrel web 服务器的 https 配置文件

  1. 导航到 调试 | 不调试启动…

  2. 在 Windows 中,如果你看到一个 Windows 安全警报,显示 Windows Defender 防火墙已阻止此应用程序的一些功能,那么请点击 允许访问 按钮。

  3. 第一次启动安全网站时,您可能会收到通知,表示您的项目配置为使用 SSL,为了避免浏览器中的警告,您可以选择信任 ASP.NET Core 生成的自签名证书。点击 。当您看到 安全警告 对话框时,再次点击

  • 对于 VS Code,输入启动具有 https 配置文件的项目命令,如下所示:dotnet run --launch-profile https。然后启动 Chrome。

  • 对于 Rider:

    1. 导航到 运行 | 编辑配置…

    2. 运行/调试配置 对话框中,选择 Northwind.Web: https

    3. 在对话框底部,启动后 复选框的右侧,选择 Chrome 然后点击 确定

    4. 导航到 运行 | 运行‘Northwind.Web: https’

  1. 在 Visual Studio 的命令提示符窗口或 VS Code 的终端中,注意以下内容,如图所示输出:

    • 网络服务器已经开始监听我们分配的 HTTP 和 HTTPS 端口。

    • 您可以按 Ctrl + C 关闭 Kestrel 网络服务器。

    • 托管环境是 开发

    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5130
    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5131
    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:\cs13net9\ModernWeb\Northwind.Web 
    

Visual Studio 还会自动启动您选择的浏览器并导航到第一个 URL。如果您使用 VS Code,您将需要手动启动 Chrome。

  1. 在命令提示符或终端中保持 Kestrel 网络服务器运行。

  2. 在 Chrome 中,显示 开发者工具,并点击 网络 选项卡。

  3. 请求网站项目的首页:

    • 如果您使用 Visual Studio 并且 Chrome 自动启动并为您输入了 URL,那么请点击 重新加载此页面 按钮或按 F5

    • 如果您正在使用 VS Code 和命令提示符或终端,那么在 Chrome 地址栏中,手动输入地址 http://localhost:5130/

  4. 网络 选项卡中,点击 localhost,并注意来自跨平台 Kestrel 网络服务器的响应是纯文本中的 Hello World!,如图 13.2 所示:

图 13.2:网站项目的纯文本响应

像 Chrome 这样的浏览器可能还会请求一个 favicon.ico 文件以在浏览器窗口或标签页中显示,但这个文件在我们的项目中不存在,因此显示为 404 Not Found 错误。如果您觉得这很烦人,那么您可以在以下链接免费生成一个 favicon.ico 文件并将其放入项目文件夹:favicon.io/。在网页中,您也可以在元标签中指定一个,例如,使用 Base64 编码的一个空白标签,如下所示:

<link rel="icon" href="data:;base64,iVBORw0KGgo=">

  1. 输入地址 https://localhost:5131/ 并注意,如果你没有使用 Visual Studio 或者当被提示信任 SSL 证书时点击了 ,那么响应将是一个隐私错误。当你没有配置浏览器可以信任的证书来加密和解密 HTTPS 流量时,你会看到这个错误(如果你没有看到这个错误,那是因为你已经配置了证书)。在生产环境中,你可能希望支付像 Verisign 这样的公司以获取 SSL 证书,因为它们提供责任保护和技术支持。在开发过程中,你可以告诉你的操作系统信任 ASP.NET Core 提供的临时开发证书。

Visual Studio 也会自动启动你选择的浏览器并导航到第一个 URL。如果你使用 VS Code,你必须手动启动 Chrome。

  1. 输入地址 https://localhost:5131/data 并注意返回了一些 JSON。

  2. 输入地址 https://localhost:5131/welcome 并注意返回了一个网页。

  3. 在命令提示符或终端中,按 Ctrl+ C 关闭 web 服务器,并注意输出的消息,如下所示:

    info: Microsoft.Hosting.Lifetime[0]
          Application is shutting down...
    **This executes after the web server has stopped!**
    C:\cs13net9\ModernWeb\Northwind.Web\bin\Debug\net9.0\Northwind.Web.exe (process 19888) exited with code 0. 
    
  4. 如果你需要信任一个本地自签名 SSL 证书,那么在命令行或终端中,输入以下命令:dotnet dev-certs https --trust

  5. 注意消息 请求信任 HTTPS 开发证书。你可能需要输入密码,并且可能已经存在一个有效的 HTTPS 证书。

启用更强的安全性和重定向到安全连接

启用更严格的安全性和自动将 HTTP 请求重定向到 HTTPS 是一种良好的实践。

良好实践:一个可选但推荐的安全增强是 HTTP 严格传输安全HSTS),你应该始终启用。如果一个网站指定了它并且浏览器支持它,那么它将强制所有通信都通过 HTTPS,并防止访问者使用不受信任或无效的证书。

让我们立即这样做:

  1. Program.cs 中,在构建 app 的语句之后,添加一个区域和一个 if 语句,以在 非开发 时启用 HSTS,并将 HTTP 请求重定向到 HTTPS,如下所示:

    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    **#****region** **Configure the HTTP pipeline and routes**
    **if** **(!app.Environment.IsDevelopment())**
    **{**
     **app.UseHsts();**
    **}**
    **app.UseHttpsRedirection();**
    app.MapGet("/", () => "Hello World!");
    // Other MapGet calls.
    **#****endregion**
    // Start the web server, host the website, and wait for requests.
    app.Run(); // This is a thread-blocking call.
    WriteLine("This executes after the web server has stopped!"); 
    
  2. 使用 https 启动配置文件,不进行调试启动 Northwind.Web 网站项目。

  3. 如果 Chrome 仍在运行,请关闭并重新启动它。

  4. 在 Chrome 中,显示 开发者工具,并点击 网络 选项卡。

  5. 输入地址 http://localhost:5130/,注意服务器如何响应一个 307 临时重定向https://localhost:5131/,以及证书是有效且受信任的,如 图 13.3 所示:

图 13.3:现在使用有效的证书和 307 重定向来确保连接安全

  1. 关闭 Chrome 并关闭 web 服务器。

良好实践:记住,每次完成网站测试后,通过切换到命令提示符或终端并按Ctrl + C来关闭 Kestrel web 服务器。

控制托管环境

在 ASP.NET Core 5 及更早版本中,项目模板设置了一个规则,即在开发模式下,任何未处理的异常都会在浏览器窗口中显示,以便开发者可以看到异常的详细信息,如下面的代码所示:

if (app.Environment.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
} 

在 ASP.NET Core 6 及更高版本中,此代码默认由WebApplication构建器配置,因此会自动执行,因此不再需要在项目模板Program.cs源代码中包含它。

ASP.NET Core 是如何知道我们在开发模式下运行的,以便IsDevelopment方法返回true,并且执行额外的代码来设置开发者异常页面?让我们来了解一下。

ASP.NET Core 可以从设置文件和环境变量中读取,以确定要使用哪种托管环境,例如DOTNET_ENVIRONMENTASPNETCORE_ENVIRONMENT

你可以在本地开发期间覆盖这些设置:

  1. Northwind.Web文件夹中,展开名为Properties的文件夹,并打开名为launchSettings.json的文件。请注意,https启动配置文件将托管环境的环境变量设置为Development,如下面的配置中突出显示所示:

    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
      "launchBrowser": true,
      "applicationUrl": "https://localhost:5131;http://localhost:5130",
    **"environmentVariables"****: {**
    **"ASPNETCORE_ENVIRONMENT"****:** **"Development"**
     **}**
    }, 
    
  2. ASPNETCORE_ENVIRONMENT环境变量从Development更改为Production

  3. 如果你正在使用 Visual Studio,可选地,将launchBrowser更改为false以防止 Visual Studio 自动启动浏览器。当你使用dotnet run或 Rider 启动网站项目时,此设置将被忽略。

  4. Program.cs中,修改第一个MapGet语句,使其返回环境名称而不是Hello World,如下面的代码中突出显示所示:

    app.MapGet("/", () =>
      **$"Environment is** **{app.Environment.EnvironmentName}****"**); 
    
  5. 使用https启动配置文件启动网站项目,并注意托管环境是Production,如下面的输出所示:

    info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Production 
    
  6. 在 Chrome 中,请注意纯文本是Environment is Production

  7. 关闭 web 服务器。

  8. launchSettings.json中,将环境变量改回Development,并保存更改到文件。

更多信息:您可以在以下链接中了解更多关于环境的信息:learn.microsoft.com/en-us/aspnet/core/fundamentals/environments

启用网站以服务静态内容

只返回单个纯文本消息的网站并不很有用!

至少,它应该返回静态 HTML 页面、网页将用于样式的 CSS 以及任何其他静态资源,例如图片和视频。

按照惯例,这些文件应该存储在名为wwwroot的目录中,以将它们与网站项目的动态执行部分分开。

创建静态文件和网页的文件夹

您现在将创建一个用于您的静态网站资源的文件夹和一个使用 Bootstrap 进行样式的基本索引页面:

  1. Northwind.Web项目/文件夹中,创建一个名为wwwroot的文件夹。请注意,Visual Studio 通过为其提供一个地球图标来识别它作为一个特殊类型的文件夹,

  2. wwwroot文件夹中,添加一个名为index.html的新文件。(在 Visual Studio 中,项目项模板的名称为HTML 页面。)

  3. index.html中,修改其标记以链接到 CDN 托管的 Bootstrap 进行样式设置,并使用现代的良好实践,例如设置视口,如下面的标记所示:

    <!doctype html>
    <html lang="en">
    <head>
      <!-- Required meta tags -->
      <meta charset="utf-8" />
      <meta name="viewport" content=
        "width=device-width, initial-scale=1, shrink-to-fit=no" />
      <!-- Bootstrap CSS -->
      <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
      <title>Welcome ASP.NET Core!</title>
    </head>
    <body>
      <div class="container">
        <div class="jumbotron">
          <h1 class="display-3">Welcome to Northwind B2B</h1>
          <p class="lead">We supply products to our customers.</p>
          <hr />
          <h2>This is a static HTML page.</h2>
          <p>Our customers include restaurants, hotels, and cruise lines.</p>
          <p>
            <a class="btn btn-primary"
              href="https://www.asp.net/">Learn more</a>
          </p>
        </div>
      </div>
    </body>
    </html> 
    

    良好实践:查看最新版本,请访问以下链接:getbootstrap.com/docs/versions/。点击最新版本进入其Bootstrap 入门页面。滚动到页面底部找到第 2 步,找到最新的<link>元素(以及在本章后面的<script>元素),然后您可以复制并粘贴。

Bootstrap 是构建响应式、移动优先网站的全球最受欢迎的框架。您可以在以下链接中阅读一个仅在线部分,介绍 Bootstrap 的一些最重要的功能:github.com/markjprice/cs13net9/blob/main/docs/ch13-bootstrap.md

  1. wwwroot文件夹中,添加一个名为site.css的文件,并修改其内容,如下面的标记所示:

    h1 {
       color: darkcyan;
       font-style: italic;
    } 
    
  2. wwwroot文件夹中,添加一个名为categories.jpeg的文件。您可以从以下链接下载它:github.com/markjprice/cs13net9/blob/main/code/images/Categories/categories.jpeg

  3. wwwroot文件夹中,添加一个名为about.html的文件,并修改其内容,如下面的标记所示:

    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
      <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
      <link href="site.css" rel="stylesheet" />
      <title>About Northwind Web</title>
    </head>
    <body>
      <div class="container">
        <div class="jumbotron">
          <h1 class="display-3">About Northwind Web</h1>
          <p class="lead">We supply products to our customers.</p>
          <img src="img/categories.jpeg" />
        </div>
      </div>
    </body>
    </html> 
    

启用静态和默认文件

如果您现在启动网站并在地址框中输入http://localhost:5130/index.htmlhttps://localhost:5131/index.html,网站将返回一个404 Not Found错误,表示未找到网页。为了使网站能够返回静态文件,例如index.html,我们必须明确配置该功能。

即使您启用了存储在wwwroot中的静态文件,如果您现在启动网站并在地址框中输入http://localhost:5130/https://localhost:5131/,网站仍然会返回一个404 Not Found错误,因为如果未请求任何命名文件,则默认情况下,Web 服务器不知道要返回什么。

您现在将启用静态文件,明确配置默认文件如index.html,并更改返回纯文本响应的注册 URL 路径:

  1. Program.cs 中,在启用 HTTPS 重定向后添加语句以启用静态文件和默认文件。同时,修改将 GET 请求映射到包含环境名称的纯文本响应的语句,使其仅对 URL 路径 /env 响应,如下面代码中突出显示所示:

    **app.UseDefaultFiles();** **// index.html, default.html, and so on.**
    **app.MapStaticAssets();** **// .NET 9 or later.**
    **// app.UseStaticFiles();****// .NET 8 or earlier.**
    app.MapGet("/**env**", () =>
      $"Environment is {app.Environment.EnvironmentName}"); 
    

    ASP.NET Core 9 引入了自动压缩静态文件的 MapStaticAssets 方法,这减少了带宽需求。对于 ASP.NET Core 8 及更早版本,你必须调用 UseStaticFiles 方法。你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/aspnet/core/release-notes/aspnetcore-9.0#optimizing-static-web-asset-delivery

警告! 在调用 MapStaticAssetsUseStaticFiles 之前,必须先调用 UseDefaultFiles,否则将不会生效!你可以在本章末尾链接的在线部分了解更多关于中间件和端点路由的顺序。

  1. 启动网站。

  2. 启动 Chrome 并显示 开发者工具

  3. 在 Chrome 中输入 http://localhost:5130/。注意你被重定向到端口 5131 上的 HTTPS 地址,并且 index.html 文件现在通过该安全连接返回,因为它是此网站的可能默认文件之一,并且是在 wwwroot 文件夹中找到的第一个匹配项。

警告! 如果你仍然看到环境名称为纯文本,请确保你将相对路径 /env 映射到该端点,而不是仅仅映射到 /,否则这将覆盖对 index.html 文件的默认请求。

  1. 在 Chrome 中输入 http://localhost:5131/about.html 并注意网页被返回,并且它请求 site.css 文件,该文件应用了额外的样式。

  2. 开发者工具 中,注意对 Bootstrap 样式表的请求。

  3. 在 Chrome 中输入 http://localhost:5130/env 并注意它返回与之前相同的纯文本环境名称。

  4. 关闭 Chrome 并关闭 web 服务器。

理解 MapStaticAssets

在 .NET 9 中引入的 MapStaticAssets 通过整合构建和发布时过程来收集应用程序中所有静态资源的数据。然后,运行时库使用这些数据高效地向浏览器提供这些文件。

虽然 MapStaticAssets 通常可以直接替换 UseStaticFiles,但它专门针对在构建和发布时已知的应用程序资源进行优化。对于从其他位置(如磁盘或嵌入式资源)提供的资源,仍然应使用 UseStaticFiles

UseStaticFiles 相比,MapStaticAssets 提供以下好处:

  • 对应用中所有资源进行构建时压缩:

    • 在开发期间使用 gzip,在发布期间使用 gzip + brotli

    • 所有资源都经过压缩,目的是将资源的大小减少到最小。

  • 每个资源的 ETags 是内容 SHA-256 哈希的 Base64 编码字符串。这确保了浏览器只有在文件内容发生变化时才会重新下载文件。

例如,表 13.1展示了使用 Fluent UI Blazor 组件库的原始和压缩大小,总共 478 KB 未压缩到 84 KB 压缩。

文件 原始 压缩 % 减少
fluent.js 384 73 80.99%
fluent.css 94 11 88.30%
总计 478 84 82.43%

表 13.1:MapStaticAssets 如何压缩 Fluent UI Blazor 组件

如果所有网页都是静态的(即,它们只能通过网页编辑器手动更改),那么我们的网站编程工作就完成了。但几乎所有的网站都需要动态内容,这意味着网页是在运行时通过执行代码生成的。

做这件事的最简单方法是使用 ASP.NET Core 的一个名为 Blazor staticSSR 的功能。但在那之前,让我们了解一下为什么您可能会在开发者工具等工具中看到额外的请求,而这些请求并不是您预期的。

开发期间理解浏览器请求

开发者工具中,我们可以看到浏览器发出的所有请求。其中一些将是您预期的请求,例如:

  • localhost: 这是网站项目中主页的请求。对于我们的当前项目,地址将是http://localhost:5130/https://localhost:5131/

  • bootstrap.min.css: 这是 Bootstrap 样式的请求。我们在主页上添加了对这个的引用,因此浏览器随后请求了这个样式表。

一些请求仅在开发期间进行,并由您使用的代码编辑器决定。如果您在开发者工具中看到它们,通常可以忽略它们。例如:

  • browserLinkaspnetcore-browser-refresh.js:这些是 Visual Studio 为连接浏览器进行调试和热重载而发出的请求。例如,https://localhost:5131/_vs/browserLinkhttps://localhost:5131/_framework/aspnetcore-browser-refresh.js

  • negotiate?requestUrlconnect?transportabort?Transport等:这些都是用于将 Visual Studio 与浏览器连接的额外请求。

  • Northwind.Web/: 这是一个与 SignalR 相关的安全 WebSockets 请求,用于将 Visual Studio 与浏览器连接:wss://localhost:44396/Northwind.Web/

现在您已经看到了如何设置一个支持静态文件(如 HTML 网页和 CSS)的基本网站,让我们通过添加对动态生成静态网页的支持(一旦网页到达浏览器客户端,它就是静态的)来让它更有趣。

ASP.NET Core 有多个技术用于动态生成静态网页,包括 Razor Pages 和模型-视图-控制器MVC)Razor 视图。最现代的动态网页技术是 Blazor 静态 SSR 页面。但让我们首先回顾 Blazor 及其创建的原因。

Blazor 的历史

Blazor 允许您使用 C# 而不是 JavaScript 来构建交互式 Web UI 组件。Blazor 支持所有现代浏览器。

JavaScript 及相关技术

传统上,任何需要在网络浏览器中执行的字节码都必须使用 JavaScript 编程语言或更高层次的技术编写,这些技术可以将代码转换(转换或编译)为 JavaScript。这是因为所有浏览器都支持 JavaScript 超过二十年,因此它是实现客户端业务逻辑的最低共同标准。

虽然 JavaScript 与 C# 和 Java 等类似 C 风格的语言在表面上有些相似,但一旦深入挖掘,它实际上非常不同。它是一种动态类型伪函数式语言,使用原型而不是类继承来实现对象重用。它可能看起来很人性化,但当你发现它其实是一个 Skrull 时,你可能会感到惊讶。

如果我们能在浏览器中使用与服务器上相同的语言和库,那就太好了。

即使 Blazor 也无法完全取代 JavaScript。例如,浏览器的一些部分只能通过 JavaScript 访问。Blazor 提供了一个互操作服务,以便您的 C# 代码可以调用 JavaScript 代码,反之亦然。您将在第十四章“使用 Blazor 构建交互式 Web 组件”的在线仅有的 与 JavaScript 互操作 部分中看到这一点。

Silverlight – 使用插件进行 C# 和 .NET 开发

微软曾尝试通过名为 Silverlight 的技术来实现这一目标。当 2008 年发布 Silverlight 2 时,C# 和 .NET 开发者可以使用他们的技能来构建由 Silverlight 插件在 Web 浏览器中执行的库和可视化组件。

到 2011 年和 Silverlight 5 时,苹果在 iPhone 上的成功以及史蒂夫·乔布斯对像 Flash 这样的浏览器插件的反感最终导致微软放弃了 Silverlight,因为像 Flash 一样,Silverlight 被禁止在 iPhone 和 iPad 上运行。

WebAssembly – Blazor 的目标

网络浏览器的发展又给了微软一次尝试的机会。2017 年,WebAssembly 协议完成,现在所有主流浏览器都支持它:Chromium(Chrome、Edge、Opera 和 Brave)、Firefox 以及 WebKit(Safari)。

WebAssemblyWasm)是一种虚拟机的二进制指令格式,它提供了一种在网络上以接近原生速度运行多种语言编写代码的方式。Wasm 被设计为 C# 等高级语言的编译目标。

.NET 7 及更早版本中的 Blazor 托管模型

Blazor 是一个单一的编程或应用模型。对于 .NET 7 及更早版本,开发者必须为每个项目选择一个托管模型:

  • Blazor Server 项目在服务器端运行,因此 C# 代码可以完全访问业务逻辑可能需要的所有资源,而无需提供凭证进行身份验证。它使用 SignalR 与客户端通信 UI 更新。服务器必须保持与每个客户端的实时 SignalR 连接,并跟踪每个客户端的当前状态。这意味着如果需要支持大量客户端,Blazor Server 的扩展性不佳。它首次作为 ASP.NET Core 3 的一部分于 2019 年 9 月发布。

  • Blazor Wasm 项目在客户端运行,因此 C# 代码只能访问浏览器中的资源。在它能够访问服务器上的资源之前,它必须进行 HTTP 调用(可能需要身份验证)。它首次作为 ASP.NET Core 3.1 的扩展于 2020 年 5 月发布,并标记为 3.2,因为它是一个当前版本,因此不受 ASP.NET Core 3.1 长期支持的影响。Blazor Wasm 3.2 版本使用了 Mono 运行时和 Mono 库。.NET 5 及以后的版本使用 Mono 运行时和 .NET 库。

  • .NET MAUI Blazor 应用,也称为 Blazor 混合应用,项目使用本地互操作通道将其 Web UI 渲染到 Web 视图控件,并在 .NET MAUI 应用中托管。从概念上讲,它类似于 Electron 应用。

.NET 8 中 Blazor 托管模型的统一

在 .NET 8 中,Blazor 团队创建了一个统一的托管模型,其中每个组件都可以设置为使用不同的渲染模型:

  • SSR(服务器端渲染):像 Razor Pages 和 MVC 一样在服务器端执行代码。然后将完整的响应发送到浏览器以显示给访客,直到浏览器发起新的 HTTP 请求之前,服务器和客户端之间没有进一步的交互。从浏览器的角度来看,网页就像任何其他 HTML 文件一样是静态的。

  • 流式渲染:在服务器端执行代码。HTML 标记可以返回并在浏览器中显示,并且在连接仍然打开的情况下,任何异步操作都可以继续执行。当所有异步操作完成后,服务器通过发送最终标记来更新页面内容。这改善了访客的体验,因为他们可以在等待其余内容时看到一些内容,如“正在加载...”消息。

  • 交互式服务器渲染:在实时交互期间在服务器端执行代码,这意味着代码可以完全且容易地访问服务器端资源,如数据库。这可以简化功能的实现。交互式请求使用 SignalR 进行,比完整请求更高效。浏览器和服务器之间需要一个永久连接,这限制了可扩展性。这对于客户端数量有限且网络带宽高的内网网站来说是一个不错的选择。

  • 交互式 Wasm 渲染:在客户端执行代码,这意味着代码只能访问浏览器内的资源。这可能会使实现复杂化,因为每当需要新数据时,必须对服务器进行回调。对于可能存在大量客户端且部分客户端带宽较低的公共网站来说,这是一个不错的选择。

  • 交互式自动渲染:首先在服务器上渲染以实现更快的初始显示,然后在后台下载 Wasm 组件,然后切换到 Wasm 以实现后续的交互性。

这种统一模型意味着,经过仔细规划,开发者可以编写一次 Blazor 组件,然后选择在 web 服务器端、web 客户端或动态切换上运行。这提供了所有最佳方案。

理解 Blazor 组件

重要的是要理解 Blazor 用于创建 UI 组件。组件定义了如何渲染 UI 以及如何响应用户事件,并且可以组合、嵌套并编译成一个 Razor 类库以进行打包和分发。

例如,为了在电子商务网站上提供产品星级评级的用户界面,您可能创建一个名为 Rating.razor 的组件,如下面的标记所示:

<div>
@for (int i = 0; i < Maximum; i++)
{
  if (i < Value)
  {
    <span class="oi oi-star-filled" />
  }
  else
  {
    <span class="oi oi-star-empty" />
  }
}
</div>
@code {
  [Parameter]
  public byte Maximum { get; set; }
  [Parameter]
  public byte Value { get; set; }
} 

然后,您可以在网页上使用该组件,如下面的标记所示:

<h1>Review</h1>
<Rating id="rating" Maximum="5" Value="3" />
<textarea id="comment" /> 

创建组件实例的标记看起来像 HTML 标签,其中标签的名称是组件类型。组件可以通过元素嵌入到网页中,例如 <Rating Value="5" />,或者它们可以被路由到,就像一个映射的端点。

与包含标记和 @code 块的单个文件不同,代码可以存储在名为 Rating.razor.cs 的单独的后台代码文件中。该文件中的类必须是 partial 的,并且与组件具有相同的名称。

有许多内置的 Blazor 组件,包括用于设置网页 <head> 部分中的 <title> 等元素的组件,以及许多第三方供应商会出售用于常见目的的组件。

Blazor 和 Razor 之间的区别是什么?

您可能会想知道为什么 Blazor 组件使用 .razor 作为它们的文件扩展名。Razor 是一种模板标记语法,允许混合 HTML 和 C#。支持 Razor 语法的旧技术使用 .cshtml 文件扩展名来表示 C# 和 HTML 的混合。

Razor 语法用于:

  • ASP.NET Core MVC 视图部分视图使用 .cshtml 文件扩展名。业务逻辑被分离到一个控制器类中,该类将视图作为模板来推送视图模型,然后将其输出到网页。

  • 使用 .cshtml 文件扩展名的 Razor Pages。业务逻辑可以嵌入或分离到一个使用 .cshtml.cs 文件扩展名的文件中。输出是一个网页。

  • Blazor 组件 使用 .razor 文件扩展名。输出作为网页的一部分渲染,尽管可以使用布局将组件包装起来,使其输出为网页,并且可以使用 @page 指令来分配一个路线,该路线定义了检索组件的 URL 路径。

现在您已经了解了 Blazor 的背景知识,让我们看看一些更实用的内容:如何将 Blazor 支持添加到现有的 ASP.NET Core 项目中。

探索 Blazor 静态 SSR

ASP.NET Core Blazor 静态 SSR 允许开发人员轻松地将 C# 代码语句与 HTML 标记混合,以使生成的网页动态化。

启用 Blazor 静态 SSR

这里是您必须完成的任务摘要,以在现有的 ASP.NET Core 项目中启用 Blazor 和其静态 SSR 功能:

  1. 创建一个 Components 文件夹来存放您的 Blazor 组件。

  2. 创建一个 Components\Pages 文件夹来存放您的 Blazor 页面组件。

  3. Components 文件夹中创建三个 .razor 文件:

    • _Imports.razor:此文件导入所有 .razor 文件所需的命名空间,这样您就不需要在每个 .razor 文件的顶部导入它们。至少,您将想要导入 Blazor 路由和您本地项目的 Blazor 组件的命名空间。

    • App.razor:此文件包含网页的 HTML,其中将包含所有您的 Blazor 组件。它还需要在网页的 <body> 中某处引用您的 Blazor Routes 组件。

    • Routes.razor:此文件定义了一个 <Router> 组件,该组件扫描当前程序集以查找页面组件及其注册的路线。

  4. Components\Pages 目录下创建一个 Index.razor 文件,这是一个 Blazor 页面组件,它将由 Blazor 路由器默认显示为您的首页。它需要在文件顶部添加一个指令来定义根路径的路线:@page "/"

  5. Program.cs 中,您必须调用 AddRazorComponents() 来将 Blazor(即 Razor 组件,*.razor)文件注册到 ASP.NET Core 的依赖项服务集合中,然后调用 MapRazorComponents<App>() 来映射所有找到的 Blazor 路由的端点。您还必须调用 UseAntiforgery(),因为 Blazor 组件会自动检查反伪造令牌;因此,HTTP 管道必须启用中间件以支持它们。

您现在将添加并启用 Blazor 静态 SSR 服务,然后复制并更改静态 HTML 页面为 Blazor 静态 SSR 文件:

  1. Northwind.Web 项目文件夹中创建一个名为 Components 的文件夹。

  2. Components 文件夹中创建一个名为 Pages 的文件夹。

  3. Components 文件夹中创建一个名为 _Imports.razor 的文件。

  4. _Imports.razor 中添加语句以导入 Blazor 组件路由的命名空间,以及您的 Northwind.Web 项目及其组件,如下面的标记所示:

    @using Microsoft.AspNetCore.Components.Routing
    @using Northwind.Web
    @using Northwind.Web.Components 
    
  5. Components 文件夹中创建一个名为 Routes.razor 的文件。

  6. Routes.razor 中,添加定义路由器的语句,该路由器扫描当前程序集以查找已注册路由的 Blazor 页面组件,如下面的标记所示:

    <Router AppAssembly="typeof(Program).Assembly">
      <Found Context="routeData">
        <RouteView RouteData="routeData" />
      </Found>
    </Router> 
    
  7. index.html 文件复制到 Components\Pages 文件夹中。(在 Visual Studio 或 Rider 中,按住 Ctrl 键同时拖放。)

  8. 对于 Components\Pages 文件夹中的文件(不是原始文件),将文件扩展名从 index.html 更改为 Index.razor。确保“I”是大写的。(Blazor 组件必须以大写字母开头,否则你会得到编译器错误!)

  9. Components 文件夹中,创建一个名为 App.razor 的文件。

  10. App.razor 中,剪切并粘贴来自 Index.razor 的根标记,包括 head 和 body 元素,然后添加使用 <HeadOutlet /> 插入页面标题的元素,以及为你的 <Routes> 组件添加元素,如下面的标记所示:

    <!doctype html>
    <html lang="en">
    <head>
      <!-- Required meta tags -->
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
      <!-- Bootstrap CSS -->
      <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
      **<****HeadOutlet** **/>**
    </head>
    <body>
    **<****Routes** **/>**
    </body>
    </html> 
    
  11. Index.razor 中,将 @page 指令添加到文件顶部,并将其路由设置为 /,添加一个 <PageTitle> 组件来设置网页 <title>,删除说这是一个静态 HTML 页面的 <h2> 元素,并注意结果,如下面的标记所示:

    **@page "/"**
    <PageTitle>Welcome to Northwind B2B</PageTitle>
    <div class="container">
      <div class="jumbotron">
        <h1 class="display-3">Welcome to Northwind B2B</h1>
        <p class="lead">We supply products to our customers.</p>
        <hr />
        <p>Our customers include restaurants, hotels, and cruise lines.</p>
        <p>
          <a class="btn btn-primary"
             href="https://www.asp.net/">Learn more</a>
        </p>
      </div>
    </div> 
    

警告! 如果你为 Visual Studio 安装了 ReSharper,或者你使用 Rider,那么它们可能会在你的 Razor 页面、Razor 视图和 Blazor 组件中给出类似“无法解析符号”的警告。这并不总是意味着存在实际的问题。如果文件可以编译,那么你可以忽略它们的错误。有时这些工具会困惑并无谓地让开发者担忧。

  1. Program.cs 中,在创建 builder 的语句之后,添加一个添加 ASP.NET Core Blazor 组件及其相关服务的语句,并可选地定义一个 #region,如下面的代码所示:

    **#****region** **Configure the web server host and services.**
    var builder = WebApplication.CreateBuilder(args);
    **builder.Services.AddRazorComponents();**
    var app = builder.Build();
    **#****endregion** 
    
  2. Program.cs 中,在调用使用 HTTPS 重定向的方法之后,添加一个使用反伪造中间件的语句,如下面的代码所示:

    app.UseHttpsRedirection();
    **app.UseAntiforgery()****;** 
    
  3. Program.cs 文件的顶部,添加一个导入你的项目组件的语句,如下面的代码所示:

    **using Northwind.Web.Components; // To use App.** 
    
  4. Program.cs 中,在映射路径 /env 的 HTTP GET 请求的语句之前,添加一个调用 MapRazorComponents 方法的语句,如下面的代码所示:

    **app.MapRazorComponents<App>();**
    app.MapGet("/env", () =>
      $"Environment is {app.Environment.EnvironmentName}"); 
    
  5. 使用 https 启动配置文件启动网站项目。

  6. 在 Chrome 中,输入 https://localhost:5131/ 并注意说这是一个静态 HTML 页面的元素已经消失。如果它仍然存在,那么你可能需要清空浏览器缓存。查看 开发者工具,点击并按住 重新加载此页面 按钮,然后选择 清空缓存并强制重新加载,如图 13.4 所示:

图 13.4:查看开发者工具,然后点击并按住重新加载此页面按钮以查看更多命令

向 Blazor 静态 SSR 页面添加代码

在网页的 HTML 标记中,Razor 语法由 @ 符号表示。Blazor 静态 SSR 页面可以描述如下:

  • Blazor 静态 SSR 页面需要在文件顶部使用 @page 指令并指定一个字符串值作为路由,例如 "/""/customers"

警告! Blazor 静态 SSR 页面Razor PagesRazor 视图(用于 ASP.NET Core MVC)不同,但它们共享相同的 Razor 语法。Blazor 组件具有 .razor 文件扩展名。Razor Pages 必须有 @page 指令。Razor 视图 必须使用 @page 指令。

  • Blazor 静态 SSR 页面可以可选地包含一个 @code 部分,用于定义存储数据值的属性,就像在类定义中一样。然后你可以在 HTML 中获取属性值。

  • Blazor 静态 SSR 页面的标记可以使用 @**@ 进行注释,如下面的代码所示:@* 这是一个注释。 *@

现在我们使用 @code 块向 Blazor 静态 SSR 页面添加一些动态内容:

  1. Components/Pages 文件夹中的 Index.cshtml 文件中,在 @page 指令之后,添加一个 @code 语句块来定义一个属性,用于存储当前日期的 string 值,如下面的代码所示:

    @page "/"
    @code
    {
      public string? DayName { get; set; }
        = DateTime.Now.ToString("dddd");
    } 
    
  2. 在第二个 HTML 段落 <p> 中,渲染日期名称,如下面的标记所示:

    <p>**It's @DayName!** Our customers include restaurants, hotels, and cruise lines.</p> 
    
  3. 使用 https 配置文件启动网站项目。

  4. 在 Chrome 中,如果需要,输入 https://localhost:5131/,并注意页面输出了当前日期名称,如图 13.5 所示:

图 13.5:欢迎来到 Northwind 页面,显示当前日期

  1. 在 Chrome 中,输入 https://localhost:5131/index.html,这正好匹配静态文件名,并注意它返回了之前的静态 HTML 页面。

  2. 在 Chrome 中,输入 https://localhost:5131/env,这正好匹配返回纯文本的端点路由,并注意它返回了之前的纯文本。

  3. 关闭 Chrome 并关闭 web 服务器。

使用 Blazor 静态 SSR 页面与共享布局

大多数网站都有多个页面。当前在 Index.razor 中网页主体的样板标记是最小的。通常,在主体中会有一些常见的标记需要在每个页面上可见。如果每个页面都需要重复这些标记,那么管理起来就会变得很麻烦。因此,ASP.NET Core 有一个名为 布局 的功能。

要使用布局,我们必须创建一个 Razor 文件来定义所有 Blazor 组件的默认布局,并在 Routes 组件中指定它。按照惯例,我们将其存储在 Layout 文件夹中。这个文件的名称可以是任何东西,因为我们将会指定它,但 MainLayout.razor 是一个好习惯。

通常,你的主要布局将包括一个导航菜单,其中包含链接到所有其他 Blazor 页面组件。这个文件的命名惯例是 NavMenu.razor

让我们看看布局的实际应用:

  1. Components 文件夹中,添加一个名为 Layout 的新文件夹。

  2. Layout 文件夹中,添加一个名为 NavMenu.razor 的文件。

  3. NavMenu.razor 中修改内容,如下面的标记所示:

    <nav class="navbar navbar-expand-lg bg-body-tertiary">
      <div class="container-fluid">
        <a class="navbar-brand" href="#">Northwind Web</a>
        <button class="navbar-toggler" type="button"
                data-bs-toggle="collapse"
                data-bs-target="#navbarNavAltMarkup"
                aria-controls="navbarNavAltMarkup"
                aria-expanded="false"
                aria-label="Toggle navigation">
          <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse"
             id="navbarNavAltMarkup">
          <div class="navbar-nav">
            <NavLink class="nav-link" href="/">
              Home
            </NavLink>
            <NavLink class="nav-link" href="suppliers">
              Suppliers
            </NavLink>
          </div>
        </div>
      </div>
    </nav> 
    

更多信息:此标记使用 Bootstrap 的 navbar 功能,您可以在以下链接中了解更多信息:getbootstrap.com/docs/5.3/components/navbar/

  1. Layout 文件夹中,添加一个名为 MainLayout.razor 的文件。

  2. MainLayout.razor 中修改内容,如下所示:

    @inherits LayoutComponentBase
    <div class="page">
      <div class="sidebar">
        <NavMenu />
      </div>
      <main>
        <article class="content px-4">
          @Body
        </article>
      </main>
      <!-- JavaScript to enable features like carousel -->
      <script src="img/bootstrap.bundle.min.js" integrity="sha384-geWF76RCwLtnZ8qwWowPQNguL3RmwHVBC9FhGdlKrxdiJJigb/j/68SIy3Te4Bkz" crossorigin="anonymous"></script>
    </div> 
    

在审查前面的标记时,请注意以下内容:

  • @Body 标记了请求的 Blazor 页面组件的插入点。

  • 在布局的底部有一个脚本,用于实现一些我们以后可以使用的 Bootstrap 的酷炫功能,例如图片轮播。

  1. Routes.razor 中,添加一个属性来设置默认布局的类名,如下所示:

    <Router AppAssembly="typeof(Program).Assembly">
      <Found Context="routeData">
        <RouteView RouteData="routeData"
          **DefaultLayout****=****"typeof(Layout.MainLayout)"** />
      </Found>
    </Router> 
    
  2. 使用 https 启动配置文件启动网站。

  3. 使用 Chrome 访问它,并注意现在每个页面的顶部都有一个导航菜单,如图 图 13.6 所示:

图 13.6:共享布局组件中的导航菜单

警告! 点击供应商的导航菜单将导致 404 Not Found 错误,因为我们还没有创建那个 Blazor 页面组件。

  1. 关闭 Chrome 并关闭 web 服务器。

创建供应商页面

现在将创建一个显示供应商列表的 Blazor 静态 SSR 页面组件。在下一个主题中,我们将从数据库中加载供应商列表,但现在是使用硬编码的 string 值数组来模拟:

  1. Pages 文件夹中,添加一个名为 Suppliers.razor 的新 Blazor 页面组件。如果您使用 Visual Studio 或 Rider,则项目项模板名为 Razor Component

  2. Suppliers.razor 中,用以下标记替换现有内容以渲染包含供应商公司名称的 HTML 表格:

    @page "/suppliers"
    @code {
      public IEnumerable<string>? Companies { get; set; }
        = new[] { "Alpha Co", "Beta Limited", "Gamma Corp" };
    }
    <div class="row">
      <h1 class="display-2">Suppliers</h1>
      <table class="table">
        <thead class="thead-inverse">
          <tr>
            <th>Company Name</th>
          </tr>
        </thead>
        <tbody>
        @if (Companies is not null)
        {
          @foreach(string name in Companies)
          {
            <tr>
              <td>@name</td>
            </tr>
          }
        }
        </tbody>
      </table>
    </div> 
    

在审查前面的标记时,请注意以下内容:

  • Suppliers 属性使用从 string 值数组中的一些示例供应商公司名称填充。稍后,我们将从 Northwind 数据库中填充它。

  • 页面输出一个具有 Bootstrap 样式的 HTML 表格。

  • 页面使用 Razor 语法 @if@for 语句将 C# 代码嵌入到 HTML 中。

  • 如果 Suppliers 属性不是 null,则表中的数据行是通过遍历该属性生成的。

  1. 使用 https 启动配置文件启动网站,并使用 Chrome 访问它。

  2. 点击按钮了解更多关于供应商的信息,并注意供应商表,如图 图 13.7 所示:

图 13.7:从字符串数组加载的供应商表

配置 ASP.NET Core 项目中包含的文件

到目前为止,我们的大多数项目都是简单的控制台应用程序和具有几个 C# 类文件的类库。默认情况下,当我们编译这些项目时,项目文件夹或子文件夹中的所有 .cs 文件都会在编译时自动包含在构建中。

ASP.NET Core 项目变得更加复杂。有更多文件类型;其中一些可以在运行时而不是在编译时编译,还有一些只是内容,不需要编译但需要与编译后的程序集一起部署。

你可以通过在项目文件中放置元素来控制文件在构建过程中的处理方式,以及哪些文件被包含或排除在部署之外。这些在构建和部署期间由MS Build和其他工具处理。

你在项目文件中将项目项声明为<ItemGroup>元素的子元素。例如:

<--Include the greet.proto file in the build process.-->
<ItemGroup>
  <Protobuf Include="Protos\greet.proto" GrpcServices="Server" />
</ItemGroup>
<--Remove the stylecop.json file from the build process.-->
<ItemGroup>
  <None Remove="stylecop.json" />
</ItemGroup>
<--Include the stylecop.json file in the deployment.-->
<ItemGroup>
  <AdditionalFiles Include="stylecop.json" />
</ItemGroup> 

你可以拥有任意多的<ItemGroup>元素,因此使用它们按类型逻辑划分元素是良好的实践。它们会被构建工具自动合并。

通常,当你知道你需要使用它们时,你会手动添加这些元素,但不幸的是,Visual Studio 和其他代码编辑器有时会试图提供帮助,结果却弄糟了事情。

在一种场景中,你可能在Components\Pages文件夹中添加了一个名为Customers.razor的新 Blazor 组件。你启动了 Web 服务器,但页面没有出现。或者,你正在处理一个 GraphQL 服务,并添加了一个名为seafoodProducts.graphql的文件,但当你运行 GraphQL 工具来自动生成客户端代理时,它失败了。

这都是你的代码编辑器决定新文件不应成为项目一部分的常见迹象。它已自动向项目文件添加了一个元素,以在不通知你的情况下从构建过程中删除该文件。

要解决此类问题,请检查项目文件中如下的意外条目,并删除它们:

<ItemGroup>
  <Content Remove="Components\Pages\Customers.razor" />
</ItemGroup>
<ItemGroup>
  <GraphQL Remove="seafoodProducts.graphql" />
</ItemGroup> 

良好实践:当使用自动“修复”问题而不通知你的工具时,当出现意外结果时,请检查你的项目文件以查找意外元素。

更多信息:你可以在以下链接中了解更多关于管理 MS Build 项目项的信息:learn.microsoft.com/en-us/visualstudio/msbuild/msbuild-items

项目文件构建操作

正如我们刚才看到的,对于 ASP.NET Core 开发者来说,了解项目构建操作如何影响编译非常重要。

.NET SDK 项目中的所有文件都有一个构建操作。大多数都是基于它们的文件扩展名隐式设置的。你可以通过显式设置构建操作来覆盖默认行为。你可以直接编辑.csproj项目文件或使用你的代码编辑器的属性窗口来完成此操作。

ASP.NET Core 项目文件的常见构建操作如表 13.2所示:

构建操作 描述
编译C#编译器 作为源文件传递给编译器。
内容 在部署网站时作为网站的一部分包含。
嵌入式资源 作为资源传递给编译器以嵌入到程序集。
None 不属于构建的一部分。此值可用于文档和其他不应与网站一起部署的文件。

表 13.2:ASP.NET Core 项目文件的常见构建操作

更多信息:你可以在以下链接中了解更多关于构建操作.csproj条目的信息:learn.microsoft.com/en-us/visualstudio/ide/build-actions

使用 Entity Framework Core 与 ASP.NET Core

Entity Framework Core 是将真实数据引入网站的一种自然方式。在第十二章中,使用.NET 进行现代 Web 开发的介绍中,你创建了一对类库(一个用于实体模型,一个用于Northwind数据库上下文)使用 SQLite 作为数据提供程序(或 SQL Server)。你现在将在你的网站项目中使用它们。

配置 Entity Framework Core 作为服务

ASP.NET Core 项目需要的功能,例如 Entity Framework Core 数据库上下文,应在网站启动时注册为依赖服务。GitHub 仓库解决方案中的代码使用 SQLite,但如果你更喜欢,可以轻松地使用 SQL Server。

让我们看看如何:

  1. Northwind.Web项目中,添加对Northwind.DataContext项目的项目引用,无论是 SQLite 还是 SQL Server,如下面的标记所示:

    <!-- Change Sqlite to SqlServer if you prefer. -->
    <ItemGroup>
      <ProjectReference Include="..\Northwind.DataContext.Sqlite\
    Northwind.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    

警告!项目引用必须全部在一行中,不能有换行。

  1. 构建Northwind.Web项目。

  2. Program.cs中,导入用于处理你的实体模型类型的命名空间,如下面的代码所示:

    using Northwind.EntityModels; // To use AddNorthwindContext method. 
    
  3. Program.cs中,在添加 Razor 组件到已注册服务的语句之后,添加一个语句来注册Northwind数据库上下文类,如下面的代码所示:

    builder.Services.AddNorthwindContext(); 
    
  4. Components文件夹中,在_Imports.razor中导入数据库上下文的命名空间,如下面的代码所示:

    @using Northwind.EntityModels @* To use NorthwindContext. *@ 
    
  5. Components\Pages文件夹中,在Suppliers.razor中注入 Northwind 上下文服务的实例,如下面的代码所示:

    @inject NorthwindContext _db 
    
  6. Companies属性改为声明为Supplier对象的序列,而不是string值,如下面的代码高亮所示:

    public IEnumerable<**Supplier**>? Companies { get; set; } 
    
  7. 定义一个OnInitialized方法,使用来自 Northwind 供应商表的数据设置Companies属性,如下面的代码所示:

    protected override void OnInitialized()
    {
      Companies = _db.Suppliers
        .OrderBy(c => c.Country)
        .ThenBy(c => c.CompanyName);
    } 
    
  8. 修改标记以渲染每个供应商的多个列,如下面的代码高亮所示:

     <table class="table">
        <thead class="thead-inverse">
          <tr>
            <th>Company Name</th>
     **<th>Country</th>**
     **<th>Phone</th>**
          </tr>
        </thead>
        <tbody>
        @if (Model.Suppliers is not null)
        {
     **@foreach(Supplier s** **in** **Companies)**
     **{**
            <tr>
              <td>**@s.CompanyName**</td>
     **<td>@s.Country</td>**
     **<td>@s.Phone</td>**
            </tr>
     **}**
        }
        </tbody>
      </table>
    </div> 
    
  9. 使用https启动配置文件启动网站并转到网站主页。

  10. 点击了解更多关于我们的供应商,并注意现在供应商表是从数据库加载的,并且数据首先按国家排序,然后按公司名称排序,如图13.8所示:

图 13.8:从 Northwind 数据库加载的供应商表

在下一章中,我们将实现一个具有交互性的 Blazor 网站,这样我们就可以轻松地修改数据以及显示它。

练习和探索

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

练习 13.1 – 在线材料

在线材料可以是微软或第三方创建的材料,或者本书的额外内容。

配置服务和 HTTP 请求管道

本在线章节详细介绍了服务和 HTTP 请求管道的工作原理:

github.com/markjprice/cs13net9/blob/main/docs/ch13-http-pipeline.md

启用 HTTP/3 和请求解压缩支持

HTTP/3 为所有互联网连接的应用程序带来了好处,尤其是移动应用。我编写了一个仅在线的章节,介绍了 HTTP/3 并展示了如何在针对 .NET 7 或更高版本的 Northwind.Web ASP.NET Core 项目中启用它。

在 .NET 8 的预览版中,HTTP/3 默认启用,但微软团队决定恢复默认禁用 HTTP/3。他们这样做是因为一些杀毒软件带来的不良体验。希望在未来 ASP.NET Core 9 中,他们能够解决这个问题并默认重新启用 HTTP/3。您可以在以下链接中了解更多关于他们决定的信息:

devblogs.microsoft.com/dotnet/asp-net-core-updates-in-dotnet-8-rc-1/#http-3-disabled-by-default

页面还包括一个关于启用请求解压缩支持的章节。您可以通过以下链接阅读页面:

github.com/markjprice/cs13net9/blob/main/docs/ch13-enabling-http3.md

介绍 Bootstrap

Bootstrap 是构建响应式、移动优先网站的世界最流行框架。您可以在以下链接中阅读一个仅在线的章节,介绍 Bootstrap 的一些最重要的功能:

github.com/markjprice/cs13net9/blob/main/docs/ch13-bootstrap.md

练习 13.2 – 实践练习

实践练习深入探讨了本章的主题。

构建数据驱动的网页

Northwind.Web 网站添加一个 Razor 页面,使用户能够查看按国家分组的客户列表。当用户点击客户记录时,他们应该看到显示该客户完整联系信息和订单列表的页面。

我建议的解决方案可以在以下链接中找到:

为函数构建网页

将前面章节中的一些控制台应用程序作为 Razor Pages 重新实现;例如,从 第四章,编写、调试和测试函数,提供一个输出乘法表、计算税费和生成阶乘与斐波那契序列的 Web UI。

我的建议解决方案可以在以下链接中找到:

练习 13.3 – 测试你的知识

回答以下问题:

  1. 列出六个可以在 HTTP 请求中指定的方法名。

  2. 列出六个可以在 HTTP 响应中返回的状态码及其描述。

  3. 在 ASP.NET Core 中,Program 类有什么用途?

  4. HSTS 这个缩写代表什么?它有什么作用?

  5. 如何为网站启用静态 HTML 页面?

  6. 你如何将 C# 代码混合到 HTML 中以创建动态页面?

  7. 你如何为 Blazor 组件定义共享布局?

  8. 在 Blazor 项目中,你通常在 Components 文件夹中创建哪三个文件,它们分别做什么?

  9. 你如何配置 Entity Framework Core 数据上下文以用于 ASP.NET Core 网站?

  10. 你必须完成哪些任务才能在现有的 ASP.NET Core Empty 项目中启用 Blazor 及其静态 SSR 功能?

练习 13.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-13---building-websites-using-aspnet-core

摘要

在本章中,你学习了:

  • 关于使用 HTTP 进行 Web 开发的基础

  • 如何构建一个返回静态文件的简单网站?

  • 如何使用 Entity Framework Core 与 ASP.NET Core 结合来创建从数据库信息动态生成的网页

在下一章中,你将学习如何使用 Blazor 构建交互式 UI。

第十四章:使用 Blazor 构建 Interactive Web 组件

本章是关于使用 Blazor 构建交互式 Web 用户界面组件。你将学习如何构建可以在 Web 服务器或 Web 浏览器中执行其 C# 和 .NET 代码的 Blazor 组件。

当组件在服务器上执行时,Blazor 使用 SignalR 与浏览器中的用户界面进行必要的更新通信。

当组件在浏览器中使用 WebAssembly 执行时,它们必须进行 HTTP 调用来与服务器上的数据进行交互。你将在第十五章“构建和消费 Web 服务”中了解更多关于此内容。

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

  • 检查 Blazor Web App 项目模板

  • 使用 Blazor 构建组件

  • 使用 EditForm 组件定义表单

检查 Blazor Web App 项目模板

在 .NET 8 之前,对于不同的托管模型有单独的项目模板,例如,Blazor 服务器应用Blazor WebAssembly 应用Blazor WebAssembly 应用(空)。.NET 8 引入了一个统一的项目模板,命名为Blazor Web App,以及一个仅客户端的项目模板,重命名为Blazor WebAssembly 独立应用。除非你必须使用较旧的 .NET SDK,否则请避免使用其他遗留项目模板。

创建 Blazor Web App 项目

让我们看看 Blazor Web App 项目的默认模板。大多数情况下,你会发现它与 ASP.NET Core Empty 模板相同,只是增加了一些关键功能:

  1. 使用你喜欢的代码编辑器打开 ModernWeb 解决方案,然后添加一个新项目,如下所示:

    • 项目模板:Blazor Web App/ blazor --interactivity Auto

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

    • 项目文件和文件夹:Northwind.Blazor

    • 身份验证类型:无

    • 配置 HTTPS:已选择

    • 交互式渲染模式:自动(服务器和 WebAssembly)

    • 交互性位置:按页面/组件

    • 包含示例页面:已选择

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

如果你正在使用 VS Code 或 Rider,请在命令提示符或终端中输入以下命令:dotnet new blazor --interactivity Auto -o Northwind.Blazor

良好实践:默认的交互式渲染模式是服务器。我们明确选择了自动,以便在这个项目中可以看到两种渲染模式。我们还选择了包含示例页面,但你可能不会在真实世界的项目中想要这些页面。

  1. 注意创建了两个项目:

    • Northwind.Blazor:这是主要的 ASP.NET Core 项目,它定义并运行静态 SSR、流式传输和服务器端 Blazor 组件。它还引用并托管你的客户端 WebAssembly Blazor 组件。

    • Northwind.Blazor.Client:这是一个用于你定义的任何客户端组件的 Blazor WebAssembly 项目。在未来,它可能不需要在单独的项目中,但对于 .NET 8 和 .NET 9,它仍然需要。

  2. ModernWeb 文件夹中,在 Directory.Packages.props 中,添加一个 <ItemGroup> 以设置服务器端托管和定义 Blazor WebAssembly 包的版本号,如下所示:

    <ItemGroup Label="For Blazor.">
      <PackageVersion Include=
        "Microsoft.AspNetCore.Components.WebAssembly.Server"
        Version="9.0.0" />
      <PackageVersion Include=
        "Microsoft.AspNetCore.Components.WebAssembly"
        Version="9.0.0" />
    </ItemGroup> 
    
  3. Northwind.Blazor.csproj 中,请注意它与使用 Web SDK 并针对 .NET 9 的 ASP.NET Core 项目相同。注意,它还引用了客户端项目。

  4. Northwind.Blazor.csproj 中,删除允许此项目托管 WebAssembly 组件的 Microsoft.AspNetCore.Components.WebAssembly.Server 包的版本号,如下所示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <ItemGroup>
        <ProjectReference Include="..\Northwind.Blazor.Client\Northwind.Blazor.Client.csproj" />
        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" />
      </ItemGroup>
    </Project> 
    
  5. Northwind.Blazor.Client.csproj 中,请注意它与 ASP.NET Core 项目类似,但使用的是 Blazor WebAssembly SDK。

  6. Northwind.Blazor.Client.csproj 中,删除允许此项目定义 WebAssembly 组件的 Microsoft.AspNetCore.Components.WebAssembly 包的版本号,如下所示:

    <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
      <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
        <StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" />
      </ItemGroup>
    </Project> 
    
  7. 构建 Northwind.BlazorNorthwind.Blazor.Client 项目。

  8. Northwind.Blazor 中,请注意 Program.cs 几乎与 ASP.NET Core 项目相同。一个区别是配置服务的部分,其中调用了 AddRazorComponents 方法,这是我们 Northwind.Web 项目中有的。此部分还调用以在服务器和客户端上启用交互性,如下所示(代码中高亮显示):

    using Northwind.Blazor.Client.Pages;
    using Northwind.Blazor.Components;
    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    **builder.Services.AddRazorComponents()**
     **.AddInteractiveServerComponents()**
     **.AddInteractiveWebAssemblyComponents();**
    var app = builder.Build(); 
    
  9. 还请注意配置 HTTP 管道的部分,它调用 MapRazorComponents<App> 方法。这配置了一个名为 App.razor 的根应用程序组件,如下所示(代码中高亮显示):

    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
      app.UseWebAssemblyDebugging();
    }
    else
    {
      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.UseAntiforgery();
    app.MapStaticAssets();
    **app.MapRazorComponents<App>()**
     **.AddInteractiveServerRenderMode()**
     **.AddInteractiveWebAssemblyRenderMode()**
     **.AddAdditionalAssemblies(**
     **typeof(Northwind.Blazor.Client._Imports).Assembly);**
    app.Run(); 
    
  10. Northwind.Blazor 中,请注意 Components 文件夹及其子文件夹,如 LayoutPages,使用与您在启用 Blazor 组件时在 Northwind.Web 项目中使用的相同命名约定。

  11. Northwind.Blazor.ClientProgram.cs 中,请注意它创建了一个 WebAssemblyHostBuilder 而不是正常的 WebApplication 构建器,如下所示(代码中高亮显示):

    using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    await builder.Build().RunAsync(); 
    
  12. Northwind.Blazor.Client 中,在 Pages 文件夹中,请注意有一个名为 Counter.razor 的 Blazor 组件。

检查 Blazor 路由、布局和导航

让我们回顾一下这个 Blazor 项目的路由配置、布局和导航菜单:

  1. Northwind.Blazor 项目的文件夹中,在 Components 文件夹中,在 App.razor 中,请注意它定义了基本的 HTML 页面标记,引用了本地 Bootstrap 版本进行样式设置,以及一些 Blazor 特定的元素,如下所示(代码中高亮显示)并在标记后的列表中注明:

    <!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="@Assets["bootstrap/bootstrap.min.css"]" />
      <link rel="stylesheet" href="@Assets["app.css"]" />
      <link rel="stylesheet"
            href="@Assets["Northwind.Blazor.styles.css"]" />
      **<ImportMap />**
      <link rel="icon" type="image/png" href="favicon.png" />
      **<****HeadOutlet** **/>**
    </head>
    <body>
      **<****Routes** **/>**
      **<****script****src****=****"****_framework/blazor.web.js"****></****script****>**
    </body>
    </html> 
    

在检查前面的标记时,请注意以下内容:

  • 使用 ComponentBase.Assets 属性引用资产,该属性解析给定资产的指纹 URL。当您在 Program.cs 中使用 MapStaticAssets 中间件时,应使用此属性。

  • 一个用于表示导入映射元素(<script type="importmap"></script>)的<ImportMap /> Blazor 组件,该元素定义了模块脚本的导入映射。您可以在以下链接中了解导入映射:developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap

  • 一个用于向<head>部分注入额外内容的<HeadOutlet /> Blazor 组件。这是所有 Blazor 项目中可用的一些内置组件之一。例如,在一个 Blazor 页面组件中,使用<PageTitle>组件来设置网页的<title>

  • 一个用于定义本项目自定义路由的<Routes /> Blazor 组件。由于该组件是当前项目的一部分,因此它可以被开发者完全自定义,其文件名为Routes.razor

  • 一个用于管理 Blazor 动态功能(如后台下载 WebAssembly 组件并在之后从服务器端到客户端组件执行切换)与服务器通信的blazor.web.js脚本块。

  1. Components文件夹中的Routes.razor文件中,请注意一个<Router>组件可以启用当前项目 assembly 或Northwind.Blazor.Client项目 assembly(对于 WebAssembly 组件)中找到的所有 Blazor 组件的路由。如果找到匹配的路由,则执行RouteView,这将设置组件的默认布局为MainLayout并将任何路由数据参数传递给组件。对于该组件,第一个<h1>元素将被聚焦,如下面的代码所示:

    <Router AppAssembly="@typeof(Program).Assembly"
      AdditionalAssemblies="new[] {
        typeof(Client._Imports).Assembly }">
      <Found Context="routeData">
        <RouteView RouteData="@routeData"
                   DefaultLayout="@typeof(Layout.MainLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
      </Found>
    </Router> 
    
  2. Components文件夹中的_Imports.razor文件中,请注意该文件导入了一些在所有自定义 Blazor 组件中使用的有用命名空间,如下面的代码所示:

    @using System.Net.Http
    @using System.Net.Http.Json
    @using Microsoft.AspNetCore.Components.Forms
    @using Microsoft.AspNetCore.Components.Routing
    @using Microsoft.AspNetCore.Components.Web
    @using static Microsoft.AspNetCore.Components.Web.RenderMode
    @using Microsoft.AspNetCore.Components.Web.Virtualization
    @using Microsoft.JSInterop
    @using Northwind.Blazor
    @using Northwind.Blazor.Client
    @using Northwind.Blazor.Components 
    
  3. Components\Layout文件夹中的MainLayout.razor文件中,请注意它定义了包含由本项目中的NavMenu.razor组件文件实现的导航菜单的侧边栏<div>,以及用于内容的 HTML5 元素如<main><article>,还有一个<div>用于显示未处理的错误,如下面的代码所示:

    @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>
    <div id="blazor-error-ui" data-nosnippet>
        An unhandled error has occurred.
        <a href="." class="reload">Reload</a>
        <span class="dismiss">![](https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/11.png)</span>
    </div> 
    
  4. Components\Layout文件夹中的MainLayout.razor.css文件中,请注意它包含针对组件的独立 CSS 样式。由于命名约定,在此文件中定义的样式优先于其他可能影响组件的样式。

Blazor 组件通常需要提供自己的 CSS 以应用样式或 JavaScript 以执行纯 C#无法执行的活动,如访问浏览器 API。为了确保这不会与站点级别的 CSS 和 JavaScript 冲突,Blazor 支持 CSS 和 JavaScript 隔离。如果您有一个名为Home.razor的组件,只需创建一个名为Home.razor.css的 CSS 文件。在此文件中定义的样式将覆盖项目中定义的任何其他样式。

  1. Components\Layout 文件夹中,在 NavMenu.razor 文件中,请注意,它有三个菜单项,首页计数器天气。这些菜单链接是通过使用名为 NavLink 的组件创建的,如下面的标记所示:

    <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="counter">
            <span class="bi bi-plus-square-fill-nav-menu"
                  aria-hidden="true"></span> Counter
          </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> 
    
  2. 注意,NavMenu.razor 有它自己的独立样式表,名为 NavMenu.razor.css

  3. Components\Pages 文件夹中,在 Home.razor 文件中,请注意,它定义了一个设置页面标题的组件,然后渲染一个标题和一个欢迎信息,如下面的代码所示:

    @page "/"
    <PageTitle>Home</PageTitle>
    <h1>Hello, world!</h1>
    Welcome to your new app. 
    
  4. Components\Pages 文件夹中,在 Weather.razor 文件中,请注意,它定义了一个组件,该组件从注入的依赖项天气服务获取天气预报,然后在一个表格中渲染它们,如下面的代码所示:

    @page "/weather"
    @attribute [StreamRendering]
    <PageTitle>Weather</PageTitle>
    
    <h1>Weather</h1>
    <p>This component demonstrates showing data.</p>
    @if (forecasts == null)
    {
      <p><em>Loading...</em></p>
    }
    else
    {
      <table class="table">
        <thead>
          <tr>
            <th>Date</th>
            <th>Temp. (C)</th>
            <th>Temp. (F)</th>
            <th>Summary</th>
          </tr>
        </thead>
        <tbody>
        @foreach (var forecast in forecasts)
        {
          <tr>
            <td>@forecast.Date.ToShortDateString()</td>
            <td>@forecast.TemperatureC</td>
            <td>@forecast.TemperatureF</td>
            <td>@forecast.Summary</td>
           </tr>
        }
        </tbody>
      </table>
    }
    @code {
      private WeatherForecast[]? forecasts;
      protected override async Task OnInitializedAsync()
      {
        // Simulate asynchronous loading to demonstrate streaming rendering
        await Task.Delay(500);
        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool",
          "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index =>
          new WeatherForecast
          {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
          }).ToArray();
      }
      private class WeatherForecast
      {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
      }
    } 
    
  5. Northwind.Blazor.Client 项目中,在 Pages 文件夹下,在 Counter.razor 文件中,请注意,一个 Blazor 页面组件被定义为具有 /counter 路由,一个会在服务器和 WebAssembly 之间自动切换的渲染模式,它有一个名为 currentCount 的私有字段,每次按钮点击时都会增加,如下面的标记所示:

    @page "/counter"
    @rendermode InteractiveAuto
    <PageTitle>Counter</PageTitle>
    <h1>Counter</h1>
    <p role="status">Current count: @currentCount</p>
    <button class="btn btn-primary"
      @onclick="IncrementCount">Click me</button>
    @code {
      private int currentCount = 0;
      private void IncrementCount()
      {
        currentCount++;
      }
    } 
    

如何定义一个可路由的页面组件

要创建一个可路由的页面组件,将 @page 指令添加到组件的 .razor 文件顶部,如下面的标记所示:

@page "/customers" 

前面的代码相当于 Program.cs 中的映射端点,如下面的代码所示:

app.MapGet("/customers", () => ...); 

页面组件可以有多个 @page 指令来注册多个路由,如下面的代码所示:

@page "/weather"
@page "/forecast" 

Router 组件在其 AppAssembly 参数中特别扫描程序集,以查找带有 @page 指令的 Blazor 组件,并将它们的 URL 路径注册为端点。

在运行时,页面组件与您在 Routes.razor 文件 <RouteView> 组件中指定的任何特定布局合并。默认情况下,Blazor Web App 项目模板将 MainLayout.razor 定义为页面组件的布局。

良好实践:按照惯例,将可路由页面 Blazor 组件放在 Components\Pages 文件夹中。

如何导航路由并传递路由参数

微软提供了一个名为 NavigationManager 的依赖服务,它理解 Blazor 路由和 NavLink 组件。NavigateTo 方法用于跳转到指定的 URL。

Blazor 路由可以包括不区分大小写的命名参数,并且您可以通过将参数绑定到代码块中的属性来最轻松地访问传递的值,使用 [Parameter] 属性,如下面的标记所示:

@page "/customers/{country}"
<div>Country parameter as the value: @Country</div>
@code {
  [Parameter]
  public string Country { get; set; }
} 

处理缺失时应具有默认值的参数的推荐方法是给参数后缀添加 ? 并在 OnParametersSet 方法中使用空合并运算符,如下面的标记所示:

@page "/customers/{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, then
    // set its value to USA.
    Country = Country ?? "USA";
  }
} 

如何使用带有路由的导航链接组件

在 HTML 中,您使用 <a> 元素来定义导航链接,如下面的标记所示:

<a href="/customers">Customers</a> 

在 Blazor 中,使用 <NavLink> 组件,如下面的标记所示:

<NavLink href="/customers">Customers</NavLink> 

NavLink组件比锚点元素更好,因为它会自动将其类设置为active,如果其href与当前位置 URL 匹配。如果您的 CSS 使用不同的类名,则可以在NavLink.ActiveClass属性中设置该类名。

默认情况下,在匹配算法中,href是一个路径前缀,因此如果NavLinkhref/customers,如前面的代码示例所示,那么它会匹配以下所有路径并将它们都设置为具有active类样式:

/customers
/customers/USA
/customers/Germany/Berlin 

为了确保匹配算法只对路径中的所有文本执行匹配(换句话说,只有当整个完整文本匹配时才进行匹配,而不是路径的一部分匹配),请将Match参数设置为NavLinkMatch.All,如下面的代码所示:

<NavLink href="/customers" Match="NavLinkMatch.All">Customers</NavLink> 

如果您设置了其他属性,例如target,它们将被传递到生成的底层<a>元素。

理解基组件类

OnParametersSet方法是由默认继承自组件的基类ComponentBase定义的,如下面的代码所示:

using Microsoft.AspNetCore.Components;
public abstract class ComponentBase
  : IComponent, IHandleAfterRender, IHandleEvent
{
  // Members not shown.
} 

ComponentBase有一些有用的方法,您可以在其中调用并覆盖,如下表14.1所示:

方法 描述
InvokeAsync 调用此方法以在关联渲染器的同步上下文中执行函数。
OnAfterRender,OnAfterRenderAsync 覆盖这些方法以在每次组件被渲染后调用代码。
OnInitialized,OnInitializedAsync 覆盖这些方法以在组件从其渲染树中的父组件接收初始参数后调用代码。
OnParametersSet,OnParametersSetAsync 覆盖这些方法以在组件接收参数并将值分配给属性后调用代码。
ShouldRender 覆盖此方法以指示组件是否应该渲染。
StateHasChanged 调用此方法以使组件重新渲染。

表 14.1:在 ComponentBase 中覆盖的有用方法

正如你在第十三章中看到的,使用 ASP.NET Core 构建网站,Blazor 组件可以有共享布局。您将创建一个.razor组件文件,并使其显式继承自LayoutComponentBase,如下面的标记所示:

@inherits LayoutComponentBase
<div>
  ...
  @Body
  ...
</div> 

基类有一个名为Body的属性,您可以在布局的适当位置在标记中渲染。

您可以在App.razor文件及其Router组件中为组件设置默认布局。要为组件显式设置布局,请使用@layout指令,如下面的标记所示:

@page "/customers"
@layout AlternativeLayout
<div>
  ...
</div> 

运行 Blazor Web App 项目模板

现在我们已经审查了项目模板以及特定于 Blazor 服务器的关键部分,我们可以启动网站并审查其行为:

  1. Northwind.Blazor 项目中,在 Properties 文件夹中,在 launchSettings.json 文件中,对于 https 配置文件,修改 applicationUrl 以使用端口 5141 进行 HTTPS 和端口 5140 进行 HTTP,如下所示,高亮显示的以下标记:

    "applicationUrl": "https://localhost:**5141**;http://localhost:**5140**", 
    
  2. 使用 https 启动配置启动 Northwind.Blazor 项目。

  3. 启动 Chrome 并导航到 https://localhost:5141/

  4. 在左侧导航菜单中,点击 Weather,然后点击 Click me 按钮 three 次,并注意标签显示 3,如图 图 14.1 所示:

图 14.1:使用 WebAssembly 的 Blazor 计数器组件

  1. 在左侧导航菜单中,点击 Weather,并注意在半秒钟内,出现一个 Loading… 消息,然后它被一个包含五个随机天气预报的表格所取代,如图 图 14.2 所示:

图 14.2:使用流将天气数据提取到 Blazor Web App 中

  1. 关闭 Chrome 并关闭 web 服务器。

现在,您已经审查了 Blazor Web App 项目模板中的示例 Blazor 组件,让我们深入挖掘并构建我们自己的组件。

使用 Blazor 构建组件

在本节中,我们将构建一个组件来列出、创建和编辑 Northwind 数据库中的客户。

我们将分几个步骤来完成它:

  1. 创建一个 Blazor 组件,用于渲染设置为参数的国家名称。

  2. 使其既能作为可路由页面,也能作为组件工作。

  3. 在数据库中实现执行 CRUD创建读取更新删除)操作的功能。

定义和测试一个简单的 Blazor 组件

我们将添加新的组件到现有的 Blazor Web App 项目中:

  1. Northwind.Blazor 项目中,在 Components\Pages 文件夹中,添加一个名为 Customers.razor 的新文件。在 Visual Studio 中,项目项模板命名为 Razor Component。在 Rider 中,项目项模板命名为 Blazor Component

良好实践:请记住,Blazor 组件文件名必须以大写字母开头;否则,您将遇到编译错误!

  1. 添加语句以输出 Customers 组件的标题,并定义一个代码块来存储国家名称,如下所示,高亮显示的以下标记:

    <h3>
      Customers **@(string.IsNullOrWhiteSpace(Country)**
     **? "Worldwide" : "in " + Country)**
    </h3>
    @code {
     **[Parameter]**
     **public string? Country { get; set; }**
    } 
    

@code 块可以放在文件的任何位置。一些开发者喜欢将其放在顶部,这样在编写下面的标记时,其中定义的任何属性都可用,并且可以更容易地引用它们。

  1. Components\Pages 文件夹中的 Home.razor 文件中,将语句添加到文件底部以实例化 Customers 组件两次,一次将 Germany 设置为 Country 参数,一次不设置国家,如下所示,以下标记:

    <Customers Country="Germany" />
    <Customers /> 
    
  2. 使用 https 启动配置启动 Northwind.Blazor 项目。

  3. 启动 Chrome,导航到 https://localhost:5141/,并注意 Customers 组件,如图 图 14.3 所示:

图 14.3:设置国家参数为德国和不设置的客户组件

  1. 关闭 Chrome 并关闭 Web 服务器。

使用 Bootstrap 图标

在较旧的.NET 7 及更早版本的 Blazor 项目模板中,它们包含了所有 Bootstrap 图标。在.NET 8 及更高版本的新的项目模板中,只定义了三个使用 SVG 的图标。让我们看看 Blazor 团队是如何定义这些图标的,然后我们将添加一些供我们使用的图标:

  1. Components\Layout文件夹中,在NavMenu.razor.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 Icons拥有 MIT 许可证,包含超过 2,000 个图标。

  3. 在网页上有两个输入框,一个标记为搜索图标,另一个标记为过滤图标。在过滤图标框中输入globe,并注意找到了六个地球仪图标。

  4. 点击第一个地球仪,在CSS部分,点击CSS按钮,并注意您可以通过点击复制到剪贴板按钮来复制并粘贴此图标的定义以用于 CSS 样式表;然而,您不需要这样做,因为我已经为您创建了一个 CSS 文件,其中定义了五个图标,您可以在 Blazor 项目中使用。

  5. 在您喜欢的浏览器中,导航到github.com/markjprice/cs13net9/blob/main/code/ModernWeb/Northwind.Blazor/Northwind.Blazor/wwwroot/icons.css,下载文件,并将其保存在您自己的项目的wwwroot文件夹中。

  6. Components文件夹中,在App.razor组件中,在<head>部分,添加一个<link>元素来引用icons.css样式表,如下面的标记所示:

    <link rel="stylesheet" href="@Assets["icons.css"]" /> 
    
  7. 保存并关闭文件。

将组件转换为可路由页面组件

将此组件转换为具有国家路由参数的可路由页面组件很简单:

  1. Components\Pages文件夹中,在Customers.razor组件中,在文件顶部添加一个语句将/customers注册为其路由,并带有可选的country路由参数,如下面的标记所示:

    @page "/customers/{country?}" 
    
  2. Components\Layout文件夹中,在NavMenu.razor文件中,在现有列表项元素底部,添加两个列表项元素用于我们的可路由页面组件,显示全球和德国的客户,两者都使用人的图标,如下面的标记所示:

    <div class="nav-item px-3">
      <NavLink class="nav-link" href="customers"
               Match="NavLinkMatch.All">
        <span class="bi bi-globe" aria-hidden="true"></span>
        Customers Worldwide
      </NavLink>
    </div>
    <div class="nav-item px-3">
      <NavLink class="nav-link" href="customers/Germany">
        <span class="bi bi-globe-europe-africa"
              aria-hidden="true"></span>
        Customers in Germany
      </NavLink>
    </div> 
    
  3. Components\Pages文件夹中,在Home.razor文件中,删除两个<Customers>组件,因为我们现在可以使用它们的导航菜单项来测试它们,并且我们希望将主页保持尽可能简单。

  4. 使用https启动配置文件启动Northwind.Blazor项目。

  5. 启动 Chrome 并导航到https://localhost:5141/

  6. 在左侧导航菜单中,点击 德国的客户。注意国家名称已正确传递到页面组件,并且该组件使用与其他页面组件相同的布局,如 Home.razor。还要注意 URL,localhost:5141/customers/Germany,如图 14.4 所示:

图 14.4:德国的客户

  1. 关闭 Chrome 并关闭 web 服务器。

将实体引入 Blazor 组件

现在您已经看到了 Blazor 页面组件的最小实现,我们可以向其中添加一些有用的功能。在这种情况下,我们将使用 Northwind 数据库上下文从数据库中获取客户:

  1. Northwind.Blazor.csproj 中,添加对 SQL Server 或 SQLite 的 Northwind 数据库上下文项目的引用,并全局导入命名空间以处理 Northwind 实体,如下面标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\..\Northwind.DataContext.Sqlite
    \Northwind.DataContext.Sqlite.csproj" />
    </ItemGroup>
    <ItemGroup>
      <Using Include="Northwind.EntityModels" />
    </ItemGroup> 
    

    警告! 数据上下文项目相对于当前项目向上两个目录,因此我们必须使用 ..\..\

  2. 构建 Northwind.Blazor 项目。

  3. Program.cs 文件中,在调用 Build 之前,向依赖服务集合中添加一个语句以注册 Northwind 数据库上下文,如下面的代码所示:

    builder.Services.AddNorthwindContext(
      relativePath: @"..\.."); 
    

我们需要显式设置 relativePath,因为 Northwind.db 文件位于 ModernWeb 文件夹中,而项目运行在 ModernWeb\Northwind.Blazor\Northwind.Blazor 文件夹中。

为 Blazor 组件抽象服务

我们可以实现 Blazor 组件,使其直接调用 Northwind 数据库上下文以使用实体模型获取客户。如果 Blazor 组件在服务器上执行,这将有效。然而,如果组件在浏览器中使用 WebAssembly 运行,则不会有效。

现在我们将创建一个本地依赖服务,以实现组件的更好重用:

  1. 使用您首选的编码工具添加一个新项目,如下面列表中定义的:

    • 项目模板:类库 / classlib

    • 项目文件和文件夹:Northwind.Blazor.Services

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

  2. Northwind.Blazor.Services.csproj 项目文件中,添加对 Northwind 实体模型库的项目引用,如下面标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\Northwind.EntityModels.Sqlite\
    Northwind.EntityModels.Sqlite.csproj" />
    </ItemGroup> 
    
  3. 构建 Northwind.Blazor.Services 项目。

  4. Northwind.Blazor.Services 项目中,将 Class1.cs 重命名为 INorthwindService.cs

  5. INorthwindService.cs 文件中,定义一个本地服务的契约,该服务抽象了 CRUD 操作,如下面的代码所示:

    using Northwind.EntityModels; // To use Customer.
    namespace Northwind.Blazor.Services;
    public interface INorthwindService
    {
      Task<List<Customer>> GetCustomersAsync();
      Task<List<Customer>> GetCustomersAsync(string country);
      Task<Customer?> GetCustomerAsync(string id);
      Task<Customer> CreateCustomerAsync(Customer c);
      Task<Customer> UpdateCustomerAsync(Customer c);
      Task DeleteCustomerAsync(string id);
    } 
    
  6. Northwind.Blazor.csproj 项目文件中,添加对服务类库的项目引用,如下面高亮显示的标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\Northwind.DataContext.Sqlite
    \Northwind.DataContext.Sqlite.csproj" />
    **<****ProjectReference****Include****=****"****..\..\Northwind.Blazor.Services\**
    **Northwind.Blazor.Services.csproj"** **/>**
    </ItemGroup> 
    
  7. 构建 Northwind.Blazor 项目。

  8. Northwind.Blazor 项目中,添加一个名为 Services 的新文件夹。

  9. Services 文件夹中,添加一个名为 NorthwindServiceServerSide.cs 的新文件,并修改其内容以使用 Northwind 数据库上下文实现 INorthwindService 接口,如下面的代码所示:

    using Microsoft.EntityFrameworkCore; // To use ToListAsync<T>.
    namespace Northwind.Blazor.Services;
    public class NorthwindServiceServerSide : INorthwindService
    {
      private readonly NorthwindContext _db;
      public NorthwindServiceServerSide(NorthwindContext db)
      {
        _db = db;
      }
      public Task<List<Customer>> GetCustomersAsync()
      {
        return _db.Customers.ToListAsync();
      }
      public Task<List<Customer>> GetCustomersAsync(string country)
      {
        return _db.Customers.Where(c => c.Country == country).ToListAsync();
      }
      public Task<Customer?> GetCustomerAsync(string id)
      {
        return _db.Customers.FirstOrDefaultAsync
          (c => c.CustomerId == id);
      }
      public Task<Customer> CreateCustomerAsync(Customer c)
      {
        _db.Customers.Add(c);
        _db.SaveChangesAsync();
        return Task.FromResult(c);
      }
      public Task<Customer> UpdateCustomerAsync(Customer c)
      {
        _db.Entry(c).State = EntityState.Modified;
        _db.SaveChangesAsync();
        return Task.FromResult(c);
      }
      public Task DeleteCustomerAsync(string id)
      {
        Customer? customer = _db.Customers.FirstOrDefaultAsync
          (c => c.CustomerId == id).Result;
        if (customer == null)
        {
          return Task.CompletedTask;
        }
        else
        {
          _db.Customers.Remove(customer);
          return _db.SaveChangesAsync();
        }
      }
    } 
    
  10. Program.cs 中,导入我们服务的命名空间,如下面的代码所示:

    using Northwind.Blazor.Services; // To use INorthwindService. 
    
  11. Program.cs 文件中,在调用 Build 之前,添加一条语句将 NorthwindServiceServerSide 注册为实现 INorthwindService 接口的一个瞬态服务,如下面的代码所示:

    builder.Services.AddTransient<INorthwindService,
      NorthwindServiceServerSide>(); 
    

瞬态服务是指为每个请求创建一个新实例的服务。您可以在以下链接中了解更多关于服务不同生命周期的信息:learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#service-lifetimes

  1. Components 文件夹中的 _Imports.razor 文件中,导入用于处理 Northwind 实体和我们的服务的命名空间,以便我们构建的 Blazor 组件不需要单独导入命名空间,如下面的标记所示:

    @using Northwind.Blazor.Services @* To use INorthwindService. *@
    @using Northwind.EntityModels @* To use Northwind entities. *@ 
    

_Imports.razor 文件仅适用于 .razor 文件。如果您使用代码背后的 .cs 文件来实现组件代码,那么它们必须单独导入命名空间或使用全局 using 语句隐式导入命名空间。

  1. Components\Pages 文件夹中的 Customers.razor 文件中,添加注入服务的语句,然后使用它通过同步数据库操作输出所有客户的表格,如下面高亮显示的代码所示:

    @page "/customers/{country?}"
    **@inject INorthwindService _service**
    <h3>
      Customers @(string.IsNullOrWhiteSpace(Country)
        ? "Worldwide" : "in " + Country)
    </h3>
    **@if (customers is null)**
    **{**
    **<****p****><****em****>****Loading...****</****em****></****p****>**
    **}**
    **else**
    **{**
    **<****table****class****=****"table"****>**
    **<****thead****>**
    **<****tr****>**
    **<****th****>****Id****</****th****>**
    **<****th****>****Company Name****</****th****>**
    **<****th****>****Address****</****th****>**
    **<****th****>****Phone****</****th****>**
    **<****th****></****th****>**
    **</****tr****>**
    **</****thead****>**
    **<****tbody****>**
     **@foreach (Customer c in customers)**
     **{**
    **<****tr****>**
    **<****td****>****@c.CustomerId****</****td****>**
    **<****td****>****@c.CompanyName****</****td****>**
    **<****td****>**
     **@c.Address****<****br****/>**
     **@c.City****<****br****/>**
     **@c.PostalCode****<****br****/>**
     **@c.Country**
    **</****td****>**
    **<****td****>****@c.Phone****</****td****>**
    **<****td****>**
    **<****a****class****=****"btn btn-info"****href****=****"editcustomer/@c.CustomerId"****>**
    **<****i****class****=****"****bi bi-pencil"****></****i****></****a****>**
    **<****a****class****=****"btn btn-danger"**
    **href****=****"deletecustomer/@c.CustomerId"****>**
    **<****i****class****=****"bi bi-trash"****></****i****></****a****>**
    **</****td****>**
    **</****tr****>**
     **}**
    **</****tbody****>**
    **</****table****>**
    **}**
    @code {
      [Parameter]
      public string? Country { get; set; }
     **private IEnumerable****<****Customer****>****? customers;**
     **protected override async Task OnParametersSetAsync()**
     **{**
     **if (string.IsNullOrWhiteSpace(Country))**
     **{**
     **customers = await _service.GetCustomersAsync();**
     **}**
     **else**
     **{**
     **customers = await _service.GetCustomersAsync(Country);**
     **}**
     **}**
    } 
    
  2. 使用 https 启动配置启动 Northwind.Blazor 项目。

  3. 启动 Chrome 并导航到 https://localhost:5141/

  4. 在左侧导航菜单中,点击 Customers in Germany,并注意客户表从数据库加载并在网页中渲染,如图 14.5 所示:

![图片 B22322_14_05.png]

图 14.5:德国客户列表

  1. 在浏览器地址栏中,将 Germany 改为 UK,并注意客户表已筛选,仅显示 UK 客户。

  2. 在左侧导航菜单中,点击 Customers Worldwide,注意客户表未按国家进行筛选。

  3. 点击任何编辑或删除按钮,并注意它们返回一个消息说 错误:404,因为我们还没有实现该功能。同时,注意编辑由五位字符标识符 ALFKI 确定的客户链接,如下面的链接所示:https://localhost:5141/editcustomer/ALFKI

  4. 关闭 Chrome 并关闭 web 服务器。

启用流式渲染

现在,让我们通过在页面向访客显示后改进客户表的渲染。我们已经在使用异步操作来获取数据,但这个操作必须在将网页响应发送回浏览器之前完成。这就是为什么我们从未在页面上看到加载中…消息。要看到它,我们必须启用流式渲染。但是,如果您从本地数据库获取数据,它可能仍然发生得太快。因此,为了确保我们能看到它,我们还将通过添加延迟来减慢数据的获取:

  1. Components\Pages文件夹中,在Customers.razor的顶部添加一个属性以启用流式渲染,如下面的代码所示:

    @attribute [StreamRendering] 
    
  2. Customers.razor中,在OnParametersSetAsync方法中添加一个语句以异步延迟一秒,如下面的代码所示:

    protected override async Task OnParametersSetAsync()
    {
      **await** **Task.Delay(****1000****);** **// Delay for one second.**
      ...
    } 
    
  3. 使用https启动配置启动Northwind.Blazor项目。

  4. 启动 Chrome 并导航到https://localhost:5141/

  5. 在左侧导航菜单中,点击德国的客户,并注意加载中…消息出现一秒后,然后被客户表替换。

到目前为止,该组件只提供了一个只读的客户表。现在,我们将通过添加完整的 CRUD 操作来扩展它。

使用 EditForm 组件定义表单

微软提供了现成的组件来构建表单。我们将使用它们为客户提供创建、编辑和删除功能。

微软提供了EditForm组件以及几个表单元素,例如InputText,以简化使用 Blazor 表单的过程。

EditForm可以设置一个模型以将其绑定到一个具有属性和自定义验证事件处理器的对象,以及识别模型类上的标准 Microsoft 验证属性,如下面的代码所示:

<EditForm Model="@customer" OnSubmit="ExtraValidation">
  <DataAnnotationsValidator />
  <ValidationSummary />
  <InputText id="name" @bind-Value="customer.CompanyName" />
  <button type="submit">Submit</button>
</EditForm>
@code {
  private Customer customer = new();
  private void ExtraValidation()
  {
    // Perform any extra validation you want.
  }
} 

作为ValidationSummary组件的替代方案,您可以使用ValidationMessage组件在单个表单元素旁边显示消息。要将验证消息绑定到一个属性,您使用 lambda 表达式来选择该属性,如下面的代码所示:

<ValidationMessage For="@(() => Customer.CompanyName)" /> 

构建客户详情组件

我们将创建一个组件来显示客户的详细信息。这只是一个组件,而不是页面,因此它不会有@page指令:

  1. Northwind.Blazor项目中,在Components文件夹中创建一个名为CustomerDetail.razor的新文件。(Visual Studio 项目项模板命名为Razor 组件;Rider 项目项模板命名为Blazor 组件。)

  2. 修改其内容以定义一个用于编辑客户属性的表单,如下面的标记所示:

    <EditForm Model="@Customer" OnValidSubmit="@OnValidSubmit">
      <DataAnnotationsValidator />
      <div>
        <label class="form-label">Customer Id</label>
        <div>
          <InputText class="form-control"
                     @bind-Value="@Customer.CustomerId" />
          <ValidationMessage For="@(() => Customer.CustomerId)" />
        </div>
      </div>
      <div>
        <label class="form-label">Company Name</label>
        <div>
          <InputText class="form-control"
                     @bind-Value="@Customer.CompanyName" />
          <ValidationMessage For="@(() => Customer.CompanyName)" />
        </div>
      </div>
      <div>
        <label class="form-label">Address</label>
        <div>
          <InputText class="form-control"
                     @bind-Value="@Customer.Address" />
          <ValidationMessage For="@(() => Customer.Address)" />
        </div>
      </div>
      <div>
        <label class="form-label">Country</label>
        <div>
          <InputText class="form-control"
                     @bind-Value="@Customer.Country" />
          <ValidationMessage For="@(() => Customer.Country)" />
        </div>
      </div>
      <button type="submit" class="btn btn-@ButtonStyle">
        @ButtonText
      </button>
    </EditForm>
    @code {
      [Parameter]
      public Customer Customer { get; set; } = null!;
      [Parameter]
      public string ButtonText { get; set; } = "Save Changes";
      [Parameter]
      public string ButtonStyle { get; set; } = "info";
      [Parameter]
      public EventCallback OnValidSubmit { get; set; }
    } 
    

构建客户创建、编辑和删除组件

现在,我们可以创建三个可路由的页面组件,它们使用该组件:

  1. Components\Pages文件夹中创建一个名为CreateCustomer.razor的新文件。

  2. CreateCustomer.razor中,修改其内容以使用客户详情组件来创建新客户,如下面的代码所示:

    @rendermode RenderMode.InteractiveServer
    @page "/createcustomer"
    @inject INorthwindService _service
    @inject NavigationManager _navigation
    <h3>Create Customer</h3>
    <CustomerDetail ButtonText="Create Customer"
                    Customer="@customer"
                    OnValidSubmit="@Create" />
    @code {
      private Customer customer = new();
      private async Task Create()
      {
        await _service.CreateCustomerAsync(customer);
        _navigation.NavigateTo("customers");
      }
    } 
    
  3. Components\Pages文件夹中,在Customers.razor中,在<h3>元素之后添加一个包含按钮的<div>元素,用于导航到创建客户页面组件,如下面的标记所示:

    <div>
      <a class="btn btn-info" href="createcustomer">
      <i class="bi bi-plus-square"></i> Create New</a>
    </div> 
    
  4. Components\Pages文件夹中,创建一个名为EditCustomer.razor的新文件,并修改其内容以使用客户详情组件来编辑并保存现有客户的更改,如下面的代码所示:

    @rendermode RenderMode.InteractiveServer
    @page "/editcustomer/{customerid}"
    @inject INorthwindService _service
    @inject NavigationManager _navigation
    <h3>Edit Customer</h3>
    <CustomerDetail ButtonText="Update"
                    Customer="@customer"
                    OnValidSubmit="@Update" />
    @code {
      [Parameter]
      public string CustomerId { get; set; } = null!;
      private Customer? customer = new();
      protected override async Task OnParametersSetAsync()
      {
        customer = await _service.GetCustomerAsync(CustomerId);
      }
      private async Task Update()
      {
        if (customer is not null)
        {
          await _service.UpdateCustomerAsync(customer);
        }
        _navigation.NavigateTo("customers");
      }
    } 
    
  5. Components\Pages文件夹中,创建一个名为DeleteCustomer.razor的新文件,并修改其内容以使用客户详情组件来显示即将被删除的客户,如下面的代码所示:

    @rendermode RenderMode.InteractiveServer
    @page "/deletecustomer/{customerid}"
    @inject INorthwindService _service
    @inject NavigationManager _navigation
    <h3>Delete Customer</h3>
    <div class="alert alert-danger">
      Warning! This action cannot be undone!
    </div>
    <CustomerDetail ButtonText="Delete Customer"
                    ButtonStyle="danger"
                    Customer="@customer"
                    OnValidSubmit="@Delete" />
    @code {
      [Parameter]
      public string CustomerId { get; set; } = null!;
      private Customer? customer = new();
      protected override async Task OnParametersSetAsync()
      {
        customer = await _service.GetCustomerAsync(CustomerId);
      }
      private async Task Delete()
      {
        if (customer is not null)
        {
          await _service.DeleteCustomerAsync(CustomerId);
        }
        _navigation.NavigateTo("customers");
      }
    } 
    

测试客户组件

现在,我们可以测试客户组件以及如何使用它们来创建、编辑和删除客户:

  1. 使用https启动配置启动Northwind.Blazor项目。

  2. 启动 Chrome 并导航到https://localhost:5141/

  3. 导航到全球客户德国客户,然后点击+ 创建新按钮。

  4. 输入无效的客户 IDABCDEF,离开文本框,并注意验证信息,如图 14.6 所示:

图 14.6:创建新客户并输入无效的客户 ID

  1. 客户 ID更改为ABCDE,为其他文本框如Alpha CorpMain StreetGermany输入值,然后点击创建客户按钮。

  2. 当客户列表出现时,点击德国客户,然后滚动到页面底部以查看新客户。

  3. ABCDE客户行上,点击编辑图标按钮,将地址更改为类似Upper Avenue的内容,点击更新按钮,并注意客户记录已被更新。

  4. ABCDE客户行上,点击删除图标按钮,注意警告,点击删除客户按钮,并注意客户记录已被删除。

  5. 关闭 Chrome 并关闭 Web 服务器。

练习和探索

通过回答一些问题、进行一些实际操作练习,并深入研究本章主题来测试你的知识和理解。

练习 14.1 – 在线材料

在线材料可以由 Microsoft 或第三方创建,或者可以在本书的额外内容中找到。

提升 Blazor 应用程序

要了解如何使用 AOT 原生发布和与 JavaScript 的互操作以及处理位置更改事件来增强 Blazor 应用程序,你可以阅读一个可选的在线部分,链接如下:github.com/markjprice/cs13net9/blob/main/docs/ch14-enhanced-blazor.md

利用开源 Blazor 组件库

为了学习如何使用一些常见的 Blazor 开源组件,我为我的 Apps and Services with .NET 8 伴侣书籍编写了一个仅在网络上可用的部分,可在以下链接找到:github.com/markjprice/apps-services-net8/blob/main/docs/ch15-blazor-libraries.md

练习 14.2 – 练习

练习深入探讨了本章的主题。首先,你将创建一个乘法表组件,然后你将创建一个国家导航组件。

创建乘法表组件

Northwind.Blazor 项目中,创建一个可路由的页面组件,该组件根据名为 Number 的参数渲染一个乘法表,然后以两种方式测试你的组件。

首先,通过将组件实例添加到 Home.razor 文件中,如下所示,以生成默认大小为 12 行的 6 乘法表,或大小为 10 行的 7 乘法表:

<TimesTable Number="6" />
<TimesTable Number="7" Size="10" /> 

其次,通过在浏览器地址栏中输入路径来实现,如下所示:

创建国家导航项

Northwind.Blazor 项目中,在 NavMenu 组件中,调用客户的 Web 服务以获取国家名称列表,并对它们进行循环,为每个国家创建一个菜单项。

这里有一个例子:

  1. Northwind.Blazor 项目中,在 INorthwindService.cs 文件中,添加以下代码:

    List<string?> GetCountries(); 
    
  2. NorthwindServiceServerSide.cs 中添加以下代码:

    public List<string?> GetCountries()
    {
      return _db.Customers.Select(c => c.Country)
        .Distinct().OrderBy(country => country).ToList();
    } 
    
  3. NavMenu.razor 中添加以下标记:

    @inject INorthwindService _service
    ...
    @foreach(string? country in _service.GetCountries())
    {
        string countryLink = "customers/" + country;
        <div class="nav-item px-3">
            <NavLink class="nav-link" href="@countryLink">
            <span class="oi oi-people" aria-hidden="true"></span>
            Customers in @country
            </NavLink>
        </div>
    } 
    

你不能使用 <NavLink class="nav-link" href="customers/@c">,因为 Blazor 不允许在组件中组合文本和 @ Razor 表达式。这就是为什么上面的代码创建了一个局部变量来组合文本以生成国家 URL 的原因。

练习 14.3 – 测试你的知识

回答以下问题:

  1. 四种 Blazor 渲染模式是什么,它们有何不同?

  2. 在一个 Blazor Web App 项目中,与 ASP.NET Core MVC 项目相比,需要哪些额外的配置?

  3. 为什么你应该避免使用 Blazor Server 和 Blazor Server Empty 项目模板?

  4. 在一个 Blazor Web App 项目中,App.razor 文件的作用是什么?

  5. 使用 <NavLink> 组件的主要好处是什么?

  6. 如何将值传递到组件中?

  7. 使用 <EditForm> 组件的主要好处是什么?

  8. 当设置参数时,如何执行一些语句?

  9. 当组件出现时,如何执行一些语句?

  10. Blazor 的一个好处是能够使用 C# 和 .NET 来实现客户端组件,而不是使用 JavaScript。Blazor 组件需要任何 JavaScript 吗?

练习 14.4 – 探索主题

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

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-15---building-interactive-web-components-using-blazor

摘要

在本章中,你学习了:

  • 关于 Blazor 组件的概念。

  • 如何构建在服务器端执行的 Blazor 组件,使用 SignalR 在交互过程中更新用户界面。

在下一章中,你将学习如何构建和消费使用 HTTP 作为通信层的服务,即 Web 服务。你还将学习如何构建在客户端执行的 Blazor 组件,使用 WebAssembly。

第十五章:构建和消费 Web 服务

本章是关于学习如何使用 ASP.NET Core Minimal APIs 构建 Web 服务(也称为 HTTP 或表示状态转移REST)服务)。然后,你将学习如何使用 HTTP 客户端消费 Web 服务,这可以是任何其他类型的.NET 应用程序,包括网站或移动或桌面应用程序。我们将创建一个 Blazor WebAssembly 客户端。

本章需要你在第十章使用 Entity Framework Core 处理数据,以及第十二章第十四章中获得的关于使用 ASP.NET Core 和 Blazor 构建网站的知识和技能。

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

  • 使用 ASP.NET Core 构建 Web 服务

  • 为 Northwind 数据库创建 Web 服务

  • 记录和尝试 Web 服务

  • 使用 HTTP 客户端消费 Web 服务

使用 ASP.NET Core 构建 Web 服务

在我们构建现代 Web 服务之前,我们需要了解一些背景知识,为本章的内容奠定基础。

理解 Web 服务缩写

虽然 HTTP 最初是为了请求和响应 HTML 和其他资源供人类查看而设计的,但它也适合构建服务。

Roy Fielding 在他的博士论文中,描述了REST架构风格,指出 HTTP 标准对于构建服务来说很好,因为它定义了以下内容:

  • 使用 URI(统一资源标识符)来唯一标识资源,例如https://localhost:5151/products/23

  • 在这些资源上执行常见任务的方法,如GETPOSTPUTDELETE

  • 能够协商请求和响应中交换的内容的媒体类型,例如 XML 和 JSON。当客户端指定一个请求头,如Accept: application/xml,*/*;q=0.8时,就会发生内容协商。ASP.NET Core Web 服务使用的默认响应格式是 JSON,这意味着其中一个响应头会是Content-Type: application/json; charset=utf-8

Web 服务使用 HTTP 通信标准,因此有时被称为HTTP 服务RESTful 服务

理解 HTTP 请求和响应

HTTP 定义了标准类型的请求和标准代码来指示响应类型。其中大部分可以用来实现 Web 服务。

最常见的请求类型是GET,用于检索由唯一路径标识的资源,还可以设置额外的选项,如请求头中可接受的媒体类型,例如Accept,如下面的示例所示:

GET /path/to/resource
Accept: application/json 

常见的响应包括成功和多种类型的失败,如表 15.1所示:

状态码 描述
101 Switching Protocols 请求者已要求服务器切换协议,并且服务器已同意这样做。例如,从 HTTP 切换到WebSocketsWS)进行更有效的通信是常见的。

| 103 Early Hints | 用于传达帮助客户端准备处理最终响应的提示。例如,服务器可能会在发送一个使用样式表和 JavaScript 文件的网页的正常 200 OK 响应之前发送以下响应:|

HTTP/1.1 103 Early Hints
Link: </style.css>; rel=preload; as=style
Link: </script.js>; rel=preload; as=script 

|

200 OK 路径正确形成,资源成功找到,序列化为可接受的媒体类型,然后返回在响应体中。响应头指定了 Content-TypeContent-LengthContent-Encoding,例如,GZIP
301 Moved Permanently 随着时间的推移,网络服务可能会更改其资源模型,包括用于标识现有资源的路径。网络服务可以通过返回此状态码和一个名为 Location 的响应头,其中包含新路径,来指示新路径。
302 Found 这与 301 相同。
304 Not Modified 如果请求包含 If-Modified-Since 头部,则网络服务可以响应此状态码。响应体为空,因为客户端应使用其缓存的资源副本。
307 Temporary Redirect 请求的资源已临时移动到 Location 头部中的 URL。浏览器应使用该 URL 发起新的请求。例如,如果你启用了 UseHttpsRedirection 并客户端发起了一个 HTTP 请求,就会发生这种情况。
400 Bad Request 请求无效,例如,它使用了一个整数 ID 的产品路径,但缺少 ID 值。
401 Unauthorized 请求有效且资源已找到,但客户端未提供凭据或无权访问该资源。重新认证可能允许访问,例如,通过添加或更改 Authorization 请求头部。
403 Forbidden 请求有效且资源已找到,但客户端无权访问该资源。重新认证不会解决问题。
404 Not Found 请求有效,但资源未找到。资源可能在稍后重复请求时找到。要指示资源永远不会找到,返回 410 Gone
406 Not Acceptable 如果请求有一个 Accept 头部,只列出了网络服务不支持媒体类型。例如,如果客户端请求 JSON,但网络服务只能返回 XML。
451 Unavailable for Legal Reasons 美国托管的一个网站可能会对来自欧洲的请求返回此状态,以避免必须遵守通用数据保护条例GDPR)。这个数字是作为对小说《 Fahrenheit 451》的参考,在那本书中,书籍被禁止并焚毁。
500 Server Error 请求有效,但在服务器端处理请求时出了问题。稍后再试可能有效。
503 Service Unavailable 网络服务正忙,无法处理请求。稍后再试可能有效。

表 15.1:对 GET 方法的常见 HTTP 状态码响应

其他常见的 HTTP 请求类型包括POSTPUTPATCHDELETE,它们用于创建、修改或删除资源。

要创建一个新资源,你可能需要发送一个包含新资源的POST请求,如下面的代码所示:

POST /path/to/resource
Content-Length: 123
Content-Type: application/json 

要创建一个新资源或更新现有资源,你可能需要发送一个包含现有资源全新版本的PUT请求,如果资源不存在,则创建它;如果它已存在,则替换它(有时称为upsert操作),如下面的代码所示:

PUT /path/to/resource
Content-Length: 123
Content-Type: application/json 

为了更有效地更新现有资源,你可能需要发送一个包含仅包含需要更改的属性的对象的PATCH请求,如下面的代码所示:

PATCH /path/to/resource
Content-Length: 123
Content-Type: application/json 

要删除现有资源,你可能需要发送一个DELETE请求,如下面的代码所示:

DELETE /path/to/resource 

除了上表中针对GET请求的响应外,所有创建、修改或删除资源的请求类型都有额外的可能常见响应,如下所示表 15.2

状态码 描述
201 已创建 新资源已成功创建,响应头中名为Location的头部包含其路径,响应体包含新创建的资源。立即GET该资源应返回200
202 已接受 新资源不能立即创建,因此请求被排队等待后续处理,立即GET该资源可能会返回404。响应体可以包含指向某种状态检查器或资源何时可用的估计的资源。
204 无内容 通常用于对DELETE请求的响应,因为删除后返回资源在请求体中通常没有意义!有时用于对POSTPUTPATCH请求的响应,如果客户端不需要确认请求是否正确处理。
405 方法不允许 当请求使用了不支持的方法时返回。例如,一个设计为只读的 Web 服务可能会明确禁止PUTDELETE等操作。
415 不支持的媒体类型 返回时,请求体中的资源使用的是该网络服务无法处理的媒体类型。例如,如果请求体包含 XML 格式的资源,但该网络服务只能处理 JSON。

表 15.2:对其他方法如 POST 和 PUT 的常见 HTTP 状态码响应

ASP.NET Core 最小 API 项目

我们将构建一个 Web 服务,该服务使用 ASP.NET Core 提供一种方式来处理 Northwind 数据库中的数据,以便任何可以发送 HTTP 请求并接收 HTTP 响应的平台上的客户端应用程序都可以使用这些数据。

传统上,你使用 ASP.NET Core Web API / dotnet new webapi 项目模板。这允许创建使用控制器或较新的最小化 API 实现的 Web 服务。

警告! 在 .NET 6 和 .NET 7 中,dotnet new webapi 命令创建的是使用控制器实现的服务。在 .NET 6 和 .NET 7 中,要使用最小化 API 实现服务,需要将 --use-minimal-apis 开关添加到命令中。使用 .NET 8 或更高版本,dotnet new webapi 命令创建的是使用最小化 API 实现的服务。要使用控制器实现服务,需要添加 --use-controllers 开关。

最小化 API 网络服务和原生 AOT 编译

.NET 8 引入了 ASP.NET Core Web API (native AOT) / dotnet new webapiaot 项目模板,它仅使用最小化 API 并支持原生 AOT 发布。随着时间的推移,.NET 的更多组件将支持 AOT,正如你可以在以下引语中读到的那样:

“我们预计将在 .NET 9 时间范围内取得调查原生 AOT 支持对于 MVC 和 Blazor 的进展,但我们不期望在 .NET 9 中提供生产就绪的原生 AOT 支持,因为涉及的工作量很大。” – 丹·罗斯

github.com/dotnet/aspnetcore/issues/51834#issuecomment-1913300365

最佳实践:最小化 API 与垂直切片架构(VSA)配合得非常好。与基于控制器的 Web API 相比,最小化 API 的一个主要优点是每个最小化 API 端点只需要实例化它需要的依赖注入(DI)服务。在使用控制器的情况下,为了执行控制器中的任何操作方法,必须在每次调用中实例化任何操作方法中使用的所有 DI 服务。这是时间和资源的浪费!

创建 ASP.NET Core 最小化 API 项目

让我们开始吧:

  1. 使用你喜欢的代码编辑器打开 ModernWeb 解决方案,然后添加一个新项目,如下所示:

    • 项目模板:ASP.NET Core Web API / webapi

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

    • 项目文件和文件夹:Northwind.WebApi

  2. 如果你使用的是 Visual Studio,那么请确认以下默认值已被选择:

    • 身份验证类型:无

    • 配置 HTTPS:已选择

    • 启用容器支持:已清除

    • 启用 OpenAPI 支持:已选择

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

    • 使用控制器:已清除

确保清除 使用控制器 复选框,否则你的代码将与你在这本书中看到的非常不同!

  1. 如果你使用 VS Code 或 Rider,那么在 ModernWeb 目录中,在命令提示符或终端中输入以下命令:

    dotnet new webapi -o Northwind.WebApi 
    
  2. 构建 Northwind.WebApi 项目。

  3. 在项目文件中,删除实现 OpenAPI 网络服务文档的包的版本号,因为我们正在使用 CPM,如下所示:

    <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> 
    
  4. Program.cs 中,查看以下代码:

    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
    builder.Services.AddOpenApi();
    var app = builder.Build();
    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
      app.MapOpenApi();
    }
    app.UseHttpsRedirection();
    var summaries = new[]
    {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
    };
    app.MapGet("/weatherforecast", () =>
    {
      var forecast = Enumerable.Range(1, 5).Select(index =>
        new WeatherForecast
        (
          DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
          Random.Shared.Next(-20, 55),
          summaries[Random.Shared.Next(summaries.Length)]
        ))
        .ToArray();
      return forecast;
    })
    .WithName("GetWeatherForecast");
    app.Run();
    internal record WeatherForecast(DateOnly Date,
      int TemperatureC, string? Summary)
    {
      public int TemperatureF => 32 +
        (int)(TemperatureC / 0.5556);
    } 
    

在审查前面的代码时,请注意以下内容:

  • 程序以与其他任何 ASP.NET Core 项目类似的配置开始,通过调用 WebApplication.CreateBuilder

  • 服务集合中添加了一个 OpenAPI 服务。这用于记录网络服务。在 .NET 8 及更早版本中,使用了第三方 Swashbuckle 包来完成此操作,但自 .NET 9 及以后版本,Microsoft 已经编写了自己的实现。您可以在以下链接中了解更多信息:github.com/dotnet/aspnetcore/issues/54599。默认情况下,OpenAPI 文档生成创建一个符合 OpenAPI 规范 v3.0 的文档:spec.openapis.org/oas/v3.0.0

  • 在开发期间,OpenAPI 文档被映射为端点,以便其他开发者可以使用它轻松地创建客户端。默认情况下,通过调用 MapOpenApi 注册的 OpenAPI 端点将文档暴露在 /openapi/{documentName}.json 端点。默认情况下,documentNamev1。在生产环境中,这些端点不会被映射,因为它们不再必要。

  • MapGet 调用注册了一个相对路径 /weatherforecast 以响应 HTTP GET 请求,并且其实现使用共享的 Random 对象返回一个包含随机温度和总结(如 BracingBalmy)的 WeatherForecast 对象数组,这些总结对应于未来五天的天气。

现在让我们允许 HTTP 请求指定预报应提前多少天。同时,我们将通过将天气端点实现放入其自己的代码文件中,来实施良好的实践:

  1. 添加一个名为 Program.Weather.cs 的新类文件。

  2. Program.Weather.cs 中,添加语句以通过移动(剪切和粘贴语句)从 Program.cs 中的天气相关语句来扩展自动生成的 partial Program 类,并进行一些小的调整,例如定义一个带有 days 参数的 GetWeather 方法来控制要生成的天气预报数量,如下所示,代码如下:

    public partial class Program
    {
      static string[] summaries = { "Freezing", "Bracing",
        "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot",
        "Sweltering", "Scorching" };
      internal static WeatherForecast[]? GetWeather(int days)
      {
        WeatherForecast[]? forecast = Enumerable.Range(1, days)
          .Select(index => new WeatherForecast
          (
            DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            Random.Shared.Next(-20, 55),
            summaries[Random.Shared.Next(summaries.Length)]
          ))
          .ToArray();
        return forecast;
      }
      internal record WeatherForecast(DateOnly Date,
        int TemperatureC, string? Summary)
      {
        public int TemperatureF => 32 +
          (int)(TemperatureC / 0.5556);
      }
    } 
    
  3. Program.cs 中,修改 MapGet 调用,如下所示,高亮显示的代码:

    app.UseHttpsRedirection();
    **app****.MapGet(****"/weatherforecast/{days:int?}"****,**
     **(int days = 5) => GetWeather(days))**
     **.WithName(****"GetWeatherForecast"****);**
    app.Run(); 
    

MapGet 调用中,请注意路由模板模式 {days:int?}days 参数限制为 int 值。? 使得 days 参数是可选的,如果缺失,则默认为 5

审查网络服务的功能

现在,我们将测试网络服务的功能:

  1. Properties 文件夹中,在 launchSettings.json 中,请注意,默认情况下,如果您使用 Visual Studio,则 https 配置文件将启动浏览器并导航到 /weatherforecast 相对 URL 路径,如下所示,高亮显示的标记:

    "https": {
      "commandName": "Project",
      "dotnetRunMessages": true,
    **"launchBrowser"****:****true****,**
    **"launchUrl"****:****"weatherforecast"**, 
    
  2. 对于 https 配置文件,对于其 applicationUrl,将 HTTPS 的随机端口号更改为 5151,对于 HTTP 更改为 5150,如下所示,高亮显示的标记:

    "applicationUrl": "https://localhost:**5151**;http://localhost:**5150**", 
    
  3. 保存所有修改文件的更改。

  4. 使用 https 启动配置文件启动 Northwind.WebApi 网络服务项目。

  5. 在 Windows 上,如果你看到一个 Windows 安全警报 对话框,显示 Windows Defender 防火墙已阻止此应用的一些功能,那么请点击 允许访问 按钮。

  6. 启动 Chrome,导航到 https://localhost:5151/,并注意你会得到一个 404 状态码响应,因为我们尚未启用静态文件,且没有 index.html。记住,这个项目不是为人类查看和交互而设计的,所以这是网络服务的预期行为。

  7. 在 Chrome 中,显示 开发者工具

  8. 导航到 https://localhost:5151/weatherforecast 并注意网络服务应该返回一个包含五个随机天气预报对象的 JSON 文档数组,如图 图 15.1 所示:

图 15.1:来自天气预报网络服务的一个请求和响应

  1. 关闭 开发者工具

  2. 导航到 https://localhost:5151/weatherforecast/14 并注意请求两周天气预报时,响应包含 14 个预报。

  3. 选择 美化打印 复选框,如图 图 15.1 所示,并注意 Chrome 的最新版本现在可以更好地格式化 JSON 响应,以便人类阅读。

  4. 关闭 Chrome 并关闭网络服务器。

路由约束

要注册 /weatherforecast 路由端点,我们使用路由约束来限制 days 参数的可接受值为整数,如下代码中突出显示所示:

app.MapGet("/weatherforecast/{days:int?}", ... 

路由约束允许我们根据数据类型和其他验证来控制匹配。它们总结在 表 15.3 中:

约束 示例 描述
required {id:required} 参数已被提供。
intlong {id:int} 任何正确大小的整数。
decimaldoublefloat {unitprice:decimal} 任何正确大小的实数。
bool {discontinued:bool} truefalse 上的不区分大小写的匹配。
datetime {hired:datetime} 不变文化的日期/时间。
guid {id:guid} 一个 GUID 值。
minlength(n)maxlength(n)length(n)length(n, m) {title:minlength(5)}{title:length(5, 25)} 文本必须具有定义的最小和/或最大长度。
min(n)max(n)range(n, m) {age:range(18, 65)} 整数必须在定义的最小和/或最大范围内。
alpharegex {firstname:alpha}{id:regex(^[A-Z]{{5}}$)} 参数必须匹配一个或多个字母字符或正则表达式。

表 15.3:带有示例和描述的路由约束

使用冒号分隔多个约束,如下例所示:

**app****.MapGet****("/weatherforecast/{days:int:****min****(****5****)}", ...** 

对于正则表达式,自动添加了 RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant。正则表达式标记必须转义(将 \ 替换为 \\,将 { 替换为 {{,将 } 替换为 }})或使用文本字符串字面量。

更多信息:您可以通过定义一个实现 IRouteConstraint 的类来创建自定义路由约束。这超出了本书的范围,但您可以在以下链接中了解相关信息:learn.microsoft.com/en-us/aspnet/core/fundamentals/routing#custom-route-constraints

短路路由

当路由匹配到端点请求时,它允许中间件管道的其他部分在调用端点逻辑之前运行。这需要时间,因此在 ASP.NET Core 8 及以后版本中,您可以立即调用端点并返回响应。

您可以通过在映射的端点路由上调用 ShortCircuit 方法来完成此操作,如下面的代码所示:

app.MapGet("/", () => "Hello World").ShortCircuit(); 

或者,您可以通过调用 MapShortCircuit 方法来响应 404 资源未找到 或其他状态码,对于不需要进一步处理的资源,如下面的代码所示:

app.MapShortCircuit(404, "robots.txt", "favicon.ico"); 

ASP.NET Core 8 及以后版本的改进路由工具

微软已经改进了用于处理所有 ASP.NET Core 8 及以后技术(包括 Web API 和 Blazor)的路由工具。这些功能包括以下内容:

  • 路由语法高亮:现在在您的代码编辑器中突出显示路由的不同部分。

  • 自动完成:参数和路由名称以及路由约束将自动完成。

  • 路由分析器和修复工具:这些工具解决了开发者在实现路由时遇到的常见问题。

您可以在以下链接找到的博客文章 ASP.NET Core 在 .NET 8 中的路由工具增强功能 中了解它们:devblogs.microsoft.com/dotnet/aspnet-core-route-tooling-dotnet-8/

理解端点路由处理程序返回类型

端点 lambda 表达式可以返回 .NET 类型,如单个 string 值;由 classrecordstruct 定义的复杂对象;或复杂对象的集合。ASP.NET Core 最小 API 将它们序列化为 JSON 或纯文本。

考虑以下端点路由处理程序,它返回一个 string 值,如下面的代码所示:

app.MapGet("/hello", () => "Hello World"); 

ASP.NET Core 最小 API 将返回带有 Content-Type: text/plain; charset=utf-8 头的 200 状态码,并在正文中包含以下内容:Hello World

现在考虑以下端点路由处理程序,它返回一个匿名类型,如下面的代码所示:

app.MapGet("/user", () => new {
  FirstName = "Bob",
  Age = 45 }); 

ASP.NET Core 最小 API 将返回带有 Content-Type: application/json; charset=utf-8 头的 200 状态码,并在正文中包含以下内容:

{"firstName":"Bob","age":45} 

为了更好地控制响应,存在返回 IResult 的辅助方法,它定义了一个表示 HTTP 端点结果的合同。静态 ResultsTypedResults 类可以用来创建代表不同类型响应的各种 IResult 对象。

相比于返回 Results,返回 TypedResults 具有以下优点:

  • TypedResults 辅助函数返回强类型对象,这可以提高代码可读性和单元测试,并减少运行时错误的可能性。

  • 实现类型自动为 OpenAPI 提供响应类型元数据,以描述端点。

TypedResults 类有一个名为 Empty 的属性,它产生一个空的结果响应,当执行时将不会做任何事情。

TypedResults 类有方法使其能够轻松返回不同的响应,如 表 15.4 所示:

方法 描述
Bytes 返回 200 状态码并将字节数组内容写入响应。
Content 返回 200 状态码并将内容字符串写入 HTTP 响应。有一个可选参数可以指定媒体类型头。
File 返回 200 状态码并将指定的 Stream 写入响应。
Json 返回 200 状态码并将指定的数据对象序列化为 JSON 格式发送到响应。
Ok 返回 200 状态码并将资源转换为客户端首选的格式,如 JSON 或 XML。通常用于响应 GET 请求。
Created, CreatedAtRoute 返回 201 状态码和新的资源路径。通常用于响应创建资源快速创建的 POST 请求。
Accepted, AcceptedAtRoute 返回 202 状态码以指示请求正在处理但尚未完成。通常用于响应 POSTPUTPATCHDELETE 请求,这些请求触发一个需要很长时间才能完成的后台进程。
Redirect, RedirectToRoute 根据临时或永久重定向的 bool 参数返回 301307308 状态码,并带有要重定向到的 url
Problem 生成 ProblemDetails 响应。
NoContent 返回 204 状态码和空响应体。通常用于响应不需要包含受影响资源的 PUTPATCHDELETE 请求。
BadRequest 返回 400 状态码和包含更多详细信息的可选消息字符串。
NotFound 返回 404 状态码并自动填充 ProblemDetails 主体(需要 2.2 或更高版本的兼容性版本)。

表 15.4:返回响应的 TypedResults 辅助方法

更多信息:您可以在以下链接中了解更多关于如何在 Minimal APIs 网络服务中创建响应的信息:learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/responses?view=aspnetcore-9.0

为 Northwind 数据库创建网络服务

我们将引用您在 第十二章使用 .NET 引入现代网络开发 中创建的 Northwind 数据库的 Entity Framework Core 实体数据模型:

  1. Northwind.WebApi 项目中,全局和静态导入 System.Console 类,并为 SQLite 或 SQL Server 添加对 Northwind 数据上下文类库的项目引用,如下所示:

    <ItemGroup Label="To simplify use of WriteLine.">
      <Using Include="System.Console" Static="true" />
    </ItemGroup>
    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include=
    "..\Northwind.DataContext.Sqlite\Northwind.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    
  2. 构建 Northwind.WebApi 项目并修复代码中的任何编译错误。

  3. Program.cs 中,导入用于处理 Northwind 实体模型的命名空间,如下所示:

    using Northwind.EntityModels; // To use AddNorthwindContext method. 
    
  4. Program.cs 中,在调用 Build 之前添加一个语句来注册 Northwind 数据库上下文类(它将使用 SQLite 或 SQL Server,具体取决于在项目文件中引用的数据库提供程序),如下所示:

    builder.Services.AddNorthwindContext(); 
    

注册依赖服务

您可以使用不同的生命周期注册依赖服务,如下列所示:

  • 瞬态:每次请求时都会创建这些服务。瞬态服务应该是轻量级和无状态的。

  • 作用域内:这些服务在每个客户端请求时创建一次,然后被销毁。然后,将响应返回给客户端。

  • 单例:这些服务通常在第一次请求时创建,然后被共享,尽管您也可以在注册时提供实例。

.NET 8 中引入了为依赖服务设置键的能力。这允许使用不同的键注册多个服务,然后使用该键稍后检索。

builder.Services.AddKeyedsingleton<IMemoryCache, BigCache>("big");
builder.Services.AddKeyedSingleton<IMemoryCache, SmallCache>("small");
class BigCacheConsumer([FromKeyedServices("big")] IMemoryCache cache)
{
  public object? GetData() => cache.Get("data");
}
class SmallCacheConsumer(IKeyedServiceProvider keyedServiceProvider)
{
  public object? GetData() => keyedServiceProvider
    .GetRequiredKeyedService<IMemoryCache>("small");
} 

在这本书中,您将使用所有三种生命周期,但我们不需要使用带键的服务。

内存、分布式和混合缓存

现在我们来概述一下内存、分布式和混合缓存。

内存缓存

内存缓存将数据存储在运行应用程序的 Web 服务器内存中。这对于需要不太广泛的缓存需求的小型到中型应用程序很有用,并且可以由单个服务器的内存处理。

内存缓存的要点如下所示:

  • 性能:由于数据存储在本地 RAM 中,因此检索速度快。

  • 简单性:易于在应用程序中实现和配置。

  • 易变性:如果应用程序重新启动或服务器关闭,数据将丢失。

  • 可伸缩性:限于单个服务器的内存;不适合需要分布式缓存的大规模应用程序。

要实现内存缓存,请在 Program.cs 中的服务集合中添加内存缓存服务,如下面的代码所示:

services.AddMemoryCache(); 

在端点中检索服务,如下面的代码所示:

private readonly IMemoryCache _cache; 

在缓存中设置数据,如下面的代码所示:

_cache.Set(key, data); 

从缓存中获取数据,如下面的代码所示:

return _cache.TryGetValue(key, out var data) ? data : null; 

现在我们来比较内存缓存和分布式缓存。

分布式缓存

分布式缓存允许跨多个服务器缓存数据,这使得它适用于大规模、分布式应用程序。这确保了在 Web 农场中不同节点之间的数据可用性和一致性。

内存缓存的关键点如下所示:

  • 可扩展性:可以处理大量数据集,并在多个服务器之间提供缓存。

  • 持久性:根据提供者,数据可以在应用程序重启后持久化。

  • 延迟:与内存缓存相比,可能具有更高的延迟,因为需要网络调用。

  • 提供者:常见的提供者包括 Redis、SQL Server 和 NCache。

要实现内存缓存,请在 Program.cs 中的服务集合中添加它,如下面的代码所示:

services.AddStackExchangeRedisCache(options =>
  options.Configuration = "localhost:6379";
  options.InstanceName = "SampleInstance";
}); 

在端点中检索服务,如下面的代码所示:

private readonly IDistributedCache _cache; 

在缓存中设置数据,如下面的代码所示:

await _cache.SetStringAsync(key, value); 

从缓存中获取数据,如下面的代码所示:

return await _cache.GetStringAsync(key); 

因此,内存缓存快速简单,但仅限于服务器的内存,并在重启时丢失数据。分布式缓存可扩展且持久,适用于大型应用程序,有各种提供者,如 Redis 和 SQL Server。

这两种方法都有助于通过减少重复获取或计算数据的需求来提高应用程序性能。选择它们取决于应用程序的规模、性能需求和架构。

但如果我们能够兼得两者之长呢?

让我们看看一个新的选项,称为混合缓存。

混合缓存

ASP.NET Core 9 中引入的 HybridCache API 解决了在 IDistributedCacheIMemoryCache API 中发现的一些限制。作为一个具有默认实现的抽象类,HybridCache 高效地管理了与从缓存中存储和检索数据相关的多数任务。

混合缓存的关键点如下所示:

  • 统一 API:为进程内和进程外缓存提供单个接口。HybridCache 可以无缝替换任何现有的 IDistributedCacheIMemoryCache 使用。它最初始终使用内存缓存,当有 IDistributedCache 实现可用时,HybridCache 会利用它进行二级缓存。这种双级缓存方法结合了内存缓存的快速性和分布式或持久缓存的持久性。

  • 拥挤保护HybridCache 防止缓存拥挤,这发生在频繁使用的缓存条目被失效时,导致多个请求同时尝试重新填充它。HybridCache 合并并发操作,确保所有相同响应的请求都等待第一个请求完成。

  • 可配置序列化HybridCache 允许在服务注册期间进行可配置的序列化,通过 WithSerializerWithSerializerFactory 方法支持类型特定的和通用的序列化器,这些方法是从 AddHybridCache 调用链式调用的。默认情况下,它内部管理 stringbyte[],并使用 System.Text.Json 处理其他类型。它可以配置为使用其他序列化器,例如 Protobuf 或 XML。

虽然混合缓存是在 .NET 9 中引入的,但其包针对 .NET Standard 2.0,因此它可以与较老的 .NET 版本一起使用,甚至包括 .NET Framework 4.6.2 或更高版本。

现在你已经了解了缓存的原理和基本实现选项,让我们为我们的 Web 服务创建一个数据仓库,该仓库缓存实体以提高性能和可伸缩性。

创建具有缓存实体的数据仓库

定义和实现数据仓库以提供 CRUD 操作是良好的实践。我们将为 Northwind 中的 Customers 表创建一个数据仓库。这个表中只有 91 个客户,因此我们将整个表的副本缓存在内存中,以提高读取客户记录时的可伸缩性和性能。

良好实践:在实际的 Web 服务中,你应该使用分布式缓存,如 Redis,这是一个开源的数据结构存储,可以用作高性能、高可用数据库、缓存或消息代理。你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/aspnet/core/performance/caching/distributed

在 .NET 9 中,引入了 HybridCache,它可以在内存和分布式缓存类型之间自动切换。

更多信息:你可以在以下链接中了解更多关于 HybridCache 的信息:learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid?view=aspnetcore-9.0

我们将遵循现代的良好实践,使仓库 API 异步。它将通过使用参数注入的端点实例化,因此为每个 HTTP 请求创建一个新的实例。它将使用 HybridCache 的单例实例。让我们开始吧:

  1. Northwind.WebApi.csproj 项目文件中,添加对混合缓存的包引用,如下所示:

    <PackageReference Include=
      "Microsoft.Extensions.Caching.Hybrid" /> 
    
  2. Program.cs 中,导入用于处理混合缓存的命名空间,如下所示:

    using Microsoft.Extensions.Caching.Hybrid; // To use HybridCacheEntryOptions. 
    
  3. Program.cs中,在调用Build之前,在配置服务的部分,使用默认的缓存条目持续时间为 60 秒以及本地内存缓存为 30 秒,注册混合缓存服务,如下面的代码所示:

    builder.Services.AddHybridCache(options =>
    {
      options.DefaultEntryOptions = new HybridCacheEntryOptions
      {
        Expiration = TimeSpan.FromSeconds(60),
        LocalCacheExpiration = TimeSpan.FromSeconds(30)
      };
    }); 
    
  4. Northwind.WebApi项目中,创建一个名为Repositories的文件夹。

  5. 将一个新接口文件和一个类文件添加到Repositories文件夹中,分别命名为ICustomerRepository.csCustomerRepository.cs

  6. ICustomerRepository.cs中,定义一个具有五个 CRUD 方法(创建、读取、更新、删除)的接口,如下面的代码所示:

    using Northwind.EntityModels; // To use Customer.
    namespace Northwind.WebApi.Repositories;
    public interface ICustomerRepository
    {
      Task<Customer?> CreateAsync(Customer c);
      Task<Customer[]> RetrieveAllAsync();
      Task<Customer?> RetrieveAsync(string id,
        CancellationToken token);
      Task<Customer?> UpdateAsync(Customer c);
      Task<bool?> DeleteAsync(string id);
    } 
    
  7. CustomerRepository.cs中,定义一个将实现该接口并使用混合缓存(其方法将在接下来的几个步骤中实现,所以现在忽略你将看到的错误),如下面的代码所示:

    using Microsoft.EntityFrameworkCore.ChangeTracking; // To use EntityEntry<T>.
    using Northwind.EntityModels; // To use Customer.
    using Microsoft.EntityFrameworkCore; // To use ToArrayAsync.
    using Microsoft.Extensions.Caching.Hybrid; // To use HybridCache.
    namespace Northwind.WebApi.Repositories;
    public class CustomerRepository : ICustomerRepository
    {
      private readonly HybridCache _cache;
      // Use an instance data context field because it should not be
      // cached due to the data context having internal caching.
      private NorthwindContext _db;
      public CustomerRepository(NorthwindContext db,
        HybridCache hybridCache)
      {
        _db = db;
        _cache = hybridCache;
      }
    } 
    
  8. 实现一个检索所有客户的方法,始终从数据库中读取最新的客户,如下面的代码所示:

    public Task<Customer[]> RetrieveAllAsync()
    {
      return _db.Customers.ToArrayAsync();
    } 
    
  9. 实现用于检索的Retrieve方法,如果可能的话,从缓存中获取客户,或从数据模型中获取,并将其设置在缓存中以便下次使用,如下面的代码所示:

    public async Task<Customer?> RetrieveAsync(string id,
      CancellationToken token = default)
    {
      id = id.ToUpper(); // Normalize to uppercase.
      return await _cache.GetOrCreateAsync(
        key: id, // Unique key to the cache entry.
        factory: async cancel => await _db.Customers
          .FirstOrDefaultAsync(c => c.CustomerId == id, token),
        cancellationToken: token);
    } 
    
  10. 实现用于创建的Create方法,如下面的代码所示:

    public async Task<Customer?> CreateAsync(Customer c)
    {
      c.CustomerId = c.CustomerId.ToUpper(); // Normalize to uppercase.
      // Add to database using EF Core.
      EntityEntry<Customer> added =
        await _db.Customers.AddAsync(c);
      int affected = await _db.SaveChangesAsync();
      if (affected == 1)
      {
        // If saved to database then store in cache.
        await _cache.SetAsync(c.CustomerId, c);
        return c;
      }
      return null;
    } 
    
  11. 实现用于更新数据库的Update方法,如果成功,同时更新缓存的客户,如下面的代码所示:

    public async Task<Customer?> UpdateAsync(Customer c)
    {
      c.CustomerId = c.CustomerId.ToUpper();
      _db.Customers.Update(c);
      int affected = await _db.SaveChangesAsync();
      if (affected == 1)
      {
        await _cache.SetAsync(c.CustomerId, c);
        return c;
      }
      return null;
    } 
    
  12. 实现用于删除的Delete方法,从数据库中删除客户,如果成功,同时删除缓存的客户,如下面的代码所示:

    public async Task<bool?> DeleteAsync(string id)
    {
      id = id.ToUpper();
      Customer? c = await _db.Customers.FindAsync(id);
      if (c is null) return null;
      _db.Customers.Remove(c);
      int affected = await _db.SaveChangesAsync();
      if (affected == 1)
      {
        await _cache.RemoveAsync(c.CustomerId);
        return true;
      }
      return null;
    } 
    

配置客户仓库

现在你已经学到了理论,你将把它应用到实践中,以配置仓库,使其可以从 Minimal API 端点内部调用。

当网络服务启动时,你将为仓库注册一个作用域依赖项服务实现,然后在定义新的用于处理客户的 Minimal API 端点时使用构造函数参数注入来获取它。

它将包含五个操作方法来对客户执行 CRUD 操作——两个GET方法(用于所有客户或单个客户)、POST(创建)、PUT(更新)和DELETE

  1. Program.cs中,导入用于处理我们的客户仓库的命名空间,如下面的代码所示:

    using Northwind.WebApi.Repositories; // To use ICustomerRepository. 
    
  2. Program.cs中,在调用Build方法之前添加一个语句,该语句将注册CustomerRepository以在运行时作为作用域依赖项使用,如下面的代码所示:

    builder.Services.AddScoped<ICustomerRepository,
      CustomerRepository>(); 
    

良好实践:我们的仓库使用一个注册为作用域依赖项的数据库上下文。你只能在其他作用域依赖项内部使用作用域依赖项,因此我们不能将仓库注册为单例。你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#scoped

  1. Northwind.WebApi项目中,添加一个名为Program.Customers.cs的新类。

  2. Program.Customers.cs 中添加语句以定义两个响应 HTTP GET 请求的所有客户或指定国家内客户的最低 API 端点路由处理程序,如下所示:

    using Microsoft.AspNetCore.Mvc; // To use ProblemDetails.
    using Northwind.EntityModels; // To use Customer.
    using Northwind.WebApi.Repositories; // To use ICustomerRepository.
    static partial class Program
    {
      internal static void MapCustomers(this WebApplication app)
      {
        // GET: /customers
        app.MapGet(pattern: "/customers", handler:
          async (ICustomerRepository repo) =>
        {
          return await repo.RetrieveAllAsync();
        });
        // GET: customers/in/[country]
        app.MapGet(pattern: "/customers/in/{country}", handler:
          async (string country, ICustomerRepository repo) =>
        {
          return (await repo.RetrieveAllAsync())
            .Where(customer => customer.Country == country);
        });
      }
    } 
    
  3. Program.Customers.cs 中,在 MapCustomers 方法中添加语句以映射响应单个客户 HTTP GET 请求的端点路由处理程序,如下所示:

    // GET: customers/[id]
    app.MapGet("/customers/{id:regex(^[A-Z]{{5}}$)}",
      async Task<IResult> (string id, ICustomerRepository repo,
        CancellationToken token = default) =>
    {
      Customer? c = await repo.RetrieveAsync(id, token);
      if (c is null)
      {
        return TypedResults.NotFound(); // 404 Resource not found.
      }
      return TypedResults.Ok(c); // 200 OK with customer in body.
    }); 
    
  4. Program.Customers.cs 中添加语句以映射响应 HTTP POST 请求以插入新的客户实体,如下所示:

    // POST: /customers
    // BODY: Customer (JSON)
    app.MapPost(pattern: "/customers", handler:
      async Task<IResult> (Customer c,
        ICustomerRepository repo) =>
    {
      if (c is null)
      {
        return TypedResults.BadRequest(); // 400 Bad request.
      }
      Customer? addedCustomer = await repo.CreateAsync(c);
      if (addedCustomer is null)
      {
        return TypedResults.BadRequest("Repository failed to create customer.");
      }
      else
      {
        return TypedResults.CreatedAtRoute( // 201 Created.
          routeName: "GetCustomer",
          routeValues: new { id = addedCustomer
            .CustomerId.ToLower() },
          value: addedCustomer);
      }
    }); 
    
  5. Program.Customers.cs 中添加语句以映射响应 HTTP PUT 请求的端点路由处理程序,如下所示:

    // PUT: /customers/[id]
    // BODY: Customer (JSON)
    app.MapPut(pattern: "/customers/{id}", handler:
      async Task<IResult> (Customer c,
        string id, ICustomerRepository repo,
        CancellationToken token = default) =>
    {
      id = id.ToUpper();
      c.CustomerId = c.CustomerId.ToUpper();
      if (c is null || c.CustomerId != id)
      {
        return TypedResults.BadRequest(); // 400 Bad request.
      }
      Customer? existing = await repo.RetrieveAsync(id, token);
      if (existing is null)
      {
        return TypedResults.NotFound(); // 404 Resource not found.
      }
      await repo.UpdateAsync(c);
      return new TypedResults.NoContent(); // 204 No content.
    }); 
    
  6. Program.Customers.cs 中添加语句以映射响应 HTTP DELETE 请求的端点路由处理程序,如下所示:

    // DELETE: /customers/[id]
    app.MapDelete(pattern: "/customers/{id}", handler:
      async Task<IResult> (string id, ICustomerRepository repo,
        CancellationToken token = default) =>
    {
      Customer? existing = await repo.RetrieveAsync(id, token);
      if (existing is null)
      {
        return TypedResults.NotFound(); // 404 Resource not found.
      }
      bool? deleted = await repo.DeleteAsync(id);
      if (deleted.HasValue && deleted.Value) // Short circuit AND.
      {
        return TypedResults.NoContent(); // 204 No content.
      }
      else
      {
        return TypedResults.BadRequest( // 400 Bad request.
          $"Customer {id} was found but failed to delete.");
      }
    }); 
    
  7. Program.cs 中,在调用 Run 之前,调用扩展方法以映射所有客户端点路由处理程序,如下所示:

    **app****.MapCustomers();**
    app.Run(); 
    
  8. 保存所有更改。

当服务接收到 HTTP 请求时,它将创建 Controller 类的实例,调用适当的行为方法,以客户端偏好的格式返回响应,并释放控制器使用的资源,包括存储库及其数据上下文。

指定问题详情

ASP.NET Core 2.1 及更高版本中添加的一个功能是实现一个用于指定问题详情的 web 标准。如果您想掌握控制权,则可以自己创建一个 ProblemDetails 实例并包含附加信息。

让我们模拟一个需要返回自定义数据的错误请求。

Delete 端点路由处理程序的实现顶部,添加语句以检查 id 是否与字面字符串值 "bad" 匹配,如果是,则返回一个自定义的 ProblemDetails 对象,如下所示:

// Take control of problem details.
if (id == "bad")
{
  ProblemDetails problemDetails = new()
  {
    Status = StatusCodes.Status400BadRequest,
    Type = "https://localhost:5151/customers/failed-to-delete",
    Title = $"Customer ID {id} found but failed to delete.",
    Detail = "More details like Company Name, Country and so on."
  };
  return TypedResults.BadRequest(problemDetails); // 400 Bad Request
} 

您将在稍后尝试此功能。

记录和尝试 web 服务

您可以通过在浏览器中使用 HTTP GET 请求轻松尝试 web 服务。要尝试其他 HTTP 方法,我们需要一个更高级的工具。

使用浏览器尝试 GET 请求

您将使用 Chrome 尝试三种 GET 请求的实现——对所有客户、对指定国家的客户以及使用其唯一的客户 ID 对单个客户的请求:

  1. 使用 https 启动配置启动 Northwind.WebApi web 服务项目。

  2. 启动 Chrome,导航到 https://localhost:5151/customers 并注意返回的 JSON 文档,其中包含 Northwind 数据库中的所有 91 个客户(未排序),如图 15.2 所示:

![img/B22322_15_02.png]

图 15.2:Northwind 数据库中的客户作为 JSON 文档

  1. 导航到 https://localhost:5151/customers/in/Germany 并注意返回的 JSON 文档,其中只包含德国的客户。

如果您返回的是一个空数组[],那么请确保您已使用正确的大小写输入国家名称,因为数据库查询是区分大小写的。例如,比较ukUK的结果。

  1. 导航到 https://localhost:5151/customers/alfki 并注意返回的仅包含名为Alfreds Futterkiste的客户的 JSON 文档。

与国家名称不同,我们不需要担心客户id值的大小写问题,因为在客户存储库实现中,我们将string值规范化为 uppercase。

但我们如何尝试其他 HTTP 方法,例如POSTPUTDELETE?以及我们如何记录我们的网络服务,使其易于任何人理解如何与之交互?

有许多用于测试网络服务的工具,例如Postman。尽管 Postman 很受欢迎,但我更喜欢 Visual Studio 中的HTTP 编辑器或 VS Code 中的REST 客户端这样的工具,因为它们不会隐藏正在发生的事情。我觉得 Postman 太图形化了。但我鼓励您探索不同的工具,找到适合您风格的工具。您可以在以下链接了解更多关于 Postman 的信息:www.postman.com/

为了解决第一个问题,我们可以使用 Visual Studio 中内置的HTTP 编辑器工具,并安装一个名为REST 客户端的 VS Code 扩展。Rider 有自己的等效工具。这些工具允许您发送任何类型的 HTTP 请求并在代码编辑器中查看响应。

为了解决第二个问题,我们可以使用OpenAPI,也称为Swagger,这是世界上用于记录 HTTP API 的最流行技术。但首先,让我们看看代码编辑器 HTTP/REST 工具能做什么。

使用 HTTP/REST 工具发送 GET 请求

我们将首先创建一个用于发送GET请求的文件:

  1. 如果您尚未安装 Huachao Mao 的REST 客户端humao.rest-client),那么现在就为 VS Code 安装它。

  2. 在您喜欢的代码编辑器中打开ModernWeb解决方案,然后启动Northwind.WebApi项目网络服务。

  3. 文件资源管理器Finder或您喜欢的 Linux 文件工具中,在ModernWeb文件夹中创建一个HttpRequests文件夹。

  4. HttpRequests文件夹中,创建一个名为get-customers.http的文件,并在您喜欢的代码编辑器中打开它。

  5. get-customers.http中,修改其内容以包含一个用于检索所有客户的 HTTP GET请求,如下面的代码所示:

    ### Configure a variable for the web service base address.
    @base_address = https://localhost:5151/customers/
    ### Make a GET request to the base address.
    GET {{base_address}} 
    
  6. 在 HTTP GET请求上方,点击发送请求,如图15.3所示。

注意响应在新的选项卡窗口中显示。

  1. 如果您使用的是 Visual Studio,请点击原始选项卡,并注意返回的 JSON,如图15.3所示:

图 15.3:使用 Visual Studio 发送 HTTP GET 请求

Visual Studio 版本 17.8 及以后的 HTTP 编辑器是一个旨在添加类似 REST 客户端功能的功能,其用户界面可能会迅速发展,以跟上进度。您可以在以下链接中阅读其官方文档:learn.microsoft.com/en-us/aspnet/core/test/http-files

  1. get-customers.http中添加更多的GET请求,每个请求之间用三个井号分隔,以测试获取不同国家的客户以及使用 ID 获取单个客户,如下所示:

    ### Get customers in Germany
    GET {{base_address}}in/Germany
    ### Get customers in USA
    GET {{base_address}}in/USA
    ### Get Alfreds Futterkiste
    GET {{base_address}}ALFKI
    ### Get a non-existent customer
    GET {{base_address}}abcxy 
    
  2. 点击每个请求上方的发送请求链接来发送请求,并确认您获得了预期的响应,例如对于不存在的客户返回 404。

使用 HTTP/REST 工具制作其他请求

接下来,我们将创建一个用于制作其他请求(如POST)的文件:

  1. HttpRequests文件夹中,创建一个名为create-customer.http的文件,并修改其内容以定义创建新客户的POST请求,如下所示:

    ### Configure a variable for the web service base address.
    @base_address = https://localhost:5151/customers/
    ### Make a POST request to the base address.
    POST {{base_address}}
    Content-Type: application/json
    {
      "customerID": "ABCXY",
      "companyName": "ABC Corp",
      "contactName": "John Smith",
      "contactTitle": "Sir",
      "address": "Main Street",
      "city": "New York",
      "region": "NY",
      "postalCode": "90210",
      "country":  "USA",
      "phone": "(123) 555-1234"
    } 
    
  2. 发送请求并注意响应为201 Created。同时注意在头部部分,新创建客户的位置(即 URL)为/customers/ABCXY,如图15.4所示,并且响应体中包含了新创建的客户(截图未显示,但您可以在格式化原始部分中看到):

图片

图 15.4:通过向 Web 服务 POST 添加新客户

我将给您一个可选的挑战,创建.http文件尝试更新客户(使用PUT)和删除客户(使用DELETE)。尝试在确实存在的客户以及不存在的客户上操作。解决方案可以在本书的 GitHub 仓库中的以下链接找到:

github.com/markjprice/cs13net9/tree/main/code/ModernWeb/HttpRequests

传递环境变量

要在.http脚本中获取环境变量,使用$processenv,如下所示:

{{$processEnv [%]envVarName}} 

例如,如果您已设置环境变量来存储密码等秘密值以连接到必须从提交到 GitHub 仓库的任何文件中排除的 SQL Server 数据库,您可以使用以下命令:

{{$processEnv MY_SQL_PWD}} 

更多信息:您可以在以下链接中了解更多关于使用 REST 客户端环境变量的信息:marketplace.visualstudio.com/items?itemName=humao.rest-client#environments。您可以在以下链接中了解更多关于使用环境变量和 Secret Manager 与 HTTP 编辑器的信息:devblogs.microsoft.com/visualstudio/safely-use-secrets-in-http-requests-in-visual-studio-2022/.

现在我们已经看到了测试我们的服务的一种快速简单的方法,这同时也是学习 HTTP 的好方法,那么对于外部开发者来说呢?我们希望他们学习并调用我们的服务尽可能简单。为此,我们将使用 Swagger。

理解 OpenAPI 规范

OpenAPI 规范定义了您的 API 的 REST 风格合约,详细说明了所有资源及其操作,以人类和机器可读的格式,便于开发、发现和集成。

开发者可以使用网络服务的 OpenAPI 规范来自动生成他们首选语言或库中的强类型客户端代码。

让我们回顾一下如何为我们的网络服务启用 OpenAPI:

  1. 如果网络服务正在运行,请关闭网络服务器。

  2. Northwind.WebApi.csproj 中,注意项目模板添加的用于实现 OpenAPI 文档的 Microsoft 包引用,如下面的标记所示:

    <PackageReference Include="Microsoft.AspNetCore.OpenApi" /> 
    
  3. Program.cs 中,在添加服务到容器的部分,注意项目模板注册的用于使用 OpenAPI 的服务,如下面的代码所示:

    // Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
    builder.Services.AddOpenApi(); 
    
  4. 默认情况下,文档名称是 v1。将文档名称参数设置为 v2,如下面的代码所示:

    builder.Services.AddOpenApi(documentName: "v2"); 
    
  5. 在配置 HTTP 请求管道的章节中,注意使用 OpenAPI 在开发模式下的语句,如下面的代码所示:

    // Configure the HTTP request pipeline.
    if (builder.Environment.IsDevelopment())
    {
      **app****.MapOpenApi****();**
    } 
    
  6. 使用 https 启动配置启动 Northwind.WebApi 网络服务项目。

  7. 启动 Chrome,导航到 https://localhost:5151/openapi/v2.json,并注意返回的 JSON 文档,如图 15.5 所示:

图片

图 15.5:Northwind 网络服务的 OpenAPI JSON 文档

关于 OpenAPI JSON 文档的以下注意事项:

  • 它指定了服务的所有相对路径,例如:/weatherforecast/{days}

  • 对于像 days 这样的参数,它指定了它们的类型和默认值,例如:"parameters":[{"name":"days","in":"path","required":true,"schema":{"type":"integer","format":"int32","default":5}}]

你可以使用两种技术来向生成的文档添加额外信息,WithSummaryWithDescription。这些技术可以通过在映射端点后调用扩展方法或通过用属性装饰 lambda 表达式来实现,如下面的代码所示:

app.MapGet("/helloUsingMethods", () => "Hello world!")
  .WithSummary("This is a summary.")
  .WithDescription("This is a description.");
app.MapGet("/helloUsingAttributes",
  [EndpointSummary("This is a summary.")]
  [EndpointDescription("This is a description.")]
  () => "Hello world!"); 

更多信息:您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/openapi?view=aspnetcore-9.0#describe-endpoints

启用 HTTP 记录

HTTP 记录是一个可选的中间件组件,在测试网络服务时非常有用。它记录有关 HTTP 请求和 HTTP 响应的信息,包括以下内容:

  • 关于 HTTP 请求的信息

  • 头部

  • 主体

  • 关于 HTTP 响应的信息

这在 Web 服务的审计和调试场景中非常有用,但请注意,它可能会对性能产生负面影响。你也可能会记录 个人身份信息PII),这可能会在某些司法管辖区引起合规性问题。

日志级别可以设置为以下:

  • 错误: 只有 错误 级别的日志

  • 警告: 错误警告 级别的日志

  • 信息: 错误, 警告, 和 信息 级别的日志

  • 详细: 所有级别的日志

可以为定义功能性的命名空间设置日志级别。嵌套的命名空间允许我们控制哪些功能启用了日志记录:

  • Microsoft: 在 Microsoft 命名空间中包含所有日志类型

  • Microsoft.AspNetCore: 在 Microsoft.AspNetCore 命名空间中包含所有日志类型

  • Microsoft.AspNetCore.HttpLogging: 在 Microsoft.AspNetCore.HttpLogging 命名空间中包含所有日志类型

让我们看看 HTTP 日志的实际应用:

  1. Northwind.WebApi 项目中,appsettings.Development.json 文件中添加一个条目来设置 HTTP 日志中间件为 信息 级别,如下面高亮显示的代码所示:

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

    尽管默认的日志级别可能被设置为 信息,但更具体的配置具有优先级。例如,Microsoft.AspNetCore 命名空间中的任何日志系统都将使用 警告 级别。通过我们所做的更改,Microsoft.AspNetCore. HttpLogging.HttpLoggingMiddleware 命名空间中的任何日志系统现在将使用 信息 级别。

  2. Program.cs 中,导入用于处理 HTTP 日志的命名空间,如下面的代码所示:

    using Microsoft.AspNetCore.HttpLogging; // To use HttpLoggingFields. 
    
  3. 在服务配置部分,在调用 Build 之前,添加一个语句来配置 HTTP 日志,如下面的代码所示:

    builder.Services.AddHttpLogging(options =>
    {
      options.LoggingFields = HttpLoggingFields.All;
      options.RequestBodyLogLimit = 4096; // Default is 32k.
      options.ResponseBodyLogLimit = 4096; // Default is 32k.
    }); 
    
  4. 在 HTTP 管道配置部分,在调用 app.UseHttpsRedirection 之前,添加一个语句来添加 HTTP 日志,如下面的代码所示:

    app.UseHttpLogging(); 
    
  5. 使用 https 启动配置启动 Northwind.WebApi 网络服务。

  6. 启动 Chrome 并导航到 https://localhost:5151/customers

  7. 在显示来自网络服务主机输出的命令提示符或终端中,注意请求和响应已经被记录,如下面的部分输出所示:

    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[1]
          Request:
          Protocol: HTTP/2
          Method: GET
          Scheme: https
          PathBase:
          Path: /weatherforecast
          Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
          Host: localhost:5151
          User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36
          Accept-Encoding: gzip, deflate, br
          Accept-Language: en-US,en-GB;q=0.9,en;q=0.8,fr-FR;q=0.7,fr;q=0.6
          Upgrade-Insecure-Requests: [Redacted]
    ...
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[2]
          Response:
          StatusCode: 200
          Content-Type: application/json; charset=utf-8
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[4]
          ResponseBody: [{"date":"2024-08-06","temperatureC":50,"summary":"Freezing","temperatureF":121},{"date":"2024-08-07","temperatureC":53,"summary":"Scorching","temperatureF":127},{"date":"2024-08-08","temperatureC":40,"summary":"Mild","temperatureF":103},{"date":"2024-08-09","temperatureC":8,"summary":"Bracing","temperatureF":46},{"date":"2024-08-10","temperatureC":-10,"summary":"Freezing","temperatureF":15}]
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[8]
          Duration: 6.8678ms 
    
  8. 关闭 Chrome 并关闭网络服务器。

仅记录 Windows 的事件日志

当配置日志时,你可能想要启用将日志记录到 Windows 事件日志,如下面的代码所示:

var builder = Host.CreateApplicationBuilder();
// Option 1
builder.Services.AddLogging(logging =>
{
  logging.AddEventLog();
});
// Option 2
builder.Host.ConfigureLogging(logging =>
{
  logging.AddEventLog();
});
// Option 3: .NET 6 or later. Concise and recommended by Microsoft.
builder.Logging.AddEventLog(); 

你将看到一个代码分析器警告,CA1416,因为仅启用事件日志在 Windows 上有效。如果你在其他操作系统上运行此代码,则会抛出运行时异常。为了避免警告(以及运行时错误),你应该使用 OS 检查来包裹对 AddEventLog 的调用。

首先,导入一个命名空间,如下面的代码所示:

using System.Runtime.InteropServices; // To use RuntimeInformation. 

然后,将任何对 AddEventLog 的调用包裹起来,如下面的代码所示:

if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
  // Call the AddEventLog method.
} 

W3CLogger 支持记录额外的请求头

W3CLogger 是一个中间件,它以 W3C 标准格式写入日志。你可以:

  • 记录 HTTP 请求和响应的详细信息。

  • 过滤记录哪些头信息和请求/响应消息的部分。

警告! W3CLogger 可能会降低应用程序的性能。

W3CLogger 类似于 HTTP 日志记录,因此在这本书中我不会详细说明如何使用它。您可以在以下链接中了解更多关于 W3CLogger 的信息:learn.microsoft.com/en-us/aspnet/core/fundamentals/w3c-logger/.

在 ASP.NET Core 7 或更高版本中,您可以在使用 W3CLogger 时指定要记录额外的请求头。调用 AdditionalRequestHeaders 方法并传递您想要记录的头名称,如下面的代码所示:

services.AddW3CLogging(options =>
{
  options.AdditionalRequestHeaders.Add("x-forwarded-for");
  options.AdditionalRequestHeaders.Add("x-client-ssl-protocol");
}); 

您现在可以构建并运行使用您的 Web 服务的应用程序。

使用 HTTP 客户端消费 Web 服务

现在我们已经构建并测试了我们的 Northwind 服务,我们将学习如何使用 HttpClient 类及其工厂从任何 .NET 应用程序中调用它。

理解 HttpClient

消费 Web 服务最简单的方法是使用 HttpClient 类。然而,许多人错误地使用它,因为它实现了 IDisposable 接口,并且微软自己的文档显示了其不良的使用方式。请参阅 GitHub 仓库中的书籍链接,以获取更多关于此问题的讨论文章。

通常,当一个类型实现了 IDisposable 接口时,您应该在其内部创建它,以确保它能够尽快被销毁。HttpClient 是不同的,因为它是可以共享的、可重入的,并且部分线程安全。

问题与如何管理底层网络套接字有关。底线是,您应该在应用程序的生命周期内为每个您消费的 HTTP 端点使用单个实例。这将允许每个 HttpClient 实例具有适合其工作的默认设置,同时有效地管理底层网络套接字。

配置 HTTP 客户端

微软已经意识到 .NET 开发者误用 HttpClient 的问题,并在 ASP.NET Core 2.1 中引入了 HttpClientFactory 以鼓励最佳实践;这正是我们将要使用的技术。

在以下示例中,我们将创建一个 Northwind Blazor WebAssembly 独立项目作为 Northwind Web API 服务的客户端。让我们配置一个 HTTP 客户端:

  1. 使用您首选的代码编辑器打开 ModernWeb 解决方案,然后添加一个新项目,如下所示:

    • 项目模板:Blazor WebAssembly 独立应用程序/ blazorwasm

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

    • 项目文件和文件夹:Northwind.WebApi.WasmClient

    • 认证类型:无

    • 配置为 HTTPS:已选择

    • 渐进式 Web 应用程序:已清除

    • 包含示例页面:已选择

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

  2. Northwind.WebApi.WasmClient.csproj 项目文件中,在包引用中删除版本属性。

  3. Properties 文件夹中,在 launchSettings.json 文件中,对于 https 配置文件,对于其 applicationUrl,将 HTTPS 的随机端口号更改为 5152,对于 HTTP 更改为 5153,如下所示(高亮显示):

    "applicationUrl": "https://localhost:**5152**;http://localhost:**5153**", 
    
  4. 保存所有修改过的文件。

  5. Program.cs 文件中,在调用 AddScoped 方法的地方,添加一条语句以启用 HttpClientFactory 的命名客户端,通过 HTTPS 在端口 5151 上调用 Northwind Web API 服务,并将 JSON 作为默认的响应格式,如下所示:

    builder.Services.AddScoped(sp => new HttpClient {
      BaseAddress = new Uri("https://localhost:5151/") }); 
    
  6. Northwind.WebApi 项目中,在 Program.cs 文件中,在命名空间导入之后,声明一个字符串常量用于 CORS 策略的名称,如下所示:

    const string corsPolicyName = "allowWasmClient"; 
    
  7. Program.cs 文件中,在调用 Build 之前,添加 CORS 并配置策略以允许来自与 Web 服务自身不同端口号的客户端的 HTTP 调用,如下所示:

    builder.Services.AddCors(options =>
    {
      options.AddPolicy(name: corsPolicyName,
        policy =>
        {
          policy.WithOrigins("https://localhost:5152",
            "http://localhost:5153");
        });
    }); 
    
  8. Program.cs 文件中,在调用 UseHttpsRedirection 之后,使用命名策略启用 CORS,如下所示:

    app.UseCors(corsPolicyName); 
    

在 Blazor 组件中以 JSON 格式获取客户

我们现在可以创建一个客户端页面:

  • 对客户进行 GET 请求。

  • 使用 .NET 5 中引入的方便的扩展方法,在 System.Net.Http.Json 程序集和命名空间中反序列化 JSON 响应。

让我们开始吧:

  1. Northwind.WebApi.WasmClient.csproj 项目文件中,添加对实体模型项目的引用,如下所示:

    <ItemGroup>
      <ProjectReference Include=
    "..\Northwind.EntityModels.Sqlite\Northwind.EntityModels.Sqlite.csproj" />
    </ItemGroup> 
    
  2. Northwind.WebApi.WasmClient 项目中,在 _Imports.razor 文件中,导入用于处理实体模型的命名空间,如下所示:

    @using Northwind.EntityModels @* To use Customer. *@ 
    
  3. Northwind.WebApi.WasmClient 项目中,在 Pages 文件夹中,添加一个名为 Customers.razor 的新文件。

  4. Customers.razor 文件中,注入 HTTP 客户端服务,并使用它调用 Northwind Web API 服务,获取所有客户,并将它们传递到一个表格中,如下所示:

    @attribute [StreamRendering]
    @page "/customers/{country?}"
    @inject HttpClient Http
    <h3>
      Customers @(string.IsNullOrWhiteSpace(Country)
        ? "Worldwide" : "in " + Country)
    </h3>
    @if (customers is null)
    {
      <p><em>Loading...</em></p>
    }
    else
    {
      <table class="table">
        <thead>
          <tr>
            <th>Id</th>
            <th>Company Name</th>
            <th>Address</th>
            <th>Phone</th>
            <th></th>
          </tr>
        </thead>
        <tbody>
          @foreach (Customer c in customers)
          {
            <tr>
              <td>@c.CustomerId</td>
              <td>@c.CompanyName</td>
              <td>
                @c.Address<br />
                @c.City<br />
                @c.PostalCode<br />
                @c.Country
              </td>
              <td>@c.Phone</td>
            </tr>
          }
        </tbody>
      </table>
    }
    @code {
      [Parameter]
      public string? Country { get; set; }
      private IEnumerable<Customer>? customers;
      protected override async Task OnParametersSetAsync()
      {
        if (string.IsNullOrWhiteSpace(Country))
        {
          customers = await Http.GetFromJsonAsync
            <Customer[]>("/customers");
        }
        else
        {
          customers = await Http.GetFromJsonAsync
            <Customer[]>($"/customers/in/{Country}");
        }
      }
    } 
    
  5. Layout 文件夹中,在 NavMenu.razor 文件中,将 天气 菜单项更改为显示客户,如下所示:

    <NavLink class="nav-link" href="customers">
      <span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Customers
    </NavLink> 
    

启动多个项目

到目前为止,我们一次只启动了一个项目。现在我们有两个项目需要启动,一个是 Web 服务,另一个是 Blazor 客户端网站。在逐步说明中,我将只告诉您逐个启动单个项目,但您应该使用您喜欢的任何技术来启动它们。

如果您正在使用 Visual Studio

Visual Studio 可以在不附加调试器的情况下手动逐个启动多个项目,具体步骤如下:

  1. 解决方案资源管理器 中,右键单击解决方案或任何项目,然后选择 配置启动项目…,或者选择解决方案并导航到 项目 | 配置启动项目…

  2. 解决方案属性页 对话框中,选择 当前选择

  3. 点击 确定

  4. 解决方案资源管理器 中选择一个项目,使其名称变为粗体。

  5. 导航到调试 | 启动而不调试或按 Ctrl + F5

  6. 重复步骤 2步骤 3,直到你需要的项目数量。

如果你需要调试项目,那么你必须启动多个 Visual Studio 实例。每个实例可以启动一个带调试的单个项目。

你也可以按照以下步骤配置多个项目同时启动:

  1. 解决方案资源管理器中,右键单击解决方案或任何项目,然后选择配置启动项目…,或者选择解决方案并导航到项目 | 配置启动项目…

  2. “解决方案 '' 属性页”对话框中,选择多个启动项目,对于你想要启动的任何项目,选择启动启动而不调试,如图15.6所示:

图 15.6:在 Visual Studio 中选择要启动的多个项目

  1. 点击确定

  2. 导航到调试 | 开始调试调试 | 启动而不调试,或者在工具栏中点击等效按钮以启动你选择的所有项目。

你可以在以下链接中了解更多关于使用 Visual Studio 进行多项目启动的信息:learn.microsoft.com/en-us/visualstudio/ide/how-to-set-multiple-startup-projects.

如果你正在使用 VS Code

如果你需要使用 dotnet 在命令行中启动多个项目,那么请编写一个脚本来执行多个 dotnet run 命令,或者打开多个命令提示符或终端窗口。

如果你需要使用 VS Code 调试多个项目,那么在你启动第一个调试会话后,你可以启动另一个会话。一旦第二个会话开始运行,用户界面将切换到多目标模式。例如,在调用栈中,你会看到带有自己线程的命名项目,然后调试工具栏显示一个包含活动会话的下拉列表。或者,你可以在 launch.json 中定义复合启动配置。

你可以在以下链接中了解更多关于使用 VS Code 进行多目标调试的信息:code.visualstudio.com/Docs/editor/debugging#_multitarget-debugging.

启动 Web 服务和 Blazor 客户端项目

现在我们可以尝试使用 Blazor 客户端调用 Web 服务:

  1. 启动 Northwind.WebApi 项目,并确认 Web 服务正在监听端口 51515150,如下所示输出:

    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5151
    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5150 
    
  2. 启动 Northwind.WebApi.WasmClient 项目,并确认网站正在监听端口 51525153,如下所示输出:

    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5152
    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5153 
    
  3. 启动 Chrome 并导航到 https://localhost:5152/

  4. 在主页上,在左侧导航菜单中,点击客户,并注意客户列表,如图15.7所示:

图 15.7:从 Web 服务获取的全球客户

  1. 在 Web 服务的命令提示符或终端中,注意 HTTP 日志显示已成功请求客户,如下所示:

    info: Microsoft.EntityFrameworkCore.Database.Command[20101]
          Executed DbCommand (20ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
          SELECT "c"."CustomerId", "c"."Address", "c"."City", "c"."CompanyName", "c"."ContactName", "c"."ContactTitle", "c"."Country", "c"."Fax", "c"."Phone", "c"."PostalCode", "c"."Region"
          FROM "Customers" AS "c"
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[2]
          Response:
          StatusCode: 200
          Content-Type: application/json; charset=utf-8
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[4]
          ResponseBody: [{"customerId":"ALFKI","companyName":"Alfreds Futterkiste","contactName":"Maria Anders","contactTitle":"Sales Representative","address":"Obere Str. 57","city":"Berlin","region":null,"postalCode":"12209","country":"Germany","phone":"030-0074321","fax":"030-0076545","orders":[]},...
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[8]
          Duration: 1039.4409ms 
    
  2. 在地址栏中,将路径更改为指定国家,例如 GermanyUKUSA,例如:customers/UK。按 Enter 并注意表格更新,只显示 UK 客户。

  3. 关闭 Chrome 并关闭两个 Web 服务器。

练习和探索

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

练习 15.1 – 在线材料

网络材料可以由微软或第三方创建,或为本书提供额外内容。

实现 Web 服务的高级功能

如果你想要了解有关 Web 服务健康检查、OpenAPI 分析器、添加安全 HTTP 标头以及为 HttpClient 启用 HTTP/3 支持的信息,那么你可以阅读以下链接中的可选在线部分:

github.com/markjprice/cs13net9/blob/main/docs/ch15-advanced.md

Minimal APIs 参数绑定

你可以在以下链接中了解更多关于如何将 HTTP 请求数据转换为用于 Minimal APIs 端点的强类型参数的信息:

learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding

Refit 客户端

Refit 是一个自动的、类型安全的 .NET REST 库。它受到了 Square 的 Retrofit 库的启发,并将你的 REST 服务转换为一个实时接口。

github.com/reactiveui/refit

使用 Microsoft Identity 的 Web 服务安全

你可以在以下链接中了解有关 Microsoft Identity 用于身份验证和授权的新功能:

devblogs.microsoft.com/dotnet/whats-new-with-identity-in-dotnet-8/

练习 15.2 – 实践练习

实践练习深入探讨了本章的主题。

使用 HttpClient 创建和删除客户

Northwind.WebApi.ClientWasm 项目扩展,使其包含访客可以填写表单以创建新客户或搜索客户然后删除他们的页面。Blazor 组件应调用 Northwind Web API Web 服务以创建和删除客户。

练习 15.3 – 测试你的知识

回答以下问题:

  1. ASP.NET Core 提供了多个项目模板用于构建 Web 服务。它们是什么,以及如何使用 CLI 创建它们?

  2. 当配置 HTTP 客户端时,你如何指定你希望从 Web 服务响应中获取的数据格式?

  3. 为什么 ASP.NET Core 团队用他们自己的 OpenAPI 支持实现替换了 Swashbuckle 包?

  4. 在调用MapGet或类似方法时,你应该如何指定期望得到的响应?

  5. 列出三种可以调用以返回不同状态码的响应的方法。

  6. 列出四种你可以测试 Web 服务的方法。

  7. 尽管HttpClient实现了IDisposable接口,但你为什么不应该在完成使用后用using语句来释放它,而应该使用什么代替?

  8. 与 HTTP/1.1 相比,HTTP/2 和 HTTP/3 有哪些好处?

  9. 你如何使用 ASP.NET Core 2.2 及更高版本使客户端能够检测你的 Web 服务是否健康?

  10. 对象缓存的主要类型有哪些,为什么.NET 9 中引入的HybridCache是最好的?

练习 15.4 – 探索主题

使用以下 GitHub 仓库中的链接了解更多关于本章涵盖主题的详细信息:

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-15---building-and-consuming-web-services

摘要

在本章中,你学习了:

  • 如何使用 Minimal APIs 构建 ASP.NET Core Web 服务,这样任何可以发起 HTTP 请求并处理 HTTP 响应的平台上的应用程序都可以调用它

  • 如何使用 OpenAPI 记录 Web 服务 API

  • 如何使用浏览器、HTTP 编辑器或 REST 客户端测试 Web 服务?

  • 如何高效地消费服务

结语中,我将提出一些建议,帮助你更深入地了解 C#和.NET。

第十六章:结语

我希望这本书与市场上的其他书籍有所不同。我希望您发现它读起来轻松愉快,内容丰富,包含了每个主题的实用、动手实践指南。

这篇结语包含以下简短部分:

  • C# 和 .NET 学习之旅的下一步

  • .NET 10 的第十版

  • 祝好运!

C# 和 .NET 学习之旅的下一步

对于我在本书中没有空间涵盖但您可能想了解更多内容的主题,我希望 GitHub 仓库中的笔记、良好实践技巧和链接能为您指明正确的方向:

github.com/markjprice/cs13net9/blob/main/docs/book-links.md

使用设计指南来磨练你的技能

现在您已经学会了使用 C# 和 .NET 进行开发的基础知识,您可以通过学习更详细的设计指南来提高您代码的质量。

在早期 .NET Framework 时代,微软出版了一本涵盖 .NET 开发所有领域的良好实践的书。这些推荐对现代 .NET 开发仍然非常适用。

以下主题将得到涵盖:

  • 命名指南

  • 类型设计指南

  • 成员设计指南

  • 为可扩展性设计

  • 异常设计指南

  • 使用指南

  • 常见设计模式

为了使指南尽可能易于遵循,推荐被简单地标记为 Do(做)、Consider(考虑)、Avoid(避免)和 Do not(不要)。

微软已将本书的摘录发布在以下链接:

learn.microsoft.com/en-us/dotnet/standard/design-guidelines/

我强烈建议您回顾所有指南并将其应用于您的代码。

继续你的学习之旅的伴侣书籍

我已经写了三本新书,以继续使用 .NET 8 和 .NET 9 的学习之旅,这些学习之旅是从这本基础知识书籍开始的。

图 16.1 展示了 .NET 8 和 .NET 9 四本书的总结及其最重要的主题:

图 16.1:学习 C# 和 .NET 的伴侣书籍

其他书籍作为本书的伴侣,共同构成了一个 .NET 8 和 .NET 9 四本书系列:

  1. 第一本书(您现在正在阅读的)涵盖了现代网络开发中 C#、.NET 和 ASP.NET Core 的基础知识。.NET 9 的第九版是对第 8 版的小幅更新,因为 .NET 9 是一个标准术语支持版本(只有 18 个月)。它包括了自第 8 版出版以来列出的勘误和改进,可以在以下链接中找到:github.com/markjprice/cs12dotnet8/blob/main/docs/errata/README.md

  2. 第二本书涵盖了使用.NET 9 的实战网络开发。这意味着它涵盖了成熟且经过验证的技术,并基于控制器架构。这包括 ASP.NET Core MVC、使用控制器的 Web API、OData 以及最受欢迎的.NET CMS,Umbraco。

  3. 第三本书涵盖了你应该学习的重要工具和技能,以成为一名全面的专业.NET 开发者。这些包括设计模式和解构架构、调试、内存分析、从单元和集成到性能和 Web UI 测试的所有重要测试类型,以及像 Docker 和.NET Aspire 这样的主题。最后,它探讨了如何准备面试以获得你想要的.NET 开发者职业生涯。这本书将不会更新为.NET 9。

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

要查看我通过 Packt 出版的所有书籍的列表,你可以使用以下链接:

subscription.packtpub.com/search?query=mark+j.+price

其他书籍以进一步深化你的学习

如果你正在寻找我的出版商出版的其他相关主题的书籍,有很多可供选择,如图图 16.2所示:

图 16.2:Packt 书籍以进一步深化你的 C#和.NET 学习

你还可以在以下链接的 GitHub 仓库中找到 Packt 书籍列表:

github.com/markjprice/cs13dotnet9/blob/main/docs/book-links.md#learn-from-other-packt-books)

.NET 10 的第十版

我已经开始着手确定第十版的改进领域,我们计划在更新我的.NET 书籍四部曲的其他成员的同时发布。我预计.NET 10 将对.NET 的所有方面做出有价值的改进,并且作为一个长期支持LTS)版本,它将得到支持直到 2028 年 11 月。

你可以通过这本书学习如何使用.NET 10,包括从 2025 年 2 月开始提供的.NET 10 预览版,以下链接:

github.com/markjprice/cs13net9/blob/main/docs/dotnet10.md

如果您有希望看到涵盖或扩展的话题建议,或者您在文本或代码中发现了需要修复的错误,请通过 Discord 频道或本书的 GitHub 仓库的聊天告诉我详细信息,该仓库的链接如下:

github.com/markjprice/cs13net9

祝您好运!

我祝您在所有 C# 和 .NET 项目中一切顺利!

在 Discord 上了解更多信息

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

packt.link/csharp13dotnet9

留下评论!

感谢您从 Packt Publishing 购买这本书——我们希望您喜欢它!您的反馈对我们来说无价,它帮助我们改进和成长。请花一点时间留下一个 Amazon 评论;这只需一分钟,但对像您这样的读者来说意义重大。

扫描下面的二维码以获得您选择的免费电子书。

一个带有黑色方块的二维码  自动生成的描述

https://packt.link/NzOWQ


  1. 0-9 ↩︎

  2. A-Z ↩︎

  3. A-Za-z ↩︎

  4. A-Z ↩︎

  5. A-Za-z\u00c0-\u017e ↩︎

posted @ 2025-10-22 10:33  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报