将插件创建和安装为 Nuget 包





5.00/5 (8投票s)
介绍如何将动态加载的插件安装为 nuget 包
引言
软件架构师最重要的任务
软件架构师最重要的软件开发任务如下:
- 创建和维护插件基础设施,允许独立开发人员几乎独立地创建、测试、调试和扩展插件。插件基础设施应负责:
- 托管插件
- 安排、显示、保存和恢复可视化插件的布局
- 允许轻松模拟尚未准备好的插件
- 查找和提取可在多个地方重用的代码和概念。
当然,架构师还需要收集需求、估算实现功能所需的时间、与客户互动、选择软件和测试框架等等,但以上内容仅涉及“软件开发”任务。
最小插件接口和插件框架
在由重构的 NP.IoCy 和 AutofacAdapter 容器实现的通用最小控制反转/依赖注入接口中,我介绍了 IoC(插件)容器的最小接口,以及它们作为NP.IoCy 插件框架的实现,还有AutofacAdapter - 一个建立在 Autofac 之上的实现。
用于可视化插件的多平台实现 - Gidon - 基于 Avalonia 的 MVVM 插件 IoC 容器仍在进行中,完成后将允许:
- 托管 Python Shell 和可视化页面
- 托管 C# 脚本 Shell 和可视化页面
- 托管网页
所有这些都将在多个桌面平台(Windows、Linux 和 Mac)上运行。
安装插件
如何将插件安装到应用程序中存在一个问题。
事实证明,每个插件都可以打包成一个特殊的 nuget 包,然后作为 nuget 包安装,并在安装时进行一些简单的特殊处理。
插件包的创建和安装应遵循以下原则:
- 插件 NuGet 应包含所有 DLL - 主插件 DLL 及其依赖项(包括 nuget 依赖项)。
- 主项目不应依赖插件 DLL - 所有插件 DLL 都应动态加载。
Nuget 文档(或缺乏文档)
正如你们中的许多人可能已经了解到的,很难找到有关将文件打包和解包为 nuget 包的信息,无论是使用 nuget 命令还是 csproj 文件和 MSBuild 命令。
在我尝试过的许多网页中,只有两个真正有用:
- 使用 MSBuild 改进 .NET 构建设置的技巧与窍门,附带位于rider-msbuild-webinar-2020 的 Github 示例。我没有全部观看 - 只看了第 6 部分。
- 如何从 MSBuild 查找 NuGet 包路径,解释了如何将 nuget 包内的路径转换为 csproj 变量,然后使用该路径从包中复制文件到您选择的磁盘文件夹。
MSBuild 和 csproj 的新版本匹配或超过了 nuget 工具的所有功能,因此,我将我的所有包(包括插件包)迁移到无需 nuget 和 nuspec 文件即可构建,只需在 Visual Studio 中构建相应的 C# 项目即可。
代码位置
示例代码位于NP.Samples 存储库下的PluginPackageSamples 文件夹中。
打包/解包示例
我们的打包/解包示例基于“通用最小控制反转/依赖注入接口,由重构的 NP.IoCy 和 AutofacAdapter 容器实现”文章中的多插件测试。
插件功能简要说明
插件是可以在框架中动态加载的软件组件。插件不应该相互依赖,也不应该依赖插件框架,而是可以依赖一组通用接口并通过这些接口进行通信。这种插件基础设施将增加关注点分离,并允许插件几乎独立于彼此和插件框架进行开发、调试和扩展。
这里,我们仅对测试插件的功能做简要说明。有关插件实现的完整说明,请参阅多插件测试链接。
涉及两个非常简单的插件:
DoubleManipulationPlugin
- 提供两个用于操作doubles
的方法:Plus(...)
用于对两个数字求和,Times(...)
用于对两个数字求积。StringManipulationPlugins
也提供两个用于string
操作的方法:Concat(...)
- 用于连接两个string
,Repeat(...)
用于将string
重复多次。
这两个插件不相互依赖,主项目也不依赖它们。相反,这两个插件和主项目都依赖于包含两个接口的 PluginInterfaces
项目,每个接口对应一个插件。
public interface IDoubleManipulationsPlugin
{
double Plus(double number1, double number2);
double Times(double number1, double number2);
}
public interface IStringManipulationsPlugin
{
string Concat(string str1, string str2);
string Repeat(string str, int numberTimesToRepeat);
}
这些接口定义在位于PluginInterfaces 文件夹内的通用项目 NP.PackagePluginsTest.PluginInterfaces
中。
打包示例
解决方案 PackagePluginsTest.sln(创建插件为 nuget 包)位于 PluginPackageSamples\PackagePlugins 文件夹下。它包含三个项目:
NP.PackagesPluginsTest.DoubleManipulationsPlugin
用于创建NP.PackagesPluginsTest.DoubleManipulationsPlugin.nupkg
包NP.PackagesPluginsTest.StringManipulationsPlugin
用于创建NP.PackagesPluginsTest.StringManipulationsPlugin.nupkg
包NP.PackagePluginsTest.PluginInterfaces
,其引用在上述项目之间共享。
让我们看看 NP.PackagesPluginsTest.DoubleManipulationsPlugin
项目(另一个项目非常相似,只是它的方法不同,并且涉及操作 string
而不是 double
)。
DoubleManipulationsPlugin
通过定义两个方法 double Plus(double number1, double number2)
和 double Times(double number1, double number2)
来实现 IDoubleManipulationsPlugin
接口。
该类标记有 RegisterTypeAttribute
,以便 NP.IoCy
框架知道如何读取它并在其容器中注册它。
[RegisterType]
public class DoubleManipulationsPlugin : IDoubleManipulationsPlugin
{
// sums two numbers
public double Plus(double number1, double number2)
{
return number1 + number2;
}
// multiplies two numbers
public double Times(double number1, double number2)
{
return number1 * number2;
}
}
现在看看该项目的 csproj 文件 - NP.PackagePluginsTest.DoubleManipulationsPlugin.csproj。
在顶部的 <PropertyTag>
标签内,我们添加了一些 nuget 包属性(包括版本 - 1.0.4,版权 - Nick Polyak 2023,PackageLicenseExpression - MIT)。
那里定义的最重要的属性是:
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
- 此属性会将所有 DLL 文件(包括来自依赖的 nuget 包的文件)复制到输出文件夹,成为包的一部分。<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
- 此属性会在每次构建项目时创建 nuget 包。创建的 .nupkg 文件将位于目标文件夹上方一个文件夹(例如,如果目标文件夹在项目文件夹下的 bin/Debug/net6.0 内,则 .nupkg 文件将在 bin/Debug 文件夹内)。
在 PropertyGroup
之后,有两个引用 - 一个 PackageReference
- 指向 NP.DependencyInjection
项目以获取 IoC 属性,另一个是项目引用,指向前面提到的 NP.PackagePluginsTest.PluginInterfaces
项目以获取我们实现的接口。
在文件末尾 - 有两个 Target
标签:
<Target Name="ClearTarget" BeforeTargets="Build">
<RemoveDir Directories="$(TargetDir)\**" />
</Target>
<Target Name="IncludeAllFilesInTargetDir" AfterTargets="Build">
<ItemGroup>
<Content Include="$(TargetDir)\**">
<Pack>true</Pack>
<PackagePath>Content</PackagePath>
</Content>
</ItemGroup>
</Target>
第一个目标在构建之前触发(BeforeTargets="Build"
),并从 $(TargetDir)
中删除所有文件或子文件夹。
第二个目标在构建之后触发。它通过将目标目录中的所有文件和子文件夹包含在 Nuget 包的Content 文件夹中来创建 nuget 包。
使用 NuGetPackageExplorer
查看结果插件的内容如下:
所有文件都包含在 nuget 包的Content 文件夹下,只有一个 - NP.PackagePluginsTest.DoubleManipulationPlugin.dll 包含在通常的位置 - lib/net6.0。我不知道如何摆脱最后一个文件,但如果我知道,消费端的更改会更简单一些(但不是根本性的)。
创建两个 nuget 包文件后 - 您必须将它们上传到 nuget.org 或其他 nuget 服务器(例如本地服务器)。我已经将两个插件文件 NP.PackagePluginsTest.DoubleManipulationsPlugin.1.0.4.nupkg 和 NP.PackagePluginsTest.StringManipulationsPlugin.1.0.4.nupkg 上传到 nuget.org 服务器。所以您不必这样做 - 您可以直接使用我已经在 nuget.org 上提供的文件。
包消费示例
展示如何从已上传的 nuget 文件创建插件的示例位于 PluginPackageSamples\PluginConsumer\PluginConsumer.sln 解决方案中。该解决方案有两个项目:
PluginsConsumer
- 主项目,它下载 nuget 包,从中创建插件并使用它们为接口提供实现。NP.PackagePluginsTest.PluginInterfaces
- 包含通用接口的项目。
Program.cs 文件的内容与上一篇文章的多插件测试部分描述的主文件内容几乎相同。因此,我将只描述主程序的开头,即动态加载插件、创建 IoC 容器以及从 IoC 容器解析 doublemanipulatesPlugin
的部分。
// create container builder
IContainerBuilder<string?> builder = new ContainerBuilder<string?>();
// load plugins dynamically from sub-folders of Plugins folder
// located under the same folder as the executable
builder.RegisterPluginsFromSubFolders("Plugins");
// build the container
IDependencyInjectionContainer<string?> container = builder.Build();
// get the plugin for manipulating double numbers
IDoubleManipulationsPlugin doubleManipulationsPlugin =
container.Resolve<IDoubleManipulationsPlugin>();
// get the result of 4 * 5
double timesResult =
doubleManipulationsPlugin.Times(4.0, 5.0);
// check that 4 * 5 == 20
timesResult.Should().Be(20.0);
这里的关键行是 builder.RegisterPluginsFromSubFolders("Plugins");
,它尝试从 $(TargetDir)/Plugins
的所有子文件夹中动态加载插件。
最有趣的代码在 csproj 文件 PluginsConsumer.csproj 中。此文件包含一个 ItemGroup
,其中包含对所有包的包引用,包括插件包。
<ItemGroup>
...
<PackageReference Include="NP.PackagePluginsTest.DoubleManipulationsPlugin"
Version="1.0.4" GeneratePathProperty="true">
<ExcludeAssets>All</ExcludeAssets>
</PackageReference>
<PackageReference Include="NP.PackagePluginsTest.StringManipulationsPlugin"
Version="1.0.4" GeneratePathProperty="true">
<ExcludeAssets>All</ExcludeAssets>
</PackageReference>
</ItemGroup>
请注意,两个包都排除了所有资产。这样做是为了让主程序不静态依赖 NP.PackagePluginsTests.DoubleManipulationsPlugin.dll 和 NP.PackagePluginsTests.StringManipulationsPlugin.dll 文件,并使它们(以及其余的 DLL 程序集)能够动态加载。
另请注意,对插件的两个 PackageReference
都具有 GeneratePathProperty="true"
属性。此属性会自动生成一个变量,该变量设置为插件根目录的路径。变量名始终以 "Pkg
" 开头,并且包名称中的每个句点都替换为下划线 "_
"。例如,NP.PackagePluginsTest.DoubleManipulationPlugin 根文件夹的变量名将是 PkgNP_PackagePluginsTest_DoubleManipulationPlugin。
这些变量名我们在下一个 ItemGroup
中使用,其中我们设置了所有需要从包中复制的文件和子文件夹。
<ItemGroup> <!-- setting up the variable for convenience -->
<DoubleManipPluginPackageFiles
Include="$(PkgNP_PackagePluginsTest_DoubleManipulationsPlugin)\Content\**\*.*" />
<StringManipPluginPackageFiles
Include="$(PkgNP_PackagePluginsTest_StringManipulationsPlugin)\Content\**\*.*" />
</ItemGroup>
现在我们来到 build
后将文件从 <nuget_package_root>\Content 复制到 $(TargetDir)/Plugins/<PluginName> 文件夹的 Target。
<Target Name="CopyPluginsFromNugetPackages" AfterTargets="Build">
<PropertyGroup>
<DoublePluginFolder>$(TargetDir)\Plugins\DoubleManipulationPlugin
</DoublePluginFolder>
<StringPluginFolder>$(TargetDir)\Plugins\StringManipulationPlugin
</StringPluginFolder>
</PropertyGroup>
<RemoveDir Directories="$(DoublePluginFolder)" />
<Copy SourceFiles="@(DoubleManipPluginPackageFiles)"
DestinationFolder="$(DoublePluginFolder)%(RecursiveDir)" />
<RemoveDir Directories="$(StringPluginFolder)" />
<Copy SourceFiles="@(StringManipPluginPackageFiles)"
DestinationFolder="$(StringPluginFolder)%(RecursiveDir)" />
</Target>
在 Target
标签的顶部,我们定义了变量 DoublePluginFolder
和 StringPluginFolder
,它们将在同一 Target 中稍后在多个地方使用。
<PropertyGroup>
<DoublePluginFolder>$(TargetDir)\Plugins\DoubleManipulationPlugin
</DoublePluginFolder>
<StringPluginFolder>$(TargetDir)\Plugins\StringManipulationPlugin
</StringPluginFolder>
</PropertyGroup>
然后,对于每个插件,我们:
- 首先 - 删除 plugin 文件夹,例如
<RemoveDir Directories="$(DoublePluginFolder)" />
- 然后 - 将文件从 nuget 包复制到 Plugin 文件夹,例如:
<Copy SourceFiles="@(DoubleManipPluginPackageFiles)" DestinationFolder="$(DoublePluginFolder)%(RecursiveDir)" />
路径末尾的 %(RecursiveDir)
允许我们保留插件 nuget 包文件内的相同文件夹结构。有时这很重要,例如当包中包含一个具有对应每个原生平台的多个子文件夹的 runtime
子文件夹时。
现在您应该能够构建 PluginConsumer
项目并运行它。构建完成后,请验证 bin\Debug\net6.0 文件夹内有一个 Plugins 文件夹,并且该文件夹包含两个子文件夹 DoublePluginFolder 和 StringPluginFolder,每个文件夹都填充了相应的插件文件。无错误地运行项目将证明容器确实加载了那些动态插件,并且它们能够正确解析到相应的接口,并且所有插件功能都能正常工作。
请注意,当您通过 Visual Studio 升级相应插件的版本时,添加到 csproj 文件中的所有与插件相关的添加都会保留。您需要编辑 csproj 文件的情况仅在添加或删除插件时发生。
仔细阅读代码的人可能会注意到,在 Program.cs 文件的末尾,我正在测试一些神秘的 "MethodNames
"
var methodNames = container.Resolve<IEnumerable<string>>("MethodNames");
methodNames.Count().Should().Be(4);
methodNames.Should().Contain(nameof(IDoubleManipulationsPlugin.Plus));
methodNames.Should().Contain(nameof(IDoubleManipulationsPlugin.Times));
methodNames.Should().Contain(nameof(IStringManipulationsPlugin.Concat));
methodNames.Should().Contain(nameof(IStringManipulationsPlugin.Repeat));
对于那些对此感兴趣的人,请参阅上一篇文章中的 MultiCells
- Multi-Cells 和 带 Multi-Cells 的插件。
结论
本文讨论了将插件创建和安装为 nuget 包。这使得我们可以在很大程度上依赖 Visual Studio 中嵌入的 MSBuild 功能来创建和安装插件。本质上,我们不需要创建任何(或几乎任何)特殊的安装机制来创建和安装插件。
我计划在未来的文章中将这种创建和安装插件的方法用于向 Google RPC 服务器添加插件。
历史
- 2023 年 1 月 17 日:初始版本