Всю свою жизнь в качестве программиста я стремилась к созданию повторно используемого кода и данных. Поэтому, начав изучать Domain-Driven Design (DDD), я испытывала большие трудности с восприятием такого разделения его связанных контекстов (bounded contexts), в результате которого могут дублироваться код и даже данные. Я чуть не впала в истерику, когда некоторые из лучших умов в области DDD пытались вмешаться, чтобы помочь мне понять потенциальные проблемы в моих старых привычках. Наконец, Эрик Эванс (Eric Evans) объяснил, что каждый должен выбирать, где ему расплачиваться за сложность. Поскольку суть DDD в уменьшении сложности ПО, за это так или иначе приходится платить поддержкой дублирующихся моделей и, возможно, дубликатов данных.
В этой статье я опишу концепции DDD и то, как они соотносятся с моим опытом в разработке, управляемой данными. Первая из статей была в рубрике за январь 2013 г. («Shrink EF Model with DDD Bounded Contexts», bit.ly/1isIoGE), а затем я опубликовала цикл из трех статей «Coding for Domain-Driven Design: Tips for Data-Focused Devs» (см. первую из них по ссылке bit.ly/XyCNrU). В первой части серии вы найдете раздел «Shared Data Can Be a Curse in Complex Systems». Прочитайте его, чтобы лучше понять, чем полезен подход, демонстрируемый мной здесь.
Меня неоднократно спрашивали, как именно можно использовать данные между связанными контекстами, если следовать одному из более экстремальных DDD-шаблонов, где каждый связанный контекст привязывается к своей базе данных. Стив Смит (Steve Smith) и я рассказываем об этом в нашем учебном курсе «Domain-Driven Design Fundamentals» на Pluralsight.com (bitly.com/PS-DDD), но на деле не реализуем, поскольку это выходит за рамки данного курса.
Есть разные способы задействовать общие данные между связанными контекстами. В этой статье я намерена сосредоточиться на одном конкретном сценарии: зеркалировании данных из одной системы в другую, где первая система рассчитана на редактирование этих данных, а во второй просто нужен доступ только для чтения какой-то части данных.
Сначала я структурирую базовый шаблон, затем добавлю некоторые дополнительные детали. Реализация включает целый ряд рабочих частей, в том числе контейнера Inversion of Control (IoC) и очереди сообщений. Если вы знакомы с этими средствами, вам будет легче понять реализацию. Я не стану вдаваться в какие-то подробности реализации IoC и очередей, но вы сможете посмотреть все это в режиме отладки, скачав пакет исходного кода, сопутствующий этой статье.
Сценарий-пример: совместное использование списка клиентов
Для демонстрации этого шаблона я выбрала очень простой сценарий. Одна из систем выделена для обслуживания клиентов. Здесь пользователи могут поддерживать данные о клиентах, а также массу другой информации. Эта система взаимодействует с механизмом хранения данных, но в этом примере он не имеет значения. Вторая система предназначена для приема заказов. В этой системе пользователям нужен доступ к информации о клиентах, но на самом деле только для того, чтобы идентифицировать клиента, делающего заказ. То есть в данном связанном контексте просто нужен список имен и идентификаторов клиентов, доступный только для чтения. А значит, база данных, подключенная ко второй системе, должна располагать актуальным списком имен и идентификаторов клиентов на основе информации о клиентах, поддерживаемой в первой системе. С этой целью я выбрала подход с зеркалированием во вторую систему этих двух частей данных для каждого клиента из первой системы.
Зеркалирование данных: высокий уровень
На самом высоком уровне мое решение заключается в том, что каждый раз, когда в систему A вставляется новый клиент, его идентификатор и имя должны быть добавлены в хранилище данных в системе B. Если я изменяю имя существующего клиента в системе A, то и система B должна получить правильное имя, поэтому изменение имени должно вызывать обновление в хранилище данных системы B. Мое решение не предусматривает удаления данных, но в качестве будущего усовершенствования можно было бы удалять неактивных клиентов из хранилища данных системы B. Однако я не стану возиться здесь с реализацией этого.
Итак, из предыдущего абзаца вытекает, что я должна реагировать лишь на два события в системе A:
- вставлен новый клиент;
- изменено имя существующего клиента.
Если бы система B была связана с системой A, она могла бы предоставлять методы для вызова из системы A, например InsertCustomer или UpdateCustomerName. Либо система A могла бы генерировать события, такие как CustomerCreated и CustomerNameUpdated, для остальных систем, включая систему B, и те захватывали бы их и соответственно реагировали бы. В ответ на каждое событие система B должна что-то делать в своей базе данных.
Поскольку эти системы разъединены, более эффективный подход — использование шаблона публикации-подписки. Система A будет публиковать одно или более событий в операторе какого-то типа. А одна или более систем затем подпишется в том же операторе, ожидая конкретных событий и выполняя свои операции в ответ на эти события.
Публикация-подписка соответствует принципам DDD, требующим, чтобы две системы ничего не знали друг о друге, и поэтому не взаимодействовали друг с другом напрямую. Я буду использовать концепцию, которая называется «антикоррупционным» уровнем (anti-corruption layer). Каждая система будет взаимодействовать через оператор, который будет передавать сообщения между двумя этими системами.
Этим оператором является очередь сообщений. Система A будет отправлять сообщения в очередь. Система B будет извлекать сообщения из очереди. В моем примере только один подписчик (система B), но подписчиков может быть много.
Что содержится в сообщении события?
Когда публикуется событие CustomerCreated, система A отправит сообщение, смысл которого — «Вставлен клиент. Вот его идентификация и имя». Это полное сообщение за исключением того, что оно представлено данными, а не фразами на английском. Интересный момент в публикации событии в очереди сообщений заключается в том, что публикатора не волнует, какие системы получают это сообщение и что они делают в ответ на него.
Система B отреагирует на это сообщение вставкой или обновлением объекта Customer в своей базе данных. На практике система B не выполняет даже эту задачу; я отдаю всю работу сервису. Более того, я позволяю базе данных определять, как следует выполнять обновление. В этом случае база данных системы B выполнит обновление клиента, используя хранимую процедуру, логика которой удаляет исходную запись клиента и вставляет новую. Поскольку в качестве ключей идентификации я буду применять GUID, идентификация клиента будет корректно сохраняться. Я не хочу заботиться в своей DDD-программе о ключах, генерируемых базой данных. Заранее созданные GUID в сравнении с ключами, значения которых увеличиваются базой данных, — неоднократно рассмотренная тема. Вам потребуется определить свою логику в соответствии с принятой в вашей компании практикой в отношении баз данных.
В итоге система B (система заказов) будет располагать полным списком клиентов. Далее в рабочем процессе, если системе заказов понадобится больше информации о конкретном клиенте, например сведения о кредитной карте или текущий адрес доставки, я смогу задействовать другие механизмы, такие как вызов сервиса, чтобы получить нужные данные. Однако здесь я больше ничего не буду говорить об этом рабочем процессе.
Взаимодействие с очередью сообщений
Система, позволяющая передавать и получать сообщения асинхронно, называется шиной событий (event bus). Шина событий содержит инфраструктуру для хранения сообщений и их предоставления всем, кому нужно их получить. Она также предоставляет API для взаимодействия с ней. Я сосредоточусь на одной конкретной реализации, которую я сочла довольно простой для новичков в таком стиле взаимодействия: очереди сообщений. Выбор очередей сообщений достаточно большой. В учебном курсе «DDD Fundamentals» на Pluralsight.com Смит и я предпочли использовать в качестве очереди сообщений SQL Server Service Broker. Поскольку мы оба работаем с SQL Server, так нам было проще подготовить все необходимое и оставалось лишь написать SQL-код для передачи сообщений в очередь и их извлечения из нее.
В процессе написания этой статьи я решила, что мне пора научиться использовать одну из более популярных очередей сообщений с открытым исходным кодом — RabbitMQ. Это требует установки на компьютер сервера RabbitMQ (и Erlang!), а также запуска RabbitMQ .NET Client, чтобы можно было легко кодировать свое приложение с его применением. Узнать больше о RabbitMQ можно на сайте rabbitmq.com. Я также сочла очень полезными учебные курсы по RabbitMQ для .NET-разработчиков на Pluralsight.com.
Следовательно, в системе A имеется какой-то механизм для отправки сообщений серверу RabbitMQ. Но система B (система заказов) никак не участвует в этом взаимодействии. Она просто ожидает, что в базе данных имеется список клиентов, и ее не волнует, как он туда попадает. Отдельная небольшая Windows-служба будет проверять очередь RabbitMQ на предмет появления сообщений и соответствующего обновления базы данных системы заказов. Схема всего рабочего процесса показана на рис. 1.
Увеличить
Рис. 1. Очередь сообщений позволяет разделенным системам обмениваться сообщениями (в нашем примере обновлять базу данных системы System B)
System A Customer Maintenance | Система A Поддержание информации о клиентах |
New Customer | Новый клиент |
Fix Customer Name | Правка имени клиента |
Message Queue | Очередь сообщений |
Windows Service | Windows-служба |
System B Order Management | Управление заказами в системе B |
Other Bounded Context | Другой связанный контекст |
Other Service | Другой сервис |
Other App | Другое приложение |
Отправка сообщений в очередь
Начну с класса Customer в системе A (рис. 2). Для упрощения примера этот класс содержит всего несколько свойств: ID, Name, источник клиента и некоторые даты регистрации. Следуя шаблонам DDD, объект имеет встроенные ограничения, предотвращающие случайное редактирование. Вы создаете новый объект клиента, используя метод фабрики Create. Если вам нужно исправить имя, вы вызываете метод FixName.
Рис. 2. Класс Customer в контексте, связанном с поддержанием информации о клиентах
public static Customer Create(string name, string source) {
return new Customer(name, source);
}
private Customer(string name, string source){
Id = Guid.NewGuid();
Name = name;
InitialDate = DateTime.UtcNow;
ModifiedDate = DateTime.UtcNow;
Source = source;
PublishEvent (true);
}
public Guid Id { get; private set; }
public string Name { get; private set; }
public DateTime InitialDate { get; private set; }
public DateTime ModifiedDate { get; private set; }
public String Source { get; private set; }
public void FixName(string newName){
Name = newName;
ModifiedDate = DateTime.UtcNow;
PublishEvent (false);
}
private void PublishEvent(bool isNew){
var dto = CustomerDto.Create(Id, Name);
DomainEvents.Raise(new CustomerUpdatedEvent(dto, isNew));
}}
Заметьте, что и конструктор, и метод FixName вызывают метод PublishEvent, который в свою очередь создает простой CustomerDto (со свойствами Id и Name), а затем использует класс DomainEvents из статьи Уди Дахана (Udi Dahan) «Employing the Domain Model Pattern» (msdn.microsoft.com/magazine/ee236415), чтобы сгенерировать новое событие CustomerUpdatedEvent (рис. 3). В своем примере я публикую событие в ответ на простые операции. В настоящей реализации вы скорее всего будете публиковать эти события после успешного сохранения данных в базе данных системы A.
Рис. 3. Класс, инкапсулирующий событие обновления клиента
public class CustomerUpdatedEvent : IApplicationEvent{
public CustomerUpdatedEvent(CustomerDto customer,
bool isNew) : this(){
Customer = customer;
IsNew = isNew;
}
public CustomerUpdatedEvent()
{
DateTimeEventOccurred = DateTime.Now;
}
public CustomerDto Customer { get; private set; }
public bool IsNew { get; private set; }
public DateTime DateTimeEventOccurred { get; set; }
public string EventType{
get { return "CustomerUpdatedEvent"; }
}}
CustomerUpdatedEvent обертывает все, что мне нужно от этого события: CustomerDto вместе с флагом, указывающим, является ли клиент новым. Кроме того, включаются метаданные, которые понадобятся универсальному обработчику событий.
CustomerUpdatedEvent может быть обработан одним или более обработчиками, определяемыми в приложении, но я определила только один обработчик — сервис с именем CustomerUpdatedService:
public class CustomerUpdatedService : IHandle<CustomerUpdatedEvent>
{
private readonly IMessagePublisher _messagePublisher;
public CustomerUpdatedService(IMessagePublisher messagePublisher){
_messagePublisher = messagePublisher;
}
public void Handle(CustomerUpdatedEvent customerUpdatedEvent){
_messagePublisher.Publish(customerUpdatedEvent);
}}
Этот сервис будет обрабатывать все экземпляры CustomerUpdatedEvent, генерируемые в моем коде, который использует указанный публикатор сообщения для публикации события. Здесь я не указала публикатор; я просто ссылалась на некую абстракцию — IMessagePublisher. Я применяю шаблон IoC, обеспечивающий свободное связывание моей логики. Но я женщина переменчивая. Сегодня мне нужен один публикатор сообщений, а завтра предпочту другой. Поэтому я задействовала StructureMap (structuremap.net) — популярное среди .NET-разработчиков средство управления IoC в .NET-приложениях. StructureMap позволяет указывать, где найти классы, которые обрабатывают события, генерируемые DomainEvents.Raise. Автор StructureMap, Джереми Миллер (Jeremy Miller), написал отличный цикл статей в журнале «MSDN Magazine» под названием «Patterns in Practice», относящихся к шаблонам, которые применяются в этом примере (bit.ly/1ltTgTw). С помощью StructureMap я сконфигурировала свое приложение так, чтобы оно, видя IMessagePublisher, использовало конкретный класс, RabbitMQMessagePublisher; его логика представлена ниже:
public class RabbitMqMessagePublisher : IMessagePublisher{
public void Publish(Shared.Interfaces.IApplicationEvent applicationEvent) {
var factory = new ConnectionFactory();
IConnection conn = factory.CreateConnection();
using (IModel channel = conn.CreateModel()) {
[код, определяющий канал RabbitMQ]
string json = JsonConvert.SerializeObject(applicationEvent, Formatting.None);
byte[] messageBodyBytes = System.Text.Encoding.UTF8.GetBytes(json);
channel.BasicPublish("CustomerUpdate", "", props, messageBodyBytes);
}}}
Заметьте, что я удалила ряд строк кода, специфичного для конфигурирования RabbitMQ. Полный исходный код вы найдете по ссылке msdn.microsoft.com/magazine/msdnmag1014.
Суть этого метода в том, что он публикует JSON-представление объекта события в очереди. Вот как выглядит эта строка, когда я добавляю нового клиента — Julie Lerman:
{
"Customer":
{"CustomerId":"a9c8b56f-6112-42da-9411-511b1a05d814",
"ClientName":"Julie Lerman"},
"IsNew":true,
"DateTimeEventOccurred":"2014-07-22T13:46:09.6661355-04:00",
"EventType":"CustomerUpdatedEvent"
}
В приложении-примере я использую набор тестов, заставляющих публиковать сообщения в очереди, как показано на рис. 4. Вместо создания тестов, проверяющих очередь, я просто перехожу в RabbitMQ Manager на своем компьютере и использую его инструментарий. Заметьте, что в конструкторе теста инициализируется класс IoC. Именно здесь я конфигурирую StructureMap, чтобы подключить IMessagePublisher и обработчики событий.
Рис. 4. Публикация в RabbitMq в тестах
[TestClass]
public class PublishToRabbitMqTests
{
public PublishToRabbitMqTests()
{IoC.Initialize();
}
[TestMethod]
public void CanInsertNewCustomer()
{
var customer = Customer.Create("Julie Lerman",
"Friend Referral");
Assert.Inconclusive("Check RabbitMQ Manager for a message re this event");
}
[TestMethod]
public void CanUpdateCustomer() {
var customer = Customer.Create("Julie Lerman",
"Friend Referral");
customer.FixName("Sampson");
Assert.Inconclusive("Check RabbitMQ Manager for 2 messages re these events");
}}
Извлечение сообщения и обновление базы данных в системе заказов
Сообщение хранится на сервере RabbitMQ, пока не будет извлечено. И эта задача выполняется Windows-службой, работающей постоянно; она периодически опрашивает очередь на предмет появления новых сообщений. Увидев сообщение, эта служба извлекает его и обрабатывает. Сообщение также может быть обработано другими подписчиками. Здесь для упрощения я создала консольное приложение, а не Windows-службу. Это позволяет мне легко запускать и отлаживать такой «сервис» из Visual Studio в процессе обучения. В следующий раз я, возможно, присмотрюсь к Microsoft Azure WebJobs (bit.ly/1l3PTYH) вместо возни с Windows-службой или использования своего консольного приложения.
Сервис использует шаблоны генерации событий, похожие на класс DomainEvents Дахана: реагирует на события в классе обработчика и инициализирует класс IoC, который с помощью StructureMap находит обработчики событий.
Сервис прослушивает сообщения в RabbitMQ, применяя класс RabbitMQ .NET Client Subscription. Соответствующую логику вы увидите в методе Poll (см. ниже), где объект _subscription продолжает прослушивать сообщения. Всякий раз, когда извлекается сообщение, он десериализует сохраненный мной в очереди JSON обратно в CustomerUpdatedEvent, а затем генерирует событие:
private void Poll() {
while (Enabled) {
var deliveryArgs = _subscription.Next();
var message = Encoding.Default.GetString(deliveryArgs.Body);
var customerUpdatedEvent =
JsonConvert.DeserializeObject<CustomerUpdatedEvent>(message);
DomainEvents.Raise(customerUpdatedEvent);
}}
Сервис содержит единственный класс, Customer:
public class Customer{
public Guid CustomerId { get; set; }
public string ClientName { get; set; }
}
При десериализации CustomerUpdatedEvent его свойство Customer (изначально заполненное CustomerDto в системе управления клиентами) десериализуется в объект Customer этого сервиса.
Самое интересное в сервисе то, что происходит со сгенерированным событием. Вот класс, CustomerUpdatedHandler, который обрабатывает событие:
public class CustomerUpdatedHandler : IHandle<CustomerUpdatedEvent>{
public void Handle(CustomerUpdatedEvent customerUpdatedEvent){
var customer = customerUpdatedEvent.Customer;
using (var repo = new SimpleRepo()){
if (customerUpdatedEvent.IsNew){
repo.InsertCustomer(customer);
}
else{
repo.UpdateCustomer(customer);
}}}}
Этот сервис использует Entity Framework (EF) для взаимодействия с базой данных. На рис. 5 показано, что операции взаимодействия инкапсулированы в два метода репозитария: InsertCustomer и UpdateCustomer. Если свойство IsNew события равно true, сервис вызывает метод InsertCustomer репозитария. В ином случае он вызывает метод UpdateCustomer.
Рис. 5. Методы InsertCustomer и UpdateCustomer
public void InsertCustomer(Customer customer){
using (var context = new CustomersContext()){
context.Customers.Add(customer);
context.SaveChanges();
}}
public void UpdateCustomer(Customer customer){
using (var context = new CustomersContext()){
var pId = new SqlParameter("@Id", customer.CustomerId);
var pName = new SqlParameter("@Name", customer.ClientName);
context.Database.ExecuteSqlCommand
("exec ReplaceCustomer {0}, {1}",
customer.CustomerId, customer.ClientName);
}}
Эти методы выполняют релевантную логику, используя EF DbContext. При вставке он добавляет клиента, а затем вызывает SaveChanges. EF выполнит команду вставки в базу данных. В случае обновления он передаст CustomerID и CustomerName хранимой процедуре, использующей любую логику, определенную мной или администратором баз данных.
Таким образом, сервис выполняет необходимую работу в базе данных, чтобы список Customers в системе заказов всегда был актуальным и соответствовал тому, который имеется в системе управления клиентами.
Да, это уйма уровней и фрагментов головоломки!
Поскольку я использовала столь простой пример для демонстрации этого рабочего процесса, вы можете подумать, что данное решение напоминает стрельбу из пушки по воробьям. Но вспомните: суть в том, как реализуется управление рабочим процессом при применении методик DDD к решению сложных задач. Сосредоточившись на предметной области управления клиентами, я проигнорировала другие системы. Используя абстракции с IoC, обработчиками и очередями сообщений, я могу удовлетворить потребности внешних систем без длительного разбирательства в самой предметной области. Класс Customer просто генерирует событие. В случае данной демонстрации здесь легче всего сделать рабочий процесс имеющим смысл читателям, но, возможно, он уже слишком замутнен для вашей предметной области. Вы определенно можете генерировать событие из другого места в своем приложении, например из репозитария, поскольку он будет отправлять изменения в собственное хранилище данных.
В пакете кода, сопутствующем этой статье, применяется RabbitMQ, а это требует установки его облегченного сервера с открытым исходным кодом на ваш компьютер. Ссылки на скачивание я включила в файл ReadMe. Кроме того, я опубликую короткий видеоролик в своем блоге на thedatafarm.com, чтобы вы наглядно увидели, как я пошагово прохожу код, изучаю RabbitMQ Manager и анализирую базу данных, чтобы понять результаты.