最近在工作中牵涉到了. NET 下的一个古老的问题: Assembly 的加载过程虽然网上有很多文章介绍这部分内容, 很多文章也是很久以前就已经出现了, 但阅读之后发现, 并没能解决我的问题, 有些点写的不是特别详细, 让人看完之后感觉还是云里雾里最后, 我决定重新复习一下这个经典而古老的问题, 并将所得总结于此, 然后会有一个实例对这个问题进行演示, 希望能够帮助到大家
.NET 下 Assembly 的加载过程
.NET 下 Assembly 的加载, 最主要的一步就是确定 Assembly 的版本在. NET 下, 托管的 DLL 和 EXE 都称之为 Assembly,Assembly 由 AssemblyName 来唯一标识, AssemblyName 也就是大家所熟悉的 Assembly.FullName, 它是由五部分: 名称版本语言公钥 Token 处理器架构组成的, 这一点相信大家都知道有关 Assembly Name 的详细描述, 请参考: https://docs.microsoft.com/en-us/dotnet/framework/app-domains/assembly-names 那么版本, 就是 AssemblyName 中的一个重要组成部分其它四部分相同, 版本如果不同的话, 就不能算作是同一个 Assembly 设计这样一个 Assembly 的版本策略, 微软本身就是为了解决最开始的 DLL Hell 的问题, 在维基百科上着关于这段黑历史的详细描述, 地址是: https://en.wikipedia.org/wiki/DLL_Hell, 在此也就不多啰嗦了
Assembly 版本的重定向和最终确定
.NET 下 Assembly 的加载过程, 其实也是 Assembly 版本的确定和 Assembly 文件的定位过程, 步骤如下:
在一个 Assembly 被编译的时候, 它所引用的 Assembly 的全名 (FullName) 就会被编译器强行写入 Assembly 的 Metadata, 这个值是死的, 从 ILSpy 可以看到, 每个 Reference 都有它的全名信息:
例如上图, System.Data 依赖 System.Xml, 它所需要的版本是 4.0.0.0, 那么当 CLR 加载 System.Data 的时候, 就可以暂且认为接下来需要加载的 System.Xml 版本是 4.0.0.0 这里强调暂且认为, 是因为这只是确定 Assembly 版本的第一步, 那么最终 System.Xml 到底是不是使用 4.0.0.0 的版本呢? 就需要看接下来这步的处理结果, 也就是 Assembly 版本的重定向
首先, 检查应用程序的配置文件, 看是否存在 Assembly 版本重定向的设定我们暂时先讨论应用程序配置文件就在 AppDomain 内的情况 (如果在 AppDomain 之外, 则需要首先下载配置文件, 再继续, 这里先不深入讨论) 应用程序配置文件常见的有. exe.config 和 web.config 两种在配置文件中, 可以在 runtime 节点下的 assemblyBinding 中进行配置例如:
在这个例子中, asm6 Assembly 的版本号被重定向到 2.0.0.0 那么假设这就是 asm6 的最终版本号, 那么接下来当 CLR 开始加载 asm6 的时候, 如果 2.0.0.0 的版本没有找到, 则直接抛出 FileLoadException(即使 3.0.0.0 的版本是存在的), 整个 Assembly 加载过程结束 FileLoadException 的详细信息类似于: Could not load file or assembly 'asm6, Version=3.0.0.0, Culture=neutral, PublicKeyToken=c0305c36380ba429' or one of its dependencies. The located assembly's manifest definition does not match the assembly reference
如果在配置文件中找到了对应的版本重定向设定, 那么, 再接着查看 Publisher Policy 文件 Publisher Policy 文件是一个仅包含配置文件的. NET Assembly, 被安装到 GAC 里它的 Assembly 版本重定向配置内容跟上面的应用程序配置文件的配置内容相同, 不同的是, 它的作用域是所有使用了该 Assembly 的应用程序这种做法对于开发系统级通用框架的 Assembly 升级非常有用, 比如. NET Framework 下面就是安装在 GAC 里的 Publisher Policy 文件的样本, 需要注意: Publisher Policy 会 override 应用程序配置信息中的版本重定向配置, 而不是相反换言之, 假如 asm6 在上面这一步被确定为 2.0.0.0, 而所对应的 Publisher Policy 文件又将其确定为 2.5.0.0, 那么, 暂且认为, CLR 应该要加载 2.5.0.0 的版本同理, 暂且认为这个词表示, 版本确定的过程还未结束
接下来, 查找 machine.config 文件同理, 如果 machine.config 文件中存在版本重定向的设定, 那么就会使用 machine.config 文件中的这个值, 作为 CLR 应该去加载的 Assembly 的版本
至此, Assembly 的最终版本已被确定, 接下来就是搜索 Assembly 文件并进行加载的过程了
Assembly 文件的搜索和加载过程
现在, CLR 已经开始加载确定版本的 Assembly 了, 接下来就是搜索 Assembly 文件的过程这个过程也叫作 Assembly ProbingCLR 会做以下事情:
首先, 查看所需的 Assembly 是否已经加载过, 如果已经加载了, 那就直接使用那个已经加载的 Assembly 的版本与当前所需的版本进行比对, 如果匹配, 则使用那个已经加载的 Assembly, 如果不匹配, 则抛出 FileLoadException, 执行结束
然后, 看 Assembly 是否已被强签名(Strongly Named), 如果是, 则去 GAC 里查找 Assembly 如果找到, 则直接加载, 整个 Assembly 加载过程结束如果没有找到, 那么就进行下一步, 继续搜索 Assembly 文件当然, 如果 Assembly 没有进行强签名, 那么就跳过这一步, 直接继续
接着, CLR 开始搜索 (Probing) 可能的 Assembly 位置, 这又要分多种情况:
首先, 查看文件中是否有指定 < codeBase>,codeBase 配置允许应用程序针对 Assembly 的不同版本指定装载地址, 遵循如下规律:
如果所指定的 Assembly 文件位于当前应用程序域的启动目录 (或其子目录) 下, 则使用相对路径指定 href 的值
如果所指定的 Assembly 文件位于其它目录, 或任何其它地方, 则 href 必须给出全路径, 并且 Assembly 必须强签名的
然后, CLR 对应用程序域的根目录以及相关的子目录进行探索:
假设 Assembly 的名字是 abc.dll, 那么 CLR 会探索以下目录:
- [appdomain_base]\abc.dll
- [appdomain_base]\abc\abc.dll
假设 abc.dll 还有语言设置(culture 不是 neutral), 那么 CLR 会探索以下目录:
- [appdomain_base]\[culture]\abc.dll
- [appdomain_base]\[culture]\abc\abc.dll
如果找到符合版本的 Assembly, 则加载, 否则进入下一步
最后, CLR 会查看应用程序配置文件中是否有 < probling > 节点, 如果有, 则按 probling 节点所指定的 privatePath 值进行逐一探索这个过程也会考虑 culture 的因素, 类似于上面这步这样, 对相应的子目录进行搜索如果找到对应的 Assembly, 则加载, 否则抛出 FileLoadException, 整个加载过程结束注意, 这里逐一探索的过程, 不是遍历并找最佳匹配的过程 CLR 仅根据 Assembly 的名字 (不带版本号的名字) 在 privatePath 下查找 Assembly 的文件, 找到第一个名字匹配但是版本不匹配的话, 就抛异常并终止加载了, 它不会继续搜索 privatePath 中余下的其它路径
在加载 Assembly 文件失败的时候, AppDomain 会触发 AssemblyResolve 的事件, 在这个事件的订阅函数中, 允许客户程序自定义对加载失败的 Assembly 的处理方式, 比如, 可以通过 Assembly.LoadFrom 或者 Assembly.LoadFile 调用手动地将 Assembly 加载到 AppDomain
fuslogvw Assembly 绑定日志查看器
在. NET SDK 中带了一个 fuslogvw.exe 的应用程序, 通过它可以查看详细的 Assembly 加载过程使用方法非常简单, 使用管理员身份启动 Visual Studio 2017 Developer Command Prompt, 然后在命令行输入 fuslogvw.exe, 即可启动日志查看器启动之后, 点击 Settings 按钮, 以启用日志记录功能:
日志启动之后, 点击 Refresh 按钮, 然后启动你的. NET 应用程序, 就可以看到当前应用程序所依赖的 Assembly 的加载过程日志了:
接下来, 我会做一个例子程序, 然后使用这个工具来分析 Assembly 的加载过程
插件系统的实现与 Assembly 加载过程的分析
理论结合实际, 看看如何通过实际代码来诠释以上所述 Assembly 的加载过程一个比较好的例子就是设计一个简单的插件系统, 并通过观察系统加载插件的过程, 来了解 Assembly 加载的来龙去脉为了简单直观, 我把这个插件系统称为 PluginDemo 这个插件很简单, 主体程序是一个控制台应用程序, 然后我们实现两个插件: Earth 和 Mars, 在不同的插件的 Initialize 方法中, 会输出不同的字符串
整个应用程序的项目结构如下:
该插件系统包含 4 个 C# 的项目:
PluginDemo.Common: 它定义了 AddIn 抽象类, 所有的插件实现都需要继承于这个抽象类此外, AddInDefinition 类是一个用来保存插件 Metadata 的类为了演示, 插件的 Metadata 仅仅包含插件类型的 Assembly Qualified Name
PluginDemo.App: 插件系统的应用程序这个程序执行的时候, 会扫描程序目录下 Modules 目录中的 DLL, 并根据 module.xml 的 Metadata 信息, 加载相应的插件对象, 并执行 Initialize 方法
PluginDemo.Plugins.Earth: 其中的一个插件实现
PluginDemo.Plugins.Mars: 另一个插件实现
注意: 除了 PluginDemo.Common 之外的其它三个项目, 都对 PluginDemo.Common 有引用关系而 PluginDemo.App 项目仅仅在项目本身依赖于 PluginDemo.Plugins.Earth 和 PluginDemo.Plugins.Mars, 它不会去引用这两个项目目的就是为了当 PluginDemo.App 被编译时, 其余两个插件项目也会同时被编译并输出到指定位置
在 Earth 插件的 CustomAddIn 类中, 我们实现了 Initialize 方法, 并在此输出一个字符串:
- public class CustomAddIn : AddIn
- {
- public override string Name => "Earth AddIn";
- public override void Initialize()
- {
- Console.WriteLine("Earth Plugin initialized.");
- }
- }
在 Mars 插件的 CustomAddIn 类中, 我们也实现了 Initialize 方法, 并在此输出一个字符串:
- public class CustomAddIn : AddIn
- {
- public override string Name => "Mars AddIn";
- public override void Initialize()
- {
- Console.WriteLine("Mars AddIn initialized.");
- }
- }
那么, 在插件系统主程序中, 就会扫描 Modules 子目录下的 module.xml 文件, 然后解析每个 module.xml 文件获得每个插件类的 Assembly Qualified Name, 然后通过 Type.GetType 方法获得插件类, 进而创建实例调用 Initialize 方法代码如下:
- static void Main()
- {
- var directory = new DirectoryInfo("Modules");
- foreach(var file in directory.EnumerateFiles("module.xml", SearchOption.AllDirectories))
- {
- var addinDefinition = AddInDefinition.ReadFromFile(file.FullName);
- var addInType = Type.GetType(addinDefinition.FullName);
- var addIn = (AddIn)Activator.CreateInstance(addInType);
- Console.WriteLine($"{addIn.Id} - {addIn.Name}");
- addIn.Initialize();
- }
- }
接下来, 修改 App.config 文件, 修改为:
- <?xml version="1.0" encoding="utf-8" ?>
- <configuration>
- <runtime>
- <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
- <probing privatePath="Modules\Earth;Modules\Mars;" />
- </assemblyBinding>
- </runtime>
- </configuration>
此时, 运行程序, 可以得到:
目前没有什么问题接下来, 对两个 AddIn 分别做一些修改让这两个 AddIn 依赖于不同版本的 Newtonsoft.Json, 比如, Earth 依赖于 7.0.0.0 的版本, Mars 依赖于 6.0.0.0 的版本, 然后分别修改两个 CustomAddIn 的 Initialize 方法, 在方法中各自调用一次 JsonConvert.SerializeObject 方法, 以触发 Newtonsoft.Json 这个 Assembly 的加载此时再次运行程序, 你将看到下面的异常:
现在, 刷新 fuslogvw.exe, 找到 Newtonsoft.Json 的日志:
双击打开日志, 可以看到如下信息:
从整个过程可以看出:
PluginDemo.App.exe 正在试图加载 PluginDemo.Plugins.Mars Assembly
PluginDemo.Plugins.Mars 开始调用 Newtonsoft.Json
扫描应用程序配置文件 Host 配置文件以及 machine.config 文件, 均无找到 Newtonsoft.Json 的重定向信息, 此时, Newtonsoft.Json 版本确定为 6.0.0.0
GAC 扫描失败, 继续查找文件
首先查找应用程序当前目录下有没有 Newtonsoft.Json, 以及 Newtonsoft.Json 子目录下有没有 Newtonsoft.Json.dll, 发现都没有, 继续
然后, 通过 App.config 中的 probing 的 privatePath 设定, 首先查找 Modules\Earth 目录(因为这个目录放在 privatePath 的第一个), 找到了一个叫做 Newtonsoft.Json.dll 的 Assembly, 于是, 判断版本是否相同结果, 找到的是 7.0.0.0, 而它需要的却是 6.0.0.0, 版本不匹配, 于是就抛出异常, 退出程序
那么接下来, 改一改 App.config 文件, 将 privatePath 下的两个值换个位置呢?
再试试:
此时, Earth AddIn 又出错了那么, 我们加上版本重定向的配置, 指定当程序需要加载 7.0.0.0 版本的 Newtonsoft.Json 时, 让它重定向到 6.0.0.0 的版本:
再次执行, 成功了:
看看日志:
版本已经被重定向到 6.0.0.0, 并且在 Mars 目录下找到了 6.0.0.0 的 Newtonsoft.Json, 加载成功了
这个案例的源代码可以点击此处下载 http://sunnycoding.net/data/plugindemo.zip
来源: https://www.cnblogs.com/daxnet/p/8525249.html