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, 26 de febrero de 2019
Entity Framework Core Sin duda, Entity Framework Core es un gran marco de trabajo para implementar el acceso a datos de nuestras aplicaciones, pues es rápido, potente y nos ahorra una gran cantidad de esfuerzo, sobre todo en proyectos muy centrados en datos.

Sin embargo, estas ayudas tienen un coste importante, al igual que ocurre con casi cualquier framework al que vayamos a confiar parte de nuestro sistema, sea del ámbito que sea: hay que conocerlo bien para no dispararse en un pie en cuanto apretemos el gatillo.

Hace muuuuchos, muchos años hablamos de cómo aplicar convenientemente la carga anticipada (eager loading) en Entity Framework podía acelerar de forma drástica el rendimiento en el acceso a datos de nuestras aplicaciones, evitando el temido problema denominado "SELECT N+1". Esto continúa siendo válido para la versión Core de este marco de trabajo: sigue existiendo el extensor Include() e incluso variantes de éste como ThenInclude() que permiten incluir en una única consulta las entidades que sabemos que vamos a necesitar.

Pues bien, en EF Core hay otro detalle que, si bien utilizándolo con conocimiento puede resultar muy interesante, es fácil que nos tumbe el rendimiento de nuestros accesos a datos si no lo usamos con cuidado: la evaluación en cliente.

La evaluación en cliente de Entity Framework Core

A la hora de construir queries a una base de datos usando LINQ o expresiones de consulta, lo habitual es que las expresiones que especificamos en ella sean trasladadas a SQL en el momento de ejecutarlas.

Por ejemplo, es esperable que la siguiente consulta en C#, que incluye tanto criterios de búsqueda como proyecciones, podría ser ejecutada en SQL Server como se incluye más abajo:
// "ctx" es un contexto EF
var johns = ctx.Friends
    .Where(f => f.Name.StartsWith("John"))
    .Select(f => f.Name).ToList();
SELECT [f].[Name]
    FROM [Friends] AS [f]
    WHERE [f].[Name] LIKE 'John%' AND (LEFT([f].[Name], LEN('John')) = 'John')
Esto es lo que llamamos evaluación en servidor. El proveedor de EF Core que estemos usando traducirá los árboles de expresión especificados en la consulta al lenguaje utilizado por el motor de datos (SQL Server, SQLite, Oracle...) y será dicho motor el que realmente evaluará las expresiones para filtrar datos o crear las proyecciones solicitadas.

Por tanto, de alguna forma estamos aceptando implícitamente que cualquier tipo de expresión podría ser traducida al lenguaje de consulta del motor de datos, lo cual no tiene por qué ser cierto.
Veamos la siguiente consulta:
// Consulta:
public List<Friend> GetAdults()
{
    var adults = ctx.Friends.Where(f => IsAdult(f));
    return adults.ToList();
}

private bool IsAdult(Friend f)
{
    f.Birthdate < DateTime.Now.AddYears(-18);
}
En este caso, está claro que la expresión IsAdult() no podría ser traducida a SQL ni ningún motor de datos porque se trata de código ejecutable que reside en la aplicación.

En Entity Framework "clásico", una consulta como la anterior daría lugar a un error en tiempo de ejecución indicándonos que el proveedor de EF no sabe traducir dicha expresión al lenguaje usado por el almacén.

Y aquí es donde aparece la evaluación en cliente de Entity Framework Core. Si observamos la sentencia SQL enviada al proveedor al ejecutar la consulta, veríamos algo como lo siguiente:
SELECT [f].[Id], [f].[Birthdate], [f].[Name]
    FROM [Friends] AS [f]
