Erstellen einer .NET Core-Anwendung mit Plug-Ins

In diesem Tutorial erfahren Sie, wie Sie einen benutzerdefinierten AssemblyLoadContext zum Laden von Plug-Ins erstellen. Ein AssemblyDependencyResolver wird verwendet, um die Abhängigkeiten des Plug-Ins aufzulösen. Das Tutorial isoliert die Plug-In-Abhängigkeiten ordnungsgemäß von der Hostanwendung. Sie lernen Folgendes:

  • Strukturieren eines Projekts zur Unterstützung von Plug-Ins
  • Erstellen einer benutzerdefinierten AssemblyLoadContext-Klasse zum Laden jedes Plug-Ins
  • Verwenden des Typs System.Runtime.Loader.AssemblyDependencyResolver zum Ermöglichen von Abhängigkeiten für Plug-Ins
  • Schreiben von Plug-Ins, die mühelos durch Kopieren der Buildartefakte bereitgestellt werden können

Voraussetzungen

  • Installieren Sie das .NET 5 SDK oder eine neuere Version.

Hinweis

Der Beispielcode ist auf .NET 5 ausgelegt. Alle verwendeten Features wurden jedoch in .NET Core 3.0 eingeführt und sind seitdem in allen höheren .NET-Releases verfügbar.

Erstellen der Anwendung

Im ersten Schritt erstellen Sie die Anwendung:

  1. Erstellen Sie einen neuen Ordner, und führen Sie in diesem Ordner den folgenden Befehl aus:

    dotnet new console -o AppWithPlugin
    
  2. Erstellen Sie eine Visual Studio-Projektmappendatei im gleichen Ordner, um das Erstellen des Projekts zu vereinfachen. Führen Sie den folgenden Befehl aus:

    dotnet new sln
    
  3. Führen Sie den folgenden Befehl aus, um das Anwendungsprojekt zur Projektmappe hinzuzufügen:

    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

Das Gerüst der Anwendung kann nun aufgefüllt werden. Ersetzen Sie den Code der Datei AppWithPlugin/Program.cs durch folgenden Code:

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

Erstellen der Plug-In-Schnittstellen

Der nächste Schritt zum Erstellen einer App mit Plug-Ins besteht im Definieren der Schnittstelle, die die Plug-Ins implementieren müssen. Es wird empfohlen, dass Sie eine Klassenbibliothek erstellen, die alle Typen enthält, die Sie für die Kommunikation zwischen Ihrer App und den Plug-Ins verwenden möchten. Diese Trennung ermöglicht Ihnen das Veröffentlichen Ihrer Plug-In-Schnittstelle als Paket, ohne die vollständige Anwendung senden zu müssen.

Führen Sie dotnet new classlib -o PluginBase im Stammverzeichnis des Projekts aus. Führen Sie außerdem dotnet sln add PluginBase/PluginBase.csproj aus, um das Projekt zur Projektmappendatei hinzuzufügen. Löschen Sie die Datei PluginBase/Class1.cs, und erstellen Sie eine neue Datei im PluginBase-Ordner namens ICommand.cs mit der folgenden Schnittstellendefinition:

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

        int Execute();
    }
}

Diese ICommand-Schnittstelle ist die, die von allen Plug-Ins implementiert wird.

Nachdem die ICommand-Schnittstelle definiert wurde, kann das Anwendungsprojekt ein wenig mehr aufgefüllt werden. Fügen Sie einen Verweis vom Projekt AppWithPlugin auf das Projekt PluginBase mit dem Befehl dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj aus dem Stammordner hinzu.

Ersetzen Sie den Kommentar // Load commands from plugins durch den folgenden Codeausschnitt, um das Laden von Plug-Ins aus den angegebenen Dateipfaden zu ermöglichen:

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

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

Ersetzen Sie dann den Kommentar // Output the loaded commands durch folgenden Codeausschnitt:

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

Ersetzen Sie den Kommentar // Execute the command with the name passed as an argument durch folgenden Codeausschnitt:

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

command.Execute();

Fügen Sie schließlich die statischen Methoden namens LoadPlugin und CreateCommands wie folgt zur Program-Klasse hinzu.

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

Laden der Plug-Ins

