Domain Event — одна из идей Domain Driven Design. Как только вы освоитесь с этой техникой, вы больше не захотите иметь дело без нее. Итак, в этой статье я покажу вам пример разработки приложения. Мы делаем процесс шаг за шагом по мере поступления новых требований. Это даст нам четкое представление о ценности событий предметной области.

Наш стек — это Java 11 + Spring Boot + Hibernate.

Предположим, мы создаем сервис по продаже книг. Авторы могут выставлять свои книги на продажу, а покупатели могут их покупать.

Определим основные хозяйствующие субъекты. Сам Book.

И BookSale.

Экземпляр Book имеет имя, автора, дату создания, дату последнего обновления, цену и список всех продаж.

Для простоты мы предполагаем, что у книг один автор и все цены указаны в одной валюте.

Хорошо, минимальная модель домена обоснована. Пришло время реализовать бизнес-требования.

1. Каждая продажа книги должна быть зарегистрирована

Вот и вся идея нашей системы.

Вот первая попытка.

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

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

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

2. Автор должен быть уведомлен о каждой 100 проданной книге.

Мы хотим сообщить автору, что его книги продаются.

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

Первое, что появляется, это то, что транзакция все еще обрабатывается, когда вызывается метод emailService.send. Во-первых, это штраф за производительность. Во-вторых, существует вероятность того, что транзакция в конце концов будет отброшена. В этом случае мы не хотим отправлять электронные письма.

Мы можем исправить это, применив программные транзакции.

Но остается одна проблема. Такой подход нарушает принцип единой ответственности (SRP) и принцип открытости-закрытости (OCP). Лучшим вариантом является Шаблон декоратора.

EmailNotifierBookSaleService внедряет BookSaleService интерфейс. В производственной среде это будет реализация BookSaleServiceImpl (на нее указывает аннотация квалификатора). Но в тестовой среде мы могли бы использовать заглушку или макет.

Так намного лучше выглядит. Функциональность разделена между двумя сервисами. И каждый из них можно протестировать индивидуально.

3. Каждое обновление книги должно быть заархивировано

Аналитики решили, что каждое возможное обновление книги (включая продажу книг) должно быть заархивировано. Вот объект BookArchive.

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

Следует указать на некоторые важные детали.
Метод sellBook заключен в @Transactional. Причина в том, что архивная запись должна быть создана в той же транзакции, что и сам BookSale. Если основная операция не удалась, мы не хотим хранить какие-либо архивы.

Метод bookRepository.findById(id) вызывается два раза во время выполнения. Но так как транзакция одна, Hibernate возвращает кэшированный экземпляр из контекста персистентности при втором вызове. Таким образом, дополнительных обращений к базе данных не требуется.

Вторая точка - @ActualBookSaleServiceQualifier. EmailNotifierBookSaleService не запускает никаких транзакций. Это означает, что origin должен быть типа BookSaleServiceImpl. Поэтому мы должны отредактировать EmailNotifierBookSaleService, чтобы не вводить BookSaleServiceImpl дважды.

Итак, вот схема текущего процесса.

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

Это кажется немного сложным, не так ли? Что ж, это еще не конец.

4. Администраторы должны иметь возможность обновлять название и описание книги. Авторы должны быть уведомлены о каждом обновлении по электронной почте

Это тоже имеет смысл. Например, могут быть опечатки. Требование можно разделить на три различные функции:

  1. Обновление информации о книге
  2. Архивирование книг
  3. Уведомление по электронной почте

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

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

Итак, какое решение лучше? Это момент, когда вступают в действие события предметной области. Но сначала нам нужно провести некоторый рефакторинг.

Модель предметной области без анемии

Какие запросы у нас есть на данный момент? Только двое из них. Заявка на продажу книги и обновление ее информации. Давайте немного перепишем сущность Book.

Хочу обратить внимание на методы sell и changeInfo. Первый регистрирует продажу новой книги. А второй обновляет название и описание книги.

Кажется, пока ничего не изменилось. Мы просто объединили функциональность, которую можно было бы реализовать через вызов сеттеров. Ну, это правда. Но давайте двигаться вперед. Теперь давайте реорганизуем файл BookSaleServiceImpl.

Код больше не выглядит как список команд. Экономическое обоснование теперь прозрачно. Кроме того, метод Book.sell может повторно использоваться в разных службах приложений. Но бизнес-правила остаются прежними.

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

Представляем доменные события

