Creare un'applicazione .NET Core con i plug-in

Questa esercitazione illustra come creare un'istanza personalizzata AssemblyLoadContext per caricare i plug-in. Viene AssemblyDependencyResolver usato per risolvere le dipendenze del plug-in. L'esercitazione isola correttamente le dipendenze del plug-in dall'applicazione host. Nello specifico:

  • Strutturare un progetto per il supporto dei plug-in.
  • Creare una classe AssemblyLoadContext personalizzata per caricare ogni plug-in.
  • Usare il tipo System.Runtime.Loader.AssemblyDependencyResolver per consentire l'uso di plug-in con dipendenze.
  • Creare plug-in che possono essere distribuiti con facilità copiando semplicemente gli artefatti di compilazione.

Prerequisiti

Nota

Il codice di esempio è destinato a .NET 5, ma tutte le funzionalità usate sono state introdotte in .NET Core 3.0 e sono disponibili in tutte le versioni .NET da allora.

Creare l'applicazione

Il primo passaggio consiste nel creare l'applicazione:

  1. Creare una nuova cartella e in tale cartella eseguire il comando seguente:

    dotnet new console -o AppWithPlugin
    
  2. Per semplificare la compilazione del progetto, creare un file di soluzione di Visual Studio nella stessa cartella. Esegui questo comando:

    dotnet new sln
    
  3. Eseguire il comando seguente per aggiungere il progetto dell'app alla soluzione:

    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

Ora è possibile compilare la struttura dell'applicazione. Sostituire il codice nel file AppWithPlugin/Program.cs con il codice seguente:

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

Creare le interfacce dei plug-in

Il passaggio successivo della compilazione di un'app con plug-in consiste nel definire l'interfaccia che deve essere implementata dai plug-in. È consigliabile creare una libreria di classi che contiene tutti i tipi che si prevede di usare per la comunicazione tra l'app e i plug-in. Questa divisione consente di pubblicare l'interfaccia del plug-in come pacchetto senza dover distribuire l'applicazione completa.

Nella cartella radice del progetto eseguire dotnet new classlib -o PluginBase. Eseguire anche dotnet sln add PluginBase/PluginBase.csproj per aggiungere il progetto al file della soluzione. Eliminare il file PluginBase/Class1.cs e nella cartella PluginBase creare un nuovo file denominato ICommand.cs con la definizione di interfaccia seguente:

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

        int Execute();
    }
}

Questa interfaccia ICommand è l'interfaccia che verrà implementata da tutti i plug-in.

Dopo aver definito l'interfaccia ICommand, è possibile compilare ulteriormente il progetto dell'applicazione. Aggiungere un riferimento dal AppWithPlugin progetto al PluginBase progetto con il dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj comando dalla cartella radice.

Sostituire il commento // Load commands from plugins con il frammento di codice seguente per abilitare il caricamento dei plug-in dai percorsi di file specificati:

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

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

Quindi sostituire il commento // Output the loaded commands con il frammento di codice seguente:

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

Sostituire il commento // Execute the command with the name passed as an argument con il frammento seguente:

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

command.Execute();

Infine, aggiungere i metodi statici denominati LoadPlugin e CreateCommands alla classe Program come illustrato di seguito:

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

Caricare i plug-in

Ora l'applicazione può caricare e creare correttamente istanze dei comandi dagli assembly plug-in caricati, ma non è ancora in grado di caricare gli assembly del plug-in. Creare un file denominato PluginLoadContext.cs nella cartella AppWithPlugin con il contenuto seguente:

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

Il tipo PluginLoadContext deriva da AssemblyLoadContext. Il AssemblyLoadContext tipo è un tipo speciale nel runtime che consente agli sviluppatori di isolare gli assembly caricati in gruppi diversi per garantire che le versioni degli assembly non siano in conflitto. Inoltre, un tipo AssemblyLoadContext personalizzato può scegliere percorsi diversi da cui caricare gli assembly ed eseguire l'override del comportamento predefinito. PluginLoadContext usa un'istanza del tipo AssemblyDependencyResolver introdotta in .NET Core 3.0 per risolvere i nomi di assembly in percorsi. L'oggetto AssemblyDependencyResolver è costruito con il percorso per una libreria di classi .NET. Risolve gli assembly e le librerie native nei relativi percorsi in base al file deps.json per la libreria di classi il cui percorso è stato passato al costruttore AssemblyDependencyResolver. Il tipo AssemblyLoadContext personalizzato consente ai plug-in di avere proprie dipendenze, mentre l'oggetto AssemblyDependencyResolver rende più semplice caricare correttamente le dipendenze.

Ora che il progetto AppWithPlugin ha il tipo PluginLoadContext, aggiornare il metodo Program.LoadPlugin con il corpo seguente:

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

Usando un'istanza PluginLoadContext diversa per ogni plug-in, i plug-in possono avere dipendenze diverse o persino in conflitto senza problemi.

Plug-in semplice senza dipendenze

Nella cartella radice eseguire le operazioni seguenti:

  1. Eseguire il comando seguente per creare un nuovo progetto di libreria di classi denominato HelloPlugin:

    dotnet new classlib -o HelloPlugin
    
  2. Eseguire il comando seguente per aggiungere il progetto alla AppWithPlugin soluzione:

    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. Sostituire il file HelloPlugin/Class1.cs con un file denominato HelloCommand.cs con il contenuto seguente:

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

