原文:
zh.annas-archive.org/md5/e2c84fd09097e50aedbc4e5989f32a85
译者:飞龙
第十七章:程序集
程序集是.NET 中的部署基本单元,也是所有类型的容器。程序集包含具有其中间语言(IL)代码的编译类型,运行时资源,并帮助版本控制和引用其他程序集的信息。程序集还定义了类型解析的边界。在.NET 中,一个程序集由一个扩展名为*.dll*的单个文件组成。
注意
当您在.NET 中构建可执行应用程序时,最终会得到两个文件:一个程序集(.dll)和一个适合目标平台的可执行启动器(.exe)。
这与.NET Framework 中的情况不同,后者生成一个可移植可执行文件(PE)程序集。PE 具有*.exe*扩展名,既可以作为程序集,又可以作为应用程序启动器。PE 可以同时针对 32 位和 64 位 Windows 版本。
本章大部分类型来自以下命名空间:
System.Reflection
System.Resources
System.Globalization
一个程序集的内容
一个程序集包含四种内容:
程序集清单
提供 CLR 的信息,例如程序集的名称、版本和其他它引用的程序集
应用程序清单
提供操作系统的信息,例如程序集应如何部署以及是否需要管理员权限
编译类型
程序集中定义的类型的编译 IL 代码和元数据
资源
程序集中嵌入的其他数据,例如图像和可本地化文本
这些中,只有程序集清单是强制的,尽管一个程序集几乎总是包含编译的类型(除非它是一个资源程序集。参见“资源和卫星程序集”)。
程序集清单
程序集清单有两个目的:
-
它描述了程序集给托管的主机环境。
-
它充当模块、类型和程序集中的资源的目录。
因此,程序集是自描述的。使用者可以发现程序集的所有数据、类型和函数,而无需额外的文件。
注意
程序集清单并不是你明确添加到程序集中的东西——它是编译过程中自动嵌入到程序集中的一部分。
下面是存储在清单中的功能重要数据的摘要:
-
程序集的简单名称
-
版本号(
AssemblyVersion
) -
程序集的公钥和签名哈希(如果是强命名的)
-
引用程序集的列表,包括它们的版本和公钥
-
列出程序集中定义的类型
-
它所针对的文化,如果是卫星程序集(
AssemblyCulture
)
清单还可以存储以下信息数据:
-
完整的标题和描述(
AssemblyTitle
和AssemblyDescription
) -
公司和版权信息(
AssemblyCompany
和AssemblyCopyright
) -
显示版本(
AssemblyInformationalVersion
) -
用于自定义数据的其他属性
其中一些数据源于向编译器提供的参数,例如引用的程序集列表或用于签名程序集的公钥。其余部分来自程序集属性,括在括号中。
注意
您可以使用 .NET 工具 ildasm.exe 查看程序集清单的内容。在 第十八章 中,我们描述了如何使用反射以编程方式执行相同操作。
指定程序集属性
常用程序集属性可以在项目的属性页上指定,位于“包”选项卡中的 Visual Studio 中。该选项卡上的设置将添加到项目文件(.csproj)中。
要指定不受“包”选项卡支持的属性,或者不使用 .csproj 文件,可以在源代码中指定程序集属性(通常在名为 AssemblyInfo.cs 的文件中完成)。
专用属性文件仅包含 using
语句和程序集属性声明。例如,要向单元测试项目公开内部作用域类型,可以执行以下操作:
using System.Runtime.CompilerServices;
[assembly:InternalsVisibleTo("MyUnitTestProject")]
应用程序清单(Windows)
应用程序清单是一个 XML 文件,用于向操作系统传达有关程序集的信息。在构建过程中,应用程序清单作为 Win32 资源嵌入到启动可执行文件中。如果存在清单,则在 CLR 加载程序集之前会读取和处理清单,可能影响 Windows 启动应用程序的方式。
.NET 应用程序清单在 XML 命名空间 urn:schemas-microsoft-com:asm.v1
中具有名为 assembly
的根元素:
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" >
<!-- contents of manifest -->
</assembly>
以下清单指示操作系统请求管理员权限:
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" >
<trustInfo >
<security>
<requestedPrivileges>
<requestedExecutionLevel level="requireAdministrator" />
</requestedPrivileges>
</security>
</trustInfo>
</assembly>
(UWP 应用程序具有更为复杂的清单,在 Package.appxmanifest 文件中进行描述。这包括程序的功能声明,决定了操作系统授予的权限。编辑此文件的最简单方式是使用 Visual Studio,双击清单文件时会显示对话框。)
部署应用程序清单
在 Visual Studio 中,通过在解决方案资源管理器中右键单击项目,选择“添加”,然后选择“新建项目”,再选择“应用程序清单文件”,即可向 .NET 项目添加应用程序清单。构建项目后,清单将嵌入到输出的程序集中。
注意
.NET 工具 ildasm.exe 无法感知嵌入的应用程序清单的存在。但是,使用 Visual Studio 时,如果在解决方案资源管理器中双击程序集,可以看到是否存在嵌入的应用程序清单。
模块
程序集的内容实际上打包在一个中间容器中,称为 模块。模块对应于包含程序集内容的文件。引入此额外的容器层的原因是允许一个程序集跨多个文件。这是 .NET Framework 中存在但在 .NET 5+ 和 .NET Core 中缺失的功能。图 17-1 描述了这种关系。
https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/cs12-ntsh/img/cn10_1701.png
图 17-1. 单文件组件
尽管 .NET 不支持多文件组件,但有时您需要注意模块带来的额外层次结构。主要场景是反射(见 “反射组件” 和 “生成组件和类型”)。
Assembly 类
System.Reflection
中的 Assembly
类是在运行时访问组件元数据的入口。有多种方法可以获取组件对象:最简单的方法是通过 Type
的 Assembly
属性:
Assembly a = typeof (Program).Assembly;
您还可以通过调用 Assembly
的静态方法之一来获取 Assembly
对象:
GetExecutingAssembly
返回定义当前执行函数的类型的组件
GetCallingAssembly
执行与 GetExecutingAssembly
相同的操作,但适用于调用当前执行函数的函数
GetEntryAssembly
返回定义应用程序原始入口方法的组件
获取 Assembly
对象后,您可以使用其属性和方法查询组件的元数据,并反映其类型。表 17-1 总结了这些功能。
表 17-1. 组件成员
函数 | 目的 | 参见章节… |
---|---|---|
FullName , GetName | 返回完全限定名称或 AssemblyName 对象 | “组件名称” |
CodeBase , Location | 组件文件的位置 | “加载、解析和隔离组件” |
Load , LoadFrom , LoadFile | 手动将组件加载到内存中 | “加载、解析和隔离组件” |
GetSatelliteAssembly | 定位给定文化的卫星组件 | “资源和卫星组件” |
GetType , GetTypes | 返回组件中定义的类型或所有类型 | “反射和激活类型” |
EntryPoint | 返回应用程序的入口方法,作为 MethodInfo | “反射和调用成员” |
GetModule , GetModules , ManifestModule | 返回组件的所有模块或主模块 | “反射组件” |
GetCustomAttribute , GetCustomAttributes | 返回组件的属性 | “使用属性” |
强命名和组件签名
注意
在 .NET Framework 中,强命名组件之所以重要有两个原因:
-
它允许组件加载到“全局组件缓存”中。
-
它允许其他强命名组件引用该组件。
在 .NET 5+ 和 .NET Core 中,强命名不再那么重要,因为这些运行时没有全局组件缓存,也不会施加第二个限制。
强命名的程序集具有唯一的标识。它通过向清单添加两个元数据来工作:
-
属于程序集作者的唯一编号
-
程序集的签名哈希,证明唯一编号持有者生成了该程序集
这需要一个公共/私有密钥对。公共密钥提供唯一的标识号码,私有密钥用于签名。
注意
强名称签名与Authenticode签名不同。我们稍后在本章讨论 Authenticode。
公共密钥在保证程序集引用的唯一性方面非常有价值:强命名的程序集将公钥并入其标识中。
在.NET Framework 中,私钥保护您的程序集免受篡改,没有私钥,无法发布修改版而不破坏签名的程序集。实际上,在将程序集加载到.NET Framework 的全局程序集缓存时,这非常有用。在.NET 5+和.NET Core 中,签名几乎没有用,因为从未检查过。
向之前命名“弱”的程序集添加强名称会改变其标识。因此,如果您认为程序集将来可能需要强名称,最好从一开始就为其添加强名称。
如何为程序集提供强名称
要为程序集提供强名称,首先使用sn.exe实用程序生成公共/私有密钥对:
sn.exe -k MyKeyPair.snk
注意
Visual Studio 安装了一个名为Developer Command Prompt for VS的快捷方式,它启动的命令提示符包含开发工具,如sn.exe。
这会生成一个新的密钥对,并将其存储到名为MyKeyPair.snk的文件中。如果随后丢失此文件,您将永久失去使用相同标识重新编译程序集的能力。
您可以通过更新项目文件来使用此文件对程序集进行签名。从 Visual Studio,转到项目属性窗口,然后在Signing选项卡上,选中“Sign the assembly”复选框,并选择您的*.snk*文件。
相同的密钥对可以签署多个程序集 - 如果它们的简单名称不同,则它们仍将具有不同的标识。
程序集名称
程序集的“标识”由其清单中的四个元数据组成:
-
其简单名称
-
其版本号(如果不存在则为“0.0.0.0”)
-
其文化(如果不是卫星则为“neutral”)
-
其公钥令牌(如果未强命名则为“null”)
简单名称并非由任何属性命名,而是来自最初编译的文件名称(减去任何扩展名)。因此,System.Xml.dll 程序集的简单名称是“System.Xml”。重命名文件不会改变程序集的简单名称。
版本号来自AssemblyVersion
属性。它是一个分为四部分的字符串,如下所示:
*major*.*minor*.*build*.*revision*
您可以如下指定版本号:
[assembly: AssemblyVersion ("2.5.6.7")]
文化来自AssemblyCulture
属性,并适用于稍后在“资源和卫星程序集”部分描述的卫星程序集。
公钥标记来自于编译时提供的强名称,正如我们在前面的部分讨论的那样。
完全限定名称
完全限定的程序集名称是一个字符串,其中包括所有四个标识组件,格式如下:
*simple-name*, Version=*version*, Culture=*culture*, PublicKeyToken=*public-key*
例如,System.Private.CoreLib.dll的完全限定名称是System.Private.CoreLib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e。
如果程序集没有AssemblyVersion
属性,则版本显示为0.0.0.0
。如果它未签名,则其公钥标记显示为null
。
Assembly
对象的FullName
属性返回其完全限定名称。编译器在记录程序集引用到清单中时总是使用完全限定名称。
注意
完全限定的程序集名称不包括用于在磁盘上定位它的目录路径。定位位于另一个目录中的程序集是一个完全不同的问题,我们将在“加载、解析和隔离程序集”中讨论。
AssemblyName
类
AssemblyName
是一个类,每个完全限定程序集名称的组件都有一个类型化的属性。AssemblyName
有两个目的:
-
它解析或构建一个完全限定的程序集名称。
-
它存储一些额外的数据以帮助解析(查找)程序集。
你可以通过以下任一方式获得一个AssemblyName
对象:
-
实例化一个
AssemblyName
,提供完全限定名。 -
在现有的
Assembly
上调用GetName
。 -
调用
AssemblyName.GetAssemblyName
,提供磁盘上程序集文件的路径。
你也可以实例化一个没有任何参数的AssemblyName
对象,然后设置每个属性以构建一个完全限定名。以这种方式构造的AssemblyName
是可变的。
下面是它的基本属性和方法:
string FullName { get; } // Fully qualified name
string Name { get; set; } // Simple name
Version Version { get; set; } // Assembly version
CultureInfo CultureInfo { get; set; } // For satellite assemblies
string CodeBase { get; set; } // Location
byte[] GetPublicKey(); // 160 bytes
void SetPublicKey (byte[] key);
byte[] GetPublicKeyToken(); // 8-byte version
void SetPublicKeyToken (byte[] publicKeyToken);
Version
本身是一个强类型表示,具有Major
、Minor
、Build
和Revision
数字的属性。GetPublicKey
返回完整的加密公钥;GetPublicKeyToken
返回用于建立身份的最后八个字节。
要使用AssemblyName
获取程序集的简单名称:
Console.WriteLine (typeof (string).Assembly.GetName().Name);
// System.Private.CoreLib
要获取程序集版本:
string v = myAssembly.GetName().Version.ToString();
程序集信息和文件版本
另外两个与版本相关的程序集属性可用。与AssemblyVersion
不同,以下两个属性不会影响程序集的标识,因此对编译时或运行时发生的情况没有影响:
AssemblyInformationalVersion
作为最终用户可见的版本。这在 Windows 文件属性对话框中显示为产品版本。可以在此处使用任何字符串,例如“5.1 Beta 2”。通常,应用程序中的所有程序集将被分配相同的信息版本号。
AssemblyFileVersion
这是用来指代该程序集的生成编号。在 Windows 文件属性对话框中称为文件版本。与AssemblyVersion
一样,它必须包含一个由点分隔的最多四个数字组成的字符串。
Authenticode 签名
Authenticode是一个代码签名系统,其目的是证明发布者的身份。Authenticode 和强名称签名是独立的:您可以使用任一或两种系统对程序集进行签名。
尽管强名称签名可以证明程序集 A、B 和 C 来自同一方(假设私钥未泄漏),但它不能告诉您那个方是谁。要知道方是 Joe Albahari 或 Microsoft Corporation,您需要 Authenticode。
从互联网下载程序时,Authenticode 非常有用,因为它确保程序来自证书颁发机构指定的发布者,并且在传输过程中未经修改。它还可以防止第一次运行下载应用程序时的“未知发布者”警告。提交应用程序到 Windows 商店也需要 Authenticode 签名。
Authenticode 不仅适用于.NET 程序集,还适用于非托管可执行文件和二进制文件,如*.msi*部署文件。当然,Authenticode 并不能保证程序没有恶意软件——尽管它确实降低了这种可能性。一个人或实体愿意在可执行文件或库文件背后署名(由护照或公司文件支持)。
注意
CLR 不将 Authenticode 签名视为程序集身份的一部分。然而,它可以按需读取和验证 Authenticode 签名,您将很快看到。
使用 Authenticode 需要与证书颁发机构(CA)联系,提供个人身份或公司身份的证明(公司章程等)。CA 审核您的文件后,将颁发一个通常有效期为一到五年的 X.509 代码签名证书。这使您可以使用signtool实用程序签名程序集。您也可以使用makecert实用程序自行创建证书;但是,它只能在显式安装了该证书的计算机上识别。
(非自签名)证书可以在任何计算机上工作的事实依赖于公钥基础设施。基本上,您的证书由另一个属于 CA 的证书签名。CA 是受信任的,因为所有 CA 都加载到操作系统中。(要查看它们,请转到 Windows 控制面板,然后在搜索框中键入**certificate**
。在“管理计算机证书”中,打开“受信任的根证书颁发机构”节点,然后单击证书。这将启动证书管理器。)如果泄露了发布者的证书,CA 可以吊销其证书,因此验证 Authenticode 签名需要定期向 CA 请求最新的证书吊销列表。
因为 Authenticode 使用加密签名,如果有人随后篡改文件,Authenticode 签名将无效。我们将在第二十章讨论加密、哈希和签名。
如何使用 Authenticode 签名
获取和安装证书
第一步是从 CA 获取代码签名证书(请参阅接下来的侧边栏)。然后,您可以将证书作为受密码保护的文件处理,或者将证书加载到计算机的证书存储中。后者的好处是,您可以在不需要指定密码的情况下进行签名。这是有利的,因为它可以防止密码出现在自动构建脚本或批处理文件中。
要将证书加载到计算机的证书存储中,请如前所述打开证书管理器。打开个人文件夹,右键单击其证书文件夹,然后选择所有任务/导入。导入向导将指导您完成整个过程。导入完成后,单击证书上的“查看”按钮,转到“详细信息”选项卡,并复制证书的指纹。这是 SHA-256 哈希值,您随后需要用它来识别签名时使用的证书。
注意
如果您还希望对程序集进行强名称签名,必须在进行 Authenticode 签名之前完成。这是因为 CLR 了解 Authenticode 签名,但反之不然。因此,如果在 Authenticode 签名后再对程序集进行强名称签名,后者将视 CLR 的强名称添加为未经授权的修改,并认为程序集已被篡改。
使用 signtool.exe 进行签名
您可以使用随 Visual Studio 提供的signtool工具对程序进行 Authenticode 签名(在Program Files下的Microsoft SDKs\ClickOnce\SignTool文件夹中查找)。以下是使用计算机的My Store中名为“Joseph Albahari”的证书,并使用安全的 SHA256 哈希算法对名为LINQPad.exe的文件进行签名的示例:
signtool sign /n "Joseph Albahari" /fd sha256 LINQPad.exe
您还可以使用/d
和/du
指定描述和产品 URL:
... /d LINQPad /du *http://www.linqpad.net*
大多数情况下,您还需要指定一个时间戳服务器。
时间戳
在您的证书过期后,您将无法再签名程序。但是,如果在其过期之前使用/tr
开关指定了时间戳服务器来签名程序,那么您之前签名的程序仍将有效。CA 将为此提供一个 URI:以下是为 Comodo(或 K Software)提供的:
... /tr *http://timestamp.comodoca.com/authenticode* /td SHA256
验证程序是否已签名
在 Windows 资源管理器中查看文件的 Authenticode 签名最简单的方法是查看文件属性(查看数字签名选项卡)。signtool工具也提供了此选项。
资源和卫星程序集
应用程序通常不仅包含可执行代码,还包括文本、图像或 XML 文件等内容。这些内容可以通过资源在程序集中表示。资源有两个重叠的用途场景:
-
包含不能放入源代码的数据,例如图片
-
在多语言应用程序中存储可能需要翻译的数据
程序集资源最终是一个带有名称的字节流。你可以将程序集看作是包含以字符串为键的字节数组字典。如果你反汇编包含名为 banner.jpg 和名为 data.xml 的资源的程序集,可以在 ildasm 中看到这一点:
.mresource public banner.jpg
{
// Offset: 0x00000F58 Length: 0x000004F6
}
.mresource public data.xml
{
// Offset: 0x00001458 Length: 0x0000027E
}
在这种情况下,banner.jpg 和 data.xml 直接包含在程序集中,每个作为其自身的嵌入资源。这是最简单的工作方式。
.NET 还允许你通过中间的 .resources 容器添加内容。这些设计用于保存可能需要翻译成不同语言的内容。本地化的 .resources 可以打包为个别的卫星程序集,根据用户操作系统语言在运行时自动选择。
图 17-2 说明了一个包含两个直接嵌入资源以及一个名为 welcome.resources 的 .resources 容器的程序集,我们已经为它创建了两个本地化卫星。
https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/cs12-ntsh/img/cn10_1702.png
图 17-2. 资源
直接嵌入资源
注意
在 Windows Store 应用中不支持将资源嵌入到程序集中。相反,将任何额外的文件添加到你的部署包中,并通过从应用的 StorageFolder
(Package.Current.InstalledLocation
) 读取它们来访问。
直接使用 Visual Studio 嵌入资源:
-
将文件添加到你的项目中。
-
将其构建操作设置为嵌入资源。
Visual Studio 总是使用项目的默认命名空间前缀资源名称,加上包含文件的任何子文件夹的名称。所以,如果你的项目默认命名空间是 Westwind.Reports
,并且你的文件在名为 pictures 的文件夹中叫做 banner.jpg,资源名称将是 Westwind.Reports.pictures.banner.jpg。
注意
资源名称区分大小写。这使得在包含资源的 Visual Studio 项目子文件夹名称实际上是大小写敏感的。
要检索资源,可以在包含资源的程序集上调用 GetManifestResourceStream
。这会返回一个流,你可以像处理其他流一样读取它:
Assembly a = Assembly.GetEntryAssembly();
using (Stream s = a.GetManifestResourceStream ("TestProject.data.xml"))
using (XmlReader r = XmlReader.Create (s))
...
System.Drawing.Image image;
using (Stream s = a.GetManifestResourceStream ("TestProject.banner.jpg"))
image = System.Drawing.Image.FromStream (s);
返回的流是可寻址的,所以你也可以这样做:
byte[] data;
using (Stream s = a.GetManifestResourceStream ("TestProject.banner.jpg"))
data = new BinaryReader (s).ReadBytes ((int) s.Length);
如果你使用 Visual Studio 嵌入资源,必须记得包含基于命名空间的前缀。为了帮助避免错误,你可以在单独的参数中指定前缀,使用类型。类型的命名空间被用作前缀:
using (Stream s = a.GetManifestResourceStream (typeof (X), "data.xml"))
X
可以是带有你资源所需命名空间的任何类型(通常是同一项目文件夹中的类型)。
注意
在 Windows Presentation Foundation(WPF)应用程序中,将项目项的构建操作设置为资源与设置其构建操作设置为嵌入资源不同。前者实际上将项目添加到名为 .g.resources 的 .resources 文件中,其内容可以通过 WPF 的 Application
类访问,使用 URI 作为键。
为了增加混淆,WPF 进一步重载了术语“资源”。静态资源 和 动态资源 都与程序集资源无关!
GetManifestResourceNames
返回程序集中所有资源的名称。
.resources 文件
.resources 文件是包含潜在本地化内容的容器。 .resources 文件最终作为嵌入式资源嵌入到程序集中,就像任何其他类型的文件一样。区别在于,您必须执行以下操作:
-
将内容打包到 .resources 文件中开始
-
通过
ResourceManager
或 pack URI 访问其内容,而不是通过GetManifestResourceStream
获取。
.resources 文件以二进制结构化,因此不适合人工编辑;因此,您必须依赖 .NET 和 Visual Studio 提供的工具来处理它们。对于字符串或简单数据类型的标准方法是使用 .resx 格式,该格式可以通过 Visual Studio 或 resgen
工具转换为 .resources 文件。 .resx 格式也适用于用于 Windows Forms 或 ASP.NET 应用程序的图像。
在 WPF 应用程序中,无论是否需要本地化,都必须使用 Visual Studio 的“资源”构建操作来处理需要通过 URI 引用的图像或类似内容。
我们在以下各节中描述如何执行每个操作。
.resx 文件
.resx 文件是用于生成 .resources 文件的设计时格式。 .resx 文件使用 XML,并且结构化为以下名称/值对:
<root>
<data name="Greeting">
<value>hello</value>
</data>
<data name="DefaultFontSize" type="System.Int32, mscorlib">
<value>10</value>
</data>
</root>
要在 Visual Studio 中创建 .resx 文件,请添加类型为资源文件的项目项。其余工作将自动完成:
-
创建了正确的标头。
-
提供了一个设计器,用于添加字符串、图像、文件和其他类型的数据。
-
.resx 文件在编译时自动转换为 .resources 格式并嵌入到程序集中。
-
编写一个类来帮助您稍后访问数据。
注意
资源设计器将图像添加为类型化的 Image
对象(System.Drawing.dll),而不是字节数组,因此不适用于 WPF 应用程序。
读取 .resources 文件
注意
如果在 Visual Studio 中创建 .resx 文件,则将自动生成同名类,其中包含检索其各个项的属性。
ResourceManager
类读取嵌入在程序集中的 .resources 文件:
ResourceManager r = new ResourceManager ("welcome",
Assembly.GetExecutingAssembly());
(如果资源是在 Visual Studio 中编译的,则第一个参数必须带命名空间前缀。)
然后,通过调用 GetString
或 GetObject
(进行强制转换)访问其中的内容。
string greeting = r.GetString ("Greeting");
int fontSize = (int) r.GetObject ("DefaultFontSize");
Image image = (Image) r.GetObject ("flag.png");
要枚举 .resources 文件的内容:
ResourceManager r = new ResourceManager (...);
ResourceSet set = r.GetResourceSet (CultureInfo.CurrentUICulture,
true, true);
foreach (System.Collections.DictionaryEntry entry in set)
Console.WriteLine (entry.Key);
在 Visual Studio 中创建 pack URI 资源
在 WPF 应用程序中,XAML 文件需要能够通过 URI 访问资源。例如:
<Button>
<Image Height="50" Source="flag.png"/>
</Button>
或者,如果资源在另一个程序集中:
<Button>
<Image Height="50" Source="UtilsAssembly;Component/flag.png"/>
</Button>
(Component
是一个字面关键字。)
要创建能以这种方式加载的资源,你不能使用*.resx文件。相反,你必须将文件添加到项目中,并将它们的构建操作设置为 Resource(而不是 Embedded Resource)。然后,Visual Studio 会将它们编译成一个名为.g.resources的.resources文件——也是编译 XAML(.baml*)文件的位置。
要以编程方式加载 URI 键入的资源,请调用Application.GetResourceStream
:
Uri u = new Uri ("flag.png", UriKind.Relative);
using (Stream s = Application.GetResourceStream (u).Stream)
注意我们使用了一个相对 URI。你也可以使用完全相同格式的绝对 URI(三个逗号不是打字错误):
Uri u = new Uri ("pack://application:,,,/flag.png");
如果你更愿意指定一个Assembly
对象,你可以使用ResourceManager
来检索内容:
Assembly a = Assembly.GetExecutingAssembly();
ResourceManager r = new ResourceManager (a.GetName().Name + ".g", a);
using (Stream s = r.GetStream ("flag.png"))
...
ResourceManager
还允许你枚举给定程序集内*.g.resources*容器的内容。
卫星程序集
嵌入在*.resources*中的数据是可本地化的。
当你的应用程序在用于显示不同语言的 Windows 版本上运行时,资源本地化就显得尤为重要。为了保持一致性,你的应用程序也应该使用相同的语言。
典型的设置如下:
-
主程序集包含默认或回退语言的*.resources*。
-
分开的卫星程序集包含翻译成不同语言的本地化*.resources*。
当你的应用程序运行时,.NET 会检查当前操作系统的语言(来自CultureInfo.CurrentUICulture
)。每当你使用ResourceManager
请求资源时,运行时会查找本地化的卫星程序集。如果有可用的卫星程序集,并且它包含你请求的资源键,则会用它替代主程序集的版本。
这意味着你可以通过添加新的卫星程序集来简单增强语言支持,而无需更改主程序集。
注意
一个卫星程序集不能包含可执行代码,只能包含资源。
卫星程序集部署在程序集文件夹的子目录中,如下所示:
programBaseFolder\MyProgram.exe
\MyLibrary.exe
\*XX*\MyProgram.resources.dll
\*XX*\MyLibrary.resources.dll
*XX*
指的是两个字母的语言代码(例如德语为“de”)或语言和区域代码(例如英语在英国的代码为“en-GB”)。这种命名系统允许 CLR 自动找到并加载正确的卫星程序集。
构建卫星程序集
回顾我们之前的*.resx*示例,其中包括以下内容:
<root>
...
<data name="Greeting"
<value>hello</value>
</data>
</root>
然后我们在运行时检索问候语如下:
ResourceManager r = new ResourceManager ("welcome",
Assembly.GetExecutingAssembly());
Console.Write (r.GetString ("Greeting"));
假设我们希望在德语版 Windows 上运行时写入“hallo”而不是“hello”。第一步是添加另一个名为welcome.de.resx的*.resx文件,将hello替换为hallo*:
<root>
<data name="Greeting">
<value>hallo<value>
</data>
</root>
在 Visual Studio 中,这是你需要做的一切——重新构建时,一个名为MyApp.resources.dll的卫星程序集会自动创建在名为de的子目录中。
测试卫星程序集
要模拟在具有不同语言的操作系统上运行,你必须使用Thread
类来更改CurrentUICulture
:
System.Threading.Thread.CurrentThread.CurrentUICulture
= new System.Globalization.CultureInfo ("de");
CultureInfo.CurrentUICulture
是相同属性的只读版本。
注意
一个有用的测试策略是将ℓѻ¢αℓïʐɘ转换为仍然可以读作英语但不使用标准罗马 Unicode 字符的单词。
Visual Studio 设计器支持
Visual Studio 中的设计器为本地化组件和视觉元素提供了扩展支持。WPF 设计器有自己的本地化工作流程;其他基于组件的设计器使用设计时属性,使组件或 Windows Forms 控件看起来具有Language
属性。要定制为另一种语言,只需更改Language
属性,然后开始修改组件。所有被标记为Localizable
的控件属性都将保存到该语言的*.resx*文件中。您可以随时通过更改Language
属性来在不同语言之间切换。
文化和子文化
文化被分为文化和子文化。文化代表特定的语言;子文化代表该语言的区域变体。.NET 运行时遵循RFC1766
标准,使用两字母代码表示文化和子文化。这里是英语和德语文化的代码:
En
de
这里是澳大利亚英语和奥地利德语子文化的代码:
en-AU
de-AT
在.NET 中,文化使用System.Globalization.CultureInfo
类表示。您可以检查应用程序的当前文化,如下所示:
Console.WriteLine (System.Threading.Thread.CurrentThread.CurrentCulture);
Console.WriteLine (System.Threading.Thread.CurrentThread.CurrentUICulture);
在配置为澳大利亚本地化的计算机上运行这个示例,可以展示两者之间的差异:
en-AU
en-US
CurrentCulture
反映了 Windows 控制面板的区域设置,而CurrentUICulture
反映了操作系统的语言。
区域设置包括时区、货币和日期格式等内容。CurrentCulture
决定了诸如DateTime.Parse
等函数的默认行为。区域设置可以定制到不再符合任何特定文化的程度。
CurrentUICulture
决定计算机与用户通信的语言。澳大利亚在这方面不需要单独的英语版本,因此只使用美国英语。如果我在奥地利工作了几个月,我会去控制面板将我的CurrentCulture
更改为奥地利德语。但是,考虑到我不会说德语,我的CurrentUICulture
仍然是美国英语。
ResourceManager
默认使用当前线程的CurrentUICulture
属性来确定加载正确卫星程序集。在加载资源时,ResourceManager
使用回退机制。如果定义了子文化程序集,则使用该程序集;否则,回退到通用文化。如果通用文化不存在,则回退到主程序集中的默认文化。
加载、解析和隔离程序集
从已知位置加载程序集是一个相对简单的过程。我们称之为程序集加载。
然而,更常见的情况是,您(或 CLR)需要加载一个程序集,只知道其完整(或简单)名称。这称为程序集解析。程序集解析与加载不同之处在于必须首先定位程序集。
程序集解析在两种情况下触发:
-
由 CLR 在需要解析依赖项时
-
明确地说,当您调用诸如
Assembly.Load(AssemblyName)
的方法时
为了说明第一个场景,考虑一个由主程序集和一组静态引用的库程序集(依赖项)组成的应用程序,如本例所示:
AdventureGame.dll // Main assembly
Terrain.dll // Referenced assembly
UIEngine.dll // Referenced assembly
“静态引用”是指AdventureGame.dll编译时引用了Terrain.dll和UIEngine.dll。编译器本身不需要执行程序集解析,因为它被告知(明确或通过 MSBuild)在哪里找到Terrain.dll和UIEngine.dll。在编译过程中,它将 Terrain 和 UIEngine 程序集的完整名称写入AdventureGame.dll的元数据中,但不包含有关如何找到它们的信息。因此,在运行时,必须解析Terrain 和 UIEngine 程序集。
程序集加载和解析由程序集加载上下文(ALC)处理;具体来说,是System.Runtime.Loader
中AssemblyLoadContext
类的一个实例。因为AdventureGame.dll是应用程序的主程序集,CLR 使用默认 ALC(AssemblyLoadContext.Default
)来解析其依赖项。默认 ALC 首先通过查找和检查名为AdventureGame.deps.json的文件(描述了依赖项的位置),或者如果不存在,则在应用程序基础文件夹中查找,那里将找到Terrain.dll和UIEngine.dll。(默认 ALC 还解析.NET 运行时程序集。)
作为开发人员,您可以在程序执行过程中动态加载额外的程序集。例如,您可能希望将可选功能打包在仅在购买这些功能时部署的程序集中。在这种情况下,您可以通过调用Assembly.Load(AssemblyName)
来加载额外的程序集(如果存在)。
一个更复杂的例子是实现一个插件系统,用户可以提供第三方程序集,您的应用程序在运行时检测并加载以扩展应用程序的功能。复杂性在于每个插件程序集可能有自己的依赖项,这些依赖项也必须被解析。
通过对AssemblyLoadContext
进行子类化并重写其程序集解析方法(Load
),您可以控制插件如何找到其依赖项。例如,您可能决定每个插件都应该位于自己的文件夹中,而其依赖项也应该位于该文件夹中。
ALC 还有另一个目的:通过为每个(插件+依赖项)实例化单独的AssemblyLoadContext
,您可以保持每个 ALC 的隔离性,确保它们的依赖项并行加载且不会相互干扰(也不会干扰宿主应用程序)。例如,每个 ALC 可以拥有自己的 JSON.NET 版本。因此,除了加载和解析之外,ALC 还提供了一种隔离的机制。在某些条件下,ALC 甚至可以卸载,释放其内存。
在本节中,我们详细阐述了这些原则,并描述了以下内容:
-
ALC 如何处理加载和解析
-
默认 ALC 的角色
-
Assembly.Load
和上下文 ALC -
如何使用
AssemblyDependencyResolver
-
如何加载和解析非托管库
-
卸载 ALC
-
旧版程序集加载方法
然后,我们将理论付诸实践,并展示如何使用 ALC 隔离编写插件系统。
注意
AssemblyLoadContext
类是.NET 5+和.NET Core 中的新功能。在.NET Framework 中,ALC 存在但受限且隐藏:与其间接地通过Assembly
类的LoadFile(string)
、LoadFrom(string)
和Load(byte[])
静态方法交互是唯一的方式。与 ALC API 相比,这些方法不够灵活,并且在处理依赖项时可能会出现意外情况。因此,在.NET 5+和.NET Core 中最好明确使用AssemblyLoadContext
API。
程序集加载上下文
正如我们刚刚讨论的那样,AssemblyLoadContext
类负责加载和解析程序集,并提供隔离的机制。
每个.NET Assembly
对象都属于一个唯一的AssemblyLoadContext
。您可以按如下方式获取一个程序集的 ALC:
Assembly assem = Assembly.GetExecutingAssembly();
AssemblyLoadContext context = AssemblyLoadContext.GetLoadContext (assem);
Console.WriteLine (context.Name);
反过来,您可以将 ALC 视为“包含”或“拥有”程序集的容器,您可以通过其Assemblies
属性获取它们。继续前面的例子:
foreach (Assembly a in context.Assemblies)
Console.WriteLine (a.FullName);
AssemblyLoadContext
类还有一个静态的All
属性,用于枚举所有的 ALC。
您可以通过实例化AssemblyLoadContext
并提供一个名称(在调试时这个名称很有帮助)来创建一个新的 ALC,尽管更常见的做法是首先子类化AssemblyLoadContext
,以便您可以实现解析依赖项的逻辑;换句话说,根据其名称加载一个程序集。
加载程序集
AssemblyLoadContext
提供了以下方法来显式地将程序集加载到其上下文中:
public Assembly LoadFromAssemblyPath (string assemblyPath);
public Assembly LoadFromStream (Stream assembly, Stream assemblySymbols);
第一种方法从文件路径加载程序集,而第二种方法从Stream
加载它(可以直接来自内存)。第二个参数是可选的,对应于项目调试(.pdb)文件的内容,这允许堆栈跟踪在代码执行时包含源代码信息(在异常报告中非常有用)。
使用这两种方法都不会进行解析。
以下代码将程序集c:\temp\foo.dll加载到其自己的 ALC 中:
var alc = new AssemblyLoadContext ("Test");
Assembly assem = alc.LoadFromAssemblyPath (@"c:\temp\foo.dll");
如果程序集有效,则加载将始终成功,但受到一个重要规则的限制:程序集的 简单名称 必须在其 ALC 中唯一。这意味着不能将同名程序集的多个版本加载到单个 ALC 中;要实现此目的,必须创建额外的 ALC。
var alc2 = new AssemblyLoadContext ("Test 2");
Assembly assem2 = alc2.LoadFromAssemblyPath (@"c:\temp\foo.dll");
请注意,即使程序集在其他方面相同,但源自不同 Assembly
对象的类型也是不兼容的。在我们的示例中,assem
中的类型与 assem2
中的类型是不兼容的。
在加载程序集后,除非卸载其 ALC(请参阅 “卸载 ALCs”),否则无法卸载程序集。CLR 在加载期间保持文件的锁定状态。
注意
您可以通过通过字节数组加载程序集来避免锁定文件:
bytes[] bytes = File.ReadAllBytes (@"c:\temp\foo.dll");
var ms = new MemoryStream (bytes);
var assem = alc.LoadFromStream (ms);
这有两个缺点:
-
程序集的
Location
属性最终将为空白。有时,知道程序集加载自何处(某些 API 依赖于此填充)是很有用的。 -
私有内存消耗必须立即增加以适应程序集的完整大小。如果您从文件名加载,则 CLR 使用内存映射文件,这使得延迟加载和进程共享成为可能。此外,如果内存不足,操作系统可以释放其内存并根据需要重新加载,而无需写入分页文件。
LoadFromAssemblyName
AssemblyLoadContext
还提供了以下方法,通过 名称 加载程序集:
public Assembly LoadFromAssemblyName (AssemblyName assemblyName);
与刚讨论的两种方法不同,您不需要传递任何信息来指示程序集的位置;相反,您正在指示 ALC 解析 程序集。
解析程序集
前面的方法触发了 程序集解析。CLR 在加载依赖项时也会触发程序集解析。例如,假设程序集 A 静态引用程序集 B。为了解析引用 B,CLR 会在加载程序集 A 的 ALC 程序集 上触发程序集解析。
注意
CLR 通过触发程序集解析来解析依赖关系——触发程序集的是默认的还是自定义的 ALC。不同之处在于,默认 ALC 的解析规则是硬编码的,而自定义 ALC 则需要您自己编写规则。
然后发生了以下情况:
-
CLR 首先检查在该 ALC 中是否已经进行了相同的解析(使用匹配的完整程序集名称);如果是,则返回它之前返回的
Assembly
。 -
否则,CLR 调用 ALC 的(虚拟受保护的)
Load
方法,执行定位和加载程序集的工作。默认 ALC 的Load
方法适用我们在 “默认 ALC” 中描述的规则。使用自定义 ALC,您完全可以决定如何定位程序集。例如,您可以在某个文件夹中查找,然后在找到程序集时调用LoadFromAssemblyPath
。从同一或另一个 ALC 返回已加载的程序集也是完全合法的(我们在 “编写插件系统” 中演示了这一点)。 -
如果第二步返回 null,CLR 将在默认的 ALC 上调用
Load
方法(这作为解析.NET 运行时和常见应用程序程序集的有用“回退”)。 -
如果第三步返回 null,CLR 将在两个 ALC 上依次触发
Resolving
事件:首先是默认的 ALC,然后是原始的 ALC。 -
(与.NET Framework 兼容性):如果程序集仍未解析,
AppDomain.CurrentDomain.AssemblyResolve
事件将触发。注意
完成此过程后,CLR 进行“健全性检查”,以确保加载的程序集名称与请求的兼容。简单名称必须匹配;如果指定了,则公钥令牌必须匹配。版本不需要匹配 - 可以比请求的版本高或低。
由此可见,在自定义 ALC 中实现程序集解析有两种方法:
-
重写 ALC 的
Load
方法。这使得您的 ALC 在发生的事情上“第一说”,通常是可取的(当需要隔离时是必要的)。 -
处理 ALC 的
Resolving
事件。这仅在默认 ALC 未能解析程序集后才触发。
注意
如果将多个事件处理程序附加到 Resolving
事件,第一个返回非 null 值的事件处理程序胜出。
举例说明,假设我们要加载一个主应用程序在编译时不知道的程序集 foo.dll,位于 c:\temp(与我们的应用程序文件夹不同)。我们还假设 foo.dll 私有依赖于 bar.dll。我们希望确保当加载 c:\temp\foo.dll 并执行其代码时,c:\temp\bar.dll 能够正确解析。我们还希望确保 foo
及其私有依赖项 bar
不会影响主应用程序。
让我们从编写自定义 ALC 重写 Load
开始:
using System.IO;
using System.Runtime.Loader;
class FolderBasedALC : AssemblyLoadContext
{
readonly string _folder;
public FolderBasedALC (string folder) => _folder = folder;
protected override Assembly Load (AssemblyName assemblyName)
{
// Attempt to find the assembly:
string targetPath = Path.Combine (_folder, assemblyName.Name + ".dll");
if (File.Exists (targetPath))
return LoadFromAssemblyPath (targetPath); // Load the assembly
return null; // We can’t find it: it could be a .NET runtime assembly
}
}
注意在 Load
方法中,如果程序集文件不存在,则返回 null
是重要的检查。这是因为 foo.dll 也依赖于.NET BCL 程序集;因此,当像 System.Runtime
这样的程序集调用 Load
方法时,我们返回 null,允许 CLR 回退到默认的 ALC,从而正确解析这些程序集。
注意
请注意,我们没有尝试将 .NET 运行时 BCL 程序集加载到我们自己的 ALC 中。这些系统程序集并不适用于在默认 ALC 之外运行,尝试加载它们到您自己的 ALC 可能导致不正确的行为、性能下降和意外的类型不兼容。
下面是如何使用我们的自定义 ALC 加载 c:\temp 中的 foo.dll 程序集:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyPath (@"c:\temp\foo.dll");
...
当我们随后开始调用 foo
程序集中的代码时,CLR 最终会需要解析对 bar.dll 的依赖关系。这是自定义 ALC 的 Load
方法将会触发并成功在 c:\temp 中定位 bar.dll 程序集的时机。
在这种情况下,我们的 Load
方法也能够解析 foo.dll,所以我们可以简化我们的代码为:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
...
现在,让我们考虑一种替代方案:不是子类化 AssemblyLoadContext
并重写 Load
,而是实例化一个普通的 AssemblyLoadContext
并处理其 Resolving
事件:
var alc = new AssemblyLoadContext ("test");
alc.Resolving += (loadContext, assemblyName) =>
{
string targetPath = Path.Combine (@"c:\temp", assemblyName.Name + ".dll");
return alc.LoadFromAssemblyPath (targetPath); // Load the assembly
};
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
现在请注意,我们不需要检查程序集是否存在。因为 Resolving
事件是在默认 ALC 尝试解析程序集失败后触发的,所以我们的处理程序不会对 .NET BCL 程序集触发。这使得这种解决方案更简单,尽管存在一个缺点。请记住,在我们的场景中,主应用程序在编译时不知道 foo.dll 或 bar.dll。这意味着主应用程序可能依赖于编译时存在的 foo.dll 或 bar.dll。如果这种情况发生,Resolving
事件将不会触发,而是加载应用程序的 foo
和 bar
程序集。换句话说,我们将无法实现隔离。
注意
我们的 FolderBasedALC
类很好地说明了程序集解析的概念,但在实际应用中用处较小,因为它无法处理特定于平台和(对于库项目)开发时的 NuGet 依赖项。在“AssemblyDependencyResolver”中,我们描述了解决此问题的方法,在“编写插件系统”中,我们给出了详细的示例。
默认 ALC
当应用程序启动时,CLR 会为静态的 AssemblyLoadContext.Default
属性分配一个特殊的 ALC。默认的 ALC 是启动程序集加载的地方,以及其静态引用的依赖项和 .NET 运行时 BCL 程序集。
默认的 ALC 首先在默认探测路径中查找以自动解析程序集(见“默认探测”);这通常等同于应用程序的 .deps.json 和 .runtimeconfig.json 文件中指示的位置。
如果 ALC 在其默认探测路径中找不到一个程序集,将会触发其Resolving
事件。处理此事件可以让您从其他位置加载程序集,这意味着您可以将应用程序的依赖项部署到其他位置,如子文件夹、共享文件夹,甚至作为主机程序集内的二进制资源:
AssemblyLoadContext.Default.Resolving += (loadContext, assemblyName) =>
{
// Try to locate assemblyName, returning an Assembly object or null.
// Typically you’d call LoadFromAssemblyPath after finding the file.
// ...
};
在默认 ALC 中,当自定义 ALC 无法解析(换句话说,当其Load
方法返回null
时)且默认 ALC 无法解析该程序集时,Resolving
事件也会触发。
您还可以从外部加载程序集到默认 ALC 中的Resolving
事件之外。但在继续之前,您应该首先确定是否可以通过使用单独的 ALC 或使用我们在以下部分描述的方法(使用executing和contextual ALC)来更好地解决问题。因为将代码硬编码到默认 ALC 会使其变得脆弱,因为它不能完全被隔离(例如通过单元测试框架或 LINQPad)。
如果您仍然希望继续,最好调用一个解析方法(如LoadFromAssemblyName
),而不是一个加载方法(例如LoadFromAssemblyPath
)——特别是如果您的程序集是静态引用的情况下。这是因为可能已经加载了该程序集,此时LoadFromAssemblyName
将返回已加载的程序集,而LoadFromAssemblyPath
将抛出异常。
(使用LoadFromAssemblyPath
时,您也可能面临从与 ALC 默认解析机制不一致的位置加载程序集的风险。)
如果程序集位于 ALC 不会自动找到的位置,您仍然可以遵循此过程并另外处理 ALC 的Resolving
事件。
请注意,在调用LoadFromAssemblyName
时,您不需要提供完整的名称;简单名称就足够了(即使程序集是强命名的也是有效的):
AssemblyLoadContext.Default.LoadFromAssemblyName ("System.Xml");
但是,如果在名称中包含公钥令牌,则必须与加载的内容匹配。
默认探测
默认探测路径通常包括以下内容:
-
路径在AppName.deps.json中指定(其中AppName是您的应用程序主程序集的名称)。如果此文件不存在,则使用应用程序基础文件夹。
-
包含.NET 运行时系统程序集的文件夹(如果您的应用程序是依赖于 Framework 的)。
MSBuild 会自动生成一个名为AppName.deps.json的文件,其中描述了如何找到所有依赖项。这些包括跨平台的程序集,放置在应用程序基础文件夹中,以及特定于平台的程序集,放置在runtimes*子目录下的一个子文件夹中,例如win或unix*。
在生成的*.deps.json文件中指定的路径是相对于应用程序基础文件夹的,或者是您在AppName.runtimeconfig.json和/或AppName.runtimeconfig.dev.json*配置文件的additionalProbingPaths
部分中指定的任何附加文件夹(后者仅适用于开发环境)。
“当前”ALC
在前面的部分中,我们警告不要显式加载程序集到默认 ALC 中。您通常希望的是加载/解析到“当前”ALC 中。
在大多数情况下,“当前”的 ALC 是包含当前执行程序集的 ALC:
var executingAssem = Assembly.GetExecutingAssembly();
var alc = AssemblyLoadContext.GetLoadContext (executingAssem);
Assembly assem = alc.LoadFromAssemblyName (...); // to resolve by name
// OR: = alc.LoadFromAssemblyPath (...); // to load by path
这是一种更灵活和明确的获取 ALC 的方式:
var myAssem = typeof (SomeTypeInMyAssembly).Assembly;
var alc = AssemblyLoadContext.GetLoadContext (myAssem);
...
有时,不可能推断“当前”的 ALC。例如,假设您负责编写.NET 二进制序列化器(我们在http://www.albahari.com/nutshell的在线补充中描述了序列化)。这样的序列化器写入它序列化的类型的完整名称(包括它们的程序集名称),在反序列化期间必须进行解析。问题是,您应该使用哪个 ALC?依赖执行程序集的问题是,它将返回包含反序列化器的程序集,而不是调用反序列化器的程序集。
最佳解决方案不是猜测,而是询问:
public object Deserialize (Stream stream, AssemblyLoadContext alc)
{
...
}
明确指定可最大化灵活性并最小化出错几率。调用者现在可以决定什么应该算作“当前”的 ALC:
var assem = typeof (SomeTypeThatIWillBeDeserializing).Assembly;
var alc = AssemblyLoadContext.GetLoadContext (assem);
var object = Deserialize (someStream, alc);
Assembly.Load 和上下文 ALCs
为了帮助加载程序集到当前执行的 ALC 的常见情况;即:
var executingAssem = Assembly.GetExecutingAssembly();
var alc = AssemblyLoadContext.GetLoadContext (executingAssem);
Assembly assem = alc.LoadFromAssemblyName (...);
Microsoft 已在Assembly
类中定义了以下方法:
public static Assembly Load (string assemblyString);
以及一个功能上相同的接受AssemblyName
对象版本:
public static Assembly Load (AssemblyName assemblyRef);
(不要将这些方法与完全不同方式行为的旧版Load(byte[])
方法混淆,请参阅“旧版加载方法”。)
与LoadFromAssemblyName
一样,您可以选择指定程序集的简单、部分或完整名称:
Assembly a = Assembly.Load ("System.Private.Xml");
这将System.Private.Xml
程序集加载到执行代码所在的任何 ALC 中。
在这种情况下,我们指定了一个简单名称。以下字符串也是有效的,并且在.NET 中结果相同:
"System.Private.Xml, PublicKeyToken=cc7b13ffcd2ddd51"
"System.Private.Xml, Version=4.0.1.0"
"System.Private.Xml, Version=4.0.1.0, PublicKeyToken=cc7b13ffcd2ddd51"
如果选择指定公钥令牌,它必须与加载的内容匹配。
注意
Microsoft 开发人员网络(MSDN)警告不要根据部分名称加载程序集,建议您指定确切的版本和公钥令牌。他们的理由基于.NET Framework 相关因素,例如全局程序集缓存和代码访问安全性的影响。在.NET 5+和.NET Core 中,这些因素不存在,因此通常可以从简单或部分名称加载。
这两种方法都严格用于解析,因此不能指定文件路径。(如果在AssemblyName
对象的CodeBase
属性中填充内容,将会被忽略。)
警告
不要陷入使用Assembly.Load
加载静态引用程序集的陷阱。在这种情况下,您只需引用程序集中的某个类型并从中获取该程序集:
Assembly a = typeof (System.Xml.Formatting).Assembly;
或者,您甚至可以这样做:
Assembly a = System.Xml.Formatting.Indented.GetType().Assembly;
这样可以避免硬编码程序集名称(您将来可能会更改),同时在执行代码的ALC 上触发程序集解析(就像使用Assembly.Load
一样)。
如果您要自己编写Assembly
.Load
方法,它(几乎)看起来会像这样:
[MethodImpl(MethodImplOptions.NoInlining)]
Assembly Load (string name)
{
Assembly callingAssembly = Assembly.GetCallingAssembly();
var callingAlc = AssemblyLoadContext.GetLoadContext (callingAssembly);
return callingAlc.LoadFromAssemblyName (new AssemblyName (name));
}
EnterContextualReflection
当通过中介(如反序列化器或单元测试运行器)调用Assembly
.Load
时,Assembly
.Load
使用调用方的加载上下文的策略失败。如果中介定义在不同的程序集中,则使用中介的加载上下文而不是调用方的加载上下文。
注意
我们在早些时候描述了这种情况,当我们讨论如何编写反序列化器时。在这种情况下,理想的解决方案是强制调用者指定一个 ALC,而不是通过Assembly.Load(string)
推断它。
但由于.NET 5+和.NET Core 是从.NET Framework 进化而来——在那里,隔离是通过应用程序域而不是 ALC 完成的——理想的解决方案并不普遍,有时在无法可靠推断 ALC 的情况下,会不适当地使用Assembly.Load(string)
。一个例子是.NET 二进制序列化器。
为了在这种场景下仍然允许Assembly
.Load
工作,Microsoft 添加了一个方法到AssemblyLoadContext
,名为EnterContextualReflection
。这会将一个 ALC 分配给AssemblyLoadContext
.CurrentContextualReflectionContext
。虽然这是一个静态属性,但它的值存储在一个AsyncLocal
变量中,因此可以在不同线程上持有不同的值(但在整个异步操作期间仍然保持)。
如果此属性非空,Assembly
.Load
会自动使用它,而不是调用方的 ALC:
Method1();
var myALC = new AssemblyLoadContext ("test");
using (myALC.EnterContextualReflection())
{
Console.WriteLine (
AssemblyLoadContext.CurrentContextualReflectionContext.Name); // test
Method2();
}
// Once disposed, EnterContextualReflection() no longer has an effect.
Method3();
void Method1() => Assembly.Load ("..."); // Will use calling ALC
void Method2() => Assembly.Load ("..."); // Will use myALC
void Method3() => Assembly.Load ("..."); // Will use calling ALC
我们之前演示了如何编写一个功能类似于Assembly
.Load
的方法。这里是一个更准确的版本,考虑到了上下文反射上下文:
[MethodImpl(MethodImplOptions.NoInlining)]
Assembly Load (string name)
{
var alc = AssemblyLoadContext.CurrentContextualReflectionContext
?? AssemblyLoadContext.GetLoadContext (Assembly.GetCallingAssembly());
return alc.LoadFromAssemblyName (new AssemblyName (name));
}
即使上下文反射上下文对于允许旧代码运行可能有用,但更健壮的解决方案(正如我们之前所描述的)是修改调用Assembly.Load
的代码,使其改为在由调用者传入的 ALC 上调用LoadFromAssemblyName
。
注意
.NET Framework 没有EnterContextualReflection
的等价物——也不需要它——尽管有相同的Assembly
.Load
方法。这是因为在.NET Framework 中,隔离主要通过应用程序域而不是 ALC 完成。应用程序域提供了更强的隔离模型,每个应用程序域都有自己的默认加载上下文,因此即使只使用默认加载上下文,隔离仍然可以工作。
加载和解析非托管库
ALC 也可以加载和解析本机库。当调用标有[DllImport]
属性的外部方法时,会触发本机解析:
[DllImport ("SomeNativeLibrary.dll")]
static extern int SomeNativeMethod (string text);
因为我们在[DllImport]
属性中没有指定完整路径,调用SomeNativeMethod
会触发在包含SomeNativeMethod
定义的程序集的任何 ALC 中的解析。
ALC 中的虚拟解析方法称为LoadUnmanagedDll
,加载方法称为LoadUnmanagedDllFromPath
:
protected override IntPtr LoadUnmanagedDll (string unmanagedDllName)
{
// Locate the full path of unmanagedDllName...
string fullPath = ...
return LoadUnmanagedDllFromPath (fullPath); // Load the DLL
}
如果无法找到文件,可以返回IntPtr.Zero
。CLR 将会触发 ALC 的ResolvingUnmanagedDll
事件。
有趣的是,LoadUnmanagedDllFromPath
方法是受保护的,因此您通常无法从ResolvingUnmanagedDll
事件处理程序中调用它。但是,您可以通过调用静态的NativeLibrary.Load
来达到相同的效果:
someALC.ResolvingUnmanagedDll += (requestingAssembly, unmanagedDllName) =>
{
return NativeLibrary.Load ("(full path to unmanaged DLL)");
};
尽管通常由 ALC 解析和加载本机库,但本机库并不属于任何 ALC。加载后,本机库独立存在,并负责解析可能具有的任何传递依赖项。此外,本机库是进程全局的,因此如果具有相同文件名的两个不同版本的本机库,是不可能加载的。
AssemblyDependencyResolver
在“默认探测”中,我们说默认的 ALC 会读取*.deps.json和.runtimeconfig.json*文件(如果存在),以确定在哪里查找解析平台特定和开发时 NuGet 依赖项。
如果您想将一个具有平台特定或 NuGet 依赖项的程序集加载到自定义 ALC 中,您需要以某种方式复制此逻辑。您可以通过解析配置文件并仔细遵循平台特定的标识规则来完成此操作,但这不仅很困难,而且如果 .NET 的后续版本中更改了规则,您编写的代码将会出错。
AssemblyDependencyResolver
类解决了这个问题。要使用它,您需要实例化它并提供您想要探测其依赖项的程序集的路径:
var resolver = new AssemblyDependencyResolver (@"c:\temp\foo.dll");
然后,要找到依赖项的路径,您需要调用ResolveAssemblyToPath
方法:
string path = resolver.ResolveAssemblyToPath (new AssemblyName ("bar"));
如果没有*.deps.json文件(或者.deps.json文件中没有与bar.dll相关的内容),则将评估为c:\temp\bar.dll*。
您可以通过调用ResolveUnmanagedDllToPath
类似地解析非托管依赖项。
说明更复杂场景的一个很好的方法是创建一个名为ClientApp
的新控制台项目,然后为其添加一个Microsoft.Data.SqlClient的 NuGet 引用。添加以下类:
using Microsoft.Data.SqlClient;
namespace ClientApp
{
public class Program
{
public static SqlConnection GetConnection() => new SqlConnection();
static void Main() => GetConnection(); // Test that it resolves
}
}
现在构建应用程序并查看输出文件夹:您会看到一个名为Microsoft.Data.SqlClient.dll的文件。但是,运行时此文件不会加载,并且尝试显式加载它会引发异常。实际加载的程序集位于runtimes\win(或runtimes/unix)子文件夹中;默认的 ALC 知道加载它,因为它解析了ClientApp.deps.json文件。
如果您试图从另一个应用程序加载ClientApp.dll程序集,您需要编写一个可以解析其依赖项Microsoft.Data.SqlClient.dll的 ALC。在此过程中,仅仅查找ClientApp.dll所在的文件夹是不够的(就像我们在“解析程序集”中所做的那样)。相反,您需要使用AssemblyDependencyResolver
来确定正在使用的平台中该文件的位置:
string path = @"C:\source\ClientApp\bin\Debug\netcoreapp3.0\ClientApp.dll";
var resolver = new AssemblyDependencyResolver (path);
var sqlClient = new AssemblyName ("Microsoft.Data.SqlClient");
Console.WriteLine (resolver.ResolveAssemblyToPath (sqlClient));
在 Windows 机器上,这将输出以下内容:
C:\source\ClientApp\bin\Debug\netcoreapp3.0\runtimes\win\lib\netcoreapp2.1
\Microsoft.Data.SqlClient.dll
我们在“编写插件系统”中提供了一个完整的示例。
卸载 ALC
在简单的情况下,可以卸载非默认的AssemblyLoadContext
,释放内存并释放它加载的程序集的文件锁。为了使其工作,ALC 必须使用isCollectible
参数true
进行实例化:
var alc = new AssemblyLoadContext ("test", isCollectible:true);
然后可以调用 ALC 上的Unload
方法来启动卸载过程。
卸载模型是合作性的,而不是抢占式的。如果任何 ALC 的程序集中的任何方法正在执行,则卸载将被推迟,直到这些方法完成。
实际的卸载发生在垃圾回收期间;如果来自 ALC 外部的任何东西(包括对象、类型和程序集)对 ALC 内部的任何东西具有任何(非弱)引用,则不会发生卸载。在.NET BCL 中,包括静态字段或字典中缓存对象以及订阅事件是很常见的 API,这使得很容易创建引用,阻止卸载,尤其是如果 ALC 中的代码以非平凡的方式使用其 ALC 外的 API。确定卸载失败的原因很困难,需要使用诸如 WinDbg 之类的工具。
旧加载方法
如果您仍在使用.NET Framework(或编写目标为.NET Standard 并希望支持.NET Framework 的库),则无法使用AssemblyLoadContext
类。取而代之的是通过以下方法实现加载:
public static Assembly LoadFrom (string assemblyFile);
public static Assembly LoadFile (string path);
public static Assembly Load (byte[] rawAssembly);
LoadFile
和Load(byte[])
提供隔离性,而LoadFrom
则不提供。
通过处理应用程序域的AssemblyResolve
事件来实现解析,其工作方式类似于默认 ALC 的Resolving
事件。
Assembly.Load(string)
方法也可用于触发解析,并以类似的方式工作。
LoadFrom
LoadFrom
从给定路径加载程序集到默认 ALC 中。这有点像调用AssemblyLoadContext.Default.LoadFromAssemblyPath
,除了以下几点:
-
如果在默认 ALC 中已经存在具有相同简单名称的程序集,则
LoadFrom
返回该程序集,而不是抛出异常。 -
如果在默认的 ALC 中尚未存在具有相同简单名称的程序集,并且发生了加载,则该程序集将被赋予特殊的“LoadFrom”状态。该状态影响默认 ALC 的解析逻辑,即如果该程序集在相同的文件夹中有任何依赖项,则这些依赖项将自动解析。
注意
.NET Framework 有全局程序集缓存(GAC)。如果程序集存在于 GAC 中,则 CLR 将始终从那里加载。这适用于所有三种加载方法。
LoadFrom
自动解析传递性相同文件夹依赖项的能力可能很方便,直到它加载不应该加载的程序集。由于这种情况很难调试,最好使用Load(string)
或LoadFile
,并通过处理应用程序域的AssemblyResolve
事件来解析传递性依赖项。这样可以让您决定如何解析每个程序集,并允许通过在事件处理程序内创建断点进行调试。
LoadFile 和 Load(byte[])
LoadFile
和Load(byte[])
从给定的文件路径或字节数组加载程序集到新的 ALC 中。与LoadFrom
不同,这些方法提供了隔离并允许您加载同一程序集的多个版本。但是,有两个注意事项:
-
使用相同路径再次调用
LoadFile
将返回先前加载的程序集。 -
在 .NET Framework 中,这两种方法首先检查 GAC,并在程序集存在时从那里加载。
使用LoadFile
和Load(byte[])
,您会得到一个单独的 ALC 每个程序集(除了注意事项)。这种隔离使得管理变得更加复杂。
要解析依赖项,您需要处理AppDomain
的Resolving
事件,该事件在所有 ALC 上触发:
AppDomain.CurrentDomain.AssemblyResolve += (sender, args) =>
{
string fullAssemblyName = args.Name;
// return an Assembly object or null
...
};
args
变量还包括一个名为 RequestingAssembly
的属性,该属性告诉您触发解析的程序集是哪个。
找到组件后,可以调用Assembly
.LoadFile
来加载它。
注意
您可以使用AppDomain.CurrentDomain.GetAssemblies()
枚举已加载到当前应用程序域中的所有程序集。在 .NET 5+ 中也适用,其等效于以下内容:
AssemblyLoadContext.All.SelectMany (a => a.Assemblies)
编写插件系统
为了完全演示本节中涵盖的概念,让我们编写一个插件系统,该系统使用无法卸载的 ALC 来隔离每个插件。
我们的演示系统最初将包括三个 .NET 项目:
Plugin.Common(库)
定义插件将实现的接口
Capitalizer(库)
一个将文本转换为大写的插件
Plugin.Host(控制台应用程序)
定位并调用插件
假设项目位于以下目录中:
c:\source\PluginDemo\Plugin.Common
c:\source\PluginDemo\Capitalizer
c:\source\PluginDemo\Plugin.Host
所有项目将引用 Plugin.Common 库,而没有其他项目间的引用。
注意
如果 Plugin.Host 引用了 Capitalizer,我们将不会编写一个插件系统;中心思想是插件是由 Plugin.Host 和 Plugin.Common 发布后的第三方编写的。
如果您使用 Visual Studio,将这三个项目放入单个解决方案中可能会很方便,以便进行此演示。如果这样做,请右键单击 Plugin.Host 项目,选择“生成依赖项” > “项目依赖项”,然后选中 Capitalizer 项目。这样在运行 Plugin.Host 项目时,会强制 Capitalizer 在不添加引用的情况下进行构建。
Plugin.Common
让我们从 Plugin.Common 开始。我们的插件将执行一个非常简单的任务,即转换一个字符串。以下是我们如何定义接口:
namespace Plugin.Common
{
public interface ITextPlugin
{
string TransformText (string input);
}
}
插件.Common 就是这样。
大写(插件)
我们的大写插件将引用 Plugin.Common 并包含一个单独的类。目前,我们将保持逻辑简单,以便插件没有额外的依赖关系:
public class CapitalizerPlugin : Plugin.Common.ITextPlugin
{
public string TransformText (string input) => input.ToUpper();
}
如果您构建了两个项目并查看大写插件的输出文件夹,您将看到以下两个程序集:
Capitalizer.dll // Our plug-in assembly
Plugin.Common.dll // Referenced assembly
Plugin.Host
Plugin.Host 是一个包含两个类的控制台应用程序。第一个类是加载插件的自定义 ALC:
class PluginLoadContext : AssemblyLoadContext
{
AssemblyDependencyResolver _resolver;
public PluginLoadContext (string pluginPath, bool collectible)
// Give it a friendly name to help with debugging:
: base (name: Path.GetFileName (pluginPath), collectible)
{
// Create a resolver to help us find dependencies.
_resolver = new AssemblyDependencyResolver (pluginPath);
}
protected override Assembly Load (AssemblyName assemblyName)
{
// See below
if (assemblyName.Name == typeof (ITextPlugin).Assembly.GetName().Name)
return null;
string target = _resolver.ResolveAssemblyToPath (assemblyName);
if (target != null)
return LoadFromAssemblyPath (target);
// Could be a BCL assembly. Allow the default context to resolve.
return null;
}
protected override IntPtr LoadUnmanagedDll (string unmanagedDllName)
{
string path = _resolver.ResolveUnmanagedDllToPath (unmanagedDllName);
return path == null
? IntPtr.Zero
: LoadUnmanagedDllFromPath (path);
}
}
在构造函数中,我们传入主插件程序集的路径以及一个标志,指示我们是否希望 ALC 可以被收集(以便可以卸载它)。
Load
方法是我们处理依赖项解析的地方。所有插件都必须引用 Plugin.Common,以便它们可以实现ITextPlugin
。这意味着Load
方法将在某个时候触发以解析 Plugin.Common。我们需要小心,因为插件的输出文件夹很可能不仅包含Capitalizer.dll,还包含自己的Plugin.Common.dll副本。如果我们加载这个Plugin.Common.dll副本到PluginLoadContext
中,我们将得到两个程序集副本:一个在主机的默认上下文中,一个在插件的PluginLoadContext
中。这些程序集将不兼容,主机将抱怨插件未实现ITextPlugin
!
要解决这个问题,我们明确地检查这个条件:
if (assemblyName.Name == typeof (ITextPlugin).Assembly.GetName().Name)
return null;
返回 null 允许主机的默认 ALC 来解析程序集。
注意
而不是返回 null,我们可以返回typeof(ITextPlugin).Assembly
,它也将正确工作。我们如何确保ITextPlugin
将在主机的 ALC 上解析,而不是在我们的PluginLoadContext
上?请记住,我们的PluginLoadContext
类定义在Plugin.Host
程序集中。因此,您从这个类静态引用的任何类型都将触发在其加载的 ALC 上解析程序集的组件,即Plugin.Host
。
检查通用程序集后,我们使用AssemblyDependencyResolver
来定位插件可能具有的任何私有依赖项。(现在不会有。)
注意,我们还重写了LoadUnamangedDll
方法。这确保了如果插件有任何非托管依赖项,它们也会正确加载。
Plugin.Host 的第二个要编写的类是程序的主要程序本身。为简单起见,让我们将路径硬编码到我们的 Capitalizer 插件(在现实生活中,您可能通过查找已知位置的 DLL 或从配置文件中读取来发现插件的路径):
class Program
{
const bool UseCollectibleContexts = true;
static void Main()
{
const string capitalizer = @"C:\source\PluginDemo\"
+ @"Capitalizer\bin\Debug\netcoreapp3.0\Capitalizer.dll";
Console.WriteLine (TransformText ("big apple", capitalizer));
}
static string TransformText (string text, string pluginPath)
{
var alc = new PluginLoadContext (pluginPath, UseCollectibleContexts);
try
{
Assembly assem = alc.LoadFromAssemblyPath (pluginPath);
// Locate the type in the assembly that implements ITextPlugin:
Type pluginType = assem.ExportedTypes.Single (t =>
typeof (ITextPlugin).IsAssignableFrom (t));
// Instantiate the ITextPlugin implementation:
var plugin = (ITextPlugin)Activator.CreateInstance (pluginType);
// Call the TransformText method
return plugin.TransformText (text);
}
finally
{
if (UseCollectibleContexts) alc.Unload(); // unload the ALC
}
}
}
让我们来看看TransformText
方法。我们首先为我们的插件实例化一个新的 ALC,然后要求它加载主插件程序集。接下来,我们使用反射来定位实现ITextPlugin
接口的类型(我们在第十八章中详细讨论这个)。然后,我们实例化插件,调用TransformText
方法,然后卸载 ALC。
注意
如果你需要重复调用TransformText
方法,更好的方法是缓存 ALC 而不是在每次调用后卸载它。
这里是输出:
BIG APPLE
添加依赖项
我们的代码完全能够解析和隔离依赖项。举例来说,让我们首先添加一个 NuGet 引用到Humanizer.Core,版本为 2.6.2。你可以通过 Visual Studio 的 UI 或者将以下元素添加到Capitalizer.csproj文件来实现这一点:
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.6.2" />
</ItemGroup>
现在,修改CapitalizerPlugin
如下:
using Humanizer;
namespace Capitalizer
{
public class CapitalizerPlugin : Plugin.Common.ITextPlugin
{
public string TransformText (string input) => input.Pascalize();
}
}
如果重新运行程序,输出现在将是这样的:
BigApple
接下来,我们创建另一个名为 Pluralizer 的插件。创建一个新的.NET 库项目,并添加一个 NuGet 引用到Humanizer.Core,版本为 2.7.9:
<ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.7.9" />
</ItemGroup>
现在,添加一个名为PluralizerPlugin
的类。这将类似于CapitalizerPlugIn
,但我们调用的是Pluralize
方法:
using Humanizer;
namespace Pluralizer
{
public class PluralizerPlugin : Plugin.Common.ITextPlugin
{
public string TransformText (string input) => input.Pluralize();
}
}
最后,我们需要在 Plugin.Host 的Main
方法中添加代码来加载和运行 Pluralizer 插件:
static void Main()
{
const string capitalizer = @"C:\source\PluginDemo\"
+ @"Capitalizer\bin\Debug\netcoreapp3.0\Capitalizer.dll";
Console.WriteLine (TransformText ("big apple", capitalizer));
const string pluralizer = @"C:\source\PluginDemo\"
+ @"Pluralizer\bin\Debug\netcoreapp3.0\Pluralizer.dll";
Console.WriteLine (TransformText ("big apple", pluralizer));
}
输出现在将是这样的:
BigApple
big apples
要完全了解发生了什么,请将UseCollectibleContexts
常量更改为 false,并将以下代码添加到Main
方法以枚举 ALC 及其程序集:
foreach (var context in AssemblyLoadContext.All)
{
Console.WriteLine ($"Context: {context.GetType().Name} {context.Name}");
foreach (var assembly in context.Assemblies)
Console.WriteLine ($" Assembly: {assembly.FullName}");
}
在输出中,你可以看到两个不同版本的 Humanizer,每个加载到它自己的 ALC 中:
Context: PluginLoadContext Capitalizer.dll
Assembly: Capitalizer, Version=1.0.0.0, Culture=neutral, PublicKeyToken=...
Assembly: Humanizer, Version=2.6.0.0, Culture=neutral, PublicKeyToken=...
Context: PluginLoadContext Pluralizer.dll
Assembly: Pluralizer, Version=1.0.0.0, Culture=neutral, PublicKeyToken=...
Assembly: Humanizer, Version=2.7.0.0, Culture=neutral, PublicKeyToken=...
Context: DefaultAssemblyLoadContext Default
Assembly: System.Private.CoreLib, Version=4.0.0.0, Culture=neutral,...
Assembly: Host, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
...
注意
即使两个插件都使用相同版本的 Humanizer,分离的程序集仍然有益,因为每个程序集都有自己的静态变量。
第十八章:反射和元数据
正如我们在 第十七章 中看到的,C# 程序编译为包含元数据、编译代码和资源的程序集。在运行时检查元数据和编译代码称为 反射。
程序集中编译的代码包含几乎所有原始源代码的内容。会丢失一些信息,如局部变量名称、注释和预处理指令。但是,反射可以访问几乎所有其他内容,甚至可以编写反编译器。
.NET 中提供的许多服务(如动态绑定、序列化和数据绑定)依赖于元数据的存在。您的程序也可以利用此元数据,甚至使用自定义属性添加新信息。System.Reflection
命名空间包含反射 API。在运行时,还可以通过 System.Reflection.Emit
命名空间中的类动态创建新的元数据和中间语言(IL)可执行指令。
本章的示例假定您导入了 System
、System.Reflection
和 System.Reflection.Emit
命名空间。
注意
在本章中,当我们使用术语“动态”时,指的是使用反射执行某些仅在运行时强制执行类型安全性的任务。这与通过 C# 的 dynamic
关键字进行的 动态绑定 在原理上类似,尽管机制和功能不同。
动态绑定更容易使用,并使用动态语言运行时(DLR)实现动态语言的互操作性。反射在使用上相对笨拙,但在与 CLR 的交互方面更加灵活。例如,反射允许您获取类型和成员列表,通过字符串实例化对象的名称,并动态构建程序集。
反射和激活类型
在本节中,我们将探讨如何获取 Type
,检查其元数据,并使用它动态实例化对象。
获取类型
System.Type
的一个实例表示类型的元数据。因为 Type
被广泛使用,所以它位于 System
命名空间而不是 System.Reflection
命名空间中。
您可以通过在任何对象上调用 GetType
或使用 C# 的 typeof
运算符来获取 System.Type
的实例:
Type t1 = DateTime.Now.GetType(); // Type obtained at runtime
Type t2 = typeof (DateTime); // Type obtained at compile time
可以使用 typeof
获取数组类型和泛型类型,如下所示:
Type t3 = typeof (DateTime[]); // 1-d Array type
Type t4 = typeof (DateTime[,]); // 2-d Array type
Type t5 = typeof (Dictionary<int,int>); // Closed generic type
Type t6 = typeof (Dictionary<,>); // Unbound generic type
您还可以通过名称检索 Type
。如果有其 Assembly
的引用,请调用 Assembly.GetType
(我们在部分 “反映程序集” 中进一步描述):
Type t = Assembly.GetExecutingAssembly().GetType ("Demos.TestProgram");
如果没有 Assembly
对象,可以通过其 程序集限定名称(类型的完整名称后跟程序集的完全或部分限定名称)获取类型。程序集会隐式加载,就像调用了 Assembly.Load(string)
一样:
Type t = Type.GetType ("System.Int32, System.Private.CoreLib");
获得 System.Type
对象后,可以使用其属性访问类型的名称、程序集、基类型、可见性等:
Type stringType = typeof (string);
string name = stringType.Name; // String
Type baseType = stringType.BaseType; // typeof(Object)
Assembly assem = stringType.Assembly; // System.Private.CoreLib
bool isPublic = stringType.IsPublic; // true
System.Type
实例是类型的整个元数据的窗口 —— 以及它所定义的程序集。
注意
System.Type
是抽象的,因此 typeof
操作符实际上必须给你一个 Type
的子类。CLR 使用的子类是 .NET 内部的,称为 RuntimeType
。
TypeInfo
如果要定位到 .NET Core 1.x(或较旧的 Windows Store 配置文件),你会发现大多数 Type
的成员都丢失了。这些丢失的成员在称为 TypeInfo
的类上公开,通过调用 GetTypeInfo
可获取。因此,为了使我们之前的示例运行,你将执行以下操作:
Type stringType = typeof(string);
string name = stringType.Name;
Type baseType = stringType.GetTypeInfo().BaseType;
Assembly assem = stringType.GetTypeInfo().Assembly;
bool isPublic = stringType.GetTypeInfo().IsPublic;
TypeInfo
也存在于 .NET Core 2 和 3 以及 .NET 5+(以及 .NET Framework 4.5+ 和所有 .NET Standard 版本),因此前面的代码几乎通用。TypeInfo
还包括用于反射成员的附加属性和方法。
获取数组类型
正如我们刚才看到的,typeof
和 GetType
可与数组类型一起使用。你还可以通过在 元素 类型上调用 MakeArrayType
来获取数组类型:
Type simpleArrayType = typeof (int).MakeArrayType();
Console.WriteLine (simpleArrayType == typeof (int[])); // True
通过在 MakeArrayType
上传递整数参数,可以创建多维数组:
Type cubeType = typeof (int).MakeArrayType (3); // cube shaped
Console.WriteLine (cubeType == typeof (int[,,])); // True
GetElementType
执行反向操作:检索数组类型的元素类型:
Type e = typeof (int[]).GetElementType(); // e == typeof (int)
GetArrayRank
返回矩形数组的维数:
int rank = typeof (int[,,]).GetArrayRank(); // 3
获取嵌套类型
要检索嵌套类型,请在包含类型上调用 GetNestedTypes
:
foreach (Type t in typeof (System.Environment).GetNestedTypes())
Console.WriteLine (t.FullName);
OUTPUT: System.Environment+SpecialFolder
或:
foreach (TypeInfo t in typeof (System.Environment).GetTypeInfo()
.DeclaredNestedTypes)
Debug.WriteLine (t.FullName);
嵌套类型的唯一警告是 CLR 将嵌套类型视为具有特殊的“嵌套”可访问级别:
Type t = typeof (System.Environment.SpecialFolder);
Console.WriteLine (t.IsPublic); // False
Console.WriteLine (t.IsNestedPublic); // True
类型名称
类型具有 Namespace
、Name
和 FullName
属性。在大多数情况下,FullName
是前两者的组合:
Type t = typeof (System.Text.StringBuilder);
Console.WriteLine (t.Namespace); // System.Text
Console.WriteLine (t.Name); // StringBuilder
Console.WriteLine (t.FullName); // System.Text.StringBuilder
有两个例外情况:嵌套类型和封闭泛型类型。
注意
Type
还有一个名为 AssemblyQualifiedName
的属性,返回 FullName
,后跟逗号,然后是其程序集的完整名称。这与您可以传递给 Type.GetType
的字符串相同,并在默认加载上下文中唯一标识类型。
嵌套类型名称
对于嵌套类型,包含的类型仅出现在 FullName
中:
Type t = typeof (System.Environment.SpecialFolder);
Console.WriteLine (t.Namespace); // System
Console.WriteLine (t.Name); // SpecialFolder
Console.WriteLine (t.FullName); // System.Environment+SpecialFolder
+
符号区分包含类型和嵌套命名空间。
泛型类型名称
泛型类型名称以 '
符号结尾,后跟类型参数的数量。如果泛型类型未绑定,此规则适用于 Name
和 FullName
两者:
Type t = typeof (Dictionary<,>); // Unbound
Console.WriteLine (t.Name); // Dictionary'2
Console.WriteLine (t.FullName); // System.Collections.Generic.Dictionary'2
然而,如果泛型类型已关闭,FullName
(仅)将获取一个显著的额外附加部分。列举每个类型参数的完整 程序集限定名称:
Console.WriteLine (typeof (Dictionary<int,string>).FullName);
// OUTPUT:
System.Collections.Generic.Dictionary`2[[System.Int32,
System.Private.CoreLib, Version=4.0.0.0, Culture=neutral,
PublicKeyToken=7cec85d7bea7798e],[System.String, System.Private.CoreLib,
Version=4.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e]]
这确保 AssemblyQualifiedName
(类型的全名和程序集名称的组合)包含足够的信息,以完全标识泛型类型及其类型参数。
数组和指针类型名称
数组的表示与你在 typeof
表达式中使用的后缀相同:
Console.WriteLine (typeof ( int[] ).Name); // Int32[]
Console.WriteLine (typeof ( int[,] ).Name); // Int32[,]
Console.WriteLine (typeof ( int[,] ).FullName); // System.Int32[,]
指针类型相似:
Console.WriteLine (typeof (byte*).Name); // Byte*
ref
和 out
参数类型名称
描述 ref
或 out
参数的 Type
具有 &
后缀:
public void RefMethod (ref int p)
{
Type t = MethodInfo.GetCurrentMethod().GetParameters()[0].ParameterType;
Console.WriteLine (t.Name); // Int32&
}
更多内容稍后在 “反射和调用成员” 一节中讨论。
基类型和接口
Type
公开了 BaseType
属性:
Type base1 = typeof (System.String).BaseType;
Type base2 = typeof (System.IO.FileStream).BaseType;
Console.WriteLine (base1.Name); // Object
Console.WriteLine (base2.Name); // Stream
GetInterfaces
方法返回一个类型实现的接口列表:
foreach (Type iType in typeof (Guid).GetInterfaces())
Console.WriteLine (iType.Name);
*IFormattable*
*IComparable*
*IComparable'1*
*IEquatable'1*
(GetInterfaceMap
方法返回一个结构,显示接口的每个成员在类或结构中如何实现——我们在 “调用静态虚拟/抽象接口成员” 中展示了此高级功能的用法。)
反射提供了三种 C# 静态 is
运算符的动态等效方式:
IsInstanceOfType
接受类型和实例
IsAssignableFrom
和(从 .NET 5 开始)IsAssignableTo
接受两种类型
这是第一个例子:
object obj = Guid.NewGuid();
Type target = typeof (IFormattable);
bool isTrue = obj is IFormattable; // Static C# operator
bool alsoTrue = target.IsInstanceOfType (obj); // Dynamic equivalent
IsAssignableFrom
更为灵活:
Type target = typeof (IComparable), source = typeof (string);
Console.WriteLine (target.IsAssignableFrom (source)); // True
IsSubclassOf
方法与 IsAssignableFrom
方法的工作原理相同,但不包括接口。
实例化类型
有两种方式可以根据其类型动态实例化对象:
-
调用静态
Activator.CreateInstance
方法 -
在
Type
上调用GetConstructor
返回的ConstructorInfo
对象上调用Invoke
(高级场景)
Activator.CreateInstance
接受一个 Type
和可选参数,将其传递给构造函数:
int i = (int) Activator.CreateInstance (typeof (int));
DateTime dt = (DateTime) Activator.CreateInstance (typeof (DateTime),
2000, 1, 1);
CreateInstance
允许你指定许多其他选项,例如从中加载类型的程序集以及是否绑定到非公共构造函数。如果运行时找不到合适的构造函数,则会抛出 MissingMethodException
。
当你的参数值无法区分重载构造函数时,需要在 ConstructorInfo
上调用 Invoke
。例如,假设类 X
有两个构造函数:一个接受 string
类型的参数,另一个接受 StringBuilder
类型的参数。如果你将 null
参数传递给 Activator.CreateInstance
,目标将变得模糊。这时就需要使用 ConstructorInfo
:
// Fetch the constructor that accepts a single parameter of type string:
ConstructorInfo ci = typeof (X).GetConstructor (new[] { typeof (string) });
// Construct the object using that overload, passing in null:
object foo = ci.Invoke (new object[] { null });
或者,如果你的目标是 .NET Core 1 或较旧的 Windows Store 配置文件:
ConstructorInfo ci = typeof (X).GetTypeInfo().DeclaredConstructors
.FirstOrDefault (c =>
c.GetParameters().Length == 1 &&
c.GetParameters()[0].ParameterType == typeof (string));
要获取非公共构造函数,需要指定 BindingFlags
——参见后面一节中的 “访问非公共成员” 和 “反射和调用成员”。
警告
动态实例化会在构造对象的时间上增加几微秒。相对来说,这在一般情况下并不多,因为 CLR 在实例化对象方面通常非常快速(在一个小类上进行简单的 new
操作大约需要几十纳秒的时间)。
要根据元素类型动态实例化数组,首先调用 MakeArrayType
。你也可以实例化泛型类型:我们在下一节中描述这一点。
要动态实例化委托,请调用 Delegate.CreateDelegate
。以下示例演示了如何实例化实例委托和静态委托:
class Program
{
delegate int IntFunc (int x);
static int Square (int x) => x * x; // Static method
int Cube (int x) => x * x * x; // Instance method
static void Main()
{
Delegate staticD = Delegate.CreateDelegate
(typeof (IntFunc), typeof (Program), "Square");
Delegate instanceD = Delegate.CreateDelegate
(typeof (IntFunc), new Program(), "Cube");
Console.WriteLine (staticD.DynamicInvoke (3)); // 9
Console.WriteLine (instanceD.DynamicInvoke (3)); // 27
}
}
您可以调用由 DynamicInvoke
返回的 Delegate
对象,就像我们在本例中所做的那样,或者通过将其转换为具有类型的委托来调用:
IntFunc f = (IntFunc) staticD;
Console.WriteLine (f(3)); // 9 *(but much faster!)*
您可以将 MethodInfo
传递给 CreateDelegate
而不是方法名称。我们稍后在 “反射和调用成员” 中描述 MethodInfo
,以及将动态创建的委托类型转换回静态委托类型的原因。
泛型类型
一个 Type
可以表示封闭或未绑定的泛型类型。与编译时一样,封闭泛型类型可以实例化,而未绑定类型则不能:
Type closed = typeof (List<int>);
List<int> list = (List<int>) Activator.CreateInstance (closed); // OK
Type unbound = typeof (List<>);
object anError = Activator.CreateInstance (unbound); // Runtime error
MakeGenericType
方法将一个未绑定的泛型类型转换为封闭泛型类型。只需传入所需的类型参数:
Type unbound = typeof (List<>);
Type closed = unbound.MakeGenericType (typeof (int));
GetGenericTypeDefinition
方法执行相反的操作:
Type unbound2 = closed.GetGenericTypeDefinition(); // unbound == unbound2
如果一个 Type
是泛型,IsGenericType
属性返回 true
;如果泛型类型是未绑定的,IsGenericTypeDefinition
属性返回 true
。以下代码测试一个类型是否是可空值类型:
Type nullable = typeof (bool?);
Console.WriteLine (
nullable.IsGenericType &&
nullable.GetGenericTypeDefinition() == typeof (Nullable<>)); // True
GetGenericArguments
返回封闭泛型类型的类型参数:
Console.WriteLine (closed.GetGenericArguments()[0]); // System.Int32
Console.WriteLine (nullable.GetGenericArguments()[0]); // System.Boolean
对于未绑定的泛型类型,GetGenericArguments
返回代表在泛型类型定义中指定的占位符类型的伪类型:
Console.WriteLine (unbound.GetGenericArguments()[0]); // T
注意
在运行时,所有泛型类型都是未绑定或封闭的。在表达式 typeof(Foo<>)
这种(相对不常见的)情况下,它们是未绑定的;否则,它们是封闭的。在以下类中的方法总是打印 False
:
class Foo<T>
{
public void Test()
=> Console.Write (GetType().IsGenericTypeDefinition);
}
反射和调用成员
GetMembers
方法返回类型的成员。考虑以下类:
class Walnut
{
private bool cracked;
public void Crack() { cracked = true; }
}
我们可以反射其公共成员,如下所示:
MemberInfo[] members = typeof (Walnut).GetMembers();
foreach (MemberInfo m in members)
Console.WriteLine (m);
这是结果:
Void Crack()
System.Type GetType()
System.String ToString()
Boolean Equals(System.Object)
Int32 GetHashCode()
Void .ctor()
当不带参数调用时,GetMembers
返回类型的所有公共成员(及其基类型)。GetMember
通过名称检索特定成员,虽然它仍然返回一个数组,因为成员可以被重载:
MemberInfo[] m = typeof (Walnut).GetMember ("Crack");
Console.WriteLine (m[0]); // Void Crack()
MemberInfo
还有一个名为 MemberType
的 MemberTypes
类型的属性。这是一个带有以下值的标志枚举:
All Custom Field NestedType TypeInfo
Constructor Event Method Property
调用 GetMembers
时,可以传入一个 MemberTypes
实例来限制返回的成员类型。或者,您可以通过调用 GetMethods
、GetFields
、GetProperties
、GetEvents
、GetConstructors
或 GetNestedTypes
来限制结果集。每个方法还有相应的单数版本,以精确找到特定成员。
注意
在检索类型成员时尽可能具体是值得的,这样如果以后添加了其他成员,您的代码也不会出错。如果通过名称检索方法,请指定所有参数类型,以确保如果稍后重载了方法,您的代码仍将正常工作(我们稍后在 “方法参数” 中提供示例)。
一个 MemberInfo
对象有一个 Name
属性和两个 Type
属性:
DeclaringType
返回定义成员的 Type
ReflectedType
返回调用 GetMembers
的 Type
当在基类型中定义的成员上调用时,这两者之间存在差异:DeclaringType
返回基类型,而 ReflectedType
返回子类型。以下示例突出了这一点:
// MethodInfo is a subclass of MemberInfo; see Figure 18-1.
MethodInfo test = typeof (Program).GetMethod ("ToString");
MethodInfo obj = typeof (object) .GetMethod ("ToString");
Console.WriteLine (test.DeclaringType); // System.Object
Console.WriteLine (obj.DeclaringType); // System.Object
Console.WriteLine (test.ReflectedType); // Program
Console.WriteLine (obj.ReflectedType); // System.Object
Console.WriteLine (test == obj); // False
因为它们具有不同的 ReflectedType
,所以 test
和 obj
对象不相等。然而,它们的差异纯粹是反射 API 的产物;我们的 Program
类型在底层类型系统中没有明确的 ToString
方法。我们可以通过两种方式验证这两个 MethodInfo
对象是否引用同一个方法:
Console.WriteLine (test.MethodHandle == obj.MethodHandle); // True
Console.WriteLine (test.MetadataToken == obj.MetadataToken // True
&& test.Module == obj.Module);
MethodHandle
是进程中每个(真正不同的)方法独有的;MetadataToken
在程序集模块中的所有类型和成员中是唯一的。
MemberInfo
还定义了返回自定义属性的方法(参见“在运行时检索属性”)。
注意
你可以通过调用 MethodBase.GetCurrentMethod
获得当前执行方法的 MethodBase
。
成员类型
MemberInfo
本身的成员较少,因为它是显示在 Figure 18-1 中的类型的抽象基类。
https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/cs12-ntsh/img/cn10_1801.png
图 18-1. 成员类型
你可以根据其 MemberType
属性将 MemberInfo
强制转换为其子类型。如果通过 GetMethod
、GetField
、GetProperty
、GetEvent
、GetConstructor
或它们的复数版本获得成员,则不需要转换。Table 18-1 总结了每种 C# 结构的使用方法。
Table 18-1. 检索成员元数据
C# 结构 | 使用方法 | 使用名称 | 结果 |
---|---|---|---|
方法 | GetMethod | (方法名) | MethodInfo |
属性 | GetProperty | (属性名) | PropertyInfo |
索引器 | GetDefaultMembers | MemberInfo[] (如果在 C# 中编译,则包含 PropertyInfo 对象) | |
字段 | GetField | (字段名) | FieldInfo |
枚举成员 | GetField | (成员名) | FieldInfo |
事件 | GetEvent | (事件名) | EventInfo |
构造函数 | GetConstructor | ConstructorInfo | |
终结器 | GetMethod | "Finalize" | MethodInfo |
操作符 | GetMethod | "op_" + 操作符名称 | MethodInfo |
嵌套类型 | GetNestedType | (类型名) | Type |
每个 MemberInfo
子类都有丰富的属性和方法,公开了成员元数据的所有方面。这包括可见性、修饰符、泛型类型参数、参数、返回类型和自定义属性等内容。
下面是使用 GetMethod
的示例:
MethodInfo m = typeof (Walnut).GetMethod ("Crack");
Console.WriteLine (m); // Void Crack()
Console.WriteLine (m.ReturnType); // System.Void
所有 *Info
实例在第一次使用时都会被反射 API 缓存:
MethodInfo method = typeof (Walnut).GetMethod ("Crack");
MemberInfo member = typeof (Walnut).GetMember ("Crack") [0];
Console.Write (method == member); // True
除了保留对象标识,缓存还提高了本来是相当慢的 API 的性能。
C# 成员与 CLR 成员的对比
上述表格说明了一些 C# 的功能构造与 CLR 构造之间并非一一对应。这是有道理的,因为 CLR 和反射 API 是设计用来支持所有 .NET 语言的——你甚至可以从 Visual Basic 使用反射。
一些 C# 构造——尤其是索引器、枚举、运算符和终结器——在 CLR 看来是人为的。具体来说:
-
C# 的索引器转换为接受一个或多个参数的属性,并标记为类型的
[DefaultMember]
。 -
C# 中的枚举转换为
System.Enum
的子类型,每个成员都有一个静态字段。 -
C# 运算符转换为一个以
"op_"
开头的特殊命名的静态方法,例如"op_Addition"
。 -
C# 的终结器转换为一个覆盖
Finalize
的方法。
另一个复杂之处在于属性和事件实际上包含两个部分:
-
描述属性或事件的元数据(由
PropertyInfo
或EventInfo
封装) -
一个或两个后备方法
在 C# 程序中,后备方法封装在属性或事件定义中。但在编译到 IL 时,这些后备方法呈现为可以像任何其他方法一样调用的普通方法。这意味着 GetMethods
会返回属性和事件的后备方法以及普通方法:
class Test { public int X { get { return 0; } set {} } }
void Demo()
{
foreach (MethodInfo mi in typeof (Test).GetMethods())
Console.Write (mi.Name + " ");
}
// OUTPUT:
get_X set_X GetType ToString Equals GetHashCode
通过 MethodInfo
的 IsSpecialName
属性可以识别这些方法。对于属性、索引器、事件访问器以及运算符,IsSpecialName
返回 true
。对于传统的 C# 方法(以及定义了终结器的 Finalize
方法),它返回 false
。
这些是 C# 生成的后备方法:
C# 构造 | 成员类型 | IL 中的方法 |
---|---|---|
属性 | Property | get_*XXX* 和 set_*XXX* |
索引器 | Property | get_Item 和 set_Item |
事件 | Event | add_*XXX* 和 remove_*XXX* |
每个后备方法都有其自己关联的 MethodInfo
对象。你可以按以下方式访问它们:
PropertyInfo pi = typeof (Console).GetProperty ("Title");
MethodInfo getter = pi.GetGetMethod(); // get_Title
MethodInfo setter = pi.GetSetMethod(); // set_Title
MethodInfo[] both = pi.GetAccessors(); // Length==2
GetAddMethod
和 GetRemoveMethod
为 EventInfo
执行类似的工作。
要从 MethodInfo
到其关联的 PropertyInfo
或 EventInfo
实现反向操作,你需要执行一个查询。LINQ 是这项工作的理想选择:
PropertyInfo p = mi.DeclaringType.GetProperties()
.First (x => x.GetAccessors (true).Contains (mi));
只读初始化属性
C# 9 中引入的只读属性可以通过对象初始化器设置,但编译器会将其后续视为只读。从 CLR 的角度看,init
访问器就像是普通的 set
访问器,但 set
方法的返回类型上有一个特殊的标志(这对编译器意味着一些东西)。
有趣的是,这个标志并没有编码为一个约定属性。相反,它使用了一个相对隐蔽的机制称为 modreq,这确保了早期版本的 C# 编译器(不识别新的 modreq)会忽略访问器,而不是将属性视为可写。
只读访问器的 modreq 称为 IsExternalInit
,你可以按如下方式查询它:
bool IsInitOnly (PropertyInfo pi) => pi
.GetSetMethod().ReturnParameter.GetRequiredCustomModifiers()
.Any (t => t.Name == "IsExternalInit");
NullabilityInfoContext
从.NET 6 开始,你可以使用NullabilityInfoContext
类获取有关字段、属性、事件或参数的 nullability 注释的信息:
void PrintPropertyNullability (PropertyInfo pi)
{
var info = new NullabilityInfoContext().Create (pi);
Console.WriteLine (pi.Name + " read " + info.ReadState);
Console.WriteLine (pi.Name + " write " + info.WriteState);
// Use info.Element to get nullability info for array elements
}
泛型类型成员
你可以获取未绑定和闭合泛型类型的成员元数据:
PropertyInfo unbound = typeof (IEnumerator<>) .GetProperty ("Current");
PropertyInfo closed = typeof (IEnumerator<int>).GetProperty ("Current");
Console.WriteLine (unbound); // T Current
Console.WriteLine (closed); // Int32 Current
Console.WriteLine (unbound.PropertyType.IsGenericParameter); // True
Console.WriteLine (closed.PropertyType.IsGenericParameter); // False
从未绑定和闭合泛型类型返回的MemberInfo
对象始终是不同的,即使对于不包含泛型类型参数签名的成员也是如此:
PropertyInfo unbound = typeof (List<>) .GetProperty ("Count");
PropertyInfo closed = typeof (List<int>).GetProperty ("Count");
Console.WriteLine (unbound); // Int32 Count
Console.WriteLine (closed); // Int32 Count
Console.WriteLine (unbound == closed); // False
Console.WriteLine (unbound.DeclaringType.IsGenericTypeDefinition); // True
Console.WriteLine (closed.DeclaringType.IsGenericTypeDefinition); // False
未绑定泛型类型的成员无法动态调用。
动态调用成员
注意
可以通过Uncapsulator 开源库(在 NuGet 和 GitHub 上可用)更轻松地实现动态调用成员。Uncapsulator 是作者编写的,提供了一个流畅的 API,通过反射使用自定义动态绑定器调用公共和非公共成员。
当你拥有MethodInfo
、PropertyInfo
或FieldInfo
对象后,你可以动态调用它或获取/设置其值。这被称为后期绑定,因为你在运行时选择要调用的成员,而不是在编译时。
为了说明,以下使用普通的静态绑定:
string s = "Hello";
int length = s.Length;
下面是使用后期绑定动态执行的相同操作:
object s = "Hello";
PropertyInfo prop = s.GetType().GetProperty ("Length");
int length = (int) prop.GetValue (s, null); // 5
GetValue
和SetValue
获取和设置PropertyInfo
或FieldInfo
的值。第一个参数是实例,对于静态成员可以是null
。访问索引器就像访问名为“Item”的属性一样,只是在调用GetValue
或SetValue
时,你需要将索引值作为第二个参数提供。
要动态调用方法,请在MethodInfo
上调用Invoke
,并提供一个参数数组传递给该方法。如果你传递的参数类型有误,运行时会抛出异常。使用动态调用,你失去了编译时类型安全性,但仍具有运行时类型安全性(就像使用dynamic
关键字一样)。
方法参数
假设我们想要动态调用string
的Substring
方法。静态情况下,我们可以这样做:
Console.WriteLine ("stamp".Substring(2)); // "amp"
下面是使用反射和后期绑定的动态等效代码:
Type type = typeof (string);
Type[] parameterTypes = { typeof (int) };
MethodInfo method = type.GetMethod ("Substring", parameterTypes);
object[] arguments = { 2 };
object returnValue = method.Invoke ("stamp", arguments);
Console.WriteLine (returnValue); // "amp"
因为Substring
方法是重载的,所以我们必须在GetMethod
中传递参数类型的数组,以指示我们想要哪个版本。如果没有传递参数类型,GetMethod
会抛出AmbiguousMatchException
。
在MethodBase
(MethodInfo
和ConstructorInfo
的基类)上定义的GetParameters
方法返回参数元数据。我们可以继续我们之前的例子,如下所示:
ParameterInfo[] paramList = method.GetParameters();
foreach (ParameterInfo x in paramList)
{
Console.WriteLine (x.Name); // startIndex
Console.WriteLine (x.ParameterType); // System.Int32
}
处理ref
和out
参数
要传递ref
或out
参数,请在获取方法之前在类型上调用MakeByRefType
。例如,你可以动态执行这段代码:
int x;
bool successfulParse = int.TryParse ("23", out x);
如下:
object[] args = { "23", 0 };
Type[] argTypes = { typeof (string), typeof (int).MakeByRefType() };
MethodInfo tryParse = typeof (int).GetMethod ("TryParse", argTypes);
bool successfulParse = (bool) tryParse.Invoke (null, args);
Console.WriteLine (successfulParse + " " + args[1]); // True 23
此方法对ref
和out
参数类型都适用。
检索和调用泛型方法
在调用GetMethod
时显式指定参数类型可能在消除重载方法歧义时至关重要。但是,无法指定泛型参数类型。例如,请考虑System.Linq.Enumerable
类,它重载了Where
方法,如下所示:
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource, bool> predicate);
public static IEnumerable<TSource> Where<TSource>
(this IEnumerable<TSource> source, Func<TSource, int, bool> predicate);
要检索特定的重载,我们必须检索所有方法,然后手动找到所需的重载。以下查询检索Where
的前一个重载:
from m in typeof (Enumerable).GetMethods()
where m.Name == "Where" && m.IsGenericMethod
let parameters = m.GetParameters()
where parameters.Length == 2
let genArg = m.GetGenericArguments().First()
let enumerableOfT = typeof (IEnumerable<>).MakeGenericType (genArg)
let funcOfTBool = typeof (Func<,>).MakeGenericType (genArg, typeof (bool))
where parameters[0].ParameterType == enumerableOfT
&& parameters[1].ParameterType == funcOfTBool
select m
在此查询上调用.Single()
会给出正确的MethodInfo
对象,带有未绑定的类型参数。下一步是通过调用MakeGenericMethod
关闭类型参数:
var closedMethod = unboundMethod.MakeGenericMethod (typeof (int));
在这种情况下,我们用int
关闭了TSource
,允许我们使用类型为IEnumerable<int>
的源和类型为Func<int,bool>
的谓词调用Enumerable.Where
:
int[] source = { 3, 4, 5, 6, 7, 8 };
Func<int, bool> predicate = n => n % 2 == 1; // Odd numbers only
现在我们可以调用闭合泛型方法了:
var query = (IEnumerable<int>) closedMethod.Invoke
(null, new object[] { source, predicate });
foreach (int element in query) Console.Write (element + "|"); // 3|5|7|
注意
如果您使用System.Linq.Expressions
API 动态构建表达式(第八章),您无需费力指定泛型方法。Expression.Call
方法被重载,允许您指定要调用的方法的闭合类型参数:
int[] source = { 3, 4, 5, 6, 7, 8 };
Func<int, bool> predicate = n => n % 2 == 1;
var sourceExpr = Expression.Constant (source);
var predicateExpr = Expression.Constant (predicate);
var callExpression = Expression.Call (
typeof (Enumerable), "Where",
new[] { typeof (int) }, // Closed generic arg type.
sourceExpr, predicateExpr);
使用委托提升性能
动态调用通常效率较低,开销通常在几微秒左右。如果你在循环中重复调用方法,可以通过调用一个动态实例化的委托来将每次调用的开销降低到纳秒级别。在下面的例子中,我们动态调用string
的Trim
方法一百万次,没有显著的开销:
MethodInfo trimMethod = typeof (string).GetMethod ("Trim", new Type[0]);
var trim = (StringToString) Delegate.CreateDelegate
(typeof (StringToString), trimMethod);
for (int i = 0; i < 1000000; i++)
trim ("test");
delegate string StringToString (string s);
这样做更快,因为昂贵的后期绑定(如粗体所示)只发生一次。
访问非公共成员
所有用于探测元数据的类型的方法(例如GetProperty
,GetField
等)都有接受BindingFlags
枚举的重载。此枚举用作元数据过滤器,并允许您更改默认选择标准。最常见的用法是检索非公共成员(仅适用于桌面应用程序)。
例如,考虑以下类:
class Walnut
{
private bool cracked;
public void Crack() { cracked = true; }
public override string ToString() { return cracked.ToString(); }
}
我们可以打开核桃,如下所示:
Type t = typeof (Walnut);
Walnut w = new Walnut();
w.Crack();
FieldInfo f = t.GetField ("cracked", BindingFlags.NonPublic |
BindingFlags.Instance);
f.SetValue (w, false);
Console.WriteLine (w); // False
使用反射访问非公共成员非常强大,但也很危险,因为它可以绕过封装,创建对类型内部实现的难以管理的依赖。
BindingFlags 枚举
BindingFlags
旨在进行按位组合。为了获得任何匹配项,您需要从以下四种组合中选择一种起始组合:
BindingFlags.Public | BindingFlags.Instance
BindingFlags.Public | BindingFlags.Static
BindingFlags.NonPublic | BindingFlags.Instance
BindingFlags.NonPublic | BindingFlags.Static
NonPublic
包括internal
,protected
,protected internal
和private
。
以下示例检索类型为object
的所有公共静态成员:
BindingFlags publicStatic = BindingFlags.Public | BindingFlags.Static;
MemberInfo[] members = typeof (object).GetMembers (publicStatic);
以下示例检索类型为object
的所有非公共成员,包括静态和实例:
BindingFlags nonPublicBinding =
BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance;
MemberInfo[] members = typeof (object).GetMembers (nonPublicBinding);
DeclaredOnly
标志排除了从基类型继承的函数,除非它们被重写。
注意
DeclaredOnly
标志有些令人困惑,因为它限制结果集(而所有其他绑定标志都扩展结果集)。
泛型方法
你不能直接调用泛型方法;以下代码会抛出异常:
class Program
{
public static T Echo<T> (T x) { return x; }
static void Main()
{
MethodInfo echo = typeof (Program).GetMethod ("Echo");
Console.WriteLine (echo.IsGenericMethodDefinition); // True
echo.Invoke (null, new object[] { 123 } ); // *Exception*
}
}
需要额外的一步,即在MethodInfo
上调用MakeGenericMethod
,指定具体的泛型类型参数。这将返回另一个MethodInfo
,然后可以像以下这样调用它:
MethodInfo echo = typeof (Program).GetMethod ("Echo");
MethodInfo intEcho = echo.MakeGenericMethod (typeof (int));
Console.WriteLine (intEcho.IsGenericMethodDefinition); // False
Console.WriteLine (intEcho.Invoke (null, new object[] { 3 } )); // 3
匿名调用泛型接口的成员
当你需要调用泛型接口的成员但直到运行时才知道类型参数时,反射是很有用的。理论上,如果类型设计完美,很少需要这样做;当然,类型并不总是设计得完美。
比如,假设我们想要编写一个更强大的ToString
版本,可以扩展 LINQ 查询的结果。我们可以这样开始:
public static string ToStringEx <T> (IEnumerable<T> sequence)
{
...
}
这已经相当有限了。如果sequence
包含我们还想要枚举的嵌套集合怎么办?我们需要重载方法来应对:
public static string ToStringEx <T> (IEnumerable<IEnumerable<T>> sequence)
然后如果sequence
包含分组或嵌套序列的投影怎么办?方法重载的静态解决方案变得不切实际,我们需要一种能够处理任意对象图的方法,例如下面的方法:
public static string ToStringEx (object value)
{
if (value == null) return "<null>";
StringBuilder sb = new StringBuilder();
if (value is List<>) // Error
sb.Append ("List of " + ((List<>) value).Count + " items"); // Error
if (value is IGrouping<,>) // Error
sb.Append ("Group with key=" + ((IGrouping<,>) value).Key); // Error
// Enumerate collection elements if this is a collection,
// recursively calling ToStringEx()
// ...
return sb.ToString();
}
不幸的是,这段代码无法编译:你不能调用未绑定泛型类型如List<>
或IGrouping<>
的成员。对于List<>
,我们可以通过使用非泛型的IList
接口来解决这个问题:
if (value is IList)
sb.AppendLine ("A list with " + ((IList) value).Count + " items");
注意
我们能够这样做是因为List<>
的设计者有远见,实现了经典的IList
(以及泛型的IList
)。在编写自己的泛型类型时,考虑到消费者可以依赖的非泛型接口或基类可能非常有价值。
对于IGrouping<,>
来说解决方案就没有那么简单。这是接口的定义方式:
public interface IGrouping <TKey,TElement> : IEnumerable <TElement>,
IEnumerable
{
TKey Key { get; }
}
没有非泛型类型可以用来访问Key
属性,因此我们必须在这里使用反射。解决方案不是调用未绑定泛型类型的成员(这是不可能的),而是调用封闭泛型类型的成员,在运行时确定其类型参数。
注意
在接下来的章节中,我们将使用 C#的dynamic
关键字更简单地解决这个问题。动态绑定的一个很好的指示是当你需要进行类型操作时,就像我们现在正在做的一样。
第一步是确定value
是否实现了IGrouping<,>
,如果是,则获取其封闭的泛型接口。我们可以通过执行 LINQ 查询来最简单地完成这个任务。然后,我们检索并调用Key
属性:
public static string ToStringEx (object value)
{
if (value == null) return "<null>";
if (value.GetType().IsPrimitive) return value.ToString();
StringBuilder sb = new StringBuilder();
if (value is IList)
sb.Append ("List of " + ((IList)value).Count + " items: ");
Type closedIGrouping = value.GetType().GetInterfaces()
.Where (t => t.IsGenericType &&
t.GetGenericTypeDefinition() == typeof (IGrouping<,>))
.FirstOrDefault();
if (closedIGrouping != null) // Call the Key property on IGrouping<,>
{
PropertyInfo pi = closedIGrouping.GetProperty ("Key");
object key = pi.GetValue (value, null);
sb.Append ("Group with key=" + key + ": ");
}
if (value is IEnumerable)
foreach (object element in ((IEnumerable)value))
sb.Append (ToStringEx (element) + " ");
if (sb.Length == 0) sb.Append (value.ToString());
return "\r\n" + sb.ToString();
}
这种方法非常强大:无论IGrouping<,>
是隐式实现还是显式实现,都能正常工作。以下演示了这种方法:
Console.WriteLine (ToStringEx (new List<int> { 5, 6, 7 } ));
Console.WriteLine (ToStringEx ("xyyzzz".GroupBy (c => c) ));
*List of 3 items: 5 6 7*
*Group with key=x: x*
*Group with key=y: y y*
*Group with key=z: z z z*
调用静态虚/抽象接口成员
从.NET 7 和 C# 11 开始,接口可以定义静态虚拟和抽象成员(参见“静态虚拟/抽象接口成员”)。一个例子是.NET 中的IParsable<TSelf>
接口:
public interface IParsable<TSelf> where TSelf : IParsable<TSelf>
{
static abstract TSelf Parse (string s, IFormatProvider provider);
...
}
通过受约束的类型参数,可以多态地调用静态抽象接口成员:
T ParseAny<T> (string s) where T : IParsable<T> => T.Parse (s, null);
要通过反射调用静态抽象接口成员,你必须从实现该接口的具体类型中获取MethodInfo
,而不是从接口本身获取。显而易见的解决方案是通过签名检索具体成员:
MethodInfo GetParseMethod (Type concreteType) =>
concreteType.GetMethod ("Parse",
new[] { typeof (string), typeof (IFormatProvider) });
然而,如果成员是显式实现的,则会失败。为了以通用方式解决这个问题,我们将首先编写一个函数,用于在实现指定接口方法的具体类型上检索MethodInfo
:
MethodInfo GetImplementedInterfaceMethod (Type concreteType,
Type interfaceType, string methodName, Type[] paramTypes)
{
var map = concreteType.GetInterfaceMap (interfaceType);
return map.InterfaceMethods
.Zip (map.TargetMethods)
.Single (m => m.First.Name == methodName &&
m.First.GetParameters().Select (p => p.ParameterType)
.SequenceEqual (paramTypes))
.Second;
}
使其工作的关键是调用GetInterfaceMap
。这个方法返回以下结构体:
public struct InterfaceMapping
{
public MethodInfo[] InterfaceMethods; // These arrays each
public MethodInfo[] TargetMethods; // have the same length.
...
}
这个结构告诉我们,实现的接口成员(InterfaceMethods
)如何映射到具体类型的成员(TargetMethods
)。
注意
GetInterfaceMap
也适用于普通(实例)方法;在处理静态抽象接口成员时特别有用。
我们接着使用 LINQ 的Zip
方法来对齐这两个数组中的元素,从而可以轻松地获取与所需签名的接口方法对应的目标方法。
现在我们可以利用这一点来编写基于反射的ParseAny
方法:
object ParseAny (Type type, string value)
{
MethodInfo parseMethod = GetImplementedInterfaceMethod (type,
type.GetInterface ("IParsable`1"),
"Parse",
new[] { typeof (string), typeof (IFormatProvider) });
return parseMethod.Invoke (null, new[] { value, null });
}
Console.WriteLine (ParseAny (typeof (float), ".2")); // 0.2
在调用GetImplementedInterfaceMethod
时,我们需要提供(封闭的)接口类型,我们通过在具体类型上调用GetInterface("IParsable`1")
来获取它。鉴于在编译时我们知道所需的接口,我们可以改用以下表达式:
typeof (IParsable<>).MakeGenericType (type)
反射程序集
你可以通过在Assembly
对象上调用GetType
或GetTypes
来动态反射一个程序集。以下从当前程序集中检索名为Demos
命名空间下的TestProgram
类型:
Type t = Assembly.GetExecutingAssembly().GetType ("Demos.TestProgram");
你也可以从现有类型获取程序集:
typeof (Foo).Assembly.GetType ("Demos.TestProgram");
下一个示例列出了位于e:\demo
下的mylib.dll
程序集中的所有类型:
Assembly a = Assembly.LoadFile (@"e:\demo\mylib.dll");
foreach (Type t in a.GetTypes())
Console.WriteLine (t);
或者:
Assembly a = typeof (Foo).GetTypeInfo().Assembly;
foreach (Type t in a.ExportedTypes)
Console.WriteLine (t);
GetTypes
和ExportedTypes
仅返回顶层类型,而不是嵌套类型。
模块
在多模块程序集上调用GetTypes
会返回所有模块中的所有类型。因此,你可以忽略模块的存在,并将程序集视为类型的容器。不过,在处理元数据标记时,有一个情况是模块是相关的。
元数据标记是一个整数,唯一地引用模块范围内的类型、成员、字符串或资源。IL 使用元数据标记,因此如果你在解析 IL,你需要能够解析它们。执行此操作的方法在Module
类型中定义,称为ResolveType
、ResolveMember
、ResolveString
和ResolveSignature
。我们将在本章的最后一节,关于编写反汇编器时重新讨论这一点。
通过调用 GetModules
,你可以获取程序集中所有模块的列表。你也可以通过其 ManifestModule
属性直接访问程序集的主模块。
使用属性工作
CLR 允许通过属性将附加的元数据附加到类型、成员和程序集上。这是某些重要 CLR 功能(如程序集标识或为本机互操作而编组类型)的指导机制,使属性成为应用程序不可分割的一部分。
属性的一个关键特性是,你可以编写自己的属性,然后像使用任何其他属性一样“装饰”代码元素,以提供额外信息。这些额外信息编译到底层程序集中,并且可以通过反射在运行时检索,用于构建声明性工作的服务,如自动化单元测试。
属性基础知识
有三种类型的属性:
-
位映射属性
-
自定义属性
-
伪自定义属性
其中,只有 自定义属性 是可扩展的。
注意
术语“属性”本身可以指任何三种属性之一,尽管在 C# 中,它通常指自定义属性或伪自定义属性。
位映射属性(我们的术语)映射到类型元数据中的专用位。大多数 C# 的修饰符关键字,如 public
、abstract
和 sealed
,编译为位映射属性。这些属性非常高效,因为它们在元数据中消耗的空间很小(通常只有一个位),CLR 可以通过很少或没有间接寻址来定位它们。反射 API 通过 Type
(和其他 MemberInfo
子类)的专用属性(如 IsPublic
、IsAbstract
和 IsSealed
)公开它们。Attributes
属性以标志枚举的形式一次性描述它们中的大多数:
static void Main()
{
TypeAttributes ta = typeof (Console).Attributes;
MethodAttributes ma = MethodInfo.GetCurrentMethod().Attributes;
Console.WriteLine (ta + "\r\n" + ma);
}
这里是结果:
AutoLayout, AnsiClass, Class, Public, Abstract, Sealed, BeforeFieldInit
PrivateScope, Private, Static, HideBySig
相比之下,自定义属性 编译成附加在类型主要元数据表上的一个 blob。所有自定义属性都由 System.Attribute
的子类表示,并且与位映射属性不同,它们是可扩展的。元数据中的 blob 标识属性类,并存储了应用属性时指定的任何位置参数或命名参数的值。你自己定义的自定义属性在架构上与 .NET 库中定义的属性完全相同。
第四章 描述了如何在 C# 中将自定义属性附加到类型或成员上。在这里,我们将预定义的 Obsolete
属性附加到 Foo
类上:
[Obsolete] public class Foo {...}
这指示编译器将 ObsoleteAttribute
的一个实例合并到 Foo
的元数据中,然后可以通过在 Type
或 MemberInfo
对象上调用 GetCustomAttributes
在运行时反映它。
伪自定义属性 在外观和感觉上与标准自定义属性完全相同。它们由 System.Attribute
的子类表示,并且以标准方式附加:
[System.Runtime.InteropServices.StructLayout(LayoutKind.Sequential)]
class SystemTime { ... }
区别在于编译器或 CLR 通过将伪自定义属性转换为位图属性来进行内部优化。示例包括StructLayout
、In
和Out
(第二十四章)。反射通过专用属性(如IsLayoutSequential
)公开伪自定义属性,并且在调用GetCustomAttributes
时,在许多情况下它们也作为System.Attribute
对象返回。这意味着您几乎可以忽略伪自定义属性和非伪自定义属性之间的区别(一个值得注意的例外是在使用Reflection.Emit
在运行时动态生成类型时;请参阅“发出程序集和类型”)。
AttributeUsage 属性
AttributeUsage
是应用于属性类的属性。它指示编译器如何使用目标属性:
public sealed class AttributeUsageAttribute : Attribute
{
public AttributeUsageAttribute (AttributeTargets validOn);
public bool AllowMultiple { get; set; }
public bool Inherited { get; set; }
public AttributeTargets ValidOn { get; }
}
AllowMultiple
控制正在定义的属性是否可以多次应用于相同的目标;Inherited
控制应用于基类的属性是否也应用于派生类(或在方法的情况下,应用于虚方法的属性是否也应用于覆盖方法)。ValidOn
确定属性可以附加到的目标集(类、接口、属性、方法、参数等)。它接受AttributeTargets
枚举的任何值组合,该枚举具有以下成员:
All | Delegate | GenericParameter | Parameter |
---|---|---|---|
Assembly | Enum | Interface | Property |
Class | Event | Method | ReturnValue |
Constructor | Field | Module | Struct |
为了说明,这里是.NET 的作者如何将AttributeUsage
应用于Serializable
属性:
[AttributeUsage (AttributeTargets.Delegate |
AttributeTargets.Enum |
AttributeTargets.Struct |
AttributeTargets.Class, Inherited = false)
]
public sealed class SerializableAttribute : Attribute { }
实际上,这几乎是Serializable
属性的完整定义。编写一个没有属性或特殊构造函数的属性类就是这么简单。
定义您自己的属性
下面是如何编写自己的属性:
-
从
System.Attribute
或System.Attribute
的后代类派生一个类。按照惯例,类名应该以单词“Attribute”结尾,尽管这不是必需的。 -
应用在前一节中描述的
AttributeUsage
属性。如果属性在其构造函数中不需要任何属性或参数,则工作完成。
-
编写一个或多个公共构造函数。构造函数的参数定义了属性的位置参数,并在使用属性时将变为必需。
-
为您希望支持的每个命名参数声明一个公共字段或属性。在使用属性时,命名参数是可选的。
注意
属性属性和构造函数参数必须是以下类型之一:
-
一个封闭的原始类型:换句话说,
bool
、byte
、char
、double
、float
、int
、long
、short
或string
-
Type
类型 -
一个枚举类型
-
这些中的任何一个一维数组
当应用属性时,编译器还必须能够静态评估每个属性或构造函数参数。
以下类定义了一个属性,用于辅助自动化单元测试系统。它指示应测试的方法、测试重复次数以及在失败时的消息:
[AttributeUsage (AttributeTargets.Method)]
public sealed class TestAttribute : Attribute
{
public int Repetitions;
public string FailureMessage;
public TestAttribute () : this (1) { }
public TestAttribute (int repetitions) { Repetitions = repetitions; }
}
这里是一个带有各种方式装饰Test
属性的Foo
类的方法:
class Foo
{
[Test]
public void Method1() { ... }
[Test(20)]
public void Method2() { ... }
[Test(20, FailureMessage="Debugging Time!")]
public void Method3() { ... }
}
在运行时检索属性
有两种标准方法在运行时检索属性:
-
在任何
Type
或MemberInfo
对象上调用GetCustomAttributes
-
调用
Attribute.GetCustomAttribute
或Attribute.GetCustomAttributes
后两种方法重载以接受与有效属性目标对应的任何反射对象(Type
、Assembly
、Module
、MemberInfo
或ParameterInfo
)。
注意
您还可以在类型或成员上调用GetCustomAttributes**Data**()
来获取属性信息。与GetCustomAttributes()
的区别在于前者让您了解属性如何实例化:它报告了使用的构造函数重载以及每个构造函数参数和命名参数的值。这在您希望发出代码或 IL 以重建属性到相同状态时非常有用(参见“发出类型成员”)。
下面是如何枚举先前的Foo
类中具有TestAttribute
的每个方法:
foreach (MethodInfo mi in typeof (Foo).GetMethods())
{
TestAttribute att = (TestAttribute) Attribute.GetCustomAttribute
(mi, typeof (TestAttribute));
if (att != null)
Console.WriteLine ("Method {0} will be tested; reps={1}; msg={2}",
mi.Name, att.Repetitions, att.FailureMessage);
}
或:
foreach (MethodInfo mi in typeof (Foo).GetTypeInfo().DeclaredMethods)
...
这是输出:
Method Method1 will be tested; reps=1; msg=
Method Method2 will be tested; reps=20; msg=
Method Method3 will be tested; reps=20; msg=Debugging Time!
为了完成说明,展示如何使用此方法编写单元测试系统,以下是扩展示例,实际调用装饰有Test
属性的方法:
foreach (MethodInfo mi in typeof (Foo).GetMethods())
{
TestAttribute att = (TestAttribute) Attribute.GetCustomAttribute
(mi, typeof (TestAttribute));
if (att != null)
for (int i = 0; i < att.Repetitions; i++)
try
{
mi.Invoke (new Foo(), null); // Call method with no arguments
}
catch (Exception ex) // Wrap exception in att.FailureMessage
{
throw new Exception ("Error: " + att.FailureMessage, ex);
}
}
返回到属性反射,这里有一个示例列出了特定类型上存在的属性:
object[] atts = Attribute.GetCustomAttributes (typeof (Test));
foreach (object att in atts) Console.WriteLine (att);
[Serializable, Obsolete]
class Test
{
}
这里是输出:
System.ObsoleteAttribute
System.SerializableAttribute
动态代码生成
System.Reflection.Emit
命名空间包含用于在运行时创建元数据和 IL 的类。动态生成代码对于某些类型的编程任务很有用。例如,正则表达式 API 会发出针对特定正则表达式进行调优的高性能类型。另一个例子是 Entity Framework Core,它使用Reflection.Emit
生成代理类以实现延迟加载。
使用DynamicMethod
生成 IL
DynamicMethod
类是System.Reflection.Emit
命名空间中用于动态生成方法的轻量级工具。与TypeBuilder
不同,它不需要您首先设置动态程序集、模块和类型来包含方法。这使它适用于简单的任务,同时也是Reflection.Emit
的很好入门。
注意
当不再引用DynamicMethod
及其关联的 IL 时,它们会被垃圾回收。这意味着您可以重复生成动态方法,而不会填满内存。(要在动态程序集中执行相同操作,创建程序集时必须应用AssemblyBuilderAccess.RunAndCollect
标志。)
下面是使用 DynamicMethod
创建一个向控制台输出 Hello world
的方法的简单示例:
public class Test
{
static void Main()
{
var dynMeth = new DynamicMethod ("Foo", null, null, typeof (Test));
ILGenerator gen = dynMeth.GetILGenerator();
gen.EmitWriteLine ("Hello world");
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // Hello world
}
}
OpCodes
拥有每个 IL 操作码的静态只读字段。大多数功能通过各种操作码公开,尽管 ILGenerator
还有专门用于生成标签和本地变量以及异常处理的方法。方法总是以 OpCodes.Ret
结束,这意味着“返回”,或者某种分支/抛出指令。ILGenerator
上的 EmitWriteLine
方法是 Emit
低级操作码的一种快捷方式。如果我们用下面的代码替换对 EmitWriteLine
的调用,将获得相同的结果:
MethodInfo writeLineStr = typeof (Console).GetMethod ("WriteLine",
new Type[] { typeof (string) });
gen.Emit (OpCodes.Ldstr, "Hello world"); // Load a string
gen.Emit (OpCodes.Call, writeLineStr); // Call a method
注意我们将 typeof(Test)
传递给 DynamicMethod
构造函数。这使得动态方法可以访问该类型的非公共方法,从而使我们可以执行如下操作:
public class Test
{
static void Main()
{
var dynMeth = new DynamicMethod ("Foo", null, null, typeof (Test));
ILGenerator gen = dynMeth.GetILGenerator();
MethodInfo privateMethod = typeof(Test).GetMethod ("HelloWorld",
BindingFlags.Static | BindingFlags.NonPublic);
gen.Emit (OpCodes.Call, privateMethod); // Call HelloWorld
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // Hello world
}
static void HelloWorld() // private method, yet we can call it
{
Console.WriteLine ("Hello world");
}
}
理解 IL 需要大量时间投资。与其理解所有操作码,不如先编译一个 C# 程序,然后检查、复制和调整 IL 更为简单。LINQPad 可以显示您键入的任何方法或代码片段的 IL,而诸如 ILSpy 的程序集查看工具对于检查现有程序集非常有用。
评估堆栈
IL 的核心概念是评估堆栈。要调用带有参数的方法,首先将参数推送(“加载”)到评估堆栈上,然后调用方法。方法然后从评估堆栈弹出其需要的参数。我们先前在调用 Console.WriteLine
中演示了这一点。以下是一个类似的例子,调用一个整数:
var dynMeth = new DynamicMethod ("Foo", null, null, typeof(void));
ILGenerator gen = dynMeth.GetILGenerator();
MethodInfo writeLineInt = typeof (Console).GetMethod ("WriteLine",
new Type[] { typeof (int) });
// The Ldc* op-codes load numeric literals of various types and sizes.
gen.Emit (OpCodes.Ldc_I4, 123); // Push a 4-byte integer onto stack
gen.Emit (OpCodes.Call, writeLineInt);
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // 123
要将两个数字相加,首先将每个数字加载到评估堆栈上,然后调用 Add
。Add
操作码从评估堆栈弹出两个值并将结果推送回去。以下代码将 2 和 2 相加,然后使用之前获取的 writeLine
方法写入结果:
gen.Emit (OpCodes.Ldc_I4, 2); // Push a 4-byte integer, value=2
gen.Emit (OpCodes.Ldc_I4, 2); // Push a 4-byte integer, value=2
gen.Emit (OpCodes.Add); // Add the result together
gen.Emit (OpCodes.Call, writeLineInt);
要计算 10 / 2 + 1
,可以选择下面任意一种方法:
gen.Emit (OpCodes.Ldc_I4, 10);
gen.Emit (OpCodes.Ldc_I4, 2);
gen.Emit (OpCodes.Div);
gen.Emit (OpCodes.Ldc_I4, 1);
gen.Emit (OpCodes.Add);
gen.Emit (OpCodes.Call, writeLineInt);
或者这样:
gen.Emit (OpCodes.Ldc_I4, 1);
gen.Emit (OpCodes.Ldc_I4, 10);
gen.Emit (OpCodes.Ldc_I4, 2);
gen.Emit (OpCodes.Div);
gen.Emit (OpCodes.Add);
gen.Emit (OpCodes.Call, writeLineInt);
向动态方法传递参数
Ldarg
和 Ldarg_*XXX*
操作码将传递给方法的参数加载到堆栈上。为了返回一个值,在完成时确保堆栈上恰好有一个值。为了使其工作,调用 DynamicMethod
构造函数时必须指定返回类型和参数类型。以下创建了一个动态方法,返回两个整数的和:
DynamicMethod dynMeth = new DynamicMethod ("Foo",
typeof (int), // Return type = int
new[] { typeof (int), typeof (int) }, // Parameter types = int, int
typeof (void));
ILGenerator gen = dynMeth.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Push first arg onto eval stack
gen.Emit (OpCodes.Ldarg_1); // Push second arg onto eval stack
gen.Emit (OpCodes.Add); // Add them together (result on stack)
gen.Emit (OpCodes.Ret); // Return with stack having 1 value
int result = (int) dynMeth.Invoke (null, new object[] { 3, 4 } ); // 7
警告
当退出时,评估堆栈必须恰好有 0 或 1 个项(取决于方法是否返回值)。如果违反此规则,CLR 将拒绝执行您的方法。通过发出 OpCodes.Pop
可以从堆栈中移除一个不处理的项。
与其调用 Invoke
,使用动态方法作为类型化委托可能更方便。CreateDelegate
方法正是为此而设计。在我们的例子中,我们需要一个有两个整数参数和一个整数返回类型的委托。我们可以使用 Func<int, int, int>
委托来实现这一目的。因此,前面例子的最后一行变成了如下内容:
var func = (Func<int,int,int>) dynMeth.CreateDelegate
(typeof (Func<int,int,int>));
int result = func (3, 4); // 7
注意
代理还消除了动态方法调用的开销,每次调用可节省几微秒。
我们演示了如何在 “发出类型成员” 中通过引用传递。
生成本地变量
您可以通过在 ILGenerator
上调用 DeclareLocal
来声明一个局部变量。这会返回一个 LocalBuilder
对象,您可以将其与诸如 Ldloc
(加载局部变量)或 Stloc
(存储局部变量)的操作码结合使用。Ldloc
将堆栈推入评估堆栈;Stloc
弹出评估堆栈。例如,请考虑以下 C# 代码:
int x = 6;
int y = 7;
x *= y;
Console.WriteLine (x);
以下动态生成了前面的代码:
var dynMeth = new DynamicMethod ("Test", null, null, typeof (void));
ILGenerator gen = dynMeth.GetILGenerator();
LocalBuilder localX = gen.DeclareLocal (typeof (int)); // Declare x
LocalBuilder localY = gen.DeclareLocal (typeof (int)); // Declare y
gen.Emit (OpCodes.Ldc_I4, 6); // Push literal 6 onto eval stack
gen.Emit (OpCodes.Stloc, localX); // Store in localX
gen.Emit (OpCodes.Ldc_I4, 7); // Push literal 7 onto eval stack
gen.Emit (OpCodes.Stloc, localY); // Store in localY
gen.Emit (OpCodes.Ldloc, localX); // Push localX onto eval stack
gen.Emit (OpCodes.Ldloc, localY); // Push localY onto eval stack
gen.Emit (OpCodes.Mul); // Multiply values together
gen.Emit (OpCodes.Stloc, localX); // Store the result to localX
gen.EmitWriteLine (localX); // Write the value of localX
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // 42
分支
在 IL 中,没有 while
、do
和 for
循环;所有操作都使用标签和等效的 goto
和条件 goto
语句完成。这些是分支操作码,如 Br
(无条件跳转)、Brtrue
(如果评估堆栈上的值为 true
跳转)、Blt
(如果第一个值小于第二个值跳转)。
要设置分支目标,首先调用 DefineLabel
(这会返回一个 Label
对象),然后在希望锚定标签的位置调用 MarkLabel
。例如,请考虑以下 C# 代码:
int x = 5;
while (x <= 10) Console.WriteLine (x++);
我们可以这样发出它:
ILGenerator gen = *...*
Label startLoop = gen.DefineLabel(); // Declare labels
Label endLoop = gen.DefineLabel();
LocalBuilder x = gen.DeclareLocal (typeof (int)); // int x
gen.Emit (OpCodes.Ldc_I4, 5); //
gen.Emit (OpCodes.Stloc, x); // x = 5
gen.MarkLabel (startLoop);
gen.Emit (OpCodes.Ldc_I4, 10); // Load 10 onto eval stack
gen.Emit (OpCodes.Ldloc, x); // Load x onto eval stack
gen.Emit (OpCodes.Blt, endLoop); // if (x > 10) goto endLoop
gen.EmitWriteLine (x); // Console.WriteLine (x)
gen.Emit (OpCodes.Ldloc, x); // Load x onto eval stack
gen.Emit (OpCodes.Ldc_I4, 1); // Load 1 onto the stack
gen.Emit (OpCodes.Add); // Add them together
gen.Emit (OpCodes.Stloc, x); // Save result back to x
gen.Emit (OpCodes.Br, startLoop); // return to start of loop
gen.MarkLabel (endLoop);
gen.Emit (OpCodes.Ret);
实例化对象并调用实例方法
new
的 IL 等效指令是 Newobj
操作码。这需要一个构造函数,并将构造的对象加载到评估堆栈上。例如,以下代码构造了一个 StringBuilder
:
var dynMeth = new DynamicMethod ("Test", null, null, typeof (void));
ILGenerator gen = dynMeth.GetILGenerator();
ConstructorInfo ci = typeof (StringBuilder).GetConstructor (new Type[0]);
gen.Emit (OpCodes.Newobj, ci);
在将对象加载到评估堆栈后,可以使用 Call
或 Callvirt
操作码调用对象的实例方法。延续这个例子,我们将通过调用属性的获取器查询 StringBuilder
的 MaxCapacity
属性,然后输出结果:
gen.Emit (OpCodes.Callvirt, typeof (StringBuilder)
.GetProperty ("MaxCapacity").GetGetMethod());
gen.Emit (OpCodes.Call, typeof (Console).GetMethod ("WriteLine",
new[] { typeof (int) } ));
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // 2147483647
模仿 C# 调用语义:
-
使用
Call
调用静态方法和值类型实例方法。 -
使用
Callvirt
调用引用类型实例方法(无论它们是否声明为虚拟的)。
在我们的例子中,我们在 StringBuilder
实例上使用了 Callvirt
,即使 MaxProperty
不是虚拟的。这不会引发错误:它只是执行一个非虚拟调用。始终使用 Callvirt
调用引用类型实例方法可以避免出现相反的情况:使用 Call
调用虚拟方法。(风险是真实存在的。目标方法的作者可能稍后 更改 其声明。)Callvirt
还有一个好处,即检查接收者是否非空。
警告
使用 Call
调用虚方法会绕过虚拟调用语义并直接调用该方法。这很少是理想的,并且实际上违反了类型安全性。
在下面的示例中,我们构造了一个 StringBuilder
,传入两个参数,向 StringBuilder
添加 ", world!"
,然后调用它的 ToString
:
// We will call: new StringBuilder ("Hello", 1000)
ConstructorInfo ci = typeof (StringBuilder).GetConstructor (
new[] { typeof (string), typeof (int) } );
gen.Emit (OpCodes.Ldstr, "Hello"); // Load a string onto the eval stack
gen.Emit (OpCodes.Ldc_I4, 1000); // Load an int onto the eval stack
gen.Emit (OpCodes.Newobj, ci); // Construct the StringBuilder
Type[] strT = { typeof (string) };
gen.Emit (OpCodes.Ldstr, ", world!");
gen.Emit (OpCodes.Call, typeof (StringBuilder).GetMethod ("Append", strT));
gen.Emit (OpCodes.Callvirt, typeof (object).GetMethod ("ToString"));
gen.Emit (OpCodes.Call, typeof (Console).GetMethod ("WriteLine", strT));
gen.Emit (OpCodes.Ret);
dynMeth.Invoke (null, null); // Hello, world!
为了好玩,我们在 typeof(object)
上调用了 GetMethod
,然后使用 Callvirt
执行了对 ToString
的虚方法调用。我们也可以直接在 StringBuilder
类型上调用 ToString
来获得相同的结果:
gen.Emit (OpCodes.Callvirt, typeof (StringBuilder).GetMethod ("ToString",
new Type[0] ));
(在调用GetMethod
时需要空的类型数组,因为StringBuilder
重载了另一个签名的ToString
。)
注意
如果我们非虚拟地调用object
的ToString
方法:
gen.Emit (OpCodes.Call,
typeof (object).GetMethod ("ToString"));
结果将会是System.Text.StringBuilder
。换句话说,我们将会绕过StringBuilder
的ToString
重写,并直接调用object
的版本。
异常处理
ILGenerator
为异常处理提供了专门的方法。因此,这段 C#代码的翻译:
try { throw new NotSupportedException(); }
catch (NotSupportedException ex) { Console.WriteLine (ex.Message); }
finally { Console.WriteLine ("Finally"); }
是这样的:
MethodInfo getMessageProp = typeof (NotSupportedException)
.GetProperty ("Message").GetGetMethod();
MethodInfo writeLineString = typeof (Console).GetMethod ("WriteLine",
new[] { typeof (object) } );
gen.BeginExceptionBlock();
ConstructorInfo ci = typeof (NotSupportedException).GetConstructor (
new Type[0] );
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Throw);
gen.BeginCatchBlock (typeof (NotSupportedException));
gen.Emit (OpCodes.Callvirt, getMessageProp);
gen.Emit (OpCodes.Call, writeLineString);
gen.BeginFinallyBlock();
gen.EmitWriteLine ("Finally");
gen.EndExceptionBlock();
就像在 C#中一样,你可以包含多个catch
块。要重新抛出相同的异常,请发出Rethrow
操作码。
警告
ILGenerator
提供了一个名为ThrowException
的辅助方法。然而,这个方法有一个 bug,阻止它与DynamicMethod
一起使用。它仅适用于MethodBuilder
(见下一节)。
发射程序集和类型
虽然DynamicMethod
很方便,但它只能生成方法。如果你需要发射任何其他结构或者完整的类型,你需要使用完整的“重量级”API。这意味着动态构建一个程序集和模块。程序集不需要在磁盘上存在(实际上不能,因为.NET 5+和.NET Core 不允许将生成的程序集保存到磁盘)。
假设我们想要动态构建一个类型。因为类型必须存在于程序集的模块中,所以我们必须在创建类型之前先创建程序集和模块。这是AssemblyBuilder
和ModuleBuilder
类型的任务:
AssemblyName aname = new AssemblyName ("MyDynamicAssembly");
AssemblyBuilder assemBuilder =
AssemblyBuilder.DefineDynamicAssembly (aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("DynModule");
注意
你不能向现有程序集中添加类型,因为一旦创建程序集,它就是不可变的。
动态程序集不会被垃圾回收,并且会一直留在内存中,直到进程结束,除非在定义程序集时指定了AssemblyBuilderAccess
.RunAndCollect
。对可收集程序集有各种限制(参见http://albahari.com/dynamiccollect)。
在我们有类型所在的模块之后,我们可以使用TypeBuilder
来创建类型。以下定义了一个名为Widget
的类:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
TypeAttributes
标志枚举支持 CLR 类型修饰符,你可以在使用ildasm反汇编类型时看到这些修饰符。除了成员可见性标志外,还包括类型修饰符如Abstract
和Sealed
,以及定义.NET 接口的Interface
。它还包括Serializable
,相当于在 C#中应用[Serializable]
属性,以及Explicit
,相当于应用[StructLayout(LayoutKind.Explicit)]
。我们将在本章后面的“附加属性”中描述如何应用其他类型的属性,位于“Attaching Attributes”。
注意
DefineType
方法还接受一个可选的基类型:
-
要定义一个结构体,请指定基类型为
System.ValueType
。 -
要定义委托,请指定基类型为
System.MulticastDelegate
。 -
要实现一个接口,使用接受接口类型数组的构造函数。
-
要定义一个接口,请指定
TypeAttributes.Interface | TypeAttributes.Abstract
。
定义委托类型需要进行多个额外的步骤。在他的博客中,Joel Pobar 在标题为“通过 Reflection.Emit 创建委托类型”的文章中展示了如何实现。
我们现在可以在类型中创建成员:
MethodBuilder methBuilder = tb.DefineMethod ("SayHello",
MethodAttributes.Public,
null, null);
ILGenerator gen = methBuilder.GetILGenerator();
gen.EmitWriteLine ("Hello world");
gen.Emit (OpCodes.Ret);
我们现在准备创建该类型,从而最终定义它:
Type t = tb.CreateType();
创建类型后,我们可以使用普通的反射来检查和执行后期绑定:
object o = Activator.CreateInstance (t);
t.GetMethod ("SayHello").Invoke (o, null); // Hello world
Reflection.Emit 对象模型
图 18-2 展示了System.Reflection.Emit
中的关键类型。每种类型描述了一个 CLR 构造,并基于System.Reflection
命名空间中的对应项。这使得你可以在构建类型时使用发射的构造替代常规构造。例如,我们之前调用了Console.WriteLine
,如下所示:
MethodInfo writeLine = typeof(Console).GetMethod ("WriteLine",
new Type[] { typeof (string) });
gen.Emit (OpCodes.Call, writeLine);
我们可以通过使用MethodBuilder
而不是MethodInfo
,通过调用gen.Emit
来调用动态生成的方法,这是至关重要的——否则,你无法编写一个调用同一类型中另一个动态方法的动态方法。
https://github.com/OpenDocCN/ibooker-csharp-zh/raw/master/docs/cs12-ntsh/img/cn10_1802.png
图 18-2. System.Reflection.Emit
请记住,在完成填充TypeBuilder
后,必须调用TypeBuilder
上的CreateType
。调用CreateType
封闭了TypeBuilder
及其所有成员——因此,不能再添加或更改任何内容——并返回一个真实的Type
,你可以实例化它。
在调用CreateType
之前,TypeBuilder
及其成员处于“未创建”状态。对未创建的构造有显著的限制。特别地,你不能调用返回MemberInfo
对象的任何成员,例如GetMembers
、GetMethod
或GetProperty
——这些都会抛出异常。如果要引用未创建类型的成员,必须使用原始的发射:
TypeBuilder tb = ...
MethodBuilder method1 = tb.DefineMethod ("Method1", ...);
MethodBuilder method2 = tb.DefineMethod ("Method2", ...);
ILGenerator gen1 = method1.GetILGenerator();
// Suppose we want method1 to call method2:
gen1.Emit (OpCodes.Call, method2); // Right
gen1.Emit (OpCodes.Call, tb.GetMethod ("Method2")); // Wrong
在调用CreateType
后,你不仅可以反射并激活返回的Type
,还可以反射原始的TypeBuilder
对象。事实上,TypeBuilder
会变形为真实Type
的代理。你将看到为什么此功能在“尴尬的发射目标”中很重要。
发射类型成员
本节中的所有示例假设已经实例化了TypeBuilder
,如下所示:
AssemblyName aname = new AssemblyName ("MyEmissions");
AssemblyBuilder assemBuilder = AssemblyBuilder.DefineDynamicAssembly (
aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("MainModule");
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
发射方法
调用DefineMethod
时,可以像实例化DynamicMethod
时一样指定返回类型和参数类型。例如,以下方法:
public static double SquareRoot (double value) => Math.Sqrt (value);
可以这样生成:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Static | MethodAttributes.Public,
CallingConventions.Standard,
typeof (double), // Return type
new[] { typeof (double) } ); // Parameter types
mb.DefineParameter (1, ParameterAttributes.None, "value"); // Assign name
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Load 1st arg
gen.Emit (OpCodes.Call, typeof(Math).GetMethod ("Sqrt"));
gen.Emit (OpCodes.Ret);
Type realType = tb.CreateType();
double x = (double) tb.GetMethod ("SquareRoot").Invoke (null,
new object[] { 10.0 });
Console.WriteLine (x); // 3.16227766016838
调用DefineParameter
是可选的,通常用于为参数分配名称。数字 1 表示第一个参数(0 表示返回值)。如果调用DefineParameter
,则参数将隐式命名为__p1
、__p2
等。分配名称是有意义的,如果你将程序集写入磁盘,则使你的方法对消费者更友好。
注意
DefineParameter
返回一个ParameterBuilder
对象,你可以在其上调用SetCustomAttribute
来附加属性(参见“附加属性”)。
要发出引用传递的参数,例如以下的 C#方法:
public static void SquareRoot (ref double value)
=> value = Math.Sqrt (value);
在参数类型上调用MakeByRefType
:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Static | MethodAttributes.Public,
CallingConventions.Standard,
null,
new Type[] { typeof (double).MakeByRefType() } );
mb.DefineParameter (1, ParameterAttributes.None, "value");
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ldind_R8);
gen.Emit (OpCodes.Call, typeof (Math).GetMethod ("Sqrt"));
gen.Emit (OpCodes.Stind_R8);
gen.Emit (OpCodes.Ret);
Type realType = tb.CreateType();
object[] args = { 10.0 };
tb.GetMethod ("SquareRoot").Invoke (null, args);
Console.WriteLine (args[0]); // 3.16227766016838
这里的操作码是从反汇编的 C#方法中复制的。注意访问通过引用传递的参数的语义差异:Ldind
和Stind
分别表示“间接加载”和“间接存储”。R8 后缀表示一个八字节的浮点数。
发出out
参数的过程相同,只是你调用DefineParameter
,如下所示:
mb.DefineParameter (1, ParameterAttributes.Out, "value");
生成实例方法
要生成一个实例方法,请在调用DefineMethod
时指定MethodAttributes.Instance
:
MethodBuilder mb = tb.DefineMethod ("SquareRoot",
MethodAttributes.Instance | MethodAttributes.Public
...
对于实例方法,参数零隐含为this
;其余参数从 1 开始。因此,Ldarg_0
将this
加载到评估堆栈上;Ldarg_1
加载第一个真实的方法参数。
覆盖方法
在基类中覆盖虚方法很简单:只需定义一个具有相同名称、签名和返回类型的方法,在调用DefineMethod
时指定MethodAttributes.Virtual
。在实现接口方法时也是如此。
TypeBuilder
还暴露了一个名为DefineMethodOverride
的方法,用于覆盖具有不同名称的方法。这仅在显式接口实现时有意义;在其他情况下,请使用DefineMethod
。
HideBySig
如果你正在子类化另一个类型,在定义方法时几乎总是值得指定MethodAttributes.HideBySig
。HideBySig
确保应用 C#风格的方法隐藏语义,即只有在子类型定义具有相同签名的方法时,基方法才会被隐藏。没有HideBySig
,方法隐藏仅考虑名称,因此子类型中的Foo(string)
将隐藏基类型中的Foo()
,这通常是不希望的。
发出字段和属性
要创建字段,你需要在TypeBuilder
上调用DefineField
,指定所需的字段名称、类型和可见性。以下创建了一个名为“length”的私有整数字段:
FieldBuilder field = tb.DefineField ("length", typeof (int),
FieldAttributes.Private);
创建属性或索引器需要几个步骤。首先,在TypeBuilder
上调用DefineProperty
,提供属性的名称和类型:
PropertyBuilder prop = tb.DefineProperty (
"Text", // Name of property
PropertyAttributes.None,
typeof (string), // Property type
new Type[0] // Indexer types
);
(如果你正在编写索引器,最后一个参数是索引器类型的数组。)请注意,我们尚未指定属性的可见性:这在访问器方法上是单独完成的。
接下来要做的是编写get
和set
方法。按照惯例,它们的名称以“get_”或“set_”为前缀。然后,通过在PropertyBuilder
上调用SetGetMethod
和SetSetMethod
将它们附加到属性。
要给出完整的示例,让我们接受以下字段和属性声明
string _text;
public string Text
{
get => _text;
internal set => _text = value;
}
并动态生成它:
FieldBuilder field = tb.DefineField ("_text", typeof (string),
FieldAttributes.Private);
PropertyBuilder prop = tb.DefineProperty (
"Text", // Name of property
PropertyAttributes.None,
typeof (string), // Property type
new Type[0]); // Indexer types
MethodBuilder getter = tb.DefineMethod (
"get_Text", // Method name
MethodAttributes.Public | MethodAttributes.SpecialName,
typeof (string), // Return type
new Type[0]); // Parameter types
ILGenerator getGen = getter.GetILGenerator();
getGen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
getGen.Emit (OpCodes.Ldfld, field); // Load field value onto eval stack
getGen.Emit (OpCodes.Ret); // Return
MethodBuilder setter = tb.DefineMethod (
"set_Text",
MethodAttributes.Assembly | MethodAttributes.SpecialName,
null, // Return type
new Type[] { typeof (string) } ); // Parameter types
ILGenerator setGen = setter.GetILGenerator();
setGen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
setGen.Emit (OpCodes.Ldarg_1); // Load 2nd arg, i.e., value
setGen.Emit (OpCodes.Stfld, field); // Store value into field
setGen.Emit (OpCodes.Ret); // return
prop.SetGetMethod (getter); // Link the get method and property
prop.SetSetMethod (setter); // Link the set method and property
我们可以按如下方式测试属性:
Type t = tb.CreateType();
object o = Activator.CreateInstance (t);
t.GetProperty ("Text").SetValue (o, "Good emissions!", new object[0]);
string text = (string) t.GetProperty ("Text").GetValue (o, null);
Console.WriteLine (text); // Good emissions!
注意,在定义访问器MethodAttributes
时,我们包括了SpecialName
。这指示编译器在静态引用程序集时禁止直接绑定到这些方法。它还确保反射工具和 Visual Studio 的 IntelliSense 适当处理访问器。
注意
您可以通过在TypeBuilder
上调用DefineEvent
类似的方式发出事件。然后,通过调用SetAddOnMethod
和SetRemoveOnMethod
将显式事件访问器方法附加到EventBuilder
。
发射构造函数
您可以通过在类型生成器上调用DefineConstructor
定义自己的构造函数。如果不这样做,将自动提供默认的无参数构造函数。默认构造函数在子类型化时调用基类构造函数,就像在 C#中一样。定义一个或多个构造函数会替代此默认构造函数。
如果需要初始化字段,则构造函数是一个好的选择。实际上,这是唯一的选择:C#的字段初始化器没有特殊的 CLR 支持——它们只是在构造函数中为字段赋值的一种语法快捷方式。
因此,要复制这一点:
class Widget
{
int _capacity = 4000;
}
您将会定义一个构造函数,如下所示:
FieldBuilder field = tb.DefineField ("_capacity", typeof (int),
FieldAttributes.Private);
ConstructorBuilder c = tb.DefineConstructor (
MethodAttributes.Public,
CallingConventions.Standard,
new Type[0]); // Constructor parameters
ILGenerator gen = c.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0); // Load "this" onto eval stack
gen.Emit (OpCodes.Ldc_I4, 4000); // Load 4000 onto eval stack
gen.Emit (OpCodes.Stfld, field); // Store it to our field
gen.Emit (OpCodes.Ret);
调用基础构造函数
如果子类化另一个类型,则刚刚编写的构造函数将规避基类构造函数。这与 C#不同,后者始终调用基类构造函数,无论是直接还是间接调用。例如,给定以下代码:
class A { public A() { Console.Write ("A"); } }
class B : A { public B() {} }
编译器实际上将第二行转换为以下内容:
class B : A { public B() : base() {} }
这在生成 IL 时并非如此:如果希望其执行(几乎总是如此),必须显式调用基础构造函数。假设基类称为A
,以下是如何做到这一点的示例:
gen.Emit (OpCodes.Ldarg_0);
ConstructorInfo baseConstr = typeof (A).GetConstructor (new Type[0]);
gen.Emit (OpCodes.Call, baseConstr);
使用参数调用构造函数与调用方法完全相同。
附加属性
您可以通过使用CustomAttributeBuilder
调用SetCustomAttribute
为动态构造附加自定义属性。例如,假设我们想将以下属性声明附加到字段或属性:
[XmlElement ("FirstName", Namespace="http://test/", Order=3)]
这依赖于接受单个字符串的XmlElementAttribute
构造函数。要使用CustomAttributeBuilder
,我们必须检索此构造函数以及我们要设置的另外两个属性(Namespace
和Order
):
Type attType = typeof (XmlElementAttribute);
ConstructorInfo attConstructor = attType.GetConstructor (
new Type[] { typeof (string) } );
var att = new CustomAttributeBuilder (
attConstructor, // Constructor
new object[] { "FirstName" }, // Constructor arguments
new PropertyInfo[]
{
attType.GetProperty ("Namespace"), // Properties
attType.GetProperty ("Order")
},
new object[] { "http://test/", 3 } // Property values
);
myFieldBuilder.SetCustomAttribute (att);
// or propBuilder.SetCustomAttribute (att);
// or typeBuilder.SetCustomAttribute (att); etc
发射泛型方法和类型
本节中的所有示例都假定modBuilder
已经实例化如下:
AssemblyName aname = new AssemblyName ("MyEmissions");
AssemblyBuilder assemBuilder = AssemblyBuilder.DefineDynamicAssembly (
aname, AssemblyBuilderAccess.Run);
ModuleBuilder modBuilder = assemBuilder.DefineDynamicModule ("MainModule");
定义泛型方法
要发射泛型方法:
-
在
MethodBuilder
上调用DefineGenericParameters
以获取GenericTypeParameterBuilder
对象数组。 -
在
MethodBuilder
上使用这些泛型类型参数调用SetSignature
。 -
可选择地,可以按您通常的方式命名参数。
例如,以下泛型方法:
public static T Echo<T> (T value)
{
return value;
}
可以这样发射:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
MethodBuilder mb = tb.DefineMethod ("Echo", MethodAttributes.Public |
MethodAttributes.Static);
GenericTypeParameterBuilder[] genericParams
= mb.DefineGenericParameters ("T");
mb.SetSignature (genericParams[0], // Return type
null, null,
genericParams, // Parameter types
null, null);
mb.DefineParameter (1, ParameterAttributes.None, "value"); // Optional
ILGenerator gen = mb.GetILGenerator();
gen.Emit (OpCodes.Ldarg_0);
gen.Emit (OpCodes.Ret);
DefineGenericParameters
方法接受任意数量的字符串参数 —— 这些对应于所需的通用类型名称。在本例中,我们只需要一个名为 T
的通用类型。GenericTypeParameterBuilder
基于 System.Type
,因此在发出操作码时,可以将其用于 TypeBuilder
的位置。
GenericTypeParameterBuilder
还允许您指定基类型约束:
genericParams[0].SetBaseTypeConstraint (typeof (Foo));
和接口约束:
genericParams[0].SetInterfaceConstraints (typeof (IComparable));
要复制这个:
public static T Echo<T> (T value) where T : IComparable<T>
你会这样写:
genericParams[0].SetInterfaceConstraints (
typeof (IComparable<>).MakeGenericType (genericParams[0]) );
对于其他类型的约束,请调用 SetGenericParameterAttributes
。它接受 GenericParameterAttributes
枚举的一个成员,其中包括以下值:
DefaultConstructorConstraint
NotNullableValueTypeConstraint
ReferenceTypeConstraint
Covariant
Contravariant
最后两个等同于将 out
和 in
修饰符应用于类型参数。
定义泛型类型
您可以以类似的方式定义通用类型。不同之处在于,您在 TypeBuilder
上调用 DefineGenericParameters
而不是 MethodBuilder
。因此,要复制这个:
public class Widget<T>
{
public T Value;
}
您将执行以下操作:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
GenericTypeParameterBuilder[] genericParams
= tb.DefineGenericParameters ("T");
tb.DefineField ("Value", genericParams[0], FieldAttributes.Public);
可以像处理方法一样添加泛型约束。
尴尬的发射目标
本节中的所有示例都假设 modBuilder
已像前几节中那样被实例化。
未创建的关闭泛型
假设您希望发出使用关闭的泛型类型的方法:
public class Widget
{
public static void Test() { var list = new List<int>(); }
}
这个过程非常直接:
TypeBuilder tb = modBuilder.DefineType ("Widget", TypeAttributes.Public);
MethodBuilder mb = tb.DefineMethod ("Test", MethodAttributes.Public |
MethodAttributes.Static);
ILGenerator gen = mb.GetILGenerator();
Type variableType = typeof (List<int>);
ConstructorInfo ci = variableType.GetConstructor (new Type[0]);
LocalBuilder listVar = gen.DeclareLocal (variableType);
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Stloc, listVar);
gen.Emit (OpCodes.Ret);
现在假设我们不是要一个整数列表,而是要一个小部件列表:
public class Widget
{
public static void Test() { var list = new List<Widget>(); }
}
理论上,这是一个简单的修改;我们所做的只是替换这一行:
Type variableType = typeof (List<int>);
使用这个:
Type variableType = typeof (List<>).MakeGenericType (tb);
不幸的是,这会导致在我们调用 GetConstructor
时抛出 NotSupportedException
。问题在于,您无法在使用未创建类型生成器关闭的通用类型上调用 GetConstructor
。对于 GetField
和 GetMethod
也是如此。
解决方案不直观。TypeBuilder
提供了三个静态方法:
public static ConstructorInfo GetConstructor (Type, ConstructorInfo);
public static FieldInfo GetField (Type, FieldInfo);
public static MethodInfo GetMethod (Type, MethodInfo);
尽管看起来并非如此,但这些方法专门用于获取使用未创建类型生成器关闭的通用类型的成员!第一个参数是关闭的通用类型;第二个参数是您希望在 未绑定 通用类型上获取的成员。以下是我们示例的修正版本:
MethodBuilder mb = tb.DefineMethod ("Test", MethodAttributes.Public |
MethodAttributes.Static);
ILGenerator gen = mb.GetILGenerator();
Type variableType = typeof (List<>).MakeGenericType (tb);
ConstructorInfo unbound = typeof (List<>).GetConstructor (new Type[0]);
ConstructorInfo ci = TypeBuilder.GetConstructor (variableType, unbound);
LocalBuilder listVar = gen.DeclareLocal (variableType);
gen.Emit (OpCodes.Newobj, ci);
gen.Emit (OpCodes.Stloc, listVar);
gen.Emit (OpCodes.Ret);
循环依赖
假设您希望构建彼此引用的两种类型,例如以下这些:
class A { public B Bee; }
class B { public A Aye; }
您可以按以下方式动态生成它:
var publicAtt = FieldAttributes.Public;
TypeBuilder aBuilder = modBuilder.DefineType ("A");
TypeBuilder bBuilder = modBuilder.DefineType ("B");
FieldBuilder bee = aBuilder.DefineField ("Bee", bBuilder, publicAtt);
FieldBuilder aye = bBuilder.DefineField ("Aye", aBuilder, publicAtt);
Type realA = aBuilder.CreateType();
Type realB = bBuilder.CreateType();
注意我们在填充两个对象之前没有在 aBuilder
或 bBuilder
上调用 CreateType
。原则是:首先连接所有内容,然后在每个类型生成器上调用 CreateType
。
有趣的是,realA
类型在在 bBuilder
上调用 CreateType
之前是有效但 不起作用 的。(如果您在此之前开始使用 aBuilder
,则在尝试访问 Bee
字段时会抛出异常。)
你可能会想知道 bBuilder
如何知道在创建 realB
后修复 realA
。答案是它不知道:realA
可以在下次使用时自行修复。这是可能的,因为在调用 CreateType
后,TypeBuilder
会变成真实运行时类型的代理。因此,realA
可以轻松获取它需要的用于升级的元数据,包括对 bBuilder
的引用。
当类型生成器要求未构造类型的简单信息时,比如类型、成员和对象引用等信息,此系统可行。在创建 realA
时,类型生成器不需要知道 realB
最终在内存中将占用多少字节,例如。这正如事实一样,因为 realB
尚未被创建!但现在想象一下,realB
是一个结构体。在创建 realA
时,realB
的最终大小现在是关键信息。
如果关系是非循环的;例如:
struct A { public B Bee; }
struct B { }
你可以通过首先创建结构体 B
,然后再创建结构体 A
来解决这个问题。但请考虑这一点:
struct A { public B Bee; }
struct B { public A Aye; }
我们不会尝试生成这个,因为两个结构体互相包含是毫无意义的(如果你尝试的话,C#会生成编译时错误)。但以下变体既合法又有用:
public struct S<T> { ... } // S can be empty and this demo will work.
class A { S<B> Bee; }
class B { S<A> Aye; }
在创建 A
时,现在 TypeBuilder
需要知道 B
的内存占用,反之亦然。为了说明问题,让我们假设结构体 S
是静态定义的。以下是生成类 A
和 B
的代码:
var pub = FieldAttributes.Public;
TypeBuilder aBuilder = modBuilder.DefineType ("A");
TypeBuilder bBuilder = modBuilder.DefineType ("B");
aBuilder.DefineField ("Bee", typeof(S<>).MakeGenericType (bBuilder), pub);
bBuilder.DefineField ("Aye", typeof(S<>).MakeGenericType (aBuilder), pub);
Type realA = aBuilder.CreateType(); // Error: cannot load type B
Type realB = bBuilder.CreateType();
现在,无论你如何操作,CreateType
都会抛出 TypeLoadException
:
-
首先调用
aBuilder.CreateType
,然后它会报“无法加载类型B
”。 -
首先调用
bBuilder.CreateType
,然后它会报“无法加载类型A
”!
要解决这个问题,你必须允许类型生成器在创建 realA
过程中部分创建 realB
。你可以通过在调用 CreateType
前处理 AppDomain
类上的 TypeResolve
事件来实现这一点。所以,在我们的例子中,我们用以下内容替换最后两行:
TypeBuilder[] uncreatedTypes = { aBuilder, bBuilder };
ResolveEventHandler handler = delegate (object o, ResolveEventArgs args)
{
var type = uncreatedTypes.FirstOrDefault (t => t.FullName == args.Name);
return type == null ? null : type.CreateType().Assembly;
};
AppDomain.CurrentDomain.TypeResolve += handler;
Type realA = aBuilder.CreateType();
Type realB = bBuilder.CreateType();
AppDomain.CurrentDomain.TypeResolve -= handler;
TypeResolve
事件在调用 aBuilder.CreateType
时触发,在这一点上,它需要你调用 bBuilder
上的 CreateType
。
注意
在定义嵌套类型时,像这个例子中处理 TypeResolve
事件同样是必要的,当嵌套和父类型相互引用时。
解析 IL
你可以通过在 MethodBase
对象上调用 GetMethodBody
来获取关于现有方法内容的信息。这将返回一个 MethodBody
对象,该对象具有用于检查方法的局部变量、异常处理子句和堆栈大小的属性,以及原始 IL。有点类似于 Reflection.Emit
的反向过程!
检查方法的原始 IL 可以帮助优化代码性能。一个简单的用法是确定当一个程序集更新时哪些方法发生了变化。
为了演示 IL 解析,我们将编写一个应用程序,以 ildasm 风格反汇编 IL。这可以作为代码分析工具或更高级别语言反汇编器的起点。
注意
记住,在反射 API 中,所有 C# 的功能结构都由 MethodBase
的子类型表示,或者(在属性、事件和索引器的情况下)附有 MethodBase
对象。
编写反汇编器
这是我们的反汇编器将产生的输出示例:
IL_00EB: ldfld Disassembler._pos
IL_00F0: ldloc.2
IL_00F1: add
IL_00F2: ldelema System.Byte
IL_00F7: ldstr "Hello world"
IL_00FC: call System.Byte.ToString
IL_0101: ldstr " "
IL_0106: call System.String.Concat
要获取此输出,我们必须解析组成 IL 的二进制标记。第一步是在 MethodBody
上调用 GetILAsByteArray
方法,以获取 IL 作为字节数组。为了使其余的工作更容易,我们将其写入一个类中,如下所示:
public class Disassembler
{
public static string Disassemble (MethodBase method)
=> new Disassembler (method).Dis();
StringBuilder _output; // The result to which we'll keep appending
Module _module; // This will come in handy later
byte[] _il; // The raw byte code
int _pos; // The position we're up to in the byte code
Disassembler (MethodBase method)
{
_module = method.DeclaringType.Module;
_il = method.GetMethodBody().GetILAsByteArray();
}
string Dis()
{
_output = new StringBuilder();
while (_pos < _il.Length) DisassembleNextInstruction();
return _output.ToString();
}
}
静态 Disassemble
方法将是此类的唯一公共成员。所有其他成员将对反汇编过程保持私有。Dis
方法包含我们处理每个指令的“主”循环。
有了这个框架,剩下的就是编写 DisassembleNextInstruction
。但在这样做之前,将所有操作码加载到静态字典中将有所帮助,这样我们可以通过它们的 8 位或 16 位值访问它们。实现这一点的最简单方法是使用反射来检索 OpCodes
类中类型为 OpCode
的所有静态字段:
static Dictionary<short,OpCode> _opcodes = new Dictionary<short,OpCode>();
static Disassembler()
{
Dictionary<short, OpCode> opcodes = new Dictionary<short, OpCode>();
foreach (FieldInfo fi in typeof (OpCodes).GetFields
(BindingFlags.Public | BindingFlags.Static))
if (typeof (OpCode).IsAssignableFrom (fi.FieldType))
{
OpCode code = (OpCode) fi.GetValue (null); // Get field's value
if (code.OpCodeType != OpCodeType.Nternal)
_opcodes.Add (code.Value, code);
}
}
我们将其写在静态构造函数中,以便它只执行一次。
现在我们可以编写 DisassembleNextInstruction
。每个 IL 指令由一个或两个字节的操作码组成,后跟零、一个、两个、四个或八个字节的操作数。(一个例外是内联开关操作码,其后跟有可变数量的操作数。)因此,我们读取操作码,然后读取操作数,并输出结果:
void DisassembleNextInstruction()
{
int opStart = _pos;
OpCode code = ReadOpCode();
string operand = ReadOperand (code);
_output.AppendFormat ("IL_{0:X4}: {1,-12} {2}",
opStart, code.Name, operand);
_output.AppendLine();
}
要读取操作码,我们前进一个字节,然后查看是否有有效的指令。如果没有,我们前进另一个字节,然后寻找一个两字节的指令:
OpCode ReadOpCode()
{
byte byteCode = _il [_pos++];
if (_opcodes.ContainsKey (byteCode)) return _opcodes [byteCode];
if (_pos == _il.Length) throw new Exception ("Unexpected end of IL");
short shortCode = (short) (byteCode * 256 + _il [_pos++]);
if (!_opcodes.ContainsKey (shortCode))
throw new Exception ("Cannot find opcode " + shortCode);
return _opcodes [shortCode];
}
要读取操作数,我们首先必须确定其长度。我们可以根据操作数类型来做这个操作。因为大多数操作数长度为四个字节,所以我们可以在条件子句中相对容易地过滤出例外情况。
下一步是调用 FormatOperand
,它尝试格式化操作数:
string ReadOperand (OpCode c)
{
int operandLength =
c.OperandType == OperandType.InlineNone
? 0 :
c.OperandType == OperandType.ShortInlineBrTarget ||
c.OperandType == OperandType.ShortInlineI ||
c.OperandType == OperandType.ShortInlineVar
? 1 :
c.OperandType == OperandType.InlineVar
? 2 :
c.OperandType == OperandType.InlineI8 ||
c.OperandType == OperandType.InlineR
? 8 :
c.OperandType == OperandType.InlineSwitch
? 4 * (BitConverter.ToInt32 (_il, _pos) + 1) :
4; // All others are 4 bytes
if (_pos + operandLength > _il.Length)
throw new Exception ("Unexpected end of IL");
string result = FormatOperand (c, operandLength);
if (result == null)
{ // Write out operand bytes in hex
result = "";
for (int i = 0; i < operandLength; i++)
result += _il [_pos + i].ToString ("X2") + " ";
}
_pos += operandLength;
return result;
}
如果调用 FormatOperand
的结果为 null
,这意味着操作数不需要特殊格式化,因此我们简单地以十六进制写出它。我们可以通过编写一个总是返回 null
的 FormatOperand
方法来测试反汇编器在此点的工作情况。输出将如下所示:
IL_00A8: ldfld 98 00 00 04
IL_00AD: ldloc.2
IL_00AE: add
IL_00AF: ldelema 64 00 00 01
IL_00B4: ldstr 26 04 00 70
IL_00B9: call B6 00 00 0A
IL_00BE: ldstr 11 01 00 70
IL_00C3: call 91 00 00 0A
...
虽然操作码是正确的,但操作数没有多大用处。我们希望使用成员名称和字符串,而不是十六进制数。当编写时,FormatOperand
方法将解决此问题,识别从这种格式化中受益的特殊情况。这包括大多数四字节操作数和短分支指令:
string FormatOperand (OpCode c, int operandLength)
{
if (operandLength == 0) return "";
if (operandLength == 4)
return Get4ByteOperand (c);
else if (c.OperandType == OperandType.ShortInlineBrTarget)
return GetShortRelativeTarget();
else if (c.OperandType == OperandType.InlineSwitch)
return GetSwitchTarget (operandLength);
else
return null;
}
我们处理的三种四字节操作数类型是特殊的。第一种是成员或类型的引用——我们通过调用定义模块的ResolveMember
方法来提取成员或类型名称。第二种情况是字符串——这些存储在程序集模块的元数据中,并且可以通过调用ResolveString
来检索。最后一种情况是分支目标,其中操作数引用 IL 中的字节偏移量。我们通过计算当前指令后的绝对地址(+四字节)来格式化这些:
string Get4ByteOperand (OpCode c)
{
int intOp = BitConverter.ToInt32 (_il, _pos);
switch (c.OperandType)
{
case OperandType.InlineTok:
case OperandType.InlineMethod:
case OperandType.InlineField:
case OperandType.InlineType:
MemberInfo mi;
try { mi = _module.ResolveMember (intOp); }
catch { return null; }
if (mi == null) return null;
if (mi.ReflectedType != null)
return mi.ReflectedType.FullName + "." + mi.Name;
else if (mi is Type)
return ((Type)mi).FullName;
else
return mi.Name;
case OperandType.InlineString:
string s = _module.ResolveString (intOp);
if (s != null) s = "'" + s + "'";
return s;
case OperandType.InlineBrTarget:
return "IL_" + (_pos + intOp + 4).ToString ("X4");
default:
return null;
}
}
注意
我们调用ResolveMember
的点是一个很好的窗口,用于报告方法依赖关系的代码分析工具。
对于任何其他四字节操作码,我们返回null
(这会导致ReadOperand
将操作数格式化为十六进制数字)。
需要特别关注的最终操作数类型包括短分支目标和内联开关。短分支目标描述目标偏移量为单个有符号字节,就像在当前指令的末尾(即,+一个字节)。开关目标后跟随变量数量的四字节分支目标:
string GetShortRelativeTarget()
{
int absoluteTarget = _pos + (sbyte) _il [_pos] + 1;
return "IL_" + absoluteTarget.ToString ("X4");
}
string GetSwitchTarget (int operandLength)
{
int targetCount = BitConverter.ToInt32 (_il, _pos);
string [] targets = new string [targetCount];
for (int i = 0; i < targetCount; i++)
{
int ilTarget = BitConverter.ToInt32 (_il, _pos + (i + 1) * 4);
targets [i] = "IL_" + (_pos + ilTarget + operandLength).ToString ("X4");
}
return "(" + string.Join (", ", targets) + ")";
}
这样完成了反汇编器。我们可以通过反汇编其自身的方法来测试它:
MethodInfo mi = typeof (Disassembler).GetMethod (
"ReadOperand", BindingFlags.Instance | BindingFlags.NonPublic);
Console.WriteLine (Disassembler.Disassemble (mi));