通过


使用插件创建 .NET Core 应用程序

本教程介绍如何创建自定义 AssemblyLoadContext 插件以加载插件。 AssemblyDependencyResolver 用于解析插件的依赖项。 本教程为插件的依赖项提供了单独的程序集上下文,允许插件和托管应用程序之间的不同程序集依赖项。 你将了解如何:

注释

无法安全地将不受信任的代码加载到受信任的 .NET 进程中。 若要提供安全或可靠性边界,请考虑 OS 或虚拟化平台提供的技术。

先决条件

创建应用程序

第一步是创建应用程序:

  1. 创建新文件夹,并在该文件夹中运行以下命令:

    dotnet new console -o AppWithPlugin
    
  2. 若要简化项目生成,请在同一文件夹中创建 Visual Studio 解决方案文件。 运行下面的命令:

    dotnet new sln
    
  3. 运行以下命令,将应用项目添加到解决方案:

    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

现在,我们可以填充应用程序的框架。 将 AppWithPlugin/Program.cs 文件中的代码替换为以下代码:

using PluginBase;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;

namespace AppWithPlugin
{
    class Program
    {
        static void Main(string[] args)
        {
            try
            {
                if (args.Length == 1 && args[0] == "/d")
                {
                    Console.WriteLine("Waiting for any key...");
                    Console.ReadLine();
                }

                // Load commands from plugins.

                if (args.Length == 0)
                {
                    Console.WriteLine("Commands: ");
                    // Output the loaded commands.
                }
                else
                {
                    foreach (string commandName in args)
                    {
                        Console.WriteLine($"-- {commandName} --");

                        // Skip command-line switches/flags (arguments starting with '/' or '-')
                        if (commandName.StartsWith("/") || commandName.StartsWith("-"))
                        {
                            Console.WriteLine($"Skipping command-line flag: {commandName}");
                            continue;
                        }

                        // Execute the command with the name passed as an argument.

                        Console.WriteLine();
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }
    }
}

创建插件接口

使用插件生成应用的下一步是定义插件需要实现的接口。 建议创建一个类库,其中包含你计划用于在应用和插件之间通信的任何类型。 此部门允许将插件接口发布为包,而无需交付完整的应用程序。

在项目的根文件夹中,运行 dotnet new classlib -o PluginBase。 此外,运行 dotnet sln add PluginBase/PluginBase.csproj 以将项目添加到解决方案文件。 删除PluginBase/Class1.cs文件,然后在PluginBase文件夹中创建一个新文件ICommand.cs,并在其中添加以下接口定义:

namespace PluginBase
{
    public interface ICommand
    {
        string Name { get; }
        string Description { get; }

        int Execute();
    }
}

ICommand 接口是所有插件将实现的接口。

现在定义了 ICommand 接口后,可以进一步完善应用程序项目。 在根文件夹中使用 dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj 命令,从 AppWithPlugin 项目向 PluginBase 项目添加引用。

// Load commands from plugins 注释替换为以下代码片段,使其能够从给定的文件路径加载插件:

string[] pluginPaths = new string[]
{
    // Paths to plugins to load.
};

IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
{
    Assembly pluginAssembly = LoadPlugin(pluginPath);
    return CreateCommands(pluginAssembly);
}).ToList();

然后将// Output the loaded commands注释替换为以下代码片段:

foreach (ICommand command in commands)
{
    Console.WriteLine($"{command.Name}\t - {command.Description}");
}

// Execute the command with the name passed as an argument 注释替换为以下代码片段:

ICommand command = commands.FirstOrDefault(c => c.Name == commandName);
if (command == null)
{
    Console.WriteLine($"No such command is known: {commandName}");
    continue;
}

command.Execute();

最后,将名为 LoadPluginCreateCommands 的静态方法添加到 Program 类中,如下所示:

static Assembly LoadPlugin(string relativePath)
{
    throw new NotImplementedException();
}

static IEnumerable<ICommand> CreateCommands(Assembly assembly)
{
    int count = 0;

    foreach (var type in assembly.GetTypes().Where(t => typeof(ICommand).IsAssignableFrom(t)))
    {
        if (Activator.CreateInstance(type) is ICommand result)
        {
            count++;
            yield return result;
        }
    }

    if (count == 0)
    {
        string availableTypes = string.Join(",", assembly.GetTypes().Select(t => t.FullName));
        throw new ApplicationException(
            $"Can't find any type which implements ICommand in {assembly} from {assembly.Location}.\n" +
            $"Available types: {availableTypes}");
    }
}

加载插件

现在,应用程序可以从加载的插件程序集正确加载和实例化命令,但仍无法加载插件程序集。 在 AppWithPlugin 文件夹中创建包含以下内容的名为 PluginLoadContext.cs 的文件:

using System;
using System.Reflection;
using System.Runtime.Loader;

namespace AppWithPlugin
{
    class PluginLoadContext : AssemblyLoadContext
    {
        private AssemblyDependencyResolver _resolver;

