課題
- ASP.NET MVC Coreにおいて、アクションフィルタにコンストラクタ・インジェクションでDIしたい。
- 副題として、アクションフィルタ内で
HttpClient
を用いたい。
方法
次のようなアクションフィルタを定義済とする。
public class MyFilterAttribute: ActionFilterAttribute
{
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
// 何らかのフィルタ処理
}
}
このフィルタは、いくつかのコントローラに対して指定することで、特別な認証処理をするようなものだと思ってもらいたい。
[MyFilter]
public class MyController : Controller
{
// コントローラアクション
}
このフィルタにて、仕様変更によって内部でHttpClientを使った外部APIへのアクセスが必要になった。
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
// HttpClientを使った何らかのフィルタ処理
var response = httpClient.XXXX( ... );
...
}
HttpClientは非同期APIな為、このフィルタクラスは非同期フィルタとして再実装する必要がある。
下記のように IAsyncActionFilterを実装するよう修正し、OnActionExecutionAsyncをオーバーライドする。
public class MyFilterAttribute: Attribute, IAsyncActionFilter
{
private readonly IHttpClientFactory _httpClientFactory;
public MyFilterAttribute(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFacto;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// HttpClientを使った何らかのフィルタ処理
var httpClient = _httpClientFactory.CreateClient()
var response = await httpClient.XXXXAsync(...);
...
}
}
ちなみに、ここでnew HttpClient()
としておらず、IHttpClientFactory
を使っているのは、それがHttpClient
を使う際のお作法だからだ。
詳しくはこちらの記事を参照されたい。
それはともかく、ここでIHttpClientFactory
をコンストラクタでDependency Injection(以降、DI)していることが重要である。
実は、規定ではアクションフィルタはDIをサポートしていない。
上記のように、コンストラクタに何かしらの引数を追加した時点で、フィルタの属性がエラーを吐く。
[MyFilter]
public class MyController : Controller
{
// コントローラアクション
}
アクションフィルタにDIする方法は大きく3つある。
番外:サービスロケータパターン
public class MyFilterAttribute: Attribute, IAsyncActionFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var httpClientFactory = context.HttpContext.RequestService.GetRequiredSeervice<IHttpClientFactory>();
// HttpClientを使った何らかのフィルタ処理
var httpClient = httpClientFactory.CreateClient()
var response = await httpClient.XXXXAsync(...);
...
}
}
context.HttpContext.RequestService
からDIコンテナを取得し、そこから必要なサービスを引っ張ってくる。
別にこれでも動くので良いのだが、サービスロケーターになってしまっており、いささか気持ち悪い部分がある。
より具体的には、ソースコードから「そのフィルタが依存しているオブジェクト」が見えにくいという問題がある。
DI的には、可能な限りコンストラクタ・インジェクションとしたい。
もちろんこれは「DIしている」とはいえない為、番外とする。
方法1: ServiceFilter
ServiceFilterAttribute
を用いてフィルタをDIする。
[ServiceFilter(typeof(MyFilterAttribute))]
public class MyController : Controller
{
// コントローラアクション
}
上記のようにコントローラクラスに属性指定すると、ServiceFilterは次のように機能する。
-
MyFilterAttribute
をDIコンテナから取得して適用する
つまり、MyFilterAttribute
自身もDIコンテナにサービス登録しておく必要がある。
services.AddScoped<MyFilterAttribute>();
ここでライフサイクルをSingleton
としたが、必要に応じてAddScoped
やAddTransient
にしても良い。
今回は、フィルタ内部で使用する主なオブジェクトは IHttpClientFactory
であり、このインスタンスはスレッドセーフである。
よって、シングルトンで問題ない。
ServiceFilterAttribute
を使うと、フィルタのライフサイクルはDIコンテナにサービス登録した際のスコープに依存する。
この方法を使うと、アクションフィルタをDIすることができるが、反面、次のデメリットがある。
- フィルタ属性の指定方法が
ServiceFilter(typeof(MyFilterAttribute))
のようになる - フィルタ自身もDIコンテナに登録する必要がある
前者は「なぜフィルタの利用側が、それがサービス登録されているかどうかを意識する必要があるのか」という問題がある。所謂「漏れやすい抽象化」という問題だ。
フィルタの利用側はそのフィルタを適用したいという要求だけがあるのであり、そのフィルタがどのようにシステムに登録されているのかなどということには興味がない。
極端な話、将来そのフィルタがDIに無関係になったらServiceFilter
を外すのかという話だ。
また、後者についてはDIコンテナに生成を任せる以上仕方のないことだが、面倒と言えば面倒だ。
方法2: TypeFilter
TypeFilterAttribute
を用いてフィルタをDIする。
[TypeFilter(typeof(MyFilter))]
public class MyController : Controller
{
// コントローラアクション
}
上記のようにコントローラクラスに属性指定すると、TypeFilterは次のように機能する。
-
MyFilter
のコンストラクタ引数をDIコンテナから取得する - それらのインスタンスをMyFilterのコンストラクタに与えて
new
する
つまり、MyFilterAttribute
自身はDIコンテナから取得するのではなく、コンストラクタ引数だけをDIコンテナから取得する。
その為、ServiceFilterAttribute
のように、フィルタ自身をDIコンテナに登録しておく必要がない。
但し、TypeFilterAttribute
を使うと、そのインスタンスのライフサイクルは対象のクラス(ここではMyController
)のライフサイクルに依存する。
通常、コントローラクラスはScopedなので、フィルタのライフサイクルもScopedとなる。
多くの場合、基本的にそれで問題はないはずが、「フィルタは別に毎回生成しなくてもよい(シングルトンでよい)のだよなー」という場合には、IsReusable
プロパティを指定することで、異なるリクエスト間でフィルタのインスタンスを使いまわすようになる。
[TypeFilter(typeof(MyFilter), IsReusable = true)]
public class MyController : Controller
{
// コントローラアクション
}
IsReusable
を true
にしたとしても、必ずインスタンスが1つしか生成されないという保証はされていない点に注意されたい(そう公式ドキュメントに書かれている)。
TypeFilterAttribute
を使うと、いちいちフィルタをDIコンテナに登録しなくていいというメリットはあるが、コントローラクラスへの指定が冗長であるという点はServiceFilterAttribute
と変わっていない。
方法3: IFilterFactory
IFilterFactory
を実装したフィルタファクトリクラスを間にはさむ方法である。
まず、既存のフィルタクラスをMyFilterAttribute
からInnerMyFilterAttribute
(名前はなんでもよい)に変更する。
そして、新たにMyFilterAttribute
として、フィルタ生成用のファクトリクラスを定義する。
public class MyFilterAttribute : ActionFilterAttribute, IFilterFactory
{
public bool IsReusable => true; // このフィルタをシステム全体で再利用する
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) =>
serviceProvider.GetRequiredService<InnerMyFilterAttribute>();
}
class InnerMyFilterAttribute: Attribute, IAsyncActionFilter
{
private readonly IHttpClientFactory _httpClientFactory;
public InnerMyFilterAttribute(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// HttpClientを使った何らかのフィルタ処理
var httpClient = _httpClientFactory.CreateClient()
var response = await httpClient.XXXXAsync(...);
...
}
}
コントローラクラス側は、修正前のようにフィルタ名だけを属性指定することができる。
[MyFilter]
public class MyController : Controller
{
// コントローラアクション
}
IFilterFactory.IsReusable
は、このフィルタが再利用される可能性があるかを返す。
ここでは true
を返している為、このフィルタは異なるリクエスト間で再利用される。複数のリクエストがあってもフィルタのコンストラクタは初回のみ呼び出される(但し、ドキュメントによれば1回しか生成されないという保証はされていない)。
CreateInstance
メソッド内で、DIコンテナからInnerMyFilterAttribute
を取得して返している。
このため、フィルタ自身はDIコンテナの制御下となる。
フィルタファクトリだけ見ればサービスロケーターとなっているが、一般的にファクトリクラスはどうしてもそうなりがちなので仕方がない。
それに対してアクションフィルタクラスの方はIoCに沿った正しいDIの実装となっており、これはとてもクリーンだ。
なお、今回のように、注入するオブジェクトがIHttpClientFactory
のみとシンプルな構成だった場合には、次のようにアクションフィルタ自身は(TypeFilterAttribute
のように)直接newするようにすれば、アクションフィルタをDIコンテナに登録する手間も省けてよいかもしれない(但し、InnerMyFilterAttribute側のコンストラクタ変更の影響を受けてしまう為、基本的にはDIコンテナに生成を任せた方がよい)。
public class MyFilterAttribute : ActionFilterAttribute, IFilterFactory
{
public bool IsReusable => true; // このフィルタをシステム全体で再利用する
public IFilterMetadata CreateInstance(IServiceProvider serviceProvider) =>
new InnerMyFilterAttribute(serviceProvider.GetRequiredService<IHttpClientFactory>());
}
方法3の場合、フィルタファクトリを作る手間はあるが、「漏れやすい抽象化」の問題を極小化できているのが素晴らしい。
このフィルタの利用側はフィルタクラス名のみ知っていればよいし、それがシングルトンかどうかを気にする必要もない。
ただ単に「フィルタ属性を指定するだけ」で良い。
将来フィルタの生成仕様が変わったり、「シングルトンではなくスコープドにしたい」となった場合でも、外部への影響はほぼない。
まとめ
以上、アクションフィルタにDIする為の3つの方法についてまとめた。
いずれも長所・短所がある為、ケースバイケースで使い分けていきたい。