Saltar al contenido

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript... y lo que venga ;)

17 años online

el blog de José M. Aguilar

Inicio El autor Contactar

Artículos, tutoriales, trucos, curiosidades, reflexiones y links sobre programación web
ASP.NET Core, MVC, Blazor, SignalR, Entity Framework, C#, Azure, Javascript...

¡Microsoft MVP!
martes, 23 de octubre de 2018
ASP.NET Core MVC Desde que comenzamos a programar con ASP.NET MVC tenemos interiorizado que si en nuestras acciones vemos código repetido, suele ser un candidato a ser implementado en forma de filtro. Así, durante años hemos implementado en forma de action filters asuntos transversales de nuestras aplicaciones, como la seguridad, logging, caching y otros aspectos.

Sin embargo, a veces olvidamos que estas mismas técnicas podemos utilizarlas para simplificar código en la implementación de convenciones o funciones más ligadas al negocio o a funcionalidades de nuestra aplicación. Por ejemplo, ¿no estáis aburridos de escribir acciones como las siguientes?
public class FriendsController: Controller
{
    public IActionResult View(int id) 
    {
        var friend = _friendServices.GetById(id);
        if(friend == null)
           return NotFound();
        ... // Prepare and show "View" view
    }

    public IActionResult Edit(int id) 
    {
        var friend = _friendServices.GetById(id);
        if(friend == null)
           return NotFound();
        ... // Prepare and show "Edit" view
    }

    public IActionResult Delete(int id) 
    {
        var friend = _friendServices.GetById(id);
        if(friend == null)
           return NotFound();
        ... // Prepare and show "Delete" view
    }

    ...
}
Pues bien, vamos a aplicar el mismo principio para simplificar este código y eliminar duplicidades extrayendo las porciones comunes a un filtro, resultando en algo así de bonito:

[Autoload(typeof(Friend))] // ¡Magia!
public class FriendsController: Controller
{
    public Task<IActionResult> View(Friend friend) 
    {
        ... // Prepare and show "View" view
    }

    public Task<IActionResult> Edit(Friend friend) 
    {
        ... // Prepare and show "Edit" view
    }

    public Task<IActionResult> Delete(Friend friend) 
    {
        ... // Prepare and show "Delete" view
    }
    ...
}
No sé lo útil que podrá resultar en la práctica pero, como mínimo, nos ayudará a conocer mejor cómo funciona por dentro el framework ASP.NET Core MVC.

Primero, tengámoslo claro: convenciones

Para conseguir el resultado pretendido primero tendremos que establecer ciertas convenciones, así como determinar claramente el comportamiento que deseamos para el filtro [Autoload].

Así pues, de momento convengamos que las peticiones a acciones afectadas por nuestro filtro serán procesadas de la siguiente forma:
  • Examinamos si la acción contiene un parámetro de entrada del mismo tipo indicado en el atributo. Es decir, si hemos decorado la acción (o controlador donde se encuentra) con [Autoload(typeof(Friend))], buscamos si ésta tiene un parámetro de entrada de tipo Friend.
     
  • En caso afirmativo, buscamos si en los datos de la petición existe un parámetro id y obtenemos su valor. Este nombre de parámetro es por defecto, pero podremos establecer uno diferente al crear el filtro, así: [Autoload(typeof(Friend), IdParamName = "ident")] si quisiésemos utilizar como identificador el parámetro ident.
     
  • Tras obtener el valor del identificador, buscaremos en el contenedor de dependencias un servicio de tipo IRepository<T>, siendo T el tipo de objeto solicitado. Siguiendo con nuestro ejemplo, buscaríamos un servicio IRepository<Friend>.
     
  • Si existe el servicio, invocamos a su método GetByIdAsync(int id) del repositorio, suministrándole el identificador obtenido anteriormente.
     
  • Si todo fue correcto y el repositorio retorna un objeto, lo inyectamos como parámetro de la acción. En caso negativo, retornaremos directamente un HTTP 404.
A nivel de código veréis que es bastante sencillo adaptar estas convenciones a vuestros escenarios específicos.
Para explicar mejor estas convenciones, veamos un ejemplo a nivel de código. Imaginad el siguiente controlador:
public class FriendsController: Controller
{
    [Autoload(typeof(Friend))]
    public Task<IActionResult> Edit(Friend friend) 
    {
        ... // Prepare and show "edit" view
    }
}
Cuando se produzca una petición hacia, digamos, friends/edit/12, el filtro detectará que la acción a ejecutar presenta un parámetro de tipo Friend. A continuación, obtendrá el contexto de la petición un valor para el parámetro “id” (12, en este caso), y lo utilizará como clave para obtener una instancia a través del servicio IRepository<Friend>. Una vez obtenida, inyectará el valor y ejecutará la acción.

[Autoload]: el código

Veamos el código de este atributo, que es bastante sencillito, y después lo comentamos un poco:
public class AutoloadAttribute : ActionFilterAttribute
{
    private readonly Type _entityType;
    private readonly Type _repositoryType;

    public string IdParamName { get; set; } = "id"; // Default ID param name

    public AutoloadAttribute(Type entityType)
    {
        _entityType = entityType;
        _repositoryType = typeof(IRepository<>).MakeGenericType(_entityType);
    }

