次の方法で共有



August 2016

Volume 31 Number 8

ASP.NET Core - 実際の ASP.NET Core MVC フィルター

Steve Smith

フィルターは優れた機能ですが、ASP.NET MVC や ASP.NET Core MVC ではあまり活用されていない機能です。フィルターは MVC のアクション呼び出しパイプラインにフックする方法を提供し、共通の反復タスクをアクションから取り出す優れた役割を担います。多くの場合、アプリでは標準ポリシーを用意し、一定の条件を処理するしくみに当てはめます。特に、特定の HTTP 状態コードの生成を求められるような場合です。または、アクションごとに固有の形式でエラー処理を行ったり、アプリケーションレベルでログを記録するような場合です。この種のポリシーを分野横断的に当てはめる場合は問題が生じます。そのため、可能であれば、同じことを繰り返さない (DRY: Don't Repeat Yourself) という原則に従って、ポリシーから共通条件を取り出して抽象化することを考えます。つまり、この抽象化をグローバルに適用したり、アプリケーション内の該当する場所に適用することを考えます。この目的に優れた役割を担うのがフィルターです。

ミドルウェアについて

2016 年 6 月のコラム (msdn.com/magazine/mt707525) では、ASP.NET Core のミドルウェアを利用してアプリの要求パイプラインを制御する方法を取り上げました。これと同じことを、ASP.NET Core MVC アプリでフィルターを使って実現できるように思えます。2 つの違いはコンテキストです。ASP.NET Core MVC はミドルウェアを使って実装されます (MVC 自体はミドルウェアではありませんが、MVC はルーティング ミドルウェアの既定の宛先になるように自身を構成します)。ASP.NET Core MVC には、モデル バインド、コンテンツ ネゴシエーション、応答形式のような多くの機能があります。フィルターは MVC のコンテキスト内に存在するため、フィルターからこのような MVC レベルの機能や抽象化にアクセスできます。一方、ミドルウェアはもっと低いレベルに存在するため、MVC やその機能についての直接的な知識はありません。

低レベルで実行する機能があり、その機能が MVC レベルのコンテキストに依存しない場合は、ミドルウェアの使用を検討します。コントローラーのアクションに多くの共通ロジックを含めることが多い場合は、フィルターの使用を考えます。フィルターによって DRY の原則に従い、メンテナンスやテストを容易にします。

フィルターの種類

MVC のミドルウェアを利用しない場合は、アクション呼び出しパイプライン内のさまざまな位置でさまざまなフィルターを呼び出します。

実行する最初のフィルターは承認フィルターです。要求を承認しなければ、フィルターはパイプラインの残りを実行しないで即座に終了します。

次がリソース フィルターです。このフィルターは、(承認後の) 要求処理の最初と最後の両方で実行します。つまり、リソース フィルターを使って、要求の一番最初と一番最後 (MVC パイプラインを終える直前) にコードを実行することができます。リソース フィルターの好例が出力キャッシュです。このフィルターを使ってパイプラインの開始時にキャッシュをチェックし、キャッシュされている結果を返すことができます。キャッシュが設定されていなければ、パイプラインの終了時にアクションの応答をキャッシュに追加します。

アクション フィルターは、アクションが実行される直前および直後に実行されます。このフィルターは、モデル バインド実行後に実行されるため、アクションに送信するモデル バインドのパラメーターにアクセスしたり、モデル検証の状態にアクセスすることができます。

アクションは結果を返します。結果フィルターは、結果が実行される直前および直後に実行されます。このフィルターでは、ビューやフォーマッタの実行に動作を追加します。

最後に、例外フィルターは、アプリ内でキャッチされない例外を処理し、このような例外にグローバル ポリシーを適用するために使用します。

今回は、アクション フィルターに注目します。

フィルターのスコープ設定

フィルターは、グローバルに適用することも、個別のコントローラー レベルやアクション レベルに適用することもできます。通常、属性として実装するフィルターはどのレベルでも追加できます。グローバル フィルターはすべてのアクションに影響します。コントローラー属性のフィルターはそのコントローラー内のすべてのアクションに影響します。アクション属性のフィルターはそのアクションのみに影響します。アクションに複数のフィルターを適用する場合、フィルターの適用順序は、まず Order プロパティによって決まり、次に対象のアクションにスコープを設定する方法によって決まります。同じ Order のフィルターは影響の大きい方から小さい方へと実行されるため、グローバル フィルター、コントローラーレベル フィルター、アクションレベル フィルターの順に実行されます。アクションの実行後には順序が逆転し、アクションレベル フィルター、コントローラーレベル フィルター、グローバル フィルターの順に実行されます。

属性として実装しないフィルターは、TypeFilterAttribute 型を使用して、コントローラーやアクションに適用できます。この属性はフィルターの種類を受け取って、コンストラクターのパラメーターとして実行します。たとえば、1 つのアクション メソッドに CustomActionFilter を適用する場合は、以下のようにします。

[TypeFilter(typeof(CustomActionFilter))]
public IActionResult SomeAction()
{
  return View();
}

TypeFilter 属性はアプリ組み込みのサービス コンテナーと連携して、CustomActionFilter が公開するすべての依存関係が実行時に設定されるようにします。

DRY API

フィルターを使って ASP.NET MVC Core アプリの設計を強化する例をいくつかデモするために、基本 CRUD (作成、読み取り、更新、削除) 機能を提供し、無効要求に対処するいくつかの標準ルールに従うシンプルな API をビルドします。API のセキュリティ保護はまた別の問題であるため、このサンプルからは意図的に除外しています。

今回のサンプル アプリでは、作者 (author) を管理する API を公開します。これは、プロパティを少ししか持たないシンプルな型です。この API は標準の HTTP 動詞に基づく表記法を使用して、作者全員の取得、ID による 1 人の作者の取得、新しい作者の作成、作者の編集や削除などを行います。API は、データ アクセスを抽象化するために、依存関係の挿入 (DI) を使って IAuthorRepository を受け取ります (DI の詳細については、5 月号のコラム msdn.com/magazine/mt703433 を参照してください)。 コントローラーの実装とリポジトリは、どちらも非同期に実装します。

API は以下の 2 つのポリシーに従います。

  1. 特定の作者 ID を指定する API 要求は、その ID が存在しない場合に 404 応答を受け取る。
  2. 無効な Author モデル インスタンス (ModelState.IsValid == false) を指定する API の要求は、モデル エラーの一覧と共に BadRequest を返す。

図 1 に、上記のルールを実装するこの API の実装を示します。

図 1 AuthorsController

[Route("api/[controller]")]
public class AuthorsController : Controller
{
  private readonly IAuthorRepository _authorRepository;
  public AuthorsController(IAuthorRepository authorRepository)
  {
    _authorRepository = authorRepository;
  }
  // GET: api/authors
  [HttpGet]
  public async Task<List<Author>> Get()
  {
    return await _authorRepository.ListAsync();
  }
  // GET api/authors/5
  [HttpGet("{id}")]
  public async Task<IActionResult> Get(int id)
  {
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
      return NotFound(id);
    }
    return Ok(await _authorRepository.GetByIdAsync(id));
  }
  // POST api/authors
  [HttpPost]
  public async Task<IActionResult> Post([FromBody]Author author)
  {
    if (!ModelState.IsValid)
    {
      return BadRequest(ModelState);
    }
    await _authorRepository.AddAsync(author);
    return Ok(author);
  }
  // PUT api/authors/5
  [HttpPut("{id}")]
  public async Task<IActionResult> Put(int id, [FromBody]Author author)
  {
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
      return NotFound(id);
    }
    if (!ModelState.IsValid)
    {
       return BadRequest(ModelState);
    }
    author.Id = id;
    await _authorRepository.UpdateAsync(author);
    return Ok();
  }
  // DELETE api/values/5
  [HttpDelete("{id}")]
  public async Task<IActionResult> Delete(int id)
  {
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
      return NotFound(id);
    }
    await _authorRepository.DeleteAsync(id);
    return Ok();
  }
  // GET: api/authors/populate
  [HttpGet("Populate")]
  public async Task<IActionResult> Populate()
  {
    if (!(await _authorRepository.ListAsync()).Any())
    {
      await _authorRepository.AddAsync(new Author()
      {
        Id = 1,
        FullName = "Steve Smith",
        TwitterAlias = "ardalis"
      });
      await _authorRepository.AddAsync(new Author()
      {
        Id = 2,
        FullName = "Neil Gaiman",
        TwitterAlias = "neilhimself"
      });
    }
    return Ok();
  }
}

