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.
今作ると、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で見ると次のイメージ
クラスローダーの実装
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.GetFullPath
と Path.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
何が違うのか?
パスのデリミタなどはうまく対応できている。自分でパスをプラとフォームに合わせてめんどくさいコードを書く必要はない。ただ、GetFullPath
と Path.Combine
がある意図がよくわからない。なんでやろ。ドキュメントのサンプルのミス(もともと違うコードで、複数の文字を Combine
してたなど。
プラグインの作成
プロジェクトの作成
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を持っている場合、false が参照の方に伝播しないので、そのワークアラウンドとして、設定が必要になります。
- ProjectReference Private
- Plugins should include ExcludeAssets=runtime for project references to shared assemblies
<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
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 なし
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);
}
}
Loading commands from: C:\Users\tsushi\Code\NET\DependencyLoading\HelloPlugin\bin\Debug\net5.0\HelloPlugin.dll
-- hello --
Hello !!!