    public override async Task OnActionExecutionAsync(ActionExecutingContext context, 
                                                      ActionExecutionDelegate next)
    {
        var entityParam = context.ActionDescriptor.Parameters
            .FirstOrDefault(p => p.ParameterType == _entityType);

        if (entityParam != null) // The action has a parameter of type _entityType
        {
            string idParamValue = await GetIdValueAsync(context);
            if (!int.TryParse(idParamValue, out var id))
            {
                // No entity ID found
                context.ActionArguments[entityParam.Name] = null;
            }
            else
            {
                // Entity ID found, so let's find it using the repository
                dynamic repo = context.HttpContext.RequestServices.GetService(_repositoryType);
                if (repo == null)
                {
                    throw new Exception(
                        $"Instance of {_repositoryType.DisplayName()} not found. "
                        + "Did you forgot to add it in ConfigureServices()?"
                        );
                }

                var entity = await repo.GetByIdAsync(id);
                if (entity == null) // We didn't found an entity, so return HTTP 404
                {
                    context.Result = new NotFoundResult();
                    return;
                }
                context.ActionArguments[entityParam.Name] = entity;
            }

        }
        await next();
    }

    // Gets the id from the request using the value providers
    private async Task<string> GetIdValueAsync(ActionExecutingContext context)
    {
        // We assume that all controllers inherit from Controller.
        var controller = context.Controller as Controller;
        var compositeValueProvider = await CompositeValueProvider
                .CreateAsync(controller.ControllerContext);
        return compositeValueProvider.GetValue(IdParamName).FirstValue;
    }
}
Creo que el código se explica por sí mismo, pero de todas formas aclaro un par de puntos clave de esta implementación:
  • Desde un filtro podemos acceder fácilmente a la descripción de la acción que se está ejecutando a través del objeto ActionDescriptor de su contexto. En este objeto encontraremos información valiosa como la descripción de sus parámetros, que usamos en nuestro código para ver si necesitamos inyectar algún valor.
     
  • Observad que para utilizar el repositorio genérico que obtenemos desde el contenedor de servicios necesitamos utilizar tipos dinámicos. Sé que suena a ñapa, pero en realidad no he encontrado otra forma de hacer un cast a IRepository<T> porque nuestro atributo no utiliza tipos genéricos (de hecho, no se soportan en C#).
     
  • También podemos acceder a los argumentos, es decir, a los valores que van a ser suministrados a la acción. Estos valores se encuentran en el diccionario ActionArguments del contexto del filtro, que es del tipo IDictionary<string, object> y, como vemos en el código, podemos manipularlos a nuestro antojo.
     
  • En el método privado GetIdValueAsync() observaréis que, para capturar desde los datos de la petición el valor del identificador que por convención usaremos para obtener la entidad, utilizamos un CompositeValueProvider, que no es sino un connjunto de value providers, inicializado con la instancia del controlador actual. Por simplificar, hemos asumido que los controladores que usarán nuestro filtro [Autoload] siempre heredarán de Controller, aunque no sería difícil proveer de otras implementaciones que no usen esta convención.
¡Y esto es todo!

Comprobando que todo funciona

Para probar el sistema sólo necesitaríamos definir el interfaz que usaremos para recuperar datos, por ejemplo así:
public interface IRepository<T>
{
    Task<T> GetByIdAsync(int id);
}
Y obviamente, necesitaríamos también una implementación del repositorio, registrada convenientemente en el sistema de inyección de dependencias de nuestra aplicación:
// En Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddScoped<IRepository<Friend>, FriendRepository>();
}
Hecho esto, ya podemos aplicar el filtro a los controladores que nos interesen, como hemos visto más arriba:
[Autoload(typeof(Friend))]
public class FriendController : Controller
{
    public string Edit(Friend friend)
    {
        return friend.Name;
    }
}
Así, una petición hacia /Friends/Edit/1 mostrará en el navegador el nombre del amigo, siempre que éste exista. En caso contrario, se retornará un error 404.

Observad además que el filtro puede ser aplicado más de una vez por controlador, por lo que podríamos solucionar escenarios más complejos como el siguiente:
[Autoload(typeof(Invoice), IdParamName = "invoiceId")]
[Autoload(typeof(Customer), IdParamName = "customerId")]
public class InvoiceController : Controller
{
    public IActionResult Add(Invoice invoice, Customer customer)
    {
        // Logic here
    }
}
En este caso, una petición hacia /invoice/add?invoiceId=1234&customerId=5678 retornaría un error 404 si alguno de los dos elementos no existe, o bien ejecutaría la acción con ambas instancias precargadas.

Conclusión

En este post hemos visto que con unas pocas decenas de líneas podemos ahorrarnos bastante trabajo si jugamos un poco con las herramientas que nos proporciona el framework MVC. En este caso, aprovechamos la potencia de los filtros para liberar a las acciones de realizar tareas muy repetitivas, como la carga automática de argumentos, partiendo de unas convenciones simples.

Aunque seguro que no es perfecta, la solución mostrada aquí puede ser un buen punto de partida para implementar funcionalidades más avanzadas. Por ejemplo, ninguna de las siguientes ideas serían difíciles de añadir:

  • Ampliar el alcance de este filtro y hacer que tenga en cuenta aspectos de seguridad, como comprobar si el usuario actual tiene permiso para acceder a la entidad solicitada.
      
  • Generalizar el filtro para que precargue todos los parámetros que implementen un interfaz o clase base determinada, de forma que no sea necesario introducir el filtro por cada uno de ellos.
     
  • Introducir mecanismos de caching que impidan la carga de elementos que no varían con frecuencia.
     
  • Y, por supuesto, podríamos definir convenciones personalizadas de ASP.NET Core MVC para aplicar automáticamente el filtro a controladores o acciones que cumplan determinados criterios.
El límite, como siempre, sólo está en nuestra imaginación y en las necesidades que debamos cubrir ;)

Publicado en Variable Not Found.

Aún no hay comentarios, ¡sé el primero!