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

Кодирование при использовании DDD: советы разработчикам, сосредоточенным на операциях с данными. Часть 3

Джули Лерман

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

Джули ЛерманЭто заключительная статья из серии, рассчитанной на разработчиков, которые в основном имеют дело с данными. Она должна помочь им понять некоторые из более сложных концепций кодирования, используемых в DDD (Domain-Driven Design). Как разработчик для Microsoft .NET Framework на основе Entity Framework (EF) и с большим опытом в разработке по принципу «сначала данные» (data first) и даже «сначала база данных» (database first) я прошла трудный путь к осознанию того, как использовать свои навыки в сочетании с некоторыми методиками реализации DDD. Даже если я не применяю полную реализацию DDD в проекте, я все равно существенно выигрываю от многих средств DDD.

В этой статье мы обсудим два важных технических шаблона кодирования в DDD и то, как они применяются к используемой мной инфраструктуре объектно-реляционного сопоставления (object-relational mapping, ORM), а именно: к EF. В одной из предыдущих статей из этой серии я говорила об отношениях «один к одному». Здесь мы изучим односторонние отношения (предпочитаемые в DDD) и то, как они влияют на ваше приложение. Этот выбор ведет к трудному решению: нужно понимать, когда лучше отказаться от столь удобной «магии» отношений, предлагаемой EF. Мы также поговорим о важности балансировки задач между агрегатным корнем и репозитарием.

Формирование односторонних отношений от корня

В те времена, когда я начинала строить модели с помощью EF, двухсторонние отношения были нормой, и даже не особо задумывалась над этим. Возможность навигации в две стороны имеет смысл. Если у вас есть заказы и клиенты, полезно иметь возможность просматривать заказы по клиентам, а отталкиваясь от заказа удобно обращаться к данным о клиенте. Я также не задумываясь создавала двухсторонние отношения между заказами и их позициями. Связь от заказа к его позициям имеет смысл. Но, если хорошенько поразмыслить, сценарии, где у вас есть позиция и нужно по ней вернуться к соответствующему заказу, можно пересчитать по пальцам. Один из них, который приходит мне в голову, заключается в том, что вы создаете отчеты по товарам и хотите провести некий анализ для выявления товаров, часто заказываемых вместе, или проанализировать информацию, включающую данные о клиентах и поставках. В таких случаях вам может понадобиться переход от товара к позициям заказа, в котором он содержится, а потом возврат к заказу. Однако я вижу такой сценарий только в отчетности, где мне вряд ли потребуется работать с объектами, на которых фокусируется DDD.

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

Как уже отмечалось, в DDD отдается предпочтение односторонним отношениям. Эрик Эванс (Eric Evans) говорит, что «важно максимально ограничивать отношения» и что «понимание предметной области может раскрыть естественные отношения». Управление сложностью отношений, особенно когда вы полагаетесь в поддержании связей на Entity Framework, определенно является той областью, которая способна создать путаницу. Я уже сочинила ряд статей из этой рубрики, посвященных связям в Entity Framework. Возможность удаления хотя бы одного уровня сложности, по-видимому, окажется выигрышной.

Если обдумать простую модель продаж, использованную в этой серии статей по DDD, то она все же представляет направление от заказа к его позициям. Я не могу вообразить создание, удаление или редактирование позиции, не начав с заказа.

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

public void CreateLineItem(Product product, int quantity)
{
  var item = new LineItem
  {
    OrderQty = quantity,
    ProductId = product.ProductId,
    UnitPrice = product.ListPrice,
    UnitPriceDiscount = CustomerDiscount + PromoDiscount
  };
  LineItems.Add(item);
}

У типа LineItem есть свойство OrderId, но нет свойства Order. То есть можно задать значение OrderId, но нельзя перейти от LineItem к реальному экземпляру Order.

В этом случае, если выразиться словами Эванса, я «ввела направление перехода». По сути, я сделала так, что перейти от Order к LineItem можно, но обратного пути нет.

Этот подход влияет не только на модель, но и на уровень данных. Я использую Entity Framework в качестве ORM-механизма, и эта инфраструктура понимает данное отношение просто по свойству LineItems класса Order. А поскольку я следую соглашениям EF, она распознает, что LineItem.OrderId является свойством внешнего ключа, указывающим на класс Order. Если бы я указала для OrderId другое имя, Entity Framework было бы куда сложнее.

Но в этом сценарии я могу добавить новый LineItem в существующий заказ так:

