LoginSignup
0
1

More than 3 years have passed since last update.

プラグインのサンプルを書いてC#のクラスローディングを理解する

Posted at

Azure Functions ホストの実装は クラスローディングの塊なので、そろそろ仕組みをじっくり理解したくなってきた。そのために、その基礎のクラスの使い方を理解していきたい。今日はまず Create a .NET Core application with pluginsのチュートリアルを理解しながら流していきたい。

プロジェクトのひな型を作る

C#のものにしては珍しく、コマンドラインから作成するようになっている。
折角なのでコマンドを調べてみよう。

dotnet new -l
Templates                                         Short Name               Language          Tags
--------------------------------------------      -------------------      ------------      ----------------------
Console Application                               console                  [C#], F#, VB      Common/Console
Class library                                     classlib                 [C#], F#, VB      Common/Library
WPF Application                                   wpf                      [C#], VB          Common/WPF
WPF Class library                                 wpflib                   [C#], VB          Common/WPF
WPF Custom Control Library                        wpfcustomcontrollib      [C#], VB          Common/WPF
WPF User Control Library                          wpfusercontrollib        [C#], VB          Common/WPF
Windows Forms App                                 winforms                 [C#], VB          Common/WinForms
Windows Forms Control Library                     winformscontrollib       [C#], VB          Common/WinForms
Windows Forms Class Library                       winformslib              [C#], VB          Common/WinForms
Worker Service                                    worker                   [C#], F#          Common/Worker/Web
Unit Test Project                                 mstest                   [C#], F#, VB      Test/MSTest
NUnit 3 Test Project                              nunit                    [C#], F#, VB      Test/NUnit
NUnit 3 Test Item                                 nunit-test               [C#], F#, VB      Test/NUnit
xUnit Test Project                                xunit                    [C#], F#, VB      Test/xUnit
Razor Component                                   razorcomponent           [C#]              Web/ASP.NET
Razor Page                                        page                     [C#]              Web/ASP.NET
MVC ViewImports                                   viewimports              [C#]              Web/ASP.NET
MVC ViewStart                                     viewstart                [C#]              Web/ASP.NET
Blazor Server App                                 blazorserver             [C#]              Web/Blazor
Blazor WebAssembly App                            blazorwasm               [C#]              Web/Blazor/WebAssembly
ASP.NET Core Empty                                web                      [C#], F#          Web/Empty
ASP.NET Core Web App (Model-View-Controller)      mvc                      [C#], F#          Web/MVC
ASP.NET Core Web App                              webapp                   [C#]              Web/MVC/Razor Pages
ASP.NET Core with Angular                         angular                  [C#]              Web/MVC/SPA
ASP.NET Core with React.js                        react                    [C#]              Web/MVC/SPA
ASP.NET Core with React.js and Redux              reactredux               [C#]              Web/MVC/SPA
Razor Class Library                               razorclasslib            [C#]              Web/Razor/Library
ASP.NET Core Web API                              webapi                   [C#], F#          Web/WebAPI
ASP.NET Core gRPC Service                         grpc                     [C#]              Web/gRPC
dotnet gitignore file                             gitignore                                  Config
global.json file                                  globaljson                                 Config
NuGet Config                                      nugetconfig                                Config
Dotnet local tool manifest file                   tool-manifest                              Config
Web Config                                        webconfig                                  Config
Solution File                                     sln                                        Solution
Protocol Buffer File                              proto                                      Web/gRPC

結構なテンプレートが使える感じだ。VSに出てくるのと近い感じだろうか。自動化するときに便利そう。早速つくってみる。-o オプションは、アウトプットを示すとヘルプに書いてあるが、ディレクトリがその名前で作成される。

> dotnet new console -o AppWithPlugin
The template "Console Application" was created successfully.

Processing post-creation actions...
Running 'dotnet restore' on AppWithPlugin\AppWithPlugin.csproj...
  Determining projects to restore...
  Restored C:\Users\tsushi\Code\NET\DependencyLoading\AppWithPlugin\AppWithPlugin.csproj (in 50 ms).
Restore succeeded.

ごく基本的なアプリケーションが生成されている様子。
image.png

今作ると、net5 になる感じ。

AppWithPlugin.csproj

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

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

</Project>

VisualStudio で使えるようにソリューションファイルを作成する。

> dotnet new sln
The template "Solution File" was created successfully.

次のような sln ファイルが出来上がる。

DependencyLoading.sln

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26124.0
MinimumVisualStudioVersion = 15.0.26124.0
Global
    GlobalSection(SolutionConfigurationPlatforms) = preSolution
        Debug|Any CPU = Debug|Any CPU
        Debug|x64 = Debug|x64
        Debug|x86 = Debug|x86
        Release|Any CPU = Release|Any CPU
        Release|x64 = Release|x64
        Release|x86 = Release|x86
    EndGlobalSection
    GlobalSection(SolutionProperties) = preSolution
        HideSolutionNode = FALSE
    EndGlobalSection
EndGlobal

csproj ファイルを ソリューションファイルに追加する

dotnet sln add AppWithPlugin\AppWithPlugin.csproj
Project `AppWithPlugin\AppWithPlugin.csproj` added to the solution.

sln ファイルに、 次の箇所が追加されている。

Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppWithPlugin", "AppWithPlugin\AppWithPlugin.csproj", "{EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}"
    GlobalSection(ProjectConfigurationPlatforms) = postSolution
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Debug|Any CPU.Build.0 = Debug|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Debug|x64.ActiveCfg = Debug|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Debug|x64.Build.0 = Debug|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Debug|x86.ActiveCfg = Debug|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Debug|x86.Build.0 = Debug|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Release|Any CPU.ActiveCfg = Release|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Release|Any CPU.Build.0 = Release|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Release|x64.ActiveCfg = Release|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Release|x64.Build.0 = Release|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Release|x86.ActiveCfg = Release|Any CPU
        {EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}.Release|x86.Build.0 = Release|Any CPU
    EndGlobalSection

上記の作業が済むと、slnファイルに csproj ファイルが認識されるので、VS から開いてみる。

Main コマンドラインパーサー

Main のプログラムを書く。これはコマンドラインパーサーのようだ。

   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: ");
                } 
                else
                {
                    foreach (string commandName in args)
                    {
                        Console.WriteLine($"-- {commandName} --");
                        Console.WriteLine();
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }
    }

プラグインインターフェイスの作成

dotnet new classlib -o PluginBase
dotnet sln add PluginBase/PluginBase.csproj

プラグインが実装すべきインターフェイスを作成しておく。

ICommand.cs

namespace PluginBase
{
    public interface ICommand
    {
        string Name { get; }
        string Description { get; }
        int Execute();
    }
}

AppWithPlugin のプロジェクトが、このインターフェイスのプロジェクトを参照できるようにする。

dotnet add AppWithPlugin/AppWithPlugin.csproj reference PluginBase/PluginBase.csproj

コマンドの実体は、AppWithPlugin.csproj のファイルに

  <ItemGroup>
    <ProjectReference Include="..\PluginBase\PluginBase.csproj" />
  </ItemGroup>

が追加されている。
VSで見ると次のイメージ

image.png

クラスローダーの実装

AssemblyLoadContext

アセンブリローディングのコンテキストオブジェクトです。これが複数あると、同じライブラリの別バージョンもロードすることが可能です。カスタムの AssemblyLoadContext を作りたいときは、自分でオーバーライドして作成します。

今回は AssemblyDependencyResolver という依存性解決のためのクラスを使っています。ResolveAssemblyToPath により、アセンブリ名から、アセンブリの存在するパスを取得しています。また、ResolveUnmanagedDllToPath によって、deps.json に載っている、ネイティブライブラリをロードします。戻りが、IntPtr になっているのはよくわかっていないので継続して調査が必要です。

** NOTE:** UnmanagedDll ネイティブライブラリロードの戻り値はなぜ IntPtr なのだろう?

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

プラグインのロード

こちらは、typeof(Program).Assembly.Location によって、Program クラスが所属するアセンブリ (DLL) の場所を戻している。ちなみに Path はクロスプラットフォーム用のライブラリで、Path.GetDirecotryName はディレクトリ名を取得している。
PluginContext を取得して、アセンブリをそこから取得している。

        static Assembly LoadPlugin(string relativePath)
        {
            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)));

        }

余談 Path 系メソッド

最初のパートはなぜそのようなコードになっているのかよくわからなかった。ぼんやりと、sln のいるプロジェクトルートを取得しているのはわかるが、Path.GetFullPathPath.Combine の存在意義がよくわからない。

ちなみに、こんなサンプルコードを書いて、Win10 と Linux (WSL2 ubuntu) でテストしてみた。

Code

            string root = Path.GetFullPath(Path.Combine(
                Path.GetDirectoryName(
                    Path.GetDirectoryName(
                        Path.GetDirectoryName(
                            Path.GetDirectoryName(
                                Path.GetDirectoryName(typeof(Program).Assembly.Location)))))));
            string rootWithoutCombine = Path.GetFullPath(
                Path.GetDirectoryName(
                    Path.GetDirectoryName(
                        Path.GetDirectoryName(
                            Path.GetDirectoryName(
                                Path.GetDirectoryName(typeof(Program).Assembly.Location))))));
            string rootWithoutCombineAndGetFullPath = 
                Path.GetDirectoryName(
                    Path.GetDirectoryName(
                        Path.GetDirectoryName(
                            Path.GetDirectoryName(
                                Path.GetDirectoryName(typeof(Program).Assembly.Location)))));
            Console.WriteLine(root);
            Console.WriteLine(rootWithoutCombine);
            Console.WriteLine(rootWithoutCombineAndGetFullPath);
            Console.WriteLine(typeof(Program).Assembly.Location);
            Console.WriteLine(Path.GetDirectoryName(typeof(Program).Assembly.Location));
            Console.WriteLine(Path.GetDirectoryName(Path.GetDirectoryName(typeof(Program).Assembly.Location)));

Windows

C:\Users\tsushi\Code\NET\DependencyLoading
C:\Users\tsushi\Code\NET\DependencyLoading
C:\Users\tsushi\Code\NET\DependencyLoading
C:\Users\tsushi\Code\NET\DependencyLoading\ClassLibrarySpike\bin\Debug\netcoreapp3.1\ClassLibrarySpike.dll
C:\Users\tsushi\Code\NET\DependencyLoading\ClassLibrarySpike\bin\Debug\netcoreapp3.1
C:\Users\tsushi\Code\NET\DependencyLoading\ClassLibrarySpike\bin\Debug

Linux

/home/ushio/Code/NET/DependencyLoading
/home/ushio/Code/NET/DependencyLoading
/home/ushio/Code/NET/DependencyLoading
/home/ushio/Code/NET/DependencyLoading/ClassLibrarySpike/bin/Debug/netcoreapp3.1/ClassLibrarySpike.dll
/home/ushio/Code/NET/DependencyLoading/ClassLibrarySpike/bin/Debug/netcoreapp3.1
/home/ushio/Code/NET/DependencyLoading/ClassLibrarySpike/bin/Debug

何が違うのか?

パスのデリミタなどはうまく対応できている。自分でパスをプラとフォームに合わせてめんどくさいコードを書く必要はない。ただ、GetFullPathPath.Combine がある意図がよくわからない。なんでやろ。ドキュメントのサンプルのミス(もともと違うコードで、複数の文字を Combine してたなど。

プラグインの作成

プロジェクトの作成

dotnet new classlib -o HelloPlugin
dotnet sln add HelloPlugin/HelloPlugin.csproj

csproj ファイル

Privateは重要で、この設定だと、PluginBaseプロジェクトを参照していますが、この設定が無いと、ここでビルドしたディレクトリ(outputフォルダ)の配下にPluginBase.dll が作成されます。そうなると、PluginContext がそのアウトプットディレクトリからアセンブリをロードしてしまいます。ですので、HelloPlugin.HelloCommand の実装する ICommandHelloPlugin の output ディレクトリの ICommand の実装となります。AppWithPlugin のデフォルトコンテキストからロードされたものになりません。

ExcludeAssets の設定に関しては、このサンプルに限っては挙動は同じなのですが、プロジェクトが複雑になって、 PluginBase がPackageReferenceを持っている場合、false が参照の方に伝播しないので、そのワークアラウンドとして、設定が必要になります。

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

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

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

</Project>

プラグイン本体

めっちゃ単純です。

HelloCommand.cs

    public class HelloCommand : ICommand
    {
        public string Name { get => "hello"; }

        public string Description { get => "Displays hello message.";  }

        public int Execute()
        {
            Console.WriteLine("Hello !!!");
            return 0;
        }
    }
}

Package reference の挙動の違い

Private = false

image.png

HelloPlugin.deps.json

{
  "runtimeTarget": {
    "name": ".NETCoreApp,Version=v5.0",
    "signature": ""
  },
  "compilationOptions": {},
  "targets": {
    ".NETCoreApp,Version=v5.0": {
      "HelloPlugin/1.0.0": {
        "dependencies": {
          "PluginBase": "1.0.0"
        },
        "runtime": {
          "HelloPlugin.dll": {}
        }
      }
    }
  },
  "libraries": {
    "HelloPlugin/1.0.0": {
      "type": "project",
      "serviceable": false,
      "sha512": ""
    }
  }
}

Private = false なし

image.png

HelloPlugin.deps.json

{
  "runtimeTarget": {
    "name": ".NETCoreApp,Version=v5.0",
    "signature": ""
  },
  "compilationOptions": {},
  "targets": {
    ".NETCoreApp,Version=v5.0": {
      "HelloPlugin/1.0.0": {
        "dependencies": {
          "PluginBase": "1.0.0"
        },
        "runtime": {
          "HelloPlugin.dll": {}
        }
      },
      "PluginBase/1.0.0": {
        "runtime": {
          "PluginBase.dll": {}
        }
      }
    }
  },
  "libraries": {
    "HelloPlugin/1.0.0": {
      "type": "project",
      "serviceable": false,
      "sha512": ""
    },
    "PluginBase/1.0.0": {
      "type": "project",
      "serviceable": false,
      "sha512": ""
    }
  }
}

実行

パラメータや、実行ファイルの場所が記載されていなかったので、追加。パラメータで、読み込むアセンブリと、Plugin の名前を指定するようにした。

    static void Main(string[] args)
        {
            try
            {
                if (args.Length == 1 && args[0] == "/d")
                {
                    Console.WriteLine("Waiting for any key...");
                    Console.ReadLine();
                }

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

                IEnumerable<ICommand> commands = pluginPaths.SelectMany(pluginPath =>
                {
                    Assembly pluginAssembly = LoadPlugin(pluginPath);
                    return CreateCommands(pluginAssembly);
                }).ToList();
                if (args.Length == 0)
                {
                    Console.WriteLine("Commands: ");
                    // Output the loaded commands.
                    foreach (ICommand command in commands)
                    {
                        Console.WriteLine($"{command.Name}\t - {command.Description}");
                    }
                }
                else
                {
                    foreach (string commandName in args.Skip(1))
                    {
                        Console.WriteLine($"-- {commandName} --");
                        // Execute the command with the name passed as an argument.
                        ICommand command = commands.FirstOrDefault(command => command.Name == commandName);
                        if (command == null)
                        {
                            Console.WriteLine("No such command is known.");
                            return;
                        }

                        command.Execute();

                        Console.WriteLine();
                    }
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
            }
        }

image.png

Loading commands from: C:\Users\tsushi\Code\NET\DependencyLoading\HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll
-- hello --
Hello !!!
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1