        public PluginLoadContext(string pluginPath)
        {
            _resolver = new AssemblyDependencyResolver(pluginPath);
        }

        protected override Assembly Load(AssemblyName assemblyName)
        {
            string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
            if (assemblyPath != null)
            {
                return LoadFromAssemblyPath(assemblyPath);
            }

            return null;
        }

        protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
        {
            string libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
            if (libraryPath != null)
            {
                return LoadUnmanagedDllFromPath(libraryPath);
            }

            return IntPtr.Zero;
        }
    }
}

类型 PluginLoadContext 派生自 AssemblyLoadContext. 该 AssemblyLoadContext 类型是运行时中的一种特殊类型,允许开发人员将加载的程序集隔离到不同的组中,以确保程序集版本不冲突。 此外,自定义 AssemblyLoadContext 还可以选择不同的路径从中加载程序集并替代默认行为。 在 .NET Core 3.0 中,引入的AssemblyDependencyResolver类型实例用于将程序集名称解析为路径。PluginLoadContext 使用这个实例。 该 AssemblyDependencyResolver 对象是使用 .NET 类库的路径构造的。 它根据传递给构造函数的类库的 .deps.json 文件,将程序集和本机库解析为相对于它们的 AssemblyDependencyResolver 路径。 通过自定义 AssemblyLoadContext ,插件可以具有自己的依赖项,并且 AssemblyDependencyResolver 可以轻松正确加载依赖项。

现在项目 AppWithPlugin 已具有 PluginLoadContext 类型,请使用以下内容更新 Program.LoadPlugin 方法:

static Assembly LoadPlugin(string relativePath)
{
    // Navigate up to the solution root
    string root = Path.GetFullPath(
        Path.Combine(typeof(Program).Assembly.Location, "..", "..", "..", "..", ".."));

    string pluginLocation = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', Path.DirectorySeparatorChar)));
    Console.WriteLine($"Loading commands from: {pluginLocation}");
    PluginLoadContext loadContext = new(pluginLocation);
    return loadContext.LoadFromAssemblyName(new(Path.GetFileNameWithoutExtension(pluginLocation)));
}

通过使用每个插件的不同 PluginLoadContext 实例,插件可以具有不同的甚至冲突的依赖项,而不会出现问题。

没有依赖项的简单插件

返回根文件夹,执行以下操作:

  1. 运行以下命令以创建一个名为 HelloPlugin 的新的类库项目:

    dotnet new classlib -o HelloPlugin
    
  2. 运行以下命令,将项目添加到 AppWithPlugin 解决方案:

    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. HelloPlugin/Class1.cs 文件替换为包含以下内容的名为 HelloCommand.cs 的文件:

using PluginBase;
using System;

namespace HelloPlugin
{
    public class HelloCommand : ICommand
    {
        public string Name { get => "hello"; }
        public string Description { get => "Displays hello message."; }

        public int Execute()
        {
            Console.WriteLine("Hello !!!");
            return 0;
        }
    }
}

现在,打开 HelloPlugin.csproj 文件。 它应如下所示:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

<PropertyGroup> 标签之间,添加以下元素:

  <EnableDynamicLoading>true</EnableDynamicLoading>

<EnableDynamicLoading>true</EnableDynamicLoading> 准备项目,以便将其用作插件。 除此之外,这会将所有依赖项复制到项目的输出。 有关详细信息,请参阅 EnableDynamicLoading

<Project>标记之间,添加以下元素:

<ItemGroup>
    <ProjectReference Include="..\PluginBase\PluginBase.csproj">
        <Private>false</Private>
        <ExcludeAssets>runtime</ExcludeAssets>
    </ProjectReference>
</ItemGroup>

元素 <Private>false</Private> 很重要。 这告知 MSBuild 不要 将PluginBase.dll 复制到 HelloPlugin 的输出目录。 如果输出目录中存在 PluginBase.dll 程序集, PluginLoadContext 将找到该程序集并在加载 HelloPlugin.dll 程序集时加载它。 此时,HelloPlugin.HelloCommand 类型将实现 ICommand 接口,该接口来自 HelloPlugin 项目的输出目录中的 PluginBase.dll,而不是加载到默认加载上下文中的 ICommand 接口。 由于运行时将这两种类型视为不同程序集的不同类型, AppWithPlugin.Program.CreateCommands 因此该方法找不到命令。 因此,引用包含插件接口的程序集需要 <Private>false</Private> 元数据。

同样,如果PluginBase引用其他包,那么<ExcludeAssets>runtime</ExcludeAssets>元素也很重要。 此设置的效果与<Private>false</Private>相同,但适用于项目或其依赖项可能包含的包引用PluginBase

项目 HelloPlugin 现在已完成,您应更新项目 AppWithPlugin,以便知道插件 HelloPlugin 的位置。 注释 // Paths to plugins to load 后,添加 @"HelloPlugin\bin\Debug\net10.0\HelloPlugin.dll" (此路径可能因使用的 .NET Core 版本而异)作为数组的 pluginPaths 元素。

具有库依赖项的插件

几乎所有插件都比简单的“Hello World”更为复杂,许多插件依赖于其他库。 JsonPluginOldJsonPlugin项目在示例中展示了两个含有 NuGet 包依赖项Newtonsoft.Json的插件示例。 因此,所有插件项目都应在项目属性中添加 <EnableDynamicLoading>true</EnableDynamicLoading>,以便将所有依赖项复制到 dotnet build 的输出中。 使用dotnet publish发布类库时,还将复制其所有依赖项到发布输出。

示例中的其他示例

可以在 dotnet/samples 存储库中找到本教程的完整源代码。 已完成的示例包括一些其他 AssemblyDependencyResolver 行为的示例。 例如,该 AssemblyDependencyResolver 对象还可以解析原生库以及 NuGet 包中包含的本地化附属程序集。 在示例库 UVPluginFrenchPlugin 中演示了这些场景。

引用来自 NuGet 包的插件接口

假设有一个应用 A,该应用在 NuGet 包中定义了名为 A.PluginBase 的插件接口。 如何在插件项目中正确引用包? 对于项目引用,使用项目文件中元素 ProjectReference 的元数据 <Private>false</Private> 可防止将 dll 复制到输出。

若要正确引用 A.PluginBase 包,需要将项目文件中的元素更改为以下 <PackageReference> 内容:

<PackageReference Include="A.PluginBase" Version="1.0.0">
    <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>

这可以防止 A.PluginBase 程序集被复制到插件的输出目录,并确保插件将使用 A 版本的 A.PluginBase

插件目标框架建议

由于插件依赖项加载使用 .deps.json 文件,因此存在与插件的目标框架相关的 gotcha。 具体而言,插件应面向运行时(如 .NET 10),而不是 .NET Standard 版本。 根据项目面向的框架生成 .deps.json 文件。由于许多与 .NET Standard 兼容的包提供用于构建的引用程序集和特定运行时的实现程序集,.deps.json 可能无法正确识别实现程序集,或者可能会获取程序集的 .NET Standard 版本,而不是您预期的 .NET Core 版本。

插件框架参考

目前,插件无法将新框架引入该过程。 例如,不能将使用该框架的 Microsoft.AspNetCore.App 插件加载到仅使用根 Microsoft.NETCore.App 框架的应用程序。 主机应用程序必须声明对插件所需的所有框架的引用。