order.CreateLineItem(aProductInstance, 2);
var repo = new SimpleOrderRepository();
repo.AddAndUpdateLineItemsForExistingOrder(order);
repo.Save();

Переменная order теперь представляет граф с заранее созданным заказом и единственным новым LineItem. Этот заказ взят из базы данных и уже имеет значение в OrderId, но у нового LineItem имеется лишь значение по умолчанию для его свойства OrderId, и оно равно 0.

Метод моего репозитария принимает этот граф заказа, добавляет его в мой EF-контекст, а затем применяет необходимое состояние, как показано на рис. 1.

Рис. 1. Применение состояния к графу заказа

public void AddAndUpdateLineItemsForExistingOrder(Order order)
{
  _context.Orders.Add(order);
  _context.Entry(order).State = EntityState.Unchanged;
  foreach (var item in order.LineItems)
  {
    // Существующие элементы из базы данных имеют идентификатор
    // и модифицируются, а не добавляются
    if (item.LineItemId > 0)
    {
      _context.Entry(item).State = EntityState.Modified;
    }
  }
}

На случай, если вы не знакомы с поведением EF, метод Add заставляет контекст начать отслеживать все, что находится в графе (заказ и одну его позицию). В то же время каждый объект в графе получает состояние Added. Но, поскольку этот метод использует заранее созданный заказ, мне известно, что Order не является новым, а значит, этот метод фиксирует состояние экземпляра Order, устанавливая его в Unchanged. Кроме того, он проверяет наличие любых заранее созданных LineItem и задает их состояние как Modified, чтобы в базе данных они были обновлены, а не вставлены как новые. В более серьезном приложении я использовала бы шаблон для более определенного выяснения состояния каждого объекта, но я не хочу загромождать этот пример дополнительными деталями. (Раннюю версию этого шаблона можно посмотреть в блоге Роуэна Миллера [Rowan Miller] по ссылке bit.ly/1cLoo14, а обновленный пример в нашей с ним книге «Programming Entity Framework: DbContext» [O’Reilly Media, 2012].)

Поскольку все эти действия выполняются, пока контекст отслеживает объекты, Entity Framework также «магически» фиксирует значение OrderId в новом экземпляре LineItem. Поэтому к моменту вызова Save объект LineItem будет знать, что значение OrderId равно 1.

Отказываемся от магии EF в управлении отношениями для обновлений

На мое счастье, данный тип LineItem отвечает соглашению EF по именованию внешнего ключа. Если бы назвали его как-то иначе, нежели OrderId, например OrderFK, вам пришлось бы вносить изменения в свой тип (скажем, вводить нежелательное навигационное свойство Order), а затем указывать сопоставления для EF. Это было бы плохо, так как вы увеличили бы сложность только из-за того, чтобы удовлетворить требования ORM. Иногда это бывает необходимым, но, когда реальной нужды в этом нет, я предпочитаю избегать таких ситуаций.

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

Вот код, который я использую в переопределенном методе DbContext.OnModelBuilder для того, чтобы EF не обращала внимания на данное отношение:

modelBuilder.Entity<Order>().Ignore(o => o.LineItems);

Теперь я возьму управление отношением на себя. Это подразумевает рефакторинг: я должна добавить конструктор в LineItem, который требует OrderId и другие значения и делает LineItem гораздо более похожим на DDD-сущность. Кроме того, я должна модифицировать метод CreateLineItem в Order, чтобы использовать этот конструктор вместо инициализатора объекта.

На рис. 2 показана обновленная версия метода репозитария.

Рис. 2. Метод репозитария

public void UpdateLineItemsForExistingOrder(Order order)
{
  foreach (var item in order.LineItems)
  {
    if (item.LineItemId > 0)
    {
      _context.Entry(item).State = EntityState.Modified;
    }
    else
    {
      _context.Entry(item).State = EntityState.Added;
      item.SetOrderIdentity(order.OrderId);
    }
  }
}

Заметьте, что я больше не добавляю граф заказа и не фиксирую потом состояние заказа как Unchanged. По сути, поскольку EF не распознает это отношение, вызов context.Orders.Add(order) добавил бы экземпляр заказа, но не связанные с ним позиции, как это делалось раньше.

