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

Рекомендации по базовому классу протоколирования в EF-моделях

Джули Лерман

 

Julie LermanНедавно я работала с клиентом, у которого проявлялись редкие, но серьезные проблемы с производительностью при использовании кода на основе Entity Framework. С помощью утилиты для профилирования запросов, генерируемых Entity Framework, мы обнаружили, что к базе данных обращается SQL-запрос, состоящий из 5800 строк. (О средствах профилирования я писала в своей статье «Profiling Database Activity in the Entity Framework» за декабрь 2010 г.; см. msdn.microsoft.com/magazine/gg490349.) Я сильно удивилась, увидев, что модель EDMX содержит ту иерархию наследованию, которую следует избегать. В модели была единственная базовая сущность, от которой наследовались все остальные сущности. Базовая сущность использовалась для того, чтобы быть уверенным в том, что у каждой сущности есть свойства для отслеживания данных протоколирования (logging data), такие как DateCreated и DateLastModified. Поскольку эта модель была создана с применением подхода Model First, инфраструктура Entity Framework интерпретировала наследование по модели Table per Type (TPT), где каждая сущность сопоставляется с собственной таблицей в базе данных. Для непосвященных это выглядит достаточно невинно.

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

Кошмарная модель

На рис. 1 приведен пример модели, которую я вижу слишком часто. Обратите внимание на сущность TheBaseType. Все остальные сущности производны от нее, чтобы автоматически наследовать свойство DateCreated. Я понимаю, насколько велико искушение спроектировать модель в таком виде, но правила схемы Entity Framework также требуют, чтобы базовому типу принадлежало свойство ключа каждой производной сущности. Для меня это уже красный флаг, сигнализирующий о том, что такое использование наследования не годится.

Все классы наследуют от TheBaseType
Рис. 1. Все классы наследуют от TheBaseType

Дело не в том, что Entity Framework будто специально создает проблему с этим проектом; здесь промах в проектировании самой модели. В данном случае наследование указывает, что Customer является TheBaseType. Что было бы, если бы мы сменили имя этой базовой сущности на LoggingInfo, а затем повторили утверждение, что Customer — это LoggingInfo? Ошибка этого утверждения становится более очевидной при присваивании классу нового имени. Сравните это с тем, что Customer является Person. Возможно, теперь я убедила вас избегать подобного в своих моделях. А если нет или если вы уже привязаны к такой модели, давайте обсудим эту тему поглубже.

По умолчанию в рабочем процессе Model First определяется схема базы данных с отношениями «один к одному» между базовой таблицей и всеми остальными таблицами, представляющими производные типы. Это та самая TPT-иерархия, о которой упоминалось ранее.

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

Например, этот запрос LINQ to Entities извлекает свойство DateCreated для конкретного объекта Customer:

context.TheBaseTypes.OfType<Customer>()
  .Where(b => b.Id == 3)
  .Select(c => c.DateCreated)
  .FirstOrDefault();

Этот запрос приводит к выполнению в базе данных следующего T-SQL:

SELECT TOP (1)
[Extent1].[DateCreated] AS [DateCreated]
FROM  [dbo].[TheBaseTypes] AS [Extent1]
INNER JOIN [dbo].[TheBaseTypes_Customer] 
AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]
WHERE 3 = [Extent1].[Id]

Довольно хороший запрос.

Запрос на извлечение всей сущности более громоздкий, так как приходится выполнять вложенный запрос. Базовый запрос извлекает все поля, представляющие объединение (join) таблицы TheBaseTypes и таблицы, содержащей производный тип. Затем запрос по этим результатам проецирует поля, которые должны возвращаться в Entity Framework для заполнения этого типа. Например, вот запрос, извлекающий один Product:

context.TheBaseTypes.OfType<Product>().FirstOrDefault();

На рис. 2 показан T-SQL, выполняемый на сервере.

Рис. 2. Фрагмент кода вложенного запроса на T-SQL при указании какого-либо типа

SELECT
[Limit1].[Id] AS [Id],
[Limit1].[C1] AS [C1],
[Limit1].[DateCreated] AS [DateCreated],
[Limit1].[ProductID] AS [ProductID],
[Limit1].[Name] AS [Name],
[...прочие поля, необходимые для класса Product...]
FROM ( SELECT TOP (1)
      [Extent1].[Id] AS [Id],
      [Extent1].[DateCreated] AS [DateCreated],
      [Extent2].[ProductID] AS [ProductID],
      [Extent2].[Name] AS [Name],
      [Extent2].[ProductNumber] AS [ProductNumber],
      [...прочие поля из таблицы Products (aka "Extent2")...],
      [Extent2].[ProductPhoto_Id] AS [ProductPhoto_Id],
      '0X0X' AS [C1]
      FROM  [dbo].[TheBaseTypes] AS [Extent1]
      INNER JOIN [dbo].[TheBaseTypes_Product] 
      AS [Extent2] ON [Extent1].[Id] = [Extent2].[Id]
)  AS [Limit1]

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

