В этом руководстве показано, как создать пользовательский модуль AssemblyLoadContext для загрузки плагинов. Для разрешения зависимостей плагина используется AssemblyDependencyResolver. В этом руководстве представлен отдельный контекст сборки для зависимостей подключаемого модуля, что позволяет различать зависимости сборки между подключаемыми модулями и приложением размещения. Вы узнаете, как:
- Структурируйте проект для поддержки подключаемых модулей.
- Создайте настраиваемую AssemblyLoadContext для загрузки каждого подключаемого модуля.
- Используйте тип System.Runtime.Loader.AssemblyDependencyResolver, чтобы подключаемые модули могли иметь зависимости.
- Создавайте плагины, которые можно легко развернуть, просто скопировав артефакты сборки.
Примечание
Ненадежный код не может быть безопасно загружен в доверенный процесс .NET. Чтобы обеспечить границу безопасности или надежности, рассмотрите технологию, предоставляемую вашей ОС или платформой виртуализации.
Первым шагом является создание приложения:
Создайте новую папку и в этой папке выполните следующую команду:
dotnet new console -o AppWithPlugin
Чтобы упростить создание проекта, создайте файл решения Visual Studio в той же папке. Выполните следующую команду:
dotnet new sln
Выполните следующую команду, чтобы добавить проект приложения в решение:
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();
}
if (args.Length == 0)
{
Console.WriteLine("Commands: ");
}
else
{
foreach (string commandName in args)
{
Console.WriteLine($"-- {commandName} --");
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
определен, проект приложения можно немного дополнить. Добавьте ссылку из проекта AppWithPlugin
в проект PluginBase
с помощью команды dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj
из корневой папки.
Замените // Load commands from plugins
комментарий следующим фрагментом кода, чтобы он мог загружать подключаемые модули из заданных путей к файлам:
string[] pluginPaths = new string[]
{
};
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.");
return;
}
command.Execute();
И, наконец, добавьте статические методы в Program
класс с именем LoadPlugin
и CreateCommands
, как показано ниже:
static Assembly LoadPlugin(string relativePath)
{
throw new NotImplementedException();
}
static IEnumerable<ICommand> CreateCommands(Assembly assembly)
{
int count = 0;
foreach (Type type in assembly.GetTypes())
{
if (typeof(ICommand).IsAssignableFrom(type))
{
ICommand result = Activator.CreateInstance(type) as ICommand;
if (result != null)
{
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}");
}
}
Теперь приложение может правильно загружать и создавать экземпляры команд из загруженных сборок подключаемого модуля, но по-прежнему не может загружать сами сборки подключаемого модуля. Создайте файл с именем PluginLoadContext.cs в папке AppWithPlugin со следующим содержимым:
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
может выбрать различные пути для загрузки сборок и переопределить поведение по умолчанию.
PluginLoadContext
использует экземпляр типа AssemblyDependencyResolver
, который был введен в .NET Core 3.0, для разрешения имен сборок в пути. Объект AssemblyDependencyResolver
создается с помощью пути к библиотеке классов .NET. Он разрешает сборки и нативные библиотеки до их относительных путей на основе файла .deps.json для библиотеки классов, путь которого был передан в конструктор AssemblyDependencyResolver
. Кастомизация AssemblyLoadContext
позволяет подключаемым модулям иметь собственные зависимости, а AssemblyDependencyResolver
облегчает правильную загрузку зависимостей.
Теперь, когда AppWithPlugin
проект имеет PluginLoadContext
тип, обновите Program.LoadPlugin
метод следующим текстом:
static Assembly LoadPlugin(string relativePath)
{
string root = Path.GetFullPath(Path.Combine(
Path.GetDirectoryName(
Path.GetDirectoryName(
Path.GetDirectoryName(
Path.GetDirectoryName(
Path.GetDirectoryName(typeof(Program).Assembly.Location)))))));
string pluginLocation = Path.GetFullPath(Path.Combine(root, relativePath.Replace('\\', Path.DirectorySeparatorChar)));
Console.WriteLine($"Loading commands from: {pluginLocation}");
PluginLoadContext loadContext = new PluginLoadContext(pluginLocation);
return loadContext.LoadFromAssemblyName(new AssemblyName(Path.GetFileNameWithoutExtension(pluginLocation)));
}
Используя другой PluginLoadContext
экземпляр для каждого подключаемого модуля, подключаемые модули могут иметь разные или даже конфликтующие зависимости без проблем.
Простой подключаемый модуль без зависимостей
Вернитесь в корневую папку, сделайте следующее:
Выполните следующую команду, чтобы создать проект библиотеки классов с именем HelloPlugin
:
dotnet new classlib -o HelloPlugin
Выполните следующую команду, чтобы добавить проект в AppWithPlugin
решение:
dotnet sln add HelloPlugin/HelloPlugin.csproj
Замените файл 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>net5.0</TargetFramework>
</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
интерфейс из PluginBase.dll в выходном каталоге проекта HelloPlugin
, а не ICommand
интерфейс, загружаемый в контекст загрузки по умолчанию. Так как среда выполнения видит эти два типа как разные типы от разных сборок, AppWithPlugin.Program.CreateCommands
метод не найдет команды. В результате метаданные <Private>false</Private>
необходимы для ссылки на сборку, содержащую интерфейсы подключаемого модуля.
Аналогичным образом элемент <ExcludeAssets>runtime</ExcludeAssets>
также важен, если PluginBase
ссылается на другие пакеты. Этот параметр оказывает такое же воздействие, как <Private>false</Private>
, но применяется к ссылкам на пакеты, которые проект PluginBase
или одна из его зависимостей может включать.
HelloPlugin
После завершения проекта необходимо обновить AppWithPlugin
проект, чтобы узнать, где находится подключаемый HelloPlugin
модуль.
// Paths to plugins to load
После комментария добавьте @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll"
(этот путь может отличаться в зависимости от используемой версии .NET Core) в качестве элемента массиваpluginPaths
.
Подключаемый модуль с зависимостями библиотеки
Почти все подключаемые модули являются более сложными, чем простой "Hello World", и многие подключаемые модули имеют зависимости от других библиотек. В примерах JsonPlugin
и OldJsonPlugin
показаны два подключаемых модуля с зависимостями на пакет NuGet Newtonsoft.Json
. Из-за этого все проекты подключаемых модулей должны добавить <EnableDynamicLoading>true</EnableDynamicLoading>
в свойства проекта, чтобы скопировать все свои зависимости в выходные данные dotnet build
. Публикация библиотеки классов с помощью dotnet publish
также будет копировать все её зависимости в выходной результат публикации.
Полный исходный код для этого руководства можно найти в репозитории dotnet/samples. Полный пример содержит несколько других примеров AssemblyDependencyResolver
поведения. Например, AssemblyDependencyResolver
объект также может разрешать использование собственных библиотек, а также локализованные сателлитные сборки, включенные в пакеты NuGet.
UVPlugin
и FrenchPlugin
в репозитории примеров демонстрируют эти сценарии.
Ссылка на интерфейс подключаемого модуля из пакета NuGet
Предположим, что есть приложение A с интерфейсом подключаемого модуля, определенным в пакете NuGet с именем A.PluginBase
. Как правильно ссылать на пакет в проекте подключаемого модуля? Для ссылок на проект использование метаданных <Private>false</Private>
на элементе ProjectReference
в файле проекта предотвратило копирование файла 'dll' в папку вывода.
Чтобы правильно ссылаться на A.PluginBase
пакет, необходимо изменить <PackageReference>
элемент в файле проекта следующим образом:
<PackageReference Include="A.PluginBase" Version="1.0.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
Это предотвращает копирование сборок A.PluginBase
в выходной каталог вашего подключаемого модуля и гарантирует, что ваш подключаемый модуль будет использовать версию A.PluginBase
от A.
Рекомендации по целевой платформе плагина
Так как загрузка зависимостей подключаемого модуля использует .deps.json файл, существует подводный камень, связанный с целевым фреймворком подключаемого модуля. В частности, ваши плагины должны быть ориентированы на среду выполнения, например .NET 5, а не на версию .NET Standard. Файл .deps.json создается на основе платформы целевых объектов проекта, и так как многие пакеты, совместимые с .NET Standard, отправляют эталонные сборки для сборки для .NET Standard и сборок реализации для конкретных сред выполнения, .deps.json может неправильно видеть сборки реализации, или она может получить версию сборки .NET Standard вместо ожидаемой версии .NET Core.
Ссылки на фреймворк подключаемых модулей
В настоящее время плагины не могут вводить новые фреймворки в процесс. Например, вы не можете загрузить подключаемый модуль, использующий Microsoft.AspNetCore.App
платформу, в приложение, которое использует только корневую Microsoft.NETCore.App
платформу. Ведущее приложение должно объявлять ссылки на все платформы, необходимые подключаемым модулям.