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

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

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

Prerequisites

Примечание

Пример кода предназначен для .NET 5, но все функции, которые он использует, появились в .NET Core 3.0 и доступны во всех выпусках .NET, начиная с той версии.

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

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

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

                        // 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 определен, в проект приложения можно добавить другие элементы. Добавьте ссылку из проекта в AppWithPluginPluginBase проект с dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj помощью команды из корневой папки.

Замените комментарий // 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.");
    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)
{
    // 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.

    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>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 для A.PluginBase.

Рекомендации для целевой платформы подключаемого модуля

Так как при загрузке зависимости подключаемого модуля используется файл .deps.json, есть один нюанс с целевой платформой подключаемого модуля. В частности, подключаемые модули должны быть нацелены на среду выполнения, такую как .NET 5, а не на версию .NET Standard. Файл .deps.json создается с учетом целевой платформы проекта. Так как многие пакеты, совместимые с .NET Standard, ссылаются на сборки для .NET Standard и сборки реализации для конкретных сред выполнения, файл .deps.json может неправильно распознавать сборки реализации или принимать версию сборки .NET Standard вместо ожидаемой версии .NET Core.

Ссылки на платформу подключаемого модуля

Сейчас подключаемые модули не могут внедрять новые платформы в процесс. Например, нельзя будет загрузить подключаемый модуль, который использует платформу Microsoft.AspNetCore.App, в приложение, которое использует только корневую платформу Microsoft.NETCore.App. Ведущее приложение должно объявлять ссылки на все платформы, необходимые подключаемым модулям.