А как насчет следующего «простого» запроса, который ищет все созданные сегодня объекты независимо от типа? Этот запрос мог бы возвращать Customers, Products, Orders, Employees или любой другой тип в вашей модели, наследующий от базового. Благодаря дизайну модели это вроде приемлемый запрос, и эта модель в сочетании с LINQ to Entities упрощает его выражение (DateCreated хранится в базе данных как тип date, поэтому в моих примерах запросов я не забочусь о сравнении с полями DateTime):

var today= DateTime.Now.Date;
context.TheBaseTypes
  .Where(b => b.DateCreated == today)  .ToList();

Выражение этого запроса в LINQ to Entities лаконично и красиво. Но не обманывайтесь. Это тяжелый запрос. Вы просите EF и базу данных вернуть экземпляры любого типа (будь то Customer, Product или Employee), созданные сегодня. Entity Framework придется начать с опроса каждой таблицы, сопоставленной с производными сущностями, и объединения каждой из них с единственной связанной таблицей TheBaseTypes с полем DateCreated. В моей среде это приводит к генерации запроса длиной в 3200 строк (после форматирования EFProfiler), что потребует от Entity Framework некоторого времени на его формирование, а от базы данных — на выполнение.

Как показывает мой опыт, запрос такого рода в любом случае относится к какому-либо средству для бизнес-анализа. А если у вас есть модель и вы хотите получать эту информацию из своего приложения, возможно, для целей отчетности? Мне доводилось видеть, как разработчики пытаются выдавать этот тип запроса в своих приложениях, и я по-прежнему утверждаю, что в таких случаях вы должны мыслить вне рамок Entity Framework. Встраивайте соответствующую логику в базу данных как представление или хранимую процедуру и вызывайте ее из Entity Framework вместо того, чтобы просить EF формировать запрос за вас. Даже если эта логика будет оформлена как процедура в базе данных, создать ее не так-то просто. Но преимущества есть. Во-первых, у вас появится больше шансов на создание более эффективно работающего запроса. Во-вторых, EF не придется тратить время на разбор запроса. В-третьих, вашему приложению не придется передавать по каналу запрос с 3300 (или более!) строк. Но я вас предупредила: чем больше вы будете закапываться в эту проблему и пытаться решить ее внутри базы данных или применением EF и .NET-логики, тем яснее будет, что проблема связана не столько с Entity Framework, сколько с общим дизайном модели.

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

context.TheBaseTypes.TypeOf<Product>()
  .Where(b => b.DateCreated == today)
  .ToList();

Поскольку EF не придется подготавливать его для каждого типа в модели, конечный T-SQL уложится всего в 25 строк. С помощью DbContext API вам даже не потребуется использовать TypeOf для запроса производных типов. Можно создать DbSet-свойства для производных типов. Поэтому я могла бы написать еще более простой запрос:

context.Products
  .Where(b => b.DateCreated == today)
  .ToList();

По сути, для этой модели я бы полностью исключила TheBaseTypes DbSet из своего класса контекста и не давала бы никому выражать запросы непосредственно из этого базового типа.

Протоколирование без кошмарной модели

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

Наша цель заключается в предоставлении часто отслеживаемых данных, таких как данных протоколирования, для каждого типа в модели. Как же эффективнее добиться этой цели?

Зачастую первое, что приходит в голову, — сохранить наследование, но изменить тип иерархии. При использовании Model First вариант TPT предлагается по умолчанию, но вы можете сменить его на Table Per Hierarchy (TPH) с помощью Entity Designer Generation Power Pack (доступен в Visual Studio Gallery через Extension Manager). В Code First по умолчанию выбирается TPH, когда вы определяете наследование в своих классах. Но вы быстро поймете, что это никак не решает проблему. Почему? TPH подразумевает, что вся иерархия содержится в одной таблице. Иначе говоря, ваша база данных должна состоять всего из одной таблицы. Надеюсь, что больше ничего пояснять не нужно, чтобы убедить вас в порочности такого пути.

Как я уже говорила (запрашивая, действительно ли Customer имеет тип LoggingInfo), данный конкретный сценарий, где нужно решить задачу отслеживания общих данных, требует, чтобы вы просто избегали наследования. Я рекомендую подумать об интерфейсе или составных типах, которые будут встраивать поля в каждую таблицу. Если вы уже имеете дело с базой данных, в которой создана отдельная таблица, вам поможет использование отношений.

Для демонстрации я переключусь на модель, основанную на классах и использующую Code First вместо EDMX (хотя вы можете добиться тех же шаблонов с помощью модели EDMX и дизайнера).

В первом случае я буду использовать интерфейс:

public interface ITheBaseType
{
  DateTime DateCreated { get; set; }
}

Каждый класс будет реализовать интерфейс. В нем будет собственное свойство ключа, и он будет содержать свойство DateCreated. Например, вот класс Product:

public class Product : ITheBaseType
{
  public int ProductID { get; set; }
  // ...прочие свойства...
  public DateTime DateCreated { get; set; }
}

