A la hora de hacer las consultas sobre nuestras WebApi, OData es un estándar propio de Microsoft que están adoptando la mayoría de API’s de los productos punteros para establecer su comunicación con el Front. Apps como Twitter, Facebook y naturalmente Microsoft Graph, utilizan esta forma de realizar consultas a sus objetos de negocio. Como desarrolladores, muchas veces no ponemos OData por miedo al performance que ocasiona en el Backend. ¿Cómo lo podemos solucionar? En este artículo vemos cómo cuidar las consultas para evitar matar muchos gatitos y ahorrarnos esfuerzos en el desarrollo 😅
Introducción
Este artículo es una continuación del post que escribió mi compañero Sergio Parra. Así que si no habéis oído nada sobre OData y cómo empezar a utilizarlo en los desarrollos en .NET, leedlo y luego continuad por este punto 🙂
Una vez nos hemos leído el artículo anterior, ya sabemos lo qué es OData: un estándar que define un conjunto de buenas prácticas para la construcción y consumo de RESTFul API.
De todo lo que tiene Odata, centrémonos en la parte relacionada con la realización de consultas. A ver, levantad la mano: ¿en cuántos proyectos, en las llamadas para hacer filtros creas un objeto para indicar sobre qué campo o valores filtramos?… No me digas que es una tarea sencilla hacer eso. Pero ojo, ¿a cuántos nos ha pasado que, una vez instalado, surge un nuevo requerimiento y no podemos filtrar por este u otro campo? Eso, a nivel de desarrollo implica que tenemos que hacer una modificación en la API y posteriormente modificar la consulta, DTO’s, Test (si los hubiera). Es decir, una simple modificación, implica dedicar un tiempo extra muy valioso.
Otra de las ventajas que tiene OData respecto a nuestro desarrollo “Custom” es que es un estándar. Esto hace que cualquier developer (ya sea de Front/Back) pueda entender cómo funciona y pueda realizar las peticiones a la API que necesita en su desarrollo.
Ahora bien, este último punto también puede ser un inconveniente dependiendo de la madurez del equipo de desarrollo.
Pensad un supuesto en el que se cree un único controlador con OData y se permita realizar cualquier filtro y consulta sobre dicha entidad. Esto puede hacer que para establecer determinada lógica de negocio, en lugar de implementar un método en el controlador con dicha lógica, se indique que esta lógica se haga implementando filtros con OData. Para mí, este supuesto es un mal uso de OData. Sí, hemos dicho que nos ahorra tiempo de implementación, pero esto no implica que en cada entidad tengamos que indicar sobre qué campos queremos filtrar y sobre todo, qué queremos mostrar.
Imaginad un caso en el que tengamos una lista de clientes y un filtro en el que mostremos cualquier información sobre él. Por ejemplo, información tan sustancial como el importe que nos adeuda… No creo que la mejor opción sea que implementemos OData y con ella poder permitir cualquier opción.
Cómo incorporar OData
Vamos a partir de que tenemos una API de Avengers. En primer lugar vamos a configurar e instalar los paquetes Nuget necesarios para empezar a utilizar OData en nuestra API. En este caso nos hará falta el siguiente paquete:
dotnet add package Microsoft.AspNetCore.OData --version 7.4.1
Una vez esta instalado, tocaremos el StartUp para añadirle el soporte para OData. Para ello, en el método Configure Services le indicamos que vamos a hacer uso de OData y añadimos la siguientes líneas:
services.AddOData();
services.AddODataQueryFilter();
services.AddMvc(options =>
{
options.EnableEndpointRouting = false;
});
Ahora, dentro del método Configure del mismo StartUp, substituímos app.UseRouting() por el UseMvc(), con la siguiente configuración:
app.UseMvc(routeBuilder =>;
{
routeBuilder.EnableDependencyInjection();
routeBuilder.Select().Filter().OrderBy().Expand().Count().MaxTop(10);
routeBuilder.MapODataServiceRoute("api", "api", GetEdmModel(app.ApplicationServices));
});
Estas líneas de código lo que hacen es:
- Activar la inyección de Dependencias
- Ver qué métodos de OData vamos a permitir: Select, Filter, OrderBy, Expand, Count, Top..
- Mapear OData con nuestro modelo. ^Para ello nos creamos el método GetEdm que tiene el siguiente código:
private static IEdmModel GetEdmModel(IServiceProvider serviceProvider)
{
ODataModelBuilder builder = new ODataConventionModelBuilder(serviceProvider);
builder.EntitySet<Avenger>("Avenger")
.EntityType
.Filter()
.Count()
.Expand()
.OrderBy()
.Page()
.Select();
return builder.GetEdmModel();
}
Una vez tenemos el arranque configurado, vamos a tunear el controlador. Para ello, en primer lugar cambiamos el ControllerBase por el ODataController, quedando nuestro Controller de la siguiente forma:
public class AvengerController : ODataController
{
...
}
Posteriormente vamos a añadir unos decoradores al método, para lo que usaremos OData. Imaginad que tenemos un método que nos devuelve todos los Avengers, quedando de la siguiente forma:
[ODataRoute]
[EnableQuery(PageSize = 20, AllowedQueryOptions = AllowedQueryOptions.All)]
[HttpGet]
public async Task<IActionResult> Get()
{
var result = await avengerDomain.GetAllAsync();
if (result != null)
{
return Ok(result);
}
else
{
return NotFound();
}
}
Si arrancamos nuestra API e invocamos al siguiente método localhost/api/avenger, vemos que nos carga todos los ítems que tenemos en Avengers.
Ahora sí, usamos la sintaxis de OData para filtrar. Si tenemos un Avenger que se llama Hulk, por ejemplo, hacemos localhost/api/avenger?filter=$name eq ‘Hulk’
¿Cuál es el problema?
A nivel de la representación de los datos la devolución es la correcta, pero ahora bien, si analizamos las tripas y ponemos un punto de parada antes de que devolvamos los resultados, veremos lo siguiente:
Como podéis ver, a pesar de que solamente hemos pedido un único registro, se está trayendo toda la información de la tabla y después, filtra los datos que he pedido. En mi opinión, esta opción no es la más optima. Imaginad que tenemos una tabla con cantidad elevada de registros y solo queremos devolver dos. Por un lado, es posible que esa consulta pueda tener una penalización en cuanto a rendimiento y, por otro lado, tampoco tiene sentido hacer los filtros en el servidor cuando lo puede/debe hacer la base de datos.
¿Cómo se puede solucionar con OData?
En primer lugar, nos hemos olvidado de un parámetro que se puede pasar en el controlador, y en el cual podemos tener la consulta que se ha realizado desde la petición, es decir, tenemos un objeto donde está nuestra consulta y cómo utilizarla. Este objeto es ODataQueryOptions filter. Lo añadimos como parámetro en nuestro controlador y volvemos a ejecutar la consulta:
Si analizamos el contenido de ese objeto, observamos que, por un lado, tiene en las propiedades cada uno de los elementos que contiene el Filter, Top, Count, etc.. y tiene un método ApplyTo. El método ApplyTo tiene como parámetro de entrada un objeto IQueryable y, sobre este método, se aplica el filtro que el usuario ha realizado a la Api. Muchas veces implementamos un patrón repositorio en el que tenemos unos métodos que directamente nos devuelven un método IList.
Un ejemplo de Patrón repositorio sería el siguiente código:
public interface IRepositoryBase<T, U> : IDisposable where T : Base<U>, new() where U : unmanaged
{
T Add(T entity);
T GetById(U id);
T GetById(U id, Func<IQueryable<T>, IIncludableQueryable<T, object>> includes);
IList<T> Get(Expression<Func<T, bool>> filter);
IList<T> Get(Expression<Func<T, bool>> filter, Func<IQueryable<T>, IQueryable<T>> func);
bool Update(T entity);
bool Delete(U id);
}
Muchas veces no entendemos la diferencia entre las interfaces IQueryable, IList, ICollection e IEnumerable y los motivos por los que usar unos u otros. Podríamos definir cada una de ellas de la siguiente forma:
IEnumerable => Es la interfaz básica y de la que heredan el resto de interfaces. Puedes iterar a través de cada elemento del IEnumerable. No puedes editar los elementos como añadir, borrar, actualizar, etc. En su lugar, sólo usas un contenedor para contener una lista de elementos. Es el tipo más básico de contenedor de listas. Todo lo que obtienes en un IEnumerable es un enumerador que ayuda a iterar sobre los elementos. Un IEnumerable no contiene ni siquiera el conteo de los elementos de la lista, en su lugar, tienes que iterar sobre los elementos para obtener el conteo de los elementos. Soporta el filtrado de elementos usando la cláusula where.
ICollection => Es la más básica de las interfaces que hemos enumerado. Es una interfaz enumerable que soporta un Conteo, y eso es todo. IList incluye todo lo que es ICollection, pero también soporta añadir y quitar elementos, recuperar elementos por índice, etc.
IQueryable => Es una interfaz enumerable que soporta LINQ. Siempre se puede crear un IQueryable a partir de un IList y usar LINQ a Objetos, pero también se encuentra el IQueryable usado para la ejecución diferida de sentencias SQL en LINQ a SQL y LINQ a Entidades.
Partiendo de esta definición, está claro que al Repositorio Base que hemos planteado le faltan unos métodos IQueryable. Principalmente, porque en el momento en el que lancemos una .ToList() se va a ejecutar la consulta, y está claro que no es lo mismo hacer una consulta con todos los ítems de la tabla, que hacer una consulta solo con la información que se necesita. Por este motivo, añadimos al patrón repositorio anterior, los siguientes métodos:
IQueryable<T> ListAll();
IQueryable<T> ListAll(Func<IQueryable<T>, IQueryable<T>> func);
Una vez tenemos añadido nuestro el método que nos devuelve el IQueryable lo que debemos de hacer es modificar nuestro método de dominio. Para ello en primer lugar le pasaremos el objeto Filter donde tenemos la consulta que se ha pedido a la API. Quedando nuestro método de la siguiente forma:
public IList<Avenger> GetAll(ODataQueryOptions<Avenger> filter)
{
var avenger = (IEnumerable<Avenger>)filter.ApplyTo(avengerRepository.ListAll());
return avenger.ToList();
}
Si ahora nos pica un poco la curiosidad y analizamos las peticiones que hacemos a la base de datos con este cambio, podemos ver que la consulta, ahora es tal y como la hemos pedido en la API, optimizando la consulta:
¿Alguna cosa más?
Esto no es todo. Si miráis por encima todas las posibilidades que trae OData, nos ofrece la opción de traernos solo los datos que realmente necesitamos de la clase. Si establecemos una instrucción Select en la petición de OData y forzamos el tipo en el que queremos devolver, se produce un error, debido a que no coinciden los tipos. Por ejemplo, si realizamos la siguiente instrucción:
https://localhost:44339/api/avenger?$name=name&$filter=name eq'Hulk'
Se producirá el siguiente error:
¿Cómo podemos solucionarlo?
Lo primero que nos viene a la cabeza es no permitir la instrucción Select en OData (muerto el perro se acabo la rabia). Pero una vez se nos pasa el cabreo por el error, tenemos que ver cómo solucionarlo ¿Y se puede solucionar? Sí, incluso podemos cambiar la devolución por un tipo Dynamic. No entraré a si conviene usarlo o no, o si es mejor o peor a nivel de ejecución, pero partiendo de que vamos a devolver datos dependiendo de las necesidades que tengan los usuarios, un Dynamic no me parece una mala opción. Como dice alguién muy cercano a mí «siempre estamos decidiendo la opción menos mala», pero eso para otro momento 😉 El método quedaría de la siguiente forma:
public IList<dynamic> GetAll(ODataQueryOptions<Avenger> filter)
{
var avenger = (IEnumerable<dynamic>)filter.ApplyTo(avengerRepository.ListAll());
return avenger.ToList();
}
Resumen
OData es una opción bastante fácil de añadir en tus desarrollos, pero las balas de plata solo existen en las películas y como en todo, hay que saber cómo cuidar determinados aspectos del Perfomance de la base de datos y sobre todo, conocer todas las capacidades que tiene el lenguaje como las librerías cuandoe hacemos uso de ellas.
Happy Codding 🙂