Créer une application .NET Core avec des plug-ins

Ce tutoriel vous montre comment créer un plug-in personnalisé AssemblyLoadContext pour charger des plug-ins. Le AssemblyDependencyResolver est utilisé pour résoudre les dépendances du plug-in. Le tutoriel isole correctement les dépendances du plug-in de l’application d’hébergement. Vous découvrirez comment effectuer les actions suivantes :

  • Structurer un projet de façon à prendre en charge les plug-ins.
  • Créer un AssemblyLoadContext personnalisé pour charger chaque plug-in.
  • Utiliser le type System.Runtime.Loader.AssemblyDependencyResolver pour permettre aux plug-ins d’avoir des dépendances.
  • Créer des plug-ins qui peuvent être facilement déployés en copiant simplement les artefacts de build.

Prérequis

Notes

L’exemple de code cible .NET 5, mais toutes les fonctionnalités qu’il utilise ont été introduites dans .NET Core 3.0 et sont disponibles dans toutes les versions .NET depuis.

Création de l'application

La première étape consiste à créer l’application :

  1. Créez un dossier et, dans ce dossier, exécutez la commande suivante :

    dotnet new console -o AppWithPlugin
    
  2. Pour faciliter la création du projet, créez un fichier de solution Visual Studio dans le même dossier. Exécutez la commande suivante :

    dotnet new sln
    
  3. Exécutez la commande suivante pour ajouter le projet d’application à la solution :

    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

Vous pouvez maintenant compléter la structure de votre application. Remplacez le code figurant dans le fichier AppWithPlugin/Program.cs par le code suivant :

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

Créer les interfaces de plug-in

La prochaine étape de la procédure de création d’une application avec plug-ins consiste à définir l’interface que doivent implémenter les plug-ins. Nous vous suggérons de créer une bibliothèque de classes qui contient les types que vous prévoyez d’utiliser pour permettre la communication entre votre application et les plug-ins. Cette division vous permet de publier votre interface de plug-in en tant que package sans avoir à transmettre votre application complète.

Dans le dossier racine du projet, exécutez dotnet new classlib -o PluginBase. De même, exécutez dotnet sln add PluginBase/PluginBase.csproj pour ajouter le projet au fichier solution. Supprimez le fichier PluginBase/Class1.cs et créez-en un nouveau dans le dossier PluginBase sous le nom ICommand.cs avec la définition d’interface suivante :

namespace PluginBase
{
    public interface ICommand
    {
        string Name { get; }
        string Description { get; }

        int Execute();
    }
}

Cette interface ICommand est l’interface que tous les plug-ins vont implémenter.

Maintenant que l’interface ICommand est définie, le projet d’application peut être davantage complété. Ajoutez une référence du projet AppWithPlugin au projet PluginBase avec la commande dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj à partir du dossier racine.

Remplacez le commentaire // Load commands from plugins par l’extrait de code suivant pour lui permettre de charger les plug-ins à partir des chemins de fichiers donnés :

string[] pluginPaths = new string[]
{
    // Paths to plugins to load.
};

IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
{
    Assembly pluginAssembly = LoadPlugin(pluginPath);
    return CreateCommands(pluginAssembly);
}).ToList();

Remplacez ensuite le commentaire // Output the loaded commands par l’extrait de code suivant :

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

Remplacez le commentaire // Execute the command with the name passed as an argument par l’extrait suivant :

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

command.Execute();

Et enfin, ajoutez à la classe Program les méthodes statiques nommées LoadPlugin et CreateCommands, comme indiqué ici :

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

Charger les plug-ins

L’application parvient maintenant à charger et instancier les commandes des assemblys de plug-in chargés, mais elle ne peut toujours pas charger les assemblys de plug-in. Créez un fichier nommé PluginLoadContext.cs dans le dossier AppWithPlugin avec le contenu suivant :

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

Le type PluginLoadContext dérive de AssemblyLoadContext. Le type AssemblyLoadContext est un type spécial dans le runtime qui permet aux développeurs d’isoler les assemblys chargés dans des groupes distincts pour éviter les conflits entre les différentes versions d’assembly. Par ailleurs, un AssemblyLoadContext personnalisé peut choisir les différents chemins à partir desquels les assemblys seront chargés et substituer le comportement par défaut. PluginLoadContext utilise une instance du type AssemblyDependencyResolver introduit dans .NET Core 3.0 pour résoudre les noms d’assembly en chemins. L’objet AssemblyDependencyResolver est construit avec le chemin d’une bibliothèque de classes .NET. Il résout les assemblys et les bibliothèques natives dans leurs chemins relatifs en se basant sur le fichier .deps.json de la bibliothèque de classes dont le chemin a été transmis au constructeur AssemblyDependencyResolver. Le AssemblyLoadContext personnalisé permet aux plug-ins d’avoir leurs propres dépendances, tandis que AssemblyDependencyResolver facilite le chargement des dépendances.

Maintenant que le projet AppWithPlugin présente le type PluginLoadContext, mettez à jour la méthode Program.LoadPlugin avec le corps suivant :

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

En utilisant une instance PluginLoadContext différente pour chaque plug-in, les plug-ins peuvent avoir différentes dépendances, voire des dépendances en conflit sans que cela ne pose problème.

Plug-in simple sans dépendances

De retour dans le dossier racine, effectuez les opérations suivantes :

  1. Exécutez la commande suivante pour créer un projet de bibliothèque de classes nommé HelloPlugin :

    dotnet new classlib -o HelloPlugin
    
  2. Exécutez la commande suivante pour ajouter le projet d’application à la solution AppWithPlugin :

    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. Remplacez le fichier HelloPlugin/Class1.cs par un fichier nommé HelloCommand.cs avec le contenu suivant :

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

