以编程方式加载程序集及其依赖项时的奇怪行为

Strange behavior when loading assemblies and its dependencies programatically

以下实验codes/projects在VS2017中使用netcore 2.0和netstandard 2.0。假设我有两个版本的第三方 dll v1.0.0.0 和 v2.0.0.0,其中只包含一个 class Constants.cs.

//ThirdPartyDependency.dll v1.0.0.0
public class Constants
{
    public static readonly string TestValue = "test value v1.0.0.0";
}

//ThirdPartyDependency.dll v2.0.0.0
public class Constants
{
    public static readonly string TestValue = "test value v2.0.0.0";
}

然后我创建了自己的名为 AssemblyLoadTest 的解决方案,其中包含:

Wrapper.Abstraction: class 没有项目引用的库

namespace Wrapper.Abstraction
{
    public interface IValueLoader
    {
        string GetValue();
    }

    public class ValueLoaderFactory
    {
        public static IValueLoader Create(string wrapperAssemblyPath)
        {
            var assembly = Assembly.LoadFrom(wrapperAssemblyPath);
            return (IValueLoader)assembly.CreateInstance("Wrapper.Implementation.ValueLoader");
        }
    }
}

Wrapper.V1: class 带有项目引用的库 Wrapper.Abstractions 和 dll 引用 ThirdPartyDependency v1.0.0.0

namespace Wrapper.Implementation
{
    public class ValueLoader : IValueLoader
    {
        public string GetValue()
        {
            return Constants.TestValue;
        }
    }
}

Wrapper.V2: class 带有项目引用的库 Wrapper.Abstractions 和 dll 引用 ThirdPartyDependency v2.0.0.0

namespace Wrapper.Implementation
{
    public class ValueLoader : IValueLoader
    {
        public string GetValue()
        {
            return Constants.TestValue;
        }
    }
}

AssemblyLoadTest:带有项目引用的控制台应用程序 Wrapper.Abstraction

class Program
{
    static void Main(string[] args)
    {
        AppDomain.CurrentDomain.AssemblyResolve += (s, e) =>
        {
            Console.WriteLine($"AssemblyResolve: {e.Name}");

            if (e.Name.StartsWith("ThirdPartyDependency, Version=1.0.0.0"))
            {
                return Assembly.LoadFrom(@"v1\ThirdPartyDependency.dll");
            }
            else if (e.Name.StartsWith("ThirdPartyDependency, Version=2.0.0.0"))
            {
                //return Assembly.LoadFrom(@"v2\ThirdPartyDependency.dll");//FlagA
                return Assembly.LoadFile(@"C:\FULL-PATH-TO\v2\ThirdPartyDependency.dll");//FlagB
            }

            throw new Exception();
        };

        var v1 = ValueLoaderFactory.Create(@"v1\Wrapper.V1.dll");
        var v2 = ValueLoaderFactory.Create(@"v2\Wrapper.V2.dll");

        Console.WriteLine(v1.GetValue());
        Console.WriteLine(v2.GetValue());

        Console.Read();
    }
}

步骤

  1. 在 DEBUG 中构建 AssemblyLoadTest

  2. 在DEBUG中构建Wrapper.V1工程,将Wrapper.V1\bin\Debug\netstandard2.0\中的文件复制到AssemblyLoadTest\bin\Debug\netcoreapp2.0\v1\

  3. 在DEBUG中构建Wrapper.V2工程,将Wrapper.V2\bin\Debug\netstandard2.0\中的文件复制到AssemblyLoadTest\bin\Debug\netcoreapp2.0\v2\

  4. 将 AssemblyLoadTest.Program.Main 中的 FULL-PATH-TO 替换为您在步骤 3 中复制的正确的绝对 v2 路径

  5. 运行 AssemblyLoadTest - Test1

  6. 注释 FlagB 行并取消注释 FlagA 行,运行 AssemblyLoadTest - Test2

  7. 评论AppDomain.CurrentDomain.AssemblyResolve、运行 AssemblyLoadTest - Test3

我的结果和问题:

  1. Test1 成功并按预期打印 v1.0.0.0 和 v2.0.0.0

  2. Test2 在 v2.GetValue()

  3. 抛出异常

System.IO.FileLoadException: 'Could not load file or assembly 'ThirdPartyDependency, Version=2.0.0.0, Culture=neutral, PublicKeyToken=null'. Could not find or load a specific file. (Exception from HRESULT: 0x80131621)'

问题1:为什么在第一个if语句中,使用绝对路径的LoadFile可以正常工作,而使用相对路径的LoadFrom不工作,同时使用相对路径的LoadFrom适用于v1.0.0.0?

  1. Test3 在同一位置失败并出现上述相同的异常,这里我的理解是 CLR 定位具有以下优先级规则的程序集:

规则 1:检查 AppDomain.AssemblyResolve 是否已注册(最高优先级)

规则 2:否则检查程序集是否已加载。

