Skapa ett .NET Core-program med plugin-program

Den här självstudien visar hur du skapar en anpassad AssemblyLoadContext för att läsa in plugin-program. En AssemblyDependencyResolver används för att lösa beroenden för plugin-programmet. Självstudien isolerar korrekt plugin-programmets beroenden från värdprogrammet. Du lär dig följande:

  • Strukturera ett projekt för att stödja plugin-program.
  • Skapa en anpassad AssemblyLoadContext för att läsa in varje plugin-program.
  • Använd typen System.Runtime.Loader.AssemblyDependencyResolver för att tillåta att plugin-program har beroenden.
  • Skapa plugin-program som enkelt kan distribueras genom att bara kopiera byggartefakterna.

Krav

Anteckning

Exempelkoden riktar sig till .NET 5, men alla funktioner som används introducerades i .NET Core 3.0 och är tillgängliga i alla .NET-versioner sedan dess.

Skapa programmet

Det första steget är att skapa programmet:

  1. Skapa en ny mapp och kör följande kommando i mappen:

    dotnet new console -o AppWithPlugin
    
  2. Skapa en Visual Studio-lösningsfil i samma mapp för att göra det enklare att skapa projektet. Kör följande kommando:

    dotnet new sln
    
  3. Kör följande kommando för att lägga till appprojektet i lösningen:

    dotnet sln add AppWithPlugin/AppWithPlugin.csproj
    

Nu kan vi fylla i stommen i vårt program. Ersätt koden i filen AppWithPlugin/Program.cs med följande kod:

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

Skapa gränssnitten för plugin-programmet

Nästa steg i att skapa en app med plugin-program är att definiera det gränssnitt som plugin-program behöver implementera. Vi rekommenderar att du skapar ett klassbibliotek som innehåller alla typer som du planerar att använda för kommunikation mellan din app och plugin-program. Med den här divisionen kan du publicera plugin-gränssnittet som ett paket utan att behöva skicka hela programmet.

I rotmappen för projektet kör dotnet new classlib -o PluginBasedu . dotnet sln add PluginBase/PluginBase.csproj Kör också för att lägga till projektet i lösningsfilen. PluginBase/Class1.cs Ta bort filen och skapa en ny fil i PluginBase mappen med namnet ICommand.cs med följande gränssnittsdefinition:

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

        int Execute();
    }
}

Det här ICommand gränssnittet är det gränssnitt som alla plugin-program implementerar.

Nu när ICommand gränssnittet har definierats kan programprojektet fyllas i lite mer. Lägg till en referens från AppWithPlugin projektet till PluginBase projektet med dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj kommandot från rotmappen.

Ersätt kommentaren // Load commands from plugins med följande kodfragment så att den kan läsa in plugin-program från angivna filsökvägar:

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

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

Ersätt sedan kommentaren // Output the loaded commands med följande kodfragment:

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

Ersätt kommentaren // Execute the command with the name passed as an argument med följande kodfragment:

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

command.Execute();

Slutligen lägger du till statiska metoder i Program klassen med namnet LoadPlugin och CreateCommands, som du ser här:

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

Läsa in plugin-program

Nu kan programmet läsa in och instansiera kommandon från inlästa plugin-sammansättningar, men det går fortfarande inte att läsa in plugin-sammansättningarna. Skapa en fil med namnet PluginLoadContext.cs i mappen AppWithPlugin med följande innehåll:

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

Typen PluginLoadContext härleds från AssemblyLoadContext. Typen AssemblyLoadContext är en särskild typ i körningen som gör att utvecklare kan isolera inlästa sammansättningar i olika grupper för att säkerställa att sammansättningsversionerna inte står i konflikt. Dessutom kan en anpassad AssemblyLoadContext välja olika sökvägar för att läsa in sammansättningar från och åsidosätta standardbeteendet. PluginLoadContext använder en instans av typen AssemblyDependencyResolver som introducerades i .NET Core 3.0 för att matcha sammansättningsnamn till sökvägar. Objektet AssemblyDependencyResolver konstrueras med sökvägen till ett .NET-klassbibliotek. Den löser sammansättningar och interna bibliotek till deras relativa sökvägar baserat på filen .deps.json för klassbiblioteket vars sökväg skickades till AssemblyDependencyResolver konstruktorn. Den anpassade AssemblyLoadContext gör det möjligt för plugin-program att ha sina egna beroenden och AssemblyDependencyResolver gör det enkelt att läsa in beroendena korrekt.

Nu när AppWithPlugin projektet har PluginLoadContext typen uppdaterar Program.LoadPlugin du metoden med följande brödtext:

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

Genom att använda en annan PluginLoadContext instans för varje plugin-program kan plugin-program ha olika eller till och med motstridiga beroenden utan problem.

Enkelt plugin-program utan beroenden

Gör följande i rotmappen:

  1. Kör följande kommando för att skapa ett nytt klassbiblioteksprojekt med namnet HelloPlugin:

    dotnet new classlib -o HelloPlugin
    
  2. Kör följande kommando för att lägga till projektet i AppWithPlugin lösningen:

    dotnet sln add HelloPlugin/HelloPlugin.csproj
    
  3. Ersätt filen HelloPlugin/Class1.cs med en fil med namnet HelloCommand.cs med följande innehåll:

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

