从一个已知路径上加载程序集,我们称之为程序集加载。
更常见的是,你(或CLR)需要加载一个程序集时,只知道它的全名(或简单的名称),需要定位他的路径,这一过程称为程序集解析(assembly resolution)。程序集解析不同于加载,因为必须首先定位程序集。
程序集的解析
程序集解析在两种情况下被触发:
- 当需要解析依赖时,由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 程序集执行解析逻辑。
程序集的加载和解析由AssemblyLoadContext(ALC)处理的。具体来说,是 System.Runtime.Loader 中 AssemblyLoadContext 类的一个实例。因为 AdventureGame.dll 是应用程序的主要程序集,所以 CLR 使用默认的 ALC (AssemblyLoadContext.Default) 来解析它的依赖关系。
默认的 ALC 首先通过查找和检查名为 AdventureGame.deps.json 的文件(它描述了在哪里可以找到依赖项)来解决依赖项,或者如果该文件不存在,它会在应用程序根目录中查找,它将在其中找到 Terrain.dll 和 UIEngine .dll。 (默认 ALC 还解析 .NET Core 框架程序集。)
作为开发者,你可以在程序执行期间动态加载其他程序集。例如,您可能希望将可选功能打包到仅在购买了这些功能后才部署的程序集中。在这种情况下,您可以通过调用 Assembly.Load(AssemblyName) 加载额外的程序集(如果存在)。
一个更复杂的例子是实现一个插件系统,用户可以在其中提供应用程序在运行时检测和加载的第三方程序集,以扩展应用程序的功能。复杂性的产生是因为每个插件程序集可能有自己的依赖关系,这些依赖关系也必须被解决。
通过继承 AssemblyLoadContext 并覆盖其程序集解析方法 (Load),你可以控制插件如何找到其依赖项。例如,你可能决定每个插件都应位于其自己的文件夹中,并且其依赖项也应位于该文件夹中。
ALC有另一个目的:通过对每个(插件 +依赖项)实例化一个单独的LoadContext,您可以保证每个插件都是隔离的,确保其依赖关系并行加载,同时不会彼此干扰(也不是主机应用程序)。例如,每个都可以有自己的 JSON.NET 版本。因此,除了加载和解析之外,ALC 还提供了一种隔离机制。在某些情况下,甚至可以卸载 ALC,释放它们的内存。
在本系列中,我们详细阐述了这些原则中的每一个,并描述了以下内容:
- ALC 如何处理加载和解析
- 默认ALC的作用(角色)
- Assembly.Load 和上下文 ALC
- 如何使用 AssemblyDependencyResolver
- 如何加载和解析非托管库
- 卸载 ALC
- 传统(legacy)程序集加载方法
然后,我们将理论付诸实践,演示如何编写具有 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(ALC)
正如我们刚刚讨论的,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 类还有一个枚举所有 ALC 的静态 All 属性。
您可以通过实例化 AssemblyLoadContext 并提供一个名称(该名称在调试时很有帮助)来创建一个新的 ALC。然而更常见的是,继承 AssemblyLoadContext ,然后重写方法,以便可以实现解决依赖关系的逻辑;换句话说,从其名称加载程序集。
Loading assemblies
AssemblyLoadContext提供了以下方法来显式加载一个assembly到它的上下文中:
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。我们可以加载 foo.dll 的另一个副本,如下所示
var alc2 = new AssemblyLoadContext ("Test 2");
Assembly assem2 = alc2.LoadFromAssemblyPath (@"c:\temp\foo.dll");
请注意,来自不同程序集对象的类型是不兼容的,即使这些程序集在其他方面是相同的。在我们的示例中,assem 中的类型与 assem2 中的类型不兼容
程序集加载后,无法卸载,除非卸载其 ALC。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去解析程序集。
Resolving assemblies
上面提到的方法会触发程序集解析,CLR在加载依赖时也会导致程序集解析。比如,假设程序集 A 静态引用程序集 B,那么为了解析引用 B,CLR 会触发加载 ALC 程序集 A 的程序集解析
无论触发程序集是在默认的还是自定义的 ALC 中,CLR 通过触发程序集解析(assembly resolution)来解决依赖关系。不同的是,在默认的ALC中,程序集解析规则是硬编码的,而在自定义的ALC中,你可以自己编写解析规则。
下面是具体的解析步骤:
- CLR 首先检查该 ALC 中是否存在相同的程序集(完整程序集名称相同)已经被解析过了;如果存在,它返回之前解析过的程序集
- 否则,它调用 ALC(由virtual protected修饰)的 Load 方法,去定位和加载程序集。 默认ALCLoad方法的加载逻辑我们下面详细说。
对于自定义的 ALC,取决与你如何去定位程序集。例如,你可以在某个文件夹中寻找,然后在找到程序集时调用LoadFromAssemblyPath来加载。从同一个或另一个 ALC 返回已加载的程序集也是完全合法的。
- 如果步骤2(无论是自定义的还是default)返回null,则CLR会在默认ALC上调用Load方法。(这个应该是充当了“保底”(fallback)策略)
- 如果步骤 3 返回 null,CLR 随后会在两个 ALC 上触发 Resolving 事件——首先是在默认的 ALC 上,然后是在原始 ALC 上。
- (为了与 .NET Framework 兼容),如果程序集仍未解析(resolved),则会触发AppDomain.CurrentDomain.AssemblyResolve 事件
上面程完成后,CLR 会进行“健全性检查”以确保加载的任何程序集都具有与请求的名称兼容的名称。其中简单名称必须匹配;如果指定了公钥令牌也必须匹配。版本不需要匹配——它可以高于或低于请求的版本。
由此我们可以看到,在自定义AL中有两种方法可以实现程序集解析(Assembly resolution):
- 覆盖 ALC 的 Load 方法。自己决定如何去解析,这是推荐的做法(且在需要程序集隔离时必不可少)。
- 处理ALC的Resolving事件。该事件仅在默认的ALC未能解析程序集(resolve assembly)后发生。
如果你将多个事件处理程序附加到解析事件上,只有第一个返回非空值的事件处理程序生效。
举个例子
为了详细说明,假设我们要在主应用程序运行时加载一个程序集,称为 foo.dll,位于 c:\temp(与我们的应用程序文件夹不同)。还假设foo.dll对bar.dll有一个私有的依赖关系。
我们要保证当我们加载c:\temp\foo.dll并执行它的代码时,c:\temp\bar.dll能够正确解析。还希望 foo 及其私有依赖项 bar 不会干扰主应用程序。
首先从编写覆盖负载的自定义 ALC 开始:
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.dl 的依赖性。这时自定义ALC的Load方法将触发并成功定位bar.dll组件在C:\ temp中。
同理,我们的 Load 方法也能够解析 foo.dll,因此我们可以将我们的代码简化为:
var alc = new FolderBasedALC (@"c:\temp");
Assembly foo = alc.LoadFromAssemblyName (new AssemblyName ("foo"));
...
现在,我么可以由另一种解决方案:我们不再继承 AssemblyLoad Context 并覆盖 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"));
原因:现在我们不需要检查程序集是否存在。因为在默认 ALC 解析程序集(并且仅当它失败时)后触发 Resolving 事件(因此会执行我们自定义的解析处理程序);同时所以我们的处理程序也不会因为找不到 .NET BCL 程序集而触发(因为它会被ALC正确解析,所以不会触发Resolving事件)。
这使得这个解决方案更加简单,尽管有一个缺点。记住,在我们的场景中,主应用程序在编译时并不知道会依赖我们指定路径下的 foo.dll 或 bar.dll。这也意味着主应用程序本身可能依赖于(不同路径下)名为 foo.dll 或 bar.dll 的程序集。如果发生这种情况,Resolving 事件永远不会触发,而会去加载应用程序的 foo 和 bar 程序集。换句话说,我们将无法实现程序集隔离。