ご覧のように、このコードには重複するロジックがかなりあります。特に NotFound と BadRequest の結果を返すロジックはよく似ています。このモデルの検証と BadRequest のチェックは、以下のようなシンプルなアクション フィルターに簡単に置き換えることができます。

public class ValidateModelAttribute : ActionFilterAttribute
{
  public override void OnActionExecuting(ActionExecutingContext context)
    {
    if (!context.ModelState.IsValid)
    {
      context.Result = new BadRequestObjectResult(context.ModelState);
    }
  }
}

この属性を、モデルの検証が必要なアクションに適用できます。そのためには、アクションのメソッドに [ValidateModel] を追加するだけです。Action­ExecutingContext の Result プロパティを設定すると、要求を無視することに注意してください。この場合、各アクションに属性を適用する理由はないため、コントローラーに属性を追加します。

作者が存在するかどうかのチェックは、DI を使ってコントローラーに渡される IAuthorRepository を利用することになるため、やや複雑です。コンストラクターのパラメーターを受け取るアクション フィルター属性を作成するだけのことですが、残念ながら、属性は、属性が宣言される場所でコンストラクターのパラメーターが指定されることを想定します。属性を適用するリポジトリ インスタンスを提供できないため、実行時にサービス コンテナーで挿入することを考えます。

