C-10-和--NET6-代码跨平台开发-全-

C#10 和 .NET6 代码跨平台开发(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

本书与众不同,它简洁明了,旨在成为一本轻松愉快的读物,充满每个主题的实用动手演练。虽然整体叙述的广度牺牲了一些深度,但你会发现许多标志指引你进一步探索,如果你愿意的话。

本书既是一本学习现代 C#实践的逐步指南,使用跨平台的.NET,也是对主要实用应用程序类型的简要介绍,这些应用程序可以用它们构建。本书最适合 C#和.NET 的初学者,或者那些在过去使用过 C#但感觉被过去几年变化所落后的程序员。

如果你已有 C#旧版本的经验,那么在第二章第一节,*说 C#*中,你可以查看新语言特性的表格并直接跳转到它们。

如果你已有.NET 库旧版本的经验,那么在第七章第一节打包和分发.NET 类型中,你可以查看新库特性的表格并直接跳转到它们。

我将指出 C#和.NET 的酷炫角落和陷阱,让你能给同事留下深刻印象并快速提高生产力。与其放慢速度并让一些读者感到无聊,通过解释每一个小细节,我会假设你足够聪明,能够通过谷歌搜索解释与主题相关但不必包含在印刷书籍有限空间内的初学者到中级指南中。

代码解决方案的获取位置

你可以在以下链接的 GitHub 仓库中下载逐步指导任务和练习的解决方案:github.com/markjprice/cs10dotnet6

如果你不知道如何操作,我会在第一章,*你好,C#!欢迎,.NET!*的末尾提供操作指南。

本书内容涵盖

第一章你好,C#!欢迎,.NET!,是关于设置你的开发环境,并使用 Visual Studio 或 Visual Studio Code 创建最简单的 C#和.NET 应用程序。对于简化的控制台应用,你将看到 C# 9 引入的顶级程序特性的使用。为了学习如何编写简单的语言结构和库特性,你将看到.NET 交互式笔记本的使用。你还将了解一些寻求帮助的好地方,以及通过 GitHub 仓库与我联系以获取解决问题或提供反馈以改进本书和未来版本的方法。

第二章说 C#,介绍了 C#的版本,并提供了表格显示哪些版本引入了新特性。我解释了日常编写应用程序源代码所需的语法和词汇。特别是,你将学习如何声明和操作不同类型的变量。

第三章控制流程、类型转换和异常处理,涵盖了使用运算符对变量执行简单操作,包括比较,编写决策代码,C# 7 到 C# 10 中的模式匹配,重复语句块,以及类型之间的转换。它还涵盖了编写防御性代码以处理不可避免发生的异常。

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

第五章使用面向对象编程构建自己的类型,讨论了类型可以拥有的所有不同类别的成员,包括用于存储数据的字段和用于执行操作的方法。你将运用面向对象编程OOP)的概念,如聚合和封装。你将了解诸如元组语法支持、out变量、默认字面量和推断元组名称等语言特性,以及如何使用 C# 9 中引入的record关键字、仅初始化属性以及with表达式定义和操作不可变类型。

第六章实现接口和继承类,解释了使用 OOP 从现有类型派生新类型。你将学习如何定义运算符和局部函数、委托和事件,如何实现关于基类和派生类的接口,如何重写类型的成员,如何使用多态性,如何创建扩展方法,如何在继承层次结构中进行类之间的转换,以及 C# 8 中引入可空引用类型的大变化。

第七章打包和分发.NET 类型,介绍了.NET 的版本,并提供了表格显示哪些版本引入了新的库特性,然后介绍了符合.NET 标准的.NET 类型以及它们与 C#的关系。你将学习如何在支持的操作系统(Windows、macOS 和 Linux 变体)上编写和编译代码。你将学习如何打包、部署和分发你自己的应用程序和库。

第八章使用常见的.NET 类型,讨论了使你的代码能够执行常见实际任务的类型,例如操作数字和文本、日期和时间、在集合中存储项目、处理网络和操作图像,以及实现国际化。

第九章使用文件、流和序列化,涵盖了与文件系统交互、读写文件和流、文本编码以及 JSON 和 XML 等序列化格式,包括System.Text.Json类增强的功能和性能。

第十章使用 Entity Framework Core 处理数据,讲解了如何使用名为实体框架核心EF Core)的对象关系映射ORM)技术读写关系数据库,如 Microsoft SQL Server 和 SQLite。你将学习如何定义映射到数据库中现有表的实体模型,以及如何定义可以在运行时创建表和数据库的 Code First 模型。

第十一章使用 LINQ 查询和操作数据,教授你关于语言集成查询LINQ)——这些语言扩展增加了处理项目序列、过滤、排序并将它们投射到不同输出的能力。你将了解并行 LINQPLINQ)和 LINQ to XML 的特殊功能。

第十二章使用多任务提高性能和可扩展性,讨论了允许同时发生多个动作以提高性能、可扩展性和用户生产率的方法。你将学习async Main特性以及如何使用System.Diagnostics命名空间中的类型来监控你的代码,以衡量性能和效率。

第十三章介绍 C#和.NET 的实际应用,向你介绍了可以使用 C#和.NET 构建的跨平台应用程序类型。你还将构建一个 EF Core 模型来表示 Northwind 数据库,该数据库将在本书的其余章节中使用。

第十四章使用 ASP.NET Core Razor Pages 构建网站,讲述了如何利用现代 HTTP 架构在服务器端使用 ASP.NET Core 学习网站构建的基础知识。你将学习如何实现 ASP.NET Core 的 Razor Pages 特性,该特性简化了为小型网站创建动态网页的过程,以及构建 HTTP 请求和响应管道的知识。

第十五章使用模型-视图-控制器模式构建网站,讲述了如何使用 ASP.NET Core MVC 以易于单元测试和管理团队编程的方式构建大型复杂网站。你将学习启动配置、认证、路由、模型、视图和控制器。

第十六章构建和消费 Web 服务,解释了使用 ASP.NET Core Web API 构建后端 REST 架构的 Web 服务以及如何正确使用工厂实例化的 HTTP 客户端消费它们。

第十七章使用 Blazor 构建用户界面,介绍如何使用 Blazor 构建可在服务器端或客户端 Web 浏览器内执行的 Web 用户界面组件。您将了解 Blazor Server 和 Blazor WebAssembly 之间的差异,以及如何构建易于在这两种托管模型之间切换的组件。

三篇在线附加章节为本版增色不少。您可以在static.packt-cdn.com/downloads/9781801077361_Bonus_Content.pdf阅读以下章节及附录:

第十八章构建和消费专业化服务,向您介绍使用 gRPC 构建服务,使用 SignalR 实现服务器与客户端之间的实时通信,通过 OData 公开 EF Core 模型,以及在云中托管响应触发器的函数使用 Azure Functions。

第十九章使用.NET MAUI 构建移动和桌面应用,向您介绍如何为 Android、iOS、macOS 和 Windows 构建跨平台的移动和桌面应用。您将学习 XAML 的基础知识,这是一种用于定义图形应用用户界面的语言。

第二十章保护您的数据和应用,涉及使用加密保护数据不被恶意用户查看,使用哈希和签名防止数据被篡改或损坏。您还将学习认证和授权以保护应用免受未授权用户的侵害。

附录测试题答案,提供了每章末尾测试题的答案。

本书所需条件

您可以在包括 Windows、macOS 和多种 Linux 在内的多个平台上使用 Visual Studio Code 开发和部署 C#和.NET 应用。

除了一个章节外,您只需一个支持 Visual Studio Code 的操作系统和互联网连接即可完成所有章节。

如果您更喜欢使用 Windows 或 macOS 上的 Visual Studio,或是第三方工具如 JetBrains Rider,那么您可以这么做。

您需要 macOS 来构建第十九章使用.NET MAUI 构建移动和桌面应用中的 iOS 应用,因为编译 iOS 应用必须要有 macOS 和 Xcode。

下载本书的彩色图像

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

您可以从static.packt-cdn.com/downloads/9781801077361_ColorImages.pdf下载此文件。

约定

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

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 

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

重要提示和指向外部进一步阅读资源的链接以这种框的形式出现。

良好实践:专家编程建议以这种方式出现。

第一章:你好,C#!欢迎,.NET!

在本章中,目标包括设置开发环境,理解现代.NET、.NET Core、.NET Framework、Mono、Xamarin 和.NET Standard 之间的异同,使用 C# 10 和.NET 6 以及各种代码编辑器创建最简单的应用程序,然后找到寻求帮助的好地方。

本书的 GitHub 仓库提供了所有代码任务的完整应用程序项目解决方案,并在可能的情况下提供笔记本:

github.com/markjprice/cs10dotnet6

只需按下.(点)键或在上述链接中将.com更改为.dev,即可将 GitHub 仓库转换为使用 Visual Studio Code for the Web 的实时编辑器,如图 1.1所示:

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

图 1.1: Visual Studio Code for the Web 正在实时编辑本书的 GitHub 仓库

这非常适合在你阅读本书并完成编程任务时与你的首选代码编辑器并行使用。你可以将自己的代码与解决方案代码进行比较,并在需要时轻松复制和粘贴部分代码。

本书中,我使用术语现代.NET来指代.NET 6 及其前身,如.NET 5 等源自.NET Core 的版本。而术语传统.NET则用来指代.NET Framework、Mono、Xamarin 和.NET Standard。现代.NET 是对这些传统平台和标准的统一。

本章之后,本书可分为三个部分:首先是 C#语言的语法和词汇;其次是.NET 中用于构建应用特性的类型;最后是使用 C#和.NET 构建的常见跨平台应用示例。

大多数人通过模仿和重复来最好地学习复杂主题,而不是通过阅读详细的理论解释;因此,本书不会用每一步的详细解释来让你负担过重。目的是让你动手编写代码并看到运行结果。

你不需要立即了解所有细节。随着你构建自己的应用程序并超越任何书籍所能教授的内容,这些知识将会逐渐积累。

正如 1755 年编写英语词典的塞缪尔·约翰逊所言,我已犯下“一些野蛮的错误和可笑的荒谬,任何如此繁多的作品都无法免俗。”我对此负全责,并希望你能欣赏我试图通过撰写关于 C#和.NET 等快速发展的技术以及使用它们构建的应用程序的书籍来挑战风车的尝试。

本章涵盖以下主题:

  • 设置开发环境

  • 理解.NET

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

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

  • 使用.NET 交互式笔记本探索代码

  • 审查项目文件夹和文件

  • 充分利用本书的 GitHub 仓库

  • 寻求帮助

设置你的开发环境

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

  • Visual Studio 2022 for Windows

  • Visual Studio 2022 for Mac

  • Visual Studio Code for Windows, Mac, or Linux

  • GitHub Codespaces

第三方已经创建了自己的 C#代码编辑器,例如,JetBrains Rider。

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

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

在学习时,最好的工具是帮助你编写代码和配置但不隐藏实际发生的事情的工具。IDE 提供了友好的图形用户界面,但它们在背后为你做了什么?一个更基础的代码编辑器,在提供帮助编写代码的同时更接近操作,在你学习时更为合适。

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

在本书第三版中,我为所有编码任务提供了针对 Windows 的 Visual Studio 和适用于所有平台的 Visual Studio Code 的详细分步指导。不幸的是,这很快就变得杂乱无章。在第六版中,我仅在第一章中提供了关于如何在 Windows 的 Visual Studio 2022 和 Visual Studio Code 中创建多个项目的详细分步指导。之后,我会给出项目名称和适用于所有工具的一般指导,以便你可以使用你偏好的任何工具。

学习 C#语言结构和许多.NET 库的最佳应用程序类型是不被不必要的应用程序代码分散注意力的类型。例如,没有必要为了学习如何编写一个switch语句而创建一个完整的 Windows 桌面应用程序或网站。

因此,我相信学习第一章第十二章中 C#和.NET 主题的最佳方法是构建控制台应用程序。然后,从第十三章第十九章开始,你将构建网站、服务以及图形桌面和移动应用。

.NET Interactive Notebooks 扩展的优缺点

Visual Studio Code 的另一个好处是.NET Interactive Notebooks 扩展。这个扩展提供了一个简单且安全的地方来编写简单的代码片段。它允许你创建一个单一的笔记本文件,其中混合了 Markdown(格式丰富的文本)和使用 C#及其他相关语言(如 PowerShell、F#和 SQL(用于数据库))的代码“单元格”。

然而,.NET Interactive Notebooks 确实有一些限制:

  • 它们无法从用户那里读取输入,例如,你不能使用ReadLineReadKey

  • 它们不能接受参数传递。

  • 它们不允许你定义自己的命名空间。

  • 它们没有任何调试工具(但未来将会提供)。

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

最现代且轻量级的代码编辑器选择,也是微软唯一一款跨平台的编辑器,是 Microsoft Visual Studio Code。它可以在所有常见的操作系统上运行,包括 Windows、macOS 以及多种 Linux 发行版,如 Red Hat Enterprise Linux(RHEL)和 Ubuntu。

Visual Studio Code 是现代跨平台开发的不错选择,因为它拥有一个庞大且不断增长的扩展集合,支持多种语言,而不仅仅是 C#。

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

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

Visual Studio Code 支持 ARM 处理器,因此你可以在 Apple Silicon 计算机和 Raspberry Pi 上进行开发。

Visual Studio Code 是目前最受欢迎的集成开发环境,根据 Stack Overflow 2021 调查,超过 70%的专业开发者选择了它。

使用 GitHub Codespaces 进行云端开发

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

使用 Visual Studio for Mac 进行常规开发

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

要为苹果操作系统如 iOS 编译应用,使其能在 iPhone 和 iPad 等设备上运行,你必须拥有 Xcode,而它仅能在 macOS 上运行。

使用 Visual Studio for Windows 进行常规开发

Microsoft Visual Studio 2022 for Windows 可以创建大多数类型的应用程序,包括控制台应用、网站、Web 服务、桌面和移动应用。尽管你可以使用 Visual Studio 2022 for Windows 配合其 Xamarin 扩展来编写跨平台移动应用,但你仍然需要 macOS 和 Xcode 来编译它。

它仅能在 Windows 上运行,版本需为 7 SP1 或更高。你必须在 Windows 10 或 Windows 11 上运行它,以创建通用 Windows 平台UWP)应用,这些应用通过 Microsoft Store 安装,并在沙盒环境中运行以保护你的计算机。

我所使用的

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

  • HP Spectre(Intel)笔记本电脑

  • Apple Silicon Mac mini(M1)台式机

  • Raspberry Pi 400(ARM v8)台式机

我还使用了以下软件:

  • Visual Studio Code 运行于:

    • 在搭载 Apple Silicon M1 芯片的 Mac mini 台式机上运行的 macOS

    • Windows 10 系统下的 HP Spectre(Intel)笔记本电脑

    • Raspberry Pi 400 上的 Ubuntu 64

  • Visual Studio 2022 for Windows 适用于:

    • HP Spectre(Intel)笔记本电脑上的 Windows 10
  • Visual Studio 2022 for Mac 适用于:

    • Apple Silicon Mac mini(M1)桌面上的 macOS

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

更多信息:您可以通过阅读我撰写的一篇额外文章,了解如何使用 Raspberry Pi 400 和 Ubuntu Desktop 64 位编写 C#和.NET 代码,链接如下:github.com/markjprice/cs9dotnet5-extras/blob/main/raspberry-pi-ubuntu64/README.md.

跨平台部署

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

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

  • Windows: Windows 7 SP1 或更高版本。Windows 10 版本 1607 或更高版本,包括 Windows 11。Windows Server 2012 R2 SP1 或更高版本。Nano Server 版本 1809 或更高版本。

  • Mac: macOS Mojave(版本 10.14)或更高版本。

  • Linux: Alpine Linux 3.13 或更高版本。CentOS 7 或更高版本。Debian 10 或更高版本。Fedora 32 或更高版本。openSUSE 15 或更高版本。Red Hat Enterprise Linux(RHEL)7 或更高版本。SUSE Enterprise Linux 12 SP2 或更高版本。Ubuntu 16.04、18.04、20.04 或更高版本。

  • Android: API 21 或更高版本。

  • iOS: 10 或更高版本。

.NET 5 及更高版本中的 Windows ARM64 支持意味着您可以在 Windows ARM 设备(如 Microsoft Surface Pro X)上进行开发和部署。但在 Apple M1 Mac 上使用 Parallels 和 Windows 10 ARM 虚拟机进行开发显然速度快两倍!

下载并安装 Windows 版 Visual Studio 2022

许多专业的微软开发人员在其日常开发工作中使用 Windows 版 Visual Studio 2022。即使您选择使用 Visual Studio Code 完成本书中的编码任务,您也可能希望熟悉 Windows 版 Visual Studio 2022。

如果您没有 Windows 计算机,则可以跳过此部分,继续到下一部分,在那里您将下载并安装 macOS 或 Linux 上的 Visual Studio Code。

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

  1. 从以下链接下载适用于 Windows 的 Microsoft Visual Studio 2022 版本 17.0 或更高版本:visualstudio.microsoft.com/downloads/.

  2. 启动安装程序。

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

    • ASP.NET 和 Web 开发

    • Azure 开发

    • .NET 桌面开发

    • 使用 C++进行桌面开发

    • 通用 Windows 平台开发

    • 使用.NET 进行移动开发

  4. 单个组件标签页的代码工具部分,选择以下内容:

    • 类设计器

    • Git for Windows

    • PreEmptive Protection - Dotfuscator

  5. 点击安装,等待安装程序获取所选软件并完成安装。

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

  7. 首次运行 Visual Studio 时,系统会提示您登录。如果您已有 Microsoft 账户,可直接使用该账户登录。若没有,请通过以下链接注册新账户:signup.live.com/

  8. 首次运行 Visual Studio 时,系统会提示您配置环境。对于开发设置,选择Visual C#。至于颜色主题,我选择了蓝色,但您可以根据个人喜好选择。

  9. 如需自定义键盘快捷键,请导航至工具 | 选项…,然后选择键盘部分。

Microsoft Visual Studio for Windows 键盘快捷键

本书中,我将避免展示键盘快捷键,因为它们常被定制。在跨代码编辑器且常用的情况下,我会尽量展示。如需识别和定制您的键盘快捷键,可参考以下链接:docs.microsoft.com/en-us/visualstudio/ide/identifying-and-customizing-keyboard-shortcuts-in-visual-studio

下载并安装 Visual Studio Code

Visual Studio Code 在过去几年中迅速改进,其受欢迎程度令微软感到惊喜。如果您勇于尝试且喜欢前沿体验,那么 Insiders 版(即下一版本的每日构建版)将是您的选择。

即使您计划仅使用 Visual Studio 2022 for Windows 进行开发,我也建议您下载并安装 Visual Studio Code,尝试本章中的编码任务,然后决定是否仅使用 Visual Studio 2022 完成本书剩余内容。

现在,让我们下载并安装 Visual Studio Code、.NET SDK 以及 C#和.NET Interactive Notebooks 扩展:

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

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

  2. 从以下链接下载并安装.NET SDK 的 3.1、5.0 和 6.0 版本:www.microsoft.com/net/download

    要全面学习如何控制.NET SDK,我们需要安装多个版本。.NET Core 3.1、.NET 5.0 和.NET 6.0 是目前支持的三个版本。您可以安全地并行安装多个版本。您将在本书中学习如何针对所需版本进行操作。

  3. 要安装 C#扩展,您必须首先启动 Visual Studio Code 应用程序。

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

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

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

  7. 在搜索框中输入.NET Interactive以查找**.NET 交互式笔记本**扩展。

  8. 点击安装并等待其安装。

安装其他扩展

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

扩展名称及标识符 描述
适用于 Visual Studio Code 的 C#(由 OmniSharp 提供支持)ms-dotnettools.csharp C#编辑支持,包括语法高亮、IntelliSense、转到定义、查找所有引用、.NET 调试支持以及 Windows、macOS 和 Linux 上的csproj项目支持。
.NET 交互式笔记本ms-dotnettools.dotnet-interactive-vscode 此扩展为在 Visual Studio Code 笔记本中使用.NET 交互式提供支持。它依赖于 Jupyter 扩展(ms-toolsai.jupyter)。
MSBuild 项目工具tinytoy.msbuild-project-tools 为 MSBuild 项目文件提供 IntelliSense,包括<PackageReference>元素的自动完成。
REST 客户端humao.rest-client 在 Visual Studio Code 中直接发送 HTTP 请求并查看响应。
ILSpy .NET 反编译器icsharpcode.ilspy-vscode 反编译 MSIL 程序集——支持现代.NET、.NET 框架、.NET Core 和.NET 标准。
Azure Functions for Visual Studio Codems-azuretools.vscode-azurefunctions 直接从 VS Code 创建、调试、管理和部署无服务器应用。它依赖于 Azure 账户(ms-vscode.azure-account)和 Azure 资源(ms-azuretools.vscode-azureresourcegroups)扩展。
GitHub 仓库github.remotehub 直接在 Visual Studio Code 中浏览、搜索、编辑和提交到任何远程 GitHub 仓库。
适用于 Visual Studio Code 的 SQL Server (mssql) ms-mssql.mssql 为 Microsoft SQL Server、Azure SQL 数据库和 SQL 数据仓库的开发提供丰富的功能集,随时随地可用。
Protobuf 3 支持 Visual Studio Codezxh404.vscode-proto3 语法高亮、语法验证、代码片段、代码补全、代码格式化、括号匹配和行与块注释。

了解 Microsoft Visual Studio Code 版本

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

  • 版本 1.59,2021 年 8 月功能发布

  • 版本 1.59.1,2021 年 8 月错误修复版本

本书使用的版本是 1.59,但微软 Visual Studio Code 的版本不如您安装的 C# for Visual Studio Code 扩展的版本重要。

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

微软 Visual Studio Code 键盘快捷键

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

如果您想为 Visual Studio Code 自定义键盘快捷键,那么您可以按照以下链接所示进行操作:code.visualstudio.com/docs/getstarted/keybindings

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

理解.NET

.NET 6、.NET Core、.NET Framework 和 Xamarin 是开发者用于构建应用程序和服务的相关且重叠的平台。在本节中,我将向您介绍这些.NET 概念。

理解.NET Framework

.NET Framework 是一个开发平台,包括公共语言运行时CLR),负责代码的执行管理,以及基础类库BCL),提供丰富的类库以构建应用程序。

微软最初设计.NET Framework 时考虑到了跨平台的可能性,但微软将其实施努力集中在使其在 Windows 上运行最佳。

自.NET Framework 4.5.2 起,它已成为 Windows 操作系统的官方组件。组件与其父产品享有相同的支持,因此 4.5.2 及更高版本遵循其安装的 Windows OS 的生命周期政策。.NET Framework 已安装在超过十亿台计算机上,因此它必须尽可能少地更改。即使是错误修复也可能导致问题,因此它更新不频繁。

对于 .NET Framework 4.0 或更高版本,计算机上为 .NET Framework 编写的所有应用共享同一版本的 CLR 和库,这些库存储在 全局程序集缓存 (GAC) 中,如果某些应用需要特定版本以确保兼容性,这可能会导致问题。

良好实践:实际上,.NET Framework 是仅限 Windows 的遗留平台。不要使用它创建新应用。

理解 Mono、Xamarin 和 Unity 项目

第三方开发了一个名为 Mono 项目的 .NET Framework 实现。Mono 是跨平台的,但它远远落后于官方的 .NET Framework 实现。

Mono 已找到自己的定位,作为 Xamarin 移动平台以及 Unity 等跨平台游戏开发平台的基础。

微软于 2016 年收购了 Xamarin,现在将曾经昂贵的 Xamarin 扩展免费提供给 Visual Studio。微软将仅能创建移动应用的 Xamarin Studio 开发工具更名为 Visual Studio for Mac,并赋予其创建控制台应用和 Web 服务等其他类型项目的能力。随着 Visual Studio 2022 for Mac 的推出,微软用 Visual Studio 2022 for Windows 的部分组件替换了 Xamarin Studio 编辑器中的部分,以提供更接近的体验和性能对等。Visual Studio 2022 for Mac 也进行了重写,使其成为真正的 macOS 原生 UI 应用,以提高可靠性并兼容 macOS 内置的辅助技术。

理解 .NET Core

如今,我们生活在一个真正的跨平台世界中,现代移动和云开发使得 Windows 作为操作系统的重要性大大降低。因此,微软一直在努力将 .NET 与其紧密的 Windows 联系解耦。在将 .NET Framework 重写为真正跨平台的过程中,他们抓住机会重构并移除了不再被视为核心的重大部分。

这一新产品被命名为 .NET Core,包括一个名为 CoreCLR 的跨平台 CLR 实现和一个名为 CoreFX 的精简 BCL。

微软 .NET 合作伙伴项目经理 Scott Hunter 表示:“我们 40% 的 .NET Core 客户是平台的新开发者,这正是我们希望看到的。我们希望吸引新的人才。”

.NET Core 发展迅速,由于它可以与应用并行部署,因此可以频繁更改,知道这些更改不会影响同一机器上的其他 .NET Core 应用。微软对 .NET Core 和现代 .NET 的大多数改进无法轻松添加到 .NET Framework 中。

理解通往统一 .NET 的旅程

2020 年 5 月的微软 Build 开发者大会上,.NET 团队宣布其.NET 统一计划的实施有所延迟。他们表示,.NET 5 将于 2020 年 11 月 10 日发布,该版本将统一除移动平台外的所有.NET 平台。直到 2021 年 11 月的.NET 6,统一.NET 平台才会支持移动设备。

.NET Core 已更名为.NET,主要版本号跳过了数字四,以避免与.NET Framework 4.x 混淆。微软计划每年 11 月发布主要版本,类似于苹果每年 9 月发布 iOS 的主要版本号。

下表显示了现代.NET 的关键版本何时发布,未来版本的计划时间,以及本书各版本使用的版本:

版本 发布日期 版本 发布日期
.NET Core RC1 2015 年 11 月 第一版 2016 年 3 月
.NET Core 1.0 2016 年 6 月
.NET Core 1.1 2016 年 11 月
.NET Core 1.0.4 和 .NET Core 1.1.1 2017 年 3 月 第二版 2017 年 3 月
.NET Core 2.0 2017 年 8 月
.NET Core for UWP in Windows 10 Fall Creators Update 2017 年 10 月 第三版 2017 年 11 月
.NET Core 2.1 (LTS) 2018 年 5 月
.NET Core 2.2 (当前) 2018 年 12 月
.NET Core 3.0 (当前) 2019 年 9 月 第四版 2019 年 10 月
.NET Core 3.1 (LTS) 2019 年 12 月
Blazor WebAssembly 3.2 (当前) 2020 年 5 月
.NET 5.0 (当前) 2020 年 11 月 第五版 2020 年 11 月
.NET 6.0 (LTS) 2021 年 11 月 第六版 2021 年 11 月
.NET 7.0 (当前) 2022 年 11 月 第七版 2022 年 11 月
.NET 8.0 (LTS) 2023 年 11 月 第八版 2023 年 11 月

.NET Core 3.1 包含了用于构建 Web 组件的 Blazor Server。微软原本计划在该版本中包含 Blazor WebAssembly,但该计划被推迟了。Blazor WebAssembly 后来作为.NET Core 3.1 的可选附加组件发布。我将其列入上表,因为它被版本化为 3.2,以将其排除在.NET Core 3.1 的 LTS 之外。

理解.NET 支持

.NET 版本要么是长期支持LTS),要么是当前,如下表所述:

  • LTS版本稳定,在其生命周期内需要的更新较少。这些版本非常适合您不打算频繁更新的应用程序。LTS 版本将在普遍可用性后支持 3 年,或者在下一个 LTS 版本发布后支持 1 年,以较长者为准。

  • 当前版本包含的功能可能会根据反馈进行更改。这些版本非常适合您正在积极开发的应用程序,因为它们提供了最新的改进。在 6 个月的维护期后,或者在普遍可用性后的 18 个月后,之前的次要版本将不再受支持。

两者在其生命周期内都会收到安全性和可靠性的关键修复。您必须保持最新补丁以获得支持。例如,如果系统运行的是 1.0,而 1.0.1 已发布,则需要安装 1.0.1 以获得支持。

为了更好地理解当前版本和 LTS 版本的选择,通过可视化方式查看是有帮助的,LTS 版本用 3 年长的黑色条表示,当前版本用长度可变的灰色条表示,并在新的大版本或小版本发布后的 6 个月内保留支持,如图 1.2所示:

文字描述自动生成,置信度低

图 1.2:对各种版本的支持

例如,如果您使用.NET Core 3.0 创建了一个项目,那么当 Microsoft 在 2019 年 12 月发布.NET Core 3.1 时,您必须在 2020 年 3 月之前将您的项目升级到.NET Core 3.1。(在.NET 5 之前,当前版本的维护期仅为三个月。)

如果您需要来自 Microsoft 的长期支持,那么今天选择.NET 6.0 并坚持使用它直到.NET 8.0,即使 Microsoft 发布了.NET 7.0。这是因为.NET 7.0 将是当前版本,因此它将在.NET 6.0 之前失去支持。请记住,即使是 LTS 版本,您也必须升级到错误修复版本,如 6.0.1。

除了以下列表中所示的版本外,所有.NET Core 和现代.NET 版本均已达到其生命周期结束:

  • .NET 5.0 将于 2022 年 5 月达到生命周期结束。

  • .NET Core 3.1 将于 2022 年 12 月 3 日达到生命周期结束。

  • .NET 6.0 将于 2024 年 11 月达到生命周期结束。

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

.NET 运行时版本遵循语义版本控制,即,主版本增量表示重大更改,次版本增量表示新功能,补丁增量表示错误修复。

.NET SDK 版本号并不遵循语义版本控制。主版本号和次版本号与对应的运行时版本绑定。补丁号遵循一个约定,指示 SDK 的主版本和次版本。

您可以在以下表格中看到一个示例:

变更 运行时 SDK
初始发布 6.0.0 6.0.100
SDK 错误修复 6.0.0 6.0.101
运行时和 SDK 错误修复 6.0.1 6.0.102
SDK 新特性 6.0.1 6.0.200

移除旧版本的.NET

.NET 运行时更新与主版本(如 6.x)兼容,.NET SDK 的更新版本保持了构建针对先前运行时版本的应用程序的能力,这使得可以安全地移除旧版本。

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

  • dotnet --list-sdks

  • dotnet --list-runtimes

在 Windows 上,使用应用和功能部分来移除.NET SDK。在 macOS 或 Windows 上,使用dotnet-core-uninstall工具。此工具默认不安装。

例如,在编写第四版时,我每月都会使用以下命令:

dotnet-core-uninstall remove --all-previews-but-latest --sdk 

现代 .NET 有何不同?

与遗留的 .NET Framework 相比,现代 .NET 是模块化的。它是开源的,微软在公开场合做出改进和变更的决定。微软特别注重提升现代 .NET 的性能。

由于移除了遗留和非跨平台技术,它比上一个版本的 .NET Framework 更小。例如,Windows Forms 和 Windows Presentation Foundation (WPF) 可用于构建 图形用户界面 (GUI) 应用,但它们与 Windows 生态紧密绑定,因此不包含在 macOS 和 Linux 上的 .NET 中。

窗口开发

现代 .NET 的特性之一是支持运行旧的 Windows Forms 和 WPF 应用,这得益于包含在 .NET Core 3.1 或更高版本的 Windows 版中的 Windows Desktop Pack,这也是它比 macOS 和 Linux 的 SDK 大的原因。如有必要,你可以对你的遗留 Windows 应用进行一些小改动,然后将其重新构建为 .NET 6,以利用新特性和性能提升。

网页开发

ASP.NET Web Forms 和 Windows Communication Foundation (WCF) 是旧的网页应用和服务技术,如今较少开发者选择用于新开发项目,因此它们也已从现代 .NET 中移除。取而代之,开发者更倾向于使用 ASP.NET MVC、ASP.NET Web API、SignalR 和 gRPC。这些技术经过重构并整合成一个运行在现代 .NET 上的平台,名为 ASP.NET Core。你将在第十四章使用 ASP.NET Core Razor Pages 构建网站第十五章使用模型-视图-控制器模式构建网站第十六章构建和消费网络服务以及第十八章构建和消费专用服务中了解这些技术。

更多信息:一些 .NET Framework 开发者对 ASP.NET Web Forms、WCF 和 Windows Workflow (WF) 在现代 .NET 中的缺失感到不满,并希望微软改变主意。有开源项目旨在使 WCF 和 WF 迁移到现代 .NET。你可以在以下链接了解更多信息:devblogs.microsoft.com/dotnet/supporting-the-community-with-wf-and-wcf-oss-projects/。有一个关于 Blazor Web Forms 组件的开源项目,链接如下:github.com/FritzAndFriends/BlazorWebFormsComponents

数据库开发

Entity FrameworkEF)6 是一种对象关系映射技术,设计用于处理存储在 Oracle 和 Microsoft SQL Server 等关系数据库中的数据。多年来,它积累了许多功能,因此跨平台 API 已经精简,增加了对 Microsoft Azure Cosmos DB 等非关系数据库的支持,并更名为 Entity Framework Core。你将在第十章使用 Entity Framework Core 处理数据中学习到它。

如果你现有的应用使用旧的 EF,那么 6.3 版本在.NET Core 3.0 或更高版本上得到支持。

现代.NET 的主题

微软创建了一个使用 Blazor 的网站,展示了现代.NET 的主要主题:themesof.net/

理解.NET Standard

2019 年.NET 的情况是,有三个由微软控制的.NET 平台分支,如下列所示:

  • .NET Core:适用于跨平台和新应用

  • .NET Framework:适用于遗留应用

  • Xamarin:适用于移动应用

每种平台都有其优缺点,因为它们都是为不同场景设计的。这导致了一个问题,开发者必须学习三种平台,每种都有令人烦恼的特性和限制。

因此,微软定义了.NET Standard——一套所有.NET 平台都可以实现的 API 规范,以表明它们具有何种程度的兼容性。例如,基本支持通过平台符合.NET Standard 1.4 来表示。

通过.NET Standard 2.0 及更高版本,微软使所有三种平台都向现代最低标准靠拢,这使得开发者更容易在任何类型的.NET 之间共享代码。

对于.NET Core 2.0 及更高版本,这一更新添加了开发者将旧代码从.NET Framework 移植到跨平台的.NET Core 所需的大部分缺失 API。然而,某些 API 虽已实现,但会抛出异常以提示开发者不应实际使用它们!这通常是由于运行.NET 的操作系统之间的差异所致。你将在第二章C#语言中学习如何处理这些异常。

重要的是要理解,.NET Standard 只是一个标准。你不能像安装 HTML5 那样安装.NET Standard。要使用 HTML5,你必须安装一个实现 HTML5 标准的网络浏览器。

要使用.NET Standard,你必须安装一个实现.NET Standard 规范的.NET 平台。最后一个.NET Standard 版本 2.1 由.NET Core 3.0、Mono 和 Xamarin 实现。C# 8.0 的一些特性需要.NET Standard 2.1。.NET Standard 2.1 未被.NET Framework 4.8 实现,因此我们应该将.NET Framework 视为遗留技术。

随着 2021 年 11 月.NET 6 的发布,对.NET Standard 的需求大幅减少,因为现在有了一个适用于所有平台的单一.NET,包括移动平台。.NET 6 拥有一个统一的 BCL 和两个 CLR:CoreCLR 针对服务器或桌面场景(如网站和 Windows 桌面应用)进行了优化,而 Mono 运行时则针对资源有限的移动和 Web 浏览器应用进行了优化。

即使在现在,为.NET Framework 创建的应用和网站仍需得到支持,因此理解您可以创建向后兼容旧.NET 平台的.NET Standard 2.0 类库这一点很重要。

.NET 平台和工具在本书各版中的使用情况

对于本书的第一版,写于 2016 年 3 月,我专注于.NET Core 功能,但在.NET Core 尚未实现重要或有用特性时使用.NET Framework,因为那时.NET Core 1.0 的最终版本还未发布。大多数示例使用 Visual Studio 2015,而 Visual Studio Code 仅简短展示。

第二版几乎完全清除了所有.NET Framework 代码示例,以便读者能够专注于真正跨平台的.NET Core 示例。

第三版完成了转换。它被重写,使得所有代码都是纯.NET Core。但为所有任务同时提供 Visual Studio Code 和 Visual Studio 2017 的逐步指导增加了复杂性。

第四版延续了这一趋势,除了最后两章外,所有代码示例都仅使用 Visual Studio Code 展示。在第二十章构建 Windows 桌面应用中,使用了运行在 Windows 10 上的 Visual Studio,而在第二十一章构建跨平台移动应用中,使用了 Mac 版的 Visual Studio。

在第五版中,第二十章构建 Windows 桌面应用,被移至附录 B,以便为新的第二十章使用 Blazor 构建 Web 用户界面腾出空间。Blazor 项目可以使用 Visual Studio Code 创建。

在本第六版中,第十九章使用.NET MAUI 构建移动和桌面应用,更新了内容,展示了如何使用 Visual Studio 2022 和**.NET MAUI**(多平台应用 UI)创建移动和桌面跨平台应用。

到了第七版及.NET 7 发布时,Visual Studio Code 将有一个扩展来支持.NET MAUI。届时,读者将能够使用 Visual Studio Code 来运行本书中的所有示例。

理解中间语言

C#编译器(名为Roslyn),由dotnet CLI 工具使用,将您的 C#源代码转换成中间语言IL)代码,并将 IL 存储在程序集(DLL 或 EXE 文件)中。IL 代码语句类似于汇编语言指令,由.NET 的虚拟机 CoreCLR 执行。

在运行时,CoreCLR 从程序集中加载 IL 代码,即时JIT)编译器将其编译成原生 CPU 指令,然后由您机器上的 CPU 执行。

这种两步编译过程的好处是微软可以为 Linux 和 macOS 以及 Windows 创建 CLR。由于第二步编译,相同的 IL 代码在所有地方运行,该步骤为本地操作系统和 CPU 指令集生成代码。

无论源代码是用哪种语言编写的,例如 C#、Visual Basic 或 F#,所有.NET 应用程序都使用 IL 代码作为其指令存储在程序集中。微软和其他公司提供了反编译工具,可以打开程序集并显示此 IL 代码,例如 ILSpy .NET 反编译器扩展。

比较.NET 技术

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

技术 描述 宿主操作系统
现代.NET 现代功能集,完全支持 C# 8、9 和 10,用于移植现有应用或创建新的桌面、移动和 Web 应用及服务 Windows、macOS、Linux、Android、iOS
.NET Framework 遗留功能集,有限的 C# 8 支持,不支持 C# 9 或 10,仅用于维护现有应用 仅限 Windows
Xamarin 仅限移动和桌面应用 Android、iOS、macOS

使用 Visual Studio 2022 构建控制台应用

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

如果你没有 Windows 电脑或者你想使用 Visual Studio Code,那么你可以跳过这一节,因为代码将保持不变,只是工具体验不同。

使用 Visual Studio 2022 管理多个项目

Visual Studio 2022 有一个名为解决方案的概念,允许你同时打开和管理多个项目。我们将使用一个解决方案来管理你将在本章中创建的两个项目。

使用 Visual Studio 2022 编写代码

让我们开始编写代码吧!

  1. 启动 Visual Studio 2022。

  2. 在启动窗口中,点击创建新项目

  3. 创建新项目对话框中,在搜索模板框中输入console,并选择控制台应用程序,确保你选择了 C#项目模板而不是其他语言,如 F#或 Visual Basic,如图 1.3所示:

    图 1.3:选择控制台应用程序项目模板

  4. 点击下一步

  5. 配置新项目对话框中,为项目名称输入HelloCS,为位置输入C:\Code,为解决方案名称输入Chapter01,如图 1.4所示:

    图 1.4:为你的新项目配置名称和位置

  6. 点击下一步

    我们故意使用.NET 5.0 的旧项目模板来查看完整的控制台应用程序是什么样的。在下一节中,你将使用.NET 6.0 创建一个控制台应用程序,并查看有哪些变化。

  7. 附加信息对话框中,在目标框架下拉列表中,注意当前和长期支持版本的.NET 的选项,然后选择**.NET 5.0(当前)并点击创建**。

  8. 解决方案资源管理器中,双击打开名为Program.cs的文件,并注意解决方案资源管理器显示了HelloCS项目,如图1.5所示:

    图 1.5:在 Visual Studio 2022 中编辑 Program.cs

  9. Program.cs中,修改第 9 行,使得写入控制台的文本显示为Hello, C#!

使用 Visual Studio 编译和运行代码

接下来的任务是编译和运行代码。

  1. 在 Visual Studio 中,导航到调试 | 开始不调试

  2. 控制台窗口的输出将显示应用程序运行的结果,如图1.6所示:图形用户界面,文本,应用程序 自动生成描述

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

  3. 按任意键关闭控制台窗口并返回 Visual Studio。

  4. 选择HelloCS项目,然后在解决方案资源管理器工具栏上,切换显示所有文件按钮,并注意编译器生成的binobj文件夹可见,如图1.7所示:图形用户界面,文本,应用程序,电子邮件 自动生成描述

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

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

编译器生成了两个文件夹,名为objbin。您无需查看这些文件夹或理解其中的文件。只需知道编译器需要创建临时文件夹和文件来完成其工作。您可以删除这些文件夹及其文件,它们稍后可以重新创建。开发者经常这样做来“清理”项目。Visual Studio 甚至在构建菜单上有一个名为清理解决方案的命令,用于删除其中一些临时文件。Visual Studio Code 中的等效命令是dotnet clean

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

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

编写顶级程序

您可能会认为,仅仅为了输出Hello, C#!就写了这么多代码。

虽然样板代码由项目模板为您编写,但有没有更简单的方法呢?

嗯,在 C# 9 或更高版本中,确实有,它被称为顶级程序

让我们比较一下项目模板创建的控制台应用程序,如下所示:

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

对于新的顶级程序最小控制台应用程序,如下所示:

using System;
Console.WriteLine("Hello World!"); 

这简单多了,对吧?如果您必须从一个空白文件开始并自己编写所有语句,这是更好的。但它是如何工作的呢?

在编译期间,所有定义命名空间、Program类及其Main方法的样板代码都会生成并围绕你编写的语句进行包装。

关于顶层程序的关键点包括以下列表:

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

  • 一个项目中只能有一个这样的文件。

using System;语句位于文件顶部,导入了System命名空间。这使得Console.WriteLine语句能够工作。你将在下一章了解更多关于命名空间的内容。

使用 Visual Studio 2022 添加第二个项目

让我们向解决方案中添加第二个项目以探索顶层程序:

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

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

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

  4. 附加信息对话框中,选择**.NET 6.0(长期支持),然后点击创建**。

  5. 解决方案资源管理器中,在TopLevelProgram项目里,双击Program.cs以打开它。

  6. Program.cs中,注意代码仅由一个注释和一个语句组成,因为它使用了 C# 9 引入的顶层程序特性,如下所示:

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

但当我之前介绍顶层程序概念时,我们需要一个using System;语句。为什么这里不需要呢?

隐式导入的命名空间

诀窍在于我们仍然需要导入System命名空间,但现在它通过 C# 10 引入的特性为我们完成了。让我们看看是如何实现的:

  1. 解决方案资源管理器中,选择TopLevelProgram项目并启用显示所有文件按钮,注意编译器生成的binobj文件夹可见。

  2. 展开obj文件夹,再展开Debug文件夹,接着展开net6.0文件夹,并打开名为TopLevelProgram.GlobalUsings.g.cs的文件。

  3. 请注意,此文件是由针对.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; 
    

    我将在下一章详细解释这一特性。目前,只需注意.NET 5 和.NET 6 之间的一个显著变化是,许多项目模板,如控制台应用程序的模板,使用新的语言特性来隐藏实际发生的事情。

  4. TopLevelProgram项目中,在Program.cs里,修改语句以输出不同的消息和操作系统版本,如下所示:

    Console.WriteLine("Hello from a Top Level Program!");
    Console.WriteLine(Environment.OSVersion.VersionString); 
    
  5. 解决方案资源管理器中,右键点击Chapter01解决方案,选择设置启动项目…,设置当前选择,然后点击确定

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

  7. 导航至调试 | 启动但不调试以运行TopLevelProgram项目,并注意结果,如图1.8所示:

    图 1.8:在 Windows 上的 Visual Studio 解决方案中运行顶级程序,该解决方案包含两个项目

使用 Visual Studio Code 构建控制台应用

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

如果你不想尝试 Visual Studio Code 或.NET Interactive Notebooks,那么请随意跳过本节和下一节,然后继续阅读审查项目文件夹和文件部分。

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

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

使用 Visual Studio Code 管理多个项目

Visual Studio Code 有一个名为工作区的概念,允许你同时打开和管理多个项目。我们将使用工作区来管理本章中你将创建的两个项目。

使用 Visual Studio Code 编写代码

让我们开始编写代码吧!

  1. 启动 Visual Studio Code。

  2. 确保没有打开任何文件、文件夹或工作区。

  3. 导航至文件 | 将工作区另存为…

  4. 在对话框中,导航至 macOS 上的用户文件夹(我的名为markjprice),Windows 上的文档文件夹,或你希望保存项目的任何目录或驱动器。

  5. 点击新建文件夹按钮并命名文件夹为Code。(如果你完成了 Visual Studio 2022 部分,则此文件夹已存在。)

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

  7. Chapter01-vscode文件夹中,将工作区保存为Chapter01.code-workspace

  8. 导航至文件 | 向工作区添加文件夹…或点击添加文件夹按钮。

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

  10. 选择HelloCS文件夹并点击添加按钮。

  11. 导航至视图 | 终端

    我们特意使用较旧的.NET 5.0 项目模板来查看完整的控制台应用程序是什么样的。在下一节中,你将使用.NET 6.0 创建控制台应用程序,并查看发生了哪些变化。

  12. 终端中,确保你位于HelloCS文件夹中,然后使用dotnet命令行工具创建一个新的面向.NET 5.0 的控制台应用,如以下命令所示:

    dotnet new console -f net5.0 
    
  13. 你将看到dotnet命令行工具在当前文件夹中为你创建一个新的控制台应用程序项目,并且资源管理器窗口显示创建的两个文件HelloCS.csprojProgram.cs,以及obj文件夹,如图 1.9 所示:

    图 1.9:资源管理器窗口将显示已创建两个文件和一个文件夹

  14. 资源管理器中,点击名为Program.cs的文件以在编辑器窗口中打开它。首次执行此操作时,如果 Visual Studio Code 未在安装 C#扩展时下载并安装 C#依赖项(如 OmniSharp、.NET Core 调试器和 Razor 语言服务器),或者它们需要更新,则可能需要下载并安装。Visual Studio Code 将在输出窗口中显示进度,并最终显示消息完成,如下所示:

    Installing C# dependencies...
    Platform: win32, x86_64
    Downloading package 'OmniSharp for Windows (.NET 4.6 / x64)' (36150 KB).................... Done!
    Validating download...
    Integrity Check succeeded.
    Installing package 'OmniSharp for Windows (.NET 4.6 / x64)'
    Downloading package '.NET Core Debugger (Windows / x64)' (45048 KB).................... Done!
    Validating download...
    Integrity Check succeeded.
    Installing package '.NET Core Debugger (Windows / x64)'
    Downloading package 'Razor Language Server (Windows / x64)' (52344 KB).................... Done!
    Installing package 'Razor Language Server (Windows / x64)'
    Finished 
    

    上述输出来自 Windows 上的 Visual Studio Code。在 macOS 或 Linux 上运行时,输出会略有不同,但会为你的操作系统下载并安装相应的组件。

  15. 名为objbin的文件夹将被创建,当你看到提示说缺少必需的资源时,请点击,如图 1.10 所示:图形用户界面,文本,应用程序,电子邮件 自动生成的描述

    图 1.10:添加所需构建和调试资产的警告信息

  16. 如果通知在你能与之交互之前消失,则可以点击状态栏最右侧的铃铛图标再次显示它。

  17. 几秒钟后,将创建另一个名为.vscode的文件夹,其中包含一些文件,这些文件由 Visual Studio Code 用于在调试期间提供功能,如 IntelliSense,你将在第四章编写、调试和测试函数中了解更多信息。

  18. Program.cs中,修改第 9 行,使得写入控制台的文本为Hello, C#!

    最佳实践:导航至文件 | 自动保存。此切换将省去每次重建应用程序前记得保存的烦恼。

使用 dotnet CLI 编译和运行代码

接下来的任务是编译和运行代码:

  1. 导航至视图 | 终端并输入以下命令:

    dotnet run 
    
  2. 终端窗口中的输出将显示运行你的应用程序的结果,如图 1.11 所示:图形用户界面,文本,应用程序,电子邮件 自动生成的描述

图 1.11:运行你的第一个控制台应用程序的输出

使用 Visual Studio Code 添加第二个项目

让我们向工作区添加第二个项目以探索顶级程序:

  1. 在 Visual Studio Code 中,导航至文件 | 将文件夹添加到工作区…

  2. Chapter01-vscode文件夹中,使用新建文件夹按钮创建一个名为TopLevelProgram的新文件夹,选中它,然后点击添加

  3. 导航至 Terminal | New Terminal,并在出现的下拉列表中选择 TopLevelProgram。或者,在 EXPLORER 中,右键点击 TopLevelProgram 文件夹,然后选择 Open in Integrated Terminal

  4. TERMINAL 中,确认你位于 TopLevelProgram 文件夹中,然后输入创建新控制台应用程序的命令,如下所示:

    dotnet new console 
    

    最佳实践:在使用工作区时,在 TERMINAL 中输入命令时要小心。确保你位于正确的文件夹中,再输入可能具有破坏性的命令!这就是为什么我在发出创建新控制台应用的命令之前,让你为 TopLevelProgram 创建一个新终端的原因。

  5. 导航至 View | Command Palette

  6. 输入 omni,然后在出现的下拉列表中选择 OmniSharp: Select Project

  7. 在两个项目的下拉列表中,选择 TopLevelProgram 项目,并在提示时点击 Yes 以添加调试所需的资产。

    最佳实践:为了启用调试和其他有用的功能,如代码格式化和“转到定义”,你必须告诉 OmniSharp 你在 Visual Studio Code 中正在积极处理哪个项目。你可以通过点击状态栏左侧火焰图标右侧的项目/文件夹快速切换活动项目。

  8. EXPLORER 中,在 TopLevelProgram 文件夹中,选择 Program.cs,然后将现有语句更改为输出不同的消息并输出操作系统版本字符串,如下所示:

    Console.WriteLine("Hello from a Top Level Program!");
    Console.WriteLine(Environment.OSVersion.VersionString); 
    
  9. TERMINAL 中,输入运行程序的命令,如下所示:

    dotnet run 
    
  10. 注意 TERMINAL 窗口中的输出,如图 1.12 所示:

    图 1.12:在 Windows 上的 Visual Studio Code 工作区中运行顶级程序,该工作区包含两个项目

如果你要在 macOS Big Sur 上运行该程序,环境操作系统将有所不同,如下所示:

Hello from a Top Level Program!
Unix 11.2.3 

使用 Visual Studio Code 管理多个文件

如果你有多个文件需要同时处理,那么你可以将它们并排编辑:

  1. EXPLORER 中展开两个项目。

  2. 打开两个项目中的 Program.cs 文件。

  3. 点击、按住并拖动其中一个打开文件的编辑窗口标签,以便你可以同时看到两个文件。

使用 .NET Interactive Notebooks 探索代码

.NET Interactive Notebooks 使得编写代码比顶级程序更加简便。它需要 Visual Studio Code,因此如果你之前未安装,请现在安装。

创建笔记本

首先,我们需要创建一个笔记本:

  1. 在 Visual Studio Code 中,关闭任何已打开的工作区或文件夹。

  2. 导航至 View | Command Palette

  3. 输入 .net inter,然后选择 .NET Interactive: Create new blank notebook,如图 1.13 所示:

    图 1.13:创建一个新的空白 .NET 笔记本

  4. 当提示选择文件扩展名时,选择 创建为 '.dib'

    .dib 是微软定义的一种实验性文件格式,旨在避免与 Python 交互式笔记本使用的 .ipynb 格式产生混淆和兼容性问题。文件扩展名历史上仅用于可以包含数据、Python 代码(PY)和输出混合的 Jupyter 笔记本文件(NB)。随着 .NET 交互式笔记本的出现,这一概念已扩展到允许混合使用 C#、F#、SQL、HTML、JavaScript、Markdown 和其他语言。.dib 是多语言兼容的,意味着它支持混合语言。支持 .dib.ipynb 文件格式之间的转换。

  5. 为笔记本中的代码单元格选择默认语言C#

  6. 如果可用的 .NET 交互式版本更新,您可能需要等待它卸载旧版本并安装新版本。导航至视图 | 输出,并在下拉列表中选择 .NET 交互式 : 诊断。请耐心等待。笔记本可能需要几分钟才能出现,因为它必须启动一个托管 .NET 的环境。如果几分钟后没有任何反应,请关闭 Visual Studio Code 并重新启动它。

  7. 一旦 .NET 交互式笔记本扩展下载并安装完成,输出窗口的诊断将显示内核进程已启动(您的进程和端口号将与下面的输出不同),如下面的输出所示,已编辑以节省空间:

    Extension started for VS Code Stable.
    ...
    Kernel process 12516 Port 59565 is using tunnel uri http://localhost:59565/ 
    

在笔记本中编写和运行代码

接下来,我们可以在笔记本单元格中编写代码:

  1. 第一个单元格应已设置为 C# (.NET 交互式),但如果设置为其他任何内容,请点击代码单元格右下角的语言选择器,然后选择 C# (.NET 交互式) 作为该单元格的语言模式,并注意代码单元格的其他语言选择,如图 1.14 所示:

    图 1.14:在 .NET 交互式笔记本中更改代码单元格的语言

  2. C# (.NET 交互式) 代码单元格中,输入一条输出消息到控制台的语句,并注意您不需要像在完整应用程序中那样在语句末尾加上分号,如下面的代码所示:

    Console.WriteLine("Hello, .NET Interactive!") 
    
  3. 点击代码单元格左侧的 执行单元格 按钮,并注意代码单元格下方灰色框中出现的输出,如图 1.15 所示:

    图 1.15:在笔记本中运行代码并在下方看到输出

保存笔记本

与其他文件一样,我们应该在继续之前保存笔记本:

  1. 导航至文件 | 另存为…

  2. 切换到 Chapter01-vscode 文件夹,并将笔记本保存为 Chapter01.dib

  3. 关闭 Chapter01.dib 编辑器标签页。

向笔记本添加 Markdown 和特殊命令

我们可以混合使用包含 Markdown 和代码的单元格,并使用特殊命令:

  1. 导航至文件 | 打开文件…,并选择 Chapter01.dib 文件。

  2. 如果提示您信任这些文件的作者吗?,点击打开

  3. 将鼠标悬停在代码块上方并点击**+标记**以添加 Markdown 单元格。

  4. 输入一级标题,如下所示的 Markdown:

    # Chapter 1 - Hello, C#! Welcome, .NET!
    Mixing *rich* **text** and code is cool! 
    
  5. 点击单元格右上角的勾选标记以停止编辑单元格并查看处理后的 Markdown。

    如果单元格顺序错误,可以拖放以重新排列。

  6. 在 Markdown 单元格和代码单元格之间悬停并点击**+代码**。

  7. 输入特殊命令以输出.NET Interactive 的版本信息,如下所示:

    #!about 
    
  8. 点击执行单元格按钮并注意输出,如图 1.16所示:

    图 1.16:在.NET Interactive 笔记本中混合 Markdown、代码和特殊命令

在多个单元格中执行代码

当笔记本中有多个代码单元格时,必须在后续代码单元格的上下文可用之前执行前面的代码单元格:

  1. 在笔记本底部,添加一个新的代码单元格,然后输入一个语句以声明变量并赋值整数值,如下所示:

    int number = 8; 
    
  2. 在笔记本底部,添加一个新的代码单元格,然后输入一个语句以输出number变量,如下所示:

    Console.WriteLine(number); 
    
  3. 注意第二个代码单元格不知道number变量,因为它是在另一个代码单元格(即上下文)中定义和赋值的,如图 1.17所示:

    图 1.17:当前单元格或上下文中不存在number变量

  4. 在第一个单元格中,点击执行单元格按钮声明并赋值给变量,然后在第二个单元格中,点击执行单元格按钮输出number变量,并注意这有效。(或者,在第一个单元格中,你可以点击执行当前及以下单元格按钮。)

    最佳实践:如果相关代码分布在两个单元格中,请记住在执行后续单元格之前执行前面的单元格。在笔记本顶部,有以下按钮 – 清除输出全部运行。这些非常方便,因为你可以点击一个,然后另一个,以确保所有代码单元格都按正确顺序执行。

本书代码使用.NET Interactive Notebooks

在其余章节中,我将不会给出使用笔记本的具体说明,但本书的 GitHub 仓库在适当时候提供了解决方案笔记本。我预计许多读者会希望运行我预先创建的笔记本,以查看第二章第十二章中涵盖的语言和库特性,并学习它们,而无需编写完整的应用程序,即使只是一个控制台应用:

github.com/markjprice/cs10dotnet6/tree/main/notebooks

查看项目文件夹和文件

本章中,你创建了两个名为HelloCSTopLevelProgram的项目。

Visual Studio Code 使用工作区文件管理多个项目。Visual Studio 2022 使用解决方案文件管理多个项目。你还创建了一个.NET Interactive 笔记本。

结果是一个文件夹结构和文件,将在后续章节中重复出现,尽管不仅仅是两个项目,如图1.18所示:

图 1.18:本章中两个项目的文件夹结构和文件

理解常见文件夹和文件

尽管.code-workspace.sln文件不同,但项目文件夹和文件(如HelloCSTopLevelProgram)对于 Visual Studio 2022 和 Visual Studio Code 是相同的。这意味着你可以根据喜好在这两个代码编辑器之间混合搭配:

  • 在 Visual Studio 2022 中,打开解决方案后,导航至文件 | 添加现有项目…,以添加由另一工具创建的项目文件。

  • 在 Visual Studio Code 中,打开工作区后,导航至文件 | 向工作区添加文件夹…,以添加由另一工具创建的项目文件夹。

    最佳实践:尽管源代码,如.csproj.cs文件,是相同的,但由编译器自动生成的binobj文件夹可能存在版本不匹配,导致错误。如果你想在 Visual Studio 2022 和 Visual Studio Code 中打开同一项目,请在另一个代码编辑器中打开项目之前删除临时的binobj文件夹。这就是为什么本章要求你为 Visual Studio Code 解决方案创建一个不同文件夹的原因。

理解 GitHub 上的解决方案代码

本书 GitHub 仓库中的解决方案代码包括为 Visual Studio Code、Visual Studio 2022 和.NET Interactive 笔记本文件设置的独立文件夹,如下所示:

充分利用本书的 GitHub 仓库

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

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

  • 存储本书的解决方案代码,以便在印刷出版日期之后进行维护。

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

  • 为读者提供一个与我联系的地方,如果他们在阅读本书时遇到问题。

提出关于本书的问题

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

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

  2. 点击新建问题

  3. 尽可能详细地提供有助于我诊断问题的信息。例如:

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

    2. 您的硬件配置,例如,Intel、Apple Silicon 或 ARM CPU。

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

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

    5. 描述预期的行为和实际体验到的行为。

    6. 截图(如有可能)。

撰写这本书对我来说是一项副业。我有一份全职工作,所以主要在周末编写这本书。这意味着我不能总是立即回复问题。但我希望所有读者都能通过我的书取得成功,所以如果我能不太麻烦地帮助您(和其他人),我会很乐意这么做。

给我反馈

如果您想就本书提供更一般的反馈,GitHub 仓库的README.md页面有一些调查链接。您可以匿名提供反馈,或者如果您希望得到我的回复,可以提供电子邮件地址。我将仅使用此电子邮件地址来回复您的反馈。

我喜欢听到读者对我书籍的喜爱之处,以及改进建议和他们如何使用 C#和.NET,所以请不要害羞。请与我联系!

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

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

我使用 GitHub 存储所有章节中涉及的动手实践编码示例的解决方案,以及每章末尾的实际练习。您可以在以下链接找到仓库:github.com/markjprice/cs10dotnet6

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

表 自动生成描述

图 1.19:将仓库下载为 ZIP 文件

我建议你将上述链接添加到你的收藏夹书签中,因为我也会使用本书的 GitHub 仓库发布勘误(更正)和其他有用链接。

使用 Git 与 Visual Studio Code 和命令行

Visual Studio Code 支持 Git,但它将使用你的操作系统上的 Git 安装,因此你必须先安装 Git 2.0 或更高版本才能使用这些功能。

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

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

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

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

  1. 在你的用户目录或文档目录下,或者你想存放 Git 仓库的任何地方,创建一个名为Repos-vscode的文件夹。

  2. 在 Visual Studio Code 中,打开Repos-vscode文件夹。

  3. 导航至视图 | 终端,并输入以下命令:

    git clone https://github.com/markjprice/cs10dotnet6.git 
    
  4. 请注意,克隆所有章节的解决方案文件需要大约一分钟,如图 1.20所示:图形用户界面,文本,应用程序,电子邮件 自动生成描述

    图 1.20:使用 Visual Studio Code 克隆本书解决方案代码

寻求帮助

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

阅读微软文档

获取微软开发者工具和平台帮助的权威资源是微软文档,你可以在以下链接找到它:docs.microsoft.com/

获取 dotnet 工具的帮助

在命令行中,你可以向dotnet工具请求其命令的帮助:

  1. 要在浏览器窗口中打开dotnet new命令的官方文档,在命令行或 Visual Studio Code 终端中输入以下内容:

    dotnet help new 
    
  2. 要在命令行获取帮助输出,使用-h--help标志,如下所示:

    dotnet new console -h 
    
  3. 你将看到以下部分输出:

    Console Application (C#)
    Author: Microsoft
    Description: A project for creating a command-line application that can run on .NET Core on Windows, Linux and macOS
    Options:
      -f|--framework. The target framework for the project.
                          net6.0           - Target net6.0
                          net5.0           - Target net5.0
                          netcoreapp3.1\.   - Target netcoreapp3.1
                          netcoreapp3.0\.   - Target netcoreapp3.0
                      Default: net6.0
    --langVersion    Sets langVersion in the created project file text – Optional 
    

获取类型及其成员的定义

代码编辑器最有用的功能之一是转到定义。它在 Visual Studio Code 和 Visual Studio 2022 中都可用。它将通过读取编译程序集中的元数据来显示类型或成员的公共定义。

一些工具,如 ILSpy .NET 反编译器,甚至能从元数据和 IL 代码反向工程回 C#代码。

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

  1. 在 Visual Studio 2022 或 Visual Studio Code 中,打开名为Chapter01的解决方案/工作区。

  2. HelloCS项目中,在Program.cs中,在Main中,输入以下语句以声明名为z的整数变量:

    int z; 
    
  3. 点击int内部,然后右键单击并选择转到定义

  4. 在出现的代码窗口中,你可以看到int数据类型是如何定义的,如图1.21所示:图形用户界面,文本,应用程序 描述自动生成

    图 1.21:int 数据类型元数据

    你可以看到int

    • 使用struct关键字定义

    • 位于System.Runtime程序集中

    • 位于System命名空间中

    • 名为Int32

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

    • 实现接口,如IComparable

    • 具有其最大和最小值的常量值

    • 具有诸如Parse等方法

    良好实践:当你尝试在 Visual Studio Code 中使用转到定义功能时,有时会看到错误提示未找到定义。这是因为 C#扩展不了解当前项目。要解决此问题,请导航至视图 | 命令面板,输入omni,选择OmniSharp: 选择项目,然后选择你想要工作的项目。

    目前,转到定义功能对你来说并不那么有用,因为你还不完全了解这些信息意味着什么。

    在本书的第一部分结束时,即第二章第六章,教你关于 C#的内容,你将对此功能变得非常熟悉。

  5. 在代码编辑器窗口中,向下滚动找到第 106 行带有单个string参数的Parse方法,以及第 86 至 105 行记录它的注释,如图1.22所示:图形用户界面,文本,应用程序 描述自动生成

    图 1.22:带有字符串参数的 Parse 方法的注释

在注释中,你会看到微软已经记录了以下内容:

  • 描述该方法的摘要。

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

  • 方法的返回值,包括其数据类型。

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

希望你已经迫不及待想要了解这一切意味着什么!

再耐心等待一会儿。你即将完成本章,下一章你将深入探讨 C#语言的细节。但首先,让我们看看还可以在哪里寻求帮助。

在 Stack Overflow 上寻找答案

Stack Overflow 是最受欢迎的第三方网站,用于获取编程难题的答案。它如此受欢迎,以至于搜索引擎如 DuckDuckGo 有一种特殊方式来编写查询以搜索该站点:

  1. 打开你最喜欢的网页浏览器。

  2. 导航至DuckDuckGo.com,输入以下查询,并注意搜索结果,这些结果也显示在图 1.23中:

     !so securestring 
    

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

    图 1.23:Stack Overflow 关于 securestring 的搜索结果

使用 Google 搜索答案

你可以使用 Google 的高级搜索选项来增加找到所需内容的可能性:

  1. 导航至 Google。

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

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

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

订阅官方.NET 博客

为了跟上.NET 的最新动态,订阅官方.NET 博客是一个很好的选择,该博客由.NET 工程团队撰写,你可以在以下链接找到它:devblogs.microsoft.com/dotnet/

观看 Scott Hanselman 的视频

Microsoft 的 Scott Hanselman 有一个关于计算机知识的优秀 YouTube 频道,这些知识他们没有教过你:computerstufftheydidntteachyou.com/

我向所有从事计算机工作的人推荐它。

实践和探索

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

练习 1.1 – 测试你的知识

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

  1. Visual Studio 2022 是否优于 Visual Studio Code?

  2. .NET 6 是否优于.NET Framework?

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

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

  5. 什么是.NET 控制台应用程序的入口点方法的名称,以及它应该如何声明?

  6. 什么是顶级程序,以及如何访问命令行参数?

  7. 在提示符下输入什么来构建并执行 C#源代码?

  8. 使用.NET Interactive Notebooks 编写 C#代码有哪些好处?

  9. 你会在哪里寻求 C#关键字的帮助?

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

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

练习 1.2 – 随处练习 C#

你不需要 Visual Studio Code,甚至不需要 Windows 或 Mac 版的 Visual Studio 2022 来编写 C#。你可以访问.NET Fiddle – dotnetfiddle.net/ – 并开始在线编码。

练习 1.3 – 探索主题

书籍是一种精心策划的体验。我试图找到在印刷书籍中包含的主题的正确平衡。我在 GitHub 仓库中为本书所写的其他内容也可以找到。

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

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

第一章 - 你好 C#,欢迎来到.NET

总结

在本章中,我们:

  • 设置你的开发环境。

  • 讨论了现代.NET、.NET Core、.NET Framework、Xamarin 和.NET Standard 之间的相似之处和差异。

  • 使用 Visual Studio Code 与.NET SDK 和 Windows 版的 Visual Studio 2022 创建了一些简单的控制台应用程序。

  • 使用.NET Interactive Notebooks 执行代码片段以供学习。

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

  • 而且,最重要的是,学会了如何寻求帮助。

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

第二章:说 C#

本章全是关于 C#编程语言基础的。在这一章中,你将学习如何使用 C#的语法编写语句,并介绍一些你每天都会用到的常用词汇。此外,到本章结束时,你将自信地知道如何暂时存储和处理计算机内存中的信息。

本章涵盖以下主题:

  • 介绍 C#语言

  • 理解 C#语法和词汇

  • 使用变量

  • 深入探讨控制台应用程序

介绍 C#语言

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

编程语言与人类语言有许多相似之处,不同之处在于编程语言中,你可以创造自己的词汇,就像苏斯博士那样!

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

"然后,只是为了展示给他们看,我将航行到卡特鲁,并带回一个伊特卡奇、一个普里普、一个普鲁,一个内克尔、一个书呆子和一个条纹薄棉布!"

理解语言版本和特性

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

本书涵盖了 C#语言从 1.0 版本到最新 10.0 版本的所有特性。

如果你已经对旧版本的 C#有所了解,并且对最新版本中的新特性感到兴奋,我通过列出语言版本及其重要的新特性,以及学习它们的章节号和主题标题,使你更容易跳转。

C# 1.0

C# 1.0 于 2002 年发布,包含了静态类型、面向对象现代语言的所有重要特性,正如您将在第二章第六章中所见。

C# 2.0

C# 2.0 于 2005 年发布,重点是使用泛型实现强类型化,以提高代码性能并减少类型错误,包括以下表格中列出的主题:

特性 章节 主题
可空值类型 6 使值类型可空
泛型 6 通过泛型使类型更可重用

C# 3.0

C# 3.0 于 2007 年发布,重点是启用声明式编码,包括语言集成查询LINQ)及相关特性,如匿名类型和 lambda 表达式,包括以下表格中列出的主题:

特性 章节 主题
隐式类型局部变量 2 推断局部变量的类型
LINQ 11 第十一章使用 LINQ 查询和操作数据中的所有主题

C# 4.0

C# 4.0 于 2010 年发布,专注于提高与 F#和 Python 等动态语言的互操作性,包括下表所列主题:

特性 章节 主题
动态类型 2 存储动态类型
命名/可选参数 5 可选参数和命名参数

C# 5.0

C# 5.0 于 2012 年发布,专注于通过自动实现复杂的状态机来简化异步操作支持,同时编写看似同步的语句,包括下表所列主题:

特性 章节 主题
简化的异步任务 12 理解异步和等待

C# 6.0

C# 6.0 于 2015 年发布,专注于对语言进行小幅优化,包括下表所列主题:

特性 章节 主题
static 导入 2 简化控制台使用
内插字符串 2 向用户显示输出
表达式主体成员 5 定义只读属性

C# 7.0

C# 7.0 于 2017 年 3 月发布,专注于添加元组和模式匹配等函数式语言特性,以及对语言进行小幅优化,包括下表所列主题:

特性 章节 主题
二进制字面量和数字分隔符 2 存储整数
模式匹配 3 使用if语句进行模式匹配
out 变量 5 控制参数传递方式
元组 5 使用元组组合多个值
局部函数 6 定义局部函数

C# 7.1

C# 7.1 于 2017 年 8 月发布,专注于对语言进行小幅优化,包括下表所列主题:

特性 章节 主题
默认字面量表达式 5 使用默认字面量设置字段
推断元组元素名称 5 推断元组名称
async 主方法 12 提高控制台应用的响应性

C# 7.2

C# 7.2 于 2017 年 11 月发布,专注于对语言进行小幅优化,包括下表所列主题:

特性 章节 主题
数值字面量中的前导下划线 2 存储整数
非尾随命名参数 5 可选参数和命名参数
private protected 访问修饰符 5 理解访问修饰符
可对元组类型进行==!=测试 5 元组比较

C# 7.3

C# 7.3 于 2018 年 5 月发布,专注于提高ref变量、指针和stackalloc的性能导向安全代码。这些特性对于大多数开发者来说较为高级且不常用,因此本书未涉及。

C# 8

C# 8 于 2019 年 9 月发布,专注于与空处理相关的主要语言变更,包括下表所列主题:

特性 章节 主题
可空引用类型 6 使引用类型可空
开关表达式 3 使用开关表达式简化switch语句
默认接口方法 6 理解默认接口方法

C# 9

C# 9 于 2020 年 11 月发布,专注于记录类型、模式匹配的改进以及最小代码控制台应用,包括下表列出的主题:

特性 章节 主题
最小代码控制台应用 1 顶层程序
目标类型的新 2 使用目标类型的新实例化对象
增强的模式匹配 5 对象的模式匹配
记录 5 使用记录

C# 10

C# 10 于 2021 年 11 月发布,专注于减少常见场景中所需代码量的特性,包括下表列出的主题:

特性 章节 主题
全局命名空间导入 2 导入命名空间
常量字符串字面量 2 使用插值字符串格式化
文件作用域命名空间 5 简化命名空间声明
必需属性 5 实例化时要求属性设置
记录结构 6 使用记录结构类型
空参数检查 6 方法参数中的空值检查

理解 C#标准

多年来,微软已向标准机构提交了几个版本的 C#,如下表所示:

C#版本 ECMA 标准 ISO/IEC 标准
1.0 ECMA-334:2003 ISO/IEC 23270:2003
2.0 ECMA-334:2006 ISO/IEC 23270:2006
5.0 ECMA-334:2017 ISO/IEC 23270:2018

C# 6 的标准仍处于草案阶段,而添加 C# 7 特性的工作正在推进中。微软于 2014 年将 C#开源。

目前,为了尽可能开放地进行 C#及相关技术的工作,有三个公开的 GitHub 仓库,如下表所示:

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

发现您的 C#编译器版本

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

.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 3.9 - 3.10 10.0

当您创建类库时,可以选择面向.NET Standard 以及现代.NET 的版本。它们有默认的 C#语言版本,如下表所示:

.NET Standard C#
2.0 7.3
2.1 8.0

如何输出 SDK 版本

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

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

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

    dotnet --version 
    
  3. 注意,撰写本文时的版本是 6.0.100,表明这是 SDK 的初始版本,尚未有任何错误修复或新功能,如下输出所示:

    6.0.100 
    

启用特定语言版本编译器

像 Visual Studio 和 dotnet 命令行接口这样的开发工具默认假设您想使用 C#语言编译器的最新主版本。在 C# 8.0 发布之前,C# 7.0 是默认使用的最新主版本。要使用 C#点版本(如 7.1、7.2 或 7.3)的改进,您必须在项目文件中添加 <LangVersion> 配置元素,如下所示:

<LangVersion>7.3</LangVersion> 

C# 10.0 随.NET 6.0 发布后,如果微软发布了 C# 10.1 编译器,并且您想使用其新语言特性,则必须在项目文件中添加配置元素,如下所示:

<LangVersion>10.1</LangVersion> 

以下表格展示了 <LangVersion> 的可能值:

LangVersion 描述
7, 7.1, 7.2, 7.38, 9, 10 输入特定版本号将使用已安装的该编译器。
latestmajor 使用最高的主版本号,例如,2019 年 8 月的 7.0,2019 年 10 月的 8.0,2020 年 11 月的 9.0,2021 年 11 月的 10.0。
latest 使用最高的主版本和次版本号,例如,2017 年的 7.2,2018 年的 7.3,2019 年的 8,以及 2022 年初可能的 10.1。
preview 使用可用的最高预览版本,例如,2021 年 7 月安装了.NET 6.0 Preview 6 的 10.0。

创建新项目后,您可以编辑 .csproj 文件并添加 <LangVersion> 元素,如下所示高亮显示:

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

您的项目必须针对 net6.0 以使用 C# 10 的全部功能。

良好实践:如果您正在使用 Visual Studio Code 且尚未安装,请安装名为 MSBuild 项目工具 的 Visual Studio Code 扩展。这将为您在编辑 .csproj 文件时提供 IntelliSense,包括轻松添加带有适当值的 <LangVersion> 元素。

理解 C#语法和词汇

要学习简单的 C#语言特性,您可以使用.NET 交互式笔记本,这消除了创建任何类型应用程序的需要。

要学习其他一些 C#语言特性,您需要创建一个应用程序。最简单的应用程序类型是控制台应用程序。

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

显示编译器版本

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

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

  2. 使用你喜欢的代码编辑器创建一个新的控制台应用,如下表所示:

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

    2. 工作区/解决方案文件和文件夹:Chapter02

    3. 项目文件和文件夹:Vocabulary

      最佳实践:如果你忘记了如何操作,或者没有完成前一章,那么第一章,*你好,C#!欢迎,.NET!*中给出了创建包含多个项目的工作区/解决方案的分步说明。

  3. 打开Program.cs文件,在注释下方,添加一个语句以显示 C#版本作为错误,如下面的代码所示:

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

    1. 在 Visual Studio Code 中,在终端输入命令dotnet run

    2. 在 Visual Studio 中,导航到调试 | 开始不调试。当提示继续并运行上次成功的构建时,点击

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

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

  6. 在 Visual Studio Code 的问题窗口或 Visual Studio 的错误列表窗口中的错误消息显示编译器版本:'4.0.0...',语言版本为10.0

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

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

理解 C#语法

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

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

语句

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

形容词black位于名词cat之前。而法语语法则不同;形容词位于名词之后:“le chat noir”。重要的是要记住,顺序很重要。

C#使用分号表示语句的结束。一个语句可以由多个变量表达式组成。例如,在以下语句中,totalPrice是一个变量,subtotal + salesTax是一个表达式:

var totalPrice = subtotal + salesTax; 

该表达式由一个名为subtotal的操作数、一个运算符+和另一个名为salesTax的操作数组成。操作数和运算符的顺序很重要。

注释

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

// sales tax must be added to the subtotal
var totalPrice = subtotal + salesTax; 

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

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

最佳实践:设计良好的代码,包括具有良好命名参数的函数签名和类封装,可以一定程度上自我说明。当你发现自己需要在代码中添加过多注释和解释时,问问自己:我能否通过重写(即重构)这段代码,使其在不依赖长篇注释的情况下更易于理解?

您的代码编辑器具有命令,使得添加和删除注释字符更加容易,如下表所示:

  • Windows 版 Visual Studio 2022:导航至编辑 | 高级 | 注释选择取消注释选择

  • Visual Studio Code:导航至编辑 | 切换行注释切换块注释

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

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

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

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

  • 命名空间包含类等类型,用于将它们组合在一起。

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

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

语句和块的示例

在面向 .NET 5.0 的控制台应用程序项目模板中,请注意,项目模板已为您编写了 C# 语法的示例。我已对语句和块添加了一些注释,如下列代码所示:

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# 词汇

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

本书中您将看到的一些预定义保留关键字包括usingnamespaceclassstaticintstringdoubleboolifswitchbreakwhiledoforforeachandornotrecordinit

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

还有其他只在特定上下文中具有特殊意义的上下文关键字。

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

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

英语有超过 25 万个不同的单词,那么 C#是如何仅用大约 100 个关键字就做到的呢?此外,如果 C#只有英语单词数量的 0.0416%,为什么它还这么难学呢?

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

世界各地的程序员都必须学习英语,因为大多数编程语言使用英语单词,如 namespace 和 class。有些编程语言使用其他人类语言,如阿拉伯语,但这些语言较为罕见。如果你对此感兴趣,这个 YouTube 视频展示了一种阿拉伯编程语言的演示:youtu.be/dkO8cdwf6v8

更改 C#语法的颜色方案

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

  1. 在 Visual Studio Code 中,导航至代码 | 首选项 | 颜色主题(在 Windows 上的文件菜单中)。

  2. 选择一个颜色主题。作为参考,我将使用**Light+(默认浅色)**颜色主题,以便截图在印刷书籍中看起来效果良好。

  3. 在 Visual Studio 中,导航至工具 | 选项

  4. 选项对话框中,选择字体和颜色,然后选择您希望自定义的显示项。

帮助编写正确的代码

像记事本这样的纯文本编辑器不会帮助你书写正确的英语。同样,记事本也不会帮助你编写正确的 C#代码。

Microsoft Word 可以通过用红色波浪线标记拼写错误来帮助你书写英语,例如 Word 会提示"icecream"应为 ice-cream 或 ice cream,并用蓝色波浪线标记语法错误,例如句子应以大写字母开头。

同样,Visual Studio Code 的 C#扩展程序和 Visual Studio 通过标记拼写错误(例如方法名应为WriteLine,其中 L 为大写)和语法错误(例如语句必须以分号结尾)来帮助你编写 C#代码。

C#扩展程序会不断监视你输入的内容,并通过用彩色波浪线标记问题来给予你反馈,类似于 Microsoft Word。

让我们看看它的实际应用:

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

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

  3. 在 Visual Studio Code 中,导航至视图 | 问题,或在 Visual Studio 中导航至视图 | 错误列表,并注意代码错误下方会出现红色波浪线,并且会显示详细信息,如图 2.2所示:

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

  4. 修复这两个编码错误。

导入命名空间

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

System.Console.WriteLine指示编译器在一个名为Console的类型中查找名为WriteLine的方法,该类型位于名为System的命名空间中。为了简化我们的代码,在.NET 6.0 之前的每个版本的控制台应用程序项目模板中,都会在代码文件顶部添加一个语句,告诉编译器始终在System命名空间中查找未带命名空间前缀的类型,如下列代码所示:

using System; // import the System namespace 

我们称之为导入命名空间。导入命名空间的效果是,该命名空间中的所有可用类型将无需输入命名空间前缀即可供您的程序使用,并在编写代码时在 IntelliSense 中可见。

.NET Interactive 笔记本会自动导入大多数命名空间。

隐式和全局导入命名空间

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

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

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

C# 10 引入了一些简化导入命名空间的新特性。

首先,global using语句意味着您只需在一个.cs文件中导入一个命名空间,它将在所有.cs文件中可用。您可以将global using语句放在Program.cs文件中,但我建议为这些语句创建一个单独的文件,命名为类似GlobalUsings.csGlobalNamespaces.cs,如下列代码所示:

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

最佳实践:随着开发者逐渐习惯这一新的 C#特性,我预计该文件的一种命名约定将成为标准。

其次,任何面向.NET 6.0 的项目,因此使用 C# 10 编译器,会在obj文件夹中生成一个.cs文件,以隐式全局导入一些常见命名空间,如System。隐式导入的命名空间列表取决于您所针对的 SDK,如下表所示:

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.Json``Microsoft.AspNetCore.Builder``Microsoft.AspNetCore.Hosting``Microsoft.AspNetCore.Http``Microsoft.AspNetCore.Routing``Microsoft.Extensions.Configuration``Microsoft.Extensions.DependencyInjection``Microsoft.Extensions.Hosting``Microsoft.Extensions.Logging
Microsoft.NET.Sdk.Worker Microsoft.NET.Sdk相同,并包括:Microsoft.Extensions.Configuration``Microsoft.Extensions.DependencyInjection``Microsoft.Extensions.Hosting``Microsoft.Extensions.Logging

让我们看看当前自动生成的隐式导入文件:

  1. 解决方案资源管理器中,选择词汇项目,打开显示所有文件按钮,并注意编译器生成的binobj文件夹可见。

  2. 展开obj文件夹,展开Debug文件夹,展开net6.0文件夹,并打开名为Vocabulary.GlobalUsings.g.cs的文件。

  3. 注意此文件是由编译器为面向.NET 6.0 的项目自动创建的,并且它导入一些常用命名空间,包括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; 
    
  4. 关闭Vocabulary.GlobalUsings.g.cs文件。

  5. 解决方案资源管理器中,选择项目,然后向项目文件添加额外的条目以控制哪些命名空间被隐式导入,如下所示高亮显示的标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
     **<ItemGroup>**
     **<Using Remove=****"System.Threading"** **/>**
     **<Using Include=****"System.Numerics"** **/>**
     **</ItemGroup>**
    </Project> 
    
  6. 保存对项目文件的更改。

  7. 展开obj文件夹,展开Debug文件夹,展开net6.0文件夹,并打开名为Vocabulary.GlobalUsings.g.cs的文件。

  8. 注意此文件现在导入System.Numerics而不是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.Tasks;
    **global****using****global****::System.Numerics;** 
    
  9. 关闭Vocabulary.GlobalUsings.g.cs文件。

您可以通过从项目文件中删除一个条目来禁用所有 SDK 的隐式导入命名空间功能,如下所示的标记:

<ImplicitUsings>enable</ImplicitUsings> 

动词是方法

在英语中,动词是表示动作或行为的词,如跑和跳。在 C#中,表示动作或行为的词被称为方法。C#中有数十万个方法可供使用。在英语中,动词根据动作发生的时间改变其书写方式。例如,Amir 过去在跳,Beth 现在跳,他们过去跳,Charlie 将来会跳

在 C#中,像WriteLine这样的方法会根据具体操作的细节改变其调用或执行方式。这称为重载,我们将在第五章使用面向对象编程构建自己的类型中详细介绍。但现在,请考虑以下示例:

// outputs the current line terminator string
// by default, this is a carriage-return and line feed
Console.WriteLine();
// outputs the greeting and the current line terminator string
Console.WriteLine("Hello Ahmed");
// outputs a formatted number and date and the current line terminator string
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,这是 C#关键字别名int映射到的,以及许多更复杂的类型,如System.Xml.Linq.XDocument

值得注意的是,术语类型经常与混淆。你玩过二十个问题这个聚会游戏吗,也称为动物、植物或矿物?在游戏中,一切都可以归类为动物、植物或矿物。在 C#中,每个类型都可以归类为classstructenuminterfacedelegate。您将在第六章实现接口和继承类中学习这些含义。例如,C#关键字string是一个class,但int是一个struct。因此,最好使用术语类型来指代两者。

揭示 C#词汇的范围

我们知道 C#中有超过 100 个关键字,但有多少种类型呢?让我们编写一些代码来找出在我们的简单控制台应用程序中 C#可用的类型(及其方法)的数量。

现在不必担心这段代码是如何工作的,但要知道它使用了一种称为反射的技术:

  1. 我们将首先在Program.cs文件顶部导入System.Reflection命名空间,如下所示:

    using System.Reflection; 
    
  2. 删除写入Hello World!的语句,并用以下代码替换:

    Assembly? assembly = Assembly.GetEntryAssembly();
    if (assembly == null) return;
    // loop through the assemblies that this app references
    foreach (AssemblyName name in assembly.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 methods
        methodCount += t.GetMethods().Count();
      }
      // output the count of types and their methods
      Console.WriteLine(
        "{0:N0} types with {1:N0} methods in {2} assembly.",
        arg0: a.DefinedTypes.Count(),
        arg1: methodCount, arg2: name.Name);
    } 
    
  3. 运行代码。您将看到在您的操作系统上运行最简单的应用程序时,实际可用的类型和方法的数量。显示的类型和方法的数量将根据您使用的操作系统而有所不同,如下所示:

    // Output on Windows
    0 types with 0 methods in System.Runtime assembly.
    106 types with 1,126 methods in System.Linq assembly.
    44 types with 645 methods in System.Console assembly.
    // Output on macOS
    0 types with 0 methods in System.Runtime assembly.
    103 types with 1,094 methods in System.Linq assembly.
    57 types with 701 methods in System.Console assembly. 
    

    为什么System.Runtime程序集中不包含任何类型?这个程序集很特殊,因为它只包含类型转发器而不是实际类型。类型转发器表示已在外部.NET 或其他高级原因中实现的一种类型。

  4. 在导入命名空间后,在文件顶部添加语句以声明一些变量,如下面的代码中突出显示的那样:

    using System.Reflection;
    **// declare some unused variables using types**
    **// in additional assemblies**
    **System.Data.DataSet ds;**
    **HttpClient client;** 
    

    通过声明使用其他程序集中的类型的变量,这些程序集会随我们的应用程序一起加载,这使得我们的代码能够看到其中的所有类型和方法。编译器会警告你有未使用的变量,但这不会阻止你的代码运行。

  5. 再次运行控制台应用程序并查看结果,结果应该类似于以下输出:

    // Output on Windows
    0 types with 0 methods in System.Runtime assembly.
    383 types with 6,854 methods in System.Data.Common assembly.
    456 types with 4,590 methods in System.Net.Http assembly.
    106 types with 1,126 methods in System.Linq assembly.
    44 types with 645 methods in System.Console assembly.
    // Output on macOS
    0 types with 0 methods in System.Runtime assembly.
    376 types with 6,763 methods in System.Data.Common assembly.
    522 types with 5,141 methods in System.Net.Http assembly.
    103 types with 1,094 methods in System.Linq assembly.
    57 types with 701 methods in System.Console assembly. 
    

现在,你更清楚为什么学习 C#是一项挑战,因为有如此多的类型和方法需要学习。方法仅是类型可以拥有的成员类别之一,而你和其他程序员不断定义新的类型和成员!

处理变量

所有应用程序都处理数据。数据进来,数据被处理,然后数据出去。

数据通常从文件、数据库或用户输入进入我们的程序,并可以暂时存储在变量中,这些变量将存储在运行程序的内存中。当程序结束时,内存中的数据就会丢失。数据通常输出到文件和数据库,或输出到屏幕或打印机。使用变量时,首先应考虑变量在内存中占用多少空间,其次应考虑处理速度有多快。

我们通过选择适当的类型来控制这一点。你可以将intdouble等简单常见类型视为不同大小的存储箱,其中较小的箱子占用较少的内存,但处理速度可能不那么快;例如,在 64 位操作系统上,添加 16 位数字可能不如添加 64 位数字快。其中一些箱子可能堆放在附近,而有些可能被扔进更远的堆中。

命名事物和赋值

事物有命名规范,遵循这些规范是良好的实践,如下表所示:

命名规范 示例 用途
驼峰式 cost, orderDetail, dateOfBirth 局部变量,私有字段
标题式(也称为帕斯卡式) String, Int32, Cost, DateOfBirth, Run 类型,非私有字段,以及其他成员如方法

良好实践:遵循一致的命名规范将使你的代码易于被其他开发者(以及未来的你自己)理解。

下面的代码块展示了一个声明命名局部变量并使用=符号为其赋值的示例。你应该注意到,可以使用 C# 6.0 引入的关键字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}."); 

前面代码中双引号内的消息因为打印页面的宽度太窄而换到第二行。在你的代码编辑器中输入这样的语句时,请将其全部输入在同一行。

字面值

当你给变量赋值时,你通常,但并非总是,赋一个字面值。但什么是字面值呢?字面值是一种表示固定值的符号。数据类型有不同的字面值表示法,在接下来的几节中,你将看到使用字面值表示法给变量赋值的示例。

存储文本

对于文本,单个字母,如A,存储为char类型。

最佳实践:实际上,这可能比那更复杂。埃及象形文字 A002(U+13001)需要两个System.Char值(称为代理对)来表示它:\uD80C\uDC01。不要总是假设一个char等于一个字母,否则你可能在你的代码中引入奇怪的错误。

使用单引号将字面值括起来,或赋值给一个虚构函数调用的返回值,来给char赋值,如下代码所示:

char letter = 'A'; // assigning literal characters
char digit = '1'; 
char symbol = '$';
char userChoice = GetSomeKeystroke(); // 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 a fictitious function
string address = GetAddressFromDatabase(id: 563); 

理解逐字字符串

当在string变量中存储文本时,你可以包含转义序列,这些序列使用反斜杠表示特殊字符,如制表符和新行,如下代码所示:

string fullNameWithTabSeparator = "Bob\tSmith"; 

但如果你要存储 Windows 上的文件路径,其中一个文件夹名以T开头,如下代码所示,该怎么办?

string filePath = "C:\televisions\sony\bravia.txt"; 

编译器会将\t转换为制表符,你将会得到错误!

你必须以前缀@符号使用逐字字面string,如下代码所示:

string filePath = @"C:\televisions\sony\bravia.txt"; 

总结如下:

  • 字面字符串:用双引号括起来的字符。它们可以使用转义字符,如\t表示制表符。要表示反斜杠,使用两个:\\

  • 逐字字符串:以@为前缀的字面字符串,用于禁用转义字符,使得反斜杠就是反斜杠。它还允许string值跨越多行,因为空白字符被视为其本身,而不是编译器的指令。

  • 内插字符串:以$为前缀的字面字符串,用于启用嵌入格式化变量。你将在本章后面了解更多关于这方面的内容。

存储数字

数字是我们想要进行算术计算的数据,例如乘法。电话号码不是数字。要决定一个变量是否应存储为数字,请问自己是否需要对该数字执行算术运算,或者该数字是否包含非数字字符,如括号或连字符来格式化数字,例如(414) 555-1234。在这种情况下,该数字是一串字符,因此应将其存储为string

数字可以是自然数,如 42,用于计数(也称为整数);它们也可以是负数,如-42(称为整数);或者,它们可以是实数,如 3.9(带有小数部分),在计算中称为单精度或双精度浮点数。

让我们探索数字:

  1. 使用您偏好的代码编辑器,在Chapter02工作区/解决方案中添加一个名为Numbers控制台应用程序

    1. 在 Visual Studio Code 中,选择Numbers作为活动的 OmniSharp 项目。当看到弹出警告消息提示缺少必需资产时,点击以添加它们。

    2. 在 Visual Studio 中,将启动项目设置为当前选择。

  2. Program.cs中,删除现有代码,然后输入语句以声明一些使用各种数据类型的数字变量,如下列代码所示:

    // unsigned integer means positive whole number or 0
    uint naturalNumber = 23;
    // integer means negative or positive whole number or 0
    int integerNumber = -23;
    // float means single-precision floating point
    // F suffix makes it a float literal
    float realNumber = 2.3F;
    // double means double-precision floating point
    double anotherRealNumber = 2.3; // double literal 
    

存储整数

您可能知道计算机将所有内容存储为位。位的值要么是 0,要么是 1。这称为二进制数系统。人类使用十进制数系统

十进制数系统,又称基数 10,其基数为 10,意味着有十个数字,从 0 到 9。尽管它是人类文明中最常用的数基,但科学、工程和计算领域中其他数基系统也很流行。二进制数系统,又称基数 2,其基数为 2,意味着有两个数字,0 和 1。

下表展示了计算机如何存储十进制数 10。请注意 8 和 2 列中值为 1 的位;8 + 2 = 10:

128 64 32 16 8 4 2 1
0 0 0 0 1 0 1 0

因此,十进制中的10在二进制中是00001010

通过使用数字分隔符提高可读性

C# 7.0 及更高版本中的两项改进是使用下划线字符_作为数字分隔符,以及支持二进制字面量。

您可以在数字字面量的任何位置插入下划线,包括十进制、二进制或十六进制表示法,以提高可读性。

例如,您可以将 100 万在十进制表示法(即基数 10)中写为1_000_000

您甚至可以使用印度常见的 2/3 分组:10_00_000

使用二进制表示法

要使用二进制表示法,即基数 2,仅使用 1 和 0,请以0b开始数字字面量。要使用十六进制表示法,即基数 16,使用 0 到 9 和 A 到 F,请以0x开始数字字面量。

探索整数

让我们输入一些代码来查看一些示例:

  1. Program.cs中,输入语句以声明一些使用下划线分隔符的数字变量,如下列代码所示:

    // 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;
    // check the three variables have the same value
    // both statements output true 
    Console.WriteLine($"{decimalNotation == binaryNotation}"); 
    Console.WriteLine(
      $"{decimalNotation == hexadecimalNotation}"); 
    
  2. 运行代码并注意结果是所有三个数字都相同,如下列输出所示:

    True
    True 
    

计算机总能使用int类型或其同类类型(如longshort)精确表示整数。

存储实数

计算机不能总是精确地表示实数,即小数或非整数。floatdouble类型使用单精度和双精度浮点来存储实数。

大多数编程语言都实现了 IEEE 浮点算术标准。IEEE 754 是由电气和电子工程师协会IEEE)于 1985 年制定的浮点算术技术标准。

下表简化了计算机如何用二进制表示数字12.75。注意 8、4、½和¼列中值为1的位。

8 + 4 + ½ + ¼ = 12¾ = 12.75。

128 64 32 16 8 4 2 1 . ½ ¼ 1/8 1/16
0 0 0 0 1 1 0 0 . 1 1 0 0

因此,十进制的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}."); 
    

    本书中打印页面的宽度使得string值(用双引号括起来)跨越多行。你必须在一行内输入它们,否则会遇到编译错误。

  2. 运行代码并查看输出,如图 2.3所示:

    图 2.3:常见数字数据类型的大小和范围信息

int变量使用四个字节的内存,并可以存储大约 20 亿以内的正负数。double变量使用八个字节的内存,可以存储更大的值!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.1这样的数字实际上无法用浮点值表示。

一般来说,只有当精确度,尤其是比较两个数的相等性不重要时,才应使用double。例如,当你测量一个人的身高,并且只会使用大于或小于进行比较,而永远不会使用等于时,就可能属于这种情况。

前面代码的问题在于计算机如何存储数字0.1,或其倍数。为了在二进制中表示0.1,计算机在 1/16 列存储 1,在 1/32 列存储 1,在 1/256 列存储 1,在 1/512 列存储 1,等等。

0.1在十进制中是二进制的0.00011001100110011…,无限重复:

4 2 1 . ½ ¼ 1/8 1/16 1/32 1/64 1/128 1/256 1/512 1/1024 1/2048
0 0 0 . 0 0 0 1 1 0 0 1 1 0 0

良好实践:切勿使用==比较double值。在第一次海湾战争期间,美国爱国者导弹电池在其计算中使用了double值。这种不准确性导致它未能跟踪并拦截来袭的伊拉克飞毛腿导弹,导致 28 名士兵丧生;你可以在www.ima.umn.edu/~arnold/disasters/patriot.html阅读有关此事件的信息。

  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值是否小于或大于等是可以的。使用decimal表示货币、CAD 图纸、通用工程以及任何对实数的精确性很重要的地方。

double类型有一些有用的特殊值:double.NaN表示非数字(例如,除以零的结果),double.Epsilon表示double中可以存储的最小的正数,double.PositiveInfinitydouble.NegativeInfinity表示无限大的正负值。

存储布尔值

布尔值只能包含truefalse这两个文字值之一,如下所示:

bool happy = true; 
bool sad = false; 

它们最常用于分支和循环。你不需要完全理解它们,因为它们在第三章控制流程、转换类型和处理异常中会有更详细的介绍。

存储任何类型的对象

有一个名为object的特殊类型,可以存储任何类型的数据,但其灵活性是以代码更混乱和可能的性能下降为代价的。由于这两个原因,应尽可能避免使用它。以下步骤展示了如果需要使用对象类型时如何操作:

  1. 使用您喜欢的代码编辑器,在Chapter02工作区/解决方案中添加一个新的控制台应用程序,命名为Variables

  2. 在 Visual Studio Code 中,选择Variables作为活动 OmniSharp 项目。当看到弹出警告消息提示缺少必需资产时,点击以添加它们。

  3. 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; // gives compile error!
    int length2 = ((string)name).Length; // tell compiler it is a string
    Console.WriteLine($"{name} has {length2} characters."); 
    
  4. 运行代码并注意第四条语句无法编译,因为name变量的数据类型编译器未知,如图 2.4 所示:

    图 2.4:对象类型没有 Length 属性

  5. 在语句开头添加双斜杠注释,以“注释掉”无法编译的语句,使其无效。

  6. 再次运行代码并注意,如果程序员明确告诉编译器object变量包含一个string,通过前缀加上类型转换表达式如(string),编译器可以访问string的长度,如下所示:

    Amir is 1.88 metres tall. 
    Amir has 4 characters. 
    

object类型自 C#的第一个版本起就可用,但 C# 2.0 及更高版本有一个更好的替代方案,称为泛型,我们将在第六章实现接口和继承类中介绍,它将为我们提供所需的灵活性,而不会带来性能开销。

存储动态类型

还有一个名为dynamic的特殊类型,也可以存储任何类型的数据,但其灵活性甚至超过了object,同样以性能为代价。dynamic关键字是在 C# 4.0 中引入的。然而,与object不同,存储在变量中的值可以在没有显式类型转换的情况下调用其成员。让我们利用dynamic类型:

  1. 添加语句以声明一个dynamic变量,然后分配一个string字面值,接着是一个整数值,最后是一个整数数组,如下所示:

    // storing a string in a dynamic object
    // string has a Length property
    dynamic something = "Ahmed";
    // int does not have a Length property
    // something = 12;
    // an array of any type has a Length property
    // something = new[] { 3, 5, 7 }; 
    
  2. 添加一个语句以输出dynamic变量的长度,如下所示:

    // this compiles but would throw an exception at run-time
    // if you later store a data type that does not have a
    // property named Length
    Console.WriteLine($"Length is {something.Length}"); 
    
  3. 运行代码并注意它有效,因为string值确实具有Length属性,如下所示:

    Length is 5 
    
  4. 取消注释分配int值的语句。

  5. 运行代码并注意运行时错误,因为int没有Length属性,如下所示:

    Unhandled exception. Microsoft.CSharp.RuntimeBinder.RuntimeBinderException: 'int' does not contain a definition for 'Length' 
    
  6. 取消注释分配数组的语句。

  7. 运行代码并注意输出,因为一个包含三个int值的数组确实具有Length属性,如下所示:

    Length is 3 
    

动态的一个限制是代码编辑器无法显示 IntelliSense 来帮助你编写代码。这是因为编译器在构建时无法检查类型是什么。相反,CLR 在运行时检查成员,并在缺失时抛出异常。

异常是一种指示运行时出现问题的方式。你将在第三章控制流程、转换类型和处理异常中了解更多关于它们以及如何处理它们的信息。

声明局部变量

局部变量在方法内部声明,它们仅在方法执行期间存在,一旦方法返回,分配给任何局部变量的内存就会被释放。

严格来说,值类型会被立即释放,而引用类型必须等待垃圾回收。你将在第六章实现接口和继承类中了解值类型和引用类型之间的区别。

指定局部变量的类型

让我们探讨使用特定类型和类型推断声明的局部变量:

  1. 使用特定类型声明并赋值给一些局部变量的类型语句,如下列代码所示:

    int population = 66_000_000; // 66 million in UK
    double weight = 1.88; // in kilograms
    decimal price = 4.99M; // in pounds sterling
    string fruit = "Apples"; // strings use double-quotes
    char letter = 'Z'; // chars use single-quotes
    bool happy = true; // Booleans have value of true or false 
    

根据你的代码编辑器和配色方案,它会在每个变量名下显示绿色波浪线,并将其文本颜色变浅,以警告你该变量已被赋值但从未使用过。

推断局部变量的类型

你可以使用var关键字来声明局部变量。编译器将从赋值运算符=后分配的值推断出类型。

没有小数点的字面数字默认推断为整数变量,除非你添加后缀,如下列列表所述:

  • L:推断为长整型

  • UL:推断为无符号长整型

  • M:推断为小数

  • D:推断为双精度浮点数

  • F:推断为单精度浮点数

带有小数点的字面数字默认推断为双精度浮点数,除非你添加M后缀,在这种情况下,它推断为小数变量,或者添加F后缀,在这种情况下,它推断为单精度浮点数变量。

双引号表示字符串变量,单引号表示字符变量,而值则暗示了布尔类型:

  1. 修改前述语句以使用var,如下列代码所示:

    var population = 66_000_000; // 66 million in UK
    var weight = 1.88; // in kilograms
    var price = 4.99M; // in pounds sterling
    var fruit = "Apples"; // strings use double-quotes
    var letter = 'Z'; // chars use single-quotes
    var happy = true; // Booleans have value of true or false 
    
  2. 将鼠标悬停在每个var关键字上,并注意你的代码编辑器会显示一个带有关于已推断类型信息的工具提示。

  3. 在类文件顶部,导入用于处理 XML 的命名空间,以便我们能够声明一些使用该命名空间中类型的变量,如下列代码所示:

    using System.Xml; 
    

    良好实践:如果你正在使用.NET 交互式笔记本,那么请在上层代码单元格中添加using语句,并在编写主代码的代码单元格上方。然后点击执行单元格以确保命名空间被导入。它们随后将在后续代码单元格中可用。

  4. 在前述语句下方,添加语句以创建一些新对象,如下列代码所示:

    // good use of var because it avoids the repeated type
    // as shown in the more verbose second statement
    var xml1 = new XmlDocument(); 
    XmlDocument xml2 = new XmlDocument();
    // 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。如果有疑问,就明确写出类型!

使用目标类型的新来实例化对象

使用 C# 9,微软引入了一种称为目标类型的新的实例化对象的语法。在实例化对象时,你可以先指定类型,然后使用new而不重复类型,如下面的代码所示:

XmlDocument xml3 = new(); // target-typed new in C# 9 or later 

如果你有一个需要设置字段或属性的类型,则可以推断类型,如下面的代码所示:

class Person
{
  public DateTime BirthDate;
}
Person kim = new();
kim.BirthDate = new(1967, 12, 26); // instead of: new DateTime(1967, 12, 26) 

最佳实践:除非必须使用版本 9 之前的 C#编译器,否则请使用目标类型的新来实例化对象。我在本书的其余部分都使用了目标类型的新。如果你发现我遗漏了任何情况,请告诉我!

获取和设置类型的默认值

除了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)}"); 
    
  2. 运行代码并查看结果,注意如果你的输出日期和时间格式与英国不同,以及null值输出为空string,如下面的输出所示:

    default(int) = 0 
    default(bool) = False
    default(DateTime) = 01/01/0001 00:00:00 
    default(string) = 
    
  3. 添加语句以声明一个数字,赋予一个值,然后将其重置为其默认值,如下面的代码所示:

    int number = 13;
    Console.WriteLine($"number has been set to: {number}");
    number = default;
    Console.WriteLine($"number has been reset to its default: {number}"); 
    
  4. 运行代码并查看结果,如下面的输出所示:

    number has been set to: 13
    number has been reset to its default: 0 
    

在数组中存储多个值

当你需要存储同一类型的多个值时,你可以声明一个数组。例如,当你需要在string数组中存储四个名字时,你可能会这样做。

接下来你将编写的代码将为存储四个string值的数组分配内存。然后,它将在索引位置 0 到 3 处存储string值(数组通常的下界为零,因此最后一项的索引比数组长度小一)。

良好实践:不要假设所有数组都从零开始计数。.NET 中最常见的数组类型是szArray,一种单维零索引数组,它们使用正常的[]语法。但.NET 也有mdArray,多维数组,它们不必具有零的下界。这些很少使用,但你应该知道它们存在。

最后,它将使用for语句遍历数组中的每个项,我们将在第三章控制流程、转换类型和处理异常中更详细地介绍这一点。

让我们看看如何使用数组:

  1. 输入语句以声明和使用string值的数组,如下面的代码所示:

    string[] names; // can reference any size array of strings
    // allocating memory for four strings in an array
    names = new string[4];
    // storing items at index positions
    names[0] = "Kate";
    names[1] = "Jack"; 
    names[2] = "Rebecca"; 
    names[3] = "Tom";
    // looping through the names
    for (int i = 0; i < names.Length; i++)
    {
      // output the item at index position i
      Console.WriteLine(names[i]);
    } 
    
  2. 运行代码并记录结果,如下面的输出所示:

    Kate 
    Jack 
    Rebecca 
    Tom 
    

数组在内存分配时总是具有固定大小,因此在实例化之前,你需要决定要存储多少项。

除了上述三个步骤定义数组的替代方法是使用数组初始化器语法,如下面的代码所示:

string[] names2 = new[] { "Kate", "Jack", "Rebecca", "Tom" }; 

当你使用new[]语法为数组分配内存时,你必须在大括号中至少包含一个项,以便编译器可以推断数据类型。

数组适用于临时存储多个项,但当需要动态添加和删除项时,集合是更灵活的选择。目前你不需要担心集合,因为我们在第八章使用常见的.NET 类型中会涉及它们。

深入探索控制台应用程序

我们已经创建并使用了基本的控制台应用程序,但现在我们应该更深入地研究它们。

控制台应用程序是基于文本的,并在命令行上运行。它们通常执行需要脚本的简单任务,例如编译文件或加密配置文件的一部分。

同样,它们也可以接受参数来控制其行为。

一个例子是使用指定的名称而不是当前文件夹的名称创建一个新的控制台应用程序,如下面的命令行所示:

dotnet new console -lang "F#" --name "ExploringConsole" 

向用户显示输出

控制台应用程序最常见的两个任务是写入和读取数据。我们已经一直在使用WriteLine方法输出,但如果我们不希望在行尾有回车,我们可以使用Write方法。

使用编号的位置参数进行格式化

生成格式化字符串的一种方法是使用编号的位置参数。

此功能受到WriteWriteLine等方法的支持,对于不支持此功能的方法,可以使用stringFormat方法对string参数进行格式化。

本节的前几个代码示例将与.NET Interactive 笔记本一起工作,因为它们是关于输出到控制台的。在本节后面,你将学习通过控制台获取输入,遗憾的是笔记本不支持这一点。

让我们开始格式化:

  1. 使用你偏好的代码编辑器,在Chapter02工作区/解决方案中添加一个新的控制台应用程序,命名为Formatting

  2. 在 Visual Studio Code 中,选择Formatting作为活动的 OmniSharp 项目。

  3. Program.cs中,输入语句以声明一些数字变量并将它们写入控制台,如下所示:

    int numberOfApples = 12; 
    decimal pricePerApple = 0.35M;
    Console.WriteLine(
      format: "{0} apples costs {1:C}", 
      arg0: numberOfApples,
      arg1: pricePerApple * numberOfApples);
    string formatted = string.Format(
      format: "{0} apples costs {1:C}",
      arg0: numberOfApples,
      arg1: pricePerApple * numberOfApples);
    //WriteToFile(formatted); // writes the string into a file 
    

WriteToFile方法是一个不存在的用于说明概念的方法。

良好实践:一旦你对格式化字符串更加熟悉,你应该停止命名参数,例如,停止使用format:arg0:arg1:。前面的代码使用了非规范的风格来显示01的来源,而你正在学习。

使用插值字符串进行格式化

C# 6.0 及更高版本有一个名为插值字符串的便捷功能。以$为前缀的字符串可以使用大括号包围变量或表达式的名称,以在该字符串中的该位置输出该变量或表达式的当前值,如下所示:

  1. Program.cs文件底部输入如下所示的语句:

    Console.WriteLine($"{numberOfApples} apples costs {pricePerApple * numberOfApples:C}"); 
    
  2. 运行代码并查看结果,如下面的部分输出所示:

     12 apples costs £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}"; 

这只适用于组合字符串常量值。它不能与其他类型(如数字)一起工作,这些类型需要运行时数据类型转换。

理解格式字符串

变量或表达式可以在逗号或冒号后使用格式字符串进行格式化。

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(
      format: "{0,-10} {1,6:N0}",
      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 
    

从用户获取文本输入

我们可以使用ReadLine方法从用户获取文本输入。该方法等待用户输入一些文本,一旦用户按下 Enter 键,用户输入的任何内容都会作为string值返回。

良好实践:如果你在本节使用.NET Interactive 笔记本,请注意它不支持使用Console.ReadLine()从控制台读取输入。相反,你必须设置文字值,如下所示:string? firstName = "Gary";。这通常更快,因为你可以简单地更改文字string值并点击执行单元格按钮,而不必每次想输入不同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}."); 
    
  2. 运行代码,然后输入姓名和年龄,如下所示:

    Type your name and press ENTER: Gary 
    Type your age and press ENTER: 34 
    Hello Gary, you look good for 34. 
    

string?数据类型声明末尾的问号表示我们承认从ReadLine调用可能返回null(空)值。你将在第六章实现接口和继承类中了解更多关于这方面的内容。

简化控制台的使用

在 C# 6.0 及更高版本中,using语句不仅可以用于导入命名空间,还可以通过导入静态类进一步简化我们的代码。然后,我们就不需要在代码中输入Console类型名称。你可以使用代码编辑器的查找和替换功能来删除我们之前写过的Console

  1. Program.cs文件顶部,添加一个语句以静态导入System.Console类,如下所示:

    using static System.Console; 
    
  2. 选择代码中的第一个Console.,确保也选中了Console单词后的点。

  3. 在 Visual Studio 中,导航至编辑 | 查找和替换 | 快速替换,或在 Visual Studio Code 中,导航至编辑 | 替换,并注意一个覆盖对话框出现,准备让你输入你想替换**Console.**的内容,如图 2.5所示:

    图 2.5:在 Visual Studio 中使用替换功能简化代码

  4. 将替换框留空,点击全部替换按钮(替换框右侧两个按钮中的第二个),然后通过点击替换框右上角的叉号关闭替换框。

从用户获取按键输入

我们可以使用ReadKey方法从用户获取按键输入。此方法等待用户按下键或键组合,然后将其作为ConsoleKeyInfo值返回。

在.NET Interactive 笔记本中,你将无法执行对ReadKey方法的调用,但如果你创建了一个控制台应用程序,那么让我们来探索读取按键操作:

  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 
    

在 Visual Studio Code 内的终端中运行控制台应用程序时,某些键盘组合会在你的应用程序处理之前被代码编辑器或操作系统捕获。

向控制台应用程序传递参数

你可能一直在思考如何获取可能传递给控制台应用程序的任何参数。

在.NET 的每个版本中,直到 6.0 之前,控制台应用程序项目模板都显而易见,如下列代码所示:

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

string[] args参数在Program类的Main方法中声明并传递。它们是一个数组,用于向控制台应用程序传递参数。但在.NET 6.0 及更高版本中使用的顶级程序中,Program类及其Main方法,连同args字符串数组的声明都被隐藏了。诀窍在于你必须知道它仍然存在。

命令行参数由空格分隔。其他字符如连字符和冒号被视为参数值的一部分。

要在参数值中包含空格,请用单引号或双引号将参数值括起来。

假设我们希望能够在命令行输入一些前景色和背景色的名称,以及终端窗口的尺寸。我们可以通过从始终传递给Main方法(即控制台应用程序的入口点)的args数组中读取这些颜色和数字来实现。

  1. 使用你偏好的代码编辑器,在Chapter02工作区/解决方案中添加一个新的控制台应用程序,命名为Arguments。由于无法向笔记本传递参数,因此你不能使用.NET Interactive 笔记本。

  2. 在 Visual Studio Code 中,选择Arguments作为活动的 OmniSharp 项目。

  3. 添加一条语句以静态导入System.Console类型,并添加一条语句以输出传递给应用程序的参数数量,如下列代码所示:

    using static System.Console;
    WriteLine($"There are {args.Length} arguments."); 
    

    良好实践:记住在所有未来的项目中静态导入System.Console类型,以简化您的代码,因为这些说明不会每次都重复。

  4. 运行代码并查看结果,如下面的输出所示:

    There are 0 arguments. 
    
  5. 如果您使用的是 Visual Studio,那么导航到项目 | 属性 参数,选择调试选项卡,在应用程序参数框中输入一些参数,保存更改,然后运行控制台应用程序,如图2.6所示:图形用户界面,文本,应用程序 自动生成的描述

    图 2.6:在 Visual Studio 项目属性中输入应用程序参数

  6. 如果您使用的是 Visual Studio Code,那么在终端中,在dotnet run命令后输入一些参数,如下面的命令行所示:

    dotnet run firstarg second-arg third:arg "fourth arg" 
    
  7. 注意结果显示了四个参数,如下面的输出所示:

    There are 4 arguments. 
    
  8. 要枚举或迭代(即循环遍历)这四个参数的值,请在输出数组长度后添加以下语句:

    foreach (string arg in args)
    {
      WriteLine(arg);
    } 
    
  9. 再次运行代码,并注意结果显示了四个参数的详细信息,如下面的输出所示:

    There are 4 arguments. 
    firstarg
    second-arg 
    third:arg 
    fourth arg 
    

使用参数设置选项

现在我们将使用这些参数让用户为输出窗口的背景、前景和光标大小选择颜色。光标大小可以是 1 到 100 的整数值,1 表示光标单元格底部的线条,100 表示光标单元格高度的百分比。

System命名空间已经导入,以便编译器知道ConsoleColorEnum类型:

  1. 添加语句以警告用户,如果他们没有输入三个参数,然后解析这些参数并使用它们来设置控制台窗口的颜色和尺寸,如下面的代码所示:

    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 上支持。

  2. 在 Visual Studio 中,导航到项目 | 属性参数,并将参数更改为:red yellow 50,运行控制台应用程序,并注意光标大小减半,窗口中的颜色已更改,如图2.7所示:图形用户界面,应用程序,网站 自动生成的描述

    图 2.7:在 Windows 上设置颜色和光标大小

  3. 在 Visual Studio Code 中,使用参数运行代码,将前景色设置为红色,背景色设置为黄色,光标大小设置为 50%,如下面的命令所示:

    dotnet run red yellow 50 
    

    在 macOS 上,您会看到一个未处理的异常,如图2.8所示:

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

图 2.8:在不受支持的 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.IsWindows())
{
  // execute code that only works on Windows
}
else if (OperatingSystem.IsWindowsVersionAtLeast(major: 10))
{
  // execute code that only works on Windows 10 or later
}
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 

许多符号会自动为你定义,如下表所示:

目标框架 符号
.NET 标准 NETSTANDARD2_0NETSTANDARD2_1
现代.NET NET6_0NET6_0_ANDROIDNET6_0_IOSNET6_0_WINDOWS

然后你可以编写仅针对指定平台编译的语句,如下所示:

#if NET6_0_ANDROID
// compile statements that only works on Android
#elif NET6_0_IOS
// compile statements that only works on iOS
#else
// compile statements that work everywhere else
#endif 

实践与探索

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

练习 2.1 – 测试你的知识

为了得到这些问题的最佳答案,你需要进行自己的研究。我希望你能“跳出书本思考”,因此我故意没有在书中提供所有答案。

我想鼓励你养成在其他地方寻求帮助的好习惯,遵循“授人以渔”的原则。

  1. 你可以在 C#文件中输入什么语句来发现编译器和语言版本?

  2. 在 C#中有哪两种类型的注释?

  3. 逐字字符串和插值字符串之间有什么区别?

  4. 为什么在使用floatdouble值时要小心?

  5. 如何确定像double这样的类型在内存中占用多少字节?

  6. 何时应该使用var关键字?

  7. 创建XmlDocument类实例的最新方法是什么?

  8. 为什么在使用dynamic类型时要小心?

  9. 如何右对齐格式字符串?

  10. 控制台应用程序的参数之间用什么字符分隔?

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

练习 2.2 – 测试你对数字类型的知识

你会为以下“数字”选择什么类型?

  1. 一个人的电话号码

  2. 一个人的身高

  3. 一个人的年龄

  4. 一个人的薪水

  5. 一本书的 ISBN

  6. 一本书的价格

  7. 一本书的运送重量

  8. 一个国家的人口

  9. 宇宙中的恒星数量

  10. 英国中小型企业中每个企业的员工数量(每个企业最多约 50,000 名员工)

练习 2.3 – 实践数字大小和范围

Chapter02解决方案/工作区中,创建一个名为Exercise02的控制台应用程序项目,输出以下每种数字类型在内存中占用的字节数及其最小和最大值:sbytebyteshortushortintuintlongulongfloatdoubledecimal

运行你的控制台应用程序的结果应该类似于图 2.9

自动生成的文本描述

图 2.9:输出数字类型大小的结果

所有练习的代码解决方案都可以从以下链接下载或克隆 GitHub 仓库:github.com/markjprice/cs10dotnet6

练习 2.4 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#第二章-使用 C#进行编程

总结

在本章中,你学会了如何:

  • 声明具有指定或推断类型的变量。

  • 使用一些内置的数字、文本和布尔类型。

  • 选择数字类型

  • 控制控制台应用程序的输出格式。

在下一章中,你将学习运算符、分支、循环、类型转换以及如何处理异常。

第三章:控制流程、类型转换和异常处理

本章是关于编写对变量执行简单操作、做出决策、执行模式匹配、重复语句或代码块、将变量或表达式的值从一种类型转换为另一种类型、处理异常以及检查数字变量溢出的代码。

本章涵盖以下主题:

  • 操作变量

  • 理解选择语句

  • 理解迭代语句

  • 类型之间的转换和强制转换

  • 处理异常

  • 检查溢出

操作变量

运算符操作数(如变量和字面值)执行简单的操作,如加法和乘法。它们通常返回一个新值,即操作的结果,该结果可以分配给一个变量。

大多数运算符是二元的,意味着它们作用于两个操作数,如下面的伪代码所示:

var resultOfOperation = firstOperand operator secondOperand; 

二元运算符的例子包括加法和乘法,如下面的代码所示:

int x = 5;
int y = 3;
int resultOfAdding = x + y;
int resultOfMultiplying = x * y; 

一些运算符是单目的,意味着它们作用于单个操作数,并且可以应用于操作数之前或之后,如下面的伪代码所示:

var resultOfOperation = onlyOperand operator; 
var resultOfOperation2 = operator onlyOperand; 

单目运算符的例子包括增量器和获取类型或其大小(以字节为单位),如下面的代码所示:

int x = 5;
int postfixIncrement = x++;
int prefixIncrement = ++x;
Type theTypeOfAnInteger = typeof(int); 
int howManyBytesInAnInteger = sizeof(int); 

三元运算符作用于三个操作数,如下面的伪代码所示:

var resultOfOperation = firstOperand firstOperator 
  secondOperand secondOperator thirdOperand; 

探索单目运算符

两个常用的单目运算符用于增加(++)和减少(--)一个数字。让我们写一些示例代码来展示它们是如何工作的:

  1. 如果你已经完成了前面的章节,那么你将已经有一个Code文件夹。如果没有,那么你需要创建它。

  2. 使用你喜欢的编程工具创建一个新的控制台应用程序,如下表所定义:

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

    2. 工作区/解决方案文件和文件夹:Chapter03

    3. 项目文件和文件夹:Operators

  3. Program.cs顶部,静态导入System.Console

  4. Program.cs中,声明两个整型变量ab,将a设置为3,在将结果赋给b的同时增加a,然后输出它们的值,如下面的代码所示:

    int a = 3; 
    int b = a++;
    WriteLine($"a is {a}, b is {b}"); 
    
  5. 在运行控制台应用程序之前,问自己一个问题:你认为输出时b的值会是多少?一旦你考虑了这一点,运行代码,并将你的预测与实际结果进行比较,如下面的输出所示:

    a is 4, b is 3 
    

    变量b的值为3,因为++运算符在赋值之后执行;这被称为后缀运算符。如果你需要在赋值之前增加,那么使用前缀运算符

  6. 复制并粘贴这些语句,然后修改它们以重命名变量并使用前缀运算符,如下面的代码所示:

    int c = 3;
    int d = ++c; // increment c before assigning it
    WriteLine($"c is {c}, d is {d}"); 
    
  7. 重新运行代码并注意结果,如下面的输出所示:

    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 
    

    要理解应用于整数的除法/和模%运算符,你需要回想小学时期。想象你有十一颗糖果和三个朋友。

    你如何将糖果分给你的朋友们?你可以给每位朋友三颗糖果,剩下两颗。那两颗糖果就是模数,也称为除法后的余数。如果你有十二颗糖果,那么每位朋友得到四颗,没有剩余,所以余数为 0。

  3. 添加语句以声明并赋值给一个名为gdouble变量,以展示整数和实数除法的区别,如下面的代码所示:

    double g = 11.0;
    WriteLine($"g is {g:N1}, f is {f}"); 
    WriteLine($"g / f = {g / f}"); 
    
  4. 运行代码并注意结果,如下面的输出所示:

    g is 11.0, f is 3
    g / f = 3.6666666666666665 
    

如果第一个操作数是浮点数,例如值为11.0g,那么除法运算符返回一个浮点值,例如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; 

探索逻辑运算符

逻辑运算符操作于布尔值,因此它们返回truefalse。让我们探索操作于两个布尔值的二元逻辑运算符:

  1. 使用你偏好的编程工具,在Chapter03工作区/解决方案中添加一个名为BooleanOperators的新控制台应用。

    1. 在 Visual Studio Code 中,选择BooleanOperators作为活动的 OmniSharp 项目。当你看到弹出警告消息说缺少必需资产时,点击以添加它们。

    2. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择的项目。

      最佳实践:记得静态导入System.Console类型以简化语句。

  2. Program.cs 中,添加语句以声明两个布尔变量,其值分别为 truefalse,然后输出应用 AND、OR 和 XOR(异或)逻辑运算符的结果的真值表,如下面的代码所示:

    bool a = true;
    bool b = false;
    WriteLine($"AND  | a     | b    ");
    WriteLine($"a    | {a & a,-5} | {a & b,-5} ");
    WriteLine($"b    | {b & a,-5} | {b & b,-5} ");
    WriteLine();
    WriteLine($"OR   | a     | b    ");
    WriteLine($"a    | {a | a,-5} | {a | b,-5} ");
    WriteLine($"b    | {b | a,-5} | {b | b,-5} ");
    WriteLine();
    WriteLine($"XOR  | a     | b    ");
    WriteLine($"a    | {a ^ a,-5} | {a ^ b,-5} ");
    WriteLine($"b    | {b ^ a,-5} | {b ^ b,-5} "); 
    
  3. 运行代码并注意结果,如下面的输出所示:

    AND  | a     | b    
    a    | True  | False 
    b    | False | False 
    OR   | a     | b    
    a    | True  | True  
    b    | True  | False 
    XOR  | a     | b    
    a    | False | True  
    b    | True  | False 
    

对于 AND & 逻辑运算符,两个操作数都必须为 true 才能使结果为 true。对于 OR | 逻辑运算符,任一操作数为 true 即可使结果为 true。对于 XOR ^ 逻辑运算符,任一操作数可以为 true(但不能同时为 true!)以使结果为 true

探索条件逻辑运算符

条件逻辑运算符类似于逻辑运算符,但你需要使用两个符号而不是一个,例如,&& 代替 &,或者 || 代替 |

第四章编写、调试和测试函数 中,你将更详细地了解函数,但我现在需要介绍函数以解释条件逻辑运算符,也称为短路布尔运算符。

函数执行语句然后返回一个值。该值可以是用于布尔运算的布尔值,例如 true。让我们利用条件逻辑运算符:

  1. Program.cs 底部,编写语句以声明一个向控制台写入消息并返回 true 的函数,如下面的代码所示:

    static bool DoStuff()
    {
      WriteLine("I am doing some stuff.");
      return true;
    } 
    

    最佳实践:如果你使用的是 .NET 交互式笔记本,请在单独的代码单元格中编写 DoStuff 函数,然后执行它,以便其上下文可供其他代码单元格使用。

  2. 在前面的 WriteLine 语句之后,对 ab 变量以及调用函数的结果执行 AND & 操作,如下面的代码所示:

    WriteLine();
    WriteLine($"a & DoStuff() = {a & DoStuff()}"); 
    WriteLine($"b & DoStuff() = {b & DoStuff()}"); 
    
  3. 运行代码,查看结果,并注意该函数被调用了两次,一次是为 a,一次是为 b,如以下输出所示:

    I am doing some stuff. 
    a & DoStuff() = True
    I am doing some stuff. 
    b & DoStuff() = False 
    
  4. & 运算符更改为 && 运算符,如下面的代码所示:

    WriteLine($"a && DoStuff() = {a && DoStuff()}"); 
    WriteLine($"b && DoStuff() = {b && DoStuff()}"); 
    
  5. 运行代码,查看结果,并注意当与 a 变量结合时,函数确实运行了。当与 b 变量结合时,它不会运行,因为 b 变量是 false,所以结果无论如何都将是 false,因此不需要执行该函数,如下面的输出所示:

    I am doing some stuff. 
    a && DoStuff() = True
    b && DoStuff() = False // DoStuff function was not executed! 
    

    最佳实践:现在你可以明白为什么条件逻辑运算符被描述为短路运算符。它们可以使你的应用程序更高效,但它们也可能在假设函数总是被调用的情况下引入微妙的错误。当与产生副作用的函数结合使用时,最安全的是避免使用它们。

探索位运算和二进制移位运算符

位运算符影响数字中的位。二进制移位运算符可以比传统运算符更快地执行一些常见的算术计算,例如,任何乘以 2 的倍数。

让我们探索位运算和二进制移位运算符:

  1. 使用您喜欢的编码工具,在Chapter03工作区/解决方案中添加一个新的控制台应用程序,命名为BitwiseAndShiftOperators

  2. 在 Visual Studio Code 中,选择BitwiseAndShiftOperators作为活动 OmniSharp 项目。当看到弹出警告消息提示缺少必需资产时,点击以添加它们。

  3. Program.cs中,键入语句以声明两个整数变量,值分别为 10 和 6,然后输出应用 AND、OR 和 XOR 位运算符的结果,如下面的代码所示:

    int a = 10; // 00001010
    int b = 6;  // 00000110
    WriteLine($"a = {a}");
    WriteLine($"b = {b}");
    WriteLine($"a & b = {a & b}"); // 2-bit column only 
    WriteLine($"a | b = {a | b}"); // 8, 4, and 2-bit columns 
    WriteLine($"a ^ b = {a ^ b}"); // 8 and 4-bit columns 
    
  4. 运行代码并注意结果,如下面的输出所示:

    a = 10
    b = 6
    a & b = 2 
    a | b = 14
    a ^ b = 12 
    
  5. Program.cs中,添加语句以输出应用左移运算符将变量a的位移动三列、将a乘以 8 以及右移变量b的位一列的结果,如下面的代码所示:

    // 01010000 left-shift a by three bit columns
    WriteLine($"a << 3 = {a << 3}");
    // multiply a by 8
    WriteLine($"a * 8 = {a * 8}");
    // 00000011 right-shift b by one bit column
    WriteLine($"b >> 1 = {b >> 1}"); 
    
  6. 运行代码并注意结果,如下面的输出所示:

    a << 3 = 80
    a * 8 = 80
    b >> 1 = 3 
    

结果80是因为其中的位向左移动了三列,因此 1 位移动到了 64 位和 16 位列,64 + 16 = 80。这相当于乘以 8,但 CPU 可以更快地执行位移。结果 3 是因为b中的 1 位向右移动了一列,进入了 2 位和 1 位列。

良好实践:记住,当操作整数值时,&|符号是位运算符,而当操作布尔值如truefalse时,&|符号是逻辑运算符。

我们可以通过将整数值转换为零和一的二进制字符串来演示这些操作:

  1. Program.cs底部,添加一个函数,将整数值转换为最多包含八个零和一的二进制(Base2)字符串,如下面的代码所示:

    static string ToBinaryString(int value)
    {
      return Convert.ToString(value, toBase: 2).PadLeft(8, '0');
    } 
    
  2. 在函数上方,添加语句以输出ab以及各种位运算符的结果,如下面的代码所示:

    WriteLine();
    WriteLine("Outputting integers as binary:");
    WriteLine($"a =     {ToBinaryString(a)}");
    WriteLine($"b =     {ToBinaryString(b)}");
    WriteLine($"a & b = {ToBinaryString(a & b)}");
    WriteLine($"a | b = {ToBinaryString(a | b)}");
    WriteLine($"a ^ b = {ToBinaryString(a ^ b)}"); 
    
  3. 运行代码并注意结果,如下面的输出所示:

    Outputting integers as binary:
    a =     00001010
    b =     00000110
    a & b = 00000010
    a | b = 00001110
    a ^ b = 00001100 
    

杂项运算符

nameofsizeof是在处理类型时方便的运算符:

  • nameof返回变量、类型或成员的简短名称(不包含命名空间)作为字符串值,这在输出异常消息时非常有用。

  • sizeof返回简单类型的大小(以字节为单位),这对于确定数据存储的效率非常有用。

还有许多其他运算符;例如,变量与其成员之间的点称为成员访问运算符,函数或方法名称末尾的圆括号称为调用运算符,如下面的代码所示:

int age = 47;
// 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,则执行else块。if语句可以嵌套。

if语句可以与其他if语句结合,形成else if分支,如下列代码所示:

if (expression1)
{
  // runs if expression1 is true
}
else if (expression2)
{
  // runs if expression1 is false and expression2 if true
}
else if (expression3)
{
  // runs if expression1 and expression2 are false
  // and expression3 is true
}
else
{
  // runs if all expressions are false
} 

每个if语句的布尔表达式都是独立的,与switch语句不同,它不需要引用单一值。

让我们编写一些代码来探索像if这样的选择语句:

  1. 使用你喜欢的编程工具,在Chapter03工作区/解决方案中添加一个名为SelectionStatements的新控制台应用程序

  2. 在 Visual Studio Code 中,选择SelectionStatements作为活动 OmniSharp 项目。

  3. Program.cs中,输入语句以检查密码是否至少有八个字符,如下列代码所示:

    string password = "ninja";
    if (password.Length < 8)
    {
      WriteLine("Your password is too short. Use at least 8 characters.");
    }
    else
    {
      WriteLine("Your password is strong.");
    } 
    
  4. 运行代码并注意结果,如下列输出所示:

     Your password is too short. Use at least 8 characters. 
    

为什么你应该始终在 if 语句中使用大括号

由于每个代码块内只有一条语句,前面的代码可以不使用花括号编写,如下列代码所示:

if (password.Length < 8)
  WriteLine("Your password is too short. Use at least 8 characters."); 
else
  WriteLine("Your password is strong."); 

应避免这种风格的if语句,因为它可能引入严重的错误,例如苹果 iPhone iOS 操作系统中臭名昭著的#gotofail 错误。

在苹果发布 iOS 6 后的 18 个月内,即 2012 年 9 月,其安全套接字层SSL)加密代码中存在一个错误,这意味着任何使用 Safari(设备上的网页浏览器)尝试连接到安全网站(如银行)的用户都没有得到适当的保护,因为一个重要的检查被意外跳过了。

仅仅因为你可以在没有花括号的情况下编写代码,并不意味着你应该这样做。没有它们的代码并不会“更高效”;相反,它更难以维护,且可能更危险。

if 语句的模式匹配

引入 C# 7.0 及更高版本的一个特性是模式匹配。if语句可以通过结合使用is关键字和声明一个局部变量来使代码更安全:

  1. 添加语句,以便如果存储在名为o的变量中的值是int,则该值被赋给局部变量i,然后可以在if语句中使用。这比使用名为o的变量更安全,因为我们确信i是一个int变量,而不是其他东西,如下列代码所示:

    // add and remove the "" to change the behavior
    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语句分支基于这个随机数的值,如下面的代码所示:

    int number = (new Random()).Next(1, 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关键字持反对态度,但在某些情况下,它可以是解决代码逻辑的好方法。但是,您应该谨慎使用它。

  2. 多次运行代码以查看随机数在各种情况下的结果,如下面的示例输出所示:

    // 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 
    

使用 switch 语句进行模式匹配

if语句一样,switch语句在 C# 7.0 及更高版本中支持模式匹配。case值不再需要是文字值;它们可以是模式。

让我们看一个使用文件夹路径的switch语句进行模式匹配的示例。如果您使用的是 macOS,则交换设置路径变量的注释语句,并将我的用户名替换为您的用户文件夹名称:

  1. 添加语句以声明一个string路径到文件,将其打开为只读或可写流,然后根据流的类型和功能显示一条消息,如下面的代码所示:

    // string path = "/Users/markjprice/Code/Chapter03";
    string path = @"C:\Code\Chapter03";
    Write("Press R for read-only or W for writeable: "); 
    ConsoleKeyInfo key = ReadKey();
    WriteLine();
    Stream? s;
    if (key.Key == ConsoleKey.R)
    {
      s =  File.Open(
        Path.Combine(path, "file.txt"), 
        FileMode.OpenOrCreate, 
        FileAccess.Read);
    }
    else
    {
      s =  File.Open( 
        Path.Combine(path, "file.txt"), 
        FileMode.OpenOrCreate, 
        FileAccess.Write);
    }
    string message; 
    switch (s)
    {
      case FileStream writeableFile when s.CanWrite:
        message = "The stream is a file that I can write to.";
        break;
      case FileStream readOnlyFile:
        message = "The stream is a read-only file.";
        break;
      case MemoryStream ms:
        message = "The stream is a memory address.";
        break;
      default: // always evaluated last despite its current position
        message = "The stream is some other type.";
        break;
      case null:
        message = "The stream is null.";
        break;
    }
    WriteLine(message); 
    
  2. 运行代码并注意名为s的变量被声明为Stream类型,因此它可以是流的任何子类型,例如内存流或文件流。在此代码中,流是通过File.Open方法创建的,该方法返回一个文件流,并根据您的按键,它将是可写的或只读的,因此结果将是一条描述情况的 message,如下面的输出所示:

    The stream is a file that I can write to. 
    

在.NET 中,Stream有多个子类型,包括FileStreamMemoryStream。在 C# 7.0 及更高版本中,你的代码可以根据流子类型更简洁地分支,并声明和赋值一个局部变量以安全使用。你将在第九章文件、流和序列化操作中了解更多关于System.IO命名空间和Stream类型的信息。

此外,case语句可以包含when关键字以执行更具体的模式匹配。在前述代码的第一个 case 语句中,s仅在流为FileStream且其CanWrite属性为true时匹配。

使用 switch 表达式简化 switch 语句

C# 8.0 及以上版本中,你可以使用switch 表达式简化switch语句。

大多数switch语句虽然简单,但需要大量输入。switch表达式旨在简化所需代码,同时在所有情况都返回值以设置单个变量的情况下,仍表达相同意图。switch表达式使用 lambda =>表示返回值。

让我们将之前使用switch语句的代码实现为switch表达式,以便比较两种风格:

  1. 使用switch表达式,根据流的类型和功能输入语句设置消息,如下列代码所示:

    message = s switch
    {
      FileStream writeableFile when s.CanWrite
        => "The stream is a file that I can write to.", 
      FileStream readOnlyFile
        => "The stream is a read-only file.", 
      MemoryStream ms
        => "The stream is a memory address.", 
      null
        => "The stream is null.",
      _
        => "The stream is some other type."
    };
    WriteLine(message); 
    

    主要区别在于移除了casebreak关键字。下划线字符_用于表示默认返回值。

  2. 运行代码,并注意结果与之前相同。

理解迭代语句

迭代语句重复一个语句块,要么在条件为真时,要么对集合中的每个项。选择使用哪种语句基于解决问题逻辑的易理解性和个人偏好。

while 语句循环

while语句评估布尔表达式,并在其为真时继续循环。让我们探索迭代语句:

  1. 使用你偏好的编程工具,在Chapter03工作区/解决方案中添加一个名为IterationStatements控制台应用程序

  2. 在 Visual Studio Code 中,选择IterationStatements作为活动 OmniSharp 项目。

  3. Program.cs中,输入语句定义一个while语句,当整型变量值小于 10 时循环,如下列代码所示:

    int x = 0;
    while (x < 10)
    {
      WriteLine(x);
      x++;
    } 
    
  4. 运行代码并查看结果,应显示数字 0 至 9,如下列输出所示:

    0
    1
    2
    3
    4
    5
    6
    7
    8
    9 
    

do 语句循环

while类似,do语句在代码块底部而非顶部检查布尔表达式,这意味着代码块至少会执行一次,如下所示:

  1. 输入语句定义一个do循环,如下列代码所示:

    string? password;
    do
    {
      Write("Enter your password: "); 
      password = ReadLine();
    }
    while (password != "Pa$$w0rd");
    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. 作为可选挑战,添加语句,以便用户只能在出现错误消息之前尝试十次。

使用 for 语句循环

for语句类似于while,只不过它更为简洁。它结合了:

  • 一个初始化表达式,它在循环开始时执行一次。

  • 一个条件表达式,它在每次迭代开始时执行,以检查循环是否应继续。

  • 一个迭代器表达式,它在每次循环的底部执行。

for语句通常与整数计数器一起使用。让我们探讨一些代码:

  1. 键入一个for语句以输出数字 1 到 10,如下所示:

    for (int y = 1; y <= 10; y++)
    {
      WriteLine(y);
    } 
    
  2. 运行代码以查看结果,结果应为数字 1 到 10。

使用 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语句将适用于遵循以下规则的任何类型:

  1. 该类型必须具有一个名为GetEnumerator的方法,该方法返回一个对象。

  2. 返回的对象必须具有名为Current的属性和名为MoveNext的方法。

  3. 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.");
} 

由于使用了迭代器,foreach语句中声明的变量不能用于修改当前项的值。

类型之间的转换和转换

你经常需要在不同类型的变量之间转换值。例如,数据输入通常作为控制台上的文本输入,因此最初存储在string类型的变量中,但随后需要将其转换为日期/时间,或数字,或其他数据类型,具体取决于应如何存储和处理。

有时在进行计算之前,你需要在整数和浮点数等数字类型之间进行转换。

转换也称为类型转换,它有两种形式:隐式显式。隐式转换会自动发生,是安全的,意味着不会丢失任何信息。

显式转换必须手动执行,因为它可能会丢失信息,例如数字的精度。通过显式转换,你告诉 C#编译器你理解并接受这种风险。

隐式和显式转换数字

int变量隐式转换为double变量是安全的,因为不会丢失任何信息,如下所示:

  1. 使用你喜欢的编码工具在Chapter03工作区/解决方案中添加一个新的控制台应用程序,命名为CastingConverting

  2. 在 Visual Studio Code 中,选择CastingConverting作为活动 OmniSharp 项目。

  3. Program.cs中,输入语句以声明和赋值一个int变量和一个double变量,然后当将整数值赋给double变量时隐式转换整数值,如下所示:

    int a = 10;
    double b = a; // an int can be safely cast into a double
    WriteLine(b); 
    
  4. 输入语句以声明和赋值一个double变量和一个int变量,然后当将double值赋给int变量时隐式转换double值,如下所示:

    double c = 9.8;
    int d = c; // compiler gives an error for this line
    WriteLine(d); 
    
  5. 运行代码并注意错误消息,如下所示:

    Error: (6,9): error CS0266: Cannot implicitly convert type 'double' to 'int'. An explicit conversion exists (are you missing a cast?) 
    

    此错误消息也会出现在 Visual Studio 错误列表或 Visual Studio Code 问题窗口中。

    你不能隐式地将double变量转换为int变量,因为这可能不安全且可能丢失数据,例如小数点后的值。你必须使用一对圆括号将要转换的double类型括起来,显式地将double变量转换为int变量。这对圆括号是类型转换运算符。即便如此,你也必须注意,小数点后的部分将被截断而不会警告,因为你选择执行显式转换,因此理解其后果。

  6. 修改d变量的赋值语句,如下所示:

    int d = (int)c;
    WriteLine(d); // d is 9 losing the .8 part 
    
  7. 运行代码以查看结果,如下所示:

    10
    9 
    

    当在较大整数和较小整数之间转换值时,我们必须执行类似的操作。再次提醒,你可能会丢失信息,因为任何过大的值都会复制其位,然后以你可能意想不到的方式进行解释!

  8. 输入语句以声明和赋值一个长 64 位变量到一个 int 32 位变量,两者都使用一个小值和一个过大的值,如下所示:

    long e = 10; 
    int f = (int)e;
    WriteLine($"e is {e:N0} and f is {f:N0}"); 
    e = long.MaxValue;
    f = (int)e;
    WriteLine($"e is {e:N0} and f is {f:N0}"); 
    
  9. 运行代码以查看结果,如下所示:

    e is 10 and f is 10
    e is 9,223,372,036,854,775,807 and f is -1 
    
  10. e的值修改为 50 亿,如下所示:

    e = 5_000_000_000; 
    
  11. 运行代码以查看结果,如下所示:

    e is 5,000,000,000 and f is 705,032,704 
    

使用 System.Convert 类型进行转换

使用类型转换操作符的替代方法是使用System.Convert类型。System.Convert类型可以转换为和从所有 C#数字类型,以及布尔值、字符串和日期时间值。

让我们写一些代码来实际看看:

  1. Program.cs顶部,静态导入System.Convert类,如下所示:

    using static System.Convert; 
    
  2. Program.cs底部,输入语句声明并赋值给一个double变量,将其转换为整数,然后将两个值写入控制台,如下所示:

    double g = 9.8;
    int h = ToInt32(g); // a method of System.Convert
    WriteLine($"g is {g} and h is {h}"); 
    
  3. 运行代码并查看结果,如下所示:

    g is 9.8 and h is 10 
    

类型转换和转换之间的一个区别是,转换将double9.8向上取整为10,而不是截去小数点后的部分。

取整数字

你现在已经看到,类型转换操作符会截去实数的小数部分,而System.Convert方法则会上下取整。但是,取整的规则是什么呢?

理解默认的取整规则

在英国 5 至 11 岁儿童的初等学校中,学生被教导如果小数部分为.5 或更高,则向上取整;如果小数部分小于.5,则向下取整。

让我们探究一下 C#是否遵循相同的初等学校规则:

  1. 输入语句声明并赋值给一个double数组,将每个值转换为整数,然后将结果写入控制台,如下所示:

    double[] doubles = new[]
      { 9.49, 9.5, 9.51, 10.49, 10.5, 10.51 };
    foreach (double n in doubles)
    {
      WriteLine($"ToInt32({n}) is {ToInt32(n)}");
    } 
    
  2. 运行代码并查看结果,如下所示:

    ToInt32(9.49) is 9
    ToInt32(9.5) is 10
    ToInt32(9.51) is 10
    ToInt32(10.49) is 10
    ToInt32(10.5) is 10
    ToInt32(10.51) is 11 
    

我们已经表明,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));
    } 
    
  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 
    

    最佳实践:对于你使用的每种编程语言,检查其取整规则。它们可能不会按照你预期的方式工作!

将任何类型转换为字符串

最常见的转换是将任何类型转换为string变量,以便输出为人类可读的文本,因此所有类型都从System.Object类继承了一个名为ToString的方法。

ToString方法将任何变量的当前值转换为文本表示。某些类型无法合理地表示为文本,因此它们返回其命名空间和类型名称。

让我们将一些类型转换为字符串

  1. 键入语句以声明一些变量,将它们转换为其字符串表示,并将它们写入控制台,如下所示:

    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
    02/28/2021 17:33:54
    System.Object 
    

从二进制对象转换为字符串

当您有一个二进制对象(如图像或视频)想要存储或传输时,有时您不希望发送原始位,因为您不知道这些位可能会如何被误解,例如,通过传输它们的网络协议或其他正在读取存储二进制对象的操作系统。

最安全的方法是将二进制对象转换为安全的字符字符串。程序员称这种编码为Base64

Convert类型有一对方法,ToBase64StringFromBase64String,它们为您执行此转换。让我们看看它们的实际应用:

  1. 键入语句以创建一个随机填充字节值的字节数组,将每个字节格式化良好地写入控制台,然后将相同的字节转换为 Base64 写入控制台,如下所示:

    // allocate array of 128 bytes
    byte[] binaryObject = new byte[128];
    // populate array with random bytes
    (new Random()).NextBytes(binaryObject); 
    WriteLine("Binary Object as bytes:");
    for(int index = 0; index < binaryObject.Length; index++)
    {
      Write($"{binaryObject[index]:X} ");
    }
    WriteLine();
    // convert to Base64 string and output as text
    string encoded = ToBase64String(binaryObject);
    WriteLine($"Binary Object as Base64: {encoded}"); 
    

    默认情况下,int值会假设为十进制表示法输出,即基数 10。您可以使用:X等格式代码以十六进制表示法格式化该值。

  2. 运行代码并查看结果,如下所示:

    Binary Object as bytes:
    B3 4D 55 DE 2D E BB CF BE 4D E6 53 C3 C2 9B 67 3 45 F9 E5 20 61 7E 4F 7A 81 EC 49 F0 49 1D 8E D4 F7 DB 54 AF A0 81 5 B8 BE CE F8 36 90 7A D4 36 42
    4 75 81 1B AB 51 CE 5 63 AC 22 72 DE 74 2F 57 7F CB E7 47 B7 62 C3 F4 2D
    61 93 85 18 EA 6 17 12 AE 44 A8 D B8 4C 89 85 A9 3C D5 E2 46 E0 59 C9 DF
    10 AF ED EF 8AA1 B1 8D EE 4A BE 48 EC 79 A5 A 5F 2F 30 87 4A C7 7F 5D C1 D
    26 EE
    Binary Object as Base64: s01V3i0Ou8++TeZTw8KbZwNF +eUgYX5PeoHsSfBJHY7U99tU r6CBBbi+zvg2kHrUNkIEdYEbq1HOBWOsInLedC9Xf8vnR7diw/QtYZOFGOoGFxKuRKgNuEyJha k81eJG4FnJ3xCv7e+KobGN7kq+SO x5pQpfLzCHSsd/XcENJu4= 
    

从字符串解析到数字或日期和时间

第二常见的转换是从字符串到数字或日期和时间值。

ToString的相反操作是Parse。只有少数类型具有Parse方法,包括所有数字类型和DateTime

让我们看看Parse的实际应用:

  1. 键入语句以从字符串解析整数和日期时间值,然后将结果写入控制台,如下所示:

    int age = int.Parse("27");
    DateTime birthday = DateTime.Parse("4 July 1980");
    WriteLine($"I was born {age} years ago."); 
    WriteLine($"My birthday is {birthday}."); 
    WriteLine($"My birthday is {birthday:D}."); 
    
  2. 运行代码并查看结果,如下所示:

    I was born 27 years ago.
    My birthday is 04/07/1980 00:00:00\. 
    My birthday is 04 July 1980. 
    

    默认情况下,日期和时间值以短日期和时间格式输出。您可以使用D等格式代码仅以长日期格式输出日期部分。

    最佳实践:使用标准日期和时间格式说明符,如下所示:docs.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings#table-of-format-specifiers

使用 Parse 时的错误

Parse方法的一个问题是,如果字符串无法转换,它会给出错误。

  1. 键入语句以尝试将包含字母的字符串解析为整数变量,如下所示:

    int count = int.Parse("abc"); 
    
  2. 运行代码并查看结果,如下所示:

    Unhandled Exception: System.FormatException: Input string was not in a correct format. 
    

除了上述异常消息外,你还会看到堆栈跟踪。由于堆栈跟踪占用太多空间,本书中未包含。

使用 TryParse 方法避免异常

为了避免错误,你可以使用TryParse方法。TryParse尝试转换输入的string,如果能够转换则返回true,否则返回false

out关键字是必需的,以便TryParse方法在转换成功时设置计数变量。

让我们看看TryParse的实际应用:

  1. int count声明替换为使用TryParse方法的语句,并要求用户输入鸡蛋的数量,如下所示:

    Write("How many eggs are there? "); 
    string? input = ReadLine(); // or use "12" in notebook
    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(或在笔记本中将string值更改为"twelve"),查看结果,如下所示:

    How many eggs are there? twelve
    I could not parse the input. 
    

你还可以使用System.Convert类型的方法将string值转换为其他类型;然而,与Parse方法类似,如果无法转换,它会报错。

处理异常

你已经看到了几种类型转换时发生错误的场景。有些语言在出现问题时返回错误代码。.NET 使用异常,这些异常比返回值更丰富,专门用于失败报告,而返回值有多种用途。当这种情况发生时,我们说运行时异常已被抛出

当抛出异常时,线程会被挂起,如果调用代码定义了try-catch语句,那么它就有机会处理这个异常。如果当前方法没有处理它,那么它的调用方法就有机会处理,以此类推,直到调用栈顶。

如你所见,控制台应用程序或.NET Interactive 笔记本的默认行为是输出有关异常的消息,包括堆栈跟踪,然后停止运行代码。应用程序终止。这比允许代码在可能损坏的状态下继续执行要好。你的代码应该只捕获和处理它理解并能正确修复的异常。

良好实践:尽量避免编写可能抛出异常的代码,或许可以通过执行if语句检查来实现。有时你做不到,有时最好让更高层次的组件来捕获调用你代码时抛出的异常。你将在第四章编写、调试和测试函数中学习如何做到这一点。

将易出错的代码包裹在 try 块中

当你知道某个语句可能引发错误时,应该将该语句包裹在try块中。例如,从文本解析为数字可能引发错误。catch块中的任何语句只有在try块中的语句抛出异常时才会执行。

我们无需在catch块中做任何事情。让我们看看实际操作:

  1. 使用您喜欢的编码工具,在Chapter03工作区/解决方案中添加一个新的控制台应用程序,命名为HandlingExceptions

  2. 在 Visual Studio Code 中,选择HandlingExceptions作为活动 OmniSharp 项目。

  3. 输入语句以提示用户输入他们的年龄,然后将他们的年龄写入控制台,如下所示:

    WriteLine("Before parsing"); 
    Write("What is your age? "); 
    string? input = ReadLine(); // or use "49" in a notebook
    try
    {
      int age = int.Parse(input); 
      WriteLine($"You are {age} years old.");
    }
    catch
    {
    }
    WriteLine("After parsing"); 
    

    您将看到以下编译器消息:Warning CS8604 Possible null reference argument for parameter 's' in 'int int.Parse(string s)'。在新建的.NET 6 项目中,默认情况下,Microsoft 已启用可空引用类型,因此您会看到更多此类编译器警告。在生产代码中,您应添加代码以检查null并适当地处理这种可能性。在本书中,我不会包含这些null检查,因为代码示例并非设计为生产质量,并且到处都是null检查会使得代码杂乱无章,占用宝贵的页面。在这种情况下,input不可能为null,因为用户必须按 Enter 键,ReadLine才会返回,而那将返回一个空string。您将在本书的代码示例中看到数百个可能为null的变量。对于本书的代码示例,这些警告可以安全地忽略。只有在编写自己的生产代码时,您才需要类似的警告。您将在第六章实现接口和继承类中了解更多关于空处理的内容。

    此代码包含两个消息,以指示解析前解析后,使代码流程更清晰。随着示例代码变得更加复杂,这些将特别有用。

  4. 运行代码,输入49,查看结果,如下所示:

    Before parsing
    What is your age? 49
    You are 49 years old. 
    After parsing 
    
  5. 运行代码,输入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 块的原因是,可能会有其他类型的异常发生。

  3. 运行代码,输入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块。

  4. 保留现有的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.");
    } 
    
  5. 运行代码,输入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();
try
{
  decimal amountValue = decimal.Parse(amount);
}
catch (FormatException) when (amount.Contains("$"))
{
  WriteLine("Amounts cannot use the dollar sign!");
}
catch (FormatException)
{
  WriteLine("Amounts must only contain digits!");
} 

检查溢出

之前,我们看到在数字类型之间转换时,可能会丢失信息,例如,将long变量转换为int变量时。如果存储在类型中的值太大,它将溢出。

使用 checked 语句抛出溢出异常

checked语句告诉.NET 在发生溢出时抛出异常,而不是默认允许它静默发生,这是出于性能原因。

我们将一个int变量的初始值设为其最大值减一。然后,我们将它递增几次,每次输出其值。一旦它超过其最大值,它就会溢出到最小值,并从那里继续递增。让我们看看这个过程:

  1. 使用您喜欢的编程工具,在Chapter03工作区/解决方案中添加一个新的控制台应用程序,命名为CheckingForOverflow

  2. 在 Visual Studio Code 中,选择CheckingForOverflow作为活动的 OmniSharp 项目。

  3. 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}"); 
    
  4. 运行代码并查看结果,显示值无声溢出并环绕至大负值,如下所示:

    Initial value: 2147483646
    After incrementing: 2147483647
    After incrementing: -2147483648
    After incrementing: -2147483647 
    
  5. 现在,让我们通过使用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}");
    **}** 
    
  6. 运行代码并查看结果,显示溢出检查导致异常抛出,如下所示:

    Initial value: 2147483646
    After incrementing: 2147483647
    Unhandled Exception: System.OverflowException: Arithmetic operation resulted in an overflow. 
    
  7. 与其他异常一样,我们应该将这些语句包裹在try语句块中,并为用户显示更友好的错误消息,如下所示:

    try
    {
      // previous code goes here
    }
    catch (OverflowException)
    {
      WriteLine("The code overflowed but I caught the exception.");
    } 
    
  8. 运行代码并查看结果,如下所示:

    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.1所示:图形用户界面,文本,应用程序,电子邮件 自动生成描述

    图 3.1:PROBLEMS 窗口中的编译时检查

  3. 要禁用编译时检查,将语句包裹在unchecked块中,将y的值写入控制台,递减它,并重复,如下所示:

    unchecked
    {
      int y = int.MaxValue + 1; 
      WriteLine($"Initial value: {y}"); 
      y--;
      WriteLine($"After decrementing: {y}"); 
      y--;
      WriteLine($"After decrementing: {y}");
    } 
    
  4. 运行代码并查看结果,如下所示:

    Initial value: -2147483648
    After decrementing: 2147483647
    After decrementing: 2147483646 
    

当然,你很少会想要明确关闭这种检查,因为它允许溢出发生。但或许你能想到一个可能需要这种行为的场景。

实践与探索

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

练习 3.1 – 测试你的知识

回答以下问题:

  1. int变量除以0时,会发生什么?

  2. double变量除以0时,会发生什么?

  3. int变量的值超出其范围(即溢出)时,会发生什么?

  4. x = y++;x = ++y;之间有何区别?

  5. 在循环语句中使用breakcontinuereturn有何区别?

  6. for语句的三个部分是什么,哪些是必需的?

  7. ===操作符之间有何区别?

  8. 以下语句能否编译?

    for ( ; true; ) ; 
    
  9. switch表达式中,下划线_代表什么?

  10. 对象必须实现什么接口才能通过foreach语句进行枚举?

练习 3.2 – 探索循环和溢出

如果执行这段代码会发生什么?

int max = 500;
for (byte i = 0; i < max; i++)
{
  WriteLine(i);
} 

Chapter03中创建一个名为Exercise02的控制台应用程序,并输入前面的代码。运行控制台应用程序并查看输出。发生了什么?

你可以添加什么代码(不要更改前面的任何代码)来警告我们这个问题?

练习 3.3 – 练习循环和运算符

FizzBuzz 是一种团体文字游戏,旨在教孩子们关于除法的知识。玩家轮流递增计数,用单词fizz替换任何可被三整除的数字,用单词buzz替换任何可被五整除的数字,用fizzbuzz替换任何可被两者整除的数字。

Chapter03中创建一个名为Exercise03的控制台应用程序,输出一个模拟的 FizzBuzz 游戏,计数到 100。输出应类似于图 3.2

文本描述自动生成

图 3.2:模拟的 FizzBuzz 游戏输出

练习 3.4 – 练习异常处理

Chapter03中创建一个名为Exercise04的控制台应用程序,要求用户输入两个 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.5 – 测试你对运算符的知识

执行以下语句后,xy的值是什么?

  1. 增量和加法运算符:

    x = 3;
    y = 2 + ++x; 
    
  2. 二进制移位运算符:

    x = 3 << 2;
    y = 10 >> 1; 
    
  3. 位运算符:

    x = 10 & 8;
    y = 10 | 7; 
    

练习 3.6 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-3---controlling-flow-and-converting-types

总结

本章中,你尝试了一些运算符,学习了如何分支和循环,如何进行类型转换,以及如何捕获异常。

你现在准备好学习如何通过定义函数重用代码块,如何向它们传递值并获取返回值,以及如何追踪代码中的错误并消除它们!

第四章:编写、调试和测试函数

本章是关于编写可重用代码的函数,开发过程中调试逻辑错误,运行时记录异常,单元测试代码以消除错误,并确保稳定性和可靠性。

本章涵盖以下主题:

  • 编写函数

  • 开发过程中的调试

  • 运行时日志记录

  • 单元测试

  • 在函数中抛出和捕获异常

编写函数

编程的一个基本原则是 不要重复自己 (DRY)。

编程时,如果你发现自己一遍又一遍地写相同的语句,那么将这些语句转换成一个函数。函数就像完成一项小任务的小程序。例如,你可能会编写一个计算销售税的函数,然后在财务应用程序的许多地方重用该函数。

与程序一样,函数通常有输入和输出。它们有时被描述为黑盒子,你在一端输入一些原材料,另一端就会产生成品。一旦创建,你不需要考虑它们是如何工作的。

乘法表示例

假设你想帮助你的孩子学习乘法表,因此你希望轻松生成任意数字的乘法表,例如 12 的乘法表:

1 x 12 = 12
2 x 12 = 24
...
12 x 12 = 144 

你之前在这本书中学过 for 语句,因此你知道它可以用来生成重复的输出行,当存在规律模式时,例如 12 的乘法表,如下列代码所示:

for (int row = 1; row <= 12; row++)
{
  Console.WriteLine($"{row} x 12 = {row * 12}");
} 

然而,我们不想只输出 12 的乘法表,而是希望使其更灵活,以便它可以输出任意数字的乘法表。我们可以通过创建一个函数来实现这一点。

编写乘法表函数

让我们通过创建一个输出 0 到 255 的任意数字乘以 1 到 12 的乘法表的函数来探索函数:

  1. 使用你喜欢的编程工具创建一个新的控制台应用程序,如下表所定义:

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

    2. 工作区/解决方案文件和文件夹:Chapter04

    3. 项目文件和文件夹:WritingFunctions

  2. 静态导入 System.Console

  3. Program.cs 中,编写语句以定义名为 TimesTable 的函数,如下列代码所示:

    static void TimesTable(byte number)
    {
      WriteLine($"This is the {number} times table:");
      for (int row = 1; row <= 12; row++)
      {
        WriteLine($"{row} x {number} = {row * number}");
      }
      WriteLine();
    } 
    

    在前面的代码中,请注意以下几点:

    • TimesTable 方法必须接收一个名为 numberbyte 类型参数。

    • TimesTable 是一个 static 方法,因为它将由 static 方法 Main 调用。

    • TimesTable 不向调用者返回值,因此它在其名称前声明了 void 关键字。

    • TimesTable 使用一个 for 语句来输出传入的 number 的乘法表。

  4. 在静态导入 Console 类和 TimesTable 函数之前,调用该函数并传入一个 byte 类型的 number 参数值,例如 6,如下列代码中突出显示的那样:

    using static System.Console;
    **TimesTable(****6****);** 
    

    良好实践:如果一个函数有一个或多个参数,仅传递值可能不足以表达其含义,那么你可以选择性地指定参数的名称及其值,如下所示:TimesTable(number: 6)

  5. 运行代码,然后查看结果,如下所示:

    This is the 6 times table:
    1 x 6 = 6
    2 x 6 = 12
    3 x 6 = 18
    4 x 6 = 24
    5 x 6 = 30
    6 x 6 = 36
    7 x 6 = 42
    8 x 6 = 48
    9 x 6 = 54
    10 x 6 = 60
    11 x 6 = 66
    12 x 6 = 72 
    
  6. 将传递给TimesTable函数的数字更改为其他byte值,范围在0255之间,并确认输出乘法表是否正确。

  7. 注意,如果你尝试传递一个非byte类型的数字,例如intdoublestring,将会返回一个错误,如下所示:

    Error: (1,12): error CS1503: Argument 1: cannot convert from 'int' to 'byte' 
    

编写一个返回值的函数

之前的函数执行了操作(循环和写入控制台),但没有返回值。假设你需要计算销售税或增值税(VAT)。在欧洲,VAT 税率可以从瑞士的 8%到匈牙利的 27%不等。在美国,州销售税率可以从俄勒冈州的 0%到加利福尼亚州的 8.25%不等。

税率随时在变化,且受多种因素影响。无需联系我告知弗吉尼亚州的税率是 6%,谢谢。

让我们实现一个计算全球各地税收的函数:

  1. 添加一个名为CalculateTax的函数,如下所示:

    static decimal CalculateTax(
      decimal amount, string twoLetterRegionCode)
    {
      decimal rate = 0.0M;
      switch (twoLetterRegionCode)
      {
        case "CH": // Switzerland
          rate = 0.08M;
          break;
        case "DK": // Denmark
        case "NO": // Norway
          rate = 0.25M;
          break;
        case "GB": // United Kingdom
        case "FR": // France
          rate = 0.2M;
          break;
        case "HU": // Hungary
          rate = 0.27M;
          break;
        case "OR": // Oregon
        case "AK": // Alaska
        case "MT": // Montana
          rate = 0.0M;
          break;
        case "ND": // North Dakota
        case "WI": // Wisconsin
        case "ME": // Maine
        case "VA": // Virginia
          rate = 0.05M;
          break;
        case "CA": // California
          rate = 0.0825M;
          break;
        default: // most US states
          rate = 0.06M;
          break;
      }
      return amount * rate;
    } 
    

    在前述代码中,请注意以下几点:

    • CalculateTax有两个输入:一个名为amount的参数,表示花费的金额,以及一个名为twoLetterRegionCode的参数,表示花费金额的地区。

    • CalculateTax将使用switch语句进行计算,并返回该金额应缴纳的销售税或增值税作为decimal值;因此,在函数名前,我们声明了返回值的数据类型为decimal

  2. 注释掉TimesTable方法的调用,并调用CalculateTax方法,传入金额值如149和有效的地区代码如FR,如下所示:

    // TimesTable(6);
    decimal taxToPay = CalculateTax(amount: 149, twoLetterRegionCode: "FR"); 
    WriteLine($"You must pay {taxToPay} in tax."); 
    
  3. 运行代码并查看结果,如下所示:

    You must pay 29.8 in tax. 
    

我们可以通过使用{taxToPay:C}taxToPay的输出格式化为货币,但它会根据您的本地文化来决定如何格式化货币符号和小数。例如,在英国的我,会看到£29.80

你能想到CalculateTax函数按原样编写可能存在的问题吗?如果用户输入一个代码如frUK会发生什么?如何重写该函数以改进它?使用switch表达式而不是switch语句是否会更为清晰?

将数字从基数转换为序数

用于计数的数字称为基数数字,例如 1、2 和 3,而用于排序的数字称为序数数字,例如 1st、2nd 和 3rd。让我们创建一个将基数转换为序数的函数:

  1. 编写一个名为CardinalToOrdinal的函数,该函数将基数int值转换为序数string值;例如,它将 1 转换为 1st,2 转换为 2nd,依此类推,如下所示:

    static string CardinalToOrdinal(int number)
    {
      switch (number)
      {
        case 11: // special cases for 11th to 13th
        case 12:
        case 13:
          return $"{number}th";
        default:
          int lastDigit = number % 10;
          string suffix = lastDigit switch
          {
            1 => "st",
            2 => "nd",
            3 => "rd",
            _ => "th"
          };
          return $"{number}{suffix}";
      }
    } 
    

    从前面的代码中,请注意以下内容:

    • CardinalToOrdinal有一个输入:名为numberint类型参数,一个输出:string类型的返回值。

    • 使用switch语句来处理 11、12 和 13 的特殊情况。

    • 然后使用switch表达式处理所有其他情况:如果最后一个数字是 1,则使用st作为后缀;如果最后一个数字是 2,则使用nd作为后缀;如果最后一个数字是 3,则使用rd作为后缀;如果最后一个数字是其他任何数字,则使用th作为后缀。

  2. 编写一个名为RunCardinalToOrdinal的函数,该函数使用for语句从 1 循环到 40,对每个数字调用CardinalToOrdinal函数,并将返回的字符串写入控制台,以空格字符分隔,如下所示:

    static void RunCardinalToOrdinal()
    {
      for (int number = 1; number <= 40; number++)
      {
        Write($"{CardinalToOrdinal(number)} ");
      }
      WriteLine();
    } 
    
  3. 注释掉CalculateTax语句,并调用RunCardinalToOrdinal方法,如下所示:

    // TimesTable(6);
    // decimal taxToPay = CalculateTax(amount: 149, twoLetterRegionCode: "FR"); 
    // WriteLine($"You must pay {taxToPay} in tax.");
    RunCardinalToOrdinal(); 
    
  4. 运行代码并查看结果,如下所示:

    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 27th 28th 29th 30th 31st 32nd 33rd 34th 35th 36th 37th 38th 39th 40th 
    

使用递归计算阶乘

5 的阶乘是 120,因为阶乘是通过将起始数乘以比自身小 1 的数,然后再乘以小 1 的数,依此类推,直到数减至 1 来计算的。一个例子可以在这里看到:5 x 4 x 3 x 2 x 1 = 120。

阶乘这样表示:5!,其中感叹号读作 bang,所以 5! = 120,即五 bang 等于一百二十。Bang 是阶乘的好名字,因为它们的大小增长非常迅速,就像爆炸一样。

我们将编写一个名为Factorial的函数;这将计算传递给它的int参数的阶乘。我们将使用一种称为递归的巧妙技术,这意味着一个函数在其实现中调用自身,无论是直接还是间接:

  1. 添加一个名为Factorial的函数,以及一个调用它的函数,如下所示:

    static int Factorial(int number)
    {
      if (number < 1)
      {
        return 0;
      }
      else if (number == 1)
      {
        return 1;
      }
      else
      {
        return number * Factorial(number - 1);
      }
    } 
    

    如前所述,前面的代码中有几个值得注意的元素,包括以下内容:

    • 如果输入参数number为零或负数,Factorial返回0

    • 如果输入参数number1Factorial返回1,因此停止调用自身。

    • 如果输入参数number大于 1,在所有其他情况下都会如此,Factorial函数会将该数乘以调用自身并传递比number小 1 的结果。这使得函数具有递归性。

    更多信息:递归虽然巧妙,但可能导致问题,如因函数调用过多而导致的栈溢出,因为每次函数调用都会占用内存来存储数据,最终会消耗过多内存。在 C#等语言中,迭代是一种更实用(尽管不那么简洁)的解决方案。您可以在以下链接了解更多信息:en.wikipedia.org/wiki/Recursion_(computer_science)#Recursion_versus_iteration

  2. 添加一个名为RunFactorial的函数,该函数使用for语句输出 1 到 14 的阶乘,在循环内部调用Factorial函数,然后使用代码N0格式化输出结果,这意味着数字格式使用千位分隔符且无小数位,如下所示:

    static void RunFactorial()
    {
      for (int i = 1; i < 15; i++)
      {
        WriteLine($"{i}! = {Factorial(i):N0}");
      }
    } 
    
  3. 注释掉RunCardinalToOrdinal方法调用,并调用RunFactorial方法。

  4. 运行代码并查看结果,如下所示:

    1! = 1
    2! = 2
    3! = 6
    4! = 24
    5! = 120
    6! = 720
    7! = 5,040
    8! = 40,320
    9! = 362,880
    10! = 3,628,800
    11! = 39,916,800
    12! = 479,001,600
    13! = 1,932,053,504
    14! = 1,278,945,280 
    

在前面的输出中并不立即明显,但 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函数以处理在调用Factorial函数时发生的溢出异常,如下所示高亮显示:

    **try**
    **{**
      WriteLine($"{i}! = {Factorial(i):N0}");
    **}**
    **catch (System.OverflowException)**
    **{**
     **WriteLine(****$"****{i}****! is too big for a 32-bit integer."****);**
    **}** 
    
  3. 运行代码并查看结果,如下所示:

    1! = 1
    2! = 2
    3! = 6
    4! = 24
    5! = 120
    6! = 720
    7! = 5,040
    8! = 40,320
    9! = 362,880
    10! = 3,628,800
    11! = 39,916,800
    12! = 479,001,600
    13! is too big for a 32-bit integer.
    14! is too big for a 32-bit integer. 
    

使用 XML 注释记录函数

默认情况下,当调用如CardinalToOrdinal这样的函数时,代码编辑器会显示一个包含基本信息的工具提示,如图 4.1所示:

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

图 4.1:显示默认简单方法签名的工具提示

让我们通过添加额外信息来改进工具提示:

  1. 如果你使用的是带有C#扩展的 Visual Studio Code,你应该导航到视图 | 命令面板 | 首选项:打开设置(UI),然后搜索formatOnType并确保其已启用。C# XML 文档注释是 Visual Studio 2022 的内置功能。

  2. CardinalToOrdinal函数上方的一行中,键入三个正斜杠///,并注意它们扩展成一个 XML 注释,该注释识别到函数有一个名为number的单个参数。

  3. CardinalToOrdinal函数的 XML 文档注释输入合适的信息,以总结并描述输入参数和返回值,如下所示:

    /// <summary>
    /// Pass a 32-bit integer and it will be converted into its ordinal equivalent.
    /// </summary>
    /// <param name="number">Number is a cardinal value e.g. 1, 2, 3, and so on.</param>
    /// <returns>Number as an ordinal value e.g. 1st, 2nd, 3rd, and so on.</returns> 
    
  4. 现在,在调用函数时,您将看到更多细节,如图 4.2所示:图形用户界面,文本,应用程序,聊天或短信,电子邮件 自动生成描述

图 4.2:显示更详细方法签名的工具提示

在编写第六版时,C# XML 文档注释在.NET Interactive 笔记本中不起作用。

良好实践:为所有函数添加 XML 文档注释。

在函数实现中使用 lambda 表达式

F#是微软的强类型函数优先编程语言,与 C#一样,它编译为 IL,由.NET 执行。函数式语言从 lambda 演算演变而来;一个仅基于函数的计算系统。代码看起来更像数学函数,而不是食谱中的步骤。

函数式语言的一些重要属性定义如下:

  • 模块化:在 C#中定义函数的相同好处也适用于函数式语言。将大型复杂代码库分解为较小的部分。

  • 不可变性:C#意义上的变量不存在。函数内部的任何数据值都不能更改。相反,可以从现有数据值创建新的数据值。这减少了错误。

  • 可维护性:代码更简洁明了(对于数学倾向的程序员而言!)。

自 C# 6 以来,微软一直致力于为该语言添加功能,以支持更函数化的方法。例如,在 C# 7 中添加元组模式匹配,在 C# 8 中添加非空引用类型,以及改进模式匹配并添加记录,即 C# 9 中的不可变对象

C# 6 中,微软增加了对表达式体函数成员的支持。我们现在来看一个例子。

数字序列的斐波那契数列总是以 0 和 1 开始。然后,序列的其余部分按照前两个数字相加的规则生成,如下列数字序列所示:

0 1 1 2 3 5 8 13 21 34 55 ... 

序列中的下一个项将是 34 + 55,即 89。

我们将使用斐波那契数列来说明命令式和声明式函数实现之间的区别:

  1. 添加一个名为FibImperative的函数,该函数将以命令式风格编写,如下所示:

    static int FibImperative(int term)
    {
      if (term == 1)
      {
        return 0;
      }
      else if (term == 2)
      {
        return 1;
      }
      else
      {
        return FibImperative(term - 1) + FibImperative(term - 2);
      }
    } 
    
  2. 添加一个名为RunFibImperative的函数,该函数在从 1 到 30 的for循环中调用FibImperative,如下所示:

    static void RunFibImperative()
    {
      for (int i = 1; i <= 30; i++)
      {
        WriteLine("The {0} term of the Fibonacci sequence is {1:N0}.",
          arg0: CardinalToOrdinal(i),
          arg1: FibImperative(term: i));
      }
    } 
    
  3. 注释掉其他方法调用,并调用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 6th term of the Fibonacci sequence is 5.
    The 7th term of the Fibonacci sequence is 8.
    The 8th term of the Fibonacci sequence is 13.
    The 9th term of the Fibonacci sequence is 21.
    The 10th term of the Fibonacci sequence is 34.
    The 11th term of the Fibonacci sequence is 55.
    The 12th term of the Fibonacci sequence is 89.
    The 13th term of the Fibonacci sequence is 144.
    The 14th term of the Fibonacci sequence is 233.
    The 15th term of the Fibonacci sequence is 377.
    The 16th term of the Fibonacci sequence is 610.
    The 17th term of the Fibonacci sequence is 987.
    The 18th term of the Fibonacci sequence is 1,597.
    The 19th term of the Fibonacci sequence is 2,584.
    The 20th term of the Fibonacci sequence is 4,181.
    The 21st term of the Fibonacci sequence is 6,765.
    The 22nd term of the Fibonacci sequence is 10,946.
    The 23rd term of the Fibonacci sequence is 17,711.
    The 24th term of the Fibonacci sequence is 28,657.
    The 25th term of the Fibonacci sequence is 46,368.
    The 26th term of the Fibonacci sequence is 75,025.
    The 27th term of the Fibonacci sequence is 121,393.
    The 28th term of the Fibonacci sequence is 196,418.
    The 29th term of the Fibonacci sequence is 317,811.
    The 30th term of the Fibonacci sequence is 514,229. 
    
  5. 添加一个名为FibFunctional的函数,采用声明式风格编写,如下列代码所示:

    static int FibFunctional(int term) => 
      term switch
      {
        1 => 0,
        2 => 1,
        _ => FibFunctional(term - 1) + FibFunctional(term - 2)
      }; 
    
  6. for语句中添加一个调用它的函数,该语句从 1 循环到 30,如下列代码所示:

    static void RunFibFunctional()
    {
      for (int i = 1; i <= 30; i++)
      {
        WriteLine("The {0} term of the Fibonacci sequence is {1:N0}.",
          arg0: CardinalToOrdinal(i),
          arg1: FibFunctional(term: i));
      }
    } 
    
  7. 注释掉RunFibImperative方法调用,并调用RunFibFunctional方法。

  8. 运行代码并查看结果(与之前相同)。

开发过程中的调试

在本节中,你将学习如何在开发时调试问题。你必须使用具有调试工具的代码编辑器,如 Visual Studio 或 Visual Studio Code。在撰写本文时,你不能使用.NET Interactive Notebooks 来调试代码,但预计未来会添加此功能。

更多信息:有些人发现为 Visual Studio Code 设置 OmniSharp 调试器很棘手。我已包含最常见问题的解决方法,但如果你仍有困难,尝试阅读以下链接中的信息:github.com/OmniSharp/omnisharp-vscode/blob/master/debugger.md

创建带有故意错误的代码

让我们通过创建一个带有故意错误的控制台应用程序来探索调试,然后我们将使用代码编辑器中的调试工具来追踪并修复它:

  1. 使用您偏好的编程工具,在Chapter04工作区/解决方案中添加一个名为Debugging控制台应用程序

  2. 在 Visual Studio Code 中,选择Debugging作为活动 OmniSharp 项目。当看到提示缺少必需资产的弹出警告消息时,点击以添加它们。

  3. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择。

  4. Program.cs中,添加一个带有故意错误的函数,如下列代码所示:

    static double Add(double a, double b)
    {
      return a * b; // deliberate bug!
    } 
    
  5. 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 
    
  6. 运行控制台应用程序并查看结果,如下列部分输出所示:

    4.5 + 2.5 = 11.25 
    

但是等等,有个错误!4.5 加上 2.5 应该是 7,而不是 11.25!

我们将使用调试工具来追踪并消除这个错误。

设置断点并开始调试

断点允许我们在想要暂停以检查程序状态和查找错误的代码行上做标记。

使用 Visual Studio 2022

让我们设置一个断点,然后使用 Visual Studio 2022 开始调试:

  1. 点击声明名为a的变量的语句。

  2. 导航至调试 | 切换断点或按 F9。随后,左侧边距栏中将出现一个红色圆圈,语句将以红色高亮显示,表明已设置断点,如图 4.3所示:

    图 4.3:使用 Visual Studio 2022 切换断点

    断点可以通过相同的操作关闭。你也可以在边距处左键点击来切换断点的开启和关闭,或者右键点击断点以查看更多选项,例如删除、禁用或编辑现有断点的条件或操作。

  3. 导航至调试 | 开始调试或按 F5。Visual Studio 启动控制台应用程序,并在遇到断点时暂停。这称为中断模式。额外的窗口标题为局部变量(显示当前局部变量的值),监视 1(显示你定义的任何监视表达式),调用堆栈异常设置即时窗口出现。调试工具栏出现。下一条将要执行的行以黄色高亮显示,黄色箭头从边距栏指向该行,如图4.4所示:

图 4.4:Visual Studio 2022 中的中断模式

如果你不想了解如何使用 Visual Studio Code 开始调试,则可以跳过下一节,继续阅读标题为使用调试工具栏导航的部分。

使用 Visual Studio Code

让我们设置一个断点,然后使用 Visual Studio Code 开始调试:

  1. 点击声明名为a的变量的语句。

  2. 导航至运行 | 切换断点或按 F9。边距栏左侧将出现一个红色圆圈,表示已设置断点,如图4.5所示:

    图 4.5:使用 Visual Studio Code 切换断点

    断点可以通过相同的操作关闭。你也可以在边距处左键点击来切换断点的开启和关闭,或者右键点击断点以查看更多选项,例如删除、禁用或编辑现有断点的条件或操作;或者在没有断点时添加断点、条件断点或日志点。

    日志点,也称为跟踪点,表示你希望记录某些信息而不必实际停止在该点执行代码。

  3. 导航至视图 | 运行,或在左侧导航栏中点击运行和调试图标(三角形“播放”按钮和“错误”),如图4.5所示。

  4. 调试窗口顶部,点击开始调试按钮(绿色三角形“播放”按钮)右侧的下拉菜单,并选择**.NET Core 启动(控制台)(调试)**,如图4.6所示:

    图 4.6:使用 Visual Studio Code 选择要调试的项目

    最佳实践:如果在调试项目的下拉列表中没有看到选项,那是因为该项目没有所需的调试资产。这些资产存储在 .vscode 文件夹中。要为项目创建 .vscode 文件夹,请导航至视图 | 命令面板,选择 OmniSharp: 选择项目,然后选择调试项目。几秒钟后,当提示调试中缺少构建和调试所需的资产。添加它们?时,点击以添加缺失的资产。

  5. 调试窗口顶部,点击开始调试按钮(绿色三角形“播放”按钮),或导航至运行 | 开始调试,或按 F5。Visual Studio Code 启动控制台应用程序,并在遇到断点时暂停。这称为断点模式。接下来要执行的行以黄色高亮显示,黄色方块从边距栏指向该行,如图 4.7 所示:

    图 4.7:Visual Studio Code 中的断点模式

使用调试工具栏导航

Visual Studio Code 显示一个浮动工具栏,上面有按钮,方便访问调试功能。Visual Studio 2022 在标准工具栏上有一个按钮用于开始或继续调试,另外还有一个单独的调试工具栏用于其他工具。

两者均在图 4.8 中展示,并按以下列表描述:

图形用户界面 描述自动生成,中等置信度

图 4.8:Visual Studio 2022 和 Visual Studio Code 中的调试工具栏

  • 继续 / F5:此按钮将从当前位置继续运行程序,直到程序结束或遇到另一个断点。

  • 单步执行 / F10,单步进入 / F11,单步退出 / Shift + F11(蓝色箭头在点上):这些按钮以不同方式逐条执行代码语句,稍后你将看到。

  • 重新启动 / Ctrl 或 Cmd + Shift + F5(圆形箭头):此按钮将停止程序,然后立即重新启动,同时再次附加调试器。

  • 停止 / Shift + F5(红色方块):此按钮将停止调试会话。

调试窗口

在调试时,Visual Studio Code 和 Visual Studio 都会显示额外的窗口,以便你在逐步执行代码时监控诸如变量等有用信息。

以下列表描述了最有用的窗口:

  • 变量,包括局部变量,自动显示任何局部变量的名称、值和类型。在逐步执行代码时,请留意此窗口。

  • 监视,或监视 1,显示你手动输入的变量和表达式的值。

  • 调用堆栈,显示函数调用堆栈。

  • 断点,显示所有断点并允许对其进行更精细的控制。

在断点模式下,编辑区域底部还有一个有用的窗口:

  • DEBUG CONSOLE即时窗口使您能够与代码实时交互。您可以查询程序状态,例如,通过输入变量名。例如,您可以通过输入1+2并按 Enter 键来提问“1+2 等于多少?”,如图4.9所示:

图 4.9:查询程序状态

逐行执行代码

让我们探索一些使用 Visual Studio 或 Visual Studio Code 逐行执行代码的方法:

  1. 导航至运行/调试 | 逐语句,或点击工具栏中的逐语句按钮,或按 F11。黄色高亮将前进一行。

  2. 导航至运行/调试 | 逐过程,或点击工具栏中的逐过程按钮,或按 F10。黄色高亮将前进一行。目前,您可以看到使用逐语句逐过程没有区别。

  3. 您现在应该位于调用Add方法的行上,如图4.10所示:

    图 4.10:逐语句进入和跳过代码

    逐语句逐过程的区别在于即将执行方法调用时:

    • 如果点击逐语句,调试器将进入方法内部,以便您可以逐行执行该方法。

    • 如果点击逐过程,整个方法将一次性执行;它不会跳过该方法而不执行。

  4. 点击逐语句以进入方法内部。

  5. 将鼠标悬停在代码编辑窗口中的ab参数上,注意会出现一个工具提示,显示它们的当前值。

  6. 选中表达式a * b,右键点击表达式,并选择添加到监视添加监视。该表达式被添加到监视窗口,显示此运算符正在将a乘以b以得到结果11.25

  7. 监视监视 1窗口中,右键点击表达式并选择删除表达式删除监视

  8. 通过在Add函数中将*更改为+来修复错误。

  9. 停止调试,重新编译,并通过点击圆形箭头重新启动按钮或按 Ctrl 或 Cmd + Shift + F5 重新开始调试。

  10. 跳过该函数,花一分钟注意它现在如何正确计算,然后点击继续按钮或按F5

  11. 使用 Visual Studio Code 时,请注意,在调试期间向控制台写入内容时,输出显示在DEBUG CONSOLE窗口中,而不是TERMINAL窗口中,如图4.11所示:

    图 4.11:调试期间向 DEBUG CONSOLE 写入内容

自定义断点

创建更复杂的断点很容易:

  1. 若仍在调试中,点击调试工具栏中的停止按钮,或导航至运行/调试 | 停止调试,或按 Shift + F5。

  2. 导航至运行 | 删除所有断点调试 | 删除所有断点

  3. 点击输出答案的WriteLine语句。

  4. 通过按 F9 或导航至运行/调试 | 切换断点来设置断点。

  5. 在 Visual Studio Code 中,右键点击断点并选择编辑断点...,然后输入一个表达式,例如answer变量必须大于 9,注意表达式必须计算为真才能激活断点,如图4.12所示:

    图 4.12:使用 Visual Studio Code 通过表达式自定义断点

  6. 在 Visual Studio 中,右键点击断点并选择条件...,然后输入一个表达式,例如answer变量必须大于 9,注意表达式必须计算为真才能激活断点。

  7. 开始调试并注意断点未被命中。

  8. 停止调试。

  9. 编辑断点或其条件,并将其表达式更改为小于 9。

  10. 开始调试并注意断点被命中。

  11. 停止调试。

  12. 编辑断点或其条件,(在 Visual Studio 中点击添加条件)并选择命中计数,然后输入一个数字,如3,意味着断点需被命中三次才会激活,如图4.13所示:图形用户界面,文本,应用程序 自动生成描述

    图 4.13:使用 Visual Studio 2022 通过表达式和热计数自定义断点

  13. 将鼠标悬停在断点的红色圆圈上以查看摘要,如图4.14所示:图形用户界面,文本,应用程序 自动生成描述

    图 4.14:Visual Studio Code 中自定义断点的摘要

您现在已使用一些调试工具修复了一个错误,并看到了设置断点的一些高级可能性。

开发和运行时日志记录

一旦您认为代码中的所有错误都已被移除,您将编译发布版本并部署应用程序,以便人们可以使用它。但没有任何代码是完全无错误的,运行时可能会发生意外错误。

最终用户通常不擅长记忆、承认,然后准确描述他们在错误发生时正在做什么,因此您不应依赖他们准确提供有用信息来重现问题以理解问题原因并进行修复。相反,您可以检测您的代码,这意味着记录感兴趣的事件。

最佳实践:在应用程序中添加代码以记录正在发生的事情,特别是在异常发生时,以便您可以审查日志并使用它们来追踪问题并修复问题。虽然我们将在第十章使用 Entity Framework Core 处理数据,以及第十五章使用模型-视图-控制器模式构建网站中再次看到日志记录,但日志记录是一个庞大的主题,因此本书只能涵盖基础知识。

理解日志记录选项

.NET 包含一些内置方法,通过添加日志记录功能来检测您的代码。本书将介绍基础知识。但日志记录是一个领域,第三方已经创建了一个丰富的生态系统,提供了超越微软所提供的强大解决方案。我无法做出具体推荐,因为最佳日志记录框架取决于您的需求。但我在此列出了一些常见选项:

  • Apache log4net

  • NLog

  • Serilog

使用调试和跟踪进行检测

有两种类型可用于向代码添加简单日志记录:DebugTrace

在我们深入探讨它们之前,让我们先快速概览一下每个:

  • Debug类用于添加仅在开发期间写入的日志记录。

  • Trace类用于添加在开发和运行时都会写入的日志记录。

您已见过Console类型的使用及其WriteLine方法向控制台窗口输出的情况。还有一对名为DebugTrace的类型,它们在输出位置上更具灵活性。

DebugTrace类向任何跟踪监听器写入。跟踪监听器是一种类型,可以配置为在调用WriteLine方法时将输出写入您喜欢的任何位置。.NET 提供了几种跟踪监听器,包括一个输出到控制台的监听器,您甚至可以通过继承TraceListener类型来创建自己的监听器。

写入默认跟踪监听器

一个跟踪监听器,即DefaultTraceListener类,是自动配置的,并写入 Visual Studio Code 的DEBUG CONSOLE窗口或 Visual Studio 的Debug窗口。您可以通过代码配置其他跟踪监听器。

让我们看看跟踪监听器的作用:

  1. 使用您偏好的编码工具,在Chapter04工作区/解决方案中添加一个名为Instrumenting的新控制台应用程序

  2. 在 Visual Studio Code 中,选择Instrumenting作为活动 OmniSharp 项目。当您看到弹出警告消息提示所需资产缺失时,点击以添加它们。

  3. Program.cs中,导入System.Diagnostics命名空间。

  4. 按照以下代码所示,从DebugTrace类中写入一条消息:

    Debug.WriteLine("Debug says, I am watching!");
    Trace.WriteLine("Trace says, I am watching!"); 
    
  5. 在 Visual Studio 中,导航至视图 | 输出,并确保显示输出自: 调试被选中。

  6. 开始调试Instrumenting控制台应用程序,并注意 Visual Studio Code 中的DEBUG CONSOLE或 Visual Studio 2022 中的Output窗口显示了这两条消息,以及其他调试信息,如加载的程序集 DLL,如图 4.154.16所示:图形用户界面,文本,网站 描述自动生成

    图 4.15:Visual Studio Code DEBUG CONSOLE 以蓝色显示两条消息

    图 4.16:Visual Studio 2022 Output 窗口显示包括两条消息在内的调试输出

配置跟踪监听器

现在,我们将配置另一个将写入文本文件的跟踪监听器:

  1. DebugTraceWriteLine调用之前,添加一个语句以在桌面上创建一个新的文本文件,并将其传递给一个新的跟踪监听器,该监听器知道如何写入文本文件,并为缓冲区启用自动刷新,如下面的代码中突出显示的那样:

    **// write to a text file in the project folder**
    **Trace.Listeners.Add(****new** **TextWriterTraceListener(**
     **File.CreateText(Path.Combine(Environment.GetFolderPath(**
     **Environment.SpecialFolder.DesktopDirectory),** **"log.txt"****))));**
    **// text writer is buffered, so this option calls**
    **// Flush() on all listeners after writing**
    **Trace.AutoFlush =** **true****;**
    Debug.WriteLine("Debug says, I am watching!");
    Trace.WriteLine("Trace says, I am watching!"); 
    

    良好实践:任何表示文件的类型通常都会实现一个缓冲区以提高性能。数据不是立即写入文件,而是写入内存缓冲区,只有当缓冲区满时,数据才会一次性写入文件。这种行为在调试时可能会令人困惑,因为我们不会立即看到结果!启用AutoFlush意味着在每次写入后自动调用Flush方法。

  2. 在 Visual Studio Code 中,通过在Instrumenting项目的TERMINAL窗口中输入以下命令来运行控制台应用的发布配置,并注意到似乎没有任何事情发生:

    dotnet run --configuration Release 
    
  3. 在 Visual Studio 2022 中,在标准工具栏上,从解决方案配置下拉列表中选择Release,如图4.17所示:

    图 4.17:在 Visual Studio 中选择 Release 配置

  4. 在 Visual Studio 2022 中,通过导航到Debug | 开始不调试来运行控制台应用的发布配置。

  5. 在您的桌面上,打开名为log.txt的文件,并注意到它包含消息Trace says, I am watching!

  6. 在 Visual Studio Code 中,通过在Instrumenting项目的TERMINAL窗口中输入以下命令来运行控制台应用的调试配置:

    dotnet run --configuration Debug 
    
  7. 在 Visual Studio 中,在标准工具栏上,从解决方案配置下拉列表中选择Debug,然后通过导航到Debug | 开始调试来运行控制台应用。

  8. 在您的桌面上,打开名为log.txt的文件,并注意到它包含了消息Debug says, I am watching!Trace says, I am watching!

良好实践:当使用Debug配置运行时,DebugTrace均处于激活状态,并将写入任何跟踪监听器。当使用Release配置运行时,只有Trace会写入任何跟踪监听器。因此,您可以在代码中自由使用Debug.WriteLine调用,知道在构建应用程序的发布版本时它们会自动被移除,从而不会影响性能。

切换跟踪级别

Trace.WriteLine调用即使在发布后仍保留在代码中。因此,能够精细控制它们的输出时机将非常有益。这可以通过跟踪开关实现。

跟踪开关的值可以使用数字或单词设置。例如,数字3可以替换为单词Info,如下表所示:

编号 单词 描述
0 Off 这将不输出任何内容。
1 Error 这将仅输出错误。
2 Warning 这将输出错误和警告。
3 信息 这将输出错误、警告和信息。
4 详细 这将输出所有级别。

让我们探索使用跟踪开关。首先,我们将向项目添加一些 NuGet 包,以启用从 JSON appsettings文件加载配置设置。

在 Visual Studio Code 中向项目添加包

Visual Studio Code 没有向项目添加 NuGet 包的机制,因此我们将使用命令行工具:

  1. 导航到Instrumenting项目的终端窗口。

  2. 输入以下命令:

    dotnet add package Microsoft.Extensions.Configuration 
    
  3. 输入以下命令:

    dotnet add package Microsoft.Extensions.Configuration.Binder 
    
  4. 输入以下命令:

    dotnet add package Microsoft.Extensions.Configuration.Json 
    
  5. 输入以下命令:

    dotnet add package Microsoft.Extensions.Configuration.FileExtensions 
    

    dotnet add package向项目文件添加对 NuGet 包的引用。它将在构建过程中下载。dotnet add reference向项目文件添加项目到项目的引用。如果需要,将在构建过程中编译引用的项目。

在 Visual Studio 2022 中向项目添加包

Visual Studio 具有添加包的图形用户界面。

  1. 解决方案资源管理器中,右键单击Instrumenting项目并选择管理 NuGet 包

  2. 选择浏览选项卡。

  3. 在搜索框中,输入Microsoft.Extensions.Configuration

  4. 选择这些 NuGet 包中的每一个,然后点击安装按钮,如图4.18所示:

    1. Microsoft.Extensions.Configuration

    2. Microsoft.Extensions.Configuration.Binder

    3. Microsoft.Extensions.Configuration.Json

    4. Microsoft.Extensions.Configuration.FileExtensions图形用户界面,文本,应用程序 描述自动生成

    图 4.18:使用 Visual Studio 2022 安装 NuGet 包

最佳实践:还有用于从 XML 文件、INI 文件、环境变量和命令行加载配置的包。为项目设置配置选择最合适的技术。

审查项目包

添加 NuGet 包后,我们可以在项目文件中看到引用:

  1. 打开Instrumenting.csproj(在 Visual Studio 的解决方案资源管理器中双击Instrumenting项目),并注意添加了 NuGet 包的<ItemGroup>部分,如下所示高亮显示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference**
     **Include=****"Microsoft.Extensions.Configuration"**
     **Version=****"6.0.0"** **/>**
     **<PackageReference**
     **Include=****"Microsoft.Extensions.Configuration.Binder"**
     **Version=****"6.0.0"** **/>**
     **<PackageReference**
     **Include=****"Microsoft.Extensions.Configuration.FileExtensions"**
     **Version=****"6.0.0"** **/>**
     **<PackageReference**
     **Include=****"Microsoft.Extensions.Configuration.Json"**
     **Version=****"6.0.0"** **/>**
     **</ItemGroup>**
    </Project> 
    
  2. Instrumenting项目文件夹添加一个名为appsettings.json的文件。

  3. 修改appsettings.json以定义一个名为PacktSwitch的设置,其Level值如下所示:

    {
      "PacktSwitch": {
        "Level": "Info"
      }
    } 
    
  4. 在 Visual Studio 2022 中,在解决方案资源管理器中右键单击appsettings.json,选择属性,然后在属性窗口中,将复制到输出目录更改为如果较新则复制。这是必要的,因为与在项目文件夹中运行控制台应用程序的 Visual Studio Code 不同,Visual Studio 在Instrumenting\bin\Debug\net6.0Instrumenting\bin\Release\net6.0中运行控制台应用程序。

  5. Program.cs顶部,导入Microsoft.Extensions.Configuration命名空间。

  6. Program.cs末尾添加一些语句,以创建一个配置构建器,该构建器会在当前文件夹中查找名为appsettings.json的文件,构建配置,创建跟踪开关,通过绑定到配置来设置其级别,然后输出四个跟踪开关级别,如下面的代码所示:

    ConfigurationBuilder builder = new();
    builder.SetBasePath(Directory.GetCurrentDirectory())
      .AddJsonFile("appsettings.json", 
        optional: true, reloadOnChange: true);
    IConfigurationRoot configuration = builder.Build(); 
    TraceSwitch ts = new(
      displayName: "PacktSwitch",
      description: "This switch is set via a JSON config."); 
    configuration.GetSection("PacktSwitch").Bind(ts);
    Trace.WriteLineIf(ts.TraceError, "Trace error"); 
    Trace.WriteLineIf(ts.TraceWarning, "Trace warning"); 
    Trace.WriteLineIf(ts.TraceInfo, "Trace information"); 
    Trace.WriteLineIf(ts.TraceVerbose, "Trace verbose"); 
    
  7. Bind语句上设置断点。

  8. 开始调试Instrumenting控制台应用。在变量局部变量窗口中,展开ts变量表达式,并注意其级别Off,其TraceErrorTraceWarning等均为false,如图 4.19 所示:图形用户界面,文本,应用程序 自动生成的描述

    图 4.19:在 Visual Studio 2022 中观察跟踪开关变量属性

  9. 通过点击步入步过按钮,或按 F11 或 F10,步入对Bind方法的调用,并注意ts变量监视表达式更新至Info级别。

  10. 步入或步过对Trace.WriteLineIf的四次调用,并注意所有级别直至Info都被写入到DEBUG CONSOLE输出 - 调试窗口,但不包括Verbose,如图 4.20 所示:图形用户界面,文本,应用程序 自动生成的描述

    图 4.20:在 Visual Studio Code 的 DEBUG CONSOLE 中显示的不同跟踪级别

  11. 停止调试。

  12. 修改appsettings.json,将其级别设置为2,即警告,如下面的 JSON 文件所示:

    {
      "PacktSwitch": { 
        "Level": "2"
      }
    } 
    
  13. 保存更改。

  14. 在 Visual Studio Code 中,通过在Instrumenting项目的TERMINAL窗口中输入以下命令来运行控制台应用程序:

    dotnet run --configuration Release 
    
  15. 在 Visual Studio 的标准工具栏中,从解决方案配置下拉列表中选择发布,然后通过导航到调试 | 启动而不调试来运行控制台应用。

  16. 打开名为log.txt的文件,并注意这次只有跟踪错误和警告级别是四个潜在跟踪级别的输出,如下面的文本文件所示:

    Trace says, I am watching! 
    Trace error
    Trace warning 
    

如果没有传递参数,默认的跟踪开关级别为Off(0),因此不会输出任何开关级别。

单元测试

修复代码中的 bug 成本高昂。在开发过程中越早发现 bug,修复它的成本就越低。

单元测试是早期开发过程中发现 bug 的好方法。一些开发者甚至遵循程序员应在编写代码之前创建单元测试的原则,这称为测试驱动开发TDD)。

微软有一个专有的单元测试框架,称为MS Test。还有一个名为NUnit的框架。然而,我们将使用免费且开源的第三方框架xUnit.net。xUnit 是由构建 NUnit 的同一团队创建的,但他们修正了之前感觉错误的方面。xUnit 更具扩展性,并拥有更好的社区支持。

理解测试类型

单元测试只是众多测试类型中的一种,如下表所述:

测试类型 描述
单元 测试代码的最小单元,通常是一个方法或函数。单元测试是通过模拟其依赖项(如果需要)来隔离代码单元进行的。每个单元应该有多个测试:一些使用典型输入和预期输出,一些使用极端输入值来测试边界,还有一些使用故意错误的输入来测试异常处理。
集成 测试较小的单元和较大的组件是否能作为一个软件整体协同工作。有时涉及与没有源代码的外部组件的集成。
系统 测试你的软件将运行的整个系统环境。
性能 测试你的软件性能;例如,你的代码必须在 20 毫秒内向访问者返回一个充满数据的网页。
负载 测试你的软件在保持所需性能的同时可以处理多少请求,例如,一个网站可以同时容纳 10,000 名访问者。
用户接受度 测试用户是否能愉快地使用你的软件完成他们的工作。

创建一个需要测试的类库

首先,我们将创建一个需要测试的函数。我们将在类库项目中创建它。类库是一个可以被其他.NET 应用程序分发和引用的代码包:

  1. 使用你偏好的编码工具,在Chapter04工作区/解决方案中添加一个新的类库,命名为CalculatorLibdotnet new模板名为classlib

  2. 将文件名为Class1.cs重命名为Calculator.cs

  3. 修改文件以定义一个Calculator类(故意包含一个错误!),如下所示的代码:

    namespace Packt
    {
      public class Calculator
      {
        public double Add(double a, double b)
        {
          return a * b;
        }
      }
    } 
    
  4. 编译你的类库项目:

    1. 在 Visual Studio 2022 中,导航到构建 | 构建 CalculatorLib

    2. 在 Visual Studio Code 中,在终端中输入命令dotnet build

  5. 使用你偏好的编码工具,在Chapter04工作区/解决方案中添加一个新的xUnit 测试项目[C#],命名为CalculatorLibUnitTestsdotnet new模板名为xunit

  6. 如果你使用的是 Visual Studio,在解决方案资源管理器中,选择CalculatorLibUnitTests项目,导航到项目 | 添加项目引用…,勾选框选择CalculatorLib项目,然后点击确定

  7. 如果你使用的是 Visual Studio Code,使用dotnet add reference命令或点击名为CalculatorLibUnitTests.csproj的文件,并修改配置以添加一个项目参考到CalculatorLib项目,如下所示的高亮标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <IsPackable>false</IsPackable>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
        <PackageReference Include="xunit" Version="2.4.1" />
        <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
          <IncludeAssets>runtime; build; native; contentfiles; 
            analyzers; buildtransitive</IncludeAssets>
          <PrivateAssets>all</PrivateAssets>
        </PackageReference>
        <PackageReference Include="coverlet.collector" Version="3.0.2">
          <IncludeAssets>runtime; build; native; contentfiles; 
            analyzers; buildtransitive</IncludeAssets>
          <PrivateAssets>all</PrivateAssets>
        </PackageReference>
      </ItemGroup>
     **<ItemGroup>**
     **<ProjectReference**
     **Include=****"..\CalculatorLib\CalculatorLib.csproj"** **/>**
     **</ItemGroup>**
    </Project> 
    
  8. 构建CalculatorLibUnitTests项目。

编写单元测试

一个编写良好的单元测试将包含三个部分:

  • 准备: 这一部分将声明并实例化输入和输出的变量。

  • 执行: 这一部分将执行你正在测试的单元。在我们的例子中,这意味着调用我们想要测试的方法。

  • 断言:这一部分将做出一个或多个关于输出的断言。断言是一种信念,如果它不成立,则表明测试失败。例如,当加 2 和 2 时,我们期望结果是 4。

现在,我们将为Calculator类编写一些单元测试:

  1. 将文件UnitTest1.cs重命名为CalculatorUnitTests.cs,然后打开它。

  2. 在 Visual Studio Code 中,将类重命名为CalculatorUnitTests。(当你重命名文件时,Visual Studio 会提示你重命名类。)

  3. 导入Packt命名空间。

  4. 修改CalculatorUnitTests类,使其有两个测试方法,分别用于加 2 和 2,以及加 2 和 3,如下面的代码所示:

    using Packt; 
    using Xunit;
    namespace CalculatorLibUnitTests
    {
      public class CalculatorUnitTests
      {
        [Fact]
        public void TestAdding2And2()
        {
          // arrange 
          double a = 2; 
          double b = 2;
          double expected = 4;
          Calculator calc = new();
          // act
          double actual = calc.Add(a, b);
          // assert
          Assert.Equal(expected, actual);
        }
        [Fact]
        public void TestAdding2And3()
        {
          // arrange 
          double a = 2; 
          double b = 3;
          double expected = 5;
          Calculator calc = new();
          // act
          double actual = calc.Add(a, b);
          // assert
          Assert.Equal(expected, actual);
        }
      }
    } 
    

使用 Visual Studio Code 运行单元测试

现在我们准备好运行单元测试并查看结果:

  1. CalculatorLibUnitTest项目的终端窗口中运行测试,如下面的命令所示:

    dotnet test 
    
  2. 注意结果显示两个测试运行,一个测试通过,一个测试失败,如图4.21所示:

    图 4.21:Visual Studio Code 的终端中的单元测试结果

使用 Visual Studio 运行单元测试

现在我们准备好运行单元测试并查看结果:

  1. 导航到测试 | 运行所有测试

  2. 测试资源管理器中,注意结果显示两个测试运行,一个测试通过,一个测试失败,如图4.22所示:图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 4.22:Visual Studio 2022 的测试资源管理器中的单元测试结果

修复错误

现在你可以修复错误:

  1. 修复Add方法中的错误。

  2. 再次运行单元测试,看看错误现在是否已被修复,两个测试都通过。

在函数中抛出和捕获异常

第三章控制流程、转换类型和处理异常中,你被介绍了异常以及如何使用try-catch语句来处理它们。但你应该只在有足够信息来缓解问题时才捕获和处理异常。如果没有,那么你应该允许异常通过调用堆栈传递到更高级别。

理解使用错误和执行错误

使用错误是指程序员错误地使用了一个函数,通常是通过传递无效的参数值。这些错误可以通过程序员修改代码以传递有效值来避免。一些初学 C#和.NET 的程序员有时认为异常总是可以避免的,因为他们假设所有错误都是使用错误。使用错误应在生产运行时之前全部修复。

执行错误是指在运行时发生的事情,无法通过编写“更好”的代码来修复。执行错误可分为程序错误系统错误。如果您尝试访问网络资源但网络已关闭,您需要能够通过记录异常来处理该系统错误,并可能暂时退避并尝试再次访问。但某些系统错误,如内存耗尽,根本无法处理。如果您尝试打开一个不存在的文件,您可能能够捕获该错误并通过编程方式处理它,即创建一个新文件。程序错误可以通过编写智能代码来编程修复。系统错误通常无法通过编程方式修复。

函数中常见的抛出异常

您很少应该定义新的异常类型来指示使用错误。.NET 已经定义了许多您应该使用的异常。

在定义带有参数的自己的函数时,您的代码应检查参数值,并在它们具有阻止您的函数正常工作的值时抛出异常。

例如,如果一个参数不应为 null,则抛出 ArgumentNullException。对于其他问题,抛出 ArgumentExceptionNotSupportedExceptionInvalidOperationException。对于任何异常,都应包含一条消息,描述问题所在,以便需要阅读它的人(通常是类库和函数的开发者受众,或如果是 GUI 应用的最高级别,则为最终用户),如下列代码所示:

static void Withdraw(string accountName, decimal amount)
{
  if (accountName is null)
  {
    throw new ArgumentNullException(paramName: nameof(accountName));
  }
  if (amount < 0)
  {
    throw new ArgumentException(
      message: $"{nameof(amount)} cannot be less than zero.");
  }
  // process parameters
} 

最佳实践:如果一个函数无法成功执行其操作,应将其视为函数失败,并通过抛出异常来报告。

您永远不需要编写 try-catch 语句来捕获这些使用类型的错误。您希望应用程序终止。这些异常应促使调用函数的程序员修复其代码以防止问题。它们应在生产部署之前修复。这并不意味着您的代码不需要抛出使用错误类型的异常。您应该这样做——以强制其他程序员正确调用您的函数!

理解调用栈

.NET 控制台应用程序的入口点是 Program 类的 Main 方法,无论您是否明确定义了这个类和方法,或者它是否由顶级程序功能为您创建。

Main 方法会调用其他方法,这些方法又会调用其他方法,依此类推,这些方法可能位于当前项目或引用的项目和 NuGet 包中,如图 4.23所示:

图 4.23:创建调用栈的方法调用链

让我们创建一个类似的方法链,以探索我们可以在哪里捕获和处理异常:

  1. 使用您偏好的编程工具,在 Chapter04 工作区/解决方案中添加一个名为 CallStackExceptionHandlingLib类库

  2. Class1.cs 文件重命名为 Calculator.cs

  3. 打开Calculator.cs并修改其内容,如下面的代码所示:

    using static System.Console;
    namespace Packt;
    public class Calculator
    {
      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. 使用你喜欢的编码工具,在Chapter04工作区/解决方案中添加一个名为CallStackExceptionHandling的新控制台应用程序

  5. 在 Visual Studio Code 中,选择CallStackExceptionHandling作为活动 OmniSharp 项目。当看到弹出警告消息提示缺少必需资产时,点击以添加它们。

  6. CallStackExceptionHandling项目中,添加对CallStackExceptionHandlingLib项目的引用。

  7. Program.cs中,添加语句以定义两个方法并链接调用它们,以及类库中的方法,如下面的代码所示:

    using Packt;
    using static System.Console;
    WriteLine("In Main");
    Alpha();
    static void Alpha()
    {
      WriteLine("In Alpha");
      Beta();
    }
    static void Beta()
    {
      WriteLine("In Beta");
      Calculator.Gamma();
    } 
    
  8. 运行控制台应用程序,并注意结果,如下面的部分输出所示:

    In Main
    In Alpha
    In Beta
    In Gamma
    In Delta
    Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\Code\Chapter04\CallStackExceptionHandling\bin\Debug\net6.0\bad file path'.
       at Microsoft.Win32.SafeHandles.SafeFileHandle.CreateFile(...
       at Microsoft.Win32.SafeHandles.SafeFileHandle.Open(...
       at System.IO.Strategies.OSFileStreamStrategy..ctor(...
       at System.IO.Strategies.FileStreamHelpers.ChooseStrategyCore(...
       at System.IO.Strategies.FileStreamHelpers.ChooseStrategy(...
       at System.IO.StreamReader.ValidateArgsAndOpenPath(...
       at System.IO.File.OpenText(String path) in ...
       at Packt.Calculator.Delta() in C:\Code\Chapter04\CallStackExceptionHandlingLib\Calculator.cs:line 16
       at Packt.Calculator.Gamma() in C:\Code\Chapter04\CallStackExceptionHandlingLib\Calculator.cs:line 10
       at <Program>$.<<Main>$>g__Beta|0_1() in C:\Code\Chapter04\CallStackExceptionHandling\Program.cs:line 16
       at <Program>$.<<Main>$>g__Alpha|0_0() in C:\Code\Chapter04\CallStackExceptionHandling\Program.cs:line 10
       at <Program>$.<Main>$(String[] args) in C:\Code\Chapter04\CallStackExceptionHandling\Program.cs:line 5 
    

注意以下几点:

  • 调用堆栈是颠倒的。从底部开始,你会看到:

    • 第一次调用是调用自动生成的Program类中的Main入口点函数。这是将参数作为string数组传递的地方。

    • 第二次调用是调用Alpha函数。

    • 第三次调用是调用Beta函数。

    • 第四次调用是调用Gamma函数。

    • 第五次调用是调用Delta函数。该函数试图通过传递错误的文件路径来打开文件。这会导致抛出异常。任何具有try-catch语句的函数都可以捕获此异常。如果它们没有捕获,异常会自动向上传递到调用堆栈的顶部,在那里.NET 输出异常(以及此调用堆栈的详细信息)。

在哪里捕获异常

程序员可以选择在故障点附近捕获异常,或者在调用堆栈的更高层集中处理。这使得代码更简化、标准化。你可能知道调用异常可能会抛出一种或多种类型的异常,但在当前调用堆栈点无需处理任何异常。

重新抛出异常

有时你想要捕获异常,记录它,然后重新抛出它。在catch块内重新抛出异常有三种方法,如下表所示:

  1. 要使用其原始调用堆栈抛出捕获的异常,请调用throw

  2. 要将捕获的异常抛出,就像它在当前调用堆栈级别抛出一样,调用throw并传入捕获的异常,例如throw ex。这通常是不良实践,因为你丢失了一些可能对调试有用的信息。

  3. 为了将捕获的异常包装在另一个异常中,该异常可以在消息中包含更多信息,这可能有助于调用者理解问题,抛出一个新异常,并将捕获的异常作为innerException参数传递。

如果在调用Gamma函数时可能发生错误,那么我们可以捕获异常,然后执行三种重新抛出异常技术中的一种,如下面的代码所示:

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语句,如下所示:

    static void Beta()
    {
      WriteLine("In Beta");
    **try**
     **{**
     **Calculator.Gamma();**
     **}**
     **catch (Exception ex)**
     **{**
     **WriteLine(****$"Caught this:** **{ex.Message}****"****);**
    **throw** **ex;**
     **}**
    } 
    
  2. 注意ex下面的绿色波浪线,它会警告你将丢失调用堆栈信息。

  3. 运行控制台应用并注意输出排除了调用堆栈的一些细节,如下所示:

    Caught this: Could not find file 'C:\Code\Chapter04\CallStackExceptionHandling\bin\Debug\net6.0\bad file path'.
    Unhandled exception. System.IO.FileNotFoundException: Could not find file 'C:\Code\Chapter04\CallStackExceptionHandling\bin\Debug\net6.0\bad file path'.
    File name: 'C:\Code\Chapter04\CallStackExceptionHandling\bin\Debug\net6.0\bad file path'
       at <Program>$.<<Main>$>g__Beta|0_1() in C:\Code\Chapter04\CallStackExceptionHandling\Program.cs:line 25
       at <Program>$.<<Main>$>g__Alpha|0_0() in C:\Code\Chapter04\CallStackExceptionHandling\Program.cs:line 11
       at <Program>$.<Main>$(String[] args) in C:\Code\Chapter04\CallStackExceptionHandling\Program.cs:line 6 
    
  4. 在重新抛出异常时删除ex

  5. 运行控制台应用并注意输出包括了调用堆栈的所有细节。

实现测试者-执行者模式

测试者-执行者模式可以避免一些抛出的异常(但不能完全消除它们)。此模式使用一对函数:一个执行测试,另一个执行如果测试未通过则会失败的操作。

.NET 本身实现了这种模式。例如,在通过调用Add方法向集合添加项之前,你可以测试它是否为只读,这会导致Add失败并因此抛出异常。

例如,在从银行账户取款前,你可能会测试账户是否透支,如下所示:

if (!bankAccount.IsOverdrawn())
{
  bankAccount.Withdraw(amount);
} 

测试者-执行者模式的问题

测试者-执行者模式可能会增加性能开销,因此你也可以实现try 模式,它实际上将测试和执行部分合并为一个函数,正如我们在TryParse中看到的那样。

测试者-执行者模式的另一个问题出现在使用多线程时。在这种情况下,一个线程可能调用测试函数并返回正常。但随后另一个线程执行改变了状态。然后原始线程继续执行,假设一切正常,但实际上并非如此。这称为竞态条件。我们将在第十二章使用多任务提高性能和可扩展性中看到如何处理它。

如果你实现自己的 try 模式函数且失败了,记得将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;
} 

实践和探索

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

练习 4.1 – 测试你的知识

回答以下问题。如果你卡住了,必要时尝试通过谷歌搜索答案,同时记住如果你完全卡住了,答案在附录中:

  1. C#关键字void是什么意思?

  2. 命令式和函数式编程风格之间有哪些区别?

  3. 在 Visual Studio Code 或 Visual Studio 中,按 F5、Ctrl 或 Cmd + F5、Shift + F5 以及 Ctrl 或 Cmd + Shift + F5 之间有何区别?

  4. Trace.WriteLine方法将输出写入到哪里?

  5. 五个跟踪级别是什么?

  6. Debug类和Trace类之间有何区别?

  7. 编写单元测试时,三个“A”是什么?

  8. 在使用 xUnit 编写单元测试时,你必须用什么属性来装饰测试方法?

  9. 执行 xUnit 测试的dotnet命令是什么?

  10. 要重新抛出名为ex的捕获异常而不丢失堆栈跟踪,应使用什么语句?

练习 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

创建一个名为PrimeFactors的工作区/解决方案,包含三个项目:一个类库,其中有一个名为PrimeFactors的方法,当传入一个int变量作为参数时,返回一个显示其质因数的string;一个单元测试项目;以及一个控制台应用程序来使用它。

为简化起见,你可以假设输入的最大数字将是 1,000。

使用调试工具并编写单元测试,以确保你的函数能正确处理多个输入并返回正确的输出。

练习 4.3 – 探索主题

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

第四章 - 编写、调试和测试函数

总结

在本章中,你学习了如何编写可重用的函数,这些函数具有输入参数和返回值,既采用命令式风格也采用函数式风格,然后如何使用 Visual Studio 和 Visual Studio Code 的调试和诊断功能来修复其中的任何错误。最后,你学习了如何在函数中抛出和捕获异常,并理解调用堆栈。

在下一章中,你将学习如何使用面向对象编程技术构建自己的类型。

第五章:使用面向对象编程构建自己的类型

本章是关于使用面向对象编程OOP)创建自己的类型。你将了解类型可以拥有的所有不同类别的成员,包括存储数据的字段和执行操作的方法。你将使用 OOP 概念,如聚合和封装。你还将了解语言特性,如元组语法支持、输出变量、推断元组名称和默认文字。

本章将涵盖以下主题:

  • 谈论 OOP

  • 构建类库

  • 使用字段存储数据

  • 编写和调用方法

  • 使用属性和索引器控制访问

  • 使用对象进行模式匹配

  • 使用记录

谈论 OOP

现实世界中的对象是某个事物,比如汽车或人,而在编程中,对象通常代表现实世界中的某个事物,比如产品或银行账户,但也可能是更抽象的东西。

在 C#中,我们使用class(大多数情况下)或struct(有时)C#关键字来定义对象类型。你将在第六章实现接口和继承类中了解类和结构之间的区别。你可以将类型视为对象的蓝图或模板。

OOP 的概念简要描述如下:

  • 封装是与对象相关的数据和操作的组合。例如,BankAccount类型可能具有数据,如BalanceAccountName,以及操作,如DepositWithdraw。在封装时,你通常希望控制可以访问这些操作和数据的内容,例如,限制从外部访问或修改对象的内部状态。

  • 组合是关于对象由什么构成的。例如,Car由不同的部分组成,例如四个Wheel对象,几个Seat对象和一个Engine

  • 聚合是关于可以与对象结合的内容。例如,Person不是Car对象的一部分,但他们可以坐在驾驶员的Seat上,然后成为汽车的Driver——两个独立的对象聚合在一起形成一个新的组件。

  • 继承是通过让子类基类超类派生来重用代码。基类中的所有功能都被继承并可在派生类中使用。例如,基类或超类Exception具有一些成员,这些成员在所有异常中具有相同的实现,而子类或派生类SqlException继承了这些成员,并且具有仅与 SQL 数据库异常发生时相关的额外成员,例如数据库连接的属性。

  • 抽象是捕捉对象核心思想并忽略细节或具体内容的概念。C#有abstract关键字正式化这一概念。如果一个类没有明确地抽象,那么它可以被描述为具体的。基类或超类通常是抽象的,例如,超类Stream是抽象的,而它的子类,如FileStreamMemoryStream,是具体的。只有具体类可以用来创建对象;抽象类只能用作其他类的基类,因为它们缺少一些实现。抽象是一个棘手的平衡。如果你使一个类更抽象,更多的类将能够继承自它,但同时,可共享的功能将更少。

  • 多态性是指允许派生类覆盖继承的操作以提供自定义行为。

构建类库

类库程序集将类型组合成易于部署的单元(DLL 文件)。除了学习单元测试时,你只创建了控制台应用程序或.NET Interactive 笔记本以包含你的代码。为了使你编写的代码可跨多个项目重用,你应该将其放入类库程序集中,就像 Microsoft 所做的那样。

创建类库

第一个任务是创建一个可重用的.NET 类库:

  1. 使用你喜欢的编码工具创建一个新的类库,如下列表所定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:Chapter05

    3. 项目文件和文件夹:PacktLibrary

  2. 打开PacktLibrary.csproj文件,并注意默认情况下类库面向.NET 6,因此只能与其他.NET 6 兼容的程序集一起工作,如下面的标记所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
    </Project> 
    
  3. 将框架修改为目标.NET Standard 2.0,并删除启用可空引用类型和隐式 using 的条目,如下面的标记中突出显示的那样:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
     **<TargetFramework>netstandard****2.0****</TargetFramework>**
      </PropertyGroup>
    </Project> 
    
  4. 保存并关闭文件。

  5. 删除名为Class1.cs的文件。

  6. 编译项目以便其他项目稍后可以引用它:

    1. 在 Visual Studio Code 中,输入以下命令:dotnet build

    2. 在 Visual Studio 中,导航到生成 | 生成 PacktLibrary

最佳实践:为了使用最新的 C#语言和.NET 平台特性,将类型放入.NET 6 类库中。为了支持如.NET Core、.NET Framework 和 Xamarin 等遗留.NET 平台,将可能重用的类型放入.NET Standard 2.0 类库中。

在命名空间中定义一个类

接下来的任务是定义一个代表人的类:

  1. 添加一个名为Person.cs的新类文件。

  2. 静态导入System.Console

  3. 将命名空间设置为Packt.Shared

良好实践:我们这样做是因为将你的类放在逻辑命名的命名空间中很重要。更好的命名空间名称应该是特定领域的,例如,System.Numerics用于与高级数字相关的类型。在这种情况下,我们将创建的类型是PersonBankAccountWondersOfTheWorld,它们没有典型的领域,因此我们将使用更通用的Packt.Shared

你的类文件现在应该看起来像以下代码:

using System;
using static System.Console;
namespace Packt.Shared
{
  public class Person
  {
  }
} 

注意,C#关键字public在类之前应用。这个关键字是访问修饰符,它允许任何其他代码访问这个类。

如果你没有明确应用public关键字,那么它将只能在定义它的程序集中访问。这是因为类的默认访问修饰符是internal。我们需要这个类在程序集外部可访问,因此必须确保它是public

简化命名空间声明

如果你针对的是.NET 6.0,因此使用 C# 10 或更高版本,你可以用分号结束命名空间声明并删除大括号,如下所示:

using System; 
namespace Packt.Shared; // the class in this file is in this namespace
public class Person
{
} 

这被称为文件范围的命名空间声明。每个文件只能有一个文件范围的命名空间。我们将在本章后面针对.NET 6.0 的类库中使用这个。

良好实践:将你创建的每个类型放在其自己的文件中,以便你可以使用文件范围的命名空间声明。

理解成员

这种类型还没有任何成员封装在其中。我们将在接下来的页面上创建一些。成员可以是字段、方法或两者的特殊版本。你将在这里找到它们的描述:

  • 字段用于存储数据。还有三种特殊类别的字段,如下所示:

    • 常量:数据永不改变。编译器会将数据直接复制到任何读取它的代码中。

    • 只读:类实例化后数据不能改变,但数据可以在实例化时计算或从外部源加载。

    • 事件:数据引用一个或多个你希望在某些事情发生时执行的方法,例如点击按钮或响应来自其他代码的请求。事件将在第六章实现接口和继承类中介绍。

  • 方法用于执行语句。你在学习第四章编写、调试和测试函数中的函数时看到了一些例子。还有四种特殊类别的方法:

    • 构造函数:当你使用new关键字分配内存以实例化类时执行语句。

    • 属性:当你获取或设置数据时执行语句。数据通常存储在字段中,但也可能存储在外部或在运行时计算。属性是封装字段的首选方式,除非需要暴露字段的内存地址。

    • 索引器:当你使用"数组"语法[]获取或设置数据时,执行这些语句。

    • 运算符:当你在你的类型的操作数上使用运算符如+/时,执行这些语句。

实例化一个类

在本节中,我们将创建一个Person类的实例。

引用程序集

在我们能够实例化一个类之前,我们需要从另一个项目引用包含该类的程序集。我们将在控制台应用程序中使用该类:

  1. 使用你偏好的编码工具,在Chapter05工作区/解决方案中添加一个名为PeopleApp的新控制台应用程序。

  2. 如果你使用的是 Visual Studio Code:

    1. 选择PeopleApp作为活动 OmniSharp 项目。当你看到弹出警告消息说缺少必需资产时,点击以添加它们。

    2. 编辑PeopleApp.csproj以添加对PacktLibrary的项目引用,如下所示突出显示:

      <Project Sdk="Microsoft.NET.Sdk">
        <PropertyGroup>
          <OutputType>Exe</OutputType>
          <TargetFramework>net6.0</TargetFramework>
          <Nullable>enable</Nullable>
          <ImplicitUsings>enable</ImplicitUsings>
        </PropertyGroup>
       **<ItemGroup>**
       **<ProjectReference Include=****"../PacktLibrary/PacktLibrary.csproj"** **/>**
       **</ItemGroup>**
      </Project> 
      
    3. 在终端中,输入命令编译PeopleApp项目及其依赖项PacktLibrary项目,如下所示:

      dotnet build 
      
  3. 如果你使用的是 Visual Studio:

    1. 将解决方案的启动项目设置为当前选择。

    2. 解决方案资源管理器中,选择PeopleApp项目,导航至项目 | 添加项目引用…,勾选复选框选择PacktLibrary项目,然后点击确定

    3. 导航至生成 | 生成 PeopleApp

导入命名空间以使用类型

现在,我们准备好编写与Person类交互的语句了:

  1. PeopleApp项目/文件夹中,打开Program.cs

  2. Program.cs文件顶部,删除注释,并添加语句以导入我们Person类的命名空间并静态导入Console类,如下所示:

    using Packt.Shared;
    using static System.Console; 
    
  3. Program.cs中,添加以下语句:

    • 创建Person类型的实例。

    • 使用自身的文本描述输出实例。

    new关键字为对象分配内存并初始化任何内部数据。我们可以使用var代替Person类名,但随后我们需要在new关键字后指定Person,如下所示:

    // var bob = new Person(); // C# 1.0 or later
    Person bob = new(); // C# 9.0 or later
    WriteLine(bob.ToString()); 
    

    你可能会疑惑,“为什么bob变量有一个名为ToString的方法?Person类是空的!”别担心,我们即将揭晓!

  4. 运行代码并查看结果,如下所示:

    Packt.Shared.Person 
    

理解对象

尽管我们的Person类没有明确选择继承自某个类型,但所有类型最终都直接或间接继承自一个名为System.Object的特殊类型。

System.Object类型中ToString方法的实现仅输出完整的命名空间和类型名称。

回到原始的Person类,我们本可以明确告诉编译器Person继承自System.Object类型,如下所示:

public class Person : System.Object 

当类 B 继承自类 A 时,我们称 A 为基类或父类,B 为派生类或子类。在这种情况下,System.Object是基类或父类,Person是派生类或子类。

你也可以使用 C#别名关键字object,如下列代码所示:

public class Person : object 

继承自 System.Object

让我们使我们的类显式继承自object,然后回顾所有对象拥有的成员:

  1. 修改你的Person类,使其显式继承自object

  2. 点击object关键字内部,按 F12,或者右键点击object关键字并选择转到定义

你将看到微软定义的System.Object类型及其成员。这方面的细节你目前无需了解,但请注意它有一个名为ToString的方法,如图 5.1所示:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 5.1:System.Object 类定义

最佳实践:假设其他程序员知道,如果未指定继承,则类将继承自System.Object

在字段中存储数据

在本节中,我们将定义类中的一系列字段,用于存储有关个人的信息。

定义字段

假设我们已决定一个人由姓名和出生日期组成。我们将把这两个值封装在一个人内部,并且这些值对外可见。

Person类内部,编写语句以声明两个公共字段,用于存储一个人的姓名和出生日期,如下列代码所示:

public class Person : object
{
  // fields
  public string Name;
  public DateTime DateOfBirth;
} 

字段可以使用任何类型,包括数组和集合,如列表和字典。如果你需要在单个命名字段中存储多个值,这些类型就会派上用场。在本例中,一个人只有一个名字和一个出生日期。

理解访问修饰符

封装的一部分是选择成员的可见性。

请注意,正如我们对类所做的那样,我们明确地对这些字段应用了public关键字。如果我们没有这样做,那么它们将默认为private,这意味着它们只能在类内部访问。

有四个访问修饰符关键字,以及两种访问修饰符关键字的组合,你可以将其应用于类成员,如字段或方法,如下表所示:

访问修饰符 描述
private 成员仅在类型内部可访问。这是默认设置。
internal 成员在类型内部及同一程序集中的任何类型均可访问。
protected 成员在类型内部及其任何派生类型中均可访问。
public 成员在任何地方均可访问。
internal``protected 成员在类型内部、同一程序集中的任何类型以及任何派生类型中均可访问。相当于一个虚构的访问修饰符,名为internal_or_protected
private``protected 成员在类型内部、任何派生类型以及同一程序集中均可访问。相当于一个虚构的访问修饰符,名为internal_and_protected。这种组合仅在 C# 7.2 或更高版本中可用。

良好实践:明确地对所有类型成员应用一个访问修饰符,即使你想要使用成员的隐式访问修饰符,即private。此外,字段通常应该是privateprotected,然后你应该创建public属性来获取或设置字段值。这是因为它控制访问。你将在本章后面这样做。

设置和输出字段值

现在我们将在你的代码中使用这些字段:

  1. Program.cs顶部,确保导入了System命名空间。我们需要这样做才能使用DateTime类型。

  2. 实例化bob后,添加语句以设置他的姓名和出生日期,然后以美观的格式输出这些字段,如下所示:

    bob.Name = "Bob Smith";
    bob.DateOfBirth = new DateTime(1965, 12, 22); // C# 1.0 or later
    WriteLine(format: "{0} was born on {1:dddd, d MMMM yyyy}", 
      arg0: bob.Name,
      arg1: bob.DateOfBirth); 
    

    我们本可以使用字符串插值,但对于长字符串,它会在多行上换行,这在印刷书籍中可能更难以阅读。在本书的代码示例中,请记住{0}arg0的占位符,依此类推。

  3. 运行代码并查看结果,如下所示:

    Bob Smith was born on Wednesday, 22 December 1965 
    

    根据你的地区设置(即语言和文化),你的输出可能看起来不同。

    arg1的格式代码由几个部分组成。dddd表示星期几的名称。d表示月份中的日期号。MMMM表示月份的名称。小写的m用于时间值中的分钟。yyyy表示年份的完整数字。yy表示两位数的年份。

    你还可以使用花括号的简写对象初始化器语法初始化字段。让我们看看如何操作。

  4. 在现有代码下方添加语句以创建另一个名为 Alice 的新人。注意在向控制台输出她的出生日期时使用的不同格式代码,如下所示:

    Person alice = new()
    {
      Name = "Alice Jones",
      DateOfBirth = new(1998, 3, 7) // C# 9.0 or later
    };
    WriteLine(format: "{0} was born on {1:dd MMM yy}",
      arg0: alice.Name,
      arg1: alice.DateOfBirth); 
    
  5. 运行代码并查看结果,如下所示:

    Alice Jones was born on 07 Mar 98 
    

使用枚举类型存储值

有时,一个值需要是有限选项集中的一个。例如,世界上有七大古代奇迹,一个人可能有一个最喜欢的。在其他时候,一个值需要是有限选项集的组合。例如,一个人可能有一个他们想要访问的古代世界奇迹的遗愿清单。我们能够通过定义一个枚举类型来存储这些数据。

枚举类型是一种非常高效的方式来存储一个或多个选择,因为它内部使用整数值与string描述的查找表相结合:

  1. PacktLibrary项目添加一个名为WondersOfTheAncientWorld.cs的新文件。

  2. 修改WondersOfTheAncientWorld.cs文件,如下所示:

    namespace Packt.Shared
    {
      public enum WondersOfTheAncientWorld
      {
        GreatPyramidOfGiza,
        HangingGardensOfBabylon,
        StatueOfZeusAtOlympia,
        TempleOfArtemisAtEphesus,
        MausoleumAtHalicarnassus,
        ColossusOfRhodes,
        LighthouseOfAlexandria
      }
    } 
    

    良好实践:如果你在.NET Interactive 笔记本中编写代码,那么包含enum的代码单元格必须位于定义Person类的代码单元格之上。

  3. Person类中,向字段列表添加以下语句:

    public WondersOfTheAncientWorld FavoriteAncientWonder; 
    
  4. Program.cs中,添加以下语句:

    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. 运行代码并查看结果,如下所示:

    Bob Smith's favorite wonder is StatueOfZeusAtOlympia. Its integer is 2. 
    

enum值内部作为int存储以提高效率。int值从0开始自动分配,因此我们的enum中的第三个世界奇迹的值为2。你可以分配enum中未列出的int值。如果找不到匹配项,它们将输出为int值而不是名称。

使用枚举类型存储多个值

对于愿望清单,我们可以创建一个enum实例的数组或集合,本章后面将解释集合,但有一个更好的方法。我们可以使用enum标志将多个选择合并为一个值:

  1. 通过为enum添加[System.Flags]属性进行修改,并为每个代表不同位列的奇迹显式设置一个byte值,如下列代码中突出显示的那样:

    namespace Packt.Shared
    {
     **[****System.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**
      }
    } 
    

    我们正在为每个选择分配明确的值,这些值在查看内存中存储的位时不会重叠。我们还应该用System.Flags属性装饰enum类型,以便当值返回时,它可以自动与多个值匹配,作为逗号分隔的string而不是返回int值。

    通常,enum类型内部使用int变量,但由于我们不需要那么大的值,我们可以通过告诉它使用byte变量来减少 75%的内存需求,即每个值 1 字节而不是 4 字节。

    如果我们想表明我们的愿望清单包括巴比伦空中花园哈利卡纳苏斯的摩索拉斯陵墓这两大古代世界奇迹,那么我们希望将162位设置为1。换句话说,我们将存储值18

    64 32 16 8 4 2 1
    0 0 1 0 0 1 0
  2. Person类中,添加以下语句到你的字段列表中,如下列代码所示:

    public WondersOfTheAncientWorld BucketList; 
    
  3. Program.cs中,添加语句使用|运算符(按位逻辑或)来组合enum值以设置愿望清单。我们也可以使用数字 18 强制转换为enum类型来设置值,如注释所示,但我们不应该这样做,因为这会使代码更难以理解,如下列代码所示:

    bob.BucketList = 
      WondersOfTheAncientWorld.HangingGardensOfBabylon
      | WondersOfTheAncientWorld.MausoleumAtHalicarnassus;
    // bob.BucketList = (WondersOfTheAncientWorld)18;
    WriteLine($"{bob.Name}'s bucket list is {bob.BucketList}"); 
    
  4. 运行代码并查看结果,如下列输出所示:

    Bob Smith's bucket list is HangingGardensOfBabylon, MausoleumAtHalicarnassus 
    

最佳实践:使用enum值来存储离散选项的组合。如果有最多 8 个选项,则从byte派生enum类型;如果有最多 16 个选项,则从ushort派生;如果有最多 32 个选项,则从uint派生;如果有最多 64 个选项,则从ulong派生。

使用集合存储多个值

现在,让我们添加一个字段来存储一个人的子女。这是一个聚合的例子,因为子女是与当前人物相关联的类的实例,但并不属于该人物本身。我们将使用泛型List<T>集合类型,它可以存储任何类型的有序集合。你将在第八章使用常见的.NET 类型中了解更多关于集合的内容。现在,只需跟随操作:

  1. Person.cs中,导入System.Collections.Generic命名空间,如下面的代码所示:

    using System.Collections.Generic; // List<T> 
    
  2. Person类中声明一个新字段,如下面的代码所示:

    public List<Person> Children = new List<Person>(); 
    

List<Person>读作“Person 列表”,例如,“名为Children的属性的类型是Person实例的列表。”我们明确地将类库的目标更改为.NET Standard 2.0(使用 C# 7 编译器),因此我们不能使用目标类型的新来初始化Children字段。如果我们保持目标为.NET 6.0,那么我们可以使用目标类型的新,如下面的代码所示:

public List<Person> Children = new(); 

我们必须确保在向集合添加项之前,集合已初始化为一个新的Person列表实例,否则字段将为null,当我们尝试使用其任何成员(如Add)时,将抛出运行时异常。

理解泛型集合

List<T>类型中的尖括号是 C#的一个特性,称为泛型,于 2005 年随 C# 2.0 引入。这是一个用于创建强类型集合的术语,即编译器明确知道集合中可以存储哪种类型的对象。泛型提高了代码的性能和正确性。

强类型静态类型有不同的含义。旧的System.Collection类型静态地包含弱类型的System.Object项。新的System.Collection.Generic类型静态地包含强类型的<T>实例。

讽刺的是,泛型这一术语意味着我们可以使用更具体的静态类型!

  1. Program.cs中,添加语句为Bob添加两个孩子,然后展示他有多少孩子以及他们的名字,如下面的代码所示:

    bob.Children.Add(new Person { Name = "Alfred" }); // C# 3.0 and later
    bob.Children.Add(new() { Name = "Zoe" }); // C# 9.0 and later
    WriteLine(
      $"{bob.Name} has {bob.Children.Count} children:");
    for (int childIndex = 0; childIndex < bob.Children.Count; childIndex++)
    {
      WriteLine($"  {bob.Children[childIndex].Name}");
    } 
    

    我们也可以使用foreach语句来遍历集合。作为额外的挑战,将for语句改为使用foreach输出相同的信息。

  2. 运行代码并查看结果,如下面的输出所示:

    Bob Smith has 2 children:
      Alfred
      Zoe 
    

将字段设为静态

到目前为止我们创建的字段都是实例成员,意味着每个字段在创建的每个类实例中都有不同的值。alicebob变量具有不同的Name值。

有时,您希望定义一个在所有实例中共享的单一值的字段。

这些被称为静态 成员,因为字段不是唯一可以静态的成员。让我们看看使用static字段可以实现什么:

  1. PacktLibrary项目中,添加一个名为BankAccount.cs的新类文件。

  2. 修改类,使其具有三个字段,两个实例字段和一个静态字段,如下面的代码所示:

    namespace Packt.Shared
    {
      public class BankAccount
      {
        public string AccountName; // instance member
        public decimal Balance; // instance member
        public static decimal InterestRate; // shared member
      }
    } 
    

    每个BankAccount实例都将有自己的AccountNameBalance值,但所有实例将共享一个InterestRate值。

  3. Program.cs中,添加语句以设置共享的利率,然后创建两个BankAccount类型的实例,如下面的代码所示:

    BankAccount.InterestRate = 0.012M; // store a shared value
    BankAccount jonesAccount = new(); // C# 9.0 and later
    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); 
    

    :C是一个格式代码,告诉.NET 使用货币格式显示数字。在第八章《使用常见的.NET 类型》中,你将学习如何控制决定货币符号的文化。目前,它将使用你操作系统安装的默认设置。我住在英国伦敦,因此我的输出显示的是英镑(£)。

  4. 运行代码并查看附加输出:

    Mrs. Jones earned £28.80 interest. 
    Ms. Gerrier earned £1.18 interest. 
    

字段并非唯一可声明为静态的成员。构造函数、方法、属性及其他成员也可以是静态的。

将字段设为常量

如果某个字段的值永远不会改变,你可以使用const关键字,并在编译时赋值一个字面量:

  1. Person.cs中,添加以下代码:

     // constants
    public const string Species = "Homo Sapien"; 
    
  2. 要获取常量字段的值,你必须写出类名,而不是类的实例名。在Program.cs中,添加一条语句,将 Bob 的名字和物种输出到控制台,如下所示:

    WriteLine($"{bob.Name} is a {Person.Species}"); 
    
  3. 运行代码并查看结果,如下所示:

    Bob Smith is a Homo Sapien 
    

    微软类型中的const字段示例包括System.Int32.MaxValueSystem.Math.PI,因为这两个值永远不会改变,如图 5.2 所示:

    图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 5.2:常量示例

最佳实践:常量并不总是最佳选择,原因有二:其值必须在编译时已知,并且必须能表示为字面量stringBoolean或数值。对const字段的每次引用在编译时都会被替换为字面量值,因此,如果未来版本中该值发生变化,且你未重新编译引用它的任何程序集以获取新值,则不会反映这一变化。

将字段设为只读

对于不应更改的字段,通常更好的选择是将其标记为只读:

  1. Person.cs中,添加一条语句,声明一个实例只读字段以存储人的母星,如下所示:

    // read-only fields
    public readonly string HomePlanet = "Earth"; 
    
  2. Program.cs中,添加一条语句,将 Bob 的名字和母星输出到控制台,如下所示:

    WriteLine($"{bob.Name} was born on {bob.HomePlanet}"); 
    
  3. 运行代码并查看结果,如下所示:

    Bob Smith was born on Earth 
    

最佳实践:出于两个重要原因,建议使用只读字段而非常量字段:其值可以在运行时计算或加载,并且可以使用任何可执行语句来表达。因此,只读字段可以通过构造函数或字段赋值来设置。对字段的每次引用都是活跃的,因此任何未来的更改都将被调用代码正确反映。

你还可以声明static readonly字段,其值将在该类型的所有实例之间共享。

使用构造函数初始化字段

字段通常需要在运行时初始化。你可以在构造函数中执行此操作,该构造函数将在使用new关键字创建类的实例时被调用。构造函数在任何字段被使用该类型的代码设置之前执行。

  1. Person.cs中,在现有的只读HomePlanet字段之后添加语句以定义第二个只读字段,然后在构造函数中设置NameInstantiated字段,如下面的代码中突出显示的那样:

    // read-only fields
    public readonly string HomePlanet = "Earth";
    **public****readonly** **DateTime Instantiated;**
    **// constructors**
    **public****Person****()**
    **{**
    **// set default values for fields**
    **// including read-only fields**
     **Name =** **"Unknown"****;** 
     **Instantiated = DateTime.Now;**
    **}** 
    
  2. Program.cs中,添加语句以实例化一个新的人,然后输出其初始字段值,如下面的代码所示:

    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. 运行代码并查看结果,如下面的输出所示:

    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. 运行代码并查看结果:

    Gunny of Mars was created at 11:59:25 on a Sunday 
    

构造函数是一种特殊的方法类别。让我们更详细地看看方法。

编写和调用方法

方法是一种类型的成员,它执行一组语句。它们是属于某个类型的函数。

从方法中返回值

方法可以返回单个值或不返回任何值:

  • 执行某些操作但不返回值的方法通过在方法名称前使用void类型来表示这一点。

  • 执行某些操作并返回值的方法通过在方法名称前使用返回值的类型来表示这一点。

例如,在下一个任务中,你将创建两个方法:

  • WriteToConsole:这将执行一个动作(向控制台写入一些文本),但它不会从方法中返回任何内容,由void关键字表示。

  • GetOrigin:这将返回一个文本值,由string关键字表示。

让我们编写代码:

  1. Person.cs中,添加语句以定义我之前描述的两种方法,如下面的代码所示:

    // methods
    public void WriteToConsole()
    {
      WriteLine($"{Name} was born on a {DateOfBirth:dddd}.");
    }
    public string GetOrigin()
    {
      return $"{Name} was born on {HomePlanet}.";
    } 
    
  2. Program.cs中,添加语句以调用这两个方法,如下面的代码所示:

    bob.WriteToConsole(); 
    WriteLine(bob.GetOrigin()); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    Bob Smith was born on a Wednesday. 
    Bob Smith was born on Earth. 
    

使用元组组合多个返回值

每个方法只能返回一个具有单一类型的值。该类型可以是简单类型,如前例中的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。番茄,西红柿,土豆,马铃薯,我想。

元组自 F#等语言的第一个版本以来就一直是其中的一部分,但.NET 直到 2010 年使用System.Tuple类型才在.NET 4.0 中添加了对它们的支持。

语言对元组的支持

直到 2017 年 C# 7.0,C#才通过使用圆括号字符()添加了对元组的语言语法支持,同时.NET 引入了一个新的System.ValueTuple类型,在某些常见场景下比旧的.NET 4.0 System.Tuple类型更高效。C#的元组语法使用了更高效的那个。

让我们来探索元组:

  1. Person.cs中,添加语句以定义一个返回结合了stringint的元组的方法,如下列代码所示:

    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. 运行代码并查看结果,如下列输出所示:

    Apples, 5 there are. 
    

命名元组的字段

要访问元组的字段,默认名称是Item1Item2等。

你可以显式指定字段名称:

  1. Person.cs中,添加语句以定义一个返回具有命名字段的元组的方法,如下列代码所示:

    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}."); 
    
  3. 运行代码并查看结果,如下列输出所示:

    There are 5 Apples. 
    

推断元组名称

如果你是从另一个对象构建元组,你可以使用 C# 7.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.0 中,两者都会使用Item1Item2命名方案。在 C# 7.1 及更高版本中,thing2可以推断出名称NameCount

解构元组

你也可以将元组解构成单独的变量。解构声明的语法与命名字段元组相同,但没有为元组指定名称的变量,如下列代码所示:

// store return value in a tuple variable with two fields
(string TheName, int TheNumber) tupleWithNamedFields = bob.GetNamedFruit();
// tupleWithNamedFields.TheName
// tupleWithNamedFields.TheNumber
// deconstruct return value into two separate variables
(string name, int number) = GetNamedFruit();
// name
// number 

这具有将元组分解为其各个部分并将这些部分分配给新变量的效果。

  1. Program.cs中,添加语句以解构从GetFruit方法返回的元组,如下列代码所示:

    (string fruitName, int fruitNumber) = bob.GetFruit();
    WriteLine($"Deconstructed: {fruitName}, {fruitNumber}"); 
    
  2. 运行代码并查看结果,如下列输出所示:

    Deconstructed: Apples, 5 
    

解构类型

元组并非唯一可被解构的类型。任何类型都可以有名为Deconstruct的特殊方法,这些方法能将对象分解为各个部分。让我们为Person类实现一些这样的方法:

  1. Person.cs中,添加两个Deconstruct方法,为我们要分解的部分定义out参数,如下面的代码所示:

    // deconstructors
    public void Deconstruct(out string name, out DateTime dob)
    {
      name = Name;
      dob = DateOfBirth;
    }
    public void Deconstruct(out string name, 
      out DateTime dob, out WondersOfTheAncientWorld fav)
    {
      name = Name;
      dob = DateOfBirth;
      fav = FavoriteAncientWonder;
    } 
    
  2. Program.cs中,添加语句以分解bob,如下面的代码所示:

    // Deconstructing a Person
    var (name1, dob1) = bob;
    WriteLine($"Deconstructed: {name1}, {dob1}");
    var (name2, dob2, fav2) = bob;
    WriteLine($"Deconstructed: {name2}, {dob2}, {fav2}"); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    Deconstructed: Bob Smith, 22/12/1965 00:00:00
    Deconstructed: Bob Smith, 22/12/1965 00:00:00, StatueOfZeusAtOlympia
    B 
    

定义和传递参数给方法

方法可以接收参数来改变其行为。参数的定义有点像变量声明,但位于方法的括号内,正如本章前面在构造函数中看到的那样。我们来看更多例子:

  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. 运行代码并查看结果:

    Bob Smith says 'Hello!'
    Bob Smith says 'Hello Emily!' 
    

在输入调用方法的语句时,IntelliSense 会显示一个工具提示,其中包含任何参数的名称和类型,以及方法的返回类型,如图 5.3所示:

图形用户界面,文本,网站 描述自动生成

图 5.3:没有重载的方法的 IntelliSense 工具提示

方法重载

我们不必为两种不同的方法取不同的名字,可以给这两种方法取相同的名字。这是允许的,因为这两种方法的签名不同。

方法签名是一系列参数类型,可以在调用方法时传递。重载方法不能仅在返回类型上有所不同。

  1. Person.cs中,将SayHelloTo方法的名称更改为SayHello

  2. Program.cs中,将方法调用更改为使用SayHello方法,并注意方法的快速信息告诉你它有一个额外的重载,1/2,以及 2/2,如图 5.4所示:图形用户界面 描述自动生成,中等置信度

图 5.4:有重载的方法的 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 的出现。你会看到一个工具提示,显示三个可选参数及其默认值,如图 5.5所示:图形用户界面,文本,应用程序,聊天或短信 描述自动生成

    图 5.5:IntelliSense 显示您键入代码时的可选参数

  4. 运行代码并查看结果,如下所示:

    command is Run!, number is 0, active is True 
    
  5. Program.cs中,添加一条语句,为command参数传递一个string值,为number参数传递一个double值,如下所示:

    WriteLine(bob.OptionalParameters("Jump!", 98.5)); 
    
  6. 运行代码并查看结果,如下所示:

    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. 运行代码并查看结果,如下所示:

    command is Hide!, number is 52.7, active is True 
    

    您甚至可以使用命名参数跳过可选参数。

  3. Program.cs中,添加一条语句,按位置顺序为command参数传递一个string值,跳过number参数,并使用命名的active参数,如下所示:

    WriteLine(bob.OptionalParameters("Poke!", active: false)); 
    
  4. 运行代码并查看结果,如下所示:

    command is Poke!, number is 0, active is False 
    

控制参数的传递方式

当参数传递给方法时,它可以以三种方式之一传递:

  • 通过(默认方式):将其视为仅输入

  • 通过引用作为ref参数:将其视为进出

  • 作为out参数:将其视为仅输出

让我们看一些参数传递的例子:

  1. Person.cs中,添加语句以定义一个带有三个参数的方法,一个in参数,一个ref参数,以及一个out参数,如下所示:

    public void PassingParameters(int x, ref int y, out int z)
    {
      // out parameters cannot have a default
      // AND must be initialized inside the method
      z = 99;
      // increment each parameter
      x++; 
      y++; 
      z++;
    } 
    
  2. Program.cs中,添加语句以声明一些int变量并将它们传递给方法,如下所示:

    int a = 10; 
    int b = 20; 
    int c = 30;
    WriteLine($"Before: a = {a}, b = {b}, c = {c}"); 
    bob.PassingParameters(a, ref b, out c); 
    WriteLine($"After: a = {a}, b = {b}, c = {c}"); 
    
  3. 运行代码并查看结果,如下所示:

    Before: a = 10, b = 20, c = 30 
    After: a = 10, b = 21, c = 100 
    
    • 当默认传递变量作为参数时,传递的是其当前值,而不是变量本身。因此,xa变量值的副本。a变量保持其原始值10

    • 当将变量作为ref参数传递时,变量的引用被传递到方法中。因此,y是对b的引用。当y参数递增时,b变量也随之递增。

    • 当将变量作为out参数传递时,变量的引用被传递到方法中。因此,z是对c的引用。c变量的值被方法内部执行的代码所替换。我们可以在Main方法中简化代码,不将值30赋给c变量,因为它总是会被替换。

简化out参数

在 C# 7.0 及更高版本中,我们可以简化使用 out 变量的代码。

Program.cs中,添加语句以声明更多变量,包括一个名为f的内联声明的out参数,如下所示:

int d = 10; 
int e = 20;
WriteLine($"Before: d = {d}, e = {e}, f doesn't exist yet!");
// simplified C# 7.0 or later syntax for the out parameter 
bob.PassingParameters(d, ref e, out int f); 
WriteLine($"After: d = {d}, e = {e}, f = {f}"); 

理解 ref 返回

在 C# 7.0 或更高版本中,ref关键字不仅用于向方法传递参数;它还可以应用于return值。这使得外部变量可以引用内部变量并在方法调用后修改其值。这在高级场景中可能有用,例如,在大数据结构中传递占位符,但这超出了本书的范围。

使用 partial 拆分类

在处理大型项目或与多个团队成员合作时,或者在处理特别庞大且复杂的类实现时,能够将类的定义拆分到多个文件中非常有用。您可以通过使用partial关键字来实现这一点。

设想我们希望向Person类添加由类似对象关系映射器(ORM)的工具自动生成的语句,该工具从数据库读取架构信息。如果该类定义为partial,那么我们可以将类拆分为一个自动生成代码文件和一个手动编辑代码文件。

让我们编写一些代码来模拟此示例:

  1. Person.cs中,添加partial关键字,如下所示突出显示:

    namespace Packt.Shared
    {
      public **partial** class Person
      { 
    
  2. PacktLibrary项目/文件夹中,添加一个名为PersonAutoGen.cs的新类文件。

  3. 向新文件添加语句,如下所示:

    namespace Packt.Shared
    {
      public partial class Person
      {
      }
    } 
    

本章剩余代码将在PersonAutoGen.cs文件中编写。

通过属性和索引器控制访问

之前,您创建了一个名为GetOrigin的方法,该方法返回一个包含人员姓名和来源的string。诸如 Java 之类的语言经常这样做。C#有更好的方法:属性。

属性本质上是一个方法(或一对方法),当您想要获取或设置值时,它表现得像字段一样,从而简化了语法。

定义只读属性

一个readonly属性仅具有get实现。

  1. PersonAutoGen.cs中,在Person类中,添加语句以定义三个属性:

    1. 第一个属性将使用适用于所有 C#版本的属性语法执行与GetOrigin方法相同的角色(尽管它使用了 C# 6 及更高版本中的字符串插值语法)。

    2. 第二个属性将使用 C# 6 及更高版本中的 lambda 表达式体=>语法返回一条问候消息。

    3. 第三个属性将计算该人的年龄。

    以下是代码:

    // a property defined using C# 1 - 5 syntax
    public string Origin
    {
      get
      {
        return $"{Name} was born on {HomePlanet}";
      }
    }
    // two properties defined using C# 6+ lambda expression body syntax
    public string Greeting => $"{Name} says 'Hello!'";
    public int Age => System.DateTime.Today.Year - DateOfBirth.Year; 
    

    良好实践:这不是计算某人年龄的最佳方法,但我们并非学习如何从出生日期计算年龄。若需正确执行此操作,请阅读以下链接中的讨论:stackoverflow.com/questions/9/how-do-i-calculate-someones-age-in-c

  2. Program.cs中,添加获取属性的语句,如下列代码所示:

    Person sam = new()
    {
      Name = "Sam",
      DateOfBirth = new(1972, 1, 27)
    };
    WriteLine(sam.Origin); 
    WriteLine(sam.Greeting); 
    WriteLine(sam.Age); 
    
  3. 运行代码并查看结果,如下列输出所示:

    Sam was born on Earth 
    Sam says 'Hello!'
    49 
    

输出显示 49,因为我在 2021 年 8 月 15 日运行了控制台应用程序,当时 Sam 49 岁。

定义可设置的属性

要创建一个可设置的属性,您必须使用较旧的语法并提供一对方法——不仅仅是get部分,还包括set部分:

  1. PersonAutoGen.cs中,添加语句以定义一个具有getset方法(也称为 getter 和 setter)的string属性,如下列代码所示:

    public string FavoriteIceCream { get; set; } // auto-syntax 
    

    尽管您没有手动创建一个字段来存储某人的最爱冰淇淋,但它确实存在,由编译器自动为您创建。

    有时,您需要更多控制权来决定属性设置时发生的情况。在这种情况下,您必须使用更详细的语法并手动创建一个private字段来存储该属性的值。

  2. PersonAutoGen.cs中,添加语句以定义一个string字段和一个具有getsetstring属性,如下列代码所示:

    private string favoritePrimaryColor;
    public string FavoritePrimaryColor
    {
      get
      {
        return favoritePrimaryColor;
      }
      set
      {
        switch (value.ToLower())
        {
          case "red":
          case "green":
          case "blue":
            favoritePrimaryColor = value;
            break;
          default:
            throw new System.ArgumentException(
              $"{value} is not a primary color. " + 
              "Choose from: red, green, blue.");
        }
      }
    } 
    

    最佳实践:避免在您的 getter 和 setter 中添加过多代码。这可能表明您的设计存在问题。考虑添加私有方法,然后在 setter 和 getter 中调用这些方法,以简化您的实现。

  3. Program.cs中,添加语句以设置 Sam 的最爱冰淇淋和颜色,然后将其写出,如下列代码所示:

    sam.FavoriteIceCream = "Chocolate Fudge";
    WriteLine($"Sam's favorite ice-cream flavor is {sam.FavoriteIceCream}."); 
    sam.FavoritePrimaryColor = "Red";
    WriteLine($"Sam's favorite primary color is {sam.FavoritePrimaryColor}."); 
    
  4. 运行代码并查看结果,如下列输出所示:

    Sam's favorite ice-cream flavor is Chocolate Fudge. 
    Sam's favorite primary color is Red. 
    

    如果您尝试将颜色设置为除红色、绿色或蓝色之外的任何值,则代码将抛出异常。调用代码随后可以使用try语句来显示错误消息。

    最佳实践:当您希望验证可以存储的值时,或者在希望进行 XAML 数据绑定时(我们将在第十九章使用.NET MAUI 构建移动和桌面应用中介绍),以及当您希望在不使用GetAgeSetAge这样的方法对的情况下读写字段时,请使用属性而不是字段。

要求在实例化时设置属性

C# 10 引入了required修饰符。如果您将其用于属性,编译器将确保在实例化时为该属性设置一个值,如下列代码所示:

public class Book
{
  public required string Isbn { get; set; }
  public string Title { get; set; }
} 

如果您尝试实例化一个Book而不设置Isbn属性,您将看到一个编译器错误,如下列代码所示:

Book novel = new(); 

required关键字可能不会出现在.NET 6 的最终发布版本中,因此请将本节视为理论性的。

定义索引器

索引器允许调用代码使用数组语法来访问属性。例如,string类型定义了一个索引器,以便调用代码可以访问string中的单个字符。

我们将定义一个索引器,以简化对某人子女的访问:

  1. PersonAutoGen.cs中,添加语句定义一个索引器,以使用孩子的索引获取和设置孩子,如下所示:

    // indexers
    public Person this[int index]
    {
      get
      {
        return Children[index]; // pass on to the List<T> indexer
      }
      set
      {
        Children[index] = value;
      }
    } 
    

    您可以重载索引器,以便不同的类型可以用于其参数。例如,除了传递一个int值外,您还可以传递一个string值。

  2. Program.cs中,添加语句向Sam添加两个孩子,然后使用较长的Children字段和较短的索引器语法访问第一个和第二个孩子,如下所示:

    sam.Children.Add(new() { Name = "Charlie" }); 
    sam.Children.Add(new() { Name = "Ella" });
    WriteLine($"Sam's first child is {sam.Children[0].Name}"); 
    WriteLine($"Sam's second child is {sam.Children[1].Name}");
    WriteLine($"Sam's first child is {sam[0].Name}"); 
    WriteLine($"Sam's second child is {sam[1].Name}"); 
    
  3. 运行代码并查看结果,如下所示:

    Sam's first child is Charlie 
    Sam's second child is Ella 
    Sam's first child is Charlie 
    Sam's second child is Ella 
    

对象的模式匹配

第三章控制流程、转换类型和处理异常中,您被介绍了基本的模式匹配。在本节中,我们将更详细地探讨模式匹配。

创建并引用.NET 6 类库

增强的模式匹配特性仅在支持 C# 9 或更高版本的现代.NET 类库中可用。

  1. 使用您偏好的编码工具,在名为Chapter05的工作区/解决方案中添加一个名为PacktLibraryModern的新类库。

  2. PeopleApp项目中,添加对PacktLibraryModern类库的引用,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <ItemGroup>
        <ProjectReference Include="../PacktLibrary/PacktLibrary.csproj" />
     **<ProjectReference** 
     **Include=****"../PacktLibraryModern/PacktLibraryModern.csproj"** **/>**
      </ItemGroup>
    </Project> 
    
  3. 构建PeopleApp项目。

定义飞行乘客

在本例中,我们将定义一些代表飞行中各种类型乘客的类,然后我们将使用带有模式匹配的 switch 表达式来确定他们的飞行费用。

  1. PacktLibraryModern项目/文件夹中,将文件Class1.cs重命名为FlightPatterns.cs

  2. FlightPatterns.cs中,添加语句定义三种具有不同属性的乘客类型,如下所示:

    namespace Packt.Shared; // C# 10 file-scoped namespace
    public class BusinessClassPassenger
    {
      public override string ToString()
      {
        return $"Business Class";
      }
    }
    public class FirstClassPassenger
    {
      public int AirMiles { get; set; }
      public override string ToString()
      {
        return $"First Class with {AirMiles:N0} air miles";
      }
    }
    public class CoachClassPassenger
    {
      public double CarryOnKG { get; set; }
      public override string ToString()
      {
        return $"Coach Class with {CarryOnKG:N2} KG carry on";
      }
    } 
    
  3. Program.cs中,添加语句定义一个包含五种不同类型和属性值的乘客对象数组,然后枚举它们,输出他们的飞行费用,如下所示:

    object[] passengers = {
      new FirstClassPassenger { AirMiles = 1_419 },
      new FirstClassPassenger { AirMiles = 16_562 },
      new BusinessClassPassenger(),
      new CoachClassPassenger { CarryOnKG = 25.7 },
      new CoachClassPassenger { CarryOnKG = 0 },
    };
    foreach (object passenger in passengers)
    {
      decimal flightCost = passenger switch
      {
        FirstClassPassenger p when p.AirMiles > 35000 => 1500M, 
        FirstClassPassenger p when p.AirMiles > 15000 => 1750M, 
        FirstClassPassenger _                         => 2000M,
        BusinessClassPassenger _                      => 1000M,
        CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M, 
        CoachClassPassenger _                         => 650M,
        _                                             => 800M
      };
      WriteLine($"Flight costs {flightCost:C} for {passenger}");
    } 
    

    在审查前面的代码时,请注意以下几点:

    • 要对对象的属性进行模式匹配,您必须命名一个局部变量,该变量随后可以在表达式中使用,如p

    • 仅对类型进行模式匹配时,可以使用_来丢弃局部变量。

    • switch 表达式也使用_来表示其默认分支。

  4. 运行代码并查看结果,如下所示:

    Flight costs £2,000.00 for First Class with 1,419 air miles 
    Flight costs £1,750.00 for First Class with 16,562 air miles 
    Flight costs £1,000.00 for Business Class
    Flight costs £650.00 for Coach Class with 25.70 KG carry on 
    Flight costs £500.00 for Coach Class with 0.00 KG carry on 
    

C# 9 或更高版本中模式匹配的增强

前面的示例使用的是 C# 8。现在我们将看看 C# 9 及更高版本的一些增强功能。首先,进行类型匹配时不再需要使用下划线来丢弃:

  1. Program.cs中,注释掉 C# 8 语法,添加 C# 9 及更高版本的语法,修改头等舱乘客的分支,使用嵌套的 switch 表达式和新的条件支持,如>,如下所示:

    decimal flightCost = passenger switch
    {
      /* C# 8 syntax
      FirstClassPassenger p when p.AirMiles > 35000 => 1500M,
      FirstClassPassenger p when p.AirMiles > 15000 => 1750M,
      FirstClassPassenger                           => 2000M, */
      // C# 9 or later syntax
      FirstClassPassenger p => p.AirMiles switch
      {
        > 35000 => 1500M,
        > 15000 => 1750M,
        _       => 2000M
      },
      BusinessClassPassenger                        => 1000M,
      CoachClassPassenger p when p.CarryOnKG < 10.0 => 500M,
      CoachClassPassenger                           => 650M,
      _                                             => 800M
    }; 
    
  2. 运行代码以查看结果,并注意它们与之前相同。

您还可以结合使用关系模式和属性模式来避免嵌套的 switch 表达式,如下面的代码所示:

FirstClassPassenger { AirMiles: > 35000 } => 1500,
FirstClassPassenger { AirMiles: > 15000 } => 1750M,
FirstClassPassenger => 2000M, 

处理记录

在我们深入了解 C# 9 及更高版本的新记录语言特性之前,让我们先看看一些其他相关的新特性。

Init-only 属性

您在本章中使用了对象初始化语法来实例化对象并设置初始属性。那些属性也可以在实例化后更改。

有时,您希望将属性视为只读字段,以便它们可以在实例化期间设置,但不能在此之后设置。新的init关键字使这成为可能。它可以用来替代set关键字:

  1. PacktLibraryModern项目/文件夹中,添加一个名为Records.cs的新文件。

  2. Records.cs中,定义一个不可变人员类,如下面的代码所示:

    namespace Packt.Shared; // C# 10 file-scoped namespace
    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. 编译控制台应用程序并注意编译错误,如下面的输出所示:

    Program.cs(254,7): 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属性的代码。

理解记录

Init-only 属性为 C#提供了一些不可变性。您可以通过使用记录将这一概念进一步推进。这些是通过使用record关键字而不是class关键字来定义的。这可以使整个对象不可变,并且在比较时它表现得像一个值。我们将在第六章实现接口和继承类中更详细地讨论类、记录和值类型的相等性和比较。

记录不应具有在实例化后更改的任何状态(属性和字段)。相反,想法是您从现有记录创建新记录,其中包含任何更改的状态。这称为非破坏性突变。为此,C# 9 引入了with关键字:

  1. Records.cs中,添加一个名为ImmutableVehicle的记录,如下面的代码所示:

    public record ImmutableVehicle
    {
      public int Wheels { get; init; }
      public string? Color { get; init; }
      public string? Brand { get; init; }
    } 
    
  2. Program.cs中,添加语句以创建一辆,然后创建其变异副本,如下面的代码所示:

    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. 运行代码以查看结果,并注意变异副本中汽车颜色的变化,如下面的输出所示:

    Original car color was Soul Red Crystal Metallic.
    New car color is Polymetal Grey Metallic. 
    

记录中的位置数据成员

定义记录的语法可以通过使用位置数据成员大大简化。

简化记录中的数据成员

与其使用花括号的对象初始化语法,有时您可能更愿意提供带有位置参数的构造函数,正如您在本章前面所见。您还可以将此与析构函数结合使用,以将对象分解为各个部分,如下面的代码所示:

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 way to define a record
    // 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 Deconstruct method 
    WriteLine($"{who} is a {what}."); 
    
  3. 运行应用程序并查看结果,如下列输出所示:

    Oscar is a Labrador. 
    

当我们查看 C# 10 支持创建struct记录时,你将在第六章实现接口和继承类中再次看到记录。

实践与探索

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

练习 5.1 – 测试你的知识

回答以下问题:

  1. 访问修饰符关键字的六种组合是什么,它们各自的作用是什么?

  2. 当应用于类型成员时,staticconstreadonly关键字之间有何区别?

  3. 构造函数的作用是什么?

  4. 当你想要存储组合值时,为什么应该对enum类型应用[Flags]属性?

  5. 为什么partial关键字有用?

  6. 什么是元组?

  7. record关键字的作用是什么?

  8. 重载是什么意思?

  9. 字段和属性之间有什么区别?

  10. 如何使方法参数变为可选?

练习 5.2 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-5---building-your-own-types-with-object-oriented-programming

总结

在本章中,你学习了使用面向对象编程(OOP)创建自己的类型。你了解了类型可以拥有的不同类别的成员,包括用于存储数据的字段和执行操作的方法,并运用了 OOP 概念,如聚合和封装。你看到了如何使用现代 C#特性,如关系和属性模式匹配增强、仅初始化属性以及记录的示例。

在下一章中,你将通过定义委托和事件、实现接口以及继承现有类来进一步应用这些概念。

第六章:实现接口和继承类

本章是关于使用面向对象编程OOP)从现有类型派生新类型的。你将学习定义运算符和局部函数以执行简单操作,以及委托和事件以在类型之间交换消息。你将实现接口以实现通用功能。你将了解泛型以及引用类型和值类型之间的区别。你将创建一个派生类以从基类继承功能,覆盖继承的类型成员,并使用多态性。最后,你将学习如何创建扩展方法以及如何在继承层次结构中的类之间进行类型转换。

本章涵盖以下主题:

  • 设置类库和控制台应用程序

  • 更多关于方法的内容

  • 引发和处理事件

  • 使用泛型安全地重用类型

  • 实现接口

  • 使用引用和值类型管理内存

  • 处理空值

  • 从类继承

  • 在继承层次结构中进行类型转换

  • 继承和扩展.NET 类型

  • 使用分析器编写更好的代码

设置类库和控制台应用程序

我们将首先定义一个包含两个项目的工作区/解决方案,类似于在第五章使用面向对象编程构建自己的类型中创建的那个。即使你完成了该章的所有练习,也要按照下面的说明操作,因为我们将在类库中使用 C# 10 特性,因此它需要面向.NET 6.0 而不是.NET Standard 2.0:

  1. 使用你喜欢的编码工具创建一个名为Chapter06的新工作区/解决方案。

  2. 添加一个类库项目,如下列表定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:Chapter06

    3. 项目文件和文件夹:PacktLibrary

  3. 添加一个控制台应用程序项目,如下列表定义:

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

    2. 工作区/解决方案文件和文件夹:Chapter06

    3. 项目文件和文件夹:PeopleApp

  4. PacktLibrary项目中,将名为Class1.cs的文件重命名为Person.cs

  5. 修改Person.cs文件内容,如下所示:

    using static System.Console;
    namespace Packt.Shared;
    public class Person : object
    {
      // fields
      public string? Name;    // ? allows null
      public DateTime DateOfBirth;
      public List<Person> Children = new(); // C# 9 or later
      // methods
      public void WriteToConsole() 
      {
        WriteLine($"{Name} was born on a {DateOfBirth:dddd}.");
      }
    } 
    
  6. PeopleApp项目中,添加对PacktLibrary的项目引用,如以下标记中突出显示的那样:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
     **<ItemGroup>**
     **<ProjectReference**
     **Include=****"..\PacktLibrary\PacktLibrary.csproj"** **/>**
     **</ItemGroup>**
    </Project> 
    
  7. 构建PeopleApp项目并注意输出,表明两个项目都已成功构建。

更多关于方法的内容

我们可能希望两个Person实例能够繁殖。我们可以通过编写方法来实现这一点。实例方法是对象对自己执行的操作;静态方法是类型执行的操作。

选择哪种方式取决于哪种对行动最有意义。

最佳实践:同时拥有静态方法和实例方法来执行类似操作通常是有意义的。例如,string类型既有Compare静态方法,也有CompareTo实例方法。这使得使用你的类型的程序员能够选择如何使用这些功能,为他们提供了更多的灵活性。

通过方法实现功能

让我们先通过使用静态和实例方法来实现一些功能:

  1. Person类添加一个实例方法和一个静态方法,这将允许两个Person对象繁衍后代,如下面的代码所示:

    // static method to "multiply"
    public static Person Procreate(Person p1, Person p2)
    {
      Person baby = new()
      {
        Name = $"Baby of {p1.Name} and {p2.Name}"
      };
      p1.Children.Add(baby);
      p2.Children.Add(baby);
      return baby;
    }
    // instance method to "multiply"
    public Person ProcreateWith(Person partner)
    {
      return Procreate(this, partner);
    } 
    

    注意以下内容:

    • 在名为Procreatestatic方法中,要繁衍后代的Person对象作为参数p1p2传递。

    • 一个新的Person类名为baby,其名字由繁衍后代的两个人的名字组合而成。这可以通过设置返回的baby变量的Name属性来稍后更改。

    • baby对象被添加到两个父母的Children集合中,然后返回。类是引用类型,意味着在内存中存储的baby对象的引用被添加,而不是baby对象的克隆。你将在本章后面学习引用类型和值类型之间的区别。

    • 在名为ProcreateWith的实例方法中,要与之繁衍后代的Person对象作为参数partner传递,它与this一起被传递给静态Procreate方法以重用方法实现。this是一个关键字,它引用当前类的实例。

    最佳实践:创建新对象或修改现有对象的方法应返回对该对象的引用,以便调用者可以访问结果。

  2. PeopleApp项目中,在Program.cs文件的顶部,删除注释并导入我们的Person类和静态导入Console类型,如下面的代码所示:

    using Packt.Shared;
    using static System.Console; 
    
  3. Program.cs中,创建三个人并让他们相互繁衍后代,注意要在string中添加双引号字符,你必须在其前面加上反斜杠字符,如下所示,\",如下面的代码所示:

    Person harry = new() { Name = "Harry" }; 
    Person mary = new() { Name = "Mary" }; 
    Person jill = new() { Name = "Jill" };
    // call instance method
    Person baby1 = mary.ProcreateWith(harry); 
    baby1.Name = "Gary";
    // call static method
    Person baby2 = Person.Procreate(harry, jill);
    WriteLine($"{harry.Name} has {harry.Children.Count} children."); 
    WriteLine($"{mary.Name} has {mary.Children.Count} children."); 
    WriteLine($"{jill.Name} has {jill.Children.Count} children."); 
    WriteLine(
      format: "{0}'s first child is named \"{1}\".",
      arg0: harry.Name,
      arg1: harry.Children[0].Name); 
    
  4. 运行代码并查看结果,如下面的输出所示:

    Harry has 2 children. 
    Mary has 1 children. 
    Jill has 1 children.
    Harry's first child is named "Gary". 
    

通过运算符实现功能

System.String类有一个名为Concatstatic方法,它将两个字符串值连接起来并返回结果,如下面的代码所示:

string s1 = "Hello "; 
string s2 = "World!";
string s3 = string.Concat(s1, s2); 
WriteLine(s3); // Hello World! 

调用像Concat这样的方法是可以的,但对程序员来说,使用+符号运算符将两个string值“相加”可能更自然,如下面的代码所示:

string s3 = s1 + s2; 

一句广为人知的圣经格言是去繁衍后代,意指生育。让我们编写代码,使得*(乘法)符号能让两个Person对象繁衍后代。

我们通过为*符号定义一个static运算符来实现这一点。语法类似于方法,因为实际上,运算符就是一个方法,但使用符号代替方法名,使得语法更为简洁。

  1. Person.cs中,创建一个static运算符用于*符号,如下所示:

    // operator to "multiply"
    public static Person operator *(Person p1, Person p2)
    {
      return Person.Procreate(p1, p2);
    } 
    

    良好实践:与方法不同,运算符不会出现在类型的 IntelliSense 列表中。对于您定义的每个运算符,都应同时创建一个方法,因为程序员可能不清楚该运算符可用。运算符的实现可以调用该方法,重用您编写的代码。提供方法的第二个原因是并非所有语言编译器都支持运算符;例如,尽管 Visual Basic 和 F#支持诸如*之类的算术运算符,但没有要求其他语言支持 C#支持的所有运算符。

  2. Program.cs中,在调用Procreate方法和向控制台写入语句之前,使用*运算符再制造一个婴儿,如下所示:

    // call static method
    Person baby2 = Person.Procreate(harry, jill);
    **// call an operator**
    **Person baby3 = harry * mary;** 
    
  3. 运行代码并查看结果,如下所示:

    Harry has 3 children. 
    Mary has 2 children. 
    Jill has 1 children.
    Harry's first child is named "Gary". 
    

使用局部函数实现功能

C# 7.0 引入的一个语言特性是能够定义局部函数

局部函数相当于方法中的局部变量。换句话说,它们是仅在其定义的包含方法内部可访问的方法。在其他语言中,它们有时被称为嵌套内部函数

局部函数可以在方法内的任何位置定义:顶部、底部,甚至中间的某个位置!

我们将使用局部函数来实现阶乘计算:

  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 < 1) return 1;
        return localNumber * localFactorial(localNumber - 1);
      }
    } 
    
  2. Program.cs中,添加一条语句以调用Factorial函数并将返回值写入控制台,如下所示:

    WriteLine($"5! is {Person.Factorial(5)}"); 
    
  3. 运行代码并查看结果,如下所示:

    5! is 120 
    

引发和处理事件

方法通常被描述为对象可以执行的动作,无论是对自己还是对相关对象。例如,List<T>可以向自身添加项目或清除自身,而File可以在文件系统中创建或删除文件。

事件通常被描述为发生在对象上的动作。例如,在用户界面中,Button有一个Click事件,点击是发生在按钮上的事情,而FileSystemWatcher监听文件系统的更改通知并引发CreatedDeleted等事件,这些事件在目录或文件更改时触发。

另一种思考事件的方式是,它们提供了一种在两个对象之间交换消息的方法。

事件基于委托构建,因此让我们先了解一下委托是什么以及它们如何工作。

使用委托调用方法

你已经看到了调用或执行方法的最常见方式:使用 . 运算符通过其名称访问该方法。例如,Console.WriteLine 告诉 Console 类型访问其 WriteLine 方法。

调用或执行方法的另一种方式是使用委托。如果你使用过支持函数指针的语言,那么可以将委托视为类型安全的方法指针

换句话说,委托包含与委托具有相同签名的方法的内存地址,以便可以安全地使用正确的参数类型调用它。

例如,假设 Person 类中有一个方法,它必须接受一个 string 类型的唯一参数,并返回一个 int 类型,如下所示:

public int MethodIWantToCall(string input)
{
  return input.Length; // it doesn't matter what the method does
} 

我可以在名为 p1Person 实例上调用此方法,如下所示:

int answer = p1.MethodIWantToCall("Frog"); 

或者,我可以定义一个与签名匹配的委托来间接调用该方法。请注意,参数的名称不必匹配。只有参数类型和返回值必须匹配,如下所示:

delegate int DelegateWithMatchingSignature(string s); 

现在,我可以创建一个委托实例,将其指向该方法,最后,调用该委托(即调用该方法),如下所示:

// create a delegate instance that points to the method
DelegateWithMatchingSignature d = new(p1.MethodIWantToCall);
// call the delegate, which calls the method
int answer2 = d("Frog"); 

你可能会想,“这有什么意义?”嗯,它提供了灵活性。

例如,我们可以使用委托来创建一个方法队列,这些方法需要按顺序调用。在服务中排队执行操作以提供更好的可扩展性是很常见的。

另一个例子是允许多个操作并行执行。委托内置支持异步操作,这些操作在不同的线程上运行,并且可以提供更好的响应性。你将在第十二章使用多任务提高性能和可扩展性中学习如何做到这一点。

最重要的例子是,委托允许我们实现事件,以便在不需要相互了解的不同对象之间发送消息。事件是组件之间松散耦合的一个例子,因为组件不需要了解彼此,它们只需要知道事件签名。

委托和事件是 C# 中最令人困惑的两个特性,可能需要几次尝试才能理解,所以如果你感到迷茫,不要担心!

定义和处理委托

Microsoft 为事件提供了两个预定义的委托,其签名简单而灵活,如下所示:

public delegate void EventHandler(
  object? sender, EventArgs e);
public delegate void EventHandler<TEventArgs>(
  object? sender, TEventArgs e); 

最佳实践:当你想在自己的类型中定义一个事件时,你应该使用这两个预定义委托之一。

让我们来探索委托和事件:

  1. Person 类添加语句,并注意以下几点,如下所示:

    • 它定义了一个名为 ShoutEventHandler 委托字段。

    • 它定义了一个 int 字段来存储 AngerLevel

    • 它定义了一个名为 Poke 的方法。

    • 每次有人被戳时,他们的AngerLevel都会增加。一旦他们的AngerLevel达到三,他们就会引发Shout事件,但前提是至少有一个事件委托指向代码中其他地方定义的方法;也就是说,它不是null

    // delegate field
    public EventHandler? Shout;
    // data field
    public int AngerLevel;
    // method
    public void Poke()
    {
      AngerLevel++;
      if (AngerLevel >= 3)
      {
        // if something is listening...
        if (Shout != null)
        {
          // ...then call the delegate
          Shout(this, EventArgs.Empty);
        }
      }
    } 
    

    在调用其方法之前检查对象是否不为null是非常常见的。C# 6.0 及更高版本允许使用?符号在.运算符之前简化内联的null检查,如以下代码所示:

    Shout?.Invoke(this, EventArgs.Empty); 
    
  2. Program.cs底部,添加一个具有匹配签名的方法,该方法从sender参数获取Person对象的引用,并输出有关他们的信息,如以下代码所示:

    static void Harry_Shout(object? sender, EventArgs e)
    {
      if (sender is null) return;
      Person p = (Person)sender;
      WriteLine($"{p.Name} is this angry: {p.AngerLevel}.");
    } 
    

    微软对于处理事件的方法命名的约定是对象名 _ 事件名

  3. Program.cs中,添加一条语句,将方法分配给委托字段,如以下代码所示:

    harry.Shout = Harry_Shout; 
    
  4. 在将方法分配给Shout事件后,添加语句调用Poke方法四次,如以下突出显示的代码所示:

    harry.Shout = Harry_Shout;
    **harry.Poke();**
    **harry.Poke();**
    **harry.Poke();**
    **harry.Poke();** 
    
  5. 运行代码并查看结果,注意哈利在前两次被戳时什么也没说,只有在被戳至少三次后才足够生气以至于大喊,如以下输出所示:

    Harry is this angry: 3\. 
    Harry is this angry: 4. 
    

定义和处理事件

你现在看到了委托如何实现事件最重要的功能:定义一个方法签名,该签名可以由完全不同的代码块实现,然后调用该方法以及连接到委托字段的其他任何方法。

那么事件呢?它们可能比你想象的要简单。

在将方法分配给委托字段时,不应使用我们在前述示例中使用的简单赋值运算符。

委托是多播的,这意味着你可以将多个委托分配给单个委托字段。我们本可以使用+=运算符而不是=赋值,这样我们就可以向同一个委托字段添加更多方法。当委托被调用时,所有分配的方法都会被调用,尽管你无法控制它们被调用的顺序。

如果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关键字所做的(几乎)所有事情!如果你永远不会将一个以上的方法分配给委托字段,那么从技术上讲,你不需要“事件”,但仍然是一种良好的实践,表明你的意图,并期望委托字段被用作事件。

  3. 将方法赋值修改为使用+=,如下列代码所示:

    harry.Shout += Harry_Shout; 
    
  4. 运行代码并注意它具有与之前相同的行为。

通过泛型安全地重用类型

2005 年,随着 C# 2.0 和.NET Framework 2.0 的推出,微软引入了一项名为泛型的功能,它使你的类型能更安全地重用且更高效。它通过允许程序员传递类型作为参数来实现这一点,类似于你可以传递对象作为参数的方式。

使用非泛型类型

首先,让我们看一个使用非泛型类型的例子,以便你能理解泛型旨在解决的问题,例如弱类型参数和值,以及使用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"); 
    
  2. 添加语句定义一个值为2key,并使用它在哈希表中查找其值,如下列代码所示:

    int key = 2; // lookup the value that has 2 as its key
    WriteLine(format: "Key {0} has value: {1}",
      arg0: key,
      arg1: lookupObject[key]); 
    
  3. 添加语句使用harry对象查找其值,如下列代码所示:

    // lookup the value that has harry as its key
    WriteLine(format: "Key {0} has value: {1}",
      arg0: harry,
      arg1: lookupObject[harry]); 
    
  4. 运行代码并注意它按预期工作,如下列输出所示:

    Key 2 has value: Beta
    Key Packt.Shared.Person has value: Delta 
    

尽管代码能运行,但存在出错的可能性,因为实际上任何类型都可以用作键或值。如果其他开发人员使用了你的查找对象,并期望所有项都是特定类型,他们可能会将其强制转换为该类型,并因某些值可能为不同类型而引发异常。包含大量项的查找对象也会导致性能不佳。

良好实践:避免使用System.Collections命名空间中的类型。

使用泛型类型

System.Collections.Generic.Dictionary<TKey, TValue>可用于存储多个值,每个值都有一个唯一键,稍后可用于快速查找其值。键和值可以是任何对象,但你必须在首次实例化集合时告诉编译器键和值的类型。你通过在尖括号<>中指定泛型参数的类型来实现这一点,即TKeyTValue

良好实践:当泛型类型有一个可定义的类型时,应将其命名为T,例如List<T>,其中T是列表中存储的类型。当泛型类型有多个可定义的类型时,应使用T作为名称前缀,并取一个合理的名称,例如Dictionary<TKey, TValue>

这提供了灵活性,速度更快,且更容易避免错误,因为添加项时会进行类型检查。

让我们编写一些代码,使用泛型来解决问题:

  1. Program.cs中,创建泛型查找集合Dictionary<TKey, TValue>的实例,然后添加四个项目,如下面的代码所示:

    // 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. 运行代码并注意它按预期工作,如下面的输出所示:

    Key 3 has value: Gamma 
    

实现接口

接口是一种将不同类型连接起来以创建新事物的方式。将它们想象成乐高™积木顶部的凸起,使它们能够“粘合”在一起,或者是插头和插座的电气标准。

如果类型实现了接口,那么它就是在向.NET 的其余部分承诺它支持特定的功能。这就是为什么它们有时被描述为合同。

常见接口

以下是您的类型可能需要实现的一些常见接口:

接口 方法 描述
IComparable CompareTo(other) 这定义了一个比较方法,类型通过该方法实现对其实例的排序。
IComparer Compare(first, second) 这定义了一个比较方法,辅助类型通过该方法实现对主类型实例的排序。
IDisposable Dispose() 这定义了一个处置方法,以更有效地释放非托管资源,而不是等待终结器(有关详细信息,请参阅本章后面的释放非托管资源部分)。
IFormattable ToString(format, culture) 这定义了一个文化感知的方法,将对象的值格式化为字符串表示。
IFormatter Serialize(stream, object)``Deserialize(stream) 这定义了将对象转换为字节流以及从字节流转换回对象的方法,用于存储或传输。
IFormatProvider GetFormat(type) 这定义了一个根据语言和区域格式化输入的方法。

排序时比较对象

您最常想要实现的接口之一是IComparable。它有一个名为CompareTo的方法。它有两种变体,一种适用于可空object类型,另一种适用于可空泛型类型T,如下面的代码所示:

namespace System
{
  public interface IComparable
  {
    int CompareTo(object? obj);
  }
  public interface IComparable<in T>
  {
    int CompareTo(T? other);
  }
} 

例如,string类型通过返回-1(如果string小于被比较的string)或1(如果它更大)来实现IComparableint类型通过返回-1(如果int小于被比较的int)或1(如果它更大)来实现IComparable

如果类型实现了IComparable接口之一,那么数组和集合就可以对其进行排序。

在我们为Person类实现IComparable接口及其CompareTo方法之前,让我们看看当我们尝试对Person实例数组进行排序时会发生什么:

  1. Program.cs中,添加语句以创建Person实例的数组,并将项目写入控制台,然后尝试对数组进行排序,并将项目再次写入控制台,如下面的代码所示:

    Person[] people =
    {
      new() { Name = "Simon" },
      new() { Name = "Jenny" },
      new() { Name = "Adam" },
      new() { Name = "Richard" }
    };
    WriteLine("Initial list of people:"); 
    foreach (Person p in people)
    {
      WriteLine($"  {p.Name}");
    }
    WriteLine("Use Person's IComparable implementation to sort:");
    Array.Sort(people);
    foreach (Person p in people)
    {
      WriteLine($"  {p.Name}");
    } 
    
  2. 运行代码,将会抛出异常。正如消息所述,要解决问题,我们的类型必须实现IComparable,如下面的输出所示:

    Unhandled Exception: System.InvalidOperationException: Failed to compare two elements in the array. ---> System.ArgumentException: At least one object must implement IComparable. 
    
  3. Person.cs中,在继承自object之后,添加一个逗号并输入IComparable<Person>,如下面的代码所示:

    public class Person : object, IComparable<Person> 
    

    你的代码编辑器会在新代码下方画一条红色波浪线,警告你尚未实现承诺的方法。点击灯泡并选择实现接口选项,你的代码编辑器可以为你编写骨架实现。

  4. 向下滚动至Person类的底部,找到为你编写的方法,并删除抛出NotImplementedException错误的语句,如以下代码中突出显示的部分所示:

    public int CompareTo(Person? other)
    {
    **throw****new** **NotImplementedException();**
    } 
    
  5. 添加一条语句以调用Name字段的CompareTo方法,该方法使用string类型的CompareTo实现并返回结果,如下面的代码中突出显示的部分所示:

    public int CompareTo(Person? other)
    {
      if (Name is null) return 0;
    **return** **Name.CompareTo(other?.Name);** 
    } 
    

    我们选择通过比较Person实例的Name字段来比较两个Person实例。因此,Person实例将按其名称的字母顺序排序。为简单起见,我没有在这些示例中添加null检查。

  6. 运行代码,并注意这次它按预期工作,如下面的输出所示:

    Initial list of people:
      Simon
      Jenny
      Adam
      Richard
    Use Person's IComparable implementation to sort:
      Adam
      Jenny
      Richard
      Simon 
    

最佳实践:如果有人想要对类型的数组或集合进行排序,那么请实现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)
      {
        if (x is null || y is null)
        {
          return 0;
        }
        // Compare the Name lengths...
        int result = x.Name.Length.CompareTo(y.Name.Length);
        // ...if they are equal...
        if (result == 0)
        {
          // ...then compare by the Names...
          return x.Name.CompareTo(y.Name);
        }
        else // result will be -1 or 1
        {
          // ...otherwise compare by the lengths.
          return result; 
        }
      }
    } 
    
  2. Program.cs中,添加语句以使用此替代实现对数组进行排序,如下面的代码所示:

    WriteLine("Use PersonComparer's IComparer implementation to sort:"); 
    Array.Sort(people, new PersonComparer());
    foreach (Person p in people)
    {
      WriteLine($"  {p.Name}");
    } 
    
  3. 运行代码并查看结果,如下面的输出所示:

    Use PersonComparer's IComparer implementation to sort:
      Adam
      Jenny
      Simon
      Richard 
    

这次,当我们对people数组进行排序时,我们明确要求排序算法使用PersonComparer类型,以便人们按名字最短的先排序,如 Adam,名字最长的后排序,如 Richard;当两个或多个名字长度相等时,按字母顺序排序,如 Jenny 和 Simon。

隐式与显式接口实现

接口可以隐式和显式实现。隐式实现更简单、更常见。只有当类型必须具有具有相同名称和签名的多个方法时,才需要显式实现。

例如,IGamePlayerIKeyHolder可能都有一个名为Lose的方法,参数相同,因为游戏和钥匙都可能丢失。在必须实现这两个接口的类型中,只能有一个Lose方法作为隐式方法。如果两个接口可以共享相同的实现,那很好,但如果不能,则另一个Lose方法必须以不同的方式实现并显式调用,如下所示:

public interface IGamePlayer
{
  void Lose();
}
public interface IKeyHolder
{
  void Lose();
}
public class Person : IGamePlayer, IKeyHolder
{
  public void Lose() // implicit implementation
  {
    // implement losing a key
  }
  void IGamePlayer.Lose() // explicit implementation
  {
    // implement losing a game
  }
}
// calling implicit and explicit implementations of Lose
Person p = new();
p.Lose(); // calls implicit implementation of losing a key
((IGamePlayer)p).Lose(); // calls explicit implementation of losing a game
IGamePlayer player = p as IGamePlayer;
player.Lose(); // calls explicit implementation of losing a game 

定义具有默认实现的接口

C# 8.0 引入的一项语言特性是接口的默认实现。让我们看看它的实际应用:

  1. PacktLibrary项目中,添加一个名为IPlayable.cs的新文件。

  2. 修改语句以定义一个具有两个方法PlayPause的公共IPlayable接口,如下所示:

    namespace Packt.Shared;
    public interface IPlayable
    {
      void Play();
      void Pause();
    } 
    
  3. PacktLibrary项目中,添加一个名为DvdPlayer.cs的新类文件。

  4. 修改文件中的语句以实现IPlayable接口,如下所示:

    using static System.Console;
    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.0 之前,一旦至少有一个类型实现了原始接口,这是不可能的。接口的主要特点之一是它是一个固定的契约。

    C# 8.0 允许接口在发布后添加新成员,只要它们具有默认实现。C#纯粹主义者可能不喜欢这个想法,但由于实用原因,例如避免破坏性更改或不得不定义一个全新的接口,它是有用的,其他语言如 Java 和 Swift 也启用了类似的技术。

    默认接口实现的支持需要对底层平台进行一些根本性的改变,因此只有在目标框架是.NET 5.0 或更高版本、.NET Core 3.0 或更高版本或.NET Standard 2.1 时,它们才受 C#支持。因此,它们不受.NET Framework 的支持。

  5. 修改IPlayable接口以添加具有默认实现的Stop方法,如下所示突出显示:

    **using****static** **System.Console;**
    namespace Packt.Shared;
    public interface IPlayable
    {
      void Play();
      void Pause();
    **void****Stop****()** **// default interface implementation**
     **{**
     **WriteLine(****"Default implementation of Stop."****);**
     **}**
    } 
    
  6. 构建PeopleApp项目并注意,尽管DvdPlayer类没有实现Stop,但项目仍能成功编译。将来,我们可以通过在DvdPlayer类中实现它来覆盖Stop的默认实现。

使用引用类型和值类型管理内存

我已经多次提到引用类型。让我们更详细地了解一下它们。

内存分为两类:内存和内存。在现代操作系统中,栈和堆可以在物理或虚拟内存的任何位置。

栈内存处理速度更快(因为它直接由 CPU 管理,并且采用后进先出机制,更有可能将数据保存在其 L1 或 L2 缓存中),但大小有限;而堆内存较慢,但资源丰富得多。

例如,在 macOS 终端中,我可以输入命令ulimit -a来发现栈大小被限制为 8192 KB,而其他内存则是“无限制”的。这种有限的栈内存量使得很容易填满它并导致“栈溢出”。

定义引用类型和值类型

定义对象类型时,可以使用三个 C#关键字:classrecordstruct。它们都可以拥有相同的成员,如字段和方法。它们之间的一个区别在于内存分配方式。

当你使用recordclass定义类型时,你定义的是引用类型。这意味着对象本身的内存是在堆上分配的,而只有对象的内存地址(以及少量开销)存储在栈上。

当你使用record structstruct定义类型时,你定义的是值类型。这意味着对象本身的内存是在栈上分配的。

如果struct使用的字段类型不是struct类型,那么这些字段将存储在堆上,这意味着该对象的数据同时存储在栈和堆上!

以下是最常见的结构体类型:

  • 数字 System 类型bytesbyteshortushortintuintlongulongfloatdoubledecimal

  • 其他 System 类型charDateTimebool

  • System.Drawing 类型ColorPointRectangle

几乎所有其他类型都是class类型,包括string

除了类型数据在内存中存储位置的差异外,另一个主要区别是struct不支持继承。

引用类型和值类型在内存中的存储方式

想象一下,你有一个控制台应用程序,它声明了一些变量,如下面的代码所示:

int number1 = 49;
long number2 = 12;
System.Drawing.Point location = new(x: 4, y: 5);
Person kevin = new() { Name = "Kevin", 
  DateOfBirth = new(year: 1988, month: 9, day: 23) };
Person sally; 

让我们回顾一下执行这些语句时栈和堆上分配的内存,如图 6.1所示,并按以下列表描述:

  • number1变量是值类型(也称为struct),因此它在栈上分配,由于它是 32 位整数,所以占用 4 字节内存。其值 49 直接存储在变量中。

  • number2变量也是值类型,因此它也在栈上分配,由于它是 64 位整数,所以占用 8 字节。

  • location变量也是值类型,因此它在栈上分配,由于它由两个 32 位整数xy组成,所以占用 8 字节。

  • kevin变量是引用类型(也称为class),因此在栈上分配了 64 位内存地址所需的 8 字节(假设是 64 位操作系统),并在堆上分配了足够字节来存储Person实例。

  • sally变量是引用类型,因此在 64 位内存地址的栈上分配了 8 字节。目前它为null,意味着堆上尚未为其分配内存。

图 6.1:值类型和引用类型在栈和堆上的分配方式

引用类型的所有已分配内存都存储在堆上。如果值类型如DateTime被用作引用类型如Person的字段,那么DateTime值将存储在堆上。

如果值类型有一个引用类型的字段,那么该部分值类型将存储在堆上。Point是一个值类型,由两个字段组成,这两个字段本身也是值类型,因此整个对象可以在栈上分配。如果Point值类型有一个引用类型的字段,如string,那么string字节将存储在堆上。

类型相等性

通常使用==!=运算符比较两个变量。这两个运算符对于引用类型和值类型的行为是不同的。

当你检查两个值类型变量的相等性时,.NET 会直接比较这两个变量在栈上的值,如果它们相等,则返回true,如下列代码所示:

int a = 3;
int b = 3;
WriteLine($"a == b: {(a == b)}"); // true 

当你检查两个引用类型变量的相等性时,.NET 会比较这两个变量的内存地址,如果它们相等,则返回true,如下列代码所示:

Person a = new() { Name = "Kevin" };
Person b = new() { Name = "Kevin" };
WriteLine($"a == b: {(a == b)}"); // false 

这是因为它们并非同一对象。如果两个变量确实指向堆上的同一对象,那么它们将被视为相等,如下列代码所示:

Person a = new() { Name = "Kevin" };
Person b = a;
WriteLine($"a == b: {(a == b)}"); // true 

此行为的一个例外是string类型。它虽是引用类型,但其相等运算符已被重载,使其表现得如同值类型一般,如下列代码所示:

string a = "Kevin";
string b = "Kevin";
WriteLine($"a == b: {(a == b)}"); // true 

你可以对你的类进行类似操作,使相等运算符即使在它们不是同一对象(即堆上同一内存地址)时也返回true,只要它们的字段具有相同值即可,但这超出了本书的范围。或者,使用record class,因为它们的一个好处是为你实现了这种行为。

定义结构类型

让我们来探讨如何定义自己的值类型:

  1. PacktLibrary项目中,添加一个名为DisplacementVector.cs的文件。

  2. 按照下列代码所示修改文件,并注意以下事项:

    • 该类型使用struct声明而非class

    • 它有两个名为XYint字段。

    • 它有一个构造函数,用于设置XY的初始值。

    • 它有一个运算符,用于将两个实例相加,返回一个新实例,其中XX相加,YY相加。

    namespace Packt.Shared;
    public struct DisplacementVector
    {
      public int X;
      public int Y;
      public DisplacementVector(int initialX, int initialY)
      {
        X = initialX;
        Y = initialY;
      }
      public static DisplacementVector operator +(
        DisplacementVector vector1,
        DisplacementVector vector2)
      {
        return new(
          vector1.X + vector2.X,
          vector1.Y + vector2.Y);
      }
    } 
    
  3. Program.cs文件中,添加语句以创建两个新的DisplacementVector实例,将它们相加,并输出结果,如下列代码所示:

    DisplacementVector dv1 = new(3, 5); 
    DisplacementVector dv2 = new(-2, 7); 
    DisplacementVector dv3 = dv1 + dv2;
    WriteLine($"({dv1.X}, {dv1.Y}) + ({dv2.X}, {dv2.Y}) = ({dv3.X}, {dv3.Y})"); 
    
  4. 运行代码并查看结果,如下列输出所示:

    (3, 5) + (-2, 7) = (1, 12) 
    

最佳实践:如果类型中所有字段占用的总字节数不超过 16 字节,且仅使用值类型作为字段,并且你永远不希望从该类型派生,那么微软建议使用struct。如果你的类型使用的堆栈内存超过 16 字节,使用引用类型作为字段,或者可能希望继承它,那么应使用class

处理记录结构类型

C# 10 引入了使用record关键字与struct类型以及class类型一起使用的能力。

我们可以定义DisplacementVector类型,如下列代码所示:

public record struct DisplacementVector(int X, int Y); 

即使class关键字可选,微软仍建议在定义record class时明确指定class,如下列代码所示:

public record class ImmutableAnimal(string Name); 

释放非托管资源

在前一章中,我们了解到构造器可用于初始化字段,且一个类型可以有多个构造器。设想一个构造器分配了一个非托管资源,即不由.NET 控制的任何资源,如操作系统控制下的文件或互斥体。由于.NET 无法使用其自动垃圾回收功能为我们释放这些资源,我们必须手动释放非托管资源。

垃圾回收是一个高级话题,因此对于这个话题,我将展示一些代码示例,但你无需亲自编写代码。

每种类型都可以有一个单一的终结器,当资源需要被释放时,.NET 运行时会调用它。终结器的名称与构造器相同,即类型名称,但前面加了一个波浪线~

不要将终结器(也称为析构器)与Deconstruct方法混淆。析构器释放资源,即它在内存中销毁一个对象。Deconstruct方法将对象分解为其组成部分,并使用 C#解构语法,例如在处理元组时:

public class Animal
{
  public Animal() // constructor
  {
    // allocate any unmanaged resources
  }
  ~Animal() // Finalizer aka destructor
  {
    // deallocate any unmanaged resources
  }
} 

前面的代码示例是在处理非托管资源时你应做的最低限度。但仅提供终结器的问题在于,.NET 垃圾回收器需要两次垃圾回收才能完全释放该类型分配的资源。

虽然可选,但建议提供一个方法,让使用你类型的开发者能明确释放资源,以便垃圾回收器可以立即且确定性地释放非托管资源(如文件)的托管部分,并在一次垃圾回收中释放对象的托管内存部分,而不是经过两次垃圾回收。

通过实现IDisposable接口,有一个标准机制可以做到这一点,如下例所示:

public class Animal : IDisposable
{
  public Animal()
  {
    // allocate unmanaged resource
  }
  ~Animal() // Finalizer
  {
    Dispose(false);
  }
  bool disposed = false; // have resources been released?
  public void Dispose()
  {
    Dispose(true);
    // tell garbage collector it does not need to call the finalizer
    GC.SuppressFinalize(this); 
  }
  protected virtual void Dispose(bool disposing)
  {
    if (disposed) return;
    // deallocate the *unmanaged* resource
    // ...
    if (disposing)
    {
      // deallocate any other *managed* resources
      // ...
    }
    disposed = true;
  }
} 

存在两个Dispose方法,一个public,一个protected

  • public void Dispose方法将由使用你类型的开发者调用。当被调用时,无论是非托管资源还是托管资源都需要被释放。

  • protected virtual void Dispose方法带有一个bool参数,内部用于实现资源的释放。它需要检查disposing参数和disposed字段,因为如果终结器线程已经运行并调用了~Animal方法,那么只需要释放非托管资源。

调用GC.SuppressFinalize(this)是为了通知垃圾收集器不再需要运行终结器,从而消除了进行第二次垃圾收集的需求。

确保 Dispose 方法被调用

当有人使用实现了IDisposable的类型时,他们可以使用using语句确保调用公共Dispose方法,如下列代码所示:

using (Animal a = new())
{
  // code that uses the Animal instance
} 

编译器将你的代码转换成类似下面的形式,这保证了即使发生异常,Dispose方法仍然会被调用:

Animal a = new(); 
try
{
  // code that uses the Animal instance
}
finally
{
  if (a != null) a.Dispose();
} 

你将在第九章文件、流和序列化操作中看到使用IDisposableusing语句以及try...finally块释放非托管资源的实际示例。

处理 null 值

你已经知道如何在struct变量中存储像数字这样的基本值。但如果一个变量还没有值呢?我们该如何表示这种情况?C#中有一个null值的概念,可以用来表示变量尚未被赋值。

使值类型可空

默认情况下,像intDateTime这样的值类型必须始终有值,因此得名。有时,例如在读取数据库中允许空、缺失或null值存储的值时,允许值类型为null会很方便。我们称这种类型为可空值类型

你可以通过在声明变量时在类型后添加问号后缀来启用此功能。

让我们来看一个例子:

  1. 使用你偏好的编程工具,在Chapter06工作区/解决方案中添加一个名为NullHandling控制台应用程序。本节需要一个完整的应用程序,包含项目文件,因此你无法使用.NET Interactive 笔记本。

  2. 在 Visual Studio Code 中,选择NullHandling作为活动的 OmniSharp 项目。在 Visual Studio 中,将NullHandling设置为启动项目。

  3. Program.cs中,输入声明并赋值的语句,包括null,给int变量,如下列代码所示:

    int thisCannotBeNull  = 4; 
    thisCannotBeNull = null; // compile error!
    int? thisCouldBeNull = null; 
    WriteLine(thisCouldBeNull); 
    WriteLine(thisCouldBeNull.GetValueOrDefault());
    thisCouldBeNull = 7; 
    WriteLine(thisCouldBeNull); 
    WriteLine(thisCouldBeNull.GetValueOrDefault()); 
    
  4. 注释掉导致编译错误的语句。

  5. 运行代码并查看结果,如下列输出所示:

    0
    7
    7 
    

第一行是空白的,因为它输出了null值!

理解可空引用类型

在众多语言中,null值的使用非常普遍,以至于许多经验丰富的程序员从未质疑过其存在的必要性。但在许多情况下,如果我们不允许变量具有null值,就能编写出更优、更简洁的代码。

C# 8 中最显著的语言变化是引入了可空和不可空的引用类型。“但是等等!”你可能会想,“引用类型不是已经可空了吗!”

您说得没错,但在 C# 8 及更高版本中,引用类型可以通过设置文件级或项目级选项来配置,不再允许null值,从而启用这一有用的新特性。由于这对 C#来说是一个重大变化,微软决定让该功能为可选。

由于成千上万的现有库包和应用程序期望旧的行为,这项新的 C#语言特性需要多年时间才能产生影响。即使是微软,也直到.NET 6 才在所有主要的.NET 包中完全实现这一新特性。

在过渡期间,您可以为您的项目选择几种方法之一:

  • 默认:无需更改。不支持不可空的引用类型。

  • 项目级选择加入,文件级选择退出:在项目级别启用该功能,并为需要与旧行为保持兼容的任何文件选择退出。这是微软在更新其自己的包以使用此新功能时内部采用的方法。

  • 文件级选择加入:仅对个别文件启用该功能。

启用可空和不可空的引用类型

要在项目级别启用该功能,请在项目文件中添加以下内容:

<PropertyGroup>
  ...
  <Nullable>enable</Nullable>
</PropertyGroup> 

这在面向.NET 6.0 的项目模板中现已默认完成。

要在文件级别禁用该功能,请在代码文件顶部添加以下内容:

#nullable disable 

要在文件级别启用该功能,请在代码文件顶部添加以下内容:

#nullable enable 

声明不可为空的变量和参数

如果您启用了可空引用类型,并且希望引用类型被赋予null值,那么您将不得不使用与使值类型可空相同的语法,即在类型声明后添加一个?符号。

那么,可空引用类型是如何工作的呢?让我们看一个例子。当存储地址信息时,您可能希望强制为街道、城市和地区提供值,但建筑可以留空,即null

  1. NullHandling.csproj中,在Program.cs文件底部,添加声明一个具有四个字段的Address类的语句,如下所示:

    class Address
    {
      public string? Building; 
      public string Street; 
      public string City; 
      public string Region;
    } 
    
  2. 几秒钟后,注意关于不可为空的字段的警告,例如Street未初始化,如图 6.2所示:Graphical user interface, text, application, chat or text message  Description automatically generated

    图 6.2:PROBLEMS 窗口中关于不可为空的字段的警告信息

  3. 将空string值分配给三个不可为空的字段中的每一个,如下所示:

    public string Street = string.Empty; 
    public string City = string.Empty; 
    public string Region = string.Empty; 
    
  4. Program.cs中,在文件顶部,静态导入Console,然后添加语句来实例化一个Address并设置其属性,如下所示:

    Address address = new(); 
    address.Building = null; 
    address.Street = null; 
    address.City = "London"; 
    address.Region = null; 
    
  5. 注意警告,如图 6.3所示:图形用户界面,文本,应用程序,聊天或短信,电子邮件 自动生成描述

    图 6.3:关于将 null 分配给不可空字段的警告消息

因此,这就是为什么新语言特性被命名为可空引用类型。从 C# 8.0 开始,未修饰的引用类型可以变为不可空,并且用于使引用类型可空的语法与用于值类型的语法相同。

检查是否为空

检查可空引用类型或可空值类型变量当前是否包含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; // could throw exception
  ...
} 

C# 7 引入了is!(非)运算符的组合作为!=的替代方案,如下所示:

if (!(thisCouldBeNull is null))
{ 

C# 9 引入了is not作为更清晰的替代方案,如下所示:

if (thisCouldBeNull is not null)
{ 

如果您尝试使用可能为null的变量的成员,请使用空条件运算符?.,如下所示:

string authorName = null;
// the following throws a NullReferenceException
int x = authorName.Length;
// instead of throwing an exception, null is assigned to y
int? y = authorName?.Length; 

有时您希望将变量分配给结果,或者如果变量为null,则使用备用值,例如3。您可以使用空合并运算符??执行此操作,如下所示:

// result will be 3 if authorName?.Length is null 
int result = authorName?.Length ?? 3; 
Console.WriteLine(result); 

良好实践:即使启用了可空引用类型,您仍应检查不可空参数是否为null并抛出ArgumentNullException

在方法参数中检查是否为空

在定义带有参数的方法时,检查null值是良好的实践。

在早期版本的 C#中,您需要编写if语句来检查null参数值,并对任何为null的参数抛出ArgumentNullException,如下所示:

public void Hire(Person manager, Person employee)
{
  if (manager == null)
  {
    throw new ArgumentNullException(nameof(manager));
  }
  if (employee == null)
  {
    throw new ArgumentNullException(nameof(employee));
  }
  ...
} 

C# 11 可能会引入一个新的!!后缀,为您执行此操作,如下所示:

public void Hire(Person manager!!, Person employee!!)
{
  ...
} 

if语句和抛出异常的操作已为您完成。

继承自类

我们之前创建的Person类型派生(继承)自object,即System.Object的别名。现在,我们将创建一个从Person继承的子类:

  1. PacktLibrary项目中,添加一个名为Employee.cs的新类文件。

  2. 修改其内容以定义一个名为Employee的类,该类派生自Person,如下所示:

    using System;
    namespace Packt.Shared;
    public class Employee : Person
    {
    } 
    
  3. Program.cs中,添加语句以创建Employee类的一个实例,如下所示:

    Employee john = new()
    {
      Name = "John Jones",
      DateOfBirth = new(year: 1990, month: 7, day: 28)
    };
    john.WriteToConsole(); 
    
  4. 运行代码并查看结果,如下所示:

    John Jones was born on a Saturday. 
    

请注意,Employee类继承了Person类的所有成员。

扩展类以添加功能

现在,我们将添加一些特定于员工的成员以扩展该类。

  1. Employee.cs中,添加语句以定义员工代码和雇佣日期这两个属性,如下所示:

    public string? EmployeeCode { get; set; } 
    public DateTime HireDate { get; set; } 
    
  2. Program.cs中,添加语句以设置 John 的员工代码和雇佣日期,如下列代码所示:

    john.EmployeeCode = "JJ001";
    john.HireDate = new(year: 2014, month: 11, day: 23); 
    WriteLine($"{john.Name} was hired on {john.HireDate:dd/MM/yy}"); 
    
  3. 运行代码并查看结果,如下列输出所示:

    John Jones was hired on 23/11/14 
    

隐藏成员

到目前为止,WriteToConsole方法是从Person继承的,它仅输出员工的姓名和出生日期。我们可能希望为员工改变此方法的功能:

  1. Employee.cs中,添加语句以重新定义WriteToConsole方法,如下列高亮代码所示:

    **using****static** **System.Console;** 
    namespace Packt.Shared;
    public class Employee : Person
    {
      public string? EmployeeCode { get; set; }
      public DateTime 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: DateOfBirth,**
     **arg2: HireDate);**
     **}**
    } 
    
  2. 运行代码并查看结果,如下列输出所示:

    John Jones was born on 28/07/90 and hired on 01/01/01 
    John Jones was hired on 23/11/14 
    

你的编码工具会警告你,你的方法现在通过在方法名下划波浪线来隐藏来自Person的方法,问题/错误列表窗口包含更多细节,编译器会在你构建并运行控制台应用程序时输出警告,如图 6.4所示:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 6.4:隐藏方法警告

正如警告所述,你可以通过将new关键字应用于该方法来隐藏此消息,以表明你是有意替换旧方法,如下列高亮代码所示:

public **new** void WriteToConsole() 

覆盖成员

与其隐藏一个方法,通常更好的做法是覆盖它。只有当基类选择允许覆盖时,你才能覆盖,这通过将virtual关键字应用于应允许覆盖的任何方法来实现。

来看一个例子:

  1. Program.cs中,添加一条语句,使用其string表示形式将john变量的值写入控制台,如下列代码所示:

    WriteLine(john.ToString()); 
    
  2. 运行代码并注意ToString方法是从System.Object继承的,因此实现返回命名空间和类型名称,如下列输出所示:

    Packt.Shared.Employee 
    
  3. Person.cs中,通过添加一个ToString方法来覆盖此行为,该方法输出人的姓名以及类型名称,如下列代码所示:

    // overridden methods
    public override string ToString()
    {
      return $"{Name} is a {base.ToString()}";
    } 
    

    base关键字允许子类访问其超类的成员;即它继承或派生自的基类

  4. 运行代码并查看结果。现在,当调用ToString方法时,它输出人的姓名,并返回基类ToString的实现,如下列输出所示:

     John Jones is a Packt.Shared.Employee 
    

最佳实践:许多现实世界的 API,例如微软的 Entity Framework Core、Castle 的 DynamicProxy 和 Episerver 的内容模型,要求你在类中定义的属性标记为virtual,以便它们可以被覆盖。仔细决定你的哪些方法和属性成员应标记为virtual

继承自抽象类

本章早些时候,你了解到接口可以定义一组成员,类型必须拥有这些成员才能达到基本的功能水平。这些接口非常有用,但主要局限在于,直到 C# 8 之前,它们无法提供任何自身的实现。

如果你仍然需要创建与.NET Framework 和其他不支持.NET Standard 2.1 的平台兼容的类库,这将是一个特定问题。

在那些早期平台中,你可以使用抽象类作为一种介于纯接口和完全实现类之间的半成品。

当一个类被标记为abstract时,这意味着它不能被实例化,因为你表明该类不完整。它需要更多的实现才能被实例化。

例如,System.IO.Stream类是抽象的,因为它实现了所有流都需要的一般功能,但并不完整,因此你不能使用new Stream()来实例化它。

让我们比较两种类型的接口和两种类型的类,如下代码所示:

public interface INoImplementation // C# 1.0 and later
{
  void Alpha(); // must be implemented by derived type
}
public interface ISomeImplementation // C# 8.0 and later
{
  void Alpha(); // must be implemented by derived type
  void Beta()
  {
    // default implementation; can be overridden
  }
}
public abstract class PartiallyImplemented // C# 1.0 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! 

防止继承和覆盖

通过在其定义中应用sealed关键字,你可以防止其他开发者继承你的类。没有人能继承史高治·麦克达克,如下代码所示:

public sealed class ScroogeMcDuck
{
} 

.NET 中sealed的一个例子是string类。微软在string类内部实现了一些极端优化,这些优化可能会因你的继承而受到负面影响,因此微软阻止了这种情况。

你可以通过在方法上应用sealed关键字来防止某人进一步覆盖你类中的virtual方法。没有人能改变 Lady Gaga 的唱歌方式,如下代码所示:

using static System.Console;
namespace Packt.Shared;
public class Singer
{
  // virtual allows this method to be overridden
  public virtual void Sing()
  {
    WriteLine("Singing...");
  }
}
public class LadyGaga : Singer
{
  // sealed 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 的新员工,将其存储在类型为Person的变量中,并调用两个变量的WriteToConsoleToString方法,如下代码所示:

    Employee aliceInEmployee = new()
      { Name = "Alice", EmployeeCode = "AA123" };
    Person aliceInPerson = aliceInEmployee; 
    aliceInEmployee.WriteToConsole(); 
    aliceInPerson.WriteToConsole(); 
    WriteLine(aliceInEmployee.ToString()); 
    WriteLine(aliceInPerson.ToString()); 
    
  3. 运行代码并查看结果,如下输出所示:

    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类,因此调用EmployeeToString实现。

成员修饰符及其效果总结在下表中:

变量类型 成员修饰符 执行的方法 所在类
Person WriteToConsole Person
Employee new WriteToConsole Employee
Person virtual ToString Employee
Employee override ToString Employee

在我看来,多态性对大多数程序员来说是学术性的。如果你理解了这个概念,那很酷;但如果不理解,我建议你不必担心。有些人喜欢通过说理解多态性对所有 C#程序员学习很重要来让别人感到自卑,但在我看来并非如此。

你可以通过 C#拥有成功的职业生涯,而不必解释多态性,正如赛车手无需解释燃油喷射背后的工程原理一样。

最佳实践:应尽可能使用virtualoverride而不是new来更改继承方法的实现。

继承层次结构内的强制转换

类型之间的强制转换与类型转换略有不同。强制转换是在相似类型之间进行的,例如 16 位整数和 32 位整数之间,或者超类及其子类之间。转换是在不同类型之间进行的,例如文本和数字之间。

隐式转换

在前面的示例中,你看到了如何将派生类型的实例存储在其基类型(或其基类型的基类型等)的变量中。当我们这样做时,称为隐式转换

显式转换

反向操作是显式转换,你必须在要转换的类型周围使用括号作为前缀来执行此操作:

  1. Program.cs中,添加一个语句,将aliceInPerson变量赋值给一个新的Employee变量,如下所示:

    Employee explicitAlice = aliceInPerson; 
    
  2. 你的编码工具会显示红色波浪线和编译错误,如图 6.5所示:图形用户界面,文本,应用程序,电子邮件,网站 自动生成描述

    图 6.5:缺少显式转换的编译错误

  3. 将语句更改为在赋值变量名前加上Employee类型的强制转换,如下所示:

    Employee explicitAlice = (Employee)aliceInPerson; 
    

避免强制转换异常

编译器现在满意了;但是,因为aliceInPerson可能是不同的派生类型,比如Student而不是Employee,我们需要小心。在更复杂的代码的实际应用程序中,此变量的当前值可能已被设置为Student实例,然后此语句将抛出InvalidCastException错误。

我们可以通过编写try语句来处理这种情况,但还有更好的方法。我们可以使用is关键字检查对象的类型:

  1. 将显式转换语句包裹在if语句中,如下所示突出显示:

    **if** **(aliceInPerson** **is** **Employee)**
    **{**
     **WriteLine(****$"****{****nameof****(aliceInPerson)}** **IS an Employee"****);** 
      Employee explicitAlice = (Employee)aliceInPerson;
    **// safely do something with explicitAlice**
    **}** 
    
  2. 运行代码并查看结果,如下所示:

    aliceInPerson IS an Employee 
    

    你可以通过使用声明模式进一步简化代码,这将避免需要执行显式转换,如下所示:

    if (aliceInPerson is Employee explicitAlice)  
    {
      WriteLine($"{nameof(aliceInPerson)} IS an Employee"); 
      // safely do something with explicitAlice
    } 
    

    或者,你可以使用as关键字进行转换。如果无法进行类型转换,as关键字不会抛出异常,而是返回null

  3. Main中,添加语句,使用as关键字转换 Alice,然后检查返回值是否不为空,如下所示:

    Employee? aliceAsEmployee = aliceInPerson as Employee; // could be null
    if (aliceAsEmployee != null)
    {
      WriteLine($"{nameof(aliceInPerson)} AS an Employee");
      // safely do something with aliceAsEmployee
    } 
    

    由于访问null变量的成员会抛出NullReferenceException错误,因此在使用结果之前应始终检查null

  4. 运行代码并查看结果,如下所示:

    aliceInPerson AS an Employee 
    

如果你想在 Alice 不是员工时执行一组语句,该怎么办?

在过去,你可能会使用!(非)运算符,如下所示:

if (!(aliceInPerson is Employee)) 

使用 C# 9 及更高版本,你可以使用not关键字,如下所示:

if (aliceInPerson is not 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中显式调用基类构造函数实现,以便让可能希望使用这些构造函数的程序员能够使用我们自定义的异常。

  3. Person.cs中,添加语句以定义一个方法,如果日期/时间参数早于某人的出生日期,则抛出异常,如下所示:

    public void TimeTravel(DateTime when)
    {
      if (when <= DateOfBirth)
      {
        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}!");
      }
    } 
    
  4. 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);
    } 
    
  5. 运行代码并查看结果,如下所示:

    Welcome to 1999!
    If you travel back in time to a date earlier than your own birth, then the universe will explode! 
    

最佳实践:在定义自己的异常时,应提供与内置异常相同的三个构造函数,并显式调用它们。

当你无法继承时扩展类型

之前,我们了解到sealed修饰符可用于防止继承。

微软已将sealed关键字应用于System.String类,以确保无人能继承并可能破坏字符串的行为。

我们还能给字符串添加新方法吗?可以,如果我们使用名为扩展方法的语言特性,该特性是在 C# 3.0 中引入的。

使用静态方法重用功能

自 C#的第一个版本以来,我们就能创建static方法来重用功能,例如验证string是否包含电子邮件地址的能力。其实现将使用正则表达式,你将在第八章使用常见的.NET 类型中了解更多相关内容。

让我们来编写一些代码:

  1. PacktLibrary项目中,添加一个名为StringExtensions的新类,如下列代码所示,并注意以下事项:

    • 该类导入了一个用于处理正则表达式的命名空间。

    • IsValidEmail方法是static的,它使用Regex类型来检查与一个简单的电子邮件模式匹配,该模式寻找@符号前后有效的字符。

    using System.Text.RegularExpressions;
    namespace Packt.Shared;
    public class StringExtensions
    {
      public static bool IsValidEmail(string input)
      {
        // use 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\.-_]+");
      }
    } 
    
  2. 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)); 
    
  3. 运行代码并查看结果,如下列输出所示:

    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方法的语法中微妙的简化。较旧、较长的语法仍然有效。

  3. IsValidEmail扩展方法现在看起来就像是string类型的所有实际实例方法一样,例如IsNormalizedInsert,如图 6.6所示:图形用户界面,文本,应用程序 自动生成的描述

    图 6.6:扩展方法在 IntelliSense 中与实例方法并列显示

  4. 运行代码并查看结果,其将与之前相同。

良好实践:扩展方法不能替换或覆盖现有实例方法。例如,你不能重新定义Insert方法。扩展方法会在 IntelliSense 中显示为重载,但具有相同名称和签名的实例方法会被优先调用。

尽管扩展方法可能看似没有带来巨大好处,但在第十一章使用 LINQ 查询和操作数据中,你将看到扩展方法的一些极其强大的用途。

使用分析器编写更优质的代码

.NET 分析器能发现潜在问题并提出修复建议。StyleCop是一个常用的分析器,帮助你编写更优质的 C#代码。

让我们看看实际操作,指导如何在面向.NET 5.0 的控制台应用项目模板中改进代码,以便控制台应用已具备一个包含Main方法的Program类:

  1. 使用您喜欢的代码编辑器添加一个控制台应用程序项目,如下表所定义:

    1. 项目模板:控制台应用程序 / console -f net5.0

    2. 工作区/解决方案文件和文件夹:Chapter06

    3. 项目文件和文件夹:CodeAnalyzing

    4. 目标框架:.NET 5.0(当前)

  2. CodeAnalyzing 项目中,添加对 StyleCop.Analyzers 包的引用。

  3. 向您的项目添加一个名为 stylecop.json 的 JSON 文件,以控制 StyleCop 设置。

  4. 修改其内容,如下面的标记所示:

    {
      "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
      "settings": {
      }
    } 
    

    $schema 条目在代码编辑器中编辑 stylecop.json 文件时启用 IntelliSense。

  5. 编辑项目文件,将目标框架更改为 net6.0,添加条目以配置名为 stylecop.json 的文件,使其不在发布的部署中包含,并在开发期间作为附加文件进行处理,如下面的标记中突出显示的那样:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
     **<ItemGroup>**
     **<None Remove=****"stylecop.json"** **/>**
     **</ItemGroup>**
     **<ItemGroup>**
     **<AdditionalFiles Include=****"stylecop.json"** **/>**
     **</ItemGroup>**
      <ItemGroup>
        <PackageReference Include="StyleCop.Analyzers" Version="1.2.0-*">
          <PrivateAssets>all</PrivateAssets>
          <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
        </PackageReference>
      </ItemGroup>
    </Project> 
    
  6. 构建您的项目。

  7. 您将看到它认为有问题的所有内容的警告,如图 6.7 所示:

    图 6.7:StyleCop 代码分析器警告

  8. 例如,它希望 using 指令放在命名空间声明内,如下面的输出所示:

    C:\Code\Chapter06\CodeAnalyzing\Program.cs(1,1): warning SA1200: Using directive should appear within a namespace declaration [C:\Code\Chapter06\CodeAnalyzing\CodeAnalyzing.csproj] 
    

抑制警告

要抑制警告,您有几种选择,包括添加代码和设置配置。

要抑制使用属性,如下面的代码所示:

[assembly:SuppressMessage("StyleCop.CSharp.OrderingRules", "SA1200:UsingDirectivesMustBePlacedWithinNamespace", Justification = "Reviewed.")] 

要抑制使用指令,如下面的代码所示:

#pragma warning disable SA1200 // UsingDirectivesMustBePlacedWithinNamespace
using System;
#pragma warning restore SA1200 // UsingDirectivesMustBePlacedWithinNamespace 

通过修改 stylecop.json 文件来抑制警告:

  1. stylecop.json 中,添加一个配置选项,将 using 语句设置为允许在命名空间外部使用,如下面的标记中突出显示的那样:

    {
      "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
      "settings": {
        "orderingRules": {
          "usingDirectivesPlacement": "outsideNamespace"
        }
      }
    } 
    
  2. 构建项目并注意警告 SA1200 已消失。

  3. stylecop.json 中,将 using 指令的位置设置为 preserve,允许 using 语句在命名空间内部和外部使用,如下面的标记所示:

    "orderingRules": {
      "usingDirectivesPlacement": "preserve"
    } 
    

修复代码

现在,让我们修复所有其他警告:

  1. CodeAnalyzing.csproj 中,添加一个元素以自动生成文档的 XML 文件,如下面的标记中突出显示的那样:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
     **<GenerateDocumentationFile>****true****</GenerateDocumentationFile>**
      </PropertyGroup> 
    
  2. stylecop.json 中,添加一个配置选项,为公司名称和版权文本的文档提供值,如下面的标记中突出显示的那样:

    {
      "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json",
      "settings": {
        "orderingRules": {
          "usingDirectivesPlacement": "preserve"
        },
    **"documentationRules"****: {**
    **"companyName"****:** **"Packt"****,**
    **"copyrightText"****:** **"Copyright (c) Packt. All rights reserved."**
     **}**
      }
    } 
    
  3. Program.cs 中,为文件头添加公司和版权文本的注释,将 using System; 声明移至命名空间内部,并为类和方法设置显式访问修饰符和 XML 注释,如下面的代码所示:

    // <copyright file="Program.cs" company="Packt">
    // Copyright (c) Packt. All rights reserved.
    // </copyright>
    namespace CodeAnalyzing
    {
      using System;
      /// <summary>
      /// The main class for this console app.
      /// </summary>
      public class Program
      {
        /// <summary>
        /// The main entry point for this console app.
        /// </summary>
        /// <param name="args">A string array of arguments passed to the console app.</param>
        public static void Main(string[] args)
        {
          Console.WriteLine("Hello World!");
        }
      }
    } 
    
  4. 构建项目。

  5. 展开 bin/Debug/net6.0 文件夹并注意名为 CodeAnalyzing.xml 的自动生成的文件,如下面的标记所示:

    <?xml version="1.0"?>
    <doc>
        <assembly>
            <name>CodeAnalyzing</name>
        </assembly>
        <members>
            <member name="T:CodeAnalyzing.Program">
                <summary>
                The main class for this console app.
                </summary>
            </member>
            <member name="M:CodeAnalyzing.Program.Main(System.String[])">
                <summary>
                The main entry point for this console app.
                </summary>
                <param name="args">A string array of arguments passed to the console app.</param>
            </member>
        </members>
    </doc> 
    

理解常见的 StyleCop 建议

在代码文件内部,应按以下列表所示顺序排列内容:

  1. 外部别名指令

  2. 使用指令

  3. 命名空间

  4. 委托

  5. 枚举

  6. 接口

  7. 结构体

在类、记录、结构或接口内部,应按以下列表所示顺序排列内容:

  1. 字段

  2. 构造函数

  3. 析构函数(终结器)

  4. 委托

  5. 事件

  6. 枚举

  7. 接口

  8. 属性

  9. 索引器

  10. 方法

  11. 结构体

  12. 嵌套类和记录

良好实践:你可以在以下链接了解所有 StyleCop 规则:github.com/DotNetAnalyzers/StyleCopAnalyzers/blob/master/DOCUMENTATION.md

实践与探索

通过回答一些问题来测试你的知识和理解。通过更深入的研究,获得一些实践经验并探索本章的主题。

练习 6.1 – 测试你的知识

回答以下问题:

  1. 什么是委托?

  2. 什么是事件?

  3. 基类和派生类是如何关联的,派生类如何访问基类?

  4. isas操作符之间有什么区别?

  5. 哪个关键字用于防止一个类被派生或一个方法被进一步重写?

  6. 哪个关键字用于防止一个类通过new关键字实例化?

  7. 哪个关键字用于允许成员被重写?

  8. 析构函数和解构方法之间有什么区别?

  9. 所有异常应具有的构造函数的签名是什么?

  10. 什么是扩展方法,如何定义一个?

练习 6.2 – 实践创建继承层次结构

通过以下步骤探索继承层次结构:

  1. 向你的Chapter06解决方案/工作区中添加一个名为Exercise02的新控制台应用程序。

  2. 创建一个名为Shape的类,其属性名为HeightWidthArea

  3. 添加三个从它派生的类——RectangleSquareCircle——根据你认为合适的任何额外成员,并正确地重写和实现Area属性。

  4. Main中,添加语句以创建每种形状的一个实例,如下列代码所示:

    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 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-6---implementing-interfaces-and-inheriting-classes

总结

在本章中,你学习了局部函数和操作符、委托和事件、实现接口、泛型以及使用继承和 OOP 派生类型。你还学习了基类和派生类,以及如何重写类型成员、使用多态性以及在类型之间进行转换。

在下一章中,你将学习.NET 是如何打包和部署的,以及在后续章节中,它为你提供的实现常见功能(如文件处理、数据库访问、加密和多任务处理)的类型。

第七章:打包和分发 .NET 类型

本章探讨 C# 关键字与 .NET 类型之间的关系,以及命名空间与程序集之间的关系。你还将熟悉如何打包和发布你的 .NET 应用和库以供跨平台使用,如何在 .NET 库中使用遗留的 .NET Framework 库,以及将遗留的 .NET Framework 代码库移植到现代 .NET 的可能性。

本章涵盖以下主题:

  • 通往 .NET 6 之路

  • 理解 .NET 组件

  • 发布应用程序以供部署

  • 反编译 .NET 程序集

  • 为 NuGet 分发打包你的库

  • 从 .NET Framework 迁移到现代 .NET

  • 使用预览功能

通往 .NET 6 之路

本书的这一部分关于 基类库 (BCL) API 提供的功能,以及如何使用 .NET Standard 在所有不同的 .NET 平台上重用功能。

首先,我们将回顾到达此点的路径,并理解过去为何重要。

.NET Core 2.0 及更高版本对 .NET Standard 2.0 的最小支持至关重要,因为它提供了 .NET Core 初版中缺失的许多 API。.NET Framework 开发者过去 15 年可用的、与现代开发相关的库和应用程序现已迁移至 .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 6 消除了对 .NET Standard 的需求,前提是所有项目都能使用 .NET 6。由于你可能仍需为遗留的 .NET Framework 项目或遗留的 Xamarin 移动应用创建类库,因此仍需创建 .NET Standard 2.0 和 2.1 类库。2021 年 3 月,我调查了专业开发者,其中一半仍需创建符合 .NET Standard 2.0 的类库。

随着 .NET 6 的发布,预览支持使用 .NET MAUI 构建的移动和桌面应用,对 .NET Standard 的需求进一步减少。

为了总结 .NET 在过去五年中的进展,我已将主要的 .NET Core 和现代 .NET 版本与相应的 .NET Framework 版本进行了比较,如下所示:

  • .NET Core 1.x:相较于 2016 年 3 月当时的当前版本 .NET Framework 4.6.1,API 规模小得多。

  • .NET Core 2.x:与 .NET Framework 4.7.1 实现了现代 API 的 API 对等,因为它们都实现了 .NET Standard 2.0。

  • .NET Core 3.x:相较于 .NET Framework,提供了更大的现代 API 集合,因为 .NET Framework 4.8 不实现 .NET Standard 2.1。

  • .NET 5:相较于 .NET Framework 4.8,提供了更大的现代 API 集合,性能显著提升。

  • .NET 6:最终统一,支持.NET MAUI 中的移动应用,预计于 2022 年 5 月实现。

.NET Core 1.0

.NET Core 1.0 于 2016 年 6 月发布,重点在于实现适合构建现代跨平台应用的 API,包括为 Linux 使用 ASP.NET Core 构建的 Web 和云应用及服务。

.NET Core 1.1

.NET Core 1.1 于 2016 年 11 月发布,主要关注于修复错误、增加支持的 Linux 发行版数量、支持.NET Standard 1.6,以及提升性能,特别是在使用 ASP.NET Core 构建的 Web 应用和服务方面。

.NET Core 2.0

.NET Core 2.0 于 2017 年 8 月发布,重点在于实现.NET Standard 2.0,能够引用.NET Framework 库,以及更多的性能改进。

(本书)第三版于 2017 年 11 月出版,涵盖至.NET Core 2.0 及用于通用 Windows 平台 (UWP) 应用的.NET Core。

.NET Core 2.1

.NET Core 2.1 于 2018 年 5 月发布,重点在于可扩展的工具系统,新增类型如Span<T>,加密和压缩的新 API,包含额外 20,000 个 API 的 Windows 兼容包以帮助移植旧 Windows 应用,Entity Framework Core 值转换,LINQ GroupBy 转换,数据播种,查询类型,以及更多的性能改进,包括下表中列出的主题:

特性 章节 主题
跨度 8 处理跨度、索引和范围
Brotli 压缩 9 使用 Brotli 算法进行压缩
加密学 20 加密学有哪些新内容?
EF Core 延迟加载 10 启用延迟加载
EF Core 数据播种 10 理解数据播种

.NET Core 2.2

.NET Core 2.2 于 2018 年 12 月发布,重点在于运行时诊断改进、可选的分层编译,以及为 ASP.NET Core 和 Entity Framework Core 添加新功能,如使用NetTopologySuite (NTS) 库类型的空间数据支持、查询标签和拥有的实体集合。

.NET Core 3.0

.NET Core 3.0 于 2019 年 9 月发布,重点在于增加对使用 Windows Forms (2001)、Windows Presentation Foundation (WPF; 2006) 和 Entity Framework 6.3 构建 Windows 桌面应用的支持,支持并行和应用本地部署,快速的 JSON 阅读器,串口访问和其他引脚访问,用于物联网 (IoT) 解决方案,以及默认的分层编译,包括下表中列出的主题:

特性 章节 主题
应用内嵌.NET 7 发布您的应用程序以供部署
IndexRange 8 处理跨度、索引和范围
System.Text.Json 9 高性能 JSON 处理
异步流 12 处理异步流

(本书)第四版于 2019 年 10 月出版,因此涵盖了后续版本中添加的一些新 API,直至.NET Core 3.0。

.NET Core 3.1

.NET Core 3.1 于 2019 年 12 月发布,专注于 bug 修复和优化,以便成为 长期支持 (LTS) 版本,直至 2022 年 12 月才停止支持。

.NET 5.0

.NET 5.0 于 2020 年 11 月发布,专注于统一除移动平台外的各种 .NET 平台,优化平台,并提升性能,包括下表所列主题:

特性 章节 主题
Half 类型 8 数值操作
正则表达式性能提升 8 正则表达式性能提升
System.Text.Json 性能改进 9 高效处理 JSON
EF Core 生成的 SQL 10 获取生成的 SQL
EF Core 筛选包含 10 筛选包含的实体
EF Core Scaffold-DbContext 现使用 Humanizer 进行单数化 10 基于现有数据库生成模型

.NET 6.0

.NET 6.0 于 2021 年 11 月发布,重点在于与移动平台统一,为 EF Core 的数据管理添加更多功能,并提升性能,包括下表所列主题:

特性 章节 主题
检查 .NET SDK 状态 7 检查 .NET SDK 更新
对 Apple Silicon 的支持 7 创建控制台应用程序发布
默认链接修剪模式 7 使用应用修剪减小应用大小
DateOnlyTimeOnly 8 指定日期和时间值
List<T>EnsureCapacity 8 通过确保集合容量提升性能
EF Core 配置约定 10 配置预约定模型
新增 LINQ 方法 11 使用 Enumerable 类构建 LINQ 表达式

从 .NET Core 2.0 到 .NET 5 的性能提升

微软在过去几年中对性能进行了重大改进。您可以在以下链接阅读详细博客文章:devblogs.microsoft.com/dotnet/performance-improvements-in-net-5/

检查 .NET SDK 更新

使用 .NET 6,微软添加了一个命令来检查已安装的 .NET SDK 和运行时版本,并在需要更新时发出警告。例如,您输入以下命令:

dotnet sdk check 

随后,您将看到包括可用更新状态在内的结果,如下所示的部分输出:

.NET SDKs:
Version                         Status
-----------------------------------------------------------------------------
3.1.412                         Up to date.
5.0.202                         Patch 5.0.206 is available.
... 

理解 .NET 组件

.NET 由多个部分组成,如下所示:

  • 语言编译器:这些编译器将使用 C#、F# 和 Visual Basic 等语言编写的源代码转换为 中间语言 (IL) 代码,存储在程序集中。使用 C# 6.0 及更高版本,微软转向了名为 Roslyn 的开源重写编译器,该编译器也用于 Visual Basic。

  • 公共语言运行时(CoreCLR):此运行时加载程序集,将存储在其中的 IL 代码编译为计算机 CPU 的本地代码指令,并在管理线程和内存等资源的环境中执行代码。

  • 基类库(BCL 或 CoreFX):这些是预构建的类型集合,通过 NuGet 打包和分发,用于在构建应用程序时执行常见任务。你可以使用它们快速构建任何你想要的东西,就像组合乐高™积木一样。.NET Core 2.0 实现了.NET 标准 2.0,它是所有先前版本的.NET 标准的超集,并将.NET Core 提升到与.NET Framework 和 Xamarin 平齐。.NET Core 3.0 实现了.NET 标准 2.1,增加了新的功能,并实现了在.NET Framework 中不可用的性能改进。.NET 6 在所有类型的应用程序中实现了一个统一的 BCL,包括移动应用。

理解程序集、NuGet 包和命名空间

程序集是类型在文件系统中存储的位置。程序集是一种部署代码的机制。例如,System.Data.dll程序集包含管理数据的类型。要使用其他程序集中的类型,必须引用它们。程序集可以是静态的(预先创建的)或动态的(在运行时生成的)。动态程序集是一个高级特性,本书中不会涉及。程序集可以编译成单个文件,作为 DLL(类库)或 EXE(控制台应用)。

程序集作为NuGet 包分发,这些是可以从公共在线源下载的文件,可以包含多个程序集和其他资源。你还会听到关于项目 SDK工作负载平台的说法,这些都是 NuGet 包的组合。

Microsoft 的 NuGet 源在这里:www.nuget.org/

什么是命名空间?

命名空间是类型的地址。命名空间是一种机制,通过要求完整的地址而不是简短的名称来唯一标识类型。在现实世界中,34 号梧桐街的鲍勃12 号柳树道的鲍勃是不同的。

在.NET 中,System.Web.Mvc命名空间中的IActionFilter接口与System.Web.Http.Filters命名空间中的IActionFilter接口不同。

理解依赖的程序集

如果一个程序集被编译为类库并提供类型供其他程序集使用,那么它具有文件扩展名.dll动态链接库),并且不能独立执行。

同样,如果一个程序集被编译为应用程序,那么它具有文件扩展名.exe可执行文件),并且可以独立执行。在.NET Core 3.0 之前,控制台应用被编译为.dll文件,必须通过dotnet run命令或宿主可执行文件来执行。

任何程序集都可以引用一个或多个类库程序集作为依赖项,但不能有循环引用。因此,如果程序集A已经引用程序集B,则程序集B不能引用程序集A。如果您尝试添加会导致循环引用的依赖项引用,编译器会警告您。循环引用通常是代码设计不良的警告信号。如果您确定需要循环引用,则使用接口来解决它。

理解 Microsoft .NET 项目 SDKs

默认情况下,控制台应用程序对 Microsoft .NET 项目 SDK 有依赖引用。该平台包含数千种类型,几乎所有应用程序都需要这些类型,例如System.Int32System.String类型。

在使用.NET 时,您在项目文件中引用应用程序所需的依赖程序集、NuGet 包和平台。

让我们探讨程序集和命名空间之间的关系:

  1. 使用您偏好的代码编辑器创建一个名为Chapter07的新解决方案/工作区。

  2. 添加一个控制台应用项目,如下表所定义:

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

    2. 工作区/解决方案文件和文件夹:Chapter07

    3. 项目文件和文件夹:AssembliesAndNamespaces

  3. 打开AssembliesAndNamespaces.csproj并注意,它是一个典型的.NET 6 应用程序项目文件,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
    </Project> 
    

理解程序集中的命名空间和类型

许多常见的.NET 类型位于System.Runtime.dll程序集中。程序集和命名空间之间并不总是存在一对一的映射。单个程序集可以包含多个命名空间,一个命名空间也可以在多个程序集中定义。您可以查看一些程序集与其提供的类型的命名空间之间的关系,如下表所示:

程序集 示例命名空间 示例类型
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

理解 NuGet 包

.NET 被拆分为一组包,使用名为 NuGet 的微软支持的包管理技术进行分发。这些包中的每一个都代表一个同名的单一程序集。例如,System.Collections包包含System.Collections.dll程序集。

以下是包的好处:

  • 包可以轻松地在公共源中分发。

  • 包可以重复使用。

  • 包可以按照自己的时间表发货。

  • 包可以独立于其他包进行测试。

  • 通过包含为不同操作系统和 CPU 构建的同一程序集的多个版本,包可以支持不同的操作系统(OSes)和 CPU。

  • 包可以有仅针对一个库的特定依赖项。

  • 应用体积更小,因为未引用的包不包含在分发中。下表列出了一些较重要的包及其重要类型:

重要类型
System.Runtime ObjectStringInt32Array
System.Collections List<T>Dictionary<TKey, TValue>
System.Net.Http HttpClientHttpResponseMessage
System.IO.FileSystem FileDirectory
System.Reflection AssemblyTypeInfoMethodInfo

理解框架

框架与包之间存在双向关系。包定义 API,而框架则整合包。一个没有任何包的框架不会定义任何 API。

.NET 包各自支持一组框架。例如,System.IO.FileSystem包版本 4.3.0 支持以下框架:

  • .NET Standard,版本 1.3 或更高。

  • .NET Framework,版本 4.6 或更高。

  • 六个 Mono 和 Xamarin 平台(例如,Xamarin.iOS 1.0)。

    更多信息:你可以在以下链接阅读详细信息:www.nuget.org/packages/System.IO.FileSystem/

导入命名空间以使用类型

让我们探讨命名空间与程序集和类型之间的关系:

  1. AssembliesAndNamespaces项目中,在Program.cs文件里,输入以下代码:

    XDocument doc = new(); 
    
  2. 构建项目并注意编译器错误信息,如下所示:

    The type or namespace name 'XDocument' could not be found (are you missing a using directive or an assembly reference?) 
    

    XDocument类型未被识别,因为我们没有告诉编译器该类型的命名空间是什么。尽管此项目已有一个指向包含该类型的程序集的引用,我们还需通过在其类型名前加上命名空间或导入命名空间来解决。

  3. 点击XDocument类名内部。你的代码编辑器会显示一个灯泡图标,表明它识别了该类型,并能自动为你修复问题。

  4. 点击灯泡图标,并从菜单中选择using System.Xml.Linq;

这将通过在文件顶部添加using语句来导入命名空间。一旦在代码文件顶部导入了命名空间,那么该命名空间内的所有类型在该代码文件中只需输入其名称即可使用,无需通过在其名称前加上命名空间来完全限定类型名。

有时我喜欢在导入命名空间后添加一个带有类型名的注释,以提醒我为何需要导入该命名空间,如下所示:

using System.Xml.Linq; // XDocument 

将 C#关键字关联到.NET 类型

我常从初学 C#的程序员那里得到的一个常见问题是:“string(小写 s)和String(大写 S)之间有什么区别?”

简短的答案是:没有区别。详细的答案是,所有 C#类型关键字,如stringint,都是.NET 类库程序集中某个类型的别名。

当你使用string关键字时,编译器将其识别为System.String类型。当你使用int类型时,编译器将其识别为System.Int32类型。

让我们通过一些代码来实际看看:

  1. Program.cs中,声明两个变量以保存string值,一个使用小写的string,另一个使用大写的String,如下列代码所示:

    string s1 = "Hello"; 
    String s2 = "World";
    WriteLine($"{s1} {s2}"); 
    
  2. 运行代码,并注意目前它们两者工作效果相同,实际上意味着相同的事情。

  3. AssembliesAndNamespaces.csproj中,添加条目以防止全局导入System命名空间,如下列标记所示:

    <ItemGroup>
      <Using Remove="System" />
    </ItemGroup> 
    
  4. Program.cs中注意编译器错误消息,如下列输出所示:

    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; // String 
    

最佳实践:当有选择时,使用 C# 关键字而非实际类型,因为关键字不需要导入命名空间。

C# 别名映射到 .NET 类型

下表显示了 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

其他 .NET 编程语言编译器也能做到同样的事情。例如,Visual Basic .NET 语言有一个名为Integer的类型,它是System.Int32的别名。

理解原生大小整数

C# 9 引入了nintnuint关键字别名,用于原生大小整数,意味着整数值的存储大小是平台特定的。它们在 32 位进程中存储 32 位整数,sizeof()返回 4 字节;在 64 位进程中存储 64 位整数,sizeof()返回 8 字节。这些别名代表内存中整数值的指针,这就是为什么它们的 .NET 名称是IntPtrUIntPtr。实际存储类型将根据进程是System.Int32还是System.Int64

在 64 位进程中,下列代码:

WriteLine($"int.MaxValue = {int.MaxValue:N0}");
WriteLine($"nint.MaxValue = {nint.MaxValue:N0}"); 

产生此输出:

int.MaxValue = 2,147,483,647
nint.MaxValue = 9,223,372,036,854,775,807 

揭示类型的位置

代码编辑器为 .NET 类型提供内置文档。我们来探索一下:

  1. XDocument内部右键单击并选择转到定义

  2. 导航到代码文件顶部,并注意程序集文件名为System.Xml.XDocument.dll,但类位于System.Xml.Linq命名空间中,如图 7.1所示:图形用户界面,文本,应用程序,电子邮件 描述自动生成

    图 7.1:包含 XDocument 类型的程序集和命名空间

  3. 关闭**XDocument [来自元数据]**选项卡。

  4. stringString内部右键单击并选择转到定义

  5. 导航至代码文件顶部,注意程序集文件名为System.Runtime.dll,但类位于System命名空间中。

实际上,你的代码编辑器在技术上对你撒了谎。如果你还记得我们在第二章,*讲 C#*中编写代码时,当我们揭示 C#词汇的范围时,我们发现System.Runtime.dll程序集中不包含任何类型。

它包含的是类型转发器。这些特殊类型看似存在于一个程序集中,但实际上在别处实现。在这种情况下,它们在.NET 运行时内部深处使用高度优化的代码实现。

使用.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 工具创建类库时,了解默认使用的目标框架可能会有所帮助,如下表所示:

SDK 新类库的默认目标框架
.NET Core 3.1 netstandard2.0
.NET 5 net5.0
.NET 6 net6.0

当然,仅仅因为类库默认面向特定版本的 .NET,并不意味着在创建使用默认模板的类库项目后不能更改它。

您可以手动将目标框架设置为支持需要引用该库的项目的值,如下表所示:

类库目标框架 可用于面向以下版本的项目
netstandard2.0 .NET Framework 4.6.1 或更高版本,.NET Core 2.0 或更高版本,.NET 5.0 或更高版本,Mono 5.4 或更高版本,Xamarin.Android 8.0 或更高版本,Xamarin.iOS 10.14 或更高版本
netstandard2.1 .NET Core 3.0 或更高版本,.NET 5.0 或更高版本,Mono 6.4 或更高版本,Xamarin.Android 10.0 或更高版本,Xamarin.iOS 12.16 或更高版本
net5.0 .NET 5.0 或更高版本
net6.0 .NET 6.0 或更高版本

最佳实践:始终检查类库的目标框架,并在必要时手动将其更改为更合适的选项。要有意识地决定它应该是什么,而不是接受默认值。

创建 .NET Standard 2.0 类库

我们将创建一个使用 .NET Standard 2.0 的类库,以便它可以在所有重要的 .NET 遗留平台上以及在 Windows、macOS 和 Linux 操作系统上跨平台使用,同时还可以访问广泛的 .NET API 集:

  1. 使用您喜欢的代码编辑器向 Chapter07 解决方案/工作区添加一个名为 SharedLibrary 的新类库。

  2. 如果您使用的是 Visual Studio 2022,当提示选择目标框架时,请选择 .NET Standard 2.0,然后将解决方案的启动项目设置为当前选择。

  3. 如果您使用的是 Visual Studio Code,请包含一个目标为 .NET Standard 2.0 的开关,如下面的命令所示:

    dotnet new classlib -f netstandard2.0 
    
  4. 如果您使用的是 Visual Studio Code,请选择 SharedLibrary 作为活动的 OmniSharp 项目。

最佳实践:如果您需要创建使用 .NET 6.0 新功能的类型,以及仅使用 .NET Standard 2.0 功能的类型,那么您可以创建两个单独的类库:一个面向 .NET Standard 2.0,另一个面向 .NET 6.0。您将在第十章使用 Entity Framework Core 处理数据中看到这一操作。

手动创建两个类库的替代方法是创建一个支持多目标的类库。如果您希望我在下一版中添加关于多目标的章节,请告诉我。您可以在这里阅读关于多目标的信息:docs.microsoft.com/en-us/dotnet/standard/library-guidance/cross-platform-targeting#multi-targeting

控制 .NET SDK

默认情况下,执行 dotnet 命令使用最新安装的 .NET SDK。有时您可能希望控制使用哪个 SDK。

例如,第四版的某位读者希望其体验与书中使用.NET Core 3.1 SDK 的步骤相匹配。但他们也安装了.NET 5.0 SDK,并且默认使用的是这个版本。如前一节所述,创建新类库时的行为已更改为针对.NET 5.0 而非.NET Standard 2.0,这让读者感到困惑。

通过使用global.json文件,你可以控制默认使用的.NET SDK。dotnet命令会在当前文件夹及其祖先文件夹中搜索global.json文件。

  1. Chapter07文件夹中创建一个名为ControlSDK的子目录/文件夹。

  2. 在 Windows 上,启动命令提示符Windows 终端。在 macOS 上,启动终端。如果你使用的是 Visual Studio Code,则可以使用集成终端。

  3. ControlSDK文件夹中,在命令提示符或终端下,输入创建强制使用最新.NET Core 3.1 SDK 的global.json文件的命令,如下所示:

    dotnet new globaljson --sdk-version 3.1.412 
    
  4. 打开global.json文件并审查其内容,如下所示:

    {
      "sdk": {
        "version": "3.1.412"
      }
    } 
    

    你可以在以下链接的表格中找到最新.NET SDK 的版本号:dotnet.microsoft.com/download/visual-studio-sdks

  5. ControlSDK文件夹中,在命令提示符或终端下,输入创建类库项目的命令,如下所示:

    dotnet new classlib 
    
  6. 如果你未安装.NET Core 3.1 SDK,则会看到如下所示的错误:

    Could not execute because the application was not found or a compatible .NET SDK is not installed. 
    
  7. 如果你已安装.NET Core 3.1 SDK,则默认将创建一个针对.NET Standard 2.0 的类库项目。

你无需完成上述步骤,但如果你想尝试且尚未安装.NET Core 3.1 SDK,则可以从以下链接安装:

dotnet.microsoft.com/download/dotnet/3.1

发布你的代码以供部署

如果你写了一部小说并希望其他人阅读,你必须将其出版。

大多数开发者编写代码供其他开发者在他们的代码中使用,或者供用户作为应用程序运行。为此,你必须将你的代码发布为打包的类库或可执行应用程序。

发布和部署.NET 应用程序有三种方式,它们是:

  1. 依赖框架的部署FDD)。

  2. 依赖框架的可执行文件FDEs)。

  3. 自包含。

如果你选择部署应用程序及其包依赖项,但不包括.NET 本身,那么你依赖于目标计算机上已有的.NET。这对于部署到服务器的 Web 应用程序非常有效,因为.NET 和其他许多 Web 应用程序可能已经在服务器上。

框架依赖部署FDD)意味着您部署的是必须由dotnet命令行工具执行的 DLL。框架依赖可执行文件FDE)意味着您部署的是可以直接从命令行运行的 EXE。两者都要求系统上已安装.NET。

有时,您希望能够在 USB 闪存驱动器上提供您的应用程序,并确保它能在他人的计算机上执行。您希望进行自包含部署。虽然部署文件的大小会更大,但您可以确信它将能够运行。

创建一个控制台应用程序以发布

让我们探索如何发布一个控制台应用程序:

  1. 使用您偏好的代码编辑器,在Chapter07解决方案/工作区中添加一个名为DotNetEverywhere的新控制台应用。

  2. 在 Visual Studio Code 中,选择DotNetEverywhere作为活动的 OmniSharp 项目。当看到弹出警告消息提示缺少必需资产时,点击以添加它们。

  3. Program.cs中,删除注释并静态导入Console类。

  4. 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))
    {
      WriteLine("I am Windows 10 or 11.");
    }
    else
    {
      WriteLine("I am some other mysterious OS.");
    }
    WriteLine("Press ENTER to stop me.");
    ReadLine(); 
    
  5. 打开DotNetEverywhere.csproj文件,并在<PropertyGroup>元素内添加运行时标识符,以针对三个操作系统进行目标设定,如下所示的高亮标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<RuntimeIdentifiers>**
     **win10-x64;osx-x64;osx****.11.0****-arm64;linux-x64;linux-arm64**
     **</RuntimeIdentifiers>**
      </PropertyGroup>
    </Project> 
    
    • win10-x64 RID 值表示 Windows 10 或 Windows Server 2016 的 64 位版本。您也可以使用win10-arm64 RID 值来部署到 Microsoft Surface Pro X。

    • osx-x64 RID 值表示 macOS Sierra 10.12 或更高版本。您也可以指定特定版本的 RID 值,如osx.10.15-x64(Catalina)、osx.11.0-x64(Intel 上的 Big Sur)或osx.11.0-arm64(Apple Silicon 上的 Big Sur)。

    • linux-x64 RID 值适用于大多数桌面 Linux 发行版,如 Ubuntu、CentOS、Debian 或 Fedora。使用linux-arm适用于 Raspbian 或 Raspberry Pi OS 的 32 位版本。使用linux-arm64适用于运行 Ubuntu 64 位的 Raspberry Pi。

理解 dotnet 命令

安装.NET SDK 时,它会包含一个名为dotnet命令行界面(CLI)

创建新项目

.NET CLI 拥有在当前文件夹上工作的命令,用于使用模板创建新项目:

  1. 在 Windows 上,启动命令提示符Windows 终端。在 macOS 上,启动终端。如果您使用的是 Visual Studio Code,则可以使用集成终端。

  2. 输入dotnet new --listdotnet new -l命令,列出您当前安装的模板,如图 7.2所示:图 7.2:已安装的 dotnet new 项目模板列表

图 7.2:已安装的 dotnet new 项目模板列表

大多数dotnet命令行开关都有长版和短版。例如,--list-l。短版输入更快,但更容易被您或其他人类误解。有时,多输入一些字符会更清晰。

获取有关.NET 及其环境的信息

查看当前安装的 .NET SDK 和运行时以及操作系统信息非常有用,如下所示:

dotnet --info 

注意结果,如下所示:

.NET SDK (reflecting any global.json):
 Version:   6.0.100
 Commit:    22d70b47bc
Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.19043
 OS Platform: Windows
 RID:         win10-x64
 Base Path:   C:\Program Files\dotnet\sdk\6.0.100\
Host (useful for support):
  Version: 6.0.0
  Commit:  91ba01788d
.NET SDKs installed:
  3.1.412 [C:\Program Files\dotnet\sdk]
  5.0.400 [C:\Program Files\dotnet\sdk]
  6.0.100 [C:\Program Files\dotnet\sdk]
.NET runtimes installed:
  Microsoft.AspNetCore.All 2.1.29 [...\dotnet\shared\Microsoft.AspNetCore.All]
... 

项目管理

.NET CLI 提供了以下命令,用于管理当前文件夹中的项目:

  • dotnet restore: 此命令下载项目的依赖项。

  • dotnet build: 此命令构建(即编译)项目。

  • dotnet test: 此命令构建项目并随后运行单元测试。

  • dotnet run: 此命令构建项目并随后运行。

  • dotnet pack: 此命令为项目创建 NuGet 包。

  • dotnet publish: 此命令构建并发布项目,无论是包含依赖项还是作为自包含应用程序。

  • dotnet add: 此命令向项目添加对包或类库的引用。

  • dotnet remove: 此命令从项目中移除对包或类库的引用。

  • dotnet list: 此命令列出项目对包或类库的引用。

发布自包含应用

既然你已经看到了一些 dotnet 工具命令的示例,我们可以发布我们的跨平台控制台应用:

  1. 在命令行中,确保你位于 DotNetEverywhere 文件夹中。

  2. 输入以下命令以构建并发布适用于 Windows 10 的控制台应用程序的发布版本:

    dotnet publish -c Release -r win10-x64 
    
  3. 注意,构建引擎会恢复任何需要的包,将项目源代码编译成程序集 DLL,并创建一个 publish 文件夹,如下所示:

    Microsoft (R) Build Engine version 17.0.0+073022eb4 for .NET
    Copyright (C) Microsoft Corporation. All rights reserved.
      Determining projects to restore...
      Restored C:\Code\Chapter07\DotNetEverywhere\DotNetEverywhere.csproj (in 46.89 sec).
      DotNetEverywhere -> C:\Code\Chapter07\DotNetEverywhere\bin\Release\net6.0\win10-x64\DotNetEverywhere.dll
      DotNetEverywhere -> C:\Code\Chapter07\DotNetEverywhere\bin\Release\net6.0\win10-x64\publish\ 
    
  4. 输入以下命令以构建并发布适用于 macOS 和 Linux 变体的发布版本:

    dotnet publish -c Release -r osx-x64
    dotnet publish -c Release -r osx.11.0-arm64
    dotnet publish -c Release -r linux-x64
    dotnet publish -c Release -r linux-arm64 
    

    最佳实践:你可以使用 PowerShell 等脚本语言自动化这些命令,并通过跨平台的 PowerShell Core 在任何操作系统上执行。只需创建一个扩展名为 .ps1 的文件,其中包含这五个命令。然后执行该文件。更多关于 PowerShell 的信息,请访问以下链接:github.com/markjprice/cs10dotnet6/tree/main/docs/powershell

  5. 打开 macOS Finder 窗口或 Windows 文件资源管理器,导航至 DotNetEverywhere\bin\Release\net6.0,并注意针对不同操作系统的输出文件夹。

  6. win10-x64 文件夹中,选择 publish 文件夹,注意所有支持程序集,如 Microsoft.CSharp.dll

  7. 选择 DotNetEverywhere 可执行文件,并注意其大小为 161 KB,如图 7.3 所示:图形用户界面 自动生成的描述

    图 7.3:适用于 Windows 10 64 位的 DotNetEverywhere 可执行文件

  8. 如果你使用的是 Windows,则双击执行程序并注意结果,如下所示:

    I can run everywhere!
    OS Version is Microsoft Windows NT 10.0.19042.0.
    I am Windows 10.
    Press ENTER to stop me. 
    
  9. 注意,publish 文件夹及其所有文件的总大小为 64.8 MB。

  10. osx.11.0-arm64文件夹中,选择publish文件夹,注意所有支持的程序集,然后选择DotNetEverywhere可执行文件,并注意可执行文件为 126 KB,而publish文件夹为 71.8 MB。

如果你将任何publish文件夹复制到相应的操作系统,控制台应用程序将运行;这是因为它是自包含的可部署.NET 应用程序。例如,在配备 Intel 芯片的 macOS 上,如下所示:

I can run everywhere!
OS Version is Unix 11.2.3
I am macOS.
Press ENTER to stop me. 

本例使用的是控制台应用程序,但你同样可以轻松创建一个 ASP.NET Core 网站或 Web 服务,或是 Windows Forms 或 WPF 应用程序。当然,你只能将 Windows 桌面应用程序部署到 Windows 计算机上,不能部署到 Linux 或 macOS。

发布单文件应用程序

要发布为“单个”文件,你可以在发布时指定标志。在.NET 5 中,单文件应用程序主要关注 Linux,因为 Windows 和 macOS 都存在限制,这意味着真正的单文件发布在技术上是不可能的。在.NET 6 中,你现在可以在 Windows 上创建真正的单文件应用程序。

如果你能假设目标计算机上已安装.NET 6,那么在发布应用程序时,你可以使用额外的标志来表明它不需要自包含,并且你希望将其发布为单个文件(如果可能),如下所示(该命令必须在一行内输入):

dotnet publish -r win10-x64 -c Release --self-contained=false
/p:PublishSingleFile=true 

这将生成两个文件:DotNetEverywhere.exeDotNetEverywhere.pdb.exe是可执行文件,而.pdb文件是程序调试数据库文件,存储调试信息。

macOS 上发布的应用程序没有.exe文件扩展名,因此如果你在上面的命令中使用osx-x64,文件名将不会有扩展名。

如果你希望将.pdb文件嵌入到.exe文件中,那么请在你的.csproj文件中的<PropertyGroup>元素内添加一个<DebugType>元素,并将其设置为embedded,如下所示:

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net6.0</TargetFramework>
  <Nullable>enable</Nullable>
  <ImplicitUsings>enable</ImplicitUsings>
  <RuntimeIdentifiers>
    win10-x64;osx-x64;osx.11.0-arm64;linux-x64;linux-arm64
  </RuntimeIdentifiers>
 **<DebugType>embedded</DebugType>**
</PropertyGroup> 

如果你不能假设目标计算机上已安装.NET 6,那么在 Linux 上虽然也只生成两个文件,但 Windows 上还需额外生成以下文件:coreclr.dllclrjit.dllclrcompression.dllmscordaccore.dll

让我们看一个 Windows 的示例:

  1. 在命令行中,输入构建 Windows 10 控制台应用程序的发布版本的命令,如下所示:

    dotnet publish -c Release -r win10-x64 /p:PublishSingleFile=true 
    
  2. 导航到DotNetEverywhere\bin\Release\net6.0\win10-x64\publish文件夹,选择DotNetEverywhere可执行文件,并注意可执行文件现在为 58.3 MB,还有一个 10 KB 的.pdb文件。你系统上的大小可能会有所不同。

通过应用程序修剪减小应用程序大小

将.NET 应用程序部署为自包含应用程序的一个问题是.NET 库占用了大量空间。其中,对减小体积需求最大的就是 Blazor WebAssembly 组件,因为所有.NET 库都需要下载到浏览器中。

幸运的是,您可以通过不在部署中打包未使用的程序集来减少此大小。随着.NET Core 3.0 的引入,应用修剪系统可以识别您的代码所需的程序集并移除不需要的那些。

随着.NET 5,修剪更进一步,通过移除单个类型,甚至是程序集内未使用的方法等成员。例如,使用 Hello World 控制台应用,System.Console.dll程序集从 61.5 KB 修剪到 31.5 KB。对于.NET 5,这是一个实验性功能,因此默认情况下是禁用的。

随着.NET 6,微软在其库中添加了注解,以指示它们如何可以安全地修剪,因此类型和成员的修剪被设为默认。这被称为链接修剪模式

关键在于修剪如何准确识别未使用的程序集、类型和成员。如果您的代码是动态的,可能使用反射,那么它可能无法正常工作,因此微软也允许手动控制。

启用程序集级别修剪

有两种方法可以启用程序集级别修剪。

第一种方法是在项目文件中添加一个元素,如下面的标记所示:

<PublishTrimmed>true</PublishTrimmed> 

第二种方法是在发布时添加一个标志,如下面的命令中突出显示的那样:

dotnet publish ... **-p:PublishTrimmed=True** 

启用类型级别和成员级别修剪

有两种方法可以启用类型级别和成员级别修剪。

第一种方法是在项目文件中添加两个元素,如下面的标记所示:

<PublishTrimmed>true</PublishTrimmed>
<TrimMode>Link</TrimMode> 

第二种方法是在发布时添加两个标志,如下面的命令中突出显示的那样:

dotnet publish ... **-p:PublishTrimmed=True -p:TrimMode=Link** 

对于.NET 6,链接修剪模式是默认的,因此您只需在想要设置如copyused等替代修剪模式时指定开关,这意味着程序集级别修剪。

反编译.NET 程序集

学习如何为.NET 编码的最佳方法之一是观察专业人士如何操作。

良好实践:您可以出于非学习目的反编译他人的程序集,例如复制他们的代码以用于您自己的生产库或应用程序,但请记住您正在查看他们的知识产权,因此请予以尊重。

使用 Visual Studio 2022 的 ILSpy 扩展进行反编译

出于学习目的,您可以使用 ILSpy 等工具反编译任何.NET 程序集。

  1. 在 Windows 上的 Visual Studio 2022 中,导航至扩展 | 管理扩展

  2. 在搜索框中输入ilspy

  3. 对于ILSpy扩展,点击下载

  4. 点击关闭

  5. 关闭 Visual Studio 以允许扩展安装。

  6. 重启 Visual Studio 并重新打开Chapter07解决方案。

  7. 解决方案资源管理器中,右键点击DotNetEverywhere项目并选择在 ILSpy 中打开输出

  8. 导航至文件 | 打开…

  9. 导航至以下文件夹:

    Code/Chapter07/DotNetEverywhere/bin/Release/net6.0/linux-x64 
    
  10. 选择System.IO.FileSystem.dll程序集并点击打开

  11. 程序集树中,展开System.IO.FileSystem程序集,展开System.IO命名空间,选择Directory类,并等待其反编译。

  12. Directory 类中,点击 [+] 展开 GetParent 方法,如图 7.4 所示:图形用户界面,文本,应用程序 自动生成的描述

    图 7.4:Windows 上 Directory 类的反编译 GetParent 方法

  13. 注意检查 path 参数的良好实践,如果为 null 则抛出 ArgumentNullException,如果长度为零则抛出 ArgumentException

  14. 关闭 ILSpy。

使用 ILSpy 扩展进行反编译

类似的功能作为 Visual Studio Code 的扩展在跨平台上可用。

  1. 如果您尚未安装 ILSpy .NET Decompiler 扩展,请搜索并安装它。

  2. 在 macOS 或 Linux 上,该扩展依赖于 Mono,因此您还需要从以下链接安装 Mono:www.mono-project.com/download/stable/

  3. 在 Visual Studio Code 中,导航到 View | Command Palette…

  4. 输入 ilspy 然后选择 ILSpy: Decompile IL Assembly (pick file)

  5. 导航到以下文件夹:

    Code/Chapter07/DotNetEverywhere/bin/Release/net6.0/linux-x64 
    
  6. 选择 System.IO.FileSystem.dll 程序集并点击 Select assembly。看似无事发生,但您可以通过查看 Output 窗口,在下拉列表中选择 ilspy-vscode,并查看处理过程来确认 ILSpy 是否在工作,如图 7.5 所示:图形用户界面,文本,应用程序,电子邮件 自动生成的描述

    图 7.5:选择要反编译的程序集时 ILSpy 扩展的输出

  7. EXPLORER 中,展开 ILSPY DECOMPILED MEMBERS,选择程序集,关闭 Output 窗口,并注意打开的两个编辑窗口,它们显示使用 C# 代码的程序集属性和使用 IL 代码的外部 DLL 和程序集引用,如图 7.6 所示:图形用户界面,文本,应用程序 自动生成的描述

    图 7.6:展开 ILSPY DECOMPILED MEMBERS

  8. 在右侧的 IL 代码中,注意对 System.Runtime 程序集的引用,包括版本号,如下所示:

    .module extern libSystem.Native
    .assembly extern System.Runtime
    {
      .publickeytoken = (
        b0 3f 5f 7f 11 d5 0a 3a
      )
      .ver 6:0:0:0
    } 
    

    .module extern libSystem.Native 表示此程序集像预期那样调用了 Linux 系统 API,这些代码与文件系统交互。如果我们反编译此程序集的 Windows 版本,它将使用 .module extern kernel32.dll 代替,这是一个 Win32 API。

  9. EXPLORER 中,在 ILSPY DECOMPILED MEMBERS 中,展开程序集,展开 System.IO 命名空间,选择 Directory,并注意打开的两个编辑窗口,它们显示使用 C# 代码的反编译 Directory 类在左侧,IL 代码在右侧,如图 7.7 所示:

    图 7.7:C# 和 IL 代码中的反编译 Directory 类

  10. 比较以下代码中 GetParent 方法的 C# 源代码:

    public static DirectoryInfo? GetParent(string path)
    {
      if (path == null)
      {
        throw new ArgumentNullException("path");
      }
      if (path.Length == 0)
      {
        throw new ArgumentException(SR.Argument_PathEmpty, "path");
      }
      string fullPath = Path.GetFullPath(path);
      string directoryName = Path.GetDirectoryName(fullPath);
      if (directoryName == null)
      {
        return null;
      }
      return new DirectoryInfo(directoryName);
    } 
    
  11. 使用 GetParent 方法的等效 IL 源代码,如下所示:

    .method /* 06000067 */ public hidebysig static 
      class System.IO.DirectoryInfo GetParent (
        string path
      ) cil managed
    {
      .param [0]
        .custom instance void System.Runtime.CompilerServices
        .NullableAttribute::.ctor(uint8) = ( 
          01 00 02 00 00
        )
      // Method begins at RVA 0x62d4
      // Code size 64 (0x40)
      .maxstack 2
      .locals /* 1100000E */ (
        [0] string,
        [1] string
      )
      IL_0000: ldarg.0
      IL_0001: brtrue.s IL_000e
      IL_0003: ldstr "path" /* 700005CB */
      IL_0008: newobj instance void [System.Runtime]
        System.ArgumentNullException::.ctor(string) /* 0A000035 */
      IL_000d: throw
      IL_000e: ldarg.0
      IL_000f: callvirt instance int32 [System.Runtime]
        System.String::get_Length() /* 0A000022 */
      IL_0014: brtrue.s IL_0026
      IL_0016: call string System.SR::get_Argument_PathEmpty() /* 0600004C */
      IL_001b: ldstr "path" /* 700005CB */
      IL_0020: newobj instance void [System.Runtime]
        System.ArgumentException::.ctor(string, string) /* 0A000036 */
      IL_0025: throw IL_0026: ldarg.0
      IL_0027: call string [System.Runtime.Extensions]
        System.IO.Path::GetFullPath(string) /* 0A000037 */
      IL_002c: stloc.0 IL_002d: ldloc.0
      IL_002e: call string [System.Runtime.Extensions]
        System.IO.Path::GetDirectoryName(string) /* 0A000038 */
      IL_0033: stloc.1
      IL_0034: ldloc.1
      IL_0035: brtrue.s IL_0039 IL_0037: ldnull
      IL_0038: ret IL_0039: ldloc.1
      IL_003a: newobj instance void 
        System.IO.DirectoryInfo::.ctor(string) /* 06000097 */
      IL_003f: ret
    } // end of method Directory::GetParent 
    

    最佳实践:IL 代码编辑窗口在深入了解 C# 和 .NET 开发之前并不是特别有用,此时了解 C# 编译器如何将源代码转换为 IL 代码非常重要。更有用的编辑窗口包含由微软专家编写的等效 C# 源代码。通过观察专业人士如何实现类型,你可以学到很多好的做法。例如,GetParent 方法展示了如何检查参数是否为 null 及其他参数异常。

  12. 关闭编辑窗口而不保存更改。

  13. 资源管理器中,在ILSPY 反编译成员中,右键单击程序集并选择卸载程序集

,从技术上讲,你无法阻止反编译。

有时会有人问我是否有办法保护编译后的代码以防止反编译。简短的回答是没有,如果你仔细想想,就会明白为什么必须如此。你可以使用Dotfuscator等混淆工具使其变得更难,但最终你无法完全阻止反编译。

所有编译后的应用程序都包含针对运行平台的指令、操作系统和硬件。这些指令必须与原始源代码功能相同,只是对人类来说更难阅读。这些指令必须可读才能执行你的代码;因此,它们必须可读才能被反编译。如果你使用某种自定义技术保护代码免受反编译,那么你也会阻止代码运行!

虚拟机模拟硬件,因此可以捕获运行应用程序与它认为正在运行的软件和硬件之间的所有交互。

如果你能保护你的代码,那么你也会阻止使用调试器附加到它并逐步执行。如果编译后的应用程序有 pdb 文件,那么你可以附加一个调试器并逐行执行语句。即使没有 pdb 文件,你仍然可以附加一个调试器并大致了解代码的工作原理。

这对所有编程语言都是如此。不仅仅是 .NET 语言,如 C#、Visual Basic 和 F#,还有 C、C++、Delphi、汇编语言:所有这些都可以附加到调试器中,或者被反汇编或反编译。以下表格展示了一些专业人士使用的工具:

类型 产品 描述
虚拟机 VMware 专业人士如恶意软件分析师总是在虚拟机中运行软件。
调试器 SoftICE 通常在虚拟机中运行于操作系统之下。
调试器 WinDbg 由于它比其他调试器更了解 Windows 数据结构,因此对于理解 Windows 内部机制非常有用。
反汇编器 IDA Pro 专业恶意软件分析师使用。
反编译器 HexRays 反编译 C 应用程序。IDA Pro 的插件。
反编译器 DeDe 反编译 Delphi 应用程序。
反编译器 dotPeek JetBrains 出品的 .NET 反编译器。

最佳实践:调试、反汇编和反编译他人软件很可能违反其许可协议,并且在许多司法管辖区是非法的。与其试图通过技术手段保护你的知识产权,法律有时是你唯一的救济途径。

为 NuGet 分发打包你的库

在我们学习如何创建和打包自己的库之前,我们将回顾一个项目如何使用现有包。

引用 NuGet 包

假设你想添加一个由第三方开发者创建的包,例如,Newtonsoft.Json,这是一个流行的用于处理 JavaScript 对象表示法(JSON)序列化格式的包:

  1. AssembliesAndNamespaces项目中,添加对Newtonsoft.JsonNuGet 包的引用,可以使用 Visual Studio 2022 的 GUI 或 Visual Studio Code 的dotnet add package命令。

  2. 打开AssembliesAndNamespaces.csproj文件,并注意到已添加了一个包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="newtonsoft.json" Version="13.0.1" />
    </ItemGroup> 
    

如果你有更新的newtonsoft.json包版本,那么自本章编写以来它已被更新。

修复依赖关系

为了始终恢复包并编写可靠的代码,重要的是你修复依赖关系。修复依赖关系意味着你正在使用为.NET 的特定版本发布的同一套包,例如,SQLite for .NET 6.0,如下面的标记中突出显示所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
 **<PackageReference**
 **Include=****"Microsoft.EntityFrameworkCore.Sqlite"**
 **Version=****"6.0.0"** **/>**
  </ItemGroup>
</Project> 

为了修复依赖关系,每个包应该只有一个版本,没有额外的限定词。额外的限定词包括测试版(beta1)、发布候选版(rc4)和通配符(*)。

通配符允许自动引用和使用未来版本,因为它们始终代表最新发布。但通配符因此具有危险性,因为它们可能导致使用未来不兼容的包,从而破坏你的代码。

在编写书籍时,这可能值得冒险,因为每月都会发布新的预览版本,你不想不断更新包引用,正如我在 2021 年所做的,如下面的标记所示:

<PackageReference
  Include="Microsoft.EntityFrameworkCore.Sqlite" 
  Version="6.0.0-preview.*" /> 

如果你使用dotnet add package命令,或者 Visual Studio 的管理 NuGet 包,那么它将默认使用包的最新特定版本。但如果你从博客文章复制粘贴配置或手动添加引用,你可能会包含通配符限定词。

以下依赖关系是 NuGet 包引用的示例,它们固定,因此除非你知道其含义,否则应避免使用:

<PackageReference Include="System.Net.Http" Version="4.1.0-*" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3-beta1" /> 

最佳实践:微软保证,如果你将依赖关系固定到.NET 的特定版本随附的内容,例如 6.0.0,那么这些包都将协同工作。几乎总是固定你的依赖关系。

为 NuGet 打包一个库

现在,让我们打包你之前创建的SharedLibrary项目:

  1. SharedLibrary项目中,将Class1.cs文件重命名为StringExtensions.cs

  2. 修改其内容,以提供一些使用正则表达式验证各种文本值的有用扩展方法,如下列代码所示:

    using System.Text.RegularExpressions;
    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 类型中学习如何编写正则表达式。

  3. SharedLibrary.csproj中,修改其内容,如下列标记中突出显示所示,并注意以下事项:

    • PackageId必须全局唯一,因此如果您希望将此 NuGet 包发布到www.nuget.org/公共源供他人引用和下载,则必须使用不同的值。

    • PackageLicenseExpression必须是从以下链接获取的值:spdx.org/licenses/,或者您可以指定一个自定义许可证。

    • 其他元素不言自明:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
     **<GeneratePackageOnBuild>****true****</GeneratePackageOnBuild>**
     **<PackageId>Packt.CSdotnet.SharedLibrary</PackageId>**
     **<PackageVersion>****6.0.0.0****</PackageVersion>**
     **<Title>C****# 10 and .NET 6 Shared Library</Title>**
     **<Authors>Mark J Price</Authors>**
     **<PackageLicenseExpression>**
     **MS-PL**
     **</PackageLicenseExpression>**
     **<PackageProjectUrl>**
     **https:****//github.com/markjprice/cs10dotnet6**
     **</PackageProjectUrl>**
     **<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-2021** **Packt Publishing Limited**
     **</Copyright>**
     **<PackageTags>****string** **extensions packt csharp dotnet</PackageTags>**
      </PropertyGroup>
     **<ItemGroup>**
     **<None Include=****"packt-csdotnet-sharedlibrary.png"****>**
     **<Pack>True</Pack>**
     **<PackagePath></PackagePath>**
     **</None>**
     **</ItemGroup>**
    </Project> 
    

    最佳实践:配置属性值如果是truefalse值,则不能包含任何空格,因此<PackageRequireLicenseAcceptance>条目不能像前面标记中那样包含回车和缩进。

  4. 从以下链接下载图标文件并保存到SharedLibrary文件夹:github.com/markjprice/cs10dotnet6/blob/main/vs4win/Chapter07/SharedLibrary/packt-csdotnet-sharedlibrary.png

  5. 构建发布程序集:

    1. 在 Visual Studio 中,从工具栏选择发布,然后导航至构建 | 构建 SharedLibrary

    2. 在 Visual Studio Code 中,在终端中输入dotnet build -c Release

  6. 如果我们未在项目文件中将<GeneratePackageOnBuild>设置为true,则需要按照以下额外步骤手动创建 NuGet 包:

    1. 在 Visual Studio 中,导航至构建 | 打包 SharedLibrary

    2. 在 Visual Studio 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文件。文件夹路径应为Code\Chapter07\SharedLibrary\bin\Release,文件名为Packt.CSdotnet.SharedLibrary.6.0.0.nupkg

  4. 确认您在SharedLibrary.csproj文件中输入的信息已正确填写,然后点击提交

  5. 稍等片刻,您将看到一条成功消息,显示您的包已上传,如图 7.8所示:

图 7.8:NuGet 包上传消息

最佳实践:如果遇到错误,请检查项目文件中的错误,或阅读有关PackageReference格式的更多信息,网址为docs.microsoft.com/en-us/nuget/reference/msbuild-targets

将包发布到私有 NuGet 源

组织可以托管自己的私有 NuGet 源。这对许多开发团队来说是一种便捷的共享工作方式。你可以在以下链接了解更多信息:

docs.microsoft.com/en-us/nuget/hosting-packages/overview

使用工具探索 NuGet 包

一个名为NuGet Package Explorer的便捷工具,由 Uno Platform 创建,用于打开并查看 NuGet 包的更多详细信息。它不仅是一个网站,还可以作为跨平台应用安装。让我们看看它能做什么:

  1. 打开你最喜欢的浏览器并导航至以下链接:nuget.info

  2. 在搜索框中输入Packt.CSdotnet.SharedLibrary

  3. 选择由Mark J Price发布的v6.0.0包,然后点击打开按钮。

  4. 目录部分,展开lib文件夹和netstandard2.0文件夹。

  5. 选择SharedLibrary.dll,并注意详细信息,如图 7.9所示:

    图 7.9:使用 Uno Platform 的 NuGet Package Explorer 探索我的包

  6. 如果你想将来在本地使用此工具,请在你的浏览器中点击安装按钮。

  7. 关闭浏览器。

并非所有浏览器都支持安装此类网络应用。我推荐使用 Chrome 进行测试和开发。

测试你的类库包

现在你将通过在AssembliesAndNamespaces项目中引用它来测试你上传的包:

  1. AssembliesAndNamespaces项目中,添加对你(或我)的包的引用,如下所示高亮显示:

    <ItemGroup>
      <PackageReference Include="newtonsoft.json" Version="13.0.1" />
     **<PackageReference Include=****"packt.csdotnet.sharedlibrary"**
     **Version=****"6.0.0"** **/>**
    </ItemGroup> 
    
  2. 构建控制台应用。

  3. Program.cs中,导入Packt.Shared命名空间。

  4. Program.cs中,提示用户输入一些string值,然后使用包中的扩展方法进行验证,如下所示:

    Write("Enter a color value in hex: "); 
    string? hex = ReadLine(); // or "00ffc8"
    WriteLine("Is {0} a valid color value? {1}",
      arg0: hex, arg1: hex.IsValidHex());
    Write("Enter a XML element: "); 
    string? xmlTag = ReadLine(); // or "<h1 class=\"<\" />"
    WriteLine("Is {0} a valid XML element? {1}", 
      arg0: xmlTag, arg1: xmlTag.IsValidXmlTag());
    Write("Enter a password: "); 
    string? password = ReadLine(); // or "secretsauce"
    WriteLine("Is {0} a valid password? {1}",
      arg0: password, arg1: password.IsValidPassword()); 
    
  5. 运行代码,按提示输入一些值,并查看结果,如下所示:

    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 Framework 迁移到现代.NET

如果你是现有的.NET Framework 开发者,那么你可能拥有一些你认为应该迁移到现代.NET 的应用程序。但你应该仔细考虑迁移是否是你的代码的正确选择,因为有时候,最好的选择是不迁移。

例如,您可能有一个复杂的网站项目,运行在 .NET Framework 4.8 上,但只有少数用户访问。如果它运行良好,并且能够在最少的硬件上处理访问者流量,那么可能花费数月时间将其移植到 .NET 6 可能是浪费时间。但如果该网站目前需要许多昂贵的 Windows 服务器,那么移植的成本最终可能会得到回报,如果您能迁移到更少、成本更低的 Linux 服务器。

您能移植吗?

现代 .NET 对 Windows、macOS 和 Linux 上的以下类型的应用程序有很好的支持,因此它们是很好的移植候选:

  • ASP.NET Core MVC 网站。

  • ASP.NET Core Web API 网络服务(REST/HTTP)。

  • ASP.NET Core SignalR 服务。

  • 控制台应用程序 命令行界面。

现代 .NET 对 Windows 上的以下类型的应用程序有不错的支持,因此它们是潜在的移植候选:

  • Windows Forms 应用程序。

  • Windows Presentation Foundation (WPF) 应用程序。

现代 .NET 对跨平台桌面和移动设备上的以下类型的应用程序有良好的支持:

  • Xamarin 移动 iOS 和 Android 应用。

  • .NET MAUI 用于桌面 Windows 和 macOS,或移动 iOS 和 Android。

现代 .NET 不支持以下类型的遗留 Microsoft 项目:

  • ASP.NET Web Forms 网站。这些可能最好使用 ASP.NET Core Razor PagesBlazor 重新实现。

  • Windows Communication Foundation (WCF) 服务(但有一个名为 CoreWCF 的开源项目,您可能可以根据需求使用)。WCF 服务可能最好使用 ASP.NET Core gRPC 服务重新实现。

  • Silverlight 应用程序。这些可能最好使用 .NET MAUI 重新实现。

Silverlight 和 ASP.NET Web Forms 应用程序将永远无法移植到现代 .NET,但现有的 Windows Forms 和 WPF 应用程序可以移植到 Windows 上的 .NET,以便利用新的 API 和更快的性能。

遗留的 ASP.NET MVC 网络应用程序和当前在 .NET Framework 上的 ASP.NET Web API 网络服务可以移植到现代 .NET,然后托管在 Windows、Linux 或 macOS 上。

您应该移植吗?

即使您 移植,您 应该 移植吗?您能获得什么好处?一些常见的好处包括以下几点:

  • 部署到 Linux、Docker 或 Kubernetes 的网站和网络服务:这些操作系统作为网站和网络服务平台轻量且成本效益高,尤其是与更昂贵的 Windows Server 相比。

  • 移除对 IIS 和 System.Web.dll 的依赖:即使您继续部署到 Windows Server,ASP.NET Core 也可以托管在轻量级、高性能的 Kestrel(或其他)Web 服务器上。

  • 命令行工具:开发人员和管理员用于自动化任务的工具通常构建为控制台应用程序。能够在跨平台上运行单个工具非常有用。

.NET Framework 与现代 .NET 之间的差异

有三个关键差异,如下表所示:

现代 .NET .NET Framework
作为 NuGet 包分发,因此每个应用程序都可以部署其所需的 .NET 版本的本地副本。 作为系统范围的共享程序集集(实际上,在全局程序集缓存 (GAC) 中)分发。
拆分为小的、分层的组件,以便可以执行最小部署。 单一的、整体的部署。
移除旧技术,如 ASP.NET Web Forms,以及非跨平台特性,如 AppDomains、.NET Remoting 和二进制序列化。 以及一些与现代 .NET 中类似的技术,如 ASP.NET Core MVC,它还保留了一些旧技术,如 ASP.NET Web Forms。

理解 .NET Portability Analyzer

Microsoft 有一个有用的工具,你可以针对现有应用程序运行它来生成移植报告。你可以在以下链接观看该工具的演示:channel9.msdn.com/Blogs/Seth-Juarez/A-Brief-Look-at-the-NET-Portability-Analyzer

理解 .NET Upgrade Assistant

Microsoft 最新推出的用于将遗留项目升级到现代 .NET 的工具是 .NET Upgrade Assistant。

在我的日常工作中,我为一家名为 Optimizely 的公司工作。我们有一个基于 .NET Framework 的企业级数字体验平台 (DXP),包括内容管理系统 (CMS) 和构建数字商务网站。Microsoft 需要一个具有挑战性的迁移项目来设计和测试 .NET Upgrade Assistant,因此我们与他们合作构建了一个出色的工具。

目前,它支持以下 .NET Framework 项目类型,未来还将添加更多:

  • ASP.NET MVC

  • Windows Forms

  • WPF

  • Console Application

  • Class Library

它作为全局 dotnet 工具安装,如下面的命令所示:

dotnet tool install -g upgrade-assistant 

你可以在以下链接中了解更多关于此工具及其使用方法的信息:

docs.microsoft.com/en-us/dotnet/core/porting/upgrade-assistant-overview

使用非 .NET Standard 库

大多数现有的 NuGet 包都可以与现代 .NET 配合使用,即使它们不是为 .NET Standard 或类似 .NET 6 这样的现代版本编译的。如果你发现一个包在其 nuget.org 网页上并未正式支持 .NET Standard,你不必放弃。你应该尝试一下,看看它是否能正常工作。

例如,Dialect Software LLC 创建了一个处理矩阵的自定义集合包,其文档链接如下:

www.nuget.org/packages/DialectSoftware.Collections.Matrix/

这个包最后一次更新是在 2013 年,远在.NET Core 或.NET 6 出现之前,所以这个包是为.NET Framework 构建的。只要像这样的程序集包仅使用.NET Standard 中可用的 API,它就可以用于现代.NET 项目。

我们来尝试使用它,看看是否有效:

  1. AssembliesAndNamespaces项目中,添加对 Dialect Software 包的包引用,如下所示:

    <PackageReference
      Include="dialectsoftware.collections.matrix"
      Version="1.0.0" /> 
    
  2. 构建AssembliesAndNamespaces项目以恢复包。

  3. Program.cs中,添加语句以导入DialectSoftware.CollectionsDialectSoftware.Collections.Generics命名空间。

  4. 添加语句以创建AxisMatrix<T>的实例,填充它们并输出它们,如下所示:

    Axis x = new("x", 0, 10, 1);
    Axis y = new("y", 0, 4, 1);
    Matrix<long> matrix = new(new[] { x, y });
    for (int i = 0; i < matrix.Axes[0].Points.Length; i++)
    {
      matrix.Axes[0].Points[i].Label = "x" + i.ToString();
    }
    for (int i = 0; i < matrix.Axes[1].Points.Length; i++)
    {
      matrix.Axes[1].Points[i].Label = "y" + i.ToString();
    }
    foreach (long[] c in matrix)
    {
      matrix[c] = c[0] + c[1];
    }
    foreach (long[] c in matrix)
    {
      WriteLine("{0},{1} ({2},{3}) = {4}",
        matrix.Axes[0].Points[c[0]].Label,
        matrix.Axes[1].Points[c[1]].Label,
        c[0], c[1], matrix[c]);
    } 
    
  5. 运行代码,注意警告信息和结果,如下所示:

    warning NU1701: Package 'DialectSoftware.Collections.Matrix
    1.0.0' was restored using '.NETFramework,Version=v4.6.1,
    .NETFramework,Version=v4.6.2, .NETFramework,Version=v4.7,
    .NETFramework,Version=v4.7.1, .NETFramework,Version=v4.7.2,
    .NETFramework,Version=v4.8' instead of the project target framework 'net6.0'. This package may not be fully compatible with your project.
    x0,y0 (0,0) = 0
    x0,y1 (0,1) = 1
    x0,y2 (0,2) = 2
    x0,y3 (0,3) = 3
    ... 
    

尽管这个包是在.NET 6 出现之前创建的,编译器和运行时无法知道它是否会工作,因此显示警告,但由于它恰好只调用与.NET Standard 兼容的 API,它能够工作。

使用预览功能

对于微软来说,提供一些具有跨领域影响的全新功能是一项挑战,这些功能涉及.NET 的许多部分,如运行时、语言编译器和 API 库。这是一个经典的先有鸡还是先有蛋的问题。你首先应该做什么?

从实际角度来看,这意味着尽管微软可能已经完成了大部分所需工作,但整个功能可能要到.NET 年度发布周期的后期才能准备就绪,那时已太晚,无法在“野外”进行适当的测试。

因此,从.NET 6 开始,微软将在正式发布GA)版本中包含预览功能。开发者可以选择加入这些预览功能并向微软提供反馈。在后续的 GA 版本中,这些功能可以为所有人启用。

最佳实践:预览功能不支持在生产代码中使用。预览功能在最终发布前可能会发生重大变更。启用预览功能需自行承担风险。

需要预览功能

[RequiresPreviewFeatures]属性用于标识使用预览功能并因此需要关于预览功能的警告的程序集、类型或成员。代码分析器随后扫描此程序集,并在必要时生成警告。如果您的代码未使用任何预览功能,您将不会看到任何警告。如果您使用了任何预览功能,那么您的代码应该警告使用您代码的消费者,您使用了预览功能。

启用预览功能

让我们来看一个.NET 6 中可用的预览功能示例,即定义一个带有静态抽象方法的接口的能力:

  1. 使用您偏好的代码编辑器,在Chapter07解决方案/工作区中添加一个名为UsingPreviewFeatures的新控制台应用程序。

  2. 在 Visual Studio Code 中,选择UsingPreviewFeatures作为活动的 OmniSharp 项目。当看到弹出警告消息提示缺少必需资产时,点击以添加它们。

  3. 在项目文件中,添加一个元素以启用预览功能,并添加一个元素以启用预览语言功能,如以下标记中突出显示的那样:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<EnablePreviewFeatures>****true****</EnablePreviewFeatures>**
     **<LangVersion>preview</LangVersion>**
      </PropertyGroup>
    </Project> 
    
  4. Program.cs中,删除注释并静态导入Console类。

  5. 添加语句以定义具有静态抽象方法的接口、实现该接口的类,然后在顶层程序中调用该方法,如下面的代码所示:

    using static System.Console;
    Doer.DoSomething();
    public interface IWithStaticAbstract
    {
      static abstract void DoSomething();
    }
    public class Doer : IWithStaticAbstract
    {
      public static void DoSomething()
      {
        WriteLine("I am an implementation of a static abstract method.");
      }
    } 
    
  6. 运行控制台应用并注意其输出是否正确。

泛型数学

为什么微软增加了定义静态抽象方法的能力?它们有何用途?

长期以来,开发者一直要求微软提供在泛型类型上使用*等运算符的能力。这将使开发者能够定义数学方法,对任何泛型类型执行加法、平均值等操作,而不必为所有想要支持的数值类型创建数十个重载方法。接口中对静态抽象方法的支持是一个基础特性,它将使泛型数学成为可能。

如果你对此感兴趣,可以在以下链接中阅读更多信息:

devblogs.microsoft.com/dotnet/preview-features-in-net-6-generic-math/

实践与探索

通过回答一些问题、获得一些实践经验以及深入研究本章主题,测试你的知识和理解。

练习 7.1 – 测试你的知识

回答以下问题:

  1. 命名空间与程序集之间有何区别?

  2. 如何在.csproj文件中引用另一个项目?

  3. 像 ILSpy 这样的工具有什么好处?

  4. C#中的float别名代表哪种.NET 类型?

  5. 在将应用程序从.NET Framework 迁移到.NET 6 之前,应该运行什么工具,以及可以使用什么工具来执行大部分迁移工作?

  6. .NET 应用程序的框架依赖部署和自包含部署之间有何区别?

  7. 什么是 RID?

  8. dotnet packdotnet publish命令之间有何区别?

  9. 哪些类型的.NET Framework 应用程序可以迁移到现代.NET?

  10. 能否使用为.NET Framework 编写的包与现代.NET 兼容?

练习 7.2 – 探索主题

使用以下页面上的链接,深入了解本章涵盖的主题:

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-7---understanding-and-packaging-net-types

练习 7.3 – 探索 PowerShell

PowerShell 是微软为在每个操作系统上自动化任务而设计的脚本语言。微软推荐使用带有 PowerShell 扩展的 Visual Studio Code 来编写 PowerShell 脚本。

由于 PowerShell 是一种广泛的语言,本书中没有足够的篇幅来涵盖它。因此,我在书籍的 GitHub 仓库中创建了一些补充页面,向您介绍一些关键概念并展示一些示例:

github.com/markjprice/cs10dotnet6/tree/main/docs/powershell

总结

本章中,我们回顾了通往.NET 6 的旅程,探讨了程序集与命名空间之间的关系,了解了将应用程序发布到多个操作系统的选项,打包并分发了一个类库,并讨论了移植现有.NET Framework 代码库的选项。

在下一章中,您将学习到现代.NET 中包含的一些常见基类库类型。

第八章:使用常见的 .NET 类型

本章介绍了一些随 .NET 一起提供的常见类型。这些类型包括用于操作数字、文本、集合、网络访问、反射和属性的类型;改进与跨度、索引和范围的工作;处理图像;以及国际化。

本章涵盖以下主题:

  • 处理数字

  • 处理文本

  • 处理日期和时间

  • 使用正则表达式进行模式匹配

  • 在集合中存储多个对象

  • 处理跨度、索引和范围

  • 处理网络资源

  • 使用反射和属性

  • 处理图像

  • 国际化你的代码

处理数字

最常见的数据类型之一是数字。.NET 中处理数字的最常见类型如下表所示:

命名空间 示例类型 描述
System SByte, Int16, Int32, Int64 整数;即零和正负整数
System Byte, UInt16, UInt32, UInt64 基数;即零和正整数
System Half, Single, Double 实数;即浮点数
System Decimal 精确实数;即用于科学、工程或金融场景
System.Numerics BigInteger, Complex, Quaternion 任意大整数、复数和四元数

.NET 自 .NET Framework 1.0 起就拥有 32 位浮点数和 64 位双精度类型。IEEE 754 标准还定义了一个 16 位浮点标准。机器学习和其他算法将从这种更小、精度更低的数字类型中受益,因此微软在 .NET 5 及更高版本中引入了 System.Half 类型。

目前,C# 语言未定义 half 别名,因此必须使用 .NET 类型 System.Half。未来可能会发生变化。

处理大整数

.NET 类型中能用 C# 别名表示的最大整数大约是十八万五千亿,存储在无符号 long 整数中。但如果需要存储更大的数字呢?

让我们探索数字:

  1. 使用您喜欢的代码编辑器创建一个名为 Chapter08 的新解决方案/工作区。

  2. 添加一个控制台应用程序项目,如下表所示:

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

    2. 工作区/解决方案文件和文件夹:Chapter08

    3. 项目文件和文件夹:WorkingWithNumbers

  3. Program.cs中,删除现有语句并添加一条语句以导入System.Numerics,如下所示:

    using System.Numerics; 
    
  4. 添加语句以输出 ulong 类型的最大值,以及使用 BigInteger 表示的具有 30 位数字的数,如下所示:

    WriteLine("Working with large integers:");
    WriteLine("-----------------------------------");
    ulong big = ulong.MaxValue;
    WriteLine($"{big,40:N0}");
    BigInteger bigger =
      BigInteger.Parse("123456789012345678901234567890");
    WriteLine($"{bigger,40:N0}"); 
    

    格式代码中的 40 表示右对齐 40 个字符,因此两个数字都排列在右侧边缘。N0 表示使用千位分隔符且小数点后为零。

  5. 运行代码并查看结果,如下所示:

    Working with large integers:
    ----------------------------------------
                  18,446,744,073,709,551,615
     123,456,789,012,345,678,901,234,567,890 
    

处理复数

复数可以表示为a + bi,其中ab是实数,i是虚数单位,其中i² = −1。如果实部a为零,则它是纯虚数。如果虚部b为零,则它是实数。

复数在许多STEM科学、技术、工程和数学)研究领域具有实际应用。此外,它们是通过分别添加被加数的实部和虚部来相加的;考虑这一点:

(a + bi) + (c + di) = (a + c) + (b + d)i 

让我们探索复数:

  1. Program.cs中,添加语句以添加两个复数,如下列代码所示:

    WriteLine("Working with complex numbers:");
    Complex c1 = new(real: 4, imaginary: 2);
    Complex c2 = new(real: 3, imaginary: 7);
    Complex c3 = c1 + c2;
    // output using default ToString implementation
    WriteLine($"{c1} added to {c2} is {c3}");
    // output using 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. 运行代码并查看结果,如下列输出所示:

    Working with complex numbers:
    (4, 2) added to (3, 7) is (7, 9)
    4 + 2i added to 3 + 7i is 7 + 9i 
    

理解四元数

四元数是一种扩展复数系统的数字系统。它们构成了一个四维的关联范数除法代数,覆盖实数,因此也是一个域。

嗯?是的,我知道。我也不明白。别担心,我们不会用它们来编写任何代码!可以说,它们擅长描述空间旋转,因此视频游戏引擎使用它们,许多计算机模拟和飞行控制系统也是如此。

处理文本

变量的另一种最常见类型是文本。.NET 中最常见的处理文本的类型如下表所示:

命名空间 类型 描述
System Char 存储单个文本字符
System String 存储多个文本字符
System.Text StringBuilder 高效地操作字符串
System.Text.RegularExpressions Regex 高效地匹配字符串模式

获取字符串长度

让我们探讨一下处理文本时的一些常见任务;例如,有时您需要找出存储在string变量中的文本片段的长度:

  1. 使用您偏好的代码编辑器,在Chapter08解决方案/工作区中添加一个名为WorkingWithText的新控制台应用:

    1. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择。

    2. 在 Visual Studio Code 中,选择WorkingWithText作为活动的 OmniSharp 项目。

  2. WorkingWithText项目中,在Program.cs文件里,添加语句定义一个变量来存储城市伦敦的名称,然后将其名称和长度写入控制台,如下列代码所示:

    string city = "London";
    WriteLine($"{city} is {city.Length} characters long."); 
    
  3. 运行代码并查看结果,如下列输出所示:

    London is 6 characters long. 
    

获取字符串的字符

string类内部使用char数组来存储文本。它还有一个索引器,这意味着我们可以使用数组语法来读取其字符。数组索引从零开始,因此第三个字符将在索引 2 处。

让我们看看这如何实际操作:

  1. 添加一条语句,以写出string变量中第一和第三位置的字符,如下列代码所示:

    WriteLine($"First char is {city[0]} and third is {city[2]}."); 
    
  2. 运行代码并查看结果,如下列输出所示:

    First char is L and third is n. 
    

分割字符串

有时,您需要根据某个字符(如逗号)分割文本:

  1. 添加语句以定义一个包含逗号分隔的城市名称的单个字符串变量,然后使用Split方法并指定你希望将逗号作为分隔符,接着枚举返回的字符串值数组,如下所示:

    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方法有九个重载,它们返回指定字符字符串字符串中的索引位置。Substring方法有两个重载,如下所示:

  • Substring(startIndex, length):返回从startIndex开始并包含接下来length个字符的子字符串。

  • Substring(startIndex):返回从startIndex开始并包含所有字符直到字符串末尾的子字符串。

让我们来看一个简单的例子:

  1. 添加语句以在字符串变量中存储一个人的全名,其中名字和姓氏之间有一个空格字符,找到空格的位置,然后提取名字和姓氏作为两个部分,以便它们可以以不同的顺序重新组合,如下所示:

    string fullName = "Alan Jones";
    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 Jones
    Swapped: Jones, Alan 
    

如果初始全名的格式不同,例如"姓氏, 名字",那么代码将需要有所不同。作为可选练习,尝试编写一些语句,将输入"Jones, Alan"转换为"Alan Jones"

检查字符串内容

有时,你需要检查一段文本是否以某些字符开始或结束,或者是否包含某些字符。你可以使用名为StartsWithEndsWithContains的方法来实现这一点:

  1. 添加语句以存储一个字符串值,然后检查它是否以或包含几个不同的字符串值,如下所示:

    string company = "Microsoft";
    bool startsWithM = company.StartsWith("M"); 
    bool containsN = company.Contains("N");
    WriteLine($"Text: {company}");
    WriteLine($"Starts with M: {startsWithM}, contains an N: {containsN}"); 
    
  2. 运行代码并查看结果,如下所示:

    Text: Microsoft
    Starts with M: True, contains an N: False 
    

连接、格式化及其他字符串成员

还有许多其他的字符串成员,如下表所示:

成员 描述
修剪TrimStartTrimEnd 这些方法从开头和/或结尾修剪空格、制表符和回车等空白字符。
ToUpperToLower 这些方法将所有字符转换为大写或小写。
插入移除 这些方法用于插入或移除某些文本。
替换 这会将某些文本替换为其他文本。
string.Empty 这可以用来代替每次使用空的双引号("")字面量字符串值时分配内存。
string.Concat 这会将两个字符串变量连接起来。当在字符串操作数之间使用时,+ 运算符执行等效操作。
string.Join 这会将一个或多个字符串变量与每个变量之间的字符连接起来。
string.IsNullOrEmpty 这检查字符串变量是否为null或空。
string.IsNullOrWhitespace 这检查字符串变量是否为null或空白;即,任意数量的水平和垂直空白字符的混合,例如,制表符、空格、回车、换行等。
string.Format 输出格式化字符串值的另一种方法,使用定位参数而不是命名参数。

前面提到的一些方法是静态方法。这意味着该方法只能从类型调用,而不能从变量实例调用。在前面的表格中,我通过在它们前面加上string.来指示静态方法,例如string.Format

让我们探索一些这些方法:

  1. 添加语句以使用Join方法将字符串值数组重新组合成带有分隔符的单个字符串变量,如下所示:

    string recombined = string.Join(" => ", citiesArray); 
    WriteLine(recombined); 
    
  2. 运行代码并查看结果,如下所示:

    Paris => Tehran => Chennai => Sydney => New York => Medellín 
    
  3. 添加语句以使用定位参数和插值字符串格式化语法来输出相同的三个变量两次,如下所示:

    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)); 
    
  4. 运行代码并查看结果,如下所示:

    Interpolated:  Apples cost £0.39 on Thursday. 
    string.Format: Apples cost £0.39 on Thursday. 
    

请注意,我们可以简化第二条语句,因为WriteLine支持与string.Format相同的格式代码,如下所示:

WriteLine("WriteLine: {0} cost {1:C} on {2:dddd}.",
  arg0: fruit, arg1: price, arg2: when); 

高效构建字符串

您可以使用String.Concat方法或简单的+运算符将两个字符串连接起来以创建新的字符串。但这两种选择都是不良实践,因为.NET 必须在内存中创建一个全新的字符串

如果您只是添加两个字符串值,这可能不明显,但如果您在循环中进行连接,并且迭代次数很多,它可能会对性能和内存使用产生显著的负面影响。在第十二章使用多任务提高性能和可扩展性中,您将学习如何使用StringBuilder类型高效地连接字符串变量。

处理日期和时间

在数字和文本之后,接下来最常处理的数据类型是日期和时间。这两种主要类型如下:

  • DateTime:表示一个固定时间点的日期和时间值。

  • TimeSpan:表示一段时间。

这两种类型通常一起使用。例如,如果您从一个DateTime值中减去另一个,结果是一个TimeSpan。如果您将一个TimeSpan添加到DateTime,则结果是一个DateTime值。

指定日期和时间值

创建日期和时间值的常见方法是分别为日期和时间组件(如日和小时)指定单独的值,如下表所述:

日期/时间参数 值范围
1 到 9999
1 到 12
1 到该月的天数
小时 0 到 23
分钟 0 到 59
0 到 59

另一种方法是提供一个string值进行解析,但这可能会根据线程的默认文化被误解。例如,在英国,日期指定为日/月/年,而在美国,日期指定为月/日/年。

让我们看看你可能想要如何处理日期和时间:

  1. 使用你偏好的代码编辑器,在Chapter08解决方案/工作区中添加一个名为WorkingWithTime的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithTime作为活动 OmniSharp 项目。

  3. Program.cs中,删除现有语句,然后添加语句以初始化一些特殊的日期/时间值,如以下代码所示:

    WriteLine("Earliest date/time value is: {0}",
      arg0: DateTime.MinValue);
    WriteLine("UNIX epoch date/time value is: {0}",
      arg0: DateTime.UnixEpoch);
    WriteLine("Date/time value Now is: {0}",
      arg0: DateTime.Now);
    WriteLine("Date/time value Today is: {0}",
      arg0: DateTime.Today); 
    
  4. 运行代码并记录结果,如以下输出所示:

    Earliest date/time value is: 01/01/0001 00:00:00
    UNIX epoch date/time value is: 01/01/1970 00:00:00
    Date/time value Now is: 23/04/2021 14:14:54
    Date/time value Today is: 23/04/2021 00:00:00 
    
  5. 添加语句以定义 2021 年的圣诞节(如果这已过去,则使用未来的一年),并以多种方式展示,如以下代码所示:

    DateTime christmas = new(year: 2021, month: 12, day: 25);
    WriteLine("Christmas: {0}",
      arg0: christmas); // default format
    WriteLine("Christmas: {0:dddd, dd MMMM yyyy}",
      arg0: christmas); // custom format
    WriteLine("Christmas is in month {0} of the year.",
      arg0: christmas.Month);
    WriteLine("Christmas is day {0} of the year.",
      arg0: christmas.DayOfYear);
    WriteLine("Christmas {0} is on a {1}.",
      arg0: christmas.Year,
      arg1: christmas.DayOfWeek); 
    
  6. 运行代码并记录结果,如以下输出所示:

    Christmas: 25/12/2021 00:00:00
    Christmas: Saturday, 25 December 2021
    Christmas is in month 12 of the year.
    Christmas is day 359 of the year.
    Christmas 2021 is on a Saturday. 
    
  7. 添加语句以执行与圣诞节相关的加法和减法,如以下代码所示:

    DateTime beforeXmas = christmas.Subtract(TimeSpan.FromDays(12));
    DateTime afterXmas = christmas.AddDays(12);
    WriteLine("12 days before Christmas is: {0}",
      arg0: beforeXmas);
    WriteLine("12 days after Christmas is: {0}",
      arg0: afterXmas);
    TimeSpan untilChristmas = christmas - DateTime.Now;
    WriteLine("There are {0} days and {1} hours until Christmas.",
      arg0: untilChristmas.Days,
      arg1: untilChristmas.Hours);
    WriteLine("There are {0:N0} hours until Christmas.",
      arg0: untilChristmas.TotalHours); 
    
  8. 运行代码并记录结果,如以下输出所示:

    12 days before Christmas is: 13/12/2021 00:00:00
    12 days after Christmas is: 06/01/2022 00:00:00
    There are 245 days and 9 hours until Christmas.
    There are 5,890 hours until Christmas. 
    
  9. 添加语句以定义圣诞节那天你的孩子们可能醒来打开礼物的时刻,并以多种方式展示,如以下代码所示:

    DateTime kidsWakeUp = new(
      year: 2021, month: 12, day: 25, 
      hour: 6, minute: 30, second: 0);
    WriteLine("Kids wake up on Christmas: {0}",
      arg0: kidsWakeUp);
    WriteLine("The kids woke me up at {0}",
      arg0: kidsWakeUp.ToShortTimeString()); 
    
  10. 运行代码并记录结果,如以下输出所示:

    Kids wake up on Christmas: 25/12/2021 06:30:00
    The kids woke me up at 06:30 
    

全球化与日期和时间

当前文化控制日期和时间的解析方式:

  1. Program.cs顶部,导入System.Globalization命名空间。

  2. 添加语句以显示用于显示日期和时间值的当前文化,然后解析美国独立日并以多种方式展示,如以下代码所示:

    WriteLine("Current culture is: {0}",
      arg0: CultureInfo.CurrentCulture.Name);
    string textDate = "4 July 2021";
    DateTime independenceDay = DateTime.Parse(textDate);
    WriteLine("Text: {0}, DateTime: {1:d MMMM}",
      arg0: textDate,
      arg1: independenceDay);
    textDate = "7/4/2021";
    independenceDay = DateTime.Parse(textDate);
    WriteLine("Text: {0}, DateTime: {1:d MMMM}",
      arg0: textDate,
      arg1: independenceDay);
    independenceDay = DateTime.Parse(textDate,
      provider: CultureInfo.GetCultureInfo("en-US"));
    WriteLine("Text: {0}, DateTime: {1:d MMMM}",
      arg0: textDate,
      arg1: independenceDay); 
    
  3. 运行代码并记录结果,如以下输出所示:

    Current culture is: en-GB
    Text: 4 July 2021, DateTime: 4 July
    Text: 7/4/2021, DateTime: 7 April
    Text: 7/4/2021, DateTime: 4 July 
    

    在我的电脑上,当前文化是英式英语。如果给定日期为 2021 年 7 月 4 日,则无论当前文化是英式还是美式,都能正确解析。但如果日期给定为 7/4/2021,则会被错误解析为 4 月 7 日。你可以通过在解析时指定正确的文化作为提供者来覆盖当前文化,如上文第三个示例所示。

  4. 添加语句以循环从 2020 年到 2025 年,显示该年是否为闰年以及二月有多少天,然后展示圣诞节和独立日是否在夏令时期间,如以下代码所示:

    for (int year = 2020; year < 2026; year++)
    {
      Write($"{year} is a leap year: {DateTime.IsLeapYear(year)}. ");
      WriteLine("There are {0} days in February {1}.",
        arg0: DateTime.DaysInMonth(year: year, month: 2), arg1: year);
    }
    WriteLine("Is Christmas daylight saving time? {0}",
      arg0: christmas.IsDaylightSavingTime());
    WriteLine("Is July 4th daylight saving time? {0}",
      arg0: independenceDay.IsDaylightSavingTime()); 
    
  5. 运行代码并记录结果,如以下输出所示:

    2020 is a leap year: True. There are 29 days in February 2020.
    2021 is a leap year: False. There are 28 days in February 2021.
    2022 is a leap year: False. There are 28 days in February 2022.
    2023 is a leap year: False. There are 28 days in February 2023.
    2024 is a leap year: True. There are 29 days in February 2024.
    2025 is a leap year: False. There are 28 days in February 2025.
    Is Christmas daylight saving time? False
    Is July 4th daylight saving time? True 
    

仅处理日期或时间

.NET 6 引入了一些新类型,用于仅处理日期值或时间值,分别名为 DateOnlyTimeOnly。这些类型比使用时间部分为零的 DateTime 值来存储仅日期值更好,因为它们类型安全且避免了误用。DateOnly 也更适合映射到数据库列类型,例如 SQL Server 中的 date 列。TimeOnly 适合设置闹钟和安排定期会议或活动,并映射到 SQL Server 中的 time 列。

让我们用它们来为英国女王策划一场派对:

  1. 添加语句以定义女王的生日及派对开始时间,然后将这两个值合并以创建日历条目,以免错过她的派对,如下列代码所示:

    DateOnly queensBirthday = new(year: 2022, month: 4, day: 21);
    WriteLine($"The Queen's next birthday is on {queensBirthday}.");
    TimeOnly partyStarts = new(hour: 20, minute: 30);
    WriteLine($"The Queen's party starts at {partyStarts}.");
    DateTime calendarEntry = queensBirthday.ToDateTime(partyStarts);
    WriteLine($"Add to your calendar: {calendarEntry}."); 
    
  2. 运行代码并注意结果,如下列输出所示:

    The Queen's next birthday is on 21/04/2022.
    The Queen's party starts at 20:30.
    Add to your calendar: 21/04/2022 20:30:00. 
    

正则表达式模式匹配

正则表达式对于验证用户输入非常有用。它们功能强大且可能非常复杂。几乎所有编程语言都支持正则表达式,并使用一组通用的特殊字符来定义它们。

让我们尝试一些正则表达式的示例:

  1. 使用您偏好的代码编辑器,在 Chapter08 解决方案/工作区中添加一个名为 WorkingWithRegularExpressions 的新控制台应用。

  2. 在 Visual Studio Code 中,选择 WorkingWithRegularExpressions 作为活动 OmniSharp 项目。

  3. Program.cs 中,导入以下命名空间:

    using System.Text.RegularExpressions; 
    

检查作为文本输入的数字

我们将从实现验证数字输入的常见示例开始:

  1. 添加语句提示用户输入年龄,然后使用正则表达式检查其有效性,该正则表达式查找数字字符,如下列代码所示:

    Write("Enter your age: "); 
    string? input = ReadLine();
    Regex ageChecker = new(@"\d"); 
    if (ageChecker.IsMatch(input))
    {
      WriteLine("Thank you!");
    }
    else
    {
      WriteLine($"This is not a valid age: {input}");
    } 
    

    注意以下关于代码的内容:

    • @ 字符关闭了在字符串中使用转义字符的能力。转义字符以前缀反斜杠表示。例如,\t 表示制表符,\n 表示新行。在编写正则表达式时,我们需要禁用此功能。借用电视剧《白宫风云》中的一句话,“让反斜杠就是反斜杠。”

    • 一旦使用 @ 禁用了转义字符,它们就可以被正则表达式解释。例如,\d 表示数字。在本主题后面,您将学习更多以反斜杠为前缀的正则表达式。

  2. 运行代码,输入一个整数如 34 作为年龄,并查看结果,如下列输出所示:

    Enter your age: 34 
    Thank you! 
    
  3. 再次运行代码,输入 carrots,并查看结果,如下列输出所示:

    Enter your age: carrots
    This is not a valid age: carrots 
    
  4. 再次运行代码,输入 bob30smith,并查看结果,如下列输出所示:

    Enter your age: bob30smith 
    Thank you! 
    

    我们使用的正则表达式是 \d,表示一个数字。然而,它并未指定在该数字之前和之后可以输入什么。这个正则表达式可以用英语描述为“输入任何你想要的字符,只要你至少输入一个数字字符。”

    在正则表达式中,您使用插入符号^符号表示某些输入的开始,使用美元$符号表示某些输入的结束。让我们使用这些符号来表示我们期望在输入的开始和结束之间除了数字外没有任何其他内容。

  5. 将正则表达式更改为^\d$,如下面的代码中突出显示:

    Regex ageChecker = new(@"^**\d$"**); 
    
  6. 再次运行代码并注意它拒绝除单个数字外的任何输入。我们希望允许一个或多个数字。为此,我们在\d表达式后添加一个+,以修改其含义为一个或多个。

  7. 更改正则表达式,如下面的代码中突出显示:

    Regex ageChecker = new(@"^**\d+$"**); 
    
  8. 再次运行代码并注意正则表达式仅允许长度为零或正整数的任何长度的数字。

正则表达式性能改进

.NET 中用于处理正则表达式的类型被广泛应用于.NET 平台及其构建的许多应用程序中。因此,它们对性能有重大影响,但直到现在,它们还没有得到微软太多的优化关注。

在.NET 5 及更高版本中,System.Text.RegularExpressions命名空间已重写内部以挤出最大性能。使用IsMatch等方法的常见正则表达式基准测试现在快了五倍。最好的事情是,您无需更改代码即可获得这些好处!

理解正则表达式的语法

以下是一些您可以在正则表达式中使用的常见正则表达式符号:

符号 含义 符号 含义
^ 输入开始 $ 输入结束
\d 单个数字 \D 单个非数字
\s 空白 \S 非空白
\w 单词字符 \W 非单词字符
[A-Za-z0-9] 字符范围 \^ ^(插入符号)字符
[aeiou] 字符集 [^aeiou] 不在字符集中
. 任何单个字符 \. .(点)字符

此外,以下是一些影响正则表达式中前述符号的正则表达式量词:

符号 含义 符号 含义
+ 一个或多个 ? 一个或无
{3} 恰好三个 {3,5} 三个到五个
{3,} 至少三个 {,3} 最多三个

正则表达式示例

以下是一些带有其含义描述的正则表达式示例:

表达式 含义
\d 输入中某处的单个数字
a 输入中某处的字符a
Bob 输入中某处的单词Bob
^Bob 输入开头的单词Bob
Bob$ 输入末尾的单词Bob
^\d{2}$ 恰好两个数字
^[0-9]{2}$ 恰好两个数字
^[A-Z]{4,}$ ASCII 字符集中仅包含至少四个大写英文字母
^[A-Za-z]{4,}$ ASCII 字符集中仅包含至少四个大写或小写英文字母
^[A-Z]{2}\d{3}$ ASCII 字符集中仅包含两个大写英文字母和三个数字
^[A-Za-z\u00c0-\u017e]+$ 至少一个 ASCII 字符集中的大写或小写英文字母,或 Unicode 字符集中的欧洲字母,如下表所示:ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖ×ØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿıŒœŠšŸ Žž
^d.g$ 字母d,然后是任何字符,然后是字母g,因此它会匹配digdogdg之间的任何单个字符
^d\.g$ 字母d,然后是一个点(.),然后是字母g,因此它只会匹配d.g

良好实践:使用正则表达式验证用户输入。相同的正则表达式可以在 JavaScript 和 Python 等其他语言中重复使用。

分割复杂的逗号分隔字符串

本章前面,你学习了如何分割一个简单的逗号分隔的字符串变量。但电影标题的以下示例呢?

"Monsters, Inc.","I, Tonya","Lock, Stock and Two Smoking Barrels" 

字符串值使用双引号围绕每个电影标题。我们可以利用这些来判断是否需要在逗号处分割(或不分割)。Split方法不够强大,因此我们可以使用正则表达式代替。

良好实践:你可以在 Stack Overflow 文章中找到更详细的解释,该文章启发了此任务,链接如下:stackoverflow.com/questions/18144431/regex-to-split-a-csv

要在string值中包含双引号,我们可以在它们前面加上反斜杠:

  1. 添加语句以存储一个复杂的逗号分隔的string变量,然后使用Split方法以一种笨拙的方式分割它,如下面的代码所示:

    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. 添加语句以定义一个正则表达式,用于智能地分割并写出电影标题,如下面的代码所示:

    WriteLine();
    Regex csv = new(
      "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)");
    MatchCollection filmsSmart = csv.Matches(films);
    WriteLine("Splitting with regular expression:"); 
    foreach (Match film in filmsSmart)
    {
      WriteLine(film.Groups[2].Value);
    } 
    
  3. 运行代码并查看结果,如下面的输出所示:

    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 
    

在集合中存储多个对象

另一种最常见的数据类型是集合。如果你需要在变量中存储多个值,那么你可以使用集合。

集合是一种内存中的数据结构,可以以不同方式管理多个项目,尽管所有集合都具有一些共享功能。

.NET 中用于处理集合的最常见类型如下表所示:

命名空间 示例类型 描述
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 ImmutableArrayImmutableDictionaryImmutableListImmutableQueue 设计用于原始集合内容永远不会改变的场景,尽管它们可以创建作为新实例的修改后的集合。

所有集合的共同特点

所有集合都实现了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; 

所有集合都实现了IEnumerable接口,这意味着它们可以使用foreach语句进行迭代。它们必须有一个GetEnumerator方法,该方法返回一个实现了IEnumerator的对象;这意味着返回的对象必须具有MoveNextReset方法来遍历集合,以及一个包含集合中当前项的Current属性,如下面的代码所示:

namespace System.Collections
{
  public interface IEnumerable
  {
    IEnumerator GetEnumerator();
  }
}
namespace System.Collections
{
  public interface IEnumerator
  {
    object Current { get; }
    bool MoveNext();
    void Reset();
  }
} 

例如,要对passengers集合中的每个对象执行一个操作,我们可以编写以下代码:

foreach (Passenger p in passengers)
{
  // perform an action on each passenger
} 

除了基于object的集合接口外,还有泛型接口和类,其中泛型类型定义了集合中存储的类型,如下面的代码所示:

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);
  }
} 

通过确保集合的容量来提高性能

自.NET 1.1 以来,像StringBuilder这样的类型就有一个名为EnsureCapacity的方法,可以预先设置其内部存储数组到预期的最终大小。这提高了性能,因为它不需要在添加更多字符时反复增加数组的大小。

自.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 

理解集合选择

有几种不同的集合选择,你可以根据不同的目的使用:列表、字典、栈、队列、集合,以及许多其他更专业的集合。

列表

列表,即实现IList<T>的类型,是有序集合,如下面的代码所示:

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);
  }
} 

IList<T>继承自ICollection<T>,因此它具有一个Count属性,以及一个Add方法,用于在集合末尾添加一个项,以及一个Insert方法,用于在列表中指定位置插入一个项,以及RemoveAt方法,用于在指定位置删除一个项。

当你想要手动控制集合中项目的顺序时,列表是一个好的选择。列表中的每个项目都有一个自动分配的唯一索引(或位置)。项目可以是T定义的任何类型,并且项目可以重复。索引是int类型,从0开始,因此列表中的第一个项目位于索引0处,如下表所示:

索引
0 伦敦
1 巴黎
2 伦敦
3 悉尼

如果一个新项(例如,圣地亚哥)被插入到伦敦和悉尼之间,那么悉尼的索引会自动增加。因此,你必须意识到,在插入或删除项后,项的索引可能会改变,如下表所示:

索引
0 伦敦
1 巴黎
2 伦敦
3 圣地亚哥
4 悉尼

字典

当每个(或对象)有一个唯一的子值(或自定义值)可以用作,以便稍后在集合中快速找到一个值时,字典是一个好选择。键必须是唯一的。例如,如果你正在存储一个人员列表,你可以选择使用政府颁发的身份证号码作为键。

将键想象成现实世界词典中的索引条目。它允许你快速找到一个词的定义,因为词(例如,键)是按顺序排列的,如果我们知道要查找海牛的定义,我们会跳到词典中间开始查找,因为字母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值,如下表所示:

BSA 鲍勃·史密斯
MW 马克斯·威廉姆斯
BSB 鲍勃·史密斯
AM 阿米尔·穆罕默德

当你想要实现后进先出LIFO)行为时,栈是一个好选择。使用栈,你只能直接访问或移除栈顶的项,尽管你可以枚举来读取整个栈的项。例如,你不能直接访问栈中的第二个项。

例如,文字处理器使用栈来记住你最近执行的操作顺序,然后当你按下 Ctrl + Z 时,它会撤销栈中的最后一个操作,然后是倒数第二个操作,依此类推。

队列

当你想要实现先进先出FIFO)行为时,队列是一个好选择。使用队列,你只能直接访问或移除队列前端的项,尽管你可以枚举来读取整个队列的项。例如,你不能直接访问队列中的第二个项。

例如,后台进程使用队列按到达顺序处理工作项,就像人们在邮局排队一样。

.NET 6 引入了PriorityQueue,其中队列中的每个项都有一个优先级值以及它们在队列中的位置。

集合

当你想要在两个集合之间执行集合操作时,集合是一个好的选择。例如,你可能有两个城市名称的集合,并且你想要知道哪些名称同时出现在两个集合中(这被称为集合之间的交集)。集合中的项必须是唯一的。

集合方法总结

每种集合都有一套不同的添加和移除项的方法,如下表所示:

集合 添加方法 移除方法 描述
列表 添加插入 移除移除位置 列表是有序的,因此项具有整数索引位置。添加将在列表末尾添加一个新项。插入将在指定的索引位置添加一个新项。
字典 添加 移除 字典是无序的,因此项没有整数索引位置。你可以通过调用ContainsKey方法来检查一个键是否已被使用。
压栈 弹栈 栈总是使用压栈方法在栈顶添加一个新项。第一个项位于栈底。总是使用弹栈方法从栈顶移除项。调用Peek方法可以查看此值而不移除它。
队列 入队 出队 队列总是使用入队方法在队列末尾添加一个新项。第一个项位于队列前端。总是使用出队方法从队列前端移除项。调用Peek方法可以查看此值而不移除它。

使用列表

让我们探索列表:

  1. 使用你偏好的代码编辑器,在Chapter08解决方案/工作区中添加一个名为WorkingWithCollections的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithCollections作为活动的 OmniSharp 项目。

  3. Program.cs中,删除现有语句,然后定义一个函数,输出带有标题的string值集合,如下所示:

    static void Output(string title, IEnumerable<string> collection)
    {
      WriteLine(title);
      foreach (string item in collection)
      {
        WriteLine($"  {item}");
      }
    } 
    
  4. 定义一个名为WorkingWithLists的静态方法,以展示一些定义和使用列表的常见方式,如下所示:

    static void WorkingWithLists()
    {
      // 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" });
      */
      Output("Initial list", cities);
      WriteLine($"The first city is {cities[0]}."); 
      WriteLine($"The last city is {cities[cities.Count - 1]}.");
      cities.Insert(0, "Sydney");
      Output("After inserting Sydney at index 0", cities); 
      cities.RemoveAt(1); 
      cities.Remove("Milan");
      Output("After removing two cities", cities);
    } 
    
  5. Program.cs顶部,在命名空间导入之后,调用WorkingWithLists方法,如下所示:

    WorkingWithLists(); 
    
  6. 运行代码并查看结果,如下所示:

    Initial list
      London
      Paris
      Milan
    The first city is London. 
    The last city is Milan.
    After inserting Sydney at index 0
      Sydney
      London
      Paris
      Milan
    After removing two cities
      Sydney
      Paris 
    

使用字典

让我们探索字典:

  1. Program.cs中,定义一个名为WorkingWithDictionaries的静态方法,以展示一些使用字典的常见方式,例如,查找单词定义,如下所示:

    static void WorkingWithDictionaries()
    {
      Dictionary<string, string> 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", // last comma is optional
      }; */
      Output("Dictionary keys:", keywords.Keys);
      Output("Dictionary values:", keywords.Values);
      WriteLine("Keywords and their definitions");
      foreach (KeyValuePair<string, string> item in keywords)
      {
        WriteLine($"  {item.Key}: {item.Value}");
      }
      // lookup a value using a key
      string key = "long";
      WriteLine($"The definition of {key} is {keywords[key]}");
    } 
    
  2. Program.cs顶部,注释掉之前的方法调用,然后调用WorkingWithDictionaries方法,如下所示:

    // WorkingWithLists();
    WorkingWithDictionaries(); 
    
  3. 运行代码并查看结果,如下所示:

    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 
    

使用队列

让我们探索队列:

  1. Program.cs中,定义一个名为WorkingWithQueues的静态方法,以展示一些使用队列的常见方式,例如,处理排队购买咖啡的顾客,如下所示:

    static void WorkingWithQueues()
    {
      Queue<string> coffee = new();
      coffee.Enqueue("Damir"); // front of queue
      coffee.Enqueue("Andrea");
      coffee.Enqueue("Ronald");
      coffee.Enqueue("Amin");
      coffee.Enqueue("Irina"); // back of queue
      Output("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}.");
      Output("Current queue from front to back", coffee);
      WriteLine($"{coffee.Peek()} is next in line.");
      Output("Current queue from front to back", coffee);
    } 
    
  2. Program.cs顶部,注释掉之前的方法调用,并调用WorkingWithQueues方法。

  3. 运行代码并查看结果,如下所示:

    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 
    
  4. 定义一个名为OutputPQ的静态方法,如下所示:

    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传递的元组中使用的两个类型。

  5. 定义一个名为WorkingWithPriorityQueues的静态方法,如下所示:

    static void WorkingWithPriorityQueues()
    {
      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);  // my mum (70s)
      vaccine.Enqueue("Rebecca", 3); // my niece (teens)
      vaccine.Enqueue("Juliet", 2);  // my sister (40s)
      vaccine.Enqueue("Ian", 1);     // my dad (70s)
      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.");
      vaccine.Enqueue("Mark", 2); // me (40s)
      WriteLine($"{vaccine.Peek()} will be next to be vaccinated.");
      OutputPQ("Current queue for vaccination:", vaccine.UnorderedItems);
    } 
    
  6. Program.cs顶部,注释掉之前的方法调用,并调用WorkingWithPriorityQueues方法。

  7. 运行代码并查看结果,如下所示:

    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.
    Mark will be next to be vaccinated.
    Current queue for vaccination:
      Mark: 2
      Rebecca: 3 
    

排序集合

List<T>类可以通过手动调用其Sort方法进行排序(但请记住,每个项的索引会改变)。手动对string值或其他内置类型的列表进行排序无需额外努力,但如果你创建了自己的类型的集合,则该类型必须实现名为IComparable的接口。你在《第六章:实现接口和继承类》中学过如何做到这一点。

Stack<T>Queue<T>集合无法排序,因为你通常不需要这种功能;例如,你可能永远不会对入住酒店的客人队列进行排序。但有时,你可能想要对字典或集合进行排序。

有时拥有一个自动排序的集合会很有用,即在添加和删除项时保持项的排序顺序。

有多种自动排序集合可供选择。这些排序集合之间的差异通常很微妙,但可能会影响应用程序的内存需求和性能,因此值得努力选择最适合你需求的选项。

一些常见的自动排序集合如下表所示:

集合 描述
SortedDictionary<TKey, TValue> 这表示一个按键排序的键/值对集合。
SortedList<TKey, TValue> 这表示一个按键排序的键/值对集合。
SortedSet<T> 这表示一个唯一的对象集合,这些对象按排序顺序维护。

更专业的集合

还有其他一些用于特殊情况的集合。

使用紧凑的位值数组

System.Collections.BitArray集合管理一个紧凑的位值数组,这些位值表示为布尔值,其中true表示位已打开(值为 1),false表示位已关闭(值为 0)。

高效地使用列表

System.Collections.Generics.LinkedList<T>集合表示一个双向链表,其中每个项都有对其前一个和下一个项的引用。与List<T>相比,在频繁从列表中间插入和删除项的场景中,它们提供了更好的性能。在LinkedList<T>中,项无需在内存中重新排列。

使用不可变集合

有时你需要使集合不可变,这意味着其成员不可更改;即,你不能添加或删除它们。

如果你导入了System.Collections.Immutable命名空间,那么任何实现IEnumerable<T>的集合都会获得六个扩展方法,用于将其转换为不可变列表、字典、哈希集等。

让我们看一个简单的例子:

  1. WorkingWithCollections项目中,在Program.cs中,导入System.Collections.Immutable命名空间。

  2. WorkingWithLists方法中,在方法末尾添加语句,将cities列表转换为不可变列表,然后向其添加一个新城市,如下代码所示:

    ImmutableList<string> immutableCities = cities.ToImmutableList();
    ImmutableList<string> newList = immutableCities.Add("Rio");
    Output("Immutable list of cities:", immutableCities); 
    Output("New list of cities:", newList); 
    
  3. Program.cs顶部,注释掉之前的方法调用,并取消对WorkingWithLists方法调用的注释。

  4. 运行代码,查看结果,并注意当对不可变城市列表调用Add方法时,该列表并未被修改;相反,它返回了一个包含新添加城市的新列表,如下输出所示:

    Immutable list of cities:
      Sydney
      Paris
    New list of cities:
      Sydney
      Paris
      Rio 
    

良好实践:为了提高性能,许多应用程序在中央缓存中存储了常用对象的共享副本。为了安全地允许多个线程使用这些对象,同时确保它们不会被更改,你应该使它们不可变,或者使用并发集合类型,你可以在以下链接中了解相关信息:docs.microsoft.com/en-us/dotnet/api/system.collections.concurrent

集合的良好实践

假设你需要创建一个处理集合的方法。为了最大程度地灵活,你可以声明输入参数为IEnumerable<T>,并使方法泛型化,如下代码所示:

void ProcessCollection<T>(IEnumerable<T> collection)
{
  // process the items in the collection,
  // perhaps using a foreach statement
} 

我可以将数组、列表、队列、栈或任何其他实现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() 方法。您的代码将快两到三倍,并且需要更少的内存。与所有与性能相关的建议一样,您应该通过在产品环境中运行实际代码的性能测试来确认好处。您将在第十二章使用多任务提高性能和可扩展性中学习如何做到这一点。

处理跨度、索引和范围

Microsoft 在 .NET Core 2.1 中的目标之一是提高性能和资源使用率。实现这一目标的关键 .NET 特性是 Span<T> 类型。

使用跨度高效利用内存

在操作数组时,您通常会创建现有子集的新副本,以便仅处理该子集。这样做效率不高,因为必须在内存中创建重复对象。

如果您需要处理数组的子集,请使用跨度,因为它就像原始数组的窗口。这在内存使用方面更有效,并提高了性能。跨度仅适用于数组,不适用于集合,因为内存必须是连续的。

在我们更详细地了解跨度之前,我们需要了解一些相关对象:索引和范围。

使用 Index 类型识别位置

C# 8.0 引入了两个特性,用于识别数组中项的索引以及使用两个索引的范围。

您在上一主题中学到,可以通过将整数传递给其索引器来访问列表中的对象,如下所示:

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 类型识别范围

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>。这使得它们非常节省内存。

使用索引、范围和跨度

让我们探索使用索引和范围来返回跨度:

  1. 使用您喜欢的代码编辑器将名为 WorkingWithRanges 的新控制台应用程序添加到 Chapter08 解决方案/工作区。

  2. 在 Visual Studio Code 中,选择 WorkingWithRanges 作为活动 OmniSharp 项目。

  3. Program.cs 中,键入语句以使用 string 类型的 Substring 方法使用范围来提取某人姓名的部分,如下所示:

    string name = "Samantha Jones";
    // Using Substring
    int lengthOfFirst = name.IndexOf(' ');
    int lengthOfLast = name.Length - lengthOfFirst - 1;
    string firstName = name.Substring(
      startIndex: 0,
      length: lengthOfFirst);
    string lastName = name.Substring(
      startIndex: name.Length - lengthOfLast,
      length: lengthOfLast);
    WriteLine($"First name: {firstName}, Last name: {lastName}");
    // Using spans
    ReadOnlySpan<char> nameAsSpan = name.AsSpan();
    ReadOnlySpan<char> firstNameSpan = nameAsSpan[0..lengthOfFirst]; 
    ReadOnlySpan<char> lastNameSpan = nameAsSpan[^lengthOfLast..⁰];
    WriteLine("First name: {0}, Last name: {1}", 
      arg0: firstNameSpan.ToString(),
      arg1: lastNameSpan.ToString()); 
    
  4. 运行代码并查看结果,如下所示:

    First name: Samantha, Last name: Jones 
    First name: Samantha, Last name: Jones 
    

处理网络资源

有时您需要处理网络资源。.NET 中用于处理网络资源的最常见类型如下表所示:

命名空间 示例类型 描述
System.Net Dns, Uri, Cookie, WebClient, IPAddress 这些用于处理 DNS 服务器、URI、IP 地址等。
System.Net FtpStatusCode, FtpWebRequest, FtpWebResponse 这些用于与 FTP 服务器进行交互。
System.Net HttpStatusCode, HttpWebRequest, HttpWebResponse 这些用于与 HTTP 服务器进行交互;即网站和服务。来自System.Net.Http的类型更容易使用。
System.Net.Http HttpClient, HttpMethod, HttpRequestMessage, HttpResponseMessage 这些用于与 HTTP 服务器(即网站和服务)进行交互。你将在第十六章构建和消费 Web 服务中学习如何使用这些。
System.Net.Mail Attachment, MailAddress, MailMessage, SmtpClient 这些用于处理 SMTP 服务器;即发送电子邮件。
System.Net.NetworkInformation IPStatus, NetworkChange, Ping, TcpStatistics 这些用于处理低级网络协议。

处理 URI、DNS 和 IP 地址

让我们探索一些用于处理网络资源的常见类型:

  1. 使用你偏好的代码编辑器,在Chapter08解决方案/工作区中添加一个名为WorkingWithNetworkResources的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithNetworkResources作为活动 OmniSharp 项目。

  3. Program.cs顶部,导入用于处理网络的命名空间,如下所示:

    using System.Net; // IPHostEntry, Dns, IPAddress 
    
  4. 输入语句以提示用户输入网站地址,然后使用Uri类型将其分解为其组成部分,包括方案(HTTP、FTP 等)、端口号和主机,如下所示:

    Write("Enter a valid web address: "); 
    string? url = ReadLine();
    if (string.IsNullOrWhiteSpace(url))
    {
      url = "https://stackoverflow.com/search?q=securestring";
    }
    Uri uri = new(url);
    WriteLine($"URL: {url}"); 
    WriteLine($"Scheme: {uri.Scheme}"); 
    WriteLine($"Port: {uri.Port}"); 
    WriteLine($"Host: {uri.Host}"); 
    WriteLine($"Path: {uri.AbsolutePath}"); 
    WriteLine($"Query: {uri.Query}"); 
    

    为了方便,代码还允许用户按下 ENTER 键使用示例 URL。

  5. 运行代码,输入有效的网站地址或按下 ENTER 键,查看结果,如下所示:

    Enter a valid web address:
    URL: https://stackoverflow.com/search?q=securestring 
    Scheme: https
    Port: 443
    Host: stackoverflow.com 
    Path: /search
    Query: ?q=securestring 
    
  6. 添加语句以获取输入网站的 IP 地址,如下所示:

    IPHostEntry entry = Dns.GetHostEntry(uri.Host); 
    WriteLine($"{entry.HostName} has the following IP addresses:"); 
    foreach (IPAddress address in entry.AddressList)
    {
      WriteLine($"  {address} ({address.AddressFamily})");
    } 
    
  7. 运行代码,输入有效的网站地址或按下 ENTER 键,查看结果,如下所示:

    stackoverflow.com has the following IP addresses: 
      151.101.193.69 (InterNetwork)
      151.101.129.69 (InterNetwork)
      151.101.1.69 (InterNetwork)
      151.101.65.69 (InterNetwork) 
    

ping 服务器

现在你将添加代码以 ping 一个 Web 服务器以检查其健康状况:

  1. 导入命名空间以获取更多网络信息,如下所示:

    using System.Net.NetworkInformation; // Ping, PingReply, IPStatus 
    
  2. 添加语句以 ping 输入的网站,如下所示:

    try
    {
      Ping ping = new();
      WriteLine("Pinging server. Please wait...");
      PingReply reply = ping.Send(uri.Host);
      WriteLine($"{uri.Host} was pinged and replied: {reply.Status}.");
      if (reply.Status == IPStatus.Success)
      {
        WriteLine("Reply from {0} took {1:N0}ms", 
          arg0: reply.Address,
          arg1: reply.RoundtripTime);
      }
    }
    catch (Exception ex)
    {
      WriteLine($"{ex.GetType().ToString()} says {ex.Message}");
    } 
    
  3. 运行代码,按下 ENTER 键,查看结果,如下所示在 macOS 上的输出:

    Pinging server. Please wait...
    stackoverflow.com was pinged and replied: Success.
    Reply from 151.101.193.69 took 18ms took 136ms 
    
  4. 再次运行代码,但这次输入google.com,如下所示:

    Enter a valid web address: http://google.com
    URL: http://google.com
    Scheme: http
    Port: 80
    Host: google.com
    Path: /
    Query: 
    google.com has the following IP addresses:
      2a00:1450:4009:807::200e (InterNetworkV6)
      216.58.204.238 (InterNetwork)
    Pinging server. Please wait...
    google.com was pinged and replied: Success.
    Reply from 2a00:1450:4009:807::200e took 24ms 
    

处理反射和属性

反射是一种编程特性,允许代码理解和操作自身。一个程序集由最多四个部分组成:

  • 程序集元数据和清单:名称、程序集和文件版本、引用的程序集等。

  • 类型元数据:关于类型、其成员等的信息。

  • IL 代码:方法、属性、构造函数等的实现。

  • 嵌入资源(可选):图像、字符串、JavaScript 等。

元数据包含有关您的代码的信息项。元数据自动从您的代码生成(例如,关于类型和成员的信息)或使用属性应用于您的代码。

属性可以应用于多个级别:程序集、类型及其成员,如下列代码所示:

// an assembly-level attribute
[assembly: AssemblyTitle("Working with Reflection")]
// a type-level attribute
[Serializable] 
public class Person
{
  // a member-level attribute 
  [Obsolete("Deprecated: use Run instead.")] 
  public void Walk()
  {
... 

基于属性的编程在 ASP.NET Core 等应用程序模型中大量使用,以启用路由、安全性、缓存等功能。

程序集版本控制

.NET 中的版本号是三个数字的组合,带有两个可选的附加项。如果遵循语义版本规则,这三个数字表示以下内容:

  • 主要:破坏性更改。

  • 次要:非破坏性更改,包括新功能,通常还包括错误修复。

  • 补丁:非破坏性错误修复。

良好实践:在更新您已在项目中使用的 NuGet 包时,为了安全起见,您应该指定一个可选标志,以确保您仅升级到最高次要版本以避免破坏性更改,或者如果您特别谨慎并且只想接收错误修复,则升级到最高补丁,如下列命令所示:Update-Package Newtonsoft.Json -ToHighestMinorUpdate-Package Newtonsoft.Json -ToHighestPatch

可选地,版本可以包括这些:

  • 预发布:不支持的预览版本。

  • 构建编号:每日构建。

良好实践:遵循语义版本规则,详情请参见以下链接:semver.org

读取程序集元数据

让我们探索属性操作:

  1. 使用您喜欢的代码编辑器,在Chapter08解决方案/工作区中添加一个名为WorkingWithReflection的新控制台应用程序。

  2. 在 Visual Studio Code 中,选择WorkingWithReflection作为活动 OmniSharp 项目。

  3. Program.cs顶部,导入反射命名空间,如下列代码所示:

    using System.Reflection; // Assembly 
    
  4. 添加语句以获取控制台应用程序的程序集,输出其名称和位置,并获取所有程序集级属性并输出它们的类型,如下列代码所示:

    WriteLine("Assembly metadata:");
    Assembly? assembly = Assembly.GetEntryAssembly();
    if (assembly is null)
    {
      WriteLine("Failed to get entry assembly.");
      return;
    }
    WriteLine($"  Full name: {assembly.FullName}"); 
    WriteLine($"  Location: {assembly.Location}");
    IEnumerable<Attribute> attributes = assembly.GetCustomAttributes(); 
    WriteLine($"  Assembly-level attributes:");
    foreach (Attribute a in attributes)
    {
      WriteLine($"   {a.GetType()}");
    } 
    
  5. 运行代码并查看结果,如下列输出所示:

    Assembly metadata:
      Full name: WorkingWithReflection, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
      Location: /Users/markjprice/Code/Chapter08/WorkingWithReflection/bin/Debug/net6.0/WorkingWithReflection.dll
      Assembly-level attributes:
        System.Runtime.CompilerServices.CompilationRelaxationsAttribute
        System.Runtime.CompilerServices.RuntimeCompatibilityAttribute
        System.Diagnostics.DebuggableAttribute
        System.Runtime.Versioning.TargetFrameworkAttribute
        System.Reflection.AssemblyCompanyAttribute
        System.Reflection.AssemblyConfigurationAttribute
        System.Reflection.AssemblyFileVersionAttribute
        System.Reflection.AssemblyInformationalVersionAttribute
        System.Reflection.AssemblyProductAttribute
        System.Reflection.AssemblyTitleAttribute 
    

    请注意,因为程序集的全名必须唯一标识程序集,所以它是以下内容的组合:

    • 名称,例如,WorkingWithReflection

    • 版本,例如,1.0.0.0

    • 文化,例如,neutral

    • 公钥标记,尽管这可以是null

    既然我们已经了解了一些装饰程序集的属性,我们可以专门请求它们。

  6. 添加语句以获取AssemblyInformationalVersionAttributeAssemblyCompanyAttribute类,然后输出它们的值,如下列代码所示:

    AssemblyInformationalVersionAttribute? version = assembly
      .GetCustomAttribute<AssemblyInformationalVersionAttribute>(); 
    WriteLine($"  Version: {version?.InformationalVersion}");
    AssemblyCompanyAttribute? company = assembly
      .GetCustomAttribute<AssemblyCompanyAttribute>();
    WriteLine($"  Company: {company?.Company}"); 
    
  7. 运行代码并查看结果,如下列输出所示:

     Version: 1.0.0
      Company: WorkingWithReflection 
    

    嗯,除非设置版本,否则默认值为 1.0.0,除非设置公司,否则默认值为程序集名称。让我们明确设置这些信息。在旧版.NET Framework 中设置这些值的方法是在 C#源代码文件中添加属性,如下所示:

    [assembly: AssemblyCompany("Packt Publishing")] 
    [assembly: AssemblyInformationalVersion("1.3.0")] 
    

    .NET 使用的 Roslyn 编译器会自动设置这些属性,因此我们不能采用旧方法。相反,必须在项目文件中设置它们。

  8. 编辑WorkingWithReflection.csproj项目文件,添加版本和公司元素,如下所示高亮显示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<Version>****6.3.12****</Version>**
     **<Company>Packt Publishing</Company>**
      </PropertyGroup>
    </Project> 
    
  9. 运行代码并查看结果,如下所示输出:

     Version: 6.3.12
      Company: Packt Publishing 
    

创建自定义属性

你可以通过继承Attribute类来定义自己的属性:

  1. 向项目中添加一个名为CoderAttribute.cs的类文件。

  2. 定义一个属性类,该类可以装饰类或方法,并存储程序员姓名和上次修改代码的日期这两个属性,如下所示:

    namespace Packt.Shared;
    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, 
      AllowMultiple = true)]
    public class CoderAttribute : Attribute
    {
      public string Coder { get; set; }
      public DateTime LastModified { get; set; }
      public CoderAttribute(string coder, string lastModified)
      {
        Coder = coder;
        LastModified = DateTime.Parse(lastModified);
      }
    } 
    
  3. Program.cs中,导入一些命名空间,如下所示:

    using System.Runtime.CompilerServices; // CompilerGeneratedAttribute
    using Packt.Shared; // CoderAttribute 
    
  4. Program.cs底部,添加一个带有方法的类,并用包含两位程序员信息的Coder属性装饰该方法,如下所示:

    class Animal
    {
      [Coder("Mark Price", "22 August 2021")]
      [Coder("Johnni Rasmussen", "13 September 2021")] 
      public void Speak()
      {
        WriteLine("Woof...");
      }
    } 
    
  5. Program.cs中,在Animal类上方,添加代码以获取类型,枚举其成员,读取这些成员上的任何Coder属性,并输出信息,如下所示:

    WriteLine(); 
    WriteLine($"* Types:");
    Type[] types = assembly.GetTypes();
    foreach (Type type in types)
    {
      WriteLine();
      WriteLine($"Type: {type.FullName}"); 
      MemberInfo[] members = type.GetMembers();
      foreach (MemberInfo member in members)
      {
        WriteLine("{0}: {1} ({2})",
          arg0: member.MemberType,
          arg1: member.Name,
          arg2: member.DeclaringType?.Name);
        IOrderedEnumerable<CoderAttribute> coders = 
          member.GetCustomAttributes<CoderAttribute>()
          .OrderByDescending(c => c.LastModified);
        foreach (CoderAttribute coder in coders)
        {
          WriteLine("-> Modified by {0} on {1}",
            coder.Coder, coder.LastModified.ToShortDateString());
        }
      }
    } 
    
  6. 运行代码并查看结果,如下所示部分输出:

    * Types:
    ...
    Type: Animal
    Method: Speak (Animal)
    -> Modified by Johnni Rasmussen on 13/09/2021
    -> Modified by Mark Price on 22/08/2021
    Method: GetType (Object)
    Method: ToString (Object)
    Method: Equals (Object)
    Method: GetHashCode (Object)
    Constructor: .ctor (Program)
    ...
    Type: <Program>$+<>c
    Method: GetType (Object)
    Method: ToString (Object)
    Method: Equals (Object)
    Method: GetHashCode (Object)
    Constructor: .ctor (<>c)
    Field: <>9 (<>c)
    Field: <>9__0_0 (<>c) 
    

<Program>$+<>c类型是什么?

这是一个编译器生成的显示类<>表示编译器生成,c表示显示类。它们是编译器的未记录实现细节,可能会随时更改。你可以忽略它们,因此作为一个可选挑战,向你的控制台应用程序添加语句,通过跳过带有CompilerGeneratedAttribute装饰的类型来过滤编译器生成的类型。

利用反射实现更多功能

这只是反射所能实现功能的一个尝鲜。我们仅使用反射从代码中读取元数据。反射还能执行以下操作:

处理图像

ImageSharp 是一个第三方跨平台 2D 图形库。当.NET Core 1.0 正在开发时,社区对缺少用于处理 2D 图像的System.Drawing命名空间有负面反馈。

ImageSharp项目正是为了填补现代.NET 应用中的这一空白而启动的。

微软在其官方文档中关于System.Drawing的部分指出:“由于不支持在 Windows 或 ASP.NET 服务中使用,且不支持跨平台,System.Drawing命名空间不建议用于新开发。推荐使用 ImageSharp 和 SkiaSharp 作为替代。”

让我们看看 ImageSharp 能实现什么:

  1. 使用您偏好的代码编辑器,向Chapter08解决方案/工作区添加一个名为WorkingWithImages的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithImages作为活动 OmniSharp 项目。

  3. 创建一个images目录,并从以下链接下载九张图片:github.com/markjprice/cs10dotnet6/tree/master/Assets/Categories

  4. 添加对SixLabors.ImageSharp的包引用,如下所示:

    <ItemGroup>
      <PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
    </ItemGroup> 
    
  5. 构建WorkingWithImages项目。

  6. Program.cs顶部,导入一些用于处理图像的命名空间,如下所示:

    using SixLabors.ImageSharp;
    using SixLabors.ImageSharp.Processing; 
    
  7. Program.cs中,输入语句将images文件夹中的所有文件转换为灰度缩略图,大小为原图的十分之一,如下所示:

    string imagesFolder = Path.Combine(
      Environment.CurrentDirectory, "images");
    IEnumerable<string> images =
      Directory.EnumerateFiles(imagesFolder);
    foreach (string imagePath in images)
    {
      string thumbnailPath = Path.Combine(
        Environment.CurrentDirectory, "images",   
        Path.GetFileNameWithoutExtension(imagePath)
        + "-thumbnail" + Path.GetExtension(imagePath));
      using (Image image = Image.Load(imagePath))
      {
        image.Mutate(x => x.Resize(image.Width / 10, image.Height / 10));   
        image.Mutate(x => x.Grayscale());
        image.Save(thumbnailPath);
      }
    }
    WriteLine("Image processing complete. View the images folder."); 
    
  8. 运行代码。

  9. 在文件系统中,打开images文件夹,注意字节数显著减少的灰度缩略图,如图8.1所示:应用程序图片 自动生成的描述

    图 8.1:处理后的图像

ImageSharp 还提供了用于程序化绘制图像和处理网络图像的 NuGet 包,如下表所示:

  • SixLabors.ImageSharp.Drawing

  • SixLabors.ImageSharp.Web

国际化您的代码

国际化是使代码在全球范围内正确运行的过程。它包括两个部分:全球化本地化

全球化意味着编写代码时要考虑多种语言和地区组合。语言与地区的组合被称为文化。代码需要了解语言和地区,因为例如魁北克和巴黎虽然都使用法语,但日期和货币格式却不同。

所有文化组合都有国际标准化组织ISO)代码。例如,代码da-DK中,da代表丹麦语,DK代表丹麦地区;而在代码fr-CA中,fr代表法语,CA代表加拿大地区。

ISO 并非缩写。ISO 是对希腊语单词isos(意为相等)的引用。

本地化是关于定制用户界面以支持一种语言,例如,将按钮的标签更改为关闭(en)或 Fermer(fr)。由于本地化更多地涉及语言,因此它并不总是需要了解区域,尽管具有讽刺意味的是,标准化(en-US)和标准化(en-GB)暗示了相反的情况。

检测和更改当前文化

国际化是一个庞大的主题,已有数千页的书籍专门论述。在本节中,你将通过System.Globalization命名空间中的CultureInfo类型简要了解基础知识。

让我们写一些代码:

  1. 使用你偏好的代码编辑器,在Chapter08解决方案/工作区中添加一个名为Internationalization的新控制台应用。

  2. 在 Visual Studio Code 中,选择Internationalization作为活动的 OmniSharp 项目。

  3. Program.cs的顶部,导入用于使用全球化类型的命名空间,如下面的代码所示:

    using System.Globalization; // CultureInfo 
    
  4. 添加语句以获取当前的全球化文化和本地化文化,并输出有关它们的一些信息,然后提示用户输入新的文化代码,并展示这如何影响常见值(如日期和货币)的格式化,如下面的代码所示:

    CultureInfo globalization = CultureInfo.CurrentCulture; 
    CultureInfo localization = CultureInfo.CurrentUICulture;
    WriteLine("The current globalization culture is {0}: {1}",
      globalization.Name, globalization.DisplayName);
    WriteLine("The current localization culture is {0}: {1}",
      localization.Name, localization.DisplayName);
    WriteLine();
    WriteLine("en-US: English (United States)"); 
    WriteLine("da-DK: Danish (Denmark)"); 
    WriteLine("fr-CA: French (Canada)"); 
    Write("Enter an ISO culture code: ");  
    string? newCulture = ReadLine();
    if (!string.IsNullOrEmpty(newCulture))
    {
      CultureInfo ci = new(newCulture); 
      // change the current cultures
      CultureInfo.CurrentCulture = ci;
      CultureInfo.CurrentUICulture = ci;
    }
    WriteLine();
    Write("Enter your name: "); 
    string? name = ReadLine();
    Write("Enter your date of birth: "); 
    string? dob = ReadLine();
    Write("Enter your salary: "); 
    string? salary = ReadLine();
    DateTime date = DateTime.Parse(dob);
    int minutes = (int)DateTime.Today.Subtract(date).TotalMinutes; 
    decimal earns = decimal.Parse(salary);
    WriteLine(
      "{0} was born on a {1:dddd}, is {2:N0} minutes old, and earns {3:C}",
      name, date, minutes, earns); 
    

    当你运行一个应用程序时,它会自动将其线程设置为使用操作系统的文化。我在英国伦敦运行我的代码,因此线程被设置为英语(英国)。

    代码提示用户输入替代的 ISO 代码。这允许你的应用程序在运行时替换默认文化。

    应用程序然后使用标准格式代码输出星期几,使用格式代码dddd;使用千位分隔符的分钟数,使用格式代码N0;以及带有货币符号的薪水。这些会根据线程的文化自动调整。

  5. 运行代码并输入en-GB作为 ISO 代码,然后输入一些样本数据,包括英国英语中有效的日期格式,如下面的输出所示:

    Enter an ISO culture code: en-GB 
    Enter your name: Alice
    Enter your date of birth: 30/3/1967 
    Enter your salary: 23500
    Alice was born on a Thursday, is 25,469,280 minutes old, and earns
    £23,500.00 
    

    如果你输入en-US而不是en-GB,则必须使用月/日/年的格式输入日期。

  6. 重新运行代码并尝试不同的文化,例如丹麦的丹麦语,如下面的输出所示:

    Enter an ISO culture code: da-DK 
    Enter your name: Mikkel
    Enter your date of birth: 12/3/1980 
    Enter your salary: 340000
    Mikkel was born on a onsdag, is 18.656.640 minutes old, and earns 340.000,00 kr. 
    

在此示例中,只有日期和薪水被全球化为丹麦语。其余文本硬编码为英语。本书目前不包括如何将文本从一种语言翻译成另一种语言。如果你希望我在下一版中包含这一点,请告诉我。

良好实践:考虑你的应用程序是否需要国际化,并在开始编码之前为此做好计划!写下用户界面中需要本地化的所有文本片段。考虑所有需要全球化的数据(日期格式、数字格式和排序文本行为)。

实践和探索

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

练习 8.1 – 测试您的知识

使用网络回答以下问题:

  1. 一个string变量中最多可以存储多少个字符?

  2. 何时以及为何应使用SecureString类型?

  3. 何时适合使用StringBuilder类?

  4. 何时应使用LinkedList<T>类?

  5. 何时应使用SortedDictionary<T>类而非SortedList<T>类?

  6. 威尔士的 ISO 文化代码是什么?

  7. 本地化、全球化与国际化之间有何区别?

  8. 在正则表达式中,$是什么意思?

  9. 在正则表达式中,如何表示数字?

  10. 为何不应使用电子邮件地址的官方标准来创建正则表达式以验证用户的电子邮件地址?

练习 8.2 – 练习正则表达式

Chapter08解决方案/工作区中,创建一个名为Exercise02的控制台应用程序,提示用户输入正则表达式,然后提示用户输入一些输入,并比较两者是否匹配,直到用户按下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. 

练习 8.3 – 练习编写扩展方法

Chapter08解决方案/工作区中,创建一个名为Exercise03的类库,该库定义了扩展数字类型(如BigIntegerint)的扩展方法,该方法名为ToWords,返回一个描述数字的string;例如,18,000,000将是“一千八百万”,而18,456,002,032,011,000,007将是“一千八百五十六万万亿,二万亿,三十二亿,一千一百万,七”。

您可以在以下链接中阅读更多关于大数名称的信息:en.wikipedia.org/wiki/Names_of_large_numbers

练习 8.4 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-8---working-with-common-net-types

总结

在本章中,您探索了用于存储和操作数字、日期和时间以及文本(包括正则表达式)的类型选择,以及用于存储多个项目的集合;处理了索引、范围和跨度;使用了某些网络资源;反思了代码和属性;使用微软推荐的第三方库操作图像;并学习了如何国际化您的代码。

下一章,我们将管理文件和流,编码和解码文本,并执行序列化。

第九章:处理文件、流和序列化

本章是关于读写文件和流、文本编码和序列化的。

我们将涵盖以下主题:

  • 管理文件系统

  • 使用流进行读写

  • 编码和解码文本

  • 序列化对象图

  • 控制 JSON 处理

管理文件系统

您的应用程序通常需要在不同的环境中对文件和目录执行输入和输出操作。SystemSystem.IO命名空间包含为此目的的类。

处理跨平台环境和文件系统

让我们探讨如何处理跨平台环境,例如 Windows 与 Linux 或 macOS 之间的差异。Windows、macOS 和 Linux 的路径不同,因此我们将从探索.NET 如何处理这一点开始:

  1. 使用您喜欢的代码编辑器创建一个名为Chapter09的新解决方案/工作区。

  2. 添加一个控制台应用程序项目,如下表所定义:

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

    2. 工作区/解决方案文件和文件夹:Chapter09

    3. 项目文件和文件夹:WorkingWithFileSystems

  3. Program.cs中,添加语句以静态导入System.ConsoleSystem.IO.DirectorySystem.EnvironmentSystem.IO.Path类型,如下所示:

    using static System.Console; 
    using static System.IO.Directory; 
    using static System.IO.Path; 
    using static System.Environment; 
    
  4. Program.cs中,创建一个静态OutputFileSystemInfo方法,并在其中编写语句以执行以下操作:

    • 输出路径和目录分隔符字符。

    • 输出当前目录的路径。

    • 输出一些特殊路径,用于系统文件、临时文件和文档。

    static void OutputFileSystemInfo()
    {
      WriteLine("{0,-33} {1}", arg0: "Path.PathSeparator",
        arg1: PathSeparator);
      WriteLine("{0,-33} {1}", arg0: "Path.DirectorySeparatorChar",
        arg1: DirectorySeparatorChar);
      WriteLine("{0,-33} {1}", arg0: "Directory.GetCurrentDirectory()",
        arg1: GetCurrentDirectory());
      WriteLine("{0,-33} {1}", arg0: "Environment.CurrentDirectory", 
        arg1: CurrentDirectory);
      WriteLine("{0,-33} {1}", arg0: "Environment.SystemDirectory", 
        arg1: SystemDirectory);
      WriteLine("{0,-33} {1}", arg0: "Path.GetTempPath()", 
        arg1: GetTempPath());
      WriteLine("GetFolderPath(SpecialFolder");
      WriteLine("{0,-33} {1}", arg0: " .System)", 
        arg1: GetFolderPath(SpecialFolder.System));
      WriteLine("{0,-33} {1}", arg0: " .ApplicationData)", 
        arg1: GetFolderPath(SpecialFolder.ApplicationData));
      WriteLine("{0,-33} {1}", arg0: " .MyDocuments)", 
        arg1: GetFolderPath(SpecialFolder.MyDocuments));
      WriteLine("{0,-33} {1}", arg0: " .Personal)", 
        arg1: GetFolderPath(SpecialFolder.Personal));
    } 
    

    Environment类型有许多其他有用的成员,我们在此代码中未使用,包括GetEnvironmentVariables方法以及OSVersionProcessorCount属性。

  5. Program.cs中,在函数上方调用OutputFileSystemInfo方法,如下所示:

    OutputFileSystemInfo(); 
    
  6. 运行代码并查看结果,如图9.1所示:文本描述自动生成

    图 9.1:运行应用程序以在 Windows 上显示文件系统信息

当使用 Visual Studio Code 中的dotnet run运行控制台应用程序时,CurrentDirectory将是项目文件夹,而不是bin内的文件夹。

最佳实践:Windows 使用反斜杠\作为目录分隔符。macOS 和 Linux 使用正斜杠/作为目录分隔符。在组合路径时,不要假设代码中使用的是哪种字符。

管理驱动器

要管理驱动器,请使用DriveInfo类型,该类型有一个静态方法,返回有关连接到计算机的所有驱动器的信息。每个驱动器都有一个驱动器类型。

让我们探索驱动器:

  1. 创建一个WorkWithDrives方法,并编写语句以获取所有驱动器并输出其名称、类型、大小、可用自由空间和格式,但仅当驱动器就绪时,如下所示:

    static void WorkWithDrives()
    {
      WriteLine("{0,-30} | {1,-10} | {2,-7} | {3,18} | {4,18}",
        "NAME", "TYPE", "FORMAT", "SIZE (BYTES)", "FREE SPACE");
      foreach (DriveInfo drive in DriveInfo.GetDrives())
      {
        if (drive.IsReady)
        {
          WriteLine(
            "{0,-30} | {1,-10} | {2,-7} | {3,18:N0} | {4,18:N0}",
            drive.Name, drive.DriveType, drive.DriveFormat,
            drive.TotalSize, drive.AvailableFreeSpace);
        }
        else
        {
          WriteLine("{0,-30} | {1,-10}", drive.Name, drive.DriveType);
        }
      }
    } 
    

    最佳实践:在读取TotalSize等属性之前,检查驱动器是否就绪,否则对于可移动驱动器,您将看到抛出的异常。

  2. Program.cs中,注释掉之前的方法调用,并添加对WorkWithDrives的调用,如下面的代码中突出显示的那样:

    **// OutputFileSystemInfo();**
    **WorkWithDrives();** 
    
  3. 运行代码并查看结果,如图 9.2所示:

    图 9.2:在 Windows 上显示驱动器信息

管理目录

要管理目录,请使用DirectoryPathEnvironment静态类。这些类型包含许多用于处理文件系统的成员。

在构建自定义路径时,必须小心编写代码,使其不依赖于平台,例如,不假设使用哪种目录分隔符字符。

  1. 创建一个WorkWithDirectories方法,并编写语句以执行以下操作:

    • 通过为用户的主目录下创建一个字符串数组来定义自定义路径,然后使用Path类型的Combine方法正确组合它们。

    • 使用Directory类的Exists方法检查自定义目录路径是否存在。

    • 使用Directory类的CreateDirectoryDelete方法创建然后删除目录,包括其中的文件和子目录:

    static void WorkWithDirectories()
    {
      // define a directory path for a new folder
      // starting in the user's folder
      string newFolder = Combine(
        GetFolderPath(SpecialFolder.Personal),
        "Code", "Chapter09", "NewFolder");
      WriteLine($"Working with: {newFolder}");
      // check if it exists
      WriteLine($"Does it exist? {Exists(newFolder)}");
      // create directory 
      WriteLine("Creating it...");
      CreateDirectory(newFolder);
      WriteLine($"Does it exist? {Exists(newFolder)}");
      Write("Confirm the directory exists, and then press ENTER: ");
      ReadLine();
      // delete directory 
      WriteLine("Deleting it...");
      Delete(newFolder, recursive: true);
      WriteLine($"Does it exist? {Exists(newFolder)}");
    } 
    
  2. Program.cs中,注释掉之前的方法调用,并添加对WorkWithDirectories的调用。

  3. 运行代码并查看结果,并使用您喜欢的文件管理工具确认目录已创建,然后按 Enter 键删除它,如下面的输出所示:

    Working with: /Users/markjprice/Code/Chapter09/NewFolder Does it exist? False
    Creating it...
    Does it exist? True
    Confirm the directory exists, and then press ENTER:
    Deleting it...
    Does it exist? False 
    

管理文件

在处理文件时,可以像我们为目录类型所做的那样静态导入文件类型,但对于下一个示例,我们将不会这样做,因为它与目录类型有一些相同的方法,并且它们会发生冲突。文件类型的名称足够短,在这种情况下不会造成影响。步骤如下:

  1. 创建一个WorkWithFiles方法,并编写语句以执行以下操作:

    1. 检查文件是否存在。

    2. 创建文本文件。

    3. 向文件写入一行文本。

    4. 关闭文件以释放系统资源和文件锁(这通常在try-finally语句块内部完成,以确保即使写入文件时发生异常,文件也会关闭)。

    5. 将文件复制到备份。

    6. 删除原始文件。

    7. 读取备份文件的内容,然后关闭它:

    static void WorkWithFiles()
    {
      // define a directory path to output files
      // starting in the user's folder
      string dir = Combine(
        GetFolderPath(SpecialFolder.Personal), 
        "Code", "Chapter09", "OutputFiles");
      CreateDirectory(dir);
      // define file paths
      string textFile = Combine(dir, "Dummy.txt");
      string backupFile = Combine(dir, "Dummy.bak");
      WriteLine($"Working with: {textFile}");
      // check if a file exists
      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 ENTER: ");
      ReadLine();
      // delete 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. Program.cs中,注释掉之前的方法调用,并添加对WorkWithFiles的调用。

  3. 运行代码并查看结果,如下面的输出所示:

    Working with: /Users/markjprice/Code/Chapter09/OutputFiles/Dummy.txt 
    Does it exist? False
    Does it exist? True
    Does /Users/markjprice/Code/Chapter09/OutputFiles/Dummy.bak exist? True 
    Confirm the files exist, and then press ENTER:
    Does it exist? False
    Reading contents of /Users/markjprice/Code/Chapter09/OutputFiles/Dummy.bak:
    Hello, C#! 
    

管理路径

有时,您需要处理路径的一部分;例如,您可能只想提取文件夹名称、文件名或扩展名。有时,您需要生成临时文件夹和文件名。您可以使用Path类的静态方法来完成此操作:

  1. WorkWithFiles方法的末尾添加以下语句:

    // 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: /Users/markjprice/Code/Chapter09/OutputFiles 
    File Name: Dummy.txt
    File Name without Extension: Dummy 
    File Extension: .txt
    Random File Name: u45w1zki.co3 
    Temporary File Name:
    /var/folders/tz/xx0y_wld5sx0nv0fjtq4tnpc0000gn/T/tmpyqrepP.tmp 
    

    GetTempFileName创建一个零字节文件并返回其名称,供你使用。GetRandomFileName仅返回一个文件名;它不创建文件。

获取文件信息

要获取有关文件或目录的更多信息,例如其大小或上次访问时间,你可以创建FileInfoDirectoryInfo类的实例。

FileInfoDirectoryInfo都继承自FileSystemInfo,因此它们都具有LastAccessTimeDelete等成员,以及它们自己的特定成员,如下表所示:

成员
FileSystemInfo 字段:FullPathOriginalPath属性:AttributesCreationTimeCreationTimeUtcExistsExtensionFullNameLastAccessTimeLastAccessTimeUtcLastWriteTimeLastWriteTimeUtcName方法:DeleteGetObjectDataRefresh
DirectoryInfo 属性:ParentRoot方法:CreateCreateSubdirectoryEnumerateDirectoriesEnumerateFilesEnumerateFileSystemInfosGetAccessControlGetDirectoriesGetFilesGetFileSystemInfosMoveToSetAccessControl
FileInfo 属性:DirectoryDirectoryNameIsReadOnlyLength方法:AppendTextCopyToCreateCreateTextDecryptEncryptGetAccessControlMoveToOpenOpenReadOpenTextOpenWriteReplaceSetAccessControl

让我们编写一些代码,使用FileInfo实例高效地对文件执行多项操作:

  1. WorkWithFiles方法末尾添加语句,为备份文件创建一个FileInfo实例,并将有关它的信息写入控制台,如下面的代码所示:

    FileInfo info = new(backupFile); 
    WriteLine($"{backupFile}:"); 
    WriteLine($"Contains {info.Length} bytes");
    WriteLine($"Last accessed {info.LastAccessTime}"); 
    WriteLine($"Has readonly set to {info.IsReadOnly}"); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    /Users/markjprice/Code/Chapter09/OutputFiles/Dummy.bak: 
    Contains 11 bytes
    Last accessed 26/10/2021 09:08:26 
    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)); 

使用流进行读写

一个是一系列字节,可以从中读取和写入。尽管文件可以像数组一样处理,通过知道文件中字节的位置提供随机访问,但将文件作为流处理,其中字节可以按顺序访问,可能会有用。

流还可用于处理终端输入输出和网络资源,如套接字和端口,这些资源不提供随机访问,也不能查找(即移动)到某个位置。您可以编写代码来处理一些任意字节,而无需知道或关心它来自哪里。您的代码只是读取或写入流,而另一段代码处理字节实际存储的位置。

理解抽象流和具体流

存在一个名为Stream抽象类,它代表任何类型的流。记住,抽象类不能使用new实例化;它们只能被继承。

有许多具体类继承自这个基类,包括FileStreamMemoryStreamBufferedStreamGZipStreamSslStream,因此它们都以相同的方式工作。所有流都实现IDisposable,因此它们有一个Dispose方法来释放非托管资源。

以下表格描述了Stream类的一些常见成员:

成员 描述
CanRead, CanWrite 这些属性确定是否可以从流中读取和写入。
Length, Position 这些属性确定流中的总字节数和当前位置。对于某些类型的流,这些属性可能会引发异常。
Dispose 此方法关闭流并释放其资源。
Flush 如果流有缓冲区,则此方法将缓冲区中的字节写入流,并清除缓冲区。
CanSeek 此属性确定是否可以使用Seek方法。
Seek 此方法将其参数指定的新位置移动当前位置。
Read, ReadAsync 这些方法从流中读取指定数量的字节到字节数组中,并推进位置。
ReadByte 此方法从流中读取下一个字节并推进位置。
Write, WriteAsync 这些方法将字节数组的内容写入流中。
WriteByte 此方法将一个字节写入流中。

理解存储流

以下表格描述了一些代表字节存储位置的存储流:

命名空间 描述
System.IO FileStream 文件系统中存储的字节。
System.IO MemoryStream 当前进程内存中存储的字节。
System.Net.Sockets NetworkStream 网络位置存储的字节。

FileStream 在 .NET 6 中被重写,以在 Windows 上具有更高的性能和可靠性。

理解功能流

某些功能流无法独立存在,只能“附加到”其他流以添加功能,如下表所述:

命名空间 描述
System.Security.Cryptography CryptoStream 此流用于加密和解密。
System.IO.Compression GZipStream, DeflateStream 这些类用于压缩和解压缩流。
System.Net.Security AuthenticatedStream 此流用于跨流发送凭据。

理解流辅助类

尽管有时您需要以低级别处理流,但大多数情况下,您可以将辅助类插入链中以简化操作。所有流辅助类型均实现 IDisposable,因此它们具有 Dispose 方法以释放非托管资源。

处理常见场景的一些辅助类如下表所述:

命名空间 描述
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 格式向底层流写入数据。

写入文本流

让我们编写一些代码将文本写入流:

  1. 使用您偏好的代码编辑器,在 Chapter09 解决方案/工作区中添加一个名为 WorkingWithStreams 的新控制台应用:

    1. 在 Visual Studio 中,将解决方案的启动项目设置为当前选定项。

    2. 在 Visual Studio Code 中,选择 WorkingWithStreams 作为活动 OmniSharp 项目。

  2. WorkingWithStreams 项目中,在 Program.cs 中,导入 System.Xml 命名空间并静态导入 System.ConsoleSystem.EnvironmentSystem.IO.Path 类型。

  3. Program.cs 底部,定义一个名为 Viper 的静态类,其中包含一个名为 Callsigns 的静态 string 数组,如下所示:

    static class Viper
    {
      // define an array of Viper pilot call signs
      public static string[] Callsigns = new[]
      {
        "Husker", "Starbuck", "Apollo", "Boomer",
        "Bulldog", "Athena", "Helo", "Racetrack"
      };
    } 
    
  4. Viper 类上方,定义一个名为 WorkWithText 的方法,该方法枚举 Viper 呼号,将每个呼号写入单个文本文件中的一行,如下所示:

    static void WorkWithText()
    {
      // 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 resources
      // output the contents of the file
      WriteLine("{0} contains {1:N0} bytes.",
        arg0: textFile,
        arg1: new FileInfo(textFile).Length);
      WriteLine(File.ReadAllText(textFile));
    } 
    
  5. 在命名空间导入下方,调用 WorkWithText 方法。

  6. 运行代码并查看结果,如下所示:

    /Users/markjprice/Code/Chapter09/WorkingWithStreams/streams.txt contains
    60 bytes. 
    Husker 
    Starbuck 
    Apollo 
    Boomer 
    Bulldog 
    Athena 
    Helo 
    Racetrack 
    
  7. 打开创建的文件并检查其是否包含呼号列表。

写入 XML 流

编写 XML 元素有两种方式,如下所示:

  • WriteStartElementWriteEndElement:当元素可能有子元素时使用这一对方法。

  • WriteElementString:当元素没有子元素时使用此方法。

现在,让我们尝试将 Viper 飞行员呼号数组string值存储在 XML 文件中:

  1. 创建一个WorkWithXml方法,该方法枚举呼号,并将每个呼号作为单个 XML 文件中的元素写入,如下面的代码所示:

    static void WorkWithXml()
    {
      // define a file to write to
      string xmlFile = Combine(CurrentDirectory, "streams.xml");
      // create a file stream
      FileStream xmlFileStream = File.Create(xmlFile);
      // wrap the file stream in an XML writer helper
      // and automatically indent nested elements
      XmlWriter 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();
      // close helper and stream
      xml.Close();
      xmlFileStream.Close();
      // output all the contents of the file
      WriteLine("{0} contains {1:N0} bytes.",
        arg0: xmlFile,
        arg1: new FileInfo(xmlFile).Length);
      WriteLine(File.ReadAllText(xmlFile));
    } 
    
  2. Program.cs中,注释掉之前的方法调用,并添加对WorkWithXml方法的调用。

  3. 运行代码并查看结果,如下面的输出所示:

    /Users/markjprice/Code/Chapter09/WorkingWithStreams/streams.xml contains
    310 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> 
    

释放文件资源

当你打开一个文件进行读取或写入时,你正在使用.NET 之外的资源。这些被称为非托管资源,并且在完成与它们的工作后必须被释放。为了确定性地控制何时释放它们,我们可以在finally块中调用Dispose方法。

让我们改进之前处理 XML 的代码,以正确释放其非托管资源:

  1. 修改WorkWithXml方法,如下面的代码中突出显示的那样:

    static void WorkWithXml()
    {
     **FileStream? xmlFileStream =** **null****;** 
     **XmlWriter? xml =** **null****;**
    **try**
     **{**
        // define a file to write to
        string xmlFile = Combine(CurrentDirectory, "streams.xml");
        // create a file stream
     **xmlFileStream = File.Create(xmlFile);**
        // wrap the file stream in an XML writer helper
        // and 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();
        // close helper and stream
        xml.Close();
        xmlFileStream.Close();
        // output all the contents of the file
        WriteLine($"{0} contains {1:N0} bytes.",
          arg0: xmlFile,
          arg1: new FileInfo(xmlFile).Length);
        WriteLine(File.ReadAllText(xmlFile));
     **}**
     **catch (Exception ex)**
     **{**
    **// if the path doesn't exist the exception will be caught**
     **WriteLine(****$"****{ex.GetType()}** **says** **{ex.Message}****"****);**
     **}**
    **finally**
     **{**
    **if** **(xml !=** **null****)**
     **{** 
     **xml.Dispose();**
     **WriteLine(****"The XML writer's unmanaged resources have been disposed."****);**
    **if** **(xmlFileStream !=** **null****)**
     **{**
     **xmlFileStream.Dispose();**
     **WriteLine(****"The file stream's unmanaged resources have been disposed."****);**
     **}**
     **}**
     **}**
    } 
    

    你也可以回去修改你之前创建的其他方法,但我会将其留作可选练习。

  2. 运行代码并查看结果,如下面的输出所示:

    The XML writer's unmanaged resources have been disposed. 
    The file stream's unmanaged resources have been disposed. 
    

最佳实践:在调用Dispose方法之前,检查对象是否不为 null。

通过使用using语句简化释放

你可以通过使用using语句简化需要检查null对象然后调用其Dispose方法的代码。一般来说,我建议使用using而不是手动调用Dispose,除非你需要更高级别的控制。

令人困惑的是,using关键字有两种用途:导入命名空间和生成一个finally语句,该语句在实现IDisposable接口的对象上调用Dispose

编译器将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}");
} 

压缩流

XML 相对冗长,因此在字节中占用的空间比纯文本多。让我们看看如何使用称为 GZIP 的常见压缩算法来压缩 XML:

  1. Program.cs的顶部,导入用于处理压缩的命名空间,如下面的代码所示:

    using System.IO.Compression; // BrotliStream, GZipStream, CompressionMode 
    
  2. 添加一个WorkWithCompression方法,该方法使用GZipStream实例创建一个包含与之前相同 XML 元素的压缩文件,然后在读取时解压缩并输出到控制台,如下面的代码所示:

    static void WorkWithCompression()
    {
      string fileExt = "gzip";
      // compress the XML output
      string filePath = Combine(
        CurrentDirectory, $"streams.**{fileExt}**");
      FileStream file = File.Create(filePath);
      Stream compressor = new GZipStream(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);
          }
          // the normal call to WriteEndElement is not necessary
          // because when the XmlWriter disposes, it will
          // automatically end any elements of any depth
        }
      } // also closes the underlying stream
      // output all the contents of the compressed file
      WriteLine("{0} contains {1:N0} bytes.",
        filePath, new FileInfo(filePath).Length);
      WriteLine($"The compressed contents:");
      WriteLine(File.ReadAllText(filePath));
      // read a compressed file
      WriteLine("Reading the compressed XML file:");
      file = File.Open(filePath, FileMode.Open);
      Stream decompressor = new GZipStream(file,
        CompressionMode.Decompress);
      using (decompressor)
      {
        using (XmlReader reader = XmlReader.Create(decompressor))
        {
          while (reader.Read()) // read the next XML node
          {
            // 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
            }
          }
        }
      }
    } 
    
  3. Program.cs中,保留对WorkWithXml的调用,并添加对WorkWithCompression的调用,如下面的代码中突出显示的那样:

    // WorkWithText();
    **WorkWithXml();**
    **WorkWithCompression();** 
    
  4. 运行代码并比较 XML 文件和压缩后的 XML 文件的大小。如以下编辑后的输出所示,压缩后的文件大小不到未压缩 XML 文件的一半。

    /Users/markjprice/Code/Chapter09/WorkingWithStreams/streams.xml contains 310 bytes.
    /Users/markjprice/Code/Chapter09/WorkingWithStreams/streams.gzip contains 150 bytes. 
    

使用 Brotli 算法压缩

在.NET Core 2.1 中,微软引入了 Brotli 压缩算法的实现。在性能上,Brotli 类似于 DEFLATE 和 GZIP 中使用的算法,但输出密度大约高出 20%。步骤如下:

  1. 修改WorkWithCompression方法,使其具有一个可选参数来指示是否应使用 Brotli,并默认使用 Brotli,如下面的代码中突出显示的那样:

    static void WorkWithCompression(**bool** **useBrotli =** **true**)
    {
      string fileExt = **useBrotli ?** **"brotli"** **:** **"gzip"****;**
      // compress the XML output
      string filePath = Combine(
        CurrentDirectory, $"streams.{fileExt}");
      FileStream file = File.Create(filePath);
     **Stream compressor;**
    **if** **(useBrotli)**
     **{**
     **compressor =** **new** **BrotliStream(file, CompressionMode.Compress);**
     **}**
    **else**
     **{**
     **compressor =** **new** **GZipStream(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
      // output all the contents of the compressed file
      WriteLine("{0} contains {1:N0} bytes.",
        filePath, new FileInfo(filePath).Length);
      WriteLine($"The compressed contents:");
      WriteLine(File.ReadAllText(filePath));
      // read a compressed file
      WriteLine("Reading the compressed XML file:");
      file = File.Open(filePath, FileMode.Open);
     **Stream decompressor;**
    **if** **(useBrotli)**
     **{**
     **decompressor =** **new** **BrotliStream(**
     **file, CompressionMode.Decompress);**
     **}**
    **else**
     **{**
     **decompressor =** **new** **GZipStream(**
     **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
            }
          }
        }
      }
    } 
    
  2. Program.cs顶部附近,调用WorkWithCompression两次,一次使用默认的 Brotli,一次使用 GZIP,如下面的代码所示:

    WorkWithCompression(); 
    WorkWithCompression(useBrotli: false); 
    
  3. 运行代码并比较两个压缩后的 XML 文件的大小。如以下编辑后的输出所示,Brotli 的密度高出 21%以上。

    /Users/markjprice/Code/Chapter09/WorkingWithStreams/streams.brotli contains 118 bytes.
    /Users/markjprice/Code/Chapter09/WorkingWithStreams/streams.gzip contains 150 bytes. 
    

编码和解码文本

文本字符可以用不同的方式表示。例如,字母表可以用摩尔斯电码编码成一系列点和划,以便通过电报线路传输。

类似地,计算机内部的文本以位(1 和 0)的形式存储,代表代码空间内的一个码位。大多数码位代表一个字符,但它们也可以有其他含义,如格式化。

例如,ASCII 有一个包含 128 个码位的代码空间。.NET 使用名为Unicode的标准来内部编码文本。Unicode 拥有超过一百万个码位。

有时,您需要将文本移出.NET,以便在不使用 Unicode 或使用 Unicode 变体的系统中使用,因此学习如何在编码之间转换非常重要。

下表列出了计算机常用的一些替代文本编码:

编码方式 描述
ASCII 此编码使用字节的低七位对有限范围的字符进行编码。
UTF-8 此方式将每个 Unicode 码位表示为一个至四个字节的序列。
UTF-7 与 UTF-8 相比,此方式在 7 位通道上更高效,但它存在安全性和健壮性问题,因此建议使用 UTF-8 而非 UTF-7。
UTF-16 此方式将每个 Unicode 码位表示为一个或两个 16 位整数的序列。
UTF-32 此方式将每个 Unicode 码位表示为一个 32 位整数,因此是一种固定长度编码,与其他所有变长 Unicode 编码不同。
ANSI/ISO 编码 这提供了对各种代码页的支持,这些代码页用于支持特定的语言或一组语言。

最佳实践:在当今大多数情况下,UTF-8 是一个好的默认选择,这也是它实际上是默认编码的原因,即Encoding.Default

将字符串编码为字节数组

让我们来探讨文本编码:

  1. 使用您喜欢的代码编辑器,在Chapter09解决方案/工作区中添加一个名为WorkingWithEncodings的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithEncodings作为活动 OmniSharp 项目。

  3. Program.cs中,导入System.Text命名空间并静态导入Console类。

  4. 添加语句以使用用户选择的编码对string进行编码,循环遍历每个字节,然后将其解码回string并输出,如下面的代码所示:

    WriteLine("Encodings"); 
    WriteLine("[1] ASCII");
    WriteLine("[2] UTF-7");
    WriteLine("[3] UTF-8");
    WriteLine("[4] UTF-16 (Unicode)");
    WriteLine("[5] UTF-32"); 
    WriteLine("[any other key] Default");
    // choose an encoding
    Write("Press a number to choose an encoding: "); 
    ConsoleKey number = ReadKey(intercept: false).Key; 
    WriteLine();
    WriteLine();
    Encoding encoder = number switch
    {
      ConsoleKey.D1 => Encoding.ASCII,
      ConsoleKey.D2 => Encoding.UTF7,
      ConsoleKey.D3 => Encoding.UTF8,
      ConsoleKey.D4 => Encoding.Unicode,
      ConsoleKey.D5 => Encoding.UTF32,
      _             => Encoding.Default
    };
    // define a string to encode
    string message = "Café cost: £4.39";
    // encode the string into a byte array
    byte[] encoded = encoder.GetBytes(message);
    // check how many bytes the encoding needed
    WriteLine("{0} uses {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.ToString("X"),4} {(char)b,5}");
    }
    // decode the byte array back into a string and display it
    string decoded = encoder.GetString(encoded); 
    WriteLine(decoded); 
    
  5. 运行代码并注意避免使用Encoding.UTF7的警告,因为它不安全。当然,如果您需要使用该编码生成文本以与其他系统兼容,它需要在.NET 中保持为选项。

  6. 按 1 选择 ASCII,并注意当输出字节时,英镑符号(£)和带重音的 e(é)无法在 ASCII 中表示,因此它使用问号代替。

    BYTE  HEX  CHAR
      67   43     C
      97   61     a
     102   66     f
      63   3F     ?
      32   20      
     111   6F     o
     115   73     s
     116   74     t
      58   3A     :
      32   20      
      63   3F     ?
      52   34     4
      46   2E     .
      51   33     3
      57   39     9
    Caf? cost: ?4.39 
    
  7. 重新运行代码并按 3 选择 UTF-8,注意 UTF-8 为需要两个字节的两个字符额外需要两个字节(总共 18 字节而不是 16 字节),但它可以编码和解码é和£字符。

    UTF8EncodingSealed uses 18 bytes.
    BYTE  HEX  CHAR
      67   43     C
      97   61     a
     102   66     f
     195   C3     Ã
     169   A9     ©
      32   20      
     111   6F     o
     115   73     s
     116   74     t
      58   3A     :
      32   20      
     194   C2     Â
     163   A3     £
      52   34     4
      46   2E     .
      51   33     3
      57   39     9
    Café cost: £4.39 
    
  8. 重新运行代码并按 4 选择 Unicode(UTF-16),注意 UTF-16 为每个字符需要两个字节,总共 32 字节,并且能够编码和解码é和£字符。这种编码被.NET 内部用于存储charstring值。

文件中的文本编码和解码

当使用流辅助类,如StreamReaderStreamWriter时,您可以指定要使用的编码。当您向辅助类写入时,文本将自动编码,当您从辅助类读取时,字节将自动解码。

要指定编码,请将编码作为第二个参数传递给辅助类型的构造函数,如下面的代码所示:

StreamReader reader = new(stream, Encoding.UTF8); 
StreamWriter writer = new(stream, Encoding.UTF8); 

良好实践:通常,您无法选择使用哪种编码,因为您将生成的文件供另一个系统使用。但是,如果可以,请选择使用最少字节数但能存储您所需所有字符的编码。

序列化对象图

序列化是将活动对象转换为使用指定格式的字节序列的过程。反序列化则是其逆过程。这样做是为了保存活动对象的当前状态,以便将来可以重新创建它。例如,保存游戏的当前状态,以便明天可以从同一位置继续。序列化的对象通常存储在文件或数据库中。

有数十种格式可供指定,但最常见的两种是可扩展标记语言XML)和JavaScript 对象表示法JSON)。

最佳实践:JSON 更紧凑,最适合 Web 和移动应用程序。XML 更冗长,但在更多遗留系统中得到更好的支持。使用 JSON 来最小化序列化对象图的大小。当向 Web 应用程序和移动应用程序发送对象图时,JSON 也是一个不错的选择,因为 JSON 是 JavaScript 的原生序列化格式,而移动应用程序通常通过有限的带宽进行调用,因此字节数很重要。

.NET 有多个类可以序列化和反序列化为 XML 和 JSON。我们将从XmlSerializerJsonSerializer开始。

序列化为 XML

让我们从 XML 开始,这可能是目前世界上最常用的序列化格式。为了展示一个典型的例子,我们将定义一个自定义类来存储有关人员的信息,然后使用嵌套的Person实例列表创建一个对象图:

  1. 使用您喜欢的代码编辑器,在Chapter09解决方案/工作区中添加一个名为WorkingWithSerialization的新控制台应用程序。

  2. 在 Visual Studio Code 中,选择WorkingWithSerialization作为活动 OmniSharp 项目。

  3. 添加一个名为Person的类,其中包含一个protectedSalary属性,这意味着它只能由自身和派生类访问。为了填充薪水,该类有一个构造函数,它有一个参数来设置初始薪水,如下所示:

    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; }
    } 
    
  4. Program.cs中,导入用于 XML 序列化的命名空间,并静态导入ConsoleEnvironmentPath类,如下所示:

    using System.Xml.Serialization; // XmlSerializer
    using Packt.Shared; // Person 
    using static System.Console; 
    using static System.Environment; 
    using static System.IO.Path; 
    
  5. 添加语句以创建Person实例的对象图,如下所示:

    // create an object graph
    List<Person> people = new()
    {
      new(30000M) 
      {
        FirstName = "Alice",
        LastName = "Smith",
        DateOfBirth = new(1974, 3, 14)
      },
      new(40000M) 
      {
        FirstName = "Bob",
        LastName = "Jones",
        DateOfBirth = new(1969, 11, 23)
      },
      new(20000M)
      {
        FirstName = "Charlie",
        LastName = "Cox",
        DateOfBirth = new(1984, 5, 4),
        Children = new()
        {
          new(0M)
          {
            FirstName = "Sally",
            LastName = "Cox",
            DateOfBirth = new(2000, 7, 12)
          }
        }
      }
    };
    // create object that will format a List of Persons as XML
    XmlSerializer xs = new(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);
    }
    WriteLine("Written {0:N0} bytes of XML to {1}",
      arg0: new FileInfo(path).Length,
      arg1: path);
    WriteLine();
    // Display the serialized object graph
    WriteLine(File.ReadAllText(path)); 
    
  6. 运行代码,查看结果,并注意抛出了一个异常,如下所示:

    Unhandled Exception: System.InvalidOperationException: Packt.Shared.Person cannot be serialized because it does not have a parameterless constructor. 
    
  7. Person中,添加一个语句来定义一个无参数构造函数,如下所示:

    public Person() { } 
    

    构造函数无需执行任何操作,但它必须存在,以便XmlSerializer在反序列化过程中调用它来实例化新的Person实例。

  8. 重新运行代码并查看结果,并注意对象图被序列化为 XML 元素,如<FirstName>Bob</FirstName>,并且Salary属性未被包含,因为它不是public属性,如下所示:

    Written 752 bytes of XML to
    /Users/markjprice/Code/Chapter09/WorkingWithSerialization/people.xml
    <?xml version="1.0"?>
    <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>2000-07-12T00:00:00</DateOfBirth>
          </Person>
        </Children>
      </Person>
    </ArrayOfPerson> 
    

生成紧凑的 XML

我们可以使用属性而不是元素来使 XML 更紧凑:

  1. Person中,导入System.Xml.Serialization命名空间,以便您可以为某些属性装饰[XmlAttribute]属性。

  2. 使用[XmlAttribute]属性修饰名字、姓氏和出生日期属性,并为每个属性设置一个简短名称,如下所示:

    **[****XmlAttribute(****"fname"****)****]**
    public string FirstName { get; set; }
    **[****XmlAttribute(****"lname"****)****]**
    public string LastName { get; set; }
    **[****XmlAttribute(****"dob"****)****]**
    public DateTime DateOfBirth { get; set; } 
    
  3. 运行代码并注意文件大小已从 752 字节减少到 462 字节,通过将属性值输出为 XML 属性,节省了超过三分之一的存储空间,如下所示:

    Written 462 bytes of XML to /Users/markjprice/Code/Chapter09/ WorkingWithSerialization/people.xml
    <?xml version="1.0"?>
    <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="2000-07-12T00:00:00" />
        </Children>
      </Person>
    </ArrayOfPerson> 
    

反序列化 XML 文件

现在让我们尝试将 XML 文件反序列化回内存中的活动对象:

  1. 添加语句以打开 XML 文件,然后对其进行反序列化,如下面的代码所示:

    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. 
    

还有许多其他属性可用于控制生成的 XML。

如果不使用任何注解,XmlSerializer在反序列化时会使用属性名称进行大小写不敏感匹配。

良好实践:使用XmlSerializer时,请记住只有公共字段和属性会被包含,且类型必须有一个无参构造函数。你可以通过属性来自定义输出。

使用 JSON 进行序列化

处理 JSON 序列化格式的最流行的.NET 库之一是 Newtonsoft.Json,也称为 Json.NET。它成熟且功能强大。让我们看看它的实际应用:

  1. WorkingWithSerialization项目中,添加对Newtonsoft.Json最新版本的包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Newtonsoft.Json" 
        Version="13.0.1" />
    </ItemGroup> 
    
  2. 构建WorkingWithSerialization项目以恢复包。

  3. Program.cs中,添加语句以创建一个文本文件,然后将人员序列化为 JSON 文件,如下面的代码所示:

    // create a file to write to
    string jsonPath = Combine(CurrentDirectory, "people.json");
    using (StreamWriter jsonStream = File.CreateText(jsonPath))
    {
      // create an object that will format as JSON
      Newtonsoft.Json.JsonSerializer jss = new();
      // serialize the object graph into a string
      jss.Serialize(jsonStream, people);
    }
    WriteLine();
    WriteLine("Written {0:N0} bytes of JSON to: {1}",
      arg0: new FileInfo(jsonPath).Length,
      arg1: jsonPath);
    // Display the serialized object graph
    WriteLine(File.ReadAllText(jsonPath)); 
    
  4. 运行代码并注意,与包含元素的 XML 相比,JSON 所需的字节数不到一半。它甚至比使用属性的 XML 文件还要小,如下面的输出所示:

    Written 366 bytes of JSON to: /Users/markjprice/Code/Chapter09/ WorkingWithSerialization/people.json [{"FirstName":"Alice","LastName":"Smith","DateOfBirth":"1974-03-
    14T00:00:00","Children":null},{"FirstName":"Bob","LastName":"Jones","Date
    OfBirth":"1969-11-23T00:00:00","Children":null},{"FirstName":"Charlie","L astName":"Cox","DateOfBirth":"1984-05-04T00:00:00","Children":[{"FirstNam e":"Sally","LastName":"Cox","DateOfBirth":"2000-07-12T00:00:00","Children ":null}]}] 
    

高性能的 JSON 处理

.NET Core 3.0 引入了一个新的命名空间来处理 JSON,System.Text.Json,它通过利用Span<T>等 API 优化了性能。

此外,像 Json.NET 这样的旧库是基于读取 UTF-16 实现的。使用 UTF-8 读写 JSON 文档将更高效,因为大多数网络协议,包括 HTTP,都使用 UTF-8,你可以避免将 UTF-8 从 Json.NET 的 Unicode string值进行转码。

根据不同场景,微软通过新 API 实现了 1.3 倍至 5 倍的性能提升。

Json.NET 的原作者 James Newton-King 加入了微软,并与他们合作开发了新的 JSON 类型。正如他在讨论新 JSON API 的评论中所说,“Json.NET 不会消失”,如图9.3所示:

图形用户界面,文本,应用程序,电子邮件 描述自动生成

图 9.3:Json.NET 原作者的一条评论

让我们看看如何使用新的 JSON API 来反序列化 JSON 文件:

  1. WorkingWithSerialization项目中,在Program.cs中,导入新的 JSON 类以进行序列化,使用别名以避免与我们之前使用的 Json.NET 名称冲突,如下面的代码所示:

    using NewJson = System.Text.Json.JsonSerializer; 
    
  2. 添加语句以打开 JSON 文件,反序列化它,并输出人员的姓名和子女数量,如下面的代码所示:

    using (FileStream jsonLoad = File.Open(jsonPath, FileMode.Open))
    {
      // deserialize object graph into a List of Person
      List<Person>? loadedPeople = 
        await NewJson.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

控制 JSON 处理

控制 JSON 处理的方式有很多选项,如下所示:

  • 包括与排除字段。

  • 设置大小写策略。

  • 选择大小写敏感策略。

  • 选择紧凑与美化空白。

让我们看看一些实际操作:

  1. 使用您偏好的代码编辑器,在Chapter09解决方案/工作区中添加一个名为WorkingWithJson的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithJson作为活动 OmniSharp 项目。

  3. WorkingWithJson项目中,在Program.cs中,删除现有代码,导入用于处理 JSON 的两个主要命名空间,然后静态导入System.ConsoleSystem.EnvironmentSystem.IO.Path类型,如下所示:

    using System.Text.Json; // JsonSerializer
    using System.Text.Json.Serialization; // [JsonInclude]
    using static System.Console;
    using static System.Environment;
    using static System.IO.Path; 
    
  4. Program.cs底部,定义一个名为Book的类,如下所示:

    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 DateOnly PublishDate;
      [JsonInclude] // include this field
      public DateTimeOffset Created;
      public ushort Pages;
    } 
    
  5. Book类上方,添加语句以创建Book类的一个实例并将其序列化为 JSON,如下所示:

    Book csharp10 = new(title: 
      "C# 10 and .NET 6 - Modern Cross-platform Development")
    { 
      Author = "Mark J Price",
      PublishDate = new(year: 2021, month: 11, day: 9),
      Pages = 823,
      Created = DateTimeOffset.UtcNow,
    };
    JsonSerializerOptions options = new()
    {
      IncludeFields = true, // includes all fields
      PropertyNameCaseInsensitive = true,
      WriteIndented = true,
      PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    };
    string filePath = Combine(CurrentDirectory, "book.json");
    using (Stream fileStream = File.Create(filePath))
    {
      JsonSerializer.Serialize<Book>(
        utf8Json: fileStream, value: csharp10, options);
    }
    WriteLine("Written {0:N0} bytes of JSON to {1}",
      arg0: new FileInfo(filePath).Length,
      arg1: filePath);
    WriteLine();
    // Display the serialized object graph 
    WriteLine(File.ReadAllText(filePath)); 
    
  6. 运行代码并查看结果,如下所示:

    Written 315 bytes of JSON to C:\Code\Chapter09\WorkingWithJson\bin\Debug\net6.0\book.json
    {
      "title": "C# 10 and .NET 6 - Modern Cross-platform Development",
      "author": "Mark J Price",
      "publishDate": {
        "year": 2021,
        "month": 11,
        "day": 9,
        "dayOfWeek": 2,
        "dayOfYear": 313,
        "dayNumber": 738102
      },
      "created": "2021-08-20T08:07:02.3191648+00:00",
      "pages": 823
    } 
    

    注意以下事项:

    • JSON 文件大小为 315 字节。

    • 成员名称使用驼峰式大小写,例如publishDate。这对后续在 JavaScript 浏览器中处理最为有利。

    • 由于设置的选项,所有字段均被包含,包括pages

    • JSON 被美化以提高人类可读性。

    • DateTimeOffset值以单一标准字符串格式存储。

    • DateOnly值存储为具有yearmonth等日期部分的子属性的对象。

  7. Program.cs中,设置JsonSerializerOptions时,注释掉大小写策略设置,写入缩进,并包含字段。

  8. 运行代码并查看结果,如下所示:

    Written 230 bytes of JSON to C:\Code\Chapter09\WorkingWithJson\bin\Debug\net6.0\book.json
    {"Title":"C# 10 and .NET 6 - Modern Cross-platform Development","Author":"Mark J Price","PublishDate":{"Year":2021,"Month":11,"Day":9,"DayOfWeek":2,"DayOfYear":313,"DayNumber":738102},"Created":"2021-08-20T08:12:31.6852484+00:00"} 
    

    注意以下事项:

    • JSON 文件大小为 230 字节,减少了超过 25%。

    • 成员名称使用正常大小写,例如PublishDate

    • Pages字段缺失。其他字段因PublishDateCreated字段上的[JsonInclude]属性而被包含。

    • JSON 紧凑,空白字符最少,以节省传输或存储的带宽。

用于处理 HTTP 响应的新 JSON 扩展方法

在.NET 5 中,微软对System.Text.Json命名空间中的类型进行了改进,例如为HttpResponse添加了扩展方法,您将在第十六章构建和消费 Web 服务中看到。

从 Newtonsoft 迁移到新 JSON

如果您现有的代码使用了 Newtonsoft Json.NET 库,并希望迁移到新的System.Text.Json命名空间,那么微软有专门的文档指导,您可以在以下链接找到:

docs.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-migrate-from-newtonsoft-how-to

实践与探索

通过回答问题、进行实践操作并深入研究本章主题,测试你的知识和理解。

练习 9.1 – 测试你的知识

回答以下问题:

  1. 使用File类和FileInfo类有何不同?

  2. 流(stream)的ReadByte方法与Read方法有何区别?

  3. 何时使用StringReaderTextReaderStreamReader类?

  4. DeflateStream类型有何作用?

  5. UTF-8 编码每个字符使用多少字节?

  6. 对象图(object graph)是什么?

  7. 哪种序列化格式最适合用于最小化空间需求?

  8. 哪种序列化格式最适合用于跨平台兼容性?

  9. 为何使用string值如"\Code\Chapter01"来表示路径是不妥的,应如何替代?

  10. 关于 NuGet 包及其依赖关系的信息在哪里可以找到?

练习 9.2 – 实践 XML 序列化

Chapter09解决方案/工作区中,创建一个名为Exercise02的控制台应用程序,该程序创建一个形状列表,使用序列化将其保存到文件系统中(使用 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 }
}; 

形状(Shapes)应有一个名为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 – 探索主题

利用以下页面上的链接,深入了解本章涉及的主题:

第九章 - 文件、流和序列化

总结

本章中,你学习了如何读写文本文件和 XML 文件,如何压缩和解压缩文件,如何编码和解码文本,以及如何将对象序列化为 JSON 和 XML(并反序列化回来)。

下一章,你将学习如何使用 Entity Framework Core 操作数据库。

第十章:使用 Entity Framework Core 处理数据

本章是关于使用名为实体框架核心EF Core)的对象到数据存储映射技术,对数据存储(如 Microsoft SQL Server、SQLite 和 Azure Cosmos DB)进行读写。

本章将涵盖以下主题:

  • 理解现代数据库

  • 设置 EF Core

  • 定义 EF Core 模型

  • EF Core 模型的查询

  • EF Core 中的加载模式

  • EF Core 中的数据操作

  • 事务处理

  • Code First EF Core 模型

理解现代数据库

存储数据最常见的两种地方是关系数据库管理系统RDBMS),如 Microsoft SQL Server、PostgreSQL、MySQL 和 SQLite,或者NoSQL数据库,如 Microsoft Azure Cosmos DB、Redis、MongoDB 和 Apache Cassandra。

理解遗留的实体框架

实体框架EF)最初作为.NET Framework 3.5 的一部分,在 2008 年底随 Service Pack 1 发布。自那时起,微软观察到程序员如何在现实世界中使用对象关系映射ORM)工具,实体框架也随之演进。

ORMs 利用映射定义将表中的列关联到类的属性上。这样,程序员就可以用他们熟悉的方式与不同类型的对象进行交互,而不必处理如何将值存储在关系表或 NoSQL 数据存储提供的其他结构中。

.NET Framework 中包含的 EF 版本是实体框架 6EF6)。它成熟、稳定,并支持 EDMX(XML 文件)方式定义模型以及复杂的继承模型,以及其他一些高级功能。

EF 6.3 及更高版本已从.NET Framework 中提取出来,作为一个独立包,以便支持.NET Core 3.0 及更高版本。这使得现有的项目,如 Web 应用程序和服务,能够移植并在跨平台上运行。然而,EF6 应被视为一种遗留技术,因为它在跨平台运行时存在一些限制,并且不会再添加新功能。

使用遗留的实体框架 6.3 或更高版本

要在.NET Core 3.0 或更高版本的项目中使用遗留的实体框架,你必须在你的项目文件中添加对该包的引用,如下面的标记所示:

<PackageReference Include="EntityFramework" Version="6.4.4" /> 

最佳实践:仅在必要时使用遗留的 EF6,例如,当迁移使用它的 WPF 应用程序时。本书是关于现代跨平台开发的,因此在本章的其余部分,我将只介绍现代的实体框架核心。你不需要像上面那样在为本章项目引用遗留的 EF6 包。

理解实体框架核心

真正的跨平台版本,EF Core,与遗留的实体框架不同。尽管 EF Core 名称相似,但你应该意识到它与 EF6 的差异。最新的 EF Core 版本是 6.0,以匹配.NET 6.0。

EF Core 5 及更高版本仅支持 .NET 5 及更高版本。EF Core 3.0 及更高版本仅在支持 .NET Standard 2.1 的平台(即 .NET Core 3.0 及更高版本)上运行。它不支持 .NET Standard 2.0 平台,如 .NET Framework 4.8。

除了传统的关系数据库管理系统,EF Core 还支持现代基于云的、非关系型的、无模式的数据存储,如 Microsoft Azure Cosmos DB 和 MongoDB,有时通过第三方提供商支持。

EF Core 有许多改进,本章无法涵盖所有内容。我将重点介绍所有 .NET 开发者应该了解的基础知识以及一些较新的酷炫功能。

与 EF Core 工作有两种方法:

  1. 数据库先行:数据库已经存在,因此你构建一个与数据库结构和特性相匹配的模型。

  2. 代码先行:不存在数据库,因此你先构建一个模型,然后使用 EF Core 创建一个与该模型结构和特性相匹配的数据库。

我们将从使用 EF Core 与现有数据库开始。

创建一个用于与 EF Core 工作的控制台应用

首先,我们将为本章创建一个控制台应用项目:

  1. 使用你偏好的代码编辑器创建一个名为 Chapter10 的新解决方案/工作区。

  2. 添加一个控制台应用项目,如下表所示:

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

    2. 工作区/解决方案文件和文件夹:Chapter10

    3. 项目文件和文件夹:WorkingWithEFCore

使用示例关系数据库

为了学习如何使用 .NET 管理关系数据库管理系统,拥有一个示例数据库会很有帮助,这样你就可以在一个中等复杂度和适当数量的示例记录上练习。微软提供了几个示例数据库,其中大多数对于我们的需求来说太复杂了,因此我们将使用一个在 20 世纪 90 年代初首次创建的数据库,称为 Northwind

让我们花一分钟时间查看 Northwind 数据库的图表。你可以使用以下图表作为参考,在我们编写本书中的代码和查询时:

图表描述自动生成

图 10.1:Northwind 数据库表及其关系

你将在本章后面编写代码以与 CategoriesProducts 表交互,并在后续章节中与其他表交互。但在我们开始之前,请注意:

  • 每个类别都有一个唯一的标识符、名称、描述和图片。

  • 每个产品都有一个唯一的标识符、名称、单价、库存单位以及其他字段。

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

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

使用 Microsoft SQL Server for Windows

微软为其流行且功能强大的 SQL Server 产品提供了多种版本,适用于 Windows、Linux 和 Docker 容器。我们将使用一个可以独立运行的免费版本,称为 SQL Server 开发者版。你也可以使用 Express 版或与 Windows 上的 Visual Studio 一起安装的免费 SQL Server LocalDB 版。

如果您没有 Windows 电脑或希望使用跨平台数据库系统,则可以跳至主题使用 SQLite

下载并安装 SQL Server。

您可以从以下链接下载 SQL Server 版本:

www.microsoft.com/en-us/sql-server/sql-server-downloads

  1. 下载开发者版本。

  2. 运行安装程序。

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

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

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

  6. SQL Server 安装中心中,点击安装,然后点击新 SQL Server 独立安装或向现有安装添加功能

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

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

  9. 审查安装规则,解决任何问题,然后点击下一步

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

  11. 实例配置中,选择默认实例,然后点击下一步。如果您已有默认实例配置,则可以创建一个命名实例,可能名为cs10dotnet6

  12. 服务器配置中,注意SQL Server 数据库引擎已设置为自动启动。将SQL Server 浏览器设置为自动启动,然后点击下一步

  13. 数据库引擎配置中,在服务器配置标签页,设置认证模式混合,设置sa账户密码为强密码,点击添加当前用户,然后点击下一步

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

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

  16. SQL Server 安装中心中,点击安装,然后选择安装 SQL Server 管理工具

  17. 在浏览器窗口中,点击下载最新版本的 SSMS。

  18. 运行安装程序并点击安装

  19. 安装程序完成后,如有需要点击重启或点击关闭

为 SQL Server 创建 Northwind 示例数据库。

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

  1. 如果您之前未下载或克隆本书的 GitHub 仓库,请使用以下链接进行操作:github.com/markjprice/cs10dotnet6/

  2. 从本地 Git 仓库的以下路径复制创建 Northwind 数据库的脚本:/sql-scripts/Northwind4SQLServer.sqlWorkingWithEFCore文件夹。

  3. 启动SQL Server Management Studio

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

    如果您需要创建一个命名实例,如cs10dotnet6,则输入.\cs10dotnet6

  5. 导航至文件 | 打开 | 文件...

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

  7. 在工具栏上,点击执行,并注意显示的命令已成功完成消息。

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

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

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

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

  11. 退出 SQL Server Management Studio。

使用服务器资源管理器管理 Northwind 示例数据库

我们无需使用 SQL Server Management Studio 来执行数据库脚本。我们还可以使用 Visual Studio 中的工具,包括SQL Server 对象资源管理器服务器资源管理器

  1. 在 Visual Studio 中,选择视图 | 服务器资源管理器

  2. 服务器资源管理器窗口中,右键点击数据连接并选择添加连接...

  3. 如果您看到如图10.3所示的选择数据源对话框,请选择Microsoft SQL Server,然后点击继续图形用户界面,应用程序 自动生成描述

    图 10.3:选择 SQL Server 作为数据源

  4. 添加连接对话框中,将服务器名称输入为.,将数据库名称输入为Northwind,然后点击确定

  5. 服务器资源管理器中,展开数据连接及其表。您应该能看到 13 个表,包括类别产品表。

  6. 右键点击产品表,选择显示表数据,并注意返回的 77 行产品数据。

  7. 要查看产品表的列和类型详细信息,右键点击产品并选择打开表定义,或在服务器资源管理器中双击该表。

使用 SQLite

SQLite 是一个小巧、跨平台、自包含的 RDBMS,属于公共领域。它是移动平台如 iOS(iPhone 和 iPad)和 Android 上最常用的 RDBMS。即使您使用 Windows 并在前一节中设置了 SQL Server,您可能也想设置 SQLite。我们编写的代码将与两者兼容,观察它们之间的细微差别也颇有趣味。

在 macOS 上设置 SQLite

SQLite 作为命令行应用程序sqlite3包含在 macOS 的/usr/bin/目录中。

在 Windows 上设置 SQLite

在 Windows 上,我们需要将 SQLite 文件夹添加到系统路径中,以便在命令提示符或终端输入命令时能够找到它:

  1. 打开您喜欢的浏览器并导航至以下链接:www.sqlite.org/download.html

  2. 向下滚动页面至Windows 预编译二进制文件部分。

  3. 点击sqlite-tools-win32-x86-3360000.zip。请注意,文件版本号可能在此书出版后有所更新。

  4. 将 ZIP 文件解压到一个名为C:\Sqlite\的文件夹中。

  5. 导航至Windows 设置

  6. 搜索环境并选择编辑系统环境变量。在非英语版本的 Windows 上,请搜索您本地语言中的等效词汇以找到该设置。

  7. 点击环境变量按钮。

  8. 系统变量中,从列表中选择路径,然后点击编辑…

  9. 点击新建,输入C:\Sqlite,然后按回车键。

  10. 点击确定

  11. 点击确定

  12. 点击确定

  13. 关闭Windows 设置

为其他操作系统设置 SQLite

SQLite 可以从以下链接下载并安装在其他操作系统上:www.sqlite.org/download.html

创建 SQLite 的 Northwind 示例数据库

现在我们可以使用 SQL 脚本为 SQLite 创建 Northwind 示例数据库:

  1. 如果您之前未克隆本书的 GitHub 仓库,请现在使用以下链接进行克隆:github.com/markjprice/cs10dotnet6/

  2. 从本地 Git 仓库的以下路径复制创建 Northwind 数据库的脚本:/sql-scripts/Northwind4SQLite.sql,将其粘贴到WorkingWithEFCore文件夹中。

  3. WorkingWithEFCore文件夹中启动命令行:

    1. 在 Windows 上,启动文件资源管理器,右键点击WorkingWithEFCore文件夹,选择在此处打开命令提示符在 Windows 终端中打开

    2. 在 macOS 上,启动Finder,右键点击WorkingWithEFCore文件夹,选择在此处新建终端

  4. 执行以下命令,使用 SQLite 运行 SQL 脚本并创建Northwind.db数据库:

    sqlite3 Northwind.db -init Northwind4SQLite.sql 
    
  5. 请耐心等待,因为此命令可能需要一段时间来创建数据库结构。最终,您将看到 SQLite 命令提示符,如下所示:

    -- Loading resources from Northwind4SQLite.sql 
    SQLite version 3.36.0 2021-08-24 15:20:15
    Enter ".help" for usage hints.
    sqlite> 
    
  6. 在 Windows 上按 Ctrl + C 或在 macOS 上按 Ctrl + D 退出 SQLite 命令模式。

  7. 保持终端或命令提示符窗口打开,因为您很快会再次用到它。

使用 SQLiteStudio 管理 Northwind 示例数据库

您可以使用跨平台的图形数据库管理器SQLiteStudio轻松管理 SQLite 数据库:

  1. 访问以下链接:sqlitestudio.pl,下载并解压应用程序至您偏好的位置。

  2. 启动SQLiteStudio

  3. 数据库菜单中,选择添加数据库

  4. 数据库对话框中,在文件部分,点击黄色文件夹按钮浏览本地计算机上的现有数据库文件,选择WorkingWithEFCore文件夹中的Northwind.db文件,然后点击确定

  5. 右键点击Northwind数据库,选择连接到数据库。您将看到由脚本创建的 10 个表。(SQLite 的脚本比 SQL Server 的简单;它没有创建那么多表或其他数据库对象。)

  6. 右键点击产品表,选择编辑表

  7. 在表编辑器窗口中,注意 Products 表的结构,包括列名、数据类型、键和约束,如图 10.4 所示:图形用户界面,文本,应用程序 描述自动生成

    图 10.4:SQLiteStudio 中的表编辑器显示产品表的结构

  8. 在表编辑器窗口中,点击数据选项卡,您将看到 77 种产品,如图 10.5 所示:图形用户界面,文本,应用程序 描述自动生成

    图 10.5:数据选项卡显示产品表中的行

  9. 数据库窗口中,右键点击Northwind并选择断开与数据库的连接

  10. 退出 SQLiteStudio。

设置 EF Core

在我们深入探讨使用 EF Core 管理数据的实际操作之前,让我们简要讨论一下在 EF Core 数据提供者之间进行选择的问题。

选择 EF Core 数据库提供者

为了管理特定数据库中的数据,我们需要知道如何高效地与该数据库通信的类。

EF Core 数据库提供者是一组针对特定数据存储优化的类。甚至还有一个提供者用于在当前进程的内存中存储数据,这对于高性能单元测试非常有用,因为它避免了访问外部系统。

它们作为 NuGet 包分发,如下表所示:

要管理此数据存储 安装此 NuGet 包
Microsoft SQL Server 2012 或更高版本 Microsoft.EntityFrameworkCore.SqlServer
SQLite 3.7 或更高版本 Microsoft.EntityFrameworkCore.SQLite
MySQL MySQL.Data.EntityFrameworkCore
内存中 Microsoft.EntityFrameworkCore.InMemory
Azure Cosmos DB SQL API Microsoft.EntityFrameworkCore.Cosmos
Oracle DB 11.2 Oracle.EntityFrameworkCore

您可以在同一项目中安装所需数量的 EF Core 数据库提供者。每个包都包括共享类型以及提供者特定的类型。

连接到数据库

要连接到 SQLite 数据库,我们只需要知道数据库文件名,使用参数 Filename 设置。

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

  • 服务器名称(如果有实例,则包括实例)。

  • 数据库名称。

  • 安全信息,例如用户名和密码,或者我们是否应该自动传递当前登录用户的凭据。

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

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

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

  • Initial Catalogdatabase:这些关键字是数据库名称。

  • Integrated Securitytrusted_connection:这些关键字设置为trueSSPI,以传递线程当前用户凭据。

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

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

SQL Server 版本 服务器名称\实例名称
LocalDB 2012 (localdb)\v11.0
LocalDB 2016 或更高版本 (localdb)\mssqllocaldb
Express .\sqlexpress
Full/Developer(默认实例) .
Full/Developer(命名实例) .\cs10dotnet6

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

定义 Northwind 数据库上下文类

Northwind类将用于表示数据库。要使用 EF Core,该类必须继承自DbContext。此类知道如何与数据库通信并动态生成 SQL 语句以查询和操作数据。

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

为了方便您尝试 SQLite 和 SQL Server,我们将创建一个支持两者的项目,并使用一个string字段在运行时控制使用哪一个:

  1. WorkingWithEFCore项目中,添加对 EF Core 数据提供程序的包引用,包括 SQL Server 和 SQLite,如下面的标记所示:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Sqlite" 
        Version="6.0.0" />
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.SqlServer" 
        Version="6.0.0" />
    </ItemGroup> 
    
  2. 构建项目以恢复包。

  3. 添加一个名为ProjectConstants.cs的类文件。

  4. ProjectConstants.cs中,定义一个具有公共字符串常量的类,以存储您想要使用的数据库提供程序名称,如下面的代码所示:

    namespace Packt.Shared;
    public class ProjectConstants
    {
      public const string DatabaseProvider = "SQLite"; // or "SQLServer"
    } 
    
  5. Program.cs中,导入Packt.Shared命名空间并输出数据库提供程序,如下面的代码所示:

    WriteLine($"Using {ProjectConstants.DatabaseProvider} database provider."); 
    
  6. 添加一个名为Northwind.cs的类文件。

  7. Northwind.cs中,定义一个名为Northwind的类,导入 EF Core 的主命名空间,使该类继承自DbContext,并在OnConfiguring方法中,检查provider字段以使用 SQLite 或 SQL Server,如下面的代码所示:

    using Microsoft.EntityFrameworkCore; // DbContext, DbContextOptionsBuilder
    using static System.Console;
    namespace Packt.Shared;
    // this manages the connection to the database
    public class Northwind : DbContext
    {
      protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
      {
        if (ProjectConstants.DatabaseProvider == "SQLite")
        {
          string path = Path.Combine(
            Environment.CurrentDirectory, "Northwind.db");
          WriteLine($"Using {path} database file.");
          optionsBuilder.UseSqlite($"Filename={path}");
        }
        else
        {
          string connection = "Data Source=.;" + 
            "Initial Catalog=Northwind;" + 
            "Integrated Security=true;" +
            "MultipleActiveResultSets=true;";
          optionsBuilder.UseSqlServer(connection);
        }
      }
    } 
    

    如果您使用的是 Windows 上的 Visual Studio,则编译后的应用程序在WorkingWithEFCore\bin\Debug\net6.0文件夹中执行,因此它将找不到数据库文件。

  8. 解决方案资源管理器中,右键单击Northwind.db文件并选择属性

  9. 属性中,将复制到输出目录设置为始终复制

  10. 打开WorkingWithEFCore.csproj并注意新元素,如下面的标记所示:

    <ItemGroup>
      <None Update="Northwind.db">
        <CopyToOutputDirectory>Always</CopyToOutputDirectory>
      </None>
    </ItemGroup> 
    

    如果你使用的是 Visual Studio Code,那么编译后的应用程序将在 WorkingWithEFCore 文件夹中执行,因此无需复制即可找到数据库文件。

  11. 运行控制台应用程序并注意输出,显示你选择的哪个数据库提供程序。

定义 EF Core 模型

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

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

使用 EF Core 约定定义模型

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

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

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

  • .NET 中的 string 类型默认在数据库中为 nvarchar 类型。

  • .NET 中的 int 类型默认在数据库中为 int 类型。

  • 主键默认是名为 IdID 的属性,或者当实体模型类名为 Product 时,属性可以名为 ProductIdProductID。如果此属性是整数类型或 Guid 类型,则还假定它为 IDENTITY 列(插入时自动赋值的列类型)。

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

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

常规往往不足以完全映射类到数据库对象。为模型添加更多智能的一种简单方法是应用注解属性。

下表展示了一些常见属性:

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

例如,在数据库中,产品名称的最大长度为 40,且值不能为空,如下所示,数据定义语言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; } 

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

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

Description列可能比nvarchar变量可以存储的最大 8,000 个字符更长,因此需要映射到ntext,如下面的代码所示:

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

使用 EF Core Fluent API 定义模型

定义模型的最后一种方式是使用 Fluent API。此 API 可以替代属性使用,也可以与属性一起使用。例如,为了定义ProductName属性,而不是用两个属性装饰该属性,可以在数据库上下文类的OnModelCreating方法中编写等效的 Fluent API 语句,如下面的代码所示:

modelBuilder.Entity<Product>()
  .Property(product => product.ProductName)
  .IsRequired()
  .HasMaxLength(40); 

这使得实体模型类更简单。

理解使用 Fluent API 进行数据播种

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

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

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

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

为 Northwind 表构建 EF Core 模型

现在你已经了解了定义 EF Core 模型的方法,让我们构建一个模型来表示Northwind数据库中的两个表。

两个实体类将相互引用,为了避免编译器错误,我们将首先创建不包含任何成员的类:

  1. WorkingWithEFCore项目中,添加两个名为Category.csProduct.cs的类文件。

  2. Category.cs中,定义一个名为Category的类,如下面的代码所示:

    namespace Packt.Shared;
    public class Category
    {
    } 
    
  3. Product.cs中,定义一个名为Product的类,如下面的代码所示:

    namespace Packt.Shared;
    public class Product
    {
    } 
    

定义 Category 和 Product 实体类

Category类,也称为实体模型,将用于表示Categories表中的一行。该表有四列,如下面的 DDL 所示:

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. 修改Category实体模型类,如下所示:

    using System.ComponentModel.DataAnnotations.Schema; // [Column]
    namespace Packt.Shared;
    public class Category
    {
      // these properties map to columns in the database
      public int CategoryId { get; set; }
      public string? CategoryName { get; set; }
      [Column(TypeName = "ntext")]
      public string? Description { get; set; }
      // defines a navigation property for related rows
      public virtual ICollection<Product> Products { get; set; }
      public Category()
      {
        // to enable developers to add products to a Category we must
        // initialize the navigation property to an empty collection
        Products = new HashSet<Product>();
      }
    } 
    

    Product类将用于表示Products表中的一行,该表有十列。

    你不需要将表中的所有列都作为类的属性包含在内。我们只会映射六个属性:ProductIdProductNameUnitPriceUnitsInStockDiscontinuedCategoryId

    未映射到属性的列不能通过类实例读取或设置。如果你使用该类创建一个新对象,那么表中新行的未映射列值将为NULL或其他默认值。你必须确保这些缺失的列是可选的,或者由数据库设置了默认值,否则在运行时会抛出异常。在这种情况下,行中已有数据值,我已决定在本应用程序中不需要读取这些值。

    我们可以通过定义一个不同名称的属性,如Cost,然后使用[Column]属性装饰该属性并指定其列名称,如UnitPrice,来重命名一列。

    最后一个属性CategoryId与一个Category属性关联,该属性将用于将每个产品映射到其父类别。

  2. 修改Product类,如下所示:

    using System.ComponentModel.DataAnnotations; // [Required], [StringLength]
    using System.ComponentModel.DataAnnotations.Schema; // [Column]
    namespace Packt.Shared;
    public class Product
    {
      public int ProductId { get; set; } // primary key
      [Required]
      [StringLength(40)]
      public string ProductName { get; set; } = null!;
      [Column("UnitPrice", TypeName = "money")]
      public decimal? Cost { get; set; } // property name != column name
      [Column("UnitsInStock")]
      public short? Stock { get; set; }
      public bool Discontinued { get; set; }
      // these two define the foreign key relationship
      // to the Categories table
      public int CategoryId { get; set; }
      public virtual Category Category { get; set; } = null!;
    } 
    

关联两个实体的两个属性,Category.ProductsProduct.Category,都被标记为virtual。这使得 EF Core 能够继承并重写这些属性,以提供额外功能,如延迟加载。

向 Northwind 数据库上下文类添加表

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

DbContext派生类可以选择性地重写名为OnModelCreating的方法。在这里,你可以编写 Fluent API 语句,作为用属性装饰实体类的一种替代方法。

让我们来写一些代码:

  1. 修改Northwind类,添加语句以定义两个表的两个属性及一个OnModelCreating方法,如下所示,高亮部分:

    public class Northwind : DbContext
    {
    **// these 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****);**
    **if** **(ProjectConstants.DatabaseProvider ==** **"SQLite"****)**
     **{**
    **// added to "fix" the lack of decimal support in SQLite**
     **modelBuilder.Entity<Product>()**
     **.Property(product => product.Cost)**
     **.HasConversion<****double****>();**
     **}**
     **}**
    } 
    

EF Core 3.0 及更高版本中,SQLite 数据库提供程序不支持decimal类型进行排序和其他操作。我们可以通过告诉模型在使用 SQLite 数据库提供程序时decimal值可以转换为double值来解决这个问题。这实际上在运行时不会执行任何转换。

既然你已经看到了手动定义实体模型的一些示例,让我们来看一个能为你完成部分工作的工具。

设置 dotnet-ef 工具

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

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

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

    dotnet tool list --global 
    
  2. 检查列表中是否已安装了旧版本的工具,例如 .NET Core 3.1 的版本,如以下输出所示:

    Package Id      Version     Commands
    -------------------------------------
    dotnet-ef       3.1.0       dotnet-ef 
    
  3. 如果已安装旧版本,则卸载该工具,如以下命令所示:

    dotnet tool uninstall --global dotnet-ef 
    
  4. 安装最新版本,如以下命令所示:

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

使用现有数据库脚手架模型

脚手架是指使用工具创建表示现有数据库模型的类的过程,使用逆向工程。一个好的脚手架工具允许您扩展自动生成的类,然后重新生成这些类而不会丢失您的扩展类。

如果你确定永远不会使用工具重新生成这些类,那么请随意修改自动生成的类的代码。工具生成的代码只是最佳近似。

良好实践:当你更了解情况时,不要害怕推翻工具的建议。

让我们看看工具是否生成了与我们手动创建相同的模型:

  1. Microsoft.EntityFrameworkCore.Design包添加到WorkingWithEFCore项目中。

  2. WorkingWithEFCore文件夹中的命令提示符或终端中,为CategoriesProducts表在新文件夹AutoGenModels中生成模型,如以下命令所示:

    dotnet ef dbcontext scaffold "Filename=Northwind.db" Microsoft.EntityFrameworkCore.Sqlite --table Categories --table Products --output-dir AutoGenModels --namespace WorkingWithEFCore.AutoGen --data-annotations --context Northwind 
    

    注意以下事项:

    • 命令操作:dbcontext scaffold

    • 连接字符串:"Filename=Northwind.db"

    • 数据库提供程序:Microsoft.EntityFrameworkCore.Sqlite

    • 生成模型的表:--table Categories --table Products

    • 输出文件夹:--output-dir AutoGenModels

    • 命名空间:--namespace WorkingWithEFCore.AutoGen

    • 同时使用数据注释和 Fluent API:--data-annotations

    • 将上下文从[数据库名称]Context 重命名为:--context Northwind

    对于 SQL Server,更改数据库提供程序和连接字符串,如以下命令所示:

    dotnet ef dbcontext scaffold "Data Source=.;Initial Catalog=Northwind;Integrated Security=true;" Microsoft.EntityFrameworkCore.SqlServer --table Categories --table Products --output-dir AutoGenModels --namespace WorkingWithEFCore.AutoGen --data-annotations --context Northwind 
    
  3. 注意构建消息和警告,如以下输出所示:

    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. 
    
  4. 打开AutoGenModels文件夹,并注意自动生成的三个类文件:Category.csNorthwind.csProduct.cs

  5. 打开Category.cs,注意与您手动创建的版本相比的差异,如下面的代码所示:

    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.ComponentModel.DataAnnotations.Schema; 
    using Microsoft.EntityFrameworkCore;
    namespace WorkingWithEFCore.AutoGen
    {
      [Index(nameof(CategoryName), Name = "CategoryName")]
      public partial class Category
      {
        public Category()
        {
          Products = new HashSet<Product>();
        }
        [Key]
        public long CategoryId { get; set; }
        [Required]
        [Column(TypeName = "nvarchar (15)")] // SQLite
        [StringLength(15)] // SQL Server
        public string CategoryName { get; set; }
        [Column(TypeName = "ntext")]
        public string? Description { get; set; }
        [Column(TypeName = "image")]
        public byte[]? Picture { get; set; }
        [InverseProperty(nameof(Product.Category))]
        public virtual ICollection<Product> Products { get; set; }
      }
    } 
    

    注意以下内容:

    • 它用 EF Core 5.0 中引入的[Index]属性装饰实体类。这表示应为哪些属性创建索引。在早期版本中,仅支持 Fluent API 定义索引。由于我们正在使用现有数据库,因此不需要这样做。但如果我们想从代码重新创建一个新的空数据库,则需要这些信息。

    • 数据库中的表名为Categories,但dotnet-ef工具使用第三方库Humanizer自动将类名单数化,变为Category,这在创建单个实体时是一个更自然的名称。

    • 实体类使用partial关键字声明,以便您可以创建匹配的部分类来添加额外的代码。这样,您可以重新运行工具并重新生成实体类,而不会丢失那些额外的代码。

    • CategoryId属性用[Key]属性装饰,表示它是此实体的主键。该属性的数据类型对于 SQL Server 是int,对于 SQLite 是long

    • Products属性使用[InverseProperty]属性定义与Product实体类上的Category属性的外键关系。

  6. 打开Product.cs,注意与您手动创建的版本相比的差异。

  7. 打开Northwind.cs,注意与您手动创建的版本相比的差异,如下面的编辑后代码所示:

    using Microsoft.EntityFrameworkCore; 
    namespace WorkingWithEFCore.AutoGen
    {
      public partial class Northwind : DbContext
      {
        public Northwind()
        {
        }
        public Northwind(DbContextOptions<Northwind> options)
          : base(options)
        {
        }
        public virtual DbSet<Category> Categories { get; set; } = null!;
        public virtual DbSet<Product> Products { get; set; } = null!;
        protected override void OnConfiguring(
          DbContextOptionsBuilder optionsBuilder)
        {
          if (!optionsBuilder.IsConfigured)
          {
    #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("Filename=Northwind.db");
          }
        }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
          modelBuilder.Entity<Category>(entity =>
          {
            ...
          });
          modelBuilder.Entity<Product>(entity =>
          {
            ...
          });
          OnModelCreatingPartial(modelBuilder);
        }
        partial void OnModelCreatingPartial(ModelBuilder modelBuilder);
      }
    } 
    

    注意以下内容:

    • 北风数据上下文类被声明为partial,以便您可以扩展它并在将来重新生成它。

    • 它有两个构造函数:一个无参数的默认构造函数和一个允许传递选项的构造函数。这在您希望在运行时指定连接字符串的应用程序中非常有用。

    • 代表CategoriesProducts表的两个DbSet<T>属性被设置为null-forgiving 值,以防止在编译时出现静态编译器分析警告。这在运行时没有影响。

    • OnConfiguring方法中,如果在构造函数中未指定选项,则默认使用在当前文件夹中查找数据库文件的连接字符串。它有一个编译器警告,提醒您不应在此连接字符串中硬编码安全信息。

    • OnModelCreating方法中,使用 Fluent API 配置了两个实体类,然后调用了名为OnModelCreatingPartial的部分方法。这允许您在自己的部分Northwind类中实现该部分方法,添加自己的 Fluent API 配置,这样在重新生成模型类时不会丢失这些配置。

  8. 关闭自动生成的类文件。

配置预设模型

除了支持DateOnlyTimeOnly类型用于 SQLite 数据库提供程序外,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 查询的知识。

目前,只需编写代码并查看结果:

  1. Program.cs顶部,导入主要的 EF Core 命名空间,以启用使用Include扩展方法从相关表预先加载:

    using Microsoft.EntityFrameworkCore; // Include extension method 
    
  2. Program.cs底部,定义一个QueryingCategories方法,并添加执行这些任务的语句,如下所示代码中所示:

    • 创建一个Northwind类的实例来管理数据库。数据库上下文实例设计为在单元工作中的短期生命周期。它们应尽快被释放,因此我们将它包裹在一个using语句中。在第十四章使用 ASP.NET Core Razor Pages 构建网站中,你将学习如何通过依赖注入获取数据库上下文。

    • 创建一个查询,获取所有包含相关产品的类别。

    • 遍历类别,输出每个类别的名称和产品数量:

    static void QueryingCategories()
    {
      using (Northwind db = new())
      {
        WriteLine("Categories and how many products they have:");
        // a query to get all categories and their related products
        IQueryable<Category>? categories = db.Categories?
          .Include(c => c.Products);
        if (categories is null)
        {
          WriteLine("No categories found.");
          return;
        }
        // execute query and enumerate results
        foreach (Category c in categories)
        {
          WriteLine($"{c.CategoryName} has {c.Products.Count} products.");
        }
      }
    } 
    
  3. Program.cs顶部,在输出数据库提供程序名称后,调用QueryingCategories方法,如下所示代码中高亮显示的部分:

    WriteLine($"Using {ProjectConstants.DatabaseProvider} database provider.");
    **QueryingCategories();** 
    
  4. 运行代码并查看结果(如果使用 Visual Studio 2022 for Windows 并使用 SQLite 数据库提供程序运行),如下所示输出:

    Using SQLite database provider.
    Categories and how many products they have: 
    Using C:\Code\Chapter10\WorkingWithEFCore\bin\Debug\net6.0\Northwind.db database file.
    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. 
    

如果你使用 Visual Studio Code 运行并使用 SQLite 数据库提供程序,那么路径将是WorkingWithEFCore文件夹。如果你使用 SQL Server 数据库提供程序运行,则不会有数据库文件路径输出。

警告! 如果你在使用 Visual Studio 2022 中的 SQLite 时看到以下异常,最可能的问题是Northwind.db文件没有被复制到输出目录。请确保复制到输出目录设置为总是复制

未处理的异常。Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite 错误 1: '没有这样的表: Categories'。

过滤包含的实体

EF Core 5.0 引入了过滤包含,这意味着您可以在Include方法调用中指定一个 lambda 表达式,以过滤结果中返回哪些实体:

  1. Program.cs底部,定义一个FilteredIncludes方法,并添加语句执行这些任务,如下所示:

    • 创建一个Northwind类的实例来管理数据库。

    • 提示用户输入库存单位的最小值。

    • 创建一个查询,查找具有该最小库存单位数量的产品的类别。

    • 遍历类别和产品,输出每个的名称和库存单位:

    static void FilteredIncludes()
    {
      using (Northwind db = new())
      {
        Write("Enter a minimum for units in stock: ");
        string unitsInStock = ReadLine() ?? "10";
        int stock = int.Parse(unitsInStock);
        IQueryable<Category>? categories = db.Categories?
          .Include(c => c.Products.Where(p => p.Stock >= stock));
        if (categories is null)
        {
          WriteLine("No categories found.");
          return;
        }
        foreach (Category c in categories)
        {
          WriteLine($"{c.CategoryName} has {c.Products.Count} products with a minimum of {stock} units in stock.");
          foreach(Product p in c.Products)
          {
            WriteLine($"  {p.ProductName} has {p.Stock} units in stock.");
          }
        }
      }
    } 
    
  2. Program.cs中,注释掉QueryingCategories方法,并调用FilteredIncludes方法,如以下高亮代码所示:

    WriteLine($"Using {ProjectConstants.DatabaseProvider} database provider.");
    **// QueryingCategories();**
    **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 版本中,微软提供的控制台存在限制。默认情况下,控制台无法显示 Unicode 字符,例如 Rhönbräu 的名称。

如果您遇到此问题,则可以通过在运行应用之前在提示符下输入以下命令来临时更改控制台中的代码页(也称为字符集)为 Unicode UTF-8:

chcp 65001 

过滤和排序产品

让我们探索一个更复杂的查询,它将过滤和排序数据:

  1. Program.cs底部,定义一个QueryingProducts方法,并添加语句执行以下操作,如下所示:

    • 创建一个Northwind类的实例来管理数据库。

    • 提示用户输入产品价格。与之前的代码示例不同,我们将循环直到输入有效价格。

    • 使用 LINQ 创建一个查询,查找价格高于指定价格的产品。

    • 遍历结果,输出 Id、名称、成本(以美元格式化)和库存单位数量:

    static void QueryingProducts()
    {
      using (Northwind db = new())
      {
        WriteLine("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)
        {
          WriteLine("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,并查看结果,如下所示:

    Products that cost more than a price, highest at top. 
    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. 
    

获取生成的 SQL

您可能会好奇我们编写的 C#查询生成的 SQL 语句写得如何。EF Core 5.0 引入了一种快速简便的方法来查看生成的 SQL:

  1. FilteredIncludes方法中,在使用foreach语句遍历查询之前,添加一条语句以输出生成的 SQL,如下所示:

    **WriteLine(****$"ToQueryString:** **{categories.ToQueryString()}****"****);**
    foreach (Category c in categories) 
    
  2. Program.cs中,注释掉对QueryingProducts方法的调用,并取消对FilteredIncludes方法的调用。

  3. 运行代码,输入库存单位的最小值,如99,并查看结果(使用 SQLite 运行时),如下所示:

    Enter a minimum for units in stock: 99 
    Using SQLite database provider.
    ToQueryString: .param set @_stock_0 99
    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", "t"."ProductId"
    Beverages has 2 products with a minimum of 99 units in stock.
      Sasquatch Ale has 111 units in stock. 
      Rhönbräu Klosterbier has 125 units in stock.
    ... 
    

注意名为@_stock_0的 SQL 参数已设置为最小库存值99

对于 SQL Server,生成的 SQL 略有不同,例如,它使用方括号而不是双引号围绕对象名称,如下所示输出:

Enter a minimum for units in stock: 99
Using SqlServer database provider.
ToQueryString: DECLARE @__stock_0 smallint = CAST(99 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], [t].[ProductId] 

使用自定义日志记录提供程序记录 EF Core

为了监控 EF Core 与数据库之间的交互,我们可以启用日志记录。这需要完成以下两个任务:

  • 注册日志记录提供程序

  • 日志记录器的实现。

让我们看一个实际操作的示例:

  1. 向项目中添加一个名为ConsoleLogger.cs的文件。

  2. 修改文件以定义两个类,一个实现ILoggerProvider,另一个实现ILogger,如下所示代码,并注意以下事项:

    • ConsoleLoggerProvider返回一个ConsoleLogger实例。它不需要任何非托管资源,因此Dispose方法无需执行任何操作,但必须存在。

    • ConsoleLogger对于日志级别NoneTraceInformation被禁用。对于所有其他日志级别均启用。

    • ConsoleLogger通过向Console写入内容来实现其Log方法:

    using Microsoft.Extensions.Logging; // ILoggerProvider, ILogger, LogLevel
    using static System.Console;
    namespace Packt.Shared;
    public class ConsoleLoggerProvider : ILoggerProvider
    {
      public ILogger CreateLogger(string categoryName)
      {
        // we could have different logger implementations for
        // different categoryName values but we only have one
        return new ConsoleLogger();
      }
      // if your logger uses unmanaged resources,
      // then you can release them here
      public void Dispose() { }
    }
    public class ConsoleLogger : ILogger
    {
      // if your logger uses unmanaged resources, you can
      // return the class that implements IDisposable here
      public IDisposable BeginScope<TState>(TState state)
      {
        return null;
      }
      public bool IsEnabled(LogLevel logLevel)
      {
        // to avoid overlogging, you can filter on the log level
        switch(logLevel)
        {
          case LogLevel.Trace:
          case LogLevel.Information:
          case LogLevel.None:
            return false;
          case LogLevel.Debug:
          case LogLevel.Warning:
          case LogLevel.Error:
          case LogLevel.Critical:
          default:
            return true;
        };
      }
      public void Log<TState>(LogLevel logLevel,
        EventId eventId, TState state, Exception? exception,
        Func<TState, Exception, string> formatter)
      {
        // log the level and event identifier
        Write($"Level: {logLevel}, Event Id: {eventId.Id}");
        // only output the state or exception if it exists
        if (state != null)
        {
          Write($", State: {state}");
        }
        if (exception != null)
        {
          Write($", Exception: {exception.Message}");
        }
        WriteLine();
      }
    } 
    
  3. Program.cs顶部,添加用于日志记录所需的命名空间导入语句,如下所示:

    using Microsoft.EntityFrameworkCore.Infrastructure;
    using Microsoft.Extensions.DependencyInjection; 
    using Microsoft.Extensions.Logging; 
    
  4. 我们已经使用ToQueryString方法获取了FilteredIncludes的 SQL,因此无需向该方法添加日志记录。在QueryingCategoriesQueryingProducts方法中,立即在Northwind数据库上下文的using块内添加语句以获取日志记录工厂并注册您的自定义控制台日志记录器,如下所示突出显示:

    using (Northwind db = new())
    {
     **ILoggerFactory loggerFactory = db.GetService<ILoggerFactory>();** 
     **loggerFactory.AddProvider(****new** **ConsoleLoggerProvider());** 
    
  5. Program.cs顶部,注释掉对FilteredIncludes方法的调用,并取消注释对QueryingProducts方法的调用。

  6. 运行代码并查看日志,部分输出如下所示:

    ...
    Level: Debug, Event Id: 20000, State: Opening connection to database 'main' on server '/Users/markjprice/Code/Chapter10/WorkingWithEFCore/Northwind.db'.
    Level: Debug, Event Id: 20001, State: Opened connection to database 'main' on server '/Users/markjprice/Code/Chapter10/WorkingWithEFCore/Northwind.db'.
    Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[@__price_0='?'], CommandType='Text', CommandTimeout='30']
    SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice", "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
    FROM "Products" AS "p"
    WHERE "p"."UnitPrice" > @__price_0
    ORDER BY "product"."UnitPrice" DESC
    ... 
    

您的日志可能与上述显示的有所不同,这取决于您选择的数据库提供程序和代码编辑器,以及 EF Core 未来的改进。目前,请注意不同事件(如打开连接或执行命令)具有不同的事件 ID。

根据提供程序特定值过滤日志

事件 ID 值及其含义将特定于.NET 数据提供程序。如果我们想了解 LINQ 查询如何被转换为 SQL 语句并执行,则要输出的事件 ID 具有Id20100

  1. 修改ConsoleLogger中的Log方法,仅输出具有Id20100的事件,如下所示突出显示:

    public void Log<TState>(LogLevel logLevel, EventId eventId,
      TState state, Exception? exception,
      Func<TState, Exception, string> formatter)
    {
    **if** **(eventId.Id ==** **20100****)**
     **{**
        // log the level and event identifier
        Write("Level: {0}, Event Id: {1}, Event: {2}",
          logLevel, eventId.Id, eventId.Name);
        // only output the state or exception if it exists
        if (state != null)
        {
          Write($", State: {state}");
        }
        if (exception != null)
        {
          Write($", Exception: {exception.Message}");
        }
        WriteLine();
     **}**
    } 
    
  2. Program.cs中,取消注释QueryingCategories方法并注释掉其他方法,以便我们可以监视在连接两个表时生成的 SQL 语句。

  3. 运行代码,并注意已记录的以下 SQL 语句,如下所示输出已为空间编辑:

    Using SQLServer database provider.
    Categories and how many products they have:
    Level: Debug, Event Id: 20100, State: 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], [p].[ProductId]
    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. 

使用 Like 进行模式匹配

EF Core 支持包括Like在内的常用 SQL 语句进行模式匹配:

  1. Program.cs底部,添加一个名为QueryingWithLike的方法,如下所示,并注意:

    • 我们已启用日志记录。

    • 我们提示用户输入产品名称的一部分,然后使用EF.Functions.Like方法在ProductName属性中任意位置进行搜索。

    • 对于每个匹配的产品,我们输出其名称、库存以及是否已停产:

    static void QueryingWithLike()
    {
      using (Northwind db = new())
      {
        ILoggerFactory loggerFactory = db.GetService<ILoggerFactory>();
        loggerFactory.AddProvider(new ConsoleLoggerProvider());
        Write("Enter part of a product name: ");
        string? input = ReadLine();
        IQueryable<Product>? products = db.Products?
          .Where(p => EF.Functions.Like(p.ProductName, $"%{input}%"));
        if (products is null)
        {
          WriteLine("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,并查看结果,如下所示:

    Using SQLServer database provider.
    Enter part of a product name: che
    Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[@__Format_1='?' (Size = 40)], 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 
    Gumbär Gummibärchen has 15 units in stock. Discontinued? False 
    

EF Core 6.0 引入了另一个有用的函数EF.Functions.Random,它映射到数据库函数,返回 0 和 1 之间(不包括 1)的伪随机数。例如,您可以将随机数乘以表中的行数,以从该表中选择一行随机行。

定义全局过滤器

北风产品可能会停产,因此即使程序员不在查询中使用Where过滤它们,确保停产产品永远不会在结果中返回可能很有用:

  1. Northwind.cs中,修改OnModelCreating方法,添加一个全局过滤器以移除停产产品,如下所示高亮部分:

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
      ...
    **// global filter to remove discontinued products**
     **modelBuilder.Entity<Product>()**
     **.HasQueryFilter(p => !p.Discontinued);**
    } 
    
  2. 运行代码,输入部分产品名称che,查看结果,并注意Chef Anton's Gumbo Mix现已缺失,因为生成的 SQL 语句中包含了对Discontinued列的过滤,如下所示高亮部分:

    SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice",
    "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock" 
    FROM "Products" AS "p"
    WHERE **("p"."Discontinued" = 0)** 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 
    

EF Core 的加载模式

与 EF Core 一起常用的有三种加载模式:

  • 预先加载:提前加载数据。

  • 延迟加载:在数据即将被使用前自动加载。

  • 显式加载:手动加载数据。

在本节中,我们将逐一介绍它们。

实体的预先加载

QueryingCategories方法中,当前代码使用Categories属性遍历每个类别,输出类别名称以及该类别中的产品数量。

这样做是因为在编写查询时,我们通过调用Include方法为相关产品启用了预先加载。

让我们看看如果我们不调用Include会发生什么:

  1. 修改查询,注释掉Include方法的调用,如下所示:

    IQueryable<Category>? categories =
      db.Categories; //.Include(c => c.Products); 
    
  2. Program.cs中,注释掉除QueryingCategories之外的所有方法。

  3. 运行代码并查看结果,如下所示部分输出:

    Beverages has 0 products. 
    Condiments has 0 products. 
    Confections has 0 products.
    Dairy Products has 0 products. 
    Grains/Cereals has 0 products. 
    Meat/Poultry has 0 products.
    Produce has 0 products. 
    Seafood has 0 products. 
    

foreach循环中的每一项都是Category类的一个实例,该类具有一个名为Products的属性,即该类别中的产品列表。由于原始查询仅从Categories表中选择,因此每个类别的此属性为空。

启用延迟加载

延迟加载是在 EF Core 2.1 中引入的,它可以自动加载缺失的相关数据。要启用延迟加载,开发者必须:

  • 引用一个 NuGet 包用于代理。

  • 配置延迟加载以使用代理。

让我们看看这是如何运作的:

  1. WorkingWithEFCore项目中,添加一个 EF Core 代理的包引用,如下面的标记所示:

    <PackageReference
      Include="Microsoft.EntityFrameworkCore.Proxies" 
      Version="6.0.0" /> 
    
  2. 构建项目以恢复包。

  3. 打开Northwind.cs,并在OnConfiguring方法的顶部调用一个扩展方法以使用延迟加载代理,如下面的高亮代码所示:

    protected override void OnConfiguring(
      DbContextOptionsBuilder optionsBuilder)
    {
     **optionsBuilder.UseLazyLoadingProxies();** 
    

    现在,每当循环枚举时,尝试读取Products属性,延迟加载代理将检查它们是否已加载。如果没有,它将通过执行一个SELECT语句为我们“懒惰地”加载当前类别的那组产品,然后正确的计数将返回到输出。

  4. 运行代码并注意产品计数现在已正确。但你会发现延迟加载的问题在于,为了最终获取所有数据,需要多次往返数据库服务器,如下面的部分输出所示:

    Categories and how many products they have:
    Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
    SELECT "c"."CategoryId", "c"."CategoryName", "c"."Description" FROM "Categories" AS "c"
    Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[@ p_0='?'], CommandType='Text', CommandTimeout='30'] 
    SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice",
    "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
    FROM "Products" AS "p"
    WHERE ("p"."Discontinued" = 0) AND ("p"."CategoryId" = @ p_0) 
    Beverages has 11 products.
    Level: Debug, Event ID: 20100, State: Executing DbCommand [Parameters=[@ p_0='?'], CommandType='Text', CommandTimeout='30'] 
    SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice",
    "p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
    FROM "Products" AS "p"
    WHERE ("p"."Discontinued" = 0) AND ("p"."CategoryId" = @ p_0) 
    Condiments has 11 products. 
    

显式加载实体

另一种加载类型是显式加载。它的工作方式与延迟加载类似,区别在于你可以控制确切的相关数据何时加载:

  1. Program.cs的顶部,导入更改跟踪命名空间,以便我们能够使用CollectionEntry类手动加载相关实体,如下面的代码所示:

    using Microsoft.EntityFrameworkCore.ChangeTracking; // CollectionEntry 
    
  2. QueryingCategories方法中,修改语句以禁用延迟加载,然后提示用户是否希望启用预先加载和显式加载,如下面的代码所示:

    IQueryable<Category>? categories;
      // = db.Categories;
      // .Include(c => c.Products);
    db.ChangeTracker.LazyLoadingEnabled = false; 
    Write("Enable eager loading? (Y/N): ");
    bool eagerloading = (ReadKey().Key == ConsoleKey.Y); 
    bool explicitloading = false;
    WriteLine();
    if (eagerloading)
    {
      categories = db.Categories?.Include(c => c.Products);
    }
    else
    {
      categories = db.Categories;
      Write("Enable explicit loading? (Y/N): ");
      explicitloading = (ReadKey().Key == ConsoleKey.Y);
      WriteLine();
    } 
    
  3. foreach循环中,在WriteLine方法调用之前,添加语句以检查是否启用了显式加载,如果是,则提示用户是否希望显式加载每个单独的类别,如下面的代码所示:

    if (explicitloading)
    {
      Write($"Explicitly load products for {c.CategoryName}? (Y/N): "); 
      ConsoleKeyInfo key = ReadKey();
      WriteLine();
      if (key.Key == ConsoleKey.Y)
      {
        CollectionEntry<Category, Product> products =
          db.Entry(c).Collection(c2 => c2.Products);
        if (!products.IsLoaded) products.Load();
      }
    }
    WriteLine($"{c.CategoryName} has {c.Products.Count} products."); 
    
  4. 运行代码:

    1. 按下N以禁用预先加载。

    2. 然后按下Y以启用显式加载。

    3. 对于每个类别,按YN以按你的意愿加载其产品。

我选择只为八个类别中的两个——饮料和海鲜——加载产品,如下面的输出所示,为了节省空间已进行了编辑:

Categories and how many products they have:
Enable eager loading? (Y/N): n 
Enable explicit loading? (Y/N): y
Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT "c"."CategoryId", "c"."CategoryName", "c"."Description" FROM "Categories" AS "c"
Explicitly load products for Beverages? (Y/N): y
Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[@ p_0='?'], CommandType='Text', CommandTimeout='30'] 
SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice",
"p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
FROM "Products" AS "p"
WHERE ("p"."Discontinued" = 0) AND ("p"."CategoryId" = @ p_0)
Beverages has 11 products.
Explicitly load products for Condiments? (Y/N): n 
Condiments has 0 products.
Explicitly load products for Confections? (Y/N): n 
Confections has 0 products.
Explicitly load products for Dairy Products? (Y/N): n 
Dairy Products has 0 products.
Explicitly load products for Grains/Cereals? (Y/N): n 
Grains/Cereals has 0 products.
Explicitly load products for Meat/Poultry? (Y/N): n 
Meat/Poultry has 0 products.
Explicitly load products for Produce? (Y/N): n 
Produce has 0 products.
Explicitly load products for Seafood? (Y/N): y
Level: Debug, Event ID: 20100, State: Executing DbCommand [Parameters=[@ p_0='?'], CommandType='Text', CommandTimeout='30'] 
SELECT "p"."ProductId", "p"."CategoryId", "p"."UnitPrice",
"p"."Discontinued", "p"."ProductName", "p"."UnitsInStock"
FROM "Products" AS "p"
WHERE ("p"."Discontinued" = 0) AND ("p"."CategoryId" = @ p_0) 
Seafood has 12 products. 

最佳实践:仔细考虑哪种加载模式最适合你的代码。延迟加载可能会让你成为懒惰的数据库开发者!更多关于加载模式的信息,请访问以下链接:docs.microsoft.com/en-us/ef/core/querying/related-data

使用 EF Core 操作数据

使用 EF Core 插入、更新和删除实体是一个容易完成的任务。

DbContext 自动维护变更跟踪,因此本地实体可以有多个变更被跟踪,包括添加新实体、修改现有实体和删除实体。当你准备好将这些变更发送到底层数据库时,调用 SaveChanges 方法。成功变更的实体数量将被返回。

插入实体

让我们先来看看如何向表中添加新行:

  1. Program.cs 中,创建一个名为 AddProduct 的新方法,如下所示:

    static bool AddProduct(
      int categoryId, string productName, decimal? price)
    {
      using (Northwind db = new())
      {
        Product p = new()
        {
          CategoryId = categoryId,
          ProductName = productName,
          Cost = price
        };
        // mark product as added in change tracking
        db.Products.Add(p);
        // save tracked change to database
        int affected = db.SaveChanges();
        return (affected == 1);
      }
    } 
    
  2. Program.cs 中,创建一个名为 ListProducts 的新方法,该方法输出每个产品的 Id、名称、成本、库存和停产属性,并按成本最高排序,如下所示:

    static void ListProducts()
    {
      using (Northwind db = new())
      {
        WriteLine("{0,-3} {1,-35} {2,8} {3,5} {4}",
          "Id", "Product Name", "Cost", "Stock", "Disc.");
        foreach (Product p in db.Products
          .OrderByDescending(product => product.Cost))
        {
          WriteLine("{0:000} {1,-35} {2,8:$#,##0.00} {3,5} {4}",
            p.ProductId, p.ProductName, p.Cost, p.Stock, p.Discontinued);
        }
      }
    } 
    

    记住,1,-35 表示将参数 1 左对齐于一个 35 字符宽的列中,而 3,5 表示将参数 3 右对齐于一个 5 字符宽的列中。

  3. Program.cs 中,注释掉之前的方法调用,然后调用 AddProductListProducts,如下所示:

    // QueryingCategories();
    // FilteredIncludes();
    // QueryingProducts();
    // QueryingWithLike();
    if (AddProduct(categoryId: 6, 
      productName: "Bob's Burgers", price: 500M))
    {
      WriteLine("Add product successful.");
    }
    ListProducts(); 
    
  4. 运行代码,查看结果,并注意新添加的产品,如下所示:

    Add product successful.
    Id  Product Name              Cost Stock Disc.
    078 Bob's Burgers          $500.00       False
    038 Côte de Blaye          $263.50    17 False
    020 Sir Rodney's Marmalade  $81.00    40 False
    ... 
    

更新实体

现在,让我们修改表中的现有行:

  1. Program.cs 中,添加一个方法,将名称以指定值(在我们的例子中使用 Bob)开头的第一种产品的价格提高一个指定数额,比如 $20,如下所示:

    static bool IncreaseProductPrice(
      string productNameStartsWith, decimal amount)
    {
      using (Northwind db = new())
      {
        // get first product whose name starts with name
        Product updateProduct = db.Products.First(
          p => p.ProductName.StartsWith(productNameStartsWith));
        updateProduct.Cost += amount;
        int affected = db.SaveChanges();
        return (affected == 1);
      }
    } 
    
  2. Program.cs 中,注释掉整个调用 AddProductif 块,并在列出产品之前添加对 IncreaseProductPrice 的调用,如下所示:

    **/***
    if (AddProduct(categoryId: 6, 
      productName: "Bob's Burgers", price: 500M))
    {
      WriteLine("Add product successful.");
    }
    ***/**
    **if** **(IncreaseProductPrice(**
     **productNameStartsWith:** **"Bob"****, amount:** **20****M))**
    **{**
     **WriteLine(****"Update product price successful."****);**
    **}**
    ListProducts(); 
    
  3. 运行代码,查看结果,并注意 Bob's Burgers 的现有实体价格已增加 $20,如下所示:

    Update product price successful.
    Id  Product Name              Cost Stock Disc.
    078 Bob's Burgers          $520.00       False
    038 Côte de Blaye          $263.50    17 False
    020 Sir Rodney's Marmalade  $81.00    40 False
    ... 
    

删除实体

你可以使用 Remove 方法移除单个实体。当你想要删除多个实体时,RemoveRange 更为高效。

现在让我们看看如何从表中删除行:

  1. Program.cs 底部,添加一个方法,删除名称以指定值(在我们的例子中是 Bob)开头的产品,如下所示:

    static int DeleteProducts(string productNameStartsWith)
    {
      using (Northwind db = new())
      {
        IQueryable<Product>? products = db.Products?.Where(
          p => p.ProductName.StartsWith(productNameStartsWith));
        if (products is null)
        {
          WriteLine("No products found to delete.");
          return 0;
        }
        else
        {
          db.Products.RemoveRange(products);
        }
        int affected = db.SaveChanges();
        return affected;
      }
    } 
    
  2. Program.cs 中,注释掉整个调用 IncreaseProductPriceif 语句块,并添加对 DeleteProducts 的调用,如下所示:

    int deleted = DeleteProducts(productNameStartsWith: "Bob");
    WriteLine($"{deleted} product(s) were deleted."); 
    
  3. 运行代码并查看结果,如下所示:

    1 product(s) were deleted. 
    

如果有多个产品名称以 Bob 开头,那么它们都会被删除。作为可选挑战,修改语句以添加三个以 Bob 开头的新产品,然后删除它们。

数据库上下文池化

DbContext 类是可释放的,并且是按照单一工作单元原则设计的。在前面的代码示例中,我们在一个 using 块中创建了所有派生自 DbContext 的 Northwind 实例,以便在工作单元结束时适当地调用 Dispose

ASP.NET Core 的一个与 EF Core 相关的特性是,在构建网站和服务时,它通过池化数据库上下文使你的代码更高效。这允许你创建和销毁任意数量的DbContext派生对象,同时确保代码尽可能高效。

处理事务

每次调用SaveChanges方法时,都会启动一个隐式 事务,以便在出现问题时自动回滚所有更改。如果事务内的多个更改成功,则提交事务和所有更改。

事务通过应用锁来防止在更改序列发生时进行读写,从而维护数据库的完整性。

事务遵循ACID原则,这是一个缩写,其含义如下:

  • 原子性意味着事务中的所有操作要么全部提交,要么全部不提交。

  • 一致性意味着数据库在事务前后保持一致状态。这取决于你的代码逻辑;例如,在银行账户之间转账时,你的业务逻辑必须确保如果在一个账户中扣除$100,则在另一个账户中增加$100。

  • 隔离性意味着在事务执行期间,其更改对其他进程是隐藏的。你可以从多个隔离级别中选择(参见下表)。隔离级别越高,数据完整性越好,但需要应用更多的锁,这可能会对其他进程产生负面影响。快照是一个特殊情况,因为它创建了多行副本以避免锁,但这会增加事务发生时数据库的大小。

  • 持久性意味着如果在事务执行过程中发生故障,可以进行恢复。这通常通过两阶段提交和事务日志来实现。一旦事务被提交,即使后续出现错误,也能保证其持久性。与持久性相对的是易失性。

控制事务使用隔离级别

开发人员可以通过设置隔离级别来控制事务,如下表所述:

隔离级别 允许的完整性问题
未提交读 脏读、不可重复读和幻像数据
已提交读 在编辑时,它应用读锁以阻止其他用户在事务结束前读取记录 不可重复读和幻像数据
可重复读 在读取时,它应用编辑锁以阻止其他用户在事务结束前编辑记录 幻像数据
可串行化 应用键范围锁以防止任何影响结果的操作,包括插入和删除
快照

定义显式事务

你可以使用数据库上下文的Database属性来控制显式事务:

  1. Program.cs中,导入 EF Core 存储命名空间以使用IDbContextTransaction接口,如下面的代码所示:

    using Microsoft.EntityFrameworkCore.Storage; // IDbContextTransaction 
    
  2. DeleteProducts方法中,在db变量实例化后,添加语句以启动显式事务并输出其隔离级别。在方法底部,提交事务并关闭大括号,如下面的代码所示:

    static int DeleteProducts(string name)
    {
      using (Northwind db = new())
      {
    **using** **(IDbContextTransaction t = db.Database.BeginTransaction())**
     **{**
     **WriteLine(****"Transaction isolation level: {0}"****,**
     **arg0: t.GetDbTransaction().IsolationLevel);**
          IQueryable<Product>? products = db.Products?.Where(
            p => p.ProductName.StartsWith(name));
          if (products is null)
          {
            WriteLine("No products found to delete.");
            return 0;
          }
          else
          {
            db.Products.RemoveRange(products);
          }
          int affected = db.SaveChanges();
     **t.Commit();**
          return affected;
     **}**
      }
    } 
    
  3. 运行代码并在 SQL Server 中查看结果,如下面的输出所示:

    Transaction isolation level: ReadCommitted 
    
  4. 运行代码并在 SQLite 中查看结果,如下面的输出所示:

    Transaction isolation level: Serializable 
    

Code First EF Core 模型

有时您可能没有现有数据库。相反,您将 EF Core 模型定义为 Code First,然后 EF Core 可以使用创建和删除 API 生成匹配的数据库。

最佳实践:创建和删除 API 仅应在开发期间使用。一旦发布应用程序,您不希望它删除生产数据库!

例如,我们可能需要为学院创建一个管理学生和课程的应用程序。一个学生可以注册参加多个课程。一个课程可以由多个学生参加。这是学生和课程之间多对多关系的一个例子。

让我们模拟这个示例:

  1. 使用您喜欢的代码编辑器,在Chapter10解决方案/工作区中添加一个名为CoursesAndStudents的新控制台应用程序。

  2. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择。

  3. 在 Visual Studio Code 中,选择CoursesAndStudents作为活动 OmniSharp 项目。

  4. CoursesAndStudents项目中,添加以下包的包引用:

    • Microsoft.EntityFrameworkCore.Sqlite

    • Microsoft.EntityFrameworkCore.SqlServer

    • Microsoft.EntityFrameworkCore.Design

  5. 构建CoursesAndStudents项目以恢复包。

  6. 添加名为Academy.csStudent.csCourse.cs的类。

  7. 修改Student.cs,并注意它是一个没有属性装饰类的 POCO(普通旧 CLR 对象),如下面的代码所示:

    namespace CoursesAndStudents;
    public class Student
    {
      public int StudentId { get; set; }
      public string? FirstName { get; set; }
      public string? LastName { get; set; }
      public ICollection<Course>? Courses { get; set; }
    } 
    
  8. 修改Course.cs,并注意我们已经用一些属性装饰了Title属性,以向模型提供更多信息,如下面的代码所示:

    using System.ComponentModel.DataAnnotations;
    namespace CoursesAndStudents;
    public class Course
    {
      public int CourseId { get; set; }
      [Required]
      [StringLength(60)]
      public string? Title { get; set; }
      public ICollection<Student>? Students { get; set; }
    } 
    
  9. 修改Academy.cs,如下面的代码所示:

    using Microsoft.EntityFrameworkCore;
    using static System.Console;
    namespace CoursesAndStudents;
    public class Academy : DbContext
    {
      public DbSet<Student>? Students { get; set; }
      public DbSet<Course>? Courses { get; set; }
      protected override void OnConfiguring(
        DbContextOptionsBuilder optionsBuilder)
      {
        string path = Path.Combine(
          Environment.CurrentDirectory, "Academy.db");
        WriteLine($"Using {path} database file.");
        optionsBuilder.UseSqlite($"Filename={path}");
        // optionsBuilder.UseSqlServer(@"Data Source=.;Initial Catalog=Academy;Integrated Security=true;MultipleActiveResultSets=true;");
      }
      protected override void OnModelCreating(ModelBuilder modelBuilder)
      {
        // Fluent API validation rules
        modelBuilder.Entity<Student>()
            .Property(s => s.LastName).HasMaxLength(30).IsRequired();
          // populate database with sample data
          Student alice = new() { StudentId = 1, 
            FirstName = "Alice", LastName = "Jones" };
          Student bob = new() { StudentId = 2, 
            FirstName = "Bob", LastName = "Smith" };
          Student cecilia = new() { StudentId = 3, 
            FirstName = "Cecilia", LastName = "Ramirez" };
          Course csharp = new() 
          { 
            CourseId = 1,
            Title = "C# 10 and .NET 6", 
          };
          Course webdev = new()
          {
            CourseId = 2,
            Title = "Web Development",
          };
          Course python = new()
          {
            CourseId = 3,
            Title = "Python for Beginners",
          };
          modelBuilder.Entity<Student>()
            .HasData(alice, bob, cecilia);
          modelBuilder.Entity<Course>()
            .HasData(csharp, webdev, python);
          modelBuilder.Entity<Course>()
            .HasMany(c => c.Students)
            .WithMany(s => s.Courses)
            .UsingEntity(e => e.HasData(
              // all students signed up for C# course
              new { CoursesCourseId = 1, StudentsStudentId = 1 },
              new { CoursesCourseId = 1, StudentsStudentId = 2 },
              new { CoursesCourseId = 1, StudentsStudentId = 3 },
              // only Bob signed up for Web Dev
              new { CoursesCourseId = 2, StudentsStudentId = 2 },
              // only Cecilia signed up for Python
              new { CoursesCourseId = 3, StudentsStudentId = 3 }
            ));
      }
    } 
    

    最佳实践:使用匿名类型为多对多关系中的中间表提供数据。属性名称遵循命名约定NavigationPropertyNamePropertyName,例如,Courses是导航属性名称,CourseId是属性名称,因此CoursesCourseId将是匿名类型的属性名称。

  10. Program.cs中,在文件顶部,导入 EF Core 和处理任务的命名空间,并静态导入Console,如下面的代码所示:

    using Microsoft.EntityFrameworkCore; // for GenerateCreateScript()
    using CoursesAndStudents; // Academy
    using static System.Console; 
    
  11. Program.cs中,添加语句以创建Academy数据库上下文的实例,并使用它来删除现有数据库,根据模型创建数据库并输出它使用的 SQL 脚本,然后枚举学生及其课程,如下所示:

    using (Academy a = new())
    {
      bool deleted = await a.Database.EnsureDeletedAsync();
      WriteLine($"Database deleted: {deleted}");
      bool created = await a.Database.EnsureCreatedAsync();
      WriteLine($"Database created: {created}");
      WriteLine("SQL script used to create database:");
      WriteLine(a.Database.GenerateCreateScript());
      foreach (Student s in a.Students.Include(s => s.Courses))
      {
        WriteLine("{0} {1} attends the following {2} courses:",
          s.FirstName, s.LastName, s.Courses.Count);
        foreach (Course c in s.Courses)
        {
          WriteLine($"  {c.Title}");
        }
      }
    } 
    
  12. 运行代码,并注意首次运行代码时无需删除数据库,因为它尚不存在,如下所示:

    Using C:\Code\Chapter10\CoursesAndStudents\bin\Debug\net6.0\Academy.db database file.
    Database deleted: False
    Database created: True
    SQL script used to create database:
    CREATE TABLE "Courses" (
        "CourseId" INTEGER NOT NULL CONSTRAINT "PK_Courses" PRIMARY KEY AUTOINCREMENT,
        "Title" TEXT NOT NULL
    );
    CREATE TABLE "Students" (
        "StudentId" INTEGER NOT NULL CONSTRAINT "PK_Students" PRIMARY KEY AUTOINCREMENT,
        "FirstName" TEXT NULL,
        "LastName" TEXT NOT NULL
    );
    CREATE TABLE "CourseStudent" (
        "CoursesCourseId" INTEGER NOT NULL,
        "StudentsStudentId" INTEGER NOT NULL,
        CONSTRAINT "PK_CourseStudent" PRIMARY KEY ("CoursesCourseId", "StudentsStudentId"),
        CONSTRAINT "FK_CourseStudent_Courses_CoursesCourseId" FOREIGN KEY ("CoursesCourseId") REFERENCES "Courses" ("CourseId") ON DELETE CASCADE,
        CONSTRAINT "FK_CourseStudent_Students_StudentsStudentId" FOREIGN KEY ("StudentsStudentId") REFERENCES "Students" ("StudentId") ON DELETE CASCADE
    );
    INSERT INTO "Courses" ("CourseId", "Title")
    VALUES (1, 'C# 10 and .NET 6');
    INSERT INTO "Courses" ("CourseId", "Title")
    VALUES (2, 'Web Development');
    INSERT INTO "Courses" ("CourseId", "Title")
    VALUES (3, 'Python for Beginners');
    INSERT INTO "Students" ("StudentId", "FirstName", "LastName")
    VALUES (1, 'Alice', 'Jones');
    INSERT INTO "Students" ("StudentId", "FirstName", "LastName")
    VALUES (2, 'Bob', 'Smith');
    INSERT INTO "Students" ("StudentId", "FirstName", "LastName")
    VALUES (3, 'Cecilia', 'Ramirez');
    INSERT INTO "CourseStudent" ("CoursesCourseId", "StudentsStudentId")
    VALUES (1, 1);
    INSERT INTO "CourseStudent" ("CoursesCourseId", "StudentsStudentId")
    VALUES (1, 2);
    INSERT INTO "CourseStudent" ("CoursesCourseId", "StudentsStudentId")
    VALUES (2, 2);
    INSERT INTO "CourseStudent" ("CoursesCourseId", "StudentsStudentId")
    VALUES (1, 3);
    INSERT INTO "CourseStudent" ("CoursesCourseId", "StudentsStudentId")
    VALUES (3, 3);
    CREATE INDEX "IX_CourseStudent_StudentsStudentId" ON "CourseStudent" ("StudentsStudentId");
    Alice Jones attends the following 1 course(s):
      C# 10 and .NET 6
    Bob Smith attends the following 2 course(s):
      C# 10 and .NET 6
      Web Development
    Cecilia Ramirez attends the following 2 course(s):
      C# 10 and .NET 6
      Python for Beginners 
    

    注意以下事项:

    • Title列不可为空,因为模型被装饰了[Required]

    • LastName列不可为空,因为模型使用了IsRequired()

    • 创建了一个名为CourseStudent的中间表,用于存储哪些学生参加了哪些课程的信息。

  13. 使用 Visual Studio Server Explorer 或 SQLiteStudio 连接到Academy数据库并查看表格,如图 10.6 所示:

图 10.6:使用 Visual Studio 2022 Server Explorer 在 SQL Server 中查看 Academy 数据库

理解迁移

发布使用数据库的项目后,很可能稍后需要更改实体数据模型,从而改变数据库结构。届时,不应使用Ensure方法。相反,你需要使用一个系统,该系统允许你在保留数据库中任何现有数据的同时逐步更新数据库架构。EF Core 迁移就是这样的系统。

迁移很快会变得复杂,因此超出了本书的范围。你可以在以下链接中了解更多信息:docs.microsoft.com/en-us/ef/core/managing-schemas/migrations/

实践与探索

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

练习 10.1 – 测试你的知识

回答以下问题:

  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.2 – 实践使用不同的序列化格式导出数据

Chapter10解决方案/工作区中,创建一个名为Exercise02的控制台应用程序,该程序查询 Northwind 数据库中的所有类别和产品,并使用.NET 提供的至少三种序列化格式对数据进行序列化。哪种序列化格式使用的字节数最少?

练习 10.3 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-10---working-with-data-using-entity-framework-core

练习 10.4 – 探索 NoSQL 数据库

本章重点介绍了 SQL Server 和 SQLite 等 RDBMS。如果你想了解更多关于 Cosmos DB 和 MongoDB 等 NoSQL 数据库的信息,以及如何使用它们与 EF Core,那么我推荐以下链接:

总结

在本章中,你学习了如何连接到现有数据库,如何执行简单的 LINQ 查询并处理结果,如何使用过滤的包含,如何添加、修改和删除数据,以及如何为现有数据库(如 Northwind)构建实体数据模型。你还学习了如何定义 Code First 模型,并使用它创建新数据库并填充数据。

在下一章中,你将学习如何编写更高级的 LINQ 查询,以进行选择、过滤、排序、连接和分组。

第十一章:使用 LINQ 查询和操作数据

本章是关于语言集成查询LINQ)表达式的。LINQ 是一系列语言扩展,它增加了处理项目序列的能力,然后对其进行过滤、排序,并将其投影到不同的输出中。

本章将涵盖以下主题:

  • 编写 LINQ 表达式

  • 使用 LINQ 处理集合

  • 将 LINQ 与 EF Core 结合使用

  • 用语法糖美化 LINQ 语法

  • 使用并行 LINQ 进行多线程处理

  • 创建自己的 LINQ 扩展方法

  • 使用 LINQ to XML

编写 LINQ 表达式

尽管我们在第十章使用 Entity Framework Core 处理数据中写了一些 LINQ 表达式,但它们并非重点,因此我没有适当地解释 LINQ 的工作原理,所以现在让我们花时间来正确理解它们。

何为 LINQ?

LINQ 包含多个部分;有些是必选的,有些是可选的:

  • 扩展方法(必选):这些包括WhereOrderBySelect等示例。正是这些方法提供了 LINQ 的功能。

  • LINQ 提供程序(必选):这些包括用于处理内存中对象的 LINQ to Objects、用于处理存储在外部数据库中并由 EF Core 建模的数据的 LINQ to Entities,以及用于处理存储为 XML 的数据的 LINQ to XML。这些提供程序是针对不同类型的数据执行 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 个扩展方法,如下表总结:

方法(s) 描述
First, FirstOrDefault, Last, LastOrDefault 获取序列中的第一个或最后一个项,如果没有则抛出异常,或者返回类型的默认值,例如,int0和引用类型的null
Where 返回与指定筛选器匹配的项序列。
Single, SingleOrDefault 返回与特定筛选器匹配的项,如果没有恰好一个匹配项,则抛出异常,或者返回类型的默认值。
ElementAt, ElementAtOrDefault 返回指定索引位置的项,如果没有该位置的项,则抛出异常,或者返回类型的默认值。.NET 6 中新增了可以传入Index而不是int的重载,这在处理Span<T>序列时更高效。
Select, SelectMany 将项投影到不同形状,即不同类型,并展平嵌套的项层次结构。
OrderBy, OrderByDescending, ThenBy, ThenByDescending 按指定字段或属性排序项。
Reverse 反转项的顺序。
GroupBy, GroupJoin, Join 对两个序列进行分组和/或连接。
Skip, SkipWhile 跳过一定数量的项;或在表达式为true时跳过。
Take, TakeWhile 获取一定数量的项;或在表达式为true时获取。.NET 6 中新增了Take的重载,可以传入一个Range,例如,Take(range: 3..⁵)表示从开始处算起第 3 项到结束处算起第 5 项的子集,或者可以用Take(4..)代替Skip(4)
Aggregate, Average, Count, LongCount, Max, Min, Sum 计算聚合值。
TryGetNonEnumeratedCount Count()检查序列上是否实现了Count属性并返回其值,或者枚举整个序列以计算其项数。.NET 6 中新增了这个方法,它仅检查Count,如果缺失则返回false并将out参数设置为0,以避免潜在的性能不佳的操作。
All, Any, Contains 如果所有或任何项匹配筛选器,或者序列包含指定项,则返回true
Cast 将项转换为指定类型。在编译器可能抱怨的情况下,将非泛型对象转换为泛型类型时非常有用。
OfType 移除与指定类型不匹配的项。
Distinct 移除重复项。
Except, Intersect, Union 执行返回集合的操作。集合不能有重复项。尽管输入可以是任何序列,因此输入可以有重复项,但结果始终是一个集合。
Chunk 将序列分割成定长批次。
Append, Concat, Prepend 执行序列合并操作。
Zip 基于项的位置对两个序列执行匹配操作,例如,第一个序列中位置 1 的项与第二个序列中位置 1 的项匹配。.NET 6 中新增了对三个序列的匹配操作。以前,您需要运行两次两个序列的重载才能达到相同目的。
ToArray, ToList, ToDictionary, ToHashSet, ToLookup 将序列转换为数组或集合。这些是唯一执行 LINQ 表达式的扩展方法。
DistinctBy, ExceptBy, IntersectBy, UnionBy, MinBy, MaxBy .NET 6 中新增了By扩展方法。它们允许在项的子集上进行比较,而不是整个项。例如,您可以仅通过比较他们的LastNameDateOfBirth来移除重复项,而不是通过比较整个Person对象。

Enumerable类还包含一些非扩展方法,如下表所示:

方法 描述
Empty<T> 返回指定类型T的空序列。它对于向需要IEnumerable<T>的方法传递空序列非常有用。
Range start值开始返回包含count个整数的序列。例如,Enumerable.Range(start: 5, count: 3)将包含整数 5、6 和 7。
Repeat 返回一个包含相同element重复count次的序列。例如,Enumerable.Repeat(element: "5", count: 3)将包含字符串值“5”、“5”和“5”。

理解延迟执行

LINQ 使用延迟执行。重要的是要理解,调用这些扩展方法中的大多数并不会执行查询并获取结果。这些扩展方法中的大多数返回一个代表问题而非答案的 LINQ 表达式。让我们来探讨:

  1. 使用您偏好的代码编辑器创建一个名为Chapter11的新解决方案/工作区。

  2. 添加一个控制台应用项目,如下表所定义:

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

    2. 工作区/解决方案文件和文件夹:Chapter11

    3. 项目文件和文件夹:LinqWithObjects

  3. Program.cs中,删除现有代码并静态导入Console

  4. 添加语句以定义一个string值序列,表示在办公室工作的人员,如下列代码所示:

    // a string array is a sequence that implements IEnumerable<string>
    string[] names = new[] { "Michael", "Pam", "Jim", "Dwight", 
      "Angela", "Kevin", "Toby", "Creed" };
    WriteLine("Deferred execution");
    // Question: Which names end with an M?
    // (written using a LINQ extension method)
    var query1 = names.Where(name => name.EndsWith("m"));
    // Question: Which names end with an M?
    // (written using LINQ query comprehension syntax)
    var query2 = from name in names where name.EndsWith("m") select name; 
    
  5. 要提出问题并获得答案,即执行查询,您必须具体化它,通过调用诸如ToArrayToLookup之类的“To”方法之一,或者通过枚举查询,如下列代码所示:

    // 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
    } 
    
  6. 运行控制台应用并注意结果,如下所示:

    Deferred execution
    Pam 
    

由于延迟执行,在输出第一个结果Pam后,如果原始数组值发生改变,那么当我们再次循环时,将不再有匹配项,因为Jim已变为Jimmy,且不再以M结尾,因此只输出Pam

在我们深入细节之前,让我们放慢脚步,逐一查看一些常见的 LINQ 扩展方法及其使用方法。

使用 Where 过滤实体

LINQ 最常见的用途是使用Where扩展方法对序列中的项进行过滤。让我们通过定义一个名字序列,然后对其应用 LINQ 操作来探索过滤:

  1. 在项目文件中,注释掉启用隐式引用的元素,如下列标记中高亮所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
     **<!--<ImplicitUsings>enable</ImplicitUsings>-->**
      </PropertyGroup>
    </Project> 
    
  2. Program.cs中,尝试对名字数组调用Where扩展方法,如下列代码所示:

    WriteLine("Writing queries"); 
    var query = names.W 
    
  3. 当你尝试输入Where方法时,注意它从字符串数组的 IntelliSense 成员列表中缺失,如图 11.1所示:

    图 11.1:缺少 Where 扩展方法的 IntelliSense

    这是因为Where是一个扩展方法。它并不存在于数组类型上。为了使Where扩展方法可用,我们必须导入System.Linq命名空间。这在新的.NET 6 项目中默认是隐式导入的,但我们禁用了它。

  4. 在项目文件中,取消注释启用隐式引用的元素。

  5. 重新输入Where方法,并注意 IntelliSense 列表现在包括了由Enumerable类添加的扩展方法,如图 11.2所示:

    图 11.2:IntelliSense 显示 LINQ Enumerable 扩展方法

  6. 当你输入Where方法的括号时,IntelliSense 告诉我们,要调用Where,我们必须传入一个Func<string, bool>委托的实例。

  7. 输入一个表达式以创建Func<string, bool>委托的新实例,目前请注意我们尚未提供方法名,因为我们将在下一步定义它,如下列代码所示:

    var query = names.Where(new Func<string, bool>( )) 
    

Func<string, bool>委托告诉我们,对于传递给该方法的每个string变量,该方法必须返回一个bool值。如果方法返回true,则表示我们应该在结果中包含该string,如果方法返回false,则表示我们应该排除它。

针对命名方法

让我们定义一个只包含长度超过四个字符的名字的方法:

  1. Program.cs底部,定义一个方法,该方法将只包含长度超过四个字符的名字,如下列代码所示:

    static bool NameLongerThanFour(string name)
    {
      return name.Length > 4;
    } 
    
  2. NameLongerThanFour方法上方,将方法名传递给Func<string, bool>委托,然后遍历查询项,如下列高亮代码所示:

    var query = names.Where(
      new Func<string, bool>(**NameLongerThanFour**));
    **foreach** **(****string** **item** **in** **query)**
    **{**
     **WriteLine(item);**
    **}** 
    
  3. 运行代码并查看结果,注意只有长度超过四个字母的名字被列出,如下列输出所示:

    Writing queries
    Michael 
    Dwight 
    Angela 
    Kevin 
    Creed 
    

通过移除显式委托实例化来简化代码

我们可以通过删除Func<string, bool>委托的显式实例化来简化代码,因为 C#编译器可以为我们实例化委托:

  1. 为了帮助你通过逐步改进的代码学习,复制并粘贴查询

  2. 注释掉第一个示例,如下面的代码所示:

    // var query = names.Where(
    //   new Func<string, bool>(NameLongerThanFour)); 
    
  3. 修改副本以删除委托的显式实例化,如下面的代码所示:

    var query = names.Where(NameLongerThanFour); 
    
  4. 运行代码并注意它具有相同的行为。

针对 lambda 表达式

我们可以使用lambda 表达式代替命名方法,进一步简化代码。

虽然一开始看起来可能很复杂,但 lambda 表达式只是一个无名函数。它使用=>(读作“转到”)符号来指示返回值:

  1. 复制并粘贴查询,注释第二个示例,并修改查询,如下面的代码所示:

    var query = names.Where(name => name.Length > 4); 
    

    请注意,lambda 表达式的语法包括了NameLongerThanFour方法的所有重要部分,但仅此而已。lambda 表达式只需要定义以下内容:

    • 输入参数的名称:name

    • 返回值表达式:name.Length > 4

    name输入参数的类型是从序列包含string值这一事实推断出来的,并且返回类型必须是一个bool值,这是由Where工作的委托定义的,因此=>符号后面的表达式必须返回一个bool值。

    编译器为我们完成了大部分工作,因此我们的代码可以尽可能简洁。

  2. 运行代码并注意它具有相同的行为。

对实体进行排序

其他常用的扩展方法是OrderByThenBy,用于对序列进行排序。

如果前一个方法返回另一个序列,即实现IEnumerable<T>接口的类型,则可以链接扩展方法。

使用 OrderBy 按单个属性排序

让我们继续使用当前项目来探索排序:

  1. 在现有查询的末尾添加对OrderBy的调用,如下面的代码所示:

    var query = names
      .Where(name => name.Length > 4)
      .OrderBy(name => name.Length); 
    

    最佳实践:将 LINQ 语句格式化,使每个扩展方法调用都发生在一行上,以便更容易阅读。

  2. 运行代码并注意,现在名字按最短的先排序,如下面的输出所示:

    Kevin 
    Creed 
    Dwight 
    Angela 
    Michael 
    

要将最长的名字放在前面,您将使用OrderByDescending

使用 ThenBy 按后续属性排序

我们可能希望按多个属性排序,例如,对相同长度的名字按字母顺序排序:

  1. 在现有查询的末尾添加对ThenBy方法的调用,如下面的代码中突出显示的那样:

    var query = names
      .Where(name => name.Length > 4)
      .OrderBy(name => name.Length)
     **.ThenBy(name => name);** 
    
  2. 运行代码并注意以下排序顺序的微小差异。在长度相同的名字组中,名字按string的完整值进行字母排序,因此Creed出现在Kevin之前,Angela出现在Dwight之前,如下面的输出所示:

    Creed 
    Kevin 
    Angela 
    Dwight 
    Michael 
    

使用 var 或指定类型声明查询

在编写 LINQ 表达式时,使用var声明查询对象很方便。这是因为随着您在 LINQ 表达式上的工作,类型经常发生变化。例如,我们的查询最初是IEnumerable<string>,目前是IOrderedEnumerable<string>

  1. 将鼠标悬停在var关键字上,并注意其类型为IOrderedEnumerable<string>

  2. var替换为实际类型,如下面的代码中突出显示的那样:

    **IOrderedEnumerable<****string****>** query = names
      .Where(name => name.Length > 4)
      .OrderBy(name => name.Length)
      .ThenBy(name => name); 
    

最佳实践:一旦完成查询工作,您可以将声明的类型从var更改为实际类型,以使其更清楚地了解类型是什么。这很容易,因为您的代码编辑器可以告诉您它是什么。

按类型筛选

Where扩展方法非常适合按值筛选,例如文本和数字。但如果序列包含多种类型,并且您想要按特定类型筛选并尊重任何继承层次结构,该怎么办?

想象一下,您有一个异常序列。有数百种异常类型形成了一个复杂的层次结构,部分显示在图 11.3中:

图表描述自动生成

图 11.3:部分异常继承层次结构

让我们探讨按类型筛选:

  1. Program.cs中,定义一个异常派生对象列表,如下面的代码所示:

    WriteLine("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()
    }; 
    
  2. 使用OfType<T>扩展方法编写语句,以删除不是算术异常的异常,并将仅算术异常写入控制台,如下面的代码所示:

    IEnumerable<ArithmeticException> arithmeticExceptionsQuery = 
      exceptions.OfType<ArithmeticException>();
    foreach (ArithmeticException exception in arithmeticExceptionsQuery)
    {
      WriteLine(exception);
    } 
    
  3. 运行代码并注意结果仅包括ArithmeticException类型的异常,或ArithmeticException派生的类型,如下面的输出所示:

    System.OverflowException: Arithmetic operation resulted in an overflow.
    System.DivideByZeroException: Attempted to divide by zero. 
    

使用 LINQ 处理集合和包

集合是数学中最基本的概念之一。集合是一个或多个唯一对象的集合。多重集合,又称,是一个或多个对象的集合,可以有重复项。

您可能还记得在学校学过的维恩图。常见的集合操作包括集合之间的交集并集

让我们创建一个控制台应用程序,该应用程序将定义三个string值数组,用于学徒队列,然后对它们执行一些常见的集合和多重集合操作:

  1. 使用您喜欢的代码编辑器,在Chapter11解决方案/工作区中添加一个名为LinqWithSets的新控制台应用程序:

    1. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择。

    2. 在 Visual Studio Code 中,选择LinqWithSets作为活动 OmniSharp 项目。

  2. Program.cs中,删除现有代码并静态导入Console类型,如下面的代码所示:

    using static System.Console; 
    
  3. Program.cs底部,添加以下方法,该方法将任何string变量序列输出为以逗号分隔的单个string到控制台输出,以及一个可选描述,如下面的代码所示:

    static void Output(IEnumerable<string> cohort, string description = "")
    {
      if (!string.IsNullOrEmpty(description))
      {
        WriteLine(description);
      }
      Write(" ");
      WriteLine(string.Join(", ", cohort.ToArray()));
      WriteLine();
    } 
    
  4. Output方法上方,添加语句以定义三个名称数组,输出它们,然后对它们执行各种集合操作,如下面的代码所示:

    string[] cohort1 = new[]
      { "Rachel", "Gareth", "Jonathan", "George" }; 
    string[] cohort2 = new[]
      { "Jack", "Stephen", "Daniel", "Jack", "Jared" }; 
    string[] cohort3 = new[]
      { "Declan", "Jack", "Jack", "Jasmine", "Conor" }; 
    Output(cohort1, "Cohort 1");
    Output(cohort2, "Cohort 2");
    Output(cohort3, "Cohort 3"); 
    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)"); 
    
  5. 运行代码并查看结果,如下面的输出所示:

    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开头的名称。

到目前为止,我们使用了 LINQ to Objects 提供程序来处理内存中的对象。接下来,我们将使用 LINQ to Entities 提供程序来处理存储在数据库中的实体。

使用 LINQ 与 EF Core

我们已经看过过滤和排序的 LINQ 查询,但没有改变序列中项的形状的查询。这称为投影,因为它涉及将一种形状的项投影到另一种形状。为了学习投影,最好有一些更复杂的类型来操作,所以在下一个项目中,我们将不再使用string序列,而是使用来自 Northwind 示例数据库的实体序列。

我将给出使用 SQLite 的指令,因为它跨平台,但如果你更喜欢使用 SQL Server,请随意。我已包含一些注释代码,以便在你选择时启用 SQL Server。

构建 EF Core 模型

我们必须定义一个 EF Core 模型来表示我们将要操作的数据库和表。我们将手动定义模型以完全控制并防止在CategoriesProducts表之间自动定义关系。稍后,您将使用 LINQ 来连接这两个实体集:

  1. 使用您喜欢的代码编辑器向Chapter11解决方案/工作区中添加一个名为LinqWithEFCore的新控制台应用程序。

  2. 在 Visual Studio Code 中,选择LinqWithEFCore作为活动 OmniSharp 项目。

  3. LinqWithEFCore项目中,添加对 SQLite 和/或 SQL Server 的 EF Core 提供程序的包引用,如下所示:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Sqlite"
        Version="6.0.0" />
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.SqlServer"
        Version="6.0.0" />
    </ItemGroup> 
    
  4. 构建项目以恢复包。

  5. Northwind4Sqlite.sql文件复制到LinqWithEFCore文件夹中。

  6. 在命令提示符或终端中,执行以下命令创建 Northwind 数据库:

    sqlite3 Northwind.db -init Northwind4Sqlite.sql 
    
  7. 请耐心等待,因为这个命令可能需要一段时间来创建数据库结构。最终,您将看到 SQLite 命令提示符,如下所示:

     -- Loading resources from Northwind.sql 
    SQLite version 3.36.0 2021-08-02 15:20:15
    Enter ".help" for usage hints.
    sqlite> 
    
  8. 在 macOS 上按 cmd + D 或在 Windows 上按 Ctrl + C 退出 SQLite 命令模式。

  9. 向项目中添加三个类文件,分别命名为Northwind.csCategory.csProduct.cs

  10. 修改名为Northwind.cs的类文件,如下所示:

    using Microsoft.EntityFrameworkCore; // DbContext, DbSet<T>
    namespace Packt.Shared;
    // this manages the connection to the database
    public class Northwind : DbContext
    {
      // these 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)
      {
        string path = Path.Combine(
          Environment.CurrentDirectory, "Northwind.db");
        optionsBuilder.UseSqlite($"Filename={path}");
        /*
        string connection = "Data Source=.;" +
            "Initial Catalog=Northwind;" +
            "Integrated Security=true;" +
            "MultipleActiveResultSets=true;";
        optionsBuilder.UseSqlServer(connection);
        */
      }
      protected override void OnModelCreating(
        ModelBuilder modelBuilder)
      {
        modelBuilder.Entity<Product>()
          .Property(product => product.UnitPrice)
          .HasConversion<double>();
      }
    } 
    
  11. 修改名为Category.cs的类文件,如下所示:

    using System.ComponentModel.DataAnnotations;
    namespace Packt.Shared;
    public class Category
    {
      public int CategoryId { get; set; }
      [Required]
      [StringLength(15)]
      public string CategoryName { get; set; } = null!;
      public string? Description { get; set; }
    } 
    
  12. 修改名为Product.cs的类文件,如下所示:

    using System.ComponentModel.DataAnnotations; 
    using System.ComponentModel.DataAnnotations.Schema;
    namespace Packt.Shared;
    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; }
      [Column(TypeName = "money")] // required for SQL Server provider
      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; }
    } 
    
  13. 构建项目并修复任何编译器错误。

    如果您使用的是 Windows 上的 Visual Studio 2022,那么编译后的应用程序将在LinqWithEFCore\bin\Debug\net6.0文件夹中执行,因此除非我们指示应始终将其复制到输出目录,否则它将找不到数据库文件。

  14. 解决方案资源管理器中,右键单击Northwind.db文件并选择属性

  15. 属性中,将复制到输出目录设置为始终复制

过滤和排序序列

现在让我们编写语句来过滤和排序来自表的行序列:

  1. Program.cs中,静态导入Console类型和用于使用 EF Core 和实体模型进行 LINQ 操作的命名空间,如下列代码所示:

    using Packt.Shared; // Northwind, Category, Product
    using Microsoft.EntityFrameworkCore; // DbSet<T>
    using static System.Console; 
    
  2. Program.cs底部,编写一个方法来过滤和排序产品,如下列代码所示:

    static void FilterAndSort()
    {
      using (Northwind 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等方法将强制执行查询并具体化结果。

  3. Program.cs中的命名空间导入之后,调用FilterAndSort方法。

  4. 运行代码并查看结果,如下列输出所示:

    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 语句:

SELECT * FROM Products; 

第十章使用 Entity Framework Core 处理数据中,您学习了如何记录针对 SQLite 执行的 SQL 命令,以便您可以亲自查看。

将序列投影到新类型

在查看投影之前,我们需要回顾对象初始化语法。如果您定义了一个类,那么您可以使用类名、new()和花括号来设置字段和属性的初始值,如下列代码所示:

public class Person
{
  public string Name { get; set; }
  public DateTime DateOfBirth { get; set; }
}
Person knownTypeObject = new()
{
  Name = "Boris Johnson",
  DateOfBirth = new(year: 1964, month: 6, day: 19)
}; 

C# 3.0 及更高版本允许使用var关键字实例化匿名类型,如下列代码所示:

var anonymouslyTypedObject = new
{
  Name = "Boris Johnson",
  DateOfBirth = new DateTime(year: 1964, month: 6, day: 19)
}; 

尽管我们没有指定类型,但编译器可以从设置的两个属性NameDateOfBirth推断出匿名类型。编译器可以从分配的值推断出这两个属性的类型:一个字符串字面量和一个新的日期/时间值实例。

当编写 LINQ 查询以将现有类型投影到新类型而不必显式定义新类型时,此功能特别有用。由于类型是匿名的,因此这只能与var声明的局部变量一起工作。

让我们通过添加对Select方法的调用,将Product类的实例投影到仅具有三个属性的新匿名类型的实例,从而使针对数据库表执行的 SQL 命令更高效:

  1. FilterAndSort中,添加一条语句以扩展 LINQ 查询,使用Select方法仅返回我们需要的三个属性(即表列),并修改foreach语句以使用var关键字和投影 LINQ 表达式,如下所示高亮显示:

    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:");
    foreach (**var** **p** **in** **projectedProducts**)
    { 
    
  2. 将鼠标悬停在Select方法调用中的new关键字和foreach语句中的var关键字上,并注意它是一个匿名类型,如图 11.4所示:

    图 11.4:LINQ 投影期间使用的匿名类型

  3. 运行代码并确认输出与之前相同。

连接和分组序列

连接和分组有两种扩展方法:

  • Join:此方法有四个参数:您想要连接的序列,要匹配的左侧序列上的属性或属性,要匹配的右侧序列上的属性或属性,以及一个投影。

  • GroupJoin:此方法具有相同的参数,但它将匹配项合并到一个组对象中,该对象具有用于匹配值的Key属性和用于多个匹配项的IEnumerable<T>类型。

连接序列

让我们在处理两个表:CategoriesProducts时探索这些方法:

  1. Program.cs底部,创建一个方法来选择类别和产品,将它们连接起来并输出,如下所示:

    static void JoinCategoriesAndProducts()
    {
      using (Northwind 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 item in queryJoin)
        {
          WriteLine("{0}: {1} is in {2}.",
            arg0: item.ProductId,
            arg1: item.ProductName,
            arg2: item.CategoryName);
        }
      }
    } 
    

    在连接中,有两个序列,外部内部。在前面的示例中,categories是外部序列,products是内部序列。

  2. Program.cs顶部,注释掉对FilterAndSort的调用,改为调用JoinCategoriesAndProducts

  3. 运行代码并查看结果。请注意,对于 77 种产品中的每一种,都有一行输出,如下所示的输出(编辑后仅包括前 10 项):

    1: Chai is in Beverages. 
    2: Chang is in Beverages.
    3: Aniseed Syrup is in Condiments.
    4: Chef Anton's Cajun Seasoning is in Condiments. 
    5: Chef Anton's Gumbo Mix is in Condiments.
    6: Grandma's Boysenberry Spread is in Condiments. 
    7: Uncle Bob's Organic Dried Pears is in Produce. 
    8: Northwoods Cranberry Sauce is in Condiments.
    9: Mishi Kobe Niku is in Meat/Poultry. 
    10: Ikura is in Seafood.
    ... 
    
  4. 在现有查询的末尾,调用OrderBy方法按CategoryName排序,如下所示:

    .OrderBy(cp => cp.CategoryName); 
    
  5. 运行代码并查看结果。请注意,对于 77 种产品中的每一种,都有一行输出,结果首先显示Beverages类别中的所有产品,然后是Condiments类别,依此类推,如下所示的部分输出:

    1: Chai is in Beverages. 
    2: Chang is in Beverages.
    24: Guaraná Fantástica is in Beverages. 
    34: Sasquatch Ale is in Beverages.
    35: Steeleye Stout is in Beverages. 
    38: Côte de Blaye is in Beverages. 
    39: Chartreuse verte is in Beverages. 
    43: Ipoh Coffee is in Beverages.
    67: Laughing Lumberjack Lager is in Beverages. 
    70: Outback Lager is in Beverages.
    75: Rhönbräu Klosterbier is in Beverages. 
    76: Lakkalikööri is in Beverages.
    3: Aniseed Syrup is in Condiments.
    4: Chef Anton's Cajun Seasoning is in Condiments.
    ... 
    

分组连接序列

  1. Program.cs底部,创建一个方法来分组和连接,显示组名,然后显示每个组内的所有项,如下列代码所示:

    static void GroupJoinCategoriesAndProducts()
    {
      using (Northwind 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 category in queryGroup)
        {
          WriteLine("{0} has {1} products.",
            arg0: category.CategoryName,
            arg1: category.Products.Count());
          foreach (var product in category.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 在内存中执行更复杂的处理。但通常,这效率较低。

  2. Program.cs顶部,注释掉之前的方法调用,并调用GroupJoinCategoriesAndProducts

  3. 运行代码,查看结果,并注意每个类别内的产品已按其名称排序,正如查询中所定义,并在以下部分输出中所示:

    Beverages has 12 products.
      Chai
      Chang
      Chartreuse verte
      Côte de Blaye
      Guaraná Fantástica
      Ipoh Coffee
      Lakkalikööri
      Laughing Lumberjack Lager
      Outback Lager
      Rhönbräu Klosterbier
      Sasquatch Ale
      Steeleye Stout
    Condiments has 12 products.
      Aniseed Syrup
      Chef Anton's Cajun Seasoning
      Chef Anton's Gumbo Mix
    ... 
    

聚合序列

有 LINQ 扩展方法可执行聚合函数,如AverageSum。让我们编写一些代码,看看这些方法如何从Products表中聚合信息:

  1. Program.cs底部,创建一个方法来展示聚合扩展方法的使用,如下列代码所示:

    static void AggregateProducts()
    {
      using (Northwind db = new())
      {
        WriteLine("{0,-25} {1,10}",
          arg0: "Product count:",
          arg1: db.Products.Count());
        WriteLine("{0,-25} {1,10:$#,##0.00}",
          arg0: "Highest product price:",
          arg1: db.Products.Max(p => p.UnitPrice));
        WriteLine("{0,-25} {1,10:N0}",
          arg0: "Sum of units in stock:",
          arg1: db.Products.Sum(p => p.UnitsInStock));
        WriteLine("{0,-25} {1,10:N0}",
          arg0: "Sum of units on order:",
          arg1: db.Products.Sum(p => p.UnitsOnOrder));
        WriteLine("{0,-25} {1,10:$#,##0.00}",
          arg0: "Average unit price:",
          arg1: db.Products.Average(p => p.UnitPrice));
        WriteLine("{0,-25} {1,10:$#,##0.00}",
          arg0: "Value of units in stock:",
          arg1: db.Products
            .Sum(p => p.UnitPrice * p.UnitsInStock));
      }
    } 
    
  2. Program.cs顶部,注释掉之前的方法调用,并调用AggregateProducts

  3. 运行代码并查看结果,如下列输出所示:

    Product count:                    77
    Highest product price:       $263.50
    Sum of units in stock:         3,119
    Sum of units on order:           780
    Average unit price:           $28.87
    Value of units in stock:  $74,050.85 
    

用语法糖美化 LINQ 语法

C# 3.0 在 2008 年引入了一些新的语言关键字,以便有 SQL 经验的程序员更容易编写 LINQ 查询。这种语法糖有时被称为LINQ 查询理解语法

考虑以下string值数组:

string[] names = new[] { "Michael", "Pam", "Jim", "Dwight", 
  "Angela", "Kevin", "Toby", "Creed" }; 

要筛选和排序名称,可以使用扩展方法和 lambda 表达式,如下列代码所示:

var query = names
  .Where(name => name.Length > 4)
  .OrderBy(name => name.Length)
  .ThenBy(name => name); 

或者,你可以使用查询理解语法实现相同的结果,如下列代码所示:

var query = from name in names
  where name.Length > 4
  orderby name.Length, name 
  select name; 

编译器会将查询理解语法转换为等效的扩展方法和 lambda 表达式。

select关键字在 LINQ 查询理解语法中始终是必需的。当使用扩展方法和 lambda 表达式时,Select扩展方法是可选的,因为如果你没有调用Select,那么整个项会被隐式选中。

并非所有扩展方法都有 C#关键字等效项,例如,常用的SkipTake扩展方法,用于为大量数据实现分页。

使用查询理解语法无法编写跳过和获取的查询,因此我们可以使用所有扩展方法编写查询,如下列代码所示:

var query = names
  .Where(name => name.Length > 4)
  .Skip(80)
  .Take(10); 

或者,你可以将查询理解语法括在括号内,然后切换到使用扩展方法,如下列代码所示:

var query = (from name in names
  where name.Length > 4
  select name)
  .Skip(80)
  .Take(10); 

良好实践:学习使用 Lambda 表达式的扩展方法和查询理解语法两种编写 LINQ 查询的方式,因为你可能需要维护使用这两种方式的代码。

使用并行 LINQ 的多线程

默认情况下,LINQ 查询仅使用一个线程执行。并行 LINQPLINQ)是一种启用多个线程执行 LINQ 查询的简便方法。

良好实践:不要假设使用并行线程会提高应用程序的性能。始终测量实际的计时和资源使用情况。

创建一个从多线程中受益的应用

为了实际演示,我们将从一段仅使用单个线程计算 45 个整数的斐波那契数的代码开始。我们将使用StopWatch类型来测量性能变化。

我们将使用操作系统工具来监控 CPU 和 CPU 核心的使用情况。如果你没有多个 CPU 或至少多个核心,那么这个练习就不会显示太多信息!

  1. 使用你偏好的代码编辑器,在Chapter11解决方案/工作区中添加一个名为LinqInParallel的新控制台应用。

  2. 在 Visual Studio Code 中,选择LinqInParallel作为活动的 OmniSharp 项目。

  3. Program.cs中,删除现有语句,然后导入System.Diagnostics命名空间,以便我们可以使用StopWatch类型,并静态导入System.Console类型。

  4. 添加语句以创建一个秒表来记录时间,等待按键开始计时,创建 45 个整数,计算每个整数的最后一个斐波那契数,停止计时器,并显示经过的毫秒数,如下面的代码所示:

    Stopwatch watch = new(); 
    Write("Press ENTER to start. "); 
    ReadLine();
    watch.Start();
    int max = 45;
    IEnumerable<int> numbers = Enumerable.Range(start: 1, count: max);
    WriteLine($"Calculating Fibonacci sequence up to {max}. Please wait...");
    int[] fibonacciNumbers = numbers
      .Select(number => Fibonacci(number)).ToArray(); 
    watch.Stop();
    WriteLine("{0:#,##0} elapsed milliseconds.",
      arg0: watch.ElapsedMilliseconds);
    Write("Results:");
    foreach (int number in fibonacciNumbers)
    {
      Write($" {number}");
    }
    static int Fibonacci(int term) =>
      term switch
      {
        1 => 0,
        2 => 1,
        _ => Fibonacci(term - 1) + Fibonacci(term - 2)
      }; 
    
  5. 运行代码,但不要按 Enter 键启动秒表,因为我们首先需要确保监控工具显示处理器活动。

使用 Windows

  1. 如果你使用的是 Windows,那么右键点击 Windows 开始按钮或按 Ctrl + Alt + Delete,然后点击任务管理器

  2. 任务管理器窗口底部,点击更多详细信息

  3. 任务管理器窗口顶部,点击性能选项卡。

  4. 右键点击CPU 利用率图表,选择更改图表为,然后选择逻辑处理器

使用 macOS

  1. 如果你使用的是 macOS,那么启动活动监视器

  2. 导航至视图 | 更新频率非常频繁(1 秒)

  3. 要查看 CPU 图表,请导航至窗口 | CPU 历史

对于所有操作系统

  1. 调整你的监控工具和代码编辑器,使它们并排显示。

  2. 等待 CPU 稳定后,按 Enter 键启动秒表并运行查询。结果应显示为经过的毫秒数,如下面的输出所示:

    Press ENTER to start. 
    Calculating Fibonacci sequence up to 45\. Please wait...
    17,624 elapsed milliseconds.
    Results: 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368 75025 121393 196418 317811 514229 832040 1346269 2178309 3524578 5702887 9227465 14930352 24157817 39088169 63245986 102334155 165580141 267914296 433494437 701408733 
    

    监控工具可能会显示,有一两个 CPU 使用率最高,随着时间交替变化,其他 CPU 可能同时执行后台任务,如垃圾收集器,因此其他 CPU 或核心不会完全空闲,但工作显然没有均匀分布在所有可能的 CPU 或核心上。还要注意,一些逻辑处理器达到了 100%的峰值。

  3. Program.cs中,修改查询以调用AsParallel扩展方法并对结果序列进行排序,因为在并行处理时结果可能会变得无序,如下面的代码所示:

    int[] fibonacciNumbers = numbers.**AsParallel()**
      .Select(number => Fibonacci(number))
     **.OrderBy(number => number)**
      .ToArray(); 
    

    最佳实践:切勿在查询的末尾调用AsParallel。这没有任何作用。你必须在调用AsParallel之后至少执行一个操作,以便该操作可以并行化。.NET 6 引入了一个代码分析器,它会警告这种误用。

  4. 运行代码,等待监控工具中的 CPU 图表稳定,然后按 Enter 键启动秒表并运行查询。这次,应用程序应该在更短的时间内完成(尽管可能不会像你希望的那样短——管理那些多线程需要额外的努力!):

    Press ENTER to start. 
    Calculating Fibonacci sequence up to 45\. Please wait...
    9,028 elapsed milliseconds.
    Results: 0 1 1 2 3 5 8 13 21 34 55 89 144 233 377 610 987 1597 2584 4181 6765 10946 17711 28657 46368 75025 121393 196418 317811 514229 832040 1346269 2178309 3524578 5702887 9227465 14930352 24157817 39088169 63245986 102334155 165580141 267914296 433494437 701408733 
    
  5. 监控工具应该显示所有 CPU 都平均用于执行 LINQ 查询,并注意没有逻辑处理器达到 100%的峰值,因为工作分布更为均匀。

你将在第十二章使用多任务提高性能和可扩展性中了解更多关于管理多线程的知识。

创建自己的 LINQ 扩展方法

第六章实现接口和继承类中,你学习了如何创建自己的扩展方法。要创建 LINQ 扩展方法,你所需要做的就是扩展IEnumerable<T>类型。

最佳实践:将你自己的扩展方法放在一个单独的类库中,以便它们可以轻松地作为自己的程序集或 NuGet 包部署。

我们将以改进Average扩展方法为例。一个受过良好教育的学童会告诉你,平均可以指三种情况之一:

  • 均值:将数字求和并除以计数。

  • 众数:最常见的数字。

  • 中位数:当数字排序时位于中间的数字。

微软实现的Average扩展方法计算的是均值。我们可能希望为ModeMedian定义自己的扩展方法:

  1. LinqWithEFCore项目中,添加一个名为MyLinqExtensions.cs的新类文件。

  2. 按照以下代码所示修改类:

    namespace System.Linq; // extend Microsoft's namespace
    public static class MyLinqExtensions
    {
      // this is a chainable LINQ extension method
      public static IEnumerable<T> ProcessSequence<T>(
        this IEnumerable<T> sequence)
      {
        // you could do some processing here
        return sequence;
      }
      public static IQueryable<T> ProcessSequence<T>(
        this IQueryable<T> sequence)
      {
        // you could do some processing here
        return sequence;
      }
      // these are scalar LINQ extension methods
      public static int? Median(
        this IEnumerable<int?> sequence)
      {
        var ordered = sequence.OrderBy(item => item);
        int middlePosition = ordered.Count() / 2;
        return ordered.ElementAt(middlePosition);
      }
      public static int? Median<T>(
        this IEnumerable<T> sequence, Func<T, int?> selector)
      {
        return sequence.Select(selector).Median();
      }
      public static decimal? Median(
        this IEnumerable<decimal?> sequence)
      {
        var ordered = sequence.OrderBy(item => item);
        int middlePosition = ordered.Count() / 2;
        return ordered.ElementAt(middlePosition);
      }
      public static decimal? Median<T>(
        this IEnumerable<T> sequence, Func<T, decimal?> selector)
      {
        return sequence.Select(selector).Median();
      }
      public static int? Mode(
        this IEnumerable<int?> sequence)
      {
        var grouped = sequence.GroupBy(item => item);
        var orderedGroups = grouped.OrderByDescending(
          group => group.Count());
        return orderedGroups.FirstOrDefault()?.Key;
      }
      public static int? Mode<T>(
        this IEnumerable<T> sequence, Func<T, int?> selector)
      {
        return sequence.Select(selector)?.Mode();
      }
      public static decimal? Mode(
        this IEnumerable<decimal?> sequence)
      {
        var grouped = sequence.GroupBy(item => item);
        var orderedGroups = grouped.OrderByDescending(
          group => group.Count());
        return orderedGroups.FirstOrDefault()?.Key;
      }
      public static decimal? Mode<T>(
        this IEnumerable<T> sequence, Func<T, decimal?> selector)
      {
        return sequence.Select(selector).Mode();
      }
    } 
    

如果这个类位于一个单独的类库中,要使用你的 LINQ 扩展方法,你只需引用类库程序集,因为System.Linq命名空间已经隐式导入。

**警告!**上述扩展方法中除一个外,都不能与IQueryable序列(如 LINQ to SQLite 或 LINQ to SQL Server 使用的序列)一起使用,因为我们没有实现将我们的代码翻译成底层查询语言(如 SQL)的方法。

尝试使用链式扩展方法

首先,我们将尝试将ProcessSequence方法与其他扩展方法链接起来:

  1. Program.cs中,在FilterAndSort方法中,修改Products的 LINQ 查询以调用您的自定义链式扩展方法,如下面的代码中突出显示的那样:

    DbSet<Product>? allProducts = db.Products;
    if (allProducts is null)
    {
      WriteLine("No products found.");
      return;
    }
    **IQueryable<Product> processedProducts = allProducts.ProcessSequence();**
    IQueryable<Product> filteredProducts = **processedProducts**
      .Where(product => product.UnitPrice < 10M); 
    
  2. Program.cs中,取消注释FilterAndSort方法,并注释掉对其他方法的任何调用。

  3. 运行代码并注意您看到与之前相同的输出,因为您的方法没有修改序列。但现在您知道如何通过自己的功能扩展 LINQ 表达式。

尝试使用众数和中位数方法

其次,我们将尝试使用ModeMedian方法来计算其他类型的平均值:

  1. Program.cs底部,创建一个方法来输出产品的UnitsInStockUnitPrice的平均值、中位数和众数,使用您的自定义扩展方法和内置的Average扩展方法,如下面的代码所示:

    static void CustomExtensionMethods()
    {
      using (Northwind db = new())
      {
        WriteLine("Mean units in stock: {0:N0}",
          db.Products.Average(p => p.UnitsInStock));
        WriteLine("Mean unit price: {0:$#,##0.00}",
          db.Products.Average(p => p.UnitPrice));
        WriteLine("Median units in stock: {0:N0}",
          db.Products.Median(p => p.UnitsInStock));
        WriteLine("Median unit price: {0:$#,##0.00}",
          db.Products.Median(p => p.UnitPrice));
        WriteLine("Mode units in stock: {0:N0}",
          db.Products.Mode(p => p.UnitsInStock));
        WriteLine("Mode unit price: {0:$#,##0.00}",
          db.Products.Mode(p => p.UnitPrice));
      }
    } 
    
  2. Program.cs中,注释掉任何之前的方法调用,并调用CustomExtensionMethods

  3. 运行代码并查看结果,如下面的输出所示:

    Mean units in stock: 41 
    Mean unit price: $28.87 
    Median units in stock: 26 
    Median unit price: $19.50 
    Mode units in stock: 0 
    Mode unit price: $18.00 
    

有四种产品的单价为$18.00。有五种产品的库存量为 0。

使用 LINQ to XML 进行工作

LINQ to XML是一种 LINQ 提供程序,允许您查询和操作 XML。

使用 LINQ to XML 生成 XML

让我们创建一个方法将Products表转换为 XML:

  1. LinqWithEFCore项目中,在Program.cs顶部导入System.Xml.Linq命名空间。

  2. Program.cs底部,创建一个方法以 XML 格式输出产品,如下面的代码所示:

    static void OutputProductsAsXml()
    {
      using (Northwind db = new())
      {
        Product[] productsArray = db.Products.ToArray();
        XElement xml = new("products",
          from p in productsArray
          select new XElement("product",
            new XAttribute("id",  p.ProductId),
            new XAttribute("price", p.UnitPrice),
           new XElement("name", p.ProductName)));
        WriteLine(xml.ToString());
      }
    } 
    
  3. Program.cs中,注释掉之前的方法调用,并调用OutputProductsAsXml

  4. 运行代码,查看结果,并注意生成的 XML 结构与 LINQ to XML 语句在前述代码中声明性地描述的元素和属性相匹配,如下面的部分输出所示:

    <products>
      <product id="1" price="18">
        <name>Chai</name>
      </product>
      <product id="2" price="19">
        <name>Chang</name>
      </product>
    ... 
    

使用 LINQ to XML 读取 XML

您可能希望使用 LINQ to XML 轻松查询或处理 XML 文件:

  1. LinqWithEFCore项目中,添加一个名为settings.xml的文件。

  2. 修改其内容,如下面的标记所示:

    <?xml version="1.0" encoding="utf-8" ?>
    <appSettings>
      <add key="color" value="red" />
      <add key="size" value="large" />
      <add key="price" value="23.99" />
    </appSettings> 
    

    如果您使用的是 Windows 上的 Visual Studio 2022,那么编译后的应用程序将在LinqWithEFCore\bin\Debug\net6.0文件夹中执行,因此除非我们指示它始终复制到输出目录,否则它将找不到settings.xml文件。

  3. 解决方案资源管理器中,右键单击settings.xml文件并选择属性

  4. 属性中,将复制到输出目录设置为始终复制

  5. Program.cs底部,创建一个方法来完成这些任务,如下面的代码所示:

    • 加载 XML 文件。

    • 使用 LINQ to XML 搜索名为appSettings的元素及其名为add的后代。

    • 将 XML 投影成具有KeyValue属性的匿名类型数组。

    • 遍历数组以显示结果:

    static void ProcessSettings()
    {
      XDocument doc = XDocument.Load("settings.xml");
      var appSettings = doc.Descendants("appSettings")
        .Descendants("add")
        .Select(node => new
        {
          Key = node.Attribute("key")?.Value,
          Value = node.Attribute("value")?.Value
        }).ToArray();
      foreach (var item in appSettings)
      {
        WriteLine($"{item.Key}: {item.Value}");
      }
    } 
    
  6. Program.cs中,注释掉之前的方法调用,并调用ProcessSettings

  7. 运行代码并查看结果,如下所示:

    color: red 
    size: large 
    price: 23.99 
    

实践与探索

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

练习 11.1 – 测试你的知识

回答以下问题:

  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.2 – 实践 LINQ 查询

Chapter11解决方案/工作区中,创建一个名为Exercise02的控制台应用程序,提示用户输入城市,然后列出该城市中 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 – 探索主题

使用以下页面上的链接,深入了解本章涉及的主题:

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-11---querying-and-manipulating-data-using-linq

总结

本章中,你学习了如何编写 LINQ 查询来选择、投影、过滤、排序、连接和分组多种不同格式的数据,包括 XML,这些都是你每天要执行的任务。

下一章中,你将使用Task类型来提升应用程序的性能。

第十二章:使用多任务处理提高性能和可扩展性

本章旨在通过允许多个操作同时发生,以提高您构建的应用程序的性能、可扩展性和用户生产力。

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

  • 理解进程、线程和任务

  • 监控性能和资源使用情况

  • 异步运行任务

  • 同步访问共享资源

  • 理解asyncawait

理解进程、线程和任务

进程,例如我们创建的每个控制台应用程序,都分配有内存和线程等资源。

线程执行您的代码,逐条语句执行。默认情况下,每个进程只有一个线程,当我们需要同时执行多个任务时,这可能会导致问题。线程还负责跟踪当前已验证的用户以及应遵循的当前语言和区域的任何国际化规则等事项。

Windows 和大多数其他现代操作系统使用抢占式多任务处理,它模拟任务的并行执行。它将处理器时间分配给各个线程,为每个线程分配一个时间片,一个接一个。当当前线程的时间片结束时,它会被挂起,处理器随后允许另一个线程运行一个时间片。

当 Windows 从一个线程切换到另一个线程时,它会保存当前线程的上下文,并重新加载线程队列中下一个线程之前保存的上下文。这个过程需要时间和资源来完成。

作为开发者,如果您有少量复杂的工作且希望完全控制它们,那么您可以创建和管理单独的Thread实例。如果您有一个主线程和多个可以在后台执行的小任务,那么您可以使用ThreadPool类将指向这些作为方法实现的任务的委托实例添加到队列中,它们将自动分配给线程池中的线程。

在本章中,我们将使用Task类型以更高的抽象级别管理线程。

线程可能需要竞争和等待访问共享资源,例如变量、文件和数据库对象。本章后面您将看到用于管理这些资源的各种类型。

根据任务的不同,将执行任务的线程(工作者)数量加倍并不一定会将完成任务所需的时间减半。事实上,它可能会增加任务的持续时间。

最佳实践:切勿假设增加线程数量会提高性能!在未使用多线程的基准代码实现上运行性能测试,然后在使用了多线程的代码实现上再次运行。您还应在尽可能接近生产环境的预生产环境中进行性能测试。

监控性能和资源使用

在我们能够改进任何代码的性能之前,我们需要能够监控其速度和效率,以记录一个基准,然后我们可以据此衡量改进。

评估类型的效率

对于某个场景,最佳类型是什么?要回答这个问题,我们需要仔细考虑我们所说的“最佳”是什么意思,并通过这一点,我们应该考虑以下因素:

  • 功能性:这可以通过检查类型是否提供了你所需的功能来决定。

  • 内存大小:这可以通过类型占用的内存字节数来决定。

  • 性能:这可以通过类型的运行速度来决定。

  • 未来需求:这取决于需求和可维护性的变化。

在存储数字等场景中,将会有多种类型具有相同的功能,因此我们需要考虑内存和性能来做出选择。

如果我们需要存储数百万个数字,那么最佳类型将是占用内存字节数最少的那个。但如果我们只需要存储几个数字,而我们又需要对它们进行大量计算,那么最佳类型将是在特定 CPU 上运行最快的那个。

你已经见过使用sizeof()函数的情况,它显示了内存中一个类型实例所占用的字节数。当我们存储大量值在更复杂的数据结构中,如数组和列表时,我们需要一种更好的方法来测量内存使用情况。

你可以在网上和书籍中阅读大量建议,但确定哪种类型最适合你的代码的唯一方法是自己比较这些类型。

在下一节中,你将学习如何编写代码来监控使用不同类型时的实际内存需求和性能。

今天,short变量可能是最佳选择,但使用int变量可能是更好的选择,尽管它在内存中占用两倍的空间。这是因为我们将来可能需要存储更广泛的值。

开发者经常忽视的一个重要指标是维护性。这是衡量另一个程序员为了理解和修改你的代码需要付出多少努力的指标。如果你做出一个不明显的类型选择,并且没有用有帮助的注释解释这个选择,那么可能会让后来需要修复错误或添加功能的程序员感到困惑。

使用诊断监控性能和内存

System.Diagnostics命名空间包含许多用于监控代码的有用类型。我们将首先查看的有用类型是Stopwatch类型:

  1. 使用你偏好的编程工具创建一个名为Chapter12的新工作区/解决方案。

  2. 添加一个类库项目,如以下列表所定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:Chapter12

    3. 项目文件和文件夹:MonitoringLib

  3. 添加一个控制台应用程序项目,如下所列:

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

    2. 工作区/解决方案文件和文件夹:Chapter12

    3. 项目文件和文件夹:MonitoringApp

  4. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择的项目。

  5. 在 Visual Studio Code 中,选择MonitoringApp作为活动的 OmniSharp 项目。

  6. MonitoringLib项目中,将Class1.cs文件重命名为Recorder.cs

  7. MonitoringApp项目中,添加对MonitoringLib类库的项目引用,如下所示:

    <ItemGroup> 
      <ProjectReference
        Include="..\MonitoringLib\MonitoringLib.csproj" />
    </ItemGroup> 
    
  8. 构建MonitoringApp项目。

有用的 Stopwatch 和 Process 类型成员

Stopwatch类型有一些有用的成员,如下表所示:

成员 描述
Restart 方法 这会将经过时间重置为零,然后启动计时器。
Stop 方法 这会停止计时器。
Elapsed 属性 这是以TimeSpan格式存储的经过时间(例如,小时:分钟:秒)
ElapsedMilliseconds 属性 这是以毫秒为单位的经过时间,存储为Int64值。

Process类型有一些有用的成员,如下表所示:

成员 描述
VirtualMemorySize64 这显示了为进程分配的虚拟内存量,单位为字节。
WorkingSet64 这显示了为进程分配的物理内存量,单位为字节。

实现一个 Recorder 类

我们将创建一个Recorder类,使监控时间和内存资源使用变得简单。为了实现我们的Recorder类,我们将使用StopwatchProcess类:

  1. Recorder.cs中,修改其内容以使用Stopwatch实例记录时间,并使用当前Process实例记录内存使用情况,如下所示:

    using System.Diagnostics; // Stopwatch
    using static System.Console;
    using static System.Diagnostics.Process; // GetCurrentProcess()
    namespace Packt.Shared;
    public static class Recorder
    {
      private static Stopwatch timer = new();
      private static long bytesPhysicalBefore = 0;
      private static long bytesVirtualBefore = 0;
      public static void Start()
      {
        // force two garbage collections to release memory that is
        // no longer referenced but has not been released yet
        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();
        // store the current physical and virtual memory use 
        bytesPhysicalBefore = GetCurrentProcess().WorkingSet64; 
        bytesVirtualBefore = GetCurrentProcess().VirtualMemorySize64; 
        timer.Restart();
      }
      public static void Stop()
      {
        timer.Stop();
        long bytesPhysicalAfter =
          GetCurrentProcess().WorkingSet64;
        long bytesVirtualAfter =
          GetCurrentProcess().VirtualMemorySize64;
        WriteLine("{0:N0} physical bytes used.",
          bytesPhysicalAfter - bytesPhysicalBefore);
        WriteLine("{0:N0} virtual bytes used.",
          bytesVirtualAfter - bytesVirtualBefore);
        WriteLine("{0} time span ellapsed.", timer.Elapsed);
        WriteLine("{0:N0} total milliseconds ellapsed.",
          timer.ElapsedMilliseconds);
      }
    } 
    

    Recorder类的Start方法使用GC类型(垃圾收集器)确保在记录已用内存量之前,收集任何当前已分配但未引用的内存。这是一种高级技术,您几乎不应在应用程序代码中使用。

  2. Program.cs中,编写语句以在生成 10,000 个整数的数组时启动和停止Recorder,如下所示:

    using Packt.Shared; // Recorder
    using static System.Console;
    WriteLine("Processing. Please wait...");
    Recorder.Start();
    // simulate a process that requires some memory resources...
    int[] largeArrayOfInts = Enumerable.Range(
      start: 1, count: 10_000).ToArray();
    // ...and takes some time to complete
    Thread.Sleep(new Random().Next(5, 10) * 1000);
    Recorder.Stop(); 
    
  3. 运行代码并查看结果,如下所示:

    Processing. Please wait...
    655,360 physical bytes used.
    536,576 virtual bytes used.
    00:00:09.0038702 time span ellapsed.
    9,003 total milliseconds ellapsed. 
    

请记住,时间间隔随机在 5 到 10 秒之间,您的结果可能会有所不同。例如,在我的 Mac mini M1 上运行时,虽然物理内存较少,但虚拟内存使用更多,如下所示:

Processing. Please wait...
294,912 physical bytes used.
10,485,760 virtual bytes used.
00:00:06.0074221 time span ellapsed.
6,007 total milliseconds ellapsed. 

测量字符串处理的效率

既然您已经了解了如何使用StopwatchProcess类型来监控您的代码,我们将使用它们来评估处理string变量的最佳方式。

  1. Program.cs中,通过使用多行注释字符/* */将之前的语句注释掉。

  2. 编写语句以创建一个包含 50,000 个int变量的数组,然后使用stringStringBuilder类用逗号作为分隔符将它们连接起来,如下所示:

    int[] numbers = Enumerable.Range(
      start: 1, count: 50_000).ToArray();
    WriteLine("Using string with +");
    Recorder.Start();
    string s = string.Empty; // i.e. ""
    for (int i = 0; i < numbers.Length; i++)
    {
      s += numbers[i] + ", ";
    }
    Recorder.Stop();
    WriteLine("Using StringBuilder");
    Recorder.Start();
    System.Text.StringBuilder builder = new();
    for (int i = 0; i < numbers.Length; i++)
    {
      builder.Append(numbers[i]);
      builder.Append(", ");
    }
    Recorder.Stop(); 
    
  3. 运行代码并查看结果,如下所示:

    Using string with +
    14,883,072 physical bytes used.
    3,609,728 virtual bytes used.
    00:00:01.6220879 time span ellapsed.
    1,622 total milliseconds ellapsed.
    Using StringBuilder
    12,288 physical bytes used.
    0 virtual bytes used.
    00:00:00.0006038 time span ellapsed.
    0 total milliseconds ellapsed. 
    

我们可以总结结果如下:

  • string类使用+运算符大约使用了 14 MB 的物理内存,1.5 MB 的虚拟内存,耗时 1.5 秒。

  • StringBuilder类使用了 12 KB 的物理内存,零虚拟内存,耗时不到 1 毫秒。

在这种情况下,StringBuilder在连接文本时速度快了 1000 多倍,内存效率提高了约 10000 倍!这是因为string连接每次使用时都会创建一个新的string,因为string值是不可变的,所以它们可以安全地池化以供重用。StringBuilder在追加更多字符时创建一个单一缓冲区。

最佳实践:避免在循环内部使用String.Concat方法或+运算符。改用StringBuilder

既然你已经学会了如何使用.NET 内置类型来衡量代码的性能和资源效率,接下来让我们了解一个提供更复杂性能测量的 NuGet 包。

使用 Benchmark.NET 监控性能和内存

有一个流行的.NET 基准测试 NuGet 包,微软在其关于性能改进的博客文章中使用,因此对于.NET 开发者来说,了解其工作原理并用于自己的性能测试是很有益的。让我们看看如何使用它来比较string连接和StringBuilder的性能:

  1. 使用您喜欢的代码编辑器,向名为BenchmarkingChapter12解决方案/工作区添加一个新的控制台应用程序。

  2. 在 Visual Studio Code 中,选择Benchmarking作为活动 OmniSharp 项目。

  3. 添加对 Benchmark.NET 的包引用,记住您可以查找最新版本并使用它,而不是我使用的版本,如下所示:

    <ItemGroup>
      <PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
    </ItemGroup> 
    
  4. 构建项目以恢复包。

  5. Program.cs中,删除现有语句,然后导入运行基准测试的命名空间,如下所示:

    using BenchmarkDotNet.Running; 
    
  6. 添加一个名为StringBenchmarks.cs的新类文件。

  7. StringBenchmarks.cs中,添加语句来定义一个包含每个基准测试所需方法的类,在这种情况下,两个方法都使用string连接或StringBuilder将二十个数字以逗号分隔进行组合,如下所示:

    using BenchmarkDotNet.Attributes; // [Benchmark]
    public class StringBenchmarks
    {
      int[] numbers;
      public StringBenchmarks()
      {
        numbers = Enumerable.Range(
          start: 1, count: 20).ToArray();
      }
      [Benchmark(Baseline = true)]
      public string StringConcatenationTest()
      {
        string s = string.Empty; // e.g. ""
        for (int i = 0; i < numbers.Length; i++)
        {
          s += numbers[i] + ", ";
        }
        return s;
      }
      [Benchmark]
      public string StringBuilderTest()
      {
        System.Text.StringBuilder builder = new();
        for (int i = 0; i < numbers.Length; i++)
        {
          builder.Append(numbers[i]);
          builder.Append(", ");
        }
        return builder.ToString();
      }
    } 
    
  8. Program.cs中,添加一个语句来运行基准测试,如下所示:

    BenchmarkRunner.Run<StringBenchmarks>(); 
    
  9. 在 Visual Studio 2022 中,在工具栏上,将解决方案配置设置为发布

  10. 在 Visual Studio Code 中,在终端中使用dotnet run --configuration Release命令。

  11. 运行控制台应用并注意结果,包括一些报告文件等附属物,以及最重要的,一张总结表显示string拼接平均耗时 412.990 ns,而StringBuilder平均耗时 275.082 ns,如下部分输出及图 12.1所示:

    // ***** BenchmarkRunner: Finish  *****
    // * Export *
      BenchmarkDotNet.Artifacts\results\StringBenchmarks-report.csv
      BenchmarkDotNet.Artifacts\results\StringBenchmarks-report-github.md
      BenchmarkDotNet.Artifacts\results\StringBenchmarks-report.html
    // * Detailed results *
    StringBenchmarks.StringConcatenationTest: DefaultJob
    Runtime = .NET 6.0.0 (6.0.21.37719), X64 RyuJIT; GC = Concurrent Workstation
    Mean = 412.990 ns, StdErr = 2.353 ns (0.57%), N = 46, StdDev = 15.957 ns
    Min = 373.636 ns, Q1 = 413.341 ns, Median = 417.665 ns, Q3 = 420.775 ns, Max = 434.504 ns
    IQR = 7.433 ns, LowerFence = 402.191 ns, UpperFence = 431.925 ns
    ConfidenceInterval = [404.708 ns; 421.273 ns] (CI 99.9%), Margin = 8.282 ns (2.01% of Mean)
    Skewness = -1.51, Kurtosis = 4.09, MValue = 2
    -------------------- Histogram --------------------
    [370.520 ns ; 382.211 ns) | @@@@@@
    [382.211 ns ; 394.583 ns) | @
    [394.583 ns ; 411.300 ns) | @@
    [411.300 ns ; 422.990 ns) | @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
    [422.990 ns ; 436.095 ns) | @@@@@
    ---------------------------------------------------
    StringBenchmarks.StringBuilderTest: DefaultJob
    Runtime = .NET 6.0.0 (6.0.21.37719), X64 RyuJIT; GC = Concurrent Workstation
    Mean = 275.082 ns, StdErr = 0.558 ns (0.20%), N = 15, StdDev = 2.163 ns
    Min = 271.059 ns, Q1 = 274.495 ns, Median = 275.403 ns, Q3 = 276.553 ns, Max = 278.030 ns
    IQR = 2.058 ns, LowerFence = 271.409 ns, UpperFence = 279.639 ns
    ConfidenceInterval = [272.770 ns; 277.394 ns] (CI 99.9%), Margin = 2.312 ns (0.84% of Mean)
    Skewness = -0.69, Kurtosis = 2.2, MValue = 2
    -------------------- Histogram --------------------
    [269.908 ns ; 278.682 ns) | @@@@@@@@@@@@@@@
    ---------------------------------------------------
    // * Summary *
    BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19043.1165 (21H1/May2021Update)
    11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
    .NET SDK=6.0.100
      [Host]     : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
      DefaultJob : .NET 6.0.0 (6.0.21.37719), X64 RyuJIT
    |                  Method |     Mean |   Error |   StdDev | Ratio | RatioSD |
    |------------------------ |---------:|--------:|---------:|------:|--------:|
    | StringConcatenationTest | 413.0 ns | 8.28 ns | 15.96 ns |  1.00 |    0.00 |
    |       StringBuilderTest | 275.1 ns | 2.31 ns |  2.16 ns |  0.69 |    0.04 |
    // * Hints *
    Outliers
      StringBenchmarks.StringConcatenationTest: Default -> 7 outliers were removed, 14 outliers were detected (376.78 ns..391.88 ns, 440.79 ns..506.41 ns)
      StringBenchmarks.StringBuilderTest: Default       -> 2 outliers were detected (274.68 ns, 274.69 ns)
    // * Legends *
      Mean    : Arithmetic mean of all measurements
      Error   : Half of 99.9% confidence interval
      StdDev  : Standard deviation of all measurements
      Ratio   : Mean of the ratio distribution ([Current]/[Baseline])
      RatioSD : Standard deviation of the ratio distribution ([Current]/[Baseline])
      1 ns    : 1 Nanosecond (0.000000001 sec)
    // ***** BenchmarkRunner: End *****
    // ** Remained 0 benchmark(s) to run **
    Run time: 00:01:13 (73.35 sec), executed benchmarks: 2
    Global total time: 00:01:29 (89.71 sec), executed benchmarks: 2
    // * Artifacts cleanup * 
    

    图 12.1:总结表显示 StringBuilder 耗时为字符串拼接的 69%

Outliers部分尤为有趣,因为它表明不仅string拼接比StringBuilder慢,而且其耗时也更不稳定。当然,你的结果可能会有所不同。

你已经见识了两种性能测量方法。现在让我们看看如何异步运行任务以潜在提升性能。

异步执行任务

为了理解如何同时运行多个任务(同时进行),我们将创建一个需要执行三个方法的控制台应用程序。

需要执行三种方法:第一种耗时 3 秒,第二种耗时 2 秒,第三种耗时 1 秒。为了模拟这项工作,我们可以使用Thread类让当前线程休眠指定毫秒数。

同步执行多个操作

在我们让任务同时运行之前,我们将同步运行它们,即一个接一个地执行。

  1. 使用你偏好的代码编辑器,在Chapter12解决方案/工作区中添加一个名为WorkingWithTasks的新控制台应用。

  2. 在 Visual Studio Code 中,选择WorkingWithTasks作为活动 OmniSharp 项目。

  3. Program.cs中,导入用于操作秒表的命名空间(与线程和任务相关的命名空间已隐式导入),并静态导入Console,如下代码所示:

    using System.Diagnostics; // Stopwatch
    using static System.Console; 
    
  4. Program.cs底部,创建一个方法输出当前线程信息,如下代码所示:

    static void OutputThreadInfo()
    {
      Thread t = Thread.CurrentThread;
      WriteLine(
        "Thread Id: {0}, Priority: {1}, Background: {2}, Name: {3}",
        t.ManagedThreadId, t.Priority,
        t.IsBackground, t.Name ?? "null");
    } 
    
  5. Program.cs底部,添加三个模拟工作的方法,如下代码所示:

    static void MethodA()
    {
      WriteLine("Starting Method A...");
      OutputThreadInfo();
      Thread.Sleep(3000); // simulate three seconds of work
      WriteLine("Finished Method A.");
    }
    static void MethodB()
    {
      WriteLine("Starting Method B...");
      OutputThreadInfo();
      Thread.Sleep(2000); // simulate two seconds of work
      WriteLine("Finished Method B.");
    }
    static void MethodC()
    {
      WriteLine("Starting Method C...");
      OutputThreadInfo();
      Thread.Sleep(1000); // simulate one second of work
      WriteLine("Finished Method C.");
    } 
    
  6. Program.cs顶部,添加语句调用输出线程信息的方法,定义并启动秒表,调用三个模拟工作方法,然后输出经过的毫秒数,如下代码所示:

    OutputThreadInfo();
    Stopwatch timer = Stopwatch.StartNew();
    WriteLine("Running methods synchronously on one thread."); 
    MethodA();
    MethodB();
    MethodC();
    WriteLine($"{timer.ElapsedMilliseconds:#,##0}ms elapsed."); 
    
  7. 运行代码,查看结果,并注意当仅有一个未命名前台线程执行任务时,所需总时间略超过 6 秒,如下输出所示:

    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Running methods synchronously on one thread.
    Starting Method A...
    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Finished Method A.
    Starting Method B...
    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Finished Method B.
    Starting Method C...
    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Finished Method C.
    6,017ms elapsed. 
    

使用任务异步执行多个操作

Thread类自.NET 的首个版本起就已存在,可用于创建新线程并管理它们,但直接使用可能较为棘手。

.NET Framework 4.0 于 2010 年引入了Task类,它是对线程的封装,使得创建和管理更为简便。通过管理多个封装在任务中的线程,我们的代码将能够同时执行,即异步执行。

每个Task都有一个Status属性和一个CreationOptions属性。Task有一个ContinueWith方法,可以通过TaskContinuationOptions枚举进行定制,并可以使用TaskFactory类进行管理。

启动任务

我们将探讨三种使用Task实例启动方法的方式。GitHub 仓库中的链接指向了讨论这些方法优缺点的文章。每种方法的语法略有不同,但它们都定义了一个Task并启动它:

  1. 注释掉对三个方法及其相关控制台消息的调用,并添加语句以创建和启动三个任务,每个方法一个,如下所示:

    OutputThreadInfo();
    Stopwatch timer = Stopwatch.StartNew();
    **/***
    WriteLine("Running methods synchronously on one thread.");
    MethodA();
    MethodB();
    MethodC();
    ***/**
    **WriteLine(****"Running methods asynchronously on multiple threads."****);** 
    **Task taskA =** **new****(MethodA);**
    **taskA.Start();**
    **Task taskB = Task.Factory.StartNew(MethodB);** 
    **Task taskC = Task.Run(MethodC);**
    WriteLine($"{timer.ElapsedMilliseconds:#,##0}ms elapsed."); 
    
  2. 运行代码,查看结果,并注意耗时毫秒数几乎立即出现。这是因为三个方法现在正由线程池分配的三个新后台工作线程执行,如下所示:

    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Running methods asynchronously on multiple threads.
    Starting Method A...
    Thread Id: 4, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Starting Method C...
    Thread Id: 7, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Starting Method B...
    Thread Id: 6, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    6ms elapsed. 
    

甚至有可能控制台应用在任务有机会启动并写入控制台之前就结束了!

等待任务

有时,你需要等待一个任务完成后再继续。为此,你可以使用Task实例上的Wait方法,或者使用Task数组上的WaitAllWaitAny静态方法,如下表所述:

方法 描述
t.Wait() 这会等待名为t的任务实例完成执行。
Task.WaitAny(Task[]) 这会等待数组中的任意任务完成执行。
Task.WaitAll(Task[]) 这会等待数组中的所有任务完成执行。

使用任务的等待方法

让我们看看如何使用这些等待方法来解决我们控制台应用的问题。

  1. Program.cs中,在创建三个任务和输出耗时之间添加语句,将三个任务的引用合并到一个数组中,并将其传递给WaitAll方法,如下所示:

    Task[] tasks = { taskA, taskB, taskC };
    Task.WaitAll(tasks); 
    
  2. 运行代码并查看结果,注意原始线程将在调用WaitAll时暂停,等待所有三个任务完成后再输出耗时,耗时略超过 3 秒,如下所示:

    Id: 1, Priority: Normal, Background: False, Name: null
    Running methods asynchronously on multiple threads.
    Starting Method A...
    Id: 6, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Starting Method B...
    Id: 7, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Starting Method C...
    Id: 4, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Finished Method C.
    Finished Method B.
    Finished Method A.
    3,013ms elapsed. 
    

三个新线程同时执行其代码,并且它们可能以任意顺序启动。MethodC应该最先完成,因为它仅需 1 秒,接着是耗时 2 秒的MethodB,最后是耗时 3 秒的MethodA

然而,实际的 CPU 使用对结果有很大影响。是 CPU 为每个进程分配时间片以允许它们执行其线程。你无法控制方法何时运行。

继续执行另一个任务

如果所有三个任务都能同时执行,那么等待所有任务完成就是我们所需做的全部。然而,通常一个任务依赖于另一个任务的输出。为了处理这种情况,我们需要定义延续任务

我们将创建一些方法来模拟对返回货币金额的网络服务的调用,然后需要使用该金额来检索数据库中有多少产品成本超过该金额。从第一个方法返回的结果需要输入到第二个方法的输入中。这次,我们将使用Random类而不是等待固定时间,为每次方法调用等待 2 到 4 秒之间的随机间隔来模拟工作。

  1. Program.cs底部,添加两个方法来模拟调用网络服务和数据库存储过程,如下面的代码所示:

    static decimal CallWebService()
    {
      WriteLine("Starting call to web service...");
      OutputThreadInfo();
      Thread.Sleep((new Random()).Next(2000, 4000));
      WriteLine("Finished call to web service.");
      return 89.99M;
    }
    static string CallStoredProcedure(decimal amount)
    {
      WriteLine("Starting call to stored procedure...");
      OutputThreadInfo();
      Thread.Sleep((new Random()).Next(2000, 4000));
      WriteLine("Finished call to stored procedure.");
      return $"12 products cost more than {amount:C}.";
    } 
    
  2. 通过将它们包裹在多行注释字符/* */中来注释掉对前三个任务的调用。保留输出经过的毫秒数的语句。

  3. 在现有语句之前添加语句以输出总时间,如下面的代码所示:

    WriteLine("Passing the result of one task as an input into another."); 
    Task<string> taskServiceThenSProc = Task.Factory
      .StartNew(CallWebService) // returns Task<decimal>
      .ContinueWith(previousTask => // returns Task<string>
        CallStoredProcedure(previousTask.Result));
    WriteLine($"Result: {taskServiceThenSProc.Result}"); 
    
  4. 运行代码并查看结果,如下面的输出所示:

    Thread Id: 1, Priority: Normal, Background: False, Name: null
    Passing the result of one task as an input into another.
    Starting call to web service...
    Thread Id: 4, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Finished call to web service.
    Starting call to stored procedure...
    Thread Id: 6, Priority: Normal, Background: True, Name: .NET ThreadPool Worker
    Finished call to stored procedure.
    Result: 12 products cost more than £89.99.
    5,463ms elapsed. 
    

您可能会看到不同的线程运行网络服务和存储过程调用,如上面的输出所示(线程 4 和 6),或者同一线程可能会被重用,因为它不再忙碌。

嵌套和子任务

除了定义任务之间的依赖关系外,您还可以定义嵌套和子任务。嵌套任务是在另一个任务内部创建的任务。子任务是必须在其父任务允许完成之前完成的嵌套任务。

让我们探索这些类型的任务是如何工作的:

  1. 使用您喜欢的代码编辑器,在Chapter12解决方案/工作区中添加一个名为NestedAndChildTasks的新控制台应用程序。

  2. 在 Visual Studio Code 中,选择NestedAndChildTasks作为活动 OmniSharp 项目。

  3. Program.cs中,删除现有语句,静态导入Console,然后添加两个方法,其中一个方法启动一个任务来运行另一个方法,如下面的代码所示:

    static void OuterMethod()
    {
      WriteLine("Outer method starting...");
      Task innerTask = Task.Factory.StartNew(InnerMethod);
      WriteLine("Outer method finished.");
    }
    static void InnerMethod()
    {
      WriteLine("Inner method starting...");
      Thread.Sleep(2000);
      WriteLine("Inner method finished.");
    } 
    
  4. 在方法上方,添加语句以启动一个任务来运行外部方法并在停止前等待其完成,如下面的代码所示:

    Task outerTask = Task.Factory.StartNew(OuterMethod);
    outerTask.Wait();
    WriteLine("Console app is stopping."); 
    
  5. 运行代码并查看结果,如下面的输出所示:

    Outer method starting...
    Inner method starting...
    Outer method finished.
    Console app is stopping. 
    

    请注意,尽管我们等待外部任务完成,但其内部任务不必同时完成。事实上,外部任务可能完成,控制台应用程序可能结束,甚至在内部任务开始之前!

    要将这些嵌套任务链接为父任务和子任务,我们必须使用一个特殊选项。

  6. 修改定义内部任务的现有代码,添加一个TaskCreationOption值为AttachedToParent,如下面的代码中突出显示所示:

    Task innerTask = Task.Factory.StartNew(InnerMethod,
      **TaskCreationOptions.AttachedToParent**); 
    
  7. 运行代码,查看结果,并注意内部任务必须在完成外部任务之前完成,如下面的输出所示:

    Outer method starting...
    Inner method starting...
    Outer method finished.
    Inner method finished.
    Console app is stopping. 
    

尽管OuterMethod可以在InnerMethod之前完成,如其在控制台上的输出所示,但其任务必须等待,如控制台在内外任务都完成之前不会停止所示。

围绕其他对象包装任务

有时你可能有一个想要异步的方法,但返回的结果本身不是一个任务。你可以将返回值包装在一个成功完成的任务中,返回一个异常,或者通过使用下表中所示的方法来表示任务已被取消:

方法 描述
FromResult<TResult>(TResult) 创建一个Task<TResult>对象,其Result属性是非任务结果,其Status属性是RanToCompletion
FromException<TResult>(Exception) 创建一个因指定异常而完成的Task<TResult>
FromCanceled<TResult>(CancellationToken) 创建一个因指定取消令牌而完成的Task<TResult>

这些方法在你需要时很有用:

  • 实现具有异步方法的接口,但你的实现是同步的。这在网站和服务中很常见。

  • 在单元测试期间模拟异步实现。

在《第七章:打包和分发.NET 类型》中,我们创建了一个类库,用于检查有效的 XML、密码和十六进制代码。

如果我们想让那些方法符合要求返回Task<T>的接口,我们可以使用这些有用的方法,如下面的代码所示:

using System.Text.RegularExpressions;
namespace Packt.Shared;
public static class StringExtensions
{
  public static Task<bool> IsValidXmlTagAsync(this string input)
  {
    if (input == null)
    {
      return Task.FromException<bool>(
        new ArgumentNullException("Missing input parameter"));
    }
    if (input.Length == 0)
    {
      return Task.FromException<bool>(
        new ArgumentException("input parameter is empty."));
    }
    return Task.FromResult(Regex.IsMatch(input,
      @"^<([a-z]+)([^<]+)*(?:>(.*)<\/\1>|\s+\/>)$"));
  }
  // other methods
} 

如果你需要实现的方法返回一个Task(相当于同步方法中的void),那么你可以返回一个预定义的已完成Task对象,如下面的代码所示:

public Task DeleteCustomerAsync()
{
  // ...
  return Task.CompletedTask;
} 

同步访问共享资源

当多个线程同时执行时,有可能两个或更多线程会同时访问同一变量或其他资源,从而可能导致问题。因此,你应该仔细考虑如何使你的代码线程安全

实现线程安全最简单的机制是使用对象变量作为标志或交通灯,以指示何时对共享资源应用了独占锁。

在威廉·戈尔丁的《蝇王》中,皮吉和拉尔夫发现了一个海螺壳,并用它召集会议。男孩们自行制定了“海螺规则”,决定只有持有海螺的人才能发言。

我喜欢将用于实现线程安全代码的对象变量命名为“海螺”。当一个线程持有海螺时,其他任何线程都不应访问由该海螺表示的共享资源。请注意,我说的是“不应”。只有尊重海螺的代码才能实现同步访问。海螺不是锁。

我们将探讨几种可用于同步访问共享资源的类型:

  • Monitor:一个可被多个线程用来检查是否应在同一进程内访问共享资源的对象。

  • Interlocked:一个用于在 CPU 级别操作简单数值类型的对象。

多线程访问资源

  1. 使用你偏好的代码编辑器,在Chapter12解决方案/工作区中添加一个名为SynchronizingResourceAccess的新控制台应用。

  2. 在 Visual Studio Code 中,选择SynchronizingResourceAccess作为活动 OmniSharp 项目。

  3. Program.cs中,删除现有语句,然后添加执行以下操作的语句:

    • 导入诊断类型(如Stopwatch)的命名空间。

    • 静态导入Console类型。

    • Program.cs底部,创建一个具有两个字段的静态类:

      • 生成随机等待时间的字段。

      • 一个string字段用于存储消息(这是一个共享资源)。

    • 在类上方,创建两个静态方法,它们在循环中五次向共享string添加字母 A 或 B,并为每次迭代等待最多 2 秒的随机间隔:

    static void MethodA()
    {
      for (int i = 0; i < 5; i++)
      {
        Thread.Sleep(SharedObjects.Random.Next(2000));
        SharedObjects.Message += "A";
        Write(".");
      }
    }
    static void MethodB()
    {
      for (int i = 0; i < 5; i++)
      {
        Thread.Sleep(SharedObjects.Random.Next(2000));
        SharedObjects.Message += "B";
        Write(".");
      }
    }
    static class SharedObjects
    {
      public static Random Random = new();
      public static string? Message; // a shared resource
    } 
    
  4. 在命名空间导入之后,编写语句以使用一对任务在单独的线程上执行两个方法,并在输出经过的毫秒数之前等待它们完成,如下面的代码所示:

    WriteLine("Please wait for the tasks to complete.");
    Stopwatch watch = Stopwatch.StartNew();
    Task a = Task.Factory.StartNew(MethodA);
    Task b = Task.Factory.StartNew(MethodB);
    
    Task.WaitAll(new Task[] { a, b });
    WriteLine();
    WriteLine($"Results: {SharedObjects.Message}.");
    WriteLine($"{watch.ElapsedMilliseconds:N0} elapsed milliseconds."); 
    
  5. 运行代码并查看结果,如下面的输出所示:

    Please wait for the tasks to complete.
    ..........
    Results: BABABAABBA.
    5,753 elapsed milliseconds. 
    

这表明两个线程都在并发地修改消息。在实际应用中,这可能是个问题。但我们可以通过对海螺对象应用互斥锁,并让两个方法在修改共享资源前自愿检查海螺,来防止并发访问,我们将在下一节中这样做。

对海螺应用互斥锁

现在,让我们使用海螺确保一次只有一个线程访问共享资源。

  1. SharedObjects中,声明并实例化一个object变量作为海螺,如下面的代码所示:

    public static object Conch = new(); 
    
  2. MethodAMethodB中,在for循环周围添加一个lock语句,以锁定海螺,如下面的高亮代码所示:

    **lock** **(SharedObjects.Conch)**
    **{**
      for (int i = 0; i < 5; i++)
      {
        Thread.Sleep(SharedObjects.Random.Next(2000));
        SharedObjects.Message += "A";
        Write(".");
      }
    **}** 
    

    最佳实践:请注意,由于检查海螺是自愿的,如果你只在两个方法中的一个使用lock语句,共享资源将继续被两个方法访问。确保所有访问共享资源的方法都尊重海螺。

  3. 运行代码并查看结果,如下面的输出所示:

    Please wait for the tasks to complete.
    ..........
    Results: BBBBBAAAAA.
    10,345 elapsed milliseconds. 
    

尽管耗时更长,但一次只能有一个方法访问共享资源。MethodAMethodB可以先开始。一旦某个方法完成了对共享资源的操作,海螺就会被释放,另一个方法就有机会执行其任务。

理解锁语句

你可能会好奇lock语句在“锁定”对象变量时做了什么(提示:它并没有锁定对象!),如下面的代码所示:

lock (SharedObjects.Conch)
{
  // work with shared resource
} 

C#编译器将lock语句转换为使用Monitor进入退出海螺对象的try-finally语句(我喜欢将其视为获取释放海螺对象),如下面的代码所示:

try
{
  Monitor.Enter(SharedObjects.Conch);
  // work with shared resource
}
finally
{
  Monitor.Exit(SharedObjects.Conch);
} 

当线程对任何对象(即引用类型)调用Monitor.Enter时,它会检查是否有其他线程已经获取了海螺。如果已经获取,线程等待。如果没有,线程获取海螺并继续处理共享资源。一旦线程完成其工作,它调用Monitor.Exit,释放海螺。如果另一个线程正在等待,现在它可以获取海螺并执行其工作。这要求所有线程通过适当调用Monitor.EnterMonitor.Exit来尊重海螺。

避免死锁

了解lock语句如何被编译器转换为Monitor类上的方法调用也很重要,因为使用lock语句可能导致死锁。

当存在两个或多个共享资源(每个资源都有一个海螺来监控当前哪个线程正在处理该共享资源)时,可能会发生死锁,如果以下事件序列发生:

  • 线程 X“锁定”海螺 A 并开始处理共享资源 A。

  • 线程 Y“锁定”海螺 B 并开始处理共享资源 B。

  • 线程 X 在仍在处理资源 A 的同时,也需要与资源 B 合作,因此它试图“锁定”海螺 B,但由于线程 Y 已经拥有海螺 B 而被阻塞。

  • 线程 Y 在仍在处理资源 B 的同时,也需要与资源 A 合作,因此它试图“锁定”海螺 A,但由于线程 X 已经拥有海螺 A 而被阻塞。

防止死锁的一种方法是在尝试获取锁时指定超时。为此,你必须手动使用Monitor类而不是使用lock语句。

  1. 修改你的代码,将lock语句替换为尝试在超时后进入海螺的代码,并输出错误,然后退出监视器,允许其他线程进入监视器,如下所示高亮显示的代码:

    **try**
    **{**
    **if** **(Monitor.TryEnter(SharedObjects.Conch, TimeSpan.FromSeconds(****15****)))**
      {
        for (int i = 0; i < 5; i++)
        {
          Thread.Sleep(SharedObjects.Random.Next(2000));
          SharedObjects.Message += "A";
          Write(".");
        }
      }
    **else**
     **{**
     **WriteLine(****"Method A timed out when entering a monitor on conch."****);**
     **}**
    **}**
    **finally**
    **{**
     **Monitor.Exit(SharedObjects.Conch);**
    **}** 
    
  2. 运行代码并查看结果,结果应与之前相同(尽管 A 或 B 可能首先抓住海螺),但这是更好的代码,因为它将防止潜在的死锁。

最佳实践:仅在你能编写避免潜在死锁的代码时使用lock关键字。如果你无法避免潜在死锁,则始终使用Monitor.TryEnter方法代替lock,并结合try-finally语句,以便你可以提供超时,如果发生死锁,其中一个线程将退出。你可以在以下链接阅读更多关于良好线程实践的内容:docs.microsoft.com/en-us/dotnet/standard/threading/managed-threading-best-practices

同步事件

第六章实现接口和继承类中,你学习了如何引发和处理事件。但.NET 事件不是线程安全的,因此你应该避免在多线程场景中使用它们,并遵循我之前展示的标准事件引发代码。

在了解到.NET 事件不是线程安全的之后,一些开发者尝试在添加和移除事件处理程序或触发事件时使用独占锁,如下面的代码所示:

// event delegate field
public event EventHandler Shout;
// conch
private object eventLock = new();
// method
public void Poke()
{
  lock (eventLock) // bad idea
  {
    // if something is listening...
    if (Shout != null)
    {
      // ...then call the delegate to raise the event
      Shout(this, EventArgs.Empty);
    }
  }
} 

最佳实践:您可以在以下链接中了解更多关于事件和线程安全的信息:docs.microsoft.com/en-us/archive/blogs/cburrows/field-like-events-considered-harmful

但这很复杂,正如 Stephen Cleary 在以下博客文章中所解释的:blog.stephencleary.com/2009/06/threadsafe-events.html

使 CPU 操作原子化

原子一词来自希腊语atomos,意为不可分割。理解多线程中哪些操作是原子的很重要,因为如果它们不是原子的,那么它们可能会在操作中途被另一个线程中断。C#的增量运算符是原子的吗,如下面的代码所示?

int x = 3;
x++; // is this an atomic CPU operation? 

它不是原子的!递增一个整数需要以下三个 CPU 操作:

  1. 从实例变量加载一个值到寄存器。

  2. 递增该值。

  3. 将值存储在实例变量中。

一个线程在执行前两步后可能会被中断。第二个线程随后可以执行所有三个步骤。当第一个线程恢复执行时,它将覆盖变量中的值,第二个线程执行的增减操作的效果将会丢失!

有一个名为Interlocked的类型,可以对值类型(如整数和浮点数)执行原子操作。让我们看看它的实际应用:

  1. SharedObjects类中声明另一个字段,用于计数已发生的操作次数,如下面的代码所示:

    public static int Counter; // another shared resource 
    
  2. 在方法 A 和 B 中,在for语句内并在修改string值后,添加一个语句以安全地递增计数器,如下面的代码所示:

    Interlocked.Increment(ref SharedObjects.Counter); 
    
  3. 输出经过的时间后,将计数器的当前值写入控制台,如下面的代码所示:

    WriteLine($"{SharedObjects.Counter} string modifications."); 
    
  4. 运行代码并查看结果,如下面的输出所示:

    Please wait for the tasks to complete.
    ..........
    Results: BBBBBAAAAA.
    13,531 elapsed milliseconds.
    **10 string modifications.** 
    

细心的读者会意识到,现有的海螺对象保护了锁定代码块内访问的所有共享资源,因此在这个特定的例子中实际上不需要使用Interlocked。但如果我们没有保护另一个像Message这样的共享资源,那么使用Interlocked将是必要的。

应用其他类型的同步

MonitorInterlocked是互斥锁,它们简单有效,但有时,您需要更高级的选项来同步对共享资源的访问,如下表所示:

类型 描述
ReaderWriterLockReaderWriterLockSlim 这些允许多个线程处于读模式,一个线程处于写模式,拥有写锁的独占所有权,以及一个线程,该线程具有对资源的读访问权限,并处于可升级读模式,从中线程可以升级到写模式,而无需放弃其对资源的读访问权限。
Mutex 类似于Monitor,它为共享资源提供独占访问,但用于进程间同步。
SemaphoreSemaphoreSlim 这些通过定义槽限制可以同时访问资源或资源池的线程数量。这被称为资源节流,而不是资源锁定。
AutoResetEventManualResetEvent 事件等待句柄允许线程通过相互发送信号和等待彼此的信号来同步活动。

理解异步和等待

C# 5 在处理Task类型时引入了两个 C#关键字。它们特别适用于以下情况:

  • 图形用户界面(GUI)实现多任务处理。

  • 提升 Web 应用和 Web 服务的可扩展性。

第十五章使用模型-视图-控制器模式构建网站中,我们将看到asyncawait关键字如何提升网站的可扩展性。

第十九章使用.NET MAUI 构建移动和桌面应用中,我们将看到asyncawait关键字如何实现 GUI 的多任务处理。

但现在,让我们先学习这两个 C#关键字被引入的理论原因,之后您将看到它们在实践中的应用。

提高控制台应用的响应性

控制台应用程序的一个限制是,您只能在标记为async的方法中使用await关键字,但 C# 7 及更早版本不允许将Main方法标记为异步!幸运的是,C# 7.1 引入了一个新特性,即支持Main中的async

  1. 使用您偏好的代码编辑器,向Chapter12解决方案/工作区中添加一个名为AsyncConsole的新控制台应用。

  2. 在 Visual Studio Code 中,选择AsyncConsole作为活动的 OmniSharp 项目。

  3. Program.cs中,删除现有语句并静态导入Console,如下所示:

    using static System.Console; 
    
  4. 添加语句以创建HttpClient实例,请求 Apple 主页,并输出其字节数,如下所示:

    HttpClient client = new();
    HttpResponseMessage response =
      await client.GetAsync("http://www.apple.com/");
    WriteLine("Apple's home page has {0:N0} bytes.",
      response.Content.Headers.ContentLength); 
    
  5. 构建项目并注意它成功构建。在.NET 5 及更早版本中,您会看到一条错误消息,如下所示:

    Program.cs(14,9): error CS4033: The 'await' operator can only be used within an async method. Consider marking this method with the 'async' modifier and changing its return type to 'Task'. [/Users/markjprice/Code/ Chapter12/AsyncConsole/AsyncConsole.csproj] 
    
  6. 您本需要向Main方法添加async关键字并将其返回类型更改为Task。使用.NET 6 及更高版本,控制台应用项目模板利用顶级程序功能自动为您定义具有异步Main方法的Program类。

  7. 运行代码并查看结果,由于苹果经常更改其主页,因此结果可能会有不同的字节数,如下面的输出所示:

    Apple's home page has 40,252 bytes. 
    

提高 GUI 应用程序的响应性

到目前为止,本书中我们只构建了控制台应用程序。当构建 Web 应用程序、Web 服务以及带有 GUI 的应用程序(如 Windows 桌面和移动应用程序)时,程序员的生活会变得更加复杂。

原因之一是,对于图形用户界面(GUI)应用程序,存在一个特殊的线程:用户界面UI)线程。

在 GUI 中工作的两条规则:

  • 不要在 UI 线程上执行长时间运行的任务。

  • 不要在除 UI 线程以外的任何线程上访问 UI 元素。

为了处理这些规则,程序员过去不得不编写复杂的代码来确保长时间运行的任务由非 UI 线程执行,但一旦完成,任务的结果会安全地传递给 UI 线程以呈现给用户。这很快就会变得混乱!

幸运的是,使用 C# 5 及更高版本,你可以使用asyncawait。它们允许你继续以同步方式编写代码,这使得代码保持清晰易懂,但在底层,C#编译器创建了一个复杂的状态机并跟踪运行线程。这有点神奇!

让我们看一个例子。我们将使用 WPF 构建一个 Windows 桌面应用程序,该应用程序从 SQL Server 数据库中的 Northwind 数据库获取员工信息,使用低级类型如SqlConnectionSqlCommandSqlDataReader。只有当你拥有 Windows 和存储在 SQL Server 中的 Northwind 数据库时,你才能完成此任务。这是本书中唯一不跨平台且现代的部分(WPF 已有 16 年历史!)。

此时,我们专注于使 GUI 应用程序具有响应性。你将在第十九章使用.NET MAUI 构建移动和桌面应用程序中学习 XAML 和构建跨平台 GUI 应用程序。由于本书其他部分不涉及 WPF,我认为这是一个很好的机会,至少可以看到一个使用 WPF 构建的示例应用程序,即使我们不详细讨论它。

我们开始吧!

  1. 如果你使用的是 Windows 上的 Visual Studio 2022,请向Chapter12解决方案中添加一个名为WpfResponsive的**WPF 应用程序[C#]**项目。如果你使用的是 Visual Studio Code,请使用以下命令:dotnet new wpf

  2. 在项目文件中,注意输出类型是 Windows EXE,目标框架是面向 Windows 的.NET 6(它不会在其他平台如 macOS 和 Linux 上运行),并且项目使用了 WPF。

  3. 向项目中添加对Microsoft.Data.SqlClient的包引用,如下面的标记中突出显示的那样:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>WinExe</OutputType>
        <TargetFramework>net6.0-windows</TargetFramework>
        <Nullable>enable</Nullable>
        <UseWPF>true</UseWPF>
      </PropertyGroup>
     **<ItemGroup>**
     **<PackageReference Include=****"Microsoft.Data.SqlClient"** **Version=****"3.0.0"** **/>**
     **</ItemGroup>**
    </Project> 
    
  4. 构建项目以恢复包。

  5. MainWindow.xaml中,在<Grid>元素内,添加元素以定义两个按钮、一个文本框和一个列表框,它们在堆栈面板中垂直布局,如下面的标记中突出显示的那样:

    <Grid>
    **<****StackPanel****>**
    **<****Button****Name****=****"GetEmployeesSyncButton"**
    **Click****=****"GetEmployeesSyncButton_Click"****>**
     **Get Employees Synchronously****</****Button****>**
    **<****Button****Name****=****"GetEmployeesAsyncButton"**
    **Click****=****"GetEmployeesAsyncButton_Click"****>**
     **Get Employees Asynchronously****</****Button****>**
    **<****TextBox****HorizontalAlignment****=****"Stretch"****Text****=****"Type in here"** **/>**
    **<****ListBox****Name****=****"EmployeesListBox"****Height****=****"400"** **/>**
    **</****StackPanel****>**
    </Grid> 
    

    Windows 上的 Visual Studio 2022 对构建 WPF 应用提供了良好的支持,并在编辑代码和 XAML 标记时提供 IntelliSense。Visual Studio Code 则不支持。

  6. MainWindow.xaml.cs中,在MainWindow类中,导入System.DiagnosticsMicrosoft.Data.SqlClient命名空间,然后创建两个string常量用于数据库连接字符串和 SQL 语句,并为两个按钮的点击创建事件处理程序,使用这些string常量打开与 Northwind 数据库的连接,并在列表框中填充所有员工的 ID 和姓名,如下所示:

    private const string connectionString = 
      "Data Source=.;" +
      "Initial Catalog=Northwind;" +
      "Integrated Security=true;" +
      "MultipleActiveResultSets=true;";
    private const string sql =
      "WAITFOR DELAY '00:00:05';" +
      "SELECT EmployeeId, FirstName, LastName FROM Employees";
    private void GetEmployeesSyncButton_Click(object sender, RoutedEventArgs e)
    {
      Stopwatch timer = Stopwatch.StartNew();
      using (SqlConnection connection = new(connectionString))
      {
        connection.Open();
        SqlCommand command = new(sql, connection);
        SqlDataReader reader = command.ExecuteReader();
        while (reader.Read())
        {
          string employee = string.Format("{0}: {1} {2}",
            reader.GetInt32(0), reader.GetString(1), reader.GetString(2));
          EmployeesListBox.Items.Add(employee);
        }
        reader.Close();
        connection.Close();
      }
      EmployeesListBox.Items.Add($"Sync: {timer.ElapsedMilliseconds:N0}ms");
    }
    private async void GetEmployeesAsyncButton_Click(
      object sender, RoutedEventArgs e)
    {
      Stopwatch timer = Stopwatch.StartNew();
      using (SqlConnection connection = new(connectionString))
      {
        await connection.OpenAsync();
        SqlCommand command = new(sql, connection);
        SqlDataReader reader = await command.ExecuteReaderAsync();
        while (await reader.ReadAsync())
        {
          string employee = string.Format("{0}: {1} {2}",
            await reader.GetFieldValueAsync<int>(0), 
            await reader.GetFieldValueAsync<string>(1), 
            await reader.GetFieldValueAsync<string>(2));
          EmployeesListBox.Items.Add(employee);
        }
        await reader.CloseAsync();
        await connection.CloseAsync();
      }
      EmployeesListBox.Items.Add($"Async: {timer.ElapsedMilliseconds:N0}ms");
    } 
    

    注意以下内容:

    • SQL 语句使用 SQL Server 命令WAITFOR DELAY模拟耗时五秒的处理过程,然后从Employees表中选择三个列。

    • GetEmployeesSyncButton_Click事件处理程序使用同步方法打开连接并获取员工行。

    • GetEmployeesAsyncButton_Click事件处理程序标记为async,并使用带有await关键字的异步方法打开连接并获取员工行。

    • 两个事件处理程序均使用秒表记录操作耗费的毫秒数,并将其添加到列表框中。

  7. 启动 WPF 应用,无需调试。

  8. 点击文本框,输入一些文本,注意 GUI 响应。

  9. 点击同步获取员工按钮。

  10. 尝试点击文本框,注意 GUI 无响应。

  11. 等待至少五秒钟,直到列表框中填满员工信息。

  12. 点击文本框,输入一些文本,注意 GUI 再次响应。

  13. 点击异步获取员工按钮。

  14. 点击文本框,输入一些文本,注意在执行操作时 GUI 仍然响应。继续输入,直到列表框中填满员工信息。

  15. 注意两次操作的时间差异。同步获取数据时 UI 被阻塞,而异步获取数据时 UI 保持响应。

  16. 关闭 WPF 应用。

提升 Web 应用和 Web 服务的可扩展性。

asyncawait关键字在构建网站、应用程序和服务时也可应用于服务器端。从客户端应用程序的角度来看,没有任何变化(或者他们甚至可能注意到请求返回所需时间略有增加)。因此,从单个客户端的角度来看,使用asyncawait在服务器端实现多任务处理会使他们的体验变差!

在服务器端,创建额外的、成本较低的工作线程来等待长时间运行的任务完成,以便昂贵的 I/O 线程可以处理其他客户端请求,而不是被阻塞。这提高了 Web 应用或服务的整体可扩展性。可以同时支持更多客户端。

支持多任务处理的常见类型

许多常见类型都具有异步方法,你可以等待这些方法,如下表所示:

类型 方法
DbContext<T> AddAsync, AddRangeAsync, FindAsync, 和 SaveChangesAsync
DbSet<T> AddAsync, AddRangeAsync, ForEachAsync, SumAsync, ToListAsync, ToDictionaryAsync, AverageAsync, 和 CountAsync
HttpClient GetAsync, PostAsync, PutAsync, DeleteAsync, 和 SendAsync
StreamReader ReadAsync, ReadLineAsync, 和 ReadToEndAsync
StreamWriter WriteAsync, WriteLineAsync, 和 FlushAsync

良好实践:每当看到以Async为后缀的方法时,检查它是否返回TaskTask<T>。如果是,那么你可以使用它代替同步的非Async后缀方法。记得使用await调用它,并为你的方法添加async修饰符。

在 catch 块中使用 await

在 C# 5 中首次引入asyncawait时,只能在try块中使用await关键字,而不能在catch块中使用。在 C# 6 及更高版本中,现在可以在trycatch块中都使用await

处理异步流

随着.NET Core 3.0 的推出,微软引入了流异步处理。

你可以在以下链接完成关于异步流的教程:docs.microsoft.com/en-us/dotnet/csharp/tutorials/generate-consume-asynchronous-stream

在 C# 8.0 和.NET Core 3.0 之前,await关键字仅适用于返回标量值的任务。.NET Standard 2.1 中的异步流支持允许async方法返回一系列值。

让我们看一个模拟示例,该示例返回三个随机整数作为异步流。

  1. 使用你偏好的代码编辑器,在Chapter12解决方案/工作区中添加一个名为AsyncEnumerable的新控制台应用。

  2. 在 Visual Studio Code 中,选择AsyncEnumerable作为活动的 OmniSharp 项目。

  3. Program.cs中,删除现有语句并静态导入Console,如下面的代码所示:

    using static System.Console; // WriteLine() 
    
  4. Program.cs底部,创建一个使用yield关键字异步返回三个随机数字序列的方法,如下面的代码所示:

    async static IAsyncEnumerable<int> GetNumbersAsync()
    {
      Random r = new();
      // simulate work
      await Task.Delay(r.Next(1500, 3000));
      yield return r.Next(0, 1001);
      await Task.Delay(r.Next(1500, 3000));
      yield return r.Next(0, 1001);
      await Task.Delay(r.Next(1500, 3000));
      yield return r.Next(0, 1001);
    } 
    
  5. GetNumbersAsync上方,添加语句以枚举数字序列,如下面的代码所示:

    await foreach (int number in GetNumbersAsync())
    {
      WriteLine($"Number: {number}");
    } 
    
  6. 运行代码并查看结果,如下面的输出所示:

    Number: 509
    Number: 813
    Number: 307 
    

实践与探索

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

练习 12.1 – 测试你的知识

回答以下问题:

  1. 关于进程,你能了解到哪些信息?

  2. Stopwatch类的精确度如何?

  3. 按照惯例,返回TaskTask<T>的方法应附加什么后缀?

  4. 要在方法内部使用await关键字,方法声明必须应用什么关键字?

  5. 如何创建子任务?

  6. 为什么要避免使用lock关键字?

  7. 何时应使用Interlocked类?

  8. 何时应使用Mutex类而不是Monitor类?

  9. 在网站或网络服务中使用asyncawait有何好处?

  10. 你能取消一个任务吗?如果可以,如何操作?

练习 12.2 – 探索主题

请使用以下网页上的链接,以了解更多关于本章所涵盖主题的详细信息:

第十二章 - 使用多任务提高性能和可扩展性

总结

在本章中,你不仅学会了如何定义和启动任务,还学会了如何等待一个或多个任务完成,以及如何控制任务完成的顺序。你还学习了如何同步访问共享资源以及asyncawait背后的奥秘。

在接下来的七章中,你将学习如何为.NET 支持的应用模型,即工作负载,创建应用程序,例如网站和服务,以及跨平台的桌面和移动应用。

第十三章:介绍 C#和.NET 的实际应用

本书的第三部分也是最后一部分是关于 C#和.NET 的实际应用。你将学习如何构建跨平台项目,如网站、服务以及移动和桌面应用。

微软将构建应用的平台称为应用模型工作负载

第一章第十八章第二十章中,你可以使用特定操作系统的 Visual Studio 或跨平台的 Visual Studio Code 和 JetBrains Rider 来构建所有应用。在第十九章使用.NET MAUI 构建移动和桌面应用中,尽管你可以使用 Visual Studio Code 来构建移动和桌面应用,但这并不容易。Windows 上的 Visual Studio 2022 对.NET MAUI 的支持比 Visual Studio Code 更好(目前)。

我建议你按顺序阅读本章及后续章节,因为后续章节会引用早期章节中的项目,并且你将积累足够的知识和技能来解决后续章节中更棘手的问题。

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

  • 理解 C#和.NET 的应用模型

  • ASP.NET Core 中的新特性

  • 项目结构

  • 使用其他项目模板

  • 构建 Northwind 实体数据模型

理解 C#和.NET 的应用模型

由于本书是关于 C# 10 和.NET 6 的,我们将学习使用它们构建实际应用的应用模型,这些应用将在本书剩余章节中遇到。

了解更多:微软在其.NET 应用架构指南文档中提供了丰富的应用模型实施指导,你可以在以下链接阅读:www.microsoft.com/net/learn/architecture

使用 ASP.NET Core 构建网站

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

在许多网站中,Web 浏览器被视为表示层,几乎所有的处理都在服务器端执行。客户端可能会使用一些 JavaScript 来实现某些表示层功能,如轮播。

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

  • ASP.NET Core Razor PagesRazor 类库是动态生成简单网站 HTML 的方法。你将在第十四章使用 ASP.NET Core Razor Pages 构建网站中详细学习它们。

  • ASP.NET Core MVC模型-视图-控制器MVC)设计模式的一种实现,该模式在开发复杂网站时非常流行。你将在第十五章使用模型-视图-控制器模式构建网站中详细学习它。

  • Blazor 允许你使用 C#和.NET 构建用户界面组件,而非基于 JavaScript 的 UI 框架如 Angular、React 和 Vue。Blazor WebAssembly 在浏览器中运行你的代码,如同 JavaScript 框架一样。Blazor Server 在服务器上运行你的代码,并动态更新网页。你将在第十七章使用 Blazor 构建用户界面中详细了解 Blazor。Blazor 不仅适用于构建网站,还可用于创建混合移动和桌面应用。

使用内容管理系统构建网站

大多数网站内容繁多,如果每次内容变动都需要开发者介入,这将难以扩展。内容管理系统CMS)使开发者能够定义内容结构和模板,以保持一致性和良好设计,同时让非技术内容所有者轻松管理实际内容。他们可以创建新页面或内容块,并更新现有内容,确保访客看到的内容美观且维护工作量最小。

针对所有网络平台,有多种 CMS 可供选择,如 PHP 的 WordPress 或 Python 的 Django CMS。支持现代.NET 的 CMS 包括 Optimizely 内容云、Piranha CMS 和 Orchard Core。

使用 CMS 的关键优势在于它提供了一个友好的内容管理用户界面。内容所有者登录网站并自行管理内容。内容随后通过 ASP.NET Core MVC 控制器和视图渲染并返回给访问者,或通过称为无头 CMS的网络服务端点,将内容提供给作为移动或桌面应用、店内触点或使用 JavaScript 框架或 Blazor 构建的客户端的“头部”。

本书不涉及.NET CMS,因此我在 GitHub 仓库中提供了链接,供你进一步了解:

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#net-content-management-systems

使用 SPA 框架构建网页应用

网页应用,又称单页应用SPAs),由单一网页构成,采用前端技术如 Blazor WebAssembly、Angular、React、Vue 或专有 JavaScript 库。这些应用在需要时向后台网络服务请求更多数据,并通过 XML 和 JSON 等通用序列化格式发布更新数据。典型例子包括谷歌的 Gmail、地图和文档等网页应用。

在网页应用中,客户端使用 JavaScript 框架或 Blazor WebAssembly 实现复杂的用户交互,但大部分重要处理和数据访问仍在服务器端进行,因为网络浏览器对本地系统资源的访问有限。

JavaScript 是弱类型语言,并非为复杂项目设计,因此当今大多数 JavaScript 库采用微软的 TypeScript,它为 JavaScript 添加了强类型特性,并设计了许多现代语言特性以应对复杂实现。

.NET SDK 提供了基于 JavaScript 和 TypeScript 的 SPA 项目模板,但本书不会花费时间学习如何构建基于 JavaScript 和 TypeScript 的 SPA,尽管这些通常与 ASP.NET Core 作为后端配合使用,因为本书专注于 C#,而非其他语言。

综上所述,C# 和 .NET 可用于服务器端和客户端构建网站,如 图 13.1 所示:

图 13.1:C# 和 .NET 用于构建服务器端和客户端网站

构建 Web 及其他服务

尽管我们不会学习基于 JavaScript 和 TypeScript 的 SPA,但我们将学习如何使用 ASP.NET Core Web API 构建 Web 服务,然后从我们的 ASP.NET Core 网站的服务器端代码调用该 Web 服务,之后再从 Blazor WebAssembly 组件以及跨平台移动和桌面应用调用该 Web 服务。

虽然没有正式定义,但服务有时会根据其复杂性进行描述:

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

  • 微服务:专注于较小功能集的多个服务。

  • Nanoservice:作为服务提供的单一功能。与全天候运行的服务和微服务不同,纳米服务通常处于非活动状态,直到被调用以减少资源和成本。

除了使用 HTTP 作为底层通信技术的 Web 服务以及 API 设计原则外,我们还将学习如何使用其他技术和设计理念构建服务,包括:

  • gRPC 用于构建高效且性能卓越的服务,支持几乎所有平台。

  • SignalR 用于构建组件间的实时通信。

  • OData 用于将 Entity Framework Core 和其他数据模型通过 Web API 进行封装。

  • GraphQL 允许客户端控制跨多个数据源检索哪些数据。

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

构建移动和桌面应用

移动平台主要有两大阵营:苹果的 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。

由于本书关注的是使用 C# 和 .NET 进行现代跨平台开发,因此不包含使用 Windows FormsWindows Presentation Foundation (WPF) 或 Universal Windows Platform (UWP) 构建桌面应用的内容,因为它们仅限于 Windows 平台。

跨平台移动和桌面应用可以为 .NET 多平台应用用户界面 (MAUI) 平台构建一次,然后就能在多种移动和桌面平台上运行。

.NET MAUI 使得通过共享用户界面组件和业务逻辑来开发这些应用变得简单。它们可以针对与控制台应用、网站和 Web 服务相同的 .NET API。应用将在移动设备上的 Mono 运行时和桌面设备上的 CoreCLR 运行时执行。与常规 .NET CoreCLR 运行时相比,Mono 运行时对移动设备的优化更好。Blazor WebAssembly 也使用 Mono 运行时,因为它像移动应用一样,资源受限。

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

未来对 .NET MAUI 的更新将支持现有的 MVVM 和 XAML 模式,以及类似 模型-视图-更新 (MVU) 的 C# 模式,这与 Apple 的 Swift UI 类似。

第六版的倒数第二章是 第十九章使用 .NET MAUI 构建跨平台移动和桌面应用,涵盖了使用 .NET MAUI 构建跨平台移动和桌面应用的内容。

.NET MAUI 的替代方案

在微软创建 .NET MAUI 之前,第三方已发起开源倡议,让 .NET 开发者能够使用 XAML 构建跨平台应用,名为 UnoAvalonia

理解 Uno Platform

正如 Uno 在其网站上所述,它是“首个也是唯一一个为 Windows、WebAssembly、iOS、macOS、Android 和 Linux 提供单一代码库应用的 UI 平台”。

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

Uno Platform 使用 Xamarin 原生平台而非 Xamarin.Forms。对于 WebAssembly,Uno 采用 Mono-WASM 运行时,与 Blazor WebAssembly 类似。在 Linux 上,Uno 利用 Skia 在画布上绘制用户界面。

理解 Avalonia

根据 .NET 基金会的网站所述,Avalonia “是一个跨平台的 XAML 基础 UI 框架,提供灵活的样式系统,并支持广泛的如 Windows、通过 Xorg 的 Linux、macOS 等操作系统。Avalonia 已准备好用于通用桌面应用开发。”

你可以将 Avalonia 视为 WPF 的精神继承者。熟悉 WPF、Silverlight 和 UWP 的开发者可以继续利用他们多年积累的知识和技能。

JetBrains 利用它来现代化其基于 WPF 的工具,并使其跨平台。

Avalonia 的 Visual Studio 扩展以及与 JetBrains Rider 的深度集成使得开发更加简便和高效。

ASP.NET Core 的新特性

过去几年,微软迅速扩展了 ASP.NET Core 的能力。您应注意哪些.NET 平台得到支持,如下表所示:

  • ASP.NET Core 1.0 至 2.2 版本可在.NET Core 或.NET Framework 上运行。

  • ASP.NET Core 3.0 及更高版本仅在.NET Core 3.0 及更高版本上运行。

ASP.NET Core 1.0

ASP.NET Core 1.0 于 2016 年 6 月发布,重点是实现一个适合构建现代跨平台 Web 应用和服务的最小 API,支持 Windows、macOS 和 Linux。

ASP.NET Core 1.1

ASP.NET Core 1.1 于 2016 年 11 月发布,主要关注错误修复和功能及性能的常规改进。

ASP.NET Core 2.0

ASP.NET Core 2.0 于 2017 年 8 月发布,重点增加了新特性,如 Razor Pages、将程序集捆绑到Microsoft.AspNetCore.All元包中、面向.NET Standard 2.0、提供新的认证模型以及性能改进。

ASP.NET Core 2.0 引入的最大新特性包括 ASP.NET Core Razor Pages,这在第十四章使用 ASP.NET Core Razor Pages 构建网站中有所介绍,以及 ASP.NET Core OData 支持,这在第十八章构建和消费专业化服务中有所介绍。

ASP.NET Core 2.1

ASP.NET Core 2.1 于 2018 年 5 月发布,是一个长期支持(LTS)版本,意味着它将支持至 2021 年 8 月 21 日(LTS 标识直到 2018 年 8 月版本 2.1.3 才正式分配给它)。

ASP.NET Core 2.2 专注于增加新特性,如SignalR用于实时通信,Razor 类库用于重用 Web 组件,ASP.NET Core Identity用于认证,以及对 HTTPS 和欧盟通用数据保护条例(GDPR)的更好支持,包括下表中列出的主题:

特性 章节 主题
Razor 类库 14 使用 Razor 类库
GDPR 支持 15 创建和探索 ASP.NET Core MVC 网站
身份验证 UI 库和脚手架 15 探索 ASP.NET Core MVC 网站
集成测试 15 测试 ASP.NET Core MVC 网站
[ApiController], ActionResult<T> 16 创建 ASP.NET Core Web API 项目
问题详情 16 实现 Web API 控制器
IHttpClientFactory 16 使用 HttpClientFactory 配置 HTTP 客户端
ASP.NET Core SignalR 18 使用 SignalR 实现实时通信

ASP.NET Core 2.2

ASP.NET Core 2.2 于 2018 年 12 月发布,重点改进 RESTful HTTP API 的构建,更新项目模板至 Bootstrap 4 和 Angular 6,优化 Azure 托管配置,以及性能改进,包括下表中列出的主题:

特性 章节 主题
Kestrel 中的 HTTP/2 14 经典 ASP.NET 与现代 ASP.NET Core 的对比
进程内托管模型 14 创建 ASP.NET Core 项目
端点路由 14 理解端点路由
健康检查 API 16 实现健康检查 API
Open API 分析器 16 实现 Open API 分析器和约定

ASP.NET Core 3.0

ASP.NET Core 3.0 于 2019 年 9 月发布,专注于充分利用.NET Core 3.0 和.NET Standard 2.1,这意味着它不支持.NET Framework,并增加了有用的改进,包括下表列出的主题:

特性 章节 主题
Razor 类库中的静态资产 14 使用 Razor 类库
MVC 服务注册的新选项 15 理解 ASP.NET Core MVC 启动
ASP.NET Core gRPC 18 使用 ASP.NET Core gRPC 构建服务
Blazor Server 17 使用 Blazor Server 构建组件

ASP.NET Core 3.1

ASP.NET Core 3.1 于 2019 年 12 月发布,是一款 LTS 版本,意味着它将得到支持直至 2022 年 12 月 3 日。它专注于改进,如 Razor 组件的局部类支持和新的<component>标签助手。

Blazor WebAssembly 3.2

Blazor WebAssembly 3.2 于 2020 年 5 月发布。它是一个当前版本,意味着项目必须在.NET 5 发布后的三个月内升级到.NET 5 版本,即 2021 年 2 月 10 日前。微软最终实现了使用.NET 进行全栈 Web 开发的承诺,并且 Blazor Server 和 Blazor WebAssembly 都在第十七章使用 Blazor 构建用户界面中有所涉及。

ASP.NET Core 5.0

ASP.NET Core 5.0 于 2020 年 11 月发布,重点在于修复错误、使用缓存提高证书认证性能、Kestrel 中 HTTP/2 响应头的 HPACK 动态压缩、ASP.NET Core 程序集的可空性注解,以及减少容器镜像大小,包括下表列出的主题:

特性 章节 主题
允许匿名访问端点的扩展方法 16 保护 Web 服务
HttpRequestHttpResponse的 JSON 扩展方法 16 在控制器中获取 JSON 格式的客户信息

ASP.NET Core 6.0

ASP.NET Core 6.0 于 2021 年 11 月发布,专注于提高生产力的改进,如最小化实现基本网站和服务的代码、.NET 热重载,以及新的 Blazor 托管选项,如使用.NET MAUI 的混合应用,包括下表列出的主题:

特性 章节 主题
新的空 Web 项目模板 14 理解空 Web 模板
HTTP 日志记录中间件 16 启用 HTTP 日志记录
最小 API 16 实现最小 Web API
Blazor 错误边界 17 定义 Blazor 错误边界
Blazor WebAssembly AOT 17 启用 Blazor WebAssembly 预先编译
.NET 热重载 17 使用.NET 热重载修复代码
.NET MAUI Blazor 应用 19 在.NET MAUI 应用中托管 Blazor 组件

| 构建仅限 Windows 的桌面应用 |

构建仅限 Windows 的桌面应用的技术包括:

  • Windows Forms, 2002.

  • Windows Presentation Foundation (WPF),2006 年。

  • Windows Store 应用,2012 年。

  • 通用 Windows 平台 (UWP) 应用,2015 年。

  • Windows App SDK(曾用名 WinUI 3Project Reunion)应用,2021 年。

理解传统 Windows 应用平台

随着 1985 年微软 Windows 1.0 的发布,创建 Windows 应用的唯一方式是使用 C 语言并调用名为 kernel、user 和 GDI 的三个核心 DLL 中的函数。当 Windows 95 成为 32 位系统后,这些 DLL 被附加了 32 后缀,并被称为 Win32 API

1991 年,微软推出了 Visual Basic,为开发者提供了一种通过工具箱中的控件进行拖放操作的可视化方式来构建 Windows 应用程序的用户界面。它极受欢迎,Visual Basic 运行时至今仍是 Windows 10 的一部分。

随着 2002 年 C# 和 .NET Framework 的首个版本发布,微软提供了名为 Windows Forms 的技术来构建 Windows 桌面应用。当时,Web 开发的对应技术名为 Web Forms,因此名称相辅相成。代码可以用 Visual Basic 或 C# 语言编写。Windows Forms 拥有类似的拖放式可视化设计器,尽管它生成的是 C# 或 Visual Basic 代码来定义用户界面,这可能对人类来说难以直接理解和编辑。

2006 年,微软发布了一种更强大的技术,名为 Windows Presentation Foundation (WPF),作为 .NET Framework 3.0 的关键组件,与 Windows Communication Foundation (WCF) 和 Windows Workflow (WF) 并列。

尽管可以通过仅编写 C# 语句来创建 WPF 应用,但它也可以使用 可扩展应用程序标记语言 (XAML) 来指定用户界面,这既便于人类理解,也便于代码处理。Windows 版的 Visual Studio 部分基于 WPF 构建。

2012 年,微软发布了 Windows 8,其内置的 Windows Store 应用运行在一个受保护的沙箱环境中。

2015 年,微软发布了 Windows 10,并引入了名为 通用 Windows 平台 (UWP) 的更新版 Windows Store 应用概念。UWP 应用可以使用 C++ 和 DirectX UI、JavaScript 和 HTML 或 C# 结合现代 .NET 的定制分支来构建,该分支虽非跨平台,但能完全访问底层的 WinRT API。

UWP 应用仅能在 Windows 10 平台上运行,不支持早期版本的 Windows,但可在 Xbox 及配备运动控制器的 Windows Mixed Reality 头显上运行。

许多 Windows 开发者因 UWP 应用对底层系统的访问受限而拒绝使用 Windows Store。微软最近推出了 Project ReunionWinUI 3,两者协同工作,使 Windows 开发者能够将现代 Windows 开发的某些优势引入现有的 WPF 应用,并使其享有与 UWP 应用相同的益处和系统集成。这一举措现称为 Windows App SDK

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

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

许多使用 Windows Forms 和 WPF 构建的企业应用程序需要维护或增强新功能,但直到最近它们还停留在.NET Framework 上,这是一个遗留平台。借助现代.NET 及其 Windows 桌面包,这些应用程序现在可以充分利用.NET 的现代功能。

项目结构

你应该如何组织你的项目?到目前为止,我们构建了小型独立的控制台应用程序来演示语言或库功能。在本书的其余部分,我们将使用不同的技术构建多个项目,这些技术协同工作以提供单一解决方案。

在大型复杂的解决方案中,要在所有代码中导航可能会很困难。因此,组织项目的主要原因是使其更容易找到组件。为解决方案或工作区提供一个反映应用程序或解决方案的整体名称是很好的。

我们将为一家名为Northwind的虚构公司构建多个项目。我们将把解决方案或工作区命名为PracticalApps,并使用Northwind作为所有项目名称的前缀。

有许多方法来组织和命名项目和解决方案,例如使用文件夹层次结构以及命名约定。如果你在一个团队中工作,请确保你知道你的团队是如何做的。

在解决方案或工作区中组织项目

在解决方案或工作区中为项目制定命名约定是很好的做法,这样任何开发人员都能立即了解每个项目的作用。常见的选择是使用项目类型,例如类库、控制台应用程序、网站等,如下表所示:

名称 描述
Northwind.Common 一个类库项目,用于跨多个项目使用的通用类型,如接口、枚举、类、记录和结构。
Northwind.Common.EntityModels 一个类库项目,用于通用的 EF Core 实体模型。实体模型通常在服务器和客户端侧都被使用,因此最好将特定数据库提供程序的依赖关系分开。
Northwind.Common.DataContext 一个类库项目,用于依赖特定数据库提供程序的 EF Core 数据库上下文。
Northwind.Web 一个 ASP.NET Core 项目,用于一个简单的网站,该网站混合使用静态 HTML 文件和动态 Razor 页面。
Northwind.Razor.Component 一个类库项目,用于在多个项目中使用的 Razor 页面。
Northwind.Mvc 一个 ASP.NET Core 项目,用于使用 MVC 模式的复杂网站,可以更容易地进行单元测试。
Northwind.WebApi 一个用于 HTTP API 服务的 ASP.NET Core 项目。与网站集成的好选择,因为它们可以使用任何 JavaScript 库或 Blazor 与服务交互。
Northwind.OData 一个实现 OData 标准以允许客户端控制查询的 HTTP API 服务的 ASP.NET Core 项目。
Northwind.GraphQL 一个实现 GraphQL 标准以允许客户端控制查询的 HTTP API 服务的 ASP.NET Core 项目。
Northwind.gRPC 一个用于 gRPC 服务的 ASP.NET Core 项目。与使用任何语言和平台构建的应用程序集成的好选择,因为 gRPC 支持广泛且高效且性能优越。
Northwind.SignalR 一个用于实时通信的 ASP.NET Core 项目。
Northwind.AzureFuncs 一个用于在 Azure Functions 中托管的无服务器纳米服务的 ASP.NET Core 项目。
Northwind.BlazorServer 一个 ASP.NET Core Blazor 服务器项目。
Northwind.BlazorWasm.Client 一个 ASP.NET Core Blazor WebAssembly 客户端项目。
Northwind.BlazorWasm.Server 一个 ASP.NET Core Blazor WebAssembly 服务器端项目。
Northwind.Maui 一个用于跨平台桌面/移动应用的.NET MAUI 项目。
Northwind.MauiBlazor 一个用于托管 Blazor 组件并具有与操作系统原生集成的.NET MAUI 项目。

使用其他项目模板

安装.NET SDK 时,包含许多项目模板:

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

    dotnet new --list 
    
  2. 您将看到当前安装的模板列表,如果您在 Windows 上运行,还包括 Windows 桌面开发模板,如图13.2所示:

    图 13.2:dotnet 项目模板列表

  3. 注意与 Web 相关的项目模板,包括使用 Blazor、Angular 和 React 创建 SPA 的模板。但另一个常见的 JavaScript SPA 库缺失了:Vue。

安装额外的模板包

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

  1. 启动浏览器并导航至dotnetnew.azurewebsites.net/

  2. 在文本框中输入vue,并注意 Vue.js 可用模板列表,其中包括微软发布的一个模板,如图13.3所示:

    图 13.3:微软提供的 Vue.js 项目模板

  3. 点击微软提供的 ASP.NET Core with Vue.js,并注意安装和使用此模板的说明,如下列命令所示:

    dotnet new --install "Microsoft.AspNetCore.SpaTemplates"
    dotnet new vue 
    
  4. 点击查看此包中的其他模板,并注意除了 Vue.js 的项目模板外,它还有 Aurelia 和 Knockout.js 的项目模板。

为 Northwind 数据库构建实体数据模型

实际应用通常需要与关系数据库或其他数据存储进行交互。在本章中,我们将为存储在 SQL Server 或 SQLite 中的 Northwind 数据库定义实体数据模型。它将被用于我们后续章节创建的大多数应用中。

Northwind4SQLServer.sqlNorthwind4SQLite.sql脚本文件有所不同。SQL Server 脚本创建了 13 个表以及相关的视图和存储过程。SQLite 脚本是一个简化版本,仅创建 10 个表,因为 SQLite 不支持那么多特性。本书的主要项目仅需要这 10 个表,因此你可以使用任一数据库完成本书中的所有任务。

安装 SQL Server 和 SQLite 的指南可在第十章使用 Entity Framework Core 处理数据中找到。该章节还包含了安装dotnet-ef工具的说明,该工具用于从现有数据库生成实体模型。

最佳实践:应为实体数据模型创建单独的类库项目。这使得在后端 Web 服务器和前端桌面、移动及 Blazor WebAssembly 客户端之间共享数据更为便捷。

为实体模型创建使用 SQLite 的类库

现在,你将在类库中定义实体数据模型,以便它们能在包括客户端应用模型在内的其他类型项目中复用。如果你未使用 SQL Server,则需为 SQLite 创建此类库。若使用 SQL Server,则可同时为 SQLite 和 SQL Server 创建类库,并根据需要切换使用。

我们将使用 EF Core 命令行工具自动生成一些实体模型:

  1. 使用你偏好的代码编辑器创建一个名为PracticalApps的新解决方案/工作区。

  2. 添加一个类库项目,如下列表所述:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.Common.EntityModels.Sqlite

  3. Northwind.Common.EntityModels.Sqlite项目中,添加 SQLite 数据库提供程序和 EF Core 设计时支持的包引用,如下所示:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.Sqlite" 
        Version="6.0.0" />
      <PackageReference 
        Include="Microsoft.EntityFrameworkCore.Design" 
        Version="6.0.0">
        <PrivateAssets>all</PrivateAssets>
        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      </PackageReference>  
    </ItemGroup> 
    
  4. 删除Class1.cs文件。

  5. 构建项目。

  6. 通过将Northwind4SQLite.sql文件复制到PracticalApps文件夹,为 SQLite 创建Northwind.db文件,然后在命令提示符或终端中输入以下命令:

    sqlite3 Northwind.db -init Northwind4SQLite.sql 
    
  7. 请耐心等待,因为此命令可能需要一段时间来创建数据库结构,如下所示:

    -- Loading resources from Northwind4SQLite.sql 
    SQLite version 3.35.5 2021-04-19 14:49:49
    Enter ".help" for usage hints.
    sqlite> 
    
  8. 在 Windows 上按 Ctrl + C 或在 macOS 上按 Cmd + D 以退出 SQLite 命令模式。

  9. 打开命令提示符或终端,定位到Northwind.Common.EntityModels.Sqlite文件夹。

  10. 在命令行中,为所有表生成实体类模型,如下所示:

    dotnet ef dbcontext scaffold "Filename=../Northwind.db" Microsoft.EntityFrameworkCore.Sqlite --namespace Packt.Shared --data-annotations 
    

    注意以下事项:

    • 执行的命令:dbcontext scaffold

    • 连接字符串:"Filename=../Northwind.db"

    • 数据库提供程序:Microsoft.EntityFrameworkCore.Sqlite

    • 命名空间:--namespace Packt.Shared

    • 同时使用数据注解和 Fluent API:--data-annotations

  11. 注意构建消息和警告,如下面的输出所示:

    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. 
    

改进类到表的映射

dotnet-ef命令行工具为 SQL Server 和 SQLite 生成不同的代码,因为它们支持不同级别的功能。

例如,SQL Server 文本列可以有字符数量的限制。SQLite 不支持这一点。因此,dotnet-ef将生成验证属性,以确保string属性在 SQL Server 上限制为指定数量的字符,而在 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!; 

两种数据库提供程序都不会将非可空string属性标记为必填:

// no runtime validation of non-nullable property
public string CategoryName { get; set; } = null!;
// nullable property
public string? Description { get; set; }
// decorate with attribute to perform runtime validation
[Required]
public string CategoryName { get; set; } = null!; 

我们将对 SQLite 的实体模型映射和验证规则进行一些小改进:

  1. 打开Customer.cs文件,并添加一个正则表达式以验证其主键值,仅允许使用大写西文字符,如下面的代码中突出显示所示:

    [Key]
    [Column(TypeName = "nchar (5)")]
    **[****RegularExpression(****"[A-Z]{5}"****)****]**
    public string CustomerId { get; set; } 
    
  2. 激活代码编辑器的查找和替换功能(在 Visual Studio 2022 中,导航至编辑 | 查找和替换 | 快速替换),切换使用正则表达式,然后在搜索框中输入一个正则表达式,如下面的表达式所示:

    \[Column\(TypeName = "(nchar|nvarchar) \((.*)\)"\)\] 
    
  3. 在替换框中,输入一个替换用的正则表达式,如下面的表达式所示:

    $&\n    [StringLength($2)] 
    

    在新行字符\n之后,我添加了四个空格字符以在我的系统上正确缩进,该系统每级缩进使用两个空格字符。您可以根据需要插入任意数量的空格。

  4. 设置查找和替换以搜索当前项目中的文件。

  5. 执行搜索和替换以替换所有内容,如图13.4所示:

    图 13.4:在 Visual Studio 2022 中使用正则表达式搜索并替换所有匹配项

  6. 将任何日期/时间属性,例如在Employee.cs中,改为使用可空DateTime而非字节数组,如下面的代码所示:

    // before
    [Column(TypeName = "datetime")] 
    public byte[] BirthDate { get; set; }
    // after
    [Column(TypeName = "datetime")]
    public DateTime? BirthDate { get; set; } 
    

    使用代码编辑器的查找功能搜索"datetime"以找到所有需要更改的属性。

  7. 将任何money属性,例如在Order.cs中,改为使用可空decimal而非字节数组,如下面的代码所示:

    // before
    [Column(TypeName =  "money")] 
    public byte[] Freight { get; set; }
    // after
    [Column(TypeName = "money")]
    public decimal? Freight { get; set; } 
    

    使用代码编辑器的查找功能搜索"money"以找到所有需要更改的属性。

  8. 将任何bit属性,例如在Product.cs中,改为使用bool而非字节数组,如下面的代码所示:

    // before
    [Column(TypeName = "bit")]
    public byte[] Discontinued { get; set; } = null!;
    // after
    [Column(TypeName = "bit")]
    public bool Discontinued { get; set; } 
    

    使用代码编辑器的查找功能搜索"bit"以找到所有需要更改的属性。

  9. Category.cs中,将CategoryId属性设为int类型,如以下代码中突出显示所示:

    [Key]
    public **int** CategoryId { get; set; } 
    
  10. Category.cs中,将CategoryName属性设为必填项,如以下代码中突出显示所示:

    **[****Required****]**
    [Column(TypeName = "nvarchar (15)")]
    [StringLength(15)]
    public string CategoryName { get; set; } 
    
  11. Customer.cs中,将CompanyName属性设为必填项,如以下代码中突出显示所示:

    **[****Required****]**
    [Column(TypeName = "nvarchar (40)")]
    [StringLength(40)]
    public string CompanyName { get; set; } 
    
  12. Employee.cs中,将EmployeeId属性改为int而非long

  13. Employee.cs中,使FirstNameLastName属性成为必填项。

  14. Employee.cs中,将ReportsTo属性改为int?而非long?

  15. EmployeeTerritory.cs中,将EmployeeId属性改为int而非long

  16. EmployeeTerritory.cs中,使TerritoryId属性成为必填项。

  17. Order.cs中,将OrderId属性改为int而非long

  18. Order.cs中,用正则表达式装饰CustomerId属性,以强制五个大写字符。

  19. Order.cs中,将EmployeeId属性改为int?而非long?

  20. Order.cs中,将ShipVia属性改为int?而非long?

  21. OrderDetail.cs中,将OrderId属性改为int而非long

  22. OrderDetail.cs中,将ProductId属性改为int而非long

  23. OrderDetail.cs中,将Quantity属性改为short而非long

  24. Product.cs中,将ProductId属性改为int而非long

  25. Product.cs中,使ProductName属性成为必填项。

  26. Product.cs中,将SupplierIdCategoryId属性改为int?而非long?

  27. Product.cs中,将UnitsInStockUnitsOnOrderReorderLevel属性改为short?而非long?

  28. Shipper.cs中,将ShipperId属性改为int而非long

  29. Shipper.cs中,使CompanyName属性成为必填项。

  30. Supplier.cs中,将SupplierId属性改为int而非long

  31. Supplier.cs中,使CompanyName属性成为必填项。

  32. Territory.cs中,将RegionId属性改为int而非long

  33. Territory.cs中,使TerritoryIdTerritoryDescription属性成为必填项。

既然我们有了实体类的类库,我们就可以为数据库上下文创建一个类库。

为 Northwind 数据库上下文创建一个类库

你现在将定义一个数据库上下文类库:

  1. 向解决方案/工作区添加一个类库项目,如以下列表所定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.Common.DataContext.Sqlite

  2. 在 Visual Studio 中,将解决方案的启动项目设置为当前选择。

  3. 在 Visual Studio Code 中,选择Northwind.Common.DataContext.Sqlite作为活动的 OmniSharp 项目。

  4. Northwind.Common.DataContext.Sqlite项目中,添加对Northwind.Common.EntityModels.Sqlite项目的项目引用,并添加对 EF Core SQLite 数据提供程序的包引用,如下所示:

    <ItemGroup>
      <PackageReference 
        Include="Microsoft.EntityFrameworkCore.SQLite" 
        Version="6.0.0" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..\Northwind.Common.EntityModels.Sqlite\Northwind.Common
    .EntityModels.Sqlite.csproj" />
    </ItemGroup> 
    

    项目引用的路径在你的项目文件中不应有换行。

  5. Northwind.Common.DataContext.Sqlite项目中,删除Class1.cs类文件。

  6. 构建Northwind.Common.DataContext.Sqlite项目。

  7. NorthwindContext.cs 文件从 Northwind.Common.EntityModels.Sqlite 项目/文件夹移动到 Northwind.Common.DataContext.Sqlite 项目/文件夹。

    在 Visual Studio 解决方案资源管理器中,如果你在项目间拖放文件,它将被复制。如果你在拖放时按住 Shift 键,文件将被移动。在 Visual Studio Code 资源管理器中,如果你在项目间拖放文件,它将被移动。如果你在拖放时按住 Ctrl 键,文件将被复制。

  8. NorthwindContext.cs 中,在 OnConfiguring 方法中,移除关于连接字符串的编译器 #warning

    良好实践:我们将在需要与 Northwind 数据库交互的任何项目(如网站)中覆盖默认数据库连接字符串,因此从 DbContext 派生的类必须有一个带有 DbContextOptions 参数的构造函数才能实现这一点,如下列代码所示:

    public NorthwindContext(DbContextOptions<NorthwindContext> options)
      : base(options)
    {
    } 
    
  9. OnModelCreating 方法中,移除所有调用 ValueGeneratedNever 方法的 Fluent API 语句,以配置主键属性如 SupplierId 永不自动生成值,或调用 HasDefaultValueSql 方法,如下列代码所示:

    modelBuilder.Entity<Supplier>(entity =>
    {
      entity.Property(e => e.SupplierId).ValueGeneratedNever();
    }); 
    

    如果我们不移除上述配置语句,那么当我们添加新供应商时,SupplierId 值将始终为 0,我们只能添加一个具有该值的供应商,之后所有其他尝试都会抛出异常。

  10. 对于 Product 实体,告诉 SQLite UnitPrice 可以从 decimal 转换为 doubleOnModelCreating 方法现在应该简化很多,如下列代码所示:

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
      modelBuilder.Entity<OrderDetail>(entity =>
      {
        entity.HasKey(e => new { e.OrderId, e.ProductId });
        entity.HasOne(d => d.Order)
          .WithMany(p => p.OrderDetails)
          .HasForeignKey(d => d.OrderId)
          .OnDelete(DeleteBehavior.ClientSetNull);
        entity.HasOne(d => d.Product)
          .WithMany(p => p.OrderDetails)
          .HasForeignKey(d => d.ProductId)
          .OnDelete(DeleteBehavior.ClientSetNull);
      });
      modelBuilder.Entity<Product>()
        .Property(product => product.UnitPrice)
        .HasConversion<double>();
      OnModelCreatingPartial(modelBuilder);
    } 
    
  11. 添加一个名为 NorthwindContextExtensions.cs 的类,并修改其内容以定义一个扩展方法,该方法将 Northwind 数据库上下文添加到依赖服务集合中,如下列代码所示:

    using Microsoft.EntityFrameworkCore; // UseSqlite
    using Microsoft.Extensions.DependencyInjection; // IServiceCollection
    namespace Packt.Shared;
    public static class NorthwindContextExtensions
    {
      /// <summary>
      /// Adds NorthwindContext to the specified IServiceCollection. Uses the Sqlite database provider.
      /// </summary>
      /// <param name="services"></param>
      /// <param name="relativePath">Set to override the default of ".."</param>
      /// <returns>An IServiceCollection that can be used to add more services.</returns>
      public static IServiceCollection AddNorthwindContext(
        this IServiceCollection services, string relativePath = "..")
      {
        string databasePath = Path.Combine(relativePath, "Northwind.db");
        services.AddDbContext<NorthwindContext>(options =>
          options.UseSqlite($"Data Source={databasePath}")
        );
        return services;
      }
    } 
    
  12. 构建两个类库并修复任何编译器错误。

使用 SQL Server 创建实体模型的类库

要使用 SQL Server,如果您已经在 第十章使用 Entity Framework Core 处理数据 中设置了 Northwind 数据库,则无需执行任何操作。但现在您将使用 dotnet-ef 工具创建实体模型:

  1. 使用您偏好的代码编辑器创建一个名为 PracticalApps 的新解决方案/工作区。

  2. 添加一个类库项目,如以下列表所定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.Common.EntityModels.SqlServer

  3. Northwind.Common.EntityModels.SqlServer 项目中,添加 SQL Server 数据库提供程序和 EF Core 设计时支持的包引用,如下列标记所示:

    <ItemGroup>
      <PackageReference
        Include="Microsoft.EntityFrameworkCore.SqlServer" 
        Version="6.0.0" />
      <PackageReference 
        Include="Microsoft.EntityFrameworkCore.Design" 
        Version="6.0.0">
        <PrivateAssets>all</PrivateAssets>
        <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      </PackageReference>  
    </ItemGroup> 
    
  4. 删除 Class1.cs 文件。

  5. 构建项目。

  6. Northwind.Common.EntityModels.SqlServer 文件夹打开命令提示符或终端。

  7. 在命令行中,为所有表生成实体类模型,如下列命令所示:

    dotnet ef dbcontext scaffold "Data Source=.;Initial Catalog=Northwind;Integrated Security=true;" Microsoft.EntityFrameworkCore.SqlServer --namespace Packt.Shared --data-annotations 
    

    注意以下事项:

    • 执行的命令:dbcontext scaffold

    • 连接字符串:"Data Source=.;Initial Catalog=Northwind;Integrated Security=true;"

    • 数据库提供程序:Microsoft.EntityFrameworkCore.SqlServer

    • 命名空间:--namespace Packt.Shared

    • 同时使用数据注解和 Fluent API:--data-annotations

  8. Customer.cs 中,添加一个正则表达式以验证其主键值,仅允许大写的西文字符,如下列高亮代码所示:

    [Key]
    [StringLength(5)]
    **[****RegularExpression(****"[A-Z]{5}"****)****]** 
    public string CustomerId { get; set; } = null!; 
    
  9. Customer.cs 中,使 CustomerIdCompanyName 属性成为必需。

  10. 向解决方案/工作区中添加一个类库项目,如以下列表所定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.Common.DataContext.SqlServer

  11. 在 Visual Studio Code 中,选择 Northwind.Common.DataContext.SqlServer 作为活动的 OmniSharp 项目。

  12. Northwind.Common.DataContext.SqlServer 项目中,添加对 Northwind.Common.EntityModels.SqlServer 项目的项目引用,并添加对 EF Core SQL Server 数据提供程序的包引用,如下列标记所示:

    <ItemGroup>
      <PackageReference 
        Include="Microsoft.EntityFrameworkCore.SqlServer" 
        Version="6.0.0" />
    </ItemGroup>
    <ItemGroup>
      <ProjectReference Include=
        "..\Northwind.Common.EntityModels.SqlServer\Northwind.Common
    .EntityModels.SqlServer.csproj" />
    </ItemGroup> 
    
  13. Northwind.Common.DataContext.SqlServer 项目中,删除 Class1.cs 类文件。

  14. 构建 Northwind.Common.DataContext.SqlServer 项目。

  15. NorthwindContext.cs 文件从 Northwind.Common.EntityModels.SqlServer 项目/文件夹移动到 Northwind.Common.DataContext.SqlServer 项目/文件夹。

  16. NorthwindContext.cs 中,移除关于连接字符串的编译器警告。

  17. 添加一个名为 NorthwindContextExtensions.cs 的类,并修改其内容以定义一个扩展方法,该方法将 Northwind 数据库上下文添加到依赖服务集合中,如下列代码所示:

    using Microsoft.EntityFrameworkCore; // UseSqlServer
    using Microsoft.Extensions.DependencyInjection; // IServiceCollection
    namespace Packt.Shared;
    public static class NorthwindContextExtensions
    {
      /// <summary>
      /// Adds NorthwindContext to the specified IServiceCollection. Uses the SqlServer database provider.
      /// </summary>
      /// <param name="services"></param>
      /// <param name="connectionString">Set to override the default.</param>
      /// <returns>An IServiceCollection that can be used to add more services.</returns>
      public static IServiceCollection AddNorthwindContext(
        this IServiceCollection services, string connectionString = 
          "Data Source=.;Initial Catalog=Northwind;"
          + "Integrated Security=true;MultipleActiveResultsets=true;")
      {
        services.AddDbContext<NorthwindContext>(options =>
          options.UseSqlServer(connectionString));
        return services;
      }
    } 
    
  18. 构建这两个类库并修复任何编译器错误。

最佳实践:我们为 AddNorthwindContext 方法提供了可选参数,以便我们可以覆盖硬编码的 SQLite 数据库文件路径或 SQL Server 数据库连接字符串。这将使我们拥有更多灵活性,例如,从配置文件加载这些值。

实践与探索

通过深入研究探索本章主题。

练习 13.1 – 测试你的知识

  1. .NET 6 是跨平台的。Windows Forms 和 WPF 应用可以在 .NET 6 上运行。那么这些应用是否能在 macOS 和 Linux 上运行呢?

  2. Windows Forms 应用如何定义其用户界面,以及为什么这可能是一个潜在问题?

  3. WPF 或 UWP 应用如何定义其用户界面,以及为什么这对开发者有益?

练习 13.2 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-13---introducing-practical-applications-of-c-and-net

总结

本章中,你已了解到一些可用于使用 C#和.NET 构建实用应用的应用模型和工作负载。

你已创建了两到四个类库,用于定义与 Northwind 数据库交互的实体数据模型,可使用 SQLite、SQL Server 或两者兼用。

在接下来的六章中,你将学习如何构建以下内容的详细信息:

  • 使用静态 HTML 页面和动态 Razor 页面的简单网站。

  • 采用模型-视图-控制器(MVC)设计模式的复杂网站。

  • 可被任何能发起 HTTP 请求的平台调用的 Web 服务,以及调用这些 Web 服务的客户端网站。

  • 可托管在 Web 服务器、浏览器或混合 Web-原生移动和桌面应用中的 Blazor 用户界面组件。

  • 使用 gRPC 实现远程过程调用的服务。

  • 使用 SignalR 实现实时通信的服务。

  • 提供简便灵活访问 EF Core 模型的服务。

  • Azure Functions 中托管的无服务器微服务。

  • 使用.NET MAUI 构建跨平台的原生移动和桌面应用。

第十四章:使用 ASP.NET Core Razor Pages 构建网站

本章是关于使用 Microsoft ASP.NET Core 在服务器端构建具有现代 HTTP 架构的网站。您将学习使用 ASP.NET Core 2.0 引入的 ASP.NET Core Razor Pages 功能以及 ASP.NET Core 2.1 引入的 Razor 类库功能构建简单的网站。

本章将涵盖以下主题:

  • 理解 Web 开发

  • 理解 ASP.NET Core

  • 探索 ASP.NET Core Razor Pages

  • 使用 Entity Framework Core 与 ASP.NET Core

  • 使用 Razor 类库

  • 配置服务和 HTTP 请求管道

理解 Web 开发

开发 Web 意味着使用超文本传输协议HTTP)进行开发,因此我们将从回顾这一重要的基础技术开始。

理解 HTTP

为了与 Web 服务器通信,客户端,也称为用户代理,使用 HTTP 通过网络进行调用。因此,HTTP 是 Web 的技术基础。所以,当我们谈论网站和 Web 服务时,我们的意思是它们使用 HTTP 在客户端(通常是 Web 浏览器)和服务器之间进行通信。

客户端对资源(如页面)发出 HTTP 请求,该资源由统一资源定位符URL)唯一标识,服务器发送回 HTTP 响应,如图 14.1所示:

图形用户界面,文本 描述自动生成

图 14.1:HTTP 请求和响应

您可以使用 Google Chrome 和其他浏览器记录请求和响应。

最佳实践:Google Chrome 在比任何其他浏览器更多的操作系统上可用,并且它具有强大的内置开发工具,因此它是测试网站的首选浏览器。始终使用 Chrome 以及至少另外两种浏览器(例如,macOS 和 iPhone 上的 Firefox 和 Safari)测试您的 Web 应用程序。Microsoft Edge 在 2019 年从使用 Microsoft 自己的渲染引擎切换到使用 Chromium,因此测试它的重要性较低。如果使用 Microsoft 的 Internet Explorer,则通常主要用于组织内部网。

理解 URL 的组成部分

URL 由几个部分组成:

  • 方案http(明文)或https(加密)。

  • :对于生产网站或服务,顶级域TLD)可能是example.com。您可能有子域,例如wwwjobsextranet。在开发过程中,您通常为所有网站和服务使用localhost

  • 端口号:对于生产网站或服务,http80https443。这些端口号通常从方案中推断出来。在开发过程中,通常使用其他端口号,例如50005001等,以区分使用共享域localhost的所有网站和服务。

  • 路径:到资源的相对路径,例如,/customers/germany

  • 查询字符串:传递参数值的一种方式,例如,?country=Germany&searchtext=shoes

  • 片段:通过其id引用网页上的元素,例如,#toc

为本书中的项目分配端口号

本书中,我们将为所有网站和服务使用域名localhost,因此当多个项目需要同时执行时,我们将使用端口号来区分项目,如下表所示:

项目 描述 端口号
Northwind.Web ASP.NET Core Razor Pages 网站 5000 HTTP, 5001 HTTPS
Northwind.Mvc ASP.NET Core MVC 网站 5000 HTTP, 5001 HTTPS
Northwind.WebApi ASP.NET Core Web API 服务 5002 HTTPS, 5008 HTTP
Minimal.WebApi ASP.NET Core Web API(最小化) 5003 HTTPS
Northwind.OData ASP.NET Core OData 服务 5004 HTTPS
Northwind.GraphQL ASP.NET Core GraphQL 服务 5005 HTTPS
Northwind.gRPC ASP.NET Core gRPC 服务 5006 HTTPS
Northwind.AzureFuncs Azure Functions 纳米服务 7071 HTTP

使用 Google Chrome 发起 HTTP 请求

让我们探索如何使用 Google Chrome 发起 HTTP 请求:

  1. 启动 Google Chrome。

  2. 导航至更多工具 | 开发者工具

  3. 点击网络标签,Chrome 应立即开始记录浏览器与任何 Web 服务器之间的网络流量(注意红色圆圈),如图 14.2 所示:

    图 14.2:Chrome 开发者工具记录网络流量

  4. 在 Chrome 地址栏中,输入微软学习 ASP.NET 的网站地址,如下所示:

    dotnet.microsoft.com/learn/aspnet

  5. 在开发者工具中,在记录的请求列表中滚动到顶部,点击第一个条目,即类型文档的行,如图 14.3 所示:

    图 14.3:开发者工具中记录的请求

  6. 在右侧,点击头部标签,您将看到有关请求头部响应头部的详细信息,如图 14.4 所示:

    图 14.4:请求和响应头部

    注意以下方面:

    • 请求方法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 网页响应,因为它知道客户端可以解压缩该格式。(这在图 14.4中不可见,因为没有足够的空间展开响应头部分。)

  7. 关闭 Chrome。

理解客户端网页开发技术

在构建网站时,开发者需要了解的不仅仅是 C#和.NET。在客户端(即在网页浏览器中),您将使用以下技术的组合:

  • HTML5:用于网页的内容和结构。

  • CSS3:用于网页上元素的样式。

  • JavaScript:用于编写网页所需的任何业务逻辑,例如验证表单输入或调用网页服务以获取网页所需的其他数据。

尽管 HTML5、CSS3 和 JavaScript 是前端网页开发的基本组成部分,但还有许多其他技术可以使前端网页开发更高效,包括全球最受欢迎的前端开源工具包 Bootstrap,以及用于样式的 CSS 预处理器如 SASS 和 LESS,用于编写更健壮代码的 Microsoft TypeScript 语言,以及 JavaScript 库如 jQuery、Angular、React 和 Vue。所有这些更高级别的技术最终都会翻译或编译到基础的三个核心技术,因此它们在所有现代浏览器中都能工作。

作为构建和部署过程的一部分,您可能会使用诸如 Node.js;客户端包管理器 npm 和 Yarn;以及 webpack,这是一个流行的模块捆绑器,用于编译、转换和捆绑网站源文件的工具。

理解 ASP.NET Core

Microsoft ASP.NET Core 是微软多年来用于构建网站和服务的一系列技术的一部分:

  • Active Server PagesASP)于 1996 年发布,是微软首次尝试为动态服务器端执行网站代码提供的平台。ASP 文件包含 HTML 和服务器端执行的 VBScript 代码的混合体。

  • ASP.NET Web Forms 于 2002 年随.NET Framework 一同发布,旨在让非 Web 开发者(如熟悉 Visual Basic 的开发者)通过拖放可视组件和编写 Visual Basic 或 C#的事件驱动代码快速创建网站。对于新的.NET Framework Web 项目,应避免使用 Web Forms,转而采用 ASP.NET MVC。

  • Windows Communication FoundationWCF)于 2006 年发布,使开发者能够构建 SOAP 和 REST 服务。SOAP 功能强大但复杂,除非需要高级特性(如分布式事务和复杂的消息拓扑),否则应避免使用。

  • ASP.NET MVC 于 2009 年发布,旨在清晰地将 Web 开发者的关注点分离为模型(临时存储数据)、视图(以各种格式在 UI 中呈现数据)和控制器(获取模型并将其传递给视图)。这种分离提高了代码复用性和单元测试能力。

  • ASP.NET Web API 于 2012 年发布,使开发者能够创建比 SOAP 服务更简单、更可扩展的 HTTP 服务(即 REST 服务)。

  • ASP.NET SignalR 于 2013 年发布,通过抽象底层技术(如 WebSockets 和长轮询)实现网站的实时通信。这使得网站能够提供如实时聊天或对时间敏感数据(如股票价格)的更新等功能,即使在不支持 WebSockets 等底层技术的多种浏览器上也能运行。

  • ASP.NET Core 于 2016 年发布,它将.NET Framework 的现代实现(如 MVC、Web API 和 SignalR)与新技术(如 Razor Pages、gRPC 和 Blazor)相结合,全部运行在现代.NET 上,因此支持跨平台执行。ASP.NET Core 提供了多种项目模板,帮助你快速上手其支持的技术。

最佳实践:选择 ASP.NET Core 开发网站和服务,因为它包含了现代且跨平台的 Web 相关技术。

ASP.NET Core 2.0 至 2.2 版本既可运行于.NET Framework 4.6.1 及以上版本(仅限 Windows),也可运行于.NET Core 2.0 及以上版本(跨平台)。ASP.NET Core 3.0 仅支持.NET Core 3.0。ASP.NET Core 6.0 仅支持.NET 6.0。

经典 ASP.NET 与现代 ASP.NET Core 的对比

迄今为止,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 项目,该项目将显示 Northwind 数据库中的供应商列表。

dotnet工具提供了许多项目模板,这些模板为你做了很多工作,但很难知道在特定情况下哪个最适合,因此我们将从空网站项目模板开始,然后逐步添加功能,以便你可以理解所有组件:

  1. 使用你偏好的代码编辑器添加一个新项目,如下表所定义:

    1. 项目模板:ASP.NET Core Empty / web

    2. 语言:C#

    3. 工作区/解决方案文件和文件夹:PracticalApps

    4. 项目文件和文件夹:Northwind.Web

    5. 对于 Visual Studio 2022,保持所有其他选项为其默认设置,例如,选中为 HTTPS 配置,未选中启用 Docker

  2. 在 Visual Studio Code 中,选择Northwind.Web作为活动的 OmniSharp 项目。

  3. 构建Northwind.Web项目。

  4. 打开Northwind.Web.csproj文件,并注意到该项目类似于类库,只是其 SDK 为Microsoft.NET.Sdk.Web,如下所示高亮显示:

    <Project Sdk="**Microsoft.NET.Sdk.Web**">
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
    </Project> 
    
  5. 如果你使用的是 Visual Studio 2022,在解决方案资源管理器中,切换显示所有文件

  6. 展开obj文件夹,展开Debug文件夹,展开net6.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; 
    
  7. 折叠obj文件夹。

  8. 打开Program.cs,并注意以下内容:

    • ASP.NET Core 项目类似于顶级控制台应用程序,其入口点是一个隐藏的Main方法,该方法通过名为args的参数传递。

    • 它调用WebApplication.CreateBuilder,该方法使用 Web 主机的默认设置创建一个网站主机,然后构建该主机。

    • 该网站将对所有 HTTP GET请求做出响应,返回纯文本:Hello World!

    • 调用Run方法是一个阻塞调用,因此隐藏的Main方法直到 Web 服务器停止运行才返回,如下所示:

    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
    app.MapGet("/", () => "Hello World!");
    app.Run(); 
    
  9. 在文件底部,添加一条语句,在调用 Run 方法后(即 Web 服务器停止后)向控制台写入一条消息,如下所示高亮显示:

    app.Run();
    **Console.WriteLine(****"This executes after the web server has stopped!"****);** 
    

测试和保护网站

现在我们将测试 ASP.NET Core 空网站项目的功能,并通过从 HTTP 切换到 HTTPS 来启用浏览器和 Web 服务器之间所有流量的加密,以保护隐私。HTTPS 是 HTTP 的安全加密版本。

  1. 对于 Visual Studio:

    1. 在工具栏中,确保选中Northwind.Web 而不是 IIS ExpressWSL,并将Web 浏览器(Microsoft Edge)切换到Google Chrome,如图 14.5 所示:

      图 14.5:在 Visual Studio 中选择带有 Kestrel Web 服务器的 Northwind.Web 配置文件

    2. 导航至调试 | 开始执行(不调试)…

    3. 首次启动安全网站时,系统会提示你的项目已配置为使用 SSL,为了避免浏览器警告,你可以选择信任 ASP.NET Core 生成的自签名证书。点击

    4. 当看到安全警告对话框时,再次点击

  2. 对于 Visual Studio Code,在终端中输入 dotnet run 命令。

  3. 在 Visual Studio 的命令提示窗口或 Visual Studio Code 的终端中,注意 Kestrel Web 服务器已开始在随机端口上监听 HTTP 和 HTTPS,你可以按 Ctrl + C 关闭 Kestrel Web 服务器,并且托管环境为 Development,如下所示:

    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5001 
    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000 
    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:\Code\PracticalApps\Northwind.Web 
    

    Visual Studio 还会自动启动你选择的浏览器。如果你使用的是 Visual Studio Code,则需要手动启动 Chrome。

  4. 保持 Web 服务器运行。

  5. 在 Chrome 中,打开开发者工具,然后点击网络标签。

  6. 输入地址 http://localhost:5000/,或分配给 HTTP 的任何端口号,并注意响应是来自跨平台 Kestrel Web 服务器的 Hello World! 纯文本,如图 14.6 所示:

    图 14.6:来自 http://localhost:5000/ 的纯文本响应

    Chrome 也会自动请求一个 favicon.ico 文件以显示在浏览器标签页中,但该文件缺失,因此显示为 404 Not Found 错误。

  7. 输入地址 https://localhost:5001/,或分配给 HTTPS 的任何端口号,并注意如果你没有使用 Visual Studio 或在被提示信任 SSL 证书时点击了,则响应将是一个隐私错误,如图 14.7 所示:图形用户界面,应用程序 自动生成描述

    图 14.7:显示 SSL 加密未使用证书启用的隐私错误

    如果你未配置浏览器可信任的证书来加密和解密 HTTPS 流量,则会看到此错误(如果你未看到此错误,则是因为你已经配置了证书)。

    在生产环境中,你可能会希望向 Verisign 等公司支付费用以获取 SSL 证书,因为他们提供责任保护和技术支持。

    Linux 开发者注意:如果你使用的 Linux 发行版无法创建自签名证书,或者你不介意每 90 天重新申请新证书,那么你可以通过以下链接获取免费证书:letsencrypt.org

    在开发过程中,你可以指示操作系统信任 ASP.NET Core 提供的临时开发证书。

  8. 在命令行或终端中,按 Ctrl + C 关闭 Web 服务器,并注意写入的消息,如下所示突出显示:

    info: Microsoft.Hosting.Lifetime[0]
          Application is shutting down...
    **This executes after the web server has stopped!**
    C:\Code\PracticalApps\Northwind.Web\bin\Debug\net6.0\Northwind.Web.exe (process 19888) exited with code 0. 
    
  9. 如果你需要信任本地自签名 SSL 证书,那么在命令行或终端中输入dotnet dev-certs https --trust命令,并注意消息提示,请求信任 HTTPS 开发证书。你可能需要输入密码,并且可能已经存在有效的 HTTPS 证书。

加强安全并自动重定向至安全连接

启用更严格的安全措施并自动将 HTTP 请求重定向到 HTTPS 是一种良好实践。

良好实践HTTP 严格传输安全HSTS)是一种应始终启用的可选安全增强措施。如果网站指定它且浏览器支持它,那么它将强制所有通信通过 HTTPS 进行,并阻止访问者使用不受信任或无效的证书。

现在让我们进行操作:

  1. Program.cs中,添加一个if语句,在非开发环境下启用 HSTS,如下代码所示:

    if (!app.Environment.IsDevelopment())
    {
      app.UseHsts();
    } 
    
  2. 在调用app.MapGet之前添加一条语句,将 HTTP 请求重定向到 HTTPS,如下代码所示:

    app.UseHttpsRedirection(); 
    
  3. 启动Northwind.Web网站项目。

  4. 如果 Chrome 仍在运行,请关闭并重新启动它。

  5. 在 Chrome 中,显示开发者工具,并点击网络标签。

  6. 输入地址http://localhost:5000/,或分配给 HTTP 的任何端口号,并注意服务器如何响应307 临时重定向到端口5001,以及证书现在如何有效且受信任,如图14.8所示:

    图 14.8:现在连接已通过有效证书和 307 重定向得到安全保障

  7. 关闭 Chrome。

  8. 关闭 Web 服务器。

良好实践:记得在完成网站测试后关闭 Kestrel Web 服务器。

控制托管环境

在 ASP.NET Core 早期版本中,项目模板设定了一项规则,即在开发模式下,任何未处理的异常都会在浏览器窗口中显示,以便开发者查看异常详情,如下代码所示:

if (app.Environment.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
} 

ASP.NET Core 6 及更高版本中,此代码默认会自动执行,因此不会包含在项目模板中。

ASP.NET Core 如何知道我们何时处于开发模式,使得IsDevelopment方法返回true?让我们来探究一下。

ASP.NET Core 可以从环境变量中读取以确定使用哪个托管环境,例如DOTNET_ENVIRONMENTASPNETCORE_ENVIRONMENT

您可以在本地开发期间覆盖这些设置:

  1. Northwind.Web文件夹中,展开名为Properties的文件夹,打开名为launchSettings.json的文件,并注意名为Northwind.Web的配置文件,该配置文件将托管环境设置为Development,如下所示:

    {
      "iisSettings": {
        "windowsAuthentication": false,
        "anonymousAuthentication": true,
        "iisExpress": {
          "applicationUrl": "http://localhost:56111",
          "sslPort": 44329
        }
      },
      "profiles": {
    **"Northwind.Web"****: {**
    **"commandName"****:** **"Project"****,**
    **"dotnetRunMessages"****:** **"true"****,**
    **"launchBrowser"****:** **true****,**
    **"applicationUrl"****:** **"https://localhost:5001;http://localhost:5000"****,** 
    **"environmentVariables"****: {**
    **"ASPNETCORE_ENVIRONMENT"****:** **"Development"**
     **}**
     **},**
        "IIS Express": {
          "commandName": "IISExpress",
          "launchBrowser": true, 
          "environmentVariables": {
            "ASPNETCORE_ENVIRONMENT": "Development"
          }
        }
      }
    } 
    
  2. 将随机分配的 HTTP 端口号更改为5000,HTTPS 端口号更改为5001

  3. 将环境更改为Production。可选地,将launchBrowser更改为false以防止 Visual Studio 自动启动浏览器。

  4. 启动网站并注意托管环境为Production,如下所示:

    info: Microsoft.Hosting.Lifetime[0] 
      Hosting environment: Production 
    
  5. 关闭 Web 服务器。

  6. launchSettings.json中,将环境更改回Development

launchSettings.json文件还具有使用随机端口号使用 IIS 作为 Web 服务器的配置。在本书中,我们将仅使用 Kestrel 作为 Web 服务器,因为它跨平台。

服务和管道配置的分离

将所有代码初始化一个简单的 Web 项目放在Program.cs中可能是一个好主意,特别是对于 Web 服务,因此我们将在第十六章构建和消费 Web 服务中再次看到这种风格。

然而,对于任何比最基本的 Web 项目更复杂的情况,您可能更愿意将配置分离到一个单独的Startup类中,该类包含两个方法:

  • ConfigureServices(IServiceCollection services):向依赖注入容器添加依赖服务,例如 Razor Pages 支持、跨源资源共享CORS)支持,或用于处理 Northwind 数据库的数据库上下文。

  • Configure(IApplicationBuilder app, IWebHostEnvironment env):通过调用app参数上的各种Use方法来设置 HTTP 管道,请求和响应通过该管道流动。按照应处理功能的顺序构建管道!

图 14.9:Startup 类 ConfigureServices 和 Configure 方法图

这两个方法将由运行时自动调用。

现在让我们创建一个Startup类:

  1. Northwind.Web项目添加一个名为Startup.cs的新类文件。

  2. 修改Startup.cs,如下所示:

    namespace Northwind.Web;
    public class Startup
    {
      public void ConfigureServices(IServiceCollection services)
      {
      }
      public void Configure(
        IApplicationBuilder app, IWebHostEnvironment env)
      {
        if (!env.IsDevelopment())
        {
          app.UseHsts();
        }
        app.UseRouting(); // start endpoint routing
        app.UseHttpsRedirection();
        app.UseEndpoints(endpoints =>
        {
          endpoints.MapGet("/", () => "Hello World!");
        });
      }
    } 
    

    请注意以下代码内容:

    • ConfigureServices方法目前为空,因为我们尚未需要添加任何依赖服务。

    • 通过Configure方法设置 HTTP 请求管道并启用端点路由功能。它配置了一个路由端点,该端点等待根路径/的每个 HTTP GET请求,并通过返回纯文本"Hello World!"来响应这些请求。我们将在本章末尾学习路由端点及其好处。

    现在我们必须指定在应用程序入口点使用Startup类。

  3. 修改Program.cs,如下所示:

    using Northwind.Web; // Startup
    Host.CreateDefaultBuilder(args)
      .ConfigureWebHostDefaults(webBuilder =>
      {
        webBuilder.UseStartup<Startup>();
      }).Build().Run();
    Console.WriteLine("This executes after the web server has stopped!"); 
    
  4. 启动网站并注意其行为与之前相同。

  5. 关闭 Web 服务器。

在本书中创建的所有其他网站和服务项目中,我们将使用.NET 6 项目模板创建的单个Program.cs文件。如果你喜欢使用Startup.cs的方式,那么你将在本章中看到如何使用它。

使网站能够提供静态内容

一个仅返回单一纯文本消息的网站并不十分有用!

至少,它应该返回静态 HTML 页面、网页将用于样式的 CSS 以及任何其他静态资源,如图像和视频。

按照惯例,这些文件应存储在名为wwwroot的目录中,以将它们与网站项目中动态执行的部分分开。

创建静态文件文件夹和网页

你现在将为静态网站资源创建一个文件夹,并创建一个使用 Bootstrap 进行样式设置的基本索引页面:

  1. Northwind.Web项目/文件夹中,创建一个名为wwwroot的文件夹。

  2. wwwroot文件夹添加一个新的 HTML 页面文件,命名为index.html

  3. 修改其内容,链接到 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 " />
      <!-- Bootstrap CSS -->
      <link href=
    "https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" 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> 
    

良好实践:为了获取最新的<link>元素用于 Bootstrap,请从以下链接的文档中复制并粘贴它:getbootstrap.com/docs/5.0/getting-started/introduction/#starter-template

启用静态和默认文件

如果你现在启动网站并在地址栏中输入http://localhost:5000/index.html,网站将返回一个404 Not Found错误,表示未找到网页。为了使网站能够返回诸如index.html之类的静态文件,我们必须明确配置该功能。

即使我们启用了静态文件,如果你启动网站并在地址栏中输入http://localhost:5000/,网站将返回一个404 Not Found错误,因为 Web 服务器不知道如果没有请求特定文件,默认应该返回什么。

你现在将启用静态文件,明确配置默认文件,并更改注册的 URL 路径,该路径返回纯文本Hello World!响应:

  1. Startup.cs中,在Configure方法中,在启用 HTTPS 重定向后添加语句以启用静态文件和默认文件,并修改将GET请求映射到返回Hello World!纯文本响应的语句,使其仅响应 URL 路径/hello,如下所示突出显示的代码:

    app.UseHttpsRedirection();
    **app.UseDefaultFiles();** **// index.html, default.html, and so on**
    **app.UseStaticFiles();**
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapGet(**"/hello"**, () => "Hello World!");
    }); 
    

    调用UseDefaultFiles必须在调用UseStaticFiles之前,否则它不会工作!你将在本章末了解更多关于中间件和端点路由的顺序。

  2. 启动网站。

  3. 启动Chrome并显示开发者工具

  4. 在 Chrome 中,输入http://localhost:5000/,并注意您被重定向到端口5001上的 HTTPS 地址,并且index.html文件现在通过该安全连接返回,因为它是该网站可能的默认文件之一。

  5. 开发者工具中,注意对 Bootstrap 样式表的请求。

  6. 在 Chrome 中,输入http://localhost:5000/hello,并注意它返回与之前一样的纯文本Hello World!

  7. 关闭 Chrome 并关闭 Web 服务器。

如果所有网页都是静态的,即它们仅由网页编辑器手动更改,那么我们的网站编程工作就完成了。但几乎所有网站都需要动态内容,这意味着在运行时通过执行代码生成的网页。

最简单的方法是使用 ASP.NET Core 的一个名为Razor 页面的功能。

探索 ASP.NET Core Razor Pages

ASP.NET Core Razor Pages 允许开发人员轻松地将 C#代码语句与 HTML 标记混合,以使生成的网页动态化。这就是为什么它们使用.cshtml文件扩展名。

按照惯例,ASP.NET Core 会在名为Pages的文件夹中查找 Razor 页面。

启用 Razor 页面

现在,您将把静态 HTML 页面转换为动态 Razor 页面,并添加并启用 Razor 页面服务:

  1. Northwind.Web项目文件夹中,创建一个名为Pages的文件夹。

  2. index.html文件复制到Pages文件夹中。

  3. 对于Pages文件夹中的文件,将文件扩展名从.html更改为.cshtml

  4. 删除表示这是静态 HTML 页面的<h2>元素。

  5. Startup.cs中的ConfigureServices方法中,添加一个语句以将 ASP.NET Core Razor Pages 及其相关服务(如模型绑定、授权、防伪、视图和标签助手)添加到构建器中,如下所示:

    services.AddRazorPages(); 
    
  6. Startup.cs中的Configure方法中,在配置使用端点的部分,添加一个语句以调用MapRazorPages方法,如下所示:

    app.UseEndpoints(endpoints =>
    {
     **endpoints.MapRazorPages();**
      endpoints.MapGet("/hello",  () => "Hello World!");
    }); 
    

向 Razor 页面添加代码

在网页的 HTML 标记中,Razor 语法由@符号表示。Razor 页面可以描述如下:

  • 它们需要在文件顶部使用@page指令。

  • 它们可以有可选的@functions部分,定义以下任何内容:

    • 用于存储数据值的属性,类似于类定义。该类的实例会自动实例化名为Model,其属性可以在特殊方法中设置,并且可以在 HTML 中获取属性值。

    • 当进行 HTTP 请求(如GETPOSTDELETE)时,执行名为OnGetOnPostOnDelete等方法。

现在让我们将静态 HTML 页面转换为 Razor 页面:

  1. Pages文件夹中,打开index.cshtml

  2. 在文件顶部添加@page语句。

  3. @page语句之后,添加一个@functions语句块。

  4. 定义一个属性以存储当前日期的名称作为string值。

  5. 定义一个设置DayName的方法,该方法在对该页面进行 HTTP GET请求时执行,如下面的代码所示:

    @page
    @functions
    {
      public string? DayName { get; set; }
      public void OnGet()
      {
        Model.DayName = DateTime.Now.ToString("dddd");
      }
    } 
    
  6. 在第二个 HTML 段落中输出日期名称,如下面的标记中突出显示的那样:

    <p>**It's @Model.DayName!** Our customers include restaurants, hotels, and cruise lines.</p> 
    
  7. 启动网站。

  8. 在 Chrome 中输入https://localhost:5001/,并注意当前日期名称输出在页面上,如图 14.10所示:

    图 14.10:欢迎来到 Northwind 页面显示当前日期

  9. 在 Chrome 中输入https://localhost:5001/index.html,它与静态文件名完全匹配,并注意它像以前一样返回静态 HTML 页面。

  10. 在 Chrome 中输入https://localhost:5001/hello,它与返回纯文本的端点路由完全匹配,并注意它像以前一样返回Hello World!

  11. 关闭 Chrome 并停止 Web 服务器。

使用 Razor Pages 的共享布局

大多数网站都有多个页面。如果每个页面都必须包含当前index.cshtml中的所有样板标记,那将变得难以管理。因此,ASP.NET Core 提供了一个名为布局的功能。

要使用布局,我们必须创建一个 Razor 文件来定义所有 Razor Pages(和所有 MVC 视图)的默认布局,并将其存储在Shared文件夹中,以便通过约定轻松找到。此文件的名称可以是任何名称,因为我们将会指定它,但_Layout.cshtml是良好的实践。

我们还必须创建一个特殊命名的文件来设置所有 Razor Pages(和所有 MVC 视图)的默认布局文件。此文件必须命名为_ViewStart.cshtml

让我们看看布局的实际应用:

  1. Pages文件夹中,添加一个名为_ViewStart.cshtml的文件。(Visual Studio 项模板名为Razor 视图开始。)

  2. 修改其内容,如下面的标记所示:

    @{
      Layout = "_Layout";
    } 
    
  3. Pages文件夹中,创建一个名为Shared的文件夹。

  4. Shared文件夹中,创建一个名为_Layout.cshtml的文件。(Visual Studio 项模板名为Razor 布局。)

  5. 修改_Layout.cshtml的内容(它与index.cshtml类似,因此你可以从那里复制粘贴 HTML 标记),如下面的标记所示:

    <!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.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
      <title>@ViewData["Title"]</title>
    </head>
    <body>
      <div class="container">
        @RenderBody()
        <hr />
        <footer>
          <p>Copyright &copy; 2021 - @ViewData["Title"]</p>
        </footer>
      </div>
      <!-- JavaScript to enable features like carousel -->
      <script src="img/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
      @RenderSection("Scripts", required: false)
    </body>
    </html> 
    

    在审查前面的标记时,请注意以下几点:

    • <title>通过从名为ViewData的字典中使用服务器端代码动态设置。这是一种在 ASP.NET Core 网站的不同部分之间传递数据的简单方法。在这种情况下,数据将在 Razor 页面类文件中设置,然后在共享布局中输出。

    • @RenderBody()标记了请求视图的插入点。

    • 每个页面底部都会出现一条水平线和页脚。

    • 布局底部有一个脚本,用于实现 Bootstrap 的一些酷炫功能,我们稍后可以使用,例如图像轮播。

    • 在 Bootstrap 的<script>元素之后,我们定义了一个名为Scripts的部分,以便 Razor 页面可以有选择地注入它所需的额外脚本。

  6. 修改 index.cshtml 以删除所有 HTML 标记,除了 <div class="jumbotron"> 及其内容,并保留你之前添加的 @functions 块中的 C# 代码。

  7. OnGet 方法添加一条语句,将页面标题存储在 ViewData 字典中,并修改按钮以导航到供应商页面(我们将在下一节创建),如下所示的高亮标记:

    @page 
    @functions
    {
      public string? DayName { get; set; }
      public void OnGet()
      {
     **ViewData[****"Title"****] =** **"Northwind B2B"****;**
        Model.DayName = DateTime.Now.ToString("dddd");
      }
    }
    <div class="jumbotron">
      <h1 class="display-3">Welcome to Northwind B2B</h1>
      <p class="lead">We supply products to our customers.</p>
      <hr />
      <p>It's @Model.DayName! Our customers include restaurants, hotels, and cruise lines.</p>
      <p>
    **<****a****class****=****"btn btn-primary"****href****=****"suppliers"****>**
     **Learn more about our suppliers****</****a****>**
      </p>
    </div> 
    
  8. 启动网站,在 Chrome 中访问它,并注意到它与之前有相似的行为,尽管点击供应商按钮会给出 404 未找到 错误,因为我们尚未创建该页面。

使用 Razor Pages 的代码后置文件

有时,将 HTML 标记与数据和可执行代码分离会更好,因此 Razor Pages 允许你通过将 C# 代码放入 代码后置 类文件中来实现这一点。它们与 .cshtml 文件同名,但以 .cshtml.cs 结尾。

你现在将创建一个显示供应商列表的页面。在本例中,我们专注于学习代码后置文件。在下一个主题中,我们将从数据库加载供应商列表,但现在,我们将使用硬编码的字符串数组来模拟这一过程:

  1. Pages 文件夹中,添加两个名为 Suppliers.cshtmlSuppliers.cshtml.cs 的新文件。(Visual Studio 项模板名为 Razor 页面 - 空,它会创建这两个文件。)

  2. 向名为 Suppliers.cshtml.cs 的代码后置文件添加如下所示的语句:

    using Microsoft.AspNetCore.Mvc.RazorPages; // PageModel
    namespace Northwind.Web.Pages;
    public class SuppliersModel : PageModel
    {
      public IEnumerable<string>? Suppliers { get; set; }
      public void OnGet()
      {
        ViewData["Title"] = "Northwind B2B - Suppliers";
        Suppliers = new[]
        {
          "Alpha Co", "Beta Limited", "Gamma Corp"
        };
      }
    } 
    

    在审查前面的标记时,请注意以下几点:

    • SuppliersModel 继承自 PageModel,因此它拥有诸如 ViewData 字典等成员,用于共享数据。你可以右键点击 PageModel 并选择 转到定义 来查看它还有许多其他有用的特性,例如当前请求的整个 HttpContext

    • SuppliersModel 定义了一个用于存储名为 Suppliersstring 值集合的属性。

    • 当对该 Razor 页面发起 HTTP GET 请求时,Suppliers 属性会用来自字符串数组的一些示例供应商名称进行填充。稍后,我们将从 Northwind 数据库填充此属性。

  3. 修改 Suppliers.cshtml 的内容,如下所示的标记:

    @page
    @model Northwind.Web.Pages.SuppliersModel
    <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 (Model.Suppliers is not null)
        {
          @foreach(string name in Model.Suppliers)
          {
            <tr><td>@name</td></tr>
          }
        }
        </tbody>
      </table>
    </div> 
    

    在审查前面的标记时,请注意以下几点:

    • 此 Razor 页面的模型类型设置为 SuppliersModel

    • 该页面输出一个带有 Bootstrap 样式的 HTML 表格。

    • 如果 ModelSuppliers 属性不为 null,则表格中的数据行是通过循环遍历该属性生成的。

  4. 启动网站并在 Chrome 中访问它。

  5. 点击按钮以了解更多关于供应商的信息,并注意供应商表格,如图 14.11 所示:

    图 14.11:从字符串数组加载的供应商表格

使用 ASP.NET Core 的 Entity Framework Core

Entity Framework Core 是一种自然而然的方式,将真实数据引入网站。在第十三章C# 和 .NET 的实际应用介绍中,你创建了两对类库:一对用于实体模型,另一对用于 Northwind 数据库上下文,适用于 SQL Server 或 SQLite,或两者兼有。现在你将在网站项目中使用它们。

将 Entity Framework Core 配置为服务

诸如 Entity Framework Core 数据库上下文之类的功能,对于 ASP.NET Core 是必需的,必须在网站启动时注册为服务。GitHub 仓库解决方案和下面的代码使用 SQLite,但如果你更喜欢,也可以轻松使用 SQL Server。

让我们看看如何操作:

  1. Northwind.Web 项目中,添加一个项目引用到 Northwind.Common.DataContext 项目,适用于 SQLite 或 SQL Server,如下列标记所示:

    <!-- change Sqlite to SqlServer if you prefer -->
    <ItemGroup>
      <ProjectReference Include="..\Northwind.Common.DataContext.Sqlite\
    Northwind.Common.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    

    项目引用必须全部在一行上,不能有换行。

  2. 构建 Northwind.Web 项目。

  3. Startup.cs 中,导入命名空间以处理你的实体模型类型,如下列代码所示:

    using Packt.Shared; // AddNorthwindContext extension method 
    
  4. ConfigureServices 方法中添加一条语句,以注册 Northwind 数据库上下文类,如下列代码所示:

    services.AddNorthwindContext(); 
    
  5. Northwind.Web 项目中,在 Pages 文件夹下,打开 Suppliers.cshtml.cs,并导入我们的数据库上下文命名空间,如下列代码所示:

    using Packt.Shared; // NorthwindContext 
    
  6. SuppliersModel 类中,添加一个私有字段来存储 Northwind 数据库上下文,以及一个构造函数来设置它,如下列代码所示:

    private NorthwindContext db;
    public SuppliersModel(NorthwindContext injectedContext)
    {
      db = injectedContext;
    } 
    
  7. Suppliers 属性更改为包含 Supplier 对象而不是 string 值。

  8. OnGet 方法中,修改语句以根据数据库上下文的 Suppliers 属性设置 Suppliers 属性,按国家和公司名称排序,如下列突出显示的代码所示:

    public void OnGet()
    {
      ViewData["Title"] = "Northwind B2B - Suppliers";
      Suppliers = **db.Suppliers**
     **.OrderBy(c => c.Country).ThenBy(c => c.CompanyName)**;
    } 
    
  9. 修改 Suppliers.cshtml 的内容,以导入 Packt.Shared 命名空间,并为每个供应商渲染多个列,如下列突出显示的标记所示:

    @page
    **@using Packt.Shared**
    @model Northwind.Web.Pages.SuppliersModel
    <div class="row">
      <h1 class="display-2">Suppliers</h1>
      <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 Model.Suppliers)**
          {
            <tr>
    **<****td****>****@s.CompanyName****</****td****>**
    **<****td****>****@s.Country****</****td****>**
    **<****td****>****@s.Phone****</****td****>**
            </tr>
          }
        }
        </tbody>
      </table>
    </div> 
    
  10. 启动网站。

  11. 在 Chrome 中,输入 https://localhost:5001/

  12. 点击 了解更多关于我们的供应商,并注意供应商表现在从数据库加载,如图 14.12 所示:

图 14.12:从 Northwind 数据库加载的供应商表

使用 Razor Pages 操作数据

现在你将添加功能以插入新供应商。

启用模型以插入实体

首先,你将修改供应商模型,使其在访问者提交表单以插入新供应商时响应 HTTP POST 请求:

  1. Northwind.Web 项目中,在 Pages 文件夹下,打开 Suppliers.cshtml.cs 并导入以下命名空间:

    using Microsoft.AspNetCore.Mvc; // [BindProperty], IActionResult 
    
  2. SuppliersModel 类中,添加一个属性来存储单个供应商,以及一个名为 OnPost 的方法,如果模型有效,该方法会将供应商添加到 Northwind 数据库的 Suppliers 表中,如下列代码所示:

    [BindProperty]
    public Supplier? Supplier { get; set; }
    public IActionResult OnPost()
    {
      if ((Supplier is not null) && ModelState.IsValid)
      {
        db.Suppliers.Add(Supplier);
        db.SaveChanges();
        return RedirectToPage("/suppliers");
      }
      else
      {
        return Page(); // return to original page
      }
    } 
    

在回顾前面的代码时,请注意以下几点:

  • 我们添加了一个名为Supplier的属性,该属性装饰有[BindProperty]特性,以便我们可以轻松地将网页上的 HTML 元素连接到Supplier类中的属性。

  • 我们添加了一个响应 HTTP POST请求的方法。它检查所有属性值是否符合Supplier类实体模型上的验证规则(如[Required][StringLength]),然后将供应商添加到现有表中,并保存对数据库上下文的更改。这将生成一个 SQL 语句以执行数据库中的插入操作。然后它重定向到Suppliers页面,以便访客看到新添加的供应商。

定义一个用于插入新供应商的表单

接下来,你将修改 Razor 页面以定义一个表单,访客可以填写并提交以插入新供应商:

  1. Suppliers.cshtml中,在@model声明后添加标签助手,以便我们可以在该 Razor 页面上使用诸如asp-for之类的标签助手,如下面的标记所示:

    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 
    
  2. 在文件底部,添加一个用于插入新供应商的表单,并使用asp-for标签助手将Supplier类的CompanyNameCountryPhone属性绑定到输入框,如下面的标记所示:

    <div class="row">
      <p>Enter details for a new supplier:</p>
      <form method="POST">
        <div><input asp-for="Supplier.CompanyName" 
                    placeholder="Company Name" /></div>
        <div><input asp-for="Supplier.Country" 
                    placeholder="Country" /></div>
        <div><input asp-for="Supplier.Phone" 
                    placeholder="Phone" /></div>
        <input type="submit" />
      </form>
    </div> 
    

    在审查前面的标记时,请注意以下几点:

    • 带有POST方法的<form>元素是常规 HTML,因此其中的<input type="submit" />元素将使用该表单内其他元素的值向当前页面发出 HTTP POST请求。

    • 带有名为asp-for的标签助手的<input>元素能够将数据绑定到 Razor 页面背后的模型。

  3. 启动网站。

  4. 点击了解更多关于我们的供应商,滚动到页面底部,输入Bob's BurgersUSA(603) 555-4567,然后点击提交

  5. 注意,你会看到一个更新的供应商表,其中新增了供应商。

  6. 关闭 Chrome 并关闭 Web 服务器。

向 Razor 页面注入依赖服务

如果你有一个没有代码后置文件的.cshtml Razor 页面,那么你可以使用@inject指令而不是构造函数参数注入来注入依赖服务,然后直接在标记中间使用 Razor 语法引用注入的数据库上下文。

让我们创建一个简单的示例:

  1. Pages文件夹中,添加一个名为Orders.cshtml的新文件。(Visual Studio 的项目模板名为Razor Page - Empty,它会创建两个文件。删除.cs文件。)

  2. Orders.cshtml中,编写代码以输出 Northwind 数据库中的订单数量,如下面的标记所示:

    @page
    @using Packt.Shared
    @inject NorthwindContext db
    @{
      string title = "Orders";
      ViewData["Title"] = $"Northwind B2B - {title}";
    }
    <div class="row">
      <h1 class="display-2">@title</h1>
      <p>
        There are @db.Orders.Count() orders in the Northwind database.
      </p>
    </div> 
    
  3. 启动网站。

  4. 导航到/orders并注意你看到 Northwind 数据库中有 830 个订单。

  5. 关闭 Chrome 并关闭 Web 服务器。

使用 Razor 类库

与 Razor 页面相关的所有内容都可以编译成类库,以便在多个项目中更容易重用。使用 ASP.NET Core 3.0 及更高版本,这可以包括静态文件,如 HTML、CSS、JavaScript 库和媒体资产,如图像文件。网站可以使用类库中定义的 Razor 页面的视图,也可以覆盖它。

创建 Razor 类库

让我们创建一个新的 Razor 类库:

使用你喜欢的代码编辑器添加新项目,如下表所示:

  1. 项目模板:Razor 类库 / razorclasslib

  2. 复选框/开关:支持页面和视图 / -s

  3. 工作区/解决方案文件和文件夹:PracticalApps

  4. 项目文件和文件夹:Northwind.Razor.Employees

-s--support-pages-and-views开关的简写,该开关使类库能够使用 Razor 页面和.cshtml文件视图。

为 Visual Studio Code 禁用紧凑文件夹

在我们实现 Razor 类库之前,我想解释一下 Visual Studio Code 的一个功能,该功能在出版后添加,曾让一些读者感到困惑。

紧凑文件夹功能意味着,如果层次结构中的中间文件夹不包含文件,则类似/Areas/MyFeature/Pages/的嵌套文件夹会以紧凑形式显示,如图14.13所示:

图 14.13:启用或禁用紧凑文件夹

如果你想禁用 Visual Studio Code 的紧凑文件夹功能,请完成以下步骤:

  1. 在 Windows 上,导航至文件 | 首选项 | 设置。在 macOS 上,导航至代码 | 首选项 | 设置

  2. 搜索设置框中,输入compact

  3. 清除资源管理器:紧凑文件夹复选框,如图14.14所示:图形用户界面,文本,应用程序,电子邮件 自动生成描述

    图 14.14:为 Visual Studio Code 禁用紧凑文件夹

  4. 关闭设置标签页。

使用 EF Core 实现员工功能

现在我们可以添加对实体模型的引用,以便在 Razor 类库中显示员工信息:

  1. Northwind.Razor.Employees项目中,添加对Northwind.Common.DataContext项目的项目引用,选择 SQLite 或 SQL Server,并注意 SDK 为Microsoft.NET.Sdk.Razor,如下所示高亮显示:

    <Project Sdk="**Microsoft.NET.Sdk.Razor**">
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
      </PropertyGroup>
      <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App" />
      </ItemGroup>
     **<!-- change Sqlite to SqlServer** **if** **you prefer -->**
     **<ItemGroup>**
     **<ProjectReference Include=****"..\Northwind.Common.DataContext.Sqlite**
    **\Northwind.Common.DataContext.Sqlite.csproj"** **/>**
     **</ItemGroup>**
    </Project> 
    

    项目引用必须全部在一行上,不能有换行。此外,不要混合使用我们的 SQLite 和 SQL Server 项目,否则你会看到编译器错误。如果你在Northwind.Web项目中使用了 SQL Server,那么在Northwind.Razor.Employees项目中也必须使用 SQL Server。

  2. 构建Northwind.Razor.Employees项目。

  3. Areas文件夹中,右键点击MyFeature文件夹,选择重命名,输入新名称PacktFeatures,然后按 Enter 键。

  4. PacktFeatures文件夹中,在Pages子文件夹中,添加一个名为_ViewStart.cshtml的新文件。(Visual Studio 项模板名为Razor 视图开始。或者直接从Northwind.Web项目复制。)

  5. 修改其内容,通知此类库任何 Razor 页面应查找与Northwind.Web项目中使用的名称相同的布局,如下所示:

    @{
      Layout = "_Layout";
    } 
    

    我们无需在此项目中创建_Layout.cshtml文件。它将使用其宿主项目中的那个,例如Northwind.Web项目中的那个。

  6. Pages子文件夹中,将Page1.cshtml重命名为Employees.cshtml,并将Page1.cshtml.cs重命名为Employees.cshtml.cs

  7. 修改Employees.cshtml.cs以定义一个页面模型,该模型包含从 Northwind 数据库加载的Employee实体实例数组,如下所示:

    using Microsoft.AspNetCore.Mvc.RazorPages; // PageModel
    using Packt.Shared; // Employee, NorthwindContext
    namespace PacktFeatures.Pages;
    public class EmployeesPageModel : PageModel
    {
      private NorthwindContext db;
      public EmployeesPageModel(NorthwindContext injectedContext)
      {
        db = injectedContext;
      }
      public Employee[] Employees { get; set; } = null!;
      public void OnGet()
      {
        ViewData["Title"] = "Northwind B2B - Employees";
        Employees = db.Employees.OrderBy(e => e.LastName)
          .ThenBy(e => e.FirstName).ToArray();
      }
    } 
    
  8. 修改Employees.cshtml,如下所示:

    @page
    @using Packt.Shared
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 
    @model PacktFeatures.Pages.EmployeesPageModel
    <div class="row">
      <h1 class="display-2">Employees</h1>
    </div>
    <div class="row">
    @foreach(Employee employee in Model.Employees)
    {
      <div class="col-sm-3">
        <partial name="_Employee" model="employee" />
      </div>
    }
    </div> 
    

在审查前面的标记时,请注意以下几点:

  • 我们导入Packt.Shared命名空间,以便可以使用其中的类,例如Employee

  • 我们添加对标签助手的支持,以便可以使用<partial>元素。

  • 我们声明此 Razor 页面的@model类型,以使用你刚定义的页面模型类。

  • 我们遍历模型中的Employees,使用部分视图输出每个员工。

实现一个部分视图以显示单个员工

<partial>标签助手是在 ASP.NET Core 2.1 中引入的。部分视图类似于 Razor 页面的一个片段。接下来几步中,你将创建一个以渲染单个员工:

  1. Northwind.Razor.Employees项目中,在Pages文件夹中创建一个Shared文件夹。

  2. Shared文件夹中,创建一个名为_Employee.cshtml的文件。(Visual Studio 项模板名为Razor 视图 - 空。)

  3. 修改_Employee.cshtml,如下所示:

    @model Packt.Shared.Employee
    <div class="card border-dark mb-3" style="max-width: 18rem;">
      <div class="card-header">@Model?.LastName, @Model?.FirstName</div>
      <div class="card-body text-dark">
        <h5 class="card-title">@Model?.Country</h5>
        <p class="card-text">@Model?.Notes</p>
      </div>
    </div> 
    

在审查前面的标记时,请注意以下几点:

  • 按照惯例,部分视图的名称以一个下划线开头。

  • 如果你将部分视图放在Shared文件夹中,那么它可以被自动找到。

  • 此部分视图的模型类型是单个Employee实体。

  • 我们使用 Bootstrap 卡片样式输出每个员工的信息。

使用和测试 Razor 类库

你现在将在网站项目中引用并使用 Razor 类库:

  1. Northwind.Web项目中,添加对Northwind.Razor.Employees项目的一个项目引用,如下所示:

    <ProjectReference Include=
      "..\Northwind.Razor.Employees\Northwind.Razor.Employees.csproj" /> 
    
  2. 修改Pages\index.cshtml,在供应商页面链接后添加一个段落,其中包含指向 Packt 功能员工页面的链接,如下所示:

    <p>
      <a class="btn btn-primary" href="packtfeatures/employees">
        Contact our employees
      </a>
    </p> 
    
  3. 启动网站,使用 Chrome 访问网站,并点击联系我们的员工按钮以查看员工卡片,如图 14.15 所示:

    图 14.15:来自 Razor 类库功能的员工列表

配置服务和 HTTP 请求管道

既然我们已经构建了一个网站,我们可以回到 Startup 配置,并更详细地审查服务和 HTTP 请求管道是如何工作的。

理解 Endpoint routing

在早期版本的 ASP.NET Core 中,路由系统和可扩展的中间件系统并不总是能轻松协同工作;例如,如果你想在中间件和 MVC 中实现 CORS 等策略。微软投资改进路由,引入了名为 Endpoint routing 的系统,该系统随 ASP.NET Core 2.2 一起推出。

最佳实践:Endpoint routing 取代了 ASP.NET Core 2.1 及更早版本中使用的基于 IRouter 的路由。微软建议尽可能将所有旧的 ASP.NET Core 项目迁移到 Endpoint routing。

Endpoint routing 旨在实现需要路由的框架(如 Razor Pages、MVC 或 Web API)与需要了解路由如何影响它们的中间件(如本地化、授权、CORS 等)之间的更好互操作性。

Endpoint routing 之所以得名,是因为它将路由表表示为可由路由系统高效遍历的编译端点树。其中最大的改进之一是路由和动作方法选择的性能。

如果兼容性设置为 2.2 或更高版本,则默认情况下,ASP.NET Core 2.2 或更高版本会启用 Endpoint routing。使用 MapRoute 方法或属性注册的传统路由会映射到新系统。

新的路由系统包括一个链接生成服务,该服务作为依赖服务注册,不需要 HttpContext

配置 Endpoint routing

Endpoint routing 需要一对调用 UseRoutingUseEndpoints 方法:

  • UseRouting 标记了做出路由决策的管道位置。

  • UseEndpoints 标记了选定端点执行的管道位置。

在这些方法之间运行的中间件(如本地化)可以看到选定的端点,并在必要时切换到不同的端点。

Endpoint routing 使用自 2010 年以来在 ASP.NET MVC 中使用的相同路由模板语法,以及 2013 年随 ASP.NET MVC 5 引入的 [Route] 属性。迁移通常只需要更改 Startup 配置。

MVC 控制器、Razor Pages 和 SignalR 等框架过去通过调用 UseMvc 或类似方法启用,但现在它们都在 UseEndpoints 方法调用内部添加,因为它们都集成到了同一个路由系统中,与中间件一起。

在我们的项目中审查 Endpoint routing 配置

查看 Startup.cs 类文件,如下所示:

using Packt.Shared; // AddNorthwindContext extension method
namespace Northwind.Web;
public class Startup
{
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddRazorPages();
    services.AddNorthwindContext();
  }
  public void Configure(
    IApplicationBuilder app, IWebHostEnvironment env)
  {
    if (!env.IsDevelopment())
    {
      app.UseHsts();
    }
    app.UseRouting();
    app.UseHttpsRedirection();
    app.UseDefaultFiles(); // index.html, default.html, and so on
    app.UseStaticFiles();
    app.UseEndpoints(endpoints =>
    {
      endpoints.MapRazorPages();
      endpoints.MapGet("/hello", () => "Hello World!");
    });
  }
} 

Startup 类有两个方法,主机自动调用这两个方法来配置网站。

ConfigureServices 方法注册了可以在需要它们提供的功能时使用依赖注入检索的服务。我们的代码注册了两个服务:Razor Pages 和 EF Core 数据库上下文。

在 ConfigureServices 方法中注册服务

注册依赖服务的常用方法,包括结合其他注册服务方法调用的服务,如下表所示:

方法 注册的服务
AddMvcCore 路由请求和调用控制器所需的最小服务集。大多数网站需要比这更多的配置。
AddAuthorization 认证和授权服务。
AddDataAnnotations MVC 数据注解服务。
AddCacheTagHelper MVC 缓存标签助手服务。
AddRazorPages 包含 Razor 视图引擎的 Razor Pages 服务。常用于简单网站项目。它调用以下附加方法:AddMvcCore``AddAuthorization``AddDataAnnotations``AddCacheTagHelper
AddApiExplorer Web API 探索服务。
AddCors 增强安全性的 CORS 支持。
AddFormatterMappings URL 格式与其对应的媒体类型之间的映射。
AddControllers 控制器服务,但不包括视图或页面服务。常用于 ASP.NET Core Web API 项目。它调用以下附加方法:AddMvcCore``AddAuthorization``AddDataAnnotations``AddCacheTagHelper``AddApiExplorer``AddCors``AddFormatterMappings
AddViews 支持 .cshtml 视图,包括默认约定。
AddRazorViewEngine 支持 Razor 视图引擎,包括处理 @ 符号。
AddControllersWithViews 控制器、视图和页面服务。常用于 ASP.NET Core MVC 网站项目。它调用以下附加方法:AddMvcCore``AddAuthorization``AddDataAnnotations``AddCacheTagHelper``AddApiExplorer``AddCors``AddFormatterMappings``AddViews``AddRazorViewEngine
AddMvc 类似于 AddControllersWithViews,但仅应为向后兼容而使用。
AddDbContext<T> 您的 DbContext 类型及其可选的 DbContextOptions<TContext>
AddNorthwindContext 我们创建的一个自定义扩展方法,以便更容易地根据项目引用注册 NorthwindContext 类,无论是 SQLite 还是 SQL Server。

在接下来的几章中,当与 MVC 和 Web API 服务一起工作时,您将看到更多使用这些扩展方法注册服务的示例。

在 Configure 方法中设置 HTTP 请求管道

Configure 方法配置 HTTP 请求管道,该管道由一系列连接的委托组成,这些委托可以执行处理,然后决定是自行返回响应,还是将处理传递给管道中的下一个委托。返回的响应也可以被操纵。

请记住,委托定义了一个方法签名,委托实现可以插入其中。HTTP 请求管道的委托很简单,如下所示:

public delegate Task RequestDelegate(HttpContext context); 

您可以看到输入参数是一个HttpContext。这提供了访问处理传入 HTTP 请求所需的一切,包括 URL 路径、查询字符串参数、Cookie 和用户代理。

这些委托通常被称为中间件,因为它们位于浏览器客户端和网站或服务之间。

中间件委托通过以下方法之一或调用它们本身的自定义方法进行配置:

  • Run:添加一个中间件委托,该委托通过立即返回响应而不是调用下一个中间件委托来终止管道。

  • Map:添加一个中间件委托,当存在匹配的请求时,通常基于 URL 路径(如/hello)在管道中创建分支。

  • Use:添加一个中间件委托,该委托构成管道的一部分,因此它可以决定是否要将请求传递给管道中的下一个委托,并且可以在下一个委托之前和之后修改请求和响应。

为了方便,有许多扩展方法使得构建管道更加容易,例如UseMiddleware<T>,其中T是一个具有以下特性的类:

  1. 一个带有RequestDelegate参数的构造函数,该参数将传递给下一个管道组件

  2. 一个带有HttpContext参数并返回TaskInvoke方法

总结关键中间件扩展方法

我们的代码中使用的关键中间件扩展方法包括以下内容:

  • UseDeveloperExceptionPage:捕获管道中的同步和异步System.Exception实例,并生成 HTML 错误响应。

  • UseHsts:添加使用 HSTS 的中间件,该中间件添加了Strict-Transport-Security头。

  • UseRouting:添加中间件,该中间件定义管道中的一个点,在此点进行路由决策,并且必须与调用UseEndpoints结合使用,在此处执行处理。这意味着对于我们的代码,任何匹配//index/suppliers的 URL 路径都将映射到 Razor 页面,而匹配/hello的 URL 路径将映射到匿名委托。任何其他 URL 路径都将传递给下一个委托进行匹配,例如静态文件。这就是为什么,尽管看起来 Razor 页面和/hello的映射发生在静态文件之后,但实际上它们具有优先权,因为调用UseRouting发生在UseStaticFiles之前。

  • UseHttpsRedirection:添加中间件,用于将 HTTP 请求重定向到 HTTPS,因此在我们的代码中,对http://localhost:5000的请求将被修改为https://localhost:5001

  • UseDefaultFiles:添加中间件,该中间件在当前路径上启用默认文件映射,因此在我们的代码中,它会识别诸如index.html之类的文件。

  • UseStaticFiles:添加中间件,该中间件在wwwroot中查找静态文件以返回 HTTP 响应。

  • UseEndpoints:添加中间件以执行,根据管道中早先做出的决策生成响应。如以下子列表所示,添加了两个端点:

    • MapRazorPages:添加中间件,将 URL 路径(如/suppliers)映射到/Pages文件夹中的 Razor Page 文件suppliers.cshtml,并将结果作为 HTTP 响应返回。

    • MapGet:添加中间件,将 URL 路径(如/hello)映射到内联委托,该委托直接将纯文本写入 HTTP 响应。

可视化 HTTP 管道

HTTP 请求和响应管道可以被可视化为一系列请求委托的序列,一个接一个地调用,如下面的简化图所示,其中省略了一些中间件委托,如UseHsts

自动生成的图表描述

图 14.16:HTTP 请求和响应管道

如前所述,UseRoutingUseEndpoints方法必须一起使用。虽然定义映射路由(如/hello)的代码写在UseEndpoints中,但决定传入的 HTTP 请求 URL 路径是否匹配以及因此执行哪个端点的决策是在管道中的UseRouting点做出的。

实现匿名内联委托作为中间件

委托可以指定为内联匿名方法。我们将注册一个在端点路由决策之后插入到管道中的委托。

它将输出选择了哪个端点,以及处理特定路由/bonjour。如果匹配到该路由,它将以纯文本形式响应,不会进一步调用管道中的任何内容:

  1. Startup.cs中静态导入Console,如下所示:

    using static System.Console; 
    
  2. 在调用UseRouting之后和调用UseHttpsRedirection之前添加语句,使用匿名方法作为中间件委托,如下所示:

    app.Use(async (HttpContext context, Func<Task> next) =>
    {
      RouteEndpoint? rep = context.GetEndpoint() as RouteEndpoint;
      if (rep is not null)
      {
        WriteLine($"Endpoint name: {rep.DisplayName}");
        WriteLine($"Endpoint route pattern: {rep.RoutePattern.RawText}");
      }
      if (context.Request.Path == "/bonjour")
      {
        // in the case of a match on URL path, this becomes a terminating
        // delegate that returns so does not call the next delegate
        await context.Response.WriteAsync("Bonjour Monde!");
        return;
      }
      // we could modify the request before calling the next delegate
      await next();
      // we could modify the response after calling the next delegate
    }); 
    
  3. 启动网站。

  4. 在 Chrome 中访问https://localhost:5001/,查看控制台输出,注意到匹配到了一个端点路由/,它被处理为/index,并且执行了Index.cshtml Razor Page 来返回响应,如下所示:

    Endpoint name: /index 
    Endpoint route pattern: 
    
  5. 访问https://localhost:5001/suppliers,注意到匹配到了一个端点路由/Suppliers,并且执行了Suppliers.cshtml Razor Page 来返回响应,如下所示:

    Endpoint name: /Suppliers 
    Endpoint route pattern: Suppliers 
    
  6. 访问https://localhost:5001/index,注意到匹配到了一个端点路由/index,并且执行了Index.cshtml Razor Page 来返回响应,如下所示:

    Endpoint name: /index 
    Endpoint route pattern: index 
    
  7. 访问https://localhost:5001/index.html,注意到控制台没有输出,因为没有匹配到端点路由,但匹配到了一个静态文件,因此作为响应返回。

  8. 访问https://localhost:5001/bonjour,注意到控制台没有输出,因为没有匹配到端点路由。相反,我们的委托匹配到了/bonjour,直接写入响应流,并返回,没有进一步处理。

  9. 关闭 Chrome 并关闭 Web 服务器。

实践与探索

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

练习 14.1 – 测试你的知识

回答以下问题:

  1. 列出六个可以在 HTTP 请求中指定的方法名称。

  2. 列出六个状态码及其描述,这些状态码可以在 HTTP 响应中返回。

  3. ASP.NET Core 中,Startup类的作用是什么?

  4. 缩写 HSTS 代表什么,它有什么作用?

  5. 如何为网站启用静态 HTML 页面?

  6. 如何在 HTML 中间混合 C#代码以创建动态页面?

  7. 如何定义 Razor Pages 的共享布局?

  8. 如何在 Razor Page 中分离标记与代码背后的代码?

  9. 如何配置 Entity Framework Core 数据上下文以用于 ASP.NET Core 网站?

  10. 如何在 ASP.NET Core 2.2 或更高版本中重用 Razor Pages?

练习 14.2 – 实践构建数据驱动的网页

Northwind.Web网站添加一个 Razor Page,使用户能够查看按国家分组的客户列表。当用户点击客户记录时,他们将看到一个显示该客户完整联系信息以及其订单列表的页面。

练习 14.3 – 实践为控制台应用构建网页

将前几章的一些控制台应用重新实现为 Razor Pages,例如,从第四章编写、调试和测试函数,提供一个 Web 用户界面来输出乘法表、计算税款、生成阶乘和斐波那契序列。

练习 14.4 – 探索主题

使用以下页面上的链接,深入了解本章涵盖的主题:

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-14---building-websites-using-aspnet-core-razor-pages

总结

本章中,你学习了使用 HTTP 进行 Web 开发的基础知识,如何构建一个返回静态文件的简单网站,以及如何使用 ASP.NET Core Razor Pages 结合 Entity Framework Core 来创建从数据库信息动态生成的网页。

我们回顾了 HTTP 请求和响应管道,辅助扩展方法的作用,以及如何添加自己的中间件,影响处理过程。

下一章,你将学习如何使用 ASP.NET Core MVC 构建更复杂的网站,该框架将构建网站的技术关注点分离为模型、视图和控制器,使其更易于管理。

第十五章:使用模型-视图-控制器模式构建网站

本章介绍使用 Microsoft ASP.NET Core MVC 在服务器端构建具有现代 HTTP 架构的网站,包括启动配置、身份验证、授权、路由、请求和响应管道、模型、视图和构成 ASP.NET Core MVC 项目的控制器。

本章将涵盖以下主题:

  • 设置 ASP.NET Core MVC 网站

  • 探索 ASP.NET Core MVC 网站

  • 自定义 ASP.NET Core MVC 网站

  • 查询数据库并使用显示模板

  • 通过使用异步任务提高可扩展性

设置 ASP.NET Core MVC 网站

ASP.NET Core Razor Pages 非常适合简单的网站。对于更复杂的网站,最好有一个更正式的结构来管理这种复杂性。

这就是模型-视图-控制器MVC)设计模式发挥作用的地方。它使用 Razor Pages 等技术,但允许技术关注点之间有更清晰的分离,如下所示:

  • 模型:表示网站上使用的数据实体和视图模型的类。

  • 视图:Razor 文件,即.cshtml文件,将视图模型中的数据渲染成 HTML 网页。Blazor 使用.razor文件扩展名,但不要将其与 Razor 文件混淆!

  • 控制器:HTTP 请求到达 Web 服务器时执行代码的类。控制器方法通常创建一个可能包含实体模型的视图模型,并将其传递给视图以生成 HTTP 响应,发回给 Web 浏览器或其他客户端。

理解使用 MVC 设计模式进行 Web 开发的最佳方式是查看一个实际示例。

创建一个 ASP.NET Core MVC 网站

您将使用项目模板创建一个具有用于身份验证和授权用户的数据库的 ASP.NET Core MVC 网站项目。Visual Studio 2022 默认使用 SQL Server LocalDB 作为账户数据库。Visual Studio Code(或更准确地说,dotnet工具)默认使用 SQLite,您可以通过指定开关改用 SQL Server LocalDB。

让我们看看它的实际效果:

  1. 使用您喜欢的代码编辑器添加一个具有存储在数据库中的身份验证账户的 MVC 网站项目,如下表所示:

    1. 项目模板:ASP.NET Core Web App(模型-视图-控制器) / mvc

    2. 语言:C#

    3. 工作区/解决方案文件和文件夹:PracticalApps

    4. 项目文件和文件夹:Northwind.Mvc

    5. 选项:身份验证类型:个人账户 / --auth Individual

    6. 对于 Visual Studio,将所有其他选项保留为其默认值

  2. 在 Visual Studio Code 中,选择Northwind.Mvc作为活动 OmniSharp 项目。

  3. 构建Northwind.Mvc项目。

  4. 在命令行或终端中,使用help开关查看此项目模板的其他选项,如下所示:

    dotnet new mvc --help 
    
  5. 注意结果,如下所示的部分输出:

    ASP.NET Core Web App (Model-View-Controller) (C#)
    Author: Microsoft
    Description: A project template for creating an ASP.NET Core application with example ASP.NET Core MVC Views and Controllers. This template can also be used for RESTful HTTP services.
    This template contains technologies from parties other than Microsoft, see https://aka.ms/aspnetcore/6.0-third-party-notices for details. 
    

有许多选项,特别是与身份验证相关的选项,如下表所示:

开关 描述
-au&#124;--auth 使用的认证类型:None(默认):此选择还允许你禁用 HTTPS。Individual:个人认证,将注册用户及其密码存储在数据库中(默认使用 SQLite)。我们将在本章创建的项目中使用此选项。IndividualB2C:使用 Azure AD B2C 的个人认证。SingleOrg:单租户的组织认证。MultiOrg:多租户的组织认证。Windows:Windows 认证。主要用于内网。
-uld&#124;--use-local-db 是否使用 SQL Server LocalDB 代替 SQLite。此选项仅在指定--auth Individual--auth IndividualB2C时适用。值是一个可选的bool,默认值为false
-rrc&#124;--razor-runtime-compilation 确定项目是否配置为在Debug构建中使用 Razor 运行时编译。这可以提高调试时启动的性能,因为它可以延迟 Razor 视图的编译。值是一个可选的bool,默认值为false
-f&#124;--framework 项目的目标框架。值可以是:net6.0(默认)、net5.0netcoreapp3.1

为 SQL Server LocalDB 创建认证数据库

如果你使用 Visual Studio 2022 创建了 MVC 项目,或者你使用dotnet new mvc并带有-uld--use-local-db开关,那么用于认证和授权的数据库将存储在 SQL Server LocalDB 中。但该数据库尚未存在。现在让我们创建它。

在命令提示符或终端中,在Northwind.Mvc文件夹下,输入运行数据库迁移的命令,以便创建用于存储认证凭据的数据库,如下所示:

dotnet ef database update 

如果你使用dotnet new创建了 MVC 项目,那么用于认证和授权的数据库将存储在 SQLite 中,且已创建名为app.db的文件。

认证数据库的连接字符串名为DefaultConnection,它存储在 MVC 网站项目根目录下的appsettings.json文件中。

对于 SQL Server LocalDB(使用截断的连接字符串),请参见以下标记:

{
  "ConnectionStrings": {
    "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-Northwind.Mvc-...;Trusted_Connection=True;MultipleActiveResultSets=true"
  }, 

对于 SQLite,请参见以下标记:

{
  "ConnectionStrings": {
    "DefaultConnection": "DataSource=app.db;Cache=Shared"
  }, 

探索默认的 ASP.NET Core MVC 网站

让我们回顾一下默认 ASP.NET Core MVC 网站项目模板的行为:

  1. Northwind.Mvc项目中,展开Properties文件夹,打开launchSettings.json文件,并注意为项目配置的随机端口号(你的将不同),用于HTTPSHTTP,如下所示:

    "profiles": {
      "Northwind.Mvc": {
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
        "applicationUrl": "https://localhost:7274;http://localhost:5274",
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        }
      }, 
    
  2. 将端口号更改为5001用于HTTPS5000用于HTTP,如下所示:

    "applicationUrl": "https://localhost:5001;http://localhost:5000", 
    
  3. 保存对launchSettings.json文件的更改。

  4. 启动网站。

  5. 启动 Chrome 并打开开发者工具

  6. 导航至http://localhost:5000/并注意以下内容,如图15.1所示:

    • HTTP 请求会自动重定向到端口5001上的 HTTPS。

    • 顶部导航菜单,包含首页隐私注册登录的链接。如果视口宽度为 575 像素或更小,则导航会折叠成一个汉堡菜单。

    • 网站标题Northwind.Mvc,显示在页眉和页脚中。

图 15.1:ASP.NET Core MVC 项目模板网站首页

理解访问者注册

默认情况下,密码必须至少包含一个非字母数字字符,至少包含一个数字(0-9),以及至少包含一个大写字母(A-Z)。在这种探索场景中,我使用Pa$$w0rd

MVC 项目模板遵循双重选择加入DOI)的最佳实践,这意味着在填写电子邮件和密码进行注册后,会向该电子邮件地址发送一封电子邮件,访问者必须点击该电子邮件中的链接以确认他们想要注册。

我们尚未配置电子邮件提供商来发送该电子邮件,因此我们必须模拟这一步骤:

  1. 在顶部导航菜单中,点击注册

  2. 输入电子邮件和密码,然后点击注册按钮。(我使用了test@example.comPa$$w0rd。)

  3. 点击文本为点击此处确认您的账户的链接,并注意您将被重定向到一个可以自定义的确认电子邮件网页。

  4. 在顶部导航菜单中,点击登录,输入您的电子邮件和密码(注意有一个可选的复选框用于记住您,以及如果访问者忘记密码或想要注册为新访问者时的链接),然后点击登录按钮。

  5. 在顶部导航菜单中点击您的电子邮件地址。这将导航到账户管理页面。请注意,您可以设置电话号码,更改您的电子邮件地址,更改您的密码,启用两因素认证(如果您添加了认证器应用),以及下载和删除您的个人数据。

  6. 关闭 Chrome 并关闭网络服务器。

审查 MVC 网站项目结构

在您的代码编辑器中,在 Visual Studio 解决方案资源管理器(切换显示所有文件)或在 Visual Studio Code 资源管理器中,审查 MVC 网站项目的结构,如图 15.2 所示:

图 15.2:ASP.NET Core MVC 项目的默认文件夹结构

我们稍后将对其中一些部分进行更详细的探讨,但目前请注意以下几点:

  • 区域:此文件夹包含用于将您的网站项目与ASP.NET Core Identity(用于身份验证)集成的嵌套文件夹和文件。

  • binobj:这些文件夹包含构建过程中所需的临时文件和项目的已编译程序集。

  • 控制器:此文件夹包含具有方法(称为动作)的 C#类,这些方法获取模型并将其传递给视图,例如,HomeController.cs

  • Data:此文件夹包含 Entity Framework Core 迁移类,这些类由 ASP.NET Core Identity 系统用于提供身份验证和授权的数据存储,例如ApplicationDbContext.cs

  • Models:此文件夹包含表示由控制器收集并传递给视图的所有数据的 C#类,例如ErrorViewModel.cs

  • Properties:此文件夹包含 Windows 上 IIS 或 IIS Express 的配置文件,以及在开发期间启动网站的名为launchSettings.json的文件。此文件仅用于本地开发机器,不会部署到生产网站。

  • Views:此文件夹包含结合 HTML 和 C#代码以动态生成 HTML 响应的.cshtml Razor 文件。_ViewStart文件设置默认布局,_ViewImports导入所有视图中使用的公共命名空间,如标签助手:

    • Home:此子文件夹包含主页和隐私页面的 Razor 文件。

    • Shared:此子文件夹包含用于共享布局、错误页面以及登录和验证脚本的两个部分视图的 Razor 文件。

  • wwwroot:此文件夹包含网站使用的静态内容,如用于样式的 CSS、JavaScript 库、此网站项目的 JavaScript 以及favicon.ico文件。您还可以在此处放置图像和其他静态文件资源,如 PDF 文档。项目模板包括 Bootstrap 和 jQuery 库。

  • app.db:这是存储注册访问者的 SQLite 数据库。(如果您使用 SQL Server LocalDB,则不需要它。)

  • appsettings.jsonappsettings.Development.json:这些文件包含网站运行时可加载的设置,例如 ASP.NET Core Identity 系统的数据库连接字符串和日志级别。

  • Northwind.Mvc.csproj:此文件包含项目设置,如使用 Web .NET SDK、确保app.db文件被复制到网站输出目录的 SQLite 入口,以及项目所需的一列 NuGet 包,包括:

    • Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore

    • Microsoft.AspNetCore.Identity.EntityFrameworkCore

    • Microsoft.AspNetCore.Identity.UI

    • Microsoft.EntityFrameworkCore.SqliteMicrosoft.EntityFrameworkCore.SqlServer

    • Microsoft.EntityFrameworkCore.Tools

  • Program.cs:此文件定义了一个隐藏的Program类,其中包含Main入口点。它构建了一个处理传入 HTTP 请求的管道,并使用默认选项(如配置 Kestrel Web 服务器和加载appsettings)托管网站。它添加并配置了网站所需的服务,例如用于身份验证的 ASP.NET Core Identity、用于身份数据存储的 SQLite 或 SQL Server 等,以及应用程序的路由。

审查 ASP.NET Core Identity 数据库

打开appsettings.json以找到用于 ASP.NET Core Identity 数据库的连接字符串,如下面的标记中突出显示的 SQL Server LocalDB 所示:

{
  "ConnectionStrings": {
    "DefaultConnection": "**Server=(localdb)\\mssqllocaldb;Database=aspnet-Northwind.Mvc-2F6A1E12-F9CF-480C-987D-FEFB4827DE22;Trusted_Connection=True;MultipleActiveResultSets=true**"
  },
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
} 

如果你使用 SQL Server LocalDB 作为身份数据存储,那么你可以使用服务器资源管理器连接到数据库。你可以从appsettings.json文件复制并粘贴连接字符串(但需移除(localdb)mssqllocaldb之间的第二个反斜杠)。

如果你安装了 SQLite 工具,如 SQLiteStudio,那么你可以打开 SQLite 的app.db数据库文件。

随后,你可以看到 ASP.NET Core Identity 系统用于注册用户和角色的表格,包括用于存储注册访问者的AspNetUsers表。

最佳实践:ASP.NET Core MVC 项目模板通过存储密码的哈希值而不是密码本身来遵循最佳实践,你将在第二十章保护你的数据和应用程序中了解更多。

探索一个 ASP.NET Core MVC 网站

让我们逐步了解构成现代 ASP.NET Core MVC 网站的各个部分。

理解 ASP.NET Core MVC 初始化

恰如其分地,我们将从探索 MVC 网站的默认初始化和配置开始:

  1. 打开Program.cs文件,并注意到它使用了顶级程序特性(因此有一个隐藏的Program类和一个Main方法)。这个文件可以被视为从上到下分为四个重要部分。

    .NET 5 及更早版本的 ASP.NET Core 项目模板使用Startup类将这些部分分离到不同的方法中,但到了.NET 6,微软鼓励将所有内容放在一个Program.cs文件中。

  2. 第一部分导入了一些命名空间,如下面的代码所示:

    using Microsoft.AspNetCore.Identity; // IdentityUser
    using Microsoft.EntityFrameworkCore; // UseSqlServer, UseSqlite
    using Northwind.Mvc.Data; // ApplicationDbContext 
    

    记住,默认情况下,许多其他命名空间是通过.NET 6 及更高版本的隐式使用功能导入的。构建项目后,全局导入的命名空间可以在以下路径找到:obj\Debug\net6.0\Northwind.Mvc.GlobalUsings.g.cs

  3. 第二部分创建并配置了一个 Web 主机构建器。它使用 SQL Server 或 SQLite 注册了一个应用程序数据库上下文,其数据库连接字符串从appsettings.json文件加载用于数据存储,添加了 ASP.NET Core Identity 用于身份验证,并配置它使用应用程序数据库,并添加了对带有视图的 MVC 控制器的支持,如下面的代码所示:

    var builder = WebApplication.CreateBuilder(args);
    // Add services to the container.
    var connectionString = builder.Configuration
      .GetConnectionString("DefaultConnection");
    builder.Services.AddDbContext<ApplicationDbContext>(options =>
      options.UseSqlServer(connectionString)); // or UseSqlite
    builder.Services.AddDatabaseDeveloperPageExceptionFilter();
    builder.Services.AddDefaultIdentity<IdentityUser>(options => 
      options.SignIn.RequireConfirmedAccount = true)
      .AddEntityFrameworkStores<ApplicationDbContext>();
    builder.Services.AddControllersWithViews(); 
    

    构建器对象有两个常用对象:配置服务

    • 配置包含了所有可能设置配置的地方的合并值:appsettings.json、环境变量、命令行参数等。

    • 服务是一个注册依赖服务的集合

    调用AddDbContext是注册依赖服务的一个示例。ASP.NET Core 实现了依赖注入(DI)设计模式,使得其他组件如控制器可以通过其构造函数请求所需服务。开发者在这一部分Program.cs(或使用Startup类时在其ConfigureServices方法中)注册这些服务。

  4. 第三部分配置了 HTTP 请求管道。它配置了一个相对 URL 路径,在网站运行于开发环境时执行数据库迁移,或在生产环境中提供更友好的错误页面和 HSTS。HTTPS 重定向、静态文件、路由、ASP.NET Identity 被启用,MVC 默认路由和 Razor 页面被配置,如下所示:

    // Configure the HTTP request pipeline.
    if (app.Environment.IsDevelopment())
    {
      app.UseMigrationsEndPoint();
    }
    else
    {
      app.UseExceptionHandler("/Home/Error");
      // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
      app.UseHsts();
    }
    app.UseHttpsRedirection();
    app.UseStaticFiles();
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();
    app.MapControllerRoute(
      name: "default",
      pattern: "{controller=Home}/{action=Index}/{id?}");
    app.MapRazorPages(); 
    

    我们在第十四章使用 ASP.NET Core Razor Pages 构建网站中学习了这些方法和功能的大部分。

    最佳实践:扩展方法UseMigrationsEndPoint的作用是什么?你可以阅读官方文档,但帮助不大。例如,它没有告诉我们默认定义了什么相对 URL 路径:docs.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.migrationsendpointextensions.usemigrationsendpoint。幸运的是,ASP.NET Core 是开源的,因此我们可以阅读源代码并发现其作用,链接如下:github.com/dotnet/aspnetcore/blob/main/src/Middleware/Diagnostics.EntityFrameworkCore/src/MigrationsEndPointOptions.cs#L18。养成探索 ASP.NET Core 源代码的习惯,以理解其工作原理。

    除了UseAuthenticationUseAuthorization方法外,Program.cs这一部分最重要的方法是MapControllerRoute,它为 MVC 映射了一个默认路由。此路由非常灵活,因为它几乎可以映射到任何传入的 URL,如下一主题所示。

    尽管本章我们不会创建任何 Razor 页面,但我们仍需保留映射 Razor 页面支持的方法调用,因为我们的 MVC 网站使用 ASP.NET Core Identity 进行认证和授权,并使用 Razor 类库为其用户界面组件,如访客注册和登录。

  5. 第四个也是最后一个部分包含一个线程阻塞的方法调用,它运行网站并等待传入的 HTTP 请求以进行响应,如下所示:

    app.Run(); // blocking call 
    

理解 MVC 的默认路由

路由的职责是发现要实例化的控制器类名称和要执行的动作方法,以及一个可选的id参数,该参数将传递给生成 HTTP 响应的方法。

MVC 的默认路由配置如下所示:

endpoints.MapControllerRoute(
  name: "default",
  pattern: "{controller=Home}/{action=Index}/{id?}"); 

路由模式在花括号{}中的部分称为,它们类似于方法的命名参数。这些段的值可以是任何字符串。URL 中的段不区分大小写。

路由模式查看浏览器请求的任何 URL 路径,并匹配它以提取控制器的名称、动作的名称和可选的id值(?符号使其可选)。

如果用户未输入这些名称,它将使用默认值Home作为控制器,Index作为操作(=赋值为命名段设置默认值)。

下表包含示例 URL 以及默认路由如何确定控制器和动作的名称:

URL 控制器 动作 ID
/ Home Index
/Muppet Muppet Index
/Muppet/Kermit Muppet Kermit
/Muppet/Kermit/Green Muppet Kermit Green
/Products Products Index
/Products/Detail Products Detail
/Products/Detail/3 Products Detail 3

理解控制器和动作

在 MVC 中,C 代表控制器。从路由和传入的 URL,ASP.NET Core 知道控制器的名称,因此它将查找一个类,该类装饰有[Controller]属性或派生自装饰有该属性的类,例如,Microsoft 提供的名为ControllerBase的类,如下代码所示:

namespace Microsoft.AspNetCore.Mvc
{
  //
  // Summary:
  // A base class for an MVC controller without view support.
  [Controller]
  public abstract class ControllerBase
  {
... 

理解 ControllerBase 类

如 XML 注释所示,ControllerBase不支持视图。它用于创建 Web 服务,正如您将在第十六章构建和消费 Web 服务中所见。

ControllerBase拥有许多有用的属性,用于处理当前 HTTP 上下文,如下表所示:

属性 描述
Request 仅 HTTP 请求。例如,头部、查询字符串参数、请求主体作为可读取的流、内容类型和长度,以及 Cookie。
Response 仅 HTTP 响应。例如,头部、响应主体作为可写入的流、内容类型和长度、状态码和 Cookie。还有像OnStartingOnCompleted这样的委托,您可以将方法挂接到它们上。
HttpContext 当前 HTTP 上下文的所有信息,包括请求和响应、连接信息、服务器上通过中间件启用的功能集合,以及用于认证和授权的用户对象。

理解 Controller 类

Microsoft 提供了另一个名为Controller的类,如果您的类确实需要视图支持,它们可以从该类继承,如下代码所示:

namespace Microsoft.AspNetCore.Mvc
{
  //
  // Summary:
  // A base class for an MVC controller with view support.
  public abstract class Controller : ControllerBase,
    IActionFilter, IFilterMetadata, IAsyncActionFilter, IDisposable
  {
... 

Controller拥有许多有用的属性,用于处理视图,如下表所示:

属性 描述
ViewData 控制器可以在其中存储键/值对的字典,该字典在视图中可访问。该字典的生命周期仅限于当前请求/响应。
ViewBag 一个动态对象,它封装了ViewData,以提供更友好的语法来设置和获取字典值。
TempData 控制器可以在其中存储键/值对的字典,该字典在视图中可访问。该字典的生命周期为当前请求/响应以及同一访问者会话的下一个请求/响应。这对于在初始请求期间存储值、响应重定向并在后续请求中读取存储的值非常有用。

Controller 有许多与视图工作相关的有用方法,如下表所示:

属性 描述
视图 执行一个视图后返回ViewResult,该视图渲染完整的响应,例如,一个动态生成的网页。视图可以通过约定或指定字符串名称来选择。可以将模型传递给视图。
PartialView 执行视图后返回PartialViewResult,该视图是完整响应的一部分,例如,动态生成的 HTML 块。视图可以通过约定或指定字符串名称来选择。可以将模型传递给视图。
ViewComponent 执行组件后返回ViewComponentResult,该组件动态生成 HTML。组件必须通过指定其类型或名称来选择。可以传递一个对象作为参数。
Json 返回包含 JSON 序列化对象的JsonResult。这对于实现 MVC 控制器的一部分简单 Web API 非常有用,该控制器主要返回供人类查看的 HTML。

理解控制器的职责

控制器的职责如下:

  • 识别控制器需要在类构造函数中处于有效状态并正常运行的服务。

  • 使用动作名称来识别要执行的方法。

  • 从 HTTP 请求中提取参数。

  • 使用参数获取构建视图模型所需的任何额外数据,并将其传递给客户端的适当视图。例如,如果客户端是 Web 浏览器,则渲染 HTML 的视图最为合适。其他客户端可能更喜欢替代渲染方式,如 PDF 文件或 Excel 文件等文档格式,或 JSON 或 XML 等数据格式。

  • 将视图的结果作为具有适当状态码的 HTTP 响应返回给客户端。

让我们回顾用于生成主页、隐私和错误页面的控制器:

  1. 展开Controllers文件夹

  2. 打开名为HomeController.cs的文件

  3. 注意,如下列代码所示:

    • 导入了额外的命名空间,我已添加注释以显示它们所需的类型。

    • 声明一个私有只读字段,用于存储在构造函数中设置的HomeController的日志记录器引用。

    • 所有三个动作方法都调用名为View的方法,并将结果作为IActionResult接口返回给客户端。

    • Error动作方法将其视图模型与用于跟踪的请求 ID 一起传递到其视图中。错误响应将不会被缓存:

    using Microsoft.AspNetCore.Mvc; // Controller, IActionResult
    using Northwind.Mvc.Models; // ErrorViewModel
    using System.Diagnostics; // Activity
    namespace Northwind.Mvc.Controllers;
    public class HomeController : Controller
    {
      private readonly ILogger<HomeController> _logger;
      public HomeController(ILogger<HomeController> logger)
      {
        _logger = logger;
      }
      public IActionResult Index()
      {
        return View();
      }
      public IActionResult Privacy()
      {
        return View();
      }
      [ResponseCache(Duration = 0,
        Location = ResponseCacheLocation.None, NoStore = true)]
      public IActionResult Error()
      {
        return View(new ErrorViewModel { RequestId = 
          Activity.Current?.Id ?? HttpContext.TraceIdentifier });
      }
    } 
    

如果访问者导航到路径//Home,则相当于/Home/Index,因为这些是默认路由中控制器和动作的默认名称。

理解视图搜索路径约定

IndexPrivacy方法在实现上相同,但它们返回不同的网页。这是因为约定。对View方法的调用在不同的路径中查找 Razor 文件以生成网页。

让我们故意破坏一个页面名称,以便我们可以看到默认搜索的路径:

  1. Northwind.Mvc项目中,展开Views文件夹,然后展开Home文件夹。

  2. Privacy.cshtml文件重命名为Privacy2.cshtml

  3. 启动网站。

  4. 启动 Chrome,导航到https://localhost:5001/,点击隐私,并注意搜索视图以渲染网页的路径(包括 MVC 视图和 Razor 页面的Shared文件夹),如图 15.3所示:

    图 15.3:显示视图默认搜索路径的异常

  5. 关闭 Chrome 并关闭 Web 服务器。

  6. Privacy2.cshtml文件重命名为Privacy.cshtml

您现在已经看到了视图搜索路径约定,如下列列表所示:

  • 特定的 Razor 视图:/Views/{controller}/{action}.cshtml

  • 共享 Razor 视图:/Views/Shared/{action}.cshtml

  • 共享 Razor 页面:/Pages/Shared/{action}.cshtml

理解日志记录

您刚刚看到,一些错误被捕获并写入控制台。您可以使用记录器以相同的方式向控制台写入消息。

  1. Controllers文件夹中的HomeController.cs文件里,在Index方法中,添加语句以使用记录器向控制台写入不同级别的消息,如下列代码所示:

    _logger.LogError("This is a serious error (not really!)");
    _logger.LogWarning("This is your first warning!");
    _logger.LogWarning("Second warning!");
    _logger.LogInformation("I am in the Index method of the HomeController."); 
    
  2. 启动Northwind.Mvc网站项目。

  3. 启动 Web 浏览器并导航到网站的主页。

  4. 在命令提示符或终端中,注意消息,如下列输出所示:

    fail: Northwind.Mvc.Controllers.HomeController[0]
          This is a serious error (not really!)
    warn: Northwind.Mvc.Controllers.HomeController[0]
          This is your first warning!
    warn: Northwind.Mvc.Controllers.HomeController[0]
          Second warning!
    info: Northwind.Mvc.Controllers.HomeController[0]
          I am in the Index method of the HomeController. 
    
  5. 关闭 Chrome 并关闭 Web 服务器。

理解过滤器

当您需要向多个控制器和动作添加某些功能时,您可以使用或定义自己的过滤器,这些过滤器作为属性类实现。

过滤器可以应用于以下级别:

  • 通过在动作方法上装饰属性,在动作级别进行设置。这只会影响该动作方法。

  • 通过在控制器类上装饰属性,在控制器级别进行设置。这将影响控制器的所有方法。

  • 通过将属性类型添加到MvcOptions实例的Filters集合中,在全局级别进行设置,该实例可用于在调用AddControllersWithViews方法时配置 MVC,如下列代码所示:

    builder.Services.AddControllersWithViews(options =>
      {
        options.Filters.Add(typeof(MyCustomFilter));
      }); 
    

使用过滤器来保护动作方法

你可能希望确保控制器类中的某个特定动作方法只能由特定安全角色的成员调用。你可以通过在方法上装饰[Authorize]属性来实现这一点,如下列表所述:

  • [Authorize]:仅允许经过身份验证(非匿名,已登录)的访问者访问此动作方法。

  • [Authorize(Roles = "Sales,Marketing")]:仅允许指定角色中的访问者访问此动作方法。

让我们来看一个例子:

  1. HomeController.cs中,导入Microsoft.AspNetCore.Authorization命名空间。

  2. Privacy方法添加一个属性,仅允许名为Administrators的组/角色中的已登录用户访问,如以下高亮代码所示:

    **[****Authorize(Roles =** **"Administrators"****)****]**
    public IActionResult Privacy() 
    
  3. 启动网站。

  4. 点击隐私,注意你将被重定向到登录页面。

  5. 输入你的电子邮件和密码。

  6. 点击登录,注意你被拒绝访问。

  7. 关闭 Chrome 并关闭 Web 服务器。

启用角色管理和编程创建角色

默认情况下,角色管理在 ASP.NET Core MVC 项目中未启用,因此我们必须首先启用它,然后创建一个控制器,该控制器将编程创建一个Administrators角色(如果不存在)并将测试用户分配给该角色:

  1. Program.cs中,在 ASP.NET Core Identity 及其数据库的设置中,添加对AddRoles的调用以启用角色管理,如下高亮代码所示:

    services.AddDefaultIdentity<IdentityUser>(
      options => options.SignIn.RequireConfirmedAccount = true)
     **.AddRoles<IdentityRole>()** **// enable role management**
      .AddEntityFrameworkStores<ApplicationDbContext>(); 
    
  2. Controllers中,添加一个名为RolesController.cs的空控制器类并修改其内容,如下代码所示:

    using Microsoft.AspNetCore.Identity; // RoleManager, UserManager
    using Microsoft.AspNetCore.Mvc; // Controller, IActionResult
    using static System.Console;
    namespace Northwind.Mvc.Controllers;
    public class RolesController : Controller
    {
      private string AdminRole = "Administrators";
      private string UserEmail = "test@example.com";
      private readonly RoleManager<IdentityRole> roleManager;
      private readonly UserManager<IdentityUser> userManager;
      public RolesController(RoleManager<IdentityRole> roleManager,
        UserManager<IdentityUser> userManager)
      {
        this.roleManager = roleManager;
        this.userManager = userManager;
      }
      public async Task<IActionResult> Index()
      {
        if (!(await roleManager.RoleExistsAsync(AdminRole)))
        {
          await roleManager.CreateAsync(new IdentityRole(AdminRole));
        }
        IdentityUser user = await userManager.FindByEmailAsync(UserEmail);
        if (user == null)
        {
          user = new();
          user.UserName = UserEmail;
          user.Email = UserEmail;
          IdentityResult result = await userManager.CreateAsync(
            user, "Pa$$w0rd");
          if (result.Succeeded)
          {
            WriteLine($"User {user.UserName} created successfully.");
          }
          else
          { 
            foreach (IdentityError error in result.Errors)
            {
              WriteLine(error.Description);
            }
          }
        }
        if (!user.EmailConfirmed)
        {
          string token = await userManager
            .GenerateEmailConfirmationTokenAsync(user);
          IdentityResult result = await userManager
            .ConfirmEmailAsync(user, token);
          if (result.Succeeded)
          {
            WriteLine($"User {user.UserName} email confirmed successfully.");
          }
          else
          {
            foreach (IdentityError error in result.Errors)
            {
              WriteLine(error.Description);
            }
          }
        }
        if (!(await userManager.IsInRoleAsync(user, AdminRole)))
        {
          IdentityResult result = await userManager
            .AddToRoleAsync(user, AdminRole);
          if (result.Succeeded)
          {
            WriteLine($"User {user.UserName} added to {AdminRole} successfully.");
          }
          else
          {
            foreach (IdentityError error in result.Errors)
            {
              WriteLine(error.Description);
            }
          }
        }
        return Redirect("/");
      }
    } 
    

    注意以下事项:

    • 角色名称和用户电子邮件的两个字段。

    • 构造函数获取并存储已注册用户和角色管理依赖服务。

    • 如果Administrators角色不存在,我们使用角色管理器创建它。

    • 我们尝试通过其电子邮件查找测试用户,如果不存在则创建它,然后将用户分配给Administrators角色。

    • 由于网站使用 DOI,我们必须生成一个电子邮件确认令牌,并使用它来确认新用户的电子邮件地址。

    • 成功消息和任何错误都会输出到控制台。

    • 你将自动重定向到主页。

  3. 启动网站。

  4. 点击隐私,注意你将被重定向到登录页面。

  5. 输入你的电子邮件和密码。(我使用了mark@example.com。)

  6. 点击登录,注意你像之前一样被拒绝访问。

  7. 点击主页

  8. 在地址栏中,手动输入roles作为相对 URL 路径,如下链接所示:https://localhost:5001/roles

  9. 查看输出到控制台的成功消息,如下所示:

    User test@example.com created successfully.
    User test@example.com email confirmed successfully.
    User test@example.com added to Administrators successfully. 
    
  10. 点击注销,因为你必须注销并重新登录以加载角色成员资格,这些成员资格是在你已经登录后创建的。

  11. 再次尝试访问隐私页面,输入新用户程序化创建的电子邮件,例如test@example.com,以及他们的密码,然后点击登录,您现在应该可以访问了。

  12. 关闭 Chrome 并关闭 Web 服务器。

使用过滤器缓存响应

为了提高响应时间和可扩展性,您可能希望缓存由操作方法生成的 HTTP 响应,通过使用[ResponseCache]属性装饰该方法。

您通过设置参数来控制响应的缓存位置和时长,如下面的列表所示:

  • 时长:以秒为单位。这设置了以秒为单位的max-age HTTP 响应头。常见的选择是一个小时(3600 秒)和一天(86400 秒)。

  • 位置ResponseCacheLocation值之一,任何客户端。这设置了缓存控制HTTP 响应头。

  • NoStore:如果true,这将忽略时长位置,并将缓存控制 HTTP 响应头设置为no-store

让我们看一个例子:

  1. HomeController.cs中,向Index方法添加一个属性,以在浏览器或服务器和浏览器之间的任何代理上缓存响应 10 秒,如下面的代码中突出显示的那样:

    **[****ResponseCache(Duration = 10, Location = ResponseCacheLocation.Any)****]**
    public IActionResult Index() 
    
  2. 视图中,在主页中,打开Index.cshtml,并添加一个段落以长格式输出当前时间,包括秒,如下面的标记所示:

    <p class="alert alert-primary">@DateTime.Now.ToLongTimeString()</p> 
    
  3. 启动网站。

  4. 注意主页上的时间。

  5. 点击注册

  6. 点击主页并注意主页上的时间相同,因为使用了页面的缓存版本。

  7. 点击注册。至少等待十秒钟。

  8. 点击主页并注意时间现已更新。

  9. 点击登录,输入您的电子邮件和密码,然后点击登录

  10. 注意主页上的时间。

  11. 点击隐私

  12. 点击主页并注意页面未被缓存。

  13. 查看控制台并注意警告消息,该消息解释说您的缓存已被覆盖,因为访问者已登录,在这种情况下,ASP.NET Core 使用防伪令牌,它们不应被缓存,如下面的输出所示:

    warn: Microsoft.AspNetCore.Antiforgery.DefaultAntiforgery[8]
          The 'Cache-Control' and 'Pragma' headers have been overridden and set to 'no-cache, no-store' and 'no-cache' respectively to prevent caching of this response. Any response that uses antiforgery should not be cached. 
    
  14. 关闭 Chrome 并关闭 Web 服务器。

使用过滤器定义自定义路由

您可能希望为操作方法定义简化路由,而不是使用默认路由。

例如,要显示隐私页面,当前需要以下 URL 路径,该路径指定了控制器和操作:

https://localhost:5001/home/privacy 

我们可以使路由更简单,如下面的链接所示:

https://localhost:5001/private 

让我们看看如何做到这一点:

  1. HomeController.cs中,向隐私方法添加一个属性,以定义简化路由,如下面的代码中突出显示的那样:

    **[****Route(****"private"****)****]**
    [Authorize(Roles = "Administrators")]
    public IActionResult Privacy() 
    
  2. 启动网站。

  3. 在地址栏中,输入以下 URL 路径:

    https://localhost:5001/private 
    
  4. 输入您的电子邮件和密码,点击登录,并注意简化路径显示了隐私页面。

  5. 关闭 Chrome 并关闭 Web 服务器。

理解实体和视图模型

MVC 中的 M 代表模型。模型代表响应请求所需的数据。常用的模型类型有两种:实体模型和视图模型。

实体模型代表数据库中的实体,如 SQL Server 或 SQLite。根据请求,可能需要从数据存储中检索一个或多个实体。实体模型使用类定义,因为它们可能需要更改,然后用于更新底层数据存储。

我们想要在响应请求时展示的所有数据就是MVC 模型,有时称为视图模型,因为它是一个传递给视图以渲染成 HTML 或 JSON 等响应格式的模型。视图模型应该是不可变的,因此通常使用记录来定义。

例如,以下 HTTP GET请求可能意味着浏览器正在请求产品编号为 3 的产品详情页:

www.example.com/products/details/3

控制器需要使用 ID 路由值 3 来检索该产品的实体,并将其传递给一个视图,该视图随后将模型转换为 HTML,以便在浏览器中显示。

设想当用户访问我们的网站时,我们希望向他们展示一个类别轮播、产品列表以及本月我们接待的访问者数量计数。

我们将引用您在第十三章介绍 C#和.NET 的实际应用中创建的 Northwind 数据库的 Entity Framework Core 实体数据模型:

  1. Northwind.Mvc项目中,添加对Northwind.Common.DataContext的项目引用,无论是 SQLite 还是 SQL Server,如下列标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include=
    "..\Northwind.Common.DataContext.Sqlite\Northwind.Common.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    
  2. 构建Northwind.Mvc项目以编译其依赖项。

  3. 如果您正在使用 SQL Server,或者可能想要在 SQL Server 和 SQLite 之间切换,那么在appsettings.json中,添加一个使用 SQL Server 的 Northwind 数据库的连接字符串,如下列标记中突出显示的那样:

    {
      "ConnectionStrings": {
        "DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=aspnet-Northwind.Mvc-DC9C4FAF-DD84-4FC9-B925-69A61240EDA7;Trusted_Connection=True;MultipleActiveResultSets=true",
    **"NorthwindConnection"****:** **"Server=.;Database=Northwind;Trusted_Connection=True;MultipleActiveResultSets=true"**
      }, 
    
  4. Program.cs中,导入用于处理实体模型类型的命名空间,如下列代码所示:

    using Packt.Shared; // AddNorthwindContext extension method 
    
  5. builder.Build方法调用之前,添加语句以加载适当的连接字符串,然后注册Northwind数据库上下文,如下列代码所示:

    // if you are using SQL Server
    string sqlServerConnection = builder.Configuration
      .GetConnectionString("NorthwindConnection");
    builder.Services.AddNorthwindContext(sqlServerConnection);
    // if you are using SQLite default is ..\Northwind.db
    builder.Services.AddNorthwindContext(); 
    
  6. Models文件夹添加一个类文件,并将其命名为HomeIndexViewModel.cs

    最佳实践:尽管 MVC 项目模板创建的ErrorViewModel类并未遵循此约定,但我建议您为视图模型类采用命名约定{Controller}{Action}ViewModel

  7. 修改语句以定义一个记录,该记录具有三个属性,分别用于访问者数量计数以及类别和产品列表,如下列代码所示:

    using Packt.Shared; // Category, Product
    namespace Northwind.Mvc.Models;
    public record HomeIndexViewModel
    (
      int VisitorCount,
      IList<Category> Categories,
      IList<Product> Products
    ); 
    
  8. HomeController.cs中,导入Packt.Shared命名空间,如下列代码所示:

    using Packt.Shared; // NorthwindContext 
    
  9. 添加一个字段以存储对Northwind实例的引用,并在构造函数中初始化它,如下列代码中突出显示的那样:

    public class HomeController : Controller
    {
      private readonly ILogger<HomeController> _logger;
    **private****readonly** **NorthwindContext db;**
      public HomeController(ILogger<HomeController> logger,
     **NorthwindContext injectedContext****)**
      {
        _logger = logger;
     **db = injectedContext;**
      }
    ... 
    

    ASP.NET Core 将使用构造函数参数注入来传递在Program.cs中指定的连接字符串的NorthwindContext数据库上下文实例。

  10. 修改Index操作方法中的语句,以创建此方法的视图模型实例,使用Random类模拟访客计数,生成 1 到 1000 之间的数字,并使用Northwind数据库获取类别和产品列表,然后将模型传递给视图,如下面的代码中突出显示所示:

    [ResponseCache(Duration = 10, Location = ResponseCacheLocation.Any)]
    public IActionResult Index()
    {
      _logger.LogError("This is a serious error (not really!)");
      _logger.LogWarning("This is your first warning!");
      _logger.LogWarning("Second warning!");
      _logger.LogInformation("I am in the Index method of the HomeController.");
     **HomeIndexViewModel model =** **new**
     **(**
     **VisitorCount: (****new** **Random()).Next(****1****,** **1001****),**
     **Categories: db.Categories.ToList(),**
     **Products: db.Products.ToList()**
     **);**
    **return** **View(model);** **// pass model to view**
    } 
    

记住视图搜索约定:当在控制器的操作方法中调用View方法时,ASP.NET Core MVC 会在Views文件夹中查找与当前控制器同名的子文件夹,即Home。然后查找与当前操作同名的文件,即Index.cshtml。它还会在Shared文件夹中搜索与操作方法名匹配的视图,以及在Pages文件夹中搜索 Razor 页面。

理解视图

MVC 中的 V 代表视图。视图的责任是将模型转换为 HTML 或其他格式。

有多种视图引擎可用于此目的。默认视图引擎称为Razor,它使用@符号指示服务器端代码执行。随 ASP.NET Core 2.0 引入的 Razor Pages 功能使用相同的视图引擎,因此可以使用相同的 Razor 语法。

让我们修改主页视图以渲染类别和产品列表:

  1. 展开Views文件夹,然后展开Home文件夹。

  2. 打开Index.cshtml文件,并注意包裹在@{ }中的 C#代码块。这会首先执行,并可用于存储需要传递到共享布局文件的数据,例如网页标题,如下面的代码所示:

    @{
      ViewData["Title"] = "Home Page";
    } 
    
  3. 注意使用 Bootstrap 进行样式化的<div>元素中的静态 HTML 内容。

    良好实践:除了定义自己的样式外,还应基于实现响应式设计的通用库(如 Bootstrap)来构建样式。

    与 Razor 页面一样,有一个名为_ViewStart.cshtml的文件,由View方法执行。它用于设置适用于所有视图的默认值。

    例如,它将所有视图的Layout属性设置为共享布局文件,如下面的标记所示:

    @{
      Layout = "_Layout";
    } 
    
  4. Views文件夹中,打开_ViewImports.cshtml文件,并注意它导入了一些命名空间,然后添加了 ASP.NET Core 标签助手,如下面的代码所示:

    @using Northwind.Mvc 
    @using Northwind.Mvc.Models
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 
    
  5. Shared文件夹中,打开_Layout.cshtml文件。

  6. 注意标题是从ViewData字典中读取的,该字典是在Index.cshtml视图中较早设置的,如下面的标记所示:

    <title>@ViewData["Title"] – Northwind.Mvc</title> 
    
  7. 注意支持 Bootstrap 和站点样式表的链接渲染,其中~表示wwwroot文件夹,如下面的标记所示:

    <link rel="stylesheet" 
      href="~/lib/bootstrap/dist/css/bootstrap.css" />
    <link rel="stylesheet" href="~/css/site.css" /> 
    
  8. 注意头部导航栏的渲染,如下面的标记所示:

    <body>
      <header>
        <nav class="navbar ..."> 
    
  9. 注意渲染一个可折叠的<div>,其中包含用于登录的部分视图和超链接,允许用户使用带有asp-controllerasp-action等属性的 ASP.NET Core 标签助手在页面间导航,如下面的标记所示:

    <div class=
      "navbar-collapse collapse d-sm-inline-flex justify-content-between">
      <ul class="navbar-nav flex-grow-1">
        <li class="nav-item">
          <a class="nav-link text-dark" asp-area=""
            asp-controller="Home" asp-action="Index">Home</a>
        </li>
        <li class="nav-item">
          <a class="nav-link text-dark"
            asp-area="" asp-controller="Home" 
            asp-action="Privacy">Privacy</a>
        </li>
      </ul>
      <partial name="_LoginPartial" />
    </div> 
    

    <a>元素使用名为asp-controllerasp-action的标签助手属性来指定链接被点击时将执行的控制器名称和动作名称。如果你想导航到一个 Razor 类库中的功能,比如你在前一章创建的employees组件,那么你可以使用asp-area来指定功能名称。

  10. 注意<main>元素内主体的渲染,如下面的标记所示:

    <div class="container">
      <main role="main" class="pb-3">
        @RenderBody()
      </main>
    </div> 
    

    RenderBody方法注入特定 Razor 视图的内容,例如在共享布局中该点的Index.cshtml文件。

  11. 注意在页面底部渲染<script>元素,这样不会减慢页面显示速度,并且你可以在一个可选定义的名为scripts的部分中添加自己的脚本块,如下面的标记所示:

    <script src="img/jquery.min.js"></script>
    <script src="img/bootstrap.bundle.min.js">
    </script>
    <script src="img/site.js" asp-append-version="true"></script> 
    @await RenderSectionAsync("scripts", required: false) 
    

当在任何元素(如<img><script>)中与src属性一起指定asp-append-version并设置为true时,将调用 Image Tag Helper(此助手的名称不佳,因为它不仅影响图像!)。

它的工作原理是自动附加一个名为v的查询字符串值,该值是从引用的源文件的哈希生成的,如下面的示例生成输出所示:

<script src="img/site.js? v=Kl_dqr9NVtnMdsM2MUg4qthUnWZm5T1fCEimBPWDNgM"></script> 

如果site.js文件中的任何一个字节发生变化,其哈希值就会不同,因此如果浏览器或 CDN 正在缓存该脚本文件,则会清除缓存的副本并替换为新版本。

定制 ASP.NET Core MVC 网站

现在你已经审查了一个基本 MVC 网站的结构,你将对其进行定制和扩展。你已经为Northwind数据库注册了一个 EF Core 模型,接下来的任务是在首页输出一些该数据。

定义自定义样式

首页将展示 Northwind 数据库中的 77 种产品列表。为了高效利用空间,我们希望以三列形式显示该列表。为此,我们需要为网站定制样式表:

  1. wwwroot\css文件夹中,打开site.css文件。

  2. 在文件底部,添加一个新的样式,该样式将应用于具有product-columns ID 的元素,如下面的代码所示:

    #product-columns
    {
      column-count: 3;
    } 
    

设置类别图像

Northwind 数据库包含一个有八个类别的表,但它们没有图像,而网站配上一些色彩丰富的图片会更好看:

  1. wwwroot文件夹中,创建一个名为images的文件夹。

  2. images文件夹中,添加八个名为category1.jpegcategory2.jpeg,以此类推,直到category8.jpeg的图像文件。

您可以从本书 GitHub 仓库的以下链接下载图片:github.com/markjprice/cs10dotnet6/tree/master/Assets/Categories

理解 Razor 语法

在我们自定义主页视图之前,让我们回顾一个具有初始 Razor 代码块的示例 Razor 文件,该代码块实例化了一个具有价格和数量的订单,然后在网页上输出订单信息,如下面的标记所示:

@{
  Order order = new()
  {
    OrderId = 123,
    Product = "Sushi",
    Price = 8.49M,
    Quantity = 3
  };
}
<div>Your order for @order.Quantity of @order.Product has a total cost of $@ order.Price * @order.Quantity</div> 

前面的 Razor 文件将产生以下错误的输出:

Your order for 3 of Sushi has a total cost of $8.49 * 3 

尽管 Razor 标记可以使用@object.property语法包含任何单一属性的值,但您应该用括号将表达式括起来,如下面的标记所示:

<div>Your order for @order.Quantity of @order.Product has a total cost of $@ (order.Price * order.Quantity)</div> 

前面的 Razor 表达式将产生以下正确的输出:

Your order for 3 of Sushi has a total cost of $25.47 

定义类型化视图

为了提高编写视图时的 IntelliSense,您可以使用顶部的@model指令定义视图可以预期的类型:

  1. Views\Home文件夹中,打开Index.cshtml

  2. 在文件顶部,添加一个语句,将模型类型设置为使用HomeIndexViewModel,如下面的代码所示:

    @model HomeIndexViewModel 
    

    现在,每当我们在本视图中键入Model时,您的代码编辑器将知道模型的正确类型,并为其提供 IntelliSense。

    在视图中输入代码时,请记住以下事项:

    • 声明模型的类型,使用@model(小写 m)。

    • 与模型实例交互,使用@Model(大写 M)。

    让我们继续自定义主页视图。

  3. 在初始的 Razor 代码块中,添加一个声明当前项的string变量的语句,并在现有的<div>元素下添加新的标记,以轮播形式输出类别,并以无序列表形式输出产品,如下面的标记所示:

    @using Packt.Shared
    @model HomeIndexViewModel 
    @{
      ViewData["Title"] = "Home Page";
      string currentItem = "";
    }
    <div class="text-center">
      <h1 class="display-4">Welcome</h1>
      <p>Learn about <a href="https://docs.microsoft.com/aspnet/core">building Web apps with ASP.NET Core</a>.</p>
      <p class="alert alert-primary">@DateTime.Now.ToLongTimeString()</p>
    </div>
    @if (Model is not null)
    {
    <div id="categories" class="carousel slide" data-ride="carousel" 
         data-interval="3000" data-keyboard="true">
      <ol class="carousel-indicators">
      @for (int c = 0; c < Model.Categories.Count; c++)
      {
        if (c == 0)
        {
          currentItem = "active";
        }
        else
        {
          currentItem = "";
        }
        <li data-target="#categories" data-slide-to="@c"  
            class="@currentItem"></li>
      }
      </ol>
      <div class="carousel-inner">
      @for (int c = 0; c < Model.Categories.Count; c++)
      {
        if (c == 0)
        {
          currentItem = "active";
        }
        else
        {
          currentItem = "";
        }
        <div class="carousel-item @currentItem">
          <img class="d-block w-100" src=   
            "~/images/category@(Model.Categories[c].CategoryId).jpeg"  
            alt="@Model.Categories[c].CategoryName" />
          <div class="carousel-caption d-none d-md-block">
            <h2>@Model.Categories[c].CategoryName</h2>
            <h3>@Model.Categories[c].Description</h3>
            <p>
              <a class="btn btn-primary"  
                href="/category/@Model.Categories[c].CategoryId">View</a>
            </p>
          </div>
        </div>
      }
      </div>
      <a class="carousel-control-prev" href="#categories" 
        role="button" data-slide="prev">
        <span class="carousel-control-prev-icon" 
          aria-hidden="true"></span>
        <span class="sr-only">Previous</span>
      </a>
      <a class="carousel-control-next" href="#categories" 
        role="button" data-slide="next">
        <span class="carousel-control-next-icon" aria-hidden="true"></span>
        <span class="sr-only">Next</span>
      </a>
    </div>
    }
    <div class="row">
      <div class="col-md-12">
        <h1>Northwind</h1>
        <p class="lead">
          We have had @Model?.VisitorCount visitors this month.
        </p>
        @if (Model is not null)
        {
        <h2>Products</h2>
        <div id="product-columns">
          <ul>
          @foreach (Product p in @Model.Products)
          {
            <li>
              <a asp-controller="Home"
                 asp-action="ProductDetail"
                 asp-route-id="@p.ProductId">
                @p.ProductName costs 
    @(p.UnitPrice is null ? "zero" : p.UnitPrice.Value.ToString("C"))
              </a>
            </li>
          }
          </ul>
        </div>
        }
      </div>
    </div> 
    

在审查前面的 Razor 标记时,请注意以下几点:

  • 很容易将静态 HTML 元素(如<ul><li>)与 C#代码混合,以输出类别轮播和产品名称列表。

  • 具有id属性为product-columns<div>元素将使用我们之前定义的自定义样式,因此该元素中的所有内容将以三列显示。

  • 每个类别的<img>元素使用括号包围 Razor 表达式,以确保编译器不会将.jpeg作为表达式的一部分,如下面的标记所示:"~/images/category@(Model.Categories[c].CategoryID).jpeg"

  • 产品链接的<a>元素使用标签助手生成 URL 路径。点击这些超链接将由HomeController及其ProductDetail动作方法处理。此动作方法目前尚不存在,但您将在本章稍后添加。产品 ID 作为名为id的路由段传递,如下面的 Ipoh Coffee 的 URL 路径所示:https://localhost:5001/Home/ProductDetail/43

审查自定义主页

让我们看看自定义首页的结果:

  1. 启动Northwind.Mvc网站项目。

  2. 注意首页有一个旋转的轮播显示类别,随机数量的访客,以及三列中的产品列表,如图 15.4所示:

    图 15.4:更新后的 Northwind MVC 网站首页

    目前,点击任何类别或产品链接都会给出404 Not Found错误,因此让我们看看如何实现使用传递的参数来查看产品或类别详细信息的页面。

  3. 关闭 Chrome 并关闭 Web 服务器。

使用路由值传递参数

传递简单参数的一种方法是使用默认路由中定义的id段:

  1. HomeController类中,添加一个名为ProductDetail的操作方法,如下面的代码所示:

    public IActionResult ProductDetail(int? id)
    {
      if (!id.HasValue)
      {
        return BadRequest("You must pass a product ID in the route, for example, /Home/ProductDetail/21");
      }
      Product? model = db.Products
        .SingleOrDefault(p => p.ProductId == id);
      if (model == null)
      {
        return NotFound($"ProductId {id} not found.");
      }
      return View(model); // pass model to view and then return result
    } 
    

    注意以下事项:

    • 此方法利用 ASP.NET Core 的一个特性,称为模型绑定,自动将路由中传递的id与方法中名为id的参数匹配。

    • 在方法内部,我们检查id是否没有值,如果是,我们调用BadRequest方法返回400状态码和一条自定义消息,解释正确的 URL 路径格式。

    • 否则,我们可以连接到数据库并尝试使用id值检索产品。

    • 如果我们找到产品,我们将其传递给视图;否则,我们调用NotFound方法返回404状态码和一条自定义消息,解释数据库中未找到该 ID 的产品。

  2. Views/Home文件夹中,添加一个名为ProductDetail.cshtml的新文件。

  3. 修改内容,如下面的标记所示:

    @model Packt.Shared.Product 
    @{
      ViewData["Title"] = "Product Detail - " + Model.ProductName;
    }
    <h2>Product Detail</h2>
    <hr />
    <div>
      <dl class="dl-horizontal">
        <dt>Product Id</dt>
        <dd>@Model.ProductId</dd>
        <dt>Product Name</dt>
        <dd>@Model.ProductName</dd>
        <dt>Category Id</dt>
        <dd>@Model.CategoryId</dd>
        <dt>Unit Price</dt>
        <dd>@Model.UnitPrice.Value.ToString("C")</dd>
        <dt>Units In Stock</dt>
        <dd>@Model.UnitsInStock</dd>
      </dl>
    </div> 
    
  4. 启动Northwind.Mvc项目。

  5. 当首页显示产品列表时,点击其中一个,例如,第二个产品,

  6. 注意浏览器地址栏中的 URL 路径,浏览器标签中显示的页面标题,以及产品详情页,如图 15.5所示:

    图 15.5:张的产品详情页

  7. 查看开发者工具

  8. 在 Chrome 的地址栏中编辑 URL,请求一个不存在的产品 ID,例如 99,并注意 404 Not Found 状态码和自定义错误响应。

更详细地理解模型绑定器

模型绑定器功能强大,默认的绑定器为您做了很多工作。默认路由确定要实例化的控制器类和要调用的操作方法后,如果该方法有参数,则这些参数需要设置值。

模型绑定器通过查找 HTTP 请求中传递的参数值来实现这一点,这些参数值可以是以下任何类型的参数:

  • 路由参数,如我们在上一节中使用的id,如以下 URL 路径所示:/Home/ProductDetail/2

  • 查询字符串参数,如下面的 URL 路径所示:/Home/ProductDetail?id=2

  • 表单参数,如下面的标记所示:

    <form action="post" action="/Home/ProductDetail">
      <input type="text" name="id" value="2" />
      <input type="submit" />
    </form> 
    

模型绑定器可以填充几乎任何类型:

  • 简单类型,如intstringDateTimebool

  • classrecordstruct定义的复杂类型。

  • 集合类型,如数组和列表。

让我们创建一个略显人为的示例,以说明使用默认模型绑定器可以实现什么:

  1. Models文件夹中,添加一个名为Thing.cs的新文件。

  2. 修改内容以定义一个具有两个属性的类,一个名为Id的可空整数和一个名为Color的字符串,如下面的代码所示:

    namespace Northwind.Mvc.Models;
    public class Thing
    {
      public int? Id { get; set; }
      public string? Color { get; set; }
    } 
    
  3. HomeController中,添加两个新的动作方法,一个用于显示带有表单的页面,另一个用于使用你的新模型类型显示带有参数的事物,如下面的代码所示:

    public IActionResult ModelBinding()
    {
      return View(); // the page with a form to submit
    }
    public IActionResult ModelBinding(Thing thing)
    {
      return View(thing); // show the model bound thing
    } 
    
  4. Views\Home文件夹中,添加一个名为ModelBinding.cshtml的新文件。

  5. 修改其内容,如下面的标记所示:

    @model Thing 
    @{
      ViewData["Title"] = "Model Binding Demo";
    }
    <h1>@ViewData["Title"]</h1>
    <div>
      Enter values for your thing in the following form:
    </div>
    <form method="POST" action="/home/modelbinding?id=3">
      <input name="color" value="Red" />
      <input type="submit" />
    </form>
    @if (Model != null)
    {
    <h2>Submitted Thing</h2>
    <hr />
    <div>
      <dl class="dl-horizontal">
        <dt>Model.Id</dt>
        <dd>@Model.Id</dd>
        <dt>Model.Color</dt>
        <dd>@Model.Color</dd>
      </dl>
    </div>
    } 
    
  6. Views/Home中,打开Index.cshtml,并在第一个<div>中,添加一个指向模型绑定页面的新段落链接,如下面的标记所示:

    <p><a asp-action="ModelBinding" asp-controller="Home">Binding</a></p> 
    
  7. 启动网站。

  8. 在首页上,点击绑定

  9. 注意图 15.6中所示的关于模糊匹配的未处理异常:

    图 15.6:未处理的模糊动作方法匹配异常

  10. 关闭 Chrome 并关闭 Web 服务器。

消除动作方法的歧义

尽管 C#编译器可以通过注意到签名不同来区分这两种方法,但从 HTTP 请求的路由角度来看,这两种方法都是潜在的匹配。我们需要一种 HTTP 特定的方法来消除动作方法的歧义。

我们可以通过为动作创建不同的名称或指定一个方法应该用于特定的 HTTP 动词,如GETPOSTDELETE来做到这一点。这就是我们将解决问题的方式:

  1. HomeController中,装饰第二个ModelBinding动作方法,以指示它应该用于处理 HTTP POST请求,即当表单提交时,如下面的代码中突出显示的那样:

    **[****HttpPost****]**
    public IActionResult ModelBinding(Thing thing) 
    

    另一个ModelBinding动作方法将隐式用于所有其他类型的 HTTP 请求,如GETPUTDELETE等。

  2. 启动网站。

  3. 在首页上,点击绑定

  4. 点击提交按钮,并注意Id属性的值是从查询字符串参数设置的,而颜色属性的值是从表单参数设置的,如图 15.7所示:

    图 15.7:模型绑定演示页面

  5. 关闭 Chrome 并关闭 Web 服务器。

传递路由参数

现在我们将使用路由参数设置属性:

  1. 修改表单的动作,以传递值2作为路由参数,如下面的标记中突出显示的那样:

    <form method="POST" action="/home/modelbinding**/2**?id=3"> 
    
  2. 启动网站。

  3. 在首页上,点击绑定

  4. 点击提交按钮,并注意Id属性的值是从路由参数设置的,而Color属性的值是从表单参数设置的。

  5. 关闭 Chrome 并关闭 Web 服务器。

传递表单参数

现在我们将使用表单参数设置属性:

  1. 修改表单的操作,将值 1 作为表单参数传递,如下面的标记中突出显示的那样:

    <form method="POST" action="/home/modelbinding/2?id=3">
     **<input name=****"id"****value****=****"1"** **/>**
      <input name="color" value="Red" />
      <input type="submit" />
    </form> 
    
  2. 启动网站。

  3. 在主页上,点击绑定

  4. 点击提交按钮,并注意IdColor属性的值都是从表单参数设置的。

最佳实践:如果你有多个同名参数,请记住表单参数的优先级最高,而查询字符串参数的优先级最低,用于自动模型绑定。

验证模型

模型绑定过程可能会导致错误,例如,如果模型被装饰了验证规则,可能会发生数据类型转换或验证错误。已绑定的数据以及任何绑定或验证错误都存储在ControllerBase.ModelState中。

让我们通过应用一些验证规则到绑定的模型上,然后在视图中显示无效数据消息,来探索我们能用模型状态做什么:

  1. Models文件夹中,打开Thing.cs

  2. 导入System.ComponentModel.DataAnnotations命名空间。

  3. 用验证属性装饰Id属性,以限制允许的数字范围为 1 到 10,并确保访问者提供颜色,并添加一个新的Email属性,使用正则表达式进行验证,如下面的代码中突出显示的那样:

    public class Thing
    {
     **[****Range(1, 10)****]**
      public int? Id { get; set; }
     **[****Required****]**
      public string? Color { get; set; }
     **[****EmailAddress****]**
    **public****string****? Email {** **get****;** **set****; }**
    } 
    
  4. Models文件夹中,添加一个名为HomeModelBindingViewModel.cs的新文件。

  5. 修改其内容以定义一个记录,该记录具有存储绑定模型的属性、指示存在错误的标志以及错误消息序列,如下面的代码所示:

    namespace Northwind.Mvc.Models;
    public record HomeModelBindingViewModel
    (
      Thing Thing,
      bool HasErrors, 
      IEnumerable<string> ValidationErrors
    ); 
    
  6. HomeController中,在处理 HTTP POSTModelBinding方法中,注释掉之前将事物传递给视图的语句,而是添加语句来创建视图模型的实例。验证模型并存储错误消息数组,然后将视图模型传递给视图,如下面的代码中突出显示的那样:

    [HttpPost]
    public IActionResult ModelBinding(Thing thing)
    {
     **HomeModelBindingViewModel model =** **new****(**
     **thing,**
     **!ModelState.IsValid,** 
     **ModelState.Values**
     **.SelectMany(state => state.Errors)**
     **.Select(error => error.ErrorMessage)**
     **);**
    **return** **View(model);**
    } 
    
  7. Views\Home中,打开ModelBinding.cshtml

  8. 修改模型类型声明以使用视图模型类,如下面的标记所示:

    @model Northwind.Mvc.Models.HomeModelBindingViewModel 
    
  9. 添加一个<div>来显示任何模型验证错误,并更改事物的属性输出,因为视图模型已更改,如下面的标记中突出显示的那样:

    <form method="POST" action="/home/modelbinding/2?id=3">
      <input name="id" value="1" />
      <input name="color" value="Red" />
      <input name="email" value="test@example.com" />
      <input type="submit" />
    </form>
    @if (Model != null)
    {
      <h2>Submitted Thing</h2>
      <hr />
      <div>
        <dl class="dl-horizontal">
          <dt>Model**.Thing**.Id</dt>	
          <dd>@Model**.Thing**.Id</dd>	
          <dt>Model**.Thing**.Color</dt>
          <dd>@Model**.Thing**.Color</dd>
    **<****dt****>****Model.Thing.Email****</****dt****>**
    **<****dd****>****@Model.Thing.Email****</****dd****>**
        </dl>
      </div>
      @if (Model.HasErrors)
      {
        <div>
          @foreach(string errorMessage in Model.ValidationErrors)
          {
            <div class="alert alert-danger" role="alert">@errorMessage</div>
          }
        </div>
      }
    } 
    
  10. 启动网站。

  11. 在主页上,点击绑定

  12. 点击提交按钮,并注意1红色test@example.com是有效值。

  13. 输入一个Id13,清空颜色文本框,删除电子邮件地址中的@,点击提交按钮,并注意错误消息,如图15.8所示:

    图 15.8:带有字段验证的模型绑定演示页面

  14. 关闭 Chrome 并关闭 Web 服务器。

最佳实践:微软在实现 EmailAddress 验证属性时使用了哪种正则表达式?请在以下链接中查找答案:github.com/microsoft/referencesource/blob/5697c29004a34d80acdaf5742d7e699022c64ecd/System.ComponentModel.DataAnnotations/DataAnnotations/EmailAddressAttribute.cs#L54

理解视图助手方法

在为 ASP.NET Core MVC 创建视图时,你可以使用 Html 对象及其方法生成标记。

以下是一些有用的方法:

  • ActionLink:使用此方法生成包含指向指定控制器和动作的 URL 路径的 <a> 锚点元素。例如,Html.ActionLink(linkText: "绑定", actionName: "模型绑定", controllerName: "主页") 将生成 <a href="/主页/模型绑定">绑定</a>。你也可以使用锚点标签助手实现相同效果:<a asp-action="模型绑定" asp-controller="主页">绑定</a>

  • AntiForgeryToken:在 <form> 内部使用此方法插入包含防伪令牌的 <hidden> 元素,该令牌将在表单提交时进行验证。

  • DisplayDisplayFor:使用此方法根据当前模型使用显示模板为相关表达式生成 HTML 标记。对于 .NET 类型,有内置的显示模板,也可以在 DisplayTemplates 文件夹中创建自定义模板。在区分大小写的文件系统上,文件夹名称是区分大小写的。

  • DisplayForModel:使用此方法为整个模型生成 HTML 标记,而非单个表达式。

  • EditorEditorFor:使用此方法根据当前模型使用编辑模板为相关表达式生成 HTML 标记。对于 .NET 类型,有使用 <label><input> 元素的内置编辑模板,也可以在 EditorTemplates 文件夹中创建自定义模板。在区分大小写的文件系统上,文件夹名称是区分大小写的。

  • EditorForModel:使用此方法为整个模型生成 HTML 标记,而非单个表达式。

  • Encode:使用此方法将对象或字符串安全地编码为 HTML。例如,字符串值 "<script>" 将被编码为 "&lt;script&gt;"。通常不需要这样做,因为 Razor 的 @ 符号默认对字符串值进行编码。

  • Raw:使用此方法渲染字符串值,进行 HTML 编码。

  • PartialAsyncRenderPartialAsync:使用这些方法为部分视图生成 HTML 标记。你可以选择性地传递模型和视图数据。

让我们看一个例子:

  1. Views/Home 中,打开 ModelBinding.cshtml

  2. 修改 Email 属性的渲染方式,使用 DisplayFor,如下所示:

    <dd>@Html.DisplayFor(model => model.Thing.Email)</dd> 
    
  3. 启动网站。

  4. 点击 绑定

  5. 点击 提交

  6. 注意电子邮件地址是一个可点击的超链接,而不仅仅是文本。

  7. 关闭 Chrome 并关闭 Web 服务器。

  8. Models/Thing.cs中,在Email属性上方注释掉[EmailAddress]属性。

  9. 启动网站。

  10. 点击绑定

  11. 点击提交

  12. 注意,电子邮件地址只是文本。

  13. 关闭 Chrome 并关闭网络服务器。

  14. Models/Thing.cs中,取消注释[EmailAddress]属性。

正是通过在Email属性上使用[EmailAddress]验证属性进行装饰,并使用DisplayFor呈现它,通知 ASP.NET Core 将该值视为电子邮件地址,从而将其渲染为可点击的链接。

查询数据库并使用显示模板

我们来创建一个新的动作方法,它可以接收查询字符串参数,并利用该参数查询 Northwind 数据库中价格高于指定值的产品。

在前面的示例中,我们定义了一个视图模型,其中包含视图中需要呈现的每个值的属性。在这个例子中,将有两个值:一个产品列表和访客输入的价格。为了避免必须为视图模型定义一个类或记录,我们将产品列表作为模型传递,并将最高价格存储在ViewData集合中。

我们来实现这个功能:

  1. HomeController中,导入Microsoft.EntityFrameworkCore命名空间。我们需要这个来添加Include扩展方法,以便我们可以包含相关实体,正如你在第十章使用 Entity Framework Core 处理数据中所学。

  2. 添加一个新的动作方法,如下所示:

    public IActionResult ProductsThatCostMoreThan(decimal? price)
    {
      if (!price.HasValue)
      {
        return BadRequest("You must pass a product price in the query string, for example, /Home/ProductsThatCostMoreThan?price=50");
      }
      IEnumerable<Product> model = db.Products
        .Include(p => p.Category)
        .Include(p => p.Supplier)
        .Where(p => p.UnitPrice > price);
      if (!model.Any())
      {
        return NotFound(
          $"No products cost more than {price:C}.");
      }
      ViewData["MaxPrice"] = price.Value.ToString("C");
      return View(model); // pass model to view
    } 
    
  3. Views/Home文件夹中,添加一个名为ProductsThatCostMoreThan.cshtml的新文件。

  4. 修改内容,如下所示:

    @using Packt.Shared
    @model IEnumerable<Product> 
    @{
      string title =
        "Products That Cost More Than " + ViewData["MaxPrice"]; 
      ViewData["Title"] = title;
    }
    <h2>@title</h2>
    @if (Model is null)
    {
      <div>No products found.</div>
    }
    else
    {
      <table class="table">
        <thead>
          <tr>
            <th>Category Name</th>
            <th>Supplier's Company Name</th>
            <th>Product Name</th>
            <th>Unit Price</th>
            <th>Units In Stock</th>
          </tr>
        </thead>
        <tbody>
        @foreach (Product p in Model)
        {
          <tr>
            <td>
              @Html.DisplayFor(modelItem => p.Category.CategoryName)
            </td>
            <td>
              @Html.DisplayFor(modelItem => p.Supplier.CompanyName)
            </td>
            <td>
              @Html.DisplayFor(modelItem => p.ProductName)
            </td>
            <td>
              @Html.DisplayFor(modelItem => p.UnitPrice)
            </td>
            <td>
              @Html.DisplayFor(modelItem => p.UnitsInStock)
            </td>
          </tr>
        }
        <tbody>
      </table>
    } 
    
  5. Views/Home文件夹中,打开Index.cshtml

  6. 在访客计数下方、产品标题及其产品列表上方添加以下表单元素。这将提供一个供用户输入价格的表单。用户点击提交后,将调用动作方法,显示价格高于输入值的产品:

    <h3>Query products by price</h3>
    <form asp-action="ProductsThatCostMoreThan" method="GET">
      <input name="price" placeholder="Enter a product price" />
      <input type="submit" />
    </form> 
    
  7. 启动网站。

  8. 在主页上,在表单中输入一个价格,例如50,然后点击提交

  9. 注意你输入的价格高于该价格的产品表,如图 15.9 所示:

    图 15.9:价格超过£50 的产品筛选列表

  10. 关闭 Chrome 并关闭网络服务器。

使用异步任务提高可扩展性

在构建桌面或移动应用时,可以使用多个任务(及其底层线程)来提高响应性,因为当一个线程忙于任务时,另一个线程可以处理与用户的交互。

任务及其线程在服务器端也非常有用,尤其是对于处理文件或从商店或可能需要一段时间响应的网络服务请求数据的网站。但对于 CPU 密集型的复杂计算,它们是有害的,因此应将这些计算同步处理,如同常规操作。

当 HTTP 请求到达 Web 服务器时,会从其池中分配一个线程来处理该请求。但如果该线程必须等待资源,则它被阻止处理任何更多的传入请求。如果网站收到的并发请求数量超过了其线程池中的线程数量,那么其中一些请求将以服务器超时错误503 服务不可用响应。

被锁定的线程并没有做有用的工作。它们本可以处理其他请求之一,但前提是我们需要在网站中实现异步代码。

每当线程等待它需要的资源时,它可以返回到线程池并处理不同的传入请求,从而提高网站的可扩展性,即增加它可以处理的同时请求的数量。

为什么不直接拥有一个更大的线程池?在现代操作系统中,池中的每个线程都有一个 1 MB 的堆栈。异步方法使用的内存较少。它还消除了在池中创建新线程的需要,这需要时间。新线程添加到池中的速率通常是每两秒一个,这与在异步线程之间切换相比,这是一个非常长的时间。

最佳实践:使你的控制器动作方法异步化。

使控制器动作方法异步化

将现有动作方法异步化很容易:

  1. 修改Index动作方法以使其异步,返回一个任务,并等待调用异步方法以获取类别和产品,如下列代码中突出显示的那样:

    public **async** **Task<IActionResult>** Index()
    {
      HomeIndexViewModel model = new
      (
        VisitorCount = (new Random()).Next(1, 1001),
        Categories = **await** db.Categories.ToList**Async**(),
        Products = **await** db.Products.ToList**Async**()
      );
      return View(model); // pass model to view
    } 
    
  2. 以类似方式修改ProductDetail动作方法,如下列代码中突出显示的那样:

    public **async** **Task<IActionResult>** ProductDetail(int? id)
    {
      if (!id.HasValue)
      {
        return BadRequest("You must pass a product ID in the route, for example,
    /Home/ProductDetail/21");
      }
      Product? model = **await** db.Products
        .SingleOrDefault**Async**(p => p.ProductId == id);
      if (model == null)
      {
        return NotFound($"ProductId {id} not found.");
      }
      return View(model); // pass model to view and then return result
    } 
    
  3. 启动网站并注意网站的功能相同,但相信它现在将更好地扩展。

  4. 关闭 Chrome 并关闭 Web 服务器。

实践与探索

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

练习 15.1 – 测试你的知识

回答以下问题:

  1. 当在Views文件夹中创建具有特殊名称的文件_ViewStart_ViewImports时,它们有什么作用?

  2. 默认 ASP.NET Core MVC 路由中定义的三个段是什么,它们代表什么,哪些是可选的?

  3. 默认模型绑定器的作用是什么,它可以处理哪些数据类型?

  4. 在共享布局文件如_Layout.cshtml中,如何输出当前视图的内容?

  5. 在共享布局文件如_Layout.cshtml中,如何输出当前视图可以提供内容的节,以及视图如何为该节提供内容?

  6. 在控制器的动作方法内部调用View方法时,按照约定会搜索哪些路径以查找视图?

  7. 如何指示访问者的浏览器将响应缓存 24 小时?

  8. 即使你不是自己创建任何 Razor 页面,为什么你可能还会启用它们?

  9. 如何识别可以作为控制器的类?ASP.NET Core MVC 是如何做到的?

  10. ASP.NET Core MVC 在哪些方面使得测试网站变得更加容易?

练习 15.2 – 实践实现 MVC,通过实现类别详细页面

Northwind.Mvc 项目有一个主页,显示类别,但当点击 查看 按钮时,网站返回 404 未找到 错误,例如,对于以下 URL:

https://localhost:5001/category/1

通过添加显示类别详细页面的功能来扩展 Northwind.Mvc 项目。

练习 15.3 – 通过理解和实现异步操作方法来实践提高可扩展性

几年前,Stephen Cleary 为 MSDN 杂志撰写了一篇精彩文章,阐述了在 ASP.NET 中实现异步操作方法的扩展性优势。这些原则同样适用于 ASP.NET Core,甚至更为重要,因为与文章中描述的旧版 ASP.NET 不同,ASP.NET Core 支持异步过滤器和其他组件。

请阅读以下链接中的文章:

docs.microsoft.com/en-us/archive/msdn-magazine/2014/october/async-programming-introduction-to-async-await-on-asp-net

练习 15.4 – 实践单元测试 MVC 控制器

控制器是网站业务逻辑运行的位置,因此使用单元测试来验证该逻辑的正确性非常重要,正如您在第四章编写、调试和测试函数中所学。

HomeController 编写一些单元测试。

良好实践:您可以在以下链接中了解更多关于如何单元测试控制器的信息:docs.microsoft.com/en-us/aspnet/core/mvc/controllers/testing

练习 15.5 – 探索主题

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

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-15---building-websites-using-the-model-view-controller-pattern

总结

在本章中,您学习了如何通过注册和注入依赖服务(如数据库上下文和记录器)来构建易于单元测试的大型复杂网站,并使用 ASP.NET Core MVC 使团队编程管理变得更加容易。您了解了配置、认证、路由、模型、视图和控制器。

在下一章中,您将学习如何构建和消费使用 HTTP 作为通信层的 Web 服务。

第十六章:构建和消费 Web 服务

本章是关于学习如何使用 ASP.NET Core Web API 构建 Web 服务(即 HTTP 或 REST 服务)以及使用 HTTP 客户端消费 Web 服务,这些客户端可以是任何类型的.NET 应用,包括网站、移动或桌面应用。

本章要求您具备在第十章使用 Entity Framework Core 处理数据,以及第十三章第十五章中关于 C#和.NET 的实际应用以及使用 ASP.NET Core 构建网站的知识和技能。

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

  • 使用 ASP.NET Core Web API 构建 Web 服务

  • 文档化和测试 Web 服务

  • 使用 HTTP 客户端消费 Web 服务

  • 为 Web 服务实现高级功能

  • 使用最小 API 构建 Web 服务

使用 ASP.NET Core Web API 构建 Web 服务

在我们构建现代 Web 服务之前,需要先介绍一些背景知识,为本章设定上下文。

理解 Web 服务缩略语

尽管 HTTP 最初设计用于请求和响应 HTML 及其他供人类查看的资源,但它也非常适合构建服务。

罗伊·菲尔丁在其博士论文中描述表述性状态转移(REST)架构风格时指出,HTTP 标准适合构建服务,因为它定义了以下内容:

  • 唯一标识资源的 URI,如https://localhost:5001/api/products/23

  • 对这些资源执行常见任务的方法,如GETPOSTPUTDELETE

  • 请求和响应中交换的内容媒体类型协商能力,如 XML 和 JSON。内容协商发生在客户端指定类似Accept: application/xml,*/*;q=0.8的请求头时。ASP.NET Core Web API 默认的响应格式是 JSON,这意味着其中一个响应头会是Content-Type: application/json; charset=utf-8

Web 服务采用 HTTP 通信标准,因此有时被称为 HTTP 或 RESTful 服务。本章讨论的就是 HTTP 或 RESTful 服务。

Web 服务也可指实现部分WS-*标准简单对象访问协议(SOAP)服务。这些标准使不同系统上实现的客户端和服务能相互通信。WS-*标准最初由 IBM 定义,微软等其他公司也参与了制定。

理解 Windows Communication Foundation (WCF)

.NET Framework 3.0 及更高版本包含名为Windows Communication Foundation(WCF)的远程过程调用(RPC)技术。RPC 技术使一个系统上的代码能通过网络在另一系统上执行代码。

WCF 使开发者能轻松创建服务,包括实现 WS-*标准的 SOAP 服务。后来它也支持构建 Web/HTTP/REST 风格的服务,但如果仅需要这些,它显得过于复杂。

如果你有现有的 WCF 服务并希望将它们迁移到现代.NET,那么有一个开源项目在 2021 年 2 月发布了其首个正式发布版GA)。你可以在以下链接中了解更多信息:

corewcf.github.io/blog/2021/02/19/corewcf-ga-release

替代 WCF 的方案

微软推荐的 WCF 替代方案是gRPC。gRPC 是一种现代的跨平台开源 RPC 框架,由谷歌创建(非官方地,“g”代表 gRPC)。你将在第十八章构建和消费专业化服务中了解更多关于 gRPC 的信息。

理解 Web API 的 HTTP 请求和响应

HTTP 定义了标准的请求类型和标准代码来指示响应类型。大多数这些类型和代码可用于实现 Web API 服务。

最常见的请求类型是GET,用于检索由唯一路径标识的资源,并附带如可接受的媒体类型等额外选项,这些选项作为请求头设置,如下例所示:

GET /path/to/resource
Accept: application/json 

常见响应包括成功和多种失败类型,如下表所示:

状态码 描述
200 成功 路径正确形成,资源成功找到,序列化为可接受的媒体类型,然后返回在响应体中。响应头指定Content-TypeContent-LengthContent-Encoding,例如 GZIP。
301 永久移动 随着时间的推移,Web 服务可能会更改其资源模型,包括用于标识现有资源的路径。Web 服务可以通过返回此状态码和一个名为Location的响应头来指示新路径,该响应头包含新路径。
302 找到 类似于301
304 未修改 如果请求包含If-Modified-Since头,则 Web 服务可以响应此状态码。响应体为空,因为客户端应使用其缓存的资源副本。
400 错误请求 请求无效,例如,它使用了一个整数 ID 的产品路径,但 ID 值缺失。
401 未授权 请求有效,资源已找到,但客户端未提供凭证或无权访问该资源。重新认证可能会启用访问,例如,通过添加或更改Authorization请求头。
403 禁止访问 请求有效,资源已找到,但客户端无权访问该资源。重新认证也无法解决问题。
404 未找到 请求有效,但资源未找到。如果稍后重复请求,资源可能会被找到。若要表明资源将永远无法找到,返回410 已删除
406 不可接受 如果请求具有仅列出网络服务不支持的媒体类型的Accept头。例如,如果客户端请求 JSON 但网络服务只能返回 XML。
451 因法律原因不可用 在美国托管的网站可能会为来自欧洲的请求返回此状态,以避免不得不遵守《通用数据保护条例》(GDPR)。该数字的选择是对小说《华氏 451 度》的引用,其中书籍被禁止和焚烧。
500 服务器错误 请求有效,但在处理请求时服务器端出现问题。稍后再试可能有效。
503 服务不可用 网络服务正忙,无法处理请求。稍后再试可能有效。

其他常见的 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请求的响应外,所有创建、修改或删除资源的请求类型都有额外的可能的常见响应,如下表所示:

状态码 描述
201 已创建 新资源已成功创建,响应头名为Location包含其路径,响应正文包含新创建的资源。立即GET资源应返回200
202 已接受 新资源无法立即创建,因此请求被排队等待稍后处理,立即GET资源可能会返回404。正文可以包含指向某种状态检查器或资源可用时间估计的资源。
204 无内容 通常用于响应DELETE请求,因为在删除后在正文中返回资源通常没有意义!有时用于响应POSTPUTPATCH请求,如果客户端不需要确认请求是否正确处理。
405 方法不允许 当请求使用的方法不被支持时返回。例如,设计为只读的网络服务可能明确禁止PUTDELETE等。
415 Unsupported Media Type 当请求体中的资源使用 Web 服务无法处理的媒体类型时返回。例如,如果主体包含 XML 格式的资源,但 Web 服务只能处理 JSON。

创建 ASP.NET Core Web API 项目

我们将构建一个 Web 服务,该服务提供了一种使用 ASP.NET Core 在 Northwind 数据库中处理数据的方法,以便数据可以被任何能够发出 HTTP 请求并在任何平台上接收 HTTP 响应的客户端应用程序使用:

  1. 使用您喜欢的代码编辑器添加新项目,如以下列表所定义:

    1. 项目模板:ASP.NET Core Web API / webapi

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.WebApi

    4. 其他 Visual Studio 选项:身份验证类型:无,为 HTTPS 配置:已选中,启用 Docker:已清除,启用 OpenAPI 支持:已选中。

  2. 在 Visual Studio Code 中,选择Northwind.WebApi作为活动的 OmniSharp 项目。

  3. 构建Northwind.WebApi项目。

  4. Controllers文件夹中,打开并审查WeatherForecastController.cs,如下所示:

    using Microsoft.AspNetCore.Mvc;
    namespace Northwind.WebApi.Controllers;
    [ApiController]
    [Route("[controller]")]
    public class WeatherForecastController : ControllerBase
    {
      private static readonly string[] Summaries = new[]
      {
        "Freezing", "Bracing", "Chilly", "Cool", "Mild",
        "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
      };
      private readonly ILogger<WeatherForecastController> _logger;
      public WeatherForecastController(
        ILogger<WeatherForecastController> logger)
      {
        _logger = logger;
      }
      [HttpGet]
      public IEnumerable<WeatherForecast> Get()
      {
        return Enumerable.Range(1, 5).Select(index =>
          new WeatherForecast
          {
            Date = DateTime.Now.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
          })
          .ToArray();
      }
    } 
    

    在审查前面的代码时,请注意以下几点:

    • 控制器类Controller继承自ControllerBase。这比 MVC 中使用的Controller类更简单,因为它没有像View这样的方法,通过将视图模型传递给 Razor 文件来生成 HTML 响应。

    • [Route]属性为客户端注册了/weatherforecast相对 URL,用于发出将由该控制器处理的 HTTP 请求。例如,对https://localhost:5001/weatherforecast/的 HTTP 请求将由该控制器处理。一些开发人员喜欢在控制器名称前加上api/,这是一种区分混合项目中 MVC 和 Web API 的约定。如果使用[controller],如所示,它使用类名中Controller之前的字符,在本例中为WeatherForecast,或者您可以简单地输入一个不同的名称,不带方括号,例如[Route("api/forecast")]

    • [ApiController]属性是在 ASP.NET Core 2.1 中引入的,它为控制器启用了 REST 特定的行为,例如对于无效模型的自动 HTTP 400响应,如本章后面将看到的。

    • [HttpGet]属性将Controller类中的Get方法注册为响应 HTTP GET请求,其实现使用共享的Random对象返回一个WeatherForecast对象数组,其中包含未来五天的随机温度和摘要,如BracingBalmy

  5. 添加第二个Get方法,该方法允许调用指定预测应提前多少天,通过实现以下内容:

    • 在原始方法上方添加注释,以显示其响应的操作方法和 URL 路径。

    • 添加一个带有整数参数days的新方法。

    • 将原始Get方法实现代码语句剪切并粘贴到新的Get方法中。

    • 修改新方法以创建一个整数IEnumerable,其上限为请求的天数,并修改原始Get方法以调用新Get方法并传递值5

你的方法应如以下代码中突出显示的那样:

**// GET /weatherforecast**
[HttpGet]
public IEnumerable<WeatherForecast> Get() **// original method**
{
  **return** **Get(****5****);** **// five day forecast**
}
**// GET /weatherforecast/7**
**[****HttpGet(****"{days:int}"****)****]**
**public** **IEnumerable<WeatherForecast>** **Get****(****int** **days****)** **// new method**
{
**return** **Enumerable.Range(****1****, days).Select(index =>**
    new WeatherForecast
    {
      Date = DateTime.Now.AddDays(index),
      TemperatureC = Random.Shared.Next(-20, 55),
      Summary = Summaries[Random.Shared.Next(Summaries.Length)]
    })
    .ToArray();
} 

[HttpGet]属性中,注意路由格式模式{days:int},它将days参数约束为int值。

审查 Web 服务的功能

现在,我们将测试 Web 服务的功能:

  1. 如果你使用的是 Visual Studio,在属性中,打开launchSettings.json文件,并注意默认情况下,它将启动浏览器并导航至/swagger相对 URL 路径,如下所示突出显示:

    "profiles": {
      "Northwind.WebApi": {
        "commandName": "Project",
        "dotnetRunMessages": "true",
    **"launchBrowser"****:** **true****,**
    **"launchUrl"****:** **"swagger"****,**
        "applicationUrl": "https://localhost:5001;http://localhost:5000",
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        }
      }, 
    
  2. 修改名为Northwind.WebApi的配置文件,将launchBrowser设置为false

  3. 对于applicationUrl,将随机端口号更改为HTTP5000HTTPS5001

  4. 启动 Web 服务项目。

  5. 启动 Chrome。

  6. 导航至https://localhost:5001/,注意你会收到一个404状态码响应,因为我们尚未启用静态文件,也没有index.html文件,或者配置了路由的 MVC 控制器。记住,此项目并非设计为人机交互界面,因此对于 Web 服务而言,这是预期行为。

    GitHub 上的解决方案配置为使用端口5002,因为在本书后面我们将更改其配置。

  7. 在 Chrome 中,显示开发者工具

  8. 导航至https://localhost:5001/weatherforecast,注意 Web API 服务应返回一个包含五个随机天气预报对象的 JSON 文档数组,如图16.1所示:

    图 16.1:来自天气预报 Web 服务的请求与响应

  9. 关闭开发者工具

  10. 导航至https://localhost:5001/weatherforecast/14,并注意请求两周天气预报时的响应,如图16.2所示:

    图 16.2:两周天气预报的 JSON 文档

  11. 关闭 Chrome 并关闭 Web 服务器。

为 Northwind 数据库创建 Web 服务

与 MVC 控制器不同,Web API 控制器不会调用 Razor 视图以返回 HTML 响应供网站访问者在浏览器中查看。相反,它们使用与发起 HTTP 请求的客户端应用程序的内容协商,在 HTTP 响应中返回 XML、JSON 或 X-WWW-FORM-URLENCODED 等格式的数据。

客户端应用程序必须随后将数据从协商格式反序列化。现代 Web 服务最常用的格式是JavaScript 对象表示法JSON),因为它紧凑且在构建使用 Angular、React 和 Vue 等客户端技术的单页应用程序SPAs)时,能与浏览器中的 JavaScript 原生工作。

我们将引用你在第十三章C#与.NET 实用应用入门中创建的 Northwind 数据库的 Entity Framework Core 实体数据模型:

  1. Northwind.WebApi项目中,为 SQLite 或 SQL Server 添加对Northwind.Common.DataContext的项目引用,如下所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include=
    "..\Northwind.Common.DataContext.Sqlite\Northwind.Common.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    
  2. 构建项目并修复代码中的任何编译错误。

  3. 打开Program.cs并导入用于处理 Web 媒体格式化程序和共享 Packt 类的命名空间,如下所示:

    using Microsoft.AspNetCore.Mvc.Formatters;
    using Packt.Shared; // AddNorthwindContext extension method
    using static System.Console; 
    
  4. 在调用AddControllers之前,添加一条语句以注册Northwind数据库上下文类(它将根据您在项目文件中引用的数据库提供程序使用 SQLite 或 SQL Server),如下所示:

    // Add services to the container.
    builder.Services.AddNorthwindContext(); 
    
  5. 在调用AddControllers时,添加一个 lambda 块,其中包含将默认输出格式化程序的名称和支持的媒体类型写入控制台的语句,然后添加 XML 序列化程序格式化程序,如下所示:

    builder.Services.AddControllers(options =>
    {
      WriteLine("Default output formatters:");
      foreach (IOutputFormatter formatter in options.OutputFormatters)
      {
        OutputFormatter? mediaFormatter = formatter as OutputFormatter;
        if (mediaFormatter == null)
        {
          WriteLine($"  {formatter.GetType().Name}");
        }
        else // OutputFormatter class has SupportedMediaTypes
        {
          WriteLine("  {0}, Media types: {1}",
            arg0: mediaFormatter.GetType().Name,
            arg1: string.Join(", ",
              mediaFormatter.SupportedMediaTypes));
        }
      }
    })
    .AddXmlDataContractSerializerFormatters()
    .AddXmlSerializerFormatters(); 
    
  6. 启动 Web 服务。

  7. 在命令提示符或终端中,请注意有四种默认的输出格式化程序,包括将null值转换为204 No Content的程序,以及支持纯文本、字节流和 JSON 响应的程序,如下所示:

    Default output formatters: 
      HttpNoContentOutputFormatter
      StringOutputFormatter, Media types: text/plain
      StreamOutputFormatter
      SystemTextJsonOutputFormatter, Media types: application/json, text/json, application/*+json 
    
  8. 关闭 Web 服务器。

为实体创建数据仓库

定义和实现提供 CRUD 操作的数据仓库是良好的实践。CRUD 缩写包括以下操作:

  • C 代表创建

  • R 代表检索(或读取)

  • U 代表更新

  • D 代表删除

我们将为 Northwind 中的Customers表创建一个数据仓库。该表中只有 91 个客户,因此我们将整个表的副本存储在内存中,以提高读取客户记录时的可扩展性和性能。

最佳实践:在实际的 Web 服务中,应使用分布式缓存,如 Redis,这是一个开源的数据结构存储,可用作高性能、高可用性的数据库、缓存或消息代理。

我们将遵循现代最佳实践,使仓库 API 异步。它将通过构造函数参数注入由Controller类实例化,因此会为每个 HTTP 请求创建一个新实例:

  1. Northwind.WebApi项目中,创建一个名为Repositories的文件夹。

  2. Repositories文件夹添加两个类文件,名为ICustomerRepository.csCustomerRepository.cs

  3. ICustomerRepository接口将定义五个方法,如下所示:

    using Packt.Shared; // Customer
    namespace Northwind.WebApi.Repositories;
    public interface ICustomerRepository
    {
      Task<Customer?> CreateAsync(Customer c);
      Task<IEnumerable<Customer>> RetrieveAllAsync();
      Task<Customer?> RetrieveAsync(string id);
      Task<Customer?> UpdateAsync(string id, Customer c);
      Task<bool?> DeleteAsync(string id);
    } 
    
  4. CustomerRepository类将实现这五个方法,记住,使用await的方法必须标记为async,如下所示:

    using Microsoft.EntityFrameworkCore.ChangeTracking; // EntityEntry<T>
    using Packt.Shared; // Customer
    using System.Collections.Concurrent; // ConcurrentDictionary
    namespace Northwind.WebApi.Repositories;
    public class CustomerRepository : ICustomerRepository
    {
      // use a static thread-safe dictionary field to cache the customers
      private static ConcurrentDictionary
        <string, Customer>? customersCache;
      // use an instance data context field because it should not be
      // cached due to their internal caching
      private NorthwindContext db;
      public CustomerRepository(NorthwindContext injectedContext)
      {
        db = injectedContext;
        // pre-load customers from database as a normal
        // Dictionary with CustomerId as the key,
        // then convert to a thread-safe ConcurrentDictionary
        if (customersCache is null)
        {
          customersCache = new ConcurrentDictionary<string, Customer>(
            db.Customers.ToDictionary(c => c.CustomerId));
        }
      }
      public async Task<Customer?> CreateAsync(Customer c)
      {
        // normalize CustomerId into uppercase
        c.CustomerId = c.CustomerId.ToUpper();
        // add to database using EF Core
        EntityEntry<Customer> added = await db.Customers.AddAsync(c);
        int affected = await db.SaveChangesAsync();
        if (affected == 1)
        {
          if (customersCache is null) return c;
          // if the customer is new, add it to cache, else
          // call UpdateCache method
          return customersCache.AddOrUpdate(c.CustomerId, c, UpdateCache);
        }
        else
        {
          return null;
        }
      }
      public Task<IEnumerable<Customer>> RetrieveAllAsync()
      {
        // for performance, get from cache
        return Task.FromResult(customersCache is null 
            ? Enumerable.Empty<Customer>() : customersCache.Values);
      }
      public Task<Customer?> RetrieveAsync(string id)
      {
        // for performance, get from cache
        id = id.ToUpper();
        if (customersCache is null) return null!;
        customersCache.TryGetValue(id, out Customer? c);
        return Task.FromResult(c);
      }
      private Customer UpdateCache(string id, Customer c)
      {
        Customer? old;
        if (customersCache is not null)
        {
          if (customersCache.TryGetValue(id, out old))
          {
            if (customersCache.TryUpdate(id, c, old))
            {
              return c;
            }
          }
        }
        return null!;
      }
      public async Task<Customer?> UpdateAsync(string id, Customer c)
      {
        // normalize customer Id
        id = id.ToUpper();
        c.CustomerId = c.CustomerId.ToUpper();
        // update in database
        db.Customers.Update(c);
        int affected = await db.SaveChangesAsync();
        if (affected == 1)
        {
          // update in cache
          return UpdateCache(id, c);
        }
        return null;
      }
      public async Task<bool?> DeleteAsync(string id)
      {
        id = id.ToUpper();
        // remove from database
        Customer? c = db.Customers.Find(id);
        if (c is null) return null;
        db.Customers.Remove(c);
        int affected = await db.SaveChangesAsync();
        if (affected == 1)
        {
          if (customersCache is null) return null;
          // remove from cache
          return customersCache.TryRemove(id, out c);
        }
        else
        {
          return null;
        }
      }
    } 
    

实现 Web API 控制器

对于返回数据而非 HTML 的控制器,有一些有用的属性和方法。

使用 MVC 控制器时,像/home/index这样的路由告诉我们控制器类名和操作方法名,例如HomeController类和Index操作方法。

使用 Web API 控制器,如/weatherforecast的路由仅告诉我们控制器类名,例如WeatherForecastController。为了确定要执行的操作方法名称,我们必须将 HTTP 方法(如GETPOST)映射到控制器类中的方法。

您应该使用以下属性装饰控制器方法,以指示它们将响应的 HTTP 方法:

  • [HttpGet][HttpHead]:这些操作方法响应GETHEAD请求以检索资源,并返回资源及其响应头或仅返回响应头。

  • [HttpPost]:此操作方法响应POST请求以创建新资源或执行服务定义的其他操作。

  • [HttpPut][HttpPatch]:这些操作方法响应PUTPATCH请求以更新现有资源,无论是替换还是更新其属性的子集。

  • [HttpDelete]:此操作方法响应DELETE请求以删除资源。

  • [HttpOptions]:此操作方法响应OPTIONS请求。

理解操作方法返回类型

操作方法可以返回.NET 类型,如单个string值、由classrecordstruct定义的复杂对象,或复杂对象的集合。ASP.NET Core Web API 会将它们序列化为 HTTP 请求Accept头中设置的请求数据格式,例如,如果已注册合适的序列化器,则为 JSON。

为了更精细地控制响应,有一些辅助方法返回围绕.NET 类型的ActionResult包装器。

如果操作方法可能基于输入或其他变量返回不同的返回类型,则应声明其返回类型为IActionResult。如果操作方法将仅返回单个类型但具有不同的状态代码,则应声明其返回类型为ActionResult<T>

最佳实践:使用[ProducesResponseType]属性装饰操作方法,以指示客户端应在响应中预期的所有已知类型和 HTTP 状态代码。此信息随后可以公开,以说明客户端应如何与您的 Web 服务交互。将其视为正式文档的一部分。本章后面,您将学习如何安装代码分析器,以便在您未按此方式装饰操作方法时给出警告。

例如,根据 id 参数获取产品的操作方法将装饰有三个属性——一个表示它响应GET请求并具有 id 参数,另外两个表示成功时和客户端提供无效产品 ID 时的处理方式,如下面的代码所示:

[HttpGet("{id}")]
[ProducesResponseType(200, Type = typeof(Product))] 
[ProducesResponseType(404)]
public IActionResult Get(string id) 

ControllerBase类具有方法,使其易于返回不同的响应,如下表所示:

方法 描述
Ok 返回200状态码和一个转换为客户端首选格式的资源,如 JSON 或 XML。常用于响应GET请求。
CreatedAtRoute 返回一个201状态码和到新资源的路径。通常用于响应POST请求以快速创建资源。
Accepted 返回一个202状态码以指示请求正在处理但尚未完成。通常用于响应POSTPUTPATCHDELETE请求,这些请求触发了一个需要很长时间才能完成的背景进程。
NoContentResult 返回一个204状态码和一个空的响应主体。通常用于响应PUTPATCHDELETE请求,当响应不需要包含受影响的资源时。
BadRequest 返回一个400状态码和一个可选的详细信息消息字符串。
NotFound 返回一个404状态码和一个自动填充的ProblemDetails主体(需要 2.2 或更高版本的兼容性版本)。

配置客户仓库和 Web API 控制器

现在您将配置仓库,以便它可以从 Web API 控制器内部调用。

当 Web 服务启动时,您将为仓库注册一个作用域依赖服务实现,然后使用构造函数参数注入在新 Web API 控制器中获取它,以便与客户工作。

为了展示使用路由区分 MVC 和 Web API 控制器的示例,我们将使用客户控制器的常见/apiURL 前缀约定:

  1. 打开Program.cs并导入Northwind.WebApi.Repositories命名空间。

  2. 在调用Build方法之前添加一个语句,该语句将注册CustomerRepository以在运行时作为作用域依赖使用,如下所示高亮显示的代码:

    **builder.Services.AddScoped<ICustomerRepository, CustomerRepository>();**
    var app = builder.Build(); 
    

    最佳实践:我们的仓库使用一个注册为作用域依赖的数据库上下文。您只能在其他作用域依赖内部使用作用域依赖,因此我们不能将仓库注册为单例。您可以在以下链接了解更多信息:docs.microsoft.com/en-us/dotnet/core/extensions/dependency-injection#scoped

  3. Controllers文件夹中,添加一个名为CustomersController.cs的新类。

  4. CustomersController类文件中,添加语句以定义一个 Web API 控制器类以与客户工作,如下所示的代码:

    using Microsoft.AspNetCore.Mvc; // [Route], [ApiController], ControllerBase
    using Packt.Shared; // Customer
    using Northwind.WebApi.Repositories; // ICustomerRepository
    namespace Northwind.WebApi.Controllers;
    // base address: api/customers
    [Route("api/[controller]")]
    [ApiController]
    public class CustomersController : ControllerBase
    {
      private readonly ICustomerRepository repo;
      // constructor injects repository registered in Startup
      public CustomersController(ICustomerRepository repo)
      {
        this.repo = repo;
      }
      // GET: api/customers
      // GET: api/customers/?country=[country]
      // this will always return a list of customers (but it might be empty)
      [HttpGet]
      [ProducesResponseType(200, Type = typeof(IEnumerable<Customer>))]
      public async Task<IEnumerable<Customer>> GetCustomers(string? country)
      {
        if (string.IsNullOrWhiteSpace(country))
        {
          return await repo.RetrieveAllAsync();
        }
        else
        {
          return (await repo.RetrieveAllAsync())
            .Where(customer => customer.Country == country);
        }
      }
      // GET: api/customers/[id]
      [HttpGet("{id}", Name = nameof(GetCustomer))] // named route
      [ProducesResponseType(200, Type = typeof(Customer))]
      [ProducesResponseType(404)]
      public async Task<IActionResult> GetCustomer(string id)
      {
        Customer? c = await repo.RetrieveAsync(id);
        if (c == null)
        {
          return NotFound(); // 404 Resource not found
        }
        return Ok(c); // 200 OK with customer in body
      }
      // POST: api/customers
      // BODY: Customer (JSON, XML)
      [HttpPost]
      [ProducesResponseType(201, Type = typeof(Customer))]
      [ProducesResponseType(400)]
      public async Task<IActionResult> Create([FromBody] Customer c)
      {
        if (c == null)
        {
          return BadRequest(); // 400 Bad request
        }
        Customer? addedCustomer = await repo.CreateAsync(c);
        if (addedCustomer == null)
        {
          return BadRequest("Repository failed to create customer.");
        }
        else
        {
          return CreatedAtRoute( // 201 Created
            routeName: nameof(GetCustomer),
            routeValues: new { id = addedCustomer.CustomerId.ToLower() },
            value: addedCustomer);
        }
      }
      // PUT: api/customers/[id]
      // BODY: Customer (JSON, XML)
      [HttpPut("{id}")]
      [ProducesResponseType(204)]
      [ProducesResponseType(400)]
      [ProducesResponseType(404)]
      public async Task<IActionResult> Update(
        string id, [FromBody] Customer c)
      {
        id = id.ToUpper();
        c.CustomerId = c.CustomerId.ToUpper();
        if (c == null || c.CustomerId != id)
        {
          return BadRequest(); // 400 Bad request
        }
        Customer? existing = await repo.RetrieveAsync(id);
        if (existing == null)
        {
          return NotFound(); // 404 Resource not found
        }
        await repo.UpdateAsync(id, c);
        return new NoContentResult(); // 204 No content
      }
      // DELETE: api/customers/[id]
      [HttpDelete("{id}")]
      [ProducesResponseType(204)]
      [ProducesResponseType(400)]
      [ProducesResponseType(404)]
      public async Task<IActionResult> Delete(string id)
      {
        Customer? existing = await repo.RetrieveAsync(id);
        if (existing == null)
        {
          return NotFound(); // 404 Resource not found
        }
        bool? deleted = await repo.DeleteAsync(id);
        if (deleted.HasValue && deleted.Value) // short circuit AND
        {
          return new NoContentResult(); // 204 No content
        }
        else
        {
          return BadRequest( // 400 Bad request
            $"Customer {id} was found but failed to delete.");
        }
      }
    } 
    

在审查此 Web API 控制器类时,请注意以下内容:

  • Controller类注册了一个以api/开头的路由,并包含控制器的名称,即api/customers

  • 构造函数使用依赖注入来获取注册的仓库以与客户工作。

  • 有五个操作方法来执行对客户的 CRUD 操作——两个GET方法(获取所有客户或一个客户),POST(创建),PUT(更新)和DELETE

  • 方法GetCustomers可以接受一个string类型的参数,该参数为国名。若该参数缺失,则返回所有客户信息。若存在,则用于按国家筛选客户。

  • GetCustomer方法有一个显式命名的路由GetCustomer,以便在插入新客户后用于生成 URL。

  • CreateUpdate方法都使用[FromBody]装饰customer参数,以告知模型绑定器从POST请求体中填充其值。

  • Create方法返回的响应使用了GetCustomer路由,以便客户端知道将来如何获取新创建的资源。我们正在将两个方法匹配起来,以创建并获取客户。

  • CreateUpdate方法无需检查 HTTP 请求体中传递的客户模型状态,并在模型无效时返回包含模型验证错误详情的400 Bad Request,因为控制器装饰有[ApiController],它会为你执行此操作。

当服务接收到 HTTP 请求时,它将创建一个Controller类实例,调用相应的动作方法,以客户端偏好的格式返回响应,并释放控制器使用的资源,包括仓库及其数据上下文。

指定问题详情

ASP.NET Core 2.1 及更高版本新增了一项特性,即实现了指定问题详情的 Web 标准。

在启用了 ASP.NET Core 2.2 或更高版本兼容性的项目中,使用[ApiController]装饰的 Web API 控制器中,返回IActionResult且返回客户端错误状态码(即4xx)的动作方法,将自动在响应体中包含ProblemDetails类的序列化实例。

如果你想自行控制,那么你可以创建一个ProblemDetails实例,并包含额外信息。

让我们模拟一个需要向客户端返回自定义数据的错误请求:

  1. Delete方法的实现顶部,添加语句检查id是否匹配字符串值"bad",如果是,则返回一个自定义的问题详情对象,如下所示:

    // take control of problem details
    if (id == "bad")
    {
      ProblemDetails problemDetails = new()
      {
        Status = StatusCodes.Status400BadRequest,
        Type = "https://localhost:5001/customers/failed-to-delete",
        Title = $"Customer ID {id} found but failed to delete.",
        Detail = "More details like Company Name, Country and so on.",
        Instance = HttpContext.Request.Path
      };
      return BadRequest(problemDetails); // 400 Bad Request
    } 
    
  2. 你稍后将测试此功能。

控制 XML 序列化

Program.cs文件中,我们添加了XmlSerializer,以便我们的 Web API 服务在客户端请求时,既能返回 JSON 也能返回 XML。

然而,XmlSerializer无法序列化接口,而我们的实体类使用ICollection<T>来定义相关子实体,这会在运行时导致警告,例如对于Customer类及其Orders属性,如下输出所示:

warn: Microsoft.AspNetCore.Mvc.Formatters.XmlSerializerOutputFormatter[1]
An error occurred while trying to create an XmlSerializer for the type 'Packt.Shared.Customer'.
System.InvalidOperationException: There was an error reflecting type 'Packt.Shared.Customer'.
---> System.InvalidOperationException: Cannot serialize member 'Packt.
Shared.Customer.Orders' of type 'System.Collections.Generic.ICollection`1[[Packt. Shared.Order, Northwind.Common.EntityModels, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]', see inner exception for more details. 

我们可以通过在将Customer序列化为 XML 时排除Orders属性来防止此警告:

  1. Northwind.Common.EntityModels.SqliteNorthwind.Common.EntityModels.SqlServer项目中,打开Customers.cs文件。

  2. 导入System.Xml.Serialization命名空间,以便我们能使用[XmlIgnore]属性。

  3. Orders属性添加一个属性,以便在序列化时忽略它,如下面的代码中突出显示的那样:

    [InverseProperty(nameof(Order.Customer))]
    **[****XmlIgnore****]**
    public virtual ICollection<Order> Orders { get; set; } 
    
  4. Northwind.Common.EntityModels.SqlServer项目中,同样为CustomerCustomerDemos属性添加[XmlIgnore]装饰。

记录和测试网络服务

通过浏览器发起 HTTP GET请求,你可以轻松测试网络服务。要测试其他 HTTP 方法,我们需要更高级的工具。

使用浏览器测试 GET 请求

你将使用 Chrome 测试GET请求的三种实现——获取所有客户、获取指定国家的客户以及通过唯一客户 ID 获取单个客户:

  1. 启动Northwind.WebApi网络服务。

  2. 启动 Chrome。

  3. 访问https://localhost:5001/api/customers并注意返回的 JSON 文档,其中包含 Northwind 数据库中的所有 91 位客户(未排序),如图16.3所示:

    图 16.3:Northwind 数据库中的客户作为 JSON 文档

  4. 访问https://localhost:5001/api/customers/?country=Germany并注意返回的 JSON 文档,其中仅包含德国的客户,如图16.4所示:

    图 16.4:来自德国的客户列表作为 JSON 文档

    如果返回的是空数组,请确保你输入的国家名称使用了正确的字母大小写,因为数据库查询是区分大小写的。例如,比较ukUK的结果。

  5. 访问https://localhost:5001/api/customers/alfki并注意返回的 JSON 文档,其中仅包含名为Alfreds Futterkiste的客户,如图16.5所示:

    图 16.5:特定客户信息作为 JSON 文档

与国家名称不同,我们无需担心客户id值的大小写,因为在控制器类内部,我们已在代码中将string值规范化为大写。

但我们如何测试其他 HTTP 方法,如POSTPUTDELETE?以及我们如何记录我们的网络服务,使其易于任何人理解如何与之交互?

为解决第一个问题,我们可以安装一个名为REST Client的 Visual Studio Code 扩展。为解决第二个问题,我们可以使用Swagger,这是全球最流行的 HTTP API 文档和测试技术。但首先,让我们看看 Visual Studio Code 扩展能做什么。

有许多工具可用于测试 Web API,例如Postman。尽管 Postman 很受欢迎,但我更喜欢REST Client,因为它不会隐藏实际发生的情况。我觉得 Postman 过于图形化。但我鼓励你探索不同的工具,找到适合你风格的工具。你可以在以下链接了解更多关于 Postman 的信息:www.postman.com/

使用 REST Client 扩展测试 HTTP 请求

REST Client 是一个扩展,允许你在 Visual Studio Code 中发送任何类型的 HTTP 请求并查看响应。即使你更喜欢使用 Visual Studio 作为代码编辑器,安装 Visual Studio Code 来使用像 REST Client 这样的扩展也是有用的。

使用 REST Client 进行 GET 请求

我们将首先创建一个文件来测试GET请求:

  1. 如果你尚未安装由毛华超(humao.rest-client)开发的 REST Client,请立即在 Visual Studio Code 中安装它。

  2. 在你偏好的代码编辑器中,启动Northwind.WebApi项目网络服务。

  3. 在 Visual Studio Code 中,在PracticalApps文件夹中创建一个RestClientTests文件夹,然后打开该文件夹。

  4. RestClientTests文件夹中,创建一个名为get-customers.http的文件,并修改其内容以包含一个 HTTP GET请求来检索所有客户,如下面的代码所示:

    GET https://localhost:5001/api/customers/ HTTP/1.1 
    
  5. 在 Visual Studio Code 中,导航至视图 | 命令面板,输入rest client,选择命令Rest Client: Send Request,然后按 Enter,如图 16.6 所示:

    图 16.6:使用 REST Client 发送 HTTP GET 请求

  6. 注意响应显示在一个新的选项卡窗口面板中,并且你可以通过拖放选项卡将打开的选项卡重新排列为水平布局。

  7. 输入更多GET请求,每个请求之间用三个井号分隔,以测试获取不同国家的客户和使用其 ID 获取单个客户,如下面的代码所示:

    ###
    GET https://localhost:5001/api/customers/?country=Germany HTTP/1.1 
    ###
    GET https://localhost:5001/api/customers/?country=USA HTTP/1.1 
    Accept: application/xml
    ###
    GET https://localhost:5001/api/customers/ALFKI HTTP/1.1 
    ###
    GET https://localhost:5001/api/customers/abcxy HTTP/1.1 
    
  8. 点击每个请求上方的发送请求链接来发送它;例如,具有请求头以 XML 而非 JSON 格式请求美国客户的GET请求,如图 16.7 所示:

图 16.7:使用 REST Client 发送 XML 请求并获取响应

使用 REST Client 进行其他请求

接下来,我们将创建一个文件来测试其他请求,如POST

  1. RestClientTests文件夹中,创建一个名为create-customer.http的文件,并修改其内容以定义一个POST请求来创建新客户,注意 REST Client 将在你输入常见 HTTP 请求时提供 IntelliSense,如下面的代码所示:

    POST https://localhost:5001/api/customers/ HTTP/1.1 
    Content-Type: application/json
    Content-Length: 301
    {
      "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",
      "fax": null,
      "orders": null
    } 
    
  2. 由于不同操作系统中的行尾不同,Content-Length头的值在 Windows 和 macOS 或 Linux 上会有所不同。如果值错误,则请求将失败。要发现正确的内容长度,选择请求的主体,然后在状态栏中查看字符数,如图 16.8 所示:

    图 16.8:检查正确的内容长度

  3. 发送请求并注意响应是201 Created。同时注意新创建客户的地址(即 URL)是https://localhost:5001/api/Customers/abcxy,并在响应体中包含新创建的客户,如图 16.9 所示:

图 16.9:添加新客户

我将留给您一个可选挑战,创建 REST 客户端文件以测试更新客户(使用PUT)和删除客户(使用DELETE)。尝试对存在和不存在的客户进行操作。解决方案位于本书的 GitHub 仓库中。

既然我们已经看到了一种快速简便的测试服务方法,这同时也是学习 HTTP 的好方法,那么外部开发者呢?我们希望他们学习和调用我们的服务尽可能简单。为此,我们将使用 Swagger。

理解 Swagger

Swagger 最重要的部分是OpenAPI 规范,它定义了您 API 的 REST 风格契约,详细说明了所有资源和操作,以易于开发、发现和集成的人机可读格式。

开发者可以使用 Web API 的 OpenAPI 规范自动生成其首选语言或库中的强类型客户端代码。

对我们来说,另一个有用的功能是Swagger UI,因为它自动为您的 API 生成文档,并内置了可视化测试功能。

让我们回顾一下如何使用Swashbuckle包为我们的 Web 服务启用 Swagger:

  1. 如果 Web 服务正在运行,请关闭 Web 服务器。

  2. 打开Northwind.WebApi.csproj并注意Swashbuckle.AspNetCore的包引用,如下所示:

    <ItemGroup>
      <PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.5" />
    </ItemGroup> 
    
  3. Swashbuckle.AspNetCore包的版本更新至最新,例如,截至 2021 年 9 月撰写时,版本为6.2.1

  4. Program.cs中,注意导入 Microsoft 的 OpenAPI 模型命名空间,如下所示:

    using Microsoft.OpenApi.Models; 
    
  5. 导入 Swashbuckle 的 SwaggerUI 命名空间,如下所示:

    using Swashbuckle.AspNetCore.SwaggerUI; // SubmitMethod 
    
  6. Program.cs大约中间位置,注意添加 Swagger 支持的语句,包括 Northwind 服务的文档,表明这是您服务的第一版,并更改标题,如下所示高亮显示:

    builder.Services.AddSwaggerGen(c =>
      {
        c.SwaggerDoc("v1", new()
          { Title = "**Northwind Service API**", Version = "v1" });
      }); 
    
  7. 在配置 HTTP 请求管道的部分中,注意在开发模式下使用 Swagger 和 Swagger UI 的语句,并定义 OpenAPI 规范 JSON 文档的端点。

  8. 添加代码以明确列出我们希望在 Web 服务中支持的 HTTP 方法,并更改端点名称,如下所示高亮显示:

    var app = builder.Build();
    // Configure the HTTP request pipeline.
    if (builder.Environment.IsDevelopment())
    {
      app.UseSwagger(); 
      app.UseSwaggerUI(c =>
     **{**
     **c.SwaggerEndpoint(****"/swagger/v1/swagger.json"****,**
    **"Northwind Service API Version 1"****);**
     **c.SupportedSubmitMethods(****new****[] {** 
     **SubmitMethod.Get, SubmitMethod.Post,**
     **SubmitMethod.Put, SubmitMethod.Delete });**
     **});**
    } 
    

使用 Swagger UI 测试请求

现在您已准备好使用 Swagger 测试 HTTP 请求:

  1. 启动Northwind.WebApi Web 服务。

  2. 在 Chrome 中导航至https://localhost:5001/swagger/,并注意CustomersWeatherForecast Web API 控制器已被发现并记录,以及 API 使用的Schemas

  3. 点击GET /api/Customers/展开该端点,并注意客户id所需的参数,如图 16.10所示:

    图 16.10:在 Swagger 中检查 GET 请求的参数

  4. 点击试用,输入ALFKI作为ID,然后点击宽大的蓝色执行按钮,如图16.11所示:

    图 16.11:点击执行按钮前输入客户 ID

  5. 向下滚动并注意请求 URL、带有代码服务器响应以及包含响应体响应头详细信息,如图16.12所示:

    图 16.12:成功 Swagger 请求中关于 ALFKI 的信息

  6. 滚动回页面顶部,点击POST /api/Customers展开该部分,然后点击试用

  7. 点击请求体框内,修改 JSON 以定义新客户,如下所示:

    {
      "customerID": "SUPER",
      "companyName": "Super Company",
      "contactName": "Rasmus Ibensen",
      "contactTitle": "Sales Leader",
      "address": "Rotterslef 23",
      "city": "Billund",
      "region": null,
      "postalCode": "4371",
      "country": "Denmark",
      "phone": "31 21 43 21",
      "fax": "31 21 43 22"
    } 
    
  8. 点击执行,并注意请求 URL、带有代码服务器响应以及包含响应体响应头详细信息,注意响应代码为201表示客户已成功创建,如图16.13所示:

    图 16.13:成功添加新客户

  9. 滚动回页面顶部,点击GET /api/Customers,点击试用,输入Denmark作为国家参数,点击执行,确认新客户已添加到数据库,如图16.14所示:

    图 16.14:成功获取包括新添加客户在内的丹麦客户

  10. 点击DELETE /api/Customers/,点击试用,输入super作为ID,点击执行,并注意服务器响应代码204,表明成功删除,如图16.15所示:

    图 16.15:成功删除客户

  11. 再次点击执行,并注意服务器响应代码404,表明客户不再存在,响应体包含问题详情 JSON 文档,如图16.16所示:

    图 16.16:已删除的客户不再存在

  12. 输入bad作为ID,再次点击执行,并注意服务器响应代码400,表明客户确实存在但删除失败(此情况下,因为网络服务模拟此错误),响应体包含一个自定义问题详情 JSON 文档,如图16.17所示:

    图 16.17:客户确实存在但删除失败

  13. 使用GET方法确认新客户已从数据库中删除(原丹麦仅有两个客户)。

    我将使用PUT方法更新现有客户的测试留给读者。

  14. 关闭 Chrome 并关闭网络服务器。

启用 HTTP 日志记录

HTTP 日志记录是一个可选的中间件组件,它记录有关 HTTP 请求和 HTTP 响应的信息,包括以下内容:

  • HTTP 请求信息

  • 头部

  • 主体

  • HTTP 响应信息

这在网络服务中对于审计和调试场景非常有价值,但需注意,它可能对性能产生负面影响。你还可能记录个人身份信息PII),这在某些司法管辖区可能导致合规问题。

让我们看看 HTTP 日志记录的实际效果:

  1. Program.cs中,导入用于处理 HTTP 日志记录的命名空间,如下列代码所示:

    using Microsoft.AspNetCore.HttpLogging; // HttpLoggingFields 
    
  2. 在服务配置部分,添加一条配置 HTTP 日志记录的语句,如下列代码所示:

    builder.Services.AddHttpLogging(options =>
    {
      options.LoggingFields = HttpLoggingFields.All;
      options.RequestBodyLogLimit = 4096; // default is 32k
      options.ResponseBodyLogLimit = 4096; // default is 32k
    }); 
    
  3. 在 HTTP 管道配置部分,添加一条在路由调用前添加 HTTP 日志记录的语句,如下列代码所示:

    app.UseHttpLogging(); 
    
  4. 启动Northwind.WebApi网络服务。

  5. 启动 Chrome 浏览器。

  6. 导航至https://localhost:5001/api/customers

  7. 在命令提示符或终端中,注意请求和响应已被记录,如下列输出所示:

    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[1]
          Request:
          Protocol: HTTP/1.1
          Method: GET
          Scheme: https
          PathBase:
          Path: /api/customers
          QueryString:
          Connection: keep-alive
          Accept: */*
          Accept-Encoding: gzip, deflate, br
          Host: localhost:5001
    info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[2]
          Response:
          StatusCode: 200
          Content-Type: application/json; charset=utf-8
          ...
          Transfer-Encoding: chunked 
    
  8. 关闭 Chrome 并关闭网络服务器。

你现在已准备好构建消费你的网络服务的应用程序。

使用 HTTP 客户端消费网络服务

既然我们已经构建并测试了 Northwind 服务,接下来我们将学习如何使用HttpClient类及其工厂从任何.NET 应用中调用该服务。

理解 HttpClient

最简便的网络服务消费方式是使用HttpClient类。然而,许多人错误地使用它,因为它实现了IDisposable,且微软的官方文档展示了其不当用法。请参阅 GitHub 仓库中的书籍链接,以获取更多关于此话题的讨论文章。

通常,当类型实现IDisposable时,你应该在using语句中创建它,以确保其尽快被释放。HttpClient则不同,因为它被共享、可重入且部分线程安全。

问题与底层网络套接字的管理方式有关。简而言之,你应该为应用程序生命周期内消费的每个 HTTP 端点使用单一的HttpClient实例。这将允许每个HttpClient实例设置适合其工作端点的默认值,同时高效管理底层网络套接字。

使用 HttpClientFactory 配置 HTTP 客户端

微软已意识到此问题,并在 ASP.NET Core 2.1 中引入了HttpClientFactory以鼓励最佳实践;这正是我们将采用的技术。

在下述示例中,我们将以 Northwind MVC 网站作为 Northwind Web API 服务的客户端。由于两者需同时托管于同一网络服务器上,我们首先需要配置它们使用不同的端口号,如下表所示:

  • Northwind Web API 服务将使用HTTPS监听端口5002

  • Northwind MVC 网站将继续使用HTTP监听端口5000,使用HTTPS监听端口5001

让我们来配置这些端口:

  1. Northwind.WebApi项目的Program.cs中,添加一个对UseUrls的扩展方法调用,指定HTTPS端口为5002,如下列高亮代码所示:

    var builder = WebApplication.CreateBuilder(args);
    **builder.WebHost.UseUrls(****"https://localhost:5002/"****);** 
    
  2. Northwind.Mvc项目中,打开Program.cs,并导入用于处理 HTTP 客户端工厂的命名空间,如下面的代码所示:

    using System.Net.Http.Headers; // MediaTypeWithQualityHeaderValue 
    
  3. 添加一条语句以启用HttpClientFactory,并使用命名客户端通过 HTTPS 在端口5002上调用 Northwind Web API 服务,并请求 JSON 作为默认响应格式,如下面的代码所示:

    builder.Services.AddHttpClient(name: "Northwind.WebApi",
      configureClient: options =>
      {
        options.BaseAddress = new Uri("https://localhost:5002/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
          "application/json", 1.0));
      }); 
    

在控制器中以 JSON 形式获取客户

我们现在可以创建一个 MVC 控制器动作方法,该方法使用工厂创建 HTTP 客户端,发起一个针对客户的GET请求,并使用.NET 5 中引入的System.Net.Http.Json程序集和命名空间中的便捷扩展方法反序列化 JSON 响应:

  1. 打开Controllers/HomeController.cs,并声明一个用于存储 HTTP 客户端工厂的字段,如下面的代码所示:

    private readonly IHttpClientFactory clientFactory; 
    
  2. 在构造函数中设置字段,如下面的代码中突出显示的那样:

    public HomeController(
      ILogger<HomeController> logger,
      NorthwindContext injectedContext**,**
     **IHttpClientFactory httpClientFactory**)
    {
      _logger = logger;
      db = injectedContext;
     **clientFactory = httpClientFactory;**
    } 
    
  3. 创建一个新的动作方法,用于调用 Northwind Web API 服务,获取所有客户,并将他们传递给一个视图,如下面的代码所示:

    public async Task<IActionResult> Customers(string country)
    {
      string uri;
      if (string.IsNullOrEmpty(country))
      {
        ViewData["Title"] = "All Customers Worldwide";
        uri = "api/customers/";
      }
      else
      {
        ViewData["Title"] = $"Customers in {country}";
        uri = $"api/customers/?country={country}";
      }
      HttpClient client = clientFactory.CreateClient(
        name: "Northwind.WebApi");
      HttpRequestMessage request = new(
        method: HttpMethod.Get, requestUri: uri);
      HttpResponseMessage response = await client.SendAsync(request);
      IEnumerable<Customer>? model = await response.Content
        .ReadFromJsonAsync<IEnumerable<Customer>>();
      return View(model);
    } 
    
  4. Views/Home文件夹中,创建一个名为Customers.cshtml的 Razor 文件。

  5. 修改 Razor 文件以渲染客户,如下面的标记所示:

    @using Packt.Shared
    @model IEnumerable<Customer>
    <h2>@ViewData["Title"]</h2>
    <table class="table">
      <thead>
        <tr>
          <th>Company Name</th>
          <th>Contact Name</th>
          <th>Address</th>
          <th>Phone</th>
        </tr>
      </thead>
      <tbody>
        @if (Model is not null)
        {
          @foreach (Customer c in Model)
          {
            <tr>
              <td>
                @Html.DisplayFor(modelItem => c.CompanyName)
              </td>
              <td>
                @Html.DisplayFor(modelItem => c.ContactName)
              </td>
              <td>
                @Html.DisplayFor(modelItem => c.Address) 
                @Html.DisplayFor(modelItem => c.City)
                @Html.DisplayFor(modelItem => c.Region)
                @Html.DisplayFor(modelItem => c.Country) 
                @Html.DisplayFor(modelItem => c.PostalCode)
              </td>
              <td>
                @Html.DisplayFor(modelItem => c.Phone)
              </td>
            </tr>
          }
        }
      </tbody>
    </table> 
    
  6. Views/Home/Index.cshtml中,在渲染访客计数后添加一个表单,允许访客输入一个国家并查看客户,如下面的标记所示:

    <h3>Query customers from a service</h3>
    <form asp-action="Customers" method="get">
      <input name="country" placeholder="Enter a country" />
      <input type="submit" />
    </form> 
    

启用跨源资源共享

跨源资源共享CORS)是一种基于 HTTP 头部的标准,用于保护当客户端和服务器位于不同域(源)时的 Web 资源。它允许服务器指示哪些源(由域、方案或端口的组合定义)除了它自己的源之外,它将允许从这些源加载资源。

由于我们的 Web 服务托管在端口5002上,而我们的 MVC 网站托管在端口50005001上,它们被视为不同的源,因此资源不能共享。

在服务器上启用 CORS,并配置我们的 Web 服务,使其仅允许来自 MVC 网站的请求,这将非常有用:

  1. Northwind.WebApi项目中,打开Program.cs

  2. 在服务配置部分添加一条语句,以添加对 CORS 的支持,如下面的代码所示:

    builder.Services.AddCors(); 
    
  3. 在 HTTP 管道配置部分添加一条语句,在调用UseEndpoints之前,使用 CORS 并允许来自具有https://localhost:5001源的 Northwind MVC 等任何网站的GETPOSTPUTDELETE请求,如下面的代码所示:

    app.UseCors(configurePolicy: options =>
    {
      options.WithMethods("GET", "POST", "PUT", "DELETE");
      options.WithOrigins(
        "https://localhost:5001" // allow requests from the MVC client
      );
    }); 
    
  4. 启动Northwind.WebApi项目,并确认 Web 服务仅在端口5002上监听,如下面的输出所示:

    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5002 
    
  5. 启动Northwind.Mvc项目,并确认网站正在监听端口50005002,如下面的输出所示:

    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5001
    info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5000 
    
  6. 启动 Chrome。

  7. 在客户表单中,输入一个国家,如GermanyUKUSA,点击提交,并注意客户列表,如图16.18所示:

    图 16.18:英国的客户

  8. 点击浏览器中的返回按钮,清除国家文本框,点击提交,并注意全球客户列表。

  9. 在命令提示符或终端中,注意HttpClient会记录它发出的每个 HTTP 请求和接收的 HTTP 响应,如下面的输出所示:

    info: System.Net.Http.HttpClient.Northwind.WebApi.ClientHandler[100]
      Sending HTTP request GET https://localhost:5002/api/customers/?country=UK
    info: System.Net.Http.HttpClient.Northwind.WebApi.ClientHandler[101]
      Received HTTP response headers after 931.864ms - 200 
    
  10. 关闭 Chrome 并关闭网络服务器。

你已成功构建了一个网络服务,并从 MVC 网站中调用了它。

为网络服务实现高级功能

既然你已经看到了构建网络服务及其从客户端调用的基础知识,让我们来看看一些更高级的功能。

实现健康检查 API

有许多付费服务执行基本的站点可用性测试,如基本 ping,有些则提供更高级的 HTTP 响应分析。

ASP.NET Core 2.2 及更高版本使得实现更详细的网站健康检查变得容易。例如,你的网站可能在线,但它准备好了吗?它能从数据库检索数据吗?

让我们为我们的网络服务添加基本的健康检查功能:

  1. Northwind.WebApi项目中,添加一个项目引用以启用 Entity Framework Core 数据库健康检查,如下面的标记所示:

    <PackageReference Include=  
      "Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore"   
      Version="6.0.0" /> 
    
  2. 构建项目。

  3. Program.cs中,在服务配置部分的底部,添加一条语句以添加健康检查,包括到 Northwind 数据库上下文,如下面的代码所示:

    builder.Services.AddHealthChecks()
      .AddDbContextCheck<NorthwindContext>(); 
    

    默认情况下,数据库上下文检查调用 EF Core 的CanConnectAsync方法。你可以通过调用AddDbContextCheck方法来自定义运行的操作。

  4. 在 HTTP 管道配置部分,在调用MapControllers之前,添加一条语句以使用基本健康检查,如下面的代码所示:

    app.UseHealthChecks(path: "/howdoyoufeel"); 
    
  5. 启动网络服务。

  6. 启动 Chrome。

  7. 导航到https://localhost:5002/howdoyoufeel并注意网络服务以纯文本响应:Healthy

  8. 在命令提示符或终端中,注意用于测试数据库健康状况的 SQL 语句,如下面的输出所示:

    Level: Debug, Event Id: 20100, State: Executing DbCommand [Parameters=[], CommandType='Text', CommandTimeout='30']
    SELECT 1 
    
  9. 关闭 Chrome 并关闭网络服务器。

实现 Open API 分析器和约定

在本章中,你学习了如何通过手动使用属性装饰控制器类来启用 Swagger 以记录网络服务。

在 ASP.NET Core 2.2 或更高版本中,有 API 分析器会反射带有[ApiController]属性的控制器类来自动记录它。分析器假设了一些 API 约定。

要使用它,你的项目必须启用 OpenAPI 分析器,如下面的标记中突出显示的那样:

<PropertyGroup>
  <TargetFramework>net6.0</TargetFramework>
  <Nullable>enable</Nullable>
  <ImplicitUsings>enable</ImplicitUsings>
 **<IncludeOpenAPIAnalyzers>****true****</IncludeOpenAPIAnalyzers>**
</PropertyGroup> 

安装后,未正确装饰的控制器应显示警告(绿色波浪线),并在编译源代码时发出警告。例如,WeatherForecastController类。

自动代码修复随后可以添加适当的[Produces][ProducesResponseType]属性,尽管这在当前仅适用于 Visual Studio。在 Visual Studio Code 中,您将看到分析器认为您应该添加属性的警告,但您必须手动添加它们。

实现瞬态故障处理

当客户端应用或网站调用 Web 服务时,可能来自世界的另一端。客户端与服务器之间的网络问题可能导致与您的实现代码无关的问题。如果客户端发起调用失败,应用不应就此放弃。如果它尝试再次调用,问题可能已经解决。我们需要一种方法来处理这些临时故障。

为了处理这些瞬态故障,微软建议您使用第三方库 Polly 来实现带有指数退避的自动重试。您定义一个策略,库将处理其余所有事务。

最佳实践:您可以在以下链接了解更多关于 Polly 如何使您的 Web 服务更可靠的信息:docs.microsoft.com/en-us/dotnet/architecture/microservices/implement-resilient-applications/implement-http-call-retries-exponential-backoff-polly

添加安全 HTTP 头部

ASP.NET Core 内置了对常见安全 HTTP 头部(如 HSTS)的支持。但还有许多其他 HTTP 头部您应考虑实现。

添加这些头部的最简单方法是使用中间件类:

  1. Northwind.WebApi项目/文件夹中,创建一个名为SecurityHeadersMiddleware.cs的文件,并修改其语句,如下所示:

    using Microsoft.Extensions.Primitives; // StringValues
    public class SecurityHeaders
    {
      private readonly RequestDelegate next;
      public SecurityHeaders(RequestDelegate next)
      {
        this.next = next;
      }
      public Task Invoke(HttpContext context)
      {
        // add any HTTP response headers you want here
        context.Response.Headers.Add(
          "super-secure", new StringValues("enable"));
        return next(context);
      }
    } 
    
  2. Program.cs中,在 HTTP 管道配置部分,添加一条语句,在调用UseEndpoints之前注册中间件,如下所示:

    app.UseMiddleware<SecurityHeaders>(); 
    
  3. 启动 Web 服务。

  4. 启动 Chrome。

  5. 显示开发者工具及其网络标签以记录请求和响应。

  6. 导航至https://localhost:5002/weatherforecast

  7. 注意我们添加的自定义 HTTP 响应头部,名为super-secure,如图 16.19所示:

    图 16.19:添加名为 super-secure 的自定义 HTTP 头部

使用最小 API 构建 Web 服务

对于.NET 6,微软投入了大量精力为 C# 10 语言添加新特性,并简化 ASP.NET Core 库,以实现使用最小 API 创建 Web 服务。

您可能还记得 Web API 项目模板中提供的天气预报服务。它展示了使用控制器类返回使用假数据的五天天气预报。我们现在将使用最小 API 重现该天气服务。

首先,天气服务有一个类来表示单个天气预报。我们将在多个项目中需要使用这个类,所以让我们为此创建一个类库:

  1. 使用您喜欢的代码编辑器添加一个新项目,如下列清单所定义:

    1. 项目模板:类库 / classlib

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.Common

  2. Class1.cs重命名为WeatherForecast.cs

  3. 修改WeatherForecast.cs,如下面的代码所示:

    namespace Northwind.Common
    {
      public class WeatherForecast
      {
        public static readonly string[] Summaries = new[]
        {
          "Freezing", "Bracing", "Chilly", "Cool", "Mild",
          "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
        };
        public DateTime Date { get; set; }
        public int TemperatureC { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
        public string? Summary { get; set; }
      }
    } 
    

使用最小 API 构建天气服务

现在让我们使用最小 API 重新创建该天气服务。它将在端口5003上监听,并启用 CORS 支持,以便请求只能来自 MVC 网站,并且只允许GET请求:

  1. 使用您喜欢的代码编辑器添加一个新项目,如下列清单所定义:

    1. 项目模板:ASP.NET Core 空 / web

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Minimal.WebApi

    4. 其他 Visual Studio 选项:身份验证类型:无,为 HTTPS 配置:已选中,启用 Docker:已清除,启用 OpenAPI 支持:已选中。

  2. 在 Visual Studio Code 中,选择Minimal.WebApi作为活动的 OmniSharp 项目。

  3. Minimal.WebApi项目中,添加一个项目引用指向Northwind.Common项目,如下面的标记所示:

    <ItemGroup>
      <ProjectReference Include="..\Northwind.Common\Northwind.Common.csproj" />
    </ItemGroup> 
    
  4. 构建Minimal.WebApi项目。

  5. 修改Program.cs,如下面的代码中突出显示的那样:

    **using** **Northwind.Common;** **// WeatherForecast**
    var builder = WebApplication.CreateBuilder(args);
    **builder.WebHost.UseUrls(****"https://localhost:5003"****);**
    **builder.Services.AddCors();**
    var app = builder.Build();
    **// only allow the MVC client and only GET requests**
    **app.UseCors(configurePolicy: options =>**
    **{**
     **options.WithMethods(****"GET"****);**
     **options.WithOrigins(****"https://localhost:5001"****);**
    **});**
    **app.MapGet(****"/api/weather"****, () =>** 
    **{**
    **return** **Enumerable.Range(****1****,** **5****).Select(index =>**
    **new** **WeatherForecast**
     **{**
     **Date = DateTime.Now.AddDays(index),**
     **TemperatureC = Random.Shared.Next(****-20****,** **55****),**
     **Summary = WeatherForecast.Summaries[**
     **Random.Shared.Next(WeatherForecast.Summaries.Length)]**
     **})**
     **.ToArray();**
    **});**
    app.Run(); 
    

    良好实践:对于简单的 Web 服务,避免创建控制器类,而是使用最小 API 将所有配置和实现放在一个地方,即Program.cs

  6. 属性中,修改launchSettings.json以配置Minimal.WebApi配置文件,使其通过 URL 中的端口5003启动浏览器,如下面的标记中突出显示的那样:

    "profiles": {
      "Minimal.WebApi": {
        "commandName": "Project",
        "dotnetRunMessages": "true",
        "launchBrowser": true,
    **"applicationUrl"****:** **"https://localhost:5003/api/weather"****,**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        } 
    

测试最小天气服务

在创建服务客户端之前,让我们测试它是否返回 JSON 格式的预报:

  1. 启动 Web 服务项目。

  2. 如果你没有使用 Visual Studio 2022,请启动 Chrome 并导航至https://localhost:5003/api/weather

  3. 注意 Web API 服务应返回一个包含五个随机天气预报对象的 JSON 文档数组。

  4. 关闭 Chrome 并关闭 Web 服务器。

向 Northwind 网站首页添加天气预报

最后,让我们向 Northwind 网站添加一个 HTTP 客户端,以便它可以调用天气服务并在首页显示预报:

  1. Northwind.Mvc项目中,添加一个项目引用指向Northwind.Common,如下面的标记中突出显示的那样:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\Northwind.Common.DataContext.Sqlite\Northwind.Common.DataContext.Sqlite.csproj" />
     **<ProjectReference Include=****"..\Northwind.Common\Northwind.Common.csproj"** **/>**
    </ItemGroup> 
    
  2. Program.cs中,添加一条语句以配置 HTTP 客户端以调用端口5003上的最小服务,如下面的代码所示:

    builder.Services.AddHttpClient(name: "Minimal.WebApi",
      configureClient: options =>
      {
        options.BaseAddress = new Uri("https://localhost:5003/");
        options.DefaultRequestHeaders.Accept.Add(
          new MediaTypeWithQualityHeaderValue(
          "application/json", 1.0));
      }); 
    
  3. HomeController.cs中,导入Northwind.Common命名空间,并在Index方法中,添加语句以获取并使用 HTTP 客户端调用天气服务以获取预报并将其存储在ViewData中,如下面的代码所示:

    try
    {
      HttpClient client = clientFactory.CreateClient(
        name: "Minimal.WebApi");
      HttpRequestMessage request = new(
        method: HttpMethod.Get, requestUri: "api/weather");
      HttpResponseMessage response = await client.SendAsync(request);
      ViewData["weather"] = await response.Content
        .ReadFromJsonAsync<WeatherForecast[]>();
    }
    catch (Exception ex)
    {
      _logger.LogWarning($"The Minimal.WebApi service is not responding. Exception: {ex.Message}");
      ViewData["weather"] = Enumerable.Empty<WeatherForecast>().ToArray();
    } 
    
  4. Views/Home中,在Index.cshtml中,导入Northwind.Common命名空间,然后在顶部代码块中从ViewData字典获取天气预报,如下面的标记所示:

    @{
      ViewData["Title"] = "Home Page";
      string currentItem = "";
     **WeatherForecast[]? weather = ViewData[****"weather"****]** **as** **WeatherForecast[];**
    } 
    
  5. 在第一个<div>中,在渲染当前时间后,除非没有天气预报,否则添加标记以枚举天气预报,并以表格形式呈现,如下所示:

    <p>
      <h4>Five-Day Weather Forecast</h4>
      @if ((weather is null) || (!weather.Any()))
      {
        <p>No weather forecasts found.</p>
      }
      else
      {
      <table class="table table-info">
        <tr>
          @foreach (WeatherForecast w in weather)
          {
            <td>@w.Date.ToString("ddd d MMM") will be @w.Summary</td>
          }
        </tr>
      </table>
      }
    </p> 
    
  6. 启动Minimal.WebApi服务。

  7. 启动Northwind.Mvc网站。

  8. 导航至https://localhost:5001/,并注意天气预报,如图16.20所示:

    图 16.20:Northwind 网站主页上的五天天气预报

  9. 查看 MVC 网站的命令提示符或终端,并注意指示请求已发送到最小 API Web 服务api/weather端点的信息消息,大约耗时 83ms,如下所示:

    info: System.Net.Http.HttpClient.Minimal.WebApi.LogicalHandler[100]
          Start processing HTTP request GET https://localhost:5003/api/weather
    info: System.Net.Http.HttpClient.Minimal.WebApi.ClientHandler[100]
          Sending HTTP request GET https://localhost:5003/api/weather
    info: System.Net.Http.HttpClient.Minimal.WebApi.ClientHandler[101]
          Received HTTP response headers after 76.8963ms - 200
    info: System.Net.Http.HttpClient.Minimal.WebApi.LogicalHandler[101]
          End processing HTTP request after 82.9515ms – 200 
    
  10. 停止Minimal.WebApi服务,刷新浏览器,并注意几秒后 MVC 网站主页出现,但没有天气预报。

  11. 关闭 Chrome 并关闭 Web 服务器。

实践与探索

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

练习 16.1 – 测试您的知识

回答以下问题:

  1. 为了创建 ASP.NET Core Web API 服务的控制器类,您应该继承自哪个类?

  2. 如果您用[ApiController]属性装饰控制器类以获得默认行为,如对无效模型自动返回400响应,还需要做什么?

  3. 指定哪个控制器操作方法将执行以响应 HTTP 请求,您必须做什么?

  4. 指定调用操作方法时应预期哪些响应,您必须做什么?

  5. 列出三种可以调用的方法,以返回具有不同状态码的响应。

  6. 列出四种测试 Web 服务的方法。

  7. 尽管HttpClient实现了IDisposable接口,为何不应在using语句中包裹其使用以在完成时释放它,以及应使用什么替代方案?

  8. CORS 缩写代表什么,为何在 Web 服务中启用它很重要?

  9. 如何在 ASP.NET Core 2.2 及更高版本中使客户端能够检测您的 Web 服务是否健康?

  10. 端点路由提供了哪些好处?

练习 16.2 – 使用 HttpClient 练习创建和删除客户

扩展Northwind.Mvc网站项目,使其拥有页面,访客可以在其中填写表单以创建新客户,或搜索客户并删除他们。MVC 控制器应调用 Northwind Web 服务来创建和删除客户。

练习 16.3 – 探索主题

使用以下页面上的链接,深入了解本章涵盖的主题:

github.com/markjprice/cs10dotnet6/blob/main/book-links.md#chapter-16---building-and-consuming-web-services

总结

本章中,你学习了如何构建一个 ASP.NET Core Web API 服务,该服务可被任何能够发起 HTTP 请求并处理 HTTP 响应的平台上的应用调用。

你还学习了如何使用 Swagger 测试和文档化 Web 服务 API,以及如何高效地消费这些服务。

下一章,你将学习使用 Blazor 构建用户界面,这是微软推出的酷炫新技术,让开发者能用 C#而非 JavaScript 来构建网站的客户端单页应用(SPAs)、桌面混合应用,以及潜在的移动应用。

第十七章:使用 Blazor 构建用户界面

本章是关于使用 Blazor 构建用户界面的。我将描述 Blazor 的不同变体及其优缺点。

你将学会如何构建能在 Web 服务器或 Web 浏览器中执行代码的 Blazor 组件。当与 Blazor Server 托管时,它使用 SignalR 向浏览器中的用户界面发送必要的更新。当与 Blazor WebAssembly 托管时,组件在客户端执行其代码,并必须通过 HTTP 调用与服务器交互。

本章我们将探讨以下主题:

  • 理解 Blazor

  • 比较 Blazor 项目模板

  • 使用 Blazor Server 构建组件

  • 为 Blazor 组件抽象服务

  • 使用 Blazor WebAssembly 构建组件

  • 改进 Blazor WebAssembly 应用

理解 Blazor

Blazor 允许你使用 C#而非 JavaScript 构建共享组件和交互式 Web 用户界面。2019 年 4 月,微软宣布 Blazor“不再是实验性的,我们将承诺将其作为支持的 Web UI 框架发布,包括在浏览器中基于 WebAssembly 运行客户端的支持。”Blazor 支持所有现代浏览器。

JavaScript 及其伙伴们

传统上,任何需要在 Web 浏览器中执行的代码都是使用 JavaScript 编程语言或转译(转换或编译)成 JavaScript 的高级技术编写的。这是因为所有浏览器都支持 JavaScript 已有大约二十年,因此它已成为客户端实现业务逻辑的最低共同标准。

JavaScript 确实存在一些问题。尽管它与 C#和 Java 等 C 风格语言有表面上的相似性,但一旦深入挖掘,你会发现它实际上非常不同。它是一种动态类型的伪函数式语言,使用原型而非类继承来实现对象重用。它可能看起来像人类,但当它揭示出实际上是 Skrull 时,你会感到惊讶。

如果能在 Web 浏览器中使用与服务器端相同的语言和库,那该多好?

Silverlight – 使用插件的 C#和.NET

Microsoft 曾尝试通过一种名为 Silverlight 的技术实现此目标。2008 年发布的 Silverlight 2.0 允许 C#和.NET 开发者运用其技能构建在 Web 浏览器中通过 Silverlight 插件执行的库和可视组件。

到了 2011 年,随着 Silverlight 5.0 的推出,苹果公司在 iPhone 上的成功以及史蒂夫·乔布斯对 Flash 等浏览器插件的厌恶,最终导致微软放弃 Silverlight。因为与 Flash 类似,Silverlight 在 iPhone 和 iPad 上被禁用。

WebAssembly – Blazor 的目标

浏览器的一项最新发展为微软提供了再次尝试的机会。2017 年,WebAssembly 共识完成,所有主流浏览器现在都支持它:Chromium(Chrome、Edge、Opera、Brave)、Firefox 和 WebKit(Safari)。Blazor 不支持微软的 Internet Explorer,因为它是一个遗留的 Web 浏览器。

WebAssemblyWasm)是一种为虚拟机设计的二进制指令格式,它提供了一种在网页上以接近原生速度运行多种语言编写的代码的方法。Wasm 被设计为 C#等高级语言编译的便携目标。

理解 Blazor 托管模型

Blazor 是一个具有多种托管模型的单一编程或应用模型:

  • Blazor Server 运行于服务器端,因此你编写的 C#代码可以完全访问业务逻辑可能需要的所有资源,无需进行认证。然后,它使用 SignalR 将用户界面更新传达给客户端。

  • 服务器必须保持与每个客户端的实时 SignalR 连接,并跟踪每个客户端的当前状态,因此如果需要支持大量客户端,Blazor Server 的扩展性不佳。它于 2019 年 9 月作为 ASP.NET Core 3.0 的一部分首次发布,并包含在.NET 5.0 及更高版本中。

  • Blazor WebAssembly 运行于客户端,因此你编写的 C#代码仅能访问浏览器内的资源,并且必须在访问服务器资源前进行 HTTP 调用(可能需要认证)。它于 2020 年 5 月作为 ASP.NET Core 3.1 的扩展首次发布,版本号为 3.2,因为它是一个当前版本,因此不受 ASP.NET Core 3.1 长期支持的覆盖。Blazor WebAssembly 3.2 版本使用了 Mono 运行时和 Mono 库;.NET 5 及更高版本则使用 Mono 运行时和.NET 5 库。“Blazor WebAssembly 运行在.NET IL 解释器上,没有 JIT,因此它不会在速度竞赛中获胜。不过,我们在.NET 5 中实现了一些显著的速度改进,并预计在.NET 6 中进一步改善。”—Daniel Roth

  • .NET MAUI Blazor App,又称Blazor 混合模式,运行在.NET 进程中,使用本地互操作通道将 Web UI 渲染到 WebView 控件,并托管在.NET MAUI 应用中。其概念类似于使用 Node.js 的 Electron 应用。我们将在在线章节《第十九章:使用.NET MAUI 构建移动和桌面应用》中看到这种托管模型。

这种多宿主模型意味着,通过精心规划,开发者可以编写一次 Blazor 组件,然后既可以在 Web 服务器端、Web 客户端运行,也可以在桌面应用中运行。

尽管 Blazor Server 支持 Internet Explorer 11,但 Blazor WebAssembly 不支持。

Blazor WebAssembly 对渐进式 Web 应用PWAs)提供可选支持,这意味着网站访问者可以通过浏览器菜单将应用添加到桌面,并在离线状态下运行该应用。

理解 Blazor 组件

重要的是要理解 Blazor 用于创建用户界面组件。组件定义了如何渲染用户界面,响应用户事件,并且可以组合和嵌套,编译成 NuGet 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; }
} 

代码可以存储在一个名为Rating.razor.cs的单独代码隐藏文件中,而不是单个文件中既有标记又有@code块。该文件中的类必须是partial,并且与组件同名。

然后,您可以在网页上使用该组件,如下面的标记所示:

<h1>Review</h1>
<Rating id="rating" Maximum="5" Value="3" />
<textarea id="comment" /> 

有许多内置的 Blazor 组件,包括设置网页<head>部分中<title>等元素的组件,以及许多第三方供应商,他们会向您出售常见用途的组件。

未来,Blazor 可能不仅限于使用 Web 技术创建用户界面组件。微软有一个名为Blazor Mobile Bindings的实验性技术,允许开发者使用 Blazor 构建移动用户界面组件。它不是使用 HTML 和 CSS 构建 Web 用户界面,而是使用 XAML 和.NET MAUI 构建跨平台的图形用户界面。

Blazor 和 Razor 之间有什么区别?

您可能会好奇为什么 Blazor 组件使用.razor作为它们的文件扩展名。Razor 是一种模板标记语法,允许混合 HTML 和 C#。支持 Razor 语法的旧技术使用.cshtml文件扩展名来表示 C#和 HTML 的混合。

Razor 语法用于:

  • ASP.NET Core MVC 视图部分视图使用.cshtml文件扩展名。业务逻辑被分离到一个控制器类中,该类将视图视为模板,推送视图模型,然后将其输出到网页。

  • Razor Pages使用.cshtml文件扩展名。业务逻辑可以嵌入或分离到一个使用.cshtml.cs文件扩展名的文件中。输出是一个网页。

  • Blazor 组件使用.razor文件扩展名。输出不是一个网页,尽管可以使用布局将组件包装起来,使其输出为网页,并且可以使用@page指令分配一个定义 URL 路径以检索组件作为页面的路由。

比较 Blazor 项目模板

理解 Blazor Server 和 Blazor WebAssembly 托管模型之间选择的一种方法是审查它们默认项目模板之间的差异。

审查 Blazor Server 项目模板

让我们看看 Blazor Server 项目的默认模板。您会发现它大多与 ASP.NET Core Razor Pages 模板相同,有几个关键的添加:

  1. 使用您喜欢的代码编辑器添加一个新项目,如下表所定义:

    1. 项目模板:Blazor Server App / blazorserver

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.BlazorServer

    4. 其他 Visual Studio 选项:身份验证类型为 HTTPS 配置:已选中;启用 Docker:已清除

  2. 在 Visual Studio Code 中,选择Northwind.BlazorServer作为活动的 OmniSharp 项目。

  3. 构建Northwind.BlazorServer项目。

  4. Northwind.BlazorServer项目/文件夹中,打开Northwind.BlazorServer.csproj,注意它与使用 Web SDK 并针对.NET 6.0 的 ASP.NET Core 项目完全相同。

  5. 打开Program.cs,注意它几乎与 ASP.NET Core 项目相同。不同之处包括配置服务的部分,其中调用了AddServerSideBlazor方法,如下面的代码中突出显示的那样:

     builder.Services.AddRazorPages();
      **builder.Services.AddServerSideBlazor();**
      builder.Services.AddSingleton<WeatherForecastService>(); 
    
  6. 还要注意配置 HTTP 管道的部分,它添加了对MapBlazorHubMapFallbackToPage方法的调用,这些方法配置 ASP.NET Core 应用程序以接受 Blazor 组件的传入 SignalR 连接,而其他请求则回退到名为_Host.cshtml的 Razor 页面,如下面的代码中突出显示的那样:

    app.UseRouting();
    **app.MapBlazorHub();**
    **app.MapFallbackToPage(****"/_Host"****);**
    app.Run(); 
    
  7. Pages文件夹中,打开_Host.cshtml,注意它设置了一个名为_Layout的共享布局,并在服务器上预渲染了一个类型为App的 Blazor 组件,如下面的标记所示:

    @page "/"
    @namespace  Northwind.BlazorServer.Pages 
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 
    @{
      Layout = "_Layout";
    }
    <component type="typeof(App)" render-mode="ServerPrerendered" /> 
    
  8. Pages文件夹中,打开名为_Layout.cshtml的共享布局文件,如下面的标记所示:

    @using Microsoft.AspNetCore.Components.Web
    @namespace Northwind.BlazorServer.Pages
    @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
    <!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="css/bootstrap/bootstrap.min.css" />
      <link href="css/site.css" rel="stylesheet" />
      <link href="Northwind.BlazorServer.styles.css" rel="stylesheet" />
      <component type="typeof(HeadOutlet)" render-mode="ServerPrerendered" />
    </head>
    <body>
      @RenderBody()
      <div id="blazor-error-ui">
        <environment include="Staging,Production">
          An error has occurred. This application may no longer respond until reloaded.
        </environment>
        <environment include="Development">
          An unhandled exception has occurred. See browser dev tools for details.
        </environment>
        <a href="" class="reload">Reload</a>
        <a class="dismiss">![](https://gitee.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/cs10-dn6-mod-xplat-dev/img/B17442_19_001.png)</a>
      </div>
      <script src="img/blazor.server.js"></script>
    </body>
    </html> 
    

    在审查前面的标记时,请注意以下几点:

    • <div id="blazor-error-ui">用于显示 Blazor 错误,当发生错误时,它会在网页底部显示为黄色条。

    • blazor.server.js的脚本块管理返回到服务器的 SignalR 连接。

  9. Northwind.BlazorServer文件夹中,打开App.razor,注意它为当前程序集中找到的所有组件定义了一个Router,如下面的代码所示:

    <Router AppAssembly="@typeof(App).Assembly">
      <Found Context="routeData">
        <RouteView RouteData="@routeData"
                   DefaultLayout="@typeof(MainLayout)" />
        <FocusOnNavigate RouteData="@routeData" Selector="h1" />
      </Found>
      <NotFound>
        <PageTitle>Not found</PageTitle>
        <LayoutView Layout="@typeof(MainLayout)">
          <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
      </NotFound>
    </Router> 
    

    在审查前面的标记时,请注意以下几点:

    • 如果找到匹配的路由,则执行RouteView,它将组件的默认布局设置为MainLayout,并将任何路由数据参数传递给组件。

    • 如果没有找到匹配的路由,则执行LayoutView,它会在MainLayout内部渲染内部标记(在这种情况下,是一个带有消息的简单段落元素,告诉访问者此处没有任何内容)。

  10. Shared文件夹中,打开MainLayout.razor,注意它定义了一个侧边栏的<div>,其中包含由本项目中的NavMenu.razor组件文件实现的导航菜单,以及用于内容的 HTML5 元素,如<main><article>,如下面的代码所示:

    @inherits LayoutComponentBase
    <PageTitle>Northwind.BlazorServer</PageTitle>
    <div class="page">
      <div class="sidebar">
        <NavMenu />
      </div>
      <main>
        <div class="top-row px-4">
          <a href="https://docs.microsoft.com/aspnet/" 
             target="_blank">About</a>
        </div>
        <article class="content px-4">
          @Body
        </article>
      </main>
    </div> 
    
  11. Shared文件夹中,打开MainLayout.razor.css,注意它包含组件的隔离 CSS 样式。

  12. Shared文件夹中,打开NavMenu.razor,注意它有三个菜单项:首页计数器获取数据。这些是通过使用微软提供的名为NavLink的 Blazor 组件创建的,如下面的标记所示:

    <div class="top-row ps-3 navbar navbar-dark">
      <div class="container-fluid">
        <a class="navbar-brand" href="">Northwind.BlazorServer</a>
        <button title="Navigation menu" class="navbar-toggler" 
                @onclick="ToggleNavMenu">
          <span class="navbar-toggler-icon"></span>
        </button>
      </div>
    </div>
    <div class="@NavMenuCssClass" @onclick="ToggleNavMenu">
      <nav class="flex-column">
        <div class="nav-item px-3">
          <NavLink class="nav-link" href="" Match="NavLinkMatch.All">
            <span class="oi oi-home" aria-hidden="true"></span> Home
          </NavLink>
        </div>
        <div class="nav-item px-3">
          <NavLink class="nav-link" href="counter">
            <span class="oi oi-plus" aria-hidden="true"></span> Counter
          </NavLink>
        </div>
        <div class="nav-item px-3">
          <NavLink class="nav-link" href="fetchdata">
            <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
          </NavLink>
        </div>
      </nav>
    </div>
    @code {
      private bool collapseNavMenu = true;
      private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
      private void ToggleNavMenu()
      {
        collapseNavMenu = !collapseNavMenu;
      }
    } 
    
  13. Pages 文件夹中,打开 FetchData.razor 并注意它定义了一个组件,该组件从注入的依赖天气服务获取天气预报,然后将其呈现在表格中,如下所示:

    @page "/fetchdata"
    <PageTitle>Weather forecast</PageTitle>
    @using Northwind.BlazorServer.Data
    @inject WeatherForecastService ForecastService
    <h1>Weather forecast</h1>
    <p>This component demonstrates fetching data from a service.</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()
      {
        forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
      }
    } 
    
  14. Data 文件夹中,打开 WeatherForecastService.cs 并注意它不是一个 Web API 控制器类;它只是一个返回随机天气数据的普通类,如下所示:

    namespace Northwind.BlazorServer.Data
    {
      public class WeatherForecastService
      {
        private static readonly string[] Summaries = new[]
        {
          "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm",
          "Balmy", "Hot", "Sweltering", "Scorching"
        };
        public Task<WeatherForecast[]> GetForecastAsync(DateTime startDate)
        {
          return Task.FromResult(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());
        }
      }
    } 
    

理解 CSS 和 JavaScript 隔离

Blazor 组件通常需要提供自己的 CSS 来应用样式或 JavaScript 来执行不能纯粹在 C# 中完成的操作,例如访问浏览器 API。为了确保这不会与站点级别的 CSS 和 JavaScript 冲突,Blazor 支持 CSS 和 JavaScript 隔离。如果您有一个名为 Index.razor 的组件,只需创建一个名为 Index.razor.css 的 CSS 文件。该文件中定义的样式将覆盖项目中的任何其他样式。

理解 Blazor 路由到页面组件

我们在 App.razor 文件中看到的 Router 组件启用了组件的路由。创建组件实例的标记看起来像一个 HTML 标签,其中标签的名称是组件类型。组件可以使用元素嵌入到网页中,例如 <Rating Stars="5" />,或者可以像 Razor 页面或 MVC 控制器一样路由。

如何定义可路由的页面组件

要创建可路由的页面组件,请在组件的 .razor 文件顶部添加 @page 指令,如下所示:

@page "customers" 

上述代码相当于一个装饰有 [Route] 属性的 MVC 控制器,如下所示:

[Route("customers")]
public class CustomersController
{ 

Router 组件在其 AppAssembly 参数中专门扫描组件,这些组件装饰有 [Route] 属性,并注册它们的 URL 路径。

任何单页组件都可以有多个 @page 指令来注册多个路由。

在运行时,页面组件将与您指定的任何特定布局合并,就像 MVC 视图或 Razor 页面一样。默认情况下,Blazor Server 项目模板将 MainLayout.razor 定义为页面组件的布局。

最佳实践:按照惯例,将可路由的页面组件放在 Pages 文件夹中。

如何导航 Blazor 路由

Microsoft 提供了一个名为 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
    // set its value to USA
    Country = Country ?? "USA";
  }
} 

理解基础组件类

OnParametersSet方法由组件默认继承的基类ComponentBase定义,如下面的代码所示:

using Microsoft.AspNetCore.Components;
public abstract class ComponentBase : IComponent, IHandleAfterRender, IHandleEvent
{
  // members not shown
} 

ComponentBase有一些有用的方法,您可以调用和重写,如下表所示:

方法 描述
InvokeAsync 调用此方法以在关联渲染器的同步上下文中执行函数。
OnAfterRender, OnAfterRenderAsync 重写这些方法以在每次组件渲染后调用代码。
OnInitialized, OnInitializedAsync 重写这些方法以在组件从渲染树中的父级接收到初始参数后调用代码。
OnParametersSet, OnParametersSetAsync 重写这些方法以在组件接收到参数且值已分配给属性后调用代码。
ShouldRender 重写此方法以指示组件是否应进行渲染。
StateHasChanged 调用此方法以使组件重新渲染。

Blazor 组件可以以类似于 MVC 视图和 Razor 页面的方式拥有共享布局。

创建一个.razor组件文件,但需明确继承自LayoutComponentBase,如下面的标记所示:

@inherits LayoutComponentBase
<div>
  ...
  @Body
  ...
</div> 

基类有一个名为Body的属性,您可以在布局中的正确位置在标记中渲染它。

App.razor文件及其Router组件中为组件设置默认布局。要明确为组件设置布局,请使用@layout指令,如下面的标记所示:

@page "/customers"
@layout AlternativeLayout
<div>
  ...
</div> 

如何使用导航链接组件与路由

在 HTML 中,您使用<a>元素来定义导航链接,如下面的标记所示:

<a href="/customers">Customers</a> 

在 Blazor 中,使用<NavLink>组件,如下面的标记所示:

<NavLink href="/customers">Customers</NavLink> 

NavLink组件优于锚元素,因为它会自动将其类设置为active,如果其href与当前位置 URL 匹配。如果您的 CSS 使用不同的类名,则可以在NavLink.ActiveClass属性中设置类名。

默认情况下,在匹配算法中,href是一个路径前缀,因此如果NavLink具有/customershref,如前述代码示例所示,则它将匹配以下所有路径并将它们都设置为具有active类样式:

/customers
/customers/USA
/customers/Germany/Berlin 

为确保匹配算法仅对所有路径执行匹配,将Match参数设置为NavLinkMatch.All,如下面的代码所示:

<NavLink href="/customers" Match="NavLinkMatch.All">Customers</NavLink> 

如果您设置其他属性,如target,它们将传递给生成的底层<a>元素。

运行 Blazor Server 项目模板

现在我们已经回顾了项目模板以及 Blazor Server 特有的重要部分,我们可以启动网站并审查其行为:

  1. Properties文件夹中,打开launchSettings.json

  2. 修改 applicationUrl 以使用端口 5000 进行 HTTP 和端口 5001 进行 HTTPS,如图中突出显示的标记所示:

    "profiles": {
      "Northwind.BlazorServer": {
        "commandName": "Project",
        "dotnetRunMessages": true,
        "launchBrowser": true,
    **"applicationUrl"****:** **"https://localhost:5001;http://localhost:5000"****,**
        "environmentVariables": {
          "ASPNETCORE_ENVIRONMENT": "Development"
        }
      }, 
    
  3. 启动网站。

  4. 启动 Chrome。

  5. 导航至 https://localhost:5001/

  6. 在左侧导航菜单中,点击 获取数据,如图 17.1 所示:

    图 17.1: 在 Blazor Server 应用中获取天气数据

  7. 在浏览器地址栏中,将路由更改为 /apples 并注意缺失的消息,如图 17.2 所示:

    图 17.2: 缺失组件消息

  8. 关闭 Chrome 并关闭 Web 服务器。

审查 Blazor WebAssembly 项目模板

现在我们将创建一个 Blazor WebAssembly 项目。如果代码与 Blazor Server 项目中的代码相同,我将不在书中展示代码:

  1. 使用您喜欢的代码编辑器向 PracticalApps 解决方案或工作区添加一个新项目,如以下列表所定义:

    1. 项目模板: Blazor WebAssembly 应用 / blazorwasm

    2. 开关: --pwa --hosted

    3. 工作区/解决方案文件和文件夹: PracticalApps

    4. 项目文件和文件夹: Northwind.BlazorWasm

    5. 认证类型:

    6. 配置 HTTPS: 已勾选

    7. ASP.NET Core 托管: 已勾选

    8. 渐进式 Web 应用: 已勾选

    在审查生成的文件夹和文件时,请注意生成了三个项目,如下列列表所述:

    • Northwind.BlazorWasm.Client 是位于 Northwind.BlazorWasm\Client 文件夹中的 Blazor WebAssembly 项目。

    • Northwind.BlazorWasm.Server 是位于 Northwind.BlazorWasm\Server 文件夹中的一个 ASP.NET Core 项目网站,用于托管天气服务,该服务与之前一样返回随机天气预报,但实现为适当的 Web API 控制器类。项目文件具有对 SharedClient 的项目引用,以及支持服务器端 Blazor WebAssembly 的包引用。

    • Northwind.BlazorWasm.Shared 是位于 Northwind.BlazorWasm\Shared 文件夹中的类库,包含天气服务的模型。

    文件夹结构已简化,如图 17.3 所示:

    图 17.3: Blazor WebAssembly 项目模板的文件夹结构

    部署 Blazor WebAssembly 应用有两种方式。您可以通过将其发布的文件放置在任何静态托管 Web 服务器中来仅部署 Client 项目。它可以配置为调用您在 第十六章构建和消费 Web 服务 中创建的天气服务,或者您可以部署 Server 项目,该项目引用 Client 应用并托管天气服务和 Blazor WebAssembly 应用。应用放置在服务器网站 wwwroot 文件夹中,以及任何其他静态资产。您可以在以下链接中阅读有关这些选择的更多信息:docs.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/webassembly

  2. Client 文件夹中,打开 Northwind.BlazorWasm.Client.csproj 并注意,它使用了 Blazor WebAssembly SDK,并引用了两个 WebAssembly 包和 Shared 项目,以及支持 PWA 所需的服务工作者,如下所示:

    <Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <ServiceWorkerAssetsManifest>service-worker-assets.js
          </ServiceWorkerAssetsManifest>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include=
          "Microsoft.AspNetCore.Components.WebAssembly" 
          Version="6.0.0" />
        <PackageReference Include=
          "Microsoft.AspNetCore.Components.WebAssembly.DevServer" 
          Version="6.0.0" PrivateAssets="all" />
      </ItemGroup>
      <ItemGroup>
        <ProjectReference Include=
          "..\Shared\Northwind.BlazorWasm.Shared.csproj" />
      </ItemGroup>
      <ItemGroup>
        <ServiceWorker Include="wwwroot\service-worker.js" 
          PublishedContent="wwwroot\service-worker.published.js" />
      </ItemGroup>
    </Project> 
    
  3. Client 文件夹中,打开 Program.cs 并注意,主机构建器现在是为 WebAssembly 而不是服务器端 ASP.NET Core 配置的,并且它注册了一个用于进行 HTTP 请求的依赖服务,这是 Blazor WebAssembly 应用中极为常见的需求,如下所示:

    using Microsoft.AspNetCore.Components.Web;
    using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
    using Northwind.BlazorWasm.Client;
    var builder = WebAssemblyHostBuilder.CreateDefault(args); 
    builder.RootComponents.Add<App>("#app");
    builder.RootComponents.Add<HeadOutlet>("head::after");
    builder.Services.AddScoped(sp => new HttpClient
      { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
    await builder.Build().RunAsync(); 
    
  4. wwwroot 文件夹中,打开 index.html 并注意支持离线工作的 manifest.jsonservice-worker.js 文件,以及下载 Blazor WebAssembly 所有 NuGet 包的 blazor.webassembly.js 脚本,如下所示:

    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
      <title>Northwind.BlazorWasm</title>
      <base href="/" />
      <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
      <link href="css/app.css" rel="stylesheet" />
      <link href="Northwind.BlazorWasm.Client.styles.css" rel="stylesheet" />
      <link href="manifest.json" rel="manifest" />
      <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
      <link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
    </head>
    <body>
      <div id="app">Loading...</div>
      <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">![](https://gitee.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/cs10-dn6-mod-xplat-dev/img/B17442_19_001.png)</a>
      </div>
      <script src="img/blazor.webassembly.js"></script>
      <script>navigator.serviceWorker.register('service-worker.js');</script>
    </body>
    </html> 
    
  5. 请注意,以下 .razor 文件与 Blazor Server 项目中的文件相同:

    • App.razor

    • Shared\MainLayout.razor

    • Shared\NavMenu.razor

    • Shared\SurveyPrompt.razor

    • Pages\Counter.razor

    • Pages\Index.razor

  6. Pages 文件夹中,打开 FetchData.razor 并注意,除了注入用于进行 HTTP 请求的依赖服务外,标记与 Blazor Server 相同,如下所示:

    @page "/fetchdata"
    @using Northwind.BlazorWasm.Shared
    **@inject HttpClient Http**
    <h1>Weather forecast</h1>
    ...
    @code {
      private WeatherForecast[]? forecasts;
      protected override async Task OnInitializedAsync()
      {
     **forecasts =** **await**
     **Http.GetFromJsonAsync<WeatherForecast[]>(****"WeatherForecast"****);**
      }
    } 
    
  7. 启动 Northwind.BlazorWasm.Server 项目。

  8. 请注意,该应用的功能与之前相同。Blazor 组件代码现在在浏览器内部执行,而不是在服务器上。天气服务运行在 Web 服务器上。

  9. 关闭 Chrome 并关闭 Web 服务器。

使用 Blazor Server 构建组件

在本节中,我们将构建一个组件,用于在 Northwind 数据库中列出、创建和编辑客户。我们将首先为 Blazor Server 简单地构建它,然后重构它以同时支持 Blazor Server 和 Blazor WebAssembly。

定义和测试一个简单组件

我们将向现有的 Blazor Server 项目添加新组件:

  1. Northwind.BlazorServer 项目(不是 Northwind.BlazorWasm.Server 项目)中,在 Pages 文件夹中,添加一个名为 Customers.razor 的新文件。在 Visual Studio 中,项目项名为 Razor 组件

    最佳实践:组件文件名必须以大写字母开头,否则会出现编译错误!

  2. 添加语句以输出 Customers 组件的标题,并定义一个代码块,该代码块定义了一个用于存储国家名称的属性,如下所示:

    <h3>Customers@(string.IsNullOrWhiteSpace(Country) ? " Worldwide" : " in " + Country)</h3>
    @code {
      [Parameter]
      public string? Country { get; set; }
    } 
    
  3. Pages 文件夹中,在 Index.razor 组件中,向文件底部添加语句,实例化 Customers 组件两次,一次传递 Germany 作为国家参数,一次不设置国家,如下所示:

    <Customers Country="Germany" />
    <Customers /> 
    
  4. 启动 Northwind.BlazorServer 网站项目。

  5. 启动 Chrome。

  6. 导航至 https://localhost:5001/ 并注意 Customers 组件,如图 17.4 所示:

    图 17.4:设置国家参数为 Germany 的 Customers 组件和不设置国家参数的情况

  7. 关闭 Chrome 并停止网络服务器。

将组件制作成可路由的页面组件

将此组件转换为带有国家路由参数的可路由页面组件非常简单:

  1. Pages 文件夹中,在 Customers.razor 组件中,在文件顶部添加一条语句,将 /customers 注册为其路由,并带有可选的国家路由参数,如下列标记所示:

    @page "/customers/{country?}" 
    
  2. Shared 文件夹中,打开 NavMenu.razor 并添加两个列表项元素,用于我们的可路由页面组件,以显示全球和德国的客户,均使用人群图标,如下列标记所示:

    <div class="nav-item px-3">
      <NavLink class="nav-link" href="customers" Match="NavLinkMatch.All">
        <span class="oi oi-people" aria-hidden="true"></span>
        Customers Worldwide
      </NavLink>
    </div>
    <div class="nav-item px-3">
      <NavLink class="nav-link" href="customers/Germany">
        <span class="oi oi-people" aria-hidden="true"></span>
        Customers in Germany
      </NavLink>
    </div> 
    

    我们在客户菜单项中使用了人群图标。您可以在以下链接查看其他可用图标:iconify.design/icon-sets/oi/

  3. 启动网站项目。

  4. 启动 Chrome。

  5. 访问 https://localhost:5001/

  6. 在左侧导航菜单中,点击 德国客户,并注意国家名称正确传递给了页面组件,且该组件使用了与其他页面组件相同的共享布局,如 Index.razor

  7. 关闭 Chrome 并停止网络服务器。

将实体引入组件

既然您已经看到了组件的最小实现,我们可以为其添加一些有用的功能。在这种情况下,我们将使用 Northwind 数据库上下文从数据库中获取客户:

  1. Northwind.BlazorServer.csproj 中,添加对 Northwind 数据库上下文项目的引用,支持 SQL Server 或 SQLite,如以下标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\Northwind.Common.DataContext.Sqlite
    \Northwind.Common.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    
  2. 构建 Northwind.BlazorServer 项目。

  3. Program.cs 中,导入用于处理 Northwind 数据库上下文的命名空间,如下列代码所示:

    using Packt.Shared; // AddNorthwindContext extension method 
    
  4. 在配置服务的部分,添加一条语句以在依赖服务集合中注册 Northwind 数据库上下文,如下列代码所示:

    builder.Services.AddNorthwindContext(); 
    
  5. 打开 _Imports.razor 并导入用于处理 Northwind 实体的命名空间,以便我们构建的 Blazor 组件无需单独导入这些命名空间,如下列标记所示:

    @using Packt.Shared  @* Northwind entities *@ 
    

    _Imports.razor 文件仅适用于 .razor 文件。如果您使用代码后置 .cs 文件来实现组件代码,则它们必须单独导入命名空间或使用全局 using 隐式导入命名空间。

  6. Pages 文件夹中,在 Customers.razor 中,添加语句以注入 Northwind 数据库上下文,然后使用它输出所有客户的表格,如下列代码所示:

    @using Microsoft.EntityFrameworkCore  @* ToListAsync extension method *@
    @page "/customers/{country?}" 
    @inject NorthwindContext db
    <h3>Customers @(string.IsNullOrWhiteSpace(Country) 
          ? "Worldwide" : "in " + Country)</h3>
    @if (customers == 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="oi oi-pencil"></i></a>
            <a class="btn btn-danger" 
               href="deletecustomer/@c.CustomerId">
              <i class="oi oi-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 db.Customers.ToListAsync();
        }
        else
        {
          customers = await db.Customers
            .Where(c => c.Country == Country).ToListAsync();
        }
      }
    } 
    
  7. 启动 Northwind.BlazorServer 项目网站。

  8. 启动 Chrome。

  9. 访问 https://localhost:5001/

  10. 在左侧导航菜单中,点击 全球客户,并注意客户表格从数据库加载并在网页中渲染,如图 17.5 所示:

    图 17.5:全球客户列表

  11. 在左侧导航菜单中,点击德国客户,注意客户表此时仅显示德国客户。

  12. 在浏览器地址栏中,将Germany更改为UK,注意客户表此时仅显示英国客户。

  13. 在左侧导航菜单中,点击首页,注意客户组件作为嵌入页面的一部分时也能正确工作。

  14. 点击任意编辑或删除按钮,注意它们会返回一条消息,内容为对不起,此地址下没有任何内容,因为我们尚未实现该功能。

  15. 关闭浏览器。

  16. 关闭 Web 服务器。

为 Blazor 组件抽象服务

目前,Blazor 组件直接调用 Northwind 数据库上下文来获取客户信息。这在 Blazor 服务器上运行良好,因为组件在服务器上执行。但当该组件托管在 Blazor WebAssembly 上时,将无法工作。

我们将创建一个本地依赖服务,以实现组件的更好复用:

  1. Northwind.BlazorServer项目中,在Data文件夹中,添加一个名为INorthwindService.cs的新文件。(Visual Studio 项目项模板名为接口。)

  2. 修改其内容以定义一个本地服务的契约,该服务抽象了 CRUD 操作,如下所示:

    namespace Packt.Shared;
    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);
    } 
    
  3. Data文件夹中,添加一个名为NorthwindService.cs的新文件,并修改其内容以使用 Northwind 数据库上下文实现INorthwindService接口,如下所示:

    using Microsoft.EntityFrameworkCore; 
    namespace Packt.Shared;
    public class NorthwindService : INorthwindService
    {
      private readonly NorthwindContext db;
      public NorthwindService(NorthwindContext db)
      {
        this.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();
        }
      }
    } 
    
  4. Program.cs中,在配置服务的部分,添加一条语句,将NorthwindService注册为实现INorthwindService接口的瞬态服务,如下所示:

    builder.Services.AddTransient<INorthwindService, NorthwindService>(); 
    
  5. Pages文件夹中,打开Customers.razor文件,将注入 Northwind 数据库上下文的指令替换为注入已注册的 Northwind 服务的指令,如下所示:

    @inject INorthwindService service 
    
  6. 修改OnParametersSetAsync方法以调用服务,如下所示:

    protected override async Task OnParametersSetAsync()
    {
      if (string.IsNullOrWhiteSpace(Country))
      {
     **customers =** **await** **service.GetCustomersAsync();**
      }
      else
      {
     **customers =** **await** **service.GetCustomersAsync(Country);**
      }
    } 
    
  7. 启动Northwind.BlazorServer网站项目,并确认它保留了与之前相同的功能。

使用 EditForm 组件定义表单

Microsoft 提供了现成的组件用于构建表单。我们将使用它们来提供、创建和编辑客户的功能。

Microsoft 提供了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
  }
} 

作为ValidationSummary组件的替代,您可以使用ValidationMessage组件在单个表单元素旁边显示消息。

构建并使用客户表单组件

现在我们可以创建一个共享组件来创建或编辑客户:

  1. Shared文件夹中,创建一个名为CustomerDetail.razor的新文件。(Visual Studio 项目项模板名为Razor 组件。)此组件将在多个页面组件中重用。

  2. 修改其内容以定义一个表单来编辑客户属性,如下列代码所示:

    <EditForm Model="@Customer" OnValidSubmit="@OnValidSubmit">
      <DataAnnotationsValidator />
      <div class="form-group">
        <div>
          <label>Customer Id</label>
          <div>
            <InputText @bind-Value="@Customer.CustomerId" />
            <ValidationMessage For="@(() => Customer.CustomerId)" />
          </div>
        </div>
      </div>
      <div class="form-group ">
        <div>
          <label>Company Name</label>
          <div>
            <InputText @bind-Value="@Customer.CompanyName" />
            <ValidationMessage For="@(() => Customer.CompanyName)" />
          </div>
        </div>
      </div>
      <div class="form-group ">
        <div>
          <label>Address</label>
          <div>
            <InputText @bind-Value="@Customer.Address" />
            <ValidationMessage For="@(() => Customer.Address)" />
          </div>
        </div>
      </div>
      <div class="form-group ">
        <div>
          <label>Country</label>
          <div>
            <InputText @bind-Value="@Customer.Country" />
            <ValidationMessage For="@(() => Customer.Country)" />
          </div>
        </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; }
    } 
    
  3. Pages文件夹中,创建一个名为CreateCustomer.razor的新文件。这将是一个可路由的页面组件。

  4. 修改其内容以使用客户详情组件来创建新客户,如下列代码所示:

    @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");
      }
    } 
    
  5. Pages文件夹中,打开名为Customers.razor的文件,并在<h3>元素后添加一个带有按钮的<div>元素,以导航到createcustomer页面组件,如下列标记所示:

    <div class="form-group">
      <a class="btn btn-info" href="createcustomer">
      <i class="oi oi-plus"></i> Create New</a>
    </div> 
    
  6. Pages文件夹中,创建一个名为EditCustomer.razor的新文件,并修改其内容以使用客户详情组件来编辑和保存对现有客户的更改,如下列代码所示:

    @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; } 
      private Customer? customer = new();
      protected async override Task OnParametersSetAsync()
      {
        customer = await service.GetCustomerAsync(CustomerId);
      }
      private async Task Update()
      {
        if (customer is not null)
        {
          await service.UpdateCustomerAsync(customer);
        }
        navigation.NavigateTo("customers");
      }
    } 
    
  7. Pages文件夹中,创建一个名为DeleteCustomer.razor的新文件,并修改其内容以使用客户详情组件来显示即将被删除的客户,如下列代码所示:

    @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; } 
      private Customer? customer = new();
      protected async override Task OnParametersSetAsync()
      {
        customer = await service.GetCustomerAsync(CustomerId);
      }
      private async Task Delete()
      {
        if (customer is not null)
        {
          await service.DeleteCustomerAsync(CustomerId);
        }
        navigation.NavigateTo("customers");
      }
    } 
    

测试客户表单组件

现在我们可以测试客户表单组件以及如何使用它来创建、编辑和删除客户:

  1. 启动Northwind.BlazorServer网站项目。

  2. 启动 Chrome。

  3. 导航至https://localhost:5001/

  4. 导航至全球客户并点击**+ 创建新**按钮。

  5. 输入一个无效的客户 ID,如ABCDEF,离开文本框,并注意验证消息,如图17.6所示:

    图 17.6:创建新客户并输入无效的客户 ID

  6. 客户 ID更改为ABCDE,为其他文本框输入值,并点击创建客户按钮。

  7. 当客户列表出现时,滚动到页面底部以查看新客户。

  8. ABCDE客户行上,点击编辑图标按钮,更改地址,点击更新按钮,并注意客户记录已被更新。

  9. ABCDE客户行上,点击删除图标按钮,注意警告,点击删除客户按钮,并注意客户记录已被删除。

  10. 关闭 Chrome 并关闭 Web 服务器。

使用 Blazor WebAssembly 构建组件

现在我们将在 Blazor WebAssembly 项目中重用相同的功能,以便您可以清楚地看到关键差异。

由于我们在INorthwindService接口中抽象了本地依赖服务,我们将能够重用所有组件以及该接口和实体模型类。唯一需要重写的是NorthwindService类的实现。它不再直接调用NorthwindContext类,而是调用服务器端的一个自定义 Web API 控制器,如图17.7所示:

图 17.7:比较使用 Blazor Server 和 Blazor WebAssembly 的实现

配置 Blazor WebAssembly 服务器

首先,我们需要一个客户端应用可以调用的 Web 服务来获取和管理客户。如果你完成了第十六章构建和消费 Web 服务,那么你已经在Northwind.WebApi服务项目中拥有了一个客户服务,你可以使用它。然而,为了让本章更加自包含,让我们在Northwind.BlazorWasm.Server项目中构建一个客户 Web API 控制器:

警告! 与之前的项目不同,共享项目(如实体模型和数据库)的相对路径引用是两级向上,例如,"..\.."

  1. Server项目/文件夹中,打开Northwind.BlazorWasm.Server.csproj并添加语句以引用适用于 SQL Server 或 SQLite 的 Northwind 数据库上下文项目,如下列标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\..\Northwind.Common.DataContext.Sqlite
    \Northwind.Common.DataContext.Sqlite.csproj" />
    </ItemGroup> 
    
  2. 构建Northwind.BlazorWasm.Server项目。

  3. Server项目/文件夹中,打开Program.cs并添加一条语句以导入用于处理 Northwind 数据库上下文的命名空间,如下列代码所示:

    using Packt.Shared; 
    
  4. 在配置服务的部分,添加一条语句以注册 Northwind 数据库上下文,适用于 SQL Server 或 SQLite,如下列代码所示:

    // if using SQL Server
    builder.Services.AddNorthwindContext();
    // if using SQLite
    builder.Services.AddNorthwindContext(
      relativePath: Path.Combine("..", "..")); 
    
  5. Server项目中,在Controllers文件夹中,创建一个名为CustomersController.cs的文件,并添加语句以定义一个具有与之前类似的 CRUD 方法的 Web API 控制器类,如下列代码所示:

    using Microsoft.AspNetCore.Mvc; // [ApiController], [Route]
    using Microsoft.EntityFrameworkCore; // ToListAsync, FirstOrDefaultAsync
    using Packt.Shared; // NorthwindContext, Customer
    namespace Northwind.BlazorWasm.Server.Controllers;
    [ApiController]
    [Route("api/[controller]")]
    public class CustomersController : ControllerBase
    {
      private readonly NorthwindContext db;
      public CustomersController(NorthwindContext db)
      {
        this.db = db;
      }
      [HttpGet]
      public async Task<List<Customer>> GetCustomersAsync()
      {
        return await db.Customers.ToListAsync(); 
      }
      [HttpGet("in/{country}")] // different path to disambiguate
      public async Task<List<Customer>> GetCustomersAsync(string country)
      {
        return await db.Customers
          .Where(c => c.Country == country).ToListAsync();
      }
      [HttpGet("{id}")]
      public async Task<Customer?> GetCustomerAsync(string id)
      {
        return await db.Customers
          .FirstOrDefaultAsync(c => c.CustomerId == id);
      }
      [HttpPost]
      public async Task<Customer?> CreateCustomerAsync
        (Customer customerToAdd)
      {
        Customer? existing = await db.Customers.FirstOrDefaultAsync
          (c => c.CustomerId == customerToAdd.CustomerId);
        if (existing == null)
        {
          db.Customers.Add(customerToAdd);
          int affected = await db.SaveChangesAsync();
          if (affected == 1)
          {
            return customerToAdd;
          }
        }
        return existing;
      }
      [HttpPut]
      public async Task<Customer?> UpdateCustomerAsync(Customer c)
      {
        db.Entry(c).State = EntityState.Modified;
        int affected = await db.SaveChangesAsync();
        if (affected == 1)
        {
          return c;
        }
        return null;
      }
      [HttpDelete("{id}")]
      public async Task<int> DeleteCustomerAsync(string id)
      {
        Customer? c = await db.Customers.FirstOrDefaultAsync
          (c => c.CustomerId == id);
        if (c != null)
        {
          db.Customers.Remove(c);
          int affected = await db.SaveChangesAsync();
          return affected;
        }
        return 0;
      }
    } 
    

配置 Blazor WebAssembly 客户端

其次,我们可以重用 Blazor Server 项目中的组件。由于组件将保持一致,我们可以复制它们,并且只需对抽象的 Northwind 服务的本地实现进行更改:

  1. Client项目中,打开Northwind.BlazorWasm.Client.csproj并添加语句以引用适用于 SQL Server 或 SQLite 的 Northwind 实体模型库项目(非数据库上下文项目),如下列标记所示:

    <ItemGroup>
      <!-- change Sqlite to SqlServer if you prefer -->
      <ProjectReference Include="..\..\Northwind.Common.EntityModels.Sqlite\
    Northwind.Common.EntityModels.Sqlite.csproj" />
    </ItemGroup> 
    
  2. 构建Northwind.BlazorWasm.Client项目。

  3. Client项目中,打开_Imports.razor并导入Packt.Shared命名空间,以便在所有 Blazor 组件中提供 Northwind 实体模型类型,如下列代码所示:

    @using Packt.Shared 
    
  4. Client项目中,在Shared文件夹中,打开NavMenu.razor并添加一个用于全球和法国客户的NavLink元素,如下列标记所示:

    <div class="nav-item px-3">
      <NavLink class="nav-link" href="customers" Match="NavLinkMatch.All">
        <span class="oi oi-people" aria-hidden="true"></span>
        Customers Worldwide
      </NavLink>
    </div>
    <div class="nav-item px-3">
      <NavLink class="nav-link" href="customers/France">
        <span class="oi oi-people" aria-hidden="true"></span>
        Customers in France
      </NavLink>
    </div> 
    
  5. Northwind.BlazorServer项目的Shared文件夹复制CustomerDetail.razor组件到Northwind.BlazorWasm Client项目的Shared文件夹。

  6. Northwind.BlazorServer项目的Pages文件夹复制以下可路由页面组件到Northwind.BlazorWasm Client项目的Pages文件夹:

    • CreateCustomer.razor

    • Customers.razor

    • DeleteCustomer.razor

    • EditCustomer.razor

  7. Client项目中,创建一个Data文件夹。

  8. Northwind.BlazorServer项目Data文件夹中的INorthwindService.cs文件复制到Client项目Data文件夹中。

  9. Data文件夹中,添加一个名为NorthwindService.cs的新文件。

  10. 修改其内容以实现INorthwindService接口,使用HttpClient调用客户 Web API 服务,如下列代码所示:

    using System.Net.Http.Json; // GetFromJsonAsync, ReadFromJsonAsync
    using Packt.Shared; // Customer
    namespace Northwind.BlazorWasm.Client.Data
    {
      public class NorthwindService : INorthwindService
      {
        private readonly HttpClient http;
        public NorthwindService(HttpClient http)
        {
          this.http = http;
        }
        public Task<List<Customer>> GetCustomersAsync()
        {
          return http.GetFromJsonAsync
            <List<Customer>>("api/customers");
        }
        public Task<List<Customer>> GetCustomersAsync(string country)
        {
          return http.GetFromJsonAsync
            <List<Customer>>($"api/customers/in/{country}");
        }
        public Task<Customer> GetCustomerAsync(string id)
        {
          return http.GetFromJsonAsync
            <Customer>($"api/customers/{id}");
        }
        public async Task<Customer>
          CreateCustomerAsync (Customer c)
        {
          HttpResponseMessage response = await 
            http.PostAsJsonAsync("api/customers", c);
          return await response.Content
            .ReadFromJsonAsync<Customer>();
        }
        public async Task<Customer> UpdateCustomerAsync(Customer c)
        {
          HttpResponseMessage response = await 
            http.PutAsJsonAsync("api/customers", c);
          return await response.Content
            .ReadFromJsonAsync<Customer>();
        }
        public async Task DeleteCustomerAsync(string id)
        {
          HttpResponseMessage response = await     
            http.DeleteAsync($"api/customers/{id}");
        }
      }
    } 
    
  11. Program.cs中,导入Packt.SharedNorthwind.BlazorWasm.Client.Data命名空间。

  12. 在配置服务部分,添加一条语句以注册 Northwind 依赖服务,如下列代码所示:

    builder.Services.AddTransient<INorthwindService, NorthwindService>(); 
    

测试 Blazor WebAssembly 组件和服务

现在我们可以启动 Blazor WebAssembly 服务器托管项目,测试组件是否能与调用客户 Web API 服务的抽象 Northwind 服务协同工作:

  1. Server项目/文件夹中,启动Northwind.BlazorWasm.Server网站项目。

  2. 启动 Chrome,显示开发者工具,并选择网络标签页。

  3. 导航至https://localhost:5001/。由于端口号是随机分配的,您的端口号将有所不同。查看控制台输出以确定其具体值。

  4. 选择控制台标签页,注意 Blazor WebAssembly 已将.NET 程序集加载到浏览器缓存中,占用约 10MB 空间,如图17.8所示:

    图 17.8: Blazor WebAssembly 将.NET 程序集加载到浏览器缓存中

  5. 选择网络标签页。

  6. 在左侧导航菜单中,点击全球客户并注意包含所有客户的 JSON 响应的 HTTP GET请求,如图17.9所示:

    图 17.9: 包含所有客户的 JSON 响应的 HTTP GET 请求

  7. 点击**+ 创建新**按钮,按照之前的方式填写表格以添加新客户,并注意所发起的 HTTP POST请求,如图17.10所示:

    图 17.10: 创建新客户的 HTTP POST 请求

  8. 重复之前的步骤,编辑并删除新创建的客户。

  9. 关闭 Chrome 并停止 Web 服务器。

提升 Blazor WebAssembly 应用性能

提升 Blazor WebAssembly 应用性能的常见方法有多种。现在我们将探讨其中最受欢迎的几种。

启用 Blazor WebAssembly AOT 编译

默认情况下,Blazor WebAssembly 使用的.NET 运行时通过 WebAssembly 编写的解释器进行 IL 解释。与其他.NET 应用不同,它不使用即时(JIT)编译器,因此对于 CPU 密集型工作负载的性能低于预期。

.NET 6 中,微软新增了对预先(AOT)编译的支持,但你必须明确选择加入,因为尽管它能显著提升运行时性能,AOT 编译在小项目上可能需要几分钟,对于更大的项目则可能更久。编译后的应用大小也比非 AOT 编译的要大——通常是两倍。因此,是否使用 AOT 取决于编译和浏览器下载时间的增加与潜在更快的运行时之间的平衡。

AOT 是微软调查中需求最高的功能,缺乏 AOT 被认为是一些开发者尚未采用.NET 开发单页应用(SPA)的主要原因。

让我们安装名为**.NET WebAssembly 构建工具**的 Blazor AOT 所需额外工作负载,并为我们的 Blazor WebAssembly 项目启用 AOT:

  1. 在具有管理员权限的命令提示符或终端中,安装 Blazor AOT 工作负载,如下所示:

    dotnet workload install wasm-tools 
    
  2. 注意以下部分输出中的消息:

    ...
    Installing pack Microsoft.NET.Runtime.MonoAOTCompiler.Task version 6.0.0...
    Installing pack Microsoft.NETCore.App.Runtime.AOT.Cross.browser-wasm version 6.0.0...
    Successfully installed workload(s) wasm-tools. 
    
  3. 修改Northwind.BlazorWasm.Client项目文件以启用 AOT,如下所示突出显示:

    <PropertyGroup>
      <TargetFramework>net6.0</TargetFramework>
      <Nullable>enable</Nullable>
      <ImplicitUsings>enable</ImplicitUsings>
      <ServiceWorkerAssetsManifest>service-worker-assets.js
        </ServiceWorkerAssetsManifest>
     **<RunAOTCompilation>****true****</RunAOTCompilation>**
    </PropertyGroup> 
    
  4. 发布Northwind.BlazorWasm.Client项目,如下所示:

    dotnet publish -c Release 
    
  5. 注意,有 75 个程序集应用了 AOT,如下所示的部分输出:

     Northwind.BlazorWasm.Client -> C:\Code\PracticalApps\Northwind.BlazorWasm\Client\bin\Release\net6.0\Northwind.BlazorWasm.Client.dll
      Northwind.BlazorWasm.Client (Blazor output) -> C:\Code\PracticalApps\Northwind.BlazorWasm\Client\bin\Release\net6.0\wwwroot
      Optimizing assemblies for size, which may change the behavior of the app. Be sure to test after publishing. See: https://aka.ms/dotnet-illink
      AOT'ing 75 assemblies
      [1/75] Microsoft.Extensions.Caching.Abstractions.dll -> Microsoft.Extensions.Caching.Abstractions.dll.bc
      ...
      [75/75] Microsoft.EntityFrameworkCore.Sqlite.dll -> Microsoft.EntityFrameworkCore.Sqlite.dll.bc
      Compiling native assets with emcc. This may take a while ...
      ...
      Linking with emcc. This may take a while ...
      ...
      Optimizing dotnet.wasm ...
      Compressing Blazor WebAssembly publish artifacts. This may take a while... 
    
  6. 等待进程完成。即使在现代多核 CPU 上,此过程也可能需要大约 20 分钟。

  7. 导航至Northwind.BlazorWasm\Client\bin\release\net6.0\publish文件夹,并注意下载大小从 10 MB 增加到了 112 MB。

没有 AOT,下载的 Blazor WebAssembly 应用大约占用 10 MB 空间。使用 AOT 后,占用空间约为 112 MB。这种大小的增加会影响网站访问者的体验。

AOT 的使用是在初始下载较慢和潜在执行更快之间的一种平衡。根据你的应用的具体情况,AOT 可能并不值得。

探索渐进式 Web 应用支持

Blazor WebAssembly 项目中的渐进式 Web 应用(PWA)支持意味着 Web 应用获得以下好处:

  • 它作为普通网页运行,直到访问者明确决定升级到完整的应用体验。

  • 应用安装后,可从操作系统的开始菜单或桌面启动。

  • 它在独立的应用窗口中显示,而不是浏览器标签页。

  • 它支持离线工作。

  • 它自动更新。

让我们看看 PWA 支持的实际效果:

  1. 启动Northwind.BlazorWasm.ServerWeb 主机项目。

  2. 导航至https://localhost:5001/或你的端口号。

  3. 在 Chrome 中,在地址栏右侧,点击带有提示安装 Northwind.BlazorWasm的图标,如图 17.11所示:

    图 17.11:将 Northwind.BlazorWasm 安装为应用

  4. 点击安装按钮。

  5. 关闭 Chrome。如果应用自动运行,你可能还需要关闭应用。

  6. 从 Windows 开始菜单或 macOS Launchpad 启动Northwind.BlazorWasm应用,并注意它具有完整的应用体验。

  7. 在标题栏右侧,点击三个点菜单,并注意您可以卸载应用,但暂时不要这样做。

  8. 导航到开发者工具。在 Windows 上,按 F12 或 Ctrl + Shift + I。在 macOS 上,按 Cmd + Shift + I。

  9. 选择网络选项卡,然后在节流下拉菜单中选择离线预设。

  10. 在左侧导航菜单中,点击主页,然后点击全球客户,并注意无法加载任何客户以及应用窗口底部的错误消息,如图17.12所示:

    图 17.12:当网络离线时无法加载任何客户

  11. 开发者工具中,将节流设置回已禁用:无节流

  12. 点击应用底部黄色错误栏中的重新加载链接,并注意功能恢复。

  13. 您现在可以卸载 PWA 应用或只是关闭它。

为 PWA 实现离线支持

我们可以通过本地缓存 Web API 服务的 HTTP GET响应,本地存储新、修改或删除的客户,然后在网络连接恢复后通过发出存储的 HTTP 请求与服务器同步来改善体验。但这需要大量努力才能实现良好,因此超出了本书的范围。

理解 Blazor WebAssembly 的浏览器兼容性分析器

使用.NET 6,微软已经统一了所有工作负载的.NET 库。然而,虽然在理论上这意味着 Blazor WebAssembly 应用可以完全访问所有.NET API,但实际上它在浏览器沙箱中运行,因此存在限制。如果您调用一个不支持的 API,这将抛出一个PlatformNotSupportedException

为了预先警告不支持的 API,您可以添加一个平台兼容性分析器,当您的代码使用浏览器不支持的 API 时,它会警告您。

Blazor WebAssembly 应用Razor 类库项目模板会自动启用浏览器兼容性检查。

要在类库项目中手动激活浏览器兼容性检查,例如,向项目文件添加一个条目,如下面的标记所示:

<ItemGroup>
  <SupportedPlatform Include="browser" />
</ItemGroup> 

微软装饰不支持的 API,如下列代码所示:

[UnsupportedOSPlatform("browser")]
public void DoSomethingOutsideTheBrowserSandbox()
{
  ...
} 

良好实践:如果您创建的库不应在 Blazor WebAssembly 应用中使用,那么您应该以相同方式装饰您的 API。

在类库中共享 Blazor 组件

我们目前在一个 Blazor 服务器项目和一个 Blazor WebAssembly 项目中重复了组件。最好在类库项目中定义它们一次,并从另外两个 Blazor 项目引用它们。

让我们创建一个新的 Razor 类库:

  1. 使用您喜欢的代码编辑器添加一个新项目,如下列清单所定义:

    1. 项目模板:Razor 类库 / razorclasslib

    2. 工作区/解决方案文件和文件夹:PracticalApps

    3. 项目文件和文件夹:Northwind.Blazor.Customers

    4. 支持页面和视图:已勾选

  2. Northwind.Blazor.Customers项目中,添加对Northwind.Common.EntityModels.SqliteSqlServer项目的项目引用。

  3. Northwind.Blazor.Customers项目中,添加一个条目以检查浏览器兼容性,如下所示突出显示:

    <Project Sdk="Microsoft.NET.Sdk.Razor">
      <PropertyGroup>
        <TargetFramework>net6.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
        <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
      </PropertyGroup>
      <ItemGroup>
        <FrameworkReference Include="Microsoft.AspNetCore.App" />
      </ItemGroup>
      <ItemGroup>
        <ProjectReference Include="..\Northwind.Common.EntityModels.Sqlite
    \Northwind.Common.EntityModels.Sqlite.csproj" />
      </ItemGroup>
     **<ItemGroup>**
     **<SupportedPlatform Include=****"browser"** **/>**
     **</ItemGroup>**
    </Project> 
    
  4. Northwind.BlazorServer项目中,添加对Northwind.Blazor.Customers项目的项目引用。

  5. 构建Northwind.BlazorServer项目。

  6. Northwind.Blazor.Customers项目中,删除Areas文件夹及其所有内容。

  7. Northwind.BlazorServer项目根目录下的_Imports.razor文件复制到Northwind.Blazor.Customers项目根目录下。

  8. _Imports.razor文件中,删除对Northwind.BlazorServer命名空间的两个导入,并添加一条语句以导入将包含我们共享 Blazor 组件的命名空间,如下所示:

    @using Northwind.Blazor.Customers.Shared 
    
  9. 创建三个名为DataPagesShared的文件夹。

  10. INorthwindService.csNorthwind.BlazorServer项目的Data文件夹移动到Northwind.Blazor.Customers项目的Data文件夹。

  11. Northwind.BlazorServer项目Shared文件夹中的所有组件移动到Northwind.Blazor.Customers项目Shared文件夹中。

  12. CreateCustomer.razorCustomers.razorEditCustomer.razorDeleteCustomer.razor组件从Northwind.BlazorServer项目的Pages文件夹移动到Northwind.Blazor.Customers项目的Pages文件夹。

    我们将保留其他页面组件,因为它们依赖于尚未正确重构的天气服务。

  13. Northwind.BlazorServer项目中,在_Imports.razor文件中,移除对Northwind.BlazorServer.Sharedusing语句,并添加语句以导入类库中的页面和共享组件,如下所示:

    @using Northwind.Blazor.Customers.Pages
    @using Northwind.Blazor.Customers.Shared 
    
  14. Northwind.BlazorServer项目中,在App.razor中,添加一个参数以告诉Router组件扫描额外的程序集,为类库中的页面组件设置路由,如下所示突出显示:

    <Router AppAssembly="@typeof(App).Assembly"
            **AdditionalAssemblies=****"new[] { typeof(Customers).Assembly }"**> 
    

    良好实践:指定哪个类并不重要,只要它在外部程序集中即可。我选择了Customers,因为它是其中最重要且明显的组件类。

  15. 启动Northwind.BlazorServer项目,并注意其行为与之前相同。

    良好实践:你现在可以在其他 Blazor Server 项目中重用这些 Blazor 组件。但是,你不能在 Blazor WebAssembly 项目中使用该类库,因为它依赖于完整的 ASP.NET Core 工作负载。创建适用于两种托管模型的 Blazor 组件库超出了本书的范围。

与 JavaScript 互操作

默认情况下,Blazor 组件无法访问浏览器功能,如本地存储、地理位置和媒体捕获,也无法访问任何 JavaScript 库,如 React 或 Vue。如果需要与它们交互,可以使用 JavaScript 互操作。

让我们看一个使用浏览器窗口的警告框和本地存储的示例,该存储可以无限期地为每位访客持久保存最多 5MB 的数据:

  1. Northwind.BlazorServer项目中,在wwwroot文件夹下,添加一个名为scripts的文件夹。

  2. scripts文件夹中,添加一个名为interop.js的文件。

  3. 修改其内容,如下代码所示:

    function messageBox(message) {
      window.alert(message);
    }
    function setColorInStorage() {
      if (typeof (Storage) !== "undefined") {
        localStorage.setItem("color", 
          document.getElementById("colorBox").value);
      }
    }
    function getColorFromStorage() {
      if (typeof (Storage) !== "undefined") {
        document.getElementById("colorBox").value = 
          localStorage.getItem("color");
      }
    } 
    
  4. Pages文件夹中的_Layout.cshtml文件里,在添加了 Blazor Server 支持的script元素之后,添加一个引用你刚创建的 JavaScript 文件的script元素,如下代码所示:

    <script src="img/interop.js"></script> 
    
  5. Pages文件夹中的Index.razor文件里,删除两个Customers组件实例,然后添加一个按钮和一个代码块,该代码块使用 Blazor JavaScript 运行时依赖服务来调用一个 JavaScript 函数,如下代码所示:

    <button type="button" class="btn btn-info" @onclick="AlertBrowser">
      Poke the browser</button>
    <hr />
    <input id="colorBox" />
    <button type="button" class="btn btn-info" @onclick="SetColor">
      Set Color</button>
    <button type="button" class="btn btn-info" @onclick="GetColor">
      Get Color</button>
    @code {
      [Inject]
      public IJSRuntime JSRuntime { get; set; } = null!;
      public async Task AlertBrowser()
      {
        await JSRuntime.InvokeVoidAsync(
          "messageBox", "Blazor poking the browser");
      }
    public async Task SetColor()
      {
        await JSRuntime.InvokeVoidAsync("setColorInStorage");
      }
      public async Task GetColor()
      {
        await JSRuntime.InvokeVoidAsync("getColorFromStorage");
      }
    } 
    
  6. 启动Northwind.BlazorServer项目。

  7. 启动 Chrome 并导航至https://localhost:5001/

  8. 在首页的文本框中输入红色,然后点击设置颜色按钮。

  9. 显示开发者工具,选择应用程序标签,展开本地存储,选择https://localhost:5001,并注意键值对color-red,如图17.13所示:

    图 17.13:使用 JavaScript 互操作在浏览器本地存储中存储颜色

  10. 关闭 Chrome 并停止 Web 服务器。

  11. 启动Northwind.BlazorServer项目。

  12. 启动 Chrome 并导航至https://localhost:5001/

  13. 在首页上,点击获取颜色按钮,并注意文本框中显示的值红色,这是从访客会话间的本地存储中检索得到的。

  14. 关闭 Chrome 并停止 Web 服务器。

Blazor 组件库

Blazor 组件库众多,付费组件库来自 Telerik、DevExpress 和 Syncfusion 等公司。开源 Blazor 组件库包括以下内容:

实践与探索

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

练习 17.1 – 测试你的知识

回答以下问题:

  1. Blazor 的两种主要托管模型是什么,它们有何不同?

  2. 在 Blazor Server 网站项目中,与 ASP.NET Core MVC 网站项目相比,Startup 类中需要额外配置什么?

  3. Blazor 的一大优势是能够使用 C#和.NET 而非 JavaScript 来实现客户端组件。Blazor 组件是否需要任何 JavaScript?

  4. 在 Blazor 项目中,App.razor文件的作用是什么?

  5. 使用<NavLink>组件的好处是什么?

  6. 如何将值传递给组件?

  7. 使用<EditForm>组件的好处是什么?

  8. 如何在参数设置时执行某些语句?

  9. 如何在组件出现时执行某些语句?

  10. Blazor Server 和 Blazor WebAssembly 项目中Program类的两个关键区别是什么?

练习 17.2 – 通过创建乘法表组件来实践

创建一个根据名为Number的参数渲染乘法表的组件,然后通过两种方式测试你的组件。

首先,通过在Index.razor文件中添加你的组件实例,如下所示标记:

<timestable Number="6" /> 

其次,通过在浏览器地址栏中输入路径,如下所示链接:

https://localhost:5001/timestable/6

练习 17.3 – 通过创建国家导航项来实践

CustomersController类添加一个动作方法,以返回国家名称列表。

在共享的NavMenu组件中,调用客户的 web 服务以获取国家名称列表,并遍历它们,为每个国家创建一个菜单项。

练习 17.4 – 探索主题

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

第十七章 - 使用 Blazor 构建用户界面

总结

在本章中,你学习了如何构建托管在 Server 和 WebAssembly 上的 Blazor 组件。你看到了两种托管模型之间的一些关键差异,例如如何使用依赖服务管理数据。

第十八章:后记

我希望这本书能与众不同,不同于市场上的其他书籍。愿你发现它是一本轻松有趣、充满实用操作指南的读物。

本后记包含以下简短部分:

  • 你 C# 和 .NET 学习之旅的下一步

  • .NET MAUI 延迟发布

  • 下一版将于 2022 年 11 月推出

  • 祝你好运!

你 C# 和 .NET 学习之旅的下一步

对于那些你想深入了解但本书篇幅有限的主题,希望 GitHub 仓库中的笔记、良好实践提示和链接能为你指明正确的方向:

github.com/markjprice/cs10dotnet6/blob/main/book-links.md

通过设计指南磨练技能

既然你已学会使用 C# 和 .NET 开发的基础知识,你已准备好通过学习更详细的设计指南来提升代码质量。

早在 .NET Framework 时代,Microsoft 就出版了一本涵盖 .NET 开发各领域最佳实践的书籍。那些建议至今仍非常适用于现代 .NET 开发。

以下主题涵盖在内:

  • 命名规范

  • 类型设计指南

  • 成员设计指南

  • 设计可扩展性

  • 异常设计指南

  • 使用指南

  • 常见设计模式

为使指导尽可能易于遵循,建议仅用 DoConsiderAvoidDo not 等术语标注。

Microsoft 已在以下链接提供本书摘录:

docs.microsoft.com/en-us/dotnet/standard/design-guidelines/

我强烈建议你审阅所有指南并将其应用于你的代码。

进一步学习的书籍推荐

若你正在寻找我出版社出版的其他相关主题书籍,选择众多。我推荐 Harrison Ferrone 的 Learning C# by Developing Games with Unity 2021,作为学习 C# 的有趣补充读物。

还有许多书籍将 C# 和 .NET 的学习推向深入,如图 18.1 所示:

图 18.1:Packt 出版的书籍,助你深入学习 C# 和 .NET

.NET MAUI 延迟发布

Microsoft 原计划随 .NET 6 发布 .NET MAUI,但团队在 2021 年 9 月意识到无法按期达成目标。他们需要额外六个月时间以确保其满足质量和性能预期。关于 .NET MAUI 延迟的官方声明,请访问以下链接:

devblogs.microsoft.com/dotnet/update-on-dotnet-maui/

我预计.NET MAUI 将在 2022 年 5 月的微软 Build 大会上发布正式版。在此之前,团队将每月发布预览版。虽然我不能做出承诺,但我希望利用这些预览版在本书的 GitHub 仓库上更新.NET MAUI 章节,或者至少在最终 GA 版发布时进行更新。

下一版将于 2022 年 11 月推出

我已经开始着手第七版的编写工作,我们计划在 2022 年 11 月.NET 7.0 发布时出版。虽然我不期待会有像 Blazor 或.NET MAUI 那样重大的新功能,但我确实期待.NET 7.0 能在.NET 的各个方面带来有价值的改进。

如果您有希望看到的内容建议,或者希望某些主题得到扩展,或者在文本或代码中发现了需要修正的错误,请通过本书的 GitHub 仓库告知我详细信息,链接如下:

github.com/markjprice/cs10dotnet6

祝好运!

祝您在所有的 C#和.NET 项目中好运连连!

分享您的想法

您已经完成了*《C# 10 与 .NET 6 - 现代跨平台开发(第六版)》*,我们非常期待听到您的想法!如果您从亚马逊购买了这本书,请点击这里直接前往亚马逊的评论页面,分享您的反馈或在该购书网站上留下评论。

您的评价对我们和整个技术社区至关重要,它将帮助我们确保提供高质量的内容。

posted @ 2024-05-17 17:50  绝不原创的飞龙  阅读(33)  评论(0编辑  收藏  举报