Доступ к данным

Предкомпиляция LINQ-запросов

Джули Лерман

Загрузите код примера

Используя LINQ to SQL или LINQ to Entities в приложениях, стоит заранее подумать о предкомпиляции любых запросов, которые вы создаете и выполняете неоднократно. Я часто ловлю себя на том, что в решении какой-то задачи пренебрегла использованием предкомпилированных запросов, но осознаю это, когда уже довольно далеко продвигаюсь в процессе разработки. Все это сильно напоминает «синдром обработки исключений», когда разработчики пытаются втиснуть обработку исключений в готовое приложение.

Но даже в случае реализации этой важной для оптимизации возможности, есть немалая вероятность потерять ее преимущества. Вы можете заметить, что обещанный выигрыш в производительности отсутствует, но причина этого (и способ ее устранения) может ускользнуть от вас.

В этой статье я впервые расскажу о том, как предкомпилировать запросы и почему могут быть утрачены преимущества предкомпиляции в веб-приложениях, сервисах и других программах. Вы узнаете, как повысить производительность между обратными передачами, в скоротечных операциях сервисов и другом коде, где критически важные экземпляры выходят из области видимости.

Предкомпиляция запросов

Преобразование LINQ-запроса в релевантный для хранилища запрос (например, в T-SQL, выполняемый базой данных) — процесс относительно дорогостоящий. Рис. 1 иллюстрирует, как происходит преобразование запроса LINQ to Entities в запрос, воспринимаемый хранилищем.


Рис. Трансформация LINQ-запроса в релевантный запрос к хранилищу

В статье «Exploring the Performance of the ADO.NET Entity Framework — Part 1» в блоге группы Entity Framework (blogs.msdn.com/adonet/archive/2008/02/04/exploring-the-performance-of-the-ado-net-entity-framework-part-1.aspx) этот процесс детально разобран и показано относительное время, затрачиваемое на каждом этапе. Заметьте, что статья была написана по Entity Framework в версии Microsoft .NET Framework 3.5 SP1, и поэтапное распределение времени скорее всего изменилось в новой версии. Тем не менее предкомпиляция все равно остается дорогостоящей частью процесса выполнения запроса.

За счет предкомпиляции вашего запроса Entity Framework и LINQ to SQL могут повторно использовать запрос к хранилищу и пропускать лишний процесс разбора вашего запроса при каждой его выдаче. Например, если ваше приложение часто извлекает информацию о различных клиентах из базы данных, вы могли бы написать такой запрос:

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

Если при очередном выполнении запроса не меняется ничего, кроме параметра _custID, то зачем тратить время попусту, каждый раз преобразуя его в SQL-команду?

И LINQ to SQL, и Entity Framework поддерживают предкомпиляцию запросов; однако из-за некоторых различий этого процесса в каждой инфраструктуре имеется собственный класс CompiledQuery. LINQ to SQL использует System.Data.LINQ.CompiledQuery, а Entity Framework — System.Data.Objects.CompiledQuery. Обе формы CompiledQuery позволяют передавать параметры, и обе требуют передачи текущего DataContext или ObjectContext. По сути, с точки зрения кодирования, обе формы одинаковы.

Метод CompiledQuery.Compile возвращает делегат в виде Func, который в свою очередь может быть вызван по требованию.

Вот простой запрос, скомпилированный классом CompiledQuery в Entity Framework, который является статическим, а потому не требует создания экземпляров:

C#:

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

VB

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

В выражении запроса можно применять LINQ-методы или операторы. Эти запросы конструируются LINQ-методами и лямбдами.

Синтаксис немного запутаннее, чем в типичном обобщенном методе, поэтому я дам более подробные пояснения. И вновь цель метода Compile — создание Func (делегата), который можно вызвать позднее:

C#:

CompiledQuery.Compile<SalesEntities, int, Customer>

VB

CompiledQuery.Compile(Of SalesEntities, Integer, Customer)

Поскольку это обобщенный метод, ему нужно сообщить, какие типы передаются как аргументы и какой тип будет возвращаться после вызова делегата. Как минимум, вы должны передать некий тип ObjectContext или DataContext. Вы можете указать System.Data.Objects.ObjectContext или другой объект, наследующий от него. В данном случае я явно использую производный класс SalesEntities, сопоставленный с моей Entity Data Model.

Вы также можете определить несколько аргументов, которые должны быть расположены сразу за контекстом. В нашем примере я сообщаю Compile, что конечный предкомпилированный запрос должен принимать и параметр типа int/Integer. Он описывает, что будет возвращать запрос; в моем случае это объект Customer:

C#:

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

VB

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

Результат работы предыдущего метода Compile — следующий делегат:

C#:

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

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