Öppna nu filen HelloPlugin.csproj . Det bör se ut ungefär så här:

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

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

</Project>

Lägg till följande element mellan taggarna <PropertyGroup> :

  <EnableDynamicLoading>true</EnableDynamicLoading>

<EnableDynamicLoading>true</EnableDynamicLoading> förbereder projektet så att det kan användas som ett plugin-program. Detta kopierar bland annat alla dess beroenden till projektets utdata. Mer information finns i: EnableDynamicLoading.

Lägg till följande element mellan taggarna <Project> :

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

Elementet <Private>false</Private> är viktigt. Detta instruerar MSBuild att inte kopiera PluginBase.dll till utdatakatalogen för HelloPlugin. Om denPluginBase.dll sammansättningen finns i utdatakatalogen PluginLoadContext hittar du sammansättningen där och läser in den när den läser inHelloPlugin.dll-sammansättningen . I det här läget HelloPlugin.HelloCommand implementerar ICommand typen gränssnittet från PluginBase.dll i projektets HelloPlugin utdatakatalog, inte ICommand det gränssnitt som läses in i standardinläsningskontexten. Eftersom körningen ser dessa två typer som olika typer från olika sammansättningar hittar AppWithPlugin.Program.CreateCommands metoden inte kommandona. Därför <Private>false</Private> krävs metadata för referensen till sammansättningen som innehåller plugin-gränssnitten.

På samma sätt är elementet <ExcludeAssets>runtime</ExcludeAssets> också viktigt om refererar PluginBase till andra paket. Den här inställningen har samma effekt som <Private>false</Private> men fungerar på paketreferenser som PluginBase projektet eller något av dess beroenden kan innehålla.

Nu när HelloPlugin projektet är klart bör du uppdatera AppWithPlugin projektet för att veta var plugin-programmet HelloPlugin finns. Efter kommentaren // Paths to plugins to load lägger du till @"HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll" (den här sökvägen kan skilja sig från den .NET Core-version som du använder) som ett element i matrisen pluginPaths .

Plugin-program med biblioteksberoenden

Nästan alla plugin-program är mer komplexa än en enkel "Hello World", och många plugin-program har beroenden på andra bibliotek. Projekten JsonPlugin och OldJsonPlugin i exemplet visar två exempel på plugin-program med NuGet-paketberoenden på Newtonsoft.Json. Därför bör alla plugin-projekt lägga <EnableDynamicLoading>true</EnableDynamicLoading> till i projektegenskaperna så att de kopierar alla sina beroenden till utdata dotnet buildfrån . Om du publicerar klassbiblioteket med dotnet publish kopieras även alla dess beroenden till publiceringsutdata.

Andra exempel i exemplet

Den fullständiga källkoden för den här självstudien finns på lagringsplatsen dotnet/samples. Det färdiga exemplet innehåller några andra exempel på AssemblyDependencyResolver beteende. Objektet kan till exempel AssemblyDependencyResolver även matcha interna bibliotek samt lokaliserade satellitsammansättningar som ingår i NuGet-paket. Lagringsplatsen UVPlugin och FrenchPlugin i exempellagringsplatsen visar dessa scenarier.

Referera till ett plugin-gränssnitt från ett NuGet-paket

Anta att det finns en app A som har ett plugin-gränssnitt som definierats i NuGet-paketet med namnet A.PluginBase. Hur refererar du till paketet korrekt i plugin-projektet? För projektreferenser förhindrade användning av <Private>false</Private> metadata för elementet ProjectReference i projektfilen dll-filen från att kopieras till utdata.

Om du vill referera A.PluginBase till paketet korrekt vill du ändra elementet <PackageReference> i projektfilen till följande:

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

Detta förhindrar A.PluginBase att sammansättningarna kopieras till utdatakatalogen för plugin-programmet och säkerställer att plugin-programmet använder A:s version av A.PluginBase.

Rekommendationer för målramverk för plugin-program

Eftersom plugin-beroendeinläsning använder .deps.json-filen finns det en gotcha som är relaterad till plugin-programmets målramverk. Mer specifikt bör dina plugin-program riktas mot en körning, till exempel .NET 5, i stället för en version av .NET Standard. .deps.json-filen genereras baserat på vilket ramverk projektet riktar sig mot, och eftersom många .NET Standard-kompatibla paket skickar referenssammansättningar för att skapa mot .NET Standard och implementeringssammansättningar för specifika körningsmiljöer, kanske .deps.json inte korrekt ser implementeringssammansättningar, eller så kan den hämta .NET Standard-versionen av en sammansättning i stället för den .NET Core-version du förväntar dig.

Referenser till plugin-ramverk

För närvarande kan plugin-program inte introducera nya ramverk i processen. Du kan till exempel inte läsa in ett plugin-program som använder ramverket Microsoft.AspNetCore.App i ett program som bara använder rotramverket Microsoft.NETCore.App . Värdprogrammet måste deklarera referenser till alla ramverk som behövs av plugin-program.