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

Шаблон использования данных между связанными контекстами в Domain-Driven Design. Часть 2

Джули Лерман

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

Julie LermanВ своей рубрике за октябрь 2014 г. (msdn.microsoft.com/magazine/dn802601) я написала о шаблоне для зеркалирования данных из одной базы данных на другую при использовании нескольких связанных контекстов (bounded contexts, BC) в Domain-Driven Design (DDD), когда каждый BC изолирован в своей базе данных. Сценарий заключался в том, что Customer Management BC давал возможность пользователям управлять данными о клиентах и вставлять/обновлять/удалять более подробные сведения о них. Второй BC предназначен для системы заказов, которой нужен доступ к двум крайне важным частям информации о клиентах: ключу идентификатора (identifier key) и имени клиента. Поскольку эти системы находятся в двух разных BC, совместное использование данных в них невозможно.

DDD предназначен для решения сложных задач, и упрощение задач в предметной области зачастую означает перенос сложности за ее границы. Поэтому Customer Management BC не нужно знать об этом последующем совместном использовании данных. В той рубрике я применяла шаблон публикации/подписки для решения задачи, используя Domain Events, очередь сообщений (RabbitMQ) и сервис. В итоге у меня оказалось много «движущихся частей».

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

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

Эти два изменения сделают решение более применимым в реальных сценариях.

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

Текущее решение генерирует уведомление, когда клиент создается или когда изменяется его имя. Конструктор и следующий метод FixName вызывают PublishEvent:

public void FixName(string newName){
    Name = newName;
    ModifiedDate = DateTime.UtcNow;
    PublishEvent(false);
  }

PublishEvent запускает рабочий процесс, в результате которого сообщение публикуется в очереди:

private void PublishEvent(bool isNew){
    var dto = CustomerDto.Create(Id, Name);
    DomainEvents.Raise(new CustomerUpdatedEvent(dto, isNew));
  }

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

Это означает удаление метода PublishEvent и его вызовов из класса Customer.

В моем уровне данных есть класс, содержащий логику доступа к данным для агрегата клиента (customer aggregate). Я переместила метод PublishEvent в этот класс и переименовала в PublishCustomerPersistedEvent. В своих методах, которые сохраняют объекты Customer в базе данных, я вызываю новое событие по завершении SaveChanges (рис. 1).

Рис. 1. Класс сохранения генерирует события после сохранения данных

public class CustomerAggregateRepository {
public bool PersistNewCustomer(Customer customer) {
  using (var context = new CustomerAggregateContext()) {
    context.Customers.Add(customer);
    int response = context.SaveChanges();
    if (response > 0) {
      PublishCustomerPersistedEvent(customer, true);
      return true;
    }
    return false;
  }
}
public bool PersistChangeToCustomer(Customer customer) {
  using (var context = new CustomerAggregateContext()) {
    context.Customers.Attach(customer);
    context.Entry(customer).State = EntityState.Modified;
    int response = context.SaveChanges();
    if (response > 0) {
      PublishCustomerPersistedEvent(customer, false);
      return true;
    }
    return false;
  }
}
   private void PublishCustomerPersistedEvent(Customer customer, 
    bool isNew) {
     CustomerDto dto = CustomerDto.Create(customer.Id, customer.Name);
     DomainEvents.Raise(new CustomerUpdatedEvent(dto, isNew));
   }
 }

В связи с этим мне нужно переместить в проект уровня данных и инфраструктуру для публикации сообщений. На рис. 2 показаны соответствующие проекты (CustomerManagement.Core и CustomerManagement.Infastructure), созданные мной для октябрьской рубрики, наряду с проектами после перемещения генерации события на уровень данных. CustomerUpdatedEvent, DTO и Service теперь находятся в проекте Infrastructure. Я с удовольствием избавилась от инфраструктурной логики в предметной области. Меня раздражало, что такой код находился в основном проекте предметной области.

Структура проекта до и после перемещения публикации событий на уровень данных
Рис. 2. Структура проекта до и после перемещения публикации событий на уровень данных

Original Solution Исходное решение
Updated Solution Обновленное решение

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

На уровне данных, а не в модели предметной области

