Puntos de datos

Compilación previa de consultas LINQ

Julie Lerman

Descargar el ejemplo de código

Al usar LINQ to SQL o LINQ to Entities en sus aplicaciones, es importante considerar la compilación previa de cualquier consulta que cree y ejecute de forma repetida. Con frecuencia, he estado por completar una tarea específica y no he aprovechado las consultas compiladas previamente hasta que he avanzado demasiado en el proceso de desarrollo. Esto es muy similar a una “enfermedad de control de excepciones”, en que los desarrolladores intentan hacer calzar el control de excepciones en una aplicación después del hecho.

Sin embargo, incluso después de que haya implementado esta importante técnica de mejora de rendimiento, existe la posibilidad de que pierda el beneficio que ésta otorga. Es posible que observe que no se ha obtenido la mejora de rendimiento prometida, pero podría no saber cuál es el motivo (ni la corrección).

En esta columna, primero explicaré cómo compilar previamente las consultas y luego me centraré en el problema de perder los beneficios de compilación previa en las aplicaciones web, los servicios web y en otros escenarios. Aprenderá cómo asegurarse de que está obteniendo el beneficio en devoluciones (postbacks), operaciones de servicio breves y otros códigos en que instancias críticas quedan fuera del ámbito.

Compilación previa de consultas

El proceso de transformar su consulta LINQ en la consulta de almacén pertinente (por ejemplo, T-SQL que ejecuta la base de datos) es relativamente costoso dentro de la gama más grande de ejecución de consultas. En la figura 1 se muestra el proceso implicado que transforma una consulta LINQ to Entities en una consulta de almacén.


Figura 1 Transformación de una consulta LINQ en una consulta de almacén pertinente

La publicación en el blog del equipo de Entity Framework, “Exploración del rendimiento de ADO.NET Entity Framework: Parte 1” (blogs.msdn.com/adonet/archive/2008/02/04/exploring-the-performance-of-the-ado-net-entity-framework-part-1.aspx), desglosa el proceso y muestra el tiempo relativo que toma cada paso. Tenga en cuenta que esta publicación se basó en la versión Microsoft .NET Framework 3.5 SP1 de Entity Framework y la distribución de tiempo por paso probablemente ha cambiado en la nueva versión. No obstante, la compilación previa aún sigue siendo una parte costosa del proceso de ejecución de consultas.

Al compilar previamente su consulta, Entity Framework y LINQ to SQL pueden reutilizar la consulta de almacén y omitir el proceso redundante de hacer un análisis de ésta cada vez. Por ejemplo, si su aplicación con frecuencia recupera diferentes clientes desde el almacén de datos, podría tener una consulta como la siguiente:

Context.Customers.Where(c=>c.CustomerID==_custID)

Cuando nada cambia, salvo el parámetro _custID, de una ejecución a la siguiente, ¿por qué perder tiempo en transponer esto a un comando SQL una y otra vez?

LINQ to SQL y Entity Framework habilitan la compilación previa de consultas; sin embargo, debido a la existencia de algunas diferencias entre los procesos en los dos marcos, cada uno tiene su propia clase CompiledQuery. LINQ to SQL utiliza System.Data.LINQ.CompiledQuery mientras que Entity Framework utiliza System.Data. Objects.CompiledQuery. Ambas formas de CompiledQuery le permiten transmitir parámetros y ambas necesitan que usted transmita DataContext u ObjectContext en uso actualmente. En esencia, desde la perspectiva de la codificación, son iguales.

El método CompiledQuery.Compile devuelve un delegado en forma de Func que, a su vez, se puede invocar a petición.

La siguiente es una consulta simple compilada por la clase CompiledQuery de Entity Framework, que es estática y, por lo tanto, no necesita una creación de instancias:


    var _custByID = CompiledQuery.Compile<SalesEntities, int, Customer>
       ((ctx, id) =>ctx.Customers.Where(c=> c.ContactID == id).Single());

    Dim _custByID= CompiledQuery.Compile(Of SalesEntities, Integer, Customer) 
       (Function(ctx As ObjectContext, id As Integer) 
        ctx.Customers.Where(Function(c) c.CustomerID = custID).Single)

Puede utilizar métodos LINQ u operadores LINQ en la expresión de consulta. Estas consultas se crean con métodos y expresiones lambda LINQ.

