しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET Core MVC で大きく変わったフィルタについて調べた

仙台に行ったとき、ぼんぷろおじさんに ActionFilter で実行時にオプションを扱う場合にどうすればいいのか聞かれて、フィルタ周りまとめないといけないことを思い出したので書きます。

思いのほか長くなってしまったので、久し振りに目次記法を使うことにします。

パイプラインの整理

ASP.NET Core MVC ではフィルタパイプラインが再実装されて、分かりやすくシンプルになりました。パイプラインに関しては公式ドキュメントの図が分かりやすいです。

新しく Resource Filter が追加されて、キャッシュなどパフォーマンスの改善といった処理を書きやすくなっています。モデルバインディングの前に実行されるのが特徴です。

抽象クラスを利用する方法

ASP.NET MVC 5 までと同じように扱える抽象クラスが Core MVC でも用意されているので、以下の抽象クラスを継承したクラスを作成して、利用したいメソッドをオーバーライドするだけで実装できます。

  • ActionFilterAttribute
  • ResultFilterAttribute
  • ExceptionFilterAttribute

機能としてはクラス名の通りなので説明は省略します。

これまでと同じ Executing / Executed メソッドの他に、Task を返す非同期メソッドが用意されています。async を使うことで簡単に処理を前後に追加することが出来ます。

public class MyActionFilterAttribute : ActionFilterAttribute
{
    public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // OnActionExecuting に相当する処理
        Debug.WriteLine("OnActionExecuting");

        await next();

        // OnActionExecuted に相当する処理
        Debug.WriteLine("OnActionExecuted");
    }
}

ActionExecutionDelegate が実際にアクションを実行するためのデリゲートなので、ちゃんと呼び出しておかないと後続の処理がスキップされます。

抽象クラスを使っている場合は、これまでと同じように簡単に実装できますが、Core MVC では抽象クラスは互換性のためのような機能です。このままでは DI が使えないので、実装できる処理が限られます。

インターフェースを実装する方法

MVC 5 までも一部のインターフェースは用意されていましたが、Core MVC では IResourceFilter が追加されて全部で 5 種類になりました。

  • IActionFilter
  • IAuthorizationFilter
  • IExceptionFilter
  • IResourceFilter
  • IResultFilter

そして Core MVC ではさらに非同期版が追加されて、インターフェースは 10 種類が利用出来ます。

  • IAsyncActionFilter
  • IAsyncAuthorizationFilter
  • IAsyncExceptionFilter
  • IAsyncResourceFilter
  • IAsyncResultFilter

更にフィルタの実行順序を指定する必要がある場合には IOrderedFilter を実装します。

使い方はインターフェースを実装したクラスを作成するだけなので、特に説明も要らない気がします。一番簡単な実装は以下のようなクラスになるかと思います。

public class MyActionFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        await next();
    }
}

ActionFilterAttribute と同じ処理を実装する場合は、IAsyncActionFilter と IAsyncResultFilter を実装するだけです。インターフェースを実装する方法の方が、属性よりも DI との親和性が高いです。

public class MyCustomFilter : IAsyncActionFilter, IAsyncResultFilter
{
    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        await next();
    }

    public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
    {
        await next();
    }
}

ちなみに ActionFilterAttribute などもインターフェースを実装しているだけの、単純な属性なので別に使わなくても問題はないです。必要なインターフェースだけ実装するのが良さそうです。

DI を利用する

属性として実装していないフィルタをコントローラ単位で使うには、以下の属性を使う必要があります。

  • TypeFilter 属性
  • ServiceFilter 属性

両方ともコンストラクタでフィルタの型を渡すだけなので簡単ですが、挙動が微妙に異なっているので注意して使い分けたいです。TypeFilter はコンストラクタへのインジェクションを行います。

以下の場合、MyActionFilter のコンストラクタに対して DI からサービスを引っ張ってきてくれます。

[TypeFilter(typeof(MyActionFilter))]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

それに対して ServiceFilter の場合は、指定されたフィルタ自体を DI から参照するので、以下の場合は ConfigureServices で DI に追加しておく必要があります。見つからない場合には例外が投げられます。

// ConfigureServices で AddScoped<MyActionFilter>() などしておく
[ServiceFilter(typeof(MyActionFilter))]
public class HomeController : Controller
{
    public IActionResult Index()
    {
        return View();
    }
}

基本的には TypeFilter を使うことが多くなるのではないかと思っています。

グローバルで利用する

MVC 5 で言うところのグローバルフィルタは Core MVC の場合はオプションで設定を行います。

パラメータとして IFilterMetadata が大体要求されますが、上で紹介したインターフェースは全て継承しているので気にしなくても大丈夫です。MVC 5 の時のようにインスタンスをコレクションに追加すれば、全体で利用されるようになります。

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services.AddMvc(options =>
    {
        options.Filters.Add(new MyActionFilter());
    });
}

それ以外にも Type を受け取るオーバーロードがあるので、こっちを使って登録する方法もあります。

インスタンスを追加する方法との違いとして、こちらは DI を使ってコンストラクタへのインジェクションを行う点が挙げられます。MVC 5 まではフィルタ内で DB を触ったりし難かったですが、Core MVC だと DI でサービスを参照できるので簡単です。

public void ConfigureServices(IServiceCollection services)
{
    // Add framework services.
    services.AddMvc(options =>
    {
        options.Filters.Add(typeof(MyActionFilter));
    });
}

この場合、MyActionFilter のコンストラクタに対して、DI からサービスをインジェクションしてくれます。

更に Core MVC では ServiceFilter を使ってフィルタ自体を DI を使って解決出来るようになりましたが、同じことをグローバルでも AddService メソッドを使って行えます。

public void ConfigureServices(IServiceCollection services)
{
    services.AddScoped<MyActionFilter>();

    // Add framework services.
    services.AddMvc(options =>
    {
        options.Filters.AddService(typeof(MyActionFilter));
    });
}

AddService を使う場合には、当然ながらフィルタの実体を DI に追加しておく必要があります。

IFilterFactory を実装した属性を用意する

少し特殊なパターンとして IFilterFactory を実装した属性を使って、フィルタ自体をインスタンス化して返す方法があります。CreateInstance メソッドに IServiceProvider が渡されるので、DI を使ってフィルタ自体を引っ張ってくることも出来ます。

public class MyFilterFactoryAttribute : Attribute, IFilterFactory
{
    public IFilterMetadata CreateInstance(IServiceProvider serviceProvider)
    {
        return serviceProvider.GetRequiredService<MyActionFilter>();
    }

    public bool IsReusable => true;
}

Core MVC の FormatFilter がこの方法を使っています。コントローラでの指定のしやすさと、DI を使って既存の実装を簡単に入れ替えられるので、拡張性も高い実装です。

今回のまとめ

長くなったので、今回のポイントを最後に簡単にまとめました。全体的に Core MVC ではフィルタの表現力が上がったという感じです。

  • DI が必要ない場合
    • 属性を実装する (抽象クラスをオーバーライド、必要なインターフェースを実装)
    • Filters.Add メソッドを使ってインスタンスを追加する
  • DI が必要な場合
    • TypeFilter 属性を使う
    • Filters.Add メソッドを使って型を追加する
  • フィルタ自体を DI で解決したい場合
    • ServiceFilter 属性を使う
    • Filters.AddService メソッドを使って型を追加する
  • フィルタのインスタンス化のタイミングで処理が必要な場合
    • IFilterFactory を実装した属性を使う

実際に使ってみて、それぞれに適した利用方法を探っていきたいですね。特に ServiceFilter 周りはパッと思いつかないので、いい感じの利用方法を見つけたいです。