В базе данных каждая таблица имеет собственное свойство DateCreated. Поэтому повторение более раннего запроса к Products приводит к созданию достаточно простого запроса:

context.Products
.Where(b => b.DateCreated == today)
.ToList();

Поскольку в этой таблице содержатся все поля, мне больше не нужен вложенный запрос:

SELECT TOP (1) [Extent1].[Id]                     AS [Id],
               [Extent1].[ProductID]              AS [ProductID],
               [Extent1].[Name]                   AS [Name],
               [Extent1].[ProductNumber]          AS [ProductNumber],
               ...остальные поля из таблицы Products...
               [Extent1].[ProductPhoto_Id]        AS [ProductPhoto_Id],
               [Extent1].[DateCreated]            AS [DateCreated]
FROM   [dbo].[Products] AS [Extent1]
WHERE  [Extent1].[DateCreated] = '2012-05-25T00:00:00.00'

Если вы предпочитаете определение составного типа и его повторное использование в каждом из классов, тогда ваши типы могли бы выглядеть так:

public class Logging
{
  public DateTime DateCreated { get; set; }
}
public class Product{
  public int ProductID { get; set; }
  // ...прочие свойства...
  public Logging Logging { get; set; } 
}

Заметьте, что в классе Logging нет поля ключа (например, Id или LoggingId). В соответствии с соглашениями Code First будет предполагаться, что это составной тип, и он будет интерпретироваться таковым при его использовании для определения свойств в других классах, как это было сделано с Product.

Таблица Products в базе данных содержит столбец Logging_DateCreated, сгенерированный Code First, и с ним сопоставлено свойство Product.Logging.DateCreated. Тот же эффект дало бы добавление свойства Logging в класс Customer. В таблице Customers также имеется собственное свойство Logging_DateCreated, и оно сопоставлено с Customer.Logging.DateCreated.

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

context.Products.Where(b => b.Logging.DateCreated == DateTime.Now).ToList();

Конечный SQL будет тем же, что и в примере с интерфейсом, но теперь полю присваивается имя Logging_DateCreated, а не DateCreated. Это короткий запрос, обращающийся только к таблице Products.

Одно из преимуществ наследования от класса в исходной модели заключается в том, что вы можете легко кодировать логику для автоматического заполнения полей из базового класса — например, при выполнении SaveChanges. Но вы можете не менее легко создать логику для составного типа или интерфейса, поэтому я не вижу никаких недостатков при использовании этих новых шаблонов. На рис. 3 показан простой пример задания свойства DateCreated для новых сущностей в ходе выполнения SaveChanges (узнать больше об этой методике можно во втором издании моей книги «Programming Entity Framework», а также в книге по DbContext).

Рис. 3. Задание свойства DateCreated интерфейса в вызове SaveChanges

public override int SaveChanges()
{
  foreach (var entity in this.ChangeTracker.Entries()
    .Where(e =>
    e.State == EntityState.Added))
  {
    ApplyLoggingData(entity);
  }
  return base.SaveChanges();
}
private static void ApplyLoggingData(DbEntityEntry entityEntry)
{
  var logger = entityEntry.Entity as ITheBaseType;
  if (logger == null) return;
  logger.DateCreated = System.DateTime.Now;
}

Некоторые изменения в EF 5

В Entity Framework 5 внесен ряд усовершенствований в запросы, генерируемые на основе иерархий TPT, что смягчает остроту проблем, продемонстрированных ранее, но не исключает их полностью. Так, запуск моего запроса, который изначально приводил к генерации 3300 строк SQL-кода на машине с установленной Microsoft .NET Framework 4.5, теперь дает 2100 строк SQL-кода. Одно из важнейших отличий EF 5 — она больше не полагается на выражения UNION при формировании запроса. Я не администратор баз данных, но, насколько я понимаю, такое улучшение не повлияет на скорость обработки этого запроса базой данных. Подробнее об этом изменении в TPT-запросах можно прочитать в моей статье в блоге «Entity Framework June 2011 CTP: TPT Inheritance Query Improvements» по ссылке bit.ly/MDSQuB.

Не всякое наследование — зло

Наличие одного базового типа для всех сущностей в вашей модели — экстремальный пример моделирования, где наследование сильно вредит. Есть много хороших примеров использования иерархии наследования в моделях, скажем, когда вы хотите описать, что Customer является Person. Из этого урока важно также извлечь, что LINQ to Entities — не единственный инструмент, доступный вам. В рассмотренном сценарии, который был показан мне моим клиентом, квалифицированный разработчик баз данных переконструировал запрос полей базового типа как хранимую процедуру базы данных, что сократило время выполнения этого запроса с десятков секунд всего до девяти миллисекунд. И мы все были рады. Однако и я, и они надеемся, что им удастся перепроектировать свою модель и оптимизировать базу данных ко времени выпуска следующей версии их программного обеспечения. А тем временем они могут позволить Entity Framework генерировать запросы, которые уже не будут создавать таких проблем.


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

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