Compartir a través de



Agosto de 2016

Volumen 31, número 8

ASP.NET Core: filtros de ASP.NET Core MVC real

Por Steve Smith

Los filtros son una característica genial y a veces infrausada de ASP.NET MVC y ASP.NET Core MVC. Proporcionan un modo de conectarse a la canalización de invocación de acciones de MVC, lo que las hace útiles para quitar tareas comunes y repetitivas de sus acciones. A menudo, una aplicación tendrá una directiva estándar que se aplica a cómo controla determinadas condiciones, especialmente aquellas que puedan generar códigos de estado HTTP particulares. O bien, puede realizar un control de errores o un registro a nivel de aplicación de un modo específico en cada acción. Este tipo de directivas representan problemas transversales y, si es posible, puede seguir el principio Una vez y solo una (DRY), quitarlos y ubicarlos en una abstracción común. Después, puede aplicar esta abstracción de forma global o en un lugar adecuado de su aplicación. Los filtros son un gran modo para lograrlo.

¿Qué hay de Middleware?

En la emisión de junio de 2016, describí cómo el middleware de ASP.NET Core le permite controlar la canalización de solicitudes en sus aplicaciones (msdn.magazine.com/mt707525). Es sospechosamente parecido a lo que pueden hacer los filtros en la aplicación ASP.NET Core MVC. La diferencia entre ambos está en el contexto. ASP.NET Core MVC se implementa mediante middleware. (MVC no es middleware, pero se configura para que sea el destino predeterminado del middleware de enrutamiento). ASP.NET Core MVC incluye muchas características, como Enlace de modelos, Negociación de contenido y Formato de respuesta. Los filtros existen en el contexto de MVC, de modo que tienen acceso a las características y abstracciones a nivel de MVC. El middleware, por el contrario, existe en un nivel inferior y no tiene conocimiento directo de MVC o sus características.

Si tiene la funcionalidad que quiere ejecutar en un nivel inferior y no depende de un contexto a nivel de MVC, considere la posibilidad de usar middleware. Si acostumbra a tener la misma lógica en la mayoría de sus acciones de controlador, los filtros le pueden proporcionar un modo de DRY para que sean más fáciles de mantener y probar.

Tipos de filtros

Cuando el middleware de MVC toma el control, llama a una variedad de filtros en distintos puntos de su canalización de invocación de acciones.

Los primeros filtros que se ejecutan son los filtros de autorización. Si no se autoriza la solicitud, el filtro provoca inmediatamente un cortocircuito en el resto de la canalización.

Los siguientes de la línea son los filtros de recursos, que (después de los de autorización) son los primeros y los últimos filtros en controlar una solicitud. Los filtros de recursos pueden ejecutar código al inicio de una solicitud y también al final, justo antes de que salga de la canalización de MVC. Un buen caso de uso de un filtro de recursos es el almacenamiento en caché de resultados. El filtro puede comprobar la memoria caché y devolver el resultado en caché al comienzo de la canalización. Si la memoria caché aún no está rellenada, el filtro puede agregar la respuesta desde la acción a la memoria caché al final de la canalización.

Los filtros de acción se ejecutan justo antes y después de que se ejecuten las acciones. Se ejecutan después de que ocurra el enlace de modelos, de modo que tienen acceso a los parámetros enlazados al modelo que se enviarán a la acción y también el estado de validación del modelo.

Resultados de retorno de la acción. Los filtros de resultado se ejecutan justo antes y después de que se ejecuten los resultados. Pueden agregar un comportamiento para ver o formatear la ejecución.

Finalmente, los filtros de excepción se usan para controlar excepciones no detectadas y aplicar directivas globales a las excepciones de la aplicación.

En este artículo, me centraré en los filtros de acción.

Ámbito de filtro

Los filtros se pueden aplicar de forma global o a nivel de controlador o acción individual. Los filtros que se implementan como atributos normalmente se pueden agregar en cualquier nivel, con filtros globales que afecten a todas las acciones, filtros de atributo de controlador que afecten a todas las acciones de ese controlador y filtros de atributo de acción que se apliquen solo a esa acción. Cuando varios filtros se aplican a una acción, su orden se determina primero con una propiedad Order y después con el ámbito de la acción concreta. Los filtros con la misma propiedad Order se ejecutan mediante interacción indirecta, lo que significa que se ejecuta primero el filtro global, seguido del filtro de controlador y después el filtro a nivel de acción. Después de que se ejecute la acción, el orden se revierte, de modo que se ejecuta primero el filtro a nivel de acción, seguido del filtro a nivel de controlador y después el filtro global.

Los filtros que no se implementan como atributos se pueden aplicar a controladores o acciones usando el tipo TypeFilterAttribute. Este atributo acepta el tipo del filtro que se va a ejecutar como parámetro de construcción. Por ejemplo, para aplicar el filtro CustomActionFilter a un único método de acción simple, debe escribir:

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