Die Anwendung kann jetzt ordnungsgemäß geladen werden und Befehle aus geladenen Plug-In-Assemblys instanziieren, jedoch kann sie die Plug-In-Assemblys noch nicht laden. Erstellen Sie eine Datei namens PluginLoadContext.cs mit den folgenden Inhalten im Ordner 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;
        }
    }
}

Der Typ PluginLoadContext wird von AssemblyLoadContext abgeleitet. Der Typ AssemblyLoadContext ist ein spezieller Typ in der Runtime, der Entwicklern ermöglicht, geladene Assemblys in verschiedenen Gruppen zu isolieren, um sicherzustellen, dass die Assemblyversionen nicht im Konflikt stehen. Darüber hinaus kann eine benutzerdefinierte AssemblyLoadContext-Klasse verschiedene Pfade zum Laden von Assemblys und zum Überschreiben des Standardverhaltens verwenden. PluginLoadContext verwendet eine Instanz vom Typ AssemblyDependencyResolver, die in .NET Core 3.0 eingeführt wurde, um Assemblynamen in Pfade aufzulösen. Das AssemblyDependencyResolver-Objekt wird mit dem Pfad zu einer .NET-Klassenbibliothek erstellt. Es löst Assemblys und native Bibliotheken basierend auf der Datei .deps.json in ihre relativen Pfade für die Klassenbibliothek auf, deren Pfad an den Konstruktor AssemblyDependencyResolver übergeben wurde. Das benutzerdefinierte Objekt AssemblyLoadContext ermöglicht eigene Abhängigkeiten für Plug-Ins, und AssemblyDependencyResolver erleichtert das richtige Laden der Abhängigkeiten.

Da das AppWithPlugin-Projekt nun über den Typ PluginLoadContext verfügt, aktualisieren Sie die Program.LoadPlugin-Methode mit folgendem Text:

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

Aufgrund der verschiedenen PluginLoadContext-Instanzen für jedes Plug-In können die Plug-Ins problemlos verschiedene oder sogar in Konflikt stehende Abhängigkeiten aufweisen.

Einfaches Plug-In ohne Abhängigkeiten

Führen Sie folgende Schritte im Stammverzeichnis aus:

  1. Führen Sie den folgenden Befehl aus, um ein neues Klassenbibliotheksprojekt namens HelloPlugin zu erstellen:

    dotnet new classlib -o HelloPlugin
    
  2. Führen Sie den folgenden Befehl aus, um das Projekt zur Projektmappe AppWithPlugin hinzuzufügen:

    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. Ersetzen Sie die Datei HelloPlugin/Class1.cs durch eine Datei namens HelloCommand.cs mit folgendem Inhalt:

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

Öffnen Sie jetzt die Datei HelloPlugin.csproj. Der Inhalt sollte Folgendem ähnlich sehen:

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

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

</Project>

Fügen Sie die folgenden Elemente zwischen den <PropertyGroup>-Tags ein:

  <EnableDynamicLoading>true</EnableDynamicLoading>

<EnableDynamicLoading>true</EnableDynamicLoading> bereitet das Projekt so vor, dass es als Plug-In verwendet werden kann. Dadurch werden unter anderem alle Abhängigkeiten in die Ausgabe des Projekts kopiert. Weitere Informationen finden Sie unter EnableDynamicLoading.

Fügen Sie die folgenden Elemente zwischen den <Project>-Tags ein:

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

Das Element <Private>false</Private> ist wichtig. Es weist MSBuild dazu an, die PluginBase.dll nicht in das Ausgabeverzeichnis für „HelloPlugin“ zu kopieren. Wenn die Assembly PluginBase.dll im Ausgabeverzeichnis enthalten ist, findet PluginLoadContext die Assembly dort und lädt sie, wenn die Assembly HelloPlugin.dll geladen wird. An diesem Punkt implementiert der Typ HelloPlugin.HelloCommand die ICommand-Schnittstelle aus der PluginBase.dll in das Ausgabeverzeichnis des HelloPlugin-Projekts und nicht die ICommand-Schnittstelle, die in den Standardladekontext geladen wurde. Da die Runtime die beiden Typen als verschiedene Typen aus verschiedenen Assemblys erkennt, findet die AppWithPlugin.Program.CreateCommands-Methode die Befehle nicht. Daher sind die <Private>false</Private>-Metadaten für den Verweis auf die Assembly erforderlich, die die Plug-In-Schnittstellen enthält.