La sintaxis es un poco más confusa que su método genérico típico, de modo que lo desglosaré. Una vez más, el objetivo del método Compile es crear un (delegado) Func que se pueda invocar posteriormente, como se muestra a continuación:


    CompiledQuery.Compile<SalesEntities, int, Customer>

    CompiledQuery.Compile(Of SalesEntities, Integer, Customer)

Puesto que es genérico, al método se le debe indicar qué tipos se han transmitido como argumentos, así como también qué tipo se devolverá cuando se invoque el delegado. Como mínimo, debe transmitir algún tipo de ObjectContext o DataContext para LINQ to SQL. Puede especificar System.Data.Objects.ObjectContext o algo que se derive de éste. En mi caso, explícitamente uso la clase derivada SalesEntities que está asociada a mi Entity Data Model.

También puede definir varios argumentos, que deben venir directamente después del contexto. En mi ejemplo, le indico a Compile que la consulta compilada previamente resultante también debe tomar un parámetro int/Integer. El último tipo describe qué devolverá la consulta; en mi caso, devolverá un objeto Customer:


    ((ctx, id) =>ctx.Customers.Where(c => c.ContactID == id).Single())

    Function(ctx As ObjectContext, id As Integer) 
       ctx.Customers.Where(Function(c) c.CustomerID = custID).Single

El resultado del método de compilación anterior es el siguiente delegado:


    private System.Func<SalesEntities, int, Customer> _custByID

    Private _custByID As System.Func(Of SalesEntities, Integer, Customer)

Una vez que la consulta se haya compilado, simplemente invóquela cada vez que desee ejecutar esa consulta, transmitiendo la instancia ObjectContext o DataContext y cualquier otro parámetro necesario. A continuación, tengo una instancia denominada _commonContext y una variable denominada _custID:

Customer cust  = _custByID.Invoke(_commonContext, _custID);

La primera vez que se invoca el delegado, la consulta se traduce a la consulta de almacén y esa traducción se almacena en caché para su reutilización en las llamadas posteriores a Invoke. LINQ puede omitir la tarea de compilar la consulta y realizar la ejecución directamente.

Garantía de que su consulta compilada previamente esté realmente en uso

No existe ningún problema tan obvio ni tan conocido con respecto a las consultas compiladas previamente. Varios desarrolladores suponen que la consulta se almacena en caché en el proceso de aplicación y que ésta permanecerá allí. Yo también supuse lo mismo, porque nada que haya encontrado indicaba lo contrario, salvo algunos números de rendimiento que no son imponentes. Sin embargo, cuando el objeto en que creó instancias de la consulta compilada queda fuera del ámbito, también pierde la consulta compilada previamente. Será necesario compilarla previamente de nuevo para cada uso, de modo que perdió completamente el beneficio de la compilación previa. En realidad, está pagando un precio mayor al que habría pagado si simplemente ejecutara una consulta LINQ, debido al esfuerzo adicional que CLR debe hacer con respecto al delegado.

Rico Mariani analiza en profundidad el costo de usar el delegado en su publicación en el blog, “Solución al cuestionario de rendimiento 13: costo de consulta compilada de Linq to SQL” (blogs.msdn.com/ricom/archive/2008/01/14/performance-quiz-13-linq-to-sql-compiled-query-cost-solution.aspx). El análisis en los comentarios es igualmente ilustrativo.

He visto informes en los blogs con respecto al “deficiente rendimiento” de LINQ to Entities en las aplicaciones web “incluso con consultas compiladas previamente”. El motivo de esto es que cada vez que una página hace devoluciones (postbacks), se obtiene contexto con instancias creadas recientemente y la consulta se vuelve a compilar previamente. La consulta compilada previamente no se vuelve a reutilizar. Experimentará el mismo problema cada vez que tenga contextos breves. Esto podría suceder en un lugar obvio, tal como un servicio web o un servicio de Windows Communication Foundation (WCF), o incluso en algo menos obvio, tal como un repositorio que creará instancias de un nuevo contexto sobre la marcha si se ha proporcionado una instancia.

Puede evitar la pérdida del delegado al usar una variable estática (Shared en VB) para retener la consulta durante los procesos e invocarla después mediante el uso de cualquier contexto que esté disponible actualmente.