さいわい、TypeFilter 属性は、このフィルターが必要とする DI サポートを提供します。アクションに TypeFilter 属性を適用して、ValidateAuthorExistsFilter 型を指定するだけです。

[TypeFilter(typeof(ValidateAuthorExistsFilter))]

これでも機能しますが、あまりお勧めできない方法です。少しわかりにくく、開発者は複数の共通属性フィルターから適用するフィルターを探す際に、IntelliSense を使って ValidateAuthorExists­Attribute を見つけることができないためです。お勧めは、TypeFilterAttribute をサブクラス化して、適切な名前を付けて、この属性内のプライベート クラスにフィルター実装を配置する方法です。図 2 に、この方法の例を示します。実際の作業はプライベート ValidateAuthorExistsFilterImpl クラスが実行し、このクラスの型が TypeFilterAttribute のコンストラクターに渡されます。

図 2 ValidateAuthorExistsAttribute

public class ValidateAuthorExistsAttribute : TypeFilterAttribute
{
  public ValidateAuthorExistsAttribute():base(typeof
    (ValidateAuthorExistsFilterImpl))
  {
  }
  private class ValidateAuthorExistsFilterImpl : IAsyncActionFilter
  {
    private readonly IAuthorRepository _authorRepository;
    public ValidateAuthorExistsFilterImpl(IAuthorRepository authorRepository)
    {
      _authorRepository = authorRepository;
    }
    public async Task OnActionExecutionAsync(ActionExecutingContext context,
      ActionExecutionDelegate next)
    {
      if (context.ActionArguments.ContainsKey("id"))
      {
        var id = context.ActionArguments["id"] as int?;
        if (id.HasValue)
        {
          if ((await _authorRepository.ListAsync()).All(a => a.Id != id.Value))
          {
            context.Result = new NotFoundObjectResult(id.Value);
            return;
          }
        }
      }
      await next();
    }
  }
}

この属性は、ActionExecutingContext パラメーターの一部として、アクションに渡される引数にアクセスできます。これにより、フィルターは、特定の ID の作者が存するかどうかチェックする前に、その id パラメーターが存在するかどうかをチェックしてその値を取得できます。また、プライベート ValidateAuthorExistsFilterImpl が非同期フィルターであることにも注意が必要です。このパターンでは、実装するメソッドは 1 つだけです。次の呼び出しの前または後に実行することで、アクションの実行前または後に処理を完了することができます。ただし、context.Result を設定することによってフィルターを無視する場合は、次を呼び出さずに戻る必要があります (それ以外は、例外が発生します)。

