Прочитать на английском

Поделиться через


Создание приложения .NET Core с подключаемыми модулями

В этом руководстве показано, как создать пользовательский модуль AssemblyLoadContext для загрузки плагинов. Для разрешения зависимостей плагина используется AssemblyDependencyResolver. В этом руководстве представлен отдельный контекст сборки для зависимостей подключаемого модуля, что позволяет различать зависимости сборки между подключаемыми модулями и приложением размещения. Вы узнаете, как:

  • Структурируйте проект для поддержки подключаемых модулей.
  • Создайте настраиваемую AssemblyLoadContext для загрузки каждого подключаемого модуля.
  • Используйте тип System.Runtime.Loader.AssemblyDependencyResolver, чтобы подключаемые модули могли иметь зависимости.
  • Создавайте плагины, которые можно легко развернуть, просто скопировав артефакты сборки.

Примечание

Ненадежный код не может быть безопасно загружен в доверенный процесс .NET. Чтобы обеспечить границу безопасности или надежности, рассмотрите технологию, предоставляемую вашей ОС или платформой виртуализации.

Предпосылки

Создание приложения

Первым шагом является создание приложения:

  1. Создайте новую папку и в этой папке выполните следующую команду:

    Интерфейс командной строки.NET
    dotnet new console -o AppWithPlugin
    
  2. Чтобы упростить создание проекта, создайте файл решения Visual Studio в той же папке. Выполните следующую команду:

    Интерфейс командной строки.NET
    dotnet new sln
    
  3. Выполните следующую команду, чтобы добавить проект приложения в решение:

    Интерфейс командной строки.NET
    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

Теперь мы можем заполнить скелет нашего приложения. Замените код в файле AppWithPlugin/Program.cs следующим кодом:

C#
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} --");

                        // 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 со следующим определением интерфейса:

C#
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 комментарий следующим фрагментом кода, чтобы он мог загружать подключаемые модули из заданных путей к файлам:

C#
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 комментарий следующим фрагментом кода:

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

Замените // Execute the command with the name passed as an argument комментарий следующим фрагментом кода:

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

command.Execute();

И, наконец, добавьте статические методы в Program класс с именем LoadPlugin и CreateCommands, как показано ниже:

C#
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 со следующим содержимым:

C#
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 метод следующим текстом:

C#
static Assembly LoadPlugin(string relativePath)
{
    // Navigate up to the solution root
    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 экземпляр для каждого подключаемого модуля, подключаемые модули могут иметь разные или даже конфликтующие зависимости без проблем.

Простой подключаемый модуль без зависимостей

Вернитесь в корневую папку, сделайте следующее:

  1. Выполните следующую команду, чтобы создать проект библиотеки классов с именем HelloPlugin:

    Интерфейс командной строки.NET
    dotnet new classlib -o HelloPlugin
    
  2. Выполните следующую команду, чтобы добавить проект в AppWithPlugin решение:

    Интерфейс командной строки.NET
    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. Замените файл HelloPlugin/Class1.cs файлом с именем HelloCommand.cs следующим содержимым:

C#
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 . Он должен выглядеть примерно так:

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>
  </PropertyGroup>

</Project>

В между <PropertyGroup> тегами добавьте следующий элемент:

XML
  <EnableDynamicLoading>true</EnableDynamicLoading>

Подготавливает проект <EnableDynamicLoading>true</EnableDynamicLoading>, чтобы использовать его как плагин. Помимо прочего, это скопирует все его зависимости в выходные данные проекта. Дополнительные сведения см. в EnableDynamicLoading.

В между <Project> тегами добавьте следующие элементы:

XML
<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> элемент в файле проекта следующим образом:

XML
<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 платформу. Ведущее приложение должно объявлять ссылки на все платформы, необходимые подключаемым модулям.


Дополнительные ресурсы

Обучение

Модуль

Создание проекта .NET и работа с зависимостями пакетов - Training

Создайте проект .NET и узнайте, как добавлять пакеты и управлять зависимостями пакетов в проекте. Используйте интерфейс командной строки .NET Core и реестр NuGet для добавления библиотек и инструментов в приложения C# с помощью Visual Studio Code.