2016 年 8 月

第 31 卷,第 8 期

本文章是由機器翻譯。

ASP.NET Core - 實際 ASP.NET Core MVC 篩選器

Steve Smith

篩選是 agreat、 ASP.NET MVC 和 ASP.NET 核心 MVC 通常未充分利用的功能。它們可連結至 MVC 動作引動過程管線,使其成為適合用來提取一般的重複性工作,從您的動作。通常,應用程式會有標準的原則套用到如何處理特定情況下,特別是那些可能會產生特定的 HTTP 狀態碼。或者,它可能會執行錯誤處理或應用程式層級登入特定的方式,在每個動作。這類原則代表跨領域問題,可能的話,您要遵守不重複自行 (乾) 原則,並拉出它們成常見的抽象概念。然後,全域套用這個抽象層,或任何適當的應用程式中。篩選提供的好方法來達到此目的。

中介軟體呢?

在 2016 年 6 月,我說明如何 ASP.NET Core 中介軟體可讓您控制應用程式中的要求管線 (msdn.magazine.com/mt707525)。疑似聽起來篩選器可以執行 ASP.NET 核心 MVC 應用程式中。兩者的差異是內容。ASP.NET Core MVC 是透過中介軟體中實作。(MVC 本身不是中介軟體,但它會設定本身是路由的中介軟體的預設目的地)。ASP.NET 核心 MVC 包含許多功能,例如模型繫結、 內容交涉,以及回應格式。MVC 的內容中的篩選器存在,所以他們可以存取這些 MVC 層級功能和抽象概念。中介軟體,相較之下,較低層級存在,且沒有任何直接瞭解 MVC 或它的功能。

如果您想要在較低的層級,而且它執行的功能不倚賴 MVC 層級內容,請考慮使用中介軟體。如果您想要在控制器動作中有許多一般的邏輯,篩選器可能會提供方式,來糾正總方便維護和測試它們。

類型的篩選器

一旦 MVC 中介軟體接管時,它會呼叫至各種不同的篩選器動作的引動過程管線內的不同點。

執行第一個篩選器是授權篩選器。如果要求未獲授權,篩選器會立即 short-circuits 其餘的管線。

接下來在一行是資源的篩選器 (後授權) 是處理要求第一個和最後一個篩選。資源篩選器可執行程式碼要求的開頭與結尾處之前離開 MVC 管線。資源的篩選器的一個良好的使用案例是輸出快取。篩選條件可以檢查快取,並傳回快取的結果管線的開頭。如果不尚未填入快取,篩選條件可以回應動作快取中加入結尾的管線。

動作篩選條件執行之前和之後執行動作。執行之後的模型繫結,讓他們擁有存取權的模型繫結參數,將會傳送至動作,以及模型驗證狀態。

動作傳回的結果。結果篩選執行之前和之後執行結果。它們可以新增行為,以檢視或格式子的執行。

最後,例外狀況篩選條件用來處理無法攔截的例外狀況,並將通用的原則套用到這些應用程式內的例外狀況。

在本文中我將著重在動作篩選條件。

篩選範圍

全域或個別的控制器或動作層級,可以套用篩選器。會實作為屬性通常在任何層級中,加入全域篩選器會影響所有動作、 控制站會都影響該控制器內的所有動作的屬性篩選條件與動作套用到只該動作的屬性篩選條件的篩選器。當多個篩選條件套用至動作時,它們的順序決定先順序屬性,第二個是由它們如何範圍至有問題的動作。篩選具有相同的順序執行內,第一次全域的這表示,然後再執行控制器,然後執行層級篩選。此動作會執行之後,會反轉順序,因此動作層級篩選執行時,控制站層級篩選器,並依全域篩選器。

不實作為屬性可以仍然會套用至控制器或動作使用 TypeFilterAttribute 類型的篩選器。這個屬性接受的建構函式參數以執行篩選器類型。例如,若要套用至單一動作方法的 CustomActionFilter,您可以撰寫︰

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

TypeFilterAttribute 適用於應用程式的內建的服務容器,以確保在執行階段系統會填入 CustomActionFilter 所公開的任何相依性。

乾 API

為了示範幾個範例,其中篩選可以改善 ASP.NET MVC 核心應用程式的設計,我建置了簡單的 API,提供基本建立、 讀取、 更新、 刪除 (CRUD) 功能和遵循幾個標準的規則,處理不正確的要求。保護 Api 是其各自的主題,因為我要刻意離開,這個範例的範圍之外。

我的範例應用程式公開的 API,來管理是幾個屬性的簡單類型的作者。API 會取得所有作者,都取得一位作者的識別碼、 建立新的作者、 編輯作者和刪除作者使用標準 HTTP 動詞命令為基礎的慣例。它接受 IAuthorRepository 透過相依性插入 (DI) 抽象資料存取。(請參閱我月文章,網址 msdn.com/magazine/mt703433 如需詳細資訊 DI。) 控制器執行方式與儲存機制會以非同步方式實作。

API 會遵循兩個原則︰

  1. 指定特定作者 ID 的 API 要求會取得 404 回應,如果該識別碼不存在。
  2. 提供無效的作者模型執行個體的 API 要求 (ModelState.IsValid = = false) 會傳回不正確的要求,以列出的模型錯誤。