El tipo TypeFilterAttribute funciona con el contenedor de servicios integrado de la aplicación para asegurar que cualquier dependencia que exponga el filtro Custom-ActionFilter se rellene en tiempo de ejecución.

Una API DRY

Para mostrar algunos ejemplos en los que los filtros pueden mejorar el diseño de la aplicación ASP.NET MVC Core, he creado una API simple que proporciona una funcionalidad básica para crear, leer, actualizar y eliminar (CRUD) y sigue unas reglas estándar para controlar solicitudes no válidas. Como la protección de API tiene su propio tema, lo estoy dejando intencionadamente fuera del ámbito de esta muestra.

Mi aplicación de muestra expone una API para administrar autores, que son tipos simples con solo un par de propiedades. La API usa convenciones basadas en verbo HTTP estándar para obtener todos los autores, obtener un autor por id., crear un nuevo autor, editar un autor y eliminar un autor. Acepta un repositorio IAuthorRepository mediante una inserción de dependencias (DI) para resumir el acceso a los datos. (consulte mi artículo de mayo en msdn.com/magazine/mt703433 para saber más sobre DI.) Tanto la implementación del controlador y el repositorio se implementan de forma asincrónica.

La API sigue dos directivas:

  1. Las solicitudes de API que especifican un id. de un autor concreto obtendrán una respuesta 404 si ese id. no existe.
  2. Las solicitudes API que proporcionan una instancia del modelo Author no válida (ModelState.IsValid == false) devolverán un error BadRequest con una lista de los errores del modelo.

En la Figura 1 se muestra la implementación de la API con las reglas en su lugar.

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

Como puede ver, hay bastante lógica duplicada en este código, especialmente en el modo en el que se devuelven los resultados NotFound y BadRequest. Puedo sustituir rápidamente las comprobaciones de validación o BadRequest del modelo con un simple filtro de acción:

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

Este atributo se puede aplicar a aquellas acciones que necesiten realizar una validación del modelo agregando [ValidateModel] al método de acción. Tenga en cuenta que establecer la propiedad Result en ActionExecutionContext provocará un cortocircuito en la solicitud. En este caso, no hay razón para no aplicar el atributo a cada acción, de modo que lo agregaré al controlador en lugar de a cada acción.

Comprobar si existe el autor es un poco más complicado, porque depende del repositorio IAuthorRepository que se incorpora al controlador mediante DI. Es bastante sencillo crear un atributo de filtro de acción que tome un parámetro del constructor, pero, desafortunadamente, los atributos esperan que esos parámetros se proporcionen donde se declaran. No puedo proporcionar la instancia del repositorio en la que se aplica el atributo. Quiero que el contenedor de servicios la inyecte en tiempo de ejecución.

Afortunadamente, el atributo TypeFilter proporcionará el soporte DI que necesita el filtro. Puedo aplicar simplemente el atributo TypeFilter a las acciones y especificar el tipo ValidateAuthorExistsFilter:

[TypeFilter(typeof(ValidateAuthorExistsFilter))]

Mientras funcione, no es mi enfoque preferido, porque es menos legible y los desarrolladores que intenten aplicar uno de los muchos filtros de atributos comunes no encontrarán el atributo ValidateAuthorExists­Attribute en IntelliSense. Un enfoque que me gusta es poner en subclase el atributo TypeFilterAttribute, darle un nombre adecuado y poner la implementación del filtro en una clase privada dentro del atributo. En la Figura 2 se muestra este enfoque. La clase ValidateAuthorExistsFilterImpl privada realiza el trabajo real y se incorpora el tipo de la clase al constructor del atributo TypeFilterAttribute.

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

Tenga en cuenta que el atributo tiene acceso a los argumentos que se pasan a la acción, como parte del parámetro ActionExecutingContext. Esto permite que el filtro compruebe si existe un parámetro de id. y obtiene su valor antes de comprobar si existe un atributo Author con ese id. Fíjese también que el filtro ValidateAuthorExistsFilterImpl privado es un filtro asincrónico. Con este patrón, solo hay un método para implementar y se puede hacer el trabajo ejecutando la acción antes o después de llamar a la siguiente antes o después de que se ejecute la acción. Sin embargo, si provoca un cortocircuito en el filtro estableciendo un resultado context.Result, necesita volver sin llamar a la siguiente (de lo contrario, obtendrá una excepción).

Otra cosa que recordar sobre los filtros es que no deberían incluir ningún estado a nivel de objeto, como un campo en un filtro IActionFilter (en especial, uno que se implemente como atributo) que se establezca durante la acción OnActionExecuting y se lea o modifique en la acción OnActionExecuted. Si necesita realizar esta lógica, puede evitar ese tipo de estado cambiándolo a un filtro IAsyncActionFilter, que simplemente puede usar variables locales en el método OnActionExecutionAsync.

