.Net 5或.Net Core里如何能引用dll形式的程序集?

1、以前用.Net Framework不存在这种问题,但.Net 5或.Net Core却不是。由于项目中用到很多非官方的Dll没有源码,怎么解决这样…
关注者
17
被浏览
97,198

8 个回答

1、引用程序集主要有3种方式:

ProjectReference,这个需要有源代码;只要你有源代码,即使需要改,也推荐改完之后这么做,更方便

PackageReference,这个不一定需要 nuget.org 上有,也可以用其它形式的源,假如你们公司里这样的东西比较多,可以自己建个本地nuget服务器

Reference Hintpath="",直接指定dll位置,属于没有办法的办法

2、即使重名,不using相应namespace也就没有问题了

.NET Core SDK不直接提供排除标准库里某一部分的功能,因为这违反设计原则。

第一个问题回答:

  1. .NET 5, .NET Core, .NET Framework 引用 dll 方法都一样的。
  2. .NET 5 本质是 .NET Core,因此可以引用 .NET Core 的类库。
  3. .NET 5 和 .NET Core 与 .NET Framework 之间所编译的产物不能相互引用,.NET 5 和 .NET Core 与 .NET Framework 之间有 .NET Standard 规定了两个框架之间共有的部分。


第二个问题回答:

  1. 不能去除不必要的自带包,但你是要最终发布成 standalone 可以在发布时进行剪裁程序集(仅缩小最终产物体积,并不影响开发)。
  2. 类重名需要重新命名,如果自己没有对它 using 到当然也可以不用考虑重名。

加载、解析和隔离程序集 Loading, Resolving, and Isolating Assemblies

从已知位置加载程序集是一个相对简单的过程。我们将此称为程序集加载 assembly loading