Efectivamente, ¡le estamos solicitando todos las filas de la tabla Friends! Por cada fila obtenida, se creará un objeto Friend y se evaluará el predicado condición IsAdult(), descartándose si no cumple la condición. Pero eso sí, la materialización ya se habrá realizado y habremos utilizado memoria y tiempo en crear un objeto que al final no va a ser utilizado.
Es fácil imaginar la repercusión que una consulta de este tipo podría tener en una tabla con millones de filas, cuando en realidad lo único que estamos haciendo es filtrar por fecha de nacimiento.
Bien es cierto que en la consulta anterior se veía muy claramente que la expresión no puede ser traducida a SQL, pero, ¿qué me decís de la siguiente?
var adults = ctx.Friends.Where(f => f.Name.IndexOf('J')==0);
Aunque IndexOf() es un método muy habitual para trabajar con cadenas en .NET, tampoco el proveedor de SQL Server lo soporta, por lo que, de nuevo, la sentencia SQL enviada al motor obtendrá todas las filas de la base de datos e IndexOf() será evaluado en cliente.

Y peor incluso es el hecho de que esto podría variar de un proveedor a otro. Por ejemplo, podría ser que el método IndexOf() estuviera implementado por el proveedor de SQLite pero no por el de SQL Server o viceversa, lo que haría que su uso pudiera producir resultados indeseados dependiendo del motor utilizado al ejecutar la aplicación.
A veces no es fácil prever si una construcción será evaluada en cliente o en servidor.
¿Qué podemos hacer?

Primero: detectar dónde están los problemas

Cuando se produce la evaluación en cliente, Entity Framework Core registrará un warning en el log. El problema es que por defecto no tendremos forma de verlo, salvo que configuremos apropiadamente el logging.

En Entity Framework Core 2.1, esto podríamos conseguirlo fácilmente insertando con un código como el siguiente dentro del contexto de datos:
public class FriendsContext : DbContext
{
    private static readonly ILoggerFactory MyLoggerFactory = GetLoggerFactory();

    private static ILoggerFactory GetLoggerFactory()
    {
        return new LoggerFactory(new[]
        {
            new ConsoleLoggerProvider(
                (category, level) =>
                    category == DbLoggerCategory.Query.Name && 
                    level == LogLevel.Warning
                , true)
        });
    }

    protected override void OnConfiguring(DbContextOptionsBuilder builder)
    {
        builder.UseLoggerFactory(GetLoggerFactory());
        builder.UseSqlServer(@"...");
    }
    ...
}
A partir de EF Core 2.2 esta fórmula ha cambiado y ya no está recomendada, porque esa forma de construir ConsoleLoggerProvider se ha marcado como obsoleta. Esto podemos solucionarlo simplemente modificando el método GetLoggerFactory() visto anteriormente:
// Recommended implementation for EF Core 2.2:
private static ILoggerFactory GetLoggerFactory()
{
    IServiceCollection serviceCollection = new ServiceCollection();
    serviceCollection.AddLogging(builder =>
        builder.AddConsole()
            .AddFilter((category, level)=> 
                category == DbLoggerCategory.Query.Name && level == LogLevel.Warning
            )
        ); 
    return serviceCollection.BuildServiceProvider()
        .GetService<ILoggerFactory>();
}
En ambos, casos, al ejecutar la consulta que incluye evaluación en cliente podremos observar la siguiente salida por consola, por ejemplo:
Query:
======
    var adults = ctx.Friends.Where(f => f.Name.IndexOf('J')==0);

Salida por consola:
===================
    warn: Microsoft.EntityFrameworkCore.Query[20500]
        => Microsoft.EntityFrameworkCore.Query.RelationalQueryModelVisitor
        The LINQ expression 'where ([f].Name.IndexOf(J) == 0)' could not be translated
        and will e evaluated locally.

Segundo: desactivar la evaluación en cliente

Si el punto anterior no nos convence, o no lo vemos muy práctico porque no vamos a mirar muy a menudo los logs, podemos recurrir a soluciones más violentas ;-)