A questo punto, aprire il file HelloPlugin.csproj. La voce deve essere simile alla seguente:

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

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

</Project>

Tra i <PropertyGroup> tag aggiungere l'elemento seguente:

  <EnableDynamicLoading>true</EnableDynamicLoading>

Prepara <EnableDynamicLoading>true</EnableDynamicLoading> il progetto in modo che possa essere usato come plug-in. Tra le altre cose, verranno copiate tutte le relative dipendenze nell'output del progetto. Per informazioni dettagliate, vedere EnableDynamicLoading.

Tra i due tag <Project> aggiungere gli elementi seguenti:

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

L'elemento <Private>false</Private> è importante. Indica a MSBuild di non copiare PluginBase.dll nella directory di output per HelloPlugin. Se l'assembly PluginBase.dll è presente nella directory di output, PluginLoadContext individuerà l'assembly e lo caricherà durante il caricamento dell'assembly HelloPlugin.dll. A questo punto, il tipo HelloPlugin.HelloCommand implementerà l'interfaccia ICommand di PluginBase.dll nella directory di output del progetto HelloPlugin, non l'interfaccia ICommand caricata nel contesto di caricamento predefinito. Poiché il runtime vede questi due tipi come tipi diversi da assembly diversi, il AppWithPlugin.Program.CreateCommands metodo non troverà i comandi. Di conseguenza, saranno necessari i metadati <Private>false</Private> per il riferimento all'assembly che contiene le interfacce dei plug-in.

Analogamente, l'elemento <ExcludeAssets>runtime</ExcludeAssets> è importante anche se fa PluginBase riferimento ad altri pacchetti. Questa impostazione ha lo stesso effetto di <Private>false</Private> ma funziona sui riferimenti al pacchetto che il PluginBase progetto o una delle relative dipendenze possono includere.

Ora che il HelloPlugin progetto è completo, è necessario aggiornare il AppWithPlugin progetto per sapere dove è possibile trovare il HelloPlugin plug-in. Dopo il // Paths to plugins to load commento, aggiungere @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll" (questo percorso potrebbe essere diverso in base alla versione di .NET Core usata) come elemento della pluginPaths matrice.

Plug-in con dipendenze della libreria

Quasi tutti i plug-in sono più complessi rispetto a un semplice "Hello World" e molti plug-in hanno dipendenze in altre librerie. I JsonPlugin progetti e OldJsonPlugin nell'esempio mostrano due esempi di plug-in con dipendenze del pacchetto NuGet da Newtonsoft.Json. Per questo motivo, tutti i progetti di plug-in devono essere aggiunti <EnableDynamicLoading>true</EnableDynamicLoading> alle proprietà del progetto in modo che copiano tutte le relative dipendenze nell'output di dotnet build. La pubblicazione della libreria di classi con dotnet publish copia anche tutte le relative dipendenze nell'output di pubblicazione.

Altri esempi nell'esempio

Il codice sorgente completo per questa esercitazione è reperibile nel repository dotnet/samples. L'esempio completato include alcuni altri esempi del comportamento di AssemblyDependencyResolver. Ad esempio, l'oggetto AssemblyDependencyResolver può anche risolvere le librerie native nonché gli assembly satellite localizzati inclusi nei pacchetti NuGet. UVPlugin e FrenchPlugin nel repository degli esempi illustrano questi scenari.

Fare riferimento a un'interfaccia del plug-in da un pacchetto NuGet

Si supponga che sia presente un'app A con un'interfaccia di plug-in definita nel pacchetto NuGet denominato A.PluginBase. Come fare riferimento correttamente al pacchetto nel progetto di plug-in? Per i riferimenti al progetto, l'uso dei metadati <Private>false</Private> nell'elemento ProjectReference nel file di progetto ha impedito la copia della dll nell'output.

Per fare riferimento correttamente al pacchetto A.PluginBase, si modifica l'elemento <PackageReference> nel file di progetto come segue:

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

In questo modo si impedisce che gli assembly A.PluginBase vengano copiati nella directory di output del plug-in e si garantisce che il plug-in usi la versione A di A.PluginBase.

Consigli sul framework di destinazione del plug-in

Poiché il caricamento delle dipendenze del plug-in usa il file deps.json, tenere presente la raccomandazione relativa al framework di destinazione del plug-in. In particolare, i plug-in devono essere destinati a un runtime, ad esempio .NET 5, anziché una versione di .NET Standard. Il file .deps.json viene generato in base al framework di destinazione del progetto e poiché numerosi pacchetti compatibili con .NET Standard offrono assembly di riferimento per la compilazione in .NET Standard e assembly di implementazione per runtime specifici, è possibile che .deps.json non consideri correttamente gli assembly di implementazione oppure ottenga la versione .NET Standard di un assembly anziché la versione .NET Core prevista.

Riferimenti al framework del plug-in

Attualmente, i plug-in non possono introdurre nuovi framework nel processo. Ad esempio, non è possibile caricare un plug-in che usa il Microsoft.AspNetCore.App framework in un'applicazione che usa solo il framework radice Microsoft.NETCore.App . L'applicazione host deve dichiarare riferimenti a tutti i framework necessari per i plug-in.