フィルターについてのもう 1 つの注意点は、フィルターにオブジェクト レベルの状態を含めてはいけないことです。たとえば、アクションの実行中 (OnActionExecuting) に設定され、アクションの実行完了時 (OnActionExecuted) に読み取りまたは変更される IActionFilter (属性として設定される特定のフィルター) のフィールドなどを含めてはいけません。この種のロジックを実行する必要がある場合は、IAsyncActionFilter に切り替えることによって、この種の状態を回避できます。このフィルターは、OnActionExecutionAsync メソッド内でローカル変数を簡単に使用できます。

コントローラーのアクション内から行っていたモデルの検証とレコードの存在チェックを共通フィルターに移動すると、コントローラーにはどのような影響があるでしょう。 比較を目的として、図 3 に Authors2Controller を示します。このコードでは、AuthorsController と同じロジックを実行しますが、共通ポリシー動作として 2 つのフィルターを利用しています。

図 3 Authors2Controller

[Route("api/[controller]")]
[ValidateModel]
public class Authors2Controller : Controller
{
  private readonly IAuthorRepository _authorRepository;
  public Authors2Controller(IAuthorRepository authorRepository)
  {
    _authorRepository = authorRepository;
  }
  // GET: api/authors2
  [HttpGet]
  public async Task<List<Author>> Get()
  {
    return await _authorRepository.ListAsync();
  }
  // GET api/authors2/5
  [HttpGet("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Get(int id)
  {
    return Ok(await _authorRepository.GetByIdAsync(id));
  }
  // POST api/authors2
  [HttpPost]
  public async Task<IActionResult> Post([FromBody]Author author)
  {
    await _authorRepository.AddAsync(author);
    return Ok(author);
  }
  // PUT api/authors2/5
  [HttpPut("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Put(int id, [FromBody]Author author)
  {
    await _authorRepository.UpdateAsync(author);
    return Ok();
  }
  // DELETE api/authors2/5
  [HttpDelete("{id}")]
  [ValidateAuthorExists]
  public async Task<IActionResult> Delete(int id)
  {
    await _authorRepository.DeleteAsync(id);
    return Ok();
  }
}

このリファクタリングしたコントローラーから 2 つのことがわかります。1 つは、コードが短く簡潔になっていることです。もう 1 つは、どのメソッドにも条件文がなくなっていることです。API の共通ロジックがフィルターにすべて取り出され、適切な場所に適用できるようになっているため、コントローラーの作業内容がわかりやすくなっています。

テストを可能に

ロジックをコントローラーから属性に移すことによって、コードが少なくなり、実行時の動作の一貫性が強化されます。残念ながら、アクション メソッドに対して直接単体テストを実行する場合、属性やフィルターの動作にはテストが適用されません。これは仕様です。ただし、個別のアクション メソッドとは別にフィルターの単体テストを実行して、設計どおり動作するかどうかを確認することはできます。フィルターが動作するかどうかだけでなく、フィルターが正しくセットアップされ、個別のアクション メソッドに正しく適用されていることを確認する必要があるとしたらどうでしょう。 既存 API コードをフィルターを活用するようにリファクタリングし、結果の API が正しく動作することを確認する必要があるとしたらどうしましょう。 そのためには、統合テストを利用します。さいわい、ASP.NET Core には、高速で簡単な統合テストを実現する優れたサポートが含まれています。

今回のサンプル アプリケーションは、インメモリの Entity Framework Core DbContext を使用するように構成していますが、SQL Server を使用している場合でも、今回の統合テスト用にインメモリ ストアを使用するように簡単に切り替えることができます。これにより、このようなテストの速度が大幅に向上し、インフラストラクチャを必要としないため、セットアップが非常に容易になります。

ASP.NET Core の統合テストにおける煩雑な作業の大半を実行するのは、Microsoft.AspNetCore.TestHost パッケージで入手できる TestServer クラスです。TestServer は、WebHostBuilder を使用して、Program.cs エントリ ポイントで Web アプリを構成するのと同じ方法で構成します。今回のテストでは、サンプル Web アプリと同じ Startup クラスを使用し、テスト環境で実行するように指定しています。これにより、サイトの起動時になんらかのサンプル データをトリガーします。

var builder = new WebHostBuilder()
  .UseStartup<Startup>()
  .UseEnvironment("Testing");
var server = new TestServer(builder);
var client = server.CreateClient();

今回のクライアントは標準の System.Net.Http.HttpClient です。これを使用して、ネットワーク上で動作しているかのようにサーバーにリクエストを行います。ただし、すべての要求がインメモリで行われるため、テストは非常に高速かつ堅牢になります。

今回のテストでは xUnit を使用しています。これには、特定のテスト メソッドに対してさまざまなデータ セットを使って複数のテストを実行する機能があります。AuthorsController クラスと Authors2Controller クラスがどちらも同じように動作することを確認するには、この機能を使用して、各テストに両方のコントローラーを指定します。図 4 に、Put メソッドの複数のテストを示します。

図 4 Authors Put のテスト

[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsNotFoundForId0(string controllerName)
{
  var authorToPost = new Author() { Id = 0, FullName = "test",
    TwitterAlias = "test" };
  var jsonContent = new StringContent(JsonConvert.SerializeObject(authorToPost),
     Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/{controllerName}/0", jsonContent);
  Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
  var stringResponse = await response.Content.ReadAsStringAsync();
  Assert.Equal("0", stringResponse);
}
[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsBadRequestGivenNoAuthorName(string controllerName)
{
  var authorToPost = new Author() {Id=1, FullName = "", TwitterAlias = "test"};
  var jsonContent = new StringContent(
    JsonConvert.SerializeObject(authorToPost),
     Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/{controllerName}/1", jsonContent);
  Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
  var stringResponse = await response.Content.ReadAsStringAsync();
  Assert.Contains("FullName", stringResponse);
  Assert.Contains("The FullName field is required.", stringResponse);
}
[Theory]
[InlineData("authors")]
[InlineData("authors2")]
public async Task ReturnsOkGivenValidAuthorData(string controllerName)
{
  var authorToPost = new Author() {
    Id=1,FullName = "John Doe",
    TwitterAlias = "johndoe" };
  var jsonContent = new StringContent(JsonConvert.SerializeObject(authorToPost),
     Encoding.UTF8, "application/json");
  var response = await _client.PutAsync($"/api/{controllerName}/1", jsonContent);
  response.EnsureSuccessStatusCode();
}

このような統合テストには、データベースやインターネット接続は必要なく、Web サーバーを実行する必要もありません。単体テストと同程度に高速かつシンプルでありながら、コントローラー クラス内の独立したメソッドとしてではなく、要求パイプライン全体を通じて ASP.NET アプリをテストできる点が最も重要です。可能な場合は単体テストを記述し、単体テストを実行できない動作については統合テストを利用するのがお勧めですが、このようなパフォーマンスの高い方法を使用して ASP.NET Core で統合テストを実行できることは非常に優れています。

次のステップ

フィルターは膨大なトピックで、今回は少しの例しか取り上げることができませんでした。公式ドキュメント (docs.asp.net、英語) を確認して、フィルターや ASP.NET Core アプリのテストについて詳しく確認することができます。

今回のサンプルのソース コードは、bit.ly/1sJruw6 (英語) から入手できます。


Steve Smith は、独立系のトレーナー、指導者兼コンサルタントで、ASP.NET の MVP でもあります。彼は、ASP.NET Core の公式ドキュメント (docs.asp.net、英語) に多くの記事を寄稿し、チームが ASP.NET Core をすばやく理解できるように手助けしています。彼の連絡先は ardalis.com (英語) で、Twitter は @ardalis (英語) です。


この記事のレビューに協力してくれたマイクロソフト技術スタッフの Doug Bunting に心より感謝いたします。
Doug Bunting は、マイクロソフトの ASP.Net チームに所属する開発者です。