C-10-编程指南-六-

90 阅读1小时+

C#10 编程指南(六)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第十二章:程序集

到目前为止,在本书中,我使用术语组件来描述库或可执行文件。现在是时候更仔细地看看这究竟意味着什么了。在.NET 中,软件组件的部署单位称为程序集,通常是一个*.dll.exe*文件。由于每个类型不仅由其名称和命名空间标识,还由其包含的程序集标识,程序集对类型系统是一个重要的方面。由于internal可访问性限定符在程序集级别工作,程序集提供了一种比单个类型更大尺度的封装。

运行时提供了一个程序集加载器,它会自动查找并加载程序需要的程序集。为了确保加载器能够找到正确的组件,程序集有结构化的名称,包括版本信息,并且可以选择性地包含一个全局唯一的元素,以防止歧义。

Visual Studio 的“创建新项目”对话框中的大多数 C#项目类型以及通过dotnet new命令行可用的大多数项目模板都会生成一个单独的程序集作为它们的主要输出。当你构建一个项目时,通常也会将额外的文件放在输出文件夹中,例如任何你的代码依赖但未内置到.NET 运行时中的程序集的副本,以及应用程序需要的其他文件。(例如,网站项目通常需要生成 CSS 和脚本文件,除了服务器端代码。)但通常会有一个特定的程序集作为项目的构建目标,其中包含所有项目定义的类型以及这些类型包含的代码。

程序集的解剖学

程序集使用 Win32 可移植可执行文件(PE)格式,这与现代 Windows 版本中的可执行文件(EXE)和动态链接库(DLL)使用的格式相同。¹ 它的“可移植性”体现在同一基本文件格式在不同 CPU 架构上的使用。非.NET PE 文件通常是特定于架构的,但.NET 程序集通常不是。即使在 Linux 或 macOS 上运行.NET,它仍然使用这种基于 Windows 的格式——大多数.NET 程序集可以在所有支持的操作系统上运行,因此我们在所有地方都使用相同的文件格式。

C# 编译器生成的输出是一个扩展名为 .dll.exe 的程序集。理解 PE 文件格式的工具会将 .NET 程序集识别为有效但相对单调的 PE 文件。CLR 实质上将 PE 文件用作包含 .NET 特定数据格式的容器,所以对于经典的 Win32 工具来说,C# DLL 不会显示为导出任何 API。请记住,C# 编译为一种二进制中间语言(IL),这种语言不能直接执行。Windows 中用于加载和运行可执行文件或 DLL 的正常机制无法处理 IL,因为只有 CLR 能够运行它。类似地,.NET 定义了自己的格式来编码元数据,并不使用 PE 格式的原生能力来导出入口点或导入其他 DLL 的服务。

注意

.NET SDK 中的提前编译(AoT)工具可以在构建过程后期向你的程序集添加本机可执行代码,但是对于 Ready to Run 程序集(这些 AoT 工具的输出称为),即使是嵌入的本机代码也是在 CLR 控制下加载和执行的,并且只能由托管代码直接访问。

在大多数情况下,你不会创建扩展名为 .exe 的 .NET 程序集。即使是生成直接可运行输出的项目类型(例如控制台或 WPF 应用程序),它们的主要输出也是一个 .dll。它们也会生成一个可执行文件,但不是 .NET 程序集。它只是一个引导程序,启动运行时,然后加载和执行你的应用程序的主程序集。默认情况下,引导程序的类型取决于你构建的操作系统,例如在 Windows 上构建时,你将得到一个 Windows 的 .exe 引导程序,而在 Linux 上则会得到一个 ELF 格式的可执行文件。²(唯一的例外是当你针对 .NET Framework 时。由于它仅支持 Windows,因此不需要为不同操作系统生成不同的引导程序,因此这些项目将生成一个扩展名为 .exe 的 .NET 程序集,并包含引导程序。)

.NET 元数据

除了包含编译的 IL 外,程序集还包含 元数据,它提供了所定义的所有类型(无论是公共的还是私有的)的完整描述。CLR 需要完全了解代码使用的所有类型,以便能够理解 IL 并将其转换为可运行的代码——IL 的二进制格式经常引用包含程序集的元数据,并且没有元数据 IL 是无意义的。反射 API,即 第十三章 的主题,使元数据中的信息可用于你的代码。

资源

你可以在 DLL 中与代码和元数据一起嵌入二进制资源。例如,客户端应用程序可以这样做来处理位图。要嵌入文件,可以将其添加到项目中,在解决方案资源管理器中选择它,然后使用属性面板将其构建操作设置为嵌入的资源。这样会将整个文件的副本嵌入到组件中。在运行时提取资源时,可以使用 Assembly 类的 GetManifestResourceStream 方法,该方法是反射 API 的一部分,详见 第十三章。然而,在实践中,通常不会直接使用这个功能——大多数应用程序通过本地化机制使用嵌入资源,我将在本章后面描述。

因此,总结来说,一个程序集包含了描述其定义的所有类型的全面元数据集合;它保存了所有这些类型方法的 IL,并且可以选择性地嵌入任意数量的二进制流。这通常被打包成一个单独的 PE 文件。然而,这并不总是故事的全部。

多文件程序集

老式(但仍受支持的)仅限 Windows 的 .NET Framework 允许一个程序集跨多个文件。你可以将代码和元数据分割到多个 模块 中,并且还可以将逻辑上嵌入到程序集中的某些二进制流放置在单独的文件中。这个特性很少被使用,并且 .NET Core 及其后续版本(包括当前版本的 .NET)不支持它。然而,有必要了解它,因为一些后果仍然存在。特别是,反射 API 的设计的某些部分(见 第十三章)在不了解这个特性的情况下是没有意义的。

在多文件程序集中,总是有一个代表程序集的主文件。这将是一个 PE 文件,并包含元数据的一个特定元素,称为 程序集清单。这不应与大多数可执行文件包含的 Win32 风格清单混淆。程序集清单只是对程序集内容的描述,包括任何外部模块或其他外部文件的列表;在多模块程序集中,清单描述了哪些类型定义在哪些文件中。当编写直接使用程序集中类型的代码时,通常不需要关心它是否跨多个模块,因为运行时会检查清单并自动加载所需的模块。多模块通常只对使用反射检查组件结构的代码是一个问题。

其他 PE 特性

尽管 C# 不使用经典的 Win32 机制来表示代码或在 EXE 和 DLL 中导出 API,但程序集仍然可以使用 PE 格式的几个老式特性。

Win32 风格资源

.NET 定义了自己的二进制资源嵌入机制,并在此基础上构建了本地化 API,因此在大多数情况下,它不使用 PE 文件格式固有的嵌入资源支持。并没有什么阻止你将经典的 Win32 风格资源放入 .NET 组件中——C# 编译器提供了各种命令行开关来实现这一点。然而,从你的应用程序中运行时访问这些资源没有 .NET API,这就是为什么你通常会使用 .NET 自己的资源系统。但也有一些例外情况。

Windows 期望在可执行文件中找到某些资源。例如,它定义了一种将版本信息作为非托管资源嵌入的方法。C# 程序集通常会这样做,但你不需要显式定义版本资源。编译器可以为你生成一个,就像我在 “Version” 中展示的那样。这确保了如果最终用户在 Windows 文件资源管理器中查看你的程序集属性,他们将能够看到版本号。(按照惯例,.NET 程序集通常包含这种 Win32 风格的版本信息,无论它们是否仅针对 Windows 或可以在任何平台上运行。)

Windows .exe 文件通常包含两个额外的 Win32 资源。你可能希望为你的应用程序定义一个自定义图标,以控制它在任务栏或 Windows 文件资源管理器中的显示方式。这需要你以 Win32 的方式嵌入图标,因为文件资源管理器不知道如何提取 .NET 资源。你可以通过在你的 .csproj 文件中添加一个 <ApplicationIcon> 属性来实现这一点。如果你使用 Visual Studio,它提供了一种通过项目属性页来设置这一点的方式。另外,如果你正在编写经典的 Windows 桌面应用程序或控制台应用程序(无论是否使用 .NET 编写),它应该提供一个应用程序清单。没有这个清单,Windows 将假定你的应用程序是在 2006 年之前编写的³,并会修改或禁用某些向后兼容性功能。如果你正在编写一个桌面应用程序并且希望它通过某些 Microsoft 认证要求,那么这种清单也必须存在并作为 Win32 资源嵌入。默认情况下,.NET SDK 会添加一个具有默认设置的清单,但如果需要自定义(例如,因为你正在编写一个需要以提升权限运行的控制台应用程序),你可以在你的 .csproj 文件中指定一个具有 <ApplicationManifest> 属性的清单(或者再次通过 Visual Studio 的项目属性页)。

请记住,在.NET 和.NET Core 中,主要的程序集是一个*.dll*,即使是用于 Windows 桌面应用程序,当你目标是 Windows 时,构建过程也会生成一个单独的*.exe*文件,它启动.NET 运行时,然后加载该程序集。在 Windows 看来,这个引导程序就是你的应用程序,因此图标和清单资源将最终出现在这个引导程序集中。但如果你的目标是.NET Framework,就不会有单独的引导程序,因此这些资源最终会出现在主要的程序集中。

控制台与 GUI

Windows 对控制台应用程序和 Windows 应用程序进行了区分。准确地说,PE 格式要求*.exe文件指定一个子系统*,在 Windows NT 早期的日子里,这使得可以使用多个操作系统子系统,例如早期版本包括 POSIX 子系统。因此,如今的 PE 文件只针对三个子系统之一,其中之一用于内核模式设备驱动程序。今天使用的两个用户模式选项在 Windows 图形用户界面(GUI)和 Windows 控制台应用程序之间进行选择。主要区别在于当运行后者时 Windows 将显示控制台窗口(或者如果从命令提示符运行它,则只使用现有的控制台窗口),但 Windows GUI 应用程序不会获得控制台窗口。

你可以通过在项目文件中设置<OutputType>属性为ExeWinExe来在这些子系统之间进行选择,或者在 Visual Studio 中你可以在项目属性的“输出类型”下拉列表中进行选择。(输出类型默认为Library,或者在 Visual Studio 的 UI 中为“类库”。这会构建一个 DLL,但由于子系统是在进程启动时确定的,因此 DLL 是否目标是 Windows 控制台或 Windows GUI 子系统是没有区别的。Library设置总是针对前者。)如果你的目标是.NET Framework,这个子系统设置将应用于作为你的应用程序主要程序集构建的*.exe文件,而对于较新版本的.NET,则会应用于引导程序.exe*。(碰巧的是,它也会应用于引导程序加载的主程序集*.dll*,但这不会产生影响,因为子系统是根据启动进程的*.exe*确定的。)

类型标识

作为一名 C#开发者,你通常会首先接触到的是程序集(assemblies)构成类型标识的一部分这一事实。当你编写一个类时,它将成为一个程序集的一部分。当你使用来自运行时库或其他库的类型时,你的项目需要引用包含该类型的程序集才能使用它。

当使用系统类型时,这并不总是显而易见的。构建系统会自动添加对各种运行时库程序集的引用,因此大多数情况下,您在使用运行时库类型之前不需要添加引用,并且由于通常不会在源代码中明确引用类型的程序集,因此很难立即意识到程序集是确定类型所需的必要部分。但尽管在代码中并没有明确指定,程序集必须作为类型身份的一部分,因为没有任何东西会阻止您或其他任何人定义与现有类型同名的新类型。例如,您可以在项目中定义一个名为System.String的类。这是一个不好的主意,编译器会警告您这样会引入歧义,但不会阻止您这样做。尽管您的类将具有与内置字符串类型完全相同的完全限定名称,编译器和运行时仍然可以区分这些类型。

每当您使用一个类型时,无论是通过显式名称(例如在变量或参数声明中)还是通过表达式隐式使用,C#编译器都确切地知道您所引用的类型,这意味着它知道哪个程序集定义了该类型。因此,它能够区分.NET 内置的System.String和您自己组件中不明智定义的System.String。C#的作用域规则意味着对System.String的显式引用将标识出您在自己项目中定义的类型,因为局部类型有效地隐藏了外部程序集中同名的类型。如果使用string关键字,那总是指向内置类型。当您使用字符串字面量或调用返回字符串的 API 时,也将使用内置类型。示例 12-1 展示了这一点——它定义了自己的System.String,然后使用了一个显示传递给它的参数的静态类型和程序集名称的通用方法。(这使用了反射 API,详见第十三章。)

示例 12-1. 一个字符串是什么类型?
using System;

// Never do this!
namespace System
{
    public class String
    {
    }
}

class Program
{
    static void Main(string[] args)
    {
        System.String? s = null;
        ShowStaticTypeNameAndAssembly(s);
        string? s2 = null;
        ShowStaticTypeNameAndAssembly(s2);
        ShowStaticTypeNameAndAssembly("String literal");
        ShowStaticTypeNameAndAssembly(Environment.OSVersion.VersionString);
    }

    static void ShowStaticTypeNameAndAssembly<T>(T item)
    {
        Type t = typeof(T);
        Console.WriteLine(
            $"Type: {t.FullName}. Assembly {t.Assembly.FullName}.");
    }
}

在本示例中的Main方法尝试了我刚刚描述的每种处理字符串的方法,并写出了以下内容:

Type: System.String. Assembly TypeIdentity, Version=1.0.0.0, Culture=neutral,
 PublicKeyToken=null.
Type: System.String. Assembly System.Private.CoreLib, Version=6.0.0.0,
 Culture=neutral, PublicKeyToken=7cec85d7bea7798e.
Type: System.String. Assembly System.Private.CoreLib, Version=6.0.0.0,
 Culture=neutral, PublicKeyToken=7cec85d7bea7798e.
Type: System.String. Assembly System.Private.CoreLib, Version=6.0.0.0,
 Culture=neutral, PublicKeyToken=7cec85d7bea7798e.

明确使用 System.String 最终导致了我的类型,并且其余部分都使用了系统定义的字符串类型。这表明 C# 编译器能够处理具有相同名称的多个类型。这也显示了 IL 能够进行区分。IL 的二进制格式确保对类型的每个引用都标识了包含程序集。但仅仅因为你可以创建和使用多个同名类型,并不意味着你应该这样做。因为在 C# 中通常不会显式命名包含程序集,所以通过定义自己的 System.String 类来引入无意义的冲突是一个特别糟糕的主意。(恰好,在必要时你可以解决这种冲突——详见边栏 “外部别名” ——但最好避免这样做。)

顺便说一句,如果你在 .NET Framework 上运行 示例 12-1,你会看到 mscorlib 而不是 System.Private.CoreLib。.NET Core 改变了许多运行库类型所在的程序集。你可能会想知道这如何与 .NET Standard 兼容,它使你能够编写一个单独的 DLL,在 .NET Framework、.NET Core 和 .NET 上都可以运行。一个 .NET Standard 组件如何能正确识别在不同目标上位于不同程序集中的类型呢?答案是 .NET 具有一种类型转发特性,其中对一个程序集中类型的引用可以在运行时重定向到其他程序集中。(类型转发器只是描述真实类型定义所在位置的一个程序集级别属性。属性是 第十四章 的主题。).NET Standard 组件既不引用 mscorlib 也不引用 System.Private.CoreLib ——它们的构建就好像运行库类型定义在一个名为 netstandard 的程序集中一样。每个 .NET 运行时都提供了一个 netstandard 实现,在运行时将转发到相应的类型。事实上,即使是直接为 .NET Core 或 .NET 构建的代码,最终也会使用类型转发。如果你检查编译输出,你会发现它期望大多数运行库类型在名为 System.Runtime 的程序集中定义,并且仅通过类型转发才能使用 System.Private.CoreLib 中的类型。

如果在同一名称下有多个类型是个坏主意,那为什么 .NET 一开始就允许这种可能性呢?事实上,支持名称冲突并不是目标;这只是 .NET 将程序集作为类型的一部分的副作用。程序集需要成为类型定义的一部分,这样 CLR 在你首次使用该类型的功能时就能知道要加载哪个程序集。

加载程序集

当我说构建系统自动向目标框架添加所有可用的运行库组件的引用时,你可能会感到惊讶。也许你会想知道如何在效率名义上删除其中一些。就运行时开销而言,你无需担心。C# 编译器会有效地忽略你的项目从未使用过的内置程序集的任何引用,因此不会加载你不需要的 DLL 的风险。然而,删除对未构建为 .NET 的未使用组件的引用是值得的——这样在部署应用程序时可以避免复制不需要的 DLL,毕竟不必使部署变得比必要时更大。但对于已作为 .NET 的一部分安装的未使用 DLL 的引用则毫无成本。

即使在编译时 C# 没有剥离未使用的引用,也不会存在加载未使用的 DLL 的风险。CLR 不会在应用程序首次需要它们之前加载程序集。大多数应用程序在每次执行时并不会涵盖所有可能的代码路径,因此你的应用程序中有相当大部分的代码可能并不会运行。你的程序甚至可能在完成工作时留下整个未使用的类——也许这些类只在出现异常错误条件时才会参与。如果你只在这类方法内部使用某个程序集,那么这个程序集就不会被加载。

CLR 对于决定“使用”特定程序集有一些自主权。如果一个方法包含任何引用特定类型的代码(例如声明该类型的变量或包含隐式使用该类型的表达式),那么当该方法首次运行时 CLR 可能会认为该类型已被使用,即使你并未真正使用它。参见 示例 12-2。

示例 12-2. 类型加载和条件执行
static IComparer<string> GetComparer(bool useStandardOrdering)
{
    if (useStandardOrdering)
    {
        return StringComparer.CurrentCulture;
    }
    else
    {
        return new MyCustomComparer();
    }
}

根据其参数不同,此函数会返回运行库提供的StringComparer对象,或构造一个类型为MyCustom​Com⁠parer的新对象。StringComparer类型在与核心类型(如intstring)相同的程序集中定义,因此在我们的程序启动时已加载。但假设另一类型MyCustomComparer定义在与我的应用程序分离的名为ComparerLib的程序集中。显然,如果以false作为参数调用此GetComparer方法,CLR 将需要在未加载时加载ComparerLib。但更令人惊讶的是,即使参数为true,CLR 也可能在首次调用此方法时加载ComparerLib。为了能够 JIT 编译此GetComparer方法,CLR 需要访问MyCustomComparer类型定义,因为它需要检查该类型确实有一个无参数构造函数。(显然,如果如此,Example 12-2 将无法编译,但可能的情况是该代码针对的是与运行时不同版本的ComparerLib。)JIT 编译器的操作是实现细节,因此并未完全记录,且可能因版本而异,但似乎是逐个方法操作。因此,仅调用此方法很可能足以触发ComparerLib程序集的加载。

这引出了.NET 如何找到程序集的问题。如果程序集可以作为运行方法的结果隐式加载,我们未必有机会告诉运行时在哪里找到它们。因此,.NET 有一种机制来处理这个问题。

程序集解析

当运行时需要加载一个程序集时,它会经历一个称为程序集解析的过程。在某些情况下,你会告诉.NET 加载特定的程序集(例如,当你首次运行一个应用程序时),但大部分是隐式加载的。具体的机制取决于几个因素:你是否针对.NET/.NET Core 或旧版.NET Framework,以及如果是前者,你的应用程序是否是自包含的。

.NET(及其前身 .NET Core)支持应用程序的两种部署选项:自包含依赖于框架。当你发布一个自包含的应用程序时,它会包括运行时和运行时库的完整副本。示例 12-3 显示了以这种方式构建应用程序的命令行——如果你从包含 .csproj 文件的文件夹运行此命令,它将编译项目,然后生成一个 publish 文件夹,其中包含编译代码和适当版本 .NET 的完整副本。(版本将取决于项目配置的目标框架。通常,项目文件会指定主要和次要版本,例如 net6.0,然后 SDK 将复制安装在您的计算机上的最新修补版本。可用版本将由您安装的 .NET SDK 版本决定。)-r 开关指定要构建的平台和处理器架构。Linux 的 CLR 与 Windows 的不同,而 macOS 的则又不同。此外,对于每个支持的操作系统,都有适用于多个 CPU 架构的 .NET 运行时可用。所有三个操作系统都支持 64 位 Intel 和 64 位 ARM。Windows 和 Linux 还额外获取面向 32 位 Intel 架构 CPU 和 32 位 ARM CPU 的 .NET 运行时。包含本地可执行二进制代码的 .NET 运行时部分在每种情况下都不同,因此当您要求自包含部署时,构建系统需要知道要复制哪一个。-r 开关使用称为 Runtime Identifier(RID)的东西来指定这一点。示例 12-3 选择了适用于运行 Windows 的 64 位 Intel 架构 CPU 的运行时。(RID 可能更详细,以指示您的应用程序具有最低版本要求。例如,第一部分可以是 win10 而不仅仅是 win;对于 macOS,我们可以使用 osx-x64,但我们可以更具体,例如 osx.10.15-x64。)

示例 12-3. 发布自包含应用程序
dotnet publish -c Release -r win-x64 --self-contained true

当你以这种方式构建时,程序集解析变得非常简单,因为所有内容——你的应用程序自身的程序集、你依赖的任何外部库、所有内置于 .NET 中的系统程序集以及 CLR 本身——都会结束在一个文件夹中。(在撰写本文时,对于 .NET 6.0 的目标架构的一个简单的“Hello, World!”控制台应用程序,总计约为 68 MB 左右。)

自包含部署有两个主要优点。首先,在目标机器上不需要安装 .NET——应用程序可以直接运行,因为它包含自己的 .NET 副本。其次,您知道确切运行的 .NET 版本和所有 DLL 的版本。微软非常努力确保向后兼容性与新版本,但有时可能会发生破坏性更改,如果在更新 .NET 后发现应用程序停止工作,自包含部署可能是一个出路。通过自包含部署,除非应用程序指示 CLR 在其他地方查找,否则一切都将从应用程序文件夹加载,包括所有内置到 .NET 的程序集。

但是,如果您不想将整个 .NET 复制到生成的输出中呢?应用程序的默认构建行为是创建一个依赖框架的可执行文件。(有一个称为依赖框架部署的变体,几乎相同,只是省略了启动器可执行文件。要运行依赖框架部署,您需要使用dotnet命令行工具启动运行时,然后运行您的应用程序。这样做的好处是完全与平台无关;依赖框架可执行部署中的启动器始终特定于操作系统。但这样做不太方便——您不能在没有dotnet工具的情况下运行生成的输出。)在这种情况下,您的代码依赖于机器上已安装合适版本的 .NET。生成的输出将包含您自己的应用程序程序集,并可能包含您的应用程序依赖的程序集,但不会包含任何内置到 .NET 的库。

依赖框架应用程序必然使用比自包含应用程序更复杂的解析机制。当这样的应用程序启动时,它首先确定要运行的 .NET 版本。这不一定是您的应用程序构建的版本,并且有各种选项可以配置确切选择的版本。默认情况下,如果可用相同的*Major*.*Minor*版本,将使用该版本。例如,如果为 .NET Core 5.0 构建的依赖框架应用程序在安装了 .NET Core 版本为 3.1.205.0.116.0.0 的机器上运行,则会运行在 5.0.11 上。在找不到这样的匹配的情况下,但是有一个主要版本号匹配的情况下,通常会向前滚动到那个版本;例如,如果应用程序目标为 3.0,而机器上只有 3.1.20,则会在 3.1.20 上运行。也可以通过配置显式请求运行高于应用程序构建的主要版本号的更高版本号(例如,为 3.1 构建但在 6.0 上运行),但是只有通过配置显式请求才能做到这一点。

所选的运行时版本不仅选择 CLR,还包括构成内置于.NET 中的运行时库部分的程序集。你通常可以在 Windows 的C:\Program Files\dotnet\shared\Micro⁠soft.NET​Core.App*文件夹中找到所有已安装的运行时版本,在 macOS 上为/usr/local/share/dotnet/shared/Microsoft​.NET⁠Core.App*,在 Linux 上为*/usr/share/dotnet/shared/Microsoft.NETCore.App*,其子文件夹基于版本命名,比如6.0.0。(你不应依赖这些路径——文件在未来版本的.NET 中可能会移动。)程序集解析过程将查找这个特定版本的文件夹,这就是框架相关应用程序如何使用内置的.NET 程序集。

如果你在这些文件夹里查找,你可能会注意到shared下面的其他文件夹,比如Microsoft.AspNetCore.App。事实证明,这种机制不仅适用于内置于.NET 中的运行时库——也可以安装整个框架的程序集。.NET 应用程序声明它们使用特定的应用程序框架。(构建工具会自动在构建输出中生成一个名为YourApp.runtimeconfig.json的文件,声明你正在使用的框架。控制台应用程序指定Microsoft.NETCore.App,而 Web 应用程序则指定Microsoft.AspNetCore.App。)这使得针对特定 Microsoft 框架的应用程序不必包含所有框架 DLL 的完整副本,即使该框架并非.NET 本身的一部分。

如果你安装了纯粹的.NET 运行时,你将只会得到Microsoft.NETCore.App,而没有任何应用程序框架。因此,如果以默认方式构建的应用程序目标框架是如 ASP.NET Core 或 WPF 等,它们将无法运行,因为这假定这些框架将预安装在目标机器上,而程序集解析过程将无法找到特定框架组件。.NET SDK 会安装这些额外的框架组件,因此在开发机器上你不会遇到这个问题,但在部署时可能会遇到。你可以告诉构建工具包含框架的组件,但这通常是不必要的。如果在公共云服务(如 Azure)上运行你的应用程序,这些服务通常会预安装相关的框架组件,因此实际上你通常只会在自己配置服务器或部署桌面应用程序时遇到这种情况。对于这些情况,微软提供了包含 Web 或桌面框架组件的.NET 运行时安装程序。

dotnet 安装文件夹中的shared 文件夹不是你应该自行修改的目录。它仅用于微软自己的框架。但是,如果你愿意,可以安装额外的系统范围组件,因为.NET 还支持称为运行时包存储的东西。这是一个额外的目录,结构与刚才描述的shared 文件夹类似。你可以使用 dotnet store 命令构建一个合适的目录布局,如果设置了 DOTNET_SHARED_STORE 环境变量,CLR 将在程序集解析时查找其中的内容。这使你能够像使用微软的框架一样进行操作:你可以构建依赖于一组组件的应用程序,而无需将它们包含在构建输出中,只要你已经安排在目标机器上预安装这些组件。

除了在这两个位置查找常见的框架之外,CLR 在程序自己的目录中进行程序集解析时,也会进行查找,就像对于自包含应用程序一样。此外,CLR 还有一些机制可以进行更新的启用。例如,在 Windows 上,微软可以通过 Windows 更新向 .NET 组件推送关键更新。

但总体来说,面向框架依赖型应用程序的程序集解析的基本过程是,隐式程序集加载会从你的应用程序目录或安装在机器上的共享组件集合中进行。这对于运行在较旧的 .NET Framework 上的应用程序也是适用的,尽管机制略有不同。它有一种称为全局程序集缓存(GAC)的东西,它有效地结合了.NET 中两个共享存储提供的功能。这种方式不太灵活,因为存储位置是固定的;.NET 使用环境变量开放了为不同应用程序提供不同共享存储的可能性。

显式加载

尽管 CLR 可以自动加载程序集,你也可以显式加载它们。例如,如果你正在创建一个支持插件的应用程序,在开发过程中你可能不知道在运行时会加载哪些组件。插件系统的整个重点在于它是可扩展的,因此你可能希望加载特定文件夹中的所有 DLL 文件。(你需要使用反射来发现和利用这些 DLL 中的类型,正如第十三章所描述的。)

警告

在某些场景下,动态加载受到限制。例如,使用 UWP 构建的应用程序并从微软商店安装的应用程序只能运行作为应用程序一部分提供的组件。这是因为微软对这些商店应用程序运行各种测试,旨在避免安全和稳定性问题,为此他们需要访问你应用程序的所有代码。下载和运行外部代码的能力将会打破这些检查。

如果您知道程序集的完整路径,加载它非常简单:您可以调用Assembly类的静态LoadFrom方法,并传递文件的路径。路径可以是相对于当前目录的,也可以是绝对的。这个静态方法返回Assembly类的一个实例,它是反射 API 的一部分。它提供了发现和使用程序集定义的类型的方式。

偶尔,您可能希望显式加载一个组件(例如,通过反射使用它),而不想指定路径。例如,您可能希望从运行时库中加载特定的程序集。永远不要硬编码系统组件的位置 —— 它们往往会从一个 .NET 版本移动到下一个版本。如果您的项目引用了相关程序集并且知道它定义的类型的名称,您可以写typeof(TheType).Assembly。但如果这不是一个选择,您应该使用Assembly.Load方法,并传递程序集的名称。

Assembly.Load使用的是与隐式触发加载完全相同的机制。因此,您可以引用安装在应用程序旁边的组件或系统组件。无论哪种情况,您都应该指定一个完整的名称,其中必须包含名称和版本信息,例如,ComparerLib, Version=1.0.0.0, Cul⁠ture=neutral, PublicKeyToken=null

.NET Framework 版本的 CLR 记得使用LoadFrom加载了哪些程序集。如果以这种方式加载的程序集触发了隐式加载其他程序集,CLR 将搜索从该程序集加载的位置。这意味着,如果您的应用程序将插件放在一个 CLR 通常不会查找的单独文件夹中,那么这些插件可以在同一插件文件夹中安装它们依赖的其他组件。CLR 将能够在没有进一步调用LoadFrom的情况下找到它们,尽管它通常不会在那个文件夹中查找触发的加载。然而,.NET 和 .NET Core 不支持这种行为。它们提供了支持插件场景的不同机制。

使用 AssemblyLoadContext 进行隔离和插件化

.NET Core 引入了一种称为AssemblyLoadContext的类型。它允许在单个应用程序内部的程序集组之间实现一定程度的隔离。⁴ 这解决了在支持插件模型的应用程序中可能出现的问题。

如果一个插件依赖于主机应用程序也使用的某些组件,但每个组件想要不同的版本,如果使用上一节描述的简单机制可能会导致问题。通常,.NET 运行时会统一这些引用,只加载单个版本。在这种情况下,如果共享组件中的类型是插件接口的一部分,这正是你需要的:如果一个应用程序要求插件实现某些依赖于Newtonsoft.Json库的接口类型,重要的是应用程序和插件都同意正在使用的库的版本。

