5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ASP.NET MVC CoreのActionFilterAttributeにDIする3つの方法(+アクションフィルタ内でHttpClientを使う)

Last updated at Posted at 2024-06-05

課題

  • 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を使う際のお作法だからだ。
詳しくはこちらの記事を参照されたい。

https://learn.microsoft.com/ja-jp/dotnet/architecture/microservices/implement-resilient-applications/use-httpclientfactory-to-implement-resilient-http-requests

それはともかく、ここで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コンテナにサービス登録しておく必要がある。

Startup.cs
    services.AddScoped<MyFilterAttribute>();

ここでライフサイクルをSingletonとしたが、必要に応じてAddScopedAddTransientにしても良い。
今回は、フィルタ内部で使用する主なオブジェクトは 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
{
	// コントローラアクション

}

IsReusabletrue にしたとしても、必ずインスタンスが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つの方法についてまとめた。
いずれも長所・短所がある為、ケースバイケースで使い分けていきたい。

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?