Это действительно простое изменение, но я долго сомневалась в нем — не в техническом плане, а в правильности перемещения события из BC на уровень данных. Я вела дебаты сама с собой, выгуливая собаку. (Для меня нет ничего необычного в прогулке по лесу или ходьбе по своей дорожке туда-сюда, разговаривая сама с собой. К счастью, я живу в тихом местечке, где никто не усомнится, в здравом ли я уме.) Дебаты закончились следующим: публикация события не относится к BC. Почему? Потому что BC заботится только о самом себе. BC не волнует, что нужно другим BC, сервисам или приложениям. Значит, «мне нужно совместно использовать сохраненные данные с системой заказов», не является нормальной обязанностью BC. Это событие относится не к предметной области, а к сохранению, которое я перекладываю в корзину Application Event.

Устранение новой проблемы, вызванной переносом

Появляется одна проблема из-за переноса события публикации на уровень сохранения (persistence layer). Метод PersistChangeToCustomer используется для сохранения и других правок в сущности Customer. Например, сущность Customer также дает возможность добавлять или обновлять адреса доставки и отправки счета клиенту. Адреса являются значимыми объектами, и их создание или замена новым набором значений отражает некое изменение в этой сущности.

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

Как дать знать этому уровню сохранения о том, что имя клиента не изменялось? Решение на уровне инстинкта — добавить свойство-флаг вроде NameChanged. Но я не хочу полагаться на добавление булевых флагов, поскольку мне требуется отслеживать детализированное состояние. Тогда я подумала о генерации события из класса Customer, но не того, которое инициировало бы отправку другого сообщения в очередь. Мне ни к чему сообщение, которое просто извещает: «Не посылать сообщение». Но как же захватить событие?

И вновь Джимми Богард (Jimmy Bogard) пришел на помощь с другим блестящим решением. В своем блоге он опубликовал в мае 2014 года статью «A Better Domain Events Pattern» (bit.ly/1vUG3sV), где предложил собирать события вместо немедленной их генерации, а затем разрешать уровню сохранения получать этот набор и обрабатывать события так, как это требуется. Суть его шаблона — в удалении статического класса DomainEvents, который не позволяет управлять тем, когда события генерируются, и поэтому может вызывать побочные эффекты. Это более новое отношение к событиям предметной области. При рефакторинге я случайно избежала этой проблемы, но все равно привязана к статическому классу DomainEvents.

Мне нравится подход Богарда, но я намерена позаимствовать идею и использовать ее немного иначе, чем в его реализации. Мне не нужно посылать сообщение в очередь — достаточно прочитать событие. А это отличный способ захватить событие в объект Customer без создания разнообразные флаги произвольного состояния. Например, я могу обойтись без громоздкости, создаваемой включением булева флага, который сообщает «Имя были исправлено» и который устанавливается в true или false.

Богард использует свойство ICollection<IDomainEvent>, называемое свойством Events в интерфейсе IEntity. Если бы в моей предметной области было более одной сущности, я сделала бы то же самое или, возможно, добавила бы его в базовый класс Entity. Но в этой демонстрации я просто помещу новое свойство прямо в свой объект Customer. Я создала закрытое поле и предоставила Events только для чтения, чтобы лишь Customer мог модифицировать этот набор:

private readonly ICollection<IDomainEvent> _events;
public ICollection<IDomainEvent> Events {
  get { return _events.ToList().AsReadOnly(); }
}

Затем я определю релевантное событие: CustomerNameFixedEvent, которое реализует интерфейс IDomainEvent, использовавшийся мной в первой части этой статьи. CustomerNameFixedEvent многого не нужно. Оно будет устанавливать свойство DateTimeEventOccurred, являющееся частью интерфейса:

public class CustomerNameFixedEvent : IDomainEvent{
  public CustomerNameFixedEvent(){
    DateTimeEventOccurred = DateTime.Now;
  }
  public DateTime DateTimeEventOccurred { get; private set; }   }
}

Теперь всякий раз, когда я вызываю метод Customer.FixName, я могу добавить экземпляр этого события в набор Events:

public void FixName(string newName){
  Name = newName;
  ModifiedDate = DateTime.UtcNow;
  _events.Add(new CustomerNameFixedEvent());
}