但是,统一可能会对用作实现细节而不是应用程序与其插件之间 API 的组件造成问题。如果主机应用程序在内部使用Microsoft.Extensions.Logging的 v3.1 版本,而插件使用相同组件的 v6.0 版本,没有必要在运行时将其统一为单个版本选择——如果应用程序和插件各自使用它们需要的版本,这不会有任何问题。统一可能会引起问题:强制插件使用 v3.1 将导致运行时异常,如果它尝试使用只存在于 v6.0 中的功能。强制应用程序使用 v6.0 也可能会导致问题,因为主要版本号更改通常意味着引入了破坏性更改。

为了避免这类问题,你可以引入自定义的程序集加载上下文。你可以编写一个从AssemblyLoadContext派生的类,每个实例化的上下文都由.NET 运行时创建,支持加载与应用程序可能已加载的不同版本的程序集。通过重载Load方法,你可以定义所需的确切策略,就像示例 12-4 中展示的那样。

示例 12-4. 一个用于插件的自定义AssemblyLoadContext
using System.Reflection;
using System.Runtime.Loader;

namespace HostApp;

public class PlugInLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;
    private readonly ICollection<string> _plugInApiAssemblyNames;

    public PlugInLoadContext(
        string pluginPath,
        ICollection<string> plugInApiAssemblies)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
        _plugInApiAssemblyNames = plugInApiAssemblies;
    }

    protected override Assembly Load(AssemblyName assemblyName)
    {
        if (!_plugInApiAssemblyNames.Contains(assemblyName.Name!))
        {
            string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
            if (assemblyPath != null)
            {
                return LoadFromAssemblyPath(assemblyPath);
            }
        }

        return AssemblyLoadContext.Default.LoadFromAssemblyName(
            assemblyName);
    }
}

这段代码需要插件 DLL 的位置,以及任何特殊程序集的名称列表,插件需要与主机应用程序使用相同版本的程序集(例如定义插件接口中使用的类型的接口)。您不需要包含作为.NET 本身一部分的程序集——即使您使用自定义加载上下文,这些程序集也始终是统一的。运行时将在每次在此上下文中加载程序集时调用此类的Load方法。此代码检查正在加载的程序集是否是那些必须对插件和主机应用程序通用的特殊程序集之一。如果不是,则在插件文件夹中查找插件是否提供了该程序集的自己版本。在不使用插件文件夹中的程序集的情况下(要么因为插件没有提供特定的程序集,要么因为它是特殊的程序集之一),此上下文将延迟至AssemblyLoadContext.Default,这意味着应用程序主机和插件在这些情况下使用相同的程序集。示例 12-5 展示了此用法。

示例 12-5. 使用插件加载上下文
Assembly[] plugInApiAssemblies =
{
    typeof(IPlugIn).Assembly,
    typeof(JsonReader).Assembly
};
var plugInAssemblyNames = new HashSet<string>(
    plugInApiAssemblies.Select(a => a.GetName().Name!));

var ctx = new PlugInLoadContext(plugInDllPath, plugInAssemblyNames);
Assembly plugInAssembly = ctx.LoadFromAssemblyPath(plugInDllPath);

此处构建了插件和应用程序必须共享的程序集列表,并将它们的名称与插件 DLL 的路径一起传递到插件上下文中。任何插件依赖的 DLL 并且被复制到与插件相同的文件夹中的 DLL 将被加载,除非它们在该列表中,在这种情况下,插件将使用与主机应用程序本身相同的程序集。

程序集名称

程序集名称是结构化的。它们总是包含一个简单名称,这是您通常用来引用 DLL 的名称,例如MyLibrarySystem.Runtime。这通常与文件名相同,但没有扩展名。从技术上讲,这并不是必须的,⁵,但是程序集解析机制假定它是。程序集名称始终包括版本号。还有一些可选组件,包括公钥标记,一个十六进制数字字符串,使得可以为程序集指定唯一名称。

强名称

如果程序集的名称包含公钥标记,则称为强名称。Microsoft 建议任何针对.NET Framework 并发布供共享使用的.NET 组件(例如通过 NuGet 提供)应具有强名称。但是,如果您正在编写仅在.NET Core 或.NET 上运行的新组件,则强命名没有任何好处,因为这些较新的运行时基本上忽略公钥标记。

由于强命名的目的是使名称唯一,您可能会想知道为什么程序集不简单地使用全局唯一标识符(GUID)。答案是,从历史上看,强名称还承担了另一个任务:它们旨在提供一定程度的保证,即程序集未被篡改。早期的.NET 版本在运行时检查强命名程序集是否被篡改,但由于这些检查带来了相当大的运行时开销,通常几乎没有好处,因此这些检查已被移除。微软的文档现在明确建议不将强名称视为安全功能。但是,为了理解和使用强名称,您需要了解它们最初的工作方式。

正如术语所示,程序集名称的公钥标记与密码学有关。它是公钥的 64 位哈希的十六进制表示。要求强名称的程序集包含生成哈希的完整公钥的副本。程序集文件格式还提供了用相应私钥生成的数字签名空间。

强名称的唯一性依赖于密钥生成系统使用密码学安全的随机数生成器,以及两个人生成具有相同公钥标记的两个密钥对的机会非常小。程序集未被篡改的保证来自于强命名程序集必须被签名,只有拥有私钥的人才能生成有效的签名。任何在签名后修改程序集的尝试都将使签名无效。

注意

强名称相关联的签名与 Windows 中较早的代码签名机制 Authenticode 独立。它们具有不同的目的。Authenticode 提供可追溯性,因为公钥包含在证书中,告诉您代码的来源。而强名称的公钥标记仅仅是一个数字,所以除非您碰巧知道谁拥有该标记,否则它不会告诉您任何信息。Authenticode 允许您询问:“这个组件来自哪里?”而公钥标记允许您说:“这就是我想要的组件。”一个单独的.NET 组件通常同时使用这两种机制。

如果一个程序集的私钥变为公共知识,任何人都可以生成具有相应密钥标记的看似有效的程序集。一些开源项目故意发布两个密钥,以便任何人都可以从源代码构建组件。这完全放弃了密钥标记可能提供的任何安全性,但这没关系,因为微软现在建议我们不把强名称视为安全功能。发布你的强命名私钥的做法认识到,即使没有真实性的保证,拥有一个唯一的名称也是有用的。.NET Core(因此.NET)更进一步,使组件可以拥有强名称而无需使用私钥。为了与微软采用开源开发的做法一致,这意味着你现在可以构建和使用具有相同强名称的自己版本的微软编写的组件,尽管微软并未发布其私钥。请参阅下一个侧边栏,"强名称密钥和公共签名",了解如何处理密钥的信息。

微软在运行时库中的大多数程序集上使用相同的标记。(微软的许多组织生产.NET 组件,因此该标记仅用于.NET 的组件,而不适用于整个微软。)这里是mscorlib的完整名称,这是一个系统程序集,提供了诸如System.String等各种核心类型的定义:

mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089

顺便说一下,在撰写本文时,这是即使在最新的.NET 版本中也是正确的名称。Version4.0.0.0,即使.NET Framework 现在是 v4.8,.NET 是 6.0 版。(在.NET 和.NET Core 中,mscorlib只包含类型转发器,因为相关类型大多数已移至System.Private.CoreLib。虽然这些类型的真正家园现在是版本6.0.0.0,但mscorlib的版本号仍然相同。)程序集版本号具有技术上的重要意义,因此微软并不总是随着市场版本号更新库组件名称中的版本号 —— 这些版本号甚至在主要版本号上也不一定匹配。例如,.NET 3.5 版的mscorlib版本号为2.0.0.0

尽管公共密钥令牌是程序集名称的可选部分,版本是强制的。

版本

所有的程序集名称都包含一个四部分的版本号。当一个程序集名称被表示为一个字符串(例如,当你将其作为参数传递给Assembly.Load时),版本号由四个用点分隔的十进制整数组成(例如,4.0.0.0)。IL 用于程序集名称和引用的二进制格式限制了这些数字的范围 —— 每个部分必须适合于一个 16 位无符号整数(即ushort),而版本部分中允许的最大值实际上比适合的最大值小 1,因此最高合法版本号是65534.65534.65534.65534

每个版本号的四个部分都有名称。从左到右,它们是主版本次版本构建修订。然而,这些名称没有特定的意义。一些开发人员使用某些约定,但没有任何检查或强制执行。一个常见的约定是,公共 API 的任何更改都需要更改主版本或次版本号,而可能会破坏现有代码的更改应该涉及主版本号的更改。(市场营销是进行主版本更改的另一个流行原因。)如果更新不打算对行为进行任何可见更改(除非是修复 bug),则更改构建号就足够了。修订号可以用来区分你认为是针对同一源构建但不是同时构建的两个组件。另外,有些人将版本号与源代码控制中的分支联系起来,因此仅更改修订号可能表示对长期停止获得主要更新的版本应用的修补程序。然而,你可以自由地制定自己的含义。就 CLR 而言,你真正能做的有趣事情只有一个,那就是将其与其他版本号进行比较——它们要么匹配,要么其中一个比另一个更高。

注意

NuGet 包也有版本号,并且这些版本号不需要以任何方式与程序集版本连接。许多包作者按照约定使它们相似,但这并非普遍规则。NuGet 确实 将包版本号的组件视为具有特定意义:它采用了广泛使用的语义化版本规则。这种规则使用由三个部分组成的版本号,分别称为主版本、次版本和修订版本。

运行时库程序集名称中的版本号忽略了我刚才描述的所有约定。在四个主要更新中,大多数组件的版本号都是相同的(2.0.0.0)。随着 .NET 4.0 的推出,所有东西都变成了4.0.0.0,这在撰写本文时仍然是 .NET Framework(4.8 版本)的最新版本。.NET Core 3.1 也使用 4 作为其大多数运行时库组件的主版本。在 .NET 6.0 中,许多这些组件现在的主版本号都是 6,但正如你在它的 mscorlib 副本中看到的那样,并非普遍适用。

通常,你通过在*.csproj*文件的<PropertyGroup>中添加一个<Version>元素来指定版本号。(Visual Studio 也为此提供了一个 UI:如果你打开项目的属性页面,其“Package”部分允许你配置各种与命名相关的设置。“Package version”字段设置版本号。)构建系统以两种方式使用这个版本号:它在程序集上设置版本号,但如果你为项目生成 NuGet 包,则默认情况下也会将此相同的版本号用于包,并且由于 NuGet 版本号有三部分,你通常只在这里指定三个数字,程序集版本的第四部分将默认为零。(如果你真的想指定所有四位数,请参阅如何分别设置程序集和 NuGet 版本的文档。)

通过程序集级别的属性,构建系统告诉编译器在程序集名称中使用哪个版本号。我将在第十四章更详细地描述属性,但这个属性相当直接。如果你想找到它,构建系统通常会在项目的obj文件夹的子文件夹中生成一个名为ProjectName.AssemblyInfo.cs的文件。其中包含各种描述程序集详细信息的属性,包括一个AssemblyVersion属性,例如在示例 12-6 中所示。

示例 12-6. 指定程序集的版本
[assembly: System.Reflection.AssemblyVersion("1.0.0.0")]

C#编译器对此属性提供特殊处理——它不像大多数属性那样盲目应用它。它解析版本号并将其嵌入到.NET 元数据格式所需的方式中。它还检查字符串是否符合预期格式,并检查数字是否在允许的范围内。

顺便说一下,组成程序集名称一部分的版本与使用标准的 Win32 机制嵌入版本存储的版本是不同的。大多数 .NET 文件包含这两种版本。默认情况下,构建系统将使用 <Version> 设置两者,但文件版本更频繁地更改是很常见的。这在 .NET Framework 中特别重要,因为一次只能安装一个主要版本的 .NET Framework —— 如果一台计算机安装了 .NET Framework 4.7.2,你安装了 .NET Framework 4.8,那么将替换版本 4.7.2。(.NET 和 .NET Core 不会这样做 —— 你可以在单台计算机上并排安装任意数量的版本。)这种原地更新与微软倾向于在各版本中保持程序集版本相同结合在一起,可能会使确定安装了哪个版本变得困难,此时文件版本变得重要。在安装了 .NET Framework 4.0 sp1 的计算机上,其 mscorlib.dll 的 Win32 版本号是 4.0.30319.239,但如果安装了 .NET 4.8,这将变为 4.8.4420.0,但程序集版本仍保持为 4.0.0.0。(随着发布服务包和其他更新,最后一部分将不断上升。)

默认情况下,构建系统将同时使用 <Version> 设置程序集和 Windows 文件版本,但如果你想分别设置文件版本,可以在项目文件中添加 <FileVersion>。(Visual Studio 的项目属性包部分也允许你设置这个。)在内部,这与另一个属性一起工作,编译器会特别处理,AssemblyFileVersion。它会导致编译器在文件中嵌入一个 Win32 版本资源,因此这是用户在 Windows Explorer 中右键单击程序集时看到的版本号。

这个文件版本通常是放置标识构建来源的版本号的更合适的地方,而不是放置在程序集名称中的版本号。后者实际上是支持的 API 版本的声明,任何设计为完全向后兼容的更新可能应该保持不变,只改变文件版本。

版本号和程序集加载

由于版本号是程序集名称(因此是其标识)的一部分,它们也最终是类型标识的一部分。mscorlibSystem.String 在版本 2.0.0.0 的不是与版本 4.0.0.0 的相同类型。

处理程序集版本号在.NET Core 中有所改变。在.NET Framework 中,当你通过名称加载一个强命名程序集(无论是隐式地使用它定义的类型还是显式地使用Assembly.Load),CLR 要求版本号必须完全匹配。⁶ .NET Core 对此进行了放宽,因此如果磁盘上的版本号等于或高于请求的版本号,它将使用它。这一变化背后有两个因素。首先,.NET 开发生态系统已经开始依赖 NuGet(在.NET 存在的第一个十年中甚至不存在),这意味着依赖于大量外部组件变得越来越普遍。其次,变化的速度增加了——在早期,我们通常需要等待多年才能看到.NET 组件的新版本发布。(安全补丁和其他错误修复可能会更频繁地出现,但新功能往往会缓慢地以大块的形式作为整个运行时、框架和开发工具更新的一部分出现。)但如今,一个应用程序在一个月内没有某个组件的版本发生变化是很少见的。.NET Framework 的严格版本策略现在看起来并不实用。(事实上,构建系统的某些部分专门负责浏览您的 NuGet 依赖项,计算出您正在使用的每个组件的具体版本,并自动生成包含大量版本替换规则的配置文件,告诉 CLR 使用这些版本,而不管任何单个程序集说它想要的版本是什么。因此,即使您面向.NET Framework,构建系统默认情况下也会有效地禁用严格版本控制。)

另一个变化是,.NET Framework 仅对强命名程序集考虑程序集版本。.NET Core 和.NET 检查磁盘上程序集的版本号是否等于或大于所需版本,而不管目标程序集是否强命名。

文化

到目前为止,我们已经看到程序集名称包括简单名称、版本号,以及可选的公钥标记。它们还有一个文化组件。(文化代表语言和一组约定,如货币、拼写变体和日期格式。)这不是可选的,尽管最常见的值是默认值:neutral,表示程序集不包含特定于文化的代码或数据。程序集的文化通常仅在包含特定于文化资源的程序集上设置为其他值。程序集名称的文化旨在支持资源(如图像和字符串)的本地化。为了说明这一点,我需要解释使用它的本地化机制。

所有程序集都可以包含嵌入的二进制流。(当然,你也可以在这些流中放置文本。你只需选择合适的编码。)反射 API 中的 Assembly 类提供了直接处理这些流的方法,但更常见的是使用 System.Resources 命名空间中的 ResourceManager 类。这比直接处理原始二进制流方便得多,因为 ResourceManager 定义了一个容器格式,允许单个流容纳任意数量的字符串、图像、声音文件和其他二进制项目,并且 Visual Studio 内置了编辑器以处理这种容器格式。我在本节中提到这一切的原因是,ResourceManager 还提供本地化支持,而程序集名称的文化特性是其机制的一部分。为了演示这是如何工作的,我将通过一个快速示例来向你展示。

使用 ResourceManager 的最简单方式是向项目中添加一个 .resx 格式的资源文件。(这不是运行时使用的格式。它是一种 XML 格式,会被编译成 ResourceManager 所需的二进制格式。在大多数源代码控制系统中,使用文本比使用二进制更容易。如果你的编辑器不支持该格式,则也可以使用这些文件。)要从 Visual Studio 的“添加新项”对话框中添加一个此类文件,请选择“Visual C#→通用”类别,然后选择“资源文件”。我将其命名为 MyResources.resx。Visual Studio 将显示其资源编辑器,以字符串编辑模式打开,正如图 12-1 所示。你可以看到,我定义了一个名为 ColString 的字符串,其值为 Color

字符串模式下的资源文件编辑器

图 12-1. 字符串模式下的资源文件编辑器

我可以在运行时检索此值。构建系统为你添加的每个 .resx 文件生成一个包装类,每个定义的资源都有一个静态属性。这使得查找字符串资源变得非常简单,正如示例 12-7 所示。

示例 12-7. 使用包装类检索资源
string colText = MyResources.ColString;

包装类隐藏了细节,通常这很方便。但在这种情况下,细节是我演示资源文件的全部原因,因此我展示了如何直接使用 ResourceManager,如示例 12-8 所示。我包含了整个文件的源代码,因为命名空间在这里很重要——构建工具会在嵌入资源流的名称前加上项目的默认命名空间,所以我要求使用 ResourceExample.MyResources 而不仅仅是 MyResources。(如果我将资源放在子文件夹中,工具也会在资源流名称中包含该文件夹的名称。)

示例 12-8. 在运行时检索资源
using System.Resources;

namespace ResourceExample;

class Program
{
    static void Main(string[] args)
    {
        `var` `rm` `=` `new` `ResourceManager``(`
            `"ResourceExample.MyResources"``,` `typeof``(``Program``)``.``Assembly``)``;`
        `string` `colText` `=` `rm``.``GetString``(``"ColString"``)``!``;`
        Console.WriteLine("And now in " + colText);
    }
}

到目前为止,这只是一个比较冗长的方法来获取字符串"Color"。然而,既然我们牵涉到了ResourceManager,我可以定义一些本地化资源了。作为英国人,我对拼写单词color的正确方式有强烈的看法。它们与奥莱利的编辑政策不一致,无论如何,我乐意为我的主要读者群(主要是美国人)适应我的工作。但一个程序可以做得更好——它应该能够为不同的受众提供不同的拼写方式。(更进一步,它应该能够在某些英语不是主要语言的国家完全改变语言。)事实上,我的程序已经包含了支持单词color本地化拼写所需的所有代码。我只需提供替代文本即可。

我可以通过添加一个精心选择的第二个资源文件来完成这个任务:MyResources.en-GB.resx。这个名字几乎与原始文件名相同,只是在*.resx扩展名之前多了一个.en-GB*。这个简称代表英国英语,虽然在政治上有点不敏感,但却是我所在地文化的标准化名称。(用于美国英语的文化名称是en-US。)在项目中添加了这样一个文件之后,我可以像之前那样添加一个名为ColString的字符串条目,但这次的值是正确的(我所在位置的值⁷),即Colour。如果你在配置了英国区域设置的计算机上运行应用程序,它将使用英国拼写。你的计算机很可能没有配置这个区域设置,所以如果你想尝试这个功能,你可以在示例 12-8 中的Main方法的开头添加示例 12-9 中的代码,强制.NET 在查找资源时使用英国文化。

示例 12-9. 强制使用非默认文化
Thread.CurrentThread.CurrentUICulture =
    new System.Globalization.CultureInfo("en-GB");

这与程序集有什么关系?如果你查看编译输出,你会看到除了通常的可执行文件和相关的调试文件外,构建过程还创建了一个名为en-GB的子目录,其中包含一个名为ResourceExample.resources.dll的程序集文件。(ResourceExample是我的项目名称。如果你创建了一个名为SomethingElse的项目,你会看到SomethingElse.resources.dll。)该程序集的名称看起来像这样:

ResourceExample.resources, Version=1.0.0.0, Culture=en-GB, PublicKeyToken=null

版本号和公钥标记将与主项目相匹配——在我的示例中,我保留了默认版本号,并且没有为我的程序集命名。但请注意Culture。我没有使用通常的neutral值,而是使用了en-GB,这与我添加的第二个资源文件的文件名中指定的文化字符串相同。如果你添加了更多带有其他文化名称的资源文件,你将会得到一个包含每种指定文化的专用程序集的文件夹。这些被称为卫星资源程序集

当您首次请求ResourceManager的资源时,它将查找一个具有与线程当前 UI 文化相同文化的卫星资源装配体。因此,它会尝试使用几段前显示的名称加载一个装配体。如果找不到,它会尝试一个更通用的文化名称——如果找不到en-GB资源,它将尝试寻找一个名为en的文化,表示没有指定特定地区的英语语言。只有当两者都找不到(或者找到匹配的装配体,但它们不包含所查找的资源)时,它才会退回到主装配体中的中性资源。

当指定非中性文化时,CLR 的装配体加载器会查找不同的位置。它会查找一个以该文化命名的子目录。这就是为什么构建过程将我的卫星资源装配体放置在一个en-GB文件夹中的原因。

为特定文化搜索资源会产生一些运行时成本。这些成本并不大,但如果您正在编写一个永远不会本地化的应用程序,您可能希望避免为您不使用的功能付出代价。然而,您可能仍希望使用ResourceManager——它比直接使用装配体清单资源流更方便地嵌入资源。避免这些成本的方法是告诉.NET,直接构建到您的主装配体中的资源适合特定文化。您可以使用示例 12-10 中显示的装配体级属性来实现这一点。

示例 12-10. 指定内置资源的文化
[assembly: NeutralResourcesLanguage("en-US")]

当带有该属性的应用程序在通常的美国区域设置的计算机上运行时,ResourceManager将不会尝试搜索资源。它将直接寻找编译到您的主装配体中的资源。

保护

在第三章中,我描述了可以应用于类型及其成员的一些可访问性限定符,例如privatepublic。在第六章中,我展示了在使用继承时可用的一些附加机制。快速回顾这些功能是值得的,因为装配体发挥了作用。

同样在第三章,我介绍了internal关键字,并说具有此可访问性的类和方法仅在组件内部可用,这是一个稍微模糊的术语,因为我还没有介绍装配体。现在已经清楚了什么是装配体,我可以安全地说internal关键字的更精确描述是指示成员或类型仅对同一装配体中的代码可访问。⁸ 同样,protected internal成员可供派生类型的代码访问,也可供同一装配体中定义的代码访问,而protected private保护级别更为严格,使成员仅对位于同一装配体中定义的派生类型中的代码可用。

目标框架和 .NET Standard

在构建每个程序集时,您需要做出的一个决定是选择目标框架或框架。每个 .csproj 文件都会有一个 <TargetFramework> 元素指示目标,或者一个包含框架列表的 <TargetFrameworks> 元素。特定的目标由 目标框架代号(TFM)指示。例如,netcoreapp3.1 标识 .NET Core 3.1,然后随着 .NET 5.0 命名规范的变化,我们有 net5.0net6.0 分别用于 .NET 5.0 和 .NET 6.0。对于 .NET Framework 4.6.2、4.7.2 和 4.8,TFMs 分别是 net462net472net48。当列出多个目标框架时,在构建时将会得到多个程序集,每个程序集位于以 TFM 命名的自己的子文件夹中。SDK 会有效地多次构建项目。

如果您需要为每个目标平台提供不同的代码(也许是因为只能在更新的目标版本上实现某些功能),您可能需要使用条件编译(在 “编译符号” 中描述)。但在同一份代码适用于所有目标的情况下,将代码构建到单个目标,如 .NET Standard,可能是有意义的。正如我在 第一章 中描述的那样,各个版本的 .NET Standard 定义了跨多个 .NET 版本可用的 .NET 运行时库的共同子集。我说过,如果您需要同时面向 .NET(或 .NET Core)和 .NET Framework,今天的最佳选择通常是 .NET Standard 2.0(其 TFM 是 netstandard2.0)。然而,了解其他选项也是值得的,特别是如果您希望将组件提供给尽可能广泛的受众。

在 NuGet 上发布的 .NET 库可能会决定以它们能够支持的最低 .NET Standard 版本为目标,以确保最广泛的覆盖范围。从版本 1.1 到 1.6,逐步增加了更多功能,作为支持更小范围目标的交换条件。(例如,如果你想在 .NET Framework 上使用一个 .NET Standard 1.3 组件,需要 .NET Framework 4.6 或更高版本;而目标为 .NET Standard 1.4 需要 .NET Framework 4.61 或更高版本。).NET Standard 2.0 是一个更大的进步,标志着 .NET Standard 进化的重要里程碑:根据微软目前的计划,这将是能在 .NET Framework 上运行的最高版本号。从 .NET Framework 4.7.2 开始的版本完全支持它,但 .NET Standard 2.1 将不会在任何现在或未来的 .NET Framework 版本上运行。它将在 .NET Core 3.0、3.1 以及 .NET 5.0 及以后版本上运行。Mono v6.4 及更高版本也支持它。但这是经典 .NET Framework 的终结。实际上,目前 .NET Standard 2.0 是组件作者当前流行的选择,因为它使组件能够在所有最近发布的 .NET 版本上运行,同时提供广泛的功能集。

所有这些都导致了一定程度的混乱,你可能会高兴地知道,.NET 6.0 带来的统一简化了事情。如果你不需要支持 .NET Framework,可以直接目标 .NET 6.0,无需考虑 .NET Standard。Mono 可以运行针对 .NET 6.0 的组件,而 .NET NativeAot 也计划支持它,因此目标 .NET 6.0 将覆盖大多数运行时。

对于 C# 开发人员,这一切意味着什么?如果你正在编写的代码永远不会在特定项目之外使用,通常会直接目标最新版本的 .NET,除非你需要一些它不提供的特定于 Windows 的功能,此时你可能会目标 .NET Framework。无论哪种方式,你都可以使用任何目标 .NET Standard 的 NuGet 包,包括 v2.0(这意味着 NuGet 上的绝大多数内容对你都是可用的)。

如果您正在编写计划共享的库,并且希望您的组件可以提供给尽可能广泛的受众,那么您应该针对 .NET Standard 进行开发,除非您绝对需要某个特定运行时中独有的功能。.NET Standard 2.0 是一个合理的选择——通过降低版本,您可以扩大库的受众范围,但是今天,支持 .NET Standard 2.0 的 .NET 版本已经广泛可用,所以只有在需要支持仍在使用旧 .NET Framework 的开发人员时,才会考虑针对旧版本进行目标定位。(微软在其大多数 NuGet 库中都这样做,但您并不一定要将自己与支持旧版本的同一体系绑定在一起。)微软提供了一个有用的指南,说明各种 .NET 实现版本支持各种 .NET Standard 版本。如果您想使用某些新特性(例如第十八章 中描述的节省内存的类型),可能需要针对更近期的 .NET Standard 版本进行目标定位,目前的最新版本是 2.1,但请注意,这将排除在 .NET Framework 上运行。在那种情况下,您最好直接将目标设定为 .NET Core 3.1 或更高版本的 .NET,因为 .NET Standard 在新统一的后 .NET Framework 世界中提供的功能有限。无论如何,开发工具将确保您只使用您声明支持的 .NET 或 .NET Standard 版本中可用的 API。

总结

程序集是一个可部署的单元,几乎总是一个单独的文件,通常使用 .dll.exe 扩展名。它是类型和代码的容器。每种类型都属于且仅属于一个程序集,而该程序集形成了类型的一部分标识——如果它们在不同的程序集中定义,.NET 运行时可以区分相同命名空间中具有相同名称的两种类型。程序集有一个复合名称,包括简单的文本名称、一个四部分的版本号、一个文化字符串,以及可选的公钥标记。带有公钥标记的程序集被称为强命名程序集,使它们具有全局唯一的名称。程序集可以与使用它们的应用程序一起部署,也可以存储在机器范围的存储库中。(在 .NET Framework 中,该存储库是全局程序集缓存,必须强命名才能使用。.NET 和 .NET Core 提供了内置程序集的共享副本,根据您如何安装这些更新的运行时,它们也可能具有共享的框架副本,如 ASP.NET Core 和 WPF。您还可以选择设置一个单独的运行时包存储库,其中包含其他共享程序集,以避免将它们包含在应用程序文件夹中。)

运行时可以根据需要自动加载程序集,通常是在第一次运行包含某些依赖于相关程序集中定义类型的代码的方法时发生。如果需要,你也可以显式加载程序集。

正如我之前提到的,每个程序集都包含详细的元数据描述其包含的类型。在下一章中,我将展示如何在运行时访问这些元数据。

¹ 在此,我广义上使用现代一词——Windows NT 于 1993 年引入了 PE 支持。

² 通过适当的构建设置,无论你在哪个操作系统上构建,都可以生成所有支持目标的引导程序。

³ 这是 Windows Vista 发布的年份。应用程序清单在此之前就存在,但这是 Windows 首次将其缺失视为传统代码的标志。

⁴ 这在.NET Framework 或.NET Standard 中不可用。在.NET Framework 中通常使用应用程序域进行隔离,这是一个不支持.NET 或.NET Core 的旧机制。

⁵ 如果你使用Assembly.LoadFrom,CLR 并不关心文件名是否与简单名称匹配。

⁶ 可以配置 CLR 以替换特定的不同版本,但即使如此,加载的程序集也必须与配置指定的确切版本匹配。

⁷ 英格兰,霍夫。

⁸ 内部项目也适用于友元程序集,这意味着任何在InternalsVisibleTo属性中引用的程序集,如第十四章所述。

第十三章:反射

