OData y como beneficiar nuestro perfomance con Entity Framework

A la hora de hacer las consultas sobre nuestras WebApi OData es un estandar propio de Microsoft que estan adoptando la mayoria 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 para realizar las consultas a sus objetos de négocio. Como desarrolladores muchas veces no ponemos OData por el miedo al performance que ocasiona en el Backend. ¿Como lo podemos solucionar? En este articulo vemos como cuidar las consultas para de esta forma 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 oido nada sobre OData y como empezar a utilizarlo en los desarrollos en .NET ir a leerlo y luego continuar por este punto :)

Una vez nos hemos leido el artículo anterior, sabemos lo que es OData que es un estándar que define un conjunto de buenas practicas para la construcción y consumo de RESTFul API. De todo lo que tiene Odata, centremonos en la parte para realizar consultas. Que levante la mano en cuantos proyectos en las llamadas para hacer filtros nos creamos un objeto para indicar sobre que campos, valores filtramos… No digamos que es una tarea sencilla hacer eso, ¿pero a cuantos nos ha pasado que una vez instalado surge un nuevo requerimiento nuevo, no podríamos filtrar por este u otro campo y eso principalmente que implica a nivel de desarrollo? Pues en primer lugar lo que implica es 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 nos implica dedicar un tiempo que puede ser valioso.

Otra de las ventajas que tiene OData respecto a nuestro desarrollo “Custom” es que es un Stándar. Esto hace que cualquier developer (ya sea de Front/Back) pueda entender como funciona y poder realizar las peticiones a la API que necesitan en su desarrollo.

Ahora bien, este último punto, tambien puede ser un inconveniente dependiendo de la madurez del equipo de desarrollo. Pensar un supuesto en el que solamente se cree un unico controlador con OData y se permita realizar cualquier filtro y consulta sobre dicha entidad. Esto puede hacer que para establecer determinada logica de negocio, en lugar de implementar un método en el controlador con dicha lógica se indique que se haga esta logica implementando filtros con OData. Para mi este supuesto es un mal uso de OData, si hemos dicho que nos ahorra tiempo de implementación, pero esto no implica que en cada entidad tengamos que indicar sobre que campos queremos filtrar y sobre todo que queremos mostrar. Pensar un caso que tengamos una lista de clientes y tengamos 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.

Como incorporar OData

Vamos a partir del que tenemos una API de Avengers en primer lugar vamos a configurar a 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 indicaremos que vamos a hacer uso de OData para ello hay que añadir la siguientes lineas:

   services.AddOData();
   services.AddODataQueryFilter();
   services.AddMvc(options =>
            {
                options.EnableEndpointRouting = false;
            });

Ahora dentro del método Configure del mismo StartUp susituiremos 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 realizan es:

    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 es cambiar el ControllerBase por el ODataController quedando nuestro Controller de la siguiente forma:

 public class AvengerController : ODataController
 {
     ...
 }

Luego en lo que vamos a realizar es añadir unos decoradores al método donde vamos a usar OData, imaginar 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 items que tenemos en Avengers. request normal

Ahora si usamos la sintasis de OData para filtrar si tenemos un Avenger que se llama Hulk por ejemplo hacemos localhost/api/avenger?filter=$name eq ‘Hulk’

request odata

Cuál es el problema?

A nivel de la representación de los datos vemos que la devolución es la correcta y tal y como toca, pero ahora bien si analizamos las tripas y ponemos un punto de parada antes de que devolvamos los resultados veremos lo siguiente:

resultado odata

Como podéis ver a pesar de que solamente hemos pedido un único registro se esta trayendo toda la información de la tabla y luego ya se encarga de hacer filtrar los datos que he pedido. Para mi esta opción no es la más optima ya que imaginar 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 luego tampoco tiene sentido hacer los filtros en el servidor cuando lo puede/debe de hacer la base de datos.

Como se puede solucionar con OData?

En primer lugar, nos hemos olvidado de un parametro que se puede pasar en el controlador donde podemos tener la consulta que se ha realiza desde la petición, es decir, tenemos un objeto donde podemos obtener la consulta que se hace y como poder utilizarla. Este objeto es ODataQueryOptions filter lo añadamos como parametro en nuestro controlador y volvemos a ejecutar la consulta y analicemos el objeto:

resultado odata

Si vemos y analizamos todo el contenido que tiene ese objeto, vemos por un lado que tiene en las propiedades cada uno de los elementos que contiene el Filter, Top, Count, etc.. y tiene un método ApplyTo que hace el método ApplyTo tiene como parametro 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 la que tenemos unos métodos que directamente nos devuelve un método IList.

Pudiendo ser un ejemplo de Patron repositorio 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 entres las diferentes 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 has 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 esta claro que al Repositorio Base que hemos planteado le falta unos métodos IQueryable principalmente porque en el momento en el que lancemos una .ToList() es el momento en el que se va a ejecutar la consulta. Y esta claro que no es lo mismo hacer una consulta con todos los items de la tabla que hacer una consulta con solamente la información que se necesita. Por esto 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 por ejemplo como la consulta ahora es tal y como la hemos pedido en la API, optimizando la consulta:

consulta insight

Alguna cosa más?

Esto no es todo, si miráis un poco todas las posibilidades que trae OData nos da la posibilidad de traernos solo los datos que realmente necesistamos 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 nos 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: error Odata

Como lo 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 lo siguiente una vez se nos pasa el cabreo por el error seria ver como poder solucionarlo. ¿Se puede solucionar? Si … y podemos cambiar la devolución por un tipo Dynamic. No entraré en este artículo 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 mi 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 pode añadir en tus desarrollos, pero no existen balas de platas y como en todo hay que saber como poder cuidar determinados aspectos del Perfomance de la base de datos y sobre todo el conocer todas las capacidades que tiene el lenguaje como las librerias que hacemos uso de ellas.

Happy Codding :)