Благодаря этому я получаю нечто, гораздо свободнее связанное, чем свойство-состояние. Кроме того, в будущем я могу добавить сюда логику по мере развития моей предметной области, не модифицируя схему класса Customer. И можно задействовать эти преимущества в методе сохранения.

Метод PersistChangeToCustomer теперь имеет новую логику. Он будет проверять это событие во входящем Customer. Если событие есть, оно инициирует отправку сообщения в очередь. На рис. 3 показан весь этот метод с новой логикой для проверки типа события до публикации.

Рис. 3. Метод PersistChangeToCustomer, проверяющий тип события

public bool PersistChangeToCustomer(Customer customer) {
    using (var context = new CustomerAggregateContext()) {
      context.Customers.Attach(customer);
      context.Entry(customer).State = EntityState.Modified;
      int response = context.SaveChanges();
      if (response > 0) {
        if (customer.Events.OfType<CustomerNameFixedEvent>().Any()) {
          PublishCustomerPersistedEvent(customer, false);
        }
        return true;
      }
      return false;
    }
  }

Этот метод по-прежнему возвращает булево значение, указывающее, успешно ли сохранен Customer; это определяется переменной response, возвращаемой методом SaveChanges: в случае успеха ее значение больше нуля. Если это так, метод проверит любые CustomerNameFixedEvent в Customer.Events и опубликует сообщение о сохранении Customer, как и раньше. Если по какой-то причине SaveChanges потерпит неудачу, об этом скорее всего сообщит исключение. Однако я также анализирую значение response, поэтому могу вернуть false из метода. Логика, вызвавшая метод, будет решать, что делать в случае сбоя, — возможно, она предпримет повторную попытку сохранения или отправит уведомление конечному пользователю или в какую-то другую систему.

Поскольку я использую Entity Framework (EF), стоит отметить, что вы можете сконфигурировать EF6 на повторную попытку вызова SaveChanges при случайных ошибках соединения. Но это уже будет выполнено к тому моменту, когда я получу response от SaveChanges. Подробности по DbExecutionStrategy см. в моей статье «Entity Framework 6, Ninja Edition» за декабрь 2013 г. (msdn.microsoft.com/magazine/dn532202).

Я добавила новый тест для проверки новой логики. Этот тест создает и сохраняет новый объект Customer, а затем изменяет его свойство BillingAddress и сохраняет это изменение. Моя очередь принимает сообщение о создании нового Customer, но не получает никакого сообщения в ответ на обновление, которое изменяет адрес:

[TestMethod]
public void WillNotSendMessageToQueueOnSuccessfulCustomerAddressUpdate() {
  Customer customer = Customer.Create("George Jetson", "Friend Referral");
  var repo = new CustomerAggregateRepository();
  repo.PersistNewCustomer(customer);
  customer.CreateNewBillingAddress
    ("123 SkyPad Apartments", "", "Orbit City", "Orbit", "n/a", "");
  repo.PersistChangeToCustomer(customer);
  Assert.Inconclusive(@"Check status of RabbitMQ Manager for a create message,
    but no update message");
}

Стивен Болен (Stephen Bohlen), который рецензировал мою статью, предложил шаблон «test spy» (xunitpatterns.com/TestSpy.html) в качестве альтернативного способа проверки того, что сообщения действительно попали в очередь.

Решение в двух частях

Как совместно использовать данные между связанными контекстами — вопрос, который задают многие разработчики, изучающие DDD. Стив Смит (Steve Smith) и я указали на эту возможность в нашем совместном учебном курсе Pluralsight «Domain-Driven Design Fundamentals» (bit.ly/PS-DDD), но не продемонстрировали ее. Нас постоянно просят подробнее рассказать о том, как решать эту задачу. В первой статье этой небольшой серии я использовала много средств для конструирования рабочего процесса, позволяющего использовать данные из базы данных в одном BC в базе данных в другом BC. Управление шаблоном публикации/подписки с применением очередей сообщений, событий и контейнера Inversion of Control позволило мне реализовать этот шаблон в очень слабо связанном стиле.

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

Всегда остается простор для дальнейших усовершенствований и оптимизаций, и, возможно, вы предпочитаете использовать другие средства. Но теперь вы должны знать, как подходить к шаблону 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).