CLR 对我们程序定义和使用的类型了如指掌。它要求所有程序集提供详细的元数据,描述每个类型的每个成员,包括私有实现细节。它依赖于此信息来执行关键功能,如 JIT 编译和垃圾回收。然而,它不会将这些知识私藏起来。反射 API 授予访问这些详细类型信息的权限,使得您的代码可以发现运行时能够看到的一切。此外,您还可以使用反射来实现一些事情。例如,代表方法的反射对象不仅描述方法的名称和签名,还允许您调用该方法。您甚至可以进一步,在运行时生成代码。

反射在可扩展框架中特别有用,因为它们可以使用它来根据代码结构在运行时调整其行为。例如,Visual Studio 的属性面板使用反射来发现组件提供的公共属性,因此,如果您编写一个可以出现在设计表面上的组件(如 UI 元素),您无需采取任何特殊措施使其属性可供编辑——Visual Studio 将自动找到它们。

注意

许多基于反射的框架可以自动发现它们需要了解的内容,同时允许组件显式地丰富这些信息。例如,尽管您无需采取任何特殊措施来支持在“属性”面板中进行编辑,但如果您希望,您可以自定义分类、描述和编辑机制。这通常通过属性来实现,它们是第十四章的主题。

反射类型

反射 API 定义了System.Reflection命名空间中的各种类。这些类具有与程序集和类型系统工作方式相对应的结构关系。例如,类型的包含程序集是其标识的一部分,因此代表类型的反射类(Type¹)具有一个Assembly属性,返回其包含的Assembly对象。而且您可以在这两个方向上导航这种关系——您可以从Assembly类的DefinedTypes属性中发现程序集中的所有类型。可以通过加载插件 DLL 来扩展的应用程序通常会使用这种方法来查找每个插件提供的类型。图 13-1 展示了与.NET 类型对应的反射类型、它们的成员以及包含它们的组件。箭头表示包含关系。(与程序集和类型一样,这些关系也都是可导航的。)

图 13-1. 反射包含层次

图 13-2 展示了这些类型的继承层次结构。其中还显示了几个额外的抽象类型,如MemberInfoMethodBase,它们被各种反射类共享,这些类具有一定的共同点。例如,构造函数和方法都有参数列表,而检查这些内容的机制则由它们共享的基类MethodBase提供。类型的所有成员都具有某些共同的特征,比如可访问性,因此反射中表示类型成员的对象都是MemberInfo派生的。

图 13-2. 反射继承层次结构

程序集

Assembly类可可预测地表示单个程序集。如果你正在编写插件系统或其他需要加载用户提供的 DLL 并使用它们的框架(例如单元测试运行器),Assembly类型将是你的起点。正如第十二章所示,静态Assembly.Load方法接受一个程序集名称,并返回该程序集的对象。(如果必要,该方法将加载程序集,但如果已加载,则只返回相关Assembly对象的引用。)但还有其他获取此类对象的方式。

Assembly类定义了三个上下文相关的静态方法,每个方法都返回一个Assembly对象。GetEntryAssembly方法返回表示包含程序Main方法的 EXE 文件的对象。GetExecutingAssembly方法返回包含调用它的方法的程序集。GetCallingAssembly方法上溯堆栈一级,并返回调用调用GetCallingAssembly的方法的程序集。

注意

JIT(即时编译器)的优化有时会在使用GetExecutingAssemblyGetCallingAssembly方法时产生意外的结果。方法内联和尾调用优化都可能导致这些方法返回比预期更深一层的堆栈帧的程序集。你可以通过为方法添加MethodImpl​Attribute并传递MethodImpl​Options枚举中的NoInlining标志来防止内联优化。(有关属性的描述,请参见第十四章。)目前没有明确的方法来禁用尾调用优化,但只有在特定方法调用是方法返回前的最后一件事时才会应用这些优化。

GetCallingAssembly 在诊断日志记录中很有用,因为它提供了调用你方法的代码的信息。GetExecutingAssembly 方法则不太有用:你可能已经知道代码将在哪个程序集中,因为你是编写代码的开发者。尽管如此,获取你正在编写的组件的 Assembly 对象可能仍然有用,但也有其他方法。下一节介绍的 Type 对象提供了一个 Assembly 属性。示例 13-1 使用这种方法通过包含类获取 Assembly。根据经验,这种方法似乎更快,这并不完全令人惊讶,因为它的工作量更少——这两种技术都需要检索反射对象,但其中一种还必须检查堆栈。

示例 13-1. 通过 Type 获得自己的 Assembly
class Program
{
    static void Main()
    {
        `Assembly` `me` `=` `typeof``(``Program``)``.``Assembly``;`
        Console.WriteLine(me.FullName);
    }
}

如果想从磁盘上的特定位置使用一个程序集,可以使用 第十二章 中描述的 LoadFrom 方法。或者,可以使用 System​.Reflec⁠tion.MetadataLoadContext NuGet 包的 MetadataLoadContext 类。这种方法加载程序集的方式使你可以检查其类型信息,但不会执行程序集中的任何代码,也不会自动加载它依赖的任何程序集。如果你正在编写一个工具来显示或处理关于组件的信息,但不想运行其代码,那么这是加载程序集的适当方式。有几个理由可以避免使用传统方式加载程序集来处理此类工具。加载程序集并检查其类型有时可能触发该程序集中的代码执行(如静态构造函数)。另外,如果仅用于反射目的加载程序集,则处理器架构并不重要,因此你可以将仅支持 32 位的 DLL 加载到 64 位进程中,或者在 x86 进程中检查 ARM-only 程序集。

从任何上述机制获取了一个 Assembly 后,可以发现它的各种信息。例如,FullName 属性提供显示名称。或者你可以调用 GetName,它返回一个 AssemblyName 对象,轻松地以编程方式访问程序集名称的所有组件。

要获取特定 Assembly 依赖的其他所有 Assembly 列表,可以调用 GetReferencedAssemblies。如果你在自己编写的程序集上调用此方法,它不一定会返回在 Visual Studio 解决方案资源管理器中“依赖项”节点中看到的所有程序集,因为 C# 编译器会剔除未使用的引用。

程序集包含类型,因此你可以通过调用 Assembly 对象的 GetType 方法找到表示这些类型的 Type 对象,传递你需要的类型的名称,包括其命名空间。如果未找到该类型,这将返回 null,除非你调用其中一个额外接受 bool 参数的重载版本 —— 使用这些,如果未找到类型,传递 true 将产生异常。还有一个重载版本,接受两个 bool 参数,其中第二个允许你传递 true 来请求大小写不敏感搜索。所有这些方法将返回 publicinternal 类型。你还可以请求一个嵌套类型,通过指定包含类型的名称,然后是一个 + 符号,然后是嵌套类型名称。 Example 13-2 获取了一个名为 Inside 的类型的 Type 对象,该类型嵌套在命名空间为 MyLib 的类型 ContainingType 内部。即使嵌套类型是私有的,这也能正常工作。

示例 13-2. 从程序集获取嵌套类型
Type? nt = someAssembly.GetType("MyLib.ContainingType+Inside");

Assembly 类还提供了 DefinedTypes 属性,返回一个集合,包含程序集定义的每个类型(顶层或嵌套)的 TypeInfo 对象,以及 ExportedTypes,仅返回公共类型,它返回 Type 对象而不是完整的 TypeInfo 对象。(“Type 和 TypeInfo” 中描述了 TypeInfoType 的区别。)这也包括任何公共的嵌套类型。它不包括位于公共类型内部的 protected 类型,这也许有点令人惊讶,因为这些类型虽然只能被派生自包含类型的类访问,但却可以从程序集外部访问。

除了返回类型外,Assembly 还可以使用 CreateInstance 方法创建它们的新实例。如果你只传递类型的完全限定名称作为字符串,这将创建一个实例,前提是该类型是公共的并且具有无参数构造函数。还有一个重载版本,让你可以处理非公共类型和需要参数的构造函数的类型;但是,这个使用起来更加复杂,因为它还接受指定类型名称是否不区分大小写的参数,以及定义用于不区分大小写比较的 CultureInfo 对象的参数 —— 不同的国家对这种比较有不同的看法。它还有用于控制更高级场景的参数。然而,对于大多数情况,你可以传递 null,正如 Example 13-3 所示。

示例 13-3. 动态构造
object? o = asm.CreateInstance(
    "MyApp.WithConstructor",
    false,
    BindingFlags.Public | BindingFlags.Instance,
    null,
    new object[] { "Constructor argument" },
    null,
    null);

asm 引用的程序集中,创建一个名为 WithConstructor 的类型的实例,该类型位于 MyApp 命名空间中。false 参数指示我们要在名称上进行精确匹配,而不是不区分大小写的比较。BindingFlags 指示我们正在寻找公共实例构造函数。(参见侧边栏 “Bind⁠ing​Flags”。)第一个 null 参数是你可以传递 Binder 对象的位置,它允许你在提供的参数与所需参数的类型不完全匹配时自定义行为。通过省略此参数,我表明我希望我提供的参数完全匹配。(如果它们不匹配,我会得到一个异常。)object[] 参数包含我想要传递给构造函数的参数列表——在本例中是一个字符串。倒数第二个 null 是我将要传递的地方,如果我使用不区分大小写的比较或数字类型与字符串之间的自动转换,但由于我都不使用,我可以省略它。最后一个参数曾支持已被弃用的场景,因此应始终为 null

模块

图 13-1 显示 Assembly 作为包含 Module 对象的容器。.NET Framework 支持将一个程序集的内容分割到多个文件(模块)中,但这个很少使用的特性在 .NET Core 或 .NET 中不被支持。在大多数情况下,你可以忽略 Module 类型——你通常可以使用反射 API 中的其他类型完成所有需要的操作。唯一的例外是,运行时生成代码的 API 需要你标识哪个模块应包含生成的代码,即使你只创建一个模块。(.NET 的运行时生成代码的 API 超出了本书的范围。)

Module 类提供另外一个服务:令人惊讶的是,它定义了 GetFieldGetFieldsGetMethodGetMethods 属性。这些属性提供对全局作用域的方法和字段的访问。在 C# 中你从未见过这些,因为该语言要求所有字段和方法都必须在类型内定义,但 CLR 允许全局作用域的方法和字段存在,所以反射 API 必须能够呈现它们。这些通过 Module 暴露出来,而不是 Assembly,因此即使在现代 .NET 的每个程序集一个模块的世界中,你也只能通过 Module 类访问它们。你可以从 Assembly 对象的 Modules 属性中检索它们,或者你可以使用从 MemberInfo 派生的以下部分中描述的任何 API 类型。(图 13-2 显示了哪些类型这样做。) 这定义了一个 Module 属性,返回定义了相关成员的 Module

MemberInfo

像本节描述的所有类一样,MemberInfo是抽象的。但与其余类不同的是,它不对应类型系统中的一个特定功能。它是一个共享的基类,为所有表示可以成为其他类型成员的项目的类型提供通用功能。因此,它是ConstructorInfoMethodInfoFieldInfoPropertyInfoEventInfoType的基类,因为所有这些都可以成为其他类型的成员。实际上,在 C#中,除了Type,所有这些都必须是某些其他类型的成员(尽管,正如您刚刚在前一节中看到的,某些语言允许方法和字段被作用域到模块而不是类型)。

MemberInfo定义了所有类型成员都必须具有的常见属性。当然,有一个Name属性,还有一个DeclaringType属性,它引用项目所在类型的Type对象;对于非嵌套类型和模块范围的方法和字段,这将返回nullMemberInfo还定义了一个Module属性,它引用包含模块,无论所讨论的项目是模块范围的还是类型的成员。

除了DeclaringType之外,MemberInfo还定义了一个ReflectedType,表示从中检索MemberInfo的类型。这些通常是相同的,但在涉及继承时可能会不同。示例 13-4 显示了区别。

示例 13-4. DeclaringTypeReflectedType
class Base
{
    public void Foo()
    {
    }
}

class Derived : Base
{
}

class Program
{
    static void Main(string[] args)
    {
        MemberInfo bf = typeof(Base).GetMethod("Foo")!;
        MemberInfo df = typeof(Derived).GetMethod("Foo")!;

        Console.WriteLine("Base    Declaring: {0}, Reflected: {1}",
                          bf.DeclaringType, bf.ReflectedType);
        Console.WriteLine("Derived Declaring: {0}, Reflected: {1}",
                          df.DeclaringType, df.ReflectedType);
    }
}

获取Base.FooDerived.Foo方法的MethodInfo。(MethodInfo派生自MemberInfo。)这只是描述同一方法的不同方式—Derived没有定义自己的Foo,所以它继承了Base定义的方法。程序产生以下输出:

Base    Declaring: Base, Reflected: Base
Derived Declaring: Base, Reflected: Derived

通过Base类的Type对象检索Foo信息时,DeclaringTypeReflectedType都显然是Base。然而,当我们通过Derived类型检索Foo方法的信息时,DeclaringType告诉我们该方法由Base定义,而ReflectedType告诉我们我们通过Derived类型获取了此方法。

警告

因为MemberInfo记住了您从哪种类型检索它,所以比较两个MemberInfo对象并不是检测它们是否引用相同事物的可靠方法。在示例 13-4 中使用==运算符或它们的Equals方法比较bfdf会返回false,尽管它们都引用Base.Foo。如果您不知道ReflectedType属性,您可能不会预期到这种行为。

令人稍感惊讶的是,MemberInfo 并不提供描述其可见性的任何信息。这可能看起来很奇怪,因为在 C# 中,所有对应于从 MemberInfo 派生的类型的构造函数、方法或属性的结构都可以加上 publicprivate 等修饰符。反射 API 确实提供了这些信息,但不是通过 MemberInfo 基类。这是因为 CLR 对某些成员类型的可见性处理方式与 C# 展示的方式略有不同。从 CLR 的角度来看,属性和事件并没有自己的访问性。相反,它们的访问性是在单个方法的级别上管理的。这使得属性的getset可以具有不同的访问级别,事件的访问器也是如此。当然,如果我们希望的话,我们可以在 C# 中独立地控制属性访问器的访问级别。C# 误导我们的地方在于,它让我们为整个属性指定一个单一的访问级别。但这只是设置两个访问器为相同级别的一种简写。令人困惑的是,它让我们为属性指定访问级别,然后为其中一个成员指定不同的访问级别,就像示例 13-5 中那样。

示例 13-5. 属性访问器的访问性
public int Count
{
    get;
    private set;
}

这有点误导性,因为尽管看起来如此,public 访问性并不适用于整个属性。这个属性级别的访问性只是告诉编译器在未指定访问级别的访问器时该使用什么。C# 的第一个版本要求属性访问器都具有相同的访问级别,因此为整个属性指定访问级别是有道理的。(对于事件仍然有类似的限制。)但这是一个任意的限制——CLR 一直允许每个访问器具有不同的访问级别。现在的 C# 支持这一点,但由于历史原因,利用这一点的语法看起来是不对称的。从 CLR 的角度来看,示例 13-5 只是指定get publicset private。示例 13-6 可能更好地表示实际发生的情况。

示例 13-6. CLR 如何看待属性的访问性
// Won't compile but arguably should
int Count
{
    public get;
    private set;
}

但是我们不能这样写,因为 C# 要求更显眼的两个访问器之一的可访问性在属性级别声明。这样做使得当两个属性具有相同的可访问性时语法更简单,但当它们不同时会显得有些奇怪。此外,在 示例 13-5 中(即编译器实际支持的语法)看起来我们应该能够在三个地方指定可访问性:属性和两个访问器。CLR 不支持这样做,所以如果你试图为两个访问器指定可访问性,编译器会报错。因此,属性或事件本身没有可访问性。(想象一下如果有的话——如果一个属性具有 public 可访问性,但其 getinternal,而 setprivate,那会是什么意思?)因此,并非从 MemberInfo 派生的所有内容都具有特定的可访问性,因此反射 API 提供了表示类层次结构中更深层次的可访问性的属性。

类型和 TypeInfo

Type 类表示特定的类型。它比本章中的任何其他类都更广泛使用,这就是为什么它单独位于 System 命名空间中,而其他类都定义在 System.Reflection 中的原因。它是最容易获取的,因为 C# 专门为此设计了一个操作符:typeof。我已经在几个示例中展示过了,但是 示例 13-7 单独展示了它。正如您所见,您可以使用内置名称,例如 string,或普通类型名称,例如 IDisposable。您还可以包括命名空间,但是当类型的命名空间在范围内时,这并不是必需的。

示例 13-7. 使用 typeof 获取 Type
Type stringType = typeof(string);
Type disposableType = typeof(IDisposable);