Ouvrez maintenant le fichier HelloPlugin.csproj. Celui-ci doit se présenter comme suit :

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

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

</Project>

Entre les balises <PropertyGroup>, ajoutez l’élément suivant :

  <EnableDynamicLoading>true</EnableDynamicLoading>

Le projet <EnableDynamicLoading>true</EnableDynamicLoading> prépare le projet afin qu’il puisse être utilisé comme plug-in. Entre autres choses, cela copie toutes ses dépendances dans la sortie du projet. Pour plus d’informations, consultez EnableDynamicLoading.

Entre les balises <Project>, ajoutez les éléments suivants :

<ItemGroup>
    <ProjectReference Include="..\PluginBase\PluginBase.csproj">
        <Private>false</Private>
        <ExcludeAssets>runtime</ExcludeAssets>
    </ProjectReference>
</ItemGroup>

L’élément <Private>false</Private> est important. Il indique à MSBuild de ne pas copier PluginBase.dll dans le répertoire de sortie de HelloPlugin. Si l’assembly PluginBase.dll est présent dans le répertoire de sortie, PluginLoadContext l’y trouvera et le chargera en même temps que l’assembly HelloPlugin.dll. À ce stade, le type HelloPlugin.HelloCommand implémentera l’interface ICommand de l’assembly PluginBase.dll situé dans le répertoire de sortie du projet HelloPlugin, et non l’interface ICommand qui est chargée dans le contexte de chargement par défaut. Comme le runtime considère ces deux types comme des types d’assemblys distincts, la méthode AppWithPlugin.Program.CreateCommands ne trouvera pas les commandes. De ce fait, les métadonnées <Private>false</Private> ne sont pas nécessaires pour la référence à l’assembly contenant les interfaces de plug-in.

De même, l’élément <ExcludeAssets>runtime</ExcludeAssets> est également important si PluginBase fait références à d’autres packages. Ce paramètre a le même effet que <Private>false</Private> mais fonctionne avec les références de package que le projet PluginBase ou l’une de ses dépendances peuvent inclure.

Maintenant que le projet HelloPlugin est terminé, nous devons mettre à jour le projet AppWithPlugin pour indiquer où se trouve le plug-in HelloPlugin. Après le commentaire // Paths to plugins to load, ajoutez @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll" (ce chemin d’accès peut être différent en fonction de la version de .NET Core que vous utilisez) comme élément du tableau pluginPaths.

Plug-in avec dépendances de bibliothèque

Les plug-ins sont presque tous plus complexes qu’un simple « Hello World », et bon nombre de plug-ins ont des dépendances vis-à-vis d’autres bibliothèques. Les projets JsonPlugin et OldJsonPlugin de l’exemple montrent deux exemples de plug-ins avec des packages NuGet qui dépendent de Newtonsoft.Json. En raison de cela, tous les projets de plug-in doivent ajouter <EnableDynamicLoading>true</EnableDynamicLoading> aux propriétés du projet afin qu’ils copient toutes leurs dépendances dans la sortie de dotnet build. La publication de la bibliothèque de classes avec dotnet publish copiera également toutes ses dépendances dans la sortie de publication.

Autres exemples dans l’échantillon

La totalité du code source de ce tutoriel se trouve dans le référentiel dotnet/samples. L’exemple terminé comprend d’autres illustrations du comportement AssemblyDependencyResolver. Par exemple, l’objet AssemblyDependencyResolver peut aussi résoudre les bibliothèques natives ainsi que les assemblys satellites localisés inclus dans les packages NuGet. Dans le référentiel d’exemples, UVPlugin et FrenchPlugin illustrent ces scénarios.

Référencer une interface de plug-in à partir d’un package NuGet

Supposons qu’il existe une application A dont l’interface de plug-in est définie dans le package NuGet nommé A.PluginBase. Comment référencer le package dans le projet de plug-in ? Pour les références de projet, l’utilisation des métadonnées <Private>false</Private> dans l’élément ProjectReference du fichier projet a empêché la copie de la dll dans la sortie.

Pour référencer correctement le package A.PluginBase, il convient de remplacer l’élément <PackageReference> dans le fichier projet par ce qui suit :

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

Cela empêche la copie des assemblys A.PluginBase dans le répertoire de sortie du plug-in et garantit que celui-ci utilisera la version de A.PluginBase de l’application A.

Recommandations concernant le framework cible des plug-ins

Sachant que le chargement des dépendances de plug-in utilise le fichier .deps.json, il existe un piège lié au framework cible des plug-ins. Plus précisément, vos plug-ins doivent cibler un runtime, comme .NET 5, et non une version de .NET Standard. Le fichier .deps.json est généré en fonction du framework cible du projet, et comme de nombreux packages compatibles avec .NET Standard intègrent des assemblys de référence pour développer par rapport à .NET Standard et aux assemblys d’implémentation de runtimes spécifiques, il se peut que .deps.json ne voie pas correctement les assemblys d’implémentation ou qu’il s’empare de la version .NET Standard d’un assembly au lieu de la version .NET Core attendue.

Références d’infrastructure de plug-in

Actuellement, les plug-ins ne peuvent pas introduire de nouvelles infrastructures dans le processus. Par exemple, vous ne pouvez pas charger un plug-in qui utilise l’infrastructure Microsoft.AspNetCore.App dans une application qui utilise uniquement l’infrastructure racine Microsoft.NETCore.App. L’application hôte doit déclarer des références à toutes les infrastructures nécessaires par les plug-ins.