Как только запрос скомпилирован, вы просто вызываете его всякий раз, когда вам нужно выполнить запрос, и передаете ему экземпляр ObjectContext или DataContext, а также любые другие необходимые параметры. Ниже я передаю экземпляр с именем _commonContext и переменную _custID:

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

При первом вызове делегата LINQ-запрос транслируется в запрос хранилища, и последний кешируется для повторного использования при последующих вызовах Invoke. LINQ пропускает компиляцию запроса и переходит непосредственно к его выполнению.

Как добиться реального использования предкомпилированных запросов

С предкомпилированными запросами есть одна неочевидная и малоизвестная проблема. Многие разработчики предполагают, что запрос кешируется в процессе приложения и хранится там постоянно. Я тоже так думала, потому что не нашла ничего, что указывало бы на иное, — кроме некоторые не особо впечатляющих показателей производительности. Однако, когда объект, где вы создали экземпляр скомпилированного запроса, выходит за область видимости, вы теряете и этот запрос. Его придется компилировать заново перед каждым использованием, и все преимущества предкомпиляции будут полностью утрачены. По сути, вы платите более высокую цену, чем при простом выполнении LINQ-запроса, — из-за некоторой дополнительной работы, которую приходится выполнять CLR для делегата.

Рико Мариани (Rico Mariani) покопался в издержках использования делегата в статье «Performance Quiz #13 — Linq to SQL compiled query cost — solution» (blogs.msdn.com/ricom/archive/2008/01/14/performance-quiz-13-linq-to-sql-compiled-query-cost-solution.aspx). Обсуждения в комментариях к статье не менее интересны.

Мне попадались в блогах упоминания о плохой работе LINQ to Entities в веб-приложениях даже при перекомпиляции запросов. Причина такой производительности в том, что при каждой обратной передаче страницы вы получаете заново созданный экземпляр контекста и заново предкомпилированный запрос. То есть предкомпилированный запрос на самом деле никогда не используется повторно. Вы получите ту же проблему везде, где вы имеете дело с короткоживущими контекстами. Это может произойти в очевидном месте, например в веб- или WCF-сервисе (Windows Communication Foundation), или даже в менее очевидном, таком как репозитарий, который будет создавать новый экземпляр контекста «на лету», если таковой экземпляр ему не предоставлен.

Вы можете избежать потери делегата, используя статическую (или общую в VB) переменную, которая сохраняет запрос между операциями, а затем вызывать ее, применяя любой доступный в данный момент контекст.

Вот шаблон, который я успешно применяла в веб-приложениях, WCF-сервисах и репозитариях, где ObjectContext часто выходит из области видимости, а мне нужно, чтобы делегат был доступен в рамках всего процесса приложения. Вы должны объявить статический делегат в конструкторе того класса, где вы будете запускать запросы. Ниже я объявляю делегат, совпадающий с ранее созданным компилированным запросом:

C#:

static Func<ObjectContext, int, Customer> _custByID;

VB

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

Компилировать запрос можно в нескольких местах. Вы можете делать это в конструкторе или перед самым его запуском. Вот метод, который выполняет запрос и возвращает объект 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);
    }

Этот метод будет использовать скомпилированный запрос. Сначала он «на лету» скомпилирует запрос, но только если это необходимо, что определяется проверкой на наличие уже существующего экземпляра запроса. Если вы выполняете компиляцию в конструкторе класса, вам понадобится делать ту же проверку, чтобы использовать ресурсы для компиляции только при необходимости.

Поскольку делегат _custByID является статическим, он остается в памяти, даже когда содержащий его класс выходит из области видимости. Следовательно, пока процесс самого приложения не выйдет из области видимости, этот делегат будет доступен, и этап компиляции не понадобится.

Предкомпилированные запросы и проекции

Есть и другие подводные камни, о которых следует знать, но они обнаруживаются гораздо легче. Первый подвох связан с проекциями, но он не является специфичным для проблемы случайной перекомпиляции предкомпилированного запроса. Используя проекции столбцов в запросе, вы всегда будете получать результат в виде анонимного типа, а не специфического типа.

При определении запроса указать возвращаемый им тип невозможно, потому что нельзя сказать нечто вроде «тип анонимного типа». Та же проблема возникнет, если вам понадобится запрос внутри метода, который возвращает результаты, так как вы не можете указать, что будет возвращать этот метод. Разработчики, использующие LINQ, в последнее время все чаще сталкиваются с этим ограничением.

Но, если вы вдумаетесь в тот факт, что анонимный тип является типом, генерируемым «на лету», и не предназначен для повторного использования, то признаете, что эти ограничения, как бы они ни были огорчительны, имеют смысл. Анонимные типы не рассчитаны на передачу между методами.

