プラグインがある .NET Core アプリケーションを作成する

このチュートリアルでは、カスタムの AssemblyLoadContext を作成してプラグインを読み込む方法を説明します。 プラグインの依存関係を解決するために AssemblyDependencyResolver が使用されます。 このチュートリアルでは、ホスト アプリケーションからプラグインの依存関係を正しく分離します。 学習内容は次のとおりです。

  • プロジェクトを構造化してプラグインをサポートする。
  • カスタム AssemblyLoadContext を作成して各プラグインを読み込む。
  • System.Runtime.Loader.AssemblyDependencyResolver 型を使用して、プラグインが依存関係を持てるようにする。
  • ビルド成果物をコピーするだけで簡単にデプロイできるプラグインを作成する。

前提条件

  • .NET 5 SDK 以降のバージョンをインストールする。

注意

このサンプル コードのターゲットは .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 インターフェイスが定義されたので、アプリケーション プロジェクトをもう少し詳しく入力することができます。 ルート フォルダーから dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj コマンドを使用して、AppWithPlugin プロジェクトから PluginBase プロジェクトへの参照を追加します。

// 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();

最後に、次に示すように、静的メソッドを LoadPluginCreateCommands という名前の Program クラスに追加します。

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

プラグインを読み込む

これでアプリケーションが正しく読み込まれ、読み込んだプラグイン アセンブリからコマンドをインスタンス化できるようになりましたが、まだプラグイン アセンブリを読み込むことはできません。 次のコンテンツを使用して、AppWithPlugin フォルダー内に PluginLoadContext.cs という名前のファイルを作成します。

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 は、.NET Core 3.0 で導入された AssemblyDependencyResolver 型のインスタンスを使用して、アセンブリ名をパスに解決します。 AssemblyDependencyResolver オブジェクトは、.NET クラス ライブラリへのパスを使用して構築されます。 これは、パスが AssemblyDependencyResolver コンストラクターに渡されたクラス ライブラリの .deps.json ファイルに基づいて、アセンブリとネイティブ ライブラリをその相対パスに解決します。 カスタム 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 インターフェイスではなく、HelloPlugin プロジェクトの出力ディレクトリ内の PluginBase.dll から ICommand インターフェイスを実装します。 ランタイムでは、これらの 2 つの型は別のアセンブリからの異なる型として認識されるため、AppWithPlugin.Program.CreateCommands メソッドではコマンドが見つかりません。 結果として、プラグイン インターフェイスを含むアセンブリへの参照に <Private>false</Private>メタデータが必要になります。

同様に、PluginBase が他のパッケージを参照している場合は、<ExcludeAssets>runtime</ExcludeAssets> 要素も重要になります。 この設定は <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 に依存しているプラグインの 2 つの例を示しています。 このため、すべての依存関係を dotnet build の出力にコピーできるように、すべてのプラグイン プロジェクトで、プロジェクトのプロパティに <EnableDynamicLoading>true</EnableDynamicLoading> を追加する必要があります。 クラス ライブラリを dotnet publish を使用して発行すると、その依存関係もすべて発行出力にコピーされます。

サンプル内のその他の例

このチュートリアルの完全なソース コードは dotnet/samples リポジトリで確認できます。 完全なサンプルには、他のいくつかの AssemblyDependencyResolver の動作例が含まれています。 たとえば、AssemblyDependencyResolver オブジェクトも、NuGet パッケージに含まれているローカライズされたサテライト アセンブリと同じようにネイティブ ライブラリを解決できます。 サンプル リポジトリの UVPluginFrenchPlugin で、これらのシナリオが示されています。

NuGet パッケージからプラグイン インターフェイスを参照する

A.PluginBase という名前の NuGet パッケージで定義されているプラグイン インターフェイスを持つアプリ A があるとします。 プラグイン プロジェクト内のパッケージを正しく参照するにはどうすればよいでしょうか。 プロジェクト参照の場合、dll が出力にコピーされるのを防止する <Private>false</Private> メタデータをプロジェクト ファイル内の ProjectReference 要素で使用します。

A.PluginBase パッケージを正しく参照するために、プロジェクト ファイル内の <PackageReference> 要素を次に変更できます。

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

これにより、A.PluginBase アセンブリがプラグインの出力ディレクトリにコピーされるのを防ぎ、プラグインで A.PluginBase の A のバージョンが確実に使用されるようになります。

プラグインのターゲット フレームワークの推奨事項

プラグインの依存関係の読み込みでは .deps.json ファイルが使用されるため、プラグインのターゲット フレームワークに関連する課題があります。 具体的には、プラグインでは .NET Standard のバージョンではなく、.NET 5 などのランタイムをターゲットとする必要があります。 プロジェクトがターゲットとするフレームワークに基づいて .deps.json ファイルが生成されます。また、多くの .NET Standard と互換性のあるパッケージでは .NET Standard に対してビルドするための参照アセンブリと、特定のランタイムのための実装アセンブリが同梱されているため、 .deps.json が実装アセンブリを正しく認識できない場合や、想定していた .NET Core バージョンではなく、アセンブリの .NET Standard バージョンが取得される場合があります。

プラグイン フレームワークの参照

現時点では、プラグインで新しいフレームワークをプロセスに導入することはできません。 たとえば、ルート Microsoft.NETCore.App フレームワークのみを使用するアプリケーションに、Microsoft.AspNetCore.App フレームワークを使用するプラグインを読み込むことはできません。 ホスト アプリケーションは、プラグインで必要なすべてのフレームワークへの参照を宣言する必要があります。