martes, 23 de octubre de 2018
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 tipoFriend
.
- 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ámetroident
.
- Tras obtener el valor del identificador, buscaremos en el contenedor de dependencias un servicio de tipo
IRepository<T>
, siendoT
el tipo de objeto solicitado. Siguiendo con nuestro ejemplo, buscaríamos un servicioIRepository<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 tipoIDictionary<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 unCompositeValueProvider
, 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 deController
, aunque no sería difícil proveer de otras implementaciones que no usen esta convención.
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.
Publicado en Variable Not Found.
Aún no hay comentarios, ¡sé el primero!
Enviar un nuevo comentario