Вместо этого я перебираю позиции заказа в графе и устанавливаю состояние существующих позиций в Modified, а состояние новых — в Added. Используемый мной синтаксис DbContext.Entry делает две вещи. Перед тем как установить состояние, он проверяет, известна ли контексту (или «отслеживается») данная конкретная сущность. Если нет, он подключает эту сущность на внутреннем уровне. Теперь он может реагировать на тот факт, что в коде задается свойство состояния. Таким образом, подключение LineItem и установка его состояния осуществляется в единственной строке кода.

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

Поскольку на данный момент я зашла в тупик из-за использования целочисленных значений для ключей (Order.OrderId, например) и зависимости от базы данных, которая предоставляет значения этих ключей, мне нужно проделать некоторую дополнительную работу в репозитарии для новых агрегатов, таких как новый заказ с позициями. Мне потребуется жесткий контроль за сохранением, чтобы я могла использовать старомодный шаблон вставки графов: вставить заказ, получить новое значение OrderId, сгенерированное базой данных, применить его к новым позициям и сохранить их в базе данных. Это необходимо потому, что я разорвала отношение, которое EF обычно использует для своей магии. В примере исходного кода, который можно скачать для этой статьи, можно увидеть, как я реализовала это в репозитарии.

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

Сохраняем магию EF в управлении отношениями для запросов

Освобождение модели от управления отношениями EF реально помогло мне в предыдущем сценарии при выполнении обновлений. Но я не хочу лишаться всех EF-средств управления отношениями. Загрузка связанных данных при запросе из базы данных — одно из средств, от которых я не собираюсь отказываться. При любом виде загрузки (интенсивной, отложенной или явной) мне нравятся преимущества EF, предоставляющей связанные данные без выражения и выполнения дополнительных запросов.

Здесь в игру вступает расширенная концепция разделения обязанностей. Следуя предписаниям DDD при проектировании, нет ничего необычного в наличии разных представлений схожих классов. Например, у вас может быть класс Customer, рассчитанный на использование в контексте управления клиентами, и класс Customer для простого заполнения списка, где нужны только имя и идентификатор клиента.

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

Экстремальный вариант этой концепции для определенного подмножества сложных задач, решаемых вашим программным обеспечением, — шаблон Command Query Responsibility Segregation (CQRS). CQRS заставляет вас рассматривать получение данных (операции чтения) и их сохранение (операции записи) как раздельные системы, которые могут потребовать разных моделей и архитектур. Мой небольшой пример, который подчеркивает преимущества использования для операций извлечения данных другого взгляда на отношения по сравнению с операциями сохранения данных, дает представление о том, чего может помочь добиться CQRS. Узнать больше о CQRS можно на отличном ресурсе CQRS Journey, доступном по ссылке msdn.microsoft.com/library/jj554200.

Доступ к данным происходит в репозитарии, а не в агрегатном корне

Теперь я хочу сделать шаг назад и рассмотреть последний вопрос — он постоянно изводил меня, когда я стала фокусироваться на односторонних отношениях. (Это не значит, что у меня больше нет вопросов по DDD, — просто это последняя тема, которую я хотела обсудить в этой серии статей.) Этот вопрос по поводу односторонних отношений часто возникает у тех, кто привык мыслить в рамках «сначала база данных»: где именно (в случае DDD) происходит доступ к данным?

Когда состоялся первый выпуск EF, единственный способ, которым она могла работать с базой данных, заключался в разборе этой базы данных на исходные компоненты. Поэтому, как я уже говорила, я привыкла к тому, что каждое отношение является двусторонним. Если таблицы Customers и Orders в базе данных имели ограничение по основному или внешнему ключу, описывающее отношение «один ко многим», я видела такое же отношение и в модели. В Customer было навигационное свойство, ведущее к набору Orders, а в Order — навигационное свойство, ведущее к экземпляру Customer.

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

Поэтому при использовании DDD, когда я оказалась наедине с агрегатным корнем Order, которому было известно о CustomerId или, возможно, даже о типе Customer в целом, но не могла перейти от Order обратно к Customer, я почувствовала, что почва уходит из-под моих ног. И мой первый вопрос заключался в том, как быть, если нужно найти все заказы для клиента? Я всегда предполагала, что мне нужна такая возможность, и привыкла полагаться на доступ к свойствам навигации в обоих направлениях.

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

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

public List<Order>GetOrdersForCustomer(Customer customer)
{
  return _context.Orders.
    Where(o => o.CustomerId == customer.Id).
ToList();
}

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

Это лишь начало моих исканий

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

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

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


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

Выражаю благодарность за рецензирование статьи эксперту Microsoft Стивену Болену (Stephen Bohlen).