Что вам потребуется сделать для своего предкомпилированного запроса, так это определить тип, совпадающий с проекцией. Заметьте, что в Entity Framework нужно использовать класс, а не структуру struct, так как LINQ to Entities не позволит вам создавать проекции на тип без конструктора. Однако LINQ to SQL поддерживает структуры struct как мишени проекции. Так что в случае Entity Framework вы можете использовать только класс, но в случае LINQ to SQL можно применять либо класс, либо структуру struct, чтобы избежать ограничений, связанных с анонимными типами.

Предкомпиляция запросов и механизм предвыборки в LINQ to SQL

Другая потенциальная проблема с предкомпилированными запросами связана с предвыборкой (prefetching), но она существует только в LINQ to SQL. В Entity Framework для предвыборки применяется метод Include, который приводит к выполнению единственного запроса в базе данных. Поскольку Include может быть частью запроса, например context.Customer.Include(“Orders”), никаких проблем нет. Однако в LINQ to SQL предвыборка определяется внутри DataContext, а не в самом запросе.

DataContext.LoadOptions содержит метод LoadWith, позволяющий указывать, какие релевантные данные должны быть загружены попутно с конкретной сущностью.

Вы можете определить LoadWith так, чтобы он загружал Orders с любым запрошенным Customer:

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

Затем можно добавить правило, указывающее загружать сведения о позициях заказов вместе с извлекаемыми заказами:

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

Вы можете определить LoadOptions непосредственно по вашему экземпляру DataContext или создать класс DataLoadOptions, определить в этом объекте правила LoadWith, а затем присоединить его к контексту:

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

Есть подводные камни и в использовании LoadOptions и класса DataLoadOptions. Например, если вы определяете, а затем присоединяете DataLoadOptions, то, как только запрос выполнен применительно к DataContext, вы не можете подключить новый набор DataLoadOptions. Различных вариантов загрузки и связанных с ними подвохов гораздо больше, но давайте рассмотрим основной шаблон применения некоторых LoadOptions к предкомпилированному запросу.

Ключевой момент в этом шаблоне — вы можете предопределить DataLoadOptions без их сопоставления с конкретным контекстом.

В классе в том месте, где объявляются статические переменные Func, которые будут содержать предкомпилированные запросы, объявляется и новая переменная DataLoadOptions. Ее тоже крайне важно сделать статической, чтобы она была доступна вместе с делегатами:

static DataLoadOptions Load_Customer_Orders_Details = new DataLoadOptions();

Затем в методе, который компилирует и запускает запрос, можно определить LoadOptions и делегат (рис. 2). Этот метод рассчитан на .NET Framework версий 3.5 и 4.

Рис. Определение LoadOptions вместе с делегатом

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);
    }

Поскольку DataLoadOptions статический, он определяется только при необходимости. В зависимости от логики вашего класса DataContext может быть новым экземпляром (а может и не быть). Если это повторно используемый контекст, тогда у него уже есть ранее назначенный LoadOptions. В ином случае вы должны будете назначить его. Тогда вы сможете запускать этот запрос повторно и все равно использовать преимущества механизма предвыборки LINQ to SQL.

Включайте предкомпиляцию в начало списка своих задач

В выполнении LINQ-запроса наиболее дорогостоящей частью является его компиляция. Всякий раз, когда вы добавляете логику LINQ-запросов в свои приложения на основе LINQ to SQL или Entity Framework, рассматривайте возможность предкомпиляции запросов и их повторного использования. Но не думайте, что на этом все заканчивается. Как вы убедились, в ряде ситуаций вы можете не получить выигрыш от предкомпилированного запроса. Поэтому применяйте какое-либо средство профилирования вроде SQL Profiler или одной из утилит от Hibernating Rhinos, в число которых входят L2SProf (l2sprof.com/.com) и EFProf (efprof.com). Не исключено, что вам придется применить один из показанных здесь шаблонов, чтобы выжать максимум из предкомпилированных запросов.

Как управлять параметрами объединения (merge options) при предкомпиляции запросов, поясняет Дэнни Симмонс (Danny Simmons) из группы Microsoft Entity Framework в своей статье в блоге по ссылке blogs.msdn.com/dsimmons/archive/2010/01/12/ef-merge-options-and-compiled-queries.aspx; там же он перечисляет некоторые дополнительные проблемы, которых следует остерегаться.

 

Джули Лерман (Julie Lerman) — Microsoft MVP, преподаватель и консультант по .NET, живет в Вермонте. Часто выступает на конференциях по всему миру и в группах пользователей по тематике, связанной с доступом к данным и другими технологиями Microsoft .NET. Ведет блог thedatafarm.com/blog, является автором очень популярной книги «Programming Entity Framework» (O’Reilly Media, 2009). Вы также можете встретить ее на Twitter (Twitter.com/julielermanvt).

Выражаю благодарность за рецензирование статьи эксперту Дэнни Симмонсу (Danny Simmons).