Ebenso ist das Element <ExcludeAssets>runtime</ExcludeAssets> wichtig, wenn PluginBase auf andere Paket verweist. Diese Einstellung hat denselben Effekt wie <Private>false</Private>, funktioniert aber in Paketverweisen, die im PluginBase-Projekt oder einer der zugehörigen Abhängigkeiten enthalten sein können.

Nach Fertigstellung des HelloPlugin-Projekts sollten Sie das AppWithPlugin-Projekt aktualisieren, um in Erfahrung zu bringen, wo sich das HelloPlugin befindet. Fügen Sie nach dem Kommentar // Paths to plugins to load@"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll" (dieser Pfad kann je nach .NET Core-Version anders sein) als Element das Array pluginPaths hinzu.

Plug-In mit Bibliotheksabhängigkeiten

Fast alle Plug-Ins sind komplexer als eine einfaches „Hallo Welt“-Programm, und viele Plug-Ins weisen Abhängigkeiten von anderen Bibliotheken auf. Im Beispiel veranschaulichen die Projekte JsonPlugin und OldJsonPlugin zwei verschiedene Plug-Ins mit NuGet-Paketabhängigkeiten von Newtonsoft.Json. Aus diesem Grund sollten alle Plug-In-Projekte den Projekteigenschaften <EnableDynamicLoading>true</EnableDynamicLoading> hinzufügen, damit sie alle ihre Abhängigkeiten in die Ausgabe von dotnet build kopieren. Beim Veröffentlichen der Klassenbibliothek mit dotnet publish werden auch alle Abhängigkeiten in die Veröffentlichungsausgabe kopiert.

Andere Beispiele in diesem Beispiel

Den vollständigen Quellcode für dieses Tutorial finden Sie im Repository dotnet/samples. Das vollständige Beispiel enthält einige weitere Beispiele für das AssemblyDependencyResolver-Verhalten. Zum Beispiel kann das AssemblyDependencyResolver-Objekt auch in NuGet-Paketen enthaltene native Bibliotheken und lokalisierte Satellitenassemblys auflösen. Das UVPlugin und FrenchPlugin im Beispielrepository veranschaulichen diese Szenarios.

Verweisen auf eine Plug-In-Schnittstelle aus einem NuGet-Paket

Angenommen, die App „A“ verfügt über eine Plug-In-Schnittstelle, die in einem NuGet-Paket mit dem Namen A.PluginBase definiert ist. Wie verweisen Sie in Ihrem Plug-In-Projekt ordnungsgemäß auf das Paket? Für Projektverweise verhindern die <Private>false</Private>-Metadaten im ProjectReference-Element in der Projektdatei, dass die DLL-Datei in die Ausgabe kopiert wird.

Um ordnungsgemäß auf das A.PluginBase-Paket zu verweisen, sollten Sie das <PackageReference>-Element in der Projektdatei in folgendes ändern:

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

Damit wird verhindert, dass die A.PluginBase-Assemblys in das Ausgabeverzeichnis Ihres Plug-Ins kopiert werden, und es wird sichergestellt, dass Ihr Plug-In die Version von A.PluginBase der App „A“ verwendet.

Empfehlungen für das Plug-In-Zielframework

Da beim Laden der Plug-In-Abhängigkeit die Datei .deps.json verwendet wird, gibt es ein Problem im Zusammenhang mit dem Zielframework des Plug-Ins. Genauer gesagt müssen Ihre Plug-Ins für eine Runtime wie .NET 5 ausgelegt sein, nicht auf eine Version von .NET Standard. Die Datei .deps.json wird anhand des Zielframeworks des Projekts generiert, und da viele Pakete, die mit .NET Standard kompatibel sind, Verweisassemblys für die Erstellung für .NET Standard und Implementierungsassemblys für spezifische Runtimes enthalten, werden Implementierungsassemblys möglicherweise nicht ordnungsgemäß von .deps.json erkannt, oder die .NET Standard-Version kann anstelle der erwarteten .NET Core-Version abgerufen werden.

Plug-In-Frameworkverweise

Plug-Ins können derzeit keine neuen Frameworks in den Prozess einbringen. Beispielsweise können Sie ein Plug-in, das das Framework Microsoft.AspNetCore.App verwendet, nicht in eine Anwendung laden, die nur das Stammframework Microsoft.NETCore.App verwendet. Die Hostanwendung muss Verweise auf alle von Plug-Ins benötigten Frameworks deklarieren.