やること
独自のDependency ResolverとASP.NET Coreを連携させる方法の完全版です。
以前の記事ではIControllerActivatorを使用したコントローラーに対するDIの連携を実装しましたが、カスタムIServiceProviderを実装することでコントローラー以外への連携も可能になるため、その方法について記述します。
環境
- Visual Strudio 2015
- .NET Core Tooling Preview 2 for Visual Studio 2015
- Smart.Resolver 1.1.2 (自作のGuice型Dependency Resolver)
サンプル
以下のサンプルを今回の内容も踏まえてアップデートしています。
ASP.NET CoreにおけるDI
連携方法の解説の前に、ASP.NET CoreにおけるDIが使用できる箇所をおさらいしておきます。
以下は全てASP.NET Coreの標準動作であり、基本的にインジェクションされるインスタンスはIServiceProviderから取得されます。
なお、以下の記述ではサンプルソースからの抜粋を例として扱っています。
コントローラー
コントローラーに対してコンストラクタインジェクションを行えます。
[Route("api/[controller]")]
public class ItemController : Controller
{
private MasterService MasterService { get; }
public ItemController(MasterService masterService)
{
MasterService = masterService;
}
[HttpGet]
public IEnumerable<ItemEntity> Get()
{
return MasterService.QueryItemList();
}
}
IControllerActivatorをカスタマイズしない場合、コントローラーのインスタンス自体はIServiceProvider外で生成され、引数のインスタンスのみがIServiceProviderから取得されます。
IControllerActivatorをカスタマイズする場合、コントローラーの生成をカスタムResolverに行わせることで、そこから連鎖して依存するオブジェクトの解決もカスタムResolverに行わせる形となります。
よって、引数のインスタンスのみをカスタムResolverで解決するのであれば、IControllerActivatorをカスタマイズする方法でも、IServiceProviderを置換する方法でも、どちらの方法でも対応はできます。
コントローラー自体に対しても、標準ではサポートされないプロパティインジェクションやコンディショナルバインディングを行いたい場合には、IServiceProvider置換の有無にかかわらず、IControllerActivatorのカスタマイズが必要になります。
アクション
FromServicesAttributeを使用することで、コントローラーのアクションメソッドに対してインジェクションを行えます。
コントローラーの処理のうち、特定のアクションでしか使用しないコンポーネント等はこの方法で解決できます。
[MetricsFilter]
public class FilterController : Controller
{
public IActionResult Index([FromServices] MetricsManager metricsManager)
{
return View(metricsManager);
}
}
想定される使用ケースとしては、PDF/CSV出力コンポーネントや、認証マネージャーなどのコンポーネント解決時でしょうか。
ビュー
@injectディレクティブを使用することで、ビューに対してコンポーネントのインジェクションが行えます。
@inject Microsoft.AspNetCore.Hosting.IHostingEnvironment Env
@inject Microsoft.Extensions.Options.IOptions<DependencyInjectionExample.Settings.ProfileSettings> Profile
@inject DependencyInjectionExample.Services.CharacterService CharacterService
<h2>Environment</h2>
<div>@Env.EnvironmentName</div>
<h2>Option</h2>
<div>@Html.DropDownList("Gender", Profile.Value.Genders.Select(_ => new SelectListItem { Text = _, Value = _ }))</div>
<h2>User Service</h2>
<div>@CharacterService.QueryCharacterList().Count</div>
IHostingEnvironmentの様に、ASP.NET Coreのコンポーネントを直接インジェクションして動作環境に応じて表示を変えたり、CharacterServiceの様に様アプリケーション固有のロジックをインジェクションしてビューから直接使うことも可能です。
また、以下のようにappsettings.jsonに設定を記述して、それに対応する設定値クラスを作成し、その内容をIServiceProviderに登録することで、設定の内容からドロップダウンリストを作成するような処理をコントローラーを経由せずに行うことも可能となります。
{
...
"ProfileSettings": {
"Genders": [
"Male",
"Female",
"Ohter"
]
}
}
// 設定クラス
public class ProfileSettings
{
public string[] Genders { get; set; }
}
public class Startup
{
...
public IServiceProvider ConfigureServices(IServiceCollection services)
{
...
// Settings
ConfigureSettings(services);
...
}
private void ConfigureSettings(IServiceCollection services)
{
services.AddOptions();
// 設定ファイルのセクションの内容をProfileSettingsとして登録
services.Configure<ProfileSettings>(Configuration.GetSection("ProfileSettings"));
}
...
}
なお、ビューから直接モデルをpullして使用することは責務を崩すことになりかねないので、適用範囲を検討し、View Componentsとの使い分けを考えて、用量用法を守って使うようにしてください。
フィルター
TypeFilterAttributeを継承して、コンポーネントがインジェクションされたフィルタを作成することができます。
public class MetricsFilterAttribute : TypeFilterAttribute
{
public MetricsFilterAttribute()
: base(typeof(MetricsActionFilter))
{
}
private class MetricsActionFilter : IActionFilter
{
private readonly MetricsManager metricsManager;
public MetricsActionFilter(MetricsManager metricsManager)
{
this.metricsManager = metricsManager;
}
public void OnActionExecuting(ActionExecutingContext context)
{
metricsManager.Increment();
}
public void OnActionExecuted(ActionExecutedContext context)
{
}
}
}
// 使用例
[MetricsFilter]
public class FilterController : Controller
{
...
}
サービス層のコンポーネントを使用するフィルタを作ることで、データベースの値による前処理フィルタ等を簡単に作ることが可能になります。
その他
例えば、AuthorizationHandler等にもインジェクションを行えますし、そもそもASP.NET Core自体がコンポーネントのDIによって構築されているともいえます。
ここで一つのポイントは、ASP.NET Coreのコンポーネントも、アプリケーション固有のユーザコンポーネントも、等しくIServiceProviderを通じて扱われるという点にあります。
これは、ユーザコンポーネントがASP.NET Coreのコンポーネントを使用することはもちろん、ユーザコンポーネントを参照するASP.NET Coreのコンポーネントの実装を用意することで、アプリケーション固有のデータを参照してASP.NET Coreの挙動を変更することが容易にできるということでもあります。
以上より、カスタムResolverを使用するIServiceProviderを実装することにより、全てのDI処理をカスタムResolverに委譲することが可能になります。
カスタム版の実装
ASP.NET Coreにおいて、DIの恩恵をいたるところでうけるためにはカスタムIServiceProviderを用意すれば良いことはわかったと思うので、その実装方法について記述します。
IServiceProviderの置換
標準で使用されるIServiceProviderを置換する方法について記述します。
ASP.NET Core Web Applicationのテンプレートで作成した場合、Startup.csでの初期化処理は以下のようになっていると思います。
public void ConfigureServices(IServiceCollection services)
{
...
}
この、ConfigureServices()の戻り値をvoidからIServiceProviderに変更することで、標準のIServiceProviderに変わってその戻り値が使用されるようになります。
テンプレートで生成されるコードは、以下と等価ともいえます。
public IServiceProvider ConfigureServices(IServiceCollection services)
{
...
// ここでカスタムIServiceProviderを返せばそれが使用される
return services.BuildServiceProvider();
}
なお、ConfigureServices()が呼び出された時点で既にIServiceCollectionにはASP.NET Coreのコンポーネント情報が登録されています。
また、AddMvc()等の拡張メソッドによってもコンポーネント情報が登録されますが、カスタムIServiceProviderに置換する場合、IServiceCollectionに登録されているコンポーネント情報もカスタムIServiceProviderで扱えるようにする必要があります。
この詳細については後述します。
カスタムIServiceProviderの実装
以下のような形で、カスタムResolverをラップする形でIServiceProviderを実装します。
using System;
using System.Collections.Generic;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Smart.Resolver;
public class SmartResolverServiceProvider : IServiceProvider, ISupportRequiredService
{
private static readonly Type EnumerableType = typeof(IEnumerable<>);
private readonly IResolver resolver;
public SmartResolverServiceProvider(IResolver resolver)
{
this.resolver = resolver;
}
public object GetService(Type serviceType)
{
return GetServiceInternal(serviceType, false);
}
public object GetRequiredService(Type serviceType)
{
return GetServiceInternal(serviceType, true);
}
private object GetServiceInternal(Type serviceType, bool required)
{
if (serviceType.GetTypeInfo().IsGenericType && serviceType.GetGenericTypeDefinition() == EnumerableType)
{
return ResolverHelper.ConvertArray(
serviceType.GenericTypeArguments[0],
resolver.ResolveAll(serviceType.GenericTypeArguments[0], null));
}
if (required)
{
return resolver.Get(serviceType);
}
bool result;
return resolver.TryGet(serviceType, out result);
}
}
なお、ASP.NET Coreで使用されるIServiceProviderについては、以下のような要件が必要とされます。
これらの要件のうち、いくつかの項目については以前のSmart.Resolverは対応していなかったので、今回の実験にあたってその部分の挙動を修正しました。
- IServiceProviderとISupportRequiredServiceの使い分け
ASP.NET Coreが必須とするものについてはISupportRequiredService.GetRequiredService()が使用され、オプショナルなものについてはIServiceProvider.GetService()が使用されます。
実際には、IServiceProviderしか実装しない場合の挙動もありますが、ここでは省略します。
- IEnumerableの扱い
ASP.NET CoreはIServiceCollectionに対して、同じインターフェースの情報を複数件登録してきます。
例えば、インターフェースIHoge型複数件の登録に対して、serviceTypeとしてIEnumerable<IHoge>がから要求された場合、個別に登録されているIHoge複数件をIEnumerable<IHoge>として返す挙動が求められます。
また、遅延評価の形だとまずいようなので、SmartResolverServiceProviderではResolverHelper.ConvertArray()を使用して、この時点でserviceType型の配列に変換して返すようにしています。
- オープンジェネリック型への対応
ASP.NET CoreはIServiceCollectionに対して、オープンジェネリック型の情報を登録してきます。
IFoo<> to Foo<>のようなオープンジェネリック型の情報登録に対して、serviceTypeとしてクローズジェネリック型のIFoo<Bar>が要求された場合、Foo<Bar>のインスタンスを返す挙動が求められます。
- 複数のコンポーネントの登録
ASP.NET CoreはIServiceCollectionに対して、登録時に重複チェックをするようなことはせず、同じ型に対するシングルトンの情報を複数件登録してきます。
登録情報が複数件あった場合、エラーとはせずに、そのうちの1件(一番最後)の情報を元にンスタンスを返す挙動が求められます。
- コンストラクタの選定
複数のコンストラクタを持つクラスのインスタンスが要求された場合に、解決可能な引数の数が最も多いコンストラクタを使用する挙動が求められます。
例えば以下のようなクラスのインスタンスが要求されたときに、IFoo、IBarともに登録があれば引数2つのコンストラクタを、IBarの登録がなければ引数1つのコンストラクタを使用する動作である必要があります。
public class Hoge
{
public Hoge(IFoo foo)
{
}
public Hoge(IFoo foo, IBar bar)
{
}
}
カスタムIServiceProviderの生成
前述してあるように、カスタムIServiceProviderでは、IServiceCollectionに登録されているASP.NET Coreコンポーネントの情報も扱えるようにする必要があり、IServiceCollectionの情報の移し替えが必要になります。
そこで、以下のようなヘルパーを作ることで、情報の移し替えと、カスタムIServiceProviderの生成を行うようにしています。
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Smart.Resolver;
using Smart.Resolver.Bindings;
public static class SmartResolverHelper
{
public static IServiceProvider BuildServiceProvider(StandardResolver resolver, IEnumerable<ServiceDescriptor> descriptors)
{
foreach (var descriptor in descriptors)
{
if (descriptor.ImplementationType != null)
{
resolver
.Bind(descriptor.ServiceType)
.To(descriptor.ImplementationType)
.ConfigureScope(descriptor.Lifetime);
}
else if (descriptor.ImplementationFactory != null)
{
resolver
.Bind(descriptor.ServiceType)
.ToMethod(kernel => descriptor.ImplementationFactory(kernel.Get<IServiceProvider>()))
.ConfigureScope(descriptor.Lifetime);
}
else if (descriptor.ImplementationInstance != null)
{
resolver
.Bind(descriptor.ServiceType)
.ToConstant(descriptor.ImplementationInstance)
.ConfigureScope(descriptor.Lifetime);
}
}
resolver.Bind<IServiceProvider>().To<SmartResolverServiceProvider>().InSingletonScope();
resolver.Bind<IServiceScopeFactory>().To<SmartResolverServiceScopeFactory>().InSingletonScope();
resolver.Bind<IHttpContextAccessor>().To<HttpContextAccessor>().InSingletonScope();
resolver.Bind<RequestScopeStorage>().ToSelf().InSingletonScope();
return resolver.Get<IServiceProvider>();
}
private static void ConfigureScope(this IBindingInSyntax syntax, ServiceLifetime lifetime)
{
switch (lifetime)
{
case ServiceLifetime.Singleton:
syntax.InSingletonScope();
break;
case ServiceLifetime.Transient:
syntax.InTransientScope();
break;
case ServiceLifetime.Scoped:
syntax.InRequestScope();
break;
}
}
}
IServiceCollection(IEnumerable<ServiceDescriptor>)の情報については、ServiceDescriptorのメンバを判定し、ImplementationTypeの型による生成か、ファクトリーメソッドImplementationFactoryによる生成か、固定値ImplementationInstanceへの参照かによって、処理を分けてStandardResolverへの登録情報を構築しています。
その後にあるresolver.Bind()の記述は、カスタムIServiceProvider自身を扱えるようにするための登録情報と、リクエストスコープを扱えるようにするための登録情報になります。
リクエストスコープの扱いについては次の段落で記述します。
最後は、カスタムIServiceProviderをStandardResolver自身から取得して返し、StartupのConfigureServices()で以下のように使用することでIServiceProviderの置換を行います。
public IServiceProvider ConfigureServices(IServiceCollection services)
{
// Add framework services.
services.AddMvc();
...
// Use custom service provider.
return SmartResolverHelper.BuildServiceProvider(resolver, services);
}
なお、アプリケーション固有のユーザコンポーネントは一端IServiceCollectionに登録する形でも、直接カスタムResolverに登録する形でも、どちらでも問題はありませんが。
サンプルでは、AddMvc()のようにIServiceCollectionへの拡張メソッドがあるもので登録されるコンポーネントは一端IServiceCollectionに情報を登録し、ユーザコンポーネントは直接カスタムResolverに登録して、最後にSmartResolverHelper.BuildServiceProvider()でマージする形としています。
リクエストスコープ
リクエストスコープに関連して、カスタムIServiceProviderにはIServiceScopeFactoryを解決できることが要件として求められます。
IServiceScopeFactoryとそれが生成するIServiceScopeを使用して、リクエストスコープのインスタンスが管理されます。
以下に、Smart.Resolver用の実装を示します。
using System;
using Microsoft.Extensions.DependencyInjection;
public class SmartResolverServiceScope : IServiceScope
{
private readonly RequestScopeStorage storage;
public IServiceProvider ServiceProvider { get; }
public SmartResolverServiceScope(IServiceProvider serviceProvider, RequestScopeStorage storage)
{
ServiceProvider = serviceProvider;
this.storage = storage;
}
public void Dispose()
{
storage.Clear();
}
}
using System;
using Microsoft.Extensions.DependencyInjection;
public class SmartResolverServiceScopeFactory : IServiceScopeFactory
{
private readonly IServiceProvider serviceProvider;
private readonly RequestScopeStorage storage;
public SmartResolverServiceScopeFactory(IServiceProvider serviceProvider, RequestScopeStorage storage)
{
this.serviceProvider = serviceProvider;
this.storage = storage;
}
public IServiceScope CreateScope()
{
return new SmartResolverServiceScope(serviceProvider, storage);
}
}
IServiceScopeの実装はリクエスト毎にIServiceScopeFactoryから生成されます。
リクエスト中のコンポーネントは生成されたIServiceScopeのIServiceProviderプロパティから取得され、リクエスト終了時にはDispose()が呼び出されます。
ここにフックを用意することにより、リクエスト間のみ存在するコンポーネントを扱えるようにする、っというのが求められる実装となります。
Smart.Resolverのスコープ管理は単純な設計となっており、スコープとは、特定のコンテキストに関連づけられたキャッシュのストレージ(ディクショナリ)である、っという考えになっています。
例えば、シングルトンスコープのストレージは単一のディクショナリであり、スコープ無し(Transient)のストレージはストレージ自体がなしという形になっています。
そこで、リクエストスコープのストレージとしては、IHttpContextAccessorをDIしてもらい、HttpContextをコンテキストとするRequestScopeStorageを用意しています。
後は、IServiceScope.Dispose()ではそれをクリアするだけで、リクエストスコープの対応ができる形となっています。
サンプルでは、ScopedController及びそのIndex.cshtmlにおいて、スコープ管理されるScopedObjectを同一リクエスト中に複数回インジェクションしてもらい、そのインスタンスが同じものであることを確認する例が入っています。
以下に、サンプルでの使用ケースからコードを抜粋します。
public sealed class ScopedObject : IDisposable
{
private readonly ILogger<ScopedObject> logger;
public ScopedObject(ILogger<ScopedObject> logger)
{
this.logger = logger;
logger.LogInformation("Construct {0}", GetHashCode());
}
public void Dispose()
{
logger.LogInformation("Dispose {0}", GetHashCode());
}
}
private void SetupComponents()
{
...
resolver
.Bind<ScopedObject>()
.ToSelf()
.InRequestScope();
...
}
public class ScopedController : Controller
{
private ScopedObject ScopedObject { get; }
public ScopedController(ScopedObject scopedObject)
{
ScopedObject = scopedObject;
}
public IActionResult Index([FromServices] ScopedObject scopedObject)
{
if (ScopedObject != scopedObject)
{
throw new InvalidOperationException("Scoped object unmatch.");
}
return View(ScopedObject);
}
}
@inject DependencyInjectionExample.Services.ScopedObject ScopedObject
<h2>Result</h2>
@if (Model == ScopedObject)
{
<div>Scoped Lifetime object matched.</div>
}
以下の3つのインスタンスが同一であることを確認できる内容となっています。
- ScopedControllerにコンストラクタインジェクションされるインスタンス
- アクションメソッドで[FromServices]によりインジェクションされるインスタンス
- ビューにインジェクションされるインスタンス
また、リクエスト開始時と終了時にはScopedObjectのログ出力も確認でき、インスタンスがリクエスト間のみ生存することを確認できるような内容になっています。
うさコメ
まあ、カスタムResolverとASP.NET Coreを連携させる拡張の作り方とか、ほとんどの人にはいらない知識だと思うんですけどね(´・ω・`)
高度なDIが必要になったとしても、普通は既存のなにかしらのコンテナを使うと思いますし、AutofacやWindsor等にはここに書いたのと同種の実装携帯による拡張があるようですが。
っというか、ほとんどのケースでは標準のIServiceProvider推奨だと思います(`・ω・´)
そもそも、高度なDIが必要なケースって、例えばサンプルでやっているように複数のデータベース接続を使い分けてインジェクションしてもらいたい、みたいなケースとかだと思いますが。
その程度であれば、データベース接続のセレクター的なコンポーネントを用意して、それをDIしてもらって、定数かなんかをキーとして使うデータベース接続をそこから取得する、みたいなベタな処理でもいいかという気にもなってくるし(´д`;)
標準のIServiceProvider、機能的には基本的な処理しかないけど、性能的には優秀だしね(・∀・;)