此外,正如我在 第六章 中提到的,System.Object 类型(或者在 C# 中我们通常写为 object)提供了一个 GetType 实例方法,不接受任何参数。您可以在任何引用类型变量上调用它,以检索该变量引用的对象的类型。这可能不会与变量本身的类型相同,因为变量可能引用派生类型的实例。您还可以在任何值类型变量上调用此方法,由于值类型不支持继承,它将始终返回变量静态类型的 Type 对象。

因此,您所需要的只是一个对象、一个值或一个类型标识符(例如 string),获取一个 Type 对象就非常简单。而且,Type 对象可以来自许多其他地方。

除了Type之外,我们还有TypeInfo。这在早期的.NET Core 版本中引入,旨在使Type仅作为轻量级标识符,并且TypeInfo作为反射类型的机制。这与.NET Framework 中Type一直以来的工作方式不同,后者同时扮演着这两个角色。这种双重角色可以说是一个错误,因为如果你只需要一个标识符,那么Type就显得不必要地笨重。最初,.NET Core 被构想为与.NET Framework 有着完全独立的存在,不需要严格的兼容性,因此看似提供了修复历史设计问题的机会。然而,一旦微软决定.NET Core 将成为所有未来版本的.NET 的基础,就有必要将其重新调整为.NET Framework 一直工作的方式。然而,到了这个时候,.NET Framework 也引入了TypeInfo,一段时间内,新的类型级别反射功能被添加到其中,而不是Type,以最小化与.NET Core 1 的不兼容性。.NET Core 2.0 重新与.NET Framework 对齐,但这意味着TypeTypeInfo之间的功能拆分现在只是添加时的结果。TypeInfo包含在其引入和决定恢复旧方式之间的短暂时期内添加的成员。在你有一个Type但你需要使用特定于TypeInfo的功能的情况下,你可以通过调用GetTypeInfo从一个Type获取它。

正如你已经看到的,你可以从一个Assembly中检索Type对象,无论是按名称还是作为一个全面的列表。派生自MemberInfo的反射类型也通过DeclaringType提供了对其包含类型的引用。(Type派生自MemberInfo,因此当处理嵌套类型时,这个属性也是相关的。)

你还可以调用Type类自己的静态GetType方法。如果你只传递了一个命名空间限定的字符串,它将在名为mscorlib的系统程序集中搜索命名的类型,并且还会在你调用该方法的程序集中搜索。然而,你可以传递一个程序集限定名称,它结合了程序集名称和类型名称。这种形式的名称以命名空间限定的类型名称开头,后跟逗号和程序集名称。例如,这是.NET Framework 4.8 中System.String类的程序集限定名称(分成两行以适应本书):

System.String, mscorlib, Version=4.0.0.0, Culture=neutral,
 PublicKeyToken=b77a5c561934e089

你可以通过Type.Assembly​Quali⁠fiedName属性发现类型的程序集限定名称。请注意,这不总是与您要求的相匹配。如果你将前述类型名称传递给.NET 6.0 中的Type.GetType,它将起作用,但如果你然后询问返回的TypeAssemblyQualifiedName,它将返回这个而不是:

System.String, System.Private.CoreLib, Version=6.0.0.0, Culture=neutral,
 PublicKeyToken=7cec85d7bea7798e

当您传递第一个字符串或只传递System.String时仍然有效,这是因为mscorlib仍然存在以支持向后兼容性。我在前一章节中描述了这一点,但总结起来,在.NET Framework 中,mscorlib程序集包含运行时库的核心类型,但在.NET Core 和.NET 5.0 或更高版本中,代码已经迁移到其他位置。mscorlib仍然存在,但它只包含类型转发条目,指示每个类现在位于哪个程序集中。例如,它将System.String转发到其新位置,即在撰写本文时位于System.Private.CoreLib程序集中。

除了标准的MemberInfo属性(例如ModuleName)之外,TypeTypeInfo类还添加了各种自己的属性。继承的Name属性包含未经修饰的名称,因此Type添加了一个Namespace属性。所有类型都作用于一个程序集,因此Type定义了一个Assembly属性。(当然,您可以通过Module.Assembly获取它,但使用Assembly属性更为方便。)它还定义了一个BaseType属性,尽管对于某些类型(例如非派生接口和System.Object类的类型对象),该属性将为null

由于Type可以表示各种类型,因此有一些属性可以用来确定确切的类型:IsArrayIsClassIsEnumIsInterfaceIsPointerIsValueType。(在互操作场景中,您还可以获取非.NET 类型的Type对象,因此还有一个IsCOMObject属性。)如果它表示一个类,有一些属性可以告诉您更多关于您所拥有的类的信息:IsAbstractIsSealedIsNested。最后一个属性不仅适用于类,还适用于值类型。

Type 还定义了许多属性,提供关于类型可见性的信息。对于非嵌套类型,IsPublic告诉您它是public还是internal,但对于嵌套类型,情况更为复杂。IsNestedAssembly表示内部嵌套类型,而IsNestedPublicIsNestedPrivate表示publicprivate的嵌套类型。CLR 不使用传统的 C 家族protected术语,而是使用family术语,因此我们有IsNestedFamily代表protectedIsNestedFamOR​As⁠sem代表protected internal,以及IsNestedFamANDAssem代表protected private

注意

没有IsRecord属性。在运行时看来,记录类型是类或结构。记录是 C#类型系统的一个特性,但不是.NET 运行时类型系统(CTS)的一部分。反射是运行时特性,因此它呈现了 CTS 的视角。

TypeInfo类还提供了一些方法来发现相关的反射对象。(本段中的属性都是在TypeInfo上定义的,而不是Type。如前所述,这只是定义时间的偶然。)这些大多数方法都有两种形式:一种是您需要指定特定类型所有项目的完整列表,另一种是您已知要查找的项目名称。例如,我们有DeclaredConstructorsDeclaredEventsDeclaredFieldsDeclaredMethodsDeclaredNestedTypesDeclaredProperties及其对应的GetDeclaredConstructorGetDeclaredEventGetDeclaredFieldGetDeclaredMethodGetDeclaredNestedTypeGetDeclaredProperty

Type类允许您发现类型兼容性关系。您可以通过调用类型的IsSubclassOf方法来询问一个类型是否派生自另一个类型。继承不是一个类型可能与不同类型的引用兼容的唯一原因——一个类型为接口的变量可以引用任何实现该接口的类型的实例,而不管其基类是什么。因此,Type类还提供了一个更通用的方法,称为IsAssignableFrom,如 Example 13-8 所示,它告诉您是否存在隐式引用转换。

Example 13-8. 测试类型兼容性
Type stringType = typeof(string);
Type objectType = typeof(object);
Console.WriteLine(stringType.IsAssignableFrom(objectType));
Console.WriteLine(objectType.IsAssignableFrom(stringType));

这显示False,然后是True,因为您无法将类型为object的实例的引用赋给类型为string的变量,但您可以将类型为string的实例的引用赋给类型为object的变量。

除了告诉您有关类型及其与其他类型的关系的信息之外,Type类还提供了在运行时使用类型成员的能力。它定义了一个InvokeMember方法,其确切含义取决于您调用的成员类型——例如,它可以意味着调用方法,或者获取或设置属性或字段。由于某些成员类型支持多种调用方式(例如获取和设置),您需要指定您想要的特定操作。Example 13-9 使用InvokeMember来调用由其名称(member字符串参数)标识的方法,该方法在动态实例化的类型实例上。这说明了反射如何用于处理在运行时才知道其标识的类型和成员。

Example 13-9. 使用InvokeMember调用方法
public static object? CreateAndInvokeMethod(
  string typeName, string member, params object[] args)
{
    Type t = Type.GetType(typeName)
        ?? throw new ArgumentException(
            $"Type {typeName} not found", nameof(typeName));
    object instance = Activator.CreateInstance(t)!;
    return t.InvokeMember(
      member,
      BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod,
      null,
      instance,
      args);
}

这个示例首先创建了指定类型的一个实例,这里采用了与我之前展示的Assembly.CreateInstance稍有不同的动态创建方法。这里我使用Type.GetType来查找类型,然后使用了一个我之前未提及的类Activator。这个类的作用是在运行时创建具有已确定类型的新对象实例。它的功能在某种程度上与Assembly.CreateInstance有重叠,但在这种情况下,这是从Type到该类型的新实例的最便捷方式。然后我使用了Type对象的InvokeMember来调用指定的方法。与示例 13-3 一样,我不得不指定绑定标志以指示我要查找的成员的类型,以及如何处理它们——这里我要调用一个方法(而不是设置属性值)。null参数就像示例 13-3 一样,如果我想支持方法参数类型的自动强制转换,我会在这里指定一个Binder

泛型类型

.NET 对泛型的支持使得Type类的角色变得更加复杂。除了表示普通的非泛型类型外,Type还可以表示泛型类型的特定实例(例如,List<int>),还可以表示未绑定的泛型类型(例如,List<>,尽管在除了一个非常特定的场景外,这是一个非法的类型标识符)。示例 13-10 展示了如何获取这两种类型的Type对象。

示例 13-10. 泛型类型的Type对象
Type bound = typeof(List<int>);
Type unbound = typeof(List<>);

typeof运算符是在 C# 中唯一可以使用未绑定泛型类型标识符的地方——在所有其他上下文中,如果不提供类型参数,这将是一个错误。顺便说一下,如果类型有多个类型参数,你必须提供逗号,例如,typeof(Dictionary<,>)。这是为了避免当有多个同名泛型类型,只有类型参数数量(也称为泛型的度)不同时的歧义——例如,typeof(Func<,>)typeof(Func<,,,>)。你不能指定部分绑定的泛型类型。例如,typeof(Dictionary<string,>)将无法编译通过。

你可以通过IsGenericType属性来判断一个Type对象是否引用一个泛型类型——对于来自示例 13-10 的boundunbound,该属性都将返回true。你还可以通过IsGenericTypeDefinition属性来确定是否提供了类型参数,对于boundunbound,它分别返回falsetrue。如果你有一个已绑定的泛型类型,并且想获取其构造的未绑定类型,你可以使用GetGenericType​Defini⁠tion方法——在bound上调用该方法将返回与unbound引用的相同类型对象。

给定一个 Type 对象,其 IsGenericTypeDefinition 属性返回 true,你可以通过调用 MakeGenericType 方法构造该类型的新的绑定版本,传递一个 Type 对象的数组,每个类型参数一个。

如果你有一个泛型类型,可以从 GenericTypeArguments 属性中检索其类型参数。或许令人惊讶的是,即使对于未绑定的类型,这也适用,尽管其行为与绑定类型不同。如果你从 示例 13-10 的 bound 获取 GenericTypeArguments,它将返回一个包含单个 Type 对象的数组,该对象与 typeof(int) 返回的对象相同。如果你从 unbound.GenericTypeArguments 获取,你将得到一个包含单个 Type 对象的数组,但这次它将是一个不代表特定类型的 Type 对象 —— 其 IsGenericParameter 属性将为 true,表明这代表一个占位符。在这种情况下,其名称将是 T。一般来说,名称将对应于泛型类型选择的任何占位符名称。例如,使用 typeof(Dictionary<,>),你将分别得到两个名为 TKeyTValueType 对象。如果你使用反射 API 查找泛型类型的成员,你将遇到类似的泛型参数占位符类型。例如,如果你检索未绑定的 List<> 类型的 Add 方法的 MethodInfo,你将发现它接受一个名为 T 的类型参数,其 IsGenericParameter 属性返回 true

当一个 Type 对象表示一个未绑定的泛型参数时,你可以通过其 GenericParameterAttributes 方法了解该参数是协变的、逆变的(或两者都不是)。

MethodBase, ConstructorInfo, and MethodInfo

构造函数和方法在很多方面都有很大的共同之处。这两种成员都具有相同的可访问性选项,它们都有参数列表,并且它们都可以包含代码。因此,MethodInfoConstructorInfo 反射类型共享一个基类,即 MethodBase,该类定义了处理这些共同方面的属性和方法。

要获取 MethodInfoConstructorInfo,除了使用我之前提到的 Type 类属性之外,还可以调用 MethodBase 类的静态 GetCurrentMethod 方法。这将检查调用代码,看它是构造函数还是普通方法,并相应地返回 MethodInfoConstructorInfo

除了从 MemberInfo 继承的成员之外,MethodBase 还定义了指定成员可访问性的属性。这些与我之前为类型描述的概念类似,但名称略有不同,因为与 Type 不同,MethodBase 并未定义区分嵌套和非嵌套成员的可访问性属性。因此,对于 publicprivateinternalprotectedprotected internalprotected private,在 MethodBase 中我们找到了 IsPublicIsPrivateIsAssemblyIsFamilyIsFamilyOrAssemblyIsFamilyAndAssembly

除了与可访问性相关的属性之外,MethodBase 还定义了一些属性,用于告知方法的各个方面,例如 IsStaticIsAbstractIsVirtualIsFinalIsConstructor

还有处理泛型方法的属性。IsGenericMethodIsGenericMethodDefinition 是方法级别的等价物,对应于类型级别的 IsGenericTypeIsGenericTypeDefinition 属性。与 Type 类似,有一个 GetGenericMethodDefinition 方法用于从绑定的泛型方法获取未绑定的泛型方法,以及一个 MakeGenericMethod 方法用于从未绑定的泛型方法生成绑定的泛型方法。您可以通过调用 GetGenericArguments 获取类型参数,并且与泛型类型一样,当在绑定方法上调用时,它将返回具体类型;而在未绑定方法上调用时,则返回占位符类型。

您可以通过调用 GetMethodBody 检查方法的实现。这将返回一个 MethodBody 对象,该对象提供对 IL(作为字节数组)以及方法使用的局部变量定义的访问权限。

MethodInfo 类从 MethodBase 派生,仅表示方法(而不是构造函数)。它添加了一个 ReturnType 属性,该属性提供一个 Type 对象,指示方法的返回类型。(有一个特殊的系统类型 System.Void,其 Type 对象在方法返回空时使用。)

ConstructorInfo 类并未添加任何除了从 MethodBase 继承的属性以外的属性。但它确实定义了两个只读的静态字段:ConstructorNameTypeConstructorName。它们分别包含字符串 ".ctor"".cctor",这些是在 ConstructorInfo 对象的 Name 属性中找到的值,用于实例和静态构造函数。就 CLR 而言,这些是真实的名称——尽管在 C#中构造函数看起来与其包含的类型同名,但这只在您的 C#源文件中成立,在运行时则不是。

您可以通过调用 Invoke 方法来调用由 MethodInfoConstructorInfo 表示的方法或构造函数。这与 Type.InvokeMember 做相同的事情——示例 13-9 使用它来调用方法。但是,因为 Invoke 专门用于处理方法和构造函数,因此使用起来更简单。对于 ConstructorInfo,您只需传递一个参数数组。对于 MethodInfo,您还需要传递要调用方法的对象,或者如果要调用静态方法,则为 null。示例 13-11 执行与 示例 13-9 相同的任务,但使用 MethodInfo

示例 13-11. 调用方法
public static object? CreateAndInvokeMethod(
  string typeName, string member, params object[] args)
{
    Type t = Type.GetType(typeName)
        ?? throw new ArgumentException(
            $"Type {typeName} not found", nameof(typeName));
    object instance = Activator.CreateInstance(t)!;
    `MethodInfo` `m` `=` `t``.``GetMethod``(``member``)`
        ?? throw new ArgumentException(
            $"Method {member} not found", nameof(member));
    `return` `m``.``Invoke``(``instance``,` `args``)``;`
}

对于方法或构造函数,您可以调用 GetParameters 方法,该方法返回表示方法参数的 ParameterInfo 对象数组。

ParameterInfo

ParameterInfo 类表示方法或构造函数的参数。其 ParameterTypeName 属性提供了查看方法签名时的基本信息。它还定义了一个 Member 属性,该属性指回该参数所属的方法或构造函数。HasDefaultValue 属性将告诉您参数是否是可选的;如果是,DefaultValue 将提供在省略参数时使用的值。

如果您正在处理由非绑定泛型类型定义的成员,或者使用非绑定泛型方法,请注意 ParameterInfoParameterType 可能指的是泛型类型参数,而不是真实类型。这也适用于反射对象的 Type 对象,描述了接下来三个部分中所述的对象。

FieldInfo

FieldInfo 表示类型中的一个字段。通常使用 Type 对象的 GetFieldGetFields 方法获取它,或者如果您使用的是支持全局字段的语言编写的代码,可以从包含的 Module 中检索这些字段。

FieldInfo 定义了一组表示可访问性的属性。这些属性看起来与 MethodBase 定义的属性非常相似。此外,还有 FieldType 属性,表示字段可以包含的类型。 (正如在非绑定泛型类型中所属成员一样,这可能是指类型参数而不是特定类型。)还有一些属性提供有关字段的进一步信息,包括 IsStaticIsInitOnlyIsLiteral。这些属性分别对应于 C# 中的 staticreadonlyconst。 (表示枚举类型中的值的字段也将从 IsLiteral 返回 true。)

FieldInfo 定义了 GetValueSetValue 方法,让您可以读取和写入字段的值。这些方法接受一个参数,指定要使用的实例,或者如果字段是静态的,则为 null。与 MethodBase 类的 Invoke 方法一样,这些方法并不会执行 Type 类的 InvokeMember 所不能做的事情,但通常更为方便。

PropertyInfo

PropertyInfo类型表示一个属性。可以通过包含的Type对象的GetPropertyGetProperties方法获取这些属性。如前所述,PropertyInfo不定义任何可访问性属性,因为可访问性是在单独的获取和设置方法级别确定的。可以使用GetGetMethodGetSetMethod方法获取这些方法,这两者都返回MethodInfo对象。

就像FieldInfo一样,PropertyInfo类定义了GetValueSetValue方法,用于读取和写入值。属性允许带参数——例如,C#索引器就是带参数的属性。因此,有多个接受参数数组的GetValueSetValue的重载方法。此外,还有一个GetIndexParameters方法,返回一个ParameterInfo对象数组,表示使用属性所需的参数。通过PropertyType属性可以获取属性的类型。

EventInfo

事件由EventInfo对象表示,这些对象由Type类的GetEventGetEvents方法返回。与PropertyInfo类似,这些对象没有可访问性属性,因为事件的添加和移除方法分别定义了它们自己的可访问性。可以使用GetAddMethodGetRemoveMethod获取这些方法,这两者都返回MethodInfoEventInfo定义了一个EventHandlerType,返回事件处理程序需要提供的委托类型。

可以通过调用AddEventHandlerRemove​EventHandler方法附加和移除处理程序。与所有其他动态调用一样,这些方法只是Type类的InvokeMember方法的更便捷的替代品。

反射上下文

.NET 拥有一种称为反射上下文的功能。这些使得反射能够提供类型系统的虚拟化视图。通过编写自定义反射上下文,您可以修改类型的外观——可以使类型看起来具有额外的属性,或者可以增加成员和参数似乎提供的属性集。(第十四章将描述属性。)

反射上下文非常有用,因为它们使得可能编写基于反射驱动的框架,使得各个类型可以自定义它们的处理方式,而不需要强制每个类型提供显式支持。在.NET 4.5 之前,这是通过各种临时系统处理的。例如,考虑 Visual Studio 中的属性面板。这可以自动显示任何.NET 对象定义的公共属性,这些对象最终出现在设计表面(例如,您编写的任何 UI 组件)。即使对于不提供任何显式处理的组件,自动编辑支持也非常好,但组件应有机会在设计时自定义其行为。

因为属性面板早于.NET 4.5,它使用了一种临时解决方案之一:TypeDescriptor类。这是反射的一个封装,允许任何类通过实现ICustomTypeDescriptor来增强其设计时行为,使类能够定制其用于编辑的属性集,并控制它们的展示方式,甚至提供自定义的编辑 UI。这种方法灵活,但它的缺点是将设计时代码与运行时代码耦合在一起——使用这种模型的组件不能轻易地在没有提供设计时代码的情况下进行发布。因此,Visual Studio 引入了自己的虚拟化机制来分离这两者。

为了避免每个框架都定义自己的虚拟化系统,自定义的反射上下文直接将虚拟化功能添加到反射 API 中。如果您想编写能够使用反射提供的类型信息但也支持设计时增强或修改该信息的代码,现在不再需要使用某种包装层。您可以使用本章前面描述的通常的反射类型,但现在可以要求反射为您提供这些类型的不同实现,提供不同的虚拟化视图。

你可以通过编写自定义的反射上下文来实现这一点,描述你希望如何修改反射提供的视图。示例 13-12 展示了一个特别无聊的类型,随后是一个自定义的反射上下文,使该类型看起来像有一个属性。

示例 13-12. 一个简单类型,通过反射上下文增强
class NotVeryInteresting
{
}

class MyReflectionContext : CustomReflectionContext
{
    protected override IEnumerable<PropertyInfo> AddProperties(Type type)
    {
        if (type == typeof(NotVeryInteresting))
        {
            var fakeProp = CreateProperty(
                MapType(typeof(string).GetTypeInfo()),
                "FakeProperty",
                o => "FakeValue",
                (o, v) => Console.WriteLine($"Setting value: {v}"));

            return new[] { fakeProp };
        }
        else
        {
            return base.AddProperties(type);
        }
    }
}

直接使用反射 API 的代码将直接看到NotVeryInteresting类型,它没有任何属性。然而,我们可以通过MyReflectionContext映射该类型,就像示例 13-13 所示的那样。

示例 13-13. 使用自定义的反射上下文
var ctx = new MyReflectionContext();
TypeInfo mappedType = ctx.MapType(typeof(NotVeryInteresting).GetTypeInfo());

foreach (PropertyInfo prop in mappedType.DeclaredProperties)
{
    Console.WriteLine($"{prop.Name} ({prop.PropertyType.Name})");
}

变量mappedType保存了映射后的类型的引用。它看起来仍然像是普通的反射TypeInfo对象,我们可以通过DeclaredProperties以通常的方式迭代其属性,但因为我们通过自定义的反射上下文映射了类型,所以看到的是修改后的版本。这段代码的输出将显示该类型似乎定义了一个名为FakePropertystring类型属性。

概要

反射 API 使得能够编写其行为基于其所处理的类型结构的代码成为可能。这可能涉及根据对象提供的属性来决定在 UI 网格中显示哪些值,或者根据特定类型选择定义的成员来修改框架的行为。例如,ASP.NET Core Web 框架的部分将检测您的代码是否使用同步或异步编程技术,并相应地进行调整。这些技术需要能够在运行时检查代码,这正是反射所实现的。类型系统所需程序集中的所有信息都可以通过我们的代码访问。此外,通过编写自定义反射上下文,可以通过虚拟视图呈现这些信息,从而使得定制反射驱动代码的行为成为可能。

代码检查类型结构以驱动其行为的需要通常需要额外的信息。例如,System.Text.Json 命名空间包含在第十五章中描述的类型,可以在 .NET 对象和 JSON 文档之间进行转换。这些依赖于反射,但是通过在属性形式中提供额外信息,你可以更精确地控制其目的。这些将是下一章的主题。

¹ 基于后面讨论的历史原因,部分功能的子集在名为 TypeInfo 的派生类型中。但是基类 Type 类是你经常遇到的类型。

第十四章:属性

在.NET 中,您可以使用属性为组件、类型及其成员添加注解。属性的目的是控制或修改框架、工具、编译器或 CLR 的行为。例如,在 第一章 中,我展示了一个使用 [TestClass] 属性进行注释的类。这告诉单元测试框架,被注释的类包含一些作为测试套件一部分运行的测试。

属性是信息的被动容器,在自身不执行任何操作。可以通过与物理世界的类比来理解,如果您打印包含地址和跟踪信息的运输标签,并将其附加到包裹上,该标签本身不会导致包裹自行送达目的地。这样的标签只有在包裹交到运输公司手中时才有用。当公司取走您的包裹时,它会期望找到标签,并使用它来确定如何路由您的包裹。因此,标签很重要,但最终它的唯一工作是提供某些系统所需的信息。.NET 属性的工作方式与此类似——它们只有在某些东西正在寻找它们时才会发挥作用。某些属性由 CLR 或编译器处理,但这些只是少数。大多数属性被框架、库、工具(如单元测试运行器)或您自己的代码消耗。

应用属性

为了避免在类型系统中引入额外的概念,.NET 将属性建模为.NET 类型的实例。要作为属性使用,类型必须派生自 System.Attribute 类,但除此之外,它可以是完全普通的。要应用属性,您将类型名称放在方括号中,这通常直接放在属性的目标之前。(由于 C#大多数情况下忽略空白字符,当目标是类型或成员时,属性不必位于单独的行上,但这是约定俗成的。)示例 14-1 展示了来自微软测试框架的一些属性。我已经将一个应用到类上,以指示这个类包含我希望运行的测试,并且还将属性应用到单独的方法上,告知测试框架哪些方法代表测试,哪些包含要在每次测试前运行的初始化代码。

示例 14-1 单元测试类中的属性
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace ImageManagement.Tests;

`[TestClass]`
public class WhenPropertiesRetrieved
{
    private ImageMetadataReader? _reader;

    `[TestInitialize]`
    public void Initialize()
    {
        _reader = new ImageMetadataReader(TestFiles.GetImage());
    }

    `[TestMethod]`
    public void ReportsCameraMaker()
    {
        Assert.AreEqual(_reader!.CameraManufacturer, "Fabrikam");
    }

    `[TestMethod]`
    public void ReportsCameraModel()
    {
        Assert.AreEqual(_reader!.CameraModel, "Fabrikam F450D");
    }
}

如果您查看大多数属性的文档,您会发现它们的实际名称以 Attribute 结尾。如果在括号中指定的名称没有对应的类,C# 编译器会尝试添加 Attribute,所以 示例 14-1 中的 [TestClass] 属性指的是 TestClassAttribute 类。如果您确实希望如此,可以完整拼写类名,例如 [TestClassAttribute],但更常见的是使用缩写版本。

如果你想应用多个属性,有两种选择。你可以提供多组括号,或者将多个属性放在单一对括号内,用逗号分隔。

一些属性类型可以接受构造函数参数。例如,微软的测试框架包含一个TestCategoryAttribute。在运行测试时,您可以选择仅执行某个类别中的测试。此属性要求您将类别名称作为构造函数参数传递,因为如果不指定名称,则应用此属性没有意义。正如 Example 14-2 所示,指定属性的构造函数参数的语法并不令人意外。

Example 14-2. 带构造函数参数的属性
`[TestCategory("Property Handling")]`
[TestMethod]
public void ReportsCameraMaker()
{
    ...

你也可以指定属性或字段的值。一些属性具有只能通过属性或字段控制的特性,而不是构造函数参数。(如果一个属性有很多可选设置,通常更容易将这些设置呈现为属性或字段,而不是为每种可能的设置组合定义构造函数重载。)其语法是在构造函数参数之后写一个或多个*PropertyOrFieldName*=*Value*条目(如果没有构造函数参数,则是代替它们)。Example 14-3 展示了另一个在单元测试中使用的属性,ExpectedExceptionAttribute,它允许你指定在测试运行时期望它抛出特定异常。异常类型是必需的,因此我们将其作为构造函数参数传递,但这个属性还允许你指定测试运行器是否接受从指定类型派生的异常。 (默认情况下,它只接受完全匹配。)这由AllowDerivedTypes属性控制。

Example 14-3. 使用属性指定可选的属性设置
`[ExpectedException(typeof(ArgumentException), AllowDerivedTypes = true)]`
[TestMethod]
public void ThrowsWhenNameMalformed()
{
    ...

应用属性并不会导致它们被实例化。当你应用一个属性时,你所做的只是提供关于如何创建和初始化该属性的指令,如果有需要的话。(有一种常见的误解认为方法属性在方法运行时会被实例化,其实不然。)当编译器为程序集构建元数据时,它会包含关于已应用到哪些项上的属性的信息,包括构造函数参数和属性值的列表,CLR 只有在有需要的时候才会提取出来使用。例如,当你要求 Visual Studio 运行你的单元测试时,它会加载你的测试程序集,然后对每个公共类型,它会向 CLR 查询任何与测试相关的属性。这就是属性被构造的时机。如果你只是简单地加载程序集,比如从另一个项目添加引用然后使用它包含的一些类型,属性就不会存在——它们只会保留在元数据中,作为一组冻结在程序集元数据中的构建指令。

属性目标

属性可以应用于许多不同类型的目标。你可以在反射 API 中展示的类型系统特性的任何地方放置属性,就像我在第十三章中展示的那样。具体来说,你可以将属性应用于程序集、模块、类型、方法、方法参数、构造函数、字段、属性、事件和泛型类型参数。此外,你还可以提供目标为方法返回值的属性。

对于大多数情况,你只需在目标前面放置属性即可标识目标。但对于程序集或模块来说不是这样,因为在你的源代码中没有单个特性代表它们——你项目中的所有内容都会进入它生成的程序集中,并且模块同样是一个集合体(通常构成整个程序集,就像我在第十二章中描述的那样)。因此,对于这些情况,我们必须在属性开头明确说明目标。你经常会在像GlobalSuppressions.cs文件中看到类似示例 14-4 所示的程序集级别属性。Visual Studio 有时会建议修改你的代码,如果你选择抑制这些警告,它就会使用程序集级别的属性来实现。

示例 14-4. 程序集级别属性
[assembly: System.Diagnostics.CodeAnalysis.SuppressMessage(
 "Style",
 "IDE0060:Remove unused parameter",
 Justification = "This is just some example code from a book",
 Scope = "member",
 Target = "~M:Idg.Examples.SomeMethod")]

你可以在任何文件中放置程序集级别的属性。唯一的限制是它们必须出现在任何命名空间或类型定义之前。程序集级别属性之前应该出现的只有你需要的using指令、注释和空白(这些都是可选的)。

模块级别的属性遵循相同的模式,尽管它们非常少见,主要是因为多模块程序集非常罕见,并且在最新版本的.NET 中不受支持——它们仅适用于.NET Framework。示例 14-5 展示了如何配置特定模块的调试性,如果您希望多模块程序集中的一个模块易于调试,但其余模块带有完全优化的 JIT 编译,则可以使用此功能。(这是一个假设的场景,我只是为了展示语法。实际上,您不太可能需要这样做。)我稍后会讨论DebuggableAttribute,在“JIT 编译”中。

示例 14-5. 模块级别属性
using System.Diagnostics;

[module: Debuggable(DebuggableAttribute.DebuggingModes.DisableOptimizations)]

另一种需要资格证书的目标是编译器生成的字段。当您在不为 getter 或 setter 提供代码的属性中使用时,以及在没有显式的addremove实现的event成员中使用时,您会得到这些字段。示例 14-6 中的属性应用于保存属性值的字段和事件委托;如果没有field:限定符,这些位置的属性将适用于属性或事件本身。

示例 14-6. 编译器生成的属性和事件字段的属性
[field: NonSerialized]
public int DynamicId { get; set; }

[field: NonSerialized]
public event EventHandler? Frazzled;

方法的返回值可以进行注释,这也需要资格证书,因为返回值属性放在方法前面,与适用于方法本身的属性位于同一位置。(参数的属性不需要资格证书,因为这些属性与参数一起出现在括号内。)示例 14-7 展示了一个同时应用于方法和返回类型的属性的方法。(本示例中的属性是支持互操作服务的一部分,这些服务使.NET 代码能够调用外部代码,例如操作系统 API。此示例导入了来自 Win32 DLL 的函数,使您能够从 C#中使用它。非托管代码中存在几种不同的布尔值表示形式,因此我在此处用MarshalAsAttribute对返回类型进行了注释,以说明 CLR 应该期望哪个特定形式。)

示例 14-7. 方法和返回值属性
[DllImport("User32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
static extern bool IsWindowVisible(HandleRef hWnd);

那么对于我们不显式编写方法声明的情况呢?正如您在第九章中看到的那样,Lambda 语法允许我们编写一个值为委托的表达式。编译器会生成一个正常的方法来保存代码(通常是在一个隐藏的类中),我们可能希望将该方法传递给使用属性来控制其功能的框架,例如 ASP.NET Core Web 框架。示例 14-8 展示了在使用 Lambda 时如何指定这些属性。

示例 14-8. 带属性的 Lambda
app.MapGet(
    "/items/{id}",
 [Authorize] ([FromRoute] int id) => $"Item {id} requested");

MapGet 方法告诉 ASP.NET Core 框架应该在收到与特定模式匹配的 URL 的 GET 请求时如何行为。第一个参数指定模式,第二个是定义行为的委托。我在这里使用了 lambda 语法,并应用了一些属性。

第一个属性是 [Authorize]。它出现在参数列表之前,因此其目标是整个方法。(您还可以在此位置使用 return: 属性。)这会导致 ASP.NET Core 阻止未经身份验证的请求匹配此 URL 模式。[FromRoute] 属性位于参数列表的括号内,因此它适用于 id 参数,并告诉 ASP.NET Core 我们希望从 URL 模式中同名表达式获取该特定参数的值。因此,如果请求 https://myserver/items/42 进来,ASP.NET Core 首先会检查请求是否符合应用程序配置的身份验证和授权要求,如果符合,然后会调用我的 lambda,并将 42 作为 id 参数传递。

注意

例子 9-22 在 第九章 中展示了在某些情况下可以省略细节。参数列表周围的括号通常对于单参数 lambda 是可选的。但是,如果您要将属性应用于 lambda,则括号 必须 存在。要看清楚原因,请想象如果 例子 14-8 在参数列表周围没有括号:那么不清楚属性是应用于方法还是参数。

编译器处理的属性

C# 编译器识别特定的属性类型,并以特殊方式处理它们。例如,程序集的名称和版本是通过属性设置的,还有一些关于您的程序集的相关信息。正如第十二章所述,在现代 .NET 项目中,构建过程会为您生成一个隐藏的源文件,其中包含这些信息。如果您感兴趣,它通常会出现在项目的 obj\Debugobj\Release 文件夹中,并且名称通常类似于 YourProject.AssemblyInfo.cs。例子 14-9 展示了一个典型的示例。

例 14-9. 具有程序集级属性的典型生成文件
//------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//     Runtime Version:4.0.30319.42000
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------

using System;
using System.Reflection;

[assembly: System.Reflection.AssemblyCompanyAttribute("MyCompany")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0")]
[assembly: System.Reflection.AssemblyProductAttribute("MyApp")]
[assembly: System.Reflection.AssemblyTitleAttribute("MyApp")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]

// Generated by the MSBuild WriteCodeFragment class.

旧版本的.NET Framework SDK 在构建时未生成此文件,因此如果您处理旧项目,您经常会在名为AssemblyInfo.cs的文件中找到这些属性。(默认情况下,Visual Studio 将其隐藏在解决方案资源管理器中项目的属性节点中,但它仍然只是一个普通的源文件。)现代项目中使用的文件生成优势在于,名称不太可能不同步。例如,默认情况下,程序集产品和标题将与项目文件名相同。如果重新命名项目文件,则生成的YourRenamedProject.AssemblyInfo.cs将相应更改(除非您向项目文件添加了<Product><AssemblyTitle>属性,在这种情况下它将使用这些属性),而旧的AssemblyInfo.cs方法则可能会意外导致名称不匹配。同样,如果从项目构建 NuGet 包,某些属性最终会出现在 NuGet 包和编译的程序集中。当这些都是从项目文件中的信息生成时,更容易保持一致性。

即使您只间接控制这些属性,理解它们也很有用,因为它们会影响编译器输出。

名称和版本

正如您在第十二章看到的,程序集具有复合名称。简单名称通常与文件名相同,但不包括*.exe.dll*扩展名,作为项目设置的一部分进行配置。名称还包括版本号,并且通过属性控制,如示例 14-10 所示。

示例 14-10. 版本属性
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

正如您可能从第十二章回忆起的,第一个集合设置了程序集名称的版本部分。第二个与.NET 无关——编译器使用它来生成 Win32 风格的版本资源。这是最终用户在 Windows 资源管理器中选择您的程序集并打开属性窗口时看到的版本号。

区域性也是程序集名称的一部分。如果您正在使用第十二章描述的卫星资源程序集机制,通常会自动设置这一点。您可以使用AssemblyCulture属性显式设置它,但对于非资源程序集,通常不应设置区域性。(您通常会显式指定的唯一与文化相关的程序集级属性是我在第十二章展示的NeutralResourcesLanguageAttribute。)

强名称程序集在其名称中有一个额外的组件:公钥标记。在 Visual Studio 中设置强名称的最简单方法是使用项目属性页中“强名称”部分(位于“生成”部分内)。如果您正在使用 VS Code 或其他编辑器,您可以简单地在您的 .csproj 文件中添加两个属性:SignAssembly 设置为 True,并且 AssemblyOriginatorKeyFile 设置为您密钥文件的路径。然而,您也可以通过源代码管理强命名,因为编译器识别一些特殊的属性用于此目的。AssemblyKeyFileAttribute 接受包含密钥的文件名。或者,您可以将密钥安装到计算机的密钥存储区(这是 Windows 加密系统的一部分)。如果您想这样做,您可以使用 AssemblyKeyNameAttribute。这两个属性中的任何一个都会导致编译器将公钥嵌入程序集并将该密钥的哈希值作为强名称的公钥标记包含在其中。如果密钥文件包含私钥,编译器还会为您的程序集签名。如果不包含私钥,则编译器将无法编译,除非您还启用了延迟签名或公共签名。您可以通过应用带有参数trueAssemblyDelaySignAttribute来启用延迟签名。或者,您可以将<DelaySign>true</DelaySign><PublicSign>true</PublicSign>添加到您的 .csproj 文件中。

警告

尽管与密钥相关的属性触发编译器的特殊处理,但它们仍然像普通属性一样嵌入到元数据中。因此,如果您使用AssemblyKeyFileAttribute,则您密钥文件的路径将显示在最终编译输出中。这并不一定是问题,但您可能更喜欢不公开这些细节,因此使用项目级别的强名称配置可能比基于属性的方法更好。

描述及相关资源

AssemblyFileVersion属性生成的版本资源并非 C# 编译器可以嵌入到 Win32 样式资源中的唯一信息。还有几个其他属性提供版权信息和其他描述性文本。示例 14-11 展示了典型的选择。

Example 14-11. 典型的程序集描述属性
[assembly: AssemblyTitle("ExamplePlugin")]
[assembly: AssemblyDescription("An example plug-in DLL")]
[assembly: AssemblyConfiguration("Retail")]
[assembly: AssemblyCompany("Endjin Ltd.")]
[assembly: AssemblyProduct("ExamplePlugin")]
[assembly: AssemblyCopyright("Copyright © 2022 Endjin Ltd.")]
[assembly: AssemblyTrademark("")]

与文件版本一样,所有这些信息都可以在文件的属性窗口的详细信息选项卡中看到,这是 Windows Explorer 可以显示的。对于所有这些属性,您都可以通过编辑项目文件来生成它们。

调用者信息属性

有一些编译器处理的属性专为您的方法需要有关其被调用上下文信息的场景而设计。这在某些诊断日志记录或错误处理场景中很有用,当然,在实现通常在 UI 代码中使用的特定接口时也非常有帮助。

示例 14-12 说明了如何在日志记录代码中使用这些属性。如果你用这三个属性中的任何一个注释方法参数,编译器在调用者省略参数时会进行一些特殊处理。我们可以请求调用带有注释方法的成员(方法或属性)的名称,调用该方法的包含代码的文件名,或者调用发生的行号。示例 14-12 请求了这三者,但你可以更有选择地使用。

注意

这些属性仅允许用于可选参数。可选参数需要指定默认值,但是当这些属性存在时,C#会始终替换为不同的值,因此你指定的默认值在从 C#(或支持这些属性的 Visual Basic)调用方法时不会被使用。尽管如此,你必须提供一个默认值,因为没有默认值时,参数就不是可选的,所以我们通常使用空字符串、null或数字0

示例 14-12. 将调用者信息属性应用于方法参数
public static void Log(
    string message,
 [CallerMemberName] string callingMethod = "",
 [CallerFilePath] string callingFile = "",
 [CallerLineNumber] int callingLineNumber = 0)
{
    Console.WriteLine("Message {0}, called from {1} in file '{2}', line {3}",
        message, callingMethod, callingFile, callingLineNumber);
}

如果在调用此方法时提供了所有参数,那么不会发生任何异常。但是如果省略了任何可选参数,C#会生成代码来提供关于调用该方法的位置的信息。在示例 14-12 中,三个可选参数的默认值将是调用此Log方法的方法或属性的名称,包含调用方法的源代码的完整路径,以及调用Log的行号。

CallerMemberName属性与我们在第八章中看到的nameof运算符有表面上的相似之处。两者都导致编译器创建包含代码某些特性名称的字符串,但它们的工作方式完全不同。使用nameof,你总是知道会得到什么字符串,因为它由你提供的表达式决定(例如,在示例 14-12 中的Log中写入nameof(message),它将始终评估为"message")。但是CallerMemberName改变了应用它们的方法被编译器调用的方式——cal⁠lin⁠g​Met⁠hod有该属性,其值不是固定的。它将取决于调用此方法的位置。

注意

你可以另一种方式发现调用方法:System.Diagnostics命名空间中的StackTraceStackFrame类可以报告调用堆栈中的上层方法的信息。然而,这些方法在运行时开销较高——调用者信息属性在编译时计算值,因此运行时开销非常低(nameof也是如此)。此外,StackFrame只能在存在调试符号时确定文件名和行号。

虽然诊断日志记录是显而易见的应用,我还提到了大多数.NET UI 开发人员熟悉的某种场景。运行时库定义了一个称为INotifyPropertyChanged的接口。正如示例 14-13 所示,这是一个非常简单的接口,只有一个成员,一个称为PropertyChanged的事件。

示例 14-13. INotifyPropertyChanged
public interface INotifyPropertyChanged
{
    event PropertyChangedEventHandler? PropertyChanged;
}

实现此接口的类型在其属性更改时引发PropertyChanged事件。PropertyChangedEventArgument提供一个字符串,其中包含刚刚更改的属性的名称。这些更改通知在 UI 中非常有用,因为它们使对象能够与数据绑定技术(例如.NET 的 WPF UI 框架提供的技术)一起使用,该技术可以在属性更改时自动更新 UI。数据绑定可以帮助您实现 UI 类型直接处理的代码与包含决定应用程序如何响应用户输入的逻辑的代码之间的清晰分离。

实现INotifyPropertyChanged可能既繁琐又容易出错。因为PropertyChanged事件以字符串形式指示哪个属性已更改,所以很容易拼写错误属性名称,或者在复制和粘贴实现时意外使用错误的名称。另外,如果重命名属性,很容易忘记更改事件的文本,这意味着以前正确的代码现在在引发PropertyChanged事件时会提供错误的名称。nameof运算符有助于避免拼写错误,并有助于重命名,但无法始终检测到复制和粘贴错误。(例如,在同一类的属性之间粘贴代码时,它不会注意到您未更新名称的情况。)

调用者信息属性可以帮助减少实现此接口时的错误。您可以参考示例 14-14,该示例显示了一个实现INotifyPropertyChanged的基类,提供了一个在利用这些属性之一时引发更改通知的帮助程序。(它还使用了空值条件运算符?.,以确保仅在委托非空时才调用事件的委托。顺便说一下,当您以这种方式使用运算符时,C#会生成只在委托非空时才评估委托的Invoke方法参数的代码。因此,它会跳过当委托为空时的调用Invoke,还会避免构造将作为参数传递的Pro⁠per⁠ty​Cha⁠nge⁠dEv⁠ent⁠Args。)此代码还会检测值是否真的已更改,仅在这种情况下引发事件,并且其返回值指示是否发生了更改,以防调用者可能会发现这有用。

示例 14-14. 可重用的INotifyPropertyChanged实现
public class NotifyPropertyChanged : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected bool SetProperty<T>(
        ref T field,
        T value,
 [CallerMemberName] string propertyName = "")
    {
        if (Equals(field, value))
        {
            return false;
        }

        field = value;

        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
        return true;
    }
}

存在[CallerMemberName]属性意味着从此类型派生的类如果在属性设置器内部调用SetProperty,则无需指定属性名称,如 Example 14-15 所示。

Example 14-15. 触发属性更改事件
public class MyViewModel : NotifyPropertyChanged
{
    private string? _name;

    public string? Name
    {
        get => _name;
        set => SetProperty(ref _name, value);
    }
}

即使有了新的属性,实现INotifyPropertyChanged显然比自动属性更费力,其中您只需编写{ get; set; }并让编译器为您完成工作。但它只比显式实现的简单字段支持属性复杂一点点,并且比没有[CallerMemberName]时简单,因为我能够在请求基类触发事件时省略属性名称。更重要的是,它更少容易出错:现在我可以确信每次都会使用正确的名称,即使将来某个时候重命名属性。

.NET 6.0 添加了一个新的调用者信息属性:CallerArgumentExpression。Example 14-16 展示了运行时库的ArgumentNullException类的摘录。它声明了一个使用此属性的ThrowIfNull方法。

Example 14-16. 在ArgumentNullException.ThrowIfNull中的CallerArgumentExpressionAttribute
public class ArgumentNullException
{
    public static void ThrowIfNull(
 [NotNull] object? argument,
 [CallerArgumentExpression("argument")] string? paramName =  null)
    {
...

正如您所见,CallerArgumentExpression属性接受一个字符串参数。这必须是同一方法中另一个参数的名称——在本例中只有一个叫做argument,因此必须引用它。其效果是,如果调用此方法而没有为注释的paramName参数提供值,C#编译器将传递包含该属性标识的参数的精确表达式的字符串。Example 14-17 展示了ThrowIfNull方法的典型调用方式。

Example 14-17. 调用使用CallerArgumentExpressionAttribute的方法
static void Greet(string greetingRecipient)
{
    ArgumentNullException.ThrowIfNull(greetingRecipient);
    Console.WriteLine($"Hello, {greetingRecipient}");
}

Greet("world");
Greet(null!);

Greet方法需要greetingRecipient不为 null,因此调用Arg⁠ume⁠nt​Nul⁠lEx⁠cep⁠tio⁠n.T⁠hro⁠wIf⁠Null,传入greetingRecipient。因为这段代码没有为ThrowIfNull提供第二个参数,编译器将提供我们用于第一个参数的表达式的完整文本。在这种情况下,即是"gre⁠eti⁠ng​Rec⁠ipi⁠ent"。因此,当运行此程序时,效果是抛出一个带有此消息的Arg⁠ume⁠nt​Nul⁠lEx⁠cep⁠tio⁠n

Value cannot be null. (Parameter 'greetingRecipient')

在 C# 10.0 之前,我们通常会使用nameof(greetingRecipient)来告诉ArgumentNullException有问题的参数名称。这种新技术防止了一种特定的错误:以前在抛出异常时很容易传递错误的参数名称。(如果您需要检查多个参数是否为 null,复制和粘贴相关检查会提供大量机会来犯这种错误。)

此属性支持的一个场景是改进断言消息。例如,单元测试库通常提供机制,用于在测试代码执行后断言某些条件是否为真。其思想是,如果您的测试包含类似 Assert.IsTrue(answer == 42); 的代码,测试库可以使用 [CallerArgumentExpression] 来在失败时报告确切的表达式 (answer == 42)。

您可能期望运行时库中的 Debug.Assert 方法以类似的方式使用此属性。但是,要使用 CallerArgumentExpressionAttribute,您必须添加一个参数来接收表达式文本(除了接收表达式值的现有参数之外),因此这不是一个二进制兼容的更改。新的 ThrowIfNull 方法是.NET 6.0 运行时库唯一使用此属性的地方,在我写这篇文章时,微软的测试框架的 NuGet 包尚未使用此属性。但是很可能测试框架会在未来采用这一特性。

CLR-Handled Attributes

CLR 在运行时会对某些属性进行特殊处理。目前还没有官方的全面属性列表,所以在接下来的几节中,我将简要描述一些最常用的例子。

InternalsVisibleToAttribute

可以将 InternalsVisibleToAttribute 应用于一个程序集,以声明其定义的任何 internal 类型或成员应该对一个或多个其他程序集可见。这种属性的一个常见用途是启用内部类型的单元测试。正如 示例 14-18 所示,您可以将程序集的名称作为构造函数参数传递。

注意

强名称会使事情变得更复杂。强名称的程序集无法将其内部成员对不强命名的程序集可见,反之亦然。当一个强命名的程序集将其内部成员对另一个强命名的程序集可见时,它必须指定不仅仅是简单名称,还包括要授予访问权限的程序集的公钥。这不仅仅是我在 第十二章 中描述的公钥令牌——它是整个公钥的十六进制表示,可能有数百位数字。您可以使用 .NET SDK 的 sn.exe 实用程序,使用 -Tp 开关,后跟程序集的路径,来发现程序集的完整公钥。

示例 14-18. InternalsVisibleToAttribute
[assembly:InternalsVisibleTo("ImageManagement.Tests")]
[assembly:InternalsVisibleTo("ImageServices.Tests")]

这表明您可以通过多次应用该属性并每次使用不同的程序集名称,使类型对多个程序集可见。

CLR 负责执行访问规则。通常,如果您尝试从另一个程序集使用内部类,您将在运行时收到错误。(C# 甚至不允许您编译这样的代码,但是有可能欺骗编译器。或者您可以直接编写 IL。IL 汇编器 ILASM 会执行您告诉它的操作,并且比 C# 实施的限制少得多。一旦您通过了编译时的限制,然后您将遇到运行时限制。) 但是,当存在此属性时,CLR 放宽了对您列出的程序集的规则。编译器也理解此属性,并允许尝试使用外部定义的内部类型的代码编译,只要外部库在 InternalsVisibleToAttribute 中命名了您的程序集。

除了在单元测试场景中很有用外,这个属性还可以在您想要将代码分散到多个程序集的情况下帮助您。如果您编写了一个大型类库,可能不想将其放入一个庞大的 DLL 中。如果它有几个区域是您的客户可能想要单独使用的,那么将它分割开来以便他们可以部署他们需要的部分就有意义了。但是,尽管您可能可以对库的公共 API 进行分区,但实现可能并不容易分割,特别是如果您的代码库执行大量重用。您可能有许多不设计为公共消费的类,但却在整个代码中使用。

如果没有InternalsVisibleToAttribute,跨程序集重用共享实现细节将会很尴尬。每个程序集都需要包含其相关类的副本,或者您需要在某个公共程序集中将它们作为公共类型。第二种技术的问题在于,将类型公开实际上邀请人们使用它们。您的文档可能说明这些类型是框架内部使用的,不应该使用,但这并不能阻止某些人。

幸运的是,您不必将它们设为 public。任何只是实现细节的类型可以保持 internal,并且您可以使用 InternalsVisibleToAttribute 将它们对所有程序集可见,同时使它们对其他人不可访问。

JIT 编译

影响 JIT 编译器生成代码的几个属性。您可以将MethodImplAttribute应用于方法,并传递Met⁠hod​Imp⁠lOp⁠tions枚举中的值。其NoInlining值确保每当其他方法调用您的方法时,它将成为完整的方法调用。如果没有这个选项,JIT 编译器有时会直接将方法的代码复制到调用代码中。

一般来说,您应该保留内联功能。JIT 编译器仅内联小方法,对于诸如属性访问器之类的简单小方法尤为重要。对于基于字段的简单属性,通过普通函数调用调用访问器通常需要更多代码,而内联优化则可能产生更小、更快的代码。即使代码大小没有减小,它仍然可能更快,因为函数调用可能会意外地昂贵。现代 CPU 倾向于更有效地处理长顺序指令流,而不是跳跃执行位置的代码。然而,内联是一个带有可观测副作用的优化——内联方法不会得到自己的堆栈帧。之前我提到过一些诊断 API,您可以使用它们来检查堆栈,内联将改变报告的堆栈帧数量。如果您只想询问“是哪个方法在调用我?”,则前面描述的调用者信息属性提供了一种更有效的发现方法,不会受内联的影响,但如果您有检查堆栈的代码,有时可能会因内联而混淆。因此,偶尔禁用它是有用的。

相反,您可以指定 AggressiveInlining,这鼓励 JIT 编译器内联那些通常作为普通方法调用的内容。如果您确定某个方法对性能非常敏感,尝试这个设置可能会产生不同效果,尽管请注意它可能使代码变慢或变快——这取决于具体情况。相反,您可以使用 NoOptimization 选项禁用所有优化(尽管文档暗示这更多是为了微软的 CLR 团队而不是为了消费者,因为它是为了“调试可能的代码生成问题”)。

另一个对优化产生影响的属性是 DebuggableAttribute。在调试版本中,C# 编译器会自动将此属性应用到您的程序集中。该属性告诉 CLR 在某些优化方面要更加谨慎,特别是那些影响变量生命周期和代码执行顺序的优化。通常情况下,编译器可以自由更改这些内容,只要最终代码的结果相同即可,但是如果您在调试器中打断点,可能会引起混乱。这个属性确保在这种情况下变量值和执行流程容易跟踪。

STAThread 和 MTAThread

只运行在 Windows 上并呈现 UI 的应用程序(例如使用 .NET 的 WPF 或 Windows Forms 框架的任何应用程序)通常在它们的 Main 方法上有 [STAThread] 属性(尽管您不总是看到它,因为入口点通常由这些应用程序的构建系统生成)。这是对 CLR 的互操作服务的一条指令,用于组件对象模型 (COM),但它有更广泛的影响:如果您希望主线程托管 UI 元素,则需要在 Main 方法上放置此属性。

各种 Windows UI 功能都依赖于 COM。例如,剪贴板使用它,某些类型的控件也是如此。COM 有几种线程模型,只有一种与 UI 线程兼容。其中一个主要原因是 UI 元素具有线程关联性,因此 COM 需要确保在正确的线程上执行某些工作。此外,如果 UI 线程不定期检查消息并处理它们,则可能发生死锁。如果您不告诉 COM 特定线程是 UI 线程,它将省略这些检查,您将遇到问题。

注意

即使你不编写 UI 代码,某些互操作场景需要 [STAThread] 属性,因为某些 COM 组件在没有它的情况下无法工作。不过,UI 工作是看到它的最常见原因。

由于 CLR 为您管理 COM,CLR 需要知道它应告诉 COM 特定线程需要作为 UI 线程处理。当您显式地使用 Chapter 16 中显示的技术创建新线程时,可以配置其 COM 线程模式,但主线程是一个特例 —— CLR 在应用程序启动时为您创建它,当您的代码运行时,配置线程已经太迟了。在 Main 方法上放置 [STAThread] 属性告诉 CLR 您的主线程应初始化为兼容 UI 的 COM 行为。

STA 是 单线程单元 的缩写。参与 COM 的线程总是属于 STA 或 多线程单元 (MTA) 中的一种。还有其他类型的单元,但线程只能在其中暂时成员;当线程开始使用 COM 时,必须选择 STA 或 MTA 模式。因此,也有一个 [MTAThread] 属性。

互操作

CLR 的互操作服务定义了许多属性。其中大多数由 CLR 直接处理,因为互操作是运行时的内在特性。由于这些属性只在它们支持的机制上下文中有意义,并且数量众多,我在这里不会详细描述它们,但 Example 14-19 展示了它们可以做的事情类型。

Example 14-19. 互操作属性
[DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true,
 EntryPoint = "LookupPrivilegeValueW")]
internal static extern bool LookupPrivilegeValue(
 [MarshalAs(UnmanagedType.LPWStr)] string lpSystemName,
 [MarshalAs(UnmanagedType.LPWStr)] string lpName,
    out LUID lpLuid);

这里使用了我们之前在示例 14-7 中看到的两个互操作属性,但使用方式稍显复杂。这调用了advapi32.dll中的一个函数,它是 Win32 API 的一部分。DllImport 属性的第一个参数告诉我们这一点,但与之前的示例不同,这还提供了互操作层的额外信息。这个 API 处理字符串,所以互操作需要知道使用的字符表示法。这个特定的 API 使用了一个常见的 Win32 习惯:它返回一个布尔值以指示成功或失败,但在失败情况下它还使用了 Windows 的 SetLastError API 提供更多信息。属性的 SetLastError 属性告诉互操作层在调用此 API 后立即检索这些信息,以便 .NET 代码在必要时进行检查。EntryPoint 属性处理了 Win32 API 可能有两种形式的字符串参数的事实,可以使用 8 位或 16 位字符(Windows 95 仅支持 8 位文本以节省内存),而我们希望调用Wide形式(因此使用 W 后缀)。然后,在两个字符串参数上使用 MarshalAs 来告诉互操作层,非托管代码中的许多不同字符串表示中,这个特定的 API 需要哪一种。

定义和使用属性

绝大多数属性并非运行时或编译器本身固有的,而是由类库定义的,并且只有在使用相关的类库或框架时才会起作用。在你自己的代码中,你可以自由地做完全相同的事情——你可以定义自己的属性类型。因为属性本身不会自行执行任何操作——除非有什么东西要求查看它们,它们甚至不会被实例化——所以通常只在你编写某种类型的框架时定义属性类型才有用,尤其是那些受反射驱动的框架。

例如,单元测试框架经常通过反射发现你编写的测试类,并允许你使用属性控制测试运行程序的行为。另一个例子是 Visual Studio 如何利用反射发现设计表面上可编辑对象(如 UI 控件)的属性,并且它将查找某些属性,这些属性使你可以自定义编辑行为。属性的另一个应用是选择性地排除静态代码分析工具应用的规则。(.NET SDK 包含用于检测代码潜在问题的内置工具。这是一个可扩展的系统,NuGet 包可以添加扩展分析器,扩展这些工具,从而检测特定库可能出现的常见错误。)有时这些工具会出错,你可以通过用属性标注代码来抑制它们的警告。

这里的共同主题是,某些工具或框架会检查你的代码,并根据发现的内容决定要做什么。这种情况正是属性非常适合的场景。例如,如果你编写一个允许最终用户扩展的应用程序,属性可能会很有用。你可能支持加载扩展你应用程序行为的外部程序集——这通常被称为插件模型。定义一个允许插件提供有关自身描述信息的属性可能会很有用。使用属性并不是绝对必要的——你可能会定义至少一个接口,所有插件都必须实现该接口,并且可以在该接口中定义用于检索必要信息的成员。然而,使用属性的一个优点是,你不需要创建插件的实例来检索描述信息。这样可以在加载插件之前向用户显示插件的详细信息,如果构建插件可能会产生用户不想要的副作用,这可能很重要。

属性类型

示例 14-20 展示了一个包含有关插件信息的属性可能是什么样子。

示例 14-20. 一个属性类型
[AttributeUsage(AttributeTargets.Class)]
public class PluginInformationAttribute : Attribute
{
    public PluginInformationAttribute(string name, string author)
    {
        Name = name;
        Author = author;
    }

    public string Name { get; }

    public string Author { get; }

    public string? Description { get; set; }
}

要作为属性,类型必须派生自Attribute基类。虽然Attribute定义了各种静态方法来发现和检索属性,但对于实例来说并没有提供太多有趣的内容。我们并不是从中派生出任何特定功能;我们这样做是因为编译器不会让你使用一个类型作为属性,除非它派生自Attribute

注意到我的类型名称以Attribute一词结尾。这不是绝对要求,但这是一个被广泛使用的约定。正如你之前看到的,即使在应用属性时忘记添加Attribute后缀,编译器也会自动添加。因此,通常没有理由不遵循这个约定。

我已经用一个属性注释了我的属性类型。大多数属性类型都使用AttributeUsageAttribute进行注释,指示属性可以有用地应用到哪些目标上。C#编译器将强制执行这一点。由于我在示例 14-20 中的属性声明只能应用于类,如果有人尝试将其应用于其他任何东西,编译器将生成错误。

注意

正如您所见,有时候当我们应用一个属性时,我们需要说明它的目标。例如,当一个属性出现在方法之前时,它的目标就是该方法,除非您用return:前缀加以限定。您可能希望在使用只能针对特定成员的属性时,能够省略这些前缀。例如,如果一个属性只能应用于程序集,您真的需要assembly:限定符吗?然而,C# 不允许您省略它。它仅使用AttributeUsageAttribute来验证属性未被错误应用。

我的属性只定义了一个构造函数,因此任何使用它的代码都必须传递构造函数所需的参数,就像示例 14-21 所示。

Example 14-21. 应用属性
[PluginInformation("Reporting", "Endjin Ltd.")]
public class ReportingPlugin
{
    ...
}

属性类可以自由地定义多个构造函数重载,以支持不同的信息集。它们还可以定义属性作为支持可选信息的一种方式。我的属性定义了一个Description属性,这不是必需的,因为构造函数不要求为其提供值,但我可以使用本章前面描述的语法来设置它。示例 14-22 展示了我属性的样子。

Example 14-22. 为属性提供可选的属性值
[PluginInformation("Reporting", "Endjin Ltd.", `Description = "Automated report generation")]`
public class ReportingPlugin
{
    ...
}

到目前为止,我展示的内容不会创建Plu⁠gin⁠Inf⁠orm⁠ation​Att⁠rib⁠ute类型的实例。这些注解只是指示,告诉属性在需要时应如何初始化。因此,如果要使用此属性,我需要编写一些代码来查找它。

获取属性

您可以使用反射 API 来发现特定类型的属性是否已经应用,并且反射 API 也可以为您实例化该属性。在第十三章中,我展示了表示各种属性目标的反射类型,例如MethodInfoTypePropertyInfo。它们都实现了一个名为ICustomAttributeProvider的接口,如示例 14-23 所示。

Example 14-23. ICustomAttributeProvider
public interface ICustomAttributeProvider
{
    object[] GetCustomAttributes(bool inherit);
    object[] GetCustomAttributes(Type attributeType, bool inherit);
    bool IsDefined(Type attributeType, bool inherit);
}

IsDefined方法仅告诉您特定属性类型是否存在,而不会实例化它。两个GetCustomAttributes重载会创建属性并返回它们。(这是属性被构造以及设置任何注解指定的属性的时机。)第一个重载返回应用于目标的所有属性,而第二个重载允许您仅请求特定类型的属性。

所有这些方法都接受一个bool参数,让您可以指定是否仅希望获取直接应用于您正在检查的目标的属性,或者还包括应用于基类型或类型的属性。

这个接口在 .NET 1.0 中引入,因此它不使用泛型,这意味着您需要对返回的对象进行强制转换。幸运的是,Cus⁠tom⁠Att⁠rib⁠ute​Ext⁠ens⁠ions 静态类定义了几个扩展方法。它并不是为 ICustomAttributeProvider 接口定义这些方法,而是扩展了提供属性的反射类。例如,如果您有一个 Type 类型的变量,您可以在其上调用 GetCustomAttribute<PluginInformationAttribute>() 方法,这将构造并返回插件信息属性,如果该属性不存在,则返回 null。示例 14-24 使用此方法显示了特定文件夹中所有 DLL 的所有插件信息。

示例 14-24. 显示插件信息
static void ShowPluginInformation(string pluginFolder)
{
    var dir = new DirectoryInfo(pluginFolder);
    foreach (FileInfo file in dir.GetFiles("*.dll"))
    {
        Assembly pluginAssembly = Assembly.LoadFrom(file.FullName);
        var plugins =
             from type in pluginAssembly.ExportedTypes
             `let` `info` `=` `type``.``GetCustomAttribute``<``PluginInformationAttribute``>``(``)`
             where info != null
             select new { type, info };

        foreach (var plugin in plugins)
        {
            Console.WriteLine($"Plugin type: {plugin.type.Name}");
            Console.WriteLine(
                $"Name: {plugin.info.Name}, written by {plugin.info.Author}");
            Console.WriteLine($"Description: {plugin.info.Description}");
        }
    }
}

这样做可能会存在一个潜在问题。我之前说过属性的一个好处是,可以在不实例化它们的目标类型的情况下检索它们。在这里是正确的 —— 我没有在 示例 14-24 中构造任何插件。然而,我正在加载插件程序集,枚举插件的一个可能副作用是运行插件 DLL 中的静态构造函数。因此,虽然我没有故意在这些 DLL 中运行任何代码,但我不能保证这些 DLL 中的代码不会运行。如果我的目标是向用户呈现插件列表,并且仅加载和运行明确选择的插件,那么我已经失败了,因为我给插件代码一个运行的机会。不过,我们可以解决这个问题。

仅加载元数据(Metadata-Only Load)

您不需要完全加载一个程序集才能检索属性信息。正如我在 第十三章 中讨论的那样,您可以仅为反射目的加载程序集,使用 MetadataLoadContext 类来实现。这样可以防止程序集中的任何代码运行,但可以检查其包含的类型。不过,这对属性造成了挑战。通常检查属性的属性的方法是通过调用 GetCustomAttributes 或相关的扩展方法来实例化它。由于这涉及构造属性 —— 也就是运行某些代码 —— 对于由 MetadataLoadContext 加载的程序集是不支持的(即使涉及的属性类型是在以正常方式完全加载的不同程序集中定义的)。如果我修改了 示例 14-24 来使用 Met⁠ada⁠ta​Loa⁠dCo⁠ntext 加载程序集,调用 Get⁠Cus⁠tom⁠Att⁠rib⁠ute⁠<Pl⁠ugi⁠nIn⁠for⁠mat⁠ion​Att⁠rib⁠ute> 将会抛出异常。

当仅加载元数据时,您必须使用GetCustomAttributesData方法。这不会为您实例化属性,而是返回存储在元数据中的信息——用于创建属性的指令。Example 14-25 展示了从 Example 14-24 修改为使用这种方式的相关代码版本。(还包括初始化MetadataLoadContext所需的代码。)

Example 14-25. 使用MetadataLoadContext检索属性
string[] runtimeAssemblies = Directory.GetFiles(
    RuntimeEnvironment.GetRuntimeDirectory(), "*.dll");
var paths = new List<string>(runtimeAssemblies);
paths.Add(file.FullName);

var resolver = new PathAssemblyResolver(paths);
var mlc = new MetadataLoadContext(resolver);

Assembly pluginAssembly = mlc.LoadFromAssemblyPath(file.FullName);
var plugins =
     from type in pluginAssembly.ExportedTypes
     `let` `info` `=` `type``.``GetCustomAttributesData``(``)``.``SingleOrDefault``(``attrData` `=``>`
            `attrData``.``AttributeType``.``FullName` `=``=` `pluginAttributeType``.``FullName``)`
     where info != null
     let description = info.NamedArguments
                           .SingleOrDefault(a => a.MemberName == "Description")
     select new
     {
         type,
         Name = (string) info.ConstructorArguments[0].Value,
         Author = (string) info.ConstructorArguments[1].Value,
         Description =
             description == null ? null : description.TypedValue.Value
     };

foreach (var plugin in plugins)
{
    Console.WriteLine($"Plugin type: {plugin.type.Name}");
    Console.WriteLine($"Name: {plugin.Name}, written by {plugin.Author}");
    Console.WriteLine($"Description: {plugin.Description}");
}

代码非常繁琐,因为我们没有得到属性的实例。GetCustomAttributesData返回一个CustomAttributeData对象的集合。Example 14-25 使用 LINQ 的SingleOrDefault操作符来查找PluginInformationAttribute的条目,如果存在,查询中的info变量将持有与相关CustomAttributeData对象的引用。然后,代码通过ConstructorArgumentsNamedArguments属性逐步选择构造函数参数和属性值,从而能够检索嵌入在属性中的三个描述性文本值。

正如这个例子所示,MetadataLoadContext增加了复杂性,因此只有在需要它提供的好处时才应该使用它。其中一个好处是它不会运行您加载的任何程序集。它还可以加载通常会被拒绝加载的程序集(例如,因为它们针对的特定处理器架构与您的进程不匹配)。但是,如果您不需要仅仅是元数据选项,直接访问属性,如 Example 14-24 所示,会更加方便。

总结

属性为将自定义数据嵌入程序集元数据提供了一种方法。您可以将属性应用于类型、类型的任何成员、参数、返回值,甚至整个程序集或其模块之一。一些属性由 CLR 特别处理,还有一些控制编译器功能,但大多数属性没有固有行为,仅充当被动信息容器。除非有人要求查看它们,否则属性甚至不会被实例化。所有这些使得属性在反射驱动行为的系统中最为有用——如果您已经有了反射 API 对象,比如ParameterInfoType,您可以直接向其询问属性。因此,您最常见到的是在检查您的代码的框架中使用属性,比如单元测试框架、序列化框架、像 Visual Studio 属性面板这样的数据驱动 UI 元素,或者插件框架。如果您使用这类框架,通常可以通过用框架识别的属性注解代码来配置其行为。如果您正在编写这种类型的框架,那么定义自己的属性类型可能是有意义的。

第十五章:文件和流

到目前为止,在本书中我展示的大多数技术都围绕着存在于对象和变量中的信息。这种状态存储在特定进程的内存中,但为了实用,程序必须与更广泛的世界进行交互。这可能通过 UI 框架实现,但有一种特定的抽象可以用于与外部世界的许多种交互:

流在计算中被如此广泛使用,以至于你无疑已经对它们非常熟悉了。在大多数其他编程系统中,.NET 流与它们基本相同:它只是一系列字节。这使得流对于许多常见功能非常有用,比如磁盘上的文件或 HTTP 响应的主体。控制台应用程序使用流来表示其输入和输出。如果你以交互方式运行这样的程序,用户在键盘上输入的文本成为程序的输入流,程序写入其输出流的任何内容都会显示在屏幕上。但程序不一定知道它的输入或输出类型——你可以重定向这些流以用于控制台程序。例如,输入流实际上可以提供磁盘上文件的内容,或者甚至可以是其他程序的输出。

注意

并非所有的 I/O API 都是基于流的。例如,除了输入流之外,Console 类还提供了一个 ReadKey 方法,可以准确地返回哪个按键被按下,但这仅在输入来自键盘时有效。因此,虽然你可以编写不关心输入是交互式还是来自文件的程序,但有些程序更为挑剔。

流 API 向你提供原始的字节数据。然而,你也可以在不同的层次上操作。例如,有些面向文本的 API 可以包装底层流,这样你可以处理字符或字符串,而不是原始字节。还有各种序列化机制,允许你将.NET 对象转换为流表示,稍后可以将其转换回对象,从而可以持久保存对象的状态或将其状态发送到网络上。我稍后会展示这些更高级的 API,但首先让我们看看流抽象本身。

Stream 类

Stream类定义在System.IO命名空间中。它是一个抽象基类,具有具体派生类型,如FileStreamGZipStream,代表特定类型的流。示例 15-1 展示了Stream类的三个最重要成员。它还有其他几个成员,但这些是抽象的核心。(稍后您将看到,还有ReadWrite的异步版本。 .NET Core 3.1 和.NET 还提供了使用第十八章中描述的span类型之一替代数组的重载版本。本节中关于这些方法的所有内容也适用于异步和基于 span 的形式。)

Example 15-1. Stream的最重要成员
public abstract int Read(byte[] buffer, int offset, int count);
public abstract void Write(byte[] buffer, int offset, int count);
public abstract long Position { get; set; }

一些流是只读的。例如,当控制台应用程序的输入流表示键盘或其他程序的输出时,程序无法向该流写入有意义的内容。(即使使用输入重定向运行具有文件输入的控制台应用程序,输入流也是只读的。)一些流是只写的,例如控制台应用程序的输出流。如果在只写流上调用Read方法或在只读流上调用Write方法,则这些方法会抛出NotSupportedException异常。

Tip

Stream类定义了各种描述流能力的bool属性,因此您不必等到出现异常才能知道流的类型。您可以检查CanReadCanWrite属性。

ReadWrite都以byte[]数组作为它们的第一个参数,并分别将数据复制到该数组中或从该数组中复制数据出来。随后的offsetcount参数指示从数组的哪个元素开始,以及要读取或写入的字节数;您不必使用整个数组。请注意,没有参数指定要在流中的偏移量处读取或写入。这由Position属性管理 —— 它从零开始,但每次读取或写入时,位置会根据处理的字节数前进。

注意,Read方法返回一个int值。这告诉您从流中读取了多少字节 —— 该方法不保证提供您请求的数据量。这样做的一个明显原因是您可能已经达到流的末尾,因此即使您要求将 100 个字节读入数组中,当前Position和流的末尾之间可能只剩下 30 个字节的数据。然而,并不是这唯一可能导致您获取少于请求数据的原因,这经常会让人摸不着头脑,因此为了那些快速浏览本章节的人,我会做出一个警告。

Warning

如果你一次请求多个字节,Stream 可以出于任何原因返回少于你从 Read 请求的数据。你不应该假设调用 Read 返回了它所能返回的所有数据量,即使你有足够的理由知道你请求的数据量是可用的。

Read 稍微复杂的原因在于,某些流是实时的,代表程序运行时逐渐产生数据的信息源。例如,如果一个控制台应用程序在交互运行,其输入流只能在用户键入时提供数据;表示通过网络连接接收的数据的流只能在数据到达时提供数据。如果你调用 Read 并请求的数据超过当前可用的数据量,流可能会等待直到它有你请求的那么多数据,但它不必这样做——它可能会立即返回任何它当前已经有的数据。(它在返回之前必须等待的唯一情况是,如果它当前没有任何数据但尚未到达流的末尾。它必须返回至少一个字节,因为返回值 0 表示流的末尾。)如果你想确保读取特定数量的字节,你需要检查 Read 是否返回少于你想要的字节数,并在必要时继续调用它,直到你得到所需的数据。示例 15-2 展示了如何做到这一点。

示例 15-2. 读取特定数量的字节
static int ReadAll(Stream s, byte[] buffer, int offset, int length)
{
    if ((offset + length) > buffer.Length)
    {
        throw new ArgumentException("Buffer too small to hold requested data");
    }

    int bytesReadSoFar = 0;
    while (bytesReadSoFar < length)
    {
        int bytes = s.Read(
            buffer, offset + bytesReadSoFar, length - bytesReadSoFar);
        if (bytes == 0)
        {
            break;
        }
        bytesReadSoFar += bytes;
    }

    return bytesReadSoFar;
}

注意,这段代码检查了从 Read 返回的 0 值以检测流的末尾。如果没有这样的检查,如果在读取了被请求的数据量之前达到了流的末尾,它将永远循环下去。这意味着如果我们确实到达了流的末尾,这个方法将必须提供比调用者请求的数据少的数据量,因此这似乎并没有真正解决问题。然而,这确实排除了在没有到达流的末尾的情况下获得少于请求的数据量的情况。(你可以更改方法,使其在到达流的末尾之前提供指定数量的字节时抛出异常。这样,如果方法返回,它保证返回的字节数正好是所请求的数量。)

Stream 提供了一个更简单的读取方法。ReadByte 方法返回一个单字节,除非你已达到流的末尾,在这种情况下它返回值 -1。(它的返回类型是 int,允许它返回任何可能的 byte 值以及负值。)这避免了只返回部分请求数据的问题,因为如果你得到任何返回,你总是得到确切的一个字节。然而,如果你想读取更大的数据块,这并不特别方便或高效。

Write方法没有任何这些问题。如果成功,它始终接受您提供的所有数据。当然,它可能会失败——可能会因为错误(例如,磁盘空间不足或网络连接丢失)而在成功写入所有数据之前抛出异常。

位置和寻址

每次读取或写入时,流会自动更新其当前位置。如您在示例 15-1 中所见,Position属性可以设置,因此您可以尝试直接移动到特定位置。这并不保证能够成功,因为并非总是可能支持它。例如,代表通过 TCP 网络连接接收的数据的Stream可以无限产生数据——只要连接保持打开且另一端继续发送数据,流将继续响应对Read的调用。连接可能保持打开多天,并在此期间接收到数 TB 的数据。如果这样的流允许您设置其Position属性,使您的代码能够返回并重新读取先前接收到的数据,则流必须找到存储每个接收到的字节的地方,以防万一代码希望再次查看它。由于这可能涉及存储比磁盘上有空间更多的数据,显然是不切实际的,因此某些流在尝试设置Position属性时会抛出NotSupportedException。(有一个CanSeek属性,您可以使用它来发现特定流是否支持更改位置,因此就像只读和只写流一样,您不必等到出现异常才能找出它是否有效。)

除了Position属性外,Stream还定义了一个Seek方法,其签名如示例 15-3 所示。这使您可以相对于流的当前位置指定所需的位置。(对于不支持寻址的流,这也会抛出NotSupportedException。)

示例 15-3. Seek方法
public abstract long Seek(long offset, SeekOrigin origin);

如果将SeekOrigin.Current作为第二个参数传递,它将通过将第一个参数添加到当前位置来设置位置。如果要向后移动,可以传递负的offset。还可以传递SeekOrigin.End将位置设置为距离流末尾指定的字节数。传递Seek​Ori⁠gin.Begin与仅设置Position具有相同的逻辑效果——它将相对于流的起始位置设置位置。

刷新

与其他编程系统上许多流 API 一样,在Stream中写入数据不一定会立即使数据到达目的地。当调用Write时,你只知道它已经将你的数据复制到某个地方;但那可能是内存中的缓冲区,而不是最终的目标。例如,如果你向代表存储设备上文件的流写入单个字节,流对象通常会推迟将其写入驱动器,直到有足够的字节使得这样做值得。存储设备是基于块的,意味着写入以固定大小的块发生,通常是几千字节大小,因此等到有足够的数据来填充一个块再进行写入是有意义的。

这种缓冲通常是件好事——它提高了写入性能,同时使你可以忽略磁盘工作的细节。然而,缺点是如果你偶尔写入数据(例如将错误消息写入日志文件时),你可能会在程序写入数据到流和数据到达磁盘之间遇到长时间的延迟。对于试图通过查看当前正在运行的程序日志文件来诊断问题的人来说,这可能会令人困惑。更隐秘的是,如果你的程序崩溃,流缓冲区中尚未到达存储设备的任何数据可能会丢失。

因此,Stream类提供了Flush方法。这让你告诉流,你希望它执行任何必要的工作,以确保任何缓冲数据都被写入到其目标,即使这意味着对缓冲区的使用不是最优的情况。

警告

使用FileStream时,Flush方法并不一定保证刷新的数据已经写入磁盘。它只是让流将数据传递给操作系统。在调用Flush之前,操作系统甚至还没有看到数据,因此如果突然终止进程,数据将会丢失。在Flush返回后,操作系统已经拥有你的代码已经写入的所有数据,因此可以在没有数据丢失的情况下终止进程。然而,操作系统可能会执行自己的额外缓冲,因此如果在操作系统开始将所有数据写入磁盘之前电源失败,数据仍可能丢失。如果需要确保数据已经持久写入(而不仅仅是确保你已经将数据交给操作系统),你还需要使用WriteThrough标志,描述在"FileStream Class"中,或者调用带有bool参数的Flush重载,传递true以强制刷新到存储设备。

调用 Dispose 时,流会自动刷新其内容。只有在希望在写出缓冲数据后保持流打开状态时,才需要使用 Flush。如果流在打开但不活动期间会有较长时间,这一点尤其重要。(如果流代表网络连接,并且如果您的应用程序依赖于及时数据传递 —— 例如在线聊天应用程序或游戏 —— 即使您期望只有相对短暂的不活动期间,也应调用 Flush。)

复制

有时将所有数据从一个流复制到另一个流会很有用。你可以轻易地编写一个循环来做到这一点,但你不必这样做,因为 Stream 类的 CopyTo 方法(或等效的 CopyToAsync)已经为你实现了这一功能。关于它,没有太多可以说的。我提到它的主要原因是,开发人员通常因为不知道 Stream 中内置了这个功能,所以会自己编写这个方法的版本。

长度

一些流能够通过名为 Length 的属性报告它们的长度。与 Position 一样,该属性的类型为 long —— Stream 使用 64 位数值,因为流通常需要比 2GB 更大的容量,如果使用 int 表示大小和位置,则会受到上限的限制。

Stream 还定义了一个 SetLength 方法,用于在支持的情况下定义流的长度。在向文件写入大量数据时,可以考虑使用此方法,以确保有足够的空间来容纳所有希望写入的数据 —— 最好在开始之前遇到 IOException,而不是浪费时间进行注定失败的操作,并可能通过使用所有可用的空闲空间来引起系统范围的问题。然而,许多文件系统支持稀疏文件,允许创建远远大于可用空间的文件,因此在实践中,您可能直到开始写入非零数据时才会看到任何错误。即使如此,如果指定的长度超出文件系统支持的长度,SetLength 将抛出 ArgumentException

并非所有流支持长度操作。Stream 类文档指出,Length 属性仅在支持 CanSeek 的流上可用。这是因为支持寻址的流通常是那些整个流内容在开始时就已知并且可访问的流。在内容在运行时生成的流上(例如代表用户输入的输入流或代表网络接收数据的流),通常也不会预先知道长度。至于 SetLength,文档说明仅在既支持写入又支持寻址的流上受支持。(与所有表示可选功能的成员一样,如果在不支持它们的流上尝试使用这些成员,LengthSetLength 将抛出 NotSupportedException。)

处置

一些流表示.NET 运行时外部的资源。例如,FileStream提供对文件内容的流访问,因此它需要从操作系统获取文件句柄。当你使用完毕后,关闭句柄非常重要;否则可能会阻止其他应用程序使用该文件。因此,Stream类实现了IDisposable接口(在第七章中描述),以便在必要时进行处理。正如我之前提到的,缓冲流如FileStream在调用Dispose之前会刷新它们的缓冲区,然后关闭句柄。

并非所有流类型都依赖于调用Dispose:例如,MemoryStream完全在内存中工作,因此 GC 可以负责处理它。但通常情况下,如果你创建了一个流,当你不再需要它时应该调用Dispose

注意

有些情况下,你会得到一个流,但并不是你的工作来处理它。例如,ASP.NET Core 可以提供流来表示 HTTP 请求和响应中的数据。它会为你创建这些流,并在你使用完之后进行处理,因此你不应该调用Dispose来处理它们。

令人困惑的是,Stream类也有一个Close方法。这是历史的偶然。在.NET 1.0 的第一个公共测试版发布时,并没有定义IDisposable,并且 C#也没有using语句——这个关键字仅用于using指令,用于将命名空间引入作用域。Stream类需要一种方式来知道何时清理其资源,但当时还没有一个标准的方法,所以它发明了自己的习惯用语。它定义了一个Close方法,这与其他编程系统中许多基于流的 API 使用的术语是一致的。在.NET 1.0 正式发布之前添加了IDisposable,并且Stream类增加了对其的支持,但保留了Close方法;如果移除它,将会影响到许多早期采用者,因为他们一直在使用测试版。但是,Close是多余的,并且文档明确建议不要使用它。文档说应该使用Dispose(通过using语句如果方便的话)。调用Close没有害处——它与Dispose之间没有实际区别——但Dispose是更常见的习惯用语,因此更为推荐。

异步操作

Stream 类提供了 ReadWrite 的异步版本。请注意有两种形式。Stream 首次出现在 .NET 1.0 中,因此它支持当时的标准异步机制,即异步编程模型(APM,见第十六章)。通过 BeginReadEndReadBeginWriteEndWrite 方法。此模型现已弃用,并已被较新的基于任务的异步模式(或 TAP,也见第十六章)所取代。Stream 通过其 ReadAsyncWriteAsync 方法支持此模式。还有两个操作最初没有任何异步形式,现在有了 TAP 版本:FlushAsyncCopyToAsync。(这些仅支持 TAP,因为 APM 在 Microsoft 添加这些方法时已经弃用。)

警告

避免使用基于旧的 APM 的 Begin/End 形式的 ReadWrite。在 .NET Core 的早期版本和 .NET Standard 2.0 之前,它们根本不存在。它们重新出现是为了更容易地将现有代码从 .NET Framework 迁移到 .NET Core,因此仅支持传统场景。

一些流类型利用非常高效的技术实现异步操作,直接对应于底层操作系统的异步能力。(FileStream 就是这样做的,以及.NET 可以提供的各种用于表示网络连接内容的流。) 您可能会遇到具有自定义流类型的库,这些流类型不会这样做,但即使如此,异步方法也会可用,因为基本的 Stream 类可以退回到使用多线程技术。

在使用异步读取和写入时需要注意的一点是,流只有一个 Position 属性。读取和写入取决于当前的 Position,并在完成时更新它,因此通常必须避免在已经开始的操作完成之前启动新的操作。但是,如果您希望从特定文件执行多个并发读取或写入操作,FileStream 对此有特殊处理。如果告诉它您将在异步模式下使用文件,则操作使用操作开始时 Position 的值,一旦异步读取或写入开始,您可以更改 Position 并启动另一个操作,而无需等待所有先前的操作完成。但这仅适用于 FileStream,且仅在文件以异步模式打开时。或者,可以使用稍后在本章描述的新 RandomAccess 类,而不是使用 FileStream

.NET Core 3.1 和 .NET 5.0 及更高版本提供了 IAsyncDisposable,这是 Dispose 的异步形式。Stream 类实现了这一点,因为处置通常涉及刷新,这是一种潜在的缓慢操作。

具体的流类型

Stream 类是抽象的,因此要使用流,你需要一个具体的派生类型。在某些情况下,这些类型将由框架提供给你——例如,ASP.NET Core Web 框架提供了表示 HTTP 请求和响应主体的流对象,客户端的 HttpClient 类会执行类似操作。但有时你需要自己创建一个流对象。本节描述了一些常用的从 Stream 派生的类型。

FileStream 类表示文件系统上的文件。我将在“文件和目录”中描述这一点。

MemoryStream 允许你在 byte[] 数组之上创建一个流。你可以取一个现有的 byte[] 并将其包装在 MemoryStream 中,或者你可以创建一个 MemoryStream,然后通过调用 Write(或异步等效)来填充数据。完成后,你可以通过调用 ToArrayGetBuffer 来检索填充的 byte[]。(ToArray 分配一个基于实际写入的字节数的新数组。GetBuffer 更有效率,因为它返回 MemoryStream 正在使用的底层数组,但除非写入恰好完全填满它,否则返回的数组通常会超过实际使用的部分,在末尾有一些未使用的空间。) 当你需要与需要流的 API 一起工作但由于某些原因没有流时,此类非常有用。例如,本章后面描述的大多数序列化 API 都与流一起工作,但你可能希望将其与某些以 byte[] 为单位的其他 API 结合使用。MemoryStream 允许你在这两种表示之间建立桥梁。

Windows 和 Unix 都定义了一种进程间通信(IPC)机制,通过流连接两个进程。Windows 将其称为 命名管道。Unix 也有一个同名的机制,但完全不同;不过它确实提供了类似于 Windows 命名管道的机制:域套接字。虽然 Windows 命名管道和 Unix 域套接字的具体细节不同,但在 .NET 中,从 PipeStream 派生的各种类提供了对两者的共同抽象。

BufferedStream 是从 Stream 派生出来的,但在其构造函数中还接受一个 Stream。它添加了一个缓冲层,如果你想在设计为更大操作最佳的流上执行小读取或写入操作,这将非常有用。(对于 FileStream,你不需要使用这个,因为它有其自己内置的缓冲机制。)

有各种流类型可以以某种方式转换其他流的内容。例如,DeflateStreamGZipStreamBrotliStream 实现了三种广泛使用的压缩算法。你可以将它们包装在其他流周围,以压缩写入底层流的数据,或者解压读取自底层流的数据。(这些只提供了最低级别的压缩服务。如果你想处理流行的 ZIP 格式,用于压缩文件包,请使用 ZipArchive 类。)还有一个称为 CryptoStream 的类,可以使用.NET 支持的各种加密机制之一加密或解密其他流的内容。

一个类型,多种行为

正如你现在所见,抽象基类 Stream 在各种场景中被广泛使用。可以说,这是一个有点过于泛化的抽象。例如,像 CanSeek 这样的属性告诉你,你所拥有的特定 Stream 是否可以以某种方式使用,这可能是一个潜在问题的症状,是一种被称为 代码异味 的示例。.NET 流并不是发明这种一刀切的方法——它早在 Unix 和 C 编程语言的标准库中就很流行了。问题在于,当编写处理 Stream 的代码时,你可能不知道正在处理的是什么类型的东西。

有许多不同的方法可以使用 Stream,但经常遇到三种使用样式:

  • 字节序列的顺序访问

  • 随机访问,假定具有高效的缓存机制

  • 访问设备或系统某些底层能力

正如你所知,不是所有的Stream实现都支持这三种模型——如果CanSeek返回false,那就排除了中间选项。但不太明显的是,即使这些属性表明某种能力可用,也不是所有流都同样高效地支持所有使用模型。

例如,我曾经参与过一个项目,该项目使用了一个库来访问云托管存储服务中的文件,并能用Stream对象表示这些文件。这看起来很方便,因为你可以将它们传递给任何可以处理Stream的 API。然而,它的设计非常适合前述列表中的第三种使用方式:每次调用Read(或ReadAsync)都会导致该库向存储服务发出 HTTP 请求。最初我们希望能够将其与另一个能够解析 Parquet 文件(一种广泛用于大容量数据处理的二进制表格数据存储格式)的库一起使用。然而,事实证明该库期望的是支持第二种访问方式的流:它会在文件中前后跳跃,进行大量相对较小的读取。它与我稍后将要描述的FileStream类型完美配合,因为后者很好地支持了前两种使用模式。(对于第二种模式,它依赖于操作系统进行缓存。)但是,直接将来自存储服务库的Stream直接插入 Parquet 解析库将会导致性能灾难。

当你遇到这种类型的不匹配时,往往并不明显。在这个例子中,像CanSeek这样的属性报告能力并没有暗示会有问题。而使用 Parquet 文件的应用程序通常使用某种远程存储服务,而不是本地文件系统,因此没有明显的理由认为该库会假设任何Stream都会提供类似本地文件系统的缓存。当我们尝试时,技术上它确实能够工作:存储库的Stream努力做到了一切所需,并且代码最终是正确的......但是要记住,每当你使用Stream时,确保你完全理解它将被应用的访问模式及其对这些模式的高效支持是非常重要的。

在某些情况下,你可能能够弥合这种差距。BufferedStream类通常可以接受仅设计用于前述第三种使用方式的Stream并使其适应第一种使用方式。然而,在运行库中没有任何内容可以为不本能地支持第二种使用方式的Stream添加对其的支持。(通常只有代表已完全在内存中或包装某些本地 API(例如操作系统文件系统 API)进行缓存的流才可用。)在这些情况下,你将需要重新考虑你的设计(例如,制作Stream内容的本地副本),更改Stream的使用方式,或编写某种自定义缓存适配器。(最终,我们编写了一个适配器,通过增加BufferedStream的能力,仅添加了足够的随机访问缓存来解决性能问题。)

无须Stream的随机访问和分散/聚集 I/O

.NET 6.0System.IO 命名空间中新增了一个新类:RandomAccess。它可以在不使用 Stream 的情况下进行文件读写操作。它可以简化需要从单个文件执行多个并发读取的场景。它还可以执行跨越不是单个连续内存块的数据的单个读取或写入操作,利用底层操作系统处理此类读写的高效能力。

要使用 RandomAccess,必须使用 .NET 6.0 中新增的 File 类的 OpenHandle 方法打开文件,该方法返回 SafeFileHandle,它是围绕操作系统文件句柄的一次性包装。可以将其传递给 RandomAccess 提供的各种 ReadReadAsyncWriteWriteAsync 静态方法。所有的读取和写入方法都要求你传递文件内的偏移量,这与 Stream 不同,后者会为你记住当前的 Position。每个方法都传递偏移量的优势在于,它避免了执行多个并发操作时出现的问题,正如前面描述的。示例 15-4 使用此方法直接从 Windows 的 .exe 文件中读取数据。注意,与 Stream 类似,读取操作可能获取比请求的数据量少,因此在需要读取特定字节数的情况下,你需要编写一个循环,以确保获取所需量的数据。

示例 15-4. 使用 RandomAccess 从文件中读取数据
static void ReadAll(SafeFileHandle fh, Span<byte> buffer, long offset)
{
    int soFar = 0;
    do
    {
        int read = RandomAccess.Read(fh, buffer[soFar..], offset + soFar);
        if (read == 0)
        {
            throw new InvalidOperationException(
                "Reached end of file before filling buffer");
        }
        soFar += read;
    } while (soFar < buffer.Length);
}

var stubSignature = new byte[2];
ReadAll(fh, stubSignature, 0);
if (stubSignature[0] != (byte)'M' || stubSignature[1] != (byte)'Z')
{
    Console.WriteLine("No 'MZ' at start of file - not an EXE file");
}

此示例仅执行单个读取以说明用法,但更复杂的示例可以自由执行多个并发读取,可以在多个线程上或使用 ReadAsync 方法。

这里的缓冲区以 Span<byte> 形式传递;Write 方法使用 ReadOnlySpan<byte>。异步形式则分别接受 Memory<byte>ReadOnlyMemory<byte>。这些类型表示内存区域——通常是数组,但不一定。它们在第十八章中均有详细描述。

每个方法还提供了接受相关类型列表(例如IReadOnlyList<Memory<byte>>IReadOnlyList<ReadOnlyMemory<byte>>等)的重载,以支持scatter/gather 读取或写入。这些操作中,单次读取或写入跨越多个内存块。如果要写出到文件的数据分布在多个内存区域中(例如因为所涉及的数据是通过对外部服务进行多次请求获得的),你可以执行单个写入操作,传入所有要写入的内存块列表。这比执行多个单独的写入要高效得多——操作系统可以直接处理这种 I/O,而在许多情况下,底层磁盘控制器硬件能够将散布在内存中的数据聚合成单个磁盘操作——它将散落在内存中的数据汇总起来,因此得名。同样的操作也适用于读取:你可以从文件中读取一块数据,并将其分布到多个目标缓冲区中。

文本导向类型

StreamRandomAccess 类是面向字节的,但通常需要处理包含文本的文件。如果要处理存储在文件中(或通过网络接收到的)文本,使用基于字节的 API 是很麻烦的,因为这会迫使你显式处理所有可能发生的变化。例如,有多种约定来表示行的结束——Windows 通常使用值为1310的两个字节,许多互联网标准如 HTTP 也是如此,但类 Unix 系统通常只使用值为10的单个字节。

目前也有多种流行的字符编码。有些文件每个字符使用一个字节,有些使用两个字节,还有一些使用可变长度编码。也有许多不同的单字节编码,因此,如果在文本文件中遇到字节值,例如163,除非知道使用的编码方式,否则无法知道其含义。

在使用单字节 Windows-1252 编码的文件中,值163代表英镑符号:£。¹ 但如果文件采用 ISO/IEC 8859-5 编码(设计用于使用西里尔字母的地区),同样的代码表示西里尔大写字母 DJE:Ђ。而如果文件使用 UTF-8 编码,值163只能作为多字节序列的一部分,代表一个单一字符。

当然,了解这些问题是任何开发者技能集的重要组成部分,但这并不意味着每次遇到文本都要处理每一个小细节。因此,.NET 定义了专门的抽象来处理文本。

TextReader 和 TextWriter

抽象的 TextReaderTextWriter 类将数据表示为一系列 char 值。从逻辑上讲,这些类类似于流,但序列中的每个元素是一个 char 而不是一个 byte。然而,在细节上有一些区别。首先,有读和写的分离抽象。Stream 结合了这些功能,因为通常希望对单个实体进行读/写访问,特别是如果流表示磁盘上的文件。对于面向字节的随机访问,这是有意义的,但对于文本来说,这是一个问题的抽象。

变长编码使得支持随机写访问变得棘手(即能够在序列的任何点更改值)。考虑一下将一个 1 GB 的 UTF-8 文本文件的第一个字符 替换为£的意义。在UTF8中, 替换为 £ 的意义。在 UTF-8 中, 字符只需要一个字节,但 £ 需要两个字节,因此更改第一个字符将需要在文件开头插入一个额外的字节。这意味着需要将剩余的文件内容——几乎 1 GB 的数据——向后移动一个字节。

即使只是只读的随机访问也相对昂贵。在一个 UTF-8 文件中找到第一百万个字符需要读取前 999,999 个字符,因为没有这样做,您无法知道其中包含的单字节和多字节字符的混合情况。第一百万个字符可能从第一百万个字节开始,但也可能从第四百万个字节开始,或者介于两者之间的任何位置。由于支持带有可变长度文本编码的随机访问是昂贵的,特别是对于可写数据,因此这些基于文本的类型不提供此功能。没有随机访问,将读者和写者合并为一个类型没有真正的好处。另外,将读者和写者类型分开消除了检查 CanWrite 属性的需要——您知道可以写入,因为您有一个 TextWriter

TextReader 提供了几种读取数据的方式。最简单的是零参数重载的 Read 方法,它返回一个 int。如果已经到达输入的末尾,它将返回 −1,否则将返回一个字符值。(在确认非负后,您需要将其转换为 char。)此外,还有两种看起来类似于 Stream 类的 Read 方法的方法,正如 示例 15-5 所示。

示例 15-5. TextReader 块读取方法
public virtual int Read(char[] buffer, int index, int count) {...}
public virtual int ReadBlock(char[] buffer, int index, int count) {...}

就像Stream.Read一样,这些方法接受一个数组,以及数组中的索引和计数,并尝试读取指定数量的值。与Stream最明显的区别是,这些方法使用char而不是byte。但是ReadReadBlock有什么区别呢?嗯,ReadBlock解决了我在 Example 15-2 中必须为Stream手动解决的问题:虽然Read可能返回比请求的字符数少,但ReadBlock不会在达到请求的字符数或到达内容结尾之前返回。

处理文本输入的挑战之一是处理各种行结束的约定,而TextReader可以使你免受这些影响。它的ReadLine方法读取整行输入并将其作为一个string返回。该字符串不包括行尾的字符。

注意

TextReader并不假定特定的行结束约定。它接受回车符(字符值13,在字符串字面量中写作\r)或换行符(10,或\n)。如果这两个字符相邻出现,该字符对被视为单个行结束,尽管实际是两个字符。此处理仅在使用ReadLineRead​Li⁠neAsync时发生。如果直接使用ReadReadBlock在字符级别操作,你将看到行结束字符的确切形式。

TextReader还提供了ReadToEnd方法,它会将输入完全读取并作为一个单独的string返回。最后,还有Peek方法,与单参数的Read方法相同,但不会改变阅读器的状态。它允许你查看下一个字符而不消耗它,所以下次调用PeekRead时,它将再次返回相同的字符。

至于TextWriter,它提供了两个重载方法用于写入:WriteWriteLine。每个方法都为所有内置的值类型(boolintfloat等)提供了重载。从功能上讲,该类本可以只使用一个接受object参数的重载方法,因为它可以直接调用参数的ToString方法,但是这些专门的重载方法使得可以避免装箱。TextWriter还提供了一个Flush方法,原因与Stream提供的相同。

默认情况下,TextWriter将使用操作系统的默认行结束序列。在 Windows 上是\r\n序列(先13,然后10)。在 Linux 上,每行末尾只有一个\n。你可以通过设置写入器的NewLine属性来更改这一行为。

这两个抽象类都实现了IDisposable接口,因为一些具体的派生文本阅读器和写入器类型是对其他可释放资源的包装。

Stream 一样,这些类提供了其方法的异步版本。与 Stream 不同的是,这是一个相当近期的增加,因此它们仅支持在 第十六章 中描述的基于任务的模式,可以使用 第十七章 中描述的 await 关键字消费。

具体的读取器和写入器类型

Stream 类似,.NET 中的各种 API 将向您提供 TextReaderTextWriter 对象。例如,Console 类定义了 InOut 属性,用于提供对进程输入和输出流的文本访问。虽然我之前没有描述过这些,但我们已经在隐式地使用它们——Console.WriteLine 方法的重载只是为您调用 Out.WriteLine 的包装器。同样,Console 类的 ReadReadLine 方法只是简单地转发到 In.ReadIn.ReadLine。还有一个 Error,另一个用于将输出写入标准错误输出流的 TextWriter。但是,有一些直接派生自 TextReaderTextWriter 的具体类,您可能希望直接实例化它们。

StreamReaderStreamWriter

可能最有用的具体文本读取器和写入器类型是 StreamReaderStreamWriter,它们包装了一个 Stream 对象。您可以将 Stream 作为构造函数参数传递,或者只需传递包含文件路径的字符串,它们将自动为您构造一个 FileStream 然后包装它。示例 15-6 使用此技术向文件写入一些文本。

示例 15-6. 使用 StreamWriter 向文件写入文本
using (var fw = new StreamWriter(@"c:\temp\out.txt"))
{
    fw.WriteLine($"Writing to a file at {DateTime.Now}");
}

提供了多种构造函数重载,以提供更精细的控制。当传递一个字符串以便使用 StreamWriter(而不是您已经获得的某个 Stream)时,可以选择性地传递一个 bool,指示是否从头开始或者追加到已存在的文件(传递 true 启用追加)。如果不传递此参数,则不使用追加,并且写入将从头开始。您还可以指定编码。默认情况下,StreamWriter 将使用没有字节顺序标记(BOM)的 UTF-8,但可以传递从 Encoding 类派生的任何类型,该类在 “编码” 中描述。

StreamReader 类似,可以通过传递 Stream 或包含文件路径的 string 来构造它,还可以选择性地指定编码。然而,如果不指定编码,其行为与 StreamWriter 稍有不同。StreamWriter 默认使用 UTF-8,而 StreamReader 则尝试从流内容中检测编码。它会查看前几个字节,并寻找一些特征,这些特征通常是确定特定编码正在使用的好迹象。如果编码的文本以 Unicode BOM 开头,这将极大地提高确定编码的准确性。

StringReaderStringWriter

StringReaderStringWriter 类与 MemoryStream 的作用类似:当你需要与要求 TextReaderTextWriter 的 API 一起工作,但希望完全在内存中操作时,它们非常有用。MemoryStreambyte[] 数组上提供了 Stream API,StringReader 则将 string 包装为 TextReader,而 StringWriterStringBuilder 上提供了 TextWriter API。

.NET 提供的用于处理 XML 的 API 之一,XmlReader,需要一个 StreamTextReader。假设你有一个存储在 string 中的 XML 内容。如果在创建新的 XmlReader 时传递一个 string,它会将其解释为用于获取内容的 URI,而不是内容本身。接受一个 stringStringReader 构造函数会将该字符串包装为读取器的内容,我们可以将其传递给需要 TextReaderXmlReader.Create 重载方法,如 示例 15-7 所示。(这行代码用粗体标记,接下来的代码仅使用 XmlReader 读取内容以展示其按预期工作。)

示例 15-7. 将字符串包装在 StringReader
string xmlContent =
    "<message><text>Hello</text><recipient>world</recipient></message>";
`var` `xmlReader` `=` `XmlReader``.``Create``(``new` `StringReader``(``xmlContent``)``)``;`
while (xmlReader.Read())
{
    if (xmlReader.NodeType == XmlNodeType.Text)
    {
        Console.WriteLine(xmlReader.Value);
    }
}

StringWriter 更为简单:你可以不带任何参数地构造它。在写入完成后,你可以调用 ToStringGetStringBuilder 来提取所有已写入的文本。

编码

正如前面提到的,如果使用 StreamReaderStreamWriter,它们需要知道底层流使用的字符编码,以便能够正确地在流中的字节和 .NET 的 charstring 类型之间进行转换。为了管理这一点,System.Text 命名空间定义了一个抽象的 Encoding 类,具有各种具体的编码特定公共派生类型,包括 ASCIIEncodingUTF7EncodingUTF8EncodingUTF32EncodingUnicodeEncoding

大多数这些类型名称都是不言自明的,因为它们命名自它们代表的标准字符编码,比如 ASCII 或 UTF-8。需要稍作解释的是 UnicodeEncoding —— 毕竟,UTF-7、UTF-8 和 UTF-32 都是 Unicode 编码,那么这个UnicodeEncoding又是什么呢?当 Windows 在第一个 Windows NT 版本中引入对 Unicode 的支持时,采用了一个有点不太恰当的约定:在文档和各种 API 名称中,“Unicode”一词被用来指代一种 2 字节的小端字符编码,这只是众多可能的编码方案中的一种,它们都可以正确地描述为某种形式的“Unicode”。

UnicodeEncoding 类的命名是为了与这个历史约定保持一致,尽管即便如此,仍然有些令人困惑。在 Win32 API 中,“Unicode”所指的编码实际上是 UTF-16LE,但 UnicodeEncoding 类也能支持大端的 UTF-16BE。

基础的Encoding类定义了静态属性,返回我提到的所有编码类型的实例,因此如果需要表示特定编码的对象,通常只需写Encoding.ASCIIEncoding.UTF8等,而不是构造新对象。有两个类型为UnicodeEncoding的属性:Unicode属性返回一个配置为 UTF-16LE 的实例,BigEndianUnicode返回一个 UTF-16BE 的实例。

对于各种 Unicode 编码,这些属性将返回编码对象,告诉StreamWriter在输出开头生成 BOM。BOM 的主要目的是使读取编码文本的软件能够自动检测编码是大端序还是小端序(你也可以用它来识别 UTF-8,因为其编码 BOM 与其他编码不同)。如果知道将使用特定字节顺序的编码(例如 UTF-16LE),则 BOM 是不必要的,因为你已经知道顺序,但 Unicode 规范定义了可以通过以 BOM 开头的编码字节来广告正在使用的顺序的可适应格式。其 16 位版本称为 UTF-16,可以通过查看其是否以 0xFE、0xFF 或 0xFF、0xFE 开始来判断任何特定的 UTF-16 编码字节集是大端序还是小端序。

警告

尽管 Unicode 定义了允许检测字节顺序的编码方案,但无法创建按此方式工作的Encoding对象——它总是具有特定的字节顺序。因此,尽管Encoding指定在写入数据时是否写入 BOM,但这不会影响读取数据的行为——它总是假定在构造Encoding时指定的字节顺序。这意味着Encoding.UTF32属性可能名字起得不太准确——它总是将数据解释为小端序,尽管 Unicode 规范允许 UTF-32 使用大端或小端序。Encoding.UTF32实际上是 UTF-32LE。

如前所述,在创建StreamWriter时未指定编码时,默认为无 BOM 的 UTF-8 编码,这与Encoding.UTF8不同,后者会生成 BOM。而StreamReader更有趣:如果未指定编码,它将尝试检测编码。因此,.NET 能够根据 Unicode 规范对 UTF-16 和 UTF-32 自动检测字节顺序;不过做法是在构造StreamReader指定特定编码。它会查找 BOM,如果找到,则使用适当的 Unicode 编码;否则假定为 UTF-8 编码。

UTF-8 是一种流行的编码方式。如果你的主要语言是英语,这是一种特别方便的表示方法,因为如果你只使用 ASCII 中可用的字符,每个字符将占据一个字节,并且编码后的文本将与 ASCII 编码具有相同的字节值。但不同于 ASCII,你不受限于 7 位字符集。所有 Unicode 代码点都可用;你只需对 ASCII 范围外的内容使用多字节表示。然而,尽管它非常广泛使用,UTF-8 并不是唯一流行的 8 位编码。

代码页编码

Windows,如同之前的 DOS 一样,长期支持扩展 ASCII 的 8 位编码。ASCII 是一个 7 位编码,意味着使用 8 位字节,你有 128 个“多余”的值可用于其他字符。这远远不足以覆盖每个区域的每个字符,但在特定国家内,通常足够应付(尽管并非总是如此——许多远东国家需要超过 8 位每字符的编码)。但每个国家往往希望有一套不同的非 ASCII 字符集,这取决于该地区流行的重音字符以及是否需要非罗马字母表。因此,为不同地区存在各种代码页。例如,代码页 1253 使用 193–254 范围内的值来定义希腊字母字符(用其余的非 ASCII 值填充有用字符,如非美元货币符号)。代码页 1255 定义希伯来字符,而 1256 则定义阿拉伯字符在上部范围内(这些特定代码页也有一些共同点,例如使用 128 表示欧元符号€,163 表示英镑符号£)。

最常见的代码页之一是 1252,因为它是英语环境下的 Windows 默认设置。这并不定义非罗马字母表;相反,它使用上部字符范围来放置有用的符号以及各种罗马字母的重音版本,使得广泛的西欧语言得到适当的表示。

你可以通过调用Encoding.GetEncoding方法,传入代码页号来创建代码页的编码。(你得到的对象的具体类型通常不是我之前列出的那些。这个方法可能会返回从Encoding派生的非公共类型。)示例 15-8 使用此方法将包含英镑符号的文本写入文件,使用代码页 1252。

示例 15-8. 使用 Windows 1252 代码页写入
using (var sw = new StreamWriter("Text.txt", false,
                                 Encoding.GetEncoding(1252)))
{
    sw.Write("£100");
}

这将把£符号编码为单字节,其值为163。使用默认的 UTF-8 编码,则会以两个字节编码,其值分别为194163

直接使用编码

TextReaderTextWriter并不是使用编码的唯一方式。代表编码的对象(如Encoding.UTF8)定义了各种成员。例如,GetBytes方法将string直接转换为byte[]数组,而GetString方法则进行相反的转换。

你还可以了解这些转换会产生多少数据。GetByteCount告诉你为给定字符串调用GetBytes将产生多大的数组,而GetCharCount告诉你解码特定数组将生成多少字符。你还可以找到在不知道确切文本情况下所需空间的上限,通过GetMaxByteCount方法。这个方法接受一个数字而不是一个string,它将其解释为字符串长度;由于.NET 字符串使用 UTF-16,这意味着这个 API 回答的问题是:“如果我有这么多 UTF-16 代码单元,那么在目标编码中表示相同文本可能需要的最大代码单元数是多少?”对于可变长度编码,这可能会产生显著的高估。例如,对于 UTF-8,GetMaxByteCount将输入字符串的长度乘以三³,并额外添加 3 个字节来处理可能出现的代理字符边缘情况。它生成了可能情况的正确描述,但是包含不需要在 UTF-8 中占用 3 个字节的任何字符(即英语或任何使用拉丁字母表的其他语言,以及使用希腊文、西里尔字母、希伯来文或阿拉伯文写作系统的任何文本)将需要比GetMaxByteCount预测的空间少得多。

有些编码可以提供一个preamble,即一系列独特的字节序列,如果在某些编码文本的开头找到它,表明你很可能正在查看使用该编码的内容。当你不知道正在使用哪种编码时,这可能非常有用。各种 Unicode 编码都会返回它们的 BOM 编码作为 preamble,你可以通过GetPreamble方法获取它。

Encoding类定义了实例属性,提供关于编码的信息。EncodingName返回编码的人类可读名称,但还有两个可用的名称。WebName属性返回与 Internet 分配号码管理局(IANA)注册的编码标准名称,该局管理互联网上的标准名称和编号,例如 MIME 类型。一些协议(如 HTTP)有时会将编码名称放入头部,这就是在该情况下应使用的文本。另外两个名称,BodyNameHeaderName,相对更为晦涩,仅用于互联网电子邮件——有不同的约定来表示某些编码在电子邮件正文和标题中的表示方式。

文件和目录

到目前为止,在本章中展示的抽象概念非常通用——您可以编写使用 Stream 的代码,而无需知道其中包含的字节来自何处或将要去哪里;同样,TextReaderTextWriter 不要求其数据有任何特定的起源或目的地。这很有用,因为它使得能够编写可应用于各种情境的代码成为可能。例如,基于流的 GZipStream 可以从文件、网络连接或任何其他流中压缩或解压缩数据。但是,有时您知道自己将处理文件并希望访问特定于文件的功能。本节描述了用于处理文件和文件系统的类。

FileStream 类

FileStream 类继承自 Stream 类,表示文件系统中的文件。我已经偶尔使用过它几次了。相比基类,它只添加了相对较少的成员。LockUnlock 方法提供了在多个进程中使用单个文件时获取特定字节范围的独占访问的方式。Name 属性告诉你文件名。

FileStream 在其构造函数中提供了极大的灵活性——忽略带有 [Obsolete] 属性标记的构造函数,[⁴] 总共有不少于 10 个构造函数重载。创建 FileStream 的方法可分为两组:一种是已经有操作系统文件句柄的情况,另一种是没有文件句柄的情况。如果你已经从某处获得了句柄,你需要告诉 FileStream 该句柄提供了对文件的读、写或读/写访问权限,这可以通过传递 FileAccess 枚举值来实现。其他重载可选地允许你指定在读取或写入时要使用的缓冲区大小,以及一个指示句柄是否已为重叠 I/O(一种支持异步操作的 Win32 机制)打开的标志。(不带该标志的构造函数假定在创建文件句柄时未请求重叠 I/O。)

更常见的是使用其他构造函数,其中 FileStream 使用操作系统 API 代表您创建文件句柄。您可以提供不同级别的详细信息来指定希望如何完成这些操作。至少,您必须指定文件的路径和 FileMode 枚举值。Table 15-1 显示了此枚举定义的值,并描述了 FileStream 构造函数在已命名文件存在和不存在的情况下将如何处理每个值的情况。

表 15-1. FileMode 枚举

文件存在时的行为文件不存在时的行为
CreateNew抛出 IOException创建新文件
Create替换现有文件创建新文件
Open打开现有文件抛出 FileNotFoundException
OpenOrCreate打开现有文件创建新文件
Truncate替换现有文件抛出FileNotFoundException
Append打开现有文件,将Position设置为文件末尾创建新文件

您也可以选择指定FileAccess。如果不指定,FileStream将使用FileAccess.ReadWrite,除非您选择了FileMode.Append。以追加模式打开的文件只能进行写入操作,因此在这种情况下,FileStream会选择Write。(如果在打开追加模式时传递显式的FileAccess请求除Write之外的任何值,构造函数会抛出ArgumentException。)

顺便说一下,在本节描述每个额外构造函数参数时,相关重载将还会接受之前描述过的所有参数(但useAsync参数除外,该参数仅出现在一个构造函数中)。正如示例 15-9 所示,大多数这些构造函数看起来都和前一个构造函数类似,只是多了一个额外参数。

示例 15-9. 使用路径的FileStream构造函数
public FileStream(string path, FileMode mode)
public FileStream(string path, FileMode mode, FileAccess access)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share, int bufferSize)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share, int bufferSize, bool useAsync)
public FileStream(string path, FileMode mode, FileAccess access,
                  FileShare share, int bufferSize, FileOptions options)

那些接受FileShare类型参数的重载允许您指示是否需要独占文件访问权。如果传递FileShare.None,那么如果文件已在其他地方打开,构造函数将抛出IOException,如果成功打开,则在您完成使用文件之前,没有其他代码能够打开该文件。如果您愿意允许其他进程(或同一进程中的其他代码)同时打开文件,您可以指示您的代码是否能够容忍其他用户同时拥有文件的读取访问权、写入访问权或两者兼有。FileShare是一个类似标志的枚举,因此您可以指定FileShare.Read|FileShare.Write,但由于这是一个常见的组合,FileShare定义了一个预组合的ReadWrite值。

那些不显式指定FileShare的构造函数重载都使用FileShare.Read,这表示您的代码允许其他代码同时打开文件以进行读取,但不允许写入。例如,如果您正在向日志文件写入条目,那么您很可能会使用FileMode.AppendFileShare.Read,这意味着只有您的代码能够追加日志条目,但其他代码仍然可以使用FileAccess.Read打开文件以读取日志。如果两个程序尝试同时以写入访问方式打开同一日志文件,并且两者都指定了FileShare.Read(显式或作为隐式默认值),那么谁先进入就会成功,但第二个尝试时构造函数会抛出IOException,因为尝试打开文件进行写入与文件已经以没有FileShare.Write的方式打开的事实相冲突。在这种情况下,这是您想要的行为——如果两个程序尝试同时向同一文件末尾追加内容,结果将会非常混乱,因为每个程序都会有自己关于文件末尾位置的想法。

如果您尝试打开某个其他代码(可能是另一个进程或应用程序内的其他位置的代码)已经打开的文件,只有在您指定的 FileAccessFileShare 与先前使用该文件的代码所使用的 FileShare 兼容时才会成功。同样地,如果您的代码已经打开了一个文件,那么在那一点上选择的 FileShare 决定了在您使用文件时其他代码可以成功应用的 FileAccessFileShare 组合。例如,如果您想要读取一个其他进程正在写入的日志文件,那么如果那个进程指定了 FileShare.Read,您将需要使用 FileAccess.Read。 (那些未指定 FileAccess 的构造函数默认为 FileAccess.ReadWrite,在这种情况下将失败,因为如果某物已经使用 FileShare.Read 打开文件,则无法获得写访问权限。)但是您还需要指定 FileShare.ReadWrite。这在只想要读取的代码中可能看起来令人惊讶,但它是有道理的:它声明了我们不介意在我们读取时其他代码试图修改文件。FileShare.Read 的默认值表明我们在使用文件时不希望文件发生更改,但这对于从日志文件中读取是错误的选择——如果我们设法使用 FileShare.Read 打开日志文件,那将阻止主应用程序打开日志文件进行写入。

单独指定 FileShare.Write(而不与 FileShare.Read 结合)是合法的,但有点奇怪。它容忍同时存在具有写访问权限的其他句柄,但不允许读取者。您还可以传递 Delete(单独或与 Read 和/或 Write 结合使用),表示您不介意在您打开文件时有人尝试删除它。显然,如果尝试在文件被删除后使用文件,将会收到 I/O 异常,因此您需要为此做好准备,但有时这样做可能值得努力;否则,尝试删除文件时将会被阻止,而您已经打开了文件。

警告

Unix 的文件锁机制比 Windows 少,因此这些锁定语义通常会在这些环境中映射为更简单的东西。此外,在 Unix 中,文件锁是建议性的,这意味着进程可以选择忽略它们。

我们可以传递的下一个信息片段是缓冲区大小。这控制了 FileStream 从文件系统读取和写入时使用的块大小。它的默认值为 4,096 字节。在大多数情况下,这个值都可以很好地工作,但是如果您正在处理非常高的数据量,较大的缓冲区大小可能会提供更好的吞吐量。然而,与所有性能问题一样,您应该测量此类更改的影响,看看是否值得——在某些情况下,您可能看不到数据吞吐量的任何差异,只会使用比必要更多一点的内存。

useAsync 标志允许你确定文件句柄是否以优化大型异步读取和写入的方式打开。(在 Windows 上,这会打开文件进行重叠 I/O,这是支持异步操作的 Win32 特性。)如果你以相对较大的块读取数据,并使用流的异步 API,通常通过设置此标志可以获得更好的性能。但是,如果每次读取几个字节,这种模式实际上会增加开销。如果访问文件的代码对性能特别敏感,值得尝试两种设置,看看哪种对你的工作负载更有效。正如前面提到的,这也使得可以在单个 FileStream 上执行多个并发操作。

下一个参数可以添加的类型是FileOptions。如果你非常注意的话,你会注意到在示例 15-9 中,到目前为止我们看过的每一个重载都添加了一个新参数,但是在这个中,FileOptions 参数替换了 bool useAsync 参数。这是因为你可以用 FileOptions 指定的选项之一是异步访问。FileOptions 是一个标志枚举,所以你可以指定它提供的任何组合标志,这些标志在表 15-2 中有描述。

表 15-2. FileOptions 标志

标志含义
WriteThrough禁用操作系统写入缓冲,数据直接传递到存储设备当你刷新流时
Asynchronous指定使用异步 I/O
RandomAccess提示文件系统缓存,你将会进行查找,而不是按顺序读取或写入数据
SequentialScan提示文件系统缓存,你将按顺序读取或写入数据
DeleteOnClose告诉 FileStream 在调用 Dispose 时删除文件
Encrypted加密文件,以防其他用户读取其内容

要注意WriteThrough 标志。虽然它按照预期工作,但可能没有预期的效果,因为某些硬盘会延迟写入以提高性能(许多硬盘有自己的 RAM,能够非常快速地接收来自计算机的数据,并在真正存储数据之前报告写入操作已完成)。WriteThrough 标志将确保当你释放或刷新流时,你写入的所有数据都已传送到驱动器,但驱动器不一定已将该数据持久写入,因此如果电源故障,你仍可能丢失数据。确切的行为将取决于你如何告诉操作系统配置驱动器。

.NET 6.0 添加了一个新的重载,接受两个参数:一个string(文件的路径)和一个FileStreamOptionsFileStreamOptions定义了我们刚刚讨论的每个设置的属性。因此,它具有ModeAccessShareOptionsBufferSize。它还添加了一个新的设置,新添加到.NET 6.0 中,PreallocationSize,允许应用程序指示文件预计需要占用的空间大小。这使得操作系统可以检测到当空间不足时,并且可以预留空间,减少由于磁盘空间不足而导致的操作失败的可能性。FileStreamOptions的重载使得只设置那些不想要默认值的方面变得更容易——您只需设置相关属性。这意味着,如果没有一个FileStream构造函数重载正好符合您需要的参数组合,那也不再不方便。

虽然FileStream可以控制文件的内容,但有些操作可能非常繁琐,或者FileStream根本不支持。例如,您可以使用这个类复制文件,但这并不像可能的那么简单,并且FileStream没有提供任何删除文件的方法。因此,运行时库包含了一个专门的类来处理这类操作。

文件类

静态类File提供了各种文件操作的方法。Delete方法从文件系统中删除指定的文件。Move方法可以移动或重命名文件。还有一些方法用于检索文件系统存储的关于每个文件的信息和属性,如GetCreationTimeGetLastAccessTimeGetLastWriteTime⁵和GetAttributes。(最后一个返回一个FileAttributes值,这是一个标志枚举类型,告诉您文件是否为只读、隐藏文件、系统文件等等。)

Encrypt方法在某种程度上与FileStream重叠——正如您之前看到的,您可以在创建文件时请求以加密方式存储它。然而,Encrypt能够处理已经创建但未加密的文件——它会在原地对其进行加密。(这仅在 Windows 上支持,在文件系统支持的驱动器上有效。在其他操作系统上会抛出PlatformNotSupportedException异常,在 Windows 上如果指定的文件不支持加密也会抛出NotSupportedException异常。这与通过 Windows 文件资源管理器中的文件属性窗口启用加密具有相同的效果。)您还可以通过调用Decrypt将加密文件转换回未加密状态。

注意

在读取加密文件之前,不需要先调用Decrypt。在以加密文件的相同用户账户登录时,可以像平常一样读取其内容——加密文件看起来就像普通文件,因为 Windows 在读取时会自动解密内容。这种特定的加密机制的目的是,如果其他用户设法获取文件访问权限(例如,如果它在被盗的外部驱动器上),文件内容将会看起来像随机垃圾。Decrypt移除了这种加密,这意味着任何能够访问文件的人都能查看其内容。

File提供的其他方法只是提供了更方便的方式来完成可以用FileStream手动完成的事情。Copy方法复制文件,虽然你可以使用FileStreamCopyTo方法来完成这个操作,但Copy方法会处理一些棘手的细节。例如,它确保目标文件保留诸如是否只读和是否启用加密等属性。

Exists方法允许你在尝试打开文件之前发现文件是否存在。虽然在尝试打开不存在的文件时,FileStream会抛出FileNotFound异常,但是Exists在你只需确定文件是否存在而不需要进行其他操作时很有用。如果你打算无论如何都要打开文件,并且只是想避免异常,那么你应该谨慎使用这个方法;仅仅因为Exists返回true并不意味着你不会收到FileNotFound异常。总是有可能在你检查文件存在性和尝试打开它之间,另一个进程删除了文件。或者,文件可能位于网络共享中,你可能会失去网络连接。因此,即使你试图避免引发异常,也应该时刻准备处理文件访问时可能出现的异常。

File提供了许多辅助方法来简化打开或创建文件的过程。Create方法简单地为你构造一个FileStream,传入适当的FileModeFileAccessFileShare值。示例 15-10 展示了如何使用它,同时展示了如果不使用Create辅助方法,等效的代码会是什么样子。Create方法提供了重载,允许你指定缓冲区大小、FileOptionsFileSecurity,但仍然为你提供了其他参数。

示例 15-10. File.Create versus 新建 FileStream
using (FileStream fs = File.Create("foo.bar"))
{
   ...
}

// Equivalent code without using File class
using (var fs = new FileStream("foo.bar", FileMode.Create,
                               FileAccess.ReadWrite, FileShare.None))
{
    ...
}

File 类的 OpenReadOpenWrite 方法为当你想要打开现有文件以供读取或打开或创建文件以供写入时提供了类似的简化功能。还有一个需要传递 FileModeOpen 方法。这种方法的效用较低——它与也接受路径和模式参数的 FileStream 构造函数重载非常相似,自动提供适当的其他设置。它们的某种任意的区别在于,虽然 FileStream 构造函数默认为 FileShare.Read,但 File.Open 方法默认为 FileShare.None

File 还提供了几个面向文本的辅助方法。最简单的方法是 OpenText,用于打开一个文本读取文件,但其价值有限,因为它与接受单个字符串参数的 StreamReader 构造函数的功能完全相同。只有在你偏好它使你的代码看起来更加整洁时才会使用它——如果你的代码大量使用 File 辅助方法,你可能会选择出于惯用性一致性的考虑使用它。

File 类暴露的几种方法都是面向文本的。这些方法使我们能够改进类似 示例 15-11 中所示的代码。它向日志文件追加一行文本。

示例 15-11. 使用 StreamWriter 向文件追加
static void Log(string message)
{
    using (var sw = new StreamWriter(@"c:\temp\log.txt", true))
    {
        sw.WriteLine(message);
    }
}

其中一个问题是一眼看不出 StreamWriter 是如何被打开的——true 参数的含义是什么?事实上,这告诉 StreamWriter 我们希望它在追加模式下创建底层的 FileStream。示例 15-12 也具有相同的效果——它使用 File.AppendText,为我们调用完全相同的 FileStream 构造函数。尽管我之前对于 File.OpenText 的评价有些苛刻,认为它提供的价值较低,但我认为 File.AppendText 曾经确实在可读性方面提供了真正有用的改进,而 File.OpenText 并没有。相比之下,通过 C# 添加了对命名参数的支持后,AppendText 看起来不再那么有用了——我们可以在 示例 15-11 中为 append 参数命名以达到类似的可读性改进。

示例 15-12. 使用 File.AppendText 创建一个追加的 StreamWriter
static void Log(string message)
{
    using (StreamWriter sw = File.AppendText(@"c:\temp\log.txt"))
    {
        sw.WriteLine(message);
    }
}

如果你只想将一些文本附加到文件并立即关闭它,有一种更简单的方法。正如 示例 15-13 所示,我们可以使用 AppendAllText 辅助方法进一步简化事情。

示例 15-13. 将单个字符串附加到文件末尾
static void Log(string message)
{
    File.AppendAllText(@"c:\temp\log.txt", message);
}

要小心哦。这与示例 15-12 并不完全相同。该示例使用了WriteLine来追加文本,但示例 15-13 相当于只使用了Write。因此,如果你在多次调用示例 15-13 中的Log方法,除非你使用的字符串恰好包含行尾字符,否则你最终会在输出文件中得到一行长文本。如果你想逐行处理,可以使用AppendAllLines方法,该方法接受一个字符串集合,并将每个字符串作为新行追加到文件末尾。示例 15-14 使用此方法在每次调用时追加一整行。

示例 15-14. 向文件追加单行
static void Log(string message)
{
    File.AppendAllLines(@"c:\temp\log.txt", new[] { message });
}

由于 AppendAllLines 接受 IEnumerable<string>,因此您可以使用它来追加任意数量的行。但是如果您只想追加一行,它也完全可以胜任。File 还定义了 WriteAllTextWriteAllLines 方法,它们的工作方式非常相似,但如果指定路径处已经存在文件,它们将替换它而不是追加到它。

还有一些用于读取文件内容的相关文本方法。ReadAllText执行的是构造一个 StreamReader,然后调用其 ReadToEnd 方法的等效操作——它将整个文件内容作为一个单独的 string 返回。ReadAllBytes将整个文件读入一个 byte[] 数组。ReadAllLines将整个文件作为一个 string[] 数组读取,每行一个元素。ReadLines看起来非常相似。它以 IEnumerable<string> 的形式提供对整个文件的访问,每行一个条目,但不同之处在于它是懒加载的——与我在本段描述的所有其他方法不同,它不会一次性将整个文件读入内存,因此对于非常大的文件来说,ReadLines是更好的选择。它不仅消耗更少的内存,而且使您的代码能够更快地启动——只要从磁盘读取到第一行数据,您就可以开始处理数据,而其他方法在读取整个文件之前都不会返回。

Directory 类

就像 File 是一个静态类,提供用于执行文件操作的方法一样,Directory 也是一个静态类,提供用于执行目录操作的方法。其中一些方法与 File 提供的方法非常相似——例如,有方法来获取和设置创建时间、最后访问时间和最后写入时间,还有 MoveExistsDelete 方法。与 File 不同,Directory.Delete 有两个重载。一个只接受路径,只有在目录为空时才起作用。另一个还接受一个 bool 参数,如果为 true,将递归删除文件夹中的所有内容,包括嵌套的文件夹和它们包含的文件。请谨慎使用这个方法。

当然,还有专用于目录的方法。GetFiles接受一个目录路径,并返回包含该目录中每个文件的完整路径的string[]数组。还有一个重载方法,允许您指定一个模式来过滤结果,并且第三个重载方法接受一个模式,并且还可以使用一个标志来请求递归搜索所有子文件夹。示例 15-15 使用它来查找我的Pictures文件夹中所有具有*.jpg*扩展名的文件。(除非您也叫伊恩,否则您需要更改该路径以匹配您的帐户名称,以使其在您的计算机上起作用。)在实际应用程序中,您应该使用“已知文件夹”中显示的技术获取此路径。

示例 15-15. 递归搜索特定类型的文件
foreach (string file in Directory.GetFiles(@"c:\users\ian\Pictures",
                                           "*.jpg",
                                           SearchOption.AllDirectories))
{
    Console.WriteLine(file);
}

还有一个类似的GetDirectories方法,提供相同的三个重载,它返回指定目录中的目录而不是文件。还有一个GetFileSystemEntries方法,同样有三个重载,它返回文件和文件夹。

还有一些称为EnumerateFilesEnumerateDirectoriesEnumerateFileSystemEntries的方法,与三个GetXxx方法完全相同,但它们返回IEnumerable<string>。这是一种延迟枚举,因此您可以立即开始处理结果,而不是等待所有结果作为一个大数组。

Directory类提供与进程当前目录(每次调用文件 API 而不指定完整路径时使用的目录)相关的方法。GetCurrentDirectory返回路径,SetCurrentDirectory设置它。

您也可以创建新目录。CreateDirectory方法接受一个路径,并尝试创建尽可能多的目录,以确保路径存在。因此,如果您传递C:\new\dir\here,并且没有C:\new目录,它将创建三个新目录:首先它将创建C:\new,然后C:\new\dir,然后C:\new\dir\here。如果您请求的文件夹已经存在,它不会将其视为错误;它只是返回而不执行任何操作。

GetDirectoryRoot将目录路径剥离为驱动器名称或其他根目录,例如网络共享名称。例如,在 Windows 上,如果您传递C:\temp\logs,它将返回C:*;如果您传递\someserver\myshare\dir\test*,它将返回*\someserver\myshare*。这种字符串分割操作,即将路径拆分为其组成部分,是一个非常常见的需求,因此有一个专门的类来处理这类操作。

路径类

Path 类提供了一些有用的工具函数,用于处理包含文件名的字符串。其中一些函数用于从文件路径中提取片段,比如包含的文件夹名称或文件扩展名。还有一些函数用于组合字符串,生成新的文件路径。这些大多数方法仅执行特定的字符串处理,不需要路径所指代的文件或目录真实存在。然而,也有一些方法超出了字符串操作的范畴。例如,Path.GetFullPath 方法会考虑当前目录,如果传入的参数不是绝对路径的话。但只有需要使用真实位置的方法才会这样做。

Path.Combine 方法处理了在组合文件夹和文件名时遇到的繁琐问题。如果你有一个文件夹名 C:\temp 和一个文件名 log.txt,将它们同时传给 Path.Combine 方法会返回 C:\temp\log.txt。如果将 *C:\temp* 作为第一个参数传入,它也会正常工作,因此其中一个处理的问题是确定是否需要提供额外的 \ 字符。如果第二个路径是绝对路径,它会检测并简单地忽略第一个路径,因此如果你传入 C:\tempC:\logs\log.txt,结果将会是 C:\logs\log.txt。虽然这些问题可能看起来微不足道,但如果尝试通过字符串连接自己做文件路径的组合,很容易出错,因此你应该始终避免这样做,而是使用 Path.Combine 方法。

当涉及路径时,.NET Core 和 .NET 在不同平台上表现不同。在类 Unix 系统上,只使用 / 作为目录分隔符,因此 Path 类中期望路径包含目录的各种方法会在这些系统上将 / 视为唯一的分隔符。Windows 使用 \ 作为分隔符,尽管在 Windows 上也普遍容忍使用 / 作为替代,Path 类也支持这一点。因此,Path​.Com⁠bine("/x/y", "/z.txt") 在 Windows 和 Linux 上会产生相同的结果,但 Path.Combine(@"\x\y", @"\z.txt") 则不会。此外,在 Windows 上,如果路径以驱动器号开头,则是绝对路径,但 Unix 不认识驱动器号。在上文中的例子中,如果在 Linux 或 macOS 上移除驱动器号并将 \ 替换为 /,结果将会是你所期望的。

给定文件路径,GetDirectoryName 方法会移除文件名部分,仅返回目录。这个方法很好地说明了为什么你需要记住大多数 Path 类的成员不会查看文件系统。如果你没有考虑这一点,你可能会期望当你传递给 GetDirectoryName 一个目录名,比如 C:\Program Files,它会检测到这是一个目录并返回相同的字符串,但事实上它将仅返回 *C:*。名称 Program Files 对于文件或目录来说都是一个完全合法的名称,由于 GetDirectoryName 不会检查磁盘,并且它期望传递的路径包括文件名,因此在这种情况下它会认为这是一个文件。(可以说,即使它意识到 C:\Program Files 是一个目录,*C:* 也将是正确的结果,因为那是包含 Program Files 目录的目录。)该方法有效地查找最后的 /\ 字符,并返回其前面的所有内容。(因此,如果你传递一个带有尾部 \ 的目录名,比如 *C:\Program Files*,它将返回 C:\Program Files。然而,这个 API 的整个目的是从文件的完整路径中移除文件名。如果你已经有一个只有目录名的字符串,你不应该调用这个 API。)

GetFileName 方法返回文件名(包括扩展名,如果有)。和 GetDirectoryName 一样,它也查找最后的目录分隔符字符,但返回的是它后面的文本,而不是前面的文本。同样,它不查看文件系统——这完全通过字符串操作完成(尽管和所有这些操作一样,它考虑了本地系统对于目录分隔符或绝对路径的规则)。GetFileNameWithoutExtension 类似,但如果存在扩展名(如 .txt.jpg),它会从名称的末尾移除扩展名。相反,GetExtension 返回扩展名而不返回其他内容。

如果您需要创建临时文件来执行一些工作,Path 提供了三个有用的方法。GetRandomFileName 使用随机数生成器创建一个您可以用于随机文件或文件夹的名称。这个随机数是密码强度的,具有两个有用的属性:名称将是唯一且难以猜测的。(如果攻击者能够预测临时文件的名称或位置,系统安全的某些攻击可能变得可能。)这个方法实际上不会在文件系统上创建任何内容,它只是返回一个合适的名称。另一方面,GetTempFileName 将在操作系统为临时文件提供的位置创建一个文件。这个文件将是空的,并且该方法返回其路径作为一个字符串。然后您可以打开文件并修改它。(这并不保证使用加密来选择一个真正随机的名称,因此不应依赖于此类文件位置的不可猜测性。它将是唯一的,但仅此而已。)您应该在完成对其的操作后删除由 GetTempFileName 创建的任何文件。最后,GetTempPath 返回 GetTempFileName 将使用的文件夹的路径;这并不会创建任何内容,但您可以与 GetRandomFileName 返回的名称(与 Path.Combine 结合使用)一起使用它来选择一个位置来创建您自己的临时文件。

FileInfo、DirectoryInfo 和 FileSystemInfo

虽然 FileFolder 类提供了访问信息的方式——例如文件的创建时间以及它是系统文件还是只读文件——但如果您需要访问多个信息,这些类存在问题。使用单独的调用收集每个数据位不是很高效,因为可以通过更少的步骤从底层操作系统获取信息。此外,有时传递包含您需要的所有数据的单个对象可能更容易,而不是找到放置许多单独项目的地方。因此,System.IO 命名空间定义了包含有关文件或目录信息的 FileInfoDirectoryInfo 类。由于存在一定的共同点,这些类型都派生自基类 FileSystemInfo

要构造这些类的实例,您需要传递您想要的文件或文件夹的路径,就像 示例 15-16 中所示。顺便说一句,如果稍后您认为文件可能已被其他程序更改,并且您想要更新 FileInfoDirectoryInfo 返回的信息,您可以调用 Refresh,它将重新加载来自文件系统的信息。

示例 15-16. 使用 FileInfo 显示有关文件的信息
var fi = new FileInfo(@"c:\temp\log.txt");
Console.WriteLine(
    $"{fi.FullName} ({fi.Length} bytes) last modified on {fi.LastWriteTime}");

除了提供对应于各种FileDirectory方法获取信息(CreationTimeAttributes等)的属性外,这些信息类还提供了实例方法,这些方法对应于FileDirectory的许多静态方法。例如,如果你有一个FileInfo,它提供了DeleteEncryptDecrypt等方法,这些方法的工作方式与它们的File名称相同,只是你不需要传递路径参数。还有一个名为MoveTo的对应方法,尽管名字不同,但功能相同。

FileInfo还提供了与使用StreamFileStream打开文件的各种辅助方法的等效方法,例如AppendTextOpenReadOpenText。也许更令人惊讶的是,CreateCreateText也是可用的。事实证明,你可以为尚不存在的文件构造一个FileInfo,然后使用这些辅助方法创建它。它不会尝试填充描述文件的任何属性,直到你尝试读取它们的第一次,因此它会推迟在那一点抛出FileNotFound​Ex⁠ception,以防你创建FileInfo是为了创建新文件。

如你所料,DirectoryInfo也提供了实例方法,这些方法对应于Directory定义的各种静态辅助方法。

已知文件夹

桌面应用程序有时需要使用特定的文件夹。例如,应用程序的设置通常存储在用户配置文件夹的特定文件夹中。有一个专门用于系统范围应用程序设置的文件夹。在 Windows 上,这些通常位于用户的AppData文件夹和C:\ProgramData文件夹中。Windows 还定义了图片、视频、音乐和文档的标准位置,还有代表特殊外壳功能的文件夹,例如桌面和用户的“收藏夹”。

尽管这些文件夹在不同系统中通常位于相同位置,但你不应假设它们会出现在你期望的位置。(因此,在真实代码中,你不应像示例 15-15 那样做。)许多这些文件夹在 Windows 的本地化版本中有不同的名称。甚至在特定语言中,也不能保证这些文件夹会出现在通常的位置——有些文件夹是可以移动的,并且它们的位置在不同版本的 Windows 中并不固定。

因此,如果需要访问特定的标准文件夹,应使用Environment类的GetFolderPath方法,如示例 15-17 中所示。这个方法接受来自嵌套的Environment.SpecialFolder枚举类型的成员,该枚举定义了 Windows 中所有已知文件夹类型的值。

示例 15-17. 发现存储设置的位置
string appSettingsRoot =
    Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string myAppSettingsFolder =
    Path.Combine(appSettingsRoot, @"Endjin\FrobnicatorPro");
注意

在非 Windows 系统上,对于大多数此枚举条目,GetFolderPath 返回空字符串,因为没有本地等效项。但是,有些项确实有效,比如 MyDocumentsCommon​Ap⁠plicationDataUserProfile

ApplicationData 文件夹位于用户配置文件的漫游部分。不需要在用户使用的所有计算机上复制的信息(例如,如果需要可以重建的缓存),应放在本地部分,可以使用 LocalApplicationData 枚举项获取。

序列化

StreamTextReaderTextWriter 类型提供了在文件、网络或其他类似流的任何东西中读取和写入数据的能力,只要提供一个适当的具体类。但这些抽象仅支持字节或文本数据。假设您有一个具有多个类型属性的对象,包括一些数值类型,可能还包括对其他对象的引用,其中一些可能是集合。如果您希望将该对象的所有信息写入文件或通过网络连接发送,以便稍后在同一类型的对象和相同属性值的计算机上或连接的另一端重新构建该对象,该怎么办?

您可以使用本章中显示的抽象来完成此操作,但这需要相当多的工作。您需要编写代码来读取每个属性并将其值写入到 StreamTextWriter 中,并且需要将值转换为二进制或文本。您还需要决定您的表示方式——您只是按固定顺序写入值,还是会设计一种方案来写入名称/值对,以便在日后需要添加更多属性时不受限制?您还需要想出处理集合和对其他对象的引用的方法,并且需要决定在面对循环引用时应采取的措施——如果两个对象互相引用,简单的代码可能会陷入无限循环。

.NET 提供了几种解决此问题的方案,每种方案在能够支持的场景复杂性、版本控制的处理能力以及与其他平台的互操作性方面存在不同的权衡。这些技术都属于广义上的序列化(因为它们涉及将对象的状态按顺序写入某种形式的数据存储中——序列化——例如 Stream)。多年来在 .NET 中引入了许多不同的机制,所以我不会涵盖所有内容。我只会介绍最能代表特定方法处理该问题的几种方式。

BinaryReader、BinaryWriter 和 BinaryPrimitives

虽然它们不严格属于序列化形式,但任何关于此领域的讨论都不完整,没有涵盖BinaryReaderBinaryWriter类,因为它们解决了任何序列化和反序列化对象尝试必须处理的基本问题:它们可以将 CLR 的内置类型转换为字节流,BinaryPrimitives也做同样的事情,但它能够处理Span<byte>和相关类型,这些在第十八章中讨论过内存效率。

BinaryWriter是围绕可写的Stream的包装器。它提供了一个Write方法,支持除了object类型以外的所有内置类型的重载。因此,它可以接受任何数值类型、stringcharbool类型的值,并将该值的二进制表示写入Stream中。它还可以写入bytechar类型的数组。

BinaryReader是围绕可读的Stream的包装器,提供了各种用于读取数据的方法,每种方法对应于BinaryWriter提供的Write的重载。例如,您有ReadDoubleReadInt32ReadString等方法。

要使用这些类型,当您想要序列化一些数据时,可以创建一个BinaryWriter,并写入每个要存储的值。稍后当您想要反序列化该数据时,可以围绕包含写入的数据的流创建一个BinaryReader,并按照与首次写出数据时完全相同的顺序调用相关的读取方法。

BinaryPrimitives的工作方式略有不同。它专为需要最小化堆分配数量的代码设计,因此它不是一个包装器类型,而是一个静态类,提供了广泛的方法,如ReadInt32LittleEndianWriteUInt16BigEndian。这些方法分别接受ReadOnlySpan<byte>Span<byte>参数,因为它设计为直接处理内存中的数据(不一定是包装在Stream中)。然而,其基本原理是相同的:它在字节序列与基本的.NET 类型之间进行转换。(另外,字符串处理相对复杂:没有ReadString方法,因为返回string的任何方法都会在堆上创建一个新的字符串对象,除非有一个预先分配并反复分配的固定字符串集。有关详细信息,请参见第十八章。)

这些类仅解决了如何以二进制形式表示各种内置类型的问题。您仍然需要解决如何表示整个对象以及如何处理对象之间的引用等更复杂的结构。

CLR 序列化

CLR 序列化如其名称所示,是内置到运行时本身的特性——它不仅仅是一个库功能。虽然它自.NET Framework 1.0 起就存在,但在最初几个版本的.NET Core 中并未支持,但微软最终以一种较简化的形式将其重新添加回去,以便更轻松地从.NET Framework 迁移应用程序。微软不鼓励使用它,但在某些场景中它仍然很受欢迎。在微服务环境中,它被广泛用于跨服务边界发送异常和相对简单的数据结构。.NET Core 和.NET 提供的有限支持针对这些场景,因此你不能对任意的.NET 对象进行序列化。

CLR 序列化最有趣的方面是它直接处理对象引用。如果你序列化一个List<SomeType>,其中列表中的多个条目引用同一个对象,CLR 序列化会检测到这一点,只存储该对象的一个副本,在反序列化时会重新创建这个一个对象多个引用的结构。(基于非常广泛使用的 JSON 格式的序列化系统通常不会这样做。)

类型需要选择 CLR 序列化。.NET 定义了一个[Serializable]属性,必须存在,一旦你添加了这个属性,CLR 就可以为你处理所有细节。序列化直接使用对象的字段。它使用反射来访问所有字段,无论是公共的还是私有的。BinaryFormatter类型(位于System.Runtime.Serialization.Formatters.Binary命名空间中)提供了一个Serialize方法,可以将任何可序列化类型的实例写入流中。它正确地检测到循环引用,在流中仅存储每个对象的一个副本。当我们将生成的流传递给Deserialize方法时,它将正确地恢复任何这样的结构。

因此这非常强大——通过添加一个属性,我可以将整个对象图写出来。但也有缺点:如果我改变了任何被序列化类型的实现,那么当新版本的代码尝试反序列化旧版本生成的流时,我会遇到问题。因此,这并不适合将应用程序的设置写入磁盘,因为这些设置可能会随着每个新版本而演变。实际上,你可以定制序列化的方式,这样可以支持版本控制,但到了这一步,你又要手工完成大部分工作。(实际上使用BinaryReaderBinaryWriter可能更容易。)此外,使用这种序列化方式很容易引入安全问题:控制反序列化流的人基本上可以完全控制你对象的所有字段。文档指出,BinaryFormatter“不安全且无法安全使用”,当你尝试使用它时会看到弃用警告。因此,我在这里只是描述 CLR 序列化,因为尽管微软试图终止它,但它仍然在使用,而且因为它的存在意味着你可能对对象创建有所假设——特别是引用类型只能通过其构造函数或通过MemberwiseClone创建,但事实证明这并非正确。

CLR 序列化的另一个问题是它生成的二进制流是.NET 特定的格式。如果需要处理该流的代码仅在.NET 上运行,那么这不是问题,但你可能希望生成更广泛受众使用的流。除了 CLR 序列化外,还有其他的序列化机制,这些机制可以生成其他系统更容易消费的流。

JSON

JSON(JavaScript 对象表示法)是一种非常广泛使用的序列化格式,.NET 运行时库提供了在System.Text.Json命名空间中处理它的支持。⁶ 它提供了三种处理 JSON 数据的方式。

Utf8JsonReaderUtf8JsonWriter类型是类似流的抽象,它们将 JSON 数据内容表示为一系列元素。如果需要处理太大无法一次性加载到内存中的 JSON 文档,它们非常有用。它们构建在第十八章描述的内存高效机制上,该章包括一个完整示例,展示了如何使用这些类型处理 JSON。这是一个非常高性能的选择,但使用起来并不是最容易的。

注意

正如名称所示,这些类型使用 UTF-8 编码来读取和写入 JSON。这是发送和存储 JSON 数据最广泛使用的编码方式,因此所有 System.Text.Json 都针对其进行了优化。因此,性能敏感的代码通常应避免在 .NET string 中获取 JSON 文档,因为这将使用 UTF-16 编码,需要在您可以使用这些 API 之前转换为 UTF-8。

还有 JsonSerializer 类,它在整个 .NET 对象和 JSON 之间进行转换。它要求您定义的类结构与 JSON 对应。

最后,System.Text.Json 提供了可以提供 JSON 文档结构描述的类型。当您在开发时不确定 JSON 数据结构的确切形式时,这些类型非常有用,因为它们提供了一个灵活的对象模型,可以适应任何形状的 JSON 数据。事实上,有两种方法可以实现这种方法。我们有 JsonDocumentJsonElement 和相关类型,提供了一种高效的只读机制,用于检查 JSON 文档,以及更灵活但略显低效的 JsonNode,它是可写的,使您可以从头开始构建 JSON 的描述,或者读入一些 JSON 然后修改它。

JsonSerializer

JsonSerializer 提供了一种基于属性的序列化模型,您可以在其中定义一个或多个类,反映您需要处理的 JSON 数据的结构,然后可以在这些模型之间进行 JSON 数据的转换。

示例 15-18 展示了一个简单的模型,适合与 JsonSerializer 一起使用。正如您所见,我不必使用任何特定的基类,也没有强制要求的属性。

示例 15-18. 简单的 JSON 序列化模型
public class SimpleData
{
    public int Id { get; set; }
    public IList<string>? Names { get; set; }
    public NestedData? Location { get; set; }
    public IDictionary<string, int>? Map { get; set; }
}

public class NestedData
{
    public string? LocationName { get; set; }
    public double Latitude { get; set; }
    public double Longitude { get; set; }
}

示例 15-19 创建了此模型的一个实例,然后使用 JsonConvert 类的 Serialize 方法将其序列化为字符串。

示例 15-19. 使用 JsonSerializer 序列化数据
var model = new SimpleData
{
    Id = 42,
    Names = new[] { "Bell", "Stacey", "her", "Jane" },
    Location = new NestedData
    {
        LocationName = "London",
        Latitude = 51.503209,
        Longitude = -0.119145
    },
    Map = new Dictionary<string, int>
    {
        { "Answer", 42 },
        { "FirstPrime", 2 }
    }
};

string json = JsonSerializer.Serialize(
    model,
    new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(json);

Serialize 的第二个参数是可选的。我在这里使用它来缩进 JSON,使其更易于阅读。(默认情况下,JsonSerializer 将使用更高效的布局,没有不必要的空格,但这样更难阅读。)结果如下所示:

{
  "Id": 42,
  "Names": [
    "Bell",
    "Stacey",
    "her",
    "Jane"
  ],
  "Location": {
    "LocationName": "London",
    "Latitude": 51.503209,
    "Longitude": -0.119145
  },
  "Map": {
    "Answer": 42,
    "FirstPrime": 2
  }
}

正如您所见,每个 .NET 对象都变成了一个 JSON 对象,其中名称/值对应于模型中的属性。数字和字符串的表示与您的预期完全一致。IList<string> 变成了一个 JSON 数组,而 IDictionary<string, int> 则变成了另一个 JSON 字典。我在这些集合中使用了接口,但您也可以使用具体的 List<T>Dictio⁠nary​<TKey,TValue> 类型。如果您喜欢,也可以使用普通的数组来表示列表。我倾向于使用接口,因为这样可以自由地使用任何集合类型(例如,示例 15-19 使用字符串数组初始化了 Names 属性,但也可以使用 List<string> 而不更改模型类型)。

将序列化后的 JSON 转换回模型同样简单,正如 示例 15-20 所示。

示例 15-20. 使用 JsonSerializer 反序列化数据
var deserialized = JsonSerializer.Deserialize<SimpleData>(json);

尽管如此简单的模型通常就足够了,但有时您可能需要控制序列化的某些方面,特别是在使用外部定义的 JSON 格式时。例如,您可能需要与使用与 .NET 不同的命名约定的 JSON API 一起工作——驼峰命名法很受欢迎,但与 .NET 属性的帕斯卡命名法冲突。解决此问题的一种方法是使用 JsonPropertyName 属性来指定在 JSON 中使用的名称,如 示例 15-21 所示。

示例 15-21. 使用 JsonPropertyName 属性控制 JSON
public class NestedData
{
 [JsonPropertyName("locationName")]
    public string? LocationName { get; set; }

 [JsonPropertyName("latitude")]
    public double Latitude { get; set; }

 [JsonPropertyName("longitude")]
    public double Longitude { get; set; }
}

在序列化时,JsonSerializer 会使用 JsonPropertyName 中指定的名称,并在反序列化时寻找这些名称。这种方法完全控制了 .NET 和 JSON 属性的命名,但在特定情况下也有更简单的解决方案。这种仅更改首字母大小写的重命名是如此常见,以至于可以让 JsonSerializer 来完成。传递给 JsonSerializer.SerializeJsonSerializationOptions 可以选择使用 JsonSerializerDefaults 类型的可选构造函数参数,如果像 示例 15-22 中那样传递 JsonSeri⁠ali⁠zerDefaults.Web,将会得到驼峰样式的命名,而无需使用任何属性。

示例 15-22. 使用 JsonSerializerDefaults 来获取驼峰式属性名称
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true
};
string json = JsonSerializer.Serialize(
    model,
    options);

JsonSerializerOptions 还提供了处理循环引用的方法。假设你要序列化 SelfRef 类型的对象,如 示例 15-23 所示。

示例 15-23. 支持循环引用的类型
public class SelfRef
{
    public string? Name { get; set; }
    public SelfRef? Next { get; set; }
}

默认情况下,如果试图序列化直接或间接引用彼此的对象,将会收到 JsonException 报告可能存在循环。它说“可能”是因为默认情况下它不直接检测循环,而是 JsonSerializer 对任何对象图的深度都有一个限制。这可以通过 JsonSerializerOptions.MaxDepth 属性进行配置,但默认情况下,如果超过 64 个对象的深度,序列化器将报告错误。但是,可以通过设置 ReferenceHandler 来更改其行为。示例 15-24 将此设置为 ReferenceHandler.Preserve,使其能够序列化相互引用的 SelfRef 实例对。

示例 15-24. 序列化支持循环引用的类型
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true,
    ReferenceHandler = ReferenceHandler.Preserve
};
var circle = new SelfRef
{
    Name = "Top",
    Next = new SelfRef
    {
        Name = "Bottom",
    }
};
circle.Next.Next = circle;
string json = JsonSerializer.Serialize(circle, options);

为实现此目的,JsonSerializer 通过添加一个 $id 属性为对象分配标识符:

{
  "$id": "1",
  "name": "Top",
  "next": {
    "$id": "2",
    "name": "Bottom",
    "next": {
      "$ref": "1"
    }
  }
}

这使得序列化器在遇到循环引用时能够避免问题。每当它需要序列化属性时,它会检查该属性是否引用了已经写出的对象(或正在写出的对象)。如果是这样,而不是尝试再次写出对象(这将导致无限循环,因为它会不断遇到循环引用),序列化器会发出一个 JSON 对象,其中包含一个特殊名称为 $ref 的属性,指向相关的 $id。这不是 JSON 的普遍支持形式,因此 ID 生成默认情况下未启用。

你可以使用 JsonSerializerOptions 控制序列化的许多其他方面,例如为数据类型定义自定义序列化机制。例如,你可能想在 C# 代码中表示某些内容为 DateTimeOffset,但希望在 JSON 中将其变为带有特定日期时间格式的字符串。详细信息可以在 System.Text.Json 文档中找到。

JSON 文档对象模型

JsonSerializer 要求你定义一个或多个类型来表示你想处理的 JSON 结构,而 System.Text.Json 则提供了一组固定类型,支持更动态的方法。你可以构建一个文档对象模型(DOM),其中诸如 JsonElementJsonNode 的类型实例表示 JSON 的结构。

System.Text.Json 提供了两种构建 DOM 的方式。如果你已经有 JSON 格式的数据,可以使用 JsonDocument 类获取 JSON 的只读模型,其中每个对象、值和数组都表示为 JsonElement,而对象中的每个属性则表示为 JsonProperty。示例 15-25 使用 JsonDocument 调用 RootElement.EnumerateObject() 来发现 JSON 根对象中的所有属性。这返回一个 JsonProperty 结构的集合。

示例 15-25. 使用 JsonDocumentJsonElement 进行动态 JSON 检查
using (JsonDocument document = JsonDocument.Parse(json))
{
    foreach (JsonProperty property in document.RootElement.EnumerateObject())
    {
        Console.WriteLine($"Property: {property.Name} ({property.Value.ValueKind})");
    }
}

运行这段代码,处理早期示例生成的序列化文档后,会产生以下输出:

Property: id (Number)
Property: names (Array)
Property: location (Object)
Property: map (Object)

正如所示,我们能够在运行时发现存在的属性。JsonProperty.Value 返回一个 JsonElement 结构,可以检查其 ValueKind 来确定其所表示的 JSON 值类型。如果是数组,可以通过调用 EnumerateArray 枚举其内容;如果是字符串值,可以通过调用 GetString 读取其值。Example 15-26 使用这些方法展示了 names 属性中的所有字符串。

Example 15-26. 使用 JsonDocumentJsonElement 动态枚举 JSON 数组
JsonElement namesElement = document.RootElement.GetProperty("names");
foreach (JsonElement name in namesElement.EnumerateArray())
{
    Console.WriteLine($"Name: {name.GetString()}");
}

正如本例还展示的那样,如果事先知道某个属性肯定存在,就无需使用 EnumerateObject 来查找它:可以直接调用 GetProperty。对于可选属性,还有 TryGetProperty 方法。Example 15-27 使用了两者:将根对象的 location 属性视为可选,但如果存在,还需要 locationNamelatitudelongitude 属性。

Example 15-27. 使用 JsonElement 读取 JSON 属性
if (root.TryGetProperty("location", out JsonElement locationElement))
{
    JsonElement nameElement = locationElement.GetProperty("locationName");
    JsonElement latitudeElement = locationElement.GetProperty("latitude");
    JsonElement longitudeElement = locationElement.GetProperty("longitude");
    string locationName = nameElement.GetString()!;
    double latitude = latitudeElement.GetDouble();
    double longitude = longitudeElement.GetDouble();
    Console.WriteLine($"Location: {locationName}: {latitude},{longitude}");
}

除了结构元素、对象和数组之外,在 JSON 规范 中的数据模型还识别四种基本数据类型:字符串、数字、布尔和 null。正如您所见,可以使用 Kind 属性确定 JsonElement 所表示的基本数据类型之一。如果是基本数据类型之一,可以使用相应的 Get 方法。最后两个示例都使用了 GetString,第二个示例还使用了 GetDouble。可以使用多种方法来获取数字:如果预期是整数,可以调用 GetSByteGetInt16GetInt32GetInt64(也有无符号版本),具体取决于预期的值范围。还有 GetDecimal 方法。

JsonElement 还提供了读取特定格式字符串属性的方法:GetGuidGetDateTimeGetDateTimeOffsetGetBytesFromBase64

所有的 Get 方法如果数值不符合所需格式,都会抛出 InvalidOperationException 异常。它们都有对应的 TryGet 形式,可以在数据无法按预期方式解析时进行检测,而无需触发异常。

这些类型试图最小化分配的内存量。JsonElementJsonProperty都是结构体,因此您可以在不引起额外堆分配的情况下获取它们。JsonDocument通过 UTF-8 格式保存底层数据,并且JsonElementJsonProperty实例只是引用该数据,避免了分配相关数据的副本的需要。显然,底层数据确实需要存放在某处,并且根据您如何加载 JSON 到JsonDocument中的方式,可能需要分配一些内存来存放它。(例如,您可以传递一个Stream,由于并非所有流都可重播,JsonDocument可能需要复制流的内容。)JsonDocument使用.NET 运行库中可用的缓冲池特性来管理这些数据,这意味着如果应用程序解析许多 JSON 文档,它可能能够重用内存,减少垃圾收集器(GC)的压力。但这也意味着JsonDocument需要知道您何时完成对 JSON 的处理,以便可以将缓冲区返回到池中。这就是在使用JsonDocument时为什么要使用using语句的原因。

警告

请注意,JsonElement.GetString比所有其他Get方法都更昂贵,因为它必须在堆上创建一个新的.NET 字符串。其他Get方法都返回值类型,因此它们不会导致堆分配。

我之前提到有两种处理 JSON DOM 的方式。JsonDocument提供了一个只读模型,允许您检查现有的 JSON。但还有JsonNode,它是可读/写的。您可以使用它的方式是JsonDocument不支持的。您可以从头开始建立一个对象模型来创建一个新的 JSON 文档。或者,您可以像使用JsonDocument一样解析现有的 JSON 到对象模型中,但是当您使用JsonNode时,结果模型是可修改的。因此,您可以使用它来加载一些 JSON 并对其进行修改,正如示例 15-28 所示。

示例 15-28. 使用JsonNode修改 JSON
JsonNode rootNode = JsonNode.Parse(json)!;
JsonNode mapNode = rootNode["map"]!;
mapNode["iceCream"] = 99;

这会将json中的 JSON 文本加载到JsonNode中,然后检索map属性。(此示例期望与前面示例中使用的 JSON 形式相同,其中属性名称为驼峰式命名。)到目前为止,这并没有做任何我们不能用JsonDocument做的事情。但最后一行在map对象中添加了一个新条目。正是这种修改文档的能力使JsonNode更加强大。那么如果JsonNode更强大,为什么还需要JsonDocument呢?这种能力是有代价的:JsonNode效率较低,因此如果不需要额外的灵活性,就不应使用它。

使用只读的JsonDocumentJsonElement或可写的JsonNode的主要优势在于,你无需定义任何类型来建模数据。它们还能更轻松地编写以数据结构驱动行为的代码,因为这些 API 能描述它们所找到的内容。只读形式通常比JsonSerializer更高效,因为在从 JSON 文档读取数据时可能能减少对象分配。

概要

Stream类是表示数据的字节序列的抽象。流可以支持读取、写入或两者,并且可以支持定位到任意偏移量以及直接的顺序访问。TextReaderTextWriter提供严格的字符数据顺序读取和写入,抽象化字符编码。这些类型可以位于文件、网络连接、内存之上,或者你可以实现自己版本的这些抽象类。FileStream类还提供一些其他的文件系统访问功能,但为了完全控制,我们还有FileDirectory类。当字节和字符串不足时,.NET 提供各种序列化机制,可以自动映射对象在内存中的状态与可以写入磁盘或发送到网络或任何其他类似流目标的表示之间的关系;这种表示后来可以转换回相同类型的对象,并且具有等效的状态。

正如你所看到的,一些文件和流 API 提供了异步形式,可以帮助提升性能,特别是在高并发系统中。下一章将讨论并发性、并行性以及这些 API 的异步形式所使用的基于任务的模式。

¹ 你可能认为井号是#,但如果像我一样是英国人,那就不对了。这就像有人坚持把@称为美元符号一样。Unicode 对#的官方名称是number sign,它还允许使用我偏爱的选项hash,以及octothorpecrosshatch,以及遗憾的是英镑符号

² 以防你还不知道这个术语,在小端表示中,多字节值以低位字节开始,因此在 16 位小端中,值 0x1234 将是 0x34、0x12,而大端版本将是 0x12、0x34。小端看起来是颠倒的,但它是 Intel 处理器的本地格式。

³ 一些 Unicode 字符在 UTF-8 中可以占据最多 4 个字节,因此乘以三似乎可能低估了。然而,所有这些字符在 UTF-16 中都需要两个代码单元。在.NET 中的任何单个char在 UTF-8 中最多只需要 3 个字节。

⁴ 当 .NET 2.0 引入了一种新的操作系统句柄表示方式时,四种重载方法变得过时了。那时接受 IntPtr 的重载方法被弃用,取而代之的是接受 SafeFileHandle 的新方法。

⁵ 这些方法都返回一个相对于计算机当前时区的 DateTime。每个方法都有一个相应的等效方法,返回相对于时区零的时间(例如 GetCreationTimeUtc)。

⁶ 这在 .NET Framework 上不可用。在那里,开源的 JSON.NET 项目,在 Newtonsoft 网站 或通过 NuGet 的 Newtonsoft.Json,是一个受欢迎的选择。