2016 年 8 月

第 31 卷,第 8 期

ASP.NET Core - 实际的 ASP.NET Core MVC 筛选器

作者 Steve Smith

ASP.NET MVC 和 ASP.NET Core MVC 的筛选器是一个非常棒但通常没有得到充分利用的功能。筛选器提供挂接到 MVC 操作调用管道的方法,这使它们非常适合获取操作中的常见重复性任务。通常,应用对其如何处理特定条件有适用的标准策略,特别是对于可能生成特定 HTTP 状态代码的情形。或者,可以在每个操作中以特定方式执行错误处理或应用程序级别日志记录。此类策略体现了横切关注点,如果可能,你希望遵循切勿重复 (DRY) 原则,将其推出到共同抽象。然后,你可以在全局或应用程序中任意适当位置应用此抽象。筛选器提供了一个很好的方法来实现此操作。

中间件怎么样?

在 2016 年 6 月刊中,我介绍了 ASP.NET Core 中间件如何允许你控制应用中的请求管道 (msdn.microsoft.com/mt707525)。这听来似乎像筛选器可以在 ASP.NET Core MVC 应用中执行的操作。两者之间的区别是上下文。ASP.NET Core MVC 通过中间件实施。(MVC 本身不是中间件,但它可将自己配置为路由中间件的默认目标。)ASP.NET Core MVC 包含许多功能,如模型绑定、内容协商和响应格式化。筛选器位于 MVC 的上下文中,因此它们可以访问这些 MVC 级功能和抽象。相反,中间件位于较低级别,无法直接获得 MVC 或其功能的信息。

如果你具有想要在较低级别运行的功能,且它不依赖于 MVC 级上下文,则可考虑使用中间件。如果你在控制器操作中纳入许多通用逻辑,则筛选器可提供一个方法来对它们执行 DRY 以使其易于维护和测试。

各种筛选器

MVC 中间件接管后,它会在其操作调用管道中的不同点调用各种筛选器。

执行的第一批筛选器是授权筛选器。如果请求未授权,筛选器会立即简化剩余管道。

接下来是资源筛选器,它们是(授权后)第一个和最后一个处理请求的筛选器。资源筛选器可在请求最开始和最后(刚要离开 MVC 管道之前)运行代码。资源筛选器的一个很好用例是输出缓存。筛选器可在管道开头检查缓存并返回缓存结果。如果缓存尚未填充,筛选器可在管道结尾将来自操作的响应添加到缓存。

操作筛选器仅在执行操作前后运行。它们在模型绑定发生之后运行,因此有权访问将发送到操作的模型绑定参数以及模型验证状态。

操作返回结果。结果筛选器仅在执行结果前后运行。它们可以将行为添加到视图或格式化程序执行。

最后,异常筛选器用于处理未捕获的异常,并在应用中将全局策略应用于这些异常。

在本文中,我将重点介绍操作筛选器。

筛选器适用范围

筛选器可应用于全局、单个控制器或操作级别。实施为属性的筛选器通常可在任意级别添加,全局筛选器会影响所有操作,控制器属性筛选器会影响控制器中的所有操作,操作属性筛选器仅应用于对应操作。多个筛选器应用于一个操作时,其顺序首先由 Order 属性确定,其次由针对相应操作的适用范围确定。具有相同的 Order 的筛选器由外而内运行,即运行顺序首先是全局筛选器,然后是控制器筛选器,最后是操作级别筛选器。操作运行后,顺序颠倒,因此首先是操作级别筛选器,然后是控制器级别筛选器,最后是全局筛选器。

未实施为属性的筛选器仍可应用于使用 TypeFilterAttribute 类型的控制器或操作。此属性接受此筛选器类型作为构造函数参数运行。例如,要将 CustomActionFilter 应用于单个操作方法,你应编写:

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

TypeFilterAttribute 与应用的内置服务容器配合工作,以确保在运行时由 Custom­ActionFilter 公开的任何依赖项均已填充。

DRY API

为了使用一些示例来演示筛选器如何改善 ASP.NET MVC Core 应用的设计,我构建了一个简单的 API 来提供基本创建、读取、更新、删除 (CRUD) 功能,并按照一些标准规则来处理无效请求。因为保护 API 是其自己的主题,因此我有意将它放在本示例范围之外。

我的示例应用公开一个 API 用于管理作者,类型非常简单,仅具有一组属性。该 API 使用基于标准 HTTP 谓词的惯例获取所有作者、通过 ID 获取作者、创建新作者、编辑作者和删除作者。它接受通过依赖关系注入 (DI) 的 IAuthorRepository 抽象化数据访问。(有关 DI 的详细信息,请转至 msdn.com/magazine/mt703433 参阅我 5 月份的文章。) 控制器实施和存储库均为异步实施。

API 遵循两个策略:

  1. 指定特定作者 ID 的 API 请求将获取 404 响应(如果该 ID 不存在)。
  2. 提供无效作者模型实例的 API 请求 (ModelState.IsValid == false) 将返回列有模型错误的 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 是异步筛选器。在此模式下,只有一个方法要实施,而且可在操作前后完成此工作,通过在调用下一个之前或之后执行该操作即可。但是,如果你正在通过设置 context.Result 来简化筛选器,你需要在不调用下一个的情况下返回(否则会出现异常)。

关于筛选器的另一个注意事项是,不应将它们纳入对象级状态,如在 OnActionExecuting 期间设置并随后在 OnActionExecuted 中读取和修改的 IActionFilter(尤其是实施为属性的那个)上的字段。如果你发现需要执行此类逻辑,通过切换到 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();
  }
}

关于此重构控制器存在两个注意事项。首先,它更简短、更清晰。其次,在任何方法中都无条件。API 的常用逻辑已完全放入筛选器(适时应用),因此控制器的工作会尽可能简单。

但能否对它进行测试?

将逻辑从控制器移动到属性可大大降低代码复杂性并强制实施一致的运行时行为。遗憾的是,如果直接针对操作方法运行单元测试,你的测试将不会对它们应用属性或筛选行为。设计就是这样,当然你可以独立于各个操作方法单元测试筛选器,以确保它们按设计运行。但如果你不仅需要确保筛选器工作还需要确保它们已正确设置并应用于各个操作方法,该怎么样? 如果你想要重构一些已有的 API 代码以利用我刚刚展示的筛选器并想要确保完成后 API 仍可正确运行,该怎么样? 这称为集成测试。幸运的是,ASP.NET Core 可为快速轻松集成测试提供非常棒的支持。

我的示例应用程序配置为使用内存中 Entity Framework Core DbContext,但即使你使用 SQL Server,我也可以轻松转换为使用内存中存储进行我的集成测试。这很重要,因为它可显著提高此类测试的速度,更易于对其进行设置,因为不需要基础结构。

TestServer 类为 ASP.NET Core 中的集成测试执行大部分繁重操作,Microsoft.AspNetCore.TestHost 包中具有此类。可按照与在 Program.cs 入口点配置 Web 应用相同的方式使用 WebHostBuilder 配置 TestServer。在我的测试中,我将选择使用与示例 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 作者 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();
}

请注意,这些集成测试不需要数据库或 Internet 连接或运行的 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:aka @ardalis


衷心感谢以下 Microsoft 技术专家对本文的审阅: Doug Bunting
Doug Bunting 是供职于 Microsoft ASP.Net 团队的开发人员。