目的
ASP.NETにおけるDI(Dependency Injection)時に、ConfigureServicesで個々の型をひとつひとつ登録しているときりがないし、これが開発のボトルネックになりかねません。
そこで必要となるのが、アセンブリスキャンによるサービスの自動登録です。AutoFacなどのDIコンテナでは標準搭載していますが、MicrosoftのDIコンテナでは今のところ未搭載です。
また、例えAutoFacを使ったとしても、「C#でアセンブリを取得するのはあなたが考えるより難しい(Getting Assemblies Is Harder Than You Think In C#)」でも書かれているとおり、スキャン対象のアセンブリを取得するのはなかなか面倒な作業でもあります。
この記事では、アセンブリをスキャンして登録対象の型を自動的にDIにサービス登録する為の適切な方法について検討し、解決策を提示します。
課題
「アセンブリをスキャンして」と一口に言うものの、スキャン対象となるアセンブリを取得するにはどうすればいいのでしょうか。直接パス指定でDLLを指定してロードするのは環境依存が強くなるので、できれば避けたいところです。
× AppDomain.CurrentDomain.GetAssemblies()
ASP.NETの場合、まず最初に思いつくのはAppDomain.CurrentDomain.GetAssemblies()
の呼び出しです。
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
しかしここで返ってくるアセンブリは、「その時点でロードされているアセンブリ」のみです。.NETでのアセンブリのロードは動的で、「必要になったタイミングでロードされる」為、この方法では対象のアセンブリを見つけることができないかもしれません。
△ Web.Compilation.BuildManager.GetReferencedAssemblies()
旧ASP.NETでは、BuildManager.GetReferencedAssemblies()
がありました。
これはWeb.configに登録されているアセンブリのリストを全部ロードして返してくれる為、ロードされているかによらず安定しています。
var assemblies = Web.Compilation.BuildManager.GetReferencedAssemblies();
しかし、理由は分からないものの(パフォーマンス上の問題があったのでしょうか)、ASP.NET Coreではこのクラスはなくなってしまいました。代替となるような機能もちょっと見つかりません。
旧ASP.NETでだけ運用するならアリかもしれませんが、今から作るのなら、普遍的な方法を探りたいものです。
△ Assembly.GetExecutingAssembly().GetReferencedAssemblies()
これは.NET Core/5+ でも使用できるAssembly
クラスのメソッドです。
まずはAssembly.GetExecutingAssembly()
で、現在実行中のアセンブリを取得します。ASP.NETであれば、エントリポイントとなるメインプロジェクトのアセンブリが返されるでしょう。
そして、Assembly.GetReferencedAssemblies()
は、そのアセンブリが直接参照しているアセンブリの一覧をAssemblyName
クラスで返します。取得できるのはAssembly
ではなくAssemblyName
なので、この時点でロードされていなくとも取得できます。
これを用いて、次のように各アセンブリをLoadする処理を書けば、エントリポイントのアセンブリが参照している全てのアセンブリを取得できます。
var asmlist = Assembly.GetExecutingAssembly().GetReferencedAssemblies()
.Select<Assembly>(asmName => Assembly.Load(asmName))
.ToList();
ちなみにAssembly.Load(asmName)
は、既にロード済みの場合は再ロードせずロード済みのアセンブリを返してくれますので、多重にロードされることはありません。
しかし、これにもまだ問題があります。
なぜなら、スキャン対象としたいアセンブリの中には、エントリポイントのアセンブリから直接参照されていない(間接的に利用されている)アセンブリがあるかもしれない為です。
Web -> Logic -> Dao
例えば上記のような参照関係になっている場合には、Webがエントリポイントのアセンブリであり、Assembly.GetReferencedAssemblies()
ではDaoにたどり着けない為、Daoを取得することができません。
〇 GetReferencedAssemblies()を再帰的に呼びだす
それならばということで、エントリポイントのアセンブリから初めて、GetReferencedAssemblies()
で取得した個々のアセンブリに対して、さらにGetReferencedAssemblies()
していきます。
そうすることで、漏れなくアセンブリのツリーを探索することができます。
実装としては自分自身を繰り返し呼び出す、再帰呼び出しになるでしょう。
しかしこれでは、スキャン対象ではないアセンブリまで大量にロードすることになります。それで問題ないこともあると思いますが、できれば必要な範囲のロードに留めたいところです。
そこで、取得したAssemblyNameからAssembly.Loadするかどうかを判断する為に、「対象となるアセンブリ名」のリストを渡すことにします。
var targetAssemblyNames = new[]{"Logic", "Dao"};
var assemblies = asm.GetReferencedAssemblies()
.Where(asmName => targetAssemblyNames.Contains(asmName.Name))
.Select<Assembly>(asmName => Assembly.Load(asmName))
.ToList();
Web -> Logic -> Dao
Logicがロードされた後、Logicが参照するアセンブリとしてDaoへ到達できます。
こうすれば、アセンブリのツリー中から必要な部分のみのアセンブリリストを取得できるでしょう。
あとは上記の処理を再帰呼び出しに変えればOKです。
対象となるアセンブリを取得することができたので、あとはその中から型情報を取り出し、対象となる型をサービス登録するだけです。
最終的な解決策
以上を踏まえて、以下のように使用できる拡張メソッド群を作成しました。
// 自動DI登録対象のアセンブリを取得
var assemblies = System.Reflection.Assembly.GetExecutingAssembly().CollectReferencedAssemblies(
new[]{ "MyRepogitory", "MyLogic" }
);
// MyRepogitoryアセンブリ中の、IRepogitoryを実装している全ての型をScopedで登録
services.AddAssemblyTypes<IRepogitory>(assemblies.GetByName("MyRepogitory"), ServiceLifetime.Scoped);
// MyLogicアセンブリ中の、末尾にLogicが付く全ての型をScopedで登録
services.AddAssemblyTypes(assemblies.GetByName("MyLogic"), ServiceLifetime.Scoped, "Logic");
// 対象の全てのアセンブリ中の、先頭がFakeで始まる全ての型をScopedで登録
services.AddAssemblyTypes(assemblies, ServiceLifetime.Scoped, t => t.Name.StartsWith("Fake"));
基本的には、以下の条件を満たすもののみをスキャン対象としています。
- 公開されている型(asm.GetExportedTypes())
- インタフェースではない(!type.IsInterface)
- 抽象型ではない(!type.IsAbstract)
このあたりは必要に応じて改変してください。
現状、AddAsImplementedInterfacesメソッドの中では、実装している全てのインタフェースに対してサービス登録しているので、例えばIDisposableなど、無視して良いような標準的なインタフェースについては除外するよう改変しても良いかもしれません。
その他の情報
AssemblyLoaderExtensionsだけあれば、AutoFacなどのDIコンテナのアセンブリスキャン機能にも利用可能です。
また、Microsoft.Extensions.DependencyInjectionにアセンブリスキャン機能を追加する「Scrutor」というオープンソースもあるので、こちらと組み合わせてもよいと思います。