规则 3:否则在文件夹中搜索程序集(可以在 probing and codeBase 中配置。

此处在未注册 AssemblyResolve 的 Test3 中,v1.GetValue 有效,因为 Rule1 和 Rule2 是 N/A,AssemblyLoadTest\bin\Debug\netcoreapp2.1\v1 在 Rule3 扫描候选中。执行v2.GetValue时,Rule1还是N/A,但是这里应用了Rule2(如果应用了Rule3,为什么会出现异常?)

问题 2:为什么使用

甚至 Wrapper.V2 引用 ThirdPartyDependency.dll 时版本也被忽略
<Reference Include="ThirdPartyDependency, Version=2.0.0.0">
  <HintPath>..\lib\ThirdPartyDependency.0.0.0\ThirdPartyDependency.dll</HintPath>
</Reference> 

Vitek Karas, original link here 的出色回答。

不幸的是,您描述的所有行为目前都是设计好的。这并不意味着它是直观的(它完全不是)。让我试着解释一下。

程序集绑定基于 AssemblyLoadContext (ALC)。每个 ALC 只能加载任何给定程序集的一个版本(因此只有一个给定简单名称的程序集,忽略版本、区域性、键等)。您可以创建一个新的 ALC,然后可以使用相同或不同的版本再次加载任何程序集。所以 ALC 提供绑定隔离。

您的 .exe 和相关程序集被加载到默认 ALC - 一个在运行时开始时创建的。

Assembly.LoadFrom 将始终尝试将指定文件加载到默认 ALC。让我在这里强调 "try" 这个词。如果默认 ALC 已加载同名程序集,并且已加载程序集的版本相同或更高,则 LoadFrom 将成功,但它将使用已加载的程序集(有效地忽略您指定的路径)。另一方面,如果已经加载的程序集的版本低于您尝试加载的版本 - 这将失败(我们无法将同一程序集第二次加载到同一 ALC 中)。

Assembly.LoadFile 将指定的文件加载到新的 ALC - 总是创建一个新的 ALC。所以加载总是会有效地成功(因为它在它自己的 ALC 中,所以它不可能与任何东西发生碰撞)。

现在开始你的场景:

测试 1 这是有效的,因为您的 ResolveAssembly 事件处理程序将两个程序集加载到单独的 ALC 中(LoadFile 将创建一个新的 ALC,因此第一个程序集进入默认的 ALC,第二个进入它自己的)。

测试2 这失败了,因为 LoadFrom 试图将程序集加载到默认 ALC 中。失败实际上发生在 AssemblyResolve 处理程序调用第二个 LoadFrom 时。第一次将 v1 加载到 Default 中,第二次尝试将 v2 加载到 Default 中 - 但失败了,因为 Default 已经加载了 v1。

测试 3 这以同样的方式失败,因为它在内部基本上完全与 Test2 所做的一样。 Assembly.LoadFrom 还为 AssemblyResolve 注册事件处理程序,这确保可以从同一文件夹加载依赖程序集。所以在你的情况下 v1\Wrapper.V1.dll 将解析它对 v1\ThirdPartyDependency.dll 的依赖,因为它在磁盘上紧挨着它。然后对于 v2,它会尝试做同样的事情,但是 v1 已经加载,所以它失败了,就像在 Test2 中一样。请记住,LoadFrom 将所有内容加载到默认 ALC 中,因此可能会发生冲突。

您的问题:

问题1 LoadFile 之所以有效,是因为它将程序集加载到它自己的 ALC 中,这提供了完全隔离,因此永远不会有任何冲突。 LoadFrom 将程序集加载到默认 ALC 中,因此如果已经加载了同名程序集,则可能会发生冲突。

问题2 版本实际上没有被忽略。该版本受到尊重,这就是 Test2 和 Test3 失败的原因。但我可能没有正确理解这个问题 - 我不清楚你是在什么情况下问的。

CLR 绑定顺序 您描述的规则顺序不同。 基本上是:

  • 规则 2 - 如果已经加载 - 使用它(如果已经加载更高版本,则使用它)
  • 规则 1 - 如果一切都失败了 - 作为最后的手段 - 调用 AppDomain.AssemblyResolve

规则 3 实际上并不存在。 .NET Core 没有探测路径或代码库的概念。它对应用程序静态引用的程序集有点作用,但对于动态加载的程序集,不执行探测(除了 LoadFrom 从与父级相同的文件夹加载依赖程序集,如上所述)。

解决方案 要使其充分发挥作用,您需要执行以下任一操作:

  • 将 LoadFile 与您的 AssemblyResolve 处理程序一起使用。但这里的问题是,如果您 LoadFile 一个本身具有其他依赖项的程序集,您还需要在处理程序中处理这些依赖项(您失去了 LoadFrom 的 "nice" 行为,它从同一文件夹加载依赖项)

  • 实施您自己的 ALC 来处理所有依赖项。这在技术上是更清洁的解决方案,但可能需要更多工作。在这方面类似,如果需要,您仍然必须从同一文件夹实现加载。

我们正在积极努力让这样的场景变得简单。今天它们是可行的,但相当困难。计划是为 .NET Core 3 解决这个问题。我们也非常清楚这方面缺少 documentation/guidance。最后但同样重要的是,我们正在努力改进目前非常混乱的错误消息。