使用插件创建 .NET Core 应用程序Create a .NET Core application with plugins

本文内容

本教程介绍了如何:

  • 构建支持插件的项目。
  • 创建自定义 AssemblyLoadContext 加载每个插件。
  • 使用 System.Runtime.Loader.AssemblyDependencyResolver 类型允许插件具有依赖项。
  • 只需复制生成项目就可以轻松部署的作者插件。

系统必备Prerequisites

创建应用程序Create the application

第一步是创建应用程序:

  • 创建新文件夹,并在该文件夹中运行以下命令:
  1. dotnet new console -o AppWithPlugin
  • 为了更容易生成项目,请创建一个 Visual Studio 解决方案文件。在同一文件夹中运行以下命令:
  1. dotnet new sln
  • 运行以下命令,向解决方案添加应用项目:
  1. dotnet sln add AppWithPlugin/AppWithPlugin.csproj

现在,我们可以填写应用程序的主干。使用下面的代码替换 AppWithPlugin/Program.cs 文件中的代码:

  1. using PluginBase;
  2. using System;
  3. using System.Collections.Generic;
  4. using System.IO;
  5. using System.Linq;
  6. using System.Reflection;
  7. namespace AppWithPlugin
  8. {
  9. class Program
  10. {
  11. static void Main(string[] args)
  12. {
  13. try
  14. {
  15. if (args.Length == 1 && args[0] == "/d")
  16. {
  17. Console.WriteLine("Waiting for any key...");
  18. Console.ReadLine();
  19. }
  20. // Load commands from plugins.
  21. if (args.Length == 0)
  22. {
  23. Console.WriteLine("Commands: ");
  24. // Output the loaded commands.
  25. }
  26. else
  27. {
  28. foreach (string commandName in args)
  29. {
  30. Console.WriteLine($"-- {commandName} --");
  31. // Execute the command with the name passed as an argument.
  32. Console.WriteLine();
  33. }
  34. }
  35. }
  36. catch (Exception ex)
  37. {
  38. Console.WriteLine(ex);
  39. }
  40. }
  41. }
  42. }

创建插件接口Create the plugin interfaces

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

在项目的根文件夹中,运行 dotnet new classlib -o PluginBase并运行 dotnet sln add PluginBase/PluginBase.csproj 向解决方案文件添加项目。删除 PluginBase/Class1.cs 文件,并使用以下接口定义在名为 ICommand.csPluginBase 文件夹中创建新的文件:

  1. namespace PluginBase
  2. {
  3. public interface ICommand
  4. {
  5. string Name { get; }
  6. string Description { get; }
  7. int Execute();
  8. }
  9. }

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

由于已定义 ICommand 接口,所以应用程序项目可以填写更多内容。使用根文件夹中的 dotnet add AppWithPlugin\AppWithPlugin.csproj reference PluginBase\PluginBase.csproj 命令将引用从 AppWithPlugin 项目添加到 PluginBase 项目。

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

  1. string[] pluginPaths = new string[]
  2. {
  3. // Paths to plugins to load.
  4. };
  5. IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
  6. {
  7. Assembly pluginAssembly = LoadPlugin(pluginPath);
  8. return CreateCommands(pluginAssembly);
  9. }).ToList();

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

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

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

  1. ICommand command = commands.FirstOrDefault(c => c.Name == commandName);
  2. if (command == null)
  3. {
  4. Console.WriteLine("No such command is known.");
  5. return;
  6. }
  7. command.Execute();

最后,将静态方法添加到名为 LoadPluginCreateCommandsProgram 类,如下所示:

  1. static Assembly LoadPlugin(string relativePath)
  2. {
  3. throw new NotImplementedException();
  4. }
  5. static IEnumerable<ICommand> CreateCommands(Assembly assembly)
  6. {
  7. int count = 0;
  8. foreach (Type type in assembly.GetTypes())
  9. {
  10. if (typeof(ICommand).IsAssignableFrom(type))
  11. {
  12. ICommand result = Activator.CreateInstance(type) as ICommand;
  13. if (result != null)
  14. {
  15. count++;
  16. yield return result;
  17. }
  18. }
  19. }
  20. if (count == 0)
  21. {
  22. string availableTypes = string.Join(",", assembly.GetTypes().Select(t => t.FullName));
  23. throw new ApplicationException(
  24. $"Can't find any type which implements ICommand in {assembly} from {assembly.Location}.\n" +
  25. $"Available types: {availableTypes}");
  26. }
  27. }

加载插件Load plugins

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

  1. using System;
  2. using System.Reflection;
  3. using System.Runtime.Loader;
  4. namespace AppWithPlugin
  5. {
  6. class PluginLoadContext : AssemblyLoadContext
  7. {
  8. private AssemblyDependencyResolver _resolver;
  9. public PluginLoadContext(string pluginPath)
  10. {
  11. _resolver = new AssemblyDependencyResolver(pluginPath);
  12. }
  13. protected override Assembly Load(AssemblyName assemblyName)
  14. {
  15. string assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
  16. if (assemblyPath != null)
  17. {
  18. return LoadFromAssemblyPath(assemblyPath);
  19. }
  20. return null;
  21. }
  22. protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
  23. {
  24. string libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
  25. if (libraryPath != null)
  26. {
  27. return LoadUnmanagedDllFromPath(libraryPath);
  28. }
  29. return IntPtr.Zero;
  30. }
  31. }
  32. }