然而,更常见的是,您(或 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 程序集。

程序集加载和解析由程序集加载上下文 (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,您可以保持每个独立,确保它们的依赖项并行加载并且不会相互干扰(也不干扰主机应用程序)。例如,每个都可以有自己的 JSON.NET 版本。因此,除了加载和解析之外,ALC 还提供了一种隔离机制。在某些情况下,甚至可以卸载 ALC,释放它们的内存。

在本节中,我们详细阐述了这些原则中的每一个并描述了以下内容:

  • ALC 如何处理加载和解析
  • 默认 ALC 的作用
  • Assembly.Load 和上下文 ALC
  • 如何使用 AssemblyDependencyResolver
  • 如何加载和解析非托管库
  • 卸载ALC
  • 遗留程序集加载方法

然后,我们将理论付诸实践并演示如何编写具有ALC 隔离功能的插件系统。

Assembly Load Contexts

正如我们刚刚讨论的,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 子类化,以便您可以实现解决依赖关系的逻辑;换句话说,从其名称加载程序集。

Loading assemblies

AssemblyLoadContext 提供了以下方法来将程序集显式加载到其上下文中:

public Assembly LoadFromAssemblyPath (string assemblyPath);
public Assembly LoadFromStream (Stream assembly, Stream assemblySymbols);

第一个方法从文件路径加载程序集,而第二个方法从 Stream(可以直接来自内存)加载它。第二个参数是可选的,对应于项目调试 (.pdb) 文件的内容,它允许堆栈跟踪在代码执行时包含源代码信息(在异常报告中很有用)。

使用这两种方法,都不会发生任何解析 resolution

下面将程序集 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");

请注意,源自不同 Assembly 对象的类型是不兼容的,即使这些程序集在其他方面是相同的。在我们的示例中,assem 中的类型与 assem2 中的类型不兼容。

加载程序集后,只能通过卸载其 ALC 来卸载它(请参阅第 773 页上的“卸载 ALC”)。 CLR 在文件加载期间保持文件锁定。

LoadFromAssemblyName

AssemblyLoadContext 还提供了以下方法,它按名称加载程序集:

public Assembly LoadFromAssemblyName (AssemblyName assemblyName);

与刚才讨论的两个方法不同,您不需要传入任何信息来指示程序集所在的位置;相反,您是在指示 ALC 解析程序集。

Resolving assemblies

前面的 LoadFromAssemblyName 方法触发程序集解析。 CLR 在加载依赖项时也会触发程序集解析。例如,假设程序集 A 静态引用程序集 B。为了解析引用 B,CLR 触发程序集解析,以加载 ALC 程序集 A。

下面是随后发生的事情:

  1. CLR 首先检查在该 ALC 中是否已经发生了相同的解析(具有匹配的完整程序集名称);如果是,它返回之前返回的 Assembly。
  2. 否则,它调用 ALC(虚拟保护)的 Load 方法,该方法完成定位和加载程序集的工作。默认 ALC 的加载方法应用我们在第 766 页“默认 ALC”中描述的规则。对于自定义 ALC,如何定位程序集完全取决于您。例如,您可能会查看某个文件夹,然后在找到程序集时调用 LoadFromAssembly-Path。从同一个或另一个 ALC 返回已加载的程序集也是完全合法的(我们在第 775 页的“编写插件系统”中演示了这一点)。
  3. 如果第 2 步返回 null,则 CLR 将调用默认 ALC 上的 Load 方法(这作为解析 .NET 运行时和常见应用程序程序集的有用“回退”)。
  4. 如果第 3 步返回 null,则 CLR 随后会在两个 ALC 上触发 Resolving 事件——首先是在默认 ALC 上,然后是在原始 ALC 上。
  5. (为了与 .NET Framework 兼容):如果程序集仍未解析,则会触发 AppDomain.Current-Domain.AssemblyResolve 事件。

由此可见,在自定义ALC中实现程序集解析有两种方式:

  • 重写ALC的Load方法。这让您的 ALC 对发生的事情有“第一决定权”,这通常是可取的(并且在您需要隔离时必不可少)。
  • 处理ALC 的 Resolving 事件。只有在默认 ALC 无法解析程序集后才会触发。

为了说明这一点,假设我们要加载一个程序集,我们的主应用程序在编译时对此一无所知,名为 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,这将正确解析这些程序集。

以下是我们如何使用我们的自定义 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 并处理它的 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 程序集。换句话说,我们将无法实现隔离。

The Default ALC

当应用程序启动时,CLR 将一个特殊的 ALC 分配给静态程序集 LoadContext.Default 属性。默认 ALC 是加载启动程序集及其静态引用依赖项和 .NET 运行时 BCL 程序集的位置。

默认 ALC 首先在默认探测路径中查找以自动解析程序集(请参阅第 767 页上的“默认探测”);这通常等同于应用程序的 .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 无法解析时(换句话说,当其 Load 方法返回 null 时)并且默认 ALC 无法解析程序集时,默认 ALC 中的 Resolving 事件也会触发。

您还可以从 Resolving 事件外部将程序集加载到默认 ALC 中。然而,在继续之前,您应该首先确定是否可以通过使用单独的 ALC 或我们在下一节中描述的方法(使用执行和上下文 ALC)更好地解决问题。对默认 ALC 进行硬编码会使您的代码变得脆弱,因为它不能作为一个整体被隔离(例如,通过单元测试框架或 LINQPad)。如果您仍想继续,最好调用解析方法(即 LoadFrom AssemblyName)而不是加载方法(例如 LoadFromAssemblyPath)——尤其是当您的程序集被静态引用时。这是因为程序集可能已加载,在这种情况下 LoadFromAssemblyName 将返回已加载的程序集,而 LoadFromAssemblyPath 将引发异常。

(使用 LoadFromAssemblyPath,您还可能会冒从与 ALC 的默认解析机制找到它的位置不一致的位置加载程序集的风险。)

如果程序集位于 ALC 不会自动找到它的位置,您可以仍然遵循此过程并额外处理 ALC 的 Resolving 事件。请注意,在调用 LoadFromAssemblyName 时,您无需提供全名;简单的名称就可以了(即使程序集被强命名也是有效的):

AssemblyLoadContext.Default.LoadFromAssemblyName ("System.Xml");

但是,如果您在名称中包含公钥令牌,它必须与加载的内容相匹配。

Default probing

默认探测路径通常包括以下内容:

  • AppName.deps.json 中指定的路径(其中 AppName 是应用程序主程序集的名称)。如果此文件不存在,则使用应用程序基础文件夹。
  • 包含.NET 运行时系统程序集的文件夹(如果您的应用程序依赖于框架)。

MSBuild 自动生成一个名为 AppName.deps.json 的文件,其中描述了在哪里可以找到它的所有依赖项。其中包括放置在应用程序基础文件夹中的与平台无关的程序集,以及放置在 win 或 unix 等子文件夹下的 runtimes\ 子目录中的特定于平台的程序集。

在生成的 .deps.json 文件中指定的路径是相对于应用程序基础文件夹的——或者您在 AppName.runtimeconfig.json 和/或 AppName.runtimeconfig.dev.json 配置文件的 additionalProbingPaths 部分中指定的任何其他文件夹(后者仅用于开发环境)。

The “Current” 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 二进制序列化程序(我们在 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 and Contextual 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[]) 方法混淆,后者的行为完全不同的方式 - 请参阅第 774 页的“传统加载方法”。)

与 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"

如果您选择指定公钥令牌,它必须与加载的内容匹配。

这两种方法都是严格解析的,不能指定文件路径。 (如果您在 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 使用调用程序集的 ALC 上下文的策略在通过中介调用 Assembly.Load 时失败,例如反序列化器或单元测试执行器 runner 。如果中介在不同的程序集中定义,则使用中介的加载上下文而不是调用者的加载上下文。

为了让 Assembly.Load 在这种情况下仍然有效,Microsoft 向 AssemblyLoadContext 添加了一个名为 EnterContextualReflection 的方法。这会将 ALC 分配给 AssemblyLoadContext.CurrentContextual-ReflectionContext。虽然这是一个静态属性,但它的值存储在一个 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。

Loading and Resolving Unmanaged Libraries

ALC 还可以加载和解析本地库。当您调用标有 [DllImport] 属性的外部方法时会触发本机解析:

[DllImport ("SomeNativeLibrary.dll")]
static extern int SomeNativeMethod (string text);

因为我们没有在 [DllImport] 属性中指定完整路径,所以调用 Some NativeMethod 会在任何包含 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

在第 767 页的“默认探测”中,我们说过默认 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。

您可以通过调用 ResolveUnmanaged DllToPath 类似地解决非托管依赖项。说明更复杂场景的一种好方法是创建一个名为 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 程序集,则需要编写一个 ALC 来解析其依赖项 Microsoft.Data.SqlClient.dll。这样做时,仅仅查看 ClientApp.dll 所在的文件夹是不够的(正如我们在第 763 页的“解析程序集”中所做的那样)。相反,您需要使用 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

我们在第 775 页的“编写插件系统”中提供了一个完整的示例。

Unloading ALCs

在简单的情况下,可以卸载非默认的 AssemblyLoadContext,释放内存并释放对其加载的程序集的文件锁。为此,必须使用 isCollectible 参数 true 实例化 ALC:

var alc = new AssemblyLoadContext ("test", isCollectible:true);

然后,您可以调用 ALC 上的 Unload 方法来启动卸载过程。

卸载模型是合作的而不是抢先的。如果任何 ALC 程序集中的任何方法正在执行,卸载将被推迟,直到这些方法完成。

实际卸载发生在垃圾收集期间;如果来自 ALC 外部的任何内容对 ALC 内部的任何内容(包括对象、类型和程序集)有任何(非弱)引用,则不会发生。 API(包括 .NET BCL 中的那些)在静态字段或字典中缓存对象或订阅事件并不少见,这使得创建防止卸载的引用变得容易,特别是如果 ALC 中的代码使用 API以一种非平凡的方式在其 ALC 之外。确定卸载失败的原因很困难,需要使用 WinDbg 等工具。

The Legacy Loading Methods

如果您仍在使用 .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 的解析逻辑,因为如果该程序集在同一文件夹中有任何依赖项,这些依赖项将自动解析。

LoadFrom 自动解析可传递的相同文件夹依赖项的能力可能很方便——直到它加载了一个它不应该加载的程序集。由于此类场景可能难以调试,因此最好使用 Load(string) 或 LoadFile 并通过处理应用程序域的 AssemblyRe 解决事件来解决传递依赖关系。这使您能够决定如何解析每个程序集并允许调试(通过在事件处理程序中创建断点)。

LoadFile and 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 来加载它。

Writing a Plug-In System

为了充分展示我们在本节中介绍的概念,让我们编写一个插件系统,使用可卸载的 ALC 来隔离每个插件。

我们的演示系统最初将包含三个 .NET 项目:

Plugin.Common(库)

定义插件将实现的接口

Capitalizer(库)

大写文本的插件

Plugin.Host(控制台应用程序)

定位和调用插件

假设项目位于以下目录中:

c:\\source\\PluginDemo\\Plugin.Common
c:\\source\\PluginDemo\\Capitalizer
c:\\source\\PluginDemo\\Plugin.Host

所有项目都将引用 Plugin.Common 库,并且不会有其他项目间引用。

Plugin.Common

让我们从 Plugin.Common 开始。我们的插件将执行一个非常简单的任务,即转换字符串。以下是我们定义接口的方式:

namespace Plugin.Common
    public interface ITextPlugin
        string TransformText (string input);

这就是 Plugin.Common 的全部内容。

Capitalizer (plug-in)

我们的 Capitalizer 插件将引用 Plugin.Common 并包含一个类。现在,我们将保持逻辑简单,以便插件没有额外的依赖项:

public class CapitalizerPlugin : Plugin.Common.ITextPlugin
    public string TransformText (string input) => input.ToUpper();

如果您构建两个项目并查看 Capitalizer 的输出文件夹,您将看到以下两个程序集:

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 来解析程序集。

检查公共程序集后,我们使用 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);
	    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 的类型(我们将在第 18 章中详细介绍)。然后,我们实例化插件,调用 TransformText 方法,并卸载 ALC。

这是输出:

BIG APPLE

Adding dependencies

我们的代码完全能够解决和隔离依赖关系。为了说明,让我们首先添加对 Humanizer.Core 版本 2.6.2 的 NuGet 引用。您可以通过 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 库项目并添加对 Humanizer.Core 版本 2.7.9 的 NuGet 引用:

<ItemGroup>
	<PackageReference Include="Humanizer.Core" Version="2.7.9" />
</ItemGroup>

现在,添加一个名为 PluralizerPlugin 的类。这将类似于 Capitalizer PlugIn,但我们改为调用 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 并添加以下代码到枚举 ALC 及其程序集的 Main 方法:

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=...