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

Сужение EF-моделей с помощью связанных контекстов DDD

В статье рассматривается вторая бета-версия Entity Framework Power Tools. Любая изложенная здесь информация может быть изменена.

Джули Лерман

Исходный код можно скачать по ссылке.

Джули ЛерманОпределяя модели для использования с Entity Framework (EF), разработчики часто включают все классы, которые будут применяться в рамках всего приложения. Это может быть результатом создания новой модели Database First в EF Designer и выбора всех доступных таблиц и представлений из базы данных. Для тех, кто использует Code First, чтобы определять свою модель, это могло бы означать создание свойств DbSet в единственном DbContext для всех ваших классов или даже неумышленное включение классов, связанных с интересующими вас классами.

При работе с большой моделью и крупным приложением есть много преимуществ в проектировании набора более компактных моделей, ориентированных на конкретные задачи приложения, вместо одной модели для всего решения. В этой статье я познакомлю вас с одной концепцией из разработки, управляемой предметной областью (domain-driven design, DDD), — связанным контекстом (Bounded Context) — и покажу, как применять ее для создания адресной модели (targeted model) с помощью EF, уделив основное внимание тому, как делать это при использовании более гибкой функциональности EF Code First. Если вы новичок в области DDD, этот подход стоит освоить, даже если вы не собираетесь досконально изучать DDD. А если вы уже используете DDD, вы получите выигрыш от применения EF в методологии DDD.

DDD и связанный контекст

DDD — отдельная, довольно обширная тематика, охватывающая целостный подход к проектированию ПО. Пол Рейнер (Paul Rayner), ведущий учебные курсы по DDD на сайте DomainLanguage.com, кратко формулирует ее так: «DDD пропагандирует прагматичный и целостный подход к проектированию ПО в сотрудничестве с экспертами в той или иной предметной области для встраивания в ПО богатых моделей предметной области — моделей, помогающих решать важные и сложные проблемы бизнеса».

DDD включает множество проектировочных шаблонов, один из которых — Bounded Context — прекрасно подходит для работы с EF. Bounded Context требует разработки малых моделей, нацеленных на поддержку конкретных операций в вашей области бизнеса. В своей книге «Domain-Driven Design» (Addison Wesley, 2003) Эрик Эванс (Eric Evans) поясняет, что «Bounded Context разграничивает возможности применения конкретной модели. Связанные контексты дают членам группы четкое понимание того, что нужно делать совместно и согласованно, а что — независимо».

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

Так как я создаю связанные контексты с помощью EF DbContext, я ссылаюсь на свои DbContext как на связанные. Однако эти два контекста на самом деле не являются эквивалентами: DbContext — это реализация класса, а Bounded Context заключает в себе более обширную концепцию в рамках всего процесса проектирования. Поэтому в дальнейшем я буду ссылаться на свои DBContext как на ограниченные (constrained) или адресные (focused).

Сравнение типичного EF DbContext со связанным контекстом

Хотя DDD чаще всего применяется в разработке крупных приложений в сложных областях бизнеса, в меньших приложениях тоже можно получить выигрыш от использования многих его концепций. В данном случае я сосредоточусь на приложении, ориентированном на специфическую предметную подобласть: отслеживание продаж и маркетинг в некоей компании. Объекты, используемые в этом приложении, могут быть самые разнообразные — от клиентов (customers), заказов (orders) и позиций заказов (line items) до товаров (products), маркетинга (marketing), агентов по сбыту (salespeople) и даже сотрудников (employees). Обычно DbContext следовало бы определять так, чтобы он содержал свойства DbSet для каждого класса в решении, который требуется сохранять в базе данных (рис. 1).

Рис. 1. Типичный DbContext, содержащий все классы предметной области в решении