PluginLoadContext 类型派生自 AssemblyLoadContextAssemblyLoadContext 类型是运行时中的特殊类型,该类型允许开发人员将已加载的程序集隔离到不同的组中,以确保程序集版本不冲突。此外,自定义 AssemblyLoadContext 可以选择不同路径来加载程序集格式并重写默认行为。PluginLoadContext 使用 .NET Core 3.0 中引入的 AssemblyDependencyResolver 类型的实例将程序集名称解析为路径。AssemblyDependencyResolver 对象是使用 .NET 类库的路径构造的。它根据类库的 .deps.json 文件(其路径传递给 AssemblyDependencyResolver 构造函数)将程序集和本机库解析为它们的相对路径 。自定义 AssemblyLoadContext 使插件能够拥有自己的依赖项,AssemblyDependencyResolver 使正确加载依赖项变得容易。

由于 AppWithPlugin 项目具有 PluginLoadContext 类型,所以请使用以下正文更新 Program.LoadPlugin 方法:

  1. static Assembly LoadPlugin(string relativePath)
  2. {
  3. // Navigate up to the solution root
  4. string root = Path.GetFullPath(Path.Combine(
  5. Path.GetDirectoryName(
  6. Path.GetDirectoryName(
  7. Path.GetDirectoryName(
  8. Path.GetDirectoryName(
  9. Path.GetDirectoryName(typeof(Program).Assembly.Location)))))));
  10. string pluginLocation = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', Path.DirectorySeparatorChar)));
  11. Console.WriteLine($"Loading commands from: {pluginLocation}");
  12. PluginLoadContext loadContext = new PluginLoadContext(pluginLocation);
  13. return loadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation)));
  14. }

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

创建不具有依赖项的简单插件Create a simple plugin with no dependencies

返回到根文件夹,执行以下步骤:

  • 运行以下命令,新建一个名为 HelloPlugin 的类库项目:
  1. dotnet new classlib -o HelloPlugin
  • 运行以下命令,将项目添加到 AppWithPlugin 解决方案中:
  1. dotnet sln add HelloPlugin/HelloPlugin.csproj
  • 使用以下内容将 HelloPlugin/Class1.cs 文件替换为名为 HelloCommand.cs 的文件:
  1. using PluginBase;
  2. using System;
  3. namespace HelloPlugin
  4. {
  5. public class HelloCommand : ICommand
  6. {
  7. public string Name { get => "hello"; }
  8. public string Description { get => "Displays hello message."; }
  9. public int Execute()
  10. {
  11. Console.WriteLine("Hello !!!");
  12. return 0;
  13. }
  14. }
  15. }

现在,打开 HelloPlugin.csproj 文件 。它应类似于以下内容:

  1. <Project Sdk="Microsoft.NET.Sdk">
  2. <PropertyGroup>
  3. <TargetFramework>netcoreapp3.0</TargetFramework>
  4. </PropertyGroup>
  5. </Project>

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

  1. <ItemGroup>
  2. <ProjectReference Include="..\PluginBase\PluginBase.csproj">
  3. <Private>false</Private>
  4. </ProjectReference>
  5. </ItemGroup>

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

因为 HelloPlugin 项目已完成,所以我们应该更新 AppWithPlugin 项目,以确认可以找到 HelloPlugin 插件的位置。// Paths to plugins to load 注释之后,添加 @"HelloPlugin\bin\Debug\netcoreapp3.0\HelloPlugin.dll" 作为 pluginPaths 数组的元素。

创建具有库依赖项的插件Create a plugin with library dependencies

几乎所有插件都比简单的“Hello World”更复杂,而且许多插件都具有其他库上的依赖项。示例中的 JsonPluginOldJson 插件项目显示了具有 Newtonsoft.Json 上的 NuGet 包依赖项的两个插件示例。项目文件本身没有关于项目引用的任何特殊信息,并且(在将插件路径添加到 pluginPaths 数组之后)即使是在 AppWithPlugin 应用的同一执行中运行,插件也能完美运行。但是,这些项目不会将引用的程序集复制到它们的输出目录中,因此需要将这些程序集呈递到用户的计算机上,以便插件能够正常工作。解决此问题有两种方法。第一种是使用 dotnet publish 命令发布类库。或者,如果希望能够将 dotnet build 的输出用于插件,可以在插件的项目文件中的 <PropertyGroup> 标记之间添加 <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> 属性。有关示例,请参阅 XcopyablePlugin 插件项目。

示例中的其他插件示例Other plugin examples in the sample

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

如何引用 NuGet 包中定义的插件接口程序集How to reference a plugin interface assembly defined in a NuGet package

假设存在应用 A,它具有 NuGet 包(名为 A.PluginBase)中定义的插件接口。如何在插件项目中正确引用包?对于项目引用,使用项目文件的 ProjectReference 元素上的 <Private>false</Private> 元数据会阻止将 dll 复制到输出。

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

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

此操作会阻止将 A.PluginBase 程序集复制到插件的输出目录,并确保插件将使用 A 版本的 A.PluginBase

插件目标框架建议Plugin target framework recommendations

因为插件依赖项加载使用 .deps.json 文件,所以存在一个与插件的目标框架相关的问题 。具体来说,插件应该以运行时为目标,比如 .NET Core 3.0,而不是某一版本的 .NET Standard。.deps.json 文件基于项目所针对的框架生成,而且由于许多与 .NET Standard 兼容的包提供了用于针对 .NET Standard 进行生成的引用程序集和用于特定运行时的实现程序集,因此 .deps.json 可能无法正确查看实现程序集,或者它可能会获取 .NET Standard 版本的程序集,而不是期望的 .NET Core 版本的程序集。