Теперь давайте перейдем к делу об архивировании книг. Что, если бы каждое обновление книги публиковало событие, которое запускало бы архивирование книги? Что ж, Spring имеет ApplicationEventPublisher bean-компонент, который позволяет публиковать события и подписываться на них по @EventListener использованию.

Хотя это помогает нам разделить процесс продажи и архивирования, оно также заставляет нас не забывать публиковать BookUpdated при любом изменении книги.

Мы могли бы предоставить ApplicationEventPublisher в качестве делегата для методов обновления.

Так-то лучше. Но в любом случае мы должны внедрить этот экземпляр ApplicationEventPublisher в каждую службу, которая так или иначе взаимодействует с Book.

Есть ли лучшее решение? Конечно. Обними @DomainEvents.

@DomainEvents

Каждый раз, когда клиент вызывает метод sell или changeInfo, событие BookUpdated добавляется в список domainEvents. Как вы можете догадаться, прямой публикации нет. Итак, как события попадают к прослушивателям событий? Когда мы вызываем метод Repository.save, Spring собирает события, ища аннотацию @DomainEvents. Затем выполняется очистка (@AfterDomainEventPublication).

Мы могли бы упростить его. Spring предоставляет класс AbstractAggregateRoot, который уже содержит необходимый функционал. Итак, это менее подробный вариант.

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

Итак, правильный способ — объявить события BookSold и BookChangedInfo.

Захват событий

Аннотация @EventListener — это простой и удобный способ отслеживать события Spring. Но есть предостережение. Нам не нужно просто фиксировать события. Мы хотим, чтобы слушатели вызывались в определенные моменты жизненного цикла транзакции.

Например, архивирование должно выполняться непосредственно перед фиксацией транзакции. Если что-то пойдет не так с основным запросом или самим архивированием, придется откатывать всю транзакцию.

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

@EventListener аннотация недостаточно мощна, чтобы удовлетворить наши потребности. Но не беспокойтесь. @TransactionalEventListener на помощь!

Разница в том, что аннотация предоставляет атрибут phase. Он объявляет точку жизненного цикла транзакции, когда должен быть вызван наш слушатель. Возможны четыре значения.

  1. BEFORE_COMMIT
  2. AFTER_COMMIT - по умолчанию
  3. AFTER_ROLLBACK
  4. AFTER_COMPLETION

Первые три варианта говорят сами за себя. AFTER_COMPLETION — это комбинация AFTER_ROLLBACK и AFTER_COMMIT.

Например, так может быть реализовано архивирование книг.

BookArchive.createNew только что инкапсулировал логику создания нового экземпляра BookArchive, описанную ранее.

Видеть? Кусок пирога! Захват BookChangedInfo и BookSold будет аналогичным.

О @TransactionalEventListener есть важная деталь. Иногда вам нужно вызвать команды в новой транзакции на фазе AFTER_COMMIT. Если это так, убедитесь, что вы также поставили @Transactional(propagation = REQUIRES_NEW). Параметр REQUIRES_NEW имеет решающее значение. Потому что может быть шанс, что ресурсы предыдущей транзакции еще не были очищены. Итак, мы должны убедиться, что Spring запустит новый.

И теперь мы можем избавиться от этих декораторов. Итак, вот сравнение между первой настройкой и окончательной архитектурой.

Первая попытка

Окончательная архитектура

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

Кстати, вам, вероятно, не нужны Spring Data и Hibernate, если вы применяете шаблон Transaction Script. Поскольку все бизнес-правила привязаны к службам, Hibernate принесет накладные расходы и не так много преимуществ. Вместо этого вы можете попробовать использовать JDBI, JOOQ или даже обычный JDBC.

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

Заключение

На мой взгляд, Hibernate в сочетании с Spring Data предназначен для использования с событиями домена. Преимущества того стоят. Мне любопытно, как вы применяете настойчивость в своем проекте? Вы предпочитаете анемическую или богатую модель предметной области? Пожалуйста, оставьте свои комментарии ниже. Спасибо за прочтение!

Ресурсы

  1. Доменное событие
  2. Дизайн, ориентированный на предметную область
  3. Модель анемичной области
  4. Программные транзакции
  5. Принцип единой ответственности (SRP)
  6. Принцип открыт-закрыт (OCP)
  7. Выкройка декоратора
  8. Аннотация весеннего отборочного турнира
  9. Спящий кэш первого уровня
  10. Слушатель весенних событий
  11. Весенние Доменные События
  12. Весеннее распространение транзакций и изоляция
  13. Шаблон сценария транзакции
  14. ЖДБИ
  15. ЖУОК
  16. Шаблон CQRS