Tenemos una forma sencilla de hacer que la aplicación explote en tiempo de ejecución cuando este caso ocurra, indicando en el método OnConfiguring() del contexto de datos que cuando se produzca un warning queremos lanzarlo como excepción cuando se trate de una evaluación en cliente:
protected override void OnConfiguring(DbContextOptionsBuilder builder)
{
    builder.ConfigureWarnings(
        warning => warning.Throw(RelationalEventId.QueryClientEvaluationWarning));
    ...
}    
Hecho esto, cualquier intento de evaluación en cliente de una expresión lanzará una excepción InvalidOperationException, y será más fácil que nos demos cuenta de que tenemos que revisar la consulta:
Unhandled Exception: System.InvalidOperationException: 
Error generated for warning 'Microsoft.EntityFrameworkCore.Query.QueryClientEvaluationWarning: 
The LINQ expression 'where ([f].Name.IndexOf(J) == 0)' could not be translated and will be 
evaluated locally.'. This exception can be suppressed or logged by passing event 
ID 'RelationalEventId.QueryClientEvaluationWarning' to the 'ConfigureWarnings' 
method in 'DbContext.OnConfiguring' or 'AddDbContext'.

Excepción InvalidOperationException al evaluar el cliente

Tercero: modificar las queries para eliminar la evaluación en cliente

Una vez somos conscientes de que estamos ante una consulta que provoca evaluación en cliente, llega el momento de solucionar el problema.

A veces la cosa puede ser tan sencilla como reformular la consulta para que las funciones utilizadas sean traducibles en el servidor de datos. Por ejemplo, ya hemos visto que IndexOf() no puede utilizarse en SQL Server, sin embargo podríamos conseguir lo mismo usando StartsWith(), que sí está soportado:
// Original query (with client evaluation):
var adults = ctx.Friends.Where(f => f.Name.IndexOf('J')==0);

// Modified query (without client evaluation):
var adults = ctx.Friends.Where(f => f.Name.StartsWith("J"));
Otra posibilidad es intentar utilizar las funciones disponibles en EF.Functions. Se trata de extensiones que representan funciones disponibles en el motor de datos y que pueden ser introducidas con toda tranquilidad en las queries LINQ porque serán traducidas a SQL correctamente.

Por ejemplo, la siguiente consulta utiliza LIKE para buscar personas cuyo nombre cumpla un determinado patrón de texto:
var people = ctx.Friends.Where(f => EF.Functions.Like(f.Name, "%P_r%"));
// --> Peter Parker, Alan Parson, Perla Romero...
Si ninguna de las soluciones anteriores son posibles, probablemente tendríamos que optar por utilizar vistas o procedimientos almacenados en el servidor, pero eso ya es otra historia... ;)

Espero que os sea de utilidad :)

5 Comentarios:

MontyCLT dijo...

Muy buen artículo, es algo importante a tener en cuenta.

¿Qué sabemos acerca de este problema en NHibernate cuando se usa Linq to SQL?

José María Aguilar dijo...

Hola!

Uf, pues no sabría decirte, hace años que no uso NHibernate...

A ver si alguien que controle el tema puede aportar algo :)

Gracias por comentar!

Unknown dijo...

Hola!

Curiosidad:
Para que añade el "AND (LEFT([f].[Name], LEN('John')) = 'John')" en la consulta:

SELECT [f].[Name]
FROM [Friends] AS [f]
WHERE [f].[Name] LIKE 'John%' AND (LEFT([f].[Name], LEN('John')) = 'John')

José María Aguilar dijo...

Hola!

La verdad es que también me llamó la atención. Al parecer, es una particularidad en la implementación del proveedor para SQL Server, que pretende evitar resultados incorrectos en casos en los que el criterio de búsqueda no sea constante y contenga comodines:

https://github.com/aspnet/EntityFrameworkCore/issues/14657#issuecomment-462366020

De hecho, acabo de ver que ya hay un pull request de hace unas horas elimina la parte del LEN() cuando no es estrictamente necesario.

Saludos & gracias por comentar!



Jambonet dijo...

Debería ser como ServiceStack.OrmLite