public class CompanyContext : DbContext
{
  public DbSet<Customer> Customers { get; set; }
  public DbSet<Employee>  Employees { get; set; }
  public DbSet<SalaryHistory> SalaryHistories { get; set; }
  public DbSet<Order> Orders { get; set; }
  public DbSet<LineItem> LineItems { get; set; }
  public DbSet<Product> Products { get; set; }
  public DbSet<Shipment> Shipments { get; set; }
  public DbSet<Shipper> Shippers { get; set; }
  public DbSet<ShippingAddress> ShippingAddresses { get; set; }
  public DbSet<Payment> Payments { get; set; }
  public DbSet<Category> Categories { get; set; }
  public DbSet<Promotion> Promotions { get; set; }
  public DbSet<Return> Returns { get; set; }
  protected override void OnModelCreating(DbModelBuilder modelBuilder)
  {
    // Конфигурация задает отношение "1:0..1"
    // между Customer и ShippingAddress
    modelBuilder.Configurations.Add(new ShippingAddressMap());
  }
}

А если бы это было гораздо более крупное приложение с сотнями классов? И вдобавок вы использовали бы для некоторых из этих классов конфигурации Fluent API. Вам пришлось бы написать уйму кода и управлять всем этим в одном классе. А разработка столь крупного приложения наверняка была бы поделена между несколькими группами. При наличии одного DbContext уровня всей компании каждой группе потребовалось бы подмножество кодовой базы, выходящее далеко за рамки ее обязанностей. Любые изменения, вносимые любой группой в этот контекст, могли бы повлиять на работу остальных групп.

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

Адресный DbContext для отдела поставок

Так как DDD рекомендует работать с меньшими, более адресными моделями с четко определенными границами контекста, давайте сузим область действия этого DbContext до функций отдела поставок и оставим в нем лишь классы, необходимые для выполнения этих функций. Следовательно, можно удалить некоторые свойства DbSet из DbContext, оставив только те из них, которые требуются для поддержки бизнес-возможностей, относящихся к поставкам. Я изымаю Returns, Promotions, Categories, Payments, Employees и SalaryHistories:

public class ShippingDeptContext : DbContext
{
  public DbSet<Shipment> Shipments { get; set; }
  public DbSet<Shipper> Shippers { get; set; }
  public DbSet<Customer> Customers { get; set; }
  public DbSet<ShippingAddress> ShippingAddresses { get; set; }
  public DbSet<Order> Order { get; set; }
  public DbSet<LineItem> LineItems { get; set; }
  public DbSet<Product> Products { get; set; }

EF Code First использует детали ShippingContext для логического построения модели. На рис. 2 показана визуализированная модель, которая создается из этого класса; я генерирую ее с помощью Entity Framework Power Tools beta 2. Теперь займемся тонкой настройкой этой модели.

Визуализированная модель после первого прохода в ShippingContext
Рис. 2. Визуализированная модель после первого прохода в ShippingContext

Настройка DbContext и создание более адресных классов

В этой модели все равно больше классов, чем я определила для отдела поставок. По соглашению, Code First включает все классы, достижимые другими классами в модели. Вот почему в модели появились Category и Payment, хотя я удалила их свойства DbSet. Поэтому я сообщу DbContext игнорировать Category и Payment:

protected override void OnModelCreating(DbModelBuilder modelBuilder)
{
  modelBuilder.Ignore<Category>();
  modelBuilder.Ignore<Payment>();
  modelBuilder.Configurations.Add(new ShippingAddressMap());
}

Это гарантирует, что Category и Payment не попадут в модель только потому, что они связаны с Product и Order.

Этот класс DbContext можно очистить еще больше, не затрагивая конечную модель. С помощью этих свойств DbSet можно явным образом запрашивать каждый из семи наборов данных в приложении. Но если вы поразмыслите об этих классах и их связях, то, вероятно, придете к выводу, что в этом контексте вам никогда не понадобится напрямую запрашивать ShippingAddress — его всегда можно получить вместе с данными Customer. Даже без ShippingAddresses DbSet вы можете полагаться на то же соглашение, которое автоматически включает Category и Payment, чтобы добавить ShippingAddress в модель из-за его связи с Customer. Поэтому можно удалить свойство ShippingAddresses без потери сопоставлений базы данных с ShippingAddress. Возможно, вы найдете и другие случаи, где можно что-то удалить, но мы сосредоточимся только на этом:

public class ShippingContext : DbContext
{
  public DbSet<Shipment> Shipments { get; set; }
  public DbSet<Shipper> Shippers { get; set; }
  public DbSet<Customer> Customers { get; set; }
  // Public DbSet<ShippingAddress> ShippingAddresses
  //   { get; set; }
  public DbSet<Order> Order { get; set; }
  public DbSet<LineItem> LineItems { get; set; }
  public DbSet<Product> Products { get; set; }
  protected override void OnModelCreating(DbModelBuilder modelBuilder)
  { ... }
}

В контексте обработки поставок на самом деле мне не нужен полный объект Customer, полный объект Order или полный объект LineItem. Мне достаточно Product, который и должен поставляться, количество (из LineItem), имя и ShippingAddress из Customer и любые заметки, которые могут быть прикреплены к Customer или Order. Я заставлю свой DBA создать представление, которое будет возвращать еще не поставленные позиции — с ShipmentId=0 или null. Тем временем я могу определить упрощенный класс, который будет сопоставлять это представление с релевантными на мой взгляд свойствами:

[Table("ItemsToBeShipped")]
public class ItemToBeShipped
{
  [Key]
  public int LineItemId { get; set; }
  public int OrderId { get; set; }
  public int ProductId { get; set; }
  public int OrderQty { get; private set; }
  public OrderShippingDetail OrderShippingDetails { get; set; }
}

Логика обработки поставок требует запроса ItemToBeShipped и последующего получения любых деталей Order, которые могут мне понадобиться наряду с Customer и ShippingAddress. Я могла бы уменьшить свое определение DbContext, запрашивая граф, начиная с этого нового типа и включая Order, Customer и ShippingAddress. Однако, поскольку мне известно, что EF реализовала бы это на основе SQL-запроса, который линеаризует результаты и возвращает повторяющиеся данные Order, Customer и ShippingAddress вместе с каждой позицией заказа, я позволю программисту запрашивать заказ и получать граф с Customer и ShippingAddress. Но опять же мне не требуются все поля из таблицы Order, поэтому я создам класс, в больше мере сфокусированный на функциях отдела поставок и включающий информацию, которую можно было бы напечатать в путевом листе. Это класс OrderShippingDetail, показанный на рис. 3.

Рис. 3. Класс OrderShippingDetail

[Table("Orders")]
public class OrderShippingDetail
{  
  [Key]
  public int OrderId { get; set; }
  public DateTime OrderDate { get; set; }
  public Nullable<DateTime> DueDate { get; set; }
  public string SalesOrderNumber { get; set; }
  public string PurchaseOrderNumber { get; set; }
  public Customer Customer { get; set; }
  public int CustomerId { get; set; }
  public string Comment { get; set; }
  public ICollection<ItemToBeShipped> OpenLineItems { get; set; }
}

Заметьте, что мой класс ItemToBeShipped имеет навигационное свойство для OrderShippingDetail, а OrderShippingDetail — для Customer. Навигационные свойства помогут мне с графами при запросах и сохранениях.

В этой головоломке есть еще один фрагмент. Отделу поставок понадобится отмечать позиции заказов как поставленные, и в таблице LineItems есть поле ShipmentId, используемое для связывания позиции заказа с отгрузкой. Приложение должно будет обновлять это поле ShipmentId при отправке позиции заказа. Я создам простой класс, который берет на себя эту задачу, и не стану полагаться на класс LineItem, используемый отделом продаж:

[Table("LineItems")]
public class LineItemShipment
{
  [Key]
  public int LineItemId { get; set; }
  public int ShipmentId { get; set; }
}

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

После дальнейшей тонкой настройки мой ShippingContext теперь определяется так:

public class ShippingContext : DbContext
{
  public DbSet<Shipment> Shipments { get; set; }
  public DbSet<Shipper> Shippers { get; set; }
  public DbSet<OrderShippingDetail> Order { get; set; }
  public DbSet<ItemToBeShipped> ItemsToBeShipped { get; set; }
  protected override void OnModelCreating(DbModelBuilder modelBuilder)
  {
    modelBuilder.Ignore<LineItem>();
    modelBuilder.Ignore<Order>();
    modelBuilder.Configurations.Add(new ShippingAddressMap());
  }
}

Вновь используя Entity Framework Power Tools beta 2 для создания EDMX, я могу увидеть в окне Model Browser (рис. 4), что Code First логически формирует модель с четырьмя классами, указанными свойствами DbSet, а также Customer и ShippingAddress, которые были обнаружены с помощью навигационных свойств из класса OrderShippingDetail.

Просмотр сущностей ShippingContext в Model Browser, логически сформированных Code First

Рис. 4. Просмотр сущностей ShippingContext в Model Browser, логически сформированных Code First

Адресный DbContext и инициализация базы данных

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

Первое заключается в том, что Code First будет искать базу данных с именем контекста. Это нежелательно, когда в вашем приложении есть ShippingContext, CustomerContext, SalesContext и др. Вместо этого вы предпочтете, чтобы все DbContext указывали на одну и ту же базу данных.

Второе поведение по умолчанию, которое следует учитывать, — Code First будет использовать модель, логически выведенную по DbContext, для определения схемы базы данных. Но теперь у вас есть DbContext, представляющий лишь некий срез этой базы данных. По этой причине вы не захотите, чтобы классы DbContext вызывали инициализацию базы данных.

Обе проблемы можно решить в конструкторе каждого класса контекста. Например, здесь в классе ShippingContext может быть конструктор, указывающий DPSalesDatabase и отключающий инициализацию базы данных:

public ShippingContext() : base("DPSalesDatabase")
{
  Database.SetInitializer<ShippingContext>(null);
}

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

public class BaseContext<TContext>
  DbContext where TContext : DbContext
{
  static BaseContext()
  {
    Database.SetInitializer<TContext>(null);
  }
  protected BaseContext() : base("DPSalesDatabase") 
  {}
}

Теперь мои разнообразные классы контекста могут реализовать BaseContext вместо того, чтобы в каждый класс помещать свой конструктор:

public class ShippingContext:BaseContext<ShippingContext>

Если вы приступаете к новой разработке и хотите позволить Code First создать или перенести базу данных на основе ваших классов, вам понадобится «убер-модель» с DbContext, который включает все классы и отношения, необходимые для построения полной модели, представляющей эту базу данных. Однако этот контекст не должен наследовать от BaseContext. Внося изменения в структуры своих классов, вы можете выполнять некоторый код, который использует такой убер-контекст для инициализации базы данных независимо от того, создаете вы или переносите базу данных.

Проверка адресного DbContext

Сложив все фрагменты, я создала автоматизированные интеграционные тесты для выполнения следующих задач:

  • получения открытых позиций заказов;
  • извлечения OrderShippingDetails наряду с данными Customer и Shipping для заказов с неотгруженными позициями;
  • выборки неотгруженной позиции заказа и создания новой поставки. Поставка связывается с позицией заказа, и эта новая поставка вставляется в базу данных с попутным обновлением этой позиции в базе данных значением ключа новой поставки.

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

Заключение

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

Другие примеры сужения DbContext с помощью DDD-концепции Bounded Context вы можете посмотреть в книге «Programming Entity Framework: DbContext» (O’Reilly Media, 2011), которую я написала в соавторстве с Роуэном Миллером (Rowan Miller).


Джули Лерман (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.

Выражаю благодарность за рецензирование статьи эксперту Полу Рейнеру (Paul Rayner).