C-9-和--NET5-高级教程-全-

C#9 和 .NET5 高级教程(全)

原文:Pro C# 9 with .NET 5

协议:CC BY-NC-SA 4.0

一、C# 和 .NET(Core)5 简介

微软的。NET 平台和 C# 编程语言大约在 2002 年正式引入,并迅速成为现代软件开发的中流砥柱。的。NET 平台使得大量的编程语言(包括 C#、VB.NET 和 F#)能够相互交互。用 C# 写的程序可以被用 VB.NET 写的另一个程序引用。本章稍后将详细介绍这种互操作性。

2016 年,微软正式推出。NET 核心。比如。网,。NET Core 允许语言之间的互操作(尽管支持的语言数量有限)。更重要的是,这个新框架不再局限于在 Windows 操作系统上运行,还可以在 iOS 和 Linux 上运行(并被开发)。这种平台独立性向更多的开发人员开放了 C#。而之前的版本支持跨平台使用 C#。NET 核心,这是通过各种其他框架,如 Mono 项目。

Note

你可能对章节标题中的括号感到疑惑。随着的发布。NET 5,名称中的“核心”部分被去掉,以表明这个版本是所有. NET 的统一。网芯和。为了清楚起见,请使用. NET Framework。

2020 年 11 月 10 日,微软推出 C# 9 和。净 5。像 C# 8 一样,C# 9 被绑定到框架的一个特定版本,只能在。NET 5.0 及以上。与框架版本绑定的语言版本让 C# 团队可以自由地将由于框架限制而无法添加到语言中的特性引入到 C# 中。

正如在书的介绍中提到的,这篇文章的目标是双重的。首要任务是为您提供对 C# 语法和语义的深入而详细的研究。第二个(同样重要的)事项是说明许多。NET 核心 API。其中包括使用 ADO.NET 和实体框架(EF)核心的数据库访问,使用 WPF(WPF)的用户界面,以及使用 ASP.NET 核心的 RESTful 服务和 web 应用。俗话说,千里之行,始于足下;就这样,我欢迎你来到第一章。

这第一章为本书的其余部分奠定了概念基础。在这里,您会发现一些高层次的讨论。NET 相关的主题,如程序集、公共中间语言(CIL)和实时(JIT)编译。除了预览 C# 编程语言的一些关键字之外,您还将逐渐理解。NET 核心框架。这包括。NET 运行库,它结合了。NET 核心公共语言运行库(CoreCLR)和。NET 核心库(CoreFX)合并成一个代码库;通用类型系统(CTS);通用语言规范(CLS);还有。NET 标准。

本章还概述了提供的功能。NET 核心基类库,有时缩写为 bcl。在这里,您将对。NET 核心平台。正如您所料,这些主题将在本文的剩余部分进行更详细的探讨。

Note

本章(以及整本书)强调的许多特征也适用于原著。NET 框架。在本书中,我总是使用这些术语。NET 核心框架和。NET 核心运行时的一般术语。NET 来明确说明这些功能在。NET 核心。

探索的一些主要优势.NETCore 平台

那个。NET 核心框架是一个软件平台,用于在 Windows、iOS 和 Linux 操作系统上构建 web 应用和服务系统,以及在 Windows 操作系统上构建 WinForms 和 WPF 应用。为了做好准备,以下是提供的一些核心特性的简要介绍.NETCore:

  • 与现有代码的互操作性:这(当然)是一件好事。现存的。NET Framework 软件可以与较新的。NET 核心软件,反之亦然,通过。净标准。

  • 支持多种编程语言:。NET 核心应用可以使用 C#、F# 和 VB.NET 编程语言创建(C# 和 F# 是 ASP.NET 核心的主要语言)。

  • 由所有人共享的公共运行时引擎。NET 核心语言:这个引擎的一个方面是一组定义良好的类型。网芯语言懂。

  • 语言整合:。NET Core 支持代码的跨语言继承、跨语言异常处理和跨语言调试。例如,您可以在 C# 中定义一个基类,并在 Visual Basic 中扩展此类型。

  • 一个 全面的基础类库:这个库提供了数千种预定义的类型,让你可以构建代码库、简单的终端应用、图形化桌面应用、企业级网站。

  • 一个 简化部署模型:。NET Core 库没有注册到系统注册表中。此外。NET 核心平台允许多个版本的框架和应用在一台机器上和谐共存。

  • 广泛的命令行支持:即。NET Core 命令行界面(CLI)是一个用于开发和打包的跨平台工具链。NET 核心应用。除了随附的标准工具之外,还可以(全局或本地)安装其他工具。NET Core SDK。

在接下来的章节中,你将会看到这些主题中的每一个(以及更多的主题)。但首先,我需要解释新的支持生命周期。NET 核心。

了解.NETCore 支持生命周期

。NET 核心版本的发布比。NET 框架。对于所有这些可用的版本,可能很难跟上,尤其是在企业开发环境中。为了更好地定义版本的支持生命周期,微软采用了长期支持模型的变体,现代开源框架通常使用的 1

长期支持(LTS)版本是将获得长期支持的主要版本。在他们的整个生命周期中,他们只会收到关键的和/或非破坏性的修复。在停产之前,LTS 版本将更改为维护名称。LTS 发布。NET Core 将在以下时间段内获得支持,以时间较长者为准:

  • 首次发布三年后

  • 后续 LTS 版本发布后的一年维护支持

Microsoft 已经决定将短期支持版本命名为当前版本,它是主要 LTS 版本之间的间隔版本。在随后的当前版本或 LTS 版本之后,它们将获得三个月的支持。

如前所述。NET 5 于 2020 年 11 月 10 日发布。它是作为当前版本发布的,而不是 LTS 版本。这意味着。NET 5 将在下一个版本发布三个月后停止支持。。2019 年 12 月发布的 NET Core 3.1 是 LTS 版本,完全支持到 2022 年 12 月 3 日。

Note

的下一个计划发布。NET 是。网 6,定于 2021 年 11 月。大约可以支持 15 个月。净 5。然而,如果微软决定发布一个补丁(例如,5.1),那么三个月的时间将随着该版本开始计时。我建议您在选择开发生产应用的版本时,考虑一下这个支持策略。澄清一下,我不是说你应该用而不是。净 5。我强烈建议您在选择时了解支持政策。生产应用开发的. NET(核心)版本。

检查每个新版本的支持策略非常重要。发布的 NET Core。只是有一个较高的数字并不一定意味着它会得到长期支持。完整的政策位于此处:

https://dotnet.microsoft.com/platform/support-policy/dotnet-core

预览的构造块。NET 核心平台(。NET 运行时、CTS 和 CLS)

现在您已经了解了。NET Core,让我们预览一下使这一切成为可能的关键(和相关的)主题:核心运行时(以前是 CoreCLR 和 CoreFX)、CTS 和 CLS。从程序员的角度来看。NET Core 可以理解为一个运行时环境,一个综合的基础类库。运行时层包含一组特定于平台(Windows、iOS、Linux)和架构(x86、x64、ARM)的最小实现,以及。NET 核心。

的另一个构造块。NET 核心平台是通用类型系统,或 CTS 。CTS 规范完整地描述了运行库支持的所有可能的数据类型和所有编程构造,指定了这些实体如何相互交互,并详细说明了它们如何在。NET Core 元数据格式(本章后面有更多关于元数据的信息;详见第十七章。

明白一个给定的。NET 核心语言可能不支持 CTS 定义的所有功能。公共语言规范,或 CLS ,是一个相关的规范,它定义了所有公共类型和编程结构的子集。NET 核心编程语言可以达成一致。因此如果你建造。NET 核心类型只公开 CLS 兼容的功能,您可以放心,所有。NET 核心语言可以消费它们。相反,如果您使用 CLS 范围之外的数据类型或编程构造,则不能保证每个。NET 核心编程语言可以与您的。NET 核心代码库。令人欣慰的是,正如你将在本章后面看到的,告诉你的 C# 编译器检查你所有的代码是否符合 CLS 是很简单的。

基类库的作用

的。NET Core platform 还提供了一组所有人都可以使用的基本类库(bcl)。NET 核心编程语言。这个基本类库不仅封装了各种原语,如线程、文件输入/输出(I/O)、图形渲染系统以及与各种外部硬件设备的交互,而且还为大多数现实应用所需的许多服务提供了支持。

基本类库定义了可用于构建任何类型的软件应用的类型,以及用于该应用的组件彼此交互的类型。

的作用.NET 标准

中基类库的数量。NET 框架远远超过了那些在。NET 核心,即使发布了。NET 5.0。这是可以理解的。NET Framework 领先了 14 年。NET 核心。这种差异在尝试使用。NET 框架代码。NET 核心代码。的解决方案(和要求)。NET 框架/。NET 核心互操作是。净标准。

。NET 标准是一种规范,它定义了。NET APIs 和基类库,它们在每个实现中都必须可用。该标准支持以下场景:

  • 为所有定义一组统一的 BCL APIs。NET 实现来实现,独立于工作负载

  • 使开发人员能够生成可跨平台使用的可移植库。NET 实现,使用同一套 API

  • 减少甚至消除共享源代码的条件编译。NET APIs,仅适用于 OS APIs

位于微软文档中的图表( https://docs.microsoft.com/en-us/dotnet/standard/net-standard )显示了各种之间的兼容性。NET 框架和。NET 核心。这对于以前版本的 C# 非常有用。然而,C# 9 只能在。NET 5.0(或以上)或。NET 标准 2.1,以及。NET Standard 2.1 不可用于。NET 框架。

C# 带来了什么

C# 是一种编程语言,其核心语法看起来与 Java 的语法非常相似。但是,把 C# 称为 Java 克隆是不准确的。实际上,C# 和 Java 都是 C 编程语言家族的成员(例如,C、Objective-C、C++ ),因此共享相似的语法。

事实是,C# 的许多语法结构都是模仿 Visual Basic (VB)和 C的各个方面的。例如,像 VB 一样,C# 支持类属性(相对于传统的 getter 和 setter 方法)和可选参数的概念。像 C一样,C# 允许重载操作符,以及创建结构、枚举和回调函数(通过委托)。

此外,当您阅读本文时,您将很快发现 C# 支持许多功能,如 lambda 表达式和匿名类型,这些功能通常在各种函数式语言(如 LISP 或 Haskell)中都能找到。此外,随着语言集成查询 (LINQ)的出现,C# 支持许多结构,这使得它在编程领域非常独特。然而,大部分 C# 确实受到了基于 C 语言的影响。

因为 C# 是多种语言的混合体,所以它的语法和 Java 一样简洁(如果不是比 Java 更简洁的话),和 VB 一样简单,并且提供了和 C++一样的功能和灵活性。以下是在所有版本的语言中都能找到的 C# 核心特性的部分列表:

  • 不需要指针!C# 程序通常不需要直接的指针操作(尽管如果绝对必要的话,你可以自由地下降到那个级别,如第十一章所示)。

  • 通过垃圾收集实现自动内存管理。鉴于此,C# 不支持delete关键字。

  • 类、接口、结构、枚举和委托的正式语法构造。

  • 类似 C++的为自定义类型重载运算符的能力,没有复杂性。

  • 支持基于属性的编程。这种类型的开发允许您注释类型及其成员,以进一步限定它们的行为。例如,如果您用[Obsolete]属性标记一个方法,程序员将会看到您定制的警告消息,如果他们试图使用被修饰的成员的话。

C# 9 已经是一种强大的语言,结合。NET Core 支持构建各种应用类型。

以前版本中的主要功能

随着的发布。NET 2.0(大约在 2005 年),C# 编程语言被更新以支持许多新的功能,最值得注意的是以下功能:

  • 生成泛型类型和泛型成员的能力。使用泛型,您能够构建高效且类型安全的代码,这些代码定义了在您与泛型项目交互时指定的大量占位符

  • 支持匿名方法,这允许您在任何需要委托类型的地方提供内联函数。

  • 使用partial关键字跨多个代码文件定义单一类型(或者,如果需要,作为内存中的表示)的能力。

。NET 3.5(大约在 2008 年发布)为 C# 编程语言增加了更多的功能,包括以下特性:

  • 支持用于与各种形式的数据交互的强类型查询(如 LINQ)。你将在第十三章第一次遇到 LINQ。

  • 支持匿名类型,允许你在代码中动态地对一个类型的结构建模(而不是它的行为)。

  • 使用扩展方法扩展现有类型的功能(无需子类化)的能力。

  • 包含 lambda 运算符(=>),这进一步简化了。NET 委托类型。

  • 一种新的对象初始化语法,允许您在创建对象时设置属性值。

。NET 4.0(2010 年发布)再次更新了 C# 的一些特性,如下所示:

  • 支持可选的方法参数,以及命名的方法参数。

  • 支持通过关键字dynamic在运行时动态查找成员。正如你将在第十九章中看到的,这提供了一个统一的方法来动态调用成员,不管成员实现了哪个框架。

  • 使用泛型类型要直观得多,因为您可以通过协方差和逆变轻松地将泛型数据映射到一般的System.Object集合或从一般的System.Object集合中映射出来。

随着的发布。NET 4.5,C# 收到了一对新的关键字(asyncawait),大大简化了多线程和异步编程。如果您使用过以前版本的 C#,您可能还记得通过辅助线程调用方法需要大量的神秘代码和各种。NET 命名空间。假定 C# 现在支持为您处理这种复杂性的语言关键字,异步调用方法的过程几乎与以同步方式调用方法一样简单。第十五章将详细讨论这些话题。

C# 6 是随。NET 4.6,并引入了许多有助于简化代码库的小特性。下面是 C# 6 中一些新特性的简要介绍:

  • 自动属性的内联初始化以及对只读自动属性的支持

  • 使用 C# lambda 运算符的单行方法实现

  • 支持静态导入,以提供对名称空间内静态成员的直接访问

  • 空条件运算符,有助于检查方法实现中的空参数

  • 称为字符串插值的新字符串格式化语法

  • 使用新的when关键字过滤异常的能力

  • 使用catchfinally块中的await

  • nameOf表达式返回一个表示符号的字符串

  • 索引初始值设定项

  • 改进了霸王分辨率

C# 7,与一起发布。NET 4.7 在 2017 年 3 月推出了简化您的代码库的附加功能,它添加了一些更重要的功能(如元组和ref局部变量和返回),开发人员很长一段时间以来一直要求在语言规范中包含这些功能。下面是 C# 7 中新特性的简要概述:

  • out变量声明为内联参数

  • 本地功能

  • 附加表达式主体成员

  • 通用异步返回类型

  • 改进数字常量可读性的新标记

  • 包含多个字段的轻量级未命名类型(称为元组)

  • 除了值检查(模式匹配)之外,还使用类型匹配更新逻辑流

  • 返回对一个值的引用,而不仅仅是值本身(ref locals and returns)

  • 轻量级一次性变量的引入(称为丢弃)

  • 抛出表达式,允许抛出在更多的地方执行,比如条件表达式、lambdas 等等

C# 7 有两个小版本,增加了以下特性:

  • 拥有一个程序的主方法的能力是async

  • 一个新的文字,default,允许任何类型的初始化。

  • 修正了模式匹配的一个问题,该问题阻止了在新的模式匹配特性中使用泛型。

  • 像匿名方法一样,元组名称可以从创建它们的投影中推断出来。

  • 编写安全、高效代码的技术,语法改进的组合,支持使用引用语义处理值类型。

  • 命名参数后面可以跟位置参数。

  • 数字文字现在可以在任何打印的数字前有前导下划线。

  • private protected访问修饰符允许访问同一程序集中的派生类。

  • 条件表达式(?:)的结果现在可以作为引用。

这也是我在部分标题中添加“(新的 7.x)”和“(更新的 7.x)”的版本,以便更容易找到语言与前一版本的变化。“x”表示 C# 7 的次要版本,如 7.1。

C# 8,与一起发布。NET Core 3.0 于 2019 年 9 月 23 日推出了用于简化您的代码库的附加功能,它添加了一些更重要的功能(如元组和ref局部变量和返回),这些功能是开发人员很长一段时间以来一直要求包含在语言规范中的。

C# 8 有两个小版本,增加了以下特性:

  • 结构的只读成员

  • 默认接口成员

  • 模式匹配增强

  • 使用声明

  • 静态局部函数

  • 一次性参考支柱

  • 可为空的引用类型

  • 异步流

  • 指数和范围

  • 零合并赋值

  • 非托管构造类型

  • stackalloc在嵌套表达式中

  • 插值逐字字符串的增强

C# 8 中的新特性在它们的章节标题中用“(新 8)”表示,更新的特性用“(更新 8)”表示

C# 9 中的新特性

C# 8,2020 年 11 月 10 日发布,带。NET 5,增加了以下功能:

  • 记录

  • 仅初始化设置器

  • 顶级语句

  • 模式匹配增强

  • 互操作的性能改进

  • “适合和完成”功能

  • 支持代码生成器

C# 9 中的新特性在它们的章节标题中被标记为“(新 9)”,更新的特性被标记为“(更新 9)”

托管代码与非托管代码

值得注意的是,C# 语言只能用于构建托管在。NET 核心运行时(您永远不能使用 C# 来构建本机 COM 服务器或非托管 C/C++风格的应用)。正式来说,这个术语用于描述针对。NET 核心运行时是托管代码。包含托管代码的二进制单元被称为程序集(稍后会有更多关于程序集的细节)。相反,不能由。NET 核心运行时被称为非托管代码

如前所述。NET 核心平台可以运行在多种操作系统上。因此,很有可能在 Windows 机器上构建 C# 应用,并使用。NET 核心运行时。同样,您可以使用 Visual Studio 代码在 Linux 上构建 C# 应用,并在 Windows 上运行该程序。使用 Visual Studio for Mac,您还可以构建。NET 核心应用运行在 Windows、macOS 或 Linux 上。

仍然可以从 C# 程序访问非托管代码,但是它会将您锁定在特定的开发和部署目标上。

使用附加。支持. NET 核心的编程语言

要明白 C# 并不是唯一可以用来构建的语言。NET 核心应用。。NET 核心应用一般可以用 C#、Visual Basic、F# 这三种微软直接支持的语言来构建。

概述.NET 程序集

不管哪个。NET 核心语言,尽管。NET 核心二进制文件与非托管 Windows 二进制文件(*.dll)采用相同的文件扩展名,它们绝对没有内部相似性。具体来说,。NET Core 二进制文件不包含特定于平台的指令,而是包含与平台无关的中间语言 ( IL )和类型元数据。

Note

IL 也称为微软中间语言(MSIL)或通用中间语言(CIL)。因此,当您阅读。NET/。NET 核心文献,理解 IL,MSIL 和 CIL 都在描述本质上相同的概念。在本书中,我将使用缩写 CIL 来指代这个低级指令集。

当使用. NET 核心编译器创建了一个*.dll时,二进制 blob 被称为一个程序集。你将会检查大量的细节。第十六章中的网络核心组件。但是,为了便于当前的讨论,您需要理解这种新文件格式的四个基本属性。

第一,不像。可以是*.dll*.exe的. NET Framework 程序集。NET 核心项目总是被编译成扩展名为 ?? 的文件,即使这个项目是可执行的。可执行。用命令dotnet <assembly name>.dll执行 NET Core 汇编。新进。NET Core 3.0(及更高版本)中,dotnet.exe命令被复制到构建目录中,并重命名为<assembly name>.exe。运行这个命令会自动调用dotnet <assembly name>.dll文件,执行等同于dotnet <assembly name>.dll的功能。带有您项目名称的*.exe实际上不是您项目的代码;这是运行应用的便捷方式。

新进。NET 5,您的应用可以简化为一个直接执行的文件。尽管这个文件看起来和行为都像一个 C++风格的本地可执行文件,但是这个文件便于打包。它包含运行应用所需的所有文件,甚至可能包含。NET 5 运行时本身!但是要知道,您的代码仍然在托管容器中运行,就好像它是作为多个文件发布的一样。

其次,汇编包含 CIL 代码,这在概念上类似于 Java 字节码,因为除非绝对必要,否则它不会被编译成特定于平台的指令。通常,“绝对必要”是指 CIL 指令块(如方法实现)被。NET 核心运行时。

第三,程序集还包含元数据,它生动详细地描述了二进制文件中每个“类型”的特征。例如,如果你有一个名为SportsCar的类,类型元数据描述了细节,比如SportsCar的基类,指定了哪些接口是由SportsCar实现的(如果有的话),并且给出了SportsCar类型支持的每个成员的完整描述。。NET Core 元数据始终存在于程序集中,并由语言编译器自动生成。

最后,除了 CIL 和类型元数据之外,程序集本身也使用元数据来描述,这被正式称为清单。清单包含有关程序集的当前版本的信息、区域性信息(用于本地化字符串和图像资源)以及正确执行所需的所有外部引用程序集的列表。在接下来的几章中,您将研究各种可用于检查程序集的类型、元数据和清单信息的工具。

公共中间语言的作用

让我们更详细地研究一下 CIL 代码、类型元数据和程序集清单。CIL 是一种位于任何特定平台指令集之上的语言。例如,下面的 C# 代码模拟了一个简单的计算器。现在不要关心确切的语法,但是请注意Calc类中Add()方法的格式。

//Calc.cs
using System;

namespace CalculatorExamples
{
  //This class contains the app's entry point.
  class Program
  {
    static void Main(string[] args)
    {
      Calc c = new Calc();
      int ans = c.Add(10, 84);
      Console.WriteLine("10 + 84 is {0}.", ans);
      //Wait for user to press the Enter key
      Console.ReadLine();
    }
  }
  // The C# calculator.
  class Calc
  {
    public int Add(int addend1, int addend2)
    {
      return addend1 + addend2;
    }
  }
}

编译这段代码会生成一个文件*.dll集合,其中包含清单、CIL 指令和描述CalcProgram类各个方面的元数据。

Note

第二章探讨了如何使用图形化集成开发环境(ide)来编译你的代码文件,比如 Visual Studio 社区。

例如,如果您要使用ildasm.exe从这个程序集输出 IL(本章稍后会详细介绍),您会发现Add()方法是使用 CIL 表示的,如下所示:

.method public hidebysig instance int32
        Add(int32 addend1,
            int32 addend2) cil managed
{
  // Code size       9 (0x9)
  .maxstack  2
  .locals init (int32 V_0)
  IL_0000:  nop
  IL_0001:  ldarg.1
  IL_0002:  ldarg.2
  IL_0003:  add
  IL_0004:  stloc.0
  IL_0005:  br.s       IL_0007
  IL_0007:  ldloc.0
  IL_0008:  ret
} // end of method Calc::Add

如果你不能理解这个方法产生的 CIL,不要担心,因为第十九章将描述 CIL 编程语言的基础。需要注意的是,C# 编译器发出的是 CIL,而不是特定于平台的指令。

现在,回想一下这是真的。NET 核心编译器。举例来说,假设您使用 Visual Basic 而不是 C# 创建了相同的应用。

' Calc.vb
Namespace CalculatorExample
  Module Program
    ' This class contains the app's entry point.
    Sub Main(args As String())
      Dim c As New Calc
      Dim ans As Integer = c.Add(10, 84)
      Console.WriteLine("10 + 84 is {0}", ans)
      'Wait for user to press the Enter key before shutting down
      Console.ReadLine()
    End Sub
  End Module
  ' The VB.NET calculator.
  Class Calc
    Public Function Add(ByVal addend1 As Integer, ByVal addend2 As Integer) As Integer
      Return addend1 + addend2
    End Function
  End Class
End Namespace

如果您检查Add()方法的 CIL,您会发现类似的指令(被 Visual Basic 编译器稍微调整了一下)。

  .method public instance int32  Add(int32 addend1,
                                     int32 addend2) cil managed
  {
    // Code size       9 (0x9)
    .maxstack  2
    .locals init (int32 V_0)
    IL_0000:  nop
    IL_0001:  ldarg.1
    IL_0002:  ldarg.2
    IL_0003:  add.ovf
    IL_0004:  stloc.0
    IL_0005:  br.s       IL_0007

    IL_0007:  ldloc.0
    IL_0008:  ret
  } // end of method Calc::Add

作为最后一个例子,用 F# 开发的同样简单的 Calc 程序(另一个。NET 核心语言)如下所示:

// Learn more about F# at http://fsharp.org

// Calc.fs
open System

module Calc =
    let add addend1 addend2 =
        addend1 + addend2

[<EntryPoint>]
let main argv =
    let ans = Calc.add 10 84
    printfn "10 + 84 is %d" ans
    Console.ReadLine()
    0

如果您检查Add()方法的 CIL,您会再次发现类似的指令(被 F# 编译器稍微调整了一下)。

.method public static int32  Add(int32 addend1,
                                   int32 addend2) cil managed
{
  .custom instance void [FSharp.Core]Microsoft.FSharp.Core.CompilationArgumentCountsAttribute::.ctor(int32[]) = ( 01 00 02 00 00 00 01 00 00 00 01 00 00 00 00 00 )
  // Code size       4 (0x4)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  ldarg.1
  IL_0002:  add
  IL_0003:  ret
} // end of method Calc::'add'

CIL 的好处

此时,您可能想知道将源代码编译成 CIL,而不是直接编译成特定的指令集,到底能得到什么。一个好处是语言整合。正如您已经看到的,每个。NET 核心编译器产生几乎相同的 CIL 指令。因此,所有语言都能够在一个定义明确的二进制领域内进行交互。

此外,鉴于 CIL 是平台不可知的。NET Core Framework 本身是与平台无关的,提供了 Java 开发人员已经习惯的相同优势(例如,在众多操作系统上运行的单一代码库)。事实上,C# 语言有一个国际标准。之前。NET 核心,有许多实现。NET 用于非 Windows 平台,比如 Mono。这些仍然存在,尽管随着的跨平台能力,对它们的需求大大减少了。NET 核心。

将 CIL 编译成特定于平台的指令

因为程序集包含 CIL 指令而不是特定于平台的指令,所以 CIL 代码必须在使用前被动态编译。将 CIL 代码编译成有意义的 CPU 指令的实体是 JIT 编译器,它有时被冠以友好的名字 jitter 。那个。NET Core 运行时环境为每个面向运行时的 CPU 利用了 JIT 编译器,每个都针对底层平台进行了优化。

例如,如果您正在构建一个要部署到手持设备(比如 iOS 或 Android 手机)上的. NET 核心应用,那么相应的抖动可以在低内存环境中运行。另一方面,如果您将程序集部署到后端公司服务器(在那里内存很少成为问题),抖动将被优化以在高内存环境中运行。通过这种方式,开发人员可以编写一个单独的代码体,该代码体可以在具有不同架构的机器上高效地进行 JIT 编译和执行。

此外,当给定的抖动将 CIL 指令编译成相应的机器代码时,它会以适合目标操作系统的方式将结果缓存在存储器中。这样,如果调用名为PrintDocument()的方法,CIL 指令在第一次调用时被编译成特定于平台的指令,并保留在内存中以备后用。因此,下次调用PrintDocument()时,不需要重新编译 CIL。

将 CIL 预编译为特定于平台的指令

中有一个实用程序。NET Core 叫做crossgen.exe,可以用来预 JIT 你的代码。好在,在。NET Core 3.0 中,框架内置了生成“现成”程序集的能力。本书后面会有更多相关内容。

的作用.NETCore 类型元数据

除了 CIL 指令之外,. NET 核心汇编件还包含完整、完整且准确的元数据,该元数据描述了二进制文件中定义的每种类型(例如,类、结构、枚举)以及每种类型的成员(例如,属性、方法、事件)。令人欣慰的是,发出最新和最好的类型元数据总是编译器(而不是程序员)的工作。因为。NET 核心元数据非常细致,程序集完全是自描述的实体。

说明…的格式。NET Core 类型元数据,我们来看看已经为您之前检查过的 C# Calc类的Add()方法生成的元数据(为 Visual Basic 版本的Add()方法生成的元数据类似,所以我们将只检查 C# 版本)。

TypeDef #2 (02000003)
-------------------------------------------------------
  TypDefName: CalculatorExamples.Calc  (02000003)
  Flags     : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100000)
  Extends   : 0100000C [TypeRef] System.Object
  Method #1 (06000003)
  -------------------------------------------------------
    MethodName: Add (06000003)
    Flags     : [Public] [HideBySig] [ReuseSlot]  (00000086)
    RVA       : 0x00002090
    ImplFlags : [IL] [Managed]  (00000000)
    CallCnvntn: [DEFAULT]
    hasThis
    ReturnType: I4
    2 Arguments
      Argument #1:  I4
      Argument #2:  I4
    2 Parameters
      (1) ParamToken : (08000002) Name : addend1 flags: [none] (00000000)
      (2) ParamToken : (08000003) Name : addend2 flags: [none] (00000000)

元数据用于的许多方面。NET 核心运行时环境,以及各种开发工具。例如,Visual Studio 等工具提供的智能感知功能是通过在设计时读取程序集的元数据来实现的。各种对象浏览实用程序、调试工具和 C# 编译器本身也使用元数据。可以肯定的是,元数据是众多。NET 核心技术,包括反射、延迟绑定和对象序列化。第十七章将正式确定。NET 核心元数据。

程序集清单的角色

最后但同样重要的是,记住. NET 核心程序集还包含描述程序集本身的元数据(技术上称为清单)。在其他详细信息中,清单记录了当前程序集正常工作所需的所有外部程序集、程序集的版本号、版权信息等等。与类型元数据一样,生成程序集清单始终是编译器的工作。以下是编译本章前面显示的Calc.cs代码文件时生成的清单的一些相关细节(为简洁起见,省略了一些行):

.assembly extern /*23000001*/ System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
  .ver 5:0:0:0
}
.assembly extern /*23000002*/ System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
  .ver 5:0:0:0
}
.assembly /*20000001*/ Calc.Cs
{
  .hash algorithm 0x00008004
  .ver 1:0:0:0
}
.module Calc.Cs.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003       // WINDOWS_CUI
.corflags 0x00000001    //  ILONLY

简而言之,清单记录了Calc.dll(通过.assembly extern指令)所需的外部程序集集合,以及程序集本身的各种特征(例如,版本号、模块名)。第十六章将会更详细的讨论清单数据的用处。

了解通用类型系统

给定的程序集可以包含任意数量的不同类型。在的世界里。NET Core, type 只是一个通用术语,用来指代集合{类、接口、结构、枚举、委托}中的成员。当您使用. NET 核心语言构建解决方案时,您很可能会与这些类型中的许多类型进行交互。例如,您的程序集可能定义一个实现一些接口的类。也许其中一个接口方法将枚举类型作为输入参数,并向调用者返回一个结构。

回想一下,CTS 是一个正式的规范,它记录了为了由。NET 运行时。通常,对 CTS 的内部工作方式非常关心的人只有那些针对。NET 核心平台。然而,这对所有人都很重要。NET 程序员学习如何使用 CTS 以他们选择的语言定义的五种类型。以下是简要概述。

CTS 类别类型

每一个。NET 核心语言至少支持类类型的概念,这是面向对象编程(OOP)的基石。一个类可以由任意数量的成员(如构造函数、属性、方法和事件)和数据点(字段)组成。在 C# 中,类是使用class关键字声明的,就像这样:

// A C# class type with 1 method.
class Calc
{
  public int Add(int addend1, int addend2)
  {
    return addend1 + addend2;
  }
}

第五章将开始你用 C# 构建类类型的正式考试;然而,表 1-1 记录了许多与类类型相关的特征。

表 1-1。

CTS 类别特征

|

阶级特征

|

生命的意义

班级被封了吗? 密封类不能作为其他类的基类。
这个类实现任何接口了吗? 接口是抽象成员的集合,它提供了对象和对象用户之间的契约。CTS 允许一个类实现任意数量的接口。
这个类是抽象的还是具体的? 抽象类不能被直接实例化,而是用来定义派生类型的公共行为。具体的类可以直接实例化。
这个班的知名度如何? 每个类都必须配置一个可见性关键字,如publicinternal。基本上,这控制了该类是可以由外部程序集使用,还是只能从定义程序集内部使用。

CTS 接口类型

接口只不过是抽象成员定义和/或(C# 8 中新增的)默认实现的命名集合,它们由给定的类或结构实现(在默认实现的情况下是可选的)。在 C# 中,接口类型是使用interface关键字定义的。按照惯例,都是。NET 接口以大写字母 I 开头,如下例所示:

// A C# interface type is usually
// declared as public, to allow types in other
// assemblies to implement their behavior.
public interface IDraw
{
  void Draw();
}

接口本身用处不大。但是,当一个类或结构以其独特的方式实现一个给定的接口时,您能够以多态的方式使用接口引用请求访问所提供的功能。基于接口的编程将在第章和第章中全面探讨。

CTS 结构类型

结构的概念也在 CTS 下形式化。如果你有 C 背景,你应该很高兴知道这些用户定义类型(udt)在。NET Core(虽然它们在引擎盖下的表现有点不同)。简单地说,一个结构可以被认为是一个轻量级的类类型,具有基于值的语义。关于结构细节的更多信息,请参见第四章。通常,结构最适合于对几何和数学数据进行建模,并使用关键字struct在 C# 中创建,如下所示:

// A C# structure type.
struct Point
{
  // Structures can contain fields.
  public int xPos, yPos;

  // Structures can contain parameterized constructors.
  public Point(int x, int y)
  { xPos = x; yPos = y;}

  // Structures may define methods.
  public void PrintPosition()
  {
    Console.WriteLine("({0}, {1})", xPos, yPos);
  }
}

CTS 枚举类型

枚举是一种方便的编程构造,允许您对名称-值对进行分组。例如,假设您正在创建一个视频游戏应用,它允许玩家从三个角色类别(巫师、战士或小偷)中进行选择。您可以使用enum关键字构建一个强类型枚举,而不是跟踪简单的数值来表示每种可能性。

// A C# enumeration type.
enum CharacterTypeEnum
{
  Wizard = 100,
  Fighter = 200,
  Thief = 300
}

默认情况下,用于保存每个项目的存储是一个 32 位整数;然而,如果需要的话,可以改变这个存储槽(例如,当为诸如移动设备的低存储设备编程时)。此外,CTS 要求枚举类型从一个公共基类System.Enum派生。正如你将在第四章中看到的,这个基类定义了许多有趣的成员,允许你以编程的方式提取、操作和转换底层的名称-值对。

CTS 委托类型

代表们是。等效于类型安全的 C 风格函数指针。关键的区别在于. NET 核心委托是一个从System.MulticastDelegate派生的,而不是一个简单的指向原始内存地址的指针。在 C# 中,委托是使用delegate关键字声明的。

// This C# delegate type can "point to" any method
// returning an int and taking two ints as input.
delegate int BinaryOp(int x, int y);

当您希望为一个对象向另一个对象转发调用提供一种方法,并为。NET 核心事件架构。正如您将在第 12 和 14 章中看到的,委托对多播(即,将一个请求转发给多个接收者)和异步方法调用(即,在辅助线程上调用方法)有内在的支持。

CTS 类型成员

现在您已经预览了 CTS 形式化的每一种类型,意识到大多数类型接受任意数量的成员。从形式上讲,类型成员受集合{构造函数,终结器,静态构造函数,嵌套类型,运算符,方法,属性,索引器,字段,只读字段,常量,事件}约束。

CTS 定义了可能与给定成员相关联的各种装饰。例如,每个成员具有给定的可见性特征(例如,公共的、私有的、受保护的)。有些成员可以声明为抽象的(在派生类型上强制执行多态行为)和虚拟的(定义固定的但可重写的实现)。此外,大多数成员可以配置为静态(在类级别绑定)或实例(在对象级别绑定)。类型成员的创建将在接下来的几章中讨论。

Note

如第十章所述,C# 语言也支持泛型类型和泛型成员的创建。

内在 CTS 数据类型

CTS 目前需要注意的最后一个方面是,它建立了一组定义明确的基本数据类型。尽管给定语言通常有一个唯一的关键字用于声明基本数据类型,但所有。NET 语言关键字最终解析为名为mscorlib.dll的程序集中定义的相同 CTS 类型。考虑表 1-2 ,它记录了关键 CTS 数据类型如何在 VB.NET 和 C# 中表示。

表 1-2。

固有 CTS 数据类型

|

CTS 数据类型

|

VB 关键字

|

C# 关键字

System.Byte Byte byte
System.SByte SByte sbyte
System.Int16 Short short
System.Int32 Integer int
System.Int64 Long long
System.UInt16 UShort ushort
System.UInt32 UInteger uint
System.UInt64 ULong ulong
System.Single Single float
System.Double Double double
System.Object Object object
System.Char Char char
System.String String string
System.Decimal Decimal decimal
System.Boolean Boolean bool

假设托管语言的唯一关键字只是在System名称空间中对真实类型的速记符号,您就不必再担心数值数据的上溢/下溢情况,或者字符串和布尔值在不同语言中是如何内部表示的。请考虑以下代码片段,这些代码片段使用语言关键字和正式的 CTS 数据类型在 C# 和 Visual Basic 中定义了 32 位数值变量:

// Define some "ints" in C#.
int i = 0;
System.Int32 j = 0;

' Define some  "ints" in VB.
Dim i As Integer = 0
Dim j As System.Int32 = 0

理解公共语言规范

如您所知,不同的语言用独特的、特定于语言的术语来表达相同的编程结构。例如,在 C# 中,您使用加号运算符(+)来表示字符串连接,而在 VB 中,您通常使用&符号(&)。即使两种不同的语言表达了相同的编程习惯用法(例如,一个没有返回值的函数),语法表面上看起来也很有可能完全不同。

// C# method returning nothing.
public void MyMethod()
{
  // Some interesting code...
}

' VB method returning nothing.
Public Sub MyMethod()
  ' Some interesting code...
End Sub

正如您已经看到的,这些微小的语法变化在。NET 核心运行时,假设各自的编译器(csc.exevbc.exe,在这种情况下)发出一组类似的 CIL 指令。然而,各种语言的总体功能水平也可能有所不同。例如,. NET 核心语言可能有也可能没有表示无符号数据的关键字,可能支持也可能不支持指针类型。考虑到这些可能的变化,最好有一个基线。NET 核心语言应该是一致的。

CLS 是一组规则,生动详细地描述了给定的最小和完整的特征集。NET Core 编译器必须支持才能生成可由。NET 运行库,同时所有面向。NET 核心平台。在许多方面,CLS 可以被视为 CTS 定义的全部功能的子集。

CLS 最终是一组规则,如果编译器构建者希望他们的产品在。净核心宇宙。每个规则都被赋予一个简单的名称(例如,CLS 规则 6 ),并描述该规则如何影响构建编译器的人以及(以某种方式)与编译器交互的人。CLS 的精英就是规则 1。

  • 规则 1 : CLS 规则只适用于那些暴露在定义程序集之外的类型部分。

根据这条规则,您可以(正确地)推断 CLS 的其余规则不适用于用于构建. NET 核心类型内部工作的逻辑。类型必须符合 CLS 的唯一方面是成员定义本身(即命名约定、参数和返回类型)。成员的实现逻辑可以使用任意数量的非 CLS 技术,因为外界不会知道其中的区别。

举例来说,下面的 C# Add()方法不符合 CLS,因为参数和返回值使用了无符号数据(这不是 CLS 的要求):

class Calc
{
  // Exposed unsigned data is not CLS compliant!
  public ulong Add(ulong addend1, ulong addend2)
  {
    return addend1 + addend2;
  }
}

但是,请考虑以下在方法内部使用无符号数据的代码:

class Calc
{
  public int Add(int addend1, int addend2)
  {
    // As this ulong variable is only used internally,
    // we are still CLS compliant.
    ulong temp = 0;
    ...
    return addend1 + addend2;
  }
}

该类仍然符合 CLS 的规则,可以放心,所有。NET 核心语言能够调用Add()方法。

当然,除了规则 1,CLS 还规定了许多其他规则。例如,CLS 描述了给定语言必须如何表示文本字符串,枚举应该如何在内部表示(用于存储的基类型),如何定义静态成员,等等。幸运的是,要成为一名专家,你不需要记住这些规则。NET 开发者。同样,总的来说,对 CTS 和 CLS 规范的深入理解通常只对工具/编译器开发者有意义。

确保 CLS 合规

正如你将在本书中看到的,C# 确实定义了许多不符合 CLS 标准的编程结构。然而,好消息是,您可以指示 C# 编译器使用单个。NET 属性。

// Tell the C# compiler to check for CLS compliance.
[assembly: CLSCompliant(true)]

第十七章深入基于属性编程的细节。在此之前,只需理解[CLSCompliant]属性将指示 C# 编译器根据 CLS 的规则检查每一行代码。如果发现任何 CLS 违规,您会收到编译器警告和违规代码的描述。

了解。NET 核心运行时

除了 CTS 和 CLS 规范之外,需要解决的最后一个难题是。NET 核心运行时,或简称为。NET 运行时。从编程的角度来说,术语 runtime 可以理解为执行给定的编译代码单元所需的服务集合。例如,当 Java 开发人员将软件部署到一台新计算机上时,他们需要确保该计算机上已经安装了 Java 虚拟机(JVM ),以便运行他们的软件。

那个。NET 核心平台提供了另一个运行时系统。之间的主要区别。NET 核心运行时和我刚才提到的各种其他运行时。NET Core runtime 提供了一个单一的、定义良好的运行时层,由所有的语言和平台共享。NET 核心。

区分程序集、命名空间和类型

我们每个人都明白代码库的重要性。框架库的目的是为开发人员提供一组定义良好的现有代码,以便在他们的应用中加以利用。然而,C# 语言并没有特定于语言的代码库。相反,C# 开发人员利用了语言中立性。NET 核心库。为了保持基类库中的所有类型组织有序。NET Core 平台广泛使用了名称空间的概念。

命名空间是一组语义相关的类型,包含在一个程序集中,或者可能分布在多个相关的程序集中。例如,System.IO名称空间包含与文件 I/O 相关的类型,System.Data名称空间定义基本的数据库类型,等等。需要指出的是,单个程序集可以包含任意数量的命名空间,每个命名空间可以包含任意数量的类型。

这种方法和特定于语言的库之间的主要区别在于,任何面向。NET 核心运行时使用相同的名称空间和相同的类型。例如,以下两个程序都说明了无处不在的 Hello World 应用,它们是用 C# 和 VB 编写的:

// Hello World in C#.
using System;

public class MyApp
{
  static void Main()
  {
    Console.WriteLine("Hi from C#");
  }
}

' Hello World in VB.
Imports System
Public Module MyApp
  Sub Main()
    Console.WriteLine("Hi from VB")
  End Sub
End Module

注意,每种语言都使用在System名称空间中定义的Console类。除了一些明显的语法差异之外,这些应用在物理上和逻辑上都非常相似。

很明显,一旦你对你的。NET 核心编程语言,作为. NET 核心开发人员,您的下一个目标是了解(众多)中定义的丰富类型。NET 核心命名空间。最基本的名称空间最初被命名为System。这个命名空间提供了一个核心类型体,作为. NET 核心开发人员,您需要反复利用它。事实上,如果不引用System名称空间,就无法构建任何功能性的 C# 应用,因为核心数据类型(例如System.Int32System.String)就是在这里定义的。表 1-3 提供了部分(但肯定不是全部)的概要。NET 核心命名空间按相关功能分组。

表 1-3。

的样本。NET 命名空间

|

。NET 命名空间

|

生命的意义

System System中,您可以找到许多有用的类型来处理内部数据、数学计算、随机数生成、环境变量和垃圾收集,以及许多常用的异常和属性。
System.Collections System.Collections.Generic 这些名称空间定义了许多库存容器类型,以及允许您构建定制集合的基本类型和接口。
System.Data System.Data.Common System.Data.SqlClient 这些名称空间用于通过 ADO.NET 与关系数据库进行交互。
System.IO System.IO.Compression System.IO.Ports 这些名称空间定义了许多用于文件 I/O、数据压缩和端口操作的类型。
System.Reflection System.Reflection.Emit 这些命名空间定义了支持运行时类型发现以及动态创建类型的类型。
System.Runtime.InteropServices 这个命名空间提供了允许。NET 类型与非托管代码(例如,基于 C 的 dll 和 COM 服务器)进行交互,反之亦然。
System.Drawing System.Windows.Forms 这些命名空间定义了用于使用。NET 的原创 UI 工具包(Windows Forms)。
System.Windows System.Windows.Controls System.Windows.Shapes System.Windows命名空间是 Windows Presentation Foundation 应用中使用的几个命名空间的根。
System.Windows.FormsSystem.Drawing System.Windows.Forms命名空间是 Windows 窗体应用中使用的几个命名空间的根。
System.Linq System.Linq.Expressions 这些命名空间定义了针对 LINQ API 编程时使用的类型。
System.AspNetCore 这是允许您构建 ASP.NET 核心 web 应用和 RESTful 服务的众多名称空间之一。
System.Threading System.Threading.Tasks 这些命名空间定义了许多类型来构建多线程应用,这些应用可以在多个 CPU 之间分配工作负载。
System.Security 安全性是。净宇宙。在以安全为中心的名称空间中,您会发现许多处理权限、加密等的类型。
System.Xml 以 XML 为中心的名称空间包含许多用于与 XML 数据交互的类型。

以编程方式访问命名空间

值得重申的是,命名空间只不过是我们人类逻辑理解和组织相关类型的一种方便方式。再次考虑一下System名称空间。从您的角度来看,您可以假设System.Console表示一个名为Console的类,该类包含在名为System的名称空间中。然而,在的眼里。NET 核心运行时,情况并非如此。运行时引擎只看到一个名为System.Console的类。

在 C# 中,using关键字简化了引用特定命名空间中定义的类型的过程。这是它的工作原理。回到本章前面的 Calc 示例程序,在文件的顶部有一个 using 语句。

using System;

该语句是启用这行代码的快捷方式:

Console.WriteLine("10 + 84 is {0}.", ans);

如果没有using语句,代码需要写成这样:

System.Console.WriteLine("10 + 84 is {0}.", ans);

虽然使用完全限定名定义类型提供了更好的可读性,但我想你会同意 C# using关键字减少了击键次数。在本文中,我们将避免使用完全限定名(除非有明确的歧义需要解决),而选择 C# using关键字的简化方法。

但是,请始终记住,using关键字只是一种用于指定类型的完全限定名的简写符号,这两种方法都会产生相同的基础 CIL(假设 CIL 代码总是使用完全限定名),并且对性能或程序集的大小没有影响。

引用外部程序集

以前版本的。NET Framework 使用了一个通用的框架库安装位置,称为全局程序集缓存 (GAC)。不是只有一个安装位置。NET Core 不使用 GAC。相反,每个版本(包括次要版本)都安装在计算机上自己的位置(按版本)。当使用 Windows 时,每个版本的运行时和 SDK 都被安装到c:\Program Files\dotnet中。

将组件添加到 most 中。NET 核心项目是通过添加 NuGet 包来完成的(在本文后面会涉及到)。然而,。以 Windows 为目标(或在 Windows 上开发)的. NET 核心应用仍然可以访问 COM 库。这也将在本文的后面部分讨论。

为了使一个程序集能够访问您正在生成的(或某人为您生成的)另一个程序集,您需要添加一个从您的程序集到另一个程序集的引用,并且能够物理访问该程序集。这取决于您用来构建您的。NET 核心应用中,您将有各种方法来通知编译器您希望在编译周期中包含哪些程序集。

使用 ildasm.exe 浏览程序集

如果您开始对掌握。NET 核心平台,请记住,名称空间的独特之处在于它包含以某种方式语义相关的类型。因此,如果除了简单的控制台应用之外,您不需要其他用户界面,那么您可以完全忘记桌面和 web 名称空间(以及其他名称空间)。如果您正在构建一个绘画应用,那么数据库名称空间很可能不太重要。随着时间的推移,您将了解与您的编程需求最相关的名称空间。

中间语言反汇编器实用程序(ildasm.exe)允许您创建一个表示. NET 核心程序集的文本文档,并研究其内容,包括相关的清单、CIL 代码和类型元数据。该工具允许您深入研究他们的 C# 代码如何映射到 CIL,并最终帮助您理解。NET 核心平台。而你绝对不需要才能用ildasm.exe成为高手。NET 核心程序员,我强烈建议您不时地使用这个工具,以便更好地理解您的 C# 代码如何映射到运行时概念。

Note

ildasm.exe程序不再随。NET 5 运行时。有两种方法可以将此工具放入您的工作空间。第一种是从。NET 5 运行时源码位于 https://github.com/dotnet/runtime 。第二种,也是更容易的方法,是从 www.nuget.org 中下拉想要的版本。NuGet 上的 ILDasm 在 https://www.nuget.org/packages/Microsoft.NETCore.ILDAsm/ 。确保选择正确的版本(对于本书,您需要 5.0.0 或更高版本)。使用下面的命令将 ILDasm 添加到您的项目中:dotnet add package Microsoft.NETCore.ILDAsm --version 5.0.0

这实际上并没有将ILDasm.exe加载到您的项目中,而是将它放在您的包文件夹中(在 Windows 上):%userprofile%\.nuget\packages\microsoft.netcore.ildasm\5.0.0\runtimes\native\

我还将本书 GitHub repo 中的ILDasm.exe5 . 0 . 0 版本包含在章节 1 文件夹中(以及使用ILDasm.exe的每个章节)。

ildasm.exe加载到您的机器上之后,您可以从命令行不带任何参数地运行程序来查看帮助注释。至少,您必须指定程序集来提取 CIL。

命令行示例如下:

ildasm /all /METADATA /out=csharp.il calc.cs.dll

这将创建一个名为csharp.il的文件,将所有可用数据导出到该文件中。

摘要

这一章的重点是为本书的其余部分奠定必要的概念框架。我首先研究了在之前的技术中发现的一些限制和复杂性。NET 核心,并概述了如何。NET Core 和 C# 试图简化当前的事态。

。NET Core 基本上归结为一个运行时执行引擎(??)和基本类库。运行库能够承载任何。遵守托管代码规则的. NET 核心二进制文件(也称为程序集)。正如您所看到的,程序集包含 CIL 指令(除了类型元数据和程序集清单之外),这些指令使用实时编译器编译成特定于平台的指令。此外,您还探索了公共语言规范和公共类型系统的作用。

在下一章中,你将浏览一下在构建 C# 编程项目时可以使用的通用集成开发环境。您会很高兴地知道,在本书中,您将使用完全免费(且功能丰富)的 ide,因此您可以开始探索。净核心宇宙没有钱下来。

二、构建 C# 应用

作为一名 C# 程序员,你可以从众多的工具中进行选择。NET 核心应用。您选择的工具将主要基于三个因素:任何相关成本、您用于开发软件的操作系统以及您的目标计算平台。本章的目的是提供安装所需的信息。NET 5 SDK 和运行时,并介绍了微软的旗舰 ide,Visual Studio 代码和 Visual Studio 的初步看法。

本章的第一部分将介绍如何使用设置您的计算机。NET 5 SDK 和运行时。下一节将研究用 Visual Studio 代码和 Visual Studio Community Edition 构建您的第一个 C# 应用。

Note

本章和后续章节中的截图来自 Windows 上的 Visual Studio 代码 v 1.51.1 或 Visual Studio 2019 社区版 v16.8.1。如果你想在不同的操作系统或 IDE 上构建你的应用,本章将为你指明正确的方向;但是,您的 IDE 的外观可能与本文中的各种截图不同。

正在安装。NET 5

开始用 C# 9 和。NET 5(在 Windows、macOS 或 Linux 上)。需要安装. NET 5 SDK(它还会安装。NET 5 运行时)。的所有安装。NET 和。网芯位于方便的 www.dot.net 。在主页上,单击下载,然后单击全部。点击“全部”后。你会看到两个 LTS 版本的。NET 核心(2.1 和 3.1)和一个链接。NET 5.0。点击”。NET 5.0(推荐)。”进入该页面后,选择正确的。适用于您的操作系统的 NET 5 SDK。对于这本书,您需要安装 SDK。NET Core 版本 5.0.100 或更高版本,该版本还会安装。NET、ASP.NET 和。NET 桌面(在 Windows 上)运行时。

Note

自发布以来,下载页面发生了变化。净 5。现在有三列带有标题。网,“”。网芯,“和”。NET 框架。点击“全部。NET Core 下载”。NET 或。NET Core header 带你到同一个页面。安装 Visual Studio 2019 也会安装。NET Core SDK 和运行时。

了解。NET 5 版本编号方案

在撰写本文时。NET 5 SDK 的版本是 5.0.100。前两个数字(5.0)表示您可以瞄准的运行时的最高版本。在这种情况下,这是 5.0。这意味着 SDK 也支持开发较低版本的运行时,如。网芯 3.1。下一个数字(1)是季度特征带。由于我们目前处于自发布以来的第一季度,所以它是 1。最后两个数字(00)表示修补程序版本。如果你在脑海中给版本加一个分隔符,把当前版本想成 5.0.1.00,这就稍微清楚一点了。

确认。NET 5 安装

要确认 SDK 和运行时的安装,请打开一个命令窗口并使用。NET 5 命令行界面(CLI),dotnet.exe。CLI 提供了 SDK 选项和命令。这些命令包括创建、构建、运行和发布项目和解决方案,您将在本文后面看到这些命令的示例。在本节中,我们将检查 SDK 选项,共有四个,如表 2-1 所示。

表 2-1。

。NET 5 CLI SDK 选项

|

[计]选项

|

生命的意义

--version 显示。正在使用. NET SDK 版本
--info 展示.NET 信息
--list-runtimes 显示已安装的运行时
--list-sdks 显示已安装的 SDK
--version 显示。正在使用. NET SDK 版本

--version选项显示安装在您机器上的 SDK 的最高版本,或者位于您当前目录或以上的global.json中指定的版本。检查的当前版本。NET 5 SDK,请输入以下内容:

dotnet --version

对于这本书,结果需要是 5.0.100(或更高)。

展示所有的。NET Core 运行时,请输入以下内容:

dotnet --list-runtimes

有三种不同的运行时间:

  • Microsoft.AspNetCore.App(用于构建 ASP.NET 核心应用)

  • Microsoft.NETCore.App(的基础运行时.NETCore)

  • Microsoft.WindowsDesktop.App(用于构建 WinForms 和 WPF 应用)

如果您运行的是 Windows 操作系统,则每个版本都必须是 5.0.0(或更高版本)。如果你不在 Windows 上,你只需要前两个,Microsoft.NETCore.AppMicrosoft.AspNetCore.App,并且显示版本 5.0.0(或更高版本)。

最后,要显示所有安装的 SDK,请输入以下内容:

dotnet --list-sdks

同样,版本必须是 5.0.100(或更高)。

使用早期版本的。NET(核心)SDK

如果需要将项目固定到早期版本的。NET Core SDK,你可以用一个global.json文件来完成。要创建该文件,可以使用以下命令:

dotnet new globaljson –sdk-version 3.1.404

这将创建一个类似如下的global.json文件:

{
  "sdk": {
    "version": "3.1.404"
  }
}

该文件“固定”了。将当前目录及其下的所有目录的. NET Core SDK 版本升级到 3.1.404。在这个目录下运行dotnet.exe --version会返回 3.1.404。

建筑。NET 核心应用与 Visual Studio

如果您有使用以前版本的 Microsoft 技术构建应用的经验,您可能对 Visual Studio 很熟悉。在产品的整个生命周期中,版本名称和功能集一直在变化,但自发布以来已经稳定下来。NET 核心。Visual Studio 有以下版本(适用于 Window 和 Mac):

  • Visual Studio 2019 社区(免费)

  • Visual Studio 2019 专业版(付费)

  • Visual Studio 2019 企业版(付费)

社区版和专业版本质上是一样的。最显著的区别在于许可模式。社区被许可用于开源、学术和小型企业。Professional 和 Enterprise 是许可用于任何开发(包括企业开发)的商业产品。正如所料,与专业版相比,企业版有许多附加功能。

Note

具体许可详情,请前往 www.visualstudio.com 。微软产品的许可可能会很复杂,本书不涉及细节。出于写作(和阅读)本书的目的,使用社区是合法的。

所有 Visual Studio 版本都附带了复杂的代码编辑器、集成的调试器、桌面和 web 应用的 GUI 设计器等等。由于它们都有一套共同的核心特性,好消息是它们之间的转换很容易,而且对它们的基本操作也很熟悉。

安装 Visual Studio 2019 (Windows)

在使用 Visual Studio 2019 开发、执行和调试 C# 应用之前,您需要安装它。2017 版本的安装体验发生了巨大变化,值得更详细地讨论。

Note

可以从 www.visualstudio.com/downloads 下载 Visual Studio 2019 社区。确保您下载并安装的版本至少是 16.8.1 或更高版本。

Visual Studio 2019 安装流程现在被分解为应用类型的工作负载。这允许您只安装您计划构建的应用类型所需的组件。例如,如果您要构建 web 应用,您应该安装“ASP。NET 和 web 开发”工作量。

另一个(极其)重大的变化是,Visual Studio 2019 支持真正的并行安装。注意,我指的不仅仅是以前版本的 Visual Studio,而是 Visual Studio 2019 本身!例如,在我的主要工作计算机上,我为我的专业工作安装了 Visual Studio 2019 Enterprise,并为我的书籍、课程和会议讲座安装了 Visual Studio 2019 社区。如果你有雇主提供的 Professional 或 Enterprise,你仍然可以安装 Community edition 来处理开源项目(或本书中的代码)。

当您启动 Visual Studio 2019 Community 的安装程序时,您会看到如图 2-1 所示的屏幕。该屏幕显示了所有可用的工作负载、选择单个组件的选项,以及显示所选内容的右侧摘要。

img/340876_10_En_2_Fig1_HTML.jpg

图 2-1。

新的 Visual Studio 安装程序

对于本书,您需要安装以下工作负载:

  • 。NET 桌面开发

  • ASP.NET 和网络开发

  • 数据存储和处理

  • 。NET Core 跨平台开发

在“单个组件”选项卡上,还选择类设计器、Git for Windows 和“GitHub extension for Visual Studio”(都在“代码工具”下)。一旦您选择了它们,点击安装。这将为你提供完成本书中的例子所需的一切。

试用 Visual Studio 2019

Visual Studio 2019 是软件开发的一站式商店。NET 平台和 C#。让我们通过构建一个简单的。NET 5 控制台应用。

使用“新建项目”对话框和 C# 代码编辑器

当你启动 Visual Studio 时,你会看到更新后的启动对话框,如图 2-2 所示。对话框的左侧有最近使用的解决方案,右侧有用于启动 Visual Studio 的选项,包括从存储库中启动代码、打开现有项目/解决方案、打开本地文件夹或创建新项目。还有一个选项是在没有任何代码的情况下继续,这只是启动 Visual Studio IDE。

img/340876_10_En_2_Fig2_HTML.jpg

图 2-2。

新的 Visual Studio 启动对话框

选择“创建一个n ew 项目”选项,会出现“创建新项目”对话框提示。如图 2-3 所示,最近使用的模板(如果有)在左边,所有可用的模板在右边,包括一组过滤器和一个搜索框。

img/340876_10_En_2_Fig3_HTML.jpg

图 2-3。

“创建新项目”对话框

首先,创建一个新的控制台应用(。NET Core) C# 项目,确保选择 C# 版本,而不是 Visual Basic 版本。

下一个屏幕是“配置你的新项目”对话框,如图 2-4 所示。输入 SimpleCSharpConsoleApp 作为项目名称,并为项目选择一个位置。该向导还将创建一个 Visual Studio 解决方案,默认情况下以项目名称命名。

img/340876_10_En_2_Fig4_HTML.jpg

图 2-4。

“配置您的新项目”对话框

Note

创建解决方案和项目也可以使用。NET Core CLI。这将在 Visual Studio 代码中介绍。

一旦创建了项目,您将会看到初始的 C# 代码文件(名为Program.cs)已经在代码编辑器中打开。用下面的代码替换Main()方法中的单行代码。您会注意到,在您键入时,智能感知(代码完成帮助)将在您应用点运算符时生效。

static void Main(string[] args)
{
  // Set up Console UI (CUI)
  Console.Title = "My Rocking App";
  Console.ForegroundColor = ConsoleColor.Yellow;
  Console.BackgroundColor = ConsoleColor.Blue;
  Console.WriteLine("*************************************");
  Console.WriteLine("***** Welcome to My Rocking App *****");
  Console.WriteLine("*************************************");
  Console.BackgroundColor = ConsoleColor.Black;

  // Wait for Enter key to be pressed.
  Console.ReadLine();
}

这里,您使用的是在System名称空间中定义的Console类。因为System名称空间已经通过using语句自动包含在文件的顶部,所以不需要在类名前限定名称空间(例如System.Console.WriteLine())。这个程序没有做任何太有趣的事情;但是,请注意对Console.ReadLine()的最后调用。这只是为了确保用户必须按键才能终止应用。对于 Visual Studio 2019,这是不必要的,因为 VS 调试器将暂停程序并阻止它退出。如果你要导航到编译版本并运行它,当调试程序时,程序几乎会立即消失!

Note

如果你想改变 VS 调试体验,自动结束程序,选择工具➤选项➤调试➤调试停止时自动关闭控制台。

改变目标。NET 核心框架

默认。NET 核心版本。NET 核心控制台应用和类库是最新的 LTS 版本。网芯 3.1。要用。NET 5 或者只是检查。NET (Core ),在解决方案资源管理器中双击该项目。这将在编辑器中打开项目文件(这是 Visual Studio 2019 和的新功能。网芯)。您也可以通过在解决方案资源管理器中右击项目名称并选择“编辑项目文件”来编辑项目文件您将看到以下内容:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>
</Project>

换成不同的。NET 核心版到。NET 5,只需将 TargetFramework 值更改为 net5.0,如下所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>
</Project>

您还可以通过在解决方案资源管理器中右键单击项目名称并选择 Properties,打开 Application 选项卡,并更新目标框架值来更改目标框架,如图 2-5 所示。

img/340876_10_En_2_Fig5_HTML.jpg

图 2-5。

更改应用的目标框架

使用 C# 9 特性

在的早期版本中。NET 中,一个项目所支持的 C# 版本是可以改变的。和。NET Core 3.0+,使用的 C# 版本捆绑成框架版本。若要确认这一点,请在解决方案资源管理器中右击项目名称,然后选择“属性”。在“属性”对话框中,单击左栏中的“构建”,然后单击右下角的“高级”。这将弹出如图 2-6 所示的对话框。

img/340876_10_En_2_Fig6_HTML.jpg

图 2-6。

高级构建设置

为了。NET 5.0 项目,语言版本锁定为 C# 9。表 2-2 列出了目标框架(。网芯,。NET 标准,以及。NET Framework)和使用的默认 C# 版本。

表 2-2。

C# 8 版本和目标框架

|

目标框架

|

版本

|

C# 语言版本默认值

。网 5.x C# 9.0
。净核心 3.x C# 8.0
。净核心 2.x C# 7.3
。净标准 Two point one C# 8.0
。净标准 Two C# 7.3
.NET 标准 1.x C# 7.3
。NET 框架 全部 C# 7.3

运行和调试项目

要运行程序并查看输出,请按 Ctrl+F5 键盘命令(也可以从“调试➤”的“不调试启动”菜单选项中访问)。一旦你这样做了,你会看到一个 Windows 控制台窗口弹出在屏幕上与你的自定义(和丰富多彩的)信息。请注意,当您使用 Ctrl+F5“运行”您的程序时,您会绕过集成调试器。

Note

。NET 核心应用也可以使用 CLI 编译和执行。要运行您的项目,请在与项目文件相同的目录中输入dotnet run(在本例中为SimpleCSharpApp.csproj)。dotnet run命令也会自动构建项目。

如果您需要调试您的代码(这在构建更大的程序时肯定很重要),您的第一步是在您想要检查的代码语句处设置断点。虽然这个例子代码不多,但是通过点击代码编辑器最左边的灰色条来设置断点(注意断点是用红点图标标记的;参见图 2-7 。

img/340876_10_En_2_Fig7_HTML.jpg

图 2-7。

设置断点

如果您现在按 F5 键(或者使用“调试➤”“开始调试”菜单选项,或者单击工具栏中旁边带有“开始”的绿色箭头),您的程序将在每个断点处暂停。如您所料,您可以使用 IDE 的各种工具栏按钮和菜单选项与调试器进行交互。一旦评估完所有断点,应用将最终在Main()完成后终止。

Note

微软 ide 有复杂的调试器,你将在接下来的章节中学习各种技术。现在,请注意,当您处于调试会话中时,大量有用的选项会出现在调试菜单下。请花点时间亲自验证这一点。

使用解决方案浏览器

如果您看一下 IDE 的右侧,您会看到一个解决方案资源管理器窗口,它向您展示了一些重要的东西。首先,请注意 IDE 已经创建了一个包含单个项目的解决方案。这一开始可能会令人困惑,因为它们被赋予了相同的名称(SimpleCSharpConsoleApp)。这里的想法是一个“解决方案”可以包含多个一起工作的项目。例如,您的解决方案可能包括三个类库、一个 WPF 应用和一个 ASP.NET 核心 web 服务。本书的前几章总是有一个单独的项目;然而,当您构建一些更复杂的示例时,您将看到如何向您的初始解决方案空间添加新项目。

Note

请注意,当您在“解决方案资源管理器”窗口中选择最顶层的解决方案时,IDE 的菜单系统将向您显示一组与选择项目时不同的选项。如果您发现自己想知道某个菜单项消失到哪里了,请仔细检查您没有意外选择错误的节点。

使用可视化类设计器

Visual Studio 还使您能够以可视化的方式设计类和其他类型(如接口或委托)。类设计器实用工具允许您查看和修改项目中类型(类、接口、结构、枚举和委托)的关系。使用此工具,您可以直观地向类型添加(或从中移除)成员,并将您的修改反映在相应的 C# 文件中。此外,当您修改给定的 C# 文件时,更改会反映在类图中。

若要访问可视化类型设计器工具,第一步是插入新的类图文件。为此,激活项目➤添加新项目菜单选项,并定位类图类型(图 2-8 )。

img/340876_10_En_2_Fig8_HTML.jpg

图 2-8。

将类图文件插入到当前项目中

最初,设计器将是空的;但是,您可以将文件从解决方案资源管理器窗口拖放到图面上。例如,一旦您将Program.cs拖到设计器上,您会发现Program类的可视化表示。如果你点击给定类型的箭头图标,你可以显示或隐藏该类型的成员(参见图 2-9 )。

img/340876_10_En_2_Fig9_HTML.jpg

图 2-9。

类图查看器

Note

使用类设计器工具栏,可以微调设计器图面的显示选项。

类设计器实用工具与 Visual Studio 的其他两个方面协同工作:“类详细信息”窗口(使用“查看➤其他窗口”菜单激活)和“类设计器工具箱”(使用“查看➤工具箱”菜单项激活)。“类详细信息”窗口不仅显示图中当前所选项的详细信息,还允许您动态修改现有成员和插入新成员(参见图 2-10 )。

img/340876_10_En_2_Fig10_HTML.jpg

图 2-10。

“类详细信息”窗口

类设计器工具箱也可以使用视图菜单激活,它允许您可视地将新类型插入到项目中(并创建这些类型之间的关系)(参见图 2-11 )。(请注意,要查看该工具箱,必须有一个类图作为活动窗口。)这样做时,IDE 会在后台自动创建新的 C# 类型定义。

img/340876_10_En_2_Fig11_HTML.jpg

图 2-11。

类设计器工具箱

例如,将一个新类从类设计器工具箱拖到您的类设计器上。在出现的对话框中,将这个类命名为Car。这将导致创建一个名为Car.cs的新 C# 文件,它会自动添加到您的项目中。现在,使用类细节窗口,添加一个名为PetName的公共string字段(见图 2-12 )。

img/340876_10_En_2_Fig12_HTML.jpg

图 2-12。

使用“类详细信息”窗口添加字段

如果您现在查看Car类的 C# 定义,您会看到它已经被相应地更新了(去掉了这里显示的附加代码注释):

public class Car
{
   // Public data is typically a bad idea; however,
   // it keeps this example simple.
   public string PetName;
}

现在,再次激活设计器文件,并将另一个新类拖到设计器上,并将其命名为SportsCar。单击类设计器工具箱中的继承图标,然后单击SportsCar图标的顶部。接下来,在Car类图标上点击鼠标。如果您正确地执行了这些步骤,那么您已经从Car中派生出了SportsCar类(参见图 2-13 )。

img/340876_10_En_2_Fig13_HTML.jpg

图 2-13。

可视化地从现有类派生

Note

继承的概念将在第六章中详细讨论。

为了完成这个例子,用名为GetPetName()的公共方法更新生成的SportsCar类,如下所示:

public class SportsCar : Car
{
   public string GetPetName()
   {
     PetName = "Fred";
     return PetName;
   }
}

如您所料,设计器显示了添加到SportsCar类的方法。

这就结束了您对 Visual Studio 的初步了解。在本文中,您将看到更多使用 Visual Studio 构建 C# 9 和。NET 5 应用。

建筑。带有 Visual Studio 代码的. NET 核心应用

微软的另一个流行的 IDE 是 Visual Studio Code (VSC)。Visual Studio 代码对于微软家族来说是一个相对较新的版本;是免费的、开源的、跨平台的;并在国内外的开发人员中获得了广泛的采用。网核生态系统。Visual Studio 代码的重点是(顾名思义)应用的代码。它没有 Visual Studio 中包含的许多内置功能。但是,还可以通过扩展将其他功能添加到 Visual Studio 代码中。这允许您拥有一个为您的工作流定制的快速 IDE。本书中的许多示例都是用 Visual Studio 代码构建和测试的。您可以从这里下载:

https://code.visualstudio.com/download

安装 VSC 后,您将需要添加 C# 扩展,如下所示:

https://marketplace.visualstudio.com/items?itemName=ms-dotnettools.csharp

Note

Visual Studio 代码用于开发基于多种语言的许多不同类型的应用。有 Angular,View,PHP,Java,还有很多很多更多的扩展。

试用 Visual Studio 代码

让我们快速浏览一下 Visual Studio 代码。NET 5 控制台应用。

创建解决方案和项目

当您启动 Visual Studio 代码时,您会看到一个空白板。创建解决方案和项目必须通过。NET 5 命令行界面,也称为 CLI。首先,通过选择“文件”“➤”“打开文件夹”,打开包含 Visual Studio 代码的文件夹,然后在资源管理器窗口中导航到您希望解决方案和项目所在的位置。接下来,通过选择终端➤新终端或按 Ctl+Shift+`,打开一个终端窗口。

在终端窗口中,输入以下命令创建一个空的。NET 5 解决方案文件:

dotnet new sln -n SimpleCSharpConsoleApp -o .\VisualStudioCode

这将在名为 VisualStudioCode 的子目录中创建一个名为(-n ) SimpleCSharpConsoleApp 的新解决方案文件。将 Visual Studio 代码用于单个项目应用时,不需要创建解决方案文件。Visual Studio 以解决方案为中心;Visual Studio 代码是以代码为中心的。我们在这里创建了一个解决方案文件来复制 Visual Studio 示例中的过程。

Note

这些示例使用 Windows 目录分隔符。根据您的操作系统调整分离器。

接下来,创建一个新的 C# 9/。NET 5 ( -f net5.0)同名子目录(-o)中名为(-n ) SimpleCSharpConsoleApp 的控制台应用(注意该命令必须全部在一行中):

dotnet new console -lang c# -n SimpleCSharpConsoleApp -o .\VisualStudioCode\SimpleCSharpConsoleApp -f net5.0

Note

因为目标框架是使用-f选项指定的,所以不需要像使用 Visual Studio 那样更新项目文件。

最后,使用以下命令将新创建的项目添加到解决方案中:

dotnet sln .\VisualStudioCode\SimpleCSharpConsoleApp.sln add .\VisualStudioCode\SimpleCSharpConsoleApp

Note

这只是 CLI 功能的一小部分。要发现 CLI 可以做的一切,请输入dotnet -h

探索 Visual Studio 代码工作区

正如您在图 2-14 中看到的,Visual Studio 代码工作区专注于代码,但也提供了许多附加功能来帮助您提高工作效率。浏览器(1)是一个集成的文件浏览器,在图中被选中。源代码控件(2)与 Git 集成。调试图标(3)启动适当的调试器(假设安装了正确的扩展)。下一个是扩展管理器(4)。单击调试图标将显示推荐的扩展以及所有可用扩展的列表。扩展管理器是上下文敏感的,它会根据打开的目录和子目录中的代码类型提出建议。

img/340876_10_En_2_Fig14_HTML.jpg

图 2-14。

Visual Studio 代码工作区

代码编辑器(5)具有完整的颜色编码和智能感知支持,这两者都依赖于扩展。代码映射(6)显示整个代码文件的映射,调试控制台(7)接收调试会话的输出并接受用户的输入(类似于 Visual Studio 中的即时窗口)。

还原包,构建和运行程序

那个。NET 5 CLI 拥有还原包、构建解决方案、构建项目和运行应用所需的所有功能。要恢复您的解决方案和项目所需的所有 NuGet 包,请在终端窗口(或 VSC 以外的命令窗口)中输入以下命令,确保从与解决方案文件相同的目录中运行该命令:

dotnet restore

要构建您的解决方案中的所有项目,请在终端/命令窗口中执行以下命令(同样,确保命令在与解决方案文件相同的目录中执行):

dotnet build

Note

当在包含解决方案文件的目录中执行dotnet restoredotnet build时,解决方案中的所有项目都会被执行。也可以通过运行 C# 项目文件目录中的命令来运行单个项目中的命令(*.csproj)。

若要在不调试的情况下运行项目,请执行以下命令。与项目文件(SimpleCSharpConsoleApp.csproj)在同一目录下的. NET CLI 命令:

dotnet run

调试您的项目

要调试程序,按 F5 键盘命令或点击调试图标(图 2-14 中的 2)。假设您已经加载了 VSC 的 C# 扩展,程序将在调试模式下运行。断点的管理与使用 Visual Studio 时相同,尽管它们在编辑器中没有那么明显(图 2-15 )。

img/340876_10_En_2_Fig15_HTML.jpg

图 2-15。

Visual Studio 代码中的断点

要更改要集成的终端并允许输入到您的程序中,首先打开launch.json文件(位于.vscode目录中)。将控制台入口从internalConsole更改为integratedTerminal,如下图所示:

{
   // Use IntelliSense to find out which attributes exist for C# debugging
   // Use hover for the description of the existing attributes
   // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
   "version": "0.2.0",
   "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            // If you have changed target frameworks, make sure to update the program path.
            "program": "${workspaceFolder}/SimpleCSharpConsoleApp/bin/Debug/net5.0/SimpleCSharpConsoleApp.Cs.dll",
            "args": [],
            "cwd": "${workspaceFolder}/SimpleCSharpConsoleApp",
            // For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
            "console": "integratedTerminal",
            "stopAtEntry": false
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach",
            "processId": "${command:pickProcess}"
        }
    ]
}

寻找。NET 核心和 C# 文档

C# 和。NET 核心文档非常好,可读性很强,并且充满了有用的信息。鉴于大量预定义的。NET 类型(数以千计),您必须愿意卷起袖子深入研究所提供的文档。您可以在此处查看所有 Microsoft 文档:

https://docs.microsoft.com/en-us/dotnet/csharp/

在本书的前半部分,您将会用到最多的地方是 C# 文档和。NET 核心文档,可在以下位置找到:

https://docs.microsoft.com/en-us/dotnet/csharp/
https://docs.microsoft.com/en-us/dotnet/core/

摘要

本章的目的是为您提供使用。NET 5 SDK 和运行时,并提供 Visual Studio 2019 社区版和 Visual Studio 代码之旅。如果你只对构建跨平台感兴趣。NET 核心应用,您有许多选择。Visual Studio(仅限 Windows)、Visual Studio for the Mac(仅限 Mac)和 Visual Studio Code(跨平台)都是由微软提供的。构建 WPF 或 WinForms 应用仍然需要 Windows 计算机上的 Visual Studio。

三、核心 C# 编程结构:第一部分

本章介绍了一些在您探索 C# 编程语言时必须熟悉的小范围独立主题,从而开始了您对 C # 编程语言的正式研究。NET 核心框架。首要任务是理解如何构建程序的应用对象,并检查可执行程序入口点的组成:方法Main()以及 C# 9.0 的一个新特性,顶级语句。接下来,您将研究基本的 C# 数据类型(以及它们在System名称空间中的等价类型),包括对System.StringSystem.Text.StringBuilder类的研究。

在你了解了基本的细节之后。NET 核心数据类型,然后您将研究许多数据类型转换技术,包括收缩操作、扩大操作以及关键字checkedunchecked的使用。

本章还将考察 C# var关键字的作用,它允许你隐式地定义一个局部变量。正如您将在本书后面看到的,当使用 LINQ 技术集时,隐式类型非常有用,如果不是偶尔强制的话。您将通过快速检查 C# 关键字和操作符来结束本章,这些关键字和操作符允许您使用各种循环和决策结构来控制应用的流程。

分解一个简单的 C# 程序

C# 要求所有的程序逻辑都包含在一个类型定义中(回想一下第一章中的类型是一个通用术语,指集合{类、接口、结构、枚举、委托}的成员)。与许多其他语言不同,在 C# 中不可能创建全局函数或全局数据点。相反,所有数据成员和所有方法都必须包含在类型定义中。首先,创建一个名为Chapter3_AllProject.sln的新的空解决方案,其中包含一个名为 SimpleCSharpApp 的 C# 控制台应用。

从 Visual Studio 中,选择“创建新项目”屏幕上的空白解决方案模板。当解决方案打开时,在解决方案资源管理器中右击该解决方案,然后选择“添加➤新项目”。从模板中选择“C# 控制台应用”,命名为 SimpleCSharpApp ,点击创建。记得把目标框架更新到 net5.0

从命令行执行以下操作:

dotnet new sln -n Chapter3_AllProjects

dotnet new console -lang c# -n SimpleCSharpApp -o .\SimpleCSharpApp -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\SimpleCSharpApp

您可能同意初始Program.cs文件中的代码相当平淡无奇。

using System;

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

鉴于此,用下面的代码语句更新您的Program类的Main()方法:

class Program
{
  static void Main(string[] args)
  {
    // Display a simple message to the user.
    Console.WriteLine("***** My First C# App *****");
    Console.WriteLine("Hello World!");
    Console.WriteLine();

    // Wait for Enter key to be pressed before shutting down.
    Console.ReadLine();
  }
}

Note

C# 是一种区分大小写的编程语言。因此,不同,读线读线不同。请注意,所有 C# 关键字都是小写的(例如,publiclockclassdynamic),而名称空间、类型和成员名称(按照惯例)以首字母大写开始,任何嵌入单词的首字母都是大写的(例如,Console.WriteLineSystem.Windows.MessageBoxSystem.Data.SqlClient)。作为一个经验法则,每当你收到一个关于“未定义符号”的编译器错误时,一定要首先检查你的拼写和大小写!

前面的代码包含一个支持名为Main()的单一方法的类类型的定义。默认情况下,Visual Studio 命名定义Main() Program的类;但是,如果您愿意,您可以自由更改。在 C# 9.0 之前,每个可执行的 C# 应用(控制台程序、Windows 桌面程序或 Windows 服务)都必须包含一个定义Main()方法的类,该方法用于表示应用的入口点。

正式来说,定义Main()方法的类被称为应用对象。一个可执行的应用可能有多个应用对象(这在执行单元测试时很有用),但是编译器必须知道哪个Main()方法应该被用作入口点。这可以通过项目文件中的元素或位于 Visual Studio 项目属性窗口的应用选项卡上的启动对象下拉列表框来完成。

请注意,Main()的签名带有static关键字,这将在第五章中详细讨论。目前,只需理解静态成员的作用域是类级别(而不是对象级别),因此无需首先创建新的类实例就可以调用静态成员。

除了关键字static之外,这个Main()方法还有一个参数,这个参数恰好是一个字符串数组(string[] args)。尽管您目前并不想处理这个数组,但是这个参数可能包含任意数量的传入命令行参数(稍后您将看到如何访问它们)。最后,这个Main()方法已经设置了一个void返回值,这意味着在退出方法范围之前,不需要使用return关键字显式定义返回值。

Program类的逻辑在Main()内。这里,您使用了在System名称空间中定义的Console类。它的一组成员中有一个静态的WriteLine(),正如您可能想到的,它向标准输出发送一个文本字符串和回车。您还调用了Console.ReadLine()来确保 Visual Studio IDE 启动的命令提示符保持可见。跑步的时候。NET Core 控制台应用,默认情况下,控制台窗口仍然可见。可以通过启用“工具”“➤”“选项”“➤调试”下的“调试停止时自动关闭控制台”设置来更改此行为。控制台。如果通过双击产品*.exe文件从 Windows 资源管理器执行程序,ReadLine 方法可以保持窗口打开。你很快会学到更多关于System.Console类的知识。

使用 Main()方法的变体(更新 7.1)

默认情况下,Visual Studio 将生成一个Main()方法,该方法有一个void返回值和一个string类型的数组作为单个输入参数。然而,这并不是Main()的唯一可能形式。允许使用以下任何签名构造应用的入口点(假设它包含在 C# 类或结构定义中):

// int return type, array of strings as the parameter.
static int Main(string[] args)
{
  // Must return a value before exiting!
  return 0;
}

// No return type, no parameters.
static void Main()
{
}

// int return type, no parameters.
static int Main()
{
  // Must return a value before exiting!
  return 0;
}

随着 C# 7.1 的发布,Main()方法现在可以异步了。异步编程包含在第十五章中,但是现在意识到还有四个额外的签名。

static Task Main()
static Task<int> Main()
static Task Main(string[])
static Task<int> Main(string[])

Note

Main()方法也可以被定义为 public 而不是 private。请注意,如果不提供特定的访问修饰符,则假定为 private。Visual Studio 自动将程序的Main()方法定义为隐式私有。第五章详细介绍了访问修饰符。

显然,你对如何构造Main()的选择将基于三个问题。首先,当Main()已经完成并且你的程序终止时,你想给系统返回值吗?如果是这样,你需要返回一个int数据类型,而不是void。第二,您需要处理任何用户提供的命令行参数吗?如果是,它们将被存储在string s 的数组中。最后,你需要从Main()方法中调用异步代码吗?让我们更详细地检查前两个选项,将异步选项留到第十五章。

使用顶级语句(新 9.0)

虽然在 C# 9.0 之前,所有的 C# 都是。NET 核心应用必须有一个Main()方法,C# 9.0 引入了顶级语句,消除了围绕 C# 应用入口点的许多仪式。类(Program)和Main()方法都可以被移除。要查看这一点,请更新Program.cs类以匹配以下内容:

using System;

// Display a simple message to the user.
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();

// Wait for Enter key to be pressed before shutting down.
Console.ReadLine();

你会看到,当你运行程序时,你会得到同样的结果!使用顶级语句有一些规则:

  • 应用中只有一个文件可以使用顶级语句。

  • 使用顶级语句时,程序不能有声明的入口点。

  • 顶级语句不能包含在命名空间中。

  • 顶级语句仍然访问一个string参数数组。

  • 顶级语句通过使用 return 返回应用代码(见下一节)。

  • Program类中声明的函数成为顶级语句的局部函数。(本地功能包含在第四章中。)

  • 附加类型可以在所有顶级语句之后声明。在顶级语句结束之前声明的任何类型都将导致编译错误。

在幕后,编译器填充空白。检查为更新代码生成的 IL,您将看到下面的TypeDef是应用的入口点:

// TypeDef #1 (02000002)
// -------------------------------------------------------
//     TypDefName: <Program>$  (02000002)
//     Flags     : [NotPublic] [AutoLayout] [Class] [Abstract] [Sealed] [AnsiClass] [BeforeFieldInit]  (00100180)
//     Extends   : 0100000D [TypeRef] System.Object
//     Method #1 (06000001) [ENTRYPOINT]
//     -------------------------------------------------------
//             MethodName: <Main>$ (06000001)

将它与第一章中的入口点TypeDef进行比较:

// TypeDef #1 (02000002)
// -------------------------------------------------------
//     TypDefName: CalculatorExamples.Program  (02000002)
//     Flags     : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100000)
//     Extends   : 0100000C [TypeRef] System.Object
//     Method #1 (06000001) [ENTRYPOINT]
//     -------------------------------------------------------
//             MethodName: Main (06000001)

注意第一章的例子,TypDefName值显示为名称空间(CalculatorExamples)加上类名(Program),MethodName值为Main。在使用顶级语句的更新示例中,编译器为TypDefName填充了<Program>$的值,为方法名填充了<Main>$的值。

指定应用错误代码(更新 9.0)

虽然绝大多数的Main()方法(或顶级语句)将返回void作为返回值,但是返回int(或Task<int>)的能力使 C# 与其他基于 C 的语言保持一致。按照惯例,返回值0表示程序已经成功终止,而另一个值(比如-1)表示一个错误条件(注意,值0是自动返回的,即使您构造了一个原型化的Main()方法来返回void)。

当使用顶级语句时(因此没有Main()方法),如果执行代码返回一个整数,那就是返回代码。如果没有显式返回任何东西,它仍然返回 0,就像显式使用一个Main()方法一样。

在 Windows 操作系统上,应用的返回值存储在名为%ERRORLEVEL%的系统环境变量中。如果你要创建一个以编程方式启动另一个可执行文件的应用(这个主题在第十九章中讨论),你可以使用已启动进程的ExitCode属性获得%ERRORLEVEL%的值。

假设应用的返回值是在应用终止时传递给系统的,那么应用显然不可能在运行时获得并显示其最终的错误代码。但是,为了说明如何在程序终止时查看该错误级别,首先更新顶级语句,如下所示:

// Note we are explicitly returning an int, rather than void.
// Display a message and wait for Enter key to be pressed.
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();
Console.ReadLine();

// Return an arbitrary error code.
return -1;

如果程序仍然使用一个Main()方法作为入口点,改变方法签名以返回int而不是void,如下所示:

static int Main()
{
…
}

现在让我们在批处理文件的帮助下捕获程序的返回值。使用 Windows 资源管理器,导航到包含您的项目文件的文件夹(例如,C:\SimpleCSharpApp)并将一个新的文本文件(名为SimpleCSharpApp.cmd)添加到该文件夹。将文件夹的内容更新为以下内容(如果您以前没有创作过*.cmd文件,请不要关心这些细节):

@echo off
rem A batch file for SimpleCSharpApp.exe
rem which captures the app's return value.

dotnet run
@if "%ERRORLEVEL%" == "0" goto success

:fail
  echo This application has failed!
  echo return value = %ERRORLEVEL%
  goto end
:success
  echo This application has succeeded!
  echo return value = %ERRORLEVEL%
  goto end
:end
echo All Done.

此时,打开命令提示符(或使用 VSC 终端)并导航到包含新的*.cmd文件的文件夹。通过键入文件名并按回车键来执行文件。假设您的Main()方法正在返回-1,您应该会发现如下所示的输出。如果Main()方法返回了0,您将会看到消息“该应用已经成功!”打印到控制台。

***** My First C# App *****

Hello World!

This application has failed!
return value = -1
All Done.

前面的*.cmd文件的 PowerShell 等价物如下:

dotnet run
if ($LastExitCode -eq 0) {
    Write-Host "This application has succeeded!"
} else
{
    Write-Host "This application has failed!"
}
Write-Host "All Done."

要运行这个脚本,在 VSC 终端中键入PowerShell,然后通过键入以下命令执行脚本:

.\SimpleCSharpApp.ps1

您将在终端窗口中看到以下内容:

***** My First C# App *****

Hello World!

This application has failed!
All Done.

绝大多数(如果不是全部的话)C# 应用将使用void作为来自Main()的返回值,正如您所记得的,它隐式返回错误代码 0。为此,本文中使用的Main()方法(超出当前示例)将返回void

处理命令行参数(已更新 9.0)

现在您已经更好地理解了Main()方法或顶级语句的返回值,让我们检查一下string数据的传入数组。假设您现在想要更新您的应用来处理任何可能的命令行参数。一种方法是使用 C# for循环。(请注意,C# 的迭代结构将在本章末尾详细讨论。)

// Display a message and wait for Enter key to be pressed.
Console.WriteLine("***** My First C# App *****");
Console.WriteLine("Hello World!");
Console.WriteLine();
// Process any incoming args.
for (int i = 0; i < args.Length; i++)
{
  Console.WriteLine("Arg: {0}", args[i]);
}
Console.ReadLine();
// Return an arbitrary error code.
return 0;

Note

这个例子使用了顶级语句,它没有使用Main()方法。更新Main()方法以接受args参数的内容将很快介绍。

再次使用顶级语句检查程序的生成 IL,注意,<Main>$方法接受一个名为argsstring数组,如下所示(缩写为 space):

.class private abstract auto ansi sealed beforefieldinit '<Program>$'
       extends [System.Runtime]System.Object
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()=
    ( 01 00 00 00 )
  .method private hidebysig static
          void  '<Main>$'(string[] args) cil managed
  {
    .entrypoint
…
  } // end of method '<Program>$'::'<Main>$'
} // end of class '<Program>$'

如果程序仍然使用Main()方法作为入口点,确保方法签名接受名为argsstring数组,如下所示:

static int Main(string[] args)
{
…
}

在这里,您使用System.ArrayLength属性来检查string的数组是否包含一些条目。正如你将在第四章中看到的,所有 C# 数组实际上都是System.Array类的别名,因此,共享一组公共成员。当您循环数组中的每一项时,它的值会打印到控制台窗口。在命令行提供参数同样简单,如下所示:

C:\SimpleCSharpApp>dotnet run /arg1 -arg2

***** My First C# App *****
Hello World!
Arg: /arg1
Arg: -arg2

作为标准for循环的替代方法,您可以使用 C# foreach关键字迭代一个传入的string数组。下面是一些示例用法(但同样,你将在本章后面看到循环结构的细节):

// Notice you have no need to check the size of the array when using "foreach".
// Process any incoming args using foreach.
foreach(string arg in args)
{
  Console.WriteLine("Arg: {0}", arg);
}
Console.ReadLine();
return 0;

最后,您还可以使用System.Environment类型的静态GetCommandLineArgs()方法来访问命令行参数。该方法的返回值是一个由string组成的数组。第一个条目包含应用本身的名称,而数组中的其余元素包含各个命令行参数。

.
// Get arguments using System.Environment.
string[] theArgs = Environment.GetCommandLineArgs();
foreach(string arg in theArgs)
{
  Console.WriteLine("Arg: {0}", arg);
}
Console.ReadLine();
return 0;

Note

GetCommandLineArgs方法不通过Main()方法接收应用的参数,也不依赖于string[] args参数。

当然,由您来决定您的程序将响应哪些命令行参数(如果有的话)以及它们必须如何被格式化(比如用一个-/前缀)。在这里,我只是传递了一系列直接打印到命令提示符下的选项。然而,假设您正在创建一个新的视频游戏,并编写您的应用来处理一个名为-godmode的选项。如果用户用这个标志启动你的应用,你知道他实际上是一个骗子,你可以采取适当的行动。

用 Visual Studio 指定命令行参数

在现实世界中,最终用户在启动程序时可以选择提供命令行参数。但是,在开发周期中,出于测试目的,您可能希望指定可能的命令行标志。若要使用 Visual Studio 执行此操作,请在解决方案资源管理器中右击项目名称,选择“属性”,然后导航到左侧的“调试”选项卡。在那里,使用“应用参数”文本框指定值(见图 3-1 )并保存您的更改。

img/340876_10_En_3_Fig1_HTML.jpg

图 3-1。

在 Visual Studio 中设置应用参数

在您建立了这样的命令行参数之后,当在 Visual Studio IDE 中调试或运行您的应用时,它们将自动传递给Main()方法。

一个有趣的旁白:该系统的一些额外成员。环境类

除了GetCommandLineArgs()之外,Environment类还公开了许多非常有用的方法。具体来说,这个类允许您获取有关当前承载您的。NET 5 应用使用各种静态成员。为了说明System.Environment的用处,更新您的代码来调用名为ShowEnvironmentDetails()的本地方法。

// Local method within the Top-level statements.
ShowEnvironmentDetails();

Console.ReadLine();
return -1;
}

在顶级语句之后实现这个方法来调用Environment类型的各种成员:

static void ShowEnvironmentDetails()
{
  // Print out the drives on this machine,
  // and other interesting details.
  foreach (string drive in Environment.GetLogicalDrives())
  {
    Console.WriteLine("Drive: {0}", drive);
  }
  Console.WriteLine("OS: {0}", Environment.OSVersion);
  Console.WriteLine("Number of processors: {0}",
    Environment.ProcessorCount);
  Console.WriteLine(".NET Core Version: {0}",
    Environment.Version);
}

以下输出显示了调用此方法的可能测试运行:

***** My First C# App *****

Hello World!

Drive: C:\
OS: Microsoft Windows NT 10.0.19042.0
Number of processors: 16
.NET Core Version: 5.0.0

Environment类型定义的成员不同于上一个示例中显示的成员。表 3-1 记录了一些感兴趣的附加属性;但是,请务必查看在线文档以了解完整的详细信息。

表 3-1

选择系统属性。环境

|

财产

|

生命的意义

ExitCode 获取或设置应用的退出代码
Is64BitOperatingSystem 返回一个bool来表示主机是否运行 64 位操作系统
MachineName 获取当前计算机的名称
NewLine 获取当前环境的换行符
SystemDirectory 返回系统目录的完整路径
UserName 返回启动该应用的用户名
Version 返回一个代表.NETCore 平台

使用系统。控制台类

在本书前几章中创建的几乎所有示例应用都大量使用了System.Console类。虽然控制台用户界面(CUI)确实不如图形用户界面(GUI)或 web 应用那样吸引人,但是将早期的示例限制在控制台程序将使您能够专注于 C# 的语法和。NET 5 平台,而不是处理构建桌面 GUI 或网站的复杂性。

顾名思义,Console类封装了基于控制台的应用的输入、输出和错误流操作。表 3-2 列出了一些(但肯定不是全部)感兴趣的成员。正如您所看到的,Console类确实提供了一些成员,可以为简单的命令行应用增添趣味,比如改变背景和前景色以及发出哔哔声(各种频率!).

表 3-2。

选择系统成员。安慰

|

成员

|

生命的意义

Beep() 此方法强制控制台发出指定频率和持续时间的嘟嘟声。
BackgroundColor 这些属性设置当前输出的背景/前景色。
ForegroundColor 它们可以被赋予ConsoleColor枚举的任何成员。
BufferHeightBufferWidth 这些属性控制控制台缓冲区的高度/宽度。
Title 此属性获取或设置当前控制台的标题。
WindowHeightWindowWidthWindowTopWindowLeft 这些属性控制控制台相对于已建立缓冲区的尺寸。
Clear() 此方法清除已建立的缓冲区和控制台显示区域。

使用控制台类执行基本输入和输出(I/O)

除了表 3-2 中的成员之外,Console类型还定义了一组捕获输入和输出的方法,所有这些方法都是静态的,因此通过在方法名前面加上类名(Console)来调用。正如您所看到的,WriteLine()将一个文本字符串(包括回车)抽取到输出流中。Write()方法将文本抽取到输出流中,不需要回车。ReadLine()允许您从输入流接收信息,直到按下回车键,而Read()用于从输入流中捕获单个字符。

为了说明使用Console类的简单 I/O,创建一个名为 BasicConsoleIO 的新控制台应用项目,并使用以下 CLI 命令将其添加到您的解决方案中:

dotnet new console -lang c# -n BasicConsoleIO -o .\BasicConsoleIO -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\BasicConsoleIO

用以下代码替换Program.cs代码:

using System;
Console.WriteLine("***** Basic Console I/O *****");
GetUserData();
Console.ReadLine();
static void GetUserData()
{
}

Note

Visual Studio 和 Visual Studio 代码都支持许多“代码片段”,这些代码片段在激活后将插入代码。在本文的前几章中,cw代码片段非常有用,因为它会自动扩展到Console.WriteLine()!为了测试你自己,在你的code中输入cw,然后按 Tab 键。注意:在 Visual Studio 代码中,你按一次 Tab 键;在 Visual Studio 中,必须按两次 Tab 键。

在顶级语句之后实现此方法,逻辑提示用户输入一些信息,并将每一项回显到标准输出流。例如,您可以要求用户输入姓名和年龄(为简单起见,将其视为文本值,而不是预期的数值),如下所示:

static void GetUserData()
{
  // Get name and age.
  Console.Write("Please enter your name: ");
  string userName = Console.ReadLine();
  Console.Write("Please enter your age: ");
  string userAge = Console.ReadLine();

  // Change echo color, just for fun.
  ConsoleColor prevColor = Console.ForegroundColor;
  Console.ForegroundColor = ConsoleColor.Yellow;

  // Echo to the console.
  Console.WriteLine("Hello {0}! You are {1} years old.",
  userName, userAge);

  // Restore previous color.
  Console.ForegroundColor = prevColor;
}

毫不奇怪,当您运行这个应用时,输入数据被打印到控制台(使用自定义颜色启动!).

格式化控制台输出

在这前几章中,您可能已经注意到在各种字符串文字中出现了大量的标记,如{0}{1}。那个。NET 5 平台支持的字符串格式有点类似于 c 语言的printf()语句。简而言之,当您定义一个包含数据段的字符串文字时,其值直到运行时才知道,您可以使用这个花括号语法在字符串文字中指定一个占位符。在运行时,传入Console.WriteLine()的值会替换每个占位符。

WriteLine()的第一个参数代表一个字符串文字,它包含由{0}{1}{2}等指定的可选占位符。请注意,花括号占位符的第一个序号总是以0开头。WriteLine()的其余参数只是插入到各自占位符中的值。

Note

如果唯一编号的花括号占位符比填充参数多,将在运行时收到格式异常。但是,如果填充参数多于占位符,未使用的填充参数将被忽略。

允许给定的占位符在给定的字符串中重复出现。例如,如果你是披头士的粉丝,想要构建字符串"9, Number 9, Number 9",你可以这样写:

// John says...
Console.WriteLine("{0}, Number {0}, Number {0}", 9);

另外,要知道可以将每个占位符放在字符串中的任何位置,并且不需要按照递增的顺序。例如,考虑下面的代码片段:

// Prints: 20, 10, 30
Console.WriteLine("{1}, {0}, {2}", 10, 20, 30);

字符串也可以使用字符串内插法格式化,这将在本章后面介绍。

格式化数字数据

如果需要对数字数据进行更精细的格式化,每个占位符可以选择包含各种格式字符。表 3-3 显示了最常见的格式化选项。

表 3-3。

.NETCore 数字格式字符

|

字符串格式字符

|

生命的意义

Cc 用于格式化货币。默认情况下,旗帜会将当地文化符号作为前缀(美元符号[$]代表美国英语)。
Dd 用于格式化十进制数。该标志还可以指定用于填充该值的最小位数。
Ee 用于指数记数法。大小写控制指数常量是大写(E)还是小写(e)。
Ff 用于定点格式化。该标志还可以指定用于填充该值的最小位数。
Gg 代表将军。此字符可用于将数字格式化为固定格式或指数格式。
Nn 用于基本的数字格式(带逗号)。
Xx 用于十六进制格式。如果您使用大写的X,您的十六进制格式也将包含大写字符。

这些格式字符使用冒号标记作为给定占位符值的后缀(例如,{0:C}{1:d}{2:X})。举例来说,更新Main()方法来调用名为FormatNumericalData()的新助手函数。在您的Program类中实现这个方法,以多种方式格式化一个固定的数值。

// Now make use of some format tags.
static void FormatNumericalData()
{
  Console.WriteLine("The value 99999 in various formats:");
  Console.WriteLine("c format: {0:c}", 99999);
  Console.WriteLine("d9 format: {0:d9}", 99999);
  Console.WriteLine("f3 format: {0:f3}", 99999);
  Console.WriteLine("n format: {0:n}", 99999);

  // Notice that upper- or lowercasing for hex
  // determines if letters are upper- or lowercase.
  Console.WriteLine("E format: {0:E}", 99999);
  Console.WriteLine("e format: {0:e}", 99999);
  Console.WriteLine("X format: {0:X}", 99999);
  Console.WriteLine("x format: {0:x}", 99999);
}

下面的输出显示了调用FormatNumericalData()方法的结果:

The value 99999 in various formats:

c format: $99,999.00
d9 format: 000099999
f3 format: 99999.000
n format: 99,999.00
E format: 9.999900E+004
e format: 9.999900e+004
X format: 1869F
x format: 1869f

在整篇文章中,您会看到其他需要的格式示例;但是,如果您有兴趣进一步研究字符串格式,请在。NET 核心文档。

格式化控制台应用之外的数字数据

最后要注意的是,字符串格式字符的使用不仅限于控制台程序。当调用静态string.Format()方法时,可以使用相同的格式化语法。当您需要在运行时编写文本数据以用于任何类型的应用(例如,桌面 GUI 应用、ASP.NET web 应用等)时,这很有帮助。).

string.Format()方法返回一个新的string对象,该对象根据提供的标志进行格式化。以下代码将字符串格式化为十六进制:

  // Using string.Format() to format a string literal.
  string userMessage = string.Format("100000 in hex is {0:x}", 100000);

使用系统数据类型和相应的 C# 关键字

与任何编程语言一样,C# 为基本数据类型定义了关键字,这些关键字用于表示局部变量、类数据成员变量、方法返回值和参数。然而,与其他编程语言不同,这些关键字不仅仅是简单的编译器可识别的标记。相反,C# 数据类型关键字实际上是在System名称空间中成熟类型的简写符号。表 3-4 列出了每个系统数据类型、其范围、相应的 C# 关键字以及该类型是否符合通用语言规范(CLS)。所有的系统类型都在 system 命名空间中,为了便于阅读,没有在图表中显示。

表 3-4。

C# 的内在数据类型

|

C# 速记

|

CLS 顺从吗?

|

系统类型

|

范围

|

生命的意义

bool Boolean 对还是错 代表真理或谬误
sbyte SByte –128 至 127 有符号的 8 位数字
byte Byte 0 到 255 无符号 8 位数
short Int16 –32768 至 32767 有符号的 16 位数字
ushort UInt16 0 到 65,535 无符号 16 位数
int Int32 -2147483648 至 2147483647 有符号的 32 位数字
uint UInt32 0 到 4,294,967,295 无符号 32 位数字
long Int64 -9 223 372 036 854 775 808 至 9 223 372 036 854 775 807 带符号的 64 位数字
ulong UInt64 0 到 18446744073709551615 无符号 64 位数字
char Char U+0000 至 U+ffff 单个 16 位 Unicode 字符
float Single –3.4 1038至+3.4 10 38 32 位浮点数
double Double 5.0 10–324至 1.7 10 308 64 位浮点数
decimal Decimal (–7.9 x 1028至 7.9 x 10 28 )/(10 0 至 28 128 位有符号数
string String 受系统内存限制 表示一组 Unicode 字符
object Object 可以在对象变量中存储任何数据类型 中所有类型的基类。净宇宙

Note

回想一下第一章中提到的 CLS 合规。NET 核心代码可以被任何其他人使用。NET 核心编程语言。如果您从您的程序中公开不符合 CLS 标准的数据,其他。NET 核心语言可能无法利用它。

理解变量声明和初始化

当声明局部变量(例如,成员范围内的变量)时,可以通过指定数据类型,后跟变量名来实现。首先,创建一个名为 BasicDataTypes 的新控制台应用项目,并使用以下命令将其添加到解决方案中:

dotnet new console -lang c# -n BasicDataTypes -o .\BasicDataTypes -f net5.0
dotnet sln .\Chapter3_AllProjects.sln add .\BasicDataTypes

将代码更新为以下内容:

using System;
using System.Numerics;

Console.WriteLine("***** Fun with Basic Data Types *****\n");

现在,添加以下静态局部函数,并从顶级语句中调用它:

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  // Local variables are declared as so:
  // dataType varName;
  int myInt;
  string myString;
  Console.WriteLine();
}

请注意,在赋值初始值之前使用局部变量是一个编译器错误。考虑到这一点,在声明时给本地数据点分配一个初始值是一个好的做法。您可以在一行中完成,也可以将声明和赋值分成两个代码语句来完成。

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  // Local variables are declared and initialized as follows:
  // dataType varName = initialValue;
  int myInt = 0;

  // You can also declare and assign on two lines.
  string myString;
  myString = "This is my character data";

  Console.WriteLine();
}

也允许在一行代码中声明同一基础类型的多个变量,如以下三个bool变量:

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  int myInt = 0;
  string myString;
  myString = "This is my character data";

  // Declare 3 bools on a single line.
  bool b1 = true, b2 = false, b3 = b1;
  Console.WriteLine();
}

由于 C# bool关键字只是System.Boolean结构的简写符号,所以也可以使用全名来分配任何数据类型(当然,对于任何 C# 数据类型关键字也是如此)。下面是LocalVarDeclarations()的最终实现,它说明了声明一个局部变量的各种方法:

static void LocalVarDeclarations()
{
  Console.WriteLine("=> Data Declarations:");
  // Local variables are declared and initialized as follows:
  // dataType varName = initialValue;
  int myInt = 0;

  string myString;
  myString = "This is my character data";

  // Declare 3 bools on a single line.
  bool b1 = true, b2 = false, b3 = b1;

  // Use System.Boolean data type to declare a bool.
  System.Boolean b4 = false;

  Console.WriteLine("Your data: {0}, {1}, {2}, {3}, {4}, {5}",
      myInt, myString, b1, b2, b3, b4);
  Console.WriteLine();
}

默认文字(新 7.1)

default文字为变量分配其数据类型的默认值。这适用于标准数据类型以及定制类(第五章和泛型类型(第十章)。创建一个名为DefaultDeclarations()的新方法,并添加以下代码:

static void DefaultDeclarations()
{
  Console.WriteLine("=> Default Declarations:");
  int myInt = default;
}

使用内部数据类型和新运算符(更新 9.0)

所有的内在数据类型都支持所谓的默认构造函数 ??(见第五章)。此功能允许您使用new关键字创建一个变量,该关键字会自动将变量设置为其默认值:

  • bool变量被设置为false

  • 数值数据被设置为0(或者在浮点数据类型的情况下设置为0.0)。

  • char变量被设置为单个空字符。

  • BigInteger变量被设置为0

  • DateTime变量被设置为1/1/0001 12:00:00 AM

  • 对象引用(包括string s)被设置为null

Note

前面列表中提到的BigInteger数据类型将在稍后解释。

尽管在创建基本数据类型变量时使用new关键字更麻烦,但下面是语法上格式良好的 C# 代码:

static void NewingDataTypes()
{
  Console.WriteLine("=> Using new to create variables:");
  bool b = new bool();              // Set to false.
  int i = new int();                // Set to 0.
  double d = new double();          // Set to 0.
  DateTime dt = new DateTime();     // Set to 1/1/0001 12:00:00 AM
  Console.WriteLine("{0}, {1}, {2}, {3}", b, i, d, dt);
  Console.WriteLine();
}

C# 9.0 增加了创建变量实例的快捷方式。这个快捷方式只是使用没有数据类型的关键字new()。这里显示的是NewingDataTypes的更新版本:

static void NewingDataTypesWith9()
{
  Console.WriteLine("=> Using new to create variables:");
  bool b = new();              // Set to false.
  int i = new();                // Set to 0.
  double d = new();          // Set to 0.
  DateTime dt = new();     // Set to 1/1/0001 12:00:00 AM
  Console.WriteLine("{0}, {1}, {2}, {3}", b, i, d, dt);
  Console.WriteLine();
}

了解数据类型类层次结构

有趣的是,即使是原始人。NET Core 数据类型被安排在一个类层次结构中。如果你是继承领域的新手,你会在第六章中发现全部细节。在此之前,只需理解位于类层次结构顶部的类型提供了一些授予派生类型的默认行为。这些核心系统类型之间的关系如图 3-2 所示。

img/340876_10_En_3_Fig2_HTML.jpg

图 3-2。

系统类型的类层次结构

注意,每个类型最终都是从System.Object派生出来的,它定义了一组方法(例如,ToString()Equals()GetHashCode()),这些方法对。NET 核心基础类库(这些方法在第六章中有详细介绍)。

还要注意,许多数字数据类型都是从名为System.ValueType的类中派生出来的。ValueType的后代被自动分配到堆栈上,因此具有可预测的生命周期,并且非常高效。另一方面,继承链中没有System.ValueType的类型(比如System.TypeSystem.StringSystem.ArraySystem.ExceptionSystem.Delegate)不会被分配到堆栈中,而是被分配到垃圾收集堆中。(你可以在第四章找到更多关于这种区别的信息。)

不要太纠结于System.ObjectSystem.ValueType的细节,只要理解因为 C# 关键字(比如int)只是对应系统类型(在本例中为System.Int32)的简写符号,下面是完全合法的语法,假设System.Int32(c#int)最终从System.Object派生而来,因此可以调用它的任何公共成员,如这个额外的助手函数所示:

static void ObjectFunctionality()
{
  Console.WriteLine("=> System.Object Functionality:");

  // A C# int is really a shorthand for System.Int32,
  // which inherits the following members from System.Object.
  Console.WriteLine("12.GetHashCode() = {0}", 12.GetHashCode());
  Console.WriteLine("12.Equals(23) = {0}", 12.Equals(23));
  Console.WriteLine("12.ToString() = {0}", 12.ToString());
  Console.WriteLine("12.GetType() = {0}", 12.GetType());
  Console.WriteLine();
}

如果您要从Main()中调用这个方法,您会发现如下所示的输出:

=> System.Object Functionality:

12.GetHashCode() = 12
12.Equals(23) = False
12.ToString() = 12
12.GetType() = System.Int32

了解数字数据类型的成员

要继续试验固有的 C# 数据类型,请理解。NET Core 支持MaxValueMinValue属性,这些属性提供关于给定类型可以存储的范围的信息。除了MinValue / MaxValue属性之外,一个给定的数值系统类型可以定义更多有用的成员。例如,System.Double类型允许您获得ε和无穷大的值(这可能会引起那些数学爱好者的兴趣)。举例来说,考虑下面的助手函数:

static void DataTypeFunctionality()
{
  Console.WriteLine("=> Data type Functionality:");

  Console.WriteLine("Max of int: {0}", int.MaxValue);
  Console.WriteLine("Min of int: {0}", int.MinValue);
  Console.WriteLine("Max of double: {0}", double.MaxValue);
  Console.WriteLine("Min of double: {0}", double.MinValue);
  Console.WriteLine("double.Epsilon: {0}", double.Epsilon);
  Console.WriteLine("double.PositiveInfinity: {0}",
    double.PositiveInfinity);
  Console.WriteLine("double.NegativeInfinity: {0}",
    double.NegativeInfinity);
  Console.WriteLine();
}

当您定义一个文字整数(比如500)时,运行时会将数据类型默认为int。同样,文字浮点数据(如55.333)将默认为double。要将底层数据类型设置为long,请使用后缀lL ( 4L)。要声明一个float变量,对原始数值(5.3F)使用后缀fF,对浮点数使用后缀mM声明一个小数(300.5M)。这在隐式声明变量时变得更加重要,这将在本章后面讨论。

了解系统成员。布尔代数学体系的

接下来,考虑System.Boolean数据类型。C# bool可以接受的唯一有效赋值是来自集合{ true || false }。鉴于这一点,应该清楚的是System.Boolean不支持MinValue / MaxValue属性集,而是支持TrueString / FalseString(分别产生字符串"True""False")。这里有一个例子:

Console.WriteLine("bool.FalseString: {0}", bool.FalseString);
Console.WriteLine("bool.TrueString: {0}", bool.TrueString);

了解系统成员。茶

C# 文本数据由关键字stringchar表示,它们是System.StringSystem.Char的简单简写符号,两者都是 Unicode。您可能已经知道,string代表一组连续的字符(例如"Hello",而char可以代表string中的一个槽(例如'H')。

除了保存单点字符数据的能力之外,System.Char类型还为您提供了大量的功能。使用System.Char的静态方法,您能够确定一个给定的字符是数字、字母、标点符号还是其他什么。考虑以下方法:

static void CharFunctionality()
{
  Console.WriteLine("=> char type Functionality:");
  char myChar = 'a';
  Console.WriteLine("char.IsDigit('a'): {0}", char.IsDigit(myChar));
  Console.WriteLine("char.IsLetter('a'): {0}", char.IsLetter(myChar));
  Console.WriteLine("char.IsWhiteSpace('Hello There', 5): {0}",
    char.IsWhiteSpace("Hello There", 5));
  Console.WriteLine("char.IsWhiteSpace('Hello There', 6): {0}",
    char.IsWhiteSpace("Hello There", 6));
  Console.WriteLine("char.IsPunctuation('?'): {0}",
    char.IsPunctuation('?'));
  Console.WriteLine();
}

如前面的方法所示,System.Char的许多成员有两个调用约定:一个单独的字符或一个带有数字索引的字符串,该数字索引指定了要测试的字符的位置。

解析字符串数据中的值

那个。NET Core 数据类型提供了在给定文本等价(例如,解析)的情况下生成其基础类型的变量的能力。当您想要将一些用户输入数据(例如从基于 GUI 的下拉列表框中选择的数据)转换成数值时,这种技术非常有用。考虑名为ParseFromStrings()的方法中的以下解析逻辑:

static void ParseFromStrings()
{
  Console.WriteLine("=> Data type parsing:");
  bool b = bool.Parse("True");
  Console.WriteLine("Value of b: {0}", b);
  double d = double.Parse("99.884");
  Console.WriteLine("Value of d: {0}", d);
  int i = int.Parse("8");
  Console.WriteLine("Value of i: {0}", i);
  char c = Char.Parse("w");
  Console.WriteLine("Value of c: {0}", c);
  Console.WriteLine();
}

使用 TryParse 解析字符串数据中的值

上述代码的一个问题是,如果字符串不能完全转换为正确的数据类型,将会引发异常。例如,以下内容将在运行时失败:

bool b = bool.Parse("Hello");

一种解决方案是将每个对Parse()的调用包装在一个try-catch块中(异常处理在第七章中有详细介绍),这可能会增加很多代码,或者使用一个TryParse()语句。TryParse()语句接受一个out参数(第四章详细介绍了out修饰符),如果解析成功,则返回一个bool。创建一个名为ParseFromStringWithTryParse()的新方法,并添加以下代码:

static void ParseFromStringsWithTryParse()
{
  Console.WriteLine("=> Data type parsing with TryParse:");
  if (bool.TryParse("True", out bool b))
  {
    Console.WriteLine("Value of b: {0}", b);
  }
  else
  {
    Console.WriteLine("Default value of b: {0}", b);
  }
  string value = "Hello";
  if (double.TryParse(value, out double d))
  {
    Console.WriteLine("Value of d: {0}", d);
  }
  else
  {
    Console.WriteLine("Failed to convert the input ({0}) to a double and the variable was assigned the default {1}", value,d);
  }
  Console.WriteLine();
}

如果你是编程新手,不知道if / else语句是如何工作的,本章后面会详细介绍。从前面的例子中需要注意的重要一点是,如果一个字符串可以被转换成所请求的数据类型,TryParse()方法返回true,并将解析后的值赋给传递给该方法的变量。如果值不能被解析,变量被赋予默认值,并且TryParse()方法返回false

使用系统。日期时间和系统。时间间隔

名称空间定义了一些没有 C# 关键字的有用的数据类型,比如结构 ?? 和 ??。(关于System.Void的调查,如图 3-2 ,我就留给感兴趣的读者吧。)

DateTime类型包含表示特定日期(月、日、年)和时间值的数据,这两种数据都可以使用提供的成员以多种方式进行格式化。TimeSpan结构允许您使用各种成员轻松定义和转换时间单位。

static void UseDatesAndTimes()
{
  Console.WriteLine("=> Dates and Times:");

  // This constructor takes (year, month, day).
  DateTime dt = new DateTime(2015, 10, 17);

  // What day of the month is this?
  Console.WriteLine("The day of {0} is {1}", dt.Date, dt.DayOfWeek);

  // Month is now December.
  dt = dt.AddMonths(2);
  Console.WriteLine("Daylight savings: {0}", dt.IsDaylightSavingTime());

  // This constructor takes (hours, minutes, seconds).
  TimeSpan ts = new TimeSpan(4, 30, 0);
  Console.WriteLine(ts);

  // Subtract 15 minutes from the current TimeSpan and
  // print the result.
  Console.WriteLine(ts.Subtract(new TimeSpan(0, 15, 0)));
}

与系统一起工作。数字命名空间

System.Numerics名称空间定义了一个名为BigInteger的结构。顾名思义,BigInteger数据类型可以在需要表示巨大的数值时使用,这些数值不受固定上限或下限的约束。

Note

System.Numerics名称空间定义了第二个名为Complex的结构,它允许您对复杂的数字数据进行数学建模(例如,虚数、实数、双曲正切)。请参考。NET 核心文档。

而你们中的许多人。NET 核心应用可能永远不需要使用BigInteger结构,如果您发现需要定义大量数值,您的第一步是将下面的using指令添加到文件中:

// BigInteger lives here!
using System.Numerics;

此时,您可以使用new操作符创建一个BigInteger变量。在构造函数中,可以指定一个数值,包括浮点数据。然而,C# 隐式地将非浮点数类型化为int,将浮点数类型化为double。那么,如何将BigInteger设置为一个巨大的值,同时又不会溢出用于原始数值的默认数据类型呢?

最简单的方法是将大量数值建立为文本文字,可以通过静态的Parse()方法将其转换为BigInteger变量。如果需要的话,你也可以将一个字节数组直接传递给BigInteger类的构造函数。

Note

在你给一个BigInteger变量赋值后,你不能改变它,因为数据是不可变的。然而,BigInteger类定义了许多成员,这些成员将根据您的数据修改返回新的BigInteger对象(例如下面代码示例中使用的静态Multiply()方法)。

在任何情况下,在你定义了一个BigInteger变量之后,你会发现这个类定义了类似的成员作为其他内在的 C# 数据类型(例如floatint)。此外,BigInteger类定义了几个静态成员,允许您将基本的数学表达式(如加法和乘法)应用于BigInteger变量。下面是一个使用BigInteger类的例子:

static void UseBigInteger()
{
  Console.WriteLine("=> Use BigInteger:");
  BigInteger biggy =
    BigInteger.Parse("9999999999999999999999999999999999999999999999");
  Console.WriteLine("Value of biggy is {0}", biggy);
  Console.WriteLine("Is biggy an even value?: {0}", biggy.IsEven);
  Console.WriteLine("Is biggy a power of two?: {0}", biggy.IsPowerOfTwo);
  BigInteger reallyBig = BigInteger.Multiply(biggy,
    BigInteger.Parse("8888888888888888888888888888888888888888888"));
  Console.WriteLine("Value of reallyBig is {0}", reallyBig);
}

同样重要的是要注意到,BigInteger数据类型响应 C# 固有的数学运算符,如+-*。因此,您可以编写以下代码,而不是调用BigInteger.Multiply()将两个巨大的数字相乘:

BigInteger reallyBig2 = biggy * reallyBig;

至此,我希望您理解表示基本数据类型的 C# 关键字在。NET 核心基类库,每个都公开一个固定的功能。虽然我没有详细介绍这些数据类型的每个成员,但是您可以根据自己的需要深入研究这些细节。请务必查阅。NET 核心文档,了解有关各种。NET 数据类型—您可能会对内置功能的数量感到惊讶。

使用数字分隔符(新 7.0)

有时,当给一个数值变量分配一个大的数字时,数字的数量会超过肉眼所能看到的数量。C# 7.0 引入了下划线(_)作为数字分隔符(用于integerlongdecimaldouble数据或十六进制类型)。C# 7.2 允许十六进制值(以及接下来介绍的新的二进制文字,在开始声明后以下划线开头)。以下是使用新数字分隔符的示例:

static void DigitSeparators()
{
  Console.WriteLine("=> Use Digit Separators:");
  Console.Write("Integer:");
  Console.WriteLine(123_456);
  Console.Write("Long:");
  Console.WriteLine(123_456_789L);
  Console.Write("Float:");
  Console.WriteLine(123_456.1234F);
  Console.Write("Double:");
  Console.WriteLine(123_456.12);
  Console.Write("Decimal:");
  Console.WriteLine(123_456.12M);
  //Updated in 7.2, Hex can begin with _
  Console.Write("Hex:");
  Console.WriteLine(0x_00_00_FF);
}

使用二进制文字(新的 7.0/7.2)

C# 7.0 为二进制值引入了新的文字,例如,用于创建位掩码。新的数字分隔符适用于二进制文字,C# 7.2 允许二进制和十六进制数字以下划线开头。现在,二进制数可以像你想的那样书写。这里有一个例子:

0b_0001_0000

下面是一个方法,显示了如何使用带有数字分隔符的新文字:

 static void BinaryLiterals()
{
  //Updated in 7.2, Binary can begin with _
  Console.WriteLine("=> Use Binary Literals:");
  Console.WriteLine("Sixteen: {0}",0b_0001_0000);
  Console.WriteLine("Thirty Two: {0}",0b_0010_0000);
  Console.WriteLine("Sixty Four: {0}",0b_0100_0000);
}

使用字符串数据

System.String提供了许多您期望从这样一个实用程序类中得到的方法,包括返回字符数据长度、在当前字符串中查找子字符串以及在大写/小写之间进行转换的方法。表 3-5 列出了一些(但绝不是全部)有趣的成员。

表 3-5。

选择系统成员。线

|

字符串成员

|

生命的意义

Length 该属性返回当前字符串的长度。
Compare() 这个静态方法比较两个字符串。
Contains() 此方法确定字符串是否包含特定的子字符串。
Equals() 此方法测试两个 string 对象是否包含相同的字符数据。
Format() 这个静态方法使用其他原语(例如,数字数据、其他字符串)和本章前面讨论过的{0}符号来格式化字符串。
Insert() 此方法在给定的字符串中插入一个字符串。
PadLeft() \ PadRight() 这些方法用于用一些字符填充字符串。
Remove() \ Replace() 这些方法用于接收经过修改(字符被删除或替换)的字符串副本。
Split() 该方法返回一个包含该实例中子字符串的String数组,这些子字符串由指定的char数组或string数组的元素分隔。
Trim() 此方法从当前字符串的开头和结尾移除一组指定字符的所有匹配项。
ToUpper() \ ToLower() 这些方法分别以大写或小写格式创建当前字符串的副本。

执行基本的字符串操作

System.String的成员一起工作正如你所料。只需声明一个string变量,并通过点运算符使用提供的功能。请注意,System.String的一些成员是静态成员,因此在类(而不是对象)级别被调用。

假设您已经创建了一个名为 FunWithStrings 的新控制台应用项目,并将其添加到您的解决方案中。清除现有代码并添加以下内容:

using System;
using System.Text;
BasicStringFunctionality();

static void BasicStringFunctionality()
{
  Console.WriteLine("=> Basic String functionality:");
  string firstName = "Freddy";
  Console.WriteLine("Value of firstName: {0}", firstName);
  Console.WriteLine("firstName has {0} characters.", firstName.Length);
  Console.WriteLine("firstName in uppercase: {0}", firstName.ToUpper());
  Console.WriteLine("firstName in lowercase: {0}", firstName.ToLower());
  Console.WriteLine("firstName contains the letter y?: {0}",
    firstName.Contains("y"));
  Console.WriteLine("New first name: {0}", firstName.Replace("dy", ""));
  Console.WriteLine();
}

这里没有太多要说的,因为这个方法只是在一个本地string变量上调用各种成员,比如ToUpper()Contains(),以产生各种格式和转换。以下是初始输出:

***** Fun with Strings *****

=> Basic String functionality:
Value of firstName: Freddy
firstName has 6 characters.
firstName in uppercase: FREDDY
firstName in lowercase: freddy
firstName contains the letter y?: True
firstName after replace: Fred

虽然这个输出看起来不太令人惊讶,但是通过调用Replace()方法看到的输出有点误导。实际上,firstName变量一点都没变;相反,你会收到一个修改后的新的string。过一会儿,您将重新审视字符串不可变的本质。

执行字符串串联

String变量可以通过 C# +(以及+=)操作符连接起来构建更大的string。如你所知,这种技术被正式命名为字符串连接。考虑以下新的助手函数:

static void StringConcatenation()
{
  Console.WriteLine("=> String concatenation:");
  string s1 = "Programming the ";
  string s2 = "PsychoDrill (PTP)";
  string s3 = s1 + s2;
  Console.WriteLine(s3);
  Console.WriteLine();
}

您可能有兴趣知道 C# +符号是由编译器处理的,以发出对静态String.Concat()方法的调用。考虑到这一点,可以通过直接调用String.Concat()来执行字符串连接,如下面这个方法的修改版本所示(尽管这样做并没有带来任何好处——事实上,您已经招致了额外的击键!):

static void StringConcatenation()
{
  Console.WriteLine("=> String concatenation:");
  string s1 = "Programming the ";
  string s2 = "PsychoDrill (PTP)";
  string s3 = String.Concat(s1, s2);
  Console.WriteLine(s3);
  Console.WriteLine();
}

使用转义字符

与其他基于 C 的语言一样,C# 字符串文字可能包含各种转义字符,这些字符限定了字符数据应该如何输出到输出流。每个转义字符都以反斜杠开头,后跟一个特定的标记。如果你对这些转义字符背后的含义有点生疏,表 3-6 列出了更常见的选项。

表 3-6。

字符串转义字符

|

性格;角色;字母

|

生命的意义

\' 在字符串中插入单引号。
\" 在字符串中插入双引号。
\\ 在字符串中插入反斜杠。这在定义文件或网络路径时非常有用。
\a 触发系统警报(嘟嘟声)。对于控制台程序,这可以是给用户的音频提示。
\n 插入新行(在 Windows 平台上)。
\r 插入一个回车。
\t 在字符串中插入水平制表符。

例如,要打印每个单词之间包含一个制表符的字符串,可以使用\t转义字符。或者假设您想要创建一个包含引号的字符串文字,另一个定义目录路径,最后一个在打印字符数据后插入三个空行的字符串文字。要做到这一点而不出现编译器错误,您需要使用\"\\\n转义字符。此外,为了骚扰你周围 10 英尺范围内的任何人,请注意,我在每个字符串文字中嵌入了一个警报(触发一个嘟嘟声)。请考虑以下几点:

static void EscapeChars()
{
  Console.WriteLine("=> Escape characters:\a");
  string strWithTabs = "Model\tColor\tSpeed\tPet Name\a ";
  Console.WriteLine(strWithTabs);

  Console.WriteLine("Everyone loves \"Hello World\"\a ");
  Console.WriteLine("C:\\MyApp\\bin\\Debug\a ");

  // Adds a total of 4 blank lines (then beep again!).
  Console.WriteLine("All finished.\n\n\n\a ");
  Console.WriteLine();
}

执行字符串插值

本章中说明的花括号语法({0}{1}等)。)已经存在于。NET 平台从 1.0 版本开始。从 C# 6 开始,C# 程序员可以使用另一种语法来构建包含变量占位符的字符串文字。形式上,这被称为字符串插值。虽然该操作的输出与传统的字符串格式语法相同,但这种新方法允许您直接嵌入变量本身,而不是将它们作为逗号分隔的列表附加上去。

考虑您的Program类(StringInterpolation())的以下附加方法,它使用每种方法构建一个string变量:

static void StringInterpolation()
{
    Console.WriteLine("=> String interpolation:\a");

    // Some local variables we will plug into our larger string
    int age = 4;
    string name = "Soren";

    // Using curly-bracket syntax.
    string greeting = string.Format("Hello {0} you are {1} years old.", name, age);
    Console.WriteLine(greeting);

    // Using string interpolation
    string greeting2 = $"Hello {name} you are {age} years old.";
    Console.WriteLine(greeting2);
}

greeting2变量中,注意您正在构建的字符串是如何以美元符号($)前缀开始的。接下来,注意花括号仍然用于标记变量占位符;但是,您可以将变量直接放入作用域中,而不是使用数字标记。假定的优点是,这种新的格式化语法更容易以线性(从左到右)方式阅读,因为您不需要“跳到末尾”来查看运行时要插入的值列表。

这种新语法还有一个有趣的方面:字符串插值中使用的花括号是一个有效的作用域。因此,您可以对变量使用点操作来更改它们的状态。考虑对每个汇编的string变量进行更新。

string greeting = string.Format("Hello {0} you are {1} years old.", name.ToUpper(), age);
string greeting2 = $"Hello {name.ToUpper()} you are {age} years old.";

在这里,我通过调用ToUpper()将名称大写。请注意,在字符串插值方法中,您在调用该方法时不需要而不是添加分号终止符。鉴于此,您不能将花括号作用域用作包含多行可执行代码的完全成熟的方法作用域。相反,您可以使用点运算符调用对象上的单个成员,并定义一个简单的通用表达式,如{age += 1}

同样值得注意的是,在这个新语法中,您仍然可以在字符串中使用转义字符。因此,如果您想插入一个制表符,您可以将一个\t标记作为前缀,如下所示:

string greeting = string.Format("\tHello {0} you are {1} years old.", name.ToUpper(), age);
string greeting2 = $"\tHello {name.ToUpper()} you are {age} years old.";

定义逐字字符串(更新 8.0)

当您在一个字符串前面加上@符号时,您就创建了一个被称为的逐字字符串。使用逐字字符串,您可以禁用对文字转义字符的处理,并按原样打印出一个string。这在使用代表目录和网络路径的string时非常有用。因此,您可以简单地编写以下代码,而不是使用\\转义字符:

// The following string is printed verbatim,
// thus all escape characters are displayed.
Console.WriteLine(@"C:\MyApp\bin\Debug");

另请注意,逐字字符串可以用于保留多行字符串的空白。

// Whitespace is preserved with verbatim strings.
string myLongString = @"This is a very
     very
          very
               long string";
Console.WriteLine(myLongString);

使用逐字字符串,您还可以通过将"标记加倍来直接将双引号插入到文字字符串中。

Console.WriteLine(@"Cerebus said ""Darrr! Pret-ty sun-sets""");

通过指定插值运算符($)和逐字运算符(@),逐字字符串也可以是插值字符串。

string interp = "interpolation";
string myLongString2 = $@"This is a very
   very
         long string with {interp}";

这是 C# 8 中的新特性,顺序无关紧要。使用$@@$都可以。

使用字符串和等式

正如将在第四章中全面解释的那样,引用类型是在垃圾收集托管堆上分配的对象。默认情况下,当您对引用类型执行相等测试时(通过 C# ==!=操作符),如果引用指向内存中的同一个对象,您将返回true。然而,即使string数据类型确实是一个引用类型,相等操作符已经被重新定义来比较string对象的,而不是它们引用的内存中的对象。

static void StringEquality()
{
  Console.WriteLine("=> String equality:");
  string s1 = "Hello!";
  string s2 = "Yo!";
  Console.WriteLine("s1 = {0}", s1);
  Console.WriteLine("s2 = {0}", s2);
  Console.WriteLine();

  // Test these strings for equality.
  Console.WriteLine("s1 == s2: {0}", s1 == s2);
  Console.WriteLine("s1 == Hello!: {0}", s1 == "Hello!");
  Console.WriteLine("s1 == HELLO!: {0}", s1 == "HELLO!");
  Console.WriteLine("s1 == hello!: {0}", s1 == "hello!");
  Console.WriteLine("s1.Equals(s2): {0}", s1.Equals(s2));
  Console.WriteLine("Yo!.Equals(s2): {0}", "Yo!".Equals(s2));
  Console.WriteLine();
}

默认情况下,C# 相等运算符对string对象执行区分大小写、不区分区域性、逐个字符的相等测试。因此,"Hello!"不等于"HELLO!",这也与"hello!"不同。此外,记住stringSystem.String之间的联系,注意您可以使用StringEquals()方法以及内置的等式操作符来测试等式。最后,假设每个字符串文字(比如"Yo!")都是一个有效的System.String实例,那么您就能够从固定的字符序列中访问以字符串为中心的功能。

修改字符串比较行为

如上所述,字符串相等运算符(Compare()Equals()==)以及IndexOf()函数在默认情况下是区分大小写和不区分文化的。如果您的程序不关心大小写,这可能会导致问题。克服这个问题的一个方法是将所有内容转换为大写或小写,然后进行比较,如下所示:

if (firstString.ToUpper() == secondString.ToUpper())
{
  //Do something
}

这将复制所有小写字母的每个字符串。在大多数情况下,这可能不是问题,但如果字符串非常大,可能会影响性能。就算不是性能问题,每次写都有点痛苦。如果你忘记打电话给ToUpper()怎么办?这可能会导致程序中出现难以发现的错误。

一个更好的实践是使用前面列出的方法的重载,这些重载接受一个StringComparison枚举的值来精确地控制比较是如何完成的。表 3-7 描述了StringComparison值。

表 3-7。

StringComparison 枚举的值

|

C# 等式/关系运算符

|

生命的意义

CurrentCulture 使用区分区域性的排序规则和当前区域性比较字符串
CurrentCultureIgnoreCase 使用区分区域性的排序规则和当前区域性比较字符串,并忽略被比较字符串的大小写
InvariantCulture 使用区分区域性的排序规则和固定区域性比较字符串
InvariantCultureIgnoreCase 使用区分区域性的排序规则和固定区域性比较字符串,并忽略被比较字符串的大小写
Ordinal 使用序数(二进制)排序规则比较字符串
OrdinalIgnoreCare 使用序数(二进制)排序规则比较字符串,并忽略被比较字符串的大小写

要查看使用StringComparison选项的效果,创建一个名为StringEqualitySpecifyingCompareRules()的新方法,并添加以下代码:

static void StringEqualitySpecifyingCompareRules()
{
  Console.WriteLine("=> String equality (Case Insensitive:");
  string s1 = "Hello!";
  string s2 = "HELLO!";
  Console.WriteLine("s1 = {0}", s1);
  Console.WriteLine("s2 = {0}", s2);
  Console.WriteLine();

  // Check the results of changing the default compare rules.
  Console.WriteLine("Default rules: s1={0},s2={1}s1.Equals(s2): {2}", s1, s2, s1.Equals(s2));
  Console.WriteLine("Ignore case: s1.Equals(s2, StringComparison.OrdinalIgnoreCase): {0}",
    s1.Equals(s2, StringComparison.OrdinalIgnoreCase));
  Console.WriteLine("Ignore case, Invariant Culture: s1.Equals(s2, StringComparison.InvariantCultureIgnoreCase): {0}",
    s1.Equals(s2, StringComparison.InvariantCultureIgnoreCase));
  Console.WriteLine();
  Console.WriteLine("Default rules: s1={0},s2={1} s1.IndexOf(\"E\"): {2}", s1, s2, s1.IndexOf("E"));
  Console.WriteLine("Ignore case: s1.IndexOf(\"E\", StringComparison.OrdinalIgnoreCase): {0}", s1.IndexOf("E",
    StringComparison.OrdinalIgnoreCase));
  Console.WriteLine("Ignore case, Invariant Culture: s1.IndexOf(\"E\", StringComparison.InvariantCultureIgnoreCase): {0}",
    s1.IndexOf("E", StringComparison.InvariantCultureIgnoreCase));
  Console.WriteLine();
}

虽然这里的例子很简单,并且在大多数文化中使用相同的字母,但是如果您的应用需要考虑不同的文化集,那么使用StringComparison选项是必须的。

字符串是不可变的

System.String的一个有趣的方面是,在你给一个string对象赋了初始值之后,角色数据就不能被改变。乍一看,这似乎是一个彻头彻尾的谎言,因为您总是将字符串重新分配给新值,并且因为System.String类型定义了许多方法,这些方法似乎以某种方式修改字符数据(例如大写和小写)。然而,如果你更仔细地观察幕后发生的事情,你会注意到string类型的方法实际上是以修改后的格式返回给你一个新的string对象。

static void StringsAreImmutable()
{
    Console.WriteLine("=> Immutable Strings:\a");
  // Set initial string value.
  string s1 = "This is my string.";
  Console.WriteLine("s1 = {0}", s1);

  // Uppercase s1?
  string upperString = s1.ToUpper();
  Console.WriteLine("upperString = {0}", upperString);

  // Nope! s1 is in the same format!
  Console.WriteLine("s1 = {0}", s1);
}

如果您检查下面的相关输出,您可以验证原始的string对象(s1)在调用ToUpper()时没有大写。相反,你会得到一个经过修改的string副本

s1 = This is my string.

upperString = THIS IS MY STRING.
s1 = This is my string.

当您使用 C# 赋值操作符时,不变性法则同样适用。举例来说,实现下面的StringsAreImmutable2()方法:

static void StringsAreImmutable2()
{
    Console.WriteLine("=> Immutable Strings 2:\a");
  string s2 = "My other string";
  s2 = "New string value";
}

现在,编译你的应用并运行ildasm.exe(参见第一章)。下面的输出显示了如果您要为StringsAreImmutable2()方法生成 CIL 代码,您会发现什么:

.method private hidebysig static void  StringsAreImmutable2() cil managed

{
  // Code size       21 (0x15)
  .maxstack  1
  .locals init (string V_0)
  IL_0000:  nop
  IL_0001:  ldstr      "My other string"
  IL_0006:  stloc.0
  IL_0007:  ldstr      "New string value" /* 70000B3B */
  IL_000c:  stloc.0
  IL_000d:  ldloc.0
  IL_0013:  nop
  IL_0014:  ret
} // end of method Program::StringsAreImmutable2

尽管您还没有检查 CIL 的底层细节,但是请注意对ldstr (load string)操作码的大量调用。简单地说,CIL 的ldstr操作码在托管堆上加载一个新的string对象。包含值"My other string"的前一个string对象最终将被垃圾回收。

那么,你从这种洞察力中能收集到什么呢?简而言之,string类可能效率低下,如果误用会导致代码膨胀,尤其是在执行字符串连接或处理大量文本数据时。如果您需要表示基本的字符数据,比如美国社会保险号、名字或姓氏,或者应用中使用的简单文本,那么string类是最佳选择。

然而,如果您正在构建一个大量使用频繁变化的文本数据的应用(比如一个字处理程序),那么使用string对象来表示字处理数据将是一个坏主意,因为您将最有可能(并且经常是间接地)最终制作不必要的字符串数据副本。那么,程序员要做什么呢?很高兴你问了。

使用系统。Text.StringBuilder 类型

鉴于string类型在鲁莽使用时可能效率低下,因此。NET 核心基类库提供了System.Text命名空间。在这个(相对较小的)名称空间中有一个名为StringBuilder的类。例如,像System.String类一样,StringBuilder定义了允许你替换或格式化段的方法。当您希望在 C# 代码文件中使用此类型时,第一步是确保将以下命名空间导入到代码文件中(对于新的 Visual Studio 项目,这应该已经是这样的情况):

// StringBuilder lives here!
using System.Text;

StringBuilder的独特之处在于,当您调用这种类型的成员时,您是在直接修改对象的内部字符数据(使其更有效),而不是以修改后的格式获得数据的副本。当您创建一个StringBuilder的实例时,您可以通过许多构造函数中的一个来提供对象的初始启动值。如果你是构造函数的新手,只需理解构造函数允许你在应用new关键字时创建一个具有初始状态的对象。考虑StringBuilder的以下用法:

static void FunWithStringBuilder()
{
  Console.WriteLine("=> Using the StringBuilder:");
  StringBuilder sb = new StringBuilder("**** Fantastic Games ****");
  sb.Append("\n");
  sb.AppendLine("Half Life");
  sb.AppendLine("Morrowind");
  sb.AppendLine("Deus Ex" + "2");
  sb.AppendLine("System Shock");
  Console.WriteLine(sb.ToString());
  sb.Replace("2", " Invisible War");
  Console.WriteLine(sb.ToString());
  Console.WriteLine("sb has {0} chars.", sb.Length);
  Console.WriteLine();
}

这里,我构造了一个设置为初始值"**** Fantastic Games ****"StringBuilder。正如你所看到的,我附加到内部缓冲区,并能够随意替换或删除字符。默认情况下,StringBuilder最初只能容纳 16 个字符或更少的字符串(但是如果需要会自动扩展);但是,这个默认的起始值可以通过一个额外的构造函数参数来更改。

// Make a StringBuilder with an initial size of 256.
StringBuilder sb = new StringBuilder("**** Fantastic Games ****", 256);

如果您添加的字符超过了指定的限制,StringBuilder对象会将其数据复制到一个新的实例中,并按照指定的限制增加缓冲区。

缩小和扩大数据类型转换

既然您已经理解了如何使用内部 C# 数据类型,那么让我们来研究一下相关的主题数据类型转换。假设您有一个名为 TypeConversions 的新控制台应用项目,并将其添加到您的解决方案中。更新代码以匹配以下内容:

using System;

Console.WriteLine("***** Fun with type conversions *****");

// Add two shorts and print the result.
short numb1 = 9, numb2 = 10;
Console.WriteLine("{0} + {1} = {2}",
  numb1, numb2, Add(numb1, numb2));
Console.ReadLine();

static int Add(int x, int y)
{
  return x + y;
}

注意,Add()方法期望被发送两个int参数。然而,调用代码实际上发送了两个short变量。虽然这看起来像是数据类型完全不匹配,但程序编译和执行时没有错误,返回预期的结果 19。

编译器认为这段代码在语法上是正确的,因为它不可能丢失数据。鉴于 a 的最大值short (32,767)正好在 a 的最大范围int (2,147,483,647)内,编译器隐式地将每个short加宽为int。正式来说,加宽是用来定义不会导致数据丢失的隐式向上转换的术语。

Note

中查找“类型转换表”。NET Core 文档,如果您想了解每种 C# 数据类型允许的扩大(和缩小,下面讨论)转换。

虽然在前面的例子中,这种隐式扩展对您有利,但在其他时候,这种“特性”可能是编译时错误的来源。例如,假设您已经设置了numb1numb2的值,当它们加在一起时,溢出了short的最大值。另外,假设您将Add()方法的返回值存储在一个新的本地short变量中,而不是直接将结果打印到控制台。

static void Main(string[] args)
{
  Console.WriteLine("***** Fun with type conversions *****");

  // Compiler error below!
  short numb1 = 30000, numb2 = 30000;
  short answer = Add(numb1, numb2);

  Console.WriteLine("{0} + {1} = {2}",
    numb1, numb2, answer);
  Console.ReadLine();
}

在这种情况下,编译器会报告以下错误:

Cannot implicitly convert type 'int' to 'short'. An explicit conversion exists (are you missing a cast?)

问题是,尽管Add()方法能够返回值为 60,000 的int(这在System.Int32的范围内),但是该值不能存储在short中,因为它溢出了该数据类型的界限。从形式上讲,CoreCLR 无法应用缩小操作。正如您所猜测的,缩小与扩大在逻辑上是相反的,因为较大的值存储在较小的数据类型变量中。

需要指出的是,所有收缩转换都会导致编译器错误,即使您可以推断收缩转换确实应该成功。例如,以下代码也会导致编译器错误:

// Another compiler error!
static void NarrowingAttempt()
{
  byte myByte = 0;
  int myInt = 200;
  myByte = myInt;

  Console.WriteLine("Value of myByte: {0}", myByte);
}

这里,int变量(myInt)中包含的值安全地在一个byte的范围内;因此,您可能希望收缩操作不会导致运行时错误。然而,考虑到 C# 是一种考虑到类型安全的语言,您确实会收到一个编译器错误。

当您想要通知编译器您愿意处理由于缩小操作而可能丢失的数据时,您必须使用 C# 强制转换运算符()应用显式强制转换。考虑下面对Program类型的更新:

class Program
{
  static void Main(string[] args)
  {
    Console.WriteLine("***** Fun with type conversions *****");
    short numb1 = 30000, numb2 = 30000;

    // Explicitly cast the int into a short (and allow loss of data).
    short answer = (short)Add(numb1, numb2);

    Console.WriteLine("{0} + {1} = {2}",
      numb1, numb2, answer);
    NarrowingAttempt();
    Console.ReadLine();
}

  static int Add(int x, int y)
{
    return x + y;
}

  static void NarrowingAttempt()
{
    byte myByte = 0;
    int myInt = 200;

    // Explicitly cast the int into a byte (no loss of data).
    myByte = (byte)myInt;
    Console.WriteLine("Value of myByte: {0}", myByte);
  }
}

此时,代码编译完毕;但是,相加的结果是完全不正确的。

***** Fun with type conversions *****

30000 + 30000 = -5536
Value of myByte: 200

正如您刚刚看到的,显式强制转换允许您强制编译器应用收缩转换,即使这样做可能会导致数据丢失。在使用NarrowingAttempt()方法的情况下,这不是一个问题,因为值 200 可以恰好在byte的范围内。但是在Main()内的两个short相加的情况下,最终结果是完全不能接受的(30000+30000 =–5536?).

如果您正在构建一个数据丢失总是不可接受的应用,C# 提供了checkedunchecked关键字来确保数据丢失不会不被发现。

使用选中的关键字

让我们从学习关键字checked的作用开始。假设您在Program中有一个新方法,它试图添加两个byte,每个都被赋予一个低于最大值(255)的安全值。如果您要将这些类型的值相加(将返回的int转换为byte,您会认为结果将是每个成员的精确总和。

static void ProcessBytes()
{
  byte b1 = 100;
  byte b2 = 250;
  byte sum = (byte)Add(b1, b2);

  // sum should hold the value 350\. However, we find the value 94!
  Console.WriteLine("sum = {0}", sum);
}

如果您要查看这个应用的输出,您可能会惊讶地发现sum包含值 94(而不是预期的 350)。原因很简单。假定System.Byte只能保存 0 到 255 之间的值(包括 0 和 255,总共 256 个槽),sum现在包含溢出值(350–256 = 94)。默认情况下,如果您不采取纠正措施,上溢/下溢情况会正确发生。

要处理应用中的上溢或下溢情况,有两种选择。您的第一选择是利用您的智慧和编程技能来手动处理所有上溢/下溢情况。当然,这种技术的问题是一个简单的事实,即你是人,即使你尽了最大的努力也可能导致你没有注意到的错误。

谢天谢地,C# 提供了checked关键字。当您在checked关键字的范围内包装一个语句(或一个语句块)时,C# 编译器会发出额外的 CIL 指令来测试两个数字数据类型的加、乘、减或除时可能导致的溢出情况。

如果发生溢出,您将收到一个运行时异常:System.OverflowException。第七章将研究结构化异常处理的所有细节以及trycatch关键字的使用。在这一点上不要太纠结于细节,观察下面的更新:

static void ProcessBytes()
{
  byte b1 = 100;
  byte b2 = 250;

  // This time, tell the compiler to add CIL code
  // to throw an exception if overflow/underflow
  // takes place.
  try
  {
    byte sum = checked((byte)Add(b1, b2));
    Console.WriteLine("sum = {0}", sum);
  }
  catch (OverflowException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

注意,Add()的返回值已经被包装在checked关键字的范围内。因为总和大于一个byte,这触发了一个运行时异常。注意通过Message属性打印出来的错误消息。

Arithmetic operation resulted in an overflow.

如果希望对代码语句块强制进行溢出检查,可以通过定义如下的“检查范围”来实现:

try
{
  checked
  {
    byte sum = (byte)Add(b1, b2);
    Console.WriteLine("sum = {0}", sum);
  }
}
catch (OverflowException ex)
{
  Console.WriteLine(ex.Message);
}

在这两种情况下,将自动评估有问题的代码是否存在可能的溢出条件,如果遇到这种情况,将触发溢出异常。

设置项目范围的溢出检查

如果您正在创建一个永远不允许静默溢出发生的应用,您可能会发现自己处于一个恼人的位置,即在 checked 关键字的范围内包装许多行代码。作为替代,C# 编译器支持/checked标志。当它被启用时,你所有的算法都将被计算溢出,而不需要使用 C# checked关键字。如果已经发现溢出,您仍然会收到一个运行时异常。要为整个项目设置此项,请在项目文件中输入以下内容:

<PropertyGroup>
    <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
</PropertyGroup>

设置项目范围的溢出检查(Visual Studio)

若要使用 Visual Studio 启用此标志,请打开项目的属性页。选择所有配置,然后单击“构建”选项卡上的“高级”按钮。从出现的对话框中,选择“检查算术溢出/下溢”复选框(见图 3-3 )。在创建调试版本时,启用此设置会很有帮助。在所有溢出异常都被挤出代码库之后,您可以为后续构建禁用/checked标志(这可以提高应用的运行时性能)。

img/340876_10_En_3_Fig3_HTML.jpg

图 3-3。

启用项目范围的溢出/下溢数据检查

Note

如果不选择所有配置,则该设置将仅应用于当前选定的配置(例如,调试、发布)

使用未检查的关键字

现在,假设您已经启用了这个项目范围的设置,如果您有一个数据丢失可接受的代码块,您该怎么办?考虑到/checked标志将评估所有的算术逻辑,C# 提供了unchecked关键字来根据具体情况禁止抛出溢出异常。该关键字的用法与checked关键字的用法相同,因为您可以指定一条语句或一组语句。

// Assuming /checked is enabled,
// this block will not trigger
// a runtime exception.
unchecked
{
  byte sum = (byte)(b1 + b2);
  Console.WriteLine("sum = {0} ", sum);
}

所以,总结一下 C# checkedunchecked关键字,记住。NET 核心运行时忽略算术上溢/下溢。当你想有选择地处理离散语句时,使用checked关键字。如果您想在整个应用中捕获溢出错误,启用/checked标志。最后,如果您有一个溢出是可接受的代码块(因此不应该触发运行时异常),可以使用unchecked关键字。

理解隐式类型的局部变量

直到本章的这一点,当你已经定义了局部变量,你已经明确地指定了每个变量的底层数据类型。

static void DeclareExplicitVars()
{
  // Explicitly typed local variables
  // are declared as follows:
  // dataType variableName = initialValue;
  int myInt = 0;
  bool myBool = true;
  string myString = "Time, marches on...";
}

虽然许多人认为显式指定每个变量的数据类型通常是一种好的做法,但 C# 语言确实提供了使用var关键字隐式键入局部变量的。var关键字可以用来代替指定特定的数据类型(例如intboolstring)。当您这样做时,编译器将根据用于初始化本地数据点的初始值自动推断基础数据类型。

为了说明隐式类型的作用,创建一个名为 ImplicitlyTypedLocalVars 的新控制台应用项目,并将其添加到您的解决方案中。将Program.cs中的代码更新如下:

using System;
using System.Linq;

Console.WriteLine("***** Fun with Implicit Typing *****");

添加以下函数来演示隐式声明:

static void DeclareImplicitVars()
{
  // Implicitly typed local variables
  // are declared as follows:
  // var variableName = initialValue;
  var myInt = 0;
  var myBool = true;
  var myString = "Time, marches on...";
}

Note

严格来说,var不是 C# 关键字。允许在没有编译时错误的情况下声明名为var的变量、参数和字段。然而,当var标记被用作数据类型时,编译器会根据上下文将其视为关键字。

在这种情况下,给定初始赋值,编译器能够推断出myInt实际上是一个System.Int32myBool是一个System.BooleanmyString确实是类型System.String。您可以通过反射打印类型名来验证这一点。正如你将在第十七章中看到的更多细节,反射是在运行时确定一个类型的组成的行为。例如,使用反射,可以确定隐式类型化局部变量的数据类型。使用以下代码语句更新您的方法:

static void DeclareImplicitVars()
{
  // Implicitly typed local variables.
  var myInt = 0;
  var myBool = true;
  var myString = "Time, marches on...";

  // Print out the underlying type.
  Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
  Console.WriteLine("myBool is a: {0}", myBool.GetType().Name);
  Console.WriteLine("myString is a: {0}", myString.GetType().Name);
}

Note

请注意,您可以对任何类型使用这种隐式类型,包括数组、泛型类型(参见第十章)和您自己的自定义类型。在本书的过程中,你会看到其他隐式类型的例子。

如果您从顶层语句中调用DeclareImplicitVars()方法,您会发现如下所示的输出:

***** Fun with Implicit Typing *****

myInt is a: Int32
myBool is a: Boolean
myString is a: String

隐式声明数字

如前所述,整数默认为整数,浮点数默认为双精度。创建一个名为DeclareImplicitNumerics的新方法,并添加以下代码来演示 numerics 的隐式声明:

static void DeclareImplicitNumerics()
{
  // Implicitly typed numeric variables.
  var myUInt = 0u;
  var myInt = 0;
  var myLong = 0L;
  var myDouble = 0.5;
  var myFloat = 0.5F;
  var myDecimal = 0.5M;

  // Print out the underlying type.
  Console.WriteLine("myUInt is a: {0}", myUInt.GetType().Name);
  Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
  Console.WriteLine("myLong is a: {0}", myLong.GetType().Name);
  Console.WriteLine("myDouble is a: {0}", myDouble.GetType().Name);
  Console.WriteLine("myFloat is a: {0}", myFloat.GetType().Name);
  Console.WriteLine("myDecimal is a: {0}", myDecimal.GetType().Name);
}

了解隐式类型变量的限制

关于var关键字的使用有各种限制。首先,隐式类型将应用于方法或属性范围内的局部变量。使用var关键字定义自定义类型的返回值、参数或字段数据是非法的。例如,下面的类定义将导致各种编译时错误:

class ThisWillNeverCompile
{
  // Error! var cannot be used as field data!
  private var myInt = 10;

  // Error! var cannot be used as a return value
  // or parameter type!
  public var MyMethod(var x, var y){}
}

同样,用关键字var声明的局部变量必须在声明的确切时间被赋予一个初始值,而不能被赋予初始值null。这最后一个限制应该是有意义的,因为编译器不能仅仅根据null来推断变量将指向内存中的哪种类型。

// Error! Must assign a value!
var myData;

// Error! Must assign value at exact time of declaration!
var myInt;
myInt = 0;

// Error! Can't assign null as initial value!
var myObj = null;

然而,允许在初始赋值后将推断的局部变量赋值给null(假设它是引用类型)。

// OK, if SportsCar is a reference type!
var myCar = new SportsCar();
myCar = null;

此外,允许将隐式类型的局部变量的值赋给其他变量的值,无论是否是隐式类型的。

// Also OK!
var myInt = 0;
var anotherInt = myInt;

string myString = "Wake up!";
var myData = myString;

此外,如果方法返回类型与var定义的数据点是相同的底层类型,那么允许向调用者返回隐式类型的局部变量。

static int GetAnInt()
{
  var retVal = 9;
  return retVal;
}

隐式类型数据是强类型数据

请注意,局部变量的隐式类型化会导致强类型数据。因此,var关键字的使用是而不是与脚本语言(如 JavaScript 或 Perl)或 COM Variant数据类型使用的相同技术,其中变量可以在程序的生存期内保存不同类型的值(通常称为动态类型化)。

Note

C# 允许使用名为-surprise,surprise-dynamic的关键字进行动态输入。你会在第十八章学到这方面的知识。

相反,类型推断保留了 C# 语言的强类型特征,并且只影响编译时的变量声明。之后,数据点被视为是用该类型声明的;将不同类型的值赋给该变量将导致编译时错误。

static void ImplicitTypingIsStrongTyping()
{
  // The compiler knows "s" is a System.String.
  var s = "This variable can only hold string data!";
  s = "This is fine...";

  // Can invoke any member of the underlying type.
  string upper = s.ToUpper();

  // Error! Can't assign numerical data to a string!
  s = 44;
}

理解隐式类型化局部变量的有用性

既然您已经看到了用于声明隐式类型化局部变量的语法,我相信您一定想知道什么时候使用这种结构。首先,仅仅为了声明局部变量而使用var并没有带来什么好处。这样做可能会让阅读您代码的其他人感到困惑,因为快速确定底层数据类型变得更加困难,因此理解变量的整体功能也更加困难。所以,如果你知道你需要一个int,那就声明一个int

然而,正如你将在第十三章开始看到的,LINQ 技术集利用了查询表达式,它可以基于查询本身的格式产生动态创建的结果集。在这些情况下,隐式类型非常有用,因为您不需要显式定义查询可能返回的类型,而这在某些情况下实际上是不可能做到的。不要纠结于下面的 LINQ 示例代码,看看您是否能弄清楚subset的底层数据类型:

static void LinqQueryOverInts()
{
  int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };

  // LINQ query!
  var subset = from i in numbers where i < 10 select i;

  Console.Write("Values in subset: ");
  foreach (var i in subset)
  {
    Console.Write("{0} ", i);
  }
  Console.WriteLine();

  // Hmm...what type is subset?
  Console.WriteLine("subset is a: {0}", subset.GetType().Name);
  Console.WriteLine("subset is defined in: {0}", subset.GetType().Namespace);
}

您可能会假设subset数据类型是一个整数数组。看起来是这样,但事实上,它是一种低级的 LINQ 数据类型,除非你已经做了很长时间的 LINQ,或者你在ildasm.exe中打开编译后的图像,否则你永远不会知道它。好消息是,当您使用 LINQ 时,您很少(如果曾经)关心查询返回值的底层类型;您只需将值赋给隐式类型的局部变量。

事实上,可以说唯一一次使用var关键字是在定义从 LINQ 查询返回的数据时。记住,如果你知道你需要一个int,就声明一个int!过度使用隐式类型(通过var关键字)被大多数开发人员认为是产品代码中糟糕的风格。

使用 C# 迭代构造

所有编程语言都提供了重复代码块的方法,直到满足终止条件。不管你过去使用过哪种语言,我想 C# 迭代语句应该不会引起太多的关注,也不需要太多的解释。C# 提供了以下四种迭代构造:

  • for循环

  • foreach/in循环

  • while循环

  • do / while循环

让我们使用一个名为 IterationsAndDecisions 的新控制台应用项目,依次快速检查每个循环构造。

Note

我将保持本章的这一节简明扼要,因为我假设你有使用类似关键字的经验(ifforswitch等)。)用你现在的编程语言。如果您需要更多信息,请在 C# 文档中查找主题“迭代语句(C# 参考)”、“跳转语句(C# 参考)”和“选择语句(C# 参考)”。

使用 for 循环

当您需要迭代固定次数的代码块时,for语句提供了很大的灵活性。本质上,您可以指定一段代码重复多少次,以及终止条件。无需赘述这一点,下面是一个语法示例:

// A basic for loop.
static void ForLoopExample()
{
  // Note! "i" is only visible within the scope of the for loop.
  for(int i = 0; i < 4; i++)
  {
    Console.WriteLine("Number is: {0} ", i);
  }
  // "i" is not visible here.
}

在构建 C# for语句时,您所有的 C、C++和 Java 技巧仍然有效。您可以创建复杂的终止条件,构建无限循环,反向循环(通过--操作符),并使用gotocontinuebreak跳转关键字。

使用 foreach 循环

C# foreach关键字允许你遍历容器中的所有条目,而不需要测试上限。然而,与for循环不同的是,foreach循环只会以线性(n+1)的方式遍历容器(因此,你不能向后遍历容器,跳过每三个元素,等等)。

然而,当您只是需要一个一个地浏览集合时,foreach循环是完美的选择。这里有两个使用foreach的例子——一个遍历字符串数组,另一个遍历整数数组。注意,in关键字之前的数据类型代表容器中的数据类型。

// Iterate array items using foreach.
static void ForEachLoopExample()
{
  string[] carTypes = {"Ford", "BMW", "Yugo", "Honda" };
  foreach (string c in carTypes)
  {
    Console.WriteLine(c);
  }

  int[] myInts = { 10, 20, 30, 40 };
  foreach (int i in myInts)
  {
    Console.WriteLine(i);
  }
}

关键字in之后的项可以是一个简单的数组(见这里),或者更具体地说,可以是实现IEnumerable接口的任何类。正如你将在第十章中看到的。NET 核心基类库附带了许多集合,这些集合包含通用抽象数据类型(ADT)的实现。这些项目中的任何一个(比如通用的List<T>)都可以在foreach循环中使用。

在 foreach 构造中使用隐式类型

也可以在一个foreach循环结构中使用隐式类型。如你所料,编译器会正确地推断出正确的“类型”回想一下本章前面展示的 LINQ 示例方法。假设您不知道subset变量的确切底层数据类型,那么您可以使用隐式类型对结果集进行迭代。确保将下面的using语句添加到文件的顶部:

using System.Linq;
static void LinqQueryOverInts()
{
  int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };

  // LINQ query!
  var subset = from i in numbers where i < 10 select i;
  Console.Write("Values in subset: ");

  foreach (var i in subset)
  {
    Console.Write("{0} ", i);
  }
}

使用 while 和 do/while 循环结构

如果您想执行一个语句块,直到达到某个终止条件,那么while循环结构非常有用。在一个while循环的范围内,您需要确保这个终止事件确实被建立;否则,你会陷入死循环。在下面的例子中,消息"In while loop"将持续打印,直到用户在命令提示符下输入yes终止循环:

static void WhileLoopExample()
{
  string userIsDone = "";

  // Test on a lower-class copy of the string.
  while(userIsDone.ToLower() != "yes")
  {
    Console.WriteLine("In while loop");
    Console.Write("Are you done? [yes] [no]: ");
    userIsDone = Console.ReadLine();
  }
}

while循环密切相关的是do / while语句。像一个简单的while循环一样,do / while在你需要执行某个动作不确定的次数时使用。不同的是do / while循环保证至少执行一次相应的代码块。相反,如果终止条件从一开始就是假的,那么简单的while循环可能永远不会执行。

static void DoWhileLoopExample()
{
  string userIsDone = "";

  do
  {
    Console.WriteLine("In do/while loop");
    Console.Write("Are you done? [yes] [no]: ");
    userIsDone = Console.ReadLine();
  }while(userIsDone.ToLower() != "yes"); // Note the semicolon!
}

关于范围的快速讨论

像所有基于 C 的语言(C#,Java 等。),使用花括号创建一个范围。到目前为止,您已经在许多示例中看到了这一点,包括名称空间、类和方法。迭代和决策构造也在一个范围内操作,如下例所示:

for(int i = 0; i < 4; i++)
{
  Console.WriteLine("Number is: {0} ", i);
}

对于这些结构(在前一节和下一节中),不使用花括号是允许的。换句话说,下面的代码与前面的例子完全相同:

for(int i = 0; i < 4; i++)
  Console.WriteLine("Number is: {0} ", i);

虽然这是允许的,但通常不是一个好主意。问题不在于一行语句,而在于从一行到多行的语句。如果没有大括号,在迭代/决策结构中扩展代码时可能会出错。例如,下面两个例子是 而不是 相同:

for(int i = 0; i < 4; i++)
{
  Console.WriteLine("Number is: {0} ", i);
  Console.WriteLine("Number plus 1 is: {0} ", i+1)
}
for(int i = 0; i < 4; i++)
  Console.WriteLine("Number is: {0} ", i);
  Console.WriteLine("Number plus 1 is: {0} ", i+1)

如果你幸运的话(就像这个例子),额外的一行代码会产生一个编译错误,因为变量 i 只在for循环的范围内定义。如果您运气不好,您正在执行的代码不会被标记为编译器错误,而是一个逻辑错误,更难发现和调试。

使用决策构造和关系/等式运算符

既然可以迭代语句块,下一个相关的概念就是如何控制程序执行的流程。C# 定义了两个简单的构造来根据各种意外情况改变程序的流程:

  • if / else语句

  • switch声明

Note

C# 7 用一种叫做模式匹配的技术扩展了is表达式和switch语句。为了完整起见,这里显示了这些扩展如何影响if / elseswitch语句的基础知识。阅读完第六章后,这些扩展会更有意义,这一章涵盖了基类/派生类规则、类型转换和标准的is操作符。

使用 if/else 语句

首先是if / else语句。与 C 和 C++不同,C# 中的if / else语句只对布尔表达式进行操作,而不是像–10这样的特殊值。

使用等式和关系运算符

C# if / else语句通常涉及使用表 3-8 中所示的 C# 运算符来获得一个文字布尔值。

表 3-8。

C# 关系和等式运算符

|

C# 等式/关系运算符

|

用法示例

|

生命的意义

== if(age == 30) 仅当每个表达式都相同时,才返回true
!= if("Foo" != myStr) 仅当每个表达式不同时才返回true
< if(bonus < 2000) 如果表达式 A ( bonus)小于表达式 B ( 2000),则返回true
> if(bonus > 2000) 如果表达式 A ( bonus)大于表达式 B ( 2000),则返回true
<= if(bonus <= 2000) 如果表达式 A ( bonus)小于或等于表达式 B ( 2000),则返回true
>= if(bonus >= 2000) 如果表达式 A ( bonus)大于或等于表达式 B ( 2000),则返回true

同样,C 和 C++程序员需要知道,测试不等于零的条件的老把戏在 C# 中不起作用。假设您想要查看您正在使用的string是否长于零个字符。你可能会想这样写:

static void IfElseExample()
{
  // This is illegal, given that Length returns an int, not a bool.
  string stringData = "My textual data";
  if(stringData.Length)
  {
    Console.WriteLine("string is greater than 0 characters");
  }
  else
  {
    Console.WriteLine("string is not greater than 0 characters");
  }
  Console.WriteLine();
}

如果您想要使用String.Length属性来确定真或假,您需要修改您的条件表达式来解析为布尔值。

// Legal, as this resolves to either true or false.
If (stringData.Length > 0)
{
  Console.WriteLine("string is greater than 0 characters");
}

使用带有模式匹配的 if/else(新 7.0)

C# 7.0 新增,模式匹配if / else语句中是允许的。模式匹配允许代码检查对象的某些特征和属性,并根据这些属性和特征的存在与否做出决定。如果您是面向对象编程的新手,请不要担心;前面的句子将在后面的章节中详细解释。只需知道(目前)你可以使用is关键字检查一个对象的类型,如果模式匹配,将该对象赋给一个变量,然后使用该变量。

IfElsePatternMatching方法检查两个对象变量,确定它们是 string 还是 int,然后将结果打印到控制台:

static void IfElsePatternMatching()
{
  Console.WriteLine("===If Else Pattern Matching ===/n");
  object testItem1 = 123;
  object testItem2 = "Hello";
  if (testItem1 is string myStringValue1)
  {
    Console.WriteLine($"{myStringValue1} is a string");
  }
  if (testItem1 is int myValue1)
  {
    Console.WriteLine($"{myValue1} is an int");
  }
  if (testItem2 is string myStringValue2)
  {
    Console.WriteLine($"{myStringValue2} is a string");
  }
  if (testItem2 is int myValue2)
  {
    Console.WriteLine($"{myValue2} is an int");
  }
  Console.WriteLine();
}

改进模式匹配(新 9.0)

C# 9.0 引入了大量对模式匹配的改进,如表 3-9 所示。

表 3-9。

模式匹配改进

|

模式

|

生命的意义

Type patterns 检查变量是否是一种类型
Parenthesized patterns 强制或强调模式组合的优先级
Conjuctive (and) patterns 要求两种模式匹配
Disjunctive (or) patterns 要求两种模式匹配
Negated (not) patterns 要求模式不匹配
Relational patterns 要求输入小于、小于或等于、大于或大于或等于

更新后的IfElsePatternMatchingUpdatedInCSharp9()展示了这些新模式的作用:

static void IfElsePatternMatchingUpdatedInCSharp9()
{
    Console.WriteLine("================ C# 9 If Else Pattern Matching Improvements ===============/n");
    object testItem1 = 123;
    Type t = typeof(string);
    char c = 'f';

    //Type patterns
    if (t is Type)
    {
        Console.WriteLine($"{t} is a Type");
    }

    //Relational, Conjuctive, and Disjunctive patterns
    if (c is >= 'a' and <= 'z' or >= 'A' and <= 'Z')
    {
        Console.WriteLine($"{c} is a character");
    };

    //Parenthesized patterns
    if (c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',')
    {
        Console.WriteLine($"{c} is a character or separator");
    };

    //Negative patterns
    if (testItem1 is not string)
    {
        Console.WriteLine($"{testItem1} is not a string");
    }
    if (testItem1 is not null)
    {
        Console.WriteLine($"{testItem1} is not null");
    }
    Console.WriteLine();
}

使用条件运算符(更新了 7.2、9.0)

条件运算符(?:),也称为三元条件运算符,是书写简单if / else语句的一种速记方法。语法是这样的:

condition ? first_expression : second_expression;

这个条件就是条件测试(if / else语句的if部分)。如果测试通过,则执行问号(?)后面的代码。如果测试结果不为真,则执行冒号后的代码(if / else语句的else部分)。前面的代码示例可以使用条件运算符编写,如下所示:

static void ExecuteIfElseUsingConditionalOperator()
{
  string stringData = "My textual data";
  Console.WriteLine(stringData.Length > 0
    ? "string is greater than 0 characters"
    : "string is not greater than 0 characters");
  Console.WriteLine();
}

条件运算符有一些限制。首先,first_expressionsecond_expression两种类型都必须有从一个到另一个的隐式转换,或者,C# 9.0 中的新特性,每种类型都必须有到目标类型的隐式转换。其次,条件运算符只能在赋值语句中使用。以下代码将导致编译器错误“只有赋值、调用、递增、递减和新对象表达式可以用作语句”:

  stringData.Length > 0
    ? Console.WriteLine("string is greater than 0 characters")
    : Console.WriteLine("string is not greater than 0 characters");

C# 7.2 中新增的条件运算符可用于返回对条件结果的引用。以下面的例子为例,它使用了两种形式的条件运算符 by ref:

static void ConditionalRefExample()
{
  var smallArray = new int[] { 1, 2, 3, 4, 5 };
  var largeArray = new int[] { 10, 20, 30, 40, 50 };

  int index = 7;
  ref int refValue = ref ((index < 5)
    ? ref smallArray[index]
    : ref largeArray[index - 5]);
  refValue = 0;

  index = 2;
  ((index < 5)
    ? ref smallArray[index]
    : ref largeArray[index - 5]) = 100;

  Console.WriteLine(string.Join(" ", smallArray));
  Console.WriteLine(string.Join(" ", largeArray));
}

如果你不熟悉关键字ref,在这一点上不要太担心,因为它将在下一章中深入讨论。总而言之,第一个例子返回一个引用到用条件检查的数组位置,并将变量refValue赋给这个引用。从概念上来说,可以把引用看作是指向数组中位置的指针,而不是数组位置的实际值。这允许通过改变分配给变量的值来直接改变该位置的数组值。将refValue变量的值设置为零的结果会将第二个数组的值更改为 10,20, 0 ,40,50。第二个示例将第一个数组的第二个值更新为 100,得到 1,2, 100 ,4,5。

使用逻辑运算符

一个if语句也可以由复杂的表达式组成,并且可以包含else语句来执行更复杂的测试。语法与 C(和 C++)和 Java 相同。为了构建复杂的表达式,C# 提供了一组预期的逻辑运算符,如表 3-10 所示。

表 3-10。

C# 逻辑运算符

|

操作员

|

例子

|

生命的意义

&& if(age == 30 && name == "Fred") 和运算符。如果所有表达式都为真,则返回true
&#124;&#124; if(age == 30 &#124;&#124; name == "Fred") 或操作员。如果至少有一个表达式为真,则返回true
! if(!myBool) 不是操作员。如果为假,则返回true,如果为真,则返回false

Note

必要时,&&||操作器都“短路”。这意味着在一个复杂表达式被确定为假之后,剩余的子表达式将不会被检查。如果你需要测试所有的表达式,你可以使用相关的&|操作符。

使用 switch 语句

C# 提供的另一个简单的选择结构是switch语句。与其他基于 C 的语言一样,switch语句允许您基于一组预定义的选项来处理程序流。例如,下面的逻辑基于两个可能的选择之一打印一个特定的字符串消息(?? 案例处理一个无效的选择):

// Switch on a numerical value.
static void SwitchExample()
{
  Console.WriteLine("1 [C#], 2 [VB]");
  Console.Write("Please pick your language preference: ");

  string langChoice = Console.ReadLine();
  int n = int.Parse(langChoice);

  switch (n)
  {
    case 1:
      Console.WriteLine("Good choice, C# is a fine language.");
      break;
    case 2:
      Console.WriteLine("VB: OOP, multithreading, and more!");
      break;
    default:
      Console.WriteLine("Well...good luck with that!");
      break;
  }
}

Note

C# 要求每个包含可执行语句的 case(包括default)都有一个终止returnbreakgoto,以避免陷入下一个语句。

C# switch语句的一个很好的特性是,除了数值数据之外,您还可以计算string数据。事实上,所有版本的 C# 都可以评估charstringboolintlongenum数据类型。正如您将在下一节看到的,C# 7 增加了额外的功能。下面是更新后的switch语句,它计算一个字符串变量:

static void SwitchOnStringExample()
{
  Console.WriteLine("C# or VB");
  Console.Write("Please pick your language preference: ");

  string langChoice = Console.ReadLine();
  switch (langChoice.ToUpper())
  {
    case "C#":
      Console.WriteLine("Good choice, C# is a fine language.");
      break;
    case "VB":
      Console.WriteLine("VB: OOP, multithreading and more!");
      break;
    default:
      Console.WriteLine("Well...good luck with that!");
      break;
  }
}

也可以打开枚举数据类型。正如你将在第四章中看到的,C# enum关键字允许你定义一组定制的名称-值对。为了激起您的兴趣,考虑下面的最后一个助手函数,它在System.DayOfWeek enum上执行一个switch测试。您会注意到一些我还没有检查的语法,但是重点是切换到enum本身的问题;缺失的部分将在后面的章节中补上。

static void SwitchOnEnumExample()
{
  Console.Write("Enter your favorite day of the week: ");
  DayOfWeek favDay;
  try
  {
    favDay = (DayOfWeek) Enum.Parse(typeof(DayOfWeek), Console.ReadLine());
  }
  catch (Exception)
  {
    Console.WriteLine("Bad input!");
    return;
  }
  switch (favDay)
  {
    case DayOfWeek.Sunday:
      Console.WriteLine("Football!!");
      break;
    case DayOfWeek.Monday:
      Console.WriteLine("Another day, another dollar");
      break;
    case DayOfWeek.Tuesday:
      Console.WriteLine("At least it is not Monday");
      break;
    case DayOfWeek.Wednesday:
      Console.WriteLine("A fine day.");
      break;
    case DayOfWeek.Thursday:
      Console.WriteLine("Almost Friday...");
      break;
    case DayOfWeek.Friday:
      Console.WriteLine("Yes, Friday rules!");
      break;
    case DayOfWeek.Saturday:
      Console.WriteLine("Great day indeed.");
      break;
  }
  Console.WriteLine();
}

不允许从一个case语句跳到另一个case语句,但是如果多个case语句产生相同的结果呢?幸运的是,它们可以组合在一起,如下面的代码片段所示:

case DayOfWeek.Saturday:
case DayOfWeek.Sunday:
  Console.WriteLine("It’s the weekend!");
  break;

如果在case语句之间包含任何代码,编译器将抛出一个错误。只要是连续的语句,如前所示,case语句可以组合在一起,共享共同的代码。

除了前面代码示例中显示的returnbreak语句,switch语句还支持使用goto来退出case条件并执行另一个case语句。虽然这是受支持的,但它被普遍认为是一种反模式,并不常用。下面是一个在switch块中使用goto语句的例子:

static void SwitchWithGoto()
{
  var foo = 5;
  switch (foo)
  {
    case 1:
      //do something
      goto case 2;
    case 2:
      //do something else
      break;
    case 3:
      //yet another action
      goto default;
    default:
      //default action
      break;
  }
}

执行 switch 语句模式匹配(新 7.0,更新 9.0)

在 C# 7 之前,switch语句中的匹配表达式仅限于将变量与常量值进行比较,有时也称为常量模式。在 C# 7 中,switch语句也可以使用类型模式,其中case语句可以评估被检查变量的类型,并且case表达式不再局限于常量值。每个case语句必须以returnbreak结束的规则仍然适用;但是,使用类型模式不支持goto语句。

Note

如果您是面向对象编程的新手,这一节可能会有点混乱。当你在类和基类的上下文中重新审视 C# 7 的新模式匹配特性时,这些都会在第六章 ?? 中出现。现在,只要明白有一种强有力的新方法来编写switch语句。

添加另一个名为ExecutePatternMatchingSwitch()的方法,并添加以下代码:

static void ExecutePatternMatchingSwitch()
{
  Console.WriteLine("1 [Integer (5)], 2 [String (\"Hi\")], 3 [Decimal (2.5)]");
  Console.Write("Please choose an option: ");
  string userChoice = Console.ReadLine();
  object choice;
  //This is a standard constant pattern switch statement to set up the example
  switch (userChoice)
  {
    case "1":
      choice = 5;
      break;
    case "2":
      choice = "Hi";
      break;
    case "3":
      choice = 2.5;
      break;
    default:
      choice = 5;
      break;
  }
  //This is new the pattern matching switch statement
  switch (choice)
  {
    case int i:
      Console.WriteLine("Your choice is an integer.");
      break;
    case string s:
      Console.WriteLine("Your choice is a string.");
      break;
    case decimal d:
      Console.WriteLine("Your choice is a decimal.");
      break;
    default:
      Console.WriteLine("Your choice is something else");
      break;
  }
  Console.WriteLine();
}

第一个switch语句使用了标准的常量模式,它只是用来设置这个(琐碎的)例子。在第二个switch语句中,变量被类型化为object,并且根据用户的输入,可以被解析为intstringdecimal数据类型。基于变量的类型,匹配不同的 case 语句。除了检查数据类型之外,在每个case语句中都分配了一个变量(除了default的情况)。将代码更新为以下内容,以使用变量中的值:

//This is new the pattern matching switch statement
switch (choice)
{
  case int i:
    Console.WriteLine("Your choice is an integer {0}.",i);
    break;
  case string s:
    Console.WriteLine("Your choice is a string. {0}", s);
    break;
  case decimal d:
    Console.WriteLine("Your choice is a decimal. {0}", d);
    break;
  default:
    Console.WriteLine("Your choice is something else");
    break;
}

除了评估匹配表达式的类型之外,when子句可以添加到case语句中,以评估变量的条件。在此示例中,除了检查类型之外,还会检查转换类型的值是否匹配:

static void ExecutePatternMatchingSwitchWithWhen()
{
  Console.WriteLine("1 [C#], 2 [VB]");
  Console.Write("Please pick your language preference: ");

  object langChoice = Console.ReadLine();
  var choice = int.TryParse(langChoice.ToString(), out int c) ? c : langChoice;

  switch (choice)
  {
    case int i when i == 2:
    case string s when s.Equals("VB", StringComparison.OrdinalIgnoreCase):
      Console.WriteLine("VB: OOP, multithreading, and more!");
      break;
    case int i when i == 1:
    case string s when s.Equals("C#", StringComparison.OrdinalIgnoreCase):
      Console.WriteLine("Good choice, C# is a fine language.");
      break;
    default:
      Console.WriteLine("Well...good luck with that!");
      break;
  }
  Console.WriteLine();
}

这给switch语句增加了一个新的维度,因为case语句的顺序现在很重要。对于固定模式,每个case语句都必须是唯一的。有了类型模式,就不再是这种情况了。例如,下面的代码将匹配第一个 case 语句中的每个整数,并且永远不会执行第二个或第三个(实际上,下面的代码将无法编译):

switch (choice)
{
  case int i:
    //do something
    break;
  case int i when i == 0:
    //do something
    break;
  case int i when i == -1:
    // do something
    break;
}

在 C# 7 的最初版本中,当使用泛型类型时,模式匹配有一个小问题。C# 7.1 已经解决了这个问题。通用类型将在第十章中介绍。

Note

之前演示的 C# 9.0 中的所有模式匹配改进也可用于 switch 语句中。

使用开关表达式(新 8.0)

C# 8 中的新特性是switch表达式,允许在简洁的语句中给变量赋值。考虑这个方法的 C# 7 版本,它接受一种颜色并返回颜色名称的十六进制值:

static string FromRainbowClassic(string colorBand)
{
  switch (colorBand)
  {
    case "Red":
      return "#FF0000";
    case "Orange":
      return "#FF7F00";
    case "Yellow":
      return "#FFFF00";
    case "Green":
      return "#00FF00";
    case "Blue":
      return "#0000FF";
    case "Indigo":
      return "#4B0082";
    case "Violet":
      return "#9400D3";
    default:
      return "#FFFFFF";
  };
}

有了 C# 8 中的新开关表达式,以前的方法可以写成如下形式,这要简洁得多:

static string FromRainbow(string colorBand)
{
  return colorBand switch
  {
    "Red" => "#FF0000",
    "Orange" => "#FF7F00",
    "Yellow" => "#FFFF00",
    "Green" => "#00FF00",
    "Blue" => "#0000FF",
    "Indigo" => "#4B0082",
    "Violet" => "#9400D3",
    _ => "#FFFFFF",
  };
}

在这个例子中,有很多东西需要解开,从 lambda ( =>)语句到 discard ( _)。这些都将在后面的章节中讨论,这个例子将会更详细。

在结束 switch 表达式的主题之前,还有一个例子,它涉及到元组。元组在第四章中有详细介绍,所以现在把元组想象成一个简单的结构,它保存多个值并用括号定义,就像这个保存一个string和一个int的元组:

(string, int)

在下面的示例中,传递到RockPaperScissors方法中的两个值被转换为一个元组,然后 switch 表达式在单个表达式中计算这两个值。这种模式允许在一个switch语句中比较多个值。

//Switch expression with Tuples
static string RockPaperScissors(string first, string second)
{
  return (first, second) switch
  {
    ("rock", "paper") => "Paper wins.",
    ("rock", "scissors") => "Rock wins.",
    ("paper", "rock") => "Paper wins.",
    ("paper", "scissors") => "Scissors wins.",
    ("scissors", "rock") => "Rock wins.",
    ("scissors", "paper") => "Scissors wins.",
    (_, _) => "Tie.",
  };
}

要调用这个方法,将下面几行代码添加到Main()方法中:

Console.WriteLine(RockPaperScissors("paper","rock"));
Console.WriteLine(RockPaperScissors("scissors","rock"));

当引入元组时,将在第四章中再次讨论这个例子。

摘要

本章的目标是向你展示 C# 编程语言的许多核心方面。您研究了您可能有兴趣构建的任何应用中的常见结构。在研究了 application 对象的角色之后,您了解到每个 C# 可执行程序都必须有一个定义Main()方法的类型,要么显式定义,要么通过使用顶级语句来定义。这个方法作为程序的入口点。

接下来,您深入研究了 C# 内置数据类型的细节,并开始理解每个数据类型关键字(例如,int)实际上是在System名称空间中成熟类型的简写符号(在本例中是System.Int32)。鉴于此,每种 C# 数据类型都有许多内置成员。同样,您还了解了扩大缩小的作用,以及checkedunchecked关键字的作用。

本章最后介绍了使用var关键字的隐式类型的作用。如前所述,隐式类型最有用的地方是在使用 LINQ 编程模型时。最后,您快速检查了 C# 支持的各种迭代和决策结构。

现在你已经理解了一些基本的细节,下一章(第章和第章)将会完成你对核心语言特性的研究。之后,你将为从第五章开始研究 C# 的面向对象特性做好充分准备。

四、核心 C# 编程结构:第二部分

本章从第三章停止的地方开始,完成你对 C# 编程语言核心方面的研究。您将从研究使用 C# 语法操作数组背后的细节开始,并了解相关的System.Array类类型中包含的功能。

接下来,您将研究关于 C# 方法构造的各种细节,探索outrefparams关键字。在这个过程中,您还将研究可选参数和命名参数的作用。我通过查看方法重载来结束对方法的讨论。

接下来,本章将讨论枚举和结构类型的构造,包括详细检查值类型引用类型之间的区别。本章最后研究了可空数据类型和相关操作符的作用。

在你完成这一章之后,你将处于学习 C# 的面向对象能力的最佳位置,从第五章开始。

了解 C# 数组

我想你已经知道了,数组是一组数据项,使用数字索引来访问。更具体地说,数组是一组相同类型的连续数据点(一个由int组成的数组,一个由string组成的数组,一个由SportsCar组成的数组,等等)。).用 C# 声明、填充和访问数组都非常简单。举例来说,创建一个名为 FunWithArrays 的新控制台应用项目,其中包含一个名为SimpleArrays();的帮助器方法,如下所示:

Console.WriteLine("***** Fun with Arrays *****");
SimpleArrays();
Console.ReadLine();

static void SimpleArrays()
{
  Console.WriteLine("=> Simple Array Creation.");
  // Create and fill an array of 3 integers
  int[] myInts = new int[3];
  // Create a 100 item string array, indexed 0 - 99
  string[] booksOnDotNet = new string[100];
  Console.WriteLine();
}

仔细看看前面的代码注释。当使用此语法声明 C# 数组时,数组声明中使用的数字表示项目总数,而不是上限。还要注意数组的下限总是从0开始。因此,当您编写int[] myInts = new int[3]时,您最终会得到一个包含三个元素的数组,分别位于012

在定义了一个数组变量之后,就可以逐个索引地填充元素了,如更新后的SimpleArrays()方法所示:

static void SimpleArrays()
{
  Console.WriteLine("=> Simple Array Creation.");
  // Create and fill an array of 3 Integers
  int[] myInts = new int[3];
  myInts[0] = 100;
  myInts[1] = 200;
  myInts[2] = 300;

  // Now print each value.
  foreach(int i in myInts)
  {
    Console.WriteLine(i);
  }
  Console.WriteLine();
}

Note

请注意,如果您声明了一个数组,但没有显式填充每个索引,则每个项都将被设置为数据类型的默认值(例如,bool的数组将被设置为false,或者int的数组将被设置为0)。

查看 C# 数组初始化语法

除了逐个元素填充数组,你还可以使用 C# 数组初始化语法来填充数组的元素。为此,在花括号({})的范围内指定每个数组项。当您创建一个已知大小的数组并希望快速指定初始值时,此语法会很有帮助。例如,考虑以下替代数组声明:

static void ArrayInitialization()
{
  Console.WriteLine("=> Array Initialization.");

  // Array initialization syntax using the new keyword.
  string[] stringArray = new string[]
    { "one", "two", "three" };
  Console.WriteLine("stringArray has {0} elements", stringArray.Length);

  // Array initialization syntax without using the new keyword.
  bool[] boolArray = { false, false, true };
  Console.WriteLine("boolArray has {0} elements", boolArray.Length);

  // Array initialization with new keyword and size.
  int[] intArray = new int[4] { 20, 22, 23, 0 };
  Console.WriteLine("intArray has {0} elements", intArray.Length);
  Console.WriteLine();
}

请注意,当您使用这种“花括号”语法时,您不需要指定数组的大小(在构造stringArray变量时可以看到),因为这将由花括号范围内的项数来推断。还要注意,new关键字的使用是可选的(在构造boolArray类型时显示)。

intArray声明的情况下,再次回忆一下,指定的数值代表数组中元素的数量,而不是上限的值。如果声明的大小和初始值设定项的数量不匹配(无论您的初始值设定项太多还是太少),就会发出一个编译时错误。下面是一个例子:

// OOPS! Mismatch of size and elements!
int[] intArray = new int[2] { 20, 22, 23, 0 };

理解隐式类型化局部数组

在第三章中,你学习了隐式类型化局部变量的主题。回想一下,var关键字允许您定义一个变量,它的底层类型由编译器决定。类似地,var关键字可以用来定义隐式类型化的局部数组。使用这种技术,您可以分配一个新的数组变量,而无需指定数组本身包含的类型(注意,使用这种方法时,您必须使用new关键字)。

static void DeclareImplicitArrays()
{
  Console.WriteLine("=> Implicit Array Initialization.");

  // a is really int[].
  var a = new[] { 1, 10, 100, 1000 };
  Console.WriteLine("a is a: {0}", a.ToString());

  // b is really double[].
  var b = new[] { 1, 1.5, 2, 2.5 };
  Console.WriteLine("b is a: {0}", b.ToString());

  // c is really string[].
  var c = new[] { "hello", null, "world" };
  Console.WriteLine("c is a: {0}", c.ToString());
  Console.WriteLine();
}

当然,就像使用显式 C# 语法分配数组一样,数组初始化列表中的项必须是相同的底层类型(例如,所有的int、所有的string或所有的SportsCar)。与您可能期望的不同,隐式类型的本地数组没有默认为System.Object;因此,下面的代码会生成一个编译时错误:

// Error! Mixed types!
var d = new[] { 1, "one", 2, "two", false };

定义对象数组

在大多数情况下,定义数组时,可以通过指定数组变量中的显式项类型来实现。虽然这看起来很简单,但是有一个值得注意的变化。正如你将在第六章中了解到的,System.Object是.NETCore 型系统。鉴于这一事实,如果您要定义一个System.Object数据类型的数组,那么子项可以是任何东西。考虑下面的ArrayOfObjects()方法:

static void ArrayOfObjects()
{
  Console.WriteLine("=> Array of Objects.");

  // An array of objects can be anything at all.
  object[] myObjects = new object[4];
  myObjects[0] = 10;
  myObjects[1] = false;
  myObjects[2] = new DateTime(1969, 3, 24);
  myObjects[3] = "Form & Void";
  foreach (object obj in myObjects)
  {
    // Print the type and value for each item in array.
    Console.WriteLine("Type: {0}, Value: {1}", obj.GetType(), obj);
  }
  Console.WriteLine();
}

在这里,当您迭代myObjects的内容时,您使用System.ObjectGetType()方法打印每个项目的底层类型,以及当前项目的值。在本文的这一点上,不要涉及太多关于System.Object.GetType()的细节,简单地理解这个方法可以用来获得项目的完全限定名(第十七章详细地讨论了类型信息和反射服务的主题)。下面的输出显示了调用ArrayOfObjects()的结果:

=> Array of Objects.

Type: System.Int32, Value: 10
Type: System.Boolean, Value: False
Type: System.DateTime, Value: 3/24/1969 12:00:00 AM
Type: System.String, Value: Form & Void

使用多维数组

除了你已经看到的一维数组,C# 还支持两种多维数组。第一种叫做矩形阵列,它只是一个多维阵列,其中每行长度相同。要声明并填充多维矩形数组,请执行以下操作:

static void RectMultidimensionalArray()
{
  Console.WriteLine("=> Rectangular multidimensional array.");
  // A rectangular MD array.
  int[,] myMatrix;
  myMatrix = new int[3,4];

  // Populate (3 * 4) array.
  for(int i = 0; i < 3; i++)
  {
    for(int j = 0; j < 4; j++)
    {
      myMatrix[i, j] = i * j;
    }
  }

  // Print (3 * 4) array.
  for(int i = 0; i < 3; i++)
  {
    for(int j = 0; j < 4; j++)
    {
      Console.Write(myMatrix[i, j] + "\t");
    }
    Console.WriteLine();
  }
  Console.WriteLine();
}

第二种多维数组被称为交错数组。顾名思义,交错数组包含一定数量的内部数组,每个内部数组可能有不同的上限。这里有一个例子:

static void JaggedMultidimensionalArray()
{
  Console.WriteLine("=> Jagged multidimensional array.");
  // A jagged MD array (i.e., an array of arrays).
  // Here we have an array of 5 different arrays.
  int[][] myJagArray = new int[5][];

  // Create the jagged array.
  for (int i = 0; i < myJagArray.Length; i++)
  {
    myJagArray[i] = new int[i + 7];
  }

  // Print each row (remember, each element is defaulted to zero!).
  for(int i = 0; i < 5; i++)
  {
    for(int j = 0; j < myJagArray[i].Length; j++)
    {
      Console.Write(myJagArray[i][j] + " ");
    }
    Console.WriteLine();
  }
  Console.WriteLine();
}

调用每个RectMultidimensionalArray()JaggedMultidimensionalArray()方法的输出如下所示:

=> Rectangular multidimensional array:

0       0       0       0
0       1       2       3
0       2       4       6

=> Jagged multidimensional array:

0 0 0 0 0 0 0
0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0

使用数组作为参数或返回值

创建数组后,您可以自由地将其作为参数传递或作为成员返回值接收。例如,下面的PrintArray()方法获取一个传入的int数组并将每个成员打印到控制台,而GetStringArray()方法填充一个string数组并将其返回给调用者:

static void PrintArray(int[] myInts)
{
  for(int i = 0; i < myInts.Length; i++)
  {
    Console.WriteLine("Item {0} is {1}", i, myInts[i]);
  }
}

static string[] GetStringArray()
{
  string[] theStrings = {"Hello", "from", "GetStringArray"};
  return theStrings;
}

如您所料,这些方法可以被调用。

static void PassAndReceiveArrays()
{
  Console.WriteLine("=> Arrays as params and return values.");
  // Pass array as parameter.
  int[] ages = {20, 22, 23, 0} ;
  PrintArray(ages);

  // Get array as return value.
  string[] strs = GetStringArray();
  foreach(string s in strs)
  {
    Console.WriteLine(s);
  }

  Console.WriteLine();
}

至此,您应该对定义、填充和检查 C# 数组变量内容的过程感到满意了。为了使画面完整,现在让我们检查一下System.Array类的角色。

使用系统。数组基类

您创建的每个数组都从System.Array类中收集了许多功能。使用这些通用成员,您可以使用一致的对象模型对数组进行操作。表 4-1 给出了一些更有趣的成员的概要(请务必查看文档以了解全部细节)。

表 4-1。

选择系统成员。排列

|

数组类的成员

|

生命的意义

Clear() 这个静态方法将数组中的一系列元素设置为空值(0表示数字,null表示对象引用,false表示布尔值)。
CopyTo() 此方法用于将源数组中的元素复制到目标数组中。
Length 该属性返回数组中的项数。
Rank 该属性返回当前数组的维数。
Reverse() 这个静态方法反转一维数组的内容。
Sort() 此静态方法对内部类型的一维数组进行排序。如果数组中的元素实现了IComparer接口,你也可以对你的自定义类型进行排序(参见第八章和第十章)。

让我们看看这些成员的一些行动。下面的 helper 方法利用静态的Reverse()Clear()方法将关于一组string类型的信息抽取到控制台:

static void SystemArrayFunctionality()
{
  Console.WriteLine("=> Working with System.Array.");
  // Initialize items at startup.
  string[] gothicBands = {"Tones on Tail", "Bauhaus", "Sisters of Mercy"};

  // Print out names in declared order.
  Console.WriteLine("-> Here is the array:");
  for (int i = 0; i < gothicBands.Length; i++)
  {
    // Print a name.
    Console.Write(gothicBands[i] + ", ");
  }
  Console.WriteLine("\n");

  // Reverse them...
  Array.Reverse(gothicBands);
  Console.WriteLine("-> The reversed array");

  // ... and print them.
  for (int i = 0; i < gothicBands.Length; i++)
  {
    // Print a name.
    Console.Write(gothicBands[i] + ", ");

  }
  Console.WriteLine("\n");

  // Clear out all but the first member.
  Console.WriteLine("-> Cleared out all but one...");
  Array.Clear(gothicBands, 1, 2);

  for (int i = 0; i < gothicBands.Length; i++)
  {
    // Print a name.
    Console.Write(gothicBands[i] + ", ");
  }
  Console.WriteLine();
}

如果您调用此方法,您将得到如下所示的输出:

=> Working with System.Array.

-> Here is the array:
Tones on Tail, Bauhaus, Sisters of Mercy,

-> The reversed array
Sisters of Mercy, Bauhaus, Tones on Tail,

-> Cleared out all but one...
Sisters of Mercy, , ,

注意,System.Array的许多成员被定义为静态成员,因此在类级别被调用(例如,Array.Sort()Array.Reverse()方法)。诸如此类的方法在您想要处理的数组中传递。System.Array的其他成员(如Length属性)在对象级绑定;因此,您可以直接在数组上调用成员。

使用指数和范围(新 8.0)

为了简化对序列(包括数组)的处理,C# 8 引入了两种新的类型和两种新的运算符,用于处理数组:

  • System.Index代表一个序列的索引。

  • System.Range表示指数的子范围。

  • 结束运算符(^)的索引指定索引相对于序列的结尾。

  • 范围运算符(...)指定范围的开始和结束作为其操作数。

Note

索引和范围可以与数组、字符串、Span<T>ReadOnlySpan<T>一起使用。

正如您已经看到的,数组是从零(0)开始索引的。序列的结尾是序列的长度–1。之前打印gothicBands数组的for循环可以更新为:

for (int i = 0; i < gothicBands.Length; i++)
{
  Index idx = i;
  // Print a name
  Console.Write(gothicBands[idx] + ", ");
}

“从末端开始的索引”运算符允许您指定从序列末端开始有多少个位置,从长度开始。记住序列中的最后一项比实际长度小一,所以会导致错误。以下代码反向打印数组:

for (int i = 1; i <= gothicBands.Length; i++)
{
  Index idx = ^i;
  // Print a name
  Console.Write(gothicBands[idx] + ", ");
}

range 操作符指定了开始和结束索引,并允许访问列表中的子序列。范围的开始包括在内,范围的结束包括在内。例如,要取出数组的前两个成员,请创建从 0(第一个成员)到 2(比所需的索引位置多一个)的范围。

foreach (var itm in gothicBands[0..2])
{
  // Print a name
  Console.Write(itm + ", ");
}
Console.WriteLine("\n");

也可以使用新的Range数据类型将范围传递给序列,如下所示:

Range r = 0..2; //the end of the range is exclusive
foreach (var itm in gothicBands[r])
{
  // Print a name
  Console.Write(itm + ", ");
}
Console.WriteLine("\n");

可以使用整数或Index变量定义范围。以下代码会产生相同的结果:

Index idx1 = 0;
Index idx2 = 2;
Range r = idx1..idx2; //the end of the range is exclusive
foreach (var itm in gothicBands[r])
{
  // Print a name
  Console.Write(itm + ", ");
}
Console.WriteLine("\n");

如果该范围的开头被忽略,则使用序列的开头。如果不考虑范围的结尾,则使用范围的长度。这不会导致错误,因为范围末尾的值是唯一的。对于数组中三个项目的前一个示例,所有范围都表示相同的子集。

gothicBands[..]
gothicBands[0..⁰]
gothicBands[0..3]

理解方法

让我们检查定义方法的细节。方法是由访问修饰符和返回类型定义的(或者是没有返回类型的void),可以带参数也可以不带参数。向调用者返回值的方法通常被称为函数,而不返回值的方法通常被称为方法

Note

方法(和类)的访问修饰符在第五章中介绍。方法参数将在下一节介绍。

在文本的这一点上,您的每个方法都有以下基本格式:

// Recall that static methods can be called directly
// without creating a class instance.
class Program
{
  // static returnType MethodName(parameter list) { /* Implementation */ }
  static int Add(int x, int y)
  {
    return x + y;
  }
}

正如您将在接下来的几章中看到的,方法可以在类、结构或(C# 8 中的新功能)接口的范围内实现。

了解表达式主体成员

您已经学习了返回值的简单方法,比如Add()方法。C# 6 引入了表达式主体成员,缩短了单行方法的语法。例如,Add()可以使用以下语法重写:

static int Add(int x, int y) => x + y;

这就是通常所说的语法糖,意味着生成的 IL 没有什么不同。这只是编写方法的另一种方式。有些人觉得它更容易阅读,有些人不觉得,所以你(或你的团队)可以选择你喜欢的风格。

Note

不要被=>操作员吓到。这是一个 lambda 操作,在第十二章中有详细介绍。那一章也确切地解释了表情-身体成员是如何工作的。现在,就把它们看作是编写单行语句的捷径。

了解本地函数(新 7.0,更新 9.0)

C# 7.0 中引入的一个特性是在方法中创建方法的能力,官方称为局部函数。局部函数是在另一个函数内部声明的函数,必须是私有的,用 C# 8.0 可以是静态的(见下一节),不支持重载。局部函数支持嵌套:一个局部函数可以在内部声明一个局部函数。

要了解其工作原理,请创建一个名为 FunWithLocalFunctions 的新控制台应用项目。举例来说,假设您想要扩展之前使用的Add()示例,以包括输入的验证。有许多方法可以实现这一点,一个简单的方法是将验证直接添加到Add()方法中。让我们继续,将前面的例子更新为下面的例子(代表验证逻辑的注释):

static int Add(int x, int y)
{
  //Do some validation here
  return x + y;
}

如你所见,没有大的变化。只有一个注释表明真正的代码应该做一些事情。如果您想将方法的实际原因(返回参数的总和)与参数的验证分开,该怎么办呢?您可以创建额外的方法,并从Add()方法中调用它们。但是这需要创建另一个方法供另一个方法使用。也许这有点过了。本地函数允许您首先进行验证,然后封装在AddWrapper()方法中定义的方法的真正目标,如下所示:

static int AddWrapper(int x, int y)
{
  //Do some validation here
  return Add();

  int Add()
  {
    return x + y;
  }
}

被包含的Add()方法只能从包装AddWrapper()方法中调用。所以,我敢肯定你在想的问题是,“这给我买了什么?”这个具体例子的答案很简单,就是很少(如果有的话)。但是如果AddWrapper()需要从多个地方执行Add()功能呢?现在,您应该开始看到拥有一个代码重用的本地函数的好处,它不会暴露在需要它的地方之外。当我们讨论定制迭代器方法(第八章)和异步方法(第十五章)时,你会看到本地函数带来的更多好处。

Note

AddWrapper()局部函数是具有嵌套局部函数的局部函数的一个例子。回想一下,在顶级语句中声明的函数是作为局部函数创建的。Add()本地函数在AddWrapper()本地函数中。这种功能通常不会在教学示例之外使用,但是如果您需要嵌套本地函数,您知道 C# 支持它。

C# 9.0 更新了局部函数,允许向局部函数、其参数和类型参数添加属性,如下例所示(不要担心NotNullWhen属性,这将在本章后面介绍) :

#nullable enable
private static void Process(string?[] lines, string mark)
{
    foreach (var line in lines)
    {
        if (IsValid(line))
        {
            // Processing logic...
        }
    }

    bool IsValid([NotNullWhen(true)] string? line)
    {
        return !string.IsNullOrEmpty(line) && line.Length >= mark.Length;
    }
}

理解静态局部函数(新 8.0)

C# 8 中引入的对局部函数的改进是将局部函数声明为静态函数的能力。在前面的例子中,本地Add()函数直接引用主函数中的变量。这可能会导致意想不到的副作用,因为局部函数可以改变变量的值。

要看到这一点,创建一个名为AddWrapperWithSideEffect()的新方法,如下所示:

static int AddWrapperWithSideEffect(int x, int y)
{
  //Do some validation here
  return Add();

  int Add()
  {
    x += 1;
    return x + y;
  }
}

当然,这个例子非常简单,在实际代码中可能不会发生。为了防止这种错误,请将 static 修饰符添加到局部函数中。这会阻止局部函数直接访问父方法变量,并导致编译器异常 CS8421,“静态局部函数不能包含对“”的引用。”"

上一种方法的改进版本如下所示:

static int AddWrapperWithStatic(int x, int y)
{
  //Do some validation here
  return Add(x,y);

  static int Add(int x, int y)
  {
    return x + y;
  }
}

了解方法参数

方法参数用于将数据传递给方法调用。在接下来的几节中,您将了解方法(及其调用者)如何处理参数的细节。

了解方法参数修饰符

将参数发送到函数的默认方式是通过值发送*。简而言之,如果没有用参数修饰符标记参数,数据的副本将被传递到函数中。正如本章后面所解释的,确切地说复制什么将取决于参数是值类型还是引用类型。*

虽然 C# 中方法的定义非常简单,但是您可以使用一些方法来控制参数如何传递给方法,如表 4-2 中所列。

表 4-2。

C# 参数修饰符

|

参数修改器

|

生命的意义

(无) 如果值类型参数没有用修饰符标记,则假定它是按值传递的,这意味着被调用的方法接收原始数据的副本。没有修饰符的引用类型通过引用传递。
out 输出参数必须由被调用的方法赋值,因此通过引用传递。如果被调用的方法未能分配输出参数,则会出现编译器错误。
ref 该值最初由调用者赋值,并可以由被调用的方法随意修改(因为数据也是通过引用传递的)。如果被调用的方法未能分配一个ref参数,则不会产生编译器错误。
in 在 C# 7.2 中新增的,in修饰符表明一个ref参数对于被调用的方法是只读的。
params 这个参数修饰符允许您将可变数量的参数作为单个逻辑参数发送。一个方法只能有一个params修饰符,并且它必须是该方法的最终参数。您可能不需要经常使用params修饰符;但是,请注意,基类库中的许多方法确实利用了 C# 语言的这一特性。

为了演示这些关键字的用法,创建一个名为 FunWithMethods 的新控制台应用项目。现在,我们来看一下每个关键字的作用。

了解默认的参数传递行为

当参数没有修饰符时,值类型的行为是通过值传入参数,而引用类型的行为是通过引用传入参数。

Note

值类型和引用类型将在本章后面介绍。

值类型的默认行为

将值类型参数发送到函数的默认方式是通过值发送*。简单地说,如果没有用修饰符标记参数,数据的副本将被传递到函数中。将下面的方法添加到对通过值传递的两个数字数据类型进行操作的Program类中:*

// Value type arguments are passed by value by default.
static int Add(int x, int y)
{
  int ans = x + y;
  // Caller will not see these changes
  // as you are modifying a copy of the
  // original data.
  x = 10000;
  y = 88888;
  return ans;
}

数值数据属于值类型的范畴。因此,如果您在成员范围内更改参数的值,调用者不会知道,因为您是在调用者原始数据的副本上更改值。

Console.WriteLine("***** Fun with Methods *****\n");

// Pass two variables in by value.
int x = 9, y = 10;
Console.WriteLine("Before call: X: {0}, Y: {1}", x, y);
Console.WriteLine("Answer is: {0}", Add(x, y));
Console.WriteLine("After call: X: {0}, Y: {1}", x, y);
Console.ReadLine();

正如您所希望的,xy的值在调用Add()前后保持一致,如下图所示,因为数据点是通过值发送的。因此,Add()方法中对这些参数的任何更改都不会被调用者看到,因为Add()方法正在对数据的副本进行操作。

***** Fun with Methods *****

Before call: X: 9, Y: 10
Answer is: 19
After call: X: 9, Y: 10

引用类型的默认行为

将引用类型参数发送到函数中的默认方式是通过对其属性的引用*,但通过其自身的值*。这将在本章后面,在讨论值类型和引用类型之后详细介绍。

Note

尽管 string 数据类型在技术上是一种引用类型,正如第三章所讨论的,但它是一种特殊情况。当字符串参数没有修饰符时,它在中通过值传递。

使用 out 修饰符(更新 7.0)

接下来,你可以使用输出参数。已经被定义为接受输出参数(通过out关键字)的方法有义务在退出方法范围之前将它们赋给一个适当的值(如果您没有这样做,您将收到编译器错误)。

举例来说,下面是另一个版本的Add()方法,它使用 C# out修饰符返回两个整数的和(注意这个方法的物理返回值现在是void):

// Output parameters must be assigned by the called method.
static void AddUsingOutParam(int x, int y, out int ans)
{
  ans = x + y;
}

调用带有输出参数的方法也需要使用out修饰符。但是,作为输出变量传递的局部变量在作为输出参数传递之前不需要赋值(如果这样做,原始值在调用后会丢失)。编译器允许你发送看似未赋值的数据是因为被调用的方法必须赋值。要调用更新的Add方法,创建一个int类型的变量,并在调用中使用out修饰符,如下所示:

int ans;
AddUsingOutParam(90, 90, out ans);

从 C# 7.0 开始,out参数不需要在使用前声明。换句话说,它们可以在方法调用中声明,如下所示:

AddUsingOutParam(90, 90, out int ans);

下面的代码是一个使用out参数的内联声明调用方法的示例:

Console.WriteLine("***** Fun with Methods *****");

// No need to assign initial value to local variables
// used as output parameters, provided the first time
// you use them is as output arguments.
// C# 7 allows for out parameters to be declared in the method call
AddUsingOutParam(90, 90, out int ans);
Console.WriteLine("90 + 90 = {0}", ans);
Console.ReadLine();

前面的例子本质上是说明性的;你真的没有理由用一个输出参数来返回你求和的值。然而,C# out修饰符确实有一个有用的目的:它允许调用者从一次方法调用中获得多个输出。

// Returning multiple output parameters.
static void FillTheseValues(out int , out string b, out bool c)
{
  a = 9;
  b = "Enjoy your string.";
  c = true;
}

调用者将能够调用FillTheseValues()方法。请记住,当您调用方法以及实现方法时,您必须使用out修饰符。

Console.WriteLine("***** Fun with Methods *****");
FillTheseValues(out int i, out string str, out bool b);

Console.WriteLine("Int is: {0}", i);
Console.WriteLine("String is: {0}", str);
Console.WriteLine("Boolean is: {0}", b);
Console.ReadLine();

Note

C# 7 还引入了元组,这是从方法调用中返回多个值的另一种方式。在这一章的后面你会学到更多。

请始终记住,定义输出参数的方法必须在退出方法范围之前将参数赋给一个有效值。因此,以下代码将导致编译器错误,因为输出参数尚未在方法范围内赋值:

static void ThisWontCompile(out int a)
{
  Console.WriteLine("Error! Forgot to assign output arg!");
}

丢弃参数(新 7.0)

如果您不关心out参数的值,您可以使用一个丢弃作为占位符。丢弃是有意不使用的临时虚拟变量。它们是未分配的,没有值,甚至可能不会分配任何内存。这可以提供性能优势,并使您的代码更具可读性。丢弃可以与out参数、元组(本章后面)、模式匹配(第 6 和 8 章)一起使用,甚至可以作为独立变量使用。

例如,如果您想获得上一个示例中的int的值,但不关心后两个参数,您可以编写以下代码:

//This only gets the value for a, and ignores the other two parameters
FillTheseValues(out int a, out _, out _);

请注意,被调用的方法仍然在为所有三个参数设置值;当方法调用返回时,最后两个参数被丢弃。

构造函数和初始化函数中的 out 修饰符(新 7.3)

C# 7.3 扩展了使用out参数的允许位置。除了方法之外,构造函数的参数、字段和属性初始化器以及查询子句都可以用out修饰符来修饰。这方面的例子将在本书的后面部分讨论。

使用 ref 修饰符

现在考虑 C# ref参数修饰符的使用。当您希望允许一个方法对在调用者作用域中声明的各种数据点(例如排序或交换例程)进行操作(并且通常更改其值)时,引用参数是必需的。请注意输出参数和参考参数之间的区别:

  • 输出参数在传递给方法之前不需要初始化。原因是该方法必须在退出前分配输出参数。

  • 引用参数在传递给方法之前必须初始化。这是因为您正在传递对现有变量的引用。如果你不把它赋给一个初始值,那就相当于对一个未赋值的局部变量进行操作。

让我们通过交换两个string变量的方法来检查一下ref关键字的用法(当然,这里可以使用任何两种数据类型,包括intboolfloat等)。).

// Reference parameters.
public static void SwapStrings(ref string s1, ref string s2)
{
  string tempStr = s1;
  s1 = s2;
  s2 = tempStr;
}

此方法可以按如下方式调用:

Console.WriteLine("***** Fun with Methods *****");

string str1 = "Flip";
string str2 = "Flop";
Console.WriteLine("Before: {0}, {1} ", str1, str2);
SwapStrings(ref str1, ref str2);
Console.WriteLine("After: {0}, {1} ", str1, str2);
Console.ReadLine();

这里,调用者已经为本地字符串数据分配了一个初始值(str1str2)。在对SwapStrings()的调用返回后,str1现在包含值"Flop",而str2报告值"Flip"

Before: Flip, Flop

After: Flop, Flip

使用 in 修饰符(新 7.2)

in修饰符通过引用传递值(对于值类型和引用类型),并防止被调用的方法修改值。这清楚地表明了代码中的设计意图,并有可能减少内存压力。当值类型通过值传递时,它们被被调用的方法(在内部)复制。如果对象很大(比如一个大的结构),为本地使用而制作副本的额外开销可能会很大。此外,即使在没有修饰符的情况下传递引用类型,它们也可以被被调用的方法修改。这两个问题都可以使用in修改器来解决。

回顾前面的Add()方法,有两行代码修改了参数,但是不影响调用方法的值。这些值不会受到影响,因为Add()方法复制了变量xy供本地使用。虽然调用方法没有任何负面影响,但是如果将Add()方法改为下面的代码会怎么样呢?

static int Add2(int x,int y)
{
  x = 10000;
  y = 88888;
  int ans = x + y;
  return ans;
}

不管发送到方法中的数字是多少,运行这段代码都会返回 98888。这显然是个问题。若要更正此问题,请将方法更新为以下内容:

static int AddReadOnly(in int x,in int y)
{
  //Error CS8331 Cannot assign to variable 'in int' because it is a readonly variable
  //x = 10000;
  //y = 88888;
  int ans = x + y;
  return ans;
}

当代码试图更改参数值时,编译器会引发 CS8331 错误,表明由于in修饰符的原因,这些值不能被修改。

使用参数修改器

C# 支持使用关键字params来使用参数数组params关键字允许您将可变数量的相同类型的参数(或通过继承相关的类)作为一个单一逻辑参数传递给一个方法。同样,如果调用者发送强类型数组或逗号分隔的项目列表,则可以处理标有params关键字的参数。是的,这可能会令人困惑!为了搞清楚,假设您想要创建一个函数,允许调用者传入任意数量的参数并返回计算出的平均值。

如果您要将这个方法原型化以获取一个由double组成的数组,这将迫使调用者首先定义该数组,然后填充该数组,最后将其传递给该方法。但是,如果您将CalculateAverage()定义为接受double[]数据类型的params,调用者可以简单地传递一个逗号分隔的double列表。这个double列表将在后台打包成一个double数组。

// Return average of "some number" of doubles.
static double CalculateAverage(params double[] values)
{
  Console.WriteLine("You sent me {0} doubles.", values.Length);

  double sum = 0;
  if(values.Length == 0)
  {
    return sum;
  }
  for (int i = 0; i < values.Length; i++)
  {
    sum += values[i];
  }
  return (sum / values.Length);
}

这个方法被定义为接受一个由double s 组成的参数数组。这个方法实际上说的是“给我发送任意数量的double s(包括零),我将计算平均值。”考虑到这一点,您可以通过以下任何一种方式调用CalculateAverage():

Console.WriteLine("***** Fun with Methods *****");

// Pass in a comma-delimited list of doubles...
double average;
average = CalculateAverage(4.0, 3.2, 5.7, 64.22, 87.2);
Console.WriteLine("Average of data is: {0}", average);

// ...or pass an array of doubles.
double[] data = { 4.0, 3.2, 5.7 };
average = CalculateAverage(data);
Console.WriteLine("Average of data is: {0}", average);

// Average of 0 is 0!
Console.WriteLine("Average of data is: {0}", CalculateAverage());
Console.ReadLine();

如果您没有在CalculateAverage()的定义中使用params修饰符,那么这个方法的第一次调用将会导致编译器错误,因为编译器将会寻找一个带有五个double参数的CalculateAverage()版本。

Note

为了避免任何歧义,C# 要求一个方法只支持单个params参数,该参数必须是参数列表中的最后一个参数。

正如您可能猜到的那样,这种技术只不过是为了方便调用者,因为数组是由。NET 核心运行时。当数组在被调用方法的范围内时,您可以将其视为完全成熟的。NET 核心数组,包含了System.Array基础类库类型的所有功能。考虑以下输出:

You sent me 5 doubles.

Average of data is: 32.864
You sent me 3 doubles.
Average of data is: 4.3
You sent me 0 doubles.
Average of data is: 0

定义可选参数

C# 允许你创建带可选参数的方法。这种技术允许调用者调用单个方法,同时省略被认为不必要的参数,只要调用者对指定的缺省值满意。

为了说明如何使用可选参数,假设您有一个名为EnterLogData()的方法,它定义了一个可选参数。

static void EnterLogData(string message, string owner = "Programmer")
{
  Console.Beep();
  Console.WriteLine("Error: {0}", message);
  Console.WriteLine("Owner of Error: {0}", owner);
}

这里,最后一个string参数通过参数定义中的赋值被赋予默认值"Programmer"。有鉴于此,你可以用两种方式称呼EnterLogData()

Console.WriteLine("***** Fun with Methods *****");
...
EnterLogData("Oh no! Grid can't find data");
EnterLogData("Oh no! I can't find the payroll data", "CFO");

Console.ReadLine();

因为第一次调用EnterLogData()时没有指定第二个string参数,所以您会发现程序员应该对网格数据的丢失负责,而 CFO 却错放了工资数据(由第二次方法调用中的第二个参数指定)。

需要注意的一件重要事情是,赋给可选参数的值必须在编译时已知,并且不能在运行时解析(如果您试图这样做,将会收到编译时错误!).举例来说,假设您想要用以下额外的可选参数更新EnterLogData():

// Error! The default value for an optional arg must be known
// at compile time!
static void EnterLogData(string message, string owner = "Programmer", DateTime timeStamp = DateTime.Now)
{
  Console.Beep();
  Console.WriteLine("Error: {0}", message);
  Console.WriteLine("Owner of Error: {0}", owner);
  Console.WriteLine("Time of Error: {0}", timeStamp);
}

这不会编译,因为DateTime类的Now属性的值是在运行时解析的,而不是在编译时。

Note

为了避免歧义,可选参数必须总是打包在方法签名的上。将可选参数列在非可选参数之前是一个编译器错误。

使用命名参数(更新 7.2)

C# 中的另一个语言特性是支持命名参数。命名参数允许您通过以任意顺序指定参数值来调用方法。因此,您可以选择使用冒号操作符按名称指定每个参数,而不是只按位置传递参数(大多数情况下都会这样做)。为了说明命名参数的使用,假设您已经向Program类添加了以下方法:

static void DisplayFancyMessage(ConsoleColor textColor,
  ConsoleColor backgroundColor, string message)
{
  // Store old colors to restore after message is printed.
  ConsoleColor oldTextColor = Console.ForegroundColor;
  ConsoleColor oldbackgroundColor = Console.BackgroundColor;
  // Set new colors and print message.
  Console.ForegroundColor = textColor;
  Console.BackgroundColor = backgroundColor;
  Console.WriteLine(message);
  // Restore previous colors.
  Console.ForegroundColor = oldTextColor;
  Console.BackgroundColor = oldbackgroundColor;
}

现在,按照编写DisplayFancyMessage()的方式,您会期望调用者通过传递两个ConsoleColor变量后跟一个string类型来调用这个方法。但是,使用命名参数,以下调用完全没问题:

Console.WriteLine("***** Fun with Methods *****");

DisplayFancyMessage(message: "Wow! Very Fancy indeed!",
  textColor: ConsoleColor.DarkRed,
  backgroundColor: ConsoleColor.White);

DisplayFancyMessage(backgroundColor: ConsoleColor.Green,
  message: "Testing...",
  textColor: ConsoleColor.DarkBlue);
Console.ReadLine();

在 C# 7.2 中,使用命名参数的规则略有更新。在 7.2 版之前,如果开始使用位置参数调用方法,必须在任何命名参数之前列出它们。在 7.2 和更高版本的 C# 中,如果参数的位置正确,命名参数和未命名参数可以混合使用。

Note

仅仅因为在 C# 7.2 和更高版本中可以混合使用命名参数和位置参数,这并不是一个好主意。仅仅因为你能并不意味着你应该!

以下代码是一个示例:

// This is OK, as positional args are listed before named args.
DisplayFancyMessage(ConsoleColor.Blue,
  message: "Testing...",
  backgroundColor: ConsoleColor.White);

// This is OK, all arguments are in the correct order
DisplayFancyMessage(textColor: ConsoleColor.White, backgroundColor:ConsoleColor.Blue, "Testing...");

// This is an ERROR, as positional args are listed after named args.
DisplayFancyMessage(message: "Testing...",
  backgroundColor: ConsoleColor.White,
  ConsoleColor.Blue);

除了这个限制,您可能还想知道什么时候需要使用这个语言特性。毕竟,如果您需要为一个方法指定三个参数,为什么要麻烦地改变它们的位置呢?

事实证明,如果您有一个定义可选参数的方法,这个特性会很有帮助。假设DisplayFancyMessage()已经重写,现在支持可选参数,因为您已经指定了拟合默认值。

static void DisplayFancyMessage(ConsoleColor textColor = ConsoleColor.Blue,
  ConsoleColor backgroundColor = ConsoleColor.White,
  string message = "Test Message")
{
   ...
}

假设每个参数都有一个默认值,命名参数允许调用者只指定他们不想接收默认值的参数。因此,如果调用者希望值"Hello!"以白色背景包围的蓝色文本显示,他们可以简单地指定以下内容:

DisplayFancyMessage(message: "Hello!");

或者,如果呼叫者希望看到绿色背景、蓝色文本的“测试消息”打印出来,他们可以调用以下内容:

DisplayFancyMessage(backgroundColor: ConsoleColor.Green);

正如您所看到的,可选参数和命名参数往往一起工作。为了总结您对构建 C# 方法的研究,我需要解决方法重载的话题。

理解方法重载

像其他现代面向对象语言一样,C# 允许一个方法被重载。简单地说,当您定义一组名称相同但参数数量(或类型)不同的方法时,这个方法被称为重载

要理解重载为什么如此有用,请考虑一下作为一名老派 Visual Basic 6.0 (VB6)开发人员的生活。假设您正在使用 VB6 构建一组方法,这些方法返回各种传入数据类型(Integer s、Double s 等)的总和。).鉴于 VB6 不支持方法重载,您需要定义一组独特的方法,这些方法本质上做同样的事情(返回参数的总和)。

' VB6 code examples.
Public Function AddInts(ByVal x As Integer, ByVal y As Integer) As Integer
  AddInts = x + y
End Function

Public Function AddDoubles(ByVal x As Double, ByVal y As Double) As Double
  AddDoubles = x + y
End Function

Public Function AddLongs(ByVal x As Long, ByVal y As Long) As Long
  AddLongs = x + y
End Function

这样的代码不仅变得难以维护,而且调用者现在必须痛苦地意识到每个方法的名称。使用重载,您可以允许调用者调用一个名为Add()的方法。同样,关键是要确保方法的每个版本都有一组不同的参数(仅返回类型不同的方法是不够唯一的)。

Note

正如将在第十章中解释的那样,有可能构建将重载概念提升到下一个层次的泛型方法。使用泛型,您可以为方法实现定义类型占位符,这些占位符是在您调用相关成员时指定的。

为了直接检验这一点,创建一个名为 FunWithMethodOverloading 的新控制台应用项目。添加一个名为AddOperations.cs的新类,并将代码更新如下:

namespace FunWithMethodOverloading {
  // C# code.
  // Overloaded Add() method.

  public static class AddOperations
  {
    // Overloaded Add() method.
    public static int Add(int x, int y)
    {
      return x + y;
    }
    public static double Add(double x, double y)
    {
      return x + y;
    }
    public static long Add(long x, long y)
    {
      return x + y;
    }
  }
}

用以下代码替换Program.cs中的代码:

using System;
using FunWithMethodOverloading;
using static FunWithMethodOverloading.AddOperations;

Console.WriteLine("***** Fun with Method Overloading *****\n");

// Calls int version of Add()
Console.WriteLine(Add(10, 10));

// Calls long version of Add() (using the new digit separator)
Console.WriteLine(Add(900_000_000_000, 900_000_000_000));

// Calls double version of Add()
Console.WriteLine(Add(4.3, 4.4));

Console.ReadLine();

Note

第五章将会涉及using static声明。现在,把它看作是在FunWithMethodOverloading名称空间中包含一个名为AddOperations的静态类的using方法的键盘快捷键。

顶层语句调用了三个不同版本的Add方法,每个都使用不同的数据类型。

调用重载方法进行引导时,Visual Studio 和 Visual Studio 代码都有帮助。当您键入重载方法的名称时(例如您的好朋友Console.WriteLine()),IntelliSense 将列出该方法的每个版本。注意,你可以使用上下箭头键在重载方法的每个版本中循环,如图 4-1 所示。

img/340876_10_En_4_Fig1_HTML.jpg

图 4-1。

用于重载方法的 Visual Studio IntelliSense

如果重载有可选参数,编译器将根据命名和/或位置参数选择与调用代码最匹配的方法。添加以下方法:

static int Add(int x, int y, int z = 0)
{
  return x + (y*z);
}

如果调用方没有传入可选参数,编译器将匹配第一个签名(没有可选参数的签名)。虽然方法位置有一个规则集,但是创建仅在可选参数上有所不同的方法通常不是一个好主意。

最后,当使用多个修饰符时,inrefout不被认为是方法重载签名的一部分。换句话说,下列重载将引发编译器错误:

static int Add(ref int x) { /* */ }
static int Add(out int x) { /* */ }

然而,如果只有一个方法使用了inrefout,编译器可以区分这些签名。所以,这是允许的:

static int Add(ref int x) { /* */ }
static int Add(int x) { /* */ }

这就结束了使用 C# 语法构建方法的初步研究。接下来,让我们看看如何构建和操作枚举和结构。

了解枚举类型

从第一章回忆起。NET 核心类型系统由类、结构、枚举、接口和委托组成。为了开始探索这些类型,让我们使用一个名为 FunWithEnums 的新控制台应用项目来检查一下枚举(或者简称为enum)的角色。

Note

不要混淆术语枚举器枚举器;它们是完全不同的概念。枚举是名称-值对的自定义数据类型。枚举器是实现名为IEnumerable的. NET 核心接口的类或结构。通常,这个接口是在集合类和System.Array类上实现的。正如你将在第八章中看到的,支持IEnumerable的对象可以在foreach循环中工作。

构建系统时,创建一组映射到已知数值的符号名通常很方便。例如,如果您正在创建一个工资单系统,您可能希望使用诸如副总裁、经理、承包商和普通员工等常量来引用雇员的类型。正因为如此,C# 支持自定义枚举的概念。例如,这里有一个名为EmpTypeEnum的枚举(如果将它放在文件的末尾,可以在与顶级语句相同的文件中定义它):

using System;

Console.WriteLine("**** Fun with Enums *****\n");
Console.ReadLine();

//local functions go here:

// A custom enumeration.
enum EmpTypeEnum
{
  Manager,      // = 0
  Grunt,        // = 1
  Contractor,   // = 2
  VicePresident // = 3
}

Note

按照惯例,枚举类型通常以Enum为后缀。这不是必须的,但可以让代码更易读。

EmpTypeEnum枚举定义了四个命名的常量,对应于离散的数值。默认情况下,第一个元素的值设置为零(0),后面是 n+1 级数。你可以随意改变初始值。例如,如果将EmpTypeEnum的成员编号为 102 到 105 是有意义的,您可以这样做:

// Begin with 102.
enum EmpTypeEnum
{
  Manager = 102,
  Grunt,        // = 103
  Contractor,   // = 104
  VicePresident // = 105
}

枚举不一定需要遵循顺序,也不需要具有唯一的值。如果(由于这样或那样的原因)像这里所示的那样建立您的EmpTypeEnum是有意义的,编译器会继续高兴:

// Elements of an enumeration need not be sequential!
enum EmpType
{
  Manager = 10,
  Grunt = 1,
  Contractor = 100,
  VicePresident = 9
}

控制枚举的基础存储

默认情况下,用于保存枚举值的存储类型是 aSystem.Int32(c#int);但是,您可以根据自己的喜好随意更改。可以用类似的方式为任何核心系统类型(byteshortintlong)定义 C# 枚举。例如,如果您想将EmpTypeEnum的底层存储值设置为byte而不是int,您可以编写以下代码:

// This time, EmpTypeEnum maps to an underlying byte.
enum EmpTypeEnum : byte
{
  Manager = 10,
  Grunt = 1,
  Contractor = 100,
  VicePresident = 9
}

如果您正在构建一个. NET 核心应用,该应用将被部署到低内存设备上,并且需要尽可能地节省内存,则更改枚举的基础类型会很有帮助。当然,如果您确实建立了您的枚举来使用一个byte作为存储,每个值必须在它的范围之内!例如,以下版本的EmpTypeEnum将导致编译器错误,因为值 999 不适合一个字节的范围:

// Compile-time error! 999 is too big for a byte!
enum EmpTypeEnum : byte
{
  Manager = 10,
  Grunt = 1,
  Contractor = 100,
  VicePresident = 999
}

声明枚举变量

一旦建立了枚举的范围和存储类型,就可以用它来代替所谓的幻数。因为枚举只不过是用户定义的数据类型,所以您可以将它们用作函数返回值、方法参数、局部变量等等。假设您有一个名为AskForBonus()的方法,将一个EmpTypeEnum变量作为唯一的参数。根据传入参数的值,您将打印出对支付奖金请求的合适响应。

Console.WriteLine("**** Fun with Enums *****");
// Make an EmpTypeEnum variable.
EmpTypeEnum emp = EmpTypeEnum.Contractor;
AskForBonus(emp);
Console.ReadLine();

// Enums as parameters.
static void AskForBonus(EmpTypeEnum e)
{
  switch (e)
  {
    case EmpType.Manager:
      Console.WriteLine("How about stock options instead?");
      break;
    case EmpType.Grunt:
      Console.WriteLine("You have got to be kidding...");
      break;
    case EmpType.Contractor:
      Console.WriteLine("You already get enough cash...");
      break;
    case EmpType.VicePresident:
      Console.WriteLine("VERY GOOD, Sir!");
      break;
  }
}

请注意,当您给一个enum变量赋值时,您必须将enum名称(EmpTypeEnum)限定为值(Grunt)。因为枚举是一组固定的名称-值对,所以将enum变量设置为不是由枚举类型直接定义的值是非法的。

static void ThisMethodWillNotCompile()
{
  // Error! SalesManager is not in the EmpTypeEnum enum!
  EmpTypeEnum emp = EmpType.SalesManager;

  // Error! Forgot to scope Grunt value to EmpTypeEnum enum!
  emp = Grunt;
}

使用系统。枚举类型

有趣的是。NET 核心枚举的一个优点是它们从System.Enum类类型中获得功能。这个类定义了几个方法,允许您查询和转换给定的枚举。一个有用的方法是静态的Enum.GetUnderlyingType(),顾名思义,它返回用于存储枚举类型值的数据类型(在当前的EmpTypeEnum声明中是System.Byte)。

Console.WriteLine("**** Fun with Enums *****");
...

// Print storage for the enum.
Console.WriteLine("EmpTypeEnum uses a {0} for storage",
  Enum.GetUnderlyingType(emp.GetType()));
Console.ReadLine();

Enum.GetUnderlyingType()方法要求您传入一个System.Type作为第一个参数。正如在第十七章中详细讨论的那样,Type代表给定的元数据描述.NETCore 实体。

获取元数据的一种可能方式(如前所示)是使用GetType()方法,该方法对于。NET 核心基本类库。另一种方法是使用 C# typeof操作符。这样做的一个好处是,您不需要拥有想要获取其元数据描述的实体的变量。

// This time use typeof to extract a Type.
Console.WriteLine("EmpTypeEnum uses a {0} for storage",
    Enum.GetUnderlyingType(typeof(EmpTypeEnum)));

动态发现枚举的名称-值对

除了Enum.GetUnderlyingType()方法,所有 C# 枚举都支持一个名为ToString()的方法,该方法返回当前枚举值的字符串名称。以下代码是一个示例:

EmpTypeEnum emp = EmpTypeEnum.Contractor;
...
// Prints out "emp is a Contractor".
Console.WriteLine("emp is a {0}.", emp.ToString());
Console.ReadLine();

如果您对发现给定枚举变量的值感兴趣,而不是它的名称,您可以简单地将enum变量转换为底层存储类型。以下是一个例子:

Console.WriteLine("**** Fun with Enums *****");
EmpTypeEnum emp = EmpTypeEnum.Contractor;
...
// Prints out "Contractor = 100".
Console.WriteLine("{0} = {1}", emp.ToString(), (byte)emp);
Console.ReadLine();

Note

静态的Enum.Format()方法通过指定一个期望的格式标志提供了一个更精细的格式化选项。有关格式化标志的完整列表,请参考文档。

System.Enum还定义了另一个名为GetValues()的静态方法。这个方法返回一个System.Array的实例。数组中的每一项都对应于指定枚举的一个成员。考虑下面的方法,它将打印出您作为参数传入的任何枚举中的每个名称-值对:

// This method will print out the details of any enum.
static void EvaluateEnum(System.Enum e)
{
  Console.WriteLine("=> Information about {0}", e.GetType().Name);

  Console.WriteLine("Underlying storage type: {0}",
    Enum.GetUnderlyingType(e.GetType()));

  // Get all name-value pairs for incoming parameter.
  Array enumData = Enum.GetValues(e.GetType());
  Console.WriteLine("This enum has {0} members.", enumData.Length);

  // Now show the string name and associated value, using the D format
  // flag (see Chapter 3).
  for(int i = 0; i < enumData.Length; i++)
  {
    Console.WriteLine("Name: {0}, Value: {0:D}",
      enumData.GetValue(i));
  }
}

为了测试这个新方法,更新您的code来创建在System名称空间中声明的几个枚举类型的变量(以及一个EmpTypeEnum枚举)。以下代码是一个示例:

  Console.WriteLine("**** Fun with Enums *****");
  ...
  EmpTypeEnum e2 = EmpType.Contractor;

  // These types are enums in the System namespace.
  DayOfWeek day = DayOfWeek.Monday;
  ConsoleColor cc = ConsoleColor.Gray;

  EvaluateEnum(e2);
  EvaluateEnum(day);
  EvaluateEnum(cc);
  Console.ReadLine();

这里显示了部分输出:

=> Information about DayOfWeek

Underlying storage type: System.Int32
This enum has 7 members.
Name: Sunday, Value: 0
Name: Monday, Value: 1
Name: Tuesday, Value: 2
Name: Wednesday, Value: 3
Name: Thursday, Value: 4
Name: Friday, Value: 5
Name: Saturday, Value: 6

正如您将在本文中看到的,枚举在整个。NET 核心基本类库。当您使用任何枚举时,请记住您可以使用System.Enum的成员与名称-值对进行交互。

使用枚举、标志和位运算

按位运算提供了一种在比特级对二进制数进行运算的快速机制。表 4-3 包含了 C# 位操作符,它们做什么,以及每个操作符的例子。

表 4-3。

位运算

|

操作员

|

操作

|

例子

&(和) 如果一个位在两个操作数中都存在,则复制该位 0110 & 0100 = 0100 (4)
|(或) 如果一个位在两个操作数中都存在,则复制该位 0110 | 0100 = 0110 (6)
^(异或) 如果某个位存在于一个操作数中,但不存在于两个操作数中,则复制该位 0110 ^ 0100 = 0010 (2)
~(某人的赞美) 翻转比特 ~0110 = -7(由于溢出)
< 将位左移 0110 << 1 = 1100 (12)
> >(右移) 将位右移 0110 << 1 = 0011 (3)

为了展示这些操作,创建一个名为 FunWithBitwiseOperations 的新控制台应用项目。将Program.cs文件更新为以下代码:

using System;
using FunWithBitwiseOperations;
Console.WriteLine("===== Fun wih Bitwise Operations");
Console.WriteLine("6 & 4 = {0} | {1}", 6 & 4, Convert.ToString((6 & 4),2));
Console.WriteLine("6 | 4 = {0} | {1}", 6 | 4, Convert.ToString((6 | 4),2));
Console.WriteLine("6 ^ 4 = {0} | {1}", 6 ^ 4, Convert.ToString((6 ^ 4),2));
Console.WriteLine("6 << 1  = {0} | {1}", 6 << 1, Convert.ToString((6 << 1),2));
Console.WriteLine("6 >> 1 = {0} | {1}", 6 >> 1, Convert.ToString((6 >> 1),2));
Console.WriteLine("~6 = {0} | {1}", ~6, Convert.ToString(~((short)6),2));
Console.WriteLine("Int.MaxValue {0}", Convert.ToString((int.MaxValue),2));
Console.readLine();

当您执行代码时,您将看到以下结果:

===== Fun wih Bitwise Operations
6 & 4 = 4 | 100
6 | 4 = 6 | 110
6 ^ 4 = 2 | 10
6 << 1  = 12 | 1100
6 >> 1 = 3 | 11
~6 =  -7 | 11111111111111111111111111111001
Int.MaxValue 1111111111111111111111111111111

既然您已经知道了按位运算的基本知识,是时候将它们应用到枚举中了。添加名为ContactPreferenceEnum.cs的新文件,并将代码更新如下:

using System;
namespace FunWithBitwiseOperations
{
  [Flags]
  public enum ContactPreferenceEnum
  {
    None = 1,
    Email = 2,
    Phone = 4,
    Ponyexpress = 6
  }
}

请注意Flags属性。这允许将一个枚举中的多个值合并到一个变量中。例如,EmailPhone可以这样组合:

ContactPreferenceEnum emailAndPhone = ContactPreferenceEnum.Email | ContactPreferenceEnum.Phone;

这允许您检查其中一个值是否存在于组合值中。例如,如果您想查看哪个ContactPreference值在emailAndPhone变量中,您可以使用下面的代码:

Console.WriteLine("None? {0}", (emailAndPhone | ContactPreferenceEnum.None) == emailAndPhone);
Console.WriteLine("Email? {0}", (emailAndPhone | ContactPreferenceEnum.Email) == emailAndPhone);
Console.WriteLine("Phone? {0}", (emailAndPhone | ContactPreferenceEnum.Phone) == emailAndPhone);
Console.WriteLine("Text? {0}", (emailAndPhone | ContactPreferenceEnum.Text) == emailAndPhone);

执行时,控制台窗口会显示以下内容:

None? False
Email? True
Phone? True
Text? False

理解结构(又名值类型)

现在您已经理解了枚举类型的作用,让我们来研究。NET 核心结构(或者简称为结构)。结构类型非常适合在应用中建模数学、几何和其他“原子”实体。结构(如枚举)是用户定义的类型;然而,结构不仅仅是名称-值对的集合。相反,结构是可以包含任意数量的数据字段和对这些字段进行操作的成员的类型。

Note

如果你有 OOP 的背景,你可以把一个结构想成一个“轻量级类类型”,因为结构提供了一种定义支持封装的类型的方法,但是不能用来构建一系列相关的类型。当你需要通过继承建立一个相关类型的家族时,你将需要利用类类型。

从表面上看,定义和使用结构的过程很简单,但是正如他们所说的,细节决定成败。为了开始理解结构类型的基础,创建一个名为 FunWithStructures 的新项目。在 C# 中,使用struct关键字定义结构。定义一个名为Point的新结构,它定义了两个int类型的成员变量和一组与所述数据交互的方法。

struct Point
{
  // Fields of the structure.
  public int X;
  public int Y;

  // Add 1 to the (X, Y) position.
  public void Increment()
  {
    X++; Y++;
  }

  // Subtract 1 from the (X, Y) position.
  public void Decrement()
  {
    X--; Y--;
  }

  // Display the current position.
  public void Display()
  {
    Console.WriteLine("X = {0}, Y = {1}", X, Y);
  }
}

这里,您已经使用public关键字定义了两个整数字段(XY),这是一个访问控制修饰符(第五章继续讨论)。用public关键字声明数据可以确保调用者可以直接访问给定的Point变量中的数据(通过点运算符)。

Note

在类或结构中定义公共数据通常被认为是不好的风格。相反,您会想要定义私有的数据,可以使用公共的属性来访问和更改这些数据。这些细节将在第五章中讨论。

下面是测试使用Point类型的代码:

Console.WriteLine("***** A First Look at Structures *****\n");

// Create an initial Point.
Point myPoint;
myPoint.X = 349;
myPoint.Y = 76;
myPoint.Display();

// Adjust the X and Y values.
myPoint.Increment();
myPoint.Display();
Console.ReadLine();

输出如您所料。

***** A First Look at Structures *****

X = 349, Y = 76
X = 350, Y = 77

创建结构变量

当你想创建一个结构变量时,你有多种选择。在这里,您只需创建一个Point变量,并在调用它的成员之前分配每个公共字段数据。如果您在使用该结构之前没有而不是分配每一个公共字段数据(在本例中是XY,您将会收到一个编译器错误。

// Error! Did not assign Y value.
Point p1;
p1.X = 10;
p1.Display();

// OK! Both fields assigned before use.
Point p2;
p2.X = 10;
p2.Y = 10;
p2.Display();

或者,您可以使用 C# new关键字创建结构变量,这将调用结构的默认构造函数。根据定义,默认构造函数不接受任何参数。调用结构的默认构造函数的好处是每一段字段数据都被自动设置为其默认值。

// Set all fields to default values
// using the default constructor.
Point p1 = new Point();

// Prints X=0,Y=0.
p1.Display();

也可以设计一个带有自定义构造器的结构。这允许您在创建变量时指定字段数据的值,而不必逐个字段地设置每个数据成员。第五章将提供对构造者的详细检查;然而,为了说明,用下面的代码更新Point结构:

struct Point
{
  // Fields of the structure.
  public int X;
  public int Y;

  // A custom constructor.
  public Point(int xPos, int yPos)
  {
    X = xPos;
    Y = yPos;
  }
...
}

这样,您现在可以创建Point变量,如下所示:

// Call custom constructor.
Point p2 = new Point(50, 60);

// Prints X=50,Y=60.
p2.Display();

使用只读结构(新 7.2)

如果需要使结构成为不可变的,也可以将它们标记为只读。不可变对象必须在构造时建立,因为它们不能被改变,所以性能更好。当将结构声明为只读时,所有属性也必须是只读的。但是你可能会问,如果一个属性是只读的,如何设置它(因为所有的属性都必须在一个结构上)?答案是该值必须在构造结构的过程中设置。

将点类更新为以下示例:

readonly struct ReadOnlyPoint
{
  // Fields of the structure.
  public int X {get; }
  public int Y { get; }

  // Display the current position and name.
  public void Display()
  {
    Console.WriteLine($"X = {X}, Y = {Y}");
  }

  public ReadOnlyPoint(int xPos, int yPos)
  {
    X = xPos;
    Y = yPos;
  }
}

因为变量是只读的,所以已经删除了IncrementDecrement方法。还要注意两个属性,XY。它们不是设置为字段,而是创建为只读自动属性。自动属性包含在第五章中。

使用只读成员(新 8.0)

C# 8.0 中的新特性,你可以将一个结构的单个字段声明为readonly。这比将整个结构设为只读更细粒度。readonly修饰符可以应用于方法、属性和属性访问器。将以下结构代码添加到您的文件中,在Program类之外:

struct PointWithReadOnly
{
  // Fields of the structure.
  public int X;
  public readonly int Y;
  public readonly string Name;

  // Display the current position and name.
  public readonly void Display()
  {
    Console.WriteLine($"X = {X}, Y = {Y}, Name = {Name}");
  }

  // A custom constructor.
  public PointWithReadOnly(int xPos, int yPos, string name)
  {
    X = xPos;
    Y = yPos;
    Name = name;
  }
}

若要使用这个新结构,请将以下内容添加到顶级语句中:

PointWithReadOnly p3 =
  new PointWithReadOnly(50,60,"Point w/RO");
p3.Display();

使用引用结构(新 7.2)

C# 7.2 中也添加了一个修饰符ref,可以在定义一个结构时使用。这要求对该结构的所有实例进行堆栈分配,并且不能作为另一个类的属性进行赋值。技术上的原因是不能从堆中引用ref结构。堆栈和堆之间的区别将在下一节讨论。

以下是ref结构的一些附加限制:

  • 它们不能赋给 object 或 dynamic 类型的变量,也不能是接口类型。

  • 它们不能实现接口。

  • 它们不能用作非ref结构的属性。

  • 它们不能用在异步方法、迭代器、lambda 表达式或局部函数中。

下面的代码创建了一个简单的结构,然后试图在该结构中创建一个类型为ref结构的属性,该代码将不会编译:

struct NormalPoint
{
  //This does not compile
  public PointWithRef PropPointer { get; set; }
}

readonlyref修饰符可以结合使用,以获得两者的优点和限制。

使用可处理的引用结构(新 8.0)

如前一节所述,ref结构(和只读ref结构)不能实现接口,因此也不能实现IDisposable。C# 8.0 中的新特性,ref结构和只读ref结构可以通过添加一个公共void Dispose()方法来进行处理。

将以下结构定义添加到主文件中:

ref struct DisposableRefStruct
{
  public int X;
  public readonly int Y;
  public readonly void Display()
  {
    Console.WriteLine($"X = {X}, Y = {Y}");
  }
  // A custom constructor.
  public DisposableRefStruct(int xPos, int yPos)
  {
    X = xPos;
    Y = yPos;
    Console.WriteLine("Created!");
  }
  public void Dispose()
  {
    //clean up any resources here
    Console.WriteLine("Disposed!");
  }
}

接下来,将以下内容添加到顶级语句的末尾,以创建和释放新的结构:

var s = new DisposableRefStruct(50, 60);
s.Display();
s.Dispose();

Note

第九章深入介绍了对象生存期和对象处置。

为了加深您对堆栈和堆分配的理解,您需要探究. NET 核心值类型和. NET 核心引用类型之间的区别。

了解值类型和引用类型

Note

下面对值类型和引用类型的讨论假设您有面向对象编程的背景。如果不是这样,你可能想跳到本章的“理解 C# 可空类型”一节,并在阅读完第 5 和 6 章后回到这一节。

与数组、字符串或枚举不同,C# 结构在。NET 核心库(即没有System.Structure类)但是从System.ValueType隐式派生而来。System.ValueType的作用是确保派生类型(如任何结构)被分配在上,而不是被垃圾收集的上。简而言之,分配在堆栈上的数据可以被快速地创建和销毁,因为它的生存期是由定义的范围决定的。另一方面,堆分配的数据由。NET 核心垃圾收集器,它的生命周期由许多因素决定,这些因素将在第九章中讨论。

从功能上来说,System.ValueType的唯一目的是覆盖由System.Object定义的虚拟方法,以使用基于值和基于引用的语义。您可能知道,重写是更改基类中定义的虚(或者可能是抽象)方法的实现的过程。ValueType的基类是System.Object。实际上,System.ValueType定义的实例方法和System.Object的是一样的。

// Structures and enumerations implicitly extend System.ValueType.
public abstract class ValueType : object
{
  public virtual bool Equals(object obj);
  public virtual int GetHashCode();
  public Type GetType();
  public virtual string ToString();
}

假设值类型使用基于值的语义,结构(包括所有数字数据类型[ intfloat ],以及任何enum或结构)的生命周期是可预测的。当一个结构变量超出定义范围时,它会被立即从内存中删除。

// Local structures are popped off
// the stack when a method returns.
static void LocalValueTypes()
{
  // Recall! "int" is really a System.Int32 structure.
  int i = 0;

  // Recall! Point is a structure type.
  Point p = new Point();
} // "i" and "p" popped off the stack here!

使用值类型、引用类型和赋值运算符

当您将一种值类型分配给另一种值类型时,将获得字段数据的逐个成员的副本。对于像System.Int32这样的简单数据类型,唯一要复制的成员是数值。然而,在您的Point中,XY的值被复制到新的结构变量中。举例来说,创建一个名为 FunWithValueAndReferenceTypes 的新控制台应用项目,然后将之前的Point定义复制到新的名称空间中。接下来,将以下局部函数添加到顶级语句中:

// Assigning two intrinsic value types results in
// two independent variables on the stack.
static void ValueTypeAssignment()
{
  Console.WriteLine("Assigning value types\n");

  Point p1 = new Point(10, 10);
  Point p2 = p1;

  // Print both points.
  p1.Display();
  p2.Display();

  // Change p1.X and print again. p2.X is not changed.
  p1.X = 100;
  Console.WriteLine("\n=> Changed p1.X\n");
  p1.Display();
  p2.Display();
}

这里,您已经创建了一个类型为Point(名为p1)的变量,然后将它分配给另一个Point ( p2)。因为Point是一个值类型,所以你在堆栈上有两个Point类型的副本,每个都可以被独立操作。因此,当您更改p1.X的值时,p2.X的值不受影响。

Assigning value types

X = 10, Y = 10
X = 10, Y = 10
=> Changed p1.X
X = 100, Y = 10
X = 10, Y = 10

与值类型形成鲜明对比的是,当您将赋值操作符应用于引用类型(意味着所有类实例)时,您是在内存中重定向引用变量所指向的内容。举例来说,创建一个名为PointRef的新类类型,它具有与Point结构相同的成员,除了重命名构造函数以匹配类名。

// Classes are always reference types.
class PointRef
{
  // Same members as the Point structure...
  // Be sure to change your constructor name to PointRef!
  public PointRef(int xPos, int yPos)
  {
    X = xPos;
    Y = yPos;
  }
}

现在,在下面的新方法中使用您的PointRef类型。注意,除了使用PointRef类,而不是Point结构,代码与ValueTypeAssignment()方法相同。

static void ReferenceTypeAssignment()
{
  Console.WriteLine("Assigning reference types\n");
  PointRef p1 = new PointRef(10, 10);
  PointRef p2 = p1;

  // Print both point refs.
  p1.Display();
  p2.Display();

  // Change p1.X and print again.
  p1.X = 100;
  Console.WriteLine("\n=> Changed p1.X\n");
  p1.Display();
  p2.Display();
}

在这种情况下,有两个引用指向托管堆上的同一个对象。因此,当您使用p1参考改变X的值时,p2.X报告相同的值。假设您已经调用了这个新方法,您的输出应该如下所示:

Assigning reference types

X = 10, Y = 10
X = 10, Y = 10
=> Changed p1.X
X = 100, Y = 10
X = 100, Y = 10

使用包含引用类型的值类型

现在,您对值类型和引用类型之间的基本区别有了更好的理解,让我们来看一个更复杂的例子。假设您有下面的引用(类)类型,它维护一个可以使用自定义构造函数设置的信息性string:

class ShapeInfo
{
  public string InfoString;
  public ShapeInfo(string info)
  {
    InfoString = info;
  }
}

现在假设您想在名为Rectangle的值类型中包含这个类类型的变量。为了允许调用者设置内部ShapeInfo成员变量的值,您还提供了一个定制的构造函数。以下是Rectangle类型的完整定义:

struct Rectangle
{
  // The Rectangle structure contains a reference type member.
  public ShapeInfo RectInfo;

  public int RectTop, RectLeft, RectBottom, RectRight;

  public Rectangle(string info, int top, int left, int bottom, int right)
  {
    RectInfo = new ShapeInfo(info);
    RectTop = top; RectBottom = bottom;
    RectLeft = left; RectRight = right;
  }

  public void Display()
  {
    Console.WriteLine("String = {0}, Top = {1}, Bottom = {2}, " +
      "Left = {3}, Right = {4}",
      RectInfo.InfoString, RectTop, RectBottom, RectLeft, RectRight);
  }
}

此时,您已经在值类型中包含了一个引用类型。这个百万美元的问题现在变成了“如果你将一个Rectangle变量赋给另一个变量会发生什么?”给定你已经知道的关于值类型的知识,你假设整数数据(它确实是一个结构,System.Int32)应该是每个Rectangle变量的独立实体是正确的。但是内部引用类型呢?对象的状态会被完全复制,还是对该对象的引用会被复制?要回答这个问题,请定义以下方法并调用它:

static void ValueTypeContainingRefType()
{
  // Create the first Rectangle.
  Console.WriteLine("-> Creating r1");
  Rectangle r1 = new Rectangle("First Rect", 10, 10, 50, 50);

  // Now assign a new Rectangle to r1.
  Console.WriteLine("-> Assigning r2 to r1");
  Rectangle r2 = r1;

  // Change some values of r2.
  Console.WriteLine("-> Changing values of r2");
  r2.RectInfo.InfoString = "This is new info!";
  r2.RectBottom = 4444;

  // Print values of both rectangles.
  r1.Display();
  r2.Display();
}

输出如下所示:

-> Creating r1

-> Assigning r2 to r1
-> Changing values of r2
String = This is new info!, Top = 10, Bottom = 50, Left = 10, Right = 50
String = This is new info!, Top = 10, Bottom = 4444, Left = 10, Right = 50

如您所见,当您使用r2引用更改信息字符串的值时,r1引用显示相同的值。默认情况下,当值类型包含其他引用类型时,赋值会产生引用的副本。这样,你就有了两个独立的结构,每个结构都包含一个指向内存中同一个对象的引用(即浅拷贝)。当你想要执行深度复制时,其中内部引用的状态被完全复制到一个新的对象中,一种方法是实现ICloneable接口(就像你在第八章中将要做的)。

通过值传递引用类型

正如本章前面所述,引用类型或值类型可以作为参数传递给方法。然而,通过引用传递引用类型(例如,类)与通过值传递有很大不同。为了理解其中的区别,假设您在一个名为 FunWithRefTypeValTypeParams 的新控制台应用项目中定义了一个简单的Person类,定义如下:

class Person
{
  public string personName;
  public int personAge;

  // Constructors.
  public Person(string name, int age)
  {
    personName = name;
    personAge = age;
  }
  public Person(){}

  public void Display()
  {
    Console.WriteLine("Name: {0}, Age: {1}", personName, personAge);
  }
}

现在,如果您创建一个方法,允许调用者通过值发送Person对象(注意缺少参数修饰符,如outref)会怎么样?

static void SendAPersonByValue(Person p)
{
  // Change the age of "p"?
  p.personAge = 99;

  // Will the caller see this reassignment?
  p = new Person("Nikki", 99);
}

注意SendAPersonByValue()方法如何试图将传入的Person引用重新分配给新的Person对象,以及更改一些状态数据。现在让我们使用下面的代码来测试这个方法:

// Passing ref-types by value.
Console.WriteLine("***** Passing Person object by value *****");
Person fred = new Person("Fred", 12);
Console.WriteLine("\nBefore by value call, Person is:");
fred.Display();

SendAPersonByValue(fred);
Console.WriteLine("\nAfter by value call, Person is:");
fred.Display();
Console.ReadLine();

以下是该调用的输出:

***** Passing Person object by value *****

Before by value call, Person is:
Name: Fred, Age: 12

After by value call, Person is:
Name: Fred, Age: 99

如您所见,personAge的值已经被修改。既然您已经理解了引用类型的工作方式,前面讨论的这种行为应该更有意义。假设您能够更改传入的Person的状态,那么复制了什么呢?答案是:调用方对象的引用的副本。因此,当SendAPersonByValue()方法与调用者指向同一个对象时,就有可能改变对象的状态数据。不可能的是重新分配引用所指向的

通过引用传递引用类型

现在假设您有一个SendAPersonByReference()方法,它通过引用传递一个引用类型(注意ref参数修饰符)。

static void SendAPersonByReference(ref Person p)
{
  // Change some data of "p".
  p.personAge = 555;

  // "p" is now pointing to a new object on the heap!
  p = new Person("Nikki", 999);
}

如您所料,这使得被调用方能够完全灵活地操作传入参数。被调用者不仅可以改变对象的状态,而且如果它愿意,它还可以将引用重新分配给一个新的Person对象。现在思考下面更新的代码:

// Passing ref-types by ref.
Console.WriteLine("***** Passing Person object by reference *****");
...

Person mel = new Person("Mel", 23);
Console.WriteLine("Before by ref call, Person is:");
mel.Display();

SendAPersonByReference(ref mel);
Console.WriteLine("After by ref call, Person is:");
mel.Display();
Console.ReadLine();

请注意以下输出:

***** Passing Person object by reference *****

Before by ref call, Person is:
Name: Mel, Age: 23
After by ref call, Person is:
Name: Nikki, Age: 999

如您所见,名为Mel的对象在调用后作为名为Nikki的对象返回,因为该方法能够改变传入引用在内存中指向的内容。传递引用类型时要记住的黄金法则如下:

  • 如果引用类型是通过引用传递的,则被调用方可以更改对象的状态数据的值,以及它所引用的对象。

  • 如果引用类型是通过值传递的,被调用者可以改变对象的状态数据的值,但是不能它所引用的对象。

关于值类型和引用类型的最终细节

为了总结这个主题,考虑表 4-4 中的信息,它总结了值类型和引用类型之间的核心区别。

表 4-4。

值类型和引用类型比较

|

有趣的问题

|

值类型

|

参考类型

对象被分配到哪里? 在堆栈上分配。 在托管堆上分配。
变量是如何表示的? 值类型变量是本地副本。 引用类型变量指向分配的实例所占用的内存。
基本类型是什么? 隐式扩展System.ValueType 可以从任何其他类型派生(除了System.ValueType),如果那个类型不是“密封的”(更多细节在第六章)。
这个类型可以作为其他类型的基础吗? 不可以。值类型总是密封的,不能从。 是的。如果该类型不是密封的,它可能充当其他类型的基。
默认的参数传递行为是什么? 变量通过值传递(即,变量的副本被传递到被调用的函数中)。 对于引用类型,引用是按值复制的。
这个类型可以覆盖System.Object.Finalize()吗? 号码 是的,间接的(更多细节在第九章)。
我可以为这种类型定义构造函数吗? 是的,但是默认构造函数是保留的(即,您的自定义构造函数必须都有参数)。 但是当然!
这种类型的变量什么时候消亡? 当它们超出定义范围时。 当对象被垃圾收集时(参见第九章)。

尽管存在差异,值类型和引用类型都可以实现接口,并且可以支持任意数量的字段、方法、重载运算符、常量、属性和事件。

了解 C# 可空类型

让我们使用名为 FunWithNullableValueTypes 的控制台应用项目来检查可空数据类型的角色。众所周知,C# 数据类型有一个固定的范围,并且在System名称空间中被表示为一个类型。例如,可以从集合{true, false}中为System.Boolean数据类型赋值。现在,回想一下所有的数字数据类型(以及Boolean数据类型)都是值类型。值类型永远不能被赋予null的值,因为它被用来建立一个空的对象引用。

// Compiler errors!
// Value types cannot be set to null!
bool myBool = null;
int myInt = null;

C# 支持可空数据类型的概念。简单地说,可空类型可以表示其基础类型的所有值,加上值null。因此,如果你声明一个可空的bool,它可以从集合{true, false, null}中被赋值。这在处理关系数据库时非常有用,因为在数据库表中经常会遇到未定义的列。如果没有可空数据类型的概念,C# 中就没有方便的方式来表示没有值的数字数据点。

为了定义可空变量类型,问号符号(?)作为基础数据类型的后缀。在 C# 8.0 之前,这种语法只有在应用于值类型时才是合法的(在下一节“可空引用类型”中有更多的介绍)。像不可空变量一样,在使用局部可空变量之前,必须给它们分配一个初始值。

static void LocalNullableVariables()
{
  // Define some local nullable variables.
  int? nullableInt = 10;
  double? nullableDouble = 3.14;
  bool? nullableBool = null;
  char? nullableChar = 'a';
  int?[] arrayOfNullableInts = new int?[10];
}

使用可空值类型

在 C# 中,?后缀符号是创建通用System.Nullable<T>结构类型实例的简写。它还用于创建可空的引用类型(在下一节中讨论),尽管行为有点不同。虽然在第十章之前你不会检查泛型,但是理解System.Nullable<T>类型提供了一组所有可空类型都可以利用的成员是很重要的。

例如,您可以使用HasValue属性或!=操作符以编程方式发现可空变量是否确实被赋予了一个null值。可空类型的赋值可以直接获得,也可以通过Value属性获得。事实上,鉴于?后缀只是使用Nullable<T>的简写,您可以如下实现您的LocalNullableVariables()方法:

static void LocalNullableVariablesUsingNullable()
{
  // Define some local nullable types using Nullable<T>.
  Nullable<int> nullableInt = 10;
  Nullable<double> nullableDouble = 3.14;
  Nullable<bool> nullableBool = null;
  Nullable<char> nullableChar = 'a';
  Nullable<int>[] arrayOfNullableInts = new Nullable<int>[10];
}

如上所述,当您与数据库交互时,可空数据类型可能特别有用,因为数据表中的列可能故意为空(例如,未定义)。为了说明,假设下面的类,它模拟了访问一个数据库的过程,该数据库的表包含两个可能是null的列。注意,GetIntFromDatabase()方法没有给可空整数成员变量赋值,而GetBoolFromDatabase()bool?成员赋值。

class DatabaseReader
{
  // Nullable data field.
  public int? numericValue = null;
  public bool? boolValue = true;

  // Note the nullable return type.
  public int? GetIntFromDatabase()
  { return numericValue; }

  // Note the nullable return type.
  public bool? GetBoolFromDatabase()
  { return boolValue; }
}

现在,检查下面的代码,该代码调用了DatabaseReader类的每个成员,并使用HasValueValue成员以及 C# 相等运算符(确切地说,不等于)发现了分配的值:

Console.WriteLine("***** Fun with Nullable Value Types *****\n");
DatabaseReader dr = new DatabaseReader();

// Get int from "database".
int? i = dr.GetIntFromDatabase();
if (i.HasValue)
{
  Console.WriteLine("Value of 'i' is: {0}", i.Value);
}
else
{
  Console.WriteLine("Value of 'i' is undefined.");
}
// Get bool from "database".
bool? b = dr.GetBoolFromDatabase();
if (b != null)
{
  Console.WriteLine("Value of 'b' is: {0}", b.Value);
}
else
{
  Console.WriteLine("Value of 'b' is undefined.");
}
Console.ReadLine();

使用可空引用类型(新 8.0)

C# 8 增加的一个重要特性是支持可空引用类型。事实上,这种变化是如此的显著。无法更新. NET Framework 来支持这项新功能。因此,我们决定在。NET Core 3.0 和更高版本,以及默认情况下禁用可空引用类型支持的决定。在中创建新项目时。NET Core 3.0/3.1 或。NET 5 中,引用类型的工作方式与它们在 C# 7 中的工作方式相同。这是为了防止破坏前 C# 8 生态系统中存在的数十亿行代码。开发人员必须选择在其应用中启用可空引用类型。

可空引用类型遵循许多与可空值类型相同的规则。不可空的引用类型必须在初始化时被赋予一个非空值,并且以后不能被更改为空值。可空引用类型可以是空的,但是在第一次使用之前仍然必须被赋值(或者是某个东西的实际实例,或者是空的值)。

可空引用类型使用相同的符号(?)来表示它们是可空的。然而,这并不是使用System.Nullable<T>的简写,因为只有值类型可以用来代替T。提醒一下,泛型和约束将在第十章中介绍。

选择可空引用类型

对可空引用类型的支持是通过设置可空上下文来控制的。这可以大到整个项目(通过更新项目文件),也可以小到几行代码(通过使用编译器指令)。还可以设置两种上下文:

  • 可空注释上下文:为可空引用类型启用/禁用可空注释(?)。

  • 可空警告上下文:启用/禁用可空引用类型的编译器警告。

要查看这些操作,请创建一个名为 FunWithNullableReferenceTypes 的新控制台应用。打开项目文件(如果使用的是 Visual Studio,请在解决方案资源管理器中双击项目名称,或者右击项目名称并选择“编辑项目文件”)。通过添加<Nullable>节点更新项目文件以支持可空引用类型(所有可用选项在表 4-5 中显示)。

表 4-5。

项目文件中可空的值

|

价值

|

生命的意义

使能够 启用可为空的批注,并启用可为空的警告。
警告信息 可空注释被禁用,可空警告被启用。
Annotations 启用可空注释,禁用可空警告。
Disable 可为空的注释被禁用,可为空的警告也被禁用。
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

<Nullable>元素影响整个项目。为了控制项目的较小部分,使用表 4-6 中所示的编译器指令。

表 4-6。

#nullable 编译器指令的值

|

价值

|

生命的意义

使能够 启用注释,并启用警告。
使残废 注释被禁用,警告也被禁用。
Restore 将所有设置恢复为项目设置。
disable warnings 警告被禁用,注释不受影响。
enable warnings 警告已启用,注释不受影响。
restore warnings 警告重置为项目设置;注释不受影响。
disable annotations 注释被禁用,警告不受影响。
enable annotations 批注已启用,警告不受影响。
restore annotations 批注被重置为项目设置;警告不受影响。

可空引用类型的作用

很大程度上是因为这一变化的重要性,可空类型只有在使用不当时才会引发错误。将以下类添加到Program.cs文件中:

public class TestClass
{
  public string Name { get; set; }
  public int Age { get; set; }
}

如你所见,这只是一个普通的类。当您在代码中使用这个类时,就会出现可空性。以下列声明为例:

string? nullableString = null;
TestClass? myNullableClass = null;

项目文件设置使整个项目成为可空的上下文。可空上下文允许stringTestClass类型的声明使用可空注释(?)。由于在可空的上下文中将 null 赋值给不可空的类型,下面的代码行将生成一条警告(CS8600):

//Warning CS8600 Converting null literal or possible null value to non-nullable type
TestClass myNonNullableClass = myNullableClass;

为了更好地控制可空上下文在项目中的位置,可以使用编译器指令(如前所述)来启用或禁用上下文。下面的代码关闭可空上下文(在项目级别设置),然后通过恢复项目设置重新启用它:

#nullable disable
TestClass anotherNullableClass = null;
//Warning CS8632 The annotation for nullable reference types
//should only be used in code within a '#nullable' annotations
TestClass? badDefinition = null;
//Warning CS8632 The annotation for nullable reference types
//should only be used in code within a '#nullable' annotations
string? anotherNullableString = null;
#nullable restore

最后要注意的是,可空引用类型没有HasValueValue属性,因为它们是由System.Nullable<T>提供的。

迁移注意事项

将代码从 C# 7 迁移到 C# 8 或 C# 9 时,如果希望利用可空的引用类型,可以结合使用项目设置和编译器指令来处理代码。一种常见的做法是从启用警告和禁用整个项目的可空注释开始。然后,在清理代码区域时,使用编译器指令逐渐启用注释。

对可空类型进行操作

C# 提供了几个运算符来处理可空类型。接下来的会话编写了空合并操作符、空合并赋值操作符和空条件操作符。对于这些示例,请回到 FunWithNullableValueTypes 项目。

零合并算子

下一个需要注意的方面是,任何可能有一个null值的变量都可以使用 C# ??操作符,它的正式名称是空合并操作符。如果检索到的值实际上是null,这个操作符允许您将一个值赋给一个可空类型。对于这个例子,假设如果从GetIntFromDatabase()返回的值是null,你想将一个局部可空整数赋给 100(当然,这个方法被编程为总是返回null,但是我相信你已经明白了大概的意思)。移回 NullableValueTypes 项目(并将其设置为启动项目),并输入以下代码:

//omitted for brevity
Console.WriteLine("***** Fun with Nullable Data *****\n");
DatabaseReader dr = new DatabaseReader();

// If the value from GetIntFromDatabase() is null,
// assign local variable to 100.
int myData = dr.GetIntFromDatabase() ?? 100;
Console.WriteLine("Value of myData: {0}", myData);
Console.ReadLine();

使用??操作符的好处是它提供了传统if / else条件的一个更紧凑的版本。但是,如果您愿意,您可以编写以下功能等效的代码,以确保如果一个值作为null返回,它将确实被设置为值 100:

// Longhand notation not using ?? syntax.
int? moreData = dr.GetIntFromDatabase();
if (!moreData.HasValue)
{
  moreData = 100;
}
Console.WriteLine("Value of moreData: {0}", moreData);

零合并赋值运算符(新 8.0)

基于零合并操作符,C# 8 引入了零合并赋值操作符 ( ??=)。仅当左侧为空时,该运算符才将左侧分配给右侧。例如,输入以下代码:

//Null-coalescing assignment operator
int? nullableInt = null;
nullableInt ??= 12;
nullableInt ??= 14;
Console.WriteLine(nullableInt);

nullableInt变量被初始化为null。下一行将值 12 赋给变量,因为左边确实是null。下一行没有没有给变量赋值 14,因为它不是null

空条件运算符

当你写软件时,通常会检查输入参数,这些参数是从类型成员(方法、属性、索引器)返回的值,对照值null。例如,让我们假设您有一个将字符串数组作为单个参数的方法。为了安全起见,您可能想在继续之前测试一下null。这样,如果数组为空,就不会出现运行时错误。以下是执行这种检查的传统方式:

static void TesterMethod(string[] args)
{
  // We should check for null before accessing the array data!
  if (args != null)
  {
    Console.WriteLine($"You sent me {args.Length} arguments.");
  }
}

这里,您使用一个条件作用域来确保如果数组是null,那么string数组的Length属性将不会被访问。如果调用方未能生成数据数组并像这样调用您的方法,您仍然是安全的,不会触发运行时错误:

TesterMethod(null);

C# 包含了null条件操作符标记(一个放在变量类型之后、访问操作符之前的问号)来简化前面的错误检查。现在,您可以编写以下代码,而不是显式地构建一个条件语句来检查null:

static void TesterMethod(string[] args)
{
  // We should check for null before accessing the array data!
  Console.WriteLine($"You sent me {args?.Length} arguments.");
}

在这种情况下,您没有使用条件语句。更确切地说,您是直接在string数组变量后面加上了?操作符的后缀。如果变量是null,它对Length属性的调用将不会抛出运行时错误。如果您想打印一个实际值,您可以利用零合并操作符来分配一个默认值,如下所示:

Console.WriteLine($"You sent me {args?.Length ?? 0} arguments.");

在一些额外的编码领域,C# 6.0 null条件操作符将会非常方便,尤其是在处理委托和事件的时候。这些主题将在本书后面讨论(见第十二章,你将会看到更多的例子。

了解元组(新的/更新的 7.0)

为了总结这一章,让我们使用一个名为 FunWithTuples 的控制台应用项目来研究元组的作用。正如本章前面提到的,使用out参数的一种方法是从一个方法调用中检索多个值。另一种方法是使用称为元组的轻量级结构。

元组是包含多个字段的轻量级数据结构。它们被添加到 C# 6 语言中,但是以一种极其有限的方式。C# 6 实现还有一个潜在的严重问题:每个字段都被实现为一个引用类型,这可能会产生内存和/或性能问题(来自装箱/取消装箱)。

在 C# 7 中,元组使用新的ValueTuple数据类型而不是引用类型,潜在地节省了大量内存。ValueTuple数据类型根据元组的属性数量创建不同的结构。C# 7 中增加的一个额外特性是元组中的每个属性都可以被赋予一个特定的名称(就像变量一样),这极大地增强了可用性。

对于元组,有两个重要的考虑因素:

  • 这些字段未经验证。

  • 您不能定义自己的方法。

它们实际上被设计成一种轻量级的数据传输机制。

元组入门

理论够了。我们写点代码吧!要创建元组,只需将要分配给元组的值括在括号中,如下所示:

("a", 5, "c")

请注意,它们不必都是相同的数据类型。括号构造也用于将元组赋给变量(或者您可以使用var关键字,编译器将为您分配数据类型)。为了将前面的例子赋给一个变量,下面两行实现了同样的事情。values变量将是一个元组,中间夹着两个string属性和一个int属性。

(string, int, string) values = ("a", 5, "c");
var values = ("a", 5, "c");

默认情况下,编译器给每个属性命名为ItemX,其中X表示元组中从 1 开始的位置。对于前面的例子,属性名是Item1Item2Item3。访问它们的方式如下:

Console.WriteLine($"First item: {values.Item1}");
Console.WriteLine($"Second item: {values.Item2}");
Console.WriteLine($"Third item: {values.Item3}");

特定的名称也可以添加到语句右侧或左侧元组中的每个属性。虽然在语句的两边都赋值不是编译器错误,但是如果这样做,右边的名字将被忽略,只使用左边的名字。下面两行代码显示了设置左边和右边的名称以达到相同的目的:

(string FirstLetter, int TheNumber, string SecondLetter) valuesWithNames = ("a", 5, "c");
var valuesWithNames2 = (FirstLetter: "a", TheNumber: 5, SecondLetter: "c");

现在可以使用字段名和ItemX符号来访问元组的属性,如下面的代码所示:

Console.WriteLine($"First item: {valuesWithNames.FirstLetter}");
Console.WriteLine($"Second item: {valuesWithNames.TheNumber}");
Console.WriteLine($"Third item: {valuesWithNames.SecondLetter}");
//Using the item notation still works!
Console.WriteLine($"First item: {valuesWithNames.Item1}");
Console.WriteLine($"Second item: {valuesWithNames.Item2}");
Console.WriteLine($"Third item: {valuesWithNames.Item3}");

注意,在右边设置名称时,必须使用关键字var来声明变量。专门设置数据类型(即使没有自定义名称)会触发编译器使用左侧,使用ItemX符号分配属性,并忽略右侧设置的任何自定义名称。以下两个例子忽略了Custom1Custom2的名字:

(int, int) example = (Custom1:5, Custom2:7);
(int Field1, int Field2) example = (Custom1:5, Custom2:7);

同样重要的是要指出,自定义字段名称只存在于编译时,在运行时使用反射检查元组时不可用(反射在第十七章中讨论)。

元组也可以作为元组嵌套在元组内部。因为元组中的每个属性都是一种数据类型,而元组也是一种数据类型,所以下面的代码是完全合法的:

Console.WriteLine("=> Nested Tuples");
var nt = (5, 4, ("a", "b"));

使用推断变量名(更新 7.1)

C# 7.1 中对元组的更新是 C# 能够推断元组的变量名,如下所示:

Console.WriteLine("=> Inferred Tuple Names");
var foo = new {Prop1 = "first", Prop2 = "second"};
var bar = (foo.Prop1, foo.Prop2);
Console.WriteLine($"{bar.Prop1};{bar.Prop2}");

了解元组相等/不相等(新 7.3)

C# 7.1 中增加的一个特性是元组等式(==)和不等式(!=).在测试不相等性时,比较运算符将对元组内的数据类型执行隐式转换,包括比较可为空和不可为空的元组和/或属性。这意味着尽管int / long之间存在差异,但以下测试工作正常:

Console.WriteLine("=> Tuples Equality/Inequality");
// lifted conversions
var left = (a: 5, b: 10);
(int? a, int? b) nullableMembers = (5, 10);
Console.WriteLine(left == nullableMembers); // Also true
// converted type of left is (long, long)
(long a, long b) longTuple = (5, 10);
Console.WriteLine(left == longTuple); // Also true
// comparisons performed on (long, long) tuples
(long a, int b) longFirst = (5, 10);
(int a, long b) longSecond = (5, 10);
Console.WriteLine(longFirst == longSecond); // Also true

也可以比较包含元组的元组,但前提是它们具有相同的形状。您不能将一个包含三个int属性的元组与另一个包含两个int和一个元组的元组进行比较。

将元组理解为方法返回值

在本章的前面,out参数被用来从一个方法调用中返回多个值。还有其他方法可以做到这一点,比如创建一个专门用于返回值的类或结构。但是,如果这个类或结构只是用作一个方法的数据传输,那就是额外的工作和额外的代码,不需要开发。元组非常适合这个任务,是轻量级的,并且易于声明和使用。

这是out参数部分的一个例子。它返回三个值,但是需要三个参数作为调用代码的传输机制传入。

static void FillTheseValues(out int a, out string b, out bool c)
{
  a = 9;
  b = "Enjoy your string.";
  c = true;
}

通过使用 tuple,您可以删除参数,但仍然可以获得三个值。

static (int a,string b,bool c) FillTheseValues()
{
  return (9,"Enjoy your string.",true);
}

调用这个方法和调用任何其他方法一样简单。

var samples = FillTheseValues();
Console.WriteLine($"Int is: {samples.a}");
Console.WriteLine($"String is: {samples.b}");
Console.WriteLine($"Boolean is: {samples.c}");

也许一个更好的例子是将一个完整的名字分解成各个部分(名、中间名、姓)。以下代码接受一个全名,并返回一个包含不同部分的元组:

static (string first, string middle, string last) SplitNames(string fullName)
{
  //do what is needed to split the name apart
  return ("Philip", "F", "Japikse");
}

用元组理解丢弃

继续讨论SplitNames()的例子,假设您知道您只需要名和姓,而不关心中间的名字。通过为要返回的值提供变量名,并使用下划线(_)占位符填充不需要的值,可以像这样优化返回值:

var (first, _, last) = SplitNames("Philip F Japikse");
Console.WriteLine($"{first}:{last}");

元组的中间名值被丢弃。

了解元组模式匹配开关表达式(新 8.0)

既然你已经对元组有了透彻的理解,那么是时候用第三章中的元组来重温一下switch表达式了。这又是一个例子:

//Switch expression with Tuples
static string RockPaperScissors(string first, string second)
{
  return (first, second) switch
  {
    ("rock", "paper") => "Paper wins.",
    ("rock", "scissors") => "Rock wins.",
    ("paper", "rock") => "Paper wins.",
    ("paper", "scissors") => "Scissors wins.",
    ("scissors", "rock") => "Rock wins.",
    ("scissors", "paper") => "Scissors wins.",
    (_, _) => "Tie.",
  };
}

在这个例子中,当这两个参数被传递给switch表达式时,它们被转换成一个元组。相关值在switch表达式中表示,任何其他情况由最终元组处理,该元组由两个丢弃组成。

也可以编写RockPaperScissors()方法签名来接受一个元组,如下所示:

static string RockPaperScissors(
  (string first, string second) value)
{
  return value switch
  {
    //omitted for brevity
  };
}

解构元组

解构是在分离出一个元组的属性以单独使用时给出的术语。就这么做了。但是这种模式还有一个有用的用途,那就是解构定制类型。

以本章前面使用的Point结构的较短版本为例。添加了一个名为Deconstruct()的新方法,以名为XPosYPos的元组的形式返回Point实例的各个属性。

struct Point
{
  // Fields of the structure.
  public int X;
  public int Y;

  // A custom constructor.
  public Point(int XPos, int YPos)
  {
    X = XPos;
    Y = YPos;
  }

  public (int XPos, int YPos) Deconstruct() => (X, Y);
}

注意新的Deconstruct()方法,在前面的代码清单中以粗体显示。这个方法可以被命名为任何名称,但是按照惯例,它通常被命名为Deconstruct()。这允许单个方法调用通过返回元组来获取结构的单个值。

Point p = new Point(7,5);
var pointValues = p.Deconstruct();
Console.WriteLine($"X is: {pointValues.XPos}");
Console.WriteLine($"Y is: {pointValues.YPos}");

用位置模式匹配解构元组(新 8.0)

当元组有一个可访问的Deconstruct()方法时,可以在基于元组的开关表达式中使用解构。以Point为例,下面的代码使用生成的元组,并将这些值用于每个表达式的when子句:

static string GetQuadrant1(Point p)
{
  return p.Deconstruct() switch
  {
    (0, 0) => "Origin",
    var (x, y) when x > 0 && y > 0 => "One",
    var (x, y) when x < 0 && y > 0 => "Two",
    var (x, y) when x < 0 && y < 0 => "Three",
    var (x, y) when x > 0 && y < 0 => "Four",
    var (_, _) => "Border",
  };
}

如果用两个out参数定义了Deconstruct()方法,那么switch表达式将自动解构该点。向Point添加另一个Deconstruct方法,如下所示:

public void Deconstruct(out int XPos, out int YPos)
  => (XPos,YPos)=(X, Y);

现在您可以更新(或添加一个新的)GetQuadrant()方法:

static string GetQuadrant2(Point p)

{
  return p switch
  {
    (0, 0) => "Origin",
    var (x, y) when x > 0 && y > 0 => "One",
    var (x, y) when x < 0 && y > 0 => "Two",
    var (x, y) when x < 0 && y < 0 => "Three",
    var (x, y) when x > 0 && y < 0 => "Four",
    var (_, _) => "Border",
  };
}

这种变化非常微妙(并以粗体突出显示)。在switch表达式中只使用了Point变量,而不是调用p.Deconstruct()

摘要

本章从对数组的研究开始。然后,我们讨论了允许您构建定制方法的 C# 关键字。回想一下,默认情况下,参数是通过值传递的;但是,如果用refout标记,您可以通过引用传递参数。您还了解了可选参数或命名参数的作用,以及如何定义和调用采用参数数组的方法。

在您研究了方法重载这一主题之后,本章的大部分讨论了有关枚举和结构如何在 C# 中定义以及如何在?NET 核心基本类库。在此过程中,您研究了关于值类型和引用类型的一些细节,包括当将它们作为参数传递给方法时它们如何响应,以及如何使用?????=操作符与可能是null的可空数据类型和变量(例如,引用类型变量和可空值类型变量)进行交互。

本章的最后一节研究了 C# 中一个期待已久的特性,元组。在理解了它们是什么以及它们是如何工作的之后,您使用它们从方法中返回多个值以及解构自定义类型。

在第五章,你将开始深入探讨面向对象开发的细节。

五、理解封装

在第三章 3 和第四章 4 中,您研究了许多对任何人来说都很常见的核心语法结构。您可能正在开发的. NET 核心应用。在这里,您将开始研究 C# 的面向对象能力。首要任务是检查构建定义良好的类类型的过程,这些类类型支持任意数量的构造函数。在你理解了定义类和分配对象的基础知识之后,本章的剩余部分将研究封装的作用。在这个过程中,您将学习如何定义类属性,并逐渐理解static关键字、对象初始化语法、只读字段、常量数据和分部类的细节。

C# 类类型简介

至于。NET 平台而言,最基本的编程结构之一是类类型。形式上,类是用户定义的类型,由字段数据(通常称为成员变量)和操作这些数据的成员(如构造函数、属性、方法、事件等)组成。).总的来说,这组字段数据代表了一个类实例的“状态”(也称为一个对象)。面向对象语言(如 C#)的强大之处在于,通过在一个统一的类定义中对数据和相关功能进行分组,您能够按照现实世界中的实体对您的软件进行建模。

首先,创建一个名为 SimpleClassExample 的新 C# 控制台应用项目。接下来,在您的项目中插入一个新的类文件(名为Car.cs)。在这个新文件中,添加以下名称空间和using语句:

using System;

namespace SimpleClassExample
{
}

Note

对于这些例子来说,定义名称空间是绝对必要的。然而,养成对所有代码使用名称空间的习惯是一个好习惯。第一章详细讨论了名称空间。

在 C# 中使用class关键字定义了一个类。下面是最简单的声明(确保将类声明添加到SimpleClassExample名称空间内):

class Car
{
}

在定义了一个类类型之后,您将需要考虑一组将用于表示其状态的成员变量。例如,您可能决定汽车维护一个int数据类型来表示当前速度,一个string数据类型来表示汽车的友好昵称。给定这些初始设计注释,如下更新你的Car类:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;
}

注意,这些成员变量是使用public访问修饰符声明的。一旦创建了这种类型的对象,就可以直接访问类的公共成员。回想一下术语对象用于描述使用new关键字创建的给定类类型的实例。

Note

一个类的字段数据应该很少(如果有的话)被定义为公共的。为了保持状态数据的完整性,更好的设计是将数据定义为私有的(或者可能是受保护的),并允许通过属性控制对数据的访问(如本章后面所示)。然而,为了使第一个例子尽可能简单,公共数据符合要求。

在定义了代表类状态的成员变量集之后,下一步设计就是建立对其行为进行建模的成员。对于这个例子,Car类将定义一个名为SpeedUp()的方法和另一个名为PrintState()的方法。更新您的类,如下所示:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;

// The functionality of the Car.
// Using the expression-bodied member syntax
// covered in Chapter 4
public void PrintState()
  => Console.WriteLine("{0} is going {1} MPH.", petName, currSpeed);

public void SpeedUp(int delta)
  => currSpeed += delta;
}

PrintState()或多或少是一个诊断函数,它将简单地把给定的Car对象的当前状态转储到命令窗口。SpeedUp()将增加Car物体的速度,增加量由输入的int参数指定。现在,用下面的代码更新Program.cs文件中的顶级语句:

Console.WriteLine("***** Fun with Class Types *****\n");

// Allocate and configure a Car object.
Car myCar = new Car();
myCar.petName = "Henry";
myCar.currSpeed = 10;

// Speed up the car a few times and print out the
// new state.
for (int i = 0; i <= 10; i++)
{
  myCar.SpeedUp(5);
  myCar.PrintState();
}
Console.ReadLine();

运行程序后,您将看到Car变量(myCar)在应用的整个生命周期中保持其当前状态,如以下输出所示:

***** Fun with Class Types *****
Henry is going 15 MPH.
Henry is going 20 MPH.
Henry is going 25 MPH.
Henry is going 30 MPH.
Henry is going 35 MPH.
Henry is going 40 MPH.
Henry is going 45 MPH.
Henry is going 50 MPH.
Henry is going 55 MPH.
Henry is going 60 MPH.
Henry is going 65 MPH.

用 new 关键字分配对象

如前面的代码示例所示,必须使用new关键字将对象分配到内存中。如果您不使用new关键字并试图在后续代码语句中使用您的类变量,您将收到一个编译器错误。例如,下面的顶级语句将不会编译:

Console.WriteLine("***** Fun with Class Types *****\n");
// Compiler error! Forgot to use 'new' to create object!
Car myCar;
myCar.petName = "Fred";

为了使用new关键字正确地创建一个对象,您可以在一行代码中定义和分配一个Car对象。

Console.WriteLine("***** Fun with Class Types *****\n");
Car myCar = new Car();
myCar.petName = "Fred";

或者,如果您想在单独的代码行上定义和分配类实例,可以按如下方式进行:

Console.WriteLine("***** Fun with Class Types *****\n");
Car myCar;
myCar = new Car();
myCar.petName = "Fred";

这里,第一个代码语句简单地声明了对一个待定的Car对象的引用。直到你给一个对象分配了一个引用,这个引用才指向内存中的一个有效对象。

无论如何,在这一点上,你有一个简单的类,它定义了几个数据点和一些基本操作。为了增强当前Car类的功能,您需要理解构造函数的作用。

理解构造函数

假设对象有状态(由对象的成员变量的值表示),程序员通常会希望在使用之前给对象的字段数据分配相关的值。目前,Car类要求在逐个字段的基础上分配petNamecurrSpeed字段。对于当前的示例,这不是太大的问题,因为您只有两个公共数据点。然而,一个类有几十个字段要处理的情况并不少见。显然,编写 20 条初始化语句来设置 20 个数据点是不可取的!

幸运的是,C# 支持使用构造函数,这允许在创建时建立对象的状态。构造函数是一个类的特殊方法,当使用new关键字创建一个对象时,它被间接调用。然而,与“普通”方法不同的是,构造函数从来没有返回值(甚至没有void),并且总是与它们正在构造的类同名。

了解默认构造函数的角色

每个 C# 类都提供了一个“免费的”默认构造函数,如果需要的话,你可以重新定义它。根据定义,默认构造函数从不接受参数。将新对象分配到内存后,默认构造函数确保该类的所有字段数据都设置为适当的默认值(有关 C# 数据类型默认值的信息,参见第三章)。

如果您对这些默认赋值不满意,您可以重新定义默认构造函数来满足您的需要。举例来说,按如下方式更新 C# Car类:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;

  // A custom default constructor.
  public Car()
  {
    petName = "Chuck";
    currSpeed = 10;
  }
...
}

在这种情况下,你正在强迫所有的Car物体以 10 英里/小时的速度开始名为Chuck的生命。这样,您就可以创建一个设置为这些默认值的Car对象,如下所示:

Console.WriteLine("***** Fun with Class Types *****\n");

// Invoking the default constructor.
Car chuck = new Car();

// Prints "Chuck is going 10 MPH."
chuck.PrintState();
...

定义自定义构造函数

通常,类定义了默认构造函数之外的其他构造函数。这样,您就为对象用户提供了一种简单而一致的方法,可以在创建时直接初始化对象的状态。考虑下面对Car类的更新,它现在总共支持三个构造函数:

class Car
{
  // The 'state' of the Car.
  public string petName;
  public int currSpeed;

  // A custom default constructor.
  public Car()
  {
    petName = "Chuck";
    currSpeed = 10;
  }

  // Here, currSpeed will receive the
  // default value of an int (zero).
  public Car(string pn)
  {
    petName = pn;
  }

  // Let caller set the full state of the Car.
  public Car(string pn, int cs)
  {
    petName = pn;
    currSpeed = cs;
  }
...
}

请记住,使一个构造函数不同于另一个构造函数(在 C# 编译器看来)的是构造函数参数的数量和/或类型。回想一下第四章的,当你定义了一个同名的方法,但是参数的数量和类型不同,那么重载了这个方法。因此,Car类重载了构造函数,提供了多种在声明时创建对象的方法。在任何情况下,您现在都能够使用任何公共构造函数创建Car对象。这里有一个例子:

Console.WriteLine("***** Fun with Class Types *****\n");

// Make a Car called Chuck going 10 MPH.
Car chuck = new Car();
chuck.PrintState();

// Make a Car called Mary going 0 MPH.
Car mary = new Car("Mary");
mary.PrintState();

// Make a Car called Daisy going 75 MPH.
Car daisy = new Car("Daisy", 75);
daisy.PrintState();
...

作为表达式主体成员的构造函数(新 7.0)

C# 7 增加了表达式主体成员样式的额外用途。属性和索引器上的构造函数、终结器和get / set访问器现在接受新语法。考虑到这一点,前面的构造函数可以写成这样:

// Here, currSpeed will receive the
// default value of an int (zero).
public Car(string pn) => petName = pn;

第二个自定义构造函数不能转换为表达式,因为表达式主体成员必须是单行方法。

不带参数的构造函数(新 7.3)

从 C# 7.3 开始,构造函数(以及后面介绍的字段和属性初始化器)可以使用out参数。举个简单的例子,将下面的构造函数添加到Car类中:

public Car(string pn, int cs, out bool inDanger)
{
  petName = pn;
  currSpeed = cs;
  if (cs > 100)
  {
    inDanger = true;
  }
  else
  {
    inDanger = false;
  }
}

必须遵守 out 参数的所有规则。在这个例子中,inDanger参数必须在构造函数结束前赋值。

重新认识默认构造函数

正如您刚刚了解到的,所有的类都提供了一个免费的默认构造函数。在名为Motorcycle.cs的项目中插入一个新文件,并添加以下内容来定义一个Motorcycle类:

using System;
namespace SimpleClassExample
{
  class Motorcycle
  {
    public void PopAWheely()
    {
      Console.WriteLine("Yeeeeeee Haaaaaeewww!");
    }
  }
}

现在,您可以通过现成的默认构造函数创建一个Motorcycle类型的实例。

Console.WriteLine("***** Fun with Class Types *****\n");
Motorcycle mc = new Motorcycle();
mc.PopAWheely();
...

但是,一旦您定义了具有任意数量参数的自定义构造函数,默认构造函数就会从该类中自动移除,并且不再可用。请这样想:如果您没有定义自定义构造函数,C# 编译器会授予您一个默认值,允许对象用户分配您的类型的实例,并将字段数据设置为正确的默认值。然而,当你定义一个独特的构造函数时,编译器会认为你已经掌握了主动权。

因此,如果你想让对象用户用默认构造函数创建你的类型的实例,以及你的自定义构造函数,你必须显式重定义默认。为此,请理解在绝大多数情况下,类的默认构造函数的实现是有意为空的,因为您所需要的只是用默认值创建对象的能力。考虑下面对Motorcycle类的更新:

class Motorcycle
{
  public int driverIntensity;

  public void PopAWheely()
  {
    for (int i = 0; i <= driverIntensity; i++)
    {
      Console.WriteLine("Yeeeeeee Haaaaaeewww!");
    }
  }

  // Put back the default constructor, which will
  // set all data members to default values.
  public Motorcycle() {}

  // Our custom constructor.
  public Motorcycle(int intensity)
  {
    driverIntensity = intensity;
  }
}

Note

既然您已经更好地理解了类构造函数的作用,这里有一个很好的捷径。Visual Studio 和 Visual Studio 代码都提供了ctor代码片段。当您键入ctor并按 Tab 键时,IDE 将自动定义一个自定义的默认构造函数。然后,您可以添加自定义参数和实现逻辑。试试看。

理解 this 关键字的作用

C# 提供了一个this关键字,该关键字提供了对当前类实例的访问。this关键字的一个可能用途是解决范围不明确的问题,当一个传入的参数与该类的一个数据字段同名时就会出现这种情况。但是,您可以简单地采用不会导致这种模糊性的命名约定;为了说明this关键字的用法,用一个新的string字段(名为name)来更新您的Motorcycle类,以表示司机的姓名。接下来,添加一个名为SetDriverName()的方法,实现如下:

class Motorcycle
{
  public int driverIntensity;

  // New members to represent the name of the driver.
  public string name;
  public void SetDriverName(string name) => name = name;
...
}

虽然这段代码可以编译,但 C# 编译器会显示一条警告消息,通知您已经将一个变量重新赋给了它自己!举例来说,更新您的代码来调用SetDriverName(),然后打印出name字段的值。您可能会惊讶地发现name字段的值是一个空字符串!

// Make a Motorcycle with a rider named Tiny?
Motorcycle c = new Motorcycle(5);
c.SetDriverName("Tiny");
c.PopAWheely();
Console.WriteLine("Rider name is {0}", c.name); // Prints an empty name value!

问题是,SetDriverName()的实现将传入的参数赋回给它自己,因为编译器假设name引用的是当前在方法范围内的变量,而不是类范围内的name字段。要通知编译器您想要将当前对象的name数据字段设置为传入的name参数,只需使用this来解决这个歧义。

public void SetDriverName(string name) => this.name = name;

如果没有歧义,当访问数据字段或成员时,就不需要使用this关键字。例如,如果您将string数据成员从name重命名为driverName(这也需要您更新顶级语句),那么这种使用是可选的,因为不再有范围模糊性。

class Motorcycle
{
  public int driverIntensity;
  public string driverName;

  public void SetDriverName(string name)
  {
    // These two statements are functionally the same.
    driverName = name;
    this.driverName = name;
  }
...
}

尽管在明确的情况下使用this没有什么好处,但您可能仍然会发现这个关键字在实现类成员时很有用,因为当指定this时,Visual Studio 和 Visual Studio 代码等 ide 将启用智能感知。当您忘记了某个类成员的名称并希望快速回忆起其定义时,这很有帮助。

Note

常见的命名约定是以下划线开始私有(或内部)类级变量名称(例如,_driverName),这样 IntelliSense 会在列表顶部显示所有变量。在我们的小例子中,所有的字段都是公共的,所以这个命名约定不适用。在本书的其余部分,你会看到私有和内部变量以一个前导下划线命名。

使用此链接构造函数调用

关键字this的另一个用途是使用一种叫做构造函数链接的技术来设计一个类。当一个类定义了多个构造函数时,这种设计模式很有用。考虑到构造函数经常验证传入的参数以执行各种业务规则,在类的构造函数集中发现冗余的验证逻辑是很常见的。考虑以下更新的Motorcycle:

class Motorcycle
{
  public int driverIntensity;
  public string driverName;

  public Motorcycle() { }

  // Redundant constructor logic!
  public Motorcycle(int intensity)
  {
    if (intensity > 10)
    {
      intensity = 10;
    }
    driverIntensity = intensity;
  }

  public Motorcycle(int intensity, string name)
  {
    if (intensity > 10)
    {
      intensity = 10;
    }
    driverIntensity = intensity;
    driverName = name;
  }
...
}

在这里(也许是为了确保骑手的安全),每个建造者都确保强度等级不超过 10。虽然这很好,但是在两个构造函数中确实有多余的代码语句。这并不理想,因为如果您的规则改变(例如,如果强度不应该大于 5 而不是 10),您现在需要在多个位置更新代码。

改善当前情况的一个方法是在Motorcycle类中定义一个方法,该方法将验证传入的参数。如果这样做,每个构造函数都可以在进行字段赋值之前调用此方法。虽然这种方法确实允许您在业务规则发生变化时隔离需要更新的代码,但是您现在要处理以下冗余:

class Motorcycle
{
   public int driverIntensity;
   public string driverName;

   // Constructors.
   public Motorcycle() { }

   public Motorcycle(int intensity)
   {
     SetIntensity(intensity);
   }

   public Motorcycle(int intensity, string name)
   {
     SetIntensity(intensity);
     driverName = name;
   }

   public void SetIntensity(int intensity)
   {
     if (intensity > 10)
     {
       intensity = 10;
     }
     driverIntensity = intensity;
   }
...
}

一种更干净的方法是将采用最大数量参数的构造函数指定为“主构造函数”,并让它的实现执行所需的验证逻辑。其余的构造函数可以利用this关键字将传入的参数转发给主构造函数,并根据需要提供任何额外的参数。这样,你只需要担心维护整个类的单个构造函数,而其余的构造函数基本上都是空的。

下面是Motorcycle类的最后一次迭代(为了便于说明,增加了一个构造函数)。当链接构造函数时,注意this关键字是如何“悬挂”在构造函数声明之外(通过冒号操作符)的。

class Motorcycle
{
   public int driverIntensity;
   public string driverName;

   // Constructor chaining.
   public Motorcycle() {}
   public Motorcycle(int intensity)
     : this(intensity, "") {}
   public Motorcycle(string name)
     : this(0, name) {}

   // This is the 'master' constructor that does all the real work.
   public Motorcycle(int intensity, string name)
   {
     if (intensity > 10)
     {
       intensity = 10;
     }
     driverIntensity = intensity;
     driverName = name;
   }
...
}

理解使用this关键字来链接构造函数调用从来都不是强制性的。然而,当您使用这种技术时,您最终会得到一个更易维护、更简洁的类定义。同样,使用这种技术,您可以简化您的编程任务,因为真正的工作被委托给单个构造函数(通常是具有最多参数的构造函数),而其他构造函数只是“推卸责任”

Note

回想一下第四章的内容,C# 支持可选参数。如果在类构造函数中使用可选参数,可以用更少的代码获得与构造函数链接相同的好处。一会儿您将看到如何做到这一点。

观察构造函数流

最后要注意的是,一旦构造函数将参数传递给指定的主构造函数(并且该构造函数已经处理了数据),最初由调用者调用的构造函数将结束执行任何剩余的代码语句。为了澄清,用对Console.WriteLine()的适当调用来更新Motorcycle类的每个构造函数。

class Motorcycle
{
  public int driverIntensity;
  public string driverName;

  // Constructor chaining.
  public Motorcycle()
  {
    Console.WriteLine("In default ctor");
  }

  public Motorcycle(int intensity)
     : this(intensity, "")
  {
    Console.WriteLine("In ctor taking an int");
  }

  public Motorcycle(string name)
     : this(0, name)
  {
    Console.WriteLine("In ctor taking a string");
  }

  // This is the 'master' constructor that does all the real work.
  public Motorcycle(int intensity, string name)
  {
    Console.WriteLine("In master ctor ");
    if (intensity > 10)
    {
      intensity = 10;
    }
    driverIntensity = intensity;
    driverName = name;
  }
...
}

现在,确保您的顶级语句使用一个Motorcycle对象,如下所示:

Console.WriteLine("***** Fun with class Types *****\n");

// Make a Motorcycle.
Motorcycle c = new Motorcycle(5);
c.SetDriverName("Tiny");
c.PopAWheely();
Console.WriteLine("Rider name is {0}", c.driverName);
Console.ReadLine();

这样,思考一下前面代码的输出:

***** Fun with Motorcycles *****
In master ctor
In ctor taking an int
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Yeeeeeee Haaaaaeewww!
Rider name is Tiny

如您所见,构造函数逻辑流程如下:

  • 通过调用只需要一个int的构造函数来创建对象。

  • 此构造函数将提供的数据转发给主构造函数,并提供调用方未指定的任何附加启动参数。

  • 主构造函数将传入数据分配给对象的字段数据。

  • 控制返回到最初调用的构造函数,并执行任何剩余的代码语句。

使用构造函数链的好处是这种编程模式可以在任何版本的 C# 语言和。NET 平台。但是,如果你针对的是。NET 4.0 和更高版本中,通过使用可选参数作为传统构造函数链接的替代方法,可以进一步简化编程任务。

重新审视可选参数

在第四章中,你学习了可选参数和命名参数。回想一下,可选参数允许您为传入的参数定义提供的默认值。如果调用者对这些缺省值满意,他们不需要指定一个唯一的值;但是,他们这样做可能是为了给对象提供自定义数据。考虑下面的Motorcycle版本,它现在提供了许多使用单个构造函数定义来构造对象的方法:

class Motorcycle
{
  // Single constructor using optional args.
  public Motorcycle(int intensity = 0, string name = "")
  {
     if (intensity > 10)
     {
       intensity = 10;
     }
     driverIntensity = intensity;
     driverName = name;
  }
...
}

有了这个构造函数,您现在可以使用零个、一个或两个参数创建一个新的Motorcycle对象。回想一下,命名参数语法允许您跳过可接受的默认设置(参见第三章)。

static void MakeSomeBikes()
{
   // driverName = "", driverIntensity = 0
   Motorcycle m1 = new Motorcycle();
   Console.WriteLine("Name= {0}, Intensity= {1}",
     m1.driverName, m1.driverIntensity);

   // driverName = "Tiny", driverIntensity = 0
   Motorcycle m2 = new Motorcycle(name:"Tiny");
   Console.WriteLine("Name= {0}, Intensity= {1}",
     m2.driverName, m2.driverIntensity);

   // driverName = "", driverIntensity = 7
   Motorcycle m3 = new Motorcycle(7);
   Console.WriteLine("Name= {0}, Intensity= {1}",
     m3.driverName, m3.driverIntensity);
}

在任何情况下,此时您都能够用字段数据(即成员变量)和各种操作(如方法和构造函数)定义一个类。接下来,让我们正式确定关键字static的作用。

理解静态关键字

一个 C# 类可以定义任意数量的静态成员,它们是使用static关键字声明的。这样做时,必须从类级别直接调用相关成员,而不是从对象引用变量调用。为了说明区别,考虑你的好朋友System.Console。如您所见,您没有从对象级别调用WriteLine()方法,如下所示:

// Compiler error! WriteLine() is not an object level method!
Console c = new Console();
c.WriteLine("I can't be printed...");

相反,只需将类名作为静态WriteLine()成员的前缀。

// Correct! WriteLine() is a static method.
Console.WriteLine("Much better! Thanks...");

简而言之,静态成员是(被类设计者)认为非常普通的项,以至于在调用成员之前没有必要创建类的实例。虽然任何类都可以定义静态成员,但它们通常出现在实用程序类中。根据定义,实用程序类是一个不维护任何对象级状态的类,并且不是用new关键字创建的。更确切地说,一个实用程序类将所有功能公开为类级(也称为静态)成员。

例如,如果您要使用 Visual Studio 对象浏览器(通过“查看➤对象浏览器”菜单项)来查看System名称空间,您会看到ConsoleMathEnvironmentGC类的所有成员(以及其他成员)通过静态成员公开了它们的所有功能。这些只是在。NET 核心基本类库。

同样,请注意,静态成员不仅出现在实用程序类中;它们可以是任何类定义的一部分。请记住,静态成员将给定的项提升到类级别,而不是对象级别。正如您将在接下来的几节中看到的,static关键字可以应用于以下内容:

  • 一类数据

  • 类的方法

  • 类的属性

  • 建筑工人

  • 整个类定义

  • 与 C# using关键字一起使用

让我们看看我们的每个选项,从静态数据的概念开始。

Note

在本章的后面,您将在研究属性本身的同时研究静态属性的作用。

定义静态字段数据

大多数时候,在设计一个类时,您将数据定义为实例级数据,或者换句话说,定义为非静态数据。当您定义实例级数据时,您知道每次创建新对象时,该对象都会维护自己的独立数据副本。相反,当你定义一个类的静态数据时,该类的所有对象共享内存。

要了解区别,请创建一个名为 StaticDataAndMembers 的新控制台应用项目。现在,在您的项目中插入一个名为SavingsAccount.cs的文件,并在该文件中创建一个名为SavingsAccount的新类。首先定义一个实例级变量(为当前余额建模)和一个自定义构造函数来设置初始余额。

using System;
namespace StaticDataAndMembers
{
  // A simple savings account class.
  class SavingsAccount
  {
    // Instance-level data.
    public double currBalance;
    public SavingsAccount(double balance)
    {
      currBalance = balance;
    }
  }
}

创建SavingsAccount对象时,为每个对象分配currBalance字段的内存。因此,你可以创建五个不同的SavingsAccount物体,每个都有自己独特的平衡。此外,如果您更改一个帐户的余额,其他对象不会受到影响。

另一方面,静态数据只分配一次,在同一类类别的所有对象之间共享。向SavingsAccount类添加一个名为currInterestRate的静态变量,该变量被设置为默认值 0.04。

// A simple savings account class.
class SavingsAccount
{
   // A static point of data.
   public static double currInterestRate = 0.04;

   // Instance-level data.
   public double currBalance;

   public SavingsAccount(double balance)
   {
     currBalance = balance;
   }
}

在顶级语句中创建三个SavingsAccount实例,如下所示:

using System;
using StaticDataAndMembers;

  Console.WriteLine("***** Fun with Static Data *****\n");
  SavingsAccount s1 = new SavingsAccount(50);
  SavingsAccount s2 = new SavingsAccount(100);
  SavingsAccount s3 = new SavingsAccount(10000.75);
  Console.ReadLine();

内存中的数据分配如图 5-1 所示。

img/340876_10_En_5_Fig1_HTML.png

图 5-1。

静态数据只分配一次,在类的所有实例之间共享

这里的假设是所有的储蓄账户都应该有相同的利率。因为静态数据由同一类别的所有对象共享,所以如果您以任何方式对其进行更改,所有对象将在下次访问静态数据时“看到”新值,因为它们实际上都在查看相同的内存位置。要理解如何更改(或获取)静态数据,您需要考虑静态方法的作用。

定义静态方法

让我们更新SavingsAccount类来定义两个静态方法。第一个静态方法(GetInterestRate())将返回当前利率,而第二个静态方法(SetInterestRate())将允许您更改利率。

// A simple savings account class.
class SavingsAccount
{
  // Instance-level data.
  public double currBalance;

  // A static point of data.
  public static double currInterestRate = 0.04;

  public SavingsAccount(double balance)
  {
    currBalance = balance;
  }

  // Static members to get/set interest rate.
  public static void SetInterestRate(double newRate)
    => currInterestRate = newRate;

  public static double GetInterestRate()
    => currInterestRate;
}

现在,观察以下用法:

using System;
using StaticDataAndMembers;

Console.WriteLine("***** Fun with Static Data *****\n");
SavingsAccount s1 = new SavingsAccount(50);
SavingsAccount s2 = new SavingsAccount(100);

// Print the current interest rate.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());

// Make new object, this does NOT 'reset' the interest rate.
SavingsAccount s3 = new SavingsAccount(10000.75);
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());

Console.ReadLine();

上面代码的输出如下所示:

***** Fun with Static Data *****
Interest Rate is: 0.04
Interest Rate is: 0.04

如您所见,当您创建新的SavingsAccount类实例时,静态数据的值不会被重置,因为 CoreCLR 将静态数据一次性分配到内存中。此后,所有类型为SavingsAccount的对象对静态currInterestRate字段的相同值进行操作。

在设计任何 C# 类时,设计挑战之一是确定哪些数据应该定义为静态成员,哪些不应该。虽然没有严格的规则,但请记住,静态数据字段由该类型的所有对象共享。因此,如果你正在定义一个数据点,所有的对象应该在它们之间共享这个数据点,那么静态就是最好的方法。

考虑一下,如果利率变量是使用关键字static定义的而不是,会发生什么。这意味着每个SavingsAccount对象都有自己的currInterestRate字段副本。现在,假设您创建了 100 个SavingsAccount对象,并需要更改利率。这将需要您调用SetInterestRate()方法 100 次!显然,这不是一种建模“共享数据”的有用方法同样,当您有一个对该类别的所有对象都通用的值时,静态数据是完美的。

Note

静态成员在其实现中引用非静态成员是一个编译器错误。与此相关,在静态成员上使用关键字this是错误的,因为this暗示了一个对象!

定义静态构造函数

典型的构造函数用于在创建时设置对象的实例级数据的值。然而,如果您试图在一个典型的构造函数中为一个静态数据点赋值,会发生什么呢?您可能会惊讶地发现,每次创建新对象时,该值都会被重置。

举例来说,假设您已经如下更新了SavingsAccount类构造函数(还要注意,您不再以内联方式分配currInterestRate字段):

class SavingsAccount
{
  public double currBalance;
  public static double currInterestRate;

  // Notice that our constructor is setting
  // the static currInterestRate value.
  public SavingsAccount(double balance)
  {
    currInterestRate = 0.04; // This is static data!
    currBalance = balance;
  }
...
}

现在,假设您已经在顶级语句中编写了以下代码:

// Make an account.
SavingsAccount s1 = new SavingsAccount(50);

// Print the current interest rate.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());

// Try to change the interest rate via property.
SavingsAccount.SetInterestRate(0.08);

// Make a second account.
SavingsAccount s2 = new SavingsAccount(100);

// Should print 0.08...right??
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.GetInterestRate());
Console.ReadLine();

如果您执行了前面的代码,您会看到每次您创建一个新的SavingsAccount对象时,currInterestRate变量都会被重置,并且它总是被设置为0.04。显然,在普通的实例级构造函数中设置静态数据的值有点违背了整个目的。每次创建新对象时,类级别的数据都会被重置。设置静态字段的一种方法是使用成员初始化语法,就像您最初做的那样。

class SavingsAccount
{
  public double currBalance;

  // A static point of data.
  public static double currInterestRate = 0.04;
...
}

这种方法将确保静态字段只被分配一次,不管您创建了多少个对象。但是,如果需要在运行时获取静态数据的值,该怎么办呢?例如,在典型的银行应用中,利率变量的值将从数据库或外部文件中读取。执行这样的任务通常需要一个方法范围,比如构造函数来执行代码语句。

出于这个原因,C# 允许您定义一个静态构造函数,这允许您安全地设置静态数据的值。考虑对您的类进行以下更新:

class SavingsAccount
{
  public double currBalance;
  public static double currInterestRate;

  public SavingsAccount(double balance)
  {
    currBalance = balance;
  }

   // A static constructor!
   static SavingsAccount()
   {
     Console.WriteLine("In static ctor!");
     currInterestRate = 0.04;
   }
...
}

简单地说,静态构造函数是一种特殊的构造函数,当静态数据的值在编译时未知时(例如,您需要从外部文件读入值、从数据库读入值、生成随机数等等),它是初始化静态数据的值的理想位置。如果您要重新运行前面的代码,您会发现您期望的输出。请注意消息“在静态 ctor 中!”只打印一次,因为 CoreCLR 在第一次使用之前调用所有静态构造函数(并且不会为应用的那个实例再次调用它们)。

***** Fun with Static Data *****
In static ctor!
Interest Rate is: 0.04
Interest Rate is: 0.08

以下是一些关于静态构造函数的有趣之处:

  • 给定的类只能定义一个静态构造函数。换句话说,静态构造函数不能重载。

  • 静态构造函数不带访问修饰符,也不能带任何参数。

  • 静态构造函数只执行一次,不管创建了多少个该类型的对象。

  • 运行库在创建类的实例时或在访问调用方调用的第一个静态成员之前调用静态构造函数。

  • 静态构造函数在任何实例级构造函数之前执行。

考虑到这种修改,当您创建新的SavingsAccount对象时,静态数据的值被保留,因为静态成员在静态构造函数中只设置一次,而不管创建的对象数量。

定义静态类

也可以在类级别上直接应用static关键字。当一个类被定义为静态时,它不能用new关键字创建,它只能包含用static关键字标记的成员或数据字段。如果不是这样,您会收到编译器错误。

Note

回想一下,只公开静态功能的类(或结构)通常被称为实用程序类。当设计一个实用程序类时,将static关键字应用于类定义是一个好的实践。

乍一看,这似乎是一个相当奇怪的特性,因为一个不能被创建的类看起来并不那么有用。然而,如果你创建了一个只包含静态成员和/或常量数据的类,那么这个类就不需要被分配了!举例来说,创建一个名为TimeUtilClass的新类,并将其定义如下:

using System;
namespace StaticDataAndMembers
{
  // Static classes can only
  // contain static members!
  static class TimeUtilClass
  {
    public static void PrintTime()
      => Console.WriteLine(DateTime.Now.ToShortTimeString());

    public static void PrintDate()
      => Console.WriteLine(DateTime.Today.ToShortDateString());
  }
}

假设这个类是用static关键字定义的,那么您不能使用new关键字创建TimeUtilClass的实例。相反,所有功能都是从类级别公开的。若要测试该类,请将以下内容添加到顶级语句中:

// This is just fine.
TimeUtilClass.PrintDate();
TimeUtilClass.PrintTime();

// Compiler error! Can't create instance of static classes!
TimeUtilClass u = new TimeUtilClass ();

Console.ReadLine();

通过 C# using 关键字导入静态成员

C# 6 增加了对用关键字using导入静态成员的支持。举例来说,考虑当前定义实用程序类的 C# 文件。因为您正在调用Console类的WriteLine()方法,以及DateTime类的NowToday属性,所以您必须有一个用于System名称空间的using语句。由于这些类的成员都是静态的,您可以用下面的静态using指令来修改您的代码文件:

// Import the static members of Console and DateTime.
using static System.Console;
using static System.DateTime;

有了这些“静态导入”,代码文件的其余部分就能够直接使用ConsoleDateTime类的静态成员,而不需要给定义类加上前缀。例如,您可以像这样更新您的实用程序类:

static class TimeUtilClass
{
  public static void PrintTime()
    => WriteLine(Now.ToShortTimeString());

  public static void PrintDate()
    => WriteLine(Today.ToShortDateString());
}

通过导入静态成员来简化代码的一个更现实的例子可能涉及到一个 C# 类,它大量使用了System.Math类(或其他一些实用程序类)。因为这个类除了静态成员什么都没有,所以对于这个类型有一个静态的using语句,然后在你的代码文件中直接调用Math类的成员可能会更容易一些。

然而,请注意过度使用静态import语句可能会导致潜在的混乱。首先,如果多个类定义了一个WriteLine()方法怎么办?编译器很困惑,其他阅读你代码的人也一样。其次,除非开发人员熟悉。NET 核心代码库,他们可能不知道WriteLine()Console类的成员。除非人们注意到 C# 代码文件顶部的一组静态导入,否则他们可能不确定这个方法实际上是在哪里定义的。出于这些原因,我将在本文中限制静态using语句的使用。

在任何情况下,在本章的这一点上,您应该对定义包含构造函数、字段和各种静态(和非静态)成员的简单类类型感到舒适。现在您已经理解了类构造的基础,您可以正式研究面向对象编程的三大支柱了。

定义面向对象的支柱

所有面向对象的语言(C#、Java、C++、Visual Basic 等。)必须与这三个核心原则相抗衡,这三个原则通常被称为面向对象编程(OOP)的支柱:

  • 封装:这种语言是如何隐藏一个对象的内部实现细节并保持数据完整性的?

  • 继承:这种语言是如何促进代码重用的?

  • 多态性:这种语言是如何让你以类似的方式对待相关对象的?

在深入了解每个支柱的细节之前,理解它们的基本角色是很重要的。下面是对每个支柱的概述,我们将在本章的剩余部分和下一章中对其进行详细的分析。

理解封装的作用

OOP 的第一个支柱叫做封装。这一特性归结于该语言对对象用户隐藏不必要的实现细节的能力。例如,假设您正在使用一个名为DatabaseReader的类,它有两个主要方法,名为Open()Close()

// Assume this class encapsulates the details of opening and closing a database.
DatabaseReader dbReader = new DatabaseReader();
dbReader.Open(@"C:\AutoLot.mdf");

// Do something with data file and close the file.
dbReader.Close();

虚构的DatabaseReader类封装了定位、加载、操作和关闭数据文件的内部细节。程序员喜欢封装,因为 OOP 的这一支柱使得编码任务更加简单。不需要担心在幕后执行DatabaseReader类工作的众多代码行。您所做的就是创建一个实例并发送适当的消息(例如,“打开位于我的 c 盘上的名为AutoLot.mdf的文件”)。

与封装编程逻辑的概念密切相关的是数据保护的概念。理想情况下,应该使用privateinternalprotected关键字来指定对象的状态数据。这样,外界必须礼貌地询问,才能改变或获得底层价值。这是一件好事,因为公开声明的数据点很容易被破坏(最好是意外而不是故意的!).稍后您将正式检查封装的这一方面。

理解继承的作用

OOP 的下一个支柱,继承,归结为语言允许你基于现有的类定义构建新的类定义的能力。本质上,通过将核心功能继承到派生的子类(也称为子类)中,继承允许您扩展基类(或父类)的行为。图 5-2 显示了一个简单的例子。

img/340876_10_En_5_Fig2_HTML.jpg

图 5-2。

“是”的关系

你可以把图 5-2 中的图表理解为“一个六边形是一个物体的形状。”当你有通过这种继承形式相关的类时,你在类型之间建立了*“是-a”关系*。这种“是-a”关系被称为继承

在这里,您可以假设Shape定义了一些所有后代共有的成员(可能是一个代表绘制形状的颜色的值和其他代表高度和宽度的值)。鉴于Hexagon类扩展了Shape,它继承了ShapeObject定义的核心功能,并定义了自己的附加六边形相关细节(无论是什么)。

Note

在下面。NET/。NET 核心平台中,System.Object总是任何类层次结构中最顶层的父类,它为所有类型定义了一些通用功能(在第六章中有完整描述)。

在 OOP 世界中还有另一种形式的代码重用:包含/委托模型,也称为*“has-a”关系*或聚合。这种形式的重用不用于建立父子关系。相反,“has-a”关系允许一个类定义另一个类的成员变量,并间接地向对象用户公开其功能(如果需要的话)。

例如,假设您再次建模一辆汽车。你可能想表达这样的想法,一辆汽车“有一个”收音机。试图从一个Radio派生出一个Car类是不合逻辑的,反之亦然(一个Car是一个Radio?我觉得不是!).相反,你有两个独立的类一起工作,其中Car类创建并公开Radio的功能。

class Radio
{
  public void Power(bool turnOn)
  {
    Console.WriteLine("Radio on: {0}", turnOn);
  }
}

class Car
{
  // Car 'has-a' Radio.
  private Radio myRadio = new Radio();

  public void TurnOnRadio(bool onOff)
  {
    // Delegate call to inner object.
    myRadio.Power(onOff);
  }
}

注意,对象用户不知道Car类正在使用内部的Radio对象。

// Call is forwarded to Radio internally.
Car viper = new Car();
viper.TurnOnRadio(false);

理解多态性的作用

OOP 的最后一个支柱是多态性。这一特征体现了一种语言以相似的方式对待相关对象的能力。具体来说,这种面向对象语言的租户允许基类定义一组成员(正式术语为多态接口),这些成员对所有后代都可用。一个类的多态接口是使用任意数量的虚拟抽象成员构建的(参见第六章了解全部细节)。

简而言之,虚拟成员是基类中的一个成员,它定义了一个可以被派生类修改(或者更正式地说,覆盖)的默认实现。相反,抽象方法是基类中的成员,它不提供默认实现,但提供签名。当一个类从定义抽象方法的基类派生时,它必须被派生类型覆盖。在这两种情况下,当派生类型重写由基类定义的成员时,它们实际上是在重新定义它们如何响应同一请求。

为了预览多态性,让我们提供一些图 5-3 所示的形状层次背后的细节。假设Shape类已经定义了一个名为Draw()的没有参数的虚方法。考虑到每个形状都需要以一种独特的方式呈现自己,子类如HexagonCircle可以根据自己的喜好随意覆盖这个方法(见图 5-3 )。

img/340876_10_En_5_Fig3_HTML.jpg

图 5-3。

经典多态性

设计了多态接口后,您可以开始在代码中进行各种假设。例如,假设HexagonCircle从一个共同的父类(Shape)派生,那么Shape类型的数组可以包含从这个基类派生的任何东西。此外,假设Shape定义了所有派生类型的多态接口(本例中的Draw()方法),您可以假设数组中的每个成员都有这个功能。

考虑下面的代码,它指示一组从Shape派生的类型使用Draw()方法来呈现它们自己:

Shape[] myShapes = new Shape[3];
myShapes[0] = new Hexagon();
myShapes[1] = new Circle();
myShapes[2] = new Hexagon();

foreach (Shape s in myShapes)
{
  // Use the polymorphic interface!
  s.Draw();
}
Console.ReadLine();

这就结束了我们对 OOP 支柱的简要概述。既然你已经有了这个理论,本章的剩余部分将进一步探讨在 C# 中如何处理封装的细节,从访问修饰符开始。第六章将处理继承和多态的细节。

理解 C# 访问修饰符(更新 7.2)

使用封装时,您必须始终考虑类型的哪些方面对应用的各个部分是可见的。具体来说,类型(类、接口、结构、枚举和委托)及其成员(属性、方法、构造函数和字段)是使用特定的关键字定义的,以控制该项对应用的其他部分的“可见性”。虽然 C# 定义了许多关键字来控制访问,但它们在成功应用的地方(类型或成员)是不同的。表 5-1 记录了每个访问修饰符的作用及其可能应用的地方。

表 5-1。

C# 访问修饰符

|

C# 访问修改

|

可应用于

|

生命的意义

public 类型或类型成员 公共项目没有访问限制。可以从对象以及任何派生类访问公共成员。可以从其他外部程序集访问公共类型。
private 类型成员或嵌套类型 私有项只能由定义该项的类(或结构)访问。
protected 类型成员或嵌套类型 受保护的项可以由定义它的类和任何子类使用。不能从继承链之外访问它们。
internal 类型或类型成员 只能在当前组件中访问内部项目。其他程序集可以被显式授予查看内部项的权限。
protected internal 类型成员或嵌套类型 protectedinternal关键字组合在一个项目上时,该项目可在定义程序集内、定义类内以及定义程序集内外的派生类中访问。
private protected``(new 7.2) 类型成员或嵌套类型 privateprotected关键字组合在一个项目上时,该项目可以在定义类中访问,也可以由同一程序集中的派生类访问。

在本章中,您只关心关键字publicprivate。后面的章节将研究internalprotected internal修饰符(当你构建代码库和单元测试时有用)和protected修饰符(当你创建类层次结构时有用)的作用。

使用默认访问修饰符

默认情况下,类型成员是隐式私有、,而类型是隐式内部。因此,下面的类定义被自动设置为internal,而该类型的默认构造函数被自动设置为private(然而,正如您可能会怀疑的那样,您很少需要私有类构造函数):

// An internal class with a private default constructor.
class Radio
{
  Radio(){}
}

如果你想更明确,你可以自己添加这些关键字,而不会产生不良影响(除了一些额外的击键)。

// An internal class with a private default constructor.
internal class Radio
{
  private Radio(){}
}

为了允许程序的其他部分调用对象的成员,你必须用public关键字定义它们(或者可能用protected关键字,你将在下一章中学习)。同样,如果您想要将Radio公开给外部程序集(同样,在构建更大的解决方案或代码库时很有用),您将需要添加public修饰符。

// A public class with a public default constructor.
public class Radio
{
  public Radio(){}
}

使用访问修饰符和嵌套类型

如表 5-1 所述,privateprotectedprotected internalprivate protected访问修饰符可以应用于嵌套类型。第六章将详细研究嵌套。但是,此时您需要知道的是,嵌套类型是直接在类或结构的范围内声明的类型。举例来说,下面是嵌套在公共类(名为SportsCar)中的私有枚举(名为CarColor):

public class SportsCar
{
  // OK! Nested types can be marked private.
  private enum CarColor
  {
    Red, Green, Blue
  }
}

这里,允许在嵌套类型上应用private访问修饰符。然而,非嵌套类型(如SportsCar)只能用publicinternal修饰符来定义。因此,下面的类定义是非法的:

// Error! Nonnested types cannot be marked private!
private class SportsCar
{}

理解第一个支柱:C# 的封装服务

封装的概念围绕着这样一个概念,即不能从对象实例直接访问对象的数据。相反,类数据被定义为私有的。如果对象用户想要改变对象的状态,它可以通过使用公共成员来间接实现。为了说明封装服务的需要,假设您已经创建了以下类定义:

// A class with a single public field.
class Book
{
  public int numberOfPages;
}

公共数据的问题在于,数据本身无法“理解”它被赋予的当前值对于系统的当前业务规则是否有效。如你所知,一个 C# int的上界是相当大的(2,147,483,647)。因此,编译器允许以下赋值:

// Humm. That is one heck of a mini-novel!
Book miniNovel = new Book();
miniNovel.numberOfPages = 30_000_000;

虽然你还没有溢出一个int数据类型的边界,但是应该清楚一个 3000 万页的迷你小说有点不合理。如您所见,公共字段没有提供捕获逻辑上限(或下限)的方法。如果您当前的系统有一个业务规则,规定一本书必须在 1 到 1,000 页之间,那么您会不知如何通过编程来强制执行。因此,公共字段通常在生产级类定义中没有位置。

Note

更具体地说,表示对象状态的类成员不应该被标记为 public。正如你将在本章后面看到的,公共常量和公共只读字段非常有用。

封装提供了一种保持对象状态数据完整性的方法。您应该养成定义私有数据的习惯,而不是定义公共字段(这很容易导致数据损坏),私有数据是使用两种主要技术之一间接操纵的。

  • 您可以定义一对公共访问器(get)和赋值器(set)方法。

  • 您可以定义公共属性。

无论您选择哪种技术,关键是一个封装良好的类应该保护它的数据,并对外界隐藏它如何操作的细节。这通常被称为黑盒编程。这种方法的美妙之处在于,对象可以自由地改变给定方法的实现方式。只要方法的参数和返回值保持不变,它就不会破坏任何使用它的现有代码。

使用传统的访问器和赋值器进行封装

在本章余下的几页中,你将构建一个相当完整的类来模拟一个普通雇员。首先,创建一个名为 EmployeeApp 的新控制台应用项目,并创建一个名为Employee.cs的新类文件。用以下名称空间、字段、方法和构造函数更新Employee类:

using System;
namespace EmployeeApp
{
  class Employee
  {
    // Field data.
    private string _empName;
    private int _empId;
    private float _currPay;

    // Constructors.
    public Employee() {}
    public Employee(string name, int id, float pay)
    {
      _empName = name;
      _empId = id;
      _currPay = pay;
    }

    // Methods.
    public void GiveBonus(float amount) => _currPay += amount;
    public void DisplayStats()
    {
      Console.WriteLine("Name: {0}", _empName);
      Console.WriteLine("ID: {0}", _empId);
      Console.WriteLine("Pay: {0}", _currPay);
    }
  }
}

注意,Employee类的字段目前是使用private关键字定义的。鉴于此,不能从对象变量直接访问 _ empName、_ empId和 _ currPay字段。因此,代码中的以下逻辑会导致编译器错误:

Employee emp = new Employee();
// Error! Cannot directly access private members
// from an object!
emp._empName = "Marv";

如果希望外部世界与工人的全名进行交互,传统的方法是定义一个访问器(get方法)和一个赋值器(set方法)。get方法的作用是向调用者返回底层状态数据的当前值。set方法允许调用者改变底层状态数据的当前值,只要满足定义的业务规则。

为了说明,让我们封装empName字段。为此,将下面的public方法添加到Employee类中。注意,SetName()方法对传入的数据执行测试,以确保string不超过 15 个字符。否则,控制台会显示一条错误信息,并且不会对empName字段进行任何更改。

Note

如果这是一个生产级别的类,您还需要在构造函数逻辑中检查雇员姓名的字符长度。暂时忽略这个细节,因为在检查属性语法时,您将很快清理这段代码。

class Employee
{
  // Field data.
  private string _empName;
  ...

  // Accessor (get method).
  public string GetName() => _empName;

  // Mutator (set method).
  public void SetName(string name)
  {
    // Do a check on incoming value
    // before making assignment.
    if (name.Length > 15)
    {
      Console.WriteLine("Error! Name length exceeds 15 characters!");
    }
    else
    {
      _empName = name;
    }
  }
}

这种技术需要两个唯一命名的方法来操作单个数据点。若要测试新方法,请按如下方式更新代码方法:

Console.WriteLine("***** Fun with Encapsulation *****\n");
Employee emp = new Employee("Marvin", 456, 30_000);
emp.GiveBonus(1000);
emp.DisplayStats();

// Use the get/set methods to interact with the object's name.
emp.SetName("Marv");
Console.WriteLine("Employee is named: {0}", emp.GetName());
Console.ReadLine();

由于您的SetName()方法中的代码,如果您试图指定超过 15 个字符(见下文),您会发现硬编码的错误消息被打印到控制台:

Console.WriteLine("***** Fun with Encapsulation *****\n");
...
// Longer than 15 characters! Error will print to console.
Employee emp2 = new Employee();
emp2.SetName("Xena the warrior princess");

Console.ReadLine();

目前为止,一切顺利。您已经使用两个名为GetName()SetName()的公共方法封装了私有的empName字段。如果要进一步将数据封装在Employee类中,就需要添加各种额外的方法(比如GetID()SetID()GetCurrentPay()SetCurrentPay())。每个 mutator 方法也可以有多行代码来检查额外的业务规则。虽然这肯定可以做到,但 C# 语言有一个有用的替代符号来封装类数据。

使用属性封装

虽然您可以使用传统的getset方法封装一段字段数据。NET 核心语言更喜欢使用属性来强制数据封装状态数据。首先,要理解属性只是“真正的”访问器和赋值器方法的容器,分别命名为getset。因此,作为一个类设计者,您仍然能够在赋值之前执行任何必要的内部逻辑(例如,大写该值,清除该值中的非法字符,检查数值的界限,等等)。).

下面是更新后的Employee类,现在使用属性语法而不是传统的getset方法来强制封装每个字段:

class Employee
{
  // Field data.
  private string _empName;
  private int _empId;
  private float _currPay;
  // Properties!
  public string Name
  {
    get { return _empName; }
    set
    {
      if (value.Length > 15)
      {
        Console.WriteLine("Error! Name length exceeds 15 characters!");
      }
      else
      {
        _empName = value;
      }
    }
  }
  // We could add additional business rules to the sets of these properties;
  // however, there is no need to do so for this example.
  public int Id
  {
    get { return _empId; }
    set { _empId = value; }
  }
  public float Pay
  {
    get { return _currPay; }
    set { _currPay = value; }
  }
...
}

C# 属性是通过直接在属性本身中定义一个get作用域(访问器)和set作用域(赋值器)组成的。请注意,属性通过似乎是返回值的内容来指定它所封装的数据的类型。还要注意,与方法不同,属性在定义时不使用括号(甚至是空括号)。考虑以下对你目前在Id的房产的评论:

// The 'int' represents the type of data this property encapsulates.
public int Id // Note lack of parentheses.
{
  get { return _empId; }
  set { _empID = value; }
}

在属性的set范围内,使用一个名为value的令牌,它用于表示调用者用来分配属性的传入值。这个标记是而不是一个真正的 C# 关键字,而是被称为上下文关键字。当标记值在属性的设置范围内时,它总是表示由调用方分配的值,并且它总是与属性本身具有相同的基础数据类型。因此,请注意Name属性仍然可以测试string的范围,如下所示:

public string Name
{
  get { return _empName; }
  set
  {
    // Here, value is really a string.
    if (value.Length > 15)
    {   Console.WriteLine("Error! Name length exceeds 15 characters!");
    }
    else
    {
      empName = value;
    }
  }
}

在您设置好这些属性之后,调用者会觉得它正在获取和设置一个数据的公共点;然而,正确的getset块在后台被调用以保持封装。

Console.WriteLine("***** Fun with Encapsulation *****\n");
Employee emp = new Employee("Marvin", 456, 30000);
emp.GiveBonus(1000);
emp.DisplayStats();

// Reset and then get the Name property.
emp.Name = "Marv";
Console.WriteLine("Employee is named: {0}", emp.Name);
Console.ReadLine();

属性(相对于访问器和赋值器方法)也使您的类型更容易操作,因为属性能够响应 C# 的内部运算符。举例来说,假设Employee类类型有一个代表雇员年龄的内部私有成员变量。下面是相关的更新(注意构造函数链接的使用):

class Employee
{
...
   // New field and property.
   private int _empAge;
   public int Age
   {
     get { return _empAge; }
     set { _empAge = value; }
   }

   // Updated constructors.
   public Employee() {}
   public Employee(string name, int id, float pay)
   :this(name, 0, id, pay){}

   public Employee(string name, int age, int id, float pay)
   {
     _empName = name;
     _empId = id;
     _empAge = age;
     _currPay = pay;
   }

   // Updated DisplayStats() method now accounts for age.
   public void DisplayStats()
   {
     Console.WriteLine("Name: {0}", _empName);
     Console.WriteLine("ID: {0}", _empId);
     Console.WriteLine("Age: {0}", _empAge);
     Console.WriteLine("Pay: {0}", _currPay);
   }
}

现在假设您已经创建了一个名为joeEmployee对象。在他生日那天,你想把年龄加一。使用传统的访问器和赋值器方法,您需要编写如下代码:

Employee joe = new Employee();
joe.SetAge(joe.GetAge() + 1);

然而,如果您使用一个名为Age的属性封装empAge,您可以简单地这样写:

Employee joe = new Employee();
joe.Age++;

作为表达式主体成员的属性(新 7.0)

如前所述,属性getset访问器也可以写成表达式体成员。规则和语法是相同的:可以使用新的语法编写单行方法。所以,Age属性可以这样写:

public int Age
{
  get => empAge;
  set => empAge = value;
}

两种语法都编译成相同的 IL,所以使用哪种语法完全由您决定。在本文中,您将看到两种风格的混合,以保持它们的可见性,而不是因为我坚持特定的代码风格。

在类定义中使用属性

属性,特别是属性的set部分,是封装类的业务规则的常见地方。目前,Employee类有一个Name属性,确保名称不超过 15 个字符。剩余的属性(IDPayAge)也可以用任何相关的逻辑来更新。

虽然这很好,但也要考虑类构造函数通常在内部做什么。它将接受传入的参数,检查有效数据,然后对内部私有字段进行赋值。目前,您的主构造函数不而不是测试传入的字符串数据的有效范围,因此您可以这样更新这个成员:

public Employee(string name, int age, int id, float pay)
{
  // Humm, this seems like a problem...
  if (name.Length > 15)
  {
    Console.WriteLine("Error! Name length exceeds 15 characters!");
  }
  else
  {
    _empName = name;
  }
  _empId = id;
  _empAge = age;
  _currPay = pay;
}

我确信你能看到这种方法的问题。属性和你的主构造函数正在执行相同的错误检查。如果您还对其他数据点进行检查,您将会有大量重复的代码。为了简化您的代码并将所有的错误检查隔离到一个中心位置,如果您在需要获取或设置值时总是使用类中的属性,您会做得很好。考虑以下更新的构造函数:

public Employee(string name, int age, int id, float pay)
{
   // Better! Use properties when setting class data.
   // This reduces the amount of duplicate error checks.
   Name = name;
   Age = age;
   ID = id;
   Pay = pay;
}

除了更新构造函数以在赋值时使用属性之外,在整个类实现中使用属性来确保您的业务规则始终得到执行也是一个很好的实践。在许多情况下,直接引用底层私有数据的唯一时间是在属性本身中。记住这一点,下面是您更新的Employee类:

class Employee
{
   // Field data.
   private string _empName;
   private int _empId;
   private float _currPay;
   private int _empAge;
   // Constructors.
   public Employee() { }
   public Employee(string name, int id, float pay)
     :this(name, 0, id, pay){}
   public Employee(string name, int age, int id, float pay)
   {
     Name = name;
     Age = age;
     ID = id;
     Pay = pay;
   }
   // Methods.
   public void GiveBonus(float amount) => Pay += amount;

   public void DisplayStats()
   {
     Console.WriteLine("Name: {0}", Name);
     Console.WriteLine("ID: {0}", Id);
     Console.WriteLine("Age: {0}", Age);
     Console.WriteLine("Pay: {0}", Pay);
   }

   // Properties as before...
...
}

属性只读属性

当封装数据时,您可能想要配置一个只读属性。为此,只需省略set块。例如,假设您有一个名为SocialSecurityNumber的新属性,它封装了一个名为empSSN的私有string变量。如果您想使它成为只读属性,可以这样写:

public string SocialSecurityNumber
{
  get { return _empSSN; }
}

只有一个 getter 的属性也可以使用表达式体成员来简化。下面一行相当于前面的代码块:

public string SocialSecurityNumber => _empSSN;

现在假设您的类构造函数有一个新参数,让调用者设置对象的 SSN。因为SocialSecurityNumber属性是只读的,所以不能这样设置值:

public Employee(string name, int age, int id, float pay, string ssn)
{
   Name = name;
   Age = age;
   ID = id;
   Pay = pay;

   // OOPS! This is no longer possible if the property is read only.
   SocialSecurityNumber = ssn;
}

除非您愿意将属性重新设计为可读写的(您很快就会这么做),否则您对只读属性的唯一选择就是在构造函数逻辑中使用底层的empSSN成员变量,如下所示:

public Employee(string name, int age, int id, float pay, string ssn)
{
   ...
   // Check incoming ssn parameter as required and then set the value.
   empSSN = ssn;
}

属性只写属性

如果您想将您的属性配置为一个*只写属性,*省略了get块,如下所示:

public int Id
{
  set { _empId = value; }
}

混合属性的私有和公共 Get/Set 方法

定义属性时,getset方法的访问级别可以不同。重新查看社会安全号,如果目标是防止来自类之外的对该号的修改,那么将get方法声明为 public,而将set方法声明为 private,如下所示:

public string SocialSecurityNumber
{
  get => _empSSN;
  private set => _empSSN = value;
}

请注意,这将属性从只读更改为读写。不同之处在于,write 对定义类之外的任何东西都是隐藏的。

重温 static 关键字:定义静态属性

在本章的前面,您研究了关键字static的作用。现在您已经理解了 C# 属性语法的使用,您可以形式化静态属性了。在本章前面创建的 StaticDataAndMembers 项目中,您的SavingsAccount类有两个公共静态方法来获取和设置利率。然而,将这个数据点包装在一个静态属性中会更标准。下面是一个例子(注意static关键字的使用):

// A simple savings account class.
class SavingsAccount
{
   // Instance-level data.
   public double currBalance;

   // A static point of data.
   private static double _currInterestRate = 0.04;

   // A static property.
   public static double InterestRate
   {
     get { return _currInterestRate; }
     set { _currInterestRate = value; }
   }
...
}

如果您想要使用这个属性来取代先前的静态方法,可以更新您的程式码,如下所示:

// Print the current interest rate via property.
Console.WriteLine("Interest Rate is: {0}", SavingsAccount.InterestRate);

与属性模式匹配的模式(新 8.0)

属性模式使您能够匹配对象的属性。要设置该示例,请添加一个新文件(EmployeePayTypeEnum.cs)来枚举员工工资类型,如下所示:

namespace EmployeeApp
{
    public enum EmployeePayTypeEnum
    {
        Hourly,
        Salaried,
        Commission
    }
}

用 pay 类型的属性更新Employee类,并从构造函数中初始化它。下面列出了相关的代码更改:

private EmployeePayTypeEnum _payType;
public EmployeePayTypeEnum PayType
{
  get => _payType;
  set => _payType = value;
}
public Employee(string name, int id, float pay, string empSsn)
  : this(name,0,id,pay, empSsn, EmployeePayTypeEnum.Salaried)
{
}
public Employee(string name, int age, int id,
  float pay, string empSsn, EmployeePayTypeEnum payType)
{
  Name = name;
  Id = id;
  Age = age;
  Pay = pay;
  SocialSecurityNumber = empSsn;
  PayType = payType;
}

现在所有的部分都已经就绪,可以根据雇员的工资类型更新GiveBonus()方法。受委托的员工获得 10%的奖金,每小时获得相当于 40 小时的按比例分配的奖金,受薪员工获得输入的金额。更新后的GiveBonus()方法如下:

public void GiveBonus(float amount)
{
  Pay = this switch
  {
    {PayType: EmployeePayTypeEnum.Commission }
      => Pay += .10F * amount,
    {PayType: EmployeePayTypeEnum.Hourly }
      => Pay += 40F * amount/2080F,
    {PayType: EmployeePayTypeEnum.Salaried }
      => Pay += amount,
    _ => Pay+=0
  };
}

与其他使用模式匹配的switch语句一样,如果没有一个case语句被满足,要么必须有一个包罗万象的case语句,要么switch语句必须抛出一个异常。

要对此进行测试,请将以下代码添加到顶级语句中:

Employee emp = new Employee("Marvin",45,123,1000,"111-11-1111",EmployeePayTypeEnum.Salaried);
Console.WriteLine(emp.Pay);
emp.GiveBonus(100);
Console.WriteLine(emp.Pay);

了解自动属性

当您构建属性来封装数据时,通常会发现 set 作用域具有强制执行程序业务规则的代码。但是,在某些情况下,除了简单地获取和设置值之外,您可能不需要任何实现逻辑。这意味着您可能会得到如下所示的大量代码:

// An Employee Car type using standard property
// syntax.
class Car
{
   private string carName = "";
   public string PetName
   {
     get { return carName; }
     set { carName = value; }
   }
}

在这些情况下,多次定义私有支持字段和简单的属性定义会变得相当冗长。举例来说,如果您正在对一个需要九个私有字段数据点的类进行建模,那么您最终会创作九个相关的属性,这些属性只不过是封装服务的瘦包装器。

为了简化提供简单字段数据封装的过程,您可以使用自动属性语法。顾名思义,这个特性将使用新的语法把定义私有支持字段和相关 C# 属性成员的工作卸载给编译器。举例来说,创建一个名为 AutoProps 的新控制台应用项目,并添加一个名为Car.cs的新类文件。现在,考虑对Car类的修改,它使用这个语法快速创建三个属性:

using System;

namespace AutoProps
{
  class Car
  {
     // Automatic properties! No need to define backing fields.
     public string PetName { get; set; }
     public int Speed { get; set; }
     public string Color { get; set; }
  }
}

Note

Visual Studio 和 Visual Studio 代码提供了prop代码片段。如果在类定义中键入prop并按 Tab 键两次,ide 将为新的自动属性生成启动代码。然后,您可以使用 Tab 键在定义的每个部分中循环,以填充细节。试试看!

定义自动属性时,只需指定访问修饰符、底层数据类型、属性名和空的get / set范围。在编译时,你的类型将被提供一个自动生成的私有支持字段和一个合适的get / set逻辑实现。

Note

自动生成的私有支持字段的名称在 C# 代码库中不可见。查看它的唯一方法是使用一个工具,比如ildasm.exe

从 C# 版本 6 开始,可以通过省略set范围来定义“只读自动属性”。只读自动属性只能在构造函数中设置。但是,不可能定义只写属性。为了巩固,请考虑以下几点:

// Read-only property? This is OK!
public int MyReadOnlyProp { get; }

// Write only property? Error!
public int MyWriteOnlyProp { set; }

与自动属性交互

因为编译器将在编译时定义私有支持字段(并且假定这些字段不能在 C# 代码中直接访问),所以类定义的自动属性将总是需要使用属性语法来获取和设置基础值。这一点很重要,因为许多程序员直接使用类定义中的私有字段*,这在这种情况下是不可能的。例如,如果Car类要提供一个DisplayStats()方法,它需要使用属性名来实现这个方法。*

class Car
{
   // Automatic properties!
   public string PetName { get; set; }
   public int Speed { get; set; }
   public string Color { get; set; }

   public void DisplayStats()
   {
     Console.WriteLine("Car Name: {0}", PetName);
     Console.WriteLine("Speed: {0}", Speed);
     Console.WriteLine("Color: {0}", Color);
   }
}

当您使用用自动属性定义的对象时,您将能够使用预期的属性语法分配和获取值。

using System;
using AutoProps;

Console.WriteLine("***** Fun with Automatic Properties *****\n");

Car c = new Car();
c.PetName = "Frank";
c.Speed = 55;
c.Color = "Red";

Console.WriteLine("Your car is named {0}? That's odd...",
  c.PetName);
c.DisplayStats();

Console.ReadLine();

属性自动属性和默认值

当您使用自动属性来封装数字或布尔数据时,您可以在代码库中直接使用自动生成的类型属性,因为隐藏的后台字段将被分配一个安全的默认值(false用于布尔数据,0用于数字数据)。但是,请注意,如果使用自动属性语法包装另一个类变量,隐藏的私有引用类型也将被设置为默认值null(如果不小心的话,这可能会有问题)。

让我们在您当前的项目中插入一个名为Garage.cs的新类文件,它利用了两个自动属性(当然,一个真正的 garage 类可能会维护一个Car对象的集合;然而,忽略这里细节)。

namespace AutoProps
{
  class Garage
  {
     // The hidden int backing field is set to zero!
     public int NumberOfCars { get; set; }

     // The hidden Car backing field is set to null!
     public Car MyAuto { get; set; }
  }
}

给定 C# 字段数据的默认值,您将能够按原样打印出NumberOfCars的值(因为它被自动赋予零值),但是如果您直接调用MyAuto,您将在运行时收到一个“空引用异常”,因为在后台使用的Car成员变量还没有被赋予一个新的对象。

...
Garage g = new Garage();

// OK, prints default value of zero.
Console.WriteLine("Number of Cars: {0}", g.NumberOfCars);

// Runtime error! Backing field is currently null!
Console.WriteLine(g.MyAuto.PetName);
Console.ReadLine();

要解决这个问题,您可以更新类构造函数,以确保对象以安全的方式出现。这里有一个例子:

class Garage
{
   // The hidden backing field is set to zero!
   public int NumberOfCars { get; set; }
   // The hidden backing field is set to null!
   public Car MyAuto { get; set; }
   // Must use constructors to override default
   // values assigned to hidden backing fields.
   public Garage()
   {
     MyAuto = new Car();
     NumberOfCars = 1;
   }
   public Garage(Car car, int number)
   {
     MyAuto = car;
     NumberOfCars = number;
   }
}

通过这一修改,您现在可以将一个Car对象放入Garage对象中,如下所示:

Console.WriteLine("***** Fun with Automatic Properties *****\n");

// Make a car.
Car c = new Car();
c.PetName = "Frank";
c.Speed = 55;
c.Color = "Red";
c.DisplayStats();

// Put car in the garage.
Garage g = new Garage();
g.MyAuto = c;
Console.WriteLine("Number of Cars in garage: {0}", g.NumberOfCars);
Console.WriteLine("Your car is named: {0}", g.MyAuto.PetName);

Console.ReadLine();

初始化自动属性

虽然前面的方法是可行的,但是自从 C# 6 发布以来,您就可以使用一种语言特性来简化自动属性接收初始值赋值的方式。回想一下本章的开头,一个类的数据字段可以在声明时直接被赋予一个初始值。这里有一个例子:

class Car
{
  private int numberOfDoors = 2;
}

以类似的方式,C# 现在允许您为编译器生成的底层支持字段分配初始值。这减轻了您在类构造函数中添加代码语句以确保属性数据符合预期的麻烦。

这是一个更新版本的Garage类,它将自动属性初始化为合适的值。注意你不再需要添加逻辑到你的默认类构造函数来进行安全赋值。在这个迭代中,你直接分配一个新的Car对象给MyAuto属性。

class Garage
{
    // The hidden backing field is set to 1.
    public int NumberOfCars { get; set; } = 1;

    // The hidden backing field is set to a new Car object.
    public Car MyAuto { get; set; } = new Car();

    public Garage(){}
    public Garage(Car car, int number)
    {
        MyAuto = car;
        NumberOfCars = number;
    }
}

您可能同意,自动属性是 C# 编程语言的一个很好的特性,因为您可以使用简化的语法为一个类定义许多属性。当然,如果您正在构建一个除了获取和设置底层私有字段之外还需要额外代码的属性(如数据验证逻辑、写入事件日志、与数据库通信等),请注意。),你将被要求定义一个“正常”。手动输入网络核心属性类型。C# 自动属性只不过为底层(编译器生成的)私有数据提供简单的封装。

了解对象初始化

如本章所示,构造函数允许您在创建新对象时指定启动值。另一方面,属性允许您以安全的方式获取和设置基础数据。当您使用其他人的类时,包括在。NET 核心基础类库,你会发现没有一个构造函数允许你设置每一个底层状态数据。鉴于这一点,程序员通常被迫选择可能的最佳构造函数,之后程序员使用一些提供的属性进行赋值。

查看对象初始化语法

为了帮助简化启动和运行对象的过程,C# 提供了对象初始化语法。使用这种技术,可以用几行代码创建一个新的对象变量,并分配一系列属性和/或公共字段。从语法上来说,对象初始化器由一个逗号分隔的指定值列表组成,由{}标记括起来。初始化列表中的每个成员都映射到正在初始化的对象的公共字段或公共属性的名称。

要查看此语法的运行情况,请创建一个名为 ObjectInitializers 的新控制台应用项目。现在,考虑一个名为Point的简单类,它是使用自动属性创建的(对于对象初始化语法来说,这不是强制性的,但是可以帮助您编写一些简洁的代码)。

class Point
{
   public int X { get; set; }
   public int Y { get; set; }

   public Point(int xVal, int yVal)
   {
     X = xVal;
     Y = yVal;
   }
   public Point() { }

   public void DisplayStats()
   {
     Console.WriteLine("[{0}, {1}]", X, Y);
   }
}

现在考虑如何使用以下方法制作Point对象:

Console.WriteLine("***** Fun with Object Init Syntax *****\n");

// Make a Point by setting each property manually.
Point firstPoint = new Point();
firstPoint.X = 10;
firstPoint.Y = 10;
firstPoint.DisplayStats();

// Or make a Point via a custom constructor.
Point anotherPoint = new Point(20, 20);
anotherPoint.DisplayStats();

// Or make a Point using object init syntax.
Point finalPoint = new Point { X = 30, Y = 30 };
finalPoint.DisplayStats();
Console.ReadLine();

最后一个Point变量没有使用定制的构造函数(就像传统的做法一样),而是为公共的XY属性设置值。在后台,调用类型的默认构造函数,然后将值设置为指定的属性。为此,对象初始化语法只是用于使用默认构造函数创建类变量和逐个属性设置状态数据的语法的速记符号。

Note

重要的是要记住,对象初始化过程是隐式使用属性 setter 的。如果属性 setter 标记为 private,则不能使用此语法。

使用仅初始化的设置器(新 9.0)

C# 9.0 中增加的一个新特性是init -only setters。这些设置器使属性能够在初始化过程中设置其值,但在对象上的构造完成后,属性将变为只读。这些类型的属性被称为*不可变的。*将名为ReadOnlyPointAfterCreation.cs的新类文件添加到您的项目中,并添加以下代码:

using System;

namespace ObjectInitializers
{
  class PointReadOnlyAfterCreation
  {
    public int X { get; init; }
    public int Y { get; init; }

    public void DisplayStats()
    {
      Console.WriteLine("InitOnlySetter: [{0}, {1}]", X, Y);
    }
    public PointReadOnlyAfterCreation(int xVal, int yVal)
    {
      X = xVal;
      Y = yVal;
    }
    public PointReadOnlyAfterCreation() { }
  }
}

使用下面的代码来测试这个新类:

//Make readonly point after construction
PointReadOnlyAfterCreation firstReadonlyPoint = new PointReadOnlyAfterCreation(20, 20);
firstReadonlyPoint.DisplayStats();

// Or make a Point using object init syntax.
PointReadOnlyAfterCreation secondReadonlyPoint = new PointReadOnlyAfterCreation { X = 30, Y = 30 };
secondReadonlyPoint.DisplayStats();

请注意,您为Point类编写的代码没有任何变化,当然类名除外。区别在于一旦创建了类,就不能修改XY的值。例如,以下代码将不会编译:

//The next two lines will not compile
secondReadonlyPoint.X = 10;
secondReadonlyPoint.Y = 10;

使用初始化语法调用自定义构造函数

前面的例子通过隐式调用类型的默认构造函数来初始化Point类型。

// Here, the default constructor is called implicitly.
Point finalPoint = new Point { X = 30, Y = 30 };

如果您想弄清楚这一点,可以显式调用默认构造函数,如下所示:

// Here, the default constructor is called explicitly.
Point finalPoint = new Point() { X = 30, Y = 30 };

请注意,当您使用初始化语法构造类型时,您可以调用由该类定义的任何构造函数。您的Point类型当前定义了一个双参数构造函数来设置( x,y )位置。因此,下面的Point声明导致100X值和100Y值,而不管构造函数参数指定了值1016的事实:

// Calling a custom constructor.
Point pt = new Point(10, 16) { X = 100, Y = 100 };

给定您的Point类型的当前定义,在使用初始化语法的同时调用自定义构造函数并不是非常有用(而且有点冗长)。但是,如果您的Point类型提供了一个新的构造函数,允许调用者建立一种颜色(通过一个名为PointColor的自定义enum,自定义构造函数和对象初始化语法的组合就变得很清楚了。

将名为PointColorEnum.cs的新类添加到您的项目中,并添加以下代码来创建颜色的枚举:

namespace ObjectInitializers
{
  enum PointColorEnum
  {
    LightBlue,
    BloodRed,
    Gold
  }
}

现在,更新Point类,如下所示:

class Point
{
   public int X { get; set; }
   public int Y { get; set; }
   public PointColorEnum Color{ get; set; }

   public Point(int xVal, int yVal)
   {
     X = xVal;
     Y = yVal;
     Color = PointColorEnum.Gold;
   }

   public Point(PointColorEnum ptColor)
   {
     Color = ptColor;
   }

   public Point() : this(PointColorEnum.BloodRed){ }

   public void DisplayStats()
   {
     Console.WriteLine("[{0}, {1}]", X, Y);
     Console.WriteLine("Point is {0}", Color);
   }
}

使用这个新的构造函数,您现在可以创建一个黄金点(位于 90,20 ),如下所示:

// Calling a more interesting custom constructor with init syntax.
Point goldPoint = new Point(PointColorEnum.Gold){ X = 90, Y = 20 };
goldPoint.DisplayStats();

使用初始化语法初始化数据

正如本章前面简要提到的(在第六章中也有详细讨论),“has-a”关系允许你通过定义现有类的成员变量来组成新的类。例如,假设您现在有一个Rectangle类,它利用Point类型来表示它的左上角/右下角坐标。因为自动属性将类变量的所有字段设置为null,所以您将使用“传统”属性语法来实现这个新类。

using System;

namespace ObjectInitializers
{
  class Rectangle
  {
    private Point topLeft = new Point();
    private Point bottomRight = new Point();

    public Point TopLeft
    {
      get { return topLeft; }
      set { topLeft = value; }
    }
    public Point BottomRight
    {
      get { return bottomRight; }
      set { bottomRight = value; }
    }

    public void DisplayStats()
    {
      Console.WriteLine("[TopLeft: {0}, {1}, {2} BottomRight: {3}, {4}, {5}]",
          topLeft.X, topLeft.Y, topLeft.Color,
          bottomRight.X, bottomRight.Y, bottomRight.Color);
    }
  }
}

使用对象初始化语法,您可以创建一个新的Rectangle变量,并如下设置内部的Point:

// Create and initialize a Rectangle.
Rectangle myRect = new Rectangle
{
   TopLeft = new Point { X = 10, Y = 10 },
   BottomRight = new Point { X = 200, Y = 200}
};

同样,对象初始化语法的好处是它基本上减少了击键次数(假设没有合适的构造函数)。以下是建立类似Rectangle的传统方法:

// Old-school approach.
Rectangle r = new Rectangle();
Point p1 = new Point();
p1.X = 10;
p1.Y = 10;
r.TopLeft = p1;
Point p2 = new Point();
p2.X = 200;
p2.Y = 200;
r.BottomRight = p2;

虽然您可能会觉得对象初始化语法可能需要一点时间来适应,但是一旦您对代码感到满意,您将会对能够以最少的麻烦和麻烦快速建立新对象的状态感到非常满意。

使用常量和只读字段数据

有时,您需要一个您根本不想更改的属性,也称为不可变的,无论是从它被编译的时候还是在构造期间被设置之后。我们已经研究了一个只有init设置器的例子。现在我们将检查常量和只读字段。

了解常量字段数据

C# 提供了const关键字来定义常量数据,这些数据在初始赋值后永远不会改变。正如您可能猜到的那样,当您在应用中定义一组与给定的类或结构有逻辑联系的已知值时,这可能会很有帮助。

假设您正在构建一个名为MyMathClass的实用程序类,它需要为 pi 定义一个值(为简单起见,您将假设该值为 3.14)。首先创建一个名为 ConstData 的新控制台应用项目,并添加一个名为MyMathClass.cs的文件。假设您不想让其他开发人员在代码中更改这个值,那么 pi 可以用下面的常量来建模:

//MyMathClass.cs
using System;
namespace ConstData
{
   class MyMathClass
   {
     public const double PI = 3.14;
   }
}

更新Program.cs类中的代码,使其与下面的代码相匹配:

using System;
using ConstData;

Console.WriteLine("***** Fun with Const *****\n");
Console.WriteLine("The value of PI is: {0}", MyMathClass.PI);
// Error! Can't change a constant!
// MyMathClass.PI = 3.1444;

Console.ReadLine();

请注意,您正在使用类名前缀(例如,MyMathClass.PI)引用由MyMathClass定义的常量数据。这是因为类的常量字段是隐式的静态的。但是,允许在方法或属性的范围内定义和访问局部常量变量。这里有一个例子:

static void LocalConstStringVariable()
{
   // A local constant data point can be directly accessed.
   const string fixedStr = "Fixed string Data";
   Console.WriteLine(fixedStr);

   // Error!
   // fixedStr = "This will not work!";
}

不管在哪里定义一个数据常量,有一点要记住,在定义常量时必须指定赋给常量的初始值。在类别建构函式中指派 pi 的值,如下列程式码所示,会产生编译错误:

class MyMathClass
{
   // Try to set PI in ctor?
   public const double PI;

   public MyMathClass()
   {
     // Not possible- must assign at time of declaration.
     PI = 3.14;
   }
}

这种限制的原因是常量数据的值在编译时必须是已知的。众所周知,构造函数(或任何其他方法)都是在运行时被调用的。

了解只读字段

与常量数据密切相关的是只读字段数据(不要与只读属性混淆)。像常量一样,只读字段在初始赋值后不能更改,否则会收到编译时错误。但是,与常量不同,赋给只读字段的值可以在运行时确定,因此可以合法地在构造函数的范围内赋值,但不能在其他地方赋值。

当您直到运行时才知道字段的值时,这可能会很有帮助,因为您需要读取外部文件来获取该值,但希望确保该值在该点之后不会更改。为了便于说明,假设对MyMathClass进行如下更新:

class MyMathClass
{
   // Read-only fields can be assigned in ctors,
   // but nowhere else.
   public readonly double PI;

   public MyMathClass ()
   {
     PI = 3.14;
   }
}

同样,任何在构造函数范围之外对标记为readonly的字段进行赋值的尝试都会导致编译器错误。

class MyMathClass
{
   public readonly double PI;
   public MyMathClass ()
   {
     PI = 3.14;
   }

   // Error!
   public void ChangePI()
   { PI = 3.14444;}
}

了解静态只读字段

与常量字段不同,只读字段不是隐式静态的。因此,如果您想从类级别公开PI,您必须显式地使用static关键字。如果你在编译时知道一个静态只读字段的值,初始赋值看起来类似于一个常量的值(然而,在这种情况下,首先简单地使用const关键字会更容易,因为你是在声明时给数据字段赋值的)。

class MyMathClass
{
   public static readonly double PI = 3.14;
}

//Program.cs
Console.WriteLine("***** Fun with Const *****");
Console.WriteLine("The value of PI is: {0}", MyMathClass.PI);
Console.ReadLine();

但是,如果静态只读字段的值直到运行时才知道,则必须使用静态构造函数,如本章前面所述。

class MyMathClass
{
   public static readonly double PI;

   static MyMathClass()
   { PI = 3.14; }
}

了解分部类

当处理类时,理解 C# partial关键字的作用是很重要的。partial 关键字允许将单个类划分到多个代码文件中。当您从数据库中构建实体框架核心类时,创建的类都是作为分部类创建的。这样,假设您的代码位于标有partial关键字的单独的类文件中,那么您编写的用于扩充这些文件的任何代码都不会被覆盖。另一个原因是,随着时间的推移,您的类可能已经变得难以管理,作为重构该类的中间步骤,您可以将它拆分成片段。

在 C# 中,您可以将单个类划分到多个代码文件中,以将样板代码与更有用(和复杂)的成员隔离开来。为了说明分部类的用处,请在 Visual Studio 中打开本章前面创建的 EmployeeApp 项目,然后打开Employee.cs文件进行编辑。正如您所记得的,这个文件包含了该类所有方面的代码。

class Employee
{
   // Field Data

   // Constructors

   // Methods

   // Properties
}

使用分部类,您可以选择将(例如)属性、构造函数和字段数据移动到一个名为Employee.Core.cs的新文件中(文件名无关紧要)。第一步是将关键字partial添加到当前的类定义中,并剪切要放入新文件中的代码。

// Employee.cs
partial class Employee
{
   // Methods

   // Properties
}

接下来,假设您已经在项目中插入了一个新的类文件,您可以使用简单的剪切和粘贴操作将数据字段和属性移动到新文件中。此外,您必须partial关键字添加到类定义的这个方面。这里有一个例子:

// Employee.Core.cs
partial class Employee
{
   // Field data

   // Properties
}

Note

记住,每个分部类都必须用关键字partial标记!

编译修改后的项目后,您应该看不到任何区别。分部类的整体思想只有在设计时才能实现。应用编译后,程序集中只有一个统一的类。定义分部类型时,唯一的要求是类型的名称(在本例中为Employee)是相同的,并在相同的。NET 核心命名空间。

使用记录(新 9.0)

在 C# 9.0 中新增,记录 类型是一个特殊类型的类。记录是提供合成方法的引用类型,以提供相等的值语义。默认情况下,记录类型是不可变的。虽然您本质上可以创建一个不可变的类,但是使用只包含init的 setters 和只读属性的组合,记录类型消除了额外的工作。

要开始试验记录,创建一个名为 FunWithRecords 的新控制台应用。考虑下面的Car类,它是根据本章前面的例子修改的:

class Car
{
  public string Make { get; set; }
  public string Model { get; set; }
  public string Color { get; set; }

  public Car() {}

  public Car(string make, string model, string color)
  {
    Make = make;
    Model = model;
    Color = color;
  }
}

正如您现在所知道的,一旦您创建了这个类的实例,您就可以在运行时更改任何属性。如果每个实例都需要是不可变的,您可以将属性定义更改为以下内容:

public string Make { get; init; }
public string Model { get; init; }
public string Color { get; init; }

为了测试这个新类,下面的代码创建了两个Car类的实例,一个通过对象初始化,另一个通过自定义构造函数。将Program.cs文件更新为以下内容:

using System;
using FunWithRecords;

Console.WriteLine("Fun with Records!");

//Use object initialization
Car myCar = new Car
{
    Make = "Honda",
    Model = "Pilot",
    Color = "Blue"
};
Console.WriteLine("My car: ");
DisplayCarStats(myCar);
Console.WriteLine();
//Use the custom constructor
Car anotherMyCar = new Car("Honda", "Pilot", "Blue");
Console.WriteLine("Another variable for my car: ");
DisplayCarStats(anotherMyCar);
Console.WriteLine();

//Compile error if property is changed
//myCar.Color = "Red";

Console.ReadLine();

static void DisplayCarStats(Car c)
{
  Console.WriteLine("Car Make: {0}", c.Make);
  Console.WriteLine("Car Model: {0}", c.Model);
  Console.WriteLine("Car Color: {0}", c.Color);
}

正如预期的那样,这两种对象创建方法都可以工作,属性会显示出来,而在构造后试图更改属性会引发编译错误。

要创建一个Car记录类型,向您的项目添加一个名为(CarRecord.cs)的新文件,并添加以下代码:

record CarRecord
{
  public string Make { get; init; }
  public string Model { get; init; }
  public string Color { get; init; }

  public CarRecord () {}
  public CarRecord (string make, string model, string color)
  {
    Make = make;
    Model = model;
    Color = color;
  }
}

通过在Program.cs中运行以下代码,您可以确认该行为与仅具有init设置的Car类相同:

Console.WriteLine("/*************** RECORDS *********************/");
//Use object initialization
CarRecord myCarRecord = new CarRecord
{
    Make = "Honda",
    Model = "Pilot",
    Color = "Blue"
};
Console.WriteLine("My car: ");
DisplayCarRecordStats(myCarRecord);
Console.WriteLine();

//Use the custom constructor
CarRecord anotherMyCarRecord = new CarRecord("Honda", "Pilot", "Blue");
Console.WriteLine("Another variable for my car: ");
Console.WriteLine(anotherMyCarRecord.ToString());
Console.WriteLine();

//Compile error if property is changed
//myCarRecord.Color = "Red";.

Console.ReadLine();

虽然我们还没有讨论记录的相等性(下一节)或继承性(下一章),但是第一次看记录似乎没什么好处。当前的Car例子包含了我们所期望的所有管道代码。在输出上有一个显著的区别:ToString()方法是为记录类型设计的,如下面的示例输出所示:

/*************** RECORDS *********************/
My car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Another variable for my car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }

但是考虑一下这个更新后的Car记录的定义:

record CarRecord(string Make, string Model, string Color);

称为位置记录类型,构造函数定义了记录的属性,所有其他的管道代码都被删除了。使用此语法时有三个注意事项:第一,不能使用紧凑定义语法对记录类型进行对象初始化;第二,必须在正确的位置使用属性构造记录;第三,构造函数中属性的大小写直接转换为记录类型属性的大小写。

了解记录类型的相等性

Car类的例子中,两个Car实例是用完全相同的数据创建的。一个可能认为这两个类是相等的,如下面的代码测试行所示:

Console.WriteLine($"Cars are the same? {myCar.Equals(anotherMyCar)}");

然而,它们并不平等。回想一下,记录类型是类的一种特殊类型,而类是引用类型*。要使两个引用类型相等,它们必须指向内存中的同一个对象。作为进一步的测试,检查两个Car对象是否指向同一个对象:*

Console.WriteLine($"Cars are the same reference? {ReferenceEquals(myCar, anotherMyCar)}");

再次运行该程序会产生以下(简略)结果:

Cars are the same? False
CarRecords are the same? False

记录类型的行为不同。记录类型隐式地覆盖了Equals==!=,它们产生的结果就像实例是值类型一样。考虑下面的代码及其结果:

Console.WriteLine($"CarRecords are the same? {myCarRecord.Equals(anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same reference? {ReferenceEquals(myCarRecord,anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same? {myCarRecord == anotherMyCarRecord}");
Console.WriteLine($"CarRecords are not the same? {myCarRecord != anotherMyCarRecord}");
/*************** RECORDS *********************/
My car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }
Another variable for my car:
CarRecord { Make = Honda, Model = Pilot, Color = Blue }

CarRecords are the same? True
CarRecords are the same reference? false
CarRecords are the same? True
CarRecords are not the same? False

请注意,它们被认为是相等的,即使变量指向内存中的两个不同的变量。

使用 with 表达式复制记录类型

对于记录类型,将记录类型实例分配给新变量会创建一个指向相同引用的指针,这与类的行为相同。下面的代码演示了这一点:

CarRecord carRecordCopy = anotherMyCarRecord;
Console.WriteLine("Car Record copy results");
Console.WriteLine($"CarRecords are the same? {carRecordCopy.Equals(anotherMyCarRecord)}");
Console.WriteLine($"CarRecords are the same? {ReferenceEquals(carRecordCopy, anotherMyCarRecord)}");

执行时,两个测试都返回Yes,证明它们的值和引用是相同的。

为了创建修改了一个或多个属性的记录的真实副本,C# 9.0 引入了带有表达式的*。*在with构造中,任何需要更新的属性都用它们的新值指定,任何没有列出的属性都被精确复制。查看以下示例:

CarRecord ourOtherCar = myCarRecord with {Model = "Odyssey"};
Console.WriteLine("My copied car:");
Console.WriteLine(ourOtherCar.ToString());

Console.WriteLine("Car Record copy using with expression results");
Console.WriteLine($"CarRecords are the same? {ourOtherCar.Equals(myCarRecord)}");
Console.WriteLine($"CarRecords are the same? {ReferenceEquals(ourOtherCar, myCarRecord)}");

代码创建了一个CarRecord类型的新实例,复制了myCarRecord实例的MakeColor值,并将Model设置为字符串Odyssey。此代码的结果如下所示:

/*************** RECORDS *********************/
My copied car:
CarRecord { Make = Honda, Model = Odyssey, Color = Blue }

Car Record copy using with expression results
CarRecords are the same? False
CarRecords are the same? False

使用with表达式,您可以用更新的属性值将记录类型组合成新的记录类型实例。

这就结束了我们对新的 C# 9.0 记录类型的第一次观察。下一章将研究记录类型和继承。

摘要

本章的目的是向你介绍 C# 类类型和新的 C# 9.0 记录类型的作用。如您所见,类可以接受任意数量的构造函数,使对象用户能够在创建时建立对象的状态。本章还举例说明了几种类设计技术(以及相关的关键字)。关键字this可以用来访问当前对象。static关键字允许您定义在类(而不是对象)级别绑定的字段和成员。只有const关键字、readonly修饰符和init设置器允许你定义一个数据点,它在初始赋值或对象构造之后永远不会改变。记录类型是一种特殊类型的类,它是不可变的,当将一个记录类型与同一记录类型的另一个实例进行比较时,其行为类似于值类型。

本章的大部分深入探讨了 OOP 的第一个支柱:封装的细节。您了解了 C# 的访问修饰符、类型属性的作用、对象初始化语法和分部类。有了这些,你现在可以转到下一章,在那里你将学习使用继承和多态来构建一系列相关的类。*

六、理解继承和多态

第五章研究了 OOP 的第一个支柱:封装。那时,您学习了如何用构造函数和各种成员(字段、属性、方法、常量和只读字段)构建一个定义良好的类类型。本章将关注 OOP 的其余两个支柱:继承和多态。

首先,您将学习如何使用继承构建相关类的家族。正如您将看到的,这种形式的代码重用允许您在父类中定义公共功能,这些功能可以被子类利用,也可能被子类修改。在这个过程中,您将学习如何使用虚拟和抽象成员建立一个进入类层次结构的多态接口,以及显式转换的角色。

本章将通过研究。NET 基础类库:System.Object

理解继承的基本机制

回想一下第五章中的内容,继承是 OOP 的一个方面,有助于代码重用。具体来说,代码重用有两种风格:继承(“is-a”关系)和包容/委托模型(“has-a”关系)。让我们从检查“是-a”关系的经典继承模型开始这一章。

当您在类之间建立“is-a”关系时,您正在构建两个或更多类类型之间的依赖关系。经典继承背后的基本思想是,可以使用现有的类作为起点来创建新的类。从一个简单的例子开始,创建一个名为BasicInheritance.的新控制台应用项目。现在假设您已经设计了一个名为Car的类,它模拟了汽车的一些基本细节。

namespace BasicInheritance
{
  // A simple base class.
  class Car
  {
    public readonly int MaxSpeed;
    private int _currSpeed;

    public Car(int max)
    {
      MaxSpeed = max;
    }
    public Car()
    {
      MaxSpeed = 55;
    }
    public int Speed
    {
      get { return _currSpeed; }
      set
      {
        _currSpeed = value;
        if (_currSpeed > MaxSpeed)
        {
          _currSpeed = MaxSpeed;
        }
      }
    }
  }
}

注意,Car类使用封装服务来控制对私有currSpeed字段的访问,该字段使用一个名为Speed的公共属性。此时,你可以锻炼你的Car类型如下:

using System;
using BasicInheritance;

Console.WriteLine("***** Basic Inheritance *****\n");
// Make a Car object, set max speed and current speed.
Car myCar = new Car(80) {Speed = 50};

// Print current speed.
Console.WriteLine("My car is going {0} MPH", myCar.Speed);
Console.ReadLine();

指定现有类的父类

现在假设您想要构建一个名为MiniVan的新类。像基本的Car一样,您希望定义MiniVan类来支持最大速度、当前速度和名为Speed的属性的数据,以允许对象用户修改对象的状态。显然,CarMiniVan类是相关的;其实可以这么说,aMiniVan就是-a 型的Car。“is-a”关系(正式名称为经典继承)允许您构建扩展现有类功能的新类定义。

将作为新类基础的现有类被称为基类超类父类。基类的作用是为扩展它的类定义所有公共数据和成员。扩展类被正式称为派生子类。在 C# 中,在类定义上使用冒号操作符来建立类之间的“is-a”关系。假设您已经编写了以下新的MiniVan类:

namespace BasicInheritance
{
  // MiniVan "is-a" Car.
  sealed class MiniVan : Car
  {
  }
}

目前,这个新类还没有定义任何成员。那么,从Car基类扩展MiniVan你得到了什么?简单地说,MiniVan对象现在可以访问父类中定义的每个公共成员。

Note

尽管构造函数通常被定义为公共的,但派生类从不继承父类的构造函数。构造函数仅用于构造定义它们的类,尽管派生类可以通过构造函数链调用它们。这将很快涉及到。

给定这两个类类型之间的关系,您现在可以像这样使用MiniVan类:

Console.WriteLine("***** Basic Inheritance *****\n");
.
// Now make a MiniVan object.
MiniVan myVan = new MiniVan {Speed = 10};
Console.WriteLine("My van is going {0} MPH", myVan.Speed);
Console.ReadLine();

同样,请注意,尽管您没有向MiniVan类添加任何成员,但是您可以直接访问父类的公共Speed属性,因此可以重用代码。这比创建一个与Car有相同成员的MiniVan类,比如一个Speed属性,要好得多。如果您确实在这两个类之间复制了代码,那么您现在需要维护两个代码体,这无疑是对您时间的浪费。

永远记住,继承保持封装;因此,下面的代码会导致编译器错误,因为私有成员永远不能从对象引用中访问:

Console.WriteLine("***** Basic Inheritance *****\n");
...
// Make a MiniVan object.
MiniVan myVan = new MiniVan();
myVan.Speed = 10;
Console.WriteLine("My van is going {0} MPH",
  myVan.Speed);

// Error! Can't access private members!
myVan._currSpeed = 55;
Console.ReadLine();

与此相关,如果MiniVan定义了自己的成员集,它仍然不能访问Car基类的任何私有成员。记住,私有成员只能被定义它的类访问*。例如,MiniVan中的以下方法会导致编译器错误:*

// MiniVan derives from Car.
class MiniVan : Car
{
  public void TestMethod()
  {
    // OK! Can access public members
    // of a parent within a derived type.
    Speed = 10;

    // Error! Cannot access private
    // members of parent within a derived type.
    _currSpeed = 10;
  }
}

关于多个基类

说到基类,重要的是要记住 C# 要求一个给定的类只有一个直接基类。不可能创建直接从两个或更多基类派生的类类型(这种技术在非托管 C++中受支持,被称为多重继承,或简称为 MI )。如果您试图创建一个指定两个直接父类的类,如下面的代码所示,您将收到编译器错误:

// Illegal! C# does not allow
// multiple inheritance for classes!
class WontWork
  : BaseClassOne, BaseClassTwo
{}

正如您将在第八章中看到的。NET 核心平台允许给定的类或结构实现任意数量的离散接口。通过这种方式,C# 类型可以展示许多行为,同时避免与 MI 相关的复杂性。使用这种技术,你可以构建复杂的接口层次来模拟复杂的行为(同样,参见第八章)。

使用 sealed 关键字

C# 提供了另一个关键字sealed,它阻止了继承的发生。当你将一个类标记为sealed时,编译器不允许你从这个类型派生。例如,假设您已经决定进一步扩展MiniVan类是没有意义的。

// The MiniVan class cannot be extended!
sealed class MiniVan : Car
{
}

如果您(或您的队友)试图从这个类派生,您将会收到一个编译时错误。

// Error! Cannot extend
// a class marked with the sealed keyword!
class DeluxeMiniVan
  : MiniVan
{
}

大多数情况下,在设计实用程序类时,密封一个类是最有意义的。例如,System名称空间定义了许多密封类,比如String类。因此,就像MiniVan一样,如果您试图构建一个扩展了System.String的新类,您将会收到一个编译时错误。

// Another error! Cannot extend
// a class marked as sealed!
class MyString
  : String
{
}

Note

在第四章中,你学到了 C# 结构总是隐式密封的(见表 4-3 )。因此,您永远不能从另一个结构派生一个结构,从一个结构派生一个类,或者从一个类派生一个结构。结构只能用于建模独立的、原子的、用户定义的数据类型。如果你想利用“是-a”关系,你必须使用类。

正如您所猜测的,在本章的剩余部分,您将会了解到更多关于继承的细节。现在,只要记住冒号操作符允许您建立基类/派生类关系,而sealed关键字防止后续继承发生。

重温 Visual Studio 类图

在第二章中,我简要提到了 Visual Studio 允许你在设计时可视化地建立基类/派生类关系。为了利用 IDE 的这一方面,第一步是在当前项目中包含一个新的类图文件。为此,访问项目➤添加新项菜单选项,并单击类图图标(在图 6-1 ,我将文件从ClassDiagram1.cd重命名为Cars.cd)。

img/340876_10_En_6_Fig1_HTML.jpg

图 6-1。

插入新的类图

单击“添加”按钮后,您将看到一个空白的设计器图面。若要向类设计器添加类型,只需将每个文件从解决方案资源管理器窗口拖到图面上。还记得,如果您从可视化设计器中删除一个项(只需选择它并按 delete 键),这不会破坏关联的源代码,而只是将该项从设计器图面中移除。图 6-2 显示了当前的等级结构。

img/340876_10_En_6_Fig2_HTML.jpg

图 6-2。

Visual Studio 的视觉设计器

除了简单地显示当前应用中类型的关系之外,回想一下第二章中的内容,您还可以使用类设计器工具箱和类细节窗口创建新类型并填充它们的成员。

如果您想在本书的剩余部分使用这些可视化工具,请随意。但是,一定要确保您分析了生成的代码,以便您对这些工具为您做了什么有一个坚实的理解。

理解 OOP 的第二个支柱:继承的细节

既然您已经看到了继承的基本语法,让我们创建一个更复杂的例子,并了解构建类层次结构的众多细节。为此,你将重用你在第五章中设计的Employee类。首先,创建一个名为 Employees 的新 C# 控制台应用项目。

接下来,将您在第五章的 EmployeeApp 示例中创建的Employee.csEmployee.Core.csEmployeePayTypeEnum.cs文件复制到 Employees 项目中。

Note

之前。NET Core 中,需要在.csproj文件中引用的文件才能在 C# 项目中使用它们。与。NET Core 中,当前目录结构中的所有文件都会自动包含到您的项目中。只需将这两个文件从另一个项目复制到当前项目目录中,就足以将它们包含在您的项目中。

在开始构建一些派生类之前,有两个细节需要注意。因为最初的Employee类是在一个名为 EmployeeApp 的项目中创建的,所以该类被包装在一个同名的。NET 核心命名空间。第十六章将详细考察名称空间;然而,为了简单起见,将当前名称空间(在所有三个文件位置中)重命名为Employees,以匹配您的新项目名称。

// Be sure to change the namespace name in both C# files!
namespace Employees
{
  partial class Employee
  {...}
}

Note

如果你在第五章中修改Employee类的时候移除了默认构造函数,确保把它添加回类中。

第二个细节是从章节 5 示例的Employee类的不同迭代中移除任何注释代码。

Note

作为健全性检查,编译并运行您的新项目,方法是在命令提示符下(在您的项目目录中)输入dotnet run,或者如果您使用的是 Visual Studio,则按 Ctrl+F5。程序此时不会做任何事情;但是,这将确保您没有任何编译器错误。

您的目标是创建一系列类来模拟公司中各种类型的员工。假设您想要利用Employee类的功能来创建两个新类(SalesPersonManager)。新的SalesPerson级“is-an”Employee(as is aManager)。请记住,在经典继承模型下,基类(如Employee)用于定义所有后代共有的一般特征。子类(比如SalesPersonManager)扩展了这个通用功能,同时增加了更多的特定功能。

对于您的示例,您将假设Manager类通过记录股票期权的数量来扩展Employee,而SalesPerson类维护销售的数量。插入一个新的类文件(Manager.cs),该文件用以下自动属性定义了Manager类:

// Managers need to know their number of stock options.
class Manager : Employee
{
  public int StockOptions { get; set; }
}

接下来,添加另一个新的类文件(SalesPerson.cs),该文件使用拟合自动属性定义了SalesPerson类。

// Salespeople need to know their number of sales.
class SalesPerson : Employee
{
  public int SalesNumber { get; set; }
}

既然已经建立了“是-a”关系,SalesPersonManager已经自动继承了Employee基类的所有公共成员。举例来说,按如下方式更新顶级语句:

// Create a subclass object and access base class functionality.
Console.WriteLine("***** The Employee Class Hierarchy *****\n");
SalesPerson fred = new SalesPerson
{
  Age = 31, Name = "Fred", SalesNumber = 50
};

用 Base 关键字调用基类构造函数

目前,SalesPersonManager只能使用“免费”的默认构造函数来创建(参见第五章)。记住这一点,假设您已经向Manager类型添加了一个新的七参数构造函数,调用如下:

...
// Assume Manager has a constructor matching this signature:
// (string fullName, int age, int empId,
// float currPay, string ssn, int numbOfOpts)
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);

如果你看一下参数列表,你可以清楚地看到这些参数大部分应该存储在由Employee基类定义的成员变量中。为此,您可以在Manager类上实现这个自定义构造函数,如下所示:

public Manager(string fullName, int age, int empId,
               float currPay, string ssn, int numbOfOpts)
{
  // This property is defined by the Manager class.
  StockOptions = numbOfOpts;

  // Assign incoming parameters using the
  // inherited properties of the parent class.
  Id = empId;
  Age = age;
  Name = fullName;
  Pay = currPay;
  PayType = EmployeePayTypeEnum.Salaried;
  // OOPS! This would be a compiler error,
  // if the SSN property were read-only!
  SocialSecurityNumber = ssn;
}

这种方法的第一个问题是,如果您将任何属性定义为只读(例如,SocialSecurityNumber属性),您就不能将传入的string参数赋给这个字段,如这个自定义构造函数的最终代码语句所示。

第二个问题是,你已经间接地创建了一个相当低效的构造函数,假设在 C# 下,除非你另有说明,否则基类的默认构造函数是在派生构造函数的逻辑被执行之前自动调用的。在这之后,当前的实现访问Employee基类的许多公共属性来建立它的状态。因此,在创建一个Manager对象的过程中,您实际上已经完成了八次点击(六次继承属性和两次构造函数调用)!

为了帮助优化派生类的创建,最好实现子类构造函数来显式调用适当的自定义基类构造函数,而不是默认构造函数。这样,您就能够减少对继承的初始化成员的调用次数(从而节省处理时间)。首先,确保您的Employee父类具有以下六个参数的构造函数:

// Add to the Employee base class.
public Employee(string name, int age, int id, float pay, string empSsn, EmployeePayTypeEnum payType)
{
  Name = name;
  Id = id;
  Age = age;
  Pay = pay;
  SocialSecurityNumber = empSsn;
  PayType = payType;
}

现在,让我们改进Manager类型的定制构造函数,使用base关键字调用这个构造函数。

public Manager(string fullName, int age, int empId,
  float currPay, string ssn, int numbOfOpts)
  : base(fullName, age, empId, currPay, ssn,
         EmployeePayTypeEnum.Salaried)
{
  // This property is defined by the Manager class.
  StockOptions = numbOfOpts;
}

这里,base关键字挂在构造函数签名上(很像在第五章中讨论的使用this关键字链接单个类上的构造函数的语法),它总是指示派生构造函数正在将数据传递给直接的父构造函数。在这种情况下,您显式地调用了由Employee定义的六参数构造函数,并在创建子类的过程中节省了不必要的调用。此外,您向Manager类添加了一个特定的行为,因为支付类型总是被设置为Salaried.,自定义的SalesPerson构造函数看起来几乎相同,除了支付类型被设置为Commission.

// As a general rule, all subclasses should explicitly call an appropriate
// base class constructor.
public SalesPerson(string fullName, int age, int empId,
  float currPay, string ssn, int numbOfSales)
  : base(fullName, age, empId, currPay, ssn,
         EmployeePayTypeEnum.Commission)
{
  // This belongs with us!
  SalesNumber = numbOfSales;
}

Note

每当子类想要访问由父类定义的公共或受保护成员时,可以使用base关键字。此关键字的使用不限于构造函数逻辑。在本章后面的多态性检查中,你会看到以这种方式使用base的例子。

最后,回想一下,一旦您将自定义构造函数添加到类定义中,默认构造函数就会被自动移除。因此,一定要为SalesPersonManager类型重新定义默认构造函数。这里有一个例子:

// Add back the default ctor
// in the Manager class as well.
public SalesPerson() {}

保守家庭秘密:受保护的关键字

正如您已经知道的,公共项可以从任何地方直接访问,而私有项只能由定义它们的类访问。回想一下第五章中的内容,C# 领先于许多其他现代对象语言,并提供了一个额外的关键字来定义成员可访问性:protected

当基类定义受保护的数据或受保护的成员时,它建立了一组可以被任何后代直接访问的项。如果你想让SalesPersonManager子类直接访问由Employee定义的数据扇区,你可以如下更新原始的Employee类定义(在EmployeeCore.cs文件中):

// Protected state data.
partial class Employee
{
  // Derived classes can now directly access this information.
  protected string EmpName;
  protected int EmpId;
  protected float CurrPay;
  protected int EmpAge;
  protected string EmpSsn;
  protected EmployeePayTypeEnum EmpPayType;...
}

Note

约定是受保护的成员被命名为 Pascal-Case(EmpName)而不是下划线-Camel-Case ( _empName)。这不是语言的要求,而是一种常见的代码风格。如果您决定像我在这里所做的那样更新名称,请确保重命名属性中的所有支持方法,以匹配 Pascal 大小写受保护的属性。

在基类中定义受保护成员的好处是,派生类型不再需要使用公共方法或属性间接访问数据。当然,可能的问题是,当派生类型可以直接访问其父类型的内部数据时,就有可能意外地绕过公共属性中的现有业务规则。当您定义受保护成员时,您在父类和子类之间创建了一个信任级别,因为编译器不会捕捉到任何违反您的类型的业务规则的情况。

最后,请理解,就对象用户而言,受保护的数据被视为私有数据(因为用户“不属于”家庭)。因此,以下行为是非法的:

// Error! Can't access protected data from client code.
Employee emp = new Employee();
emp.empName = "Fred";

Note

虽然protected字段数据可以打破封装,但是定义protected方法是非常安全的(也是非常有用的)。在构建类层次结构时,通常定义一组只供派生类型使用的方法,而不是供外界使用的方法。

添加密封类

回想一下,一个密封的类不能被其他类扩展。如上所述,这种技术最常用于设计实用程序类。然而,当构建类层次结构时,您可能会发现继承链中的某个分支应该被“封顶”,因为进一步扩展血统是没有意义的。例如,假设您已经向您的程序(PtSalesPerson)添加了另一个类,它扩展了现有的SalesPerson类型。图 6-3 显示了当前的更新。

img/340876_10_En_6_Fig3_HTML.jpg

图 6-3。

PtSalesPerson 类

是一个代表兼职销售人员的类。为了便于讨论,假设您希望确保没有其他开发人员能够从PTSalesPerson继承子类。为了防止其他人扩展一个类,使用sealed关键字。

sealed class PtSalesPerson : SalesPerson
{
  public PtSalesPerson(string fullName, int age, int empId,
    float currPay, string ssn, int numbOfSales)
    : base(fullName, age, empId, currPay, ssn, numbOfSales)
  {
  }
  // Assume other members here...
}

了解记录类型的继承(新 9.0)

新的 C# 9.0 记录类型也支持继承。要探索这一点,请暂停 Employees 项目中的工作,并创建一个名为 RecordInheritance 的新控制台应用。添加两个名为Car.csMiniVan.cs,的新文件,并将以下记录定义代码添加到各自的文件中:

//Car.cs
namespace RecordInheritance
{
  //Car record type
  public record Car
  {
    public string Make { get; init; }
    public string Model { get; init; }
    public string Color { get; init; }

    public Car(string make, string model, string color)
    {
      Make = make;
      Model = model;
      Color = color;
    }
  }
}

//MiniVan.cs
namespace RecordInheritance
{
    //MiniVan record type
    public sealed record MiniVan : Car
    {
        public int Seating { get; init; }
        public MiniVan(string make, string model, string color, int seating) : base(make, model, color)
        {
            Seating = seating;
        }
    }
}

注意,这些使用记录类型的例子和前面使用类的例子没有太大的区别。属性和方法上的受保护访问修饰符行为相同,记录类型上的密封访问修饰符防止其他记录类型从密封记录类型派生。您还会发现本章的其余主题也与继承的记录类型有关。这是因为记录类型只是不可变类的一种特殊类型(详见第五章)。

记录类型还包括对其基类的隐式转换,如下面的代码所示:

using System;
using RecordInheritance;

Console.WriteLine("Record type inheritance!");

Car c = new Car("Honda","Pilot","Blue");
MiniVan m = new MiniVan("Honda", "Pilot", "Blue",10);
Console.WriteLine($"Checking MiniVan is-a Car:{m is Car}");

正如所料,检查m的输出是Car返回 true,如下面的输出所示:

Record type inheritance!
Checking minvan is-a car:True

重要的是要注意,即使记录类型是专门的类,也不能在类和记录之间交叉继承。明确地说,类不能从记录类型继承,记录类型也不能从类继承。考虑下面的代码,注意最后两个例子不能编译:

namespace RecordInheritance
{
  public class TestClass { }
  public record TestRecord { }

  //Classes cannot inherit records
  // public class Test2 : TestRecord { }

  //Records types cannot inherit from classes
  // public record Test2 : TestClass {  }
}

继承也适用于位置记录类型。在项目中创建一个名为PositionalRecordTypes.cs的新文件。将以下代码添加到您的文件中:

namespace RecordInheritance
{
  public record PositionalCar (string Make, string Model, string Color);
  public record PositionalMiniVan (string Make, string Model, string Color)
    : PositionalCar(Make, Model, Color);
}

添加以下代码,以说明您已经知道的事实,即位置记录类型的工作方式与记录类型完全相同:

PositionalCar pc = new PositionalCar("Honda", "Pilot", "Blue");
PositionalMiniVan pm = new PositionalMiniVan("Honda", "Pilot", "Blue", 10);
Console.WriteLine($"Checking PositionalMiniVan is-a PositionalCar:{pm is PositionalCar}");

与继承的记录类型相等

回想一下第五章,记录类型使用值语义来确定相等性。关于记录类型的另一个细节是记录的类型是平等考虑的一部分。例如,考虑以下平凡的例子:

public record MotorCycle(string Make, string Model);
public record Scooter(string Make, string Model) : MotorCycle(Make,Model);

忽略通常继承的类扩展基类的事实,这些简单的例子定义了具有相同属性的两种不同的记录类型。当创建属性值相同的实例时,由于类型不同,它们无法通过相等性测试。以下面的代码和结果为例:

MotorCycle mc = new MotorCycle("Harley","Lowrider");
Scooter sc = new Scooter("Harley", "Lowrider");
Console.WriteLine($"MotorCycle and Scooter are equal: {Equals(mc,sc)}");

Record type inheritance!
MotorCycle and Scooter are equal: False

包容/委托的编程

回想一下,代码重用有两种形式。你刚刚探索了经典的“是”的关系。在检查 OOP 的第三个支柱(多态性)之前,让我们检查一下“has-a”关系(也称为包容/委托模型聚合)。返回到 Employees 项目,创建一个名为BenefitPackage.cs的新文件,并添加代码来模拟雇员福利包,如下所示:

namespace Employees
{
  // This new type will function as a contained class.
  class BenefitPackage
  {
    // Assume we have other members that represent
    // dental/health benefits, and so on.
    public double ComputePayDeduction()
    {
      return 125.0;
    }
  }
}

显然,在BenefitPackage类和雇员类型之间建立“is-a”关系是很奇怪的。(Employee“is-a”BenefitPackage?我不这么认为。)然而,应该清楚的是,可以在两者之间建立某种关系。简而言之,你想表达的想法是,每个员工都“有-a”BenefitPackage。为此,您可以如下更新Employee类定义:

// Employees now have benefits.
partial class Employee
{
  // Contain a BenefitPackage object.
  protected BenefitPackage EmpBenefits = new BenefitPackage();
...
}

至此,您已经成功地包含了另一个对象。但是,向外界公开所包含对象的功能需要委托。委托是简单地将公共成员添加到使用被包含对象功能的包含类的行为。

例如,您可以更新Employee类,使用自定义属性公开包含的empBenefits对象,并使用名为GetBenefitCost()的新方法在内部使用其功能。

partial class Employee
{
  // Contain a BenefitPackage object.
  protected BenefitPackage EmpBenefits = new BenefitPackage();

  // Expose certain benefit behaviors of object.
  public double GetBenefitCost()
     => EmpBenefits.ComputePayDeduction();

  // Expose object through a custom property.
  public BenefitPackage Benefits
  {
    get { return EmpBenefits; }
    set { EmpBenefits = value; }
  }
}

在下面更新的code中,注意如何与由Employee类型定义的内部BenefitsPackage类型交互:

Console.WriteLine("***** The Employee Class Hierarchy *****\n");
...
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
double cost = chucky.GetBenefitCost();
Console.WriteLine($"Benefit Cost: {cost}");
Console.ReadLine();

了解嵌套类型定义

第五章简要提到了嵌套类型的概念,这是对你刚刚检查过的“has-a”关系的一个改进。在 C#(以及其他。NET 语言),可以直接在类或结构的范围内定义类型(枚举、类、接口、结构或委托)。当您这样做时,嵌套(或“内部”)类型被视为嵌套(或“外部”)类的成员,并且在运行时看来,可以像任何其他成员(字段、属性、方法和事件)一样进行操作。用于嵌套类型的语法非常简单。

public class OuterClass
{
  // A public nested type can be used by anybody.
  public class PublicInnerClass {}

  // A private nested type can only be used by members
  // of the containing class.
  private class PrivateInnerClass {}
}

尽管语法相当清楚,但理解您为什么想要这样做可能并不容易。要理解这种技术,请思考嵌套类型的以下特征:

  • 嵌套类型允许您完全控制内部类型的访问级别,因为它们可以被私有声明(回想一下,非嵌套类不能使用private关键字声明)。

  • 因为嵌套类型是包含类的成员,所以它可以访问包含类的私有成员。

  • 通常,嵌套类型只在作为外部类的助手时有用,并不打算供外部世界使用。

当一个类型嵌套另一个类类型时,它可以创建该类型的成员变量,就像对任何数据点一样。但是,如果要使用包含类型之外的嵌套类型,必须用嵌套类型的范围来限定它。考虑以下代码:

// Create and use the public inner class. OK!
OuterClass.PublicInnerClass inner;
inner = new OuterClass.PublicInnerClass();

// Compiler Error! Cannot access the private class.
OuterClass.PrivateInnerClass inner2;
inner2 = new OuterClass.PrivateInnerClass();

为了在雇员的例子中使用这个概念,假设您现在已经将BenefitPackage直接嵌套在了Employee类类型中。

partial class Employee
{
  public class BenefitPackage
  {
    // Assume we have other members that represent
    // dental/health benefits, and so on.
    public double ComputePayDeduction()
    {
      return 125.0;
    }
  }
...
}

嵌套过程可以像你要求的那样“深”。例如,假设您想要创建一个名为BenefitPackageLevel的枚举,它记录了员工可能选择的各种福利级别。为了以编程方式强制EmployeeBenefitPackageBenefitPackageLevel之间的紧密连接,可以如下嵌套枚举:

// Employee nests BenefitPackage.
public partial class Employee
{
  // BenefitPackage nests BenefitPackageLevel.
  public class BenefitPackage
  {
    public enum BenefitPackageLevel
    {
      Standard, Gold, Platinum
    }

    public double ComputePayDeduction()
    {
      return 125.0;
    }
  }
...
}

由于嵌套关系,请注意如何要求您使用此枚举:

...
// Define my benefit level.
Employee.BenefitPackage.BenefitPackageLevel myBenefitLevel =
    Employee.BenefitPackage.BenefitPackageLevel.Platinum;

太棒了!至此,您已经接触了许多关键字(和概念),它们允许您通过传统的继承、包容和嵌套类型来构建相关类型的层次结构。如果细节现在还不清楚,不要担心。在本书的剩余部分,您将构建一些额外的层次结构。接下来,让我们检查 OOP 的最后一个支柱:多态性。

理解 OOP 的第三个支柱:C# 的多态支持

回想一下,Employee基类定义了一个名为GiveBonus()的方法,最初实现如下(在更新它以使用属性模式之前):

public partial class Employee
{
  public void GiveBonus(float amount) => _currPay += amount;
...
}

因为这个方法是用public关键字定义的,所以现在可以给销售人员和经理(以及兼职销售人员)发放奖金。

Console.WriteLine("***** The Employee Class Hierarchy *****\n");

// Give each employee a bonus?
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
chucky.GiveBonus(300);
chucky.DisplayStats();
Console.WriteLine();

SalesPerson fran = new SalesPerson("Fran", 43, 93, 3000, "932-32-3232", 31);
fran.GiveBonus(200);
fran.DisplayStats();
Console.ReadLine();

当前设计的问题是,公共继承的GiveBonus()方法对所有子类的操作都是一样的。理想情况下,销售人员或兼职销售人员的奖金应该考虑销售数量。或许经理们应该获得额外的股票期权,同时增加工资。考虑到这一点,您突然面临一个有趣的问题:“相关类型如何对同一请求做出不同的响应?”再次,很高兴你问了!

使用虚拟和覆盖关键字

多态性为子类提供了一种方法,通过使用称为方法覆盖的过程,来定义其基类定义的方法的自己的版本。要改进您当前的设计,您需要理解virtualoverride关键字的含义。如果一个基类想要定义一个方法,使得可以被子类(但不是必须)覆盖,它必须用virtual关键字标记这个方法。

partial class Employee
{
  // This method can now be "overridden" by a derived class.
  public virtual void GiveBonus(float amount)
  {
    Pay += amount;
  }
...
}

Note

标有virtual关键字的方法(不足为奇)被称为虚拟方法

当子类想要改变虚拟方法的实现细节时,它使用关键字override来实现。例如,SalesPersonManager可以如下覆盖GiveBonus()(假设PTSalesPerson不会覆盖GiveBonus(),因此,简单地继承由SalesPerson定义的版本):

using System;
class SalesPerson : Employee
{
...
  // A salesperson's bonus is influenced by the number of sales.
  public override void GiveBonus(float amount)
  {
    int salesBonus = 0;
    if (SalesNumber >= 0 && SalesNumber <= 100)
      salesBonus = 10;
    else
    {
      if (SalesNumber >= 101 && SalesNumber <= 200)
        salesBonus = 15;
      else
        salesBonus = 20;
    }
    base.GiveBonus(amount * salesBonus);
  }
}

class Manager : Employee
{
...
  public override void GiveBonus(float amount)
  {
    base.GiveBonus(amount);
    Random r = new Random();
    StockOptions += r.Next(500);
  }
}

注意每个被覆盖的方法是如何使用base关键字自由利用默认行为的。

这样,您不需要完全重新实现GiveBonus()背后的逻辑,而是可以重用(并且可能扩展)父类的默认行为。

还假设Employee类的当前DisplayStats()方法已经被虚拟声明。

public virtual void DisplayStats()
{
    Console.WriteLine("Name: {0}", Name);
    Console.WriteLine("Id: {0}", Id);
    Console.WriteLine("Age: {0}", Age);
    Console.WriteLine("Pay: {0}", Pay);
    Console.WriteLine("SSN: {0}", SocialSecurityNumber);
}

通过这样做,每个子类都可以覆盖这个方法来显示销售额(对于销售人员)和当前股票期权(对于经理)。例如,考虑一下ManagerDisplayStats()方法版本(SalesPerson类将以类似的方式实现DisplayStats()来显示销售额)。

//Manager.cs
public override void DisplayStats()
{
  base.DisplayStats();
  Console.WriteLine("Number of Stock Options: {0}", StockOptions);
}
//SalesPerson.cs
public override void DisplayStats()
{
  base.DisplayStats();
  Console.WriteLine("Number of Sales: {0}", SalesNumber);
}

现在每个子类都可以解释这些虚方法对自己的意义,每个对象实例都表现为一个更加独立的实体。

Console.WriteLine("***** The Employee Class Hierarchy *****\n");

// A better bonus system!
Manager chucky = new Manager("Chucky", 50, 92, 100000, "333-23-2322", 9000);
chucky.GiveBonus(300);
chucky.DisplayStats();
Console.WriteLine();

SalesPerson fran = new SalesPerson("Fran", 43, 93, 3000, "932-32-3232", 31);
fran.GiveBonus(200);
fran.DisplayStats();
Console.ReadLine();

以下输出显示了到目前为止您的应用可能的测试运行:

***** The Employee Class Hierarchy *****
Name: Chucky
ID: 92
Age: 50
Pay: 100300
SSN: 333-23-2322
Number of Stock Options: 9337

Name: Fran
ID: 93
Age: 43
Pay: 5000
SSN: 932-32-3232
Number of Sales: 31

用 Visual Studio/Visual Studio 代码重写虚拟成员

您可能已经注意到,当您重写一个成员时,您必须回忆每个参数的类型——更不用说方法名和参数传递约定(refoutparams)。Visual Studio 和 Visual Studio 代码都有一个有用的功能,您可以在重写虚拟成员时加以利用。如果在类类型的范围内键入单词override(然后按空格键),IntelliSense 将自动显示在父类中定义的所有可重写成员的列表,不包括已经被重写的方法。

当您选择一个成员并按下 Enter 键时,IDE 会自动为您填充方法存根。请注意,您还会收到一条代码语句,该语句调用您的父版本的虚拟成员(如果不需要,您可以随意删除这一行)。例如,如果您在重写DisplayStats()方法时使用了这种技术,您可能会发现以下自动生成的代码:

public override void DisplayStats()
{
  base.DisplayStats();
}

密封虚拟成员

回想一下,sealed关键字可以应用于一个类类型,以防止其他类型通过继承来扩展它的行为。您可能还记得,您密封了PtSalesPerson,因为您认为其他开发人员进一步扩展这条继承线是没有意义的。

另一方面,有时您可能不想密封整个类,而只想防止派生类型重写特定的虚方法。例如,假设您不希望兼职销售人员获得定制的奖金。为了防止PTSalesPerson类覆盖虚拟的GiveBonus()方法,您可以有效地将该方法密封在SalesPerson类中,如下所示:

// SalesPerson has sealed the GiveBonus() method!
class SalesPerson : Employee
{
...
  public override sealed void GiveBonus(float amount)
  {
    ...
  }
}

这里,SalesPerson确实覆盖了在Employee类中定义的虚拟GiveBonus()方法;但是,它已经明确标记为密封。因此,如果您试图在PtSalesPerson类中覆盖此方法,您将会收到编译时错误,如以下代码所示:

sealed class PTSalesPerson : SalesPerson
{
...
  // Compiler error! Can't override this method
  // in the PTSalesPerson class, as it was sealed.
  public override void GiveBonus(float amount)
  {
  }
}

理解抽象类

目前,Employee基类已经被设计为向它的后代提供各种数据成员,以及提供两个可能被给定后代覆盖的虚方法(GiveBonus()DisplayStats())。虽然这一切都很好,但目前的设计有一个相当奇怪的副产品;您可以直接创建Employee基类的实例。

// What exactly does this mean?
Employee X = new Employee();

在这个例子中,Employee基类的唯一真正目的是为所有子类定义公共成员。十有八九,你不希望任何人创建这个类的直接实例,原因是Employee类型本身是一个过于一般化的概念。例如,如果我走到你面前说“我是一名员工”,我敢打赌你的第一个问题会是“你是哪种员工?你是顾问、培训师、行政助理、文字编辑还是白宫助理?”

鉴于许多基类往往是相当模糊的实体,对于这个例子来说,更好的设计是防止在代码中直接创建新的Employee对象。在 C# 中,您可以通过在类定义中使用abstract关键字来以编程方式强制实现这一点,从而创建一个抽象基类

// Update the Employee class as abstract
// to prevent direct instantiation.
abstract partial class Employee
{
  ...
}

这样,如果您现在试图创建一个Employee类的实例,就会出现一个编译时错误。

// Error! Cannot create an instance of an abstract class!
Employee X = new Employee();

乍一看,定义一个不能直接创建实例的类似乎很奇怪。然而,回想一下,基类(抽象或非抽象)是有用的,因为它们包含了派生类型的所有公共数据和功能。使用这种形式的抽象,你能够模拟一个雇员的“想法”是完全有效的;它只是不是一个具体的实体。还要明白,虽然你不能直接创建一个抽象类的实例,但是当派生类被创建时,它仍然在内存中被组装。因此,当派生类被分配时,抽象类定义任意数量的被间接调用的构造函数是非常好的(也是常见的)。**

至此,您已经构建了一个相当有趣的员工层次结构。在本章的后面,当你研究 C# 转换规则的时候,你会给这个应用添加更多的功能。在此之前,图 6-4 说明了您当前设计的症结所在。

img/340876_10_En_6_Fig4_HTML.jpg

图 6-4。

员工层级

理解多态接口

当一个类被定义为抽象基类时(通过abstract关键字),它可以定义任意数量的抽象成员。当你想定义一个提供默认实现,但是必须由每个派生类负责的成员时,可以使用抽象成员。通过这样做,您在每个后代上实施了一个多态接口,让他们去处理提供抽象方法背后的细节的任务。

简单来说,抽象基类的多态接口只是指它的一组虚拟和抽象方法。这比第一眼看到的要有趣得多,因为 OOP 的这一特性允许您构建易于扩展和灵活的软件应用。举例来说,在 OOP 支柱概述中,你将实现(并稍微修改)第五章中简要介绍的形状层次。首先,创建一个名为 Shapes 的新 C# 控制台应用项目。

在图 6-5 中,注意到HexagonCircle类型都扩展了Shape基类。像任何基类一样,Shape定义了许多成员(在本例中是一个PetName属性和一个Draw()方法),这些成员是所有后代共有的。

img/340876_10_En_6_Fig5_HTML.jpg

图 6-5。

形状层次结构

与雇员层次结构非常相似,您应该能够判断出您不希望允许对象用户直接创建Shape的实例,因为它是一个太抽象的概念。同样,为了防止直接创建Shape类型,您可以将其定义为一个抽象类。同样,假设您希望派生类型唯一地响应Draw()方法,让我们将其标记为virtual并定义一个默认实现。请注意,构造函数被标记为 protected,因此它只能从派生类中调用。

// The abstract base class of the hierarchy.
abstract class Shape
{
  protected Shape(string name = "NoName")
  { PetName = name; }

  public string PetName { get; set; }

  // A single virtual method.
  public virtual void Draw()
  {
    Console.WriteLine("Inside Shape.Draw()");
  }
}

注意,虚拟的Draw()方法提供了一个默认的实现,它只是打印出一条消息,通知您正在调用Shape基类中的Draw()方法。现在回想一下,当一个方法用virtual关键字标记时,该方法提供了一个所有派生类型自动继承的默认实现。如果子类这样选择,它可以覆盖该方法,但是没有来覆盖该方法。鉴于此,考虑下面的CircleHexagon类型的实现:

// Circle DOES NOT override Draw().
class Circle : Shape
{
  public Circle() {}
  public Circle(string name) : base(name){}
}

// Hexagon DOES override Draw().
class Hexagon : Shape
{
  public Hexagon() {}
  public Hexagon(string name) : base(name){}
  public override void Draw()
  {
    Console.WriteLine("Drawing {0} the Hexagon", PetName);
  }
}

当你再次记住子类从来不需要覆盖虚方法时,抽象方法的用处就变得非常清楚了(就像在Circle的例子中一样)。因此,如果您创建一个HexagonCircle类型的实例,您会发现Hexagon知道如何正确地“绘制”自己,或者至少向控制台输出一条适当的消息。然而Circle却不止是有点困惑。

Console.WriteLine("***** Fun with Polymorphism *****\n");

Hexagon hex = new Hexagon("Beth");
hex.Draw();
Circle cir = new Circle("Cindy");
// Calls base class implementation!
cir.Draw();
Console.ReadLine();

现在考虑前面代码的以下输出:

***** Fun with Polymorphism *****
Drawing Beth the Hexagon
Inside Shape.Draw()

很明显,对于当前的等级制度来说,这不是一个明智的设计。为了强制每个子类覆盖Draw()方法,您可以将Draw()定义为Shape类的一个抽象方法,根据定义,这意味着您不提供任何默认实现。在 C# 中,要将一个方法标记为抽象的,可以使用abstract关键字。请注意,抽象成员不提供任何实现。

abstract class Shape
{
  // Force all child classes to define how to be rendered.
  public abstract void Draw();
  ...
}

Note

抽象方法只能在抽象类中定义。如果您尝试不这样做,您将被发出一个编译器错误。

标有abstract的方法是纯协议。它们只是定义名称、返回类型(如果有的话)和参数集(如果需要的话)。这里,抽象的Shape类通知派生的类型“我有一个名为Draw()的方法,它没有参数,也不返回任何东西。如果你从我这里得到,你就能弄清楚细节。”

鉴于此,您现在有义务在Circle类中覆盖Draw()方法。如果不这样做,Circle也被认为是一个不可创建的抽象类型,必须用abstract关键字来修饰(这在本例中显然没有用)。下面是代码更新:

// If we did not implement the abstract Draw() method, Circle would also be
// considered abstract, and would have to be marked abstract!
class Circle : Shape
{
  public Circle() {}
  public Circle(string name) : base(name) {}
  public override void Draw()
  {
    Console.WriteLine("Drawing {0} the Circle", PetName);
  }
}

简而言之,你现在可以假设从Shape派生的任何东西确实有一个唯一版本的Draw()方法。为了说明多态性的全部情况,考虑下面的代码:

Console.WriteLine("***** Fun with Polymorphism *****\n");

// Make an array of Shape-compatible objects.
Shape[] myShapes = {new Hexagon(), new Circle(), new Hexagon("Mick"),
  new Circle("Beth"), new Hexagon("Linda")};

// Loop over each item and interact with the
// polymorphic interface.
foreach (Shape s in myShapes)
{
  s.Draw();
}
Console.ReadLine();

下面是修改后的代码的输出:

***** Fun with Polymorphism *****
Drawing NoName the Hexagon
Drawing NoName the Circle
Drawing Mick the Hexagon
Drawing Beth the Circle
Drawing Linda the Hexagon

这段代码很好地说明了多态性。虽然不能直接创建一个抽象基类的实例(??),但是你可以自由地存储对任何带有抽象基类变量的子类的引用。因此,当您创建一个由Shape组成的数组时,该数组可以保存从Shape基类派生的任何对象(如果您试图将Shape不兼容的对象放入该数组,您会收到一个编译器错误)。

*鉴于myShapes数组中的所有元素确实都是从Shape派生的,你知道它们都支持相同的“多态接口”(或者更直白地说,它们都有一个Draw()方法)。当您迭代Shape引用的数组时,底层类型是在运行时确定的。此时,内存中调用了正确版本的Draw()方法。

这种技术也使得安全地扩展当前层次变得简单。例如,假设您从抽象的Shape基类(TriangleSquare等)派生了更多的类。).由于多态接口,您的foreach循环中的代码不需要做任何改动,因为编译器强制要求只有与Shape兼容的类型才放在myShapes数组中。

了解成员隐藏

C# 提供了一个与方法覆盖逻辑相反的功能,称为隐藏。从形式上讲,如果一个派生类定义了一个与基类中定义的成员相同的成员,那么派生类就隐藏了父类的版本。在现实世界中,当您从一个不是您(或您的团队)自己创建的类中创建子类时(例如当您购买第三方软件包时),这种情况发生的可能性最大。

为了便于说明,假设您从同事(或同学)那里收到一个名为ThreeDCircle的类,该类定义了一个名为Draw()的不带参数的子例程。

class ThreeDCircle
{
  public void Draw()
  {
    Console.WriteLine("Drawing a 3D Circle");
  }
}

你认为ThreeDCircle是-aCircle,所以你从你现有的Circle类型中派生出来。

class ThreeDCircle : Circle
{
  public void Draw()
  {
    Console.WriteLine("Drawing a 3D Circle");
  }
}

重新编译后,您会发现以下警告:

'ThreeDCircle.Draw()' hides inherited member 'Circle.Draw()'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword.

问题是您有一个派生类(ThreeDCircle),它包含一个与继承方法相同的方法。要解决这个问题,您有几个选择。您可以使用override关键字简单地更新Draw()的子版本(正如编译器所建议的)。使用这种方法,ThreeDCircle类型能够根据需要扩展父类的默认行为。但是,如果您没有访问定义基类的代码的权限(这也是许多第三方库中的情况),您将无法作为虚拟成员修改Draw()方法,因为您没有访问代码文件的权限!

作为一种选择,您可以将new关键字包含到派生类型的违规Draw()成员中(在本例中为ThreeDCircle)。这样做明确表明派生类型的实现是有意设计来有效忽略父版本的(同样,在现实世界中,如果外部软件以某种方式与您当前的软件冲突,这可能是有帮助的)。

// This class extends Circle and hides the inherited Draw() method.
class ThreeDCircle : Circle
{
  // Hide any Draw() implementation above me.
  public new void Draw()
  {
    Console.WriteLine("Drawing a 3D Circle");
  }
}

还可以将new关键字应用于从基类继承的任何成员类型(字段、常量、静态成员或属性)。作为进一步的例子,假设ThreeDCircle想要隐藏继承的PetName属性。

class ThreeDCircle : Circle
{
  // Hide the PetName property above me.
  public new string PetName { get; set; }

  // Hide any Draw() implementation above me.
  public new void Draw()
  {
    Console.WriteLine("Drawing a 3D Circle");
  }
}

最后,请注意,仍有可能使用显式强制转换来触发被隐藏成员的基类实现,如下一节所述。以下代码显示了一个示例:

...
// This calls the Draw() method of the ThreeDCircle.
ThreeDCircle o = new ThreeDCircle();
o.Draw();

// This calls the Draw() method of the parent!
((Circle)o).Draw();
Console.ReadLine();

了解基类/派生类转换规则

现在你可以构建一个相关类类型的家族,你需要学习类的规则转换操作。为此,让我们回到本章前面创建的雇员层次结构,并向Program类添加一些新方法(如果您正在学习,请在 Visual Studio 中打开 Employees 项目)。如本章后面所述,系统中的最终基类是System.Object。因此,Object一切事物都“是-个”并能被如此对待。鉴于这一事实,在对象变量中存储任何类型的实例都是合法的。

static void CastingExamples()
{
  // A Manager "is-a" System.Object, so we can
  // store a Manager reference in an object variable just fine.
  object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
}

在 Employees 项目中,ManagersSalesPersonPtSalesPerson类型都扩展了Employee,因此您可以在一个有效的基类引用中存储任何这些对象。因此,下列语句也是合法的:

static void CastingExamples()
{
  // A Manager "is-a" System.Object, so we can
  // store a Manager reference in an object variable just fine.
  object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);

  // A Manager "is-an" Employee too.
  Employee moonUnit = new Manager("MoonUnit Zappa", 2, 3001, 20000, "101-11-1321", 1);

  // A PtSalesPerson "is-a" SalesPerson.
  SalesPerson jill = new PtSalesPerson("Jill", 834, 3002, 100000, "111-12-1119", 90);
}

类类型之间转换的第一条法则是,当两个类通过“is-a”关系相关联时,将派生对象存储在基类引用中总是安全的。形式上,这被称为隐式强制转换,因为根据遗传法则“它 9 就是工作的”。这导致了一些强大的编程结构。例如,假设您已经在当前的Program类中定义了一个新方法。

static void GivePromotion(Employee emp)
{
  // Increase pay...
  // Give new parking space in company garage...

  Console.WriteLine("{0} was promoted!", emp.Name);
}

因为这个方法只接受一个类型为Employee的参数,考虑到“is-a”关系,您可以有效地将来自Employee类的任何后代直接传递给这个方法。

static void CastingExamples()
{
  // A Manager "is-a" System.Object, so we can
  // store a Manager reference in an object variable just fine.
  object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);

  // A Manager "is-an" Employee too.
  Employee moonUnit = new Manager("MoonUnit Zappa", 2, 3001, 20000, "101-11-1321", 1);
  GivePromotion(moonUnit);

  // A PTSalesPerson "is-a" SalesPerson.
  SalesPerson jill = new PtSalesPerson("Jill", 834, 3002, 100000, "111-12-1119", 90);
  GivePromotion(jill);
}

给定从基类类型(Employee)到派生类型的隐式转换,前面的代码进行编译。但是,如果您也想推广弗兰克·扎帕(目前存储在一个通用的System.Object引用中)呢?如果您将frank对象直接传递给这个方法,您会发现如下编译器错误:

object frank = new Manager("Frank Zappa", 9, 3000, 40000, "111-11-1111", 5);
// Error!
GivePromotion(frank);

问题是你试图传入一个没有被声明为Employee而是更一般的System.Object的变量。考虑到objectEmployee在继承链中处于更高的位置,编译器将不允许隐式强制转换,以尽可能保证代码的类型安全。

即使您可以知道object引用指向内存中的Employee兼容类,编译器也不能,因为这要到运行时才能知道。您可以通过执行显式强制转换来满足编译器。这是造型的第二条法则:在这种情况下,可以使用 C# 造型运算符进行显式向下造型。执行显式强制转换时要遵循的基本模板如下所示:

(ClassIWantToCastTo)referenceIHave

因此,要将对象变量传递给GivePromotion()方法,您可以编写以下代码:

// OK!
GivePromotion((Manager)frank);

使用 C# 作为关键字

请注意,显式强制转换是在运行时进行评估的,而不是在编译时。为了便于讨论,假设您的 Employees 项目有一个本章前面创建的Hexagon类的副本。为简单起见,您可以将以下类添加到当前项目中:

class Hexagon
{
  public void Draw()
  {
    Console.WriteLine("Drawing a hexagon!");
  }
}

尽管将 employee 对象转换为 shape 对象完全没有意义,但可以编译如下代码,而不会出错:

// Ack! You can't cast frank to a Hexagon, but this compiles fine!
object frank = new Manager();
Hexagon hex = (Hexagon)frank;

然而,您会收到一个运行时错误,或者更正式地说,一个运行时异常。第七章将研究结构化异常处理的全部细节;然而,目前值得指出的是,当您执行显式强制转换时,您可以使用trycatch关键字来捕获无效强制转换的可能性(同样,参见第七章了解全部细节)。

// Catch a possible invalid cast.
object frank = new Manager();
Hexagon hex;
try
{
  hex = (Hexagon)frank;
}
catch (InvalidCastException ex)
{
  Console.WriteLine(ex.Message);
}

显然,这是一个人为的例子;在这种情况下,你绝不会费心在这两种类型之间进行选择。然而,假设您有一个System.Object类型的数组,其中只有少数包含与Employee兼容的对象。在这种情况下,您希望确定数组中的某个项是否兼容,如果兼容,则执行强制转换。

C# 提供了关键字as来在运行时快速确定一个给定的类型是否与另一个兼容。当您使用as关键字时,您可以通过检查null返回值来确定兼容性。请考虑以下几点:

// Use "as" to test compatibility.
object[] things = new object[4];
things[0] = new Hexagon();
things[1] = false;
things[2] = new Manager();
things[3] = "Last thing";

foreach (object item in things)
{
  Hexagon h = item as Hexagon;
  if (h == null)
  {
    Console.WriteLine("Item is not a hexagon");
  }
  else
  {
    h.Draw();
  }
}

在这里,循环遍历对象数组中的每一项,检查每一项与Hexagon类的兼容性。如果(且仅如果!)找到一个与Hexagon兼容的对象,调用Draw()方法。否则,您只需报告项目不兼容。

使用 C# is 关键字(更新 7.0、9.0)

除了as关键字,C# 语言还提供了is关键字来确定两个项目是否兼容。然而,与as关键字不同,如果类型不兼容,is关键字返回false,而不是null引用。目前,GivePromotion()方法已经被设计成接受从Employee派生的任何可能的类型。考虑下面的更新,它现在检查以查看传入的是哪种“雇员类型”:

static void GivePromotion(Employee emp)
{
  Console.WriteLine("{0} was promoted!", emp.Name);
  if (emp is SalesPerson)
  {
    Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
      ((SalesPerson)emp).SalesNumber);
    Console.WriteLine();
  }
  else if (emp is Manager)
  {
    Console.WriteLine("{0} had {1} stock options...", emp.Name,
      ((Manager)emp).StockOptions);
    Console.WriteLine();
  }
}

这里,您正在执行运行时检查,以确定传入的基类引用实际上指向内存中的什么。在确定接收的是SalesPerson还是Manager类型之后,您就可以执行显式强制转换来访问该类的专用成员。还要注意,您不需要将您的造型操作包装在一个try / catch构造中,因为您知道如果您进入任一个if范围,造型是安全的,给定您的条件检查。

在 C# 7.0 中新增的关键字is也可以将转换后的类型赋给一个变量,如果转换有效的话。这通过防止“双重转换”问题清理了前面的方法。在前面的示例中,第一次强制转换是在检查类型是否匹配时完成的,如果匹配,则变量必须再次强制转换。考虑对前面方法的更新:

static void GivePromotion(Employee emp)
{
  Console.WriteLine("{0} was promoted!", emp.Name);
  //Check if is SalesPerson, assign to variable s
  if (emp is SalesPerson s)
  {
    Console.WriteLine("{0} made {1} sale(s)!", s.Name,
      s.SalesNumber);
    Console.WriteLine();
  }
  //Check if is Manager, if it is, assign to variable m
  else if (emp is Manager m)
  {
    Console.WriteLine("{0} had {1} stock options...",
      m.Name, m.StockOptions);
    Console.WriteLine();
  }
}

C# 9.0 引入了额外的模式匹配功能(在第三章中介绍)。这些更新的模式匹配可以与关键字is一起使用。例如,要检查雇员是否不是ManagerSalesPerson,,请使用以下代码:

if (emp is not Manager and not SalesPerson)
{
  Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
  Console.WriteLine();
}

用 is 关键字丢弃(新 7.0)

关键字is也可以与丢弃变量占位符结合使用。如果您想在您的ifswitch语句中创建一个总汇,您可以如下操作:

if (obj is var _)
{
//do something
}

这将匹配所有内容,所以要注意使用丢弃比较器的顺序。更新后的GivePromotion()方法如下所示:

if (emp is SalesPerson s)
{
  Console.WriteLine("{0} made {1} sale(s)!", s.Name, s.SalesNumber);
  Console.WriteLine();
}
//Check if is Manager, if it is, assign to variable m
else if (emp is Manager m)
{
  Console.WriteLine("{0} had {1} stock options...", m.Name, m.StockOptions);
  Console.WriteLine();
}
else if (emp is var _)
{
  Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
  Console.WriteLine();
}

最后的if语句将捕获任何不是Manager, SalesPerson,PtSalesPerson.Employee实例。记住,你可以降级为基类,所以PtSalesPerson 注册为SalesPerson .

重温模式匹配(新 7.0)

第三章 ?? 介绍了 C# 7 的模式匹配特性以及 C# 9.0 的更新。现在你已经对选角有了坚定的认识,是时候举个更好的例子了。现在可以干净地更新前面的示例,以使用模式匹配switch语句,如下所示:

static void GivePromotion(Employee emp)
{
  Console.WriteLine("{0} was promoted!", emp.Name);
  switch (emp)
  {
    case SalesPerson s:
      Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
        s.SalesNumber);
      break;
    case Manager m:
      Console.WriteLine("{0} had {1} stock options...",
        emp.Name, m.StockOptions);
      break;
  }
  Console.WriteLine();
}

当将一个when子句添加到case语句时,对象的完整定义在被转换为时可供使用。例如,SalesNumber属性只存在于SalesPerson类中,而不存在于Employee类中。如果第一个case语句中的转换成功,变量s将保存一个SalesPerson类的实例,因此case语句可以更新为:

case SalesPerson s when s.SalesNumber > 5:

isswitch语句的这些新添加提供了很好的改进,有助于减少执行匹配的代码量,如前面的例子所示。

用 switch 语句丢弃(新 7.0)

丢弃也可以用在switch语句中,如下面的代码所示:

switch (emp)
{
  case SalesPerson s when s.SalesNumber > 5:
    Console.WriteLine("{0} made {1} sale(s)!", emp.Name,
      s.SalesNumber);
    break;
  case Manager m:
    Console.WriteLine("{0} had {1} stock options...",
      emp.Name, m.StockOptions);
    break;
  case Employee _:
    Console.WriteLine("Unable to promote {0}. Wrong employee type", emp.Name);
    break;
}

每个传入的类型都已经是一个Employee,,所以最后的case语句总是真的。然而,正如在第三章中介绍模式匹配时所讨论的,一旦匹配成功,就会退出switch语句。这证明了获得正确订单的重要性。如果最后一条语句被移到最上面,就不会有Employee被提升。

理解超级父类:System。目标

为了结束这一章,我想检查一下超级父类的细节:Object。当您在阅读前一节时,您可能已经注意到您的层次结构中的基类(CarShapeEmployee)从来没有显式地指定它们的父类。

// Who is the parent of Car?
class Car
{...}

在。NET Core universe,每个类型最终都是从一个名为System.Object的基类派生出来的,可以用 C# object关键字(小写 o )来表示。Object类为框架中的每种类型定义了一组公共成员。事实上,当您构建一个没有显式定义其父类的类时,编译器会自动从Object中派生出您的类型。如果你想弄清楚你的意图,你可以自由地如下定义从Object派生的类(然而,同样,没有必要这样做):

// Here we are explicitly deriving from System.Object.
class Car : object
{...}

像任何类一样,System.Object定义了一组成员。在下面的正式 C# 定义中,注意其中一些项被声明为virtual,它指定一个给定的成员可以被一个子类覆盖,而其他的被标记为static(因此在类级别被调用):

public class Object
{
  // Virtual members.
  public virtual bool Equals(object obj);
  protected virtual void Finalize();
  public virtual int GetHashCode();
  public virtual string ToString();

  // Instance-level, nonvirtual members.
  public Type GetType();
  protected object MemberwiseClone();

  // Static members.
  public static bool Equals(object objA, object objB);
  public static bool ReferenceEquals(object objA, object objB);
}

表 6-1 提供了一些你最可能使用的方法所提供的功能的概要。

表 6-1。

System.Object的核心成员

|

对象类的实例方法

|

生命的意义

Equals() 默认情况下,只有当被比较的项目引用内存中的同一个项目时,该方法才返回true。因此,Equals()用于比较对象引用,而不是对象的状态。通常,只有当被比较的对象具有相同的内部状态值(即基于值的语义)时,该方法才会被覆盖以返回true
注意,如果你覆盖了Equals(),你也应该覆盖GetHashCode(),因为这些方法被Hashtable类型内部使用来从容器中检索子对象。
还记得在第四章中,ValueType类覆盖了所有结构的这个方法,所以它们使用基于值的比较。
Finalize() 目前,您可以理解调用这个方法(当被覆盖时)是为了在对象被销毁之前释放所有分配的资源。我将在第九章中详细介绍 CoreCLR 垃圾收集服务。
GetHashCode() 该方法返回一个标识特定对象实例的int
ToString() 这个方法使用<namespace>.<type name>格式(称为完全限定名)返回这个对象的字符串表示。这个方法通常会被一个子类覆盖,以返回一个表示对象内部状态的名称-值对的标记化字符串,而不是它的完全限定名。
GetType() 这个方法返回一个Type对象,它完整地描述了你当前引用的对象。简而言之,这是一个对所有对象都可用的运行时类型识别(RTTI)方法(在第十六章有更详细的讨论)。
MemberwiseClone() 这个方法的存在是为了返回当前对象的一个成员接一个成员的副本,这在克隆对象时经常用到(见第八章)。

为了演示由Object基类提供的一些默认行为,创建一个名为 ObjectOverrides 的最终 C# 控制台应用项目。插入一个新的 C# 类类型,它包含以下名为Person的类型的空类定义:

// Remember! Person extends Object.
class Person {}

现在,更新您的顶级语句,以便与System.Object的继承成员进行交互,如下所示:

Console.WriteLine("***** Fun with System.Object *****\n");
Person p1 = new Person();

// Use inherited members of System.Object.
Console.WriteLine("ToString: {0}", p1.ToString());
Console.WriteLine("Hash code: {0}", p1.GetHashCode());
Console.WriteLine("Type: {0}", p1.GetType());

// Make some other references to p1.
Person p2 = p1;
object o = p2;
// Are the references pointing to the same object in memory?
if (o.Equals(p1) && p2.Equals(o))
{
  Console.WriteLine("Same instance!");
}
Console.ReadLine();
}

以下是当前代码的输出:

***** Fun with System.Object *****
ToString: ObjectOverrides.Person
Hash code: 58225482
Type: ObjectOverrides.Person
Same instance!

注意ToString()的默认实现如何返回当前类型的完全限定名(ObjectOverrides.Person)。正如你将在第十五章的构建定制名称空间的检查中看到的,每个 C# 项目都定义了一个“根名称空间”,它与项目本身同名。在这里,您创建了一个名为ObjectOverrides的项目;因此,Person类型和Program类都被放在了ObjectOverrides名称空间中。

Equals()的默认行为是测试两个变量是否指向内存中的同一个对象。在这里,您创建了一个名为p1的新的Person变量。此时,一个新的Person对象被放在托管堆上。p2也是Person类型。然而,您不是在创建一个新的实例,而是将这个变量分配给引用p1。因此,p1p2都指向内存中的同一个对象,变量o(类型object,为了更好的测量,它被抛出)也是如此。假设p1p2o都指向相同的存储位置,则相等测试成功。

虽然System.Object的固定行为在很多情况下可以满足要求,但是对于你的自定义类型来说,重写这些继承的方法是很常见的。举例来说,更新Person类以支持一些表示个人名字、姓氏和年龄的属性,每个属性都可以由自定义构造函数设置。

// Remember! Person extends Object.
class Person
{
  public string FirstName { get; set; } = "";
  public string LastName { get; set; } = "";
  public int Age { get; set; }

  public Person(string fName, string lName, int personAge)
  {
    FirstName = fName;
    LastName = lName;
    Age = personAge;
  }
  public Person(){}
}

超驰系统。Object.ToString()

您创建的许多类(和结构)可以受益于覆盖ToString()来返回类型当前状态的字符串文本表示。这对于调试非常有帮助(还有其他原因)。你如何选择构造这个字符串是个人的选择;但是,推荐的方法是用分号分隔每个名称-值对,并将整个字符串放在方括号内(许多类型在。NET 核心基类库遵循这种方法)。为您的Person类考虑以下被覆盖的ToString():

public override string ToString() => $"[First Name: {FirstName}; Last Name: {LastName}; Age: {Age}]";

鉴于Person类只有三条状态数据,所以ToString()的实现非常简单。然而,永远记住一个适当的ToString()覆盖也应该考虑到继承链上的定义的任何数据。

当您为一个扩展自定义基类的类重写ToString()时,首先要做的是使用base关键字从父类获取ToString()值。获得父级的字符串数据后,可以追加派生类的自定义信息。

超驰系统。对象。等于()

让我们也覆盖Object.Equals()的行为来处理基于值的语义。回想一下,默认情况下,只有当被比较的两个对象引用内存中的同一个对象实例时,Equals()才会返回true。对于Person类,如果被比较的两个变量包含相同的状态值(例如,名字、姓氏和年龄),实现Equals()以返回true可能会有所帮助。

首先,注意到Equals()方法的传入参数是一个通用的System.Object。鉴于此,您的首要任务是确保调用者确实传入了一个Person对象,并且作为额外的保护措施,确保传入的参数不是一个null引用。

在您建立了调用者已经向您传递了一个分配的Person之后,实现Equals()的一种方法是对传入对象的数据和当前对象的数据进行逐字段比较。

public override bool Equals(object obj)
{
  if (!(obj is Person temp))
  {
    return false;
  }
  if (temp.FirstName == this.FirstName
      && temp.LastName == this.LastName
      && temp.Age == this.Age)
  {
    return true;
  }
  return false;
}

这里,您将对照您的内部值检查传入对象的值(注意使用了this关键字)。如果每个对象的名称和年龄都相同,那么就有两个对象具有相同的状态数据,因此返回true。任何其他的可能性导致返回false

虽然这种方法确实有效,但是您可以想象为可能包含几十个数据字段的非平凡类型实现一个定制的Equals()方法会有多费力。一个常见的捷径是利用您自己的ToString()实现。如果一个类有一个基本且正确的ToString()实现,它包含了继承链上的所有字段数据,那么你可以简单地比较对象的字符串数据(检查是否为空)。

// No need to cast "obj" to a Person anymore,
// as everything has a ToString() method.
public override bool Equals(object obj)
  => obj?.ToString() == ToString();

请注意,在这种情况下,您不再需要检查传入参数的类型是否正确(在本例中是 a Person),因为。NET 支持一个ToString()方法。更好的是,您不再需要执行逐个属性的相等检查,因为您现在只是测试从ToString()返回的值。

超驰系统。Object.GetHashCode()

当一个类覆盖了Equals()方法时,你也应该覆盖GetHashCode()的默认实现。简单地说,散列码是一个数值,它将一个对象表示为一个特定的状态。例如,如果您创建两个保存值Hellostring变量,您将获得相同的哈希代码。然而,如果其中一个string对象全部是小写的(hello,您将获得不同的散列码。

默认情况下,System.Object.GetHashCode()使用对象在内存中的当前位置来产生哈希值。但是,如果您正在构建一个自定义类型,并打算存储在一个Hashtable类型中(在System.Collections名称空间中),您应该总是覆盖这个成员,因为Hashtable将在内部调用Equals()GetHashCode()来检索正确的对象。

Note

更具体地说,System.Collections.Hashtable类在内部调用GetHashCode()来获得对象所在位置的大致信息,但是对Equals()的后续(内部)调用确定了精确匹配。

虽然在这个例子中你不打算把你的Person放入System.Collections.Hashtable中,但是为了完整起见,让我们覆盖GetHashCode()。有许多算法可以用来创建散列码——有些很奇特,有些则不那么奇特。大多数时候,您可以通过利用System.StringGetHashCode()实现来生成一个散列码值。

假设String类已经有了一个可靠的哈希代码算法,它使用String的字符数据来计算哈希值,如果您可以在您的类中识别出一个对于所有实例都应该是唯一的字段数据(比如一个社会保险号),只需在该字段数据点上调用GetHashCode()。因此,如果Person类定义了一个SSN属性,您可以编写以下代码:

// Assume we have an SSN property as so.
class Person
{
  public string SSN {get; } = "";
  public Person(string fName, string lName, int personAge,
    string ssn)
  {
    FirstName = fName;
    LastName = lName;
    Age = personAge;
    SSN = ssn;
  }
  // Return a hash code based on unique string data.
  public override int GetHashCode() => SSN.GetHashCode();
}

如果使用读写属性作为哈希代码的基础,将会收到警告。一旦创建了对象,哈希代码应该是不可变的。在前面的例子中,SSN 属性只有一个get方法,该方法使属性成为只读的,并且只能在构造函数中设置。

如果您找不到唯一的string数据的单点,但是您已经覆盖了ToString()(它满足只读约定),那么在您自己的字符串表示上调用GetHashCode()

// Return a hash code based on the person's ToString() value.
public override int GetHashCode() => ToString().GetHashCode();

测试修改后的 Person 类

现在您已经覆盖了Objectvirtual成员,更新顶层语句来测试您的更新。

Console.WriteLine("***** Fun with System.Object *****\n");

// NOTE: We want these to be identical to test
// the Equals() and GetHashCode() methods.
Person p1 = new Person("Homer", "Simpson", 50,
  "111-11-1111");
Person p2 = new Person("Homer", "Simpson", 50,
  "111-11-1111");

// Get stringified version of objects.
Console.WriteLine("p1.ToString() = {0}", p1.ToString());
Console.WriteLine("p2.ToString() = {0}", p2.ToString());

// Test overridden Equals().
Console.WriteLine("p1 = p2?: {0}", p1.Equals(p2));

// Test hash codes.
//still using the hash of the SSN
Console.WriteLine("Same hash codes?: {0}", p1.GetHashCode() == p2.GetHashCode());
Console.WriteLine();

// Change age of p2 and test again.
p2.Age = 45;
Console.WriteLine("p1.ToString() = {0}", p1.ToString());
Console.WriteLine("p2.ToString() = {0}", p2.ToString());
Console.WriteLine("p1 = p2?: {0}", p1.Equals(p2));
//still using the hash of the SSN
Console.WriteLine("Same hash codes?: {0}", p1.GetHashCode() == p2.GetHashCode());
Console.ReadLine();

输出如下所示:

***** Fun with System.Object *****
p1.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p2.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p1 = p2?: True
Same hash codes?: True

p1.ToString() = [First Name: Homer; Last Name: Simpson; Age: 50]
p2.ToString() = [First Name: Homer; Last Name: Simpson; Age: 45]
p1 = p2?: False
Same hash codes?: True

使用系统的静态成员。目标

除了您刚才检查的实例级成员之外,System.Object还定义了两个静态成员,它们也测试基于值或基于引用的相等性。考虑以下代码:

static void StaticMembersOfObject()
{
  // Static members of System.Object.
  Person p3 = new Person("Sally", "Jones", 4);
  Person p4 = new Person("Sally", "Jones", 4);
  Console.WriteLine("P3 and P4 have same state: {0}", object.Equals(p3, p4));
  Console.WriteLine("P3 and P4 are pointing to same object: {0}",
    object.ReferenceEquals(p3, p4));
}

在这里,您可以简单地发送两个对象(任何类型)并允许System.Object类自动确定细节。

输出(从顶级语句调用时)如下所示:

***** Fun with System.Object *****
P3 and P4 have the same state: True
P3 and P4 are pointing to the same object: False

摘要

本章探讨了继承和多态的作用和细节。在这些页面中,向您介绍了许多新的关键字和令牌来支持这些技术。例如,回想一下冒号标记用于建立给定类型的父类。父类型能够定义任意数量的虚拟和/或抽象成员来建立多态接口。派生类型使用override关键字覆盖这样的成员。

除了构建大量的类层次结构之外,本章还研究了如何在基类和派生类之间进行显式转换,并通过深入研究。NET 基础类库:System.Object。**

七、了解结构化异常处理

在本章中,你将学习如何通过使用结构化异常处理来处理 C# 代码中的运行时异常。你不仅会研究允许你处理这些事情的 C# 关键字(trycatchthrowfinallywhen),而且你还会理解应用级和系统级异常的区别,以及System.Exception基类的作用。该讨论将引入构建自定义异常的主题,并最终快速浏览一些 Visual Studio 的以异常为中心的调试工具。

错误、错误和异常的颂歌

不管我们(有时是膨胀的)自我告诉我们什么,没有一个程序员是完美的。编写软件是一项复杂的任务,考虑到这种复杂性,即使是最好的软件也经常会出现各种问题*。有时问题是由糟糕的代码引起的(比如溢出数组的边界)。其他时候,问题是由伪造的用户输入引起的,这些用户输入在应用的代码库中没有考虑到(例如,分配给值Chucky的电话号码输入字段)。现在,不管问题的原因是什么,最终的结果都是应用不能像预期的那样工作。为了帮助构建即将到来的结构化异常处理的讨论,请允许我提供三个常用的以异常为中心的术语的定义。*

** bug:简单来说,就是程序员犯的错误。例如,假设您正在使用非托管 C++进行编程。如果您未能删除动态分配的内存,从而导致内存泄漏,那么您就有一个 bug。

  • 用户错误:另一方面,用户错误通常是由运行你的应用的人引起的,而不是由创建它的人引起的。例如,一个终端用户在文本框中输入了一个格式错误的字符串,如果您不能在代码库中处理这个错误的输入,他很可能会产生一个错误*。*

  • 异常(Exceptions):异常通常被认为是运行时的异常,在编写应用时很难解释清楚。可能的例外包括尝试连接到不再存在的数据库、打开损坏的 XML 文件或尝试联系当前脱机的计算机。在每一种情况下,程序员(或最终用户)对这些“异常”情况几乎没有控制力。

给定这些定义,应该很清楚。NET 结构化异常处理是一种处理运行时异常的技术。然而,即使对于那些你看不到的 bug 和用户错误,运行时通常也会生成一个相应的异常来识别即将发生的问题。举几个例子。NET 5 基础类库定义了众多的异常,比如FormatExceptionIndexOutOfRangeExceptionFileNotFoundExceptionArgumentOutOfRangeException等等。

在。NET 命名法中,异常说明了 bug、虚假用户输入和运行时错误,尽管程序员可能会将这些视为不同的问题。然而,在我走得太远之前,让我们形式化一下结构化异常处理的角色,看看它与传统的错误处理技术有什么不同。

Note

为了使本书中使用的代码示例尽可能简洁,我不会捕捉基类库中给定方法可能抛出的每个可能的异常。当然,在你的产品级项目中,你应该充分利用本章介绍的技术。

的作用。NET 异常处理

之前。NET 中,Windows 操作系统下的错误处理是一个混乱的技术大杂烩。许多程序员在给定应用的上下文中使用他们自己的错误处理逻辑。例如,开发团队可以定义一组表示已知错误条件的数字常量,并将它们用作方法返回值。举例来说,考虑下面的部分 C 代码:

/* A very C-style error trapping mechanism. */
#define E_FILENOTFOUND 1000

int UseFileSystem()
{
  // Assume something happens in this function
  // that causes the following return value.
  return E_FILENOTFOUND;
}

void main()
{
  int retVal = UseFileSystem();
  if(retVal == E_FILENOTFOUND)
    printf("Cannot find file...");
}

这种方法不太理想,因为常量E_FILENOTFOUND只不过是一个数值,对于如何处理这个问题来说远远不是一个有用的代理。理想情况下,您希望将错误的名称、描述性消息和其他关于该错误条件的有用信息打包到一个定义明确的包中(这正是结构化异常处理中发生的情况)。除了开发人员的特别技术之外,Windows API 还定义了数百个错误代码,这些错误代码来自于#definesHRESULT以及简单布尔值(boolBOOLVARIANT_BOOL等)的太多变体。).

这些老技术的明显问题是严重缺乏对称性。每种方法都或多或少地适合于给定的技术、给定的语言,甚至可能是给定的项目。为了结束这种疯狂。NET 平台提供了发送和捕获运行时错误的标准技术:结构化异常处理。这种方法的美妙之处在于,开发人员现在有了一种统一的错误处理方法,这种方法对于所有面向。NET 平台。因此,C# 程序员处理错误的方式在语法上类似于 VB 程序员,或者使用 C++/CLI 的 C++程序员。

额外的好处是,用于跨程序集和计算机边界抛出和捕获异常的语法是相同的。例如,如果您使用 C# 构建一个 ASP.NET 核心 RESTful 服务,您可以使用允许您在同一个应用的方法之间抛出异常的相同关键字,向远程调用者抛出一个 JSON 错误。

另一个好处是。NET exceptions 的一个特点是,异常不是接收一个神秘的数值,而是包含问题的可读描述的对象,以及首先触发异常的调用堆栈的详细快照。此外,您可以为最终用户提供帮助链接信息,将用户指向一个提供错误详细信息的 URL,以及自定义的程序员定义的数据。

的组成部分。NET 异常处理

使用结构化异常处理进行编程涉及到四个相关实体的使用。

  • 表示异常详细信息的类类型

  • 在正确的情况下,向调用者抛出异常类实例的成员

  • 调用者端调用易发生异常的成员的代码块

  • 调用者端的代码块将处理(或捕捉)发生的异常

C# 编程语言提供了五个关键字(trycatchthrowfinallywhen,允许您抛出和处理异常。代表当前问题的对象是一个扩展了System.Exception的类(或其派生)。鉴于这一事实,让我们来看看这个以异常为中心的基类的作用。

系统。异常基类

所有异常最终都是从System.Exception基类派生的,而基类又是从System.Object派生的。这个类的关键是(注意,其中一些成员是虚拟的,因此可能被派生类重写):

public class Exception : ISerializable
{
  // Public constructors
  public Exception(string message, Exception innerException);
  public Exception(string message);
  public Exception();
...
  // Methods
  public virtual Exception GetBaseException();
  public virtual void GetObjectData(SerializationInfo info,
    StreamingContext context);

  // Properties
  public virtual IDictionary Data { get; }
  public virtual string HelpLink { get; set; }
  public int HResult {get;set;}
  public Exception InnerException { get; }
  public virtual string Message { get; }
  public virtual string Source { get; set; }
  public virtual string StackTrace { get; }
  public MethodBase TargetSite { get; }
}

正如您所看到的,由System.Exception定义的许多属性实际上是只读的。这是因为派生类型通常会为每个属性提供默认值。例如,IndexOutOfRangeException类型的默认消息是“索引超出了数组的界限。”

表 7-1 描述了System.Exception最重要的成员。

表 7-1。

System.Exception类型的核心成员

|

系统。异常属性

|

生命的意义

Data 这个只读属性检索一组键值对(由实现IDictionary的对象表示),这些键值对提供了额外的、程序员定义的关于异常的信息。默认情况下,此集合为空。
HelpLink 此属性获取或设置详细描述错误的帮助文件或网站的 URL。
InnerException 此只读属性可用于获取导致当前异常发生的以前异常的信息。先前的异常通过将它们传递到最新异常的构造函数中来记录。
Message 此只读属性返回给定错误的文本描述。错误消息本身被设置为构造函数参数。
Source 此属性获取或设置引发当前异常的程序集或对象的名称。
StackTrace 此只读属性包含一个字符串,该字符串标识触发异常的调用序列。正如您可能猜到的那样,该属性在调试期间或者如果您想要将错误转储到外部错误日志中时非常有用。
TargetSite 这个只读属性返回一个MethodBase对象,该对象描述了关于抛出异常的方法的许多细节(调用ToString()将通过名称识别该方法)。

最简单的例子

为了说明结构化异常处理的有用性,您需要创建一个在正确的(或者可以说是异常)情况下抛出异常的类。假设您已经创建了一个新的 C# 控制台应用项目(名为 SimpleException ),它定义了由“has-a”关系关联的两个类类型(CarRadio)。Radio类型定义了打开或关闭无线电电源的单一方法。

using System;
namespace SimpleException
{
  class Radio
  {
    public void TurnOn(bool on)
    {
      Console.WriteLine(on ? "Jamming..." : "Quiet time...");
    }
  }
}

除了通过包含/委托利用Radio类之外,Car类(如下所示)的定义方式是,如果用户将Car对象加速到超过预定义的最大速度(使用名为MaxSpeed的常量成员变量指定),其引擎就会爆炸,导致Car不可用(由名为_carIsDead的私有bool成员变量捕获)。

除此之外,Car类型还有一些属性来表示当前速度和用户提供的“昵称”,以及各种构造函数来设置新的Car对象的状态。下面是完整的定义(带代码注释):

using System;

namespace SimpleException
{
  class Car
  {
    // Constant for maximum speed.
    public const int MaxSpeed = 100;

    // Car properties.
    public int CurrentSpeed {get; set;} = 0;
    public string PetName {get; set;} = "";

    // Is the car still operational?
    private bool _carIsDead;

    // A car has-a radio.
    private readonly Radio _theMusicBox = new Radio();

    // Constructors.
    public Car() {}
    public Car(string name, int speed)
    {
      CurrentSpeed = speed;
      PetName = name;
    }

    public void CrankTunes(bool state)
    {
      // Delegate request to inner object.
      _theMusicBox.TurnOn(state);
    }

    // See if Car has overheated.
    public void Accelerate(int delta)
    {
      if (_carIsDead)
      {
        Console.WriteLine("{0} is out of order...", PetName);
      }
      else
      {
        CurrentSpeed += delta;
        if (CurrentSpeed > MaxSpeed)
        {
          Console.WriteLine("{0} has overheated!", PetName);
          CurrentSpeed = 0;
          _carIsDead = true;
        }
        else
        {
          Console.WriteLine("=> CurrentSpeed = {0}",
            CurrentSpeed);
        }
      }
    }
  }
}

接下来,更新您的Program.cs代码以强制Car对象超过预定义的最大速度(在Car类中设置为 100),如下所示:

using System;
using System.Collections;
using SimpleException;

Console.WriteLine("***** Simple Exception Example *****");
Console.WriteLine("=> Creating a car and stepping on it!");
Car myCar = new Car("Zippy", 20);
myCar.CrankTunes(true);

for (int i = 0; i < 10; i++)
{
  myCar.Accelerate(10);
}
Console.ReadLine();

执行代码时,您会看到以下输出:

***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100
Zippy has overheated!
Zippy is out of order...

引发一般异常

现在您已经有了一个函数类,我将演示抛出异常的最简单方法。如果调用者试图加速Car超过其上限,当前的Accelerate()实现简单地显示一条错误消息。

如果用户试图在汽车遇到制造者后加速汽车,要改进这个方法抛出一个异常,您需要创建并配置一个新的System.Exception类实例,通过类构造函数设置只读Message属性的值。当你想把异常对象发送回调用者时,使用 C# throw关键字。下面是对Accelerate()方法的相关代码更新:

// This time, throw an exception if the user speeds up beyond MaxSpeed.
public void Accelerate(int delta)
{
  if (_carIsDead)
  {
    Console.WriteLine("{0} is out of order...", PetName);
  }
  else
  {
    CurrentSpeed += delta;
    if (CurrentSpeed >= MaxSpeed)
    {
      CurrentSpeed = 0;
      _carIsDead = true;

      // Use the "throw" keyword to raise an exception.
      throw new Exception($"{PetName} has overheated!");
    }
    Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
  }
}

在研究调用者如何捕捉这个异常之前,让我们先来看几个有趣的地方。首先,当您抛出一个异常时,总是由您来决定到底是什么构成了问题中的错误,以及何时应该抛出一个异常。这里,你假设如果程序试图增加一个Car对象的速度超过最大值,应该抛出一个System.Exception对象来指示Accelerate()方法不能继续(这可能是也可能不是一个有效的假设;这将是您根据您正在创建的应用做出的判断。

或者,您可以实现Accelerate()来自动恢复,而不需要首先抛出异常。总的来说,异常应该仅在满足更多的终止条件时抛出(例如,找不到必要的文件、无法连接到数据库等),而不是用作逻辑流机制。确切地决定抛出异常的理由是一个您必须始终应对的设计问题。就目前的目的而言,假设要求一辆注定要失败的汽车加速会引发一个异常。

其次,注意最后一个else是如何从方法中移除的。当抛出一个异常时(由框架或者手动使用一个throw语句),控制权返回给调用方法(或者由try catch 中的catch块)。这样就不需要最后的else。是否保留可读性取决于您和您的编码标准。

在任何情况下,如果您在此时使用顶级语句中的先前逻辑重新运行应用,异常最终将被抛出。如以下输出所示,不处理此错误的结果并不理想,因为您会收到一个详细的错误转储,然后程序终止(带有您的特定文件路径和行号):

 ***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100

Unhandled exception. System.Exception: Zippy has overheated!
   at SimpleException.Car.Accelerate(Int32 delta) in [path to file]\Car.cs:line 52
   at SimpleException.Program.Main(String[] args) in [path to file]\Program.cs:line 16

捕捉异常

Note

对于那些即将到来的。NET 5 从 Java 背景,理解类型成员不是用它们可能抛出的异常集(换句话说。NET Core 不支持检查异常)。不管是好是坏,您不需要处理给定成员抛出的每个异常。

因为Accelerate()方法现在抛出一个异常,调用者需要准备好处理这个异常,如果它发生的话。当你调用一个可能抛出异常的方法时,你使用了一个try / catch块。在您捕获异常对象之后,您能够调用异常对象的成员来提取问题的细节。

你如何处理这些数据很大程度上取决于你自己。您可能希望将此信息记录到报告文件中,将数据写入事件日志,向系统管理员发送电子邮件,或者向最终用户显示问题。在这里,您只需将内容转储到控制台窗口:

// Handle the thrown exception.
Console.WriteLine("***** Simple Exception Example *****");
Console.WriteLine("=> Creating a car and stepping on it!");
Car myCar = new Car("Zippy", 20);
myCar.CrankTunes(true);

// Speed up past the car's max speed to
// trigger the exception.
try
{
  for(int i = 0; i < 10; i++)
  {
    myCar. Accelerate(10);
  }
}
catch(Exception e)
{
  Console.WriteLine("\n*** Error! ***");
  Console.WriteLine("Method: {0}", e.TargetSite);
  Console.WriteLine("Message: {0}", e.Message);
  Console.WriteLine("Source: {0}", e.Source);
}
// The error has been handled, processing continues with the next statement.
Console.WriteLine("\n***** Out of exception logic *****");
Console.ReadLine();

本质上,try块是一段可能在执行过程中抛出异常的语句。如果检测到异常,程序执行流程被发送到适当的catch模块。另一方面,如果try块中的代码没有触发异常,那么catch块将被完全跳过,一切正常。以下输出显示了该程序的测试运行:

***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
=> CurrentSpeed = 100

*** Error! ***
Method: Void Accelerate(Int32)
Message: Zippy has overheated!
Source: SimpleException

***** Out of exception logic *****

正如您所看到的,在一个异常被处理之后,应用可以从catch块之后的点继续运行。在某些情况下,给定的异常可能非常关键,足以保证终止应用。然而,在很多情况下,异常处理程序中的逻辑将确保应用能够继续愉快地运行(尽管它的功能可能会稍微差一些,比如不能连接到远程数据源)。

作为表达式抛出(新 7.0)

在 C# 7 之前,throw是一个语句,这意味着你只能在允许语句的地方抛出异常。在 C# 7.0 和更高版本中,throw也可以作为表达式使用,并且可以在任何允许表达式的地方被调用。

配置异常的状态

目前,Accelerate()方法中配置的System.Exception对象只是建立一个暴露给Message属性的值(通过一个构造函数参数)。然而,如表 7-1 所示,Exception类还提供了许多附加成员(TargetSiteStackTraceHelpLinkData),这些成员可用于进一步限定问题的性质。为了更好地展示当前的例子,让我们逐个分析这些成员的更多细节。

TargetSite 属性

属性允许您确定关于抛出给定异常的方法的各种细节。如前面的代码示例所示,打印TargetSite的值将显示抛出异常的方法的返回类型、名称和参数类型。然而,TargetSite不仅仅返回一个香草味的字符串,而是一个强类型的System.Reflection.MethodBase对象。此类型可用于收集有关违规方法以及定义违规方法的类的大量详细信息。为了说明,假设前面的catch逻辑已经更新如下:

...
// TargetSite actually returns a MethodBase object.
catch(Exception e)
{
  Console.WriteLine("\n*** Error! ***");
  Console.WriteLine("Member name: {0}", e.TargetSite);
  Console.WriteLine("Class defining member: {0}",
    e.TargetSite.DeclaringType);
  Console.WriteLine("Member type: {0}",
    e.TargetSite.MemberType);
  Console.WriteLine("Message: {0}", e.Message);
  Console.WriteLine("Source: {0}", e.Source);
}
Console.WriteLine("\n***** Out of exception logic *****");
Console.ReadLine();

这一次,您使用MethodBase.DeclaringType属性来确定抛出错误的类的完全限定名(在本例中为SimpleException.Car)以及MethodBase对象的MemberType属性来标识引发该异常的成员类型(例如属性与方法)。在这种情况下,catch逻辑将显示以下内容:

*** Error! ***
Member name: Void Accelerate(Int32)
Class defining member: SimpleException.Car
Member type: Method
Message: Zippy has overheated!
Source: SimpleException

StackTrace 属性

属性允许您识别导致异常的一系列调用。请注意,永远不要设置StackTrace的值,因为它是在创建异常时自动建立的。举例来说,假设你再次更新了你的catch逻辑。

catch(Exception e)
{
  ...
  Console.WriteLine("Stack: {0}", e.StackTrace);
}

如果您要运行该程序,您会发现下面的堆栈跟踪被打印到控制台(当然,您的行号和文件路径可能不同):

Stack: at SimpleException.Car.Accelerate(Int32 delta)
in [path to file]\car.cs:line 57 at <Program>$.<Main>$(String[] args)
in [path to file]\Program.cs:line 20

StackTrace返回的string记录了导致抛出该异常的调用序列。注意这个string最下面的行号如何标识序列中的第一个调用,而最上面的行号标识违规成员的确切位置。显然,这些信息在给定应用的调试或日志记录过程中非常有用,因为您能够“跟踪”错误的来源。

虽然TargetSiteStackTrace属性允许程序员了解给定的异常,但是这些信息对最终用户没有什么用处。正如您已经看到的,System.Exception.Message属性可以用来获取可以显示给当前用户的可读信息。此外,HelpLink属性可以被设置为将用户指向包含更多详细信息的特定 URL 或标准帮助文件。

默认情况下,由HelpLink属性管理的值是一个空字符串。使用对象初始化更新异常,以提供更有趣的值。下面是对Car.Accelerate()方法的相关更新:

public void Accelerate(int delta)
{
  if (_carIsDead)
  {
    Console.WriteLine("{0} is out of order...", PetName);
  }
  else
  {
    CurrentSpeed += delta;
    if (CurrentSpeed >= MaxSpeed)
    {
      CurrentSpeed = 0;
      _carIsDead = true;

      // Use the "throw" keyword to raise an exception and
      // return to the caller.
      throw new Exception($"{PetName} has overheated!")
      {
        HelpLink = "http://www.CarsRUs.com"
      };
    }
    Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
  }
}

现在可以更新catch逻辑来打印帮助链接信息,如下所示:

catch(Exception e)
{
  ...
  Console.WriteLine("Help Link: {0}", e.HelpLink);
}

数据属性

System.ExceptionData属性允许你用相关的辅助信息(比如时间戳)填充一个异常对象。Data属性返回一个实现名为IDictionary的接口的对象,该接口在System.Collections名称空间中定义。第八章研究了基于接口编程的角色,以及System.Collections名称空间。目前,只需理解字典集合允许您创建一组使用特定键检索的值。观察Car.Accelerate()方法的下一次更新:

public void Accelerate(int delta)
{
  if (_carIsDead)
  {
    Console.WriteLine("{0} is out of order...", PetName);
  }
  else
  {
    CurrentSpeed += delta;
    if (CurrentSpeed >= MaxSpeed)
    {
      Console.WriteLine("{0} has overheated!", PetName);
      CurrentSpeed = 0;
      _carIsDead = true;
      // Use the "throw" keyword to raise an exception
      // and return to the caller.
      throw new Exception($"{PetName} has overheated!")
      {
        HelpLink = "http://www.CarsRUs.com",
        Data = {
          {"TimeStamp",$"The car exploded at {DateTime.Now}"},
          {"Cause","You have a lead foot."}
        }
      };
    }
    Console.WriteLine("=> CurrentSpeed = {0}", CurrentSpeed);
  }
}

为了成功地枚举键值对,请确保您为System.Collections名称空间添加了一个using指令,因为您将在包含实现顶级语句的类的文件中使用一个DictionaryEntry类型:

using System.Collections;

接下来,您需要更新catch逻辑来测试从Data属性返回的值不是null(默认值)。之后,使用DictionaryEntry类型的KeyValue属性将定制数据打印到控制台。

catch (Exception e)
{
...
  Console.WriteLine("\n-> Custom Data:");
  foreach (DictionaryEntry de in e.Data)
  {
    Console.WriteLine("-> {0}: {1}", de.Key, de.Value);
  }
}

有了这个,这就是你看到的最终输出:

***** Simple Exception Example *****
=> Creating a car and stepping on it!
Jamming...
=> CurrentSpeed = 30
=> CurrentSpeed = 40
=> CurrentSpeed = 50
=> CurrentSpeed = 60
=> CurrentSpeed = 70
=> CurrentSpeed = 80
=> CurrentSpeed = 90
*** Error! ***
Member name: Void Accelerate(Int32)
Class defining member: SimpleException.Car
Member type: Method
Message: Zippy has overheated!
Source: SimpleException
Stack: at SimpleException.Car.Accelerate(Int32 delta) ...
       at SimpleException.Program.Main(String[] args) ...
Help Link: http://www.CarsRUs.com

-> Custom Data:
-> TimeStamp: The car exploded at 3/15/2020 16:22:59
-> Cause: You have a lead foot.

***** Out of exception logic *****

Data属性是有用的,因为它允许你装入关于手边错误的定制信息,而不需要构建一个新的类类型来扩展Exception基类。尽管Data属性可能很有帮助,但是,开发人员构建强类型异常类仍然很常见,这些类使用强类型属性来处理自定义数据。

这种方法允许调用者捕捉一个特定的exception派生类型,而不必挖掘数据集合来获得额外的细节。为了理解如何做到这一点,您需要研究系统级异常和应用级异常之间的区别。

系统级异常(系统。系统异常)

那个。NET 5 基础类库定义了很多最终从System.Exception派生的类。例如,System名称空间定义了核心异常对象,如ArgumentOutOfRangeExceptionIndexOutOfRangeExceptionStackOverflowException等等。其他命名空间定义反映该命名空间行为的异常。例如,System.Drawing.Printing定义打印异常,System.IO定义基于输入/输出的异常,System.Data定义以数据库为中心的异常,等等。

引发的异常。NET 5 平台被(恰当地)称为系统异常。这些异常通常被认为是不可恢复的致命错误。系统异常直接从一个名为System.SystemException的基类派生而来,这个基类又从System.Exception派生而来(?? 又从System.Object派生而来)。

public class SystemException : Exception
{
  // Various constructors.
}

假定System.SystemException类型除了一组自定义构造函数之外没有添加任何额外的功能,您可能会奇怪为什么SystemException首先会存在。简单地说,当一个异常类型从System.SystemException派生时,您能够确定。NET 5 运行库是引发异常的实体,而不是执行应用的代码库。您可以使用is关键字非常简单地验证这一点。

// True! NullReferenceException is-a SystemException.
NullReferenceException nullRefEx = new NullReferenceException();
Console.WriteLine(
  "NullReferenceException is-a SystemException? : {0}",
  nullRefEx is SystemException);

应用级异常(系统。应用异常)

鉴于这一切。NET 5 异常是类类型,你可以自由地创建你自己的特定于应用的异常。然而,因为System.SystemException基类表示从运行时抛出的异常,您可能会自然地认为您应该从System.Exception类型中派生您的自定义异常。你可以这样做,但是你可以从System.ApplicationException类派生。

public class ApplicationException : Exception
{
  // Various constructors.
}

SystemException一样,ApplicationException除了一组构造函数之外,没有定义任何额外的成员。从功能上来说,System.ApplicationException的唯一目的是识别错误的来源。当您处理源自System.ApplicationException的异常时,您可以假设该异常是由正在执行的应用的代码库引发的,而不是由。NET 核心基本类库或。NET 5 运行时引擎。

构建自定义异常,取 1

虽然您总是可以抛出System.Exception的实例来发出运行时错误信号(如第一个示例所示),但有时构建一个代表您当前问题的独特细节的强类型异常是有利的。例如,假设您想要构建一个定制的异常(名为CarIsDeadException)来表示加速一辆注定失败的汽车的错误。第一步是从System.Exception / System.ApplicationException派生一个新类(按照惯例,所有异常类名都以Exception后缀结尾)。

Note

通常,所有定制的异常类都应该被定义为公共类(回想一下,非嵌套类型的默认访问修饰符是 internal)。原因是异常通常在程序集边界之外传递,因此调用代码基应该可以访问异常。

创建一个名为 CustomException 的新控制台应用项目,将之前的Car.csRadio.cs文件复制到您的新项目中,并将定义CarRadio类型的名称空间从SimpleException更改为CustomException。接下来,添加一个名为CarIsDeadException.cs的新文件,并添加以下类定义:

using System;

namespace CustomException
{
  // This custom exception describes the details of the car-is-dead condition.
  // (Remember, you can also simply extend Exception.)
  public class CarIsDeadException : ApplicationException
  {
  }
}

与任何类一样,您可以自由地包含任意数量的自定义成员,这些成员可以在调用逻辑的catch块中调用。您也可以自由地重写由父类定义的任何虚拟成员。例如,您可以通过覆盖虚拟的Message属性来实现CarIsDeadException

同样,在抛出异常时,构造函数允许发送方传入时间戳和错误原因,而不是填充数据字典(通过Data属性)。最后,可以使用强类型属性获得时间戳数据和错误原因。

public class CarIsDeadException : ApplicationException
{
  private string _messageDetails = String.Empty;
  public DateTime ErrorTimeStamp {get; set;}
  public string CauseOfError {get; set;}

  public CarIsDeadException(){}
  public CarIsDeadException(string message,
    string cause, DateTime time)
  {
    _messageDetails = message;
    CauseOfError = cause;
    ErrorTimeStamp = time;
  }

  // Override the Exception.Message property.
  public override string Message
    => $"Car Error Message: {_messageDetails}";
}

这里,CarIsDeadException类维护一个私有字段(_messageDetails),表示关于当前异常的数据,可以使用自定义构造函数来设置。从Accelerate()方法中抛出这个异常非常简单。简单地分配、配置和抛出一个CarIsDeadException类型,而不是一个System.Exception

// Throw the custom CarIsDeadException.
public void Accelerate(int delta)
{
...
  throw new CarIsDeadException(
    $"{PetName} has overheated!",
      "You have a lead foot", DateTime.Now)
  {
    HelpLink = "http://www.CarsRUs.com",
  };
...
}

为了捕捉这个传入的异常,现在可以更新您的catch范围来捕捉一个特定的CarIsDeadException类型(然而,考虑到CarIsDeadException是一个System.Exception,捕捉一个System.Exception也是允许的)。

using System;
using CustomException;

Console.WriteLine("***** Fun with Custom Exceptions *****\n");
Car myCar = new Car("Rusty", 90);

try
{
  // Trip exception.
  myCar.Accelerate(50);
}
catch (CarIsDeadException e)
{
  Console.WriteLine(e.Message);
  Console.WriteLine(e.ErrorTimeStamp);
  Console.WriteLine(e.CauseOfError);
}
Console.ReadLine();

因此,既然您已经理解了构建自定义异常的基本过程,那么是时候在这些知识的基础上进行构建了。

构建自定义异常,取 2

当前的CarIsDeadException类型已经覆盖了虚拟的System.Exception.Message属性来配置一个定制的错误消息,并且提供了两个定制的属性来处理额外的数据位。然而,实际上,您不需要覆盖虚拟的Message属性,因为您可以简单地将传入的消息传递给父类的构造函数,如下所示:

public class CarIsDeadException : ApplicationException
{
  public DateTime ErrorTimeStamp { get; set; }
  public string CauseOfError { get; set; }

  public CarIsDeadException() { }

  // Feed message to parent constructor.
  public CarIsDeadException(string message, string cause, DateTime time)
    :base(message)
  {
    CauseOfError = cause;
    ErrorTimeStamp = time;
  }
}

注意,这次您已经用而不是定义了一个字符串变量来表示消息,并且用而不是覆盖了Message属性。相反,您只是将参数传递给基类构造函数。使用这种设计,定制的异常类只不过是从System.ApplicationException派生的一个唯一命名的类(如果合适的话还有附加属性),没有任何基类覆盖。

如果您的大多数(如果不是全部)自定义异常类都遵循这个简单的模式,请不要感到惊讶。很多时候,自定义异常的作用不一定是提供从基类继承的功能之外的额外功能,而是提供一个强名称类型来清楚地标识错误的性质,以便客户端可以为不同类型的异常提供不同的处理程序逻辑。

构建自定义异常,取 3

如果您想要构建一个真正整洁、适当的自定义异常类,您需要确保您的自定义异常执行以下操作:

  • 源自Exception / ApplicationException

  • 定义默认构造函数

  • 定义一个设置继承的Message属性的构造函数

  • 定义一个处理“内部异常”的构造函数

为了完成您对构建定制异常的检查,下面是CarIsDeadException的最后一次迭代,它说明了这些特殊构造函数中的每一个(属性如前面的示例所示):

public class CarIsDeadException : ApplicationException
{
  private string _messageDetails = String.Empty;
  public DateTime ErrorTimeStamp {get; set;}
  public string CauseOfError {get; set;}

  public CarIsDeadException(){}
  public CarIsDeadException(string cause, DateTime time) : this(cause,time,string.Empty)
  {
  }
  public CarIsDeadException(string cause, DateTime time, string message) : this(cause,time,message, null)
  {
  }

  public CarIsDeadException(string cause, DateTime time, string message, System.Exception inner)
    : base(message, inner)
  {
    CauseOfError = cause;
    ErrorTimeStamp = time;
  }
}

随着对您的定制异常的更新,将Accelerate方法更新为以下内容:

throw new CarIsDeadException("You have a lead foot",
  DateTime.Now,$"{PetName} has overheated!")
{
  HelpLink = "http://www.CarsRUs.com",
};

假定构建自定义异常遵循。NET 核心最佳实践的区别仅仅在于它们的名称,您会很高兴地知道 Visual Studio 提供了一个名为Exception的代码片段模板,它将自动生成一个新的异常类,该类遵循。NET 最佳实践。要激活它,在编辑器中键入exc并按 Tab 键(在 Visual Studio 中,按 Tab 键两次)。

处理多个异常

最简单的形式是,一个try块有一个catch块。然而,在现实中,您经常会遇到这样的情况:一个try块中的语句可能会触发许多可能的异常。创建一个名为 ProcessMultipleExceptions 的新 C# 控制台应用项目;将前面的 CustomException 示例中的Car.csRadio.csCarIsDeadException.cs文件复制到新项目中,并相应地更新您的名称空间名称。

现在,更新CarAccelerate()方法,如果传递一个无效参数(可以假设是任何小于零的值),也抛出一个预定义的基类库ArgumentOutOfRangeException。注意,这个异常类的构造函数将有问题的参数的名称作为第一个string,后面跟着一条描述错误的消息。

// Test for invalid argument before proceeding.
public void Accelerate(int delta)
{
  if (delta < 0)
  {
    throw new ArgumentOutOfRangeException(nameof(delta),
      "Speed must be greater than zero");
  }
  ...
}

Note

nameof()操作符返回一个表示对象名称的字符串,在本例中是变量 delta。当需要字符串版本时,这是引用 C# 对象、方法和变量的更安全的方式。

catch逻辑现在可以对每种类型的异常做出具体的响应。

using System;
using System.IO;
using ProcessMultipleExceptions;

Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
  // Trip Arg out of range exception.
  myCar.Accelerate(-10);
}
catch (CarIsDeadException e)
{
  Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
  Console.WriteLine(e.Message);
}
Console.ReadLine();

当您创作多个catch块时,您必须意识到当一个异常被抛出时,它将被第一个适当的 catch 处理。为了准确地说明“第一个适当的”catch 的含义,假设您用一个额外的catch作用域改进了前面的逻辑,该作用域试图通过捕获一个一般的System.Exception来处理CarIsDeadExceptionArgumentOutOfRangeException之外的所有异常,如下所示:

// This code will not compile!
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);

try
{
  // Trigger an argument out of range exception.
  myCar.Accelerate(-10);
}
catch(Exception e)
{
  // Process all other exceptions?
  Console.WriteLine(e.Message);
}
catch (CarIsDeadException e)
{
  Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
  Console.WriteLine(e.Message);
}
Console.ReadLine();

这个异常处理逻辑会生成编译时错误。问题是第一个catch块可以处理System.Exception派生的任何东西(给定“is-a”关系),包括CarIsDeadExceptionArgumentOutOfRangeException类型。因此,最后两个catch区块是不可及的!

要记住的经验法则是确保你的catch块的结构是这样的,第一个 catch 是最具体的异常(即异常类型继承链中最具派生性的类型),最后一个catch是最一般的异常(即给定异常继承链的基类,在这里是System.Exception)。

因此,如果你想定义一个catch块来处理任何超过CarIsDeadExceptionArgumentOutOfRangeException的错误,你可以写如下:

// This code compiles just fine.
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
  // Trigger an argument out of range exception.
  myCar.Accelerate(-10);
}
catch (CarIsDeadException e)
{
  Console.WriteLine(e.Message);
}
catch (ArgumentOutOfRangeException e)
{
  Console.WriteLine(e.Message);
}
// This will catch any other exception
// beyond CarIsDeadException or
// ArgumentOutOfRangeException.
catch (Exception e)
{
  Console.WriteLine(e.Message);
}
Console.ReadLine();

Note

只要有可能,总是支持捕获特定的异常类,而不是一般的System.Exception。虽然它可能在短期内使生活变得简单(你可能会想“啊!这抓住了我不关心的所有其他事情。”),从长远来看,您可能会以奇怪的运行时崩溃告终,因为您的代码中没有直接处理更严重的错误。记住,处理System.Exception的最后一个catch块实际上非常通用。

通用 catch 语句

C# 还支持一个“通用”catch作用域,该作用域不显式接收由给定成员抛出的异常对象。

// A generic catch.
Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
try
{
  myCar.Accelerate(90);
}
catch
{
  Console.WriteLine("Something bad happened...");
}
Console.ReadLine();

显然,这不是处理异常的最有用的方法,因为您无法获得关于所发生错误的有意义的数据(例如方法名、调用堆栈或自定义消息)。尽管如此,C# 确实允许这样的构造,当您希望以一种通用的方式处理所有错误时,这是很有帮助的。

再次引发异常

当你捕捉到一个异常时,允许一个try块中的逻辑将异常重新抛出到调用栈中的前一个调用者。为此,只需在catch块中使用throw关键字。这将异常沿调用逻辑链向上传递,如果您的catch块只能部分处理手边的错误,这将很有帮助。

// Passing the buck.
...
try
{
  // Speed up car logic...
}
catch(CarIsDeadException e)
{
  // Do any partial processing of this error and pass the buck.
  throw;
}
...

请注意,在这个示例代码中,CarIsDeadException的最终接收者是。因为它是重新引发异常的顶级语句。因此,您的最终用户会看到系统提供的错误对话框。通常,您只会将部分处理的异常重新引发给有能力更优雅地处理传入异常的调用方。

还要注意,您没有显式地重新抛出CarIsDeadException对象,而是使用了不带参数的throw关键字。您没有创建新的异常对象;您只是重新抛出原始异常对象(及其所有原始信息)。这样做可以保留原始目标的上下文。

内部异常

正如您可能会怀疑的那样,在您处理另一个异常时触发一个异常是完全可能的。例如,假设您正在一个特定的catch范围内处理一个CarIsDeadException,在这个过程中,您试图将堆栈跟踪记录到您的C:驱动器上一个名为carErrors.txt的文件中(您必须指定您正在使用System.IO命名空间来获得对这些以 I/O 为中心的类型的访问)。

catch(CarIsDeadException e)
{
  // Attempt to open a file named carErrors.txt on the C drive.
  FileStream fs = File.Open(@"C:\carErrors.txt", FileMode.Open);
  ...
}

现在,如果指定的文件不在您的C:驱动器上,那么对File.Open()的调用将导致一个FileNotFoundException!在本书的后面,您将了解关于System.IO名称空间的所有内容,在这里,您将发现如何在试图打开文件之前以编程方式确定文件是否存在于硬盘上(从而完全避免异常)。然而,为了保持对异常主题的关注,假设已经引发了异常。

当您在处理另一个异常时遇到另一个异常时,最佳实践表明,您应该将新的异常对象作为“内部异常”记录在与初始异常类型相同的新对象中。(真是拗口!)您需要为正在处理的异常分配一个新对象的原因是,记录内部异常的唯一方法是通过构造函数参数。考虑以下代码:

using System.IO;
//Update the exception handler
catch (CarIsDeadException e)
{
  try
  {
    FileStream fs =
      File.Open(@"C:\carErrors.txt", FileMode.Open);
    ...
  }
  catch (Exception e2)
  {
    //This causes a compile error-InnerException is read only
    //e.InnerException = e2;
    // Throw an exception that records the new exception,
    // as well as the message of the first exception.
    throw new CarIsDeadException(
      e.CauseOfError, e.ErrorTimeStamp, e.Message, e2);  }
}

注意,在这种情况下,我将FileNotFoundException对象作为第四个参数传递给了CarIsDeadException构造函数。在配置了这个新对象之后,您将它在调用堆栈中向上抛给下一个调用者,在本例中是顶级语句。

假设在顶级语句之后没有“下一个调用者”来捕捉异常,您将再次看到一个错误对话框。与再次引发异常的行为非常相似,记录内部异常通常只有在调用者有能力首先捕获异常时才有用。如果是这种情况,调用者的catch逻辑可以使用InnerException属性提取内部异常对象的细节。

最终块

一个try / catch作用域也可以定义一个可选的finally块。一个finally块的目的是确保一组代码语句将总是执行,不管是否有异常(任何类型)。举例来说,假设您希望在退出程序之前总是关闭汽车的收音机,而不考虑任何已处理的异常。

Console.WriteLine("***** Handling Multiple Exceptions *****\n");
Car myCar = new Car("Rusty", 90);
myCar.CrankTunes(true);
try
{
  // Speed up car logic.
}
catch(CarIsDeadException e)
{
  // Process CarIsDeadException.
}
catch(ArgumentOutOfRangeException e)
{
  // Process ArgumentOutOfRangeException.
}
catch(Exception e)
{
  // Process any other Exception.
}
finally
{
  // This will always occur. Exception or not.
  myCar.CrankTunes(false);
}
Console.ReadLine();

如果您没有包含finally块,无线电将不会在遇到异常时关闭(这可能有问题,也可能没有问题)。在一个更真实的场景中,当您需要处理对象、关闭文件或从数据库中分离(或其他)时,一个finally块确保了一个适当清理的位置。

异常过滤器

C# 6 引入了一个新的子句,可以通过关键字when放在catch范围内。当您添加这个子句时,您有能力确保只有当代码中的某些条件为真时,才会执行catch块中的语句。该表达式必须计算为布尔值(真或假),可以通过在when定义中使用简单的代码语句或在代码中调用额外的方法来获得。简而言之,这种方法允许您向异常逻辑添加“过滤器”。

考虑下面修改过的异常逻辑。我在CarIsDeadException处理程序中添加了一个when子句,以确保catch块永远不会在星期五被执行(这是一个人为的例子,但是谁会希望他们的汽车在周末前抛锚呢?).注意,when子句中的单个布尔语句必须用括号括起来。

catch (CarIsDeadException e) when (e.ErrorTimeStamp.DayOfWeek != DayOfWeek.Friday)
{
  // This new line will only print if the when clause evaluates to true.
  Console.WriteLine("Catching car is dead!");

  Console.WriteLine(e.Message);
}

虽然这个例子非常牵强,但是使用异常过滤器的一个更实际的用途是捕获SystemException s。例如,假设您的代码正在将数据保存到数据库,则会引发一个一般的异常。通过检查消息和异常量据,可以根据导致异常的原因创建特定的处理程序。

使用 Visual Studio 调试未处理的异常

Visual Studio 提供了许多工具来帮助您调试未处理的异常。假设您已经将一个Car对象的速度提高到超过最大值,但是这次没有麻烦将您的调用封装在一个try块中。

Car myCar = new Car("Rusty", 90);
myCar.Accelerate(100);

如果在 Visual Studio 中启动调试会话(使用“调试”➤“启动调试”菜单选项),Visual Studio 会在引发未捕获的异常时自动中断。更好的是,你会看到一个窗口(见图 7-1 )显示Message属性的值。

img/340876_10_En_7_Fig1_HTML.jpg

图 7-1。

用 Visual Studio 调试未处理的自定义异常

Note

如果未能处理由。NET 基类库,Visual Studio 调试器在调用有问题的方法的语句处中断。

如果您点击查看详细信息链接,您将找到关于对象状态的详细信息(参见图 7-2 )。

img/340876_10_En_7_Fig2_HTML.jpg

图 7-2。

查看异常详细信息

摘要

在本章中,您研究了结构化异常处理的作用。当一个方法需要向调用者发送一个错误对象时,它会通过 C# throw关键字分配、配置并抛出一个特定的System.Exception派生类型。调用者能够使用 C# catch关键字和可选的finally作用域来处理任何可能的异常。从 C# 6.0 开始,增加了使用可选的when关键字创建异常过滤器的能力,C# 7 扩展了抛出异常的位置。

当您创建自己的定制异常时,您最终会创建一个从System.ApplicationException派生的类类型,这表示一个从当前执行的应用抛出的异常。相反,从System.SystemException派生的错误对象表示由。NET 5 运行时。最后但同样重要的是,本章说明了 Visual Studio 中可用于创建自定义异常的各种工具(根据。NET 最佳实践)以及调试异常。*

八、使用接口

本章通过研究基于接口的编程主题,建立在您当前对面向对象开发的理解之上。在这里,您将学习如何定义和实现接口,并逐渐理解构建支持多种行为的类型的好处。在这个过程中,您将会看到几个相关的主题,比如获取接口引用、实现显式接口和构造接口层次结构。您还将研究几个在?NET 核心基本类库。还涵盖了 C# 8 中关于接口的新特性,包括默认接口方法、静态成员和访问修饰符。正如您将看到的,您的自定义类和结构可以自由地实现这些预定义的接口,以支持一些有用的行为,如对象克隆、对象枚举和对象排序。

了解接口类型

在本章开始,请允许我提供一个接口类型的正式定义,它随着 C# 8.0 的引入而改变。在 C# 8.0 之前,接口只不过是一组命名的抽象成员。回想一下第六章中的抽象方法是纯协议,因为它们不提供默认的实现。接口定义的具体成员取决于它所建模的确切行为。换句话说,一个接口表达了一个给定的类或结构可能选择支持的行为。此外,正如你将在本章看到的,一个类或结构可以支持任意多的接口,从而支持(本质上)多种行为。

C# 8.0 中引入的默认接口方法功能允许接口方法包含一个实现,该实现可能会也可能不会被实现类重写。本章后面会有更多的介绍。

正如您可能猜到的那样。NET Core 基本类库附带了许多预定义的接口类型,这些接口类型由各种类和结构实现。例如,正如您将在第二十一章中看到的,ADO.NET 提供了多个数据提供程序,允许您与特定的数据库管理系统进行通信。因此,在 ADO.NET 下,您有许多连接对象可供选择(SqlConnectionOleDbConnectionOdbcConnection等)。).此外,第三方数据库供应商(以及众多开源项目)提供。NET 库与大量其他数据库(MySQL、Oracle 等)进行通信。),所有这些都包含实现这些接口的对象。

尽管每个连接类都有一个惟一的名称,在不同的名称空间中定义,并且(在某些情况下)捆绑在不同的程序集中,但是所有连接类都实现了一个名为IDbConnection的公共接口。

// The IDbConnection interface defines a common
// set of members supported by all connection objects.
public interface IDbConnection : IDisposable
{
   // Methods
   IDbTransaction BeginTransaction();
   IDbTransaction BeginTransaction(IsolationLevel il);
   void ChangeDatabase(string databaseName);
   void Close();
   IDbCommand CreateCommand();
   void Open();
   // Properties
   string ConnectionString { get; set;}
   int ConnectionTimeout { get; }
   string Database { get; }

   ConnectionState State { get; }
}

Note

按照惯例,。NET 接口名称的前缀是大写字母 I 。当您创建自己的自定义接口时,最好也这样做。

此时不要关心这些成员做什么的细节。简单地理解一下,IDbConnection接口定义了一组所有 ADO.NET 连接类共有的成员。鉴于此,可以保证每个连接对象都支持诸如Open()Close()CreateCommand()等成员。此外,由于接口成员总是抽象的,每个连接对象都可以以自己独特的方式自由实现这些方法。

在阅读本书的剩余部分时,您将会接触到。NET 核心基本类库。正如您将看到的,这些接口可以在您自己的定制类和结构上实现,以定义与框架紧密集成的类型。同样,一旦你理解了接口类型的有用性,你肯定会找到构建你自己的接口类型的理由。

接口类型与抽象基类

鉴于你在第六章中的工作,接口类型可能看起来有点像抽象基类。回想一下,当一个类被标记为抽象时,它可以定义任意数量的抽象成员来为所有派生类型提供多态接口。然而,即使一个类定义了一组抽象成员,它也可以自由定义任意数量的构造函数、字段数据、非抽象成员(带实现)等等。接口(在 C# 8.0 之前)只包含成员定义。现在,有了 C# 8,接口可以包含成员定义(比如抽象成员)、具有默认实现的成员(比如虚方法)和静态成员。真正的区别只有两个:接口不能有非静态的构造函数,一个类可以实现多个接口。接下来我们将详细讨论第二点。

由抽象父类建立的多态接口有一个主要的限制,即只有 派生类型支持由抽象父类定义的成员。然而,在更大的软件系统中,开发多个除了System.Object之外没有公共父类的类层次结构是很常见的。假设抽象基类中的抽象成员只适用于派生类型,您就没有办法在不同的层次结构中配置类型来支持相同的多态接口。首先,创建一个名为 CustomInterfaces 的新控制台应用项目。将以下抽象类添加到项目中:

namespace CustomInterfaces
{
  public abstract class CloneableType
  {
    // Only derived types can support this
    // "polymorphic interface." Classes in other
    // hierarchies have no access to this abstract
   // member.
    public abstract object Clone();
  }
}

根据这个定义,只有扩展了CloneableType的成员才能支持Clone()方法。如果你创建了一组新的类,但没有扩展这个基类,你就不能获得这个多态接口。同样,回想一下 C# 不支持类的多重继承。因此,如果你想创造一个“是-a”Car和“是-a”CloneableTypeMiniVan,你是无法做到的。

// Nope! Multiple inheritance is not possible in C#
// for classes.
public class MiniVan : Car, CloneableType
{
}

正如你可能猜到的那样,接口类型来帮忙了。定义接口后,它可以由任何类或结构、任何层次结构、任何命名空间或任何程序集(用任何。NET 核心编程语言)。如你所见,接口是高度多态的*。考虑标准。NET 核心接口命名为ICloneable,定义在System命名空间中。这个接口定义了一个名为Clone()的方法。*

public interface ICloneable
{
  object Clone();
}

如果你检查。NET 核心基础类库,你会发现很多看似不相关的类型(System.ArraySystem.Data.SqlClient.SqlConnectionSystem.OperatingSystemSystem.String等。)都实现了这个接口。尽管这些类型没有共同的父类型(除了System.Object,但是您可以通过ICloneable接口类型对它们进行多态处理。

首先,清除Program.cs代码并添加以下内容:

using System;
using CustomInterfaces;

Console.WriteLine("***** A First Look at Interfaces *****\n");
CloneableExample();

接下来,将下面名为CloneMe()的局部函数添加到顶级语句中。该函数接受一个ICloneable接口参数,该参数接受实现该接口的任何对象。下面是功能代码:

static void CloneableExample()
{
  // All of these classes support the ICloneable interface.
  string myStr = "Hello";
  OperatingSystem unixOS =
    new OperatingSystem(PlatformID.Unix, new Version());

  // Therefore, they can all be passed into a method taking ICloneable.
  CloneMe(myStr);
  CloneMe(unixOS);
  static void CloneMe(ICloneable c)
  {
    // Clone whatever we get and print out the name.
    object theClone = c.Clone();
    Console.WriteLine("Your clone is a: {0}",
      theClone.GetType().Name);
  }
}

当您运行这个应用时,每个类的类名通过您从System.Object继承的GetType()方法打印到控制台。正如将在第十七章中详细解释的,这个方法允许你在运行时理解任何类型的组成。无论如何,上一个程序的输出如下所示:

***** A First Look at Interfaces *****
Your clone is a: String
Your clone is a: OperatingSystem

抽象基类的另一个限制是每个派生类型必须与一组抽象成员竞争并提供一个实现。为了解决这个问题,回想一下你在第六章中定义的形状层次。假设您在名为GetNumberOfPoints()Shape基类中定义了一个新的抽象方法,它允许派生类型返回呈现形状所需的点数。

namespace CustomInterfaces
{
  abstract class Shape
  {
...
    // Every derived class must now support this method!
   public abstract byte GetNumberOfPoints();
  }
}

显然,唯一有分数的职业是Hexagon。然而,有了这次更新,每个派生类(CircleHexagonThreeDCircle)现在都必须提供这个函数的具体实现,即使这样做毫无意义。同样,接口类型提供了一个解决方案。如果你定义了一个代表“拥有点”行为的接口,你可以简单地把它插入到Hexagon类型中,而不去碰CircleThreeDCircle

Note

在我的记忆中,C# 8 中对接口的改变可能是对现有语言特性最重要的改变。如前所述,新的接口功能使它们更接近抽象类的功能,增加了一个类实现多个接口的能力。我的建议是在这些水域中小心行事,运用常识。仅仅因为你能做某事并不意味着你应该做。

定义自定义接口

现在,您已经更好地理解了接口类型的总体作用,让我们来看一个定义和实现定制接口的例子。从您在第六章创建的 Shapes 解决方案中复制Shape.csHexagon.csCircle.csThreeDCircle.cs文件。完成之后,将定义以形状为中心的类型的名称空间重命名为CustomInterfaces(只是为了避免在新项目中导入名称空间定义)。现在,在您的项目中插入一个名为IPointy.cs的新文件。

在语法层面,接口是使用 C# interface关键字定义的。与类不同,接口从不指定基类(甚至不指定System.Object;然而,正如你将在本章后面看到的,一个接口可以指定基本接口。在 C# 8.0 之前,接口成员从不指定访问修饰符(因为所有接口成员都是隐式公共和抽象的)。C# 8.0 中的新特性,私有、内部、受保护甚至静态成员也可以被定义。稍后将对此进行更多介绍。为了让球滚动起来,这里有一个用 C# 定义的自定义接口:

namespace CustomInterfaces
{
  // This interface defines the behavior of "having points."
  public interface IPointy
  {
    // Implicitly public and abstract.
    byte GetNumberOfPoints();
  }
}

C# 8 中的接口不能定义数据字段或非静态构造函数。因此,以下版本的IPointy将导致各种编译器错误:

// Ack! Errors abound!
public interface IPointy
{
  // Error! Interfaces cannot have data fields!
  public int numbOfPoints;
  // Error! Interfaces do not have nonstatic constructors!
  public IPointy() { numbOfPoints = 0;}
}

无论如何,这个初始的IPointy接口定义了一个方法。接口类型也能够定义任意数量的属性原型。例如,我们可以更新IPointy接口来使用一个读写属性(注释掉)和一个只读属性。Points属性取代了GetNumberOfPoints()方法。

// The pointy behavior as a read-only property.
public interface IPointy
{
  // Implicitly public and abstract.
  //byte GetNumberOfPoints();

  // A read-write property in an interface would look like:
  //string PropName { get; set; }

  // while a write-only property in an interface would be:
   byte Points { get; }
}

Note

接口类型也可以包含事件(见第十二章)和索引器(见第十一章)定义。

接口类型本身毫无用处,因为你不能像分配一个类或结构那样分配接口类型。

// Ack! Illegal to allocate interface types.
IPointy p = new IPointy(); // Compiler error!

在被类或结构实现之前,接口不会带来太多好处。这里,IPointy是一个表示“有积分”行为的接口。这个想法很简单:形状层次结构中的一些类有点(如Hexagon),而另一些类(如Circle)没有点。

实现接口

当一个类(或结构)选择通过支持接口来扩展其功能时,它会在类型定义中使用逗号分隔的列表。请注意,直接基类必须是冒号运算符后列出的第一项。当您的类类型直接从System.Object派生时,您可以简单地列出该类支持的接口(或多个接口),因为如果您没有另外说明,C# 编译器将从System.Object扩展您的类型。与此相关的一点是,鉴于结构总是从System.ValueType派生而来(参见第章第四部分),只需在结构定义后直接列出每个接口。思考下面的例子:

// This class derives from System.Object and
// implements a single interface.
public class Pencil : IPointy
{...}

// This class also derives from System.Object
// and implements a single interface.
public class SwitchBlade : object, IPointy
{...}

// This class derives from a custom base class
// and implements a single interface.
public class Fork : Utensil, IPointy
{...}

// This struct implicitly derives from System.ValueType and
// implements two interfaces.
public struct PitchFork : ICloneable, IPointy
{...}

要明白,对于不包含默认实现的接口项来说,实现接口是一个要么全有要么全无的命题。支持类型不能有选择地选择它将实现哪些成员。鉴于IPointy接口定义了一个只读属性,这并不是太大的负担。然而,如果你正在实现一个定义了 10 个成员的接口(比如前面显示的IDbConnection接口),那么这个类型现在负责充实所有 10 个抽象成员的细节。

对于这个例子,插入一个名为Triangle的新类类型,它“是-a”Shape并支持IPointy。注意,只读Points属性的实现(使用表达式主体成员语法实现)只是返回正确的点数(三)。

using System;
namespace CustomInterfaces
{
  // New Shape derived class named Triangle.
  class Triangle : Shape, IPointy
  {
    public Triangle() { }
    public Triangle(string name) : base(name) { }
    public override void Draw()
    {
      Console.WriteLine("Drawing {0} the Triangle", PetName);
    }

    // IPointy implementation.
    //public byte Points
    //{
      //    get { return 3; }
    //}
    public byte Points => 3;
  }
}

现在,更新您现有的Hexagon类型来支持IPointy接口类型。

using System;
namespace CustomInterfaces
{
  // Hexagon now implements IPointy.
  class Hexagon : Shape, IPointy
  {
    public Hexagon(){ }
    public Hexagon(string name) : base(name){ }
    public override void Draw()
    {
      Console.WriteLine("Drawing {0} the Hexagon", PetName);
    }

    // IPointy implementation.
    public byte Points => 6;
  }
}

综上所述,图 8-1 所示的 Visual Studio 类图使用流行的“棒棒糖”符号说明了与IPointy兼容的类。再次注意,CircleThreeDCircle没有实现IPointy,因为这种行为对这些类没有意义。

img/340876_10_En_8_Fig1_HTML.jpg

图 8-1。

形状层次结构,现在带有接口

Note

若要在类设计器中显示或隐藏接口名称,请右键单击接口图标,然后选择折叠或展开选项。

在对象级别调用接口成员

既然已经有了一些支持IPointy接口的类,下一个问题就是如何与新功能交互。与给定接口提供的功能进行交互的最直接方式是直接从对象级别调用成员(假设接口成员没有显式实现;您可以在“实现显式接口”一节中找到更多的细节)。例如,考虑以下代码:

Console.WriteLine("***** Fun with Interfaces *****\n");
// Call Points property defined by IPointy.
Hexagon hex = new Hexagon();
Console.WriteLine("Points: {0}", hex.Points);
Console.ReadLine();

在这种情况下,这种方法工作得很好,假设您知道Hexagon类型已经实现了正在讨论的接口,因此有一个Points属性。但是,其他时候,您可能无法确定给定类型支持哪些接口。例如,假设您有一个包含 50 个Shape兼容类型的数组,其中只有一部分支持IPointy。显然,如果你试图在一个没有实现IPointy的类型上调用Points属性,你会收到一个错误。那么,如何动态地确定一个类或结构是否支持正确的接口呢?

在运行时确定类型是否支持特定接口的一种方法是使用显式强制转换。如果类型不支持请求的接口,您会收到一个InvalidCastException。若要妥善处理这种可能性,请使用结构化异常处理,如下例所示:

...
// Catch a possible InvalidCastException.
Circle c = new Circle("Lisa");
IPointy itfPt = null;
try
{
  itfPt = (IPointy)c;
  Console.WriteLine(itfPt.Points);
}
catch (InvalidCastException e)
{
  Console.WriteLine(e.Message);
}
Console.ReadLine();

虽然您可以使用try / catch逻辑并抱乐观态度,但是在调用接口成员之前确定支持哪些接口是最理想的。让我们看看这样做的两种方法。

获取接口引用:as 关键字

你可以通过使用第六章中介绍的as关键字来确定一个给定的类型是否支持一个接口。如果对象可以被视为指定的接口,则返回一个对相关接口的引用。如果没有,您将收到一个null参考。因此,在继续之前,一定要检查null值。

...
// Can we treat hex2 as IPointy?
Hexagon hex2 = new Hexagon("Peter");
IPointy itfPt2 = hex2 as IPointy;
if(itfPt2 != null)
{
  Console.WriteLine("Points: {0}", itfPt2.Points);
}
else
{
   Console.WriteLine("OOPS! Not pointy...");
}
Console.ReadLine();

注意,当你使用as关键字时,你不需要使用try / catch逻辑;如果引用不是null,那么您知道您正在调用一个有效的接口引用。

获取接口引用:is 关键字(更新于 7.0)

你也可以使用关键字is检查一个实现的接口(也在第六章中首次讨论)。如果有问题的对象与指定的接口不兼容,则返回值false。如果在语句中提供变量名,则该类型被赋给该变量,从而消除了进行类型检查和强制转换的需要。前面的示例在此处更新:

Console.WriteLine("***** Fun with Interfaces *****\n");
...
if(hex2 is IPointy itfPt3)
{
  Console.WriteLine("Points: {0}", itfPt3.Points);
}
else
{
  Console.WriteLine("OOPS! Not pointy...");
}
 Console.ReadLine();

默认实现(新 8.0)

如前所述,C# 8.0 增加了接口方法和属性拥有默认实现的能力。添加一个名为IRegularPointy的新接口来表示一个规则形状的多边形。代码如下所示:

namespace CustomInterfaces
{
  interface IRegularPointy : IPointy
  {
    int SideLength { get; set; }
    int NumberOfSides { get; set; }
    int Perimeter => SideLength * NumberOfSides;
  }
}

向项目中添加一个名为Square.cs的新类,继承Shape基类,并实现IRegularPointy接口,如下所示:

namespace CustomInterfaces
{
  class Square: Shape,IRegularPointy
  {
    public Square() { }
    public Square(string name) : base(name) { }
    //Draw comes from the Shape base class
    public override void Draw()
    {
      Console.WriteLine("Drawing a square");
    }

    //This comes from the IPointy interface
    public byte Points => 4;
    //These come from the IRegularPointy interface
    public int SideLength { get; set; }
    public int NumberOfSides { get; set; }
    //Note that the Perimeter property is not implemented
  }
}

这里我们无意中引入了在接口中使用默认实现的第一个“陷阱”。在IRegularPointy接口上定义的Perimeter属性没有在Square类中定义,这使得它不能从Square的实例中访问。要查看实际情况,创建一个Square类的新实例,并将相关值输出到控制台,如下所示:

Console.WriteLine("\n***** Fun with Interfaces *****\n");
...
var sq = new Square("Boxy")
  {NumberOfSides = 4, SideLength = 4};
sq.Draw();
//This won’t compile
//Console.WriteLine($"{sq.PetName} has {sq.NumberOfSides} of length {sq.SideLength} and a perimeter of {sq.Perimeter}");

相反,Square实例必须被显式地转换为IRegularPointy接口(因为这是实现所在的地方),然后才能访问Perimeter属性。将代码更新为以下内容:

Console.WriteLine($"{sq.PetName} has {sq.NumberOfSides} of length {sq.SideLength} and a perimeter of {((IRegularPointy)sq).Perimeter}");

解决这个问题的一个选择是始终对类型的接口进行编码。将Square实例的定义从Square改为IRegularPointy,如下所示:

IRegularPointy sq = new Square("Boxy") {NumberOfSides = 4, SideLength = 4};

这种方法的问题是Draw()方法和PetName属性没有在接口上定义,导致编译错误。

虽然这是一个微不足道的例子,但它确实展示了默认接口的一个问题。在您的代码中使用该特性之前,请确保您衡量了调用代码必须知道实现存在于何处的含义。

静态构造函数和成员(新 8.0)

C# 8.0 中接口的另一个新增功能是拥有静态构造函数和成员的能力,它们的功能与类定义中的静态成员相同,但都是在接口上定义的。用一个示例静态属性和一个静态构造函数更新IRegularPointy接口。

interface IRegularPointy : IPointy
{
  int SideLength { get; set; }
  int NumberOfSides { get; set; }
  int Perimeter => SideLength * NumberOfSides;

  //Static members are also allowed in C# 8
  static string ExampleProperty { get; set; }

  static IRegularPointy() => ExampleProperty = "Foo";
}

静态构造函数必须是无参数的,并且只能访问静态属性和方法。若要访问接口静态属性,请将以下代码添加到顶级语句中:

Console.WriteLine($"Example property: {IRegularPointy.ExampleProperty}");
IRegularPointy.ExampleProperty = "Updated";
Console.WriteLine($"Example property: {IRegularPointy.ExampleProperty}");

请注意,静态属性必须从接口而不是实例变量中调用。

作为参数的接口

假设接口是有效的类型,你可以构造将接口作为参数的方法,如本章前面的CloneMe()方法所示。对于当前的例子,假设您已经定义了另一个名为IDraw3D的接口。

namespace CustomInterfaces
{
  // Models the ability to render a type in stunning 3D.
  public interface IDraw3D
  {
    void Draw3D();
  }
}

接下来,假设您的三个形状中的两个(ThreeDCircleHexagon)已经被配置为支持这个新行为。

// Circle supports IDraw3D.
class ThreeDCircle : Circle, IDraw3D
{
...
  public void Draw3D()
    =>  Console.WriteLine("Drawing Circle in 3D!"); }
}

// Hexagon supports IPointy and IDraw3D.
class Hexagon : Shape, IPointy, IDraw3D
{
...
  public void Draw3D()
    => Console.WriteLine("Drawing Hexagon in 3D!");
}

图 8-2 展示了更新后的 Visual Studio 类图。

img/340876_10_En_8_Fig2_HTML.jpg

图 8-2。

更新的形状层次结构

如果您现在定义了一个将IDraw3D接口作为参数的方法,那么您可以有效地发送任何实现IDraw3D的对象。如果试图传入不支持必要接口的类型,则会收到编译时错误。考虑在您的Program类中定义的以下方法:

// I'll draw anyone supporting IDraw3D.
static void DrawIn3D(IDraw3D itf3d)
{
  Console.WriteLine("-> Drawing IDraw3D compatible type");
  itf3d.Draw3D();
}

您现在可以测试Shape数组中的一个项目是否支持这个新接口,如果支持,就将其传递给DrawIn3D()方法进行处理。

Console.WriteLine("***** Fun with Interfaces *****\n");
Shape[] myShapes = { new Hexagon(), new Circle(),
  new Triangle("Joe"), new Circle("JoJo") } ;
for(int i = 0; i < myShapes.Length; i++)
{
  // Can I draw you in 3D?
  if (myShapes[i] is IDraw3D s)
  {
    DrawIn3D(s);
  }
}

下面是更新后的应用的输出。注意,只有Hexagon对象在 3D 中打印出来,因为Shape数组的其他成员没有实现IDraw3D接口。

***** Fun with Interfaces *****
...
-> Drawing IDraw3D compatible type
Drawing Hexagon in 3D!

作为返回值的接口

接口也可以用作方法返回值。例如,您可以编写一个方法,该方法采用一组Shape对象,并返回对第一个支持IPointy的项的引用。

// This method returns the first object in the
// array that implements IPointy.
static IPointy FindFirstPointyShape(Shape[] shapes)
{
  foreach (Shape s in shapes)
  {
    if (s is IPointy ip)
    {
      return ip;
    }
  }
  return null;
}

您可以按如下方式与此方法交互:

Console.WriteLine("***** Fun with Interfaces *****\n");
// Make an array of Shapes.
Shape[] myShapes = { new Hexagon(), new Circle(),
                 new Triangle("Joe"), new Circle("JoJo")};

// Get first pointy item.
IPointy firstPointyItem = FindFirstPointyShape(myShapes);
// To be safe, use the null conditional operator.
Console.WriteLine("The item has {0} points",
  firstPointyItem?.Points);

接口类型数组

回想一下,同一个接口可以由许多类型实现,即使它们不在同一个类层次结构中,并且没有超过System.Object的公共父类。这可以产生一些强大的编程结构。例如,假设您已经在您当前的项目中开发了三个新的类类型来建模厨房用具(通过KnifeFork类)和另一个建模园艺设备(à la PitchFork)。这里显示了类的相关代码,更新后的类图如图 8-3 所示:

img/340876_10_En_8_Fig3_HTML.jpg

图 8-3。

回想一下,接口可以“插入”类层次结构中任何部分的任何类型

//Fork.cs
namespace CustomInterfaces
{
  class Fork : IPointy
  {
    public byte Points => 4;
  }
}
//PitchFork.cs
namespace CustomInterfaces
{
  class PitchFork : IPointy
  {
    public byte Points => 3;
  }
}
//Knife.cs.cs
namespace CustomInterfaces
{
  class Knife : IPointy
  {
    public byte Points => 1;
  }
}

如果您定义了PitchForkForkKnife类型,那么您现在可以定义一个IPointy兼容对象的数组。假设这些成员都支持相同的接口,那么您可以遍历数组并将每一项视为一个IPointy兼容的对象,而不管类层次结构的总体多样性。

...
// This array can only contain types that
// implement the IPointy interface.
IPointy[] myPointyObjects = {new Hexagon(), new Knife(),
  new Triangle(), new Fork(), new PitchFork()};

foreach(IPointy i in myPointyObjects)
{
  Console.WriteLine("Object has {0} points.", i.Points);
}
Console.ReadLine();

为了强调这个例子的重要性,请记住:当你有一个给定接口的数组时,这个数组可以包含任何实现这个接口的类或结构。

自动使用实现接口

尽管基于接口的编程是一种强大的技术,但是实现接口可能需要大量的输入。鉴于接口是一组命名的抽象成员,您需要在支持行为的每个类型上键入每个接口方法的定义和实现。因此,如果您想要支持一个总共定义了五个方法和三个属性的接口,您需要考虑所有八个成员(否则您将会收到编译器错误)。

正如您所希望的那样,Visual Studio 和 Visual Studio 代码都支持各种工具,这些工具可以减轻实现接口的负担。通过一个简单的测试,将一个 final 类插入到当前名为PointyTestClass的项目中。当您向一个类类型添加一个像IPointy这样的接口(或者任何这样的接口)时,您可能已经注意到,当您完成输入接口名称时(或者当您将鼠标光标放在代码窗口中的接口名称上时),Visual Studio 和 Visual Studio 代码都添加了一个灯泡,它也可以用 Ctrl+句点(.)组合键。当你点击灯泡时,会出现一个下拉列表,允许你实现接口(见图 8-4 和 8-5 )。

img/340876_10_En_8_Fig5_HTML.jpg

图 8-5。

使用 Visual Studio 自动实现接口

img/340876_10_En_8_Fig4_HTML.jpg

图 8-4。

使用 Visual Studio 代码自动实现接口

注意,您有两个选择,第二个(显式接口实现)将在下一节中讨论。暂时选择第一个选项,您会看到 Visual Studio/Visual Studio 代码已经生成了存根代码供您更新。(注意默认实现抛出一个System.NotImplementedException,显然可以删除。)

namespace CustomInterfaces
{
  class PointyTestClass : IPointy
  {
    public byte Points => throw new NotImplementedException();
  }
}

Note

Visual Studio /Visual Studio 代码还支持提取接口重构,可从“快速操作”菜单的“提取接口”选项中获得。这允许您从现有的类定义中提取新的接口定义。例如,您可能正在编写一个类,这时您突然意识到可以将行为一般化到一个接口中(从而打开了替代实现的可能性)。

显式接口实现

如本章前面所示,一个类或结构可以实现任意数量的接口。考虑到这一点,您总是有可能实现包含相同成员的接口,因此有名称冲突要处理。为了说明解决此问题的各种方式,请创建一个名为 InterfaceNameClash 的新控制台应用项目。现在设计三个接口,表示实现类型可以将其输出呈现到的不同位置。

namespace InterfaceNameClash
{
  // Draw image to a form.
  public interface IDrawToForm
  {
    void Draw();
  }
}

namespace InterfaceNameClash
{
  // Draw to buffer in memory.
  public interface IDrawToMemory
  {
    void Draw();
  }
}

namespace InterfaceNameClash
{
  // Render to the printer.
  public interface IDrawToPrinter
  {
    void Draw();
  }
}

注意,每个接口都定义了一个名为Draw()的方法,具有相同的签名。如果您现在想要在名为Octagon的单个类类型上支持这些接口中的每一个,编译器将允许以下定义:

using System;
namespace InterfaceNameClash
{
  class Octagon : IDrawToForm, IDrawToMemory, IDrawToPrinter
  {
   public void Draw()
   {
      // Shared drawing logic.
      Console.WriteLine("Drawing the Octagon...");
    }
  }
}

尽管代码可以干净地编译,但是您可能会遇到一个问题。简单地说,提供Draw()方法的单一实现并不允许您根据从Octagon对象获得的接口采取独特的行动。例如,下面的代码将调用相同的Draw()方法,而不管您获得哪个接口:

using System;
using InterfaceNameClash;

Console.WriteLine("***** Fun with Interface Name Clashes *****\n");
// All of these invocations call the
// same Draw() method!
Octagon oct = new Octagon();

// Shorthand notation if you don't need
// the interface variable for later use.
((IDrawToPrinter)oct).Draw();

// Could also use the "is" keyword.
if (oct is IDrawToMemory dtm)
{
  dtm.Draw();
}

Console.ReadLine();

显然,将图像呈现到窗口所需的代码与将图像呈现到网络打印机或内存区域所需的代码完全不同。当您实现几个具有相同成员的接口时,您可以使用显式接口实现语法来解决这种名称冲突。考虑以下对Octagon类型的更新:

class Octagon : IDrawToForm, IDrawToMemory, IDrawToPrinter
{
   // Explicitly bind Draw() implementations
   // to a given interface.
   void IDrawToForm.Draw()
   {
     Console.WriteLine("Drawing to form...");
   }
   void IDrawToMemory.Draw()
   {
     Console.WriteLine("Drawing to memory...");
   }
   void IDrawToPrinter.Draw()
   {
     Console.WriteLine("Drawing to a printer...");
   }
}

如您所见,当显式实现接口成员时,一般模式可以分解为:

returnType InterfaceName.MethodName(params){}

请注意,使用此语法时,不需要提供访问修饰符;显式实现的成员自动是私有的。例如,以下是非法语法:

// Error! No access modifier!
public void IDrawToForm.Draw()
{
   Console.WriteLine("Drawing to form...");
}

因为显式实现的成员总是隐式私有的,所以这些成员在对象级别不再可用。事实上,如果您将点运算符应用于一个Octagon类型,您会发现 IntelliSense 不会向您显示任何Draw()成员。正如所料,您必须使用显式转换来访问所需的功能。顶层语句中的前一段代码已经使用了显式强制转换,因此它可以使用显式接口。

Console.WriteLine("***** Fun with Interface Name Clashes *****\n");
Octagon oct = new Octagon();

// We now must use casting to access the Draw()
// members.
IDrawToForm itfForm = (IDrawToForm)oct;
itfForm.Draw();

// Shorthand notation if you don't need
// the interface variable for later use.
((IDrawToPrinter)oct).Draw();

// Could also use the "is" keyword.
if (oct is IDrawToMemory dtm)
{
  dtm.Draw();
}
Console.ReadLine();

虽然当您需要解决名称冲突时,这种语法非常有用,但是您可以使用显式接口实现来简单地隐藏对象级别的更多“高级”成员。这样,当对象用户应用点运算符时,用户将只能看到该类型整体功能的一个子集。但是,那些需要更高级行为的人可以通过显式强制转换提取所需的接口。

设计接口层次结构

接口可以排列在接口层次结构中。像类层次结构一样,当一个接口扩展一个现有的接口时,它继承了由父类定义的抽象成员。在 C# 8 之前,派生接口从不继承真正的实现。相反,派生接口只是用额外的抽象成员扩展了它自己的定义。在 C# 8 中,派生接口继承了默认实现,扩展了定义,并可能添加新的默认实现。

当您希望在不破坏现有代码库的情况下扩展现有接口的功能时,接口层次结构会很有用。为了进行说明,创建一个名为 InterfaceHierarchy 的新控制台应用项目。现在,让我们设计一组新的以渲染为中心的接口,这样IDrawable就是家谱的根。

namespace InterfaceHierarchy
{
  public interface IDrawable
  {
    void Draw();
  }
}

鉴于IDrawable定义了一个基本的绘制行为,您现在可以创建一个派生接口,用修改后的格式来扩展这个接口。这里有一个例子:

namespace InterfaceHierarchy
{
  public interface IAdvancedDraw : IDrawable
  {
    void DrawInBoundingBox(int top, int left, int bottom, int right);
    void DrawUpsideDown();
  }
}

根据这种设计,如果一个类要实现IAdvancedDraw,那么它现在需要实现继承链中定义的每个成员(特别是Draw()DrawInBoundingBox()DrawUpsideDown()方法)。

using System;
namespace InterfaceHierarchy
{
  public class BitmapImage : IAdvancedDraw
  {
    public void Draw()
    {
      Console.WriteLine("Drawing...");
    }

    public void DrawInBoundingBox(int top, int left, int bottom, int right)
    {
      Console.WriteLine("Drawing in a box...");
    }

    public void DrawUpsideDown()
    {
      Console.WriteLine("Drawing upside down!");
    }
  }
}

现在,当您使用BitmapImage时,您可以在对象级别调用每个方法(因为它们都是public),以及通过强制转换提取对每个支持的接口的引用。

using System;
using InterfaceHierarchy;

Console.WriteLine("***** Simple Interface Hierarchy *****");

// Call from object level.
BitmapImage myBitmap = new BitmapImage();
myBitmap.Draw();
myBitmap.DrawInBoundingBox(10, 10, 100, 150);
myBitmap.DrawUpsideDown();

// Get IAdvancedDraw explicitly.
if (myBitmap is IAdvancedDraw iAdvDraw)
{
  iAdvDraw.DrawUpsideDown();
}
Console.ReadLine();

默认实现的接口层次结构(新 8.0)

当接口层次结构还包括默认实现时,下游接口可以选择从基接口继承该实现,或者创建一个新的默认实现。将IDrawable接口更新如下:

public interface IDrawable
{
  void Draw();
  int TimeToDraw() => 5;
}

接下来,将顶级语句更新为以下内容:

Console.WriteLine("***** Simple Interface Hierarchy *****");
...
if (myBitmap is IAdvancedDraw iAdvDraw)
{
  iAdvDraw.DrawUpsideDown();
  Console.WriteLine($"Time to draw: {iAdvDraw.TimeToDraw()}");
}
Console.ReadLine();

这段代码不仅会编译,而且会为TimeToDraw()方法输出一个值 5。这是因为默认实现会自动结转到后代接口。将BitMapImage转换为IAdvancedDraw接口提供了对TimeToDraw()方法的访问,即使BitMapImage实例不能访问默认实现。要证明这一点,请输入以下代码并查看编译错误:

//This does not compile
myBitmap.TimeToDraw();

如果下游接口想要提供自己的默认实现,它必须隐藏上游实现。例如,如果IAdvancedDraw TimeToDraw()方法需要 15 个单位来绘制,则将接口更新为以下定义:

public interface IAdvancedDraw : IDrawable
{
  void DrawInBoundingBox(
    int top, int left, int bottom, int right);
  void DrawUpsideDown();
  new int TimeToDraw() => 15;
}

当然,BitMapImage类也可以自由实现TimeToDraw()方法。与IAdvancedDraw TimeToDraw()方法不同,这个类只需要 实现 方法,而不需要隐藏它。

public class BitmapImage : IAdvancedDraw
{
...
  public int TimeToDraw() => 12;
}

当将BitmapImage实例转换为IAdvancedDrawIDrawable接口时,实例上的方法仍然被执行。将此代码添加到顶级语句中:

//Always calls method on instance:
Console.WriteLine("***** Calling Implemented TimeToDraw *****");
Console.WriteLine($"Time to draw: {myBitmap.TimeToDraw()}");
Console.WriteLine($"Time to draw: {((IDrawable) myBitmap).TimeToDraw()}");
Console.WriteLine($"Time to draw: {((IAdvancedDraw) myBitmap).TimeToDraw()}");

结果如下:

***** Simple Interface Hierarchy *****
...
***** Calling Implemented TimeToDraw *****
Time to draw: 12
Time to draw: 12
Time to draw: 12

接口类型的多重继承

与类类型不同,一个接口可以扩展多个基本接口,允许您设计一些强大而灵活的抽象。创建一个名为 MiInterfaceHierarchy 的新控制台应用项目。这是另一个接口集合,对各种渲染和形状抽象进行建模。注意,IShape接口同时扩展了IDrawableIPrintable

//IDrawable.cs
namespace MiInterfaceHierarchy
{
  // Multiple inheritance for interface types is A-okay.
  interface IDrawable
  {
    void Draw();
  }
}

//IPrintable.cs
namespace MiInterfaceHierarchy
{
  interface IPrintable
  {
    void Print();
    void Draw(); // <-- Note possible name clash here!
  }
}

//IShape.cs
namespace MiInterfaceHierarchy
{
  // Multiple interface inheritance. OK!
  interface IShape : IDrawable, IPrintable
  {
    int GetNumberOfSides();
  }
}

图 8-6 显示了当前的接口层次。

img/340876_10_En_8_Fig6_HTML.jpg

图 8-6。

与类不同,接口可以扩展多种接口类型

此时,百万美元的问题是“如果你有一个支持IShape的类,需要实现多少个方法?”答案是:视情况而定。如果你想提供一个简单的Draw()方法的实现,你只需要提供三个成员,如下面的Rectangle类型所示:

using System;

namespace MiInterfaceHierarchy
{
  class Rectangle : IShape
  {
    public int GetNumberOfSides() => 4;
    public void Draw() => Console.WriteLine("Drawing...");
    public void Print() => Console.WriteLine("Printing...");
  }
}

如果您希望每个Draw()方法都有特定的实现(在这种情况下最有意义),您可以使用显式接口实现来解决名称冲突,如下面的Square类型所示:

namespace MiInterfaceHierarchy
{
  class Square : IShape
  {
    // Using explicit implementation to handle member name clash.
    void IPrintable.Draw()
    {
      // Draw to printer ...
    }
    void IDrawable.Draw()
    {
      // Draw to screen ...
    }
    public void Print()
    {
      // Print ...
    }

    public int GetNumberOfSides() => 4;
  }
}

理想情况下,此时您会对使用 C# 语法定义和实现自定义接口的过程感到更加舒适。老实说,基于接口的编程可能需要一段时间才能适应,所以如果你实际上仍然有点挠头,这是完全正常的反应。

但是,请注意,接口是。NET 核心框架。不管你开发的应用是什么类型(基于网络的,桌面图形用户接口,数据访问库,等等)。),使用接口将是这个过程的一部分。总结一下到目前为止的情况,记住接口在以下情况下非常有用:

  • 您有一个单一的层次结构,其中只有派生类型的子集支持一个公共行为。

  • 您需要对一个常见的行为进行建模,这个行为存在于多个层次结构中,除了System.Object之外没有共同的父类。

既然您已经深入研究了构建和实现自定义接口的细节,本章的剩余部分将研究。NET 核心基本类库。正如您将看到,您可以实现标准。NET 核心接口,以确保它们无缝集成到框架中。

IEnumerable 和 IEnumerator 接口

开始检查实现现有。NET 核心接口,我们先来看看IEnumerableIEnumerator的作用。回想一下,C# 支持一个名为foreach的关键字,它允许你迭代任何数组类型的内容。

// Iterate over an array of items.
int[] myArrayOfInts = {10, 20, 30, 40};

foreach(int i in myArrayOfInts)
{
  Console.WriteLine(i);
}

虽然看起来只有数组类型可以使用这个构造,但事实是任何支持名为GetEnumerator()的方法的类型都可以被foreach构造求值。举例来说,首先创建一个名为 CustomEnumerator 的新控制台应用项目。接下来,将第七章的 SimpleException 示例中定义的Car.csRadio.cs文件复制到新项目中。确保将类的名称空间更新为CustomEnumerator

现在,插入一个名为Garage的新类,它在一个System.Array中存储一组Car对象。

using System.Collections;

namespace CustomEnumerator
{
  // Garage contains a set of Car objects.
  public class Garage
  {
    private Car[] carArray = new Car[4];

    // Fill with some Car objects upon startup.
    public Garage()
    {
      carArray[0] = new Car("Rusty", 30);
      carArray[1] = new Car("Clunker", 55);
      carArray[2] = new Car("Zippy", 30);
      carArray[3] = new Car("Fred", 30);
    }
  }
}

理想情况下,使用foreach构造遍历Garage对象的子项会很方便,就像数据值数组一样。

using System;
using CustomEnumerator;

// This seems reasonable ...
Console.WriteLine("***** Fun with IEnumerable / IEnumerator *****\n");
Garage carLot = new Garage();

// Hand over each car in the collection?
foreach (Car c in carLot)
{
  Console.WriteLine("{0} is going {1} MPH",
    c.PetName, c.CurrentSpeed);
}
Console.ReadLine();

遗憾的是,编译器通知您,Garage类没有实现名为GetEnumerator()的方法。这个方法由隐藏在System.Collections名称空间中的IEnumerable接口形式化。

Note

在第十章中,你将学习泛型的角色和System.Collections.Generic名称空间。正如您将看到的,这个名称空间包含了IEnumerable / IEnumerator的通用版本,提供了一种更加类型安全的方式来迭代条目。

支持这种行为的类或结构宣称它们可以向调用者公开所包含的项目(在本例中,是关键字foreach本身)。这个标准接口的定义如下:

// This interface informs the caller
// that the object's items can be enumerated.
public interface IEnumerable
{
   IEnumerator GetEnumerator();
}

如您所见,GetEnumerator()方法返回了对另一个名为System.Collections.IEnumerator的接口的引用。该接口提供了允许调用者遍历兼容IEnumerable的容器所包含的内部对象的基础设施。

// This interface allows the caller to
// obtain a container's items.
public interface IEnumerator
{
   bool MoveNext ();  // Advance the internal position of the cursor.
   object Current { get;}  // Get the current item (read-only property).
   void Reset (); // Reset the cursor before the first member.
}

如果您想更新Garage类型来支持这些接口,您可以走很长的路,手动实现每个方法。虽然你当然可以自由地提供定制版本的GetEnumerator()MoveNext()CurrentReset(),但是有一个更简单的方法。由于System.Array类型(以及许多其他集合类)已经实现了IEnumerableIEnumerator,您可以简单地将请求委托给System.Array,如下所示(注意,您需要将System.Collections名称空间导入到您的代码文件中):

using System.Collections;
...
public class Garage : IEnumerable
{
  // System.Array already implements IEnumerator!
  private Car[] carArray = new Car[4];

  public Garage()
  {
    carArray[0] = new Car("FeeFee", 200);
    carArray[1] = new Car("Clunker", 90);
    carArray[2] = new Car("Zippy", 30);
    carArray[3] = new Car("Fred", 30);
  }

  // Return the array object's IEnumerator.
  public IEnumerator GetEnumerator()
    => carArray.GetEnumerator();
}

在您更新了您的Garage类型之后,您可以在 C# foreach构造中安全地使用该类型。此外,鉴于GetEnumerator()方法已经被公开定义,对象用户也可以与IEnumerator类型交互。

// Manually work with IEnumerator.
IEnumerator carEnumerator = carLot.GetEnumerator();
carEnumerator.MoveNext();
Car myCar = (Car)i.Current;
Console.WriteLine("{0} is going {1} MPH", myCar.PetName, myCar.CurrentSpeed);

然而,如果您喜欢在对象级隐藏IEnumerable的功能,只需使用显式接口实现。

// Return the array object's IEnumerator.
IEnumerator IEnumerable.GetEnumerator()
  => return carArray.GetEnumerator();

通过这样做,偶然的对象用户将不会发现GarageGetEnumerator()方法,而foreach构造将在必要时在后台获得接口。

用 yield 关键字构建迭代器方法

有一种替代方法可以通过迭代器构建与foreach循环一起工作的类型。简单地说,迭代器是一个成员,它指定了容器的内部项在被foreach处理时应该如何返回。举例来说,创建一个名为 CustomEnumeratorWithYield 的新控制台应用项目,并插入上一个示例中的CarRadioGarage类型(同样,将您的名称空间定义重命名为当前项目)。现在,对当前的Garage型进行如下改装:

public class Garage : IEnumerable
{
...
  // Iterator method.

  public IEnumerator GetEnumerator()
  {
    foreach (Car c in carArray)
    {
      yield return c;
    }
  }
}

注意,GetEnumerator()的这个实现使用内部foreach逻辑遍历子项,并使用yield return语法将每个Car返回给调用者。yield关键字用于指定返回给调用者的foreach结构的值。当到达yield return语句时,存储容器中的当前位置,下次调用迭代器时从这个位置重新开始执行。

迭代器方法不需要使用foreach关键字来返回其内容。也可以将这个迭代器方法定义如下:

public IEnumerator GetEnumerator()
{
   yield return carArray[0];
   yield return carArray[1];
   yield return carArray[2];
   yield return carArray[3];
}

在这个实现中,注意到GetEnumerator()方法在每次传递时都显式地向调用者返回一个新值。在这个例子中这样做没有什么意义,因为如果您要向carArray成员变量添加更多的对象,那么您的GetEnumerator()方法现在将会不同步。然而,当您想从一个方法返回本地数据以便用foreach语法处理时,这个语法会很有用。

具有本地功能的保护子句(新 7.0)

在第一次迭代项目(或访问任何元素)之前,不会执行GetEnumerator()方法中的任何代码。这意味着如果在yield语句之前有一个异常,它不会在方法第一次被调用时抛出,而只会在第一个MoveNext()被调用时抛出。

为了测试这一点,将GetEnumerator方法更新为:

public IEnumerator GetEnumerator()
{
  //This will not get thrown until MoveNext() is called
  throw new Exception("This won't get called");
  foreach (Car c in carArray)
  {
    yield return c;
  }
}

如果你像这样调用这个函数并且不做任何其他事情,那么这个异常永远不会被抛出:

using System.Collections;
...
Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
IEnumerator carEnumerator = carLot.GetEnumerator();

Console.ReadLine();

直到调用MoveNext()代码才会执行,并抛出异常。根据您的程序的需要,这可能非常好。但也可能不会。您的GetEnumerator方法可能有一个保护子句,它需要在方法第一次被调用时执行。例如,假设列表是从数据库中收集的。您可能想要检查数据库连接是否可以在方法被调用时打开,而不是在列表被迭代时打开。或者您可能想要检查Iterator方法的输入参数(接下来将介绍)的有效性。

从第四章回忆 C# 7 局部函数特性;局部函数是其他函数内部的私有函数。通过将yield return移动到从方法主体返回的局部函数中,顶级语句中的代码(在局部函数返回之前)会立即执行。调用MoveNext()时执行本地函数。

将方法更新为:

public IEnumerator GetEnumerator()
{
  //This will get thrown immediately
  throw new Exception("This will get called");

  return ActualImplementation();

  //this is the local function and the actual IEnumerator implementation
  IEnumerator ActualImplementation()
  {
    foreach (Car c in carArray)
    {
      yield return c;
    }
  }
}

通过将调用代码更新为以下代码来测试这一点:

Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();
try
{
  //Error at this time
  var carEnumerator = carLot.GetEnumerator();
}
catch (Exception e)
{
  Console.WriteLine($"Exception occurred on GetEnumerator");
}
Console.ReadLine();

随着对GetEnumerator()方法的更新,异常被立即抛出,而不是在调用MoveNext()时抛出。

构建命名迭代器

有趣的是,yield关键字在技术上可以用在任何方法中,不管它的名字是什么。这些方法(技术上称为命名迭代器)的独特之处还在于它们可以接受任意数量的参数。当构建一个命名迭代器时,要注意该方法将返回IEnumerable接口,而不是预期的IEnumerator兼容类型。举例来说,您可以将下面的方法添加到Garage类型中(使用局部函数来封装迭代功能):

public IEnumerable GetTheCars(bool returnReversed)
{
  //do some error checking here
  return ActualImplementation();

  IEnumerable ActualImplementation()
  {
    // Return the items in reverse.
    if (returnReversed)
    {
      for (int i = carArray.Length; i != 0; i--)
      {
        yield return carArray[i - 1];
      }
    }
    else
    {
      // Return the items as placed in the array.
      foreach (Car c in carArray)
      {
        yield return c;
      }
    }
  }
}

注意,如果传入参数的值为true,新方法允许调用者以顺序和逆序获取子项。现在,您可以与您的新方法进行如下交互(确保注释掉GetEnumerator()方法中的throw new异常语句):

Console.WriteLine("***** Fun with the Yield Keyword *****\n");
Garage carLot = new Garage();

// Get items using GetEnumerator().
foreach (Car c in carLot)
{
  Console.WriteLine("{0} is going {1} MPH",
    c.PetName, c.CurrentSpeed);
}

Console.WriteLine();

// Get items (in reverse!) using named iterator.
foreach (Car c in carLot.GetTheCars(true))
{
  Console.WriteLine("{0} is going {1} MPH",
    c.PetName, c.CurrentSpeed);
}
Console.ReadLine();

您可能同意,命名迭代器是有用的构造,因为单个定制容器可以定义多种方式来请求返回的集合。

因此,为了总结构建可枚举对象的内容,请记住,要让您的自定义类型使用 C# foreach关键字,容器必须定义一个名为GetEnumerator()的方法,该方法已经由IEnumerable接口类型形式化。此方法的实现通常通过简单地将它委托给持有子对象的内部成员来实现;然而,也可以使用yield return语法来提供多个“命名迭代器”方法。

可克隆的接口

您可能还记得第六章中的,System.Object定义了一个名为MemberwiseClone()的方法。这个方法用来获得当前对象的一个浅拷贝。对象用户不直接调用此方法,因为它是受保护的。然而,在克隆过程中,一个给定的对象可能会调用这个方法本身。举例来说,创建一个名为CloneablePoint的新控制台应用项目,它定义了一个名为Point的类。

using System;

namespace CloneablePoint
{
  // A class named Point.
  public class Point
  {
    public int X {get; set;}
    public int Y {get; set;}

    public Point(int xPos, int yPos) { X = xPos; Y = yPos;}
    public Point(){}

    // Override Object.ToString().
    public override string ToString() => $"X = {X}; Y = {Y}";
  }
}

给定你已经知道的引用类型和值类型(见第四章,你知道如果你把一个引用变量赋给另一个,你有两个引用指向内存中的同一个对象。因此,下面的赋值操作导致对堆上同一个Point对象的两次引用;使用任一引用的修改都会影响堆上的同一对象:

Console.WriteLine("***** Fun with Object Cloning *****\n");
// Two references to same object!
Point p1 = new Point(50, 50);
Point p2 = p1;
p2.X = 0;
Console.WriteLine(p1);
Console.WriteLine(p2);
Console.ReadLine();

当您想让您的自定义类型能够向调用者返回其自身的相同副本时,您可以实现标准的ICloneable接口。如本章开头所示,该类型定义了一个名为Clone()的方法。

public interface ICloneable
{
  object Clone();
}

显然,Clone()方法的实现因类而异。但是,基本功能是相同的:将成员变量的值复制到一个相同类型的新对象实例中,并将其返回给用户。为了说明这一点,请考虑下面对Point类的更新:

// The Point now supports "clone-ability."
public class Point : ICloneable
{
  public int X { get; set; }
  public int Y { get; set; }

  public Point(int xPos, int yPos) { X = xPos; Y = yPos; }
  public Point() { }

  // Override Object.ToString().
  public override string ToString() => $"X = {X}; Y = {Y}";

  // Return a copy of the current object.
  public object Clone() => new Point(this.X, this.Y);
}

这样,您可以创建Point类型的精确独立副本,如以下代码所示:

Console.WriteLine("***** Fun with Object Cloning *****\n");
...
// Notice Clone() returns a plain object type.
// You must perform an explicit cast to obtain the derived type.
Point p3 = new Point(100, 100);
Point p4 = (Point)p3.Clone();

// Change p4.X (which will not change p3.X).
p4.X = 0;

// Print each object.
Console.WriteLine(p3);
Console.WriteLine(p4);
Console.ReadLine();

虽然当前的Point实现符合要求,但是您可以稍微简化一下。因为Point类型不包含任何内部引用类型变量,您可以将Clone()方法的实现简化如下:

// Copy each field of the Point member by member.
public object Clone() => this.MemberwiseClone();

但是,请注意,如果Point包含任何引用类型成员变量,MemberwiseClone()将复制对这些对象的引用(即,浅层复制)。如果你想支持真正的深度拷贝,你需要在克隆过程中创建一个引用类型变量的新实例。接下来我们来看一个例子。

一个更复杂的克隆例子

现在假设Point类包含一个PointDescription类型的引用类型成员变量。这个类维护一个点的友好名称以及一个标识号,表示为一个System.Guid(一个全局唯一标识符【GUID】是一个统计上唯一的 128 位数字)。下面是实现过程:

using System;

namespace CloneablePoint
{
  // This class describes a point.
  public class PointDescription
  {
    public string PetName {get; set;}
    public Guid PointID {get; set;}

    public PointDescription()
    {
      PetName = "No-name";
      PointID = Guid.NewGuid();
    }
  }
}

Point类本身的初始更新包括修改ToString()来说明这些新的状态数据,以及定义和创建PointDescription引用类型。为了让外界给Point起一个昵称,还需要更新传递给重载构造函数的参数。

public class Point : ICloneable
{
  public int X { get; set; }
  public int Y { get; set; }
  public PointDescription desc = new PointDescription();

  public Point(int xPos, int yPos, string petName)
  {
    X = xPos; Y = yPos;
    desc.PetName = petName;
  }
  public Point(int xPos, int yPos)
  {
    X = xPos; Y = yPos;
  }
  public Point() { }

  // Override Object.ToString().
  public override string ToString()
     => $"X = {X}; Y = {Y}; Name = {desc.PetName};\nID = {desc.PointID}\n";

  // Return a copy of the current object.
  public object Clone() => this.MemberwiseClone();
}

注意,您还没有更新您的Clone()方法。因此,当对象用户请求使用当前实现进行克隆时,将获得浅层(逐个成员)拷贝。举例来说,假设您已经更新了调用代码,如下所示:

Console.WriteLine("***** Fun with Object Cloning *****\n");
...
Console.WriteLine("Cloned p3 and stored new Point in p4");
Point p3 = new Point(100, 100, "Jane");
Point p4 = (Point)p3.Clone();

Console.WriteLine("Before modification:");
Console.WriteLine("p3: {0}", p3);
Console.WriteLine("p4: {0}", p4);
p4.desc.PetName = "My new Point";
p4.X = 9;

Console.WriteLine("\nChanged p4.desc.petName and p4.X");
Console.WriteLine("After modification:");
Console.WriteLine("p3: {0}", p3);
Console.WriteLine("p4: {0}", p4);
Console.ReadLine();

请注意,在下面的输出中,虽然值类型确实已经更改,但是内部引用类型保持相同的值,因为它们“指向”内存中相同的对象(具体来说,请注意这两个对象的昵称现在都是“My new Point”)。

***** Fun with Object Cloning *****
Cloned p3 and stored new Point in p4
Before modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

p4: X = 100; Y = 100; Name = Jane;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

Changed p4.desc.petName  and p4.X
After modification:
p3: X = 100; Y = 100; Name = My new Point;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

p4: X = 9; Y = 100; Name = My new Point;
ID = 133d66a7-0837-4bd7-95c6-b22ab0434509

为了让您的Clone()方法对内部引用类型进行完整的深度复制,您需要配置由MemberwiseClone()返回的对象,以说明当前点的名称(System.Guid类型实际上是一个结构,因此数字数据确实被复制了)。下面是一个可能的实现:

// Now we need to adjust for the PointDescription member.
public object Clone()
{
  // First get a shallow copy.
  Point newPoint = (Point)this.MemberwiseClone();

  // Then fill in the gaps.
  PointDescription currentDesc = new PointDescription();
  currentDesc.PetName = this.desc.PetName;
  newPoint.desc = currentDesc;
  return newPoint;
}

如果您再次运行应用并查看输出(如下所示),您会看到从Clone()返回的Point确实复制了它的内部引用类型成员变量(注意宠物名称现在对于p3p4都是惟一的)。

***** Fun with Object Cloning *****
Cloned p3 and stored new Point in p4
Before modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 51f64f25-4b0e-47ac-ba35-37d263496406

p4: X = 100; Y = 100; Name = Jane;
ID = 0d3776b3-b159-490d-b022-7f3f60788e8a

Changed p4.desc.petName  and p4.X
After modification:
p3: X = 100; Y = 100; Name = Jane;
ID = 51f64f25-4b0e-47ac-ba35-37d263496406

p4: X = 9; Y = 100; Name = My new Point;
ID = 0d3776b3-b159-490d-b022-7f3f60788e8a

总结一下克隆过程,如果你有一个只包含值类型的类或结构,使用MemberwiseClone()实现你的Clone()方法。但是,如果您有一个维护其他引用类型的自定义类型,您可能希望创建一个新的对象,该对象考虑每个引用类型成员变量以获得“深层副本”

IComparable 接口

System.IComparable接口指定了一种行为,允许基于某个指定的键对对象进行排序。以下是正式的定义:

// This interface allows an object to specify its
// relationship between other like objects.
public interface IComparable
{
  int CompareTo(object o);
}

Note

这个接口的通用版本(IComparable<T>)提供了一种更加类型安全的方式来处理对象之间的比较。你将在第十章中研究泛型。

创建一个名为 ComparableCar 的新控制台应用项目,从第七章的 SimpleException 示例中复制CarRadio类,并将每个文件的名称空间重命名为ComparableCar。通过添加一个新属性来表示每辆汽车的唯一 ID 和一个修改后的构造函数,从而更新Car类:

using System;
using System.Collections;

namespace ComparableCar
{
  public class Car
  {
...
    public int CarID {get; set;}
    public Car(string name, int currSp, int id)
    {
      CurrentSpeed = currSp;
      PetName = name;
      CarID = id;
    }
...
  }
}

现在假设您有一个如下的Car对象数组:

using System;
using ComparableCar;
Console.WriteLine("***** Fun with Object Sorting *****\n");

// Make an array of Car objects.
Car[] myAutos = new Car[5];
myAutos[0] = new Car("Rusty", 80, 1);
myAutos[1] = new Car("Mary", 40, 234);
myAutos[2] = new Car("Viper", 40, 34);
myAutos[3] = new Car("Mel", 40, 4);
myAutos[4] = new Car("Chucky", 40, 5);

Console.ReadLine();

System.Array类定义了一个名为Sort()的静态方法。当您对一组内部类型(intshortstring等)调用此方法时。),您可以按数字/字母顺序对数组中的项目进行排序,因为这些固有的数据类型实现了IComparable。然而,如果您将一个Car类型的数组发送到Sort()方法中,情况会怎样呢?

// Sort my cars? Not yet!
Array.Sort(myAutos);

如果您运行这个测试,您会得到一个运行时异常,因为Car类不支持必要的接口。当您构建定制类型时,您可以实现IComparable来允许您的类型的数组被排序。当你充实了CompareTo()的细节后,将由你来决定订购操作的基线是什么。对于Car型,内部的CarID似乎是合乎逻辑的候选。

// The iteration of the Car can be ordered
// based on the CarID.
public class Car : IComparable
{
...
  // IComparable implementation.
  int IComparable.CompareTo(object obj)
  {
    if (obj is Car temp)
    {
      if (this.CarID > temp.CarID)
      {
        return 1;
      }
      if (this.CarID < temp.CarID)
      {
        return -1;
      }
      return 0;
    }
    throw new ArgumentException("Parameter is not a Car!");
  }
}

如您所见,CompareTo()背后的逻辑是根据特定的数据点,针对当前实例测试传入的对象。CompareTo()的返回值用于发现该类型是小于、大于还是等于与之比较的对象(见表 8-1 )。

表 8-1。

比较返回值

|

返回值

|

描述

任何小于零的数字 在排序顺序中,此实例位于指定对象之前。
此实例等于指定的对象。
任何大于零的数字 在排序顺序中,此实例位于指定对象之后。

假设 C# int数据类型(这只是System.Int32的简写)实现了IComparable,那么您可以简化前面的CompareTo()实现。您可以如下实现CarCompareTo():

int IComparable.CompareTo(object obj)
{
  if (obj is Car temp)
  {
    return this.CarID.CompareTo(temp.CarID);
  }
  throw new ArgumentException("Parameter is not a Car!");
}

在这两种情况下,为了让您的Car类型理解如何将自己与相似的对象进行比较,您可以编写以下用户代码:

// Exercise the IComparable interface.
// Make an array of Car objects.
...
// Display current array.
Console.WriteLine("Here is the unordered set of cars:");
foreach(Car c in myAutos)
{
  Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}

// Now, sort them using IComparable!
Array.Sort(myAutos);
Console.WriteLine();

// Display sorted array.
Console.WriteLine("Here is the ordered set of cars:");
foreach(Car c in myAutos)
{
  Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
Console.ReadLine();

下面是前面代码清单的输出:

***** Fun with Object Sorting *****
Here is the unordered set of cars:
1 Rusty
234 Mary
34 Viper
4 Mel
5 Chucky

Here is the ordered set of cars:
1 Rusty
4 Mel
5 Chucky
34 Viper
234 Mary

使用 IComparer 指定多个排序顺序

在这个版本的Car类型中,您使用汽车的 ID 作为排序顺序的基础。另一种设计可能使用汽车的昵称作为排序算法的基础(按字母顺序列出汽车)。现在,如果你想构建一个既可以通过 ID 排序又可以通过昵称排序的Car会怎么样呢?如果这是您感兴趣的行为类型,您需要与另一个名为IComparer的标准接口交朋友,该接口在System.Collections名称空间中定义如下:

// A general way to compare two objects.
interface IComparer
{
  int Compare(object o1, object o2);
}

Note

这个接口的通用版本(IComparer<T>)提供了一种更加类型安全的方式来处理对象之间的比较。你将在第十章中研究泛型。

IComparable接口不同,IComparer通常是而不是在您试图排序的类型(即Car)上实现的。相反,您可以在任意数量的助手类上实现这个接口,每个助手类对应一个排序顺序(昵称、汽车 ID 等)。).目前,Car型已经知道如何根据内部汽车 ID 与其他汽车进行比较。因此,允许对象用户按昵称对一组Car对象进行排序将需要一个额外的实现IComparer的助手类。下面是代码(确保在代码文件中导入System.Collections名称空间):

using System;
using System.Collections;

namespace ComparableCar
{
  // This helper class is used to sort an array of Cars by pet name.
  public class PetNameComparer : IComparer
  {
    // Test the pet name of each object.
    int IComparer.Compare(object o1, object o2)
    {
      if (o1 is Car t1 && o2 is Car t2)
      {
        return string.Compare(t1.PetName, t2.PetName,
          StringComparison.OrdinalIgnoreCase);
      }
      else
      {
        throw new ArgumentException("Parameter is not a Car!");
      }
    }
  }
}

对象用户代码可以使用这个助手类。System.Array有几个重载的Sort()方法,其中一个恰好接受一个实现IComparer的对象。

...
// Now sort by pet name.
Array.Sort(myAutos, new PetNameComparer());

// Dump sorted array.
Console.WriteLine("Ordering by pet name:");
foreach(Car c in myAutos)
{
  Console.WriteLine("{0} {1}", c.CarID, c.PetName);
}
...

自定义属性和自定义排序类型

值得指出的是,当按照特定的数据点对Car类型进行排序时,可以使用定制的静态属性来帮助对象用户。假设Car类已经添加了一个名为SortByPetName的静态只读属性,该属性返回实现IComparer接口的对象实例(在本例中为PetNameComparer);一定要导入System.Collections

// We now support a custom property to return
// the correct IComparer interface.
public class Car : IComparable
{
...
  // Property to return the PetNameComparer.
  public static IComparer SortByPetName
    => (IComparer)new PetNameComparer();}

对象用户代码现在可以使用强关联属性按昵称排序,而不是“必须知道”才能使用独立的PetNameComparer类类型。

// Sorting by pet name made a bit cleaner.
Array.Sort(myAutos, Car.SortByPetName);

理想情况下,在这一点上,你不仅理解如何定义和实现你自己的接口,而且理解它们的有用性。可以肯定的是,每个专业都有接口。NET 核心命名空间,在本书的剩余部分,您将继续使用各种标准接口。

摘要

一个接口可以被定义为一个命名的抽象成员的集合。通常认为接口是一个给定类型可以支持的行为。当两个或多个类实现同一个接口时,即使类型是在唯一的类层次结构中定义的,也可以用相同的方式对待每种类型(基于接口的多态性)。

C# 提供了关键字interface来允许你定义一个新的接口。如您所见,使用逗号分隔的列表,一个类型可以支持任意多的接口。此外,允许构建从多个基本接口派生的接口。

除了构建自定义接口之外。NET 核心库定义了几个标准(即框架提供的)接口。正如您所看到的,您可以自由地构建实现这些预定义接口的自定义类型,以获得一些理想的特征,如克隆、排序和枚举。*

九、了解对象生存期

至此,您已经学习了很多关于如何使用 C# 构建自定义类类型的知识。现在您将看到运行时如何通过垃圾收集 ( GC )来管理分配的类实例(又名对象)。C# 程序员从不直接从内存中释放托管对象(回想一下,C# 语言中没有delete关键字)。更确切地说。NET 核心对象被分配到一个叫做托管堆的内存区域,在那里它们将被垃圾收集器“在将来的某个时候”自动销毁

在您查看了收集过程的核心细节之后,您将学习如何使用System.GC类类型以编程方式与垃圾收集器进行交互(对于您的大多数项目来说,这通常不是必需的)。接下来,您将研究如何使用虚拟的System.Object.Finalize()方法和IDisposable接口来构建以可预测和及时的方式释放内部非托管资源的类。

您还将深入研究中介绍的垃圾收集器的一些功能。NET 4.0,包括后台垃圾收集和使用泛型System.Lazy<>类的惰性实例化。当你完成这一章的时候,你会对如何做有一个坚实的理解。NET 核心对象由运行库管理。

类、对象和引用

为了构建本章所涉及的主题,进一步澄清类、对象和引用变量之间的区别是很重要的。回想一下,类只不过是描述这种类型的实例在内存中的外观和感觉的蓝图。当然,类是在一个代码文件中定义的(按照惯例,在 C# 中使用一个*.cs扩展名)。考虑在名为 SimpleGC 的新 C# 控制台应用项目中定义的以下简单的Car类:

namespace SimpleGC
{
  // Car.cs
  public class Car
  {
    public int CurrentSpeed {get; set;}
    public string PetName {get; set;}

    public Car(){}
    public Car(string name, int speed)
    {
      PetName = name;
      CurrentSpeed = speed;
    }
    public override string ToString()
      => $"{PetName} is going {CurrentSpeed} MPH";
    }
  }
}

定义了一个类之后,你可以使用 C# new关键字分配任意数量的对象。但是,请理解,new关键字返回的是对堆上对象的引用,而不是实际的对象。如果将引用变量声明为方法范围内的局部变量,它将存储在堆栈中,供应用进一步使用。当您想要调用对象上的成员时,将 C# 点运算符应用于存储的引用,如下所示:

using System;
using SimpleGC;
Console.WriteLine("***** GC Basics *****");

// Create a new Car object on the managed heap.
// We are returned a reference to the object
// ("refToMyCar").
Car refToMyCar = new Car("Zippy", 50);

// The C# dot operator (.) is used to invoke members
// on the object using our reference variable.
Console.WriteLine(refToMyCar.ToString());
Console.ReadLine();

图 9-1 说明了类、对象和引用关系。

img/340876_10_En_9_Fig1_HTML.jpg

图 9-1。

对托管堆上对象的引用

Note

回想一下第四章,结构是值类型,它们总是被直接分配到堆栈上,从不放在。NET 核心托管堆。只有在创建类的实例时,才会发生堆分配。

对象生存期的基础

当您构建 C# 应用时,假设。NET Core runtime environment 会处理托管堆,无需您的直接干预。事实上……NET 核心内存管理很简单。

Rule

使用new关键字将一个类实例分配到托管堆上,然后忘掉它。

一旦实例化,垃圾收集器将销毁不再需要的对象。当然,下一个明显的问题是“垃圾收集器如何确定何时不再需要某个对象?”简短的(即不完整的)答案是,垃圾收集器仅在对象被你的代码库的任何部分不可达时才从堆中移除该对象。假设您的Program类中有一个方法,它分配一个本地Car对象,如下所示:

static void MakeACar()
{
  // If myCar is the only reference to the Car object, it *may* be destroyed when this method returns.
  Car myCar = new Car();
}

请注意,这个Car引用(myCar)是直接在MakeACar()方法中创建的,没有被传递到定义范围之外(通过返回值或ref / out参数)。因此,一旦这个方法调用完成,myCar引用不再可达,关联的Car对象现在是垃圾收集的候选对象。但是要知道,你不能保证这个对象会在MakeACar()完成后立即从内存中被回收。在这一点上,您可以假设的是,当运行时执行下一次垃圾收集时,可以安全地销毁myCar对象。

您肯定会发现,在垃圾收集环境中编程极大地简化了您的应用开发。与之形成鲜明对比的是,C++程序员痛苦地意识到,如果他们不能手动删除堆分配的对象,内存泄漏就不远了。事实上,跟踪内存泄漏是非托管环境中编程最耗时(也是最乏味)的方面之一。通过允许垃圾收集器负责销毁对象,内存管理的负担已经从您的肩上卸下,并放在运行时的肩上。

新 CIL

当 C# 编译器遇到new关键字时,它向方法实现中发出一个 CIL newobj指令。如果您编译当前的示例代码,并使用ildasm.exe研究产生的程序集,您会在MakeACar()方法中找到以下 CIL 语句:

.method assembly hidebysig static
          void  '<<Main>$>g__MakeACar|0_0'() cil managed
{
    // Code size       8 (0x8)
    .maxstack  1
    .locals init (class SimpleGC.Car V_0)
    IL_0000: nop
    IL_0001: newobj     instance void SimpleGC.Car::.ctor()
    IL_0006: stloc.0
    IL_0007: ret
  } // end of method '<Program>$'::'<<Main>$>g__MakeACar|0_0'

在检查确定何时从托管堆中移除对象的确切规则之前,让我们更详细地检查一下 CIL newobj指令的作用。首先,要理解托管堆不仅仅是运行时访问的随机内存块。那个。NET Core 垃圾收集器是一个相当整洁的堆管家,因为它会压缩空的内存块(必要时)以达到优化的目的。

为了有助于这项工作,托管堆维护一个指针(通常称为下一个对象指针新对象指针),该指针准确地标识下一个对象将位于何处。也就是说,newobj指令告诉运行时执行以下核心操作:

  1. 计算要分配的对象所需的内存总量(包括数据成员和基类所需的内存)。

  2. 检查托管堆,确保确实有足够的空间来承载要分配的对象。如果有,则调用指定的构造函数,最终向调用者返回内存中新对象的引用,该对象的地址恰好与下一个对象指针的最后位置相同。

  3. 最后,在将引用返回给调用方之前,将下一个对象指针向前移动,指向托管堆上的下一个可用槽。

图 9-2 说明了基本过程。

img/340876_10_En_9_Fig2_HTML.jpg

图 9-2。

将对象分配到托管堆的详细信息

当应用忙于分配对象时,托管堆上的空间最终可能会变满。当处理newobj指令时,如果运行时确定托管堆没有足够的内存来分配所请求的类型,它将执行垃圾收集以尝试释放内存。因此,垃圾收集的下一个规则也很简单。

Rule

如果托管堆没有足够的内存来分配请求的对象,将发生垃圾回收。

然而,这种垃圾收集是如何发生的,取决于你的应用使用哪种垃圾收集。在这一章的后面,你会看到不同之处。

将对象引用设置为空

C/C++程序员经常将指针变量设置为null,以确保它们不再引用非托管内存。鉴于此,您可能想知道在 C# 下将对象引用分配给null的最终结果是什么。例如,假设MakeACar()子程序已经更新如下:

static void MakeACar()
{
  Car myCar = new Car();
  myCar = null;
}

当您将对象引用分配给null时,编译器会生成 CIL 代码,确保引用(在本例中为myCar)不再指向任何对象。如果您再次使用ildasm.exe来查看修改后的MakeACar()的 CIL 代码,您会发现ldnull操作码(它将一个null值推送到虚拟执行堆栈上)后跟一个stloc.0操作码(它将null引用设置到变量上)。

  .method assembly hidebysig static
          void  '<<Main>$>g__MakeACar|0_0'() cil managed
  {
    // Code size       10 (0xa)
    .maxstack  1
    .locals init (class SimpleGC.Car V_0)
    IL_0000: nop
    IL_0001: newobj     instance void SimpleGC.Car::.ctor()
    IL_0006: stloc.0
    IL_0007: ldnull
    IL_0008: stloc.0
    IL_0009: ret
  } // end of method '<Program>$'::'<<Main>$>g__MakeACar|0_0'

然而,你必须明白的是,给null分配一个引用并不会以任何方式迫使垃圾收集器在那个时刻启动,并从堆中移除对象。您唯一完成的事情是显式地剪切引用和它以前指向的对象之间的连接。鉴于这一点,在 C# 下设置对null的引用远不如在其他基于 C 的语言中这样做重要;但是,这样做肯定不会造成什么伤害。

确定对象是否是活动的

现在,回到垃圾收集器如何确定何时不再需要某个对象的主题。垃圾收集器使用以下信息来确定对象是否是活动的:

  • 堆栈根:编译器和堆栈审核器提供的堆栈变量

  • 垃圾收集句柄:指向可从代码或运行时引用的托管对象的句柄

  • 静态数据:应用领域中可以引用其他对象的静态对象

在垃圾回收过程中,运行时将调查托管堆上的对象,以确定应用是否仍然可以访问这些对象。为此,运行时将构建一个对象图,它表示堆上每个可到达的对象。在第二十章讨论对象序列化的时候,对象图会有一些详细的解释。现在,只要理解对象图用于记录所有可到达的对象。同样,请注意垃圾收集器不会两次绘制同一个对象,从而避免 COM 编程中令人讨厌的循环引用计数。

假设托管堆包含一组名为 A、B、C、D、E、F 和 g 的对象,在垃圾收集期间,会检查这些对象(以及它们可能包含的任何内部对象引用)。在构建了图之后,不可到达的对象(可以假设是对象 C 和 F)被标记为垃圾。图 9-3 为刚刚描述的场景绘制了一个可能的对象图(你可以使用短语取决于需要来阅读方向箭头;比如 E 依赖于 G 和 B,A 不依赖于任何东西等等。).

img/340876_10_En_9_Fig3_HTML.jpg

图 9-3。

构建对象图是为了确定应用根可以访问哪些对象。

在对象被标记为终止后(在这种情况下是 C 和 F,因为它们在对象图中没有被考虑),它们被从内存中清除。此时,堆上的剩余空间被压缩,这又导致运行时修改底层指针集以指向正确的内存位置(这是自动且透明地完成的)。最后但同样重要的是,下一个对象指针被重新调整,指向下一个可用的槽。图 9-4 显示了最终的重新调整。

img/340876_10_En_9_Fig4_HTML.jpg

图 9-4。

干净而紧实的堆

Note

严格地说,垃圾收集器使用两个不同的堆,其中一个专门用于存储大型对象。考虑到重定位大型对象可能带来的性能损失,在收集周期中很少查询这个堆。英寸 NET Core,大型堆可以按需压缩,或者在达到绝对或百分比内存使用的可选硬限制时压缩。

了解对象生成

当运行时试图定位不可访问的对象时,它不会检查托管堆上的每个对象。显然,这样做需要相当长的时间,尤其是在较大的(即真实世界的)应用中。

为了帮助优化这个过程,堆上的每个对象都被分配给一个特定的“代”世代背后的思想很简单:一个对象在堆上存在的时间越长,它就越有可能留在那里。例如,定义桌面应用主窗口的类将会一直在内存中,直到程序终止。相反,最近才放入堆中的对象(比如在方法范围内分配的对象)很可能很快就无法访问。给定这些假设,堆上的每个对象都属于以下代之一的集合:

  • 第 0 代:标识一个新分配的从未被标记为收集的对象(大对象除外,它们最初被放在第 2 代收集中)。大多数对象在第 0 代中被回收用于垃圾收集,并且现在存活到第 1 代。

  • 第 1 代:标识在垃圾收集中幸存的对象。这一代还充当短寿命对象和长寿命对象之间的缓冲。

  • 第 2 代:标识一个在垃圾收集器的多次扫描中幸存下来的对象,或者一个在第 2 代收集中开始的非常大的对象。

Note

第 0 代和第 1 代被称为短暂代。正如在下一节中所解释的,您将看到垃圾收集过程确实以不同的方式对待短暂的世代。

垃圾收集器将首先调查所有第 0 代对象。如果标记和清除(或者更直白地说,清除)这些对象导致了所需的空闲内存量,则任何幸存的对象都被提升到第 1 代。要查看对象的生成如何影响收集过程,请思考图 9-5 ,该图描述了一旦回收了所需的内存,一组幸存的第 0 代对象(A、B 和 E)是如何提升的。

img/340876_10_En_9_Fig5_HTML.jpg

图 9-5。

在垃圾收集中幸存的第 0 代对象被提升到第 1 代

如果已经评估了所有第 0 代对象,但是仍然需要额外的内存,则调查第 1 代对象的可达性并相应地收集。幸存的第 1 代对象随后被提升到第 2 代。如果垃圾收集器仍然需要额外的内存,则评估第 2 代对象。在这一点上,如果第 2 代对象在垃圾收集中幸存下来,它仍然是第 2 代对象,给定了对象代的预定义上限。

底线是,通过给堆上的对象分配一个世代值,较新的对象(如局部变量)将被快速移除,而较旧的对象(如程序的主窗口)不会经常被“打扰”。

当系统物理内存不足时,当托管堆上分配的内存超过可接受的阈值时,或者当应用代码中调用GC.Collect()时,就会触发垃圾收集。

如果这看起来比自己管理内存更好,那么请记住,垃圾收集的过程是有代价的。虽然垃圾收集肯定会受到好的或坏的影响,但垃圾收集的时间和收集的内容通常不受开发人员的控制。当执行垃圾收集时,会占用 CPU 周期,这会影响应用的性能。接下来的部分将研究不同类型的垃圾收集。

短暂的世代和片段

如前所述,第 0 代和第 1 代是短命的,被称为短暂代。这些代被分配在一个被称为短暂段的内存段中。当垃圾收集发生时,由垃圾收集获得的新段成为新的临时段,并且包含超过第 1 代的对象的段成为新的第 2 代段。

短暂段的大小因多种因素而异,例如垃圾收集类型(接下来将介绍)和系统的容量。表 9-1 显示了短暂段的不同大小。

表 9-1。

短暂的段大小

|

垃圾收集类型

|

32 位

|

64 位

工作站 16 兆字节 256 兆字节
计算机网络服务器 64 兆字节 4 GB
具有 4 个以上逻辑 CPU 的服务器 32 兆字节 2 GB
具有 8 个以上逻辑 CPU 的服务器 16 兆字节 1 GB

垃圾收集类型

运行库提供了两种类型的垃圾收集:

  • 工作站垃圾收集:这是为客户端应用设计的,也是独立应用的默认设置。工作站 GC 可以是后台的(接下来将介绍)或非并发的。

  • 服务器垃圾收集:这是为需要高吞吐量和可伸缩性的服务器应用设计的。服务器 GC 可以是后台或非并发的,就像工作站 GC 一样。

Note

这些名称表示工作站和服务器应用的默认设置,但垃圾收集的方法可通过机器的runtimeconfig.json或系统环境变量进行配置。除非计算机只有一个处理器,否则它将始终使用工作站垃圾收集。

工作站 GC 发生在触发垃圾收集的同一个线程上,并保持与触发时相同的优先级。这可能会导致与应用中其他线程的竞争。

服务器垃圾回收发生在设置为THREAD_PRIORITY_HIGHEST优先级的多个专用线程上(线程在第十五章中介绍)。每个 CPU 都有一个专用堆和专用线程来执行垃圾收集。这可能导致服务器垃圾收集变得非常耗费资源。

后台垃圾收集

从……开始。NET 4.0(并继续在。NET Core),垃圾收集器能够在清理托管堆上的对象时处理线程挂起,使用后台垃圾收集。尽管有其名称,但这并不意味着所有的垃圾收集现在都发生在额外的后台执行线程上。相反,如果后台垃圾回收正在对非 phe pharmal 代中的对象进行,则。NET Core runtime 现在能够使用一个专用的后台线程来收集临时代上的对象。

与此相关的是。NET 4.0 和更高版本的垃圾收集得到了改进,进一步减少了涉及垃圾收集细节的给定线程必须挂起的时间。这些变化的最终结果是,清理第 0 代或第 1 代中未使用的对象的过程得到了优化,可以提高程序的运行时性能(这对需要较小且可预测的 GC 停止时间的实时系统非常重要)。

但是,请理解,这种新的垃圾收集模型的引入对您如何构建自己的。NET 核心应用。实际上,您可以简单地允许垃圾收集器在没有您直接干预的情况下执行它的工作(并且很高兴微软的人正在以透明的方式改进收集过程)。

系统。GC 类型

mscorlib.dll程序集提供了一个名为System.GC的类类型,允许您使用一组静态成员以编程方式与垃圾收集器进行交互。现在,请注意,您很少(如果有的话)需要在代码中直接使用这个类。通常,只有在创建内部使用非托管资源的类时,才会用到System.GC的成员。如果您正在构建一个类,该类使用。NET 核心平台调用协议,或者可能是因为一些非常低级和复杂的 COM 互操作逻辑。表 9-2 提供了一些更有趣的成员的概要(参考。NET Framework SDK 文档以了解完整的详细信息)。

表 9-2。

选择System.GC类型的成员

|

系统。GC 成员

|

描述

AddMemoryPressure() RemoveMemoryPressure() 允许您指定一个数值来表示调用对象在垃圾收集过程中的“紧急程度”。请注意,这些方法应该依次改变压力,因此,永远不要移除超过您添加的总量的压力。
Collect() 强制 GC 执行垃圾回收。该方法已被重载,以指定要收集的代,以及收集的模式(通过GCCollectionMode枚举)。
CollectionCount() 返回一个数值,表示给定层代被扫描的次数。
GetGeneration() 返回对象当前所属的层代。
GetTotalMemory() 返回托管堆上当前分配的估计内存量(以字节为单位)。一个布尔参数指定调用在返回之前是否应该等待垃圾回收。
MaxGeneration 返回目标系统支持的最大代数。微软旗下。NET 4.0,有三个可能的代:0,1,2。
SuppressFinalize() 设置一个标志,指示指定对象不应调用其Finalize()方法。
WaitForPendingFinalizers() 挂起当前线程,直到所有可终结的对象都已终结。该方法通常在调用GC.Collect()后直接调用。

为了说明如何使用System.GC类型来获得各种以垃圾收集为中心的细节,请将 SimpleGC 项目的顶级语句更新为以下内容,它使用了GC的几个成员:

using System;

Console.WriteLine("***** Fun with System.GC *****");

// Print out estimated number of bytes on heap.
Console.WriteLine("Estimated bytes on heap: {0}",
  GC.GetTotalMemory(false));

// MaxGeneration is zero based, so add 1 for display
// purposes.
Console.WriteLine("This OS has {0} object generations.\n",
 (GC.MaxGeneration + 1));

Car refToMyCar = new Car("Zippy", 100);
Console.WriteLine(refToMyCar.ToString());

// Print out generation of refToMyCar object.
Console.WriteLine("Generation of refToMyCar is: {0}",
  GC.GetGeneration(refToMyCar));
Console.ReadLine();

运行此命令后,您应该会看到类似如下的输出:

***** Fun with System.GC *****

Estimated bytes on heap: 75760
This OS has 3 object generations.

Zippy is going 100 MPH
Generation of refToMyCar is: 0

在下一节中,您将探索表 9-2 中的更多方法。

强制垃圾收集

同样,垃圾收集器的全部目的是代表您管理内存。然而,在一些罕见的情况下,使用GC.Collect()以编程方式强制垃圾收集可能是有益的。以下是您可能会考虑与收集流程进行交互的两种常见情况:

  • 您的应用将要进入一个代码块,您不希望被可能的垃圾收集中断。

  • 您的应用刚刚分配完大量的对象,您希望尽快移除尽可能多的已获得的内存。

如果您确定让垃圾收集器检查无法访问的对象是有益的,您可以显式触发垃圾收集,如下所示:

...
// Force a garbage collection and wait for
// each object to be finalized.
GC.Collect();
GC.WaitForPendingFinalizers();
...

当您手动强制垃圾收集时,您应该总是调用GC.WaitForPendingFinalizers()。使用这种方法,您可以放心,在您的程序继续之前,所有的可终结对象(在下一节中描述)都有机会执行任何必要的清理。在引擎盖下,GC.WaitForPendingFinalizers()会在收集过程中挂起调用线程。这是一件好事,因为它确保您的代码不会调用当前正在被销毁的对象上的方法!

还可以向GC.Collect()方法提供一个数值,该数值标识将在其上执行垃圾收集的最老的代。例如,要指示运行时只调查第 0 代对象,您应该编写以下代码:

...
// Only investigate generation 0 objects.
GC.Collect(0);
GC.WaitForPendingFinalizers();
...

同样,Collect()方法可以作为第二个参数传入GCCollectionMode枚举的值,以精确调整运行时应该如何强制垃圾收集。这个enum定义了以下值:

public enum GCCollectionMode
{
  Default,  // Forced is the current default.
  Forced,   // Tells the runtime to collect immediately!
  Optimized // Allows the runtime to determine whether the current time is optimal to reclaim objects.
}

与任何垃圾收集一样,调用GC.Collect()会提升幸存的代。举例来说,假设您的顶级语句已经更新如下:

Console.WriteLine("***** Fun with System.GC *****");

// Print out estimated number of bytes on heap.
Console.WriteLine("Estimated bytes on heap: {0}",
  GC.GetTotalMemory(false));

// MaxGeneration is zero based.
Console.WriteLine("This OS has {0} object generations.\n",
  (GC.MaxGeneration + 1));
Car refToMyCar = new Car("Zippy", 100);
Console.WriteLine(refToMyCar.ToString());

// Print out generation of refToMyCar.
Console.WriteLine("\nGeneration of refToMyCar is: {0}",
  GC.GetGeneration(refToMyCar));

// Make a ton of objects for testing purposes.
object[] tonsOfObjects = new object[50000];
for (int i = 0; i < 50000; i++)
{
  tonsOfObjects[i] = new object();
}

// Collect only gen 0 objects.
Console.WriteLine("Force Garbage Collection");
GC.Collect(0, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();

// Print out generation of refToMyCar.
Console.WriteLine("Generation of refToMyCar is: {0}",
  GC.GetGeneration(refToMyCar));

// See if tonsOfObjects[9000] is still alive.
if (tonsOfObjects[9000] != null)
{
  Console.WriteLine("Generation of tonsOfObjects[9000] is: {0}", GC.GetGeneration(tonsOfObjects[9000]));
}
else
{
  Console.WriteLine("tonsOfObjects[9000] is no longer alive.");
}

// Print out how many times a generation has been swept.
Console.WriteLine("\nGen 0 has been swept {0} times",
  GC.CollectionCount(0));
Console.WriteLine("Gen 1 has been swept {0} times",
  GC.CollectionCount(1));
Console.WriteLine("Gen 2 has been swept {0} times",
  GC.CollectionCount(2));
Console.ReadLine();

这里,我特意创建了一个大的对象类型数组(准确地说是 50,000 个)用于测试目的。以下是该程序的输出:

***** Fun with System.GC *****

Estimated bytes on heap: 75760
This OS has 3 object generations.

Zippy is going 100 MPH
Generation of refToMyCar is: 0
Forcing Garbage Collection
Generation of refToMyCar is: 1
Generation of tonsOfObjects[9000] is: 1

Gen 0 has been swept 1 times
Gen 1 has been swept 0 times
Gen 2 has been swept 0 times

在这一点上,我希望您对对象生命周期的细节感觉更舒服。在下一节中,您将通过解决如何构建可终结对象以及可处置对象来进一步研究垃圾收集过程。请注意,只有在构建维护内部非托管资源的 C# 类时,通常才需要下列技术。

构建可终结的对象

在第六章的中,你了解到最高基础类的。NET Core,System.Object,定义了一个名为Finalize()的虚方法。该方法的默认实现不做任何事情。

// System.Object
public class Object
{
  ...
  protected virtual void Finalize() {}
}

当您为您的定制类重写Finalize()时,您建立了一个特定的位置来为您的类型执行任何必要的清理逻辑。假设这个成员被定义为 protected,那么就不可能通过点运算符从类实例中直接调用对象的Finalize()方法。相反,垃圾收集器会在从内存中移除对象之前调用对象的Finalize()方法(如果支持的话)。

Note

在结构类型上覆盖Finalize()是非法的。考虑到结构是值类型,从一开始就不会在堆上分配,因此也不会被垃圾收集,这是很有意义的。但是,如果您创建了一个包含需要清理的非托管资源的结构,那么您可以实现IDisposable接口(稍后描述)。记住从第四章中得知ref结构和只读ref结构不能实现接口,但是可以实现Dispose()方法。

当然,在“自然”垃圾收集期间,或者当您通过GC.Collect()以编程方式强制收集时,对Finalize()的调用将(最终)发生。在的早期版本中。网(不是。NET Core),在应用关闭时调用每个对象的终结器。英寸 NET Core 中,没有任何方法可以强制执行终结器,即使应用被关闭。

现在,不管你的开发人员本能告诉你什么,你的大多数 C# 类都不需要任何显式的清理逻辑或自定义终结器。原因很简单:如果你的类只是利用其他的托管对象,所有的东西最终都会被垃圾回收。只有在使用非托管资源(如原始 OS 文件句柄、原始非托管数据库连接、非托管内存块或其他非托管资源)时,才需要设计一个可以自我清理的类。在下面。NET 核心平台,非托管资源是通过使用平台调用服务(PInvoke)直接调用操作系统的 API 获得的,或者是一些复杂的 COM 互操作性方案的结果。鉴于此,考虑垃圾收集的下一个规则。

Rule

重写Finalize()的唯一令人信服的理由是,如果您的 C# 类通过 PInvoke 或复杂的 COM 互操作性任务(通常通过由System.Runtime.InteropServices.Marshal类型定义的各种成员)使用非托管资源。原因是在这些情况下,您正在操作运行时无法管理的内存。

超驰系统。Object.Finalize()

在极少数情况下,当您构建使用非托管资源的 C# 类时,您显然希望确保底层内存以可预测的方式释放。假设您已经创建了一个名为 SimpleFinalize 的新 C# 控制台应用项目,并插入了一个名为MyResourceWrapper的类,该类使用了一个非托管资源(无论是什么),并且您想要覆盖Finalize()。在 C# 中这样做的奇怪之处在于,你不能使用预期的override关键字。

using System;
namespace SimpleFinalize
{
  class MyResourceWrapper
  {
    // Compile-time error!
    protected override void Finalize(){ }
  }
}

相反,当您想要配置您的自定义 C# 类类型来覆盖Finalize()方法时,您可以使用(类似 C++)析构函数语法来达到相同的效果。这种替代形式覆盖虚拟方法的原因是,当 C# 编译器处理终结器语法时,它会自动在隐式覆盖的Finalize()方法中添加大量必需的基础结构(马上就会显示)。

C# 终结器看起来与构造函数相似,因为它们的名称与定义它们的类相同。此外,终结器以波浪号(~)为前缀。然而,与构造函数不同,终结器从不接受访问修饰符(它们被隐式保护),从不接受参数,并且不能重载(每个类只有一个终结器)。

下面是一个为MyResourceWrapper定制的终结器,它在被调用时会发出一声系统哔哔声。显然,这个例子只是为了教学目的。现实世界中的终结器除了释放任何非托管资源之外什么都不会做,并且不会与其他托管对象交互,甚至是那些被当前对象引用的对象,因为你不能假设它们在垃圾收集器调用你的Finalize()方法的时候还活着。

using System;
// Override System.Object.Finalize() via finalizer syntax.
class MyResourceWrapper
{
    // Clean up unmanaged resources here.
    // Beep when destroyed (testing purposes only!)
  ~MyResourceWrapper() => Console.Beep();
}

如果您使用ildasm.exe检查这个 C# 析构函数,您会看到编译器插入了一些必要的错误检查代码。首先,你的Finalize()方法范围内的代码语句被放在一个try块内(参见第七章)。相关的finally块确保基类的Finalize()方法将总是执行,不管在try范围内遇到任何异常。

  .method family hidebysig virtual instance void
  Finalize() cil managed
  {
    .override [System.Runtime]System.Object::Finalize
    // Code size       17 (0x11)
    .maxstack  1
    .try
    {
      IL_0000:  call  void [System.Console]System.Console::Beep()
      IL_0005: nop
      IL_0006: leave.s    IL_0010
    }  // end .try
    finally
    {
      IL_0008:  ldarg.0
      IL_0009:  call instance void [System.Runtime]System.Object::Finalize()
      IL_000e:  nop
      IL_000f:  endfinally
    }  // end handler
    IL_0010:  ret
  } // end of method MyResourceWrapper::Finalize

如果您随后测试了MyResourceWrapper类型,您会发现当终结器执行时,系统会发出嘟嘟声。

using System;
using SimpleFinalize;

Console.WriteLine("***** Fun with Finalizers *****\n");
Console.WriteLine("Hit return to create the objects ");
Console.WriteLine("then force the GC to invoke Finalize()");
//Depending on the power of your system,
//you might need to increase these values
CreateObjects(1_000_000);
//Artificially inflate the memory pressure
GC.AddMemoryPressure(2147483647);
GC.Collect(0, GCCollectionMode.Forced);
GC.WaitForPendingFinalizers();
Console.ReadLine();

static void CreateObjects(int count)
{
  MyResourceWrapper[] tonsOfObjects =
    new MyResourceWrapper[count];
  for (int i = 0; i < count; i++)
  {
    tonsOfObjects[i] = new MyResourceWrapper();
  }
  tonsOfObjects = null;
}

Note

保证这个小的控制台应用强制垃圾收集的唯一方法。NET Core 是在内存中创建大量的对象,然后将它们设置为null。如果您运行这个示例应用,请确保按 Ctrl+C 组合键来停止程序执行和所有的蜂鸣声!

详述最终确定过程

务必记住,Finalize()方法的作用是确保. NET 核心对象在被垃圾收集时能够清理非托管资源。因此,如果您正在构建一个不使用非托管内存的类(这是最常见的情况),那么终结化就没什么用了。事实上,如果可能的话,你应该设计你的类型来避免支持一个Finalize()方法,原因很简单,终结需要时间。

当您将对象分配到托管堆上时,运行时会自动确定您的对象是否支持自定义的Finalize()方法。如果是这样,该对象被标记为可终结的,并且指向该对象的指针被存储在一个名为终结队列的内部队列中。终结队列是由垃圾收集器维护的表,它指向在从堆中移除对象之前必须终结的每个对象。

当垃圾收集器确定是时候从内存中释放一个对象时,它检查终结队列中的每个条目,并将对象从堆中复制到另一个被称为终结可达表的托管结构中(通常缩写为 freachable ,发音为“eff-reachable”)。此时,会产生一个单独的线程,以便在下一次垃圾收集时为可访问表上的每个对象调用Finalize()方法。考虑到这一点,至少需要两次垃圾收集才能真正终结一个对象。

底线是,虽然对象的终结确实确保了对象可以清理非托管资源,但它本质上仍然是不确定的,并且由于额外的幕后处理,速度会慢得多。

建造一次性物品

正如您所看到的,当垃圾回收器开始工作时,终结器可以用来释放非托管资源。然而,考虑到许多非托管对象是“珍贵的项目”(如原始数据库或文件句柄),尽快释放它们而不是依赖垃圾回收可能是有价值的。作为覆盖Finalize()的替代方法,您的类可以实现IDisposable接口,它定义了一个名为Dispose()的方法,如下所示:

public interface IDisposable
{
  void Dispose();
}

当您实现IDisposable接口时,假设当对象用户结束使用对象时,对象用户在允许对象引用脱离范围之前手动调用Dispose()。通过这种方式,对象可以对非托管资源执行任何必要的清理,而不会被放在终结队列中,也不会等待垃圾回收器触发类的终结逻辑。

Note

当对象用户(不是垃圾收集器)调用Dispose()方法时,非ref结构和类类型都可以实现IDisposable(不同于为类类型保留的覆盖Finalize())。第四章介绍了一次性ref结构。

为了演示此接口的用法,创建一个名为 SimpleDispose 的新 C# 控制台应用项目。下面是一个更新的MyResourceWrapper类,它现在实现了IDisposable,而不是覆盖System.Object.Finalize():

using System;
namespace SimpleDispose
{
  // Implementing IDisposable.
  class MyResourceWrapper : IDisposable
  {
    // The object user should call this method
    // when they finish with the object.
    public void Dispose()
    {
      // Clean up unmanaged resources...
      // Dispose other contained disposable objects...
      // Just for a test.
      Console.WriteLine("***** In Dispose! *****");
    }
  }
}

请注意,Dispose()方法不仅负责释放该类型的非托管资源,还可以在任何其他包含的可处置方法上调用Dispose()。与Finalize()不同,在Dispose()方法中与其他托管对象通信是非常安全的。原因很简单:垃圾收集器对IDisposable接口毫无头绪,永远不会调用Dispose()。因此,当对象用户调用此方法时,该对象仍然在托管堆上生活,并且可以访问所有其他堆分配的对象。这里显示的调用逻辑很简单:

using System;
using System.IO;
using SimpleDispose;
Console.WriteLine("***** Fun with Dispose *****\n");
// Create a disposable object and call Dispose()
// to free any internal resources.
MyResourceWrapper rw = new MyResourceWrapper();
rw.Dispose();
Console.ReadLine();

当然,在您尝试对一个对象调用Dispose()之前,您会希望确保该类型支持IDisposable接口。虽然通过查阅文档,你通常会知道哪些基类库类型实现了IDisposable,但是可以使用第六章中讨论的isas关键字来完成编程检查。

Console.WriteLine("***** Fun with Dispose *****\n");
MyResourceWrapper rw = new MyResourceWrapper();
if (rw is IDisposable)
{
  rw.Dispose();
}
Console.ReadLine();

这个例子揭示了另一个关于内存管理的规则。

Rule

如果对象支持IDisposable,那么在您直接创建的任何对象上调用Dispose()是一个好主意。您应该做的假设是,如果类设计者选择支持Dispose()方法,那么该类型需要执行一些清理工作。如果你忘记了,记忆最终会被清理掉(所以不要惊慌),但这可能会花费不必要的时间。

前面的规则有一个警告。实现了IDisposable接口的基类库中的许多类型为Dispose()方法提供了一个(有点混乱的)别名,试图让以处置为中心的方法听起来对定义类型来说更自然。举例来说,虽然System.IO.FileStream类实现了IDisposable(因此支持一个Dispose()方法),但它也定义了以下用于相同目的的Close()方法:

// Assume you have imported
// the System.IO namespace...
static void DisposeFileStream()
{
  FileStream fs = new FileStream("myFile.txt", FileMode.OpenOrCreate);

  // Confusing, to say the least!
  // These method calls do the same thing!
  fs.Close();
  fs.Dispose();
}

虽然“关闭”一个文件比“处理”一个文件感觉起来更自然,但这种清理方法的重叠可能会令人困惑。对于少数提供别名的类型,只要记住如果一个类型实现了IDisposable,调用Dispose()总是安全的。

重用 C# using 关键字

当您处理实现了IDisposable的托管对象时,通常使用结构化异常处理来确保类型的Dispose()方法在发生运行时异常时被调用,如下所示:

Console.WriteLine("***** Fun with Dispose *****\n");
MyResourceWrapper rw = new MyResourceWrapper ();
try
{
  // Use the members of rw.
}
finally
{
  // Always call Dispose(), error or not.
  rw.Dispose();
}

虽然这是防御性编程的一个很好的例子,但事实是,很少有开发人员会对在一个try / finally块中包装每一个一次性类型的前景感到兴奋,只是为了确保调用Dispose()方法。为了以一种不那么突兀的方式实现相同的结果,C# 支持一种特殊的语法,如下所示:

Console.WriteLine("***** Fun with Dispose *****\n");
// Dispose() is called automatically when the using scope exits.
using(MyResourceWrapper rw = new MyResourceWrapper())
{
  // Use rw object.
}

如果您使用ildasm.exe查看下面的top-level statements的 CIL 代码,您会发现using语法确实扩展到了try / finally逻辑,并带有对Dispose()的预期调用:

.method private hidebysig static void
    '<Main>$'(string[] args) cil managed
{
...
  .try
  {
  }  // end .try
  finally
  {
      IL_0019:  callvirt   instance void [System.Runtime]System.IDisposable::Dispose()
  }  // end handler
} // end of method '<Program>$'::'<Main>$'

Note

如果您试图“使用”一个没有实现IDisposable的对象,您将会收到一个编译器错误。

虽然这种语法不需要在try / finally逻辑中手动包装可处置对象,但不幸的是,C# using关键字现在有了双重含义(导入名称空间和调用Dispose()方法)。然而,当你使用支持IDisposable接口的类型时,这种语法结构将确保一旦using块退出,被“使用”的对象将自动调用它的Dispose()方法。

还要注意,在一个using范围内声明多个相同类型的对象是可能的。如您所料,编译器将注入代码来调用每个声明对象上的Dispose()

// Use a comma-delimited list to declare multiple objects to dispose.
using(MyResourceWrapper rw = new MyResourceWrapper(), rw2 = new MyResourceWrapper())
{
  // Use rw and rw2 objects.
}

使用声明(新 8.0)

C# 8.0 中的新特性是使用声明添加了*。using 声明是前面带有关键字using的变量声明。除了用大括号({})标记的显式代码块之外,这在功能上与上一个问题中的语法相同。*

将以下方法添加到您的类中:

private static void UsingDeclaration()
{
  //This variable will be in scope until the end of the method
  using var rw = new MyResourceWrapper();
  //Do something here
  Console.WriteLine("About to dispose.");
  //Variable is disposed at this point.
}

接下来,将以下调用添加到顶级语句中:

Console.WriteLine("***** Fun with Dispose *****\n");
...
Console.WriteLine("Demonstrate using declarations");
UsingDeclaration();
Console.ReadLine();

如果您使用 ILDASM 检查新方法,您将(如您所料)发现与以前相同的代码。

.method private hidebysig static
  void  UsingDeclaration() cil managed
{
...
  .try
  {
...
  }  // end .try
  finally
  {
    IL_0018: callvirt instance void
      [System.Runtime]System.IDisposable::Dispose()
...
  }  // end handler
  IL_001f: ret
} // end of method Program::UsingDeclaration

这个新特性本质上是编译器的魔法,节省了几次击键。使用时要小心,因为新语法不像以前的语法那样明确。

构建可终结和可释放的类型

至此,您已经看到了构造清理内部非托管资源的类的两种不同方法。一方面,您可以使用终结器。使用这种技术,您可以放心地知道对象在垃圾收集时(无论何时)会自行清理,而不需要用户交互。另一方面,您可以实现IDisposable来为对象用户提供一种一旦完成就清理对象的方法。但是,如果调用者忘记调用Dispose(),非托管资源可能会无限期地保留在内存中。

正如您可能会怀疑的那样,将这两种技术混合到一个类定义中是可能的。通过这样做,您可以获得两种模式的优点。如果对象用户确实记得调用Dispose(),您可以通过调用GC.SuppressFinalize()通知垃圾收集器绕过终结过程。如果对象用户忘记调用Dispose(),该对象将最终被终结,并有机会释放内部资源。好消息是对象的内部非托管资源将以某种方式被释放。

下面是MyResourceWrapper的下一个迭代,它现在是可终结和可处置的,在一个名为FinalizableDisposableClass的 C# 控制台应用项目中定义:

using System;

namespace FinalizableDisposableClass
{
  // A sophisticated resource wrapper.
  public class MyResourceWrapper : IDisposable
  {
    // The garbage collector will call this method if the object user forgets to call Dispose().
    ~MyResourceWrapper()
    {
      // Clean up any internal unmanaged resources.
      // Do **not** call Dispose() on any managed objects.
    }
    // The object user will call this method to clean up resources ASAP.
    public void Dispose()
    {
      // Clean up unmanaged resources here.
      // Call Dispose() on other contained disposable objects.
      // No need to finalize if user called Dispose(), so suppress finalization.
      GC.SuppressFinalize(this);
    }
  }
}

注意,这个Dispose()方法已经更新为调用GC.SuppressFinalize(),通知运行时当这个对象被垃圾回收时,不再需要调用析构函数,因为非托管资源已经通过Dispose()逻辑被释放了。

正式的处置模式

MyResourceWrapper的当前实现工作得相当好;然而,你也有一些小缺点。首先,Finalize()Dispose()方法都必须清理相同的非托管资源。这可能导致重复代码,这很容易成为维护的噩梦。理想情况下,您应该定义一个私有的 helper 函数,这两种方法都可以调用它。

接下来,您希望确保Finalize()方法不会试图释放任何托管对象,而Dispose()方法应该这样做。最后,您还想确定对象用户可以安全地多次调用Dispose()而不会出错。目前,Dispose()方法没有这种保护措施。

为了解决这些设计问题,微软定义了一个正式的、初步的和适当的处理模式,在健壮性、可维护性和性能之间取得了平衡。下面是MyResourceWrapper的最终(带注释)版本,它使用了这个官方模式:

class MyResourceWrapper : IDisposable
{
  // Used to determine if Dispose() has already been called.
  private bool disposed = false;

  public void Dispose()
  {
    // Call our helper method.
    // Specifying "true" signifies that the object user triggered the cleanup.
    CleanUp(true);

    // Now suppress finalization.
    GC.SuppressFinalize(this);
  }

  private void CleanUp(bool disposing)
  {
    // Be sure we have not already been disposed!
    if (!this.disposed)
    {

      // If disposing equals true, dispose all managed resources.
      if (disposing)
      {
        // Dispose managed resources.
      }
      // Clean up unmanaged resources here.
    }
    disposed = true;
  }
  ~MyResourceWrapper()
  {
    // Call our helper method.
    // Specifying "false" signifies that the GC triggered the cleanup.
    CleanUp(false);
  }
}

注意,MyResourceWrapper现在定义了一个名为CleanUp()的私有 helper 方法。通过将true指定为参数,您表明对象用户已经启动了清理,因此您应该清理所有托管的非托管的资源。然而,当垃圾收集器启动清理时,您在调用CleanUp()时指定false,以确保内部可处置对象是而不是被处置的(因为您不能假设它们仍然在内存中!).最后但同样重要的是,在退出CleanUp()之前,将bool成员变量(disposed)设置为true,以确保Dispose()可以被多次调用而不出错。

Note

在一个对象被“释放”后,客户端仍然有可能调用其上的成员,因为它仍然在内存中。因此,一个健壮的资源包装类还需要用额外的编码逻辑来更新该类的每个成员,实际上就是说,“如果我被释放,什么也不做,从该成员返回。”

为了测试MyResourceWrapper的最终迭代,将您的Program.cs文件更新如下:

using System;
using FinalizableDisposableClass;

Console.WriteLine("***** Dispose() / Destructor Combo Platter *****");

// Call Dispose() manually. This will not call the finalizer.
MyResourceWrapper rw = new MyResourceWrapper();
rw.Dispose();

// Don't call Dispose(). This will trigger the finalizer when the object gets GCd.
MyResourceWrapper rw2 = new MyResourceWrapper();

请注意,您在rw对象上显式调用了Dispose(),因此析构函数调用被取消了。但是,你已经“忘记”在rw2对象上调用Dispose();不用担心,当对象被垃圾回收时,终结器仍然会执行。

这就结束了您对运行时如何通过垃圾收集来管理对象的研究。虽然关于收集过程还有一些额外的(有点深奥的)细节我还没有在这里介绍(比如弱引用和对象复活),但是您现在已经处于自己进一步探索的最佳位置。作为本章的总结,您将研究一个名为对象的惰性实例化的编程特性。

理解惰性对象实例化

当您创建类时,您可能偶尔需要在代码中考虑一个特定的成员变量,这可能实际上永远都不需要,因为对象用户可能不会调用使用它的方法(或属性)。很公平。但是,如果正在讨论的成员变量需要大量内存来实例化,这可能会有问题。

例如,假设您正在编写一个封装数字音乐播放器操作的类。除了预期的方法,如Play()Pause()Stop(),您还想提供返回一组Song对象的能力(通过一个名为AllTracks的类),这些对象代表设备上的每一个数字音乐文件。

如果您想继续操作,请创建一个名为 LazyObjectInstantiation 的新控制台应用项目,并定义以下类类型:

//Song.cs
namespace LazyObjectInstantiation
{
  // Represents a single song.
  class Song
  {
    public string Artist { get; set; }
    public string TrackName { get; set; }
    public double TrackLength { get; set; }
  }
}

//AllTracks.cs
using System;
namespace LazyObjectInstantiation
{
  // Represents all songs on a player.
  class AllTracks
  {
    // Our media player can have a maximum
    // of 10,000 songs.
    private Song[] _allSongs = new Song[10000];

    public AllTracks()
    {
      // Assume we fill up the array
      // of Song objects here.
      Console.WriteLine("Filling up the songs!");
    }
  }
}

//MediaPlayer.cs
using System;
namespace LazyObjectInstantiation
{
  // The MediaPlayer has-an AllTracks object.
  class MediaPlayer
  {
    // Assume these methods do something useful.
    public void Play() { /* Play a song */ }
    public void Pause() { /* Pause the song */ }
    public void Stop() { /* Stop playback */ }
    private AllTracks _allSongs = new AllTracks();

    public AllTracks GetAllTracks()
    {
      // Return all of the songs.
      return _allSongs;
    }
  }
}

MediaPlayer的当前实现假设对象用户想要通过GetAllTracks()方法获得歌曲列表。那么,如果对象用户需要获得这个列表呢?在当前实现中,AllTracks成员变量仍将被分配,从而在内存中创建 10,000 个Song对象,如下所示:

using System;
using LazyObjectInstantiation;

Console.WriteLine("***** Fun with Lazy Instantiation *****\n");

// This caller does not care about getting all songs,
// but indirectly created 10,000 objects!
MediaPlayer myPlayer = new MediaPlayer();
myPlayer.Play();

Console.ReadLine();

显然,您不希望创建 10,000 个没有人会使用的对象,因为这将给。NET Core 垃圾收集器。虽然您可以手动添加一些代码来确保 _ allSongs对象仅在使用时才被创建(可能使用工厂方法设计模式),但是有一种更简单的方法。

基础类库提供了一个名为Lazy<>的有用的泛型类,在mscorlib.dllSystem命名空间中定义。这个类允许你定义数据,除非你的代码库实际使用它,否则不会被创建。由于这是一个泛型类,因此必须指定首次使用时要创建的项的类型,该类型可以是带有。NET 核心基类库或您自己创作的自定义类型。要启用AllTracks成员变量的惰性实例化,您可以简单地将MediaPlayer代码更新为:

// The MediaPlayer has-an Lazy<AllTracks> object.
class MediaPlayer
{
...
  private Lazy<AllTracks> _allSongs = new Lazy<AllTracks>();
  public AllTracks GetAllTracks()
  {
    // Return all of the songs.
    return _allSongs.Value;
  }
}

除了将AllTracks成员变量表示为Lazy<>类型之外,请注意之前的GetAllTracks()方法的实现也被更新了。具体来说,您必须使用Lazy<>类的只读Value属性来获取实际存储的数据(在本例中,是维护 10,000 个Song对象的AllTracks对象)。

通过这个简单的更新,请注意只有在真正调用了GetAllTracks()时,下面更新的代码才会间接分配Song对象:

Console.WriteLine("***** Fun with Lazy Instantiation *****\n");

// No allocation of AllTracks object here!
MediaPlayer myPlayer = new MediaPlayer();
myPlayer.Play();

// Allocation of AllTracks happens when you call GetAllTracks().
MediaPlayer yourPlayer = new MediaPlayer();
AllTracks yourMusic = yourPlayer.GetAllTracks();

Console.ReadLine();

Note

惰性对象实例化不仅有助于减少不必要对象的分配。如果给定成员有昂贵的创建代码,比如调用远程方法、与关系数据库通信等,也可以使用这种技术。

定制惰性数据的创建

当您声明一个Lazy<>变量时,实际的内部数据类型是使用默认的构造函数创建的,如下所示:

// Default constructor of AllTracks is called when the Lazy<>
// variable is used.
private Lazy<AllTracks> _allSongs = new Lazy<AllTracks>();

虽然在某些情况下这可能没问题,但是如果AllTracks类有一些额外的构造函数,并且您希望确保调用正确的构造函数,该怎么办呢?此外,如果在生成Lazy<>变量时,您有一些额外的工作要做(不仅仅是创建AllTracks对象),那该怎么办?幸运的是,Lazy<>类允许您指定一个泛型委托作为可选参数,这将指定一个在创建包装类型期间调用的方法。

讨论中的泛型委托属于类型System.Func<>,它可以指向一个方法,该方法返回由相关的Lazy<>变量创建的相同数据类型,并且可以接受多达 16 个参数(使用泛型类型参数类型化)。在大多数情况下,您不需要指定任何参数来传递给由Func<>指向的方法。此外,为了大大简化所需的Func<>的使用,我推荐使用 lambda 表达式(参见第十二章来学习或回顾委托/lambda 关系)。

考虑到这一点,下面是MediaPlayer的最终版本,它在创建包装的AllTracks对象时添加了一些定制代码。记住,这个方法在退出之前必须返回一个由Lazy<>包装的类型的新实例,你可以使用你选择的任何构造函数(这里,你仍然调用默认的构造函数AllTracks)。

class MediaPlayer
{
...
  // Use a lambda expression to add additional code
  // when the AllTracks object is made.
  private Lazy<AllTracks> _allSongs =
    new Lazy<AllTracks>( () =>
      {
        Console.WriteLine("Creating AllTracks object!");
        return new AllTracks();
      }
  );

  public AllTracks GetAllTracks()
  {
    // Return all of the songs.
    return _allSongs.Value;
  }
}

太好了。我希望你能看到Lazy<>类的用处。本质上,这个泛型类允许您确保昂贵的对象仅在对象用户需要时才被分配。

摘要

本章的目的是揭开垃圾收集过程的神秘面纱。正如您所看到的,垃圾收集器只有在无法从托管堆获取必要的内存时(或者当开发人员调用GC.Collect())才会运行。当发生收集时,您可以放心,Microsoft 的收集算法已经通过使用对象生成、用于对象终结的辅助线程以及专用于承载大型对象的托管堆进行了优化。

本章还演示了如何使用System.GC类类型以编程方式与垃圾收集器交互。如前所述,真正需要这样做的唯一时间是在构建操作非托管资源的可终结或可释放的类类型时。

回想一下,可终结类型是在垃圾回收时提供了析构函数(有效地覆盖了Finalize()方法)来清理非托管资源的类。另一方面,可处置对象是实现IDisposable接口的类(或非ref结构),当对象用户使用完所述对象时,应该调用该接口。最后,您了解了混合两种方法的官方“处置”模式。

本章最后看了一个名为Lazy<>的泛型类。正如您所看到的,您可以使用这个类来延迟创建一个昂贵的(就内存消耗而言)对象,直到调用者真正需要它。通过这样做,您可以帮助减少存储在托管堆上的对象数量,还可以确保只在调用者真正需要时才创建昂贵的对象。

十、集合和泛型

使用。NET 核心平台将需要处理在内存中维护和操作一组数据点的问题。这些数据点可以来自任何位置,包括关系数据库、本地文本文件、XML 文档、web 服务调用,或者用户提供的输入。

当。NET 平台首次发布时,程序员经常使用System.Collections名称空间的类来存储应用中使用的数据并与之交互。英寸在. NET 2.0 中,C# 编程语言被增强以支持一个被称为泛型的特性;随着这一变化,在基类库中引入了一个新的名称空间:System.Collections.Generic

本章将向您概述在中找到的各种集合(泛型和非泛型)命名空间和类型。NET 核心基本类库。正如您将看到的,泛型容器通常比非泛型容器更受青睐,因为它们通常提供更好的类型安全性和性能优势。在您学习了如何创建和操作框架中的泛型项之后,本章的剩余部分将研究如何构建您自己的泛型方法和泛型类型。当你这样做的时候,你将了解到约束(以及相应的 C# where关键字)的作用,它允许你构建非常类型安全的类。

集合类的动机

可以用来保存应用数据的最原始的容器无疑是数组。正如你在第四章中看到的,C# 数组允许你定义一组固定上限的相同类型的项(包括一个System.Object的数组,它本质上代表一个任何类型数据的数组)。还记得第四章中的所有 C# 数组变量都从System.Array类中收集了大量的功能。快速回顾一下,考虑下面的代码,它创建了一个文本数据数组,并以各种方式操作其内容:

// Make an array of string data.
string[] strArray = {"First", "Second", "Third" };

// Show number of items in array using Length property.
Console.WriteLine("This array has {0} items.", strArray.Length);
Console.WriteLine();

// Display contents using enumerator.
foreach (string s in strArray)
{
  Console.WriteLine("Array Entry: {0}", s);
}
Console.WriteLine();

// Reverse the array and print again.
Array.Reverse(strArray);
foreach (string s in strArray)
{
  Console.WriteLine("Array Entry: {0}", s);
}

Console.ReadLine();

虽然基本数组对于管理少量固定大小的数据很有用,但在很多其他情况下,您需要更灵活的数据结构,例如动态增长和收缩的容器,或者可以容纳仅满足特定标准的对象(例如,仅从特定基类派生的对象或仅实现特定接口的对象)的容器。当你使用一个简单的数组时,请记住它们是以“固定大小”创建的如果你做一个三项的数组,你只能得到三项;因此,下面的代码将导致一个运行时异常(确切地说是一个IndexOutOfRangeException):

// Make an array of string data.
string[] strArray = { "First", "Second", "Third" };

// Try to add a new item at the end?? Runtime error!
strArray[3] = "new item?";
...

Note

实际上可以使用通用的Resize()<T>方法来改变数组的大小。但是,这将导致数据复制到一个新的数组对象中,并且可能是低效的。

为了帮助克服简单数组的局限性。NET 核心基类库附带了许多包含集合类的名称空间。与简单的 C# 数组不同,当您插入或移除项时,集合类会动态调整自身大小。此外,许多集合类提供了更高的类型安全性,并经过高度优化,以内存高效的方式处理所包含的数据。当你阅读这一章时,你会很快注意到一个集合类可以属于两大类中的一类。

  • 非泛型集合(主要在System.Collections名称空间中)

  • 通用集合(主要在System.Collections.Generic名称空间中)

非泛型集合通常被设计为操作System.Object类型,因此是松散类型的容器(然而,一些非泛型集合确实只操作特定类型的数据,例如string对象)。相比之下,泛型集合更加类型安全,因为您必须在创建时指定它们包含的“类型的类型”。正如你将看到的,任何通用项目的指示符号是用尖括号标记的“类型参数”(例如,List<T>)。在本章的稍后部分,你将会研究泛型的细节(包括它们提供的许多好处)。现在,让我们研究一下System.CollectionsSystem.Collections.Specialized名称空间中的一些关键的非泛型集合类型。

系统。集合命名空间

当。NET 平台首次发布时,程序员经常使用在System.Collections名称空间中找到的非泛型集合类,它包含一组用于管理和组织大量内存数据的类。表 10-1 记录了这个名称空间的一些更常用的集合类和它们实现的核心接口。

表 10-1。

System.Collections的有用类型

|

系统。集合类

|

生命的意义

|

主要实现的接口

ArrayList 表示按顺序列出的动态调整大小的对象集合 IListICollectionIEnumerableICloneable
BitArray 管理以布尔值表示的位值的紧凑数组,其中 true 表示该位为开(1),false 表示该位为关(0) ICollectionIEnumerableICloneable
Hashtable 表示基于键的哈希代码组织的键值对的集合 IDictionaryICollectionIEnumerableICloneable
Queue 表示对象的标准先进先出(FIFO)集合 ICollectionIEnumerableICloneable
SortedList 表示按键排序并可按键和索引访问的键-值对的集合 IDictionaryICollectionIEnumerableICloneable
Stack 一种后进先出(LIFO)堆栈,提供推入和弹出(以及窥视)功能 ICollectionIEnumerableICloneable

这些集合类实现的接口提供了对其整体功能的深入了解。表 10-2 记录了这些关键接口的总体性质,其中一些你在第八章中直接使用过。

表 10-2。

System.Collections类支持的关键接口

|

系统。收集界面

|

生命的意义

ICollection 定义所有非泛型集合类型的一般特征(例如,大小、枚举和线程安全)
ICloneable 允许实现对象将自身的副本返回给调用方
IDictionary 允许非泛型集合对象使用键值对来表示其内容
IEnumerable 返回一个实现IEnumerator接口的对象(见下一个表项)
IEnumerator 启用集合项目的foreach样式迭代
IList 提供在对象的顺序列表中添加、移除和索引项的行为

一个说明性的例子:使用数组列表

根据您的经验,您可能有一些使用(或实现)这些经典数据结构(如堆栈、队列和列表)的第一手经验。如果不是这样,当你在本章稍后检查它们的通用对应物时,我将提供一些关于它们的差异的进一步细节。在此之前,下面是使用ArrayList对象的示例代码:

// You must import System.Collections to access the ArrayList.
using System.Collections;
ArrayList strArray = new ArrayList();
strArray.AddRange(new string[] { "First", "Second", "Third" });

// Show number of items in ArrayList.
System.Console.WriteLine("This collection has {0} items.", strArray.Count);
System.Console.WriteLine();

// Add a new item and display current count.
strArray.Add("Fourth!");
System.Console.WriteLine("This collection has {0} items.", strArray.Count);

// Display contents.
foreach (string s in strArray)
{
  System.Console.WriteLine("Entry: {0}", s);
}
System.Console.WriteLine();

请注意,您可以动态地添加(或删除)项目,容器会相应地自动调整大小。

如您所料,ArrayList类除了Count属性、AddRange()Add()方法之外,还有许多有用的成员,所以请务必参考。NET 核心文档以了解全部详细信息。另外,System.Collections ( StackQueue)的其他等级。)中也有完整的记录。NET 核心帮助系统。

然而,重要的是要指出,你的大多数。NET 核心项目最有可能而不是利用System.Collections名称空间中的集合类!可以肯定的是,如今使用在System.Collections.Generic名称空间中找到的通用对应类要普遍得多。鉴于这一点,我不会对System.Collections中剩余的非泛型类进行评论(或提供代码示例)。

系统概述。集合。专用命名空间

System.Collections不是唯一的。包含非泛型集合类的. NET Core 命名空间。System.Collections.Specialized名称空间定义了许多(原谅冗余)专门化的集合类型。表 10-3 记录了这个特殊的以集合为中心的名称空间中一些更有用的类型,所有这些类型都是非泛型的。

表 10-3。

System.Collections.Specialized的有用类别

|

系统。集合。专用类型

|

生命的意义

HybridDictionary 这个类通过在集合很小时使用一个ListDictionary来实现IDictionary,然后在集合变大时切换到一个Hashtable
ListDictionary 当您需要管理少量(十个左右)会随时间变化的项目时,这个类非常有用。这个类使用一个单链表来维护它的数据。
StringCollection 这个类提供了管理大型字符串数据集合的最佳方式。
BitVector32 这个类提供了一个简单的结构,在 32 位内存中存储布尔值和小整数。

除了这些具体的类类型之外,这个命名空间还包含许多附加的接口和抽象基类,您可以将它们用作创建自定义集合类的起点。虽然这些“专门化”类型可能正是您的项目在某些情况下所需要的,但是我不会在这里对它们的用法进行评论。同样,在许多情况下,您可能会发现,System.Collections.Generic名称空间提供了具有类似功能和额外好处的类。

Note

中有两个额外的以集合为中心的名称空间(System.Collections.ObjectModelSystem.Collections.Concurrent)。NET 核心基本类库。在熟悉了泛型的主题之后,你将在本章的后面检查前一个名称空间。System.Collections.Concurrent提供了非常适合多线程环境的集合类(见第十五章关于多线程的信息)。

非一般性收藏的问题

虽然许多成功人士。NET 和。NET 核心应用已经使用这些非泛型集合类(和接口)构建了多年,历史表明使用这些类型会导致许多问题。

第一个问题是使用System.CollectionsSystem.Collections.Specialized类会导致一些性能很差的代码,尤其是当你操作数字数据(例如值类型)的时候。正如您马上会看到的,当您在任何非泛型集合类中存储结构以对System.Object进行操作时,CoreCLR 必须执行大量内存转移操作,这会降低运行时执行速度。

第二个问题是,大多数非泛型集合类都不是类型安全的,因为(再次)它们是为在System.Object上操作而开发的,因此它们可以包含任何内容。如果开发人员需要创建一个高度类型安全的集合(例如,一个可以保存只实现某个接口的对象的容器),唯一真正的选择是手工创建一个新的集合类。这样做不需要太多的劳动,但是有点乏味。

在研究如何在程序中使用泛型之前,您会发现更仔细地研究一下非泛型集合类的问题是有帮助的;这将帮助你更好地理解泛型首先要解决的问题。如果您想继续操作,请创建一个名为 IssuesWithNonGenericCollections 的新控制台应用项目。接下来,确保将SystemSystem.Collections名称空间导入到Program.cs文件的顶部,并清除剩余的代码。

using System;
using System.Collections;

性能的问题

你可能还记得第四章。NET 核心平台支持两大类数据:值类型和引用类型。鉴于此。NET Core 定义了两个主要的类型类别,您可能偶尔需要将一个类别的变量表示为另一个类别的变量。为此,C# 提供了一个简单的机制,称为装箱,将数据存储在引用变量的值类型中。假设您已经在一个名为SimpleBoxUnboxOperation的方法中创建了一个类型为int的局部变量。如果在应用的过程中,您要将这个值类型表示为一个引用类型,那么您应该装箱这个值,如下所示:

static void SimpleBoxUnboxOperation()
{
  // Make a ValueType (int) variable.
  int myInt = 25;

  // Box the int into an object reference.
  object boxedInt = myInt;
}

装箱可以被正式定义为将值类型显式分配给一个System.Object变量的过程。当您装箱一个值时,CoreCLR 在堆上分配一个新对象,并将值类型的值(在本例中为25)复制到该实例中。返回给您的是对新分配的基于堆的对象的引用。

通过拆箱也可以进行相反的操作。取消装箱是将对象引用中保存的值转换回堆栈上相应值类型的过程。从语法上来说,拆箱操作看起来像普通的造型操作。然而,语义却大相径庭。CoreCLR 首先验证接收数据类型是否等同于 boxed 类型,如果是,它将值复制回一个基于堆栈的局部变量。例如,假设boxedInt的底层类型确实是一个int,下面的拆箱操作会成功:

static void SimpleBoxUnboxOperation()
{
  // Make a ValueType (int) variable.
  int myInt = 25;

  // Box the int into an object reference.
  object boxedInt = myInt;

  // Unbox the reference back into a corresponding int.
  int unboxedInt = (int)boxedInt;
}

当 C# 编译器遇到装箱/拆箱语法时,它会发出包含box / unbox操作码的 CIL 代码。如果您使用ildasm.exe检查您编译的程序集,您会发现如下内容:

.method assembly hidebysig static
    void  '<<Main>$>g__SimpleBoxUnboxOperation|0_0'() cil managed
{
  .maxstack  1
  .locals init (int32 V_0, object V_1, int32 V_2)
    IL_0000:  nop
    IL_0001:  ldc.i4.s   25
    IL_0003:  stloc.0
    IL_0004:  ldloc.0
    IL_0005:  box        [System.Runtime]System.Int32
    IL_000a:  stloc.1
    IL_000b:  ldloc.1
    IL_000c:  unbox.any  [System.Runtime]System.Int32
    IL_0011:  stloc.2
    IL_0012:  ret
  } // end of method '<Program>$'::'<<Main>$>g__SimpleBoxUnboxOperation|0_0'

请记住,与执行典型的强制转换不同,您必须取消装箱成适当的数据类型。如果您试图将一段数据拆箱为不正确的数据类型,将会抛出一个InvalidCastException异常。为了绝对安全,你应该用try / catch逻辑包装每个拆箱操作;然而,对于每个拆箱操作来说,这将是相当劳动密集型的。考虑下面的代码更新,它将抛出一个错误,因为您试图将装箱的int解装箱成一个long:

static void SimpleBoxUnboxOperation()
{
  // Make a ValueType (int) variable.
  int myInt = 25;

  // Box the int into an object reference.
  object boxedInt = myInt;

  // Unbox in the wrong data type to trigger
  // runtime exception.
  try
  {
    long unboxedLong = (long)boxedInt;
  }
  catch (InvalidCastException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

乍一看,装箱/取消装箱似乎是一个平淡无奇的语言特性,其学术性大于实用性。毕竟,您很少需要在本地object变量中存储本地值类型,如下所示。然而,事实证明装箱/拆箱过程非常有用,因为它允许您假设一切都可以被视为一个System.Object,而 CoreCLR 则代表您处理与内存相关的细节。

让我们看看这些技术的实际应用。我们将研究System.Collections.ArrayList类,并使用它保存一批数值(堆栈分配的)数据。下面列出了ArrayList类的相关成员。注意,它们的原型是对System.Object数据进行操作。现在考虑一下Add()Insert()Remove()方法,以及类索引器。

public class ArrayList : IList, ICloneable
{
...
  public virtual int Add(object? value);
  public virtual void Insert(int index, object? value);
  public virtual void Remove(object? obj);
  public virtual object? this[int index] {get; set; }
}

ArrayList已经被构建为在object上操作,这些表示在堆上分配的数据,因此下面的代码编译和执行时没有抛出错误可能看起来很奇怪:

static void WorkWithArrayList()
{
  // Value types are automatically boxed when
  // passed to a method requesting an object.
  ArrayList myInts = new ArrayList();
  myInts.Add(10);
  myInts.Add(20);
  myInts.Add(35);
}

尽管您将数字数据直接传递给需要一个object的方法,但运行时会自动为您将基于堆栈的数据装箱。稍后,如果您想使用类型索引器从ArrayList中检索一个项目,您必须使用一个转换操作将堆分配的对象拆箱为堆栈分配的整数。记住,ArrayList的索引器返回的是System.Object s,而不是System.Int32 s

static void WorkWithArrayList()
{
  // Value types are automatically boxed when
  // passed to a member requesting an object.
  ArrayList myInts = new ArrayList();
  myInts.Add(10);
  myInts.Add(20);
  myInts.Add(35);

  // Unboxing occurs when an object is converted back to
  // stack-based data.
  int i = (int)myInts[0];

  // Now it is reboxed, as WriteLine() requires object types!
  Console.WriteLine("Value of your int: {0}", i);
}

同样,请注意,堆栈分配的System.Int32在调用ArrayList.Add()之前被装箱,因此它可以在所需的System.Object中传递。还要注意的是,一旦通过转换操作从ArrayList中检索到System.Object,它就被解装箱回一个System.Int32,只有当它被传递给Console.WriteLine()方法时才被再次装箱*,因为这个方法是对System.Object变量进行操作的。*

*从程序员的角度来看,装箱和拆箱很方便,但是这种简化的堆栈/堆内存传输方法带来了性能问题(在执行速度和代码大小方面)和缺乏类型安全性。要理解性能问题,请考虑对一个简单整数进行装箱和拆箱时必须执行的以下步骤:

  1. 必须在托管堆上分配一个新对象。

  2. 基于堆栈的数据的值必须被传送到该存储器位置。

  3. 取消装箱时,存储在基于堆的对象上的值必须传输回堆栈。

  4. 堆上现在未使用的对象将(最终)被垃圾回收。

尽管这个特殊的WorkWithArrayList()方法不会导致性能方面的主要瓶颈,但是如果一个ArrayList包含了成千上万的整数,并且程序在一定程度上定期地对这些整数进行操作,那么您肯定会感觉到这种影响。在理想情况下,您可以在一个容器中操作基于堆栈的数据,而不会有任何性能问题。理想情况下,如果您不必费心使用try / catch范围从这个容器中提取数据就好了(这正是泛型让您实现的)。

类型安全的问题

在讨论拆箱操作时,我提到了类型安全的问题。回想一下,您必须将数据取消装箱为装箱前声明的相同数据类型。然而,在一个无泛型的世界里,你必须记住类型安全的另一个方面:事实上大多数的System.Collections类通常可以包含任何东西,因为它们的成员被原型化为在System.Object上操作。例如,这个方法构建了一个由不相关数据的随机比特组成的ArrayList:

static void ArrayListOfRandomObjects()
{
  // The ArrayList can hold anything at all.
  ArrayList allMyObjects = new ArrayList();
  allMyObjects.Add(true);
  allMyObjects.Add(new OperatingSystem(PlatformID.MacOSX, new Version(10, 0)));
  allMyObjects.Add(66);
  allMyObjects.Add(3.14);
}

在某些情况下,你会需要一个非常灵活的容器,几乎可以容纳任何东西(如此处所示)。然而,大多数时候你想要一个类型安全的容器,它只能在特定类型的数据点上操作。例如,您可能需要一个只能容纳数据库连接、位图或与IPointy兼容的对象的容器。

在泛型出现之前,解决类型安全问题的唯一方法是手动创建一个自定义(强类型)集合类。假设您想要创建一个自定义集合,它只能包含类型为Person的对象。

namespace IssuesWithNonGenericCollections
{
  public class Person
  {
    public int Age {get; set;}
    public string FirstName {get; set;}
    public string LastName {get; set;}

    public Person(){}
    public Person(string firstName, string lastName, int age)
    {
      Age = age;
      FirstName = firstName;
      LastName = lastName;
    }

    public override string ToString()
    {
      return $"Name: {FirstName} {LastName}, Age: {Age}";
    }
  }
}

要构建一个只能容纳Person对象的集合,可以在名为PersonCollection的类中定义一个System.Collections.ArrayList成员变量,并将所有成员配置为操作强类型Person对象,而不是操作System.Object类型。下面是一个简单的例子(产品级定制集合可以支持许多额外的成员,并且可能从System.CollectionsSystem.Collections.Specialized名称空间扩展一个抽象基类):

using System.Collections;
namespace IssuesWithNonGenericCollections
{
  public class PersonCollection : IEnumerable
  {
    private ArrayList arPeople = new ArrayList();

    // Cast for caller.
    public Person GetPerson(int pos) => (Person)arPeople[pos];

    // Insert only Person objects.
    public void AddPerson(Person p)
    {
      arPeople.Add(p);
    }

    public void ClearPeople()
    {
      arPeople.Clear();
    }

    public int Count => arPeople.Count;

    // Foreach enumeration support.
    IEnumerator IEnumerable.GetEnumerator() => arPeople.GetEnumerator();
  }
}

注意,PersonCollection类实现了IEnumerable接口,该接口允许对每个包含的项目进行类似于foreach的迭代。还要注意,您的GetPerson()AddPerson()方法已经被原型化,只对Person对象进行操作,而不是位图、字符串、数据库连接或其他项目。有了这些定义的类型,现在就可以确保类型安全,因为 C# 编译器将能够确定任何插入不兼容数据类型的尝试。将Program.cs中的using语句更新为以下内容,并将UserPersonCollection()方法添加到当前代码的末尾:

using System;
using System.Collections;
using IssuesWithNonGenericCollections;
//Top level statements in Program.cs
static void UsePersonCollection()
{
  Console.WriteLine("***** Custom Person Collection *****\n");
  PersonCollection myPeople = new PersonCollection();
  myPeople.AddPerson(new Person("Homer", "Simpson", 40));
  myPeople.AddPerson(new Person("Marge", "Simpson", 38));
  myPeople.AddPerson(new Person("Lisa", "Simpson", 9));
  myPeople.AddPerson(new Person("Bart", "Simpson", 7));
  myPeople.AddPerson(new Person("Maggie", "Simpson", 2));

  // This would be a compile-time error!
  // myPeople.AddPerson(new Car());

  foreach (Person p in myPeople)
  {
    Console.WriteLine(p);
  }
}

虽然自定义集合确实可以确保类型安全,但是这种方法使您必须为要包含的每个唯一数据类型创建一个(几乎相同的)自定义集合。因此,如果您需要一个只能在从Car基类派生的类上操作的定制集合,您需要构建一个高度相似的集合类。

using System.Collections;
public class CarCollection : IEnumerable
{
  private ArrayList arCars = new ArrayList();

  // Cast for caller.
  public Car GetCar(int pos) => (Car) arCars[pos];

  // Insert only Car objects.
  public void AddCar(Car c)
  {
    arCars.Add(c);
  }

  public void ClearCars()
  {
    arCars.Clear();
  }

  public int Count => arCars.Count;

  // Foreach enumeration support.
  IEnumerator IEnumerable.GetEnumerator() => arCars.GetEnumerator();
}

然而,自定义集合类并不能解决装箱/拆箱惩罚的问题。即使您要创建一个名为IntCollection的定制集合,并设计为只对System.Int32项进行操作,您也必须分配某种类型的对象来保存数据(例如,System.ArrayArrayList)。

using System.Collections;
public class IntCollection : IEnumerable
{
  private ArrayList arInts = new ArrayList();

  // Get an int (performs unboxing!).
  public int GetInt(int pos) => (int)arInts[pos];

  // Insert an int (performs boxing)!
  public void AddInt(int i)
  {
    arInts.Add(i);
  }

  public void ClearInts()
  {
    arInts.Clear();
  }

  public int Count => arInts.Count;

  IEnumerator IEnumerable.GetEnumerator() => arInts.GetEnumerator();
}

无论您选择哪种类型来保存整数,您都无法使用非泛型容器来摆脱装箱的困境。

通用集合初探

当您使用泛型集合类时,您纠正了所有以前的问题,包括装箱/取消装箱惩罚和缺乏类型安全性。此外,构建定制(通用)集合类的需求变得非常少。您可以使用一个通用集合类并指定类型的类型,而不必构建可以包含人、车和整数的唯一类。将下面的using语句添加到Program.cs类的顶部:

using System.Collections.Generic;

考虑下面的方法(添加到Program.cs的底部),它使用泛型List<T>类(在System.Collections.Generic名称空间中)以强类型的方式包含各种类型的数据(此时不要担心泛型语法的细节):

static void UseGenericList()
{
  Console.WriteLine("***** Fun with Generics *****\n");

  // This List<> can hold only Person objects.
  List<Person> morePeople = new List<Person>();
  morePeople.Add(new Person ("Frank", "Black", 50));
  Console.WriteLine(morePeople[0]);

  // This List<> can hold only integers.
  List<int> moreInts = new List<int>();
  moreInts.Add(10);
  moreInts.Add(2);
  int sum = moreInts[0] + moreInts[1];

  // Compile-time error! Can't add Person object
  // to a list of ints!
  // moreInts.Add(new Person());
}

第一个List<T>对象只能包含Person对象。因此,当从容器中提取项时,不需要执行强制转换,这使得这种方法更加类型安全。第二个List<T>只能包含整数,全部分配在堆栈上;换句话说,不存在您在非泛型ArrayList中发现的隐藏装箱或取消装箱。下面是泛型容器相对于非泛型容器的优势列表:

  • 泛型提供了更好的性能,因为它们在存储值类型时不会导致装箱或取消装箱的损失。

  • 泛型是类型安全的,因为它们只能包含您指定的类型。

  • 泛型极大地减少了构建自定义集合类型的需要,因为您在创建泛型容器时指定了“类型的类型”。

泛型类型参数的作用

中可以找到泛型类、接口、结构和委托。NET 核心基础类库,这些可能是任何。NET 核心命名空间。还要注意,泛型的用途远不止定义一个集合类。当然,出于各种原因,你会在本书的剩余部分看到许多不同的泛型。

Note

只有类、结构、接口和委托可以通用地编写;枚举类型不能。

当您看到列在。NET 核心文档或 Visual Studio 对象浏览器,您会注意到一对尖括号,中间夹着一个字母或其他标记。图 10-1 显示了 Visual Studio 对象浏览器显示了位于System.Collections.Generic名称空间内的许多通用项,包括突出显示的List<T>类。

img/340876_10_En_10_Fig1_HTML.jpg

图 10-1。

支持类型参数的一般项

正式来说,你把这些令牌叫做类型参数;然而,用更加用户友好的术语来说,你可以简单地称它们为占位符。你可以把符号<T>读作“of T”。因此,你可以把IEnumerable<T>读作“T 的IEnumerable”或者换一种说法,“T 型的IEnumerable

Note

类型参数(占位符)的名称无关紧要,这取决于创建泛型项的开发人员。但是,通常 T 用于表示类型, TKeyK 用于键, TValueV 用于值。

当创建泛型对象、实现泛型接口或调用泛型成员时,由您来决定是否向类型参数提供值。在这一章和正文的其余部分,你会看到许多例子。然而,为了做好准备,让我们看看与泛型类型和成员交互的基础知识。

为泛型类/结构指定类型参数

创建泛型类或结构的实例时,在声明变量和调用构造函数时指定类型参数。正如您在前面的代码示例中看到的,UseGenericList()定义了两个List<T>对象。

// This List<> can hold only Person objects.
List<Person> morePeople = new List<Person>();
// This List<> can hold only integers.
List<int> moreInts = new List<int>();

您可以将前面代码片段中的第一行理解为“a List<> of T,其中T属于类型Person或者,更简单地说,你可以把它理解为“一个人对象的列表”指定了泛型项的类型参数之后,就不能再更改了(记住,泛型都是关于类型安全的)。当您为泛型类或结构指定类型参数时,所有出现的占位符现在都将替换为您提供的值。

如果您要使用 Visual Studio 对象浏览器查看泛型List<T>类的完整声明,您将会看到占位符T贯穿于List<T>类型的定义中。以下是部分清单:

// A partial listing of the List<T> class.
namespace System.Collections.Generic
{
  public class List<T> : IList<T>, IList, IReadOnlyList<T>
  {
...
    public void Add(T item);
    public void AddRange(IEnumerable<T> collection);
    public ReadOnlyCollection<T> AsReadOnly();
    public int BinarySearch(T item);
    public bool Contains(T item);
    public void CopyTo(T[] array);
    public int FindIndex(System.Predicate<T> match);
    public T FindLast(System.Predicate<T> match);
    public bool Remove(T item);
    public int RemoveAll(System.Predicate<T> match);
    public T[] ToArray();
    public bool TrueForAll(System.Predicate<T> match);
    public T this[int index] { get; set; }
  }
}

当你创建一个指定Person对象的List<T>时,就好像List<T>类型被定义如下:

namespace System.Collections.Generic
{
  public class List<Person>
    : IList<Person>, IList, IReadOnlyList<Person>
  {
...
    public void Add(Person item);
    public void AddRange(IEnumerable<Person> collection);
    public ReadOnlyCollection<Person> AsReadOnly();
    public int BinarySearch(Person item);
    public bool Contains(Person item);
    public void CopyTo(Person[] array);
    public int FindIndex(System.Predicate<Person> match);
    public Person FindLast(System.Predicate<Person> match);
    public bool Remove(Person item);
    public int RemoveAll(System.Predicate<Person> match);
    public Person[] ToArray();
    public bool TrueForAll(System.Predicate<Person> match);
    public Person this[int index] { get; set; }
  }
}

当然,当您创建一个通用的List<T>变量时,编译器并不会真的创建一个List<T>类的新实现。相反,它将只处理您实际调用的泛型类型的成员。

为泛型成员指定类型参数

非泛型类或结构可以支持泛型属性。在这些情况下,您还需要在调用方法时指定占位符值。例如,System.Array支持几种通用方法。具体来说,非泛型静态Sort()方法现在有了一个名为Sort<T>()的泛型对应方法。考虑下面的代码片段,其中T的类型是int:

int[] myInts = { 10, 4, 2, 33, 93 };

// Specify the placeholder to the generic
// Sort<>() method.
Array.Sort<int>(myInts);

foreach (int i in myInts)
{
  Console.WriteLine(i);
}

为泛型接口指定类型参数

当您构建需要支持各种框架行为(例如,克隆、排序和枚举)的类或结构时,通常会实现泛型接口。在第八章中,你学习了一些非通用接口,比如IComparableIEnumerableIEnumeratorIComparer。回想一下,非泛型IComparable接口是这样定义的:

public interface IComparable
{
  int CompareTo(object obj);
}

在第八章中,你也在你的Car类中实现了这个接口来支持标准数组中的排序。然而,代码需要几次运行时检查和转换操作,因为参数是一个通用的System.Object

public class Car : IComparable
{
...
  // IComparable implementation.
  int IComparable.CompareTo(object obj)
  {
    if (obj is Car temp)
    {
      return this.CarID.CompareTo(temp.CarID);
    }
    throw new ArgumentException("Parameter is not a Car!");
  }
}

现在假设您使用这个接口的通用对应物。

public interface IComparable<T>
{
  int CompareTo(T obj);
}

在这种情况下,您的实现代码将被大大清理。

public class Car : IComparable<Car>
{
...
  // IComparable<T> implementation.
  int IComparable<Car>.CompareTo(Car obj)
  {
    if (this.CarID > obj.CarID)
    {
      return 1;
    }
    if (this.CarID < obj.CarID)
    {
      return -1;
    }
    return 0;
  }
}

这里,你不需要检查传入的参数是否是一个Car,因为它只能只有是一个Car!如果有人传入不兼容的数据类型,您会得到一个编译时错误。现在您已经更好地掌握了如何与通用项交互,以及类型参数(也称为占位符)的角色,您已经准备好检查System.Collections.Generic名称空间的类和接口了。

系统。Collections .泛型命名空间

当您正在构建一个. NET 核心应用,并且需要一种方法来管理内存中的数据时,System.Collections.Generic类最有可能满足您的需求。在这一章的开始,我简要地提到了一些由非泛型集合类实现的核心非泛型接口。毫不奇怪,System.Collections.Generic名称空间为它们中的许多定义了通用替换。

事实上,您可以找到许多扩展其非泛型对应物的泛型接口。这可能看起来很奇怪;然而,通过这样做,实现类也将支持在它们的非泛型兄弟中找到的遗留功能。比如IEnumerable<T>扩展IEnumerable。表 10-4 记录了你在使用通用集合类时会遇到的核心通用接口。

表 10-4。

System.Collections.Generic类支持的关键接口

|

系统。集合.通用接口

|

生命的意义

ICollection<T> 定义所有泛型集合类型的一般特征(例如,大小、枚举和线程安全)。
IComparer<T> 定义一种与对象进行比较的方式。
IDictionary<TKey, TValue> 允许泛型集合对象使用键值对来表示其内容。
IEnumerable<T>/IAsyncEnumerable<T> 返回给定对象的IEnumerator<T>接口。IAsyncEnumerable(C # 8.0 中的新功能)包含在第十五章中。
IEnumerator<T> 对一般集合启用foreach样式的迭代。
IList<T> 提供在对象的顺序列表中添加、移除和索引项的行为。
ISet<T> 为集合的抽象提供基本接口。

名称空间还定义了几个实现这些关键接口的类。表 10-5 描述了这个名称空间的一些常用类,它们实现的接口,以及它们的基本功能。

表 10-5。

System.Collections.Generic的类别

|

通用类

|

支持的关键接口

|

生命的意义

Dictionary<TKey, TValue> ICollection<T>IDictionary<TKey, TValue>IEnumerable<T> 这表示键和值的一般集合。
LinkedList<T> ICollection<T>IEnumerable<T> 这代表了一个双向链表。
List<T> ICollection<T>IEnumerable<T>IList<T> 这是一个可动态调整大小的项目顺序列表。
Queue<T> ICollection(不是错别字!这是非泛型集合接口。),IEnumerable<T> 这是先进先出列表的一般实现。
SortedDictionary<TKey, TValue> ICollection<T>IDictionary<TKey, TValue>IEnumerable<T> 这是一组排序的键值对的一般实现。
SortedSet<T> ICollection<T>IEnumerable<T>ISet<T> 这表示对象的集合,这些对象按排序顺序维护,没有重复。
Stack<T> ICollection(不是错别字!这是非泛型集合接口。),IEnumerable<T> 这是后进先出列表的一般实现。

System.Collections.Generic名称空间还定义了许多与特定容器协同工作的辅助类和结构。例如,LinkedListNode<T>类型表示泛型LinkedList<T>中的一个节点,当试图使用不存在的键从容器中获取一个项目时会引发KeyNotFoundException异常,等等。请务必查阅。NET 核心文档来获得关于System.Collections.Generic名称空间的全部细节。

无论如何,您的下一个任务是学习如何使用这些通用集合类。但是,在此之前,请允许我举例说明 C# 语言的一个特性(首先在。NET 3.5),它简化了用数据填充通用(和非通用)收集容器的方式。

了解集合初始化语法

在第四章中,你学习了对象初始化语法,它允许你在构造的时候设置一个新变量的属性。与此密切相关的是集合初始化语法。C# 语言的这一特性使得通过使用与填充基本数组类似的语法来用项目填充许多容器(如ArrayListList<T>)成为可能。创建新的。名为 funwithcollectioninitial ization 的. NET 核心控制台应用。清除Program.cs中生成的代码,添加以下using语句:

using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;

Note

您只能将集合初始化语法应用于支持Add()方法的类,该方法由ICollection<T> / ICollection接口形式化。

考虑下面的例子:

// Init a standard array.
int[] myArrayOfInts = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

// Init a generic List<> of ints.
List<int> myGenericList = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

// Init an ArrayList with numerical data.
ArrayList myList = new ArrayList { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

如果您的容器正在管理一个类集合或一个结构,您可以将对象初始化语法与集合初始化语法结合起来,以生成一些功能代码。您可能还记得第五章中的Point类,它定义了两个名为XY的属性。如果您想构建一个通用的Point对象的List<T>,您可以编写如下代码:

List<Point> myListOfPoints = new List<Point>
{
  new Point { X = 2, Y = 2 },
  new Point { X = 3, Y = 3 },
  new Point { X = 4, Y = 4 }
};

foreach (var pt in myListOfPoints)
{
  Console.WriteLine(pt);
}

同样,这种语法的好处是您可以节省大量的击键次数。如果您不介意格式,嵌套的花括号可能会变得难以阅读,想象一下如果您没有集合初始化语法,填充下面的List<T> of Rectangle将需要多少代码。

List<Rectangle> myListOfRects = new List<Rectangle>
{
  new Rectangle {
    Height = 90, Width = 90,
    Location = new Point { X = 10, Y = 10 }},
  new Rectangle {
    Height = 50,Width = 50,
    Location = new Point { X = 2, Y = 2 }},
};
foreach (var r in myListOfRects)
{
  Console.WriteLine(r);
}

使用列表

创建一个名为 FunWithGenericCollections 的新控制台应用项目。添加一个新文件,命名为Person.cs,并添加以下代码(与之前的Person类代码相同):

namespace FunWithGenericCollections
{
  public class Person
  {
    public int Age {get; set;}
    public string FirstName {get; set;}
    public string LastName {get; set;}

    public Person(){}
    public Person(string firstName, string lastName, int age)
    {
      Age = age;
      FirstName = firstName;
      LastName = lastName;
    }

    public override string ToString()
    {
      return $"Name: {FirstName} {LastName}, Age: {Age}";
    }
  }
}

清除Program.cs中生成的代码,添加以下using语句:

using System;
using System.Collections.Generic;
using FunWithGenericCollections;

您将研究的第一个泛型类是List<T>,您已经在本章中见过一两次了。在System.Collections.Generic命名空间中,List<T>类肯定是您最常用的类型,因为它允许您动态地调整容器内容的大小。为了说明这种类型的基本原理,考虑一下您的Program类中的以下方法,它利用List<T>来操作本章前面显示的一组Person对象;您可能还记得这些Person对象定义了三个属性(AgeFirstNameLastName)和一个定制的ToString()实现:

static void UseGenericList()
{
  // Make a List of Person objects, filled with
  // collection/object init syntax.
  List<Person> people = new List<Person>()
  {
    new Person {FirstName= "Homer", LastName="Simpson", Age=47},
    new Person {FirstName= "Marge", LastName="Simpson", Age=45},
    new Person {FirstName= "Lisa", LastName="Simpson", Age=9},
    new Person {FirstName= "Bart", LastName="Simpson", Age=8}
  };

  // Print out # of items in List.
  Console.WriteLine("Items in list: {0}", people.Count);

  // Enumerate over list.
  foreach (Person p in people)
  {
    Console.WriteLine(p);
  }

  // Insert a new person.
  Console.WriteLine("\n->Inserting new person.");
  people.Insert(2, new Person { FirstName = "Maggie", LastName = "Simpson", Age = 2 });
  Console.WriteLine("Items in list: {0}", people.Count);

  // Copy data into a new array.
  Person[] arrayOfPeople = people.ToArray();
  foreach (Person p in arrayOfPeople)
  {
    Console.WriteLine("First Names: {0}", p.FirstName);
  }
}

这里,您使用集合初始化语法用对象填充您的List<T>,作为多次调用Add()的简写符号。在打印出集合中的条目数量(以及枚举每个条目)之后,调用Insert()。如您所见,Insert()允许您在指定的索引处将一个新项目插入到List<T>中。

*最后,注意对ToArray()方法的调用,它基于原始List<T>的内容返回一个Person对象的数组。从此数组中,使用数组的索引器语法再次循环遍历这些项。如果从顶级语句中调用此方法,将得到以下输出:

***** Fun with Generic Collections *****
Items in list: 4
Name: Homer Simpson, Age: 47
Name: Marge Simpson, Age: 45
Name: Lisa Simpson, Age: 9
Name: Bart Simpson, Age: 8

->Inserting new person.
Items in list: 5
First Names: Homer
First Names: Marge
First Names: Maggie
First Names: Lisa
First Names: Bart

List<T>类定义了许多感兴趣的额外成员,所以请务必查阅文档以获得更多信息。接下来,让我们看看几个更通用的集合,具体来说就是Stack<T>Queue<T>SortedSet<T>。这将使您能够很好地理解关于如何保存自定义应用数据的基本选择。

使用堆栈

Stack<T>类表示使用后进先出方式维护项目的集合。如您所料,Stack<T>定义了名为Push()Pop()的成员来将项目放入堆栈或从堆栈中移除项目。下面的方法创建了一个Person对象的堆栈:

static void UseGenericStack()
{
  Stack<Person> stackOfPeople = new();
  stackOfPeople.Push(new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 });
  stackOfPeople.Push(new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 });
  stackOfPeople.Push(new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 });

  // Now look at the top item, pop it, and look again.
  Console.WriteLine("First person is: {0}", stackOfPeople.Peek());
  Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
  Console.WriteLine("\nFirst person is: {0}", stackOfPeople.Peek());
  Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
  Console.WriteLine("\nFirst person item is: {0}", stackOfPeople.Peek());
  Console.WriteLine("Popped off {0}", stackOfPeople.Pop());

  try
  {
    Console.WriteLine("\nnFirst person is: {0}", stackOfPeople.Peek());
    Console.WriteLine("Popped off {0}", stackOfPeople.Pop());
  }
  catch (InvalidOperationException ex)
  {
    Console.WriteLine("\nError! {0}", ex.Message);
  }
}

在这里,您构建了一个包含三个人的堆栈,按照他们名字的顺序添加:Homer、Marge 和 Lisa。当你窥视堆栈时,你总是首先看到顶部的对象;因此,对Peek()的第一次调用揭示了第三个Person对象。在一系列的Pop()Peek()调用之后,堆栈最终清空,此时额外的Peek()Pop()调用引发一个系统异常。您可以在这里看到它的输出:

***** Fun with Generic Collections *****
First person is: Name: Lisa Simpson, Age: 9
Popped off Name: Lisa Simpson, Age: 9

First person is: Name: Marge Simpson, Age: 45
Popped off Name: Marge Simpson, Age: 45

First person item is: Name: Homer Simpson, Age: 47
Popped off Name: Homer Simpson, Age: 47

Error! Stack empty

.

使用队列

队列是确保以先进先出的方式访问项目的容器。可悲的是,我们人类整天都在排队:在银行排队,在电影院排队,在早晨的咖啡馆排队。当您需要建立一个场景模型,在这个场景中,项目是按照先来先服务的原则处理的,您会发现Queue<T>类符合这个要求。除了被支持的接口所提供的功能外,Queue还定义了表 10-6 中所示的关键成员。

表 10-6。

Queue<T>类型的成员

|

选择队列成员

|

生命的意义

Dequeue() 移除并返回Queue<T>开头的对象
Enqueue() 将一个对象添加到Queue<T>的末尾
Peek() 返回Queue<T>开头的对象,但不删除它

现在让我们将这些方法付诸实践。您可以再次利用您的Person类,构建一个Queue<T>对象来模拟排队等候点咖啡的人群。

static void UseGenericQueue()
{
  // Make a Q with three people.
  Queue<Person> peopleQ = new();
  peopleQ.Enqueue(new Person {FirstName= "Homer", LastName="Simpson", Age=47});
  peopleQ.Enqueue(new Person {FirstName= "Marge", LastName="Simpson", Age=45});
  peopleQ.Enqueue(new Person {FirstName= "Lisa", LastName="Simpson", Age=9});

  // Peek at first person in Q.
  Console.WriteLine("{0} is first in line!", peopleQ.Peek().FirstName);

  // Remove each person from Q.
  GetCoffee(peopleQ.Dequeue());
  GetCoffee(peopleQ.Dequeue());
  GetCoffee(peopleQ.Dequeue());
  // Try to de-Q again?
  try
  {
    GetCoffee(peopleQ.Dequeue());
  }
  catch(InvalidOperationException e)
  {
    Console.WriteLine("Error! {0}", e.Message);
  }
  //Local helper function
  static void GetCoffee(Person p)
  {
    Console.WriteLine("{0} got coffee!", p.FirstName);
  }
}

这里,您使用Enqueue()方法将三个项目插入到Queue<T>类中。对Peek()的调用允许您查看(但不能删除)当前在Queue中的第一个项目。最后,对Dequeue()的调用从行中删除项目,并将其发送到GetCoffee()辅助函数进行处理。请注意,如果试图从空队列中移除项,将会引发运行时异常。以下是调用此方法时收到的输出:

***** Fun with Generic Collections *****
Homer is first in line!
Homer got coffee!
Marge got coffee!
Lisa got coffee!
Error! Queue empty.

使用 SortedSet

SortedSet<T>类很有用,因为它能自动确保在插入或删除项目时对集合中的项目进行排序。然而,你确实需要通过传入一个实现通用IComparer<T>接口的对象作为构造函数参数,准确地通知SortedSet<T>你希望它如何排序对象。

首先创建一个名为SortPeopleByAge的新类,它实现了IComparer<T>,其中T的类型是Person。回想一下,这个接口定义了一个名为Compare()的方法,在这个方法中,您可以编写任何需要进行比较的逻辑。下面是该类的一个简单实现:

using System.Collections.Generic;

namespace FunWithGenericCollections
{
  class SortPeopleByAge : IComparer<Person>
  {
    public int Compare(Person firstPerson, Person secondPerson)
    {
      if (firstPerson?.Age > secondPerson?.Age)
      {
          return 1;
      }
      if (firstPerson?.Age < secondPerson?.Age)
      {
        return -1;
      }
      return 0;
    }
  }
}

现在添加以下演示使用SortedSet<Person>的新方法:

static void UseSortedSet()
{
  // Make some people with different ages.
  SortedSet<Person> setOfPeople = new SortedSet<Person>(new SortPeopleByAge())
  {
    new Person {FirstName= "Homer", LastName="Simpson", Age=47},
    new Person {FirstName= "Marge", LastName="Simpson", Age=45},
    new Person {FirstName= "Lisa", LastName="Simpson", Age=9},
    new Person {FirstName= "Bart", LastName="Simpson", Age=8}
  };

  // Note the items are sorted by age!
  foreach (Person p in setOfPeople)
  {
    Console.WriteLine(p);
  }
    Console.WriteLine();

  // Add a few new people, with various ages.
  setOfPeople.Add(new Person { FirstName = "Saku", LastName = "Jones", Age = 1 });
  setOfPeople.Add(new Person { FirstName = "Mikko", LastName = "Jones", Age = 32 });

  // Still sorted by age!
  foreach (Person p in setOfPeople)
  {
    Console.WriteLine(p);
  }
}

当您运行应用时,对象列表现在总是基于Age属性的值进行排序,而不管您插入或移除对象的顺序。

***** Fun with Generic Collections *****
Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9
Name: Marge Simpson, Age: 45
Name: Homer Simpson, Age: 47

Name: Saku Jones, Age: 1
Name: Bart Simpson, Age: 8
Name: Lisa Simpson, Age: 9
Name: Mikko Jones, Age: 32
Name: Marge Simpson, Age: 45
Name: Homer Simpson, Age: 47

使用字典

另一个方便的泛型集合是Dictionary<TKey,TValue>类型,它允许您保存任意数量的对象,这些对象可以通过一个惟一的键来引用。因此,您可以使用唯一的文本键(例如,“给我第二个对象”),而不是使用数字标识符从List<T>获取项目(例如,“给我我键入为 Homer 的对象”)。

像其他集合对象一样,您可以通过手动调用通用的Add()方法来填充一个Dictionary<TKey,TValue>。但是,您也可以使用集合初始化语法填充一个Dictionary<TKey,TValue>。请注意,在填充这个集合对象时,键名必须是唯一的。如果多次错误地指定了同一个键,将会收到运行时异常。

考虑以下用各种对象填充Dictionary<K,V>的方法。注意,当您创建Dictionary<TKey,TValue>对象时,您指定键类型(TKey)和底层对象类型(TValue)作为构造函数参数。在这个例子中,您使用一个string数据类型作为键,使用一个Person类型作为值。另请注意,您可以将对象初始化语法与集合初始化语法结合使用。

private static void UseDictionary()
{
    // Populate using Add() method
    Dictionary<string, Person> peopleA = new Dictionary<string, Person>();
    peopleA.Add("Homer", new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 });
    peopleA.Add("Marge", new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 });
    peopleA.Add("Lisa", new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 });

    // Get Homer.
    Person homer = peopleA["Homer"];
    Console.WriteLine(homer);

    // Populate with initialization syntax.
    Dictionary<string, Person> peopleB = new Dictionary<string, Person>()
    {
        { "Homer", new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 } },
        { "Marge", new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 } },
        { "Lisa", new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 } }
    };

    // Get Lisa.
    Person lisa = peopleB["Lisa"];
    Console.WriteLine(lisa);
}

也可以使用特定于这种类型容器的相关初始化语法来填充Dictionary<TKey,TValue>(毫不奇怪地称为字典初始化)。类似于前面代码示例中用于填充personB对象的语法,您仍然为集合对象定义一个初始化范围;但是,您可以使用索引器来指定键,并将其分配给新对象,如下所示:

// Populate with dictionary initialization syntax.
Dictionary<string, Person> peopleC = new Dictionary<string, Person>()
{
    ["Homer"] = new Person { FirstName = "Homer", LastName = "Simpson", Age = 47 },
    ["Marge"] = new Person { FirstName = "Marge", LastName = "Simpson", Age = 45 },
    ["Lisa"] = new Person { FirstName = "Lisa", LastName = "Simpson", Age = 9 }
};

系统。Collections.ObjectModel 命名空间

既然您已经理解了如何使用主要的泛型类,我们将简要地研究一个额外的以集合为中心的名称空间,System.Collections.ObjectModel。这是一个相对较小的名称空间,包含少量的类。表 10-7 记录了你最应该知道的两个类别。

表 10-7。

System.Collections.ObjectModel的有用成员

|

系统。集合. ObjectModel 类型

|

生命的意义

ObservableCollection<T> 表示一个动态数据集合,该集合在添加项、移除项或刷新整个列表时提供通知
ReadOnlyObservableCollection<T> 表示只读版本的ObservableCollection<T>

ObservableCollection<T>类是有用的,因为当它的内容以某种方式改变时,它能够通知外部对象(正如您可能猜到的,使用ReadOnlyObservableCollection<T>类似,但本质上是只读的)。

使用 ObservableCollection

创建一个名为 FunWithObservableCollections 的新控制台应用项目,并将名称空间System.Collections.ObjectModel导入到初始 C# 代码文件中。在许多方面,使用ObservableCollection<T>与使用List<T>是相同的,因为这两个类实现了相同的核心接口。ObservableCollection<T>类的独特之处在于它支持一个名为CollectionChanged的事件。每当插入新项、移除(或重新定位)当前项或修改整个集合时,都会触发此事件。

像任何事件一样,CollectionChanged是根据委托定义的,在本例中是NotifyCollectionChangedEventHandler。这个委托可以调用任何以一个对象作为第一个参数,以一个NotifyCollectionChangedEventArgs作为第二个参数的方法。考虑下面的代码,它填充了一个包含Person对象的可观察集合,并连接了CollectionChanged事件:

using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using FunWithObservableCollections;

// Make a collection to observe
//and add a few Person objects.
ObservableCollection<Person> people = new ObservableCollection<Person>()
{
  new Person{ FirstName = "Peter", LastName = "Murphy", Age = 52 },
  new Person{ FirstName = "Kevin", LastName = "Key", Age = 48 },
};

// Wire up the CollectionChanged event.
people.CollectionChanged += people_CollectionChanged;

static void people_CollectionChanged(object sender,
    System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
  throw new NotImplementedException();
}

传入的NotifyCollectionChangedEventArgs参数定义了两个重要的属性,OldItemsNewItems,这将为您提供一个列表,其中列出了事件触发前集合中的当前项目以及变更中涉及的新项目。但是,您只希望在正确的情况下检查这些列表。回想一下,当添加、删除、重定位或重置项目时,会触发CollectionChanged事件。要发现这些动作中的哪一个触发了事件,您可以使用NotifyCollectionChangedEventArgsAction属性。可以针对NotifyCollectionChangedAction枚举的以下任何成员测试Action属性:

public enum NotifyCollectionChangedAction
{
  Add = 0,
  Remove = 1,
  Replace = 2,
  Move = 3,
  Reset = 4,
}

下面是一个CollectionChanged事件处理程序的实现,当一个项目被插入到集合中或从集合中删除时,它将遍历旧的和新的集合(注意System.Collections.Specializedusing):

using System.Collections.Specialized;
...
static void people_CollectionChanged(object sender,
  NotifyCollectionChangedEventArgs e)
{
  // What was the action that caused the event?
  Console.WriteLine("Action for this event: {0}", e.Action);

  // They removed something.
  if (e.Action == NotifyCollectionChangedAction.Remove)
  {
    Console.WriteLine("Here are the OLD items:");
    foreach (Person p in e.OldItems)
    {
      Console.WriteLine(p.ToString());
    }
    Console.WriteLine();
  }

  // They added something.
  if (e.Action == NotifyCollectionChangedAction.Add)
  {
    // Now show the NEW items that were inserted.
    Console.WriteLine("Here are the NEW items:");
    foreach (Person p in e.NewItems)
    {
      Console.WriteLine(p.ToString());
    }
  }
}

现在,更新您的调用代码来添加和移除一个项目。

// Now add a new item.
people.Add(new Person("Fred", "Smith", 32));
// Remove an item.
people.RemoveAt(0);

当您运行该程序时,您将看到类似如下的输出:

Action for this event: Add
Here are the NEW items:
Name: Fred Smith, Age: 32

Action for this event: Remove
Here are the OLD items:
Name: Peter Murphy, Age: 52

这就结束了对各种以集合为中心的名称空间的检查。作为本章的总结,现在您将研究如何构建自己的自定义泛型方法和自定义泛型类型。

创建自定义泛型方法

虽然大多数开发人员通常使用基类库中的现有泛型类型,但也可以构建自己的泛型成员和自定义泛型类型。让我们看看如何将自定义泛型合并到您自己的项目中。第一步是构建一个通用的交换方法。首先创建一个名为 CustomGenericMethods 的新控制台应用。

当您构建自定义泛型方法时,您实现了传统方法重载的增压版本。在第二章中,你学到了重载是定义一个方法的多个版本的行为,这些版本在参数的数量或类型上有所不同。

虽然重载在面向对象语言中是一个有用的特性,但有一个问题是,你很容易用大量本质上做同样事情的方法来结束。例如,假设您需要构建一些方法,这些方法可以使用一个简单的交换例程来交换两段数据。您可以从创建一个新的静态类开始,使用一个可以操作整数的方法,如下所示:

using System;
namespace CustomGenericMethods
{
  static class SwapFunctions
  {
    // Swap two integers.
    static void Swap(ref int a, ref int b)
    {
      int temp = a;
      a = b;
      b = temp;
    }
  }
}

目前为止,一切顺利。但是现在假设您还需要交换两个Person对象;这需要创作一个新版本的Swap()

// Swap two Person objects.
static void Swap(ref Person a, ref Person b)
{
  Person temp = a;
  a = b;
  b = temp;
}

毫无疑问,你可以看到这将走向何方。如果你还需要交换浮点数、位图、汽车、按钮等。,您将不得不构建更多的方法,这将成为维护的噩梦。你可以构建一个操作object参数的单一(非泛型)方法,但是你会面临你在本章前面检查过的所有问题,包括装箱、拆箱、缺乏类型安全、显式强制转换等等。

每当你有一组重载的方法,它们只有传入的参数不同,这是你的线索,泛型可以让你的生活更容易。考虑下面的通用Swap<T>()方法,它可以交换任意两个T:

// This method will swap any two items.
// as specified by the type parameter <T>.
static void Swap<T>(ref T a, ref T b)
{
  Console.WriteLine("You sent the Swap() method a {0}", typeof(T));
  T temp = a;
  a = b;
  b = temp;
}

请注意,泛型方法是如何通过在方法名之后、参数列表之前指定类型参数来定义的。这里,您声明了Swap<T>()方法可以对任意两个类型为<T>的参数进行操作。为了增加一点趣味,您还可以使用 C# 的typeof()操作符将所提供的占位符的类型名称打印到控制台。现在考虑下面的调用代码,它交换整数和字符串:

Console.WriteLine("***** Fun with Custom Generic Methods *****\n");

// Swap 2 ints.
int a = 10, b = 90;
Console.WriteLine("Before swap: {0}, {1}", a, b);
SwapFunctions.Swap<int>(ref a, ref b);
Console.WriteLine("After swap: {0}, {1}", a, b);
Console.WriteLine();

// Swap 2 strings.
string s1 = "Hello", s2 = "There";
Console.WriteLine("Before swap: {0} {1}!", s1, s2);
SwapFunctions.Swap<string>(ref s1, ref s2);
Console.WriteLine("After swap: {0} {1}!", s1, s2);

Console.ReadLine();

输出如下所示:

***** Fun with Custom Generic Methods *****
Before swap: 10, 90
You sent the Swap() method a System.Int32
After swap: 90, 10

Before swap: Hello There!
You sent the Swap() method a System.String
After swap: There Hello!

这种方法的主要好处是您只需要维护一个版本的Swap<T>(),但是它可以以类型安全的方式对给定类型的任意两个项目进行操作。更好的是,基于栈的项留在栈上,而基于堆的项留在堆上!

类型参数的推断

当您调用泛型方法(如Swap<T>)时,如果(且仅当)泛型方法需要参数,您可以选择省略类型参数,因为编译器可以根据成员参数推断类型参数。例如,您可以通过在顶级语句中添加以下代码来交换两个System.Boolean值:

// Compiler will infer System.Boolean.
bool b1 = true, b2 = false;
Console.WriteLine("Before swap: {0}, {1}", b1, b2);
SwapFunctions.Swap(ref b1, ref b2);
Console.WriteLine("After swap: {0}, {1}", b1, b2);

即使编译器可以根据用于声明b1b2的数据类型发现正确的类型参数,您也应该养成总是显式指定类型参数的习惯。

SwapFunctions.Swap<bool>(ref b1, ref b2);

这让你的程序员同事清楚,这个方法确实是通用的。此外,只有当泛型方法至少有一个参数时,类型参数的推断才有效。例如,假设您的Program类中有以下泛型方法:

static void DisplayBaseClass<T>()
{
  // BaseType is a method used in reflection,
  // which will be examined in Chapter 17
  Console.WriteLine("Base class of {0} is: {1}.", typeof(T), typeof(T).BaseType);
}

在这种情况下,您必须在调用时提供类型参数。

...
// Must supply type parameter if
// the method does not take params.
DisplayBaseClass<int>();
DisplayBaseClass<string>();

// Compiler error! No params? Must supply placeholder!
// DisplayBaseClass();
Console.ReadLine();

当然,泛型方法不需要像这些例子中那样是静态的。非泛型方法的所有规则和选项也适用。

创建自定义泛型结构和类

现在,您已经了解了如何定义和调用泛型方法,是时候将注意力转向在名为 GenericPoint 的新控制台应用项目中构造泛型结构了(构建泛型类的过程是相同的)。假设您已经构建了一个通用的Point结构,它支持单个类型参数,该参数表示( x,y )坐标的底层存储。然后调用者可以创建如下的Point<T>类型:

// Point using ints.
Point<int> p = new Point<int>(10, 10);

// Point using double.
Point<double> p2 = new Point<double>(5.4, 3.3);

// Point using strings.
Point<string> p3 = new Point<string>(""",""3"");

使用字符串创建一个点乍一看可能有点奇怪,但是考虑一下虚数的情况。那么使用字符串表示一个点的值XY可能是有意义的。无论如何,它展示了泛型的力量。这里是Point<T>的完整定义:

namespace GenericPoint
{
  // A generic Point structure.
  public struct Point<T>
  {
    // Generic state data.
    private T _xPos;
    private T _yPos;

    // Generic constructor.
    public Point(T xVal, T yVal)
    {
      _xPos = xVal;
      _yPos = yVal;
    }

    // Generic properties.
    public T X
    {
      get => _xPos;
      set => _xPos = value;
    }

    public T Y
    {
      get => _yPos;
      set => _yPos = value;
    }

    public override string ToString() => $"[{_xPos}, {_yPos}]";
  }
}

如您所见,Point<T>在字段数据的定义、构造函数参数和属性定义中利用了它的类型参数。

具有泛型的默认值表达式

随着泛型的引入,C# default关键字被赋予了双重身份。除了在switch构造中使用之外,它还可以用于将类型参数设置为默认值。这很有帮助,因为泛型类型事先不知道实际的占位符,这意味着它不能安全地假定默认值是什么。类型参数的默认值如下:

  • 数值的默认值为0

  • 引用类型有一个默认值null

  • 结构的字段被设置为0(对于值类型)或null(对于引用类型)。

要重置Point<T>的实例,您可以直接将XY的值设置为0。这假定调用者将只提供数字数据。那string版本呢?这就是default(T)语法派上用场的地方。default 关键字将变量重置为该变量数据类型的默认值。如下添加一个名为ResetPoint()的方法:

// Reset fields to the default value of the type parameter.
// The "default" keyword is overloaded in C#.
// When used with generics, it represents the default
// value of a type parameter.
public void ResetPoint()
{
  _xPos = default(T);
  _yPos = default(T);
}

现在你已经有了ResetPoint()的方法,你可以充分运用Point<T> struct的方法。

using System;
using GenericPoint;

Console.WriteLine("***** Fun with Generic Structures *****\n");
// Point using ints.
Point<int> p = new Point<int>(10, 10);
Console.WriteLine("p.ToString()={0}", p.ToString());
p.ResetPoint();
Console.WriteLine("p.ToString()={0}", p.ToString());
Console.WriteLine();

// Point using double.
Point<double> p2 = new Point<double>(5.4, 3.3);
Console.WriteLine("p2.ToString()={0}", p2.ToString());
p2.ResetPoint();
Console.WriteLine("p2.ToString()={0}", p2.ToString());
Console.WriteLine();

// Point using strings.
Point<string> p3 = new Point<string>("i", "3i");
Console.WriteLine("p3.ToString()={0}", p3.ToString());
p3.ResetPoint();
Console.WriteLine("p3.ToString()={0}", p3.ToString());
Console.ReadLine();

以下是输出:

***** Fun with Generic Structures *****
p.ToString()=[10, 10]
p.ToString()=[0, 0]

p2.ToString()=[5.4, 3.3]
p2.ToString()=[0, 0]

p3.ToString()=[i, 3i]
p3.ToString()=[, ]

默认文字表达式(新 7.1)

除了设置属性的默认值,C# 7.1 还引入了默认的文字表达式。这消除了在 default 语句中指定变量类型的需要。将ResetPoint()方法更新如下:

public void ResetPoint()
{
  _xPos = default;
  _yPos = default;
}

默认表达式不限于简单变量,也可以应用于复杂类型。例如,要创建并初始化Point结构,您可以编写以下代码:

Point<string> p4 = default;
Console.WriteLine("p4.ToString()={0}", p4.ToString());
Console.WriteLine();
Point<int> p5 = default;
Console.WriteLine("p5.ToString()={0}", p5.ToString());

泛型模式匹配(新 7.1)

C# 7.1 的另一个更新是泛型模式匹配的能力。以下面的方法为例,该方法检查Point实例的数据类型(可能不完整,但足以说明概念):

static void PatternMatching<T>(Point<T> p)
{
  switch (p)
  {
    case Point<string> pString:
      Console.WriteLine("Point is based on strings");
      return;
    case Point<int> pInt:
      Console.WriteLine("Point is based on ints");
      return;
  }
}

要运行模式匹配代码,请更新top-level statements to the following:

Point<string> p4 = default;
Point<int> p5 = default;
PatternMatching(p4);
PatternMatching(p5);

约束类型参数

如本章所示,任何泛型项都至少有一个类型参数,您需要在与泛型类型或成员交互时指定该类型参数。仅这一点就允许您构建一些类型安全的代码;但是,您也可以使用where关键字来非常具体地说明给定的类型参数应该是什么样子。

使用该关键字,可以向给定的类型参数添加一组约束,C# 编译器将在编译时检查这些约束。具体来说,你可以约束一个类型参数,如表 10-8 所述。

表 10-8。

泛型类型参数的可能约束

|

通用约束

|

生命的意义

where T : struct 类型参数<T>在其继承链中必须有System.ValueType(即<T>必须是一个结构)。
where T : class 类型参数<T>的继承链中不能有System.ValueType(即<T>必须是引用类型)。
where T : new() 类型参数<T>必须有默认的构造函数。如果您的泛型类型必须创建类型参数的实例,这将很有帮助,因为您无法假定自己知道自定义构造函数的格式。请注意,在多约束类型中,该约束必须列在最后。
where T : NameOfBaseClass 类型参数<T>必须从NameOfBaseClass指定的类中派生。
where T : NameOfInterface 类型参数<T>必须实现NameOfInterface指定的接口。您可以用逗号分隔的列表分隔多个接口。

除非您需要构建一些极其类型安全的自定义集合,否则您可能永远不需要在 C# 项目中使用where关键字。不管怎样,下面几个(部分)代码示例说明了如何使用where关键字。

使用 where 关键字的示例

首先假设您已经创建了一个自定义泛型类,并且希望确保类型参数有一个默认的构造函数。当自定义泛型类需要创建T的实例时,这可能是有用的,因为默认构造函数是所有类型可能共有的唯一构造函数。同样,以这种方式约束T可以让您获得编译时检查;如果T是一个引用类型,程序员记得在类定义中重定义默认构造函数(您可能记得当您定义自己的构造函数时,默认构造函数在类中被移除了)。

// MyGenericClass derives from object, while
// contained items must have a default ctor.
public class MyGenericClass<T> where T : new()
{
  ...
}

注意,where子句指定了哪个类型参数被约束,后面跟着一个冒号操作符。在冒号操作符之后,您列出了每个可能的约束(在本例中,是一个默认的构造函数)。这是另一个例子:

// MyGenericClass derives from object, while
// contained items must be a class implementing IDrawable
// and must support a default ctor.
public class MyGenericClass<T> where T : class, IDrawable, new()
{
  ...
}

在这种情况下,T有三个要求。它必须是引用类型(不是结构),用class标记。第二,T必须实现IDrawable接口。第三,它还必须有一个默认的构造函数。多个约束列在逗号分隔的列表中;然而,你应该知道new()约束必须总是列在最后!因此,下面的代码不会编译:

// Error! new() constraint must be listed last!
public class MyGenericClass<T> where T : new(), class, IDrawable
{
  ...
}

如果您曾经创建了一个指定多个类型参数的定制泛型集合类,那么您可以使用单独的where子句为每个类型参数指定一组唯一的约束。

// <K> must extend SomeBaseClass and have a default ctor,
// while <T> must be a structure and implement the
// generic IComparable interface.
public class MyGenericClass<K, T> where K : SomeBaseClass, new()
  where T : struct, IComparable<T>
{
  ...
}

您很少会遇到需要构建完整的自定义泛型集合类的情况;然而,您也可以在泛型方法上使用where关键字。例如,如果您想要指定您的泛型Swap<T>()方法只能在结构上操作,您将像这样更新该方法:

// This method will swap any structure, but not classes.
static void Swap<T>(ref T a, ref T b) where T : struct
{
  ...
}

注意,如果您以这种方式约束Swap()方法,您将不再能够交换string对象(如示例代码所示),因为string是一个引用类型。

缺乏对运算符的约束

在本章结束时,我想对泛型方法和约束再做一点评论。您可能会惊讶地发现,在创建泛型方法时,如果应用任何 C# 操作符(+-*==等),都会出现编译器错误。)上的类型参数。例如,想象一下一个可以对泛型类型进行加、减、乘、除的类有多有用。

// Compiler error! Cannot apply
// operators to type parameters!
public class BasicMath<T>
{
  public T Add(T arg1, T arg2)
  { return arg1 + arg2; }
  public T Subtract(T arg1, T arg2)
  { return arg1 - arg2; }
  public T Multiply(T arg1, T arg2)
  { return arg1 * arg2; }
  public T Divide(T arg1, T arg2)
  { return arg1 / arg2; }
}

不幸的是,前面的BasicMath类无法编译。虽然这看起来是一个主要的限制,但是你需要记住泛型是通用的。当然,数值数据可以使用 C# 的二元运算符。然而,为了便于讨论,如果<T>是一个定制类或结构类型,编译器可以假设该类支持+-*/操作符。理想情况下,C# 允许泛型类型受支持的运算符约束,如下例所示:

// Illustrative code only!
public class BasicMath<T> where T : operator +, operator -,
  operator *, operator /
{
  public T Add(T arg1, T arg2)
  { return arg1 + arg2; }
  public T Subtract(T arg1, T arg2)
  { return arg1 - arg2; }
  public T Multiply(T arg1, T arg2)
  { return arg1 * arg2; }
  public T Divide(T arg1, T arg2)
  { return arg1 / arg2; }
}

唉,当前版本的 C# 不支持运算符约束。然而,通过定义一个支持这些操作符的接口(C# 接口可以定义操作符!)来达到预期的效果是可能的(尽管这需要更多的工作)!)然后指定泛型类的接口约束。无论如何,这总结了本书对构建定制泛型类型的初步看法。在第十二章中,我将在研究委托类型时再次提起泛型的话题。

摘要

本章从检查System.CollectionsSystem.Collections.Specialized的非泛型集合类型开始,包括与许多非泛型容器相关的各种问题,包括缺乏类型安全性以及装箱和取消装箱操作的运行时开销。如上所述,由于这些原因,现代。NET 程序通常会利用System.Collections.GenericSystem.Collections.ObjectModel中的通用集合类。

正如您所看到的,泛型项允许您指定占位符(类型参数),这些占位符是您在对象创建(或调用,在泛型方法的情况下)时指定的。虽然您通常会简单地使用。NET 基础类库,您也将能够创建自己的泛型类型(和泛型方法)。当您这样做时,您可以选择指定任意数量的约束(使用where关键字)来提高类型安全级别,并确保您对保证展示某些基本功能的已知数量的类型执行操作。

最后要注意的是,记住泛型出现在。NET 基础类库。在这里,您特别关注了泛型集合。然而,当您阅读本书的剩余部分时(当您按照自己的方式深入平台时),您肯定会发现泛型类、结构和委托位于给定的名称空间中。同样,要注意非泛型类的泛型成员!**

十一、高级 C# 语言功能

在本章中,您将通过研究几个更高级的主题来加深对 C# 编程语言的理解。首先,您将学习如何实现和使用一个索引器方法。这种 C# 机制使您能够构建自定义类型,这些自定义类型使用类似数组的语法提供对内部子项的访问。在您学习了如何构建索引器方法之后,您将看到如何重载各种操作符(+-<>等)。)以及如何为您的类型创建自定义的显式和隐式转换例程(您将了解为什么您可能想要这样做)。

接下来,您将研究在使用以 LINQ 为中心的 API 时特别有用的主题(尽管您可以在 LINQ 的上下文之外使用它们),特别是扩展方法和匿名类型。

最后,您将学习如何创建一个“不安全”的代码上下文来直接操作非托管指针。虽然在 C# 应用中使用指针确实是一种不常见的活动,但是在一些涉及复杂互操作性的情况下,理解如何使用指针会很有帮助。

了解索引器方法

作为一名程序员,您肯定很熟悉使用索引操作符([])访问一个简单数组中包含的各个项的过程。这里有一个例子:

// Loop over incoming command-line arguments
// using index operator.
for(int i = 0; i < args.Length; i++)
{
  Console.WriteLine("Args: {0}", args[i]);
}

// Declare an array of local integers.
int[] myInts = { 10, 9, 100, 432, 9874};

// Use the index operator to access each element.
for(int j = 0; j < myInts.Length; j++)
{
  Console.WriteLine("Index {0}  = {1} ", j,  myInts[j]);
}
Console.ReadLine();

这个代码绝不是一个主要的新闻快讯。然而,通过定义一个索引器方法,C# 语言提供了设计定制类和结构的能力,这些类和结构可以像标准数组一样被索引。当您创建自定义集合类(泛型或非泛型)时,此功能最有用。

在研究如何实现自定义索引器之前,让我们先来看一个实例。假设您已经在第十章中开发的自定义PersonCollection类型中添加了对索引器方法的支持(具体来说,就是 issueswingnongenericcollections 项目)。虽然您尚未添加索引器,但请在名为 SimpleIndexer 的新控制台应用项目中观察以下用法:

using System;
using System.Collections.Generic;
using System.Data;
using SimpleIndexer;

// Indexers allow you to access items in an array-like fashion.
Console.WriteLine("***** Fun with Indexers *****\n");

PersonCollection myPeople = new PersonCollection();

// Add objects with indexer syntax.
myPeople[0] = new Person("Homer", "Simpson", 40);
myPeople[1] = new Person("Marge", "Simpson", 38);
myPeople[2] = new Person("Lisa", "Simpson", 9);
myPeople[3] = new Person("Bart", "Simpson", 7);
myPeople[4] = new Person("Maggie", "Simpson", 2);

// Now obtain and display each item using indexer.
for (int i = 0; i < myPeople.Count; i++)
{
  Console.WriteLine("Person number: {0}", i);
  Console.WriteLine("Name: {0} {1}",
    myPeople[i].FirstName, myPeople[i].LastName);
  Console.WriteLine("Age: {0}", myPeople[i].Age);
  Console.WriteLine();
}

如您所见,索引器允许您像操作标准数组一样操作子对象的内部集合。现在来看一个大问题:如何配置PersonCollection类(或任何定制类或结构)来支持这个功能?索引器表示为稍加修改的 C# 属性定义。最简单的形式是使用this[]语法创建一个索引器。下面是PersonCollection类所需的更新:

using System.Collections;

namespace SimpleIndexer
{
  // Add the indexer to the existing class definition.
  public class PersonCollection : IEnumerable
  {
    private ArrayList arPeople = new ArrayList();
...
    // Custom indexer for this class.
    public Person this[int index]
    {
      get => (Person)arPeople[index];
      set => arPeople.Insert(index, value);
    }
  }
}

除了使用带括号的关键字this之外,索引器看起来就像任何其他 C# 属性声明一样。例如,get作用域的作用是将正确的对象返回给调用者。这里,您通过将请求委托给ArrayList对象的索引器来实现,因为这个类也支持索引器。set范围监督添加新的Person对象;这是通过调用ArrayListInsert()方法实现的。

索引器是另一种形式的语法糖,因为这种功能也可以使用“普通的”公共方法来实现,比如AddPerson()GetPerson()。然而,当您在自定义集合类型上支持索引器方法时,它们可以很好地集成到。NET 核心基本类库。

虽然创建索引器方法在构建自定义集合时很常见,但请记住泛型类型为您提供了开箱即用的功能。考虑下面的方法,它使用了一个通用的Person对象的List<T>。注意,你可以简单地直接使用List<T>的索引器。这里有一个例子:

using System.Collections.Generic;
static void UseGenericListOfPeople()
{
  List<Person> myPeople = new List<Person>();
  myPeople.Add(new Person("Lisa", "Simpson", 9));
  myPeople.Add(new Person("Bart", "Simpson", 7));

  // Change first person with indexer.
  myPeople[0] = new Person("Maggie", "Simpson", 2);

  // Now obtain and display each item using indexer.
  for (int i = 0; i < myPeople.Count; i++)
  {
    Console.WriteLine("Person number: {0}", i);
    Console.WriteLine("Name: {0} {1}", myPeople[i].FirstName, myPeople[i].LastName);
    Console.WriteLine("Age: {0}", myPeople[i].Age);
    Console.WriteLine();
  }
}

使用字符串值索引数据

当前的PersonCollection类定义了一个索引器,该索引器允许调用者使用数值来标识子项。但是,请理解,这不是索引器方法的要求。假设您更喜欢使用System.Collections.Generic.Dictionary<TKey, TValue>而不是ArrayList来包含Person对象。假设Dictionary类型允许使用一个键(比如一个人的名字)访问包含的类型,您可以如下定义一个索引器:

using System.Collections;
using System.Collections.Generic;
namespace SimpleIndexer
{
  public class PersonCollectionStringIndexer : IEnumerable
  {
    private Dictionary<string, Person> listPeople = new Dictionary<string, Person>();

    // This indexer returns a person based on a string index.
    public Person this[string name]
    {
      get => (Person)listPeople[name];
      set => listPeople[name] = value;
    }
    public void ClearPeople()
    {
      listPeople.Clear();
    }

    public int Count => listPeople.Count;

    IEnumerator IEnumerable.GetEnumerator() => listPeople.GetEnumerator();
  }
}

调用者现在能够与包含的Person对象交互,如下所示:

Console.WriteLine("***** Fun with Indexers *****\n");

PersonCollectionStringIndexer myPeopleStrings =
  new PersonCollectionStringIndexer();

myPeopleStrings["Homer"] =
  new Person("Homer", "Simpson", 40);
myPeopleStrings["Marge"] =
  new Person("Marge", "Simpson", 38);

// Get "Homer" and print data.
Person homer = myPeopleStrings["Homer"];
Console.ReadLine();

同样,如果您直接使用泛型Dictionary<TKey, TValue>类型,您将获得现成的索引器方法功能,而无需构建一个支持字符串索引器的自定义、非泛型类。尽管如此,请理解任何索引器的数据类型都将基于支持的集合类型如何允许调用方检索子项。

重载索引器方法

索引器方法可以在单个类或结构上重载。因此,如果允许调用者使用数字索引或字符串值访问子项是有意义的,那么可以为一个类型定义多个索引器。举例来说,在 ADO.NET(。NET 的本地数据库访问 API),DataSet类支持一个名为Tables的属性,它返回给你一个强类型的DataTableCollection类型。事实证明,DataTableCollection定义了三个索引器来获取和设置DataTable对象——一个通过序号位置,另一个通过友好的字符串名字对象和可选的包含名称空间,如下所示:

public sealed class DataTableCollection : InternalDataCollectionBase
{
...
  // Overloaded indexers!
  public DataTable this[int index] { get; }
  public DataTable this[string name] { get; }
  public DataTable this[string name, string tableNamespace] { get; }
}

基类库中的类型支持索引器方法是很常见的。所以请注意,即使您当前的项目不要求您为您的类和结构构建自定义索引器,许多类型已经支持这种语法。

多维索引器

您还可以创建一个接受多个参数的索引器方法。假设您有一个在 2D 数组中存储子项的自定义集合。如果是这种情况,您可以按如下方式定义索引器方法:

public class SomeContainer
{
  private int[,] my2DintArray = new int[10, 10];

  public int this[int row, int column]
  {  /* get or set value from 2D array */  }
}

同样,除非您正在构建一个高度风格化的自定义集合类,否则您不太需要构建一个多维索引器。尽管如此,ADO.NET 再次展示了这种构造是多么有用。ADO.NETDataTable本质上是行和列的集合,很像一张绘图纸或 Microsoft Excel 电子表格的一般结构。

虽然通常使用相关的“数据适配器”代表您填充DataTable对象,但是下面的代码演示了如何手动创建包含三列(每个记录的名字、姓氏和年龄)的内存中的DataTable。请注意,一旦您向DataTable添加了一行,您如何使用多维索引器来钻取第一行(也是唯一一行)的每一列。(如果您正在跟进,您需要将System.Data名称空间导入到您的代码文件中。)

static void MultiIndexerWithDataTable()
{
  // Make a simple DataTable with 3 columns.
  DataTable myTable = new DataTable();
  myTable.Columns.Add(new DataColumn("FirstName"));
  myTable.Columns.Add(new DataColumn("LastName"));
  myTable.Columns.Add(new DataColumn("Age"));

  // Now add a row to the table.
  myTable.Rows.Add("Mel", "Appleby", 60);

  // Use multidimension indexer to get details of first row.
  Console.WriteLine("First Name: {0}", myTable.Rows[0][0]);
  Console.WriteLine("Last Name: {0}", myTable.Rows[0][1]);
  Console.WriteLine("Age : {0}", myTable.Rows[0][2]);
}

请注意,您将从第二十一章开始深入研究 ADO.NET,所以如果前面的一些代码看起来不熟悉,也不用担心。此示例的要点是索引器方法可以支持多维度,如果使用正确,可以简化您与自定义集合中包含的子对象的交互方式。

接口类型的索引器定义

索引器可以在给定的。NET 核心接口类型,以允许支持类型提供自定义实现。下面是一个简单的接口示例,它定义了使用数字索引器获取字符串对象的协议:

public interface IStringContainer
{
  string this[int index] { get; set; }
}

使用此接口定义,任何实现此接口的类或结构现在都必须支持读写索引器,该索引器使用数值来操作子项。下面是此类的部分实现:

class SomeClass : IStringContainer
{
  private List<string> myStrings = new List<string>();

  public string this[int index]
  {
    get => myStrings[index];
    set => myStrings.Insert(index, value);
  }
}

这就结束了本章的第一个主要话题。现在让我们来研究一个语言特性,它允许您构建定制的类或结构,这些类或结构对 C# 的内部运算符做出独特的响应。接下来,请允许我介绍一下运算符重载的概念。

理解运算符重载

像任何编程语言一样,C# 有一组固定的标记,用于对内部类型执行基本操作。例如,您知道可以将+运算符应用于两个整数,以产生一个更大的整数。

// The + operator with ints.
int a = 100;
int b = 240;
int c = a + b; // c is now 340

再说一次,这不是什么大新闻,但是你有没有停下来注意过同一个+操作符是如何应用于大多数 C# 数据类型的?例如,考虑以下代码:

// + operator with strings.
string s1 = "Hello";
string s2 = " world!";
string s3 = s1 + s2;  // s3 is now "Hello World!"

+操作符基于所提供的数据类型(本例中是字符串或整数)以特定的方式运行。当+运算符应用于数值类型时,结果是操作数的总和。然而,当+操作符应用于字符串类型时,结果是字符串连接。

C# 语言为您提供了构建定制类和结构的能力,这些定制类和结构也可以唯一地响应同一组基本标记(如+操作符)。虽然不是每个可能的 C# 操作符都可以重载,但是很多都可以,如表 11-1 所示。

表 11-1。

c# 运算符的可重载性

|

C# 运算符

|

过载能力

+-!~++--truefalse 这些一元运算符可以重载。C# 要求如果 true 或 false 被重载,两者都必须被重载。
+-*/%&&#124;^<<>> 这些二元运算符可以重载。
==!=<><=>= 这些比较运算符可以重载。C# 要求“like”操作符(即<><=>===!=)一起重载。
[] []运算符不能重载。然而,正如您在本章前面所看到的,索引器构造提供了相同的功能。
() ()运算符不能重载。然而,正如您将在本章后面看到的,自定义转换方法提供了相同的功能。
+=-=*=/=%=&=&#124;=^=<<=>>= 速记赋值运算符不能重载;然而,当你重载相关的二元操作符时,你可以免费得到它们。

重载二元运算符

为了说明重载二元运算符的过程,假设在一个名为 OverloadedOps 的新控制台应用项目中定义了以下简单的Point类:

using System;
namespace OverloadedOps
{
  // Just a simple, everyday C# class.
  public class Point
  {
    public int X {get; set;}
    public int Y {get; set;}

    public Point(int xPos, int yPos)
    {
      X = xPos;
      Y = yPos;
    }
    public override string ToString()
      => $"[{this.X}, {this.Y}]";
  }
}

现在,从逻辑上讲,把Point s“加”在一起是有意义的。例如,如果你将两个Point变量加在一起,你应该得到一个新的Point,它是XY值的总和。当然,从另一个中减去一个Point也是有帮助的。理想情况下,您希望能够编写以下代码:

using System;
using OverloadedOps;

// Adding and subtracting two points?
Console.WriteLine("***** Fun with Overloaded Operators *****\n");

// Make two points.
Point ptOne = new Point(100, 100);
Point ptTwo = new Point(40, 40);
Console.WriteLine("ptOne = {0}", ptOne);
Console.WriteLine("ptTwo = {0}", ptTwo);
// Add the points to make a bigger point?
Console.WriteLine("ptOne + ptTwo: {0} ", ptOne + ptTwo);

// Subtract the points to make a smaller point?
  Console.WriteLine("ptOne - ptTwo: {0} ", ptOne - ptTwo);
  Console.ReadLine();

然而,就像现在的Point一样,您将会收到编译时错误,因为Point类型不知道如何响应+-操作符。为了使自定义类型能够唯一地响应内部运算符,C# 提供了operator关键字,您只能将它与static关键字结合使用。当您重载一个二元操作符(比如+-)时,您通常会传入两个与定义类类型相同的参数(本例中为Point),如下面的代码更新所示:

// A more intelligent Point type.
public class Point
{
...
  // Overloaded operator +.
  public static Point operator + (Point p1, Point p2)
    => new Point(p1.X + p2.X, p1.Y + p2.Y);

  // Overloaded operator -.
  public static Point operator - (Point p1, Point p2)
    => new Point(p1.X - p2.X, p1.Y - p2.Y);
}

操作符+背后的逻辑只是基于传入的Point参数的字段总和返回一个新的Point对象。因此,当您编写pt1 + pt2时,您可以想象下面对静态操作符+方法的隐藏调用:

// Pseudo-code: Point p3 = Point.operator+ (p1, p2)
Point p3 = p1 + p2;

同样,p1p2映射到以下内容:

// Pseudo-code: Point p4 = Point.operator- (p1, p2)
Point p4 = p1 - p2;

有了这个更新,您的程序现在可以编译了,并且您发现您可以添加和减去Point对象,如下面的输出所示:

***** Fun with Overloaded Operators *****
ptOne = [100, 100]
ptTwo = [40, 40]
ptOne + ptTwo: [140, 140]
ptOne - ptTwo: [60, 60]

当重载二元运算符时,不需要传入两个相同类型的参数。如果这样做有意义,其中一个论点可以不同。例如,这里有一个重载操作符+,它允许调用者获得一个基于数字调整的新的Point:

public class Point
{
...
  public static Point operator + (Point p1, int change)
    => new Point(p1.X + change, p1.Y + change);

  public static Point operator + (int change, Point p1)
    => new Point(p1.X + change, p1.Y + change);
}

请注意,如果您希望参数以任意顺序传递,您需要方法的两个版本(即,您不能只定义其中一个方法,并期望编译器自动支持另一个)。您现在可以使用这些新版本的运算符+,如下所示:

// Prints [110, 110].
Point biggerPoint = ptOne + 10;
Console.WriteLine("ptOne + 10 = {0}", biggerPoint);

// Prints [120, 120].
Console.WriteLine("10 + biggerPoint = {0}", 10 + biggerPoint);
Console.WriteLine();

+=和–=运算符是什么?

如果你是从 C++背景进入 C# 的,你可能会感叹重载速记赋值操作符(+=-=等)的损失。).不要绝望。就 C# 而言,如果一个类型重载了相关的二元运算符,那么会自动模拟速记赋值运算符。因此,假设Point结构已经重载了+-操作符,您可以编写如下代码:

// Overloading binary operators results in a freebie shorthand operator.
...
// Freebie +=
Point ptThree = new Point(90, 5);
Console.WriteLine("ptThree = {0}", ptThree);
Console.WriteLine("ptThree += ptTwo: {0}", ptThree += ptTwo);

// Freebie -=
Point ptFour = new Point(0, 500);
Console.WriteLine("ptFour = {0}", ptFour);
Console.WriteLine("ptFour -= ptThree: {0}", ptFour -= ptThree);
Console.ReadLine();

重载一元运算符

C# 还允许你重载各种一元运算符,比如++--。当重载一元运算符时,还必须将static关键字与operator关键字一起使用;但是,在这种情况下,您只需传入一个与定义的类/结构类型相同的参数。例如,如果您要用以下重载操作符更新Point:

public class Point
{
...
  // Add 1 to the X/Y values for the incoming Point.
  public static Point operator ++(Point p1)
    => new Point(p1.X+1, p1.Y+1);

  // Subtract 1 from the X/Y values for the incoming Point.
  public static Point operator --(Point p1)
    => new Point(p1.X-1, p1.Y-1);
}

您可以像这样递增和递减Pointxy值:

...
// Applying the ++ and -- unary operators to a Point.
Point ptFive = new Point(1, 1);
Console.WriteLine("++ptFive = {0}", ++ptFive);  // [2, 2]
Console.WriteLine("--ptFive = {0}", --ptFive);  // [1, 1]

// Apply same operators as postincrement/decrement.
Point ptSix = new Point(20, 20);
Console.WriteLine("ptSix++ = {0}", ptSix++);    // [20, 20]
Console.WriteLine("ptSix-- = {0}", ptSix--);    // [21, 21]
Console.ReadLine();

请注意,在前面的代码示例中,您以两种不同的方式应用了自定义的++--操作符。在 C++中,可以分别重载前后递增/递减运算符。这在 C# 中是不可能的。然而,递增/递减的返回值会被自动“正确”地免费处理(例如,对于重载的++操作符,pt++将未修改对象的值作为其在表达式中的值,而++pt在表达式中使用之前应用了新值)。

重载相等运算符

你可能还记得第六章,可以覆盖System.Object.Equals()来执行引用类型之间基于值的(而不是基于引用的)比较。如果您选择覆盖Equals()(以及通常相关的System.Object.GetHashCode()方法),重载等式操作符(==!=)是微不足道的。为了说明,下面是更新后的Point类型:

// This incarnation of Point also overloads the == and != operators.
public class Point
{
...
  public override bool Equals(object o)
    => o.ToString() == this.ToString();

  public override int GetHashCode()
    => this.ToString().GetHashCode();

  // Now let's overload the == and != operators.
  public static bool operator ==(Point p1, Point p2)
    => p1.Equals(p2);

  public static bool operator !=(Point p1, Point p2)
    => !p1.Equals(p2);
}

注意操作符==和操作符!=的实现是如何简单地调用被覆盖的Equals()方法来完成大部分工作的。考虑到这一点,您现在可以如下练习您的Point类:

// Make use of the overloaded equality operators.
...
Console.WriteLine("ptOne == ptTwo : {0}", ptOne == ptTwo);
Console.WriteLine("ptOne != ptTwo : {0}", ptOne != ptTwo);
Console.ReadLine();

正如您所看到的,使用众所周知的==!=操作符来比较两个对象是非常直观的,而不是调用Object.Equals()。如果你确实重载了给定类的等式操作符,记住 C# 要求如果你覆盖了==操作符,你必须也覆盖了!=操作符(如果你忘记了,编译器会告诉你)。

重载比较运算符

在第八章中,你学习了如何实现IComparable接口来比较两个相似对象之间的关系。事实上,您还可以为同一个类重载比较操作符(<><=>=)。和等式操作符一样,C# 要求如果你重载了<,你也必须重载>。这同样适用于<=>=操作符。如果Point类型重载了这些比较操作符,对象用户现在可以比较Point s,如下所示:

// Using the overloaded < and > operators.
...
Console.WriteLine("ptOne < ptTwo : {0}", ptOne < ptTwo);
Console.WriteLine("ptOne > ptTwo : {0}", ptOne > ptTwo);
Console.ReadLine();

假设您已经实现了IComparable接口(或者更好的是通用等效接口),重载比较操作符是微不足道的。下面是更新后的类定义:

// Point is also comparable using the comparison operators.
public class Point : IComparable<Point>
{
...
  public int CompareTo(Point other)
  {
    if (this.X > other.X && this.Y > other.Y)
    {
      return 1;
    }
    if (this.X < other.X && this.Y < other.Y)
    {
      return -1;
    }
    return 0;
  }
  public static bool operator <(Point p1, Point p2)
    => p1.CompareTo(p2) < 0;

  public static bool operator >(Point p1, Point p2)
    => p1.CompareTo(p2) > 0;

  public static bool operator <=(Point p1, Point p2)
    => p1.CompareTo(p2) <= 0;

  public static bool operator >=(Point p1, Point p2)
    => p1.CompareTo(p2) >= 0;
}

关于运算符重载的最终想法

正如您所看到的,C# 提供了构建类型的能力,这些类型可以唯一地响应各种固有的、众所周知的运算符。现在,在您修改您的所有类以支持这种行为之前,您必须确保您将要重载的操作符在世界范围内具有某种逻辑意义。

例如,假设您重载了MiniVan类的乘法运算符。将两个MiniVan对象相乘到底意味着什么?不多。事实上,如果队友看到MiniVan对象的以下用法,会感到困惑:

// Huh?! This is far from intuitive...
MiniVan newVan = myVan * yourVan;

重载操作符通常只在构建原子数据类型时有用。向量、矩阵、文本、点、形状、集合等。,是运算符重载的理想选择。人、管理人员、汽车、数据库连接和网页没有。根据经验,如果一个重载的操作符使用户理解一个类型的功能变得更加困难,那就不要这样做。明智地使用这个特性。

了解自定义类型转换

现在让我们研究一个与运算符重载密切相关的主题:自定义类型转换。为了给讨论做好准备,让我们快速回顾一下数字数据和相关类类型之间显式和隐式转换的概念。

回忆:数字转换

根据固有的数字类型(sbyteintfloat等)。),当您试图在较小的容器中存储较大的值时,需要一个显式转换,因为这可能会导致数据丢失。基本上,这是你告诉编译器,“别管我,我知道我在做什么。”相反,当您试图将一个较小的类型放入一个不会导致数据丢失的目标类型中时,一个隐式转换会自动发生。

int a = 123;
long b = a;       // Implicit conversion from int to long.
int c = (int) b;  // Explicit conversion from long to int.

回忆:相关类类型之间的转换

如第六章所示,类类型可能通过经典继承相关(“is-a”关系)。在这种情况下,C# 转换过程允许您上下转换类层次结构。例如,派生类总是可以隐式转换为基类型。但是,如果您想在派生变量中存储基类类型,则必须执行显式转换,如下所示:

// Two related class types.
class Base{}
class Derived : Base{}

// Implicit cast between derived to base.
Base myBaseType;
myBaseType = new Derived();
// Must explicitly cast to store base reference
// in derived type.
Derived myDerivedType = (Derived)myBaseType;

这种显式强制转换是可行的,因为BaseDerived类通过传统继承相关联,并且myBaseType被构造为Derived的一个实例。但是,如果myBaseTypeBase的一个实例,那么 cast 抛出一个InvalidCastException。如果对转换会失败有任何疑问,你应该使用as关键字,如第六章中所讨论的。下面是重新制作的示例来演示这一点:

// Implicit cast between derived to base.
Base myBaseType2 = new();
// Throws InvalidCastException
//Derived myDerivedType2 = (Derived)myBaseType2 as Derived;
//No exception, myDerivedType2 is null
Derived myDerivedType2 = myBaseType2 as Derived;

然而,如果在不同的层次结构中有两个类类型没有共同的父类(除了System.Object)需要转换,该怎么办呢?假设它们没有传统的继承关系,典型的造型操作不会提供任何帮助(而且你会得到一个编译错误!).

另一方面,考虑值类型(结构)。假设您有两个名为SquareRectangle的结构。鉴于结构不能利用经典继承(因为它们总是密封的),您没有自然的方法在这些看似相关的类型之间进行转换。

虽然您可以在结构中创建助手方法(如Rectangle.ToSquare()),但 C# 允许您构建自定义转换例程,允许您的类型响应()转换操作符。因此,如果您正确配置了结构,您将能够使用以下语法在它们之间进行显式转换,如下所示:

// Convert a Rectangle to a Square!
Rectangle rect = new Rectangle
{
  Width = 3;
  Height = 10;
}
Square sq = (Square)rect;

创建自定义转换例程

首先创建一个名为 CustomConversions 的新控制台应用项目。C# 提供了两个关键字,explicitimplicit,您可以使用它们来控制您的类型在尝试转换期间如何响应。假设您有以下结构定义:

using System;

namespace CustomConversions
{
  public struct Rectangle
  {
    public int Width {get; set;}
    public int Height {get; set;}

    public Rectangle(int w, int h)
    {
      Width = w;
      Height = h;
    }

    public void Draw()
    {
      for (int i = 0; i < Height; i++)
      {
        for (int j = 0; j < Width; j++)
        {
          Console.Write("*");
        }
        Console.WriteLine();
      }
    }

    public override string ToString()
      => $"[Width = {Width}; Height = {Height}]";
  }
}

using System;

namespace CustomConversions
{
  public struct Square
  {
    public int Length {get; set;}
    public Square(int l) : this()
    {
      Length = l;
    }

    public void Draw()
    {
      for (int i = 0; i < Length; i++)
      {
        for (int j = 0; j < Length; j++)
        {
          Console.Write("*");
        }
        Console.WriteLine();
      }
    }

    public override string ToString() => $"[Length = {Length}]";

    // Rectangles can be explicitly converted into Squares.
    public static explicit operator Square(Rectangle r)
    {
      Square s = new Square {Length = r.Height};
      return s;
    }
  }
}

注意,Square类型的这个迭代定义了一个显式转换操作符。像重载操作符的过程一样,转换例程使用 C# operator关键字,结合explicitimplicit关键字,并且必须被定义为static。传入的参数是你要从转换的实体,而操作符类型是你要从转换的实体。

在这种情况下,假设可以从矩形的高度获得正方形(所有边都等长的几何图案)。因此,您可以自由地将Rectangle转换成Square,如下所示:

using System;
using CustomConversions;

Console.WriteLine("***** Fun with Conversions *****\n");
// Make a Rectangle.
Rectangle r = new Rectangle(15, 4);
Console.WriteLine(r.ToString());
r.Draw();

Console.WriteLine();

// Convert r into a Square,
// based on the height of the Rectangle.
Square s = (Square)r;
Console.WriteLine(s.ToString());
s.Draw();
Console.ReadLine();

您可以在这里看到输出:

***** Fun with Conversions *****
[Width = 15; Height = 4]

***************
***************
***************
***************

[Length = 4]
****
****
****
****

虽然在同一个作用域内将一个Rectangle转换成一个Square可能没什么帮助,但是假设你有一个被设计成接受Square参数的函数。

// This method requires a Square type.
static void DrawSquare(Square sq)
{
  Console.WriteLine(sq.ToString());
  sq.Draw();
}

使用对Square类型的显式转换操作,您现在可以传入Rectangle类型以使用显式强制转换进行处理,如下所示:

...
// Convert Rectangle to Square to invoke method.
Rectangle rect = new Rectangle(10, 5);
DrawSquare((Square)rect);
Console.ReadLine();

Square 类型的其他显式转换

既然您已经可以显式地将Rectangle转换成Square了,那么让我们检查一些额外的显式转换。假设一个正方形在所有边上都是对称的,那么提供一个显式的转换例程,允许调用者从整数类型转换为Square(当然,它的边长等于传入的整数)可能会有所帮助。同样,如果您要更新Square以便调用者可以将中的Square转换为int会怎么样?下面是调用逻辑:

...
// Converting an int to a Square.
Square sq2 = (Square)90;
Console.WriteLine("sq2 = {0}", sq2);

// Converting a Square to an int.
int side = (int)sq2;
Console.WriteLine("Side length of sq2 = {0}", side);
Console.ReadLine();

下面是对Square类的更新:

public struct Square
{
...
  public static explicit operator Square(int sideLength)
  {
    Square newSq = new Square {Length = sideLength};
    return newSq;
  }

  public static explicit operator int (Square s) => s.Length;
}

老实说,将Square转换成整数可能不是最直观(或最有用)的操作(毕竟,您可以将这些值传递给构造函数)。然而,它确实指出了关于自定义转换例程的一个重要事实:如果您编写了语法正确的代码,编译器并不关心您转换成什么或转换成什么。

因此,就像重载操作符一样,仅仅因为你可以为一个给定的类型创建一个显式的强制转换操作,并不意味着你应该。通常,这种技术在创建结构类型时最有帮助,因为它们不能参与经典继承(强制转换是免费的)。

定义隐式转换例程

到目前为止,您已经创建了各种自定义的显式的转换操作。但是,下面的隐式转换呢?

...
Square s3 = new Square {Length = 83};

// Attempt to make an implicit cast?
Rectangle rect2 = s3;

Console.ReadLine();

假定您没有为Rectangle类型提供隐式转换例程,这段代码将不会编译。这里有一个问题:在同一类型上定义显式和隐式转换函数是非法的,如果它们的返回类型或参数集没有区别的话。这似乎是一种限制;然而,第二个问题是,当一个类型定义了一个隐式转换例程时,调用者使用显式转换语法是合法的!

迷茫?为了弄清楚,让我们使用 C# implicit关键字向Rectangle结构添加一个隐式转换例程(注意,下面的代码假设产生的Rectangle的宽度是通过将Square的边乘以 2 来计算的):

public struct Rectangle
{
...
  public static implicit operator Rectangle(Square s)
  {
    Rectangle r = new Rectangle
    {
      Height = s.Length,
      Width = s.Length * 2 // Assume the length of the new Rectangle with (Length x 2).
    };
    return r;
  }
}

通过此更新,您现在可以在类型之间进行转换,如下所示:

...
// Implicit cast OK!
Square s3 = new Square { Length= 7};

Rectangle rect2 = s3;
Console.WriteLine("rect2 = {0}", rect2);

// Explicit cast syntax still OK!
Square s4 = new Square {Length = 3};
Rectangle rect3 = (Rectangle)s4;

Console.WriteLine("rect3 = {0}", rect3);
Console.ReadLine();

这就结束了您对自定义转换例程的定义。与重载操作符一样,记住这一点语法只是“普通”成员函数的简写符号,从这个角度来看,它总是可选的。然而,当正确使用时,自定义结构可以更自然地使用,因为它们可以被视为通过继承相关的真正的类类型。

理解扩展方法

。NET 3.5 引入了扩展方法的概念,它允许你向一个类或结构添加新的方法或属性,而不用以任何直接的方式修改原始类型。那么,这在哪里会有帮助呢?考虑以下可能性。

首先,假设您有一个生产中的给定类。随着时间的推移,很明显这个类应该支持一些新成员。如果直接修改当前的类定义,就有可能破坏使用它的旧代码库的向后兼容性,因为它们可能没有用最新和最好的类定义编译。确保向后兼容的一种方法是从现有父类创建新的派生类;然而,现在您有两个类需要维护。众所周知,代码维护是软件工程师工作描述中最不光彩的部分。

现在考虑这种情况。假设您有一个结构(或者一个密封的类)并想添加新的成员,这样它在您的系统中的行为就多样化了。由于结构和密封类不能被扩展,你唯一的选择就是将成员添加到类型中,这又一次冒着破坏向后兼容性的风险!

使用扩展方法,您可以修改类型,而无需创建子类,也无需直接修改类型。问题是,只有在当前项目中引用了扩展方法时,才会向类型提供新功能。

定义扩展方法

当你定义扩展方法时,第一个限制是它们必须在静态类中定义(见第五章);因此,每个扩展方法都必须用关键字static声明。第二点是所有的扩展方法都是这样标记的,使用this关键字作为方法的第一个(也是唯一的一个)参数的修饰符。“this合格”参数代表被扩展的项目。

为了进行说明,创建一个名为 ExtensionMethods 的新控制台应用项目。现在,假设您正在创作一个名为MyExtensions的类,它定义了两个扩展方法。第一种方法允许任何一个object使用一个名为DisplayDefiningAssembly()的新方法,该方法利用System.Reflection名称空间中的类型来显示包含该类型的程序集的名称。

Note

你将在第十七章中正式检查反射 API。如果您不熟悉这个主题,只需理解反射允许您在运行时发现程序集、类型和类型成员的结构。

第二种扩展方法名为ReverseDigits(),允许任何int获得其自身的新版本,其中值被逐位反转。例如,如果一个值为 1234 的整数被称为ReverseDigits(),那么返回的整数被设置为值 4321。考虑下面的类实现(如果您继续学习,请确保导入System.Reflection名称空间):

using System;
using System.Reflection;

namespace MyExtensionMethods
{
  static class MyExtensions
  {
    // This method allows any object to display the assembly
    // it is defined in.
    public static void DisplayDefiningAssembly(this object obj)
    {
      Console.WriteLine("{0} lives here: => {1}\n",
        obj.GetType().Name,
        Assembly.GetAssembly(obj.GetType()).GetName().Name);
    }

    // This method allows any integer to reverse its digits.
    // For example, 56 would return 65.
    public static int ReverseDigits(this int i)
    {
      // Translate int into a string, and then
      // get all the characters.
      char[] digits = i.ToString().ToCharArray();

      // Now reverse items in the array.
      Array.Reverse(digits);

      // Put back into string.
      string newDigits = new string(digits);

      // Finally, return the modified string back as an int.
      return int.Parse(newDigits);
    }
  }
}

同样,在定义参数类型之前,注意每个扩展方法的第一个参数是如何用关键字this限定的。扩展方法的第一个参数总是表示被扩展的类型。鉴于DisplayDefiningAssembly()已经被原型化以扩展System.Object,每个类型现在都有了这个新成员,因为Object是。NET 核心平台。然而,ReverseDigits()已经被原型化,只扩展整数类型;因此,如果整数以外的任何东西试图调用此方法,您将收到一个编译时错误。

Note

理解一个给定的扩展方法可以有多个参数,但是只有的第一个参数可以用this限定。附加参数将被视为该方法使用的正常输入参数。

调用扩展方法

现在已经有了这些扩展方法,请考虑下面的代码示例,该示例将扩展方法应用于基类库中的各种类型:

using System;
using MyExtensionMethods;

Console.WriteLine("***** Fun with Extension Methods *****\n");

// The int has assumed a new identity!
int myInt = 12345678;
myInt.DisplayDefiningAssembly();

// So has the DataSet!
System.Data.DataSet d = new System.Data.DataSet();
d.DisplayDefiningAssembly();

// Use new integer functionality.
Console.WriteLine("Value of myInt: {0}", myInt);
Console.WriteLine("Reversed digits of myInt: {0}",
  myInt.ReverseDigits());

Console.ReadLine();

以下是输出:

***** Fun with Extension Methods *****
Int32 lives here: => System.Private.CoreLib

DataSet lives here: => System.Data.Common

Value of myInt: 12345678
Reversed digits of myInt: 87654321

导入扩展方法

当您定义一个包含扩展方法的类时,它无疑将被定义在一个名称空间中。如果这个名称空间不同于使用扩展方法的名称空间,您将需要使用预期的 C# using关键字。当您这样做时,您的代码文件可以访问被扩展类型的所有扩展方法。记住这一点很重要,因为如果不显式导入正确的命名空间,扩展方法就不能用于该 C# 代码文件。

实际上,尽管表面上看起来扩展方法本质上是全局的,但实际上它们仅限于定义它们的命名空间或导入它们的命名空间。回想一下,您将MyExtensions类包装到一个名为MyExtensionMethods的名称空间中,如下所示:

namespace MyExtensionMethods
{
  static class MyExtensions
  {
    ...
  }
}

要在类中使用扩展方法,您需要显式导入MyExtensionMethods名称空间,正如我们在用于练习示例的顶级语句中所做的那样。

扩展实现特定接口的类型

至此,您已经看到了如何通过扩展方法用新的功能来扩展类(并间接地扩展遵循相同语法的结构)。也可以定义一个只能扩展实现正确接口的类或结构的扩展方法。例如,你可以说“如果一个类或结构实现了IEnumerable<T>,那么该类型将获得下面的新成员。”当然,有可能要求一个类型支持任何接口,包括您自己的自定义接口。

为了进行说明,创建一个名为 InterfaceExtensions 的新控制台应用项目。这里的目标是向实现IEnumerable的任何类型添加一个新方法,这将包括任何数组和许多非泛型集合类(回想一下第十章中的泛型IEnumerable<T>接口扩展了非泛型IEnumerable接口)。将以下扩展类添加到新项目中:

using System;

namespace InterfaceExtensions
{
  static class AnnoyingExtensions
  {
    public static void PrintDataAndBeep(
      this System.Collections.IEnumerable iterator)
    {
      foreach (var item in iterator)
      {
        Console.WriteLine(item);
        Console.Beep();
      }
    }
  }
}

假设任何实现IEnumerable的类或结构都可以使用PrintDataAndBeep()方法,您可以通过下面的代码进行测试:

using System;
using System.Collections.Generic;
using InterfaceExtensions;

Console.WriteLine("***** Extending Interface Compatible Types *****\n");

// System.Array implements IEnumerable!
string[] data =
  { "Wow", "this", "is", "sort", "of", "annoying",
      "but", "in", "a", "weird", "way", "fun!"};
data.PrintDataAndBeep();

Console.WriteLine();

// List<T> implements IEnumerable!
List<int> myInts = new List<int>() {10, 15, 20};
myInts.PrintDataAndBeep();

Console.ReadLine();

这就结束了对 C# 扩展方法的研究。请记住,每当您希望扩展类型的功能,但不想创建子类(或者如果类型是密封的,则无法创建子类)时,这种语言特性就非常有用,因为这是为了实现多态性。正如您将在本文后面看到的,扩展方法对 LINQ API 起着关键作用。事实上,你会看到在 LINQ API 下,最常见的扩展项之一是一个类或结构实现(惊喜!)通用版的IEnumerable

扩展方法 GetEnumerator 支持(新 9.0)

在 C# 9.0 之前,要在一个类上使用foreach,必须直接在那个类上定义GetEnumerator()方法。在 C# 9.0 中,foreach方法将检查类的扩展方法,如果找到了GetEnumerator()方法,将使用该方法获取该类的IEnumerator。要看到这一点,添加一个名为 ForEachWithExtensionMethods 的新控制台应用,并添加第八章中的CarGarage类的简化版本。

//Car.cs
using System;

namespace ForEachWithExtensionMethods
{
  class Car
  {
    // Car properties.
    public int CurrentSpeed {get; set;} = 0;
    public string PetName {get; set;} = "";

    // Constructors.
    public Car() {}
    public Car(string name, int speed)
    {
      CurrentSpeed = speed;
      PetName = name;
    }

    // See if Car has overheated.
  }
}

//Garage.cs
namespace ForEachWithExtensionMethods
{
  class Garage
  {
    public Car[] CarsInGarage { get; set; }

    // Fill with some Car objects upon startup.
    public Garage()
    {
      CarsInGarage = new Car[4];
      CarsInGarage[0] = new Car("Rusty", 30);
      CarsInGarage[1] = new Car("Clunker", 55);
      CarsInGarage[2] = new Car("Zippy", 30);
      CarsInGarage[3] = new Car("Fred", 30);
    }

  }
}

注意,Garage类没有实现IEnumerable,也没有GetEnumerator()方法。通过GarageExtensions类添加了GetEnumerator()方法,如下所示:

using System.Collections;

namespace ForEachWithExtensionMethods
{
  static class GarageExtensions
  {
    public static IEnumerator GetEnumerator(this Garage g)
        => g.CarsInGarage.GetEnumerator();
  }
}

测试这个新特性的代码与测试第八章中的GetEnumerator()方法的代码相同。将Program.cs更新如下:

using System;
using ForEachWithExtensionMethods;

Console.WriteLine("***** Support for Extension Method GetEnumerator *****\n");
Garage carLot = new Garage();

// Hand over each car in the collection?
foreach (Car c in carLot)
{
    Console.WriteLine("{0} is going {1} MPH",
        c.PetName, c.CurrentSpeed);
}

您将看到代码起作用了,将汽车及其速度的列表打印到控制台上。

***** Support for Extension Method GetEnumerator *****

Rusty is going 30 MPH
Clunker is going 55 MPH
Zippy is going 30 MPH
Fred is going 30 MPH

Note

这个新特性有一个潜在的缺点,那就是从来没有打算foreach ed 的类现在可以foreach ed 了。

了解匿名类型

作为一个面向对象的程序员,您知道定义类来表示您试图建模的给定项目的状态和功能的好处。可以肯定的是,每当您需要定义一个要在项目中重用的类,并且这个类通过一组方法、事件、属性和自定义构造函数提供大量的功能时,创建一个新的 C# 类是常见的做法。

然而,有些时候,您希望定义一个类,只是为了对一组封装的(或以某种方式相关的)数据点进行建模,而没有任何相关的方法、事件或其他专门的功能。此外,如果程序中只有少数方法使用这种类型,那该怎么办呢?当你很清楚这个类只在少数地方使用时,定义一个完整的类定义会很麻烦,如下所示。为了强调这一点,下面是当您需要创建一个遵循典型的基于值的语义的“简单”数据类型时,您可能需要做的事情的大致轮廓:

class SomeClass
{
  // Define a set of private member variables...

  // Make a property for each member variable...

  // Override ToString() to account for key member variables...

  // Override GetHashCode() and Equals() to work with value-based equality...
}

如你所见,事情不一定这么简单。您不仅需要编写大量的代码,还需要在系统中维护另一个类。对于像这样的临时数据,快速创建一个自定义数据类型会很有用。例如,假设您需要构建一个接收一组传入参数的自定义方法。您希望获取这些参数,并使用它们来创建一个新的数据类型,以便在此方法范围内使用。此外,您可能希望使用典型的ToString()方法快速打印出这些数据,并且可能使用System.Object的其他成员。您可以使用匿名类型语法来做这件事。

定义匿名类型

当你定义一个匿名类型时,你可以通过使用var关键字(参见第三章)和对象初始化语法(参见第五章)来实现。您必须使用var关键字,因为编译器会在编译时自动生成一个新的类定义(并且您永远不会在 C# 代码中看到这个类的名称)。初始化语法用于告诉编译器为新创建的类型创建私有支持字段和(只读)属性。

举例来说,创建一个名为 AnonymousTypes 的新控制台应用项目。现在,使用传入的参数数据,将下面的方法添加到您的Program类中,动态地组成一个新的类型:

static void BuildAnonymousType( string make, string color, int currSp )
{
  // Build anonymous type using incoming args.
  var car = new { Make = make, Color = color, Speed = currSp };

  // Note you can now use this type to get the property data!
   Console.WriteLine("You have a {0} {1} going {2} MPH", car.Color, car.Make, car.Speed);

  // Anonymous types have custom implementations of each virtual
  // method of System.Object. For example:
  Console.WriteLine("ToString() == {0}", car.ToString());
}

请注意,除了将代码包装在函数中之外,还可以以内联方式创建匿名类型,如下所示:

Console.WriteLine("***** Fun with Anonymous Types *****\n");

// Make an anonymous type representing a car.
var myCar = new { Color = "Bright Pink", Make = "Saab", CurrentSpeed = 55 };

// Now show the color and make.
Console.WriteLine("My car is a {0} {1}.", myCar.Color, myCar.Make);

// Now call our helper method to build anonymous type via args.
BuildAnonymousType("BMW", "Black", 90);

Console.ReadLine();

因此,在这一点上,简单地理解一下,匿名类型允许您以很少的开销快速地对数据的“形状”建模。这种技术只不过是一种快速创建新数据类型的方法,它通过属性支持基本的封装,并根据基于值的语义进行操作。为了理解最后一点,让我们看看 C# 编译器如何在编译时构建匿名类型,具体来说,它如何覆盖System.Object的成员。

匿名类型的内部表示

所有匿名类型都是从System.Object自动派生的,因此支持这个基类提供的每个成员。鉴于此,您可以对隐式类型化的myCar对象调用ToString()GetHashCode()Equals()GetType()。假设您的Program类定义了以下静态助手函数:

static void ReflectOverAnonymousType(object obj)
{
  Console.WriteLine("obj is an instance of: {0}",
    obj.GetType().Name);
  Console.WriteLine("Base class of {0} is {1}",
    obj.GetType().Name, obj.GetType().BaseType);
  Console.WriteLine("obj.ToString() == {0}", obj.ToString());
  Console.WriteLine("obj.GetHashCode() == {0}",
    obj.GetHashCode());
  Console.WriteLine();
}

现在假设您调用这个方法,将myCar对象作为参数传入,如下所示:

Console.WriteLine("***** Fun with Anonymous Types *****\n");

// Make an anonymous type representing a car.
var myCar = new {Color = "Bright Pink", Make = "Saab",
  CurrentSpeed = 55};

// Reflect over what the compiler generated.
ReflectOverAnonymousType(myCar);
...

Console.ReadLine();

输出将如下所示:

***** Fun with Anonymous Types *****
obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() = { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() = -564053045

首先,注意,在这个例子中,myCar对象的类型是<>f__AnonymousType03`(您的名字可能不同)。请记住,分配的类型名称完全由编译器决定,并且不能在 C# 代码库中直接访问。

也许最重要的是,注意使用对象初始化语法定义的每个名称-值对都被映射到一个同名的只读属性和一个相应的私有的仅支持init的字段。下面的 C# 代码近似于编译器生成的用于表示myCar对象的类(同样可以使用ildasm.exe进行验证):

private sealed class <>f__AnonymousType0'3'<'<Color>j__TPar',
  '<Make>j__TPar', <CurrentSpeed>j__TPar>'
  extends [System.Runtime][System.Object]
{
  // init-only fields.
  private initonly <Color>j__TPar <Color>i__Field;
  private initonly <CurrentSpeed>j__TPar <CurrentSpeed>i__Field;
  private initonly <Make>j__TPar <Make>i__Field;

  // Default constructor.
  public <>f__AnonymousType0(<Color>j__TPar Color,
    <Make>j__TPar Make, <CurrentSpeed>j__TPar CurrentSpeed);
  // Overridden methods.
  public override bool Equals(object value);
  public override int GetHashCode();
  public override string ToString();

  // Read-only properties.
  <Color>j__TPar Color { get; }
  <CurrentSpeed>j__TPar CurrentSpeed { get; }
  <Make>j__TPar Make { get; }
}

ToString()和 GetHashCode()的实现

所有匿名类型都自动从System.Object派生而来,并提供有Equals()GetHashCode()ToString()的覆盖版本。ToString()实现简单地从每个名称-值对构建一个字符串。这里有一个例子:

public override string ToString()
{
  StringBuilder builder = new StringBuilder();
  builder.Append("{ Color = ");
  builder.Append(this.<Color>i__Field);
  builder.Append(", Make = ");
  builder.Append(this.<Make>i__Field);
  builder.Append(", CurrentSpeed = ");
  builder.Append(this.<CurrentSpeed>i__Field);
  builder.Append(" }");
  return builder.ToString();
}

GetHashCode()实现使用每个匿名类型的成员变量作为System.Collections.Generic.EqualityComparer<T>类型的输入来计算哈希值。使用GetHashCode()的这种实现,当(且仅当)两个匿名类型具有相同的属性集并被赋予相同的值时,它们将产生相同的哈希值。给定这个实现,匿名类型非常适合包含在一个Hashtable容器中。

匿名类型相等的语义

虽然被覆盖的ToString()GetHashCode()方法的实现很简单,但是您可能想知道Equals()方法是如何实现的。例如,如果您要定义两个指定相同名称-值对的“匿名汽车”变量,这两个变量会被认为是相等的吗?为了直接看到结果,用下面的新方法更新您的Program类型:

static void EqualityTest()
{
  // Make 2 anonymous classes with identical name/value pairs.
  var firstCar = new { Color = "Bright Pink", Make = "Saab",
    CurrentSpeed = 55 };
  var secondCar = new { Color = "Bright Pink", Make = "Saab",
    CurrentSpeed = 55 };

  // Are they considered equal when using Equals()?
  if (firstCar.Equals(secondCar))
  {
    Console.WriteLine("Same anonymous object!");
  }
  else
  {
    Console.WriteLine("Not the same anonymous object!");
  }

  // Are they considered equal when using ==?
  if (firstCar == secondCar)
  {
    Console.WriteLine("Same anonymous object!");
  }
  else
  {
    Console.WriteLine("Not the same anonymous object!");
  }

  // Are these objects the same underlying type?
  if (firstCar.GetType().Name == secondCar.GetType().Name)
  {
    Console.WriteLine("We are both the same type!");
  }
  else
  {
    Console.WriteLine("We are different types!");
  }

  // Show all the details.
  Console.WriteLine();
  ReflectOverAnonymousType(firstCar);
  ReflectOverAnonymousType(secondCar);
}

当您调用此方法时,输出可能有些令人惊讶。

My car is a Bright Pink Saab.
You have a Black BMW going 90 MPH
ToString() == { Make = BMW, Color = Black, Speed = 90 }

Same anonymous object!
Not the same anonymous object!
We are both the same type!

obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() == { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() == -925496951

obj is an instance of: <>f__AnonymousType0`3
Base class of <>f__AnonymousType0`3 is System.Object
obj.ToString() == { Color = Bright Pink, Make = Saab, CurrentSpeed = 55 }
obj.GetHashCode() == -925496951

当您运行这个测试代码时,您将看到您调用Equals()的第一个条件测试返回了true,因此,消息“相同的匿名对象!”打印到屏幕上。这是因为编译器生成的Equals()方法在测试相等性时使用基于值的语义(例如,检查被比较对象的每个字段的值)。

然而,第二个条件测试使用了 C# 的等式操作符(==),打印出“不是同一个匿名对象!”乍一看,这似乎有点违反直觉。这个结果是因为匿名类型没有接收 C# 等式操作符的重载版本(==!=)。鉴于此,当您使用 C# 相等操作符(而不是Equals()方法)测试匿名类型的相等性时,测试的是引用,而不是对象维护的值。

最后,在最后的条件测试中(检查底层类型名),您会发现匿名类型是同一个编译器生成的类类型的实例(在本例中为<>f AnonymousType03),因为firstCarsecondCar具有相同的属性(ColorMakeCurrentSpeed`)。

这说明了重要但微妙的一点:只有当匿名类型包含匿名类型的唯一名称时,编译器才会生成新的类定义。因此,如果在同一个程序集中声明相同的匿名类型(也就是相同的名称),编译器只会生成一个匿名类型定义。

包含匿名类型的匿名类型

可以创建一个由其他匿名类型组成的匿名类型。例如,假设您想要对一个由时间戳、价格点和购买的汽车组成的采购订单进行建模。下面是一个新的(稍微复杂一点)匿名类型,表示这样一个实体:

// Make an anonymous type that is composed of another.
var purchaseItem = new {
  TimeBought = DateTime.Now,
  ItemBought = new {Color = "Red", Make = "Saab", CurrentSpeed = 55},
  Price = 34.000};

ReflectOverAnonymousType(purchaseItem);

至此,您应该理解了用于定义匿名类型的语法,但是您可能仍然想知道在什么地方(以及什么时候)使用这个新的语言特性。坦率地说,匿名类型声明应该尽量少用,通常只在使用 LINQ 技术集时使用(参见第十三章)。鉴于匿名类型的诸多限制,您绝不会为了放弃使用强类型类/结构而放弃使用它们,这些限制包括:

  • 您不能控制匿名类型的名称。

  • 匿名类型总是扩展System.Object

  • 匿名类型的字段和属性总是只读的。

  • 匿名类型不支持事件、自定义方法、自定义运算符或自定义重写。

  • 匿名类型总是隐式密封的。

  • 匿名类型总是使用默认构造函数创建的。

然而,当使用 LINQ 技术集编程时,您会发现,在许多情况下,当您想要快速建模一个实体的整体形状而不是它的功能时,这个语法会很有帮助。

使用指针类型

现在是本章的最后一个主题,它很可能是大多数 C# 中最少使用的特性。净核心项目。

Note

在下面的例子中,我假设你有一些 C++指针操作的背景知识。如果不是这样,请完全跳过这个话题。对于大多数 C# 应用来说,使用指针并不是一项常见的任务。

在第四章中,你了解到。NET 核心平台定义了两大类数据:值类型和引用类型。不过,说实话,还有第三类:指针类型。要使用指针类型,您需要特定的运算符和关键字来绕过。Net 5 运行时的内存管理方案,把事情掌握在自己手中(见表 11-2 )。

表 11-2。

以指针为中心的 C# 运算符和关键字

|

运算符/关键字

|

生命的意义

* 该操作符用于创建指针变量(即表示内存中直接位置的变量)。与 C++中一样,这个操作符也用于指针间接寻址。
& 该操作符用于获取变量在内存中的地址。
-> 该运算符用于访问由指针表示的类型的字段(C# 点运算符的不安全版本)。
[] 这个操作符(在不安全的上下文中)允许你索引指针变量所指向的槽(如果你是 C++程序员,你会记得指针变量和[]操作符之间的相互作用)。
++-- 在不安全的上下文中,递增和递减运算符可以应用于指针类型。
+- 在不安全的上下文中,加法和减法运算符可以应用于指针类型。
==!=<><==> 在不安全的上下文中,比较和相等运算符可以应用于指针类型。
Stackalloc 在不安全的上下文中,stackalloc关键字可以用来直接在堆栈上分配 C# 数组。
Fixed 在不安全的上下文中,fixed关键字可以用来临时修复一个变量,以便可以找到它的地址。

现在,在深入细节之前,让我再次指出,你将很少需要使用指针类型。尽管 C# 确实允许您下降到指针操作的级别,但是要理解。NET 核心运行时完全不知道你的意图。因此,如果你对一个指针管理不当,你就是负责处理后果的人。考虑到这些警告,什么时候需要使用指针类型呢?有两种常见情况。

  • 您希望通过在管理之外直接操作内存来优化应用的选定部分。NET 5 运行时。

  • 您正在调用基于 C 的.dll或 COM 服务器的方法,这些方法需要指针类型作为参数。即使在这种情况下,你也可以绕过指针类型,转而使用System.IntPtr类型和System.Runtime.InteropServices.Marshal类型的成员。

当您决定利用 C# 语言的这一特性时,您需要通过使您的项目支持“不安全代码”来告知 C# 编译器您的意图。创建一个名为 UnsafeCode 的新控制台应用项目,并通过将以下内容添加到UnsafeCode.csproj文件中,将该项目设置为支持不安全代码:

<PropertyGroup>
  <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>

Visual Studio 提供了一个 GUI 来设置此属性。访问项目的属性页,导航到 Build 选项卡,选择顶部的所有配置,然后选中“允许不安全的代码”框。见图 11-1 。

img/340876_10_En_11_Fig1_HTML.jpg

图 11-1。

使用 Visual Studio 启用不安全代码

不安全的关键字

当您想在 C# 中使用指针时,您必须使用unsafe关键字明确声明一个“不安全代码”块(任何没有用unsafe关键字标记的代码都自动被认为是“安全的”)。例如,下面的Program类在安全顶级语句中声明了不安全代码的范围:

using System;
using UnsafeCode;

Console.WriteLine("***** Calling method with unsafe code *****");

unsafe
{
  // Work with pointer types here!
}
// Can't work with pointers here!

除了在方法中声明不安全代码的范围之外,您还可以构建“不安全”的结构、类、类型成员和参数下面是几个例子(不需要在当前项目中定义NodeNode2类型):

// This entire structure is "unsafe" and can
// be used only in an unsafe context.
unsafe struct Node
{
  public int Value;
  public Node* Left;
  public Node* Right;
}

// This struct is safe, but the Node2* members
// are not. Technically, you may access "Value" from
// outside an unsafe context, but not "Left" and "Right".
public struct Node2
{
  public int Value;

  // These can be accessed only in an unsafe context!
  public unsafe Node2* Left;
  public unsafe Node2* Right;
}

方法(静态或实例级)也可能被标记为不安全的。例如,假设您知道静态方法将使用指针逻辑。为了确保只能从不安全的上下文中调用该方法,可以将该方法定义如下:

static unsafe void SquareIntPointer(int* myIntPointer)
{
  // Square the value just for a test.
  *myIntPointer *= *myIntPointer;
}

您的方法的配置要求调用者如下调用SquareIntPointer():

  unsafe
  {
    int myInt = 10;

    // OK, because we are in an unsafe context.
    SquareIntPointer(&myInt);
    Console.WriteLine("myInt: {0}", myInt);
  }

  int myInt2 = 5;

  // Compiler error! Must be in unsafe context!
  SquareIntPointer(&myInt2);
  Console.WriteLine("myInt: {0}", myInt2);

如果您不想强迫调用者在不安全的上下文中包装调用,您可以用不安全的块包装所有的顶级语句。如果你使用一个Main()方法作为入口点,你可以用unsafe关键字更新Main()。在这种情况下,将编译以下代码:

static unsafe void Main(string[] args)
{
  int myInt2 = 5;
  SquareIntPointer(&myInt2);
  Console.WriteLine("myInt: {0}", myInt2);
}

如果您运行这个版本的code,您将看到以下输出:

myInt: 25

Note

值得注意的是,选择术语不安全是有原因的。直接访问堆栈和使用指针会导致应用以及运行它的机器出现意外问题。如果你不得不用不安全的代码工作,要格外勤奋。

使用*和&运算符

在你建立了一个不安全的上下文之后,你就可以使用*操作符构建指向数据类型的指针,并使用&操作符获得所指向的地址。与 C 或 C++不同,在 C# 中,*运算符只应用于基础类型,而不是作为每个指针变量名的前缀。例如,考虑下面的代码,它说明了声明指向整型变量的指针的正确和不正确的方法:

// No! This is incorrect under C#!
int *pi, *pj;

// Yes! This is the way of C#.
int* pi, pj;

考虑以下不安全的方法:

static unsafe void PrintValueAndAddress()
{
  int myInt;

  // Define an int pointer, and
  // assign it the address of myInt.
  int* ptrToMyInt = &myInt;

  // Assign value of myInt using pointer indirection.
  *ptrToMyInt = 123;

  // Print some stats.
  Console.WriteLine("Value of myInt {0}", myInt);
  Console.WriteLine("Address of myInt {0:X}", (int)&ptrToMyInt);
}

如果您从不安全的块中运行此方法,您将看到以下输出:

**** Print Value And Address ****
Value of myInt 123
Address of myInt 90F7E698

不安全(和安全)的交换函数

当然,仅仅为了赋值而声明指向局部变量的指针(如前一个例子)从来都不是必需的,也不是完全有用的。为了说明不安全代码的一个更实际的例子,假设您想使用指针算法构建一个交换函数。

unsafe static void UnsafeSwap(int* i, int* j)
{
  int temp = *i;
  *i = *j;
  *j = temp;
}

很 C 的样子,你不觉得吗?但是,鉴于您之前的工作,您应该知道您可以使用 C# ref关键字编写以下安全版本的交换算法:

static void SafeSwap(ref int i, ref int j)
{
  int temp = i;
  i = j;
  j = temp;
}

每个方法的功能都是相同的,因此强调了直接指针操作不是 C# 下的强制任务这一点。下面是使用安全顶级语句和不安全上下文的调用逻辑:

Console.WriteLine("***** Calling method with unsafe code *****");

// Values for swap.
int i = 10, j = 20;

// Swap values "safely."
Console.WriteLine("\n***** Safe swap *****");
Console.WriteLine("Values before safe swap: i = {0}, j = {1}", i, j);
SafeSwap(ref i, ref j);
Console.WriteLine("Values after safe swap: i = {0}, j = {1}", i, j);

// Swap values "unsafely."
Console.WriteLine("\n***** Unsafe swap *****");
Console.WriteLine("Values before unsafe swap: i = {0}, j = {1}", i, j);
unsafe { UnsafeSwap(&i, &j); }

Console.WriteLine("Values after unsafe swap: i = {0}, j = {1}", i, j);
Console.ReadLine();

通过指针访问字段(运算符->运算符)

现在假设您已经定义了一个简单、安全的Point结构,如下所示:

struct Point
{
  public int x;
  public int y;

  public override string ToString() => $"({x}, {y})";
}

如果你声明一个指向Point类型的指针,你将需要使用指针字段访问操作符(由->表示)来访问它的公共成员。如表 11-2 所示,这是标准(安全)点运算符(.的不安全版本。事实上,使用指针间接操作符(*),可以取消引用指针(再次)应用点操作符符号。检查不安全的方法:

static unsafe void UsePointerToPoint()
{
  // Access members via pointer.
  Point;
  Point* p = &point;
  p->x = 100;
  p->y = 200;
  Console.WriteLine(p->ToString());

  // Access members via pointer indirection.
  Point point2;
  Point* p2 = &point2;
  (*p2).x = 100;
  (*p2).y = 200;
  Console.WriteLine((*p2).ToString());
}

stackalloc 关键字

在不安全的上下文中,您可能需要声明一个局部变量,该变量直接从调用堆栈分配内存(因此不受。NET 核心垃圾收集)。为此,C# 提供了stackalloc关键字,它相当于 C 运行时库的_alloca函数。这里有一个简单的例子:

static unsafe string UnsafeStackAlloc()
{
  char* p = stackalloc char[52];
  for (int k = 0; k < 52; k++)
  {
    p[k] = (char)(k + 65)k;
  }
  return new string(p);
}

通过 fixed 关键字固定类型

正如您在前面的例子中看到的,在不安全的上下文中分配一块内存可能会通过关键字stackalloc变得更容易。由于这种操作的本质,一旦分配方法返回(从堆栈中获取内存),分配的内存就会被清除。然而,假设一个更复杂的例子。在我们检查->操作符的过程中,您创建了一个名为Point的值类型。像所有值类型一样,一旦执行范围终止,分配的内存将弹出堆栈。为了便于讨论,假设Point被定义为引用类型,如下所示:

class PointRef // <= Renamed and retyped.
{
  public int x;
  public int y;
  public override string ToString() => $"({x}, {y})";
}

如您所知,如果调用者声明了一个类型为Point的变量,那么内存将被分配到垃圾收集堆中。紧迫的问题变成了“如果一个不安全的上下文想要与这个对象(或者堆上的任何对象)交互怎么办?”考虑到垃圾收集可能在任何时候发生,想象一下当访问Point的成员时遇到的问题,就在这样一个正在进行堆清理的时间点上。从理论上讲,不安全上下文有可能试图与一个不再可访问的成员进行交互,或者在经历了分代清除之后被重新定位到堆上(这是一个明显的问题)。

为了从不安全的上下文中锁定内存中的引用类型变量,C# 提供了fixed关键字。fixed语句设置一个指向托管类型的指针,并在代码执行期间“固定”该变量。如果没有fixed,指向托管变量的指针将没有什么用处,因为垃圾收集可能会不可预测地重新定位变量。(事实上,除了在fixed语句中,C# 编译器不允许你设置指向托管变量的指针。)

因此,如果您创建了一个PointRef对象并希望与其成员交互,您必须编写以下代码(否则会收到一个编译器错误):

unsafe static void UseAndPinPoint()
{
  PointRef pt = new PointRef
  {
    x = 5,
    y = 6
  };

  // Pin pt in place so it will not
  // be moved or GC-ed.
  fixed (int* p = &pt.x)
  {
    // Use int* variable here!
  }

  // pt is now unpinned, and ready to be GC-ed once
  // the method completes.
  Console.WriteLine ("Point is: {0}", pt);
}

简而言之,fixed关键字允许您构建一个锁定内存中引用变量的语句,这样它的地址在语句(或作用域块)的持续时间内保持不变。任何时候在不安全代码的上下文中与引用类型进行交互时,都必须锁定引用。

sizeof 关键字

最后要考虑的以不安全为中心的 C# 关键字是sizeof。与 C++一样,C# sizeof关键字用于获取内在数据类型的字节大小,但不是自定义类型,除非在不安全的上下文中。例如,下面的方法不需要声明为“不安全”,因为sizeof关键字的所有参数都是内部类型:

static void UseSizeOfOperator()
{
  Console.WriteLine("The size of short is {0}.", sizeof(short));
  Console.WriteLine("The size of int is {0}.", sizeof(int));
  Console.WriteLine("The size of long is {0}.", sizeof(long));
}

但是,如果您想获得自定义的Point结构的大小,您需要更新这个方法(注意已经添加了unsafe关键字):

unsafe static void UseSizeOfOperator()
{
...
  unsafe {
    Console.WriteLine("The size of Point is {0}.", sizeof(Point));
  }
}

以上介绍了 C# 编程语言的一些更高级的特性。为了确保我们都在同一页上,我必须再次说,你的大部分。NET 项目可能永远不需要直接使用这些特性(尤其是指针)。然而,正如你将在后面的章节中看到的,当使用 LINQ API 时,有些主题即使不是必需的,也是非常有用的,尤其是扩展方法和匿名类型。

摘要

本章的目的是加深你对 C# 编程语言的理解。首先,您研究了各种高级类型构造技术(索引器方法、重载操作符和自定义转换例程)。

接下来,您研究了扩展方法和匿名类型的作用。正如你将在第十三章中看到的一些细节,这些特性在使用以 LINQ 为中心的 API 时很有用(尽管你可以在代码中的任何地方使用它们,如果它们有用的话)。回想一下,匿名方法允许您快速地为类型的“形状”建模,而扩展方法允许您为类型添加新的功能,而不需要子类化。

在本章的剩余部分,您研究了一小组鲜为人知的关键字(sizeofunsafe等)。)并在此过程中学习了如何使用原始指针类型。正如在整个指针类型的研究中所说的,你的大多数 C# 应用永远不需要使用它们。

十二、委托、事件和 Lambda 表达式

到本文的这一点为止,你开发的大多数应用都将不同的代码作为顶级语句添加到了Program.cs中,这些语句以某种方式将请求发送到给定对象的。然而,许多应用要求一个对象能够使用回调机制将传递回创建它的实体。虽然回调机制可以在任何应用中使用,但它们对于图形用户界面尤其重要,因为控件(如按钮)需要在正确的情况下(单击按钮时、鼠标进入按钮表面时等)调用外部方法。).

在下面。NET 核心平台中,委托类型是在应用中定义和响应回调的首选方式。本质上。NET Core 委托类型是一个类型安全的对象,它“指向”一个方法或一组以后可以调用的方法。然而,与传统的 C++函数指针不同,委托是具有多播内置支持的类。

Note

在的早期版本中。NET 中,用BeginInvoke() / EndInvoke()委托公开的异步方法调用。虽然这些仍由编译器生成,但在下不受支持。NET 核心。这是因为代理使用的IAsyncResult() / BeginInvoke()模式已经被基于任务的异步模式所取代。有关异步执行的更多信息,请参见第十五章。

在本章中,你将学习如何创建和操作委托类型,然后你将研究 C# event关键字,它简化了使用委托类型的过程。在此过程中,您还将研究 C# 的几个以委托为中心和以事件为中心的语言特性,包括匿名方法和方法组转换。

我通过检查λ表达式来结束这一章。使用 C# lambda 运算符(=>),您可以在任何需要强类型委托的地方指定代码语句块(以及传递给这些代码语句的参数)。正如您将看到的,lambda 表达式只不过是一个伪装的匿名方法,并提供了一种简化的委托处理方法。此外,相同的操作(截至。NET Framework 4.6 及更高版本)可用于使用简洁的语法实现单语句方法或属性。

了解委托类型

在正式定义代表之前,让我们先了解一下情况。历史上,Windows API 经常使用 C 风格的函数指针来创建被称为回调函数的实体,或者简称为回调。使用回调,程序员能够配置一个函数来报告(回调)应用中的另一个函数。通过这种方法,Windows 开发人员能够处理按钮点击、鼠标移动、菜单选择以及内存中两个实体之间的一般双向通信。

在。NET 和。NET 核心框架中,回调是使用委托以类型安全和面向对象的方式完成的。委托是一个类型安全的对象,它指向应用中的另一个方法(或者可能是一列方法),以后可以调用该方法。具体来说,代理维护三条重要的信息。

  • 它调用的方法的地址

  • 该方法的参数(如果有)

  • 该方法的返回类型(如果有)

Note

。NET 核心委托可以指向静态方法或实例方法。

在委托对象被创建并被赋予必要的信息后,它可以在运行时动态地调用它所指向的方法。

在 C# 中定义委托类型

当你想在 C# 中创建一个委托类型时,你可以使用delegate关键字。您的委托类型的名称可以是您想要的任何名称。但是,您必须定义委托以匹配它将指向的方法的签名。例如,下面的委托类型(名为BinaryOp)可以指向任何返回一个整数并接受两个整数作为输入参数的方法(在本章的稍后部分,您将自己构建并使用这个委托,所以暂时不要着急):

// This delegate can point to any method,
// taking two integers and returning an integer.
public delegate int BinaryOp(int x, int y);

当 C# 编译器处理委托类型时,它会自动生成一个从System.MulticastDelegate派生的密封类。这个类(与它的基类System.Delegate一起)为委托提供了必要的基础结构,以保存稍后要调用的方法列表。例如,如果您使用ildasm.exe来检查BinaryOp委托,您会发现如下所示的细节(如果您想自己检查,您将马上构建这个完整的示例):

//     -------------------------------------------------------
//     TypDefName: SimpleDelegate.BinaryOp
//     Extends   : System.MulticastDelegate
//     Method #1
//     -------------------------------------------------------
//             MethodName: .ctor
//             ReturnType: Void
//             2 Arguments
//                     Argument #1:  Object
//                     Argument #2:  I
//     Method #2
//     -------------------------------------------------------
//             MethodName: Invoke
//             ReturnType: I4
//             2 Arguments
//                     Argument #1:  I4
//                     Argument #2:  I4
//             2 Parameters
//                     (1) ParamToken : Name : x flags: [none]
//                     (2) ParamToken : Name : y flags: [none] //
//     Method #3
//     -------------------------------------------------------
//             MethodName: BeginInvoke
//             ReturnType: Class System.IAsyncResult
//             4 Arguments
//                     Argument #1:  I4
//                     Argument #2:  I4
//                     Argument #3:  Class System.AsyncCallback
//                     Argument #4:  Object
//             4 Parameters
//                     (1) ParamToken : Name : x flags: [none]
//                     (2) ParamToken : Name : y flags: [none]
//                     (3) ParamToken : Name : callback flags: [none]
//                     (4) ParamToken : Name : object flags: [none]
//
//     Method #4
//     -------------------------------------------------------
//             MethodName: EndInvoke
//             ReturnType: I4 (int32)
//             1 Arguments
//                     Argument #1:  Class System.IAsyncResult
//             1 Parameters
//                     (1) ParamToken : Name : result flags: [none]

如您所见,编译器生成的BinaryOp类定义了三个公共方法。Invoke()是中的关键方法。NET Core,因为它用于以一种同步的方式调用由委托对象维护的每个方法,这意味着调用者必须等待调用完成后才能继续它的方式。奇怪的是,同步Invoke()方法可能不需要从 C# 代码中显式调用。正如您马上会看到的,当您使用适当的 C# 语法时,Invoke()会在幕后被调用。

Note

虽然生成了BeginInvoke()EndInvoke(),但是在下运行代码时不支持它们。NET 核心。这可能会令人沮丧,因为如果使用它们,您将不会收到编译器错误,而是运行时错误。

现在,编译器到底是如何知道如何定义Invoke()方法的呢?为了理解这个过程,下面是编译器生成的BinaryOp类类型的关键(粗斜体标记了由定义的委托类型指定的项目):

sealed class BinaryOp : System.MulticastDelegate
{
  public int Invoke(int x, int y);
...
}

首先,注意为Invoke()方法定义的参数和返回类型与BinaryOp委托的定义完全匹配。

让我们看另一个例子。假设您已经定义了一个委托类型,它可以指向任何返回一个string并接收三个System.Boolean输入参数的方法。

public delegate string MyDelegate (bool a, bool b, bool c);

这一次,编译器生成的类分解如下:

sealed class MyDelegate : System.MulticastDelegate
{
  public string Invoke(bool a, bool b, bool c);
...
}

委托还可以“指向”包含任意数量的outref参数(以及标有params关键字的数组参数)的方法。例如,假设以下委托类型:

public delegate string MyOtherDelegate(
  out bool a, ref bool b, int c);

Invoke()方法的签名看起来就像你所期望的那样。

总的来说,C# 委托类型定义会产生一个密封类,其中包含一个编译器生成的方法,该方法的参数和返回类型基于委托的声明。以下伪代码近似于基本模式:

// This is only pseudo-code!
public sealed class DelegateName : System.MulticastDelegate
{
  public delegateReturnValue Invoke(allDelegateInputRefAndOutParams);
}

系统。多播代理和系统。委托基类

因此,当您使用 C# delegate关键字构建类型时,您是在间接声明一个从System.MulticastDelegate派生的类类型。该类为后代提供对列表的访问,该列表包含由委托对象维护的方法的地址,以及与调用列表交互的几个附加方法(和几个重载运算符)。以下是System.MulticastDelegate的一些精选成员:

public abstract class MulticastDelegate : Delegate
{
  // Returns the list of methods "pointed to."
  public sealed override Delegate[] GetInvocationList();

  // Overloaded operators.
  public static bool operator ==
    (MulticastDelegate d1, MulticastDelegate d2);
  public static bool operator !=
    (MulticastDelegate d1, MulticastDelegate d2);

  // Used internally to manage the list of methods maintained by the delegate.
  private IntPtr _invocationCount;
  private object _invocationList;
}

System.MulticastDelegate从其父类System.Delegate获得附加功能。下面是类定义的部分快照:

public abstract class Delegate : ICloneable, ISerializable
{
  // Methods to interact with the list of functions.
  public static Delegate Combine(params Delegate[] delegates);
  public static Delegate Combine(Delegate a, Delegate b);
  public static Delegate Remove(
    Delegate source, Delegate value);
  public static Delegate RemoveAll(
    Delegate source, Delegate value);

  // Overloaded operators.
  public static bool operator ==(Delegate d1, Delegate d2);
  public static bool operator !=(Delegate d1, Delegate d2);

  // Properties that expose the delegate target.
  public MethodInfo Method { get; }
  public object Target { get; }
}

现在,要明白你永远不能在你的代码中直接从这些基类派生(这样做是一个编译器错误)。然而,当您使用delegate关键字时,您已经间接地创建了一个“is-a”MulticastDelegate类。表 12-1 记录了所有委托类型共有的核心成员。

表 12-1。

选择System.MulticastDelegate/System.Delegate的成员

|

成员

|

生命的意义

Method 该属性返回一个代表由委托维护的静态方法的细节的System.Reflection.MethodInfo对象。
Target 如果要调用的方法是在对象级定义的(而不是静态方法),Target返回一个对象,该对象表示由委托维护的方法。如果从Target返回的值等于null,那么要调用的方法是一个静态成员。
Combine() 此静态方法将方法添加到由委托维护的列表中。在 C# 中,您使用重载的+=操作符作为一种简写符号来触发这个方法。
GetInvocationList() 这个方法返回一个由System.Delegate对象组成的数组,每个对象代表一个可能被调用的方法。
Remove() /``RemoveAll() 这些静态方法从委托的调用列表中移除一个方法(或所有方法)。在 C# 中,可以使用重载的-=运算符间接调用Remove()方法。

最简单的委托示例

当然,第一次遇到委托时,可能会引起一些混乱。因此,开始吧,让我们看一个简单的控制台应用(名为 SimpleDelegate ),它使用了您之前见过的BinaryOp委托类型。下面是完整的代码,并附有分析:

//SimpleMath.cs
namespace SimpleDelegate
{
  // This class contains methods BinaryOp will
  // point to.
  public class SimpleMath
  {
    public static int Add(int x, int y) => x + y;
    public static int Subtract(int x, int y) => x - y;
  }
}

//Program.cs
using System;
using SimpleDelegate;

Console.WriteLine("***** Simple Delegate Example *****\n");

// Create a BinaryOp delegate object that
// "points to" SimpleMath.Add().
BinaryOp b = new BinaryOp(SimpleMath.Add);

// Invoke Add() method indirectly using delegate object.
Console.WriteLine("10 + 10 is {0}", b(10, 10));
Console.ReadLine();

//Additional type definitions must be placed at the end of the
// top-level statements
// This delegate can point to any method,
// taking two integers and returning an integer.
public delegate int BinaryOp(int x, int y);

Note

回想一下第三章中的内容,额外的类型声明(在这个例子中是BinaryOp委托)必须跟在所有的顶级语句之后。

再次注意BinaryOp委托类型声明的格式;它指定BinaryOp委托对象可以指向任何一个接受两个整数并返回一个整数的方法(所指向的方法的实际名称是不相关的)。这里,您已经创建了一个名为SimpleMath的类,它定义了两个静态方法,这两个方法与BinaryOp委托定义的模式相匹配。

当您想要将目标方法分配给给定的委托对象时,只需将方法的名称传递给委托的构造函数。

// Create a BinaryOp delegate object that
// "points to" SimpleMath.Add().
BinaryOp b = new BinaryOp(SimpleMath.Add);

此时,您可以使用类似于直接函数调用的语法来调用所指向的成员。

// Invoke() is really called here!
Console.WriteLine("10 + 10 is {0}", b(10, 10));

在幕后,运行时在您的MulticastDelegate派生类上调用编译器生成的Invoke()方法。如果您在ildasm.exe中打开您的程序集,并在Main()方法中检查 CIL 代码,您可以自己验证这一点。

.method private hidebysig static void Main(string[] args) cil managed
{
...
  callvirt   instance int32 BinaryOp::Invoke(int32, int32)
}

C# 不要求你在代码库中显式调用Invoke()。因为BinaryOp可以指向带两个参数的方法,下面的代码语句也是允许的:

Console.WriteLine("10 + 10 is {0}", b.Invoke(10, 10));

回想一下。NET 核心委托是类型安全的。因此,如果您试图创建一个指向与模式不匹配的方法的委托对象,就会收到一个编译时错误。举例来说,假设SimpleMath类现在定义了一个名为SquareNumber()的附加方法,它接受一个整数作为输入。

public class SimpleMath
{
  public static int SquareNumber(int a) => a * a;
}

鉴于BinaryOp委托只能将指向接受两个整数并返回一个整数的方法,下面的代码是非法的,不会被编译:

// Compiler error! Method does not match delegate pattern!
BinaryOp b2 = new BinaryOp(SimpleMath.SquareNumber);

调查委托对象

让我们通过在Program类中创建一个静态方法(名为DisplayDelegateInfo())来增加当前示例的趣味。该方法将打印出由委托对象维护的方法的名称,以及定义该方法的类的名称。为此,您将迭代由GetInvocationList()返回的System.Delegate数组,调用每个对象的TargetMethod属性。

static void DisplayDelegateInfo(Delegate delObj)
{
  // Print the names of each member in the
  // delegate's invocation list.
  foreach (Delegate d in delObj.GetInvocationList())
  {
    Console.WriteLine("Method Name: {0}", d.Method);
    Console.WriteLine("Type Name: {0}", d.Target);
  }
}

假设您已经更新了您的Main()方法来调用这个新的帮助器方法,如下所示:

BinaryOp b = new BinaryOp(SimpleMath.Add);
DisplayDelegateInfo(b);

您会发现如下所示的输出:

***** Simple Delegate Example *****
Method Name: Int32 Add(Int32, Int32)
Type Name:
10 + 10 is 20

注意,当调用Target属性时,目标类(SimpleMath)的名称当前显示为而不是。原因是您的BinaryOp委托指向一个静态方法,因此没有对象可以引用!然而,如果您将Add()Subtract()方法更新为非静态的(只需删除static关键字),您可以创建一个SimpleMath类的实例,并使用对象引用指定要调用的方法。

using System;
using SimpleDelegate;

Console.WriteLine("***** Simple Delegate Example *****\n");

// Delegates can also point to instance methods as well.
SimpleMath m = new SimpleMath();
BinaryOp b = new BinaryOp(m.Add);

// Show information about this object.
DisplayDelegateInfo(b);

Console.WriteLine("10 + 10 is {0}", b(10, 10));
Console.ReadLine();

在这种情况下,您会发现如下所示的输出:

***** Simple Delegate Example *****
Method Name: Int32 Add(Int32, Int32)
Type Name: SimpleDelegate.SimpleMath
10 + 10 is 20

使用委托发送对象状态通知

显然,前面的 SimpleDelegate 示例本质上纯粹是说明性的,因为没有令人信服的理由来定义一个简单地将两个数相加的委托。为了更真实地使用委托类型,让我们使用委托来定义一个Car类,它可以通知外部实体它当前的引擎状态。为此,您将采取以下步骤:

  1. 定义将用于向呼叫者发送通知的新委托类型。

  2. Car类中声明这个委托的成员变量。

  3. Car上创建一个助手函数,允许调用者指定要回调的方法。

  4. 实现Accelerate()方法以在正确的情况下调用委托的调用列表。

首先,创建一个名为 CarDelegate 的新控制台应用项目。现在,定义一个新的Car类,最初如下所示:

using System;
using System.Linq;

namespace CarDelegate
{
  public class Car
  {
    // Internal state data.
    public int CurrentSpeed { get; set; }
    public int MaxSpeed { get; set; } = 100;
    public string PetName { get; set; }

    // Is the car alive or dead?
    private bool _carIsDead;

    // Class constructors.
    public Car() {}
    public Car(string name, int maxSp, int currSp)
    {
      CurrentSpeed = currSp;
      MaxSpeed = maxSp;
      PetName = name;
    }
  }
}

现在,考虑以下更新,这些更新解决了前三点:

public class Car
{
  ...
  // 1) Define a delegate type.
  public delegate void CarEngineHandler(string msgForCaller);

  // 2) Define a member variable of this delegate.
  private CarEngineHandler _listOfHandlers;

  // 3) Add registration function for the caller.
  public void RegisterWithCarEngine(CarEngineHandler methodToCall)
  {
    _listOfHandlers = methodToCall;
  }
}

请注意,在这个例子中,您直接在Car类的范围内定义了委托类型,这当然不是必需的,但确实有助于强化委托自然地与这个类一起工作的思想。委托类型CarEngineHandler可以指向任何将单个string作为输入并将void作为返回值的方法。

接下来,请注意,您声明了一个委托类型的私有成员变量(名为_listOfHandlers)和一个助手函数(名为RegisterWithCarEngine()),该函数允许调用者将一个方法分配给委托的调用列表。

Note

严格地说,您可以将您的委托成员变量定义为 public,从而避免创建额外的注册方法。但是,通过将委托成员变量定义为 private,您可以实施封装服务并提供更类型安全的解决方案。在本章的后面,当你查看 C# event关键字时,你将再次讨论公共委托成员变量的风险。

此时,您需要创建Accelerate()方法。回想一下,这里的要点是允许一个Car对象向任何订阅的侦听器发送与引擎相关的消息。以下是最新消息:

// 4) Implement the Accelerate() method to invoke the delegate's

//    invocation list under the correct circumstances.
public void Accelerate(int delta)
{
  // If this car is "dead," send dead message.
  if (_carIsDead)
  {
    _listOfHandlers?.Invoke("Sorry, this car is dead...");
  }
  else
  {
    CurrentSpeed += delta;
    // Is this car "almost dead"?
    if (10 == (MaxSpeed - CurrentSpeed))
    {
      _listOfHandlers?.Invoke("Careful buddy! Gonna blow!");
    }
    if (CurrentSpeed >= MaxSpeed)
    {
      _carIsDead = true;
    }
    else
    {
      Console.WriteLine("CurrentSpeed = {0}", CurrentSpeed);
    }
  }
}

请注意,在尝试调用由listOfHandlers成员变量维护的方法时,您使用了空传播语法。原因是调用者的工作是通过调用RegisterWithCarEngine() helper 方法来分配这些对象。如果调用者没有调用这个方法,而你试图调用委托的调用列表,你将在运行时触发一个NullReferenceException。现在您已经有了委托基础设施,观察对Program类的更新,如下所示:

using System;
using CarDelegate;

Console.WriteLine("** Delegates as event enablers **\n");

// First, make a Car object.
Car c1 = new Car("SlugBug", 100, 10);

// Now, tell the car which method to call
// when it wants to send us messages.
c1.RegisterWithCarEngine(
  new Car.CarEngineHandler(OnCarEngineEvent));

// Speed up (this will trigger the events).
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}
Console.ReadLine();

// This is the target for incoming events.
static void OnCarEngineEvent(string msg)
{
  Console.WriteLine("\n*** Message From Car Object ***");
  Console.WriteLine("=> {0}", msg);
  Console.WriteLine("********************\n");
}

代码从简单地创建一个新的Car对象开始。既然您对引擎事件感兴趣,那么下一步就是调用您的定制注册函数RegisterWithCarEngine()。回想一下,这个方法期望被传递一个嵌套的CarEngineHandler委托的实例,和任何委托一样,您指定一个“指向的方法”作为构造函数参数。本例中的技巧是,所讨论的方法位于Program类中!再次注意,OnCarEngineEvent()方法与相关委托完全匹配,因为它接受一个string作为输入并返回void。考虑当前示例的输出:

***** Delegates as event enablers *****
***** Speeding up *****
CurrentSpeed = 30
CurrentSpeed = 50
CurrentSpeed = 70

***** Message From Car Object *****
=> Careful buddy! Gonna blow!
***********************************
CurrentSpeed = 90
***** Message From Car Object *****
=> Sorry, this car is dead...
***********************************

启用多播

回想一下.NETCore 代表具有内置的组播能力。换句话说,委托对象可以维护要调用的方法列表,而不仅仅是单个方法。当你想给一个委托对象添加多个方法时,你只需使用重载的+=操作符,而不是直接赋值。要在Car类上启用多播,您可以更新RegisterWithCarEngine()方法,如下所示:

public class Car
{
  // Now with multicasting support!
  // Note we are now using the += operator, not
  // the assignment operator (=).
  public void RegisterWithCarEngine(
    CarEngineHandler methodToCall)
  {
    _listOfHandlers += methodToCall;
  }
...
}

当您在委托对象上使用+=操作符时,编译器将其解析为对静态Delegate.Combine()方法的调用。事实上,你可以直接给Delegate.Combine()打电话;然而,+=操作符提供了一个更简单的选择。不需要修改您当前的RegisterWithCarEngine()方法,但是这里有一个使用Delegate.Combine()而不是+=操作符的例子:

public void RegisterWithCarEngine( CarEngineHandler methodToCall )
{
  if (_listOfHandlers == null)
  {
    _listOfHandlers = methodToCall;
  }
  else
  {
    _listOfHandlers =
      Delegate.Combine(_listOfHandlers, methodToCall)
        as CarEngineHandler;
  }
}

无论如何,调用者现在可以为同一个回调通知注册多个目标。这里,第二个处理程序以大写形式打印传入的消息,只是为了显示:

Console.WriteLine("***** Delegates as event enablers *****\n");

// First, make a Car object.
Car c1 = new Car("SlugBug", 100, 10);

// Register multiple targets for the notifications.
c1.RegisterWithCarEngine(
  new Car.CarEngineHandler(OnCarEngineEvent));
c1.RegisterWithCarEngine(
  new Car.CarEngineHandler(OnCarEngineEvent2));

// Speed up (this will trigger the events).
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}
Console.ReadLine();

// We now have TWO methods that will be called by the Car
// when sending notifications.
static void OnCarEngineEvent(string msg)
{
  Console.WriteLine("\n*** Message From Car Object ***");
  Console.WriteLine("=> {0}", msg);
  Console.WriteLine("*********************************\n");
}

static void OnCarEngineEvent2(string msg)
{
  Console.WriteLine("=> {0}", msg.ToUpper());
}

从委托的调用列表中删除目标

Delegate类还定义了一个静态的Remove()方法,允许调用者从委托对象的调用列表中动态删除一个方法。这使得允许调用者在运行时“取消订阅”给定的通知变得简单。虽然您可以在代码中直接调用Delegate.Remove(),但是 C# 开发人员可以使用-=操作符作为一种方便的简写符号。让我们给Car类添加一个新方法,它允许调用者从调用列表中删除一个方法。

public class Car
{
...
  public void UnRegisterWithCarEngine(CarEngineHandler methodToCall)
  {
    _listOfHandlers -= methodToCall;
  }
}

使用当前对Car类的更新,您可以通过更新调用代码来停止在第二个处理程序上接收引擎通知,如下所示:

Console.WriteLine("***** Delegates as event enablers *****\n");

// First, make a Car object.
Car c1 = new Car("SlugBug", 100, 10);
c1.RegisterWithCarEngine(
  new Car.CarEngineHandler(OnCarEngineEvent));

// This time, hold onto the delegate object,
// so we can unregister later.
Car.CarEngineHandler handler2 =
  new Car.CarEngineHandler(OnCarEngineEvent2);
c1.RegisterWithCarEngine(handler2);

// Speed up (this will trigger the events).
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}

// Unregister from the second handler.
c1.UnRegisterWithCarEngine(handler2);

// We won't see the "uppercase" message anymore!
Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}

Console.ReadLine();

这段代码中的一个不同之处是,这次您创建了一个Car.CarEngineHandler对象,并将其存储在一个局部变量中,这样您就可以在以后使用该对象来注销通知。因此,第二次加速Car对象时,您将不再看到输入消息数据的大写版本,因为您已经从委托的调用列表中删除了这个目标。

方法组转换语法

在前面的 CarDelegate 示例中,您显式创建了Car.CarEngineHandler delegate 对象的实例,以便向引擎通知注册和注销。

Console.WriteLine("***** Delegates as event enablers *****\n");

Car c1 = new Car("SlugBug", 100, 10);
c1.RegisterWithCarEngine(new Car.CarEngineHandler(OnCarEngineEvent));

Car.CarEngineHandler handler2 =
  new Car.CarEngineHandler(OnCarEngineEvent2);
c1.RegisterWithCarEngine(handler2);
...

可以肯定的是,如果您需要调用MulticastDelegateDelegate的任何继承成员,手动创建一个委托变量是最简单的方法。然而,在大多数情况下,您并不真正需要抓住委托对象不放。相反,您通常只需要使用委托对象将方法名作为构造函数参数传入。

作为一种简化,C# 提供了一种称为方法组转换的快捷方式。当调用以委托作为参数的方法时,此功能允许您提供直接的方法名,而不是委托对象。

Note

正如你将在本章后面看到的,你也可以使用方法组转换语法来简化你注册 C# 事件的方式。

举例来说,考虑下面对Program类的更新,该类使用方法组转换来注册和注销引擎通知:

...
Console.WriteLine("***** Method Group Conversion *****\n");
Car c2 = new Car();

// Register the simple method name.
c2.RegisterWithCarEngine(OnCarEngineEvent);

Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c2.Accelerate(20);
}

// Unregister the simple method name.
c2.UnRegisterWithCarEngine(OnCarEngineEvent);

// No more notifications!
for (int i = 0; i < 6; i++)
{
  c2.Accelerate(20);
}

Console.ReadLine();

请注意,您不是直接分配相关的委托对象,而是简单地指定一个与委托的预期签名相匹配的方法(在本例中,该方法返回void并接受一个string)。要明白 C# 编译器仍然在确保类型安全。因此,如果OnCarEngineEvent()方法没有接受string并返回void,就会出现编译器错误。

了解泛型委托

在第十章中,我提到 C# 允许你定义通用的委托类型。例如,假设您想要定义一个委托类型,它可以调用任何返回void并接收单个参数的方法。如果所讨论的参数可能不同,您可以使用类型参数对此进行建模。为了说明这一点,请考虑名为 GenericDelegate 的新控制台应用项目中的以下代码:

Console.WriteLine("***** Generic Delegates *****\n");

// Register targets.
MyGenericDelegate<string> strTarget =
  new MyGenericDelegate<string>(StringTarget);
strTarget("Some string data");

//Using the method group conversion syntax
MyGenericDelegate<int> intTarget = IntTarget;
intTarget(9);
Console.ReadLine();

static void StringTarget(string arg)
{
  Console.WriteLine("arg in uppercase is: {0}", arg.ToUpper());
}

static void IntTarget(int arg)
{
  Console.WriteLine("++arg is: {0}", ++arg);
}
  // This generic delegate can represent any method
  // returning void and taking a single parameter of type T.
  public delegate void MyGenericDelegate<T>(T arg);

注意,MyGenericDelegate<T>定义了一个类型参数,它表示传递给委托目标的参数。创建此类型的实例时,需要指定类型参数的值,以及委托将调用的方法的名称。因此,如果您指定了一个字符串类型,您将向目标方法发送一个字符串值。

// Create an instance of MyGenericDelegate<T>
// with string as the type parameter.
MyGenericDelegate<string> strTarget = StringTarget;
strTarget("Some string data");

给定strTarget对象的格式,StringTarget()方法现在必须将单个字符串作为参数。

static void StringTarget(string arg)
{
  Console.WriteLine(
    "arg in uppercase is: {0}", arg.ToUpper());
}

通用动作<>和功能<>委托

在本章的过程中,你已经看到了当你想在你的应用中使用委托来启用回调时,你通常遵循如下所示的步骤:

  1. 定义与所指向方法的格式相匹配的自定义委托。

  2. 创建自定义委托的实例,将方法名作为构造函数参数传入。

  3. 通过调用委托对象上的Invoke()来间接调用该方法。

当您采用这种方法时,您通常会得到几个自定义委托,这些委托可能永远不会在当前任务之外使用(例如,MyGenericDelegate<T>CarEngineHandler等)。).虽然您确实需要为您的项目定制一个唯一命名的委托类型,但是其他时候委托类型的确切名称是不相关的。在许多情况下,您只是希望“某个委托”接受一组参数,并可能有一个不同于void的返回值。在这些情况下,您可以使用框架内置的Action<>Func<>委托类型。为了说明它们的用途,创建一个名为 ActionAndFuncDelegates 的新控制台应用项目。

泛型Action<>委托是在System名称空间中定义的,您可以使用这个泛型委托来“指向”一个最多占用 16 个参数的方法(这应该足够了!)并返回void。现在回想一下,因为Action<>是一个泛型委托,所以您还需要指定每个参数的底层类型。

更新您的Program类来定义一个新的静态方法,它接受三个(或更多)唯一的参数。这里有一个例子:

// This is a target for the Action<> delegate.
static void DisplayMessage(string msg, ConsoleColor txtColor, int printCount)
{
  // Set color of console text.
  ConsoleColor previous = Console.ForegroundColor;
  Console.ForegroundColor = txtColor;

  for (int i = 0; i < printCount; i++)
  {
    Console.WriteLine(msg);
  }

  // Restore color.
  Console.ForegroundColor = previous;
}

现在,您可以使用现成的Action<>委托,而不是手动构建一个自定义委托来将程序流传递给DisplayMessage()方法,如下所示:

Console.WriteLine("***** Fun with Action and Func *****");

// Use the Action<> delegate to point to DisplayMessage.
Action<string, ConsoleColor, int> actionTarget =
  DisplayMessage;
actionTarget("Action Message!", ConsoleColor.Yellow, 5);

Console.ReadLine();

如您所见,使用Action<>委托可以省去定义定制委托类型的麻烦。然而,回想一下,Action<>委托类型只能指向采用void返回值的方法。如果您想指向一个确实有返回值的方法(并且不想麻烦自己编写自定义委托),您可以使用Func<>

通用的Func<>委托可以指向(像Action<>)最多接受 16 个参数和一个自定义返回值的方法。为了举例说明,将下面的新方法添加到Program类中:

// Target for the Func<> delegate.
static int Add(int x, int y)
{
  return x + y;
}

在本章的前面,我让你构建一个定制的BinaryOp委托来“指向”加减法。然而,您可以使用一个总共有三个类型参数的版本的Func<>来简化您的工作。要知道Func<>final 类型参数是总是方法的返回值。为了巩固这一点,假设Program类还定义了以下方法:

static string SumToString(int x, int y)
{
  return (x + y).ToString();
}

现在,调用代码可以调用这些方法中的每一个,如下所示:

Func<int, int, int> funcTarget = Add;
int result = funcTarget.Invoke(40, 40);
Console.WriteLine("40 + 40 = {0}", result);

Func<int, int, string> funcTarget2 = SumToString;
string sum = funcTarget2(90, 300);
Console.WriteLine(sum);

在任何情况下,考虑到Action<>Func<>可以让您省去手动定义自定义委托的步骤,您可能想知道是否应该一直使用它们。与编程的许多方面一样,答案是“视情况而定”在许多情况下,Action<>Func<>将是首选的行动方案(没有双关语)。但是,如果您需要一个具有自定义名称的委托,并且您认为它有助于更好地捕获您的问题域,那么构建自定义委托就像一条代码语句一样简单。在阅读本文的剩余部分时,您将会看到这两种方法。

Note

许多重要的。NET 核心 API 大量使用了Action<>Func<>委托,包括并行编程框架和 LINQ(等等)。

这就结束了我们对委托类型的初步了解。接下来,让我们继续讨论 C# event关键字的相关主题。

了解 C# 事件

委托是有趣的构造,因为它们使内存中的对象能够进行双向对话。然而,在 raw 中使用委托可能需要创建一些样板代码(定义委托、声明必要的成员变量、创建自定义注册和注销方法以保留封装,等等)。).

此外,当您使用 raw 中的委托作为应用的回调机制时,如果您没有将类的委托成员变量定义为 private,调用方将可以直接访问委托对象。在这种情况下,调用者可以将变量重新分配给一个新的委托对象(有效地删除当前要调用的函数列表),更糟糕的是,调用者可以直接调用委托的调用列表。为了演示这个问题,创建一个名为 PublicDelegateProblem 的新控制台应用,并添加对前面 CarDelegate 示例中的Car类的以下修改(和简化):

namespace PublicDelegateproblem
{
  public class Car
  {
    public delegate void CarEngineHandler(string msgForCaller);

    // Now a public member!
    public CarEngineHandler ListOfHandlers;

    // Just fire out the Exploded notification.
    public void Accelerate(int delta)
    {
      if (ListOfHandlers != null)
      {
        ListOfHandlers("Sorry, this car is dead...");
      }
    }
  }
}

请注意,您不再拥有用自定义注册方法封装的私有委托成员变量。因为这些成员确实是公共的,所以调用者可以直接访问listOfHandlers成员变量,并将该类型重新分配给新的CarEngineHandler对象,并在需要时调用委托。

using System;
using PublicDelegateProblem;

Console.WriteLine("***** Agh! No Encapsulation! *****\n");
// Make a Car.
Car myCar = new Car();
// We have direct access to the delegate!
myCar.ListOfHandlers = CallWhenExploded;
myCar.Accelerate(10);

// We can now assign to a whole new object...
// confusing at best.
myCar.ListOfHandlers = CallHereToo;
myCar.Accelerate(10);

// The caller can also directly invoke the delegate!
myCar.ListOfHandlers.Invoke("hee, hee, hee...");
Console.ReadLine();

static void CallWhenExploded(string msg)
{
  Console.WriteLine(msg);
}

static void CallHereToo(string msg)
{
  Console.WriteLine(msg);
}

公开公共委托成员会破坏封装,这不仅会导致代码难以维护(和调试),还会使您的应用面临潜在的安全风险!以下是当前示例的输出:

***** Agh! No Encapsulation! *****
Sorry, this car is dead...
Sorry, this car is dead...
hee, hee, hee...

显然,您不希望让其他应用有权更改委托所指向的内容,或者在未经您允许的情况下调用成员。鉴于此,通常的做法是声明私有委托成员变量。

C# 事件关键字

作为一种快捷方式,C# 提供了event关键字,这样您就不必构建自定义方法来向委托的调用列表添加或移除方法。当编译器处理event关键字时,会自动为您提供注册和注销方法,以及您的委托类型所需的任何成员变量。这些委托成员变量总是声明为私有的,因此,它们不会直接从触发事件的对象中暴露出来。当然,event关键字可以用来简化定制类向外部对象发送通知的方式。

定义事件是一个两步过程。首先,您需要定义一个委托类型(或者重用一个现有的类型),它将保存事件触发时要调用的方法列表。接下来,根据相关的委托类型声明一个事件(使用 C# event关键字)。

为了说明event关键字,创建一个名为 CarEvents 的新控制台应用。在这个Car类的迭代中,您将定义两个名为AboutToBlowExploded的事件。这些事件与一个名为CarEngineHandler的委托类型相关联。下面是对Car类的初始更新:

using System;

namespace CarEvents
{
  public class Car
  {
...
    // This delegate works in conjunction with the
    // Car's events.
    public delegate void CarEngineHandler(string msgForCaller);

    // This car can send these events.
    public event CarEngineHandler Exploded;
    public event CarEngineHandler AboutToBlow;
 ...
  }
}

向调用者发送事件非常简单,只需按名称指定事件,以及相关委托定义的任何必需参数。为了确保调用者确实注册了事件,在调用委托的方法集之前,您需要对照一个null值来检查事件。考虑到这几点,下面是CarAccelerate()方法的新迭代:

public void Accelerate(int delta)
{
  // If the car is dead, fire Exploded event.
  if (_carIsDead)
  {
    Exploded?.Invoke("Sorry, this car is dead...");
  }
  else
  {
    CurrentSpeed += delta;

    // Almost dead?
    if (10 == MaxSpeed - CurrentSpeed)
    {
      AboutToBlow?.Invoke("Careful buddy! Gonna blow!");
    }

    // Still OK!
    if (CurrentSpeed >= MaxSpeed)
    {
      _carIsDead = true;
    }
    else
    {
      Console.WriteLine("CurrentSpeed = {0}", CurrentSpeed);
    }
  }
}

至此,您已经配置了 car 来发送两个定制事件,而不必定义定制注册函数或声明委托成员变量。您将很快看到这种新汽车的用法,但首先让我们更详细地检查一下事件架构。

幕后事件

当编译器处理 C# event关键字时,它会生成两个隐藏方法,一个有一个add_前缀,另一个有一个remove_前缀。每个前缀后跟 C# 事件的名称。例如,Exploded事件产生了两个名为add_Exploded()remove_Exploded()的隐藏方法。如果您要查看add_AboutToBlow()后面的 CIL 指令,您会发现对Delegate.Combine()方法的调用。考虑部分 CIL 码:

.method public hidebysig specialname instance void  add_AboutToBlow(
  class [System.Runtime]System.EventHandler`1<class CarEvents.CarEventArgs> 'value') cil managed
  {
...
    IL_000b: call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Combine(class [System.Runtime]System.Delegate, class [System.Runtime]System.Delegate)
...
  } // end of method Car::add_AboutToBlow

正如您所料,remove_AboutToBlow()将代表您调用Delegate.Remove()

.method public hidebysig specialname instance void  remove_AboutToBlow (
  class [System.Runtime]System.EventHandler`1<class CarEvents.CarEventArgs> 'value') cil managed
  {
...
    IL_000b:  call class [System.Runtime]System.Delegate [System.Runtime]System.Delegate::Remove(class [System.Runtime]System.Delegate, class [System.Runtime]System.Delegate)
...
}

最后,代表事件本身的 CIL 代码使用.addon.removeon指令来映射要调用的正确的add_XXX()remove_XXX()方法的名称。

.event class [System.Runtime]System.EventHandler`1<class CarEvents.CarEventArgs> AboutToBlow
{
  .addon instance void CarEvents.Car::add_AboutToBlow(
    class [System.Runtime]System.EventHandler`1<class CarEvents.CarEventArgs>)
  .removeon instance void CarEvents.Car::remove_AboutToBlow(
    class [System.Runtime]System.EventHandler`1<class CarEvents.CarEventArgs>)
} // end of event Car::AboutToBlow

既然您已经理解了如何构建一个可以发送 C# 事件的类(并且意识到事件只不过是一个节省键入时间的工具),下一个大问题就是如何在调用者端监听传入的事件。

监听传入事件

C# 事件还简化了注册调用方事件处理程序的操作。调用者不必指定定制的助手方法,而是直接使用+=-=操作符(这将在后台触发正确的add_XXX()remove_XXX()方法)。当您想要注册某个事件时,请遵循此处显示的模式:

// NameOfObject.NameOfEvent +=
//    new RelatedDelegate(functionToCall);
//
Car.CarEngineHandler d =
  new Car.CarEngineHandler(CarExplodedEventHandler);
myCar.Exploded += d;

当您想要从事件源分离时,使用-=操作符,使用以下模式:

// NameOfObject.NameOfEvent -=
//  new RelatedDelegate(functionToCall);
//
myCar.Exploded -= d;

请注意,您也可以对事件使用方法组转换语法:

Car.CarEngineHandler d = CarExplodedEventHandler;
myCar.Exploded += d;

给定这些非常可预测的模式,下面是重构后的calling code,现在使用 C# 事件注册语法:

Console.WriteLine("***** Fun with Events *****\n");
Car c1 = new Car("SlugBug", 100, 10);

// Register event handlers.
c1.AboutToBlow += CarIsAlmostDoomed;
c1.AboutToBlow += CarAboutToBlow;

Car.CarEngineHandler d = CarExploded;
c1.Exploded += d;

Console.WriteLine("***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}

// Remove CarExploded method
// from invocation list.
c1.Exploded -= d;

Console.WriteLine("\n***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}
Console.ReadLine();

static void CarAboutToBlow(string msg)
{
  Console.WriteLine(msg);
}

static void CarIsAlmostDoomed(string msg)
{
  Console.WriteLine("=> Critical Message from Car: {0}", msg);
}

static void CarExploded(string msg)
{
  Console.WriteLine(msg);
}

使用 Visual Studio 简化事件注册

Visual Studio 帮助注册事件处理程序。当您在事件注册期间应用+=语法时,您会发现显示了一个智能感知窗口,邀请您点击 Tab 键来自动完成关联的委托实例(参见图 12-1 ,它是使用方法组转换语法捕获的。

img/340876_10_En_12_Fig1_HTML.jpg

图 12-1。

委托选择智能感知

点击 Tab 键后,IDE 会自动生成新方法,如图 12-2 所示。

img/340876_10_En_12_Fig2_HTML.jpg

图 12-2。

委托目标格式智能感知

注意存根代码是委托目标的正确格式(注意这个方法已经被声明为静态的,因为事件是在静态方法中注册的)。

static void NewCar_AboutToBlow(string msg)
{
  throw new NotImplementedException();
}

所有人都可以使用智能感知。NET 核心事件、自定义事件和基类库中的所有事件。这个 IDE 特性可以节省大量的时间,因为它使您不必搜索帮助系统来确定事件要使用的正确委托以及委托目标方法的格式。

创建自定义事件参数

说实话,你可以对当前的Car类做最后一个增强,它反映了微软推荐的事件模式。当你开始研究基类库中给定类型发送的事件时,你会发现底层委托的第一个参数是一个System.Object,而第二个参数是System.EventArgs的后代。

System.Object参数表示对发送事件的对象的引用(比如Car),而第二个参数表示关于当前事件的信息。System.EventArgs基类表示不发送任何自定义信息的事件。

public class EventArgs
{
  public static readonly EventArgs Empty;
  public EventArgs();
}

对于简单的事件,可以直接传递一个EventArgs的实例。然而,当您想要传递自定义数据时,您应该构建一个从EventArgs派生的合适的类。对于这个例子,假设您有一个名为CarEventArgs的类,它维护一个表示发送给接收者的消息的字符串。

using System;

namespace CarEvents
{
  public class CarEventArgs : EventArgs
  {
    public readonly string msg;
    public CarEventArgs(string message)
    {
      msg = message;
    }
  }
}

这样,您现在可以如下更新CarEngineHandler委托类型定义(事件将保持不变):

public class Car
{
  public delegate void CarEngineHandler(object sender, CarEventArgs e);
...
}

这里,当从Accelerate()方法中触发事件时,您现在需要提供一个对当前Car(通过this关键字)的引用和一个CarEventArgs类型的实例。例如,考虑以下部分更新:

public void Accelerate(int delta)
{
  // If the car is dead, fire Exploded event.
  if (carIsDead)
  {
    Exploded?.Invoke(this, new CarEventArgs("Sorry, this car is dead..."));
  }
...
}

在调用者端,您需要做的就是更新您的事件处理程序来接收传入的参数并通过只读字段获取消息。这里有一个例子:

static void CarAboutToBlow(object sender, CarEventArgs e)
{
  Console.WriteLine($"{sender} says: {e.msg}");
}

如果接收者想要与发送事件的对象交互,可以显式地强制转换System.Object。从这个引用中,您可以利用发送事件通知的对象的任何公共成员。

static void CarAboutToBlow(object sender, CarEventArgs e)
{
  // Just to be safe, perform a
  // runtime check before casting.
  if (sender is Car c)
  {
    Console.WriteLine(
      $"Critical Message from {c.PetName}: {e.msg}");
  }
}

通用 EventHandler 委托

鉴于如此多的自定义委托将一个object作为第一个参数,将一个EventArgs后代作为第二个参数,您可以通过使用通用的EventHandler<T>类型来进一步简化前面的示例,其中T是您的自定义EventArgs类型。考虑下面对Car类型的更新(注意您不再需要定义自定义委托类型):

public class Car
{
...
  public event EventHandler<CarEventArgs> Exploded;
  public event EventHandler<CarEventArgs> AboutToBlow;
}

然后,调用代码可以在之前指定了CarEventHandler的任何地方使用EventHandler<CarEventArgs>(或者,再次使用方法组转换)。

Console.WriteLine("***** Prim and Proper Events *****\n");

// Make a car as usual.
Car c1 = new Car("SlugBug", 100, 10);

// Register event handlers.
c1.AboutToBlow += CarIsAlmostDoomed;
c1.AboutToBlow += CarAboutToBlow;

EventHandler<CarEventArgs> d = CarExploded;
c1.Exploded += d;
...

太好了。至此,您已经看到了在 C# 语言中使用委托和事件的核心方面。虽然您可以使用这些信息来满足所有的回调需求,但是在本章结束时,您将会看到一些最终的简化,特别是匿名方法和 lambda 表达式。

了解 C# 匿名方法

正如您所看到的,当调用者想要监听传入事件时,它必须在一个类(或结构)中定义一个自定义方法,该方法与相关委托的签名相匹配。这里有一个例子:

SomeType t = new SomeType();

// Assume "SomeDelegate" can point to methods taking no
// args and returning void.
t.SomeEvent += new SomeDelegate(MyEventHandler);

// Typically only called by the SomeDelegate object.
static void MyEventHandler()
{
  // Do something when event is fired.
}

然而,仔细想想,像MyEventHandler()这样的方法很少会被程序中除了调用委托之外的任何部分调用。就生产效率而言,手动定义一个由委托对象调用的单独方法有点麻烦(尽管这绝不是一个阻碍)。

为了解决这一点,可以在事件注册时将事件直接关联到代码语句块。形式上,这样的代码被称为匿名方法。为了说明语法,首先创建一个名为 AnonymousMethods 的新控制台应用,并将 CarEvents 项目中的Car.csCarEventArgs.cs类复制到新项目中(确保将它们的名称空间更改为AnonymousMethods)。更新Program.cs文件的代码以匹配下面的代码,它使用匿名方法处理从Car类发送的事件,而不是专门命名的事件处理程序:

using System;
using AnonymousMethods;

Console.WriteLine("***** Anonymous Methods *****\n");
Car c1 = new Car("SlugBug", 100, 10);

// Register event handlers as anonymous methods.
c1.AboutToBlow += delegate
{
  Console.WriteLine("Eek! Going too fast!");
};

c1.AboutToBlow += delegate(object sender, CarEventArgs e)
{
  Console.WriteLine("Message from Car: {0}", e.msg);
};

c1.Exploded += delegate(object sender, CarEventArgs e)
{
  Console.WriteLine("Fatal Message from Car: {0}", e.msg);
};

// This will eventually trigger the events.
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}
Console.ReadLine();

Note

匿名方法的最后一个花括号必须以分号结束。如果不这样做,就会出现编译错误。

再次注意,调用代码不再需要定义特定的静态事件处理程序,比如CarAboutToBlow()CarExploded()。相反,在调用者使用+=语法处理事件时,未命名(又名匿名)方法被内联定义。匿名方法的基本语法与以下伪代码相匹配:

SomeType t = new SomeType();
t.SomeEvent += delegate (optionallySpecifiedDelegateArgs)
{ /* statements */ };

当处理前一个代码示例中的第一个AboutToBlow事件时,请注意,您没有指定从委托传递的参数。

c1.AboutToBlow += delegate
{
  Console.WriteLine("Eek! Going too fast!");
};

严格地说,您不需要接收特定事件发送的传入参数。但是,如果您想要利用可能的传入参数,您将需要指定由委托类型原型化的参数(如第二个对AboutToBlowExploded事件的处理所示)。这里有一个例子:

c1.AboutToBlow += delegate(object sender, CarEventArgs e)
{
  Console.WriteLine("Critical Message from Car: {0}", e.msg);
};

访问局部变量

匿名方法很有趣,因为它们可以访问定义它们的方法的局部变量。从形式上讲,这样的变量被称为匿名方法的外部变量。关于匿名方法范围和定义方法范围之间的交互,应该提到以下要点:

  • 匿名方法不能访问定义方法的refout参数。

  • 匿名方法中的局部变量不能与外部方法中的局部变量同名。

  • 匿名方法可以访问外部类范围内的实例变量(或静态变量,视情况而定)。

  • 匿名方法可以声明与外部类成员变量同名的局部变量(局部变量具有不同的范围并隐藏外部类成员变量)。

假设您的顶级语句定义了一个名为aboutToBlowCounter的局部整数。在处理AboutToBlow事件的匿名方法中,您将使这个计数器加 1,并在语句完成之前打印出计数。

Console.WriteLine("***** Anonymous Methods *****\n");
int aboutToBlowCounter = 0;

// Make a car as usual.
Car c1 = new Car("SlugBug", 100, 10);

// Register event handlers as anonymous methods.
c1.AboutToBlow += delegate
{
  aboutToBlowCounter++;
  Console.WriteLine("Eek! Going too fast!");
};

c1.AboutToBlow += delegate(object sender, CarEventArgs e)
{
  aboutToBlowCounter++;
  Console.WriteLine("Critical Message from Car: {0}", e.msg);
};
...
// This will eventually trigger the events.
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}

Console.WriteLine("AboutToBlow event was fired {0} times.",
  aboutToBlowCounter);
Console.ReadLine();

运行更新后的代码后,您会发现最后的Console.WriteLine()报告了AboutToBlow事件被触发了两次。

使用静态和匿名方法(新 9.0)

前面的例子演示了匿名方法与方法本身范围之外声明的变量进行交互。虽然这可能是您想要的,但它破坏了封装,并可能在您的程序中引入意想不到的副作用。回想一下第四章,通过将局部函数设置为静态,可以将它们从包含代码中分离出来,如下例所示:

static int AddWrapperWithStatic(int x, int y)
{
  //Do some validation here
  return Add(x,y);
  static int Add(int x, int y)
  {
    return x + y;
  }
}

C# 9.0 中新增的匿名方法也可以标记为静态,以保持封装性,并确保该方法不会给包含它的代码带来任何副作用。例如,请参见此处更新的匿名方法:

c1.AboutToBlow += static delegate
{
  //This causes a compile error because it is marked static
  aboutToBlowCounter++;
  Console.WriteLine("Eek! Going too fast!");
};

由于匿名方法试图访问在其范围之外声明的变量,上述代码将无法编译。

用匿名方法丢弃(新 9.0)

在第三章中介绍的丢弃,已经在 C# 9.0 中更新,作为匿名方法的输入参数,带有一个 catch。因为下划线(_)在以前版本的 C# 中是一个合法的变量标识符,所以必须有两个或更多与匿名方法一起使用的丢弃才会被视为丢弃。

例如,下面的代码为一个接受两个整数并返回另一个整数的Func创建了一个委托。该实现忽略任何传入的变量,并返回 42:

Console.WriteLine("******** Discards with Anonymous Methods ********");

Func<int,int,int> constant = delegate (int _, int _) {return 42;};
Console.WriteLine("constant(3,4)={0}",constant(3,4));

理解 Lambda 表达式

以此来结束您对。NET 核心事件架构,您将研究 C# lambda 表达式。正如刚才所解释的,C# 支持“内联”处理事件的能力,方法是使用匿名方法将一组代码语句直接分配给一个事件,而不是构建一个由底层委托调用的独立方法。Lambda 表达式只不过是一种简洁的方式来创作匿名方法,并最终简化您使用。NET 核心委托类型。

要为 lambda 表达式的检查做准备,请创建一个名为 lambda expressions 的新控制台应用项目。首先,考虑泛型List<T>类的FindAll()方法。当您需要从集合中提取项目的子集时,可以调用此方法,其原型如下:

// Method of the System.Collections.Generic.List<T>
public List<T> FindAll(Predicate<T> match)

正如您所看到的,这个方法返回了一个新的代表数据子集的List<T>。还要注意的是,FindAll()的唯一参数是一个类型为System.Predicate<T>的泛型委托。这个委托类型可以指向任何返回一个bool的方法,并将一个类型参数作为唯一的输入参数。

// This delegate is used by FindAll() method
// to extract out the subset.
public delegate bool Predicate<T>(T obj);

当你调用FindAll()时,List<T>中的每一项都被传递给Predicate<T>对象所指向的方法。所述方法的实现将执行一些计算,以查看传入的数据是否匹配必要的标准,并将返回truefalse。如果这个方法返回true,这个条目将被添加到新的代表子集的List<T>中(明白了吗?).

在您看到 lambda 表达式如何简化使用FindAll()之前,让我们直接使用委托对象,用手写符号来解决这个问题。在您的Program类型中添加一个与System.Predicate<T>类型交互的方法(名为TraditionalDelegateSyntax()),以发现整数的List<T>中的偶数。

using System;
using System.Collections.Generic;
using LambdaExpressions;

Console.WriteLine("***** Fun with Lambdas *****\n");
TraditionalDelegateSyntax();
Console.ReadLine();

static void TraditionalDelegateSyntax()
{
  // Make a list of integers.
  List<int> list = new List<int>();
  list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });

  // Call FindAll() using traditional delegate syntax.
  Predicate<int> callback = IsEvenNumber;
  List<int> evenNumbers = list.FindAll(callback);

  Console.WriteLine("Here are your even numbers:");
  foreach (int evenNumber in evenNumbers)
  {
    Console.Write("{0}\t", evenNumber);
  }
  Console.WriteLine();
}

// Target for the Predicate<> delegate.
static bool IsEvenNumber(int i)
{
  // Is it an even number?
  return (i % 2) == 0;
}

这里,您有一个方法(IsEvenNumber()),它通过 C# 模操作符%监督测试传入的整数参数,以查看它是偶数还是奇数。如果您执行您的应用,您会发现数字 20、4、8 和 44 打印到控制台。

虽然这种使用委托的传统方法如预期的那样工作,但是只有在有限的情况下才会调用IsEvenNumber()方法——特别是当您调用FindAll()时,这会给您留下一个完整方法定义的包袱。虽然您可以将它作为一个局部函数,但是如果您使用匿名方法,您的代码将会清理得相当干净。考虑下面这个Program类的新方法:

static void AnonymousMethodSyntax()
{
  // Make a list of integers.
  List<int> list = new List<int>();
  list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });

  // Now, use an anonymous method.
  List<int> evenNumbers =
    list.FindAll(delegate(int i) { return (i % 2) == 0; } );

  Console.WriteLine("Here are your even numbers:");
  foreach (int evenNumber in evenNumbers)
  {
    Console.Write("{0}\t", evenNumber);
  }
  Console.WriteLine();
}

在这种情况下,您可以匿名内联一个方法,而不是直接创建一个Predicate<T>委托对象,然后创作一个独立的方法。虽然这是朝着正确方向迈出的一步,但是仍然需要使用关键字delegate(或者强类型的Predicate<T>),并且必须确保参数列表是完全匹配的。

List<int> evenNumbers = list.FindAll(
  delegate(int i)
  {
    return (i % 2) == 0;
  }
);

λ表达式可以用来进一步简化对FindAll()的调用。当您使用 lambda 语法时,根本没有任何底层委托对象的痕迹。考虑下面对Program类的新方法:

static void LambdaExpressionSyntax()
{
  // Make a list of integers.
  List<int> list = new List<int>();
  list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });

  // Now, use a C# lambda expression.
  List<int> evenNumbers = list.FindAll(i => (i % 2) == 0);

  Console.WriteLine("Here are your even numbers:");
  foreach (int evenNumber in evenNumbers)
  {
    Console.Write("{0}\t", evenNumber);
  }
  Console.WriteLine();
}

在这种情况下,请注意传递给FindAll()方法的代码语句,它实际上是一个 lambda 表达式。在这个例子的迭代中,没有任何关于Predicate<T>委托(或者delegate关键字)的痕迹。您所指定的只是 lambda 表达式。

i => (i % 2) == 0

在我分解这个语法之前,首先要理解 lambda 表达式可以用在任何使用匿名方法或强类型委托的地方(通常击键次数少得多)。在幕后,C# 编译器利用Predicate<T>委托类型(可以使用ildasm.exereflector.exe来验证)将表达式翻译成标准的匿名方法。具体来说,下面的代码语句:

// This lambda expression...
List<int> evenNumbers = list.FindAll(i => (i % 2) == 0);

被编译成如下近似的 C# 代码:

// ...becomes this anonymous method.
List<int> evenNumbers = list.FindAll(delegate (int i)
{
  return (i % 2) == 0;
});

剖析 Lambda 表达式

lambda 表达式是这样编写的:首先定义一个参数列表,然后是=>标记(在 lambda 演算中找到的 lambda 运算符的 C# 标记),然后是一组将处理这些参数的语句(或单个语句)。从高层次来看,lambda 表达式可以理解为:

ArgumentsToProcess => StatementsToProcessThem

LambdaExpressionSyntax()方法中,事情是这样分解的:

// "i" is our parameter list.
// "(i % 2) == 0" is our statement set to process "i".
List<int> evenNumbers = list.FindAll(i => (i % 2) == 0);

lambda 表达式的参数可以显式或隐式类型化。目前,代表i参数(整数)的底层数据类型是隐式确定的。编译器可以根据 lambda 表达式和底层委托的上下文判断出i是一个整数。但是,也可以通过将数据类型和变量名放在一对括号中来显式定义表达式中每个参数的类型,如下所示:

// Now, explicitly state the parameter type.
List<int> evenNumbers = list.FindAll((int i) => (i % 2) == 0);

正如你所看到的,如果一个 lambda 表达式只有一个隐式类型的参数,那么圆括号可以从参数列表中省略。如果你想在 lambda 参数的使用上保持一致,你可以总是将参数列表放在括号内,留给你这个表达式:

List<int> evenNumbers = list.FindAll((i) => (i % 2) == 0);

最后,请注意,当前表达式没有用括号括起来(当然,您已经将 modulo 语句括起来,以确保它在相等测试之前首先执行)。Lambda 表达式允许语句包装如下:

// Now, wrap the expression as well.
List<int> evenNumbers = list.FindAll((i) => ((i % 2) == 0));

既然您已经看到了构建 lambda 表达式的各种方法,那么您如何以人类友好的方式阅读这个 lambda 语句呢?抛开原始的数学,下面的解释符合这个要求:

// My list of parameters (in this case, a single integer named i)
// will be processed by the expression (i % 2) == 0.
List<int> evenNumbers = list.FindAll((i) => ((i % 2) == 0));

处理多条语句中的参数

第一个 lambda 表达式是一个最终计算为布尔值的语句。然而,如您所知,许多委托目标必须执行几个代码语句。出于这个原因,C# 允许您通过使用标准花括号指定代码块来构建包含多个语句的 lambda 表达式。考虑以下对LambdaExpressionSyntax()方法的示例更新:

static void LambdaExpressionSyntax()
{
  // Make a list of integers.
  List<int> list = new List<int>();
  list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });

  // Now process each argument within a group of
  // code statements.
  List<int> evenNumbers = list.FindAll((i) =>
  {
    Console.WriteLine("value of i is currently: {0}", i);
    bool isEven = ((i % 2) == 0);
    return isEven;
  });

  Console.WriteLine("Here are your even numbers:");
  foreach (int evenNumber in evenNumbers)
  {
    Console.Write("{0}\t", evenNumber);
  }
  Console.WriteLine();
}

在这种情况下,参数列表(同样是一个名为i的整数)由一组代码语句处理。除了对Console.WriteLine()的调用,为了增加可读性,modulo 语句被分成了两个代码语句。假设您在本节中看到的每个方法都是从顶级语句中调用的:

Console.WriteLine("***** Fun with Lambdas *****\n");
TraditionalDelegateSyntax();
AnonymousMethodSyntax();
Console.WriteLine();
LambdaExpressionSyntax();
Console.ReadLine();

您会发现以下输出:

***** Fun with Lambdas *****
Here are your even numbers:
20      4       8       44
Here are your even numbers:
20      4       8       44
value of i is currently: 20
value of i is currently: 1
value of i is currently: 4
value of i is currently: 8
value of i is currently: 9
value of i is currently: 44
Here are your even numbers:
20      4       8       44

具有多个(或零个)参数的 Lambda 表达式

到目前为止,你在本章中看到的 lambda 表达式只处理了一个参数。然而,这不是必需的,因为 lambda 表达式可以处理多个参数(或者一个都不处理)。为了说明多参数的第一种情况,添加下面的SimpleMath类型实例:

public class SimpleMath
{
  public delegate void MathMessage(string msg, int result);
  private MathMessage _mmDelegate;

  public void SetMathHandler(MathMessage target)
  {
    _mmDelegate = target;
  }

  public void Add(int x, int y)
  {
    _mmDelegate?.Invoke("Adding has completed!", x + y);
  }
}

请注意,MathMessage委托类型需要两个参数。为了将它们表示为 lambda 表达式,可以将Main()方法编写如下:

// Register with delegate as a lambda expression.
SimpleMath m = new SimpleMath();
m.SetMathHandler((msg, result) =>
  {Console.WriteLine("Message: {0}, Result: {1}", msg, result);});

// This will execute the lambda expression.
m.Add(10, 10);
Console.ReadLine();

这里,您利用了类型推断,因为为了简单起见,这两个参数没有被强类型化。但是,您可以调用SetMathHandler(),如下所示:

m.SetMathHandler((string msg, int result) =>
  {Console.WriteLine("Message: {0}, Result: {1}", msg, result);});

最后,如果使用 lambda 表达式与不带任何参数的委托进行交互,可以通过提供一对空括号作为参数来实现。因此,假设您已经定义了以下委托类型:

public delegate string VerySimpleDelegate();

您可以按如下方式处理调用的结果:

// Prints "Enjoy your string!" to the console.
VerySimpleDelegate d = new VerySimpleDelegate( () => {return "Enjoy your string!";} );
Console.WriteLine(d());

使用新的表达式语法,前一行可以写成这样:

VerySimpleDelegate d2 =
  new VerySimpleDelegate(() => "Enjoy your string!");

也可以简化为:

VerySimpleDelegate d3 = () => "Enjoy your string!";

在 Lambda 表达式中使用 static(新 9.0)

因为 lambda 表达式是委托的简写,所以可以理解 lambda 也支持static关键字(在 C# 9.0 中)和丢弃(在下一节讨论)。将以下内容添加到顶级语句中:

var outerVariable = 0;

Func<int, int, bool> DoWork = (x,y) =>
{
  outerVariable++;
  return true;
};
DoWork(3,4);
Console.WriteLine("Outer variable now = {0}", outerVariable);

执行此代码时,它会输出以下内容:

***** Fun with Lambdas *****

Outer variable now = 1

如果将 lambda 更新为 static,将会收到一个编译错误,因为表达式试图更新外部作用域中声明的变量。

var outerVariable = 0;

Func<int, int, bool> DoWork = static (x,y) =>
{
 //Compile error since it’s accessing an outer variable
  //outerVariable++;
  return true;
};

用 Lambda 表达式丢弃(新 9.0)

与委托(和 C# 9.0)一样,如果不需要输入变量,lambda 表达式的输入变量可以用丢弃来替换。与委托的情况相同。因为下划线(_)在以前版本的 C# 中是合法的变量标识符,所以它们必须在 lambda 表达式中被丢弃两次或更多次。

var outerVariable = 0;

Func<int, int, bool> DoWork = (x,y) =>
{
  outerVariable++;
  return true;
};
DoWork(_,_);
Console.WriteLine("Outer variable now = {0}", outerVariable);

使用 lambda 表达式改进汽车事件示例

考虑到 lambda 表达式的全部原因是提供一种干净、简洁的方式来定义一个匿名方法(从而间接地简化委托的工作),让我们改进本章前面创建的CarEventArgs项目。下面是该项目的Program类的简化版本,它利用 lambda 表达式语法(而不是原始委托)来挂钩从Car对象发送的每个事件:

using System;
using CarEventsWithLambdas;

Console.WriteLine("***** More Fun with Lambdas *****\n");

// Make a car as usual.
Car c1 = new Car("SlugBug", 100, 10);

// Hook into events with lambdas!
c1.AboutToBlow += (sender, e)
  => { Console.WriteLine(e.msg);};
c1.Exploded += (sender, e) => { Console.WriteLine(e.msg); };

// Speed up (this will generate the events).
Console.WriteLine("\n***** Speeding up *****");
for (int i = 0; i < 6; i++)
{
  c1.Accelerate(20);
}
Console.ReadLine();

Lambdas 和 Expression-body 成员(更新 7.0)

既然您已经理解了 lambda 表达式及其工作原理,那么显而易见的是表达式体成员是如何工作的。正如第四章提到的,从 C# 6 开始,允许使用=>操作符来简化成员实现。具体来说,如果您有一个方法或属性(除了自定义运算符或转换例程之外;参见第十一章)在实现中只包含一行代码,您不需要通过花括号定义作用域。相反,您可以利用 lambda 运算符并编写一个表达式体成员。在 C# 7 中,你也可以对类构造器、终结器(在第九章中讨论)以及属性成员的getset访问器使用这种语法。

但是,请注意,这种新的简化语法可以在任何地方使用,即使您的代码与委托或事件无关。因此,例如,如果您要构建一个简单的类来添加两个数字,您可以编写以下代码:

class SimpleMath
{
  public int Add(int x, int y)
  {
    return x + y;
  }

  public void PrintSum(int x, int y)
  {
    Console.WriteLine(x + y);
  }
}

或者,您现在可以编写如下代码:

class SimpleMath
{
  public int Add(int x, int y) =>  x + y;
  public void PrintSum(int x, int y) => Console.WriteLine(x + y);
}

理想情况下,在这一点上,您可以看到 lambda 表达式的整体作用,并理解它们如何提供一种“函数方式”来处理匿名方法和委托类型。尽管 lambda 运算符(=>)可能需要一点时间来适应,但请记住,lambda 表达式可以分解为以下简单的等式:

ArgumentsToProcess =>
{
  //StatementsToProcessThem
}

或者,如果使用=>操作符来实现单行类型成员,它将是这样的:

TypeMember => SingleCodeStatement

值得指出的是,LINQ 编程模型也大量使用 lambda 表达式来帮助简化您的编码工作。你将从第十三章开始研究 LINQ。

摘要

在本章中,您研究了多个对象参与双向对话的几种方式。首先,您查看了 C# delegate关键字,该关键字用于间接构造从System.MulticastDelegate派生的类。如您所见,委托对象在被告知调用方法时会维护该方法。

然后研究了 C# event关键字,当它与委托类型结合使用时,可以简化将事件通知发送给等待调用方的过程。如生成的 CIL 所示。NET 事件模型映射到System.Delegate / System.MulticastDelegate类型的隐藏调用。在这种情况下,C# event关键字完全是可选的,因为它只是为您节省了一些键入时间。同样,您已经看到 C# 6.0 空条件操作符简化了您如何安全地向任何感兴趣的一方触发事件。

本章还探讨了 C# 语言的一个特性,称为匿名方法。使用这种语法结构,您可以将代码语句块直接关联到给定的事件。正如您所看到的,匿名方法可以忽略事件发送的参数,并可以访问定义方法的“外部变量”。您还研究了使用方法组转换注册事件的简化方法。

最后,通过查看 C# lambda 操作符=>来总结一下。如图所示,这种语法是创作匿名方法的一种很好的速记符号,其中可以将一堆参数传递给一组语句进行处理。中的任何方法。NET 核心平台可以用一个相关的 lambda 表达式来代替,这通常会大大简化你的代码库。

十三、LINQToObj

无论您使用。NET 核心平台,您的程序在执行时肯定需要访问某种形式的数据。可以肯定的是,数据可以在很多地方找到,包括 XML 文件、关系数据库、内存集合和原始数组。从历史上来说,基于所述数据的位置,程序员需要使用不同的和不相关的 API。语言集成查询(LINQ)技术集,最初在。NET 3.5 提供了一种简洁、对称和强类型的方式来访问各种各样的数据存储。在这一章中,你将通过关注 LINQ 来开始你对 LINQ 的调查。

在深入 LINQ 到对象本身之前,本章的第一部分快速回顾了支持 LINQ 的关键 C# 编程结构。当你阅读本章时,你会发现隐式类型的局部变量、对象初始化语法、lambda 表达式、扩展方法和匿名类型将会非常有用(如果不是偶尔强制的话)。

在回顾了这个支持基础结构之后,本章的剩余部分将向您介绍 LINQ 编程模型及其在。NET 平台。在这里,您将学习查询操作符和查询表达式的作用,它们允许您定义查询数据源以产生请求的结果集的语句。在这个过程中,您将构建许多与数组中包含的数据以及各种集合类型(泛型和非泛型)进行交互的 LINQ 示例,并理解表示 LINQ 到对象 API 的程序集、命名空间和类型。

Note

本章中的信息是本书以后章节的基础,包括并行 LINQ(第十五章)和实体框架核心(第 22 和 23 章)。

特定于 LINQ 的编程结构

从高层次来看,LINQ 可以理解为一种强类型查询语言,直接嵌入到 C# 的语法中。使用 LINQ,您可以构建任意数量的表达式,其外观和感觉都像数据库 SQL 查询。然而,LINQ 查询可以应用于任何数量的数据存储,包括与文字关系数据库无关的存储。

Note

尽管 LINQ 查询看起来类似于 SQL 查询,但是语法是相同的。事实上,许多 LINQ 查询似乎是一个类似的数据库查询的完全相反的格式!如果您试图将 LINQ 直接映射到 SQL,您肯定会感到沮丧。为了保持理智,我建议您尽最大努力将 LINQ 查询视为唯一的语句,它只是“碰巧看起来”像 SQL。

当 LINQ 第一次被介绍给。NET 平台的 3.5 版本中,C# 和 VB 语言都扩展了许多新的编程结构,用于支持 LINQ 技术集。具体来说,C# 语言使用以下以 LINQ 为中心的核心功能:

  • 隐式类型的局部变量

  • 对象/集合初始化语法

  • λ表达式

  • 扩展方法

  • 匿名类型

这些特征已经在文本的各个章节中详细探讨过了。然而,为了开始,让我们快速地依次回顾一下每个特性,以确保我们都处于正确的心态。

Note

因为接下来的部分是对本书其他地方的内容的回顾,所以我没有为这些内容包含 C# 代码项目。

局部变量的隐式类型化

在第三章中,你学习了 C# 的var关键字。该关键字允许您定义局部变量,而无需显式指定基础数据类型。但是,该变量是强类型的,因为编译器将根据初始赋值确定正确的数据类型。回想一下第三章中的代码示例:

static void DeclareImplicitVars()
{
  // Implicitly typed local variables.
  var myInt = 0;
  var myBool = true;
  var myString = "Time, marches on...";

  // Print out the underlying type.
  Console.WriteLine("myInt is a: {0}", myInt.GetType().Name);
  Console.WriteLine("myBool is a: {0}",
    myBool.GetType().Name);
  Console.WriteLine("myString is a: {0}",
    myString.GetType().Name);
}

在使用 LINQ 时,这种语言特性很有帮助,而且通常是强制性的。正如您将在本章中看到的,许多 LINQ 查询将返回一系列数据类型,这些数据类型直到编译时才知道。考虑到在编译应用之前不知道底层数据类型,显然不能显式声明变量!

对象和集合初始化语法

第五章探讨了对象初始化语法的作用,它允许你创建一个类或结构变量,并一次性设置任意数量的公共属性。结果是一个紧凑的(但仍然很容易看)语法,可以用来让您的对象准备好使用。还记得第九章的内容吗,C# 语言允许你使用类似的语法来初始化对象集合。考虑下面的代码片段,它使用集合初始化语法来填充一个Rectangle对象的List<T>,每个对象维护两个Point对象来表示一个(x,y)位置:

List<Rectangle> myListOfRects = new List<Rectangle>
{
  new Rectangle {TopLeft = new Point { X = 10, Y = 10 },
                 BottomRight = new Point { X = 200, Y = 200}},
  new Rectangle {TopLeft = new Point { X = 2, Y = 2 },
                 BottomRight = new Point { X = 100, Y = 100}},
  new Rectangle {TopLeft = new Point { X = 5, Y = 5 },
                 BottomRight = new Point { X = 90, Y = 75}}
};

虽然您从来不需要使用集合/对象初始化语法,但是这样做可以产生更紧凑的代码库。此外,当与局部变量的隐式类型化结合使用时,这种语法允许您声明一个匿名类型,这在创建 LINQ 投影时很有用。在本章的后面你会学到 LINQ 投影。

λ表达式

C# lambda 操作符(=>)在第十二章中有充分的探讨。回想一下,这个操作符允许您构建 lambda 表达式,只要您调用需要强类型委托作为参数的方法,就可以使用这个表达式。Lambdas 极大地简化了您使用委托的方式,因为它们减少了您必须手工创作的代码量。回想一下,lambda 表达式可以分解为以下用法:

( ArgumentsToProcess ) => { StatementsToProcessThem }

在第十二章中,我用三种不同的方法向你展示了如何与泛型List<T>类的FindAll()方法进行交互。在使用了原始的Predicate<T>委托和一个 C# 匿名方法之后,您最终用这个 lambda 表达式实现了下面这个(非常简洁的)迭代:

static void LambdaExpressionSyntax()
{
  // Make a list of integers.
  List<int> list = new List<int>();
  list.AddRange(new int[] { 20, 1, 4, 8, 9, 44 });

  // C# lambda expression.
  List<int> evenNumbers = list.FindAll(i => (i % 2) == 0);

  Console.WriteLine("Here are your even numbers:");
  foreach (int evenNumber in evenNumbers)
  {
    Console.Write("{0}\t", evenNumber);
  }
  Console.WriteLine();
}

当使用 LINQ 的底层对象模型时,Lambdas 会很有用。您很快就会发现,C# LINQ 查询操作符只是一种在名为System.Linq.Enumerable的类上调用可靠方法的简写符号。这些方法通常需要委托(特别是Func<>委托)作为参数,用于处理数据以产生正确的结果集。使用 lambdas,您可以简化代码,并允许编译器推断底层委托。

扩展方法

C# 扩展方法允许你在现有的类上添加新的功能,而不需要子类化。此外,扩展方法允许您向密封的类和结构添加新的功能,这些功能永远不会在第一个位置被子类化。回想一下第十一章中的,当你创建一个扩展方法时,第一个参数用this关键字限定,并标记被扩展的类型。还记得扩展方法必须总是在静态类中定义,因此也必须使用static关键字声明。这里有一个例子:

namespace MyExtensions
{
  static class ObjectExtensions
  {
    // Define an extension method to System.Object.
    public static void DisplayDefiningAssembly(
      this object obj)
    {
      Console.WriteLine("{0} lives here:\n\t->{1}\n", obj.GetType().Name,
        Assembly.GetAssembly(obj.GetType()));
    }
  }
}

若要使用此扩展,应用必须导入定义该扩展的命名空间(并可能添加对外部程序集的引用)。此时,只需导入定义的名称空间和代码。

// Since everything extends System.Object, all classes and structures
// can use this extension.
int myInt = 12345678;
myInt.DisplayDefiningAssembly();

System.Data.DataSet d = new System.Data.DataSet();
d.DisplayDefiningAssembly();

当您使用 LINQ 时,您将很少需要手动构建自己的扩展方法。但是,当您创建 LINQ 查询表达式时,您将会使用微软已经定义的许多扩展方法。事实上,每个 C# LINQ 查询操作符都是对底层扩展方法进行手动调用的简写符号,通常由System.Linq.Enumerable实用程序类定义。

匿名类型

我想快速回顾的最后一个 C# 语言特性是匿名类型,这在第十一章中已经探讨过了。通过允许编译器在编译时基于提供的一组名称-值对生成新的类定义,该特性可用于快速建模数据的“形状”。回想一下,这个类型将使用基于值的语义来组合,并且System.Object的每个虚方法将被相应地覆盖。若要定义匿名类型,请声明一个隐式类型变量,并使用对象初始化语法指定数据的形状。

// Make an anonymous type that is composed of another.
var purchaseItem = new {
  TimeBought = DateTime.Now,
  ItemBought =
    new {Color = "Red", Make = "Saab", CurrentSpeed = 55},
  Price = 34.000};

当你想设计新形式的数据时,LINQ 经常使用匿名类型。例如,假设您有一个Person对象的集合,并想使用 LINQ 来获得每个对象的年龄和社会保险号信息。使用 LINQ 投影,您可以允许编译器生成包含您的信息的新匿名类型。

理解 LINQ 的角色

这就结束了对 C# 语言特性的快速回顾,这些特性让 LINQ 发挥了它的魔力。然而,为什么首先有 LINQ 呢?作为软件开发人员,很难否认大量的编程时间花费在获取和操作数据上。当谈到“数据”时,很容易立即想到关系数据库中包含的信息。然而,数据的另一个流行位置是在 XML 文档或简单的文本文件中。

除了这两个常见的信息之外,还可以在许多地方找到数据。例如,假设您有一个包含 300 个整数的数组或泛型List<T>类型,并且您想要获得一个满足给定标准的子集(例如,容器中只有奇数或偶数成员,只有质数,只有大于 50 的非重复数)。或者,您可能正在利用反射 API,并且只需要获得从一个Type数组中的父类派生的每个类的元数据描述。事实上,数据到处都是*。*

*之前。NET 3.5 中,与各种数据交互需要程序员使用非常多样化的 API。例如,考虑一下表 13-1 ,它说明了几种用于访问各种类型数据的常见 API(我相信您可以想到许多其他的例子)。

表 13-1。

操作各种类型数据的方法

|

你想要的数据

|

如何获得它

关系数据 System.Data.dllSystem.Data.SqlClient.dll等。
XML 文档数据 System.Xml.dll
元数据表 System.Reflection名称空间
对象集合 System.ArraySystem.Collections/System.Collections.Generic名称空间

当然,这些处理数据的方法并没有错。事实上,您可以(也将会)直接使用 ADO.NET、XML 名称空间、反射服务和各种集合类型。然而,基本的问题是这些 API 中的每一个都是一个孤岛,很少提供集成。的确,可以(例如)将 ADO.NETDataSet保存为 XML,然后通过System.Xml名称空间操纵它,但是尽管如此,数据操纵仍然相当不对称。

LINQ API 试图提供一种一致的、对称的方式,程序员可以在广义上获取和操作“数据”。使用 LINQ,你可以在 C# 编程语言中直接创建名为的查询表达式。这些查询表达式基于许多查询操作符,这些操作符被有意设计成看起来和感觉上与 SQL 表达式相似(但不完全相同)。

然而,问题是查询表达式可以用于与多种类型的数据交互,甚至是与关系数据库无关的数据。严格地说,“LINQ”是用来描述这种整体数据访问方法的术语。但是,根据您应用 LINQ 查询的位置,您会遇到各种术语,例如:

  • 对象的 LINQ:这个术语指的是对数组和集合应用 LINQ 查询的行为。

  • LINQ 到 XML :这个术语指的是使用 LINQ 操作和查询 XML 文档的行为。

  • 到实体的 LINQ:LINQ 的这一方面允许您在 ADO.NET 实体框架(EF)核心 API 内使用 LINQ 查询。

  • 并行 LINQ(又名 PLINQ ):这允许并行处理从 LINQ 查询返回的数据。

今天,LINQ 是世界不可分割的一部分。NET 核心基类库、托管语言和 Visual Studio 本身。

LINQ 表达式是强类型的

指出 LINQ 查询表达式(不同于传统的 SQL 语句)是强类型的也很重要。因此,C# 编译器会让你保持诚实,并确保这些表达式的语法格式良好。Visual Studio 等工具可以将元数据用于智能感知、自动完成等有用的功能。

核心 LINQ 组件

要使用 LINQ 对象,必须确保每个包含 LINQ 查询的 C# 代码文件都导入了System.Linq名称空间。确保使用 LINQ 的每个代码文件中都有下面的using语句:

using System.Linq;

将 LINQ 查询应用于原始数组

要开始研究对象的 LINQ,让我们构建一个将 LINQ 查询应用于各种数组对象的应用。创建一个名为 LinqOverArray 的控制台应用项目,并在名为QueryOverStrings()Program类中定义一个静态 helper 方法。在这个方法中,创建一个包含大约六个您喜欢的项目的string数组(这里,我在我的库中列出了一批视频游戏)。确保至少有两个包含数值的条目和几个包含空格的条目。

static void QueryOverStrings()
{
  // Assume we have an array of strings.
  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};
}

现在,更新Program.cs来调用QueryOverStrings()

Console.WriteLine("***** Fun with LINQ to Objects *****\n");
QueryOverStrings();
Console.ReadLine();

当您有任何数据数组时,通常会根据给定的需求提取项目的子集。也许您只想获得包含数字的子项(例如,System Shock 2、Uncharted 2 和辐射 3)、包含一定数量的字符的子项或者不包含嵌入空格的子项(例如,Morrowind 或 Daxter)。虽然您当然可以使用System.Array类型的成员和一些额外的工作来执行这样的任务,但是 LINQ 查询表达式可以大大简化这个过程。

假设您希望从数组中仅获取包含嵌入空格的项目,并且希望这些项目按字母顺序列出,您可以构建以下 LINQ 查询表达式:

static void QueryOverStrings()
{
  // Assume we have an array of strings.
  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  // Build a query expression to find the items in the array
  // that have an embedded space.
  IEnumerable<string> subset =
    from g in currentVideoGames
    where g.Contains(" ")
    orderby g
    select g;

  // Print out the results.
  foreach (string s in subset)
  {
    Console.WriteLine("Item: {0}", s);
  }
}

注意,这里创建的查询表达式使用了frominwhereorderbyselect LINQ 查询操作符。在本章的后面,你将深入研究查询表达式语法的形式。然而,即使是现在,您也应该能够大致将该语句理解为“给我包含空格的currentVideoGames中的项目,按字母顺序排列。”

这里,每个匹配搜索标准的项目都被赋予了名称g(如“game”);然而,任何有效的 C# 变量名都可以。

IEnumerable<string> subset =
  from game in currentVideoGames
  where game.Contains(" ")
  orderby game
  select game;

注意,返回的序列保存在一个名为subset的变量中,该变量的类型是实现通用版本IEnumerable<T>的类型,其中T的类型是System.String(毕竟,您查询的是一个由string组成的数组)。获得结果集后,您只需使用标准的foreach结构打印出每一项。如果运行您的应用,您会发现以下输出:

***** Fun with LINQ to Objects *****
Item: Fallout 3
Item: System Shock 2
Item: Uncharted 2

再次使用扩展方法

前面使用的 LINQ 语法(以及本章的其余部分)被称为 LINQ 查询表达式,这是一种类似于 SQL 但略有不同的格式。还有另一种使用扩展方法的语法,这种语法将在本书的大多数示例中使用。

创建一个名为QueryOverStringsWithExtensionMethods()的新方法,并输入以下代码:

static void QueryOverStringsWithExtensionMethods()
{
  // Assume we have an array of strings.
  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  // Build a query expression to find the items in the array
  // that have an embedded space.
  IEnumerable<string> subset =
    currentVideoGames.Where(g => g.Contains(" ")).OrderBy(g => g).Select(g => g);

  // Print out the results.
  foreach (string s in subset)
  {
    Console.WriteLine("Item: {0}", s);
  }
}

除了用粗体显示的行之外,所有内容都与前面的方法相同。这是使用扩展方法语法。该语法在每个方法中使用 lambda 表达式来定义操作。例如,Where()方法中的 lambda 定义了条件(其中值包含一个空格)。就像在查询表达式语法中一样,用于表示 lambda 中被求值的值的字母是任意的;我本可以用v来玩视频游戏。

虽然结果是相同的(运行这个方法产生的输出与前面使用查询表达式的方法相同),但是您很快就会发现结果集的类型略有不同。对于大多数(如果不是几乎所有)场景,这种差异不会导致任何问题,并且这些格式可以互换使用。

又一次,没有 LINQ

可以肯定的是,LINQ 从来不是强制性的。如果您选择这样做,您可以通过完全放弃 LINQ 并使用编程原语(如if语句和for循环)来找到相同的结果集。下面是一个方法,它产生与QueryOverStrings()方法相同的结果,但是方式更加冗长:

static void QueryOverStringsLongHand()
{
  // Assume we have an array of strings.
  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  string[] gamesWithSpaces = new string[5];

  for (int i = 0; i < currentVideoGames.Length; i++)
  {
    if (currentVideoGames[i].Contains(" "))
    {
      gamesWithSpaces[i] = currentVideoGames[i];
    }
  }

  // Now sort them.
  Array.Sort(gamesWithSpaces);

  // Print out the results.
  foreach (string s in gamesWithSpaces)
  {
    if( s != null)
    {
      Console.WriteLine("Item: {0}", s);
    }
  }
  Console.WriteLine();
}

虽然我确信您可以想办法调整前面的方法,但事实是 LINQ 查询可以用来从根本上简化从数据源提取新数据子集的过程。一旦您创建了一个合适的 LINQ 查询,C# 编译器将代表您执行脏活累活,而不是构建嵌套循环、复杂的if / else逻辑、临时数据类型等等。

对 LINQ 结果集的反思

现在,假设Program类定义了一个名为ReflectOverQueryResults()的辅助函数,它将打印出 LINQ 结果集的各种细节(注意,该参数是一个System.Object,用于说明多种类型的结果集)。

static void ReflectOverQueryResults(object resultSet, string queryType = "Query Expressions")
{
  Console.WriteLine($"***** Info about your query using {queryType} *****");
  Console.WriteLine("resultSet is of type: {0}", resultSet.GetType().Name);
  Console.WriteLine("resultSet location: {0}", resultSet.GetType().Assembly.GetName().Name);
}

QueryOverStrings()方法的核心更新为:

// Build a query expression to find the items in the array
// that have an embedded space.
IEnumerable<string> subset =
  from g in currentVideoGames
  where g.Contains(" ")
  orderby g
  select g;

ReflectOverQueryResults(subset);

// Print out the results.
foreach (string s in subset)
{
  Console.WriteLine("Item: {0}", s);
}

当您运行应用时,您将会看到subset变量实际上是通用OrderedEnumerable<TElement, TKey>类型(表示为OrderedEnumerable2)的一个实例,它是驻留在System.Linq.dll`程序集中的一个内部抽象类型。

***** Info about your query using Query Expressions*****
resultSet is of type: OrderedEnumerable`2
resultSet location: System.Linq

QueryOverStringsWithExtensionMethods()方法进行相同的更改,除了为第二个参数添加"Extension Methods"

// Build a query expression to find the items in the array
// that have an embedded space.
IEnumerable<string> subset =
  currentVideoGames
    .Where(g => g.Contains(" "))
    .OrderBy(g => g)
    .Select(g => g);

ReflectOverQueryResults(subset,"Extension Methods");

// Print out the results.
foreach (string s in subset)
{
  Console.WriteLine("Item: {0}", s);
}

当您运行应用时,您会看到subset变量是类型SelectIPartitionIterator的一个实例。如果您从查询中删除Select(g=>g),您将回到拥有类型OrderedEnumerable<TElement, TKey>的实例。这一切意味着什么?对于大多数开发人员来说,并不多(如果有的话)。它们都是从IEnumerable<T>派生的,都可以用同样的方式迭代,都可以从它们的值创建一个列表或数组。

***** Info about your query using Extension Methods *****
resultSet is of type: SelectIPartitionIterator`2
resultSet location: System.Linq

LINQ 和隐式类型化局部变量

虽然当前的示例程序可以相对容易地确定结果集可以被捕获为string对象的枚举(例如IEnumerable<string>),但是我猜想而不是清楚subset实际上是类型OrderedEnumerable<TElement, TKey>

考虑到 LINQ 结果集可以在各种以 LINQ 为中心的名称空间中使用大量类型来表示,定义适当的类型来保存结果集将是乏味的,因为在许多情况下,底层类型可能并不明显,甚至无法从您的代码库直接访问(正如您将看到的,在某些情况下,类型是在编译时生成的)。

为了进一步强调这一点,考虑下面在Program类中定义的辅助方法:

static void QueryOverInts()
{
  int[] numbers = {10, 20, 30, 40, 1, 2, 3, 8};

  // Print only items less than 10.
  IEnumerable<int> subset = from i in numbers where i < 10 select i;

  foreach (int i in subset)
  {
    Console.WriteLine("Item: {0}", i);
  }
  ReflectOverQueryResults(subset);
}

在这种情况下,subset变量是完全不同的底层类型。这一次,实现IEnumerable<int>接口的类型是一个名为WhereArrayIterator<T>的低级类。

Item: 1
Item: 2
Item: 3
Item: 8

***** Info about your query *****
resultSet is of type: WhereArrayIterator`1
resultSet location: System.Linq

鉴于 LINQ 查询的确切底层类型肯定不是显而易见的,这些第一个示例将查询结果表示为一个IEnumerable<T>变量,其中T是返回序列中的数据类型(stringint等)。).然而,这仍然相当麻烦。雪上加霜的是,鉴于IEnumerable<T>扩展了非泛型IEnumerable接口,也允许捕获 LINQ 查询的结果,如下所示:

System.Collections.IEnumerable subset =
  from i in numbers
  where i < 10
  select i;

幸运的是,在处理 LINQ 查询时,隐式类型化大大简化了工作。

static void QueryOverInts()
{
  int[] numbers = {10, 20, 30, 40, 1, 2, 3, 8};

  // Use implicit typing here...
  var subset = from i in numbers where i < 10 select i;

  // ...and here.
  foreach (var i in subset)
  {
    Console.WriteLine("Item: {0} ", i);
  }
  ReflectOverQueryResults(subset);
}

根据经验,在捕获 LINQ 查询的结果时,您总是希望利用隐式类型。然而,请记住(在大多数情况下)实数返回值是实现通用IEnumerable<T>接口的类型。

究竟这种类型是什么在掩盖之下(OrderedEnumerable<TElement, TKey>WhereArrayIterator<T>等)。)无关,没必要发现。如前面的代码示例所示,您可以简单地在一个foreach构造中使用var关键字来迭代获取的数据。

LINQ 和扩展方法

尽管当前的例子没有让您直接编写任何扩展方法,但是您实际上是在后台无缝地使用它们。LINQ 查询表达式可用于迭代实现通用IEnumerable<T>接口的数据容器。然而,System.Array类类型(用于表示字符串数组和整数数组)并没有而不是实现这个契约。

// The System.Array type does not seem to implement the
// correct infrastructure for query expressions!
public abstract class Array : ICloneable, IList,
  IStructuralComparable, IStructuralEquatable
{
  ...
}

虽然System.Array没有直接实现IEnumerable<T>接口,但是它通过静态的System.Linq.Enumerable类类型间接获得了这种类型(以及许多其他以 LINQ 为中心的成员)所需的功能。

这个实用程序类定义了许多通用的扩展方法(如Aggregate<T>()First<T>()Max<T>()等)。),由System.Array(及其他类型)在后台获取。因此,如果您在currentVideoGames局部变量上应用点操作符,您会发现在System.Array的正式定义中有很多成员而不是

延期执行的作用

关于 LINQ 查询表达式的另一个要点是,当它们返回一个序列时,在对结果序列进行迭代之前,不会对它们进行实际计算。正式来说,这被称为延期执行。这种方法的好处是,您可以对同一个容器多次应用同一个 LINQ 查询,并且可以放心地获得最新和最好的结果。考虑下面对QueryOverInts()方法的更新:

static void QueryOverInts()
{
  int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };

  // Get numbers less than ten.
  var subset = from i in numbers where i < 10 select i;

  // LINQ statement evaluated here!
  foreach (var i in subset)
  {
    Console.WriteLine("{0} < 10", i);
  }
  Console.WriteLine();
  // Change some data in the array.
  numbers[0] = 4;

  // Evaluated again!
  foreach (var j in subset)
  {
    Console.WriteLine("{0} < 10", j);
  }

  Console.WriteLine();
  ReflectOverQueryResults(subset);
}

Note

当 LINQ 语句选择单个元素时(使用First / FirstOrDefaultSingle / SingleOrDefault或任何聚合方法),查询会立即执行。下一节将介绍FirstFirstOrDefaultSingleSingleOrDefault。本章稍后将介绍聚合方法。

如果您再次执行该程序,您会发现下面的输出。请注意,第二次迭代请求的序列时,您会发现一个额外的成员,因为您将数组中的第一项设置为小于 10 的值。

1 < 10
2 < 10
3 < 10
8 < 10

4 < 10
1 < 10
2 < 10
3 < 10
8 < 10

Visual Studio 的一个有用的方面是,如果在计算 LINQ 查询之前设置断点,则可以在调试会话期间查看内容。只需将鼠标光标放在 LINQ 结果集变量上(图 13-1 中的subset)。当您这样做时,您将可以通过展开 Results View 选项来评估查询。

img/340876_10_En_13_Fig1_HTML.jpg

图 13-1

调试 LINQ 表达式

立即执行的作用

当你需要计算一个 LINQ 表达式,产生一个超出foreach逻辑范围的序列时,你可以调用任意数量的由Enumerable类型定义的扩展方法,比如ToArray<T>()ToDictionary<TSource,TKey>()ToList<T>()。这些方法将导致 LINQ 查询在您调用它们来获取数据快照的同时执行。完成此操作后,可以独立操作数据快照。

此外,如果只查找一个元素,查询会立即执行。First()返回序列的第一个成员(并且应该总是与orderby一起使用)。如果没有要返回的内容,例如当原始序列为空或者where子句过滤掉所有元素时,则FirstOrDefault()返回列表中项目类型的默认值。Single()还返回序列的第一个成员(基于orderby,如果没有orderby子句,则返回元素顺序)。像它的同名对应物一样,如果序列中没有任何项目(或者所有记录都被where子句过滤掉)或者所有项目都被where子句过滤掉,那么SingleOrDefault()返回元素类型的默认值。First(OrDefault)Single(OrDefault)的区别在于,如果查询将返回多个元素,Single(OrDefault)将抛出异常。

static void ImmediateExecution()
{
    Console.WriteLine();
    Console.WriteLine("Immediate Execution");
    int[] numbers = { 10, 20, 30, 40, 1, 2, 3, 8 };

    //get the first element in sequence order
    int number = (from i in numbers select i).First();
    Console.WriteLine("First is {0}", number);

    //get the first in query order
    number = (from i in numbers orderby i select i).First();
    Console.WriteLine("First is {0}", number);

    //get the one element  that matches the query
    number = (from i in numbers where i > 30 select i).Single();
    Console.WriteLine("Single is {0}", number);

    try
    {
        //Throw an exception if more than one element passes the query
        number = (from i in numbers where i > 10 select i).Single();
    }
    catch (Exception ex)
    {
        Console.WriteLine("An exception occurred: {0}", ex.Message);
    }
  // Get data RIGHT NOW as int[].
  int[] subsetAsIntArray =
    (from i in numbers where i < 10 select i).ToArray<int>();

  // Get data RIGHT NOW as List<int>.
  List<int> subsetAsListOfInts =
    (from i in numbers where i < 10 select i).ToList<int>();
}

请注意,整个 LINQ 表达式都被括在括号中,以将其转换为正确的底层类型(无论是什么类型),从而调用Enumerable的扩展方法。

还记得在第十章中提到的,当 C# 编译器可以明确地确定泛型的类型参数时,你不需要指定类型参数。因此,您也可以如下调用ToArray<T>()(或ToList<T>()):

int[] subsetAsIntArray =
  (from i in numbers where i < 10 select i).ToArray();

当您需要将 LINQ 查询的结果返回给外部调用者时,立即执行的用处是显而易见的。幸运的是,这恰好是本章的下一个主题。

返回 LINQ 查询的结果

可以在类(或结构)中定义一个字段,其值是 LINQ 查询的结果。但是,要做到这一点,您不能使用隐式类型(因为关键字var不能用于字段),并且 LINQ 查询的目标不能是实例级数据;因此,它必须是静态的。鉴于这些限制,您很少需要编写如下代码:

class LINQBasedFieldsAreClunky
{
  private static string[] currentVideoGames =
    {"Morrowind", "Uncharted 2",
    "Fallout 3", "Daxter", "System Shock 2"};

  // Can't use implicit typing here! Must know type of subset!
  private IEnumerable<string> subset =
    from g in currentVideoGames
    where g.Contains(" ")
    orderby g
    select g;

  public void PrintGames()
  {
    foreach (var item in subset)
    {
      Console.WriteLine(item);
    }
  }
}

通常,LINQ 查询是在方法或属性的范围内定义的。此外,为了简化编程,用于保存结果集的变量将使用关键字var存储在隐式类型的局部变量中。现在,回想一下第三章中的内容,隐式类型变量不能用来定义参数、返回值或者类或结构的字段。

考虑到这一点,您可能想知道如何将查询结果返回给外部调用者。答案是:看情况。如果你有一个由强类型数据组成的结果集,比如一个字符串数组或者一个CarList<T>,你可以放弃使用var关键字,使用一个合适的IEnumerable<T>或者IEnumerable类型(同样,因为IEnumerable<T>扩展了IEnumerable)。考虑以下名为 LinqRetValues 的新控制台应用的示例:

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

Console.WriteLine("***** LINQ Return Values *****\n");
IEnumerable<string> subset = GetStringSubset();

foreach (string item in subset)
{
  Console.WriteLine(item);
}

Console.ReadLine();

static IEnumerable<string> GetStringSubset()
{
  string[] colors = {"Light Red", "Green", "Yellow", "Dark Red", "Red", "Purple"};
  // Note subset is an IEnumerable<string>-compatible object.
  IEnumerable<string> theRedColors = from c in colors where c.Contains("Red") select c;
  return theRedColors;
}

结果在意料之中。

Light Red
Dark Red
Red

通过立即执行返回 LINQ 结果

这个例子按预期工作,只是因为这个方法中的返回值和 LINQ 查询是强类型的。如果您使用了var关键字来定义subset变量,那么如果该方法仍然原型化为返回IEnumerable<string>(并且如果隐式类型化的局部变量实际上与指定的返回类型兼容),则只允许返回值*。*

*因为在IEnumerable<T>上操作有点不方便,可以用立即执行。例如,如果将序列转换为强类型数组,可以简单地返回一个string[],而不是返回IEnumerable<string>。考虑一下Program类的这个新方法,它做了这样一件事:

static string[] GetStringSubsetAsArray()
{
  string[] colors = {"Light Red", "Green", "Yellow", "Dark Red", "Red", "Purple"};

  var theRedColors = from c in colors where c.Contains("Red") select c;

  // Map results into an array.
  return theRedColors.ToArray();
}

有了这个,调用者可以幸福地不知道他们的结果来自于 LINQ 查询,并简单地按照预期使用一组string s。这里有一个例子:

foreach (string item in GetStringSubsetAsArray())
{
  Console.WriteLine(item);
}

当试图将 LINQ 投影的结果返回给调用者时,立即执行也很关键。您将在本章的稍后部分研究这个主题。接下来,让我们看看如何将 LINQ 查询应用于泛型和非泛型集合对象。

将 LINQ 查询应用于集合对象

除了从简单的数据数组中提取结果,LINQ 查询表达式还可以在System.Collections.Generic名称空间的成员中操作数据,比如List<T>类型。创建一个名为 LinqOverCollections 的新控制台应用项目,并定义一个基本的Car类,该类维护当前的速度、颜色、品牌和昵称,如以下代码所示:

namespace LinqOverCollections
{
  class Car
  {
    public string PetName {get; set;} = "";
    public string Color {get; set;} = "";
    public int Speed {get; set;}
    public string Make {get; set;} = "";
  }
}

现在,在顶层语句中,定义一个类型为Car的局部List<T>变量,并利用对象初始化语法用一些新的Car对象填充列表。

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

Console.WriteLine("***** LINQ over Generic Collections *****\n");

// Make a List<> of Car objects.
List<Car> myCars = new List<Car>() {
  new Car{ PetName = "Henry", Color = "Silver", Speed = 100, Make = "BMW"},
  new Car{ PetName = "Daisy", Color = "Tan", Speed = 90, Make = "BMW"},
  new Car{ PetName = "Mary", Color = "Black", Speed = 55, Make = "VW"},
  new Car{ PetName = "Clunker", Color = "Rust", Speed = 5, Make = "Yugo"},
  new Car{ PetName = "Melvin", Color = "White", Speed = 43, Make = "Ford"}
};

Console.ReadLine();

访问包含的子对象

将 LINQ 查询应用到通用容器与使用简单数组没有什么不同,因为对象的 LINQ 可以用在任何实现IEnumerable<T>的类型上。这一次,您的目标是构建一个查询表达式,只选择myCars列表中的Car对象,其中速度大于 55。

获得子集后,您将通过调用PetName属性打印出每个Car对象的名称。假设您有下面的 helper 方法(带一个List<Car>参数),它是从顶层语句调用的:

static void GetFastCars(List<Car> myCars)
{
  // Find all Car objects in the List<>, where the Speed is
  // greater than 55.
  var fastCars = from c in myCars where c.Speed > 55 select c;

  foreach (var car in fastCars)
  {
    Console.WriteLine("{0} is going too fast!", car.PetName);
  }
}

请注意,您的查询表达式只从List<T>中获取那些属性大于 55 的项目。如果您运行该应用,您会发现只有HenryDaisy两项符合搜索条件。

如果您想要构建一个更复杂的查询,您可能想要只查找那些Speed值大于 90 的 BMW。为此,只需使用 C# &&操作符构建一个复合布尔语句。

static void GetFastBMWs(List<Car> myCars)
  {
  // Find the fast BMWs!
  var fastCars = from c in myCars where c.Speed > 90 && c.Make == "BMW" select c;
  foreach (var car in fastCars)
  {
    Console.WriteLine("{0} is going too fast!", car.PetName);
  }
}

这种情况下,唯一打印出来的宠物名是Henry

将 LINQ 查询应用于非泛型集合

回想一下,LINQ 的查询操作符被设计成可以处理任何实现IEnumerable<T>的类型(直接或者通过扩展方法)。鉴于System.Array已经提供了这种必要的基础设施,您可能会惊讶于System.Collections中的遗留(非通用)容器还没有。幸运的是,仍然可以使用泛型Enumerable.OfType<T>()扩展方法迭代非泛型集合中包含的数据。

当从非通用集合对象(如ArrayList)调用OfType<T>()时,只需在容器中指定项目的类型,以提取兼容的IEnumerable<T>对象。在代码中,可以使用隐式类型的变量存储该数据点。

考虑下面的新方法,它用一组Car对象填充ArrayList(确保将System.Collections名称空间导入到您的Program.cs文件中):

static void LINQOverArrayList()
{
  Console.WriteLine("***** LINQ over ArrayList *****");

  // Here is a nongeneric collection of cars.
  ArrayList myCars = new ArrayList() {
    new Car{ PetName = "Henry", Color = "Silver", Speed = 100, Make = "BMW"},
    new Car{ PetName = "Daisy", Color = "Tan", Speed = 90, Make = "BMW"},
    new Car{ PetName = "Mary", Color = "Black", Speed = 55, Make = "VW"},
    new Car{ PetName = "Clunker", Color = "Rust", Speed = 5, Make = "Yugo"},
    new Car{ PetName = "Melvin", Color = "White", Speed = 43, Make = "Ford"}
  };

  // Transform ArrayList into an IEnumerable<T>-compatible type.
  var myCarsEnum = myCars.OfType<Car>();

  // Create a query expression targeting the compatible type.
  var fastCars = from c in myCarsEnum where c.Speed > 55 select c;
  foreach (var car in fastCars)
  {
    Console.WriteLine("{0} is going too fast!", car.PetName);
  }
}

和前面的例子一样,当从顶层语句调用这个方法时,它将根据 LINQ 查询的格式只显示名字HenryDaisy

使用 OfType ()过滤数据

如你所知,非泛型类型可以包含任何项目的组合,因为这些容器的成员(比如ArrayList)被原型化为接收System.Object。例如,假设一个ArrayList包含各种项目,其中只有一个子集是数字的。如果您想获得一个只包含数字数据的子集,您可以使用OfType<T>()来实现,因为它会在迭代过程中过滤掉类型不同于给定类型的每个元素。

static void OfTypeAsFilter()
{
  // Extract the ints from the ArrayList.
  ArrayList myStuff = new ArrayList();
  myStuff.AddRange(new object[] { 10, 400, 8, false, new Car(), "string data" });
  var myInts = myStuff.OfType<int>();

  // Prints out 10, 400, and 8.
  foreach (int i in myInts)
  {
    Console.WriteLine("Int value: {0}", i);
  }
}

至此,您已经有机会将 LINQ 查询应用于数组、泛型集合和非泛型集合。这些容器保存了 C# 基本类型(整数、字符串数据)以及自定义类。下一个任务是学习更多的 LINQ 操作符,这些操作符可以用来构建更复杂、更有用的查询。

调查 C# LINQ 查询运算符

C# 定义了大量现成的查询操作符。表 13-2 记录了一些更常用的查询运算符。除了表 13-2 中显示的部分操作符列表外,System.Linq.Enumerable类还提供了一组方法,这些方法没有直接的 C# 查询操作符简写符号,而是作为扩展方法公开。可以调用这些通用方法以各种方式转换结果集(Reverse<>()ToArray<>()ToList<>()等)。).一些用于从结果集中提取单例,另一些执行各种集合操作(Distinct<>()Union<>()Intersect<>()等)。),还有一些汇总结果(Count<>()Sum<>()Min<>()Max<>()等)。).

表 13-2。

常见的 LINQ 查询运算符

|

查询运算符

|

生命的意义

fromin 用于定义任何 LINQ 表达式的主干,这允许您从拟合容器中提取数据的子集。
where 用于定义从容器中提取哪些项目的限制。
select 用于从容器中选择一个序列。
joinonequalsinto 基于指定的键执行联接。请记住,这些“连接”不需要与关系数据库中的数据有任何关系。
orderbyascendingdescending 允许结果子集按升序或降序排序。
groupby 生成包含按指定值分组的数据的子集。

要开始研究更复杂的 LINQ 查询,请创建一个名为 FunWithLinqExpressions 的新控制台应用项目。接下来,您需要定义一些样本数据的数组或集合。对于这个项目,您将创建一个由以下代码定义的ProductInfo对象组成的数组:

namespace FunWithLinqExpressions
{
  class ProductInfo
  {
    public string Name {get; set;} = "";
    public string Description {get; set;} = "";
    public int NumberInStock {get; set;} = 0;

    public override string ToString()
      => $"Name={Name}, Description={Description}, Number in Stock={NumberInStock}";
  }
}

现在用调用代码中的一批ProductInfo对象填充一个数组。

Console.WriteLine("***** Fun with Query Expressions *****\n");

// This array will be the basis of our testing...
ProductInfo[] itemsInStock = new[] {
  new ProductInfo{ Name = "Mac's Coffee", Description = "Coffee with TEETH", NumberInStock = 24},
  new ProductInfo{ Name = "Milk Maid Milk", Description = "Milk cow's love", NumberInStock = 100},
  new ProductInfo{ Name = "Pure Silk Tofu", Description = "Bland as Possible", NumberInStock = 120},
  new ProductInfo{ Name = "Crunchy Pops", Description = "Cheezy, peppery goodness", NumberInStock = 2},
  new ProductInfo{ Name = "RipOff Water", Description = "From the tap to your wallet", NumberInStock = 100},
  new ProductInfo{ Name = "Classic Valpo Pizza", Description = "Everyone loves pizza!",  NumberInStock = 73}
};

// We will call various methods here!
Console.ReadLine();

基本选择语法

因为 LINQ 查询表达式的语法正确性是在编译时验证的,所以您需要记住这些运算符的顺序非常重要。用最简单的话来说,每个 LINQ 查询表达式都是使用frominselect操作符构建的。以下是要遵循的通用模板:

var result =
  from matchingItem in container
  select matchingItem;

from操作符后面的项表示与 LINQ 查询条件匹配的项,它可以被命名为您选择的任何名称。in操作符后面的项表示要搜索的数据容器(数组、集合、XML 文档等。).

下面是一个简单的查询,只需选择容器中的每一项(行为类似于数据库Select * SQL 语句)。请考虑以下几点:

static void SelectEverything(ProductInfo[] products)
{
  // Get everything!
  Console.WriteLine("All product details:");
  var allProducts = from p in products select p;

  foreach (var prod in allProducts)
  {
    Console.WriteLine(prod.ToString());
  }
}

老实说,这个查询表达式并不完全有用,因为您的子集与传入参数中的数据子集相同。如果您愿意,可以使用以下选择语法只提取每辆汽车的Name值:

static void ListProductNames(ProductInfo[] products)
{
  // Now get only the names of the products.
  Console.WriteLine("Only product names:");
  var names = from p in products select p.Name;

  foreach (var n in names)
  {
    Console.WriteLine("Name: {0}", n);
  }
}

获取数据子集

要从容器中获取特定的子集,可以使用where操作符。执行此操作时,通用模板现在变成以下代码:

var result =
  from item
  in container
  where BooleanExpression
  select item;

注意,where操作符期望一个解析为布尔值的表达式。例如,要从ProductInfo[]参数中仅提取手头有超过 25 个项目的项目,您可以编写以下代码:

static void GetOverstock(ProductInfo[] products)
{
  Console.WriteLine("The overstock items!");

  // Get only the items where we have more than
  // 25 in stock.
  var overstock =
    from p
    in products
    where p.NumberInStock > 25
    select p;

  foreach (ProductInfo c in overstock)
  {
    Console.WriteLine(c.ToString());
  }
}

如本章前面所示,当您构建一个where子句时,允许使用任何有效的 C# 操作符来构建复杂的表达式。例如,回想一下这个查询,它只提取时速至少为 100 英里的宝马:

// Get BMWs going at least 100 MPH.
var onlyFastBMWs =
  from c
  in myCars
  where c.Make == "BMW" && c.Speed >= 100
  select c;

投影新的数据类型

也可以从现有的数据源投射新形式的数据。让我们假设您想要接受传入的ProductInfo[]参数,并获得一个只包含每一项的名称和描述的结果集。为此,您可以定义一个select语句来动态生成一个新的匿名类型。

static void GetNamesAndDescriptions(ProductInfo[] products)
{
  Console.WriteLine("Names and Descriptions:");
  var nameDesc =
    from p
    in products
    select new { p.Name, p.Description };

  foreach (var item in nameDesc)
  {
    // Could also use Name and Description properties
    // directly.
    Console.WriteLine(item.ToString());
  }
}

请记住,当您的 LINQ 查询使用投影时,您无法知道底层的数据类型,因为这是在编译时确定的。在这些情况下,var关键字是必需的。同样,回想一下,您不能创建具有隐式类型返回值的方法。因此,下面的方法不会编译:

static var GetProjectedSubset(ProductInfo[] products)
{
  var nameDesc =
    from p in products select new { p.Name, p.Description };
  return nameDesc; // Nope!
}

当您需要将投影数据返回给调用者时,一种方法是使用ToArray()扩展方法将查询结果转换成System.Array对象。因此,如果您要按如下方式更新查询表达式:

// Return value is now an Array.
static Array GetProjectedSubset(ProductInfo[] products)
{
  var nameDesc =
    from p in products select new { p.Name, p.Description };

  // Map set of anonymous objects to an Array object.
  return nameDesc.ToArray();
}

您可以调用并处理数据,如下所示:

Array objs = GetProjectedSubset(itemsInStock);
foreach (object o in objs)
{
  Console.WriteLine(o); // Calls ToString() on each anonymous object.
}

请注意,您必须使用文字System.Array对象,并且不能使用 C# 数组声明语法,因为您不知道底层类型,因为您正在对编译器生成的匿名类进行操作!还要注意,您没有为泛型ToArray<T>()方法指定类型参数,因为您在编译时才知道底层的数据类型,这对于您的目的来说已经太晚了。

明显的问题是您丢失了任何强类型,因为Array对象中的每一项都被假定为类型Object。然而,当您需要返回一个 LINQ 结果集,它是一个匿名类型的投影操作的结果时,将数据转换成一个Array类型(或者通过Enumerable类型的其他成员转换成另一个合适的容器)是强制性的。

投影到不同的数据类型

除了投射到匿名类型,您还可以将 LINQ 查询的结果投射到另一个具体类型。这允许静态输入并使用IEnumerable<T>作为结果集。首先,创建一个较小版本的ProductInfo类。

namespace FunWithLinqExpressions
{
  class ProductInfoSmall
  {
    public string Name {get; set;} = "";
    public string Description {get; set;} = "";
    public override string ToString()
      => $"Name={Name}, Description={Description}";
  }
}

下一个变化是将查询结果投射到一组ProductInfoSmall对象中,而不是匿名类型。将以下方法添加到您的类中:

static void GetNamesAndDescriptionsTyped(
  ProductInfo[] products)
{
  Console.WriteLine("Names and Descriptions:");
  IEnumerable<ProductInfoSmall> nameDesc =
    from p
    in products
    select new ProductInfoSmall
      { Name=p.Name, Description=p.Description };

  foreach (ProductInfoSmall item in nameDesc)
  {
    Console.WriteLine(item.ToString());
  }
}

使用 LINQ 投影,您可以选择使用哪种方法(匿名或强类型对象)。您做出的决定完全取决于您的业务需求。

使用可枚举获得计数

当您计划新的数据批次时,您可能需要准确地发现有多少项被返回到序列中。任何时候,当您需要确定从 LINQ 查询表达式返回的项数时,只需使用Enumerable类的Count()扩展方法。例如,下面的方法将在一个本地数组中查找所有长度超过六个字符的string对象:

static void GetCountFromQuery()
{
  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  // Get count from the query.
  int numb =  (from g in currentVideoGames where g.Length > 6 select g).Count();

  // Print out the number of items.
  Console.WriteLine("{0} items honor the LINQ query.", numb);
}

反转结果集

使用Enumerable类的Reverse<>()扩展方法,可以非常简单地反转结果集中的项目。例如,以下方法从传入的ProductInfo[]参数中反向选择所有项目:

static void ReverseEverything(ProductInfo[] products)
{
  Console.WriteLine("Product in reverse:");
  var allProducts = from p in products select p;
  foreach (var prod in allProducts.Reverse())
  {
    Console.WriteLine(prod.ToString());
  }
}

排序表达式

正如你在本章最初的例子中所看到的,查询表达式可以使用一个orderby操作符来按照特定的值对子集中的项目进行排序。默认情况下,顺序将是升序;因此,按字符串排序将是字母顺序,按数字数据排序将是从低到高,依此类推。如果您需要以降序查看结果,只需包含descending操作符。思考以下方法:

static void AlphabetizeProductNames(ProductInfo[] products)
{
  // Get names of products, alphabetized.
  var subset = from p in products orderby p.Name select p;

  Console.WriteLine("Ordered by Name:");
  foreach (var p in subset)
  {
    Console.WriteLine(p.ToString());
  }
}

虽然升序是默认的,但是您可以使用ascending操作符来表达您的意图。

var subset = from p in products orderby p.Name ascending select p;

如果您想按降序排列项目,您可以通过descending操作符来实现。

var subset = from p in products orderby p.Name descending select p;

LINQ 作为一个更好的文氏作图工具

Enumerable类支持一组扩展方法,允许您使用两个(或更多)LINQ 查询作为基础来查找数据的联合、差异、连接和交集。首先,考虑一下Except()扩展方法,它将返回一个 LINQ 结果集,其中包含两个容器之间的差异,在本例中是值Yugo

static void DisplayDiff()
{
  List<string> myCars =
    new List<String> {"Yugo", "Aztec", "BMW"};
  List<string> yourCars =
    new List<String>{"BMW", "Saab", "Aztec" };

  var carDiff =
    (from c in myCars select c)
    .Except(from c2 in yourCars select c2);

  Console.WriteLine("Here is what you don't have, but I do:");
  foreach (string s in carDiff)
  {
    Console.WriteLine(s); // Prints Yugo.
  }
}

Intersect()方法将返回一个结果集,该结果集包含一组容器中的公共数据项。例如,以下方法返回序列AztecBMW:

static void DisplayIntersection()
{
  List<string> myCars = new List<String> { "Yugo", "Aztec", "BMW" };
  List<string> yourCars = new List<String> { "BMW", "Saab", "Aztec" };

  // Get the common members.
  var carIntersect =
    (from c in myCars select c)
    .Intersect(from c2 in yourCars select c2);

  Console.WriteLine("Here is what we have in common:");
  foreach (string s in carIntersect)
  {
    Console.WriteLine(s); // Prints Aztec and BMW.
  }
}

正如您所猜测的,Union()方法返回一个包含一批 LINQ 查询的所有成员的结果集。像任何适当的联合一样,如果一个公共成员出现多次,您将不会发现重复值。因此,下面的方法将打印出值YugoAztecBMWSaab:

static void DisplayUnion()
{
  List<string> myCars =
    new List<string> { "Yugo", "Aztec", "BMW" };
  List<string> yourCars =
    new List<String> { "BMW", "Saab", "Aztec" };

  // Get the union of these containers.
  var carUnion =
    (from c in myCars select c)
    .Union(from c2 in yourCars select c2);

  Console.WriteLine("Here is everything:");
  foreach (string s in carUnion)
  {
    Console.WriteLine(s); // Prints all common members.
  }
}

最后,Concat()扩展方法返回一个结果集,它是 LINQ 结果集的直接串联。例如,下面的方法打印出结果YugoAztecBMWBMWSaabAztec:

static void DisplayConcat()
{
  List<string> myCars =
    new List<String> { "Yugo", "Aztec", "BMW" };
  List<string> yourCars =
    new List<String> { "BMW", "Saab", "Aztec" };

  var carConcat =
    (from c in myCars select c)
    .Concat(from c2 in yourCars select c2);

  // Prints:
  // Yugo Aztec BMW BMW Saab Aztec.
  foreach (string s in carConcat)
  {
    Console.WriteLine(s);
  }
}

删除重复项

当您调用Concat()扩展方法时,您很可能在获取的结果中得到冗余条目,这在某些情况下可能正是您想要的。但是,在其他情况下,您可能希望删除数据中的重复条目。为此,只需调用Distinct()扩展方法,如下所示:

static void DisplayConcatNoDups()
{
  List<string> myCars =
    new List<String> { "Yugo", "Aztec", "BMW" };
  List<string> yourCars =
    new List<String> { "BMW", "Saab", "Aztec" };

  var carConcat =
    (from c in myCars select c)
    .Concat(from c2 in yourCars select c2);

  // Prints:
  // Yugo Aztec BMW Saab.
  foreach (string s in carConcat.Distinct())
  {
    Console.WriteLine(s);
  }
}

LINQ 聚合运算

LINQ 查询还可以设计为对结果集执行各种聚合操作。Count()扩展方法就是这样一个聚合例子。其他可能性包括使用Enumerable类的Max()Min()Average()Sum()成员获得平均值、最大值、最小值或值的总和。这里有一个简单的例子:

static void AggregateOps()
{
  double[] winterTemps = { 2.0, -21.3, 8, -4, 0, 8.2 };

  // Various aggregation examples.
  Console.WriteLine("Max temp: {0}",
    (from t in winterTemps select t).Max());

  Console.WriteLine("Min temp: {0}",
    (from t in winterTemps select t).Min());

  Console.WriteLine("Average temp: {0}",
    (from t in winterTemps select t).Average());

  Console.WriteLine("Sum of all temps: {0}",
    (from t in winterTemps select t).Sum());
}

这些例子应该给你足够的知识,让你对构建 LINQ 查询表达式的过程感到舒服。虽然您还没有研究其他操作符,但是当您学习相关的 LINQ 技术时,将会在本文后面看到更多的例子。为了总结你对 LINQ 的初步了解,本章的剩余部分将深入到 C# LINQ 查询操作符和底层对象模型之间的细节。

LINQ 查询语句的内部表示

至此,您已经了解了使用各种 C# 查询操作符(如frominwhereorderbyselect)构建查询表达式的过程。此外,您发现 LINQ 到对象 API 的一些功能只有在调用Enumerable类的扩展方法时才能被访问。然而,事实是,当编译 LINQ 查询时,C# 编译器将所有 C# LINQ 操作符翻译成对Enumerable类方法的调用。

大量的Enumerable方法已经被原型化,将委托作为参数。许多方法需要一个名为Func<>的泛型委托,这是在第十章的泛型委托研究中介绍的。考虑一下EnumerableWhere()方法,当您使用 C# where LINQ 查询操作符时,它会以您的名义被调用。

// Overloaded versions of the Enumerable.Where<T>() method.
// Note the second parameter is of type System.Func<>.
public static IEnumerable<TSource> Where<TSource>(
  this IEnumerable<TSource> source,
  System.Func<TSource,int,bool> predicate)

public static IEnumerable<TSource> Where<TSource>(
  this IEnumerable<TSource> source,
  System.Func<TSource,bool> predicate)

Func<>委托(顾名思义)用一组多达 16 个参数和一个返回值表示给定函数的模式。如果您使用 Visual Studio 对象浏览器来检查这种类型,您会注意到各种形式的Func<>委托。这里有一个例子:

// The various formats of the Func<> delegate.
public delegate TResult Func<T1,T2,T3,T4,TResult>(T1 arg1, T2 arg2, T3 arg3, T4 arg4)

public delegate TResult Func<T1,T2,T3,TResult>(T1 arg1, T2 arg2, T3 arg3)

public delegate TResult Func<T1,T2,TResult>(T1 arg1, T2 arg2)

public delegate TResult Func<T1,TResult>(T1 arg1)

public delegate TResult Func<TResult>()

鉴于System.Linq.Enumerable的许多成员需要一个委托作为输入,当调用它们时,您可以手动创建一个新的委托类型并编写必要的目标方法,使用 C# 匿名方法,或者定义一个适当的 lambda 表达式。不管你采取哪种方法,结果都是一样的。

虽然使用 C# LINQ 查询操作符确实是构建 LINQ 查询表达式最简单的方法,但是让我们来看看这些可能的方法,这样您就可以看到 C# 查询操作符和底层的Enumerable类型之间的联系。

用查询运算符构建查询表达式(重访)

首先,创建一个名为 LinqUsingEnumerable 的新控制台应用项目。Program类将定义一系列静态帮助器方法(每个方法都在顶级语句中调用),以说明构建 LINQ 查询表达式的各种方式。

第一种方法QueryStringsWithOperators()提供了构建查询表达式的最直接的方法,与本章前面的 LinqOverArray 示例中显示的代码相同。

using System.Linq;
static void QueryStringWithOperators()
{
  Console.WriteLine("***** Using Query Operators *****");

  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  var subset = from game in currentVideoGames
    where game.Contains(" ") orderby game select game;

  foreach (string s in subset)
  {
    Console.WriteLine("Item: {0}", s);
  }
}

使用 C# 查询操作符构建查询表达式的明显好处是,Func<>委托和对Enumerable类型的调用是看不见也想不到的,因为执行这种翻译是 C# 编译器的工作。当然,使用各种查询操作符(frominwhereorderby)构建 LINQ 表达式是最常见和最直接的方法。

使用可枚举类型和 Lambda 表达式构建查询表达式

请记住,这里使用的 LINQ 查询操作符只是调用由Enumerable类型定义的各种扩展方法的简写版本。考虑下面的QueryStringsWithEnumerableAndLambdas()方法,它现在直接使用Enumerable扩展方法处理本地字符串数组:

static void QueryStringsWithEnumerableAndLambdas()
{
  Console.WriteLine("***** Using Enumerable / Lambda Expressions *****");

  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  // Build a query expression using extension methods
  // granted to the Array via the Enumerable type.
  var subset = currentVideoGames
    .Where(game => game.Contains(" "))
    .OrderBy(game => game).Select(game => game);

  // Print out the results.
  foreach (var game in subset)
  {
    Console.WriteLine("Item: {0}", game);
  }
  Console.WriteLine();
}

在这里,首先调用currentVideoGames字符串数组上的Where()扩展方法。回想一下,Array类通过Enumerable授予的扩展方法接收这个。Enumerable.Where()方法需要一个System.Func<T1, TResult>委托参数。该委托的第一个类型参数表示要处理的与IEnumerable<T>兼容的数据(在本例中是一个字符串数组),而第二个类型参数表示方法结果数据,该数据是从 lambda 表达式中的一个语句获得的。

在这个代码示例中,Where()方法的返回值是隐藏的,但是在幕后,您操作的是一个OrderedEnumerable类型。从这个对象中,您调用通用的OrderBy()方法,它也需要一个Func<>委托参数。这一次,您只是通过一个合适的 lambda 表达式依次传递每一项。调用OrderBy()的结果是初始数据的一个新的有序序列。

最后,您调用从OrderBy()返回的序列的Select()方法,这导致最终的数据集存储在名为subset的隐式类型变量中。

可以肯定的是,这个“手写的”LINQ 查询比前面的 C# LINQ 查询操作符示例要复杂得多。毫无疑问,部分复杂性是由于使用点运算符将调用链接在一起。下面是同一个查询,其中每一步都被分解成离散的块(正如您可能猜到的,您可以用各种方式分解整个查询):

static void QueryStringsWithEnumerableAndLambdas2()
{
  Console.WriteLine("***** Using Enumerable / Lambda Expressions *****");

  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  // Break it down!
  var gamesWithSpaces = currentVideoGames.Where(game => game.Contains(" "));
  var orderedGames = gamesWithSpaces.OrderBy(game => game);
  var subset = orderedGames.Select(game => game);

  foreach (var game in subset)
  {
    Console.WriteLine("Item: {0}", game);
  }
  Console.WriteLine();
}

您可能同意,直接使用Enumerable类的方法构建 LINQ 查询表达式比使用 C# 查询操作符要冗长得多。同样,考虑到Enumerable的方法需要委托作为参数,您通常需要编写 lambda 表达式,以允许底层委托目标处理输入数据。

使用可枚举类型和匿名方法构建查询表达式

假设 C# lambda 表达式只是使用匿名方法的简写符号,考虑在QueryStringsWithAnonymousMethods() helper 函数中创建的第三个查询表达式,如下所示:

static void QueryStringsWithAnonymousMethods()
{
  Console.WriteLine("***** Using Anonymous Methods *****");

  string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

  // Build the necessary Func<> delegates using anonymous methods.
  Func<string, bool> searchFilter = delegate(string game) { return game.Contains(" "); };
  Func<string, string> itemToProcess = delegate(string s) { return s; };

  // Pass the delegates into the methods of Enumerable.
  var subset = currentVideoGames.Where(searchFilter).OrderBy(itemToProcess).Select(itemToProcess);

  // Print out the results.
  foreach (var game in subset)
  {
    Console.WriteLine("Item: {0}", game);
  }
  Console.WriteLine();
}

这个查询表达式的迭代更加冗长,因为您正在手动创建由Enumerable类的Where()OrderBy()Select()方法使用的Func<>委托。有利的一面是,匿名方法语法确实将所有的委托处理包含在一个方法定义中。然而,该方法在功能上等同于前面章节中创建的QueryStringsWithEnumerableAndLambdas()QueryStringsWithOperators()方法。

使用可枚举类型和原始委托构建查询表达式

最后,如果您想使用详细方法构建一个查询表达式,您可以避免使用 lambdas/anonymous 方法语法,直接为每个Func<>类型创建委托目标。这是查询表达式的最后一次迭代,在名为VeryComplexQueryExpression的新类类型中建模:

class VeryComplexQueryExpression
{
  public static void QueryStringsWithRawDelegates()
  {
    Console.WriteLine("***** Using Raw Delegates *****");
    string[] currentVideoGames = {"Morrowind", "Uncharted 2", "Fallout 3", "Daxter", "System Shock 2"};

    // Build the necessary Func<> delegates.
    Func<string, bool> searchFilter =
      new Func<string, bool>(Filter);
    Func<string, string> itemToProcess =
      new Func<string,string>(ProcessItem);

    // Pass the delegates into the methods of Enumerable.
    var subset =
      currentVideoGames
       .Where(searchFilter)
       .OrderBy(itemToProcess)
       .Select(itemToProcess);

    // Print out the results.
    foreach (var game in subset)
    {
      Console.WriteLine("Item: {0}", game);
    }
    Console.WriteLine();
  }

  // Delegate targets.
  public static bool Filter(string game)
  {
    return game.Contains(" ");
  }
  public static string ProcessItem(string game)
  {
    return game;
  }
}

您可以通过在Program类的顶级语句中调用该方法来测试字符串处理逻辑的迭代,如下所示:

VeryComplexQueryExpression.QueryStringsWithRawDelegates();

如果您现在运行应用来测试每种可能的方法,那么无论采用哪种方法,输出都是相同的就不足为奇了。关于 LINQ 查询表达式如何在幕后表示,请记住以下几点:

  • 查询表达式是使用各种 C# 查询运算符创建的。

  • 查询操作符只是调用由System.Linq.Enumerable类型定义的扩展方法的简写符号。

  • Enumerable的许多方法需要委托(特别是Func<>)作为参数。

  • 任何需要委托参数的方法都可以被传递一个 lambda 表达式。

  • Lambda 表达式只是伪装的匿名方法(大大提高了可读性)。

  • 匿名方法是分配原始委托和手动构建委托目标方法的简写符号。

咻!这可能比你想的要深入一些,但是我希望这个讨论能够帮助你理解用户友好的 C# 查询操作符在幕后做了什么。

摘要

LINQ 是一组相关的技术,试图提供一种单一的、对称的方式来与不同形式的数据进行交互。正如本章所解释的,LINQ 可以与任何实现IEnumerable<T>接口的类型交互,包括简单的数组以及通用和非通用的数据集合。

正如您所看到的,使用 LINQ 技术是通过几个 C# 语言特性来完成的。例如,假设 LINQ 查询表达式可以返回任意数量的结果集,那么通常使用var关键字来表示底层数据类型。此外,lambda 表达式、对象初始化语法和匿名类型都可以用来构建功能性和紧凑的 LINQ 查询。

更重要的是,您已经看到了 C# LINQ 查询操作符是如何简单地对System.Linq.Enumerable类型的静态成员进行调用的简写符号。如图所示,Enumerable的大多数成员操作的是Func<T>委托类型,它可以将文字方法地址、匿名方法或 lambda 表达式作为输入来评估查询。**

十四、进程、应用域和加载上下文

在这一章中,你将深入探究运行时如何承载程序集的细节,并开始理解进程、应用域和对象上下文之间的关系。

简而言之,应用域(或简称为 AppDomains )是给定进程中的逻辑子部分,包含一组相关的。NET 核心程序集。正如您将看到的,AppDomain 被进一步细分为上下文边界,用于分组志同道合的人。NET 核心对象。使用上下文的概念,运行时可以确保具有特殊要求的对象得到适当的处理。

虽然您的许多日常编程任务可能不涉及直接使用进程、AppDomains 或对象上下文,但在使用大量。NET 核心 API,包括多线程、并行处理和对象序列化。

Windows 进程的角色

“进程”的概念早在。NET/。NET 核心平台。简单来说,进程就是一个正在运行的程序。然而,从形式上来说,进程是一个操作系统级的概念,用于描述一组资源(如外部代码库和主线程)以及正在运行的应用所使用的必要内存分配。对于每一个。NET 核心应用加载到内存中时,操作系统会创建一个单独且隔离的进程供其在生命周期中使用。

使用这种应用隔离方法,结果是一个更加健壮和稳定的运行时环境,假设一个进程的失败不会影响另一个进程的运行。此外,一个进程中的数据不能被另一个进程直接访问,除非您使用特定的工具,如System.IO.PipesMemoryMappedFile类。考虑到这几点,您可以将该进程视为正在运行的应用的一个固定的、安全的边界。

每个 Windows 进程都分配有一个唯一的进程标识符(PID ),并且可以根据需要由操作系统独立加载和卸载(也可以通过编程方式)。如您所知,Windows 任务管理器实用程序的“进程”选项卡(在 Windows 上通过 Ctrl+Shift+Esc 组合键激活)允许您查看有关给定计算机上运行的进程的各种统计信息。详细信息选项卡允许您查看分配的 PID 和图像名称(参见图 14-1 )。

img/340876_10_En_14_Fig1_HTML.jpg

图 14-1。

Windows 任务管理器

线程的作用

每个 Windows 进程都包含一个初始“线程”,作为应用的入口点。第十五章研究了在。NET 核心平台;然而,为了方便这里介绍的主题,您需要一些工作定义。首先,线程是一个进程中的执行路径。从形式上讲,进程入口点创建的第一个线程被称为主线程。任何。NET 核心程序(控制台应用、Windows 服务、WPF 应用等。)用Main()方法或包含顶级语句的文件标记它的入口点。调用这段代码时,会自动创建主线程。

包含单个主执行线程的进程本质上是线程安全的,因为在给定时间只有一个线程可以访问应用中的数据。但是,如果单线程执行复杂的操作(例如打印一个很长的文本文件,执行数学密集型计算,或者试图连接到数千英里之外的远程服务器),单线程进程(尤其是基于 GUI 的进程)对用户来说通常会显得有点无响应。

鉴于单线程应用的这一潜在缺点,支持的操作系统。NET Core(以及。NET Core platform)使得主线程可以使用一些 API 函数(如CreateThread)来产生额外的辅助线程(也称为工作线程)。每个线程(主线程或次线程)都成为进程中唯一的执行路径,并且可以同时访问进程中所有共享的数据点。

您可能已经猜到,开发人员通常会创建额外的线程来帮助提高程序的整体响应能力。多线程进程提供了大量活动同时发生的假象。例如,一个应用可能会产生一个工作线程来执行一个劳动密集型的工作单元(比如打印一个大的文本文件)。当这个辅助线程运行时,主线程仍然响应用户输入,这使得整个进程有可能提供更好的性能。然而,实际情况可能并非如此:在单个进程中使用太多线程实际上会降低性能,因为 CPU 必须在进程中的活动线程之间切换(这需要时间)。

在某些机器上,多线程通常是操作系统提供的假象。承载单个(非超线程)CPU 的机器不具备同时处理多个线程的能力。相反,单个 CPU 将部分基于线程的优先级在单位时间(称为时间片)内执行一个线程。当一个线程的时间片结束时,现有的线程被挂起,以允许另一个线程执行其业务。为了让一个线程记住在它被踢出之前发生了什么,每个线程都被赋予了写入线程本地存储(TLS)的能力,并被提供了一个单独的调用堆栈,如图 14-2 所示。

img/340876_10_En_14_Fig2_HTML.jpg

图 14-2。

Windows 进程/线程关系

如果线程的主题对你来说是新的,不要为细节伤脑筋。此时,请记住线程是 Windows 进程中唯一的执行路径。每个进程都有一个主线程(通过可执行文件的入口点创建),并且可能包含以编程方式创建的其他线程。

使用与进程交互。净核心

虽然进程和线程并不新鲜,但是您在。NET 核心平台发生了相当大的变化(变得更好)。为了给理解多线程程序集的构建铺平道路(参见第十五章),让我们先看看如何使用?NET 核心基本类库。

System.Diagnostics名称空间定义了几种类型,允许您以编程方式与进程和各种与诊断相关的类型(如系统事件日志和性能计数器)进行交互。在本章中,你只关心表 14-1 中定义的以过程为中心的类型。

表 14-1。

选择系统的成员。诊断名称空间

|

以流程为中心的系统类型。诊断名称空间

|

生命的意义

Process Process类提供对本地和远程进程的访问,并允许您以编程方式启动和停止进程。
ProcessModule 这个类型表示一个模块(*.dll*.exe)被加载到一个进程中。要知道,ProcessModule类型可以代表任何基于 COM 的模块。基于. NET 或传统的基于 C 的二进制文件。
ProcessModuleCollection 这提供了一个强类型的ProcessModule对象集合。
ProcessStartInfo 这指定了通过Process.Start()方法启动流程时使用的一组值。
ProcessThread 此类型表示给定进程中的线程。请注意,ProcessThread是一种用于诊断进程线程集的类型,而不是用于在一个进程中产生新的执行线程。
ProcessThreadCollection 这提供了一个强类型的ProcessThread对象集合。

System.Diagnostics.Process类允许您分析在给定机器(本地或远程)上运行的进程。Process类还提供了一些成员,允许您以编程方式启动和终止进程,查看(或修改)进程的优先级,以及获取给定进程中活动线程和/或加载模块的列表。表 14-2 列出了System.Diagnostics.Process的一些关键属性。

表 14-2。

选择流程类型的属性

|

财产

|

生命的意义

ExitTime 该属性获取与已经终止的进程相关联的时间戳(用一个DateTime类型表示)。
Handle 该属性返回操作系统与进程关联的句柄(由一个IntPtr表示)。这在构建时会很有用。需要与非托管代码通信的. NET 应用。
Id 此属性获取关联进程的 PID。
MachineName 此属性获取运行关联进程的计算机的名称。
MainWindowTitle MainWindowTitle获取进程主窗口的标题(如果进程没有主窗口,您会收到一个空的string)。
Modules 该属性提供对强类型ProcessModuleCollection类型的访问,该类型表示当前进程中加载的模块集(*.dll*.exe)。
ProcessName 该属性获取进程的名称(如您所想,这是应用本身的名称)。
Responding 此属性获取一个值,该值指示进程的用户界面是否正在响应用户输入(或者当前是否“挂起”)。
StartTime 该属性获取相关进程开始的时间(通过一个DateTime类型)。
Threads 该属性获取在相关进程中运行的一组线程(通过一组ProcessThread对象表示)。

除了刚刚检查的属性,System.Diagnostics.Process还定义了一些有用的方法(见表 14-3 )。

表 14-3。

选择流程类型的方法

|

方法

|

生命的意义

CloseMainWindow() 此方法通过向主窗口发送关闭消息来关闭具有用户界面的进程。
GetCurrentProcess() 这个静态方法返回一个新的代表当前活动进程的Process对象。
GetProcesses() 这个静态方法返回在给定机器上运行的新的Process对象的数组。
Kill() 此方法会立即停止关联的进程。
Start() 此方法启动一个进程。

枚举正在运行的进程

为了演示操作Process对象的过程(原谅冗余),创建一个名为ProcessManipulator的 C# 控制台应用项目,该项目在Program.cs类中定义了以下静态帮助器方法(确保在代码文件中导入了System.DiagnosticsSystem.Linq名称空间):

static void ListAllRunningProcesses()
{
  // Get all the processes on the local machine, ordered by
  // PID.
  var runningProcs =
    from proc
    in Process.GetProcesses(".")
    orderby proc.Id
    select proc;

  // Print out PID and name of each process.
  foreach(var p in runningProcs)
  {
    string info = $"-> PID: {p.Id}\tName: {p.ProcessName}";
    Console.WriteLine(info);
  }
  Console.WriteLine("************************************\n");
}

静态的Process.GetProcesses()方法返回一组Process对象,这些对象代表目标机器上正在运行的进程(这里显示的点符号代表本地计算机)。在你获得了Process对象的数组后,你可以调用表 14-2 和 14-3 中列出的任何成员。这里,您只是显示 PID 和每个进程的名称,按 PID 排序。按如下方式更新顶级语句:

using System;
using System.Diagnostics;
using System.Linq;

Console.WriteLine("***** Fun with Processes *****\n");
ListAllRunningProcesses();
Console.ReadLine();

当您运行该应用时,您将看到本地计算机上所有进程的名称和 PID。以下是我当前机器的部分输出(您的输出很可能会不同):

***** Fun with Processes *****
-> PID: 0       Name: Idle
-> PID: 4       Name: System
-> PID: 104     Name: Secure System
-> PID: 176     Name: Registry
-> PID: 908     Name: svchost
-> PID: 920     Name: smss
-> PID: 1016    Name: csrss
-> PID: 1020    Name: NVDisplay.Container
-> PID: 1104    Name: wininit
-> PID: 1112    Name: csrss
************************************

调查特定流程

除了获得给定机器上所有正在运行的进程的完整列表,静态Process.GetProcessById()方法还允许您通过相关的 PID 获得单个Process对象。如果您请求访问一个不存在的 PID,就会抛出一个ArgumentException异常。例如,如果您有兴趣获得一个代表 PID 为 30592 的进程的Process对象,您可以编写以下代码:

// If there is no process with the PID of 30592, a runtime exception will be thrown.
static void GetSpecificProcess()
{
  Process theProc = null;
  try
  {
    theProc = Process.GetProcessById(30592);
  }
  catch(ArgumentException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

至此,您已经学会了如何通过 PID 查找获得所有进程的列表,以及机器上的特定进程。虽然发现 PID 和进程名有些用处,但是Process类还允许您发现给定进程中使用的一组当前线程和库。让我们看看如何做到这一点。

调查进程的线程集

线程集由强类型的ProcessThreadCollection集合表示,它包含一些单独的ProcessThread对象。举例来说,将以下额外的静态 helper 函数添加到您当前的应用中:

static void EnumThreadsForPid(int pID)
{
  Process theProc = null;
  try
  {
    theProc = Process.GetProcessById(pID);
  }
  catch(ArgumentException ex)
  {
    Console.WriteLine(ex.Message);
    return;
  }

  // List out stats for each thread in the specified process.
  Console.WriteLine(
    "Here are the threads used by: {0}", theProc.ProcessName);
  ProcessThreadCollection theThreads = theProc.Threads;

  foreach(ProcessThread pt in theThreads)
  {
    string info =
       $"-> Thread ID: {pt.Id}\tStart Time: {pt.StartTime.ToShortTimeString()}\tPriority: {pt.PriorityLevel}";
    Console.WriteLine(info);
  }
  Console.WriteLine("************************************\n");
}

如您所见,System.Diagnostics.Process类型的Threads属性提供了对ProcessThreadCollection类的访问。这里,您将打印客户机指定的进程中每个线程的分配线程 ID、开始时间和优先级。现在,更新程序的顶级语句,提示用户输入要调查的 PID,如下所示:

...
// Prompt user for a PID and print out the set of active threads.
Console.WriteLine("***** Enter PID of process to investigate *****");
Console.Write("PID: ");
string pID = Console.ReadLine();
int theProcID = int.Parse(pID);

EnumThreadsForPid(theProcID);
Console.ReadLine();

当您运行程序时,您现在可以输入机器上任何进程的 PID,并查看该进程中使用的线程。以下输出显示了我的计算机上 PID 3804 使用的线程的部分列表,该计算机恰好托管 Edge:

***** Enter PID of process to investigate *****
PID: 3804
Here are the threads used by: msedge
-> Thread ID: 3464      Start Time: 01:20 PM    Priority: Normal
-> Thread ID: 19420     Start Time: 01:20 PM    Priority: Normal
-> Thread ID: 17780     Start Time: 01:20 PM    Priority: Normal
-> Thread ID: 22380     Start Time: 01:20 PM    Priority: Normal
-> Thread ID: 27580     Start Time: 01:20 PM    Priority: -4
…
************************************

除了IdStartTimePriorityLevel之外,ProcessThread类型还有其他感兴趣的成员。表 14-4 记录了一些感兴趣的成员。

表 14-4。

选择进程线程类型的成员

|

成员

|

生命的意义

CurrentPriority 获取线程的当前优先级
Id 获取线程的唯一标识符
IdealProcessor 设置此线程运行的首选处理器
PriorityLevel 获取或设置线程的优先级
ProcessorAffinity 设置相关线程可以运行的处理器
StartAddress 获取操作系统调用的启动此线程的函数的内存地址
StartTime 获取操作系统启动线程的时间
ThreadState 获取该线程的当前状态
TotalProcessorTime 获取该线程使用处理器的总时间
WaitReason 获取线程等待的原因

在您进一步阅读之前,请注意ProcessThread类型是而不是,它是用于在。NET 核心平台。更确切地说,ProcessThread是一种工具,用于获取正在运行的进程中的活动 Windows 线程的诊断信息。同样,您将在第十五章中研究如何使用System.Threading名称空间构建多线程应用。

调查进程的模块集

接下来,让我们看看如何迭代给定进程中托管的已加载模块的数量。当谈到进程时,模块是一个通用术语,用于描述由特定进程托管的给定*.dll(或*.exe本身)。当您通过Process.Modules属性访问ProcessModuleCollection时,您可以通过枚举托管在一个进程中的所有模块。基于. NET Core、基于 COM 或传统的基于 C 的库。思考下面的附加帮助函数,它将基于 PID 枚举特定进程中的模块:

static void EnumModsForPid(int pID)
{
  Process theProc = null;
  try
  {
    theProc = Process.GetProcessById(pID);
  }
  catch(ArgumentException ex)
  {
    Console.WriteLine(ex.Message);
    return;
  }

  Console.WriteLine("Here are the loaded modules for: {0}",
    theProc.ProcessName);
  ProcessModuleCollection theMods = theProc.Modules;
  foreach(ProcessModule pm in theMods)
  {
    string info = $"-> Mod Name: {pm.ModuleName}";
    Console.WriteLine(info);
  }
  Console.WriteLine("************************************\n");
}

为了查看一些可能的输出,让我们检查托管当前示例程序(ProcessManipulator)的进程的已加载模块。为此,运行应用,识别分配给ProcessManipulator.exe的 PID(通过任务管理器),并将该值传递给EnumModsForPid()方法。一旦你这样做了,你可能会惊讶地看到一个简单的控制台应用项目使用的*.dll列表(GDI32.dllUSER32.dllole32.dll等)。).以下输出是加载的模块的部分列表(为简洁起见进行了编辑):

Here are (some of) the loaded modules for: ProcessManipulator
Here are the loaded modules for: ProcessManipulator
-> Mod Name: ProcessManipulator.exe
-> Mod Name: ntdll.dll
-> Mod Name: KERNEL32.DLL
-> Mod Name: KERNELBASE.dll
-> Mod Name: USER32.dll
-> Mod Name: win32u.dll
-> Mod Name: GDI32.dll
-> Mod Name: gdi32full.dll
-> Mod Name: msvcp_win.dll
-> Mod Name: ucrtbase.dll
-> Mod Name: SHELL32.dll
-> Mod Name: ADVAPI32.dll
-> Mod Name: msvcrt.dll
-> Mod Name: sechost.dll
-> Mod Name: RPCRT4.dll
-> Mod Name: IMM32.DLL
-> Mod Name: hostfxr.dll
-> Mod Name: hostpolicy.dll
-> Mod Name: coreclr.dll
-> Mod Name: ole32.dll
-> Mod Name: combase.dll
-> Mod Name: OLEAUT32.dll
-> Mod Name: bcryptPrimitives.dll
-> Mod Name: System.Private.CoreLib.dll
…
************************************

以编程方式启动和停止进程

这里考察的System.Diagnostics.Process类的最后一个方面是Start()Kill()方法。正如您可以通过它们的名称收集的那样,这些成员分别提供了一种以编程方式启动和终止进程的方法。例如,考虑下面的静态StartAndKillProcess() helper 方法。

Note

根据操作系统的安全设置,您可能需要以管理员权限运行才能启动新进程。

static void StartAndKillProcess()
{
  Process proc = null;

  // Launch Edge, and go to Facebook!
  try
  {
    proc = Process.Start(@"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", "www.facebook.com");
  }
  catch (InvalidOperationException ex)
  {
    Console.WriteLine(ex.Message);
  }

  Console.Write("--> Hit enter to kill {0}...",
    proc.ProcessName);
  Console.ReadLine();

  // Kill all of the msedge.exe processes.
  try
  {
    foreach (var p in Process.GetProcessesByName("MsEdge"))
    {
      p.Kill(true);
    }
  }
  catch (InvalidOperationException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

静态的Process.Start()方法被重载了几次。至少,您需要指定想要启动的进程的路径和文件名。这个例子使用了一个Start()方法的变体,它允许您指定任何额外的参数来传递到程序的入口点,在这个例子中是要加载的 web 页面。

在您调用了Start()方法之后,您将返回一个对新激活的进程的引用。当您想要终止流程时,只需调用实例级的Kill()方法。在本例中,由于 Microsoft Edge 启动了许多进程,因此您将循环终止所有已启动的进程。您还将对Start()Kill()的调用包装在try / catch块中,以处理任何InvalidOperationException错误。这在调用Kill()方法时尤其重要,因为如果在调用Kill()之前进程已经终止,就会出现这个错误。

Note

使用时。NET Framework(之前的版本。NET Core),Process.Start()方法允许启动进程的完整路径和文件名或操作系统快捷方式(例如,msedge)。和。NET 核心和跨平台支持,您必须指定完整的路径和文件名。可以使用ProcessStartInfo来利用操作系统关联,这将在接下来的两节中介绍。

使用 ProcessStartInfo 类控制进程启动

Process.Start()方法还允许您传入一个System.Diagnostics.ProcessStartInfo类型来指定关于给定流程应该如何运行的附加信息。下面是ProcessStartInfo的部分定义(参见文档了解全部细节):

public sealed class ProcessStartInfo : object
{
  public ProcessStartInfo();
  public ProcessStartInfo(string fileName);
  public ProcessStartInfo(string fileName, string arguments);
  public string Arguments { get; set; }
  public bool CreateNoWindow { get; set; }
  public StringDictionary EnvironmentVariables { get; }
  public bool ErrorDialog { get; set; }
  public IntPtr ErrorDialogParentHandle { get; set; }
  public string FileName { get; set; }
  public bool LoadUserProfile { get; set; }
  public SecureString Password { get; set; }
  public bool RedirectStandardError { get; set; }
  public bool RedirectStandardInput { get; set; }
  public bool RedirectStandardOutput { get; set; }
  public Encoding StandardErrorEncoding { get; set; }
  public Encoding StandardOutputEncoding { get; set; }
  public bool UseShellExecute { get; set; }
  public string Verb { get; set; }
  public string[] Verbs { get; }
  public ProcessWindowStyle WindowStyle { get; set; }
  public string WorkingDirectory { get; set; }
}

为了说明如何微调您的流程启动,下面是一个修改后的StartAndKillProcess()版本,它将加载 Microsoft Edge 并导航到 www.facebook.com ,使用 windows 关联MsEdge:

static void StartAndKillProcess()
{
  Process proc = null;

  // Launch Microsoft Edge, and go to Facebook, with maximized window.
  try
  {
    ProcessStartInfo startInfo = new
      ProcessStartInfo("MsEdge", "www.facebook.com");
    startInfo.UseShellExecute = true;
    proc = Process.Start(startInfo);
  }
  catch (InvalidOperationException ex)
  {
    Console.WriteLine(ex.Message);
  }
...
}

英寸 NET Core 中,UseShellExecute属性默认为 false,而在以前版本的。NET 中,UseShellExecute属性默认为 true。这就是上一版本流程的原因。如果不使用ProcessStartInfo并将UseShellExecute属性设置为 true,这里显示的 Start()将不再工作:

Process.Start("msedge")

通过 ProcessStartInfo 利用操作系统动词

除了使用操作系统快捷方式启动应用,您还可以利用与ProcessStartInfo的文件关联。在 Windows 上,如果右键单击 Word 文档,会出现编辑或打印文档的选项。让我们使用ProcessStartInfo来确定可用的动词,然后使用它们来操纵流程。

使用以下代码创建一个新方法:

static void UseApplicationVerbs()
{
  int i = 0;
  //adjust this path and name to a document on your machine
  ProcessStartInfo si =
    new ProcessStartInfo(@"..\TestPage.docx");
  foreach (var verb in si.Verbs)
  {
    Console.WriteLine($"  {i++}. {verb}");
  }
  si.WindowStyle = ProcessWindowStyle.Maximized;
  si.Verb = "Edit";
  si.UseShellExecute = true;
  Process.Start(si);
}

运行此代码时,第一部分打印出 Word 文档的所有可用动词,如下所示:

***** Fun with Processes *****
  0\. Edit
  1\. OnenotePrintto
  2\. Open
  3\. OpenAsReadOnly
  4\. Print
  5\. Printto
  6\. ViewProtected

WindowStyle设置为最大化后,动词被设置为Edit,这将在编辑模式下打开文档。如果将动词设置为Print,文档将被直接发送到打印机。

现在,您已经了解了 Windows 进程的作用以及如何从 C# 代码中与它们进行交互,您已经准备好研究. NET 应用域的概念了。

Note

应用运行的目录取决于您如何运行示例应用。如果使用 CLI 命令dotnet run,当前目录与项目文件所在的目录相同。如果使用的是 Visual Studio,当前目录将是编译后的程序集的目录,也就是.\bin\debug\net5.0。您需要相应地调整 Word 文档的路径。

理解。NET 应用域

在下面。NET 和。NET 核心平台中,可执行文件不像传统的非托管应用那样直接驻留在 Windows 进程中。更确切地说。NET 和。NET 核心可执行文件由一个名为应用域的进程中的一个逻辑分区托管。传统 Windows 进程的这种划分提供了几个好处,其中一些如下:

  • AppDomains 是。NET 核心平台,因为这种逻辑划分抽象出了底层操作系统如何表示加载的可执行文件的差异。

  • 就处理能力和内存而言,AppDomains 远比成熟的进程便宜。因此,CoreCLR 可以比正式进程更快地加载和卸载应用域,并且可以极大地提高服务器应用的可伸缩性。

appdomain 与进程中的其他 appdomain 完全隔离。鉴于这一事实,请注意,在一个 AppDomain 中运行的应用无法在另一个 AppDomain 中获得任何类型的数据(全局变量或静态字段),除非它们使用分布式编程协议。

Note

对 AppDomains 的支持在中有所更改。NET 核心。英寸 NET Core,正好有一个 AppDomain。不再支持创建新的 AppDomains,因为它们需要运行时支持,并且创建起来通常很昂贵。ApplicationLoadContext(本章稍后介绍)在中提供组件隔离。NET 核心。

系统。AppDomain 类

AppDomain类在很大程度上被弃用。NET 核心。而剩下的大部分支持都是为了从。NET 4.x 到。NET Core 更容易,剩下的特性仍然可以提供价值,这将在接下来的两节中介绍。

与默认应用域交互

您的应用可以使用静态的AppDomain.CurrentDomain属性访问默认的应用域。有了这个访问点之后,您可以使用AppDomain的方法和属性来执行一些运行时诊断。

要了解如何与默认应用域交互,首先创建一个名为DefaultAppDomainApp的新控制台应用项目。现在,用下面的逻辑更新您的Program.cs类,这将使用AppDomain类的一些成员简单地显示关于默认应用域的一些细节:

using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;

Console.WriteLine("***** Fun with the default AppDomain *****\n");
DisplayDADStats();
Console.ReadLine();

static void DisplayDADStats()
{
  // Get access to the AppDomain for the current thread.
  AppDomain defaultAD = AppDomain.CurrentDomain;
  // Print out various stats about this domain.
  Console.WriteLine("Name of this domain: {0}",
    defaultAD.FriendlyName);
  Console.WriteLine("ID of domain in this process: {0}",
    defaultAD.Id);
  Console.WriteLine("Is this the default domain?: {0}",
    defaultAD.IsDefaultAppDomain());
  Console.WriteLine("Base directory of this domain: {0}",
    defaultAD.BaseDirectory);
  Console.WriteLine("Setup Information for this domain:");
  Console.WriteLine("\t Application Base: {0}",
    defaultAD.SetupInformation.ApplicationBase);
  Console.WriteLine("\t Target Framework: {0}",
    defaultAD.SetupInformation.TargetFrameworkName);
}

此示例的输出如下所示:

***** Fun with the default AppDomain *****
Name of this domain: DefaultAppDomainApp
ID of domain in this process: 1
Is this the default domain?: True
Base directory of this domain: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\DefaultAppDomainApp\DefaultAppDomainApp\bin\Debug\net5.0\
Setup Information for this domain:
  Application Base: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\DefaultAppDomainApp\DefaultAppDomainApp\bin\Debug\net5.0\
  Target Framework: .NETCoreApp,Version=v5.0

请注意,默认应用域的名称将与其中包含的可执行文件的名称相同(在本例中为DefaultAppDomainApp.exe)。另请注意,将用于探测外部所需私有程序集的基目录值映射到已部署的可执行文件的当前位置。

枚举加载的程序集

也有可能发现所有加载的。NET 核心程序集在给定的应用域中使用实例级的GetAssemblies()方法。这个方法将返回给你一个Assembly对象的数组(在第十七章中涉及)。为此,您必须将System.Reflection名称空间添加到您的代码文件中(正如您在本节前面所做的)。

举例来说,在Program类中定义一个名为ListAllAssembliesInAppDomain()的新方法。这个帮助器方法将获取所有加载的程序集,并打印每个程序集的友好名称和版本。

static void ListAllAssembliesInAppDomain()
{
  // Get access to the AppDomain for the current thread.
  AppDomain defaultAD = AppDomain.CurrentDomain;

  // Now get all loaded assemblies in the default AppDomain.
  Assembly[] loadedAssemblies = defaultAD.GetAssemblies();
  Console.WriteLine("***** Here are the assemblies loaded in {0} *****\n",
    defaultAD.FriendlyName);
  foreach(Assembly a in loadedAssemblies)
  {
    Console.WriteLine($"-> Name, Version: {a.GetName().Name}:{a.GetName().Version}" );
  }
}

假设您已经更新了顶级语句来调用这个新成员,您将会看到承载您的可执行文件的应用域当前正在使用。NET 核心库:

***** Here are the assemblies loaded in DefaultAppDomainApp *****
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Threading:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0

现在要明白,当您编写新的 C# 代码时,加载的程序集列表可能会随时改变。例如,假设您已经更新了您的ListAllAssembliesInAppDomain()方法以利用 LINQ 查询,该查询将按名称对加载的程序集进行排序,如下所示:

using System.Linq;
static void ListAllAssembliesInAppDomain()
{
  // Get access to the AppDomain for the current thread.
  AppDomain defaultAD = AppDomain.CurrentDomain;

  // Now get all loaded assemblies in the default AppDomain.
  var loadedAssemblies =
    defaultAD.GetAssemblies().OrderBy(x=>x.GetName().Name);
  Console.WriteLine("***** Here are the assemblies loaded in {0} *****\n", defaultAD.FriendlyName);
  foreach(Assembly a in loadedAssemblies)
  {
    Console.WriteLine($"-> Name, Version: {a.GetName().Name}:{a.GetName().Version}" );
  }
}

如果您再次运行该程序,您将会看到System.Linq.dll也被加载到内存中。

** Here are the assemblies loaded in DefaultAppDomainApp **
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Linq:5.0.0.0
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0.0.0
-> Name, Version: System.Threading:5.0.0

具有应用加载上下文的程序集隔离

正如您刚才看到的,AppDomains 是用于托管的逻辑分区。NET 核心程序集。此外,应用域可以进一步细分为多个加载上下文边界。从概念上讲,加载上下文创建了加载、解析和可能卸载一组程序集的范围。简而言之,. NET 核心加载上下文为单个 AppDomain 提供了一种为给定对象建立“特定主目录”的方式。

Note

虽然理解过程和应用域非常重要,但是大多数。NET 核心应用永远不会要求您使用对象上下文。我已经包括了这个概述材料,只是为了描绘一个更完整的画面。

AssemblyLoadContext类提供了将附加程序集加载到它们自己的上下文中的能力。为了进行演示,首先添加一个名为ClassLibary1的类库项目,并将其添加到您当前的解决方案中。使用。NET Core CLI,在包含当前解决方案的目录中执行以下命令:

dotnet new classlib -lang c# -n ClassLibrary1 -o .\ClassLibrary1 -f net5.0
dotnet sln .\Chapter14_AllProjects.sln add .\ClassLibrary1

接下来,通过执行以下 CLI 命令,从DefaultAppDomainApp添加对 ClassLibrary1 项目的引用:

dotnet add DefaultAppDomainApp reference ClassLibrary1

如果使用的是 Visual Studio,请在解决方案资源管理器中右击解决方案节点,选择“添加➤新项目”,然后添加一个名为 ClassLibrary1 的. NET 核心类库。这将创建项目并将其添加到您的解决方案中。接下来,通过右键单击 DefaultAppDomainApp 项目并选择“添加➤引用”来添加对此新项目的引用。选择左边栏中的项目➤解决方案选项,并选中类库 1 复选框,如图 14-3 所示。

img/340876_10_En_14_Fig3_HTML.jpg

图 14-3。

在 Visual Studio 中添加项目引用

在这个新类库中,添加一个Car类,如下所示:

namespace ClassLibrary1
{
  public class Car
  {
    public string PetName { get; set; }
    public string Make { get; set; }
    public int Speed { get; set; }
  }
}

有了这个新的程序集,添加下面的using语句:

using System.IO;
using System.Runtime.Loader;

您将添加的下一个方法需要已经在Program.cs中添加的System.IOSystem.Runtime.Loader using语句。该方法如下所示:

static void LoadAdditionalAssembliesDifferentContexts()
{
  var path =
   Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
                "ClassLibrary1.dll");
  AssemblyLoadContext lc1 =
    new AssemblyLoadContext("NewContext1",false);
  var cl1 = lc1.LoadFromAssemblyPath(path);
  var c1 = cl1.CreateInstance("ClassLibrary1.Car");

  AssemblyLoadContext lc2 =
    new AssemblyLoadContext("NewContext2",false);
  var cl2 = lc2.LoadFromAssemblyPath(path);
  var c2 = cl2.CreateInstance("ClassLibrary1.Car");
  Console.WriteLine("*** Loading Additional Assemblies in Different Contexts ***");
  Console.WriteLine($"Assembly1 Equals(Assembly2) {cl1.Equals(cl2)}");
  Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}");
  Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}");
  Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}

第一行使用静态的Path.Combine方法为ClassLibrary1组件构建目录。

Note

您可能想知道为什么要为动态加载的程序集创建引用。这是为了确保当项目构建时,ClassLibrary1程序集也构建,并且与DefaultAppDomainApp在同一目录中。这仅仅是为了这个例子的方便。不需要引用将动态加载的程序集。

接下来,代码创建一个名为NewContext1(方法的第一个参数)的新AssemblyLoadContext,并且不支持卸载(第二个参数)。这个LoadContext用于加载ClassLibrary1程序集,然后创建一个Car类的实例。如果其中一些代码对你来说是新的,我们会在第十九章对其进行更全面的解释。使用新的AssemblyLoadContext重复该过程,然后比较程序集和类的相等性。当您运行这个新方法时,您将看到以下输出:

*** Loading Additional Assemblies in Different Contexts ***
Assembly1 Equals(Assembly2) False
Assembly1 == Assembly2 False
Class1.Equals(Class2) False
Class1 == Class2 False

这表明同一个程序集已被加载到应用域中两次。正如所料,类也是不同的。

接下来,添加一个新方法,它将从同一个AssemblyLoadContext加载程序集。

static void LoadAdditionalAssembliesSameContext()
{
  var path =
   Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
                "ClassLibrary1.dll");
  AssemblyLoadContext lc1 =
    new AssemblyLoadContext(null,false);
  var cl1 = lc1.LoadFromAssemblyPath(path);
  var c1 = cl1.CreateInstance("ClassLibrary1.Car");
  var cl2 = lc1.LoadFromAssemblyPath(path);
  var c2 = cl2.CreateInstance("ClassLibrary1.Car");
  Console.WriteLine("*** Loading Additional Assemblies in Same Context ***");
  Console.WriteLine($"Assembly1.Equals(Assembly2) {cl1.Equals(cl2)}");
  Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}");
  Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}");
  Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}

这段代码的主要区别是只创建了一个AssemblyLoadContext。现在,当ClassLibrary1程序集被加载两次时,第二个程序集只是一个指向第一个程序集实例的指针。运行代码会产生以下输出:

*** Loading Additional Assemblies in Same Context ***
Assembly1.Equals(Assembly2) True
Assembly1 == Assembly2 True
Class1.Equals(Class2) False
Class1 == Class2 False

汇总流程、AppDomains 和加载上下文

至此,您应该对运行库如何承载. NET 核心程序集有了更好的了解。如果前几页对你来说有点太低级了,不用担心。在很大程度上。NET Core 代表您自动处理进程、应用域和负载上下文的细节。不过,好消息是这些信息为理解。NET 核心平台。

摘要

本章的重点是研究. NET 核心应用是如何由?NET 核心平台。正如您所看到的,长期存在的 Windows 进程的概念已经被改变,以适应 CoreCLR 的需要。单个流程(可以通过System.Diagnostics.Process类型以编程方式操作)现在由一个应用域组成,它代表流程中隔离和独立的边界。

应用域能够承载和执行任意数量的相关程序集。此外,单个应用域可以包含任意数量的加载上下文,用于进一步的程序集隔离。使用这种额外级别的类型隔离,CoreCLR 可以确保特殊需要的对象得到正确处理。

十五、多线程、并行和异步编程

没有人喜欢使用在执行过程中反应迟钝的应用。此外,没有人喜欢在一个应用中启动一个任务(可能是通过点击一个工具栏项启动的),这个任务会阻止程序的其他部分尽可能地响应。在发布之前。网(和。NET Core),构建能够执行多项任务的应用通常需要编写使用 Windows 线程 API 的复杂 C++代码。谢天谢地。NET/。NET Core platform 为您提供了多种方法来构建软件,这些软件可以在独特的执行路径上执行复杂的操作,而棘手的问题却少得多。

本章首先定义了“多线程应用”的整体性质接下来,将向您介绍从开始提供的原始线程名称空间。NET 1.0,具体是System.Threading。在这里,你将考察众多类型(ThreadThreadStart等)。)允许您显式地创建额外的执行线程并同步您的共享资源,这有助于确保多个线程能够以非易失的方式共享数据。

本章的其余部分将研究三种最新的技术。NET 核心开发人员可以使用来构建多线程软件,特别是任务并行库(TPL)、并行 LINQ (PLINQ)以及相对较新的(从 C# 6 开始)C# 固有异步关键字(asyncawait)。正如您将看到的,这些特性可以极大地简化您构建响应性多线程软件应用的方式。

进程/AppDomain/上下文/线程关系

在第十四章中,线程被定义为可执行应用中的执行路径。虽然很多人。NET 核心应用可以过着快乐而高效的单线程生活,程序集的主线程(当应用的入口点执行时由运行时产生)可以随时创建执行的辅助线程来执行额外的工作单元。通过创建额外的线程,您可以构建响应速度更快(但不一定在单核机器上执行得更快)的应用。

名称空间System.Threading是随。NET 1.0 并提供了一种构建多线程应用的方法。Thread类可能是核心类型,因为它代表一个给定的线程。如果您想以编程方式获取对当前执行给定成员的线程的引用,只需调用静态的Thread.CurrentThread属性,如下所示:

static void ExtractExecutingThread()
{
  // Get the thread currently
  // executing this method.
  Thread currThread = Thread.CurrentThread;
}

回想一下。NET 核心,只有一个 AppDomain。即使不能创建额外的 AppDomain,一个应用的 AppDomain 在任何给定时间都可以有许多线程在其中执行。要获取对托管应用的 AppDomain 的引用,请调用静态的Thread.GetDomain()方法,如下所示:

static void ExtractAppDomainHostingThread()
{
  // Obtain the AppDomain hosting the current thread.
  AppDomain ad = Thread.GetDomain();
}

单个线程也可以在任何给定的时间被移动到一个执行上下文中,并且可以在。NET 核心运行时。当您想要获得一个线程正在其中执行的当前执行上下文时,使用静态的Thread.CurrentThread.ExecutionContext属性,如下所示:

static void ExtractCurrentThreadExecutionContext()
{
  // Obtain the execution context under which the
  // current thread is operating.
  ExecutionContext ctx =
    Thread.CurrentThread.ExecutionContext;
}

再说一遍。NET Core Runtime 监督将线程移入(和移出)执行上下文。作为一名. NET 核心开发人员,您通常可以幸福地保持不知道给定线程的结束位置。然而,您应该知道获得底层原语的各种方法。

并发的问题

多线程编程的众多“乐趣”(也就是痛苦的方面)之一是,您几乎无法控制底层操作系统或运行时如何使用它的线程。例如,如果您创建了一个新的执行线程的代码块,您不能保证该线程立即执行。相反,这些代码只指示操作系统/运行时尽快执行线程(通常是在线程调度程序开始执行时)。

此外,鉴于线程可以根据运行时的需要在应用和上下文边界之间移动,您必须注意应用的哪些方面是线程易变的(例如,受多线程访问的影响),哪些操作是原子的(线程易变的操作是危险的!).

为了说明这个问题,假设一个线程正在调用一个特定对象的方法。现在假设线程调度器指示该线程暂停其活动,以允许另一个线程访问同一对象的同一方法。

如果原始线程没有完成其操作,则第二个传入线程可能正在查看处于部分修改状态的对象。在这一点上,第二个线程基本上是在读取假数据,这肯定会让位于极其奇怪(并且难以发现)的错误,这些错误甚至更难复制和调试。

另一方面,原子操作在多线程环境中总是安全的。可悲的是,在美国几乎没有什么行动。NET 核心基本类库,保证是原子的。甚至给成员变量赋值的行为也不是原子的!除非。NET 核心文档明确指出操作是原子性的,您必须假设它是线程易变的,并采取预防措施。

线程同步的作用

在这一点上,应该清楚多线程程序本身是非常不稳定的,因为许多线程可以同时(或多或少)在共享资源上操作。为了保护应用的资源免受可能的损坏。NET 核心开发人员必须使用任意数量的线程原语(比如锁、监视器和[Synchronization]属性或语言关键字支持)来控制执行线程之间的访问。

虽然。NET Core 平台并不能使构建健壮的多线程应用的困难完全消失,这个过程已经大大简化了。使用在System.Threading名称空间、任务并行库以及 C# asyncawait语言关键字中定义的类型,您可以以最少的麻烦和麻烦处理多线程。

在深入到System.Threading名称空间、TPL、C# asyncawait关键字之前,您将首先检查。NET 核心委托类型可用于以异步方式调用方法。虽然可以肯定的是。NET 4.6 新的 C# asyncawait关键字为异步委托提供了一个更简单的替代方法,知道如何使用这种方法与代码交互仍然很重要(相信我,生产中有大量代码使用异步委托)。

系统。线程命名空间

在下面。NET 和。NET 核心平台中,System.Threading命名空间提供了支持直接构建多线程应用的类型。除了提供允许您与. NET 核心运行时线程交互的类型之外,此命名空间还定义了允许访问。NET Core 运行时维护的线程池,一个简单的(非基于 GUI 的)Timer类,以及许多用于提供对共享资源的同步访问的类型。表 15-1 列出了这个名称空间的一些重要成员。(请务必查阅。NET Core SDK 文档以获得完整的详细信息。)

表 15-1。

系统的核心类型。线程命名空间

|

类型

|

生命的意义

Interlocked 这种类型为由多个线程共享的变量提供原子操作。
Monitor 这种类型使用锁和等待/信号来提供线程对象的同步。C# lock关键字使用了一个Monitor对象。
Mutex 这个同步原语可用于应用域边界之间的同步。
ParameterizedThreadStart 此委托允许线程调用接受任意数量参数的方法。
Semaphore 这种类型允许您限制可以并发访问资源的线程数量。
Thread 此类型表示在中执行的线程。NET 核心运行时。使用这种类型,您可以在原始 AppDomain 中生成额外的线程。
ThreadPool 此类型允许您与。NET 核心运行时——给定进程中维护的线程池。
ThreadPriority 该枚举表示线程的优先级(HighestNormal等)。).
ThreadStart 此委托用于指定给定线程要调用的方法。与ParameterizedThreadStart委托不同,ThreadStart的目标必须总是有相同的原型。
ThreadState 该枚举指定了线程可能采用的有效状态(RunningAborted等)。).
Timer 这种类型提供了一种以指定间隔执行方法的机制。
TimerCallback 该委托类型与Timer类型一起使用。

系统。线程.线程类

System.Threading名称空间的所有类型中,最原始的是Thread。此类表示 AppDomain 中给定执行路径周围的面向对象包装。此类型还定义了几个方法(静态和实例级),允许您在当前 AppDomain 中创建新线程,以及挂起、停止和销毁线程。考虑表 15-2 中的关键静态成员列表。

表 15-2。

线程类型的关键静态成员

|

静态构件

|

生命的意义

ExecutionContext 此只读属性返回与执行的逻辑线程相关的信息,包括安全性、调用、同步、本地化和事务上下文。
CurrentThread 此只读属性返回对当前运行线程的引用。
Sleep() 此方法将当前线程挂起一段指定的时间。

Thread类还支持几个实例级成员,其中一些如表 15-3 所示。

表 15-3。

选择线程类型的实例级成员

|

实例级成员

|

生命的意义

IsAlive 返回一个 Boolean 值,指示该线程是否已启动(尚未终止或中止)。
IsBackground 获取或设置一个值,该值指示此线程是否为“后台线程”(稍后将提供更多详细信息)。
Name 允许您建立线程的友好文本名称。
Priority 获取或设置线程的优先级,可以从ThreadPriority枚举中为其赋值。
ThreadState 获取该线程的状态,可以从ThreadState枚举中为其赋值。
Abort() 指示。NET Core 运行时尽快终止线程。
Interrupt() 从合适的等待周期中断(例如唤醒)当前线程。
Join() 阻塞调用线程,直到指定的线程(调用Join()的线程)退出。
Resume() 恢复先前挂起的线程。
Start() 指示。NET 核心运行时尽快执行线程。
Suspend() 挂起线程。如果线程已经被挂起,调用Suspend()没有任何效果。

Note

中止或挂起一个活动线程通常被认为是一个坏主意。当您这样做时,线程在受到干扰或终止时有可能会“泄漏”其工作负载(尽管可能性很小)。

获取当前执行线程的统计信息

回想一下,可执行程序集的入口点(即顶级语句或Main()方法)运行在执行的主线程上。为了说明Thread类型的基本用法,假设您有一个名为 ThreadStats 的新控制台应用项目。如您所知,静态的Thread.CurrentThread属性检索一个代表当前执行线程的Thread对象。一旦获得了当前线程,就可以打印出各种统计数据,如下所示:

// Be sure to import the System.Threading namespace.
using System;
using System.Threading;
Console.WriteLine("***** Primary Thread stats *****\n");

// Obtain and name the current thread.
Thread primaryThread = Thread.CurrentThread;
primaryThread.Name = "ThePrimaryThread";

// Print out some stats about this thread.
Console.WriteLine("ID of current thread: {0}",
  primaryThread.ManagedThreadId);
Console.WriteLine("Thread Name: {0}",
  primaryThread.Name);
Console.WriteLine("Has thread started?: {0}",
  primaryThread.IsAlive);
Console.WriteLine("Priority Level: {0}",
  primaryThread.Priority);
Console.WriteLine("Thread State: {0}",
  primaryThread.ThreadState);
Console.ReadLine();

以下是当前输出:

***** Primary Thread stats *****
ID of current thread: 1
Thread Name: ThePrimaryThread
Has thread started?: True
Priority Level: Normal
Thread State: Running

名称属性

注意,Thread类支持一个名为Name的属性。如果不设置这个值,Name将返回一个空的string。然而,一旦你给一个给定的Thread对象分配了一个友好的字符串名字,你就可以大大简化你的调试工作。如果您使用的是 Visual Studio,则可以在调试会话期间访问“线程”窗口(在程序运行时选择“调试➤ Windows ➤线程”)。从图 15-1 可以看出,可以快速识别出想要诊断的线程。

img/340876_10_En_15_Fig1_HTML.jpg

图 15-1。

使用 Visual Studio 调试线程

优先属性

接下来,请注意,Thread类型定义了一个名为Priority的属性。默认情况下,所有线程的优先级都是Normal。然而,你可以在线程生命周期的任何时候使用Priority属性和相关的System.Threading.ThreadPriority枚举来改变它,就像这样:

public enum ThreadPriority
{
  Lowest,
  BelowNormal,
  Normal, // Default value.
  AboveNormal,
  Highest
}

如果你要给一个线程的优先级指定一个不同于默认值(ThreadPriority.Normal)的值,要知道你不能直接控制线程调度器何时在线程间切换。线程的优先级为。NET 核心运行时关于线程活动的重要性。因此,具有值ThreadPriority.Highest的线程不一定保证被给予最高优先级。

同样,如果线程调度器全神贯注于给定的任务(例如,同步对象、切换线程或移动线程),则优先级很可能会相应地改变。然而,所有的事情都是平等的。NET Core Runtime 将读取这些值,并指示线程调度程序如何最好地分配时间片。具有相同线程优先级的每个线程应该获得相同的时间来执行它们的工作。

在大多数情况下,您很少(如果有的话)需要直接改变线程的优先级。理论上,可以提高一组线程的优先级,从而阻止优先级较低的线程在它们需要的级别上执行(所以要小心)。

手动创建辅助线程

当您想要以编程方式创建额外的线程来执行某个工作单元时,请在使用System.Threading名称空间的类型时遵循这个可预测的过程:

  1. 创建一个方法作为新线程的入口点。

  2. 创建一个新的ParameterizedThreadStart(或ThreadStart)委托,将步骤 1 中定义的方法的地址传递给构造函数。

  3. 创建一个Thread对象,将ParameterizedThreadStart/ThreadStart委托作为构造函数参数传递。

  4. 建立任何初始线程特征(名称、优先级等。).

  5. 调用Thread.Start()方法。这将尽快在步骤 2 中创建的委托所引用的方法处启动线程。

如步骤 2 所述,您可以使用两种不同的委托类型来“指向”辅助线程将执行的方法。委托可以指向任何不带参数且不返回任何内容的方法。当方法被设计为只在后台运行而不需要进一步交互时,此委托会很有帮助。

ThreadStart的限制是不能传入参数进行处理。然而,ParameterizedThreadStart委托类型允许类型为System.Object的单个参数。鉴于任何东西都可以表示为一个System.Object,您可以通过一个定制的类或结构传入任意数量的参数。但是,请注意,ThreadStartParameterizedThreadStart委托只能指向返回void的方法。

使用 ThreadStart 委托

为了说明构建多线程应用的过程(以及演示这样做的有用性),假设您有一个名为 SimpleMultiThreadApp 的控制台应用项目,该项目允许最终用户选择应用是使用单个主线程来执行其任务,还是使用两个单独的执行线程来分担其工作负载。

假设您已经导入了System.Threading名称空间,您的第一步是定义一个方法来执行(可能的)辅助线程的工作。为了将重点放在构建多线程程序的机制上,该方法将简单地在控制台窗口中打印出一系列数字,每次大约暂停两秒钟。下面是Printer类的完整定义:

using System;
using System.Threading;

namespace SimpleMultiThreadApp
{
  public class Printer
  {
    public void PrintNumbers()
    {
      // Display Thread info.
      Console.WriteLine("-> {0} is executing PrintNumbers()",
        Thread.CurrentThread.Name);

      // Print out numbers.
      Console.Write("Your numbers: ");
      for(int i = 0; i < 10; i++)
      {
        Console.Write("{0}, ", i);
        Thread.Sleep(2000);
      }
      Console.WriteLine();
    }
  }
}

现在,在Program.cs中,您将添加顶级语句,首先提示用户确定是使用一个还是两个线程来执行应用的工作。如果用户请求单个线程,您只需在主线程中调用PrintNumbers()方法。但是,如果用户指定了两个线程,您将创建一个指向PrintNumbers()ThreadStart委托,将这个委托对象传递给一个新的Thread对象的构造函数,并调用Start()来通知。此线程已准备好进行处理。下面是完整的实现:

using System;
using System.Threading;
using SimpleMultiThreadApp;

Console.WriteLine("***** The Amazing Thread App *****\n");
Console.Write("Do you want [1] or [2] threads? ");
string threadCount = Console.ReadLine();

// Name the current thread.
Thread primaryThread = Thread.CurrentThread;
primaryThread.Name = "Primary";

// Display Thread info.
Console.WriteLine("-> {0} is executing Main()",
Thread.CurrentThread.Name);

// Make worker class.
Printer p = new Printer();

switch(threadCount)
{
  case "2":
    // Now make the thread.
    Thread backgroundThread =
      new Thread(new ThreadStart(p.PrintNumbers));
    backgroundThread.Name = "Secondary";
    backgroundThread.Start();
    break;
  case "1":
    p.PrintNumbers();
    break;
  default:
    Console.WriteLine("I don't know what you want...you get 1 thread.");
    goto case "1";
}
// Do some additional work.
Console.WriteLine("This is on the main thread, and we are finished.");
Console.ReadLine();

现在,如果您用单线程运行这个程序,您会发现最终的消息框不会显示消息,直到整个数字序列打印到控制台。由于在打印每个数字后,您会明显地暂停大约两秒钟,这将导致不太好的最终用户体验。但是,如果您选择两个线程,消息框会立即显示,因为有一个唯一的Thread对象负责将数字打印到控制台。

使用 ParameterizedThreadStart 委托

回想一下,ThreadStart委托只能指向返回void且不带参数的方法。虽然在某些情况下这可能符合要求,但是如果您想将数据传递给在辅助线程上执行的方法,您将需要使用ParameterizedThreadStart委托类型。举例来说,创建一个名为 AddWithThreads 的新控制台应用项目,并导入System.Threading名称空间。现在,假设ParameterizedThreadStart可以指向任何带System.Object参数的方法,您将创建一个包含要添加的数字的自定义类型,如下所示:

namespace AddWithThreads
{
  class AddParams
  {
    public int a, b;

    public AddParams(int numb1, int numb2)
    {
      a = numb1;
      b = numb2;
    }
  }
}

接下来,在Program类中创建一个方法,该方法将接受一个AddParams参数并打印所涉及的两个数字的和,如下所示:

void Add(object data)
{
  if (data is AddParams ap)
  {
    Console.WriteLine("ID of thread in Add(): {0}",
      Thread.CurrentThread.ManagedThreadId);

    Console.WriteLine("{0} + {1} is {2}",
      ap.a, ap.b, ap.a + ap.b);
  }
}

Program.cs中的代码很简单。简单地使用ParameterizedThreadStart而不是ThreadStart,就像这样:

using System;
using System.Threading;
using AddWithThreads;

Console.WriteLine("***** Adding with Thread objects *****");
Console.WriteLine("ID of thread in Main(): {0}",
  Thread.CurrentThread.ManagedThreadId);

// Make an AddParams object to pass to the secondary thread.
AddParams ap = new AddParams(10, 10);
Thread t = new Thread(new ParameterizedThreadStart(Add));
t.Start(ap);

// Force a wait to let other thread finish.
Thread.Sleep(5);
Console.ReadLine();

AutoResetEvent 类

在前面的几个例子中,没有一种明确的方法来知道辅助线程何时完成了它的工作。在最后一个例子中,Sleep在任意时间被调用,以让另一个线程完成。一种简单且线程安全的强制线程等待另一个线程完成的方法是使用AutoResetEvent类。在需要等待的线程中,创建这个类的一个实例,并将false传递给构造函数,以表示您还没有得到通知。然后,在你愿意等待的点上,调用WaitOne()方法。下面是对Program.cs类的更新,它将使用静态级别的AutoResetEvent成员变量来做这件事:

AutoResetEvent _waitHandle = new AutoResetEvent(false);

Console.WriteLine("***** Adding with Thread objects *****");
Console.WriteLine("ID of thread in Main(): {0}",
  Thread.CurrentThread.ManagedThreadId);
AddParams ap = new AddParams(10, 10);
Thread t = new Thread(new ParameterizedThreadStart(Add));
t.Start(ap);

// Wait here until you are notified!
_waitHandle.WaitOne();
Console.WriteLine("Other thread is done!");

Console.ReadLine();
...

当另一个线程完成其工作负载时,它将在同一个AutoResetEvent类型的实例上调用Set()方法。

void Add(object data)
{
  if (data is AddParams ap)
  {
    Console.WriteLine("ID of thread in Add(): {0}",
      Thread.CurrentThread.ManagedThreadId);

    Console.WriteLine("{0} + {1} is {2}",
      ap.a, ap.b, ap.a + ap.b);

    // Tell other thread we are done.
    _waitHandle.Set();
  }
}

前台线程和后台线程

既然您已经看到了如何使用System.Threading名称空间以编程方式创建新的执行线程,那么让我们来正式区分前台线程和后台线程:

  • 前台线程可以阻止当前应用终止。那个。在所有前台线程结束之前,NET Core Runtime 不会关闭应用(也就是说,卸载宿主 AppDomain)。

  • 后台线程(有时称为后台线程)被。NET 核心运行时作为可消耗的执行路径,可以在任何时间点被忽略(即使它们当前正在某个工作单元上工作)。因此,如果所有前台线程都已终止,当应用域卸载时,所有后台线程都会自动终止。

值得注意的是,前台和后台线程与主线程和工作线程是不同的。默认情况下,通过Thread.Start()方法创建的每个线程都自动成为前台线程。同样,这意味着 AppDomain 不会卸载,直到所有执行线程都完成了它们的工作单元。在大多数情况下,这正是您需要的行为。

然而,为了便于讨论,假设您想要在一个应该作为后台线程的辅助线程上调用Printer.PrintNumbers()。同样,这意味着由Thread类型(通过ThreadStartParameterizedThreadStart委托)指向的方法应该能够在所有前台线程完成它们的工作后安全地暂停。配置这样一个线程就像将IsBackground属性设置为true一样简单,就像这样:

Console.WriteLine("***** Background Threads *****\n");
Printer p = new Printer();
Thread bgroundThread =
  new Thread(new ThreadStart(p.PrintNumbers));

// This is now a background thread.
bgroundThread.IsBackground = true;
bgroundThread.Start();

注意这个code而不是调用Console.ReadLine()来强制控制台保持可见直到你按下回车键。因此,当您运行应用时,它会立即关闭,因为Thread对象已经被配置为后台线程。假设进入应用的入口点(这里显示的顶级语句或Main()方法)触发主前台线程的创建,一旦入口点中的逻辑完成,AppDomain 就会在辅助线程完成其工作之前卸载。

但是,如果您注释掉设置IsBackground属性的行,您会发现每个数字都会打印到控制台,因为所有前台线程都必须在 AppDomain 从宿主进程中卸载之前完成它们的工作。

在很大程度上,当相关的工作线程正在执行程序的主任务完成后不再需要的非关键任务时,配置线程作为后台类型运行会很有帮助。例如,您可以构建一个应用,每隔几分钟就向电子邮件服务器发送一次新邮件,更新当前天气状况,或者执行一些其他非关键任务。

并发性的问题

当您构建多线程应用时,您的程序需要确保任何共享数据都受到保护,以防大量线程更改其值。假设 AppDomain 中的所有线程都可以并发访问应用的共享数据,想象一下如果多个线程访问同一点数据会发生什么。由于线程调度器会强制线程随机暂停它们的工作,如果线程 A 在完全完成工作之前就被踢出去了呢?线程 B 现在正在读取不稳定的数据。

为了说明并发性问题,让我们构建另一个名为 MultiThreadedPrinting 的控制台应用项目。这个应用将再次使用之前创建的Printer类,但是这次PrintNumbers()方法将强制当前线程暂停一段随机生成的时间。

using System;
using System.Threading;

namespace MultiThreadedPrinting
{
  public class Printer
  {
    public void PrintNumbers()
    {
      // Display Thread info.
      Console.WriteLine("-> {0} is executing PrintNumbers()",
        Thread.CurrentThread.Name);

      // Print out numbers.
      for (int i = 0; i < 10; i++)
      {
        // Put thread to sleep for a random amount of time.
        Random r = new Random();
        Thread.Sleep(1000 * r.Next(5));
        Console.Write("{0}, ", i);
      }
      Console.WriteLine();
    }
  }
}

调用代码负责创建一个由十个(唯一命名的)Thread对象组成的数组,每个对象调用Printer对象的同一个实例,如下所示:

using System;
using System.Threading;
using MultiThreadedPrinting;

Console.WriteLine("*****Synchronizing Threads *****\n");

Printer p = new Printer();

// Make 10 threads that are all pointing to the same
// method on the same object.
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++)
{
  threads[i] = new Thread(new ThreadStart(p.PrintNumbers))
  {
    Name = $"Worker thread #{i}"
  };
}
// Now start each one.
foreach (Thread t in threads)
{
  t.Start();
}
Console.ReadLine();

在查看一些测试运行之前,让我们回顾一下这个问题。这个 AppDomain 中的主线程通过产生十个辅助工作线程而开始存在。每个工作线程被告知在同一个 Printer实例上调用PrintNumbers()方法。假设您没有采取任何预防措施来锁定该对象的共享资源(控制台),那么在PrintNumbers()方法能够打印完整的结果之前,当前线程很有可能会被踢出去。因为您不知道这种情况何时(或是否)会发生,所以您肯定会得到不可预测的结果。例如,您可能会发现如下所示的输出:

*****Synchronizing Threads *****
-> Worker thread #3 is executing PrintNumbers()
-> Worker thread #0 is executing PrintNumbers()
-> Worker thread #1 is executing PrintNumbers()
-> Worker thread #2 is executing PrintNumbers()
-> Worker thread #4 is executing PrintNumbers()
-> Worker thread #5 is executing PrintNumbers()
-> Worker thread #6 is executing PrintNumbers()
-> Worker thread #7 is executing PrintNumbers()
-> Worker thread #8 is executing PrintNumbers()
-> Worker thread #9 is executing PrintNumbers()
0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 2, 3, 1, 2, 2, 2, 1, 2, 1, 1, 2, 2, 3, 3, 4, 3, 3, 2, 2, 3, 4, 3, 4, 5, 4, 5, 4, 4, 3, 6, 7, 2, 3, 4, 4, 4, 5, 6, 5, 3, 5, 8, 9,
6, 7, 4, 5, 6, 6, 5, 5, 5, 8, 5, 6, 7, 8, 7, 7, 6, 6, 6, 8, 9,
8, 7, 7, 7, 7, 9,
6, 8, 9,
8, 9,
9, 9,

8, 8, 7, 8, 9,
9,
9,

现在再运行几次应用并检查输出。很可能每次都不一样。

Note

如果您无法生成不可预测的输出,那么将线程数量从 10 增加到 100(例如),或者在您的程序中引入另一个对Thread.Sleep()的调用。最终,您会遇到并发问题。

这里显然存在一些问题。当每个线程告诉Printer打印数字数据时,线程调度器很高兴地在后台交换线程。结果是输出不一致。您需要的是一种以编程方式强制同步访问共享资源的方法。正如您所猜测的,System.Threading名称空间提供了几种以同步为中心的类型。C# 编程语言还为多线程应用中同步共享数据的任务提供了一个关键字。

使用 C# lock 关键字进行同步

第一个可以用来同步访问共享资源的技术是 C# lock关键字。该关键字允许您定义必须在线程间同步的语句范围。通过这样做,传入线程不能中断当前线程,从而阻止它完成工作。lock关键字要求您指定一个令牌(一个对象引用),线程必须获得这个令牌才能进入锁范围。当您试图锁定一个私有实例级方法时,您可以简单地传入一个对当前类型的引用,如下所示:

private void SomePrivateMethod()
{
  // Use the current object as the thread token.
  lock(this)
  {
    // All code within this scope is thread safe.
  }
}

然而,如果您要锁定一个公共成员中的一段代码,那么声明一个私有object成员变量作为锁标记会更安全(也是最佳实践),如下所示:

public class Printer
{
  // Lock token.
  private object threadLock = new object();

  public void PrintNumbers()
  {
    // Use the lock token.
    lock (threadLock)
    {
      ...
    }
  }
}

在任何情况下,如果您检查PrintNumbers()方法,您可以看到线程竞争访问的共享资源是控制台窗口。在锁定范围内确定所有与Console类型的交互的范围,如下所示:

public void PrintNumbers()
{
  // Use the private object lock token.
  lock (threadLock)
  {
    // Display Thread info.
    Console.WriteLine("-> {0} is executing PrintNumbers()",
      Thread.CurrentThread.Name);
    // Print out numbers.
    Console.Write("Your numbers: ");
    for (int i = 0; i < 10; i++)
    {
      Random r = new Random();
      Thread.Sleep(1000 * r.Next(5));
      Console.Write("{0}, ", i);
    }
    Console.WriteLine();
  }
}

当这样做时,您已经有效地设计了一个方法,该方法将允许当前线程完成其任务。一旦线程进入锁范围,其他线程就无法访问锁令牌(在这种情况下,是对当前对象的引用),直到在锁范围退出后释放锁。因此,如果线程 A 已经获得锁令牌,其他线程就不能进入任何使用相同锁令牌的范围,直到线程 A 放弃锁令牌。

Note

如果您试图锁定静态方法中的代码,只需声明一个私有静态对象成员变量作为锁标记。

如果您现在运行应用,您可以看到每个线程都有足够的机会来完成它的任务。

*****Synchronizing Threads *****
-> Worker thread #0 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #1 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #3 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #2 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #4 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #5 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #7 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #6 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #8 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #9 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,

使用系统进行同步。线程。监视器类型

C# lock语句是使用System.Threading.Monitor类的简写符号。一旦被 C# 编译器处理,锁的作用域就解析为如下内容(可以使用ildasm.exe来验证):

public void PrintNumbers()
{
  Monitor.Enter(threadLock);
  try
  {
    // Display Thread info.
    Console.WriteLine("-> {0} is executing PrintNumbers()",
      Thread.CurrentThread.Name);

    // Print out numbers.
    Console.Write("Your numbers: ");
    for (int i = 0; i < 10; i++)
    {
      Random r = new Random();
      Thread.Sleep(1000 * r.Next(5));
      Console.Write("{0}, ", i);
    }
    Console.WriteLine();
  }
  finally
  {
    Monitor.Exit(threadLock);
  }
}

首先,请注意,Monitor.Enter()方法是您指定为lock关键字参数的线程令牌的最终接收者。接下来,锁范围内的所有代码都被包装在一个try块中。相应的finally块确保线程令牌被释放(通过Monitor.Exit()方法),不管任何可能的运行时异常。如果您要修改多线程打印程序以直接使用Monitor类型(如上所示),您会发现输出是相同的。

现在,鉴于lock关键字似乎比显式使用System.Threading.Monitor类型需要更少的代码,您可能想知道直接使用Monitor类型的好处。简单的答案是控制。如果使用Monitor类型,可以指示活动线程等待一段时间(通过静态Monitor.Wait()方法),在当前线程完成时通知等待线程(通过静态Monitor.Pulse()Monitor.PulseAll()方法),等等。

如您所料,在大多数情况下,C# lock关键字将符合要求。但是,如果您对检查Monitor类的其他成员感兴趣,请参考。NET 核心文档。

使用系统进行同步。螺纹.互锁型

尽管这总是令人难以置信,但直到你看到底层的 CIL 代码,赋值和简单的算术运算才是原子的 ??。出于这个原因,System.Threading名称空间提供了一种类型,允许您以比Monitor类型更少的开销原子地操作单点数据。Interlocked类定义了表 15-4 中所示的关键静态成员。

表 15-4。

选择系统的静态成员。穿线.互锁

|

成员

|

生命的意义

CompareExchange() 安全地测试两个值是否相等,如果相等,将其中一个值与第三个值交换
Decrement() 安全地将值递减 1
Exchange() 安全地交换两个值
Increment() 安全地将值递增 1

虽然从一开始看起来不像,但是在多线程环境中原子地改变单个值的过程是很常见的。假设您有代码来增加一个名为intVal的整数成员变量。而不是编写如下的同步代码:

int intVal = 5;
object myLockToken = new();
lock(myLockToken)
{
  intVal++;
}

你可以通过静态的Interlocked.Increment()方法来简化你的代码。只需通过引用传递要递增的变量。请注意,Increment()方法不仅调整传入参数的值,还返回新值。

intVal = Interlocked.Increment(ref intVal);

除了Increment()Decrement()之外,Interlocked类型允许你原子地分配数字和对象数据。例如,如果您想将成员变量的值赋给值83,您可以避免使用显式的lock语句(或显式的Monitor逻辑),而使用Interlocked.Exchange()方法,如下所示:

Interlocked.Exchange(ref myInt, 83);

最后,如果您想测试两个值是否相等,并以线程安全的方式改变比较点,您可以如下利用Interlocked.CompareExchange()方法:

public void CompareAndExchange()
{
  // If the value of i is currently 83, change i to 99.
  Interlocked.CompareExchange(ref i, 99, 83);
}

用定时器回调编程

许多应用需要在固定的时间间隔内调用特定的方法。例如,您可能有一个应用需要通过给定的助手函数在状态栏上显示当前时间。作为另一个例子,您可能希望让您的应用偶尔调用一个 helper 函数来执行非关键的后台任务,比如检查新的电子邮件。对于这样的情况,您可以将System.Threading.Timer类型与名为TimerCallback的相关委托结合使用。

举例来说,假设您有一个控制台应用项目(TimerApp ),它将每秒打印一次当前时间,直到用户按下一个键来终止应用。第一个明显的步骤是编写将由Timer类型调用的方法(确保将System.Threading导入到您的代码文件中)。

using System;
using System.Threading;

Console.WriteLine("***** Working with Timer type *****\n");
Console.ReadLine();

static void PrintTime(object state)
{
  Console.WriteLine("Time is: {0}",
    DateTime.Now.ToLongTimeString());
}

注意,PrintTime()方法有一个类型为System.Object的单一参数,并返回void。这不是可选的,因为TimerCallback委托只能调用匹配这个签名的方法。传递到您的TimerCallback委托的目标中的值可以是任何类型的对象(在电子邮件示例中,该参数可能表示在该过程中要与之交互的 Microsoft Exchange 服务器的名称)。还要注意,假设这个参数确实是一个System.Object,那么您可以使用一个System.Array或者定制的类/结构来传递多个参数。

下一步是配置一个TimerCallback委托的实例,并将其传递给Timer对象。除了配置一个TimerCallback委托之外,Timer构造函数还允许您指定传递给委托目标的可选参数信息(定义为一个System.Object)、轮询方法的时间间隔以及在进行第一次调用之前等待的时间(以毫秒为单位)。这里有一个例子:

Console.WriteLine("***** Working with Timer type *****\n");

// Create the delegate for the Timer type.
TimerCallback timeCB = new TimerCallback(PrintTime);

// Establish timer settings.
Timer t = new Timer(
  timeCB,     // The TimerCallback delegate object.
  null,       // Any info to pass into the called method (null for no info).
  0,          // Amount of time to wait before starting (in milliseconds).
  1000);      // Interval of time between calls (in milliseconds).

Console.WriteLine("Hit Enter key to terminate...");
Console.ReadLine();

在这种情况下,PrintTime()方法将大约每秒被调用一次,并且不会向该方法传递任何附加信息。以下是输出:

***** Working with Timer type *****
Hit key to terminate...
Time is: 6:51:48 PM
Time is: 6:51:49 PM
Time is: 6:51:50 PM
Time is: 6:51:51 PM
Time is: 6:51:52 PM
Press any key to continue . . .

如果您确实想发送一些信息供委托目标使用,只需用适当的信息替换第二个构造函数参数的null值,如下所示:

// Establish timer settings.
Timer t = new Timer(timeCB, "Hello From C# 9.0", 0, 1000);

然后,您可以按如下方式获取传入数据:

static void PrintTime(object state)
{
  Console.WriteLine("Time is: {0}, Param is: {1}",
    DateTime.Now.ToLongTimeString(), state.ToString());
}

使用独立丢弃(新 7.0)

在前面的示例中,Timer变量没有在任何执行路径中使用,因此可以用 discard 替换它,如下所示:

  var _ = new Timer(
    timeCB,  // The TimerCallback delegate object.
    null,    // Any info to pass into the called method
             // (null for no info).
    0,       // Amount of time to wait before starting
             //(in milliseconds).
    1000);   // Interval of time between calls
             //(in milliseconds).

了解线程池

你将在本章研究的下一个以线程为中心的主题是运行时线程池的角色。启动一个新线程是有成本的,所以为了提高效率,线程池会保留已创建的(但不活动的)线程,直到需要为止。为了允许您与这个等待线程池进行交互,System.Threading名称空间提供了ThreadPool类类型。

如果您想让一个方法调用在池中排队由一个工作线程处理,您可以使用ThreadPool.QueueUserWorkItem()方法。这个方法已经被重载,允许你为自定义状态数据指定一个可选的System.Object以及一个WaitCallback委托的实例。

public static class ThreadPool
{
  ...
  public static bool QueueUserWorkItem(WaitCallback callBack);
  public static bool QueueUserWorkItem(WaitCallback callBack,
                                      object state);
}

WaitCallback委托可以指向任何将System.Object作为其唯一参数(代表可选的状态数据)并且不返回任何内容的方法。请注意,如果在调用QueueUserWorkItem()时没有提供System.Object。NET Core 运行时自动传递空值。来说明供。NET 核心运行时线程池,思考下面的程序(在一个名为 ThreadPoolApp 的控制台应用中),它再次使用了Printer类型。然而,在这种情况下,您不是手动创建一个Thread对象的数组;相反,您是在将池中的成员分配给PrintNumbers()方法。

using System;
using System.Threading;
using ThreadPoolApp;

Console.WriteLine("***** Fun with the .NET Core Runtime Thread Pool *****\n");

Console.WriteLine("Main thread started. ThreadID = {0}",
  Thread.CurrentThread.ManagedThreadId);

Printer p = new Printer();

WaitCallback workItem = new WaitCallback(PrintTheNumbers);

// Queue the method ten times.
for (int i = 0; i < 10; i++)
{
  ThreadPool.QueueUserWorkItem(workItem, p);
}
Console.WriteLine("All tasks queued");
Console.ReadLine();

static void PrintTheNumbers(object state)
{
  Printer task = (Printer)state;
  task.PrintNumbers();
}

此时,您可能想知道使用。NET 核心运行时维护线程池,而不是显式创建Thread对象。考虑利用线程池的这些好处:

  • 线程池通过最小化必须创建、启动和停止的线程数量来有效地管理线程。

  • 通过使用线程池,您可以专注于您的业务问题,而不是应用的线程基础设施。

但是,在某些情况下,最好使用手动线程管理。这里有一个例子:

  • 如果需要前台线程或者必须设置线程优先级。池线程是总是具有默认优先级的后台线程(ThreadPriority.Normal)。

  • 如果你需要一个有固定身份的线程来中止它,挂起它,或者通过名字发现它。

这就完成了对System.Threading名称空间的研究。可以肯定的是,在创建多线程应用时,理解本章到目前为止介绍的主题(尤其是在您研究并发性问题的过程中)是非常有价值的。有了这个基础,现在将注意力转向中引入的几个新的以线程为中心的主题。NET 4.0 并延续到。NET 核心。首先,您将研究另一个线程模型任务并行库的作用。

使用任务并行库的并行编程

在本章的这一点上,您已经检查了允许您构建多线程软件的System.Threading名称空间对象。从发布。在. NET 4.0 中,微软引入了一种新的多线程应用开发方法,该方法使用一个名为任务并行库 (TPL)的并行编程库。使用System.Threading.Tasks的类型,您可以构建细粒度的、可伸缩的并行代码,而不必直接使用线程或线程池。

然而,这并不是说当你使用 TPL 时,你不会使用System.Threading的类型。这两个线程工具包可以非常自然地一起工作。尤其是因为System.Threading名称空间仍然提供了您之前检查过的大多数同步原语(MonitorInterlocked等)。).然而,你很可能会发现你更喜欢使用 TPL 而不是原来的System.Threading名称空间,因为同样的任务可以用更直接的方式来执行。

系统。线程.任务命名空间

统称起来,System.Threading.Tasks的类型被称为任务并行库。TPL 将使用运行时线程池,在可用的 CPU 之间动态地自动分配应用的工作负载。TPL 处理工作的划分、线程调度、状态管理和其他底层细节。结果是,您可以最大限度地发挥。NET 核心应用,同时避免了许多直接使用线程的复杂性。

并行类的作用

第三方物流的一个关键类别是System.Threading.Tasks.Parallel。这个类包含的方法允许你以并行的方式迭代一组数据(特别是一个实现了IEnumerable<T>的对象),主要是通过两个主要的静态方法Parallel.For()Parallel.ForEach(),每个方法都定义了许多重载版本。

这些方法允许您创作将以并行方式处理的代码语句体。从概念上讲,这些语句与您在普通循环结构中编写的逻辑是相同的(通过forforeach C# 关键字)。好处是Parallel类将代表您从线程池中提取线程(并管理并发性)。

这两种方法都要求您指定一个兼容IEnumerableIEnumerable<T>的容器,该容器保存您需要以并行方式处理的数据。容器可以是一个简单的数组、一个非泛型集合(比如ArrayList)、一个泛型集合(比如List<T>)或者一个 LINQ 查询的结果。

此外,您将需要使用System.Func<T>System.Action<T>委托来指定将被调用来处理数据的目标方法。你已经遇到了第十三章中的Func<T>代表,在你调查 LINQ 地对象期间。回想一下,Func<T>表示一个可以有给定返回值和不同数量参数的方法。Action<T>委托类似于Func<T>,因为它允许你指向一个带一些参数的方法。但是,Action<T>指定了一个只能返回void的方法。

虽然您可以调用Parallel.For()Parallel.ForEach()方法并传递强类型的Func<T>Action<T>委托对象,但是您可以通过使用合适的 C# 匿名方法或 lambda 表达式来简化您的编程。

并行类的数据并行性

使用 TPL 的第一种方法是执行数据并行。简单地说,这个术语指的是使用Parallel.For()Parallel.ForEach()方法以并行方式迭代数组或集合的任务。假设您需要执行一些劳动密集型的文件 I/O 操作。具体来说,你需要将大量的*.jpg文件加载到内存中,翻转过来,将修改后的图像数据保存到新的位置。

在这个示例中,您将看到如何使用图形用户界面执行相同的整体任务,因此您可以检查“匿名委托”的使用,以允许辅助线程更新主用户界面线程(也称为 UI 线程)。

Note

当您构建多线程图形用户界面(GUI)应用时,辅助线程永远不能直接访问用户界面控件。原因是控件(按钮、文本框、标签、进度条等。)与创建它们的线程有线程关联。在下面的例子中,我将说明一种允许辅助线程以线程安全的方式访问 UI 项的方法。当您检查 C# asyncawait关键字时,您会看到一个更简化的方法。

举例来说,创建一个新的 WPF 应用(模板缩写为 WPF 应用。NET Core))命名为 DataParallelismWithForEach。要使用 CLI 创建项目并将其添加到本章的解决方案中,请输入以下命令:

dotnet new wpf -lang c# -n DataParallelismWithForEach -o .\DataParallelismWithForEach -f net5.0
dotnet sln .\Chapter15_AllProjects.sln add .\DataParallelismWithForEach

Note

Windows Presentation Foundation(WPF)仅适用于此版本的 Windows。NET 核心,将在第 24—28 章节中详细介绍。如果你没有和 WPF 一起工作过,这里列出了你在这个例子中需要的所有东西。如果您更愿意跟随一个完整的解决方案,您可以在Chapter 15 文件夹中找到 DataParallelismWithForEach。WPF 开发使用 Visual Studio 代码,尽管没有设计器支持。为了获得更丰富的开发体验,我建议您使用 Visual Studio 2019 来获得本章中的 WPF 示例。

在解决方案资源管理器中双击MainWindow.xaml文件,并用以下内容替换 XAML:

<Window x:Class="DataParallelismWithForEach.MainWindow"
        xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"

        xmlns:local="clr-namespace:DataParallelismWithForEach"
        mc:Ignorable="d"
        Title="Fun with TPL" Height="400" Width="800">
  <Grid>
    <Grid.RowDefinitions>
      <RowDefinition Height="Auto"/>
      <RowDefinition Height="*"/>
      <RowDefinition Height="Auto"/>
    </Grid.RowDefinitions>
    <Label Grid.Row="0" Grid.Column="0">
      Feel free to type here while the images are processed...
    </Label>
    <TextBox Grid.Row="1" Grid.Column="0"  Margin="10,10,10,10"/>
    <Grid Grid.Row="2" Grid.Column="0">
      <Grid.ColumnDefinitions>
        <ColumnDefinition Width="Auto"/>
        <ColumnDefinition Width="*"/>
        <ColumnDefinition Width="Auto"/>
      </Grid.ColumnDefinitions>
            <Button Name="cmdCancel" Grid.Row="0" Grid.Column="0" Margin="10,10,0,10" Click="cmdCancel_Click">
                Cancel
            </Button>
            <Button Name="cmdProcess" Grid.Row="0" Grid.Column="2" Margin="0,10,10,10"
                         Click="cmdProcess_Click">
                Click to Flip Your Images!
            </Button>
    </Grid>
  </Grid>
</Window>

同样,不要担心标记意味着什么或者它是如何工作的;你很快就会有很多时间和 WPF 在一起。应用的 GUI 由多行TextBox和单个Button(名为cmdProcess)组成。文本区域的目的是允许您在后台执行工作时输入数据,从而说明并行任务的非阻塞性质。

对于这个例子,需要一个额外的 NuGet 包(System.Drawing.Common)。若要将它添加到项目中,请在 Visual Studio 的命令行(与解决方案文件位于同一目录中)或包管理器控制台中输入以下行(全部在一行中):

dotnet add DataParallelismWithForEach package System.Drawing.Common

打开MainWindow.xaml.cs文件(在 Visual Studio 中双击它——您可能需要通过MainWindow.xaml展开节点),并将以下using语句添加到文件的顶部:

// Be sure you have these namespaces! (System.Threading.Tasks should already be there from the default template)
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Threading;
using System.Windows;
using System.IO;

Note

您应该更新传递到下面的Directory.GetFiles()方法调用中的字符串,以指向您的计算机上有一些图像文件的路径(比如家庭照片的个人文件夹)。为了方便起见,我在Solution目录中包含了一些示例图像(Windows 操作系统附带的)。

public partial class MainWindow : Window
{
  public MainWindow()
  {
    InitializeComponent();
  }

  private void cmdCancel_Click(object sender, EventArgs e)
  {
    // This will be updated shortly
  }

  private void cmdProcess_Click(object sender, EventArgs e)
  {
    ProcessFiles();
    this.Title = "Processing Complete";
  }

  private void ProcessFiles()
  {
    // Load up all *.jpg files, and make a new folder for the
    //   modified data.
    //Get the directory path where the file is executing
    //For VS 2019 debugging, the current directory will be <projectdirectory>\bin\debug\net5.0-windows
    //For VS Code or “dotnet run”, the current directory will be <projectdirectory>
    var basePath = Directory.GetCurrentDirectory();
    var pictureDirectory =
      Path.Combine(basePath, "TestPictures");
    var outputDirectory =
      Path.Combine(basePath, "ModifiedPictures");
    //Clear out any existing files
    if (Directory.Exists(outputDirectory))
    {
      Directory.Delete(outputDirectory, true);
    }
    Directory.CreateDirectory(outputDirectory);
    string[] files = Directory.GetFiles(pictureDirectory,
       "*.jpg", SearchOption.AllDirectories);

    // Process the image data in a blocking manner.
    foreach (string currentFile in files)
    {
      string filename =
        System.IO.Path.GetFileName(currentFile);
      // Print out the ID of the thread processing the current image.
       this.Title = $"Processing {filename} on thread {Thread.CurrentThread.ManagedThreadId}";
      using (Bitmap bitmap = new Bitmap(currentFile))
      {
        bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
        bitmap.Save(System.IO.Path.Combine(
          outputDirectory, filename));
      }
    }
  }
}

Note

如果您收到一条错误消息,指出PathSystem.IO.PathSystem.Windows.Shapes.Path之间的不明确引用,请删除System.Windows.Shapesusing,或者将System.IO添加到Path : System.IO.Path.Combine(...)

请注意,ProcessFiles()方法将旋转指定目录下的每个*.jpg文件。目前,所有的工作都发生在可执行文件的主线程上。因此,如果单击该按钮,程序将显示为挂起。此外,窗口的标题还将报告同一个主线程正在处理文件,因为我们只有一个执行线程。

为了在尽可能多的 CPU 上处理文件,您可以重写当前的foreach循环来使用Parallel.ForEach()。回想一下,这个方法已经被重载了无数次;然而,在最简单的形式中,您必须指定包含要处理的项目的与IEnumerable<T>兼容的对象(那将是files字符串数组)和一个指向将执行工作的方法的Action<T>委托。

下面是相关的更新,使用 C# lambda 操作符代替文字Action<T>委托对象。请注意,您目前正在注释掉显示执行当前图像文件的线程 ID 的代码行。见下一节找出原因。

// Process the image data in a parallel manner!
Parallel.ForEach(files, currentFile =>
  {
    string filename = Path.GetFileName(currentFile);

      // This code statement is now a problem! See next section.
      // this.Title = $"Processing {filename} on thread
      //      {Thread.CurrentThread.ManagedThreadId}"
      // Thread.CurrentThread.ManagedThreadId);

    using (Bitmap bitmap = new Bitmap(currentFile))
    {
      bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
      bitmap.Save(Path.Combine(outputDirectory, filename));
    }
  }
);

访问辅助线程上的 UI 元素

您会注意到,我已经注释掉了用当前执行线程的 ID 更新主窗口标题的前一行代码。如前所述,GUI 控件与创建它们的线程有“线程亲缘关系”。如果辅助线程试图访问它们没有直接创建的控件,那么在调试软件时,您肯定会遇到运行时错误。另一方面,如果您要运行应用(通过 Ctrl+F5),您可能永远不会发现原始代码有任何问题。

Note

让我重申前面的观点:当您调试多线程应用时,您有时可以捕捉到当辅助线程“接触”在主线程上创建的控件时出现的错误。然而,通常当您运行应用时,应用可能看起来运行正常(或者可能立即出错)。除非您采取预防措施(接下来将讨论),否则在这种情况下,您的应用有可能引发运行时错误。

允许这些辅助线程以线程安全的方式访问控件的一种方法是另一种以委托为中心的技术,特别是一种匿名委托。WPF 中的Control父类定义了一个Dispatcher对象,它管理一个线程的工作项。这个对象有一个名为Invoke()的方法,它接受一个System.Delegate作为输入。当您处于涉及辅助线程的编码上下文中时,可以调用此方法,以提供线程安全的方式来更新给定控件的 UI。现在,虽然您可以直接编写所有需要的委托代码,但大多数开发人员使用表达式语法作为简单的替代方法。以下是对先前注释掉的代码语句内容的相关更新:

// Eek! This will not work anymore!
//this.Title = $"Processing {filename} on thread {Thread.CurrentThread.ManagedThreadId}";

// Invoke on the Form object, to allow secondary threads to access controls
// in a thread-safe manner.
Dispatcher?.Invoke(() =>
{
  this.Title = $"Processing {filename}";
});
using (Bitmap bitmap = new Bitmap(currentFile))
{
  bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
  bitmap.Save(Path.Combine(outputDirectory, filename));
}

现在,如果您运行这个程序,TPL 将会使用尽可能多的 CPU 将工作负载分配给线程池中的多个线程。然而,由于Title总是从主线程更新,所以Title更新代码不再显示当前线程,并且如果您在文本框中键入内容,直到所有图像都被处理完,您将看不到任何内容!原因是主 UI 线程仍然被阻塞,等待所有其他线程完成它们的任务。

任务类

Task类允许您轻松地调用辅助线程上的方法,并且可以作为使用异步委托的简单替代方法。更新Button控件的Click处理程序,如下所示:

private void cmdProcess_Click(object sender, EventArgs e)
{
  // Start a new "task" to process the files.
  Task.Factory.StartNew(() => ProcessFiles());
  //Can also be written this way
  //Task.Factory.StartNew(ProcessFiles);

}

TaskFactory属性返回一个TaskFactory对象。当您调用它的StartNew()方法时,您传入一个Action<T>委托(这里,用一个合适的 lambda 表达式隐藏起来),该委托指向要以异步方式调用的方法。通过这个小小的更新,您会发现窗口的标题将显示线程池中的哪个线程正在处理给定的文件,更好的是,文本区域能够接收输入,因为 UI 线程不再被阻塞。

处理取消请求

您可以对当前示例进行的一个改进是,通过第二个(恰当命名的)Cancel 按钮,为用户提供一种停止处理图像数据的方法。幸运的是,Parallel.For()Parallel.ForEach()方法都支持使用取消令牌进行取消。当您调用Parallel上的方法时,您可以传入一个ParallelOptions对象,该对象又包含一个CancellationTokenSource对象。

首先,在名为_cancelTokenCancellationTokenSource类型的Form派生类中定义以下新的私有成员变量:

public partial class MainWindow :Window
{
  // New Window-level variable.
  private CancellationTokenSource _cancelToken = new CancellationTokenSource();
...
}

将取消按钮Click事件更新为以下代码:

private void cmdCancel_Click(object sender, EventArgs e)
{
  // This will be used to tell all the worker threads to stop!
  _cancelToken.Cancel();
}

现在,真正的修改需要发生在ProcessFiles()方法中。考虑最终的实现:

private void ProcessFiles()
{
  // Use ParallelOptions instance to store the CancellationToken.
  ParallelOptions parOpts = new ParallelOptions();
  parOpts.CancellationToken = _cancelToken.Token;
  parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;

  // Load up all *.jpg files, and make a new folder for the modified data.
  string[] files = Directory.GetFiles(@".\TestPictures", "*.jpg", SearchOption.AllDirectories);
  string outputDirectory = @".\ModifiedPictures";
  Directory.CreateDirectory(outputDirectory);

  try
  {
    // Process the image data in a parallel manner!
    Parallel.ForEach(files, parOpts, currentFile =>
    {
      parOpts
         .CancellationToken.ThrowIfCancellationRequested();

      string filename = Path.GetFileName(currentFile);
      Dispatcher?.Invoke(() =>
      {
        this.Title =
          $"Processing {filename} on thread {Thread.CurrentThread.ManagedThreadId}";
      });
      using (Bitmap bitmap = new Bitmap(currentFile))
      {
        bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
        bitmap.Save(Path.Combine(outputDirectory, filename));
      }
    });
    Dispatcher?.Invoke(()=>this.Title = "Done!");
  }
  catch (OperationCanceledException ex)
  {
    Dispatcher?.Invoke(()=>this.Title = ex.Message);
  }
}

注意,这个方法是通过配置一个ParallelOptions对象开始的,设置CancellationToken属性来使用CancellationTokenSource令牌。还要注意,当您调用Parallel.ForEach()方法时,您将把ParallelOptions对象作为第二个参数传入。

在循环逻辑的范围内,您调用令牌上的ThrowIfCancellationRequested(),这将确保如果用户单击 Cancel 按钮,所有线程都将停止,并且您将通过运行时异常得到通知。当您捕捉到OperationCanceledException错误时,您将把主窗口的文本设置为错误消息。

使用并行类的任务并行性

除了数据并行性之外,TPL 还可以使用Parallel.Invoke()方法轻松地启动任意数量的异步任务。这种方法比使用来自System.Threading的成员更简单;然而,如果您需要对任务的执行方式有更多的控制,您可以放弃使用Parallel.Invoke()而直接使用Task类,就像您在前面的例子中所做的那样。

为了说明任务并行性,创建一个名为 MyEBookReader 的新控制台应用,并确保在Program.cs的顶部导入了System.ThreadingSystem.TextSystem.Threading.TasksSystem.LinqSystem.Net名称空间(这个示例是对。NET 核心文档)。在这里,您将从 Project Gutenberg ( www.gutenberg.org )获取一个公开可用的电子书,然后并行执行一组冗长的任务。

这本书是用GetBook()方法下载的,如下所示:

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Text;

string _theEBook = "";
GetBook();
Console.WriteLine("Downloading book...");
Console.ReadLine();

void GetBook()
{
  WebClient wc = new WebClient();
  wc.DownloadStringCompleted += (s, eArgs) =>
  {
    _theEBook = eArgs.Result;
    Console.WriteLine("Download complete.");
    GetStats();
  };

  // The Project Gutenberg EBook of A Tale of Two Cities, by Charles Dickens
  // You might have to run it twice if you’ve never visited the site before, since the first
  // time you visit there is a message box that pops up, and breaks this code.
  wc.DownloadStringAsync(new Uri("http://www.gutenberg.org/files/98/98-8.txt"));
}

WebClient类是System.Net的成员。此类提供向 URI 标识的资源发送数据和从该资源接收数据的方法。事实证明,这些方法中有很多都有异步版本,比如DownloadStringAsync()。此方法将从。NET 核心运行时自动线程池。当WebClient完成获取数据时,它将触发DownloadStringCompleted事件,这里使用 C# lambda 表达式处理该事件。如果您要调用该方法的同步版本(DownloadString()),那么在下载完成之前,不会显示“正在下载”的消息。

接下来,实现GetStats()方法来提取包含在theEBook变量中的单个单词,然后将字符串数组传递给几个辅助函数进行处理,如下所示:

void GetStats()
{
  // Get the words from the ebook.
  string[] words = _theEBook.Split(new char[]
    { ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
    StringSplitOptions.RemoveEmptyEntries);

  // Now, find the ten most common words.
  string[] tenMostCommon = FindTenMostCommon(words);

  // Get the longest word.
  string longestWord = FindLongestWord(words);

  // Now that all tasks are complete, build a string to show all stats.
  StringBuilder bookStats = new StringBuilder("Ten Most Common Words are:\n");
  foreach (string s in tenMostCommon)
  {
    bookStats.AppendLine(s);
  }

  bookStats.AppendFormat("Longest word is: {0}", longestWord);
  bookStats.AppendLine();
  Console.WriteLine(bookStats.ToString(), "Book info");
}

FindTenMostCommon()方法使用 LINQ 查询来获得在string数组中最常出现的string对象的列表,而FindLongestWord()则定位最长的单词。

string[] FindTenMostCommon(string[] words)
{
    var frequencyOrder = from word in words
                         where word.Length > 6
                         group word by word into g
                         orderby g.Count() descending
                         select g.Key;
    string[] commonWords = (frequencyOrder.Take(10)).ToArray();
    return commonWords;
}
string FindLongestWord(string[] words)
{
    return (from w in words orderby w.Length descending select w).FirstOrDefault();
}

如果您要运行这个项目,根据您的机器的 CPU 数量和整体处理器速度,执行所有任务可能会花费大量的时间。最终,您应该会看到如下所示的输出:

Downloading book...
Download complete.
Ten Most Common Words are:
Defarge
himself
Manette
through
nothing
business
another
looking
prisoner
Cruncher
Longest word is: undistinguishable

通过并行调用FindTenMostCommon()FindLongestWord()方法,可以帮助确保您的应用使用主机上所有可用的 CPU。为此,将您的GetStats()方法修改如下:

void GetStats()
{
  // Get the words from the ebook.
  string[] words = _theEBook.Split(
    new char[] { ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
    StringSplitOptions.RemoveEmptyEntries);
  string[] tenMostCommon = null;
  string longestWord = string.Empty;

  Parallel.Invoke(
    () =>
    {
      // Now, find the ten most common words.
      tenMostCommon = FindTenMostCommon(words);
    },
    () =>
    {
      // Get the longest word.
      longestWord = FindLongestWord(words);
    });

  // Now that all tasks are complete, build a string to show all stats.
  ...
}

Parallel.Invoke()方法需要一个Action<>委托的参数数组,这是您使用 lambda 表达式间接提供的。同样,虽然输出是相同的,但好处是 TPL 现在将使用机器上所有可能的处理器来尽可能并行地调用每个方法。

并行 LINQ 查询(PLINQ)

总结一下您对 TPL 的看法,要知道还有另一种方法可以将并行任务合并到您的。NET 核心应用。如果您愿意,可以使用一组扩展方法来构造一个并行执行其工作负载的 LINQ 查询(如果可能的话)。相应地,设计为并行运行的 LINQ 查询被称为 PLINQ 查询

像使用Parallel类创作的并行代码一样,如果需要,PLINQ 可以选择忽略您并行处理集合的请求。PLINQ 框架已经在许多方面进行了优化,包括确定一个查询实际上是否会以同步方式执行得更快。

在运行时,PLINQ 分析查询的整体结构,如果查询可能受益于并行化,它将并发运行。但是,如果并行化查询会损害性能,PLINQ 只会按顺序运行查询。如果 PLINQ 可以在潜在昂贵的并行算法或便宜的顺序算法之间进行选择,默认情况下它会选择顺序算法。

必要的扩展方法可以在名称空间System.LinqParallelEnumerable类中找到。表 15-5 记录了一些有用的 PLINQ 扩展。

表 15-5。

选择 ParallelEnumerable 类的成员

|

成员

|

生命的意义

AsParallel() 指定查询的其余部分应该并行化(如果可能)
WithCancellation() 指定 PLINQ 应定期监视所提供的取消令牌的状态,并在收到请求时取消执行
WithDegreeOfParallelism() 指定 PLINQ 用于并行查询的最大处理器数量
ForAll() 支持并行处理结果,而无需先合并回消费者线程,这是使用foreach关键字枚举 LINQ 结果时的情况

要查看 PLINQ 的运行情况,创建一个名为 plinqdataprocessingwithcassignation 的控制台应用,并导入System.LinqSystem.ThreadingSystem.Threading.Tasks名称空间(如果还没有的话)。当处理开始时,程序将触发一个新的Task,它执行一个 LINQ 查询,该查询调查一个大的整数数组,只查找x % 3 == 0true的项目。下面是一个不平行的版本的查询:

using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine("Start any key to start processing");
Console.ReadKey();

Console.WriteLine("Processing");
Task.Factory.StartNew(ProcessIntData);
Console.ReadLine();

void ProcessIntData()
{
  // Get a very large array of integers.
  int[] source = Enumerable.Range(1, 10_000_000).ToArray();
  // Find the numbers where num % 3 == 0 is true, returned
  // in descending order.
  int[] modThreeIsZero = (
    from num in source
    where num % 3 == 0
    orderby num descending
    select num).ToArray();
  Console.WriteLine($"Found { modThreeIsZero.Count()} numbers that match query!");
}

选择加入 PLINQ 查询

如果您想要通知 TPL 并行执行这个查询(如果可能的话),您将想要使用AsParallel()扩展方法,如下所示:

int[] modThreeIsZero = (
  from num in source.AsParallel()
  where num % 3 == 0
  orderby num descending select num).ToArray();

请注意,LINQ 查询的整体格式与您在前面章节中看到的完全相同。然而,通过包含对AsParallel()的调用,TPL 将试图将工作负载传递给任何可用的 CPU。

取消 PLINQ 查询

也可以使用CancellationTokenSource对象通知 PLINQ 查询在正确的条件下停止处理(通常是因为用户干预)。声明一个名为_cancelToken的类级CancellationTokenSource对象,并更新顶级语句方法以接受用户输入。以下是相关的代码更新:

CancellationTokenSource _cancelToken =
  new CancellationTokenSource();

do
{
  Console.WriteLine("Start any key to start processing");
  Console.ReadKey();
  Console.WriteLine("Processing");
  Task.Factory.StartNew(ProcessIntData);
  Console.Write("Enter Q to quit: ");
  string answer = Console.ReadLine();
  // Does user want to quit?
  if (answer.Equals("Q",
    StringComparison.OrdinalIgnoreCase))
  {
    _cancelToken.Cancel();
    break;
  }
}
while (true);

Console.ReadLine();

现在,通过链接WithCancellation()扩展方法并传入令牌,通知 PLINQ 查询它应该注意一个传入的取消请求。此外,您将希望将这个 PLINQ 查询包装在一个适当的try / catch范围内,并处理可能的异常。下面是ProcessIntData()方法的最终版本:

void ProcessIntData()
{
  // Get a very large array of integers.
  int[] source = Enumerable.Range(1, 10_000_000).ToArray();
  // Find the numbers where num % 3 == 0 is true, returned
  // in descending order.
  int[] modThreeIsZero = null;
  try
  {
    modThreeIsZero = (from num in source.AsParallel().WithCancellation(_cancelToken.Token)
            where num % 3 == 0
            orderby num descending
            select num).ToArray();
    Console.WriteLine();
    Console.WriteLine($"Found {modThreeIsZero.Count()} numbers that match query!");
  }
  catch (OperationCanceledException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

当运行这个程序时,您会想要点击 Q 并快速输入以查看来自取消令牌的消息。在我的开发机器上,在它自己完成之前,我有大约一秒钟的时间退出。

用 async/await 进行异步调用

在这一章(相当长)中,我已经介绍了很多材料。可以肯定的是,构建、调试和理解复杂的多线程应用在任何框架中都具有挑战性。虽然 TPL、PLINQ 和 delegate 类型可以在某种程度上简化事情(特别是与其他平台和语言相比),但开发人员仍然需要了解各种高级技术的来龙去脉。

自从发布以来。NET 4.5 中,C# 编程语言已经更新了两个新的关键字,进一步简化了创作异步代码的过程。与本章中的所有例子相比,当你使用新的asyncawait关键字时,编译器将使用System.ThreadingSystem.Threading.Tasks名称空间的众多成员为你生成大量线程代码。

首先看看 C# async 和 await 关键字(更新 7.1,9.0)

C# 的关键字async用于限定方法、lambda 表达式或匿名方法应该以异步方式自动调用。是的,这是真的。只需用async修饰符标记一个方法。NET 核心运行时将创建一个新的执行线程来处理手头的任务。此外,当您调用一个async方法时,await关键字将自动暂停当前线程的任何进一步活动,直到任务完成,让调用线程自由地继续。

举例来说,创建一个名为 FunWithCSharpAsync 的控制台应用,并将System.ThreadingSystem.Threading.TasksSystem.Collections.Generic名称空间导入到Program.cs中。添加一个名为DoWork()的方法,强制调用线程等待五秒钟。到目前为止,故事是这样的:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

Console.WriteLine(" Fun With Async ===>");
Console.WriteLine(DoWork());
Console.WriteLine("Completed");
Console.ReadLine();

static string DoWork()
{
  Thread.Sleep(5_000);
  return "Done with work!";
}

现在,考虑到你在这一章中的工作,你知道如果你要运行这个程序,你需要等待五秒钟,然后其他事情才会发生。如果这是一个图形应用,整个屏幕将被锁定,直到工作完成。

如果你要使用本章中介绍的任何一种技术来提高程序的响应能力,你将有大量的工作要做。然而自从。NET 4.5,您可以编写以下 C# 代码库:

...
string message = await DoWorkAsync();
Console.WriteLine(message);
...

static string DoWork()
{
  Thread.Sleep(5_000);
  return "Done with work!";
}
static async Task<string> DoWorkAsync()
{
  return await Task.Run(() =>
  {
    Thread.Sleep(5_000);
    return "Done with work!";
  });
}

如果您使用一个Main()方法作为入口点(而不是顶级语句),您需要将该方法标记为async,这是在 C# 7.1 中引入的。

static async Task Main(string[] args)
{
...
string message = await DoWorkAsync();
Console.WriteLine(message);
...
}

Note

从 C# 7.1 开始,可以用async来修饰Main()方法。在 C# 9.0 中,顶层语句是隐式的async

注意在命名将以异步方式调用的方法之前的await关键字*。这一点很重要:如果你用async关键字修饰一个方法,但是没有至少一个内部的await为中心的方法调用,你实际上已经构建了一个同步方法调用(事实上,你将得到一个关于这个效果的编译器警告)。*

现在,请注意,您需要使用来自System.Threading.Tasks名称空间的Task类来重构您的Main()(如果您正在使用Main())和DoWork()方法(后者被添加为DoWorkAsync())。基本上,不是直接返回一个特定的返回值(在当前的例子中是一个string对象),而是返回一个Task<T>对象,其中泛型类型参数T是底层的实际返回值(到目前为止?).如果方法没有返回值(就像在Main()方法中一样),那么就用 Task代替任务

DoWorkAsync()的实现现在直接返回一个Task<T>对象,这个对象就是Task.Run()的返回值。Run()方法接受一个Func<>Action<>委托,正如您在本文中所知,您可以通过使用 lambda 表达式来简化您的生活。基本上你的新版DoWorkAsync()本质上是在说下面的话:

当你调用我的时候,我会运行一个新的任务。这个任务将导致调用线程睡眠五秒钟,当它完成时,它给我一个字符串返回值。我将把这个字符串放到一个新的 Task < string >对象中,并将其返回给调用者。

DoWorkAsync()的这个新实现翻译成更自然(诗意)的语言后,您对await标记的真正作用有了一些了解。这个关键字将总是修改返回一个Task对象的方法。当逻辑流到达await标记时,调用线程在这个方法中被挂起,直到调用完成。如果您要运行这个版本的应用,您会发现Completed消息显示在Done with work!消息之前。如果这是一个图形应用,当DoWorkAsync()方法执行时,用户可以继续使用 UI。

同步上下文和异步/等待

SynchronizationContext的官方定义是一个基类,提供无同步的自由线程上下文。虽然最初的定义不是很有描述性,但官方文档继续说:

由该类实现的同步模型的目的是允许公共语言运行库的内部异步/同步操作在不同的同步模型下正常运行。

这一陈述,以及您对多线程的了解,阐明了这个问题。回想一下,GUI 应用(WinForms,WPF)不允许辅助线程直接访问控件,但必须委托该访问。我们已经看到了 WPF 例子中的Dispatcher对象。对于不使用 WPF 的控制台应用,没有这种限制。这些是提到的不同的同步模型。记住这一点,让我们更深入地了解一下SynchronizationContext

SynchonizationContext是一种提供虚拟 post 方法的类型,它接受一个要异步执行的委托。这为框架提供了适当处理异步请求的模式(为 WPF/WinForms 分派,为非 GUI 应用直接执行,等等)。).它提供了一种方法来将一个工作单元排队到一个上下文中,并对未完成的async操作进行计数。

正如我们前面讨论的,当一个委托被排队异步运行时,它被安排在一个单独的线程上运行。这个细节由。NET 核心运行时。这通常是使用。NET 核心运行时托管线程池,但可以用自定义实现重写。

虽然这种管道工作可以通过代码手动管理,但是async / await模式完成了大部分繁重的工作。当等待一个async方法时,它利用目标框架的SynchronizationContextTaskScheduler实现。例如,如果您在一个 WPF 应用中使用async / await,WPF 框架会管理委托的分派,并在等待的任务完成时回调状态机,以便安全地更新控件。

ConfigureAwait 的作用

现在您对SynchronizationContext有了更好的理解,是时候介绍一下ConfigureAwait()方法的作用了。默认情况下,等待Task将导致同步上下文被利用。当开发 GUI 应用(WinForms,WPF)时,这是您想要的行为。但是,如果您正在编写非 GUI 应用代码,在不需要时对原始上下文进行排队的开销可能会导致应用中的性能问题。

要了解这一点,请将您的顶级语句更新为以下内容:

Console.WriteLine(" Fun With Async ===>");
//Console.WriteLine(DoWork());
string message = await DoWorkAsync();
Console.WriteLine(message);

string message1 = await DoWorkAsync().ConfigureAwait(false);
Console.WriteLine(message1);

原始代码块使用框架提供的SynchronizationContext(在本例中,是。NET 核心运行时)。相当于调用ConfigureAwait(true)。第二个例子忽略了当前的上下文和调度程序。

的指导。NET 核心团队建议在开发应用代码时(WinForms、WPF 等。)保留默认行为。如果你正在编写非应用代码(如库代码),那么使用ConfigureAwait(false)。一个例外是 ASP.NET 核心(在第九部分中讨论)。ASP.NET 核心不创建自定义SynchronizationContext;因此,ConfigureAwait(false)在使用其他框架时不提供这种好处。

异步方法的命名约定

当然,你注意到了从DoWork()DoWorkAsync()的名称变化,但是为什么会发生变化呢?假设新版本的方法仍被命名为DoWork();但是,调用代码是这样实现的:

//Oops! No await keyword here!
string message = DoWork();

注意你确实用async关键字标记了方法,但是你忽略了在DoWork()方法调用之前使用await关键字作为修饰。此时,您将遇到编译器错误,因为DoWork()的返回值是一个Task对象,您试图将它直接赋给一个字符串变量。记住,await标记提取包含在Task对象中的内部返回值。因为您没有使用这个标记,所以您有一个类型不匹配。

Note

一个“可适应的”方法只是一个返回TaskTask<T>的方法。

鉴于返回Task对象的方法现在可以通过asyncawait标记以非阻塞的方式调用,微软建议(作为最佳实践)任何返回Task的方法都用Async后缀标记。通过这种方式,知道命名约定的开发人员会收到一个视觉提示,如果他们打算在异步上下文中调用该方法,则需要使用await关键字。

Note

GUI 控件的事件处理程序(如按钮Click处理程序)以及 MVC 风格应用中使用async / await关键字的动作方法不遵循这种命名约定(按照约定,请原谅冗余!).

Void 异步方法

目前,您的DoWorkAsync()方法正在返回一个Task,它包含调用者的“真实数据”,这些数据将通过await关键字透明地获得。但是,如果要构建一个返回 void 的异步方法呢?如何实现这一点取决于该方法是否需要等待(就像在“一劳永逸”的场景中一样)。

适用的 Void 异步方法

如果你的async方法需要是可适应的,你使用非泛型Task类并省略任何return语句,就像这样:

static async Task MethodReturningTaskOfVoidAsync()
{
  await Task.Run(() => { /* Do some work here... */
                         Thread.Sleep(4_000);
                       });
  Console.WriteLine("Void method completed");
}

这个方法的调用者将使用关键字await,如下所示:

await MethodReturningVoidAsync();
Console.WriteLine("Void method complete");

“一劳永逸”的 Void 异步方法

如果你的方法需要是async但不需要是可实现的,而是用于“一劳永逸”的情况,添加带有voidasync关键字,而不是Task返回类型。这通常用于日志记录之类的情况,在这种情况下,您不希望日志记录工作延迟其余的代码。

static async void MethodReturningVoidAsync()
{
  await Task.Run(() => { /* Do some work here... */
                         Thread.Sleep(4_000);
                       });
  Console.WriteLine("Fire and forget void method completed");
}

这个方法的调用者将而不是这样使用await关键字:

MethodReturningVoidAsync();
Console.WriteLine("Void method complete");

具有多个等待的异步方法

一个async方法在其实现中拥有多个 await 上下文是完全允许的。下面是完全可以接受的代码:

static async Task MultipleAwaits()
{
    await Task.Run(() => { Thread.Sleep(2_000); });
    Console.WriteLine("Done with first task!");

    await Task.Run(() => { Thread.Sleep(2_000); });
    Console.WriteLine("Done with second task!");

    await Task.Run(() => { Thread.Sleep(2_000); });
    Console.WriteLine("Done with third task!");
}

同样,这里的每个任务都只是暂停当前线程一段时间;然而,任何工作单元都可以由这些任务来表示(调用 web 服务、读取数据库等。).

另一种选择是不等待每个任务,而是一起等待它们。这是一个更可能的场景,其中有三件事(检查邮件、更新服务器、下载文件)必须成批完成,但可以并行完成。下面是使用Task.WhenAll()方法更新的代码:

static async Task MultipleAwaits()
{
  var task1 = Task.Run(() =>
  {
    Thread.Sleep(2_000);
    Console.WriteLine("Done with first task!");
  });

  var task2=Task.Run(() =>
  {
    Thread.Sleep(1_000);
    Console.WriteLine("Done with second task!");
  });

  var task3 = Task.Run(() =>
  {
    Thread.Sleep(1_000);
    Console.WriteLine("Done with third task!");
  });
  await Task.WhenAll(task1,task2,task3);
}

当您现在运行程序时,您会看到这三个任务按照最短的Sleep时间的顺序启动。

Fun With Async ===>
Done with work!
Void method completed
Done with second task!
Done with third task!
Done with first task!
Completed

还有一个WhenAny(),它返回完成的任务。为了演示WhenAny(),将MultipleAwaits的最后一行改为:

await Task.WhenAny(task1,task2,task3);

当您这样做时,输出更改为:

Fun With Async ===>
Done with work!
Void method completed
Done with second task!
Completed
Done with third task!
Done with first task!

从非异步方法调用异步方法

前面的每个例子都使用了async关键字在async方法执行时将线程返回给调用代码。在 review 中,您只能在标记为async的方法中使用await关键字。如果您不能(或者不想)标记一个方法async该怎么办?

幸运的是,还有其他方法可以调用异步方法。如果您只是不使用await关键字,那么该方法中的代码会继续通过async方法,而不会返回给调用者。如果您需要等待您的async方法完成(当您使用await关键字时就会发生这种情况),有两种方法。

第一种是简单地使用Task<T>上的Result属性或者Task / Task<T>方法上的Wait。(记住异步时返回值的方法必须返回Task<T>,无返回值的方法在async时返回Task)。如果该方法失败,则返回一个AggregateException

您也可以调用GetAwaiter().GetResult(),它完成与async方法中的await关键字相同的事情,并以与aync / await相同的方式传播异常。然而,这些方法在文档中被标记为“不供外部使用”,这意味着它们可能会在将来的某个时候改变或消失。GetAwaiter().GetResult()方法对有返回值的方法和没有返回值的方法都有效。

Note

Task<T>上使用Result还是GetAwaiter().GetResult()取决于你自己,大多数开发者基于异常处理来决定。如果你的方法返回Task,你必须使用GetAwaiter().GetResult()或者Wait()

例如,您可以像这样调用DoWorkAsync()方法:

Console.WriteLine(DoWorkAsync().Result);
Console.WriteLine(DoWorkAsync().GetAwaiter().GetResult());

要暂停执行,直到一个async方法返回一个void返回类型,只需在Task上调用Wait(),就像这样:

MethodReturningVoidAsync().Wait();

等待捕获并最终阻塞

C# 6 引入了在catchfinally块中放置 await 调用的能力。方法本身必须是async才能做到这一点。下面的代码示例演示了该功能:

static async Task<string> MethodWithTryCatch()
{
  try
  {
    //Do some work
    return "Hello";
  }
  catch (Exception ex)
  {
    await LogTheErrors();
    throw;
  }
  finally
  {
    await DoMagicCleanUp();
  }
}

通用异步返回类型(新 7.0)

在 C# 7 之前,async方法的唯一返回选项是TaskTask<T>void。C# 7 支持额外的返回类型,如果它们遵循async模式的话。一个具体的例子就是ValueTask。要了解这一点,请创建如下代码:

static async ValueTask<int> ReturnAnInt()
{
  await Task.Delay(1_000);
  return 5;
}

同样的规则也适用于ValueTaskTask,因为ValueTask只是值类型的一个Task,而不是强制在堆上分配一个对象。

本地函数(新 7.0)

局部函数在第四章中介绍,在第八章中使用迭代器。它们也有利于async方法。为了证明好处,你需要首先看到问题。添加一个名为MethodWithProblems()的新方法,并添加以下代码:

static async Task MethodWithProblems(int firstParam, int secondParam)
{
  Console.WriteLine("Enter");
  await Task.Run(() =>
  {
    //Call long running method
    Thread.Sleep(4_000);
    Console.WriteLine("First Complete");
    //Call another long running method that fails because
    //the second parameter is out of range
    Console.WriteLine("Something bad happened");
  });
}

场景是第二个长时间运行的任务由于无效的输入数据而失败。您可以(也应该)将检查添加到方法的开头,但是由于整个方法是异步的,因此无法保证检查将在何时执行。在调用代码继续运行之前,最好立即进行检查。在下面的更新中,检查以同步方式完成,然后私有函数异步执行:

static async Task MethodWithProblemsFixed(int firstParam, int secondParam)
{
  Console.WriteLine("Enter");
  if (secondParam < 0)
  {
    Console.WriteLine("Bad data");
    return;
  }

  await actualImplementation();

  async Task actualImplementation()
  {
    await Task.Run(() =>
    {
      //Call long running method
      Thread.Sleep(4_000);
      Console.WriteLine("First Complete");
      //Call another long running method that fails because
      //the second parameter is out of range
      Console.WriteLine("Something bad happened");
    });
  }
}

取消异步/等待操作

使用async / await模式也可以取消,比使用Parallel.ForEach模式简单得多。为了演示,我们将使用本章前面的同一个 WPF 项目。您可以重用该项目或添加一个新的 WPF 应用(。NET Core)添加到解决方案中,并通过执行以下 CLI 命令将System.Drawing.Common包添加到项目中:

dotnet new wpf -lang c# -n PictureHandlerWithAsyncAwait -o .\PictureHandlerWithAsyncAwait -f net5.0
dotnet sln .\Chapter15_AllProjects.sln add .\PictureHandlerWithAsyncAwait
dotnet add PictureHandlerWithAsyncAwait package System.Drawing.Common

如果您使用的是 Visual Studio,可以在解决方案资源管理器中右键单击解决方案名称,选择“添加➤项目”,并将其命名为PictureHandlerWithAsyncAwait。确保通过右键单击新项目名称并选择 Set as StartUp Project 将新项目设置为启动项目。添加System.Drawing.Common NuGet 包。

dotnet add PictureHandlerWithAsyncAwait package System.Drawing.Common

替换 XAML 以匹配之前的 WPF 项目,除了将标题更改为Picture Handler with Async/Await

MainWindow.xaml.cs文件中,确保以下using语句到位:

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Drawing;

接下来,为CancellationToken添加一个类级变量,并添加 Cancel 按钮事件处理程序:

private CancellationTokenSource _cancelToken = null;
private void cmdCancel_Click(object sender, EventArgs e)
{
  _cancelToken.Cancel();
}

过程和前面的例子一样:获取图片目录,创建输出目录,获取图片文件,旋转,保存到新目录。代替使用Parallel.ForEach(),这个新版本将使用async方法来完成工作,并且方法签名接受一个CancellationToken作为参数。输入以下代码:

private async void cmdProcess_Click(object sender, EventArgs e)
{
  _cancelToken = new CancellationTokenSource();
  var basePath = Directory.GetCurrentDirectory();
  var pictureDirectory =
    Path.Combine(basePath, "TestPictures");
  var outputDirectory =
    Path.Combine(basePath, "ModifiedPictures");
  //Clear out any existing files
  if (Directory.Exists(outputDirectory))
  {
    Directory.Delete(outputDirectory, true);
  }
  Directory.CreateDirectory(outputDirectory);
  string[] files = Directory.GetFiles(
    pictureDirectory, "*.jpg", SearchOption.AllDirectories);
  try
  {
    foreach(string file in files)
    {
      try
      {
        await ProcessFile(
         file, outputDirectory,_cancelToken.Token);
      }
      catch (OperationCanceledException ex)
      {
        Console.WriteLine(ex);
        throw;
      }
    }
  }
  catch (OperationCanceledException ex)
  {
    Console.WriteLine(ex);
    throw;
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex);
    throw;
  }
  _cancelToken = null;
  this.Title = "Processing complete";
}

在初始设置之后,代码遍历文件,并为每个文件异步调用ProcessFile()。对ProcessFile()的调用被包装在一个try / catch块中,CancellationToken被传递给ProcessFile()方法。如果在CancellationTokenSource上执行Cancel()(比如当用户点击取消按钮时),就会抛出OperationCanceledException

Note

try / catch代码可以在调用链中的任何地方(您很快就会看到)。是将它放在第一次调用中还是放在异步方法本身中,这纯粹是偏好和应用需求的问题。

要添加的最后一个方法是ProcessFile()方法。

private async Task ProcessFile(string currentFile,
  string outputDirectory, CancellationToken token)
{
  string filename = Path.GetFileName(currentFile);
  using (Bitmap bitmap = new Bitmap(currentFile))
  {
    try
    {
      await Task.Run(() =>
      {
        Dispatcher?.Invoke(() =>
        {
          this.Title = $"Processing {filename}";
        });
        bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
        bitmap.Save(Path.Combine(outputDirectory, filename));
      }
      ,token);
    }
    catch (OperationCanceledException ex)
    {
      Console.WriteLine(ex);
      throw;
    }
  }
}

这个方法使用了Task.Run命令的另一个重载,将CancellationToken作为一个参数。这个Task.Run命令被封装在一个try / catch块中(就像调用代码一样),以防用户点击取消按钮。

异步流(新 8.0)

C# 8.0 中的新特性,流(在第二十章中讨论)可以异步创建和使用。返回异步流的方法

  • 是用async修饰符声明的

  • 返回一个IAsyncEnumerable<T>

  • 包含用于返回异步流中连续元素的yield return语句(在第八章中介绍)

举以下例子:

public static async IAsyncEnumerable<int> GenerateSequence()
{
  for (int i = 0; i < 20; i++)
  {
    await Task.Delay(100);
    yield return i;
  }
}

该方法被声明为async,返回一个IAsyncEnumerable<int>,并使用yield return从序列中返回整数。若要调用此方法,请将以下内容添加到调用代码中:

await foreach (var number in GenerateSequence())
{
  Console.WriteLine(number);
}

异步包装并等待

这一节包含了很多例子;这一部分的要点如下:

  • 方法(以及 lambda 表达式或匿名方法)可以用async关键字标记,以使方法能够以非阻塞方式工作。

  • 标有async关键字的方法(以及 lambda 表达式或匿名方法)将同步运行,直到遇到await关键字。

  • 一个async方法可以有多个await上下文。

  • 当遇到await表达式时,调用线程被挂起,直到等待的任务完成。同时,控制权将返回给方法的调用方。

  • 关键字await将从视图中隐藏返回的Task对象,看起来像是直接返回底层返回值。没有返回值的方法简单地返回void

  • 参数检查和其他错误处理应该在方法的主要部分完成,实际的async部分被移到私有函数中。

  • 对于堆栈变量,ValueTaskTask对象更有效,这可能会导致装箱和取消装箱。

  • 作为命名约定,异步调用的方法应该用后缀Async标记。

摘要

本章从检查System.Threading名称空间的角色开始。正如您所了解的,当应用创建额外的执行线程时,结果是相关程序可以同时(看起来)执行许多任务。您还研究了几种保护线程敏感的代码块的方式,以确保共享资源不会变成不可用的伪数据单元。

然后,本章研究了一些新的模型,用于处理。NET 4.0,特别是任务并行库和 PLINQ。我总结了一下asyncawait关键字的作用。正如你所看到的,这些关键字在后台使用了许多类型的 TPL 框架;然而,编译器为您完成了创建复杂线程和同步代码的大部分工作。

十六、构建和配置类库

对于本书到目前为止的大多数例子,你已经创建了“独立的”可执行应用,其中所有的编程逻辑都被打包在一个单独的汇编(*.dll)中,并使用dotnet.exe(或者以汇编命名的dotnet.exe的副本)来执行。这些程序集使用的内存比。NET 核心基本类库。虽然有些简单。NET 核心程序可能只使用基本类库来构建,您(或您的队友)将可重用的编程逻辑隔离到可以在应用之间共享的自定义类库(*.dll文件)中可能是很平常的事情。

在这一章中,你将从学习把类型划分成。NET 核心命名空间。在此之后,您将深入了解。NET 核心,NET 核心和。NET 标准、应用配置、发布。NET 核心控制台应用,并将您的库打包成可重用的 NuGet 包。

定义自定义命名空间

在深入库部署和配置方面之前,第一项任务是了解将自定义类型打包到。NET 核心命名空间。到本文的这一点,您已经构建了一些小型测试程序,这些程序利用了。净核心宇宙(System,特指)。但是,当您构建具有许多类型的大型应用时,将相关类型分组到自定义命名空间中会很有帮助。在 C# 中,这是通过使用namespace关键字来完成的。创建共享程序集时,显式定义自定义命名空间甚至更加重要,因为其他开发人员将需要引用该库并导入您的自定义命名空间来使用您的类型。自定义命名空间还通过将您的自定义类与可能同名的其他自定义类隔离开来,来防止名称冲突。

为了直接调查这些问题,首先创建一个新的。名为 CustomNamespaces 的. NET 核心控制台应用项目。现在,假设您正在开发一个名为SquareCircleHexagon的几何类集合。鉴于它们的相似性,您希望将它们分组到一个独特的名称空间中,这个名称空间在CustomNamespaces.exe程序集内被称为MyShapes

虽然 C# 编译器对包含多种类型的单个 C# 代码文件没有问题,但在团队环境中工作时,这可能会有问题。如果您正在处理Circle类型,而您的同事需要处理Hexagon类,那么您将不得不轮流处理单块文件,或者面临难以解决的(至少是耗时的)合并冲突。

更好的方法是将每个类放在自己的文件中,每个类都有一个名称空间定义。为了确保每个类型都被打包到同一个逻辑组中,只需将给定的类定义包装在同一个名称空间范围内,如下所示:

namespace MyShapes
{
  // Circle class
  public class Circle { /* Interesting methods... */ }
}

// Hexagon.cs
namespace MyShapes
{
  // Hexagon class
  public class Hexagon { /* More interesting methods... */ }
}

// Square.cs
namespace MyShapes
{
  // Square class
  public class Square { /* Even more interesting methods...*/}
}

Guidance

每个代码文件中只有一个类被认为是最佳实践。虽然一些早期的例子没有做到这一点,但这是为了简化教学。在本书中,我的意图是总是将每个类分离到它自己的文件中。

注意MyShapes名称空间是如何充当这些类的概念“容器”的。当另一个名称空间(比如CustomNamespaces)想要使用单独名称空间中的类型时,可以使用using关键字,就像使用。NET 核心基类库,如下所示:

// Bring in a namespace from the base class libraries.
using System;
// Make use of types defined the MyShapes namespace.
using MyShapes;

Hexagon h = new Hexagon();
Circle c = new Circle();
Square s = new Square();

对于这个例子,假设定义MyShapes名称空间的 C# 文件是同一个控制台应用项目的一部分;换句话说,所有文件都被编译成一个程序集。如果在外部程序集中定义了MyShapes名称空间,那么在成功编译之前,还需要添加一个对该库的引用。在这一章中,你将学到构建使用外部库的应用的所有细节。

解析具有完全限定名的名称类

从技术上讲,当引用外部命名空间中定义的类型时,不需要使用 C# using关键字。您可以使用类型的完全限定名*,您可能还记得第一章中的内容,这是以定义名称空间为前缀的类型名。这里有一个例子:*

// Note we are not importing MyShapes anymore!
using System;

MyShapes.Hexagon h = new MyShapes.Hexagon();
MyShapes.Circle c = new MyShapes.Circle();
MyShapes.Square s = new MyShapes.Square();

通常,不需要使用完全限定名。它不仅需要更多的击键次数,而且在代码大小或执行速度方面没有任何区别。事实上,在 CIL 代码中,类型总是用完全限定名定义。从这个角度来看,C# using关键字仅仅是一个节省打字时间的工具。

但是,当使用包含同名类型的多个命名空间时,完全限定名有助于(有时是必要的)避免潜在的名称冲突。假设您有一个名为My3DShapes的新名称空间,它定义了以下三个类,能够以令人惊叹的 3D 方式呈现形状:

// Another shape-centric namespace.
//Circle.cs
namespace My3DShapes
{
  // 3D Circle class.
  public class Circle { }
}
//Hexagon.cs
namespace My3DShapes
{
  // 3D Hexagon class.
  public class Hexagon { }
}
//Square.cs
namespace My3DShapes
{
  // 3D Square class.
  public class Square { }
}

如果如下所示更新顶级语句,会出现几个编译时错误,因为两个命名空间都定义了同名的类:

// Ambiguities abound!
using System;
using MyShapes;
using My3DShapes;

// Which namespace do I reference?
Hexagon h = new Hexagon(); // Compiler error!
Circle c = new Circle();   // Compiler error!
Square s = new Square();   // Compiler error!

可以使用类型的完全限定名来解决这种不确定性,如下所示:

// We have now resolved the ambiguity.
My3DShapes.Hexagon h = new My3DShapes.Hexagon();
My3DShapes.Circle c = new My3DShapes.Circle();
MyShapes.Square s = new MyShapes.Square();

解析带有别名的名称类

C# using关键字还允许您为类型的完全限定名创建别名。这样做时,您定义了一个在编译时替换类型全名的标记。定义别名提供了解决名称冲突的第二种方法。这里有一个例子:

using System;
using MyShapes;
using My3DShapes;

// Resolve the ambiguity using a custom alias.
using The3DHexagon = My3DShapes.Hexagon;

// This is really creating a My3DShapes.Hexagon class.
The3DHexagon h2 = new The3DHexagon();
...

这种替代的using语法还允许您为冗长的名称空间创建别名。其中一个较长的基类库名称空间是System.Runtime.Serialization.Formatters.Binary,它包含一个名为BinaryFormatter的成员。如果您愿意,您可以创建一个BinaryFormatter的实例,如下所示:

using bfHome = System.Runtime.Serialization.Formatters.Binary;
bfHome.BinaryFormatter b = new bfHome.BinaryFormatter();
...

以及传统的using指令:

using System.Runtime.Serialization.Formatters.Binary;
BinaryFormatter b = new BinaryFormatter();
...

在游戏的这一点上,没有必要关心这个BinaryFormatter类是用来做什么的(你会在第二十章中研究这个类)。现在,只要记住 C# using关键字可以用来为冗长的完全限定名定义别名,或者更常见的是,用来解决在导入多个定义同名类型的名称空间时可能出现的名称冲突。

Note

请注意,过度使用 C# 别名会导致混乱的代码库。如果您团队中的其他程序员不知道您的自定义别名,他们可能会认为别名引用了基类库中的类型,当他们在文档中找不到这些标记时会变得非常困惑!

创建嵌套命名空间

在组织类型时,您可以自由地在其他命名空间中定义命名空间。基类库在许多地方都这样做,以提供更深层次的类型组织。例如,IO名称空间嵌套在System中以产生System.IO

那个。NET Core 项目模板将Program.cs中的初始代码添加到以项目命名的名称空间中。这个基本名称空间被称为名称空间。在此示例中,根命名空间由。NET Core 模板为CustomNamespaces,如下图所示:

namespace CustomNamespaces
{
  class Program
  {
  ...
  }
}

Note

用顶级语句替换Program / Main()组合时,不能给这些顶级语句分配名称空间。

要在根名称空间中嵌套MyShapesMy3DShapes名称空间,有两种选择。第一种方法是简单地嵌套 namespace 关键字,就像这样:

namespace CustomNamespaces
{
    namespace MyShapes
    {
        // Circle class
        public class Circle
        {
            /* Interesting methods... */
        }
    }
}

另一种选择(也是更常见的)是在名称空间定义中使用“点符号”,如下所示:

namespace CustomNamespaces.MyShapes
{
    // Circle class
    public class Circle
    {
         /* Interesting methods... */
    }
}

命名空间不必直接包含任何类型。这允许开发人员使用名称空间来提供更高层次的范围。

假设您现在已经在CustomNamespaces根名称空间中嵌套了My3DShapes名称空间,那么您需要更新任何现有的using指令和类型别名,如下所示(假设您已经更新了嵌套在根名称空间下的所有示例类):

using The3DHexagon = CustomNamespaces.My3DShapes.Hexagon;
using CustomNamespaces.MyShapes;

Guidance

一种常见的做法是按目录将文件分组到一个命名空间中。如图所示,文件在目录结构中的位置对名称空间没有影响。但是,它确实使名称空间结构对其他开发人员来说更加清晰(和具体)。因此,许多开发人员和代码林挺工具希望名称空间与文件夹结构相匹配。

更改 Visual Studio 的根命名空间

如上所述,当您使用 Visual Studio(或。NET Core CLI),应用的根命名空间的名称将与项目名称相同。从这一点开始,当您使用 Visual Studio 通过项目➤的“添加新项”菜单选项插入新的代码文件时,类型将自动包装在根命名空间中。如果您想更改根命名空间的名称,只需使用项目属性窗口的应用选项卡访问“默认命名空间”选项(参见图 16-1 )。

img/340876_10_En_16_Fig1_HTML.jpg

图 16-1。

配置默认命名空间

Note

Visual Studio 属性页仍然将根命名空间称为默认的命名空间。接下来你会看到为什么我称它为名称空间。

如果不使用 Visual Studio(或者甚至使用 Visual Studio),也可以通过更新项目(*.csproj)文件来配置根命名空间。和。NET 核心项目,在 Visual Studio 中编辑项目文件就像在解决方案资源管理器中双击项目文件一样简单(或者在解决方案资源管理器中右击项目文件并选择“编辑项目文件”)。文件打开后,通过添加RootNamespace节点来更新主PropertyGroup,如下所示:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <RootNamespace>CustomNamespaces2</RootNamespace>
  </PropertyGroup>

</Project>

目前为止,一切顺利。既然您已经看到了关于如何将自定义类型打包到组织良好的命名空间中的一些细节,那么让我们快速回顾一下。网芯装配。此后,您将深入研究创建、部署和配置自定义类库的细节。

的作用.NETCore 组件

。NET 核心应用是由任意数量的组件拼凑而成的。简而言之,程序集是由。NET 核心运行时。尽管如此。NET Core 程序集与以前的 Windows 二进制文件具有相同的文件扩展名(*.exe*.dll),它们与那些文件几乎没有共同之处。在解开最后一个陈述之前,让我们考虑一下汇编格式提供的一些好处。

程序集促进代码重用

由于您已经在前面的章节中构建了您的控制台应用项目,似乎所有的应用的功能都包含在您正在构建的可执行程序集中。您的应用利用了包含在始终可访问的。NET 核心基本类库。

正如你可能知道的,一个代码库(也称为类库)是一个*.dll,它包含了外部应用想要使用的类型。当您创建可执行程序集时,您无疑会在创建应用时利用大量系统提供的和自定义的代码库。但是,请注意,代码库不需要使用*.dll文件扩展名。可执行程序集完全有可能(尽管肯定不常见)使用外部可执行文件中定义的类型。由此看来,引用的*.exe也可以被认为是一个代码库。

不管代码库是如何打包的。NET Core platform 允许您以独立于语言的方式重用类型。例如,您可以在 C# 中创建一个代码库,并在任何其他代码库中重用该库。NET 核心编程语言。不仅可以跨语言分配类型,还可以从中派生类型。C# 中定义的基类可以由 Visual Basic 中编写的类来扩展。F# 中定义的接口可以通过 C# 中定义的结构来实现,等等。重点是,当你开始把一个单一的可执行文件分解成许多个时。NET 核心程序集,您实现了一种与语言无关的代码重用形式。

程序集建立类型边界

回想一下,类型的完全限定名是通过将类型的名称空间(例如System)作为其名称(例如Console)的前缀来组成的。然而,严格地说,类型所在的程序集进一步建立了类型的标识。例如,如果您有两个唯一命名的程序集(比如,MyCars.dllYourCars.dll,它们都定义了一个包含名为SportsCar的类的名称空间(CarLibrary),那么它们在。净核心宇宙。

程序集是可版本化的单位

。NET Core 程序集被分配一个由四部分组成的数字版本号,形式为< major >。< 小调 >。< 打造 >。< 修订 >。(如果没有显式提供版本号,则会自动为程序集分配 1.0.0.0 版本(给定默认值)。NET 核心项目设置。)这个数字允许同一程序集的多个版本在一台机器上和谐共存。

程序集是自描述的

程序集被认为是自描述的,部分原因是它们在程序集的清单中记录了它们必须能够访问以正确运行的每个外部程序集。回想一下第一章中的内容,清单是描述程序集本身(名称、版本、所需的外部程序集等)的元数据块。).

除了清单数据,程序集还包含描述组成的元数据(成员名、实现的接口、基类、构造函数等)。)的所有包含类型。因为程序集记录得如此详细,所以。NET Core Runtime 不也不咨询 Windows 系统注册表来解析它的位置(与微软遗留的 COM 编程模型完全不同)。这种与注册中心的分离是使。NET 核心应用可以在除 Windows 之外的其他操作系统上运行,并且支持多个版本的。NET Core 在同一台机器上。

正如您将在本章中发现的。NET Core Runtime 使用了一种全新的方案来解析外部代码库的位置。

了解. NET 核心程序集的格式

既然您已经了解了。NET Core assembly,让我们换个话题,更好地了解一个程序集是如何组成的。从结构上讲,. NET 核心程序集(*.dll*.exe)由以下元素组成:

  • 操作系统(例如,Windows)文件头

  • CLR 文件头

  • CIL 队列

  • 类型元数据

  • 程序集清单

  • 可选嵌入式资源

虽然前两个元素(操作系统和 CLR 头)是您通常可以忽略的数据块,但它们确实值得简单考虑一下。以下是每个元素的概述。

安装 C++分析工具

接下来的几节使用一个实用程序调用dumpbin.exe,它是 C分析工具附带的。安装时,在快速搜索栏中输入 *C评测工具*,点击提示安装工具,如图 16-2 所示。

img/340876_10_En_16_Fig2_HTML.jpg

图 16-2。

从快速启动安装 C++分析工具

这将打开带有所选工具的 Visual Studio 安装程序。或者,您可以自己启动 Visual Studio 安装程序,并选择如图 16-3 所示的组件。

img/340876_10_En_16_Fig3_HTML.jpg

图 16-3。

安装 C++分析工具

操作系统(Windows)文件头

操作系统文件头确定了目标操作系统(在下面的示例中为 Windows)可以加载和操作程序集的事实。这个头数据还标识了操作系统托管的应用的种类(基于控制台、基于 GUI 或*.dll代码库)。

使用带有/headers标志的dumpbin.exe实用程序(通过开发者命令提示符)打开CarLibrary.dll文件(在书的回购中或在本章后面创建),如下所示:

dumpbin /headers CarLibrary.dll

这将显示程序集的操作系统头文件信息(在为 Windows 构建时,如下所示)。以下是CarLibrary.dll的(部分)窗口标题信息:

Dump of file carlibrary.dll
PE signature found
File Type: DLL

FILE HEADER VALUES
       14C machine (x86)
         3 number of sections
  BB89DC3D time date stamp
         0 file pointer to symbol table
         0 number of symbols
        E0 size of optional header
      2022 characteristics
             Executable
             Application can handle large (>2GB) addresses
             DLL
...

现在,记住这一点。NET 核心程序员永远不需要关心嵌入在. NET 核心程序集中的标题数据的格式。除非你碰巧在建造一个新的。NET Core language compiler(在这里,你会关心这些信息),你可以自由地保持幸福,不知道头数据的肮脏细节。但是,请注意,当操作系统将二进制映像加载到内存中时,这些信息是在幕后使用的。

CLR 文件头

CLR 头是一个数据块。NET 核心程序集必须支持由。NET 核心运行时。简而言之,这个头文件定义了许多标志,使运行时能够理解托管文件的布局。例如,存在标识元数据和资源在文件中的位置、生成程序集所依据的运行库版本、(可选)公钥的值等的标志。用/clrheader标志再次执行dumpbin.exe

dumpbin /clrheader CarLibrary.dll

您将看到给定的内部 CLR 头信息。NET 核心程序集,如下所示:

Dump of file CarLibrary.dll
File Type: DLL

  clr Header:

   48 cb
 2.05 runtime version
 2158 [ B7C] RVA [size] of MetaData Directory
    1 flags
        IL Only
    0 entry point token
    0 [   0] RVA [size] of Resources Directory
    0 [   0] RVA [size] of StrongNameSignature Directory
    0 [   0] RVA [size] of CodeManagerTable Directory
    0 [   0] RVA [size] of VTableFixups Directory
    0 [   0] RVA [size] of ExportAddressTableJumps Directory
    0 [   0] RVA [size] of ManagedNativeHeader Directory

  Summary

        2000 .reloc
        2000 .rsrc
        2000 .text

同样,作为. NET 核心开发人员,您不需要关心程序集的 CLR 头信息的血淋淋的细节。你只要明白。NET Core assembly 包含这些数据,这些数据由。当图像数据加载到内存中时。现在将注意力转向一些在日常编程任务中更有用的信息。

CIL 代码、类型元数据和程序集清单

在它的核心,一个汇编包含 CIL 代码,你还记得,这是一个平台和 CPU 无关的中间语言。在运行时,根据特定于平台和 CPU 的指令,使用实时(JIT)编译器动态编译内部 CIL。鉴于这种设计。NET 核心程序集确实可以在各种体系结构、设备和操作系统上执行。(尽管不理解 CIL 编程语言的细节,你也可以过上快乐而富有成效的生活,但第十九章提供了 CIL 语法和语义的介绍。)

程序集还包含完整描述所包含类型的格式以及该程序集引用的外部类型的格式的元数据。那个。NET Core runtime 使用此元数据来解析类型(及其成员)在二进制文件中的位置,在内存中布置类型,并方便远程方法调用。您将了解。NET 元数据格式在第十七章中。

一个程序集还必须包含一个关联的清单(也称为程序集元数据)。清单记录程序集内的每个模块,建立程序集的版本,并记录当前程序集引用的任何外部程序集。正如您将在本章中看到的,CLR 在定位外部程序集引用的过程中广泛使用了程序集清单。

可选程序集资源

最后,一个. NET 核心程序集可能包含任意数量的嵌入式资源,如应用图标、图像文件、声音剪辑或字符串表。事实上。NET 核心平台支持只包含本地化资源的附属程序集。如果您希望基于特定的文化(英语、德语等)来划分资源,这可能会很有用。)以构建国际软件为目的。生成附属程序集的主题超出了本文的范围。请参考。NET 核心文档,如果您感兴趣,可以获得有关附属程序集和本地化的信息。

类库与控制台应用

到目前为止,本书中的例子几乎都是独家的。NET 核心控制台应用。如果你正在阅读这本书。NET 开发人员,这些就像。NET 控制台应用,主要区别在于配置过程(稍后将介绍)以及它们运行的环境。NET 核心。控制台应用具有单一入口点(指定的Main()方法或顶级语句),可以与控制台交互,并且可以直接从操作系统启动。另一个区别是。网芯和。NET 控制台应用是控制台应用在。NET 核心是使用。NET 核心应用主机(dotnet.exe)。

另一方面,类库没有入口点,因此不能直接启动。它们用于封装逻辑、自定义类型等,并被其他类库和/或控制台应用引用。换句话说,类库是用来包含“的角色”中谈到的东西的。NET 核心程序集”一节。

.NET 标准与。NET 核心类库

。NET 核心类库运行在。网核,还有。NET 类库运行在. NET 上。非常简单。但是,这有一个问题。假设你有一个大的。NET 代码库,在您和您的团队的支持下(可能)进行了多年的开发。您和您的团队多年来构建的应用可能利用了大量的共享代码。也许是集中的日志记录、报告或特定领域的功能。

现在,您(和您的组织)想搬到。所有新开发的 NET Core。那些共享代码呢?将所有遗留代码重写为。NET 核心程序集可能会很重要,直到您的所有应用都被迁移到。NET Core,您必须支持两个版本(一个在。网和一个在。网芯)。这会让生产率嘎然而止。

幸运的是。NET Core 想通了这个场景。。NET Standard 是一种新型的类库项目,由。NET 核心并可以被。NET 以及。NET 核心应用。不过,在你燃起希望之前,还有一个难题。NET(核心)5。稍后会有更多内容。

每个。NET 标准版定义了一组所有人都必须支持的通用 API。NET 版本(。. NET。网芯,Xamarin 等。)要符合标准。例如,如果您要将类库构建为. NET Standard 2.0 项目,它可以被。NET 4.61+和。NET Core 2.0+(加上各种版本的 Xamarin、Mono、Universal Windows Platform、Unity)。

这意味着您可以将代码从。NET 类库转换成。NET 标准 2.0 类库,它们可以由。NET 核心和。NET 应用。这比支持相同代码的重复拷贝(每个框架一个)要好得多。

现在开始抓东西。每个。NET 标准版代表了它所支持的框架的最小公分母。这意味着版本越低,你在类库中能做的就越少。而。NET(核心)5 和。NET Core 3.1 都可以引用. NET Standard 2.0 库,但不能在. NET Standard 2.0 库中使用大量 C# 8.0(或任何 C# 9.0)功能。你必须使用。NET Standard 2.1,完全支持 C# 8.0 和 C# 9.0。还有。NET 4.8(原版的最新/最后版本。NET Framework)只上升到。NET 标准 2.0。

对于在新的应用中利用现有代码来说,这仍然是一个很好的机制,但不是一个灵丹妙药。

配置应用

虽然有可能保留您的所需的所有信息。NET 核心应用的源代码中,能够在运行时更改某些值在大多数重要的应用中是至关重要的。这通常是通过应用附带的配置文件来完成的。

Note

以前的。NET 框架主要依赖于名为app.config(对于 ASP.NET 应用来说,名为web.config)的 XML 文件。虽然仍然可以使用基于 XML 的配置文件,但是配置的主要方法。NET 核心应用是与 JavaScript 对象符号(JSON)文件一起使用的,如本节所示。配置将在“ASP.NET 核心”和“WPF”章节中深入讨论。

为了演示这个过程,创建一个新的。名为 FunWithConfiguration 的. NET Core 控制台应用,并将以下包引用添加到您的项目中:

dotnet new console -lang c# -n FunWithConfiguration -o .\FunWithConfiguration -f net5.0
dotnet add FunWithConfiguration package Microsoft.Extensions.Configuration.Json

这为基于 JSON 文件的。NET 核心配置子系统(及其依赖项)到您的项目中。为了利用这一点,首先在项目中添加一个名为appsettings.json的新 JSON 文件。更新项目文件,以确保在生成项目时,该文件始终被复制到输出目录中。

<ItemGroup>
  <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </None>
</ItemGroup>

最后,更新文件以匹配以下内容:

{
  "CarName": "Suzy"
}

Note

如果您不熟悉 JSON,它是一种名称-值对格式,每个对象都用花括号括起来。整个文件可以作为单个对象读取,子对象也用花括号标记。在本书的后面,您将使用更复杂的 JSON 文件。

最后一步是读取配置文件并获得CarName值。将Program.cs中的using语句更新如下:

using System;
using System.IO;
using Microsoft.Extensions.Configuration;

Main()方法更新如下:

static void Main(string[] args)
{
  IConfiguration config = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", true, true)
    .Build();
}

新的配置系统以一个ConfigurationBuilder开始。这允许您添加多个文件,设置属性(比如配置文件的位置),然后最终将配置构建到一个IConfiguration实例中。

一旦有了IConfiguration的实例,就可以像在。净 4.8。将以下内容添加到Main()方法的底部,当您运行应用时,您将看到写入控制台的值:

Console.WriteLine($"My car's name is {config["CarName"]}");
Console.ReadLine();

除了 JSON 文件,还有支持环境变量、Azure Key Vault、命令行参数等的配置包。参考。NET 核心文档以了解更多信息。

构建和使用. NET 核心类库

开始探索。NET 核心类库,您将首先创建一个包含一小组公共类型的*.dll程序集(名为CarLibrary)。首先,创建章节解决方案。如果你还没有这样做,创建一个名为CarLibrary的类库,并将其添加到你的章节解决方案中。

dotnet new sln -n Chapter16_AllProjects
dotnet new classlib -lang c# -n CarLibrary -o .\CarLibrary -f net5.0
dotnet sln .\Chapter16_AllProjects.sln add .\CarLibrary

第一个命令在当前目录中创建一个名为Chapter16_AllProjects ( -n)的空解决方案文件。下一个命令创建一个新的。NET 5.0 ( -f)类库名为CarLibrary ( -n),在子目录名为CarLibrary ( -o)中。输出(-o)位置是可选的。如果关闭,将在与项目名称同名的子目录中创建项目。最后一个命令将新项目添加到解决方案中。

Note

那个。NET Core CLI 有很好的帮助系统。要获得任何命令的帮助,请将-h添加到命令中。例如,要查看所有模板,请键入dotnet new -h。要获得更多关于创建类库的信息,请键入dotnet new classlib -h

既然已经创建了项目和解决方案,就可以在 Visual Studio(或 Visual Studio 代码)中打开它,开始构建类。打开解决方案后,删除自动生成的文件Class1.cs

你的汽车库的设计从EngineStateEnumMusicMediaEnum枚举开始。向您的项目添加两个名为MusicMediaEnum.csEngineStateEnum.cs的文件,并分别添加以下代码:

//MusicMediaEnum.cs
namespace CarLibrary
{
    // Which type of music player does this car have?
    public enum MusicMediaEnum
    {
        MusicCd,
        MusicTape,
        MusicRadio,
        MusicMp3
    }
}
//EngineStateEnum.cs
namespace CarLibrary
{
    // Represents the state of the engine.
    public enum EngineStateEnum
    {
        EngineAlive,
        EngineDead
    }
}

接下来,添加一个名为Car的抽象基类,它通过自动属性语法定义各种状态数据。这个类还有一个名为TurboBoost()的抽象方法,它使用一个自定义枚举(EngineState)来表示汽车引擎的当前状态。将一个名为Car.cs的新 C# 类文件插入到您的项目中,该文件包含以下代码:

using System;

namespace CarLibrary
{
  // The abstract base class in the hierarchy.
  public abstract class Car
  {
    public string PetName {get; set;}
    public int CurrentSpeed {get; set;}
    public int MaxSpeed {get; set;}

    protected EngineStateEnum State = EngineStateEnum.EngineAlive;
    public EngineStateEnum EngineState => State;
    public abstract void TurboBoost();

    protected Car(){}
    protected Car(string name, int maxSpeed, int currentSpeed)
    {
      PetName = name;
      MaxSpeed = maxSpeed;
      CurrentSpeed = currentSpeed;
    }
  }
}

现在假设您有两个名为MiniVanSportsCarCar类型的直接后代。每个都通过控制台消息显示适当的消息来覆盖抽象的TurboBoost()方法。在你的项目中插入两个新的 C# 类文件,分别命名为MiniVan.csSportsCar.cs。用相关代码更新每个文件中的代码。

//SportsCar.cs
using System;
namespace CarLibrary
{
  public class SportsCar : Car
  {
    public SportsCar(){ }
    public SportsCar(
      string name, int maxSpeed, int currentSpeed)
      : base (name, maxSpeed, currentSpeed){ }

    public override void TurboBoost()
    {
      Console.WriteLine("Ramming speed! Faster is better...");
    }
  }
}

//MiniVan.cs
using System;
namespace CarLibrary
{
  public class MiniVan : Car
  {
    public MiniVan(){ }
    public MiniVan(
      string name, int maxSpeed, int currentSpeed)
      : base (name, maxSpeed, currentSpeed){ }

    public override void TurboBoost()

    {
      // Minivans have poor turbo capabilities!
      State = EngineStateEnum.EngineDead;
      Console.WriteLine("Eek! Your engine block exploded!");
    }
  }
}

探索清单

在从客户端应用使用CarLibrary.dll之前,让我们看看代码库是如何在幕后组成的。假设您已经编译了这个项目,对编译后的程序集运行ildasm.exe。如果你没有ildasm.exe(本书前面已经介绍过了),它也位于本书资源库的第十六章目录中。

ildasm /all /METADATA /out=CarLibrary.il .\CarLibrary\bin\Debug\net5.0\CarLibrary.dll

拆解结果的Manifest部分从//Metadata version: 4.0.30319开始。紧随其后的是类库所需的所有外部程序集的列表,如下所示:

// Metadata version: v4.0.30319
.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 5:0:0:0
}
.assembly extern System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 5:0:0:0
}

每个.assembly extern块都由.publickeytoken.ver指令限定。只有当程序集配置了一个强名称时,.publickeytoken指令才会出现。.ver标记定义了引用程序集的数字版本标识符。

Note

以前版本的。NET Framework 在很大程度上依赖于强命名,这涉及到使用公钥/私钥组合。在 Windows 上,要将程序集添加到全局程序集缓存中,这是必需的,但随着的出现,这种需要已经大大减少了。NET 核心。

在外部引用之后,您会发现许多标识汇编级属性的.custom标记(有些是系统生成的,但也有版权信息、公司名称、汇编版本等。).以下是这部分清单数据的(非常)部分清单:

.assembly CarLibrary
{
...
  .custom instance void ... TargetFrameworkAttribute ...
  .custom instance void ... AssemblyCompanyAttribute ...
  .custom instance void ... AssemblyConfigurationAttribute ...
  .custom instance void ... AssemblyFileVersionAttribute ...
  .custom instance void ... AssemblyProductAttribute ...
  .custom instance void ... AssemblyTitleAttribute ...

可以使用 Visual Studio 属性页设置这些设置,也可以编辑项目文件并添加正确的元素。若要在 Visual Studio 中进行编辑,请在解决方案资源管理器中右击该项目,选择“属性”,然后导航到窗口左栏中的“包”菜单。这将弹出如图 16-4 所示的对话框。

img/340876_10_En_16_Fig4_HTML.jpg

图 16-4。

使用 Visual Studio 的属性窗口编辑程序集信息

将元数据添加到程序集的另一种方式是直接在*.csproj项目文件中。以下对项目文件中主PropertyGroup的更新与填写如图 16-4 所示的表格做同样的事情。

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <Copyright>Copyright 2020</Copyright>
    <Authors>Phil Japikse</Authors>
    <Company>Apress</Company>
    <Product>Pro C# 9.0</Product>
    <PackageId>CarLibrary</PackageId>
    <Description>This is an awesome library for cars.</Description>
    <AssemblyVersion>1.0.0.1</AssemblyVersion>
    <FileVersion>1.0.0.2</FileVersion>
    <Version>1.0.0.3</Version>
  </PropertyGroup>

Note

图 16-4 (和项目文件列表)中的其余条目在从您的程序集生成 NuGet 包时使用。这将在本章的后面介绍。

探索 CIL

回想一下,程序集不包含特定于平台的指令;相反,它包含平台无关的通用中间语言(CIL)指令。当。NET 核心运行库将程序集加载到内存中,基础 CIL 被编译(使用 JIT 编译器)成目标平台可以理解的指令。例如,SportsCar类的TurboBoost()方法由下面的 CIL 表示:

.method public hidebysig virtual
   instance void  TurboBoost() cil managed
{
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldstr "Ramming speed! Faster is better..."
  IL_0006:  call  void [System.Console]System.Console::WriteLine(string)
  IL_000b:  nop
  IL_000c:  ret
}
// end of method SportsCar::TurboBoost

正如本书中的其他 CIL 例子一样,大多数。NET Core 开发者不需要深度关注细节。第十九章提供了关于它的语法和语义的更多细节,这在你构建需要高级服务的更复杂的应用时会很有帮助,比如程序集的运行时构造。

探索类型元数据

在构建一些使用您的自定义。在. NET 库中,检查CarLibrary.dll程序集中类型的元数据。举个例子,下面是EngineStateEnumTypeDef:

 TypeDef #1 (02000002)
 -------------------------------------------------------
  TypDefName: CarLibrary.EngineStateEnum
  Flags     : [Public] [AutoLayout] [Class] [Sealed] [AnsiClass]
  Extends   : [TypeRef] System.Enum
  Field #1
  -------------------------------------------------------
   Field Name: value__
   Flags     : [Public] [SpecialName] [RTSpecialName]
   CallCnvntn: [FIELD]
   Field type:  I4

  Field #2
  -------------------------------------------------------
   Field Name: EngineAlive
   Flags     : [Public] [Static] [Literal] [HasDefault]
  DefltValue: (I4) 0
   CallCnvntn: [FIELD]
   Field type:  ValueClass CarLibrary.EngineStateEnum

  Field #3
  -------------------------------------------------------
   Field Name: EngineDead
   Flags     : [Public] [Static] [Literal] [HasDefault]
  DefltValue: (I4) 1
   CallCnvntn: [FIELD]
   Field type:  ValueClass CarLibrary.EngineStateEnum

正如下一章所解释的,程序集的元数据是。NET 核心平台,并作为众多技术(对象序列化、延迟绑定、可扩展应用等)的主干。).无论如何,现在你已经看到了CarLibrary.dll程序集的内部,你可以构建一些使用你的类型的客户端应用。

构建 C# 客户端应用

因为每个 CarLibrary 类型都是使用关键字public声明的,所以其他。NET 核心应用也能够使用它们。回想一下,您也可以使用 C# internal关键字定义类型(事实上,这是类的默认 C# 访问模式)。内部类型只能由定义它们的程序集使用。外部客户端既不能看到也不能创建用internal关键字标记的类型。

Note

这个规则的例外是,当一个程序集使用InternalsVisibleTo属性显式地允许访问另一个程序集时,这一点很快就会谈到。

要使用库的功能,请在与 CarLibrary 相同的解决方案中创建一个名为 CSharpCarClient 的新 C# 控制台应用项目。您可以使用 Visual Studio(右击该解决方案并选择“添加➤新项目”)或使用命令行(三行,每一行单独执行)来完成此操作。

dotnet new console -lang c# -n CSharpCarClient -o .\CSharpCarClient -f net5.0
dotnet add CSharpCarClient reference CarLibrary
dotnet sln .\Chapter16_AppRojects.sln add .\CSharpCarClient

前面的命令创建了控制台应用,为新项目添加了对 CarLibrary 项目的项目引用,并将其添加到您的解决方案中。

Note

add reference命令创建一个项目引用。这便于开发,因为 CSharpCarClient 将始终使用最新版本的 CarLibrary。你也可以直接引用一个组件*。直接引用是通过引用编译后的类库创建的。*

*如果您仍然在 Visual Studio 中打开了该解决方案,您会注意到新项目会显示在解决方案资源管理器中,无需您进行任何干预。

最后要做的更改是在解决方案资源管理器中右击 CSharpCarClient 并选择“设为启动项目”。如果您没有使用 Visual Studio,您可以通过执行项目目录中的dotnet run来运行新项目。

Note

您也可以在 Visual Studio 中设置项目引用,方法是在解决方案资源管理器中右击 CSharpCarClient 项目,选择“添加➤引用”,然后从项目节点中选择 CarLibrary 项目。

此时,您可以构建您的客户端应用来使用外部类型。按如下方式更新您的初始 C# 文件:

using System;
// Don't forget to import the CarLibrary namespace!
using CarLibrary;

Console.WriteLine("***** C# CarLibrary Client App *****");
// Make a sports car.
SportsCar viper = new SportsCar("Viper", 240, 40);
viper.TurboBoost();

// Make a minivan.
MiniVan mv = new MiniVan();
mv.TurboBoost();

Console.WriteLine("Done. Press any key to terminate");
Console.ReadLine();

这段代码看起来就像书中到目前为止开发的其他应用的代码。唯一有趣的一点是,C# 客户端应用现在使用在单独的自定义库中定义的类型。运行您的程序,并验证您是否看到消息的显示。

您可能想知道当您引用 CarLibrary 项目时到底发生了什么。当产生一个项目引用时,解决方案构建顺序被调整,使得依赖项目(本例中为 CarLibrary)首先构建,然后该构建的输出被复制到父项目(CSharpCarLibrary)的输出目录中。编译后的客户端库引用编译后的类库。当重新构建客户端项目时,从属库也会重新构建,新版本会再次复制到目标文件夹中。

Note

如果您使用的是 Visual Studio,可以在解决方案资源管理器中单击“显示所有文件”按钮,这样就可以看到所有的输出文件,并验证编译后的类库是否存在。如果您使用的是 Visual Studio 代码,请在资源管理器选项卡中导航到bin/debug/net5.0目录。

当进行直接引用时,编译后的库也被复制到客户端库的输出目录中,但是在进行引用时。如果没有适当的项目引用,项目可能会彼此独立地构建,并且文件可能会变得不同步。简而言之,如果您正在开发依赖库(实际软件项目通常都是这样),最好引用项目而不是项目输出。

构建 Visual Basic 客户端应用

回想一下。NET 核心平台允许开发人员跨编程语言共享编译后的代码。来说明。NET 核心平台,让我们创建另一个控制台应用项目(VisualBasicCarClient),这次使用 Visual Basic(注意每个命令都是一行命令)。

dotnet new console -lang vb -n VisualBasicCarClient -o .\VisualBasicCarClient -f net5.0
dotnet add VisualBasicCarClient reference CarLibrary
dotnet sln .\Chapter16_AllProjects.sln add VisualBasicCarClient

像 C# 一样,Visual Basic 允许您列出当前文件中使用的每个命名空间。然而,Visual Basic 提供了Imports关键字而不是 C# using关键字,所以在Program.vb代码文件中添加下面的Imports语句:

Imports CarLibrary
Module Program
  Sub Main()
  End Sub
End Module

请注意,Main()方法是在 Visual Basic 模块类型中定义的。简而言之,模块是一种 Visual Basic 符号,用于定义只能包含静态方法的类(很像 C# 静态类)。在任何情况下,要使用 Visual Basic 的语法来练习MiniVanSportsCar类型,请按如下方式更新您的Main()方法:

Sub Main()
  Console.WriteLine("***** VB CarLibrary Client App *****")
  ' Local variables are declared using the Dim keyword.
  Dim myMiniVan As New MiniVan()
  myMiniVan.TurboBoost()

  Dim mySportsCar As New SportsCar()
  mySportsCar.TurboBoost()
  Console.ReadLine()
End Sub

当您编译并运行您的应用时(确保在 Visual Studio 中将 VisualBasicCarClient 设置为启动项目),您将再次发现显示了一系列消息框。此外,这个新的客户端应用在bin\Debug\net5.0文件夹下有自己的CarLibrary.dll本地副本。

跨语言继承在起作用

迷人的一面。NET 核心开发的概念是跨语言继承。为了说明,让我们创建一个从SportsCar(使用 C# 编写)派生的新 Visual Basic 类。首先,在当前的 Visual Basic 应用中添加一个名为PerformanceCar.vb的新类文件。通过使用Inherits关键字从SportsCar类型派生来更新初始类定义。然后,使用Overrides关键字覆盖抽象的TurboBoost()方法,就像这样:

Imports CarLibrary
' This VB class is deriving from the C# SportsCar.
Public Class PerformanceCar
  Inherits SportsCar
  Public Overrides Sub TurboBoost()
    Console.WriteLine("Zero to 60 in a cool 4.8 seconds...")
  End Sub
End Class

为了测试这个新的类类型,更新模块的Main()方法,如下所示:

Sub Main()
...
  Dim dreamCar As New PerformanceCar()

  ' Use Inherited property.
  dreamCar.PetName = "Hank"
  dreamCar.TurboBoost()
  Console.ReadLine()
End Sub

注意,dreamCar对象可以调用继承链中的任何公共成员(比如PetName属性),尽管基类是用完全不同的语言和完全不同的程序集中定义的!以独立于语言的方式跨程序集边界扩展类的能力是。净核心开发周期。这使得使用由不愿意用 C# 构建共享代码的人编写的编译代码变得容易。

向其他程序集公开内部类型

如前所述,内部类仅对定义它们的程序集中的其他对象可见。例外情况是,当另一个项目被明确授予可见性时。

首先向 CarLibrary 项目添加一个名为MyInternalClass的新类,并将代码更新如下:

namespace CarLibrary
{
  internal class MyInternalClass
  {
  }
}

Note

为什么要公开内部类型呢?这通常是为单元和集成测试而做的。开发人员希望能够测试他们的代码,但不一定要将代码暴露在程序集之外。

使用程序集属性

第十七章将深入讨论属性,但现在打开 CarLibrary 项目中的Car.cs类,并添加以下属性和using语句:

using System.Runtime.CompilerServices;
[assembly:InternalsVisibleTo("CSharpCarClient")]
namespace CarLibrary
{
}

InternalsVisibleTo属性采用项目的名称,该项目可以查看设置了属性的类。请注意,其他项目不能“请求”此权限;它必须由持有内部类型的项目授予。

Note

以前版本的。NET 利用了AssemblyInfo.cs类,它仍然存在于。NET Core,但它是自动生成的,不是供开发人员使用的。

现在,您可以通过向Main()方法添加以下代码来更新 CSharpCarClient 项目:

var internalClassInstance = new MyInternalClass();

这工作完美。现在尝试在VisualBasicCarClient Main方法中做同样的事情。

'Will not compile
'Dim internalClassInstance = New MyInternalClass()

因为没有授予 VisualBasicCarClient 库查看内部的权限,所以前面一行代码将不会编译。

使用项目文件

完成同样事情的另一种方法(也可能被认为更符合。NET 核心方式)是使用。NET 核心项目文件。

注释掉您刚刚添加的属性,并打开 CarLibrary 的项目文件。在项目文件中添加以下ItemGroup:

<ItemGroup>
  <AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
    <_Parameter1>CSharpCarClient</_Parameter1>
  </AssemblyAttribute>
</ItemGroup>

这实现了与在类上使用属性相同的事情,在我看来,这是一个更好的解决方案,因为其他开发人员将在项目文件中看到它,而不必知道在整个项目中的何处查找。

努杰和。净核心

NuGet 是的包管理器。NET 和。NET 核心。它是一种以某种格式共享软件的机制。NET 核心应用理解并且是默认的加载机制。NET 核心及其相关框架组件(ASP.NET 核心、EF 核心等)。).许多组织将用于横切关注点(如日志和错误报告)的标准程序集打包到 NuGet 包中,以供他们的业务线应用使用。

用 NuGet 打包程序集

为了看到这一点,我们将把 CarLibrary 转换成一个 NuGet 包,然后从两个客户机应用中引用它。

可以从项目的属性页访问 NuGet 包属性。右键单击 CarLibrary 项目,然后选择“属性”。导航到 Package 页面,查看我们之前输入的值,以自定义程序集。可以为 NuGet 包设置其他属性(例如,许可协议接受和项目信息,如 URL 和存储库位置)。

Note

Visual Studio 包页面 UI 中的所有值都可以手动输入到项目文件中,但是您需要知道关键字。至少使用一次 Visual Studio 来填写所有内容会有所帮助,然后您可以手动编辑项目文件。您还可以在。NET 核心文档。

对于这个例子,我们不需要设置任何额外的属性,除了选中“在构建时生成 NuGet 包”复选框或者用以下内容更新项目文件:

<PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <Copyright>Copyright 2020</Copyright>
    <Authors>Phil Japikse</Authors>
    <Company>Apress</Company>
    <Product>Pro C# 9.0</Product>
    <PackageId>CarLibrary</PackageId>
    <Description>This is an awesome library for cars.</Description>
    <AssemblyVersion>1.0.0.1</AssemblyVersion>
    <FileVersion>1.0.0.2</FileVersion>
    <Version>1.0.0.3</Version>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
  </PropertyGroup>

这将导致每次构建软件时都要重新构建软件包。默认情况下,将在bin/Debugbin/Release文件夹中创建包,这取决于所选择的配置。

也可以从命令行创建包,CLI 提供了比 Visual Studio 更多的选项。例如,要构建包并将其放在名为Publish的目录中,输入以下命令(在CarLibrary项目目录中)。第一个命令构建程序集,第二个命令打包 NuGet 包。

dotnet build -c Release
dotnet pack -o .\Publish -c Debug

Note

Debug 是默认配置,所以没有必要指定-c Debug,但是我明确地包含了这个选项,以便完全清楚它的意图。

CarLibrary.1.0.0.3.nupkg文件现在位于Publish目录中。要查看它的内容,用任何 zip 实用程序(比如 7-Zip)打开文件,就可以看到整个内容,包括程序集,还包括附加的元数据。

引用 NuGet 包

您可能想知道在前面的例子中添加的包是从哪里来的。NuGet 包的位置由一个名为NuGet.Config的基于 XML 的文件控制。在 Windows 上,该文件位于%appdata%\NuGet目录中。这是主文件。打开它,你会看到几个包源。

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
    <add key="Microsoft Visual Studio Offline Packages" value="C:\Program Files (x86)\Microsoft SDKs\NuGetPackages\" />
  </packageSources>
</configuration>

前面的文件清单显示了两个来源。第一个指向 NuGet。org ,这是世界上最大的 NuGet 包存储库。第二个在本地驱动器上,由 Visual Studio 用作包的缓存。

需要注意的重要一点是,NuGet.Config文件默认为附加。要添加额外的源而不改变整个系统的列表,您可以添加额外的NuGet.Config文件。每个文件对它所在的目录以及任何子目录都有效。在解决方案目录中添加一个名为NuGet.Config的新文件,并将内容更新为:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <packageSources>
        <add key="local-packages" value=".\CarLibrary\Publish" />
    </packageSources>
</configuration>

您还可以通过将<clear/>添加到<packageSources>节点来重置包列表,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="local-packages" value=".\CarLibrary\Publish" />
    <add key="NuGet" value="https://api.nuget.org/v3/index.json" />
  </packageSources>
</configuration>

Note

如果您使用的是 Visual Studio,您必须重新启动 IDE,更新后的nuget.config设置才会生效。

从 CSharpCarClient 和 VisualBasicCarClient 项目中移除项目引用,然后像这样添加包引用(从解决方案目录中):

dotnet add CSharpCarClient package CarLibrary
dotnet add VisualBasicCarClient package CarLibrary

一旦设置了引用,构建解决方案并查看目标目录(bin\Debug\new5.0)中的输出,您将在目录中看到CarLibrary.dll,而不是CarLibrary.nupkg文件。这是因为。NET Core 将内容解包并添加到作为直接引用包含的程序集中。

现在,将其中一个客户端设置为启动项目并运行应用,它的工作方式与之前完全一样。

接下来,将 CarLibrary 的版本号更新为 1.0.0.4,并重新打包。在Publish目录中,现在有两个 CarLibrary NuGet 包。如果您重新运行add package命令,项目将被更新以使用新版本。如果旧版本是首选,那么add package命令允许为特定的包添加版本号。

发布控制台应用(已更新。净 5)

现在您已经有了 C# CarClient 应用(及其相关的 CarLibrary 程序集),如何将它提供给用户呢?打包应用及其相关依赖项被称为发布。出版。NET Framework 应用要求在目标计算机上安装 Framework,而。NET 核心应用也可以以类似的方式发布,称为依赖于框架的部署。然而,。NET 核心应用也可以发布为一个自包含应用,这并不需要。NET 核心到底要不要装!

将应用发布为独立的应用时,必须指定目标运行时标识符。运行时标识符用于为特定的操作系统打包应用。有关可用运行时标识符的完整列表,请参见。 https://docs.microsoft.com/en-us/dotnet/core/rid-catalog 的网芯 RID 目录。

Note

发布 ASP.NET 核心应用是一个更复杂的过程,将在本书后面介绍。

发布依赖框架的应用

依赖于框架的部署是dotnet publish命令的默认模式。要打包您的应用和所需的文件,您只需使用 CLI 执行以下命令:

dotnet publish

Note

publish命令使用项目的默认配置,通常是 debug。

这将把您的应用及其支持文件(总共 16 个文件)放到bin\Debug\net5.0\publish目录中。检查添加到该目录的文件,您会看到两个包含所有应用代码的*.dll文件(CarLibrary.dllCSharpCarClient.dll)。提醒一下,CSharpCarClient.exe文件是dotnet.exe的打包版本,被配置为启动CSharpCarClient.dll。目录中的附加文件是。不属于。NET 核心运行时。

要创建一个发布版本(将放在bin\release\net5.0\publish目录中),输入以下命令:

dotnet publish -c release

发布独立的应用

与依赖于框架的部署一样,自包含部署包括所有应用代码和引用的程序集,但也包括。应用所需的. NET 核心运行时文件。要将您的应用发布为自包含部署,请使用以下 CLI 命令(选择名为selfcontained的文件夹作为输出目标):

dotnet publish  -r win-x64 -c release -o selfcontained --self-contained true

Note

当创建自包含部署时,运行时标识符是必需的,因此发布过程知道应用代码要包含哪些运行时文件。

这也将您的应用及其支持文件(总共 235 个文件)放入到selfcontained目录中。如果将这些文件复制到另一台 64 位 Windows 计算机上,即使。没有安装. NET 5 运行时。

将独立的应用作为单个文件发布

在大多数情况下,部署 235 个文件(对于打印几行文本的应用)可能不是将应用展示给用户的最有效方式。幸运的是。NET 5 极大地提高了将应用和跨平台运行时文件发布到单个文件中的能力。唯一不包括的文件是必须存在于单个 EXE 之外的本地库。

以下命令为 64 位 Windows 操作系统创建一个单文件、自包含的部署包,并将生成的文件放在名为singlefile的文件夹中。

dotnet publish -r win-x64 -c release -o singlefile --self-contained true -p:PublishSingleFile=true

当您检查创建的文件时,您会发现一个可执行文件(CSharpCarClient.exe)、一个调试文件(CSharpCarClient.pdb)和四个特定于操作的 dll。之前的发布过程产生了 235 个文件,而CSharpCarClient.exe的单个文件版本达到了 54 MB!创建单一文件发布会将 235 个文件打包成一个文件。文件数量减少的补偿是以文件大小为代价的。

最后要注意的是,本地库可以与单个文件打包在一起。将CSharpCarClient.csproj文件更新为以下内容:

<Project Sdk="Microsoft.NET.Sdk">
  <ItemGroup>
    <PackageReference Include="CarLibrary" Version="1.0.0.3" />
  </ItemGroup>
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
  </PropertyGroup>
</Project>

如果再次运行相同的命令,输出确实是一个文件。然而,这只是一种传输机制。执行应用时,本地文件将被提取到目标机器上的临时位置。

怎么会。NET Core 定位程序集

到目前为止,在本书中,您构建的所有程序集都是直接相关的(除了您刚刚完成的 NuGet 示例)。您添加了项目引用或项目间的直接引用。在这些情况下(以及 NuGet 示例),依赖程序集被直接复制到客户端应用的目标目录中。定位依赖程序集不成问题,因为它们就在需要它们的应用旁边的磁盘上。

但是。NET 核心框架?这些是如何定位的?以前版本的。NET 将框架文件安装到全局程序集缓存(GAC)中。NET 应用知道如何定位框架文件。

但是,GAC 阻止了中的并行功能。NET 核心,所以没有运行时和框架文件的单一存储库。相反,组成框架的文件一起安装在C:\Program Files\dotnet(在 Windows 上),由版本分开。基于应用的版本(如在.csproj文件中指定的),从指定版本的目录中为应用加载必要的运行时和框架文件。

具体来说,当一个版本的运行时启动时,运行时主机提供一组探测路径,它将使用这些路径来查找应用的依赖项。有五种探测属性(每种都是可选的),如表 16-1 所列。

表 16-1。

应用探测属性

|

[计]选项

|

生命的意义

TRUSTED_PLATFORM_ASSEMBLIES 平台和应用程序集文件路径列表
PLATFORM_RESOURCE_ROOTS 搜索附属资源程序集的目录路径列表
NATIVE_DLL_SEARCH_DIRECTORIES 用于搜索非托管(本机)库的目录路径列表
APP_PATHS 用于搜索托管程序集的目录路径列表
APP_NI_PATHS 用于搜索托管程序集的本机映像的目录路径列表

若要查看这些的默认路径,请创建一个新的。NET 核心控制台应用命名为 FunWithProbingPaths。将顶级语句更新为以下内容:

using System;
using System.Linq;

Console.WriteLine("*** Fun with Probing Paths ***");
Console.WriteLine($"TRUSTED_PLATFORM_ASSEMBLIES: ");
//Use ':' on non-Windows platforms
var list = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")
              .ToString().Split(';');
foreach (var dir in list)
{
  Console.WriteLine(dir);
}
Console.WriteLine();
Console.WriteLine($"PLATFORM_RESOURCE_ROOTS: {AppContext.GetData ("PLATFORM_RESOURCE_ROOTS")}");
Console.WriteLine();
Console.WriteLine($"NATIVE_DLL_SEARCH_DIRECTORIES: {AppContext.GetData ("NATIVE_DLL_SEARCH_DIRECTORIES")}");
Console.WriteLine();
Console.WriteLine($"APP_PATHS: {AppContext.GetData("APP_PATHS")}");
Console.WriteLine();
Console.WriteLine($"APP_NI_PATHS: {AppContext.GetData("APP_NI_PATHS")}");
Console.WriteLine();
Console.ReadLine();

当你运行这个应用时,你会看到大部分的值来自于TRUSTED_PLATFORM_ASSEMBLIES变量。除了在目标目录中为这个项目创建的程序集之外,您还会看到当前运行时目录中的基类库列表,C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.0(您的版本号可能不同)。

应用直接引用的每个文件以及应用所需的任何运行时文件都会添加到列表中。运行时库的列表由一个或多个用。NET 核心运行时。在 SDK(用于构建软件)和运行时(用于运行软件)的安装目录中有几个。在我们的简单例子中,唯一使用的文件是Microsoft.NETCore.App.deps.json

随着应用复杂性的增加,TRUSTED_PLATFORM_ASSEMBLIES中的文件列表也会增加。例如,如果您添加一个对Microsoft.EntityFrameworkCore包的引用,那么所需程序集的列表就会增加。为了演示这一点,在包管理器控制台中输入以下命令(与*.csproj文件在同一个目录中):

dotnet add package Microsoft.EntityFrameworkCore

添加完包后,重新运行应用,注意又列出了多少文件。即使您只添加了一个新的引用,Microsoft.EntityFrameworkCore包也有它的依赖项,这些依赖项被添加到可信文件列表中。

摘要

本章考察了。NET 核心类库(又名。NET *.dll s)。如你所见,类库是。NET Core 二进制文件,其中包含的逻辑可以在各种项目中重用。

您了解了将类型划分为。NET 核心命名空间以及。NET 标准,从应用配置开始,深入研究类库的组成。接下来你学习了如何出版。NET 核心控制台应用。最后,您学习了如何使用 NuGet 打包您的应用。**

十七、类型反射、延迟绑定和基于属性的编程

如第十六章所示,组件是部署的基本单位。净核心宇宙。使用 Visual Studio 的集成对象浏览器(以及许多其他 ide),您可以检查项目引用的程序集集合中的类型。此外,像ildasm.exe这样的外部工具允许您查看给定的底层 CIL 代码、类型元数据和程序集清单。网芯二进制。除了设计时对。NET 核心程序集,您也能够通过编程方式使用System.Reflection名称空间获得相同的信息。为此,本章的首要任务是界定反思的作用和必要性。NET 核心元数据。

*本章的剩余部分研究了几个密切相关的主题,它们依赖于反射服务。例如,您将了解. NET 核心客户端如何使用动态加载和延迟绑定来激活它在编译时不了解的类型。您还将了解如何将自定义元数据插入到您的。NET 核心程序集使用系统提供的和自定义的属性。为了将所有这些(看似深奥的)主题放在适当的位置,本章最后演示了如何构建几个可以插入可扩展控制台应用的“管理单元对象”。

类型元数据的必要性

使用元数据完整描述类型(类、接口、结构、枚举和委托)的能力是。NET 核心平台。很多。NET 核心技术,如对象序列化,需要能够在运行时发现类型的格式。此外,跨语言的互操作性、众多的编译器服务和 IDE 的智能感知能力都依赖于对类型的具体描述。

回想一下,ildasm.exe实用程序允许您查看程序集的类型元数据。在生成的CarLibrary.il文件中(来自第章第十六部分,导航到METAINFO部分查看所有卡莉图库的元数据。这里有一小段:

// ==== M E T A I N F O ===

// ===========================================================
// ScopeName : CarLibrary.dll
// MVID      : {598BC2B8-19E9-46EF-B8DA-672A9E99B603}
// ===========================================================
// Global functions
// -------------------------------------------------------
//
// Global fields
// -------------------------------------------------------
//
// Global MemberRefs
// -------------------------------------------------------
//
// TypeDef #1
// -------------------------------------------------------
//   TypDefName: CarLibrary.Car
//   Flags     : [Public] [AutoLayout] [Class] [Abstract] [AnsiClass] [BeforeFieldInit]
//   Extends   : [TypeRef] System.Object
//   Field #1
//   -------------------------------------------------------
//     Field Name: value__
//     Flags     : [Private]
//     CallCnvntn: [FIELD]
//     Field type:  String
//

如您所见。NET 核心类型元数据很冗长(实际的二进制格式要简洁得多)。事实上,如果我要列出代表CarLibrary.dll程序集的整个元数据描述,它将跨越几页。考虑到这种行为是对纸张的严重浪费,让我们来看一下CarLibrary.dll程序集的一些关键元数据描述。

Note

不要太在意每一段。NET 核心元数据。更重要的是。NET Core 元数据非常具有描述性,列出了在给定代码库中找到的每个内部定义(和外部引用)的类型。

查看 EngineStateEnum 枚举的(部分)元数据

当前程序集中定义的每个类型都使用一个TypeDef #n标记进行记录(其中TypeDef类型定义的缩写)。如果所描述的类型使用在单独的。NET 核心程序集,引用的类型使用一个TypeRef #n标记来记录(其中TypeRef类型引用的缩写)。TypeRef标记是一个指针(如果你愿意的话),指向外部程序集中被引用类型的完整元数据定义。简单地说,。NET Core 元数据是一组清楚地标记所有类型定义(TypeDef s)和引用类型(TypeRef s)的表,所有这些都可以使用ildasm.exe来检查。

CarLibrary.dll而言,一个TypeDefCarLibrary.EngineStateEnum枚举的元数据描述(你的数字可能不同;TypeDef编号基于 C# 编译器处理文件的顺序)。

// TypeDef #2
// -------------------------------------------------------
//   TypDefName: CarLibrary.EngineStateEnum
//   Flags     : [Public] [AutoLayout] [Class] [Sealed] [AnsiClass]
//   Extends   : [TypeRef] System.Enum
//   Field #1
//   -------------------------------------------------------
//     Field Name: value__
//     Flags     : [Public] [SpecialName] [RTSpecialName]
//     CallCnvntn: [FIELD]
//     Field type:  I4
//
//   Field #2
//   -------------------------------------------------------
//     Field Name: EngineAlive
//     Flags     : [Public] [Static] [Literal] [HasDefault]
//     DefltValue: (I4) 0
//     CallCnvntn: [FIELD]
//     Field type:  ValueClass CarLibrary.EngineStateEnum
//
...

这里,TypDefName标记用于建立给定类型的名称,在本例中是自定义的CarLibrary.EngineStateEnum枚举。Extends元数据标记用于记录给定。NET 核心类型(在本例中是引用类型,System.Enum)。枚举的每个字段都用Field #n标记。为了简洁起见,我只简单地列出了部分元数据。

Note

虽然看起来像是一个错别字,TypDefName并没有人们所期望的“e”。

查看汽车类型的(部分)元数据

下面是Car类的部分转储,说明了以下内容:

  • 如何定义字段?网络核心元数据

  • 方法是如何通过?网络核心元数据

  • 中如何表示自动属性.NETCore 元数据

// TypeDef #1
// -------------------------------------------------------
//   TypDefName: CarLibrary.Car
//   Flags     : [Public] [AutoLayout] [Class] [Abstract] [AnsiClass] [BeforeFieldInit]
//   Extends   : [TypeRef] System.Object
//   Field #1
//   -------------------------------------------------------
//     Field Name: <PetName>k__BackingField
//     Flags     : [Private]
//     CallCnvntn: [FIELD]
//     Field type:  String
...

  Method #1
-------------------------------------------------------
    MethodName: get_PetName
    Flags      : [Public] [HideBySig] [ReuseSlot] [SpecialName]
    RVA        : 0x000020d0
    ImplFlags  : [IL] [Managed]
    CallCnvntn: [DEFAULT]
    hasThis
    ReturnType: String
    No arguments.

...

//   Method #2
//   -------------------------------------------------------
//     MethodName: set_PetName
//     Flags     : [Public] [HideBySig] [ReuseSlot] [SpecialName]
//     RVA       : 0x00002058
//     ImplFlags : [IL] [Managed]
//     CallCnvntn: [DEFAULT]
//     hasThis
//     ReturnType: Void
//     1 Arguments
//       Argument #1:  String
//     1 Parameters
//       (1) ParamToken : Name : value flags: [none]
...

//   Property #1
//   -------------------------------------------------------
//     Prop.Name : PetName
//     Flags     : [none]
//     CallCnvntn: [PROPERTY]
//     hasThis
//     ReturnType: String
//     No arguments.
//     DefltValue:
//     Setter    : set_PetName
//     Getter    : get_PetName
//     0 Others
...

首先,请注意,Car类元数据标记了该类型的基类(System.Object),并包括描述该类型如何构造的各种标志(例如,[Public][Abstract]等等)。方法(如Car的构造函数)由它们的参数、返回值和名称来描述。

请注意自动属性是如何产生编译器生成的私有支持字段(名为<PetName>k__BackingField)和两个编译器生成的方法(在读写属性的情况下)的,在本例中,这两个方法名为get_PetName()set_PetName()。最后,实际属性被映射到内部的get / set方法。NET Core 元数据Getter / Setter令牌。

检查 TypeRef

回想一下,程序集的元数据将不仅描述内部类型的集合(CarEngineStateEnum等)。)以及内部类型引用的任何外部类型。例如,假设CarLibrary.dll已经定义了两个枚举,您可以为System.Enum类型找到一个TypeRef块,如下所示:

// TypeRef #19
// -------------------------------------------------------
// Token:             0x01000013
// ResolutionScope:   0x23000001
// TypeRefName:       System.Enum

记录定义程序集

CarLibrary.il文件还允许您查看。使用Assembly标记描述程序集本身的. NET 核心元数据。以下是CarLibrary.dll货单的部分转储:

// Assembly
// -------------------------------------------------------
//   Token: 0x20000001
//   Name : CarLibrary
//   Public Key    :
//   Hash Algorithm : 0x00008004
//   Version: 1.0.0.1
//   Major Version: 0x00000001
//   Minor Version: 0x00000000
//   Build Number: 0x00000000
//   Revision Number: 0x00000001
//   Locale: <null>
//   Flags : [none] (00000000)

记录引用的程序集

除了Assembly令牌和一组TypeDefTypeRef块之外。NET Core 元数据还利用AssemblyRef #n标记来记录每个外部程序集。鉴于每个人。NET Core 程序集引用了System.Runtime基类库程序集,您为System.Runtime程序集找到了一个AssemblyRef,如下面的代码所示:

// AssemblyRef #1 (23000001)
// -------------------------------------------------------
//   Token: 0x23000001
//   Public Key or Token: b0 3f 5f 7f 11 d5 0a 3a
//   Name: System.Runtime
//   Version: 5.0.0.0
//   Major Version: 0x00000005
//   Minor Version: 0x00000000
//   Build Number: 0x00000000
//   Revision Number: 0x00000000
//   Locale: <null>
//   HashValue Blob:
//   Flags: [none] (00000000) 

记录字符串文字

最后一个有趣的地方是。NET Core 元数据是这样一个事实,即代码库中的每个字符串都记录在User Strings标记下。

// User Strings
// -------------------------------------------------------
// 70000001 : (23) L"CarLibrary Version 2.0!"
// 70000031 : (13) L"Quiet time..."
// 7000004d : (11) L"Jamming {0}"
// 70000065 : (32) L"Eek! Your engine block exploded!"
// 700000a7 : (34) L"Ramming speed! Faster is better..."

Note

如最后一个元数据列表所示,请始终注意,所有字符串都清楚地记录在程序集元数据中。如果您使用字符串来表示密码、信用卡号或其他敏感信息,这可能会带来巨大的安全后果。

您脑海中的下一个问题可能是(在最好的情况下)“我如何在我的应用中利用这些信息?”或者(在最坏的情况下)“我为什么要关心元数据?”为了回应这两种观点,请允许我介绍。NET 核心反射服务。请注意,在本章结束之前,接下来几页中介绍的主题的有用性可能有点让人摸不着头脑。所以,坚持住。

Note

您还会发现一些由METAINFO部分显示的CustomAttribute标记,它记录了代码库中应用的属性。你将会了解到。NET 核心属性。

理解反射

在。NET 核心宇宙,反射是运行时类型发现的过程。使用反射服务,您可以使用友好的对象模型以编程方式获得由ildasm.exe生成的相同元数据信息。例如,通过反射,您可以获得包含在给定的*.dll*.exe程序集内的所有类型的列表,包括由给定类型定义的方法、字段、属性和事件。您还可以动态地发现给定类型支持的接口集、方法的参数以及其他相关细节(基类、命名空间信息、清单数据等)。).

像任何名称空间一样,System.Reflection(在System.Runtime.dll中定义)包含几个相关的类型。表 17-1 列出了一些你应该熟悉的核心项目。

表 17-1。

系统成员的抽样。反射名称空间

|

类型

|

生命的意义

Assembly 此抽象类包含允许您加载、调查和操作程序集的成员。
AssemblyName 这个类允许您发现程序集标识背后的许多细节(版本信息、区域性信息等)。).
EventInfo 这个抽象类保存给定事件的信息。
FieldInfo 这个抽象类保存给定字段的信息。
MemberInfo 这是一个抽象基类,定义了EventInfoFieldInfoMethodInfoPropertyInfo类型的通用行为。
MethodInfo 这个抽象类包含给定方法的信息。
Module 这个抽象类允许您访问多文件程序集中的给定模块。
ParameterInfo 这个类保存给定参数的信息。
PropertyInfo 这个抽象类保存给定属性的信息。

了解如何利用System.Reflection名称空间以编程方式读取。NET 核心元数据,您需要首先接受System.Type类。

系统。类型类别

System.Type类定义了可用于检查类型元数据的成员,其中许多成员从System.Reflection名称空间返回类型。例如,Type.GetMethods()返回一组MethodInfo对象,Type.GetFields()返回一组FieldInfo对象,依此类推。System.Type曝光的全套成员相当膨胀;然而,表 17-2 提供了由System.Type支持的成员的部分快照(参见。NET 核心文档以了解全部详细信息)。

表 17-2。

选择系统的成员。类型

|

成员

|

生命的意义

IsAbstract``IsArray``IsClass``IsCOMObject``IsEnum``IsGenericTypeDefinition``IsGenericParameter``IsInterface``IsPrimitive``IsNestedPrivate``IsNestedPublic``IsSealed``IsValueType 这些属性(以及其他属性)允许您发现您所引用的Type的一些基本特征(例如,如果它是一个抽象实体、一个数组、一个嵌套类等等。).
GetConstructors()``GetEvents()``GetFields()``GetInterfaces()``GetMembers()``GetMethods()``GetNestedTypes()``GetProperties() 这些方法(以及其他方法)允许您获得一个表示项目(接口、方法、属性等)的数组。)你感兴趣。每个方法返回一个相关的数组(例如,GetFields()返回一个FieldInfo数组,GetMethods()返回一个MethodInfo数组,等等)。).请注意,这些方法都有单数形式(例如,GetMethod()GetProperty()等)。)允许您按名称检索特定的项,而不是所有相关项的数组。
FindMembers() 该方法根据搜索条件返回一个MemberInfo数组。
GetType() 这个静态方法返回一个给定字符串名称的Type实例。
InvokeMember() 该方法允许给定项目的“延迟绑定”。在本章的后面你会学到延迟绑定。

使用系统获取类型引用。Object.GetType()

您可以通过多种方式获得Type类的实例。然而,有一件事你不能做,那就是使用new关键字直接创建一个Type对象,因为Type是一个抽象类。关于您的第一个选择,回想一下System.Object定义了一个名为GetType()的方法,它返回一个代表当前对象元数据的Type类的实例。

// Obtain type information using a SportsCar instance.
SportsCar sc = new SportsCar();
Type t = sc.GetType();

显然,只有当您知道要反射的类型(在本例中为SportsCar)的编译时知识,并且当前在内存中有该类型的实例时,这种方法才有效。考虑到这个限制,像ildasm.exe这样的工具不通过直接调用每种类型的System.Object.GetType()来获取类型信息应该是有意义的,因为ildasm.exe不是针对你的定制程序集编译的。

使用 typeof()获取类型引用

获取类型信息的下一种方法是使用 C# typeof操作符,如下所示:

// Get the type using typeof.
Type t = typeof(SportsCar);

System.Object.GetType()不同,typeof操作符非常有用,因为您不需要首先创建一个对象实例来提取类型信息。然而,你的代码库必须仍然有你感兴趣的类型的编译时知识,因为typeof期望类型的强类型名称。

使用系统获取类型引用。Type.GetType()

为了以更灵活的方式获得类型信息,您可以调用System.Type类的静态GetType()成员,并指定您感兴趣的类型的完全限定字符串名称。使用这种方法,你不需要而不是知道你从中提取元数据的类型,因为Type.GetType()取了一个无所不在的System.String的实例。

Note

当我说您在调用Type.GetType()时不需要编译时知识时,我指的是这个方法可以接受任何字符串值(而不是强类型变量)。当然,您仍然需要知道“stringified”格式的类型名!

Type.GetType()方法已被重载,允许您指定两个布尔参数,其中一个控制如果找不到类型是否应该抛出异常,另一个确定字符串的大小写。为了说明这一点,请思考以下几点:

// Obtain type information using the static Type.GetType() method
// (don't throw an exception if SportsCar cannot be found and ignore case).
Type t = Type.GetType("CarLibrary.SportsCar", false, true);

在前面的例子中,请注意您传递到GetType()中的字符串没有提到包含该类型的程序集。在这种情况下,假设该类型是在当前执行的程序集中定义的。但是,当您想要获取外部程序集中某个类型的元数据时,字符串参数的格式是使用该类型的完全限定名,后跟一个逗号,再后跟包含该类型的程序集的友好名称(没有任何版本信息的程序集名称),如下所示:

// Obtain type information for a type within an external assembly.
Type t = Type.GetType("CarLibrary.SportsCar, CarLibrary");

同样,要知道传入Type.GetType()的字符串可能会指定一个加号(+)来表示一个嵌套类型。假设您想要获取嵌套在名为JamesBondCar的类中的枚举(SpyOptions)的类型信息。为此,您应该编写以下代码:

// Obtain type information for a nested enumeration
// within the current assembly.
Type t = Type.GetType("CarLibrary.JamesBondCar+SpyOptions");

构建自定义元数据查看器

为了说明反射的基本过程(以及System.Type的用处),让我们创建一个名为 MyTypeViewer 的控制台应用项目。这个程序将显示System.Runtime.dll中任何类型的方法、属性、字段和支持的接口的详细信息(除了一些其他感兴趣的点)。NET 核心应用自动访问此核心框架类库)或 MyTypeViewer 本身内的类型。一旦创建了应用,一定要导入SystemSystem.ReflectionSystem.Linq名称空间。

// Need to import this namespace to do any reflection!
using System;
using System.Linq;
using System.Reflection;

反思方法

几个静态方法将被添加到Program类中,每个方法接受一个System.Type参数并返回void。首先是ListMethods(),它(正如您可能猜到的那样)打印由传入类型定义的每个方法的名称。注意Type.GetMethods()如何返回一个System.Reflection.MethodInfo对象的数组,可以用一个标准的foreach循环来枚举,如下所示:

// Display method names of type.
static void ListMethods(Type t)
{
  Console.WriteLine("***** Methods *****");
  MethodInfo[] mi = t.GetMethods();
  foreach(MethodInfo m in mi)
  {
    Console.WriteLine("->{0}", m.Name);
  }
  Console.WriteLine();
}

这里,您只是使用MethodInfo.Name属性打印方法的名称。正如您可能猜到的,MethodInfo有许多额外的成员,允许您确定方法是静态的、虚拟的、泛型的还是抽象的。同样,MethodInfo类型允许您获得方法的返回值和参数集。您将很快完善ListMethods()的实现。

如果愿意,还可以构建一个合适的 LINQ 查询来枚举每个方法的名称。回想一下第十三章,对象的 LINQ 允许你构建强类型查询,这些查询可以应用于内存中的对象集合。一个好的经验法则是,无论何时发现循环或决策编程逻辑块,都可以利用相关的 LINQ 查询。例如,您可以用 LINQ 重写前面的方法,如下所示:

using System.Linq;
static void ListMethods(Type t)
{
  Console.WriteLine("***** Methods *****");
  var methodNames = from n in t.GetMethods() select n.Name;
  foreach (var name in methodNames)
  {
    Console.WriteLine("->{0}", name);
  }
  Console.WriteLine();
}

反思字段和属性

ListFields()的实现也差不多。唯一值得注意的区别是对Type.GetFields()的调用和由此产生的FieldInfo数组。同样,为了简单起见,使用 LINQ 查询只打印出每个字段的名称。

// Display field names of type.
static void ListFields(Type t)
{
  Console.WriteLine("***** Fields *****");
  var fieldNames = from f in t.GetFields() select f.Name;
  foreach (var name in fieldNames)
  {
    Console.WriteLine("->{0}", name);
  }
  Console.WriteLine();
}

显示类型属性的逻辑是相似的。

// Display property names of type.
static void ListProps(Type t)
{
  Console.WriteLine("***** Properties *****");
  var propNames = from p in t.GetProperties() select p.Name;
  foreach (var name in propNames)
  {
    Console.WriteLine("->{0}", name);
  }
  Console.WriteLine();
}

反思实现的接口

接下来,您将编写一个名为ListInterfaces()的方法,该方法将打印传入类型支持的任何接口的名称。这里唯一有趣的一点是对GetInterfaces()的调用返回了一个System.Type的数组!鉴于接口确实是类型,这应该是有意义的。

// Display implemented interfaces.
static void ListInterfaces(Type t)
{
  Console.WriteLine("***** Interfaces *****");
  var ifaces = from i in t.GetInterfaces() select i;
  foreach(Type i in ifaces)
  {
    Console.WriteLine("->{0}", i.Name);
  }
}

Note

要知道大多数的System.Type ( GetMethods()GetInterfaces()等的“get”方法。)已被重载,以允许您从BindingFlags枚举中指定值。这提供了对应该搜索什么的更高级别的控制(例如,仅静态成员、仅公共成员、包括私有成员等)。).有关详细信息,请参考文档。

展示各种零碎的东西

最后但同样重要的是,您有一个最终的 helper 方法,它将简单地显示各种统计信息(指示类型是否是泛型、基类是什么、类型是否是密封的,等等)。)关于传入类型。

// Just for good measure.
static void ListVariousStats(Type t)
{
  Console.WriteLine("***** Various Statistics *****");
  Console.WriteLine("Base class is: {0}", t.BaseType);
  Console.WriteLine("Is type abstract? {0}", t.IsAbstract);
  Console.WriteLine("Is type sealed? {0}", t.IsSealed);
  Console.WriteLine("Is type generic? {0}", t.IsGenericTypeDefinition);
  Console.WriteLine("Is type a class type? {0}", t.IsClass);
  Console.WriteLine();
}

添加顶级语句

Program.cs文件的顶层语句提示用户输入类型的全限定名。一旦获得这个字符串数据,就将它传递给Type.GetType()方法,并将提取的System.Type发送给每个助手方法。这个过程一直重复,直到用户按下 Q 来终止应用。

Console.WriteLine("***** Welcome to MyTypeViewer *****");
string typeName = "";

do
{
  Console.WriteLine("\nEnter a type name to evaluate");
  Console.Write("or enter Q to quit: ");

  // Get name of type.
  typeName = Console.ReadLine();

  // Does user want to quit?
  if (typeName.Equals("Q",StringComparison.OrdinalIgnoreCase))
  {
    break;
  }

  // Try to display type.
  try
  {
    Type t = Type.GetType(typeName);
    Console.WriteLine("");
    ListVariousStats(t);
    ListFields(t);
    ListProps(t);
    ListMethods(t);
    ListInterfaces(t);
  }
  catch
  {
    Console.WriteLine("Sorry, can't find type");
  }
} while (true);

至此,MyTypeViewer.exe准备试驾了。例如,运行您的应用并输入以下完全限定的名称(注意Type.GetType()需要区分大小写的字符串名称):

  • System.Int32

  • System.Collections.ArrayList

  • System.Threading.Thread

  • System.Void

  • System.IO.BinaryWriter

  • System.Math

  • MyTypeViewer.Program

例如,下面是指定System.Math时的部分输出:

***** Welcome to MyTypeViewer *****
Enter a type name to evaluate
or enter Q to quit: System.Math

***** Various Statistics *****
Base class is: System.Object
Is type abstract? True
Is type sealed? True
Is type generic? False
Is type a class type? True

***** Fields *****
->PI
->E

***** Properties *****

***** Methods *****
->Acos
->Asin
->Atan
->Atan2
->Ceiling
->Cos

...

反思静态类型

如果在前面的方法中输入了System.Console,那么在第一个帮助器方法中将会抛出一个异常,因为t的值将会是 null。不能使用Type.GetType(typeName)方法加载静态类型。相反,你必须使用另一种机制,来自System.Typetypeof函数。更新程序以处理System.Console的特殊情况,如下所示:

Type t = Type.GetType(typeName);
if (t == null && typeName.Equals("System.Console",
         StringComparison.OrdinalIgnoreCase))
{
  t = typeof(System.Console);
}

思考泛型类型

当您调用Type.GetType()来获取泛型类型的元数据描述时,您必须使用一种特殊的语法,包括一个“反勾”字符(```cs),后跟一个表示该类型支持的类型参数数量的数值。例如,如果您想打印出System.Collections.Generic.List<T>的元数据描述,您需要将以下字符串传递到您的应用中:

System.Collections.Generic.List`1

```cs

这里,您使用的是`1`的数值,因为`List<T>`只有一个类型参数。然而,如果你想反映`Dictionary<TKey, TValue>`,提供值`2`,像这样:

System.Collections.Generic.Dictionary`2


### 反映方法参数和返回值

到目前为止,一切顺利!接下来,我们将对当前应用做一个小小的增强。具体来说,您将更新`ListMethods()` helper 函数,不仅列出给定方法的名称,还列出返回类型和传入参数类型。类型为这些任务提供了`ReturnType`属性和`GetParameters()`方法。在下面修改过的代码中,请注意,您正在使用嵌套的`foreach`循环(没有使用 LINQ)构建一个包含每个参数的类型和名称的字符串:

static void ListMethods(Type t)
{
Console.WriteLine("***** Methods *****");
MethodInfo[] mi = t.GetMethods();
foreach (MethodInfo m in mi)
{
// Get return type.
string retVal = m.ReturnType.FullName;
string paramInfo = "( ";
// Get params.
foreach (ParameterInfo pi in m.GetParameters())
{
paramInfo += string.Format("{0} {1} ", pi.ParameterType, pi.Name);
}
paramInfo += " )";

// Now display the basic method sig.
Console.WriteLine("->{0} {1} {2}", retVal, m.Name, paramInfo);

}
Console.WriteLine();
}


如果您现在运行这个更新的应用,您会发现给定类型的方法更加详细。如果您输入您的好朋友`System.Object`作为程序的输入,将显示以下方法:

***** Methods *****
→System.Type GetType ( )
→System.String ToString ( )
→System.Boolean Equals ( System.Object obj )
→System.Boolean Equals ( System.Object objA System.Object objB )
→System.Boolean ReferenceEquals ( System.Object objA System.Object objB )
→System.Int32 GetHashCode ( )


`ListMethods()`的当前实现很有帮助,因为您可以使用`System.Reflection`对象模型直接研究每个参数和方法返回类型。作为一个极端的捷径,请注意所有的`XXXInfo`类型(`MethodInfo`、`PropertyInfo`、`EventInfo`等)。)已经覆盖了`ToString()`以显示所请求项目的签名。因此,您也可以如下实现`ListMethods()`(再次使用 LINQ,您只需选择所有`MethodInfo`对象,而不仅仅是`Name`值):

static void ListMethods(Type t)
{
Console.WriteLine("***** Methods *****");
var methodNames = from n in t.GetMethods() select n;
foreach (var name in methodNames)
{
Console.WriteLine("->{0}", name);
}
Console.WriteLine();
}


有趣的东西,是吧?显然,`System.Reflection`名称空间和`System.Type`类允许你反映类型的许多其他方面,而不仅仅是`MyTypeViewer`当前显示的内容。正如您所希望的,您可以获得类型的事件,获得给定成员的任何泛型参数的列表,并收集许多其他细节。

尽管如此,此时您已经创建了一个(有点功能的)对象浏览器。这个特定示例的主要限制是,除了当前程序集(`MyTypeViewer`)或基类库中始终被引用的程序集(如`mscorlib.dll`)之外,您无法进行反射。这就引出了一个问题“我如何构建可以加载(并反射)编译时未引用的程序集的应用?”很高兴你问了。

## 动态加载程序集

有时您需要以编程方式动态加载程序集,即使清单中没有该程序集的记录。正式来说,按需加载外部程序集的行为被称为*动态加载*。

`System.Reflection`定义一个名为`Assembly`的类。使用此类,您可以动态加载程序集,以及发现有关程序集本身的属性。使用`Assembly`类型,您可以动态加载程序集,也可以加载位于任意位置的程序集。从本质上讲,`Assembly`类提供了允许您以编程方式从磁盘加载程序集的方法。

为了演示动态加载,创建一个名为 ExternalAssemblyReflector 的新控制台应用项目。您的任务是构造代码,提示输入要动态加载的程序集的名称(不含任何扩展名)。您将把`Assembly`引用传递到一个名为`DisplayTypes()`的 helper 方法中,该方法将简单地打印它包含的每个类、接口、结构、枚举和委托的名称。代码非常简单。

using System;
using System.Reflection;
using System.IO; // For FileNotFoundException definition.

Console.WriteLine("***** External Assembly Viewer *****");
string asmName = "";
Assembly asm = null;
do
{
Console.WriteLine("\nEnter an assembly to evaluate");
Console.Write("or enter Q to quit: ");
// Get name of assembly.
asmName = Console.ReadLine();
// Does user want to quit?
if (asmName.Equals("Q",StringComparison.OrdinalIgnoreCase))
{
break;
}

// Try to load assembly.
try
{
asm = Assembly.LoadFrom(asmName);
DisplayTypesInAsm(asm);
}
catch
{
Console.WriteLine("Sorry, can't find assembly.");
}
} while (true);

static void DisplayTypesInAsm(Assembly asm)
{
Console.WriteLine("\n***** Types in Assembly *****");
Console.WriteLine("->{0}", asm.FullName);
Type[] types = asm.GetTypes();
foreach (Type t in types)
{
Console.WriteLine("Type: {0}", t);
}
Console.WriteLine("");
}


如果您想通过`CarLibrary.dll`进行反射,您需要将`CarLibrary.dll`二进制文件(来自上一章)复制到 ExternalAssemblyReflector 应用的项目目录(如果使用 Visual Studio 代码)或`\bin\Debug\net5.0`(如果使用 Visual Studio)目录,以运行该程序。出现提示时,输入 **CarLibrary** (扩展名可选),输出如下:

***** External Assembly Viewer *****
Enter an assembly to evaluate
or enter Q to quit: CarLibrary

***** Types in Assembly *****
→CarLibrary, Version=1.0.0.1, Culture=neutral, PublicKeyToken=null
Type: CarLibrary.MyInternalClass
Type: CarLibrary.EngineStateEnum
Type: CarLibrary.MusicMedia
Type: CarLibrary.Car
Type: CarLibrary.MiniVan
Type: CarLibrary.SportsCar


`LoadFrom`方法也可以获取您想要查看的程序集的绝对路径(例如,`C:\MyApp\MyAsm.dll`)。使用此方法,您可以传入控制台应用项目的完整路径。因此,如果`CarLibrary.dll`位于`C:\MyCode`下,您可以输入 **C:\MyCode\CarLibrary** (注意扩展名是可选的)。

## 反思框架程序集

`Assembly.Load()`方法有几个重载。一种变体允许您指定区域性值(对于本地化程序集),以及版本号和公钥标记值(对于框架程序集)。总的来说,标识一个组件的项目集被称为*显示名*。显示名称的格式是以逗号分隔的名称-值对字符串,以程序集的友好名称开头,后跟可选的限定符(可以按任何顺序出现)。下面是要遵循的模板(可选项目出现在括号中):

Name (,Version = major.minor.build.revision) (,Culture = culture token) (,PublicKeyToken= public key token)


当你创建一个显示名时,约定`PublicKeyToken=null`表明需要绑定和匹配一个非强名称的程序集。此外,`Culture=""`表示匹配目标计算机的默认区域性。这里有一个例子:

// Load version 1.0.0.0 of CarLibrary using the default culture.
Assembly a =
Assembly.Load("CarLibrary, Version=1.0.0.0, PublicKeyToken=null, Culture=""");
// The quotes must be escaped with back slashes in C#


还要注意,`System.Reflection`名称空间提供了`AssemblyName`类型,这允许您在一个方便的对象变量中表示前面的字符串信息。通常,这个类与`System.Version`一起使用,后者是一个封装程序集版本号的面向对象包装器。一旦建立了显示名称,就可以将它传递给重载的`Assembly.Load()`方法,如下所示:

// Make use of AssemblyName to define the display name.
AssemblyName asmName;
asmName = new AssemblyName();
asmName.Name = "CarLibrary";
Version v = new Version("1.0.0.0");
asmName.Version = v;
Assembly a = Assembly.Load(asmName);


加载. NET Framework 程序集(不是。NET Core),`Assembly.Load()`参数应该指定一个`PublicKeyToken`值。和。NET Core,它不是必需的,因为强命名的使用减少了。例如,假设您有一个名为 FrameworkAssemblyViewer 的新控制台应用项目,该项目引用了 Microsoft。EntityFrameworkCore 包。提醒一下,这都可以通过。NET 5 命令行界面(CLI)。

dotnet new console -lang c# -n FrameworkAssemblyViewer -o .\FrameworkAssemblyViewer -f net5.0
dotnet sln .\Chapter17_AllProjects.sln add .\FrameworkAssemblyViewer
dotnet add .\FrameworkAssemblyViewer package Microsoft.EntityFrameworkCore -v 5.0.0


回想一下,当您引用另一个程序集时,该程序集的副本被复制到引用项目的输出目录中。使用 CLI 构建项目。

dotnet build


随着项目的创建、`EntityFrameworkCode`的引用以及项目的构建,您现在可以加载并检查它了。鉴于该程序集中的类型数量相当大,下面的应用使用简单的 LINQ 查询,仅打印出公共枚举的名称:

using System;
using System.Linq;
using System.Reflection;

Console.WriteLine("***** The Framework Assembly Reflector App *****\n");

// Load Microsoft.EntityFrameworkCore.dll
var displayName =
"Microsoft.EntityFrameworkCore, Version=5.0.0.0, Culture="", PublicKeyToken=adb9793829ddae60";
Assembly asm = Assembly.Load(displayName);
DisplayInfo(asm);
Console.WriteLine("Done!");
Console.ReadLine();

private static void DisplayInfo(Assembly a)
{
Console.WriteLine("***** Info about Assembly *****");
Console.WriteLine($"Asm Name: {a.GetName().Name}" );
Console.WriteLine($"Asm Version: {a.GetName().Version}");
Console.WriteLine($"Asm Culture:
{a.GetName().CultureInfo.DisplayName}");
Console.WriteLine("\nHere are the public enums:");

// Use a LINQ query to find the public enums.
Type[] types = a.GetTypes();
var publicEnums =
from pe in types
where pe.IsEnum && pe.IsPublic
select pe;

foreach (var pe in publicEnums)
{
Console.WriteLine(pe);
}
}


此时,您应该理解如何使用`System.Reflection`名称空间的一些核心成员在运行时发现元数据。当然,我意识到尽管有“酷的因素”,你可能不需要在你工作的地方经常构建定制的对象浏览器。但是,请记住,反射服务是许多常见编程活动的基础,包括延迟绑定。

## 了解延迟绑定

简单地说,*延迟绑定*是一种技术,在这种技术中,您可以创建给定类型的实例,并在运行时调用其成员,而无需硬编码编译时了解其存在。当您正在生成延迟绑定到外部程序集中的类型的应用时,您没有理由设置对该程序集的引用;因此,调用方的清单没有程序集的直接列表。

乍一看,并不容易看出延迟绑定的价值。的确,如果您可以“早期绑定”到一个对象(例如,添加一个程序集引用并使用 C# `new`关键字分配类型),您应该选择这样做。出于一个原因,早期绑定允许您在编译时确定错误,而不是在运行时。尽管如此,延迟绑定在您可能构建的任何可扩展应用中确实扮演着重要角色。在本章末尾的“构建可扩展的应用”一节中,您将有机会构建这样一个“可扩展的”程序在此之前,让我们检查一下`Activator`类的作用。

### 系统。活化剂类别

类是。网芯延迟绑定流程。对于下一个例子,您只对`Activator.CreateInstance()`方法感兴趣,该方法用于通过延迟绑定创建一个类型的实例。此方法已被重载多次,以提供很大的灵活性。`CreateInstance()`成员最简单的变体是接受一个有效的`Type`对象,该对象描述了您想要动态分配到内存中的实体。

创建一个名为 LateBindingApp 的新控制台应用项目,并通过 C# `using`关键字导入`System.IO`和`System.Reflection`名称空间。现在,更新`Program.cs`文件,如下所示:

using System;
using System.IO;
using System.Reflection;

// This program will load an external library,
// and create an object using late binding.
Console.WriteLine("***** Fun with Late Binding *****");
// Try to load a local copy of CarLibrary.
Assembly a = null;
try

catch(FileNotFoundException ex)
{
Console.WriteLine(ex.Message);
return;
}
if(a != null)
{
CreateUsingLateBinding(a);
}
Console.ReadLine();

static void CreateUsingLateBinding(Assembly asm)
{
try
{
// Get metadata for the Minivan type.
Type miniVan = asm.GetType("CarLibrary.MiniVan");

// Create a Minivan instance on the fly.
object obj = Activator.CreateInstance(miniVan);
Console.WriteLine("Created a {0} using late binding!", obj);

}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
}


现在,在运行这个应用之前,您需要手动将`CarLibrary.dll`的副本放入这个新应用的项目文件夹(或者如果您使用 Visual Studio,则放入`bin\Debug\net5.0`文件夹)中。

Note

这个例子不要添加对`CarLibrary.dll`的引用!延迟绑定的全部意义在于,您试图创建一个在编译时未知的对象。

注意,`Activator.CreateInstance()`方法返回一个`System.Object`,而不是一个强类型的`MiniVan`。因此,如果您在`obj`变量上应用点操作符,您将看不到`MiniVan`类的任何成员。乍一看,您可能认为可以通过显式强制转换来解决这个问题,如下所示:

// Cast to get access to the members of MiniVan?
// Nope! Compiler error!
object obj = (MiniVan)Activator.CreateInstance(minivan);


但是,因为您的程序没有添加对`CarLibrary.dll`的引用,所以您不能使用 C# `using`关键字来导入`CarLibrary`名称空间,因此,您不能在转换操作期间使用`MiniVan`类型!请记住,延迟绑定的要点是创建没有编译时知识的对象实例。考虑到这一点,如何调用存储在`System.Object`引用中的`MiniVan`对象的底层方法呢?答案当然是通过使用反射。

### 调用不带参数的方法

假设您想要调用`MiniVan`的`TurboBoost()`方法。正如您所记得的,这个方法将把引擎的状态设置为“dead ”,并显示一个信息消息框。第一步是使用`Type.GetMethod()`为`TurboBoost()`方法获取一个`MethodInfo`对象。从产生的`MethodInfo`中,您可以使用`Invoke()`调用`MiniVan.TurboBoost`。`MethodInfo.Invoke()`要求您将所有参数发送给由`MethodInfo`表示的方法。这些参数由一组`System.Object`类型表示(因为给定方法的参数可以是任意数量的各种实体)。

鉴于`TurboBoost()`不需要任何参数,可以简单地通过`null`(意思是“这个方法没有参数”)。更新您的`CreateUsingLateBinding()`方法如下:

static void CreateUsingLateBinding(Assembly asm)
{
try
{
// Get metadata for the Minivan type.
Type miniVan = asm.GetType("CarLibrary.MiniVan");

// Create the Minivan on the fly.
object obj = Activator.CreateInstance(miniVan);
Console.WriteLine($"Created a {obj} using late binding!");
// Get info for TurboBoost.
MethodInfo mi = miniVan.GetMethod("TurboBoost");

// Invoke method ('null' for no parameters).
mi.Invoke(obj, null);

}
catch(Exception ex)
{
Console.WriteLine(ex.Message);
}
}


此时,您将在控制台中看到您的发动机爆炸的消息。

### 调用带参数的方法

当您想要使用延迟绑定来调用需要参数的方法时,您应该将参数打包成一个松散类型的数组`object`。这个版本的`Car`类有一个无线电,并有以下方法:

public void TurnOnRadio(bool musicOn, MusicMediaEnum mm)
⇒ MessageBox.Show(musicOn ? $"Jamming " : "Quiet time...");


这个方法有两个参数:一个布尔值表示汽车的音乐系统应该打开还是关闭,一个枚举表示音乐播放器的类型。回想一下,该枚举的结构如下:

public enum MusicMediaEnum
{
musicCd, // 0
musicTape, // 1
musicRadio, // 2
musicMp3 // 3
}


这里有一个`Program`类的新方法,它调用`TurnOnRadio()`。请注意,您正在使用`MusicMediaEnum`枚举的底层数值来指定一个“收音机”媒体播放器。

static void InvokeMethodWithArgsUsingLateBinding(Assembly asm)
{
try
{
// First, get a metadata description of the sports car.
Type sport = asm.GetType("CarLibrary.SportsCar");

// Now, create the sports car.
object obj = Activator.CreateInstance(sport);
// Invoke TurnOnRadio() with arguments.
MethodInfo mi = sport.GetMethod("TurnOnRadio");
mi.Invoke(obj, new object[] { true, 2 });

}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}


理想情况下,此时,您可以看到反射、动态加载和延迟绑定之间的关系。可以肯定的是,反射 API 提供了许多超出这里所讨论的特性,但是如果您感兴趣的话,您应该准备好深入研究更多的细节。

同样,您可能仍然想知道*何时*您应该在您自己的应用中使用这些技术。本章的结论应该阐明这个问题;然而,下一个研究的主题是。净核心属性。

## 理解的作用.NET 属性

如本章开头所述,. NET 核心编译器的一个作用是为所有定义和引用的类型生成元数据描述。除了任何程序集中包含的标准元数据之外。NET Core platform 为程序员提供了一种使用*属性*将附加元数据嵌入到程序集中的方法。简而言之,属性只不过是可以应用于给定类型(类、接口、结构等)的代码注释。)、成员(属性、方法等。)、程序集或模块。

。NET Core 属性是扩展抽象基类`System.Attribute`的类类型。当你探索。NET 核心命名空间,您会发现许多预定义的属性,您可以在您的应用中使用。此外,您可以自由构建定制属性,通过创建一个从`Attribute`派生的新类型来进一步限定您的类型的行为。

那个。NET Core 基本类库在各种命名空间中提供属性。表 17-3 给出了一些预定义属性的快照,但是*绝对*否意味着全部。

表 17-3。

预定义属性的微小样本

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

属性

 | 

生命的意义

 |
| --- | --- |
| `[CLSCompliant]` | 强制带批注的项目符合公共语言规范(CLS)的规则。回想一下,CLS 兼容的类型保证可以在所有。NET 核心编程语言。 |
| `[DllImport]` | 允许。NET 核心代码调用任何基于非托管 C 或 C++的代码库,包括底层操作系统的 API。 |
| `[Obsolete]` | 标记不推荐使用的类型或成员。如果其他程序员试图使用这样的项目,他们将收到一个编译器警告,描述他们的方法的错误。 |

请理解,当您在代码中应用属性时,嵌入的元数据基本上是无用的,直到另一个软件明确地反映了这些信息。如果不是这样,嵌入在程序集中的元数据的格式回复将被忽略,并且完全无害。

### 属性消费者

正如您所猜测的。NET Core Framework 附带了许多实用程序,它们确实在寻找各种属性。C# 编译器(`csc.exe`)本身已经被预编程,以便在编译周期中发现各种属性的存在。例如,如果 C# 编译器遇到了`[CLSCompliant]`属性,它将自动检查属性化的项,以确保它只公开符合 CLS 的构造。作为另一个例子,如果 C# 编译器发现一个具有`[Obsolete]`属性的项,它将在 Visual Studio 错误列表窗口中显示一个编译器警告。

除了开发工具之外。NET 核心基本类库被预编程以反映特定的属性。第二十章介绍 XML 和 JSON 序列化,两者都使用属性来控制序列化过程。

最后,您可以自由地构建应用,这些应用被编程为反映您自己的自定义属性以及。NET 核心基本类库。通过这样做,您基本上能够创建一组“关键字”,这些关键字被一组特定的程序集所理解。

### 在 C# 中应用属性

为了演示在 C# 中应用属性的过程,创建一个名为 applying attributes 的新控制台应用项目,并添加对`System.Text.Json`的引用。假设您想要构建一个名为`Motorcycle`的类,它可以持久化为 JSON 格式。如果您有一个不应该导出到 JSON 的字段,您可以应用`[JsonIgnore]`属性。

public class Motorcycle
{
[JsonIgnore]
public float weightOfCurrentPassengers;
// These fields are still serializable.
public bool hasRadioSystem;
public bool hasHeadSet;
public bool hasSissyBar;
}


Note

属性适用于“下一个”项目。

此时,不要关心对象序列化的实际过程(第二十章讨论了细节)。请注意,当您想要应用一个属性时,属性的名称被夹在方括号中。

正如您可能猜到的那样,一个单一的项目可以有多个属性。假设您有一个遗留的 C# 类类型(`HorseAndBuggy`),它被认为有一个定制的 XML 名称空间。随着时间的推移,代码库已经发生了变化,该类现在被认为对于当前的开发已经过时。您可以用`[Obsolete]`属性来标记这个类,而不是从您的代码库中删除这个类定义(并冒着破坏现有软件的风险)。要将多个属性应用于单个项目,只需使用逗号分隔的列表,如下所示:

using System;
using System.Xml.Serialization;

namespace ApplyingAttributes
{
[XmlRoot(Namespace = "http://www.MyCompany.com"), Obsolete("Use another vehicle!")]
public class HorseAndBuggy
{
// ...
}
}


或者,您也可以将多个属性应用于单个项目,方法是按如下方式堆叠每个属性:

[XmlRoot(Namespace = "http://www.MyCompany.com")]
[Obsolete("Use another vehicle!")]
public class HorseAndBuggy
{
// ...
}


### C# 属性速记符号

如果你在咨询。NET 核心文档,您可能已经注意到了`[Obsolete]`属性的实际类名是`ObsoleteAttribute`,而不是`Obsolete`。作为命名约定,所有。NET Core 属性(包括您可能自己创建的自定义属性)的后缀是`Attribute`标记。然而,为了简化应用属性的过程,C# 语言不要求您键入`Attribute`后缀。考虑到这一点,下面的`HorseAndBuggy`类型的迭代与前面的相同(它只是涉及到更多的击键):

[SerializableAttribute]
[ObsoleteAttribute("Use another vehicle!")]
public class HorseAndBuggy
{
// ...
}


要知道这是 C# 提供的一种礼貌。不全是。NET 核心语言支持这种速记属性语法。

### 为属性指定构造函数参数

注意,`[Obsolete]`属性可以接受看起来像是构造函数的参数。如果您通过在代码编辑器中右键单击项目并选择 Go To Definition 菜单选项来查看`[Obsolete]`属性的正式定义,您会发现这个类确实提供了一个接收`System.String`的构造函数。

public sealed class ObsoleteAttribute : Attribute
{
public ObsoleteAttribute(string message, bool error);
public ObsoleteAttribute(string message);
public ObsoleteAttribute();
public bool IsError { get; }
public string? Message { get; }
}


请理解,当您向属性提供构造函数参数时,属性是*而不是*分配到内存中的,直到参数被另一个类型或外部工具反射。在属性级别定义的字符串数据只是作为元数据的格式回复存储在程序集中。

### 作用中的过时属性

既然`HorseAndBuggy`已经被标记为过时,如果您要分配这种类型的实例:

using System;
using ApplyingAttributes;

Console.WriteLine("Hello World!");
HorseAndBuggy mule = new HorseAndBuggy();


您会发现会发出一个编译器警告。该警告特别是 CS0618,并且该消息包括传递到属性中的信息。

‘HorseAndBuggy’ is obsolete: ‘Use another vehicle!'


Visual Studio 和 Visual Studio 代码也有助于智能感知,它通过反射获取信息。图 17-1 显示了 Visual Studio 中`Obsolete`属性的结果,图 17-2 正在使用 Visual Studio 代码。注意,两者都使用了术语*弃用的*而不是*废弃的*。

![img/340876_10_En_17_Fig2_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-csharp-zh/raw/master/docs/pro-cs9-dnet5/img/340876_10_En_17_Fig2_HTML.jpg)

图 17-2。

Visual Studio 代码中的实际属性

![img/340876_10_En_17_Fig1_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-csharp-zh/raw/master/docs/pro-cs9-dnet5/img/340876_10_En_17_Fig1_HTML.jpg)

图 17-1。

Visual Studio 中的实际属性

理想情况下,在这一点上,你应该了解以下关键点.NETCore 属性:

*   属性是从`System.Attribute`派生的类。

*   属性导致嵌入的元数据。

*   属性基本上是无用的,直到另一个代理反映出来。

*   在 C# 中,使用方括号应用属性。

接下来,让我们看看如何构建自己的定制属性和一个定制软件来反映嵌入的元数据。

## 构建自定义属性

构建定制属性的第一步是创建一个从`System.Attribute`派生的新类。与本书通篇使用的汽车主题保持一致,假设您已经创建了一个名为 AttributedCarLibrary 的新 C# 类库项目。

这个程序集将定义一些车辆,每个车辆都使用一个名为`VehicleDescriptionAttribute`的自定义属性进行描述,如下所示:

using System;
// A custom attribute.
public sealed class VehicleDescriptionAttribute :Attribute
{
public string Description { get; set; }

public VehicleDescriptionAttribute(string description)
⇒ Description = description;
public VehicleDescriptionAttribute()
}


如您所见,`VehicleDescriptionAttribute`维护了一段使用自动属性(`Description`)操作的字符串数据。除了这个类是从`System.Attribute`派生出来的这个事实之外,这个类的定义并没有什么独特之处。

Note

出于安全原因,将所有自定义属性设计为密封的被认为是. NET 核心最佳实践。事实上,Visual Studio 和 Visual Studio 代码都提供了一个名为`Attribute`的代码片段,它将在您的代码窗口中搭建一个新的`System.Attribute`派生类。您可以通过键入代码段的名称并按 Tab 键来展开任何代码段。

### 应用自定义属性

鉴于`VehicleDescriptionAttribute`是从`System.Attribute`衍生而来的,你现在可以给你的车辆添加你认为合适的注释了。出于测试目的,将以下类添加到新的类库中:

//Motorcycle.cs
namespace AttributedCarLibrary
{
// Assign description using a "named property."
[Serializable]
[VehicleDescription(Description = "My rocking Harley")]
public class Motorcycle

//HorseAndBuggy.cs
namespace AttributedCarLibrary
{
[Serializable]
[Obsolete ("Use another vehicle!")]
[VehicleDescription("The old gray mare, she ain't what she used to be...")]
public class HorseAndBuggy

}

//Winnebago.cs
namespace AttributedCarLibrary

{
[VehicleDescription("A very long, slow, but feature-rich auto")]
public class Winnebago

}


### 命名属性语法

注意到,`Motorcycle`的描述被分配了一个描述,它使用了一个新的属性语法,称为*命名属性*。在第一个`[VehicleDescription]`属性的构造函数中,使用`Description`属性设置底层字符串数据。如果这个属性被一个外部代理反射,那么这个值就被输入到`Description`属性中(只有当这个属性提供了一个可写的。净核心财产)。

相比之下,`HorseAndBuggy`和`Winnebago`类型不使用命名属性语法,只是通过自定义构造函数传递字符串数据。在任何情况下,一旦编译了`AttributedCarLibrary`程序集,您就可以使用`ildasm.exe`来查看为您的类型注入的元数据描述。例如,下面显示了对`Winnebago`类的嵌入式描述:

// CustomAttribute #1
// -------------------------------------------------------
// CustomAttribute Type: 06000005
// CustomAttributeName: AttributedCarLibrary.VehicleDescriptionAttribute :: instance void .ctor(class System.String)
// Length: 45
// Value : 01 00 28 41 20 76 65 72 79 20 6c 6f 6e 67 2c 20 > (A very long, <
// : 73 6c 6f 77 2c 20 62 75 74 20 66 65 61 74 75 72 >slow, but feature<
// : 65 2d 72 69 63 68 20 61 75 74 6f 00 00 >e-rich auto <
// ctor args: ("A very long, slow, but feature-rich auto")


### 限制属性使用

默认情况下,自定义属性可以应用于代码的任何方面(方法、类、属性等)。).因此,如果这样做有意义的话,您可以使用`VehicleDescription`来限定方法、属性或字段(等等)。

[VehicleDescription("A very long, slow, but feature-rich auto")]
public class Winnebago
{
[VehicleDescription("My rocking CD player")]
public void PlayMusic(bool On)

}


在某些情况下,这正是您需要的行为。但是,在其他时候,您可能希望构建一个只能应用于选定代码元素的自定义属性。如果您想要约束一个定制属性的范围,您将需要在您的定制属性的定义上应用`[AttributeUsage]`属性。`[AttributeUsage]`属性允许您从`AttributeTargets`枚举中提供值的任意组合(通过一个`OR`操作),如下所示:

// This enumeration defines the possible targets of an attribute.
public enum AttributeTargets
{
All, Assembly, Class, Constructor,
Delegate, Enum, Event, Field, GenericParameter,
Interface, Method, Module, Parameter,
Property, ReturnValue, Struct
}


此外,`[AttributeUsage]`还允许您有选择地设置一个命名属性(`AllowMultiple`),指定该属性是否可以在同一个项目上多次应用(默认为`false`)。同样,`[AttributeUsage]`允许您使用`Inherited`命名的属性(默认为`true`)来确定属性是否应该被派生类继承。

要确定`[VehicleDescription]`属性只能在一个类或结构上应用一次,可以按如下方式更新`VehicleDescriptionAttribute`定义:

// This time, we are using the AttributeUsage attribute
// to annotate our custom attribute.
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public sealed class VehicleDescriptionAttribute : System.Attribute


这样,如果开发人员试图在类或结构之外的任何东西上应用`[VehicleDescription]`属性,他们会收到一个编译时错误。

## 程序集级属性

也可以使用`[assembly:]`标签将属性应用于给定程序集中的所有类型。例如,假设您希望确保程序集中定义的每个公共类型的每个公共成员都符合 CLS 标准。为此,只需在任何 C# 源代码文件的顶部添加下面的程序集级属性。请注意,所有程序集或模块级属性都必须在任何命名空间范围之外列出!我建议向您的项目添加一个名为`AssemblyAttributes.cs`(不是`AssemblyInfo.cs`,因为它是自动生成的)的新文件,并将您的程序集级属性放在那里。

Note

使用单独的文件没有技术上的原因;这纯粹是为了你的代码的可支持性。将程序集属性放在一个单独的文件中可以清楚地表明您的项目使用程序集级属性以及它们的位置。

如果将程序集级或模块级属性添加到项目中,下面是一个推荐的文件布局:

// List "using" statements first.
using System;

// Now list any assembly- or module-level attributes.
// Enforce CLS compliance for all public types in this
// assembly.
[assembly: CLSCompliant(true)]


如果您现在添加了一点 CLS 规范之外的代码(比如一个无符号数据的暴露点),您将会收到一个编译器警告。

// Ulong types don't jibe with the CLS.
public class Winnebago
{
public ulong notCompliant;
}


Note

中有两个重要的变化。NET 核心。首先是`AssemblyInfo.cs`文件现在是从项目属性中自动生成的,不建议定制。第二个(也是相关的)变化是许多先前的汇编级属性(`Version`、`Company`等)。)已被替换为项目文件中的属性。

### 将项目文件用于部件属性

如第十六章与`InternalsVisibleToAttribute`所示,装配属性也可以添加到项目文件中。有一个问题,只有单字符串参数属性可以这样使用。对于可以在项目属性中的 Package 选项卡上设置的属性来说,也是如此。

Note

在撰写本文时,MSBuild GitHub repo 上正在积极讨论增加非字符串参数支持的功能。这将允许使用项目文件而不是`*.cs`文件来添加`CLSCompliant`属性。

继续设置一些属性(比如`Authors`、`Description`),方法是在 Solution Explorer 中右键单击项目,选择 properties,然后单击 Package。同样,像你在第十六章中做的那样添加`InternalsVisibleToAttribute`。您的项目文件现在看起来将如下所示:

net5.0 Philip Japikse Apress This is a simple car library with attributes <_Parameter1>CSharpCarClient

编译完项目后,导航到`\obj\Debug\net5.0`目录,并查找`AttributedCarLibrary.AssemblyInfo.cs`文件。打开它,您将看到这些属性(不幸的是,这种格式可读性不强):

using System;
using System.Reflection;

[assembly: System.Runtime.CompilerServices.InternalsVisibleToAttribute("CSharpCarClient")]
[assembly: System.Reflection.AssemblyCompanyAttribute("Philip Japikse")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyDescriptionAttribute("This is a sample car library with attributes")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("AttributedCarLibrary")]
[assembly: System.Reflection.AssemblyTitleAttribute("AttributedCarLibrary")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]


关于汇编属性的最后一句结束语是,如果您想自己管理流程,可以关闭`AssemblyInfo.cs`类的生成。

## 使用早期绑定反映属性

请记住,在另一个软件反映出它的值之前,一个属性是毫无用处的。一旦发现了给定的属性,该软件就可以采取任何必要的行动。现在,像任何应用一样,这个“软件的另一部分”可以使用早期绑定或晚期绑定来发现自定义属性的存在。如果您想利用早期绑定,您将需要客户端应用有一个正在讨论的属性的编译时定义(在本例中为`VehicleDescriptionAttribute`)。鉴于`AttributedCarLibrary`程序集已经将这个自定义属性定义为一个公共类,早期绑定是最好的选择。

为了演示反射自定义属性的过程,向解决方案添加一个名为`VehicleDescriptionAttributeReader`的新 C# 控制台应用项目。接下来,添加对`AttributedCarLibrary`项目的引用。使用 CLI,执行以下命令(每个命令必须各占一行):

dotnet new console -lang c# -n VehicleDescriptionAttributeReader -o .\VehicleDescriptionAttributeReader -f net5.0
dotnet sln .\Chapter17_AllProjects.sln add .\VehicleDescriptionAttributeReader
dotnet add VehicleDescriptionAttributeReader reference .\AttributedCarLibrary


用以下代码更新`Program.cs`文件:

using System;
using AttributedCarLibrary;

Console.WriteLine("***** Value of VehicleDescriptionAttribute *****\n");
ReflectOnAttributesUsingEarlyBinding();
Console.ReadLine();

static void ReflectOnAttributesUsingEarlyBinding()
{
// Get a Type representing the Winnebago.
Type t = typeof(Winnebago);

// Get all attributes on the Winnebago.
object[] customAtts = t.GetCustomAttributes(false);

// Print the description.
foreach (VehicleDescriptionAttribute v in customAtts)
{
Console.WriteLine("-> {0}\n", v.Description);
}
}


`Type.GetCustomAttributes()`方法返回一个对象数组,表示应用于由`Type`表示的成员的所有属性(布尔参数控制搜索是否应该沿着继承链向上扩展)。一旦获得了属性列表,迭代每个`VehicleDescriptionAttribute`类,并打印出`Description`属性获得的值。

## 使用延迟绑定反射属性

前面的例子使用早期绑定来打印出`Winnebago`类型的车辆描述数据。这是可能的,因为`VehicleDescriptionAttribute`类类型被定义为`AttributedCarLibrary`程序集中的公共成员。还可以利用动态加载和延迟绑定来反映属性。

将名为`VehicleDescriptionAttributeReaderLateBinding`的新项目添加到解决方案中,将其设置为启动项目,并将`AttributedCarLibrary.dll`复制到项目的文件夹中(如果使用 Visual Studio,则复制到`\bin\Debug\net5.0`)。现在,更新您的`Program`类,如下所示:

using System;
using System.Reflection;

Console.WriteLine("***** Value of VehicleDescriptionAttribute *****\n");
ReflectAttributesUsingLateBinding();
Console.ReadLine();

static void ReflectAttributesUsingLateBinding()
{
try
{
// Load the local copy of AttributedCarLibrary.
Assembly asm = Assembly.LoadFrom("AttributedCarLibrary");

// Get type info of VehicleDescriptionAttribute.
Type vehicleDesc =
  asm.GetType("AttributedCarLibrary.VehicleDescriptionAttribute");

// Get type info of the Description property.
 PropertyInfo propDesc = vehicleDesc.GetProperty("Description");

// Get all types in the assembly.
 Type[] types = asm.GetTypes();

// Iterate over each type and obtain any VehicleDescriptionAttributes.
foreach (Type t in types)
{
  object[] objs = t.GetCustomAttributes(vehicleDesc, false);

  // Iterate over each VehicleDescriptionAttribute and print
  // the description using late binding.
  foreach (object o in objs)
  {
    Console.WriteLine("-> {0}: {1}\n", t.Name,
      propDesc.GetValue(o, null));
  }
}

}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
}
}


如果您能够按照本章中的示例进行操作,这段代码应该(或多或少)是不言自明的。唯一有趣的地方是使用了`PropertyInfo.GetValue()`方法,该方法用于触发属性的访问器。以下是当前示例的输出:

***** Value of VehicleDescriptionAttribute *****
→ Motorcycle: My rocking Harley

→ HorseAndBuggy: The old gray mare, she ain't what she used to be...

→ Winnebago: A very long, slow, but feature-rich auto


## 正确看待反射、延迟绑定和自定义属性

尽管您已经看到了这些技术的大量实例,但您可能仍然想知道何时在程序中使用反射、动态加载、延迟绑定和自定义属性。可以肯定的是,这些主题可能看起来有点像编程的学术方面(这可能是也可能不是一件坏事,取决于你的观点)。为了帮助将这些主题映射到真实世界的情况,您需要一个可靠的示例。现在假设您在一个编程团队中,该团队正在构建一个具有以下需求的应用:

该产品必须使用额外的第三方工具进行扩展。

*可延伸*到底是什么意思?好吧,考虑一下 Visual Studio IDE。当开发该应用时,各种“挂钩”被插入到代码库中,以允许其他软件供应商将定制模块“嵌入”(或插入)到 IDE 中。显然,Visual Studio 开发团队没有办法设置对外部。NET 程序集(因此,没有早期绑定),那么应用将如何提供所需的钩子呢?这里有一个解决这个问题的可能方法:

1.  首先,可扩展的应用必须提供某种输入机制,以允许用户指定要插入的模块(例如对话框或命令行标志)。这就需要*动态加载*。

2.  第二,可扩展的应用必须能够确定模块是否支持要插入到环境中的正确功能(例如一组必需的接口)。这就需要*反思*。

3.  最后,可扩展的应用必须获得对所需基础结构的引用(例如一组接口类型),并调用成员来触发底层功能。这可能需要*延迟绑定*。

简单地说,如果可扩展的应用已经被预编程为查询特定的接口,它能够在运行时确定该类型是否可以被激活。一旦通过了验证测试,所讨论的类型就可以支持为其功能提供多态结构的附加接口。这正是 Visual Studio 团队所采用的方法,不管您怎么想,这一点也不难!

## 构建可扩展的应用

在接下来的小节中,我将通过一个例子来说明构建一个可以通过外部程序集的功能来扩充的应用的过程。作为路线图,可扩展的应用需要以下组件:

*   `CommonSnappableTypes.dll`:此程序集包含将由每个管理单元对象使用的类型定义,并将由 Windows 窗体应用直接引用。

*   `CSharpSnapIn.dll`:用 C# 写的一个管理单元,利用了`CommonSnappableTypes.dll`的类型。

*   `VBSnapIn.dll`:用 Visual Basic 编写的管理单元,利用了`CommonSnappableTypes.dll`的类型。

*   `MyExtendableApp.exe`:可以通过每个管理单元的功能扩展的控制台应用。

这个应用将使用动态加载、反射和延迟绑定来动态获取它事先不知道的程序集的功能。

Note

您可能会想,“我的老板从来没有要求我构建一个控制台应用”,您可能是正确的!使用 C# 构建的业务线应用通常属于智能客户端(WinForms 或 WPF)、web 应用/RESTful 服务(ASP.NET 核心)或无头流程(Azure 函数、Windows 服务等)的范畴。).我们使用控制台应用来关注示例中的特定概念,在本例中是动态加载、反射和延迟绑定。在本书的后面,你将使用 ASP.NET 核心和 WPF 探索“真正的”面向用户的应用。

### 构建多项目可扩展应用解决方案

到目前为止,本书中的大多数应用都是独立的项目,只有少数例外(就像前一个)。这样做是为了让例子简单明了。然而,在现实世界的开发中,您通常会在一个解决方案中同时处理多个项目。

#### 使用 CLI 创建解决方案和项目

要开始使用 CLI,请输入以下命令来创建新的解决方案、类库和控制台应用以及项目引用:

dotnet new sln -n Chapter17_ExtendableApp

dotnet new classlib -lang c# -n CommonSnappableTypes -o .\CommonSnappableTypes -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\CommonSnappableTypes

dotnet new classlib -lang c# -n CSharpSnapIn -o .\CSharpSnapIn -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\CSharpSnapIn
dotnet add CSharpSnapin reference CommonSnappableTypes

dotnet new classlib -lang vb -n VBSnapIn -o .\VBSnapIn -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\VBSnapIn
dotnet add VBSnapIn reference CommonSnappableTypes

dotnet new console -lang c# -n MyExtendableApp -o .\MyExtendableApp -f net5.0
dotnet sln .\Chapter17_ExtendableApp.sln add .\MyExtendableApp
dotnet add MyExtendableApp reference CommonSnappableTypes


##### 将后期生成事件添加到项目文件中

当构建项目时(无论是从 Visual Studio 还是从命令行),都有可以挂接的事件。例如,我们希望在每次成功构建后,将两个管理单元程序集复制到控制台应用项目目录(用`dotnet run`调试时)和控制台应用输出目录(用 Visual Studio 调试时)。为此,我们将利用几个内置的宏。

将这个标记块复制到`CSharpSnapIn.csproj`和`VBSnapIn.vbproj`文件中,这将编译后的程序集复制到`MyExtendableApp`项目目录和输出目录(`MyExtendableApp\bin\debug\net5.0`):


现在,当构建每个项目时,它的程序集也被复制到`MyExtendableApp`的目标目录中。

#### 使用 Visual Studio 创建解决方案和项目

回想一下,默认情况下,Visual Studio 将该解决方案命名为在该解决方案中创建的第一个项目。但是,您可以很容易地更改解决方案的名称,如图 17-3 所示。

![img/340876_10_En_17_Fig3_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-csharp-zh/raw/master/docs/pro-cs9-dnet5/img/340876_10_En_17_Fig3_HTML.jpg)

图 17-3。

创建 CommonSnappableTypes 项目和 ExtendableApp 解决方案

要创建 ExtendableApp 解决方案,首先选择“文件”“➤新建项目”以加载“新建项目”对话框。选择类库并输入名称 **CommonSnappableTypes** 。在点击确定之前,输入解决方案名称 **ExtendableApp** ,如图 17-3 所示。

若要将另一个项目添加到解决方案中,请在解决方案资源管理器中右键单击解决方案名称(ExtendableApp )(或单击“文件”“➤”“添加➤新项目”),然后选择“添加➤新项目”。当向现有解决方案中添加另一个项目时,添加新项目对话框现在略有不同;解决方案选项不再存在,所以您将只看到项目信息(名称和位置)。将类库项目命名为 CSharpSnapIn,然后单击“创建”。

接下来,从 CSharpSnapIn 项目添加对 CommonSnappableTypes 项目的引用。若要在 Visual Studio 中执行此操作,请右击 CSharpSnapIn 项目,然后选择“添加➤项目引用”。在“引用管理器”对话框中,从左侧选择“项目➤解决方案”(如果尚未选择),然后选中“CommonSnappableTypes”旁边的框。

对引用 CommonSnappableTypes 项目的新 Visual Basic 类库(`VBSnapIn`)重复该过程。

要添加的最后一个项目是名为 MyExtendableApp 的. NET 核心控制台应用。添加对 CommonSnappableTypes 项目的引用,并将控制台应用设置为解决方案的启动项目。为此,在解决方案资源管理器中右键单击`MyExtendableApp`项目,并选择 Set as StartUp Project。

Note

如果右击 ExtendableApp 解决方案而不是其中一个项目,则显示的上下文菜单选项是“设置启动项目”。除了在单击“运行”时只执行一个项目之外,还可以设置多个项目来执行。这将在后面的章节中演示。

##### 设置项目生成依赖项

当 Visual Studio 获得运行解决方案的命令时,如果检测到任何更改,将生成启动项目和所有引用的项目。但是,任何未被引用的项目都是*而不是*构建的。这可以通过设置项目依赖关系来更改。为此,请在解决方案资源管理器中右击该解决方案,选择“项目生成顺序”,然后在出现的对话框中,选择“依赖项”选项卡,并将项目更改为 MyExtendableApp。

请注意,已经选择了 CommonSnappableTypes 项目,并且复选框被禁用。这是因为它是直接引用的。同时选中 CSharpSnapIn 和 VBSnapIn 项目复选框,如图 17-4 所示。

![img/340876_10_En_17_Fig4_HTML.jpg](https://gitee.com/OpenDocCN/vkdoc-csharp-zh/raw/master/docs/pro-cs9-dnet5/img/340876_10_En_17_Fig4_HTML.jpg)

图 17-4。

访问项目构建顺序上下文菜单

现在,每次构建 MyExtendableApp 项目时,也会构建 CSharpSnapIn 和 VBSnapIn 项目。

##### 添加后期生成事件

打开 CSharpSnapIn 的项目属性(右击解决方案资源管理器并选择“属性”),然后导航到“生成事件”页(C#)。单击编辑后期生成按钮,然后单击宏> >。在这里你可以看到可用的宏,它们都指向路径和/或文件名。在构建事件中使用这些宏的优点是它们是独立于机器的,并且在相对路径上工作。例如,我正在一个名为`c-sharp-wf\code\chapter17`的目录中工作。您可能正在使用不同的目录。通过使用宏,MSBuild 将总是使用相对于`*.csproj`文件的正确路径。

在 PostBuild 框中,输入以下内容(两行):

copy $(TargetPath) \((SolutionDir)MyExtendableApp\$(OutDir)\)(TargetFileName) /Y
copy $(TargetPath) $(SolutionDir)MyExtendableApp$(TargetFileName) /Y


对 VBSnapIn 项目执行相同的操作,只是属性页名为 Compile,您可以从这里单击 Build Events 按钮。

添加这些后期生成事件命令后,每次编译时,每个程序集都将被复制到 MyExtendableApp 的项目和输出目录中。

### 建设 CommonSnappableTypes.dll

在 CommonSnappableTypes 项目中,删除默认的`Class1.cs`文件,添加一个名为`IAppFunctionality.cs`的新接口文件,并将该文件更新为:

namespace CommonSnappableTypes
{
public interface IAppFunctionality
{
void DoIt();
}
}


添加名为`CompanyInfoAttribute.cs`的类文件,并将其更新为:

using System;
namespace CommonSnappableTypes
{
[AttributeUsage(AttributeTargets.Class)]
public sealed class CompanyInfoAttribute : System.Attribute
{
public string CompanyName { get; set; }
public string CompanyUrl { get; set; }
}
}


`IAppFunctionality`接口为可扩展应用使用的所有管理单元提供了一个多态接口。假设这个例子纯粹是说明性的,您提供一个名为`DoIt()`的方法。

`CompanyInfoAttribute`类型是一个定制属性,可以应用于任何想要嵌入到容器中的类类型。从这个类的定义可以看出,`[CompanyInfo]`允许管理单元的开发人员提供一些关于组件起点的基本细节。

### 构建 C# 管理单元

在 CSharpSnapIn 项目中,删除`Class1.cs`文件并添加一个名为`CSharpModule.cs`的新文件。更新代码以匹配以下内容:

using System;
using CommonSnappableTypes;

namespace CSharpSnapIn
{
[CompanyInfo(CompanyName = "FooBar", CompanyUrl = "www.FooBar.com")]
public class CSharpModule : IAppFunctionality
{
void IAppFunctionality.DoIt()
{
Console.WriteLine("You have just used the C# snap-in!");
}
}
}


注意,当支持`IAppFunctionality`接口时,我选择了使用显式接口实现(参见第章第 8 )。这不是必需的;然而,这个想法是,系统中唯一需要与这个接口类型直接交互的部分是宿主应用。通过显式实现这个接口,`DoIt()`方法不会直接从`CSharpModule`类型中暴露出来。

### 构建 Visual Basic 管理单元

转到 VBSnapIn 项目,删除`Class1.vb`文件并添加一个名为`VBSnapIn.vb`的新文件。代码(再次)故意简单。

Imports CommonSnappableTypes

<CompanyInfo(CompanyName:="Chucky's Software", CompanyUrl:="www.ChuckySoft.com")>
Public Class VBSnapIn
Implements IAppFunctionality

Public Sub DoIt() Implements CommonSnappableTypes.IAppFunctionality.DoIt
Console.WriteLine("You have just used the VB snap in!")
End Sub
End Class


请注意,在 Visual Basic 的语法中应用属性需要尖括号(`< >`),而不是方括号(`[ ]`)。还要注意,`Implements`关键字用于实现给定类或结构的接口类型。

### 为 ExtendableApp 添加代码

最后要更新的项目是 C# 控制台应用(`MyExtendableApp`)。在将 MyExtendableApp 控制台应用添加到解决方案中并将其设置为启动项目后,添加对 CommonSnappableTypes 项目的引用,但*不是*`CSharpSnapIn.dll`或`VBSnapIn.dll`项目。

首先将位于`Program.cs`类顶部的`using`语句更新为:

using System;
using System.Linq;
using System.Reflection;
using CommonSnappableTypes;


`LoadExternalModule()`方法执行以下任务:

*   将选定的程序集动态加载到内存中

*   确定程序集是否包含任何实现`IAppFunctionality`的类型

*   使用延迟绑定创建类型

如果找到实现`IAppFunctionality`的类型,调用`DoIt()`方法,然后发送给`DisplayCompanyData()`方法,输出反射类型的附加信息。

static void LoadExternalModule(string assemblyName)
{
Assembly theSnapInAsm = null;
try
{
// Dynamically load the selected assembly.
theSnapInAsm = Assembly.LoadFrom(assemblyName);
}
catch (Exception ex)
{
Console.WriteLine($"An error occurred loading the snapin: ");
return;
}

// Get all IAppFunctionality compatible classes in assembly.
var theClassTypes = theSnapInAsm
.GetTypes()
.Where(t ⇒ t.IsClass && (t.GetInterface("IAppFunctionality") != null))
.ToList();
if (!theClassTypes.Any())
{
Console.WriteLine("Nothing implements IAppFunctionality!");
}

// Now, create the object and call DoIt() method.
foreach (Type t in theClassTypes)
{
// Use late binding to create the type.
IAppFunctionality itfApp = (IAppFunctionality) theSnapInAsm.CreateInstance(t.FullName, true);
itfApp?.DoIt();
// Show company info.
DisplayCompanyData(t);
}
}


最后一项任务是显示由`[CompanyInfo]`属性提供的元数据。如下创建`DisplayCompanyData()`方法。注意这个方法只有一个`System.Type`参数。

static void DisplayCompanyData(Type t)
{
// Get [CompanyInfo] data.
var compInfo = t
.GetCustomAttributes(false)
.Where(ci ⇒ (ci is CompanyInfoAttribute));
// Show data.
foreach (CompanyInfoAttribute c in compInfo)
{
Console.WriteLine($"More info about can be found at ");
}
}


最后,将顶级语句更新为以下内容:

Console.WriteLine("***** Welcome to MyTypeViewer *****");
string typeName = "";
do
{
Console.WriteLine("\nEnter a snapin to load");
Console.Write("or enter Q to quit: ");

// Get name of type.
typeName = Console.ReadLine();

// Does user want to quit?
if (typeName.Equals("Q", StringComparison.OrdinalIgnoreCase))
{
break;
}
// Try to display type.
try
{
LoadExternalModule(typeName);
}
catch (Exception ex)
{
Console.WriteLine("Sorry, can't find snapin");
}
}
while (true);


太棒了!这就结束了示例应用。我希望您可以看到,本章中介绍的主题在现实世界中可以相当有帮助,并且不限于世界的工具构建者。

## 摘要

反射是健壮的 OO 环境的一个有趣的方面。在的世界里。NET 核心,反射服务的关键围绕着`System.Type`类和`System.Reflection`名称空间。正如您所看到的,反射是在运行时将一个类型放在放大镜下以理解给定项目的谁、什么、哪里、何时、为什么以及如何的过程。

延迟绑定是创建一个类型的实例并调用其成员的过程,而事先不知道这些成员的具体名称。延迟绑定通常是动态加载的直接结果,它允许你以编程的方式将. NET 核心程序集加载到内存中。正如本章的可扩展应用示例所示,这是工具构建者和工具消费者使用的一种强大技术。

本章还研究了基于属性的编程的作用。当您用属性修饰您的类型时,结果是基础程序集元数据的增加。*

# 十八、动态类型和动态语言运行时

NET 4.0 为 C# 语言引入了一个新的关键字,具体来说就是`dynamic`。该关键字允许您将类似脚本的行为合并到类型安全、分号和花括号的强类型世界中。使用这种松散的类型,您可以极大地简化一些复杂的编码任务,还可以获得与许多动态语言进行互操作的能力。网核悟性。

在这一章中,将向您介绍 C# `dynamic`关键字,并理解如何使用动态语言运行时(DLR)将松散类型的调用映射到正确的内存对象。在理解了 DLR 提供的服务之后,您将会看到使用动态类型来简化如何执行延迟绑定方法调用(通过反射服务)以及如何轻松地与传统 COM 库进行通信的示例。

Note

不要混淆 C# `dynamic`关键字和*动态汇编*的概念(参见第十九章)。虽然在构建动态程序集时可以使用`dynamic`关键字,但这最终是两个独立的概念。

## C# 动态关键字的作用

在第三章中,你学习了`var`关键字,它允许你以这样一种方式定义局部变量,即底层数据类型是在编译时根据初始赋值确定的(回想一下这被称为*隐式类型化*)。一旦进行了初始赋值,就有了一个强类型变量,任何试图赋值不兼容的值都会导致编译器错误。

要开始研究 C# `dynamic`关键字,请创建一个名为 DynamicKeyword 的新控制台应用项目。现在,在您的`Program`类中添加下面的方法,并验证如果取消注释,最终的代码语句确实会触发编译时错误:

```cs
static void ImplicitlyTypedVariable()
{
  // a is of type List<int>.
  var a = new List<int> {90};
  // This would be a compile-time error!
  // a = "Hello";
}

仅仅为了这样做而使用隐式类型被一些人认为是不好的风格(如果你知道你需要一个List<int>,只需要声明一个List<int>)。然而,正如你在第十三章中看到的,隐式类型对 LINQ 很有用,因为许多 LINQ 查询返回匿名类的枚举(通过投影),你不能在你的 C# 代码中直接声明。然而,即使在这种情况下,隐式类型变量实际上也是强类型的。

与此相关,正如你在第六章中了解到的,System.Object是。NET 核心框架,可以代表任何东西。同样,如果你声明了一个类型为object的变量,你就有了一个强类型的数据;但是,它在内存中指向的内容会因引用的赋值而不同。要访问内存中对象引用所指向的成员,需要执行显式强制转换。

假设您有一个名为Person的简单类,它定义了两个自动属性(FirstNameLastName),这两个属性都封装了一个string。现在,观察下面的代码:

static void UseObjectVariable()
{
  // Assume we have a class named Person.
  object o = new Person() { FirstName = "Mike", LastName = "Larson" };

  // Must cast object as Person to gain access
  // to the Person properties.
  Console.WriteLine("Person's first name is {0}", ((Person)o).FirstName);
}

现在,回到dynamic关键词。从高层次来看,您可以将dynamic关键字视为System.Object的一种特殊形式,因为任何值都可以赋给动态数据类型。乍一看,这可能会令人非常困惑,因为现在您似乎有三种方法来定义其基础类型没有在您的代码库中直接指示的数据。例如,这种方法

static void PrintThreeStrings()
{
  var s1 = "Greetings";
  object s2 = "From";
  dynamic s3 = "Minneapolis";

  Console.WriteLine("s1 is of type: {0}", s1.GetType());
  Console.WriteLine("s2 is of type: {0}", s2.GetType());
  Console.WriteLine("s3 is of type: {0}", s3.GetType());
}

如果从Main()调用,将打印出以下内容:

s1 is of type: System.String
s2 is of type: System.String
s3 is of type: System.String

动态变量与隐式声明或通过System.Object引用声明的变量有很大不同,因为它是而不是强类型的。换个方式说,动态数据不是静态类型化的。就 C# 编译器而言,用dynamic关键字声明的数据点可以被赋予任何初始值,也可以在其生命周期内被重新赋予任何新的(也可能是不相关的)值。考虑以下方法和结果输出:

static void ChangeDynamicDataType()
{
  // Declare a single dynamic data point
  // named "t".
  dynamic t = "Hello!";
  Console.WriteLine("t is of type: {0}", t.GetType());

  t = false;
  Console.WriteLine("t is of type: {0}", t.GetType());

  t = new List<int>();
  Console.WriteLine("t is of type: {0}", t.GetType());
}
t is of type: System.String
t is of type: System.Boolean
t is of type: System.Collections.Generic.List`1[System.Int32]

在您研究的这一点上,请注意,如果您将t变量声明为System.Object,前面的代码会以相同的方式编译和执行。然而,你很快就会看到,dynamic关键字提供了许多额外的功能。

对动态声明的数据调用成员

假设一个动态变量可以动态地呈现任何类型的身份(就像类型为System.Object的变量一样),那么您想到的下一个问题可能是关于调用动态变量的成员(属性、方法、索引器、注册事件等)。).嗯,从语法上来说,它看起来也没有什么不同。只需对动态数据变量应用点运算符,指定一个公共成员,并提供任何参数(如果需要)。

然而(这是一个非常大的“然而”),你指定的成员的有效性不会被编译器检查!记住,与定义为System.Object的变量不同,动态数据不是静态类型的。直到运行时,您才会知道您调用的动态数据是否支持指定的成员,您是否传入了正确的参数,您是否正确地拼写了成员,等等。因此,尽管看起来很奇怪,下面的方法可以完美地编译:

static void InvokeMembersOnDynamicData()
{
  dynamic textData1 = "Hello";
  Console.WriteLine(textData1.ToUpper());

  // You would expect compiler errors here!
  // But they compile just fine.
  Console.WriteLine(textData1.toupper());
  Console.WriteLine(textData1.Foo(10, "ee", DateTime.Now));
}

注意对WriteLine()的第二次调用试图在动态数据点上调用名为toupper()的方法(注意不正确的大小写——应该是ToUpper())。如您所见,textData1的类型是string,因此,您知道它没有一个全部用小写字母命名的方法。此外,string肯定没有一个名为Foo()的方法接受intstringDateTime对象!

尽管如此,C# 编译器还是满意的。然而,如果您从Main()中调用这个方法,您将得到如下输出所示的运行时错误:

Unhandled Exception: Microsoft.CSharp.RuntimeBinder.RuntimeBinderException:
'string' does not contain a definition for 'toupper'

对动态数据和强类型数据调用成员的另一个明显区别是,当您对一段动态数据应用点运算符时,您将而不是看到预期的 Visual Studio IntelliSense。IDE 将允许您输入任何您能想到的成员名称。

智能感知对于动态数据是不可能的,这应该是有道理的。但是,请记住,这意味着当您在这样的数据点上键入 C# 代码时,您需要非常小心。成员的任何拼写错误或不正确的大写都会引发运行时错误,特别是RuntimeBinderException类的实例。

RuntimeBinderException表示一个错误,如果你试图调用一个实际上并不存在的动态数据类型的成员,就会抛出这个错误(就像在toupper()Foo()方法的情况下)。如果您为确实存在的成员指定了错误的参数数据,也会引发同样的错误。

因为动态数据非常不稳定,所以每当您调用用 C# dynamic关键字声明的变量的成员时,您可以将调用包装在适当的try / catch块中,并以优雅的方式处理错误,如下所示:

static void InvokeMembersOnDynamicData()
{
  dynamic textData1 = "Hello";

  try
  {
    Console.WriteLine(textData1.ToUpper());
    Console.WriteLine(textData1.toupper());
    Console.WriteLine(textData1.Foo(10, "ee", DateTime.Now));
  }
  catch (Microsoft.CSharp.RuntimeBinder.RuntimeBinderException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

如果再调用这个方法,会发现对ToUpper()(注意大写 TU 的调用工作正常;但是,您会发现控制台上显示了错误数据。

HELLO
'string' does not contain a definition for 'toupper'

当然,将所有动态方法调用包装在一个try / catch块中的过程相当繁琐。如果您注意拼写和参数传递,这不是必需的。但是,如果您事先不知道某个成员是否会出现在目标类型上,那么捕捉异常就非常方便。

动态关键字的范围

回想一下,隐式类型数据(用var关键字声明)只可能用于成员范围内的局部变量。var关键字永远不能用作返回值、参数或类/结构的成员。然而,dynamic关键词却不是这种情况。考虑下面的类定义:

namespace DynamicKeyword
{
  class VeryDynamicClass
  {
    // A dynamic field.
    private static dynamic _myDynamicField;

    // A dynamic property.
    public dynamic DynamicProperty { get; set; }

    // A dynamic return type and a dynamic parameter type.
    public dynamic DynamicMethod(dynamic dynamicParam)
    {
      // A dynamic local variable.
      dynamic dynamicLocalVar = "Local variable";

      int myInt = 10;

      if (dynamicParam is int)
      {
        return dynamicLocalVar;
      }
      else
      {
        return myInt;
      }
    }
  }
}

您现在可以像预期的那样调用公共成员;但是,当您操作动态方法和属性时,您不能完全确定数据类型是什么!可以肯定的是,VeryDynamicClass的定义在现实世界的应用中可能没有用,但是它确实说明了可以应用这个 C# 关键字的范围。

动态关键字的限制

虽然使用关键字dynamic可以定义很多东西,但是它的用法有一些限制。虽然它们并不引人注目,但是要知道,在调用方法时,动态数据项不能使用 lambda 表达式或 C# 匿名方法。例如,下面的代码总是会导致错误,即使目标方法确实接受了一个委托参数,该参数接受一个string值并返回void:

dynamic a = GetDynamicObject();

// Error! Methods on dynamic data can't use lambdas!
a.Method(arg => Console.WriteLine(arg));

为了规避这个限制,你需要使用第十二章中描述的技术直接使用底层委托。另一个限制是数据的动态点不能理解任何扩展方法(参见第十一章)。不幸的是,这也将包括来自 LINQ API 的任何扩展方法。因此,用dynamic关键字声明的变量在 LINQ 中的使用仅限于对象和其他 LINQ 技术。

dynamic a = GetDynamicObject();
// Error! Dynamic data can't find the Select() extension method!
var data = from d in a select d;

动态关键字的实际应用

假设动态数据不是强类型的,没有在编译时进行检查,没有能力触发智能感知,也不能成为 LINQ 查询的目标,那么您完全有理由认为仅仅为了这样做而使用dynamic关键字是一种糟糕的编程实践。

然而,在少数情况下,dynamic关键字可以从根本上减少您需要手工编写的代码量。具体来说,如果您正在构建一个大量使用延迟绑定(通过反射)的. NET 核心应用,那么dynamic关键字可以节省您的输入时间。同样,如果您正在构建一个需要与遗留 COM 库(如微软 Office 产品)通信的. NET 核心应用,您可以通过dynamic关键字极大地简化您的代码库。最后一个例子,使用 ASP.NET 核心构建的 web 应用经常使用ViewBag类型,也可以使用dynamic关键字以简化的方式访问。

Note

COM 交互严格来说是一种 Windows 范式,它消除了应用的跨平台能力。

像任何“捷径”一样,你需要权衡利弊。关键字dynamic的使用是代码简洁和类型安全之间的权衡。虽然 C# 本质上是一种强类型语言,但您可以根据调用情况选择加入(或退出)动态行为。永远记住你永远不需要使用dynamic关键字。您可以通过手工编写替代代码(通常更多)来获得相同的最终结果。

动态语言运行库的作用

现在您更好地理解了“动态数据”是什么,让我们学习它是如何处理的。自从发布以来。NET 4.0 中,公共语言运行时(CLR)补充了一个名为动态语言运行时的补充运行时环境。“动态运行时”的概念当然不是新的。事实上,许多编程语言如 JavaScript、LISP、Ruby 和 Python 已经使用它很多年了。简而言之,动态运行时允许动态语言在运行时完全发现类型,无需编译时检查。

Note

虽然大量的 DLR 被移植到。NET Core(从 3.0 开始),具有 DLR 之间的奇偶校验功能.NETCore 5 和。NET 4.8 还没做到。

如果你有强类型语言(包括 C#,没有动态类型)的背景,那么这种运行时的概念可能是不可取的。毕竟,您通常希望尽可能接收编译时错误,而不是运行时错误。然而,动态语言/运行时确实提供了一些有趣的特性,包括:

  • 极其灵活的代码库。您可以重构代码,而无需对数据类型进行大量更改。

  • 一种与不同平台和编程语言中构建的不同对象类型进行互操作的简单方法。

  • 一种在运行时在内存中添加或移除类型成员的方法。

DLR 的一个作用是支持各种动态语言与。NET 运行库,并为它们提供了一种与其他。NET 代码。这些语言生活在一个动态的世界中,类型只在运行时被发现。然而,这些语言拥有丰富的。NET 基础类库。更好的是,由于包含了dynamic关键字,它们的代码库可以与 C# 互操作(反之亦然)。

Note

本章不会讨论如何使用 DLR 来集成动态语言。

表达式树的作用

DLR 利用表达式树来捕捉中性术语中动态调用的含义。例如,以下面的 C# 代码为例:

dynamic d = GetSomeData();
d.SuperMethod(12);

在这个例子中,DLR 将自动构建一个表达式树,实际上就是“调用对象d上名为SuperMethod的方法,将数字12作为参数传入”该信息(正式名称为有效负载)随后被传递给正确的运行时绑定器,该绑定器也可以是 C# 动态绑定器,甚至是(简单解释一下)遗留 COM 对象。

从这里,请求被映射到目标对象所需的调用结构中。这些表达式树的好处(除此之外,您不需要手动创建它们)是,这允许您编写固定的 C# 代码语句,而不用担心底层目标实际上是什么。

表达式树的动态运行时查找

如前所述,DLR 会将表达式树传递给目标对象;但是,这种调度会受到一些因素的影响。如果动态数据类型在内存中指向一个 COM 对象,则表达式树被发送到一个名为IDispatch的低级 COM 接口。正如您可能知道的,这个接口是 COM 合并它自己的一组动态服务的方式。然而,COM 对象可以在不使用 DLR 或 C# dynamic关键字的情况下在. NET 应用中使用。然而,这样做(正如您将看到的),往往会导致更复杂的 C# 编码。

如果动态数据没有指向 COM 对象,表达式树可以被传递给实现IDynamicObject接口的对象。该接口在后台使用,允许诸如 IronRuby 之类的语言获取 DLR 表达式树并将其映射到 Ruby 细节。

最后,如果动态数据指向的对象是而不是COM 对象,并且而不是实现了IDynamicObject,那么这个对象就是一个普通的日常对象。NET 对象。在这种情况下,表达式树被分派到 C# 运行时绑定器进行处理。将表达式树映射到。NET specifications 涉及反射服务。

在表达式树被给定的绑定器处理后,动态数据将被解析为真正的内存数据类型,之后使用任何必要的参数调用正确的方法。现在,让我们看看 DLR 的一些实际用途,从延迟绑定的简化开始.NET 电话。

使用动态类型简化延迟绑定调用

您可能决定使用dynamic关键字的一个实例是当您使用反射服务时,特别是在进行延迟绑定方法调用时。在第十七章中,你看到了一些这种类型的方法调用有用的例子,最常见的是在你构建某种类型的可扩展应用时。那时,您学习了如何使用Activator.CreateInstance()方法来创建一个object,对此您没有任何编译时知识(除了它的显示名称)。然后,您可以利用System.Reflection名称空间的类型通过延迟绑定来调用成员。回想一下第十七章中的例子:

static void CreateUsingLateBinding(Assembly asm)
{
  try
  {
    // Get metadata for the Minivan type.
    Type miniVan = asm.GetType("CarLibrary.MiniVan");

    // Create the Minivan on the fly.
    object obj = Activator.CreateInstance(miniVan);

    // Get info for TurboBoost.
    MethodInfo mi = miniVan.GetMethod("TurboBoost");

    // Invoke method ("null" for no parameters).
    mi.Invoke(obj, null);
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex.Message);
  }
}

虽然这段代码像预期的那样工作,但您可能会认为它有点笨拙。您必须手动使用MethodInfo类,手动查询元数据,等等。下面是同一方法的一个版本,现在使用 C# dynamic关键字和 DLR:

static void InvokeMethodWithDynamicKeyword(Assembly asm)
{
  try
  {
    // Get metadata for the Minivan type.
    Type miniVan = asm.GetType("CarLibrary.MiniVan");

    // Create the Minivan on the fly and call method!
    dynamic obj = Activator.CreateInstance(miniVan);
    obj.TurboBoost();
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex.Message);
  }
}

通过使用dynamic关键字声明obj变量,反射的繁重工作由 DRL 代表您完成。

利用动态关键字传递参数

当您需要对接受参数的方法进行延迟绑定调用时,DLR 的用处会变得更加明显。当你使用“手写”反射调用时,参数需要打包成一个数组objects,传递给MethodInfoInvoke()方法。

为了使用新的示例进行说明,首先创建一个名为 LateBindingWithDynamic 的新 C# 控制台应用项目。接下来,添加一个名为 MathLibrary 的类库项目。将 MathLibrary 项目的初始文件Class1.cs重命名为SimpleMath.cs,并如下实现该类:

namespace MathLibrary
{
  public class SimpleMath
  {
    public int Add(int x, int y)
    {
      return x + y;
    }
  }
}

用以下内容更新MathLibrary.csproj文件(将编译后的程序集复制到LateBindingWithDynamic目标目录):

<Target Name="PostBuild" AfterTargets="PostBuildEvent">
    <Exec Command="copy $(TargetPath) $(SolutionDir)LateBindingWithDynamic\$(OutDir)$(TargetFileName) /Y 
copy $(TargetPath) $(SolutionDir)LateBindingWithDynamic\$(TargetFileName) /Y" />
</Target>

Note

如果这些项目构建事件对您来说是新的,请回顾第十七章中的技术以获得完整的细节。

现在,回到 LateBindingWithDynamic 项目,将System.ReflectionMicrosoft.CSharp.RuntimeBinder名称空间导入到Program.cs文件中。接下来,将下面的方法添加到Program类,该类使用典型的反射 API 调用来调用Add()方法:

static void AddWithReflection()
{
  Assembly asm = Assembly.LoadFrom("MathLibrary");
  try
  {
    // Get metadata for the SimpleMath type.
    Type math = asm.GetType("MathLibrary.SimpleMath");

    // Create a SimpleMath on the fly.
    object obj = Activator.CreateInstance(math);

    // Get info for Add.
    MethodInfo mi = math.GetMethod("Add");

    // Invoke method (with parameters).
    object[] args = { 10, 70 };
    Console.WriteLine("Result is: {0}", mi.Invoke(obj, args));
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex.Message);
  }
}

现在,通过下面的新方法,考虑用关键字dynamic简化前面的逻辑:

private static void AddWithDynamic()
{
  Assembly asm = Assembly.LoadFrom("MathLibrary");

  try
  {
    // Get metadata for the SimpleMath type.
    Type math = asm.GetType("MathLibrary.SimpleMath");

    // Create a SimpleMath on the fly.
    dynamic obj = Activator.CreateInstance(math);

    // Note how easily we can now call Add().
    Console.WriteLine("Result is: {0}", obj.Add(10, 70));
  }
  catch (RuntimeBinderException ex)
  {
    Console.WriteLine(ex.Message);
  }
}

不算太寒酸!如果您调用这两个方法,您将看到相同的输出。然而,当使用dynamic关键字时,您为自己节省了相当多的工作。使用动态定义的数据,您不再需要手动将参数打包为对象数组、查询程序集元数据或其他类似的详细信息。如果您正在构建一个大量使用动态加载/延迟绑定的应用,我相信您可以看到这些代码节省是如何随着时间的推移而增加的。

使用动态数据简化 COM 互操作性(仅限 Windows)

让我们看一下在 COM 互用性项目的上下文中dynamic关键字的另一个有用的例子。现在,如果您对 COM 开发没有太多的背景知识,请注意下一个例子,编译后的 COM 库包含元数据,就像. NET 核心库一样;但是,格式完全不同。正因为如此,如果一个. NET 核心程序需要与一个 COM 对象通信,首先要做的就是生成一个所谓的互操作程序集(将在下面的段落中描述)。这样做非常简单。

Note

如果您没有安装 Visual Studio Tools for Office(VSTO)单个组件或“Office/SharePoint development”工作负载,则需要这样做才能完成本节。您可以重新运行安装程序来选择缺少的组件,也可以使用 Visual Studio 快速启动(Ctrl+Q)。在快速启动中键入Visual Studio Tools for Office并选择安装选项。

首先,创建一个名为 ExportDataToOfficeApp 的新控制台应用,通过在解决方案资源管理器中右击该项目来激活“添加 COM 引用”对话框,然后选择“添加➤ COM 引用”。选中 COM 页签,找到你要使用的 COM 库,就是微软 Excel 16.0 对象库(见图 18-1 )。

img/340876_10_En_18_Fig1_HTML.jpg

图 18-1。

“添加引用”对话框的“COM”选项卡将显示计算机上所有注册的 COM 库

一旦选择了 COM 库,IDE 将通过生成包含。COM 元数据的. NET 描述。正式来说,这些被称为互用性程序集(或者简称为互操作程序集)。互操作程序集不包含任何实现代码,除了少量帮助将 COM 事件转换为.NETCore 事件。但是,这些互操作程序集是有用的,因为它们屏蔽了您的。NET 核心代码库来自 COM 内部的复杂底层。

在 C# 代码中,您可以直接针对 interop 程序集进行编程,该程序集映射。NET 核心数据类型转换为 COM 类型,反之亦然。在后台,数据在。NET 核心和 COM 应用使用运行时可调用包装器(RCW),这基本上是一个动态生成的代理。这个 RCW 代理将会整理和转换。NET 核心数据类型转换为 COM 类型,并将任何 COM 返回值映射到。核心等价物净额。

主互操作程序集的角色

许多由 COM 库供应商创建的 COM 库(例如允许访问 Microsoft Office 产品的对象模型的 Microsoft COM 库)提供了一个“官方”的互操作程序集,称为主互操作程序集 (PIA)。pia 是优化的互操作程序集,它清理(并可能扩展)通常在使用“添加引用”对话框引用 COM 库时生成的代码。

引用 Microsoft Excel 16.0 对象库后,在解决方案资源管理器中检查项目。在 Dependencies 节点下,您将看到一个新节点(COM ),其中包含一个名为 interop . Microsoft . office . interop . excel 的项。

嵌入互操作元数据

在发布之前。NET 4.0 中,当 C# 应用使用 COM 库(PIA 或其他)时,您需要确保客户端计算机在其计算机上有一个 interop 程序集的副本。这不仅增加了应用安装程序包的大小,而且安装脚本必须检查 PIA 程序集是否确实存在,如果不存在,就将一个副本安装到全局程序集缓存(GAC)中。

Note

全局程序集缓存是。NET framework 程序集。它不再用于。NET 核心。

然而,随着。NET 4.0 和更高版本,您现在可以将互用性数据直接嵌入到您编译的应用中。当您这样做时,您不再需要随您的一起提供互用性程序集的副本。NET 核心应用,因为必要的互用性元数据是硬编码在程序中的。和。NET 核心,嵌入 PIA 是必需的。

要使用 Visual Studio 嵌入 PIA,请展开项目下的“依赖项”节点,展开“COM”节点,右键单击“互操作”。然后选择属性。在属性对话框中,将嵌入互操作类型的值更改为 Yes,如图 18-2 所示。

img/340876_10_En_18_Fig2_HTML.jpg

图 18-2。

嵌入互操作类型

要通过项目文件改变属性,添加<EmbedInteropTypes>True</EmbedInteropTypes >,如下图所示:

<ItemGroup>
  <COMReference Include="Microsoft.Office.Excel.dll">
    <Guid>00020813-0000-0000-c000-000000000046</Guid>
    <VersionMajor>1</VersionMajor>
    <VersionMinor>9</VersionMinor>
    <WrapperTool>tlbimp</WrapperTool>
    <Lcid>0</Lcid>
    <Isolated>false</Isolated>
    <EmbedInteropTypes>true</EmbedInteropTypes>
  </COMReference>
</ItemGroup>

C# 编译器将只包含您正在使用的互操作库部分。因此,如果真正的互操作库。NET 核心描述数百个 COM 对象,您将只引入您在 C# 代码中真正使用的子集的定义。除了减少必须部署的文件大小,您还有一个更容易的安装路径,因为您不需要在目标机器上安装任何缺失的 pia。

常见的 COM 互操作难点

许多 COM 库定义了采用可选参数的方法,这在 C# 中直到才得到支持。净 3.5。这要求您为可选参数的每次出现指定值Type.Missing。谢天谢地有了。NET 3.5 及以上(包括。NET Core),如果您没有指定一个特定的值,Type.Missing值将在编译时被插入。

与此相关的是,许多 COM 方法提供了对命名参数的支持,正如你在第四章中回忆的那样,它允许你以任何你需要的顺序将值传递给成员。假设 C# 支持这个相同的特性,很容易“跳过”一组您不关心的可选参数,只设置您关心的几个参数。

另一个常见的 COM 互操作痛点是,许多 COM 方法被设计为接受和返回一个特定的数据类型,称为Variant。很像 C# dynamic关键字,Variant数据类型可以动态地分配给任何类型的 COM 数据(字符串、接口引用、数值等)。).在拥有dynamic关键字之前,传递或接收Variant数据点需要一些跳跃,通常是通过大量的转换操作。

当您将“嵌入互操作类型”属性设置为 True 时,所有 COM Variant类型都会自动映射到动态数据。这不仅会减少在处理底层 COM Variant数据类型时对额外转换操作的需求,还会进一步隐藏一些 COM 复杂性,比如处理 COM 索引器。

使用 COM Interop 和。NET 5 缺乏构建和运行时支持。那个。MSBuild . NET 5 版本无法解析互操作库,因此。使用 COM interop 的. NET Core 项目不能使用。NET Core CLI。它们必须使用 Visual Studio 构建,并且编译后的可执行文件可以按预期运行。

使用 C# 动态数据的 COM 互操作

为了展示 C# 可选参数、命名参数和dynamic关键字如何一起简化 COM 互操作,现在您将构建一个使用 Microsoft Office 对象模型的应用。添加包含以下代码的新类名Car.cs:

namespace ExportDataToOfficeApp
{
  public class Car
  {
    public string Make { get; set; }
    public string Color { get; set; }
    public string PetName { get; set; }
  }
}

接下来,将以下using语句添加到Program.cs的顶部:

using System;
using System.Collections.Generic;
using System.Reflection;
using Excel = Microsoft.Office.Interop.Excel;
using ExportDataToOfficeApp;

请注意 Excel 命名空间别名。虽然在与 COM 库交互时不需要定义别名,但它为所有导入的 COM 对象提供了一个缩短的限定符。这不仅减少了输入,还可以解决 COM 对象的名称与.NETCore 类型。

// Create an alias to the Excel object model.
using Excel = Microsoft.Office.Interop.Excel;

接下来,在Program.cs的顶层语句中创建一个Car记录列表:

List<Car> carsInStock = new List<Car>
{
  new Car {Color="Green", Make="VW", PetName="Mary"},
  new Car {Color="Red", Make="Saab", PetName="Mel"},
  new Car {Color="Black", Make="Ford", PetName="Hank"},
  new Car {Color="Yellow", Make="BMW", PetName="Davie"}
};

因为您使用 Visual Studio 导入了 COM 库,所以 PIA 已被自动配置,以便所使用的元数据将被嵌入到。NET 核心应用。因此,所有 COM Variant数据类型都实现为dynamic数据类型。此外,您可以使用 C# 可选参数和命名参数。考虑下面的ExportToExcel()实现:

void ExportToExcel(List<Car> carsInStock)
{
  // Load up Excel, then make a new empty workbook.
  Excel.Application excelApp = new Excel.Application();
  excelApp.Workbooks.Add();

  // This example uses a single workSheet.
  Excel._Worksheet workSheet = (Excel._Worksheet)excelApp.ActiveSheet;

  // Establish column headings in cells.
  workSheet.Cells[1, "A"] = "Make";
  workSheet.Cells[1, "B"] = "Color";
  workSheet.Cells[1, "C"] = "Pet Name";

  // Now, map all data in List<Car> to the cells of the spreadsheet.
  int row = 1;
  foreach (Car c in carsInStock)
  {
    row++;
    workSheet.Cells[row, "A"] = c.Make;
    workSheet.Cells[row, "B"] = c.Color;
    workSheet.Cells[row, "C"] = c.PetName;
  }

  // Give our table data a nice look and feel.
  workSheet.Range["A1"].AutoFormat(Excel.XlRangeAutoFormat.xlRangeAutoFormatClassic2);

  // Save the file, quit Excel, and display message to user.
  workSheet.SaveAs($@"{Environment.CurrentDirectory}\Inventory.xlsx");
  excelApp.Quit();
  Console.WriteLine("The Inventory.xslx file has been saved to your app folder");
}

该方法首先将 Excel 加载到内存中;但是,您不会在电脑桌面上看到它。对于这个应用,您只对使用内部 Excel 对象模型感兴趣。但是,如果您确实想显示 Excel 的用户界面,请用下面的代码行更新您的方法:

static void ExportToExcel(List<Car> carsInStock)
{
  // Load up Excel, then make a new empty workbook.
  Excel.Application excelApp = new Excel.Application();

  // Go ahead and make Excel visible on the computer.
  excelApp.Visible = true;
...
}

创建一个空工作表后,添加三列,它们的名称类似于Car类的属性。然后,用List<Car>的数据填充单元格,并以(硬编码的)名称Inventory.xlsx保存文件。

此时,如果您运行您的应用,您将能够打开Inventory.xlsx文件,该文件将被保存到项目的\bin\Debug\net5.0文件夹中。

虽然在前面的代码中似乎没有使用任何动态数据,但要知道 DLR 提供了重要的帮助。如果没有 DLR,代码应该是这样的:

static void ExportToExcelManual(List<Car> carsInStock)
{
  Excel.Application excelApp = new Excel.Application();
  // Must mark missing params!
  excelApp.Workbooks.Add(Type.Missing);
  // Must cast Object as _Worksheet!
  Excel._Worksheet workSheet =
    (Excel._Worksheet)excelApp.ActiveSheet;
  // Must cast each Object as Range object then call low-level Value2 property!
  ((Excel.Range)excelApp.Cells[1, "A"]).Value2 = "Make";
  ((Excel.Range)excelApp.Cells[1, "B"]).Value2 = "Color";
  ((Excel.Range)excelApp.Cells[1, "C"]).Value2 = "Pet Name";
  int row = 1;
  foreach (Car c in carsInStock)
  {
    row++;
    // Must cast each Object as Range and call low-level Value2 prop!
    ((Excel.Range)workSheet.Cells[row, "A"]).Value2 = c.Make;
    ((Excel.Range)workSheet.Cells[row, "B"]).Value2 = c.Color;
    ((Excel.Range)workSheet.Cells[row, "C"]).Value2 = c.PetName;
  }
  // Must call get_Range method and then specify all missing args!
  excelApp.get_Range("A1", Type.Missing).AutoFormat(
    Excel.XlRangeAutoFormat.xlRangeAutoFormatClassic2,
    Type.Missing, Type.Missing, Type.Missing,
    Type.Missing, Type.Missing, Type.Missing);
  // Must specify all missing optional args!
  workSheet.SaveAs(
    $@"{Environment.CurrentDirectory}\InventoryManual.xlsx",
    Type.Missing, Type.Missing, Type.Missing,
    Type.Missing, Type.Missing, Type.Missing,
    Type.Missing, Type.Missing, Type.Missing);
  excelApp.Quit();
  Console.WriteLine("The InventoryManual.xslx file has been saved to your app folder");
}

这就结束了你对 C# dynamic关键字和 DLR 的研究。我希望您能看到这些特性如何简化复杂的编程任务,并且(也许更重要的是)理解其中的利弊。当您选择使用动态数据时,您确实失去了大量的类型安全性,并且您的代码库容易出现更多的运行时错误。

虽然关于 DLR 肯定还有更多要说的,但本章试图将重点放在日常编程中实用和有用的主题上。如果您想了解更多关于动态语言运行库的高级功能,如与脚本语言集成,请务必参考。NET Core SDK 文档(查阅主题“动态语言运行时概述”开始)。

摘要

关键字dynamic允许你定义直到运行时才知道其身份的数据。当由动态语言运行时处理时,自动创建的“表达式树”将被传递到正确的动态语言绑定器,在那里有效负载将被解包并发送到正确的对象成员。

使用动态数据和 DLR,可以从根本上简化复杂的 C# 编程任务,尤其是将 COM 库合并到。NET 核心应用。正如您在本章中看到的,这为 COM interop 提供了许多进一步的简化(与动态数据无关),例如将 COM interop 数据嵌入到您的应用、可选参数和命名参数中。

虽然这些特性确实可以简化您的代码,但是请记住,动态数据会使您的 C# 代码的类型安全性大大降低,并且容易出现运行时错误。一定要权衡在 C# 项目中使用动态数据的利弊,并进行相应的测试!

十九、理解 CIL 和动态程序集的作用

当你建造一个全尺寸的。NET 核心应用,鉴于 C#(或类似的托管语言,如 Visual Basic)固有的生产力和易用性,您肯定会使用它。然而,正如您在本书开头所学的,托管编译器的作用是将*.cs代码文件翻译成 CIL 代码、类型元数据和汇编指令清单。事实证明,CIL 是一个成熟的。NET 核心编程语言,有自己的语法、语义和编译器(ilasm.exe)。

在这一章中,你将参观。网芯的母语。在这里,你会明白 CIL 指令、CIL 属性和 CIL 操作码之间的区别。然后,您将了解. NET 核心程序集和各种 CIL 编程工具的往返工程的作用。本章的剩余部分将带你了解使用 CIL 语法定义命名空间、类型和成员的基本知识。本章将以对名称空间System.Reflection.Emit的角色的检查结束,并解释如何在运行时动态地构造一个汇编(用 CIL 指令)。

当然,很少有程序员需要在日常工作中使用原始的 CIL 代码。因此,本章一开始,我将研究一下为什么要了解这个底层的语法和语义。NET 核心语言可能值得你花时间。

学习 CIL 语法的动机

CIL 语是美国人真正的母语。NET 核心平台。当您使用您选择的托管语言(C#、VB、F# 等)构建. NET 核心程序集时。),相关的编译器将你的源代码翻译成 CIL。像任何编程语言一样,CIL 提供了许多结构化和以实现为中心的标记。鉴于 CIL 只是另一个。NET 核心编程语言,所以构建您的。NET 核心汇编直接使用 CIL 和 CIL 编译器(ilasm.exe)。

Note

如第一章所述,ildasm.exeilasm.exe都不附带。NET 5 运行时。获得这些工具有两种选择。首先是编译。NET 5 运行时从位于 https://github.com/dotnet/runtime 的源代码。第二种,也是更容易的方法,是从 www.nuget.org 中下拉想要的版本。在 NuGet 上 ILDasm 的 URL 是 https://www.nuget.org/packages/Microsoft.NETCore.ILDAsm/ ,对于ILAsm.exehttps://www.nuget.org/packages/Microsoft.NETCore.ILAsm/ 。确保选择正确的版本(对于本书,您需要 5.0.0 或更高版本)。使用以下命令将 ILDasm 和 ILAsm NuGet 包添加到项目中:

微软的 dotnet 添加包。NETCore.ILDAsm -版本 5.0.0

微软的 dotnet 添加包。NETCore.ILAsm -版本 5.0.0

这实际上并没有将ILDasm.exeILAsm.exe添加到您的项目中,而是将它们放在您的包文件夹中(在 Windows 上):

%userprofile%\。nu get \ packages \ Microsoft . netcore . ilasm \ 5 . 0 . 0 \ runtimes \ native \

%userprofile%\。nu get \ packages \ Microsoft . netcore . ildasm \ 5 . 0 . 0 \ runtimes \ native \

我还将这两个程序的 5.0.0 版本包含在本书的 GitHub repo 的第十九章文件夹中。

现在虽然这是事实,很少(如果有的话!)程序员会选择构建一个完整的。NET 核心应用直接与 CIL,CIL 仍然是一个极其有趣的智力追求。简单地说,你对 CIL 的语法理解得越多,你就越有能力进入高级领域。净核心开发。通过一些具体的例子,了解 CIL 教的个人能够做到以下几点:

  • 拆卸现有的。NET 核心程序集,编辑 CIL 代码,并将更新后的代码库重新编译为修改后的。网芯二进制。例如,在某些情况下,您可能需要修改 CIL 来与一些高级 COM 功能进行互操作。

  • 使用System.Reflection.Emit名称空间构建动态程序集。这个 API 允许您在内存中生成一个。NET 核心程序集,它可以选择保存到磁盘上。对于需要动态生成程序集的工具构建者来说,这是一种非常有用的技术。

  • 理解高级管理语言不支持但在 CIL 级别存在的 cts 方面。可以肯定的是,CIL 是唯一的。NET 核心语言,允许您访问 CTS 的各个方面。例如,使用原始 CIL,您可以定义全局级别的成员和字段(这在 C# 中是不允许的)。

同样,非常清楚的是,如果你选择而不是来关注 CIL 代码的细节,你仍然能够掌握 C# 和。NET 核心基本类库。在许多方面,CIL 的知识类似于 C(和 C++)程序员对汇编语言的理解。那些知道底层“goo”的来龙去脉的人可以为手头的任务创建更高级的解决方案,并对底层编程(和运行时)环境有更深入的理解。所以,如果你准备好迎接挑战,让我们开始研究 CIL 的细节。

Note

理解这一章并不打算是 CIL 语法和语义的全面处理。

检查 CIL 指令、属性和操作码

当你开始研究像 CIL 这样的低级语言时,你肯定会为熟悉的概念找到新的(通常听起来吓人的)名称。例如,在文本的这一点上,如果向您显示以下一组项目:

{new, public, this, base, get, set, explicit, unsafe, enum, operator, partial}

您肯定会认为它们是 C# 语言的关键字(这是正确的)。但是,如果您更仔细地观察这个集合的成员,您可能会发现虽然每个条目确实是一个 C# 关键字,但是它具有完全不同的语义。例如,enum关键字定义了一个System.Enum派生的类型,而thisbase关键字允许你分别引用当前对象或对象的父类。unsafe关键字用于建立一个不能被 CLR 直接监控的代码块,而operator关键字允许你构建一个隐藏的(特别命名的)方法,当你应用一个特定的 C# 操作符(比如加号)时,这个方法将被调用。

与 C# 这样的高级语言形成鲜明对比的是,CIL 本身并不只是简单地定义一组通用的关键字。相反,CIL 编译器所理解的标记集根据语义被细分为以下三大类:

  • cil 指令

  • CIL 属性

  • CIL 操作码

每一类 CIL 令牌都使用特定的语法来表示,并且这些令牌被组合起来以构建有效的。NET 程序集。

CIL 指令的作用

首先,有一组众所周知的 CIL 标记,用于描述. NET 程序集的整体结构。这些令牌被称为指令。CIL 指令用于通知 CIL 编译器如何定义将填充程序集的命名空间、类型和成员。

指令在语法上使用单个点(.)前缀(例如.namespace.class.publickeytoken.method.assembly等)来表示。).因此,如果您的*.il文件(包含 CIL 代码的文件的传统扩展名)有一个.namespace指令和三个.class指令,CIL 编译器将生成一个定义单个。NET 核心命名空间包含三个。NET 核心类类型。

CIL 属性的作用

在许多情况下,CIL 指令本身的描述性不足以完全表达给定的定义。NET 类型或类型成员。鉴于这一事实,许多 CIL 指令可以进一步指定各种 CIL 属性来限定指令应该如何被处理。例如,.class指令可以用public属性(建立类型可见性)、extends属性(显式指定类型的基类)和implements属性(列出该类型支持的接口集)来修饰。

Note

不要混淆. NET 属性和 CIL 属性,这是两个非常不同的概念。

CIL 操作码的作用

一旦使用各种指令和相关属性按照 CIL 定义了. NET 核心程序集、命名空间和类型集,剩下的最后一项任务就是提供类型的实现逻辑。这是操作码,或者简称为操作码的工作。在其他低级语言的传统中,许多 CIL 操作码往往是神秘的,对于我们这些普通人来说完全无法发音。例如,如果你需要将一个string变量加载到内存中,你不需要使用一个友好的操作码LoadString,而是使用ldstr

现在,公平地说,一些 CIL 操作码确实非常自然地映射到它们的 C# 对应物(例如,boxunboxthrowsizeof)。正如您将看到的,CIL 的操作码总是在成员的实现范围内使用,并且不像 CIL 指令,它们从不带有点前缀。

CIL 操作码/CIL 助记符的区别

如前所述,操作码如ldstr用于实现给定类型的成员。然而,像ldstr这样的记号是实际的二进制 CIL 操作码CIL 助记符。为了澄清区别,假设您已经在名为 FirstSamples 的. NET 核心控制台应用中用 C# 编写了以下方法:

int Add(int x, int y)
{
  return x + y;
}

两个数相加的行为用 CIL 操作码0X58来表示。类似地,用操作码0X59来表示减去两个数字,并且使用0X73操作码来实现在托管堆上分配新对象的动作。鉴于这一现实,请理解由 JIT 编译器处理的“CIL 代码”只不过是二进制数据块。

谢天谢地,对于 CIL 的每一个二进制操作码,都有相应的助记符。例如,可以使用add助记符而不是0X58sub而不是0X59newobj而不是0X73。考虑到操作码/助记符的区别,CIL 反编译器如ildasm.exe将汇编的二进制操作码翻译成相应的 CIL 助记符。例如,这里是ildasm.exe为之前的 C# Add()方法提供的 CIL(根据您的版本,您的确切输出可能会有所不同.NETCore):

.method assembly hidebysig static int32 Add(int32 x,int32 y) cil managed
{
  // Code size 9 (0x9)
  .maxstack 2
  .locals init ([0] int32 int32 V_0)
  IL_0000:  /* 00   |                  */ nop
  IL_0001:  /* 02   |                  */ ldarg.0
  IL_0002:  /* 03   |                  */ ldarg.1
  IL_0003:  /* 58   |                  */ add
  IL_0004:  /* 0A   |                  */ stloc.0
  IL_0005:  /* 2B   | 00               */ br.s       IL_0007
  IL_0007:  /* 06   |                  */ ldloc.0
  IL_0008:  /* 2A   |                  */ ret
} //end of method

除非你是建一些极低级的。NET 核心软件(如定制的托管编译器),你将永远不需要关心自己的文字数字二进制操作码的 CIL。实际上,当。NET 核心程序员谈论“CIL 操作码”,他们指的是一组友好的字符串标记助记符(正如我在本文中所做的,并将在本章的剩余部分中做的),而不是底层的数值。

推进和弹出:CIL 基于堆栈的本质

更高级的。NET 核心语言(如 C#)试图尽可能隐藏低级的 CIL 垃圾。的一方面。NET 核心开发中隐藏得特别好的一点是,CIL 是一种基于堆栈的编程语言。回想一下对集合名称空间的检查(参见第十章),Stack<T>类可以用来将一个值压入堆栈,也可以将最顶端的值弹出堆栈以供使用。当然,CIL 开发人员不会使用类型为Stack<T>的对象来加载和卸载要评估的值;然而,同样的推动和弹出心态仍然适用。

从形式上讲,用来保存一组待评估值的实体被称为虚拟执行堆栈。正如您将看到的,CIL 提供了几个操作码,用于将一个值推送到堆栈上;这个过程被称为装载。同样,CIL 定义了额外的操作码,这些操作码使用称为存储的过程将栈顶的值转移到内存中(比如一个局部变量)。

在 CIL 的世界里,不可能直接访问一个数据点,包括本地定义的变量、传入的方法参数或某种类型的字段数据。相反,您需要显式地将该项加载到堆栈中,然后弹出它以备后用(记住这一点,因为它将有助于解释为什么给定的 CIL 代码块看起来有点多余)。

Note

回想一下,CIL 不是直接执行的,而是按需编译的。在编译 CIL 代码的过程中,许多实现冗余被优化掉了。此外,如果为当前项目启用代码优化选项(使用 Visual Studio 项目属性窗口的“生成”选项卡),编译器还将移除各种 CIL 冗余。

为了理解 CIL 如何利用基于堆栈的处理模型,考虑一个简单的 C# 方法PrintMessage(),它没有参数,返回void。在这个方法的实现中,您只需将一个本地字符串变量的值打印到标准输出流中,如下所示:

void PrintMessage()
{
  string myMessage = "Hello.";
  Console.WriteLine(myMessage);
}

如果您要研究 C# 编译器如何根据 CIL 来翻译这个方法,您首先会发现PrintMessage()方法使用.locals指令为局部变量定义了一个存储槽。然后使用ldstr(加载字符串)和stloc.0操作码(可以理解为“将当前值存储在存储槽零的局部变量中”)加载并存储局部字符串。

然后,使用ldloc.0(“在索引 0 处加载本地参数”)操作码将该值(同样在索引 0 处)加载到内存中,以供System.Console.WriteLine()方法调用(使用call操作码指定)使用。最后,函数通过ret操作码返回。下面是PrintMessage()方法的(带注释的)CIL 代码(注意,为了简洁起见,我已经从清单中删除了nop操作码):

.method assembly hidebysig static void PrintMessage() cil managed
{
  .maxstack 1
  // Define a local string variable (at index 0).
  .locals init ([0] string V_0)

  // Load a string onto the stack with the value "Hello."
  ldstr " Hello."

  // Store string value on the stack in the local variable.
  stloc.0

  // Load the value at index 0.
  ldloc.0

  // Call method with current value.
  call void [System.Console]System.Console::WriteLine(string)
  ret
}

Note

如您所见,CIL 支持使用双斜线语法(以及/*...*/语法)的代码注释。和 C# 一样,CIL 编译器完全忽略代码注释。

现在您已经有了 CIL 指令、属性和操作码的基础,让我们看看 CIL 编程的实际应用,从往返工程的主题开始。

了解往返工程

你知道如何使用ildasm.exe来查看 C# 编译器生成的 CIL 代码(参见第一章)。然而,您可能不知道的是,ildasm.exe允许您将加载到ildasm.exe的程序集中包含的 CIL 转储到外部文件。一旦你有了 CIL 代码,你就可以使用 CIL 编译器ilasm.exe自由地编辑和重新编译代码库。

从形式上来说,这种技术被称为往返工程,它在特定的情况下很有用,例如:

  • 您需要修改不再有源代码的程序集。

  • 你正在和一个不完美的人一起工作。NET 核心语言编译器发出了无效(或完全不正确)的 CIL 代码,并且您想要修改代码库。

  • 您正在构建一个 COM 互用性库,并希望解决在转换过程中丢失的一些 COM IDL 属性(如 COM [helpstring]属性)。

为了说明往返过程,首先创建一个新的 C#。NET 核心控制台应用命名为往返使用。NET 核心命令行界面(CLI)。

dotnet new console -lang c# -n RoundTrip -o .\RoundTrip -f net5.0

将顶级语句更新为以下内容:

// A simple C# console app.
Console.WriteLine("Hello CIL code!");
Console.ReadLine();

使用。NET Core CLI。

dotnet build

Note

回忆一下第一章。NET 核心程序集(类库或控制台应用)被编译成扩展名为*.dll的程序集。它们是使用。NET Core CLI。新进。NET Core 3+(及更高版本),将dotnet.exe文件复制到输出目录中,并重命名以匹配程序集名称。因此,虽然看起来像是你的项目被编译到了RoundTrip.exe,,但是它被编译到了RoundTrip.dll,而dotnet.exe被复制到了RoundTrip.exe,同时还有执行Roundtrip.dll.所需的命令行参数

接下来使用以下命令对RoundTrip.dll执行ildasm.exe(从解决方案文件夹级别执行):

ildasm /all /METADATA /out=.\RoundTrip\RoundTrip.il .\RoundTrip\bin\Debug\net5.0\RoundTrip.dll

Note

ildasm.exe在将汇编的内容转储到文件时也会生成一个*.res文件。在本章中,这些资源文件可以被忽略(和删除),因为您不会用到它们。该文件包含一些低级别的 CLR 安全信息(以及其他信息)。

现在您可以使用您选择的文本编辑器查看RoundTrip.il。下面是(稍微重新格式化和注释的)结果:

// Referenced assemblies.
.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A)
  .ver 5:0:0:0
}
.assembly extern System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 5:0:0:0
}

// Our assembly.
.assembly RoundTrip
{
...
  .hash algorithm 0x00008004
  .ver 1:0:0:0
}
.module RoundTrip.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003
.corflags 0x00000001

// Definition of Program class.
.class private abstract auto ansi beforefieldinit '<Program>$'
  extends [System.Runtime]System.Object
{
  .custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
    = ( 01 00 00 00 )
  .method private hidebysig static void  '<Main>$'(string[] args) cil managed
  {
    // Marks this method as the entry point of the executable.
    .entrypoint
    .maxstack  8
    IL_0000:  ldstr "Hello CIL code!"
    IL_0005:  call   void [System.Console]System.Console::WriteLine(string)
    IL_000a:  nop
    IL_000b:  call  string [System.Console]System.Console::ReadLine()
    IL_0010:  pop
    IL_0011:  ret
  } // end of method '<Program>$'::'<Main>$'
} // end of class '<Program>$'

首先,请注意,*.il文件是通过声明每个外部引用的程序集来打开的,当前程序集是针对该程序集编译的。如果你的类库在其他引用的程序集中使用了额外的类型(除了System.RuntimeSystem.Console),你会发现额外的.assembly extern指令。

接下来,您会发现使用各种 CIL 指令(例如.module.imagebase等)描述的RoundTrip.dll程序集的正式定义。).

在记录外部引用的程序集并定义当前程序集之后,您会发现一个从顶级语句创建的Program类型的定义。请注意,.class指令有各种属性(其中许多是可选的),如这里所示的extends,它标记了该类型的基类:

.class private abstract auto ansi beforefieldinit '<Program>$'
  extends [System.Runtime]System.Object
{ ... }

大部分 CIL 代码代表了类的默认构造函数和自动生成的Main()方法的实现,这两者都是用.method指令定义的(部分)。一旦使用正确的指令和属性定义了成员,就可以使用各种操作码来实现它们。

了解这一点非常重要。NET 核心类型(比如 CIL 的System.Console),你会总是需要使用该类型的完全限定名。此外,类型的完全限定名必须始终以定义程序集的友好名称为前缀(在方括号中)。考虑下面的Main()的 CIL 实现:

  .method private hidebysig static void  '<Main>$'(string[] args) cil managed
  {
    // Marks this method as the entry point of the executable.
    .entrypoint
    .maxstack  8
    IL_0000:  ldstr "Hello CIL code!"
    IL_0005:  call   void [System.Console]System.Console::WriteLine(string)
    IL_000a:  nop
    IL_000b:  call  string [System.Console]System.Console::ReadLine()
    IL_0010:  pop
    IL_0011:  ret
  } // end of method '<Program>$'::'<Main>$'

CIL 代码标签的作用

你肯定注意到的一件事是每一行实现代码。

以形式为IL_XXX:的标记为前缀(例如IL_0000:IL_0001:等)。).这些标记被称为代码标签,可以以您选择的任何方式命名(前提是它们在同一个成员范围内不重复)。当您使用ildasm.exe将一个程序集转储到文件时,它将自动生成遵循IL_XXX:命名约定的代码标签。但是,您可以更改它们以反映更具描述性的标记。这里有一个例子:

.method private hidebysig static void Main(string[] args) cil managed
{
  .entrypoint
  .maxstack 8
  Nothing_1: nop
  Load_String: ldstr "Hello CIL code!"
  PrintToConsole: call void [System.Console]System.Console::WriteLine(string)
  Nothing_2: nop
  WaitFor_KeyPress: call string [System.Console]System.Console::ReadLine()
  RemoveValueFromStack: pop
  Leave_Function: ret
}

事实是,大多数代码标签是完全可选的。代码标签唯一真正必需的时候是在你编写使用各种分支或循环结构的 CIL 代码的时候,因为你可以通过这些代码标签指定逻辑流向哪里。对于当前示例,您可以完全删除这些自动生成的标签,而不会产生不良影响,如下所示:

.method private hidebysig static void Main(string[] args) cil managed
{
  .entrypoint
  .maxstack 8
  nop
  ldstr "Hello CIL code!"
  call void [System.Console]System.Console::WriteLine(string)
  nop
  call string [System.Console]System.Console::ReadLine()
  pop
  ret
}

与 CIL 互动:修改一个*。il 文件

现在,您对基本的 CIL 文件是如何构成的有了更好的理解,让我们完成往返实验。这里的目标很简单:改变输出到控制台的消息。您可以做更多的事情,比如添加程序集引用或创建新的类和方法,但我们将保持简单。

要进行更改,您需要改变顶级语句的当前实现,创建为<Main>$方法。在*.il文件中找到这个方法,并将消息改为“Hello from altered CIL 代码!”

实际上,您已经更新了 CIL 代码,以对应下面的 C# 类定义:

static void Main(string[] args)
{
  Console.WriteLine("Hello from altered CIL code!");
  Console.ReadLine();
}

编译 CIL 代码

以前版本的。NET 允许你使用ilasm.exe.编译*.il文件,这在。NET 核心。要编译*.il文件,您必须使用一个Microsoft.NET.Sdk.IL项目类型,在撰写本文时,这仍然不是标准 SDK 的一部分。

首先在您的机器上创建一个新目录。在这个目录中,创建一个global.json文件。global.json文件适用于当前目录及其下的所有子目录。它用于定义运行时将使用哪个 SDK 版本。NET Core CLI 命令。将文件更新为以下内容:

{
  "msbuild-sdks": {
    "Microsoft.NET.Sdk.IL": "5.0.0-preview.1.20120.5"
  }
}

下一步是创建项目文件。创建一个名为RoundTrip.ilproj的文件,并将其更新为以下内容:

<Project Sdk="Microsoft.NET.Sdk.IL">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
    <MicrosoftNetCoreIlasmPackageVersion>5.0.0-preview.1.20120.5</MicrosoftNetCoreIlasmPackageVersion>
    <ProduceReferenceAssembly>false</ProduceReferenceAssembly>
  </PropertyGroup>
</Project>

最后,将更新后的RoundTrip.il文件复制到目录中。使用编译该程序集。NET Core CLI:

dotnet build

您将在常用的bin\debug\net5.0文件夹中找到结果文件。此时,您可以运行您的新应用了。果然,您将看到控制台窗口中显示更新的消息。虽然这个简单示例的输出并不那么壮观,但它确实说明了编程在 CIL 的一个实际应用:往返。

了解 CIL 指令和属性

现在您已经了解了如何转换。NET 核心程序集编译成 IL 并将 IL 编译成程序集之后,您就可以着手检查 CIL 本身的语法和语义了。接下来的几节将带您完成创作包含一组类型的自定义名称空间的过程。然而,为了简单起见,这些类型不会包含其成员的任何实现逻辑。在理解了如何创建空类型之后,就可以将注意力转向使用 CIL 操作码定义“真实”成员的过程了。

在 CIL 指定外部引用的程序集

在一个新目录中,复制上一个示例中的global.jsonNuGet.config文件。创建一个名为CILTypes.ilproj,的新项目文件,并将其更新为:

<Project Sdk="Microsoft.NET.Sdk.IL">
  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
    <MicrosoftNetCoreIlasmPackageVersion>5.0.0-preview.1.20120.5</MicrosoftNetCoreIlasmPackageVersion>
    <ProduceReferenceAssembly>false</ProduceReferenceAssembly>
  </PropertyGroup>
</Project>

接下来,使用您选择的编辑器创建一个名为CILTypes.il的新文件。CIL 项目要求的第一项任务是列出当前程序集使用的外部程序集。对于这个例子,您将只使用在System.Runtime.dll中找到的类型。为此,将使用external属性来限定.assembly指令。当你引用一个强命名的程序集时,比如System.Runtime.dll,你会想要指定.publickeytoken.ver指令,就像这样:

.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 5:0:0:0
}

定义 CIL 的当前程序集

下一步是使用.assembly指令定义您感兴趣的程序集。在最简单的层次上,可以通过指定二进制文件的友好名称来定义程序集,如下所示:

// Our assembly.
.assembly CILTypes { }

虽然这确实定义了一个新的。NET 核心程序集,您通常会在程序集声明的范围内放置附加指令。对于本例,使用.ver指令更新您的程序集定义以包括版本号 1.0.0.0(注意每个数字标识符由冒号分隔,而不是以 C# 为中心的点符号),如下所示:

// Our assembly.
.assembly CILTypes
{
  .ver 1:0:0:0
}

假设CILTypes程序集是一个单文件程序集,您将使用下面的单个.module指令来完成程序集定义,该指令标记了您的。CILTypes.dll净二进制:

.assembly CILTypes
{
  .ver 1:0:0:0
}
// The module of our single-file assembly.
.module CILTypes.dll

除了.assembly.module之外,CIL 指令进一步限定了的整体结构。您正在编写的. NET 二进制文件。表 19-1 列出了一些更常见的汇编级指令。

表 19-1。

其他以程序集为中心的指令

|

管理的

|

生命的意义

.mresources 如果您的程序集使用内部资源(如位图或字符串表),此指令用于标识包含要嵌入的资源的文件的名称。
.subsystem 这个 CIL 指令用于建立程序集希望在其中执行的首选用户界面。例如,2的值表示程序集应该在 GUI 应用中运行,而3的值表示控制台可执行程序。

在 CIL 中定义名称空间

既然已经定义了程序集的外观(以及所需的外部引用),就可以使用.namespace指令创建一个. NET 核心命名空间(MyNamespace),如下所示:

// Our assembly has a single namespace.
.namespace MyNamespace {}

像 C# 一样,CIL 命名空间定义可以嵌套在更多的命名空间中。这里不需要定义根命名空间;然而,为了便于讨论,假设您想要创建以下名为MyCompany的根名称空间:

.namespace MyCompany
{
  .namespace MyNamespace {}
}

像 C# 一样,CIL 允许您定义嵌套的名称空间,如下所示:

// Defining a nested namespace.
.namespace MyCompany.MyNamespace {}

在 CIL 定义分类类型

空的名称空间并不十分有趣,所以现在让我们看看使用 CIL 定义类类型的过程。毫不奇怪,.class指令被用来定义一个新的类。但是,这个简单的指令可以用许多附加属性来修饰,以进一步限定类型的性质。举例来说,向名为MyBaseClass的名称空间添加一个公共类。和 C# 一样,如果你不指定一个显式基类,你的类型将自动从System.Object派生。

.namespace MyNamespace
{
  // System.Object base class assumed.
  .class public MyBaseClass {}
}

当您构建一个从除了System.Object之外的任何类派生的类类型时,您使用extends属性。每当您需要引用在同一程序集中定义的类型时,CIL 要求您也使用完全限定名(但是,如果基类型在同一程序集中,您可以省略程序集的友好名称前缀)。因此,以下扩展MyBaseClass的尝试会导致编译器错误:

// This will not compile!
.namespace MyNamespace
{
  .class public MyBaseClass {}

  .class public MyDerivedClass
    extends MyBaseClass {}
}

为了正确定义MyDerivedClass的父类,您必须指定MyBaseClass的全名,如下所示:

// Better!
.namespace MyNamespace
{
  .class public MyBaseClass {}

  .class public MyDerivedClass
    extends MyNamespace.MyBaseClass {}
}

除了publicextends属性之外,CIL 类定义可能会采用许多额外的限定符来控制类型的可见性、字段布局等等。表 19-2 说明了一些(但不是全部)可能与.class指令结合使用的属性。

表 19-2。

.class指令结合使用的各种属性

|

属性

|

生命的意义

publicprivatenested assemblynested famandassemnested familynested famorassemnested publicnested private CIL 定义了各种属性,用于指定给定类型的可见性。正如你所看到的,除了 C# 提供的,原始 CIL 还提供了许多其他的可能性。如有兴趣,请参考 ECMA 335 了解详情。
abstractsealed 这两个属性可以附加到一个.class指令上,分别定义一个抽象类或密封类。
autosequentialexplicit 这些属性用于指示 CLR 如何在内存中布置字段数据。对于类类型,默认布局标志(auto)是合适的。如果您需要使用 P/Invoke 来调用非托管 C 代码,更改此默认值会很有帮助。
extendsimplements 这些属性允许你定义一个类型的基类(通过extends)或者实现一个类型的接口(通过implements)。

在 CIL 中定义和实现接口

看起来很奇怪,接口类型在 CIL 是用.class指令定义的。然而,当.class指令用interface属性修饰时,该类型被实现为 CTS 接口类型。一旦定义了一个接口,就可以使用 CIL implements属性将它绑定到一个类或结构类型,如下所示:

.namespace MyNamespace
{
  // An interface definition.
  .class public interface IMyInterface {}

  // A simple base class.
  .class public MyBaseClass {}

  // MyDerivedClass now implements IMyInterface,
  // and extends MyBaseClass.
  .class public MyDerivedClass
    extends MyNamespace.MyBaseClass
    implements MyNamespace.IMyInterface {}
}

Note

extends子句必须在implements子句之前。同样,implements子句可以包含逗号分隔的接口列表。

正如你在第十章中回忆的那样,接口可以作为其他接口类型的基础接口来构建接口层次结构。然而,与您的想法相反,extends属性不能用于从接口 b 派生接口 A。extends属性仅用于限定类型的基类。当您想要扩展一个接口时,您将再次使用implements属性。这里有一个例子:

// Extending interfaces in terms of CIL.
.class public interface IMyInterface {}

.class public interface IMyOtherInterface
  implements MyNamespace.IMyInterface {}

定义 CIL 的结构

如果类型扩展了System.ValueType,则.class指令可用于定义 CTS 结构。同样,.class指令必须用sealed属性限定(因为结构永远不能成为其他值类型的基础结构)。如果你试图不这样做,ilasm.exe将发布一个编译错误。

// A structure definition is always sealed.
.class public sealed MyStruct
  extends [System.Runtime]System.ValueType{}

请注意,CIL 提供了一种定义结构类型的简写符号。如果您使用value属性,新类型将自动从[System.Runtime]System.ValueType派生类型。因此,您可以将MyStruct定义如下:

// Shorthand notation for declaring a structure.
.class public sealed value MyStruct{}

在 CIL 定义枚举

。NET Core 枚举(正如您回忆的那样)源自System.Enum,它是一个System.ValueType(因此也必须是密封的)。当你想用 CIL 定义一个枚举时,只需扩展[System.Runtime]System.Enum,就像这样:

// An enum.
.class public sealed MyEnum
  extends [System.Runtime]System.Enum{}

像结构定义一样,枚举可以用简写符号使用enum属性来定义。这里有一个例子:

// Enum shorthand.
.class public sealed enum MyEnum{}

稍后您将看到如何指定枚举的名称-值对。

在 CIL 定义仿制药

泛型类型在 CIL 语法中也有特定的表示。回想一下第十章中,一个给定的泛型类型或泛型成员可能有一个或多个类型参数。例如,List<T>类型只有一个类型参数,而Dictionary<TKey, TValue>有两个。就 CIL 而言,类型参数的数量是使用一个向后倾斜的单引号(```cs)来指定的,后跟一个表示类型参数数量的数值。像 C# 一样,类型参数的实际值被放在尖括号内。

Note

在美式键盘上,您可以在 Tab 键上方的键上找到`字符(在 1 键的左侧)。

例如,假设您想要创建一个List<T>变量,其中T的类型是System.Int32。在 C# 中,您应该键入以下内容:

void SomeMethod()
{
  List<int> myInts = new List<int>();
}

```cs

在 CIL,您将编写以下代码(它可能出现在任何 CIL 方法范围内):

// In C#: List myInts = new List();
newobj instance void class [System.Collections]
System.Collections.Generic.List`1::.ctor()


注意,这个泛型类被定义为`List`1<int32>`,因为`List<T>`只有一个类型参数。然而,如果你需要定义一个`Dictionary<string, int>`类型,你可以这样做:

// In C#: Dictionary<string, int> d = new Dictionary<string, int>();
newobj instance void class [System.Collections]
System.Collections.Generic.Dictionary`2<string,int32>
::.ctor()


作为另一个示例,如果您有一个使用另一个泛型类型作为类型参数的泛型类型,您将编写如下所示的 CIL 代码:

// In C#: List<List> myInts = new List<List>();
newobj instance void class [mscorlib]
System.Collections.Generic.List1<class [System.Collections] System.Collections.Generic.List1>
::.ctor()


### 编译 CILTypes.il 文件

即使您还没有向您定义的类型添加任何成员或实现代码,您也能够将这个`*.il`文件编译成一个. NET 核心 DLL 程序集(这是您必须做的,因为您还没有指定一个`Main()`方法)。打开命令提示符,输入以下命令:

dotnet build


这样做之后,现在可以在`ildasm.exe`中打开编译后的程序集来验证每个类型的创建。要理解如何用内容填充类型,首先需要研究 CIL 的基本数据类型。

## 。NET 基类库、C# 和 CIL 数据类型映射

表 19-3 说明了. NET 基类类型如何映射到相应的 C# 关键字,以及每个 C# 关键字如何映射到原始 CIL。同样,表 19-3 记录了用于每种 CIL 类型的简写常量符号。正如你马上会看到的,这些常量经常被许多 CIL 操作码引用。

表 19-3。

映射。NET 基类类型转换为 C# 关键字,C# 关键字转换为 CIL

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"> <col class="tcol3 align-left"> <col class="tcol4 align-left"></colgroup> 
| 

。NET Core 基类类型

 | 

C# 关键字

 | 

CIL 代表

 | 

CIL 常量符号

 |
| --- | --- | --- | --- |
| `System.SByte` | `sbyte` | `int8` | `I1` |
| `System.Byte` | `byte` | `unsigned int8` | `U1` |
| `System.Int16` | `short` | `int16` | `I2` |
| `System.UInt16` | `ushort` | `unsigned int16` | `U2` |
| `System.Int32` | `int` | `int32` | `I4` |
| `System.UInt32` | `uint` | `unsigned int32` | `U4` |
| `System.Int64` | `long` | `int64` | `I8` |
| `System.UInt64` | `ulong` | `unsigned int64` | `U8` |
| `System.Char` | `char` | `char` | `CHAR` |
| `System.Single` | `float` | `float32` | `R4` |
| `System.Double` | `double` | `float64` | `R8` |
| `System.Boolean` | `bool` | `bool` | `BOOLEAN` |
| `System.String` | `string` | `string` | 不适用的 |
| `System.Object` | `object` | `object` | 不适用的 |
| `System.Void` | `void` | `void` | `VOID` |

Note

`System.IntPtr`和`System.UIntPtr`类型映射到本机`int`和本机`unsigned int`(知道这一点很好,因为许多 COM 互操作性和 P/Invoke 场景广泛使用这些)。

## 在 CIL 中定义类型成员

正如你已经知道的。NET 核心类型可以支持各种成员。枚举有一些名称-值对。结构和类可能有构造函数、字段、方法、属性、静态成员等等。在本书的前 18 章中,您已经看到了前面提到的项目的部分 CIL 定义,但是尽管如此,这里还是快速回顾一下各种成员如何映射到 CIL 原语。

### 定义 CIL 的字段数据

枚举、结构和类都可以支持字段数据。在每种情况下,都将使用`.field`指令。例如,让我们给骨架`MyEnum`枚举注入一些活力,并定义以下三个名称-值对(注意这些值是在括号内指定的):

.class public sealed enum MyEnum
{
.field public static literal valuetype
MyNamespace.MyEnum A = int32(0)
.field public static literal valuetype
MyNamespace.MyEnum B = int32(1)
.field public static literal valuetype
MyNamespace.MyEnum C = int32(2)
}


使用`static`和`literal`属性限定位于. NET 核心`System.Enum`派生类型范围内的字段。正如您所猜测的,这些属性将字段数据设置为可从类型本身访问的固定值(例如,`MyEnum.A`)。

Note

分配给枚举值的值也可以是带有`0x`前缀的十六进制值。

当然,当您想要在类或结构中定义一个字段数据点时,您并不局限于一个公共静态文本数据点。例如,您可以更新`MyBaseClass`来支持两点私有的、实例级的字段数据,设置为默认值。

.class public MyBaseClass


与 C# 一样,类字段数据将自动初始化为适当的默认值。如果您希望允许对象用户在创建私有字段数据的每个点时提供自定义值,您(当然)需要创建自定义构造函数。

### 在 CIL 中定义类型构造函数

CTS 支持实例级和类级(静态)构造函数。根据 CIL,实例级构造函数使用`.ctor`标记表示,而静态级构造函数通过`.cctor`(类构造函数)表示。两个 CIL 令牌都必须使用`rtspecialname`(返回类型特殊名称)和`specialname`属性进行限定。简而言之,这些属性用于标识一个特定的 CIL 令牌,该令牌可以由给定的。NET 核心语言。例如,在 C# 中,构造函数不定义返回类型;然而,就 CIL 而言,构造函数的返回值确实是`void`。

.class public MyBaseClass
{
.field private string stringField
.field private int32 intField

.method public hidebysig specialname rtspecialname
instance void .ctor(string s, int32 i) cil managed
{
// TODO: Add implementation code...
}
}


注意,`.ctor`指令已经用`instance`属性限定了(因为它不是一个静态构造函数)。`cil managed`属性表示该方法的范围包含 CIL 代码,而不是非托管代码,这些代码可能会在平台调用请求期间使用。

### 在 CIL 定义属性

属性和方法也有特定的 CIL 表示。举例来说,如果`MyBaseClass`被更新以支持名为`TheString`的公共属性,您将编写以下 CIL(再次注意`specialname`属性的使用):

.class public MyBaseClass
{
...
.method public hidebysig specialname
instance string get_TheString() cil managed
{
// TODO: Add implementation code...
}

.method public hidebysig specialname
instance void set_TheString(string 'value') cil managed
{
// TODO: Add implementation code...
}

.property instance string TheString()
{
.get instance string
MyNamespace.MyBaseClassget_TheString()
.set instance void
MyNamespace.MyBaseClass
set_TheString(string)
}
}


根据 CIL,一个属性映射到一对带有前缀`get_`和`set_`的方法。`.property`指令利用相关的`.get`和`.set`指令将属性语法映射到正确的“专门命名的”方法。

Note

请注意,属性的`set`方法的传入参数放在单引号中,单引号表示在方法范围内赋值运算符右侧使用的标记的名称。

### 定义成员参数

简而言之,在 CIL 中指定参数(或多或少)与在 C# 中相同。例如,每个参数都是通过指定其数据类型,后跟参数名称来定义的。此外,像 C# 一样,CIL 提供了一种定义输入、输出和按引用传递参数的方法。同样,CIL 允许你定义一个参数数组实参(又名 C# `params`关键字),以及可选的参数。

为了说明在原始 CIL 中定义参数的过程,假设您想要构建一个方法,该方法采用一个`int32`(按值)、一个`int32`(按引用)、一个`[mscorlib]System.Collection.ArrayList`和一个单一输出参数(类型为`int32`)。就 C# 而言,此方法类似于以下内容:

public static void MyMethod(int inputInt,
ref int refInt, ArrayList ar, out int outputInt)
{
outputInt = 0; // Just to satisfy the C# compiler...
}


如果您将这个方法映射到 CIL 术语中,您会发现 C# 引用参数用一个&符号(`&`)标记,其后缀是参数的基础数据类型(`int32&`)。

输出参数也使用`&`后缀,但是它们使用 CIL `[out]`标记进一步限定。还要注意,如果参数是引用类型(在本例中是`[mscorlib]System.Collections.ArrayList`类型),那么`class`标记将作为数据类型的前缀(不要与`.class`指令混淆!).

.method public hidebysig static void MyMethod(int32 inputInt,
int32& refInt,
class [System.Runtime.Extensions]System.Collections.ArrayList ar,
[out] int32& outputInt) cil managed


## 检查 CIL 操作码

你将在本章研究的 CIL 码的最后一个方面与各种操作码(操作码)的作用有关。回想一下,操作码只是一个 CIL 令牌,用于为给定成员构建实现逻辑。完整的 CIL 操作码集(很大)可以分为以下几大类:

*   控制程序流程的操作码

*   计算表达式的操作码

*   访问内存值的操作码(通过参数、局部变量等。)

为了通过 CIL 提供对成员实现世界的一些洞察,表 19-4 定义了一些与成员实现逻辑相关的更有用的操作码,按相关功能分组。

表 19-4。

各种特定于实现的 CIL 操作码

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

操作码

 | 

生命的意义

 |
| --- | --- |
| `add`、`sub`、`mul`、`div`、`rem` | 这些 CIL 操作码允许你加、减、乘、除两个值(`rem`返回除法运算的余数)。 |
| `and`、`or`、`not`、`xor` | 这些 CIL 操作码允许您对两个值执行逐位运算。 |
| `ceq`、`cgt`、`clt` | 这些 CIL 操作码允许你以不同的方式比较堆栈上的两个值。以下是一些例子:`ceq`:比较是否相等`cgt`:比较大于`clt`:比较小于 |
| `box`,`unbox` | 这些 CIL 操作码用于在引用类型和值类型之间进行转换。 |
| `Ret` | 这个 CIL 操作码用于退出一个方法并向调用者返回值(如果需要的话)。 |
| `beq`、`bgt`、`ble`、`blt`、`switch` | 这些 CIL 操作码(以及许多其他相关操作码)用于控制方法内的分支逻辑。以下是一些例子:`beq`:如果相等,则断开代码标签`bgt`:如果大于,则断开代码标签`ble`:如果小于或等于,则中断到代码标签`blt`:如果小于,则中断到代码标签所有以分支为中心的操作码都要求您指定一个 CIL 代码标签,以便在测试结果为真时跳转到该标签。 |
| `Call` | 这个 CIL 操作码用于调用给定类型的成员。 |
| `newarr`,`newobj` | 这些 CIL 操作码允许你分配一个新的数组或新的对象类型到内存中。 |

下一大类 CIL 操作码(其子集如表 19-5 所示)用于将参数加载(推送)到虚拟执行堆栈上。请注意这些特定于加载的操作码是如何使用`ld` (load)前缀的。

表 19-5。

CIL 的主要堆栈中心操作码

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

操作码

 | 

生命的意义

 |
| --- | --- |
| `ldarg`(变化多端) | 将方法的参数加载到堆栈上。除了一般的`ldarg`(它与标识参数的给定索引一起工作),还有许多其他的变化。例如,`ldarg`带有数字后缀(`ldarg.0`)的操作码硬编码加载哪个参数。同样,`ldarg`操作码的变体允许您使用表 19-4 ( `ldarg_I4`表示`int32`)中所示的 CIL 常量符号硬编码数据类型,以及数据类型和值(`ldarg_I4_5`,用值`5`加载`int32`)。 |
| `ldc`(变化多端) | 将常量值加载到堆栈上。 |
| `ldfld`(变化多端) | 将实例级字段的值加载到堆栈上。 |
| `ldloc`(变化多端) | 将局部变量的值加载到堆栈上。 |
| `Ldobj` | 获取由基于堆的对象收集的所有值,并将它们放在堆栈上。 |
| `Ldstr` | 将字符串值加载到堆栈上。 |

除了一组特定于加载的操作码,CIL 还提供了许多操作码,*显式地*弹出栈顶的值。如本章前几个例子所示,从堆栈中弹出一个值通常涉及到将该值存储到临时本地存储中以备将来使用(如即将到来的方法调用的参数)。鉴于此,请注意有多少从虚拟执行堆栈中弹出当前值的操作码带有`st`(存储)前缀。表 19-6 击中亮点。

表 19-6。

各种以流行为中心的操作码

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

操作码

 | 

生命的意义

 |
| --- | --- |
| `Pop` | 移除当前位于计算堆栈顶部的值,但不存储该值 |
| `Starg` | 将堆栈顶部的值存储到指定索引处的方法参数中 |
| `stloc`(变化多端) | 从计算堆栈顶部弹出当前值,并将其存储在指定索引处的局部变量列表中 |
| `Stobj` | 将指定类型的值从计算堆栈复制到提供的内存地址中 |
| `Stsfld` | 用计算堆栈中的值替换静态字段的值 |

请注意,各种 CIL 操作码将*隐式*从堆栈中弹出值来执行手头的任务。例如,如果您试图使用`sub`操作码减去两个数字,应该清楚的是`sub`必须弹出下两个可用值才能执行计算。一旦计算完成,值(surprise,surprise)的结果再次被压入堆栈。

### 那个。maxstack 指令

当您使用原始 CIL 编写方法实现时,您需要注意一个名为`.maxstack`的特殊指令。顾名思义,`.maxstack`建立了在方法执行期间的任何给定时间可以被推到堆栈上的变量的最大数量。好消息是,`.maxstack`指令有一个默认值(`8`,对于您可能正在创作的绝大多数方法来说,这应该是安全的。但是,如果您想要显式的,您可以手动计算堆栈上局部变量的数量,并显式定义该值,如下所示:

.method public hidebysig instance void
Speak() cil managed
{
// During the scope of this method, exactly
// 1 value (the string literal) is on the stack.
.maxstack 1
ldstr "Hello there..."
call void [mscorlib]System.Console::WriteLine(string)
ret
}


### 在 CIL 声明局部变量

让我们先看看如何声明一个局部变量。假设您想在 CIL 构建一个名为`MyLocalVariables()`的方法,该方法不带参数并返回`void`。在这个方法中,您想要定义三个类型为`System.String`、`System.Int32`和`System.Object`的局部变量。在 C# 中,此成员将如下所示(回想一下,局部作用域的变量不接收默认值,应该在进一步使用之前设置为初始状态):

public static void MyLocalVariables()
{
string myStr = "CIL code is fun!";
int myInt = 33;
object myObj = new object();
}


如果您要在 CIL 直接构建`MyLocalVariables()`,您可以编写以下代码:

.method public hidebysig static void
MyLocalVariables() cil managed
{
.maxstack 8
// Define three local variables.
.locals init (string myStr, int32 myInt, object myObj)
// Load a string onto the virtual execution stack.
ldstr "CIL code is fun!"
// Pop off current value and store in local variable [0].
stloc.0

// Load a constant of type "i4"
// (shorthand for int32) set to the value 33.
ldc.i4.s 33
// Pop off current value and store in local variable [1].
stloc.1

// Create a new object and place on stack.
newobj instance void [mscorlib]System.Object::.ctor()
// Pop off current value and store in local variable [2].
stloc.2
ret
}


在原始 CIL 中分配局部变量的第一步是使用`.locals`指令,它与`init`属性成对出现。每个变量由其数据类型和可选的变量名来标识。定义局部变量后,将值加载到堆栈上(使用各种以加载为中心的操作码)并将值存储在局部变量中(使用各种以存储为中心的操作码)。

### 将参数映射到 CIL 的本地变量

您已经看到了如何使用`.locals init`指令在原始 CIL 中声明局部变量;但是,您还没有看到如何将传入的参数映射到本地方法。考虑下面的静态 C# 方法:

public static int Add(int a, int b)
{
return a + b;
}


就 CIL 而言,这种看似无辜的方法有很多话要说。首先,必须使用`ldarg`(加载参数)操作码将传入的参数(`a`和`b`)推送到虚拟执行堆栈上。接下来,`add`操作码将用于从堆栈中弹出接下来的两个值,找到总和并将值再次存储在堆栈中。最后,这个总和被弹出堆栈,并通过`ret`操作码返回给调用者。如果您使用`ildasm.exe`反汇编这个 C# 方法,您会发现构建过程注入了许多额外的标记,但是 CIL 代码的关键非常简单。

.method public hidebysig static int32 Add(int32 a,
int32 b) cil managed
{
.maxstack 2
ldarg.0 // Load "a" onto the stack.
ldarg.1 // Load "b" onto the stack.
add // Add both values.
ret
}


### 隐藏此引用

注意,假定虚拟执行堆栈从位置 0 开始索引,那么两个传入参数(`a`和`b`)在 CIL 代码中使用它们的索引位置(索引 0 和索引 1)来引用。

在检查或创作 CIL 代码时要注意的一点是,每个接受传入参数的非静态方法都会自动接收一个隐式附加参数,该参数是对当前对象的引用(如 C# `this`关键字)。鉴于此,如果将`Add()`方法定义为*非静态*,就像这样:

// No longer static!
public int Add(int a, int b)
{
return a + b;
}


然后使用`ldarg.1`和`ldarg.2`加载传入的`a`和`b`参数(而不是预期的`ldarg.0`和`ldarg.1`操作码)。同样,原因是槽 0 包含隐式的`this`引用。考虑下面的伪代码:

// This is JUST pseudocode!
.method public hidebysig static int32 AddTwoIntParams(
MyClass_HiddenThisPointer this, int32 a, int32 b) cil managed
{
ldarg.0 // Load MyClass_HiddenThisPointer onto the stack.
ldarg.1 // Load "a" onto the stack.
ldarg.2 // Load "b" onto the stack.
...
}


### 在 CIL 中表示迭代构造

C# 编程语言中的迭代结构使用`for`、`foreach`、`while`和`do`关键字来表示,其中每一个关键字在 CIL 中都有特定的表示。考虑下面这个经典的`for`循环:

public static void CountToTen()
{
for(int i = 0; i < 10; i++)

}


现在,你可能还记得,`br`操作码(`br`、`blt`等)。)用于在满足某些条件时控制流动的中断。在本例中,您已经设置了一个条件,当本地变量`i`等于或大于值 10 时,`for`循环应该中断。每次通过时,值 1 被加到`i`,此时再次评估测试条件。

还记得当您使用任何 CIL 分支操作码时,您将需要定义一个特定的代码标签(或两个)来标记当条件确实为真时跳转到的位置。考虑到这几点,思考下面通过`ildasm.exe`生成的(编辑过的)CIL 代码(包括自动生成的代码标签):

.method public hidebysig static void CountToTen() cil managed
{
.maxstack 2
.locals init (int32 V_0, bool V_1)
IL_0000: ldc.i4.0 // Load this value onto the stack.
IL_0001: stloc.0 // Store this value at index "0".
IL_0002: br.s IL_000b // Jump to IL_0008.
IL_0003: ldloc.0 // Load value of variable at index 0.
IL_0004: ldc.i4.1 // Load the value "1" on the stack.
IL_0005: add // Add current value on the stack at index 0.
IL_0006: stloc.0
IL_0007: ldloc.0 // Load value at index "0".
IL_0008: ldc.i4.s 10 // Load value of "10" onto the stack.
IL_0009: clt // check less than value on the stack
IL_000a: stloc.1 // Store result at index "1"
IL_000b: ldloc.1 // Load value at index "1"
IL_000c: brtrue.s IL_0002 // if true jump back to IL_0002
IL_000d: ret
}


简而言之,这段 CIL 代码从定义局部变量`int32`并将其加载到堆栈开始。此时,您在代码标签`IL_0008`和`IL_0004`之间来回跳转,每次将`i`的值增加 1,并测试`i`是否仍然小于值 10。如果是,则退出该方法。

### 关于 CIL 的最后一句话

现在您已经看到了从一个`*.IL`文件创建可执行文件的过程,您可能会想“这是一个可怕的工作量”,然后想知道“有什么好处?”对于绝大多数人来说,您永远不会从 IL 创建一个. NET 核心可执行文件。但是,如果您试图深入研究一个没有源代码的程序集,能够理解 IL 会很有帮助。

也有一些商业项目可以将. NET 核心程序集逆向工程成源代码。如果你曾经使用过这些工具,现在你知道它们是如何工作的了!

## 了解动态程序集

可以肯定的是,建造一个综合体的过程。NET 核心应用在 CIL 将是相当爱的劳动。一方面,CIL 是一种极具表现力的编程语言,它允许你与 cts 允许的所有编程结构进行交互。另一方面,创作原始 CIL 是单调乏味、容易出错和痛苦的。诚然,知识就是力量,但你可能真的想知道记住 CIL 语法法则有多重要。答案是“看情况。”可以肯定的是,你的大部分。NET 核心编程工作不需要您查看、编辑或创作 CIL 代码。然而,有了 CIL 初级读本,你现在可以研究动态程序集的世界(相对于静态程序集)和`System.Reflection.Emit`名称空间的角色了。

你可能有的第一个问题是“静态和动态程序集之间到底有什么区别?”根据定义,*静态程序集*是。NET 二进制文件直接从磁盘存储中加载,这意味着当 CLR 请求它们时,它们位于硬盘上的某个物理文件中(或者在多文件程序集的情况下可能是一组文件)。正如你可能猜到的,每次你编译你的 C# 源代码,你都会得到一个静态汇编。

另一方面,*动态程序集*是使用`System.Reflection.Emit`名称空间提供的类型在内存中动态创建的。`System.Reflection.Emit`名称空间使得在*运行时*创建一个程序集及其模块、类型定义和 CIL 实现逻辑成为可能。完成之后,您就可以将内存中的二进制文件保存到磁盘上了。这当然会产生一个新的静态程序集。可以肯定的是,使用`System.Reflection.Emit`命名空间构建动态程序集的过程确实需要对 CIL 操作码的本质有一定程度的理解。

尽管创建动态程序集是一项高级(且不常见)的编程任务,但它们在各种情况下都很有用。这里有一个例子:

*   您正在构建一个需要根据用户输入按需生成程序集的. NET 编程工具。

*   您正在构建一个程序,该程序需要根据获得的元数据动态生成远程类型的代理。

*   您希望加载静态程序集并将新类型动态插入二进制映像。

让我们来看看`System.Reflection.Emit`中的类型。

### 探索系统。Reflection.Emit 命名空间

创建一个动态程序集需要你对 CIL 操作码有一些熟悉,但是`System.Reflection.Emit`命名空间的类型尽可能地隐藏了 CIL 的复杂性。例如,不用指定必要的 CIL 指令和属性来定义类类型,您可以简单地使用`TypeBuilder`类。同样,如果您想定义一个新的实例级构造函数,您不需要发出`specialname`、`rtspecialname`或`.ctor`标记;相反,你可以使用`ConstructorBuilder`。表 19-7 记录了`System.Reflection.Emit`名称空间的关键成员。

表 19-7。

选择`System.Reflection.Emit`名称空间的成员

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

成员

 | 

生命的意义

 |
| --- | --- |
| `AssemblyBuilder` | 用于在运行时创建一个装配(`*.dll`或`*.exe`)。`*.exe` s 必须调用`ModuleBuilder.SetEntryPoint()`方法来设置作为模块入口点的方法。如果没有指定入口点,将会生成一个`*.dll`。 |
| `ModuleBuilder` | 用于定义当前程序集中的模块集。 |
| `EnumBuilder` | 用于创建. NET 枚举类型。 |
| `TypeBuilder` | 可用于在运行时在模块内创建类、接口、结构和委托。 |
| `MethodBuilder LocalBuilder PropertyBuilder FieldBuilder ConstructorBuilder CustomAttributeBuilder ParameterBuilder EventBuilder` | 用于在运行时创建类型成员(如方法、局部变量、属性、构造函数和特性)。 |
| `ILGenerator` | 向给定的类型成员发出 CIL 操作码。 |
| `OpCodes` | 提供了许多映射到 CIL 操作码的字段。该类型与`System.Reflection.Emit.ILGenerator`的各种成员一起使用。 |

一般来说,`System.Reflection.Emit`名称空间的类型允许您在动态程序集的构造过程中以编程方式表示原始 CIL 标记。您将在下面的示例中看到许多这样的成员;然而,`ILGenerator`型值得一试。

### 系统的作用。Reflection.Emit.ILGenerator

顾名思义,`ILGenerator`类型的作用是将 CIL 操作码注入给定的类型成员。但是,您不能直接创建`ILGenerator`对象,因为这种类型没有公共构造函数;相反,您通过调用以构建者为中心的类型的特定方法(例如`MethodBuilder`和`ConstructorBuilder`类型)来接收一个`ILGenerator`类型。这里有一个例子:

// Obtain an ILGenerator from a ConstructorBuilder
// object named "myCtorBuilder".
ConstructorBuilder myCtorBuilder = /* */;
ILGenerator myCILGen = myCtorBuilder.GetILGenerator();
0


一旦你手中有了一个`ILGenerator`,你就可以用任何方法发出原始的 CIL 操作码。表 19-8 记录了`ILGenerator`的一些(但不是全部)方法。

表 19-8。

`ILGenerator`的各种方法

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

生命的意义

 |
| --- | --- |
| `BeginCatchBlock()` | 开始一个`catch`块 |
| `BeginExceptionBlock()` | 开始异常的异常范围 |
| `BeginFinallyBlock()` | 开始一个`finally`块 |
| `BeginScope()` | 开始词法范围 |
| `DeclareLocal()` | 声明一个局部变量 |
| `DefineLabel()` | 声明一个新标签 |
| `Emit()` | 被重载多次,以允许您发出 CIL 操作码 |
| `EmitCall()` | 将一个`call`或`callvirt`操作码推入 CIL 流 |
| `EmitWriteLine()` | 用不同类型的值发出对`Console.WriteLine()`的调用 |
| `EndExceptionBlock()` | 结束异常块 |
| `EndScope()` | 结束词法范围 |
| `ThrowException()` | 发出引发异常的指令 |
| `UsingNamespace()` | 指定用于计算当前活动词法范围的局部变量和监视器的命名空间 |

`ILGenerator`的关键方法是`Emit()`,它与`System.Reflection.Emit.OpCodes`类类型一起工作。正如本章前面提到的,这种类型公开了大量映射到原始 CIL 操作码的只读字段。所有这些成员都记录在联机帮助中,您将在接下来的页面中看到各种示例。

### 发出动态程序集

为了说明在运行时定义. NET 核心程序集的过程,让我们浏览一下创建单文件动态程序集的过程。在这个集合中有一个名为`HelloWorld`的类。`HelloWorld`类支持一个默认的构造函数和一个自定义的构造函数,用于为`string`类型的私有成员变量(`theMessage`)赋值。此外,`HelloWorld`支持一个名为`SayHello()`的公共实例方法,它向标准 I/O 流打印问候,还支持另一个名为`GetMsg()`的实例方法,它返回内部私有字符串。实际上,您将通过编程生成以下类类型:

// This class will be created at runtime
// using System.Reflection.Emit.
public class HelloWorld
{
private string theMessage;
HelloWorld()
HelloWorld(string s)

public string GetMsg() {return theMessage;}
public void SayHello()
{
System.Console.WriteLine("Hello from the HelloWorld class!");
}
}


假设您已经创建了一个名为 DynamicAsmBuilder 的新控制台应用项目,并添加了系统。发出 NuGet 包。接下来,导入`System.Reflection`和`System.Reflection.Emit`名称空间。在`Program`类中定义一个名为`CreateMyAsm()`的静态方法。这种方法监督以下内容:

*   定义动态程序集的特征(名称、版本等。)

*   实现`HelloClass`类型

*   将 AssemblyBuilder 返回到调用方法

下面是完整的代码,并附有分析:

static AssemblyBuilder CreateMyAsm()
{
// Establish general assembly characteristics.
AssemblyName assemblyName = new AssemblyName
{
Name = "MyAssembly",
Version = new Version("1.0.0.0")
};

// Create new assembly.
var builder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,AssemblyBuilderAccess.Run);

// Define the name of the module

.
ModuleBuilder module =
builder.DefineDynamicModule("MyAssembly");
// Define a public class named "HelloWorld".
TypeBuilder helloWorldClass =
module.DefineType("MyAssembly.HelloWorld",
TypeAttributes.Public);

// Define a private String variable named "theMessage".
FieldBuilder msgField = helloWorldClass.DefineField(
"theMessage",
Type.GetType("System.String"),
attributes: FieldAttributes.Private);

// Create the custom ctor.
Type[] constructorArgs = new Type[1];
constructorArgs[0] = typeof(string);
ConstructorBuilder constructor =
helloWorldClass.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
constructorArgs);
ILGenerator constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
Type objectClass = typeof(object);
ConstructorInfo superConstructor =
objectClass.GetConstructor(new Type[0]);
constructorIl.Emit(OpCodes.Call, superConstructor);
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
constructorIl.Emit(OpCodes.Stfld, msgField);
constructorIl.Emit(OpCodes.Ret);

// Create the default ctor.
helloWorldClass.DefineDefaultConstructor(
MethodAttributes.Public);
// Now create the GetMsg() method.
MethodBuilder getMsgMethod = helloWorldClass.DefineMethod(
"GetMsg",
MethodAttributes.Public,
typeof(string),
null);
ILGenerator methodIl = getMsgMethod.GetILGenerator();
methodIl.Emit(OpCodes.Ldarg_0);
methodIl.Emit(OpCodes.Ldfld, msgField);
methodIl.Emit(OpCodes.Ret);

// Create the SayHello method

.
MethodBuilder sayHiMethod = helloWorldClass.DefineMethod(
"SayHello", MethodAttributes.Public, null, null);
methodIl = sayHiMethod.GetILGenerator();
methodIl.EmitWriteLine("Hello from the HelloWorld class!");
methodIl.Emit(OpCodes.Ret);

// "Bake" the class HelloWorld.
// (Baking is the formal term for emitting the type.)
helloWorldClass.CreateType();

return builder;
}


### 发射组件和模块组

方法体首先使用`AssemblyName`和`Version`类型(在`System.Reflection`命名空间中定义)建立关于程序集的最小特征集。接下来,通过静态的`AssemblyBuilder.DefineDynamicAssembly()`方法获得一个`AssemblyBuilder`类型。

调用`DefineDynamicAssembly()`时,必须指定要定义的程序集的访问模式,最常用的值如表 19-9 所示。

表 19-9。

`AssemblyBuilderAccess`枚举的公共值

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

**值**

 | 

**人生意义**

 |
| --- | --- |
| `RunAndCollect` | 该程序集将被立即卸载,一旦不再可访问,其内存将被回收。 |
| `Run` | 表示动态程序集可以在内存中执行,但不能保存到磁盘。 |

下一个任务是为新的程序集定义模块集(及其名称)。一旦`DefineDynamicModule()`方法返回,就会为您提供一个对有效的`ModuleBuilder`类型的引用。

// Create new assembly.
var builder = AssemblyBuilder.DefineDynamicAssembly(
assemblyName,AssemblyBuilderAccess.Run);


### ModuleBuilder 类型的作用

`ModuleBuilder`是动态程序集开发过程中使用的键类型。如您所料,`ModuleBuilder`支持几个成员,允许您定义给定模块中包含的类型集(类、接口、结构等)。)以及嵌入式资源集(字符串表、图像等)。)包含在内。表 19-10 描述了两种以创造为中心的方法。(请注意,每个方法都将向您返回一个相关的类型,该类型表示您想要构造的类型。)

表 19-10。

选择`ModuleBuilder`类型的成员

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

方法

 | 

生命的意义

 |
| --- | --- |
| `DefineEnum()` | 用于发出. NET 枚举定义 |
| `DefineType()` | 构造一个`TypeBuilder`,它允许您定义值类型、接口和类类型(包括委托) |

需要注意的`ModuleBuilder`类的关键成员是`DefineType()`。除了指定类型的名称(通过一个简单的字符串),您还将使用`System.Reflection.TypeAttributes`枚举来描述类型本身的格式。表 19-11 列出了`TypeAttributes`枚举的一些(但不是全部)关键成员。

表 19-11。

选择`TypeAttributes`枚举的成员

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

成员

 | 

生命的意义

 |
| --- | --- |
| `Abstract` | 指定该类型是抽象的 |
| `Class` | 指定该类型是类 |
| `Interface` | 指定该类型是接口 |
| `NestedAssembly` | 指定该类嵌套有程序集可见性,因此只能由其程序集中的方法访问 |
| `NestedFamANDAssem` | 指定该类嵌套有程序集和族可见性,因此只能由位于其族和程序集的交集中的方法访问 |
| `NestedFamily` | 指定该类是用族可见性嵌套的,因此只能由它自己的类型和任何子类型中的方法访问 |
| `NestedFamORAssem` | 指定该类是用族或程序集可见性嵌套的,因此只能由位于其族和程序集的联合中的方法访问 |
| `NestedPrivate` | 指定该类嵌套有私有可见性 |
| `NestedPublic` | 指定该类嵌套有公共可见性 |
| `NotPublic` | 指定该类不是公共的 |
| `Public` | 指定该类是公共的 |
| `Sealed` | 指定该类是具体的,不能扩展 |
| `Serializable` | 指定该类可以序列化 |

### 发出 HelloClass 类型和字符串成员变量

现在您已经对`ModuleBuilder.CreateType()`方法的作用有了更好的理解,让我们看看如何发出公共`HelloWorld`类类型和私有字符串变量。

// Define a public class named "HelloWorld".
TypeBuilder helloWorldClass =
module.DefineType("MyAssembly.HelloWorld",
TypeAttributes.Public);

// Define a private String variable named "theMessage".
FieldBuilder msgField = helloWorldClass.DefineField(
"theMessage",
Type.GetType("System.String"),
attributes: FieldAttributes.Private);


注意`TypeBuilder.DefineField()`方法如何提供对`FieldBuilder`类型的访问。`TypeBuilder`类还定义了提供对其他“构建器”类型访问的其他方法。例如,`DefineConstructor()`返回一个`ConstructorBuilder`,`DefineProperty()`返回一个`PropertyBuilder`,以此类推。

### 发出构造函数

如前所述,`TypeBuilder.DefineConstructor()`方法可以用来为当前类型定义一个构造函数。然而,当实现`HelloClass`的构造函数时,您需要将原始的 CIL 代码注入构造函数体,它负责将传入的参数分配给内部私有字符串。为了获得一个`ILGenerator`类型,您从您引用的相应“构建器”类型(在本例中是`ConstructorBuilder`类型)中调用`GetILGenerator()`方法。

`ILGenerator`类的`Emit()`方法是负责将 CIL 放入成员实现的实体。`Emit()`本身经常使用`OpCodes`类类型,它使用只读字段公开 CIL 的操作码集。例如,`OpCodes.Ret`表示方法调用的返回,`OpCodes.Stfld`对成员变量赋值,`OpCodes.Call`用于调用给定的方法(在本例中,是基类构造函数)。也就是说,思考下面的构造函数逻辑:

// Create the custom ctor taking single string arg.
Type[] constructorArgs = new Type[1];
constructorArgs[0] = typeof(string);
ConstructorBuilder constructor =
helloWorldClass.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
constructorArgs);
//Emit the necessary CIL into the ctor
ILGenerator constructorIl = constructor.GetILGenerator();
constructorIl.Emit(OpCodes.Ldarg_0);
Type objectClass = typeof(object);
ConstructorInfo superConstructor =
objectClass.GetConstructor(new Type[0]);
constructorIl.Emit(OpCodes.Call, superConstructor);
//Load this pointer onto the stack
constructorIl.Emit(OpCodes.Ldarg_0);
constructorIl.Emit(OpCodes.Ldarg_1);
//Load argument on virtual stack and store in msdField
constructorIl.Emit(OpCodes.Stfld, msgField);
constructorIl.Emit(OpCodes.Ret);


现在,如您所知,一旦您为类型定义了自定义构造函数,默认构造函数就会被自动移除。要重新定义无参数构造函数,只需调用`TypeBuilder`类型的`DefineDefaultConstructor()`方法,如下所示:

// Create the default ctor.
helloWorldClass.DefineDefaultConstructor(
MethodAttributes.Public);


### 发出 SayHello()方法

最后,让我们检查一下发射`SayHello()`方法的过程。第一个任务是从`helloWorldClass`变量中获取一个`MethodBuilder`类型。这样做之后,定义方法并获得底层的`ILGenerator`来注入 CIL 指令,如下所示:

// Create the SayHello method.
MethodBuilder sayHiMethod = helloWorldClass.DefineMethod(
"SayHello", MethodAttributes.Public, null, null);
methodIl = sayHiMethod.GetILGenerator();

//Write to the console
methodIl.EmitWriteLine("Hello from the HelloWorld class!");
methodIl.Emit(OpCodes.Ret);


在这里,您已经建立了一个公共方法(`MethodAttributes.Public`),它不接受任何参数,也不返回任何内容(由包含在`DefineMethod()`调用中的空条目标记)。也注意到了`EmitWriteLine()`的称呼。这个`ILGenerator`类的助手成员自动地向标准输出写了一行,最大限度地减少了麻烦。

### 使用动态生成的程序集

现在您已经有了创建程序集的逻辑,接下来需要做的就是执行生成的代码。调用代码中的逻辑调用`CreateMyAsm()`方法,获取对创建的 AssemblyBuilder 的引用。

接下来,你将练习一些延迟绑定(参见第十七章)来创建一个`HelloWorld`类的实例并与其成员交互。按如下方式更新顶级语句:

using System;
using System.Reflection;
using System.Reflection.Emit;

Console.WriteLine("***** The Amazing Dynamic Assembly Builder App *****");
// Create the assembly builder using our helper f(x).
AssemblyBuilder builder = CreateMyAsm();

// Get the HelloWorld type.
Type hello = builder.GetType("MyAssembly.HelloWorld");

// Create HelloWorld instance and call the correct ctor.
Console.Write("-> Enter message to pass HelloWorld class: ");
string msg = Console.ReadLine();
object[] ctorArgs = new object[1];
ctorArgs[0] = msg;
object obj = Activator.CreateInstance(hello, ctorArgs);

// Call SayHello and show returned string.
Console.WriteLine("-> Calling SayHello() via late binding.");
MethodInfo mi = hello.GetMethod("SayHello");
mi.Invoke(obj, null);

// Invoke method.
mi = hello.GetMethod("GetMsg");
Console.WriteLine(mi.Invoke(obj, null));


实际上,您已经创建了一个能够创建和执行的. NET 核心程序集。运行时的. NET 核心程序集!这就结束了对 CIL 和动态程序集的作用的研究。我希望这一章加深了你对。NET 核心类型系统,CIL 的语法和语义,以及 C# 编译器如何在编译时处理您的代码。

## 摘要

本章概述了 CIL 的语法和语义。与 C# 等更高级别的托管语言不同,CIL 不仅定义了一组关键字,还提供了指令(用于定义程序集及其类型的结构)、属性(进一步限定给定的指令)和操作码(用于实现类型成员)。

向您介绍了一些以 CIL 为中心的编程工具,并学习了如何使用往返工程用新的 CIL 指令改变. NET 程序集的内容。此后,您花时间学习了如何建立当前(和引用的)程序集、命名空间、类型和成员。最后,我用一个简单的例子来构建一个. NET 代码库和可执行文件,这个例子只使用了 CIL、命令行工具和一些额外的工作。

最后,您介绍了创建一个*动态装配*的过程。使用`System.Reflection.Emit`名称空间,可以在运行时在内存中定义一个. NET 核心程序集。正如您亲眼所见,使用这个 API 需要您详细了解 CIL 代码的语义。虽然构建动态程序集的需求对于大多数人来说肯定不是一项常见的任务。NET 核心应用,对于那些需要构建支持工具和其他编程实用程序的人来说,它可能很有用。

# 二十、文件 I/O 和对象序列化

当您创建桌面应用时,在用户会话之间保存信息的能力是很常见的。本章从的角度研究了几个与 I/O 相关的主题。NET 核心框架。首要任务是探索在`System.IO`名称空间中定义的核心类型,并学习如何以编程方式修改机器的目录和文件结构。下一个任务是探索读取和写入基于字符、基于二进制、基于字符串和基于内存的数据存储的各种方法。

在您学习了如何使用核心 I/O 类型操作文件和目录之后,您将研究相关的主题*对象序列化*。您可以使用对象序列化将对象的状态持久化并检索到任何从`System.IO.Stream`派生的类型。

Note

为了确保您可以运行本章中的每个示例,请以管理权限启动 Visual Studio(只需右击 Visual Studio 图标并选择“以管理员身份运行”)。如果不这样做,在访问计算机文件系统时可能会遇到运行时安全异常。

## 探索系统。IO 命名空间

在...的框架内。NET 核心中,`System.IO`命名空间是专用于基于文件(和基于内存)的输入和输出(I/O)服务的基类库区域。像任何名称空间一样,`System.IO`定义了一组类、接口、枚举、结构和委托,其中大部分可以在`mscorlib.dll`中找到。除了包含在`mscorlib.dll`中的类型之外,`System.dll`程序集还定义了`System.IO`名称空间的其他成员。

`System.IO`名称空间中的许多类型侧重于物理目录和文件的编程操作。但是,其他类型支持从字符串缓冲区以及原始内存位置读取数据和向其中写入数据。表 20-1 概述了核心(非抽象)类,提供了`System.IO`中功能的路线图。

表 20-1。

`System.IO`名称空间的主要成员

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

非抽象 I/O 类类型

 | 

生命的意义

 |
| --- | --- |
| `BinaryReader``BinaryWriter` | 这些类允许您以二进制值的形式存储和检索原始数据类型(整数、布尔值、字符串等等)。 |
| `BufferedStream` | 此类为字节流提供临时存储,您可以在以后提交给存储。 |
| `Directory``DirectoryInfo` | 您可以使用这些类来操作机器的目录结构。`Directory`类型使用*静态成员*公开功能,而`DirectoryInfo`类型从有效的*对象引用*公开类似的功能。 |
| `DriveInfo` | 该类提供关于给定机器使用的驱动器的详细信息。 |
| `File``FileInfo` | 您使用这些类来操作机器的一组文件。`File`类型使用*静态成员*公开功能,而`FileInfo`类型从有效的*对象引用*公开类似的功能。 |
| `FileStream` | 这个类为您提供了随机文件访问(例如,搜索功能),数据以字节流的形式表示。 |
| `FileSystemWatcher` | 这个类允许你监视指定目录中外部文件的修改。 |
| `MemoryStream` | 该类提供对存储在内存中而不是物理文件中的流数据的随机访问。 |
| `Path` | 这个类以平台无关的方式对包含文件或目录路径信息的`System.String`类型执行操作。 |
| `StreamWriter``StreamReader` | 您可以使用这些类将文本信息存储到(或从)文件中检索。这些类型不支持随机文件访问。 |
| `StringWriter``StringReader` | 像`StreamReader` / `StreamWriter`类一样,这些类也处理文本信息。然而,底层存储是一个字符串缓冲区,而不是一个物理文件。 |

除了这些具体的类类型,`System.IO`还定义了几个枚举,以及一组抽象类(例如,`Stream`、`TextReader`和`TextWriter`,它们为所有后代定义了一个共享的多态接口。在这一章中,你将会读到许多这种类型的内容。

## 目录(信息)和文件(信息)类型

提供四个类,允许你操作单个文件,以及与机器的目录结构交互。前两种类型,`Directory`和`File`,使用各种静态成员公开创建、删除、复制和移动操作。密切相关的`FileInfo`和`DirectoryInfo`类型公开了与实例级方法相似的功能(因此,必须用`new`关键字分配它们)。`Directory`和`File`类直接扩展`System.Object`,而`DirectoryInfo`和`FileInfo`从抽象的`FileSystemInfo`类型派生而来。

`FileInfo`和`DirectoryInfo`通常是获得文件或目录的完整细节(例如,创建时间或读/写能力)的更好选择,因为它们的成员倾向于返回强类型对象。相反,`Directory`和`File`类成员倾向于返回简单的字符串值,而不是强类型对象。然而,这只是一个指导方针;在许多情况下,你可以使用`File` / `FileInfo`或`Directory` / `DirectoryInfo`完成同样的工作。

### 抽象 FileSystemInfo 基类

`DirectoryInfo`和`FileInfo`类型从抽象的`FileSystemInfo`基类接收许多行为。在大多数情况下,您使用`FileSystemInfo`类的成员来发现一般特征(例如创建时间、各种属性等。)关于给定的文件或目录。表 20-2 列出了一些感兴趣的核心属性。

表 20-2。

`FileSystemInfo`属性

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

财产

 | 

生命的意义

 |
| --- | --- |
| `Attributes` | 获取或设置与当前文件关联的属性,这些属性由`FileAttributes`枚举表示(例如,文件或目录是只读的、加密的、隐藏的还是压缩的?) |
| `CreationTime` | 获取或设置当前文件或目录的创建时间 |
| `Exists` | 确定给定的文件或目录是否存在 |
| `Extension` | 检索文件的扩展名 |
| `FullName` | 获取目录或文件的完整路径 |
| `LastAccessTime` | 获取或设置上次访问当前文件或目录的时间 |
| `LastWriteTime` | 获取或设置上次写入当前文件或目录的时间 |
| `Name` | 获取当前文件或目录的名称 |

`FileSystemInfo`也定义了`Delete()`方法。这是通过派生类型从硬盘上删除给定的文件或目录来实现的。此外,您可以在获取属性信息之前调用`Refresh()`,以确保关于当前文件(或目录)的统计信息没有过时。

## 使用 DirectoryInfo 类型

您将研究的第一个可创建的以 I/O 为中心的类型是`DirectoryInfo`类。该类包含一组用于创建、移动、删除和枚举目录和子目录的成员。除了其基类(`FileSystemInfo`)提供的功能外,`DirectoryInfo`还提供表 20-3 中详细列出的关键成员。

表 20-3。

`DirectoryInfo`类型的主要成员

<colgroup><col class="tcol1 align-left"> <col class="tcol2 align-left"></colgroup> 
| 

成员

 | 

生命的意义

 |
| --- | --- |
| `Create()``CreateSubdirectory()` | 给定路径名时,创建一个目录(或一组子目录) |
| `Delete()` | 删除目录及其所有内容 |
| `GetDirectories()` | 返回代表当前目录中所有子目录的`DirectoryInfo`对象数组 |
| `GetFiles()` | 检索代表给定目录中一组文件的一组`FileInfo`对象 |
| `MoveTo()` | 将目录及其内容移动到新路径 |
| `Parent` | 检索了此目录的父目录 |
| `Root` | 获取路径的根部分 |

通过指定一个特定的目录路径作为构造函数参数,开始使用`DirectoryInfo`类型。如果您想要访问当前工作目录(执行应用的目录),请使用点(`.`)符号。以下是一些例子:

```cs
// Bind to the current working directory.
DirectoryInfo dir1 = new DirectoryInfo(".");
// Bind to C:\Windows,
// using a verbatim string.
DirectoryInfo dir2 = new DirectoryInfo(@"C:\Windows");

在第二个例子中,假设传递给构造函数(C:\Windows)的路径已经存在于物理机器上。然而,如果您试图与一个不存在的目录交互,就会抛出一个System.IO.DirectoryNotFoundException。因此,如果您指定了一个尚未创建的目录,您需要在继续之前调用Create()方法,如下所示:

// Bind to a nonexistent directory, then create it.
DirectoryInfo dir3 = new DirectoryInfo(@"C:\MyCode\Testing");
dir3.Create();

上例中使用的路径语法是以窗口为中心的。如果你在发展。NET 核心应用,您应该使用Path.VolumeSeparatorCharPath.DirectorySeparatorChar构造,它们将基于平台产生适当的字符。将前面的代码更新为以下内容:

DirectoryInfo dir3 = new DirectoryInfo(
$@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}MyCode{Path.DirectorySeparatorChar}Testing");

在创建了一个DirectoryInfo对象之后,您可以使用从FileSystemInfo继承的任何属性来研究底层目录内容。要看到这一点,创建一个名为 DirectoryApp 的新控制台应用项目,并更新您的 C# 文件以导入SystemSystem.IO。用以下新的静态方法更新您的Program类,该方法创建一个映射到C:\Windows的新的DirectoryInfo对象(如果需要,调整您的路径),它显示几个有趣的统计数据:

using System;
using System.IO;

Console.WriteLine("***** Fun with Directory(Info) *****\n");
ShowWindowsDirectoryInfo();
Console.ReadLine();

static void ShowWindowsDirectoryInfo()
{
  // Dump directory information. If you are not on Windows, plug in another directory
  DirectoryInfo dir = new DirectoryInfo($@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Windows");
  Console.WriteLine("***** Directory Info *****");
  Console.WriteLine("FullName: {0}", dir.FullName);
  Console.WriteLine("Name: {0}", dir.Name);
  Console.WriteLine("Parent: {0}", dir.Parent);
  Console.WriteLine("Creation: {0}", dir.CreationTime);
  Console.WriteLine("Attributes: {0}", dir.Attributes);
  Console.WriteLine("Root: {0}", dir.Root);
  Console.WriteLine("**************************\n");
}

虽然您的输出可能会有所不同,但您应该会看到如下所示的内容:

***** Fun with Directory(Info) *****
***** Directory Info *****
FullName: C:\Windows
Name: Windows
Parent:
Creation: 3/19/2019 00:37:22
Attributes: Directory
Root: C:\
**************************

枚举 DirectoryInfo 类型的文件

除了获得现有目录的基本细节,您还可以扩展当前示例,使用一些DirectoryInfo类型的方法。首先,您可以利用GetFiles()方法获得位于C:\Windows\Web\Wallpaper目录中的所有*.jpg文件的信息。

Note

如果您不是在 Windows 机器上,修改此代码以读取您机器上某个目录下的文件。记住使用Path.VolumeSeparatorCharPath.DirectorySeparatorChar值来使你的代码跨平台兼容。

GetFiles()方法返回一个由FileInfo对象组成的数组,每个对象公开一个特定文件的细节(你将在本章后面了解到FileInfo类型的全部细节)。在Program类中创建以下静态方法:

static void DisplayImageFiles()
{
  DirectoryInfo dir = new
    DirectoryInfo(@"C:\Windows\Web\Wallpaper");
  // Get all files with a *.jpg extension.
  FileInfo[] imageFiles =
    dir.GetFiles("*.jpg", SearchOption.AllDirectories);

  // How many were found?
  Console.WriteLine("Found {0} *.jpg files\n", imageFiles.Length);

  // Now print out info for each file.
  foreach (FileInfo f in imageFiles)
  {
    Console.WriteLine("***************************");
    Console.WriteLine("File name: {0}", f.Name);
    Console.WriteLine("File size: {0}", f.Length);
    Console.WriteLine("Creation: {0}", f.CreationTime);
    Console.WriteLine("Attributes: {0}", f.Attributes);
    Console.WriteLine("***************************\n");
  }
}

注意,当您调用GetFiles()时,您指定了一个搜索选项;这样做是为了在根目录的所有子目录中查找。运行应用后,您将看到所有符合搜索模式的文件的列表。

创建 DirectoryInfo 类型的子目录

您可以使用DirectoryInfo.CreateSubdirectory()方法以编程方式扩展目录结构。此方法可以在一次函数调用中创建一个子目录以及多个嵌套子目录。这个方法演示了如何做到这一点,用一些自定义子目录扩展了应用执行目录(用.表示)的目录结构:

static void ModifyAppDirectory()
{
  DirectoryInfo dir = new DirectoryInfo(".");

  // Create \MyFolder off application directory.
  dir.CreateSubdirectory("MyFolder");

  // Create \MyFolder2\Data off application directory.
  dir.CreateSubdirectory(
    $@"MyFolder2{Path.DirectorySeparatorChar}Data");
}

您不需要捕获CreateSubdirectory()方法的返回值,但是您应该知道,表示新创建的项目的DirectoryInfo对象在成功执行时被传递回来。考虑对先前方法的以下更新:

static void ModifyAppDirectory()
{
  DirectoryInfo dir = new DirectoryInfo(".");

  // Create \MyFolder off initial directory.
  dir.CreateSubdirectory("MyFolder");

  // Capture returned DirectoryInfo object.
  DirectoryInfo myDataFolder = dir.CreateSubdirectory(
    $@"MyFolder2{Path.DirectorySeparatorChar}Data");

  // Prints path to ..\MyFolder2\Data.
  Console.WriteLine("New Folder is: {0}", myDataFolder);
}

如果从顶级语句中调用此方法,并使用 Windows 资源管理器检查 Windows 目录,您将看到新的子目录存在并被考虑在内。

使用目录类型

你已经看到了DirectoryInfo型的作用;现在你已经准备好学习Directory类型了。在很大程度上,Directory的静态成员模仿了由DirectoryInfo定义的实例级成员所提供的功能。然而,回想一下,Directory的成员通常返回字符串数据,而不是强类型的FileInfo / DirectoryInfo对象。

现在让我们看看Directory类型的一些功能。这个最后的帮助器函数显示映射到当前计算机的所有驱动器的名称(使用Directory.GetLogicalDrives()方法),并使用静态的Directory.Delete()方法删除之前创建的\MyFolder\MyFolder2\Data子目录。

static void FunWithDirectoryType()
{
  // List all drives on current computer.
  string[] drives = Directory.GetLogicalDrives();
  Console.WriteLine("Here are your drives:");
  foreach (string s in drives)
  {
    Console.WriteLine("--> {0} ", s);
  }

  // Delete what was created.
  Console.WriteLine("Press Enter to delete directories");
  Console.ReadLine();
  try
  {
    Directory.Delete("MyFolder");

    // The second parameter specifies whether you
    // wish to destroy any subdirectories.
    Directory.Delete("MyFolder2", true);
  }
  catch (IOException e)

  {
    Console.WriteLine(e.Message);
  }
}

使用 DriveInfo 类类型

System.IO名称空间提供了一个名为DriveInfo的类。像Directory.GetLogicalDrives()一样,静态DriveInfo.GetDrives()方法允许您发现机器驱动器的名称。然而,与Directory.GetLogicalDrives()不同的是,DriveInfo提供了许多其他细节(例如,驱动器类型、可用空间和卷标)。考虑在名为 DriveInfoApp 的新控制台应用项目中定义的以下Program类:

using System;
using System.IO;

// Get info regarding all drives.
DriveInfo[] myDrives = DriveInfo.GetDrives();
// Now print drive stats.
foreach(DriveInfo d in myDrives)
{
  Console.WriteLine("Name: {0}", d.Name);
  Console.WriteLine("Type: {0}", d.DriveType);

  // Check to see whether the drive is mounted.
  if(d.IsReady)
  {
    Console.WriteLine("Free space: {0}", d.TotalFreeSpace);
    Console.WriteLine("Format: {0}", d.DriveFormat);
    Console.WriteLine("Label: {0}", d.VolumeLabel);
  }
  Console.WriteLine();
}
Console.ReadLine();

以下是一些可能的输出:

***** Fun with DriveInfo *****
Name: C:\
Type: Fixed
Free space: 284131119104
Format: NTFS
Label: OS

Name: M:\
Type: Network
Free space: 4711871942656
Format: NTFS
Label: DigitalMedia

至此,您已经研究了DirectoryDirectoryInfoDriveInfo类的一些核心行为。接下来,您将学习如何创建、打开、关闭和销毁填充给定目录的文件。

使用 FileInfo 类

如前面的DirectoryApp示例所示,FileInfo类允许您获取硬盘上现有文件的详细信息(例如,创建时间、大小和文件属性),并帮助创建、复制、移动和销毁文件。除了由FileSystemInfo继承的功能集,你可以找到一些FileInfo类独有的核心成员,你可以在表 20-4 中看到描述。

表 20-4。

FileInfo核心成员

|

成员

|

生命的意义

AppendText() 创建一个向文件追加文本的StreamWriter对象(稍后描述)
CopyTo() 将现有文件复制到新文件中
Create() 创建一个新文件并返回一个FileStream对象(稍后描述)来与新创建的文件交互
CreateText() 创建一个写新文本文件的StreamWriter对象
Delete() 删除绑定了FileInfo实例的文件
Directory 获取父目录的实例
DirectoryName 获取父目录的完整路径
Length 获取当前文件的大小
MoveTo() 将指定文件移动到新位置,并提供指定新文件名的选项
Name 获取文件的名称
Open() 以各种读/写和共享权限打开文件
OpenRead() 创建一个只读的FileStream对象
OpenText() 创建一个从现有文本文件中读取的StreamReader对象(稍后描述)
OpenWrite() 创建一个只写的FileStream对象

请注意,FileInfo类的大多数方法都返回一个特定的以 I/O 为中心的对象(例如,FileStreamStreamWriter),该对象允许您开始以各种格式向相关文件读写数据。您将很快了解这些类型;然而,在您看到一个工作示例之前,您会发现检查使用FileInfo类类型获得文件句柄的各种方法是有帮助的。

文件信息。Create()方法

下一组示例都在一个名为 SimpleFileIO 的控制台应用中。创建文件句柄的一种方法是使用FileInfo.Create()方法,如下所示:

using System;
using System.IO;

Console.WriteLine("***** Simple IO with the File Type *****\n");
//Change to a folder on your machine that you have read/write access to, or run as administrator
var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}temp{Path.DirectorySeparatorChar}Test.dat";
// Make a new file on the C drive.
FileInfo f = new FileInfo(fileName);
FileStream fs = f.Create();

// Use the FileStream object...

// Close down file stream.
fs.Close();

Note

这些示例可能需要以管理员身份运行 Visual Studio,具体取决于您的用户权限和系统配置。

注意,FileInfo.Create()方法返回了一个FileStream对象,该对象公开了对底层文件的同步和异步写/读操作(稍后会有更多细节)。注意由FileInfo.Create()返回的FileStream对象授予所有用户完全的读/写权限。

还要注意,在使用完当前的FileStream对象后,必须确保关闭句柄来释放底层的非托管流资源。鉴于FileStream实现了IDisposable,你可以使用 C# using的作用域来允许编译器生成拆卸逻辑(详见第八章),如下所示:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
//wrap the file stream in a using statement
// Defining a using scope for file I/O
FileInfo f1 = new FileInfo(fileName);
using (FileStream fs1 = f1.Create())
{
  // Use the FileStream object...
}
f1.Delete();

Note

本章中几乎所有的例子都包含了using语句。我本可以使用新的 using 声明语法,但在这次重写中没有这样做,以保持示例集中在我们正在检查的System.IO组件上。

文件信息。Open()方法

您可以使用FileInfo.Open()方法打开现有文件,也可以创建比使用FileInfo.Create()更精确的新文件。这是可行的,因为Open()通常采用几个参数来限定如何迭代您想要操作的文件。一旦对Open()的调用完成,就会返回一个FileStream对象。考虑以下逻辑:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
// Make a new file via FileInfo.Open().
FileInfo f2 = new FileInfo(fileName);
using(FileStream fs2 = f2.Open(FileMode.OpenOrCreate,
  FileAccess.ReadWrite, FileShare.None))
{
  // Use the FileStream object...
}
f2.Delete();

这个版本的重载Open()方法需要三个参数。Open()方法的第一个参数指定了 I/O 请求的一般风格(例如,创建一个新文件,打开一个现有文件,并追加到一个文件中),这可以使用FileMode枚举来指定(详细信息请参见表 20-5 ,如下所示:

表 20-5。

FileMode枚举的成员

|

成员

|

生命的意义

CreateNew 通知操作系统创建一个新文件。如果它已经存在,抛出一个IOException
Create 通知操作系统创建一个新文件。如果它已经存在,它将被覆盖。
Open 打开现有文件。如果文件不存在,抛出一个FileNotFoundException
OpenOrCreate 如果文件存在,则打开该文件;否则,将创建一个新文件。
Truncate 打开一个现有文件并将文件截断为 0 字节大小。
Append 打开一个文件,移动到文件末尾,并开始写操作(只能对只写流使用此标志)。如果文件不存在,将创建一个新文件。
public enum FileMode
{
  CreateNew,
  Create,
  Open,
  OpenOrCreate,
  Truncate,
  Append
}

使用Open()方法的第二个参数,一个来自FileAccess枚举的值,来确定底层流的读/写行为,如下所示:

public enum FileAccess
{
  Read,
  Write,
  ReadWrite
}

最后,Open()方法的第三个参数FileShare指定如何在其他文件处理程序之间共享文件。以下是核心名称:

public enum FileShare
{
  None,
  Read,
  Write,
  ReadWrite,
  Delete,
  Inheritable
}

文件信息。OpenRead()和 FileInfo。OpenWrite()方法

FileInfo.Open()方法允许您以灵活的方式获得文件句柄,但是FileInfo类也提供了名为OpenRead()OpenWrite()的成员。正如您所想象的,这些方法返回一个正确配置的只读或只写的FileStream对象,而不需要提供各种枚举值。和FileInfo.Create()FileInfo.Open()一样,OpenRead()OpenWrite()返回一个FileStream对象。

注意,OpenRead()方法要求文件已经存在。下面的代码创建文件,然后关闭FileStream,这样它就可以被OpenRead()方法使用了:

f3.Create().Close();

以下是完整的示例:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
// Get a FileStream object with read-only permissions.
FileInfo f3 = new FileInfo(fileName);
//File must exist before using OpenRead
f3.Create().Close();
using(FileStream readOnlyStream = f3.OpenRead())
{
  // Use the FileStream object...
}
f3.Delete();

// Now get a FileStream object with write-only permissions.
FileInfo f4 = new FileInfo(fileName);
using(FileStream writeOnlyStream = f4.OpenWrite())
{
  // Use the FileStream object...
}
f4.Delete();

文件信息。OpenText()方法

FileInfo类型的另一个开中心成员是OpenText()。与Create()Open()OpenRead()OpenWrite()不同,OpenText()方法返回StreamReader类型的实例,而不是FileStream类型的实例。假设您在C:驱动器上有一个名为boot.ini的文件,下面的代码片段让您可以访问它的内容:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
// Get a StreamReader object.
//If not on a Windows machine, change the file name accordingly
FileInfo f5 = new FileInfo(fileName);
//File must exist before using OpenText
f5.Create().Close();
using(StreamReader sreader = f5.OpenText())
{
  // Use the StreamReader object...
}
f5.Delete();

很快您就会看到,StreamReader类型提供了一种从底层文件读取字符数据的方法。

文件信息。CreateText()和 FileInfo。AppendText()方法

此时最后两个感兴趣的FileInfo方法是CreateText()AppendText()。两者都返回一个StreamWriter对象,如下所示:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
FileInfo f6 = new FileInfo(fileName);
using(StreamWriter swriter = f6.CreateText())
{
  // Use the StreamWriter object...
}
f6.Delete();
FileInfo f7 = new FileInfo(fileName);
using(StreamWriter swriterAppend = f7.AppendText())
{
  // Use the StreamWriter object...
}
f7.Delete();

正如您可能猜到的,StreamWriter类型提供了一种将字符数据写入底层文件的方法。

使用文件类型

File类型使用几个静态成员来提供与FileInfo类型几乎相同的功能。像FileInfoFile供给AppendText()Create()CreateText()Open()OpenRead()OpenWrite()OpenText()的方法。在许多情况下,您可以互换使用FileFileInfo类型。注意OpenText()OpenRead()要求文件已经存在。要看到这一点,您可以通过使用File类型来简化前面的每个FileStream示例,如下所示:

var fileName = $@"C{Path.VolumeSeparatorChar}{Path.DirectorySeparatorChar}Test.dat";
...
//Using File instead of FileInfo
using (FileStream fs8 = File.Create(fileName))
{
  // Use the FileStream object...
}
File.Delete(fileName);
// Make a new file via FileInfo.Open().
using(FileStream fs9 =  File.Open(fileName,
  FileMode.OpenOrCreate, FileAccess.ReadWrite,
  FileShare.None))
{
  // Use the FileStream object...
}
// Get a FileStream object with read-only permissions.
using(FileStream readOnlyStream = File.OpenRead(fileName))
{}
File.Delete(fileName);
// Get a FileStream object with write-only permissions.
using(FileStream writeOnlyStream = File.OpenWrite(fileName))
{}
// Get a StreamReader object.
using(StreamReader sreader = File.OpenText(fileName))
{}
File.Delete(fileName);
// Get some StreamWriters.
using(StreamWriter swriter = File.CreateText(fileName))
{}
File.Delete(fileName);

using(StreamWriter swriterAppend =
  File.AppendText(fileName))
{}
File.Delete(fileName)

;

其他以文件为中心的成员

File类型还支持一些成员,如表 20-6 所示,可以大大简化读写文本数据的过程。

表 20-6。

文件类型的方法

|

方法

|

生命的意义

ReadAllBytes() 打开指定的文件,以字节数组的形式返回二进制数据,然后关闭文件
ReadAllLines() 打开指定的文件,以字符串数组的形式返回字符数据,然后关闭文件
ReadAllText() 打开指定文件,返回字符数据作为System.String,然后关闭文件
WriteAllBytes() 打开指定的文件,写出字节数组,然后关闭文件
WriteAllLines() 打开指定文件,写出字符串数组,然后关闭文件
WriteAllText() 打开指定文件,写入指定字符串中的字符数据,然后关闭该文件

您可以使用这些File类型的方法,只用几行代码就可以读写成批的数据。更好的是,这些成员中的每一个都会自动关闭底层文件句柄。例如,下面的控制台程序(名为SimpleFileIO)将字符串数据持久化到C:驱动器上的一个新文件中(并将它读入内存中),而不会产生任何麻烦(这个例子假设您已经导入了System.IO):

Console.WriteLine("***** Simple I/O with the File Type *****\n");
string[] myTasks = {
  "Fix bathroom sink", "Call Dave",
  "Call Mom and Dad", "Play Xbox One"};

// Write out all data to file on C drive.
File.WriteAllLines(@"tasks.txt", myTasks);

// Read it all back and print out.
foreach (string task in File.ReadAllLines(@"tasks.txt"))
{
  Console.WriteLine("TODO: {0}", task);
}
Console.ReadLine();
File.Delete("tasks.txt");

这里的教训是,当你想快速获得一个文件句柄时,File类型将为你节省一些击键。然而,首先创建一个FileInfo对象的一个好处是,您可以使用抽象的FileSystemInfo基类的成员来研究文件。

抽象流类

至此,您已经看到了许多获取FileStreamStreamReaderStreamWriter对象的方法,但是您还没有使用这些类型从文件中读取数据或将数据写入文件。为了理解如何做到这一点,你需要熟悉的概念。在 I/O 操作的世界里,代表了在源和目的地之间流动的数据块。流提供了一种与字节序列进行交互的通用方式,无论哪种设备(例如文件、网络连接或打印机)存储或显示相关的字节。

抽象System.IO.Stream类定义了几个成员,这些成员为与存储介质(例如,底层文件或存储位置)的同步和异步交互提供支持。

Note

流的概念不限于文件 I/O。NET 核心库提供了对网络、内存位置和其他以流为中心的抽象的流访问。

同样,Stream后代将数据表示为原始字节流;因此,直接处理原始流是相当神秘的。一些Stream派生的类型支持寻找,是指获取并调整流中当前位置的过程。表 20-7 通过描述Stream类的核心成员来帮助你理解它所提供的功能。

表 20-7。

摘要Stream成员

|

成员

|

生命的意义

CanRead``CanWrite``CanSeek 确定当前流是否支持读取、查找和/或写入。
Close() 关闭当前流并释放与当前流关联的任何资源(如套接字和文件句柄)。在内部,这个方法是Dispose()方法的别名;因此,关闭流在功能上等同于处置流
Flush() 用缓冲区的当前状态更新底层数据源或储存库,然后清除缓冲区。如果流没有实现缓冲区,则此方法不执行任何操作。
Length 以字节为单位返回流的长度。
Position 确定当前流中的位置。
Read()``ReadByte()``ReadAsync() 从当前流中读取一个字节序列(或单个字节),并将流中的当前位置提升所读取的字节数。
Seek() 设置当前流中的位置。
SetLength() 设置当前流的长度。
Write()``WriteByte()``WriteAsync() 将一个字节序列(或单个字节)写入当前流,并按写入的字节数提升流中的当前位置。

使用文件流

FileStream类以适合基于文件的流的方式为抽象Stream成员提供了一个实现。这是一条原始的河流;它只能读取或写入单个字节或字节数组。然而,你并不经常需要与FileStream类型的成员直接交互。相反,你可能会使用各种流包装器,这使得处理文本数据或.NETCore 类型。尽管如此,您会发现尝试一下FileStream类型的同步读/写功能是很有帮助的。

假设您有一个名为 FileStreamApp 的新控制台应用项目(并验证System.IOSystem.Text已导入到您的初始 C# 代码文件中)。你的目标是写一个简单的文本信息到一个名为myMessage.dat的新文件中。然而,鉴于FileStream只能对原始字节进行操作,您需要将System.String类型编码到相应的字节数组中。幸运的是,System.Text名称空间定义了一个名为Encoding的类型,它提供了将字符串编码和解码为字节数组的成员。

一旦编码完成,字节数组就用FileStream.Write()方法保存到文件中。要将字节读回内存,必须重置流的内部位置(使用Position属性)并调用ReadByte()方法。最后,向控制台显示原始字节数组和解码后的字符串。以下是完整的代码:

using System;
using System.IO;
using System.Text;

// Don't forget to import the System.Text and System.IO namespaces.
Console.WriteLine("***** Fun with FileStreams *****\n");

// Obtain a FileStream object.
using(FileStream fStream = File.Open("myMessage.dat",
  FileMode.Create))
{
  // Encode a string as an array of bytes.
  string msg = "Hello!";
  byte[] msgAsByteArray = Encoding.Default.GetBytes(msg);

  // Write byte[] to file.
  fStream.Write(msgAsByteArray, 0, msgAsByteArray.Length);

  // Reset internal position of stream.
  fStream.Position = 0;

  // Read the types from file and display to console.
  Console.Write("Your message as an array of bytes: ");
  byte[] bytesFromFile = new byte[msgAsByteArray.Length];
  for (int i = 0; i < msgAsByteArray.Length; i++)
  {
    bytesFromFile[i] = (byte)fStream.ReadByte();
    Console.Write(bytesFromFile[i]);
  }

  // Display decoded messages.
  Console.Write("\nDecoded Message: ");
  Console.WriteLine(Encoding.Default.GetString(bytesFromFile));
  Console.ReadLine();
}
File.Delete("myMessage.dat");

这个例子用数据填充文件,但是它也强调了直接使用FileStream类型的主要缺点:它要求对原始字节进行操作。其他Stream派生的类型以类似的方式操作。例如,如果你想将一个字节序列写入内存区域,你可以分配一个MemoryStream

如前所述,System.IO名称空间提供了几个读取器写入器类型,它们封装了使用Stream派生类型的细节。

使用 streamwriter 和 streamreader

当您需要读取或写入基于字符的数据(例如字符串)时,StreamWriterStreamReader类非常有用。默认情况下,两者都使用 Unicode 字符;然而,您可以通过提供一个正确配置的System.Text.Encoding对象引用来改变这一点。为了简单起见,假设默认的 Unicode 编码符合要求。

StreamReader从一个名为TextReader的抽象类型派生而来,相关的StringReader类型也是如此(本章稍后讨论)。TextReader基类为这些后代中的每一个提供了一组有限的功能;具体来说,它提供了读取和查看字符流的能力。

StreamWriter类型(以及StringWriter,你将在本章后面研究)来自一个名为TextWriter的抽象基类。此类定义允许派生类型将文本数据写入给定字符流的成员。

为了帮助你理解StreamWriterStringWriter类的核心编写能力,表 20-8 描述了抽象TextWriter基类的核心成员。

表 20-8。

TextWriter 的核心成员

|

成员

|

生命的意义

Close() 此方法关闭编写器并释放所有关联的资源。在这个过程中,缓冲区被自动刷新(同样,这个成员在功能上等同于调用Dispose()方法)。
Flush() 此方法清除当前编写器的所有缓冲区,并将所有缓冲的数据写入基础设备;但是,它不会关闭编写器。
NewLine 此属性指示派生的 writer 类的换行符常量。Windows 操作系统的默认行结束符是回车,后面跟一个换行符(\r\n)。
Write()``WriteAsync() 这个重载方法将数据写入文本流,而不使用换行符常量。
WriteLine()``WriteLineAsync() 这个重载的方法用一个换行符常量将数据写入文本流。

Note

最后两个TextWriter类的成员可能你看起来很熟悉。如果您还记得,System.Console类型有Write()WriteLine()成员,它们将文本数据推送到标准输出设备。实际上,Console.In属性包装了一个TextReader,而Console.Out属性包装了一个TextWriter

派生的StreamWriter类为Write()Close()Flush()方法提供了适当的实现,并定义了额外的AutoFlush属性。当设置为true时,该属性强制StreamWriter在每次执行写操作时刷新所有数据。请注意,通过将AutoFlush设置为false,您可以获得更好的性能,前提是当您使用StreamWriter完成写入时,您总是调用Close()

写入文本文件

要查看运行中的StreamWriter类型,创建一个名为 StreamWriterReaderApp 的新控制台应用项目,并导入System.IOSystem.Text。下面的代码使用File.CreateText()方法在当前执行文件夹中创建一个名为reminders.txt的新文件。使用获得的StreamWriter对象,您可以向新文件添加一些文本数据。

using System;
using System.IO;
using System.Text;

Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");

// Get a StreamWriter and write string data.
using(StreamWriter writer = File.CreateText("reminders.txt"))
{
  writer.WriteLine("Don't forget Mother's Day this year...");
  writer.WriteLine("Don't forget Father's Day this year...");
  writer.WriteLine("Don't forget these numbers:");
  for(int i = 0; i < 10; i++)
  {
    writer.Write(i + " ");
  }

  // Insert a new line.
  writer.Write(writer.NewLine);
}
Console.WriteLine("Created file and wrote some thoughts...");
Console.ReadLine();
//File.Delete("reminders.txt");

运行该程序后,您可以检查这个新文件的内容。您将在项目的根目录(Visual Studio 代码)或在bin\Debug\net5.0文件夹(Visual Studio)下找到该文件,因为您在调用CreateText()时没有指定绝对路径,并且文件位置默认为程序集的当前执行目录。

从文本文件中读取

接下来,您将学习通过使用相应的StreamReader类型以编程方式从文件中读取数据。回想一下,这个类源自抽象TextReader,它提供了表 20-9 中描述的功能。

表 20-9。

TextReader核心成员

|

成员

|

生命的意义

Peek() 返回下一个可用字符(用整数表示),而不改变读取器的位置。值-1表示您位于流的末尾。
Read()``ReadAsync() 从输入流中读取数据。
ReadBlock()``ReadBlockAsync() 从当前流中读取指定的最大字符数,并将数据写入缓冲区,从指定的索引处开始。
ReadLine()``ReadLineAsync() 从当前流中读取一行字符,并将数据作为字符串返回(null字符串表示 e of)。
ReadToEnd()``ReadToEndAsync() 读取从当前位置到流尾的所有字符,并将它们作为单个字符串返回。

如果您现在扩展当前的示例应用以使用一个StreamReader,您可以从reminders.txt文件中读入文本数据,如下所示:

Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");
...
// Now read data from file.
Console.WriteLine("Here are your thoughts:\n");
using(StreamReader sr = File.OpenText("reminders.txt"))
{
  string input = null;
  while ((input = sr.ReadLine()) != null)
  {
    Console.WriteLine (input);
  }
}
Console.ReadLine();

运行程序后,你会看到reminders.txt中的字符数据显示到控制台上。

直接创建 StreamWriter/StreamReader 类型

使用System.IO中的类型的一个令人困惑的方面是,您经常可以使用不同的方法获得相同的结果。例如,您已经看到,您可以使用CreateText()方法获得带有FileFileInfo类型的StreamWriter。碰巧你可以用另一种方式处理StreamWriterStreamReader s:直接创建它们。例如,您可以对当前应用进行如下改进:

Console.WriteLine("***** Fun with StreamWriter / StreamReader *****\n");

// Get a StreamWriter and write string data.
using(StreamWriter writer = new StreamWriter("reminders.txt"))
{
  ...
}

// Now read data from file.
using(StreamReader sr = new StreamReader("reminders.txt"))
{
  ...
}

虽然看到这么多看似相同的文件 I/O 方法会让人有点困惑,但请记住,这样做的结果是更大的灵活性。无论如何,现在您已经准备好检查StringWriterStringReader类的作用,因为您已经看到了如何使用StreamWriterStreamReader类型将字符数据移入和移出给定的文件。

使用 stringwriter 和 stringreader

您可以使用StringWriterStringReader类型将文本信息视为内存中的字符流。当您想要将基于字符的信息追加到底层缓冲区时,这可能会很有帮助。下面的控制台应用项目(名为 StringReaderWriterApp)通过将一个字符串数据块写入一个StringWriter对象,而不是写入本地硬盘上的一个文件(不要忘记导入System.IOSystem.Text)来说明这一点:

using System;
using System.IO;
using System.Text;

  Console.WriteLine("***** Fun with StringWriter / StringReader *****\n");

// Create a StringWriter and emit character data to memory.
using(StringWriter strWriter = new StringWriter())
{
  strWriter.WriteLine("Don't forget Mother's Day this year...");
  // Get a copy of the contents (stored in a string) and dump
  // to console.
  Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);
}
Console.ReadLine();

StringWriterStreamWriter都来源于同一个基类(TextWriter),所以编写逻辑差不多。然而,考虑到StringWriter的性质,你也应该知道这个类允许你使用下面的GetStringBuilder()方法来提取一个System.Text.StringBuilder对象:

using (StringWriter strWriter = new StringWriter())
{
  strWriter.WriteLine("Don't forget Mother's Day this year...");
  Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);

  // Get the internal StringBuilder.
  StringBuilder sb = strWriter.GetStringBuilder();
  sb.Insert(0, "Hey!! ");
  Console.WriteLine("-> {0}", sb.ToString());
  sb.Remove(0, "Hey!! ".Length);
  Console.WriteLine("-> {0}", sb.ToString());
}

当您想从字符数据流中读取数据时,您可以使用相应的StringReader类型,它(正如您所期望的)的功能与相关的StreamReader类相同。事实上,StringReader类只不过覆盖了继承的成员,从字符数据块中读取,而不是从文件中读取,如下所示:

using (StringWriter strWriter = new StringWriter())
{
  strWriter.WriteLine("Don't forget Mother's Day this year...");
  Console.WriteLine("Contents of StringWriter:\n{0}", strWriter);

  // Read data from the StringWriter.
  using (StringReader strReader = new StringReader(strWriter.ToString()))
  {
    string input = null;
    while ((input = strReader.ReadLine()) != null)
    {
      Console.WriteLine(input);
    }
  }
}

使用 binarywriter 和 binaryreader

您将在本节中检查的最后一组写入器/读取器是BinaryReaderBinaryWriter。两者都直接来源于System.Object。这些类型允许您以紧凑的二进制格式读写基础流中的离散数据类型。BinaryWriter类定义了一个高度重载的Write()方法,将数据类型放入底层流中。除了Write()成员之外,BinaryWriter还提供了额外的成员,允许您获取或设置Stream派生的类型;它还支持对数据的随机访问(见表 20-10 )。

表 20-10。

BinaryWriter核心成员

|

成员

|

生命的意义

BaseStream 这个只读属性提供对与BinaryWriter对象一起使用的基础流的访问。
Close() 此方法关闭二进制流。
Flush() 此方法刷新二进制流。
Seek() 此方法设置当前流中的位置。
Write() 此方法将一个值写入当前流。

BinaryReader类用表 20-11 中描述的成员补充了BinaryWriter提供的功能。

表 20-11。

BinaryReader核心成员

|

成员

|

生命的意义

BaseStream 这个只读属性提供对与BinaryReader对象一起使用的基础流的访问。
Close() 此方法关闭二进制读取器。
PeekChar() 此方法返回下一个可用字符,而不提升在流中的位置。
Read() 此方法读取一组给定的字节或字符,并将它们存储在传入数组中。
ReadXXXX() BinaryReader类定义了许多从流中获取下一个类型的读取方法(例如,ReadBoolean()ReadByte()ReadInt32())。

以下示例(一个名为 BinaryWriterReader 的控制台应用项目,使用了System.IO)将一些数据类型写入一个新的*.dat文件:

using System;
using System.IO;

Console.WriteLine("***** Fun with Binary Writers / Readers *****\n");

// Open a binary writer for a file.
FileInfo f = new FileInfo("BinFile.dat");
using(BinaryWriter bw = new BinaryWriter(f.OpenWrite()))
{
  // Print out the type of BaseStream.
  // (System.IO.FileStream in this case).
  Console.WriteLine("Base stream is: {0}", bw.BaseStream);

  // Create some data to save in the file.
  double aDouble = 1234.67;
  int anInt = 34567;
  string aString = "A, B, C";

  // Write the data.
  bw.Write(aDouble);
  bw.Write(anInt);
  bw.Write(aString);
}
Console.WriteLine("Done!");
Console.ReadLine();

注意从FileInfo.OpenWrite()返回的FileStream对象是如何传递给BinaryWriter类型的构造函数的。使用这种技术使得在写出数据之前流中分层变得容易。请注意,BinaryWriter的构造函数接受任何从Stream派生的类型(例如,FileStreamMemoryStreamBufferedStream)。因此,将二进制数据写入内存就像提供一个有效的MemoryStream对象一样简单。

为了从BinFile.dat文件中读取数据,BinaryReader类型提供了几个选项。在这里,您调用各种以读取为中心的成员从文件流中提取每个数据块:

...
FileInfo f = new FileInfo("BinFile.dat");
...
// Read the binary data from the stream.
using(BinaryReader br = new BinaryReader(f.OpenRead()))
{
  Console.WriteLine(br.ReadDouble());
  Console.WriteLine(br.ReadInt32());
  Console.WriteLine(br.ReadString());
}
Console.ReadLine();

以编程方式监视文件

既然您对各种阅读器和编写器的使用有了更好的理解,那么您将会看到FileSystemWatcher类的作用。当您希望以编程方式监控(或“监视”)系统上的文件时,这种类型非常有用。具体来说,您可以指示FileSystemWatcher类型监视文件中由System.IO.NotifyFilters枚举指定的任何动作。

public enum NotifyFilters
{
  Attributes, CreationTime,
  DirectoryName, FileName,
  LastAccess, LastWrite,
  Security, Size
}

要开始使用FileSystemWatcher类型,您需要设置Path属性来指定包含您想要监控的文件的目录的名称(和位置),以及定义您想要监控的文件的文件扩展名的Filter属性。

此时,您可以选择处理ChangedCreatedDeleted事件,所有这些事件都与FileSystemEventHandler委托协同工作。此委托可以调用与以下模式匹配的任何方法:

// The FileSystemEventHandler delegate must point
// to methods matching the following signature.
void MyNotificationHandler(object source, FileSystemEventArgs e)

您还可以使用RenamedEventHandler委托类型来处理Renamed事件,这可以调用匹配以下签名的方法:

// The RenamedEventHandler delegate must point
// to methods matching the following signature.
void MyRenamedHandler(object source, RenamedEventArgs e)

虽然您可以使用传统的委托/事件语法来处理每个事件,但我们将使用新的 lambda 表达式语法。

接下来,我们来看看看一个文件的过程。下面的控制台应用项目(名为 MyDirectoryWatcher,用一个using表示System.IO)监视bin\debug\net5.0目录中的*.txt文件,并在创建、删除、修改或重命名文件时打印消息:

using System;
using System.IO;

Console.WriteLine("***** The Amazing File Watcher App *****\n");
// Establish the path to the directory to watch.
FileSystemWatcher watcher = new FileSystemWatcher();
try
{
  watcher.Path = @".";
}
catch(ArgumentException ex)
{
  Console.WriteLine(ex.Message);
  return;
}
// Set up the things to be on the lookout for.
watcher.NotifyFilter = NotifyFilters.LastAccess
  | NotifyFilters.LastWrite
  | NotifyFilters.FileName
  | NotifyFilters.DirectoryName;

// Only watch text files.
watcher.Filter = "*.txt";

// Add event handlers.
// Specify what is done when a file is changed, created, or deleted.
watcher.Changed += (s, e) =>
  Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");
watcher.Created += (s, e) =>
  Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");
watcher.Deleted += (s, e) =>
  Console.WriteLine($"File: {e.FullPath} {e.ChangeType}!");
// Specify what is done when a file is renamed.
watcher.Renamed += (s, e) =>
  Console.WriteLine($"File: {e.OldFullPath} renamed to {e.FullPath}");
// Begin watching the directory.
watcher.EnableRaisingEvents = true;

// Wait for the user to quit the program.
Console.WriteLine(@"Press 'q' to quit app.");
// Raise some events.
using (var sw = File.CreateText("Test.txt"))
{
  sw.Write("This is some text");
}
File.Move("Test.txt","Test2.txt");
File.Delete("Test2.txt");

while(Console.Read()!='q');

当您运行这个程序时,最后几行将创建、更改、重命名,然后删除一个文本文件,并在此过程中引发事件。您还可以导航到bin\debug\net5.0目录,处理文件(扩展名为*.txt)并引发其他事件。

***** The Amazing File Watcher App *****
Press 'q' to quit app.
File: .\Test.txt Created!
File: .\Test.txt Changed!
File: .\Test.txt renamed to .\Test2.txt
File: .\Test2.txt Deleted!

这就结束了本章对。NET 核心平台。虽然您肯定会在许多应用中使用这些技术,但是您可能也会发现,对象序列化服务可以极大地简化您持久存储大量数据的方式。

了解对象序列化

术语序列化描述了将对象的状态持久化(并且可能转移)到流(例如,文件流或内存流)中的过程。持久化的数据序列包含了重构(或反序列化)对象的公共状态以备后用所需的所有必要信息。使用这项技术使得保存大量数据变得轻而易举。在许多情况下,使用序列化服务保存应用数据会比使用在System.IO名称空间中找到的读取器/写入器产生更少的代码。

例如,假设您想要创建一个基于 GUI 的桌面应用,为最终用户提供一种保存他们的首选项(例如,窗口颜色和字体大小)的方法。为此,您可以定义一个名为UserPrefs的类,它封装了大约 20 条字段数据。现在,如果你要使用一个System.IO.BinaryWriter类型,你需要手动保存UserPrefs对象的每个字段。同样,如果您要将数据从一个文件加载回内存,您将需要使用一个System.IO.BinaryReader和(再次)手动读入每个值来重新配置一个新的UserPrefs对象。

这都是可行的,但是您可以通过使用可扩展标记语言(XML)或 JavaScript 对象表示法(JSON)序列化来节省大量时间。每种格式都由名称-值对组成,允许在单个文本块中表示对象的公共状态,该文本块可跨平台和编程语言使用。这样做意味着您只需几行代码就可以保持对象的整个公共状态。

Note

本书前几版涉及的BinaryFormatter类型,安全风险较高,应立即停止使用( http://aka.ms/bnaryformatter )。更安全的替代方法包括对 XML/JSON 使用BinaryReader/BinaryWriters。

。NET 核心对象序列化使得持久化对象变得容易;然而,幕后使用的流程相当复杂。例如,当对象被持久保存到流中时,所有相关联的公共数据(例如,基类数据和所包含的对象)也被自动序列化。因此,如果您试图持久化一个派生类,继承链上的所有公共数据都会随之而来。正如您将看到的,您使用一个对象图来表示一组相互关联的对象。

最后,要明白你可以将一个对象图持久化到任何 System.IO.Stream派生的类型中。重要的是数据序列正确地表示了图形中对象的状态。

对象图的作用

如前所述。NET 运行库将考虑所有相关的对象,以确保在序列化对象时公共数据被正确地持久化。这组相关对象被称为对象图。对象图提供了一种简单的方法来记录一组项目如何相互引用。对象图是而不是表示 OOP 是-a有-a 关系。相反,您可以将对象图中的箭头理解为“需要”或“依赖”

对象图中的每个对象都被赋予一个唯一的数值。请记住,分配给对象图中成员的数字是任意的,对外界没有实际意义。一旦给所有对象分配了一个数值,对象图就可以记录每个对象的依赖集。

例如,假设您已经创建了一组为一些汽车建模的类(当然)。你有一个名为Car的基类,而有-a Radio。另一个名为JamesBondCar的类扩展了Car的基本类型。图 20-1 显示了模拟这些关系的可能的对象图。

img/340876_10_En_20_Fig1_HTML.jpg

图 20-1。

一个简单的对象图

在读取对象图形时,连接箭头时可以使用短语依赖于 。因此,在图 20-1 中,你可以看到Car指的是Radio类(假定有-a 关系)。JamesBondCar指的是Car(给定是-a 关系),也指的是Radio(它继承了这个受保护的成员变量)。

当然,CLR 不会在内存中绘制图片来表示相关对象的图形。相反,图 20-1 中记录的关系是由一个数学公式表示的,看起来像这样:

[Car 3, ref 2], [Radio 2], [JamesBondCar 1, ref 3, ref 2]

如果您解析这个公式,您可以看到对象 3(Car)依赖于对象 2(Radio)。对象 2,Radio,是一只孤独的狼,不需要任何人。最后,对象 1(JamesBondCar)依赖于对象 3,也依赖于对象 2。在任何情况下,当你序列化或者反序列化一个JamesBondCar的实例时,对象图确保RadioCar类型也参与到这个过程中。

序列化过程的美妙之处在于,表示对象之间关系的图形是在幕后自动建立的。然而,正如你将在本章后面看到的,通过使用属性和接口定制序列化过程,你可以更多地参与给定对象图的构造。

创建示例类型和顶级语句

创建新的。NET 5 控制台应用命名为简单序列化。在这个项目中,添加一个名为Radio.cs的新类,并将代码更新为:

using System;
using System.Linq;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Serialization;

namespace SimpleSerialize
{
  public class Radio
  {
    public bool HasTweeters;
    public bool HasSubWoofers;
    public List<double> StationPresets;
    public string RadioId = "XF-552RR6";
    public override string ToString()
    {
      var presets = string.Join(",", StationPresets.Select(i => i.ToString()).ToList());
      return $"HasTweeters:{HasTweeters} HasSubWoofers:{HasSubWoofers} Station Presets:{presets}";
    }
  }
}

接下来,添加一个名为Car.cs的类,并更新代码以匹配清单:

using System;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Serialization;

namespace SimpleSerialize
{
  public class Car
  {
    public Radio TheRadio = new Radio();
    public bool IsHatchBack;
    public override string ToString()
      => $"IsHatchback:{IsHatchBack} Radio:{TheRadio.ToString()}";
  }
}

接下来,添加另一个名为JamesBondCar.cs的类,并对此类使用以下代码:

using System;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Serialization;

namespace SimpleSerialize
{
  public class JamesBondCar : Car
  {
    public bool CanFly;
    public bool CanSubmerge;
    public override string ToString()
      => $"CanFly:{CanFly}, CanSubmerge:{CanSubmerge} {base.ToString()}";
  }
}

最后一个类Person.cs,如下所示:

using System;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Serialization;

namespace SimpleSerialize
{
  public class Person
  {
    // A public field.
    public bool IsAlive = true;
    // A private field.
    private int PersonAge = 21;
    // Public property/private data.
    private string _fName = string.Empty;
    public string FirstName
    {
      get { return _fName; }
      set { _fName = value; }
    }
    public override string ToString() =>
    $"IsAlive:{IsAlive} FirstName:{FirstName} Age:{PersonAge} ";
  }
}

最后,将Program.cs类更新为以下起始代码:

using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Xml;
using System.Xml.Serialization;
using SimpleSerialize;

Console.WriteLine("***** Fun with Object Serialization *****\n");
// Make a JamesBondCar and set state.
JamesBondCar jbc = new()
{
  CanFly = true,
  CanSubmerge = false,
  TheRadio = new()
     {
       StationPresets = new() {89.3, 105.1, 97.1},
       HasTweeters = true
     }
};

Person p = new()
{
  FirstName = "James",
  IsAlive = true
};

现在,您已经准备好探索 XML 和 JSON 序列化了。

使用 XmlSerializer 进行序列化和反序列化

System.Xml名称空间提供了System.Xml.Serialization.XmlSerializer。您可以使用这个格式化程序将给定对象的公共状态作为纯 XML 持久化。注意,XmlSerializer要求您声明将被序列化(或反序列化)的类型。

控制生成的 XML 数据

如果你有 XML 技术的背景,你会知道确保 XML 文档中的数据符合一组建立数据的有效性的规则通常是非常重要的。理解一个有效的 XML 文档与 XML 元素的语法无关(例如,所有的开始元素必须有一个结束元素)。相反,有效文档符合商定的格式规则(例如,字段X必须表示为属性而不是子元素),这些规则通常由 XML 模式或文档类型定义(DTD)文件定义。

默认情况下,XmlSerializer将所有公共字段/属性序列化为 XML 元素,而不是 XML 属性。如果您想控制XmlSerializer如何生成结果 XML 文档,您可以用任意数量的附加。来自System.Xml.Serialization命名空间的. NET 属性。表 20-12 记录了。NET 核心属性,这些属性影响 XML 数据如何编码为流。

表 20-12。

选择System.Xml.Serialization名称空间的属性

|

.NET 属性

|

生命的意义

[XmlAttribute] 你可以用这个。NET 属性告诉XmlSerializer将数据序列化为 XML 属性(而不是子元素)。
[XmlElement] 该字段或属性将被序列化为您选择的 XML 元素。
[XmlEnum] 此属性提供枚举成员的元素名称。
[XmlRoot] 该属性控制如何构造根元素(名称空间和元素名称)。
[XmlText] 属性或字段将被序列化为 XML 文本(即根元素的开始标记和结束标记之间的内容)。
[XmlType] 该属性提供 XML 类型的名称和命名空间。

当然,您可以使用许多其他方法。NET Core 属性来控制XmlSerializer如何生成结果 XML 文档。要了解完整的细节,请在。NET Core SDK 文档。

Note

XmlSerializer要求对象图中的所有序列化类型都支持一个默认的构造函数(所以如果定义了自定义构造函数,一定要把它添加回来)。

使用 XmlSerializer 序列化对象

考虑将以下局部函数添加到您的Program.cs类中:

static void SaveAsXmlFormat<T>(T objGraph, string fileName)
{
  //Must declare type in the constructor of the XmlSerializer
  XmlSerializer xmlFormat = new XmlSerializer(typeof(T));
  using (Stream fStream = new FileStream(fileName,
    FileMode.Create, FileAccess.Write, FileShare.None))
  {
    xmlFormat.Serialize(fStream, objGraph);
  }
}

将以下代码添加到顶级语句中:

  SaveAsXmlFormat(jbc, "CarData.xml");
  Console.WriteLine("=> Saved car in XML format!");

  SaveAsXmlFormat(p, "PersonData.xml");
  Console.WriteLine("=> Saved person in XML format!");

如果您查看新生成的CarData.xml文件,您会发现如下所示的 XML 数据:

<?xml version="1.0"?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:="http://www.MyCompany.com">
  <TheRadio>
    <HasTweeters>true</HasTweeters>
    <HasSubWoofers>false</HasSubWoofers>
    <StationPresets>
      <double>89.3</double>
      <double>105.1</double>
      <double>97.1</double>
    </StationPresets>
    <RadioId>XF-552RR6</RadioId>
  </TheRadio>
  <IsHatchBack>false</IsHatchBack>
  <CanFly>true</CanFly>
  <CanSubmerge>false</CanSubmerge>
</JamesBondCar>

如果您想要指定一个自定义的 XML 名称空间来限定JamesBondCar并将canFlycanSubmerge值编码为 XML 属性,您可以通过修改 C# 对JamesBondCar的定义来实现,如下所示:

[Serializable, XmlRoot(Namespace = "http://www.MyCompany.com")]
public class JamesBondCar : Car
{
  [XmlAttribute]
  public bool CanFly;
  [XmlAttribute]
  public bool CanSubmerge;
...
}

这产生了下面的 XML 文档(注意开始的<JamesBondCar>元素):

<?xml version="1.0"""?>
<JamesBondCar xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  xmlns:xsd="http://www.w3.org/2001/XMLSchema"
  CanFly="true" CanSubmerge="false" xmlns:="http://www.MyCompany.com">
...
</JamesBondCar>

接下来,检查下面的PersonData.xml文件:

<?xml version="1.0"?>
<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
  <IsAlive>true</IsAlive>
  <FirstName>James</FirstName>
</Person>

请注意PersonAge属性是如何没有被序列化到 XML 中的。这证实了 XML 序列化只序列化公共属性和字段。

序列化对象集合

既然您已经看到了如何将单个对象持久化到流中,那么您就可以研究如何保存一组对象了。创建一个本地函数,初始化一个JamesBondCars列表,然后将它们序列化为 XML。

static void SaveListOfCarsAsXml()
{
  //Now persist a List<T> of JamesBondCars.
  List<JamesBondCar> myCars = new()
    {
      new JamesBondCar{CanFly = true, CanSubmerge = true},
      new JamesBondCar{CanFly = true, CanSubmerge = false},
      new JamesBondCar{CanFly = false, CanSubmerge = true},
      new JamesBondCar{CanFly = false, CanSubmerge = false},
    };

  using (Stream fStream = new FileStream("CarCollection.xml",
    FileMode.Create, FileAccess.Write, FileShare.None))
  {
    XmlSerializer xmlFormat = new XmlSerializer(typeof(List<JamesBondCar>));
    xmlFormat.Serialize(fStream, myCars);
  }
  Console.WriteLine("=> Saved list of cars!");
}

最后,添加下面一行来练习新函数:

SaveListOfCarsAsXml();

反序列化对象和对象集合

XML 反序列化实际上与序列化对象(和对象集合)相反。考虑下面的局部函数,将 XML 反序列化回对象图。再次注意,要处理的类型必须传递给XmlSerializer的构造函数:

static T ReadAsXmlFormat<T>(string fileName)
{
  // Create a typed instance of the XmlSerializer
  XmlSerializer xmlFormat = new XmlSerializer(typeof(T));
  using (Stream fStream = new FileStream(fileName, FileMode.Open))
  {
    T obj = default;
    obj = (T)xmlFormat.Deserialize(fStream);
    return obj;
  }
}

将以下代码添加到顶级语句中,以将 XML 重新组成对象(或对象列表):

JamesBondCar savedCar = ReadAsXmlFormat<JamesBondCar>("CarData.xml");
Console.WriteLine("Original Car: {0}",savedCar.ToString());
Console.WriteLine("Read Car: {0}",savedCar.ToString());

List<JamesBondCar> savedCars = ReadAsXmlFormat<List<JamesBondCar>>("CarCollection.xml");

用系统进行序列化和反序列化。文本. Json

System.Text.Json名称空间提供了System.Text.Json.JsonSerializer。您可以使用这个格式化程序将给定对象的公共状态持久化为 JSON。

控制生成的 JSON 数据

默认情况下,JsonSerializer使用与对象属性名称相同的名称(和大小写)将所有公共属性序列化为 JSON 名称-值对。您可以使用表 20-13 中列出的最常用属性来控制序列化过程的许多方面。

表 20-13。

选择System.text.Json.Serialization名称空间的属性

|

.NET 属性

|

生命的意义

[JsonIgnore] 该属性将被忽略。
[JsonInclude] 将包括该成员。
[JsonPropertyName] 指定序列化/反序列化成员时要使用的属性名。这通常用于解决字符大小写问题。
[JsonConstructor] 指示将 JSON 反序列化回对象图时应该使用的构造函数。

使用 JsonSerializer 序列化对象

JsonSerializer包含用于转换的静态Serialize方法。NET 核心对象(包括对象图)转换成公共属性的字符串表示形式。在 JavaScript 对象符号中,数据被表示为名称-值对。考虑将以下局部函数添加到您的Program.cs类中:

static void SaveAsJsonFormat<T>(T objGraph, string fileName)
{
  File.WriteAllText(fileName,System.Text.Json.JsonSerializer.Serialize(objGraph));
}

将以下代码添加到顶级语句中:

  SaveAsJsonFormat(jbc, "CarData.json");
  Console.WriteLine("=> Saved car in JSON format!");

  SaveAsJsonFormat(p, "PersonData.json");
  Console.WriteLine("=> Saved person in JSON format!");

当您检查创建的 JSON 文件时,您可能会惊讶地发现,CarData.json文件是空的(除了一对大括号),而PersonData.json文件只包含Firstname值。这是因为默认情况下JsonSerializer只写公共属性,不写公共字段。您将在下一节中更正这一点。

包括字段

要将公共字段包含到生成的 JSON 中,有两种选择。另一种方法是使用JsonSerializerOptions类来指示JsonSerializer包含所有字段。第二种方法是通过向应该包含在 JSON 输出中的每个公共字段添加[JsonInclude]属性来更新您的类。注意,第一种方法(使用JsonSerializationOptions)将在对象图中包含所有的公共字段。要使用这种技术排除某些公共字段,必须对要排除的字段使用JsonExclude属性。

将 SaveAsJsonFormat 方法更新为以下内容:

static void SaveAsJsonFormat<T>(T objGraph, string fileName)
{
  var options = new JsonSerializerOptions
  {
    IncludeFields = true,
  };
  File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
}

不使用JsonSerializerOptions,您可以通过将示例类中的所有公共字段更新为以下内容来获得相同的结果(注意,您可以将Xml属性留在类中,它们不会干扰JsonSerializer):

//Radio.cs
public class Radio
{
  [JsonInclude]
  public bool HasTweeters;
  [JsonInclude]
  public bool HasSubWoofers;
  [JsonInclude]
  public List<double> StationPresets;
  [JsonInclude]
  public string RadioId = "XF-552RR6";
...
}

//Car.cs
public class Car
{
  [JsonInclude]
  public Radio TheRadio = new Radio();
  [JsonInclude]
  public bool IsHatchBack;
...
}

//JamesBondCar.cs
public class JamesBondCar : Car
{
  [XmlAttribute]
  [JsonInclude]
  public bool CanFly;
  [XmlAttribute]
  [JsonInclude]
  public bool CanSubmerge;
...
}

//Person.cs
public class Person
{
  // A public field.
  [JsonInclude]
  public bool IsAlive = true;
...
}

现在,当您使用任何一种方法运行代码时,所有公共属性和字段都被写入文件。然而,当你检查内容时,你会看到 JSON 被写成缩小了。 Minified 是一种删除所有无关紧要的空白和换行符的格式。这是默认格式,主要是因为 JSON 广泛用于 RESTful 服务,并且在通过 HTTP/HTTPS 在服务之间发送信息时减少了数据包的大小。

Note

序列化 JSON 的字段处理与反序列化 JSON 相同。如果您选择将选项设置为在序列化 JSON 时包含字段,那么在反序列化 JSON 时也必须包含该选项。

漂亮印刷的 JSON

除了包含公共字段的选项,还可以指示JsonSerializer编写缩进的 JSON(并且是人类可读的)。将您的方法更新为以下内容:

static void SaveAsJsonFormat<T>(T objGraph, string fileName)
{
  var options = new JsonSerializerOptions
  {
    IncludeFields = true,
    WriteIndented = true
  };
  File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
}

现在检查一下CarData.json文件,输出更加易读。

{
  "CanFly": true,
  "CanSubmerge": false,
  "TheRadio": {
    "HasTweeters": true,
    "HasSubWoofers": false,
    "StationPresets": [
      89.3,
      105.1,
      97.1
    ],
    "RadioId": "XF-552RR6"
  },
  "IsHatchBack": false
}

PascalCase 或 camelCase JSON

Pascal 大小写是一种格式,它使用大写的第一个字符以及名称的每个重要部分。以之前的 JSON 清单为例。CanSubmerge是帕斯卡大小写的例子。另一方面,camelCase 将第一个字符设置为小写(就像本节标题中的单词 camelCase ),然后名称的每个重要部分都以大写字母开头。上例的 camel case 版本是canSubmerge

为什么这很重要?这很重要,因为大多数流行语言都是区分大小写的(比如 C#)。这意味着CanSubmergecanSubmerge是两个不同的项目。正如你在本书中所看到的,在 C# 中命名公共事物的公认标准(类、公共属性、函数等等。)就是用 Pascal 大小写。然而,大多数 JavaScript 框架更喜欢使用骆驼大小写。使用时,这可能会有问题。NET 和 C# 与其他系统交互,例如通过在 RESTful 服务之间来回传递 JSON。

幸运的是,JsonSerializer可以定制来处理大多数情况,包括大小写差异。如果没有指定命名策略,JsonSerializer在序列化和反序列化 JSON 时将使用 Pascal 大小写。要将序列化过程更改为使用 camel 大小写,请将选项更新为以下内容:

static void SaveAsJsonFormat<T>(T objGraph, string fileName)
{
  JsonSerializerOptions options = new()
  {
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    IncludeFields = true,
    WriteIndented = true,
  };
  File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));
}

现在,当您执行调用代码时,产生的 JSON 都是骆驼大小写的。

{
  "canFly": true,
  "canSubmerge": false,
  "theRadio": {
    "hasTweeters": true,
    "hasSubWoofers": false,
    "stationPresets": [
      89.3,
      105.1,
      97.1
    ],
    "radioId": "XF-552RR6"
  },
  "isHatchBack": false
}

当读取 JSON 时,默认情况下 C# 是区分大小写的。外壳与Deserialization期间使用的PropertyNamingPolicy的设置相匹配。如果没有设置,则使用默认值(帕斯卡大小写)。通过将PropertyNamingPolicy设置为 camel case,那么所有传入的 JSON 都应该在 camel case 中。如果大小写不匹配,反序列化过程(很快会介绍)就会失败。

反序列化 JSON 时还有第三种选择,那就是大小写无关。通过将PropertyNameCaseInsensitive选项设置为 true,C# 将反序列化canSubmergeCanSubmerge。下面是设置选项的代码:

JsonSerializerOptions options = new()
{
  PropertyNameCaseInsensitive = true,
  IncludeFields = true
};

用 JsonSerializer 处理数字

数字的默认处理是严格,这意味着数字将被序列化为数字(不带引号)和反序列化为数字(不带引号)。JsonSerializerOptions有一个NumberHandling属性,控制数字的读写。表 20-14 列出了JsonNumberHandling枚举中的可用值。

表 20-14。

JSON number 处理枚举值

|

枚举值

|

生命的意义

Strict (0) 数字是从数字读出来的,写成数字。不允许报价,也不会生成报价。
AllowReadingFromString (1) 可以从数字或字符串标记中读取数字。
WriteAsString (2) 数字被写成 JSON 字符串(带引号)。
AllowNamedFloatingPointLiterals (4) 可以读取NanInfinity-Infinity字符串标记,并且SingleDouble值将作为它们对应的 JSON 字符串表示形式写入。

enum 有一个flags属性,允许其值的按位组合。例如,如果要读取字符串(和数字)并将数字写成字符串,可以使用以下选项设置:

JsonSerializerOptions options = new()
{
...
  NumberHandling = JsonNumberHandling.AllowReadingFromString & JsonNumberHandling.WriteAsString
};

通过这一更改,为Car类创建的 JSON 如下所示:

{
  "canFly": true,
  "canSubmerge": false,
  "theRadio": {
    "hasTweeters": true,
    "hasSubWoofers": false,
    "stationPresets": [
      "89.3",
      "105.1",
      "97.1"
    ],
    "radioId": "XF-552RR6"
  },
  "isHatchBack": false
}

使用 JsonSerializerOption 的潜在性能问题

使用JsonSerializerOption时,最好创建一个实例,并在整个应用中重用它。记住这一点,将您的顶级语句和 JSON 方法更新如下:

JsonSerializerOptions options = new()
{
    PropertyNameCaseInsensitive = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    IncludeFields = true,
    WriteIndented = true,
    NumberHandling = JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString
};
SaveAsJsonFormat(options, jbc, "CarData.json");
Console.WriteLine("=> Saved car in JSON format!");

SaveAsJsonFormat(options, p, "PersonData.json");
Console.WriteLine("=> Saved person in JSON format!");

static void SaveAsJsonFormat<T>(JsonSerializerOptions options, T objGraph, string fileName)
=> File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(objGraph, options));

JsonSerializer 的 Web 默认值

构建 web 应用时,可以使用专门的构造函数来设置下列属性:

PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
NumberHandling = JsonNumberHandling.AllowReadingFromString

您仍然可以通过对象初始化来设置附加属性,如下所示:

JsonSerializerOptions options = new(JsonSerializerDefaults.Web)
{
  WriteIndented = true
};

序列化对象集合

将一个对象集合序列化为 JSON 的过程与单个对象是一样的。将以下局部函数添加到顶级语句的末尾:

static void SaveListOfCarsAsJson(JsonSerializerOptions options, string fileName)
{
    //Now persist a List<T> of JamesBondCars.
    List<JamesBondCar> myCars = new()
    {
        new JamesBondCar { CanFly = true, CanSubmerge = true },
        new JamesBondCar { CanFly = true, CanSubmerge = false },
        new JamesBondCar { CanFly = false, CanSubmerge = true },
        new JamesBondCar { CanFly = false, CanSubmerge = false },
    };

    File.WriteAllText(fileName, System.Text.Json.JsonSerializer.Serialize(myCars, options));
    Console.WriteLine("=> Saved list of cars!");
}

最后,添加下面一行来练习新函数:

SaveListOfCarsAsJson(options, "CarCollection.json");

反序列化对象和对象集合

就像 XML 反序列化一样,JSON 反序列化是序列化的反义词。以下函数将使用方法的泛型版本反序列化指定类型的 JSON:

static T ReadAsJsonFormat<T>(JsonSerializerOptions options, string fileName) =>
  System.Text.Json.JsonSerializer.Deserialize<T>(File.ReadAllText(fileName), options);

将以下代码添加到顶级语句中,以将 XML 重新组成对象(或对象列表):

JamesBondCar savedJsonCar = ReadAsJsonFormat<JamesBondCar>(options, "CarData.json");
Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());

List<JamesBondCar> savedJsonCars = ReadAsJsonFormat<List<JamesBondCar>>(options, "CarCollection.json");
Console.WriteLine("Read Car: {0}", savedJsonCar.ToString());

摘要

你在本章开始时研究了Directory(Info)File(Info)类型的使用。正如您所了解的,这些类允许您操作硬盘上的物理文件或目录。接下来,您研究了从抽象Stream类派生的几个类。假定Stream派生的类型在原始字节流上操作,System.IO命名空间提供了许多读取器/写入器类型(例如StreamWriterStringWriterBinaryWriter),从而简化了这个过程。在这个过程中,您还了解了由DriveType提供的功能,学习了如何使用FileSystemWatcher类型来监控文件,并了解了如何以异步方式与流进行交互。

本章还向您介绍了对象序列化服务的主题。如您所见。NET Core platform 使用一个对象图来描述您希望保存到流中的相关对象的完整集合。然后,您处理了 XML 和 JSON 序列化和反序列化。

二十一、ADO.NET 的数据访问

那个。NET Core platform 定义了几个名称空间,允许您与关系数据库系统进行交互。总的来说,这些名称空间被称为 ADO.NET。在这一章中,您将了解 about 的整体作用以及核心类型和名称空间,然后您将继续讨论 about 数据提供者的主题。那个。NET 核心平台支持许多数据提供者(都是作为。NET 核心框架并可从第三方来源获得),其中每一个都被优化以与特定的数据库管理系统(例如,Microsoft SQL Server、Oracle 和 MySQL)通信。

在理解了各种数据提供者提供的通用功能之后,您将会看到数据提供者工厂模式。正如您将看到的,使用System.Data名称空间中的类型(包括System.Data.Common和特定于提供者的名称空间,如Microsoft.Data.SqlClientSystem.Data.Odbc,以及仅用于 Windows 的System.Data.Oledb),您可以构建一个单一的代码库,该代码库可以动态地挑选底层数据提供者,而无需重新编译或重新部署应用的代码库。

接下来,您将学习如何直接使用 SQL Server 数据库提供程序,创建和打开连接以检索数据,然后继续插入、更新和删除数据,最后研究数据库事务主题。最后,您将使用 ADO.NET 执行 SQL Server 的批量复制功能,将记录列表加载到数据库中。

Note

这一章主要讲述未加工的 ADO.NET。从第二十二章开始,我将介绍实体框架(EF)核心,微软的对象关系映射(ORM)框架。由于实体框架核心使用 ADO.NET 进行后台数据访问,因此在排除数据访问故障时,对 ADO.NET 工作方式的深入了解至关重要。还有一些场景是 EF Core 无法解决的(比如执行 SQL 批量复制),你需要了解 ADO.NET 来解决这些问题。

ADO.NET 对阿多

如果您有 Microsoft 以前基于 COM 的数据访问模型(活动数据对象[ADO])的背景,并且刚刚开始使用。NET 核心平台,你需要明白 ADO.NET 除了字母 ADO 之外和 ADO 关系不大。虽然这两个系统之间确实存在一些关系(例如,每个系统都有连接和命令对象的概念),但是一些熟悉的 ADO 类型(例如,Recordset)已经不存在了。此外,您可以找到许多在传统 ADO 下没有直接对等物的新类型(例如,数据适配器)。

了解 ADO.NET 数据提供者

ADO.NET 不提供与多个数据库管理系统(DBMSs)通信的单一对象集。相反,ADO.NET 支持多个数据提供者,其中每一个都被优化为与特定的 DBMS 交互。这种方法的第一个好处是,您可以对特定的数据提供程序进行编程,以访问特定 DBMS 的任何独特功能。第二个好处是,特定的数据提供者可以直接连接到所讨论的 DBMS 的底层引擎,而无需在各层之间设置中间映射层。

简单地说,数据提供者是在给定的名称空间中定义的一组类型,它们知道如何与特定类型的数据源进行通信。无论您使用哪种数据提供程序,都定义了一组提供核心功能的类类型。表 21-1 记录了一些核心基类和它们实现的关键接口。

表 21-1。

ADO.NET 数据提供者的核心对象

|

基础类

|

相关接口

|

生命的意义

DbConnection IDbConnection 提供连接到数据存储和从数据存储断开连接的能力。连接对象还提供对相关事务对象的访问。
DbCommand IDbCommand 表示 SQL 查询或存储过程。命令对象还提供对提供程序的数据读取器对象的访问。
DbDataReader IDataReaderIDataRecord 使用服务器端游标提供对数据的只进、只读访问。
DbDataAdapter IDataAdapterIDbDataAdapter 在调用者和数据存储器之间传输。数据适配器包含一个连接和一组四个内部命令对象,用于从数据存储中选择、插入、更新和删除信息。
DbParameter IDataParameterIDbDataParameter 表示参数化查询中的命名参数。
DbTransaction IDbTransaction 封装数据库事务。

尽管这些核心类的具体名称在数据提供者之间会有所不同(例如,SqlConnectionOdbcConnection),但是每个类都是从实现相同接口(例如,IDbConnection)的同一个基类(在连接对象的情况下是DbConnection)中派生出来的。考虑到这一点,您可以正确地假设,在您学会如何使用一个数据提供者之后,其余的提供者都很简单。

Note

当您引用 ADO.NET 下的一个连接对象时,您实际上是在引用一个特定的DbConnection派生类型;没有一个类字面上叫做连接。同样的想法也适用于命令对象数据适配器对象,等等。作为命名约定,特定数据提供者中的对象以相关 DBMS 的名称为前缀(例如,SqlConnectionSqlCommandSqlDataReader)。

图 21-1 展示了 ADO.NET 数据提供商背后的大图景。客户端程序集可以是任何类型的。NET 核心应用:控制台程序,Windows 窗体,WPF,ASP.NET 核心。NET 核心代码库等等。

img/340876_10_En_21_Fig1_HTML.jpg

图 21-1。

ADO.NET 数据提供程序提供对给定 DBMS 的访问

除了图 21-1 所示的对象之外,数据提供者将为您提供其他类型;然而,这些核心对象定义了所有数据提供者的公共基线。

ADO.NET 数据提供商

和所有的一样。NET 核心,数据提供者作为 NuGet 包提供。微软和许多第三方提供商都支持这几种软件。表 21-2 记录了微软支持的一些数据提供者。

表 21-2。

一些微软支持的数据提供者

|

数据提供者

|

namespace/nu 获取包名

搜寻配置不当的 Microsoft.Data.SqlClient
开放式数据库连接性 System.Data.Odbc
OLE DB(仅适用于 Windows) System.Data.OleDb

微软 SQL Server 数据提供者提供对微软 SQL Server 数据存储的直接访问——并且仅提供对 SQL Server 数据存储(包括 SQL Azure)的直接访问。Microsoft.Data.SqlClient名称空间包含 SQL Server 提供程序使用的类型。

Note

虽然仍然支持System.Data.SqlClient,但是与 SQL Server(和 SQL Azure)交互的所有开发工作都集中在新的Microsoft.Data.SqlClient提供者库上。

ODBC 提供者(System.Data.Odbc)提供对 ODBC 连接的访问。在System.Data.Odbc名称空间中定义的 ODBC 类型通常只有在您需要与没有自定义的给定 DBMS 通信时才有用。NET 核心数据提供者。这是真的,因为 ODBC 是一种广泛使用的模型,它提供了对多个数据存储的访问。

OLE DB 数据提供程序由在System.Data.OleDb命名空间中定义的类型组成,它允许您访问位于任何支持传统的基于 COM 的 OLE DB 协议的数据存储中的数据。由于对 COM 的依赖,此提供程序只能在 Windows 操作系统上工作,在的跨平台环境中应被视为不推荐使用。NET 核心。

系统的类型。数据命名空间

在所有的 ADO.NET 名称空间中,System.Data是最小的公分母。此命名空间包含所有 among 数据提供程序共享的类型,而不考虑基础数据存储。除了许多以数据库为中心的异常(例如,NoNullAllowedExceptionRowNotInTableExceptionMissingPrimaryKeyException),System.Data包含表示各种数据库原语(例如,表、行、列和约束)的类型,以及由数据提供者对象实现的公共接口。表 21-3 列出了一些你应该知道的核心类型。

表 21-3。

System.Data名称空间的核心成员

|

类型

|

生命的意义

Constraint 代表给定DataColumn对象的约束
DataColumn 表示一个DataTable对象中的一列
DataRelation 表示两个DataTable对象之间的父子关系
DataRow 代表一个DataTable对象中的一行
DataSet 表示由任意数量的相关DataTable对象组成的数据的内存缓存
DataTable 表示内存中数据的表格块
DataTableReader 允许您将DataTable视为消防水龙带光标(只进、只读数据访问)
DataView 表示用于排序、过滤、搜索、编辑和导航的DataTable的定制视图
IDataAdapter 定义数据适配器对象的核心行为
IDataParameter 定义参数对象的核心行为
IDataReader 定义数据读取器对象的核心行为
IDbCommand 定义命令对象的核心行为
IDbDataAdapter 扩展IDataAdapter以提供数据适配器对象的附加功能
IDbTransaction 定义事务对象的核心行为

你的下一个任务是在高层次上检查System.Data的核心接口;这可以帮助您理解任何数据提供者提供的通用功能。在本章中,您还将了解具体的细节;然而,现在最好关注每种接口类型的整体行为。

IDbConnection 接口的作用

IDbConnection类型由数据提供者的连接对象实现。此接口定义了一组用于配置到特定数据存储的连接的成员。它还允许您获取数据提供者的事务对象。下面是IDbConnection的正式定义:

public interface IDbConnection : IDisposable
{
  string ConnectionString { get; set; }
  int ConnectionTimeout { get; }
  string Database { get; }
  ConnectionState State { get; }

  IDbTransaction BeginTransaction();
  IDbTransaction BeginTransaction(IsolationLevel il);
  void ChangeDatabase(string databaseName);
  void Close();
  IDbCommand CreateCommand();
  void Open();
  void Dispose();
}

IDbTransaction 接口的作用

IDbConnection定义的重载BeginTransaction()方法提供了对提供者的事务对象的访问。您可以使用由IDbTransaction定义的成员以编程方式与事务性会话和底层数据存储进行交互。

public interface IDbTransaction : IDisposable
{
  IDbConnection Connection { get; }
  IsolationLevel IsolationLevel { get; }

  void Commit();
  void Rollback();
  void Dispose();
}

IDbCommand 接口的作用

接下来是IDbCommand接口,它将由数据提供者的命令对象实现。与其他数据访问对象模型一样,命令对象允许对 SQL 语句、存储过程和参数化查询进行编程操作。命令对象还通过重载的ExecuteReader()方法提供对数据提供者的数据读取器类型的访问。

public interface IDbCommand : IDisposable
{
  string CommandText { get; set; }
  int CommandTimeout { get; set; }
  CommandType CommandType { get; set; }
  IDbConnection Connection { get; set; }
  IDbTransaction Transaction { get; set; }
  IDataParameterCollection Parameters { get; }
  UpdateRowSource UpdatedRowSource { get; set; }

  void Prepare();
  void Cancel();
  IDbDataParameter CreateParameter();
  int ExecuteNonQuery();
  IDataReader ExecuteReader();
  IDataReader ExecuteReader(CommandBehavior behavior);
  object ExecuteScalar();
  void Dispose();
}

IDbDataParameter 和 IDataParameter 接口的作用

注意,IDbCommandParameters属性返回一个实现了IDataParameterCollection的强类型集合。该接口提供对一组符合IDbDataParameter的类类型(例如,参数对象)的访问。

public interface IDbDataParameter : IDataParameter
{
//Plus members in the IDataParameter interface
  byte Precision { get; set; }
  byte Scale { get; set; }
  int Size { get; set; }
}

IDbDataParameter扩展IDataParameter接口以获得以下附加行为:

public interface IDataParameter
{
  DbType DbType { get; set; }
  ParameterDirection Direction { get; set; }
  bool IsNullable { get; }
  string ParameterName { get; set; }
  string SourceColumn { get; set; }
  DataRowVersion SourceVersion { get; set; }
  object Value { get; set; }
}

正如您将看到的,IDbDataParameterIDataParameter接口的功能允许您通过特定的 ADO.NET 参数对象来表示 SQL 命令(包括存储过程)中的参数,而不是通过硬编码的字符串文字。

IDbDataAdapter 和 IDataAdapter 接口的作用

您使用数据适配器DataSet推送到给定的数据存储,或者从给定的数据存储中拉出。IDbDataAdapter接口定义了以下一组属性,您可以使用这些属性来维护相关选择、插入、更新和删除操作的 SQL 语句:

public interface IDbDataAdapter : IDataAdapter
{
  //Plus members of IDataAdapter
  IDbCommand SelectCommand { get; set; }
  IDbCommand InsertCommand { get; set; }
  IDbCommand UpdateCommand { get; set; }
  IDbCommand DeleteCommand { get; set; }
}

除了这四个属性之外,ADO.NET 数据适配器还会选择基本接口IDataAdapter中定义的行为。这个接口定义了数据适配器类型的关键功能:使用Fill()Update()方法在调用者和底层数据存储之间传输DataSet的能力。IDataAdapter接口还允许您使用TableMappings属性将数据库列名映射到更加用户友好的显示名称。

public interface IDataAdapter
{
  MissingMappingAction MissingMappingAction { get; set; }
  MissingSchemaAction MissingSchemaAction { get; set; }
  ITableMappingCollection TableMappings { get; }

  DataTable[] FillSchema(DataSet dataSet, SchemaType schemaType);
  int Fill(DataSet dataSet);
  IDataParameter[] GetFillParameters();
  int Update(DataSet dataSet);
}

IDataReader 和 IDataRecord 接口的作用

下一个需要注意的关键接口是IDataReader,它表示给定数据读取器对象支持的常见行为。当您从 ADO.NET 数据提供者那里获得一个与IDataReader兼容的类型时,您可以以只进、只读的方式迭代结果集。

public interface IDataReader : IDisposable, IDataRecord
{
  //Plus members from IDataRecord
  int Depth { get; }
  bool IsClosed { get; }
  int RecordsAffected { get; }

  void Close();
  DataTable GetSchemaTable();
  bool NextResult();
  bool Read();
  Dispose();
}

最后,IDataReader扩展了IDataRecord,它定义了许多成员,允许您从流中提取强类型值,而不是强制转换从数据读取器的重载索引器方法中检索的通用System.Object。下面是IDataRecord interface的定义:

public interface IDataRecord
{
  int FieldCount { get; }
  object this[ int i ] { get; }
  object this[ string name ] { get; }
  bool GetBoolean(int i);
  byte GetByte(int i);
  long GetBytes(int i, long fieldOffset, byte[] buffer,
    int bufferoffset, int length);
  char GetChar(int i);
  long GetChars(int i, long fieldoffset, char[] buffer,
    int bufferoffset, int length);
  IDataReader GetData(int i);
  string GetDataTypeName(int i);
  DateTime GetDateTime(int i);
  Decimal GetDecimal(int i);
  double GetDouble(int i);
  Type GetFieldType(int i);
  float GetFloat(int i);
  Guid GetGuid(int i);
  short GetInt16(int i);
  int GetInt32(int i);
  long GetInt64(int i);
  string GetName(int i);
  int GetOrdinal(string name);
  string GetString(int i);
  object GetValue(int i);
  int GetValues(object[] values);
  bool IsDBNull(int i);
}

Note

在尝试从数据读取器获取值之前,可以使用IDataReader.IsDBNull()方法以编程方式发现指定的字段是否设置为null(以避免触发运行时异常)。还记得 C# 支持可空数据类型(参见第四章的,这是与数据库表中可能是null的数据列交互的理想选择。

使用接口抽象数据提供者

至此,您应该对所有这些工具的共同功能有了更好的了解。NET 核心数据提供者。回想一下,尽管实现类型的确切名称在不同的数据提供者之间会有所不同,但是您可以以类似的方式针对这些类型进行编程——这就是基于接口的多态性的美妙之处。例如,如果您定义一个采用IDbConnection参数的方法,您可以传入任何 ADO.NET 连接对象,如下所示:

public static void OpenConnection(IDbConnection cn)
{
  // Open the incoming connection for the caller.
  connection.Open();
}

Note

接口不是严格要求的;使用抽象基类(如DbConnection)作为参数或返回值,可以达到相同的抽象级别。然而,使用接口而不是基类是普遍接受的最佳实践。

这同样适用于成员返回值。创建新的。NET 核心控制台应用名为 MyConnectionFactory。将以下 NuGet 包添加到项目中(OleDb包仅在 Windows 上有效):

  • Microsoft.Data.SqlClient

  • System.Data.Common

  • System.Data.Odbc

  • System.Data.OleDb

接下来,添加一个名为DataProviderEnum.cs的新文件,并将代码更新如下:

namespace MyConnectionFactory
{
  //OleDb is Windows only and is not supported in .NET Core
  enum DataProviderEnum
  {
    SqlServer,
#if PC
    OleDb,
#endif
    Odbc,
    None
  }
}

如果您在开发机器上使用 Windows 操作系统,请更新项目文件以定义条件编译器符号PC

<PropertyGroup>
  <DefineConstants>PC</DefineConstants>
</PropertyGroup>

如果您使用的是 Visual Studio,请右击项目,选择“属性”,然后转到“生成”选项卡,输入“条件编译器符号”值。

下面的代码示例允许您基于自定义枚举的值获取特定的连接对象。出于诊断目的,您只需使用反射服务打印底层连接对象。

using System;
using System.Data;
using System.Data.Odbc;
#if PC
  using System.Data.OleDb;
#endif
using Microsoft.Data.SqlClient;
using MyConnectionFactory;

Console.WriteLine("**** Very Simple Connection Factory *****\n");
Setup(DataProviderEnum.SqlServer);
#if PC
  Setup(DataProviderEnum.OleDb); //Not supported on macOS
#endif
Setup(DataProviderEnum.Odbc);
Setup(DataProviderEnum.None);
Console.ReadLine();

void Setup(DataProviderEnum provider)
{
  // Get a specific connection.
  IDbConnection myConnection = GetConnection(provider);
  Console.WriteLine($"Your connection is a {myConnection?.GetType().Name ?? "unrecognized type"}");
  // Open, use and close connection...
}

// This method returns a specific connection object
// based on the value of a DataProvider enum.
IDbConnection GetConnection(DataProviderEnum dataProvider)
  => dataProvider switch
  {
    DataProviderEnum.SqlServer => new SqlConnection(),
#if PC
    //Not supported on macOS
    DataProviderEnum.OleDb => new OleDbConnection(),
#endif
    DataProviderEnum.Odbc => new OdbcConnection(),
    _ => null,
  };

使用System.Data的通用接口(或者,就此而言,System.Data.Common的抽象基类)的好处是,您有更好的机会构建一个灵活的代码库,可以随着时间的推移而发展。例如,今天您可能正在构建一个面向 Microsoft SQL Server 的应用;但是,您的公司可能会切换到不同的数据库。如果您构建的解决方案对特定于 Microsoft SQL Server 的System.Data.SqlClient类型进行了硬编码,那么您将需要为新的数据库提供者编辑、重新编译和重新部署代码。

至此,您已经编写了一些(相当简单的)point 代码,允许您创建不同类型的特定于提供者的连接对象。然而,获得连接对象只是使用 ADO.NET 的一个方面。要创建一个有价值的数据提供者工厂库,您还必须考虑命令对象、数据读取器、事务对象和其他以数据为中心的类型。构建这样一个代码库并不困难,但是需要大量的代码和时间。

自从发布以来。在. NET 2.0 中,Redmond 的好心人已经将这种精确的功能直接构建到。NET 基础类库。此功能已针对进行了重大更新。NET 核心。

一会儿您将检查这个正式的 API 但是,首先您需要创建一个自定义数据库,以便在本章(以及后面的许多章节)中使用。

设置 SQL Server 和 Azure Data Studio

在学习本章的过程中,您将对一个名为AutoLot的简单 SQL Server 测试数据库执行查询。为了与本书通篇使用的汽车主题保持一致,该数据库将包含五个相互关联的表(InventoryMakesOrdersCustomersCreditRisks),这些表包含代表一家虚构的汽车销售公司的信息的各种数据。在了解数据库详细信息之前,您必须设置 SQL Server 和 SQL Server IDE。

Note

如果您使用的是基于 Windows 的开发机器,并且安装了 Visual Studio 2019,那么您还安装了 SQL Server Express 的一个实例(名为localdb),它可以用于本书中的所有示例。如果您愿意使用该版本,请跳到“安装 SQL Server IDE”一节

正在安装 SQL Server

对于本章以及本书中的许多剩余章节,您需要能够访问 SQL Server 的实例。如果您使用的是非基于 Windows 的开发机器,并且没有可用的外部 SQL Server 实例,或者选择不使用外部 SQL Server 实例,则可以在基于 Mac 或 Linux 的工作站上的 Docker 容器中运行 SQL Server。Docker 也可以在 Windows 机器上运行,所以不管你选择什么操作系统,都欢迎你使用 Docker 来运行本书中的例子。

在 Docker 容器中安装 SQL Server

如果您使用的是非基于 Windows 的开发计算机,并且没有可用于示例的 SQL Server 实例,则可以在基于 Mac 或 Linux 的工作站上的 Docker 容器中运行 SQL Server。Docker 也可以在 Windows 机器上运行,所以不管你选择什么操作系统,都欢迎你使用 Docker 来运行本书中的例子。

Note

集装箱化是一个很大的话题,在这本书里没有足够的空间来深入探讨集装箱或码头的细节。这本书将涵盖足够的内容,因此你可以通过例子。

Docker 桌面可以从 www.docker.com/get-started 下载。为您的工作站下载并安装合适的版本(Windows、Mac、Linux)(您需要一个免费的 DockerHub 用户帐户)。确保在出现提示时选择 Linux 容器。

Note

容器选择(Windows 或 Linux)是在容器内运行的操作系统,而不是您工作站的操作系统。

提取映像并运行 SQL Server 2019

容器基于图像,每个图像都是构建最终产品的分层集合。要在容器中获取运行 SQL Server 2019 所需的映像,请打开命令窗口并输入以下命令:

docker pull mcr.microsoft.com/mssql/server:2019-latest

一旦将映像加载到机器上,就需要启动 SQL Server。为此,请输入以下命令(全部在一行中):

docker run -e "ACCEPT_EULA=Y" -e "SA_PASSWORD=P@ssw0rd" -p 5433:1433 --name AutoLot -d mcr.microsoft.com/mssql/server:2019-latest

前面的命令接受最终用户许可协议,设置密码(在现实生活中,您需要使用强密码),设置端口映射(您机器上的端口 5433 映射到容器中 SQL Server 的默认端口(1433),然后命名容器(AutoLot),最后通知 Docker 使用之前下载的映像。

Note

这些不是您想要在实际开发中使用的设置。有关更改 SA 密码的信息以及查看教程,请转至 https://docs.microsoft.com/en-us/sql/linux/quickstart-install-connect-docker?view=sql-server-ver15&pivots=cs1-bash

要确认它正在运行,请在命令提示符下输入命令docker ps -a。您将看到如下所示的输出(为简洁起见,省略了一些列):

C:\Users\japik>docker ps -a
CONTAINER ID IMAGE                                     STATUS         PORTS          NAMES
347475cfb823 mcr.microsoft.com/mssql/server:2019-latest Up 6 minutes 0.0.0.0:5433->1433/tcp   AutoLot

要停止集装箱,输入docker stop 34747,其中数字 34747 是集装箱 ID 的前五个字符。要重启容器,输入docker start 34747,再次用容器 ID 的开头更新命令。

Note

您还可以在 Docker CLI 命令中使用容器的名称(本例中为AutoLot),例如docker start AutoLot。请注意,无论操作系统如何,Docker 命令都是区分大小写的。

如果您想使用 Docker Dashboard,右键单击 Docker ship(在您的系统托盘中)并选择 Dashboard,您应该会看到在端口 5433 上运行的图像。将鼠标悬停在图像名称上,您将看到停止、启动和删除命令(以及其他命令),如图 21-2 所示。

img/340876_10_En_21_Fig2_HTML.jpg

图 21-2。

Docker 仪表板

正在安装 SQL Server 2019

SQL Server 的一个特殊实例(名为(localdb)\mssqllocaldb)随 Visual Studio 2019 一起安装。如果选择不使用 SQL Server Express LocalDB(或 Docker),并且使用的是 Windows 机器,则可以安装 SQL Server 2019 Developer Edition。SQL Server 2019 开发者版是免费的,可以从这里下载:

https://www.microsoft.com/en-us/sql-server/sql-server-downloads

如果你有另一个版本,你也可以在这本书上使用那个实例;您只需要适当地更改您的连接屏幕。

安装 SQL Server IDE

Azure Data Studio 是一个用于 SQL Server 的新 IDE。它是免费的和跨平台的,所以它可以在 Windows、Mac 或 Linux 上运行。可以从这里下载:

https://docs.microsoft.com/en-us/sql/azure-data-studio/download-azure-data-studio

Note

如果您使用的是 Windows 机器,并且更喜欢使用 SQL Server Management Studio (SSMS),您可以从这里下载最新版本: https://docs.microsoft.com/en-us/sql/ssms/download-sql-server-management-studio-ssms

正在连接到 SQL Server

一旦安装了 Azure Data Studio 或 SSMS,就该连接到数据库实例了。以下部分介绍了 Docker 或 LocalDb 容器中 SQL Server 的连接。如果您使用的是 SQL Server 的另一个实例,请相应地更新以下部分中使用的连接字符串。

连接到 Docker 容器中的 SQL Server

要连接到 Docker 容器中运行的 SQL Server 实例,首先要确保它已经启动并正在运行。接下来在 Azure Data Studio 中点击“创建连接”,如图 21-3 所示。

img/340876_10_En_21_Fig3_HTML.jpg

图 21-3。

在 Azure Data Studio 中创建连接

在连接细节对话框中,输入**。,5433"** 为服务器值。圆点表示当前主机,5433 是您在 Docker 容器中创建 SQL Server 实例时指定的端口。输入 sa 作为用户名;并且密码与您在创建 SQL Server 实例时输入的密码相同。该名称是可选的,但允许您在后续 Azure Data Studio 会话中快速选择此连接。图 21-4 显示了这些连接选项。

img/340876_10_En_21_Fig4_HTML.jpg

图 21-4。

为 Docker SQL Server 设置连接选项

正在连接到 SQL Server LocalDb

若要连接到 SQL Server Express LocalDb 的 Visual Studio 安装版本,请更新连接信息以匹配图 21-5 中所示的内容。

img/340876_10_En_21_Fig5_HTML.jpg

图 21-5。

为 SQL Server LocalDb 设置连接选项

当连接到 LocalDb 时,您可以使用 Windows 身份验证,因为该实例与 Azure Data Studio 运行在同一台计算机上,并且与当前登录的用户具有相同的安全上下文。

连接到任何其他 SQL Server 实例

如果要连接到任何其他 SQL Server 实例,请相应地更新连接属性。

恢复汽车人数据库备份

您可以使用 SSMS 或 Azure Data Studio 来恢复包含在存储库中的章节文件中的备份,而不是从头开始构建数据库。提供了两个备份:名为AutoLotWindows.ba_的备份是为在 Windows 机器(LocalDb、Windows Server 等)上使用而设计的。),名为AutoLotDocker.ba_的是为 Docker 容器设计的。

Note

默认情况下,Git 会忽略扩展名为bak的文件。在恢复数据库之前,您需要将扩展名从ba_重命名为bak

将备份文件复制到容器中

如果在 Docker 容器中使用 SQL Server,首先必须将备份文件复制到容器中。幸运的是,Docker CLI 提供了一种处理容器文件系统的机制。首先,在主机上的命令窗口中使用以下命令为备份创建一个新目录:

docker exec -it AutoLot mkdir var/opt/mssql/backup

路径结构必须匹配容器的操作系统(在本例中是 Ubuntu),即使您的主机是基于 Windows 的。接下来,使用以下命令将备份复制到您的新目录(将AutoLotDocker.bak的位置更新到您本地机器的相对或绝对路径):

[Windows]
docker cp .\AutoLotDocker.bak AutoLot:var/opt/mssql/backup

[Non-Windows]
docker cp ./AutoLotDocker.bak AutoLot:var/opt/mssql/backup

注意,源目录结构匹配主机(在我的例子中是 Windows),而目标是容器名,然后是目录路径(以目标 OS 格式)。

使用 SSMS 恢复数据库

若要使用 SSMS 还原数据库,请在对象资源管理器中右键单击“数据库”节点。选择恢复数据库。选择设备并单击省略号。这将打开“选择备份设备”对话框。

将数据库还原到 SQL Server (Docker)

保持“备份媒体类型”设置为文件,然后单击添加,导航到容器中的AutoLotDocker.bak文件,并单击确定。当你回到主恢复屏幕时,点击确定,如图 21-6 所示。

img/340876_10_En_21_Fig6_HTML.jpg

图 21-6。

使用 SSMS 恢复数据库

将数据库还原到 SQL Server (Windows)

保持“备份媒体类型”设置为文件,然后点击添加,导航至AutoLotWindows.bak,并点击确定。当你回到主恢复屏幕时,点击确定,如图 21-7 所示。

img/340876_10_En_21_Fig7_HTML.jpg

图 21-7。

使用 SSMS 恢复数据库

使用 Azure Data Studio 恢复数据库

要使用 Azure Data Studio 恢复数据库,请单击查看,选择命令面板(或按 Ctrl+Shift+P),然后选择恢复。选择“备份文件”作为“恢复自”选项,然后选择您刚刚复制的文件。目标数据库和相关字段将为您填充,如图 21-8 所示。

img/340876_10_En_21_Fig8_HTML.jpg

图 21-8。

使用 Azure Data Studio 将数据库恢复到 Docker

Note

使用 Azure Data Studio 恢复 Windows 版本备份的过程是相同的。只需调整文件名和路径。

创建汽车人数据库

这一整节致力于使用 Azure Data Studio 创建AutoLot数据库。如果您正在使用 SSMS,您可以使用这里讨论的 SQL 脚本或者使用 GUI 工具来执行这些步骤。如果您恢复了备份,可以跳到“ADO.NET 数据提供者工厂模型”一节

Note

所有的脚本文件都位于 Git 存储库中的一个名为Scripts的文件夹中。

创建数据库

要创建AutoLot数据库,使用 Azure Data Studio 连接到您的数据库服务器。通过选择文件➤新查询(或按 Ctrl+N)并输入以下命令文本来打开新查询:

USE [master]
GO
/****** Object:  Database [AutoLot50]    Script Date: 12/20/2020 01:48:05 ******/
CREATE DATABASE [AutoLot]
GO
ALTER DATABASE [AutoLot50] SET RECOVERY SIMPLE
GO

除了将恢复模式更改为 simple 之外,它还使用 SQL Server 默认值创建了AutoLot数据库。单击运行(或按 F5)创建数据库。

创建表

AutoLot数据库包含五个表格:InventoryMakesCustomersOrdersCreditRisks

创建库存表

创建了数据库之后,就该创建表了。首先是Inventory表。打开一个新查询,并输入以下 SQL:

USE [AutoLot]
GO
CREATE TABLE [dbo].Inventory NOT NULL,
    [MakeId] [int] NOT NULL,
    [Color] nvarchar NOT NULL,
    [PetName] nvarchar NOT NULL,
    [TimeStamp] [timestamp] NULL,
 CONSTRAINT [PK_Inventory] PRIMARY KEY CLUSTERED
(
  [Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO

单击运行(或按 F5)创建表。

创建 Makes 表

Inventory表存储(尚未创建)Makes表的外键。创建一个新的查询,并输入以下 SQL 来创建Makes表:

USE [AutoLot]
GO
CREATE TABLE [dbo].Makes NOT NULL,
  [Name] nvarchar NOT NULL,
  [TimeStamp] [timestamp] NULL,
 CONSTRAINT [PK_Makes] PRIMARY KEY CLUSTERED
(
  [Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO

单击运行(或按 F5)创建表。

创建客户表

Customers表(顾名思义)将包含一个客户列表。创建一个新查询,并输入以下 SQL 命令:

USE [AutoLot]
GO
CREATE TABLE [dbo].Customers NOT NULL,
  [FirstName] nvarchar NOT NULL,
  [LastName] nvarchar NOT NULL,
  [TimeStamp] [timestamp] NULL,
 CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED
(
  [Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO

点击运行(或按 F5)创建Customers表。

创建订单表

您将使用下一个表Orders来表示给定客户订购的汽车。创建一个新查询,输入以下代码,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
CREATE TABLE [dbo].Orders NOT NULL,
  [CustomerId] [int] NOT NULL,
  [CarId] [int] NOT NULL,
  [TimeStamp] [timestamp] NULL,
 CONSTRAINT [PK_Orders] PRIMARY KEY CLUSTERED
(
  [Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO

创建信用风险表

您将使用您的最终表CreditRisks来代表被认为有信用风险的客户。创建一个新查询,输入以下代码,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
CREATE TABLE [dbo].CreditRisks NOT NULL,
  [FirstName] nvarchar NOT NULL,
  [LastName] nvarchar NOT NULL,
  [CustomerId] [int] NOT NULL,
  [TimeStamp] [timestamp] NULL,
 CONSTRAINT [PK_CreditRisks] PRIMARY KEY CLUSTERED
(
    [Id] ASC
) ON [PRIMARY]
) ON [PRIMARY]
GO

创建表关系

下一节将添加相关表之间的外键关系。

创建库存以建立关系

打开一个新查询,输入以下 SQL,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
CREATE NONCLUSTERED INDEX [IX_Inventory_MakeId] ON [dbo].[Inventory]
(
  [MakeId] ASC
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Inventory]  WITH CHECK ADD  CONSTRAINT [FK_Make_Inventory] FOREIGN KEY([MakeId])
REFERENCES [dbo].[Makes] ([Id])
GO
ALTER TABLE [dbo].[Inventory] CHECK CONSTRAINT [FK_Make_Inventory]
GO

创建库存与订单的关系

打开一个新查询,输入以下 SQL,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
CREATE NONCLUSTERED INDEX [IX_Orders_CarId] ON [dbo].[Orders]
(
  [CarId] ASC
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Orders]  WITH CHECK ADD  CONSTRAINT [FK_Orders_Inventory] FOREIGN KEY([CarId])
REFERENCES [dbo].[Inventory] ([Id])
GO
ALTER TABLE [dbo].[Orders] CHECK CONSTRAINT [FK_Orders_Inventory]
GO

创建订单到客户的关系

打开一个新查询,输入以下 SQL,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
CREATE UNIQUE NONCLUSTERED INDEX [IX_Orders_CustomerId_CarId] ON [dbo].[Orders]
(
  [CustomerId] ASC,
  [CarId] ASC
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Orders]  WITH CHECK ADD  CONSTRAINT [FK_Orders_Customers] FOREIGN KEY([CustomerId])
REFERENCES [dbo].[Customers] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[Orders] CHECK CONSTRAINT [FK_Orders_Customers]
GO

创建客户与信贷风险的关系

打开一个新查询,输入以下 SQL,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
CREATE NONCLUSTERED INDEX [IX_CreditRisks_CustomerId] ON [dbo].[CreditRisks]
(
  [CustomerId] ASC
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[CreditRisks]  WITH CHECK ADD  CONSTRAINT [FK_CreditRisks_Customers] FOREIGN KEY([CustomerId])
REFERENCES [dbo].[Customers] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[CreditRisks] CHECK CONSTRAINT [FK_CreditRisks_Customers]
GO

Note

如果您想知道为什么有与客户表有关系的列FirstNameLastName ,这只是为了演示的目的。我可以为它想出一个创造性的理由,但最终,它很好地建立了第二十三章。

创建 GetPetName()存储过程

在本章的后面,你将学习如何使用 ADO.NET 来调用存储过程。正如您可能已经知道的那样,存储过程是存储在数据库中的代码例程,用来做一些事情。像 C# 方法一样,存储过程可以返回数据或者只对数据进行操作而不返回任何东西。您将添加一个单独的存储过程,它将根据提供的carId返回汽车的昵称。为此,创建一个新的查询窗口,并输入以下 SQL 命令:

USE [AutoLot]
GO
CREATE PROCEDURE [dbo].[GetPetName]
@carID int,
@petName nvarchar(50) output
AS
SELECT @petName = PetName from dbo.Inventory where Id = @carID
GO

单击“运行”(或按 F5)创建存储过程。

添加测试记录

没有数据的数据库是相当枯燥的,拥有能够快速将测试记录加载到数据库中的脚本是一个好主意。

制作表格记录

创建一个新的查询并执行以下 SQL 语句,将记录添加到Makes表中:

USE [AutoLot]
GO
SET IDENTITY_INSERT [dbo].[Makes] ON
INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (1, N'VW')
INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (2, N'Ford')
INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (3, N'Saab')
INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (4, N'Yugo')
INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (5, N'BMW')
INSERT INTO [dbo].[Makes] ([Id], [Name]) VALUES (6, N'Pinto')
SET IDENTITY_INSERT [dbo].[Makes] OFF

库存表记录

要将记录添加到您的第一个表中,创建一个新的查询并执行以下 SQL 语句将记录添加到Inventory表中:

USE [AutoLot]
GO
SET IDENTITY_INSERT [dbo].[Inventory] ON
GO
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (1, 1, N'Black', N'Zippy')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (2, 2, N'Rust', N'Rusty')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (3, 3, N'Black', N'Mel')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (4, 4, N'Yellow', N'Clunker')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (5, 5, N'Black', N'Bimmer')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (6, 5, N'Green', N'Hank')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (7, 5, N'Pink', N'Pinky')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (8, 6, N'Black', N'Pete')
INSERT INTO [dbo].[Inventory] ([Id], [MakeId], [Color], [PetName]) VALUES (9, 4, N'Brown', N'Brownie')SET IDENTITY_INSERT [dbo].[Inventory] OFF
GO

向客户表中添加测试记录

要向Customers表添加记录,创建一个新的查询并执行以下 SQL 语句:

USE [AutoLot]
GO
SET IDENTITY_INSERT [dbo].[Customers] ON
INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName]) VALUES (1, N'Dave', N'Brenner')
INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName]) VALUES (2, N'Matt', N'Walton')
INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName]) VALUES (3, N'Steve', N'Hagen')
INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName]) VALUES (4, N'Pat', N'Walton')
INSERT INTO [dbo].[Customers] ([Id], [FirstName], [LastName]) VALUES (5, N'Bad', N'Customer')
SET IDENTITY_INSERT [dbo].[Customers] OFF

向订单表中添加测试记录

现在将数据添加到您的Orders表中。创建一个新查询,输入以下 SQL,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
SET IDENTITY_INSERT [dbo].[Orders] ON
INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (1, 1, 5)
INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (2, 2, 1)
INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (3, 3, 4)
INSERT INTO [dbo].[Orders] ([Id], [CustomerId], [CarId]) VALUES (4, 4, 7)
SET IDENTITY_INSERT [dbo].[Orders] OFF

向 CreditRisks 表添加测试记录

最后一步是向CreditRisks表添加数据。创建一个新查询,输入以下 SQL,然后单击 Run(或按 F5 键):

USE [AutoLot]
GO
SET IDENTITY_INSERT [dbo].[CreditRisks] ON
INSERT INTO [dbo].[CreditRisks] ([Id], [FirstName], [LastName], [CustomerId]) VALUES (1, N'Bad', N'Customer', 5)
SET IDENTITY_INSERT [dbo].[CreditRisks] OFF

至此,AutoLot数据库完成!当然,这与真实世界的应用数据库相去甚远,但是它将满足您对本章的需求,并将被添加到实体框架核心章节中。既然您已经有了一个要测试的数据库,那么您可以深入研究 ADO.NET 数据提供者工厂模型的细节了。

ADO.NET 数据提供者工厂模型

那个。NET 核心数据提供者工厂模式允许您使用通用的数据访问类型构建一个单一的代码库。为了理解数据提供者工厂实现,从表 21-1 中回忆一下,数据提供者中的类都是从System.Data.Common名称空间中定义的相同基类中派生出来的。

  • DbCommand:所有命令类的抽象基类

  • DbConnection:所有连接类的抽象基类

  • DbDataAdapter:所有数据适配器类的抽象基类

  • DbDataReader:所有数据读取器类的抽象基类

  • DbParameter:所有参数类的抽象基类

  • DbTransaction:所有交易类的抽象基类

每一个。NET Core 兼容的数据提供程序包含一个从System.Data.Common.DbProviderFactory派生的类类型。这个基类定义了几个检索特定于提供程序的数据对象的方法。以下是DbProviderFactory的成员:

public abstract class DbProviderFactory
{
..public virtual bool CanCreateDataAdapter { get;};
..public virtual bool CanCreateCommandBuilder { get;};
  public virtual DbCommand CreateCommand();
  public virtual DbCommandBuilder CreateCommandBuilder();
  public virtual DbConnection CreateConnection();
  public virtual DbConnectionStringBuilder
    CreateConnectionStringBuilder();
  public virtual DbDataAdapter CreateDataAdapter();
  public virtual DbParameter CreateParameter();
  public virtual DbDataSourceEnumerator
    CreateDataSourceEnumerator();
}

为了获得数据提供者的DbProviderFactory派生类型,每个提供者都提供了一个静态属性,用于返回正确的类型。要返回 SQL Server 版本的DbProviderFactory,请使用以下代码:

// Get the factory for the SQL data provider.
DbProviderFactory sqlFactory =
  Microsoft.Data.SqlClient.SqlClientFactory.Instance;

为了使程序更加通用,您可以创建一个DbProviderFactory工厂,根据应用的appsettings.json文件中的设置返回一个特定风格的DbProviderFactory。您将很快学会如何做到这一点;目前,一旦获得了数据提供者的工厂,就可以获得相关的特定于提供者的数据对象(例如,连接、命令和数据读取器)。

完整的数据提供者工厂示例

作为一个完整的例子,创建一个新的 C# 控制台应用项目(名为DataProviderFactory),它打印出AutoLot数据库的汽车库存。对于这个初始示例,您将直接在控制台应用中硬编码数据访问逻辑(为了保持简单)。随着本章的深入,你会看到更好的方法。

首先向项目文件中添加一个新的ItemGroup以及Microsoft.Extensions.Configuration.JsonSystem.Data.CommonSystem.Data.OdbcSystem.Data.OleDbMicrosoft.Data.SqlClient包。

dotnet add DataProviderFactory package Microsoft.Data.SqlClient
dotnet add DataProviderFactory package System.Data.Common
dotnet add DataProviderFactory package System.Data.Odbc
dotnet add DataProviderFactory package System.Data.OleDb
dotnet add DataProviderFactory package Microsoft.Extensions.Configuration.Json

定义PC编译器常量(如果您使用的是 Windows 操作系统)。

<PropertyGroup>
  <DefineConstants>PC</DefineConstants>
</PropertyGroup>

接下来,添加一个名为DataProviderEnum.cs的新文件,并将代码更新如下:

namespace DataProviderFactory
{
  //OleDb is Windows only and is not supported in .NET Core
  enum DataProviderEnum
  {
    SqlServer,
#if PC
    OleDb,
#endif
    Odbc
  }
}

将名为appsettings.json的新 JSON 文件添加到项目中,并将其内容更新为以下内容(根据您的特定环境更新连接字符串):

{
  "ProviderName": "SqlServer",
  //"ProviderName": "OleDb",
  //"ProviderName": "Odbc",
  "SqlServer": {
    // for localdb use @"Data Source=(localdb)\mssqllocaldb;Integrated Security=true; Initial Catalog=AutoLot"
    "ConnectionString": "Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot"
  },
  "Odbc": {
    // for localdb use @"Driver={ODBC Driver 17 for SQL Server};Server=(localdb)\mssqllocaldb;Database=AutoLot;Trusted_Connection=Yes";
    "ConnectionString": "Driver={ODBC Driver 17 for SQL Server};Server=localhost,5433; Database=AutoLot;UId=sa;Pwd=P@ssw0rd;"
  },
  "OleDb": {
    // if localdb use @"Provider=SQLNCLI11;Data Source=(localdb)\mssqllocaldb;Initial Catalog=AutoLot;Integrated Security=SSPI"),
    "ConnectionString": "Provider=SQLNCLI11;Data Source=.,5433;User Id=sa;Password=P@ssw0rd; Initial Catalog=AutoLot;"
  }
}

通知 MSBuild 在每次构建时将 JSON 文件复制到输出目录。通过添加以下内容来更新项目文件:

<ItemGroup>
  <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </None>
</ItemGroup>

Note

CopyToOutputDirectory是区分空白的。确保所有内容都在一行上,并且单词之间没有空格。

现在您已经有了一个合适的appsettings.json,您可以使用.NETCore 配置。首先将Program.cs顶部的using语句更新为以下内容:

using System;
using System.Data.Common;
using System.Data.Odbc;
#if PC
  using System.Data.OleDb;
#endif
using System.IO;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;

清除Program.cs文件中的所有代码,并添加以下内容:

using System;
using System.Data.Common;
using System.Data.Odbc;
#if PC
  using System.Data.OleDb;
#endif
using System.IO;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using DataProviderFactory;

Console.WriteLine("***** Fun with Data Provider Factories *****\n");
var (provider, connectionString) = GetProviderFromConfiguration();
DbProviderFactory factory = GetDbProviderFactory(provider);
// Now get the connection object.
using (DbConnection connection = factory.CreateConnection())
{
  if (connection == null)
  {
    Console.WriteLine($"Unable to create the connection object");
    return;
  }

  Console.WriteLine($"Your connection object is a: {connection.GetType().Name}");
  connection.ConnectionString = connectionString;
  connection.Open();

  // Make command object.
  DbCommand command = factory.CreateCommand();
  if (command == null)
  {
    Console.WriteLine($"Unable to create the command object");
    return;
  }

  Console.WriteLine($"Your command object is a: {command.GetType().Name}");
  command.Connection = connection;
  command.CommandText =
    "Select i.Id, m.Name From Inventory i inner join Makes m on m.Id = i.MakeId ";

  // Print out data with data reader.
  using (DbDataReader dataReader = command.ExecuteReader())
  {
    Console.WriteLine($"Your data reader object is a: {dataReader.GetType().Name}");
    Console.WriteLine("\n***** Current Inventory *****");
    while (dataReader.Read())
    {
      Console.WriteLine($"-> Car #{dataReader["Id"]} is a {dataReader["Name"]}.");
    }
  }
}
Console.ReadLine();

接下来,将下面的代码添加到Program.cs文件的末尾。这些方法读取配置,将DataProviderEnum设置为正确的值,获取连接字符串,并返回DbProviderFactory的一个实例:

static DbProviderFactory GetDbProviderFactory(DataProviderEnum provider)
  => provider switch
{
  DataProviderEnum.SqlServer => SqlClientFactory.Instance,
  DataProviderEnum.Odbc => OdbcFactory.Instance,
#if PC
  DataProviderEnum.OleDb => OleDbFactory.Instance,
#endif
  _ => null
};

static (DataProviderEnum Provider, string ConnectionString)
  GetProviderFromConfiguration()
{
  IConfiguration config = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", true, true)
    .Build();
  var providerName = config["ProviderName"];
  if (Enum.TryParse<DataProviderEnum>
    (providerName, out DataProviderEnum provider))
  {
    return (provider,config[$"{providerName}:ConnectionString"]);
  };
  throw new Exception("Invalid data provider value supplied.");
}

请注意,出于诊断目的,您使用反射服务来打印基础连接、命令和数据读取器的名称。如果您运行这个应用,您将在打印到控制台的AutoLot数据库的Inventory表中找到以下当前数据:

***** Fun with Data Provider Factories *****
Your connection object is a: SqlConnection
Your command object is a: SqlCommand
Your data reader object is a: SqlDataReader

***** Current Inventory *****
-> Car #1 is a VW.
-> Car #2 is a Ford.
-> Car #3 is a Saab.
-> Car #4 is a Yugo.
-> Car #9 is a Yugo.
-> Car #5 is a BMW.
-> Car #6 is a BMW.
-> Car #7 is a BMW.
-> Car #8 is a Pinto.

现在修改settings文件来指定一个不同的提供者。除了特定于类型的信息之外,代码将获取相关的连接字符串并产生与以前相同的输出。

当然,根据你使用 ADO.NET 的经验,你可能有点不确定连接、命令和数据读取器对象实际上是做什么的。暂时不要考虑细节(毕竟,这一章还有好几页呢!).至此,知道您可以使用 point 数据提供者工厂模型来构建一个可以以声明方式使用各种数据提供者的单一代码库就足够了。

数据提供者工厂模型的一个潜在缺点

尽管这是一个强大的模型,但是您必须确保代码库只使用通过抽象基类成员对所有提供程序通用的类型和方法。因此,在创作您的代码库时,您只能使用由DbConnectionDbCommand和其他类型的System.Data.Common名称空间公开的成员。

考虑到这一点,您可能会发现这种一般化的方法会阻止您直接访问特定 DBMS 的一些附加功能。如果您必须能够调用基础提供者的特定成员(例如,SqlConnection),您可以使用显式强制转换来实现,如下例所示:

if (connection is SqlConnection sqlConnection)
{
  // Print out which version of SQL Server is used.
  WriteLine(sqlConnection.ServerVersion);
}

然而,当这样做时,您的代码库变得有点难以维护(并且不太灵活),因为您必须添加许多运行时检查。尽管如此,如果您需要以最灵活的方式构建 doing 数据访问库,数据提供者工厂模型为您提供了一个很好的机制。

Note

Entity Framework Core 及其对依赖注入的支持极大地简化了构建需要访问不同数据源的数据访问库。

有了第一个例子,你现在可以深入了解与 ADO.NET 合作的细节。

深入研究连接、命令和数据读取器

如前面的示例所示,ADO.NET 允许您使用数据提供程序的连接、命令和数据读取器对象与数据库进行交互。现在,您将创建一个扩展示例来更深入地了解 ADO.NET 的这些对象。

在前面演示的示例中,当您想要连接到数据库并使用数据读取器对象读取记录时,需要执行以下步骤:

  1. 分配、配置和打开您的连接对象。

  2. 分配和配置命令对象,将连接对象指定为构造函数参数或使用Connection属性。

  3. 在配置的命令类上调用ExecuteReader()

  4. 使用数据读取器的Read()方法处理每条记录。

首先,创建一个名为 AutoLot 的新控制台应用项目。DataReader 并添加Microsoft.Data.SqlClient包。下面是Program.cs内的完整代码(分析随后):

using System;
using Microsoft.Data.SqlClient;

Console.WriteLine("***** Fun with Data Readers *****\n");

// Create and open a connection.
using (SqlConnection connection = new SqlConnection())
{
  connection.ConnectionString =
    @" Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot";
    connection.Open();
   // Create a SQL command object.
  string sql =
    @"Select i.id, m.Name as Make, i.Color, i.Petname
          FROM Inventory i
          INNER JOIN Makes m on m.Id = i.MakeId";
  SqlCommand myCommand = new SqlCommand(sql, connection);

  // Obtain a data reader a la ExecuteReader().
  using (SqlDataReader myDataReader = myCommand.ExecuteReader())
  {
    // Loop over the results.
    while (myDataReader.Read())
    {
      Console.WriteLine($"-> Make: {myDataReader["Make"]}, PetName: {myDataReader ["PetName"]}, Color: {myDataReader["Color"]}.");
    }
  }
}
Console.ReadLine();

使用连接对象

使用数据提供者的第一步是使用 connection 对象(您记得,它是从DbConnection派生的)与数据源建立会话。。NET Core 连接对象带有格式化的连接字符串;该字符串包含许多名称-值对,用分号分隔。您可以使用这些信息来标识要连接的计算机的名称、所需的安全设置、该计算机上的数据库名称以及其他特定于数据提供程序的信息。

正如您可以从前面的代码中推断的那样,Initial Catalog名称指的是您想要与之建立会话的数据库。Data Source名称标识维护数据库的机器的名称。我使用的是".,5433",它指的是主机(句点,与使用“localhost”相同),端口 5433 是 Docker 容器映射到 SQL Server 端口的端口。如果您使用的是不同的实例,那么您可以将属性定义为machinename,port\instance。例如,MYSERVER\SQLSERVER2019表示MYSERVER是运行 SQL Server 的服务器的名称,默认端口正在使用,而SQLSERVER2019是实例的名称。如果机器是本地的,您可以使用句点(.)或标记(localhost)作为服务器名称。如果 SQL Server 实例是默认实例,则不使用实例名称。例如,如果您在 Microsoft SQL Server 安装上创建了AutoLot,并将其设置为本地计算机上的默认实例,那么您将使用"Data Source=localhost"

除此之外,您可以提供任意数量的表示安全凭证的令牌。如果Integrated Security设置为true,则当前 Windows 帐户凭证用于验证和授权。

建立连接字符串后,可以调用Open()来建立与 DBMS 的连接。除了ConnectionStringOpen()Close()成员之外,连接对象还提供了许多成员,允许您配置关于连接的附加设置,比如超时设置和事务信息。表 21-4 列出了DbConnection基类的一些(但不是全部)成员。

表 21-4。

DbConnection类型的成员

|

成员

|

生命的意义

BeginTransaction() 您使用此方法开始数据库事务。
ChangeDatabase() 您可以使用此方法在打开的连接上更改数据库。
ConnectionTimeout 此只读属性返回建立连接时,在终止连接并生成错误之前等待的时间(默认值取决于提供程序)。如果您想更改默认值,请在连接字符串中指定一个Connect Timeout段(例如Connect Timeout=30)。
Database 此只读属性获取由 connection 对象维护的数据库的名称。
DataSource 此只读属性获取由 connection 对象维护的数据库的位置。
GetSchema() 该方法返回一个包含来自数据源的模式信息的DataTable对象。
State 这个只读属性获取连接的当前状态,由ConnectionState枚举表示。

DbConnection类型的属性本质上通常是只读的,只有当您想要在运行时获得连接的特征时才有用。当需要重写默认设置时,必须更改连接字符串本身。例如,以下连接字符串将connection timeout设置从默认值(SQL Server 为 15 秒)设置为 30 秒:

using(SqlConnection connection = new SqlConnection())
{
  connection.ConnectionString =
    @" Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot;Connect Timeout=30";
  connection.Open();
}

下面的代码输出关于它传递给它的SqlConnection的细节:

static void ShowConnectionStatus(SqlConnection connection)
{
  // Show various stats about current connection object.
  Console.WriteLine("***** Info about your connection *****");
  Console.WriteLine($@"Database location:
    {connection.DataSource}");
  Console.WriteLine($"Database name: {connection.Database}");
  Console.WriteLine($@"Timeout:
    {connection.ConnectionTimeout}");
  Console.WriteLine($"Connection state:
    {connection.State}\n");
}

虽然这些属性中的大多数都是不言自明的,但是State属性值得特别一提。您可以将ConnectionState枚举的任何值赋给该属性,如下所示:

public enum ConnectionState
{
  Broken,
  Closed,
  Connecting,
  Executing,
  Fetching,
  Open
}

然而,唯一有效的ConnectionState值是ConnectionState.OpenConnectionState.ConnectingConnectionState.Closed(该枚举的其余成员保留供将来使用)。此外,关闭连接总是安全的,即使连接状态当前是ConnectionState.Closed

使用 ConnectionStringBuilder 对象

以编程方式处理连接字符串可能很麻烦,因为它们通常被表示为字符串文字,这很难维护,而且很容易出错。那个。符合 NET Core 的数据提供者支持连接字符串生成器对象,它允许您使用强类型属性建立名称-值对。考虑以下对当前code的更新:

var connectionStringBuilder = new SqlConnectionStringBuilder
{
  InitialCatalog = "AutoLot",
  DataSource = ".,5433",
  UserID = "sa",
  Password = "P@ssw0rd",
  ConnectTimeout = 30
};
  connection.ConnectionString =
    connectionStringBuilder.ConnectionString;

在这个迭代中,您创建一个SqlConnectionStringBuilder的实例,相应地设置属性,并使用ConnectionString属性获得内部字符串。另请注意,您使用了该类型的默认构造函数。如果您愿意,还可以通过传入现有连接字符串作为起点来创建数据提供程序的连接字符串生成器对象的实例(当您从外部源动态读取这些值时,这可能会很有帮助)。一旦使用初始字符串数据对对象进行了合并,就可以使用相关属性更改特定的名称-值对。

使用命令对象

既然您已经更好地理解了 connection 对象的角色,下一步工作就是检查如何向相关数据库提交 SQL 查询。SqlCommand类型(从DbCommand派生而来)是 SQL 查询、表名或存储过程的面向对象表示。使用CommandType属性指定命令的类型,该属性可以从CommandType枚举中获取任何值,如下所示:

public enum CommandType
{
  StoredProcedure,
  TableDirect,
  Text // Default value.
}

当您创建一个命令对象时,您可以将 SQL 查询建立为一个构造函数参数,或者直接使用CommandText属性。此外,在创建命令对象时,需要指定要使用的连接。同样,您可以通过构造函数参数或使用Connection属性来实现。考虑以下代码片段:

// Create command object via ctor args.
string sql =
    @"Select i.id, m.Name as Make, i.Color, i.Petname
         FROM Inventory i
         INNER JOIN Makes m on m.Id = i.MakeId";
SqlCommand myCommand = new SqlCommand(sql, connection);
// Create another command object via properties.
SqlCommand testCommand = new SqlCommand();
testCommand.Connection = connection;
testCommand.CommandText = sql;

要知道,在这一点上,您实际上并没有将 SQL 查询提交给AutoLot数据库,而是准备了命令对象的状态以备将来使用。表 21-5 突出显示了DbCommand类型的一些附加成员。

表 21-5。

DbCommand类型的成员

|

成员

|

生命的意义

CommandTimeout 获取或设置在终止尝试并生成错误之前执行命令时等待的时间。默认值为 30 秒。
Connection 获取或设置此DbCommand实例使用的DbConnection
Parameters 获取用于参数化查询的DbParameter对象的集合。
Cancel() 取消命令的执行。
ExecuteReader() 执行 SQL 查询并返回数据提供者的DbDataReader对象,该对象提供对查询结果的只进、只读访问。
ExecuteNonQuery() 执行 SQL 非查询(例如,插入、更新、删除或创建表)。
ExecuteScalar() 一个轻量级版本的ExecuteReader()方法,它是专门为单独查询设计的(例如,获取记录计数)。
Prepare() 在数据源上创建命令的准备(或编译)版本。您可能知道,准备好的查询执行起来稍微快一些,当您需要多次执行相同的查询(通常每次使用不同的参数)时会很有用。

使用数据读取器

建立活动连接和 SQL 命令后,下一步是向数据源提交查询。正如您可能猜到的,您有许多方法可以做到这一点。DbDataReader类型(实现了IDataReader)是从数据存储中获取信息的最简单快捷的方式。回想一下,数据读取器表示只读、只进的数据流,一次返回一条记录。鉴于此,数据读取器只有在向基础数据存储提交 SQL 选择语句时才有用。

当您需要快速迭代大量数据并且不需要维护内存中的表示时,数据读取器非常有用。例如,如果您请求将一个表中的 20,000 条记录存储在一个文本文件中,那么将这些信息保存在一个DataSet中会占用大量内存(因为一个DataSet同时将整个查询结果保存在内存中)。

一个更好的方法是创建一个数据读取器,尽可能快地旋转每条记录。但是,请注意,数据读取器对象(与数据适配器对象不同,您将在后面研究数据适配器对象)会保持与其数据源的打开连接,直到您显式关闭该连接。

您通过调用ExecuteReader()从命令对象获得数据读取器对象。数据读取器表示它从数据库中读取的当前记录。数据读取器有一个索引器方法(例如,C# 中的[]语法),允许您访问当前记录中的一列。您可以通过名称或从零开始的整数来访问该列。

数据读取器的以下使用利用了Read()方法来确定何时到达记录的末尾(使用一个false返回值)。对于从数据库中读取的每个传入记录,使用类型索引器打印出每辆汽车的品牌、昵称和颜色。还要注意,一旦处理完记录,就调用Close(),这释放了连接对象。

...
// Obtain a data reader via ExecuteReader().
using(SqlDataReader myDataReader = myCommand.ExecuteReader())
{
  // Loop over the results.
  while (myDataReader.Read())
  {
    WriteLine($"-> Make: { myDataReader["Make"]}, PetName: { myDataReader["PetName"]}, Color: { myDataReader["Color"]}.");
  }
}
ReadLine();

在前面的代码片段中,您重载了数据读取器对象的索引器,以接受一个string(表示列的名称)或一个int(表示列的序号位置)。因此,您可以用下面的更新清理当前的阅读器逻辑(并避免硬编码的字符串名称)(注意FieldCount属性的使用):

while (myDataReader.Read())
{
  for (int i = 0; i < myDataReader.FieldCount; i++)
  {
    Console.Write(i != myDataReader.FieldCount - 1
      ? $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)}, "
      : $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)} ");
  }
  Console.WriteLine();
}

如果您在此时编译并运行您的项目,您应该会在AutoLot数据库的Inventory表中看到所有汽车的列表。

***** Fun with Data Readers *****

***** Info about your connection *****
Database location: .,5433
Database name: AutoLot
Timeout: 30
Connection state: Open

id = 1, Make = VW, Color = Black, Petname = Zippy
id = 2, Make = Ford, Color = Rust, Petname = Rusty
id = 3, Make = Saab, Color = Black, Petname = Mel
id = 4, Make = Yugo, Color = Yellow, Petname = Clunker
id = 5, Make = BMW, Color = Black, Petname = Bimmer
id = 6, Make = BMW, Color = Green, Petname = Hank
id = 7, Make = BMW, Color = Pink, Petname = Pinky
id = 8, Make = Pinto, Color = Black, Petname = Pete
id = 9, Make = Yugo, Color = Brown, Petname = Brownie

使用数据读取器获取多个结果集

数据读取器对象可以使用单个命令对象获得多个结果集。例如,如果您想获得来自Inventory表的所有行,以及来自Customers表的所有行,您可以使用分号分隔符指定这两个 SQL Select语句,如下所示:

    sql += ";Select * from Customers;";

Note

开头的分号不是错别字。使用多个语句时,它们必须用分号分隔。因为最初的语句不包含,所以在第二个语句的开头添加了一个。

获得数据读取器后,可以使用NextResult()方法迭代每个结果集。请注意,您总是自动返回第一个结果集。因此,如果您想要读取每个表的行,您可以构建以下迭代结构:

do
{
  while (myDataReader.Read())
  {
    for (int i = 0; i < myDataReader.FieldCount; i++)
    {
      Console.Write(i != myDataReader.FieldCount - 1
        ? $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)}, "
        : $"{myDataReader.GetName(i)} = {myDataReader.GetValue(i)} ");
    }
    Console.WriteLine();
  }
  Console.WriteLine();
} while (myDataReader.NextResult());

此时,您应该更清楚数据读取器对象为表带来的功能。永远记住,数据读取器只能处理 SQL Select语句;您不能使用它们通过InsertUpdateDelete请求来修改现有的数据库表。修改现有数据库需要对命令对象进行额外的调查。

使用创建、更新和删除查询

ExecuteReader()方法提取数据读取器对象,该对象允许您使用只进、只读信息流来检查 SQL Select语句的结果。但是,当您想要提交导致给定表修改的 SQL 语句(或任何其他非查询 SQL 语句,如创建表或授予权限)时,您可以调用命令对象的ExecuteNonQuery()方法。这个方法根据命令文本的格式执行插入、更新和删除操作。

Note

从技术上讲,非查询是一个不返回结果集的 SQL 语句。因此,Select语句是查询,而InsertUpdateDelete语句不是查询。鉴于此,ExecuteNonQuery()返回一个代表受影响的行数的int,而不是一组新的记录。

到目前为止,本章中的所有数据库交互示例都只打开了连接,并使用它们来检索数据。这只是使用数据库的一部分;除非数据访问框架也完全支持创建、读取、更新和删除(CRUD)功能,否则它没有多大用处。接下来,您将学习如何使用对ExecuteNonQuery()的调用来做到这一点。

首先创建一个名为 AutoLot 的新 C# 类库项目。dal(AutoLot 数据访问层的简称),删除默认的类文件,将Microsoft.Data.SqlClient包添加到项目中。

在构建将执行数据操作的类之前,我们将首先创建一个 C# 类,它表示来自Inventory表的记录及其相关的Make信息。

创建 Car 和 CarViewModel 类

现代数据访问库使用类(通常称为模型或实体)来表示和传输数据库中的数据。此外,可以使用类来表示数据的视图,该视图将两个或更多的表组合在一起,使数据更有意义。实体类用于处理数据库目录(用于更新语句),视图模型类用于以有意义的方式显示数据。在下一章中,您将看到这些概念是像实体框架核心这样的对象关系映射器(ORM)的基础,但是现在,您只需创建一个模型(针对原始库存行)和一个视图模型(将库存行与Makes表中的相关数据结合起来)。向您的项目添加一个名为Models的新文件夹,并添加两个名为Car.csCarViewModel.cs的新文件。将代码更新为以下内容:

//Car.cs
namespace AutoLot.Dal.Models
{
  public class Car
  {
    public int Id { get; set; }
    public string Color { get; set; }
    public int MakeId { get; set; }
    public string PetName { get; set; }
    public byte[] TimeStamp {get;set;}
  }
}

//CarViewModel.cs
namespace AutoLot.Dal.Models
{
  public class CarViewModel : Car
  {
    public string Make { get; set; }
  }
}

Note

如果您不熟悉 SQL Server TimeStamp数据类型(在 C# 中,它映射到一个byte[]),此时不必担心。只知道它用于行级并发检查,会被实体框架核心覆盖。

这些类将很快被使用。

添加 InventoryDal 类

接下来,添加一个名为DataOperations的新文件夹。在这个新文件夹中,添加一个名为InventoryDal.cs的新类,并将该类更改为public。这个类将定义各种成员来与AutoLot数据库的Inventory表交互。最后,导入以下名称空间:

using System;
using System.Collections.Generic;
using System.Data;
using AutoLot.Dal.Models;
using Microsoft.Data.SqlClient;

添加构造函数

创建一个接受字符串参数(connectionString)并将该值赋给类级变量的构造函数。接下来,创建一个无参数的构造函数,将一个默认的连接字符串传递给另一个构造函数。这使调用代码能够更改默认的连接字符串。相关代码如下:

namespace AuoLot.Dal.DataOperations
{
  public class InventoryDal
  {
    private readonly string _connectionString;
    public InventoryDal() : this(
      @"Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot")
    {
    }
    public InventoryDal(string connectionString)
      => _connectionString = connectionString;
  }
}

打开和关闭连接

接下来,添加一个类级变量来保存数据访问代码将使用的连接。同样,添加两个方法,一个打开连接(OpenConnection()),另一个关闭连接(CloseConnection())。在CloseConnection()方法中,检查连接的状态,如果没有关闭,那么在连接上调用Close()。代码清单如下:

private SqlConnection _sqlConnection = null;
private void OpenConnection()
{
  _sqlConnection = new SqlConnection
  {
    ConnectionString = _connectionString
  };
  _sqlConnection.Open();
}
private void CloseConnection()
{
  if (_sqlConnection?.State != ConnectionState.Closed)
  {
    _sqlConnection?.Close();
  }
}

为了简洁起见,InventoryDal类中的大多数方法不会使用try / catch块来处理可能的异常,也不会抛出自定义异常来报告执行中的各种问题(例如,格式错误的连接字符串)。如果你要构建一个工业级的数据访问库,你绝对会想要使用结构化异常处理技术(如第七章所述)来解决任何运行时异常。

添加 IDisposable

IDisposable接口添加到类定义中,如下所示:

public class InventoryDal : IDisposable
{
...
}

接下来,实现一次性模式,在SqlConnection对象上调用Dispose

bool _disposed = false;
protected virtual void Dispose(bool disposing)
{
  if (_disposed)
  {
    return;
  }
  if (disposing)
  {
    _sqlConnection.Dispose();
  }
  _disposed = true;
}
public void Dispose()
{
  Dispose(true);
  GC.SuppressFinalize(this);
}

添加选择方法

首先,结合您已经知道的关于Command对象、DataReader和通用集合的知识,从Inventory表中获取记录。正如您在本章前面所看到的,数据提供者的数据读取器对象允许使用只读、只进机制和Read()方法来选择记录。在这个例子中,DataReader上的CommandBehavior属性被设置为当阅读器关闭时自动关闭连接。GetAllInventory()方法返回一个List<CarViewModel>来表示Inventory表中的所有数据。

public List<CarViewModel> GetAllInventory()
{
  OpenConnection();
  // This will hold the records.
  List<CarViewModel> inventory = new List<CarViewModel>();

  // Prep command object.
  string sql =
    @"SELECT i.Id, i.Color, i.PetName,m.Name as Make
          FROM Inventory i
          INNER JOIN Makes m on m.Id = i.MakeId";
  using SqlCommand command =
    new SqlCommand(sql, _sqlConnection)
    {
      CommandType = CommandType.Text
    };
  command.CommandType = CommandType.Text;
  SqlDataReader dataReader =
    command.ExecuteReader(CommandBehavior.CloseConnection);
  while (dataReader.Read())
  {
    inventory.Add(new CarViewModel
    {
      Id = (int)dataReader["Id"],
      Color = (string)dataReader["Color"],
      Make = (string)dataReader["Make"],
      PetName = (string)dataReader["PetName"]
    });
  }
  dataReader.Close();
  return inventory;
}

下一个选择方法基于CarId获得单个CarViewModel

public CarViewModel GetCar(int id)
{
  OpenConnection();
  CarViewModel car = null;
  //This should use parameters for security reasons
  string sql =
   $@"SELECT i.Id, i.Color, i.PetName,m.Name as Make
          FROM Inventory i
          INNER JOIN Makes m on m.Id = i.MakeId
          WHERE i.Id = {id}";
  using SqlCommand command =
    new SqlCommand(sql, _sqlConnection)
    {
      CommandType = CommandType.Text
    };
  SqlDataReader dataReader =
    command.ExecuteReader(CommandBehavior.CloseConnection);
  while (dataReader.Read())
  {
    car = new CarViewModel
    {
      Id = (int) dataReader["Id"],
      Color = (string) dataReader["Color"],
      Make = (string) dataReader["Make"],
      PetName = (string) dataReader["PetName"]
    };
  }
  dataReader.Close();
  return car;
}

Note

像这里所做的那样,接受用户输入到原始 SQL 语句中通常是一种不好的做法。在本章的后面,这段代码将被更新以使用参数。

插入一辆新车

Inventory表中插入一条新记录非常简单,只需格式化 SQL Insert语句(基于用户输入),打开连接,使用命令对象调用ExecuteNonQuery(),然后关闭连接。您可以通过向名为InsertAuto()InventoryDal类型添加一个公共方法来看到这一点,该方法采用三个参数映射到Inventory表的不相同列(ColorMakePetName)。您可以使用这些参数来格式化字符串类型,以便插入新记录。最后,使用您的SqlConnection对象来执行 SQL 语句。

public void InsertAuto(string color, int makeId, string petName)
{
  OpenConnection();
  // Format and execute SQL statement.
  string sql = $"Insert Into Inventory (MakeId, Color, PetName) Values ('{makeId}', '{color}', '{petName}')";
  // Execute using our connection.
  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
  {
    command.CommandType = CommandType.Text;
    command.ExecuteNonQuery();
  }
  CloseConnection();
}

前面的方法为Car取三个值,只要调用代码以正确的顺序传递这些值,它就能工作。一个更好的方法是使用Car来创建一个强类型方法,确保所有的属性都以正确的顺序传递到方法中。

创建强类型 InsertCar()方法

向您的InventoryDal类添加另一个将Car作为参数的InsertAuto()方法,如下所示:

public void InsertAuto(Car car)
{
  OpenConnection();
  // Format and execute SQL statement.
  string sql = "Insert Into Inventory (MakeId, Color, PetName) Values " +
    $"('{car.MakeId}', '{car.Color}', '{car.PetName}')";

  // Execute using our connection.
  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
  {
    command.CommandType = CommandType.Text;
    command.ExecuteNonQuery();
  }
  CloseConnection();
}

添加删除逻辑

删除现有记录就像插入新记录一样简单。与您为InsertAuto()创建代码时不同,这次您将了解一个重要的try / catch作用域,该作用域处理尝试删除Customers表中某个人当前订购的汽车的可能性。外键的默认INSERTUPDATE选项默认防止删除链接表中的相关记录。当这种情况发生时,抛出一个SqlException。真正的程序会智能地处理错误;然而,在这个示例中,您只是抛出了一个新的异常。将以下方法添加到InventoryDal类类型中:

public void DeleteCar(int id)
{
  OpenConnection();
  // Get ID of car to delete, then do so.
  string sql = $"Delete from Inventory where Id = '{id}'";
  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
  {
    try
    {
      command.CommandType = CommandType.Text;
      command.ExecuteNonQuery();
    }
    catch (SqlException ex)
    {
      Exception error = new Exception("Sorry! That car is on order!", ex);
      throw error;
    }
  }
  CloseConnection();
}

添加更新逻辑

当涉及到更新Inventory表中现有记录的行为时,您必须决定的第一件事是您希望允许调用者更改什么,是汽车的颜色、昵称、品牌,还是所有这些。给予调用者完全灵活性的一种方法是定义一个方法,该方法采用一个string类型来表示任何类型的 SQL 语句,但这充其量也是有风险的。

理想情况下,您希望有一组允许调用者以多种方式更新记录的方法。但是,对于这个简单的数据访问库,您将定义一个方法,允许调用者更新给定汽车的昵称,如下所示:

public void UpdateCarPetName(int id, string newPetName)
{
  OpenConnection();
  // Get ID of car to modify the pet name.
  string sql = $"Update Inventory Set PetName = '{newPetName}' Where Id = '{id}'";
  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
  {
    command.ExecuteNonQuery();
  }
  CloseConnection();
}

使用参数化命令对象

目前,InventoryDal类型的插入、更新和删除逻辑对每个 SQL 查询使用硬编码的字符串。使用参数化查询,SQL 参数是对象,而不是简单的文本块。以更加面向对象的方式处理 SQL 查询有助于减少打字错误的数量(给定强类型属性);另外,参数化查询的执行速度通常比文字 SQL 字符串快得多,因为它们只被解析一次(而不是每次 SQL 字符串被分配给CommandText属性)。参数化查询还有助于防范 SQL 注入攻击(一个众所周知的数据访问安全问题)。

为了支持参数化查询,ADO.NET 命令对象维护单个参数对象的集合。默认情况下,这个集合是空的,但是您可以插入任意数量的参数对象,这些对象映射到 SQL 查询中的一个占位符参数。当您想要将 SQL 查询中的参数与 command 对象的 parameters 集合中的成员相关联时,您可以在 SQL 文本参数前面加上符号@(至少在使用 Microsoft SQL Server 时是这样;并非所有 DBMSs 都支持这种表示法)。

使用 DbParameter 类型指定参数

在构建参数化查询之前,您需要熟悉DbParameter类型(它是提供者的特定参数对象的基类)。该类维护许多属性,这些属性允许您配置参数的名称、大小和数据类型,以及其他特征,包括参数的行进方向。表 21-6 描述了DbParameter型的一些关键特性。

表 21-6。

DbParameter类型的主要成员

|

财产

|

生命的意义

DbType 获取或设置参数的本机数据类型,表示为 CLR 数据类型
Direction 获取或设置参数是仅输入、仅输出、双向还是返回值参数
IsNullable 获取或设置参数是否接受空值
ParameterName 获取或设置DbParameter的名称
Size 获取或设置数据的最大参数大小(以字节为单位);这仅对文本数据有用
Value 获取或设置参数的值

现在让我们看看如何通过修改InventoryDal方法来使用参数,从而填充一个命令对象的DBParameter兼容对象的集合。

更新 GetCar 方法

在构建 SQL 字符串来检索汽车数据时,GetCar()方法的最初实现使用了 C# 字符串插值法。要更新这个方法,用适当的值创建一个SqlParameter的实例,如下所示:

SqlParameter param = new SqlParameter
{
  ParameterName = "@carId",
  Value = id,
  SqlDbType = SqlDbType.Int,
  Direction = ParameterDirection.Input
};

ParameterName值必须与 SQL 查询中使用的名称匹配(接下来您将更新它),类型必须与数据库列类型匹配,方向取决于参数是用于将数据发送到查询ParameterDirection.Input中,还是用于从查询(ParameterDirection.Output)返回数据*。参数也可以定义为输入/输出或返回值(例如,来自存储过程)。*

接下来,更新 SQL 字符串以使用参数名("@carId")而不是 C# 字符串插值构造("{id}")。

string sql =
  @"SELECT i.Id, i.Color, i.PetName,m.Name as Make
        FROM Inventory i
        INNER JOIN Makes m on m.Id = i.MakeId
        WHERE i.Id = @CarId";

最后的更新是将新参数添加到 command 对象的Parameters集合中。

command.Parameters.Add(param);

更新 DeleteCar 方法

同样,DeleteCar()方法的最初实现使用了 C# 字符串插值。要更新这个方法,用适当的值创建一个SqlParameter的实例,如下所示:

SqlParameter param = new SqlParameter
{
  ParameterName = "@carId",
  Value = id,
  SqlDbType = SqlDbType.Int,
  Direction = ParameterDirection.Input
};

接下来,更新 SQL 字符串以使用参数名("@carId")。

string sql = "Delete from Inventory where Id = @carId";

最后的更新是将新参数添加到 command 对象的Parameters集合中。

command.Parameters.Add(param);

更新 UpdateCarPetName 方法

这个方法需要两个参数,一个是汽车Id的,另一个是新PetName的。第一个参数的创建与前两个示例一样(除了不同的变量名),第二个参数创建一个映射到数据库NVarChar类型的参数(来自Inventory表的PetName字段类型)。请注意,设置了一个Size值。重要的是,该大小要与数据库字段大小相匹配,以免在执行命令时出现问题。

SqlParameter paramId = new SqlParameter
{
  ParameterName = "@carId",
  Value = id,
  SqlDbType = SqlDbType.Int,
  Direction = ParameterDirection.Input
};
SqlParameter paramName = new SqlParameter
{
  ParameterName = "@petName",
  Value = newPetName,
  SqlDbType = SqlDbType.NVarChar,
  Size = 50,
  Direction = ParameterDirection.Input
};

接下来,更新 SQL 字符串以使用参数。

string sql = $"Update Inventory Set PetName = @petName Where Id = @carId";

最后的更新是将新参数添加到 command 对象的Parameters集合中。

command.Parameters.Add(paramId);
command.Parameters.Add(paramName);

更新 internauto 方法

添加以下版本的InsertAuto()方法来利用参数对象:

public void InsertAuto(Car car)
{
  OpenConnection();
  // Note the "placeholders" in the SQL query.
  string sql = "Insert Into Inventory" +
    "(MakeId, Color, PetName) Values" +
    "(@MakeId, @Color, @PetName)";

  // This command will have internal parameters.
  using (SqlCommand command = new SqlCommand(sql, _sqlConnection))
  {
    // Fill params collection.
    SqlParameter parameter = new SqlParameter
    {
      ParameterName = "@MakeId",
      Value = car.MakeId,
      SqlDbType = SqlDbType.Int,
      Direction = ParameterDirection.Input
    };
    command.Parameters.Add(parameter);

    parameter = new SqlParameter
    {
      ParameterName = "@Color",
      Value = car.Color,
      SqlDbType = SqlDbType. NVarChar,
      Size = 50,
      Direction = ParameterDirection.Input
    };
    command.Parameters.Add(parameter);

    parameter = new SqlParameter
    {
      ParameterName = "@PetName",
      Value = car.PetName,
      SqlDbType = SqlDbType. NVarChar,
      Size = 50,
      Direction = ParameterDirection.Input
    };
    command.Parameters.Add(parameter);

    command.ExecuteNonQuery();
    CloseConnection();
  }
}

虽然构建参数化查询通常需要更多代码,但最终结果是以更方便的方式以编程方式调整 SQL 语句,并获得更好的整体性能。当您想要触发存储过程时,它们也非常有用。

执行存储过程

回想一下,存储过程是存储在数据库中的 SQL 代码的命名块。您可以构造存储过程,使它们返回一组行或标量数据类型,或者执行任何其他有意义的操作(例如,插入、更新或删除记录);您也可以让它们接受任意数量的可选参数。最终结果是一个行为类似于典型方法的工作单元,除了它位于数据存储而不是二进制业务对象上。目前,AutoLot数据库定义了一个名为GetPetName的存储过程。

现在考虑下面的InventoryDal类型的最后一个方法,它调用您的存储过程:

public string LookUpPetName(int carId)
{
  OpenConnection();
  string carPetName;

  // Establish name of stored proc.
  using (SqlCommand command = new SqlCommand("GetPetName", _sqlConnection))
  {
    command.CommandType = CommandType.StoredProcedure;

    // Input param.
    SqlParameter param = new SqlParameter
    {
      ParameterName = "@carId",
      SqlDbType = SqlDbType.Int,
      Value = carId,
      Direction = ParameterDirection.Input
    };
    command.Parameters.Add(param);

    // Output param.
    param = new SqlParameter
    {
      ParameterName = "@petName",
      SqlDbType = SqlDbType.NVarChar,
      Size = 50,
      Direction = ParameterDirection.Output
    };
    command.Parameters.Add(param);

    // Execute the stored proc.
    command.ExecuteNonQuery();

    // Return output param.
    carPetName = (string)command.Parameters["@petName"].Value;
    CloseConnection();
  }
  return carPetName;
}

调用存储过程的一个重要方面是要记住,命令对象可以表示 SQL 语句(默认)或存储过程的名称。当你想通知一个命令对象它将调用一个存储过程时,你传入该过程的名称(作为一个构造函数参数或者通过使用CommandText属性)并且必须将CommandType属性设置为值CommandType.StoredProcedure。(如果您未能做到这一点,您将会收到一个运行时异常,因为默认情况下命令对象需要一个 SQL 语句。)

接下来,请注意,@petName参数的Direction属性被设置为ParameterDirection.Output。和前面一样,将每个参数对象添加到命令对象的参数集合中。

在存储过程通过调用ExecuteNonQuery()完成之后,您可以通过研究命令对象的参数集合和相应的转换来获得输出参数的值。

// Return output param.
carPetName = (string)command.Parameters["@petName"].Value;

至此,您已经有了一个极其简单的数据访问库,可以用它来构建一个显示和编辑数据的客户机。您还没有研究如何构建图形用户界面,所以接下来您将从新的控制台应用测试您的数据库。

创建基于控制台的客户端应用

AutoLot.Dal解决方案添加一个新的控制台应用(名为AutoLot.Client)并添加一个对AutoLot.Dal项目的引用。完成此任务的dotnet CLI 命令如下(假设您的解决方案名为Chapter21_AllProjects.sln):

dotnet new console -lang c# -n AutoLot.Client -o .\AutoLot.Client -f net5.0
dotnet sln .\Chapter21_AllProjects.sln add .\AutoLot.Client
dotnet add AutoLot.Client package Microsoft.Data.SqlClient
dotnet add AutoLot.Client reference AutoLot.Dal

如果使用 Visual Studio,右击您的解决方案并选择“添加➤新项目”。将新项目设置为启动项目(通过在解决方案资源管理器中右击该项目并选择“设置为启动项目”)。这将在 Visual Studio 中调试时运行您的新项目。如果您使用的是 Visual Studio 代码,您需要导航到AutoLot.Test目录并使用dotnet run运行项目(当时间到了的时候)。

清除Program.cs中生成的代码,并将下面的using语句添加到Program.cs的顶部:

using System;
using System.Linq;
using AutoLot.Dal;
using AutoLot.Dal.Models;
using AutoLot.Dal.DataOperations;
using System.Collections.Generic;

用下面的代码替换Main()方法来练习AutoLot.Dal:

InventoryDal dal = new InventoryDal();
List<CarViewModel> list = dal.GetAllInventory();
Console.WriteLine(" ************** All Cars ************** ");
Console.WriteLine("Id\tMake\tColor\tPet Name");
foreach (var itm in list)
{
  Console.WriteLine($"{itm.Id}\t{itm.Make}\t{itm.Color}\t{itm.PetName}");
}
Console.WriteLine();
CarViewModel car = dal.GetCar(list.OrderBy(x=>x.Color).Select(x => x.Id).First());
Console.WriteLine(" ************** First Car By Color ************** ");
Console.WriteLine("CarId\tMake\tColor\tPet Name");
Console.WriteLine($"{car.Id}\t{car.Make}\t{car.Color}\t{car.PetName}");

try
{
  //This will fail because of related data in the Orders table
  dal.DeleteCar(5);
  Console.WriteLine("Car deleted.");
}
catch (Exception ex)
{
  Console.WriteLine($"An exception occurred: {ex.Message}");
}
dal.InsertAuto(new Car { Color = "Blue", MakeId = 5, PetName = "TowMonster" });
list = dal.GetAllInventory();
var newCar = list.First(x => x.PetName == "TowMonster");
Console.WriteLine(" ************** New Car ************** ");
Console.WriteLine("CarId\tMake\tColor\tPet Name");

Console.WriteLine($"{newCar.Id}\t{newCar.Make}\t{newCar.Color}\t{newCar.PetName}");
dal.DeleteCar(newCar.Id);
var petName = dal.LookUpPetName(car.Id);
Console.WriteLine(" ************** New Car ************** ");
Console.WriteLine($"Car pet name: {petName}");
Console.Write("Press enter to continue...");
Console.ReadLine();

了解数据库事务

让我们通过了解数据库事务的概念来结束对 ADO.NET 的研究。简单地说,一个事务是一组数据库操作,它们作为一个整体单元成功或失败。如果其中一个操作失败,所有其他操作都将回滚,就像什么都没发生过一样。可以想象,事务对于确保表数据的安全性、有效性和一致性非常重要。

当数据库操作涉及与多个表或多个存储过程(或数据库原子的组合)进行交互时,事务非常重要。典型的交易示例涉及在两个银行账户之间转移货币资金的过程。例如,如果您要将 500 美元从您的储蓄账户转入您的支票账户,则应以交易方式执行以下步骤:

  1. 银行应该从你的储蓄账户中取出 500 美元。

  2. 银行应该在你的支票账户上加 500 美元。

如果钱从储蓄账户中取出,但没有转到支票账户(因为银行方面的一些错误),这将是一件非常糟糕的事情,因为那样你将损失 500 美元!但是,如果这些步骤被打包到一个数据库事务中,DBMS 会确保所有相关步骤作为一个单元发生。如果事务的任何部分失败,整个操作将回滚到原始状态。另一方面,如果所有步骤都成功了,事务就会被提交

Note

您可能在阅读交易文献时熟悉缩写 ACID。这代表了一个适当的事务的四个关键属性:原子的(全有或全无)、一致的(数据在整个事务中保持稳定)、隔离的(事务不干扰其他操作)、以及持久的(事务被保存和记录)。

事实证明。NET 核心平台支持多种方式的交易。本章将研究您的 ADO.NET 数据提供者的事务对象(在Microsoft.Data.SqlClient的情况下,是SqlTransaction)。

除了内置的事务支持之外。NET 基本类库,可以使用数据库管理系统的 SQL 语言。例如,您可以编写一个使用BEGIN TRANSACTIONROLLBACKCOMMIT语句的存储过程。

ADO.NET 事务对象的主要成员

我们将使用的所有事务都实现了IDbTransaction接口。回想一下本章开始时,IDbTransaction将一些成员定义如下:

public interface IDbTransaction : IDisposable
{
  IDbConnection Connection { get; }
  IsolationLevel IsolationLevel { get; }

  void Commit();
  void Rollback();
}

注意Connection属性,它返回对启动当前事务的连接对象的引用(正如您将看到的,您从给定的连接对象中获得一个事务对象)。当每个数据库操作成功时,调用Commit()方法。这样做将导致每个挂起的更改都保留在数据存储中。相反,如果出现运行时异常,您可以调用Rollback()方法,通知 DBMS 忽略任何挂起的更改,保持原始数据不变。

Note

transaction 对象的IsolationLevel属性允许您指定一个事务应该如何积极地防范其他并行事务的活动。默认情况下,事务在提交之前是完全隔离的。

除了由IDbTransaction接口定义的成员之外,SqlTransaction类型还定义了一个名为Save()的额外成员,它允许您定义保存点。这个概念允许您将失败的事务回滚到指定的点,而不是回滚整个事务。本质上,当您使用SqlTransaction对象调用Save()时,您可以指定一个友好的字符串名字对象。当您调用Rollback()时,您可以指定这个相同的名字作为参数来执行有效的部分回滚。不带参数调用Rollback()会导致所有挂起的更改回滚。

将交易方法添加到库存

现在让我们看看如何以编程方式处理 ADO.NET 事务。首先打开您之前创建的AutoLot.Dal代码库项目,并将一个名为ProcessCreditRisk()的新公共方法添加到InventoryDal类中,以处理感知的信用风险。该方法将查找一个客户,将他们添加到CreditRisks表中,然后通过在末尾添加“(信用风险)”来更新他们的姓氏。

public void ProcessCreditRisk(bool throwEx, int customerId)
{
  OpenConnection();
  // First, look up current name based on customer ID.
  string fName;
  string lName;
  var cmdSelect = new SqlCommand(
    "Select * from Customers where Id = @customerId",
    _sqlConnection);
  SqlParameter paramId = new SqlParameter
  {
    ParameterName = "@customerId",
    SqlDbType = SqlDbType.Int,
    Value = customerId,
    Direction = ParameterDirection.Input
  };
  cmdSelect.Parameters.Add(paramId);
  using (var dataReader = cmdSelect.ExecuteReader())
  {
    if (dataReader.HasRows)
    {
      dataReader.Read();
      fName = (string) dataReader["FirstName"];
      lName = (string) dataReader["LastName"];
    }
    else
    {
      CloseConnection();
      return;
    }
  }
  cmdSelect.Parameters.Clear();
// Create command objects that represent each step of the operation.
  var cmdUpdate = new SqlCommand(
    "Update Customers set LastName = LastName + ' (CreditRisk) ' where Id = @customerId",
    _sqlConnection);
  cmdUpdate.Parameters.Add(paramId);
  var cmdInsert = new SqlCommand(
    "Insert Into CreditRisks (CustomerId,FirstName, LastName) Values( @CustomerId, @FirstName, @LastName)",
    _sqlConnection);
  SqlParameter parameterId2 = new SqlParameter
  {
    ParameterName = "@CustomerId",
    SqlDbType = SqlDbType.Int,
    Value = customerId,
    Direction = ParameterDirection.Input
  };
  SqlParameter parameterFirstName = new SqlParameter

  {
    ParameterName = "@FirstName",
    Value = fName,
    SqlDbType = SqlDbType.NVarChar,
    Size = 50,
    Direction = ParameterDirection.Input
  };
  SqlParameter parameterLastName = new SqlParameter
  {
    ParameterName = "@LastName",
    Value = lName,
    SqlDbType = SqlDbType.NVarChar,
    Size = 50,
    Direction = ParameterDirection.Input
  };

  cmdInsert.Parameters.Add(parameterId2);
  cmdInsert.Parameters.Add(parameterFirstName);
  cmdInsert.Parameters.Add(parameterLastName);
  // We will get this from the connection object.
  SqlTransaction tx = null;
  try
  {
    tx = _sqlConnection.BeginTransaction();
    // Enlist the commands into this transaction.
    cmdInsert.Transaction = tx;
    cmdUpdate.Transaction = tx;
    // Execute the commands.
    cmdInsert.ExecuteNonQuery();
    cmdUpdate.ExecuteNonQuery();
    // Simulate error.
    if (throwEx)
    {
      throw new Exception("Sorry!  Database error! Tx failed...");
    }
    // Commit it!
    tx.Commit();
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex.Message);
    // Any error will roll back transaction.  Using the new conditional access operator to check for null.
    tx?.Rollback();
  }
  finally
  {
    CloseConnection();
  }
}

这里,您使用一个传入的bool参数来表示当您试图处理违规的客户时是否会抛出一个任意的异常。这允许您模拟会导致数据库事务失败的意外情况。显然,您在这里这样做只是为了说明的目的;真正的数据库事务方法不会允许调用者心血来潮地强迫逻辑失败!

注意,您使用两个SqlCommand对象来表示您将开始的事务中的每一步。在根据传入的customerID参数获得客户的名字和姓氏之后,可以使用BeginTransaction()从连接对象中获得一个有效的SqlTransaction对象。接下来,也是最重要的,您必须通过将Transaction属性分配给您刚刚获得的事务对象来登记每个命令对象。如果您没有这样做,Insert / Update逻辑将不会在事务上下文中。

在每个命令上调用ExecuteNonQuery()之后,当(且仅当)参数bool的值为true时,抛出异常。在这种情况下,所有挂起的数据库操作都将回滚。如果您没有抛出异常,那么一旦您调用了Commit(),这两个步骤都将被提交给数据库表。

测试您的数据库事务

选择您添加到 customers 表中的一个客户(例如,Dave Benner,Id = 1)。接下来,在自动 Lot 中添加一个新方法到Program.cs。客户项目名为FlagCustomer()

void FlagCustomer()
{
  Console.WriteLine("***** Simple Transaction Example *****\n");

  // A simple way to allow the tx to succeed or not.
  bool throwEx = true;
  Console.Write("Do you want to throw an exception (Y or N): ");
  var userAnswer = Console.ReadLine();
  if (string.IsNullOrEmpty(userAnswer) || userAnswer.Equals("N",StringComparison.OrdinalIgnoreCase))
  {
    throwEx = false;
  }
  var dal = new InventoryDal();
  // Process customer 1 – enter the id for the customer to move.
  dal.ProcessCreditRisk(throwEx, 1);
  Console.WriteLine("Check CreditRisk table for results");
  Console.ReadLine();
}

如果您要运行您的程序并选择抛出一个异常,您会发现客户的姓是*,而不是Customers表中的*,因为整个事务已经回滚。但是,如果您没有抛出异常,您会发现客户的姓氏在Customers表中被更新,并被添加到CreditRisks表中。

使用 ADO.NET 执行批量复制

在需要将大量记录加载到数据库中的情况下,目前显示的方法效率相当低。SQL Server 有一个名为批量复制的特性,它是专门为这个场景设计的,在 ADO.NET 中用SqlBulkCopy类包装。本章的这一节将介绍如何使用 ADO.NET 来实现这一点。

探索 SqlBulkCopy 类

SqlBulkCopy类有一个方法WriteToServer()(和异步版本WriteToServerAsync()),它处理记录列表并将数据写入数据库,比编写一系列insert语句并用Command对象运行它们更有效。WriteToServer重载使用一个DataTable、一个DataReader或一个DataRow数组。为了与本章的主题保持一致,您将使用DataReader版本。为此,您需要创建一个定制的数据读取器。

创建自定义数据读取器

您希望您的自定义数据读取器是通用的,并保存您要导入的模型列表。首先在名为BulkImportAutoLot.Dal项目中创建一个新文件夹;在文件夹中,创建一个名为IMyDataReader.cs的实现IDataReader的新接口类,并将代码更新如下:

using System.Collections.Generic;
using System.Data;

namespace AutoLot.Dal.BulkImport
{
  public interface IMyDataReader<T> : IDataReader
  {
    List<T> Records { get; set; }
  }
}

接下来是实现自定义数据读取器的任务。正如您已经看到的,数据读取器有许多活动部件。对你来说好消息是,对于SqlBulkCopy,你只需要实现其中的一小部分。创建一个名为MyDataReader.cs的新类,并添加以下using语句:

using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Reflection;

接下来,将类更新为 public 和 sealed 并实现IMyDataReader。添加一个构造函数来接收记录并设置属性。

public sealed class MyDataReader<T> : IMyDataReader<T>
{
  public List<T> Records { get; set; }
  public MyDataReader(List<T> records)
  {
    Records = records;
  }
}

让 Visual Studio 或 Visual Studio 代码为您实现所有方法(或从下面复制它们),您就有了自定义数据读取器的起点。表 21-7 详细说明了这种情况下需要实现的唯一方法。

表 21-7。

IDataReader的关键方法为SqlBulkCopy

|

方法

|

生命的意义

Read 获取下一条记录;如果有另一条记录,则返回true,如果在列表末尾,则返回false
FieldCount 获取数据源中的字段总数
GetValue 根据序号位置获取字段的值
GetSchemaTable 获取目标表的架构信息

Read()方法开始,如果读取器在列表的末尾,则返回false,如果读取器不在列表的末尾,则返回true(并增加一个类级计数器)。添加一个类级变量来保存List<T>的当前索引,并更新Read()方法,如下所示:

public class MyDataReader<T> : IMyDataReader<T>
{
...
  private int _currentIndex = -1;
  public bool Read()
  {
    if (_currentIndex + 1 >= Records.Count)
    {
      return false;
    }
    _currentIndex++;
    return true;
  }
}

get方法和FieldCount方法中的每一种都需要对要加载的特定模型有深入的了解。GetValue()方法的一个例子(使用CarViewModel)如下:

public object GetValue(int i)
{
  Car currentRecord = Records[_currentIndex] as Car;
  return i switch
  {
    0 => currentRecord.Id,
    1 => currentRecord.MakeId,
    2 => currentRecord.Color,
    3 => currentRecord.PetName,
    4 => currentRecord.TimeStamp,
    _ => string.Empty,
  };
}

数据库只有四个表,但这意味着您仍然有四种不同的数据读取器。想象一下,如果您有一个包含更多表的真正的生产数据库!你可以使用反射(在第十七章中介绍)和对象 LINQ(在第十三章中介绍)做得更好。

添加readonly变量来保存模型的PropertyInfo值,并添加一个字典来保存 SQL Server 中表的字段位置和名称。更新构造函数以获取泛型类型的属性并初始化Dictionary。添加的代码如下:

private readonly PropertyInfo[] _propertyInfos;
private readonly Dictionary<int, string> _nameDictionary;

public MyDataReader(List<T> records)
{
  Records = records;
  _propertyInfos = typeof(T).GetProperties();
  _nameDictionary = new Dictionary<int,string>();
}

接下来,更新构造函数以获取一个SQLConnection以及模式的字符串和记录将要插入的表的表名,并为值添加类级别的变量。

private readonly SqlConnection _connection;
private readonly string _schema;
private readonly string _tableName;
public MyDataReader(List<T> records, SqlConnection connection, string schema, string tableName)
{
  Records = records;
  _propertyInfos = typeof(T).GetProperties();
  _nameDictionary = new Dictionary<int, string>();

  _connection = connection;
  _schema = schema;
  _tableName = tableName;
}

接下来实现GetSchemaTable()方法。这将检索关于目标表的 SQL Server 信息。

public DataTable GetSchemaTable()
{
  using var schemaCommand = new SqlCommand($"SELECT * FROM {_schema}.{_tableName}", _connection);
  using var reader = schemaCommand.ExecuteReader(CommandBehavior.SchemaOnly);
  return reader.GetSchemaTable();
}

更新构造函数以使用SchemaTable来构造字典,该字典按照数据库顺序包含目标表的字段。

public MyDataReader(List<T> records, SqlConnection connection, string schema, string tableName)
{
...
  DataTable schemaTable = GetSchemaTable();
  for (int x = 0; x<schemaTable?.Rows.Count;x++)
  {
    DataRow col = schemaTable.Rows[x];
    var columnName = col.Field<string>("ColumnName");
    _nameDictionary.Add(x,columnName);
  }
}

现在,可以使用反射的信息一般地实现以下方法:

public int FieldCount => _propertyInfos.Length;
public object GetValue(int i)
  => _propertyInfos
      .First(x=>x.Name.Equals(_nameDictionary[i],StringComparison.OrdinalIgnoreCase))
      .GetValue(Records[_currentIndex]);

这里列出了必须存在(但未实现)的其余方法,以供参考:

public string GetName(int i) => throw new NotImplementedException();
public int GetOrdinal(string name) => throw new NotImplementedException();
public string GetDataTypeName(int i) => throw new NotImplementedException();
public Type GetFieldType(int i) => throw new NotImplementedException();
public int GetValues(object[] values) => throw new NotImplementedException();
public bool GetBoolean(int i) => throw new NotImplementedException();
public byte GetByte(int i) => throw new NotImplementedException();
public long GetBytes(int i, long fieldOffset, byte[] buffer, int bufferoffset, int length)
  => throw new NotImplementedException();
public char GetChar(int i) => throw new NotImplementedException();
public long GetChars(int i, long fieldoffset, char[] buffer, int bufferoffset, int length)
   => throw new NotImplementedException();
public Guid GetGuid(int i) => throw new NotImplementedException();
public short GetInt16(int i) => throw new NotImplementedException();
public int GetInt32(int i) => throw new NotImplementedException();
public long GetInt64(int i) => throw new NotImplementedException();
public float GetFloat(int i) => throw new NotImplementedException();
public double GetDouble(int i)  => throw new NotImplementedException();
public string GetString(int i) => throw new NotImplementedException();
public decimal GetDecimal(int i) => throw new NotImplementedException();
public DateTime GetDateTime(int i) => throw new NotImplementedException();
public IDataReader GetData(int i) => throw new NotImplementedException();
public bool IsDBNull(int i) => throw new NotImplementedException();
object IDataRecord.this[int i] => throw new NotImplementedException();
object IDataRecord.this[string name] => throw new NotImplementedException();
public void Close() => throw new NotImplementedException();
public DataTable GetSchemaTable() => throw new NotImplementedException();
public bool NextResult() => throw new NotImplementedException();
public int Depth { get; }
public bool IsClosed { get; }
public int RecordsAffected { get; }

执行批量复制

BulkImport文件夹中添加一个名为ProcessBulkImport.cs的新public static类。将以下using语句添加到文件的顶部:

using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using Microsoft.Data.SqlClient;

添加处理打开和关闭连接的代码(类似于InventoryDal类中的代码),如下所示:

private const string ConnectionString =
  @"Data Source=.,5433;User Id=sa;Password=P@ssw0rd;Initial Catalog=AutoLot";
private static SqlConnection _sqlConnection = null;

private static void OpenConnection()
{
  _sqlConnection = new SqlConnection
  {
    ConnectionString = ConnectionString
  };
  _sqlConnection.Open();
}

private static void CloseConnection()
{
  if (_sqlConnection?.State != ConnectionState.Closed)
  {
    _sqlConnection?.Close();
  }
}

SqlBulkCopy类需要名称(和模式,如果不同于dbo)来处理记录。创建新的SqlBulkCopy实例(传入连接对象)后,设置DestinationTableName属性。然后,创建一个新的定制数据读取器实例,保存要批量复制的列表,并调用WriteToServer()。这里显示了ExecuteBulkImport方法:

public static void ExecuteBulkImport<T>(IEnumerable<T> records, string tableName)
{
  OpenConnection();
  using SqlConnection conn = _sqlConnection;
  SqlBulkCopy bc = new SqlBulkCopy(conn)
  {
    DestinationTableName = tableName
  };
  var dataReader = new MyDataReader<T>(records.ToList(),_sqlConnection, "dbo",tableName);    try
  {
    bc.WriteToServer(dataReader);
  }
  catch (Exception ex)
  {
    //Should do something here
  }
  finally
  {
    CloseConnection();
  }
}

测试批量副本

回到AutoLot.Client项目,向Program.cs添加以下using语句:

using AutoLot.Dal.BulkImport;
using SystemCollections.Generic;

Program.cs添加一个新方法,名为DoBulkCopy()。创建一个Car对象的列表,并将该列表(以及表的名称)传递给ExecuteBulkImport()方法。其余代码显示批量复制的结果。

void DoBulkCopy()
{
  Console.WriteLine(" ************** Do Bulk Copy ************** ");
  var cars = new List<Car>
  {
    new Car() {Color = "Blue", MakeId = 1, PetName = "MyCar1"},
    new Car() {Color = "Red", MakeId = 2, PetName = "MyCar2"},
    new Car() {Color = "White", MakeId = 3, PetName = "MyCar3"},
    new Car() {Color = "Yellow", MakeId = 4, PetName = "MyCar4"}
  };
  ProcessBulkImport.ExecuteBulkImport(cars, "Inventory");
  InventoryDal dal = new InventoryDal();
  List<CarViewModel> list = dal.GetAllInventory();
  Console.WriteLine(" ************** All Cars ************** ");
  Console.WriteLine("CarId\tMake\tColor\tPet Name");
  foreach (var itm in list)
  {
    Console.WriteLine(
      $"{itm.Id}\t{itm.Make}\t{itm.Color}\t{itm.PetName}");
  }
  Console.WriteLine();
}

虽然添加四辆新车并不能展示使用SqlBulkCopy类所涉及的工作的价值,但是想象一下试图加载数千条记录。我曾经和客户这样做过,加载时间只有几秒钟,而遍历每条记录需要几个小时!就像所有的东西一样。NET Core,这只是您工具箱中的另一个工具,在最有意义的时候使用。

摘要

ADO.NET 是本地的数据访问技术。NET 核心平台。在这一章中,你从学习数据提供者的角色开始,数据提供者本质上是几个抽象基类(在System.Data.Common名称空间中)和接口类型(在System.Data名称空间中)的具体实现。您还看到了使用 ADO.NET 数据提供者工厂模型构建提供者中立的代码库是可能的。

您还了解了如何使用连接对象、事务对象、命令对象和数据读取器对象来选择、更新、插入和删除记录。此外,还记得 command 对象支持内部参数集合,您可以使用它为 SQL 查询增加一些类型安全性;事实证明,这些在触发存储过程时也非常有用。

接下来,您学习了如何用事务保护您的数据操作代码,并通过使用 ADO.NET 使用SqlBulkCopy类将大量数据加载到 SQL Server 来结束这一章。

二十二、实体框架核心简介

前一章研究了 ADO.NET 的基本面。ADO.NET 促成了。NET 程序员使用关系数据(以一种相对简单的方式)。NET 平台。在 ADO.NET 的基础上,微软引入了 ADO.NET API 的一个新组件,名为实体框架(或简称为 EF )。NET 3.5 服务包 1。

EF 的首要目标是允许您使用直接映射到应用中业务对象(或域对象)的对象模型与关系数据库中的数据进行交互。例如,您可以对称为实体的强类型对象集合进行操作,而不是将一批数据视为行和列的集合。这些实体保存在支持 LINQ 的专用集合类中,支持使用 C# 代码进行数据访问操作。集合类使用你在第十三章中学到的相同的 LINQ 语法提供对数据存储的查询。

就像。NET 核心框架,实体框架核心是对实体框架 6 的完全重写。它建立在。NET Core 框架,使 EF Core 能够在多个平台上运行。重写 EF Core 使团队能够为 EF Core 添加新的特性和性能改进,这些在 EF 6 中无法合理实现。

从头开始重新创建一个完整的框架需要仔细考虑新框架将支持哪些特性,哪些特性将被抛弃。EF 6 的一个特性是对实体设计器的支持,这个特性不在 EF 核心中(也不太可能被添加)。EF Core 只支持代码优先的开发范式。如果您目前正在首先使用代码,您可以放心地忽略前面的句子。

Note

EF Core 可用于现有数据库以及空白和/或新数据库。这两种机制都被称为代码优先,这可能不是最好的名字。实体类和派生的DbContext可以从现有的数据库中搭建,数据库可以从实体类中创建和更新。在 EF 核心章节中,你会学到这两种方法。

在每个版本中,EF Core 都添加了更多 EF 6 中已有的功能,以及 EF 6 中从未有过的新功能。3.1 版本显著缩短了 EF 核心中缺少的基本特性列表(与 EF 6 相比),5.0 版本进一步缩小了差距。事实上,对于大多数项目来说,英孚核心拥有你所需要的一切。

本章和下一章将向您介绍使用实体框架核心的数据访问。您将了解如何创建域模型、将实体类和属性映射到数据库表和列、实现变更跟踪、使用 EF Core 命令行界面(CLI)进行搭建和迁移,以及DbContext类的角色。您还将学习将实体与导航属性、事务和并发检查相关联,这只是所探索的一些特性。

当您完成这些章节时,您将拥有我们的AutoLot数据库的数据访问层的最终版本。在我们进入 EF Core 之前,我们先来谈谈对象关系映射器。

Note

两章远不足以涵盖所有的实体框架核心,因为整本书(有些和这本书一样大)都是专门讨论 EF 核心的。这些章节的目的是为您提供实用知识,帮助您开始将 EF Core 用于您的业务线应用。

对象关系映射器

ADO.NET 为您提供了一个结构,允许您通过连接、命令和数据读取器来选择、插入、更新和删除数据。虽然这一切都很好,但 ADO.NET 的这些方面迫使您以与物理数据库模式紧密耦合的方式处理提取的数据。例如,回想一下,当从数据库中获取记录时,您打开一个连接,创建并执行一个命令对象,然后使用数据读取器通过数据库特定的列名迭代每条记录。

当您使用 ADO.NET 时,您必须时刻注意后端数据库的物理结构。您必须知道每个数据表的模式,编写潜在的复杂 SQL 查询以与数据表交互,跟踪对检索(或添加)数据的更改,等等。这可能会迫使您编写一些相当冗长的 C# 代码,因为 C# 本身并不直接使用数据库模式的语言。

更糟糕的是,物理数据库的构造方式通常完全集中在数据库构造上,如外键、视图、存储过程和数据规范化,而不是面向对象的编程。

应用开发人员关心的另一个问题是变更跟踪。从数据库获取数据是该过程的一个步骤,但是开发人员必须跟踪任何更改、添加和/或删除,以便可以将它们持久化回数据存储。

中的对象关系映射框架(通常称为 ORM)的可用性。NET 通过为开发人员管理大量的创建、读取、更新和删除(CRUD)数据访问任务,极大地增强了数据访问能力。开发人员创建。NET 对象和关系数据库,ORM 管理连接、查询生成、变更跟踪和持久化数据。这使得开发人员可以专注于应用的业务需求。

Note

重要的是要记住,ORM 不是骑在彩虹上的神奇独角兽。每一个决定都涉及到取舍。ORM 减少了开发人员创建数据访问层的工作量,但是如果使用不当,也会带来性能和伸缩问题。对 CRUD 操作使用 ORM,对基于集合的操作使用数据库的能力。

尽管不同的 ORM 在操作方式和使用方式上略有不同,但它们本质上都有相同的部分,并为相同的目标而努力——使数据访问操作更容易。实体是映射到数据库表的类。专用集合类型包含一个或多个实体。改变跟踪机制跟踪实体的状态以及对它们所做的任何改变、添加和/或删除,并且中央构造作为领头者控制操作。

理解实体框架核心的作用

在幕后,EF 核心使用你已经在前一章检查的 ADO.NET 基础设施。像任何与数据存储的 ADO.NET 交互一样,EF Core 使用 ADO.NET 数据提供者进行数据存储交互。在 EF Core 可以使用 ADO.NET 数据提供程序之前,必须对其进行更新,以与 EF Core 完全集成。由于这一新增功能,EF 核心数据提供商可能比 added 数据提供商更少。

使用 ADO.NET 数据库提供商模式的 EF Core 的好处是,它使您能够在同一个项目中结合 EF Core 和 ADO.NET 数据访问范例,增强您的能力。例如,使用 EF Core 为批量复制操作提供连接、模式和表名利用了 EF Core 的映射功能和内置于 ADO.NET 的 BCP 功能。这种混合方法使 EF Core 成为您工具箱中的又一个工具。

当您看到有多少基本的数据访问管道以方便和有效的方式为您处理时,EF Core 很可能会成为您的数据访问首选机制。

Note

许多第三方数据库(如 Oracle 和 MySQL)都提供支持 EF 的数据提供者。如果您没有使用 SQL Server,请咨询您的数据库供应商了解详细信息,或者导航至 https://docs.microsoft.com/en-us/ef/core/providers 获取可用的 EF 核心数据提供商列表。

EF Core 最适合数据之上的表单(或数据之上的 API)情况下的开发过程。使用工作单元模式对少量实体进行操作以确保一致性是 EF Core 的优势。它不太适合大规模数据操作,如提取-转换-加载(ETL)数据仓库应用或大型报告情况。

实体框架的构建块

EF 核心的主要组件是DbContextChangeTrackerDbSet专用集合类型、数据库提供者和应用的实体。要完成本部分,请创建一个名为 AutoLot 的新控制台应用。取样并添加Microsoft.EntityFrameworkCoreMicrosoft.EntityFrameworkCore.DesignMicrosoft.EntityFrameworkCore.SqlServer包。

dotnet new sln -n Chapter22_AllProjects
dotnet new console -lang c# -n AutoLot.Samples -o .\AutoLot.Samples -f net5.0
dotnet sln .\Chapter22_AllProjects.sln add .\AutoLot.Samples
dotnet add AutoLot.Samples package Microsoft.EntityFrameworkCore
dotnet add AutoLot.Samples package Microsoft.EntityFrameworkCore.Design
dotnet add AutoLot.Samples package Microsoft.EntityFrameworkCore.SqlServer

DbContext 类

DbContext是 EF 核心的首要组件,通过Database属性提供对数据库的访问。DbContext管理ChangeTracker实例,公开访问 Fluent API 的虚拟OnModelCreating方法,保存所有的DbSet<T>属性,并提供SaveChanges方法将数据保存到数据存储中。它不是直接使用的,而是通过一个继承了DbContext的自定义类。在这个类中放置了DbSet<T>属性。

表 22-1 显示了一些更常用的DbContext成员。

表 22-1。

DbContext的普通成员

|

DbContext的成员

|

生命的意义

Database 提供对数据库相关信息和功能的访问,包括 SQL 语句的执行。
Model 关于实体形状、它们之间的关系以及它们如何映射到数据库的元数据。**注意:**这个属性通常不直接交互。
ChangeTracker 提供对该DbContext正在跟踪的实体实例的信息和操作的访问。
DbSet<T> 不是真正的DbContext成员,而是添加到自定义派生类DbContext的属性。这些属性属于DbSet<T>类型,用于查询和保存应用实体的实例。针对DbSet<T>属性的 LINQ 查询被转换成 SQL 查询。
Entry 提供对实体的更改跟踪信息和操作的访问,例如显式加载相关实体或更改EntityState。也可以在未跟踪的实体上调用,以将状态更改为已跟踪。
Set<TEntity> 创建可用于查询和保存数据的DbSet<T>属性的实例。
SaveChanges / SaveChangesAsync 将所有实体更改保存到数据库,并返回受影响的记录数。在事务中执行(隐式或显式)。
Add / AddRange``Update / UpdateRange``Remove / RemoveRange 方法来添加、更新和移除实体实例。只有当SaveChanges成功执行时,更改才会被保存。异步版本也可用。**注意:**虽然在派生的DbContext上可用,但这些方法通常直接在DbSet<T>属性上调用。
Find 查找具有给定主键值的类型的实体。异步版本也可用。**注意:**虽然在派生的DbContext上可用,但这些方法通常直接在DbSet<T>属性上调用。
Attach / AttachRange 开始跟踪实体(或实体列表)。异步版本也可用。**注意:**虽然在派生的DbContext上可用,但这些方法通常直接在DbSet<T>属性上调用。
SavingChanges 在开始调用SaveChanges / SaveChangesAsync时触发事件。
SavedChanges 在对SaveChanges / SaveChangesAsync的调用结束时触发的事件。
SaveChangesFailed SaveChanges / SaveChangesAsync的调用失败时触发的事件。
OnModelCreating 当一个模型已经被初始化,但在它完成之前调用。来自 Fluent API 的方法被放置在该方法中,以最终确定模型的形状。
OnConfiguring 用于创建或修改DbContext选项的生成器。每次创建一个DbContext实例时执行。**注意:**建议不要使用这个,而是使用DbContextOptions在运行时配置DbContext实例,在设计时使用IDesignTimeDbContextFactory实例。

创建派生的 DbContext

EF Core 的第一步是创建一个从DbContext继承的自定义类。然后添加一个构造函数,该构造函数接受一个强类型的实例DbContextOptions(接下来将介绍)并将该实例传递给基类。

namespace AutoLot.Samples
{
  public class ApplicationDbContext : DbContext
  {
    public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
            : base(options)
    {
    }
  }
}

这是一个用来访问数据库和处理实体、变更跟踪器以及 EF 核心的所有组件的类。

配置数据库上下文

使用DbContextOptions类的实例来配置DbContext实例。使用DbContextOptionsBuilder创建DbContextOptions实例,因为DbContextOptions类并不意味着直接在您的代码中构造。通过DbContextOptionsBuilder实例,选择数据库提供者(以及任何提供者特定的设置),并设置 EF Core DbContext通用选项(如日志记录)。然后在运行时将Options属性注入到基DbContext中。

这种动态配置功能支持在运行时更改设置,只需选择不同的选项(例如,MySQL 而不是 SQL Server provider)并创建派生的DbContext的新实例。

设计时 DbContext 工厂

设计时DbContext工厂是实现IDesignTimeDbContextFactory<T>接口的类,其中T是派生的DbContext类。该接口有一个方法CreateDbContext(),您必须实现它来创建您的派生DbContext的实例。

下面的ApplicationDbContextFactory类使用CreateDbContext()方法为ApplicationDbContext类创建一个强类型的DbContextOptionsBuilder,将数据库提供者设置为 SQL Server 提供者(使用第二十一章中的 Docker 实例连接字符串),然后创建并返回一个新的ApplicationDbContext实例:

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

namespace AutoLot.Samples
{
  public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
  {
    public ApplicationDbContext CreateDbContext(string[] args)
    {
      var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
      var connectionString = @"server=.,5433;Database=AutoLotSamples;User Id=sa;Password=P@ssw0rd;";
      optionsBuilder.UseSqlServer(connectionString);
      Console.WriteLine(connectionString);
      return new ApplicationDbContext(optionsBuilder.Options);
    }
  }
}

命令行界面使用上下文工厂来创建派生的DbContext类的实例,以执行诸如数据库迁移创建和应用之类的操作。因为它是设计时构造的,而不是在运行时使用的,所以开发数据库的连接字符串通常是硬编码的。

EF Core 5 中的新特性,参数可以从命令行传递给CreateDbContext()方法。在这一章的后面你会学到更多。

on model 创建

基本的DbContext类公开了OnModelCreating方法,该方法用于使用 Fluent API 来形成您的实体。这将在本章后面深入讨论,但是现在,将下面的代码添加到ApplicationDbContext类中:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  // Fluent API calls go here
  OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);

保存更改

为了触发DbContextChangeTracker来保持被跟踪实体中的任何变化,在派生的DbContext上调用SaveChanges()(或SaveChangesAsync())方法。

static void SampleSaveChanges()
{
  //The factory is not meant to be used like this, but it’s demo code :-)
    var context = new ApplicationDbContextFactory().CreateDbContext(null);
    //make some changes
    context.SaveChanges();
}

在本章(和本书)的剩余部分将会有很多保存更改的例子。

交易和保存点支持

EF Core 使用数据库的隔离级别将每个对SaveChanges / SaveChangesAsync的调用包装在一个隐式事务中。为了获得更多的控制,您还可以将派生的DbContext加入到显式事务中。要在显式事务中执行,使用派生的DbContextDatabase属性创建一个事务。照常执行操作,然后提交或回滚事务。下面是演示这一点的代码片段:

using var trans = context.Database.BeginTransaction();
try
{
  //Create, change, delete stuff
  context.SaveChanges();
  trans.Commit();
}
catch (Exception ex)
{
  trans.Rollback();
}

EF Core 5 中引入了 EF Core 交易的保存点。当调用SaveChanges() / SaveChangesAsync()时,事务已经在进行中,EF Core 在该事务中创建一个保存点。如果调用失败,事务将回滚到保存点,而不是事务的开始。保存点也可以通过调用事务上的CreateSavePoint()RollbackToSavepoint()以编程方式进行管理,如下所示:

using var trans = context.Database.BeginTransaction();
try
{
  //Create, change, delete stuff
  trans.CreateSavepoint("check point 1");
  context.SaveChanges();
  trans.Commit();
}
catch (Exception ex)
{
  trans. RollbackToSavepoint("check point 1");
}

交易和执行策略

当一个执行策略处于活动状态时(如在使用EnableRetryOnFailure()时),在创建一个显式事务之前,您必须获得一个对 EF Core 正在使用的当前执行策略的引用。然后调用策略上的Execute()方法来创建一个显式事务。

var strategy = context.Database.CreateExecutionStrategy();
strategy.Execute(() =>
{
  using var trans = context.Database.BeginTransaction();
  try
  {
    actionToExecute();
    trans.Commit();
  }
  catch (Exception ex)
  {
    trans.Rollback();
  }
});

保存/已保存的更改事件

EF Core 5 引入了三个由SaveChanges() / SaveChangesAsync()方法触发的新事件。当调用SaveChanges()时(但是在针对数据存储执行 SQL 语句之前)触发SavingChanges,在SaveChanges()完成后SavedChanges触发。以下(简单的)代码示例显示了事件及其处理程序的运行情况:

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
    : base(options)
{
  SavingChanges += (sender, args) =>
  {
    Console.WriteLine($"Saving changes for {((DbContext)sender).Database.GetConnectionString()}");
  };
  SavedChanges += (sender, args) =>
  {
    Console.WriteLine($"Saved {args.EntitiesSavedCount} entities");
  };
  SaveChangesFailed += (sender, args) =>
  {
    Console.WriteLine($"An exception occurred! {args.Exception.Message} entities");
  };
}

DbSet

对于对象模型中的每个实体,添加一个类型为DbSet<T>的属性。DbSet<T>类是一个专门的集合属性,用于与数据库提供者交互,以获取、添加、更新或删除数据库中的记录。每个DbSet<T>为数据库交互的每个集合提供许多核心服务。对DbSet<T>类执行的任何 LINQ 查询都被数据库提供者翻译成数据库查询。表 22-2 描述了一些DbSet<T>级的核心成员。

表 22-2。

DbSet<T>的公共成员和扩展方法

|

DbSet<T>的成员

|

生命的意义

Add / AddRange 开始跟踪处于Added状态的实体。当调用SaveChanges时,项目将被添加。异步版本也可用。
AsAsyncEnumerable IAsyncEnumerable<T>的形式返回集合。
AsQueryable IQueryable<T>的形式返回集合。
Find 通过主键在ChangeTracker中搜索实体。如果在变更跟踪器中找不到,则查询数据存储中的对象。异步版本也可用。
Update / UpdateRange 开始跟踪处于Modified状态的实体。调用SaveChanges时,项目将被更新。异步版本也可用。
Remove / RemoveRange 开始跟踪处于Deleted状态的实体。当调用SaveChanges时,项目将被移除。异步版本也可用。
Attach / AttachRange 开始跟踪实体。将数字主键定义为标识且值等于零的实体在添加时被跟踪。所有其他的被跟踪为未改变。异步版本也可用。
FromSqlRaw / FromSqlInterpolated 基于表示 SQL 查询的原始或插值字符串创建 LINQ 查询。可以与用于服务器端执行的附加 LINQ 语句结合使用。
AsQueryable() DbSet<T>返回一个IQueryable<T>实例。

DbSet<T>实现IQueryable<T>并且通常是实体查询的 LINQ 的目标。除了 EF Core 增加的扩展方法,DbSet<T>还支持你在第十三章中了解到的相同的扩展方法,比如ForEach()Select()All()

您将在“实体”部分向ApplicationDbContext添加DbSet<T>属性。

Note

表 22-2 中列出的许多方法的名称与表 22-1 中的方法相同。主要的区别在于,DbSet<T>方法已经知道要操作的类型,并且有实体列表。DbContext方法必须使用反射来决定做什么。使用DbSet<T>的方法比使用DbContext的方法更常见。

查询类型

查询类型是用于表示视图、SQL 语句或没有主键的表的DbSet<T>集合。以前版本的 EF 核心使用DbQuery<T>来处理这些,但是从 EF 核心 3.1 开始,DbQuery类型已经被淘汰。使用DbSet<T>属性将查询类型添加到派生的DbContext中,并配置为无键。

例如,CustomerOrderViewModel(您将在构建完整的AutoLot数据访问库时创建)配置有Keyless属性。

[Keyless]
public class CustomerOrderViewModel
{
...
}

其余的配置在 Fluent API 中进行。以下示例将实体设置为 keyless,并将查询类型映射到dbo.CustomerOrderView数据库视图(注意,如果Keyless数据注释在模型上,则HasNoKey() Fluent API 方法不是必需的,反之亦然,但为了完整起见,在本示例中显示了该方法):

modelBuilder.Entity<CustomerOrderViewModel>().HasNoKey().ToView("CustomerOrderView", "dbo");

查询类型也可以映射到 SQL 查询,如下所示:

modelBuilder.Entity<CustomerOrderViewModel>().HasNoKey().ToSqlQuery(
  @"SELECT c.FirstName, c.LastName, i.Color, i.PetName, m.Name AS Make
        FROM   dbo.Orders o
        INNER JOIN dbo.Customers c ON o.CustomerId = c.Id
        INNER JOIN dbo.Inventory  i ON o.CarId = i.Id
        INNER JOIN dbo.Makes m ON m.Id = i.MakeId");

查询类型可以使用的最后一种机制是FromSqlRaw()FromSqlInterpolated()方法。下面是一个使用FromSqlRaw()的相同查询的例子:

public IEnumerable<CustomerOrderViewModel> GetOrders()
{
  return CustomerOrderViewModels.FromSqlRaw(
    @"SELECT c.FirstName, c.LastName, i.Color, i.PetName, m.Name AS Make
          FROM   dbo.Orders o
          INNER JOIN dbo.Customers c ON o.CustomerId = c.Id
          INNER JOIN dbo.Inventory  i ON o.CarId = i.Id
          INNER JOIN dbo.Makes m ON m.Id = i.MakeId");
}

灵活的查询/表映射

EF Core 5 引入了将同一个类映射到多个数据库对象的能力。这些对象可以是表、视图或函数。例如,来自章节 21 的CarViewModel可以映射到一个视图,该视图返回带有Car数据和Inventory表的品牌名称。然后,EF Core 将从视图中查询,并将更新发送到表中。

modelBuilder.Entity<CarViewModel>()
  .ToTable("Inventory")
  .ToView("InventoryWithMakesView");

变化跟踪者

在一个DbContext实例中,ChangeTracker实例跟踪加载到DbSet<T>中的对象的状态。表 22-3 列出了对象状态的可能值。

表 22-3。

实体状态枚举值

|

价值

|

生命的意义

Added 正在跟踪该实体,但它尚不存在于数据库中。
Deleted 该实体正在被跟踪,并被标记为从数据库中删除。
Detached 变更跟踪器没有跟踪该实体。
Modified 该条目正在被跟踪并已被更改。
Unchanged 该实体正在被跟踪,存在于数据库中,并且尚未被修改。

如果需要检查对象的状态,请使用以下代码:

EntityState state = context.Entry(entity).State;

您还可以使用相同的机制以编程方式更改对象的状态。要将状态更改为Deleted(例如),请使用以下代码:

context.Entry(entity).State = EntityState.Deleted;

ChangeTracker 事件

有两个事件可以由ChangeTracker引发。第一个是StateChanged,第二个是Tracked。当一个实体的状态改变时,触发StateChanged事件。当第一次跟踪实体时,它不会触发。当一个实体开始被跟踪时,触发Tracked事件,无论是通过编程添加到一个DbSet<T>实例,还是从一个查询返回。

重置 DbContext 状态

EF Core 5 的新功能是重置一个DbContextChangeTracker.Clear()方法通过将实体的状态设置为 detached 来清除DbSet<T>属性中的所有实体。

实体

映射到数据库表的强类型类被正式称为实体。应用中的实体集合包括物理数据库的概念模型。正式来说,这个模型被称为实体数据模型 (EDM),通常简称为模型。模型被映射到应用/业务领域。实体及其属性使用实体框架核心约定、配置和 Fluent API(代码)映射到表和列。实体不需要直接映射到数据库模式。您可以自由地构建实体类来满足您的应用需求,然后将您唯一的实体映射到您的数据库模式。

数据库和实体之间的这种松散耦合意味着您可以独立于数据库设计和结构来塑造实体以匹配您的业务领域。例如,从上一章的AutoLot数据库中的简单的Inventory表和Car实体类。名称不同,但是Car实体映射到了Inventory表。EF Core 检查模型中实体的配置,将客户端表示的Inventory表(在我们的例子中是Car类)映射到Inventory表的正确列。

接下来的几个部分详细介绍了 EF 核心约定、数据注释和代码(使用 Fluent API)如何将模式中的实体、属性和实体之间的关系映射到数据库中的表、列和外键关系。

将属性映射到列

当使用关系数据存储时,EF 核心约定将所有读写公共属性映射到实体所映射到的表中的列。如果属性是自动属性,EF Core 通过 getter 和 setter 进行读写。如果属性有支持字段,EF Core 将读写支持字段而不是公共属性,即使支持字段是私有的。虽然 EF Core 可以对私有字段进行读写,但是仍然必须有一个封装了后台字段的公共读写属性。

后台字段支持有两种优势,一种是在 Windows Presentation Foundation(WPF)应用中使用INotifyPropertyChanged模式,另一种是数据库默认值与。核心默认值净值。在第二十八章中介绍了使用 EF 内核和 WPF,数据库默认值将在本章稍后介绍。

列的名称、数据类型和可空性是通过约定、数据注释和/或 Fluent API 配置的。本章稍后将深入讨论这些主题。

将类映射到表

EF Core 中有两种可用的类到表的映射方案:每层次表(TPH)每类型表(TPT) 。TPH 映射是默认的,它将继承层次结构映射到单个表。TPT 是 EF Core 5 中的新特性,它将层次结构中的每个类映射到自己的表中。

Note

类也可以映射到视图和原始 SQL 查询。这些被称为查询类型,将在本章后面介绍。

每层次表映射(TPH)

考虑下面的例子,它显示了从第二十一章到的Car类被分成两个类:一个基类用于IdTimeStamp属性,其余的属性留在Car类中。这两个类都应该创建在自动程序的Models目录中。示例项目。

using System.Collections.Generic;

namespace AutoLot.Samples.Models
{
  public abstract class BaseEntity
  {
    public int Id { get; set; }
    public byte[] TimeStamp { get; set; }
  }
}

using System.Collections.Generic;
namespace AutoLot.Samples.Models
{
  public class Car : BaseEntity
  {
    public string Color { get; set; }
    public string PetName { get; set; }
    public int MakeId { get; set; }
  }
}

为了让 EF Core 知道实体类是对象模型的一部分,为实体添加一个DbSet<T>属性。将以下using语句添加到ApplicationDbContext类中:

using AutoLot.Samples.Models;

将以下代码添加到构造函数和OnModelCreating()方法之间的ApplicationDbContext类中:

public DbSet<Car> Cars { get; set; }

注意,基类是作为DbSet<T>实例添加的而不是。尽管移植细节将在本章后面介绍,但让我们创建数据库和Cars表。在与 AutoLot 相同的目录中打开命令提示符。示例项目,并运行以下命令(全部在一行中):

dotnet tool install s--global dotnet-ef --version 5.0.1
dotnet ef migrations add TPH -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update TPH  -c AutoLot.Samples.ApplicationDbContext

第一个命令将 EF 核心命令行工具安装为一个全局工具。这只需要在您的机器上进行一次。第二个命令使用AutoLot.Samples名称空间中的ApplicationDbContextMigrations目录中创建了一个名为 TPH 的迁移。第三个命令从 TPH 迁移中更新数据库。

当使用 EF Core 在数据库中创建这个表时,继承的BaseEntity类被合并到Car类中,并且创建了一个表,如下所示:

CREATE TABLE [dbo].Cars NOT NULL,
  [MakeId] [int] NOT NULL,
  [Color] nvarchar NULL,
  [PetName] nvarchar NULL,
  [TimeStamp] varbinary NULL,
 CONSTRAINT [PK_Cars] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]

前一个例子依赖于 EF 核心约定(很快会介绍)来创建表和列属性。

每类型表映射(TPT)

为了探索 TPT 映射模式,可以使用前面的相同实体,即使基类被标记为抽象。因为 TPH 是默认的,所以必须指示 EF Core 将每个类映射到一个表。这可以通过数据注释或 Fluent API 来完成。将以下代码添加到ApplicationDbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.Entity<BaseEntity>().ToTable("BaseEntities");
  modelBuilder.Entity<Car>().ToTable("Cars");
  OnModelCreatingPartial(modelBuilder);
}
partial void OnModelCreatingPartial(ModelBuilder modelBuilder);

要“重置”数据库和项目,请删除Migrations文件夹和数据库。要使用 CLI 强制删除数据库,请输入以下内容:

dotnet ef database drop -f -c AutoLot.Samples.ApplicationDbContext

现在为 TPT 模式创建并应用迁移。

dotnet ef migrations add TPT -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update TPT  -c AutoLot.Samples.ApplicationDbContext

EF 核心将在更新数据库时创建以下表格。索引还显示这些表有一对一的映射。

CREATE TABLE [dbo].BaseEntities NOT NULL,
  [TimeStamp] varbinary NULL,
 CONSTRAINT [PK_BaseEntities] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

CREATE TABLE [dbo].Inventory NULL,
  [PetName] nvarchar NULL,
 CONSTRAINT [PK_Inventory] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

ALTER TABLE [dbo].[Inventory]  WITH CHECK ADD  CONSTRAINT [FK_Inventory_BaseEntities_Id] FOREIGN KEY([Id])
REFERENCES [dbo].[BaseEntities] ([Id])
GO
ALTER TABLE [dbo].[Inventory] CHECK CONSTRAINT [FK_Inventory_BaseEntities_Id]
GO

Note

每种类型的表映射具有显著的性能影响,在使用这种映射方案之前应该考虑到这一点。更多信息请参考文档: https://docs.microsoft.com/en-us/ef/core/performance/modeling-for-performance#inheritance-mapping

为了“重置”数据库和项目以准备下一组示例,注释掉OnModelCreating()方法中的代码,并再次删除Migrations文件夹和数据库。

dotnet ef database drop -f -c AutoLot.Samples.ApplicationDbContext

导航属性和外键

导航属性表示实体类如何相互关联,并使代码能够从一个实体实例遍历到另一个实体实例。根据定义,导航属性是映射到数据库提供程序定义的非标量类型的任何属性。实际上,导航属性映射到另一个实体(称为引用导航属性)或者另一个实体的集合(称为集合导航属性)。在数据库端,导航属性被转换成表之间的外键关系。在 EF Core 中直接支持一对一、一对多和(EF Core 5 中新增的)多对多关系。实体类也可以有自己的导航属性,表示自引用表。

Note

我发现将具有导航属性的对象视为链表是很有帮助的,如果导航属性是双向的,那么对象的行为就像双向链表。

在详细介绍导航属性和实体关系模式之前,请参考表 22-4 。这三种关系模式中都用到这些术语。

表 22-4。

用于描述导航属性和关系的术语

|

学期

|

生命的意义

主要实体 关系的父级。
从属实体 关系的孩子。
主键 用于定义主体实体的属性。可以是主键或备用键。可以使用单个属性或多个属性来配置密钥。
外键 子实体保存的用于存储主键的一个或多个属性。
所需的关系 需要外键值的关系(不可为空)。
可选关系 外键值不可为空的关系。
缺少外键属性

如果具有引用导航属性的实体没有用于外键值的属性,EF Core 将在实体上创建必要的属性。这些被称为影子外键属性,并以<navigation property name><principal key property name><principal entity name><principal key property name>的格式命名。这适用于所有的关系类型(一对多、一对一、多对多)。与让 EF Core 为您创建实体相比,使用显式外键属性来构建您的实体是一种更为干净的方法。

一对多关系

为了创建一对多关系,一方(主体)的实体类添加多方(从属)的实体类的集合属性。依赖实体还应该拥有主体外键的属性。如果没有,EF Core 将创建影子外键属性,如前所述。

例如,在第二十一章创建的数据库中,Makes表(由Make实体类表示)和Inventory表(由Car实体类表示)是一对多的关系。为了使这些例子简单起见,Car实体将映射到Cars表。以下代码显示了表示这种关系的双向导航属性:

using System.Collections.Generic;
namespace AutoLot.Samples.Models
{
    public class Make : BaseEntity
    {
       public string Name { get; set; }
       public IEnumerable<Car> Cars { get; set; } = new List<Car>();
    }
}

using System.Collections.Generic;
namespace AutoLot.Samples.Models
{
  public class Car : BaseEntity
  {
    public string Color { get; set; }
    public string PetName { get; set; }
    public int MakeId { get; set; }
    public Make MakeNavigation { get; set; }
  }
}

Note

当搭建现有数据库时,EF 核心名称引用与属性类型名称相同的导航属性(例如,public Make {get; set;})。这可能会导致导航和智能感知方面的问题,更不用说使代码难以处理了。为了清楚起见,我更喜欢在引用导航属性时添加后缀Navigation,如前面的例子所示。

Car / Make的例子中,Car实体是从属实体(一对多的),而Make实体是主体实体(一对多的)。

DbSet<Make>实例添加到ApplicationDbContext,如下图所示:

public DbSet<Car> Cars { get; set; }
public DbSet<Make> Makes { get; set; }

使用以下命令创建迁移并更新数据库:

dotnet ef migrations add One2Many -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update One2Many  -c AutoLot.Samples.ApplicationDbContext

使用 EF 核心迁移更新数据库时,会创建以下表格:

CREATE TABLE [dbo].Makes NOT NULL,
  [Name] nvarchar NULL,
  [TimeStamp] varbinary NULL,
 CONSTRAINT [PK_Makes] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

CREATE TABLE [dbo].Cars NOT NULL,
  [Color] nvarchar NULL,
  [PetName] nvarchar NULL,
  [TimeStamp] varbinary NULL,
  [MakeId] [int] NOT NULL,
 CONSTRAINT [PK_Cars] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

ALTER TABLE [dbo].[Cars]  WITH CHECK ADD  CONSTRAINT [FK_Cars_Makes_MakeId] FOREIGN KEY([MakeId])
REFERENCES [dbo].[Makes] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[Cars] CHECK CONSTRAINT [FK_Cars_Makes_MakeId]
GO

注意在 dependent ( Cars)表上创建的外键和检查约束。

一对一的关系

在一对一关系中,两个实体都具有对另一个实体的引用导航属性。虽然一对多关系明确表示主体和从属实体,但在建立一对一关系时,必须通过明确定义主体实体的外键或通过使用 Fluent API 指示主体来告知 EF Core 哪一方是主体。如果 EF Core 没有得到通知,它将根据自己检测外键的能力选择一个。实际上,您应该通过添加外键属性来明确定义依赖项。

namespace AutoLot.Samples.Models
{
  public class Car : BaseEntity
  {
    public string Color { get; set; }
    public string PetName { get; set; }
    public int MakeId { get; set; }
    public Make MakeNavigation { get; set; }
    public Radio RadioNavigation { get; set; }
  }
}

namespace AutoLot.Samples.Models
{
  public class Radio : BaseEntity
  {
    public bool HasTweeters { get; set; }
    public bool HasSubWoofers { get; set; }
    public string RadioId { get; set; }
    public int CarId { get; set; }
    public Car CarNavigation { get; set; }
  }
}

由于Radio有一个到Car类的外键(基于约定,稍后将介绍),Radio是依赖实体,Car是主体实体。EF Core 隐式地在依赖实体的外键属性上创建所需的唯一索引。如果您想要更改索引的名称,可以使用数据注释或 Fluent API 来完成。

DbSet<Radio>加到ApplicationDbContext上。

public virtual DbSet<Car> Cars { get; set; }
public virtual DbSet<Make> Makes { get; set; }
public virtual DbSet<Radio> Radios { get; set; }

使用以下命令创建迁移并更新数据库:

dotnet ef migrations add One2One -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update One2One  -c AutoLot.Samples.ApplicationDbContext

当使用 EF 核心迁移更新数据库时,Cars表保持不变,并创建以下Radios表:

CREATE TABLE [dbo].Radios NOT NULL,
  [HasTweeters] [bit] NOT NULL,
  [HasSubWoofers] [bit] NOT NULL,
  [RadioId] nvarchar NULL,
  [TimeStamp] varbinary NULL,
  [CarId] [int] NOT NULL,
 CONSTRAINT [PK_Radios] PRIMARY KEY CLUSTERED
(
    [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO
ALTER TABLE [dbo].[Radios]  WITH CHECK ADD  CONSTRAINT [FK_Radios_Cars_CarId] FOREIGN KEY([CarId])
REFERENCES [dbo].[Cars] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[Radios] CHECK CONSTRAINT [FK_Radios_Cars_CarId]
GO

注意在 dependent ( Radios)表上创建的外键和检查约束。

多对多关系(新 EF Core 5)

在多对多关系中,两个实体都有指向另一个实体的集合导航属性。这是在数据存储中实现的,在两个实体表之间有一个连接表。这个连接表是以使用<Entity1Entity2>的两个表命名的。可以通过 Fluent API 以编程方式更改该名称。连接实体与每个实体表都有一对多的关系。

namespace AutoLot.Samples.Models
{
  public class Car : BaseEntity
  {
    public string Color { get; set; }
    public string PetName { get; set; }
    public int MakeId { get; set; }
    public Make MakeNavigation { get; set; }
    public Radio RadioNavigation { get; set; }
    public IEnumerable<Driver> Drivers { get; set; } = new List<Driver>();
  }
}

namespace AutoLot.Samples.Models
{
  public class Driver : BaseEntity
  {
    public string FirstName { get; set; }
    public string LastName { get; set; }
    public IEnumerable<Car> Cars { get; set; } = new List<Car>();
  }
}

等效的方法可以通过显式创建三个表来完成,这也是在 EF Core 5 之前的 EF Core 版本中必须完成的工作。下面是一个简短的例子:

public class Driver
{
...
  public IEnumerable<CarDriver> CarDrivers { get; set; }
}

public class Car
{
...
  public IEnumerable<CarDriver> CarDrivers { get; set; }
}
public class CarDriver
{
  public int CarId {get;set;}
  public Car CarNavigation {get;set;}
  public int DriverId {get;set;}
  public Driver DriverNavigation {get;set;}
}

DbSet<Driver>加到ApplicationDbContext上。

public virtual DbSet<Car> Cars { get; set; }
public virtual DbSet<Make> Makes { get; set; }
public virtual DbSet<Radio> Radios { get; set; }
public virtual DbSet<Driver> Drivers { get; set; }

使用以下命令创建迁移并更新数据库:

dotnet ef migrations add Many2Many -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update many2Many  -c AutoLot.Samples.ApplicationDbContext

当使用 EF 核心迁移更新数据库时,Cars表保持不变,而DriversCarDriver表被创建。

CREATE TABLE [dbo].Drivers NOT NULL,
  [FirstName] NVARCHAR NULL,
  [LastName] NVARCHAR NULL,
  [TimeStamp] VARBINARY NULL,
 CONSTRAINT [PK_Drivers] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY]
GO

CREATE TABLE [dbo].CarDriverWITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[CarDriver]  WITH CHECK ADD  CONSTRAINT [FK_CarDriver_Cars_CarsId] FOREIGN KEY([CarsId])
REFERENCES [dbo].[Cars] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[CarDriver] CHECK CONSTRAINT [FK_CarDriver_Cars_CarsId]
GO
ALTER TABLE [dbo].[CarDriver]  WITH CHECK ADD  CONSTRAINT [FK_CarDriver_Drivers_DriversId] FOREIGN KEY([DriversId])
REFERENCES [dbo].[Drivers] ([Id])
ON DELETE CASCADE
GO
ALTER TABLE [dbo].[CarDriver] CHECK CONSTRAINT [FK_CarDriver_Drivers_DriversId]
GO

请注意,复合主键、检查约束(外键)和级联行为都是由 EF Core 创建的,以确保将CarDriver表配置为正确的连接表。

Note

在撰写本文时,还不支持搭建多对多关系。多对多关系是基于表结构搭建的,如第二个示例中的CarDriver实体。这里正在跟踪的问题: https://github.com/dotnet/efcore/issues/22475

级联行为

大多数数据存储(如 SQL Server)都有规则来控制删除行时的行为。如果相关(从属)记录也被删除,这被称为级联删除。在 EF Core 中,当删除主体实体(内存中加载了相关实体)时,会发生三种操作。

  • 相关记录被删除。

  • 相关外键被设置为 null。

  • 从属实体保持不变。

可选关系和必需关系的默认行为是不同的。行为也可以配置为七个值中的一个,尽管只推荐使用五个。使用 Fluent API 通过DeleteBehavior枚举配置行为。枚举中可用的选项如下所示:

  • Cascade

  • ClientCascade

  • ClientNoAction(不推荐使用)

  • ClientSetNull

  • NoAction(不推荐使用)

  • SetNull

  • Restrict

在 EF Core 中,只有在删除了一个实体并且在派生的DbContext上调用了SaveChanges()之后,才会触发指定的行为。有关 EF Core 何时与数据存储交互的更多详细信息,请参见“查询执行”一节。

可选关系

回想一下表 22-4 中的可选关系,依赖实体可以将外键值设置为空。对于可选关系,默认行为是ClientSetNull。表 22-5 显示了使用 SQL Server 时依赖实体的级联行为以及对数据库记录的影响。

表 22-5。

具有可选关系的级联行为

|

删除行为

|

对受抚养人的影响(在内存中)

|

对受抚养人的影响(在数据库中)

Cascade 实体被删除。 实体被数据库删除。
ClientCascade 实体被删除。 对于不支持级联删除的数据库,EF Core 会删除实体。
ClientSetNull(默认) 外键属性设置为空。 没有。
SetNull 外键属性设置为空。 外键属性设置为空。
Restrict 没有。 没有。
所需的关系

所需的关系是依赖实体不能将外键值设置为空。对于必需的关系,默认行为是Cascade。表 22-6 显示了使用 SQL Server 时依赖实体的级联行为以及对数据库记录的影响。

表 22-6。

具有所需关系的级联行为

|

删除行为

|

对受抚养人的影响(在内存中)

|

对受抚养人的影响(在数据库中)

Cascade(默认) 实体被删除。 实体被删除。
ClientCascade 实体被删除。 对于不支持级联删除的数据库,EF Core 会删除实体。
ClientSetNull SaveChanges抛出异常。 没有。
SetNull SaveChanges抛出异常。 SaveChanges抛出异常。
Restrict 没有。 没有。

实体约定

EF Core 使用许多约定来定义一个实体以及它与数据存储的关系。除非被 Fluent API 中的数据注释或代码否决,否则这些约定将始终启用。表 22-7 列出了一些更重要的 EF 核心惯例。

表 22-7。

EF 的一些核心惯例

|

惯例

|

生命的意义

包含的表格 所有具有DbSet属性的类和所有能够被DbSet类访问(通过导航属性)的类都在数据库中创建。
包含的列 所有带有 getter 和 setter 的公共属性(包括自动属性)都被映射到列。
表名 映射到派生的DbContext中的DbSet属性名。如果不存在DbSet,则使用类名。
计划 表是在数据存储的默认模式中创建的(在 SQL Server 上为dbo)。
列名 列名映射到类的属性名。
列数据类型 数据类型是根据。NET 核心数据类型,并由数据库提供程序(SQL Server)转换。DateTime映射到datetime2(7),string映射到nvarchar(max)。字符串作为主键的一部分映射到nvarchar(450)
列为空性 不可为 Null 的数据类型被创建为 Not Null 持久性列。EF 核心支持 C# 8 可空性。
主关键字 名为Id<EntityTypeName>Id的属性将被配置为主键。类型为shortintlongGuid的键具有由数据存储器控制的值。数值被创建为标识列(SQL Server)。
关系 当两个实体类之间有导航属性时,就创建了表之间的关系。
外键 名为<OtherClassName>Id的属性是类型为<OtherClassName>的导航属性的外键。

前面的导航属性示例都利用 EF 核心约定来构建表之间的关系。

将属性映射到列

按照约定,公共读写属性映射到同名的列。数据类型与属性的 CLR 数据类型的数据存储区等效项相匹配。不可为空的属性在数据存储中设置为 not null,可为空的属性设置为允许 null。EF 核心支持在 C# 8 中引入的可空引用类型。

对于支持字段,EF Core 希望使用以下约定之一来命名支持字段(按优先顺序排列):

  • _<camel-cased property name>

  • _<property name>

  • m_<camel-cased property name>

  • m_<property name>

如果Car类的Color属性被更新为使用后备字段,则(按照惯例)需要将其命名为_color_Colorm_colorm_Color之一,如下所示:

private string _color = "Gold";
public string Color
{
  get => _color;
  set => _color = value;
}

实体框架数据注释

数据注释是 C# 属性,用于进一步塑造您的实体。表 22-8 列出了一些最常用的数据注释,用于定义实体类和属性如何映射到数据库表和字段。数据注释会覆盖任何冲突的约定。正如您将在本章和本书的其余部分中看到的,您可以使用更多的注释来细化模型中的实体。

表 22-8。

实体框架核心支持的一些数据注释(* EF Core 5 中的新属性)

|

数据注释

|

生命的意义

Table 定义实体的模式和表名。
Keyless* 指示实体没有键(例如,表示数据库视图)。
Column 定义实体属性的列名。
BackingField* 指定属性的 C# 支持字段。
Key 定义实体的主键。关键字段也是隐式的[Required]
Index* 放置在类上以指定单列或多列索引。允许指定索引是唯一的。
Owned 声明该类将由另一个实体类拥有。
Required 将属性声明为在数据库中不可为空。
ForeignKey 声明一个用作导航属性的外键的属性。
InverseProperty 在关系的另一端声明导航属性。
StringLength 指定字符串属性的最大长度。
TimeStamp 在 SQL Server 中将类型声明为rowversion,并向涉及实体的数据库操作添加并发检查。
ConcurrencyCheck 执行更新和删除时用于并发检查的标志字段。
DatabaseGenerated 指定字段是否由数据库生成。取ComputedIdentityNoneDatabaseGeneratedOption值。
DataType 提供比内部数据类型更具体的字段定义。
NotMapped 排除与数据库字段和表相关的属性或类。

下面的代码显示了带有注释的BaseEntity类,该注释将Id字段声明为主键。属性Id上的第二个数据注释表明它是 SQL Server 中的一个标识列。TimeStamp属性将是一个 SQL Server timestamp / rowversion属性(用于并发检查,将在本章后面介绍)。

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
public abstract class BaseEntity
{
  [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
  public int Id { get; set; }
  [Timestamp]
  public byte[] TimeStamp { get; set; }
}

下面是数据库中的Car类和塑造它的数据注释:

using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

[Table("Inventory", Schema="dbo")]
[Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]
public class Car : BaseEntity
{
  [Required, StringLength(50)]
  public string Color { get; set; }
  [Required, StringLength(50)]
  public string PetName { get; set; }
  public int MakeId { get; set; }
  [ForeignKey(nameof(MakeId))]
  public Make MakeNavigation { get; set; }
  [InverseProperty(nameof(Driver.Cars))]
  public IEnumerable<Driver> Drivers { get; set; }
}

Table属性将Car类映射到dbo模式中的Inventory表(Column属性用于更改列名或数据类型)。属性在外键MakeId上创建一个索引。两个文本字段被设置为Required和最多 50 个字符的StringLength。下一节将解释InversePropertyForeignKey属性。

EF 核心惯例的变化如下:

  • 将表格从Cars重命名为Inventory

  • TimeStamp列从varbinary(max)更改为 SQL Server 时间戳数据类型

  • ColorPetName列的数据类型和可空性从nvarchar(max) /null 设置为nvarchar(50) /not null

  • 重命名MakeId上的索引

使用的其余注释与 EF 核心约定定义的配置相匹配。

如果您要创建迁移并尝试应用它,迁移将会失败。SQL Server 不允许将现有列从另一种数据类型更改为 timestamp 数据类型。必须删除并重新创建该列。不幸的是,迁移基础设施不能丢弃和重新创建。它试图改变列。

解决这个问题最简单的方法是注释掉基本实体上的TimeStamp属性,创建并应用一个迁移,然后取消对TimeStamp的注释,创建并应用另一个迁移。

注释掉TimeStamp属性和数据注释,并执行以下命令:

dotnet ef migrations add RemoveTimeStamp -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update RemoveTimeStamp  -c AutoLot.Samples.ApplicationDbContext

取消对TimeStamp属性和数据注释的注释,并运行这些命令将属性作为timestamp列添加到每个表中:

dotnet ef migrations add ReplaceTimeStamp -o Migrations -c AutoLot.Samples.ApplicationDbContext
dotnet ef database update ReplaceTimeStamp  -c AutoLot.Samples.ApplicationDbContext

现在,您的数据库与您的模型相匹配。

注释和导航属性

ForeignKey注释让 EF Core 知道哪个属性是导航属性的支持字段。按照惯例,<TypeName>Id将被自动设置为外键属性,但在前面的示例中,它是显式设置的。这支持不同的命名风格,以及同一个表有多个外键。它也(在我看来)增加了代码的可读性。

InverseProperty通过指示导航回该实体的其他实体的导航属性,告知 EF Core 这些表是如何关联的。当一个实体不止一次地与另一个实体相关时,需要使用InverseProperty,这也(再次,以我的诚实观点)使代码更可读。

流畅的 API

Fluent API 通过 C# 代码配置应用实体。这些方法由在DbContext OnModelCreating()方法中可用的ModelBuilder实例公开。Fluent API 是最强大的配置方法,可以覆盖任何冲突的约定或数据注释。一些配置选项仅在使用 Fluent API 时可用,例如为导航属性设置默认值和级联行为。

类别和属性映射

下面的代码显示了前面的Car示例,其中 Fluent API 相当于所使用的数据注释(省略了导航属性,这将在接下来讨论)。

modelBuilder.Entity<Car>(entity =>
{
  entity.ToTable("Inventory","dbo");
  entity.HasKey(e=>e.Id);
  entity.HasIndex(e => e.MakeId, "IX_Inventory_MakeId");
  entity.Property(e => e.Color)
    .IsRequired()
    .HasMaxLength(50);
  entity.Property(e => e.PetName)
    .IsRequired()
    .HasMaxLength(50);
  entity.Property(e => e.TimeStamp)
    .IsRowVersion()
    .IsConcurrencyToken();
});

如果您现在创建并运行迁移,您会发现没有任何变化,因为 Fluent API 中的命令与约定和数据注释定义的当前配置相匹配。

默认值

Fluent API 提供了为列设置默认值的方法。默认值可以是值类型或 SQL 字符串。例如,要将新Car的默认Color设置为Black,请使用以下命令:

modelBuilder.Entity<Car>(entity =>
{
...
  entity.Property(e => e.Color)
  .HasColumnName("CarColor")
  .IsRequired()
  .HasMaxLength(50)
  .HasDefaultValue("Black");
});

要将值设置为数据库函数(如getdate()),请使用HasDefaultValueSql()方法。假设一个名为DateBuiltDateTime属性已经被添加到Car类中,默认值应该是使用 SQL Server getdate()方法的当前日期。列的配置如下:

modelBuilder.Entity<Car>(entity =>
{
...
  entity.Property(e => e.DateBuilt)
  .HasDefaultValueSql("getdate()");
});

就像使用 SQL 插入记录一样,如果在 EF Core 插入记录时映射到具有默认值的列的属性具有值,则使用该属性的值而不是默认值。如果属性值为 null,则使用列的默认值。

当属性的数据类型有默认值时,就会出现问题。回想一下,数字默认为零,布尔默认为 false。如果您将数字属性的值设置为零或将布尔属性的值设置为 false,然后插入该实体,EF Core 会将该属性视为没有设置值的*。如果该属性映射到具有默认值的列,则使用列定义中的默认值。*

例如,向Car类添加一个名为IsDrivablebool属性。将属性的列映射的默认值设置为true

//Car.cs
public class Car : BaseEntity
{
...
  public bool IsDrivable { get; set; }
}

//ApplicationDbContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.Entity<Car>(entity =>
  {
...
  entity.Property(e => e.IsDrivable).HasDefaultValue(true);
});

当用IsDrivable = false保存新的 a 记录时,该值将被忽略(因为它是布尔值的默认值),将使用数据库默认值。这意味着IsDrivable的值将始终为真!对此的一个解决方案是使您的公共属性(以及列)可为空,但是这可能不符合业务需求。

另一个解决方案是由 EF Core 及其对后台字段的支持提供的。回想一下前面的内容,如果支持字段存在(并且通过约定、数据注释或 Fluent API 被标识为属性的 backfield),那么 EF Core 将使用支持字段进行读写操作,而不是公共属性。

如果你更新IsDrivable使用一个可空的后备字段(但是保持属性不可空),ER Core 将从后备字段而不是属性中读写。可空布尔值的默认值是 null,而不是 false。这一更改现在使属性按预期工作。

public class Car
{
...
private bool? _isDrivable;
public bool IsDrivable
{
  get => _isDrivable ?? true;
  set => _isDrivable = value;
}

Fluent API 用于通知 EF 核心支持字段。

modelBuilder.Entity<Car>(entity =>
{
  entity.Property(p => p.IsDrivable)
    .HasField("_isDrivable")
    .HasDefaultValue(true);
});

Note

在这个例子中,HasField()方法不是必需的,因为支持字段的名称遵循命名约定。我把它包括进来是为了展示如何使用 Fluent API 来设置它。

EF Core 将该字段转换为以下 SQL 定义:

CREATE TABLE [dbo].Inventory)) FOR [IsDrivable]
GO

计算列

还可以将列设置为基于数据存储的功能进行计算。对于 SQL Server,有两种选择:根据同一记录中其他字段的值计算值,或者使用标量函数。例如,要在Inventory表上创建一个计算列,该列组合了PetNameColor值以创建一个DisplayName,请使用HasComputedColumnSql()函数。

modelBuilder.Entity<Car>(entity =>
{
  entity.Property(p => p.FullName)
    .HasComputedColumnSql("[PetName] + ' (' + [Color] + ')'");
});

EF Core 5 中的新特性是,计算出的值可以持久化,因此该值只在创建或更新行时计算。虽然 SQL Server 支持这一点,但并非所有数据存储都支持,因此请查阅数据库提供商的文档。

modelBuilder.Entity<Car>(entity =>
{
  entity.Property(p => p.FullName)
    .HasComputedColumnSql("[PetName] + ' (' + [Color] + ')'", stored:true);

});

一对多关系

要使用 Fluent API 来定义一对多关系,选择要更新的实体中的一个。导航链的两端都在一个代码块中设置。

modelBuilder.Entity<Car>(entity =>
{
...
  entity.HasOne(d => d.MakeNavigation)
    .WithMany(p => p.Cars)
    .HasForeignKey(d => d.MakeId)
    .OnDelete(DeleteBehavior.ClientSetNull)
    .HasConstraintName("FK_Inventory_Makes_MakeId");
});

如果选择主体实体作为导航属性配置的基础,则代码如下所示:

modelBuilder.Entity<Make>(entity =>
{
...
  entity.HasMany(e=>e.Cars)
    .WithOne(c=>c.MakeNavigation)
    .HasForeignKey(c=>c.MakeId)
    .OnDelete(DeleteBehavior.ClientSetNull)
    .HasConstraintName("FK_Inventory_Makes_MakeId");
 });

一对一的关系

一对一关系的配置方式相同,只是使用了WithOne() Fluent API 方法而不是WithMany()。向依赖实体添加唯一索引。下面是使用依赖实体(Radio)的CarRadio实体之间的关系代码:

modelBuilder.Entity<Radio>(entity =>
{
  entity.HasIndex(e => e.CarId, "IX_Radios_CarId")
    .IsUnique();

  entity.HasOne(d => d.CarNavigation)
    .WithOne(p => p.RadioNavigation)
    .HasForeignKey<Radio>(d => d.CarId);
});

如果关系是在主体实体上定义的,则唯一索引仍会添加到依赖实体中。下面是使用关系的主体实体的CarRadio实体之间的关系代码:

modelBuilder.Entity<Radio>(entity =>
{
  entity.HasIndex(e => e.CarId, "IX_Radios_CarId")
    .IsUnique();
});

modelBuilder.Entity<Car>(entity =>
{
  entity.HasOne(d => d.RadioNavigation)
    .WithOne(p => p.CarNavigation)
    .HasForeignKey<Radio>(d => d.CarId);
});

多对多关系

使用 Fluent API 可以更好地定制多对多关系。外键字段名称、索引名称和级联行为都可以在定义关系的语句中设置。下面是前面使用 Fluent API 复制的多对多关系示例(更改了键和列名以使它们更具可读性):

modelBuilder.Entity<Car>()
  .HasMany(p => p.Drivers)
  .WithMany(p => p.Cars)
  .UsingEntity<Dictionary<string, object>>(
    "CarDriver",
    j => j
      .HasOne<Driver>()
      .WithMany()
      .HasForeignKey("DriverId")
      .HasConstraintName("FK_CarDriver_Drivers_DriverId")
      .OnDelete(DeleteBehavior.Cascade),
    j => j
      .HasOne<Car>()
      .WithMany()
      .HasForeignKey("CarId")
      .HasConstraintName("FK_CarDriver_Cars_CarId")
      .OnDelete(DeleteBehavior.ClientCascade));

约定、注释和流畅的 API,天哪!

在本章的这一点上,您可能想知道使用三个选项中的哪一个来塑造您的实体以及它们彼此之间和与数据存储的关系。答案是三者皆有。这些约定总是有效的(除非您用数据注释或 Fluent API 覆盖它们)。数据注释几乎可以做 Fluent API 方法可以做的所有事情,并将信息保存在实体类本身中,这可以增加代码的可读性和支持。Fluent API 是所有三个 API 中最强大的,但是代码隐藏在DbContext类中。无论您使用数据注释还是 Fluent API,都要知道数据注释否决了内置约定,而 Fluent API 的方法否决了一切。

查询执行

数据检索查询是用针对DbSet<T>属性编写的 LINQ 查询创建的。数据库提供商的 LINQ 翻译引擎将 LINQ 查询转换为特定于数据库的语言(例如,T-SQL ),并在服务器端执行。多记录(或潜在的多记录)LINQ 查询直到该查询被迭代(例如,使用foreach)或被绑定到用于显示的控件(像数据网格)时才被执行。这种延迟执行允许在代码中构建查询,而不会因为与数据库的对话而出现性能问题。

例如,要从数据库中获取所有黄色的Car记录,请执行以下查询:

var cars = Context.Cars.Where(x=>x.Color == "Yellow");

对于延迟执行,在结果被迭代之前,不会真正查询该数据库。要立即执行查询,请使用ToList()

var cars = Context.Cars.Where(x=>x.Color == "Yellow").ToList();

因为查询在被触发之前不会被执行,所以它们可以在多行代码中构建。下面的代码示例与前面的示例执行相同:

var query = Context.Cars.AsQueryable();
query = query.Where(x=>x.Color == "Yellow");
var cars = query.ToList();

单记录查询(如使用First() / FirstOrDefault()时)在调用动作(如FirstOrDefault())时立即执行,create、update 和 delete 语句在执行DbContext.SaveChanges()方法时立即执行。

混合客户端-服务器评估

EF Core 的早期版本引入了混合服务器端和客户端执行的能力。这意味着 C# 函数可以用在 LINQ 语句的中间,从本质上否定我在上一段中描述的内容。直到 C# 函数的部分将在服务器端执行,但是所有的结果(在查询时)将被带回客户端,然后查询的其余部分将作为对象的 LINQ 执行。这最终导致了比它所解决的更多的问题,随着 EF Core 3.1 的发布,这个功能被改变了。现在,只有 LINQ 语句的最后一个节点可以在客户端执行。

跟踪与非跟踪查询

当数据从数据库读入一个DbSet<T>实例时,实体(默认情况下)被变更跟踪器跟踪。这通常是您在应用中想要的。一旦实例被更改跟踪器跟踪,对同一项(基于主键)的数据库的任何进一步调用将导致该项的更新,而不是重复。

然而,有时您可能需要从数据库中获取一些数据,但是您不希望它被更改跟踪器跟踪。原因可能是性能(跟踪大量记录的原始值和当前值会增加内存压力),也可能是您知道那些记录永远不会被需要数据的应用部分更改。

要将数据加载到一个DbSet<T>实例中而不将数据添加到ChangeTracker中,请将AsNoTracking()添加到 LINQ 语句中。这向 EF 内核发出信号以检索数据,而不将其添加到ChangeTracker中。例如,要加载一条Car记录而不将其添加到ChangeTracker中,执行以下命令:

public virtual Car? FindAsNoTracking(int id)
  => Table.AsNoTracking().FirstOrDefault(x => x.Id == id);

这样做的好处是不会增加潜在的内存压力,但也有潜在的缺点:检索同一个Car的额外调用会创建记录的额外副本。以使用更多内存和稍慢的执行时间为代价,可以修改查询以确保只有一个未映射的Car实例。

public virtual Car? FindAsNoTracking(int id)
  => Table.AsNoTrackingWithIdentityResolution().FirstOrDefault(x => x.Id == id);

英孚的显著核心特征

EF 6 的许多特性在 EF Core 中得到了复制,并且在每个版本中都增加了更多的特性。EF Core 中的许多功能在功能和性能上都有了巨大的改进。除了复制 EF 6 的功能之外,EF Core 还有许多新功能,是以前版本中没有的。以下是 EF Core 中一些比较值得注意的特性(排名不分先后)。

Note

本节中的代码样本直接来自您将在下一章构建的完整的AutoLot数据访问库。

处理数据库生成的值

除了变更跟踪和从 LINQ 生成 SQL 查询之外,与原始 ADO.NET 相比,使用 EF Core 的一个显著优势是无缝处理数据库生成的值。添加或更新实体后,EF Core 会查询任何数据库生成的数据,并自动使用正确的值更新实体。在原始的 ADO.NET,你需要自己去做。

例如,Inventory表有一个在 SQL Server 中定义为标识列的整数主键。当添加记录时,标识列由 SQL Server 使用唯一的编号(来自序列)填充,并且在正常更新期间不允许更新(不包括启用identity insert的特殊情况)。另外,Inventory表有一个用于并发检查的Timestamp列。接下来将讨论并发检查,但是现在只需要知道Timestamp列是由 SQL Server 维护的,并在任何添加或编辑操作时更新。

例如,向Inventory表中添加一个新的Car。下面的代码创建一个新的Car实例,将其添加到派生的DbContext上的DbSet<Car>实例中,并调用SaveChanges()来保存数据:

var car = new Car
{
  Color = "Yellow",
  MakeId = 1,
  PetName = "Herbie"
};
Context.Cars.Add(car);
Context.SaveChanges();

当执行SaveChanges时,新记录被插入到表中,然后IdTimestamp值从表中返回到 EF Core,实体的属性相应地被更新。

INSERT INTO [Dbo].[Inventory] ([Color], [MakeId], [PetName])
VALUES (N'Yellow', 1, N'Herbie');
SELECT [Id], [TimeStamp]
FROM [Dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

Note

EF Core 实际上执行参数化查询,但是为了可读性,我简化了所有的例子。

这也适用于向数据库中添加多个项目的情况。EF 核心知道如何将价值与正确的实体联系起来。当更新记录时,主键值是已知的,所以在我们的Car示例中,只查询并返回更新后的Timestamp值。

并发检查

当两个独立的进程(用户或系统)试图几乎同时更新同一条记录时,就会出现并发问题。例如,用户 1 和用户 2 都获得了客户 a 的数据。用户 1 更新了地址并保存了更改。用户 2 更新信用评级并试图保存相同的记录。如果用户 2 的保存起作用,来自用户 1 的更改将被恢复,因为地址是在用户 2 检索到记录后更改的。另一个选项是对用户 2 的保存失败,在这种情况下,用户 1 的更改会被持久化,但用户 2 的更改不会。

如何处理这种情况取决于应用的需求。解决方案从什么都不做(第二次更新覆盖第一次更新)到使用开放式并发(第二次更新失败)到更复杂的解决方案,如检查单个字段。除了选择什么都不做(普遍认为这是一个糟糕的编程想法),开发人员需要知道并发问题何时出现,以便可以适当地处理它们。

幸运的是,许多现代数据库都有工具来帮助开发团队处理并发问题。SQL Server 有一个名为timestamp的内置数据类型,是rowversion的同义词。如果一个列被定义为数据类型为timestamp,当一条记录被添加到数据库时,该列的值由 SQL Server 创建,当一条记录被更新时,该列的值也被更新。该值实际上保证是唯一的,并由 SQL Server 控制。

EF Core 可以通过在实体上实现一个Timestamp属性(在 C# 中表示为byte[])来利用 SQL Server 时间戳数据类型。当更新或删除记录时,用Timestamp属性或 Fluent API 名称定义的实体属性被添加到where子句中。生成的 SQL 并不仅仅使用主键值,而是将时间戳属性的值添加到where子句中。这将结果限制为主键和时间戳值匹配的那些记录。如果另一个用户(或系统)更新了记录,时间戳值将不匹配,并且updatedelete语句不会更新记录。下面是一个使用Timestamp列的更新查询示例:

UPDATE [Dbo].[Inventory] SET [Color] = N'Yellow'
WHERE [Id] = 1 AND [TimeStamp] = 0x000000000000081F;

当数据存储报告受影响的记录数量不同于ChangeTracker预期要更改的记录数量时,EF Core 抛出一个DbUpdateConcurrencyException并回滚整个事务。DbUpdateConcurrencyException包含所有没有保存的记录的信息,包括原始值(从数据库加载实体时)和当前值(用户/系统更新它们时)。还有一个获取当前数据库值的方法(这需要再次调用服务器)。有了这些丰富的信息,开发人员就可以按照应用的要求处理并发错误。下面的代码展示了这一点:

try
{
  //Get a car record (doesn’t matter which one)
  var car = Context.Cars.First();
  //Update the database outside of the context
  Context.Database.ExecuteSqlInterpolated($"Update dbo.Inventory set Color="Pink" where Id = {car.Id}");
  //update the car record in the change tracker and then try and save changes
  car.Color = "Yellow";
  Context.SaveChanges();
}
catch (DbUpdateConcurrencyException ex)
{
  //Get the entity that failed to update
  var entry = ex.Entries[0];
  //Get the original values (when the entity was loaded)
  PropertyValues originalProps = entry.OriginalValues;
  //Get the current values (updated by this code path)
  PropertyValues currentProps = entry.CurrentValues;
  //get the current values from the data store –
  //Note: This needs another database call
  //PropertyValues databaseProps = entry.GetDatabaseValues();
}

连接弹性

暂时性错误很难调试,更难复制。幸运的是,许多数据库提供商有一个内置的重试机制,可以处理数据库系统中的故障(tempdb问题、用户限制等)。)可以被 EF Core 利用。对于 SQL Server,SqlServerRetryingExecutionStrategy捕获暂时的错误(由 SQL Server 团队定义),如果在派生的DbContextDbContextOptions上启用,EF Core 会自动重试操作,直到达到最大重试限制。

对于 SQL Server,有一个快捷方法可以用来启用所有默认的SqlServerRetryingExecutionStrategy。与SqlServerOptions一起使用的方法是EnableRetryOnFailure(),此处演示:

public ApplicationDbContext CreateDbContext(string[] args)
{
  var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
  var connectionString = @"server=.,5433;Database=AutoLot50;User Id=sa;Password=P@ssw0rd;";
  optionsBuilder.UseSqlServer(connectionString, options => options.EnableRetryOnFailure());
  return new ApplicationDbContext(optionsBuilder.Options);
}

最大重试次数和重试之间的时间限制可以根据应用的要求进行配置。如果在操作没有完成的情况下达到重试限制,EF Core 将通过抛出RetryLimitExceededException来通知应用连接问题。当由开发人员处理时,该异常可以将相关信息传递给用户,从而提供更好的体验。

try
{
  Context.SaveChanges();
}
catch (RetryLimitExceededException ex)
{
  //A retry limit error occurred
  //Should handle intelligently
  Console.WriteLine($"Retry limit exceeded! {ex.Message}");
}

对于不提供内置执行策略的数据库提供者,也可以创建定制的执行策略。更多信息请参考 EF 核心文档: https://docs.microsoft.com/en-us/ef/core/miscellaneous/connection-resiliency

相关资料

实体导航属性用于加载实体的相关数据。相关数据可以被急切地加载(一个 LINQ 语句,一个 SQL 查询),急切地使用拆分查询(一个 LINQ 语句,多个 SQL 查询),显式地加载(多个 LINQ 调用,多个 SQL 查询),或者懒惰地加载(一个 LINQ 语句,多个按需 SQL 查询)。

除了使用导航属性加载相关数据的能力之外,EF Core 还将在实体被加载到变更跟踪器时自动修复实体。例如,假设所有的Make记录都被加载到DbSet<Make>中。接下来,所有的Car记录都被加载到DbSet<Car>中。尽管这些记录是分别加载的,但是它们可以通过导航属性相互访问。

急切装载

急切加载是在一次数据库调用中从多个表中加载相关记录的术语。这类似于在 T-SQL 中创建一个用连接链接两个或多个表的查询。当实体具有导航属性并且这些属性在 LINQ 查询中使用时,翻译引擎使用联接从相关表中获取数据并加载相应的实体。这通常比执行一个查询从一个表中获取数据,然后对每个相关的表运行额外的查询要有效得多。对于那些使用一个查询效率较低的时候,EF Core 5 引入了查询拆分,这将在下一篇文章中介绍。

Include()ThenInclude()(用于后续导航属性)方法用于遍历 LINQ 查询中的导航属性。如果需要该关系,LINQ 翻译引擎将创建一个内部连接。如果关系是可选的,翻译引擎将创建左连接。

例如,要加载所有的Car记录及其相关的Make信息,请执行以下 LINQ 查询:

var queryable = Context.Cars.IgnoreQueryFilters().Include(c => c.MakeNavigation).ToList();

前面的 LINQ 对数据库执行以下查询:

SELECT [i].[Id], [i].[Color], [i].[MakeId], [i].[PetName], [i].[TimeStamp],
  [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [Dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]

可以在同一个查询中使用多个Include()语句将多个实体连接到原始实体。要向下操作导航属性树,在Include()后使用ThenInclude()。例如,要获得所有的Cars记录及其相关的MakeOrder信息以及与Order相关的Customer信息,使用以下语句:

var cars = Context.Cars.Where(c => c.Orders.Any())
  .Include(c => c.MakeNavigation)
  .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation).ToList();

过滤包括

EF Core 5 中的新功能是可以对包含的数据进行过滤和排序。收藏导航允许的操作有Where()OrderBy()OrderByDescending()ThenBy()ThenByDescending()Skip()Take()。例如,如果您想要获取所有的Make记录,但是只获取颜色为黄色的相关Car记录,您可以在 lambda 表达式中过滤 navigation 属性,如下所示:

var query = Context.Makes
    .Include(x => x.Cars.Where(x=>x.Color == "Yellow")).ToList();

执行的查询如下:

SELECT [m].[Id], [m].[Name], [m].[TimeStamp], [t].[Id], [t].[Color],
              [t].[MakeId], [t].[PetName], [t].[TimeStamp]
FROM [dbo].[Makes] AS [m]
LEFT JOIN (
        SELECT [i].[Id], [i].[Color], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
        FROM [Dbo].[Inventory] AS [i]
        WHERE [i].[Color] = N'Yellow') AS [t] ON [m].[Id] = [t].[MakeId]
ORDER BY [m].[Id], [t].[Id]

使用拆分查询进行快速加载

当 LINQ 查询包含大量包含时,可能会对性能产生负面影响。为了解决这种情况,EF Core 5 引入了拆分查询。EF 核心不是执行单个查询,而是将 LINQ 查询拆分成多个 SQL 查询,然后连接所有相关数据。例如,通过将AsSplitQuery()添加到 LINQ 查询中,可以将前面的查询预期为多个 SQL 查询,如下所示:

var query = Context.Makes.AsSplitQuery()
  .Include(x => x.Cars.Where(x=>x.Color == "Yellow")).ToList();

执行的查询如下所示:

SELECT [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Makes] AS [m]
ORDER BY [m].[Id]

SELECT [t].[Id], [t].[Color], [t].[MakeId], [t].[PetName], [t].[TimeStamp], [m].[Id]
FROM [dbo].[Makes] AS [m]
INNER JOIN (
    SELECT [i].[Id], [i].[Color], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
    FROM [Dbo].[Inventory] AS [i]
    WHERE [i].[Color] = N'Yellow'
) AS [t] ON [m].[Id] = [t].[MakeId]
ORDER BY [m].[Id]

使用拆分查询有一个缺点:如果在执行查询之间数据发生了变化,那么返回的数据将会不一致。

显式加载

显式加载是在已经加载了核心对象之后沿着导航属性加载数据。这个过程包括执行一个额外的数据库调用来获取相关数据。如果您的应用需要有选择地获取相关记录,而不是基于某些用户操作提取所有相关记录,这可能会很有用。

这个过程从一个已经加载的实体开始,并在派生的DbContext上使用Entry()方法。当查询参考导航属性时(例如,获取汽车的Make信息),使用Reference()方法。当查询集合导航属性时,使用Collection()方法。查询被推迟,直到执行Load()ToList()或聚合函数(例如Count()Max())。

以下示例显示了如何获取相关的Make数据以及Car记录的任何Orders:

//Get the Car record
var car = Context.Cars.First(x => x.Id == 1);
//Get the Make information
Context.Entry(car).Reference(c => c.MakeNavigation).Load();
//Get any orders the Car is related to
Context.Entry(car).Collection(c => c.Orders).Query().IgnoreQueryFilters().Load();

惰性装载

当导航属性用于访问尚未加载到内存中的相关记录时,延迟加载是按需加载记录。延迟加载是 EF 6 的一个特性,在 2.1 版中被添加到 EF Core 中。虽然打开它听起来是个好主意,但是启用延迟加载会导致潜在的不必要的数据库往返,从而导致应用的性能问题。因此,在 EF 内核中,延迟加载是默认关闭的(在 EF 6 中,它是默认启用的)。

延迟加载在智能客户端(WPF、WinForms)应用中很有用,但建议不要在 web 或服务应用中使用。因此,我不打算在本文中讨论延迟加载。如果你想了解更多关于延迟加载的知识,以及如何在 EF Core 中使用它,请参考这里的文档: https://docs.microsoft.com/en-us/ef/core/querying/related-data/lazy

全局查询过滤器

全局查询过滤器允许将一个where子句添加到特定实体的所有 LINQ 查询中。例如,一种常见的数据库设计模式是使用软删除而不是硬删除。表中会添加一个字段来指示记录的删除状态。如果记录被“删除”,则该值被设置为 true(或 1),但不会从数据库中删除。这叫做软删除。为了从正常操作中过滤出被软删除的记录,每个where子句都必须检查该字段的值。如果没有问题的话,记住在每个查询中包含这个过滤器是很费时间的。

EF Core 支持向实体添加一个全局查询过滤器,然后应用于涉及该实体的每个查询。对于前面描述的软删除示例,您在实体类上设置了一个过滤器来排除被软删除的记录。EF 核心创建的任何涉及具有全局查询过滤器的实体的查询都将应用其过滤器。您不再需要记住在每个查询中包含where子句。

与本书的Car主题保持一致,假设所有不可驱动的Car记录都应该从正常查询中过滤掉。使用 Fluent API,您可以像这样添加一个全局查询过滤器:

modelBuilder.Entity<Car>(entity =>
{
  entity.HasQueryFilter(c => c.IsDrivable == true);
  entity.Property(p => p.IsDrivable).HasField("_isDrivable").HasDefaultValue(true);
});

有了全局查询过滤器,涉及Car实体的查询将自动过滤掉不可驾驶的汽车。例如,执行以下 LINQ 查询:

var cars = Context.Cars.ToList();

执行以下 SQL:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [Dbo].[Inventory] AS [i]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

如果需要查看过滤后的记录,将IgnoreQueryFilters()添加到 LINQ 查询中,这将禁用 LINQ 查询中每个实体的全局查询过滤器。执行以下 LINQ 查询:

var cars = Context.Cars.IgnoreQueryFilters().ToList();

执行以下 SQL:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [Dbo].[Inventory] AS [i]

值得注意的是,调用IgnoreQueryFilters()会删除 LINQ 查询中每个实体的查询过滤器,包括任何涉及Include()ThenInclude()语句的实体。

导航属性上的全局查询过滤器

还可以在导航属性上设置全局查询过滤器。假设您想要过滤掉任何包含不可驾驶的Car的订单。查询过滤器在Order实体的CarNavigation导航属性上创建,如下所示:

modelBuilder.Entity<Order>().HasQueryFilter(e => e.CarNavigation.IsDrivable);

执行标准 LINQ 查询时,任何包含不可驾驶汽车的订单都将从结果中排除。下面是 LINQ 语句和生成的 SQL 语句:

//C# Code
var orders = Context.Orders.ToList();

/* Generated SQL query */
SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]
FROM [Dbo].[Orders] AS [o]
INNER JOIN (SELECT [i].[Id], [i].[IsDrivable]
                       FROM [Dbo].[Inventory] AS [i]
                       WHERE [i].[IsDrivable] = CAST(1 AS bit)) AS [t]
         ON [o].[CarId] = [t].[Id]
WHERE [t].[IsDrivable] = CAST(1 AS bit)

要删除查询过滤器,请使用IgnoreQueryFilters()。以下是更新后的 LINQ 语句和后续生成的 SQL:

//C# Code
var orders = Context.Orders.IgnoreQueryFilters().ToList();

/* Generated SQL query */
SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]
FROM [Dbo].[Orders] AS [o]

这里需要注意的是:EF Core 不检测循环的全局查询过滤器,所以在向导航属性添加查询过滤器时要小心。

使用全局查询过滤器进行显式加载

当显式加载相关数据时,全局查询过滤器也是有效的。例如,如果你想加载一个MakeCar记录,IsDrivable过滤器将阻止不可驾驶的汽车被加载到内存中。以下面的代码片段为例:

var make = Context.Makes.First(x => x.Id == makeId);
Context.Entry(make).Collection(c=>c.Cars).Load();

到目前为止,生成的 SQL 查询包括不可驾驶汽车的过滤器就不足为奇了。

SELECT [i].[Id], [i].[Color], [i].[IsDrivable],
              [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [Dbo].[Inventory] AS [i]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = 1

显式加载数据时忽略查询过滤器有一个小问题。由Collection()方法返回的类型是CollectionEntry<Make,Car>,并且没有显式实现IQueryable<T>接口。要调用IgnoreQueryFilters(),必须先调用Query(),它返回一个IQueryable<Car>

var make = Context.Makes.First(x => x.Id == makeId);
Context.Entry(make).Collection(c=>c.Cars).Query().IgnoreQueryFilters().Load();

当使用Reference()方法从引用导航属性中检索数据时,同样的过程也适用。

使用 LINQ 的原始 SQL 查询

有时,为复杂的查询获取正确的 LINQ 语句可能比直接编写 SQL 语句更难。幸运的是,EF Core 有一种机制允许在DbSet<T>上执行原始 SQL 语句。FromSqlRaw()FromSqlRawInterpolated()方法接受一个字符串,该字符串成为 LINQ 查询的基础。这个查询在服务器端执行。

如果原始 SQL 语句是非终止的(例如,既不是存储过程,也不是用户定义的函数,也不是使用公用表表达式或以分号结束的语句),则可以向查询中添加附加的 LINQ 语句。额外的 LINQ 语句,如Include()OrderBy()Where()子句,将与原始的原始 SQL 调用和任何全局查询过滤器相结合,整个查询在服务器端执行。

当使用其中一个FromSql变量时,必须使用数据存储模式和表名而不是实体名来编写查询。FromSqlRaw()将按原样发送字符串。FromSqlInterpolated()使用 C# 字符串插值,每个插值后的字符串在 SQL 参数中进行翻译。每当使用变量来增加参数化查询中固有的保护时,都应该使用插值版本。

假设在Car实体上设置了全局查询过滤器,下面的 LINQ 语句将获得第一条库存记录,其中Id为 1,包括相关的Make数据,并过滤掉不可驾驶的汽车:

var car = Context.Cars
  .FromSqlInterpolated($"Select * from dbo.Inventory where Id = {carId}")
  .Include(x => x.MakeNavigation)
  .First();

LINQ 到 SQL 转换引擎将原始 SQL 语句与 LINQ 语句的其余部分结合起来,并执行以下查询:

SELECT TOP(1) [c].[Id], [c].[Color], [c].[IsDrivable], [c].[MakeId],
                           [c].[PetName], [c].[TimeStamp],
                           [m].[Id], [m].[Name], [m].[TimeStamp]
FROM (Select * from dbo.Inventory where Id = 1) AS [c]
INNER JOIN [dbo].[Makes] AS [m] ON [c].[MakeId] = [m].[Id]
WHERE [c].[IsDrivable] = CAST(1 AS bit)

要知道,在 LINQ 中使用原始 SQL 时,有一些规则必须遵守。

  • SQL 查询必须返回实体类型的所有属性的数据。

  • 列名必须与它们被映射到的属性相匹配(这是对 EF 6 的一个改进,在 EF 6 中映射被忽略了)。

  • SQL 查询不能包含相关数据。

语句的批处理

EF Core 通过在一个或多个批处理中执行语句来保存对数据库的更改,从而显著提高了性能。这减少了应用和数据库之间的往返,提高了性能并潜在地降低了成本(例如,对于对事务收费的云数据库)。

EF 核心使用表值参数对 create、update 和 delete 语句进行批处理。EF 批处理的语句数量取决于数据库提供者。例如,对于 SQL Server,低于 4 条语句和高于 40 条语句时,批处理效率很低。不管批处理的数量是多少,所有语句仍然在一个事务中执行。批量大小也可以通过DbContextOptions配置,但是建议让 EF Core 计算大多数(如果不是全部)情况下的批量大小。

如果您要像这样在一次交易中插入四辆汽车:

var cars = new List<Car>
{
  new Car { Color = "Yellow", MakeId = 1, PetName = "Herbie" },
  new Car { Color = "White", MakeId = 2, PetName = "Mach 5" },
  new Car { Color = "Pink", MakeId = 3, PetName = "Avon" },
  new Car { Color = "Blue", MakeId = 4, PetName = "Blueberry" },
};
Context.Cars.AddRange(cars);
Context.SaveChanges();

EF Core 会在一次调用中批量处理这些语句。生成的查询如下所示:

exec sp_executesql N'SET NOCOUNT ON;
DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
MERGE [Dbo].[Inventory] USING (
VALUES (@p0, @p1, @p2, 0),
(@p3, @p4, @p5, 1),
(@p6, @p7, @p8, 2),
(@p9, @p10, @p11, 3)) AS i ([Color], [MakeId], [PetName], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Color], [MakeId], [PetName])
VALUES (i.[Color], i.[MakeId], i.[PetName])
OUTPUT INSERTED.[Id], i._Position
INTO @inserted0;

SELECT [t].[Id], [t].[IsDrivable], [t].[TimeStamp] FROM [Dbo].[Inventory] t
INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])
ORDER BY [i].[_Position];

',N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50),@p3 nvarchar(50),@p4 int,@p5 nvarchar(50),@p6 nvarchar(50),@p7 int,@p8 nvarchar(50),@p9 nvarchar(50),@p10 int,@p11 nvarchar(50)',@p0=N'Yellow',@p1=1,@p2=N'Herbie',@p3=N'White',@p4=2,@p5=N'Mach 5',@p6=N'Pink',@p7=3,@p8=N'Avon',@p9=N'Blue',@p10=4,@p11=N'Blueberry'

拥有的实体类型

使用 C# 类作为一个实体的属性来定义另一个实体的属性集合是在 2.0 版本中首次引入的,并在不断更新。当用[Owned]属性标记的类型(或用 Fluent API 配置的类型)被添加为实体的属性时,EF Core 会将来自[Owned]实体类的所有属性添加到拥有实体中。这增加了 C# 代码重用的可能性。

在幕后,EF Core 认为这是一对一的关系。拥有的类是依赖实体,拥有的类是主体实体。拥有的类,即使被认为是一个实体,如果没有拥有的实体就不能存在。所拥有类型的默认列名将被格式化为NavigationPropertyName_OwnedEntityPropertyName(例如PersonalNavigation_FirstName)。可以使用 Fluent API 更改默认名称。

以这个Person类为例(注意Owned属性):

[Owned]
public class Person
{
  [Required, StringLength(50)]
  public string FirstName { get; set; } = "New";
  [Required, StringLength(50)]
  public string LastName { get; set; } = "Customer";
}

这由Customer类使用:

[Table("Customers", Schema = "Dbo")]
public partial class Customer : BaseEntity
{
  public Person PersonalInformation { get; set; } = new Person();
  [JsonIgnore]
  [InverseProperty(nameof(CreditRisk.CustomerNavigation))]
  public IEnumerable<CreditRisk> CreditRisks { get; set; } = new List<CreditRisk>();
  [JsonIgnore]
  [InverseProperty(nameof(Order.CustomerNavigation))]
  public IEnumerable<Order> Orders { get; set; } = new List<Order>();
}

默认情况下,两个Person属性被映射到名为PersonalInformation_FirstNamePersonalInformation_LastName的列。为了改变这一点,将下面的 Fluent API 代码添加到OnConfiguring()方法中:

modelBuilder.Entity<Customer>(entity =>
{
  entity.OwnsOne(o => o.PersonalInformation,
      pd =>
      {
        pd.Property<string>(nameof(Person.FirstName))
             .HasColumnName(nameof(Person.FirstName))
             .HasColumnType("nvarchar(50)");
        pd.Property<string>(nameof(Person.LastName))
             .HasColumnName(nameof(Person.LastName))
             .HasColumnType("nvarchar(50)");
      });
});

生成的表是这样创建的(注意,FirstNameLastName列的可空性与Person拥有的实体上的数据注释不匹配):

CREATE TABLE [dbo].Customers NOT NULL,
  [FirstName] nvarchar NULL,
  [LastName] nvarchar NULL,
  [TimeStamp] [timestamp] NULL,
  [FullName]  AS (([LastName]+', ')+[FirstName]),
CONSTRAINT [PK_Customers] PRIMARY KEY CLUSTERED
(
  [Id] ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, OPTIMIZE_FOR_SEQUENTIAL_KEY = OFF) ON [PRIMARY]
) ON [PRIMARY]
GO

EF Core 5 解决了一个拥有实体的问题,这个问题可能不会出现在你面前,但可能是一个重大问题。注意,Person类的两个属性上都有Required数据注释,但是 SQL Server 列都被设置为NULL。这是由于当所拥有的实体用于可选关系时,迁移系统如何转换所拥有的实体的问题。解决方法是建立必要的关系。

要纠正这一点,有几个选择。第一个是启用 C# 可空性(在项目级别或在类中)。这使得PersonalInformation导航属性不可为空,EF Core 支持这一点,然后 EF Core 相应地配置所拥有的实体中的列。另一个选项是添加一个流畅的 API 语句,使导航属性成为必需的。

modelBuilder.Entity<Customer>(entity =>
{
  entity.OwnsOne(o => o.PersonalInformation,
      pd =>
      {
        pd.Property<string>(nameof(Person.FirstName))
             .HasColumnName(nameof(Person.FirstName))
             .HasColumnType("nvarchar(50)");
        pd.Property<string>(nameof(Person.LastName))
             .HasColumnName(nameof(Person.LastName))
             .HasColumnType("nvarchar(50)");
      });
  entity.Navigation(c => c.PersonalInformation).IsRequired(true);
});

对于拥有的实体,还有其他选项可以探索,包括集合、表拆分和嵌套。这些都超出了本书的范围。如需了解更多信息,请在此处查阅关于所有实体的 EF 核心文件: https://docs.microsoft.com/en-us/ef/core/modeling/owned-entities

数据库功能映射

SQL Server 函数可以映射到 C# 方法,并包含在 LINQ 语句中。C# 方法只是一个占位符,因为服务器函数被合并到为查询生成的 SQL 中。在 EF Core 中,对表值函数映射的支持已经添加到对标量函数映射的支持中。关于数据库函数映射的更多信息,请查阅文档: https://docs.microsoft.com/en-us/ef/core/querying/user-defined-function-mapping

EF 核心全球工具 CLI 命令

dotnet-ef global CLI tool EF 核心工具包含将现有数据库移植到代码中、创建/删除数据库迁移以及对数据库进行操作(更新、删除等)所需的命令。).在您可以使用dotnet-ef全局工具之前,必须使用以下命令安装它(如果您已经按照本章前面的内容进行了安装,那么您已经完成了):

dotnet tool install --global dotnet-ef --version 5.0.1

Note

因为 EF Core 5 不是一个长期支持的版本,要使用 EF Core 5 全球工具,您必须指定一个版本。

要测试安装,请打开命令提示符并输入以下命令:

dotnet ef

如果工具安装成功,您将获得 EF 核心独角兽(团队的吉祥物)和可用命令列表,如下所示(独角兽在屏幕上更好看):

               _/\__
         ---==/     \\
  ___ ___     |.      \|\
 |__||__| |   )     \\\
 |_||_|   \_/ |   //|\\
 |__ ||_|     /    \\\/\\

Entity Framework Core .NET Command-line Tools 5.0.1

Usage: dotnet ef [options] [command]

Options:
  --version        Show version information
  -h|--help        Show help information
  -v|--verbose     Show verbose output.
  --no-color       Don't colorize output.
  --prefix-output  Prefix output with level.

Commands:
  database    Commands to manage the database.
  dbcontext   Commands to manage DbContext types.
  migrations  Commands to manage migrations.

Use "dotnet ef [command] --help" for more information about a command.

表 22-9 描述了 EF 核心全局工具中的三个主要命令。每个主命令都有附加的子命令。就像所有的。NET 核心命令,每个命令都有丰富的帮助系统,可以通过随命令输入-h来访问。

表 22-9。

EF 核心工具命令

|

命令

|

生命的意义

Database 管理数据库的命令。子命令包括dropupdate
DbContext 管理DbContext类型的命令。子命令包括scaffoldlistinfo
Migrations 管理迁移的命令。子命令包括addlistremovescript

EF 核心命令在上执行。NET 核心项目文件(而不是解决方案文件)。目标项目需要引用 EF 核心工具 NuGet 包Microsoft.EntityFrameworkCore.Design。这些命令对位于运行命令的同一目录中的项目文件进行操作,或者对通过命令行选项引用的另一个目录中的项目文件进行操作。

对于需要派生的DbContext类(DatabaseMigrations)的实例的 EF Core CLI 命令,如果项目中只有一个实例,将使用那个实例。如果有多个,那么需要在命令行选项中指定DbContext。派生的DbContext类将使用实现IDesignTimeDbContextFactory<TContext>接口的类的实例进行实例化,如果可以找到的话。如果工具找不到,那么派生的DbContext将使用无参数构造函数进行实例化。如果两者都不存在,该命令将失败。注意,无参数构造函数选项要求存在OnConfiguring覆盖,这不是一个好的实践。最好的(也是唯一的)选择是始终为应用中的每个派生的DbContext创建一个IDesignTimeDbContextFactory<TContext>

EF 核心命令有常用选项,如表 22-10 所示。许多命令都有额外的选项或参数。

表 22-10。

EF 核心命令选项

|

选项(速记||手写)

|

生命的意义

--c &#124;&#124; --context <DBCONTEXT> 要使用的完全限定的派生类DbContext。如果项目中存在多个派生的DbContext,这是一个必需选项。
-p &#124;&#124; --project <PROJECT> 要使用的项目(放置文件的位置)。默认为当前工作目录。
-s &#124;&#124; --startup-project <PROJECT> 要使用的启动项目(包含派生的DbContext)。默认为当前工作目录。
-h &#124;&#124; --help 显示帮助和所有选项。
-v || -详细 显示详细输出。

要列出命令的所有参数和选项,请在命令窗口中输入dotnet ef <command> -h,如下所示:

dotnet ef migrations add -h

Note

需要注意的是,CLI 命令不是 C# 命令,因此转义斜杠和引号的规则不适用。

迁移命令

migrations命令用于添加、删除、列出和编写迁移脚本。当迁移应用于一个基础时,在__EFMigrationsHistory表中创建一个记录。表 22-11 描述了这些命令。以下部分详细解释了这些命令。

表 22-11。

EF 核心迁移命令

|

命令

|

生命的意义

Add 基于上一次迁移的更改创建新的迁移
Remove 检查项目中的最后一次迁移是否已应用于数据库,如果没有,则删除迁移文件(及其设计器),然后将快照类回滚到上一次迁移
List 列出派生DbContext的所有迁移及其状态(已应用或待定)
Script 为所有、一个或一系列迁移创建 SQL 脚本

添加命令

add命令基于当前对象模型创建一个新的数据库迁移。该过程检查派生的DbContext上具有DbSet<T>属性的每个实体(以及使用导航属性可以从这些实体到达的每个实体),并确定是否有任何需要应用到数据库的更改。如果有更改,将生成适当的代码来更新数据库。稍后您将了解到更多相关信息。

Add命令需要一个name参数,用于命名迁移的创建类和文件。除了通用选项之外,选项-o <PATH>–output-dir <PATH>指示迁移文件应该放在哪里。相对于当前路径,默认目录被命名为Migrations

添加的每个迁移都会创建两个属于同一类的文件。这两个文件都以时间戳和迁移名称作为名称的开头,用作add命令的参数。第一个文件命名为<YYYYMMDDHHMMSS>_<MigrationName>.cs,第二个命名为<YYYYMMDDHHMMSS>_<MigrationName>.Designer.cs。时间戳基于文件的创建时间,两个文件的时间戳将完全匹配。第一个文件表示在这个迁移中为数据库更改生成的代码,设计器文件表示基于到这个迁移为止的所有迁移创建和更新数据库的代码。

主文件包含两个方法,Up()Down()Up()方法包含用这次迁移的变更更新数据库的代码,而Down()方法包含回滚这次迁移的变更的代码。本章前面的初始迁移(One2Many迁移)的部分列表如下:

public partial class One2Many : Migration
{
  protected override void Up(MigrationBuilder migrationBuilder)
  {
    migrationBuilder.CreateTable(
      name: "Make",
      columns: table => new
        {
          Id = table.Column<int>(type: "int", nullable: false)
            .Annotation("SqlServer:Identity", "1, 1"),
          Name = table.Column<string>(type: "nvarchar(max)", nullable: true),
          TimeStamp = table.Column<byte[]>(type: "varbinary(max)", nullable: true)
        },
        constraints: table =>
        {
          table.PrimaryKey("PK_Make", x => x.Id);
        });
...
    migrationBuilder.CreateIndex(
      name: "IX_Cars_MakeId",
      table: "Cars",
      column: "MakeId");
  }

  protected override void Down(MigrationBuilder migrationBuilder)
  {
    migrationBuilder.DropTable(name: "Cars");
    migrationBuilder.DropTable(name: "Make");
  }
}

如您所见,Up()方法正在创建表、列、索引等。Down()方法正在删除创建的项目。迁移引擎将根据需要发出alteradddrop语句,以确保数据库与您的模型相匹配。

设计器文件包含两个属性,将这些部分与文件名和派生的DbContext联系起来。此处显示了设计类别的部分属性列表:

[DbContext(typeof(ApplicationDbContext))]
[Migration("20201230020509_One2Many")]
partial class One2Many
{
  protected override void BuildTargetModel(ModelBuilder modelBuilder)
  {
...
  }
}

第一次迁移在目标目录中创建一个附加文件,以派生的DbContext命名,格式为<DerivedDbContextName>ModelSnapshot.cs。该文件的格式与 designer partial 相同,包含所有迁移的代码。添加或删除迁移时,该文件会自动更新以匹配更改。

Note

不要手动删除迁移文件,这一点非常重要。这将导致<DerivedDbContext>ModelSnapshot.cs与您的迁移不同步,从根本上破坏它们。如果您要手动删除它们,请全部删除并重新开始。要删除一个迁移,使用remove命令,稍后将会介绍。

从迁移中排除表

如果一个实体在多个DbContexts之间共享,每个DbContext将在迁移文件中为该实体的任何变更创建代码。这将导致一个问题,因为如果数据库中已经存在更改,第二个迁移脚本将会失败。在 EF Core 5 之前,唯一的解决方案是手动编辑其中一个迁移文件来删除这些更改。

在 EF Core 5 中,DbContext可以将一个实体标记为排除在迁移之外,让另一个DbContext成为该实体的记录系统。以下代码显示了从迁移中排除的实体:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
  modelBuilder.Entity<LogEntry>().ToTable("Logs", t => t.ExcludeFromMigrations());
}

移除命令

remove命令用于从项目中删除迁移,并且总是在最后一次迁移时运行(基于迁移的时间戳)。移除迁移时,EF Core 将通过检查数据库中的__EFMigrationsHistory表来确保它没有被应用。如果已经应用了迁移,则该过程失败。如果迁移尚未应用或已回滚,迁移将被删除,模型快照文件将被更新。

remove命令不带任何参数(因为它总是在最后一次迁移时工作),并使用与add命令相同的选项。还有一个额外的选项,force选项(-f || --force)。这将回滚上一次迁移,然后一步将其删除。

列表命令

list命令用于显示派生DbContext的所有迁移。默认情况下,它会列出所有迁移并查询数据库以确定它们是否已被应用。如果尚未应用,它们将被列为待定。有一个选项传入特定的连接字符串,另一个选项根本不连接到数据库,而是只列出迁移。表 22-12 显示了这些选项。

表 22-12。

EF 核心迁移列表命令的附加选项

|

选项(速记||手写)

|

生命的意义

--connection <CONNECTION> 数据库的连接字符串。默认为在IDesignTimeDbContextFactory的实例或DbContextOnConfiguring方法中指定的。
--no-connect 指示命令跳过数据库检查。

脚本命令

script命令基于一个或多个迁移创建一个 SQL 脚本。该命令采用两个可选参数,分别表示迁移开始和迁移结束。如果两者都没有输入,所有迁移都将编写脚本。表 22-13 描述了这些参数。

表 22-13。

EF 核心迁移脚本命令的参数

|

争吵

|

生命的意义

<FROM> 开始迁移。默认为 0(零),开始迁移。
<TO> 目标迁移。默认为上次迁移。

如果没有命名迁移,则创建的脚本将是所有迁移的累积总和。如果提供了命名迁移,脚本将包含两次迁移之间的更改(包括两次迁移)。每个迁移都包装在一个事务中。如果执行脚本的数据库中不存在__EFMigrationsHistory表,将会创建该表。该表也将被更新,以匹配已执行的迁移。以下是一些例子:

//Script all of the migrations
dotnet ef migrations script
//script from the beginning to the Many2Many migrations
dotnet ef migrations script 0 Many2Many

还有一些附加选项可用,如表 22-14 所示。-o选项允许您为脚本指定一个文件(该目录相对于命令执行的位置),而-i创建一个等幂脚本。这意味着它包含检查以查看是否已经应用了迁移,如果已经应用,则跳过该迁移。–no-transaction选项禁用添加到脚本中的普通事务。

表 22-14。

EF 核心迁移脚本命令的附加选项

|

选项(速记||手写)

|

生命的意义

-o &#124;&#124; -output <FILE> 要将结果脚本写入的文件
-i &#124;&#124; --idempotent 生成一个脚本,在应用迁移之前检查是否已经应用了迁移
--no-transactions 不会将每个迁移都包含在一个事务中

数据库命令

有两个数据库命令,dropupdate。如果数据库存在,drop命令会删除它。update命令使用迁移来更新数据库。

Drop 命令

drop命令删除由DbContextOnConfiguring方法的上下文工厂中的连接字符串指定的数据库。使用force选项不要求确认,强制关闭所有连接。见表 22-15 。

表 22-15。

EF 核心数据库删除选项

|

选项(速记||手写)

|

生命的意义

-f &#124;&#124; --force 不要确认下落。强制关闭所有连接。
--dry-run 显示要删除的数据库,但不要删除它。

数据库更新命令

update命令有一个参数(迁移名称)和常用选项。该命令还有一个附加选项--connection <CONNECTION>。这允许使用未在设计时工厂或DbContext中配置的连接字符串。

如果在没有迁移名称的情况下执行命令,该命令会将数据库更新为最近的迁移,并在必要时创建数据库。如果迁移已命名,数据库将更新到该迁移。所有尚未应用的先前迁移也将被应用。应用迁移时,它们的名称存储在__EFMigrationsHistory表中。

如果指定迁移的时间戳早于其他应用的迁移,则所有以后的迁移都将回滚。如果 0(零)作为命名迁移被传入,所有迁移都被恢复,留下一个空数据库(除了__EFMigrationsHistory表)。

DbContext 命令

有四个DbContext命令。其中三个(listinfoscript)操作项目中的衍生DbContext类。scaffold命令从现有数据库创建一个派生的DbContext和实体。表 22-16 显示了这四个命令。

表 22-16。

DbContext 命令

|

命令

|

生命的意义

Info 获取关于DbContext类型的信息
List 列出可用的DbContext类型
Scaffold 为数据库搭建一个DbContext和实体类型
Script 基于对象模型从DbContext生成 SQL 脚本,绕过任何迁移

listinfo命令有常用的选项。list命令列出了目标项目中派生的DbContext类。info命令提供了关于指定的派生DbContext类的细节,包括连接字符串、提供者名称、数据库名称和数据源。script 命令创建一个 SQL 脚本,该脚本基于对象模型创建您的数据库,忽略可能存在的任何迁移。scaffold命令用于对现有数据库进行逆向工程,将在下一节中介绍。

DbContext Scaffold 命令

scaffold命令创建 C# 类(派生的DbContext和实体),包括数据注释(如果需要)和来自现有数据库的流畅 API 命令。有两个必需的参数,数据库连接字符串和完全限定的提供者(例如,Microsoft.EntityFrameworkCore.SqlServer)。表 22-17 描述了这些争论。

表 22-17。

DbContext 支架参数

|

争吵

|

生命的意义

Connection 数据库的连接字符串
Provider 要使用的 EF 核心数据库提供商(如Microsoft.EntityFrameworkCore.SqlServer)

可用的选项包括选择特定的模式和表、创建的上下文类名和名称空间、生成的实体类的输出目录和名称空间等等。标准选项也可用。扩展选项在表 22-18 中列出,讨论如下。

表 22-18。

DbContext 支架选项

|

选项(速记||手写)

|

生命的意义

-d &#124;&#124; --data-annotations 使用属性来配置模型(如果可能的话)。如果省略,则仅使用 Fluent API。
-c &#124;&#124; --context <NAME> 要创建的派生DbContext的名称。
--context-dir <PATH> 放置派生的DbContext的目录,相对于项目目录。默认为数据库名称。
-f &#124;&#124; --force 替换目标目录中的任何现有文件。
-o &#124;&#124; --output-dir <PATH> 将生成的实体类放入的目录。相对于项目目录。
--schema <SCHEMA_NAME>... 要为其生成实体类型的表的架构。
-t &#124;&#124; --table <TABLE_NAME>... 要为其生成实体类型的表。
--use-database-names 直接使用数据库中的表名和列名。
-n &#124; --namespaces <NAMESPACE> 生成的实体类的命名空间。默认情况下匹配目录。
--context-namespace <NAMESPACE> 生成的派生DbContext类的名称空间。默认情况下匹配目录。
--no-onconfiguring 不生成OnConfiguring方法。
--no-pluralize 不使用复数。

EF Core 5.0 中的scaffold命令变得更加强大。如你所见,有很多选项可供选择。如果选择了数据注释(-d)选项,EF Core 将在可能的地方使用数据注释,并填写与 Fluent API 的差异。如果未选择该选项,整个配置(与约定不同的地方)将在 Fluent API 中编码。您可以为生成的实体和派生的DbContext文件指定名称空间、模式和位置。如果不想搭建整个数据库,可以选择某些模式和表。--no-onconfiguring选项从搭建的类中消除了OnConfiguring()方法,–no-pluralize选项关闭了复数器,它在创建迁移时将单个实体(Car)转换为多个表(Cars),在搭建时将多个表转换为单个实体。

摘要

本章开始了进入实体框架核心的旅程。本章研究了 EF 核心基础知识、查询如何执行以及变更跟踪。您学习了如何塑造您的模型、EF 核心约定、数据注释和 Fluent API,以及如何使用它们来影响您的数据库设计。最后一节介绍了 EF 核心命令行界面和全局工具的强大功能。

虽然这一章涵盖了很多理论和一些代码,但下一章几乎都是带有一点理论的代码。当您完成第二十三章时,您将拥有完整的AutoLot数据访问层。

二十三、实用实体框架核心构建数据访问层

上一章详细介绍了 EF 核心及其功能。本章着重于应用您所学的 EF 核心知识来构建AutoLot数据访问层。您可以通过搭建实体并从前一章的数据库中导出DbContext来开始这一章。然后,项目从数据库优先改为代码优先,实体被更新到它们的最终版本,并使用 EF 核心迁移应用到数据库。对数据库的最后一个更改是重新创建GetPetName存储过程,并创建一个新的数据库视图(包括一个匹配的视图模型),所有这些都使用迁移。

下一步是创建存储库,提供对数据库的独立创建、读取、更新和删除(CRUD)访问。然后,将数据初始化代码(包括示例数据)添加到项目中,以便在测试中使用。本章的剩余部分将通过自动化集成测试来测试驱动AutoLot数据访问层。

代码优先还是数据库优先

在我们开始构建数据访问层之前,让我们花点时间来讨论使用 EF Core 和您的数据库的两种不同方式:代码优先和数据库优先。这两种方法都是使用 EF Core 的有效方法,至于使用哪种方法,很大程度上取决于您的开发团队。

代码优先意味着您在代码中创建和配置您的实体类和派生的DbContext,然后使用迁移来更新数据库。这就是大多数新建项目的开发方式。这样做的好处是,当您构建应用时,您的实体会根据应用的需求而发展。迁移使数据库保持同步,因此数据库设计会随着应用的发展而发展。这种新兴的设计过程很受敏捷开发团队的欢迎,因为您在正确的时间构建了正确的部分。

如果您已经有了一个数据库,或者更喜欢让您的数据库设计来驱动您的应用,这被称为数据库优先。不是手工创建派生的DbContext和所有的实体,而是从数据库中构建类。当数据库发生变化时,您需要重新搭建您的类,以保持您的代码与数据库同步。实体或派生的DbContext中的任何定制代码必须放在分部类中,这样当类被重新搭建时就不会被覆盖。幸运的是,搭建过程正是出于这个原因创建了分部类。

无论您选择哪种方法,代码优先还是数据库优先,都要知道这是一种承诺。如果首先使用代码,则对实体和上下文类进行所有更改,并使用迁移来更新数据库。如果您首先使用数据库,则必须在数据库中进行所有更改,然后重新构建类。通过一些努力和计划,您可以从数据库优先切换到代码优先(反之亦然),但是您不应该同时对代码和数据库进行手动更改。

创建自动 Lot。达尔和奥托洛特。模型项目

AutoLot数据访问层由两个项目组成,一个包含 EF 核心特定的代码(派生的DbContext、上下文工厂、存储库、迁移等)。)另一个用来保存实体和视图模型。创建一个名为 Chapter23_AllProjects 的新解决方案,并在该解决方案中添加一个名为AutoLot.Models的. NET 核心类库。删除使用模板创建的默认类,并将以下 NuGet 包添加到项目中:

  • Microsoft.EntityFrameworkCore.Abstractions

  • System.Text.Json

T``Microsoft.EntityFrameworkCore.Abstractions包提供了对许多 EF 核心构造(如数据注释)的访问,并且比Microsoft.EntityFrameworkCore包更轻。

再加一个。NET 核心类库项目命名为AutoLot.Dal地解决方案。删除使用模板创建的默认类,添加对AutoLot.Models项目的引用,并将以下 NuGet 包添加到项目中:

  • Microsoft.EntityFrameworkCore

  • Microsoft.EntityFrameworkCore.SqlServer

  • Microsoft.EntityFrameworkCore.Design

T``Microsoft.EntityFrameworkCore包提供了 EF 内核的通用功能。Microsoft.EntityFrameworkCore.SqlServer包提供了 SQL Server 数据提供者,EF 核心命令行工具需要Microsoft.EntityFrameworkCore.Design包。

要使用命令行完成所有这些步骤,请使用以下命令(在要创建解决方案的目录中):

dotnet new sln -n Chapter23_AllProjects

dotnet new classlib -lang c# -n AutoLot.Models -o .\AutoLot.Models -f net5.0
dotnet sln .\Chapter23_AllProjects.sln add .\AutoLot.Models
dotnet add AutoLot.Models package Microsoft.EntityFrameworkCore.Abstractions
dotnet add AutoLot.Models package System.Text.Json

dotnet new classlib -lang c# -n AutoLot.Dal -o .\AutoLot.Dal -f net5.0
dotnet sln .\Chapter23_AllProjects.sln add .\AutoLot.Dal
dotnet add AutoLot.Dal reference AutoLot.Models
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.Design
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.SqlServer
dotnet add AutoLot.Dal package Microsoft.EntityFrameworkCore.Tools

Note

如果您使用的不是基于 Windows 的计算机,请根据您的操作系统调整目录分隔符。本章中的所有 CLI 命令都需要这样做。

创建项目后,更新每个*.csproj文件以启用 C# 8 可空引用类型。此处更新以粗体显示:

<PropertyGroup>
  <TargetFramework>net5.0</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>

搭建 DbContext 和实体

下一步是使用 EF 核心命令行工具搭建第二十一章的AutoLot数据库。在命令提示符或 Visual Studio 的包管理器控制台中导航到AutoLot.Dal项目目录。

Note

在 repo 的第二十一章的文件夹中有 Windows 和 Docker 的数据库备份。如果需要恢复数据库,请参考第二十一章中的说明。

使用 EF Core CLI 工具,通过以下命令将AutoLot数据库移植到实体和DbContext派生类中(全部在一行中):

dotnet ef dbcontext scaffold "server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;" Microsoft.EntityFrameworkCore.SqlServer -d -c ApplicationDbContext --context-namespace AutoLot.Dal.EfStructures --context-dir EfStructures --no-onconfiguring -n AutoLot.Models.Entities -o ..\AutoLot.Models\Entities

前面的命令使用 SQL Server 数据库提供程序搭建位于所提供的连接字符串(这是第二十一章中使用的 Docker 容器的连接字符串)的数据库。-d标志是在可能的情况下优先考虑数据注释(通过 Fluent API)。-c命名上下文,--context-namespaces指定上下文的名称空间,--context-dir表示上下文的目录(相对于当前项目),--no-onconfiguring防止OnConfiguring方法被搭建,-o是实体的输出目录(相对于项目目录),-n指定实体的名称空间。该命令将所有实体放置在自动 Lot 中。模型投影在entities文件夹中,并将ApplicationDbContext放置在自动 Lot 的EfStructures文件夹中。Dal 项目。

如果您一直在学习这一章,您会注意到存储过程并没有被搭建起来。如果数据库中有任何视图,它们将被搭建成无键实体。因为没有直接映射到存储过程的 EF 核心构造,所以没有任何东西可以支撑存储过程。可以使用 EF Core 创建存储过程和其他 SQL 对象,但是目前只搭建了表和视图。

首先切换到代码

既然您已经将数据库搭建成实体,那么是时候从数据库优先切换到代码优先了。要进行切换,必须创建一个上下文工厂,并从项目的当前状态创建一个迁移。接下来,要么通过删除并重新创建数据库来应用迁移,要么通过“欺骗”EF 核心来假应用迁移。

创建 DbContext 设计时工厂

正如你在第二十二章中回忆的,EF 核心命令行工具使用IDesignTimeDbContextFactory来创建派生DbContext类的一个实例。在 AutoLot 中创建一个名为ApplicationDbContextFactory.cs的新类文件。EfStructures目录下的 Dal 项目。将下列命名空间添加到类中:

using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;

工厂的细节在前一章已经介绍过了,所以我在这里只列出代码。对Console.WriteLine()的额外调用将连接字符串输出到控制台。这只是为了提供信息。确保更新您的连接字符串以匹配您的环境。

namespace AutoLot.Dal.EfStructures
{
  public class ApplicationDbContextFactory : IDesignTimeDbContextFactory<ApplicationDbContext>
  {
    public ApplicationDbContext CreateDbContext(string[] args)
    {
      var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
      var connectionString = @"server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;";
      optionsBuilder.UseSqlServer(connectionString);
      Console.WriteLine(connectionString);
      return new ApplicationDbContext(optionsBuilder.Options);
    }
  }
}

创建初始迁移

回想一下,第一次迁移将创建三个文件:两个文件用于迁移分部类,第三个文件是完整的模型快照。在命令提示符下,在AutoLot.Dal目录中输入以下内容,创建一个名为Initial的新迁移(使用刚刚搭建好的ApplicationDbContext实例),并将迁移文件放在 AutoLot 的EfStructures\Migrations文件夹中。Dal 项目:

dotnet ef migrations add Initial -o EfStructures\Migrations -c AutoLot.Dal.EfStructures.ApplicationDbContext

Note

在创建和应用第一次迁移之前,务必确保不会对生成的文件或数据库进行任何更改。任何一方的更改都会导致代码和数据库不同步。一旦应用,对数据库的所有更改都需要通过 EF 核心迁移来完成。

要确认迁移已创建并等待应用,请执行list命令。

dotnet ef migrations list -c AutoLot.Dal.EfStructures.ApplicationDbContext

结果将显示Initial迁移挂起(您的时间戳将不同)。由于CreateDbContext()方法中的Console.Writeline(),连接字符串显示在输出中。

Build started...
Build succeeded.
server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;
20201231203939_Initial (Pending)

应用迁移

将迁移应用到数据库的最简单方法是删除数据库并重新创建它。如果这是一个选项,您可以输入以下命令并继续下一部分:

dotnet ef database drop -f
dotnet ef database update Initial -c AutoLot.Dal.EfStructures.ApplicationDbContext

如果不能删除并重新创建数据库(例如,它是一个 Azure SQL 数据库),那么 EF Core 需要相信已经应用了迁移。幸运的是,这很简单,所有的工作都由 EF Core 完成。首先,使用以下命令从迁移中创建 SQL 脚本:

dotnet ef migrations script --idempotent -o FirstMigration.sql

这个脚本的相关部分是创建__EFMigrationsHistory表,然后将迁移记录添加到表中以表明它已被应用。将这些片段复制到 Azure Data Studio 或 SQL Server Manager Studio 中的新查询中。以下是您需要的 SQL 代码(您的时间戳会有所不同):

IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
    CREATE TABLE [__EFMigrationsHistory] (
        [MigrationId] nvarchar(150) NOT NULL,
        [ProductVersion] nvarchar(32) NOT NULL,
        CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
    );
END;
GO

INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20201231203939_Initial', N'5.0.1');

现在,如果您运行list命令,它将不再把Initial迁移显示为挂起。随着初始迁移的应用,项目和数据库是同步的,开发将首先继续代码。

更新模型

这个部分将所有当前实体更新到它们的最终版本,并添加一个日志实体。请注意,在本节完成之前,您的项目不会编译。

实体

AutoLot.Models项目的Entities目录中,您会发现五个文件,每个文件对应数据库中的一个表。请注意,名称是单数,而不是复数(因为它们在数据库中)。这是 EF Core 5 中的一个变化,在 EF Core 5 中,当从数据库中移植实体时,默认情况下 multivarizer 是打开的。

您将对实体进行的更改包括添加一个基类,创建一个拥有的Person实体,修复导航属性名称,以及添加一些附加属性。您还将添加一个新的日志实体(将被 ASP.NET 核心章节使用)。上一章深入介绍了 EF 核心约定、数据注释和 Fluent API,因此本节的大部分内容都是代码清单和简要说明。

BaseEntity 类

BaseEntity类将保存每个实体上的IdTimeStamp列。在AutoLot.Models项目的Entities目录下创建一个名为Base的新目录。在这个目录中,创建一个名为BaseEntity.cs的新文件。更新代码以匹配以下内容:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace AutoLot.Models.Entities.Base
{
  public abstract class BaseEntity
  {
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    [Timestamp]
    public byte[]? TimeStamp { get; set; }
  }
}

AutoLot数据库中搭建的所有实体都将被更新以使用这个基类。

所有者实体

CustomerCreditRisk实体都有FirstNameLastName属性。每个实体都具有完全相同的属性,将这些属性移动到自己的类中会有好处。虽然两个属性是一个微不足道的例子,但拥有的实体有助于减少代码重复和增加一致性。除了类中的两个属性之外,还添加了一个将映射到 SQL Server 计算列的新属性。

在 AutoLot 的Entities目录中创建一个名为Owned的新目录。模型项目。在这个新目录中,创建一个名为Person.cs的新文件。更新代码以匹配以下内容:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace AutoLot.Models.Entities.Owned
{
  [Owned]
  public class Person
  {
    [Required, StringLength(50)]
    public string FirstName { get; set; } = "New";

    [Required, StringLength(50)]
    public string LastName { get; set; } = "Customer";

    [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
    public string? FullName { get; set; }
  }
}

属性FullName可以为空,因为新实体在保存到数据库之前不会设置值。将使用 Fluent API 添加Fullname属性的最终配置。

汽车(库存)实体

Inventory表被搭建到一个名为Inventory的实体类上。我们更喜欢用Car这个名字。这很容易解决:将文件名改为Car.cs,将类名改为CarTable属性已经被正确应用,所以只需添加dbo模式。注意,schema 参数是可选的,因为 SQL Server 默认为dbo,但是为了完整起见,我将它包括在内。

[Table("Inventory", Schema = "dbo")]
[Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]
public partial class Car : BaseEntity
{
...
}

更新using语句以匹配以下内容:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

接下来,从BaseEntity继承,并移除IdTimeStamp属性、构造函数和 pragma #nullable disable。以下是经过这些更改后的类代码:

namespace AutoLot.Models.Entities
{
  [Table("Inventory", Schema = "dbo")]
  [Index(nameof(MakeId), Name = "IX_Inventory_MakeId")]
  public partial class Car : BaseEntity
  {
    public int MakeId { get; set; }
    [Required]
    [StringLength(50)]
    public string Color { get; set; }
    [Required]
    [StringLength(50)]
    public string PetName { get; set; }
    [ForeignKey(nameof(MakeId))]
    [InverseProperty("Inventories")]
    public virtual Make Make { get; set; }
    [InverseProperty(nameof(Order.Car))]
    public virtual ICollection<Order> Orders { get; set; }
  }
}

这段代码仍然有一些问题需要解决,而且还需要添加新的属性。ColorPetName属性被设置为不可空,但是值没有在构造函数中设置,也没有用属性定义初始化。这可以通过给每个属性分配一个初始化器来解决。将DisplayName属性添加到PetName属性中,以获得一个更好的、人类可读的名称。更新属性以匹配以下内容(更改以粗体显示):

[Required]
[StringLength(50)]
public string Color { get; set; } = "Gold";

[Required]
[StringLength(50)]
[DisplayName("Pet Name")]
public string PetName { get; set; } = "My Precious";

Note

属性由 ASP.NET 核心使用,将在第八部分中介绍。

Make导航属性需要重命名为MakeNavigation并使其可为空,反向属性使用一个神奇的字符串,而不是 C# nameof()方法。最后的改变是去掉virtual修改器。以下是更新后的属性:

[ForeignKey(nameof(MakeId))]
[InverseProperty(nameof(Make.Cars))]
public Make? MakeNavigation { get; set; }

Note

延迟加载需要虚拟修饰符。因为本书中没有一个例子使用延迟加载,所以虚拟修饰符将从数据访问层的所有属性中删除。

Orders导航属性需要JsonIgnore属性来防止序列化对象模型时的循环 JSON 引用。搭建的代码确实在逆向属性中使用了nameof()方法,但是需要更新,因为所有引用导航属性的名称都将添加后缀Navigation。最后的改变是将属性的类型改为IEnumerable<Order>而不是ICollection<Order>,并用新的List<Order>初始化。这不是必需的改变,因为ICollection<Order>也可以工作。我更喜欢在集合导航属性上使用较低级别的IEnumerable<T>(因为IQueryable<T>ICollection<T>都是从IEnumerable<T>派生的)。更新代码以匹配以下内容:

[JsonIgnore]
[InverseProperty(nameof(Order.CarNavigation))]
public IEnumerable<Order> Orders { get; set; } = new List<Order>();

接下来,添加一个将显示CarMake值的NotMapped属性。这消除了对章节 21 中CarViewModel的需要。如果从带有Car记录的数据库中检索到相关的Make信息,将显示Make Name。如果未检索到相关数据,该属性将显示“未知”提醒一下,NotMapped属性不是数据库的一部分,只存在于实体上。添加以下内容:

[NotMapped]
public string MakeName => MakeNavigation?.Name ?? "Unknown";

超越ToString()显示车辆信息。

public override string ToString()
{
  // Since the PetName column could be empty, supply
  // the default name of **No Name**.
  return $"{PetName ?? "**No Name**"} is a {Color} {MakeNavigation?.Name} with ID {Id}.";
}

RequiredDisplayName属性添加到MakeId中。尽管 EF 核心认为MakeId属性是必需的,因为它不可为空,但是 ASP.NET 核心验证引擎需要Required属性。更新代码以匹配以下内容:

[Required]
[DisplayName("Make")]
public int MakeId { get; set; }

最后一项更改是添加不可空的bool IsDrivable属性,该属性带有一个可空的支持字段和一个显示名称。

private bool? _isDrivable;

[DisplayName("Is Drivable")]
public bool IsDrivable
{
  get => _isDrivable ?? false;
  set => _isDrivable = value;
}

这就完成了更新的Car实体。

客户实体

Customers表被搭建到一个名为Customer的实体类上。更新using语句以匹配以下内容:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using AutoLot.Models.Entities.Base;
using AutoLot.Models.Entities.Owned;

接下来,从BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。删除FirstNameLastName属性,因为它们将被Person拥有的实体所取代。这是目前类别代码的位置:

namespace AutoLot.Models.Entities
{
  [Table("Customers", Schema = "dbo")]
  public partial class Customer : BaseEntity
  {
    [InverseProperty(nameof(CreditRisk.Customer))]
    public virtual ICollection<CreditRisk> CreditRisks { get; set; }
    [InverseProperty(nameof(Order.Customer))]
    public virtual ICollection<Order> Orders { get; set; }
  }
}

Car实体一样,这段代码仍然有一些问题需要解决,并且必须添加所拥有的实体。导航属性需要JsonIgnore属性,反向属性属性需要用Navigation后缀更新,类型被更改为初始化的IEnumerable<T>,并且virtual修饰符被删除。更新代码以匹配以下内容:

[JsonIgnore]
[InverseProperty(nameof(CreditRisk.CustomerNavigation))]
public IEnumerable<CreditRisk> CreditRisks { get; set; } = new List<CreditRisk>();

[JsonIgnore]
[InverseProperty(nameof(Order.CustomerNavigation))]
public IEnumerable<Order> Orders { get; set; } = new List<Order>();

最后的更改是添加拥有的属性。该关系将在 Fluent API 中进一步配置。

public Person PersonalInformation { get; set; } = new Person();

这就完成了更新的Customer实体。

制作实体

Makes表被搭建到一个名为Make的实体类上。更新using语句以匹配以下内容:

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。实体的当前状态如下:

namespace AutoLot.Models.Entities
{
  [Table("Makes", Schema = "dbo")]
  public partial class Make : BaseEntity
  {
    [Required]
    [StringLength(50)]
    public string Name { get; set; }
    [InverseProperty(nameof(Inventory.Make))]
    public virtual ICollection<Inventory> Inventories { get; set; }
  }
}

下面的代码显示了不可空的Name属性的初始化和Cars导航属性的更正(注意在nameof方法中从InventoryCar的变化):

[Required]
[StringLength(50)]
public string Name { get; set; } = "Ford";

[JsonIgnore]
[InverseProperty(nameof(Car.MakeNavigation))]
public IEnumerable<Car> Cars { get; set; } = new List<Car>();

这就完成了Make实体。

信用风险实体

CreditRisks表被搭建到一个名为CreditRisk的实体类上。更新using语句以匹配以下内容:

using System.ComponentModel.DataAnnotations.Schema;
using AutoLot.Models.Entities.Base;
using AutoLot.Models.Entities.Owned;

BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。删除FirstNameLastName属性,因为它们将被Person拥有的实体所取代。以下是更新后的类别代码:

namespace AutoLot.Models.Entities
{
  [Table("CreditRisks", Schema = "dbo")]
  public partial class CreditRisk : BaseEntity
  {
    public Person PersonalInformation { get; set; } = new Person();
    public int CustomerId { get; set; }

    [ForeignKey(nameof(CustomerId))]
    [InverseProperty("CreditRisks")]
    public virtual Customer Customer { get; set; }
  }
}

通过删除virtual修饰符来修复导航属性,在InverseProperty属性中使用nameof()方法,并在属性名中添加Navigation后缀。

[ForeignKey(nameof(CustomerId))]
[InverseProperty(nameof(Customer.CreditRisks))]
public Customer? CustomerNavigation { get; set; }

最后的更改是添加拥有的属性。该关系将在 Fluent API 中进一步配置。

public Person PersonalInformation { get; set; } = new Person();

这就完成了CreditRisk实体。

订单实体

Orders表被搭建到一个名为Order的实体类上。更新using语句以匹配以下内容:

using System;
using System.ComponentModel.DataAnnotations.Schema;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

BaseEntity继承并删除IdTimeStamp属性。删除构造函数和杂注#nullable disable并添加带有模式的Table属性。以下是当前代码:

namespace AutoLot.Models.Entities
{
  [Table("Orders", Schema = "dbo")]
  [Index(nameof(CarId), Name = "IX_Orders_CarId")]
  [Index(nameof(CustomerId), nameof(CarId), Name = "IX_Orders_CustomerId_CarId", IsUnique = true)]
  public partial class Order : BaseEntity
  {
    public int CustomerId { get; set; }
    public int CarId { get; set; }
    [ForeignKey(nameof(CarId))]
    [InverseProperty(nameof(Inventory.Orders))]
    public virtual Inventory Car { get; set; }
    [ForeignKey(nameof(CustomerId))]
    [InverseProperty("Orders")]
    public virtual Customer { get; set; }
    }
}

CarCustomer导航属性需要在它们的属性名后面加上Navigation后缀。Car导航属性需要从Inventory修正为Car的类型。逆属性需要nameof()方法使用Car.Orders而不是Inventory.OrdersCustomer导航属性需要使用InversePropertynameof()方法。这两个属性都需要被设置为可空,并且virtual修饰符被移除。

[ForeignKey(nameof(CarId))]
[InverseProperty(nameof(Car.Orders))]
public Car? CarNavigation { get; set; }

[ForeignKey(nameof(CustomerId))]
[InverseProperty(nameof(Customer.Orders))]
public Customer? CustomerNavigation { get; set; }

这就完成了Order实体。

Note

这时,自动手枪。模型项目应该正确构建。自动手枪。在更新ApplicationDbContext类之前,Dal 项目不会构建。

SeriLogEntry 实体

数据库需要一个附加的表来保存日志记录。第八部分中的 ASP.NET 核心项目将使用 SeriLog 日志框架,其中一个选项是将日志记录写到 SQL Server 表中。我们现在要添加这个表,因为我们知道从现在开始的几个章节中会用到它。

该表不与任何其他表相关,并且不使用BaseEntity类。在Entities文件夹中添加一个名为SeriLogEntry.cs的新类文件。此处列出了完整的代码:

using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Xml.Linq;

namespace AutoLot.Models.Entities
{
  [Table("SeriLogs", Schema = "Logging")]
  public class SeriLogEntry
  {
    [Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public string? Message { get; set; }
    public string? MessageTemplate { get; set; }
    [MaxLength(128)]
    public string? Level { get; set; }
    [DataType(DataType.DateTime)]
    public DateTime? TimeStamp { get; set; }
    public string? Exception { get; set; }
    public string? Properties { get; set; }
    public string? LogEvent { get; set; }
    public string? SourceContext { get; set; }
    public string? RequestPath { get; set; }
    public string? ActionName { get; set; }
    public string? ApplicationName { get; set; }
    public string? MachineName { get; set; }
    public string? FilePath { get; set; }
    public string? MemberName { get; set; }
    public int? LineNumber { get; set; }
    [NotMapped]
    public XElement? PropertiesXml => (Properties != null)? XElement.Parse(Properties):null;
  }
}

这就完成了SeriLogEntry实体。

Note

该实体中的TimeStamp属性与BaseEntity类中的TimeStamp属性不同。名称是相同的,但是在这个表中,它保存条目被记录的日期和时间(这将被配置为 SQL Server 默认值),而不是其他实体中的rowversion

应用数据库上下文

是时候更新ApplicationDbContext.cs了。首先更新using语句以匹配以下内容:

using System;
using System.Collections;
using System.Collections.Generic;
using AutoLot.Models.Entities;
using AutoLot.Models.Entities.Owned;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using AutoLot.Dal.Exceptions;

该文件以无参数的构造函数开始。删除它,因为我们不需要它。下一个构造函数接受了一个DbContextOptions对象的实例,现在可以了。DbContextChangeTracker的事件挂钩将在本章后面添加。

需要将DbSet<T>属性更新为可空,修改名称,并删除virtual修饰符。需要添加新的日志记录实体。导航到DbSet<T>属性,并将其更新为以下内容:

public DbSet<SeriLogEntry>? LogEntries { get; set; }
public DbSet<CreditRisk>? CreditRisks { get; set; }
public DbSet<Customer>? Customers { get; set; }
public DbSet<Make>? Makes { get; set; }
public DbSet<Car>? Cars { get; set; }
public DbSet<Order>? Orders { get; set; }

更新 Fluent API 代码

OnModelCreating覆盖是 Fluent API 代码的归属,它使用ModelBuilder类的一个实例来更新模型。

SeriLog 实体

这个方法的第一个变化是为实体SeriLogEntry的配置添加了流畅的 API 代码。Properties属性是 SQL Server XML 列,TimeStamp属性映射到 SQL Server 中的datetime2列,默认值设置为getdate() SQL Server 函数。在OnModelCreating方法中,添加以下代码:

modelBuilder.Entity<SeriLogEntry>(entity =>
{
  entity.Property(e => e.Properties).HasColumnType("Xml");
  entity.Property(e => e.TimeStamp).HasDefaultValueSql("GetDate()");
});

信用风险实体

下一个要更新的代码是针对CreditRisk实体的。TimeStamp列的配置块被删除,因为它是在BaseEntity中配置的。导航配置必须用新名称更新。我们还断言导航属性不为空。另一个变化是为FirstNameLastName配置所拥有实体的属性到列名的映射,并为FullName属性添加计算值。下面是更新后的CreditRisk实体块,更改以粗体突出显示:

modelBuilder.Entity<CreditRisk>(entity =>
{
  entity.HasOne(d => d.CustomerNavigation)
      .WithMany(p => p!.CreditRisks)
      .HasForeignKey(d => d.CustomerId)
      .HasConstraintName("FK_CreditRisks_Customers");

  entity.OwnsOne(o => o.PersonalInformation,
    pd =>
    {
      pd.Property<string>(nameof(Person.FirstName))
           .HasColumnName(nameof(Person.FirstName))
           .HasColumnType("nvarchar(50)");
      pd.Property<string>(nameof(Person.LastName))
           .HasColumnName(nameof(Person.LastName))
           .HasColumnType("nvarchar(50)");
      pd.Property(p => p.FullName)
           .HasColumnName(nameof(Person.FullName))
           .HasComputedColumnSql("[LastName] + ', ' + [FirstName]");
    });
});

客户实体

下一个要更新的代码是针对Customer实体的。删除TimeStamp代码,并配置所拥有实体的属性。

modelBuilder.Entity<Customer>(entity =>
{
  entity.OwnsOne(o => o.PersonalInformation,
     pd =>
     {
                        pd.Property(p => p.FirstName).HasColumnName(nameof(Person.FirstName));
                        pd.Property(p => p.LastName).HasColumnName(nameof(Person.LastName));
                        pd.Property(p => p.FullName)
                            .HasColumnName(nameof(Person.FullName))
                            .HasComputedColumnSql("[LastName] + ', ' + [FirstName]");
     });
});

制作实体

对于Make实体,更新配置块以删除TimeStamp,并添加代码以限制删除具有依赖实体的实体。

modelBuilder.Entity<Make>(entity =>
{
  entity.HasMany(e => e.Cars)
      .WithOne(c => c.MakeNavigation!)
      .HasForeignKey(k => k.MakeId)
      .OnDelete(DeleteBehavior.Restrict)
      .HasConstraintName("FK_Make_Inventory");
});

订单实体

对于Order实体,更新导航属性名称并断言反向属性不为空。不再限制删除,而是将CustomerOrders的关系设置为级联删除。

modelBuilder.Entity<Order>(entity =>
{
  entity.HasOne(d => d.CarNavigation)
     .WithMany(p => p!.Orders)
     .HasForeignKey(d => d.CarId)
     .OnDelete(DeleteBehavior.ClientSetNull)
     .HasConstraintName("FK_Orders_Inventory");

  entity.HasOne(d => d.CustomerNavigation)
     .WithMany(p => p!.Orders)
     .HasForeignKey(d => d.CustomerId)
     .OnDelete(DeleteBehavior.Cascade)
     .HasConstraintName("FK_Orders_Customers");
});

Order表的CarNavigation属性上设置一个查询过滤器,过滤掉不可驾驶的汽车。请注意,这段代码与前面的代码不在同一个块中。分离它没有技术上的理由;在单独的块中设置配置是一种替代语法。

modelBuilder.Entity<Order>().HasQueryFilter(e => e.CarNavigation!.IsDrivable);

汽车实体

搭建的类包含了Inventory类的配置。需要改成Car类。可以删除TimeStamp,导航属性配置保留对MakeNavigationCars属性名称的更新。该实体得到一个查询过滤器,默认设置为只显示可驾驶的汽车,并将属性IsDrivable的默认值设置为true。更新代码以匹配以下内容:

modelBuilder.Entity<Car>(entity =>
{
  entity.HasQueryFilter(c => c.IsDrivable);
  entity.Property(p => p.IsDrivable).HasField("_isDrivable").HasDefaultValue(true);

  entity.HasOne(d => d.MakeNavigation)
    .WithMany(p => p.Cars)
    .HasForeignKey(d => d.MakeId)
    .OnDelete(DeleteBehavior.ClientSetNull)
    .HasConstraintName("FK_Make_Inventory");
});

自定义例外

异常处理的一个常见模式是捕捉系统异常(和/或 EF 核心异常,如本例所示),记录异常,然后抛出一个自定义异常。如果自定义异常在上游方法中被捕获,开发人员知道该异常已经被记录,只需要在他们的代码中对该异常做出适当的反应。

在 AutoLot 中创建一个名为Exceptions的新目录。Dal 项目。在该目录中,创建四个新的类文件:CustomException.csCustomConcurrencyException.csCustomDbUpdateException.csCustomRetryLimitExceededException.cs。以下清单显示了所有四个文件:

//CustomException.cs
using System;
namespace AutoLot.Dal.Exceptions
{
  public class CustomException : Exception
  {
    public CustomException() {}
    public CustomException(string message) : base(message) { }
    public CustomException(string message, Exception innerException)
            : base(message, innerException) { }
  }
}

//CustomConcurrencyException.cs
using Microsoft.EntityFrameworkCore;
namespace AutoLot.Dal.Exceptions
{
  public class CustomConcurrencyException : CustomException
  {
    public CustomConcurrencyException() { }
    public CustomConcurrencyException(string message) : base(message) { }
    public CustomConcurrencyException(
      string message, DbUpdateConcurrencyException innerException)
            : base(message, innerException) { }
  }
}

//CustomDbUpdateException.cs
using Microsoft.EntityFrameworkCore;
namespace AutoLot.Dal.Exceptions
{
  public class CustomDbUpdateException : CustomException
  {
    public CustomDbUpdateException() { }
    public CustomDbUpdateException(string message) : base(message) { }
    public CustomDbUpdateException(
      string message, DbUpdateException innerException)
            : base(message, innerException) { }
  }
}

//CustomRetryLimitExceededException.cs
using System;
using Microsoft.EntityFrameworkCore.Storage;

namespace AutoLot.Dal.Exceptions
{
  public class CustomRetryLimitExceededException : CustomException
  {
    public CustomRetryLimitExceededException() { }
    public CustomRetryLimitExceededException(string message)
        : base(message) { }
    public CustomRetryLimitExceededException(
      string message, RetryLimitExceededException innerException)
        : base(message, innerException) { }
  }
}

Note

自定义异常处理在第七章中有详细介绍。

重写 SaveChanges 方法

如前一章所述,基类DbContext上的SaveChanges()方法将数据更改、添加和删除保存到数据库中。重写该方法可以将异常处理封装在一个地方。定制异常就绪后,将AutoLot.Dal.Exceptions using语句添加到ApplicationDbContext类的顶部。接下来,向SaveChanges()方法添加以下覆盖:

public override int SaveChanges()
{
  try
  {
    return base.SaveChanges();
  }
  catch (DbUpdateConcurrencyException ex)
  {
    //A concurrency error occurred
    //Should log and handle intelligently
    throw new CustomConcurrencyException("A concurrency error happened.", ex);
  }
  catch (RetryLimitExceededException ex)
  {
    //DbResiliency retry limit exceeded
    //Should log and handle intelligently
    throw new CustomRetryLimitExceededException("There is a problem with SQl Server.", ex);
  }
  catch (DbUpdateException ex)
  {
    //Should log and handle intelligently
    throw new CustomDbUpdateException("An error occurred updating the database", ex);
  }
  catch (Exception ex)
  {
    //Should log and handle intelligently
    throw new CustomException("An error occurred updating the database", ex);
  }
}

处理 DbContext 和 ChangeTracker 事件

导航到ApplicationDbContext的构造函数,添加上一章讨论的三个DbContext事件。

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
  : base(options)
{
  base.SavingChanges += (sender, args) =>
  {
    Console.WriteLine($"Saving changes for {((ApplicationDbContext)sender)!.Database!.GetConnectionString()}");
  };
  base.SavedChanges += (sender, args) =>
  {
    Console.WriteLine($"Saved {args!.EntitiesSavedCount} changes for {((ApplicationDbContext)sender)!.Database!.GetConnectionString()}");
  };
  base.SaveChangesFailed += (sender, args) =>
  {
    Console.WriteLine($"An exception occurred! {args.Exception.Message} entities");
  };
}

接下来,为ChangeTracker StateChangedTracked事件添加处理程序。

public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
  : base(options)
{
...
  ChangeTracker.Tracked += ChangeTracker_Tracked;
  ChangeTracker.StateChanged += ChangeTracker_StateChanged;
}

Tracked事件参数保存对触发事件的实体的引用,以及它是来自查询(从数据库加载)还是以编程方式添加的。在ApplicationDbContext中添加以下事件处理程序:

private void ChangeTracker_Tracked(object? sender, EntityTrackedEventArgs e)
{
  var source = (e.FromQuery) ? "Database" : "Code";
  if (e.Entry.Entity is Car c)
  {
    Console.WriteLine($"Car entry {c.PetName} was added from {source}");
  }
}

当被跟踪实体的状态改变时,触发StateChanged事件。该事件的一个用途是审计。在下面的事件处理程序中,如果实体的NewStateUnchanged,则检查OldState以查看实体是否被添加或修改。将以下事件处理程序添加到ApplicationDbContext中:

private void ChangeTracker_StateChanged(object? sender, EntityStateChangedEventArgs e)
{
  if (e.Entry.Entity is not Car c)
  {
    return;
  }
  var action = string.Empty;
  Console.WriteLine($"Car {c.PetName} was {e.OldState} before the state changed to {e.NewState}");
  switch (e.NewState)
  {
    case EntityState.Unchanged:
      action = e.OldState switch
      {
        EntityState.Added => "Added",
        EntityState.Modified => "Edited",
        _ => action
      };
      Console.WriteLine($"The object was {action}");
      break;
  }
}

创建迁移并更新数据库

在本章的这一点上,两个项目都编译好了,我们准备创建另一个迁移来更新数据库。在AutoLot.Dal项目目录中输入以下命令(每个命令必须在一行中输入):

dotnet ef migrations add UpdatedEntities -o EfStructures\Migrations -c  AutoLot.Dal.EfStructures.ApplicationDbContext

dotnet ef database update UpdatedEntities -c AutoLot.Dal.EfStructures.ApplicationDbContext

添加数据库视图和存储过程

数据库还有两个变化。第一个是添加章节 21 中的GetPetName存储过程,第二个是添加一个数据库视图,该视图将Orders表与CustomerCarMake细节结合在一起。

添加 MigrationHelpers 类

我们使用迁移来创建存储过程和视图,这需要手动编写迁移代码。这样做的原因(而不是仅仅打开 Azure Data Studio 并运行 T-SQL 代码)是为了将所有的数据库配置放在一个进程中。当所有内容都包含在迁移中时,对dotnet ef database update的一次调用就可以确保数据库是最新的,包括 EF 核心配置和定制 SQL。

当没有任何模型变化时调用dotnet migrations add命令仍然会用空的Up()Down()方法创建带有正确时间戳的迁移文件。执行以下操作创建空迁移(但不应用迁移):

dotnet ef migrations add SQL -o EfStructures\Migrations -c AutoLot.Dal.EfStructures.ApplicationDbContext

现在,在AutoLot.Dal项目的EfStructures文件夹中添加一个名为MigrationHelpers.cs的新文件。为Microsoft.EntityFrameworkCore.Migrations添加一条using语句,创建类publicstatic,并添加以下方法,这些方法使用MigrationBuilder对数据库执行 SQL 语句:

namespace AutoLot.Dal.EfStructures
{
  public static class MigrationHelpers
  {
    public static void CreateSproc(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql($@"
          exec (N'
          CREATE PROCEDURE [dbo].[GetPetName]
              @carID int,
              @petName nvarchar(50) output
          AS
          SELECT @petName = PetName from dbo.Inventory where Id = @carID
      ')");
    }
    public static void DropSproc(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql("DROP PROCEDURE [dbo].[GetPetName]");
    }

    public static void CreateCustomerOrderView(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql($@"
          exec (N'
          CREATE VIEW [dbo].[CustomerOrderView]
          AS
          SELECT dbo.Customers.FirstName, dbo.Customers.LastName,
             dbo.Inventory.Color, dbo.Inventory.PetName, dbo.Inventory.IsDrivable,
             dbo.Makes.Name AS Make
          FROM   dbo.Orders
          INNER JOIN dbo.Customers ON dbo.Orders.CustomerId = dbo.Customers.Id
          INNER JOIN dbo.Inventory ON dbo.Orders.CarId = dbo.Inventory.Id
          INNER JOIN dbo.Makes ON dbo.Makes.Id = dbo.Inventory.MakeId
      ')");
    }
    public static void DropCustomerOrderView(MigrationBuilder migrationBuilder)
    {
      migrationBuilder.Sql("EXEC (N' DROP VIEW [dbo].[CustomerOrderView] ')");
    }
  }
}

更新并应用迁移

对于每个 SQL Server 对象,MigrationHelpers类有两个方法:一个创建对象,一个删除对象。回想一下,当应用迁移时,执行Up()方法,当回滚迁移时,执行Down()方法。创建静态方法进入迁移的Up()方法,删除方法进入迁移的Down()方法。当应用此迁移时,会创建两个 SQL Server 对象,当迁移回滚时,会删除这两个 SQL Server 对象。以下是更新后的迁移代码列表:

namespace AutoLot.Dal.EfStructures.Migrations
{
  public partial class SQL : Migration
  {
    protected override void Up(MigrationBuilder migrationBuilder)
    {
      MigrationHelpers.CreateSproc(migrationBuilder);
      MigrationHelpers.CreateCustomerOrderView(migrationBuilder);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
      MigrationHelpers.DropSproc(migrationBuilder);
      MigrationHelpers.DropCustomerOrderView(migrationBuilder);
    }
  }
}

如果为了运行初始迁移而删除了数据库,则可以应用该迁移并继续。通过执行以下命令来应用迁移:

dotnet ef database update -c AutoLot.Dal.EfStructures.ApplicationDbContext

如果第一次迁移时没有删除数据库,则该过程已经存在,无法创建。简单的解决方法是注释掉在Up()方法中创建存储过程的调用,如下所示:

protected override void Up(MigrationBuilder migrationBuilder)
{
//  MigrationHelpers.CreateSproc(migrationBuilder);
  MigrationHelpers.CreateCustomerOrderView(migrationBuilder);
}

在第一次应用这个迁移之后,取消对该行的注释,一切都会正常进行。当然,另一种选择是从数据库中删除存储过程,然后应用迁移。这确实打破了“一个地方更新”的模式,但这是从数据库优先到代码优先的过渡的一部分。

Note

您也可以编写代码,首先检查一个对象是否存在,如果已经存在,就删除它,但是我发现对于一个可能永远不会发生的问题来说,这样做太过分了。

添加视图模型

现在 SQL Server 视图已经就绪,是时候创建用于显示视图数据的ViewModel了。视图模型将被添加为一个Keyless DbSet<T>。这样做的好处是可以使用所有DbSet<T>集合通用的正常 LINQ 过程来查询数据。

添加视图模型

在 AutoLot 中添加一个名为ViewModels的新文件夹。模型项目。在这个文件夹中,添加一个名为CustomerOrderViewModel.cs的类,并将下面的using语句添加到文件中:

using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

接下来,将代码更新为以下内容:

namespace AutoLot.Models.ViewModels
{
  [Keyless]
  public class CustomerOrderViewModel
  {
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
    public string? Color { get; set; }
    public string? PetName { get; set; }
    public string? Make { get; set; }
    public bool? IsDrivable { get;set; }
    [NotMapped]
    public string FullDetail =>
     $"{FirstName} {LastName} ordered a {Color} {Make} named {PetName}";

    public override string ToString() => FullDetail;
  }
}

KeyLess数据注释表明这是一个处理没有主键的数据的实体,并且可以优化为只读数据(从数据库的角度来看)。前五个属性表示来自视图的数据。FullDetail属性用NotMapped数据注释来修饰。这通知 EF 核心该属性将不被包括在数据库中,也不会由于查询操作而来自数据库。EF 内核也会忽略ToString()覆盖。

将 ViewModel 添加到 ApplicationDbContext

最后一步是在ApplicationDbContext中注册和配置CustomerOrderViewModel。将AutoLot.Models.ViewModelsusing语句添加到ApplicationDbContext,然后添加DbSet<T>属性。

public virtual DbSet<CustomerOrderViewModel>? CustomerOrderViewModels { get; set; }

除了添加DbSet<T>实例,Fluent API 还将视图模型映射到 SQL Server 视图。HasNoKey() Fluent API 方法和Keyless数据注释完成同样的事情,Fluent API 方法取代了数据注释。为了清晰起见,我更喜欢保留数据注释。将以下内容添加到OnModelCreating()方法:

modelBuilder.Entity<CustomerOrderViewModel>(entity =>
{
  entity.HasNoKey().ToView("CustomerOrderView","dbo");
});

添加存储库

一种常见的数据访问设计模式是存储库模式。正如马丁·福勒( www.martinfowler.com/eaaCatalog/repository.html )所描述的,这种模式的核心是在域和数据映射层之间进行调解。拥有一个包含公共数据访问代码的通用库有助于消除代码重复。拥有从基本存储库派生的特定存储库和接口也可以很好地与 ASP.NET 核心中的依赖注入框架一起工作。

AutoLot数据访问层中的每个域实体都有一个强类型 repo 来封装所有的数据访问工作。首先,在AutoLot.Dal项目中创建一个名为Repos的文件夹来保存所有的类。

Note

下一节不打算(也不假装)对 Fowler 先生的设计模式进行字面解释。如果你对激发这个版本的原始模式感兴趣,你可以在 www.martinfowler.com/eaaCatalog/repository.html 找到更多关于存储库模式的信息。

添加 IRepo 基本接口

IRepo基本接口公开了数据访问中使用的许多常用方法。在自动 Lot 中添加新文件夹。Dal 项目命名为Repos,并在那个文件夹中,新建一个文件夹命名为Base。在Repos\Base文件夹中添加一个名为IRepo的新界面。将using语句更新如下:

using System;
using System.Collections.Generic;

下面列出了完整的界面:

namespace AutoLot.Dal.Repos.Base
{
  public interface IRepo<T>: IDisposable
  {
    int Add(T entity, bool persist = true);
    int AddRange(IEnumerable<T> entities, bool persist = true);
    int Update(T entity, bool persist = true);
    int UpdateRange(IEnumerable<T> entities, bool persist = true);
    int Delete(int id, byte[] timeStamp, bool persist = true);
    int Delete(T entity, bool persist = true);
    int DeleteRange(IEnumerable<T> entities, bool persist = true);
    T? Find(int? id);
    T? FindAsNoTracking(int id);
    T? FindIgnoreQueryFilters(int id);
    IEnumerable<T> GetAll();
    IEnumerable<T> GetAllIgnoreQueryFilters();
    void ExecuteQuery(string sql, object[] sqlParametersObjects);
    int SaveChanges();
  }
}

添加 BaseRepo

接下来,将名为BaseRepo的类添加到Repos\Base目录中。这个类将实现IRepo接口,并为特定类型的回购协议提供核心功能(接下来会介绍)。将using语句更新如下:

using System;
using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Exceptions;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;

使用类型T使类成为泛型,并将类型约束为BaseEntitynew(),这将类型限制为具有无参数构造函数的类。实现IRepo<T>接口,如下所示:

public abstract class BaseRepo<T> : IRepo<T> where T : BaseEntity, new()

repo 需要将一个ApplicationDbContext实例注入到构造函数中。当与 ASP.NET 核心 DI 容器一起使用时,该容器将处理上下文的生命周期。第二个构造函数将接受DbContextOptions,并需要创建一个ApplicationDbContext.的实例,该上下文需要被释放。因为这个类是抽象的,所以两个构造函数都受到保护。为公共ApplicationDbContext、两个构造函数和Dispose模式添加以下代码:

private readonly bool _disposeContext;
public ApplicationDbContext Context { get; }

protected BaseRepo(ApplicationDbContext context)
{
  Context = context;
  _disposeContext = false;
}

protected BaseRepo(DbContextOptions<ApplicationDbContext> options) : this(new ApplicationDbContext(options))
{
  _disposeContext = true;
}

public void Dispose()
{
  Dispose(true);
  GC.SuppressFinalize(this);
}
private bool _isDisposed;
protected virtual void Dispose(bool disposing)
{
  if (_isDisposed)
  {
    return;
  }

  if (disposing)
  {
    if (_disposeContext)
    {
      Context.Dispose();
    }
  }
  _isDisposed = true;
}

~BaseRepo()
{
  Dispose(false);
}

通过使用Context.Set<T>()方法可以引用ApplicationDbContextDbSet<T>属性。创建一个名为TableDbSet<T>类型的公共属性,并在初始构造函数中设置值,如下所示:

public DbSet<T> Table { get; }
protected BaseRepo(ApplicationDbContext context)
{
  Context = context;
  Table = Context.Set<T>();
  _disposeContext = false;
}

实现 SaveChanges 方法

BaseRepo有一个SaveChanges()调用被覆盖的SaveChanges()方法,该方法演示了定制异常模式。将以下代码添加到BaseRepo类中:

public int SaveChanges()
{
  try
  {
    return Context.SaveChanges();
  }
  catch (CustomException ex)
  {
    //Should handle intelligently - already logged
    throw;
  }
  catch (Exception ex)
  {
    //Should log and handle intelligently
    throw new CustomException("An error occurred updating the database", ex);
  }
}

实现常见的读取方法

接下来的一系列方法使用 LINQ 语句返回记录。Find()方法获取主键值并首先搜索ChangeTracker。如果实体已经被跟踪,则返回被跟踪的实例。如果没有,则从数据库中检索记录。

public virtual T? Find(int? id) => Table.Find(id);

两个额外的Find()方法扩展了Find()基本方法。下一个方法演示了使用AsNoTrackingWithIdentityResolution()检索记录,但不将其添加到ChangeTracker中。将以下代码添加到类中:

public virtual T? FindAsNoTracking(int id) =>
  Table.AsNoTrackingWithIdentityResolution().FirstOrDefault(x => x.Id == id);

下一个变化是从实体中移除查询过滤器,然后使用简写版本(跳过Where()方法)来获取FirstOrDefault()。将以下内容添加到类中:

public T? FindIgnoreQueryFilters(int id) =>
  Table.IgnoreQueryFilters().FirstOrDefault(x => x.Id == id);

GetAll()方法返回表中的所有记录。第一个按数据库顺序检索它们,第二个轮流检索任何查询过滤器。

public virtual IEnumerable<T> GetAll() => Table;
public virtual IEnumerable<T> GetAllIgnoreQueryFilters()
  => Table.IgnoreQueryFilters();

ExecuteQuery()方法用于执行存储过程:

public void ExecuteQuery(string sql, object[] sqlParametersObjects)
  => Context.Database.ExecuteSqlRaw(sql, sqlParametersObjects);

Add、Update 和 Delete 方法

要添加的下一个代码块包装了特定DbSet<T>属性上匹配的Add()Update()Remove()方法。persist参数决定了当调用Add() / Update() / Remove()存储库方法时,repo 是否立即执行SaveChanges()。所有的方法都被标记为virtual以允许下游覆盖。将以下代码添加到您的类中:

public virtual int Add(T entity, bool persist = true)
{
  Table.Add(entity);
  return persist ? SaveChanges() : 0;
}
public virtual int AddRange(IEnumerable<T> entities, bool persist = true)
{
  Table.AddRange(entities);
  return persist ? SaveChanges() : 0;
}
public virtual int Update(T entity, bool persist = true)
{
  Table.Update(entity);
  return persist ? SaveChanges() : 0;
}
public virtual int UpdateRange(IEnumerable<T> entities, bool persist = true)
{
  Table.UpdateRange(entities);
  return persist ? SaveChanges() : 0;
}
public virtual int Delete(T entity, bool persist = true)
{
  Table.Remove(entity);
  return persist ? SaveChanges() : 0;
}
public virtual int DeleteRange(IEnumerable<T> entities, bool persist = true)
{
  Table.RemoveRange(entities);
  return persist ? SaveChanges() : 0;
}

还有一个不遵循相同模式的Delete()方法。这种方法使用EntityState来执行删除操作,这在 ASP.NET 核心操作中经常使用,以减少网络流量。这里列出了:

public int Delete(int id, byte[] timeStamp, bool persist = true)
{
  var entity = new T {Id = id, TimeStamp = timeStamp};
  Context.Entry(entity).State = EntityState.Deleted;
  return persist ? SaveChanges() : 0;
}

这就结束了BaseRepo类,现在是时候构建特定于实体的回购协议了。

实体特定的回购接口

每个实体都有一个从BaseRepo<T>派生的强类型存储库和一个实现IRepo<T>的接口。在 AutoLot 中的Repos目录下添加一个名为Interfaces的新文件夹。Dal 项目。在这个新目录中,添加五个接口。

  • ICarRepo.cs

  • ICreditRiskRepo.cs

  • ICustomerRepo.cs

  • IMakelRepo.cs

  • IOrderRepo.cs

接下来的部分将完成这些界面。

汽车存储库接口

打开ICarRepo.cs界面。将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;

将界面更改为public和柠檬IRepo<Category>如下:

namespace AutoLot.Dal.Repos.Interfaces
{
  public interface ICarRepo : IRepo<Car>
  {
    IEnumerable<Car> GetAllBy(int makeId);
    string GetPetName(int id);
  }
}

信贷风险界面

打开ICreditRiskRepo.cs界面。除了在BaseRepo.中提供的功能之外,该接口不添加任何功能。将代码更新如下:

using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
namespace AutoLot.Dal.Repos.Interfaces
{
  public interface ICreditRiskRepo : IRepo<CreditRisk>
  {
  }
}

客户存储库界面

打开ICustomerRepo.cs界面。除了在BaseRepo.中提供的功能之外,该接口不添加任何功能。将代码更新如下:

using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
namespace AutoLot.Dal.Repos.Interfaces
{
  public interface ICustomerRepo : IRepo<Customer>
  {
  }
}

创建存储库接口

打开IMakeRepo.cs界面。除了在BaseRepo.中提供的功能之外,该接口不添加任何功能。将代码更新如下:

using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
namespace AutoLot.Dal.Repos.Interfaces
{
  public interface IMakeRepo : IRepo<Make>
  {
  }
}

订单存储库界面

打开IOrderRepo.cs界面。将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Linq;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Models.ViewModels;

将界面更改为public和柠檬IRepo<Order>如下:

namespace AutoLot.Dal.Repos.Interfaces
{
  public interface IOrderRepo : IRepo<Order>
  {
    IQueryable<CustomerOrderViewModel> GetOrdersViewModel();
  }
}

这就完成了接口,因为所有必需的 API 端点都包含在基类中。

实现特定于实体的存储库

实现的存储库从基类中获得大部分功能。本节介绍添加到基本存储库中或从基本存储库中覆盖的功能。在自动车床的Repos目录中。Dal 项目,添加五个回购类。

  • CarRepo.cs

  • CreditRiskRepo.cs

  • CustomerRepo.cs

  • MakeRepo.cs

  • OrderRepo.cs

接下来的部分完成了存储库。

汽车仓库

打开CarRepo.cs类并将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Data;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

将类改为public,继承BaseRepo<Car>,实现ICarRepo

namespace AutoLot.Dal.Repos
{
  public class CarRepo : BaseRepo<Car>, ICarRepo
  {
  }
}

每个存储库都必须实现来自BaseRepo的两个构造函数。

public CarRepo(ApplicationDbContext context) : base(context)
{
}
internal CarRepo(DbContextOptions<ApplicationDbContext> options) : base(options)
{
}

GetAll()GetAllIgnoreQueryFilters()添加覆盖以包含MakeNavigation属性,并按PetName值排序。

public override IEnumerable<Car> GetAll()
  => Table
            .Include(c => c.MakeNavigation)
            .OrderBy(o => o.PetName);

public override IEnumerable<Car> GetAllIgnoreQueryFilters()
  => Table
            .Include(c => c.MakeNavigation)
            .OrderBy(o => o.PetName)
            .IgnoreQueryFilters();

实现GetAllBy()方法。此方法必须在执行前对上下文设置查询过滤器。包括Make导航属性并按PetName值排序。

public IEnumerable<Car> GetAllBy(int makeId)
{
  return Table
    .Where(x => x.MakeId == makeId)
    .Include(c => c.MakeNavigation)
    .OrderBy(c => c.PetName);
}

Find()添加一个覆盖,以包含MakeNavigation属性并忽略查询过滤器。

public override Car? Find(int? id)
  => Table
        .IgnoreQueryFilters()
        .Where(x => x.Id == id)
        .Include(m => m.MakeNavigation)
        .FirstOrDefault();

添加使用存储过程获取汽车的PetName值的方法。

public string GetPetName(int id)
{
  var parameterId = new SqlParameter
  {
    ParameterName = "@carId",
    SqlDbType = SqlDbType.Int,
    Value = id,
  };

  var parameterName = new SqlParameter
  {
    ParameterName = "@petName",
    SqlDbType = SqlDbType.NVarChar,
    Size = 50,
    Direction = ParameterDirection.Output
  };

  _ = Context.Database
    .ExecuteSqlRaw("EXEC [dbo].[GetPetName] @carId, @petName OUTPUT",parameterId, parameterName);
  return (string)parameterName.Value;
}

信用风险库

打开CreditRiskRepo.cs类并将以下using语句添加到文件的顶部:

using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,从BaseRepo<CreditRisk>继承,实现ICreditRiskRepo,并添加两个必需的构造函数。

namespace AutoLot.Dal.Repos
{
  public class CreditRiskRepo : BaseRepo<CreditRisk>, ICreditRiskRepo
  {
    public CreditRiskRepo(ApplicationDbContext context) : base(context)
    {
    }
    internal CreditRiskRepo(
      DbContextOptions<ApplicationDbContext> options)
    : base(options)
    {
    }
  }
}

客户存储库

打开CustomerRepo.cs类并将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,从BaseRepo<Customer>继承,实现ICustomerRepo,并添加两个必需的构造函数。

namespace AutoLot.Dal.Repos
{
  public class CustomerRepo : BaseRepo<Customer>, ICustomerRepo
  {
    public CustomerRepo(ApplicationDbContext context)
      : base(context)
    {
    }
    internal CustomerRepo(
      DbContextOptions<ApplicationDbContext> options)
      : base(options)
    {
    }
  }
}

最后一步是添加方法,该方法返回所有按LastName排序的Customer记录。将以下方法添加到类中:

public override IEnumerable<Customer> GetAll()
  => Table
      .Include(c => c.Orders)
      .OrderBy(o => o.PersonalInformation.LastName);

制作存储库

打开MakeRepo.cs类并将以下using语句添加到文件的顶部:

using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,从BaseRepo<Make>继承,实现IMakeRepo,并添加两个必需的构造函数。

namespace AutoLot.Dal.Repos
{
  public class MakeRepo : BaseRepo<Make>, IMakeRepo
  {
    public MakeRepo(ApplicationDbContext context)
      : base(context)
    {
    }

    internal MakeRepo(
      DbContextOptions<ApplicationDbContext> options)
      : base(options)
    {
    }
  }
}

要覆盖的最后一个方法是GetAll()方法,按名称对Make值进行排序。

public override IEnumerable<Make> GetAll()
  => Table.OrderBy(m => m.Name);
public override IEnumerable<Make> GetAllIgnoreQueryFilters()
  => Table.IgnoreQueryFilters().OrderBy(m => m.Name);

订单存储库

打开OrderRepo.cs类并将以下using语句添加到文件的顶部:

using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Models.Entities;
using AutoLot.Dal.Repos.Base;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

将类改为public,继承BaseRepo<Order>,实现IOrderRepo

namespace AutoLot.Dal.Repos
{
  public class OrderRepo : BaseRepo<Order>, IOrderRepo
  {
    public OrderRepo(ApplicationDbContext context)
      : base(context)
    {
    }

    internal OrderRepo(
      DbContextOptions<ApplicationDbContext> options)
      : base(options)
    {
    }
  }
}

要实现的最后一个方法是GetOrderViewModel()方法,它从数据库视图返回一个IQueryable<CustomOrderViewModel>

public IQueryable<CustomerOrderViewModel> GetOrdersViewModel()
{
  return Context.CustomerOrderViewModels!.AsQueryable();
}

这就完成了所有的存储库。下一节将创建删除、创建和播种数据库的代码。

程序化数据库和迁移处理

DbContextDatabase属性提供了删除和创建数据库以及运行所有迁移的编程方法。表 23-1 描述了与这些操作相关的方法。

表 23-1。

以编程方式使用数据库

|

数据库成员

|

生命的意义

EnsureDeleted 如果数据库存在,则删除该数据库。如果不存在,则不执行任何操作。
Ensure-created 如果数据库不存在,则创建数据库。如果有也不做任何事。基于从DbSet<T>属性可到达的类创建表和列。不应用任何迁移。**注意:**这不应与迁移结合使用。
Migrate 如果数据库不存在,则创建数据库。将所有迁移应用于数据库。

如表中所述,如果数据库不存在,EnsureCreated()方法将创建数据库,然后基于实体模型创建表、列和索引。它不适用于任何迁移。如果您正在使用迁移(就像我们一样),这将在处理数据库时出现错误,并且您将不得不欺骗 EF Core(就像我们之前所做的那样)来相信迁移已经被应用。您还必须手动将任何自定义 SQL 对象应用到数据库。当您处理迁移时,总是使用Migrate()方法以编程方式创建数据库,而不是使用EnsureCreated()方法。

删除、创建和清理数据库

在开发过程中,删除并重新创建开发数据库,然后用示例数据为其播种可能是有益的。这就创造了一个环境,在这个环境中,测试(手动或自动的)可以被执行,而不用担心由于更改数据而破坏其他测试。在 AutoLot 中创建一个名为Initialization的新文件夹。Dal 项目。在这个文件夹中,创建一个名为SampleDataInitializer.cs的新类。在文件的顶部,将using语句更新为以下内容:

using System;
using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Models.Entities;
using AutoLot.Models.Entities.Base;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

如下所示创建类publicstatic:

namespace AutoLot.Dal.Initialization
{
  public static class SampleDataInitializer
  {
  }
}

创建一个名为DropAndCreateDatabase的方法,该方法将ApplicationDbContext的实例作为单个参数。该方法使用ApplicationDbContextDatabase属性首先删除数据库(使用EnsureDeleted()方法),然后创建数据库(使用Migrate()方法)。

public static void DropAndCreateDatabase(ApplicationDbContext context)
{
  context.Database.EnsureDeleted();
  context.Database.Migrate();
}

创建另一个名为ClearData()的方法,删除数据库中的所有数据,并重置每个表的主键的标识值。该方法遍历域实体列表,并使用DbContext Model属性获取每个实体映射到的模式和表名。然后,它执行一个delete语句,并使用DbContext Database属性上的ExecuteSqlRaw()方法重置每个表的标识。

internal static void ClearData(ApplicationDbContext context)
{
  var entities = new[]
  {
    typeof(Order).FullName,
    typeof(Customer).FullName,
    typeof(Car).FullName,
    typeof(Make).FullName,
    typeof(CreditRisk).FullName
  };
  foreach (var entityName in entities)
  {
    var entity = context.Model.FindEntityType(entityName);
    var tableName = entity.GetTableName();
    var schemaName = entity.GetSchema();
    context.Database.ExecuteSqlRaw($"DELETE FROM {schemaName}.{tableName}");
    context.Database.ExecuteSqlRaw($"DBCC CHECKIDENT (\"{schemaName}.{tableName}\", RESEED, 1);");
  }
}

Note

应该小心使用数据库外观的ExecuteSqlRaw()方法,以防止潜在的 SQL 注入攻击。

现在,您可以删除并创建数据库并清除数据,接下来是时候创建添加示例数据的方法了。

数据初始化

我们将构建自己的数据播种系统,可以按需运行。第一步是创建样本数据,然后将方法添加到用于将样本数据加载到数据库的SampleDataInitializer中。

创建示例数据

将名为SampleData.cs的新文件添加到Initialization文件夹中。创建publicstatic类,并将using语句更新如下:

using System.Collections.Generic;
using AutoLot.Dal.Entities;
using AutoLot.Dal.Entities.Owned;

namespace AutoLot.Dal.Initialization
{
  public static class SampleData
  {
  }
}

该文件由五个创建示例数据的静态方法组成。

public static List<Customer> Customers => new()
{
  new() {Id = 1, PersonalInformation = new() {FirstName = "Dave", LastName = "Brenner"}},
  new() {Id = 2, PersonalInformation = new() {FirstName = "Matt", LastName = "Walton"}},
  new() {Id = 3, PersonalInformation = new() {FirstName = "Steve", LastName = "Hagen"}},
  new() {Id = 4, PersonalInformation = new() {FirstName = "Pat", LastName = "Walton"}},
  new() {Id = 5, PersonalInformation = new() {FirstName = "Bad", LastName = "Customer"}},
};

public static List<Make> Makes => new()
{
  new() {Id = 1, Name = "VW"},
  new() {Id = 2, Name = "Ford"},
  new() {Id = 3, Name = "Saab"},
  new() {Id = 4, Name = "Yugo"},
  new() {Id = 5, Name = "BMW"},
  new() {Id = 6, Name = "Pinto"},
};

public static List<Car> Inventory => new()
{
  new() {Id = 1, MakeId = 1, Color = "Black", PetName = "Zippy"},
  new() {Id = 2, MakeId = 2, Color = "Rust", PetName = "Rusty"},
  new() {Id = 3, MakeId = 3, Color = "Black", PetName = "Mel"},
  new() {Id = 4, MakeId = 4, Color = "Yellow", PetName = "Clunker"},
  new() {Id = 5, MakeId = 5, Color = "Black", PetName = "Bimmer"},
  new() {Id = 6, MakeId = 5, Color = "Green", PetName = "Hank"},
  new() {Id = 7, MakeId = 5, Color = "Pink", PetName = "Pinky"},
  new() {Id = 8, MakeId = 6, Color = "Black", PetName = "Pete"},
  new() {Id = 9, MakeId = 4, Color = "Brown", PetName = "Brownie"},
  new() {Id = 10, MakeId = 1, Color = "Rust", PetName = "Lemon", IsDrivable = false},
};

public static List<Order> Orders => new()
{
  new() {Id = 1, CustomerId = 1, CarId = 5},
  new() {Id = 2, CustomerId = 2, CarId = 1},
  new() {Id = 3, CustomerId = 3, CarId = 4},
  new() {Id = 4, CustomerId = 4, CarId = 7},
  new() {Id = 5, CustomerId = 5, CarId = 10},
};

public static List<CreditRisk> CreditRisks => new()
{
  new()
  {
    Id = 1,
    CustomerId = Customers[4].Id,
    PersonalInformation = new()
    {
      FirstName = Customers[4].PersonalInformation.FirstName,
      LastName = Customers[4].PersonalInformation.LastName
    }
  }
};

加载示例数据

SampleDataInitializer类中的内部SeedData()方法将来自SampleData方法的数据添加到ApplicationDbContext的实例中,然后将数据保存到数据库中。

internal static void SeedData(ApplicationDbContext context)
{
  try
  {
    ProcessInsert(context, context.Customers!, SampleData.Customers);
    ProcessInsert(context, context.Makes!, SampleData.Makes);
    ProcessInsert(context, context.Cars!, SampleData.Inventory);
    ProcessInsert(context, context.Orders!, SampleData.Orders);
    ProcessInsert(context, context.CreditRisks!, SampleData.CreditRisks);
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex);
    //Set a break point here to determine what the issues is
    throw;
  }
  static void ProcessInsert<TEntity>(
    ApplicationDbContext context,
    DbSet<TEntity> table,
    List<TEntity> records) where TEntity : BaseEntity
  {
     if (table.Any())
     {
       return;
     }
    IExecutionStrategy strategy = context.Database.CreateExecutionStrategy();
    strategy.Execute(() =>
    {
      using var transaction = context.Database.BeginTransaction();
      try
      {
        var metaData = context.Model.FindEntityType(typeof(TEntity).FullName);
        context.Database.ExecuteSqlRaw(
            $"SET IDENTITY_INSERT {metaData.GetSchema()}.{metaData.GetTableName()} ON");
        table.AddRange(records);
        context.SaveChanges();
        context.Database.ExecuteSqlRaw(
            $"SET IDENTITY_INSERT {metaData.GetSchema()}.{metaData.GetTableName()} OFF");
        transaction.Commit();
      }
      catch (Exception)
      {
        transaction.Rollback();
      }
      });
  }
}

SeedData()方法使用一个本地函数来处理数据。它首先检查表中是否有记录,如果没有,就继续处理样本数据。从数据库外观创建一个ExecutionStrategy,它用于创建一个显式事务,这是打开和关闭身份插入所需要的。记录被添加,如果全部成功,事务被提交;否则,它将回滚。

这两个方法是公共的,用于重置数据库。InitializeData()在播种之前删除并重新创建数据库,而ClearDatabase()方法只是删除所有记录,重置标识,然后播种数据。

public static void InitializeData(ApplicationDbContext context)
{
  DropAndCreateDatabase(context);
  SeedData(context);
}

public static void ClearAndReseedDatabase(ApplicationDbContext context)
{
  ClearData(context);
  SeedData(context);
}

设置试驾

我们将使用自动化集成测试,而不是创建一个客户端应用来测试完整的AutoLot数据访问层。测试将演示对数据库的创建、读取、更新和删除调用。这允许我们检查代码,而不需要创建另一个应用。本节中的每个测试都将执行一个查询(创建、读取、更新或删除),然后使用一个或多个Assert语句来验证结果是否符合预期。

创建项目

首先,我们将使用 xUnit(一个. NET 核心兼容的测试框架)建立一个集成测试平台。首先添加一个名为 AutoLot.Dal.Tests 的新 xUnit 测试项目。网芯)。

Note

单元测试旨在测试单个代码单元。我们将在本章中所做的是从技术上创建集成测试,因为我们正在测试 C# 代码 EF 内核到数据库并返回。

从命令行界面,执行以下命令:

dotnet new xunit -lang c# -n AutoLot.Dal.Tests -o .\AutoLot.Dal.Tests -f net5.0
dotnet sln .\Chapter23_AllProjects.sln add AutoLot.Dal.Tests

将以下 NuGet 包添加到AutoLot.Dal.Tests项目中:

  • Microsoft.EntityFrameworkCore

  • Microsoft.EntityFrameworkCore.SqlServer

  • Microsoft.Extensions.Configuration.Json

因为 xUnit 项目模板附带的Microsoft.NET.Test.Sdk包的版本通常落后于当前可用的版本,所以使用 NuGet 包管理器来更新所有的 NuGet 包。接下来,添加对AutoLot.ModelsAutoLot.Dal的项目引用。

如果您正在使用 CLI,请执行以下命令(注意,这些命令会删除并重新添加Microsoft.NET.Test.Sdk以确保引用最新版本):

dotnet add AutoLot.Dal.Tests package Microsoft.EntityFrameworkCore
dotnet add AutoLot.Dal.Tests package Microsoft.EntityFrameworkCore.SqlServer
dotnet add AutoLot.Dal.Tests package Microsoft.Extensions.Configuration.Json
dotnet remove AutoLot.Dal.Tests package Microsoft.NET.Test.Sdk
dotnet add AutoLot.Dal.Tests package Microsoft.NET.Test.Sdk
dotnet add AutoLot.Dal.Tests reference AutoLot.Dal
dotnet add AutoLot.Dal.Tests reference AutoLot.Models

配置项目

为了在运行时检索连接字符串,我们将使用。使用 JSON 文件的. NET 核心配置功能。将一个名为appsettings.json的 JSON 文件添加到项目中,并将您的连接字符串信息添加到以下格式的文件中(根据需要更新此处列出的连接字符串):

{
  "ConnectionStrings": {
    "AutoLot": "server=.,5433;Database=AutoLotFinal;User Id=sa;Password=P@ssw0rd;"
  }
}

更新项目文件,以便在每次生成时将设置文件复制到输出文件夹中。通过将下面的ItemGroup添加到AutoLot.Dal.Tests.csproj文件中来实现:

  <ItemGroup>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
    </None>
  </ItemGroup>

创建测试助手

TestHelper类将处理应用配置,并创建一个新的ApplicationDbContext实例。在项目的根中添加一个名为TestHelpers.cs的新public static类。将using声明更新如下:

using System.IO;
using AutoLot.Dal.EfStructures;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;

namespace AutoLot.Dal.Tests
{
  public static class TestHelpers
  {
  }
}

添加两个公共静态方法来创建IConfigurationApplicationDbContext类的实例。将以下代码添加到类中:

public static IConfiguration GetConfiguration() =>
  new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", true, true)
    .Build();

public static ApplicationDbContext GetContext(IConfiguration configuration)
{
  var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
  var connectionString = configuration.GetConnectionString("AutoLot");
  optionsBuilder.UseSqlServer(connectionString, sqlOptions => sqlOptions.EnableRetryOnFailure());
  return new ApplicationDbContext(optionsBuilder.Options);
}

注意对EnableRetryOnFailure()的调用(粗体)。提醒一下,这选择了 SQL Server 重试执行策略,该策略将自动重试由于暂时性错误而失败的操作。

添加另一个静态方法,该方法将使用与传入的原始方法相同的连接和事务创建一个新的ApplicationDbContext实例。这个方法展示了如何从一个现有的实例创建一个ApplicationDbContext的实例来共享连接和事务。

public static ApplicationDbContext GetSecondContext(
  ApplicationDbContext oldContext,
  IDbContextTransaction trans)
{
  var optionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>();
  optionsBuilder.UseSqlServer(
    oldContext.Database.GetDbConnection(),
    sqlServerOptions => sqlServerOptions.EnableRetryOnFailure());
  var context = new ApplicationDbContext(optionsBuilder.Options);
  context.Database.UseTransaction(trans.GetDbTransaction());
  return context;
}

添加 BaseTest 类

现在向项目添加一个名为Base的新文件夹,并向该文件夹添加一个名为BaseTest.cs的新类文件。将using声明更新如下:

using System;
using System.Data;
using AutoLot.Dal.EfStructures;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Configuration;

使类抽象并实现IDisposable。添加两个受保护的readonly属性来保存IConfigurationApplicationDbContext实例,并在virtual Dispose()方法中释放ApplicationDbContext实例。

namespace AutoLot.Dal.Tests.Base
{
  public abstract class BaseTest : IDisposable
  {
    protected readonly IConfiguration Configuration;
    protected readonly ApplicationDbContext Context;

    public virtual void Dispose()
    {
      Context.Dispose();
    }
  }
}

xUnit 测试框架提供了一种机制,在执行每个测试的之前和之后运行代码。实现IDisposable接口的测试类(称为fixture*)将在每个测试运行之前执行类构造函数(在本例中是基类构造函数和派生类构造函数)中的代码(也称为测试设置),并且在每个测试运行之后运行Dispose方法(在派生类和基类中)中的代码(也称为测试拆除)。*

添加一个受保护的构造函数,该构造函数创建一个IConfiguration的实例,并将其赋给受保护的类变量。使用配置创建一个使用TestHelper类的ApplicationDbContext实例,并将其分配给protected类变量。

protected BaseTest()
{
  Configuration = TestHelpers.GetConfiguration();
  Context = TestHelpers.GetContext(Configuration);
}

添加翻译后的测试执行助手

BaseTest类中的最后两个方法支持在事务中运行测试方法。这些方法将把一个Action委托作为单个参数,创建一个显式事务(或登记一个现有的事务),执行Action委托,然后回滚事务。我们这样做是为了让任何创建/更新/删除测试都保持数据库在测试运行之前的状态。由于ApplicationDbContext被配置为启用瞬时错误重试,整个过程必须从ApplicationDbContext.的执行策略执行

ExecuteInATransaction()使用ApplicationDbContext的单个实例执行。ExecuteInASharedTransaction()方法允许多个ApplicationDbContext实例共享一个事务。在本章的后面你会学到更多关于这些方法的知识。现在,将下面的代码添加到您的BaseTest类中:

protected void ExecuteInATransaction(Action actionToExecute)
{
  var strategy = Context.Database.CreateExecutionStrategy();
  strategy.Execute(() =>
  {
    using var trans = Context.Database.BeginTransaction();
    actionToExecute();
    trans.Rollback();
  });
}

protected void ExecuteInASharedTransaction(Action<IDbContextTransaction> actionToExecute)
{
  var strategy = Context.Database.CreateExecutionStrategy();
  strategy.Execute(() =>
  {
    using IDbContextTransaction trans =
      Context.Database.BeginTransaction(IsolationLevel.ReadUncommitted);
    actionToExecute(trans);
    trans.Rollback();
  });
}

添加 EnsureAutoLotDatabase 测试夹具类

xUnit 测试框架提供了在任何测试运行之前(称为夹具设置)和所有测试运行之后(称为夹具拆除)运行代码的机制。通常不推荐这种做法,但是在我们的例子中,我们希望确保在运行任何测试之前创建数据库并加载数据,而不是在运行每个测试之前。实现IClassFixture<T> where T: TestFixtureClass的测试类将在任何测试运行之前执行T(TestFixtureClass)的构造器代码,而Dispose()代码将在所有测试完成之后运行。

将名为EnsureAutoLotDatabaseTestFixture.cs的新类添加到Base目录中,并实现IDisposable。制作publicsealed类,增加以下using语句:

using System;
using AutoLot.Dal.Initialization;

namespace AutoLot.Dal.Tests.Base
{
  public sealed class EnsureAutoLotDatabaseTestFixture : IDisposable
  {
  }
}

构造器代码创建一个IConfiguration的实例,然后使用IConfiguration实例创建一个ApplicationDbContext的实例。接下来,它从SampleDataInitializer.调用ClearAndReseedDatabase()方法,最后一行处理上下文实例。在我们的例子中,Dispose()方法没有任何工作要做(但是需要满足IDisposable接口)。下面的清单显示了构造函数和Dispose()方法:

public EnsureAutoLotDatabaseTestFixture()
{
  var configuration =  TestHelpers.GetConfiguration();
  var context = TestHelpers.GetContext(configuration);
  SampleDataInitializer.ClearAndReseedDatabase(context);
  context.Dispose();
}

public void Dispose()
{
}

添加集成测试类

下一步是添加保存自动化测试的类。这些类别被称为测试夹具。在AutoLot.Dal.Tests文件夹中添加一个名为IntegrationTests的新文件夹,并在该文件夹中添加四个名为CarTests.csCustomerTests.csMakeTests.csOrderTests.cs的文件。

根据测试运行程序的能力,xUnit 测试在一个测试设备(类)中串行运行,但是在所有测试设备(类)中并行运行。当执行与数据库交互的集成测试时,这可能会有问题,因为测试是与单个数据库交互的。通过将测试设备添加到同一个测试集合中,可以将执行更改为跨测试设备的串行执行。测试集合是使用类上的Collection属性按名称定义的。将下面的Collection属性添加到所有四个类的顶部:

[Collection("Integration Tests")]

接下来,从BaseTest继承并在两个类中实现IClassFixture接口。更新每个类的using语句,以匹配以下内容:

//CarTests.cs
using System.Collections.Generic;
using System.Linq;
using AutoLot.Dal.Exceptions;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Tests.Base;
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Storage;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests
{
  [Collection("Integation Tests")]
  public class CarTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

//CustomerTests.cs
using System.Collections.Generic;
using System;
using System.Linq;
using System.Linq.Expressions;
using AutoLot.Dal.Tests.Base;
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests

{
  [Collection("Integation Tests")]
  public class CustomerTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

//MakeTests.cs
using System.Linq;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Dal.Tests.Base;
using AutoLot.Models.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests
{
  [Collection("Integation Tests")]
  public class MakeTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

//OrderTests.cs
using System.Linq;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Dal.Tests.Base;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace AutoLot.Dal.Tests.IntegrationTests
{
  [Collection("Integation Tests")]
  public class OrderTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
  {
  }
}

对于MakeTests类,添加一个构造函数,创建一个MakeRepo的实例,并将该实例分配给一个private readonly类级别的变量。覆盖Dispose()方法,并在该方法中处置回购。

[Collection("Integration Tests")]
public class MakeTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
{
  private readonly IMakeRepo _repo;
  public MakeTests()
  {
    _repo = new MakeRepo(Context);
  }
  public override void Dispose()
  {
    _repo.Dispose();
  }
...
}

重复到OrderTests类,使用OrderRepo代替MakeRepo.

[Collection("Integration Tests")]
public class OrderTests : BaseTest, IClassFixture<EnsureAutoLotDatabaseTestFixture>
{
  private readonly IOrderRepo _repo;
  public OrderTests()
  {
    _repo = new OrderRepo(Context);
  }
  public override void Dispose()
  {
    _repo.Dispose();
  }
...
}

事实和理论测试方法

无参数测试方法被称为事实(并使用Fact属性)。接受参数的测试被称为理论(并使用Theory属性),并且可以运行多次迭代,将不同的值作为参数传递给测试方法。为了演示这些测试类型,在AutoLot.Dal.Tests项目中创建一个名为SampleTests.cs的新类。将using声明更新如下:

using Xunit;

namespace AutoLot.Dal.Tests
{
  public class SampleTests
  {
  }
}

要创建的第一个测试是一个Fact测试。对于Fact测试,所有值都包含在测试方法中。下面的例子测试了 3+2=5:

[Fact]
public void SimpleFactTest()
{
  Assert.Equal(5,3+2);
}

当使用Theory类型测试时,测试的值被传递到测试方法中。这些值可以来自InlineData属性、方法或类。出于我们的目的,我们将只使用InlineData属性。创建以下测试,为测试提供不同的加数和预期结果:

[Theory]
[InlineData(3,2,5)]
[InlineData(1,-1,0)]
public void SimpleTheoryTest(int addend1, int addend2, int expectedResult)
{
  Assert.Equal(expectedResult,addend1+addend2);
}

Note

有关 xUnit 测试框架的更多信息,请参考位于 https://xunit.net/ .的文档

执行测试

虽然 xUnit 测试可以从命令行执行(使用dotnet test),但是使用 Visual Studio 执行测试是更好的开发体验(在我看来)。从“测试”菜单启动测试资源管理器,以便能够运行和调试所有或选定的测试。

查询数据库

回想一下,从数据库数据创建实体实例通常涉及针对DbSet<T>属性执行 LINQ 语句。数据库提供商和 LINQ 翻译引擎将 LINQ 语句转换为 SQL,并从数据库中读取适当的数据。也可以使用原始 SQL 字符串通过FromSqlRaw()FromSqlInterpolated()方法加载数据。默认情况下,加载到DbSet<T>集合中的实体被添加到ChangeTracker中,但是可以在没有跟踪的情况下添加。无钥匙DbSet<T>收藏中加载的数据永远不会被跟踪。

如果相关实体已经加载到DbSet<T>中,EF Core 将沿着导航属性连接新的实例。例如,如果将Cars加载到DbSet<Car>集合中,然后将相关的Orders加载到同一个ApplicationDbContext实例的DbSet<Order>中,则Car.Orders导航属性将返回相关的Order实体,而不重新查询数据库。

这里演示的许多方法都有可用的异步版本。LINQ 查询的语法在结构上是相同的,所以我将只演示非同步版本。

实体状态

当通过从数据库中读取数据来创建实体时,EntityState值被设置为Unchanged

LINQ 询问

DbSet<T>集合类型实现了(在其他接口中)IQueryable<T>。这允许使用 C# LINQ 命令创建查询来从数据库中获取数据。虽然所有的 C# LINQ 语句都可以与DbSet<T>集合类型一起使用,但是一些 LINQ 语句可能不被数据库提供者支持,并且额外的 LINQ 语句由 EF Core 添加。除非语句是 LINQ 链的最后一条语句,否则无法翻译成数据库提供者的查询语言的不受支持的 LINQ 语句将引发运行时异常。如果不支持的 LINQ 语句是 LINQ 链中的最后一条语句,它将在客户端执行(在 C# 中)。

Note

这本书不是一个完整的 LINQ 参考,但只是显示了几个例子。为了获得更多 LINQ 查询的例子,微软在 https://code.msdn.microsoft.com/101-LINQ-Samples-3fb9811b 发布了 101 个 LINQ 样本。

LINQ 处决

提醒一下,当使用 LINQ 在数据库中查询实体列表时,只有在查询被迭代、转换为List<T>(或数组)或绑定到列表控件(如数据网格)时,才会执行查询。对于单记录查询,当单记录调用(First()Single()等)时,该语句立即执行。)被使用。

EF Core 5 中的新功能,您可以在大多数 LINQ 查询中调用ToQueryString()方法来检查针对数据库执行的查询。对于分割查询,ToQueryString()方法只返回将要执行的第一个查询。如果可以的话,下一节中的测试将一个变量(qs)设置为这个值,这样您就可以在调试测试的同时检查查询。

第一组测试(除非特别提到)在CustomerTests.cs类中。

获取所有记录

要获得一个表的所有记录,只需直接使用DbSet<T>属性,不需要任何 LINQ 语句。添加以下Fact:

[Fact]
public void ShouldGetAllOfTheCustomers()
{
  var qs = Context.Customers.ToQueryString();
  var customers = Context.Customers.ToList();
  Assert.Equal(5, customers.Count);
}

该语句被翻译成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]

相同的过程用于Keyless实体,如CustomerOrderViewModel,它被配置为从CustomerOrderView获取数据。

modelBuilder.Entity<CustomerOrderViewModel>().HasNoKey().ToView("CustomerOrderView", "dbo");

视图模型的DbSet<T>实例为键控实体提供了DbSet<T>的所有查询功能。区别在于更新能力。视图模型的更改不能持久化到数据库中,而键控实体可以。将下面的测试添加到OrderTest.cs类中,以显示从视图中获取数据:

public void ShouldGetAllViewModels()
{
  var qs = Context.Orders.ToQueryString();
  var orders = Context.Orders.ToList();
  Assert.NotEmpty(orders);
  Assert.Equal(5,orders.Count);
}

该语句被翻译成以下 SQL:

SELECT [c].[Color], [c].[FirstName], [c].[IsDrivable], [c].[LastName], [c].[Make], [c].[PetName]
FROM [dbo].[CustomerOrderView] AS [c]

过滤记录

Where()方法用于过滤来自DbSet<T>.的记录。多个Where()方法可以流畅地链接起来,以动态构建查询。链接的Where()方法总是被组合成and子句。要创建一个or语句,使用相同的Where()子句。

以下测试返回姓氏以 W 开头的客户(不区分大小写):

[Fact]
public void ShouldGetCustomersWithLastNameW()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Equal(2, customers.Count);
}

LINQ 查询被翻译成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%')

以下测试返回姓氏以 W (不区分大小写)开头,名字以 M (不区分大小写)开头的客户,并演示在 LINQ 查询中链接Where()方法:

[Fact]
public void ShouldGetCustomersWithLastNameWAndFirstNameM()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W"))
    .Where(x => x.PersonalInformation.FirstName.StartsWith("M"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Single(customers);
}

以下测试使用单个Where()方法返回姓氏以 W (不区分大小写)开头并且名字以 M (不区分大小写)开头的客户:

[Fact]
public void ShouldGetCustomersWithLastNameWAndFirstNameM()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W") &&
                           x.PersonalInformation.FirstName.StartsWith("M"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Single(customers);
}

这两个查询都被翻译成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%'))
AND ([c].[FirstName] IS NOT NULL AND ([c].[FirstName] LIKE N'M%'))

以下测试返回姓氏以 W (不区分大小写)开头的 H (不区分大小写)的客户:

[Fact]
public void ShouldGetCustomersWithLastNameWOrH()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => x.PersonalInformation.LastName.StartsWith("W") ||
                           x.PersonalInformation.LastName.StartsWith("H"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Equal(3, customers.Count);
}

这被转换成以下 SQL:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'W%'))
OR ([c].[LastName] IS NOT NULL AND ([c].[LastName] LIKE N'H%'))

以下测试返回姓氏以 W (不区分大小写)开头的客户,姓氏以 H (不区分大小写)开头。该测试演示了使用EF.Functions.Like()方法。注意,您必须自己包含通配符(%)。

[Fact]
public void ShouldGetCustomersWithLastNameWOrH()
{
  IQueryable<Customer> query = Context.Customers
    .Where(x => EF.Functions.Like(x.PersonalInformation.LastName, "W%") ||
                            EF.Functions.Like(x.PersonalInformation.LastName, "H%"));
  var qs = query.ToQueryString();
  List<Customer> customers = query.ToList();
  Assert.Equal(3, customers.Count);
}

这被转换成下面的 SQL(注意它不检查 null):

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE ([c].[LastName] LIKE N'W%') OR ([c].[LastName] LIKE N'H%')

下面在CarTests.cs类中的测试使用一个Theory来测试基于MakeIdInventory表中Car记录的数量(在“全局查询过滤器”一节中介绍了IgnoreQueryFilters()方法):

[Theory]
[InlineData(1, 2)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCarsByMake(int makeId, int expectedCount)
{
  IQueryable<Car> query =
    Context.Cars.IgnoreQueryFilters().Where(x => x.MakeId == makeId);
  var qs = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(expectedCount, cars.Count);
}

每一行InlineData都成为测试运行程序中的一个独特的测试。对于这个例子,处理了六个测试,并对数据库执行了六个查询。下面是其中一个测试的 SQL 语句(与其他测试的查询在Theory中的唯一区别是MakeId的值):

DECLARE @__makeId_0 int = 1;
SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE [i].[MakeId] = @__makeId_0

下面的Theory测试显示了一个带有CustomerOrderViewModel的过滤查询(将测试放在OrderTests.cs类中):

[Theory]
[InlineData("Black",2)]
[InlineData("Rust",1)]
[InlineData("Yellow",1)]
[InlineData("Green",0)]
[InlineData("Pink",1)]
[InlineData("Brown",0)]
public void ShouldGetAllViewModelsByColor(string color, int expectedCount)
{
    var query = _repo.GetOrdersViewModel().Where(x=>x.Color == color);
    var qs = query.ToQueryString();
    var orders = query.ToList();
    Assert.Equal(expectedCount,orders.Count);
}

第一个InlineData测试生成的查询如下所示:

DECLARE @__color_0 nvarchar(4000) = N'Black';
SELECT [c].[Color], [c].[FirstName], [c].[IsDrivable], [c].[LastName], [c].[Make], [c].[PetName]
FROM [dbo].[CustomerOrderView] AS [c]
WHERE [c].[Color] = @__color_0

排序记录

OrderBy()OrderByDescending()方法分别设置查询的排序,升序和降序。如果需要后续排序,使用ThenBy()ThenByDescending()方法。排序显示在以下测试中:

[Fact]
public void ShouldSortByLastNameThenFirstName()
{
  //Sort by Last name then first name
  var query = Context.Customers
    .OrderBy(x => x.PersonalInformation.LastName)
    .ThenBy(x => x.PersonalInformation.FirstName);
  var qs = query.ToQueryString();
  var customers = query.ToList();
  //if only one customer, nothing to test
  if (customers.Count <= 1) { return; }
  for (int x = 0; x < customers.Count - 1; x++)
  {
    var pi = customers[x].PersonalInformation;
    var pi2 = customers[x + 1].PersonalInformation;
    var compareLastName = string.Compare(pi.LastName,
        pi2.LastName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareLastName <= 0);
    if (compareLastName != 0) continue;
    var compareFirstName = string.Compare(pi.FirstName,
        pi2.FirstName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareFirstName <= 0);
  }
}

前面的 LINQ 查询被转换为以下内容:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName], [c].[FirstName]

反向排序记录

Reverse()方法颠倒了整个排序顺序,如下一个测试所示:

[Fact]
public void ShouldSortByFirstNameThenLastNameUsingReverse()
{
  //Sort by Last name then first name then reverse the sort
  var query = Context.Customers
    .OrderBy(x => x.PersonalInformation.LastName)
    .ThenBy(x => x.PersonalInformation.FirstName)
    .Reverse();
  var qs = query.ToQueryString();
  var customers = query.ToList();
  //if only one customer, nothing to test
  if (customers.Count <= 1) { return; }

  for (int x = 0; x < customers.Count - 1; x++)
  {
    var pi1 = customers[x].PersonalInformation;
    var pi2 = customers[x + 1].PersonalInformation;
    var compareLastName = string.Compare(pi1.LastName,
    pi2.LastName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareLastName >= 0);
    if (compareLastName != 0) continue;
    var compareFirstName = string.Compare(pi1.FirstName,
    pi2.FirstName, StringComparison.CurrentCultureIgnoreCase);
    Assert.True(compareFirstName >= 0);
  }
}

前面的 LINQ 查询被转换为以下内容:

SELECT [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName] DESC, [c].[FirstName] DESC

检索单个记录

查询返回单个记录主要有三种方法:First() / FirstOrDefault()Last() / LastOrDefault()Single() / SingleOrDefault()。虽然这三种方法都返回一条记录,但它们的方法都不同。下面详细介绍了这三种方法及其变体:

  • First()返回匹配查询条件和任何排序子句的第一条记录。如果没有指定顺序,则返回的记录基于数据库顺序。如果没有记录返回,将引发异常。

  • 除了如果没有记录匹配查询之外,FirstOrDefault()行为匹配First(),该方法返回类型的默认值(null)。

  • Single()返回匹配查询条件和任何排序子句的第一条记录。如果没有指定顺序,则返回的记录基于数据库顺序。如果没有记录或有多条记录与查询匹配,则会引发异常。

  • 除了如果没有记录匹配查询之外,SingleOrDefault()行为匹配Single(),该方法返回类型的默认值(null)。

  • Last()返回匹配查询条件和任何排序子句的最后一条记录。如果没有指定顺序,则会引发异常。如果没有记录返回,将引发异常。

  • 除了如果没有记录匹配查询之外,LastOrDefault()行为匹配Last(),该方法返回类型的默认值(null)。

所有方法还可以使用一个Expression<Func<T, bool>>(一个 lambda)来过滤结果集。这意味着您可以将Where()表达式放在对First() / Single()方法的调用中。以下语句是等效的:

Context.Customers.Where(c=>c.Id < 5).First();
Context.Customers.First(c=>c.Id < 5);

由于直接执行单记录 LINQ 语句,ToQueryString()方法不可用。列出的查询翻译是使用 SQL Server Profiler 提供的。

首先使用

当使用无参数形式的First()FirstOrDefault()时,将返回第一条记录(基于数据库顺序或任何前面的排序子句)。

以下测试根据数据库顺序获取第一条记录:

[Fact]
public void GetFirstMatchingRecordDatabaseOrder()
{
  //Gets the first record, database order
  var customer = Context.Customers.First();
  Assert.Equal(1, customer.Id);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]

以下测试根据“姓,名”的顺序获取第一条记录:

[Fact]
public void GetFirstMatchingRecordNameOrder()
{
  //Gets the first record, lastname, first name order
  var customer = Context.Customers
      .OrderBy(x => x.PersonalInformation.LastName)
      .ThenBy(x => x.PersonalInformation.FirstName)
      .First();
  Assert.Equal(1, customer.Id);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName], [c].[FirstName]

下面的测试断言,如果使用First()时没有匹配,就会抛出一个异常:

[Fact]
public void FirstShouldThrowExceptionIfNoneMatch()
{
  //Filters based on Id. Throws due to no match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.First(x => x.Id == 10));
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

Note

Assert.Throws()是一种特殊类型的断言语句。它需要由表达式中的代码引发的异常。如果异常没有被抛出,断言失败。

当使用FirstOrDefault()时,当没有数据返回时,结果不是一个异常,而是一个空记录。

[Fact]
public void FirstOrDefaultShouldReturnDefaultIfNoneMatch()
{
  //Expression<Func<Customer>> is a lambda expression
  Expression<Func<Customer, bool>> expression = x => x.Id == 10;
  //Returns null when nothing is found
  var customer = Context.Customers.FirstOrDefault(expression);
  Assert.Null(customer);
}

前面的 LINQ 查询被翻译成与前面相同的形式:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

使用最后

当使用无参数形式的Last()LastOrDefault()时,将返回最后一条记录(基于任何前面的排序子句)。

以下测试根据“姓,名”的顺序获取最后一条记录:

[Fact]
public void GetLastMatchingRecordNameOrder()
{
  //Gets the last record, lastname desc, first name desc order
  var customer = Context.Customers
      .OrderBy(x => x.PersonalInformation.LastName)
      .ThenBy(x => x.PersonalInformation.FirstName)
      .Last();
  Assert.Equal(4, customer.Id);
}

EF 内核反转order by语句,然后取top(1)得到结果。以下是执行的查询:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
ORDER BY [c].[LastName] DESC, [c].[FirstName] DESC

使用单

从概念上讲,Single() / SingleOrDefault()First() / FirstOrDefault().的工作原理相同,主要区别在于Single() / SingleOrDefault()返回Top(2)而不是Top(1),如果从数据库返回两条记录,则抛出异常。

以下测试检索单个记录,其中Id == 1:

[Fact]
public void GetOneMatchingRecordWithSingle()
{
  //Gets the first record, database order
  var customer = Context.Customers.Single(x => x.Id == 1);
  Assert.Equal(1, customer.Id);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 1

如果没有记录返回,抛出异常。

[Fact]
public void SingleShouldThrowExceptionIfNoneMatch()
{
  //Filters based on Id. Throws due to no match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.Single(x => x.Id == 10));
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

当使用Single()SingleOrDefault()并且返回多条记录时,会抛出异常。

[Fact]
public void SingleShouldThrowExceptionIfMoreThenOneMatch()
{
  // Throws due to more than one match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.Single());
}
[Fact]
public void SingleOrDefaultShouldThrowExceptionIfMoreThenOneMatch()
{
  // Throws due to more than one match
  Assert.Throws<InvalidOperationException>(() => Context.Customers.SingleOrDefault());
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(2) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]

当使用SingleOrDefault()时,当没有数据返回时,结果不是一个异常,而是一个空记录。

[Fact]
public void SingleOrDefaultShouldReturnDefaultIfNoneMatch()
{
  //Expression<Func<Customer>> is a lambda expression
  Expression<Func<Customer, bool>> expression = x => x.Id == 10;
  //Returns null when nothing is found
  var customer = Context.Customers.SingleOrDefault(expression);
  Assert.Null(customer);
}

前面的 LINQ 查询被转换为以下内容:

SELECT TOP(1) [c].[Id], [c].[TimeStamp], [c].[FirstName], [c].[FullName], [c].[LastName]
FROM [Dbo].[Customers] AS [c]
WHERE [c].[Id] = 10

全局查询过滤器

回想一下,Car实体上有一个全局查询过滤器,用于过滤掉任何IsDrivable为假的汽车。

modelBuilder.Entity<Car>(entity =>
{
  entity.HasQueryFilter(c => c.IsDrivable);
...
});

打开CarTests.cs类并添加下面的测试(除非特别提到,否则下一节中的所有测试都在CarTests.cs类中):

[Fact]
public void ShouldReturnDrivableCarsWithQueryFilterSet()
{
  IQueryable<Car> query = Context.Cars;
  var qs = query.ToQueryString();
  var cars = query.ToList();
  Assert.NotEmpty(cars);
  Assert.Equal(9, cars.Count);
}

同样,回想一下,我们在数据初始化过程中创建了 10 辆汽车,其中一辆被设置为不可驾驶。执行查询时,将应用全局查询过滤器,并执行以下 SQL:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

Note

当加载相关实体以及使用FromSqlRaw()FromSqlInterpolated()时,也会应用全局查询过滤器。这些将很快涵盖。

禁用查询过滤器

要禁用查询中实体的全局查询过滤器,请将IgnoreQueryFilters()方法添加到 LINQ 查询中。这将禁用查询中所有实体的所有过滤器。如果有多个实体具有全局查询过滤器,并且需要一些实体的过滤器,则必须将它们添加到 LINQ 语句的Where()方法中。

将下面的测试添加到CarTests.cs类,这将禁用查询过滤器并返回所有记录:

[Fact]
public void ShouldGetAllOfTheCars()
{
  IQueryable<Car> query = Context.Cars.IgnoreQueryFilters();
  var qs = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(10, cars.Count);
}

正如所料,消除不可驾驶汽车的where子句不再出现在生成的 SQL 中。

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]

导航属性上的查询过滤器

除了对Car实体的全局查询过滤器之外,我们还向Order实体的CarNavigation属性添加了一个查询过滤器。

modelBuilder.Entity<Order>().HasQueryFilter(e => e.CarNavigation!.IsDrivable);

要查看这一点,请将下面的测试添加到OrderTests.cs类中:

[Fact]
public void ShouldGetAllOrdersExceptFiltered()
{
    var query = Context.Orders.AsQueryable();
    var qs = query.ToQueryString();
    var orders = query.ToList();
    Assert.NotEmpty(orders);
    Assert.Equal(4,orders.Count);
}

下面列出了生成的 SQL:

SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]
FROM [Dbo].[Orders] AS [o]
INNER JOIN (
    SELECT [i].[Id], [i].[IsDrivable]
    FROM [dbo].[Inventory] AS [i]
    WHERE [i].[IsDrivable] = CAST(1 AS bit)\r\n) AS [t] ON [o].[CarId] = [t].[Id]
WHERE [t].[IsDrivable] = CAST(1 AS bit)

因为CarNavigation导航属性是一个必需的导航属性,所以查询翻译引擎使用一个INNER JOIN,消除了Car不可驱动的Order记录。

要返回所有记录,将IgnoreQueryFilters()添加到您的 LINQ 查询中。

急切地加载相关数据

正如上一章所讨论的,通过导航属性链接的实体可以在一个查询中使用快速加载进行实例化。Include()方法表示对相关实体的连接,ThenInclude()方法用于后续的连接。这两种方法都将在这些测试中演示。如前所述,当Include() / ThenInclude()方法被翻译成 SQL 时,必需的关系使用内部连接,可选的关系使用左连接。

将以下测试添加到CarTests.cs类中,以显示单个Include():

[Fact]
public void ShouldGetAllOfTheCarsWithMakes()
{
  IIncludableQueryable<Car, Make?> query =
  Context.Cars.Include(c => c.MakeNavigation);
  var queryString = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(9, cars.Count);
}

该测试将MakeNavigation属性添加到结果中,通过执行下面的 SQL 来执行内部连接。请注意,全局查询过滤器已经生效。

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp],
    [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

第二个测试使用两组相关数据。第一个是获取Make信息(与前面的测试相同),而第二个是获取Orders,然后是附加到Orders.Customers,整个测试还过滤掉了有任何订单的Car记录。可选关系生成左连接。

[Fact]
public void ShouldGetCarsOnOrderWithRelatedProperties()
{
  IIncludableQueryable<Car, Customer?> query = Context.Cars
    .Where(c => c.Orders.Any())
    .Include(c => c.MakeNavigation)
    .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation);
  var queryString = query.ToQueryString();
  var cars = query.ToList();
  Assert.Equal(4, cars.Count);
  cars.ForEach(c =>
  {
    Assert.NotNull(c.MakeNavigation);
    Assert.NotNull(c.Orders.ToList()[0].CustomerNavigation);
  });
}

下面是生成的查询:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp],
    [m].[Id], [m].[Name], [m].[TimeStamp], [t0].[Id], [t0].[CarId], [t0].[CustomerId],
    [t0].[TimeStamp], [t0].[Id0], [t0].[TimeStamp0], [t0].[FirstName], [t0].[FullName],
    [t0].[LastName], [t0].[Id1]
FROM [dbo].[Inventory] AS [i]
     INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId]=[m].[Id]
     LEFT JOIN(SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp],
        [c].[Id] AS [Id0], [c].[TimeStamp] AS [TimeStamp0], [c].[FirstName], [c].[FullName],
        [c].[LastName], [t].[Id] AS [Id1]
               FROM [dbo].[Orders] AS [o]
                    INNER JOIN(SELECT [i0].[Id], [i0].[IsDrivable]
                               FROM [dbo].[Inventory] AS [i0]
                               WHERE [i0].[IsDrivable]=CAST(1 AS BIT)) AS [t] ON [o].[CarId]=[t].[Id]
                    INNER JOIN [dbo].[Customers] AS [c] ON [o].[CustomerId]=[c].[Id]
               WHERE [t].[IsDrivable]=CAST(1 AS BIT)) AS [t0] ON [i].[Id]=[t0].[CarId]
WHERE([i].[IsDrivable]=CAST(1 AS BIT))AND EXISTS (SELECT 1
                                                  FROM [dbo].[Orders] AS [o0]
                                                       INNER JOIN(SELECT [i1].[Id], [i1].[Color], [i1].[IsDrivable],
                                                                                  [i1].[MakeId], [i1].[PetName], [i1].[TimeStamp]
                                                                  FROM [dbo].[Inventory] AS [i1]
                                                                  WHERE [i1].[IsDrivable]=CAST(1 AS BIT)) AS [t1] ON [o0].[CarId]=[t1].[Id]
                                                  WHERE([t1].[IsDrivable]=CAST(1 AS BIT))AND([i].[Id]=[o0].[CarId]))
ORDER BY [i].[Id], [m].[Id], [t0].[Id], [t0].[Id1], [t0].[Id0];

拆分对相关数据的查询

LINQ 查询中添加的连接越多,生成的查询就越复杂。EF Core 5 的新特性是能够将复杂的连接作为分割查询运行。参考前一章的完整讨论,但是总结一下,将AsSplitQuery()方法添加到 LINQ 查询中指示 EF Core 将对数据库的调用分成多个调用。这可以在数据不一致的风险下提高效率。将以下测试添加到您的测试夹具中:

[Fact]
public void ShouldGetCarsOnOrderWithRelatedPropertiesAsSplitQuery()
{
  IQueryable<Car> query = Context.Cars.Where(c => c.Orders.Any())
    .Include(c => c.MakeNavigation)
    .Include(c => c.Orders).ThenInclude(o => o.CustomerNavigation)
    .AsSplitQuery();
  var cars = query.ToList();
  Assert.Equal(4, cars.Count);
  cars.ForEach(c =>
  {
    Assert.NotNull(c.MakeNavigation);
    Assert.NotNull(c.Orders.ToList()[0].CustomerNavigation);
  });
}

ToQueryString()方法只返回第一个查询,因此下面的查询是使用 SQL Server Profiler 捕获的:

SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp], [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND EXISTS (
    SELECT 1
    FROM [Dbo].[Orders] AS [o]
    INNER JOIN (
        SELECT [i0].[Id], [i0].[Color], [i0].[IsDrivable], [i0].[MakeId], [i0].[PetName], [i0].[TimeStamp]
        FROM [dbo].[Inventory] AS [i0]
        WHERE [i0].[IsDrivable] = CAST(1 AS bit)
    ) AS [t] ON [o].[CarId] = [t].[Id]
    WHERE ([t].[IsDrivable] = CAST(1 AS bit)) AND ([i].[Id] = [o].[CarId]))
ORDER BY [i].[Id], [m].[Id]

SELECT [t0].[Id], [t0].[CarId], [t0].[CustomerId], [t0].[TimeStamp], [t0].[Id1], [t0].[TimeStamp1], [t0].[FirstName], [t0].[FullName], [t0].[LastName], [i].[Id], [m].[Id]
FROM [dbo].[Inventory] AS [i]
INNER JOIN [dbo].[Makes] AS [m] ON [i].[MakeId] = [m].[Id]
INNER JOIN (
    SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp], [c].[Id] AS [Id1], [c].[TimeStamp] AS [TimeStamp1], [c].[FirstName], [c].[FullName], [c].[LastName]
    FROM [Dbo].[Orders] AS [o]
    INNER JOIN (
        SELECT [i0].[Id], [i0].[IsDrivable]
        FROM [dbo].[Inventory] AS [i0]
        WHERE [i0].[IsDrivable] = CAST(1 AS bit)
    ) AS [t] ON [o].[CarId] = [t].[Id]
    INNER JOIN [Dbo].[Customers] AS [c] ON [o].[CustomerId] = [c].[Id]
    WHERE [t].[IsDrivable] = CAST(1 AS bit)
) AS [t0] ON [i].[Id] = [t0].[CarId]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND EXISTS (
    SELECT 1
    FROM [Dbo].[Orders] AS [o0]
    INNER JOIN (
        SELECT [i1].[Id], [i1].[Color], [i1].[IsDrivable], [i1].[MakeId], [i1].[PetName], [i1].[TimeStamp]
        FROM [dbo].[Inventory] AS [i1]
        WHERE [i1].[IsDrivable] = CAST(1 AS bit)
    ) AS [t1] ON [o0].[CarId] = [t1].[Id]
    WHERE ([t1].[IsDrivable] = CAST(1 AS bit)) AND ([i].[Id] = [o0].[CarId]))
ORDER BY [i].[Id], [m].[Id]

是否拆分查询取决于您的业务需求。

过滤相关数据

EF Core 5 在包含集合属性时引入了过滤功能。在 EF Core 5 之前,获取集合导航属性的过滤列表的唯一方法是使用显式加载。将下面的测试添加到MakeTests.cs类中,它演示了如何获取所有的Make记录,这些汽车都是黄色的:

[Fact]
public void ShouldGetAllMakesAndCarsThatAreYellow()
{
  var query = Context.Makes.IgnoreQueryFilters()
      .Include(x => x.Cars.Where(x => x.Color == "Yellow"));
  var qs = query.ToQueryString();
  var makes = query.ToList();
  Assert.NotNull(makes);
  Assert.NotEmpty(makes);
  Assert.NotEmpty(makes.Where(x => x.Cars.Any()));
  Assert.Empty(makes.First(m => m.Id == 1).Cars);
  Assert.Empty(makes.First(m => m.Id == 2).Cars);
  Assert.Empty(makes.First(m => m.Id == 3).Cars);
  Assert.Single(makes.First(m => m.Id == 4).Cars);
  Assert.Empty(makes.First(m => m.Id == 5).Cars);
}

生成的 SQL 如下所示:

SELECT [m].[Id], [m].[Name], [m].[TimeStamp], [t].[Id], [t].[Color], [t].[IsDrivable],
  [t].[MakeId], [t].[PetName], [t].[TimeStamp]
FROM [dbo].[Makes] AS [m]
LEFT JOIN (
     SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
     FROM [dbo].[Inventory] AS [i]
     WHERE [i].[Color] = N'Yellow') AS [t] ON [m].[Id] = [t].[MakeId]
ORDER BY [m].[Id], [t].[Id]

将查询更改为拆分查询会生成以下 SQL(来自 SQL Server Profiler 的集合):

SELECT [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Makes] AS [m]
ORDER BY [m].[Id]

SELECT [t].[Id], [t].[Color], [t].[IsDrivable], [t].[MakeId], [t].[PetName], [t].[TimeStamp], [m].[Id]
FROM [dbo].[Makes] AS [m]
INNER JOIN (
    SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
    FROM [dbo].[Inventory] AS [i]
    WHERE [i].[Color] = N'Yellow'
) AS [t] ON [m].[Id] = [t].[MakeId]
ORDER BY [m].[Id]

显式加载相关数据

如果需要在将主体实体查询到内存中之后加载相关数据,可以通过后续的数据库调用从数据库中检索相关实体。这是使用派生的DbContext上的Entry()方法触发的。当在一对多关系的多端加载实体时,对Entry结果使用Collection()方法。要加载一对多(或一对一关系)一端的实体,使用Reference()方法。在Collection()Reference()方法上调用Query()会返回一个IQueryable<T>,它可用于获取查询字符串(如下面的测试所示)和管理查询过滤器(如下一节所示)。要执行查询并加载记录,请在Collection()Reference()Query()方法上调用Load()方法。当调用Load()时,查询立即执行。

下面的测试(回到CarTests.cs类)展示了如何在Car实体上加载一个引用导航属性:

[Fact]
public void ShouldGetReferenceRelatedInformationExplicitly()
{
  var car = Context.Cars.First(x => x.Id == 1);
  Assert.Null(car.MakeNavigation);
  var query = Context.Entry(car).Reference(c => c.MakeNavigation).Query();
  var qs = query.ToQueryString();
  query.Load();
  Assert.NotNull(car.MakeNavigation);
}

生成的 SQL 如下所示:

DECLARE @__p_0 int = 1;
SELECT [m].[Id], [m].[Name], [m].[TimeStamp]
FROM [dbo].[Makes] AS [m]
WHERE [m].[Id] = @__p_0

该测试显示了如何在Car实体上加载集合导航属性:

[Fact]
public void ShouldGetCollectionRelatedInformationExplicitly()
{
  var car = Context.Cars.First(x => x.Id == 1);
  Assert.Empty(car.Orders);
  var query = Context.Entry(car).Collection(c => c.Orders).Query();
  var qs = query.ToQueryString();
  query.Load();
  Assert.Single(car.Orders);
}

生成的 SQL 如下所示:

DECLARE @__p_0 int = 1;
SELECT [o].[Id], [o].[CarId], [o].[CustomerId], [o].[TimeStamp]
FROM [Dbo].[Orders] AS [o]
INNER JOIN (
    SELECT [i].[Id], [i].[IsDrivable]
    FROM [dbo].[Inventory] AS [i]
    WHERE [i].[IsDrivable] = CAST(1 AS bit)
) AS [t] ON [o].[CarId] = [t].[Id]
WHERE ([t].[IsDrivable] = CAST(1 AS bit)) AND ([o].[CarId] = @__p_0)

使用查询过滤器显式加载相关数据

除了调整急切加载相关数据时生成的查询之外,全局查询过滤器在显式加载相关数据时是活动的。参加以下测试(在MakeTests.cs类中):

[Theory]
[InlineData(1,1)]
[InlineData(2,1)]
[InlineData(3,1)]
[InlineData(4,2)]
[InlineData(5,3)]
[InlineData(6,1)]
public void ShouldGetAllCarsForAMakeExplicitlyWithQueryFilters(int makeId, int carCount)
{
  var make = Context.Makes.First(x => x.Id == makeId);
  IQueryable<Car> query = Context.Entry(make).Collection(c => c.Cars).Query();
  var qs = query.ToQueryString();
  query.Load();
  Assert.Equal(carCount,make.Cars.Count());
}

该测试类似于“过滤记录”部分的ShouldGetTheCarsByMake()。然而,测试不是只获取具有某个MakeIdCar记录,而是首先获取一个Make记录,然后显式地为内存中的Make记录加载Car记录。生成的查询如下所示:

DECLARE @__p_0 int = 5;
SELECT [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__p_0)

请注意,查询过滤器仍在使用,尽管查询中的主体实体是Make记录。要在显式加载记录时关闭查询过滤器,请结合使用IgnoreQueryFilters()Query()方法。下面是关闭查询过滤器的测试(还是在MakeTests.cs类中):

[Theory]
[InlineData(1, 2)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetAllCarsForAMakeExplicitly(int makeId, int carCount)
{
  var make = Context.Makes.First(x => x.Id == makeId);
  IQueryable<Car> query =
    Context.Entry(make).Collection(c => c.Cars).Query().IgnoreQueryFilters();
  var qs = query.IgnoreQueryFilters().ToQueryString();
  query.Load();
  Assert.Equal(carCount, make.Cars.Count());
}

使用 LINQ 的 SQL 查询

如果特定查询的 LINQ 语句过于复杂,或者测试显示性能低于预期,可以使用原始 SQL 语句,使用DbSet<T>FromSqlRaw()FromSqlInterpolated()方法来检索数据。SQL 语句可以是内联 T-SQL select 语句、存储过程或表值函数。如果查询是开放查询(例如,没有终止分号的 T-SQL 语句),那么可以将 LINQ 语句添加到FromSqlRaw() / FromSqlInterpolated()方法中,以进一步定义生成的查询。整个查询在服务器端执行,将 SQL 语句与从 LINQ 语句生成的 SQL 结合起来。

如果语句被终止或包含无法构建的 SQL(例如,使用公共表表达式),该查询仍在服务器端执行,但任何附加的过滤和处理必须在客户端作为对象的 LINQ 来完成。

FromSqlRaw()完全按照输入的内容执行查询。FromSqlInterpolated()使用 C# 字符串插值,然后将插值转换为参数。下面的测试(在CarTests.cs类中)展示了使用这两种方法的例子,有和没有全局查询过滤器:

[Fact]
public void ShouldNotGetTheLemonsUsingFromSql()
{
    var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");
    var tableName = entity.GetTableName();
    var schemaName = entity.GetSchema();
    var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}").ToList();
    Assert.Equal(9, cars.Count);
}

[Fact]
public void ShouldGetTheCarsUsingFromSqlWithIgnoreQueryFilters()
{
    var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");
    var tableName = entity.GetTableName();
    var schemaName = entity.GetSchema();
    var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")
        .IgnoreQueryFilters().ToList();
    Assert.Equal(10, cars.Count);
}

[Fact]
public void ShouldGetOneCarUsingInterpolation()
{
    var carId = 1;
    var car = Context.Cars
        .FromSqlInterpolated($"Select * from dbo.Inventory where Id = {carId}")
        .Include(x => x.MakeNavigation)
        .First();
    Assert.Equal("Black", car.Color);
    Assert.Equal("VW", car.MakeNavigation.Name);
}

[Theory]
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCarsByMakeUsingFromSql(int makeId, int expectedCount)
{
  var entity = Context.Model.FindEntityType($"{typeof(Car).FullName}");
  var tableName = entity.GetTableName();
  var schemaName = entity.GetSchema();
  var cars = Context.Cars.FromSqlRaw($"Select * from {schemaName}.{tableName}")
    .Where(x => x.MakeId == makeId).ToList();
  Assert.Equal(expectedCount, cars.Count);
}

使用FromSqlRaw() / FromSqlInterpolated()方法时有一些规则:SQL 语句返回的列必须与模型上映射的列相匹配,必须返回模型的所有列,不能返回相关数据。

聚合方法

EF Core 还支持服务器端聚合方法(Max()Min()Count()Average(),等)。).可以使用Where()方法将聚合方法添加到 LINQ 查询的末尾,或者过滤表达式可以包含在聚合方法本身中(就像First()Single())。聚合在服务器端执行,并且从查询中返回单个值。全局查询过滤器也影响聚合方法,可以用IgnoreQueryFilters()禁用。

本节中显示的所有 SQL 语句都是使用 SQL Server Profiler 收集的。

第一个测试(在CarTests.cs)中)只是统计数据库中所有的Car记录。由于查询过滤器仍然处于活动状态,因此计数返回 9 辆汽车。

[Fact]
public void ShouldGetTheCountOfCars()
{
  var count = Context.Cars.Count();
  Assert.Equal(9, count);
}

执行的 SQL 如下所示:

SELECT COUNT(*)
FROM [dbo].[Inventory] AS [i]
WHERE [i].[IsDrivable] = CAST(1 AS bit)

通过添加IgnoreQueryFilters(),Count()方法返回 10,并且从 SQL 查询中删除了where子句。

[Fact]
public void ShouldGetTheCountOfCarsIgnoreQueryFilters()
{
  var count = Context.Cars.IgnoreQueryFilters().Count();
  Assert.Equal(10, count);
}

--Generated SQL
SELECT COUNT(*) FROM [dbo].[Inventory] AS [i]

以下测试(也在CarTests.cs中)演示了带有where条件的Count()方法。第一个测试将表达式直接添加到Count()方法中,第二个测试将Count()方法添加到 LINQ 语句的末尾。

[Theory]
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCountOfCarsByMakeP1(int makeId, int expectedCount)
{
    var count = Context.Cars.Count(x=>x.MakeId == makeId);
    Assert.Equal(expectedCount, count);
}

[Theory]
[InlineData(1, 1)]
[InlineData(2, 1)]
[InlineData(3, 1)]
[InlineData(4, 2)]
[InlineData(5, 3)]
[InlineData(6, 1)]
public void ShouldGetTheCountOfCarsByMakeP2(int makeId, int expectedCount)
{
    var count = Context.Cars.Where(x => x.MakeId == makeId).Count();
    Assert.Equal(expectedCount, count);
}

两个测试都创建了相同的对服务器的 SQL 调用,如下所示(MakeId随着基于InlineData的每个测试而变化):

exec sp_executesql N'SELECT COUNT(*)
FROM [dbo].[Inventory] AS [i]
WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__makeId_0)'
,N'@__makeId_0 int',@__makeId_0=6

任意()和全部()

Any()All()方法检查一组记录,查看是否有任何记录符合标准(Any())或者是否所有记录都符合标准(All())。就像聚合方法一样,它们可以用Where()方法添加到 LINQ 查询的末尾,或者过滤表达式可以包含在方法本身中。Any()All()方法在服务器端执行,查询返回一个布尔值。全局查询过滤器也影响Any()All()方法函数,并且可以用IgnoreQueryFilters()禁用。

本节中显示的所有 SQL 语句都是使用 SQL Server Profiler 收集的。

第一个测试(在CarTests.cs)中)检查的任何汽车记录是否有特定的MakeId

[Theory]
[InlineData(1, true)]
[InlineData(11, false)]
public void ShouldCheckForAnyCarsWithMake(int makeId, bool expectedResult)
{
  var result = Context.Cars.Any(x => x.MakeId == makeId);
  Assert.Equal(expectedResult, result);
}

第一次理论测试执行的 SQL 如下所示:

exec sp_executesql N'SELECT CASE
    WHEN EXISTS (
        SELECT 1
        FROM [dbo].[Inventory] AS [i]
        WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] = @__makeId_0)) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END',N'@__makeId_0 int',@__makeId_0=1

第二个测试检查是否所有的汽车记录都有一个特定的MakeId

[Theory]
[InlineData(1, false)]
[InlineData(11, false)]
public void ShouldCheckForAllCarsWithMake(int makeId, bool expectedResult)
{
  var result = Context.Cars.All(x => x.MakeId == makeId);
  Assert.Equal(expectedResult, result);
}

第一次理论测试执行的 SQL 如下所示:

exec sp_executesql N'SELECT CASE
    WHEN NOT EXISTS (
        SELECT 1
        FROM [dbo].[Inventory] AS [i]
        WHERE ([i].[IsDrivable] = CAST(1 AS bit)) AND ([i].[MakeId] <> @__makeId_0)) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END',N'@__makeId_0 int',@__makeId_0=1

从存储过程中获取数据

要检查的最后一个数据检索模式是从存储过程中获取数据。虽然 EF Core 在存储过程方面存在一些差距(与 EF 6 相比),但请记住 EF Core 是建立在 ADO.NET 之上的。我们只需要放下一层,记住我们是如何调用存储过程的。CarRepo中的以下方法创建所需的参数(输入和输出),利用ApplicationDbContext Database属性,并调用ExecuteSqlRaw():

public string GetPetName(int id)
{
  var parameterId = new SqlParameter
  {
    ParameterName = "@carId",
    SqlDbType = System.Data.SqlDbType.Int,
    Value = id,
  };

  var parameterName = new SqlParameter
  {
    ParameterName = "@petName",
    SqlDbType = System.Data.SqlDbType.NVarChar,
    Size = 50,
    Direction = ParameterDirection.Output
  };

  var result = Context.Database
      .ExecuteSqlRaw("EXEC [dbo].[GetPetName] @carId, @petName OUTPUT",parameterId, parameterName);
  return (string)parameterName.Value;
}

有了这些代码,测试就变得简单了。将以下测试添加到CarTests.cs类中:

[Theory]
[InlineData(1, "Zippy")]
[InlineData(2, "Rusty")]
[InlineData(3, "Mel")]
[InlineData(4, "Clunker")]
[InlineData(5, "Bimmer")]
[InlineData(6, "Hank")]
[InlineData(7, "Pinky")]
[InlineData(8, "Pete")]
[InlineData(9, "Brownie")]
public void ShouldGetValueFromStoredProc(int id, string expectedName)
{
    Assert.Equal(expectedName, new CarRepo(Context).GetPetName(id));
}

创建记录

通过在代码中创建记录,将它们添加到它们的DbSet<T>,并在上下文中调用SaveChanges() / SaveChangesAsync()来将记录添加到数据库中。当执行SaveChanges()时,ChangeTracker报告所有添加的实体,EF Core(和数据库提供者一起)创建适当的 SQL 语句来插入记录。

提醒一下,SaveChanges()在隐式事务中执行,除非使用显式事务。如果保存成功,则查询服务器生成的值来设置实体的值。这些测试都将使用一个显式事务,因此可以回滚更改,使数据库保持测试开始时的状态。

本节中显示的所有 SQL 语句都是使用 SQL Server Profiler 收集的。

Note

也可以使用派生的DbContext添加记录。这些例子都将使用DbSet<T>集合属性来添加记录。DbSet<T>DbContext都有异步版本的Add() / AddRange()。仅显示同步版本。

实体状态

当一个实体通过代码创建,但还没有添加到一个DbSet<T>中时,EntityState就是Detached。一旦一个新的实体被添加到DbSet<T>,则EntityState被设置为Added。在SaveChanges()执行成功后,EntityState被设置为Unchanged

添加一条记录

以下测试演示了如何向Inventory表添加一条记录:

[Fact]
public void ShouldAddACar()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = new Car
    {
      Color = "Yellow",
      MakeId = 1,
      PetName = "Herbie"
    };
    var carCount = Context.Cars.Count();
    Context.Cars.Add(car);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount+1,newCarCount);
  }
}

这里显示了执行的 SQL 语句。注意,最近添加的实体被查询数据库生成的属性(IdTimeStamp))。当查询结果到达 EF 核心时,实体用服务器端的值更新。

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [dbo].[Inventory] ([Color], [MakeId], [PetName])
VALUES (@p0, @p1, @p2);

SELECT [Id], [IsDrivable], [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50)',@p0=N'Yellow',@p1=1,@p2=N'Herbie'

使用附加添加单个记录

当实体的主键映射到 SQL Server 中的标识列时,如果主键属性值为零,EF Core 会将该实体实例视为Added。下面的测试创建了一个新的Car实体,其Id保留默认值零。当实体附加到ChangeTracker时,状态被设置为Added,调用SaveChanges()将实体添加到数据库中。

[Fact]
public void ShouldAddACarWithAttach()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = new Car
    {
      Color = "Yellow",
      MakeId = 1,
      PetName = "Herbie"
    };
    var carCount = Context.Cars.Count();
    Context.Cars.Attach(car);
    Assert.Equal(EntityState.Added, Context.Entry(car).State);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount + 1, newCarCount);
  }
}

一次添加多条记录

要在单个事务中插入多条记录,请使用DbSet<T>AddRange()方法,如本测试所示(注意,对于 SQL Server,为了在持久化数据时使用批处理,必须至少执行四个操作):

[Fact]
public void ShouldAddMultipleCars()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    //Have to add 4 to activate batching
    var cars = new List<Car>
    {
      new() { Color = "Yellow", MakeId = 1, PetName = "Herbie" },
      new() { Color = "White", MakeId = 2, PetName = "Mach 5" },
      new() { Color = "Pink", MakeId = 3, PetName = "Avon" },
      new() { Color = "Blue", MakeId = 4, PetName = "Blueberry" },
    };
    var carCount = Context.Cars.Count();
    Context.Cars.AddRange(cars);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount + 4, newCarCount);
  }
}

add语句被批处理成对数据库的单个调用,所有生成的列都被查询。当查询结果到达 EF 核心时,实体用服务器端的值更新。执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
MERGE [dbo].[Inventory] USING (
VALUES (@p0, @p1, @p2, 0),
(@p3, @p4, @p5, 1),
(@p6, @p7, @p8, 2),
(@p9, @p10, @p11, 3)) AS i ([Color], [MakeId], [PetName], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Color], [MakeId], [PetName])
VALUES (i.[Color], i.[MakeId], i.[PetName])
OUTPUT INSERTED.[Id], i._Position
INTO @inserted0;

SELECT [t].[Id], [t].[IsDrivable], [t].[TimeStamp] FROM [dbo].[Inventory] t
INNER JOIN @inserted0 i ON ([t].[Id] = [i].[Id])
ORDER BY [i].[_Position];',
N'@p0 nvarchar(50),@p1 int,@p2 nvarchar(50),@p3 nvarchar(50),@p4 int,@p5 nvarchar(50),@p6 nvarchar(50),@p7 int,@p8 nvarchar(50),@p9 nvarchar(50),@p10 int,@p11 nvarchar(50)',@p0=N'Yellow',@p1=1,@p2=N'Herbie',@p3=N'White',@p4=2,@p5=N'Mach 5',@p6=N'Pink',@p7=3,@p8=N'Avon',@p9=N'Blue',@p10=4,@p11=N'Blueberry'

添加记录时的标识列注意事项

当实体具有定义为主键的数值属性时,该属性(默认情况下)会映射到 SQL Server 中的标识列。EF Core 将任何具有 key 属性默认值(零)的实体视为新实体,而将任何具有非默认值的实体视为数据库中已存在的实体。如果您创建一个新实体,并将主键属性设置为非零值,并尝试将其添加到数据库中,EF Core 将无法添加记录,因为身份插入未启用。Initialize数据代码演示了如何启用身份插入。

添加对象图

当向数据库添加实体时,只要将子记录添加到父记录的集合属性中,就可以在同一个调用中添加子记录,而无需专门将它们添加到它们自己的DbSet<T>中。例如,创建了一个新的Make实体,并且在MakeCars属性中添加了一个子Car记录。当Make实体被添加到DbSet<Make>属性中时,EF Core 也自动开始跟踪子Car记录,而不必将其显式添加到DbSet<Car>属性中。执行SaveChanges()MakeCar一起保存。以下测试演示了这一点:

[Fact]
public void ShouldAddAnObjectGraph()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var make = new Make {Name = "Honda"};
    var car = new Car { Color = "Yellow", MakeId = 1, PetName = "Herbie" };
    //Cast the Cars property to List<Car> from IEnumerable<Car>
    ((List<Car>)make.Cars).Add(car);
    Context.Makes.Add(make);
    var carCount = Context.Cars.Count();
    var makeCount = Context.Makes.Count();
    Context.SaveChanges();
    var newCarCount = Context.Cars. Count();
    var newMakeCount = Context.Makes. Count();
    Assert.Equal(carCount+1,newCarCount);
    Assert.Equal(makeCount+1,newMakeCount);
  }
}

add 语句不进行批处理,因为语句少于两个,而对于 SQL Server,批处理从四个语句开始。执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [dbo].[Makes] ([Name])
VALUES (@p0);
SELECT [Id], [TimeStamp]
FROM [dbo].[Makes]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p0 nvarchar(50)',@p0=N'Honda'

exec sp_executesql N'SET NOCOUNT ON;
INSERT INTO [dbo].[Inventory] ([Color], [MakeId], [PetName])
VALUES (@p1, @p2, @p3);
SELECT [Id], [IsDrivable], [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();

',N'@p1 nvarchar(50),@p2 int,@p3 nvarchar(50)',@p1=N'Yellow',@p2=7,@p3=N'Herbie'

更新记录

通过将记录作为被跟踪的实体加载到DbSet<T>中,通过代码更改它们,然后在上下文中调用SaveChanges()来更新记录。当执行SaveChanges()时,ChangeTracker报告所有修改的实体,EF Core(和数据库提供者一起)创建适当的 SQL 语句来更新记录。

实体状态

编辑被跟踪的实体时,EntityState被设置为Modified。更改成功保存后,状态返回Unchanged

更新跟踪的实体

更新单个记录非常类似于添加单个记录。将数据库中的记录加载到被跟踪的实体中,进行一些更改,然后调用SaveChanges()。注意,您不必在DbSet<T>上调用Update() / UpdateRange()方法,因为实体已经被跟踪了。下面的测试只更新一条记录,但是如果更新并保存多个被跟踪的实体,过程是相同的。

[Fact]
public void ShouldUpdateACar()
{
  ExecuteInASharedTransaction(RunTheTest);

  void RunTheTest(IDbContextTransaction trans)
  {
    var car = Context.Cars.First(c => c.Id == 1);
    Assert.Equal("Black",car.Color);
    car.Color = "White";
    //Calling update is not needed because the entity is tracked
    //Context.Cars.Update(car);
    Context.SaveChanges();
    Assert.Equal("White", car.Color);
    var context2 = TestHelpers.GetSecondContext(Context, trans);
    var car2 = context2.Cars.First(c => c.Id == 1);
    Assert.Equal("White", car2.Color);
  }
}

前面的代码使用了跨越两个ApplicationDbContext实例的共享事务。这是为了在执行测试的上下文和检查测试结果的上下文之间提供隔离。

执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [dbo].[Inventory] SET [Color] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;
SELECT [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = @p1;

',N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'White',@p2=0x000000000000862D

Note

前面的where子句不仅检查了Id列,还检查了TimeStamp列。并发检查,稍后将会介绍。

更新未跟踪的实体

未跟踪的实体也可以用于更新数据库记录。该过程类似于更新被跟踪的实体,除了该实体是在代码中创建的(并且不被查询),并且 EF 核心必须被通知该实体应该已经存在于数据库中并且需要被更新。

创建实体的实例后,有两种方法通知 EF Core 该实体需要作为更新进行处理。第一个是调用DbSet<T>上的Update()方法,它将状态设置为Modified,,如下所示:

context2.Cars.Update(updatedCar);

第二种是使用上下文实例和Entry()方法将状态设置为Modified,就像这样:

context2.Entry(updatedCar).State = EntityState.Modified;

无论哪种方式,都必须调用SaveChanges()来保持这些值。

下面的示例读取一个未跟踪的记录,从该记录创建一个新的Car类实例,并更改一个属性(Color)。然后,它要么设置状态,要么在DbSet<T>上使用Update()方法,这取决于您取消注释的代码行。Update()方法也将状态更改为Modified。测试然后调用SaveChanges()。所有额外的上下文都是为了确保测试的准确性,并且上下文之间没有任何交叉。

[Fact]
public void ShouldUpdateACarUsingState()
{
  ExecuteInASharedTransaction(RunTheTest);

  void RunTheTest(IDbContextTransaction trans)
  {
    var car = Context.Cars.AsNoTracking().First(c => c.Id == 1);
    Assert.Equal("Black", car.Color);
    var updatedCar = new Car
    {
      Color = "White", //Original is Black
      Id = car.Id,
      MakeId = car.MakeId,
      PetName = car.PetName,
      TimeStamp = car.TimeStamp
      IsDrivable = car.IsDrivable
    };
    var context2 = TestHelpers.GetSecondContext(Context, trans);
    //Either call Update or modify the state
    context2.Entry(updatedCar).State = EntityState.Modified;
    //context2.Cars.Update(updatedCar);
    context2.SaveChanges();
    var context3 =
      TestHelpers.GetSecondContext(Context, trans);
    var car2 = context3.Cars.First(c => c.Id == 1);
    Assert.Equal("White", car2.Color);
  }
}

执行的 SQL 语句如下所示:

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [dbo].[Inventory] SET [Color] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;
SELECT [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = @p1;

',N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'White',@p2=0x000000000000862D

并发检查

前一章非常详细地介绍了并发检查。提醒一下,当一个实体定义了一个Timestamp属性时,当更改(更新或删除)被持久化到数据库时,该属性的值被用在where子句中。不是只搜索主键,而是将TimeStamp值添加到查询中,如下例所示:

UPDATE [dbo].[Inventory] SET [PetName] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;

下面的测试展示了一个创建并发异常、捕获它并使用Entries获取原始值、当前值和当前存储在数据库中的值的示例。获取当前值需要另一个数据库调用。

[Fact]
public void ShouldThrowConcurrencyException()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = Context.Cars.First();
    //Update the database outside of the context
    Context.Database.ExecuteSqlInterpolated(
      $"Update dbo.Inventory set Color="Pink" where Id = {car.Id}");
    car.Color = "Yellow";
    var ex = Assert.Throws<CustomConcurrencyException>(
      () => Context.SaveChanges());
    var entry = ((DbUpdateConcurrencyException) ex.InnerException)?.Entries[0];
    PropertyValues originalProps = entry.OriginalValues;
    PropertyValues currentProps = entry.CurrentValues;
    //This needs another database call
    PropertyValues databaseProps = entry.GetDatabaseValues();
  }
}

这里列出了执行的 SQL 调用。第一个是 update 语句,第二个是获取数据库值的调用。

exec sp_executesql N'SET NOCOUNT ON;
UPDATE [dbo].[Inventory] SET [Color] = @p0
WHERE [Id] = @p1 AND [TimeStamp] = @p2;
SELECT [TimeStamp]
FROM [dbo].[Inventory]
WHERE @@ROWCOUNT = 1 AND [Id] = @p1;'
,N'@p1 int,@p0 nvarchar(50),@p2 varbinary(8)',@p1=1,@p0=N'Yellow',@p2=0x0000000000008665

exec sp_executesql N'SELECT TOP(1) [i].[Id], [i].[Color], [i].[IsDrivable], [i].[MakeId], [i].[PetName], [i].[TimeStamp]
FROM [dbo].[Inventory] AS [i]
WHERE [i].[Id] = @__p_0',N'@__p_0 int',@__p_0=1

删除记录

通过在DbSet<T>上调用Remove()或者通过将其状态设置为Deleted,单个实体被标记为删除。通过调用DbSet<T>上的RemoveRange(),记录列表被标记为删除。移除过程将根据OnModelCreating()中配置的规则(或 EF 核心约定)对导航属性产生级联效应。如果由于级联策略而阻止删除,则会引发异常。

实体状态

当在被跟踪的实体上调用Remove()方法时,它的EntityState被设置为Deleted。删除语句成功执行后,实体从ChangeTracker中移除,其状态变为Detached。请注意,实体仍然存在于您的应用中,除非它已经超出范围并被垃圾收集。

删除跟踪的记录

删除过程反映了更新过程。一旦跟踪到一个实体,就在该实例上调用Remove(),然后调用SaveChanges()从数据库中删除记录。

[Fact]
public void ShouldRemoveACar()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var carCount = Context.Cars. Count();
    var car = Context.Cars.First(c => c.Id == 2);
    Context.Cars.Remove(car);
    Context.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount - 1, newCarCount);
    Assert.Equal(
      EntityState.Detached,
      Context.Entry(car).State);
  }
}

调用SaveChanges()后,实体实例仍然存在,但不再在ChangeTracker中。检查EntityState时,状态为Detached

下面列出了为删除而执行的 SQL 调用:

exec sp_executesql N'SET NOCOUNT ON;
DELETE FROM [dbo].[Inventory]
WHERE [Id] = @p0 AND [TimeStamp] = @p1;
SELECT @@ROWCOUNT;'
,N'@p0 int,@p1 varbinary(8)',@p0=2,@p1=0x0000000000008680

删除未跟踪的实体

未被跟踪的实体可以删除记录,就像未被跟踪的实体可以更新记录一样。不同的是通过调用Remove() / RemoveRange()或者将状态设置为Deleted然后调用SaveChanges()来跟踪实体。

下面的示例读取一个未跟踪的记录,从该记录创建一个新的Car类实例,并更改一个属性(Color)。然后,它要么设置状态,要么在DbSet<T>上使用Remove()方法(取决于您取消注释的是哪一行)。测试然后调用SaveChanges()。所有额外的上下文都是为了确保上下文之间没有交叉。

[Fact]
public void ShouldRemoveACarUsingState()
{
  ExecuteInASharedTransaction(RunTheTest);

  void RunTheTest(IDbContextTransaction trans)
  {
    var carCount = Context.Cars.Count();
    var car = Context.Cars.AsNoTracking().First(c => c.Id == 2);
    var context2 = TestHelpers.GetSecondContext(Context, trans);
    //Either call Remove or modify the state
    context2.Entry(car).State = EntityState.Deleted;
    //context2.Cars.Remove(car);
    context2.SaveChanges();
    var newCarCount = Context.Cars.Count();
    Assert.Equal(carCount - 1, newCarCount);
    Assert.Equal(
      EntityState.Detached,
      Context.Entry(car).State);
  }
}

捕捉级联删除失败

当删除记录的尝试由于级联规则而失败时,EF Core 将抛出一个DbUpdateException。下面的测试展示了这一点:

[Fact]
public void ShouldFailToRemoveACar()
{
  ExecuteInATransaction(RunTheTest);

  void RunTheTest()
  {
    var car = Context.Cars.First(c => c.Id == 1);
    Context.Cars.Remove(car);
    Assert.Throws<CustomDbUpdateException>(
      ()=>Context.SaveChanges());
  }
}

并发检查

如果实体有一个TimeStamp属性,Delete 也使用并发检查。有关更多信息,请参见“更新记录”一节中的“并发检查”一节。

摘要

本章使用前一章中获得的知识来完成AutoLot数据库的数据访问层。您使用了 EF 核心命令行工具来搭建现有的数据库,将模型更新到最终版本,然后创建并应用迁移。添加了用于封装数据访问的存储库,带有示例数据的数据库初始化代码可以以可重复、可靠的方式删除和创建数据库。本章的其余部分集中于测试数据访问层。这就完成了我们的数据访问和实体框架核心之旅。*

二十四、WPF 和 XAML 简介

当 1.0 版的。NET 平台发布后,需要构建图形桌面应用的程序员使用了两个名为 Windows Forms 和 GDI+的 API,它们主要打包在System.Windows.Forms.dllSystem.Drawing.dll程序集中。虽然 Windows 窗体和 GDI+仍然是构建传统桌面 GUI 的可行 API,但从发布开始,Microsoft 提供了一个名为 Windows Presentation Foundation(WPF)的替代 GUI 桌面 API。NET 3.0。WPF 和 Windows 窗体加入了。NET 核心系列的发布。网芯 3.0。

WPF 的第一章从研究这个新的 GUI 框架背后的动机开始,这将帮助你了解 Windows 窗体/GDI+和 WPF 编程模型之间的区别。接下来,您将了解几个重要类的作用,包括ApplicationWindowContentControlControlUIElementFrameworkElement

本章将向你介绍一种基于 XML 的语法,名为可扩展应用标记语言(XAML;发音为“zammel”)。在这里,您将学习 XAML 的语法和语义(包括附加属性语法以及类型转换器和标记扩展的角色)。

本章通过构建您的第一个 WPF 应用来研究 Visual Studio 的集成 WPF 设计器。在此期间,您将学习截取键盘和鼠标活动,定义应用范围的数据,以及执行其他常见的 WPF 任务。

WPF 背后的动机

多年来,微软创造了许多图形用户界面工具包(原始 C/C++/Windows API 开发,VB6,MFC 等。)来构建桌面可执行文件。这些 API 中的每一个都提供了一个代码库来表示 GUI 应用的基本方面,包括主窗口、对话框、控件、菜单系统等。的初始版本。NET 平台,Windows Forms API 很快成为 UI 开发的首选模型,因为它具有简单而强大的对象模型。

虽然许多功能齐全的桌面应用已经使用 Windows 窗体成功地创建出来,但事实是这种编程模型是相当不对称的。简而言之,System.Windows.Forms.dllSystem.Drawing.dll没有为构建功能丰富的桌面应用所需的许多附加技术提供直接支持。为了说明这一点,考虑一下 WPF 发布之前 GUI 桌面开发的特殊性质(见表 24-1 )。

表 24-1。

针对所需功能的 WPF 前解决方案

|

期望的功能

|

技术

用控件构建窗口 Windows 窗体
2D 图形支持 GDI+ ( System.Drawing.dll)
3D 图形支持 directx apis
支持流式视频 Windows Media Player APIs
支持流样式的文档 PDF 文件的编程操作

如您所见,Windows 窗体开发人员必须从几个不相关的 API 和对象模型中引入类型。虽然使用这些不同的 API 在语法上看起来确实相似(毕竟只是 C# 代码),但您可能也同意每种技术需要完全不同的思维方式。例如,使用 DirectX 创建 3D 渲染动画所需的技能与将数据绑定到网格所需的技能完全不同。当然,Windows 窗体程序员很难掌握每个 API 的多样性。

统一不同的 API

WPF 的创建是为了将这些以前不相关的编程任务合并到一个统一的对象模型中。因此,如果您需要创作一个 3D 动画,您不需要针对 DirectX API 手动编程(尽管您可以这样做),因为 3D 功能是直接嵌入到 WPF 中的。为了了解事情已经清理得有多好,考虑一下表 24-2 ,它展示了从。NET 3.0。

表 24-2。

。针对所需功能的. NET 3.0+解决方案

|

期望的功能

|

技术

使用控件构建窗体 数据绑定
2D 图形支持 数据绑定
3D 图形支持 数据绑定
支持流式视频 数据绑定
支持流样式的文档 数据绑定

这里明显的好处是。NET 程序员现在有一个单一的、对称的 API 来满足所有常见的 GUI 桌面编程需求。当你熟悉了关键的 WPF 程序集的功能和 XAML 的语法后,你会惊奇地发现你可以如此快速地创建复杂的 ui。

通过 XAML 提供关注点分离

也许最引人注目的好处之一是 WPF 提供了一种方法,将 GUI 应用的外观和感觉与驱动它的编程逻辑完全分开。使用 XAML,可以通过 XML 标记定义应用的 UI。这种标记(理想情况下使用 Microsoft Visual Studio 或 Blend for Visual Studio 等工具生成)可以连接到相关的 C# 代码文件,以提供程序功能的核心。

Note

XAML 不限于 WPF 应用。任何应用都可以用 XAML 来描述一棵。NET 对象,即使它们与可见的用户界面无关。

当你深入研究 WPF 时,你可能会惊讶于这种“桌面标记”所提供的灵活性。XAML 不仅允许你定义简单的用户界面元素(按钮、网格、列表框等)。)以及交互式 2D 和 3D 图形、动画、数据绑定逻辑和多媒体功能(如视频回放)。

XAML 还使得自定义控件如何呈现其视觉外观变得容易。例如,定义一个使公司徽标生动的圆形按钮控件只需要几行标记。如第二十七章所示,WPF 控件可以通过样式和模板来修改,这允许你用最少的麻烦和麻烦来改变应用的整体外观。与 Windows 窗体开发不同,从头构建自定义 WPF 控件的唯一令人信服的理由是,如果您需要更改控件的行为(例如,添加自定义方法、属性或事件;子类化现有控件以重写虚拟成员)。如果你仅仅需要改变一个控件的外观和感觉(比如一个圆形的动画按钮),你可以完全通过标记来完成。

提供优化的渲染模型

GUI 工具包(如 Windows 窗体、MFC 或 VB6)使用低级的、基于 C 的 API (GDI)来执行所有图形呈现请求(包括按钮和列表框等 UI 元素的呈现),该 API 多年来一直是 Windows 操作系统的一部分。GDI 为典型的商业应用或简单的图形程序提供了足够的性能;然而,如果一个 UI 应用需要利用高性能图形,DirectX 是必需的。

WPF 编程模型非常不同,在渲染图形数据时使用的是 GDI而不是。所有渲染操作(例如,2D 图形、3D 图形、动画、控件渲染等。)现在利用 DirectX API。第一个明显的好处是,您的 WPF 应用将自动利用硬件和软件优化。此外,WPF 应用可以利用丰富的图形服务(模糊效果、抗锯齿、透明度等)。)而没有直接针对 DirectX API 编程的复杂性。

Note

尽管 WPF 确实将所有渲染请求都推送到 DirectX 层,但我并不想暗示 WPF 应用会像直接使用非托管 C和 DirectX 构建应用一样快。尽管 WPF 的每个版本都取得了显著的性能提升,但是如果您打算构建一个需要尽可能快的执行速度的桌面应用(如 3D 视频游戏),非托管 C和 DirectX 仍然是最好的方法。

简化复杂的 UI 编程

概括一下到目前为止的故事,Windows Presentation Foundation(WPF)是一个用于构建桌面应用的 API,它将各种桌面 API 集成到单个对象模型中,并通过 XAML 提供了关注点的清晰分离。除了这些要点之外,WPF 应用还受益于一种将服务集成到程序中的简单方法,这在历史上是相当复杂的。以下是 WPF 核心功能的简要概述:

  • 多个布局管理器(比 Windows 窗体多得多)为内容的放置和重新定位提供了极其灵活的控制。

  • 使用增强的数据绑定引擎以多种方式将内容绑定到 UI 元素。

  • 一个内置的样式引擎,允许你为 WPF 应用定义“主题”。

  • 使用矢量图形,允许内容自动调整大小,以适应应用所在屏幕的大小和分辨率。

  • 支持 2D 和三维图形,动画,视频和音频播放。

  • 丰富的排版 API,例如支持 XML 纸张规范(XPS)文档、固定文档(WYSIWYG)、流文档和文档注释(例如,便笺 API)。

  • 支持与传统 GUI 模型(例如,Windows 窗体、ActiveX 和 Win32 HWNDs)的互操作。例如,您可以将自定义 Windows 窗体控件合并到 WPF 应用中,反之亦然。

现在您已经对 WPF 带来了什么有了一些了解,让我们看看可以使用这个 API 创建的各种类型的应用。这些特性中的许多将在后面的章节中详细探讨。

调查 WPF 议会

WPF 最终不过是捆绑在其中的类型的集合。NET 核心程序集。表 24-3 描述了用于构建 WPF 应用的关键组件,每个组件在创建新项目时都必须被引用。正如您所希望的,Visual Studio WPF 项目会自动引用这些必需的程序集。

表 24-3。

核心 WPF 组件

|

装配

|

生命的意义

PresentationCore 该程序集定义了许多名称空间,这些名称空间构成了 WPF GUI 层的基础。例如,该程序集包含对 WPF 墨迹 API、动画原语和许多图形呈现类型的支持。
PresentationFramework 该程序集包含大多数 WPF 控件、ApplicationWindow类、对交互式 2D 图形的支持以及数据绑定中使用的许多类型。
System.Xaml.dll 此程序集提供了允许您在运行时针对 XAML 文档进行编程的命名空间。总的来说,这个库只有在创作 WPF 支持工具或者需要在运行时绝对控制 XAML 时才有用。
WindowsBase.dll 这个程序集定义了构成 WPF API 基础设施的类型,包括那些代表 WPF 线程类型、安全类型、各种类型转换器以及对依赖属性路由事件的支持(在第二十七章中描述)。

这四个程序集共同定义了新的命名空间和。NET 核心类、接口、结构、枚举和委托。表 24-4 描述了一些(但肯定不是全部)重要名称空间的角色。

表 24-4。

核心 WPF 命名空间

|

命名空间

|

生命的意义

System.Windows 这是 WPF 的根命名空间。在这里,您会发现任何 WPF 桌面项目都需要的核心类(比如ApplicationWindow)。
System.Windows.Controls 它包含了所有预期的 WPF 小部件,包括构建菜单系统、工具提示和许多布局管理器的类型。
System.Windows.Data 这包含使用 WPF 数据绑定引擎的类型,以及对数据绑定模板的支持。
System.Windows.Documents 它包含使用 documents API 的类型,允许您通过 XML Paper Specification (XPS)协议将 PDF 样式的功能集成到您的 WPF 应用中。
System.Windows.Ink 这提供了对 Ink API 的支持,它允许您捕获来自手写笔或鼠标的输入,响应输入手势,等等。这对平板电脑编程很有用;然而,任何 WPF 都可以使用这个 API。
System.Windows.Markup 该命名空间定义了几种类型,允许以编程方式解析和处理 XAML 标记(以及等效的二进制格式 BAML)。
System.Windows.Media 这是几个以媒体为中心的命名空间的根命名空间。在这些命名空间中,您可以找到处理动画、3D 呈现、文本呈现和其他多媒体原语的类型。
System.Windows.Navigation 此命名空间提供了一些类型,用于说明 XAML 浏览器应用(XBAPs)以及需要导航页面模型的标准桌面应用所采用的导航逻辑。
System.Windows.Shapes 这定义了一些类,允许您呈现自动响应鼠标输入的交互式 2D 图形。

为了开始您的 WPF 编程模型之旅,您将研究任何传统桌面开发工作中常见的两个名称空间成员:ApplicationWindow

Note

如果您已经使用 Windows 窗体 API 创建了桌面用户界面,请注意System.Windows.Forms.*System.Drawing.*程序集与 WPF 无关。这些库代表了原始的。NET GUI 工具包,Windows Forms/GDI+。

应用类的角色

System.Windows.Application类代表一个正在运行的 WPF 应用的全局实例。这个类提供了一个Run()方法(启动应用),一系列你可以处理的事件,以便与应用的生命周期交互(比如StartupExit)。表 24-5 详细列出了一些关键属性。

表 24-5。

应用类型的关键属性

|

财产

|

生命的意义

Current 这个静态属性允许您从代码中的任何地方访问正在运行的Application对象。当一个窗口或对话框需要访问创建它的Application对象时,这是很有帮助的,通常是访问应用范围的变量和功能。
MainWindow 此属性允许您以编程方式获取或设置应用的主窗口。
Properties 此属性允许您建立和获取可在 WPF 应用的所有方面(窗口、对话框等)访问的数据。).
StartupUri 此属性获取或设置一个 URI,它指定应用启动时自动打开的窗口或页面。
Windows 该属性返回一个WindowCollection类型,它提供对从创建Application对象的线程创建的每个窗口的访问。当您想要迭代应用的每个打开的窗口并改变其状态(例如最小化所有窗口)时,这可能很有帮助。

构造应用类

任何 WPF 应用都需要定义一个扩展Application的类。在这个类中,您将定义程序的入口点(Main()方法),它创建这个子类的一个实例,并且通常处理StartupExit事件(如果需要的话)。这里有一个例子:

// Define the global application object
// for this WPF program.
class MyApp : Application
{
  [STAThread]
  static void Main(string[] args)
  {
    // Create the application object.
    MyApp app = new MyApp();

    // Register the Startup/Exit events.
    app.Startup += (s, e) => { /* Start up the app */ };
    app.Exit += (s, e) => { /* Exit the app */ };
  }
}

Startup处理程序中,您通常会处理任何传入的命令行参数,并启动程序的主窗口。如您所料,在Exit处理程序中,您可以为程序编写任何必要的关闭逻辑(例如,保存用户首选项,写入 Windows 注册表)。

Note

WPF 应用的Main()方法必须具有[STAThread]属性,这确保了应用使用的任何遗留 COM 对象都是线程安全的。如果你不以这种方式注释Main(),你将会遇到一个运行时异常。即使在 C# 9.0 中引入了顶级语句,您仍然希望在您的 WPF 应用中使用更传统的Main()方法。事实上,Main()方法是自动为您生成的。

枚举 Windows 集合

Application公开的另一个有趣的属性是Windows,它提供了对一个集合的访问,该集合代表当前 WPF 应用加载到内存中的每个窗口。当您创建新的Window对象时,它们会自动添加到Application.Windows集合中。下面是一个最小化应用每个窗口的示例方法(可能是为了响应终端用户触发的给定键盘手势或菜单选项):

static void MinimizeAllWindows()
{
  foreach (Window wnd in Application.Current.Windows)
  {
    wnd.WindowState = WindowState.Minimized;
  }
}

您将很快构建一些 WPF 应用,但在此之前,让我们检查一下Window类型的核心功能,并在此过程中了解一些重要的 WPF 基类。

窗口类的作用

System.Windows.Window类(位于PresentationFramework.dll汇编中)代表由Application派生类拥有的单个窗口,包括主窗口显示的任何对话框。毫不奇怪,Window有一系列的父类,每个父类都为表带来了更多的功能。考虑图 24-1 ,它显示了通过 Visual Studio 对象浏览器看到的System.Windows.Window的继承链(和实现的接口)。

img/340876_10_En_24_Fig1_HTML.jpg

图 24-1。

窗口类的层次结构

随着本章和后续章节的学习,你会逐渐理解这些基类所提供的功能。然而,为了激起您的兴趣,下面几节将对每个基类提供的功能进行细分(请参考。NET 5 文档以获得完整的详细信息)。

系统的作用。窗口.控件.内容控件

Window的直接父类是ContentControl,它很可能是所有 WPF 类中最吸引人的。这个基类为派生类型提供了承载单个内容的能力,简单地说,就是通过Content属性引用放置在控件表面区域内部的可视数据。WPF 内容模型使得定制内容控件的基本外观变得非常简单。

例如,当您想到一个典型的“按钮”控件时,您倾向于假设内容是一个简单的字符串(OK、Cancel、Abort 等)。).如果您使用 XAML 来描述一个 WPF 控件,并且您想要分配给属性Content的值可以被捕获为一个简单的字符串,那么您可以在元素的开始定义中这样设置Content属性(此时不要担心确切的标记):

<!-- Setting the Content value in the opening element -->
<Button Height="80" Width="100" Content="OK"/>

Note

也可以在 C# 代码中设置Content属性,这允许你在运行时改变控件的内部。

然而,内容几乎可以是任何东西。例如,假设您想要一个比简单字符串更有趣的“按钮”,可能是一个自定义图形和一个文本广告。在其他 UI 框架(如 Windows 窗体)中,您可能需要构建一个自定义控件,这可能需要维护相当多的代码和一个全新的类。对于 WPF 内容模型,没有必要这样做。

当您想要将Content属性赋给一个不能被捕获为简单字符数组的值时,您不能使用控件的开始定义中的属性来分配它。相反,您必须在元素的范围内隐式定义内容数据*。例如,下面的<Button>包含一个<StackPanel>作为内容,它本身包含一些唯一的数据(确切地说是一个<Ellipse><Label>):*

<!-- Implicitly setting the Content property with complex data -->
<Button Height="80" Width="100">
  <StackPanel>
    <Ellipse Fill="Red" Width="25" Height="25"/>
    <Label Content ="OK!"/>
  </StackPanel>
</Button>

你也可以利用 XAML 的属性元素语法来设置复杂的内容。考虑下面的功能等价的<Button>定义,它使用属性元素语法显式地设置了Content属性(同样,在本章的后面你会找到更多关于 XAML 的信息,所以现在还不要过多考虑细节):

<!-- Setting the Content property using property-element syntax -->
<Button Height="80" Width="100">
  <Button.Content>
    <StackPanel>
      <Ellipse Fill="Red" Width="25" Height="25"/>
      <Label Content ="OK!"/>
    </StackPanel>
  </Button.Content>
</Button>

请注意,不是每个 WPF 元素都是从ContentControl派生的,因此,不是所有的控件都支持这种独特的内容模型(然而,大多数都支持)。此外,一些 WPF 控件对您刚刚检查过的基本内容模型进行了一些改进。第二十五章将会更详细的讨论 WPF 内容的作用。

系统的作用。窗口.控件.控件

ContentControl不同,所有的 WPF 控件共享Control基类作为一个公共的父类。这个基类提供了许多核心成员,这些成员负责基本的 UI 功能。例如,Control定义属性来建立控件的大小、不透明度、tab 键顺序逻辑、显示光标、背景颜色等等。此外,这个父类提供了对模板服务的支持。正如第二十七章所解释的,WPF 控件可以使用模板和样式完全改变它们的外观。表 24-6 记录了Control类型的一些关键成员,按相关功能分组。

表 24-6。

控制类型的关键成员

|

成员

|

生命的意义

BackgroundForegroundBorderBrushBorderThicknessPaddingHorizontalContentAlignmentVerticalContentAlignment 这些属性允许您设置有关如何呈现和定位控件的基本设置。
FontFamilyFontSizeFontStretchFontWeight 这些属性控制各种字体居中设置。
IsTabStopTabIndex 这些属性用于在窗口上的控件之间建立 tab 键顺序。
MouseDoubleClickPreviewMouseDoubleClick 这些事件处理双击小部件的行为。
Template 此属性允许您获取和设置控件的模板,该模板可用于更改小部件的呈现输出。

系统的作用。Windows.FrameworkElement

这个基类提供了许多在整个 WPF 框架中使用的成员,例如对故事板(在动画中使用)和数据绑定的支持,以及命名成员(通过Name属性)的能力,获取由派生类型定义的任何资源,以及建立派生类型的整体维度。表 24-7 击中亮点。

表 24-7。

FrameworkElement 类型的关键成员

|

成员

|

生命的意义

ActualHeightActualWidthMaxHeightMaxWidthMinHeightMinWidthHeightWidth 这些属性控制派生类型的大小。
ContextMenu 获取或设置与派生类型关联的弹出菜单。
Cursor 获取或设置与派生类型关联的鼠标光标。
HorizontalAlignmentVerticalAlignment 获取或设置类型在容器(如面板或列表框)中的定位方式。
Name 允许您为类型指定一个名称,以便在代码文件中访问其功能。
Resources 提供对由类型定义的任何资源的访问(参见第二十九章检查 WPF 资源系统)。
ToolTip 获取或设置与派生类型关联的工具提示。

系统的作用。Windows.UIElement

Window的继承链中的所有类型中,UIElement基类提供了最多的功能。UIElement的主要任务是为派生类型提供大量的事件,以允许派生类型接收焦点和处理输入请求。例如,该类提供了许多事件来解释拖放操作、鼠标移动、键盘输入、手写笔输入和触摸。

第二十五章详细挖掘 WPF 事件模型;然而,许多核心事件看起来都很熟悉(MouseMoveKeyUpMouseDownMouseEnterMouseLeave等)。).除了定义几十个事件之外,这个父类还提供了几个属性来说明控件焦点、启用状态、可见性和点击测试逻辑,如表 24-8 所示。

表 24-8。

UIElement类型的主要成员

|

成员

|

生命的意义

FocusableIsFocused 这些属性允许您将焦点设置在给定的派生类型上。
IsEnabled 此属性允许您控制是启用还是禁用给定的派生类型。
IsMouseDirectlyOverIsMouseOver 这些属性提供了一种执行点击测试逻辑的简单方法。
IsVisibleVisibility 这些属性允许您使用派生类型的可见性设置。
RenderTransform 此属性允许您建立将用于呈现派生类型的转换。

系统的作用。Windows.Media.Visual

Visual类类型在 WPF 中提供核心渲染支持,包括图形数据的点击测试、坐标转换和边界框计算。实际上,Visual类与底层 DirectX 子系统交互,在屏幕上绘制数据。正如你将在第二十六章中看到的,WPF 提供了三种可能的方式来呈现图形数据,每种方式在功能和性能上都有所不同。使用Visual类型(及其子类型,如DrawingVisual)提供了最轻量级的方式来呈现图形数据,但它也需要大量的手动代码来处理所有需要的服务。同样,更多细节将在第二十八章中介绍。

系统的作用。Windows . DependencyObject 对象

WPF 支持一种特殊的口味。NET 属性称为依赖属性。简而言之,这种属性样式提供了额外的代码,以允许属性响应多种 WPF 技术,如样式、数据绑定、动画等。对于支持这个新属性方案的类型,它需要从DependencyObject基类派生。虽然依赖属性是 WPF 开发的一个关键方面,但是大部分时间它们的细节是隐藏的。第二十五章进一步深入依赖属性的细节。

系统的作用。windows . threading . dispatch object

Window类型的最后一个基类是DispatcherObject(在System.Object之后,我认为在这本书的这一点上不需要进一步解释)。这个类型提供了一个感兴趣的属性,Dispatcher,它返回相关的System.Windows.Threading.Dispatcher对象。Dispatcher类是 WPF 应用事件队列的入口点,它提供了处理并发和线程的基本构造。第十五章探讨了Dispatcher职业。

理解 WPF·XAML 的句法

生产级 WPF 应用通常会利用专用工具来生成必要的 XAML。尽管这些工具很有帮助,但是理解 XAML 标记的整体结构是一个好主意。为了帮助你的学习过程,请允许我介绍一个流行的(免费的)工具,它可以让你轻松地体验 XAML。

Kaxaml 简介

当你第一次学习 XAML 语法时,使用一个名为 Kaxaml 的免费工具会很有帮助。你可以从 https://github.com/punker76/kaxaml 获得这个流行的 XAML 编辑器/解析器。

Note

对于这本书的许多版本,我都将用户指向 www.kaxaml.com ,但不幸的是,那个网站已经被停用了。Jan Karger ( https://github.com/punker76 )继承了旧代码,并做了一些改进工作。你可以在 GitHub https://github.com/punker76/kaxaml/releases 上找到他版本的工具。非常尊重和感谢 Kaxaml 的最初开发者和 Jan 让它保持活力;这是一个很棒的工具,帮助了无数开发者学习 XAML。

Kaxaml 很有帮助,因为它对 C# 源代码、事件处理程序或实现逻辑一无所知。与使用完整的 Visual Studio WPF 项目模板相比,这是一种更直接的测试 XAML 代码片段的方法。此外,Kaxaml 有几个集成的工具,如颜色选择器,xaml 片段管理器,甚至还有一个“XAML 洗涤器”选项,可以根据您的设置格式化您的 XAML。当您第一次打开 Kaxaml 时,您会发现一个<Page>控件的简单标记,如下所示:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>

  </Grid>
</Page>

Window一样,Page包含各种布局管理器和控件。然而,与Window不同,Page对象不能作为独立实体运行。相反,它们必须放在合适的主机中,如NavigationWindowFrame。好消息是,您可以在<Page><Window>范围内键入相同的标记。

Note

如果您将 Kaxaml 标记窗口中的<Page></Page>元素更改为<Window></Window>,您可以按 F5 键将一个新窗口加载到屏幕上。

作为初始测试,在工具底部的 XAML 窗格中输入以下标记:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <!-- A button with custom content -->
    <Button Height="100" Width="100">
      <Ellipse Fill="Green" Height="50" Width="50"/>
    </Button>
  </Grid>
</Page>

现在你应该看到你的页面呈现在 Kaxaml 编辑器的上部(见图 24-2 )。

img/340876_10_En_24_Fig2_HTML.jpg

图 24-2。

Kaxaml 是一个有用的(免费的)工具,用来学习 xaml 的语法

当您使用 Kaxaml 时,请记住该工具不允许您创作任何需要代码编译的标记(但是,允许使用x:Name)。这包括定义一个x:Class属性(用于指定代码文件),在标记中输入事件处理程序名称,或者使用任何需要代码编译的 XAML 关键字(比如FieldModifier或者ClassModifier)。任何这样做的尝试都将导致标记错误。

XAML XML 名称空间和 XAML“关键词”

WPF XAML 文档的根元素(如<Window><Page><UserControl><Application>定义)几乎总是引用以下两个预定义的 XML 名称空间:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>

  </Grid>
</Page>

第一个 XML 名称空间, http://schemas.microsoft.com/winfx/2006/xaml/presentation ,映射了一系列 WPF。NET 命名空间供当前的*.xaml文件使用(System.WindowsSystem.Windows.ControlsSystem.Windows.InkSystem.Windows.MediaSystem.Windows.Navigation等)。).

这种一对多的映射是在 WPF 程序集(WindowsBase.dllPresentationCore.dllPresentationFramework.dll)中使用程序集级的[XmlnsDefinition]属性硬编码的。例如,如果您打开 Visual Studio 对象浏览器并选择PresentationCore.dll程序集,您将看到如下清单,它实际上导入了System.Windows:

[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml/presentation",
                           "System.Windows")]

第二个 XML 名称空间, http://schemas.microsoft.com/winfx/2006/xaml ,用于包含特定于 XAML 的“关键字”(因为缺少更好的术语)以及System.Windows.Markup名称空间,如下所示:

[assembly: XmlnsDefinition("http://schemas.microsoft.com/winfx/2006/xaml",
                           "System.Windows.Markup")]

任何格式良好的 XML 文档的一个规则(记住,XAML 是一种基于 XML 的语法)是,开始的根元素指定一个 XML 名称空间作为主名称空间,它是包含最常见项的名称空间。如果根元素需要包含额外的辅助名称空间(如此处所示),则必须使用惟一的标记前缀来定义它们(以解决任何可能的名称冲突)。按照惯例,前缀简单来说就是x;然而,这可以是您需要的任何唯一令牌,比如XamlSpecificStuff

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:XamlSpecificStuff="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <!-- A button with custom content -->
    <Button XamlSpecificStuff:Name="button1" Height="100" Width="100">
      <Ellipse Fill="Green" Height="50" Width="50"/>
    </Button>
  </Grid>
</Page>

定义冗长的 XML 名称空间前缀的明显缺点是,每次 XAML 文件需要引用这个以 XAML 为中心的 XML 名称空间中定义的一个项目时,您都需要键入XamlSpecificStuff。鉴于XamlSpecificStuff需要许多额外的击键,只需坚持使用x

在任何情况下,除了x:Namex:Classx:Code关键字之外, http://schemas.microsoft.com/winfx/2006/xaml XML 名称空间还提供了对其他 XAML 关键字的访问,其中最常见的如表 24-9 所示。

表 24-9。

XAML 关键词

|

XAML 关键词

|

生命的意义

x:Array 表示 XAML 中的. NET 数组类型。
x:ClassModifier 允许您定义由关键字Class表示的 C# 类(内部或公共)的可见性。
x:FieldModifier 允许您为根的任何命名子元素(例如,<Window>元素中的<Button>)定义类型成员(内部、公共、私有或受保护)的可见性。使用关键字Name XAML 定义了一个命名元素
x:Key 允许您为将放入 dictionary 元素中的 XAML 项建立一个键值。
x:Name 允许您指定给定 XAML 元素的生成 C# 名称。
x:Null 代表一个null引用。
x:Static 允许您引用某个类型的静态成员。
x:Type C# typeof操作符的 XAML 等价物(它将基于所提供的名称产生一个System.Type)。
x:TypeArguments 允许您将元素建立为具有特定类型参数的泛型类型(例如,List<int>List<bool>)。

除了这两个必要的 XML 名称空间声明之外,在 XAML 文档的开始元素中定义额外的标记前缀是可能的,有时也是必要的。每当您需要在 XAML 中描述一个在外部程序集中定义的. NET 核心类时,您通常会这样做。

例如,假设您已经构建了一些定制的 WPF 控件,并将它们打包在一个名为MyControls.dll的库中。现在,如果您想创建一个使用这些控件的新的Window,您可以使用clr-namespaceassembly标记建立一个映射到您的库的定制 XML 名称空间。以下是创建名为myCtrls的标签前缀的一些示例标记,该标签前缀可用于访问库中的控件:

<Window x:Class="WpfApplication1.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:myCtrls="clr-namespace:MyControls;assembly=MyControls"
  Title="MainWindow" Height="350" Width="525">
  <Grid>
    <myCtrls:MyCustomControl />
  </Grid>
</Window>

clr-namespace标记被分配给。NET 核心命名空间,而assembly标记被设置为外部*.dll程序集的友好名称。您可以将此语法用于任何外部。NET 核心库你想操纵的标记。虽然目前还不需要这样做,但以后的章节将要求您定义自定义的 XML 名称空间声明来描述标记中的类型。

Note

如果需要在标记中定义一个类,该标记是当前程序集的一部分,但在不同的。NET 核心命名空间中,您的xmlns标记前缀是在没有assembly=属性的情况下定义的,比如:xmlns:myCtrls="clr-namespace:SomeNamespaceInMyApp"

控制类和成员变量的可见性

在接下来的章节中,你会在需要的地方看到很多这样的关键词;然而,作为一个简单的例子,考虑下面的 XAML <Window>定义,它使用了ClassModifierFieldModifier关键字,以及x:Namex:Class(记住kaxaml.exe不允许您使用任何需要代码编译的 XAML 关键字,比如x:Codex:FieldModifierx:ClassModifier):

<!-- This class will now be declared internal in the *.g.cs file -->
<Window x:Class="MyWPFApp.MainWindow" x:ClassModifier ="internal"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

  <!-- This button will be public in the *.g.cs file -->
  <Button x:Name ="myButton" x:FieldModifier ="public" Content = "OK"/>
</Window>

默认情况下,所有 C#/XAML 类型定义都是public,而成员默认为internal。然而,基于您的 XAML 定义,最终自动生成的文件包含一个带有公共Button变量的内部类类型。

internal partial class MainWindow :
System.Windows.Window,
  System.Windows.Markup.IComponentConnector
{
  public System.Windows.Controls.Button myButton;
...
}

XAML 元素、XAML 属性和类型转换器

在建立了根元素和任何所需的 XML 名称空间之后,下一个任务是用一个子元素填充根元素。在现实世界的 WPF 应用中,这个孩子将是一个布局管理器(比如一个GridStackPanel),它依次包含任意数量的描述用户界面的附加 UI 元素。下一章将详细研究这些布局管理器,所以现在假设您的<Window>类型将包含一个Button元素。

正如你在本章已经看到的,XAML 元素映射到一个给定的类或结构类型。NET 核心名称空间,而开始元素标记中的属性映射到该类型的属性或事件。举例来说,在 Kaxaml 中输入下面的<Button>定义:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Grid>
    <!-- Configure the look and feel of a Button -->
    <Button Height="50" Width="100" Content="OK!"
            FontSize="20" Background="Green" Foreground="Yellow"/>
  </Grid>
</Page>

请注意,分配给每个属性的值已经被捕获为一个简单的文本值。这看起来像是完全不匹配的数据类型,因为如果你在 C# 代码中使用这个Button,你将而不是给这些属性分配字符串对象,而是使用特定的数据类型。例如,下面是用代码创作的同一个按钮:

public void MakeAButton()
{
  Button myBtn = new Button();
  myBtn.Height = 50;
  myBtn.Width = 100;
  myBtn.FontSize = 20;
  myBtn.Content = "OK!";
  myBtn.Background = new SolidColorBrush(Colors.Green);
  myBtn.Foreground = new SolidColorBrush(Colors.Yellow);
}

事实证明,WPF 附带了几个类型转换器类,用于将简单的文本值转换成正确的底层数据类型。这个过程透明地(并且自动地)发生。

虽然这很好,但是很多时候您需要为 XAML 属性分配一个更复杂的值,而这个值不能作为一个简单的字符串被捕获。例如,假设您想要构建一个自定义画笔来设置ButtonBackground属性。如果你在代码中构建画笔,这是非常简单的,如下所示:

public void MakeAButton()
{
...
  // A fancy brush for the background.
  LinearGradientBrush fancyBruch =
    new LinearGradientBrush(Colors.DarkGreen, Colors.LightGreen, 45);
  myBtn.Background = fancyBruch;
  myBtn.Foreground = new SolidColorBrush(Colors.Yellow);
}

如何将复杂的画笔表示为字符串?你不能!幸运的是,XAML 提供了一种特殊的语法,当你需要给一个复杂的对象赋值时,可以使用这种语法,称为属性元素语法

理解 XAML 属性元素语法

属性元素语法允许你将复杂的对象分配给一个属性。下面是一个对使用一个LinearGradientBrush来设置其Background属性的Button的 XAML 描述:

<Button Height="50" Width="100" Content="OK!"
        FontSize="20" Foreground="Yellow">
  <Button.Background>
    <LinearGradientBrush>
      <GradientStop Color="DarkGreen" Offset="0"/>
      <GradientStop Color="LightGreen" Offset="1"/>
    </LinearGradientBrush>
  </Button.Background>
</Button>

注意,在<Button></Button>标记的范围内,您已经定义了一个名为<Button.Background>的子范围。在这个范围内,您已经定义了一个自定义的<LinearGradientBrush>。(不要担心画笔的确切代码;你会在第二十八章中了解到 WPF 图形。)

任何属性都可以使用属性元素语法来设置,该语法通常分为以下模式:

<DefiningClass>
  <DefiningClass.PropertyOnDefiningClass>
    <!-- Value for Property here! -->
  </DefiningClass.PropertyOnDefiningClass>
</DefiningClass>

虽然任何属性都可以使用这个语法进行设置,但是如果您可以将一个值捕获为一个简单的字符串,那么您将节省自己的输入时间。例如,这里有一个更详细的方法来设置您的ButtonWidth:

<Button Height="50" Content="OK!"
        FontSize="20" Foreground="Yellow">
...
  <Button.Width>
    100
  </Button.Width>
</Button>

了解 XAML 附加属性

除了属性元素语法之外,XAML 还定义了一种特殊的语法,用于为附加到属性的设置一个值。本质上,附加属性允许子元素为父元素中定义的属性设置值。要遵循的通用模板如下所示:

<ParentElement>
  <ChildElement ParentElement.PropertyOnParent = "Value">
</ParentElement>

附加属性语法最常见的用法是将 UI 元素放置在 WPF 布局管理器的一个类中(GridDockPanel等)。).下一章将详细介绍这些面板;现在,在 Kaxaml 中输入以下内容:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Canvas Height="200" Width="200" Background="LightBlue">
    <Ellipse Canvas.Top="40" Canvas.Left="40" Height="20" Width="20" Fill="DarkBlue"/>
  </Canvas>
</Page>

这里,您已经定义了一个包含一个EllipseCanvas布局管理器。注意,Ellipse可以使用附加的属性语法通知其父节点(Canvas)在哪里放置它的顶部/左侧位置。

关于附加属性,有一些事项需要注意。首先也是最重要的,这不是一个可以应用于 any 父节点的 any 属性的通用语法。例如,以下 XAML 无法正确解析:

<!-- Error! Set Background property on Canvas via attached property? -->
<Canvas Height="200" Width="200">
  <Ellipse Canvas.Background="LightBlue"
           Canvas.Top="40" Canvas.Left="90"
           Height="20" Width="20" Fill="DarkBlue"/>
</Canvas>

附加属性是 WPF 特有概念的一种特殊形式,称为依赖属性。除非属性是以特定方式实现的,否则不能使用附加属性语法设置其值。你将在第二十五章中详细探究依赖属性。

Note

Visual Studio 具有 IntelliSense,它将向您显示可由给定元素设置的有效附加属性。

了解 XAML 标记扩展

如前所述,属性值通常使用简单的字符串或通过属性元素语法来表示。然而,还有另一种方法来指定 XAML 属性的值,使用标记扩展。标记扩展允许 XAML 解析器从专用的外部类获取属性值。考虑到一些属性值需要执行几个代码语句来计算值,这可能是有益的。

标记扩展提供了一种用新功能干净地扩展 XAML 语法的方法。标记扩展在内部表示为从MarkupExtension派生的类。请注意,您需要构建自定义标记扩展的机会微乎其微。然而,XAML 关键字的子集(如x:Arrayx:Nullx:Staticx:Type)是伪装的标记扩展!

标记扩展夹在大括号之间,就像这样:

<Element PropertyToSet = "{MarkUpExtension}"/>

要查看一些正在运行的标记扩展,请将以下代码编写到 Kaxaml 中:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:CorLib="clr-namespace:System;assembly=mscorlib">

  <StackPanel>
    <!-- The Static markup extension lets us obtain a value
         from a static member of a class -->
    <Label Content ="{x:Static CorLib:Environment.OSVersion}"/>
    <Label Content ="{x:Static CorLib:Environment.ProcessorCount}"/>

    <!-- The Type markup extension is a XAML version of
         the C# typeof operator -->
    <Label Content ="{x:Type Button}" />
    <Label Content ="{x:Type CorLib:Boolean}" />

    <!-- Fill a ListBox with an array of strings! -->
    <ListBox Width="200" Height="50">
      <ListBox.ItemsSource>
        <x:Array Type="CorLib:String">
          <CorLib:String>Sun Kil Moon</CorLib:String>
          <CorLib:String>Red House Painters</CorLib:String>
          <CorLib:String>Besnard Lakes</CorLib:String>
        </x:Array>
      </ListBox.ItemsSource>
    </ListBox>
  </StackPanel>
</Page>

首先,注意到<Page>定义有一个新的 XML 名称空间声明,它允许您访问mscorlib.dllSystem名称空间。建立了这个 XML 名称空间后,首先使用x:Static标记扩展,并从System.Environment类的OSVersionProcessorCount中获取值。

x:Type标记扩展允许您访问指定项目的元数据描述。这里,您只是简单地指定了 WPF ButtonSystem.Boolean类型的完全限定名。

这个标记中最有趣的部分是ListBox。这里,您将ItemsSource属性设置为完全在标记中声明的字符串数组!注意这里的x:Array标记扩展如何允许你在它的范围内指定一组子项:

<x:Array Type="CorLib:String">
  <CorLib:String>Sun Kil Moon</CorLib:String>
  <CorLib:String>Red House Painters</CorLib:String>
  <CorLib:String>Besnard Lakes</CorLib:String>
</x:Array>

Note

前面的 XAML 例子只是用来说明标记扩展的作用。正如你将在第二十五章中看到的,填充ListBox控件有更简单的方法!

图 24-3 显示了这个<Page>在 Kaxaml 中的标记。

img/340876_10_En_24_Fig3_HTML.jpg

图 24-3。

标记扩展允许您通过专用类的功能来设置值

现在,您已经看到了许多展示 XAML 语法每个核心方面的例子。你可能会同意,XAML 是有趣的,因为它允许你描述一个树。NET 对象。虽然这在配置图形用户界面时非常有用,但请记住,XAML 可以描述来自任何程序集的任何类型,只要它是包含默认构造函数的非抽象类型。

使用 Visual Studio 构建 WPF 应用

让我们看看 Visual Studio 如何简化 WPF 程序的构造。虽然您可以使用 Visual Studio 代码生成 WPF 应用,但 Visual Studio 代码没有任何用于生成 WPF 应用的设计器支持。Visual Studio 具有丰富的 XAML 支持,在构建 WPF 应用时是一个更高效的 IDE。

Note

在这里,我将指出使用 Visual Studio 构建 WPF 应用的一些关键特性。接下来的章节将在必要的地方说明 IDE 的其他方面。

WPF 项目模板

Visual Studio 的新建项目对话框定义了一组 WPF 项目模板,包括 WPF App、WPF 自定义控件库、WPF 用户控件库。创建新的 WPF 应用(。NET)名为 WpfTesterApp 的项目。

Note

当从 Visual Studio“添加新项目”屏幕中选择 WPF 项目时,请确保选择具有“(”的 WPF 项目模板。NET)“在标题中,而不是”(。NET 框架)。”的当前版本。NET Core 已经被简单地重命名为。净 5。如果选择带有“(”的模板。NET Framework)”在标题中,您将使用。NET Framework 4.x。

除了将项目 SDK 设置为Microsoft.NET.Sdk之外,还将为您提供初始的WindowApplication派生类,每一个都使用 XAML 和 C# 代码文件来表示。

工具箱和 XAML 设计器/编辑器

Visual Studio 提供了一个工具箱(可以通过“视图”菜单打开),其中包含许多 WPF 控件。面板的顶部包含最常用的控件,底部包含所有控件(参见图 24-4 )。

img/340876_10_En_24_Fig4_HTML.jpg

图 24-4。

工具箱包含可以放置在设计器图面上的 WPF 控件

使用标准的拖放操作,您可以将这些控件中的任何一个放置在窗口的设计器图面上,或者将控件拖动到设计器底部的 XAML 标记编辑器中。当你这样做的时候,最初的 XAML 将会以你的名义被创作。用鼠标将一个Button控件和一个Calendar控件拖动到设计器表面上。完成后,请注意如何重新定位和调整控件的大小(并确保检查基于编辑生成的 XAML)。

除了通过鼠标和工具箱构建 UI 之外,您还可以使用集成的 XAML 编辑器手动输入标记。正如你在图 24-5 中看到的,你得到了智能感知支持,这可以帮助简化标记的创作。例如,尝试将Background属性添加到开始的<Window>元素中。

img/340876_10_En_24_Fig5_HTML.jpg

图 24-5。

WPF 橱窗设计师

花点时间在 XAML 编辑器中直接添加一些属性值。确保你花时间去适应使用 WPF 设计器的这个方面。

使用“属性”窗口设置属性

将一些控件放置到设计器上(或在编辑器中手动定义它们)后,可以利用“属性”窗口来设置所选控件的属性值,以及装配所选控件的事件处理程序。通过一个简单的测试,在设计器上选择您的Button控件。现在,使用属性窗口通过集成笔刷编辑器改变ButtonBackground颜色(见图 24-6;在你检查 WPF 图形的时候,你会在第二十六章学到更多关于笔刷编辑器的知识。

img/340876_10_En_24_Fig6_HTML.jpg

图 24-6。

“属性”窗口可用于配置 WPF 控件的用户界面

Note

“属性”窗口的顶部提供了一个搜索文本区域。键入要设置的属性的名称,以便快速找到有问题的项目。

在您完成了对画笔编辑器的修改之后,检查生成的标记。它可能看起来像这样:

<Button Content="Button" HorizontalAlignment="Left" Margin="10,10,0,0" VerticalAlignment="Top" Width="75">
  <Button.Background>
    <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
      <GradientStop Color="Black" Offset="0"/>
      <GradientStop Color="#FFE90E0E" Offset="1"/>
      <GradientStop Color="#FF1F4CE3"/>
    </LinearGradientBrush>
  </Button.Background>
</Button>

使用“属性”窗口处理事件

如果您想要处理给定控件的事件,也可以利用“属性”窗口,但是这一次您需要单击“属性”窗口右上角的“事件”按钮(寻找闪电图标)。确保在您的设计器上选择了按钮,并定位到Click事件。完成后,直接双击Click事件条目。这将导致 Visual Studio 自动生成一个事件处理程序,该处理程序采用以下常规形式:

NameOfControl_NameOfEvent

因为你没有重命名你的按钮,属性窗口显示它生成了一个名为Button_Click的事件处理程序(见图 24-7 )。

img/340876_10_En_24_Fig7_HTML.jpg

图 24-7。

使用“属性”窗口处理事件

同样,Visual Studio 在窗口的代码文件中生成了相应的 C# 事件处理程序。在这里,您可以添加任何类型的代码,这些代码必须在按钮被单击时执行。要进行快速测试,只需输入以下代码语句:

private void Button_Click(object sender, RoutedEventArgs e)
{
  MessageBox.Show("You clicked the button!");
}

在 XAML 编辑器中处理事件

您也可以直接在 XAML 编辑器中处理事件。举个例子,将鼠标放在<Window>元素中,输入MouseMove事件,后面跟着等号。一旦你这样做了,你会看到 Visual Studio 在你的代码文件中显示任何兼容的处理程序(如果它们存在的话),以及创建一个新的事件处理程序的选项(见图 24-8 )。

img/340876_10_En_24_Fig8_HTML.jpg

图 24-8。

使用 XAML 编辑器处理事件

让 IDE 创建MouseMove事件处理程序,输入以下代码,然后运行应用以查看结果:

private void MainWindow_MouseMove (object sender, MouseEventArgs e)
{
  this.Title = e.GetPosition(this).ToString();
}

Note

第二十八章讲述了 MVVM 和命令模式,这是在企业应用中处理点击事件的一种更好的方式。但是如果你只需要一个简单的应用,用一个直接的事件处理器来处理点击事件是完全可以接受的。

“文档大纲”窗口

当你处理任何基于 XAML 的项目时,你肯定会使用大量的标记来表示你的用户界面。当您开始处理更复杂的 XAML 时,可视化标记以快速选择要在 Visual Studio 设计器上编辑的项会很有用。

目前,您的标记相当平淡,因为您只在初始的<Grid>中定义了几个控件。但是,在 IDE 中找到文档大纲窗口,默认情况下,该窗口安装在 Visual Studio 的左侧(如果找不到,只需使用“查看➤其他窗口”菜单选项激活它)。现在,确保你的 XAML 设计器是 IDE 中的活动窗口(而不是 C# 代码文件),你会注意到文档大纲窗口显示了嵌套的元素(见图 24-9 )。

img/340876_10_En_24_Fig9_HTML.jpg

图 24-9。

通过文档大纲窗口可视化您的 XAML

此工具还提供了一种在设计器上临时隐藏给定项(或一组项)以及锁定项以防止发生额外编辑的方法。在下一章中,您将看到文档大纲窗口如何还提供了许多其他功能来将所选项目分组到新的布局管理器中(以及其他功能)。

启用或禁用 XAML 调试器

当您运行应用时,您会在屏幕上看到MainWindow。您还会看到交互式调试器,如图 24-10 所示。

img/340876_10_En_24_Fig10_HTML.jpg

图 24-10。

XAML 用户界面调试

如果你想关闭它,你可以在工具➤选项➤调试➤热重装下找到 XAML 调试的条目。取消选择顶部的框,以防止调试器窗口覆盖您的窗口。图 24-11 显示了条目。

img/340876_10_En_24_Fig11_HTML.jpg

图 24-11。

XAML 用户界面调试

检查 App.xaml 文件

项目如何知道启动哪个窗口?更有趣的是,如果您检查应用中的代码文件,您还会发现到处都找不到Main()方法。你已经通过这本书了解到应用必须有一个入口点,那么。不知道如何启动你的应用?幸运的是,这两个管道项目都是通过 Visual Studio 模板和 WPF 框架来处理的。

为了解决启动哪个窗口的难题,App.xaml文件通过标记定义了一个应用类。除了名称空间定义,它还定义了应用属性,如StartupUri、应用范围的资源(在第二十七章中介绍)以及应用事件的特定处理程序,如StartupExitStartupUri指示启动时加载哪个窗口。打开App.xaml文件并检查标记,如下所示:

<Application x:Class="WpfTesterApp.App"
             xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfTesterApp"
             StartupUri="MainWindow.xaml">
    <Application.Resources>
    </Application.Resources>
</Application>

使用 XAML 设计器和 Visual Studio 代码完成功能,为StartupExit事件添加处理程序。更新后的 XAML 应该是这样的(注意以粗体显示的变化):

<Application x:Class="WpfTesterApp.App"
             xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:local="clr-namespace:WpfTesterApp"
             StartupUri="MainWindow.xaml" Startup="App_OnStartup" Exit="App_OnExit">
    <Application.Resources>
    </Application.Resources>
</Application>

如果您查看App.xaml.cs文件,它应该是这样的:

public partial class App : Application
{
  private void App_OnStartup(object sender, StartupEventArgs e)
  {
  }
  private void App_OnExit(object sender, ExitEventArgs e)
  {
  }
}

请注意,该类被标记为分部类。事实上,XAML 文件的所有代码隐藏窗口都被标记为分部窗口。这是解开Main()方法存在于何处之谜的关键。但是首先,您需要检查当msbuild.exe处理 XAML 文件时发生了什么。

将窗口 XAML 标记映射到 C# 代码

msbuild.exe处理您的*.csproj文件时,它为您项目中的每个 XAML 文件生成三个文件,格式为*.g.cs(其中g表示自动生成)、*.g.i.cs(其中 i 表示智能感知)、以及*.baml(用于二进制应用标记语言)。这些文件被保存到\obj\Debug目录中(可以通过点击 Show All Files 按钮在 Solution Explorer 中查看)。您可能必须点击解决方案资源管理器中的刷新按钮才能看到它们,因为它们不是实际项目的一部分,而是构建工件。

为了更好地理解该过程,为控件提供名称会很有帮助。继续为ButtonCalendar控件提供名称,如下所示:

<Button Name="ClickMe" Content="Button" HorizontalAlignment="Left" Margin="10,10,0,0"
       VerticalAlignment="Top" Width="75" Click="Button_Click">
//omitted for brevity
</Button>
<Calendar Name="MyCalendar" HorizontalAlignment="Left" Margin="10,41,0,0" VerticalAlignment="Top"/>

现在重新生成您的解决方案(或项目)并刷新解决方案资源管理器中的文件。如果你在文本编辑器中打开MainWindow.g.cs文件,你会发现一个名为MainWindow的类,它扩展了Window基类。这个类的名字是由<Window>开始标签中的x:Class属性直接产生的。

这个类定义了一个类型为bool(名为_contentLoaded)的私有成员变量,它在 XAML 标记中没有被直接考虑。这个数据成员用于确定(并确保)窗口的内容只被分配一次。这个类还包含一个类型为System.Windows.Controls.Button的成员变量,名为ClickMe。控件的名称基于开始的<Button>声明中的x:Name(或者简写为form Name)属性值。你看不到的是Calendar控件的变量。这是因为msbuild.exe为 XAML 中每个名为的控件创建了一个变量,该控件在代码隐藏中有相关的代码。如果没有任何代码,就不需要变量。更令人困惑的是,如果您没有命名Button控件,它也不会有变量。这是 WPF 魔力的一部分,并且与IComponentConnector接口实现紧密相连。

编译器生成的类还显式实现了在System.Windows.Markup名称空间中定义的 WPF IComponentConnector接口。该接口定义了一个名为Connect()的方法,该方法已经被实现来准备标记中定义的每个控件,并按照原始MainWindow.xaml文件中指定的那样装配事件逻辑。您可以看到为ClickMe点击事件设置的处理程序。在该方法完成之前,_contentLoaded成员变量被设置为true。这是该方法的关键:

void System.Windows.Markup.IComponentConnector.Connect(int connectionId, object target)
{
  switch (connectionId)
  {
    case 1:
    this.ClickMe = ((System.Windows.Controls.Button)(target));
    #line 11 "..\..\MainWindow.xaml"
    this.ClickMe.Click += new System.Windows.RoutedEventHandler(this.Button_Click);
    #line default
    #line hidden
    return;
  }
  this._contentLoaded = true;
}

要用代码显示未命名控件的效果,请为日历上的SelectedDatesChanged事件添加一个事件处理程序。重新构建应用,刷新文件,并重新加载MainWindow.g.cs文件。在Connect()方法中,您现在可以看到下面的代码块:

#line 20 "..\..\MainWindow.xaml"
this.MyCalendar.SelectedDatesChanged += new
     System.EventHandler<System.Windows.Controls.SelectionChangedEventArgs>(
         this.MyCalendar_OnSelectedDatesChanged);

这告诉框架 XAML 文件第 20 行的控件分配了SelectedDatesChanged事件处理程序,如前面的代码所示。

最后,MainWindow类定义并实现了一个名为InitializeComponent()的方法。您可能希望这个方法包含通过设置各种属性(HeightWidthContent等)来设置每个控件的外观和感觉的代码。).然而事实并非如此!那么控件如何呈现正确的用户界面呢?带有InitializeComponent()的逻辑解析与原始*.xaml文件同名的嵌入式汇编资源的位置,如下所示:

public void InitializeComponent() {
  if (_contentLoaded) {
    return;
    }
    _contentLoaded = true;
    System.Uri resourceLocater = new System.Uri("/WpfTesterApp;component/mainwindow.xaml",
        System.UriKind.Relative);
    #line 1 "..\..\MainWindow.xaml"
    System.Windows.Application.LoadComponent(this, resourceLocater);
    #line default
    #line hidden
}

此时,问题变成了*“*这个嵌入式资源到底是什么?”

BAML 的角色

正如您可能从名称中猜到的那样,二进制应用标记语言(BAML)是原始 XAML 数据的一种紧凑的二进制表示。这个*.baml文件作为资源(通过一个生成的*.g.resources文件)嵌入到编译后的程序集中。这个 BAML 资源包含了建立 UI 小部件的外观所需的所有数据(同样,比如HeightWidth属性)。

这里重要的一点是理解 WPF 应用本身包含标记的二进制表示(BAML)。在运行时,这个 BAML 将从资源容器中提取出来,用于确保所有的窗口和控件都被初始化为正确的外观。

另外,请记住,这些二进制资源的名称与您创作的独立*.xaml文件的名称相同。然而,这并不意味着您必须将松散的*.xaml文件与您编译的 WPF 程序一起分发。除非您构建了一个 WPF 应用,可以在运行时动态加载和解析*.xaml文件,否则您永远不需要发送原始标记。

解开 Main 之谜()

现在您已经知道了 MSBuild 过程是如何工作的,打开App.g.cs文件。在这里,您将找到自动生成的Main()方法,它初始化并运行您的应用对象。

public static void Main() {
  WpfTesterApp.App app = new WpfTesterApp.App();
  app.InitializeComponent();
  app.Run();
}

InitializeComponent()方法配置应用属性,包括StartupUri以及StartupExit事件的事件处理程序。

public void InitializeComponent() {
    #line 5 "..\..\App.xaml"
    this.Startup += new System.Windows.StartupEventHandler(this.App_OnStartup);
    #line default
    #line hidden
    #line 5 "..\..\App.xaml"
    this.Exit += new System.Windows.ExitEventHandler(this.App_OnExit);
    #line default
    #line hidden
    #line 5 "..\..\App.xaml"
    this.StartupUri = new System.Uri("MainWindow.xaml", System.UriKind.Relative);
    #line default
    #line hidden
}

与应用级数据交互

回想一下,Application类定义了一个名为Properties的属性,它允许您通过类型索引器定义一组名称-值对。因为这个索引器被定义为在类型System.Object上操作,所以您可以在这个集合中存储任何种类的项(包括您的自定义类),以便以后使用友好的名字对象进行检索。使用这种方法,在 WPF 应用的所有窗口之间共享数据变得很简单。

举例来说,您将更新当前的Startup事件处理程序,以检查名为/GODMODE(许多 PC 视频游戏的常见欺骗代码)的值的传入命令行参数。如果找到这个标记,您将在同名的 properties 集合中建立一个设置为truebool值(否则,您将把该值设置为false)。

这听起来很简单,但是如何将传入的命令行参数(通常从Main()方法获得)传递给Startup事件处理程序呢?一种方法是调用静态的Environment.GetCommandLineArgs()方法。然而,这些相同的参数被自动添加到传入的StartupEventArgs参数中,并且可以通过Args属性来访问。以下是对当前代码库的首次更新:

private void App_OnStartup(object sender, StartupEventArgs e)
{
  Application.Current.Properties["GodMode"] = false;
  // Check the incoming command-line arguments and see if they
  // specified a flag for /GODMODE.
  foreach (string arg in e.Args)
  {
    if (arg.Equals("/godmode",StringComparison.OrdinalIgnoreCase))
    {
      Application.Current.Properties["GodMode"] = true;
      break;
    }
  }
}

可以从 WPF 应用中的任何位置访问应用范围的数据。您需要做的就是获得一个全局应用对象的访问点(通过Application.Current)并研究这个集合。例如,您可以这样更新ButtonClick事件处理程序:

private void Button_Click(object sender, RoutedEventArgs e)
{
  // Did user enable /godmode?
  if ((bool)Application.Current.Properties["GodMode"])
  {
    MessageBox.Show("Cheater!");
  }
}

这样,如果您在项目属性的 Debug 选项卡中输入/godmode命令行参数,然后运行程序,您将会感到羞愧,程序将会退出。您也可以通过输入以下命令从命令行运行该程序(打开命令提示符并导航到bin/debug目录):

WpfAppAllCode.exe /godmode

当终止应用时,你会看到一个不光彩的消息框。

Note

回想一下,您可以在 Visual Studio 中提供命令行参数。只需双击解决方案资源管理器中的属性图标,在结果编辑器中单击 Debug 选项卡,然后在“命令行参数”编辑器中输入/godmode

处理窗口对象的关闭

最终用户可以通过使用许多内置的系统级技术(例如,单击窗口框架上的 x 关闭按钮)或通过间接调用Close()方法来响应一些用户交互元素(例如,文件➤退出)来关闭窗口。在这两种情况下,WPF 都提供了两个事件,您可以截取它们来确定用户是否真的准备好关闭窗口并从内存中删除它。第一个触发的事件是Closing,它与CancelEventHandler委托一起工作。

该委托期望目标方法将System.ComponentModel.CancelEventArgs作为第二个参数。CancelEventArgs提供了Cancel属性,当设置为true时,将阻止窗口实际关闭(当您询问用户是否真的想关闭窗口,或者他是否想先保存他的工作时,这很方便)。

如果用户确实想关闭窗口,可以将CancelEventArgs.Cancel设置为false(默认设置)。这将导致Closed事件被触发(与System.EventHandler委托一起工作),使它成为窗口将要被永久关闭的点。

通过将这些代码语句添加到当前构造函数中,更新MainWindow类来处理这两个事件,如下所示:

public MainWindow()
{
  InitializeComponent();
  this.Closed+=MainWindow_Closed;
  this.Closing += MainWindow_Closing;
}

现在,实现相应的事件处理程序,如下所示:

private void MainWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
  // See if the user really wants to shut down this window.
  string msg = "Do you want to close without saving?";
  MessageBoxResult result = MessageBox.Show(msg,
    "My App", MessageBoxButton.YesNo, MessageBoxImage.Warning);
  if (result == MessageBoxResult.No)
  {
    // If user doesn't want to close, cancel closure.
    e.Cancel = true;
  }
}

private void MainWindow_Closed(object sender, EventArgs e)
{
  MessageBox.Show("See ya!");
}

现在,运行您的程序并尝试关闭窗口,方法是单击窗口右上角的 X 图标或单击按钮控件。您应该会看到确认对话框,询问您是否真的要离开。如果您回答是,您将会看到告别信息。单击“否”按钮会将该窗口保留在内存中。

拦截鼠标事件

WPF API 提供了几个事件,您可以捕捉这些事件来与鼠标交互。具体来说,UIElement基类定义了以鼠标为中心的事件,比如MouseMoveMouseUpMouseDownMouseEnterMouseLeave等等。

例如,考虑处理MouseMove事件的行为。该事件与System.Windows.Input.MouseEventHandler委托协同工作,该委托期望其目标将一个System.Windows.Input.MouseEventArgs类型作为第二个参数。使用MouseEventArgs,可以提取鼠标的(x,y)位置和其他相关细节。考虑以下部分定义:

public class MouseEventArgs : InputEventArgs
{
...
  public Point GetPosition(IInputElement relativeTo);
  public MouseButtonState LeftButton { get; }
  public MouseButtonState MiddleButton { get; }
  public MouseDevice MouseDevice { get; }
  public MouseButtonState RightButton { get; }
  public StylusDevice StylusDevice { get; }
  public MouseButtonState XButton1 { get; }
  public MouseButtonState XButton2 { get; }
}

Note

XButton1XButton2属性允许您与“扩展鼠标按钮”交互(例如一些鼠标控件上的“下一个”和“上一个”按钮)。这些通常用于与浏览器的历史列表交互,以便在访问过的页面之间导航。

方法允许你获得相对于窗口中 UI 元素的(x,y)值。如果您对捕捉相对于激活窗口的位置感兴趣,只需传入this。在你的MainWindow类的构造函数中处理MouseMove事件,就像这样:

public MainWindow(string windowTitle, int height, int width)
{
...
  this.MouseMove += MainWindow_MouseMove;
}

这里有一个MouseMove的事件处理程序,它将在窗口的标题区域显示鼠标的位置(注意,您正在通过ToString()将返回的Point类型转换为文本值):

private void MainWindow_MouseMove(object sender,
  System.Windows.Input.MouseEventArgs e)
{
  // Set the title of the window to the current (x,y) of the mouse.
  this.Title = e.GetPosition(this).ToString();
}

截取键盘事件

为聚焦窗口处理键盘输入也很简单。UIElement定义您可以捕获的事件,以截取活动元素上键盘的按键(例如KeyUpKeyDown)。KeyUpKeyDown事件都与System.Windows.Input.KeyEventHandler委托一起工作,委托期望目标的第二个事件处理程序是KeyEventArgs类型,它定义了几个感兴趣的公共属性,如下所示:

public class KeyEventArgs : KeyboardEventArgs
{
...
  public bool IsDown { get; }
  public bool IsRepeat { get; }
  public bool IsToggled { get; }
  public bool IsUp { get; }
  public Key Key { get; }
  public KeyStates KeyStates { get; }
  public Key SystemKey { get; }
}

为了说明如何在MainWindow的构造函数中处理KeyDown事件(就像您对前面事件所做的那样),实现下面的事件处理程序,它用当前按下的键来改变按钮的内容:

private void MainWindow0s_KeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
  // Display key press on the button.
  ClickMe.Content = e.Key.ToString();
}

在本章的这一点上,WPF 可能看起来只不过是另一个 GUI 框架,提供(或多或少)与 Windows 窗体、MFC 或 VB6 相同的服务。如果事实上是这样的话,您可能会质疑是否还需要另一个 UI 工具包。要真正了解 WPF 的独特之处,你需要理解基于 XML 的语法,XAML。

摘要

windows Presentation Foundation(WPF)是随发行版推出的用户界面工具包。NET 3.0。WPF 的主要目标是集成和统一以前不相关的桌面技术(2D 图形、3D 图形、窗口和控件开发等)。)集成到一个统一的编程模型中。除此之外,WPF 程序通常使用 XAML,它允许你通过标记声明 WPF 元素的外观。

回想一下 XAML 允许你描述。NET 对象使用声明性语法。在本章对 XAML 的研究中,您接触到了一些新的语法,包括属性元素语法和附加属性,以及类型转换器和 XAML 标记扩展的作用。

XAML 是任何生产级 WPF 应用的一个关键方面。本章的最后一个例子让你有机会构建一个 WPF 应用,展示本章中讨论的许多概念。接下来的章节将会深入这些概念,并引入更多的概念。*

二十五、WPF 控件、布局、事件和数据绑定

第二十四章为 WPF 编程模型提供了基础,包括对WindowApplication类的检查,XAML 的语法,以及代码文件的使用。第二十四章还向您介绍了使用 Visual Studio 的设计器构建 WPF 应用的过程。在本章中,您将使用几个新的控件和布局管理器深入研究更复杂的图形用户界面的构造,同时了解 Visual Studio 的 XAML 版 WPF 可视化设计器的其他功能。

本章还将研究一些重要的相关 WPF 控制主题,如数据绑定编程模型和控制命令的使用。您还将学习如何使用 Ink 和 Documents APIs,这两个 API 分别允许您捕获手写笔(或鼠标)输入和使用 XML Paper 规范构建富文本文档。

WPF 核心控制措施调查

除非您对构建图形用户界面的概念不熟悉(这很好),否则主要 WPF 控件的一般用途应该不会引起太多问题。不管你过去可能使用过哪种 GUI 工具包(例如 VB6、MFC、Java AWT/Swing、Windows Forms、macOS 或 GTK+/GTK #[等等]),表 25-1 中列出的核心 WPF 控件可能看起来很熟悉。

表 25-1。

核心 WPF 控件

|

WPF 控制类别

|

成员示例

|

生命的意义

核心用户输入控件 ButtonRadioButtonComboBoxCheckBoxCalendarDatePickerExpanderDataGridListBoxListViewToggleButtonTreeViewContextMenuScrollBarSliderTabControlTextBlockTextBoxRepeatButtonRichTextBoxLabel WPF 提供了一个完整的控件家族,你可以用它来构建用户界面的核心。
窗口和控件装饰 MenuToolBarStatusBarToolTipProgressBar 您使用这些 UI 元素来装饰带有输入设备(如Menu)和用户信息元素(如StatusBarToolTip)的Window对象的框架。
媒体控制 ImageMediaElementSoundPlayerAction 这些控件支持音频/视频回放和图像显示。
布局控件 BorderCanvasDockPanelGridGridViewGridSplitterGroupBoxPanelTabControlStackPanelViewboxWrapPanel WPF 提供了许多控件,允许您分组和组织其他控件以进行布局管理。

Note

本章的目的是而不是介绍每个 WPF 控件的每个成员。相反,您将获得各种控件的概述,重点是大多数 WPF 控件通用的基础编程模型和关键服务。

WPF 油墨控制

除了表 25-1 中列出的常见 WPF 控件,WPF 还定义了用于数字墨水 API 的附加控件。WPF 开发的这一方面在 Tablet PC 开发过程中非常有用,因为它允许您从手写笔捕获输入。然而,这并不是说标准的桌面应用不能利用 Ink API,因为相同的控件可以使用鼠标捕获输入。

PresentationCore.dllSystem.Windows.Ink名称空间包含各种 Ink API 支持类型(如StrokeStrokeCollection);然而,大多数的 Ink API 控件(例如,InkCanvasInkPresenter)都与通用的 WPF 控件一起打包在PresentationFramework.dll汇编中的System.Windows.Controls名称空间下。在本章的后面,您将使用 Ink API。

WPF 文件控制

WPF 还提供了高级文档处理控件,允许您构建包含 Adobe PDF 样式功能的应用。使用System.Windows.Documents名称空间中的类型(也在PresentationFramework.dll汇编中),您可以创建支持缩放、搜索、用户注释(便笺)和其他富文本服务的打印就绪文档。

然而,在封面下,文档控件不使用 Adobe PDF APIs 相反,他们使用 XML 纸张规范(XPS) API。对最终用户来说,看起来真的没有区别,因为 PDF 文档和 XPS 文档具有几乎相同的外观和感觉。事实上,您可以找到许多免费的实用程序,允许您在两种文件格式之间进行动态转换。由于篇幅限制,这些控件将不在本版中讨论。

WPF 通用对话框

WPF 还为您提供了一些常用的对话框,如OpenFileDialogSaveFileDialog。这些对话框是在PresentationFramework.dll程序集的Microsoft.Win32名称空间中定义的。使用这些对话框都是创建一个对象并调用ShowDialog()方法,就像这样:

using Microsoft.Win32;
//omitted for brevity
private void btnShowDlg_Click(object sender, RoutedEventArgs e)
{
  // Show a file save dialog.
  SaveFileDialog saveDlg = new SaveFileDialog();
  saveDlg.ShowDialog();
}

正如您所希望的,这些类支持各种成员,这些成员允许您建立文件过滤器和目录路径,并获得对用户选择的文件的访问。您将在后面的示例中使用这些文件对话框;您还将学习如何构建自定义对话框来收集用户输入。

Visual Studio WPF 设计器简评

这些标准 WPF 控件中的大部分都被打包在PresentationFramework.dll程序集的System.Windows.Controls名称空间中。当您使用 Visual Studio 构建 WPF 应用时,如果您有一个作为活动窗口打开的 WPF 设计器,您会发现工具箱中包含大多数这些常用控件。

类似于用 Visual Studio 创建的其他 UI 框架,你可以将这些控件拖到 WPF 窗口设计器上,并使用属性窗口配置它们(你在第二十四章中学到了)。虽然 Visual Studio 会为您生成大量的 XAML,但您自己手动编辑标记的情况并不少见。我们来复习一下基础知识。

使用 Visual Studio 处理 WPF 控件

你可能还记得第二十四章中的,当你把一个 WPF 控件放到 Visual Studio 设计器上时,你想通过属性窗口(或者直接通过 XAML)设置x:Name属性,因为这允许你访问相关 C# 代码文件中的对象。您可能还记得,可以使用“属性”窗口的“事件”选项卡为选定的控件生成事件处理程序。因此,您可以使用 Visual Studio 为一个简单的Button控件生成以下标记:

<Button x:Name="btnMyButton" Content="Click Me!" Height="23" Width="140" Click="btnMyButton_Click" />

这里,您将ButtonContent属性设置为一个简单的值为"Click Me!"string。然而,由于 WPF 控件内容模型,您可以设计一个包含以下复杂内容的Button:

<Button x:Name="btnMyButton" Height="121" Width="156" Click="btnMyButton_Click">
  <Button.Content>
    <StackPanel Height="95" Width="128" Orientation="Vertical">
      <Ellipse Fill="Red" Width="52" Height="45" Margin="5"/>
      <Label Width="59" FontSize="20" Content="Click!" Height="36" />
    </StackPanel>
  </Button.Content>
</Button>

您可能还记得,ContentControl派生类的直接子元素是隐含的内容;因此,在指定复杂内容时,您不需要明确定义一个Button.Content范围。您可以简单地编写以下内容:

<Button x:Name="btnMyButton" Height="121" Width="156" Click="btnMyButton_Click">
  <StackPanel Height="95" Width="128" Orientation="Vertical">
    <Ellipse Fill="Red" Width="52" Height="45" Margin="5"/>
    <Label Width="59" FontSize="20" Content="Click!" Height="36" />
  </StackPanel>
</Button>

在这两种情况下,都要将按钮的Content属性设置为相关项目的StackPanel。您还可以使用 Visual Studio 设计器创作这种复杂的内容。为内容控件定义布局管理器后,可以在设计器上选择它作为内部控件的放置目标。此时,您可以使用“属性”窗口编辑每个属性。如果您要使用“属性”窗口来处理Button控件的Click事件(如前面的 XAML 声明所示),IDE 将生成一个空的事件处理程序,您可以向其中添加自己的自定义代码,如下所示:

private void btnMyButton_Click(object sender, RoutedEventArgs e)
{
  MessageBox.Show("You clicked the button!");
}

使用文档大纲编辑器

您应该还记得,在前一章中,Visual Studio 的“文档大纲”窗口(可以使用“查看➤其他窗口”菜单打开)在设计包含复杂内容的 WPF 控件时非常有用。为您正在构建的Window显示 XAML 的逻辑树,如果您单击这些节点中的任何一个,它将在可视设计器和 XAML 编辑器中被自动选中进行编辑。

在当前版本的 Visual Studio 中,“文档大纲”窗口有一些您可能会觉得有用的附加功能。在任何节点的右边,你会发现一个看起来像眼球的图标。当您切换此按钮时,您可以选择隐藏或显示设计器上的一个项目,这在您想要聚焦于要编辑的特定片段时会很有帮助(注意,这将而不是在运行时隐藏项目;这只隐藏设计器图面上的项)。

紧挨着“眼球图标”的是第二个开关,允许您锁定设计器上的一个项目。正如您可能猜到的,当您希望确保您(或您的同事)不会意外更改给定项目的 XAML 时,这可能会很有帮助。实际上,锁定一个项会使它在设计时成为只读的(但是,您可以在运行时更改对象的状态)。

使用面板控制内容布局

WPF 应用总是包含大量的 UI 元素(例如,用户输入控件、图形内容、菜单系统和状态栏),这些元素需要在不同的窗口中进行良好的组织。放置 UI 元素后,您需要确保当最终用户调整窗口大小或可能调整窗口的一部分时(如拆分窗口的情况),它们的行为符合预期。为了确保你的 WPF 控件在托管窗口中保持它们的位置,你可以利用大量的面板类型(也称为布局管理器)。

默认情况下,用 Visual Studio 创建的新 WPF Window将使用类型Grid的布局管理器(稍后会有更多细节)。然而,现在假设一个没有声明布局管理器的Window,如下所示:

<Window x:Class="MyWPFApp.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Title="Fun with Panels!" Height="285" Width="325">

</Window>

当您直接在不使用面板的窗口中声明控件时,该控件位于容器的正中央。考虑下面这个简单的窗口声明,它包含一个Button控件。无论您如何调整窗口大小,UI 小部件始终与客户区的四边等距。Button的大小由分配给ButtonHeightWidth属性决定。

<!- This button is in the center of the window at all times ->
<Window x:Class="MyWPFApp.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Title="Fun with Panels!" Height="285" Width="325">

  <Button x:Name="btnOK" Height = "100" Width="80" Content="OK"/>
</Window>

您可能还记得,如果您试图将多个元素直接放在一个Window的范围内,您将会收到标记和编译时错误。这些错误的原因是一个窗口(或者任何一个ContentControl的后代)只能分配一个对象给它的Content属性。因此,下面的 XAML 会产生标记和编译时错误:

<!- Error! Content property is implicitly set more than once! ->
<Window x:Class="MyWPFApp.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Title="Fun with Panels!" Height="285" Width="325">
  <!- Error! Two direct child elements of the <Window>! ->
  <Label x:Name="lblInstructions" Width="328" Height="27" FontSize="15" Content="Enter Information"/>
  <Button x:Name="btnOK" Height = "100" Width="80" Content="OK"/>
</Window>

显然,只能包含单个控件的窗口用处不大。当一个窗口需要包含多个元素时,这些元素必须排列在任意数量的面板中。面板将包含代表窗口的所有 UI 元素,之后面板本身被用作分配给Content属性的单个对象。

System.Windows.Controls名称空间提供了许多面板,每个面板控制如何维护子元素。如果最终用户调整了窗口的大小,如果控件保持在设计时放置的位置,如果控件从左到右水平重排或从上到下垂直重排,等等,都可以使用面板来确定控件的行为。

您还可以在其他面板中混合面板控件(例如,包含其他项目的StackPanelDockPanel),以提供大量的灵活性和控制。表 25-2 记录了一些常用 WPF 面板控件的作用。

表 25-2。

核心 WPF 面板控制

|

面板控制

|

生命的意义

Canvas 提供内容放置的经典模式。项目会停留在设计时放置它们的地方。
DockPanel 将内容锁定到面板的指定一侧(TopBottomLeftRight)。
Grid 在表格网格中维护的一系列单元格内排列内容。
StackPanel 按照Orientation属性的指示,以垂直或水平方式堆叠内容。
WrapPanel 从左到右放置内容,在包含框的边缘将内容换行。根据Orientation属性的值,后续排序从上到下或从右到左依次进行。

在接下来的几节中,您将通过将一些预定义的 XAML 数据复制到您在第二十四章中安装的kaxaml.exe应用中来学习如何使用这些常用的面板类型。你可以在你的章节 25 代码下载文件夹的PanelMarkup子文件夹中找到所有这些松散的 XAML 文件。使用 Kaxaml 时,要模拟调整窗口大小,请在标记中更改Page元素的高度或宽度。

在画布面板中定位内容

如果你来自 WinForms 背景,你可能会觉得使用Canvas面板最舒服,因为它允许 UI 内容的绝对定位。如果最终用户调整窗口的大小,使其小于由Canvas面板维护的布局,那么直到容器被拉伸到等于或大于Canvas区域的大小时,内部内容才可见。

要向Canvas添加内容,首先要在开始和结束Canvas标记的范围内定义所需的控件。接下来,指定每个控件的左上角;这是使用Canvas.TopCanvas.Left属性开始渲染的地方。您可以通过设置控件的HeightWidth属性来间接指定每个控件的右下角区域,或者通过使用Canvas.RightCanvas.Bottom属性来直接指定。

要查看Canvas的运行,使用kaxaml.exe打开提供的SimpleCanvas.xaml文件。您应该会看到下面的Canvas定义(如果将这些例子加载到 WPF 应用中,您会希望将Page标签改为Window标签):

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Title="Fun with Panels!" Height="285" Width="325">
  <Canvas Background="LightSteelBlue">
    <Button x:Name="btnOK" Canvas.Left="212" Canvas.Top="203" Width="80" Content="OK"/>
    <Label x:Name="lblInstructions" Canvas.Left="17" Canvas.Top="14" Width="328" Height="27" FontSize="15"
           Content="Enter Car Information"/>
    <Label x:Name="lblMake" Canvas.Left="17" Canvas.Top="60" Content="Make"/>
    <TextBox x:Name="txtMake" Canvas.Left="94" Canvas.Top="60" Width="193" Height="25"/>
    <Label x:Name="lblColor" Canvas.Left="17" Canvas.Top="109" Content="Color"/>
    <TextBox x:Name="txtColor" Canvas.Left="94" Canvas.Top="107" Width="193" Height="25"/>
    <Label x:Name="lblPetName" Canvas.Left="17" Canvas.Top="155" Content="Pet Name"/>
    <TextBox x:Name="txtPetName" Canvas.Left="94" Canvas.Top="153" Width="193" Height="25"/>
  </Canvas>
</Page>

您应该会在屏幕的上半部分看到如图 25-1 所示的窗口。

img/340876_10_En_25_Fig1_HTML.jpg

图 25-1。

画布布局管理器允许内容的绝对定位

请注意,您在Canvas中声明内容的顺序并不用于计算位置;相反,放置是基于控件的大小和Canvas.TopCanvas.BottomCanvas.LeftCanvas.Right属性。

Note

如果Canvas中的子元素没有使用附加属性语法定义特定的位置(例如Canvas.LeftCanvas.Top,它们会自动附加到Canvas的左上角。

使用Canvas类型似乎是安排内容的首选方式(因为感觉很熟悉),但是这种方法有一些限制。首先,Canvas中的项目在应用样式或模板时不会自动调整大小(例如,它们的字体大小不受影响)。其次,当最终用户将窗口调整到更小的表面时,Canvas不会试图保持元素可见。

也许Canvas类型的最佳用途是定位图形内容。例如,如果您使用 XAML 构建自定义图像,您肯定希望线条、形状和文本保持在相同的位置,而不是在用户调整窗口大小时看到它们动态地重新定位!当你研究 WPF 的图形渲染服务时,你会在第二十六章中重温Canvas

在 WrapPanel 面板中定位内容

一个WrapPanel允许你定义当窗口调整大小时在面板上流动的内容。当在WrapPanel中定位元素时,不需要像通常使用Canvas那样指定顶部、底部、左侧和右侧的停靠值。但是,每个子元素可以自由定义一个HeightWidth值(以及其他属性值)来控制它在容器中的总大小。

因为WrapPanel中的内容不停靠在面板的给定侧,所以声明元素的顺序很重要(内容从第一个元素到最后一个元素呈现)。如果您要加载在SimpleWrapPanel.xaml文件中找到的 XAML 数据,您会发现它包含以下标记(包含在Page定义中):

<WrapPanel Background="LightSteelBlue">
  <Label x:Name="lblInstruction" Width="328" Height="27" FontSize="15" Content="Enter Car Information"/>
  <Label x:Name="lblMake" Content="Make"/>
  <TextBox x:Name="txtMake" Width="193" Height="25"/>
  <Label x:Name="lblColor" Content="Color"/>
  <TextBox x:Name="txtColor" Width="193" Height="25"/>
  <Label x:Name="lblPetName" Content="Pet Name"/>
  <TextBox x:Name="txtPetName" Width="193" Height="25"/>
  <Button x:Name="btnOK" Width="80" Content="OK"/>
</WrapPanel>

当你加载这个标记时,内容看起来是乱序的,因为它从左到右流过窗口(见图 25-2 )。

img/340876_10_En_25_Fig2_HTML.jpg

图 25-2。

一个 WrapPanel 中的内容表现得很像一个传统的 HTML 页面

默认情况下,WrapPanel中的内容从左向右排列。但是,如果您将Orientation属性的值更改为Vertical,您可以让内容以自顶向下的方式换行。

<WrapPanel Background="LightSteelBlue" Orientation ="Vertical">

您可以通过指定ItemWidthItemHeight值来声明一个WrapPanel(以及其他一些面板类型),这两个值控制每个项目的默认大小。如果一个子元素确实提供了它自己的Height和/或Width值,那么它将相对于面板确定的大小进行定位。考虑以下标记:

<Page
    xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Fun with Panels!" Height="100" Width="650">
  <WrapPanel Background="LightSteelBlue" Orientation ="Horizontal" ItemWidth ="200" ItemHeight ="30">
  <Label x:Name="lblInstruction" FontSize="15" Content="Enter Car Information"/>
  <Label x:Name="lblMake" Content="Make"/>
  <TextBox x:Name="txtMake"/>
  <Label x:Name="lblColor" Content="Color"/>
  <TextBox x:Name="txtColor"/>
  <Label x:Name="lblPetName" Content="Pet Name"/>
  <TextBox x:Name="txtPetName"/>
  <Button x:Name="btnOK" Width ="80" Content="OK"/>
</WrapPanel>
</Page>

呈现的代码如图 25-3 (注意Button控件的大小和位置,它有一个指定的唯一Width值)。

img/340876_10_En_25_Fig3_HTML.jpg

图 25-3。

一个 WrapPanel 可以建立给定项目的宽度和高度

看了图 25-3 后,你可能会同意,WrapPanel通常不是直接在窗口中排列内容的最佳选择,因为当用户调整窗口大小时,它的元素会变得混乱。在大多数情况下,WrapPanel将是另一个面板类型的子元素,允许窗口的一小部分区域在调整大小时包装其内容(例如,ToolBar控件)。

在堆栈面板中定位内容

WrapPanel一样,StackPanel控件根据分配给Orientation属性的值,将内容排列成水平或垂直方向的单行(默认)。然而,不同之处在于,当用户调整窗口大小时,StackPanel而不是尝试包装内容。相反,StackPanel中的项目将简单地伸展(基于它们的方向)以适应StackPanel本身的大小。例如,SimpleStackPanel.xaml文件包含以下标记,其输出如图 25-4 所示:

img/340876_10_En_25_Fig4_HTML.jpg

图 25-4。

内容的垂直堆叠

<Page
    xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    Title="Fun with Panels!" Height="200" Width="400">
  <StackPanel Background="LightSteelBlue" Orientation ="Vertical">
    <Label Name="lblInstruction"
           FontSize="15" Content="Enter Car Information"/>
    <Label Name="lblMake" Content="Make"/>
    <TextBox Name="txtMake"/>
    <Label Name="lblColor" Content="Color"/>
    <TextBox Name="txtColor"/>
    <Label Name="lblPetName" Content="Pet Name"/>
    <TextBox Name="txtPetName"/>
    <Button Name="btnOK" Width ="80" Content="OK"/>
  </StackPanel>
</Page>

如果您将Orientation属性分配给Horizontal,如下所示,渲染输出将与图 25-5 所示相匹配:

img/340876_10_En_25_Fig5_HTML.jpg

图 25-5。

内容的水平堆叠

<StackPanel Background="LightSteelBlue" Orientation="Horizontal">

同样,与使用WrapPanel的情况一样,您很少会想要使用StackPanel来直接在窗口中排列内容。相反,你应该使用StackPanel作为主面板的子面板。

在网格面板中定位内容

在 WPF API 提供的所有面板中,Grid无疑是最灵活的。像 HTML 表格一样,Grid可以被分割成一组单元格,每个单元格都提供内容。当定义一个Grid时,你执行这三个步骤:

  1. 定义和配置每个列。

  2. 定义和配置每一行。

  3. 使用附加属性语法将内容分配给网格的每个单元格。

Note

如果您没有定义任何行或列,Grid默认为填充整个窗口表面的单个单元格。此外,如果您没有为Grid中的子元素分配单元格值(列和行),它会自动附加到列 0,行 0。

您可以通过使用Grid.ColumnDefinitionsGrid.RowDefinitions元素来实现前两步(定义列和行),这两个元素分别包含一个ColumnDefinitionRowDefinition元素的集合。网格中的每个单元格都是真实的。NET 对象,因此您可以根据自己的需要配置每个单元格的外观和行为。

这里有一个Grid定义(你可以在SimpleGrid.xaml文件中找到)安排你的 UI 内容,如图 25-6 所示:

img/340876_10_En_25_Fig6_HTML.jpg

图 25-6。

行动中的Grid面板

<Grid ShowGridLines ="True" Background ="LightSteelBlue">
  <!-- Define the rows/columns -->
  <Grid.ColumnDefinitions>
    <ColumnDefinition/>
    <ColumnDefinition/>
  </Grid.ColumnDefinitions>
  <Grid.RowDefinitions>
    <RowDefinition/>
    <RowDefinition/>
  </Grid.RowDefinitions>

  <!-- Now add the elements to the grid's cells -->
  <Label x:Name="lblInstruction" Grid.Column ="0" Grid.Row ="0"
         FontSize="15" Content="Enter Car Information"/>
  <Button x:Name="btnOK" Height ="30" Grid.Column ="0"
          Grid.Row ="0" Content="OK"/>
  <Label x:Name="lblMake" Grid.Column ="1"
         Grid.Row ="0" Content="Make"/>
  <TextBox x:Name="txtMake" Grid.Column ="1"
           Grid.Row ="0" Width="193" Height="25"/>
  <Label x:Name="lblColor" Grid.Column ="0"
         Grid.Row ="1" Content="Color"/>
  <TextBox x:Name="txtColor" Width="193" Height="25"
           Grid.Column ="0" Grid.Row ="1" />

  <!-- Just to keep things interesting, add some color to the pet name cell -->
  <Rectangle Fill ="LightGreen" Grid.Column ="1" Grid.Row ="1" />
  <Label x:Name="lblPetName" Grid.Column ="1" Grid.Row ="1" Content="Pet Name"/>
  <TextBox x:Name="txtPetName" Grid.Column ="1" Grid.Row ="1"
           Width="193" Height="25"/>
</Grid>

注意,每个元素(包括一个浅绿的Rectangle元素)使用Grid.RowGrid.Column附加属性将自己连接到网格中的一个单元格。默认情况下,网格中单元格的排序从左上角开始,这是使用Grid.Column="0" Grid.Row="0"指定的。假设您的网格总共定义了四个单元格,您可以使用Grid.Column="1" Grid.Row="1"来标识右下角的单元格。

调整网格列和行的大小

网格中的列和行可以用三种方法之一来调整大小。

  • 绝对尺寸(例如,100)

  • 自动尺寸监控

  • 相对规模(例如,3 倍)

绝对大小正是您所期望的;列(或行)的大小被调整为特定数量的与设备无关的单元。根据列或行中包含的控件自动调整每列或行的大小。相对大小相当于 CSS 中的百分比大小。相对大小的列或行中的数字总数除以可用空间总量。

在以下示例中,第一行获得 25%的空间,第二行获得 75%的空间:

<Grid.ColumnDefinitions>
  <ColumnDefinition Width="1*" />
  <ColumnDefinition Width="3*" />
</Grid.ColumnDefinitions>

具有 GridSplitter 类型的网格

Grid对象也可以支持拆分器。您可能知道,拆分器允许最终用户调整网格类型的行或列的大小。完成后,每个可调整大小的单元格内的内容将根据项目的包含方式调整自身的形状。向Grid添加分割器很容易做到;您只需定义GridSplitter控件,使用附加的属性语法来确定它影响的行或列。

请注意,您必须指定一个WidthHeight值(取决于垂直或水平拆分),以便拆分器在屏幕上可见。考虑下面这个简单的Grid类型,在第一列有一个分割器(Grid.Column = "0")。提供的GridWithSplitter.xaml文件的内容如下:

<Grid Background ="LightSteelBlue">
  <!-- Define columns -->
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width ="Auto"/>
    <ColumnDefinition/>
  </Grid.ColumnDefinitions>

  <!-- Add this label to cell 0 -->
  <Label x:Name="lblLeft" Background ="GreenYellow"
         Grid.Column="0" Content ="Left!"/>

  <!-- Define the splitter -->
  <GridSplitter Grid.Column ="0" Width ="5"/>

  <!-- Add this label to cell 1 -->
  <Label x:Name="lblRight" Grid.Column ="1" Content ="Right!"/>
</Grid>

首先,请注意将支持拆分器的列有一个AutoWidth属性。接下来,请注意,GridSplitter使用附加的属性语法来建立它正在处理的列。如果您要查看这个输出,您会发现一个五像素的分割器,它允许您调整每个Label的大小。请注意,内容填满了整个单元格,因为您没有为任何一个Label指定HeightWidth属性(参见图 25-7 )。

img/340876_10_En_25_Fig7_HTML.jpg

图 25-7。

Grid包含拆分器的类型

在 DockPanel 面板中定位内容

DockPanel通常用作容纳任意数量的附加面板的容器,用于对相关内容进行分组。使用附加属性语法(如CanvasGrid类型所示)来控制每个条目在DockPanel中的停靠位置。

SimpleDockPanel.xaml文件定义了以下简单的DockPanel定义,其输出如图 25-8 所示:

img/340876_10_En_25_Fig8_HTML.jpg

图 25-8。

一个简单的DockPanel

<DockPanel LastChildFill ="True" Background="AliceBlue">
  <!-- Dock items to the panel -->
  <Label DockPanel.Dock ="Top" Name="lblInstruction" FontSize="15" Content="Enter Car Information"/>
  <Label DockPanel.Dock ="Left" Name="lblMake" Content="Make"/>
  <Label DockPanel.Dock ="Right" Name="lblColor" Content="Color"/>
  <Label DockPanel.Dock ="Bottom" Name="lblPetName" Content="Pet Name"/>
  <Button Name="btnOK" Content="OK"/>
</DockPanel>

Note

如果将多个元素添加到DockPanel的同一侧,它们将按照声明的顺序沿着指定的边缘堆叠。

使用DockPanel类型的好处是,当用户调整窗口大小时,每个元素保持连接到面板的指定边(通过DockPanel.Dock)。还要注意,本例中开始的DockPanel标签将LastChildFill属性设置为true。鉴于Button控件确实是容器中的“最后一个子控件”,因此它将在剩余的空间内被拉伸。

启用面板类型的滚动

值得指出的是,WPF 提供了一个ScrollViewer类,为面板对象中的数据提供自动滚动行为。SimpleScrollViewer.xaml文件定义了以下内容:

<ScrollViewer>
  <StackPanel>
    <Button Content ="First" Background = "Green" Height ="50"/>
    <Button Content ="Second" Background = "Red" Height ="50"/>
    <Button Content ="Third" Background = "Pink" Height ="50"/>
    <Button Content ="Fourth" Background = "Yellow" Height ="50"/>
    <Button Content ="Fifth" Background = "Blue" Height ="50"/>
  </StackPanel>
</ScrollViewer>

你可以在图 25-9 中看到之前 XAML 定义的结果(注意右边的滚动条,因为窗口的大小没有调整到显示所有五个按钮)。

img/340876_10_En_25_Fig9_HTML.jpg

图 25-9。

使用ScrollViewer类型

如您所料,每个面板都提供了许多成员,允许您微调内容的位置。与此相关的是,许多 WPF 控件支持两个相关属性(PaddingMargin),这两个属性允许控件本身通知面板它希望如何处理。具体来说,Padding属性控制内部控件周围应该有多少额外空间,而Margin控制控件外部周围的额外空间。

这就结束了本章对 WPF 主要面板类型的介绍,以及它们放置内容的各种方式。接下来,您将学习如何使用 Visual Studio 设计器创建布局。

使用 Visual Studio 设计器配置面板

现在,您已经大致了解了用于定义一些常见布局管理器的 XAML,您会很高兴地知道 Visual Studio 为构造布局提供了非常好的设计时支持。这样做的关键在于本章前面描述的文档大纲窗口。为了说明一些基础知识,创建一个名为VisualLayoutTester的新 WPF 应用项目。

注意你的初始Window是如何默认使用Grid布局的,如下所示:

<Window x:Class="VisualLayoutTester.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
  xmlns:local="clr-namespace:VisualLayoutTesterApp"
  mc:Ignorable="d"
    Title="MainWindow" Height="450" Width="800">
    <Grid>
    </Grid>
</Window>

如果你喜欢使用Grid布局系统,请注意在图 25-10 中,你可以很容易地使用可视化布局来切割和调整网格单元的大小。为此,首先在文档大纲窗口中选择Grid组件,然后单击网格的边界来创建新的行和列。

img/340876_10_En_25_Fig10_HTML.jpg

图 25-10。

使用 IDE 的设计器可以将Grid控件直观地切割成单元格

现在,假设您已经定义了一个包含一定数量单元格的网格。然后,您可以将控件拖放到布局系统的给定单元格中,IDE 将自动设置相关控件的Grid.RowGrid.Column属性。下面是将Button拖动到预定义的单元格中后,IDE 可能生成的一些标记:

<Button x:Name="button" Content="Button" Grid.Column="1" HorizontalAlignment="Left" Margin="21,21.4,0,0" Grid.Row="1" VerticalAlignment="Top" Width="75"/>

现在,让我们假设你宁愿根本不使用Grid。如果你右击文档大纲窗口中的任何布局节点,你会发现一个菜单选项,允许你将当前容器改变为另一个(见图 25-11 )。请注意,当您这样做时,您将(很可能)从根本上改变控件的位置,因为控件将符合新面板类型的规则。

img/340876_10_En_25_Fig11_HTML.jpg

图 25-11。

“文档大纲”窗口允许您转换到新的面板类型

另一个方便的技巧是能够在可视化设计器上选择一组控件,并将它们分组到一个新的嵌套布局管理器中。假设您有一个包含一组随机对象的Grid。现在,通过按住 Ctrl 键并用鼠标左键单击每一项来选择设计器上的一组项。如果你右击选择,你可以将选择的项目分组到一个新的子面板中(见图 25-12 )。

img/340876_10_En_25_Fig12_HTML.jpg

图 25-12。

将项目分组到新的子面板中

完成后,再次检查“文档大纲”窗口以验证嵌套布局系统。当您构建功能全面的 WPF 窗口时,您很可能总是需要利用嵌套布局系统,而不是简单地为所有的 UI 显示选择一个面板(事实上,本文中剩余的 WPF 示例通常会这样做)。最后,文档大纲窗口中的节点都是可拖放的。例如,如果你想将一个当前在 DockPanel 中的控件移动到父面板中,你可以如图 25-13 所示那样做。

img/340876_10_En_25_Fig13_HTML.jpg

图 25-13。

通过文档大纲窗口重新定位项目

当你阅读完剩余的 WPF 章节时,我会尽可能指出额外的布局快捷方式。然而,你绝对值得花时间亲自试验和测试各种特性。为了让你朝着正确的方向前进,本章的下一个例子将说明如何为一个定制的文本处理应用构建一个嵌套的布局管理器(带拼写检查!).

使用嵌套面板构建窗口的框架

如上所述,典型的 WPF 窗口不会使用单个面板控件,而是将面板嵌套在其他面板中,以获得所需的布局系统。首先创建一个名为 MyWordPad 的新 WPF 应用。

你的目标是构造一个布局,其中主窗口有一个最上面的菜单系统,一个工具栏在菜单系统下面,一个状态栏安装在窗口的底部。状态栏将包含一个窗格来保存当用户选择菜单项(或工具栏按钮)时显示的文本提示,而菜单系统和工具栏将提供 UI 触发器来关闭应用并在Expander小部件中显示拼写建议。图 25-14 显示你拍摄的初始布局;它还展示了 WPF 内部的拼写检查功能。

img/340876_10_En_25_Fig14_HTML.jpg

图 25-14。

使用嵌套面板建立窗口的用户界面

要开始构建这个 UI,请更新您的Window类型的初始 XAML 定义,以便它使用一个DockPanel子元素,而不是默认的Grid,如下所示:

<Window x:Class="MyWordPad.MainWindow"
    xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"

    xmlns:local="clr-namespace:MyWordPad"
    mc:Ignorable="d"
    Title="My Spell Checker" Height="450" Width="800">
  <!-- This panel establishes the content for the window -->
  <DockPanel>
  </DockPanel>
</Window>

建立菜单系统

WPF 中的菜单系统由Menu类表示,它维护一个MenuItem对象的集合。在 XAML 构建菜单系统时,你可以让每个MenuItem处理各种事件。这些事件中最值得注意的是Click,它在最终用户选择一个子项时发生。在本例中,首先构建两个最顶层的菜单项(文件和工具;您将在本示例的后面构建 Edit 菜单),它分别公开 Exit 和拼写提示子项。

除了处理每个子项的Click事件,您还需要处理MouseEnterMouseExit事件,您将在后面的步骤中使用它们来设置状态栏文本。在您的DockPanel范围内添加以下标记:

<!-- Dock menu system on the top -->
<Menu DockPanel.Dock ="Top"
      HorizontalAlignment="Left" Background="White" BorderBrush ="Black">
  <MenuItem Header="_File">
    <Separator/>
    <MenuItem Header ="_Exit" MouseEnter ="MouseEnterExitArea"
              MouseLeave ="MouseLeaveArea" Click ="FileExit_Click"/>
    </MenuItem>
    <MenuItem Header="_Tools">
      <MenuItem Header ="_Spelling Hints"
          MouseEnter ="MouseEnterToolsHintsArea"
          MouseLeave ="MouseLeaveArea" Click ="ToolsSpellingHints_Click"/>
  </MenuItem>
</Menu>

注意,您将菜单系统停靠在DockPanel的顶部。此外,您使用Separator元素在菜单系统中插入一条细水平线,直接在退出选项之前。还要注意每个MenuItemHeader值包含一个嵌入的下划线标记(例如,_Exit)。您使用这个令牌来建立当最终用户按下 Alt 键(对于键盘快捷键)时哪个字母将被加下划线。这与 Windows 窗体中使用的&字符有所不同,因为 XAML 是基于 XML 的,而&字符在 XML 中有意义。

到目前为止,您已经实现了完整的菜单系统定义;接下来,您需要实现各种事件处理程序。首先,您有一个文件退出处理程序,FileExit_Click(),它简单地关闭窗口,然后终止应用,因为这是您的最顶层窗口。每个子项的MouseEnterMouseExit事件处理程序将最终更新你的状态栏;然而,现在,您将简单地提供 shells。最后,Tools 拼写提示菜单项的ToolsSpellingHints_Click()处理程序暂时仍将是一个 shell。以下是您的代码隐藏文件的最新更新(包括更新后的using语句):

using System.IO;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Microsoft.Win32;
public partial class MainWindow : Window
{
  public MainWindow()
  {
    InitializeComponent();
  }

  protected void FileExit_Click(object sender, RoutedEventArgs args)
  {
    // Close this window.
    this.Close();
  }

  protected void ToolsSpellingHints_Click(object sender, RoutedEventArgs args)
  {
  }
  protected void MouseEnterExitArea(object sender, RoutedEventArgs args)
  {
  }
  protected void MouseEnterToolsHintsArea(object sender, RoutedEventArgs args)
  {
  }
  protected void MouseLeaveArea(object sender, RoutedEventArgs args)
  {
  }
}

可视化地构建菜单

虽然知道如何在 XAML 中手动定义项目总是好的,但这可能有点乏味。Visual Studio 支持对菜单系统、工具栏、状态栏和许多其他 UI 控件的可视化设计支持。如果右键单击Menu控件,您会注意到一个 Add MenuItem 选项。顾名思义,这为Menu控件添加了一个新的菜单项。添加了一组最上面的项目后,您可以添加子菜单项和分隔符,展开或折叠菜单本身,并通过第二次右键单击执行其他以菜单为中心的操作。

正如您在当前 MyWordPad 示例的剩余部分所看到的,我将典型地向您展示最终生成的 example 然而,一定要花时间与视觉设计者一起试验,以简化手头的任务。

构建工具栏

工具栏(由 WPF 的ToolBar类表示)通常提供了激活菜单选项的另一种方式。直接在您的Menu定义的结束范围之后添加以下标记:

<!-- Put Toolbar under the Menu -->
<ToolBar DockPanel.Dock ="Top" >
  <Button Content ="Exit" MouseEnter ="MouseEnterExitArea"
          MouseLeave ="MouseLeaveArea" Click ="FileExit_Click"/>
  <Separator/>
  <Button Content ="Check" MouseEnter ="MouseEnterToolsHintsArea"
          MouseLeave ="MouseLeaveArea" Click ="ToolsSpellingHints_Click"
          Cursor="Help" />
</ToolBar>

您的ToolBar控件由两个Button控件组成,这两个控件恰好处理相同的事件,并且在您的代码文件中由相同的方法处理。使用这种技术,您可以将处理程序加倍,以服务于菜单项和工具栏按钮。虽然这个工具栏使用的是典型的按钮,但是你应该意识到ToolBar类型的“is-a”ContentControl;因此,您可以自由地将任何类型嵌入其表面(例如,下拉列表、图像和图形)。这里另一个有趣的地方是,复选按钮通过Cursor属性支持自定义鼠标光标。

Note

您可以选择将ToolBar元素包装在ToolBarTray元素中,后者控制一组ToolBar对象的布局、停靠和拖放操作。

构建状态栏

一个StatusBar控件将停靠在DockPanel的下部,并包含一个单独的TextBlock控件,在本章的这一点之前,您还没有使用过这个控件。您可以使用TextBlock来保存支持大量文本注释的文本,比如粗体文本、下划线文本、换行符等等。在前面的ToolBar定义后直接添加以下标记:

<!-- Put a StatusBar at the bottom -->
<StatusBar DockPanel.Dock ="Bottom" Background="Beige" >
  <StatusBarItem>
    <TextBlock Name="statBarText" Text="Ready"/>
  </StatusBarItem>
</StatusBar>

最终确定用户界面设计

UI 设计的最后一个方面是定义一个 splittable Grid,它定义了两列。在左边,放置一个Expander控件,它将显示一个拼写建议列表,包裹在一个StackPanel中。在右边,放置一个支持多行和滚动条并支持拼写检查的TextBox控件。你将整个Grid挂载到父DockPanel的左边。直接在StatusBar标记下添加以下 XAML 标记,以完成窗口 UI 的定义:

<Grid DockPanel.Dock ="Left" Background ="AliceBlue">
  <!-- Define the rows and columns -->
  <Grid.ColumnDefinitions>
    <ColumnDefinition />
    <ColumnDefinition />
  </Grid.ColumnDefinitions>

  <GridSplitter Grid.Column ="0" Width ="5" Background ="Gray" />
  <StackPanel Grid.Column="0" VerticalAlignment ="Stretch" >
    <Label Name="lblSpellingInstructions" FontSize="14" Margin="10,10,0,0">
     Spelling Hints
    </Label>

    <Expander Name="expanderSpelling" Header ="Try these!"
              Margin="10,10,10,10">
      <!-- This will be filled programmatically -->
      <Label Name ="lblSpellingHints" FontSize ="12"/>
    </Expander>
  </StackPanel>

  <!-- This will be the area to type within -->
  <TextBox  Grid.Column ="1"
            SpellCheck.IsEnabled ="True"
            AcceptsReturn ="True"
            Name ="txtData" FontSize ="14"
            BorderBrush ="Blue"
            VerticalScrollBarVisibility="Auto"
            HorizontalScrollBarVisibility="Auto">
  </TextBox>
</Grid>

实现 MouseEnter/MouseLeave 事件处理程序

至此,你的窗口的 UI 就完成了。剩下的唯一任务是为剩下的事件处理程序提供一个实现。首先更新您的 C# 代码文件,以便每个MouseEnterMouseLeaveMouseExit处理程序用合适的消息设置状态栏的文本窗格,以帮助最终用户,如下所示:

public partial class MainWindow : System.Windows.Window
{
...
  protected void MouseEnterExitArea(object sender, RoutedEventArgs args)
  {
    statBarText.Text = "Exit the Application";
  }
  protected void MouseEnterToolsHintsArea(object sender, RoutedEventArgs args)
  {
    statBarText.Text = "Show Spelling Suggestions";
  }
  protected void MouseLeaveArea(object sender, RoutedEventArgs args)
  {
    statBarText.Text = "Ready";
  }
}

此时,您可以运行您的应用了。你应该看到你的状态栏会根据你鼠标悬停在哪个菜单项/工具栏按钮上来改变它的文本。

实现拼写检查逻辑

WPF API 附带了内置的拼写检查支持,它独立于 Microsoft Office 产品。这意味着您不需要使用 COM 互操作层来使用 Microsoft Word 的拼写检查器;相反,您只需几行代码就可以轻松添加相同类型的支持。

您可能还记得,当您定义TextBox控件时,您将SpellCheck.IsEnabled属性设置为true。当您这样做时,拼写错误的单词会带有红色的下划线,就像它们在 Microsoft Office 中一样。更好的是,底层编程模型允许您访问拼写检查器引擎,该引擎允许您获得拼写错误单词的建议列表。将以下代码添加到您的ToolsSpellingHints_Click()方法中:

protected void ToolsSpellingHints_Click(object sender, RoutedEventArgs args)
{
  string spellingHints = string.Empty;

  // Try to get a spelling error at the current caret location.
  SpellingError error = txtData.GetSpellingError(txtData.CaretIndex);
  if (error != null)
  {
    // Build a string of spelling suggestions.
    foreach (string s in error.Suggestions)
    {
      spellingHints += $"{s}\n";
    }

    // Show suggestions and expand the expander.
    lblSpellingHints.Content = spellingHints;
    expanderSpelling.IsExpanded = true;
  }
}

前面的代码非常简单。您只需通过使用CaretIndex属性提取一个SpellingError对象来计算出插入符号在文本框中的当前位置。如果在所述位置有错误(意味着值不是null),您使用恰当命名的Suggestions属性遍历建议列表。在获得拼写错误单词的所有建议后,将数据连接到Expander中的Label

所以你有它!只有几行程序代码(和适量的 XAML),你就有了一个功能性文字处理器的雏形。理解控制命令可以帮助你增加一点活力。

理解 WPF 命令

WPF 通过命令架构为被认为是控制不可知事件提供支持。典型的。NET Core event 在特定的基类中定义,只能由该类或其派生类使用。所以,正常。NET 核心事件与定义它们的类紧密相关。

相比之下,WPF 命令是独立于特定控件的类似事件的实体,在许多情况下,可以成功地应用于许多(看似不相关的)控件类型。举例来说,WPF 支持复制、粘贴和剪切命令,这些命令可以应用于各种 UI 元素(例如,菜单项、工具栏按钮和自定义按钮),以及键盘快捷键(例如,Ctrl+C 和 Ctrl+V)。

虽然其他 UI 工具包(如 Windows 窗体)为此提供了标准事件,但使用它们通常会留下冗余且难以维护的代码。在 WPF 模式下,您可以使用命令作为替代方法。最终结果通常会产生一个更小、更灵活的代码库。

内在命令对象

WPF 附带了许多内建的控制命令,您可以使用相关的键盘快捷键(或其他输入手势)来配置所有这些命令。从编程角度来说,WPF 命令是支持属性(通常称为Command)的任何对象,该属性返回实现ICommand接口的对象,如下所示:

public interface ICommand
{
  // Occurs when changes occur that affect whether
  // or not the command should execute.
  event EventHandler CanExecuteChanged;

  // Defines the method that determines whether the command
  // can execute in its current state.
  bool CanExecute(object parameter);

  // Defines the method to be called when the command is invoked.
  void Execute(object parameter);
}

WPF 提供了各种命令类,开箱即用,暴露了近 100 个命令对象。这些类定义了许多公开特定命令对象的属性,每个属性都实现了ICommand。表 25-3 记录了一些可用的标准命令对象。

表 25-3。

固有的 WPF 控制命令对象

|

WPF 级

|

命令对象

|

生命的意义

ApplicationCommands CloseCopyCutDeleteFindOpenPasteSaveSaveAsRedoUndo 各种应用级命令
ComponentCommands MoveDownMoveFocusBackMoveLeftMoveRightScrollToEndScrollToHome UI 组件通用的各种命令
MediaCommands BoostBaseChannelUpChannelDownFastForwardNextTrackPlayRewindSelectStop 各种以媒体为中心的命令
NavigationCommands BrowseBackBrowseForwardFavoritesLastPageNextPageZoom 与 WPF 导航模型相关的各种命令
EditingCommands AlignCenterCorrectSpellingErrorDecreaseFontSizeEnterLineBreakEnterParagraphBreakMoveDownByLineMoveRightByWord 与 WPF 文档 API 相关的各种命令

将命令连接到命令特性

如果您想将任何 WPF 命令属性连接到支持Command属性的 UI 元素(例如ButtonMenuItem),您只需做很少的工作。您可以通过更新当前的菜单系统来了解如何做到这一点,这样它就支持一个名为 Edit 的新的最顶层菜单项和三个子菜单项,用于复制、粘贴和剪切文本数据,如下所示:

<Menu DockPanel.Dock ="Top" HorizontalAlignment="Left" Background="White" BorderBrush ="Black">
  <MenuItem Header="_File" Click ="FileExit_Click" >
    <MenuItem Header ="_Exit" MouseEnter ="MouseEnterExitArea" MouseLeave ="MouseLeaveArea"
        Click ="FileExit_Click"/>
  </MenuItem>

  <!-- New menu item with commands! -->
  <MenuItem Header="_Edit">
    <MenuItem Command ="ApplicationCommands.Copy"/>
    <MenuItem Command ="ApplicationCommands.Cut"/>
    <MenuItem Command ="ApplicationCommands.Paste"/>
  </MenuItem>

  <MenuItem Header="_Tools">
    <MenuItem Header ="_Spelling Hints"
              MouseEnter ="MouseEnterToolsHintsArea"
              MouseLeave ="MouseLeaveArea"
              Click ="ToolsSpellingHints_Click"/>
  </MenuItem>
</Menu>

注意,编辑菜单上的每个子项都有一个分配给Command属性的值。这样做意味着菜单项在菜单项 UI 中自动接收正确的名称和快捷键(例如,对于剪切操作,Ctrl+C );这也意味着应用现在可以复制、剪切和粘贴了,不需要任何程序代码!

如果您运行应用并选择一些文本,您就可以开箱即用地使用新菜单项。另外,您的应用还可以响应标准的右键单击操作,为用户提供相同的选项。

将命令连接到任意动作

如果您想要将一个命令对象连接到一个任意的(特定于应用的)事件,您将需要下拉到过程代码。这样做并不复杂,但它涉及的逻辑比你在 XAML 看到的要多一点。例如,假设您希望整个窗口都响应 F1 键,这样当最终用户按下该键时,他将激活相关的帮助系统。此外,假设主窗口的代码文件定义了一个名为SetF1CommandBinding()的新方法,在调用InitializeComponent()之后,在构造函数中调用该方法。

public MainWindow()
{
  InitializeComponent();
  SetF1CommandBinding();
}

这个新方法将以编程方式创建一个新的CommandBinding对象,当您需要将命令对象绑定到应用中的给定事件处理程序时,就可以使用这个对象。在这里,您配置您的CommandBinding对象来使用ApplicationCommands.Help命令操作,它自动感知 F1:

private void SetF1CommandBinding()
{
  CommandBinding helpBinding = new CommandBinding(ApplicationCommands.Help);
  helpBinding.CanExecute += CanHelpExecute;
  helpBinding.Executed += HelpExecuted;
  CommandBindings.Add(helpBinding);
}

大多数CommandBinding对象想要处理CanExecute事件(允许您根据程序的操作指定命令是否发生)和Executed事件(在这里您可以创作命令发生时应该发生的内容)。将以下事件处理程序添加到您的Window派生类型中(根据相关委托的要求,注意每个方法的格式):

private void CanHelpExecute(object sender, CanExecuteRoutedEventArgs e)
{
  // Here, you can set CanExecute to false if you want to prevent the command from executing.
  e.CanExecute = true;
}

private void HelpExecuted(object sender, ExecutedRoutedEventArgs e)
{
  MessageBox.Show("Look, it is not that difficult. Just type something!", "Help!");
}

在前面的代码片段中,您实现了CanHelpExecute(),因此它总是允许 F1 帮助启动;您只需返回true就可以做到这一点。但是,如果在某些情况下,帮助系统不应该显示,您可以说明这一点,并在必要时返回false。您在HelpExecuted()中显示的“帮助系统”只不过是一个消息框。此时,您可以运行您的应用了。当您按下键盘上的 F1 键时,您将看到消息框出现。

使用打开和保存命令

为了完成当前示例,您将添加将文本数据保存到外部文件并打开*.txt文件进行编辑的功能。如果您想走长路,您可以手动添加编程逻辑,根据您的TextBox中是否有数据来启用或禁用新的菜单项。然而,您可以再次使用命令来减轻您的负担。

首先,通过添加下面两个使用SaveOpen ApplicationCommands对象的新子菜单,更新代表最顶层文件菜单的MenuItem元素:

<MenuItem Header="_File">
  <MenuItem Command ="ApplicationCommands.Open"/>
  <MenuItem Command ="ApplicationCommands.Save"/>
  <Separator/>
  <MenuItem Header ="_Exit"
            MouseEnter ="MouseEnterExitArea"
            MouseLeave ="MouseLeaveArea" Click ="FileExit_Click"/>
</MenuItem>

同样,记住所有的命令对象都实现了ICommand接口,该接口定义了两个事件(CanExecuteExecuted)。现在,您需要启用整个窗口,以便它可以检查当前是否可以启动这些命令;如果是这样,您可以定义一个事件处理程序来执行自定义代码。

您可以通过填充由窗口维护的CommandBindings集合来做到这一点。在 XAML 这样做需要使用属性元素语法来定义一个Window.CommandBindings作用域,在其中放置两个CommandBinding定义。像这样更新你的Window:

<Window x:Class="MyWordPad.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Title="MySpellChecker" Height="331" Width="508"
  WindowStartupLocation ="CenterScreen" >

  <!-- This will inform the Window which handlers to call,
       when testing for the Open and Save commands. -->
  <Window.CommandBindings>
    <CommandBinding Command="ApplicationCommands.Open"
                    Executed="OpenCmdExecuted"
                    CanExecute="OpenCmdCanExecute"/>
    <CommandBinding Command="ApplicationCommands.Save"
                    Executed="SaveCmdExecuted"
                    CanExecute="SaveCmdCanExecute"/>
  </Window.CommandBindings>

  <!-- This panel establishes the content for the window -->
  <DockPanel>
  ...
  </DockPanel>
</Window>

现在右键单击 XAML 编辑器中的每个ExecutedCanExecute属性,并选择导航到事件处理程序菜单选项。你可能还记得第二十四章中的内容,这将自动为事件本身生成存根代码。此时,窗口的 C# 代码文件中应该有四个空处理程序。

CanExecute事件处理程序的实现将告诉窗口,通过设置传入的CanExecuteRoutedEventArgs对象的CanExecute属性,可以随时触发相应的Executed事件。

private void OpenCmdCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
  e.CanExecute = true;
}

private void SaveCmdCanExecute(object sender, CanExecuteRoutedEventArgs e)
{
  e.CanExecute = true;
}

相应的Executed处理程序执行显示打开和保存对话框的实际工作;他们还将你的TextBox中的数据发送到一个文件中。首先确保将System.IOMicrosoft.Win32名称空间导入到代码文件中。以下完整的代码非常简单:

private void OpenCmdExecuted(object sender, ExecutedRoutedEventArgs e)
{
  // Create an open file dialog box and only show XAML files.
  var openDlg = new OpenFileDialog { Filter = "Text Files |*.txt"};

  // Did they click on the OK button?
  if (true == openDlg.ShowDialog())
  {
    // Load all text of selected file.
    string dataFromFile = File.ReadAllText(openDlg.FileName);

    // Show string in TextBox.
    txtData.Text = dataFromFile;
  }
}

private void SaveCmdExecuted(object sender, ExecutedRoutedEventArgs e)
{
  var saveDlg = new SaveFileDialog { Filter = "Text Files |*.txt"};

  // Did they click on the OK button?
  if (true == saveDlg.ShowDialog())
  {
    // Save data in the TextBox to the named file.
    File.WriteAllText(saveDlg.FileName, txtData.Text);
  }
}

Note

第二十八章将会对 WPF 的指挥系统进行更深入的研究。在其中,您将创建基于ICommandRelayCommands的定制命令。

这就结束了这个例子,以及您对使用 WPF 控件的初步了解。在这里,您学习了如何使用基本命令、菜单系统、状态栏、工具栏、嵌套面板和一些基本的 UI 控件,如TextBoxExpander。下一个示例将使用一些更奇特的控件,同时检查几个重要的 WPF 服务。

了解路由事件

您可能已经注意到了前面代码示例中的参数RoutedEventArgs而不是EventArgs。路由事件模型是标准 CLR 事件模型的改进,旨在确保事件能够以适合 XAML 的对象树描述的方式进行处理。假设您有一个名为 WpfRoutedEvents 的新 WPF 应用项目。现在,通过添加下面的Button控件来更新初始窗口的 XAML 描述,该控件定义了一些复杂的内容:

<Window ...
  <Grid>
    <Button Name="btnClickMe" Height="75" Width = "250"
        Click ="btnClickMe_Clicked">
      <StackPanel Orientation ="Horizontal">
        <Label Height="50" FontSize ="20">
          Fancy Button!</Label>
        <Canvas Height ="50" Width ="100" >
          <Ellipse Name = "outerEllipse" Fill ="Green"
                   Height ="25" Width ="50" Cursor="Hand"
                   Canvas.Left="25" Canvas.Top="12"/>
          <Ellipse Name = "innerEllipse" Fill ="Yellow"
             Height = "15" Width ="36"
             Canvas.Top="17" Canvas.Left="32"/>
        </Canvas>
      </StackPanel>
    </Button>
  </Grid>
</Window>

注意在Button的开始定义中,您已经通过指定当事件被引发时要调用的方法的名称来处理了Click事件。Click事件与RoutedEventHandler委托一起工作,该委托期望一个事件处理程序将object作为第一个参数,将System.Windows.RoutedEventArgs作为第二个参数。按如下方式实现该处理程序:

public void btnClickMe_Clicked(object sender, RoutedEventArgs e)
{
  // Do something when button is clicked.
  MessageBox.Show("Clicked the button");
}

如果您运行您的应用,您将看到这个消息框显示,不管您单击按钮内容的哪一部分(绿色的Ellipse、黄色的EllipseLabelButton的表面)。这是一件好事。想象一下,如果您被迫为这些子元素中的每一个处理一个Click事件,那么 WPF 事件处理会有多乏味。不仅为Button的每个方面创建单独的事件处理程序需要耗费大量的劳动,而且最终还会有一些令人讨厌的代码需要维护。

幸运的是,WPF 路由事件负责确保无论按钮的哪个部分被自动点击,您的单个Click事件处理程序都会被调用。简单地说,路由事件模型自动地沿着对象树向上(或向下)传播事件,寻找合适的处理程序。

具体来说,一个路由事件可以利用三种路由策略。如果一个事件从原点向上移动到对象树中的其他定义范围,该事件被称为冒泡事件。相反,如果事件从最外面的元素(例如,a Window)向下移动到原点,则该事件被称为隧道事件。最后,如果一个事件仅由发起元素引发和处理(这可以被描述为一个普通的 CLR 事件),则称之为直接事件

路由冒泡事件的角色

在当前的例子中,如果用户点击内部的黄色椭圆,Click事件会冒泡到下一级作用域(??),然后到StackPanel,最后到Button,在那里处理Click事件处理程序。同样,如果用户点击Label,事件会冒泡到StackPanel,最后到Button元素。

考虑到这种冒泡路由事件模式,您不必担心为复合控件的所有成员注册特定的Click事件处理程序。但是,如果您想要为同一个对象树中的多个元素执行定制的单击逻辑,您可以这样做。

举例来说,假设您需要以一种独特的方式处理outerEllipse控件的点击。首先,处理这个子元素的MouseDown事件(图形呈现类型,如Ellipse不支持Click事件;但是,他们可以通过MouseDownMouseUp等监控鼠标按键活动。).

<Button Name="btnClickMe" Height="75" Width = "250"
        Click ="btnClickMe_Clicked">
  <StackPanel Orientation ="Horizontal">
    <Label Height="50" FontSize ="20">Fancy Button!</Label>
    <Canvas Height ="50" Width ="100" >
    <Ellipse Name = "outerEllipse" Fill ="Green"
             Height ="25" MouseDown ="outerEllipse_MouseDown"
             Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>
    <Ellipse Name = "innerEllipse" Fill ="Yellow" Height = "15" Width ="36"
             Canvas.Top="17" Canvas.Left="32"/>
    </Canvas>
  </StackPanel>
</Button>

然后实现一个适当的事件处理程序,为了便于说明,它将简单地改变主窗口的Title属性,如下所示:

public void outerEllipse_MouseDown(object sender, MouseButtonEventArgs e)
{
  // Change title of window.
  this.Title = "You clicked the outer ellipse!";
}

这样,您现在可以根据最终用户点击的位置(归结为外部椭圆和按钮范围内的任何地方)采取不同的操作过程。

Note

路由冒泡事件总是从原点移动到下一个定义范围的*。因此,在本例中,如果您单击innerEllipse对象,事件将冒泡到Canvas而不是outerEllipse,因为它们都是Canvas范围内的Ellipse类型。*

继续或停止冒泡

目前,如果用户点击outerEllipse对象,它将触发这个Ellipse对象的注册的MouseDown事件处理程序,此时事件冒泡到按钮的Click事件。如果您想通知 WPF 停止冒泡对象树,您可以将参数EventArgsHandled属性设置为true,如下所示:

public void outerEllipse_MouseDown(object sender, MouseButtonEventArgs e)
{
  // Change title of window.
  this.Title = "You clicked the outer ellipse!";
  // Stop bubbling!
  e.Handled = true;
}

在这种情况下,您会发现窗口的标题发生了变化,但是您不会看到由ButtonClick事件处理程序显示的MessageBox。简而言之,路由冒泡事件使得一组复杂的内容既可以作为单个逻辑元素(例如一个Button)也可以作为离散的项目(例如Button中的一个Ellipse)。

路由隧道事件的角色

严格地说,路由事件本质上可以是冒泡(如前所述)或隧道。隧道事件(都以Preview后缀开始;例如PreviewMouseDown)从最顶端的元素向下钻至对象树的内部范围。总的来说,WPF 基类库中的每个冒泡事件都与一个相关的隧道事件成对出现,该事件在冒泡事件之前触发。例如,在冒泡MouseDown事件触发之前,隧道PreviewMouseDown事件首先触发。

处理隧道事件看起来就像处理任何其他事件一样;只需在 XAML 中指定事件处理程序名称(或者,如果需要,在代码文件中使用相应的 C# 事件处理语法)并在代码文件中实现该处理程序。为了说明隧道和冒泡事件的相互作用,首先处理outerEllipse对象的PreviewMouseDown事件,如下所示:

<Ellipse Name = "outerEllipse" Fill ="Green" Height ="25"
         MouseDown ="outerEllipse_MouseDown"
         PreviewMouseDown ="outerEllipse_PreviewMouseDown"
         Width ="50" Cursor="Hand" Canvas.Left="25" Canvas.Top="12"/>

接下来,通过更新每个事件处理程序(针对所有对象)来改进当前的 C# 类定义,使用传入的事件args对象将关于当前事件的数据追加到名为mouseActivitystring成员变量中。这将允许您观察在后台触发的事件流。

public partial class MainWindow : Window
{
  string _mouseActivity = string.Empty;
  public MainWindow()
  {
    InitializeComponent();
  }
  public void btnClickMe_Clicked(object sender, RoutedEventArgs e)
  {
    AddEventInfo(sender, e);
    MessageBox.Show(_mouseActivity, "Your Event Info");
    // Clear string for next round.
    _mouseActivity = "";
  }
  private void AddEventInfo(object sender, RoutedEventArgs e)
  {
    _mouseActivity += string.Format(
      "{0} sent a {1} event named {2}.\n", sender,
      e.RoutedEvent.RoutingStrategy,
      e.RoutedEvent.Name);
  }
  private void outerEllipse_MouseDown(object sender, MouseButtonEventArgs e)
  {
    AddEventInfo(sender, e);
  }
  private void outerEllipse_PreviewMouseDown(object sender, MouseButtonEventArgs e)
  {
    AddEventInfo(sender, e);
  }
}

请注意,您没有停止任何事件处理程序的事件冒泡。如果您运行这个应用,您将看到一个独特的消息框,它基于您单击按钮的位置而显示。图 25-15 显示了点击外部Ellipse对象的结果。

img/340876_10_En_25_Fig15_HTML.jpg

图 25-15。

先挖隧道,后冒泡

那么,为什么 WPF 事件通常成对出现(一个隧穿,一个冒泡)?答案是,通过预览事件,您可以执行任何特殊的逻辑(数据验证、禁用冒泡操作等。)在冒泡的对应物火起来之前。举例来说,假设您有一个应该只包含数字数据的TextBox。您可以处理PreviewKeyDown事件,如果您看到用户输入了非数字数据,您可以通过将Handled属性设置为true来取消冒泡事件。

正如您所猜测的,当您构建一个包含自定义事件的自定义控件时,您可以以这样一种方式创作事件,使其能够冒泡(或隧道)穿过 XAML 树。出于本章的目的,我将不研究如何构建自定义路由事件(然而,该过程与构建自定义依赖属性没有太大的不同)。如果您有兴趣,请查看。NET Framework 4.7 SDK 文档。在这本书里,你会找到很多对你有帮助的教程。

深入了解 WPF API 和控件

本章的剩余部分将让你有机会使用 Visual Studio 构建一个新的 WPF 应用。目标是创建一个由包含一组选项卡的TabControl小部件组成的 UI。每个选项卡将说明一些新的 WPF 控件和有趣的 API,您可能希望在您的软件项目中使用它们。在此过程中,您还将了解 Visual Studio WPF 设计器的其他功能。

使用 TabControl

首先,创建一个名为 WpfControlsAndAPIs 的新 WPF 应用。如上所述,您的初始窗口将包含一个带有三个不同选项卡的TabControl,每个选项卡显示一组相关的控件和/或 WPF API。将窗口的Width更新为 800,将Height更新为 350。

在 Visual Studio 工具箱中找到TabControl控件,将其放到您的设计器上,并将标记更新为以下内容:

<TabControl Name="MyTabControl" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
    <TabItem Header="TabItem">
        <Grid Background="#FFE5E5E5"/>
    </TabItem>
    <TabItem Header="TabItem">
        <Grid Background="#FFE5E5E5"/>
    </TabItem>
</TabControl>

您会注意到,系统会自动为您提供两个选项卡项目。要添加额外的选项卡,您只需右键单击文档大纲窗口中的TabControl节点,并选择添加TabItem菜单选项(您也可以右键单击设计器上的TabControl来激活相同的菜单选项),或者只需在 XAML 编辑器中开始键入。使用任一方法添加一个额外的选项卡。

现在,通过 XAML 编辑器更新每个TabItem控件,并更改每个选项卡的Header属性,将它们命名为Ink APIData BindingDataGrid。此时,你的窗口设计器应该如图 25-16 所示。

img/340876_10_En_25_Fig16_HTML.jpg

图 25-16。

标签系统的初始布局

请注意,当您选择一个选项卡进行编辑时,该选项卡将成为活动选项卡,您可以通过从“工具箱”窗口中拖动控件来设计该选项卡。既然已经定义了核心内容TabControl,您可以一个标签一个标签地研究细节,并在此过程中了解 WPF API 的更多特性。

构建 Ink API 选项卡

第一个选项卡将用于显示 WPF 的数字墨水 API 的整体作用,它允许您轻松地将绘画功能合并到程序中。当然,该应用并不一定是绘画应用;您可以将这个 API 用于多种用途,包括捕获手写输入。

Note

对于这一章的其余大部分(以及接下来的 WPF 章节),我将直接编辑 XAML,而不是使用各种设计器窗口。虽然控件的拖放工作正常,但通常布局不是您想要的(Visual Studio 会根据您放置控件的位置添加边距和填充),而且您无论如何都要花费大量时间来清理 XAML。

首先将 Ink API TabItem下的Grid标签改为StackPanel,并添加一个结束标签(确保从开始标签中删除"/")。您的标记应该如下所示:

<TabItem Header="Ink API">
  <StackPanel Background="#FFE5E5E5">
  </StackPanel>
</TabItem>

设计工具栏

添加一个新的ToolBar控件到 StackPanel(使用 XAML 编辑器),名为InkToolbar,高度为60

.<ToolBar Name="InkToolBar" Height="60">
</ToolBar>

将三个RadioButton控件添加到ToolBarWrapPanel控件和Border控件内,如下所示:

<Border Margin="0,2,0,2.4" Width="280" VerticalAlignment="Center">
  <WrapPanel>
    <RadioButton x:Name="inkRadio" Margin="5,10" Content="Ink Mode!" IsChecked="True" />
    <RadioButton x:Name="eraseRadio" Margin="5,10" Content="Erase Mode!" />
    <RadioButton x:Name="selectRadio" Margin="5,10" Content="Select Mode!" />
  </WrapPanel>
</Border>

RadioButton控件不在父面板控件中时,它将呈现与Button控件相同的 UI!这就是为什么我把RadioButton控件包在WrapPanel里的原因。

接下来,添加一个Separator,然后添加一个ComboBox,其Width为 175,Margin为 10,0,0,0。添加三个内容为RedGreenBlueComboBoxItem标签,并在整个ComboBox之后添加另一个Separator控件,如下所示:

<Separator/>
<ComboBox x:Name="comboColors" Width="175" Margin="10,0,0,0">
  <ComboBoxItem Content="Red"/>
  <ComboBoxItem Content="Green"/>
  <ComboBoxItem Content="Blue"/>
</ComboBox>
<Separator/>

单选按钮控件

在本例中,您希望这三个RadioButton控件互斥。在其他 GUI 框架中,要确保一组相关的控件(比如单选按钮)是互斥的,就需要将它们放在同一个分组框中。在 WPF 时代,你不需要这么做。相反,你可以简单地将它们分配到同一个组名。这是有帮助的,因为相关的项目不需要被物理地收集在相同的区域中,而是可以在窗口中的任何地方。

RadioButton类包含一个IsChecked属性,当最终用户点击 UI 元素时,该属性在truefalse之间切换。此外,RadioButton提供了两个事件(CheckedUnchecked),您可以使用它们来拦截这种状态变化。

添加保存、加载和删除按钮

ToolBar控件中的最终控件将是一个拥有三个Button控件的Grid。在最后一个Separator控件后添加以下标记:

<Grid>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="Auto"/>
    <ColumnDefinition Width="Auto"/>
    <ColumnDefinition Width="Auto"/>
  </Grid.ColumnDefinitions>
  <Button Grid.Column="0" x:Name="btnSave" Margin="10,10" Width="70" Content="Save Data"/>
  <Button Grid.Column="1" x:Name="btnLoad" Margin="10,10" Width="70" Content="Load Data"/>
  <Button Grid.Column="2" x:Name="btnClear" Margin="10,10" Width="70" Content="Clear"/>
</Grid>

添加 InkCanvas 控件

TabControl的最终控制是InkCanvas控制。在结束ToolBar标签之后和结束StackPanel标签之前添加以下标记,如下所示:

<InkCanvas x:Name="MyInkCanvas" Background="#FFB6F4F1" />

预览窗口

此时,您已经准备好测试程序了,这可以通过按 F5 键来完成。你现在应该看到三个互斥的单选按钮,一个有三个选项的组合框,和三个按钮(见图 25-17 )。

img/340876_10_En_25_Fig17_HTML.jpg

图 25-17。

Ink API 选项卡的完整布局

处理 Ink API 选项卡的事件

Ink API 选项卡的下一步是处理每个RadioButton控件的Click事件。正如您在本书的其他 WPF 项目中所做的那样,只需单击 Visual Studio 属性编辑器的闪电图标,输入事件处理程序的名称。使用这种方法,将每个按钮的Click事件路由到同一个处理程序,名为RadioButtonClicked。处理完所有三个Click事件后,使用名为ColorChanged()的处理程序处理ComboBoxSelectionChanged事件。完成后,您应该会看到下面的 C# 代码:

public partial class MainWindow : Window
{
  public MainWindow()
  {
    this.InitializeComponent();

    // Insert code required on object creation below this point.
  }
  private void RadioButtonClicked(object sender,RoutedEventArgs e)
  {
    // TODO: Add event handler implementation here.
  }

  private void ColorChanged(object sender,SelectionChangedEventArgs e)
  {
    // TODO: Add event handler implementation here.
  }
}

您将在后面的步骤中实现这些处理程序,所以暂时让它们为空。

向工具箱添加控件

您通过直接编辑 XAML 添加了一个InkCanvas控件。如果你想使用 UI 来添加它,Visual Studio 工具箱默认情况下不会而不是向你显示每一个可能的 WPF 组件。但是您可以更新工具箱中显示的项。

为此,右键单击工具箱区域中的任意位置,然后选择“选择项”菜单选项。过一会儿,您会看到一个可能要添加到工具箱的组件列表。出于您的目的,您对添加InkCanvas控件感兴趣(参见图 25-18 )。

img/340876_10_En_25_Fig18_HTML.jpg

图 25-18。

向 Visual Studio 工具箱添加新组件

Note

墨迹控件与 16.8.3 版(撰写本文时的当前版本)或 Visual Studio 16.9 预览版 2 中的 Visual Studio XAML 设计器不兼容。这些控件仍然可以使用,只是不能通过设计器使用。

InkCanvas 控件

只需添加InkCanvas就可以在你的窗口中绘图。您可以使用鼠标,或者如果您有支持触摸的设备,也可以使用手指或数字化笔。运行应用并绘制到框中(参见图 25-19 )。

img/340876_10_En_25_Fig19_HTML.jpg

图 25-19。

InkCanvas在行动

InkCanvas不仅仅是画鼠标(或手写笔)的笔画;它还支持许多独特的编辑模式,由EditingMode属性控制。您可以从相关的InkCanvasEditingMode枚举中为该属性赋值。对于这个例子,你感兴趣的是Ink模式,这是你刚才目睹的默认选项;Select模式,允许用户用鼠标选择一个区域进行移动或调整大小;以及EraseByStoke,将删除之前的鼠标笔画。

Note

一个笔划是在单个鼠标按下/鼠标抬起操作期间发生的渲染。InkCanvas将所有笔画存储在一个StrokeCollection对象中,您可以使用Strokes属性访问该对象。

根据所选的RadioButton,用以下逻辑更新您的RadioButtonClicked()处理程序,将InkCanvas置于正确的模式:

private void RadioButtonClicked(object sender,RoutedEventArgs e)
{
  // Based on which button sent the event, place the InkCanvas in a unique
  // mode of operation.
  this.MyInkCanvas.EditingMode = (sender as RadioButton)?.Content.ToString() switch
  {
    // These strings must be the same as the Content values for each
    // RadioButton.
    "Ink Mode!" => InkCanvasEditingMode.Ink,
    "Erase Mode!" => InkCanvasEditingMode.EraseByStroke,
    "Select Mode!" => InkCanvasEditingMode.Select,
    _ => this.MyInkCanvas.EditingMode
  };
}

此外,在窗口的构造函数中默认设置模式为Ink。同时,为ComboBox设置一个默认选择(下一节将详细介绍这个控件),如下所示:

public MainWindow()
{
  this.InitializeComponent();

  // Be in Ink mode by default.
  this.MyInkCanvas.EditingMode = InkCanvasEditingMode.Ink;
  this.inkRadio.IsChecked = true;
  this.comboColors.SelectedIndex = 0;
}

现在,按 F5 再次运行您的程序。进入墨迹模式,画一些数据。接下来,进入擦除模式,删除之前输入的鼠标笔划(您会注意到鼠标图标自动看起来像橡皮擦)。最后进入选择模式,用鼠标当套索选择一些笔画。

圈出项目后,您可以在画布上移动它并调整其尺寸。图 25-20 显示了您工作时的编辑模式。

img/340876_10_En_25_Fig20_HTML.jpg

图 25-20。

动作中的InkCanvas,带编辑模式!

ComboBox 控件

在您填充了一个ComboBox控件(或者一个ListBox)之后,您有三种方法来确定所选择的项目。首先,如果你想找到所选项目的数字索引,你可以使用SelectedIndex属性(它是从零开始的;值-1表示没有选择)。第二,如果您想获得列表中已被选中的对象,SelectedItem属性符合要求。第三,SelectedValue允许您获取所选对象的值(通常通过调用ToString()来获取)。

您需要为这个选项卡添加最后一点代码,以更改在InkCanvas上输入的笔画的颜色。InkCanvasDefaultDrawingAttributes属性返回一个DrawingAttributes对象,允许您配置笔尖的许多方面,包括它的大小和颜色(以及其他设置)。用这个ColorChanged()方法的实现更新你的 C# 代码:

private void ColorChanged(object sender, SelectionChangedEventArgs e)
{
  // Get the selected value in the combo box.
  string colorToUse =
    (this.comboColors.SelectedItem as ComboBoxItem)?.Content.ToString();

  // Change the color used to render the strokes.
  this.MyInkCanvas.DefaultDrawingAttributes.Color =
    (Color)ColorConverter.ConvertFromString(colorToUse);
}

现在回想一下,ComboBox有一个ComboBoxItems的集合。如果查看生成的 XAML,您会看到以下定义:

<ComboBox x:Name="comboColors" Width="100" SelectionChanged="ColorChanged">
  <ComboBoxItem Content="Red"/>
  <ComboBoxItem Content="Green"/>
  <ComboBoxItem Content="Blue"/>
</ComboBox>

当你调用SelectedItem时,你抓取选中的ComboBoxItem,它被存储为一个通用的Object。将Object转换为ComboBoxItem后,取出Content的值,它将是字符串RedGreenBlue。然后使用便利的ColorConverter实用程序类将这个string转换成一个Color对象。现在再次运行你的程序。渲染图像时,您应该能够在颜色之间进行切换。

注意,ComboBoxListBox控件也可以包含复杂的内容,而不是文本数据列表。您可以通过打开您窗口的 XAML 编辑器并更改您的ComboBox的定义来了解一些可能的事情,因此它包含一组StackPanel元素,每个元素包含一个Ellipse和一个Label(注意ComboBoxWidth175)。

<ComboBox x:Name="comboColors" Width="175" Margin=”10,0,0,0” SelectionChanged="ColorChanged">
  <StackPanel Orientation ="Horizontal" Tag="Red">
    <Ellipse Fill ="Red" Height ="50" Width ="50"/>
    <Label FontSize ="20" HorizontalAlignment="Center"
           VerticalAlignment="Center" Content="Red"/>
  </StackPanel>

  <StackPanel Orientation ="Horizontal" Tag="Green">
    <Ellipse Fill ="Green" Height ="50" Width ="50"/>
    <Label FontSize ="20" HorizontalAlignment="Center"
           VerticalAlignment="Center" Content="Green"/>
  </StackPanel>

  <StackPanel Orientation ="Horizontal" Tag="Blue">
    <Ellipse Fill ="Blue" Height ="50" Width ="50"/>
    <Label FontSize ="20" HorizontalAlignment="Center"
           VerticalAlignment="Center" Content="Blue"/>
  </StackPanel>
</ComboBox>

请注意,每个StackPanel都为它的Tag属性赋值,这是一种简单、快速、方便的方法来发现用户选择了哪一堆项目(有更好的方法可以做到这一点,但这只是暂时的)。通过这种调整,您需要更改您的ColorChanged()方法的实现,就像这样:

private void ColorChanged(object sender, SelectionChangedEventArgs e)
{
  // Get the Tag of the selected StackPanel.
  string colorToUse = (this.comboColors.SelectedItem
      as StackPanel).Tag.ToString();
  ...
}

现在再次运行你的程序并记录下你的独特的ComboBox(见图 25-21 )。

img/340876_10_En_25_Fig21_HTML.jpg

图 25-21。

自定义ComboBox,感谢 WPF 内容模型

保存、加载和清除 InkCanvas 数据

该选项卡的最后一部分将使您能够保存和加载画布数据,以及通过为工具栏中的按钮添加事件处理程序来清除所有内容。通过为单击事件添加标记来更新按钮的 XAML,如下所示:

<Button Grid.Column="0" x:Name="btnSave" Margin="10,10" Width="70" Content="Save Data" Click="SaveData"/>
<Button Grid.Column="1" x:Name="btnLoad" Margin="10,10" Width="70" Content="Load Data" Click="LoadData"/>
<Button Grid.Column="2" x:Name="btnClear" Margin="10,10" Width="70" Content="Clear" Click="Clear"/>

接下来,将System.IOSystem.Windows.Ink名称空间导入到代码文件中。实现处理程序,如下所示:

private void SaveData(object sender, RoutedEventArgs e)
{
  // Save all data on the InkCanvas to a local file.
  using (FileStream fs = new FileStream("StrokeData.bin", FileMode.Create))
  this.MyInkCanvas.Strokes.Save(fs);
  fs.Close();
  MessageBox.Show("Image Saved","Saved");
}

private void LoadData(object sender, RoutedEventArgs e)
{
  // Fill StrokeCollection from file.
  using(FileStream fs = new FileStream("StrokeData.bin", FileMode.Open, FileAccess.Read))
  StrokeCollection strokes = new StrokeCollection(fs);
  this.MyInkCanvas.Strokes = strokes;
}

private void Clear(object sender, RoutedEventArgs e)
{
  // Clear all strokes.
  this.MyInkCanvas.Strokes.Clear();
}

现在,您应该能够将您的数据保存到一个文件中,从文件中加载它,并清除所有数据的InkCanvas。这就结束了TabControl的第一个选项卡,以及您对 WPF 数字墨水 API 的检查。可以肯定的是,关于这项技术还有更多要说的;然而,如果你对这个话题感兴趣的话,你应该能够更深入地挖掘。接下来,您将学习如何使用 WPF 数据绑定。

介绍 WPF 数据绑定模型

控件通常是各种数据绑定操作的目标。简而言之,数据绑定是将控件属性连接到数据值的行为,这些数据值可能会在应用的生命周期中发生变化。这样做可以让用户界面元素显示代码中变量的状态。例如,您可以使用数据绑定来完成以下任务:

  • 基于给定对象的布尔属性检查CheckBox控件。

  • 显示来自关系数据库表的DataGrid对象中的数据。

  • 将一个Label连接到一个表示文件夹中文件数量的整数。

当您使用固有的 WPF 数据绑定引擎时,您必须注意绑定操作的目的地之间的区别。如您所料,数据绑定操作的源是数据本身(例如,布尔属性或关系数据),而目的地(目标)是使用数据内容的 UI 控件属性(例如,CheckBoxTextBox控件上的属性)。

除了绑定到传统数据,WPF 还支持元素绑定,如前面的示例所述。这意味着您可以根据复选框的 checked 属性绑定(例如)属性的可见性。您当然可以在 WinForms 中做到这一点,但这必须通过代码来完成。WPF 框架提供了一个丰富的数据绑定生态系统,几乎可以完全用标记来处理。这也使您能够确保源和目标在它们的任何一个值发生变化时保持同步。

构建数据绑定选项卡

使用文档大纲编辑器,将第二个选项卡的Grid更改为StackPanel。现在,使用 Visual Studio 的工具箱和属性编辑器构建以下初始布局:

<TabItem x:Name="tabDataBinding" Header="Data Binding">
  <StackPanel Width="250">
    <Label Content="Move the scroll bar to see the current value"/>

    <!-- The scrollbar's value is the source of this data bind. -->
    <ScrollBar x:Name="mySB" Orientation="Horizontal" Height="30"
           Minimum = "1" Maximum = "100" LargeChange="1" SmallChange="1"/>

    <!-- The label's content will be bound to the scroll bar! -->
    <Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
           BorderThickness="2" Content = "0"/>
  </StackPanel>
</TabItem>

注意,ScrollBar对象(此处命名为mySB)被配置了一个介于1100之间的范围。目标是确保当你重新定位滚动条(或者点击左箭头或右箭头)时,Label会自动更新当前值。目前,Label控件的Content属性被设置为值"0";但是,您将通过数据绑定操作来更改这一点。

建立数据绑定

使在 XAML 定义绑定成为可能的粘合剂是{Binding}标记扩展。虽然可以通过 Visual Studio 定义绑定,但直接在标记中定义也同样容易。将名为labelSBThumbLabelContent属性编辑如下:

<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2"
       Content = "{Binding Path=Value, ElementName=mySB}"/>

注意分配给LabelContent属性的值。{Binding}语句表示数据绑定操作。ElementName值表示数据绑定操作的源(ScrollBar对象),而Path表示被绑定的属性,在本例中是滚动条的Value

如果您再次运行您的程序,您会发现当您移动滑块时,标签的内容会根据滚动条的值进行更新!

DataContext 属性

您可以使用另一种格式在 XAML 中定义数据绑定操作,在这种格式中,可以通过将DataContext属性显式设置为绑定操作的源来分解由{Binding}标记扩展指定的值,如下所示:

<!-- Breaking object/value apart via DataContext -->
<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2"
       DataContext = "{Binding ElementName=mySB}" Content = "{Binding Path=Value}" />

在当前示例中,如果您以这种方式修改标记,输出将是相同的。考虑到这一点,您可能想知道何时需要显式设置DataContext属性。这样做很有帮助,因为子元素可以在标记树中继承它的值。

通过这种方式,您可以轻松地将同一个数据源设置为一系列控件,而不必将一堆冗余的"{Binding ElementName=X, Path=Y}" XAML 值重复给多个控件。例如,假设您已经将以下新的Button添加到该选项卡的StackPanel中(您马上就会明白为什么它如此之大):

<Button Content="Click" Height="200"/>

您可以使用 Visual Studio 为多个控件生成数据绑定,但可以尝试使用 XAML 编辑器手动输入修改后的标记,如下所示:

<!-- Note the StackPanel sets the DataContext property. -->
<StackPanel Background="#FFE5E5E5" DataContext = "{Binding ElementName=mySB}">
...
  <!-- Now both UI elements use the scrollbar's value in unique ways. -->
  <Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2"
         Content = "{Binding Path=Value}"/>

  <Button Content="Click" Height="200" FontSize = "{Binding Path=Value}"/>
</StackPanel>

这里,您直接在StackPanel上设置DataContext属性。因此,当你移动拇指时,你不仅会看到Label上的当前值,还会看到Button的字体大小根据相同的值相应地增大和缩小(图 25-22 显示了一个可能的输出)。

img/340876_10_En_25_Fig22_HTML.jpg

图 25-22。

ScrollBar值绑定到LabelButton

格式化绑定数据

ScrollBar类型使用double来表示 thumb 的值,而不是期望的整数(例如,整数)。因此,当你拖动拇指时,你会发现在Label中显示各种浮点数(如 61.066923076923)。最终用户会发现这相当不直观,因为他很可能期望看到整数(例如,61、62 和 63)。

如果想要格式化数据,可以添加一个ContentStringFormat属性,传入一个自定义字符串和一个. NET 核心格式说明符,如下所示:

<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
  BorderThickness="2" Content = "{Binding Path=Value}" ContentStringFormat="The value is: {0:F0}"/>

如果在格式规范中没有任何文本,那么需要以一组空的大括号开始,这是 XAML 的转义序列。例如,这让处理器知道接下来的字符是文字,而不是绑定语句。以下是更新后的 XAML:

<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
  BorderThickness="2" Content = "{Binding Path=Value}" ContentStringFormat="{}{0:F0}"/>

Note

如果您正在绑定一个控件的Text属性,您可以在 binding 语句中添加一个StringFormat名称-值对。只需要为Content属性单独设置即可。

使用 IValueConverter 进行数据转换

如果您需要做的不仅仅是格式化数据,您可以创建一个自定义类来实现名称空间System.Windows.DataIValueConverter接口。此接口定义了两个成员,允许您在目标和目的地之间执行转换(在双向数据绑定的情况下)。定义该类后,可以用它来进一步限定数据绑定操作的处理。

除了使用 format 属性,您还可以使用值转换器在Label控件中显示整数。为此,向项目类添加一个新类(名为MyDoubleConverter)。接下来,添加以下内容:

using System;
using System.Windows.Data;
namespace
namespace WpfControlsAndAPIs
{
  public class MyDoubleConverter : IValueConverter
  {
    public object Convert(object value, Type targetType, object parameter,  System.Globalization.CultureInfo culture)
    {
      // Convert the double to an int.
      double v = (double)value;
      return (int)v;
    }

    public object ConvertBack(object value, Type targetType, object parameter,
      System.Globalization.CultureInfo culture)
    {
    // You won't worry about "two-way" bindings here, so just return the value.
    return value;
    }
  }
}

当值从源(ScrollBar)传输到目的地(TextBoxText属性)时,调用Convert()方法。您将收到许多传入的参数,但是您只需要操作传入的object进行转换,这是当前double的值。您可以使用此类型将类型转换为整数并返回新的数字。

当值从目的地传递到源时,将调用ConvertBack()方法(如果您启用了双向绑定模式)。这里,您只需直接返回值。这样做可以让您在TextBox(例如99.9)中键入一个浮点值,并让它在用户关闭控件时自动转换成一个整数值(例如99)。这种“自由”转换的发生是因为在调用了ConvertBack()之后,再次调用了Convert()方法。如果你只是简单地从ConvertBack()返回null,你的绑定会看起来不同步,因为文本框仍然会显示一个浮点数。

要在标记中使用这个转换器,首先必须创建一个本地资源来表示您刚刚构建的自定义类。不要担心添加资源的机制;接下来的几章将深入探讨这个问题。在开始的Window标签后添加以下内容:

<Window.Resources>
  <local:MyDoubleConverter x:Key="DoubleConverter"/>
</Window.Resources>

接下来,将Label控件的绑定语句更新为:

<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue"
    BorderThickness="2" Content = "{Binding Path=Value,Converter={StaticResource DoubleConverter}}" />

现在,当你运行应用时,你只能看到整数。

在代码中建立数据绑定

您也可以在代码中注册数据转换类。首先清理数据绑定选项卡中的Label控件的当前定义,使其不再使用{Binding}标记扩展。

<Label x:Name="labelSBThumb" Height="30" BorderBrush="Blue" BorderThickness="2" />

确保有对System.Windows.Data的使用;然后在你的窗口的构造函数中,调用一个名为SetBindings()的新的私有帮助函数。在此方法中,添加以下代码(并确保从构造函数中调用它):

using System.Windows.Data;
...
namespace WpfControlsAndAPIs
{
  public partial class MainWindow : Window
  {
    public MainWindow()
    {
      InitializeComponent();
...
      SetBindings();
    }
...
    private void SetBindings()
    {
      // Create a Binding object.
      Binding b = new Binding
      {
        // Register the converter, source, and path.
        Converter = new MyDoubleConverter(),
        Source = this.mySB,
        Path = new PropertyPath("Value")
        // Call the SetBinding method on the Label.
        this.labelSBThumb.SetBinding(Label.ContentProperty, b);
      }
    }
  }
}

这个函数唯一看起来有点不正常的部分是对SetBinding()的调用。注意,第一个参数调用了名为ContentPropertyLabel类的一个静态只读字段。正如你将在本章后面学到的,你正在指定所谓的依赖属性。目前,只需知道当您在代码中设置绑定时,第一个参数几乎总是要求您指定需要绑定的类的名称(在本例中为Label),然后调用带有Property后缀的底层属性。无论如何,运行应用说明Label只打印出整数。

构建数据网格选项卡

前面的数据绑定示例阐释了如何配置两个(或更多)控件来参与数据绑定操作。虽然这很有帮助,但是也可以绑定来自 XML 文件、数据库数据和内存中对象的数据。为了完成这个示例,您将设计选项卡控件的最后一个选项卡,以便它显示从AutoLot数据库的Inventory表中获得的数据。

与其他选项卡一样,首先将当前的Grid更改为StackPanel。为此,可以使用 Visual Studio 直接更新 XAML。现在在名为gridInventory的新StackPanel中定义一个DataGrid控件,如下所示:

<TabItem x:Name="tabDataGrid" Header="DataGrid">
  <StackPanel>
    <DataGrid x:Name="gridInventory" Height="288"/>
  </StackPanel>
</TabItem>

使用 NuGet 包管理器将以下包添加到项目中:

  • Microsoft.EntityFrameworkCore

  • Microsoft.EntityFrameworkCore.SqlServer

  • Microsoft.Extensions.Configuration

  • Microsoft.Extensions.Configuration.Json

如果您喜欢使用。NET Core 命令行界面(CLI)要添加包,请输入以下命令(从解决方案目录中):

dotnet add WpfControlsAndAPIs package Microsoft.EntityFrameworkCore
dotnet add WpfControlsAndAPIs package Microsoft.EntityFrameworkCore.SqlServer
dotnet add WpfControlsAndAPIs package Microsoft.Extensions.Configuration
dotnet add WpfControlsAndAPIs package Microsoft.Extensions.Configuration.Json

接下来,右键单击该解决方案,选择“添加➤现有项目”,然后添加 AutoLot。达尔和奥托洛特。Dal .从第章到第二十三章对项目建模,并对这些项目进行项目引用。您还可以使用 CLI 通过以下命令添加引用(您需要根据项目的位置和计算机的操作系统调整路径):

dotnet sln .\Chapter25_AllProjects.sln add ..\Chapter_23\AutoLot.Models
dotnet sln .\Chapter25_AllProjects.sln add ..\Chapter_23\AutoLot.Dal
dotnet add WpfControlsAndAPIs reference ..\Chapter_23\AutoLot.Models
dotnet add WpfControlsAndAPIs reference ..\Chapter_23\AutoLot.Dal

确认来自 AutoLot 的项目引用。达尔到奥特洛特。Dal.Models 还在。将以下名称空间添加到MainWindow.xaml.cs:

using System.Linq;
using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Repos;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;

MainWindow.cs中添加两个模块级属性来保存IConfigurationApplicationDbContext的实例。

private IConfiguration _configuration;
private ApplicationDbContext _context;

添加一个名为GetConfigurationAndContext()的新方法来创建这些实例,并从构造函数中调用它。下面列出了整个方法:

private void GetConfigurationAndDbContext()
{
  _configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json", true, true)
    .Build();
  var optionsBuilder =
    new DbContextOptionsBuilder<ApplicationDbContext>();
  var connectionString =
    _configuration.GetConnectionString("AutoLot");
  optionsBuilder.UseSqlServer(connectionString,
    sqlOptions => sqlOptions.EnableRetryOnFailure());
  _context = new ApplicationDbContext(optionsBuilder.Options);
}

将名为appsettings.json的新 JSON 文件添加到项目中,并将其构建状态设置为 copy always。这可以通过在解决方案资源管理器中右击文件,选择 Properties,然后输入 Copy always 作为 Copy To Output Directory 设置来完成。您也可以将它添加到项目文件中来完成相同的任务:

<ItemGroup>
  <None Update="appsettings.json">
    <CopyToOutputDirectory>Always</CopyToOutputDirectory>
  </None>
</ItemGroup>

将 JSON 文件更新为以下内容(更新连接字符串以匹配您的环境):

{
  "ConnectionStrings": {
    "AutoLotFinal": "server=.,5433;Database=AutoLot;User Id=sa;Password=P@ssw0rd;"
  }
}

打开MainWindow.xaml.cs,添加一个名为ConfigureGrid()的最终 helper 函数,配置好ApplicationDbContext后从你的构造函数中调用。您需要做的只是添加几行代码,就像这样:

private void ConfigureGrid()
{
  using var repo = new CarRepo(_context);
  gridInventory.ItemsSource = repo
    .GetAllIgnoreQueryFilters()
    .ToList()
    .Select(x=> new {
      x.Id,
      Make=x.MakeName,
      x.Color,
      x.PetName
    });
}

现在,当您运行项目时,您会看到数据填充了网格。如果您想让网格看起来更漂亮,可以使用 Visual Studio 属性窗口编辑网格,使其更有吸引力。

这就结束了当前的例子。在后面的章节中,你会看到一些其他的控件在起作用;然而,在这一点上,您应该对在 Visual Studio 中构建 ui 以及手动使用 XAML 和 C# 代码的过程感到舒适。

了解依赖项属性的作用

像其他人一样。NET 核心 API,WPF 利用每个成员的。NET 核心类型系统(类、结构、接口、委托、枚举)和每个类型成员(属性、方法、事件、常量数据、只读字段等。)在其实现中。然而,WPF 也支持一个独特的编程概念,称为依赖属性

像个“正常人”。NET 核心属性(在 WPF 文献中通常称为 CLR 属性),依赖属性可以使用 XAML 以声明方式设置,也可以在代码文件中以编程方式设置。此外,依赖属性(如 CLR 属性)最终是为了封装类的数据字段而存在的,并且可以配置为只读、只写或读写。

更有趣的是,几乎在每种情况下,您都不会意识到您实际上设置了(或访问了)一个依赖属性,而不是 CLR 属性!例如,WPF 控件从FrameworkElement继承的HeightWidth属性,以及从ControlContent继承的Content成员,实际上都是依赖属性。

<!-- Set three dependency properties! -->
<Button x:Name = "btnMyButton" Height = "50" Width = "100" Content = "OK"/>

鉴于所有这些相似之处,为什么 WPF 要为这样一个熟悉的概念定义一个新的术语呢?答案在于依赖属性是如何在类中实现的。一会儿你会看到一个编码的例子。但是,从高层次来看,所有依赖关系属性都是以下列方式创建的:

  • 首先,定义依赖属性的类在其继承链中必须有DependencyObject

  • 单个依赖属性在类型为DependencyProperty的类中被表示为一个公共的、静态的、只读的字段。按照惯例,这个字段是通过在 CLR 包装器的名称后面加上单词Property来命名的(参见最后一点)。

  • 通过对DependencyProperty.Register()的静态调用来注册DependencyProperty变量,这通常发生在静态构造函数中,或者在声明变量时内联。

  • 最后,该类将定义一个 XAML 友好的 CLR 属性,该属性调用由DependencyObject提供的方法来获取和设置值。

一旦实现,依赖属性提供了许多强大的功能,这些功能被各种 WPF 技术使用,包括数据绑定、动画服务、样式、模板等等。简而言之,依赖属性的动机是提供一种基于其他输入的值来计算属性值的方法。以下是一些关键优势的列表,这些优势远远超出了使用 CLR 属性进行简单数据封装的优势:

  • 依赖属性可以从父元素的 XAML 定义中继承它们的值。例如,如果在Window的开始标记中为FontSize属性定义了一个值,那么在默认情况下,Window中的所有控件将具有相同的字体大小。

  • 依赖属性支持由包含在它们的 XAML 范围内的元素设置值的能力,例如一个Button设置一个DockPanel父的Dock属性。(回想一下附加属性做这件事,因为附加属性是依赖属性的一种形式。)

  • 依赖属性允许 WPF 根据多个外部值计算一个值,这对动画和数据绑定服务很重要。

  • 依赖属性为 WPF 触发器提供基础结构支持(在处理动画和数据绑定时也经常使用)。

现在请记住,在许多情况下,您将以与普通 CLR 属性相同的方式与现有的依赖属性进行交互(多亏了 XAML 包装器)。在前面讨论数据绑定的部分中,您看到了如果您需要在代码中建立数据绑定,您必须在作为操作目的地的对象上调用SetBinding()方法,并指定它将操作的依赖属性,如下所示:

private void SetBindings()
{
  Binding b = new Binding
  {
    // Register the converter, source, and path.
    Converter = new MyDoubleConverter(),
    Source = this.mySB,
    Path = new PropertyPath("Value")
  };
  // Specify the dependency property!
  this.labelSBThumb.SetBinding(Label.ContentProperty, b);
}

当你在第二十七章中研究如何用代码启动动画时,你会看到类似的代码。

// Specify the dependency property!
rt.BeginAnimation(RotateTransform.AngleProperty, dblAnim);

只有在创作自定义 WPF 控件时,才需要构建自己的自定义依赖项属性。例如,如果您正在构建一个定义了四个定制属性的UserControl,并且您希望这些属性能够很好地集成到 WPF API 中,那么您应该使用依赖属性逻辑来创作它们。

具体来说,如果您的属性需要成为数据绑定或动画操作的目标,如果属性必须在更改时广播,如果它必须能够作为 WPF 风格的Setter工作,或者如果它必须能够从父元素接收它们的值,那么普通的 CLR 属性将而不是就足够了。如果你使用一个普通的 CLR 属性,其他程序员可能真的能够获得和设置一个值;然而,如果他们试图在 WPF 服务的上下文中使用您的属性,事情将不会像预期的那样工作。因为你永远不知道其他人可能想要如何与你的定制UserControl类的属性交互,你应该养成在构建定制控件时总是定义依赖属性的习惯。

检查现有的依赖属性

在您学习如何构建一个定制的依赖属性之前,让我们来看看FrameworkElement类的Height属性是如何在内部实现的。相关代码如下所示(包括我的注释):

// FrameworkElement is-a DependencyObject.
public class FrameworkElement : UIElement, IFrameworkInputElement,
  IInputElement, ISupportInitialize, IHaveResources, IQueryAmbient
{
...
  // A static read-only field of type DependencyProperty.
  public static readonly DependencyProperty HeightProperty;

  // The DependencyProperty field is often registered
  // in the static constructor of the class.
  static FrameworkElement()
  {
    ...
    HeightProperty = DependencyProperty.Register(
      "Height",
      typeof(double),
      typeof(FrameworkElement),
      new FrameworkPropertyMetadata((double) 1.0 / (double) 0.0,
        FrameworkPropertyMetadataOptions.AffectsMeasure,
        new PropertyChangedCallback(FrameworkElement.OnTransformDirty)),
      new ValidateValueCallback(FrameworkElement.IsWidthHeightValid));
    }

    // The CLR wrapper, which is implemented using
    // the inherited GetValue()/SetValue() methods.
    public double Height
    {
      get { return (double) base.GetValue(HeightProperty); }
      set { base.SetValue(HeightProperty, value); }
    }
}

正如您所看到的,依赖属性需要普通 CLR 属性的相当多的额外代码!实际上,依赖关系可能比您在这里看到的更复杂(幸运的是,许多实现比Height更简单)。

首先,记住如果一个类想要定义一个依赖属性,它必须在继承链中有DependencyObject,因为这是定义 CLR 包装器中使用的GetValue()SetValue()方法的类。因为FrameworkElement 是-a DependencyObject,这个要求就满足了。

接下来,回想一下将保存属性实际值的实体(在Height的情况下是一个double)被表示为一个DependencyProperty类型的公共、静态、只读字段。按照惯例,这个字段的名称应该总是通过在相关 CLR 包装器的名称后面加上单词Property来命名,就像这样:

public static readonly DependencyProperty HeightProperty;

假设依赖属性被声明为静态字段,它们通常在类的静态构造函数中创建(和注册)。通过调用静态的DependencyProperty.Register()方法来创建DependencyProperty对象。此方法已被重载多次;然而,在Height的情况下,调用DependencyProperty.Register()如下:

HeightProperty = DependencyProperty.Register(
  "Height",
  typeof(double),
  typeof(FrameworkElement),
  new FrameworkPropertyMetadata((double)0.0,
    FrameworkPropertyMetadataOptions.AffectsMeasure,
    new PropertyChangedCallback(FrameworkElement.OnTransformDirty)),
  new ValidateValueCallback(FrameworkElement.IsWidthHeightValid));

DependencyProperty.Register()的第一个参数是类的普通 CLR 属性的名称(本例中为Height),而第二个参数是它封装的底层数据类型的类型信息(??)。第三个参数指定该属性所属的类的类型信息(在本例中为FrameworkElement)。虽然这看起来有些多余(毕竟,HeightProperty字段已经在FrameworkElement类中定义了),但这是 WPF 的一个巧妙之处,因为它允许一个类在另一个类上注册属性(即使类定义已经被密封了!).

在这个例子中,传递给DependencyProperty.Register()的第四个参数真正赋予了依赖属性自己独特的味道。在这里,传递了一个FrameworkPropertyMetadata对象,该对象描述了关于 WPF 应该如何处理该属性的各种细节,这些细节涉及回调通知(如果该属性需要在值改变时通知其他人)和各种选项(由FrameworkPropertyMetadataOptions枚举表示),这些选项控制所讨论的属性所影响的内容。(它和数据绑定一起工作吗?能遗传吗?)在这种情况下,FrameworkPropertyMetadata的构造函数参数分解如下:

new FrameworkPropertyMetadata(
  // Default value of property.
  (double)0.0,

  // Metadata options.
  FrameworkPropertyMetadataOptions.AffectsMeasure,

  // Delegate pointing to method called when property changes.
  new PropertyChangedCallback(FrameworkElement.OnTransformDirty)
)

因为FrameworkPropertyMetadata构造函数的最后一个参数是一个委托,注意它的构造函数参数指向了FrameworkElement类上一个名为OnTransformDirty()的静态方法。我不会费心展示这个方法背后的代码,但是请注意,任何时候您构建一个定制的依赖属性,您都可以指定一个PropertyChangedCallback委托来指向一个方法,当您的属性值被更改时,这个方法将被调用。

这就把我带到了传递给DependencyProperty.Register()方法的最后一个参数,类型为ValidateValueCallback的第二个委托,它指向FrameworkElement类上的一个方法,调用这个方法是为了确保分配给属性的值是有效的。

new ValidateValueCallback(FrameworkElement.IsWidthHeightValid)

这个方法包含了您通常期望在属性的 set 块中找到的逻辑(在下一节中有关于这一点的更多信息)。

private static bool IsWidthHeightValid(object value)
{
  double num = (double) value;
  return ((!DoubleUtil.IsNaN(num) && (num >= 0.0))
    && !double.IsPositiveInfinity(num));
}

在注册了DependencyProperty对象之后,最后的任务是将该字段包装在一个普通的 CLR 属性中(在本例中是Height)。但是,请注意,getset作用域并不简单地返回或设置类级别的双成员变量,而是使用来自System.Windows.DependencyObject基类的GetValue()SetValue()方法间接这样做,如下所示:

public double Height
{
  get { return (double) base.GetValue(HeightProperty); }
  set { base.SetValue(HeightProperty, value); }
}

关于 CLR 属性包装的重要说明

因此,简单回顾一下到目前为止的故事,当您在 XAML 或代码中获取或设置它们的值时,依赖属性看起来就像普通的日常属性,但是在幕后,它们是用更复杂的编码技术实现的。请记住,完成此过程的全部原因是为了构建一个自定义控件,该控件具有需要与 WPF 服务集成的自定义属性,这些服务需要与依赖属性(例如,动画、数据绑定和样式)进行通信。

即使依赖项属性的部分实现包括定义 CLR 包装,也不应该将验证逻辑放在 set 块中。就此而言,依赖属性的 CLR 包装除了调用GetValue()SetValue()之外不应该做任何事情。

原因在于,WPF 运行时的构造方式使得当您编写 XAML 时,它似乎设置了一个属性,例如

<Button x:Name="myButton" Height="100" .../>

运行时会完全绕过Height属性的 set 块,直接调用SetValue()!这种奇怪行为的原因与一种简单的优化技术有关。如果 WPF 运行时要调用Height属性的 set 块,它将不得不执行运行时反射来找出DependencyProperty字段(由SetValue()的第一个参数指定)的位置,在内存中引用它,等等。如果您要编写检索属性Height的值的 XAML,那么同样的故事也是成立的— GetValue()将被直接调用。

既然是这样,为什么还需要构建这个 CLR 包装器呢?WPF·XAML 不允许你在标记中调用函数,所以下面的标记是错误的:

<!-- Nope! Can't call methods in WPF XAML! -->
<Button x:Name="myButton" this.SetValue("100") .../>

实际上,当您使用 CLR 包装在标记中设置或获取一个值时,可以把它看作是告诉 WPF 运行时“嘿!去帮我调用GetValue() / SetValue(),因为我不能直接用标记来做!”现在,如果您用如下代码调用 CLR 包装器会怎么样:

Button b = new Button();
b.Height = 10;

在这种情况下,如果Height属性的 set 块包含对SetValue()的调用之外的代码,它执行,因为不涉及 WPF·XAML 解析器优化。

要记住的基本规则是,当注册一个依赖属性时,使用一个ValidateValueCallback委托来指向一个执行数据验证的方法。这确保了无论您是使用 XAML 还是代码来获取/设置依赖项属性,都会发生正确的行为。

构建自定义依赖项属性

如果你在这一章的这一点上有点头疼,这是完全正常的反应。构建依赖属性可能需要一些时间来适应。然而,无论好坏,这是构建许多自定义 WPF 控件过程的一部分,所以让我们来看看如何构建依赖属性。

首先创建一个名为 CustomDependencyProperty 的新 WPF 应用。现在,使用项目菜单,激活添加用户控件(WPF)菜单选项,并创建一个名为ShowNumberControl.xaml的控件。

Note

你会在第二十七章中了解到更多关于 WPF UserControl的细节,所以现在就按照图中所示进行吧。

就像一个窗口,WPF UserControl类型有一个 XAML 文件和一个相关的代码文件。更新用户控件的 XAML,在Grid中定义一个Label控件,如下所示:

<UserControl x:Class="CustomDepProp.ShowNumberControl"
    xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:local="clr-namespace: CustomDependencyProperty"
    mc:Ignorable="d"
    d:DesignHeight="300" d:DesignWidth="300">
  <Grid>
    <Label x:Name="numberDisplay" Height="50" Width="200" Background="LightBlue"/>
  </Grid>
</UserControl>

在该自定义控件的代码文件中,创建一个正常的、日常的。NET Core 属性,它包装了一个int,并用新值设置了LabelContent属性,如下所示:

public partial class ShowNumberControl : UserControl
{
  public ShowNumberControl()
  {
    InitializeComponent();
  }

  // A normal, everyday .NET property.
  private int _currNumber = 0;
  public int CurrentNumber
  {
    get => _currNumber;
    set
    {
      _currNumber = value;
      numberDisplay.Content = CurrentNumber.ToString();
    }
  }
}

现在,更新MainWindow.xml中的 XAML 定义,在StackPanel布局管理器中声明自定义控件的一个实例。因为您的自定义控件不是核心 WPF 程序集堆栈的一部分,所以您需要定义一个映射到您的控件的自定义 XML 命名空间。以下是所需的标记:

<Window x:Class="CustomDepPropApp.MainWindow"
    xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:myCtrls="clr-namespace: CustomDependencyProperty"
    xmlns:local="clr-namespace: CustomDependencyProperty"
    mc:Ignorable="d"
    Title="Simple Dependency Property App" Height="450" Width="450"
    WindowStartupLocation="CenterScreen">
  <StackPanel>
    <myCtrls:ShowNumberControl HorizontalAlignment="Left" x:Name="myShowNumberCtrl" CurrentNumber="100"/>
  </StackPanel>
</Window>

如您所见,Visual Studio 设计器似乎正确地显示了您在CurrentNumber属性中设置的值(参见图 25-23 )。

img/340876_10_En_25_Fig23_HTML.jpg

图 25-23。

看起来你的房子像预期的那样工作

但是,如果您想将一个动画对象应用到CurrentNumber属性,使其值在10秒内从100变为200,该怎么办呢?如果你想在标记中这样做,你可以这样更新你的myCtrls:ShowNumberControl范围:

<myCtrls:ShowNumberControl x:Name="myShowNumberCtrl" CurrentNumber="100">
  <myCtrls:ShowNumberControl.Triggers>
    <EventTrigger RoutedEvent = "myCtrls:ShowNumberControl.Loaded">
      <EventTrigger.Actions>
        <BeginStoryboard>
          <Storyboard TargetProperty = "CurrentNumber">
            <Int32Animation From = "100" To = "200" Duration = "0:0:10"/>
          </Storyboard>
        </BeginStoryboard>
      </EventTrigger.Actions>
    </EventTrigger>
  </myCtrls:ShowNumberControl.Triggers>
</myCtrls:ShowNumberControl>

如果运行应用,动画对象将找不到合适的目标,并引发异常。原因是CurrentNumber属性没有注册为依赖属性!若要解决问题,请返回自定义控件的代码文件,并完全注释掉当前的属性逻辑(包括私有支持字段)。

现在,添加以下代码来创建CurrentNumber作为依赖属性:

public int CurrentNumber
{
  get => (int)GetValue(CurrentNumberProperty);
  set => SetValue(CurrentNumberProperty, value);
}
public static readonly DependencyProperty CurrentNumberProperty =
  DependencyProperty.Register("CurrentNumber",
  typeof(int),
  typeof(ShowNumberControl),
  new UIPropertyMetadata(0));

这与您在Height属性的实现中看到的类似;但是,代码片段以内联方式注册属性,而不是在静态构造函数中注册(这很好)。还要注意,UIPropertyMetadata对象用于定义整数的默认值(0),而不是更复杂的FrameworkPropertyMetadata对象。这是作为依赖属性的CurrentNumber的最简单版本。

添加数据验证例程

尽管您现在有了一个名为CurrentNumber的依赖属性(并且不再抛出异常),但是您仍然看不到您的动画。您可能要做的下一个调整是指定一个要调用的函数来执行一些数据验证逻辑。对于这个例子,假设您需要确保CurrentNumber的值在0500之间。

为此,向类型为ValidateValueCallbackDependencyProperty.Register()方法添加一个最终参数,该参数指向一个名为ValidateCurrentNumber的方法。

ValidateValueCallback是一个委托,它只能指向返回bool的方法,并将object作为唯一的参数。这个object代表正在被分配的新值。如果输入值在预期范围内,执行ValidateCurrentNumber返回truefalse

public static readonly DependencyProperty CurrentNumberProperty =
  DependencyProperty.Register("CurrentNumber",
    typeof(int),
    typeof(ShowNumberControl),
    new UIPropertyMetadata(100),
    new ValidateValueCallback(ValidateCurrentNumber));

// Just a simple rule. Value must be between 0 and 500.
public static bool ValidateCurrentNumber(object value) =>
  Convert.ToInt32(value) >= 0 && Convert.ToInt32(value) <= 500;

响应属性更改

所以,现在你有一个有效的数字,但仍然没有动画。您需要做的最后一个更改是为UIPropertyMetadata的构造函数指定第二个参数,这是一个PropertyChangedCallback对象。这个委托可以指向任何一个将DependencyObject作为第一个参数,将DependencyPropertyChangedEventArgs作为第二个参数的方法。首先,更新代码,如下所示:

// Note the second param of UIPropertyMetadata constructor.
public static readonly DependencyProperty CurrentNumberProperty =
  DependencyProperty.Register("CurrentNumber", typeof(int), typeof(ShowNumberControl),
  new UIPropertyMetadata(100, new PropertyChangedCallback(CurrentNumberChanged)),
  new ValidateValueCallback(ValidateCurrentNumber));

CurrentNumberChanged()方法中,您的最终目标是将LabelContent更改为由CurrentNumber属性分配的新值。然而,你有一个大问题:CurrentNumberChanged()方法是静态的,因为它必须与静态的DependencyProperty对象一起工作。那么,对于当前的ShowNumberControl实例,如何获得对Label的访问呢?该引用包含在第一个DependencyObject参数中。您可以使用传入的事件参数找到新值。下面是更改LabelContent属性的必要代码:

private static void CurrentNumberChanged(DependencyObject depObj,   DependencyPropertyChangedEventArgs args)
{
  // Cast the DependencyObject into ShowNumberControl.
  ShowNumberControl c = (ShowNumberControl)depObj;
  // Get the Label control in the ShowNumberControl.
  Label theLabel = c.numberDisplay;
  // Set the Label with the new value.
  theLabel.Content = args.NewValue.ToString();
}

咻!仅仅改变一个标签的输出就有很长的路要走。好处是您的CurrentNumber依赖属性现在可以成为 WPF 风格的目标、动画对象、数据绑定操作的目标等等。如果您再次运行您的应用,您现在应该会看到在执行过程中值发生了变化。

这就结束了您对 WPF 依赖属性的了解。虽然我希望您对这些构造允许您做什么有一个更好的了解,并且对如何创建自己的构造有一个更好的了解,但是请注意,这里还有许多我没有涉及的细节。

如果您发现自己正在构建许多支持自定义属性的自定义控件,请在。NET Framework 4.7 SDK 文档。在本文中,您将发现更多构建依赖属性、附加属性的示例,配置属性元数据的各种方法,以及许多其他细节。

摘要

本章研究了 WPF 控件的几个方面,首先概述了控件工具包和布局管理器(面板)的作用。第一个示例让您有机会构建一个简单的文字处理应用,演示 WPF 的集成拼写检查功能,以及如何构建一个包含菜单系统、状态栏和工具栏的主窗口。

更重要的是,您研究了如何使用 WPF 命令。回想一下,您可以将这些与控件无关的事件附加到 UI 元素或输入手势,以自动继承现成的服务(例如,剪贴板操作)。

你也学到了很多关于在 XAML 构建复杂 UI 的知识,同时你也学到了 WPF Ink API。您还了解了 WPF 数据绑定操作,包括如何使用 WPF DataGrid类显示自定义AutoLot数据库中的数据。

最后,你调查了 WPF 是如何对传统文化进行独特的诠释的。NET 核心编程原语,特别是属性和事件。如您所见,依赖属性允许您构建一个可以集成到 WPF 服务集(动画、数据绑定、样式等)中的属性。).与此相关的是,路由事件为事件提供了一种沿标记树向上或向下流动的方式。

二十六、WPF 图形渲染服务

在这一章中,你将研究 WPF 的图形渲染能力。正如您将看到的,WPF 提供了三种不同的方式来呈现图形数据:形状、绘图和视觉效果。在你理解了每种方法的优缺点之后,你将开始使用System.Windows.Shapes中的类来学习交互式 2D 图形的世界。在此之后,您将看到绘图和几何如何让您以更轻量级的方式渲染 2D 数据。最后,您将了解可视化层如何为您提供最高级别的功能和性能。

在此过程中,您将探索几个相关的主题,例如如何创建自定义画笔和钢笔,如何将图形转换应用到渲染中,以及如何执行点击测试操作。您将看到 Visual Studio 的集成工具和一个名为 Inkscape 的附加工具如何简化您的图形编码工作。

Note

图形是 WPF 发展的一个重要方面。即使您不是在构建图形密集型应用(如视频游戏或多媒体应用),当您使用控件模板、动画和数据绑定自定义等服务时,本章中的主题也是至关重要的。

了解 WPF 的图形渲染服务

WPF 使用了一种特殊风格的图形渲染,称为保留模式图形。简而言之,这意味着既然你正在使用 XAML 或程序代码来生成图形渲染,那么 WPF 的责任就是保存这些可视项目,并确保它们以最佳方式被正确地重绘和刷新。因此,当您呈现图形数据时,它总是存在的,即使最终用户通过调整窗口大小或最小化窗口、用另一个窗口覆盖窗口等方式隐藏图像。

与之形成鲜明对比的是,之前的微软图形渲染 API(包括 Windows Forms 的 GDI+)都是即时模式图形系统。在这个模型中,由程序员来确保渲染的视觉效果在应用的生命周期中被正确地“记住”和更新。例如,在 Windows 窗体应用中,呈现矩形等形状涉及处理Paint事件(或覆盖虚拟的OnPaint()方法),获得一个Graphics对象来绘制矩形,最重要的是,添加基础结构来确保当用户调整窗口大小时图像是持久的(例如,创建成员变量来表示矩形的位置,并在整个程序中调用Invalidate())。

从即时模式到保留模式图形的转变确实是一件好事,因为程序员要创作和维护的图形代码要少得多。然而,我并不是说 WPF 图形 API 与早期的渲染工具包完全不同。例如,像 GDI+一样,WPF 支持各种画笔类型和笔对象、点击测试技术、剪辑区域、图形转换等等。所以,如果你目前有 GDI+(或基于 C/C++的 GDI)的背景,你已经知道了很多关于如何在 WPF 下执行基本渲染的知识。

WPF 图形渲染选项

与 WPF 开发的其他方面一样,除了决定通过 XAML 或过程化 C# 代码(或者两者的结合)来执行图形呈现之外,关于如何执行图形呈现,您还有许多选择。具体来说,WPF 提供了以下三种不同的方式来呈现图形数据:

  • Shapes : WPF 提供了System.Windows.Shapes名称空间,它定义了少量用于渲染 2D 几何对象(矩形、椭圆、多边形等)的类。).虽然这些类型使用简单且功能强大,但如果不加考虑地使用,它们确实会带来相当大的内存开销。

  • 绘图和几何图形:WPF API 提供了第二种呈现图形数据的方式,使用来自System.Windows.Media.Drawing抽象类的后代。使用像GeometryDrawingImageDrawing这样的类(除了各种几何对象,你可以用一种更轻量级(但功能不丰富)的方式呈现图形数据。

  • 视觉效果:在 WPF 下渲染图形数据的最快和最轻量级的方法是使用视觉层,它只能通过 C# 代码访问。使用System.Windows.Media.Visual的后代,您可以直接与 WPF 图形子系统对话。

提供不同方式来完成同一件事情(例如,呈现图形数据)的原因与内存使用以及最终的应用性能有关。因为 WPF 是一个图形密集型系统,所以一个应用在一个窗口的表面上呈现数百甚至数千个不同的图像是合理的,并且实现的选择(形状、绘图或视觉)可能会产生巨大的影响。

请理解,当您构建一个 WPF 应用时,很有可能会用到这三个选项。根据经验,如果你需要适量的可由用户操作的交互式图形数据(接收鼠标输入,显示工具提示等)。),您将需要使用System.Windows.Shapes名称空间中的成员。

相比之下,当您需要使用 XAML 或 C# 对复杂的、通常非交互式的、基于矢量的图形数据建模时,绘图和几何图形更合适。虽然绘图和几何图形仍然可以响应鼠标事件、点击测试和拖放操作,但通常需要编写更多的代码来实现这一点。

最后但同样重要的是,如果您需要尽可能最快的方式来呈现大量的图形数据,那么可视化层是一个不错的选择。例如,假设您正在使用 WPF 构建一个科学应用,它可以绘制出成千上万的数据点。使用视觉图层,您可以尽可能以最佳方式渲染地块点。正如你将在本章后面看到的,可视化层只能通过 C# 代码访问,并且不是 XAML 友好的。

无论您采用哪种方法(形状、绘图和几何图形,或视觉),您都将使用常见的图形原语,如画笔(填充内部)、钢笔(绘制外部)和转换对象(转换数据)。为了开始这个旅程,您将开始使用System.Windows.Shapes的类。

Note

WPF 还附带了一个成熟的 API,可用于渲染和操作 3D 图形,这在本文中没有涉及。

使用形状呈现图形数据

System.Windows.Shapes名称空间的成员提供了最直接、最具交互性、但最占用内存的方式来呈现二维图像。这个名称空间(在PresentationFramework.dll汇编中定义)非常小,只包含六个扩展抽象Shape基类的密封类:EllipseRectangleLinePolygonPolylinePath

抽象的Shape类继承自FrameworkElement,后者继承自UIElement。这些类定义成员来处理大小调整、工具提示、鼠标光标等等。给定这个继承链,当您使用Shape派生类来呈现图形数据时,这些对象的功能(就用户交互性而言)就像 WPF 控件一样!

例如,确定用户是否点击了您的渲染图像并不比处理MouseDown事件更复杂。举个简单的例子,如果你在你最初的WindowGrid中创作了这个Rectangle对象的 XAML:

<Rectangle x:Name="myRect" Height="30" Width="30" Fill="Green" MouseDown="myRect_MouseDown"/>

您可以为MouseDown事件实现一个 C# 事件处理程序,它会在单击时改变矩形的背景颜色,如下所示:

private void myRect_MouseDown(object sender, MouseButtonEventArgs e)
{
  // Change color of Rectangle when clicked.
  myRect.Fill = Brushes.Pink;
}

与您可能使用过的其他图形工具包不同,您不需要而不是编写大量的基础设施代码,这些代码手动将鼠标坐标映射到几何图形,手动计算点击测试,渲染到屏幕外缓冲区,等等。System.Windows.Shapes的成员只是简单地响应你注册的事件,就像一个典型的 WPF 控件(如Button等)。).

所有这些开箱即用的功能的缺点是形状确实会占用大量内存。如果您正在构建一个在屏幕上绘制成千上万个点的科学应用,使用形状将是一个糟糕的选择(本质上,它将与渲染成千上万个Button对象一样占用大量内存!).然而,当你需要生成一个交互式的 2D 矢量图像时,形状是一个很好的选择。

除了从UIElementFrameworkElement父类继承的功能之外,Shape为每个子类定义了许多成员;表 26-1 显示了一些更有用的。

表 26-1。

Shape基类的关键属性

|

性能

|

生命的意义

DefiningGeometry 返回一个代表当前形状总尺寸的Geometry对象。该对象只包含用于渲染数据的绘图点,没有UIElementFrameworkElement功能的痕迹。
Fill 允许您指定画笔对象来填充形状的内部。
GeometryTransform 允许您在图形呈现在屏幕上之前对其应用变换*。继承的RenderTransform属性(来自UIElement)在呈现在屏幕上后应用变换。*
Stretch 描述如何在分配给形状的空间内填充形状,如形状在布局管理器中的位置。这是使用相应的System.Windows.Media.Stretch枚举来控制的。
Stroke 定义一个画笔对象,或者在某些情况下,定义一个钢笔对象(实际上是一个伪装的画笔),用于绘制形状的边框。
StrokeDashArrayStrokeEndLineCapStrokeStartLineCapStrokeThickness 这些(和其他)与笔画相关的属性控制在绘制形状的边框时如何配置线条。在大多数情况下,这些属性将配置用于绘制边框或线条的画笔。

Note

如果你忘记设置FillStroke属性,WPF 会给你“不可见”的笔刷,因此,这个形状在屏幕上是不可见的!

将矩形、椭圆和线条添加到画布

您将使用 XAML 和 C# 构建一个可以呈现形状的 WPF 应用,并且在此过程中,学习一点关于点击测试的过程。创建一个名为 RenderingWithShapes 的新 WPF 应用,并将标题MainWindow.xaml改为“有趣的形状!”然后更新<Window>的初始 XAML,用包含一个(现在为空)<ToolBar>和一个<Canvas><DockPanel>替换Grid。注意,通过Name属性,每个包含的项目都有一个配件名称。

<DockPanel LastChildFill="True">
  <ToolBar DockPanel.Dock="Top" Name="mainToolBar" Height="50">
  </ToolBar>
  <Canvas Background="LightBlue" Name="canvasDrawingArea"/>
</DockPanel>

现在,用一组<RadioButton>对象填充<ToolBar>,每个对象包含一个特定的Shape派生类作为内容。请注意,每个<RadioButton>都被分配给同一个GroupName(以确保互斥性),并且还被赋予了一个合适的名称。

<ToolBar DockPanel.Dock="Top" Name="mainToolBar" Height="50">
  <RadioButton Name="circleOption" GroupName="shapeSelection" Click="CircleOption_Click">
    <Ellipse Fill="Green" Height="35" Width="35" />
  </RadioButton>
  <RadioButton Name="rectOption" GroupName="shapeSelection" Click="RectOption_Click">
    <Rectangle Fill="Red" Height="35" Width="35" RadiusY="10" RadiusX="10" />
  </RadioButton>
  <RadioButton Name="lineOption" GroupName="shapeSelection" Click="LineOption_Click">
    <Line Height="35" Width="35" StrokeThickness="10" Stroke="Blue"
          X1="10" Y1="10" Y2="25" X2="25"
          StrokeStartLineCap="Triangle" StrokeEndLineCap="Round" />
  </RadioButton>
</ToolBar>

如您所见,在 XAML 声明RectangleEllipseLine对象非常简单,几乎不需要注释。回想一下,Fill属性用于指定画笔来绘制形状的内部。当需要纯色画笔时,只需指定一个已知值的硬编码字符串,底层类型转换器就会生成正确的对象。Rectangle类型的一个有趣的特性是它定义了RadiusXRadiusY属性来允许你渲染弯曲的角落。

Line使用X1X2Y1Y2属性表示其起点和终点(假设高度宽度在描述一条线时没有什么意义)。在这里,您设置了几个附加属性来控制如何呈现Line的起点和终点,以及如何配置笔画设置。图 26-1 显示了通过 Visual Studio WPF 设计器看到的渲染工具栏。

img/340876_10_En_26_Fig1_HTML.jpg

图 26-1。

使用形状作为一组RadioButtons的内容

现在,使用 Visual Studio 的属性窗口,为Canvas处理MouseLeftButtonDown事件,为每个RadioButton处理Click事件。在您的 C# 文件中,您的目标是当用户在Canvas中单击时呈现选定的形状(圆形、正方形或直线)。首先,在您的Window-派生类中定义下面的嵌套enum(以及相应的成员变量):

public partial class MainWindow : Window
{
  private enum SelectedShape
  { Circle, Rectangle, Line }
  private SelectedShape _currentShape;
}

在每个Click事件处理程序中,将currentShape成员变量设置为正确的SelectedShape值,如下所示:

private void CircleOption_Click(object sender, RoutedEventArgs e)
{
  _currentShape = SelectedShape.Circle;
}

private void RectOption_Click(object sender, RoutedEventArgs e)
{
  _currentShape = SelectedShape.Rectangle;
}

private void LineOption_Click(object sender, RoutedEventArgs e)
{
  _currentShape = SelectedShape.Line;
}

使用CanvasMouseLeftButtonDown事件处理程序,您将使用鼠标光标的 X,Y 位置作为起点,渲染出正确的形状(预定义大小)。下面是完整的实现,分析如下:

private void CanvasDrawingArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
  Shape shapeToRender = null;
  // Configure the correct shape to draw.
  switch (_currentShape)
  {
    case SelectedShape.Circle:
      shapeToRender = new Ellipse() { Fill = Brushes.Green, Height = 35, Width = 35 };
      break;
    case SelectedShape.Rectangle:
      shapeToRender = new Rectangle()
        { Fill = Brushes.Red, Height = 35, Width = 35, RadiusX = 10, RadiusY = 10 };
      break;
    case SelectedShape.Line:
      shapeToRender = new Line()
      {
        Stroke = Brushes.Blue,
        StrokeThickness = 10,
        X1 = 0, X2 = 50, Y1 = 0, Y2 = 50,
        StrokeStartLineCap= PenLineCap.Triangle,
        StrokeEndLineCap = PenLineCap.Round
      };
      break;
    default:
      return;
  }
  // Set top/left position to draw in the canvas.
  Canvas.SetLeft(shapeToRender, e.GetPosition(canvasDrawingArea).X);
  Canvas.SetTop(shapeToRender, e.GetPosition(canvasDrawingArea).Y);
  // Draw shape!
  canvasDrawingArea.Children.Add(shapeToRender);
}

Note

您可能会注意到,在这个方法中创建的EllipseRectangleLine对象与相应的 XAML 定义具有相同的属性设置!正如你所希望的,你可以简化这些代码,但是这需要理解 WPF 对象资源,这将在第二十七章中讨论。

如您所见,您正在测试currentShape成员变量以创建正确的Shape派生对象。在这之后,使用传入的MouseButtonEventArgs设置Canvas中左上角的值。最后但同样重要的是,您将新的Shape派生类型添加到由Canvas维护的UIElement对象集合中。如果您现在运行您的程序,您应该能够单击画布中的任何位置,并看到在鼠标左键单击的位置呈现的所选形状。

从画布中移除矩形、椭圆和线条

有了维护对象集合的Canvas,您可能想知道如何动态地移除一个项目,也许是为了响应用户右击一个形状。您当然可以使用名为VisualTreeHelperSystem.Windows.Media名称空间中的类来实现这一点。第二十七章将详细解释“视觉树”和“逻辑树”的作用。在此之前,您可以处理您的Canvas对象上的MouseRightButtonDown事件,并实现相应的事件处理程序,如下所示:

private void CanvasDrawingArea_MouseRightButtonDown(object sender, MouseButtonEventArgs e)
{
  // First, get the X,Y location of where the user clicked.
  Point pt = e.GetPosition((Canvas)sender);
  // Use the HitTest() method of VisualTreeHelper to see if the user clicked
  // on an item in the canvas.
  HitTestResult result = VisualTreeHelper.HitTest(canvasDrawingArea, pt);
  // If the result is not null, they DID click on a shape!
  if (result != null)
  {
      // Get the underlying shape clicked on, and remove it from
      // the canvas.
      canvasDrawingArea.Children.Remove(result.VisualHit as Shape);
  }
}

该方法首先获取用户在Canvas中点击的准确的 X,Y 位置,并通过静态VisualTreeHelper.HitTest()方法执行点击测试操作。如果用户没有点击Canvas中的UIElement,返回值HitTestResult对象将被设置为空。如果HitTestResult而不是 null,你可以通过VisualHit属性获得被点击的底层UIElement,你将它转换成一个Shape派生的对象(记住,Canvas可以保存任何UIElement,而不仅仅是形状!).同样,在下一章中你会得到更多关于“视觉树”的细节。

Note

默认情况下,VisualTreeHelper.HitTest()返回被点击的最上面的UIElement,不提供该项下面的其他对象的信息(例如,按 Z 顺序重叠的对象)。

通过这一修改,您应该能够用鼠标左键在画布上添加一个形状,用鼠标右键从画布上删除一个项目!

目前为止,一切顺利。至此,您已经使用 XAML 使用Shape派生的对象在RadioButton上呈现内容,并使用 C# 填充了一个Canvas。当您检查画笔和图形转换的作用时,您将向这个示例添加更多的功能。与此相关,本章中的另一个例子将说明在UIElement对象上的拖放技术。在那之前,让我们检查一下System.Windows.Shapes的剩余成员。

使用多段线和多边形

当前的例子只使用了三个Shape派生类。其余的子类(PolylinePolygonPath)在没有工具支持的情况下(例如 Microsoft Blend,为 WPF 开发人员设计的 Visual Studio 的配套工具,或其他可以创建矢量图形的工具),要正确渲染极其繁琐,因为它们需要大量的绘图点来表示它们的输出。以下是其余Shapes类型的概述。

Polyline类型允许您定义一组(x,y)坐标(通过Points属性)来绘制一系列不需要连接端点的线段。Polygon型也差不多;但是,它被编程为总是关闭起点和终点,并用指定的画笔填充内部。假设您已经在 Kaxaml 编辑器中编写了下面的<StackPanel>:

<!-- Polylines do not automatically connect the ends. -->
<Polyline Stroke ="Red" StrokeThickness ="20" StrokeLineJoin ="Round" Points ="10,10 40,40 10,90 300,50"/>
<!-- A Polygon always closes the end points. -->
<Polygon Fill ="AliceBlue" StrokeThickness ="5" Stroke ="Green" Points ="40,10 70,80 10,50" />

图 26-2 显示了 Kaxaml 的渲染输出。

img/340876_10_En_26_Fig2_HTML.png

图 26-2。

多边形和折线

使用路径

单独使用RectangleEllipsePolygonPolylineLine类型来绘制详细的 2D 矢量图像会非常复杂,因为这些图元不允许您轻松地捕捉图形数据,如曲线、重叠数据的联合等。最后一个Shape派生类Path,提供了定义复杂的 2D 图形数据的能力,这些数据被表示为一组独立的几何图形。在您定义了这样的几何图形集合之后,您可以将它们分配给Path类的Data属性,其中的信息将用于呈现复杂的 2D 图像。

Data属性采用一个System.Windows.Media.Geometry派生类,包含表 26-2 中描述的关键成员。

表 26-2。

选择System.Windows.Media.Geometry类型的成员

|

成员

|

生命的意义

Bounds 建立包含几何图形的当前边框。
FillContains() 确定给定的Point(或其他Geometry对象)是否在特定的Geometry派生类的范围内。这对于点击测试计算很有用。
GetArea() 返回一个Geometry派生类型占据的整个区域。
GetRenderBounds() 返回一个Rect,它包含可能用于呈现Geometry派生类的最小矩形。
Transform 给几何体分配一个Transform对象,以改变渲染。

扩展Geometry的类(见表 26-3 )看起来非常像它们的Shape派生的对应类。例如,EllipseGeometry的成员与Ellipse相似。最大的区别是Geometry的派生类不知道如何直接呈现它们自己,因为它们不是UIElement的。相反,Geometry的派生类只代表一个绘图点数据的集合,实际上是说“如果一个Path使用我的数据,这就是我如何呈现自己。”

表 26-3。

Geometry-派生类

|

几何课

|

生命的意义

LineGeometry 代表一条直线
RectangleGeometry 表示一个矩形
EllipseGeometry 表示一个椭圆
GeometryGroup 允许您对几个Geometry对象进行分组
CombinedGeometry 允许您将两个不同的Geometry对象合并成一个形状
PathGeometry 表示由直线和曲线组成的图形

Note

不是 WPF 唯一可以使用几何图形集合的类。例如,DoubleAnimationUsingPathDrawingGroupGeometryDrawing,甚至UIElement都可以使用几何图形进行渲染,分别使用PathGeometryClipGeometryGeometryClip属性。

下面是一个使用了一些Geometry派生类型的Path。请注意,您正在将PathData属性设置为一个GeometryGroup对象,该对象包含其他Geometry派生的对象,如EllipseGeometryRectangleGeometryLineGeometry。图 26-3 显示了输出。

img/340876_10_En_26_Fig3_HTML.jpg

图 26-3。

包含各种Geometry对象的路径

<!-- A Path contains a set of geometry objects, set with the Data property. -->
<Path Fill = "Orange" Stroke = "Blue" StrokeThickness = "3">
  <Path.Data>
    <GeometryGroup>
      <EllipseGeometry Center = "75,70" RadiusX = "30" RadiusY = "30" />
    <RectangleGeometry Rect = "25,55 100 30" />
    <LineGeometry StartPoint="0,0" EndPoint="70,30" />
    <LineGeometry StartPoint="70,30" EndPoint="0,30" />
  </GeometryGroup>
  </Path.Data>
</Path>

图 26-3 中的图像可以使用之前显示的LineEllipseRectangle类来渲染。然而,这会将各种UIElement对象放到内存中。当您使用几何图形对要绘制的绘图点进行建模,然后将几何图形集合放入一个可以呈现数据的容器(在本例中为Path)中时,您可以减少内存开销。

现在回想一下,PathSystem.Windows.Shapes的任何其他成员具有相同的继承链,因此可以发送与其他UIElement对象相同的事件通知。因此,如果您要在 Visual Studio 项目中定义这个相同的<Path>元素,您可以通过处理鼠标事件来确定用户是否单击了扫描行中的任何位置(记住,Kaxaml 不允许您处理您所创作的标记的事件)。

“微型语言”的路径建模

在表 26-3 中列出的所有类中,PathGeometry在 XAML 或代码方面是最复杂的。这与PathGeometry的每个都是由包含各种段和图形的对象组成的(如ArcSegmentBezierSegmentLineSegmentPolyBezierSegmentPolyLineSegmentPolyQuadraticBezierSegment等)。).下面是一个Path对象的示例,其Data属性已被设置为由各种图形和线段组成的<PathGeometry>:

<Path Stroke="Black" StrokeThickness="1" >
  <Path.Data>
    <PathGeometry>
      <PathGeometry.Figures>
        <PathFigure StartPoint="10,50">
          <PathFigure.Segments>
           <BezierSegment
             Point1="100,0"
             Point2="200,200"
             Point3="300,100"/>
           <LineSegment Point="400,100" />
           <ArcSegment
             Size="50,50" RotationAngle="45"
             IsLargeArc="True" SweepDirection="Clockwise"
             Point="200,100"/>
           </PathFigure.Segments>
        </PathFigure>
      </PathGeometry.Figures>
    </PathGeometry>
  </Path.Data>
</Path>

现在,说实话,很少有程序员需要通过直接描述GeometryPathSegment派生类来手工构建复杂的 2D 映像。在本章的后面,你将学习如何将矢量图形转换成可以在 XAML 中使用的路径语句。

即使有这些工具的帮助,定义一个复杂的Path对象所需的 XAML 量也将是可怕的,因为数据由各种GeometryPathSegment派生类的完整描述组成。为了产生更简洁紧凑的标记,Path类被设计成能够理解一种专门的“迷你语言”

例如,与其将PathData属性设置为GeometryPathSegment派生类型的集合,不如将Data属性设置为包含许多已知符号和定义要呈现的形状的各种值的单个字符串文字。下面是一个简单的例子,结果输出如图 26-4 所示:

img/340876_10_En_26_Fig4_HTML.png

图 26-4。

Path 微型语言允许您简洁地描述一个Geometry/PathSegment对象模型

<Path Stroke="Black" StrokeThickness="3" Data="M 10,75 C 70,15 250,270 300,175 H 240" />

M命令(简称移动)取一个 X,Y 位置,代表绘图的起点。C命令采用一系列绘图点来绘制一条 c 曲线(确切地说是一条三次贝塞尔曲线),而H绘制一条水平线

现在,老实说,您需要手动构建或解析包含 Path 微型语言指令的字符串文字的机会微乎其微。然而,至少,当你看到 XAML 开发的专用工具时,你不会再感到惊讶了。

WPF 画笔和钢笔

每个 WPF 图形渲染选项(形状,绘图和几何图形,视觉效果)都大量使用了笔刷,它允许你控制 2D 表面的内部是如何填充的。WPF 提供了六种不同的笔刷类型,它们都扩展了System.Windows.Media.Brush。虽然Brush是抽象的,但是表 26-4 中描述的后代可以用来填充一个区域,几乎可以用任何可以想到的选项。

表 26-4。

WPFBrush-衍生类型

|

刷型

|

生命的意义

DrawingBrush 用从Drawing派生的对象(GeometryDrawingImageDrawingVideoDrawing)绘制区域
ImageBrush 用图像绘制一个区域(由一个ImageSource对象表示)
LinearGradientBrush 用线性渐变绘制区域
RadialGradientBrush 用径向渐变绘制区域
SolidColorBrush 使用Color属性设置,绘制单一颜色
VisualBrush 用从Visual派生的对象(DrawingVisualViewport3DVisualContainerVisual)绘制一个区域

DrawingBrushVisualBrush类允许你基于现有的DrawingVisual派生类来构建画笔。当你使用 WPF 的另外两个图形选项(绘图或视觉)时,会用到这些笔刷类,我们将在本章的后面进行讨论。

顾名思义,ImageBrush通过设置ImageSource属性,让您构建一个显示来自外部文件或嵌入式应用资源的图像数据的画笔。剩下的笔刷类型(LinearGradientBrushRadialGradientBrush)很容易使用,尽管输入所需的 XAML 可能有点冗长。幸运的是,Visual Studio 支持集成的画笔编辑器,这使得生成风格化的画笔变得简单。

使用 Visual Studio 配置画笔

让我们更新你的 WPF 绘图程序,RenderingWithShapes,使用一些更有趣的笔刷。到目前为止,您用来在工具栏上呈现数据的三个形状都使用简单的纯色,因此您可以使用简单的字符串来获取它们的值。为了增加一点趣味,你现在将使用集成的笔刷编辑器。确保初始窗口的 XAML 编辑器是 IDE 中打开的窗口,并选择Ellipse元素。现在,在属性窗口中,定位笔刷类别,然后点击顶部列出的Fill属性(参见图 26-5 )。

img/340876_10_En_26_Fig5_HTML.jpg

图 26-5。

任何需要画笔的属性都可以用集成的画笔编辑器来配置

在画笔编辑器的顶部,你会看到一组属性,它们都是所选项目的“画笔兼容的”(例如,FillStrokeOpacityMask)。在这下面,你会看到一系列的标签,允许你配置不同类型的画笔,包括当前的纯色画笔。您可以使用颜色选择器工具以及 ARGB (alpha、红色、绿色和蓝色,其中“alpha”控制透明度)编辑器来控制当前画笔的颜色。使用这些滑块和相关的颜色选择区域,您可以创建任何种类的纯色。使用这些工具来改变你的EllipseFill颜色,并查看生成的 XAML。您会注意到颜色是以十六进制值存储的,如下所示:

<Ellipse Fill="#FF47CE47" Height="35" Width="35" />

更有趣的是,这个编辑器允许您配置渐变画笔,用于定义一系列颜色和过渡点。回想一下,这个笔刷编辑器为您提供了一组选项卡,其中的第一个选项卡允许您为无渲染输出设置一个空笔刷。其他四个允许你设置一个纯色笔刷(你刚刚检查的),渐变笔刷,拼贴笔刷,或者图像笔刷。

点击渐变画笔按钮,编辑器会显示一些新的选项(见图 26-6 )。左下方的三个按钮允许您选择线性渐变、径向渐变或反转渐变停止点。最底部的条将显示每个渐变停止点的当前颜色,每个渐变停止点都由条上的“拇指”标记。当您在渐变条周围拖移这些滑块时,您可以控制渐变偏移。此外,当您单击给定的缩略图时,您可以通过颜色选择器更改特定渐变停止点的颜色。最后,如果您直接点按渐变条,您可以添加渐变停止点。

img/340876_10_En_26_Fig6_HTML.jpg

图 26-6。

Visual Studio 画笔编辑器允许您构建基本的渐变画笔

花几分钟时间使用这个编辑器来创建一个包含三个渐变停止点的径向渐变画笔,设置为你选择的颜色。图 26-6 显示了你刚刚构建的笔刷,使用了三种不同的绿色。

完成后,IDE 将使用自定义画笔更新您的 XAML,使用属性元素语法设置为画笔兼容属性(本例中为EllipseFill属性),如下所示:

<Ellipse Height="35" Width="35">
  <Ellipse.Fill>
    <RadialGradientBrush>
      <GradientStop Color="#FF17F800"/>
      <GradientStop Color="#FF24F610" Offset="1"/>
      <GradientStop Color="#FF1A6A12" Offset="0.546"/>
    </RadialGradientBrush>
   </Ellipse.Fill>
</Ellipse>

在代码中配置画笔

现在你已经为你的Ellipse的 XAML 定义构建了一个自定义笔刷,相应的 C# 代码已经过时了,因为它仍然会渲染一个实心的绿色圆圈。为了同步备份,更新正确的case语句来使用你刚刚创建的笔刷。以下是必要的更新,看起来比你想象的要复杂,因为你正在通过System.Windows.Media.ColorConverter类将十六进制值转换成一个合适的Color对象(修改后的输出见图 26-7 ):

img/340876_10_En_26_Fig7_HTML.jpg

图 26-7。

用更多的活力画圆

case SelectedShape.Circle:
  shapeToRender = new Ellipse() { Height = 35, Width = 35 };
  // Make a RadialGradientBrush in code!
  RadialGradientBrush brush = new RadialGradientBrush();
  brush.GradientStops.Add(new GradientStop(
    (Color)ColorConverter.ConvertFromString("#FF77F177"), 0));
  brush.GradientStops.Add(new GradientStop(
    (Color)ColorConverter.ConvertFromString("#FF11E611"), 1));
  brush.GradientStops.Add(new GradientStop(
    (Color)ColorConverter.ConvertFromString("#FF5A8E5A"), 0.545));
  shapeToRender.Fill = brush;
  break;

顺便说一下,您可以通过使用Colors枚举指定一个简单的颜色作为第一个构造函数参数来构建GradientStop对象,这将返回一个已配置的Color对象。

GradientStop g = new GradientStop(Colors.Aquamarine, 1);

或者,如果您需要更精细的控制,您可以传入一个已配置的Color对象,如下所示:

Color myColor = new Color() { R = 200, G = 100, B = 20, A = 40 };
GradientStop g = new GradientStop(myColor, 34);

当然,Colors枚举和Color类并不局限于渐变画笔。您可以在任何需要用代码表示颜色值的时候使用它们。

配置笔

与画笔相比,是用于绘制几何图形边界的对象,或者在LinePolyLine类的情况下,是线条几何图形本身。具体来说,Pen类允许你绘制一个指定的厚度,用一个double值表示。此外,Pen可以配置与Shape类相同的属性,比如开始和停止笔帽、点划线图案等等。例如,您可以将以下标记添加到形状中,以定义钢笔属性:

<Pen Thickness="10" LineJoin="Round" EndLineCap="Triangle" StartLineCap="Round" />

在许多情况下,您不需要直接创建一个Pen对象,因为这将在您为属性赋值时间接完成,例如将StrokeThickness赋值给一个Shape派生的类型(以及其他的UIElements)。然而,当使用Drawing派生的类型时,构建一个定制的Pen对象是很方便的(在本章后面会有描述)。Visual Studio 没有笔编辑器本身,但是它允许您使用属性窗口配置选定项的所有以笔画为中心的属性。

应用图形转换

为了总结使用形状的讨论,让我们讨论一下转换的话题。WPF 附带了许多扩展抽象基类的类。表 26-5 记录了许多关键的现成的Transform派生类。

表 26-5。

System.Windows.Media.Transform类型的主要后代

|

类型

|

生命的意义

MatrixTransform 创建任意矩阵变换,用于操作 2D 平面中的对象或坐标系
RotateTransform 围绕 2D (x,y)坐标系中的指定点顺时针旋转对象
ScaleTransform 在 2D (x,y)坐标系中缩放对象
SkewTransform 在 2D (x,y)坐标系中倾斜对象
TranslateTransform 在 2D (x,y)坐标系中平移(移动)对象
TransformGroup 表示由其他Transform对象组成的复合Transform

变换可以应用于任何UIElement(例如,Shape的后代以及诸如Button控件、TextBox控件等控件)。使用这些转换类,您可以以给定的角度呈现图形数据,在表面上倾斜图像,并以各种方式扩展、收缩或翻转目标项目。

Note

虽然变换对象可以在任何地方使用,但您会发现它们在处理 WPF 动画和自定义控件模板时最有用。正如您将在本章后面看到的,您可以使用 WPF 动画为自定义控件的最终用户提供视觉提示。

可以将变换(或一整套变换)分配给目标对象(例如,ButtonPath等)。)使用两个共同的属性,LayoutTransformRenderTransform

LayoutTransform属性是有帮助的,因为转换发生在元素被呈现到布局管理器之前的*,因此转换不会影响 Z 排序操作(换句话说,转换后的图像数据不会重叠)。*

另一方面,RenderTransform属性发生在项目进入它们的容器之后,因此很有可能元素可以根据它们在容器中的排列方式以相互重叠的方式进行转换。

转换的初步观察

一会儿,您将为您的RenderingWithShapes项目添加一些转换逻辑。然而,要查看实际的转换对象,打开 Kaxaml,在根PageWindow中定义一个简单的StackPanel,并将Orientation属性设置为Horizontal。现在,添加下面的Rectangle,它将使用RotateTransform对象以 45 度角绘制:

<!-- A Rectangle with a rotate transformation. -->
<Rectangle Height ="100" Width ="40" Fill ="Red">
  <Rectangle.LayoutTransform>
    <RotateTransform Angle ="45"/>
  </Rectangle.LayoutTransform>
</Rectangle>

这里有一个<Button>在表面上倾斜了 20 度,使用的是一个<SkewTransform>:

<!-- A Button with a skew transformation. -->
<Button Content ="Click Me!" Width="95" Height="40">
  <Button.LayoutTransform>
   <SkewTransform AngleX ="20" AngleY ="20"/>
  </Button.LayoutTransform>
</Button>

为了更好地测量,这里有一个用ScaleTransform缩放了 20 度的Ellipse(注意设置为初始HeightWidth的值),以及一个应用了一组变换对象的TextBox:

<!-- An Ellipse that has been scaled by 20%. -->
<Ellipse Fill ="Blue" Width="5" Height="5">
  <Ellipse.LayoutTransform>
    <ScaleTransform ScaleX ="20" ScaleY ="20"/>
  </Ellipse.LayoutTransform>
</Ellipse>
<!-- A TextBox that has been rotated and skewed. -->
<TextBox Text ="Me Too!" Width="50" Height="40">
  <TextBox.LayoutTransform>
    <TransformGroup>
      <RotateTransform Angle ="45"/>
      <SkewTransform AngleX ="5" AngleY ="20"/>
    </TransformGroup>
  </TextBox.LayoutTransform>
</TextBox>

请注意,当应用转换时,您不需要执行任何手动计算来正确地响应点击测试、输入焦点等等。WPF 图形引擎代表你处理这样的任务。例如,在图 26-8 中,你可以看到TextBox仍然对键盘输入有反应。

img/340876_10_En_26_Fig8_HTML.jpg

图 26-8。

图形转换对象的结果

转换您的画布数据

现在,让我们将一些转换逻辑合并到你的 RenderingWithShapes 示例中。除了将变换对象应用于单个项目(例如,RectangleTextBox等)。),您也可以将转换对象应用于布局管理器,以转换所有内部数据。例如,你可以以一个角度渲染主窗口的整个DockPanel

<DockPanel LastChildFill="True">
  <DockPanel.LayoutTransform>
    <RotateTransform Angle="45"/>
  </DockPanel.LayoutTransform>
...
</DockPanel>

对于这个例子来说这有点极端,所以让我们添加一个最终的(不太激进的)特性,允许用户翻转整个Canvas和所有包含的图形。首先将最后一个ToggleButton添加到您的ToolBar中,定义如下:

<ToggleButton Name="flipCanvas" Click="FlipCanvas_Click" Content="Flip Canvas!"/>

Click事件处理程序中,创建一个RotateTransform对象,如果这个新的ToggleButton被点击,通过LayoutTransform属性将它连接到Canvas对象。如果没有点击ToggleButton,通过将相同的属性设置为null来移除转换。

private void FlipCanvas_Click(object sender, RoutedEventArgs e)
{
  if (flipCanvas.IsChecked == true)
  {
    RotateTransform rotate = new RotateTransform(-180);
    canvasDrawingArea.LayoutTransform = rotate;
  }
  else
  {
    canvasDrawingArea.LayoutTransform = null;
  }
}

运行您的应用,在整个画布区域添加一堆图形,确保它们并排。如果你点击你的新按钮,你会发现形状数据超出了画布的边界!这是因为你没有定义一个裁剪区域(见图 26-9 )。

img/340876_10_En_26_Fig9_HTML.jpg

图 26-9。

哎呀!在转换之后,您的数据正在流出画布!

解决这个问题很简单。不需要手工编写复杂的裁剪逻辑代码,只需将CanvasClipToBounds属性设置为true,这样可以防止子元素被呈现在父元素的边界之外。如果你再次运行你的程序,你会发现数据不会从画布边界溢出。

<Canvas ClipToBounds = "True" ... >

要做的最后一个微小的修改是,当您通过按下切换按钮翻转画布,然后单击画布来绘制新形状时,您单击的点是而不是应用图形数据的点。相反,数据呈现在鼠标光标上方。

要解决这个问题,在渲染发生之前,将同一个变换对象应用到正在绘制的形状(通过RenderTransform)。代码的关键在于:

private void CanvasDrawingArea_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
  //omitted for brevity
  if (flipCanvas.IsChecked == true)
  {
    RotateTransform rotate = new RotateTransform(-180);
    shapeToRender.RenderTransform = rotate;
  }
  // Set top/left to draw in the canvas.
  Canvas.SetLeft(shapeToRender,
    e.GetPosition(canvasDrawingArea).X);
  Canvas.SetTop(shapeToRender,
    e.GetPosition(canvasDrawingArea).Y);

  // Draw shape!
  canvasDrawingArea.Children.Add(shapeToRender);
}

这就完成了对System.Windows.Shapes、笔刷和变换的检查。在查看使用绘图和几何图形呈现图形的作用之前,让我们看看如何使用 Visual Studio 来简化处理基本图形的方式。

使用 Visual Studio 转换编辑器

在前面的示例中,您通过手动输入标记和创作一些 C# 代码来应用各种转换。虽然这肯定是有用的,但是您会很高兴地知道最新版本的 Visual Studio 附带了一个集成的转换编辑器。回想一下,任何 UI 元素都可以成为转换服务的接受者,包括包含各种 UI 元素的布局系统。为了演示 Visual Studio 的转换编辑器的用法,创建一个名为 FunWithTransforms 的新 WPF 应用。

构建初始布局

首先,使用集成的网格编辑器将最初的Grid分成两列(具体大小无关紧要)。现在,在你的工具箱中找到StackPanel控件,并添加它以占据Grid第一列的整个空间;然后给StackPanel添加三个Button控件,像这样:

<Grid>
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="*"/>
    <ColumnDefinition Width="*"/>
  </Grid.ColumnDefinitions>
  <StackPanel Grid.Row="0" Grid.Column="0">
    <Button Name="btnSkew" Content="Skew" Click="Skew"/>
    <Button Name="btnRotate" Content="Rotate" Click="Rotate"/>
    <Button Name="btnFlip" Content="Flip" Click="Flip"/>
  </StackPanel>
</Grid>

将按钮的处理程序添加到代码页,如下所示:

private void Skew(object sender, RoutedEventArgs e)
{
}
private void Rotate(object sender, RoutedEventArgs e)
{
}
private void Flip(object sender, RoutedEventArgs e)
{
}

为了完成 UI,创建一个您选择的图形(使用本章讨论的任何技术),定义在Grid的第二列。示例中使用的标记如下所示:

<Canvas x:Name="myCanvas" Grid.Column="1" Grid.Row="0">
  <Ellipse HorizontalAlignment="Left" VerticalAlignment="Top"
       Height="186"  Width="92" Stroke="Black"
       Canvas.Left="20" Canvas.Top="31">
    <Ellipse.Fill>
      <RadialGradientBrush>
        <GradientStop Color="#FF951ED8" Offset="0.215"/>
        <GradientStop Color="#FF2FECB0" Offset="1"/>
      </RadialGradientBrush>
    </Ellipse.Fill>
  </Ellipse>
  <Ellipse HorizontalAlignment="Left" VerticalAlignment="Top"
       Height="101" Width="110" Stroke="Black"
       Canvas.Left="122" Canvas.Top="126">
    <Ellipse.Fill>
      <LinearGradientBrush EndPoint="0.5,1" StartPoint="0.5,0">
        <GradientStop Color="#FFB91DDC" Offset="0.355"/>
        <GradientStop Color="#FFB0381D" Offset="1"/>
      </LinearGradientBrush>
    </Ellipse.Fill>
  </Ellipse>
</Canvas>

图 26-10 显示了示例的最终布局。

img/340876_10_En_26_Fig10_HTML.jpg

图 26-10。

您的转换示例的布局

在设计时应用转换

如前所述,Visual Studio 提供了一个集成的转换编辑器,它可以在属性面板中找到。找到该区域,并确保展开转换部分以查看编辑器的 RenderTransform 和 LayoutTransform 部分(参见图 26-11 )。

img/340876_10_En_26_Fig11_HTML.jpg

图 26-11。

转换编辑器

与画笔部分类似,变换部分提供了许多选项卡来配置对当前所选项目的各种类型的图形变换。表 26-6 描述了每个转换选项,按照从左到右评估每个选项卡的顺序列出。

表 26-6

混合变换选项

|

转换选项

|

生命的意义

翻译 允许您在 X,Y 位置上偏移项目的位置。
辐状的 允许您将项目旋转 360 度。
规模 允许您在 X 和 Y 方向上放大或缩小项目。
斜交 允许您将包含选定项目的边界框在 X 和 Y 方向上倾斜一个因子。
中心点 当您旋转或翻转对象时,该项目相对于一个固定点移动,该点称为对象的中心点。默认情况下,对象的中心点位于对象的中心;但是,这种变换允许您更改对象的中心点,以围绕不同的点旋转或翻转对象。
翻转 基于 X 或 Y 中心点翻转选定项目。

我建议您使用您的自定义形状作为目标来测试这些转换中的每一个(只需按 Ctrl+Z 来撤消前面的操作)。像 Transform Properties 面板的许多其他方面一样,每个转换部分都有一组独特的配置选项,当您修改时,这些选项应该变得很容易理解。例如,倾斜变换编辑器允许您设置 X 和 Y 倾斜值,翻转变换编辑器允许您在 X 轴或 Y 轴上翻转,等等。

用代码转换画布

每个Click事件处理程序的实现或多或少是相同的。您将配置一个转换对象,并将其分配给myCanvas对象。然后,当您运行应用时,您可以单击一个按钮来查看应用转换的结果。以下是每个事件处理程序的完整代码(注意,您正在设置LayoutTransform属性,因此形状数据保持相对于父容器的位置):

private void Flip(object sender, System.Windows.RoutedEventArgs e)
{
  myCanvas.LayoutTransform = new ScaleTransform(-1, 1);
}

private void Rotate(object sender, System.Windows.RoutedEventArgs e)
{
  myCanvas.LayoutTransform = new RotateTransform(180);
}

private void Skew(object sender, System.Windows.RoutedEventArgs e)
{
  myCanvas.LayoutTransform = new SkewTransform(40, -20);
}

使用绘图和几何图形呈现图形数据

虽然Shape类型允许你生成任何类型的交互式二维表面,但是由于它们丰富的继承链,它们需要相当多的内存开销。虽然Path类可以使用包含的几何图形(而不是其他形状的大量集合)来帮助消除一些开销,但 WPF 提供了一个复杂的绘图和几何图形编程接口,可以呈现更轻量级的 2D 矢量图像。

这个 API 的入口点是抽象的System.Windows.Media.Drawing类(在PresentationCore.dll中),它本身只不过是定义一个边界矩形来保存渲染。鉴于UIElementFrameworkElement都不在继承链中,Drawing类明显比Shape更轻量级。

WPF 提供了各种扩展Drawing的类,每个类都代表了一种绘制内容的特定方式,如表 26-7 中所述。

表 26-7

WPFDrawing-衍生类型

|

类型

|

生命的意义

DrawingGroup 用于将一组独立的Drawing派生对象组合成一个单一的合成渲染。
GeometryDrawing 用于以非常轻量级的方式渲染 2D 图形。
GlyphRunDrawing 用于使用 WPF 图形呈现服务呈现文本数据。
ImageDrawing 用于将图像文件或几何体集渲染到边框中。
VideoDrawing 用于播放音频文件或视频文件。只有使用过程代码才能充分利用这种类型。如果你想通过 XAML 播放视频,MediaPlayer型是更好的选择。

因为它们更轻量级,Drawing派生的类型不具有处理输入事件的内在支持,因为它们不是UIElementFrameworkElement(尽管有可能以编程方式执行点击测试逻辑)。

Drawing派生的类型和从Shape派生的类型之间的另一个关键区别是,从Drawing派生的类型没有能力呈现它们自己,因为它们不是从UIElement派生的!相反,派生类型必须放在宿主对象中(特别是,DrawingImageDrawingBrushDrawingVisual)才能显示它们的内容。

DrawingImage允许你在 WPF Image控件中放置图形和几何图形,该控件通常用于显示来自外部文件的数据。DrawingBrush允许您基于绘图及其几何图形构建画笔,以设置需要画笔的属性。最后,DrawingVisual只用于图形渲染的“视觉”层,完全通过 C# 代码驱动。

虽然使用绘图比使用简单形状要复杂一些,但是这种图形合成与图形呈现的分离使得Drawing派生类型比Shape派生类型更加轻量级,同时仍然保留了关键服务。

使用几何图形构建画笔

在本章的前面,您用一组几何图形填充了一个Path,就像这样:

<Path Fill = "Orange" Stroke = "Blue" StrokeThickness = "3">
  <Path.Data>
    <GeometryGroup>
      <EllipseGeometry Center = "75,70" RadiusX = "30" RadiusY = "30" />
    <RectangleGeometry Rect = "25,55 100 30" />
    <LineGeometry StartPoint="0,0" EndPoint="70,30" />
    <LineGeometry StartPoint="70,30" EndPoint="0,30" />
  </GeometryGroup>
  </Path.Data>
</Path>

通过这样做,你从Path获得了交互性,但是考虑到你的几何形状,仍然是相当轻量级的。但是,如果您想要呈现相同的输出,并且不需要任何(现成的)交互性,您可以将相同的<GeometryGroup>放在DrawingBrush中,如下所示:

<DrawingBrush>
  <DrawingBrush.Drawing>
    <GeometryDrawing>
      <GeometryDrawing.Geometry>
        <GeometryGroup>
            <EllipseGeometry Center = "75,70" RadiusX = "30" RadiusY = "30" />
            <RectangleGeometry Rect = "25,55 100 30" />
            <LineGeometry StartPoint="0,0" EndPoint="70,30" />
            <LineGeometry StartPoint="70,30" EndPoint="0,30" />
          </GeometryGroup>
        </GeometryDrawing.Geometry>
        <!-- A custom pen to draw the borders. -->
        <GeometryDrawing.Pen>
           <Pen Brush="Blue" Thickness="3"/>
        </GeometryDrawing.Pen>
        <!-- A custom brush to fill the interior. -->
        <GeometryDrawing.Brush>
          <SolidColorBrush Color="Orange"/>
        </GeometryDrawing.Brush>
      </GeometryDrawing>
    </DrawingBrush.Drawing>
</DrawingBrush>

当您将一组几何图形放入DrawingBrush时,您还需要建立用于绘制边界的Pen对象,因为您不再从Shape基类继承Stroke属性。在这里,您创建了一个<Pen>,其设置与上一个Path示例中的StrokeStrokeThickness值相同。

此外,由于您不再从Shape继承一个Fill属性,您还需要使用属性元素语法来定义一个用于<DrawingGeometry>的笔刷对象,这里是一个纯色的橙色笔刷,就像前面的Path设置一样。

用画笔画画

现在您有了一个DrawingBrush,您可以用它来设置任何需要 brush 对象的属性的值。例如,如果您在 Kaxaml 中创作这个标记,您可以使用属性元素语法在一个Page的整个表面上绘制您的图形,如下所示:

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Page.Background>
    <DrawingBrush>
    <!-- Same DrawingBrush as seen above. -->
    </DrawingBrush>
  </Page.Background>
</Page>

或者你可以使用这个<DrawingBrush>来设置一个不同的画笔兼容属性,比如一个ButtonBackground属性。

<Page
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <Button Height="100" Width="100">
  <Button.Background>
    <DrawingBrush>
    <!-- Same DrawingBrush as seen above. -->
    </DrawingBrush>
  </Button.Background>
  </Button>
</Page>

无论你用自定义的<DrawingBrush>设置哪个笔刷兼容的属性,底线是你渲染的 2D 矢量图像比用形状渲染的 2D 图像开销要少得多。

在绘图图像中包含绘图类型

DrawingImage类型允许你将你的绘图几何图形插入到 WPF <Image>控件中。请考虑以下几点:

<Image>
  <Image.Source>
    <DrawingImage>
      <DrawingImage.Drawing>
        <!--Same GeometryDrawing from above -->
      </DrawingImage.Drawing>
    </DrawingImage>
  </Image.Source>
</Image>

在这种情况下,你的<GeometryDrawing>被放入了一个<DrawingImage>,而不是一个<DrawingBrush>。使用这个<DrawingImage>,可以设置Image控件的Source属性。

使用矢量图像

您可能同意,对于图形艺术家来说,使用 Visual Studio 提供的工具和技术来创建复杂的基于矢量的图像是一件非常具有挑战性的事情。图形艺术家有自己的一套工具,可以制作出令人惊叹的矢量图形。无论是 Visual Studio 还是其配套的 Expression Blend for Visual Studio 都不具备这种设计能力。在将矢量图像导入 WPF 应用之前,必须将其转换成Path表达式。此时,您可以使用 Visual Studio 针对生成的对象模型进行编程。

Note

您可以在下载文件的Chapter 26文件夹中找到正在使用的图像(LaserSign.svg)以及导出的路径(LaserSign.xaml)数据。图片最初来自维基百科,位于 https://en.wikipedia.org/wiki/Hazard_symbol

将样本矢量图形文件转换为 XAML

在将复杂的图形数据(如矢量图形)导入 WPF 应用之前,您需要将图形转换为路径数据。作为如何做到这一点的一个例子,从一个样本.svg图像文件开始,例如前面注释中提到的激光标记。然后下载并安装一个名为 Inkscape 的开源工具(位于 www.inkscape.org )。使用 Inkscape,从下载一章中打开LaserSign.svg文件。可能会提示您升级格式。如图 26-12 所示填写选项。

img/340876_10_En_26_Fig12_HTML.jpg

图 26-12。

在 Inkscape 中将 SVG 文件升级到最新格式

接下来的步骤一开始看起来会有点奇怪,但是一旦你克服了这种奇怪,这是一个将矢量图像转换成正确的 XAML 的简单方法。当您得到想要的图像时,选择文件➤打印菜单选项。接下来,选择 Microsoft XPS Document Writer 作为打印机目标,然后单击打印。在下一个屏幕上,输入一个文件名并选择保存文件的位置;然后单击保存。现在你有了一个完整的*.xps(或*.oxps)文件。

Note

根据系统配置中的变量数量,生成的文件会有.xps.oxps扩展名。无论哪种方式,过程都是一样的。

*.xps*.oxps格式实际上是.zip文件。将文件的扩展名重命名为.zip,就可以在文件资源管理器(或者 7-Zip,或者你喜欢的存档工具)中打开文件了。你会看到它包含了如图 26-13 所示的层级。

img/340876_10_En_26_Fig13_HTML.jpg

图 26-13。

打印的 XPS 文件的文件夹层次结构

您需要的文件在Pages目录(Documents/1/Pages)中,并被命名为1.fpage。用文本编辑器打开文件,复制除了<FixedPage>开始和结束标签之外的所有内容。然后可以将路径数据复制到 Kaxaml 中,并放在主WindowCanvas中。你的图像将显示在 XAML 窗口。

Note

最新版本的 Inkscape 可以选择将文件保存为微软 XAML。不幸的是,在撰写本文时,它与 WPF 不兼容。

将图形数据导入 WPF 项目

此时,创建一个名为 InteractiveLaserSign 的新 WPF 应用。将Window调整为 600 的Height和 650 的Width,并将Grid替换为Canvas

<Window x:Class="InteractiveLaserSign.MainWindow"
        xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"

        xmlns:local="clr-namespace:InteractiveLaserSign"
        mc:Ignorable="d"
        Title="MainWindow" Height="600" Width="650">
    <Canvas>
    </Canvas>
</Window>

1.fpage文件中复制整个 XAML(不包括外部的FixedPage标签)并粘贴到Canvas控件中。在设计模式下查看Window,您将看到在您的应用中复制的标志。

如果您查看文档轮廓,您会看到图像的每个部分都表示为一个 XAML Path元素。如果您调整Window的大小,无论窗口有多大,图像质量都保持不变。这是因为由Path元素表示的图像是使用绘图引擎和数学来呈现的,而不是翻转像素。

与标志互动

回想一下路由的事件隧道和气泡,因此在Canvas中单击的任何Path都可以由画布上的 click 事件处理程序来处理。将Canvas标记更新如下:

<Canvas MouseLeftButtonDown="Canvas_MouseLeftButtonDown">

使用以下代码添加事件处理程序:

private void Canvas_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
{
  if (e.OriginalSource is Path p)
  {
    p.Fill = new SolidColorBrush(Colors.Red);
  }
}

现在,运行您的应用。单击线条查看效果。

您现在了解了为复杂图形生成Path数据的过程,以及如何在代码中与图形数据交互。您可能会同意,专业图形艺术家生成复杂图形数据并将数据导出为 XAML 的能力非常强大。一旦生成了图形数据,开发人员就可以导入标记并针对对象模型进行编程。

使用可视层呈现图形数据

用 WPF 渲染图形数据的最后一个选项被称为视觉层。如前所述,您只能通过代码访问该层(它对 XAML 不友好)。虽然绝大多数 WPF 应用使用形状、绘图和几何图形都能正常工作,但可视化图层确实提供了渲染大量图形数据的最快方法。当您需要在大面积上渲染单个图像时,这个低级图形层也很有用。例如,如果您需要用普通的静态图像填充窗口的背景,视觉图层是最快的方法。如果你需要根据用户输入或类似的东西在窗口背景之间快速切换,它也会很有用。

我不会花太多时间深入研究 WPF 编程这方面的细节,但是让我们构建一个小的示例程序来说明基本原理。

Visual 基类和派生的子类

抽象的System.Windows.Media.Visual类类型提供了一个最小的服务集(渲染、点击测试、转换)来渲染图形,但是它不支持额外的非可视服务,这会导致代码膨胀(输入事件、布局服务、样式和数据绑定)。Visual类是一个抽象基类。您需要使用一个派生类型来执行实际的呈现操作。WPF 提供了一些子类,包括DrawingVisualViewport3DVisualContainerVisual

在本例中,您将只关注DrawingVisual,这是一个轻量级绘图类,用于呈现形状、图像或文本。

使用 DrawingVisual 类初探

要使用DrawingVisual将数据渲染到表面上,您需要采取以下基本步骤:

  1. DrawingVisual类中获取一个DrawingContext对象。

  2. 使用DrawingContext渲染图形数据。

这两个步骤代表了将一些数据渲染到表面所需的最少步骤。但是,如果您希望呈现的图形数据能够响应点击测试计算(这对增加用户交互性很重要),您还需要执行以下附加步骤:

  1. 更新正在渲染的容器所维护的逻辑树和可视化树。

  2. 覆盖来自FrameworkElement类的两个虚拟方法,允许容器获得您创建的可视数据。

稍后您将检查这最后两个步骤。首先,为了说明如何使用DrawingVisual类来呈现 2D 数据,创建一个名为 RenderingWithVisuals 的新 WPF 应用。你的第一个目标是使用一个DrawingVisual动态地分配数据给一个 WPF Image控件。首先更新窗口的 XAML 来处理Loaded事件,如下所示:

<Window x:Class="RenderingWithVisuals.MainWindow"
      <!--omitted for brevity -->
      Title="Fun With Visual Layer" Height="450" Width="800"
      Loaded="MainWindow_Loaded">

接下来,用一个StackPanel替换Grid,并在StackPanel中添加一个Image,就像这样:

<StackPanel Background="AliceBlue" Name="myStackPanel">
  <Image Name="myImage" Height="80"/>
</StackPanel>

您的<Image>控件还没有Source值,因为这将在运行时发生。Loaded事件将使用一个DrawingBrush对象完成构建内存中图形数据的工作。确保以下名称空间位于MainWindow.cs的顶部:

using System;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;

下面是Loaded事件处理程序的实现:

private void MainWindow_Loaded(
  object sender, RoutedEventArgs e)
{
  const int TextFontSize = 30;
  // Make a System.Windows.Media.FormattedText object.
  FormattedText text = new FormattedText(
    "Hello Visual Layer!",
    new System.Globalization.CultureInfo("en-us"),
    FlowDirection.LeftToRight,
    new Typeface(this.FontFamily, FontStyles.Italic,
      FontWeights.DemiBold, FontStretches.UltraExpanded),
    TextFontSize,
    Brushes.Green,
    null,
    VisualTreeHelper.GetDpi(this).PixelsPerDip);
  // Create a DrawingVisual, and obtain the DrawingContext.
  DrawingVisual drawingVisual = new DrawingVisual();
  using(DrawingContext drawingContext =
    drawingVisual.RenderOpen())
  {
    // Now, call any of the methods of DrawingContext to render data.
    drawingContext.DrawRoundedRectangle(
      Brushes.Yellow, new Pen(Brushes.Black, 5),
      new Rect(5, 5, 450, 100), 20, 20);
    drawingContext.DrawText(text, new Point(20, 20));
  }
  // Dynamically make a bitmap, using the data in the DrawingVisual.
  RenderTargetBitmap bmp = new RenderTargetBitmap(
    500, 100, 100, 90, PixelFormats.Pbgra32);
  bmp.Render(drawingVisual);
  // Set the source of the Image control!
  myImage.Source = bmp;
}

这段代码引入了许多新的 WPF 类,我将在这里对它们进行简单的评论。该方法首先创建一个新的FormattedText对象,表示您正在构建的内存图像的文本部分。如您所见,构造函数允许您指定许多属性,如字体大小、字体系列、前景色和文本本身。

接下来,您通过在DrawingVisual实例上调用RenderOpen()来获得必要的DrawingContext对象。这里,您将一个彩色的圆角矩形呈现到DrawingVisual中,后面是您的格式化文本。在这两种情况下,您都是使用硬编码的值将图形数据放入DrawingVisual中,这对于生产来说不一定是个好主意,但是对于这个简单的测试来说却很好。

最后几个语句将DrawingVisual映射到一个RenderTargetBitmap对象,该对象是System.Windows.Media.Imaging名称空间的成员。这个类将接受一个可视对象,并将其转换成内存中的位图图像。至此,您设置了Image控件的Source属性,果然,您将看到图 26-14 中的输出。

img/340876_10_En_26_Fig14_HTML.jpg

图 26-14。

使用可视层呈现内存中的位图

Note

System.Windows.Media.Imaging名称空间包含许多额外的编码类,允许您以各种格式将内存中的RenderTargetBitmap对象保存到物理文件中。查看JpegBitmapEncoder类(和朋友)了解更多信息。

向自定义布局管理器呈现可视数据

虽然使用DrawingVisual在 WPF 控件的背景上绘图很有趣,但构建一个自定义布局管理器(GridStackPanelCanvas等)可能更常见。)在内部使用可视层来呈现其内容。在你创建了这样一个定制的布局管理器之后,你可以把它插入到一个普通的Window(或者Page或者UserControl)中,并且拥有一个使用高度优化的渲染代理的 UI 的一部分,而主机Window的非关键方面使用图形和绘图来处理剩余的图形数据。

如果您不需要专用布局管理器提供的额外功能,您可以选择简单地扩展FrameworkElement,它有必要的基础设施来包含可视项目。为了说明如何做到这一点,在您的项目中插入一个名为CustomVisualFrameworkElement的新类。从FrameworkElement扩展这个类并导入 System、System.WindowsSystem.Windows.InputSystem.Windows.MediaSystem.Windows.Media.Imaging名称空间。

这个类将维护一个类型为VisualCollection的成员变量,它包含两个固定的DrawingVisual对象(当然,您可以通过鼠标操作向这个集合添加新成员,但是这个示例将保持简单)。使用以下新功能更新您的类:

public class CustomVisualFrameworkElement : FrameworkElement
{
  // A collection of all the visuals we are building.
  VisualCollection theVisuals;
  public CustomVisualFrameworkElement()
  {
    // Fill the VisualCollection with a few DrawingVisual objects.
    // The ctor arg represents the owner of the visuals.
    theVisuals = new VisualCollection(this)
      {AddRect(),AddCircle()};
  }
  private Visual AddCircle()
  {
    DrawingVisual drawingVisual = new DrawingVisual();
    // Retrieve the DrawingContext in order to create new drawing content.
    using DrawingContext drawingContext =
      drawingVisual.RenderOpen()
    // Create a circle and draw it in the DrawingContext.
    drawingContext.DrawEllipse(Brushes.DarkBlue, null,
      new Point(70, 90), 40, 50);
    return drawingVisual;
  }
  private Visual AddRect()
  {
    DrawingVisual drawingVisual = new DrawingVisual();
    using DrawingContext drawingContext =
      drawingVisual.RenderOpen()
    Rect rect =
      new Rect(new Point(160, 100), new Size(320, 80));
    drawingContext.DrawRectangle(Brushes.Tomato, null, rect);
    return drawingVisual;
  }
}

现在,在您可以在您的Window中使用这个自定义的FrameworkElement之前,您必须覆盖前面提到的两个关键的虚拟方法,这两个方法都是在渲染过程中由 WPF 在内部调用的。GetVisualChild()方法从子元素集合中返回指定索引处的子元素。只读VisualChildrenCount属性返回该可视集合中可视子元素的数量。这两种方法都很容易实现,因为您可以将真正的工作委托给VisualCollection成员变量。

protected override int VisualChildrenCount
  => theVisuals.Count;

protected override Visual GetVisualChild(int index)
{
  // Value must be greater than zero, so do a sanity check.
  if (index < 0 || index >= theVisuals.Count)
  {
     throw new ArgumentOutOfRangeException();
  }
  return theVisuals[index];
}

现在,您已经有了足够的功能来测试您的定制类。更新Window的 XAML 描述,将一个CustomVisualFrameworkElement对象添加到现有的StackPanel中。这样做将要求您添加一个自定义 XML 命名空间,该命名空间映射到您的。NET 核心命名空间。

<Window x:Class="RenderingWithVisuals.MainWindow"
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  xmlns:local="clr-namespace:RenderingWithVisuals"
  Title="Fun with the Visual Layer" Height="350" Width="525"
  Loaded="Window_Loaded" WindowStartupLocation="CenterScreen">
    <StackPanel Background="AliceBlue" Name="myStackPanel">
          <Image Name="myImage" Height="80"/>
          <local:CustomVisualFrameworkElement/>
    </StackPanel>
</Window>

当你运行程序时,你会看到如图 26-15 所示的结果。

img/340876_10_En_26_Fig15_HTML.jpg

图 26-15。

使用可视层将数据渲染到自定义的FrameworkElement

响应点击测试操作

因为DrawingVisual没有UIElementFrameworkElement的任何基础设施,您将需要以编程方式添加计算点击测试操作的能力。幸运的是,这在视觉层很容易做到,因为有了逻辑视觉树的概念。事实证明,当你创作一个 XAML 的 blob 时,你实际上是在构建一个元素的逻辑树。然而,在每一个逻辑树的背后都有一个更丰富的描述,称为视觉树,它包含更低级的渲染指令。

第二十七章将会更详细地探究这些树,但是现在,你要明白,直到你用这些数据结构注册了你的自定义视图,你才能够执行点击测试操作。幸运的是,VisualCollection容器为您做了这件事(这解释了为什么您需要传入对自定义FrameworkElement的引用作为构造函数参数)。

首先,使用标准 C# 语法更新CustomVisualFrameworkElement类以处理类构造函数中的MouseDown事件,如下所示:

this.MouseDown += CustomVisualFrameworkElement_MouseDown;

这个处理程序的实现将调用VisualTreeHelper.HitTest()方法来查看鼠标是否在一个渲染的视觉对象的边界内。要做到这一点,您需要指定一个执行计算的HitTestResultCallback委托作为HitTest()的参数。如果单击某个视觉对象,将在该视觉对象的倾斜呈现和原始呈现之间切换。将以下方法添加到您的CustomVisualFrameworkElement类中:

void CustomVisualFrameworkElement_MouseDown(object sender, MouseButtonEventArgs e)
{
  // Figure out where the user clicked.
  Point pt = e.GetPosition((UIElement)sender);
  // Call helper function via delegate to see if we clicked on a visual.
  VisualTreeHelper.HitTest(this, null,
  new HitTestResultCallback(myCallback), new PointHitTestParameters(pt));
}

public HitTestResultBehavior myCallback(HitTestResult result)
{
    // Toggle between a skewed rendering and normal rendering,
    // if a visual was clicked.
    if (result.VisualHit.GetType() == typeof(DrawingVisual))
    {
      if (((DrawingVisual)result.VisualHit).Transform == null)
      {
         ((DrawingVisual)result.VisualHit).Transform = new SkewTransform(7, 7);
      }
      else
      {
         ((DrawingVisual)result.VisualHit).Transform = null;
    }
  }
  // Tell HitTest() to stop drilling into the visual tree.
  return HitTestResultBehavior.Stop;
}

现在,再次运行你的程序。现在,您应该能够单击任一渲染视图,并看到正在进行的转换!虽然这只是使用 WPF 视觉图层的一个简单示例,但请记住,您使用的笔刷、变换、钢笔和布局管理器与使用 XAML 时相同。因此,您已经对使用这个Visual派生类有了相当多的了解。

这就结束了您对 Windows Presentation Foundation 的图形呈现服务的研究。虽然您了解了许多有趣的主题,但实际情况是,您只是触及了 WPF 图形功能的皮毛。我将把它留给你,让你更深入地挖掘形状、绘画、画笔、变换和视觉效果的主题(当然,你会在 WPF 剩余的章节中看到这些主题的更多细节)。

摘要

因为 Windows Presentation Foundation 是一个图形密集型 GUI API,所以我们有多种方法来呈现图形输出也就不足为奇了。本章首先研究了 WPF 应用可以做到的三种方式(形状、绘图和视觉),并讨论了各种呈现原语,如画笔、钢笔和变换。

请记住,当你需要建立交互式 2D 渲染,形状使过程非常简单。但是,静态、非交互式渲染可以通过使用绘图和几何图形以更优化的方式进行渲染,而可视化层(仅在代码中可访问)为您提供最大的控制和性能。

二十七、WPF 资源、动画、样式和模板

本章向您介绍了三个重要的(且相互关联的)主题,它们将加深您对 WPF(WPF) API 的理解。首要任务是学习逻辑资源的作用。正如您将看到的,逻辑资源(也称为对象资源)系统是一种命名和引用 WPF 应用中常用对象的方式。虽然逻辑资源通常是在 XAML 中编写的,但是它们也可以在过程代码中定义。

接下来,您将学习如何定义、执行和控制动画序列。不管你怎么想,WPF 动画并不局限于视频游戏或多媒体应用。在 WPF API 下,动画可以非常微妙,比如让一个按钮在获得焦点时发光,或者扩展DataGrid中选定行的大小。理解动画是构建自定义控件模板的一个关键方面(你将在本章后面看到)。

然后,您将探索 WPF 风格和模板的作用。就像使用 CSS 或 ASP.NET 主题引擎的网页一样,WPF 应用可以为一组控件定义一个共同的外观。您可以在标记中定义这些样式,并将它们存储为对象资源供以后使用,还可以在运行时动态应用它们。最后一个例子将教你如何构建自定义控件模板。

了解 WPF 资源系统

您的第一个任务是研究嵌入和访问应用资源的主题。WPF 支持两种类型的资源。第一个是二进制资源,这一类别通常包括大多数程序员认为是传统意义上的资源的项目(嵌入的图像文件或声音剪辑、应用使用的图标等)。).

第二种风格称为对象资源逻辑资源,代表一个命名的。NET 对象,可以打包并在整个应用中重用。而任何。NET 对象可以打包成对象资源,逻辑资源在处理任何种类的图形数据时特别有用,因为您可以定义常用的图形元素(画笔、钢笔、动画等)。)并在需要时参考它们。

使用二进制资源

在进入对象资源的主题之前,让我们快速检查一下如何将二进制资源打包到您的应用中,例如图标或图像文件(例如,公司徽标或动画图像)。如果您想继续,创建一个名为BinaryResourcesApp的新 WPF 应用。更新初始窗口的标记,以处理Window Loaded事件并使用DockPanel作为布局根,如下所示:

<Window x:Class="BinaryResourcesApp.MainWindow"
  <!-- Omitted for brevity -->
    Title="Fun with Binary Resources" Height="500" Width="649" Loaded="MainWindow_OnLoaded">
  <DockPanel LastChildFill="True">
  </DockPanel>
</Window>

现在,假设您的应用需要根据用户输入在窗口的一部分显示三个图像文件中的一个。WPF Image控件不仅可用于显示典型的图像文件(*.bmp*.gif*.ico*.jpg*.png*.wdp*.tiff),还可用于显示DrawingImage中的数据(如您在第二十六章中所见)。您可以为您的窗口构建一个支持DockPanel的 UI,该 UI 包含一个带有下一个和上一个按钮的简单工具栏。在这个工具栏下面,您可以放置一个Image控件,该控件目前没有设置为Source属性的值,如下所示:

  <DockPanel LastChildFill="True">
    <ToolBar Height="60" Name="picturePickerToolbar" DockPanel.Dock="Top">
      <Button x:Name="btnPreviousImage" Height="40" Width="100" BorderBrush="Black"
              Margin="5" Content="Previous" Click="btnPreviousImage_Click"/>
      <Button x:Name="btnNextImage" Height="40" Width="100" BorderBrush="Black"
              Margin="5" Content="Next" Click="btnNextImage_Click"/>
    </ToolBar>
    <!-- We will fill this Image in code. -->
    <Border BorderThickness="2" BorderBrush="Green">
      <Image x:Name="imageHolder" Stretch="Fill" />
    </Border>
  </DockPanel>

接下来,添加以下空事件处理程序:

private void MainWindow_OnLoaded(
  object sender, RoutedEventArgs e)
{
}
private void btnPreviousImage_Click(
  object sender, RoutedEventArgs e)
{
}
private void btnNextImage_Click(
  object sender, RoutedEventArgs e)
{
}

当窗口加载时,图像将被添加到一个集合中,下一个和上一个按钮将在其中循环。现在,应用框架已经就绪,让我们检查实现它的不同选项。

在项目中包含松散的资源文件

一种选择是将您的图像文件作为一组松散的文件放在应用安装路径的子目录中。首先向您的项目添加一个新文件夹(名为Images)。右键单击并选择“添加➤现有项目”,向该文件夹添加一些图像。确保将添加现有项目对话框中的文件过滤器更改为*.*,以便显示图像文件。您可以添加自己的图像文件,或者使用可下载代码中的三个名为Deer.jpgDogs.jpgWelcome.jpg的图像文件。

配置松散资源

要在项目构建时将\Images文件夹中的内容复制到\bin\Debug文件夹中,首先在解决方案资源管理器中选择所有图像。现在,在这些图像仍处于选中状态的情况下,右键单击并选择 Properties 以打开 Properties 窗口。将Build Action属性设置为Content,将Copy to Output Directory属性设置为Copy always(见图 27-1 )。

img/340876_10_En_27_Fig1_HTML.jpg

图 27-1。

配置要复制到输出目录的图像数据

Note

您还可以选择Copy if Newer,如果您正在构建包含大量内容的大型项目,这将节省您的时间。对于这个例子,Copy always起作用。

如果您构建了您的项目,现在您可以单击解决方案资源管理器的 Show All Files 按钮,并查看您的\bin\Debug目录下复制的Image文件夹(您可能需要单击 Refresh 按钮)。

以编程方式加载图像

WPF 提供了一个名为BitmapImage的类,它是System.Windows.Media.Imaging名称空间的一部分。这个类允许你从一个图像文件中加载数据,这个图像文件的位置由一个System.Uri对象表示。添加一个List<BitmapImage>来保存图像,以及一个int来存储当前显示图像的索引。

// A List of BitmapImage files.
List<BitmapImage> _images=new List<BitmapImage>();
// Current position in the list.
private int _currImage=0;

在窗口的Loaded事件中,填充图像列表,然后将Image控制源设置为列表中的第一幅图像。

private void MainWindow_OnLoaded(
  object sender, RoutedEventArgs e)
{
  try
  {
    string path=Environment.CurrentDirectory;
    // Load these images from disk when the window loads.
    _images.Add(new BitmapImage(new Uri($@"{path}\Images\Deer.jpg")));
    _images.Add(new BitmapImage(new Uri($@"{path}\Images\Dogs.jpg")));
    _images.Add(new BitmapImage(new Uri($@"{path}\Images\Welcome.jpg")));
    // Show first image in the List.
    imageHolder.Source=_images[_currImage];
  }
  catch (Exception ex)
  {
    MessageBox.Show(ex.Message);
  }
}

接下来,实现 previous 和 Next 处理程序来遍历图像。如果用户到达列表的末尾,让他们从头开始,反之亦然。

private void btnPreviousImage_Click(
  object sender, RoutedEventArgs e)
{
  if (--_currImage < 0)
  {
    _currImage=_images.Count - 1;
  }
  imageHolder.Source=_images[_currImage];
}
private void btnNextImage_Click(
  object sender, RoutedEventArgs e)
{
  if (++_currImage >=_images.Count)
  {
    _currImage=0;
  }
  imageHolder.Source=_images[_currImage];
}

此时,你可以运行你的程序,浏览每张图片。

嵌入应用资源

如果您希望将图像文件配置为直接编译到。NET 核心程序集作为二进制资源,在解决方案资源管理器中选择图像文件(在\Images文件夹中,而不是在\bin\Debug\Images文件夹中)。将Build Action属性更改为Resource,将Copy to Output Directory属性设置为Do not copy(见图 27-2 )。

img/340876_10_En_27_Fig2_HTML.jpg

图 27-2。

将图像配置为嵌入式资源

现在,使用 Visual Studio 的 Build 菜单,选择 Clean Solution 选项清除当前的\bin\Debug\Images内容,然后重新构建您的项目。刷新解决方案资源管理器,观察您的\bin\Debug\Images目录中是否缺少数据。使用当前的构建选项,您的图形数据不再被复制到输出文件夹中,而是嵌入到程序集本身中。这确保了资源的存在,但也增加了编译后程序集的大小。

您需要修改代码,通过从编译后的程序集中提取这些图像来将它们加载到列表中。

// Extract from the assembly and then load images
_images.Add(new BitmapImage(new Uri(img/Deer.jpg", UriKind.Relative)));
_images.Add(new BitmapImage(new Uri(img/Dogs.jpg", UriKind.Relative)));
_images.Add(new BitmapImage(new Uri(img/Welcome.jpg", UriKind.Relative)));

在这种情况下,您不再需要确定安装路径,可以简单地按名称列出资源,这考虑了原始子目录的名称。还要注意,当你创建你的Uri对象时,你指定了一个RelativeUriKind值。此时,您的可执行文件是一个独立的实体,可以从机器上的任何位置运行,因为所有编译的数据都在二进制文件中。

使用对象(逻辑)资源

在构建 WPF 应用时,通常会定义一个 XAML 的简介,在一个窗口中的多个位置使用,或者跨多个窗口或项目使用。例如,假设你已经创建了完美的线性渐变画笔,它由十行标记组成。现在,您想要使用该笔刷作为项目中每个Button控件的背景色(项目由 8 个窗口组成),总共有 16 个Button控件。

最糟糕的事情是将 XAML 复制并粘贴到每个控件中。很明显,这将是一场维护的噩梦,因为你需要在任何时候对画笔的外观和感觉进行大量的修改。

谢天谢地,对象资源允许你定义一个 XAML 的 blob,给它一个名字,并把它存储在一个 fitting 字典中以备后用。像二进制资源一样,对象资源通常被编译到需要它们的程序集中。但是,您不需要修改Build Action属性就可以做到这一点。如果你把你的 XAML 放到正确的位置,编译器会自动完成剩下的工作。

使用对象资源是 WPF 开发的一大部分。正如你将看到的,对象资源可能比自定义画笔复杂得多。您可以定义基于 XAML 的动画、3D 呈现、自定义控件样式、数据模板、控件模板等,并将每个模板打包为可重用的资源。

资源属性的作用

如前所述,对象资源必须放在 fitting dictionary 对象中,以便在整个应用中使用。目前,FrameworkElement的每个后代都支持一个Resources属性。该属性封装了一个包含已定义对象资源的ResourceDictionary对象。ResourceDictionary可以保存任何类型的项目,因为它在System.Object类型上操作,并且可以通过 XAML 或程序代码进行操作。

在 WPF,所有的控件、WindowPage(构建导航应用时使用)和UserControl都扩展了FrameworkElement,所以几乎所有的小部件都提供了对ResourceDictionary的访问。此外,Application类虽然没有扩展FrameworkElement,但出于同样的目的,它支持一个同名的Resources属性。

定义窗口范围的资源

要开始探索对象资源的角色,创建一个名为 ObjectResourcesApp 的新 WPF 应用,并将最初的Grid更改为水平对齐的StackPanel布局管理器。在这个StackPanel中,像这样定义两个Button控件(你真的不需要太多来说明对象资源的作用,这样就行了):

<StackPanel Orientation="Horizontal">
  <Button Margin="25" Height="200" Width="200" Content="OK" FontSize="20"/>
  <Button Margin="25" Height="200" Width="200" Content="Cancel" FontSize="20"/>
</StackPanel>

现在,选择 OK 按钮,使用集成笔刷编辑器将Background颜色属性设置为自定义笔刷类型(在第二十六章中讨论)。完成后,注意画笔是如何嵌入在<Button></Button>标签的范围内的,如下所示:

<Button Margin="25" Height="200" Width="200" Content="OK" FontSize="20">
  <Button.Background>
    <RadialGradientBrush>
      <GradientStop Color="#FFC44EC4" Offset="0" />
      <GradientStop Color="#FF829CEB" Offset="1" />
      <GradientStop Color="#FF793879" Offset="0.669" />
    </RadialGradientBrush>
  </Button.Background>
</Button>

为了让 Cancel 按钮也使用这个画笔,您应该将<RadialGradientBrush>的范围提升到父元素的资源字典。例如,如果你把它移动到<StackPanel>,两个按钮可以使用相同的笔刷,因为它们是布局管理器的子元素。更好的是,你可以将画笔打包到Window本身的资源字典中,这样窗口的内容就可以使用它。

当您需要定义一个资源时,您可以使用 property-element 语法来设置所有者的Resources属性。您还为资源项赋予了一个x:Key值,当窗口的其他部分想要引用对象资源时,将会使用这个值。要知道x:Keyx:Name是不一样的!x:Name属性允许您访问代码文件中作为成员变量的对象,而x:Key属性允许您引用资源字典中的一个项目。

Visual Studio 允许您使用资源各自的属性窗口将资源提升到更高的范围。要做到这一点,首先要确定包含要打包为资源的复杂对象的属性(在本例中是Background属性)。属性的右边是一个小方块,单击它将打开一个弹出菜单。从中选择转换为新资源选项(参见图 27-3 )。

img/340876_10_En_27_Fig3_HTML.jpg

图 27-3。

将复杂对象移动到资源容器中

要求您命名您的资源(myBrush)并指定放置它的位置。对于本例,保留当前文件的默认选择(见图 27-4 )。

img/340876_10_En_27_Fig4_HTML.jpg

图 27-4。

命名对象资源

当你完成后,你会看到画笔已经被移动到了Window.Resources标签内。

<Window.Resources>
  <RadialGradientBrush x:Key="myBrush">
    <GradientStop Color="#FFC44EC4" Offset="0" />
    <GradientStop Color="#FF829CEB" Offset="1" />
    <GradientStop Color="#FF793879" Offset="0.669" />
  </RadialGradientBrush>
</Window.Resources>

并且Button控件的Background已经被更新以使用新的资源。

<Button Margin="25" Height="200" Width="200" Content="OK"
        FontSize="20" Background="{DynamicResource myBrush}"/>

创建资源向导创建新资源作为DynamicResource。稍后你会在文中了解到DynamicResource s,但是现在,把它改成StaticResource,就像这样:

<Button Margin="25" Height="200" Width="200" Content="OK"
    FontSize="20" Background="{StaticResource myBrush}"/>

要看到好处,将取消ButtonBackground属性更新为同一个StaticResource,就可以看到重用在起作用。

<Button Margin="25" Height="200" Width="200" Content="Cancel"
    FontSize="20" Background="{StaticResource myBrush}"/>

{StaticResource}标记扩展只应用资源一次(初始化时),并在应用的生命周期内保持与原始对象的“连接”。一些属性(例如渐变停止)将会更新,但是如果您创建一个新的Brush,控件将不会更新。要看到这一点,给每个Button控件添加一个NameClick事件处理程序,如下所示:

<Button Name="Ok" Margin="25" Height="200" Width="200" Content="OK"
    FontSize="20" Background="{StaticResource myBrush}" Click="Ok_OnClick"/>
<Button Name="Cancel" Margin="25" Height="200" Width="200" Content="Cancel"
    FontSize="20" Background="{StaticResource myBrush}" Click="Cancel_OnClick"/>

接下来,将以下代码添加到Ok_OnClick()事件处理程序中:

private void Ok_OnClick(object sender, RoutedEventArgs e)
{
  // Get the brush and make a change.
  var b=(RadialGradientBrush)Resources["myBrush"];
  b.GradientStops[1]=new GradientStop(Colors.Black, 0.0);
}

Note

在这里,您使用Resources索引器通过名称来定位资源。但是,请注意,如果找不到资源,这将引发运行时异常。您也可以使用TryFindResource()方法,它不会抛出运行时错误;如果找不到指定的资源,它将简单地返回null

当您运行程序并单击 OK Button时,您会看到渐变发生了适当的变化。现在将以下代码添加到Cancel_OnClick()事件处理程序中:

private void Cancel_OnClick(object sender, RoutedEventArgs e)
{
  // Put a totally new brush into the myBrush slot.
  Resources["myBrush"]=new SolidColorBrush(Colors.Red);
}

再次运行程序,点击取消Button,什么都没发生!

属性也可以使用DynamicResource标记扩展。要查看差异,请将取消Button的标记更改为以下内容:

<Button Name="Cancel" Margin="25" Height="200" Width="200" Content="Cancel"
                FontSize="20" Background="{DynamicResource myBrush}" Click="Cancel_OnClick"/>

这一次,当你点击取消Button时,取消Button的背景会改变,但是确定Button的背景保持不变。这是因为{DynamicResource}标记扩展可以检测底层键控对象是否已经被新对象替换。正如您可能猜到的,这需要一些额外的运行时基础设施,所以您通常应该坚持使用{StaticResource},除非您知道您有一个对象资源将在运行时与另一个对象交换,并且您希望使用该资源的所有项目都得到通知。

应用级资源

当窗口的资源字典中有对象资源时,窗口中的所有项都可以自由使用它,但应用中的其他窗口不能。跨应用共享资源的解决方案是在应用级别定义对象资源,而不是在窗口级别。在 Visual Studio 中没有办法实现自动化,所以只需将当前的 brush 对象从<Windows.Resources>范围中剪切出来,并将其放在App.xaml文件的<Application.Resources>范围中。

现在,应用中的任何附加窗口或控件都可以自由地使用这个画笔。如果要为控件设置Background属性,可以选择应用级资源,如图 27-5 所示。

img/340876_10_En_27_Fig5_HTML.jpg

图 27-5。

应用应用级资源

Note

将资源置于应用级别并将其分配给控件的属性将会冻结资源,从而防止在运行时更改值。可以克隆资源,并且可以更新克隆。

定义合并的资源词典

应用级的资源通常是足够好的,但是它们无助于跨项目的重用。在这种情况下,您想要定义一个被称为合并资源字典的东西。把它想象成 WPF 资源的类库;它只不过是一个包含资源集合的XAML文件。单个项目可以根据需要拥有多个这样的文件(一个用于画笔,一个用于动画,等等)。),每一个都可以使用通过项目菜单激活的添加新项目对话框插入(见图 27-6 )。

img/340876_10_En_27_Fig6_HTML.jpg

图 27-6。

插入新的合并资源字典

在新的MyBrushes.xaml文件中,剪切Application.Resources范围中的当前资源,并将它们移动到您的字典中,如下所示:

<ResourceDictionary xmlns:=http://schemas.microsoft.com/winfx/2006/xaml/presentation
  xmlns:local="clr-namespace:ObjectResourcesApp"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
  <RadialGradientBrush x:Key="myBrush">
    <GradientStop Color="#FFC44EC4" Offset="0" />
    <GradientStop Color="#FF829CEB" Offset="1" />
    <GradientStop Color="#FF793879" Offset="0.669" />
  </RadialGradientBrush>
</ResourceDictionary>

即使此资源字典是项目的一部分,所有资源字典都必须合并(通常在应用级别)到现有的资源字典中才能使用。为此,在App.xaml文件中使用以下格式(注意,可以通过在<ResourceDictionary.MergedDictionaries>范围内添加多个<ResourceDictionary>元素来合并多个资源字典):

  <Application.Resources>
    <ResourceDictionary>
      <ResourceDictionary.MergedDictionaries>
        <ResourceDictionary Source="MyBrushes.xaml"/>
      </ResourceDictionary.MergedDictionaries>
    </ResourceDictionary>
  </Application.Resources>

这种方法的问题是,每个资源文件都必须添加到每个需要资源的项目中。共享资源的一个更好的方法是定义一个. NET 核心类库在项目之间共享,这是您接下来要做的。

定义仅资源程序集

生成纯资源程序集的最简单方法是从 WPF 用户控件库(。NET Core)项目。通过 Visual 的“添加➤新项目”菜单选项将这样一个项目(名为 MyBrushesLibrary)添加到当前解决方案中,并从 ObjectResourcesApp 项目中添加对它的项目引用。

现在,从项目中删除UserControl1.xaml文件。接下来,将MyBrushes.xaml文件拖放到您的MyBrushesLibrary项目中,并将其从ObjectResourcesApp项目中删除。最后,打开MyBrushesLibrary项目中的MyBrushes.xaml,将文件中的x:local名称空间改为clr-namespace:MyBrushesLibrary。您的MyBrushes.xaml文件应该如下所示:

<ResourceDictionary xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
                    xmlns:local="clr-namespace:MyBrushesLibrary">
    <RadialGradientBrush x:Key="myBrush">
        <GradientStop Color="#FFC44EC4" Offset="0" />
        <GradientStop Color="#FF829CEB" Offset="1" />
        <GradientStop Color="#FF793879" Offset="0.669" />
    </RadialGradientBrush>
</ResourceDictionary>

编译您的用户控件库项目。现在,将这些二进制资源合并到ObjectResourcesApp项目的应用级资源字典中。然而,这样做需要一些相当时髦的语法,如下所示:

<Application.Resources>
  <ResourceDictionary>
    <ResourceDictionary.MergedDictionaries>
      <!-- The syntax is /NameOfAssembly;Component/NameOfXamlFileInAssembly.xaml -->
      <ResourceDictionary Source="/MyBrushesLibrary;Component/MyBrushes.xaml"/>
    </ResourceDictionary.MergedDictionaries>
  </ResourceDictionary>
</Application.Resources>

首先,请注意这个字符串是区分空间的。如果分号或正斜杠两边有多余的空格,就会产生错误。字符串的第一部分是外部库的友好名称(没有文件扩展名)。在分号后,键入单词Component,后跟编译后的二进制资源的名称,这将与原始的 XAML 资源字典相同。

这就结束了对 WPF 资源管理系统的检查。您将在大多数(如果不是全部)应用中很好地利用这些技术。接下来,我们来考察一下 Windows Presentation Foundation 的集成动画 API。

了解 WPF 的动画服务

除了你在第二十六章中研究的图形渲染服务,WPF 还提供了一个编程接口来支持动画服务。术语动画可能会让人想起旋转的公司徽标、一系列旋转的图像资源(以提供运动的错觉)、屏幕上跳动的文本或特定类型的程序,如视频游戏或多媒体应用。

虽然 WPF 的动画 API 肯定可以用于这种目的,但如果你想给应用增添一些特色,随时都可以使用动画。例如,您可以为屏幕上的按钮创建一个动画,当鼠标光标悬停在其边界内时,该动画会稍微放大(当鼠标光标移动到边界之外时,动画会缩小)。或者,您可以制作窗口动画,使其以特定的视觉外观关闭,例如慢慢淡入透明。更以业务应用为中心的用途是淡入应用屏幕上的错误消息,以改善用户体验。事实上,WPF 的动画支持可以用于任何类型的应用(商业应用、多媒体程序、视频游戏等)。)每当您想要提供更吸引人的用户体验时。

正如 WPF 的许多其他方面一样,制作动画的概念并不新鲜。新的是,与您过去可能使用的其他 API(包括 Windows 窗体)不同,开发人员不需要手动创作必要的基础结构。在 WPF 下,不需要创建用于推进动画序列的后台线程或计时器,不需要定义自定义类型来表示动画,不需要擦除和重绘图像,也不需要进行繁琐的数学计算。像 WPF 的其他方面一样,你可以完全使用 XAML、完全使用 C# 代码或者两者结合来制作动画。

Note

Visual Studio 不支持使用 GUI 动画工具创作动画。如果您使用 Visual Studio 创作动画,您可以通过直接键入 XAML 来完成。然而,Blend for Visual Studio(Visual Studio 2019 附带的配套产品)确实有一个内置的动画编辑器,可以大大简化你的生活。

动画类的角色类型

为了理解 WPF 的动画支持,您必须从检查PresentationCore.dllSystem.Windows.Media.Animation名称空间中的动画类开始。在这里,您会发现 100 多个不同的类类型是使用Animation标记命名的。

这些类别可以分为三大类。第一,任何遵循命名约定数据类型 Animation ( ByteAnimationColorAnimationDoubleAnimationInt32Animation等的类。)允许您使用线性插值动画。这使您能够随着时间的推移平稳地将值从起始值更改为最终值。

接下来,遵循命名约定的类有数据类型 AnimationUsingKeyFrames ( StringAnimationUsingKeyFramesDoubleAnimationUsingKeyFramesPointAnimationUsingKeyFrames等。)表示“关键帧动画”,它允许您在一段时间内循环通过一组定义的值。例如,您可以通过在一系列单个字符之间循环,使用关键帧来更改按钮的标题。

最后,遵循数据类型 AnimationUsingPath命名约定的类(DoubleAnimationUsingPathPointAnimationUsingPath等等)是基于路径的动画,允许你动画化对象沿着你定义的路径移动。举例来说,如果您正在构建一个 GPS 应用,您可以使用基于路径的动画来沿着最快的旅行路线将项目移动到用户的目的地。

现在,很明显,这些类是而不是用来以某种方式直接向特定数据类型的变量提供动画序列(毕竟,你怎么能使用Int32Animation来制作值“9”的动画呢?).

例如,考虑一下Label类型的HeightWidth属性,这两个属性都是包装了double的依赖属性。如果你想定义一个在一段时间内增加标签高度的动画,你可以将一个DoubleAnimation对象连接到Height属性,并允许 WPF 处理实际动画本身的细节。作为另一个例子,如果你想在五秒钟内将画笔类型的颜色从绿色转换为黄色,你可以使用ColorAnimation类型来完成。

为了清楚起见,这些Animation类可以连接到匹配底层类型的给定对象的任何依赖属性。正如第二十五章所解释的,依赖属性是许多 WPF 服务所需要的一种特殊形式的属性,包括动画、数据绑定和样式。

按照惯例,依赖属性被定义为类的静态只读字段,并通过在普通属性名后面加上单词Property来命名。例如,在代码中使用Button.HeightProperty可以访问ButtonHeight属性的依赖属性。

“收件人”、“发件人”和“依据”属性

所有的Animation类都定义了以下几个关键属性,这些属性控制用于执行动画的开始和结束值:

  • To:该属性表示动画的结束值。

  • From:该属性表示动画的起始值。

  • By:该属性表示动画改变其起始值的总量。

尽管所有的Animation类都支持ToFromBy属性,但它们并不通过基类的虚拟成员接收这些属性。原因是这些属性所包装的底层类型差异很大(整数、颜色、Thickness对象等)。),并且使用单个基类来表示所有的可能性会导致复杂的编码结构。

另一方面,你可能也想知道为什么。NET 泛型不用于定义具有单一类型参数(例如,Animate<T>)的单一泛型动画类。同样,假设有这么多的底层数据类型(颜色、向量、intstring等)。)习惯了动态的依赖属性,它不会像你期望的那样是一个干净的解决方案(更不用说 XAML 对泛型类型的支持是有限的)。

时间轴基类的角色

尽管没有使用单个基类来定义虚拟的ToFromBy属性,但是Animation类确实共享一个公共基类:System.Windows.Media.Animation.Timeline。这种类型提供了几个控制动画步调的附加属性,如表 27-1 所述。

表 27-1。

Timeline基类的关键成员

|

性能

|

生命的意义

AccelerationRatioDecelerationRatioSpeedRatio 这些属性可用于控制动画序列的整体速度。
AutoReverse 该属性获取或设置一个值,该值指示时间轴在完成正向迭代后是否反向播放(默认值为false)。
BeginTime 此属性获取或设置此时间线的开始时间。默认值为 0,表示立即开始播放动画。
Duration 此属性允许您设置播放时间线的持续时间。
FillBehaviorRepeatBehavior 这些属性用于控制时间轴完成后应该发生的事情(重复动画,什么都不做,等等)。).

用 C# 代码创作动画

具体来说,您将构建一个包含一个ButtonWindow,每当鼠标进入它的表面区域时,它就会有一个奇怪的旋转行为(基于左上角)。首先创建一个名为SpinningButtonAnimationApp的新 WPF 应用。将初始标记更新为以下内容(注意,您正在处理按钮的MouseEnter事件):

<Button x:Name="btnSpinner" Height="50" Width="100" Content="I Spin!"
      MouseEnter="btnSpinner_MouseEnter" Click="btnSpinner_OnClick"/>

在代码隐藏文件中,导入System.Windows.Media.Animation命名空间,并在窗口的 C# 代码文件中添加以下代码:

private bool _isSpinning=false;

private void btnSpinner_MouseEnter(
  object sender, MouseEventArgs e)
{
  if (!_isSpinning)
  {
    _isSpinning=true;
    // Make a double animation object, and register
    // with the Completed event.
    var dblAnim=new DoubleAnimation();
    dblAnim.Completed +=(o, s)=> { _isSpinning=false; };
    // Set the start value and end value.
    dblAnim.From=0;
    dblAnim.To=360;
    // Now, create a RotateTransform object, and set
    // it to the RenderTransform property of our
    // button.
    var rt=new RotateTransform();
    btnSpinner.RenderTransform=rt;
    // Now, animation the RotateTransform object.
    rt.BeginAnimation(RotateTransform.AngleProperty, dblAnim);
  }
}
private void btnSpinner_OnClick(
  object sender, RoutedEventArgs e)
{

}

该方法的第一个主要任务是配置一个DoubleAnimation对象,它将从值 0 开始,到值 360 结束。请注意,您也正在处理该对象上的Completed事件,以切换一个类级别的bool变量,该变量用于确保如果一个动画当前正在执行,您不会“重置”它以重新开始。

接下来,您创建一个连接到您的Button控件(btnSpinner)的RenderTransform属性的RotateTransform对象。最后,您通知RenderTransform对象使用您的DoubleAnimation对象开始制作其Angle属性的动画。当您在代码中创作动画时,通常通过调用BeginAnimation()来完成,然后传入您想要制作动画的底层依赖属性(记住,按照惯例,这是类上的静态字段),后面跟一个相关的动画对象。

让我们在程序中添加另一个动画,这个动画会导致按钮在被点击时淡入不可见状态。首先,在Click事件处理程序中添加以下代码:

private void btnSpinner_OnClick(
  object sender, RoutedEventArgs e)
{
  var dblAnim=new DoubleAnimation
  {
    From=1.0,
    To=0.0
  };
  btnSpinner.BeginAnimation(Button.OpacityProperty, dblAnim);
}

这里,您正在更改Opacity属性值,以使按钮淡出视图。然而,目前这很难做到,因为按钮旋转得非常快!那么,你如何控制动画的节奏呢?很高兴你问了。

控制动画的速度

默认情况下,动画在分配给FromTo属性的值之间转换大约需要一秒钟。因此,你的按钮有一秒钟的时间旋转 360 度,而按钮将在一秒钟内消失(当被点击时)。

如果您想要为动画的过渡定义一个自定义的时间量,您可以通过动画对象的Duration属性来实现,该属性可以设置为一个Duration对象的实例。通常,时间跨度是通过将一个TimeSpan对象传递给Duration的构造函数来建立的。考虑下面的更新,它将为按钮提供整整四秒的旋转时间:

private void btnSpinner_MouseEnter(
  object sender, MouseEventArgs e)
{
  if (!_isSpinning)
  {
    _isSpinning=true;

    // Make a double animation object, and register
    // with the Completed event.
    var dblAnim=new DoubleAnimation();
    dblAnim.Completed +=(o, s)=> { _isSpinning=false; };

    // Button has four seconds to finish the spin!
    dblAnim.Duration=new Duration(TimeSpan.FromSeconds(4));

...
  }
}

通过这种调整,你应该有机会在按钮旋转时点击它,此时它会逐渐消失。

Note

一个Animation类的BeginTime属性也接受一个TimeSpan对象。回想一下,可以设置该属性来建立开始动画序列之前的等待时间。

反转和循环播放动画

还可以通过将AutoReverse属性设置为true来告诉Animation对象在动画序列完成时反向播放动画。例如,如果您想让按钮在消失后重新出现,您可以编写以下代码:

private void btnSpinner_OnClick(object sender, RoutedEventArgs e)
{
  DoubleAnimation dblAnim=new DoubleAnimation
  {
    From=1.0,
    To=0.0
  };
  // Reverse when done.
  dblAnim.AutoReverse=true;
  btnSpinner.BeginAnimation(Button.OpacityProperty, dblAnim);
}

如果你想让一个动画重复一定次数(或者一旦激活就永不停止),你可以使用所有Animation类共有的RepeatBehavior属性。如果向构造函数传递一个简单的数值,可以指定硬编码的重复次数。另一方面,如果你将一个TimeSpan对象传递给构造函数,你可以确定动画应该重复的时间。最后,如果你想让一个动画无限循环*,你可以简单的指定RepeatBehavior.Forever。考虑以下方法,您可以更改本例中使用的任一DoubleAnimation对象的重复行为:*

// Loop forever.
dblAnim.RepeatBehavior=RepeatBehavior.Forever;

// Loop three times.
dblAnim.RepeatBehavior=new RepeatBehavior(3);

// Loop for 30 seconds.
dblAnim.RepeatBehavior=new RepeatBehavior(TimeSpan.FromSeconds(30));

这就结束了关于如何使用 C# 代码和 WPF 动画 API 来制作对象动画的研究。接下来,您将学习如何使用 XAML 做同样的事情。

在 XAML 创作动画

在标记中创作动画就像在代码中创作一样,至少对于简单直接的动画序列是这样。当您需要捕获更复杂的动画时,这可能涉及到一次更改许多属性的值,标记的数量可能会大大增加。即使您使用工具来生成基于 XAML 的动画,了解动画在 XAML 的基本表现方式也很重要,因为这将使您更容易修改和调整工具生成的内容。

Note

您会在可下载源代码的XamlAnimations文件夹中找到许多 XAML 文件。在接下来的几页中,将这些标记文件复制到您的自定义 XAML 编辑器或 Kaxaml 编辑器中,以查看结果。

在很大程度上,创作一部动画就像你已经看到的一样。您仍然需要配置一个Animation对象,并将它与一个对象的属性相关联。然而,一个很大的不同是,WPF 不是函数调用友好的。因此,您不用调用BeginAnimation(),而是使用故事板作为间接层。

让我们看一个用 XAML 定义的动画的完整例子,然后是一个详细的分解。下面的 XAML 定义将显示一个包含单个标签的窗口。一旦Label对象加载到内存中,它就开始一个动画序列,其中字体大小在 4 秒内从 12 磅增加到 100 磅。只要Window对象加载到内存中,动画就会重复播放。您可以在GrowLabelFont.xaml文件中找到这个标记,所以将它复制到 Kaxaml 中(确保按 F5 显示窗口)并观察行为。

<Window
  xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
  xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
  Height="200" Width="600" WindowStartupLocation="CenterScreen" Title="Growing Label Font!">
  <StackPanel>
    <Label Content="Interesting...">
      <Label.Triggers>
        <EventTrigger RoutedEvent="Label.Loaded">
          <EventTrigger.Actions>
            <BeginStoryboard>
              <Storyboard TargetProperty="FontSize">
                <DoubleAnimation From="12" To="100" Duration="0:0:4"
                  RepeatBehavior="Forever"/>
              </Storyboard>
            </BeginStoryboard>
          </EventTrigger.Actions>
        </EventTrigger>
      </Label.Triggers>
    </Label>
  </StackPanel>
</Window>

现在,让我们一点一点地分解这个例子。

故事板的作用

从最里面的元素开始,您首先会遇到<DoubleAnimation>元素,它利用了您在过程代码中设置的相同属性(FromToDurationRepeatBehavior)。

<DoubleAnimation From="12" To="100" Duration="0:0:4"
                 RepeatBehavior="Forever"/>

如上所述,Animation元素被放置在一个<Storyboard>元素中,该元素用于通过TargetProperty属性将动画对象映射到父类型上的给定属性,在本例中是FontSize。一个<Storyboard>总是被包装在一个名为<BeginStoryboard>的父元素中。

<BeginStoryboard>
  <Storyboard TargetProperty="FontSize">
    <DoubleAnimation From="12" To="100" Duration="0:0:4"
                     RepeatBehavior="Forever"/>
  </Storyboard>
</BeginStoryboard>

事件触发器的作用

在定义了<BeginStoryboard>元素之后,您需要指定某种动作来使动画开始执行。WPF 有几种不同的方式来响应标记中的运行时条件,其中一种被称为触发器。从高层次来看,您可以将触发器视为一种响应 XAML 事件条件的方式,而不需要过程代码。

通常,当您在 C# 中响应事件时,您创作的自定义代码将在事件发生时执行。然而,触发器只是一种被通知某些事件条件已经发生的方式(“我被加载到内存中!”或者“鼠标在我身上!”或者“我有焦点了!”).

一旦你被通知一个事件条件已经发生,你就可以开始故事板。在本例中,您正在响应加载到内存中的Label。因为您感兴趣的是LabelLoaded事件,所以<EventTrigger>被放在Label的触发集合中。

<Label Content="Interesting...">
  <Label.Triggers>
    <EventTrigger RoutedEvent="Label.Loaded">
      <EventTrigger.Actions>
        <BeginStoryboard>
          <Storyboard TargetProperty="FontSize">
            <DoubleAnimation From="12" To="100" Duration="0:0:4"
                             RepeatBehavior="Forever"/>
          </Storyboard>
        </BeginStoryboard>
      </EventTrigger.Actions>
    </EventTrigger>
  </Label.Triggers>
</Label>

让我们看另一个在 XAML 定义动画的例子,这次使用一个关键帧动画。

使用离散关键帧的动画

与只能在起点和终点之间移动的线性插值动画对象不同,关键帧副本允许您为应该在特定时间发生的动画创建特定值的集合。

为了说明离散关键帧类型的用法,假设您想要构建一个Button控件,它可以使其内容动画化,这样在三秒钟的时间内,值“OK!”一次显示一个字符。您将在AnimateString.xaml文件中找到以下标记。将这个标记复制到您的MyXamlPad.exe程序(或 Kaxaml)中,并查看结果:

<Window xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
   xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
   Height="100" Width="300"
   WindowStartupLocation="CenterScreen" Title="Animate String Data!">
   <StackPanel>
     <Button Name="myButton" Height="40"
             FontSize="16pt" FontFamily="Verdana" Width="100">
      <Button.Triggers>
        <EventTrigger RoutedEvent="Button.Loaded">
          <BeginStoryboard>
            <Storyboard>
              <StringAnimationUsingKeyFrames RepeatBehavior="Forever"
                Storyboard.TargetProperty="Content"
                Duration="0:0:3">
                <DiscreteStringKeyFrame Value="" KeyTime="0:0:0" />
                <DiscreteStringKeyFrame Value="O" KeyTime="0:0:1" />
                <DiscreteStringKeyFrame Value="OK" KeyTime="0:0:1.5" />
                <DiscreteStringKeyFrame Value="OK!" KeyTime="0:0:2" />
              </StringAnimationUsingKeyFrames>
            </Storyboard>
          </BeginStoryboard>
        </EventTrigger>
      </Button.Triggers>
    </Button>
  </StackPanel>
</Window>

首先,请注意,您已经为按钮定义了一个事件触发器,以确保当按钮加载到内存中时故事板会执行。StringAnimationUsingKeyFrames类通过Storyboard.TargetProperty值监督按钮内容的改变。

<StringAnimationUsingKeyFrames>元素的范围内,您定义了四个DiscreteStringKeyFrame元素,它们在两秒钟内改变按钮的Content属性(注意StringAnimationUsingKeyFrames建立的持续时间总共是三秒钟,因此您将看到在最后的!和循环O之间有一个轻微的停顿)。

既然您对如何用 C# 代码和 XAML 构建动画有了更好的感觉,让我们看看 WPF 风格的作用,它大量使用图形、对象资源和动画。

了解 WPF 风格的作用

当您构建 WPF 应用的用户界面时,一系列控件需要共享的外观并不罕见。例如,您可能希望所有按钮类型的字符串内容具有相同的高度、宽度、背景颜色和字体大小。虽然您可以通过将每个按钮的单个属性设置为相同的值来解决这个问题,但这种方法很难实现后续的更改,因为每次更改都需要在多个对象上重置相同的属性集。

幸运的是,WPF 提供了一种简单的方法来约束使用风格的相关控件的外观和感觉。简单地说,WPF 样式是一个维护属性-值对集合的对象。从编程的角度来说,使用System.Windows.Style类来表示一个单独的样式。这个类有一个名为Setters的属性,它公开了一个Setter对象的强类型集合。是Setter对象允许您定义属性-值对。

除了Setters集合之外,Style类还定义了一些其他重要的成员,这些成员允许您合并触发器,限制可以应用样式的位置,甚至基于现有的样式创建新的样式(可以将其视为“样式继承”)。注意下面这个Style类的成员:

  • Triggers:公开一个触发器对象集合,允许您在一个样式中捕获各种事件条件

  • BasedOn:允许您在现有样式的基础上构建新样式

  • TargetType:允许您限制样式的应用位置

定义和应用样式

几乎在每种情况下,一个Style对象都会被打包成一个对象资源。像任何对象资源一样,您可以在窗口或应用级别打包它,以及在一个专用的资源字典中打包(这很好,因为它使Style对象在整个应用中很容易被访问)。现在回想一下,目标是定义一个用一组属性-值对填充(至少)集合的Style对象。

让我们构建一个样式,它可以捕获应用中控件的基本字体特征。首先创建一个名为WpfStyles的新 WPF 应用。打开您的App.xaml文件并定义以下命名样式:

<Application.Resources>
  <Style x:Key="BasicControlStyle">
    <Setter Property="Control.FontSize" Value="14"/>
    <Setter Property="Control.Height" Value="40"/>
    <Setter Property="Control.Cursor" Value="Hand"/>
  </Style>
</Application.Resources>

注意,您的BasicControlStyle向内部集合添加了三个Setter对象。现在,让我们将这种风格应用到主窗口中的几个控件上。因为这个样式是一个对象资源,想要使用它的控件仍然需要使用{StaticResource}{DynamicResource}标记扩展来定位样式。当他们找到样式时,他们会将资源项设置为同名的Style属性。用以下标记替换默认的Grid控件:

<StackPanel>
  <Label x:Name="lblInfo" Content="This style is boring..."
         Style="{StaticResource BasicControlStyle}" Width="150"/>
  <Button x:Name="btnTestButton" Content="Yes, but we are reusing settings!"
         Style="{StaticResource BasicControlStyle}" Width="250"/>
</StackPanel>

如果您在 Visual Studio 设计器中查看Window(或者运行应用),您会发现两个控件都支持相同的光标、高度和字体大小。

覆盖样式设置

虽然你的两个控件都选择了样式,但是如果一个控件想要应用一个样式,然后改变一些已定义的设置,那也没问题。例如,Button现在将使用Help光标(而不是样式中定义的Hand光标)。

<Button x:Name="btnTestButton" Content="Yes, but we are reusing settings!"
        Cursor="Help" Style="{StaticResource BasicControlStyle}" Width="250" />

在使用样式的控件的单个属性设置之前处理样式;因此,控件可以根据具体情况“覆盖”设置。

目标类型对样式的影响

目前,你的风格是以这样一种方式定义的,任何控件都可以采用它(并且必须通过设置控件的Style属性显式地这样做),假设每个属性都由Control类限定。对于一个定义了许多设置的程序来说,这需要大量的重复代码。稍微清理一下这种风格的一种方法是使用TargetType属性。当您将该属性添加到Style的开始元素时,您可以准确地标记一次它可以应用的位置(在本例中,在App.XAML)。

<Style x:Key="BasicControlStyle" TargetType="Control">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Height" Value="40"/>
  <Setter Property="Cursor" Value="Hand"/>
</Style>

Note

当您生成使用基类类型的样式时,您不必担心是否将值赋给了派生类型不支持的依赖属性。如果派生类型不支持给定的依赖项属性,则忽略该属性。

这在一定程度上是有帮助的,但是您仍然有一种可以应用于任何控件的样式。当您想要定义一个只能应用于特定类型控件的样式时,TargetType属性会更有用。将以下新样式添加到应用的资源字典中:

<Style x:Key="BigGreenButton" TargetType="Button">
  <Setter Property="FontSize" Value="20"/>
  <Setter Property="Height" Value="100"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Background" Value="DarkGreen"/>
  <Setter Property="Foreground" Value="Yellow"/>
</Style>

这种风格只适用于Button控件(或Button的子类)。如果将它应用于不兼容的元素,将会出现标记和编译器错误。添加一个使用这个新样式的新Button,如下所示:

<Button x:Name="btnAnotherButton" Content="OK!" Margin="0,10,0,0"
    Style="{StaticResource BigGreenButton}" Width="250" Cursor="Help"/>

您将看到如图 27-7 所示的输出。

img/340876_10_En_27_Fig7_HTML.png

图 27-7。

不同样式的控件

TargetType的另一个作用是,如果x:Key属性不存在,样式将被应用到样式定义范围内该类型的所有元素。

下面是另一个应用级样式,它将自动应用于当前应用中的所有TextBox控件:

<!-- The default style for all text boxes. -->
<Style TargetType="TextBox">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="30"/>
  <Setter Property="BorderThickness" Value="5"/>
  <Setter Property="BorderBrush" Value="Red"/>
  <Setter Property="FontStyle" Value="Italic"/>
</Style>

您现在可以定义任意数量的TextBox控件,它们将自动获得定义的外观。如果给定的TextBox不想要这个默认的外观,它可以通过将Style属性设置为{x:Null}来退出。例如,txtTest将获得默认的未命名样式,而txtTest2则以自己的方式做事。

<TextBox x:Name="txtTest"/>
<TextBox x:Name="txtTest2" Style="{x:Null}" BorderBrush="Black"
       BorderThickness="5" Height="60" Width="100" Text="Ha!"/>

子类化现有样式

您还可以通过BasedOn属性使用现有的样式构建新的样式。您正在扩展的样式必须在字典中被赋予一个合适的x:Key,因为派生的样式将使用{StaticResource}{DynamicResource}标记扩展通过名称引用它。下面是一个基于BigGreenButton的新样式,它将按钮元素旋转了 20 度:

<!-- This style is based on BigGreenButton. -->
<Style x:Key="TiltButton" TargetType="Button" BasedOn="{StaticResource BigGreenButton}">
  <Setter Property="Foreground" Value="White"/>
  <Setter Property="RenderTransform">
    <Setter.Value>
      <RotateTransform Angle="20"/>
    </Setter.Value>
  </Setter>
</Style>

要使用这种新样式,请将按钮的标记更新为:

<Button x:Name="btnAnotherButton" Content="OK!" Margin="0,10,0,0"
    Style="{StaticResource TiltButton}" Width="250" Cursor="Help"/>

这将改变图 27-8 所示图像的外观。

img/340876_10_En_27_Fig8_HTML.jpg

图 27-8。

使用派生样式

使用触发器定义样式

通过将Trigger对象打包到Style对象的Triggers集合中,WPF 样式也可以包含触发器。在一个样式中使用触发器允许您定义某些<Setter>元素,使得它们只有在给定的触发条件为true时才会被应用。例如,当鼠标停留在按钮上时,您可能想要增加字体的大小。或者,您可能希望确保具有当前焦点的文本框以给定的颜色突出显示。触发器对于这类情况很有用,因为它们允许您在属性更改时采取特定的操作,而无需在代码隐藏文件中编写显式 C# 代码。

下面是对TextBox样式的更新,确保当TextBox拥有输入焦点时,它将获得黄色背景:

<!-- The default style for all text boxes. -->
<Style TargetType="TextBox">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="30"/>
  <Setter Property="BorderThickness" Value="5"/>
  <Setter Property="BorderBrush" Value="Red"/>
  <Setter Property="FontStyle" Value="Italic"/>
  <!-- The following setter will be applied only when the text box is in focus. -->
  <Style.Triggers>
    <Trigger Property="IsFocused" Value="True">
      <Setter Property="Background" Value="Yellow"/>
    </Trigger>
  </Style.Triggers>
</Style>

如果你测试这种风格,你会发现当你在不同的TextBox对象之间切换时,当前选中的TextBox有一个亮黄色的背景(假设它没有通过将{x:Null}分配给Style属性而退出)。

属性触发器也非常智能,当触发器的条件为非真时,属性会自动接收默认的赋值。因此,一旦TextBox失去焦点,它也会自动变成默认颜色,无需您做任何工作。相反,事件触发器(在你看 WPF 动画时检查过)不会自动回复到先前的状态。

使用多个触发器定义样式

触发器也可以这样设计,当多个条件为真时,定义的<Setter>元素将被应用。假设您想将一个TextBox的背景设置为Yellow,只要它有活动的焦点并且鼠标在它的边界内悬停。为此,您可以利用<MultiTrigger>元素来定义每个条件,如下所示:

<!-- The default style for all text boxes. -->
<Style TargetType="TextBox">
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="30"/>
  <Setter Property="BorderThickness" Value="5"/>
  <Setter Property="BorderBrush" Value="Red"/>
  <Setter Property="FontStyle" Value="Italic"/>
  <!-- The following setter will be applied only when the text box is
  in focus AND the mouse is over the text box. -->
  <Style.Triggers>
    <MultiTrigger>
      <MultiTrigger.Conditions>
            <Condition Property="IsFocused" Value="True"/>
            <Condition Property="IsMouseOver" Value="True"/>
        </MultiTrigger.Conditions>
      <Setter Property="Background" Value="Yellow"/>
    </MultiTrigger>
  </Style.Triggers>
</Style>

动画样式

样式还可以包含启动动画序列的触发器。下面是最后一个样式,当应用于Button控件时,当鼠标在按钮的表面区域内时,它将导致控件的大小增大和缩小:

<!-- The growing button style! -->
<Style x:Key="GrowingButtonStyle" TargetType="Button">
  <Setter Property="Height" Value="40"/>
  <Setter Property="Width" Value="100"/>
  <Style.Triggers>
    <Trigger Property="IsMouseOver" Value="True">
      <Trigger.EnterActions>
        <BeginStoryboard>
          <Storyboard TargetProperty="Height">
            <DoubleAnimation From="40" To="200"
                             Duration="0:0:2" AutoReverse="True"/>
          </Storyboard>
        </BeginStoryboard>
      </Trigger.EnterActions>
    </Trigger>
  </Style.Triggers>
</Style>

在这里,Triggers集合正在寻找IsMouseOver属性来返回true。当这种情况发生时,您定义一个<Trigger.EnterActions>元素来执行一个简单的故事板,强制按钮在两秒钟内增长到200Height值(然后返回到40Height)。如果您想要执行其他的属性更改,您也可以定义一个<Trigger.ExitActions>范围来定义当IsMouseOver更改为false时要采取的任何自定义动作。

以编程方式分配样式

回想一下,样式也可以在运行时应用。如果您想让最终用户选择他们的用户界面的外观和感觉,或者如果您需要基于安全设置(例如,DisableAllButton样式)或您所拥有的东西来加强外观和感觉,这可能是有帮助的。

在这个项目中,您定义了几种样式,其中许多可以应用于Button控件。所以,让我们重组主窗口的 UI,让用户通过在ListBox中选择名字来选择这些风格。根据用户的选择,您将应用适当的样式。下面是<Window>元素的新的(也是最终的)标记:

<DockPanel >
  <StackPanel Orientation="Horizontal" DockPanel.Dock="Top" Margin="0,0,0,50">
    <Label Content="Please Pick a Style for this Button" Height="50"/>
    <ListBox x:Name="lstStyles" Height="80" Width="150" Background="LightBlue"
             SelectionChanged="comboStyles_Changed" />
  </StackPanel>
  <Button x:Name="btnStyle" Height="40" Width="100" Content="OK!"/>
</DockPanel>

ListBox控件(名为lstStyles)将在窗口的构造函数中动态填充,如下所示:

public MainWindow()
{
  InitializeComponent();
  // Fill the list box with all the Button styles.
  lstStyles.Items.Add("GrowingButtonStyle");
  lstStyles.Items.Add("TiltButton");
  lstStyles.Items.Add("BigGreenButton");
  lstStyles.Items.Add("BasicControlStyle");}
}

最后一个任务是处理相关代码文件中的SelectionChanged事件。注意在下面的代码中,如何使用继承的TryFindResource()方法按名称提取当前资源:

private void comboStyles_Changed(object sender, SelectionChangedEventArgs e)
{
  // Get the selected style name from the list box.
  var currStyle=(Style)TryFindResource(lstStyles.SelectedValue);
  if (currStyle==null) return;
  // Set the style of the button type.
  this.btnStyle.Style=currStyle;
}

当您运行这个应用时,您可以从这四种按钮样式中选择一种。图 27-9 显示了您完成的申请。

img/340876_10_En_27_Fig9_HTML.jpg

图 27-9。

不同样式的控件

逻辑树、可视化树和默认模板

现在您已经了解了样式和资源,在开始学习如何构建自定义控件之前,还有几个准备主题需要研究。具体来说,您需要了解逻辑树、可视化树和默认模板之间的区别。当你在 Visual Studio 或类似kaxaml.exe的工具中输入 XAML 时,你的标记就是 XAML 文档的逻辑视图。同样,如果您编写了向布局控件添加新项的 C# 代码,您就是在向逻辑树中插入新项。本质上,一个逻辑视图代表了你的内容将如何在主Window(或者另一个根元素,比如Page或者NavigationWindow)的各种布局管理器中定位。

然而,在每个逻辑树的背后是一个更详细的表示,称为视觉树,WPF 内部使用它来正确地将元素渲染到屏幕上。在任何视觉树中,都有用于呈现每个对象的模板和样式的完整细节,包括任何必要的绘图、形状、视觉效果和动画。

理解逻辑树和可视化树之间的区别非常有用,因为当您生成自定义控件模板时,实际上是替换控件的全部或部分默认可视化树并插入您自己的树。因此,如果您想要将Button控件呈现为星形,您可以定义一个新的星形模板,并将其插入到Button的可视化树中。从逻辑上讲,Button仍然是类型Button,它支持预期的属性、方法和事件。但在视觉上,它呈现出全新的面貌。鉴于其他工具包会要求您构建一个新的类来制作星形按钮,仅这一事实就使 WPF 成为一个极其有用的 API。有了 WPF,你只需要定义新的标记。

Note

WPF 控件通常被描述为无外观。这是指这样一个事实,即 WPF 控件的外观和感觉完全独立于它的行为。

以编程方式检查逻辑树

虽然在运行时分析一个窗口的逻辑树并不是一个非常常见的 WPF 编程活动,但是值得一提的是,System.Windows名称空间定义了一个名为LogicalTreeHelper的类,它允许您在运行时检查逻辑树的结构。为了说明逻辑树、可视化树和控件模板之间的联系,创建一个名为 TreesAndTemplatesApp 的新 WPF 应用。

用以下标记替换Grid,该标记包含两个Button控件和一个启用滚动条的大只读TextBox。确保使用 IDE 处理每个按钮的Click事件。下面的 XAML 会做得很好:

<DockPanel LastChildFill="True">
  <Border Height="50" DockPanel.Dock="Top" BorderBrush="Blue">
    <StackPanel Orientation="Horizontal">
      <Button x:Name="btnShowLogicalTree" Content="Logical Tree of Window"
            Margin="4" BorderBrush="Blue" Height="40" Click="btnShowLogicalTree_Click"/>
      <Button x:Name="btnShowVisualTree" Content="Visual Tree of Window"
            BorderBrush="Blue" Height="40" Click="btnShowVisualTree_Click"/>
    </StackPanel>
  </Border>
  <TextBox x:Name="txtDisplayArea" Margin="10" Background="AliceBlue" IsReadOnly="True"
         BorderBrush="Red" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto" />
</DockPanel>

在 C# 代码文件中,定义一个名为 _ dataToShowstring成员变量。现在,在btnShowLogicalTree对象的Click处理程序中,调用一个 helper 函数,该函数递归地调用自身,用Window的逻辑树填充字符串变量。为此,您将调用LogicalTreeHelper的静态GetChildren()方法。下面是代码:

private string _dataToShow=string.Empty;

private void btnShowLogicalTree_Click(object sender, RoutedEventArgs e)
{
  _dataToShow="";
  BuildLogicalTree(0, this);
  txtDisplayArea.Text=_dataToShow;
}

void BuildLogicalTree(int depth, object obj)
{
  // Add the type name to the dataToShow member variable.
  _dataToShow +=new string(' ', depth) + obj.GetType().Name + "\n";
  // If an item is not a DependencyObject, skip it.
  if (!(obj is DependencyObject))
    return;
  // Make a recursive call for each logical child.
  foreach (var child in LogicalTreeHelper.GetChildren((DependencyObject)obj))
  {
      BuildLogicalTree(depth + 5, child);
  }
}
private void btnShowVisualTree_Click(
  object sender, RoutedEventArgs e)
{
}

如果你运行你的应用并点击第一个按钮,你会在文本区域看到一个树形图,它几乎是原始 XAML 的精确复制品(见图 27-10 )。

img/340876_10_En_27_Fig10_HTML.jpg

图 27-10。

在运行时查看逻辑树

以编程方式检查可视化树

使用System.Windows.MediaVisualTreeHelper类也可以在运行时检查Window的可视化树。下面是第二个Button控件(btnShowVisualTree)的Click实现,它执行类似的递归逻辑来构建视觉树的文本表示:

using System.Windows.Media;

private void btnShowVisualTree_Click(object sender, RoutedEventArgs e)
{
  _dataToShow="";
  BuildVisualTree(0, this);
  txtDisplayArea.Text=_dataToShow;
}
void BuildVisualTree(int depth, DependencyObject obj)
{
  // Add the type name to the dataToShow member variable.
  _dataToShow +=new string(' ', depth) + obj.GetType().Name + "\n";
  // Make a recursive call for each visual child.
  for (int i=0; i < VisualTreeHelper.GetChildrenCount(obj); i++)
  {
    BuildVisualTree(depth + 1, VisualTreeHelper.GetChild(obj, i));
  }
}

如图 27-11 所示,视觉树公开了几个低级渲染代理,如ContentPresenterAdornerDecoratorTextBoxLineDrawingVisual等。

img/340876_10_En_27_Fig11_HTML.jpg

图 27-11。

在运行时查看可视化树

以编程方式检查控件的默认模板

回想一下,WPF 使用视觉树来理解如何呈现一个Window和所有包含的元素。每个 WPF 控件都在其默认模板中存储自己的呈现命令集。从编程的角度来说,任何模板都可以表示为ControlTemplate类的一个实例。同样,您可以通过使用名副其实的Template属性来获得控件的默认模板,如下所示:

// Get the default template of the Button.
Button myBtn=new Button();
ControlTemplate template=myBtn.Template;

同样,您可以在代码中创建一个新的ControlTemplate对象,并将其插入控件的Template属性,如下所示:

// Plug in a new template for the button to use.
Button myBtn=new Button();
ControlTemplate customTemplate=new ControlTemplate();

// Assume this method adds all the code for a star template.
MakeStarTemplate(customTemplate);
myBtn.Template=customTemplate;

虽然您可以用代码构建一个新的模板,但在 XAML 这样做要常见得多。但是,在您开始构建自己的模板之前,让我们先完成当前的示例,并添加在运行时查看 WPF 控件的默认模板的功能。这是查看模板整体构成的一种有用方式。用停靠在主控件DockPanel左侧的新控件StackPanel更新窗口的标记,定义如下(放置在<TextBox>元素之前):

<Border DockPanel.Dock="Left" Margin="10" BorderBrush="DarkGreen" BorderThickness="4" Width="358">
  <StackPanel>
    <Label Content="Enter Full Name of WPF Control" Width="340" FontWeight="DemiBold" />
    <TextBox x:Name="txtFullName" Width="340" BorderBrush="Green"
             Background="BlanchedAlmond" Height="22" Text="System.Windows.Controls.Button" />
    <Button x:Name="btnTemplate" Content="See Template" BorderBrush="Green"
            Height="40" Width="100" Margin="5" Click="btnTemplate_Click" HorizontalAlignment="Left" />
    <Border BorderBrush="DarkGreen" BorderThickness="2" Height="260"
            Width="301" Margin="10" Background="LightGreen" >
      <StackPanel x:Name="stackTemplatePanel" />
    </Border>
  </StackPanel>
</Border>

btnTemplate_Click()事件添加一个空事件处理函数,如下所示:

private void btnTemplate_Click(
  object sender, RoutedEventArgs e)
{
}

左上角的文本区允许你输入位于PresentationFramework.dll组件中的 WPF 控件的全限定名。一旦库被加载,您将动态地创建对象的一个实例,并将其显示在左下角的大方框中。最后,控件的默认模板将显示在右边的文本区域。首先,向类型为Control的 C# 类添加一个新的成员变量,如下所示:

private Control _ctrlToExamine=null;

下面是剩余的代码,它要求您导入System.ReflectionSystem.XmlSystem.Windows.Markup名称空间:

private void btnTemplate_Click(
  object sender, RoutedEventArgs e)
{
  _dataToShow="";
  ShowTemplate();
  txtDisplayArea.Text=_dataToShow;
}

private void ShowTemplate()
{
  // Remove the control that is currently in the preview area.
  if (_ctrlToExamine !=null)
    stackTemplatePanel.Children.Remove(_ctrlToExamine);
  try
  {
    // Load PresentationFramework, and create an instance of the
    // specified control. Give it a size for display purposes, then add to the
    // empty StackPanel.
    Assembly asm=Assembly.Load("PresentationFramework, Version=4.0.0.0," +
      "Culture=neutral, PublicKeyToken=31bf3856ad364e35");
    _ctrlToExamine=(Control)asm.CreateInstance(txtFullName.Text);
    _ctrlToExamine.Height=200;
    _ctrlToExamine.Width=200;
    _ctrlToExamine.Margin=new Thickness(5);
    stackTemplatePanel.Children.Add(_ctrlToExamine);
    // Define some XML settings to preserve indentation.
    var xmlSettings=new XmlWriterSettings{Indent=true};
    // Create a StringBuilder to hold the XAML.
    var strBuilder=new StringBuilder();
    // Create an XmlWriter based on our settings.
    var xWriter=XmlWriter.Create(strBuilder, xmlSettings);
    // Now save the XAML into the XmlWriter object based on the ControlTemplate.
    XamlWriter.Save(_ctrlToExamine.Template, xWriter);
    // Display XAML in the text box.
    _dataToShow=strBuilder.ToString();
  }
  catch (Exception ex)
  {
    _dataToShow=ex.Message;
  }
}

大部分工作只是修补编译后的 BAML 资源,将其映射成 XAML 字符串。图 27-12 显示了你的最终应用,显示了System.Windows.Controls.DatePicker控件的默认模板。图像显示的是Calendar,点击控件右侧的按钮即可进入。

img/340876_10_En_27_Fig12_HTML.jpg

图 27-12。

在运行时调查一个ControlTemplate

太好了。您应该对逻辑树、可视化树和控件默认模板如何协同工作有了更好的了解。现在,您可以用本章的剩余部分来学习如何构建自定义模板和用户控件。

使用触发器框架构建控件模板

当您为控件生成自定义模板时,除了 C# 代码之外,您什么都不用做。使用这种方法,您可以将数据添加到一个ControlTemplate对象,然后将它分配给一个控件的Template属性。然而,大多数时候,您将使用 XAML 定义一个ControlTemplate的外观,并添加一些代码(或者可能是相当多的代码)来驱动运行时行为。

在本章的剩余部分,您将研究如何使用 Visual Studio 构建自定义模板。在此过程中,您将了解 WPF 触发器框架和可视化状态管理器(VSM),并了解如何使用动画为最终用户提供可视化提示。单独使用 Visual Studio 来构建复杂的模板可能需要大量的输入和一些繁重的工作。可以肯定的是,生产级模板将受益于 Blend for Visual Studio,这是随 Visual Studio 一起安装的(现在)免费配套应用。然而,鉴于这一版本的文本不包括 Blend 的覆盖范围,是时候卷起袖子敲打一些标记了。

首先,创建一个名为 ButtonTemplate 的新 WPF 应用。对于这个项目,您对创建和使用模板的机制更感兴趣,所以用下面的标记替换Grid:

  <StackPanel Orientation="Horizontal">
    <Button x:Name="myButton" Width="100" Height="100" Click="myButton_Click"/>
  </StackPanel>

Click事件处理程序中,简单地显示一个消息框(通过MessageBox.Show())来显示一条确认控件点击的消息。记住,当你构建定制模板时,控件的行为是不变的,但是外观可能会变化。

目前,这个Button是使用默认模板呈现的,如前面的例子所示,它是给定 WPF 程序集中的一个 BAML 资源。当您想要定义自己的模板时,实际上是用您自己的创建来替换这个默认的可视化树。首先,更新<Button>元素的定义,使用 property-element 语法指定一个新模板。该模板将使控件具有圆形外观。

<Button x:Name="myButton" Width="100" Height="100" Click="myButton_Click">
  <Button.Template>
    <ControlTemplate>
      <Grid x:Name="controlLayout">
        <Ellipse x:Name="buttonSurface" Fill="LightBlue"/>
        <Label x:Name="buttonCaption"
        VerticalAlignment="Center"
        HorizontalAlignment="Center"
        FontWeight="Bold" FontSize="20" Content="OK!"/>
      </Grid>
    </ControlTemplate>
  </Button.Template>
</Button>

这里,您已经定义了一个模板,它由一个名为Grid的控件组成,该控件包含一个名为Ellipse的控件和一个名为Label的控件。因为您的Grid没有已定义的行或列,所以每个子控件都堆叠在前一个控件的顶部,使内容居中。如果您现在运行您的应用,您会注意到当鼠标光标在Ellipse的边界内时,Click事件将只触发*!这是 WPF 模板架构的一个很大的特点:你不需要重新计算命中测试、边界检查或者任何其他底层细节。因此,如果你的模板使用了一个Polygon对象来呈现一些奇怪的几何图形,你可以放心,鼠标点击测试的细节是相对于控件的形状,而不是更大的边框。*

模板作为资源

目前,您的模板被嵌入到一个特定的Button控件中,这限制了重用。理想情况下,您应该将模板放在资源字典中,以便可以在项目之间重用圆形按钮模板,或者至少将它移动到应用资源容器中,以便在该项目中重用。让我们通过从Button中剪切模板定义并将其粘贴到App.xaml文件的Application.Resources标签中,将本地Button资源移动到应用级别。添加一个Key和一个TargetType,如下所示:

<Application.Resources>
  <ControlTemplate x:Key="RoundButtonTemplate" TargetType="{x:Type Button}">
    <Grid x:Name="controlLayout">
      <Ellipse x:Name="buttonSurface" Fill="LightBlue"/>
      <Label x:Name="buttonCaption" VerticalAlignment="Center" HorizontalAlignment="Center"
             FontWeight="Bold" FontSize="20" Content="OK!"/>
    </Grid>
  </ControlTemplate>
</Application.Resources>

Button标记更新为以下内容:

<Button x:Name="myButton" Width="100" Height="100"
  Click="myButton_Click"
  Template="{StaticResource RoundButtonTemplate}">
</Button>

现在,因为整个应用都可以使用这个资源,所以只需应用模板就可以定义任意数量的圆形按钮。创建两个额外的Button控件,使用这个模板进行测试(不需要为这些新项目处理Click事件)。

<StackPanel>
  <Button x:Name="myButton" Width="100" Height="100"
    Click="myButton_Click"
    Template="{StaticResource RoundButtonTemplate}"></Button>
  <Button x:Name="myButton2" Width="100" Height="100"
    Template="{StaticResource RoundButtonTemplate}"></Button>
  <Button x:Name="myButton3" Width="100" Height="100"
    Template="{StaticResource RoundButtonTemplate}"></Button>
</StackPanel>

使用触发器整合视觉提示

定义自定义模板时,默认模板的视觉提示也会被删除。例如,默认的 button 模板包含一些标记,这些标记通知控件在某些 UI 事件发生时如何显示,例如当它获得焦点时、用鼠标单击时、启用(或禁用)时等等。用户非常习惯于这种视觉提示,因为它给了控件某种程度的触觉反应。然而,您的RoundButtonTemplate没有定义任何这样的标记,所以无论鼠标活动如何,控件的外观都是相同的。理想情况下,你的控件在被点击时看起来应该有所不同(可能通过颜色变化或阴影),让用户知道视觉状态已经改变。

正如您已经了解到的,这可以通过使用触发器来完成。对于简单的操作,触发器工作得非常好。还有其他方法可以做到这一点,超出了本书的范围,但在 https://docs.microsoft.com/en-us/dotnet/desktop-wpf/themes/how-to-create-apply-template 可以获得更多信息。

举例来说,用下面的标记更新您的RoundButtonTemplate,它添加了两个触发器。第一个将在鼠标停留在表面上时将控件的颜色更改为蓝色,前景色更改为黄色。第二种方法是在通过鼠标按下控件时缩小Grid(以及所有子元素)的大小。

<ControlTemplate x:Key="RoundButtonTemplate" TargetType="Button" >
  <Grid x:Name="controlLayout">
    <Ellipse x:Name="buttonSurface" Fill="LightBlue" />
    <Label x:Name="buttonCaption" Content="OK!"
      FontSize="20" FontWeight="Bold"
      HorizontalAlignment="Center"
      VerticalAlignment="Center" />
  </Grid>
    <ControlTemplate.Triggers>
      <Trigger Property="IsMouseOver" Value="True">
        <Setter TargetName="buttonSurface" Property="Fill"
          Value="Blue"/>
        <Setter TargetName="buttonCaption"
          Property="Foreground" Value="Yellow"/>
      </Trigger>
      <Trigger Property="IsPressed" Value="True">
        <Setter TargetName="controlLayout"
           Property="RenderTransformOrigin" Value="0.5,0.5"/>
        <Setter TargetName="controlLayout"
          Property="RenderTransform">
          <Setter.Value>
            <ScaleTransform ScaleX="0.8" ScaleY="0.8"/>
          </Setter.Value>
        </Setter>
      </Trigger>
  </ControlTemplate.Triggers>
</ControlTemplate>

控件模板的问题是每个按钮看起来和说的都一样。将标记更新为以下内容没有任何效果:

<Button x:Name="myButton" Width="100" Height="100"
  Background="Red" Content="Howdy!" Click="myButton_Click"
  Template="{StaticResource RoundButtonTemplate}" />
<Button x:Name="myButton2" Width="100" Height="100"
  Background="LightGreen" Content="Cancel!" Template="{StaticResource RoundButtonTemplate}" />
<Button x:Name="myButton3" Width="100" Height="100"
  Background="Yellow" Content="Format" Template="{StaticResource RoundButtonTemplate}" />

这是因为控件的默认属性(如BackGroundContent)在模板中被覆盖。要启用它们,必须将它们映射到模板中的相关属性。您可以在构建模板时使用{TemplateBinding}标记扩展来解决这些问题。这允许您使用模板捕获由控件定义的属性设置,并使用它们来设置模板本身中的值。

下面是RoundButtonTemplate的修改版本,它现在使用这个标记扩展将ButtonBackground属性映射到EllipseFill属性;它还确保了ButtonContent确实被传递给了LabelContent属性:

<Ellipse x:Name="buttonSurface" Fill="{TemplateBinding Background}"/>
<Label x:Name="buttonCaption" Content="{TemplateBinding Content}"
  FontSize="20" FontWeight="Bold" HorizontalAlignment="Center"
  VerticalAlignment="Center" />

通过此次更新,您现在可以创建各种颜色和文本值的按钮。图 27-13 显示了 XAML 更新后的结果。

img/340876_10_En_27_Fig13_HTML.jpg

图 27-13。

模板绑定允许值传递给内部控件。

内容演示者的角色

当你设计你的模板时,你使用了一个Label来显示控件的文本值。像Button一样,Label支持一个Content属性。因此,考虑到您对{TemplateBinding}的使用,您可以定义一个包含复杂内容的Button,而不仅仅是一个简单的字符串。

然而,如果您需要将复杂的内容传递给一个没有没有Content属性的模板成员,该怎么办呢?当您想要在模板中定义一个通用的内容显示区域时,您可以使用ContentPresenter类,而不是特定类型的控件(LabelTextBlock)。对于这个例子,没有必要这样做;然而,这里有一些简单的标记说明了如何构建一个使用ContentPresenter的定制模板,以显示使用该模板的控件的Content属性的值:

<!-- This button template will display whatever is set to the Content of the hosting button. -->
<ControlTemplate x:Key="NewRoundButtonTemplate" TargetType="Button">
  <Grid>
    <Ellipse Fill="{TemplateBinding Background}"/>
    <ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
  </Grid>
</ControlTemplate>

将模板并入样式

目前,你的模板仅仅定义了Button控件的基本外观。但是,建立控件的基本属性(内容、字体大小、字体粗细等)的过程。)是Button本身的责任。

<!-- Currently the Button must set basic property values, not the template. -->
<Button x:Name="myButton" Foreground="Black" FontSize="20"
  FontWeight="Bold"
  Template="{StaticResource RoundButtonTemplate}"
  Click="myButton_Click"/>

如果您愿意,您可以在模板中建立这些值*。通过这样做,您可以有效地创建默认的外观。你可能已经意识到了,这是 WPF·斯泰尔斯的工作。当您构建一个样式时(考虑到基本的属性设置),您可以在样式中定义一个模板!这是您在App.xaml的应用资源中更新的应用资源,它已被重设密钥为RoundButtonStyle:*

<!-- A style containing a template. -->
<Style x:Key="RoundButtonStyle" TargetType="Button">
  <Setter Property="Foreground" Value="Black"/>
  <Setter Property="FontSize" Value="14"/>
  <Setter Property="FontWeight" Value="Bold"/>
  <Setter Property="Width" Value="100"/>
  <Setter Property="Height" Value="100"/>
  <!-- Here is the template! -->
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="Button">
          <!-- Control template from above example -->
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

有了这次更新,您现在可以通过设置Style属性来创建按钮控件,如下所示:

<Button x:Name="myButton" Background="Red" Content="Howdy!"
        Click="myButton_Click" Style="{StaticResource RoundButtonStyle}"/>

虽然按钮的呈现和行为是相同的,但是在样式中嵌套模板的好处是可以为公共属性提供一组固定的值。这就概括了如何使用 Visual Studio 和触发器框架为控件构建自定义模板。虽然关于 Windows Presentation Foundation API 还有很多内容没有在这里讨论,但是您应该已经为进一步的学习打下了坚实的基础。

摘要

本章第一部分考察了 WPF 的资源管理体系。您从如何使用二进制资源开始,然后研究了对象资源的角色。正如您所了解的,对象资源被命名为 XAML 的 blobs,可以存储在不同的位置以重用内容。

接下来,你学习了 WPF 的动画框架。在这里,你有机会用 C# 代码和 XAML 制作一些动画。您了解到,如果您在标记中定义动画,您将使用<Storyboard>元素和触发器来控制执行。然后您看到了 WPF 风格的机制,它大量使用图形、对象资源和动画。

您检查了逻辑树视觉树之间的关系。逻辑树基本上是您为描述 WPF 根元素而创作的标记的一一对应关系。在这个逻辑树的后面是一个更深的可视化树,它包含了详细的渲染指令。

然后检查了默认模板的作用。请记住,当您构建自定义模板时,您实际上是将控件的可视化树全部(或部分)取出,并用您自己的自定义实现替换它。*

二十八、WPF 通知、验证、命令和 MVVM

本章将通过介绍支持模型-视图-视图模型(MVVM)模式的功能来结束您对 WPF 编程模型的研究。第一部分介绍了模型-视图-视图模型模式。接下来,您将了解 WPF 通知系统及其通过可观察模型和可观察集合实现的可观察模式。让 UI 中的数据准确地描述数据的当前状态可以自动显著改善用户体验,并减少在旧技术(如 WinForms)中实现相同结果所需的手动编码。

在可观察模式的基础上,您将研究向应用中添加验证的机制。验证是任何应用的一个重要部分——不仅让用户知道有什么地方出错了,还让他们知道什么地方出错了。为了通知用户错误是什么,您还将学习如何将验证合并到视图标记中。

接下来,您将更深入地研究 WPF 命令系统,并创建自定义命令来封装程序逻辑,就像您在第二十五章中使用内置命令一样。创建定制命令有几个优点,包括(但不限于)支持代码重用、逻辑封装和关注点分离。

最后,您将在一个示例 MVVM 应用中将所有这些结合在一起。

介绍模型-视图-视图模型

在深入研究 WPF 中的通知、验证和命令之前,最好理解一下本章的最终目标,即模型-视图-视图模型模式(MVVM)。MVVM 源自马丁·福勒的表示模型模式,它利用了本章中讨论的 XAML 特有的能力,使你的 WPF 开发更快更干净。名称本身描述了模式的主要组成部分:模型、视图、视图模型。

模型

模型是数据的对象表示。在 MVVM,模型在概念上与来自数据访问层(DAL)的模型相同。有时候是同一个物理班,但是这个没有要求。当你阅读这一章时,你将学会如何决定你是否可以使用你的 DAL 模型或者你是否需要创建新的模型。

模型通常通过数据注释和INotifyDataErrorInfo接口利用内置(或自定义)验证,并被配置为可观察的,以与 WPF 通知系统相结合。在本章的后面你会看到所有这些。

景色

视图是应用的 UI,它被设计得非常轻量级。想想免下车餐馆的菜单板。该板显示菜单项和价格,并且它有一个机制,以便用户可以与后端系统通信。然而,该电路板没有内置任何智能,除非它是专门的用户界面逻辑,例如在天黑时开灯。

应该怀着同样的目标发展 MVVM 观点。任何智能都应该内置到应用的其他地方。代码隐藏文件中唯一的代码(例如MainWindow.xaml.cs)应该与操作 UI 直接相关。它不应该基于业务规则或任何需要保留以备将来使用的东西。虽然这不是 MVVM 的主要目标,但是开发良好的 MVVM 应用通常只有很少的代码隐藏。

视图模型

在 WPF 和其他 XAML 技术中,视图模型有两个用途。

  • 视图模型为视图所需的所有数据提供了一站式服务。这并不意味着视图模型负责获取实际数据;相反,它只是一种将数据从数据存储区移动到视图的传输机制。通常,视图和视图模型之间存在一对一的关联,但是存在架构差异,并且您的里程可能会有所不同。

  • 第二项工作是充当视图的控制器。就像菜单板一样,视图模型接受用户的指示,并将该调用转发给相关代码,以确保采取正确的操作。这些代码通常以自定义命令的形式出现。

贫血模型或贫血视图模型

在 WPF 的早期,当开发人员仍然在研究如何最好地实现 MVVM 模式时,有关于在哪里实现验证和可观察模式的重要(有时是激烈的)讨论。一个阵营(贫血模型阵营)认为所有的都应该在视图模型中,因为将这些功能添加到模型中打破了关注点的分离。另一个阵营(贫血视图模型阵营)认为应该全部放在模型中,因为这样可以减少代码的重复。

真正的答案当然是视情况而定。当INotifyPropertyChangedIDataErrorInfoINotifyDataErrorInfo在模型类上实现时,这确保了相关代码接近代码的目标(正如你将在本章中看到的),并且对于每个模型只实现一次。也就是说,有时候你的view model类本身也需要被开发成可观察的。最终,您需要确定什么对您的应用最有意义,而不会使您的代码过于复杂或牺牲 MVVM 的好处。

Note

有多种 MVVM 框架可用于 WPF,如 MVVMLite、Caliburn。Micro 和 Prism(尽管 Prism 不仅仅是一个 MVVM 框架)。本章讨论 MVVM 模式和 WPF 支持实现该模式的特性。读者朋友们,我让你们来研究不同的框架,并选择最符合你的应用需求的框架。

WPF 约束通知系统

WinForms 绑定系统的一个显著缺点是缺少通知。如果视图中表示的数据是以编程方式更新的,则 UI 也必须以编程方式刷新,以使它们保持同步。这导致了对控件上的Refresh()的大量调用,为了安全起见,通常比绝对必要的调用更多。虽然包含太多对Refresh()的调用通常不是一个严重的性能问题,但是如果没有包含足够多的调用,用户的体验可能会受到负面影响。

内置于基于 XAML 的应用中的绑定系统纠正了这个问题,它使您能够将数据对象和集合作为可观察对象开发到通知系统中。每当一个属性的值在一个可观察的模型上改变或者集合在一个可观察的集合上改变(例如,项目被添加、删除或者重新排序),一个事件被引发(或者NotifyPropertyChanged或者NotifyCollectionChanged)。绑定框架自动侦听这些事件的发生,并在它们触发时更新绑定的控件。更好的是,作为开发人员,您可以控制哪些属性会引发通知。听起来很完美,对吧?嗯,这不是完全完美。如果您全部手动完成,那么为可观察的模型设置这个过程会涉及到相当多的代码。幸运的是,有一个开源框架使它变得更简单,您很快就会看到这一点。

可观察模型和集合

在本节中,您将创建一个使用可观察模型和集合的应用。首先,创建一个名为 WpfNotifications 的新 WPF 应用。该应用将是一个主从表单,允许用户使用ComboBox选择特定的汽车,然后该汽车的详细信息将显示在下面的TextBox控件中。通过用以下标记替换默认网格来更新MainWindow.xaml:

<Grid IsSharedSizeScope="True" Margin="5,0,5,5">
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="Auto"/>
  </Grid.RowDefinitions>
  <Grid Grid.Row="0">
    <Grid.ColumnDefinitions>
      <ColumnDefinition Width="Auto"
        SharedSizeGroup="CarLabels"/>
      <ColumnDefinition Width="*"/>
    </Grid.ColumnDefinitions>
    <Label Grid.Column="0" Content="Vehicle"/>
    <ComboBox Name="cboCars"  Grid.Column="1"
      DisplayMemberPath="PetName" />
</Grid>
<Grid Grid.Row="1" Name="DetailsGrid">
  <Grid.ColumnDefinitions>
    <ColumnDefinition Width="Auto"
      SharedSizeGroup="CarLabels"/>
    <ColumnDefinition Width="*"/>
  </Grid.ColumnDefinitions>
  <Grid.RowDefinitions>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="Auto"/>
    <RowDefinition Height="Auto"/>
  </Grid.RowDefinitions>
  <Label Grid.Column="0" Grid.Row="0" Content="Id"/>
  <TextBox Grid.Column="1" Grid.Row="0" />
  <Label Grid.Column="0" Grid.Row="1" Content="Make"/>
  <TextBox Grid.Column="1" Grid.Row="1" />
  <Label Grid.Column="0" Grid.Row="2" Content="Color"/>
  <TextBox Grid.Column="1" Grid.Row="2" />
  <Label Grid.Column="0" Grid.Row="3" Content="Pet Name"/>
  <TextBox Grid.Column="1" Grid.Row="3" />
  <StackPanel Grid.Column="0" Grid.ColumnSpan="2" Grid.Row="4"
       HorizontalAlignment="Right" Orientation="Horizontal" Margin="0,5,0,5">
    <Button x:Name="btnAddCar" Content="Add Car" Margin="5,0,5,0" Padding="4, 2" />
    <Button x:Name="btnChangeColor" Content="Change Color" Margin="5,0,5,0"
       Padding="4, 2"/>
  </StackPanel>
  </Grid>
</Grid>

您的窗口将类似于图 28-1 。

img/340876_10_En_28_Fig1_HTML.jpg

图 28-1。

显示汽车详细信息的主-详细信息窗口

Grid控件上的IsSharedSizeScope标签设置子网格来共享维度。标有SharedSizeGroupColumnDefinitions将自动调整到相同的宽度,无需任何编程。在这个例子中,如果Pet Name标签变得更长,那么Vehicle列(在另一个Grid控件中)的大小将与之匹配,保持窗口的外观整洁。

接下来,在解决方案资源管理器中右键单击项目名称,选择添加➤新文件夹,并将文件夹命名为Models。在这个新文件夹中,创建一个名为Car的类。这里列出了初始类:

public class Car
{
  public int Id { get; set; }
  public string Make { get; set; }
  public string Color { get; set; }
  public string PetName { get; set; }
}

添加绑定和数据

下一步是为控件添加绑定语句。请记住,数据绑定语句围绕数据上下文,这可以在控件本身或父控件上设置。这里,您将在DetailsGrid上设置上下文,因此包含的每个控件都将继承该数据上下文。将DataContext设置为ComboBoxSelectedItem属性。将保存细节控件的Grid更新为以下内容:

<Grid Grid.Row="1" Name="DetailsGrid"
  DataContext="{Binding ElementName=cboCars, Path=SelectedItem}">

DetailsGrid中的文本框将显示所选汽车的个别属性。向TextBox控件添加适当的文本属性和相关绑定,如下所示:

<TextBox Grid.Column="1" Grid.Row="0" Text="{Binding Path=Id}" />
<TextBox Grid.Column="1" Grid.Row="1" Text="{Binding Path=Make}" />
<TextBox Grid.Column="1" Grid.Row="2" Text="{Binding Path=Color}" />
<TextBox Grid.Column="1" Grid.Row="3" Text="{Binding Path=PetName}" />

最后,将数据添加到ComboBox中。在MainWindow.xaml.cs中,创建一个新的Car记录列表,并将ComboBoxItemsSource设置到列表中。此外,为Notifications.Models名称空间添加using语句。

using WpfNotifications.Models;
//omitted for brevity
public partial class MainWindow : Window
{
  readonly IList<Car> _cars = new List<Car>();
  public MainWindow()
  {
    InitializeComponent();
    _cars.Add(new Car {Id = 1, Color = "Blue", Make = "Chevy", PetName = "Kit"});
    _cars.Add(new Car {Id = 2, Color = "Red", Make = "Ford", PetName = "Red Rider"});
    cboCars.ItemsSource = _cars;
    }
}

运行应用。您将看到车辆选择器有两辆车可供选择。选择其中一个,文本框将自动填充车辆详细信息。更改其中一辆车的颜色,选择另一辆车,然后返回到您编辑的车辆。你会看到新的颜色确实仍然附着在车辆上。这没什么了不起的。在前面的例子中,您已经看到了 XAML 数据绑定的强大功能。

以编程方式更改车辆数据

虽然前面的例子像预期的那样工作,但是如果数据以编程方式改变,用户界面将而不是反映这些变化,除非你编写应用来刷新数据。为了演示这一点,为btnChangeColor Button添加一个事件处理程序,如下所示:

<Button x:Name="btnChangeColor" Content="Change Color" Margin="5,0,5,0"
    Padding="4, 2" Click="BtnChangeColor_OnClick"/>

BtnChangeColor_Click()事件处理程序中,使用ComboBoxSelectedItem属性从汽车列表中定位选中的记录,并将颜色改为Pink。代码如下所示:

private void BtnChangeColor_OnClick(object sender, RoutedEventArgs e)
{
  _cars.First(x => x.Id == ((Car)cboCars.SelectedItem)?.Id).Color = "Pink";
}

运行应用,选择一辆车,然后单击“改变颜色”按钮。没有明显的变化。选择另一辆车,然后回到最初选择的车。现在您将看到更新后的值。这对用户来说不是一个好的体验!

现在给btnAddCar按钮添加一个事件处理程序,如下所示:

<Button x:Name="btnAddCar" Content="Add Car" Margin="5,0,5,0" Padding="4, 2"
  Click="BtnAddCar_OnClick" />

BtnAddCar_Click事件处理程序中,向Car列表添加一条新记录。

private void BtnAddCar_Click(object sender, RoutedEventArgs e)
{
  var maxCount = _cars?.Max(x => x.Id) ?? 0;
  _cars?.Add(new Car { Id=++maxCount,Color="Yellow",Make="VW",PetName="Birdie"});
}

运行应用,点击 Add Car 按钮,检查ComboBox的内容。尽管您知道列表中有三辆汽车,但只显示了两辆!为了纠正这两个问题,您将把Car类转换成一个可观察的模型,并使用一个可观察的集合来保存所有的Car实例。

可观测模型

通过在您的Car模型类上实现INotifyPropertyChanged接口,解决了您的模型属性上的数据更改和不在 UI 中显示的问题。INotifyPropertyChanged界面包含一个单独的事件:PropertyChangedEvent。XAML 绑定引擎为实现INotifyPropertyChanged接口的类上的每个绑定属性监听该事件。界面如下所示:

public interface INotifyPropertyChanged
{
  event PropertyChangedEventHandler PropertyChanged;
}

将以下using语句添加到Car.cs类中:

using System.ComponentModel;
using System.Runtime.CompilerServices;

接下来,在类上实现INotifyPropertyChanged接口,如下所示:

public class Car : INotifyPropertyChanged
{
  //Omitted for brevity
  public event PropertyChangedEventHandler PropertyChanged;
}

PropertyChanged事件接受一个对象引用和一个PropertyChangedEventArgs类的新实例,如下例所示:

PropertyChanged?.Invoke(this,
  new PropertyChangedEventArgs("Model"));

第一个参数是引发事件的对象实例。PropertyChangedEventArgs构造函数接受一个字符串,该字符串表示属性已被更改,需要更新。当引发事件时,绑定引擎在该实例上查找绑定到命名属性的任何控件。如果将String.Empty传递给PropertyChangedEventArgs,那么实例的所有绑定属性都会更新。

您可以控制在自动更新中登记哪些属性。只有那些在 setter 中引发PropertyChanged事件的属性会被自动更新。这通常是模型类的所有属性,但是您可以根据应用的需求选择省略某些属性。一种常见的模式是创建一个帮助器方法(通常名为OnPropertyChanged())来代表属性引发事件,而不是直接在 setter 中为每个登记的属性引发事件,通常是在模型的基类中。将以下方法和代码添加到Car.cs类中:

protected void OnPropertyChanged([CallerMemberName] string propertyName = "")
{
  PropertyChanged?.Invoke(this,
    new PropertyChangedEventArgs(propertyName));
}

接下来,更新Car类中的每个自动属性,使其拥有一个完整的 getter 和 setter 以及一个支持字段。当值改变时,调用OnPropertyChanged()帮助器方法。下面是更新后的Id属性:

private int _id;
public int Id
{
  get => _id;
  set
  {
    if (value == _id) return;
    _id = value;
    OnPropertyChanged();
  }
}

确保对该类中的所有属性执行相同的操作,然后再次运行该应用。选择一辆车并点击“改变颜色”按钮。您将立即看到 UI 中显示的更改。第一个问题解决!

使用名称 of

C# 6 中增加的一个特性是nameof操作符,它提供传递给nameof方法的项目的字符串名称。您可以在 setters 中调用OnPropertyChanged(),就像这样:

public string Color
{
  get { return _color; }
  set
  {
    if (value == _color) return;
    _color = value;
    OnPropertyChanged(nameof(Color));
  }
}

注意,当您使用nameof方法时,您不必从OnPropertyChanged()中移除CallerMemberName属性(尽管它变得没有必要)。最后,是使用nameof方法还是CallerMemberName属性,这取决于个人的选择。

可观察的集合

下一个要解决的问题是当集合的内容改变时更新 UI。这是通过实现INotifyCollectionChanged接口来完成的。像INotifyPropertyChanged接口一样,这个接口公开了一个事件,即CollectionChanged事件。与INotifyPropertyChanged事件不同,手工实现这个接口不仅仅是调用 setter 中的一个方法。您需要创建一个完整的List实现,并在列表发生变化时引发CollectionChanged事件。

使用 ObservableCollections 类

幸运的是,有一种比创建自己的集合类更简单的方法。ObservableCollection<T>类实现了INotifyCollectionChangedINotifyPropertyChangedCollection<T>,它是。NET 核心框架。没有额外的工作!为此,为System.Collections.ObjectModel添加一个using语句,然后将_cars的私有字段更新为:

private readonly IList<Car> _cars =
  new ObservableCollection<Car>();

再次运行应用,然后单击添加汽车按钮。您将看到新记录适当地出现。

实现脏标志

可观测模型的另一个优点是跟踪状态变化的能力。使用 WPF 进行脏跟踪(当一个或多个对象的值发生变化时进行跟踪)相当简单。向Car类添加一个名为IsChangedbool属性。确保像调用Car类中的其他属性一样调用OnPropertyChanged()

private bool _isChanged;
public bool IsChanged {
  get => _isChanged;
  set
  {
    if (value == _isChanged) return;
    _isChanged = value;
    OnPropertyChanged();
  }
}

您需要在OnPropertyChanged()方法中将IsChanged属性设置为true。当IsChanged更新时,你还需要确保你没有将IsChanged设置为true,否则你将遇到堆栈溢出异常!将OnPropertyChanged()方法更新如下(使用前面讨论的nameof方法):

protected virtual void OnPropertyChanged(
  [CallerMemberName] string propertyName = "")
{
  if (propertyName != nameof(IsChanged))
  {
    IsChanged = true;
  }
  PropertyChanged?.Invoke(this,
    new PropertyChangedEventArgs(propertyName));
}

打开MainWindow.xaml并给DetailsGrid增加一个额外的RowDefinition。将以下内容添加到包含一个Label和一个CheckBoxGrid的末尾,绑定到IsChanged属性,如下所示:

<Label Grid.Column="0" Grid.Row="5" Content="Is Changed"/>
<CheckBox Grid.Column="1" Grid.Row="5" VerticalAlignment="Center"
    Margin="10,0,0,0" IsEnabled="False" IsChecked="{Binding Path=IsChanged}" />

如果您现在运行该应用,您会看到每一条记录都显示为已更改,即使您没有更改任何内容!这是因为对象创建会设置属性值,设置任何值都会调用OnPropertyChanged()。这将设置对象的IsChanged属性。要纠正这一点,将IsChanged属性设置为false,作为对象初始化代码中的最后一个属性。打开MainWindow.xaml.cs,将创建列表的代码改为如下:

_cars.Add(
    new Car {Id = 1, Color = "Blue", Make = "Chevy", PetName = "Kit", IsChanged = false});
_cars.Add(
    new Car {Id = 2, Color = "Red", Make = "Ford", PetName = "Red Rider", IsChanged = false});

再次运行应用,选择一辆车,然后单击“更改颜色”按钮。您将看到复选框和更新的颜色一起被选中。

通过 UI 交互更新源代码

您可能会注意到,如果在用户界面中键入文本,“已更改”复选框实际上不会被选中,直到您退出正在编辑的控件。这是因为TextBox绑定上的UpdateSourceTrigger属性。UpdateSourceTrigger决定了什么事件(比如改变值、跳转等等)。)使用户界面更新基础数据。有四种选择,如表 28-1 所示。

表 28-1。

UpdateSourceTrigger

|

成员

|

生命的意义

Default 为控件设置默认值(例如,TextBox控件设置为LostFocus)。
Explicit 仅在调用UpdateSource方法时更新源对象。
LostFocus 当控件失去焦点时更新。这是TextBox控件的默认设置。
PropertyChanged 属性一改变就更新。这是CheckBox控件的默认设置。

TextBox的默认源触发器是LostFocus事件。通过将颜色TextBox的绑定更新为以下 XAML,将其更改为PropertyChanged:

<TextBox Grid.Column="1" Grid.Row="2" Text="{Binding Path=Color, UpdateSourceTrigger=PropertyChanged}" />

现在,当您运行应用并开始在颜色文本框中键入内容时,复选框会立即被选中。你可能会问为什么默认设置为TextBox控件的LostFocus。一个模型的任何确认(稍后介绍)都与UpdateSourceTrigger一起启动。对于TextBox,这可能会导致错误持续闪烁,直到用户输入正确的值。例如,如果验证规则不允许在一个TextBox中少于五个字符,错误将在每次击键时显示,直到用户输入五个或更多。在这些情况下,最好等待用户退出TextBox(在完成对文本的更改之后)来更新源代码。

包装通知和可观察项

对模型使用INotifyPropertyChanged和对列表使用ObservableCollections类可以通过保持数据和 UI 同步来改善用户体验。虽然这两个接口都不复杂,但它们确实需要更新您的代码。幸运的是,微软已经包含了ObservableCollection类来处理创建可观察集合的所有管道。同样幸运的是,Fody 项目的更新自动添加了INotifyPropertyChanged功能。有了这两个工具,没有理由不在您的 WPF 应用中实现 observables。

WPF 验证

既然您已经实现了INotifyPropertyChanged并且正在使用ObservableCollection,那么是时候为您的应用添加验证了。应用需要验证用户输入,并在输入的数据不正确时向用户提供反馈。本节涵盖了现代 WPF 应用最常见的验证机制,但这些仍然只是 WPF 内置功能的一部分。

当数据绑定试图更新数据源时,会发生验证。除了内置验证(如属性 setter 中的异常)之外,您还可以创建自定义验证规则。如果任何验证规则(内置的或定制的)失败,那么Validation类(稍后将讨论)就会发挥作用。

Note

对于本章中的每一节,您可以继续使用上一节中的同一项目,也可以为每个新节创建一个项目副本。在本章的回购中,每个部分都是一个不同的项目。

更新验证示例的样本

在本章的 repo 中,新项目(复制自上一个例子)被称为 WpfValidations。如果您使用的是上一节中的同一个项目,那么在将本节中列出的示例中的代码复制到您的项目中时,您只需要记下名称空间的变化。

验证类

在向项目添加验证之前,理解Validation类很重要。该类是验证框架的一部分,它提供了可用于显示验证结果的方法和附加属性。在处理验证错误时,Validation类有三个常用的主要属性(如表 28-2 所示)。在本节的剩余部分,您将使用其中的每一项。

表 28-2。

Validation班的主要成员

|

成员

|

生命的意义

HasError 附加的属性,指示验证规则在过程中的某个地方失败
Errors 所有活动ValidationError对象的集合
ErrorTemplate HasError设置为true时,变得可见并修饰绑定元素的控制模板

验证选项

如前所述,XAML 技术公司有几种将验证逻辑整合到应用中的机制。在接下来的小节中,您将研究三种最常用的验证选择。

异常时通知

虽然不应该使用异常来实施业务逻辑,但是异常可能并且确实会发生,并且应该适当地处理它们。如果代码中没有处理它们,用户应该会收到问题的视觉反馈。WinForms 的一个重要变化是,默认情况下,WPF 绑定异常不会作为异常传播给用户。但是,它们是使用装饰器(位于控件顶部的可视层)以可视方式指示的。

为了测试这一点,运行应用,从ComboBox中选择一个记录,并清除Id值。因为Id属性被定义为int(不是nullable int),所以需要一个数值。当您跳出Id字段时,绑定框架会向Id属性发送一个空字符串,由于空字符串不能转换为int,setter 中会抛出一个异常。通常,未处理的异常会向用户生成一个消息框,但在这种情况下,不会发生类似的情况。如果查看输出窗口的调试部分,您会看到以下内容:

System.Windows.Data Error: 7 : ConvertBack cannot convert value '' (type 'String'). BindingExpression:Path=Id; DataItem="Car" (HashCode=52579650); target element is 'TextBox' (Name=''); target property is 'Text' (type 'String') FormatException:'System.FormatException: Input string was not in a correct format.

异常的直观显示是控件周围的一个红色细框,如图 28-2 所示。

img/340876_10_En_28_Fig2_HTML.jpg

图 28-2。

默认错误模板

红框是Validation对象的ErrorTemplate属性,充当绑定控件的装饰器。虽然默认的错误装饰器显示确实有一个错误,但是没有任何迹象表明什么是错误的。好消息是ErrorTemplate是完全可定制的,你将在本章后面看到。

IDataErrorInfo

IDataErrorInfo接口为您向模型类添加定制验证提供了一种机制。这个接口直接添加到您的模型(或视图模型)类中,验证代码放在您的模型类中(最好是在分部类中)。这将验证代码集中在您的项目中,与 WinForms 项目形成鲜明对比,后者的验证通常在 UI 本身中完成。

这里显示的IDataErrorInfo接口包含两个属性:一个索引器和一个名为Error的字符串属性。注意,WPF 绑定引擎不使用Error属性。

public interface IDataErrorInfo
{
  string this[string columnName] { get; }
  string Error { get; }
}

您将很快添加Car分部类,但是首先您需要更新Car.cs类并将其标记为分部类。接下来,向Models目录添加另一个名为CarPartial.cs的文件。重命名该类Car,确保该类标记为partial,并添加IDataErrorInfo接口。最后,实现接口的 API。初始代码如下所示:

public partial class Car : IDataErrorInfo
{
  public string this[string columnName] => string.Empty;
  public string Error { get;}
}

对于选择加入到IDataErrorInfo接口的绑定控件,它必须将ValidatesOnDataErrors添加到绑定表达式中。将Make文本框的绑定表达式更新如下(并以同样的方式更新其余的绑定语句):

<TextBox Grid.Column="1" Grid.Row="1" Text="{Binding Path=Make, ValidatesOnDataErrors=True}" />

一旦对绑定语句进行了更新,模型上的索引器就会在每次引发PropertyChanged事件时被调用。事件的属性名被用作索引器中的columnName参数。如果索引器返回string.Empty,那么框架假设所有的验证都通过了,并且不存在错误情况。如果索引器返回除string.Empty之外的任何内容,则认为该对象实例的属性存在错误,并且绑定到该类的特定实例上正在验证的属性的每个控件都被认为有错误,Validation对象的HasError属性被设置为true,并且为受影响的控件激活ErrorTemplate装饰器。

接下来,您将向CarPartial.cs中的索引器添加一些简单的验证逻辑。验证规则很简单。

  • 如果Make等于ModelT,设置误差等于"Too Old"

  • 如果Make等于ChevyColor等于Pink,则设置误差等于$"{Make}'s don't come in {Color}"

首先为每个属性添加一个switch语句。为了避免在case语句中使用神奇的字符串,您将再次使用nameof方法。如果代码没有通过switch语句,返回string.Empty。接下来,添加验证规则。在适当的case语句中,添加一个基于前面列出的规则的属性值检查。在Make属性的case语句中,首先检查以确保值不是ModelT。如果是,则返回错误。如果通过,下一行将调用一个 helper 方法,如果违反了第二条规则,它将返回一个错误,否则它将返回string.Empty。在针对Color属性的case语句中,也调用 helper 方法。代码如下:

public string this[string columnName]
{
  get
  {
    switch (columnName)
    {
      case nameof(Id):
        break;
      case nameof(Make):
        return Make == "ModelT"
          ? “Too Old”
          : CheckMakeAndColor();
      case nameof(Color):
        return CheckMakeAndColor();
      case nameof(PetName):
        break;
    }
    return string.Empty;
  }
}

internal string CheckMakeAndColor()
{
  if (Make == "Chevy" && Color == "Pink")
  {
    return $"{Make}'s don't come in {Color}";
  }
  return string.Empty;
}

运行应用,选择红色骑手车辆(福特),并将品牌更改为 ModelT。一旦您跳出该字段,就会出现红色的错误装饰。现在从下拉列表中选择 Kit(这是一辆 Chevy ),并单击 Change Color 按钮将颜色更改为粉红色。红色错误装饰立即出现在颜色字段中,但不会出现在生成文本框中。现在,将 Make 更改为 Ford,跳出文本框,注意红色装饰符没有消失!

这是因为索引器仅在属性的PropertyChanged事件被触发时运行。正如在“WPP 绑定通知系统”一节中所讨论的,当源对象的属性改变时,PropertyChanged事件被触发,这或者通过代码(比如单击改变颜色按钮)或者通过用户交互(时间通过UpdateSourceTrigger来控制)来实现。当您改变颜色时,Make属性没有改变,所以事件没有为Make属性触发。因为事件没有触发,索引器没有被调用,所以对Make属性的验证没有运行。

有两种方法可以解决这个问题。第一个是通过传入string.Empty而不是字段名来更改PropertyChangedEventArgs以更新每个绑定属性。如前所述,这会导致绑定引擎更新该实例上的每个属性的*。像这样更新Car.cs类中的OnPropertyChanged()方法:*

protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = "")
{
  if (propertyName != nameof(IsChanged))
  {
    IsChanged = true;
  }
  //PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
  PropertyChanged?.Invoke(this,
    new PropertyChangedEventArgs(string.Empty));
}

现在,当您运行相同的测试时,您会看到当 Make 和 Color 文本框中的一个被更新时,它们都用错误模板进行了修饰。那么,为什么不总是以这种方式引发事件呢?很大程度上是性能问题。刷新一个对象的所有属性可能会降低性能。当然,不测试是无法知道的,你的里程可能(很可能)会有所不同。

另一个解决方案是当一个字段发生变化时,为其他依赖字段引发PropertyChanged事件。使用这种机制的缺点是,你(或其他支持你的应用的开发者)必须知道MakeColor属性是通过验证码联系在一起的。

INotifyDataErrorInfo

中引入的INotifyDataErrorInfo接口。NET 4.5 建立在IDataErrorInfo接口的基础上,并增加了额外的验证功能。当然,随着额外的功率而来的是额外的工作!与您必须特别选择的先前的验证技术相比,这是一个巨大的转变,ValidatesOnNotifyDataErrors绑定属性默认为true,因此将该属性添加到您的绑定语句是可选的。

INotifyDataErrorInfo接口非常小,但是确实需要大量的管道代码来使其有效,您很快就会看到这一点。界面如下所示:

public interface INotifyDataErrorInfo
{
  bool HasErrors { get; }
  event EventHandler<DataErrorsChangedEventArgs>
    ErrorsChanged;
  IEnumerable GetErrors(string propertyName);
}

绑定引擎使用HasErrors属性来确定实例的任何属性上是否有任何错误。如果使用 null 或空字符串调用GetErrors()方法的propertyName参数,它将返回实例中存在的所有错误。如果一个propertyName被传递到方法中,那么只返回特定属性的错误。ErrorsChanged事件(类似于PropertyChangedCollectionChanged事件)通知绑定引擎更新当前错误列表的 UI。

实现支持代码

在实现INotifyDataErrorInfo的时候,大部分代码通常会被推送到一个基础模型类中,所以只需要编写一次。从在CarPartial.cs类中用INotifyDataErrorInfo替换IDataErrorInfo开始,并添加接口成员(你可以把来自IDataErrorInfo的代码留在类中;您稍后将更新此内容)。

public partial class Car: INotifyDataErrorInfo, IDataErrorInfo
{
...
public IEnumerable GetErrors(string propertyName)
{
  throw new NotImplementedException();
}

public bool HasErrors { get; }
public event
  EventHandler<DataErrorsChangedEventArgs> ErrorsChanged;
}

接下来,添加一个Dictionary<string,List<string>>来保存按属性名分组的任何错误。您还需要为System.Collections.Generic添加一个using语句。两者都显示在这里:

using System.Collections.Generic;
private readonly Dictionary<string,List<string>> _errors
  = new Dictionary<string, List<string>>();

如果字典中有任何错误,HasErrors属性应该返回true。这很容易实现,如下所示:

public bool HasErrors => _errors.Any();

接下来,创建一个 helper 方法来引发ErrorsChanged事件(就像引发PropertyChanged事件一样),如下所示:

private void OnErrorsChanged(string propertyName)
{
  ErrorsChanged?.Invoke(this,
    new DataErrorsChangedEventArgs(propertyName));
}

如前所述,如果参数为空,那么GetErrors()方法应该返回字典中的所有错误。如果传入一个propertyName值,它将返回为该属性找到的任何错误。如果参数不匹配(或者属性没有任何错误),那么该方法将返回 null。

public IEnumerable GetErrors(string propertyName)
{
  if (string.IsNullOrEmpty(propertyName))
  {
    return _errors.Values;
  }
  return _errors.ContainsKey(propertyName)
    ? _errors[propertyName]
    : null;
}

最后一组助手将为一个属性添加一个或多个错误,或者清除一个属性(或所有属性)的所有错误。每当字典改变时,记得调用OnErrorsChanged() helper 方法。

private void AddError(string propertyName, string error)
{
  AddErrors(propertyName, new List<string> { error });
}
private void AddErrors(
  string propertyName, IList<string> errors)
{
  if (errors == null || !errors.Any())
  {
    return;
  }
  var changed = false;
  if (!_errors.ContainsKey(propertyName))
  {
    _errors.Add(propertyName, new List<string>());
    changed = true;
  }
  foreach (var err in errors)
  {
    if (_errors[propertyName].Contains(err)) continue;
    _errors[propertyName].Add(err);
    changed = true;
  }
  if (changed)
  {
    OnErrorsChanged(propertyName);
  }
}
protected void ClearErrors(string propertyName = "")
{
  if (string.IsNullOrEmpty(propertyName))
  {
    _errors.Clear();
  }
  else
  {
    _errors.Remove(propertyName);
  }
  OnErrorsChanged(propertyName);
}

现在的问题是“这个代码是如何被激活的?”绑定引擎监听ErrorsChanged事件,如果绑定语句的错误集合发生变化,它将更新 UI。但是验证代码仍然需要一个触发器来执行。对此有两种机制,它们将在下面讨论。

使用 INotifyDataErrorInfo 进行验证

检查错误的一个地方是属性设置器,如下例所示,简化为只检查ModelT验证:

public string Make
{
  get { return _make; }
  set
  {
    if (value == _make) return;
    _make = value;
    if (Make == "ModelT")
    {
      AddError(nameof(Make), "Too Old");
    }
    else
    {
      ClearErrors(nameof(Make));
    }
    OnPropertyChanged(nameof(Make));
    OnPropertyChanged(nameof(Color));
  }
}

这种方法的主要问题是,您必须将验证逻辑与属性设置器结合起来,这使得代码更难阅读和支持。

将 idataerrorinfo 与 inotifydataerrorinfo 结合起来进行验证

在上一节中,您看到了可以将IDataErrorInfo添加到分部类中,这意味着您不必更新 setters。您还看到,当属性上的PropertyChanged被引发时,索引器会自动被调用。结合IDataErrorInfoINotifyDataErrorInfo为您提供了来自INotifyDataErrorInfo的额外验证特性,以及IDataErrorInfo提供的与设置器的分离。

使用IDataErrorInfo的目的不是运行验证,而是确保每次在对象上引发PropertyChanged时,利用INotifyDataErrorInfo的验证代码都会被调用。因为没有使用IDataErrorInfo进行验证,所以总是从索引器返回string.Empty。将索引器和CheckMakeAndColor()帮助器方法更新为以下代码:

public string this[string columnName]
{
  get
  {
    ClearErrors(columnName);
    switch (columnName)
    {
      case nameof(Id):
        break;
      case nameof(Make):
        CheckMakeAndColor();
        if (Make == "ModelT")
        {
          AddError(nameof(Make), "Too Old");
          hasError = true;
        }
        break;
      case nameof(Color):
        CheckMakeAndColor();
        break;
      case nameof(PetName):
        break;
    }
    return string.Empty;
  }
}
internal bool CheckMakeAndColor()
{
  if (Make == "Chevy" && Color == "Pink")
  {
    AddError(nameof(Make), $"{Make}'s don't come in {Color}");
    AddError(nameof(Color),
      $"{Make}'s don't come in {Color}");
    return true;
  }
  return false;
}

运行应用,选择雪佛兰,并改变颜色为粉红色。除了品牌和型号文本框周围的红色装饰外,您还会看到整个网格周围的红色框装饰,其中包含了Car细节字段(如图 28-3 所示)。

img/340876_10_En_28_Fig3_HTML.jpg

图 28-3。

更新的错误装饰器

这是使用INotifyDataErrorInfo的另一个好处。除了有错误的控件之外,定义数据上下文的控件也用错误模板装饰。

显示所有错误

Validation类上的Errors属性以ValidationError对象的形式返回特定对象上的所有验证错误。每个ValidationError对象都有一个ErrorContent属性,包含该属性的错误消息列表。这意味着您要显示的错误消息在列表中的这个列表中。为了正确显示它们,您需要创建一个保存显示数据的ListBoxListBox。听起来有点递归,但是一旦看到就有道理了。

首先向DetailsGrid添加另一行,并确保WindowHeight至少为 300。在最后一行添加一个ListBox,将ItemsSource绑定到DetailsGrid,使用Validation.Errors作为路径,如下所示:

<ListBox Grid.Row="6" Grid.Column="0" Grid.ColumnSpan="2"
    ItemsSource="{Binding ElementName=DetailsGrid, Path=(Validation.Errors)}">
</ListBox>

ListBox中添加一个DataTemplate,在DataTemplate中添加一个与ErrorContent属性绑定的ListBox。在这种情况下,每个ListBoxItem的数据上下文是一个ValidationError对象,所以您不需要设置数据上下文,只需要设置路径。将绑定路径设置为ErrorContent,如下所示:

<ListBox.ItemTemplate>
  <DataTemplate>
    <ListBox ItemsSource="{Binding Path=ErrorContent}"/>
  </DataTemplate>
</ListBox.ItemTemplate>

运行应用,选择雪佛兰,并设置颜色为粉红色。您将看到图 28-4 中显示的错误。

img/340876_10_En_28_Fig4_HTML.jpg

图 28-4。

显示错误集合

这仅仅触及了验证和显示生成的错误的表面,但是它应该会让您在开发改善用户体验的信息丰富的 ui 的道路上走得很好。

将支持代码移动到基类

正如您可能注意到的,现在在CarPartial.cs类中有很多代码。因为这个例子只有一个模型类,这并不可怕。但是,当您将模型添加到实际的应用中时,您不希望必须将所有的管道添加到模型的每个分部类中。最好的做法是将所有支持代码下推到一个基类。你现在就去做。

向名为BaseEntity.csModels文件夹添加一个新的类文件。增加System.CollectionsSystem.ComponentModelusing语句。将该类公开,并添加INotifyDataErrorInfo接口,如下所示:

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

namespace Validations.Models
{
  public class BaseEntity : INotifyDataErrorInfo
}

将所有与INofityDataErrorInfo相关的代码从CarPartial.cs移到新的基类中。任何私有方法和变量都需要受到保护。接下来,从CarPartial.cs类中移除INotifyDataErrorInfo接口,并添加BaseEntity作为基类,如下所示:

public partial class Car : BaseEntity, IDataErrorInfo
{
 //removed for brevity
}

现在,您创建的任何额外的模型类都将继承所有的INotifyDataErrorInfo管道代码。

通过 WPF 利用数据注释

WPF 也可以利用数据注释进行 UI 验证。让我们给Car模型添加一些数据注释。

向模型添加数据注释

打开Car.cs,为System.ComponentModel.DataAnnotations添加一条using语句。将[Required][StringLength(50)]属性添加到MakeColorPetName中。Required属性添加了一个验证规则,即属性不能为空(当然,这对Id属性来说是多余的,因为它不是nullable int)。StringLength(50)属性增加了一条验证规则,即属性值不能超过 50 个字符。

检查基于数据注释的验证错误

在 WPF 中,您必须以编程方式检查基于数据注释的验证错误。基于注释的验证的两个关键类是ValidationContextValidator类。ValidationContext类提供了检查类验证错误的上下文。Validator类允许您在ValidationContext中检查对象的基于属性的错误。

打开BaseEntity.cs,添加以下using语句:

using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

接下来,创建一个名为GetErrorsFromAnnotations()的新方法。这个方法是通用的,接受一个字符串属性名和一个类型为T的值作为参数,并返回一个字符串数组。确保该方法被标记为受保护。签名如下所示:

protected string[] GetErrorsFromAnnotations<T>(
  string propertyName, T value)
{}

在该方法中,创建一个保存验证检查结果的List<ValidationResult>变量,并创建一个作用域为传递给该方法的属性名的ValidationContext。当你准备好这两个项目后,调用Validate.TryValidateProperty,它会返回一个bool。如果一切都通过(关于数据注释验证),它返回true。如果不是,它返回false并用错误填充List<ValidationResult>。完整的代码如下所示:

protected string[] GetErrorsFromAnnotations<T>(
  string propertyName, T value)
{
  var results = new List<ValidationResult>();
  var vc = new ValidationContext(this, null, null)
    { MemberName = propertyName };
  var isValid = Validator.TryValidateProperty(
    value, vc, results);
  return (isValid)
    ? null
    : Array.ConvertAll(
        results.ToArray(), o => o.ErrorMessage);
}

现在您可以更新CarPartial.cs中的索引器方法,根据数据注释检查任何错误。如果发现任何错误,将它们添加到支持INotifyDataErrorInfo的错误集合中。这使我们能够清理错误处理。在索引器方法的开始,清除列的错误。然后处理验证,最后是实体的定制逻辑。更新后的索引器代码如下所示:

public string this[string columnName]
{
  get
  {
    ClearErrors(columnName);
    var errorsFromAnnotations =
      GetErrorsFromAnnotations(columnName,
        typeof(Car)
        .GetProperty(columnName)?.GetValue(this,null));
    if (errorsFromAnnotations != null)
    {
      AddErrors(columnName, errorsFromAnnotations);
    }
    switch (columnName)
    {
      case nameof(Id):
        break;
      case nameof(Make):
        CheckMakeAndColor();
        if (Make == "ModelT")
        {
          AddError(nameof(Make), "Too Old");
        }
        break;
      case nameof(Color):
        CheckMakeAndColor();
        break;
      case nameof(PetName):
        break;
    }
    return string.Empty;
  }
}

运行应用,选择其中一辆车,并为颜色添加超过 50 个字符的文本。当超过 50 个字符的阈值时,StringLength数据注释会创建一个验证错误,并报告给用户,如图 28-5 所示。

img/340876_10_En_28_Fig5_HTML.jpg

图 28-5。

验证所需的数据注释

自定义错误模板

最后一个主题是创建一个在控件出错时应用的样式,并更新ErrorTemplate以显示更有意义的错误信息。正如你在第二十七章中学到的,控件可以通过样式和控件模板来定制。

首先在目标类型为TextBoxMainWindow.xamlWindows.Resources部分添加一个新样式。接下来,当Validation.HasError属性被设置为true时,在设置属性的样式上添加一个触发器。要设置的属性和值是Background ( Pink)、Foreground ( Black)和TooltipErrorContentBackgroundForeground设置器并不新鲜,但是设置ToolTip的语法需要一些解释。绑定指向应用该样式的控件,在本例中是TextBox。该路径是Validation.Errors集合的第一个ErrorContent值。标记如下所示:

<Window.Resources>
  <Style TargetType="{x:Type TextBox}">
    <Style.Triggers>
      <Trigger Property="Validation.HasError" Value="true">
        <Setter Property="Background" Value="Pink" />
        <Setter Property="Foreground" Value="Black" />
        <Setter Property="ToolTip"
            Value="{Binding RelativeSource={RelativeSource Self},
            Path=(Validation.Errors)[0].ErrorContent}"/>
      </Trigger>
    </Style.Triggers>
  </Style>
</Window.Resources>

运行应用并创建一个错误条件。结果将类似于图 28-6 ,并带有显示错误信息的工具提示。

img/340876_10_En_28_Fig6_HTML.jpg

图 28-6。

显示自定义错误模板

以前的样式改变了任何有错误条件的TextBox的外观。接下来,您将创建一个定制的控件模板来更新Validation类的ErrorTemplate以显示一个红色的感叹号,并为感叹号设置工具提示。ErrorTemplate是一个装饰器,它位于控件的顶部。当刚刚创建的样式更新控件本身时,ErrorTemplate将位于控件之上。

在刚刚创建的样式中,在Style.Triggers结束标记之后立即放置一个 setter。您将创建一个控制模板,它由一个TextBlock(显示感叹号)和一个BorderBrush组成,包围包含错误的TextBox。在 XAML 有一个特殊的标签,这个标签上装饰着名为AdornedElementPlaceholderErrorTemplate。通过向该控件添加名称,可以访问与该控件相关联的错误。在这个例子中,您想要访问Validation.Errors属性,这样您就可以获得ErrorContent(就像您在Style.Trigger中所做的那样)。以下是 setter 的完整标记:

<Setter Property="Validation.ErrorTemplate">
  <Setter.Value>
    <ControlTemplate>
      <DockPanel LastChildFill="True">
        <TextBlock Foreground="Red" FontSize="20" Text="!"
          ToolTip="{Binding ElementName=controlWithError,
          Path=AdornedElement.(Validation.Errors)[0].ErrorContent}"/>
        <Border BorderBrush="Red" BorderThickness="1">
          <AdornedElementPlaceholder Name="controlWithError" />
        </Border>
      </DockPanel>
    </ControlTemplate>
  </Setter.Value>
</Setter>

运行应用并创建一个错误条件。结果将类似于图 28-7 。

img/340876_10_En_28_Fig7_HTML.jpg

图 28-7。

显示自定义错误模板

完成验证

这就完成了您对 WPF 验证方法的了解。当然,你还可以做更多的事情。有关更多信息,请参考 WPF 文档。

创建自定义命令

与验证部分一样,您可以继续在同一个项目中工作,或者创建一个新项目并将所有代码复制到其中。我将创建一个名为 WpfCommands 的新项目。如果您正在使用同一个项目,请务必注意本节代码示例中的名称空间,并根据需要进行调整。

正如你在第二十五章中了解到的,命令是 WPF 不可或缺的一部分。命令可以挂接到 WPF 控件(比如ButtonMenuItem控件)来处理用户事件,比如Click()事件。不是直接创建事件处理程序并将代码直接添加到代码隐藏文件中,而是在 click 事件触发时执行命令的Execute()方法。CanExecute()方法用于根据自定义代码启用或禁用控件。除了您在第二十五章中使用的内置命令之外,您还可以通过实现ICommand接口来创建自己的定制命令。通过使用命令而不是事件处理程序,您获得了封装应用代码以及基于业务逻辑自动启用和禁用控件的好处。

实现 ICommand 接口

作为对第二十五章的快速回顾,ICommand界面如下所示:

public interface ICommand
{
  event EventHandler CanExecuteChanged;
  bool CanExecute(object parameter);
  void Execute(object parameter);
}

添加 ChangeColorCommand 命令

从改变颜色按钮开始,Button控件的事件处理程序将被替换为命令。首先在项目中添加一个新文件夹(名为Cmds)。添加一个名为ChangeColorCommand.cs的新类。公开类,实现ICommand接口。添加下面的using语句(第一个可能会有所不同,这取决于您是否为这个示例创建了一个新项目):

using WpfCommands.Models;
using System.Windows.Input;

您的类应该是这样的:

public class ChangeColorCommand : ICommand
{
  public bool CanExecute(object parameter)
  {
    throw new NotImplementedException();
  }
  public void Execute(object parameter)
  {
    throw new NotImplementedException();
  }
  public event EventHandler CanExecuteChanged;
}

如果CanExecute()方法返回true,任何绑定控件都将被启用,如果它返回false,它们将被禁用。如果一个控件被启用(因为CanExecute()返回true)并被点击,那么Execute()方法将被触发。传递给这两个方法的参数来自基于绑定语句上设置的CommandParameter属性的 UI。CanExecuteChanged事件绑定到绑定和通知系统,通知 UICanExecute()方法的结果已经改变(很像PropertyChanged事件)。

在本例中,只有当参数不为空并且类型为Car时,更改颜色按钮才起作用。将CanExecute()方法更新如下:

public bool CanExecute(object parameter)
  => (parameter as Car) != null;

Execute()方法参数的值与CanExecute()方法的值相同。由于Execute()方法只能在对象类型为Car时执行,参数必须转换为Car类型并更新颜色,如下所示:

public void Execute(object parameter)
{
  ((Car)parameter).Color="Pink";
}

将命令附加到 CommandManager

命令类的最后一个更新是在命令管理器中键入命令。当Window第一次加载,然后当命令管理器指示它重新执行时,CanExecute()方法触发。每个命令类都必须选择加入命令管理器。这是通过更新关于CanExecuteChanged事件的代码来完成的,如下所示:

public event EventHandler CanExecuteChanged
{
  add => CommandManager.RequerySuggested += value;
  remove => CommandManager.RequerySuggested -= value;
}

更新 MainWindow.xaml.cs

下一个变化是创建这个类的一个实例,Button可以访问它。现在,您将把它放在MainWindow的代码隐藏文件中(在本章的后面,您将把它移到一个view model)。打开MainWindow.xaml.cs并删除改变颜色按钮的Click事件处理程序。将下面的using语句添加到文件的顶部(同样,命名空间可能会根据您是仍在使用同一个项目还是开始了一个新项目而有所不同):

using WpfCommands.Cmds;
using System.Windows.Input;

接下来,添加一个名为ChangeColorCmd的公共属性,类型为ICommand,带有一个支持字段。在属性的表达式体中,返回支持属性(如果支持字段为空,确保实例化一个新的ChangeColorCommand实例)。

private ICommand _changeColorCommand = null;
public ICommand ChangeColorCmd
  => _changeColorCommand ??= new ChangeColorCommand());

更新 MainWindow.xaml

正如你在第二十五章中看到的,WPF 中的可点击控件(像Button控件)有一个Command属性,允许你给控件分配一个命令对象。首先,将代码隐藏中实例化的命令连接到btnChangeColor按钮。因为命令的属性在MainWindow类上,所以使用RelativeSourceMode绑定语法来访问包含ButtonWindow,如下所示:

Command="{Binding Path=ChangeColorCmd,
  RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"

Button仍然需要发送一个Car对象作为CanExecute()Execute()方法的参数。这是通过CommandParameter房产转让的。您将此设置为cboCars ComboBoxSelectedItem,如下所示:

CommandParameter="{Binding ElementName=cboCars, Path=SelectedItem}"

按钮的完整标记如下所示:

<Button x:Name="btnChangeColor" Content="Change Color" Margin="5,0,5,0"
    Padding="4, 2" Command="{Binding Path=ChangeColorCmd,
        RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"
    CommandParameter="{Binding ElementName=cboCars, Path=SelectedItem}"/>

测试应用

运行应用。你会看到变色命令是而不是被激活,如图 28-8 所示,因为没有选择车辆。

img/340876_10_En_28_Fig8_HTML.jpg

图 28-8。

未选择任何内容的窗口

现在,选择一辆车;该按钮将被启用,点击它将改变颜色,正如所料!

创建 commandbars 类

如果对AddCarCommand.cs继续使用这种模式,将会有代码在类之间重复。这是一个好迹象,表明基类可以提供帮助。在Cmds文件夹中创建一个名为CommandBase.cs的新类,并为System.Windows.Input名称空间添加一个using。将类设置为 public 并实现ICommand接口。将类和Execute()CanExecute()方法改为抽象。最后,添加来自ChangeColorCommand类的更新后的CanExecuteChanged事件。下面列出了完整的实现:

using System;
using System.Windows.Input;

namespace WpfCommands.Cmds
{
  public abstract class CommandBase : ICommand
  {
    public abstract bool CanExecute(object parameter);
    public abstract void Execute(object parameter);
    public event EventHandler CanExecuteChanged
    {
      add => CommandManager.RequerySuggested += value;
      remove => CommandManager.RequerySuggested -= value;
    }
  }
}

添加 AddCarCommand 类

将名为AddCarCommand.cs的新类添加到Cmds文件夹中。将该类公开,并添加CommandBase作为基类。将以下using语句添加到文件的顶部:

using System.Collections.ObjectModel;
using System.Linq;
using WpfCommands.Models;

该参数应该是一个ObservableCollection<Car>,所以在CanExecute()方法中检查以确保这一点。如果是,那么Execute()方法应该添加一辆额外的汽车,就像Click事件处理程序一样。

public class AddCarCommand :CommandBase
{
  public override bool CanExecute(object parameter)
    => parameter is ObservableCollection<Car>;
  public override void Execute(object parameter)
  {
    if (parameter is not ObservableCollection<Car> cars)
    {
      return;
    }
    var maxCount = cars.Max(x => x.Id);
    cars.Add(new Car
    {
      Id = ++maxCount,
      Color = "Yellow",
      Make = "VW",
      PetName = "Birdie"
    });
  }
}

更新 MainWindow.xaml.cs

添加一个名为AddCarCmd的公共属性,类型为ICommand,带有一个支持字段。在属性的表达式体中,返回支持属性(如果支持字段为空,确保实例化一个新的AddCarCommand实例)。

private ICommand _addCarCommand = null;
public ICommand AddCarCmd
  => _addCarCommand ??= new AddCarCommand());

更新 MainWindow.xaml

更新 XAML 以删除Click属性,并添加CommandCommandParameter属性。AddCarCommand将从cboCars组合框中接收汽车列表。整个按钮的 XAML 如下:

<Button x:Name="btnAddCar" Content="Add Car" Margin="5,0,5,0" Padding="4, 2"
  Command="{Binding Path=AddCarCmd,
      RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"
   CommandParameter="{Binding ElementName=cboCars, Path=ItemsSource}"/>

有了这些,您现在可以使用独立类中包含的可重用代码添加汽车和更新汽车的颜色。

正在更新 ChangeColorCommand

最后一步是更新ChangeColorCommand以继承CommandBase。将ICommand改为CommandBase,在两个方法中添加override关键字,删除CanExecuteChanged代码。真的就这么简单!下面列出了新代码:

public class ChangeColorCommand : CommandBase
{
  public override bool CanExecute(object parameter)
    => parameter is Car;
  public override void Execute(object parameter)
  {
    ((Car)parameter).Color = "Pink";
  }
}

中继命令

在 WPF,命令模式的另一个实现是RelayCommand。该模式使用委托来实现ICommand接口,而不是为每个命令创建一个新的类。这是一个轻量级的实现,因为每个命令都没有自己的类。RelayCommand通常在执行命令不需要重用的时候使用。

创建基本继电器命令

通常在两个类中实现。当CanExecute()Execute()方法不需要任何参数时,使用基类RelayCommand,当需要参数时,使用RelayCommand<T>。您将从基本的RelayCommand类开始,它利用了CommandBase类。将名为RelayCommand.cs的新类添加到Cmds文件夹中。将该类公开,并添加CommandBase作为基类。添加两个类级变量来保存Execute()CanExecute()委托。

private readonly Action _execute;
private readonly Func<bool> _canExecute;

创建三个构造函数。第一个是默认构造函数(RelayCommand<T>-派生类需要),第二个是带Action参数的构造函数,第三个是带Action参数和Func参数的构造函数,如下所示:

public RelayCommand(){}
public RelayCommand(Action execute) : this(execute, null) { }
public RelayCommand(Action execute, Func<bool> canExecute)
{
  _execute = execute
    ?? throw new ArgumentNullException(nameof(execute));
  _canExecute = canExecute;
}

最后,实现CanExecute()Execute()覆盖。如果Func为空,则CanExecute()返回true;或者如果不为空,则执行并返回trueExecute()执行Action参数。

public override bool CanExecute(object parameter)
  => _canExecute == null || _canExecute();
public override void Execute(object parameter) { _execute(); }

创建继电器命令

将名为RelayCommandT.cs的新类添加到Cmds文件夹中。这个类几乎是基类的翻版,只是委托都带有一个参数。使该类成为公共的和通用的,并添加RelayCommand作为基类,如下所示:

public class RelayCommand<T> : RelayCommand

添加两个类级变量来保存Execute()CanExecute()委托:

private readonly Action<T> _execute;
private readonly Func<T, bool> _canExecute;

创建两个构造函数。第一个带一个Action<T>参数,第二个带一个Action<T>参数和一个Func<T,bool>参数,如下所示:

public RelayCommand(Action<T> execute):this(execute, null) {}
public RelayCommand(
  Action<T> execute, Func<T, bool> canExecute)
{
  _execute = execute
    ?? throw new ArgumentNullException(nameof(execute));
  _canExecute = canExecute;
}

最后,实现CanExecute()Execute()覆盖。如果Func为空,则CanExecute()返回 true 或者,如果不为空,它执行并返回trueExecute()执行Action参数。

public override bool CanExecute(object parameter)
  => _canExecute == null || _canExecute((T)parameter);
public override void Execute(object parameter)
  { _execute((T)parameter); }

更新 MainWindow.xaml.cs

当您使用RelayCommand s 时,在构造新命令时,需要指定委托的所有方法。这并不意味着代码需要存在于代码隐藏中(如此处所示);它只需要可以从代码隐藏中访问。它可以存在于另一个类(甚至另一个程序集)中,提供创建自定义命令类的代码封装优势。

添加一个类型为RelayCommand<Car>的新私有变量和一个名为DeleteCarCmd的公共属性,如下所示:

private RelayCommand<Car> _deleteCarCommand = null;
public RelayCommand<Car> DeleteCarCmd
  => _deleteCarCommand ??=
     new RelayCommand<Car>(DeleteCar,CanDeleteCar));

还必须创建DeleteCar()CanDeleteCar()方法,如下所示:

private bool CanDeleteCar(Car car) => car != null;
private void DeleteCar(Car car)
{
  _cars.Remove(car);
}

注意方法中的强类型——这是使用RelayCommand<T>的好处之一。

添加和实现删除汽车按钮

最后一步是添加按钮,并分配CommandCommandParameter绑定。添加以下标记:

<Button x:Name="btnDeleteCar" Content="Delete Car" Margin="5,0,5,0" Padding="4, 2"
  Command="{Binding Path=DeleteCarCmd,
      RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"
  CommandParameter="{Binding ElementName=cboCars, Path=SelectedItem}"/>

现在,当您运行应用时,您可以测试只有在下拉列表中选择了一辆汽车时,Delete Car 按钮才被启用,并且单击该按钮确实会从汽车列表中删除该汽车。

包装命令

你在 WPF 司令部的短暂旅程到此结束。通过将事件处理从代码隐藏文件中移出并放到单独的命令类中,您可以获得代码封装、重用和提高可维护性的好处。如果您不需要那么多的关注点分离,您可以使用轻量级的RelayCommand实现。目标是提高可维护性和代码质量,因此选择最适合您的方法。

将代码和数据迁移到视图模型

正如在“WPF 验证”一节中一样,您可以继续在同一个项目中工作,或者您可以创建一个新的项目并复制所有的代码。我将创建一个名为 WpfViewModel 的新项目。如果您正在使用同一个项目,请务必注意本节代码示例中的名称空间,并根据需要进行调整。

在您的项目中创建一个名为ViewModels的新文件夹,并将名为MainWindowViewModel.cs的新类添加到该文件夹中。添加以下命名空间并将该类设为公共类:

using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Windows.Input;
using WpfViewModel.Cmds;
using WpfViewModel.Models;

Note

一个流行的惯例是根据视图模型支持的窗口来命名视图模型。我通常遵循这一惯例,并将在本章中这样做。然而,像任何模式或惯例一样,这不是一条规则,你会发现关于这一点有各种各样的观点。

移动 MainWindow.xaml.cs 代码

代码隐藏文件中的几乎所有代码都将被移动到视图模型中。最后,只有几行代码,包括对InitializeComponent()的调用和将窗口的数据上下文设置为视图模型的代码。

创建一个名为CarsIList<Car>类型的公共属性,如下所示:

public IList<Car> Cars { get; } =
  new ObservableCollection<Car>();

创建一个默认的构造函数,从MainWindow.xaml.cs文件中移走所有的汽车创建代码,更新列表变量名。您也可以从MainWindow.xaml.cs中删除_cars变量。以下是视图模型构造函数:

public MainWindowViewModel()
{
  Cars.Add(
    new Car { Id = 1, Color = "Blue", Make = "Chevy", PetName = "Kit", IsChanged = false });
  Cars.Add(
    new Car { Id = 2, Color = "Red", Make = "Ford", PetName = "Red Rider", IsChanged = false });
}

接下来,将所有与命令相关的代码从窗口代码隐藏文件移动到视图模型,更新对Cars的变量引用_cars。下面是更改后的代码:

//rest omitted for brevity
private void DeleteCar(Car car)
{
  Cars.Remove(car);
}

更新主窗口代码和标记

MainWindow.xaml.cs文件中删除了大部分代码。删除为组合框分配ItemsSource的行,只留下对InitializeComponent的调用。它现在应该是这样的:

public MainWindow()
{
    InitializeComponent();
}

将下面的using语句添加到文件的顶部:

using WpfViewModel.ViewModels;

接下来,创建一个强类型属性来保存视图模型的实例。

public MainWindowViewModel ViewModel { get; set; }
  = new MainWindowViewModel();

最后,在 XAML 的窗口声明中添加一个DataContext属性。

DataContext="{Binding ViewModel, RelativeSource={RelativeSource Self}}"

更新控件标记

既然WindowDataContext被设置为视图模型,控件的 XAML 绑定需要更新。从组合框开始,通过添加一个ItemsSource来更新标记。

<ComboBox Name="cboCars" Grid.Column="1" DisplayMemberPath="PetName" ItemsSource="{Binding Cars}" />

这是因为Window的数据上下文是MainWindowViewModel,而Cars是视图模型的公共属性。回想一下,绑定调用沿着元素树向上走,直到找到数据上下文。接下来,您需要更新Button控件的绑定。这很简单;由于绑定已经设置到窗口级别,您只需要更新绑定语句,从DataContext属性开始,如下所示:

<Button x:Name="btnAddCar" Content="Add Car" Margin="5,0,5,0" Padding="4, 2"
    Command="{Binding Path=DataContext.AddCarCmd,
        RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"
    CommandParameter="{Binding ElementName=cboCars, Path=ItemsSource}"/>
<Button x:Name="btnDeleteCar" Content="Delete Car" Margin="5,0,5,0" Padding="4, 2"
    Command="{Binding Path=DataContext.DeleteCarCmd,
        RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"
    CommandParameter="{Binding ElementName=cboCars, Path=SelectedItem}" />
<Button x:Name="btnChangeColor" Content="Change Color" Margin="5,0,5,0" Padding="4, 2"
    Command="{Binding Path=DataContext.ChangeColorCmd,
        RelativeSource={RelativeSource Mode=FindAncestor, AncestorType={x:Type Window}}}"
    CommandParameter="{Binding ElementName=cboCars, Path=SelectedItem}"/>

包装视图模型

信不信由你,你刚刚完成了你的第一份 MVVM WPF 申请。您可能会想,“这不是真正的应用。数据呢?本例中的数据是硬编码的。你是对的。不是真正的 app 是 demoware。然而,这就是 MVVM 模式的美妙之处。视图不知道数据来自哪里;它只是绑定到视图模型上的一个属性。您可以交换视图模型实现,也许使用一个硬编码数据的版本用于测试,一个命中数据库的版本用于生产。

还有很多可以讨论的地方,包括各种开源框架、视图模型定位器模式,以及关于如何最好地实现该模式的许多不同观点。这就是软件设计模式的美妙之处——通常有许多正确的方法来实现它,然后您只需要根据您的业务和技术需求找到最佳方式。

更新自动 Lot。MVVM 的 Dal

如果你想为 MVVM 更新AutoLot.Dal,你必须将我们为Car类所做的更改应用到自动 Lot 中的所有实体。Dal.Models 项目,包括BaseEntity

摘要

本章研究了支持模型-视图-视图模型模式的 WPF 主题。您已经开始学习如何在绑定管理器中将模型类和集合绑定到通知系统中。您实现了INotifyPropertyChanged并使用了ObservableCollections类来保持 UI 与绑定数据的同步。

接下来,使用IDataErrorInfoINotifyDataErrorInfo向模型添加验证代码,并检查数据注释错误。然后,您在 UI 中显示任何验证错误,以便用户知道问题是什么以及如何修复它,并且您创建了一个样式和自定义控件模板来以有意义的方式呈现错误。

最后,您通过添加一个视图模型将所有这些放在一起,并且您清理了 UI 标记和代码隐藏文件以增加关注点的分离。

二十九、ASP.NET 核心简介

本书的最后一节介绍了 ASP.NET 核心,这是使用 C# 和。NET 核心。这一章介绍了 ASP.NET 核心和对以前版本的 web 开发框架 ASP.NET 的一些改变。

在了解了 ASP.NET 核心中实现的 MVC 模式的基础知识之后,您将开始构建两个能够协同工作的应用。第一个应用,一个 ASP.NET 核心 RESTful 服务,将在第三十章完成。第二个是使用模型-视图-控制器模式的 ASP.NET 核心 web 应用,将在第三十一章中完成。自动手枪。达尔和奥托洛特。你在第二十三章中完成的模型项目将作为两个应用的数据访问层。

快速回顾

微软在 2007 年发布了 ASP.NET MVC,取得了巨大的成功。该框架基于模型-视图-控制器模式,为对 WebForms 感到沮丧的开发人员提供了一个答案,web forms 本质上是 HTTP 上一个有漏洞的抽象。WebForms 的创建是为了帮助客户机-服务器开发人员转移到 Web 上,在这方面它相当成功。然而,随着开发人员越来越习惯于 web 开发,许多人希望对呈现的输出进行更多的控制,消除视图状态,并遵循经过验证的 web 应用设计模式。有了这些目标,ASP.NET MVC 就诞生了。

介绍 MVC 模式

模型-视图-控制器(MVC)模式自 20 世纪 70 年代就已经存在,最初是作为 Smalltalk 中使用的模式而创建的。这种模式最近死灰复燃,用许多不同的语言实现,包括 Java (Spring Framework)、Ruby (Ruby on Rails)和。NET(ASP.NET MVC)。

模型

模型是你的应用的数据。数据通常由普通的旧 CLR 对象(POCOs)表示。视图模型由一个或多个模型组成,并且是专门为数据消费者设计的。考虑模型和视图模型的一种方式是将它们与数据库表和数据库视图相关联。

从学术上讲,模型应该非常干净,不包含验证或任何其他业务规则。实际上,模型是否包含验证逻辑或其他业务规则完全取决于所使用的语言和框架,以及特定的应用需求。例如,EF Core 包含许多数据注释,这些注释既是形成数据库表的机制,也是在 ASP.NET Core web 应用中进行验证的手段。在本书中(以及在我的专业工作中),示例集中在减少代码的重复,这将数据注释和验证放在它们最有意义的地方。

景色

视图是应用的用户界面。视图接受命令并将这些命令的结果呈现给用户。视图应该尽可能的轻量级,并且不实际处理任何工作,而是将所有工作交给控制器。

控制器

控制器是应用的大脑。控制器通过动作方法接受来自用户(通过视图)或客户机(通过 API 调用)的命令/请求,并适当地处理它们。然后,操作的结果被返回给用户或客户端。控制器应该是轻量级的,并利用其他组件或服务来处理请求的细节。这促进了关注点的分离,并增加了可测试性和可维护性。

ASP.NET 核心和 MVC 模式

ASP.NET 核心能够创建许多类型的网络应用和服务。其中两个选项是使用 MVC 模式的 web 应用和 RESTful 服务。如果你使用过 ASP.NET 的“经典”,它们分别类似于 ASP.NET 的 MVC 和 ASP.NET 的 Web API。MVC web 应用和 API 应用类型共享模式的“模型”和“控制器”部分,而 MVC web 应用也实现“视图”来完成 MVC 模式。

ASP.NET 核心和。净核心

正如实体框架核心是实体框架 6 的完全重写,ASP.NET 核心是流行的 ASP.NET 框架的重写。重写 ASP.NET 不是一件小事,但为了消除对System.Web的依赖,这是必要的。消除这种依赖性使 ASP.NET 应用能够在 Windows 之外的操作系统和 Internet 信息服务(IIS)之外的其他 web 服务器上运行,包括自托管。这为 ASP.NET 核心应用使用名为 Kestrel 的跨平台、轻量级、快速和开源的 web 服务器打开了大门。Kestrel 提供了跨所有平台的统一开发体验。

Note

Kestrel 最初基于 LibUV,但是从 ASP.NET 核心 2.1 开始,它现在基于托管套接字。

和 EF Core 一样,ASP.NET Core 也正在 GitHub 上开发,作为一个完全开源的项目(https:// github.com/aspnet )。它也被设计成一个 NuGet 包的模块化系统。开发人员只安装特定应用所需的功能,从而最小化应用的占用空间,减少开销,并降低安全风险。其他改进包括简化的启动、内置的依赖注入、更简洁的配置系统和可插拔的中间件。

一个框架,多种用途

ASP.NET 核心中有许多变化和改进,您将在本节的其余章节中看到。除了跨平台能力,另一个重要的变化是 web 应用框架的统一。ASP.NET 核心将 ASP.NET MVC、ASP.NET Web API 和 Razor Pages 包含在一个开发框架中。开发 web 应用和服务。NET 框架提供了几种选择,包括 WebForms、MVC、Web API、Windows Communication Foundation(WCF)和 WebMatrix。它们都有积极和消极的一面;有些是密切相关的,其他的则大不相同。所有可用的选择意味着开发人员必须了解每一个选择,以便为手头的任务选择合适的一个,或者只选择一个并希望最好的。

借助 ASP.NET 核心,您可以构建使用 Razor 页面、模型-视图-控制器模式、RESTful 服务的应用,以及使用 Angular 和 React 等 JavaScript 框架的 SPA 应用。虽然 UI 呈现会随着 MVC、Razor Pages 和 JavaScript 框架之间的选择而变化,但是底层开发框架在所有选择中都是相同的。之前的两个选择是 WebForms 和 WCF,它们没有被引入 ASP.NET 核心系统。

Note

由于所有的独立框架都在同一个屋檐下,ASP.NET MVC 和 ASP.NET Web API 的前身已经正式退役。在本书中,为了简单起见,我仍然将使用模型-视图-控制器模式的 ASP.NET 核心 web 应用称为 MVC,将 ASP.NET RESTful 服务称为 Web API。

来自 MVC/Web API 的 ASP.NET 核心特性

许多让开发人员使用 ASP.NET MVC 和 ASP.NET Web API 的设计目标和特性在 ASP.NET 核心中仍然受到支持(并得到了改进)。下面列出了其中的一些(但不是全部):

  • 约定胜于配置

  • 控制器和动作

  • 模型绑定

  • 模型验证

  • 选择途径

  • 过滤

  • 布局和剃刀视图

这些将在接下来的章节中介绍,除了布局和 Razor 视图,它们将在第三十一章中介绍。

约定胜于配置

ASP.NET MVC 和 ASP.NET Web API 通过引入某些约定减少了必要的配置量。当遵循这些约定时,会减少手动(或模板化)配置的数量,但也要求开发人员了解这些约定以便利用它们。两个主要的约定包括命名约定和目录结构。

命名规格

对于 MVC 和 API 应用,ASP.NET 核心有多种命名约定。例如,除了从Controller(或ControllerBase)派生之外,控制器通常以Controller后缀命名(例如HomeController)。通过路由访问时,Controller后缀会被删除。当查找控制器的视图时,控制器名称减去后缀就是开始搜索的位置。这种删除后缀的惯例在 ASP.NET 核心中重复出现。在接下来的章节中会有很多例子。

另一个命名约定用于视图位置和选择。默认情况下,一个动作方法(在 MVC 应用中)将呈现与该方法同名的视图。编辑器和显示模板以它们在视图中呈现的类命名。如果您的应用需要,可以更改这些默认值。所有这些都将在 AutoLot 时进一步探讨。构建 Mvc 应用。

目录结构

要成功构建 ASP.NET 核心 web 应用和服务,您必须了解几个文件夹约定。

控制器文件夹

按照惯例,Controllers文件夹是 ASP.NET 核心 MVC 和 API 实现(以及路由引擎)期望放置应用控制器的地方。

视图文件夹

Views文件夹是存储应用视图的地方。每个控制器在以控制器名称命名的主Views文件夹下有自己的文件夹(减去Controller后缀)。默认情况下,操作方法将在其控制器的文件夹中呈现视图。例如,Views/Home文件夹保存了HomeController控制器类的所有视图。

共享文件夹

Views下面有一个特殊的文件夹叫做Shared。所有控制器及其操作方法都可以访问该文件夹。搜索以控制器命名的文件夹后,如果找不到视图,则在Shared文件夹中搜索视图。

wwwroot 文件夹(ASP.NETCore)

对 ASP.NET MVC 的一个改进是为 ASP.NET 核心 web 应用创建了一个名为wwwroot的特殊文件夹。在 ASP.NET MVC 中,JavaScript 文件、图像、CSS 和其他客户端内容与所有其他文件夹混合在一起。在 ASP.NET 核心中,客户端都包含在wwwroot文件夹下。当使用 ASP.NET 核心时,编译文件与客户端文件的这种分离显著地清理了项目结构。

控制器和动作

就像 ASP.NET MVC 和 ASP.NET Web API 一样,控制器和动作方法是 ASP.NET 核心 MVC 或 API 应用的核心。

控制器类

如前所述,ASP.NET 核心统一了 ASP.NET mv C5 和 ASP.NET Web API。这种统一还将 MVC5 和 Web API 2.2 中的ControllerApiControllerAsyncController基类合并成一个新类Controller,它有自己的基类,名为ControllerBase。ASP.NET 核心 web 应用控制器继承自Controller类,而 ASP.NET 核心服务控制器继承自ControllerBase类(将在下一章介绍)。

Controller类为 web 应用提供了许多助手方法。表 29-1 列出了最常用的方法。

表 29-1

Controller类提供的一些助手方法

|

助手方法

|

生命的意义

ViewDataTempDataViewBag 通过ViewDataDictionaryTempDataDictionary和动态ViewBag传输向视图提供数据。
View 返回一个ViewResult(从ActionResult派生而来)作为 HTTP 响应。默认为与操作方法同名的视图,可以选择指定特定的视图。所有选项都允许指定一个强类型的view model并发送给View。视图包含在第三十一章中。
PartialView 向响应管道返回一个PartialViewResult。部分视图包含在第三十一章中。
ViewComponent 向响应管道返回一个ViewComponentResult。在第三十一章中有涉及。
Json 返回一个包含一个序列化为 JSON 的对象的JsonResult作为响应。
OnActionExecuting 在操作方法执行之前执行。
OnActionExecutionAsync OnActionExecuting的异步版本。
OnActionExecuted 在操作方法执行后执行。

ControllerBase 类

除了返回 HTTP 状态代码的帮助方法之外,ControllerBase类还为 ASP.NET 核心 web 应用和服务提供了核心功能。表 29-2 列出了ControllerBase中的一些核心功能,表 29-3 涵盖了一些返回 HTTP 状态码的帮助器方法。

表 29-3

ControllerBase类提供的一些 HTTP 状态代码帮助器方法

|

助手方法

|

HTTP 状态代码操作结果

|

状态代码

NoContent NoContentResult 204
Ok OkResult 200
NotFound NotFoundResult 404
BadRequest BadRequestResult 400
CreatedCreatedAtActionCreatedAtRoute CreatedResultCreatedAtActionResultCreateAtRouteResult 201
AcceptedAcceptedAtActionAcceptedAtRoute AcceptedResultAcceptedAtActionResultAcceptedAtRouteResult 202

表 29-2

ControllerBase类提供的一些助手方法

|

助手方法

|

生命的意义

HttpContext 返回当前正在执行的动作的HttpContext
Request 返回当前正在执行的动作的HttpRequest
Response 返回当前正在执行的动作的HttpResponse
RouteData 返回当前执行动作的RouteData(路由将在本章后面介绍)。
ModelState 返回与模型绑定和验证相关的模型状态(这两个问题将在本章后面讨论)。
Url 返回IUrlHelper的实例,提供对 ASP.NET 核心 MVC 应用和服务的构建 URL 的访问。
User 返回ClaimsPrincipal用户。
Content 向响应返回一个ContentResult。重载允许添加内容类型和编码定义。
File 向响应返回一个FileContentResult
Redirect 通过返回一个RedirectResult将用户重定向到另一个 URL 的一系列方法。
LocalRedirect 仅当 URL 是本地的时,将用户重定向到另一个 URL 的一系列方法。比一般的Redirect方法更安全。
RedirectToActionRedirectToPageRedirectToRoute 重定向到另一个动作方法、Razor 页面或命名路由的一系列方法。本章稍后将介绍路由。
TryUpdateModel 显式模型绑定(将在本章后面介绍)。
TryValidateModel 显式模型验证(将在本章后面介绍)。

行动

动作是控制器上返回IActionResult(或者异步操作的Task<IActionResult>)或者实现IActionResult的类的方法,比如ActionResult或者ViewResult。在接下来的章节中将会详细介绍这些操作。

模型绑定

模型绑定是 ASP.NET 核心使用在 HTTP Post 调用中提交的名称-值对为模型赋值的过程。要绑定到引用类型,名称-值对来自表单值或请求正文,引用类型必须有一个公共的默认构造函数,并且要绑定的属性必须是公共的和可写的。当赋值时,隐式类型转换(例如使用int设置string属性值)在适用的地方被使用。如果类型转换不成功,该属性将被标记为错误。在更详细地讨论绑定之前,理解ModelState字典及其在绑定(和验证)过程中的作用是很重要的。

模型状态字典

ModelState字典包含每个被绑定属性的一个条目和模型本身的一个条目。如果在模型绑定期间出现错误,绑定引擎会将错误添加到属性的字典条目中,并设置ModelState.IsValid = false。如果所有匹配的属性都被成功分配,绑定引擎将设置ModelState.IsValid = true

Note

模型验证也设置了ModelState字典条目,发生在模型绑定之后。隐式和显式模型绑定都会自动调用模型验证。下一节将介绍验证。

将自定义错误添加到模型状态字典中

除了由绑定引擎添加的属性和错误之外,还可以将自定义错误添加到ModelState字典中。可以在属性级别或整个模型中添加错误。要添加属性的特定错误(例如,Car实体的PetName属性),请使用以下命令:

ModelState.AddModelError("PetName","Name is required");

要为整个模型添加一个错误,使用string.Empty作为属性名,如下所示:

ModelState.AddModelError(string.Empty, $"Unable to create record: {ex.Message}");

隐式模型绑定

当要绑定的模型是动作方法的参数时,会发生隐式模型绑定。它对复杂类型使用反射和递归,将模型的可写属性名与发布到 action 方法的名称-值对中包含的名称进行匹配。如果存在名称匹配,绑定器将使用名称-值对中的值来尝试设置属性值。如果名称-值对中有多个名称匹配,则使用第一个匹配名称的值。如果找不到名称,该属性将设置为默认值。名称-值对的搜索顺序如下:

  • 来自 HTTP Post 方法的表单值(包括 JavaScript AJAX posts)

  • 请求体(用于 API 控制器)

  • 通过 ASP.NET 核心路由提供的路由值(对于简单类型)

  • 查询字符串值(对于简单类型)

  • 上传的文件(针对IFormFile类型)

例如,下面的方法将尝试设置Car类型的所有属性。如果绑定过程没有错误地完成,ModelState.IsValid属性返回true

[HttpPost]
public ActionResult Create(Car entity)
{
  if (ModelState.IsValid)
  {
    //Save the data;
  }
}

显式模型绑定

显式模型绑定是通过调用TryUpdateModelAsync()来执行的,传递被绑定类型的实例和要绑定的属性列表。如果模型绑定失败,该方法返回false并以与隐式模型绑定相同的方法设置ModelState错误。当使用显式模型绑定时,被绑定的类型不是 action 方法的参数。例如,您可以这样编写前面的Create()方法,并使用显式模型绑定:

[HttpPost]
public async Task<IActionResult> Create()
{
  var vm = new Car();
  if (await TryUpdateModelAsync(vm,"",
     c=>c.Color,c=>c.PetName,c=>c.MakeId, c=>c.TimeStamp))
  {
    //do something important
  }
}

绑定属性

HTTP Post 方法中的Bind属性允许您限制参与模型绑定的属性,或者为名称-值对中的名称设置前缀。限制可以绑定的属性有助于减少过度发布攻击的威胁。如果一个Bind属性被放置在一个引用参数上,那么在Include列表中列出的字段是唯一将通过模型绑定被分配的字段。如果没有使用Bind属性,所有字段都是可绑定的。

在下面的Create()动作方法示例中,Car实例上的所有字段都可以用于模型绑定,因为没有使用Bind属性:

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create(Car car)
{
  if (ModelState.IsValid)
  {
    //Add the record
  }
    //Allow the user to retry
}

假设您的业务需求指定只允许更新Create()方法中的PetNameColor字段。添加Bind属性(如下例所示)会限制参与绑定的属性,并指示模型绑定器忽略其余的属性。

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create(
[Bind(nameof(Car.PetName),nameof(Car.Color))]Car car)
{
  if (ModelState.IsValid)
  {
    //Save the data;
  }
    //Allow the user to retry

}

Bind属性也可以用来指定属性名的前缀。如果名称-值对的名称在发送给 action 方法时添加了前缀,那么Bind属性用于通知ModelBinder如何将名称映射到类型的属性。以下示例为名称设置前缀,并允许绑定所有属性:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult Create(
[Bind(Prefix="MakeList")]Car car)
{
  if (ModelState.IsValid)
  {
    //Save the data;
  }
}

控制 ASP.NET 核心中的模型绑定源

绑定源可以通过动作参数上的一组属性来控制。也可以创建定制的模型绑定;然而,这超出了本书的范围。表 29-4 列出了可用于控制模型绑定的属性。

表 29-4

控制模型绑定源

|

属性

|

生命的意义

BindingRequired 如果无法发生绑定,将会添加模型状态错误,而不只是将属性设置为其默认值。
BindNever 告诉模型绑定器永远不要绑定到这个参数。
FromHeaderFromQueryFromRouteFromForm 用于指定要应用的确切绑定源(头、查询字符串、路由参数或表单值)。
FromServices 使用依赖注入绑定类型(将在本章后面介绍)。
FromBody 从请求体绑定数据。基于请求的内容(例如,JSON、XML 等)选择格式化程序。).最多只能有一个用FromBody属性修饰的参数。
ModelBinder 用于覆盖默认模型绑定器(用于自定义模型绑定)。

模型验证

模型验证在模型绑定(显式和隐式)之后立即发生。由于转换问题,模型绑定会向ModelState数据字典添加错误,而验证会基于业务规则向ModelState数据字典添加错误。业务规则的示例包括必填字段、具有最大允许长度的字符串或在特定允许范围内的日期。

验证规则是通过内置或自定义的验证属性设置的。表 29-5 列出了一些内置的验证属性。请注意,有几个还兼作数据注释,用于形成 EF 核心实体。

表 29-5

一些内置的验证属性

|

属性

|

生命的意义

CreditCard 对信用卡号执行 Luhn-10 检查
Compare 验证模型匹配中的两个属性
EmailAddress 验证属性是否具有有效的电子邮件格式
Phone 验证属性是否具有有效的电话号码格式
Range 验证属性是否在指定的范围内
RegularExpression 验证属性是否与指定的正则表达式匹配
Required 验证属性是否有值
StringLength 验证属性没有超过最大长度
Url 验证属性是否具有有效的 URL 格式
Remote 通过调用服务器上的操作方法来验证客户端上的输入

还可以开发定制的验证属性,但不在本书中讨论。

选择途径

路由是 ASP.NET 核心如何将 HTTP 请求匹配到应用中的控制器和动作(可执行的端点),而不是将 URL 匹配到项目文件结构的旧 Web 表单过程。它还提供了一种基于这些端点从应用内部创建 URL 的机制。MVC 或 Web API 风格的应用中的端点由控制器、动作(仅限 MVC)、HTTP 动词和可选值(称为路由值)组成。

Note

路由也适用于 Razor pages、SignalR、gRPC 服务等。这本书涵盖了 MVC 和 Web API 风格的控制器。

ASP.NET 核心使用路由中间件来匹配传入请求的 URL,并生成响应中发出的 URL。中间件注册在Startup类中,端点添加在Startup类中,或者通过路由属性添加,这两个都将在本章后面介绍。

URL 模式和路由令牌

路由端点由 URL 模式和文字组成,URL 模式包含可变占位符(称为标记,文字放入一个有序集合中,称为路由表。每个条目定义一个不同的 URL 模式来匹配。占位符可以是自定义变量,也可以来自预定义列表。表 29-6 列出了保留的路由名称。

表 29-6

为 MVC 和 API 应用保留路由令牌

|

代币

|

生命的意义

Area 定义路线的 MVC 区域
Controller 定义控制器(减去控制器后缀)
Action 定义 MVC 应用中的动作名称

除了保留的令牌之外,路由还可以包含映射(模型绑定)到动作方法参数的自定义令牌。

路由和 ASP.NET 核心 RESTful 服务

为 ASP.NET 服务定义路由时,未指定操作方法。相反,一旦找到控制器,要执行的操作方法就基于请求的 HTTP 谓词和分配给操作方法的 HTTP 谓词。稍后会有更多内容。

传统路由

传统路由在Startup类的UseEndpoints()方法中构建路由表。MapControllerRoute()方法将端点添加到路由表中。方法为 URL 模式中的变量指定名称、URL 模式和任何默认值。在下面的代码示例中,预定义的{controller}{action}占位符引用一个控制器和包含在该控制器中的动作方法。占位符{id}是自定义的,并被转换为动作方法的参数(名为id)。向路由令牌添加问号表示它是可选的。

app.UseEndpoints(endpoints =>
{
  endpoints.MapControllerRoute(
    name: "default",
    template: "{controller=Home}/{action=Index}/{id?}");
});

当请求 URL 时,会对照路由表进行检查。如果匹配,则执行位于该应用端点的代码。由该路由提供服务的一个示例 URL 是Car/Delete/5。这将调用CarController上的Delete()动作方法,将 5 传递给id参数。

缺省值指定如何为不包含所有已定义组件的 URL 填充空格。在前面的代码中,如果 URL 中没有指定任何内容(例如http://localhost:5001,那么路由引擎将调用HomeController类的Index()操作方法,而不使用id参数。缺省值是渐进的,这意味着它们可以从右到左被排除。但是,路线部分不能跳过。输入一个像http://localhost:60466/Delete/5这样的网址将无法通过{controller}/{action}/{id}模式。

路由引擎将尝试根据控制器、操作、自定义令牌和 HTTP 谓词来查找第一条路由。如果路由引擎不能确定最佳路径,它将抛出一个AmbiguousMatchException

请注意,路由模板不包含协议或主机名。路由引擎在创建路由时会自动预先考虑正确的信息,并使用 HTTP 谓词、路径和参数来确定正确的应用端点。例如,如果您的站点在 https://www.skimedic.com 上运行,协议(HTTPS)和主机名( www.skimedic.com )会在创建时自动添加到路由前面(例如 https://www.skimedic.com/Car/Delete/5 )。对于传入的请求,路由引擎使用 URL 的Car/Delete/5部分。

命名路线

路由名称可以用作在应用中生成 URL 的简写。在之前的常规回合中,端点被赋予名称default

属性路由

在属性路由中,路由是使用控制器及其动作方法上的 C# 属性来定义的。这可以导致更精确的路由,但也会增加配置量,因为每个控制器和动作都需要指定路由信息。

例如,以下面的代码片段为例。Index()动作方法上的四个Route属性等同于前面定义的相同路线。Index()动作方法是应用端点为 mysite.commysite.com/Homemysite.com/Home/Indexmysite.com/Home/Index/5

public class HomeController : Controller
{
  [Route("/")]
  [Route("/Home")]
  [Route("/Home/Index")]
  [Route("/Home/Index/{id?}")]
  public IActionResult Index(int? id)
  {
    ...
  }
}

传统路由和属性路由的主要区别在于,传统路由覆盖应用,而属性路由覆盖带有Route属性的控制器。如果不使用常规路由,每个控制器都需要定义路由,否则将无法访问。例如,如果路由表中没有定义默认路由,则无法发现以下代码,因为控制器没有配置任何路由:

public class CarController : Controller
{
  public IActionResult Delete(int id)
  {
    ...
  }
}

Note

常规和属性路由可以一起使用。如果默认控制器路由设置在UseEndpoints()(如在传统路由示例中),前面的控制器将通过路由表定位。

当在控制器级别添加路线时,动作方法源自该基本路线。例如,下面的控制器路线涵盖了 Delete() (以及任何其他)动作方法:

[Route("[controller]/[action]/{id?}")]
public class CarController : Controller
{
  public IActionResult Delete(int id)
  {
    ...
  }
}

Note

在属性路由中使用方括号([])来区分内置令牌,而不是传统路由中使用的花括号({})。自定义标记仍然使用花括号。

如果一个动作方法需要重新启动路由模式,在路由前加上一个正斜杠(/)。例如,如果删除方法应该遵循 URL 模式 mysite.com/Delete/Car/5 ,则配置操作如下:

[Route("[controller]/[action]/{id?}")]
public class CarController : Controller
{
  [Route("/[action]/[controller]/{id}")]
  public IActionResult Delete(int id)
  {
    ...
  }
}

路由还可以对路由值进行硬编码,而不是使用令牌替换。下面的代码将产生与前面的代码示例相同的结果:

[Route("[controller]/[action]/{id?}")]
public class CarController : Controller
{
  [Route("/Delete/Car/{id}")]
  public IActionResult Delete(int id)
  {
    ...
  }
}

命名路线

也可以为路线指定名称。这就创建了一种简单的方法,只需使用名称就可以重定向到特定的路由。例如,以下路由属性的名称为GetOrderDetails:

[HttpGet("{orderId}", Name = "GetOrderDetails")]

路由和 HTTP 动词

您可能已经注意到,这两种路由模板定义方法都没有定义 HTTP 动词。这是因为路由引擎(在 MVC 和 API 风格的应用中)结合使用路由模板和 HTTP 动词来选择适当的应用端点。

Web 应用(MVC)路由中的 HTTP 谓词

当使用 MVC 模式构建 web 应用时,通常会有两个应用端点匹配特定的路由模板。这些实例中的鉴别器是 HTTP 动词。例如,如果CarController包含两个名为Delete()的动作方法,并且它们都匹配路由模板,那么选择执行哪个方法是基于请求中使用的动词。第一个Delete()方法是用HttpGet属性修饰的,将在传入请求是 get 请求时执行。第二个Delete()方法用HttpPost属性修饰,将在传入请求是 post 时执行。

[Route("[controller]/[action]/{id?}")]
public class CarController : Controller
{
  [HttpGet]
  public IActionResult Delete(int id)
  {
    ...
  }
  [HttpPost]
  public IActionResult Delete(int id, Car recordToDelete)
  {
    ...
  }
}

也可以使用 HTTP 动词属性而不是Route属性来修改路由。例如,下面显示了添加到两个Delete()方法的路由模板中的可选id路由令牌:

[Route("[controller]/[action] ")]
public class CarController : Controller
{
  [HttpGet("{id?}")]
  public IActionResult Delete(int? id)
  {
    ...
  }
  [HttpPost("{id}")]
  public IActionResult Delete(int id, Car recordToDelete)
  {
    ...
  }
}

也可以使用 HTTP 动词重新启动路由;只需在模板化的路线前加一个正斜杠(/),如下例所示:

[HttpGet("/[controller]/[action]/{makeId}/{makeName}")]
public IActionResult ByMake(int makeId, string makeName)
{
  ViewBag.MakeName = makeName;
  return View(_repo.GetAllBy(makeId));
}

Note

如果 action 方法没有用 HTTP verb 属性修饰,它默认为 get 方法。但是,在 MVC web 应用中,未标记的动作方法也可以响应 post 请求。因此,用正确的动词属性显式标记所有动作方法被认为是最佳实践。

API 服务路由

用于 MVC 风格应用的路由定义和用于 RESTful 服务的路由定义之间的一个显著区别是,服务路由定义不指定动作方法。操作方法是根据请求的 HTTP 动词(和可选的内容类型)而不是名称来选择的。下面的代码展示了一个 API 控制器,它有四个方法,都匹配同一个路由模板。请注意 HTTP 动词属性:

[Route("api/[controller]")]
[ApiController]
public class CarController : ControllerBase
{
  [HttpGet("{id}")]
  public IActionResult GetCarsById(int id)
  {
    ...
  }
  [HttpPost]
  public IActionResult CreateANewCar(Car entity)
  {
    ...
  }
  [HttpPut("{id}")]
  public IActionResult UpdateAnExistingCar(int id, Car entity)
  {
    ...
  }
  [HttpDelete("{id}")]
  public IActionResult DeleteACar(int id, Car entity)
  {
    ...
  }
}

如果一个动作方法没有 HTTP 动词属性,它将被视为 get 请求的应用端点。如果路由请求匹配,但没有具有正确动词属性的操作方法,服务器将返回 404(未找到)。

Note

如果名称以 GetPutDeletePost 开头,ASP.NET Web API 允许您省略方法的 HTTP 动词。这个惯例通常被认为是一个坏主意,并已在 ASP.NET 核心删除。如果一个动作方法没有指定 HTTP 谓词,它将被使用 HTTP Get 调用。

API 控制器的最后一个端点选择器是可选的Consumes属性,它指定端点接受的内容类型。该请求必须使用匹配的内容类型头,否则将返回 415 不支持的媒体类型错误。以下两个示例端点都在同一个控制器中,它们区分了 JSON 和 XML:

[HttpPost]
[Consumes("application/json")]
public IActionResult PostJson(IEnumerable<int> values) =>
  Ok(new { Consumes = "application/json", Values = values });

[HttpPost]
[Consumes("application/x-www-form-urlencoded")]
public IActionResult PostForm([FromForm] IEnumerable<int> values) =>
  Ok(new { Consumes = "application/x-www-form-urlencoded", Values = values });

使用路由重定向

路由的另一个优点是你不再需要为你站点中的其他页面硬编码 URL。路由条目用于匹配传入的请求以及构建 URL。构建 URL 时,会根据当前请求的值添加方案、主机和端口。

过滤

ASP.NET 核心中的过滤器在请求处理管道的特定阶段之前或之后运行代码。有用于授权和缓存的内置过滤器,以及用于分配客户过滤器的选项。表 29-7 列出了可以添加到管道中的过滤器类型,按照它们的执行顺序列出。

表 29-7

ASP.NET 核心中可用的过滤器

|

过滤器

|

生命的意义

授权过滤器 首先运行并确定用户是否有权处理当前请求。
资源筛选器 在授权筛选器之后立即运行,并且可以在管道的其余部分完成之后运行。在模型绑定之前运行。
动作过滤器 在执行操作之前和/或执行操作之后立即运行。可以改变传递给操作的值和从操作返回的结果。
异常过滤器 用于将全局策略应用于写入响应正文之前发生的未处理异常。
结果过滤器 成功执行操作结果后立即运行代码。对于围绕视图或格式化程序执行的逻辑很有用。

授权过滤器

授权过滤器与 ASP.NET 核心身份系统配合使用,以防止对用户无权使用的控制器或操作的访问。不建议构建自定义授权过滤器,因为内置的AuthorizeAttributeAllowAnonymousAttribute通常在使用 ASP.NET 核心身份时提供足够的覆盖范围。

资源筛选器

before 代码在授权筛选器之后和任何其他筛选器之前执行,after 代码在所有其他筛选器之后执行。这使得资源筛选器能够缩短整个响应管道。资源过滤器的一个常见用户是用于缓存。如果响应在缓存中,过滤器可以跳过管道的其余部分。

动作过滤器

before 代码在 action 方法执行之前立即执行,after 代码在 action 方法执行之后立即执行。动作过滤器可以短路动作方法和被动作过滤器包装的任何过滤器(执行和包装的顺序将很快介绍)。

异常过滤器

异常过滤器在应用中实现横切错误处理。它们没有 before 或 after 事件,但是它们处理控制器创建、模型绑定、动作过滤器或动作方法中任何未处理的异常。

结果过滤器

结果过滤器包装动作方法的IActionResult的执行。一个常见的场景是使用结果过滤器将头信息添加到 HTTP 响应消息中。

ASP.NET 核心的新功能

除了支持 ASP.NET MVC 和 ASP.NET Web API 的基本功能之外,该团队还能够在以前的框架上添加许多新功能和改进。除了框架和控制器的统一之外,还有一些额外的改进和创新:

  • 内置依赖注入。

  • 云就绪、基于环境的配置系统。

  • 轻量级、高性能和模块化的 HTTP 请求管道。

  • 整个框架基于细粒度的 NuGet 包。

  • 现代客户端框架和开发工作流的集成。

  • 标签助手介绍。

  • 视图组件介绍。

  • 性能的巨大提高。

内置依赖注入

依赖注入(DI)是一种支持对象间松散耦合的机制。参数被定义为接口,而不是直接创建依赖对象或将特定的实现传递给类和/或方法。这样,接口的任何实现都可以传递到类或方法和类中,极大地增加了应用的灵活性。

DI 支持是重写 ASP.NET 核心的主要原则之一。不仅仅是Startup类(本章后面会讲到)通过依赖注入接受所有的配置和中间件服务,您的定制类也可以(并且应该)被添加到服务容器中,以注入到应用的其他部分。当一个项目被配置到 ASP.NET 核心 DI 容器中时,有三个生命周期选项,如表 29-8 所示。

表 29-8

服务的终身选项

|

终身期权

|

提供的功能

Transient 在需要的时候创建*。*
Scoped 为每个请求创建一次。推荐用于实体框架DbContext对象。
Singleton 在第一次请求时创建一次,然后在对象的生存期内重用。相对于将类作为单例实现,这是推荐的方法。

DI 容器中的条目可以被注入到类构造函数和方法以及 Razor 视图中。

Note

如果你想使用一个不同的依赖注入容器,ASP.NET 核心就是考虑到这种灵活性而设计的。查阅文档,了解如何插入不同的容器: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection

环境意识

ASP.NET 核心应用通过一个IWebHostEnvironment实例了解其执行环境,包括主机环境变量和文件位置。表 29-9 显示了通过该接口可用的属性。

表 29-9

IWebHostEnvironment 属性

|

财产

|

提供的功能

ApplicationName 获取或设置应用的名称。默认为入口程序集的名称。
ContentRootPath 获取或设置包含应用内容文件的目录的绝对路径。
ContentRootFileProvider 获取或设置一个指向ContentRootPathIFileProvider
EnvironmentName 获取或设置环境的名称。设置为环境变量ASPNETCORE_ENVIRONMENT的值。
WebRootFileProvider 获取或设置一个指向WebRootPathIFileProvider
WebRootPath 获取或设置包含 web 可服务的应用内容文件的目录的绝对路径。

除了访问相关的文件路径,IWebHostEnvironment用于确定运行时环境。

确定运行时环境

ASP.NET 核心自动读取名为ASPNETCORE_ENVIRONMENT的环境变量的值来设置运行时环境。如果未设置变量ASPNETCORE_ENVIRONMENT,ASP.NET 核心将该值设置为Production。通过IWebHostEnvironment上的EnvironmentName属性可以访问该值集。

在开发 ASP.NET 核心应用时,通常使用设置文件或命令行来设置此变量。下游环境(试运行、生产等。)通常使用标准的操作系统环境变量。

您可以自由地使用环境的任何名称或者由静态类Environments提供的三个名称。

public static class Environments
{
  public static readonly string Development = "Development";
  public static readonly string Staging = "Staging";
  public static readonly string Production = "Production";
}

HostEnvironmentEnvExtensions类在IHostEnvironment上提供了扩展方法,用于处理环境名称属性。表 29-10 列出了可用的便利方法。

表 29-10

HostEnvironmentEnvExtensions 方法

|

方法

|

提供的功能

IsProduction 如果环境变量设置为Production(不区分大小写),则返回 true
IsStaging 如果环境变量设置为Staging(不区分大小写),则返回 true
IsDevelopment 如果环境变量设置为Development(不区分大小写),则返回 true
IsEnvironment 如果环境变量与传递给方法的字符串匹配,则返回 true(不区分大小写)

以下是使用环境设置的一些示例:

  • 确定要加载哪些配置文件

  • 设置调试、错误和日志记录选项

  • 加载特定于环境的 JavaScript 和 CSS 文件

在构建自动 Lot 的过程中,您将会看到其中的每一项。Api 和 AutoLot。下两章将讨论 Mvc 应用。

应用配置

以前版本的 ASP.NET 使用web.config文件来配置服务和应用,开发者通过System.Configuration类访问配置设置。当然,网站的所有配置设置,而不仅仅是特定应用的设置,都被转储到web.config文件中,使得它(可能)变得复杂混乱。

ASP.NET 核心引入了一个大大简化的配置系统。默认情况下,它基于简单的 JSON 文件,这些文件将配置设置保存为名称-值对。配置的默认文件是appsettings.json文件。初始版本的appsettings.json文件(由 ASP.NET 核心 web 应用和 API 服务模板创建)仅包含日志记录的配置信息以及限制主机的设置,如下所示:

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

该模板还创建了一个appsettings.Development.json文件。配置系统与运行时环境感知协同工作,以基于运行时环境加载附加的配置文件。这是通过指示配置系统在appSettings.json文件之后加载一个名为appsettings.{environmentname}.json的文件来实现的。在开发下运行时,appsettings.Development.json文件在初始设置文件之后加载。如果环境正在升级,则加载appsettings.Staging.json文件。值得注意的是,当加载多个文件时,两个文件中出现的任何设置都会被最后加载的文件覆盖;它们不是相加的。

所有配置值都可以通过一个IConfiguration实例来访问,该实例可以通过 ASP.NET 核心依赖注入系统获得。

正在检索设置

一旦构建好配置,就可以使用传统的Get系列方法来访问设置,比如GetSection()GetValue()等等。

Configuration.GetSection("Logging")

还有一个获取应用连接字符串的快捷方式。

Configuration.GetConnectionString("AutoLot")

本书的其余部分将会用到更多的配置特性。

部署 ASP.NET 核心应用

以前版本的 ASP.NET 应用只能部署到使用 IIS 的 Windows 服务器上。ASP.NET 核心可以以多种方式部署到多个操作系统,包括在 web 服务器之外。高级选项如下:

  • 在使用 IIS 的 Windows 服务器(包括 Azure)上

  • 在 IIS 之外的 Windows 服务器(包括 Azure 应用服务)上

  • 在使用 Apache 或 NGINX 的 Linux 服务器上

  • 在 Docker 容器中的 Windows 或 Linux 上

这种灵活性允许组织决定对组织最有意义的部署平台,包括流行的基于容器的部署模型(如使用 Docker),而不是局限于 Windows 服务器。

轻量级和模块化的 HTTP 请求管道

遵循…的原则。净核心,你必须选择在 ASP.NET 核心的一切。默认情况下,应用中不会加载任何内容。这使得应用尽可能地轻量级,提高性能并最小化表面区域和潜在风险。

创建和配置解决方案

现在,您已经了解了 ASP.NET 核心的一些主要概念,是时候开始构建 ASP.NET 核心应用了。可以使用 Visual Studio 或命令行创建 ASP.NET 核心项目。这两个选项都将在接下来的两节中讨论。

使用 Visual Studio

Visual Studio 具有 GUI 的优势,可以引导您完成创建解决方案和项目、添加 NuGet 包以及创建项目间引用的过程。

创建解决方案和项目

首先在 Visual Studio 中创建新项目。从“创建新项目”对话框中选择 C# 模板 ASP.NET 核心 Web 应用。在“配置你的新项目”对话框中,输入 AutoLot。项目名称为 Api ,解决方案名称为 AutoLot ,如图 29-1 所示。

img/340876_10_En_29_Fig1_HTML.jpg

图 29-1

创建自动 Lot。Api 项目和自动 Lot 解决方案

在下一个屏幕上,选择 ASP.NET 核心 Web API 模板。NET 核心和 ASP.NET 核心 5.0。保留高级复选框的默认设置,如图 29-2 所示。

img/340876_10_En_29_Fig2_HTML.jpg

图 29-2

选择 ASP.NET 核心 Web API 模板

接下来,向解决方案添加另一个 ASP.NET 核心 web 应用。选择 ASP.NET 核心 Web 应用(模型-视图-控制器)模板。确保。在顶部的选择框中选择了 NET Core 和 ASP.NET Core 5.0;保留高级复选框的默认值。

最后,添加一个 C# 类库(。NET Core)添加到项目中,并将其命名为 AutoLot.Services,编辑项目文件,将TargetFramework设置为net5.0,如下所示:

<PropertyGroup>
  <TargetFramework>net5.0</TargetFramework>
</PropertyGroup>

加入自动 Lot。模型和自动 Lot。木豆

该解决方案需要第二十三章中完整的数据访问层。您可以将文件复制到当前的解决方案目录中,也可以将它们留在原处。无论哪种方式,您都需要在解决方案资源管理器中右键单击您的解决方案名称,选择添加➤现有项目,并导航到AutoLot.Models.csproj文件并选择它。对自动 Lot 重复上述步骤。Dal 项目。

Note

虽然项目添加到解决方案的顺序在技术上并不重要,但 Visual Studio 将保留 AutoLot 之间的引用。模型和自动 Lot。如果首先添加模型项目,则为 Dal。

添加项目引用

通过在解决方案资源管理器中右击项目名称并为每个项目选择“添加➤项目引用”,添加以下项目引用。

AutoLot。Api 和 AutoLot。Mvc 引用了以下内容:

  • AutoLot。模型

  • 汽车旅馆

  • AutoLot。服务

AutoLot。服务参考了以下内容:

  • AutoLot。模型

  • 汽车旅馆

添加 NuGet 包

需要附加的 NuGet 包来完成应用。

去自动售货机。Api 项目,添加以下包:

  • AutoMapper

  • System.Text.Json

  • Swashbuckle.AspNetCore.Annotations

  • Swashbuckle.AspNetCore.Swagger

  • Swashbuckle.AspNetCore.SwaggerGen

  • Swashbuckle.AspNetCore.SwaggerUI

  • Microsoft.VisualStudio.Web.CodeGeneration.Design

  • Microsoft.EntityFrameworkCore.SqlServer

Note

在 ASP.NET 核心 5.0 API 模板中,Swashbuckle.AspNetCore已经被引用。列出的Swashbuckle包增加了基本实现之外的功能。

去自动售货机。Mvc 项目,添加以下包:

  • AutoMapper

  • System.Text.Json

  • LigerShark.WebOptimizer.Core

  • Microsoft.Web.LibraryManager.Build

  • Microsoft.VisualStudio.Web.CodeGeneration.Design

  • Microsoft.EntityFrameworkCore.SqlServer

去自动售货机。服务项目,添加以下包:

  • Microsoft.Extensions.Hosting.Abstractions

  • Microsoft.Extensions.Options

  • Serilog.AspNetCore

  • Serilog.Enrichers.Environment

  • Serilog.Settings.Configuration

  • Serlog.Sinks.Console

  • Serilog.Sinks.File

  • Serilog.Sinks.MSSqlServer

  • System.Text.Json

使用命令行

如本书前面所示。NET 核心项目和解决方案可以使用命令行创建。打开提示符并导航到您希望解决方案所在的目录。

Note

列出的命令使用 Windows 目录分隔符。如果您使用的是非 Windows 操作系统,请根据需要调整分隔符。

以下命令创建自动放样解决方案并添加现有自动放样。模型和自动 Lot。Dal 项目融入解决方案:

rem create the solution
dotnet new sln -n AutoLot
rem add autolot dal to solution
dotnet sln AutoLot.sln add ..\Chapter_23\AutoLot.Models
dotnet sln AutoLot.sln add ..\Chapter_23\AutoLot.Dal

创建自动 Lot。服务项目,将其添加到解决方案中,添加 NuGet 包,并添加项目引用。

rem create the class library for the application services and add it to the solution
dotnet new classlib -lang c# -n AutoLot.Services -o .\AutoLot.Services -f net5.0
dotnet sln AutoLot.sln add AutoLot.Services

dotnet add AutoLot.Services package Microsoft.Extensions.Hosting.Abstractions
dotnet add AutoLot.Services package Microsoft.Extensions.Options
dotnet add AutoLot.Services package Serilog.AspNetCore
dotnet add AutoLot.Services package Serilog.Enrichers.Environment
dotnet add AutoLot.Services package Serilog.Settings.Configuration
dotnet add AutoLot.Services package Serilog.Sinks.Console
dotnet add AutoLot.Services package Serilog.Sinks.File
dotnet add AutoLot.Services package Serilog.Sinks.MSSqlServer
dotnet add AutoLot.Services package System.Text.Json

dotnet add AutoLot.Services reference ..\Chapter_23\AutoLot.Models
dotnet add AutoLot.Services reference ..\Chapter_23\AutoLot.Dal

创建自动 Lot。Api 项目,将其添加到解决方案中,添加 NuGet 包,并添加项目引用。

dotnet new webapi -lang c# -n AutoLot.Api -au none -o .\AutoLot.Api -f net5.0
dotnet sln AutoLot.sln add AutoLot.Api

dotnet add AutoLot.Api package AutoMapper
dotnet add AutoLot.Api package Swashbuckle.AspNetCore
dotnet add AutoLot.Api package Swashbuckle.AspNetCore.Annotations
dotnet add AutoLot.Api package Swashbuckle.AspNetCore.Swagger
dotnet add AutoLot.Api package Swashbuckle.AspNetCore.SwaggerGen
dotnet add AutoLot.Api package Swashbuckle.AspNetCore.SwaggerUI
dotnet add AutoLot.Api package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add AutoLot.Api package Microsoft.EntityFrameworkCore.SqlServer
dotnet add AutoLot.Api package System.Text.Json

dotnet add AutoLot.Api reference ..\Chapter_23\AutoLot.Dal
dotnet add AutoLot.Api reference ..\Chapter_23\AutoLot.Models
dotnet add AutoLot.Api reference AutoLot.Services

创建自动 Lot。Api 项目,将其添加到解决方案中,添加 NuGet 包,并添加项目引用。

dotnet new mvc -lang c# -n AutoLot.Mvc -au none -o .\AutoLot.Mvc -f net5.0
dotnet sln AutoLot.sln add AutoLot.Mvc

rem add project references
dotnet add AutoLot.Mvc reference ..\Chapter_23\AutoLot.Models
dotnet add AutoLot.Mvc reference ..\Chapter_23\AutoLot.Dal
dotnet add AutoLot.Mvc reference AutoLot.Services

rem add packages
dotnet add AutoLot.Mvc package AutoMapper
dotnet add AutoLot.Mvc package System.Text.Json
dotnet add AutoLot.Mvc package LigerShark.WebOptimizer.Core
dotnet add AutoLot.Mvc package Microsoft.Web.LibraryManager.Build
dotnet add AutoLot.Mvc package Microsoft.EntityFrameworkCore.SqlServer
dotnet add AutoLot.Mvc package Microsoft.VisualStudio.Web.CodeGeneration.Design

这就完成了使用命令行的设置。如果你不需要 Visual Studio GUI 来帮助你,它会更有效。

运行 ASP.NET 核心应用

以前版本的 ASP.NET web 应用总是使用 IIS(或 IIS Express)运行。借助 ASP.NET 核心,应用通常使用 Kestrel web 服务器运行,并可选择使用 IIS、Apache、Nginx 等。通过 Kestrel 和其他 web 服务器之间的反向代理。这不仅背离了严格使用 IIS 来改变部署模型,而且还改变了开发的可能性。在开发过程中,您现在可以通过以下方式运行您的应用:

  • 从 Visual Studio,使用 IIS Express

  • 在 Visual Studio 中,使用 Kestrel

  • 在命令提示符下使用。NET CLI,使用 Kestrel

  • 从 Visual Studio 代码,使用 Kestrel,从运行菜单

  • 在 Visual Studio 代码的终端窗口中,使用。NET CLI 和 Kestrel

配置启动设置

launchsettings.json文件(位于解决方案资源管理器的 Properties 节点下)配置应用如何在开发中运行,包括在 Kestrel 和 IIS Express 下。此处列出了launchsettings.json文件以供参考(您的 IIS Express 端口会有所不同):

{
  "$schema": "http://json.schemastore.org/launchsettings.json",
  "iisSettings": {
    "windowsAuthentication": false,
    "anonymousAuthentication": true,
    "iisExpress": {
      "applicationUrl": "http://localhost:42788",
      "sslPort": 44375
    }
  },
  "profiles": {
    "IIS Express": {
      "commandName": "IISExpress",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    },
    "AutoLot.Api": {
      "commandName": "Project",
      "dotnetRunMessages": "true",
      "launchBrowser": true,
      "launchUrl": "swagger",
      "applicationUrl": "https://localhost:5001;http://localhost:5000",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }
  }
}

使用 Visual Studio

iisSettings部分定义了使用 IIS Express 作为 web 服务器运行应用的设置。需要注意的最重要的设置是定义端口的applicationUrl和定义运行时环境的environmentVariables块。在调试模式下运行时,此设置将取代任何机器环境设置。第二个概要文件(AutoLot.MvcAutoLot.Api,取决于您正在使用的项目)定义了使用 Kestrel 作为 web 服务器运行应用时的设置。该配置文件定义了applicationUrl和端口,以及环境。

Visual Studio 中的 Run 命令允许选择 IIS Express 或 Kestrel,如图 29-3 所示。一旦选择了一个概要文件,您就可以通过按 F5(调试模式)、按 Ctrl+F5(与“调试”菜单中的“启动而不调试”相同)或单击绿色的运行箭头(与“调试”菜单中的“启动调试”相同)来运行项目。

img/340876_10_En_29_Fig3_HTML.jpg

图 29-3

可用的 Visual Studio 调试配置文件

Note

从 Visual Studio 运行应用时,不再支持“编辑并继续”。

使用命令行或 Visual Studio 代码终端窗口

要从命令行或 VSC 终端运行,导航到应用的csproj文件所在的目录。输入以下命令,使用 Kestrel 作为 web 服务器启动您的应用:

dotnet run

若要结束该过程,请按 Ctrl+C。

调试时更改代码

从命令行运行时,代码可以更改,但不会反映在运行的 app 中。要在运行的应用中反映更改,请输入以下命令:

dotnet watch run

该命令的更新在启动应用的同时运行文件监视器。当在任何项目(或引用的项目)文件中检测到更改时,应用将自动停止,然后重新启动。新的 ASP.NET 核心 5,任何连接的浏览器窗口也将重新加载。它不完全是“编辑并继续”,但它是一个很好的开发解决方案。

使用 Visual Studio 代码(VS 代码)

若要从 Visual Studio 代码运行项目,请打开解决方案所在的文件夹。当你按下 F5(或者点击 Run),VS 代码会提示你选择要运行的项目(AutoLot。Api 或 AutoLot。Mvc),然后创建一个运行配置并把它放在一个名为launch.json的文件中。VS 代码也使用launchsettings.json文件进行端口配置。

调试时更改代码

从 VS 代码运行时,代码可以更改,但不会在运行的 app 中体现出来。要在运行的应用中反映更改,请从终端运行dotnet watch run命令。

调试 ASP.NET 核心应用

从 Visual Studio 或 Visual Studio 代码运行应用时,调试按预期工作。当从命令行运行时,必须先连接到正在运行的进程,然后才能调试应用。在 Visual Studio 和 Visual Studio 代码中做到这一点很容易。

使用 Visual Studio 附加

启动您的应用(使用dotnet rundotnet watch run)后,在 Visual Studio 中选择调试➤附加到进程。当“附加到进程”对话框出现时,根据您的应用名称过滤进程,如图 29-4 所示。

img/340876_10_En_29_Fig4_HTML.jpg

图 29-4

附加到正在运行的应用以便在 Visual Studio 中进行调试

一旦附加到正在运行的进程,就可以在 Visual Studio 中设置断点,调试就可以按预期进行了。您不能编辑并继续;您必须从进程中分离,更改才能反映在您正在运行的应用中。

用 Visual Studio 代码附加

启动应用后(使用dotnet rundotnet watch run,选择。NET Core Attach 代替。点击 VS 代码中的绿色运行箭头,启动 NET Core(web),如图 29-5 所示。

img/340876_10_En_29_Fig5_HTML.jpg

图 29-5

附加到正在运行的应用以在 Visual Studio 代码中进行调试

当您单击“运行”按钮时,系统会提示您选择要附加的进程。选择您的应用。现在,您可以按预期设置断点。

使用 Visual Studio 代码的优势在于,一旦它被附加(并使用dotnet watch run)你就可以在运行时更新你的代码(无需分离),你的更改将会反映在你的应用中。

更新自动 Lot。Api 端口

你可能已经注意到了。Api 和 AutoLot。Mvc 为其 IIS Express 配置文件指定了不同的端口,但两者都将其 Kestrel 端口配置为 5000 (HTTP)和 5001 (HTTPS)。当你尝试一起运行应用时,这会导致问题。更新自动 Lot。Api 端口到 5020 (HTTP)和 5021 (HTTPS),就像这样:

    "AutoLot.Api": {
      "commandName": "Project",
      "launchBrowser": true,
      "launchUrl": "api/values",
      "applicationUrl": "https://localhost:5021;http://localhost:5020",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development"
      }
    }

创建和配置 WebHost

与经典的 ASP.NET MVC 或 ASP.NET Web API 应用不同,ASP.NET 核心应用非常简单。创建和配置一个WebHost的. NET 核心控制台应用。WebHost的创建和随后的配置将应用设置为监听(和响应)HTTP 请求。WebHost是在Program.cs文件的Main()方法中创建的。然后在Startup.cs文件中为您的应用配置WebHost

Program.cs 文件

打开自动 Lot 中的Program.cs类。Api 应用,并检查这里显示的内容,以供参考:

namespace AutoLot.Api
{
  public class Program
  {
    public static void Main(string[] args)
    {
            CreateHostBuilder(args).Build().Run();
    }

    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
  }
}

CreateDefaultBuilder()方法将最典型的应用设置压缩到一个方法调用中。它配置应用(使用环境变量和appsettings JSON 文件),配置默认的日志记录提供者,并设置依赖注入容器。这种设置是由 API 和 MVC 风格的应用的 ASP.NET 核心模板提供的。

下一个方法(ConfigureWebHostDefaults())也是元方法,增加了对 Kestrel、IIS 和附加配置的支持。最后一步是设置特定于应用的配置类,在本例中(按照惯例)命名为Startup。最后一步是使用Run()方法来激活 web 主机。

除了WebHost之外,前面的代码还创建了IConfiguration实例,并将其添加到依赖注入容器中。

Startup.cs 文件

Startup类配置应用如何处理 HTTP 请求和响应,配置任何需要的服务,并向依赖注入容器添加服务。类名可以是任何东西,只要它匹配CreateHostBuilder()方法配置中的UseStartup<T>()行,但是约定是命名类Startup

可用于启动的服务

启动过程需要访问框架和环境服务及值,这些由框架注入到类中。表 29-11 中列出了Startup类可用于配置应用的五种服务。

表 29-11

启动时可用的服务

|

服务

|

提供的功能

IApplicationBuilder 定义一个类,该类提供配置应用请求管道的机制。
IWebHostEnvironment 提供有关运行应用的 web 宿主环境的信息。
ILoggerFactory 用于配置日志记录系统,并从已注册的日志记录提供程序创建日志程序实例。
IServiceCollection 指定服务描述符集合的协定。这是依赖注入框架的一部分。
IConfiguration 应用配置的实例,在Program类的Main方法中创建。

构造函数接受一个IConfiguration的实例和一个IWebHostEnvironment / IHostEnvironment的可选实例。ConfigureServices()方法在Configure()方法获取IServiceCollection实例之前运行。Configure()方法必须接受IApplicationBuilder的一个实例,但是也可以接受IWebHostEnvironment / IHostEnvironmentILoggerFactory以及添加到ConfigureServices()中依赖注入容器的任何接口的实例。每个组件都将在接下来的章节中讨论。

构造函数

构造函数获取由Program.cs文件中的Host.CreateDefaultBuilder方法创建的IConfiguration接口的实例,并将其分配给Configuration属性,以便在类中的其他地方使用。构造函数也可以获取IWebHostEnvironment和/或ILoggerFactory的一个实例,尽管它们没有被添加到默认模板中。

IWebHostEnvironment的参数添加到构造函数中,并将其赋给一个局部类级变量。这在ConfigureServices()方法中是需要的。对两个自动驾驶仪都这样做。Api 和 AutoLot。Mvc 应用。

private readonly IWebHostEnvironment _env;
public Startup(
  IConfiguration configuration, IWebHostEnvironment env)
{
  _env = env;
  Configuration = configuration;
}

ConfigureServices 方法

ConfigureServices()方法用于配置应用所需的任何服务,并将它们插入依赖注入容器。这包括支持 MVC 应用和 API 服务所需的服务。

AutoLot。美国石油学会(American Petroleum Institute)

AutoLot API 的ConfigureServices()方法在默认情况下只配置了一个服务,该服务添加了对控制器的支持。在这个元方法的背后是一系列附加的服务,包括路由、授权、模型绑定以及本章已经讨论过的所有非 UI 项目。

public void ConfigureServices(IServiceCollection services)
{
  services.AddControllers();
}

可以扩展AddControllers()方法。一个例子是配置 JSON 处理。ASP.NET 核心的缺省值是 camel case JSON(首字母小写,每个后续单词字符大写,如" c ar R epo ")。这与大多数用于 web 开发的非微软框架相匹配。然而,ASP.NET·帕斯卡的早期版本对所有东西都进行了封装。对于许多期待 Pascal 大小写的应用来说,camel 大小写的改变是一个突破性的改变。要将应用的 JSON 处理改回 Pascal 大小写(并更好地格式化 JSON),请将AddControllers()方法更新为:

public void ConfigureServices(IServiceCollection services)
{
  services.AddControllers()
    .AddJsonOptions(options =>
    {
      options.JsonSerializerOptions.PropertyNamingPolicy = null;
      options.JsonSerializerOptions.WriteIndented = true;
    });
}

下一次更新需要将下面的using语句添加到Startup.cs类中:

using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Initialization;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

API 服务需要访问数据访问层中的ApplicationDbContext和 repos。内置支持将 EF 核心添加到 ASP.NET 核心应用中。将以下代码添加到Startup类的ConfigureServices()方法中:

var connectionString = Configuration.GetConnectionString("AutoLot");
services.AddDbContextPool<ApplicationDbContext>(
  options => options.UseSqlServer(connectionString,
  sqlOptions => sqlOptions.EnableRetryOnFailure()));

第一行从设置文件中获取连接字符串(稍后将详细介绍)。下一行将一个ApplicationDbContext实例池添加到 DI 容器中。与连接池非常相似,ApplicationDbContexts的池可以通过让预初始化的实例等待使用来提高性能。当需要一个上下文时,就从池中加载它。当它被用完时,它被清理掉任何使用的残留物,并被放回水池。

下一个更新是将 repos 添加到 DI 容器中。将以下代码添加到ConfigureServices()方法中配置ApplicationDbContext的代码之后:

services.AddScoped<ICarRepo, CarRepo>();
services.AddScoped<ICreditRiskRepo, CreditRiskRepo>();
services.AddScoped<ICustomerRepo, CustomerRepo>();
services.AddScoped<IMakeRepo, MakeRepo>();
services.AddScoped<IOrderRepo, OrderRepo>();

将连接字符串添加到应用设置

appsettings.development.json文件更新如下,这将连接字符串添加到数据库中。请确保包含分隔各部分的逗号,并更新连接字符串以匹配您的环境。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "ConnectionStrings": {
    "AutoLot": "Server=.,5433;Database=AutoLotFinal;User ID=sa;Password=P@ssw0rd;"
  }
}

如前所述,每个配置文件都以一个环境命名。这允许将特定于环境的值分离到不同的文件中。将名为appsettings.production.json的新文件添加到项目中,并将其更新为以下内容:

{
  "ConnectionStrings": {
    "AutoLot": "ITSASECRET"
  }
}

这使得真正的连接字符串不受源代码控制,并允许在部署过程中替换标记(ITSASECRET)。

AutoLot。手动音量调节

MVC 风格的 web 应用的ConfigureServices()方法增加了 API 应用的基本服务以及对呈现视图的支持。MVC 风格的应用不调用AddControllers(),而是调用AddControllersWithViews(),如下所示:

public void ConfigureServices(IServiceCollection services)
{
  services.AddControllersWithViews();
}

将以下using语句添加到Startup.cs类中:

using AutoLot.Dal.EfStructures;
using AutoLot.Dal.Initialization;
using AutoLot.Dal.Repos;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.EntityFrameworkCore;

web 应用也需要使用数据访问层。将以下代码添加到Startup类的ConfigureServices()方法中:

var connectionString = Configuration.GetConnectionString("AutoLot");
services.AddDbContextPool<ApplicationDbContext>(
  options => options.UseSqlServer(connectionString,
  sqlOptions => sqlOptions.EnableRetryOnFailure()));
services.AddScoped<ICarRepo, CarRepo>();
services.AddScoped<ICreditRiskRepo, CreditRiskRepo>();
services.AddScoped<ICustomerRepo, CustomerRepo>();
services.AddScoped<IMakeRepo, MakeRepo>();
services.AddScoped<IOrderRepo, OrderRepo>();

Note

MVC web 应用将使用数据访问层和 API 来与数据交互,以演示这两种机制。

将连接字符串添加到应用设置

appsettings.development.json文件更新为以下内容:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "ConnectionStrings": {
    "AutoLot": "Server=.,5433;Database=AutoLotFinal;User ID=sa;Password=P@ssw0rd;"
  }
}

该配置方法

Configure()方法用于设置应用来响应 HTTP 请求。这个方法在和ConfigureServices()方法之后执行*,这意味着添加到 DI 容器中的任何东西也可以被注入到Configure()方法中。*

API 风格的应用和 MVC 风格的应用在处理 HTTP 管道请求和响应的配置上有所不同。

AutoLot。美国石油学会(American Petroleum Institute)

默认模板检查环境,如果它被设置为开发,那么UseDeveloperExceptionPage()中间件被添加到处理管道中。这提供了调试信息,您可能不希望在生产中公开这些信息。

然后调用UseHttpsRedirection()将所有流量重定向到 HTTPS(而不是 HTTP)。然后添加对app.UseRouting()app.UseAuthorization()app.UseEndpoints()的调用。下面列出了整个方法:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  if (env.IsDevelopment())
  {
    //If in development environment, display debug info
    app.UseDeveloperExceptionPage();
    //Original code
    app.UseSwagger();
    app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "AutoLot.Api v1"));
  }

  //redirect http traffic to https
  app.UseHttpsRedirection();
  //opt-in to routing
  app.UseRouting();
  //enable authorization checks
  app.UseAuthorization();
  //opt-in to using endpoint routing
  //use attribute routing on controllers
  app.UseEndpoints(endpoints =>
  {
    endpoints.MapControllers();
  });
}

我们将对此进行的更改是当系统在开发中运行时初始化数据库。将ApplicationDbContext作为参数添加到方法中,并从 AutoLot.Dal 调用InitializeData()。更新后的代码如下所示:

public void Configure(
  IApplicationBuilder app,
  IWebHostEnvironment env,
  ApplicationDbContext context)
{
  if (env.IsDevelopment())
  {
    //If in development environment, display debug info
    app.UseDeveloperExceptionPage();
    //Initialize the database
   if (Configuration.GetValue<bool>(“RebuildDataBase”))
    {
      SampleDataInitializer.InitializeData(context);
    }
  }
  ...
}

现在,用RebuildDataBase属性更新appsettings.development.json(现在将节点设置为false)。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "RebuildDataBase": false,
  "ConnectionStrings": {
    "AutoLot": "Server=db;Database=AutoLotPresentation;User ID=sa;Password=P@ssw0rd;"
  }
}

AutoLot。手动音量调节

web 应用的Configure()方法比 API 方法要复杂一些。此处列出了完整的方法,稍后将进行讨论:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
  if (env.IsDevelopment())
  {
    app.UseDeveloperExceptionPage();
  }
  else
  {
    app.UseExceptionHandler("/Home/Error");
    app.UseHsts();
  }
  app.UseHttpsRedirection();
  app.UseStaticFiles();
  app.UseRouting();
  app.UseAuthorization();
  app.UseEndpoints(endpoints =>
  {
    endpoints.MapControllerRoute(
      name: "default",
      pattern: "{controller=Home}/{action=Index}/{id?}");
  });
}

该方法还检查环境,如果设置为development,则添加中间件DeveloperExceptionPage。如果环境不是开发,那么通用的ExceptionHandler中间件以及 HTTP 严格传输安全协议(HSTS)将被添加到管道中。

回到主执行路径,像它的 API 对应物一样,添加了对app.UseHttpsRedirection()的调用。下一步是用app.UseStaticFiles()添加对静态文件的支持。作为一种安全措施,可以选择支持静态文件。如果你的 app 不需要它们(比如 API),那么就不要添加支持,它们不可能是安全隐患。添加了路由、授权和端点中间件。

ApplicationDbContext作为参数添加到方法中,并从 AutoLot.Dal 调用InitializeData()。更新后的代码如下所示:

public void Configure(
  IApplicationBuilder app,
  IWebHostEnvironment env,
  ApplicationDbContext context)
{
  if (env.IsDevelopment())
  {
    //If in development environment, display debug info
    app.UseDeveloperExceptionPage();
    //Initialize the database
   if (Configuration.GetValue<bool>(“RebuildDataBase”))
    {
      SampleDataInitializer.InitializeData(context);
    }
  }
  ...
}

现在,用RebuildDataBase属性更新appsettings.development.json(现在将节点设置为false)。

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "RebuildDataBase": false,
  "ConnectionStrings": {
    "AutoLot": "Server=db;Database=AutoLotPresentation;User ID=sa;Password=P@ssw0rd;"
  }
}

UseEndpoints()方法中,默认模板设置常规路由。我们将关闭它,并在整个应用中使用属性路由。注释掉(或删除)对MapControllerRoute()的调用,并替换为MapControllers(),如下所示:

app.UseEndpoints(endpoints =>
{
  endpoints.MapControllers();
});

下一个变化是将路线属性添加到自动 Lot 中的HomeController。Mvc 应用。首先,将控制器/动作模式添加到控制器本身:

[Route("[controller]/[action]")]
public class HomeController : Controller
{
  ...
}

接下来,将三条路线添加到Index()方法中,这样当没有指定动作或者没有指定控制器或动作时,它就是默认动作。此外,将HttpGet属性放在方法上,将它显式声明为“get”动作:

[Route("/")]
[Route("/[controller]")]
[Route("/[controller]/[action]")]
[HttpGet]
public IActionResult Index()
{
  return View();
}

记录

作为启动和配置过程的一部分,基本日志被添加到依赖注入容器中。ILogger<T>是日志基础设施使用的日志接口,非常简单。日志记录的主力是LoggerExtensions类,其方法定义如下:

public static class LoggerExtensions
{
  public static void LogDebug(this ILogger logger, EventId eventId,
    Exception exception, string message, params object[] args)
  public static void LogDebug(this ILogger logger, EventId eventId, string message, params object[] args)
  public static void LogDebug(this ILogger logger, Exception exception, string message, params object[] args)
  public static void LogDebug(this ILogger logger, string message, params object[] args)

  public static void LogTrace(this ILogger logger, EventId eventId,
    Exception exception, string message, params object[] args)
  public static void LogTrace(this ILogger logger, EventId eventId, string message, params object[] args)
  public static void LogTrace(this ILogger logger, Exception exception, string message, params object[] args)
  public static void LogTrace(this ILogger logger, string message, params object[] args)

  public static void LogInformation(this ILogger logger, EventId eventId,
    Exception exception, string message, params object[] args)
  public static void LogInformation(this ILogger logger, EventId eventId, string message, params object[] args)
  public static void LogInformation(this ILogger logger, Exception exception, string message, params object[] args)
  public static void LogInformation(this ILogger logger, string message, params object[] args)

  public static void LogWarning(this ILogger logger, EventId eventId,
    Exception exception, string message, params object[] args)
  public static void LogWarning(this ILogger logger, EventId eventId, string message, params object[] args)
  public static void LogWarning(this ILogger logger, Exception exception, string message, params object[] args)
  public static void LogWarning(this ILogger logger, string message, params object[] args)

  public static void LogError(this ILogger logger, EventId eventId,
    Exception exception, string message, params object[] args)
  public static void LogError(this ILogger logger, EventId eventId, string message, params object[] args)
  public static void LogError(this ILogger logger, Exception exception, string message, params object[] args)
  public static void LogError(this ILogger logger, string message, params object[] args)

  public static void LogCritical(this ILogger logger, EventId eventId,
    Exception exception, string message, params object[] args)
  public static void LogCritical(this ILogger logger, EventId eventId, string message, params object[] args)
  public static void LogCritical(this ILogger logger, Exception exception, string message, params object[] args)
  public static void LogCritical(this ILogger logger, string message, params object[] args)

  public static void Log(this ILogger logger, LogLevel logLevel, string message, params object[] args)
  public static void Log(this ILogger logger, LogLevel logLevel, EventId eventId, string message, params object[] args)
  public static void Log(this ILogger logger, LogLevel logLevel,
    Exception exception, string message, params object[] args)
  public static void Log(this ILogger logger, LogLevel logLevel, EventId eventId,
    Exception exception, string message, params object[] args)
}

ASP.NET 核心的一个强大特性是管道整体的可扩展性,特别是日志记录。只要新的框架能够与日志模式集成,默认日志记录器就可以与另一个日志框架交换。Serilog 是一个与 ASP.NET 核心集成的框架。接下来的部分将介绍如何创建基于 Serilog 的日志记录基础设施,以及如何配置 ASP.NET 核心应用来使用新的日志记录代码。

IAppLogging 接口

首先在 AutoLot 中添加名为Logging的新目录。服务项目。在这个目录中,添加一个名为IAppLogging<T>的新接口。更新此接口中的代码以匹配以下内容:

using System;
using System.Runtime.CompilerServices;

namespace AutoLot.Services.Logging
{
  public interface IAppLogging<T>
  {
    void LogAppError(Exception exception, string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppError(string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppCritical(Exception exception, string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppCritical(string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppDebug(string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppTrace(string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppInformation(string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);

    void LogAppWarning(string message,
      [CallerMemberName] string memberName = "",
      [CallerFilePath] string sourceFilePath = "",
      [CallerLineNumber] int sourceLineNumber = 0);
  }
}

属性CallerMemberNameCallerFilePathCallerLineNumber检查调用堆栈,从调用代码中获取它们的命名值。例如,如果调用LogAppWarning()的代码行在名为MyClassFile.cs的文件中的DoWork()函数中,并且位于第 36 行,那么调用:

_appLogger.LogAppWarning(“A warning”);

被转换成等价的:

_appLogger.LogAppWarning(“A warning”,”DoWork”,”c:/myfilepath/MyClassFile.cs”,36);

如果值被传入方法调用,则使用传入的值,而不是属性中的值。

应用类

AppLogging类实现了IAppLogging接口。添加一个名为AppLogging的新类,并将using语句更新如下:

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Serilog.Context;

公开类并实现IAppLogging<T>。添加一个接受ILogger<T>(ASP.NET 内核直接支持的接口)实例和IConfiguration实例的构造函数。在构造函数中,访问配置以从设置文件中检索应用名称。所有这三项(ILogger<T>IConfiguration和应用名称)都需要保存在类级变量中。

namespace AutoLot.Services.Logging
{
  public class AppLogging<T> : IAppLogging<T>
  {
    private readonly ILogger<T> _logger;
    private readonly IConfiguration _config;
    private readonly string _applicationName;

    public AppLogging(ILogger<T> logger, IConfiguration config)
    {
      _logger = logger;
      _config = config;
      _applicationName = config.GetValue<string>("ApplicationName");
    }
  }
}

Serilog 通过将属性推送到LogContext上,支持将属性添加到标准日志记录过程中。添加一个内部方法来推送MemberNameFilePathLineNumberApplicationName属性。

internal List<IDisposable> PushProperties(
  string memberName,
  string sourceFilePath,
  int sourceLineNumber)
{
  List<IDisposable> list = new List<IDisposable>
  {
    LogContext.PushProperty("MemberName", memberName),
    LogContext.PushProperty("FilePath", sourceFilePath),
    LogContext.PushProperty("LineNumber", sourceLineNumber),
    LogContext.PushProperty("ApplicationName", _applicationName)
  };
  return list;
}

每个方法实现都遵循相同的过程。第一步是调用PushProperties()方法来添加额外的属性,然后调用由ILogger<T>上的LoggerExtensions公开的适当的日志记录方法。这里列出了所有实现的接口方法:

public void LogAppError(Exception exception, string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogError(exception, message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppError(string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogError(message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppCritical(Exception exception, string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogCritical(exception, message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppCritical(string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogCritical(message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppDebug(string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogDebug(message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppTrace(string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogTrace(message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppInformation(string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogInformation(message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

public void LogAppWarning(string message,
  [CallerMemberName] string memberName = "",
  [CallerFilePath] string sourceFilePath = "",
  [CallerLineNumber] int sourceLineNumber = 0)
{
  var list = PushProperties(memberName, sourceFilePath, sourceLineNumber);
  _logger.LogWarning(message);
  foreach (var item in list)
  {
    item.Dispose();
  }
}

日志记录配置

通过向 AutoLot 的Logging目录添加一个名为LoggingConfiguration的新类,开始用 Serilog 替换默认日志程序。服务项目。将using语句更新为以下内容,并创建publicstatic类,如下所示:

using System;
using System.Collections.Generic;
using System.Data;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Serilog;
using Serilog.Events;
using Serilog.Sinks.MSSqlServer;

namespace AutoLot.Services.Logging
{
  public static class LoggingConfiguration
  {
  }
}

Serilog 使用接收器写入不同的日志记录目标。我们将用于登录 ASP.NET 核心应用的目标是文本文件、数据库和控制台。文本文件和数据库接收器需要配置、文本文件接收器的输出模板和数据库接收器的字段列表。

要设置文件模板,创建以下静态readonly字符串:

private static readonly string OutputTemplate =
  @"[{Timestamp:yy-MM-dd HH:mm:ss} {Level}]{ApplicationName}:{SourceContext}{NewLine}Message:{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";

SQL Server 接收器需要一个使用SqlColumn类型标识的列列表。添加以下代码来配置数据库列:

private static readonly ColumnOptions ColumnOptions = new ColumnOptions
{
  AdditionalColumns = new List<SqlColumn>
  {
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "ApplicationName"},
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "MachineName"},
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "MemberName"},
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "FilePath"},
    new SqlColumn {DataType = SqlDbType.Int, ColumnName = "LineNumber"},
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "SourceContext"},
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "RequestPath"},
    new SqlColumn {DataType = SqlDbType.VarChar, ColumnName = "ActionName"},
  }
};

用 Serilog 替换默认日志程序是一个三步的过程。第一步是清除现有的提供者,第二步是将 Serilog 添加到HostBuilder中,第三步是完成 Serilog 的配置。添加一个名为ConfigureSerilog()的新方法,它是对IHostBuilder的扩展方法。

public static IHostBuilder ConfigureSerilog(this IHostBuilder builder)
{
  builder
    .ConfigureLogging((context, logging) => { logging.ClearProviders(); })
    .UseSerilog((hostingContext, loggerConfiguration) =>
  {
    var config = hostingContext.Configuration;
    var connectionString = config.GetConnectionString("AutoLot").ToString();
    var tableName = config["Logging:MSSqlServer:tableName"].ToString();
    var schema = config["Logging:MSSqlServer:schema"].ToString();
    string restrictedToMinimumLevel =
      config["Logging:MSSqlServer:restrictedToMinimumLevel"].ToString();
    if (!Enum.TryParse<LogEventLevel>(restrictedToMinimumLevel, out var logLevel))
    {
      logLevel = LogEventLevel.Debug;
    }
    LogEventLevel level = (LogEventLevel)Enum.Parse(typeof(LogEventLevel), restrictedToMinimumLevel);
    var sqlOptions = new MSSqlServerSinkOptions
    {
      AutoCreateSqlTable = false,
      SchemaName = schema,
      TableName = tableName,
    };
    if (hostingContext.HostingEnvironment.IsDevelopment())
    {
      sqlOptions.BatchPeriod = new TimeSpan(0, 0, 0, 1);
      sqlOptions.BatchPostingLimit = 1;
    }
    loggerConfiguration
      .Enrich.FromLogContext()
      .Enrich.WithMachineName()
      .WriteTo.File(
        path: "ErrorLog.txt",
        rollingInterval: RollingInterval.Day,
        restrictedToMinimumLevel: logLevel,
        outputTemplate: OutputTemplate)
      .WriteTo.Console(restrictedToMinimumLevel: logLevel)
      .WriteTo.MSSqlServer(
        connectionString: connectionString,
        sqlOptions,
        restrictedToMinimumLevel: level,
        columnOptions: ColumnOptions);
  });
  return builder;
}

一切就绪后,是时候用 Serilog 替换默认日志了。

应用设置更新

自动 Lot 的所有应用设置文件(appsettings.jsonappsettings.development.jsonappsettings.production)的Logging部分。Api 和 AutoLot。Dal 项目必须用新的日志信息更新,并添加应用名称。

打开appsettings.json文件,将 JSON 更新为以下内容,确保为ApplicationName节点使用正确的项目名称,并更新连接字符串以匹配您的配置:

//appsettings.json
{
  "Logging": {
    "MSSqlServer": {
      "schema": "Logging",
      "tableName": "SeriLogs",
      "restrictedToMinimumLevel": "Warning"
    }
  },
  "ApplicationName": "AutoLot.Api",
  "AllowedHosts": "*"
}

//appsettings.development.json
{
  "Logging": {
    "MSSqlServer": {
      "schema": "Logging",
      "tableName": "SeriLogs",
      "restrictedToMinimumLevel": "Warning"
    }
  },
  "RebuildDataBase": false,
  "ApplicationName": "AutoLot.Api - Dev",
  "ConnectionStrings": {
    "AutoLot": "Server=.,5433;Database=AutoLot;User ID=sa;Password=P@ssw0rd;"
  }
}

//appsettings.production.json
{
  "Logging": {
    "MSSqlServer": {
      "schema": "Logging",
      "tableName": "SeriLogs",
      "restrictedToMinimumLevel": "Warning"
    }
  },
  "RebuildDataBase": false,
  "ApplicationName": "AutoLot.Api - Prod",
  "ConnectionStrings": {
    "AutoLot": "It's a secret"
  }
}

Program.cs 更新

将以下using语句添加到两个自动 Lot 中的Program.cs文件中。Api 和 AutoLot。Mvc 项目:

using AutoLot.Services.Logging;

接下来,将两个项目中的CreateHostBuilder()方法更新为:

public static IHostBuilder CreateHostBuilder(string[] args) =>
  Host.CreateDefaultBuilder(args)
          .ConfigureWebHostDefaults(webBuilder =>
          {
            webBuilder.UseStartup<Startup>();
          }).ConfigureSerilog();

Startup.cs 更新

将以下using语句添加到两个自动 Lot 中的Startup.cs文件中。Api 和 AutoLot。Mvc 项目:

using AutoLot.Services.Logging;

接下来,需要将新的日志记录接口添加到依赖注入容器中。将以下内容添加到两个项目的ConfigureServices()方法中:

services.AddScoped(typeof(IAppLogging<>), typeof(AppLogging<>));

控制器更新

下一个变化是将对ILogger的任何引用更新为IAppLogging。从自动手枪中的WeatherForecastController开始。Api 项目。将下面的using语句添加到类中:

using AutoLot.Services.Logging;

接下来,更新ILogger<T> IAppLogging<T>

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
...
  private readonly IAppLogging<WeatherForecastController> _logger;
  public WeatherForecastController(IAppLogging<WeatherForecastController> logger)
  {
    _logger = logger;
  }
...
}

现在更新自动 Lot 中的HomeController。Mvc 项目。将下面的using语句添加到类中:

using AutoLot.Services.Logging;

接下来,更新ILogger<T> IAppLogging<T>

[Route("[controller]/[action]")]
public class HomeController : Controller
{
  private readonly IAppLogging<HomeController> _logger;
  public HomeController(IAppLogging<HomeController> logger)
  {
    _logger = logger;
  }
...
}

然后,只需像这样简单地调用记录器,就可以在每个控制器中完成日志记录:

//WeatherForecastController.cs (AutoLot.Api)
[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
  _logger.LogAppWarning("This is a test");
...
}

//HomeController.cs (AutoLot.Mvc)
[Route("/")]
[Route("/[controller]")]
[Route("/[controller]/[action]")]
[HttpGet]
public IActionResult Index()
{
  _logger.LogAppWarning("This is a test");
  return View();
}

测试日志框架

有了 Serilog 之后,是时候测试应用的日志记录了。如果您使用的是 Visual Studio,请设置 AutoLot。Mvc 应用作为启动应用(在解决方案资源管理器中右击,选择“设为启动项目”,然后单击绿色的运行箭头,或按 F5)。如果使用的是 Visual Studio 代码,打开终端窗口(Ctrl+),导航到AutoLot.Mvc目录,输入dotnet run`。

使用 Visual Studio,浏览器将自动启动到Home/Index视图(您将看到“欢迎/了解使用 ASP.NET 核心构建应用”)。如果您正在使用 Visual Studio 代码,您将需要打开一个浏览器并导航到https://localhost:5001。一旦浏览器加载完毕,您就可以关闭它,因为登录调用是在主页加载时进行的。使用 VS 关闭浏览器将会停止调试。若要停止使用 VS 代码进行调试,请在终端窗口中按 Ctrl+C。

在项目目录中,您会看到一个名为ErrorLogYYYYMMDD.txt的文件。在该文件中,您将看到一个类似如下的条目:

[YY-MM-DD hh:mm:ss Warning]AutoLot.Mvc - Dev:AutoLot.Mvc.Controllers.HomeController
Message:This is a test
in method Index at D:\Projects\Books\csharp9-wf\Code\New\Chapter_29\AutoLot.Mvc\Controllers\HomeController.cs:30

测试自动测试中的记录代码。Api 项目,将该项目设置为启动应用(VS)或导航到 AutoLot。终端窗口中的 Api 目录(VCS)。按 F5 或输入dotnet run并导航至https://localhost:44375/swagger/index.html。这将加载 API 应用的 Swagger 页面,如图 29-6 所示。

img/340876_10_En_29_Fig6_HTML.jpg

图 29-6

AutoLot 的初始 Swagger 页面。美国石油学会(American Petroleum Institute)

点击WeatherForecast条目的获取按钮。这将打开一个屏幕,显示该操作方法的详细信息,包括一个“尝试”选项,如图 29-7 所示。

img/340876_10_En_29_Fig7_HTML.jpg

图 29-7

天气预报控制器的 Get 方法的详细信息

点击“尝试”按钮后,点击执行按钮(图 29-8 ),顾名思义,执行对端点的调用。

img/340876_10_En_29_Fig8_HTML.jpg

图 29-8

执行天气预报控制器的 Get 方法的详细信息

在自动售货机里。Api 项目目录下,你会再次看到一个名为ErrorLogYYYYMMDD.txt的文件。在该文件中,您会发现类似于以下内容的条目:

[YY-MM-DD hh:mm:ss Warning]AutoLot.Api - Dev:AutoLot.Api.Controllers.WeatherForecastController
Message:This is a test
in method Get at D:\Projects\Books\csharp9-wf\Code\New\Chapter_29\AutoLot.Api\Controllers\WeatherForecastController.cs:30

Note

ASP.NET 核心 5 中新增的 Swagger 在 API 模板中是默认启用的。斯瓦格将在下一章详细讨论。

摘要

本章介绍了 ASP.NET 核心,是涵盖 ASP.NET 核心的一系列章节中的第一章。本章首先简要回顾了 ASP.NET 的历史,然后介绍了 ASP.NET 核心中的经典 ASP.NET MVC 和 ASP.NET Web API 的特性。

接下来的部分将研究 ASP.NET 核心中的新特性以及它们是如何工作的。然后,在了解了运行和调试 ASP.NET 核心应用的不同方法后,您用两个 ASP.NET 核心项目、一个应用服务的公共库和 AutoLot 数据访问层(来自第二十三章)建立了解决方案。最后,在这两个项目中,您用 Serilog 替换了默认的 ASP.NET 岩心记录器。

在下一章中,您将完成自动 Lot。Api 应用。

三十、ASP.NET 核心的 RESTful 服务

前一章介绍了 ASP.NET 核心,讨论了一些新特性,创建了项目,并更新了 AutoLot 中的代码。Mvc 和 AutoLot。包含自动 Lot 的 Api。Dal 和 Serilog 测井。本章重点介绍如何完成自动 Lot。Api RESTful 服务。

Note

本章的示例代码在本书 repo 的Chapter 30目录中。请随意继续你在第二十九章开始的解决方案。

介绍 ASP.NET 核心 RESTful 服务

ASP.NET MVC 框架几乎一发布就开始获得关注,微软发布了 ASP.NET Web API 和 ASP.NET MVC 4 以及 Visual Studio 2012。ASP.NET Web API 2 随 Visual Studio 2013 一起发布,然后随 Visual Studio 2013 Update 1 更新到 2.2 版。

从一开始,ASP.NET Web API 就被设计成一个基于服务的框架,用于构建REpresentationalSstateTtransfer(RESTful)服务。它基于 MVC 框架减去 V (视图),优化创建无头服务。这些服务可以被任何技术调用,而不仅仅是微软旗下的那些。对 Web API 服务的调用基于核心 HTTP 动词(Get、Put、Post、Delete ),通过统一资源标识符(URI ),如下所示:

http://www.skimedic.com:5001/api/cars

如果这看起来像一个统一资源定位器(URL),那是因为它就是!URL 只是一个指向网络上物理资源的 URI。

对 Web API 的调用使用特定主机上的HyperTextTtransferProtocol(HTTP)方案(在本例中为 www.skimedic.com )、特定端口(上例中为 5001),后跟路径(api/cars)和可选的查询和片段(本例中未显示)。Web API 调用也可以在消息体中包含文本,这一点你会在本章中看到。正如前一章所讨论的,ASP.NET 核心将 Web API 和 MVC 统一到一个框架中。

RESTful 服务的控制器动作

回想一下,动作返回一个IActionResult(或者异步操作返回一个Task<IActionResult>)。除了返回特定 HTTP 状态代码的ControllerBase中的 helper 方法之外,action 方法还可以以格式化的 JavaScript 对象符号(JSON)响应的形式返回内容。

Note

严格来说,动作方法可以返回多种格式。JSON 包含在本书中,因为它是最常见的。

格式化的 JSON 响应结果

大多数 RESTful APIs 使用 JSON(发音为“Jay-saw”)从客户端接收数据,并向客户端发送数据。这里显示了一个简单的 JSON 示例,包含两个值:

[
  "value1",
  "value2"
]

Note

第二十章使用System.Text.Json深入讨论了 JSON 序列化。

API 也使用 HTTP 状态代码来传达成功或失败。在表 29-3 中的前一章中列出了一些在ControllerBase类中可用的 HTTP 状态助手方法。成功的请求返回 200 范围内的状态代码,200 (OK)是最常见的成功代码。事实上,它是如此普遍,以至于你不必显式地返回一个 OK。如果没有抛出异常,并且代码没有指定状态代码,那么将向客户端返回 200 以及任何数据。

要设置以下示例,请向自动 Lot 添加一个新的控制器。Api 项目。在Controllers目录中添加一个名为ValuesController.cs的新文件,并更新代码以匹配以下内容:

using System.Collections.Generic;
using Microsoft.AspNetCore.Mvc;

[Route("api/[controller]")]
[ApiController]
public class ValuesController : ControllerBase
{
}

Note

如果使用 Visual Studio,有一个架子工负责控制器。要访问它,右击自动 Lot 中的Controllers文件夹。Api 项目并选择添加控制器。选择 MVC 控制器-空。

该代码使用一个值(api)和一个令牌([controller])为控制器设置路由。这个路由模板将匹配类似于 www.skimedic.com/ api / values 的 URL。下一个属性(ApiController)选择几个特定于 API 的特性(在下一节讨论)。最后,控制器继承自ControllerBase。正如在第二十九章中所讨论的,ASP.NET 核心将经典 ASP.NET 中所有可用的不同控制器类型整合为一个,命名为Controller,带有一个基类ControllerBaseController类提供特定于视图的功能(MVC 中的 V ),而ControllerBase为 MVC 风格的应用提供所有其余的核心功能。

有几种方法可以将内容作为 JSON 从 action 方法中返回。以下示例都返回相同的 JSON 以及 200 状态代码。不同之处主要在文体上。将以下代码添加到您的ValuesController类中:

[HttpGet]
public IActionResult Get()
{
  return Ok(new string[] { "value1", "value2" });
}
[HttpGet("one")]
public IEnumerable<string> Get1()
{
  return new string[] { "value1", "value2" };
}
[HttpGet("two")]
public ActionResult<IEnumerable<string>> Get2()
{
  return new string[] { "value1", "value2" };
}
[HttpGet("three")]
public string[] Get3()
{
  return new string[] { "value1", "value2" };
}
[HttpGet("four")]
public IActionResult Get4()
{
    return new JsonResult(new string[] { "value1", "value2" });
}

要对此进行测试,请运行 AutoLot。Api 应用,你会看到 Swagger UI 中列出了从ValuesController开始的所有方法,如图 30-1 所示。回想一下,在确定路由时,Controller后缀被从名称中去掉,因此ValuesController上的端点被映射为Values,而不是ValuesController

img/340876_10_En_30_Fig1_HTML.jpg

图 30-1。

Swagger 文档页面

要执行其中一种方法,请单击“获取”按钮、“尝试”按钮,然后单击“执行”按钮。一旦方法执行完毕,UI 就会更新以显示结果,图 30-2 中只显示了 Swagger UI 的相关部分。

img/340876_10_En_30_Fig2_HTML.jpg

图 30-2。

Swagger 服务器响应信息

您将看到,执行每个方法都会产生相同的 JSON 结果。

ApiController 属性

在 ASP.NET 核心 2.1 中添加的ApiController属性在与ControllerBase类结合时提供了特定于 REST 的规则、约定和行为。这些约定和行为将在以下几节中概述。

属性路由要求

使用ApiController属性时,控制器必须使用属性路由。这只是强化了许多人认为的最佳实践。

自动 400 响应

如果模型绑定有问题,该操作将自动返回 HTTP 400(错误请求)响应代码。这将替换以下代码:

if (!ModelState.IsValid)
{
  return BadRequest(ModelState);
}

ASP.NET 核心使用ModelStateInvalidFilter动作过滤器来做前面的检查。当出现绑定或验证错误时,HTTP 400 响应的主体中会包含错误的详细信息。这里显示了一个示例:

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "traceId": "|7fb5e16a-4c8f23bbfc974667.",
  "errors": {
    "": [
      "A non-empty request body is required."
    ]
  }
}

这种行为可以通过在Startup.cs类的ConfigureServices()方法中进行配置来禁用。

services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.SuppressModelStateInvalidFilter = true;
    });

绑定源参数推断

模型绑定引擎将根据表 30-1 中列出的约定推断在哪里检索值。

表 30-1。

绑定源推理约定

|

来源

|

参数界限

FromBody 除了有特殊含义的内置类型,如IFormCollectionCancellationToken之外,对于复杂类型参数进行推断。只能存在一个FromBody参数,否则将抛出异常。如果简单类型需要绑定(例如,stringint),那么FromBody属性仍然是必需的。
FromForm 为类型IFormFileIFormFileCollection的动作参数推断。当参数标有FromForm时,将推断出多部分/表单数据内容类型。
FromRoute 对于匹配路由令牌名称的任何参数名称进行推断。
FromQuery 推断出任何其他行动参数。

这种行为可以通过在Startup.cs类的ConfigureServices()方法中进行配置来禁用。

services.AddControllers().ConfigureApiBehaviorOptions(options =>
{
  //suppress all binding inference
  options.SuppressInferBindingSourcesForParameters= true;
  //suppress multipart/form-data content type inference
  options. SuppressConsumesConstraintForFormFileParameters = true;
});

错误状态代码的问题详细信息

ASP.NET 核心将错误结果(状态为 400 或更高)转换为带有ProblemDetails的结果。这里列出了ProblemDetails类型:

public class ProblemDetails
{
  public string Type { get; set; }
  public string Title { get; set; }
  public int? Status { get; set; }
  public string Detail { get; set; }
  public string Instance { get; set; }
  public IDictionary<string, object> Extensions { get; }
    = new Dictionary<string, object>(StringComparer.Ordinal);
}

为了测试这种行为,向ValuesController添加另一个方法,如下所示:

[HttpGet("error")]
public IActionResult Error()
{
  return NotFound();
}

运行应用并使用 Swagger UI 来执行新的Error端点。结果仍然是 404 ( NotFound)状态代码,但是在响应的主体中返回了附加信息。以下是一个示例响应(您的traceId会有所不同):

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-9a609e7e05f46d4d82d5f897b90da624-a6484fb34a7d3a44-00"
}

这种行为可以通过在Startup.cs类的ConfigureServices()方法中进行配置来禁用。

services.AddControllers()
    .ConfigureApiBehaviorOptions(options =>
    {
        options.SuppressMapClientErrors = true;
    });

当行为被禁用时,对Error端点的调用返回一个 404,没有任何附加信息。

更新 Swagger/OpenAPI 设置

Swagger(也称为 OpenAPI)是一个用于记录 RESTful APIs 的开放标准。将 Swagger 添加到 ASP.NET 核心 API 的两个主要选择是 Swashbuckle 和 NSwag。ASP.NET 核心 5 现在包括 Swashbuckle 作为新项目模板的一部分。为自动 Lot 生成的swagger.json文件。Api 包含站点、每个端点以及端点中涉及的任何对象的信息。

Swagger UI 是一个基于 web 的 UI,它提供了一个交互式界面来检查和测试应用的端点(就像你在本章前面所做的那样)。通过将文档添加到生成的swagger.json文件中,可以增强这种体验。

更新启动类中的 Swagger 调用

默认的 API 模板在Startup.csConfigureService()方法中添加了生成swagger.json文件的代码。

services.AddSwaggerGen(c =>
{
  c.SwaggerDoc("v1", new OpenApiInfo { Title = "AutoLot.Api", Version = "v1" });
});

默认代码的第一个变化是向OpenApiInfo添加元数据。将AddSwaggerGen()调用更新为以下内容,这将更新标题并添加描述和许可信息:

services.AddSwaggerGen(c =>
{
  c.SwaggerDoc("v1",
    new OpenApiInfo
    {
      Title = "AutoLot Service",
      Version = "v1",
      Description = "Service to support the AutoLot dealer site",
      License = new OpenApiLicense
      {
        Name = "Skimedic Inc",
        Url = new Uri("http://www.skimedic.com")
      }
    });
});

下一步是将UseSwagger()UseSwaggerUI()移出开发专用块,进入Configure()中的主执行路径。另外,将标题从“自动锁定”更新为“自动锁定服务 v1”。Api v1。

public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ApplicationDbContext context)
{
  if (env.IsDevelopment())
  {
    //If in development environment, display debug info
    app.UseDeveloperExceptionPage();
    //Original code
    //app.UseSwagger();
    //app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "AutoLot.Api v1"));
    //Initialize the database
    if (Configuration.GetValue<bool>("RebuildDataBase"))
    {
      SampleDataInitializer.ClearAndReseedDatabase(context);
    }
  }

  // Enable middleware to serve generated Swagger as a JSON endpoint.
  app.UseSwagger();
  // Enable middleware to serve swagger-ui (HTML, JS, CSS, etc.),
  // specifying the Swagger JSON endpoint.
  app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/v1/swagger.json", "AutoLot Service v1"); });
...
}

前面的代码选择使用 Swagger ( app.UseSwagger())和 Swagger UI ( app.useSwaggerUI())。它还为swagger.json文件配置端点。

添加 XML 文档文件

。NET Core 可以通过检查三斜线(///)注释的方法,从您的项目中生成一个 XML 文档文件。若要使用 Visual Studio 启用此功能,请右键单击自动标注。Api 项目并打开“属性”窗口。选择构建页面,选中 XML 文档文件复选框,并输入AutoLot.Api.xml作为文件名。同样,在“抑制警告”文本框中输入 1591 ,如图 30-3 所示。

img/340876_10_En_30_Fig3_HTML.jpg

图 30-3。

添加 XML 文档文件并取消 1591

相同的设置可以直接输入到项目文件中。下面显示了要添加的PropertyGroup:

  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <DocumentationFile>AutoLot.Api.xml</DocumentationFile>
    <NoWarn>1701;1702;1591;</NoWarn>
  </PropertyGroup>

NoWarn 1591 设置为没有 XML 注释的方法关闭编译器警告。

Note

1701 和 1702 警告是早期经典的延续。方法公开的。NET 核心编译器。

要查看这个过程的运行情况,请将ValuesController的 Get 方法更新为:

/// <summary>
/// This is an example Get method returning JSON
/// </summary>
/// <remarks>This is one of several examples for returning JSON:
/// <pre>
/// [
///   "value1",
///   "value2"
/// ]
/// </pre>
/// </remarks>
/// <returns>List of strings</returns>
[HttpGet]
public IActionResult Get()
{
  return Ok(new string[] { "value1", "value2" });
}

当您构建项目时,会在项目的根目录下创建一个名为AutoLot.Api.xml的新文件。打开文件以查看您刚刚添加的注释。

<?xml version="1.0"?>
<doc>
  <assembly>
    <name>AutoLot.Api</name>
  </assembly>
  <members>
    <member name="M:AutoLot.Api.Controllers.ValuesController.Get">
      <summary>
        This is an example Get method returning JSON
      </summary>
      <remarks>This is one of several examples for returning JSON:
        <pre>
        [
          "value1",
          "value2"
        ]
        </pre>
      </remarks>
      <returns>List of strings</returns>    </member>
  </members>
</doc>

Note

使用 Visual Studio 时,如果在类或方法定义前输入三个反斜杠,Visual Studio 将为您剔除初始 XML 注释。

下一步是将 XML 注释合并到生成的swagger.json文件中。

向 SwaggerGen 添加 XML 注释

生成的 XML 注释必须添加到swagger.json生成过程中。首先向Startup类添加以下using语句:

using System.IO;
using System.Reflection;

通过调用AddSwaggerGen()方法中的IncludeXmlComments()方法,XML 文档文件被添加到 Swagger 中。导航到Startup类的ConfigureServices()方法,并将AddSwaggerGen()方法更新为以下内容,以添加 XML 文档文件:

services.AddSwaggerGen(c =>
{
  c.SwaggerDoc("v1",
    new OpenApiInfo
    {
      Title = "AutoLot Service",
      Version = "v1",
      Description = "Service to support the AutoLot dealer site",
      License = new OpenApiLicense
      {
        Name = "Skimedic Inc",
        Url = new Uri("http://www.skimedic.com")
      }
    });
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    c.IncludeXmlComments(xmlPath);
});

运行应用并检查 Swagger UI。注意集成到 Swagger UI 中的 XML 注释,如图 30-4 所示。

img/340876_10_En_30_Fig4_HTML.jpg

图 30-4。

集成到 Swagger UI 中的 XML 文档

除了 XML 文档之外,应用端点上的附加配置可以改进文档。

API 端点的附加文档选项

Swagger 文档还有一些额外的属性。要使用它们,首先将下面的using语句添加到ValuesController.cs文件中:

using Microsoft.AspNetCore.Http;
using Swashbuckle.AspNetCore.Annotations;

Produces属性表示端点的内容类型。ProducesResponseType属性使用StatusCodes枚举来指示端点可能的返回代码。更新ValuesControllerGet()方法,指定application/json为返回类型,动作结果将返回 200 OK 或 400 Bad 请求。

[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult<IEnumerable<string>> Get()
{
  return new string[] {"value1", "value2"};
}

虽然ProducesResponseType属性将响应代码添加到文档中,但是该信息不能被定制。幸运的是,Swashbuckle 为此添加了SwaggerResponse属性。将Get()方法更新如下:

[HttpGet]
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerResponse(200, "The execution was successful")]
[SwaggerResponse(400, "The request was invalid")]
public ActionResult<IEnumerable<string>> Get()
{
  return new string[] {"value1", "value2"};
}

在 Swagger 注释被选取并添加到生成的文档之前,它们必须被启用。打开Startup.cs并导航至Configure()方法。将对AddSwaggerGen()的呼叫更新为:

services.AddSwaggerGen(c =>
{
  c.EnableAnnotations();
...
});

现在,当您查看 Swagger UI 的 responses 部分时,您将看到定制的消息,如图 30-5 所示。

img/340876_10_En_30_Fig5_HTML.jpg

图 30-5。

更新了 Swagger UI 中的响应

Note

Swashbuckle 支持许多额外的定制。更多信息请咨询 https://github.com/domaindrivendev/Swashbuckle.AspNetCore 的文档。

构建 API 操作方法

自动手枪的大部分功能。Api 应用可分为以下几种方法:

  • GetOne()

  • GetAll()

  • UpdateOne()

  • AddOne()

  • DeleteOne()

主要的 API 方法将在通用的基本 API 控制器中实现。首先在 AutoLot 的Controllers目录中创建一个名为Base的新文件夹。Api 项目。在这个文件夹中,添加一个名为BaseCrudController.cs的新类。将using语句和类定义更新如下:

using System;
using System.Collections.Generic;
using AutoLot.Dal.Exceptions;
using AutoLot.Models.Entities.Base;
using AutoLot.Dal.Repos.Base;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Swashbuckle.AspNetCore.Annotations;

namespace AutoLot.Api.Controllers.Base
{
  [ApiController]
  public abstract class BaseCrudController<T, TController> : ControllerBase
    where T : BaseEntity, new()
    where TController : BaseCrudController<T, TController>
  {
  }
}

这个类是publicabstract,并且继承了ControllerBase。该类接受两个泛型参数。第一种类型被限制为从BaseEntity派生,并有一个默认的构造函数,第二种类型从BaseCrudController派生(表示派生的控制器)。当ApiController属性被添加到基类中时,派生的控制器将获得该属性提供的功能。

Note

此类中没有定义路线。它将使用派生类来设置。

构造函数

下一步是添加两个受保护的类级变量:一个保存IRepo<T>的实例,另一个保存IAppLogging<T>的实例。这两者都应该使用构造函数来设置。

protected readonly IRepo<T> MainRepo;
protected readonly IAppLogging<TController> Logger;
protected BaseCrudController(IRepo<T> repo, IAppLogging<TController> logger)
{
  MainRepo = repo;
  Logger = logger;
}

Get 方法

有两个 HTTP Get 方法,GetOne()GetAll()。两者都使用传递给控制器的回购。首先,添加GetAll()方法。此方法用作派生控制器的路由模板的端点。

/// <summary>
/// Gets all records
/// </summary>
/// <returns>All records</returns>
/// <response code="200">Returns all items</response>
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[SwaggerResponse(200, "The execution was successful")]
[SwaggerResponse(400, "The request was invalid")]
[HttpGet]
public ActionResult<IEnumerable<T>> GetAll()
{
  return Ok(MainRepo.GetAllIgnoreQueryFilters());
}

下一个方法基于id获得一条记录,该记录作为必需的 route 参数传递,并被添加到派生控制器的 route 中。

/// <summary>
/// Gets a single record
/// </summary>
/// <param name="id">Primary key of the record</param>
/// <returns>Single record</returns>
/// <response code="200">Found the record</response>
/// <response code="204">No content</response>
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SwaggerResponse(200, "The execution was successful")]
[SwaggerResponse(204, "No content")]
[HttpGet("{id}")]
public ActionResult<T> GetOne(int id)
{
  var entity = MainRepo.Find(id);
  if (entity == null)
  {
    return NotFound();
  }
  return Ok(entity);
}

路线值自动分配给id参数(implicit [FromRoute])。

UpdateOne 方法

HTTP Put 谓词表示对记录的更新。此处列出了该方法,并附有解释:

/// <summary>
/// Updates a single record
/// </summary>
/// <remarks>
/// Sample body:
/// <pre>
/// {
///   "Id": 1,
///   "TimeStamp": "AAAAAAAAB+E="
///   "MakeId": 1,
///   "Color": "Black",
///   "PetName": "Zippy",
///   "MakeColor": "VW (Black)",
/// }
/// </pre>
/// </remarks>
/// <param name="id">Primary key of the record to update</param>
/// <returns>Single record</returns>
/// <response code="200">Found and updated the record</response>
/// <response code="400">Bad request</response>
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerResponse(200, "The execution was successful")]
[SwaggerResponse(400, "The request was invalid")]
[HttpPut("{id}")]
public IActionResult UpdateOne(int id,T entity)
{
  if (id != entity.Id)
  {
    return BadRequest();
  }

  try
  {
    MainRepo.Update(entity);
  }
  catch (CustomException ex)
  {
    //This shows an example with the custom exception
    //Should handle more gracefully
    return BadRequest(ex);
  }
  catch (Exception ex)
  {
    //Should handle more gracefully
    return BadRequest(ex);
  }

  return Ok(entity);
}

该方法首先基于具有所需的Id路由参数的派生控制器的路由,将路由设置为HttpPut请求。路由值被分配给id参数(implicit [FromRoute],实体从请求体中分配(implicit [FromBody])。还要注意没有对ModelState有效性的检查。这也由ApiController属性自动完成。如果ModelState无效,将向客户端返回一个 400 (BadRequest)。

该方法检查以确保路由值(id)与正文中的id匹配。如果没有,则返回一个BadRequest。如果是,回购用于更新记录。如果更新因异常而失败,则向客户端返回 400。如果全部成功,则向客户机返回 200 (OK ),并将更新后的记录作为响应体传入。

Note

这个例子中的异常处理(以及其他例子)非常不完善。生产应用应该利用到目前为止您在本书中学到的所有知识,按照需求的指示优雅地处理问题。

添加一个方法

HTTP Post 谓词表示对记录的插入。此处列出了该方法,并附有解释:

/// <summary>
/// Adds a single record
/// </summary>
/// <remarks>
/// Sample body:
/// <pre>
/// {
///   "Id": 1,
///   "TimeStamp": "AAAAAAAAB+E="
///   "MakeId": 1,
///   "Color": "Black",
///   "PetName": "Zippy",
///   "MakeColor": "VW (Black)",
/// }
/// </pre>
/// </remarks>
/// <returns>Added record</returns>
/// <response code="201">Found and updated the record</response>
/// <response code="400">Bad request</response>
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerResponse(201, "The execution was successful")]
[SwaggerResponse(400, "The request was invalid")]
[HttpPost]
public ActionResult<T> AddOne(T entity)
{
  try
  {
    MainRepo.Add(entity);
  }
  catch (Exception ex)
  {
    return BadRequest(ex);
  }
  return CreatedAtAction(nameof(GetOne), new {id = entity.Id}, entity);
}

该方法首先将路由定义为 HTTP Post。因为是新记录,所以没有路径参数。如果回购成功添加记录,则响应为CreatedAtAction()。这将向客户机返回一个 HTTP 201,新创建的实体的 URL 作为Location头值。响应的主体是新添加的实体 JSON。

DeleteOne 方法

HTTP Delete 谓词表示记录的移除。一旦从主体内容创建了实例,就可以使用 repo 来处理删除。下面列出了整个方法:

/// <summary>
/// Deletes a single record
/// </summary>
/// <remarks>
/// Sample body:
/// <pre>
/// {
///   "Id": 1,
///   "TimeStamp": "AAAAAAAAB+E="
/// }
/// </pre>
/// </remarks>
/// <returns>Nothing</returns>
/// <response code="200">Found and deleted the record</response>
/// <response code="400">Bad request</response>
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[SwaggerResponse(200, "The execution was successful")]
[SwaggerResponse(400, "The request was invalid")]
[HttpDelete("{id}")]
public ActionResult<T> DeleteOne(int id, T entity)
{
  if (id != entity.Id)
  {
    return BadRequest();
  }
  try
  {
    MainRepo.Delete(entity);
  }
  catch (Exception ex)
  {
    //Should handle more gracefully
    return new BadRequestObjectResult(ex.GetBaseException()?.Message);
  }
  return Ok();
}

该方法首先将路由定义为 HTTP Delete,并将id作为必需的路由参数。将路由中的id与正文中实体的其余部分发送的id进行比较,如果它们不匹配,则返回一个BadRequest。如果回购成功删除记录,则响应为 OK;如果有错误,响应是一个BadRequest

这就完成了基本控制器。

小车控制器

自动手枪。Api app 需要一个额外的 HTTP Get 方法来基于一个Make值获取Car记录。这将进入一个名为CarsController的新类别。在Controllers文件夹中创建一个名为CarsController的新的空 API 控制器。将using语句更新如下:

using System.Collections.Generic;
using AutoLot.Api.Controllers.Base;
using Microsoft.AspNetCore.Mvc;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Http;
using Swashbuckle.AspNetCore.Annotations;

CarsController源自BaseCrudController并定义控制器路线。构造函数接受特定于实体的 repo 和记录器的一个实例。以下是初始控制器布局:

namespace AutoLot.Api.Controllers
{
  [Route("api/[controller]")]
  public class CarsController : BaseCrudController<Car, CarsController>
  {
    public CarsController(ICarRepo carRepo, IAppLogging<CarsController> logger) : base(carRepo, logger)
    {
    }
  }
}

CarsController用另一个动作方法扩展了基类,该方法获取特定品牌的所有汽车。添加以下代码,解释如下:

/// <summary>
/// Gets all cars by make
/// </summary>
/// <returns>All cars for a make</returns>
/// <param name="id">Primary key of the make</param>
/// <response code="200">Returns all cars by make</response>
[Produces("application/json")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SwaggerResponse(200, "The execution was successful")]
[SwaggerResponse(204, "No content")]

[HttpGet("bymake/{id?}")]
public ActionResult<IEnumerable<Car>> GetCarsByMake(int? id)
{
  if (id.HasValue && id.Value>0)
  {
    return Ok(((ICarRepo)MainRepo).GetAllBy(id.Value));
  }
  return Ok(MainRepo.GetAllIgnoreQueryFilters());
}

HTTP Get 属性使用bymake常量扩展路由,然后使用 make 的可选id进行过滤,例如:

https://localhost:5021/api/cars/bymake/5

接下来,它检查是否为id传递了一个值。如果没有,它将获取所有车辆。如果传入了一个值,它将使用CarRepoGetAllBy()方法来获取汽车的制造商。由于基类的MainRepo保护属性被定义为IRepo<T>,所以必须将其强制转换回ICarRepo接口。

剩余的控制器

其余的特定于实体的控制器都是从BaseCrudController派生出来的,但是没有添加任何额外的功能。将四个名为CreditRisksControllerCustomersControllerMakesControllerOrdersController的空 API 控制器添加到Controllers文件夹中。其余的控制器都显示在这里:

//CreditRisksController.cs
using AutoLot.Api.Controllers.Base;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Mvc;

namespace AutoLot.Api.Controllers
{
  [Route("api/[controller]")]
  public class CreditRisksController : BaseCrudController<CreditRisk, CreditRisksController>
  {
    public CreditRisksController(
      ICreditRiskRepo creditRiskRepo, IAppLogging<CreditRisksController> logger)
      : base(creditRiskRepo, logger)
    {
    }
  }
}

//CustomersController.cs
using AutoLot.Api.Controllers.Base;
using AutoLot.Models.Entities;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Mvc;

namespace AutoLot.Api.Controllers
{
  [Route("api/[controller]")]
  public class CustomersController : BaseCrudController<Customer, CustomersController>
  {
    public CustomersController(
      ICustomerRepo customerRepo, IAppLogging<CustomersController> logger)
      : base(customerRepo, logger)
    {
    }
  }
}

//MakesController.cs
using AutoLot.Api.Controllers.Base;
using AutoLot.Models.Entities;
using Microsoft.AspNetCore.Mvc;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Services.Logging;

namespace AutoLot.Api.Controllers
{
  [Route("api/[controller]")]
  public class MakesController : BaseCrudController<Make, MakesController>
  {
    public MakesController(IMakeRepo makeRepo, IAppLogging<MakesController> logger)
      : base(makeRepo, logger)
    {
    }
  }
}

//OrdersController.cs
using AutoLot.Api.Controllers.Base;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Models.Entities;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Mvc;

namespace AutoLot.Api.Controllers
{
  [Route("api/[controller]")]
  public class OrdersController : BaseCrudController<Order, OrdersController>
  {
    public OrdersController(IOrderRepo orderRepo, IAppLogging<OrdersController> logger) : base(orderRepo, logger)
    {
    }
  }
}

这就完成了所有的控制器,您可以使用 Swagger UI 来测试所有的功能。如果您要添加/更新/删除记录,请将appsettings.development.json文件中的RebuildDataBase值更新为 true。

{
...
  "RebuildDataBase": true,
...
}

异常过滤器

当 Web API 应用中出现异常时,不会显示错误页面,因为用户通常是另一个应用,而不是人。任何信息都必须作为 JSON 与 HTTP 状态代码一起发送。正如在第二十九章中所讨论的,ASP.NET 内核允许创建在未处理异常事件中运行的过滤器。可以在控制器级别或动作级别全局应用过滤器。对于这个应用,您将构建一个异常过滤器来发回格式化的 JSON(连同 HTTP 500 ),如果站点运行在调试模式下,还将包含一个堆栈跟踪。

Note

过滤器是 ASP.NET 核心的一个非常强大的功能。在这一章中,我们只研究异常过滤器,但是还可以创建更多的过滤器,从而在构建 ASP.NET 核心应用时节省大量时间。有关过滤器的完整信息,请参考 https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/filters 的文档。

创建 CustomExceptionFilter

创建一个名为Filters的新目录,并在该目录中添加一个名为CustomExceptionFilterAttribute.cs的新类。将using声明更新如下:

using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;

将类更改为public并从ExceptionFilterAttribute继承。覆盖OnException()方法,如下所示:

namespace AutoLot.Api.Filters
{
  public class CustomExceptionFilterAttribute : ExceptionFilterAttribute
  {
    public override void OnException(ExceptionContext context)
    {
    }
  }
}

不像 ASP.NET 核心中的大多数过滤器有一个 before 和 after 事件处理程序,异常过滤器只有一个处理程序:OnException()(或OnExceptionAsync())。这个处理程序有一个参数,ExceptionContext。该参数提供对ActionContext以及抛出的异常的访问。

过滤器还参与依赖注入,允许在代码中访问容器中的任何项目。在这个例子中,我们需要将一个IWebHostEnvironment实例注入过滤器。这将用于确定运行时环境。如果环境是Development,响应也应该包括堆栈跟踪。添加一个类级变量来保存IWebHostEnvironment的实例,并添加构造函数,如下所示:

private readonly IWebHostEnvironment _hostEnvironment;
public CustomExceptionFilterAttribute(IWebHostEnvironment hostEnvironment)
{
  _hostEnvironment = hostEnvironment;
}

OnException()事件处理程序中的代码检查抛出的异常类型,并构建适当的响应。如果环境为Development,则堆栈跟踪包含在响应消息中。构建一个包含发送给调用请求的值的动态对象,并在IActionResult中返回。更新后的方法如下所示:

public override void OnException(ExceptionContext context)
{
  var ex = context.Exception;
  string stackTrace = _hostEnvironment.IsDevelopment() ? context.Exception.StackTrace : string.Empty;
  string message = ex.Message;
  string error;
  IActionResult actionResult;
  switch (ex)
  {
    case DbUpdateConcurrencyException ce:
      //Returns a 400
      error = "Concurrency Issue.";
      actionResult = new BadRequestObjectResult(
        new {Error = error, Message = message, StackTrace = stackTrace});
      break;
    default:
      error = "General Error.";
      actionResult = new ObjectResult(
        new {Error = error, Message = message, StackTrace = stackTrace})
      {
        StatusCode = 500
      };
      break;
  }
  //context.ExceptionHandled = true; //If this is uncommented, the exception is swallowed
  context.Result = actionResult;
}

如果您希望异常过滤器接收异常并将响应设置为 200(例如,记录错误但不将其返回给客户端),请在设置Result(在前面的示例中被注释掉)之前添加以下行:

context.ExceptionHandled = true;

向处理管道添加过滤器

过滤器可以应用于动作方法、控制器或应用的全局。滤镜的代码前的由外向内执行(全局、控制器、动作方法),滤镜的代码后的由内向外执行(动作方法、控制器、全局)。

在应用级别添加过滤器是在Startup类的ConfigureServices()方法中完成的。打开Startup.cs类并将下面的using语句添加到文件的顶部:

using AutoLot.Api.Filters;

更新AddControllers()方法以添加自定义过滤器。

services
  .AddControllers(config => config.Filters.Add(new CustomExceptionFilterAttribute(_env)))
  .AddJsonOptions(options =>
  {
    options.JsonSerializerOptions.PropertyNamingPolicy = null;
    options.JsonSerializerOptions.WriteIndented = true;
  })
  .ConfigureApiBehaviorOptions(options =>
  {
...
  });

测试异常过滤器

要测试异常过滤器,打开WeatherForecastController.cs文件,并将Get()动作更新为如下所示的代码:

[HttpGet]
public IEnumerable<WeatherForecast> Get()
{
  _logger.LogAppWarning("This is a test");
  throw new Exception("Test Exception");
...
}

使用 Swagger 运行应用并练习该方法。Swagger UI 中显示的结果应该与以下输出相匹配(为简洁起见,堆栈跟踪被缩短):

{
  "Error": "General Error.",
  "Message": "Test Exception",
  "StackTrace": "   at AutoLot.Api.Controllers.WeatherForecastController.Get() in D:\\Projects\\Books\\csharp9-wf\\Code\\New\\Chapter_30\\AutoLot.Api\\Controllers\\WeatherForecastController.cs:line 31\r\n   "
}

添加跨来源请求支持

API 应该有适当的策略,允许或阻止来自另一个服务器的客户端与 API 通信。这些类型的请求被称为跨来源请求 (CORS)。虽然当您在一个全 ASP.NET 的核心世界中本地运行您的机器时这是不需要的,但是想要与您的 API 通信的 JavaScript 框架需要它,即使所有都在本地运行。

Note

有关 CORS 支持的更多信息,请参阅位于 https://docs.microsoft.com/en-us/aspnet/core/security/cors 的文档文章。

创建 CORS 策略

ASP.NET 核心为配置核心提供了丰富的支持,包括允许/禁止标头、方法、来源、凭证等方法。在本例中,我们将尽可能开放所有内容。配置从创建 CORS 策略并将该策略添加到服务集合开始。策略被命名(这个名称将在Configure()方法中使用),然后是规则。下面的例子创建了一个名为AllowAll的策略,然后就这么做了。将以下代码添加到Startup.cs类中的ConfigureServices()方法中:

services.AddCors(options =>
{
  options.AddPolicy("AllowAll", builder =>
  {
    builder
      .AllowAnyHeader()
      .AllowAnyMethod()
      .AllowAnyOrigin();
  });
});

将 CORS 策略添加到 HTTP 管道处理

最后一步是将 CORS 策略添加到 HTTP 管道处理中。将下面一行添加到Startup.csConfigure()方法中,确保它在app.UseRouting()app.UseEndpoints()方法调用之间:

public void Configure(
    IApplicationBuilder app,
    IWebHostEnvironment env,
    ApplicationDbContext context)
{
  ...
            //opt-in to routing
            app.UseRouting();
            //Add CORS Policy
            app.UseCors("AllowAll");
            //enable authorization checks
            app.UseAuthorization();
...
}

摘要

本章继续我们对 ASP.NET 岩心的研究。我们首先学习了从 action 方法返回 JSON,然后我们看了一下ApiController属性及其对 API 控制器的影响。接下来,更新了通用 Swashbuckle 实现,以包括应用的 XML 文档和来自 action 方法属性的信息。

接下来,构建基本控制器,它拥有应用的大部分功能。之后,派生的、实体特定的控制器被添加到项目中。最后两个步骤增加了应用范围的异常过滤器和对跨源请求的支持。

在下一章中,您将完成 ASP.NET 核心 Web 应用 AutoLot.Mvc 的构建

三十一、ASP.NET 核心的 MVC 应用

第二十九章奠定了 ASP.NET 核心的基础,在第三十章,我们建立了 RESTful 服务。在这一章中,我们将使用 MVC 模式构建 web 应用。我们首先将 V 放回 MVC。

Note

本章的示例代码在本书 repo 的Chapter 31目录中。请随意继续处理您在第二十九章中开始并在第三十章中更新的解决方案。

介绍 ASP.NET 核心中的“V”

在构建 ASP.NET 核心服务时,只使用了 MVC 模式的 M (模型)和 C (控制器)。用户界面是使用 V 或者 MVC 模式的视图创建的。视图是使用 HTML、JavaScript、CSS 和 Razor 代码构建的。它们可选地具有基本布局页面,并且从控制器动作方法或视图组件呈现。如果你在经典的 ASP.NET MVC 中工作过,这听起来应该很熟悉。

查看结果和行动方法

正如在第二十九章中简要提到的,ViewResultPartialView结果是使用Controller帮助器方法从动作方法返回的ActionResult。一个PartialViewResult被设计成在另一个视图中呈现,并且不使用布局页面,而一个ViewResult通常与一个布局页面一起呈现。

ASP.NET 核心中的约定(就像在 ASP.NET MVC 中一样)是让ViewPartialView呈现一个与方法同名的*.cshtml文件。视图必须位于以控制器命名的文件夹(减去控制器后缀)或Shared文件夹(都位于父Views文件夹下)。例如,以下代码将呈现位于Views\SampleViews\Shared文件夹中的SampleAction.cshtml视图:

[Route("[controller]/[action]")]
public class SampleController: Controller
{
  public ActionResult SampleAction()
  {
    return View();
  }
}

Note

首先搜索以控制器命名的视图文件夹。如果找不到视图,则搜索Shared文件夹。如果还是找不到,就会抛出一个异常。

要使用不同于操作方法名称的名称来呈现视图,请传入文件名(不带扩展名cshtml)。下面的代码将呈现CustomViewName.cshtml视图:

public ActionResult SampleAction()
{
  return View("CustomViewName");
}

最后两个重载用于传入成为视图模型的数据对象。第一个示例使用默认视图名称,第二个示例指定不同的视图名称。

public ActionResult SampleAction()
{
  var sampleModel = new SampleActionViewModel();
  return View(sampleModel);
}
public ActionResult SampleAction()
{
  var sampleModel = new SampleActionViewModel();
  return View("CustomViewName",sampleModel);
}

下一节将使用一个视图详细探索 Razor 视图引擎,该视图是从一个名为RazorSyntax()的动作方法在HomeController上呈现的。动作方法将从注入到方法中的CarRepo类的实例中获得一个Car记录,并将Car实例作为模型传递给视图。

打开自动 Lot 中的HomeController。Mvc 应用的Controllers目录,并添加下面的using语句:

using AutoLot.Dal.Repos.Interfaces;

接下来,向控制器添加RazorSyntax()方法。

[HttpGet]
public IActionResult RazorSyntax([FromServices] ICarRepo carRepo)
{
  var car = carRepo.Find(1);
  return View(car);
}

action 方法用HTTPGet属性修饰,只要请求是 HTTP Get,就将该方法设置为/Home/RazorSyntax的应用端点。ICarRepo参数上的FromServices属性通知 ASP.NET 核心,该参数不应该与任何传入数据绑定,而是该方法从依赖注入容器接收ICarRepo的实例。该方法获取一个Car的实例,并使用View方法返回一个ViewResult。由于视图没有被命名,ASP.NET 核心将在Views\Home目录或Views\Shared目录中寻找名为RazorSyntax.cshtml的视图。如果在任一位置都找不到视图,将向客户机(浏览器)返回一个异常。

运行应用,将浏览器导航到https://localhost:5001/Home/RazorSyntax(如果您使用 Visual Studio 和 IIS,您将需要更新端口)。由于项目中没有可以满足请求的视图,所以浏览器会返回一个异常。回想一下第二十九章,如果环境是Development,Startup类的Configure()方法将UseDeveloperExceptionPage()方法添加到 HTTP 管道中。图 31-1 显示了该方法的实际效果。

img/340876_10_En_31_Fig1_HTML.jpg

图 31-1。

来自开发人员例外页面的错误消息

开发人员异常页面提供了调试应用的大量信息,包括原始异常详细信息和堆栈跟踪。现在,注释掉Configure()方法中的那一行,用“标准”错误处理程序替换它,就像这样:

if (env.IsDevelopment())
{
  //app.UseDeveloperExceptionPage();
  app.UseExceptionHandler("/Home/Error");
...
}

再次运行 app,导航到http://localhost:5001/Home/RazorSyntax,会看到标准错误页面,如图 31-2 所示。

img/340876_10_En_31_Fig2_HTML.jpg

图 31-2。

来自标准错误页面的错误消息

Note

本章中的示例 URL 都使用 Kestrel 和端口 5001。如果您将 Visual Studio 与 IIS Express 一起使用,请使用 IIS 的launchsettings.json配置文件中的 URL。

标准的错误处理程序将错误重定向到HomeControllerError动作方法。记得返回Configure()方法来使用开发者异常页面:

if (env.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
...
}

有关自定义错误处理和其他可用选项的更多信息,请查阅文档: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/error-handling?view=aspnetcore-5.0

Razor 视图引擎和 Razor 语法

Razor 视图引擎是对 Web 表单视图引擎的改进,它使用 Razor 作为核心语言。Razor 是嵌入到视图中的服务器端代码,基于 C#,纠正了 Web 表单视图引擎的许多问题。Razor 与 HTML 和 CSS 的结合使得代码比使用 Web Forms 视图引擎语法更加简洁易读。

首先,右击自动 Lot 中的Views\Home文件夹,添加一个新视图。Mvc 项目并选择添加➤新项。从添加新项目-自动 Lot 中选择 Razor 视图-空。Mvc 对话框并将视图命名为RazorSyntax.cshtml

Note

当你右击Views\Home文件夹时,还有一个菜单选项:添加➤视图。但是,该对话框会将您带回“添加新项目”对话框。

Razor 视图通常使用@model指令进行强类型化(注意小写的 m )。通过在视图顶部添加以下内容,将新视图的类型更新为Car实体:

@model AutoLot.Models.Entities.Car

在页面顶部添加一个<h1>标签。这与剃刀无关;它只是在页面上添加了一个标题。

<h1>Razor Syntax</h1>

Razor 语句块以一个@符号开始,或者是自包含语句(如foreach)或者是用大括号括起来的,如下例所示:

@for (var i = 0; i < 15; i++)
{
    //do something
}

@{
    //Code Block
    var foo = "Foo";
    var bar = "Bar";
    var htmlString = "<ul><li>one</li><li>two</li></ul>";
}

要将变量值输出到视图,只需在变量名中使用@符号。这相当于Response.Write()。注意,当直接输出到浏览器时,语句后面没有结束分号。

@foo
<br />
@htmlString
<br />
@foo.@bar
<br />

前面的例子(@foo.@bar)结合了两个变量,它们之间有一个句点(.)。这不是导航属性链的常用 C#“点”符号。它只是两个变量到响应流的输出,它们之间有一个物理周期。如果你需要在一个变量上“打点”,在变量上使用@,然后像平常一样写你的代码。

@foo.ToUpper()

如果你想输出原始的 HTML,你可以使用一个叫做的 HTML 助手。这些是内置在 Razor 视图引擎中的助手。以下是输出原始 HTML 的代码行:

@Html.Raw(htmlString)
<hr />

代码块可以混合标记和代码。以标记开头的行被解释为 HTML,而所有其他行被解释为代码。如果一行以而非代码的文本开始,您必须使用内容指示器(@:<text></text>内容块指示器。请注意,线条可以来回转换。这里有一个例子:

@{
    @:Straight Text
    <div>Value:@Model.Id</div>
    <text>
        Lines without HTML tag
    </text>
    <br />
}

如果想转义@符号,就用双@。Razor 也足够智能来处理电子邮件地址,所以它们不需要被转义。如果您需要 Razor 像 Razor 令牌一样处理@符号,请添加括号。

foo@foo.com
<br />
@@foo
<br />
test@foo
<br/>
test@(foo)
<br />

前面的代码分别输出foo@foo.com@foo``test@footestFoo

剃刀评论以@*开头,以*@结尾。

@*
    Multiline Comments
    Hi.
*@

Razor 也支持内联函数。以下示例函数对字符串列表进行排序:

@functions {
    public static IList<string> SortList(IList<string> strings)  {
        var list = from s in strings orderby s select s;
        return list.ToList();
    }
}

下面的代码创建一个字符串列表,使用SortList()函数对它们进行排序,然后将排序后的列表输出到浏览器:

@{
    var myList = new List<string> {"C", "A", "Z", "F"};
    var sortedList = SortList(myList);
}
@foreach (string s in sortedList)
{
    @s@:&nbsp;
}
<hr/>

下面是另一个示例,它创建了一个可用于将字符串设置为粗体的委托:

@{
    Func<dynamic, object> b = @<strong>@item</strong>;
}
This will be bold: @b("Foo")

Razor 还包含 HTML 助手,它们是由 ASP.NET 核心提供的方法。两个例子是DisplayForModel()EditorForModel()。前者使用视图模型上的反射来显示在网页中。后者也使用反射为编辑表单创建 HTML(注意,它不提供Form标签,只提供模型的标记)。HTML 助手将在本章后面详细讨论。

最后,新的 ASP.NET 核心是标签助手。标记帮助器结合了标记和代码,将在本章后面介绍。

视图

视图是带有cshtml扩展名的特殊代码文件,使用 HTML 标记、CSS、JavaScript 和 Razor 语法的组合编写。

视图目录

文件夹是 ASP.NET 核心项目中使用 MVC 模式存储视图的地方。在Views文件夹的根目录下,有两个文件:_ViewStart.cshtml_ViewImports.cshtml

在呈现任何其他视图(不包括部分视图和布局)之前,_ViewStart.cshtml执行它的代码。该文件通常用于为没有指定默认布局的视图设置默认布局。布局在“布局”一节中有更详细的讨论。这里显示的是_ViewStart.cshtml文件:

@{
    Layout = "_Layout";
}

_ViewImports.cshtml文件用于导入共享指令,如using语句。这些内容适用于_ViewImports文件的同一目录或子目录中的所有视图。为 AutoLot.Models.Entities 添加一条using语句

@using AutoLot.Mvc
@using AutoLot.Mvc.Models
@using AutoLot.Models.Entities
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

标签助手将覆盖@addTegHelper行。

Note

为什么_ViewStart.html_ViewImports.cshtml_Layout.cshtml的前导下划线?Razor 视图引擎最初是为 WebMatrix 创建的,它不允许任何以下划线开头的文件被直接呈现。所有核心文件(如布局和配置)的名称都以下划线开头。这不是 MVC 所关心的约定,因为 MVC 没有 WebMatrix 的问题,但是下划线的传统仍然存在。

如前所述,每个控制器在Views文件夹下都有自己的目录,其中存储了特定的视图。这些名称与控制器的名称相匹配(减去单词控制器)。例如,Views\Cars目录保存了CarsController的所有视图。视图通常以呈现它们的操作方法命名,尽管名称可以更改,如前所示。

共享目录

Views下有一个名为Shared的特殊目录。该目录包含所有控制器和动作都可用的视图。如上所述,如果在特定于控制器的目录中找不到请求的视图文件,则会搜索共享文件夹。

显示模板文件夹

DisplayTemplates文件夹保存自定义模板,这些模板控制类型的呈现方式,促进代码重用和显示一致性。当调用DisplayFor() / DisplayForModel()方法时,Razor 视图引擎会寻找一个与正在呈现的类型同名的模板,例如,Car.cshtml表示一个Car类。如果找不到自定义模板,则使用反射来呈现标记。搜索路径从Views\{CurrentControllerName}\DisplayTemplates文件夹开始,如果没有找到,则在Views\Shared\DisplayTemplates文件夹中查找。这两种方法都采用可选参数来指定模板名称。

日期时间显示模板

Views\Shared文件夹下创建一个名为DisplayTemplates的新文件夹。将名为DateTime.cshtml的新视图添加到该文件夹中。清除所有生成的代码和注释,并用以下内容替换它们:

@model DateTime?
@if (Model == null)
{
  @:Unknown
}
else
{
  if (ViewData.ModelMetadata.IsNullableValueType)
  {
    @:@(Model.Value.ToString("d"))
  }
  else
  {
    @:@(((DateTime)Model).ToString("d"))
  }
}

注意,强类型化视图的@model指令使用了小写的m。当提到 Razor 中模型的赋值时,使用大写的M。在这个例子中,模型定义是可空的。如果传递到视图中的模型值为 null,模板将显示单词Unknown。否则,它使用可空类型的Value属性或实际模型本身,以短日期格式显示日期。

汽车展示模板

Views目录下新建一个名为Cars的目录,在Cars目录下增加一个名为DisplayTemplates的目录。将名为Car.cshtml的新视图添加到该文件夹中。清除所有生成的代码和注释,并用下面的代码替换它们,这将显示一个Car实体:

@model AutoLot.Models.Entities.Car
<dl class="row">
  <dt class="col-sm-2">
    @Html.DisplayNameFor(model => model.MakeId)
  </dt>
  <dd class="col-sm-10">
    @Html.DisplayFor(model => model.MakeNavigation.Name)
  </dd>
  <dt class="col-sm-2">
    @Html.DisplayNameFor(model => model.Color)
  </dt>
  <dd class="col-sm-10">
    @Html.DisplayFor(model => model.Color)
  </dd>
  <dt class="col-sm-2">
    @Html.DisplayNameFor(model => model.PetName)
  </dt>
  <dd class="col-sm-10">
    @Html.DisplayFor(model => model.PetName)
  </dd>
</dl>

HTML 帮助器显示属性的名称,除非该属性是用Display(Name="")DisplayName("")属性修饰的,在这种情况下使用显示值。DisplayFor()方法显示表达式中指定的模型属性的值。注意,MakeNavigation的导航属性被用来获取品牌名称。

如果您运行应用并导航到RazorSyntax页面,您可能会惊讶于没有使用Car显示模板。这是因为模板在Cars视图文件夹中,而RazorSyntax动作方法和视图是从HomeController中调用的。HomeController中的动作方法将只在HomeShared目录中搜索视图,因此不会找到汽车显示模板。

如果您将Car.cshtml模板移动到Shared\DisplayTemplates目录中,RazorSyntax视图将使用显示模板。

带彩色显示模板的汽车

以下模板类似于Car模板。不同之处在于,这个模板根据模型的Color属性值来改变颜色文本的颜色。将名为CarWithColors.cshtml的新模板添加到Cars\DisplayTemplates目录中,并将标记更新为:

@model Car
<hr />
<div>
  <dl class="row">
    <dt class="col-sm-2">
      @Html.DisplayNameFor(model => model.PetName)
    </dt>
    <dd class="col-sm-10">
      @Html.DisplayFor(model => model.PetName)
    </dd>
    <dt class="col-sm-2">
      @Html.DisplayNameFor(model => model.MakeNavigation)
    </dt>
    <dd class="col-sm-10">
      @Html.DisplayFor(model => model.MakeNavigation.Name)
    </dd>
    <dt class="col-sm-2">
      @Html.DisplayNameFor(model => model.Color)
    </dt>
    <dd class="col-sm-10" style="color:@Model.Color">
      @Html.DisplayFor(model => model.Color)
    </dd>
  </dl>
</div>

要使用这个模板而不是Car.cshtml模板,用模板的名称调用DisplayForModel()(注意位置规则仍然适用)。

@Html.DisplayForModel("CarWithColors")

EditorTemplates 文件夹

除了模板用于编辑之外,EditorTemplates文件夹与DisplayTemplates文件夹的工作方式相同。

汽车编辑模板

Views\Cars目录下创建一个名为EditorTemplates的新目录。将名为Car.cshtml的新视图添加到该文件夹中。清除所有生成的代码和注释,并用下面的代码替换它们,它代表编辑一个Car实体的标记:

@model Car
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
    <label asp-for="PetName" class="col-form-label"></label>
    <input asp-for="PetName" class="form-control" />
    <span asp-validation-for="PetName" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="MakeId" class="col-form-label"></label>
    <select asp-for="MakeId" class="form-control" asp-items="ViewBag.MakeId"></select>
</div>
<div class="form-group">
    <label asp-for="Color" class="col-form-label"></label>
    <input asp-for="Color" class="form-control"/>
    <span asp-validation-for="Color" class="text-danger"></span>
</div>

编辑器模板使用了几个标签助手(asp-forasp-itemsasp-validation-forasp-validation-summary)。这些将在本章后面讨论。

这个模板由EditorFor() / EditorForModel() HTML 助手调用。像显示模板一样,这些方法将寻找名为Car.cshtml的视图或方法中命名的视图。

布局

与 Web 窗体母版页类似,MVC 支持视图之间共享的布局,以使站点的页面具有一致的外观和感觉。导航到Views\Shared文件夹并打开_Layout.cshtml文件。这是一个成熟的 HTML 文件,带有<head><body>标签。

该文件是其他视图呈现的基础。此外,由于页面的大部分内容(如导航和任何页眉和/或页脚标记)由布局页面处理,因此视图页面保持小而简单。在文件中向下滚动,直到看到下面一行 Razor 代码:

@RenderBody()

该行指示布局页面在哪里呈现视图。现在向下滚动到右</body>标签之前的那一行。以下代码行为布局创建一个新部分,并使其成为可选部分:

@await RenderSectionAsync("scripts", required: false)

通过将true作为第二个参数传入,也可以将节标记为required。它们也可以同步渲染,如下所示:

@RenderSection("Header",true)

视图文件的@section块中的任何代码和/或标记都不会用@RenderBody()调用来呈现,而是呈现在布局的节定义的位置。例如,假设您有一个包含以下部分实现的视图:

@section Scripts {
  <script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>
}

视图中的代码被呈现到布局中,代替了节定义。如果布局具有以下定义:

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)

然后添加视图的部分,导致这个标记被发送到浏览器:

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>
<script src="~/lib/jquery-validation/dist/jquery.validate.js"></script>

ASP.NET 核心中的两个新选项是IgnoreBodyIgnoreSection。放置在布局中的这些方法将分别不呈现视图的主体或特定部分。这些功能可以根据条件逻辑(如安全级别)打开或关闭布局中的视图功能。

指定视图的默认布局

如前所述,默认布局页面是在_ViewStart.cshtml文件中定义的。任何未指定布局的视图将使用位于视图目录或其上方的第一个_ViewStart.cshtml文件中定义的布局。

局部视图

分部视图在概念上类似于 Web 窗体中的用户控件。局部视图对于封装 UI 很有用,这有助于减少重复的代码和/或标记。局部视图不使用布局,而是被注入到另一个视图中,或者使用视图组件进行渲染(本章稍后将介绍)。

更新布局和局部

有时,布局文件会变得很大,难以处理。管理这一点的一个技巧是将布局分割成一组重点突出的部分。

创造分音

Shared文件夹下创建一个名为Partials的新文件夹。创建三个名为_Head.cshtml_JavaScriptFiles.cshtml_Menu.cshtml的空视图。

头部偏角

剪切布局中位于<head></head>标签之间的内容,并将其粘贴到_Head.cshtml文件中。

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AutoLot.Mvc</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" />

_Layout.cshtml中,用调用替换删除的标记,以呈现新的部分:

<head>
  <partial name="Partials/_Head"/>
</head>

<partial>标签是标签助手的另一个例子。name属性是部分的名称,路径从视图的当前目录开始,在本例中是Views\Shared

菜单部分

对于部分菜单,剪切掉<header></header>标签(不是<head></head>标签)之间的所有标记,并将其粘贴到_Menu.cshtml文件中。更新_Layout来渲染Menu的局部。

<header>
  <partial name="Partials/_Menu"/>
</header>

JavaScript 文件部分

此时的最后一步是剪切出 JavaScript 文件的<script>标签,并将它们粘贴到JavaScriptFiles片段中。确保将RenderSection标签留在原位。这里是JavaScriptFiles偏:

<script src="~/lib/jquery/dist/jquery.min.js"></script>
<script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
<script src="~/js/site.js" asp-append-version="true"></script>

以下是_Layout.cshtml文件的当前标记:

<!DOCTYPE html>
<html lang="en">
<head>
  <partial name="Partials/_Head" />
</head>
<body>
  <header>
    <partial name="Partials/_Menu" />
  </header>
  <div class="container">
    <main role="main" class="pb-3">
      @RenderBody()
    </main>
  </div>

  <footer class="border-top footer text-muted">
    <div class="container">
      &copy; 2021 - AutoLot.Mvc - <a asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
    </div>
  </footer>
  <partial name="Partials/_JavaScriptFiles" />
  @await RenderSectionAsync("Scripts", required: false)
</body>
</html>

向视图发送数据

有多种方法可以将数据发送到视图中。当视图是强类型时,可以在呈现视图时发送数据(通过动作方法或通过<partial>标签助手)。

强类型视图和视图模型

当模型或ViewModel被传递到视图方法中时,该值被赋给强类型视图的@model属性,如下所示(注意小写的 m ):

@model IEnumerable<Order>

@model为视图设置类型,然后可以通过使用@Model Razor 命令来访问,就像这样(注意大写的 M ):

@foreach (var item in Model)
{
  //Do something interesting here
}

RazorViewSyntax()动作方法演示了视图从动作方法中获取数据。

[HttpGet]
public IActionResult RazorSyntax([FromServices] ICarRepo carRepo)
{
  var car = carRepo.Find(1);
  return View(car);
}

模型值可以通过<partial>传入,如下所示:

<partial name="Partials/_CarListPartial" model="@Model"/>

ViewBag、ViewData 和 TempData

ViewBagViewDataTempData对象是向视图发送少量数据的机制。表 31-1 列出了将数据从控制器传递到视图(除了Model属性)或从控制器传递到控制器的三种机制。

表 31-1。

向视图发送数据的其他方法

|

数据传输对象

|

使用说明

TempData 这是一个短命的对象,只在当前请求和下一个请求期间工作。通常在重定向到另一个操作方法时使用。
ViewData 允许在名称-值对中存储值的字典(例如,ViewData["Title"] = "My Page")。
ViewBag ViewData字典的动态包装器(例如ViewBag.Title = "My Page")。

ViewBagViewData都指向同一个对象;它们只是提供了不同的方法来访问数据。

让我们再来看看您之前创建的_HeadPartial.cshtml文件(重要的一行用粗体显示):

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AutoLot.Mvc</title>
<link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" />

您会注意到<title>属性使用ViewData来设置值。由于ViewData是一个剃刀构造,它以@符号开始。要看到这一点,用下面的代码更新RazorSyntax.cshtml视图:

@model AutoLot.Models.Entities.Car
@{
    ViewData["Title"] = "RazorSyntax";
}
<h1>Razor Syntax</h1>
...

现在,当您运行应用并导航到https://localhost:5001/Home/RazorSyntax时,您将看到标题“Razor Syntax–AutoLot”。Mvc”在浏览器标签。

标签助手

标签助手是 ASP.NET 核心中引入的新功能。标签助手是表示服务器端代码的标记(自定义标签或标准标签上的属性)。然后,服务器端代码帮助形成发出的 HTML。它们极大地改善了 MVC 视图的开发体验和可读性。

与作为 Razor 方法调用的 HTML 帮助器不同,标记帮助器是添加到标准 HTML 元素或独立自定义标记的属性。如果您正在使用 Visual Studio 进行开发,那么对于内置的标记助手来说,IntelliSense 还有一个额外的好处。

例如,下面的 HTML 助手为客户的FullName属性创建一个标签:

@Html.Label("FullName","Full Name:",new {@class="customer"})

这会生成以下 HTML:

<label class="customer" for="FullName">Full Name:</label>

对于一直使用 ASP.NET MVC 和 Razor 的 C# 开发人员来说,HTML helper 语法可能已经很好理解了。但这并不直观,尤其是对于一个用 HTML/CSS/JavaScript 而不是 C# 工作的人来说。

标签助手版本如下所示:

<label class="customer" asp-for="FullName">Full Name:</label>

它们产生相同的输出,但是标记帮助器,通过集成到 HTML 标记中,使您保持“在标记中”

有许多内置的标记帮助器,它们被设计用来代替它们各自的 HTML 帮助器。然而,并不是所有的 HTML 助手都有相关的标签助手。表 31-2 列出了更常用的标签助手,它们对应的 HTML 助手,以及可用的属性。我们将在本章的其余部分详细介绍它们。

表 31-2。

常用的内置标签助手

|

标签助手

|

HTML 助手

|

可用属性

Form Html.BeginForm Html.BeginRouteForm Html.AntiForgeryToken asp-route—用于命名路线(不能与控制器或动作属性一起使用)。asp-antiforgery—是否需要添加防伪(默认为真)。asp-area—该地区的名称。asp-controller—控制器的名称。asp-action—动作的名称。asp-route-<ParameterName>—将参数添加到路线中,例如asp-route-id="1"asp-page—Razor 页面的名称。asp-page-handler—Razor 页面处理程序的名称。asp-all-route-data—附加路线值字典。
Form Action``(button or input type=image) N/A asp-route—用于命名路线(不能与控制器或动作属性一起使用)。asp-antiforgery—是否需要添加防伪(默认为真)。asp-area—该地区的名称。asp-controller—控制器的名称。asp-action—动作的名称。asp-route-<ParameterName>—将参数添加到路线中,例如asp-route-id="1"asp-page—Razor 页面的名称。asp-page-handler—Razor 页面处理程序的名称。asp-all-route-data—附加路线值字典。
Anchor Html.ActionLink asp-route—用于命名路线(不能与控制器或动作属性一起使用)。asp-area—区域的名称。asp-controller—定义控制器。asp-action—定义动作。asp-protocol —HTTP 或 HTTPS。asp-fragment —URL 片段。asp-host—主机名称。asp-route-<ParameterName>—将参数添加到路线中,例如asp-route-id="1"asp-page—Razor 页面的名称。asp-page-handler—Razor 页面处理程序的名称。asp-all-route-data—附加路径值的字典。
Input Html.TextBox/TextBoxFor Html.Editor/EditorFor asp-for—一个模型属性。可以浏览模型(Customer.Address.AddressLine1)和使用表达式(asp-for="@localVariable")。idname属性自动生成。任何 HTML5 data-val属性和type属性都是自动生成的。
TextArea Html.TextAreaFor asp-for—一个模型属性。可以浏览模型(Customer.Address.Description)和使用表达式(asp-for="@localVariable")。idname属性自动生成。任何 HTML5 data-val属性和type属性都是自动生成的。
Label Html.LabelFor asp-for—一个模型属性。可以浏览模型(Customer.Address.AddressLine1)和使用表达式(asp-for="@localVariable")。显示Display属性的值(如果存在);否则使用属性名。
Partial Html.Partial(Async)Html.RenderPartial(Async) name—局部视图的路径和名称。for—当前表单上的模型表达式将成为分部中的模型。model—局部模型中的对象。view-data——ViewData为偏科。
Select Html.DropDownListFor Html.ListBoxFor asp-for—一个模型属性。可以浏览模型(Customer.Address.AddressLine1)和使用表达式(asp-for="@localVariable")。asp-items—指定options元素。自动生成selected="selected"属性。自动生成idname属性。任何 HTML5 data-val属性都是自动生成的。
Validation Message (Span) Html.ValidationMessageFor asp-validation-for—一个模型属性。可以浏览模型(Customer.Address.AddressLine1)和使用表达式(asp-for="@localVariable")。将data-valmsg-for属性添加到span中。
Validation Summary (Div) Html.ValidationSummaryFor asp-validation-summary—选择AllModelOnlyNone中的一个。将data-valmsg-summary属性添加到div
Link N/A asp-append-version—将文件的哈希作为版本指示符附加到文件名(作为查询字符串),用于缓存破坏。href—源的内容传递网络版本的地址。asp-fallback-href—主文件不可用时使用的后备文件;通常与 CDN 源一起使用。asp-fallback-href-include—回退时要包含的文件的成组文件列表。asp-fallback-href-exclude—回退时要排除的文件的全局文件列表。asp-fallback-test-*—用于回退测试的属性。包括classpropertyvalueasp-href-include—要包含的文件的成组文件模式。asp-href-exclude—要排除的文件的组合文件模式。
Script N/A asp-append-version—将文件的哈希作为版本指示符附加到文件名(作为查询字符串),用于缓存破坏。src—源的内容传递网络版本的地址asp-fallback-src—主文件不可用时使用的后备文件;通常与 CDN 源一起使用。asp-fallback-src-include—回退时要包含的文件的成组文件列表。asp-fallback-src-exclude—回退时要排除的文件的全局文件列表。asp-fallback-test—回退测试中使用的脚本方法。asp-src-include—要包含的文件的 globbed 文件模式。asp-src-exclude—要排除的文件的组合文件模式。
Image N/A asp-append-version—将文件的哈希作为版本指示符附加到文件名(作为查询字符串),用于缓存破坏。
Environment N/A names—触发内容呈现的单个主机环境名称或逗号分隔的名称列表(忽略大小写)。include—触发内容呈现的单个主机环境名称或逗号分隔的名称列表(忽略大小写)。exclude—要从内容呈现中排除的单个主机环境名称或逗号分隔的名称列表(忽略大小写)。

启用标签助手

标记助手必须对任何想要使用它们的代码可见。标准模板中的_ViewImports.html文件已经包含以下行:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

这使得Microsoft.AspNetCore.Mvc.TagHelpers集合中的所有标签助手(包含所有内置标签助手)对位于或低于_ViewImports.cshtml文件目录级别的所有视图可用。

表单标签帮助器

表单标签助手取代了Html.BeginFormHtml.BeginRouteForm HTML 助手。例如,要创建一个表单,该表单提交给带有一个参数(Id)的CarsController上的Edit动作的 HTTP Post版本,请使用以下代码和标记:

<form method="post" asp-controller="Cars" asp-action="Edit"
  asp-route-id="@Model.Id" >
<!-- Omitted for brevity -->
</form>

从严格的 HTML 角度来看,Form标签可以在没有表单标签助手属性的情况下工作。如果这些属性都不存在,那么它只是一个普通的旧 HTML 表单,必须手动添加防伪标记。然而,一旦添加了一个asp-标签,防伪标记就被添加到表单中。可以通过在表单标签中添加asp-antiforgery="false"来禁用防伪标记。防伪标记将在后面介绍。

汽车创造形式

Car实体的创建表单提交给CarsControllerCreate动作方法。在Views\Cars目录中添加一个名为Create.cshtml的新的空 Razor 视图。将视图更新为以下内容:

@model Car

@{
  ViewData["Title"] = "Create";
}

<h1>Create a New Car</h1>
<hr/>
<div class="row">
  <div class="col-md-4">
    <form asp-controller="Cars" asp-action="Create">
    </form>
  </div>
</div>

这不是一个完整的视图,但是它足以显示我们到目前为止所讨论的内容以及表单标记帮助器。回顾一下,第一行将视图强类型化为Car实体类。Razor 块为页面设置特定于视图的标题。HTML <form>标签具有asp-controllerasp-action属性,它们在服务器端执行以形成标签并添加防伪标记。

为了渲染这个视图,将名为CarsController的新控制器添加到Controllers文件夹中。将代码更新为以下内容(该代码将在本章稍后更新):

using Microsoft.AspNetCore.Mvc;

namespace AutoLot.Mvc.Controllers
{
  [Route("[controller]/[action]")]
  public class CarsController : Controller
  {
    public IActionResult Create()
    {
      return View();
    }
  }
}

现在运行应用并导航到http://localhost:5001/Cars/Create。检查源代码会发现表单具有基于asp-controllerasp-action的动作属性,方法被设置为post,并且__RequestVerificationToken被添加为隐藏的表单输入。

<form action="/Cars/Create" method="post">
  <input name="__RequestVerificationToken" type="hidden" value="CfDJ8Hqg5HsrvCtOkkLRHY4ukxwvix0vkQ3vOvezvtJWdl0P5lwbI5-FFWXh8KCFZo7eKxveCuK8NRJywj8Jz23pP2nV37fIGqqcITRyISGgq7tRYZDuPv8NMIYz2nCWRiDbxOvlkg61DTDW9BrJxr8H63Y">
</form>

在本章中,Create视图将被更新。

表单操作标签帮助器

表单动作标签帮助器用于按钮和图像,以更改包含它们的表单的动作。例如,添加到编辑表单的以下按钮将导致 post 请求转到Create端点:

<button type="submit" asp-action="Create">Index</button>

锚标记辅助对象

锚点标签辅助对象替换了Html.ActionLink HTML 辅助对象。例如,要创建 RazorSyntax 视图的链接,请使用以下代码:

<a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="RazorSyntax">
  Razor Syntax
</a>

要将 Razor 语法页面添加到菜单中,请将_Menu.cshtml更新为以下内容,在 Home 和 Privacy 菜单项之间添加新菜单项(锚标签周围的<li>标签用于引导菜单):

...
<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="RazorSyntax">Razor Syntax</a>
</li>
<li class="nav-item">
  <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a>
</li>

输入标签助手

标签助手是最通用的标签助手之一。除了自动生成 HTML idname属性,以及任何 HTML5 data-val验证属性之外,tag helper 还基于目标属性的数据类型构建适当的 HTML 标记。表 31-3 列出了基于。属性的 NET Core 类型。

表 31-3。

生成的 HTML 类型。NET 类型使用输入标记帮助器

|

.NET 类型

|

生成的 HTML 类型

Bool type="checkbox"
String type="text"
DateTime type="datetime"
ByteIntSingleDouble type="number"

此外,Input标签助手将根据数据注释添加 HTML5 type属性。表 31-4 列出了一些最常见的注释和生成的 HTML5 type属性。

表 31-4。

生成的 HTML5 类型属性.NET 数据注释

|

.NET 数据注释

|

生成的 HTML5 类型属性

EmailAddress type="email"
Url type="url"
HiddenInput type="hidden"
Phone type="tel"
DataType(DataType.Password) type="password"
DataType(DataType.Date) type="date"
DataType(DataType.Time) type="time"

Car.cshtml编辑器模板包含用于PetNameColor属性的<input>标签。提醒一下,这里只列出了这些标签:

<input asp-for="PetName" class="form-control" />
<input asp-for="Color" class="form-control"/>

输入标签帮助器将nameid属性添加到呈现的标签、属性的现有值(如果有)和 HTML5 验证属性中。这两个字段都是必需的,并且字符串长度限制为 50。以下是这两个属性的呈现标记:

<input class="form-control" type="text" data-val="true" data-val-length="The field Pet Name must be a string with a maximum length of 50." data-val-length-max="50" data-val-required="The Pet Name field is required." id="PetName" maxlength="50" name="PetName" value="Zippy">

<input class="form-control valid" type="text" data-val="true" data-val-length="The field Color must be a string with a maximum length of 50." data-val-length-max="50" data-val-required="The Color field is required." id="Color" maxlength="50" name="Color" value="Black" aria-describedby="Color-error" aria-invalid="false">

TextArea 标签帮助器

<textarea>标签助手自动添加idname属性以及为属性定义的任何 HTML5 验证标签。例如,下面的代码行为Description属性创建了一个textarea标记:

<textarea asp-for="Description"></textarea>

选择标签助手

标签助手从模型属性和集合中构建输入选择标签。与其他输入标签助手一样,idname被添加到标记中,任何 HTML5 data-val属性也是如此。如果模型属性值与选择列表项的值之一匹配,则该选项会将选定的属性添加到标记中。

例如,假设一个模型有一个名为Country的属性和一个名为CountriesSelectList,其列表定义如下:

public List<SelectListItem> Countries { get; } = new List<SelectListItem>
{
  new SelectListItem { Value = "MX", Text = "Mexico" },
  new SelectListItem { Value = "CA", Text = "Canada" },
  new SelectListItem { Value = "US", Text = "USA"  },
};

以下标记将使用适当的选项呈现select标记:

<select asp-for="Country" asp-items="Model.Countries"></select>

如果Country属性的值被设置为 CA,下面的完整标记将被输出到视图中:

<select id="Country" name="Country">
  <option value="MX">Mexico</option>
  <option selected="selected" value="CA">Canada</option>
  <option value="US">USA</option>
</select>

验证标签助手

验证消息和验证摘要标签帮助器与Html.ValidationMessageForHtml.ValidationSummaryFor HTML 帮助器非常相似。第一个应用于模型上特定属性的 HTML span,第二个应用于div标签并代表整个模型。验证总结可选择All错误、ModelOnly(不包括模型属性错误)或None

Car.cshtml文件的EditorTemplate中调用验证标签助手(这里以粗体显示):

<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
    <label asp-for="PetName" class="col-form-label"></label>
    <input asp-for="PetName" class="form-control" />
    <span asp-validation-for="PetName" class="text-danger"></span>
</div>
<div class="form-group">
    <label asp-for="MakeId" class="col-form-label"></label>
    <select asp-for="MakeId" class="form-control" asp-items="ViewBag.MakeId"></select>
</div>
<div class="form-group">
    <label asp-for="Color" class="col-form-label"></label>
    <input asp-for="Color" class="form-control"/>
    <span asp-validation-for="Color" class="text-danger"></span>
</div>

这些帮助器将显示来自绑定和验证的ModelState错误,如图 31-3 所示。

img/340876_10_En_31_Fig3_HTML.jpg

图 31-3。

有效的验证标签助手

环境标签助手

标签助手通常用于根据站点运行的环境有条件地加载 JavaScript 和 CSS 文件(或任何标记)。打开_Head.cshtml partial 并将标记更新如下:

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AutoLot.Mvc</title>
<environment include="Development">
  <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" />
</environment>
<environment exclude="Development">
  <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
</environment>
<link rel="stylesheet" href="~/css/site.css" />

当环境设置为Development时,第一个<environment>标签助手使用include=”Development”属性来包含所包含的文件。在前面的代码中,加载了 Bootstrap 的非精简版本。当环境不是而是 Development时,第二个标签助手使用exclude=”Development”来使用包含的文件,并加载缩小版本的bootstrap.css。在开发和非开发环境中,site.css文件不会改变,所以它被列在<environment>标签助手之外。

此外,将_JavaScriptFiles.cshtml部分更新为以下内容(注意,Development部分中的文件不再具有.min扩展名):

<environment include="Development">
    <script src="~/lib/jquery/dist/jquery.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.js"></script>
</environment>
<environment exclude="Development">
    <script src="~/lib/jquery/dist/jquery.min.js"></script>
    <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script>
</environment>
<script src="~/js/site.js" asp-append-version="true"></script>

链接标签帮助器

<link>标签助手具有用于本地和远程的属性。与本地文件一起使用的asp-append-version属性将文件的散列作为查询字符串参数添加到发送给浏览器的 URL 中。当文件改变时,散列也改变,更新发送到浏览器的 URL。由于链接已更改,浏览器会清除该文件的缓存并重新加载它。将_Head.cshtml文件中的bootstrap.csssite.css链接标签更新如下:

<environment include="Development">
  <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.css" asp-append-version="true"/>
</environment>
<environment exclude="Development">
  <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" />
</environment>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>

发送到浏览器的site.css文件的链接现在如下所示(您的散列会有所不同):

<link href="/css/site.css?v=v9cmzjNgxPHiyLIrNom5fw3tZj3TNT2QD7a0hBrSa4U" rel="stylesheet">

当从内容交付网络加载 CSS 文件时,标记助手提供了一种测试机制来确保文件被正确加载。该测试寻找特定 CSS 类的特定属性值,如果该属性不匹配,tag helper 将加载回退文件。更新_Head.cshtml文件中的exclude=”Development”以匹配以下内容:

<environment exclude="Development">
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
    asp-fallback-href="~/lib/bootstrap/dist/css/bootstrap.css"
    asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute"
    crossorigin="anonymous"
    integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"/>
</environment>

脚本标签帮助器

<script>标签助手类似于<link>标签助手,具有缓存破坏和 CDN 回退设置。asp-append-version属性对脚本和链接样式表的作用是一样的。asp-fallback-*属性也用于 CDN 文件源。asp-fallback-test只是检查 JavaScript 的真实性,如果失败,就从后备源加载文件。

更新_JavaScriptFiles.cshtml片段以使用缓存破坏和 CDN 回退功能(注意 MVC 模板已经在site.js脚本标签上有了asp-append-version)。

<environment include="Development">
  <script src="~/lib/jquery/dist/jquery.js" asp-append-version="true"></script>
  <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.js" asp-append-version="true"></script>
</environment>
<environment exclude="Development">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"
    asp-fallback-src="~/lib/jquery/dist/jquery.min.js" asp-fallback-test="window.jQuery"
    crossorigin="anonymous"
    integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=">
  </script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.bundle.min.js"
    asp-fallback-src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"
    asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
    crossorigin="anonymous"
    integrity="sha384-xrRywqdh3PHs8keKZN+8zzc5TX0GRTLCcmivcbNJWm2rs5C8PRhcEn3czEjhAO9o">
  </script>
</environment>
<script src="~/js/site.js" asp-append-version="true"></script>

需要用<environment><script>标签助手来更新_ValidationScriptsPartial.cshtml

<environment include="Development">
  <script src="~/lib/jquery-validation/dist/jquery.validate.js" asp-append-version="true"></script>
  <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js" asp-append-version="true"></script>
</environment>
<environment exclude="Development">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.1/jquery.validate.min.js"
    asp-fallback-src="~/lib/jquery-validation/dist/jquery.validate.min.js"
    asp-fallback-test="window.jQuery && window.jQuery.validator"
    crossorigin="anonymous"
    integrity="sha256-F6h55Qw6sweK+t7SiOJX+2bpSAa3b/fnlrVCJvmEj1A=">
  </script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"
    asp-fallback-src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
    asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive"
    crossorigin="anonymous"
    integrity="sha256-9GycpJnliUjJDVDqP0UEu/bsm9U+3dnQUH8+3W10vkY=">
  </script>
</environment>

图像标签帮助器

图像标签帮助器提供了asp-append-version属性,其工作原理与链接和脚本标签帮助器中描述的一样。

自定义标签助手

自定义标记帮助器有助于消除重复代码。为了 AutoLot。Mvc,自定义标签助手将取代 HTML 来导航Car CRUD 屏幕。

奠定基础

定制标签助手使用一个UrlHelperFactoryIActionContextAccessor来创建基于路由的链接。我们还将添加一个字符串扩展方法来删除控制器名称中的Controller后缀。

更新 Startup.cs

要从非Controller派生类中创建UrlFactory的实例,必须将IActionContextAccessor添加到服务集合中。首先将以下名称空间添加到Startup.cs:

using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection.Extensions;

接下来,将下面一行添加到ConfigureServices()方法:

services.TryAddSingleton<IActionContextAccessor, ActionContextAccessor>();

创建字符串扩展方法

当在代码中引用控制器名称时,ASP.NET 核心经常需要原始字符串值,没有Controller后缀。这防止了在没有调用string.Replace()的情况下使用nameof()方法。随着时间的推移,这变得越来越乏味,我们将创建一个字符串扩展方法来处理这个问题。

AutoLot.Services项目添加一个名为Utilities的新文件夹,并在该文件夹中添加一个名为StringExtensions.cs的新静态类。将代码更新为以下内容,以添加RemoveController()扩展方法:

using System;

namespace AutoLot.Mvc.Extensions
{
  public static class StringExtensions
  {
    public static string RemoveController(this string original)
      => original.Replace("Controller", "", StringComparison.OrdinalIgnoreCase);
  }
}

创建基类

在 AutoLot 的根目录下创建一个名为TagHelpers的新文件夹。Mvc 项目。在这个文件夹中,创建一个名为Base的新文件夹,在那个文件夹中,创建一个名为ItemLinkTagHelperBase.cs的类,将该类公共化、抽象化,并继承自TagHelper。将以下using语句添加到新文件中:

using AutoLot.Mvc.Controllers;
using AutoLot.Services.Utilities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace AutoLot.Mvc.TagHelpers.Base
{
  public abstract class ItemLinkTagHelperBase : TagHelper
  {
  }
}

添加一个接受IActionContextAccessorIUrlHelperFactory实例的构造函数。使用UrlHelperFactoryActionContextAccessor创建一个IUrlHelper的实例,并将其存储在一个类级变量中。代码如下所示:

protected readonly IUrlHelper UrlHelper;
protected ItemLinkTagHelperBase(IActionContextAccessor contextAccessor, IUrlHelperFactory urlHelperFactory)
{
  UrlHelper = urlHelperFactory.GetUrlHelper(contextAccessor.ActionContext);
}

添加一个公共属性来保存该项的Id,如下所示:

public int? ItemId { get; set; }

当一个标签助手被调用时,Process()方法被调用。Process()方法有两个参数,一个TagHelperContext和一个TagHelperOutputTagHelperContext用于获取标签上的任何其他属性,以及一个对象字典,用于与其他以子元素为目标的标签助手进行通信。TagHelperOutput用于创建渲染输出。

由于这是一个基类,我们将添加一个名为BuildContent()的方法,派生类可以从Process()方法中调用该方法。添加以下方法和代码:

protected void BuildContent(TagHelperOutput output,
  string actionName, string className, string displayText, string fontAwesomeName)
{
  output.TagName = "a"; // Replaces <item-list> with <a> tag
  var target = (ItemId.HasValue)
    ? UrlHelper.Action(actionName, nameof(CarsController).RemoveController(), new {id = ItemId})
    : UrlHelper.Action(actionName, nameof(CarsController).RemoveController());
  output.Attributes.SetAttribute("href", target);
  output.Attributes.Add("class",className);
  output.Content.AppendHtml($@"{displayText} <i class=""fas fa-{fontAwesomeName}""></i>");
}

前面的代码清单引用了字体 Awesome,它将在本章的后面添加到项目中。

项目详细信息标签帮助器

TagHelpers文件夹中创建一个名为ItemDetailsTagHelper.cs的新类。使类public继承ItemLinkTagHelperBase。将以下using语句添加到新文件中:

using AutoLot.Mvc.Controllers;
using AutoLot.Mvc.TagHelpers.Base;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace AutoLot.Mvc.TagHelpers
{
  public class ItemDetailsTagHelper : ItemLinkTagHelperBase
  {
  }
}

添加一个公共构造函数来接收所需的对象实例,并将它们传递给基类。

public ItemDetailsTagHelper(
    IActionContextAccessor contextAccessor,
    IUrlHelperFactory urlHelperFactory)
      : base(contextAccessor, urlHelperFactory) { }

覆盖Process()方法,调用基类中的BuildContent()方法。

public override void Process(TagHelperContext context, TagHelperOutput output)
{
  BuildContent(output,nameof(CarsController.Details),"text-info","Details","info-circle");
}

这将创建带有字体 Awesome info 图像的详细信息链接。为了防止编译器错误,在CarsController中添加一个基本的Details()方法。

public IActionResult Details()
{
  return View();
}

项目删除标签帮助器

TagHelpers文件夹中创建一个名为ItemDeleteTagHelper.cs的新类。使类public继承ItemLinkTagHelperBase。将以下using语句添加到新文件中:

using AutoLot.Mvc.Controllers;
using AutoLot.Mvc.TagHelpers.Base;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace AutoLot.Mvc.TagHelpers
{
  public class ItemDeleteTagHelper : ItemLinkTagHelperBase
  {
  }
}

添加一个公共构造函数来接收所需的对象实例,并将它们传递给基类。

public ItemDeleteTagHelper(
    IActionContextAccessor contextAccessor,
    IUrlHelperFactory urlHelperFactory)
      : base(contextAccessor, urlHelperFactory) { }

覆盖Process()方法,调用基类中的BuildContent()方法。

public override void Process(TagHelperContext context, TagHelperOutput output)
{
  BuildContent(output,nameof(CarsController.Delete),"text-danger","Delete","trash");
}

这创建了带有字体 Awesome 垃圾桶图像的Delete链接。为了防止编译器错误,在CarsController中添加一个基本的Delete()方法。

public IActionResult Delete()
{
  return View();
}

项目编辑标签助手

TagHelpers文件夹中创建一个名为ItemEditTagHelper.cs的新类。使类public继承ItemLinkTagHelperBase。将以下using语句添加到新文件中:

using AutoLot.Mvc.Controllers;
using AutoLot.Mvc.TagHelpers.Base;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace AutoLot.Mvc.TagHelpers
{
  public class ItemEditTagHelper : ItemLinkTagHelperBase
  {
  }
}

添加一个公共构造函数来接收所需的对象实例,并将它们传递给基类。

public ItemEditTagHelper(
    IActionContextAccessor contextAccessor,
    IUrlHelperFactory urlHelperFactory)
      : base(contextAccessor, urlHelperFactory) { }

覆盖Process()方法,调用基类中的BuildContent()方法。

public override void Process(TagHelperContext context, TagHelperOutput output)
{
  BuildContent(output,nameof(CarsController.Edit),"text-warning","Edit","edit");
}

这将创建带有字体 Awesome 铅笔图像的编辑链接。为了防止编译器错误,在CarsController中添加一个基本的Edit()方法。

public IActionResult Edit()
{
  return View();
}

项目创建标签帮助器

TagHelpers文件夹中创建一个名为ItemCreateTagHelper.cs的新类。使类public继承ItemLinkTagHelperBase。将以下using语句添加到新文件中:

using AutoLot.Mvc.Controllers;
using AutoLot.Mvc.TagHelpers.Base;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace AutoLot.Mvc.TagHelpers
{
  public class ItemCreateTagHelper : ItemLinkTagHelperBase
  {
  }
}

添加一个公共构造函数来接收所需的对象实例,并将它们传递给基类:

public ItemCreateTagHelper(
    IActionContextAccessor contextAccessor,
    IUrlHelperFactory urlHelperFactory)
      : base(contextAccessor, urlHelperFactory) { }

覆盖Process()方法,调用基类中的BuildContent()方法。

public override void Process(TagHelperContext context, TagHelperOutput output)
{
  BuildContent(output,nameof(CarsController.Create),"text-success","Create new","plus");
}

这将创建带有字体 Awesome plus 图像的创建链接。

项目列表标签帮助器

TagHelpers文件夹中创建一个名为ItemEditTagHelper.cs的新类。使类public继承ItemLinkTagHelperBase。将以下using语句添加到新文件中:

using AutoLot.Mvc.Controllers;
using AutoLot.Mvc.TagHelpers.Base;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace AutoLot.Mvc.TagHelpers
{
  public class ItemListTagHelper : ItemLinkTagHelperBase
  {
  }
}

添加一个公共构造函数来接收所需的对象实例,并将它们传递给基类。

public ItemListTagHelper(
    IActionContextAccessor contextAccessor,
    IUrlHelperFactory urlHelperFactory)
      : base(contextAccessor, urlHelperFactory) { }

覆盖Process()方法,调用基类中的BuildContent()方法。

public override void Process(TagHelperContext context, TagHelperOutput output)
{
  BuildContent(output,nameof(CarsController.Index),"text-default","Back to List","list");
}

这将创建带有字体 Awesome 列表图像的编辑链接。为了防止编译器错误,在CarsController中添加一个基本的Index()方法。

public IActionResult Index()
{
  return View();
}

使自定义标记助手可见

要使定制标签助手可见,必须对任何使用标签助手或添加到_ViewImports.cshtml文件的视图执行@addTagHelper命令。打开Views文件夹根目录下的_ViewImports.cshtml文件,添加下面一行:

@addTagHelper *, AutoLot.Mvc

HTML 助手

来自 ASP.NET MVC 的 HTML 助手仍然被支持,并且有一些仍然被广泛使用。表 31-5 列出了那些仍然常用的助手。

表 31-5。

常用的 HTML 助手

|

HTML 助手

|

使用

Html.DisplayFor() 显示由表达式定义的对象
Html.DisplayForModel() 使用默认模板或自定义模板显示模型
Html.DisplayNameFor() 如果显示名称存在,则获取显示名称;如果没有显示名称,则获取属性名称
Html.EditorFor() 显示由表达式定义的对象的编辑器
Html.EditorForModel() 使用默认模板或自定义模板显示模型的编辑器

HTML 助手的显示

DisplayFor()助手显示一个由表达式定义的对象。如果显示的类型有一个显示模板,那么它将用于创建该项的 HTML。例如,如果一个视图的模型是Car实体,那么这个CarMake信息可以用下面的代码显示:

@Html.DisplayFor(x=>x.MakeNavigation);

如果名为Make.cshtml的视图存在于DisplayTemplates文件夹中,那么该视图将用于呈现值(记住模板名称查找是基于对象的类型,而不是其属性名称)。如果有一个名为ShowMake.cshtml的视图(例如),它可以通过以下调用来呈现对象:

@Html.DisplayFor(x=>x.MakeNavigation, "ShowMake");

如果没有指定模板,并且没有用于类名的模板,则使用反射来创建用于显示的 HTML。

DisplayForModel HTML 帮助器

DisplayForModel()助手显示视图的模型。如果显示的类型有一个显示模板,那么它将用于创建该项的 HTML。继续前面的例子,用Car实体作为它的模型的视图,全部的Car信息可以用这个来显示:

@Html.DisplayForModel();

就像DisplayFor()助手一样,如果一个以类型命名的显示模板存在,它将被使用。也可以使用命名模板。例如,要用CarWithColors.html显示模板显示Car,使用下面的调用:

@Html.DisplayForModel("CarWithColors");

如果没有指定模板,并且没有用于类名的模板,则使用反射来创建用于显示的 HTML。

EditorFor 和 EditorForModel HTML 助手

EditorFor()EditorForModel()辅助功能与其对应的显示辅助功能相同。不同之处在于,在EditorTemplates目录中搜索模板,并显示 HTML 编辑器,而不是对象的只读表示。

管理客户端库

在完成视图之前,是时候更新客户端库(CSS 和 JavaScript)了。LibraryManager 项目(最初由 Mads Kristensen 构建)现在是 Visual Studio (VS2019)的一部分,也可以作为. NET 核心全局工具使用。LibraryManager 使用一个简单的 JSON 文件从 CDNJS 中提取 CSS 和 JavaScript 工具。comUNPKG。com ,JSDeliver,或者文件系统。

将库管理器作为. NET 核心全局工具安装

库管理器现在内置于 Visual Studio 中。要将其作为. NET 核心全局工具安装,请输入以下命令:

dotnet tool install --global Microsoft.Web.LibraryManager.Cli --version 2.1.113

当前版本可在 https://www.nuget.org/packages/Microsoft.Web.LibraryManager.Cli/ 找到。

将客户端库添加到自动 Lot。手动音量调节

当自动手枪。Mvc 项目已创建(使用 Visual Studio 或。NET Core CLI),在wwwroot\lib文件夹中安装了几个 JavaScript 和 CSS 文件。删除整个lib文件夹及其包含的所有文件,因为库管理器将替换所有文件。

添加 libman.json 文件

libman.json文件控制安装的内容、来源以及安装文件的目的地。

可视化工作室

如果您使用的是 Visual Studio,请右键单击 AutoLot。Mvc 项目并选择管理客户端库。这将把libman.json文件添加到项目的根目录中。Visual Studio 中还有一个将库管理器绑定到 MSBuild 进程的选项。右键单击libman.json文件并选择“在构建时启用恢复”这将提示您允许将另一个 NuGet 包(Microsoft.Web.LibraryManager.Build)恢复到项目中。允许安装软件包。

命令行

使用以下命令创建一个新的libman.json文件(这将默认提供者设置为 cdnjs.com ):

libman init --default-provider cdnjs

更新 libman.json 文件

当搜索要安装的库时,CDNJS.com 有一个很好的、人类可读的 API 可以使用。使用以下 URL 列出所有可用的库:

https://api.cdnjs.com/libraries?output=human

当您找到想要安装的资源库时,请使用列出的资源库名称更新 URL,以查看所有版本和每个版本的文件。例如,要查看 jQuery 的所有可用内容,请输入以下内容:

https://api.cdnjs.com/libraries/jquery?output=human

一旦确定了要安装的版本和文件,添加库名(和版本)、目的地(通常是wwwroot/lib/<library name>)和要加载的文件。例如,要加载 jQuery,请在库的 JSON 数组中输入以下内容:

{
  "library": "jquery@3.5.1",
  "destination": "wwwroot/lib/jquery",
  "files": [ "jquery.js"]
},

添加该应用所需的所有文件后,整个libman.json文件如下所示:

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "defaultDestination": "wwwroot/lib",
  "libraries": [
    {
      "library": "jquery@3.5.1",
      "destination": "wwwroot/lib/jquery",
      "files": [ "jquery.js", "jquery.min.js" ]
    },
    {
      "library": "jquery-validate@1.19.2",
      "destination": "wwwroot/lib/jquery-validation",
      "files": [ "jquery.validate.js", "jquery.validate.min.js", "additional-methods.js", "additional-methods.min.js" ]
    },
    {
      "library": "jquery-validation-unobtrusive@3.2.11",
      "destination": "wwwroot/lib/jquery-validation-unobtrusive",
      "files": [ "jquery.validate.unobtrusive.js", "jquery.validate.unobtrusive.min.js" ]
    },
    {
      "library": "twitter-bootstrap@4.5.3",
      "destination": "wwwroot/lib/bootstrap",
      "files": [
        "css/bootstrap.css",
        "js/bootstrap.bundle.js",
        "js/bootstrap.js"
      ]
    },
    {
      "library": "font-awesome@5.15.1",
      "destination": "wwwroot/lib/font-awesome/",
      "files": [
        "js/all.js",
        "css/all.css",
        "sprites/brands.svg",
        "sprites/regular.svg",
        "sprites/solid.svg",
        "webfonts/fa-brands-400.eot",
        "webfonts/fa-brands-400.svg",
        "webfonts/fa-brands-400.ttf",
        "webfonts/fa-brands-400.woff",
        "webfonts/fa-brands-400.woff2",
        "webfonts/fa-regular-400.eot",
        "webfonts/fa-regular-400.svg",
        "webfonts/fa-regular-400.ttf",
        "webfonts/fa-regular-400.woff",
        "webfonts/fa-regular-400.woff2",
        "webfonts/fa-solid-900.eot",
        "webfonts/fa-solid-900.svg",
        "webfonts/fa-solid-900.ttf",
        "webfonts/fa-solid-900.woff",
        "webfonts/fa-solid-900.woff2"
      ]
    }
  ]
}

Note

如果你想知道为什么没有列出任何缩小的文件,这将很快涵盖。

一旦保存了文件(在 Visual Studio 中),这些文件将被加载到项目的wwwroot\lib文件夹中。如果从命令行运行,请输入以下命令来重新加载所有文件:

libman restore

还提供了其他命令行选项。输入libman -h浏览所有选项。

更新 JavaScript 和 CSS 参考

许多 JavaScript 和 CSS 文件的位置随着库管理器的移动而改变。Bootstrap 和 jQuery 是从\dist文件夹中加载的。我们还在应用中添加了字体 Awesome。

引导文件的位置需要更新到~/lib/boostrap/css而不是~/lib/boostrap/dist/css。在最后加上字体 Awesome,就在site.css之前。将_Head.cshtml文件更新为以下内容:

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@ViewData["Title"] - AutoLot.Mvc</title>
<environment include="Development">
  <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.css" asp-append-version="true"/>
</environment>
<environment exclude="Development">
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
      asp-fallback-href="~/lib/bootstrap/css/bootstrap.css"
      asp-fallback-test-class="sr-only" asp-fallback-test-property="position" asp-fallback-test-value="absolute"
      crossorigin="anonymous"
      integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"/>
</environment>
<link rel="stylesheet" href="~/lib/font-awesome/css/all.css" asp-append-version="true"/>
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>

接下来,更新_JavaScriptFiles.cshtml以将\dist从 jQuery 和引导位置中取出。

<environment include="Development">
  <script src="~/lib/jquery/jquery.js" asp-append-version="true"></script>
  <script src="~/lib/bootstrap/js/bootstrap.bundle.js" asp-append-version="true"></script>
</environment>
<environment exclude="Development">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"
    asp-fallback-src="~/lib/jquery/jquery.min.js"
    asp-fallback-test="window.jQuery"
    crossorigin="anonymous"
    integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=">
  </script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/js/bootstrap.bundle.min.js"
    asp-fallback-src="~/lib/bootstrap/js/bootstrap.bundle.min.js"
    asp-fallback-test="window.jQuery && window.jQuery.fn && window.jQuery.fn.modal"
    crossorigin="anonymous"
    integrity="sha384-xrRywqdh3PHs8keKZN+8zzc5TX0GRTLCcmivcbNJWm2rs5C8PRhcEn3czEjhAO9o">
  </script>
</environment>
<script src="~/js/site.js" asp-append-version="true"></script>

最后的改变是更新_ValidationScriptsPartial.cshtml局部视图中jquery.validate的位置。

<environment include="Development">
  <script src="~/lib/jquery-validation/jquery.validate.js" asp-append-version="true"></script>
  <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js" asp-append-version="true"></script>
</environment>
<environment exclude="Development">
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validate/1.19.1/jquery.validate.min.js"
    asp-fallback-src="~/lib/jquery-validation/jquery.validate.min.js"
    asp-fallback-test="window.jQuery && window.jQuery.validator"
    crossorigin="anonymous"
    integrity="sha256-F6h55Qw6sweK+t7SiOJX+2bpSAa3b/fnlrVCJvmEj1A=">
  </script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-validation-unobtrusive/3.2.11/jquery.validate.unobtrusive.min.js"
  asp-fallback-src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"
  asp-fallback-test="window.jQuery && window.jQuery.validator && window.jQuery.validator.unobtrusive"
  crossorigin="anonymous"
  integrity="sha256-9GycpJnliUjJDVDqP0UEu/bsm9U+3dnQUH8+3W10vkY=">
  </script>
</environment>

完成小车控制器和汽车视图

该部分完成了CarsControllerCars视图。如果您将appsettings.development.json中的RebuildDatabase标志设置为true,那么您在测试这些视图时所做的任何更改都将在您下次启动应用时被重置。

小车控制器

CarsController是自动 Lot 的焦点。Mvc 应用,具有创建、读取、更新和删除功能。这个版本的CarsController直接使用数据访问层。在本章的后面,你将创建另一个版本的CarsController,它使用自动手枪。用于数据访问的 Api 服务。

首先更新CarsController类的using语句,使其符合以下内容:

using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Models.Entities;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;

前面,您添加了带有路由的控制器类。现在是时候通过依赖注入来添加ICarRepoIAppLogging<CarsController>实例了。添加两个类级变量来保存实例,并添加一个将被注入这两个项的构造函数。

private readonly ICarRepo _repo;
private readonly IAppLogging<CarsController> _logging;
public CarsController(ICarRepo repo, IAppLogging<CarsController> logging)
{
  _repo = repo;
  _logging = logging;
}

汽车列表局部视图

列表视图(一个是全部汽车清单,一个是制造商汽车列表)都共享一个局部视图。在Views\Cars目录下创建一个名为Partials的新目录。在这个目录中,添加一个名为_CarListPartial.cshtml的新视图,并清除现有代码。将IEnumerable<Car>设置为类型(它不需要完全限定,因为我们将AutoLot.Models.Entities名称空间添加到了_ViewImports.cshtml文件中)。

@model IEnumerable< Car>

接下来,添加一个 Razor 块,其中包含一组布尔变量,指示是否应该显示Makes。当该部分被整个库存清单使用时,应显示Makes。当它只显示一个Make时,应该隐藏Make字段。

@{
    var showMake = true;
    if (bool.TryParse(ViewBag.ByMake?.ToString(), out bool byMake))
    {
        showMake = !byMake;
    }
}

下一个标记使用ItemCreateTagHelper创建一个到 Create HTTP Get 方法的链接。当使用自定义标签助手时,名称是小写的 kebab。这意味着TagHelper后缀被删除,然后每个 Pascal 大小写的单词被小写,并用连字符分隔,就像烤肉串一样:

<p>
  <item-create></item-create>
</p>

为了设置表格和表格标题,Razor HTML 助手用于获取每个属性的DisplayNameDisplayName将选择DisplayDisplayName属性的值,如果没有设置,它将使用属性名。本节使用一个剃刀块来显示基于前面设置的视图级变量的Make信息。

<table class="table">
  <thead>
  <tr>
    @if (showMake)
    {
      <th>
        @Html.DisplayNameFor(model => model.MakeId)
      </th>
    }
    <th>
      @Html.DisplayNameFor(model => model.Color)
    </th>
    <th>
      @Html.DisplayNameFor(model => model.PetName)
    </th>
    <th></th>
  </tr>
  </thead>

最后一部分循环遍历记录,并使用DisplayFor Razor HTML 助手显示表记录。这个助手将寻找一个匹配属性类型的DisplayTemplate模板名,如果没有找到,将以默认方式创建标记。对象上的每个属性也将检查显示模板,如果找到就使用它。例如,如果Car有一个DateTime属性,那么本章前面显示的DisplayTemplate将被调用。

这个块还使用了在上一节中添加的item-edititem-detailsitem-delete定制标记助手。请注意,当将值传递给自定义标记助手的公共属性时,属性名称是小写的,并作为属性添加到标记中。

  <tbody>
    @foreach (var item in Model)
    {
      <tr>
        @if (showMake)
        {
          <td>
            @Html.DisplayFor(modelItem => item.MakeNavigation.Name)
          </td>
        }
        <td>
          @Html.DisplayFor(modelItem => item.Color)
        </td>
        <td>
          @Html.DisplayFor(modelItem => item.PetName)
        </td>
        <td>
          <item-edit item-id="@item.Id"></item-edit> |
          <item-details item-id="@item.Id"></item-details> |
          <item-delete item-id="@item.Id"></item-delete>
        </td>
      </tr>
    }
    </tbody>
</table>

索引视图

_CarListPartial部分就位的情况下,Index视图很小。在Views\Cars目录中创建一个名为Index.cshtml的新视图。清除所有生成的代码,并添加以下内容:

@model IEnumerable<Car>
@{
  ViewData["Title"] = "Index";
}
<h1>Vehicle Inventory</h1>
<partial name="Partials/_CarListPartial" model="@Model"/>

分部_CarListPartial由包含视图的模型值(IEnumerable<Car>)调用,该值通过model属性传递。这将局部视图的模型设置为传递给<partial>标签辅助对象的对象。

要查看这个视图的运行情况,请将CarsController Index()方法更新如下:

[Route("/[controller]")]
[Route("/[controller]/[action]")]
public IActionResult Index()
  => View(_repo.GetAllIgnoreQueryFilters());

现在您已经有了Index视图,运行应用并导航到https://localhost:5001/Cars/Index,您将看到如图 31-4 所示的列表。

img/340876_10_En_31_Fig4_HTML.jpg

图 31-4。

汽车库存页面

虽然自定义标签助手显示在列表的右侧,但是字体 Awesome 图像没有显示,因为字体 Awesome 库还没有添加到应用中。

备用视图

ByMake视图类似于索引,但是将部分视图设置为除了在页面标题中不显示Make信息。在Views\Cars目录中创建一个名为ByMake.cshtml的新视图。清除所有生成的代码,并添加以下内容:

@model IEnumerable<Car>
@{
    ViewData["Title"] = "Index";
}
<h1>Vehicle Inventory for @ViewBag.MakeName</h1>
@{
    var mode = new ViewDataDictionary(ViewData) {{"ByMake", true}};
}
<partial name="Partials/_CarListPartial" model="Model" view-data="@mode"/>

有两个明显的区别。第一个是从ViewBag创建一个包含ByMake属性的新的ViewDataDictionary。然后将它和模型一起传递到 partial 中,这样就可以隐藏Make信息。

该视图的动作方法需要获取所有具有指定MakeId的车辆,并将ViewBag设置为MakeName,以便在 UI 中显示。这两个值都来自路由。在CarsController中添加一个名为ByMake()的新动作方法。

[HttpGet("/[controller]/[action]/{makeId}/{makeName}")]
public IActionResult ByMake(int makeId, string makeName)
{
  ViewBag.MakeName = makeName;
  return View(_repo.GetAllBy(makeId));
}

现在您已经有了Index视图,运行应用并导航到https://localhost:5001/Cars/1/VW,您将看到如图 31-5 所示的列表。

img/340876_10_En_31_Fig5_HTML.jpg

图 31-5。

特定品牌的汽车库存

详细视图

Views\Cars目录中创建一个名为Details.cshtml的新视图。清除所有生成的代码,并添加以下内容:

@model Car
@{
  ViewData["Title"] = "Details";
}
<h1>Details for @Model.PetName</h1>
@Html.DisplayForModel()
<div>
  <item-edit item-id="@Model.Id"></item-edit>
  <item-delete item-id="@Model.Id"></item-delete>
  <item-list></item-list>
</div>

@Html.DisplayForModel()使用本节前面构建的显示模板(Car.cshtml)来显示Car细节。

在更新Details()动作方法之前,添加一个名为GetOne()的助手方法,该方法将检索单个Car记录。

internal Car GetOneCar(int? id) => !id.HasValue ? null : _repo.Find(id.Value);

Details()动作方法更新如下:

[HttpGet("{id?}")]
public IActionResult Details(int? id)
{
  if (!id.HasValue)
  {
    return BadRequest();
  }
  var car = GetOneCar(id);
  if (car == null)
  {
    return NotFound();
  }
  return View(car);
}

Details()动作方法的 route 为Car id取一个可选的 route 参数,并将其设置为该方法的id参数。请注意,route 参数带有一个带标记的问号。这表明它是一个可选参数,就像int?类型上的问号使变量成为可空的int。如果没有提供该参数,或者如果服务包装器找不到具有所提供的 route 参数的车辆,则该方法返回一个NotFound结果。否则,该方法将定位的Car记录发送到细节视图。

运行应用并导航至https://localhost:5001/Cars/Details/1,您将看到如图 31-6 所示的屏幕。

img/340876_10_En_31_Fig6_HTML.jpg

图 31-6。

特定汽车的详细信息

创建视图

Create视图启动较早。以下是完整视图的完整列表:

@model Car

@{
    ViewData["Title"] = "Create";
}

<h1>Create a New Car</h1>
<hr/>
<div class="row">
    <div class="col-md-4">
        <form asp-controller="Cars" asp-action="Create">
            <div asp-validation-summary="ModelOnly" class="text-danger"></div>
            @Html.EditorForModel()
            <div class="form-group">
                <button type="submit" class="btn btn-success">Create <i class="fas fa-plus"></i></button>&nbsp;&nbsp;|&nbsp;&nbsp;
                <item-list></item-list>
            </div>
        </form>
    </div>
</div>
@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

@Html.EditorForModel()方法使用本节前面构建的编辑器模板(Car.cshtml)来显示Car细节。

该视图还在Scripts部分引入了_ValidationScriptsPartial。回想一下,在布局中,这个部分发生在加载 jQuery 之后的*。sections 模式有助于确保在节的内容之前加载适当的依赖项。*

创建操作方法

创建过程使用两个操作方法:一个(HTTP Get)返回新记录输入的空白视图,另一个(HTTP Put)提交新记录的值。

getnames 帮助器方法

GetMakes()助手方法将Make记录的列表返回到一个SelectList中。它将IMakeRepo的一个实例作为参数。

internal SelectList GetMakes(IMakeRepo makeRepo)
  => new SelectList(makeRepo.GetAll(), nameof(Make.Id), nameof(Make.Name));

Create Get 方法

Create() HTTP Get action 方法将Make记录的一个SelectList添加到ViewData字典中,然后由Id获取一辆汽车并发送给Create视图。

[HttpGet]
public IActionResult Create([FromServices] IMakeRepo makeRepo)
{
  ViewData["MakeId"] = GetMakes(makeRepo);
  return View();
}

创建的表单可在/Cars/Create查看,如图 31-7 所示。

img/340876_10_En_31_Fig7_HTML.jpg

图 31-7。

创建视图

创建帖子的方法

Create() HTTP Post 操作方法使用隐式模型绑定从表单值构建一个Car实体。此处列出了代码,并附有解释:

[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Create([FromServices] IMakeRepo makeRepo, Car car)
{
  if (ModelState.IsValid)
  {
    _repo.Add(car);
    return RedirectToAction(nameof(Details),new {id = car.Id});
  }
  ViewData["MakeId"] = GetMakes(makeRepo);
  return View(car);
}

当请求是 post 时,HttpPost属性将其标记为Cars / Create路由的应用端点。ValidateAntiForgeryToken属性使用__RequestVerificationToken的隐藏输入值来帮助减少对站点的攻击。

IMakeRepo从依赖注入容器注入到方法中。因为这是方法注入,所以使用了FromServices属性。提醒一下,这通知绑定引擎不要尝试这种类型的绑定,并让 DI 容器知道创建该类的一个实例。

Car实体被隐式绑定到传入的请求数据。如果ModelState有效,实体被添加到数据库,然后用户被重定向到Detail()动作方法,使用新创建的Car Id作为路由参数。这是 Post-Redirect-Get 模式。用户提交了一个 Post 方法(Create()),然后被重定向到一个 Get 方法(Details())。这可以防止浏览器在用户刷新页面时重新提交帖子。

如果ModelState无效,则Makes SelectList被添加到ViewData,并且被提交的实体被发送回Create视图。ModelState也被隐式发送到视图中,因此任何错误都可以显示出来。

编辑视图

Views\Cars目录中创建一个名为Edit.cshtml的新视图。清除所有生成的代码,并添加以下内容:

@model Car
@{
    ViewData["Title"] = "Edit";
}
<h1>Edit @Model.PetName</h1>
<hr />
<div class="row">
  <div class="col-md-4">
    <form asp-area="" asp-controller="Cars" asp-action="Edit" asp-route-id="@Model.Id">
      @Html.EditorForModel()
      <input type="hidden" asp-for="Id" />
      <input type="hidden" asp-for="TimeStamp" />
      <div class="form-group">
        <button type="submit" class="btn btn-primary">
            Save <i class="fas fa-save"></i>
        </button>&nbsp;&nbsp;|&nbsp;&nbsp;
        <item-list></item-list>
      </div>
    </form>
  </div>
</div>
@section Scripts {
    <partial name="_ValidationScriptsPartial" />
}

这个视图也使用了@Html.EditorForModel()方法和_ValidationScriptsPartial。但它也包括两个隐藏的输入,分别用于IdTimeStamp。这些将与其余的表单数据一起发布,但用户不能编辑。没有IdTimeStamp,更新将无法保存。

编辑操作方法

编辑过程还使用两个操作方法:一个(HTTP Get)返回要编辑的实体,另一个(HTTP Put)提交更新记录的值。

Edit Get 方法

Create() HTTP Get action 方法通过Id使用服务包装器获取一辆汽车,并将其发送给Create视图。

[HttpGet("{id?}")]
public IActionResult Edit([FromServices] IMakeRepo makeRepo, int? id)
{
  var car = GetOneCar(id);
  if (car == null)
  {
    return NoContent();
  }
  ViewData["MakeId"] = GetMakes(makeRepo);
  return View(car);
}

该路线有一个选项id参数,然后使用id参数将其传递给该方法。一个IMakeRepo的实例被注入到该方法中,并用于为品牌下拉菜单创建SelectList。该方法使用GetOneCar()助手方法来获得一个Car记录。如果不能定位一个Car记录,该方法返回一个NoContent错误。否则,它会将Make SelectList添加到ViewData字典中,并呈现视图。

编辑表单可在/Cars/Edit/1处查看,如图 31-8 所示。

img/340876_10_En_31_Fig8_HTML.jpg

图 31-8。

编辑视图

编辑帖子的方法

Edit() HTTP Post 操作方法类似于Create() HTTP Post 方法,除了在代码清单后面注明的例外:

[HttpPost("{id}")]
[ValidateAntiForgeryToken]
public IActionResult Edit([FromServices] IMakeRepo makeRepo, int id, Car car)
{
  if (id != car.Id)
  {
    return BadRequest();
  }
  if (ModelState.IsValid)
  {
    _repo.Update(car);
    return RedirectToAction(nameof(Details),new {id = car.Id});
  }
  ViewData["MakeId"] = GetMakes(makeRepo);
  return View(car);
}

Edit HTTP Post 方法采用一个必需的路由参数。如果这与重组的CarId不匹配,则向客户端发送一个BadRequest结果。如果ModelState有效,实体被更新,然后用户被重定向到Detail()动作方法,使用Car Id作为路由参数。这也使用了 Post-Redirect-Get 模式。

如果ModelState无效,则Makes SelectList被添加到ViewData,并且被提交的实体被发送回Edit视图。ModelState也被隐式发送到视图中,因此任何错误都可以显示出来。

删除视图

Views\Cars目录中创建一个名为Delete.cshtml的新视图。清除所有生成的代码,并添加以下内容:

@model Car
@{
  ViewData["Title"] = "Delete";
}
<h1>Delete @Model.PetName</h1>
<h3>Are you sure you want to delete this car?</h3>
<div>
  @Html.DisplayForModel()
  <form asp-action="Delete">
    <input type="hidden" asp-for="Id" />
    <input type="hidden" asp-for="TimeStamp" />
    <button type="submit" class="btn btn-danger">
      Delete <i class="fas fa-trash"></i>
    </button>&nbsp;&nbsp;|&nbsp;&nbsp;
    <item-list></item-list>
  </form>
</div>

这个视图也使用了@Html.DisplayForModel()方法和两个隐藏输入IdTimeStamp。这些将是作为表单数据发布的唯一字段。

删除操作方法

Delete流程还使用了两个动作方法:一个(HTTP Get)返回要删除的实体,一个(HTTP Put)提交要删除的值。

Delete Get 方法

Delete() HTTP Get 操作方法的功能与Details()操作方法相同。

[HttpGet("{id?}")]
public IActionResult Delete(int? id)
{
  var car = GetOneCar(id);
  if (car == null)
  {
    return NotFound();
  }
  return View(car);
}

删除表单可在/Cars/Delete/1查看,如图 31-9 所示。

img/340876_10_En_31_Fig9_HTML.jpg

图 31-9。

删除视图

删除帖子的方法

Delete() HTTP Post 动作方法只将IdTimeStamp发送给服务包装器。

[HttpPost("{id}")]
[ValidateAntiForgeryToken]
public IActionResult Delete(int id, Car car)
{
  if (id != car.Id)
  {
    return BadRequest();
  }
  _repo.Delete(car);
  return RedirectToAction(nameof(Index));
}

HTTP Post 方法被简化为只发送 EF Core 删除记录所需的值。

这就完成了Car实体的视图和控制器。

查看组件

视图组件是 ASP.NET 核心中的另一个新特性。它们结合了部分视图和子动作的优点来呈现部分 UI。像局部视图一样,它们是从另一个视图调用的,但是与局部视图本身不同,视图组件也有一个服务器端组件。这种组合使它们非常适合创建动态菜单(如下所示)、登录面板、侧边栏内容或任何需要运行服务器端代码但不能作为独立视图的功能。

Note

经典 ASP.NET MVC 中的子动作是控制器上的动作方法,不能作为面向客户端的端点。他们不存在于 ASP.NET 核心。

对于 AutoLot,视图组件将根据数据库中的品牌动态创建菜单。菜单在每个页面上都是可见的,所以它的逻辑位置是在_Layout.cshtml中。但是_Layout.cshtml没有服务器端组件(不像视图),所以应用中的每个动作都必须向_Layout.cshtml提供数据。这可以在OnActionExecuting事件处理程序中和放置在ViewBag中的记录中完成,但是维护起来可能会很麻烦。服务器端功能和 UI 封装的结合使这个场景成为视图组件的完美用例。

服务器端代码

在 AutoLot 的根目录下创建一个名为ViewComponents的新文件夹。Mvc 项目。在这个文件夹中添加一个名为MenuViewComponent.cs的新类文件。约定是用ViewComponent后缀命名视图组件类,就像控制器一样。就像控制器一样,当调用视图组件时,ViewComponent后缀会被删除。

将以下using语句添加到文件顶部:

using System.Linq;
using AutoLot.Dal.Repos.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;

将类更改为public并从ViewComponent继承。视图组件不必从ViewComponent基类继承,但是像Controller基类一样,从ViewComponent继承简化了很多工作。创建一个接受IMakeRepo接口实例的构造函数,并将其赋给一个类级变量。此时的代码如下所示:

namespace AutoLot.Mvc.ViewComponents
{
  public class MenuViewComponent : ViewComponent
  {
    private readonly IMakeRepo _makeRepo;
    public MenuViewComponent(IMakeRepo makeRepo)
    {
      _makeRepo = makeRepo;
  }
}

视图组件有两种可用的方法,Invoke()InvokeAsync()。必须实现其中的一个,因为MakeRepo只进行同步调用,所以添加Invoke()方法,如下所示:

public async IViewComponentResult Invoke()
{
}

当从视图中呈现视图组件时,调用公共方法Invoke() / InvokeAsync()。这个方法返回一个IViewComponentResult,它在概念上类似于一个PartialViewResult,但是更加精简。在Invoke()方法中,从回购中获取Makes的列表,如果成功,则返回一个ViewViewComponentResult(不,那不是错别字;它实际上是类型的名称)使用 makes 列表作为视图模型。如果获取Make记录的调用失败,返回带有错误消息的ContentViewComponentResult。将方法更新为以下内容:

public IViewComponentResult Invoke()
{
  var makes = _makeRepo.GetAll().ToList();
  if (!makes.Any())
  {
    return new ContentViewComponentResult("Unable to get the makes");
  }
  return View("MenuView", makes);
}

基类ViewComponentView助手方法类似于同名的Controller类助手方法,有几个关键区别。第一个区别是默认的视图文件名是Default.cshtml而不是方法名。然而,像控制器视图助手方法一样,视图的名称可以是任何东西,只要名称被传递到方法调用中(没有.cshtml扩展名)。第二个区别是视图 的位置必须是以下三个目录之一:

Views/< controller>/Components/<view_component_name>/<view_name>
Views/Shared/Components/<view_component_name>/<view_name>
Pages/Shared/Components/<view_component_name>/<view_name>

Note

ASP.NET Core 2 . x 引入了 Razor 页面作为创建 web 应用的另一种机制。这本书不包括剃刀页。

C# 类可以存在于任何地方(甚至在另一个程序集中),但是<viewname>.cshtml必须在前面列出的目录中。

构建局部视图

MenuViewComponent呈现的局部视图将遍历Make记录,将每个记录添加为一个列表项,显示在引导菜单中。“全部”菜单项首先作为硬编码值添加。

Views\Shared文件夹下创建一个名为Components的新文件夹。在这个新文件夹中,创建另一个名为Menu的新文件夹。这个文件夹名必须与之前创建的视图组件类的名称相匹配,并去掉后缀ViewComponent。在这个文件夹中,创建一个名为MenuView.cshtml的局部视图。

清除现有代码并添加以下标记:

@model IEnumerable<Make>
<div class="dropdown-menu">
<a class="dropdown-item text-dark" asp-area="" asp-controller="Cars" asp-action="Index">All</a>

@foreach (var item in Model)
{
    <a class="dropdown-item text-dark" asp-controller="Cars" asp-action="ByMake" asp-route-makeId="@item.Id" asp-route-makeName="@item.Name">@item.Name</a>
}
</div>

调用视图组件

视图组件通常从视图中呈现(尽管它们也可以从控制器动作方法中呈现)。语法很简单:Component.Invoke(<view component name>)或者@await Component.InvokeAsync(<view component name>)。就像控制器一样,当调用视图组件时,必须删除ViewComponent后缀。

@await Component.InvokeAsync("Menu") //async version
@Component.Invoke("Menu") //non-async version

调用视图组件作为定制标记助手

在 ASP.NET 1.1 中引入了视图组件,可以使用标记助手语法来调用它。不要使用Component.InvokeAsync() / Component.Invoke(),只需像这样调用视图组件:

<vc:menu></vc:menu>

要使用这种调用视图组件的方法,您的应用必须选择使用它们。这是通过添加带有包含视图组件的程序集名称的@addTagHelper命令来完成的。必须将下面一行添加到_ViewImports.cshtml文件中,该文件已经为定制标记助手添加了:

@addTagHelper *, AutoLot.Mvc

更新菜单

打开_Menu.cshtml部分并导航到映射到Home/Index动作方法的<li></li>块之后。将以下标记复制到分部:

<li class="nav-item dropdown">
  <a class="nav-link dropdown-toggle text-dark" data-toggle="dropdown">Inventory <i class="fa fa-car"></i></a>
    <vc:menu />
</li>

粗体行将MenuViewComponent呈现到菜单中。周围的标记是引导格式。

现在,当您运行该应用时,您将看到清单菜单,子菜单项中列出了品牌,如图 31-10 所示。

img/340876_10_En_31_Fig10_HTML.jpg

图 31-10。

MenuViewComponent 提供的菜单

捆绑和缩小

使用客户端库构建 web 应用的另外两个考虑因素是捆绑和缩小以提高性能。

集束

Web 浏览器对允许从同一个端点同时下载的文件数量有一个限制。如果您对 JavaScript 和 CSS 文件使用可靠的开发技术,将相关的代码和样式分离成更小、更易维护的文件,这可能会有问题。这提供了更好的开发体验,但在文件等待下载时会降低应用的性能。捆绑只是将文件连接在一起,以防止它们在等待浏览器下载限制时被阻止。

缩小

此外,为了提高性能,缩小过程会更改 CSS 和 JavaScript 文件,使它们变得更小。删除了不必要的空格和行尾,并缩短了非关键字名称。虽然这使得文件对人来说几乎不可读,但是功能不受影响,并且大小可以显著减小。这反过来加快了下载过程,从而提高了应用的性能。

网络优化解决方案

作为构建过程的一部分,有许多开发工具可以捆绑和缩小文件。这些当然是有效的,但如果进程变得不同步,可能会有问题,因为对于原始文件以及打包和缩小的文件,确实没有一个好的比较器。

WebOptimizer 是一个开源包,它将捆绑、缩小和缓存作为 ASP.NET 核心管道的一部分。这确保了捆绑和缩小的文件准确地表示原始文件。它们不仅准确,而且被缓存,大大减少了页面请求的磁盘读取次数。在第二十九章中创建项目时,您已经添加了Libershark.WebOptimizer.Core包。现在是使用它的时候了。

更新 Startup.cs

第一步是将 WebOptimizer 添加到管道中。打开自动 Lot 中的Startup.cs文件。Mvc 项目,导航到Configure()方法,并添加下面一行代码(就在app.UseStaticFiles()调用之前):

app.UseWebOptimizer();

下一步是配置最小化和捆绑的内容。通常,在开发您的应用时,您希望看到文件的非绑定/非精简版本,但是对于登台和生产,绑定和精简才是您想要的。在ConfigureServices()方法中,添加以下代码块:

if (_env.IsDevelopment() || _env.IsEnvironment("Local"))
{
  services.AddWebOptimizer(false,false);
}
else
{
  services.AddWebOptimizer(options =>
  {
    options.MinifyCssFiles(); //Minifies all CSS files
    //options.MinifyJsFiles(); //Minifies all JS files
    options.MinifyJsFiles("js/site.js");
    options.MinifyJsFiles("lib/**/*.js");
  });
}

如果环境为Development,则禁用所有捆绑和缩小。如果不是,下面的代码将缩小所有 CSS 文件,缩小site.js,并缩小lib目录下的所有 JavaScript 文件(扩展名为.js)。注意,所有路径都从项目中的wwwroot文件夹开始。

WebOptimizer 也支持捆绑。第一个示例使用文件 globbing 创建一个包,第二个示例创建一个列出特定名称的包。

options.AddJavaScriptBundle("js/validations/validationCode.js", "js/validations/**/*.js");
options.AddJavaScriptBundle("js/validations/validationCode.js", "js/validations/validators.js", "js/validations/errorFormatting.js");

需要注意的是,缩小和打包的文件实际上并不在磁盘上,而是放在缓存中。同样需要注意的是,缩小后的文件保持相同的名称(site.js),并且名称中没有通常的 min(site.min.js)。

Note

当更新视图以添加捆绑文件的链接时,Visual Studio 会报错捆绑文件不存在。别担心,它仍然会从缓存中进行渲染。

Update _ViewImports.cshtml

最后一步是将 WebOptimizer 标记助手添加到系统中。这些功能与本章前面提到的asp-append-version标签助手的功能相同,但对所有捆绑和缩小的文件都是自动完成的。将下面一行添加到_ViewImports.cshtml文件的末尾:

@addTagHelper *, WebOptimizer.Core

ASP.NET 核心的期权模式

options 模式通过依赖注入提供了配置的设置类对其他类的访问。可以使用IOptions<T>的一个版本将配置类注入到另一个类中。该接口有多个版本,如表 31-6 所示。

表 31-6。

一些选项接口

|

连接

|

描述

IOptionsMonitor<T> 检索选项并支持以下功能:变更通知(使用OnChange)、配置重载、命名选项(使用GetCurrentValue)和选择性选项失效。
IOptionsMonitorCache<T> 缓存T的实例,支持完全/部分失效/重新加载。
IOptionsSnaphot<T> 对每个请求重新计算选项。
IOptionsFactory<T> 创建 t 的新实例。
IOptions<T> 根接口。不支持IOptionsMonitor<T>。保留以向后兼容。

添加经销商信息

一个汽车网站应该显示经销商信息,该信息应该是可定制的,而不必重新部署整个网站。这将通过使用 options 模式来完成。首先更新appsettings.json文件,添加经销商信息。

{
  "Logging": {
    "MSSqlServer": {
      "schema": "Logging",
      "tableName": "SeriLogs",
      "restrictedToMinimumLevel": "Warning"
    }
  },
  "ApplicationName": "AutoLot.MVC",
  "AllowedHosts": "*",
  "DealerInfo": {
    "DealerName": "Skimedic's Used Cars",
    "City": "West Chester",
    "State": "Ohio"
  }
}

接下来,我们需要创建一个视图模型来保存经销商信息。在自动车床的Models文件夹中。Mvc 项目,添加一个名为DealerInfo.cs的新类。将该类更新为以下内容:

namespace AutoLot.Mvc.Models
{
  public class DealerInfo
  {
    public string DealerName { get; set; }
    public string City { get; set; }
    public string State { get; set; }
  }
}

Note

要配置的类必须有一个公共的无参数构造函数,并且是非抽象的。可以在类属性上设置默认值。

IServiceCollectionConfigure()方法将配置文件的一部分映射到一个特定的类型。然后可以使用 options 模式将该类型注入到类和视图中。打开Startup.cs文件,添加以下using语句:

using AutoLot.Mvc.Models;

接下来,导航到ConfigureServices()方法,并添加以下代码行:

services.Configure<DealerInfo>(Configuration.GetSection(nameof(DealerInfo)));

打开HomeController并添加以下using语句:

using Microsoft.Extensions.Options;

接下来将Index()方法更新如下:

[Route("/")]
[Route("/[controller]")]
[Route("/[controller]/[action]")]
[HttpGet]
public IActionResult Index([FromServices] IOptionsMonitor<DealerInfo> dealerMonitor)
{
  var vm = dealerMonitor.CurrentValue;
  return View(vm);
}

当从服务集合中配置一个类并将其添加到 DI 容器中时,可以使用 options 模式检索它。在这个例子中,OptionsMonitor将读取配置文件来创建一个DealerInfo类的实例。CurrentValue属性获取从当前设置文件创建的DealerInfo的实例(即使该文件在应用启动后已经更改)。然后,DealerInfo实例被传递给Index.cshtml视图。

现在更新位于Views\Home目录中的Index.cshtml视图,将其强类型化为DealerInfo类,并显示模型的属性。

@model AutoLot.Mvc.Models.DealerInfo
@{
    ViewData["Title"] = "Home Page";
}

<div class="text-center">
    <h1 class="display-4">Welcome to @Model.DealerName</h1>
    <p class="lead">Located in @Model.City, @Model.State</p>
</div>

Note

有关 ASP.NET 核心中选项模式的更多信息,请查阅文档: https://docs.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options?view=aspnetcore-5.0

创建服务包装

到目前为止,自动 Lot。Mvc 应用一直直接使用数据访问层。另一种方法是使用 AutoLot。Api 服务,并让该服务处理所有数据访问。

更新应用配置

自动手枪。Api 应用端点会因环境而异。例如,在您的工作站上开发时,基本 URI 是https://localhost:5021。在您的集成环境中,URI 可能是 https://mytestserver.com 。环境意识,结合更新的配置系统(在第二十九章中介绍),将用于添加这些不同的值。

appsettings.Development.json文件将添加本地机器的服务信息。随着代码在不同的环境中移动,每个环境的特定文件中的设置都会更新,以匹配该环境的基本 URI 和终结点。在本例中,您只更新开发环境的设置。打开appsettings.Development.json文件,并将其更新为以下内容(更改内容以粗体显示):

{
  "Logging": {
    "MSSqlServer": {
      "schema": "Logging",
      "tableName": "SeriLogs",
      "restrictedToMinimumLevel": "Warning"
    }
  },
  "RebuildDataBase": false,
  "ApplicationName": "AutoLot.Mvc - Dev",
  "ConnectionStrings": {
    "AutoLot": "Server=.,5433;Database=AutoLot;User ID=sa;Password=P@ssw0rd;"
  },
  "ApiServiceSettings": {
    "Uri": "https://localhost:5021/",
    "CarBaseUri": "api/Cars",
    "MakeBaseUri": "api/Makes"
  }
}

Note

确保端口号与 AutoLot.Api 的配置相匹配。

通过利用 ASP.NET 核心配置系统并更新特定于环境的文件(例如,appsettings.staging.jsonappsettings.production.json),您的应用将具有适当的值,而无需更改任何代码。

创建 ServiceSettings 类

服务设置将以与经销商信息相同的方式从设置中填充。在 AutoLot 中创建一个名为ApiWrapper的新文件夹。服务项目。在这个文件夹中,添加一个名为ApiServiceSettings.cs的类。类的属性名需要匹配 JSON ApiServiceSettings部分中的属性名。此处列出了该类:

namespace AutoLot.Services.ApiWrapper
{
  public class ApiServiceSettings
  {
    public ApiServiceSettings() { }
    public string Uri { get; set; }
    public string CarBaseUri { get; set; }
    public string MakeBaseUri { get; set; }
  }
}

API 服务包装器

ASP.NET 核心 2.1 引入了IHTTPClientFactory,它允许配置强类型类来调用 RESTful 服务。创建强类型类允许将所有 API 调用封装在一个地方。这集中了与服务的通信、HTTP 客户机的配置、错误处理等等。然后可以将该类添加到依赖注入容器中,供以后在应用中使用。DI 容器和IHTTPClientFactory处理HTTPClient的创建和处理。

IApiServiceWrapper 接口

AutoLot 服务包装器接口包含调用 AutoLot 的方法。Api 服务。在ApiWrapper目录下创建一个名为IApiServiceWrapper.cs的新接口,并将using语句更新如下:

using System.Collections.Generic;
using System.Threading.Tasks;
using AutoLot.Models.Entities;

将界面更新为如下所示的代码:

namespace AutoLot.Services.ApiWrapper
{
  public interface IApiServiceWrapper
  {
    Task<IList<Car>> GetCarsAsync();
    Task<IList<Car>> GetCarsByMakeAsync(int id);
    Task<Car> GetCarAsync(int id);
    Task<Car> AddCarAsync(Car entity);
    Task<Car> UpdateCarAsync(int id, Car entity);
    Task DeleteCarAsync(int id, Car entity);
    Task<IList<Make>> GetMakesAsync();
  }
}

ApiServiceWrapper 类

在 AutoLot 的ApiWrapper目录中创建一个名为ApiServiceWrapper的新类。服务项目。将using声明更新如下:

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using AutoLot.Models.Entities;
using Microsoft.Extensions.Options;

创建类public并添加一个构造函数,该构造函数接受HttpClientIOptionsMonitor<ApiServiceSettings>的实例。创建一个ServiceSettings类型的私有变量,并使用IOptionsMonitor<ServiceSettings>参数的CurrentValue属性对其赋值。代码如下所示:

public class ApiServiceWrapper : IApiServiceWrapper
{
  private readonly HttpClient _client;
  private readonly ApiServiceSettings _settings;
  public ApiServiceWrapper(HttpClient client, IOptionsMonitor<ApiServiceSettings> settings)
  {
      _settings = settings.CurrentValue;
    _client = client;
    _client/BaseAddress = new Uri(_settins.Uri);
  }
}

Note

接下来的部分包含大量没有任何错误处理的代码。这真是个馊主意!在已经很长的一章中,为了节省空间,省略了错误处理。

内部支撑方法

该类包含四个由公共方法使用的支持方法。

Post 和 Put 辅助方法

这些方法包装了相关的HttpClient方法。

internal async Task<HttpResponseMessage> PostAsJson(string uri, string json)
{
  return await _client.PostAsync(uri, new StringContent(json, Encoding.UTF8, "application/json"));
}

internal async Task<HttpResponseMessage> PutAsJson(string uri, string json)
{
  return await _client.PutAsync(uri, new StringContent(json, Encoding.UTF8, "application/json"));
}

HTTP 删除助手方法调用

最后一个 helper 方法用于执行 HTTP delete。HTTP 1.1 规范(以及更高版本)允许在 delete 语句中传递主体,但是还没有一个扩展方法来实现这个功能。HttpRequestMessage必须从零开始建造。

第一步是创建一个请求消息,使用对象初始化来设置ContentMethodRequestUri。完成后,消息被发送,响应被返回给调用代码。该方法如下所示:

internal async Task<HttpResponseMessage> DeleteAsJson(string uri, string json)
{
  HttpRequestMessage request = new HttpRequestMessage
  {
    Content = new StringContent(json, Encoding.UTF8, "application/json"),
    Method = HttpMethod.Delete,
    RequestUri = new Uri(uri)
  };
  return await _client.SendAsync(request);
}

HTTP Get 调用

有四个 Get 调用:一个获取所有的Car记录,一个通过Make获取Car记录,一个获取单个的Car,一个获取所有的Make记录。它们都遵循相同的模式。调用GetAsync()方法返回一个HttpResponseMessage。使用EnsureSuccessStatusCode()方法检查调用的成功或失败,如果调用没有返回成功的状态代码,就会抛出异常。然后,响应的主体被序列化回属性类型(实体或实体列表)并返回给调用代码。这里显示了这些方法中的每一种:

public async Task<IList<Car>> GetCarsAsync()
{
  var response = await _client.GetAsync($"{_settings.Uri}{_settings.CarBaseUri}");
  response.EnsureSuccessStatusCode();
  var result = await response.Content.ReadFromJsonAsync<IList<Car>>();
  return result;
}

public async Task<IList<Car>> GetCarsByMakeAsync(int id)
{
  var response = await _client.GetAsync($"{_settings.Uri}{_settings.CarBaseUri}/bymake/{id}");
  response.EnsureSuccessStatusCode();
  var result = await response.Content.ReadFromJsonAsync<IList<Car>>();
  return result;
}

public async Task<Car> GetCarAsync(int id)
{
  var response = await _client.GetAsync($"{_settings.Uri}{_settings.CarBaseUri}/{id}");
  response.EnsureSuccessStatusCode();
  var result = await response.Content.ReadFromJsonAsync<Car>();
  return result;
}

public async Task<IList<Make>> GetMakesAsync()
{
  var response = await _client.GetAsync($"{_settings.Uri}{_settings.MakeBaseUri}");
  response.EnsureSuccessStatusCode();
  var result = await response.Content.ReadFromJsonAsync<IList<Make>>();
  return result;
}

HTTP Post 调用

添加Car记录的方法使用 HTTP Post 请求。它使用 helper 方法将实体发布为 JSON,然后从响应体返回Car记录。此处列出了方法:

public async Task<Car> AddCarAsync(Car entity)
{
  var response = await PostAsJson($"{_settings.Uri}{_settings.CarBaseUri}",
    JsonSerializer.Serialize(entity));
  if (response == null)
  {
    throw new Exception("Unable to communicate with the service");
  }

  return await response.Content.ReadFromJsonAsync<Car>();
}

HTTP Put 调用

更新Car记录的方法使用了 HTTP Put 请求。它还使用 helper 方法将Car记录作为 JSON,并从响应主体返回更新后的Car记录。

public async Task<Car> UpdateCarAsync(int id, Car entity)
{
  var response = await PutAsJson($"{_settings.Uri}{_settings.CarBaseUri}/{id}",
    JsonSerializer.Serialize(entity));
  response.EnsureSuccessStatusCode();
  return await response.Content.ReadFromJsonAsync<Car>();
}

HTTP 删除调用

要添加的最后一个方法是用于执行 HTTP Delete。该模式遵循其余的方法:使用 helper 方法并检查响应是否成功。因为实体被删除了,所以没有任何东西返回到调用代码。该方法如下所示:

public async Task DeleteCarAsync(int id, Car entity)
{
  var response = await DeleteAsJson($"{_settings.Uri}{_settings.CarBaseUri}/{id}",
    JsonSerializer.Serialize(entity));
  response.EnsureSuccessStatusCode();
}

配置服务

在 AutoLot 的ApiWrapper目录中创建一个名为ServiceConfiguration.cs的新类。服务项目。将using声明更新如下:

using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

创建类publicstatic,并为IServiceCollection添加一个公共静态扩展方法。

namespace AutoLot.Services.ApiWrapper
{
  public static class ServiceConfiguration
  {
    public static IServiceCollection ConfigureApiServiceWrapper(this IServiceCollection services, IConfiguration config)
    {
      return services;
    }
  }
}

扩展方法的第一行将ApiServiceSettings添加到 DI 容器中。第二行将IApiServiceWrapper添加到 DI 容器中,并向HTTPClient工厂注册该类。这使得IApiServiceWrapper能够被注入到其他类中,HTTPClient工厂将管理HTTPClient的注入和寿命。

public static IServiceCollection ConfigureApiServiceWrapper(this IServiceCollection services, IConfiguration config)
{
  services.Configure<ApiServiceSettings>(config.GetSection(nameof(ApiServiceSettings)));
  services.AddHttpClient<IApiServiceWrapper,ApiServiceWrapper>();
  return services;
}

打开Startup.cs类并添加以下using语句:

using AutoLot.Services.ApiWrapper;

最后,导航到ConfigureServices()方法并添加下面一行:

services.ConfigureApiServiceWrapper(Configuration);

构建 api cartcontroller

当前版本的CarsController与数据访问库中的 repos 紧密绑定。CarsController的下一次迭代将使用服务包装器与数据库通信。将CarsController重命名为CarsDalController(包括构造函数),并将一个名为CarsController的新类添加到Controllers目录中。该类几乎是CarsController的精确副本。我将它们分开,以帮助澄清使用回购和服务之间的区别。

Note

在访问同一个数据库时,很少会同时使用数据访问层和服务层。我展示了这两种选择,以便您可以决定哪种方法最适合您。

using语句更新如下:

using System.Threading.Tasks;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Models.Entities;
using AutoLot.Services.ApiWrapper;
using AutoLot.Services.Logging;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;

接下来,创建类public,从Controller继承,并添加Route属性。创建一个接受IAutoLotServiceWrapperIAppLogging实例的构造函数,并将这两个实例分配给类级变量。起始代码如下所示:

namespace AutoLot.Mvc.Controllers
{
[Route("[controller]/[action]")]
public class CarsController : Controller
{
  private readonly IApiServiceWrapper _serviceWrapper;
  private readonly IAppLogging<CarsController> _logging;
  public CarsController(IApiServiceWrapper serviceWrapper, IAppLogging<CarsController> logging)
  {
    _serviceWrapper = serviceWrapper;
    _logging = logging;
  }
}

getnames 帮助器方法

GetMakes()助手方法为数据库中的所有Makes构建一个SelectList。它使用Id作为值,使用Name作为显示文本。

internal async Task<SelectList> GetMakesAsync()=>
  new SelectList(
    await _serviceWrapper.GetMakesAsync(),
    nameof(Make.Id),
    nameof(Make.Name));

获得一辆汽车的方法

GetOne()帮助器方法获得一个单独的Car记录:

internal async Task<Car> GetOneCarAsync(int? id)
  => !id.HasValue ? null : await _serviceWrapper.GetCarAsync(id.Value);

公共行动方法

这个控制器中的公共动作方法与CarsController的唯一区别是数据访问,所有的动作方法都是异步的。既然您已经了解了每个操作的用途,下面是其余的方法,其中的更改以粗体和/或带注释的形式突出显示:

[Route("/[controller]")]
[Route("/[controller]/[action]")]
public async Task<IActionResult> Index()
  => View(await _serviceWrapper.GetCarsAsync());

[HttpGet("{makeId}/{makeName}")]
public async Task<IActionResult> ByMake(int makeId, string makeName)
{
  ViewBag.MakeName = makeName;
  return View(await _serviceWrapper.GetCarsByMakeAsync(makeId));
}

[HttpGet("{id?}")]
public async Task<IActionResult> Details(int? id)
{
  if (!id.HasValue)
  {
    return BadRequest();
  }
  var car = await GetOneCarAsync(id);
  if (car == null)
  {
    return NotFound();
  }
  return View(car);
}

[HttpGet]
public async Task<IActionResult> Create()
{
  ViewData["MakeId"] = await GetMakesAsync();
  return View();
}

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Car car)
{
  if (ModelState.IsValid)
  {
    await _serviceWrapper.AddCarAsync(car);
    return RedirectToAction(nameof(Index));
  }
  ViewData["MakeId"] = await GetMakesAsync();
  return View(car);
}

[HttpGet("{id?}")]
public async Task<IActionResult> Edit(int? id)
{
  var car = await GetOneCarAsync(id);
  if (car == null)
  {
    return NotFound();
  }
  ViewData["MakeId"] = await GetMakesAsync();
  return View(car);
}

[HttpPost("{id}")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Car car)
{
  if (id != car.Id)
  {

    return BadRequest();
  }
  if (ModelState.IsValid)
  {
    await _serviceWrapper.UpdateCarAsync(id,car);
    return RedirectToAction(nameof(Index));
  }
  ViewData["MakeId"] = await GetMakesAsync();
  return View(car);
}

[HttpGet("{id?}")]
public async Task<IActionResult> Delete(int? id)
{
  var car = await GetOneCarAsync(id);
  if (car == null)
  {
    return NotFound();
  }
  return View(car);
}

[HttpPost("{id}")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id, Car car)
{
  await _serviceWrapper.DeleteCarAsync(id,car);
  return RedirectToAction(nameof(Index));
}

更新视图组件

MenuViewComponent目前使用的是数据访问层和Invoke的非同步版本。对该类进行以下更改:

using System.Linq;
using System.Threading.Tasks;
using AutoLot.Dal.Repos.Interfaces;
using AutoLot.Services.ApiWrapper;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ViewComponents;

namespace AutoLot.Mvc.ViewComponents
{

  public class MenuViewComponent : ViewComponent
  {
    private readonly IApiServiceWrapper _serviceWrapper;
    public MenuViewComponent(IApiServiceWrapper serviceWrapper)
    {
        _serviceWrapper = serviceWrapper;
    }

    public async Task<IViewComponentResult> InvokeAsync()
    {
      var makes = await _serviceWrapper.GetMakesAsync();
      if (makes == null)
      {
          return new ContentViewComponentResult("Unable to get the makes");
      }
      return View("MenuView", makes);
    }
  }
}

运行 AutoLot。Mvc 和 AutoLot。Api 在一起

AutoLot。Mvc 依赖于 AutoLot。要运行的 Api。这可以通过 Visual Studio、命令行或两者的组合来完成。

Note

记住两者都是自动的。Mvc 和 AutoLot。Api 被配置为在每次运行时重建数据库。确保至少关闭其中一个,否则它们会发生冲突。为了加快调试速度,在测试不会更改任何数据的功能时,请关闭这两个选项。

使用 Visual Studio

您可以将 Visual Studio 配置为同时运行多个项目。这是通过在解决方案资源管理器中右击该解决方案,选择“选择启动项目”,并设置 AutoLot 的操作来完成的。Api 和 AutoLot。Mvc 启动,如图 31-11 所示。

img/340876_10_En_31_Fig11_HTML.jpg

图 31-11。

在 Visual Studio 中设置多个启动项目

当您按 F5(或单击绿色运行箭头)时,两个项目都将启动。当你这样做的时候,有一些复杂的事情。首先,Visual Studio 会记住运行应用的最后一个配置文件。这意味着如果您使用 IIS Express 运行 AutoLot。Api,两者一起运行将运行 AutoLot。Api 使用 IIS Express,服务设置中的端口将会不正确。这很容易解决。在配置多个启动选项之前,更改appsettings.development.json文件中的端口或使用 Kestrel 运行应用。

第二个问题归结于时机。两个项目基本上同时开始。如果你有 AutoLot。Api 配置为在每次运行时重新创建数据库,它不会为自动装载做好准备。当执行ViewComponent来构建菜单时使用 Mvc。自动手枪的快速刷新。Mvc 浏览器(一旦你在 AutoLot 看到了 SwaggerUI。Api)将解决这个问题。

使用命令行

在每个项目目录中打开命令提示符,输入dotnet watch run。这允许你控制顺序和时间,也将确保应用使用 Kestrel 而不是 IIS 来执行。关于从命令行运行时的调试信息,请参考第二十九章。

摘要

这一章完成了我们对 ASP.NET 岩心的研究,完成了自动 Lot。Mvc 应用。它从深入研究视图、局部视图以及编辑器和显示模板开始。接下来是对标记助手的检查,将客户端标记与服务器端代码混合在一起。

下一组主题涵盖了客户端库,包括管理项目中的库以及捆绑和缩小。配置完成后,使用库的新路径更新布局,将布局分解成一组片段,并添加环境标记助手以进一步细化客户端库处理。

接下来是使用HTTPClientFactory和 ASP.NET 核心配置系统来创建与 AutoLot 通信的服务包装器。Api,它用于为动态菜单系统创建一个视图组件。在简要讨论了如何加载这两个应用(AutoLot。Api 和 AutoLot。Mvc)同时开发了应用的核心。

这一发展始于CarsController和所有动作方法的创建。然后,通过创建所有的Cars视图,添加并完成定制标记助手。当然,我们只构建了一个控制器及其视图,但是可以遵循该模式为所有的 AutoLot 实体提供控制器和视图。

第一部分:C# 和.NET 5 简介

第二部分:核心 C# 编程

第三部分: C# 面向对象编程

第四部分:高级 C# 编程

第五部分:.NETCore 组件

第六部分:文件处理、对象序列化和数据访问

第七部分:实体框架核心

第八部分:Windows 客户端开发

第九部分:ASP.NET 核心

posted @ 2024-08-10 19:01  绝不原创的飞龙  阅读(1)  评论(0编辑  收藏  举报