[圖 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();
  }
}

如您所見,沒有相當的重複的邏輯,在此程式碼,尤其是在找不到與不正確的要求結果的方式。我可以快速將簡單的動作篩選模型驗證/不正確的要求檢查取代︰

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

然後,這個屬性可以套用需要執行動作的方法中加入 [ValidateModel] 模型驗證這些動作。請注意 ActionExecutingContext 上設定的 Result 屬性將會縮短要求。在此情況下,是沒有理由不將屬性套用至每個動作,因此我會將其新增到控制器,而不是每個動作。

檢查存在作者是需要一點技巧,因為這必須透過 DI 傳入控制器 IAuthorRepository。它相當簡單,若要建立參數的建構函式動作篩選條件屬性,但不幸的是,屬性會預期宣告位置提供這些參數。我無法提供儲存機制執行個體套用屬性的位置。我想要在執行階段插入受服務容器。

所幸,TypeFilter 屬性會提供此篩選器需要 DI 支援。我可以只是 TypeFilter 屬性套用至動作,並指定 ValidateAuthorExistsFilter 類型︰

[TypeFilter(typeof(ValidateAuthorExistsFilter))]

雖然可以運作,它不是我慣用的方法,因為它比較不容易閱讀而且想要套用多個通用的屬性篩選器的其中一個開發人員將不會發現 intellisense ValidateAuthorExistsAttribute。我偏好的方法是子類別化 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 參數,並取得其值之後,再檢查一位作者是否存在具有該識別碼您也應該注意到私用 ValidateAuthorExistsFilterImpl 是非同步篩選器。此模式中,只有一個方法來實作,與之前或之後執行它之前或之後的下一個呼叫會執行此動作,就可以完成工作。不過,如果您正在最少運算的篩選條件所設定的內容。結果,您需要傳回,而不呼叫 next (否則您會收到例外狀況)。

另一個篩選器時,必須注意的是,它們不應包含任何物件層級狀態,例如 (在特定的實作做為屬性) IActionFilter OnActionExecuting 期間設定,然後讀取或 OnActionExecuted 中已修改的欄位。如果您發現進行這類邏輯的需求,您可以藉由切換至 IAsyncActionFilter,可以直接使用 OnActionExecutionAsync 方法內的區域變數來避免這種狀態。

移位模型驗證,並檢查是否存在的記錄從控制器動作中常見的篩選器之後, 已在我的控制器上的效果? 如需比較, [圖 3 顯示 Authors2Controller,就會執行相同的邏輯為 AuthorsController,但會利用其一般原則行為的這兩個篩選。

[圖 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();
  }
}

請注意有關這個重構 controller 兩件事。首先,它是簡短也更清楚。其次,有任何一種方法中的任何條件。為使控制站的工作盡可能直接在適當的地方套用篩選器已經完全提取 API 的一般的邏輯。

但您可以測試它嗎?

從屬性到控制器的移動邏輯適合用來降低複雜性和強制執行一致的執行階段行為。不幸的,如果您直接對動作方法中執行單元測試,測試不會有套用到它們的屬性或篩選行為。這是根據設計,而且當然可以單元測試篩選獨立的個別動作方法,以確保其如預期般運作。但是,如果您需要確保不僅來篩選您的工作,但是它們是正確設定並套用至個別動作的方法? 如果您要重構一些 API 程式碼您已經利用我剛才顯示的篩選器而您想要確保 API 仍會正確地完成? 所呼叫的整合測試。幸好,ASP.NET 核心提供一些絕佳的快速、 輕鬆整合測試支援。

我的範例應用程式設定為使用記憶體中 Entity Framework 核心,DbContext,但即使它使用 SQL Server,可以輕鬆地切換我的整合測試中使用記憶體中存放區。這一點,因為它大幅改進的這類測試的速度並更方便進行設定,因為沒有基礎結構是必要。

類別的程式碼的整合測試中 ASP.NET 核心重責大任,大多是 TestServer 類別 Microsoft.AspNetCore.TestHost 套件中可用。您設定完全要如何您 Web 應用程式中 Program.cs 的進入點,使用設定 WebHostBuilder TestServer。在我的測試,使用相同的啟動類別,我範例 Web 應用程式中,選擇,我指定它在測試環境中執行。啟動網站時,這樣會觸發某些範例資料︰

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 作者放測試

[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 核心中的整合測試。

後續步驟

篩選器是很大的主題,只需要幾個例子,本文中的空間。您可以簽出 docs.asp.net 若要深入了解篩選器和測試 ASP.NET 核心應用程式上的官方文件。

此範例的原始程式碼位於 bit.ly/1sJruw6


Steve Smith 是獨立的訓練、 指導和顧問,以及 ASP.NET 的 MVP。 他投稿數十個文件以正式的 ASP.NET 核心文件 (docs.asp.net),並可協助小組快速掌握使用 ASP.NET 的核心。他的連絡 ardalis.com ,關注他的 Twitter︰ 也稱為 @ardalis


感謝以下的微軟技術專家對本文的審閱: Doug Bunting
Doug Bunting 是使用 Microsoft ASP.Net 團隊的開發人員。