Este es un modelo que he utilizado satisfactoriamente con las aplicaciones web, los servicios WCF y los repositorios, donde ObjectContext con frecuencia queda fuera del ámbito y deseo que el delegado se encuentre disponible durante el proceso de aplicación. Es necesario que declare un delegado estático en el constructor de la clase en que invocará consultas. A continuación, declaro un delegado que coincide con la consulta compilada que cree previamente:


    static Func<ObjectContext, int, Customer> _custByID;

    Shared _custByID As Func(Of ObjectContext, Integer, Customer)

Existen unos pocos lugares posibles para compilar la consulta. Puede hacerlo en un constructor de clase o inmediatamente antes de invocarla. Este es un método que está diseñado para realizar una consulta y devolver un objeto Customer:

public static Customer GetCustomer( int ID)
    {
      //test for an existing instance of the compiled query
      if (_custByID == null)
      {
        _custByID = CompiledQuery.Compile<SalesEntities, int, Customer>
         ((ctx, id) => ctx.Customers.Where(c => c.CustomerID == id).Single());
      }
      return _custByID.Invoke(_context, ID);
    }

Este método usará la consulta compilada. Primero, compilará la consulta sobre la marcha, pero sólo si es necesario, lo que determino mediante prueba para ver si ya se han creado instancias para la consulta. Si compila en su constructor de clase, será necesario que realice la misma prueba para asegurarse de que es el único usuario que utiliza los recursos para compilación, cuando sea necesario.

Puesto que el delegado _custByID es estático, permanecerá en memoria incluso cuando su clase contenedora quede fuera del ámbito. Por lo tanto, siempre que el proceso de aplicación mismo se encuentre dentro del ámbito, el delegado estará disponible; no será nulo y se omitirá el paso de compilación.

Consultas compiladas previamente y proyecciones

Existen algunos otros obstáculos que se deben tener en cuenta que son mucho más reconocibles. El primero gira en torno a proyecciones, pero no es específico al problema de volver a compilar de forma inadvertida su consulta compilada previamente. Cuando proyecte columnas en una consulta, en lugar de devolver tipos específicos, siempre obtendrá como resultado un tipo anónimo.

Al definir la consulta, es imposible especificar su tipo de devolución porque no existe ninguna manera de indicar “tipo de tipo anónimo”. Tendrá el mismo problema si desea tener la consulta dentro de un método que devuelve los resultados, porque no puede especificar qué devolverá el método. Los desarrolladores que usan LINQ con frecuencia se encuentran con esta última limitación.

Si se centra en el hecho de que un tipo anónimo es un tipo sobre la marcha que no tiene el propósito de ser reutilizado, estas limitaciones tienen sentido, aunque sea frustrante. Los tipos anónimos no están diseñados para ser transmitidos de un método a otro.

Lo que deberá hacer para su consulta compilada previamente es definir un tipo que coincida con la proyección. Tenga en cuenta que en Entity Framework debe usar una clase, y no una estructura, ya que LINQ to Entities no le permitirá proyectar en un tipo que no posea un constructor. LINQ to SQL permite que las estructuras sean el destino de una proyección. Por lo tanto, para Entity Framework, sólo puede usar una clase, pero para LINQ to SQL puede usar una clase o una estructura para evitar las limitaciones en torno a los tipos anónimos.

Consultas compiladas previamente y captura previa de LINQ to SQL

Otro problema potencial con las consultas compiladas previamente implica la captura previa, o carga diligente, pero el problema sólo surge con LINQ to SQL. En Entity Framework, se usa el método Include para carga diligente, lo que ocasiona que sólo se ejecute una consulta en la base de datos. Puesto que Include puede formar parte de una consulta, tal como context.Customer.Include(“Orders”), en este caso no constituye un problema. Sin embargo, con LINQ to SQL, la carga diligente está definida dentro de DataContext, y no en la consulta misma.

DataContext.LoadOptions tiene un método LoadWith que le permite especificar qué datos relacionados deben obtener carga diligente junto con una entidad específica.

Puede definir LoadWith para cargar Pedidos con cualquier Cliente que se consulte:

Context.LoadOptions.LoadWith<Customer>(c => c.Orders)

Luego, puede agregar una regla que indique cargar los detalles con cualquier pedido que se cargue:

Context.LoadOptions.LoadWith<Customer>(c => c.Orders)
Context.LoadOptions.LoadWith<Order>(o =>o.Details)

Puede definir LoadOptions directamente con su instancia DataContext o crear una clase DataLoadOptions, definir las reglas de LoadWith en este objeto y después adjuntarla a su contexto:

DataLoadOptions _myLoadOptions = new DataLoadOptions();
_myLoadOptions.LoadWith<Customer>(c => c.Orders)
Context.LoadOptions= myLoadOptions

Existen advertencias con respecto al uso general de LoadOptions y la clase DataLoadOptions. Por ejemplo, si define y luego adjunta DataLoadOptions, una vez que una consulta se haya ejecutado con respecto a DataContext, no puede adjuntar un nuevo conjunto de DataLoadOptions. Existen varios otros aspectos que puede aprender con respecto a las distintas opciones de carga y sus advertencias, pero analicemos un modelo básico para aplicar algunas LoadOptions a una consulta compilada previamente.

La clave del modelo es que puede predefinir DataLoadOptions sin asociarlas a un contexto específico.

En las declaraciones de clase en que declara las variables estáticas Func para contener las consultas compiladas previamente, declare una nueva variable DataLoadOptions. Es esencial que esta variable sea estática, de modo que también permanezca disponible junto con los delegados:

static DataLoadOptions Load_Customer_Orders_Details = new DataLoadOptions();

Luego en el método que compila e invoca la consulta, puede definir LoadOptions junto con el delegado (consulte la figura 2). Este método es válido en.NET Framework 3.5 y .NET Framework 4.

Figura 2 Definición de LoadOptions junto con un delegado

public Customer GetRandomCustomerWithOrders()
    {
      if (Load_Customer_Orders_Details == null)
      {
        Load_Customer_Orders_Details = new DataLoadOptions();
        Load_Customer_Orders_Details.LoadWith<Customer>(c => c.Orders);
        Load_Customer_Orders_Details.LoadWith<Order>(o => o.Details);
      }
      if (_context.LoadOptions == null)
      {
        _context.LoadOptions = Load_Customer_Orders_Details;
      }
      if (_CustWithOrders == null)
      {
        _CustWithOrders = CompiledQuery.Compile<DataClasses1DataContext, Customer>
               (ctx => ctx.Customers.Where(c => c.Orders.Any()).FirstOrDefault());
      }
      return _CustWithOrders.Invoke(_context);
    }

Puesto que DataLoadOptions son estáticas, se definen sólo cuando es necesario. Según la lógica de su clase, DataContext puede ser nuevo o no serlo. Si es un contexto que se va a reutilizar, se le asignará previamente LoadOptions. De otro modo, deberá asignarlas. Ahora, podrá invocar esta consulta de forma repetida y aún así obtendrá el beneficio de las capacidades de captura previa de LINQ to SQL.

Mantener la compilación previa al principio de la lista de comprobación

En el ámbito de la ejecución de consultas LINQ, la compilación de consultas es una parte costosa del proceso. En cualquier momento que agregue lógica de la consulta LINQ a su LINQ to SQL, o aplicaciones basadas en Entity Framework, debe considerar la posibilidad de compilar previamente las consultas y reutilizarlas. Sin embargo, no piense que esto termina aquí. Como ya ha observado, existen escenarios en que es posible que no obtenga beneficios de la consulta compilada previamente. Utilice algún tipo de generador de perfiles, tal como SQL Profiler o una de las herramientas de generación de perfiles de Hibernating Rhinos, que incluyen L2SProf (l2sprof.com/.com) y EFProf (efprof.com). Es posible que deba aprovechar algunos de los modelos que se muestran en este documento para asegurarse de obtener la ventaja que prometen las consultas compiladas previamente.

Danny Simmons, del equipo de Microsoft Entity Framework, explica cómo controlar las opciones de combinación al compilar previamente las consultas e indica algunas trampas adicionales que es necesario tener en cuenta, en su publicación en el blog blogs.msdn.com/dsimmons/archive/2010/01/12/ef-merge-options-and-compiled-queries.aspx.

 

Julie Lermanes MVP de Microsoft, profesora de .NET y consultora que vive en las colinas de Vermont. Puede encontrar su presentación sobre acceso a datos y otros temas de Microsoft .NET en grupos de usuarios y conferencias en todo el mundo. Lerman mantiene un blog en thedatafarm.com/blog y es la autora del célebre libro, “Programming Entity Framework” (O’Reilly Media, 2009). Puede seguir a Julie por Twitter en Twitter.com/julielermanvt.

*Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo:*Danny Simmons