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

Azure Functions ホストの実装は　クラスローディングの塊なので、そろそろ仕組みをじっくり理解したくなってきた。そのために、その基礎のクラスの使い方を理解していきたい。今日はまず [Create a .NET Core application with plugins](https://docs.microsoft.com/en-us/dotnet/core/tutorials/creating-app-with-plugin-support)のチュートリアルを理解しながら流していきたい。

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

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

```csharp
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` オプションは、アウトプットを示すとヘルプに書いてあるが、ディレクトリがその名前で作成される。

```cmd
> 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470/95c56984-0ac9-3665-7706-085efd424f11.png)

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

_AppWithPlugin.csproj_

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

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

</Project>

```


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

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

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

_DependencyLoading.sln_ 

```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 ファイルを ソリューションファイルに追加する

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

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

```sln
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AppWithPlugin", "AppWithPlugin\AppWithPlugin.csproj", "{EF89D6BC-1C8C-4CEA-81C4-B0F89D5FCCE5}"
```

```sln
	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 のプログラムを書く。これはコマンドラインパーサーのようだ。

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

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

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

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

_ICommand.cs_

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

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

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

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

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

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

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470/cdc7bdde-4059-255c-4a4b-bd7a8e27bbcd.png)


# クラスローダーの実装

## AssemblyLoadContext

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

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

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

```csharp
  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` を取得して、アセンブリをそこから取得している。

```csharp
        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.GetFullPath` と `Path.Combine` の存在意義がよくわからない。

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

### Code

```csharp
            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 

```cmd
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

```bash
/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
```

### 何が違うのか？

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

# プラグインの作成

## プロジェクトの作成

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

## csproj ファイル

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

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

* [ProjectReference Private](https://docs.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-2015/msbuild/common-msbuild-project-items?view=vs-2015&redirectedfrom=MSDN#reference)
* [Plugins should include ExcludeAssets=runtime for project references to shared assemblies](https://github.com/dotnet/docs/issues/15811)


```xml
<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_

```csharp
    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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470/877c1a41-aa7f-a31c-719e-1fe91fb3616d.png)

_HelloPlugin.deps.json_

```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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470/89e75411-f8a5-beb6-6cab-038ded9793af.png)


_HelloPlugin.deps.json_

```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 の名前を指定するようにした。

```csharp
    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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3470/a6cafde5-94bd-d025-cc8a-15b05c6e0d45.png)

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

* [ソースコード](https://github.com/TsuyoshiUshio/DependencyLoading)