通过反射或Mono Cecil访问程序集元数据(第六部分)





5.00/5 (7投票s)
使用反射或 Mono Cecil 从程序集中加载类型元数据。
介绍
本文介绍如何使用反射或 Mono Cecil 从 .NET 程序集中加载类型元数据,具体目标是支持 codeDOM 中符号引用的解析。源代码包含在内。这是关于 codeDOM 系列的第 6 部分,但对于任何希望出于任何原因从 .NET 程序集中检索类型元数据的人来说,都可能很有用。在前面的部分中,我已 讨论了“CodeDOMs”,提供了一个 C# codeDOM,一个 WPF UI 和 IDE,一个 C# 解析器,以及 解决方案/项目 codeDOM 类。
背景
本系列讨论的 codeDOM 处理指向派生自 SymbolicRef
基类的各种类的命名对象的符号引用。这些引用可能在手动生成的对象树中解析,但在从 C# 源文件解析时,大多数引用将是未解析的(由 UnresolvedRef
对象表示)。只有指向内置类型的引用才会被解析,因为它们是从关键字解析的。codeDOM 中指向 codeDOM 内声明的符号引用(局部变量、同一类型的成员、在 codeDOM 中声明的类型等)可以通过在 codeDOM 中搜索声明来解析。但是,指向其他项目或程序集中文档的符号引用需要一个 Project
对象来解析,因为它包含指向此类外部类型源的引用的集合(这些引用派生自 Reference
类,而不是 SymbolicRef
,并且与符号引用无关)。本文仅涵盖程序集及其类型的加载——使用此元数据实际解析符号引用将在下一篇文章中介绍。
从引用加载类型
加载 Project
引用的类型数据的第一个步骤是验证和定位(或“解析”)项目和程序集引用。对于项目引用,公共类型仅从引用的项目导入到本地项目的命名空间中(如果“InternalsVisibleTo
”特性指定了本地项目,则非公共类型也必须导入)。对于程序集引用,必须先定位并加载引用的程序集,然后才能检索类型元数据,这可能是一个相当复杂的过程。引用的程序集可能有一个“提示路径”,但这并非保证的位置——必须进行搜索才能找到程序集。有些程序集可能位于 GAC 中,.NET Framework(BCL)程序集有自己的位置,必须借助为项目指定的、目标框架版本来确定。
在定位和加载程序集时,必须跟踪它们以防止重复加载,并且(如果可能)在某个时间点卸载它们。此功能已放置在一个名为 ApplicationContext
的类中,该类用于加载和跟踪当前应用程序(加载到主 AppDomain
中)加载的所有程序集。一个名为 FrameworkContext
的类用于跟踪每个框架版本或配置文件(如 .NET 2.0、Silverlight、Client Profiles 等)的所有程序集。某些框架是部分框架,意味着它们建立在之前的版本之上(如 .NET 3.0 和 3.5),因此它们的 FrameworkContext
实例会链接在一起。每个项目都有一个目标框架版本,程序集使用 Project.LoadAssembly()
加载,该方法调用 FrameworkContext.LoadAssembly()
加载目标框架的程序集,然后从全局 ApplicationContext
实例加载。加载的程序集由 LoadedAssembly
类表示,该类实际上是一个基类,将被子类化以适应不同的加载方法,并且还有一个子类 ErrorLoadedAssembly
来表示由于某种原因加载失败的程序集。当上下文对象在 GAC 中查找程序集时,它们会利用 GACUtil
辅助类,该类还包含一个用于比较程序集版本的方法。
当一个 Project
将要被解析时,首先在其上调用 ResolveReferences()
,它会调用每个 Reference
上的 Resolve()
。对于 AssemblyReference
,它会尝试查找引用的“.dll”并验证它是否适用于目标框架。对于 ProjectReference
,引用的项目在解决方案中按文件名查找。如果项目在 codeDOM 中找不到,或者项目类型不受支持,那么将确定输出文件名,并将项目引用作为程序集引用来处理,以尝试仍然解析类型。一旦引用被解析,将在项目上调用 LoadReferencedAssemblies()
来将所有引用的程序集加载到内存中(通过调用每个 Reference
上的 Load()
),最后从每个程序集中加载公共类型的类型元数据(通过调用每个 LoadedAssembly
上的 LoadTypes()
)。
当整个 Solution
将要被解析时,将调用每个项目上的 ResolveReferences()
,然后调用解决方案上的 UpdateProjectDependencies()
,该方法根据项目之间的相互引用来确定项目的解析顺序。然后,将 *按依赖顺序* 调用每个项目上的 LoadReferencedAssemblies()
来加载引用的程序集,最后从每个程序集中加载类型元数据。
使用反射加载类型元数据
在 .NET 中检索程序集类型元数据的最明显方法是使用反射。这是通过调用 Assembly.Load()
来加载程序集,该方法返回一个 Assembly
实例。LoadedAssembly
类在这种程序集中被子类化为 ReflectionLoadedAssembly
。然后,在得到的程序集对象上调用 LoadTypes()
来获取 Type
对象数组。类型的成员由 MethodInfo
、PropertyInfo
、FieldInfo
等表示。提供一些有用静态方法的辅助类,用于处理这些类,位于 *Utilities/Reflection*。
功能 | 反射类 |
Assemblies |
程序集
|
类型的成员 |
MemberInfo
|
类型 |
类型
|
所有方法 |
MethodBase
|
方法 |
MethodInfo
|
构造函数 |
ConstructorInfo
|
属性 |
PropertyInfo
|
事件 |
EventInfo
|
字段 |
FieldInfo
|
参数 |
ParameterInfo
|
反射不仅仅加载元数据用于检查——它加载程序集是为了允许它们执行,这意味着它们必须通过各种安全和验证检查,否则程序集将加载失败。其中一些检查发生在浏览类型及其成员时,并可能导致异常被抛出。
在使用此项目的反射时,我遇到了以下问题
- 您不能将同一 AppDomain 中的程序集的不同版本加载到其中,并且不同的项目可以并且确实引用相同程序集的不同版本——即使在同一个项目中,由于引用的链式调用,也可能发生这种情况。这也意味着运行在 .NET 4 上的应用程序无法加载旧的 .NET 库。对于像 Nova CodeDOM 这样的工具来说,这些都是严重的问题。
- 您无法在程序集加载后将其从 AppDomain 中卸载,只能卸载整个 AppDomain,但不能卸载主 AppDomain。这会导致在加载一系列引用不同程序集的项目时内存持续增长。
- 尝试通过使用多个 AppDomain 来解决上述 #1 和/或 #2 问题比预期的要困难得多——您不能通过一个 AppDomain 中的代码引用加载到另一个 AppDomain 中的类型,否则它们将被静默加载到另一个 AppDomain 中。您必须在本地查找所有引用它们的代码,或创建自己的类型来在域之间传递数据。这并不明显——我见过有人认为他们解决了这个问题,但由于跨域引用类型,实际上他们并没有解决。
- 加载程序集或浏览类型时可能会抛出异常,包括安全(CAS)异常,以及由于各种原因而出现的各种其他类型的异常。这可能是一个主要问题,导致某些类型或整个程序集无法加载。
- 性能不太好。
事实证明,虽然反射可能适用于程序集在运行时分析自身,但对于加载来自不相关程序集的类型以进行静态分析来说,它实际上并不是一个好的选择。那么,还有其他选择吗?
使用反射加载类型的元数据,使用仅反射加载
早在 .NET 2.0 中,反射就增加了一个“只读”功能,以解决使用反射进行程序集静态分析的许多问题。Assembly.ReflectionOnlyLoad()
方法专门用于允许反射元数据进行静态分析——这意味着您不打算执行程序集中的任何代码,只进行检查。这似乎正是本项目所需要的,因此我添加了一个 CodeDOM.ApplicationContext.UseReflectionOnlyLoad
配置文件选项来启用此模式,并默认将其设置为开启。
使用以仅反射模式加载的程序集进行反射可以解决无法在同一 AppDomain 中加载相同程序集不同版本这一重大问题。它还可以避免许多可能的异常,因为它绕过了强名称验证、CAS 策略检查、处理器体系结构加载规则、绑定策略,不执行任何初始化代码,并防止自动加载依赖程序集。
然而,仅反射加载仍然存在一些问题
- 您仍然无法将不同版本的“mscorlib”程序集加载到主 AppDomain 中(您可以尝试,它会假装工作,但它不会——您仍然只会拥有运行应用程序编译时使用的版本)。这可以通过在需要旧版本以防止解析冲突时“隐藏”mscorlib 中的较新类型来解决。
- 您 *仍然* 无法在程序集加载后将其从 AppDomain 中卸载。
- 所以,#1 基本修复了,但 #2 保持不变,并且使用多个 AppDomain 来解决这些问题与以前一样困难。
- 虽然避免了许多异常,但可能会出现新的、特定于仅反射模式的问题。绕过绑定策略可能会导致尝试加载与较新和/或 64 位操作系统不兼容的旧框架程序集。正常加载的程序集(如您无法替换的驻留“mscorlib”)与仅反射加载的程序集之间的交叉引用可能会导致问题。异常已减少,但远未消除。
- 与正常反射一样,性能不太好。
-
与正常反射不同,在使用仅反射模式时,依赖程序集不会自动处理。每当一个程序集引用另一个程序集时(参见
OnReflectionOnlyAssemblyResolve
),都会触发一个回调,并且必须手动定位和加载该程序集。这提供了一些灵活性,但找到正确的程序集可能需要大量工作,特别是因为回调可能随时发生(例如,在浏览类型元数据时),并且没有简单的方法来确定精确的上下文(例如,当加载整个解决方案时,回调与哪个项目相关)。 -
由于无法执行程序集中的任何代码,任何实例化类型的操作都会抛出异常。例如,您无法使用正常的
GetCustomAttributes()
方法(在Assembly
、MemberInfo
或ParameterInfo
上)检索自定义属性,因为它会实例化它们。此特定问题可以通过使用CustomAttributeData
类来解决,该类提供用于从这些类型检索自定义属性的静态方法。
可以解决其中一些问题,并且我已经添加了逻辑来尽可能解决它们,但仍然很容易遇到一个您无法完全顺利加载的项目。
总而言之,事实证明,虽然在仅反射模式下使用反射可以避免一些问题,但在某些情况下仍然存在一些严重问题。它仍然可能无法加载某些程序集或类型,因此对于加载用于静态分析的程序集元数据来说,它不是一个很好的选择。那么,我们还有其他选择吗?是的——CodePlex 上有 CCI(Common Compiler Infrastructure),也许还有其他,但网上的说法是 Mono Cecil 通常更容易使用。如果有人有直接经验,请告诉我。与此同时,我认为 Mono Cecil 对此项目来说足够好了。
使用 Mono Cecil 加载类型元数据
Mono Cecil 是一个用于读取 .NET 程序集元数据的开源库(它是 Mono 项目的一部分)。基于我在使用反射时遇到的问题,我决定添加对使用 Mono Cecil(版本 0.9.5)加载元数据的支持,看看它的效果如何,因此我添加了一个 CodeDOM.ApplicationContext.UseMonoCecilLoads
配置文件选项(默认开启)。在这种情况下,通过调用 AssemblyDefinition.ReadAssembly()
来加载程序集,该方法返回一个 AssemblyDefinition
实例。LoadedAssembly
类被子类化为 MonoCecilLoadedAssembly
以适应此类程序集,并且使用 MonoCecilAssemblyResolver
类在加载或类型浏览过程中解析依赖程序集。然后,从程序集定义对象加载 TypeDefinition
对象。类型的成员由 MethodDefinition
、PropertyDefinition
、FieldDefinition
等表示。提供一些有用静态方法的辅助类,用于处理这些类,位于 *Utilities/Mono.Cecil*。
功能 | 反射类 | Mono Cecil 类 |
Assemblies |
程序集
|
AssemblyDefinition
|
类型的成员 |
MemberInfo
|
IMemberDefinition
|
类型 |
类型
|
TypeDefinition
|
泛型类型 |
类型
|
GenericInstanceType
|
类型参数 |
类型
|
GenericParameter
|
所有方法 |
MethodBase
|
MethodDefinition
|
方法 |
MethodInfo
|
MethodDefinition
|
构造函数 |
ConstructorInfo
|
MethodDefinition
|
属性 |
PropertyInfo
|
PropertyDefinition
|
事件 |
EventInfo
|
EventDefinition
|
字段 |
FieldInfo
|
FieldDefinition
|
参数 |
ParameterInfo
|
ParameterDefinition
|
Mono Cecil 的性能通常非常好,这显然部分归功于延迟加载(它将一些 CPU 时间从程序集加载时间转移到了稍后浏览类型数据时)。根据我的经验,平均而言,它加载程序集和类型的时间大约是 Reflection 的 1/3。然而,内存使用量实际上比 Reflection 高得多——平均是其两倍。下表显示了一些加载解决方案的程序集和类型的时间示例以及内存使用情况。
解决方案 | 项目 | 文件 | 加载(秒) | 差异 | 内存(MB) | 差异 | ||
Refl. | Cecil | Refl. | Cecil | |||||
Nova | 8 | 687 | 1.1 | 0.5 | 45% | 27 | 49 | 181% |
SubText 2.5.2 | 7 | 849 | 1.1 | 0.2 | 18% | 24 | 76 | 317% |
MS EntLib Tests | 70 | 2,445 | 2.2 | 0.7 | 32% | 76 | 155 | 204% |
大型专有 | 43 | 4,677 | 4.6 | 1.2 | 26% | 129 | 223 | 173% |
Mono Cecil 的问题包括
- 尽管网上有一些相反的说法(可能针对旧版本),但它比 Reflection 使用更多的内存——平均是其两倍(从多 50% 到两倍,或者在某些情况下多 3-4 倍)。
- 它不是线程安全的——即使您只读取类型数据也是如此。对于一个倾向于加载和处理大量数据的库来说,这是一个令人震惊的疏忽。作为一种变通方法,GitHub 上的 ILSpy 项目有一个分支版本,该版本已经过线程安全处理,仅用于读取。
-
它有一个有问题的对象模型,表示定义的对象的基类是表示引用的对象(
TypeDefinition
派生自TypeReference
,MethodDefinition
派生自MethodReference
,等等)。这似乎是为了允许将定义也视为引用,或者可能仅仅是为了继承类似的功能,这可能是元数据格式的一个后果。无论如何,这不是一个逻辑上的“is-a”关系,所以它有点令人困惑,并且排除了使用常规继承的可能性,例如所有成员定义的通用基类(而是实现了接口)。在某些情况下,它会变得非常糟糕,例如由GenericInstanceType
表示的泛型类型实例具有TypeReferences
的GenericArguments
集合(和HasGenericArguments
属性),但 *还* 具有GenericParameters
的GenericParameters
集合(和HasGenericParameters
属性)——由TypeReference
基类提供——而这并没有被使用。对我来说,它似乎并不太干净,而且似乎它也可以更类似于 Reflection,以减少学习曲线。 - 它硬编码了“mscorlib”用于内置类型,因此它无法正确处理用自己的内置类型代替 mscorlib 的程序集(这种情况相对较少)。可以通过修改源代码来解决此限制。
Mono Cecil(0.9.5 版本)比 Reflection 使用更多的内存,不是线程安全的,并且有一个有些令人困惑的对象模型。这很遗憾,因为否则它将是真正伟大的。但是,当 Reflection 有时无法完成工作时,它确实可以完成任务,它速度更快,而且是开源的——所以仍然有很多值得称赞的地方。我应该提到,它还允许您读取和修改 IL,如果您需要这样做,它是一个很好的选择。
我将 Reflection 功能保留在 Nova CodeDOM 中作为一种后备方案,主要是因为 Mono Cecil 使用的内存更多,但我并不指望它会被大量使用——Mono Cecil 总体上工作得更好。还有其他替代方案吗?是的——CodePlex 上有 CCI(Common Compiler Infrastructure),也许还有其他,但网上的说法是 Mono Cecil 通常更容易使用。如果有人有直接经验,请告诉我。与此同时,我认为 Mono Cecil 对此项目来说足够好了。
使用 .NET BCL 的“引用程序集”
从 .NET 3.0 开始,为 .NET Framework 提供了“引用程序集”,用于设计和构建时使用,并且优于 GAC 中的运行时程序集。添加这些程序集是为了避免因运行时程序集的细微更改而引起的冲突,并且它们只包含元数据(没有 IL 代码)。它们位于“%ProgramFiles%\Reference Assemblies\Microsoft\Framework\...”,为不同的版本和配置文件提供单独的子目录。如果您查看 VS 项目中的 BCL 程序集引用(例如“System”),您会看到它指向这些“引用”程序集。本文随附的代码将尝试在可能的情况下使用这些程序集而不是运行时程序集(如果未在计算机上安装 VS,则它们将不存在,并且在 3.0 之前的框架中也不存在)。
使用附加源代码
添加了一个新的 *Projects/Assemblies* 文件夹,其中包含用于加载程序集及其类型元数据的新类,并且一些现有类已更新以支持新功能(例如 Solution
和 Project
用于与加载相关的内容)。加载解决方案/项目现在将显示有关加载引用的程序集及其类型的输出消息(在配置文件中将 Log.LogLevel
设置为 Detailed
以列出所有已加载的程序集)。除了加载程序集和类型数据之外,与上一篇文章相比,功能上的变化很小——这是为解析符号引用所做的必要准备,解析将在下一篇文章中实现。像往常一样,提供了一个单独的 ZIP 文件,其中包含二进制文件,以便您无需先构建即可运行它们。
总结
我的 codeDOM 现在能够从项目引用的各种程序集中加载类型元数据,并且它还了解解决方案中项目之间的引用。我现在拥有解决此项目下一大重要环节所需的一切:解析。在我的下一篇文章中,我将着手完成 在 codeDOM 中解析所有符号引用 的重大任务。