Después de cambiar la validación del modelo y comprobar la existencia de registros desde las acciones del controlador hasta los filtros comunes, ¿qué efecto ha tenido en el controlador? Como comparación, en la Figura 3 se muestra el controlador Authors2Controller, que realiza la misma lógica que el controlador AuthorsController, pero aprovecha los dos filtros para su comportamiento de directiva común.

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

Tenga en cuenta dos cosas sobre este controlador refactorizado. Primero, es más corto y más claro. Segundo, no hay condicionales en ninguno de sus métodos. La lógica común de la API se ha incorporado completamente a los filtros, que se aplican donde es adecuado, de modo que el trabajo del controlador es lo más directo posible.

Pero, ¿puede probarlo?

Mover lógicas del controlador a los atributos es genial para reducir la complejidad del código y aplicar un comportamiento de tiempo de ejecución coherente. Desafortunadamente, si ejecuta pruebas unitarias directamente contra sus métodos de acción, las pruebas no tendrán el comportamiento de atributo o filtro que se les aplica. Esto es por diseño y, obviamente, puede realizar pruebas unitarias de los filtros independientemente de los métodos de acción individuales para asegurarse de que funcionan como se espera. Pero, ¿qué pasa si necesita asegurarse de que no solo funcionan los filtros, sino que también se configuraron y aplicaron adecuadamente a métodos de acción individuales? ¿Qué pasa si quiere refactorizar algún código de API que ya tiene para sacar provecho de los filtros que mostré y quiere estar seguro de que la API todavía se comporta correctamente cuando ha terminado? Eso requiere realizar pruebas de integración. Afortunadamente, ASP.NET Core incluye una gran compatibilidad con pruebas de integración fáciles y rápidas.

Mi aplicación de muestra está configurada para usar un elemento DbContext de Entity Framework Core en memoria, pero incluso si usara SQL Server, podría cambiarlo fácilmente y usar un almacenamiento en memoria para mis pruebas de integración. Esto es importante porque mejora sustancialmente la velocidad de las pruebas y facilita configurarlas porque no se necesita ninguna infraestructura.

La clase que hace la mayoría del levantamiento para las pruebas de integración en ASP.NET Core es la clase TestServer, disponible en el paquete Microsoft.AspNetCore.TestHost. Debe configurar la clase TestServer igual que la aplicación web en el punto de entrada Program.cs usando un elemento WebHostBuilder. En mis pruebas, elijo usar la misma clase de inicio que en mi aplicación web de muestra y especifico que se ejecute en el entorno de pruebas. Esto desencadenará algunos datos de muestra cuando se inicie el sitio:

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

En este caso, el cliente es un cliente System.Net.Http.HttpClient estándar, que debe usar para realizar solicitudes al servidor como si estuviera en la red. Pero, como todas las solicitudes se hacen en memoria, las pruebas son extremadamente rápidas y sólidas.

Para mis pruebas, uso xUnit, que incluye la posibilidad de ejecutar varias pruebas con conjuntos de datos distintos para un método de prueba proporcionado. Para confirmar que mis clases AuthorsController y Authors2Controller se comportan idénticamente, uso esta característica para especificar los controladores a cada prueba. En la Figura 4 se muestran varias pruebas del método Put.

Figura 4 Pruebas de Put de autores

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

Tenga en cuenta que las pruebas de integración no necesitan base de datos, conexión a Internet ni un servidor web en ejecución. Son casi tan rápidas y sencillas como las pruebas unitarias, pero, lo más importante, le permiten probar sus aplicaciones de ASP.NET mediante toda la canalización de solicitudes, no solo como un método aislado en una clase de controlador. Sigo recomendando escribir pruebas unitarias donde pueda y volver a las pruebas de integración para los comportamientos de los que no pueda hacer las pruebas unitarias, pero es genial tener un modo de alto rendimiento para ejecutar pruebas de integración en ASP.NET Core.

Pasos siguientes

Los filtros son un gran tema, solo tenía espacio en este artículo para poner un par de ejemplos. Puede comprobar la documentación oficial en docs.asp.net para obtener más información sobre los filtros y las pruebas de aplicaciones de ASP.NET Core.

El código fuente de este ejemplo está disponible en bit.ly/1sJruw6.


Steve Smithes instructor, mentor y asesor independiente, además de MVP de ASP.NET. Ha aportado decenas de artículos a la documentación oficial de ASP.NET Core (docs.asp.net) y ayuda a equipos para que saquen el máximo provecho de ASP.NET Core rápidamente. Puede ponerse en contacto con él en ardalis.com y seguirlo en Twitter: @ardalis.


Gracias al siguiente experto técnico de Microsoft por revisar este artículo: Doug Bunting
Doug Bunting es un desarrollador que trabaja en el equipo de ASP.Net de Microsoft.