natural born minority
GenricHost
& Microsoft.Extensions.DependencyInjection
で「注釈ベースのAutoScan(自動検索・登録)」なDIが出来るようになる15年ぶりくらいに「WindowsのGUIアプリ(Windowsのクライアント)」を作成することになったのですが、Javaでサーバアプリを組むことが多かったので、「はあ、レイヤードアーキテクチャとDIで組みたいなぁ」と思ったのです。
.NET系は長く「スタンダードなDIがない(あるにはあるが群雄割拠である)」と思っていました。
が、昨今は「常駐系のアプリは全部コレ使え」な Generic Host
への統一が促進される中で、MS謹製の Microsoft.Extensions.DependencyInjection
というDIが付いてきたので飛びつきました。
ただ、このDI「自力で登録する」式のやつでして…。
Java & SpringBootでの
「”ここ以下全部”と指定してアノテーション付けときゃ勝手にDI管理してくれる」
の書き心地を知ってると、少々面倒…。
ということで「SpringBoot風の注釈で勝手にDI登録できる仕組み」を作ってみます。
この記事は以下の前提で書かれています。
VisualStudio2019
がインストールされている.NET5
がインストールされており、 dotnet
コマンドが使える.NET5
、 言語は C#
なお、実装されたものが こちらのリポジトリ にあります。
対象となるアプリと「DIが出来る状態」までを事前に作ります。
.NET 5.0
を選択するGeneric Host
と WindowsFormsLifetime
を加えるcd [プロジェクトのフォルダ]
dotnet add package OswaldTechnologies.Extensions.Hosting.WindowsFormsLifetime
dotnet add pacakge Microsoft.Extensions.Hosting
Program.cs
の Main
メソッドを書き換えるGeneric Host
式の起動方法に書き換える
static class Program
{
static void Main() => CreateHostBuilder().Build().Run();
public static IHostBuilder CreateHostBuilder() =>
Host.CreateDefaultBuilder(Array.Empty<string>())
.UseWindowsFormsLifetime<Form1>()
.ConfigureServices((hostContext, services) => { });
}
実際の実装は こちらのリポジトリ になります。
「参考のための実装」なので、簡単な解説だけにします。
MiuraService.Get()
-> IMiuraRepositry.Get()
から、実装である MiuraDataSource.Get()
が呼び出され TheMiura
オブジェクトが取り出されるMiuraService
に MiuraDatasource
がコンストラクタによりフィールドにオブジェクトがセットされる予定Form1
には MiuraService
がコンストラクタでフィールドにセットされる予定Form1
は Load
イベントで「 TheMiura
オブジェクトからの文字列がタイトル部分に表示される」実装になっているが、現在は NullReferenceException
で落ちる
属性(attribute)
なので「classに特定の属性がついていたら」を条件とする
Component
, Repository
, Service
あたりがあればController
は、少し違うのでForm等のために View
という属性にしてみるSingleton
のみ
Scoped
, Transient
があるが今回は対応しない以下の実装を行います。
[AttributeUsage(AttributeTargets.Class)]
public class ComponentAttribute : Attribute { }
[AttributeUsage(AttributeTargets.Class)]
public class ServiceAttribute : ComponentAttribute { }
[AttributeUsage(AttributeTargets.Class)]
public class RepositoryAttribute : ComponentAttribute { }
[AttributeUsage(AttributeTargets.Class)]
public class ViewAttribute : ComponentAttribute { }
本家を踏襲し、Component
を基本に、それ以外は継承した形としています。
「.NET(C#)の属性クラス」は、クラス名末尾に Attribute
を付けるルールであり、実際に属性として使用する場合には「クラス名の Attribute
より前の名前で指定する」感じになります。
例:
[Component]
public class ClassOfDITarget
「対象のアセンブリ中、特定の属性が付与されたクラスはDI登録する」という拡張メソッドを定義したクラスを作成します。
IServiceCollection
をへの拡張クラスとして実装することとします。
public static class AttributedClassScannerExtension
{
public static void AddAttributedClassOf(this IServiceCollection services, Assembly scanTargetAssembly)
{
scanTargetAssembly.ExportedTypes
.Where(type => type.IsClass && !type.IsSubclassOf(typeof(Attribute)))
.Where(type => type.GetCustomAttributes<ComponentAttribute>().Any())
.ToList()
.ForEach(type => RegisterDI(type, services));
}
private static void RegisterDI(Type type, IServiceCollection services)
{
if (type.GetInterfaces().Any())
services.AddSingleton(type.GetInterfaces()[0], type);
else services.AddSingleton(type);
}
}
少々分かり辛いかもですが、以下を行っています。
ComponentAttribute
あるいはそのサブクラスの属性が付いたものをフィルタIServiceCollection
へシングルトンでDI登録
Generic Host
から「DIに自動登録するメソッド」を呼ばせる準備作業で書き換えたProgram.cs
をさらに書き換えます。
using [先程追加した AttributedClassScannerExtension のnamespace];
(省略)
static class Program
{
static void Main() => CreateHostBuilder().Build().Run();
public static IHostBuilder CreateHostBuilder() =>
Host.CreateDefaultBuilder(Array.Empty<string>())
.UseWindowsFormsLifetime<Form1>()
.ConfigureServices((hostContext, services) =>
{
// ↓ここを追加
services.AddAttributedClassOf(typeof(Program).Assembly);
});
}
自身アセンブリを引数に、先程定義した拡張メソッドを呼ぶようにします。
拡張メソッドなので、using
に対象クラスの namespace
記述を忘れないように。
DI登録の対象にしたいクラスに属性を追加していきます。
使う側からは、Attributeクラスの Attribute
を除いたクラス名を属性として指定します。
例えば、
[Service]
public class MiuraService
{ /* 省略 */ }
[Repository]
public class MiuraDatasource : IMiuraRepository
{ /* 省略 */ }
[View]
public partial class Form1 : Form
{ /* 省略 */ }
と言った感じでクラスに付与します。
実際にDI登録・オブジェクトの取り出しとコンストラクタインジェクションが行われているかを動かして確認します。
VisualStudio2019
で作ったままの Form1
というFormがあるので、そのウィンドウタイトルに「Serviceから取得した値」を表示してみます。
Form1
に以下の実装を行います。
[View]
public partial class Form1 : Form
{
private readonly MiuraService service;
public Form1(MiuraService service)
{
InitializeComponent();
this.service = service;
}
private void Form1_Load(object sender, EventArgs e)
{
var miura = service.Get();
Text = miura.ToString();
}
}
起動したウィンドウのタイトルに “Kazuhito Miura” が表示されれば、Form -> Service -> Datasource(Repository) -> ドメインオブジェクト と辿って値を取得したということ。
つまりは「DIが属性ベースで自動登録されている」ということです。
自分も、自身が所属しているチームも、普段は「Javaでサーバサイドを作ってるチーム」であり、「C#でクライアントを実装する」というの突然のイレギュラーでした。
せめて「書き心地を似せられないか?」と思ってDI部分をSpringBootに寄せてみました。
普段も「シングルトン期待で複雑な機能は使ってない」ので、それさえサポートすれば十分使えると思ってたので、狙い通りのものは出来た感じです。
…チームの仲間が無茶を言うてこなければ、ですが。