Если правильно понимаю, то что-то где-то потеряться может, если используется внешний брокер и есть вызовы по сети. Защиту от потерь обычно на себя берет брокер сообщений. На принимающей стороне для этого можно сделать универсальное решение с идемпотентностью.
Тут два момента:
Решение защищающее от потерь сообщений не влияет на сложность поиска обработчиков. То есть код от этого решения не должен становиться запутанее.
Блокирующие вызовы по сети не имеют защиты от потерь. Это обычно приводят в качестве преимуществ и обычно это и является веским основанием использовать очереди.
Если, например, без событий, то у вас есть условно класс A1, который вызывает метод класса B1. С помощью IDE вы переходите в класс B1.
А если у вас отправляется событие, то у вас класс A1 отправляет событие E1. Класс B1 обрабатывает событие E1. То есть по классу события E1 или по названию топика вы можете найти класс B1.
Если мы, например, говорим про kafka, то для масштабирования есть partition. В качестве ключа партиции указываем id заказа. Тогда заказы обрабатываются параллельно, но для одного заказа порядок всегда фиксированный. Это позволяет машстабироваться линейно.
Скажите, как при тестировании вы поступаете с кафкой между несколькими тестами? Перезапускаете ли её между каждым тестом или каким-то иным способом приводите кафку к тестируемому состоянию (т.е. как происходит teardown для kafka)? Вопрос подразумевает не несколько отдельных запусков, а случай когда есть несколько интеграционных тестов, задействующих кафку.
Технически АОП можно реализовать через прокси или декоратор или даже кодогенератор, возможностей довольно много практически в любом языке. Аспекты можно поместить в любое место используя инверсию зависимостей и DI.
Выше я имел ввиду не способ реализации АОП, а именно эффект от его использования. Т.е. неважно как реализован АОП, он будет работать как декоратор, то есть добавлять логику перед и после метода.
дать явный доступ к TransactionManager и LockManager, если задача встречается редко;
Вот этого очень хочется избежать, т.к. если интерфейсы TransactionManager или LockManager заберутся в core модуль, то потом нужно ещё постоянно контролировать их использование. В этом смысле проще не допустить их использования вообще в core модуле.
---
Предлагаю рассмотреть интересную аналогию
Если к примеру взять задачу исключительно по блокировкам, то решениям с декоратором вы можете сопоставить блок synchronize {} в java, причем который вы можете добавлять только на метод целиком. Т.е. на самом деле даже получается не блок synchronize, а именно ключевое слово дополнительное к сигнатуре метода.
А решениям с наблюдателями, можно сопоставить использование ReentrantLock, которые позволяют в любом месте захватить блокировку и отпустить.
Да вы правы, для подобных задач можно использовать AOP, который является одной из возможных реализацией паттерна декоратор в java. О том, что паттерн декоратор предпочтительнее для случаев, когда есть логика в начале метода и в конце, я постарался ответить в коментариях выше . Также о том как планируется рассказать об этом в следующей части я постарался ответить тут .
Важно отметить, что статью я стараюсь позиционировать как независимую от языка программирования, поэтому инструменты подобные AOP не могу рассматривать в полной мере, т.к. не во всех языках есть механизмы AOP.
Выше я также старался указать, что считаю важным то, что в core отсутствуют интерфейсы подобные TransactionManager и LockManager и другие интерфейсы с методами begin/commit/lock, которые являются явно инфраструктурными.
То есть, например, если мы говорим про AOP, то чтобы ваш пример работал необходимо куда-то поместить аспекты, которые будут использовать TransactionManager и LockManager. Скорее всего, чтобы удовлетворить требованию выше, такие аспекты придётся разместить в модуле application. То есть тут я хочу сказать, что в таком случае мы все равно придём к примеру описанному тут , только с помощью аспектов. Но вместо явного декорирующего класса будут использованы аспекты.
И ещё раз хочу подметить, что все решения с декораторами (включая AOP) обладают одним общим недостатком. Они могут добавлять логику только в начале и конце метода. Т.е. всюду, где мы не можем задекорировать предлагается использовать наблюдателей.
В этом смысле получается, что паттерн наблюдатель более универсальное решение, чем декоратор для задачи внедрения произвольной логики, поэтому считаю, что его нельзя игнорировать. Иначе непонятно как решать случаи, когда необходимо сделать что-то инфраструктурное посередине бизнес-логики.
Ага, я вас понял. А если на любой HTTP запрос отправлять сообщение сразу в kafka и уже полностью асинхронно производить обработку. То есть заведомо приводить систему к кейсу, когда одновременная обработка базой и очередью возможна только тогда, когда исходным запросом тоже было сообщение
Ну вот получается, что тут я больше ссылался именно на второй кейс. Что отправка сообщений в kafka можно делать без CDC, используя всюду ID идемпотентности.
//метод getId возвращает ID, не который установлен брокером,
//а который установлен в коде, когда сообщение отправлялось.
var consumedMessage = consumer.consume();
//допустим есть таблица consumed_message c колонками
//id - id строки в БД (ни на что не влияет)
//consumed_message_id: UNIQUE INDEX - id обрабатываемого сообщения
//и есть таблица produced_message с колонками
//id - id строки в БД (ни на что не влияет)
//consumed_message_id - id обрабатываемого сообщения
//produced_message_id - id отправляемого сообщения
//produced_message_body: JSON - тело отправляемого сообщения
var messageWasConsumed =
select count(*) > 0
from consumed_message
where consumed_message_id = consumedMessage.getId();
//здесь сообщения которые необходимо отправить
List producedMessage = new ArrayList<>();
if (!messageWasConsumed) {
TRANSACTION BEGIN;
//здесь любая бизнесовая логика с сохранениями в БД
//и например в рамках этой логики необходимо отправить
//несколько сообщений, поэтому здесь происходит наполнение
//массива producedMessages
//с генерированными в коде id - producedMessage.getId()
//и с телом - producedMessage.getBody()
insert into consumed_message
(consumed_message_id)
values (consumedMessageId);
for(var producedMessage in producedMessages) {
insert into produced_message
(consmed_message_id, produced_message_id, produced_message_body)
values (consmedMessage.getId(), producedMessage.getId(), producedMessage.getBody());
}
TRANSACTOIN COMMIT;
} else {
producedMessages =
select produced_message_id, produced_message_body
from produced_message
where consumed_message_id = consumedMessage.getId();
}
for(var producedMessage in producedMessages) {
producer.produce(producedMessage);
}
consumer.ack(message);
Тут важно, что каждый консьюмер имеет точно такую же структуру. Разница только внутри блока с бизнес-логикой.
Кажется, что данная структура позволяет решить оба кейса. И прием сообщения из kafka с записью в БД (первый кейс), который у вас описан, и одновременно отправку в kafka и запись в БД (второй кейс) без использования CDC.
Скажите, что вы думаете по поводу следующего подхода?
Для всех сообщений в брокере затребовать, чтобы указывался id сообщения из кода приложения (это важный момент). Для всех потребителей сообщений сделать так, чтобы они реализовывали механизм идемпотентности относительно id сообщения.
При этом обработка любого сообщения будет следующей: выполнение всех бизнес действий под транзакцией БД, сохранение в той же БД для входящего сообщения для его ID все другие сообщения, которые необходимо отправить с фиксированным ID. Затем фиксация транзакции в БД. Затем отправка сообщений. Затем отправка acknowledge для исходного сообщения запустившего процесс.
При этом, если в самом начале для сообщения с заданным ID уже есть информация об обработке в БД, то действия для БД пропускаются, из БД извлекаются сообщения, которые должны были быть отправлены в брокер и они отправляются. Затем отправка acknowledge для исходного сообщения запустившего процесс.
Фокус тут в том, что если критерием идемпотентности является генерированный ID из кода, который сохраняется в БД, то в брокер могут попасть более одного сообщения с одинаковым ID, но т.к. каждый консьюмер умеет откидывать уже обработанные сообщения, то в случае попадание дубликата выполнение повторных действий не будет.
При этом обеспечивается гарантия, что если произошла фиксация данных в БД, то отправка из приложения точно когда-то произойдёт, возможно более одной.
Разница с использования ID сообщения из брокера или offset-а в качестве ключа идемпотентности в том, что для них повторная успешная отправка одного и того же сообщения со стороны producer-а не обладает идемпотентностью, т.к. для каждого успешно отправленного сообщения будет присвоен уникальный ID из брокера и offset.
Как вы считаете можно ли рассматривать данное решение как альтернативу CDC описанного вами второго случая?
Знаете, мне не очень интересно идти и перечитывать всю дискуссию под соусом "на самом деле я имел в виду совсем другое", особенно учитывая, что я несколько раз объяснял, что для "предложенных мной решений" критично, но вы это проигнорировали.
Вам и ненужно ничего перечитывать. Я протестовал конкретно против ваших решений. И подмечал, что мне не нравится. Конкретно для вас ничего не изменилось. Возможно, для других участников, которые, если начнут читать с середины, может быть непонятно.
Я понятия не имею, кто это.
Scott Millett автор книги Patterns, Principle, and Practices of Domain-Driven Design. У него книга значительно новее чем у Эванса и рассказывает подробнее. Учитывает такие подходы и механизмы как CQRS, Event Sourcing, микросервисы и т.д.
Это не добавление в БД. Это привязка изменений к unit of work, как я уже описывал выше, просто у Эванса она явная. Так что это не repository.
Повторюсь еще раз: на картинке из DDD бизнес-слой явно вызывает методы из Unit of Work Manager. Это то, против чего вы протестовали всю дорогу.
Вы не правы. Пойдём по порядку, я протестовал против явных интерфейсов TransactionManager и LockManager в core модуле. Затем вы предложили UoW и я пытался узнать у вас, как вы намереваетесь его использовать.
После вы ушли в сторону ConsistencyManager-а, и я вам заметил, что мне не нравятся любые решения, которые оставят TransactionManager и LockManager в core (тут). И также я против любых решений, которые работают с begin/commit внутри core модуля.
Затем, конечно, всюду я уже не уточнял свою позицию и обобщал, что против UoW и ConsistencyManager в core модуле. Но я отталкивался от предложенных вами решений. То есть мне было правильнее говорить, что я не против UoW и ConsistencyManager вообще, а против UoW и ConsistencyManager предложенных вами. Поэтому прошу рассматривать мои слова именно в контексте предложенных вами решений.
----
А вот например решение с UoW и ConsistencyManager, которое меня полностью устроит. Также оно выравнено со схемой, которую приводит Эванс. И также оно полностью подходит под определение того, что транзакция управляется вне core модуля и в core модуле нет интерфейсов TransactionManager.
//Core module
interface UoW {
void add(Order order);
}
//Application module
class ConsistencyManager {
TransactionManager tm;
UoW begin() {
var transactionScope = tm.begin();
return new UoWImpl(transactionScope);
}
void commit(UoW uow) {
((UoWImpl) uow).getTransactionScope().commit();
}
}
//Application module
class OrderAppService {
ConsistencyManager consistencyManager;
OrderService coreService;
void create(CreateOrderRequest request) {
var uow = consistencyManager.begin();
coreService.create(request, uow);
consistencyManager.commit(uow);
}
}
//Core Module
class OrderService {
UserRepository userRepository;
ProductRepository productRepositor;
void create(CreateOrderRequest request, UoW uow) {
var user = userRepositor.find();
var product = productRepository.find();
var order = new Order(user, product);
uow.add(order);
}
}
---
Предлагаю подумать почему в DDD используется именно UoW. В DDD основным носителем бизнес-логики являются агрегаты (объекты с данными), которые ещё и восстанавливаются из репозиториев. Т.е. дизайн таких классов плохо сочетается с набором полей из репозиториев. Потому как поля там это данные.
Для дизайна таких классов крайне удобно принимать в качестве дополнительного параметра в доменные методы именно UoW, т.к. он может выступать фасадом для всех отложенных изменений без необходимости внедрять весь список доступных репозиториев.
Но ещё подумайте вот о чем. Раз носителем бизнес операций в DDD являются агрегаты, то там архитектурно невозможно начать транзакцию в ядре. К тому же ещё и агрегат выступает как граница синхронизации, если мы говорим про конкурентность.
Т.е. чтобы начать какую-либо бизнес-операцию необходимо сначала открыть транзакцию вне ядра, затем получить из репозитория агрегат, а затем уже у агрегата запустить бизнес-логику.
Но для анимичной модели таких ограничений нет. Что вы собственно и продемонстрировали предлагая открывать транзакцию в core модуле.
---
Сегодня я ещё и посмотрел как Миллетт определяет ответственность слоев.
Часть 2. Глава 8. Application Architecture. Раздел: Dependency Inversion
Of course, the state of domain objects needs to be saved to some kind of persistence store. To achieve this without coupling the domain layer to technical code, the application layer defines an interface that enables domain objects to be hydrated and persisted. This interface is written from the perspective of the application layer and in a language and style it understands free from specific frameworks or technical jargon. The infrastructural layers then implement and adapt to these interfaces, thus giving the dependency that the lower layers need without coupling. Transaction management along with cross-cutting concerns such as security and logging are provided in the same manner. Figure 8-2 shows the direction of dependencies and the direction of interfaces that describe the relationship between the application layer and technical layers.
Но вы посвящаете этому "редкому кейсу" статью, причем еще и продолжая отстаивать неудачный пример.
Пример неудачный с вашей точки зрения. Вы же не знаете как я поведу статью. Мне для перехода к описанию application layer нужен был пример, который в него отлично впишется. Это и будет TransactionManager. Также TransactionManager был удобен, чтобы показать все возможные подходы для работы с наблюдателем и контекстом.
Я попробовал поискать, и в DDD нет ни одного упоминания Unit of Work, и в индексе его нет. Вы можете привести цитату?
Цитату вряд ли могу привести. Там схема как Эванс распределяет ответственность по слоям. Не знаю можно ли давать ссылку на книгу в хабре, поэтому опишу способ поиска.
Запрос в гугле: "эрик эванс предметно-ориентированное проектирование pdf", первая ссылка.
Часть 2. Глава 4. Изоляция предметной области. 83 страница, рис. 4.1.
Там у него пример следующего типа:
Класс FundsTransferService из application слоя вызывает что-то из инфраструктурного слоя методом beginTransaction();
Затем есть бизнес-логика, внутри доменных сущностей класса Account которые в ходе выполнения вызывают что-то из инфраструктурного слоя методом addToUnitOfWork.
Затем FundsTransferService из application слоя вызывает что-то из инфраструктурного слоя с методом commit();
----
То есть у Эванса начало и коммит транзакции происходит в application слое. Непонятно правда как по классам распределены эти методы
А добавление в БД через методы как-то связанные с UoW. Если UoW без методов begin и commit, то он выглядит как аналог интерфейса Repository.
Надо у Миллетта посмотреть у него примеры более подробные.
… и это, в общем-то, противоречит DDD, раз уж вы к нему аппелируете. Потому что посреди бизнес-метода у вас может быть только бизнес-событие, а не абстрактный вызов наблюдателя "потому что нам надо вызвать инфраструктурное".
К DDD не апеллирую, у меня и модели в статье анемичные, но на распределение ответственности по слоям core/application/infrastructure это вряд ли влияет. Скорее на форму внутри core модуля.
Если вы утверждаете, что наблюдатели добавляют определенные проблемы, то я с вами согласен. Выше постарался ответить, что все приведенные вами решения предпочтительнее, чем наблюдатели, если мы используем дополнительный модуль application, который избавляет от необходимости вносить TM внутрь модуля core.
Но у всех этих решений есть недостаток, они добавляют логику только в рамках паттерна декоратор т.е. в начале и конце метода. Поэтому моя позиция в том, чтобы всюду где возможно использовать решения с декоратором, и только в исключительных и редких случаях использовать наблюдателей, чтобы не добавлять TM и им подобные в core.
Именно так, расходимся, и нет никакого способа формально определить, кто прав. Но вся ваша архитектура с наблюдателями, со всеми ее недостатками, вырастает исключительно из вашего нежелания иметь в core-модуле типовой интерфейс (хотя при этом интерфейс Repository вы в нем иметь согласны).
Хочу также подчеркнуть, что у меня нет цели предложить все строить на наблюдателях. В целевом решении наблюдатели редкий кейс.
Например, предлагаемые решения @Throwableчерез AOP, использование из фреймворка аннотации Transactional, лямбду в transactional - это все разные формы декоратора.
Ваши предположения о том, что в основном необходимо выполнять действия в начале и конце метода, чтобы использовать UoM, тоже выравниваются с декоратором. Кстати если не ошибаюсь даже у Эванса в DDD указано, что UoM находится на уровне application layer.
С этим нет никаких противоречий. Я сам с этим согласен и сообщаю об этом в статье. Но есть редкие кейсы, которые могут заставить сделать что-то "инфраструктурное" посередине метода. И только в этом случае предлагается использовать наблюдателей.
В статье на данном этапе сообщается как это сделать. О том что не нужно для TM городить всюду наблюдателей я прямым текстом сообщаю в статье, чтобы меня не поняли буквально.
Отсюда также и вывод, что шанс возникновения всех обозначенных проблем крайне мал. Чтобы понадобилось для одного места более одного наблюдателя, со связями и порядком выполнения, с исключениями и обработкой.
Если мы исходим из предположения о том, что TM, UoM и им подобные не должны быть в core, иногда чтобы это сделать придётся усложнить, решив тот редкий кейс. Мой опыт подсказывает, что это не превращается в ад с наблюдателями.
Тогда мы расходимся с вами во мнении допустимо или нет использование consistency manager в core модуле. Предлагаю тогда не начинать это спор повторно, каждый высказал свою позицию об этом ранее
Есть, понимаете ли, проблема. Каждый из обработчиков ничего не должен знать про другие обработчики. Т.е. падает у вас обработчик А, а обязательно выполнить надо обработчик Б. А обработчик Ц выполнять не надо. Если добавление обработчика Б требует изменения обработчика А, с архитектурой что-то пошло не так.
Так приведите пример кода без наблюдателей, который будет с одной стороны скрывать все инфраструктурные детали, а с другой стороны выполнять ваш кейс. И, мне кажется, у меня получится его переписать с архитектурными гарантиями не хуже чем то, что вы напишите.
Я понимаю о чем вы говорите, но как только вы скрываете A, B и С, вы делаете неявным управление. Только если соберётесь писать код, прошу не использовать methodA, methodB и т.д. Тут крайне важно название, потому что наблюдатель его меняет. Если под methodA кроется beginTransaction или ещё что-то подобное, то это неправильный пример.
Видите ли, форма ведения дискуссии совершенно никак не влияет на суть излагаемого и лишь усиливает стилистически эмоциональный окрас контраргументов. Что же касается сути статьи, то по моему скромному мнению, жесткое следование принципам любой отдельно взятой парадигмы отрицательно влияет на архитектуру вцелом и финальный код, кроме того приведенные кейсы мне кажутся слишком искусственными наивными, которые оставляют неосвещенным огромное количество связанны с ними проблем, которые обязательно возникнут в реальных системах, что не могло не вызвать у меня бурную эмоциональную реакцию.
Форма излагаемого влияет на восприятие и на желание с вами продолжать разговор. То что вы не можете держать свои эмоции в себе вас не оправдывает. Есть правила хабра, прошу их соблюдать.
События синронны или асинхронны?
Синхроны или асинхроны в каком смысле? Есть реактивные фреймворки типа Reactor они делают в одном смысле асихронность без потери контекста. Есть очереди сообщений, для которых обработки подразумевает потерю контекста. Предположу, что вы имели ввиду асинхронность подобную Reactor (без потери контекста). Тогда если это важно, то можно всех наблюдателей делать асинхронными, с последовательным вызовом после окончания очередного вызова.
Гарантируется ли детерминированный порядок выполнения (и какой)?
Гарантируется, всегда в порядке очередности в которой произошла подписка. Если это влияет на логику выполнения, то порядком можно управлять. Если мы говорим про Spring то для этого достаточно добавить аннотация `@Order` с цифрой.
Гарантируется ли доставка сообщения, если removeListener() или addListener() вызван внутри обработчика? В вашем примере поведение недетерминировано.
Верно, потому что в примерах нет необходимости это детерминировать. И в большинстве случаев их нет необходимости детерминировать.
Если это важно такие гарантии можно как дать так и нет. Но если мы говорим конкретно про решаемые задачи в статье, то таких проблем даже не будет возникать. Все сервисы singelton, привязываемые обработчики также singleton, привязка только в момент инициализации бинов.
Отсюда следствие: не будет необходимости отвязывать или привязывать наблюдателей внутри обработки. Также нет необходимости в removeListener. Возможно, такой кейс может возникнуть, но он крайне редкий.
Если один из хендлеров кинул исключение, гарантируется ли доставка сообщения другим хендлерам?
Если кидается исключение из обработчика, то для остальных обработчиков не доходит сообщение.
Если важно, чтобы после обработчика другие обработчики что-то выполнили, то внутри обработчика должен быть try-catch. То есть в этом смысле обработчик не прокинет сообщение выше.
Если вы считаете, что тут есть какие-то подводные камни, потому что на ваши общие проблемы у меня общий ответ, то прошу вас привести конкретный пример с кодом, который будет демонстрировать проблему, которую по вашему мнению нельзя будет решить с помощью наблюдателей. В свою очередь я постараюсь привести пример с наблюдателями.
Уже упомянутый: гарантируется ли вызов onEnd(), если обработчик обработал onStart(), а другой выкинул исключение?
Нет, следует из предыдущего ответа
Связанная проблема корректного shutdown-а: событие может послаться уже остановленному компоненту (проблема неразрешима, если есть циклические зависимости, которые может давать паттерн observer).
Да, циклические зависимости в шаблоне наблюдатель это проблема. Но они в большей степени справедливы для UI, где возможны циклические потоки данных. В статье описан подход в большей степени для бизнес-логики. В силу специфики области применения, там поток данных однонаправленный. От core в базу, от core в другой сервис, от core в очередь. Обозначенная проблема с цилкическими зависимостями очень редкий кейс. P.s. Кейс от core в core через наблюдателей исключается, т.к. наблюдатели не предлагается использовать для вызовов в одном и том же модуле.
А если учесть, что обработчики могут провоцировать повторные события от того же компонента, то здесь образуется еще целое поле недетерминированных поведенческих сценариев:
Если говорить в принципе про наблюдателей, то проблема есть. Но в контексте области применения её нет. Ответил выше.
------
Резюмируя ответы на ваш список вопросов Вы привели ряд вопросов, которые необходимо решить для наблюдателей. Но они достаточно тривиальны, если мы спускаемся на уровень области применения описанный в статье.
------
Относительно вашего примера с transactionManager
public void create(CreateOrderRequest request) {
// практически реальный код -- вся магия внутри TM, принцип DRY соблюден
// бизнес код защищен от некорректного использования и забывчивости/неумения программиста
transactionManager.transactional(() -> {
// some domain code
});
}
На самом деле вы привели ещё один пример с декоратором. Ранее вы говорили про использование AOP - тоже пример с декоратором. Предложенный мною ApplicationService тоже пример с декоратором. Противоречия с тем, что декоратор для TM это в большинстве случаев самое удачно решение, с этим у меня нет никаких противоречий. В статье об этом я также написал. Но проблема в том, что вы оставили его в core модуле.
Предположим, что мы хотим навесить более одного наблюдателя -- а кто нам мешает, раз уж дизайн позволяет? Например я хочу еще залогировать сие событие в audit таблицу в базе данных. Тогда сразу же встает проблема последовательности инициализации (которая есть неявная): если контекст фреймворком был собран так, что первый обработчик -- это TM, тогда все нормально -- второй запишет в audit таблицу уже в транзакции. Если же нет -- первым вызовется audit и упадет с ошибкой transaction required.
Ладно, вы создаете отдельный AuditService, где делаете свои обзерверы и добавляете тот же обзервер с транзакцией. А теперь мне нужно отослать данные только что созданного заказа (и еще клиента) удаленному rest-логгеру. Я вешаю обработчик на onEnd() и опять проблема с последовательностью вызовов: может случиться, что транзакция уже как бы закрыта, а мне нужно еще читать данные клиента. Или в другой транзакции будем читать?
Проблема с очередностью как уже несколько раз писал, решается одной аннотацией @Order с цифрой очередности. Если вы предлагали решение с AOP то также должны знать, что это проблема решается там аналогично.
Порядок обработки детерминирован и его можно задать без особой сложности.
Теперь позвольте мне сделать review вашего кода:
Позвольте мне сделать ревью вашего ревью. Ответы на ваши комментарии имеют дополнительный отступ и указываются после ваших комментариев.
class OrderService {
public void create(CreateOrderRequest request) {
// ремарка: это нужно будет копипейстить в любом компоненте, где требуется транзакция
try {
// если список обзерверов может меняетья динамически,
// то нужно делать дефенсивную копию
// new ArrayList<>(observers).forEach(...)
// Вы решаете несуществующую проблему, т.к. нет задачи
// по динамическому добавлению и удалению
// Более того для этого есть CopyOnWriteArrayList,
// поэтому приведенный код полностью валиден.
observers.forEach(observer -> observer.onStart());
//some domain code
// для обеспечения детерминированности вызовы onEnd()
// должны осуществляться в обратном порядке (LIFO)
// тот, кто первым открыл транзакцию, должен закрыть ее последней
// Наблюдатели не должны знать и решать проблему LIFO в явном виде
// Для этого используется механизм очередности в котором добавляются
// наблюдатели. Смотрите ответы выше про аннотацию @Order
// То что транзакции должны закрываться в обратном порядке, это деталь
// реализации, её нужно решать не здесь.
observers.forEach(observer -> observer.onEnd());
} catch (Exception e) {
// абсолютно нелогично вызывать onCreationFailed() для обработчиков,
// для которых не был вызван onStart() - они вообще ничего не должны знать о событии
// более того, для некоторых уже успел вызваться onEnd(), зачем им посылать onCreationFailed()?
// они уже освободили ресурсы и забыли про операцию
// Забыл добавить: onCreationFailed() тоже может выкинуть исключение,
// и в этом случае остальные обработчики не получат сообщение, что приведет
// к утечке ресурсов. Здесь нужно вызывать хендлер в try - catch и продолжать в случае ошибки.
// А после цепочки вызовов кинуть исключение с той ошибкой.
// Абсолютно логично, напишите код без наблюдателей, с конкретной реализацией
// на примере интерфейса transactionManager, который привел ранее.
// Про обработку ошибок написал выше. Смотрите, я свой же пример переписал
// с помошью наблюдателей, полностью сохранив все гаранти которые давал мой пример без
// наблюдателей. Приведите более сложный пример, и он аналогичным образом будет
// решен через наблюдателей.
// А про то, что вы забыли добавить. Если все обработчики должны выполнить свой код,
// то значит должен быть внутри try-catch. Вообще если вы знакомы с AOP то должны понимать,
// что обернув в конструкцию try catch и поставив обработку в начале, в конце и в случае исключения
// это полностью эквивалентно аспекту Around, и все что можно сделать с аспектом
// Around справедливо и для приведенной конструкции.
observers.forEach(observer -> observer.onCreationFailed(e));
}
}
}
// Ваш обработчик нереентерабелен (не позволяет корректно работать внутри уже созданной транзакции)
// Как надо было:
// Не было цели сделать точную копию TM. Да вы правы в конкретной реализации
// у меня есть ошибка для TM, но нет ошибки в используемом приеме с наблюдателями.
// То есть на суть использования приемов приведенных в статье это никак не влияет.
class CreateOrderObserverImpl {
// Нет гарантии, что это prototype, поэтому использует ThreadLocal
ThreadLocal<Boolean> isManagedByCurrent = new ThreadLocal<>();
public void onStart() {
if (!transactionManager.hasActive()) {
transactionManager.begin();
isManagedByCurrent.set(true);
}
}
public void onEnd() {
// если транзакция создалась выше, мы ее и не должны коммитить
if (isManagedByCurrent.get()) {
try {
transactionManager.commit();
} finally {
isManagedByCurrent.clear();
}
}
}
public void onCreationFailed(Exception e) {
if (isManagedByCurrent.get()) {
try {
transactionManager.rollback();
} finally {
isManagedByCurrent.clear();
}
}
}
}
------ Прошу меньше общих слов иначе мне придётся на них отвечать также общими словами и мы так можем продолжать очень долго. Если вы считаете, что данный подход можно легко опровергнуть, то вам достаточно привести конкретный пример с кодом без наблюдателей, который по вашему мнению переписать на использование наблюдателей будет невозможно.
Я же постараюсь его переписать на использование с наблюдателями. Это позволит также исключить проблемы, которые вы видите в реализации для TM. Т.к. все важные по вашему мнению аспекты будут представлены в исходном вами примере, которые я не буду игнорировать при реализации через наблюдателей.
Теперь я понимаю, почему многие авторы не пытаются отвечать на вопросы. Проблема тут не в размерах дискуссии, а в игнорировании правил этикета хабра. Отвечать на подобные посты 1, 2, 3 крайне неприятно
Если правильно понимаю, то что-то где-то потеряться может, если используется внешний брокер и есть вызовы по сети. Защиту от потерь обычно на себя берет брокер сообщений. На принимающей стороне для этого можно сделать универсальное решение с идемпотентностью.
Тут два момента:
Решение защищающее от потерь сообщений не влияет на сложность поиска обработчиков. То есть код от этого решения не должен становиться запутанее.
Блокирующие вызовы по сети не имеют защиты от потерь. Это обычно приводят в качестве преимуществ и обычно это и является веским основанием использовать очереди.
Если где-то возможна нежелательная гонка или возникает сложность в понимании процесса обработки, то в этом случае можно управлять процессом явно. см: https://www.enterpriseintegrationpatterns.com/patterns/messaging/ProcessManager.html
Подскажите, а чем вызвано усложнение отладки?
Если, например, без событий, то у вас есть условно класс A1, который вызывает метод класса B1. С помощью IDE вы переходите в класс B1.
А если у вас отправляется событие, то у вас класс A1 отправляет событие E1. Класс B1 обрабатывает событие E1. То есть по классу события E1 или по названию топика вы можете найти класс B1.
Если мы, например, говорим про kafka, то для масштабирования есть partition. В качестве ключа партиции указываем id заказа. Тогда заказы обрабатываются параллельно, но для одного заказа порядок всегда фиксированный. Это позволяет машстабироваться линейно.
Спасибо за статью!
Скажите, как при тестировании вы поступаете с кафкой между несколькими тестами? Перезапускаете ли её между каждым тестом или каким-то иным способом приводите кафку к тестируемому состоянию (т.е. как происходит teardown для kafka)? Вопрос подразумевает не несколько отдельных запусков, а случай когда есть несколько интеграционных тестов, задействующих кафку.
Выше я имел ввиду не способ реализации АОП, а именно эффект от его использования. Т.е. неважно как реализован АОП, он будет работать как декоратор, то есть добавлять логику перед и после метода.
Вот этого очень хочется избежать, т.к. если интерфейсы TransactionManager или LockManager заберутся в core модуль, то потом нужно ещё постоянно контролировать их использование. В этом смысле проще не допустить их использования вообще в core модуле.
---
Предлагаю рассмотреть интересную аналогию
Если к примеру взять задачу исключительно по блокировкам, то решениям с декоратором вы можете сопоставить блок synchronize {} в java, причем который вы можете добавлять только на метод целиком. Т.е. на самом деле даже получается не блок synchronize, а именно ключевое слово дополнительное к сигнатуре метода.
А решениям с наблюдателями, можно сопоставить использование ReentrantLock, которые позволяют в любом месте захватить блокировку и отпустить.
Да вы правы, для подобных задач можно использовать AOP, который является одной из возможных реализацией паттерна декоратор в java. О том, что паттерн декоратор предпочтительнее для случаев, когда есть логика в начале метода и в конце, я постарался ответить в коментариях выше . Также о том как планируется рассказать об этом в следующей части я постарался ответить тут .
Важно отметить, что статью я стараюсь позиционировать как независимую от языка программирования, поэтому инструменты подобные AOP не могу рассматривать в полной мере, т.к. не во всех языках есть механизмы AOP.
Выше я также старался указать, что считаю важным то, что в core отсутствуют интерфейсы подобные TransactionManager и LockManager и другие интерфейсы с методами begin/commit/lock, которые являются явно инфраструктурными.
То есть, например, если мы говорим про AOP, то чтобы ваш пример работал необходимо куда-то поместить аспекты, которые будут использовать TransactionManager и LockManager. Скорее всего, чтобы удовлетворить требованию выше, такие аспекты придётся разместить в модуле application. То есть тут я хочу сказать, что в таком случае мы все равно придём к примеру описанному тут , только с помощью аспектов. Но вместо явного декорирующего класса будут использованы аспекты.
И ещё раз хочу подметить, что все решения с декораторами (включая AOP) обладают одним общим недостатком. Они могут добавлять логику только в начале и конце метода. Т.е. всюду, где мы не можем задекорировать предлагается использовать наблюдателей.
В этом смысле получается, что паттерн наблюдатель более универсальное решение, чем декоратор для задачи внедрения произвольной логики, поэтому считаю, что его нельзя игнорировать. Иначе непонятно как решать случаи, когда необходимо сделать что-то инфраструктурное посередине бизнес-логики.
Теперь я вас полностью понял, спасибо)
Ага, я вас понял. А если на любой HTTP запрос отправлять сообщение сразу в kafka и уже полностью асинхронно производить обработку. То есть заведомо приводить систему к кейсу, когда одновременная обработка базой и очередью возможна только тогда, когда исходным запросом тоже было сообщение
Ну вот получается, что тут я больше ссылался именно на второй кейс. Что отправка сообщений в kafka можно делать без CDC, используя всюду ID идемпотентности.
Тут важно, что каждый консьюмер имеет точно такую же структуру. Разница только внутри блока с бизнес-логикой.
Кажется, что данная структура позволяет решить оба кейса. И прием сообщения из kafka с записью в БД (первый кейс), который у вас описан, и одновременно отправку в kafka и запись в БД (второй кейс) без использования CDC.
Скажите, что вы думаете по поводу следующего подхода?
Для всех сообщений в брокере затребовать, чтобы указывался id сообщения из кода приложения (это важный момент). Для всех потребителей сообщений сделать так, чтобы они реализовывали механизм идемпотентности относительно id сообщения.
При этом обработка любого сообщения будет следующей: выполнение всех бизнес действий под транзакцией БД, сохранение в той же БД для входящего сообщения для его ID все другие сообщения, которые необходимо отправить с фиксированным ID. Затем фиксация транзакции в БД. Затем отправка сообщений. Затем отправка acknowledge для исходного сообщения запустившего процесс.
При этом, если в самом начале для сообщения с заданным ID уже есть информация об обработке в БД, то действия для БД пропускаются, из БД извлекаются сообщения, которые должны были быть отправлены в брокер и они отправляются. Затем отправка acknowledge для исходного сообщения запустившего процесс.
Фокус тут в том, что если критерием идемпотентности является генерированный ID из кода, который сохраняется в БД, то в брокер могут попасть более одного сообщения с одинаковым ID, но т.к. каждый консьюмер умеет откидывать уже обработанные сообщения, то в случае попадание дубликата выполнение повторных действий не будет.
При этом обеспечивается гарантия, что если произошла фиксация данных в БД, то отправка из приложения точно когда-то произойдёт, возможно более одной.
Разница с использования ID сообщения из брокера или offset-а в качестве ключа идемпотентности в том, что для них повторная успешная отправка одного и того же сообщения со стороны producer-а не обладает идемпотентностью, т.к. для каждого успешно отправленного сообщения будет присвоен уникальный ID из брокера и offset.
Как вы считаете можно ли рассматривать данное решение как альтернативу CDC описанного вами второго случая?
Вам и ненужно ничего перечитывать. Я протестовал конкретно против ваших решений. И подмечал, что мне не нравится. Конкретно для вас ничего не изменилось. Возможно, для других участников, которые, если начнут читать с середины, может быть непонятно.
Scott Millett автор книги Patterns, Principle, and Practices of Domain-Driven Design. У него книга значительно новее чем у Эванса и рассказывает подробнее. Учитывает такие подходы и механизмы как CQRS, Event Sourcing, микросервисы и т.д.
Вы не правы. Пойдём по порядку, я протестовал против явных интерфейсов TransactionManager и LockManager в core модуле. Затем вы предложили UoW и я пытался узнать у вас, как вы намереваетесь его использовать.
После вы ушли в сторону ConsistencyManager-а, и я вам заметил, что мне не нравятся любые решения, которые оставят TransactionManager и LockManager в core (тут). И также я против любых решений, которые работают с begin/commit внутри core модуля.
Затем, конечно, всюду я уже не уточнял свою позицию и обобщал, что против UoW и ConsistencyManager в core модуле. Но я отталкивался от предложенных вами решений. То есть мне было правильнее говорить, что я не против UoW и ConsistencyManager вообще, а против UoW и ConsistencyManager предложенных вами. Поэтому прошу рассматривать мои слова именно в контексте предложенных вами решений.
----
А вот например решение с UoW и ConsistencyManager, которое меня полностью устроит. Также оно выравнено со схемой, которую приводит Эванс. И также оно полностью подходит под определение того, что транзакция управляется вне core модуля и в core модуле нет интерфейсов TransactionManager.
---
Предлагаю подумать почему в DDD используется именно UoW. В DDD основным носителем бизнес-логики являются агрегаты (объекты с данными), которые ещё и восстанавливаются из репозиториев. Т.е. дизайн таких классов плохо сочетается с набором полей из репозиториев. Потому как поля там это данные.
Для дизайна таких классов крайне удобно принимать в качестве дополнительного параметра в доменные методы именно UoW, т.к. он может выступать фасадом для всех отложенных изменений без необходимости внедрять весь список доступных репозиториев.
Но ещё подумайте вот о чем. Раз носителем бизнес операций в DDD являются агрегаты, то там архитектурно невозможно начать транзакцию в ядре. К тому же ещё и агрегат выступает как граница синхронизации, если мы говорим про конкурентность.
Т.е. чтобы начать какую-либо бизнес-операцию необходимо сначала открыть транзакцию вне ядра, затем получить из репозитория агрегат, а затем уже у агрегата запустить бизнес-логику.
Но для анимичной модели таких ограничений нет. Что вы собственно и продемонстрировали предлагая открывать транзакцию в core модуле.
---
Сегодня я ещё и посмотрел как Миллетт определяет ответственность слоев.
Часть 2. Глава 8. Application Architecture. Раздел: Dependency Inversion
Of course, the state of domain objects needs to be saved to some kind of persistence store. To achieve this without coupling the domain layer to technical code, the application layer defines an interface that enables domain objects to be hydrated and persisted. This interface is written from the perspective of the application layer and in a language and style it understands free from specific frameworks or technical jargon. The infrastructural layers then implement and adapt to these interfaces, thus giving the dependency that the lower layers need without coupling. Transaction management along with cross-cutting concerns such as security and logging are provided in the same manner. Figure 8-2 shows the direction of dependencies and the direction of interfaces that describe the relationship between the application layer and technical layers.
Пример неудачный с вашей точки зрения. Вы же не знаете как я поведу статью. Мне для перехода к описанию application layer нужен был пример, который в него отлично впишется. Это и будет TransactionManager. Также TransactionManager был удобен, чтобы показать все возможные подходы для работы с наблюдателем и контекстом.
Цитату вряд ли могу привести. Там схема как Эванс распределяет ответственность по слоям. Не знаю можно ли давать ссылку на книгу в хабре, поэтому опишу способ поиска.
Запрос в гугле: "эрик эванс предметно-ориентированное проектирование pdf", первая ссылка.
Часть 2. Глава 4. Изоляция предметной области.
83 страница, рис. 4.1.
Там у него пример следующего типа:
Класс FundsTransferService из application слоя вызывает что-то из инфраструктурного слоя методом beginTransaction();
Затем есть бизнес-логика, внутри доменных сущностей класса Account которые в ходе выполнения вызывают что-то из инфраструктурного слоя методом addToUnitOfWork.
Затем FundsTransferService из application слоя вызывает что-то из инфраструктурного слоя с методом commit();
----
То есть у Эванса начало и коммит транзакции происходит в application слое. Непонятно правда как по классам распределены эти методы
А добавление в БД через методы как-то связанные с UoW. Если UoW без методов begin и commit, то он выглядит как аналог интерфейса Repository.
Надо у Миллетта посмотреть у него примеры более подробные.
К DDD не апеллирую, у меня и модели в статье анемичные, но на распределение ответственности по слоям core/application/infrastructure это вряд ли влияет. Скорее на форму внутри core модуля.
Если вы утверждаете, что наблюдатели добавляют определенные проблемы, то я с вами согласен. Выше постарался ответить, что все приведенные вами решения предпочтительнее, чем наблюдатели, если мы используем дополнительный модуль application, который избавляет от необходимости вносить TM внутрь модуля core.
Но у всех этих решений есть недостаток, они добавляют логику только в рамках паттерна декоратор т.е. в начале и конце метода. Поэтому моя позиция в том, чтобы всюду где возможно использовать решения с декоратором, и только в исключительных и редких случаях использовать наблюдателей, чтобы не добавлять TM и им подобные в core.
Хочу также подчеркнуть, что у меня нет цели предложить все строить на наблюдателях. В целевом решении наблюдатели редкий кейс.
Например, предлагаемые решения @Throwableчерез AOP, использование из фреймворка аннотации Transactional, лямбду в transactional - это все разные формы декоратора.
Ваши предположения о том, что в основном необходимо выполнять действия в начале и конце метода, чтобы использовать UoM, тоже выравниваются с декоратором. Кстати если не ошибаюсь даже у Эванса в DDD указано, что UoM находится на уровне application layer.
С этим нет никаких противоречий. Я сам с этим согласен и сообщаю об этом в статье. Но есть редкие кейсы, которые могут заставить сделать что-то "инфраструктурное" посередине метода. И только в этом случае предлагается использовать наблюдателей.
В статье на данном этапе сообщается как это сделать. О том что не нужно для TM городить всюду наблюдателей я прямым текстом сообщаю в статье, чтобы меня не поняли буквально.
Отсюда также и вывод, что шанс возникновения всех обозначенных проблем крайне мал. Чтобы понадобилось для одного места более одного наблюдателя, со связями и порядком выполнения, с исключениями и обработкой.
Если мы исходим из предположения о том, что TM, UoM и им подобные не должны быть в core, иногда чтобы это сделать придётся усложнить, решив тот редкий кейс. Мой опыт подсказывает, что это не превращается в ад с наблюдателями.
Тогда мы расходимся с вами во мнении допустимо или нет использование consistency manager в core модуле. Предлагаю тогда не начинать это спор повторно, каждый высказал свою позицию об этом ранее
Так приведите пример кода без наблюдателей, который будет с одной стороны скрывать все инфраструктурные детали, а с другой стороны выполнять ваш кейс. И, мне кажется, у меня получится его переписать с архитектурными гарантиями не хуже чем то, что вы напишите.
Я понимаю о чем вы говорите, но как только вы скрываете A, B и С, вы делаете неявным управление. Только если соберётесь писать код, прошу не использовать methodA, methodB и т.д. Тут крайне важно название, потому что наблюдатель его меняет. Если под methodA кроется beginTransaction или ещё что-то подобное, то это неправильный пример.
Форма излагаемого влияет на восприятие и на желание с вами продолжать разговор. То что вы не можете держать свои эмоции в себе вас не оправдывает. Есть правила хабра, прошу их соблюдать.
Синхроны или асинхроны в каком смысле? Есть реактивные фреймворки типа Reactor они делают в одном смысле асихронность без потери контекста. Есть очереди сообщений, для которых обработки подразумевает потерю контекста.
Предположу, что вы имели ввиду асинхронность подобную Reactor (без потери контекста). Тогда если это важно, то можно всех наблюдателей делать асинхронными, с последовательным вызовом после окончания очередного вызова.
Гарантируется, всегда в порядке очередности в которой произошла подписка. Если это влияет на логику выполнения, то порядком можно управлять. Если мы говорим про Spring то для этого достаточно добавить аннотация `@Order` с цифрой.
Верно, потому что в примерах нет необходимости это детерминировать. И в большинстве случаев их нет необходимости детерминировать.
Если это важно такие гарантии можно как дать так и нет. Но если мы говорим конкретно про решаемые задачи в статье, то таких проблем даже не будет возникать. Все сервисы singelton, привязываемые обработчики также singleton, привязка только в момент инициализации бинов.
Отсюда следствие: не будет необходимости отвязывать или привязывать наблюдателей внутри обработки. Также нет необходимости в removeListener.
Возможно, такой кейс может возникнуть, но он крайне редкий.
Если кидается исключение из обработчика, то для остальных обработчиков не доходит сообщение.
Если важно, чтобы после обработчика другие обработчики что-то выполнили, то внутри обработчика должен быть try-catch. То есть в этом смысле обработчик не прокинет сообщение выше.
Если вы считаете, что тут есть какие-то подводные камни, потому что на ваши общие проблемы у меня общий ответ, то прошу вас привести конкретный пример с кодом, который будет демонстрировать проблему, которую по вашему мнению нельзя будет решить с помощью наблюдателей. В свою очередь я постараюсь привести пример с наблюдателями.
Нет, следует из предыдущего ответа
Да, циклические зависимости в шаблоне наблюдатель это проблема. Но они в большей степени справедливы для UI, где возможны циклические потоки данных. В статье описан подход в большей степени для бизнес-логики. В силу специфики области применения, там поток данных однонаправленный. От core в базу, от core в другой сервис, от core в очередь. Обозначенная проблема с цилкическими зависимостями очень редкий кейс.
P.s. Кейс от core в core через наблюдателей исключается, т.к. наблюдатели не предлагается использовать для вызовов в одном и том же модуле.
Если говорить в принципе про наблюдателей, то проблема есть. Но в контексте области применения её нет. Ответил выше.
------
Резюмируя ответы на ваш список вопросов
Вы привели ряд вопросов, которые необходимо решить для наблюдателей. Но они достаточно тривиальны, если мы спускаемся на уровень области применения описанный в статье.
------
Относительно вашего примера с transactionManager
На самом деле вы привели ещё один пример с декоратором. Ранее вы говорили про использование AOP - тоже пример с декоратором. Предложенный мною ApplicationService тоже пример с декоратором. Противоречия с тем, что декоратор для TM это в большинстве случаев самое удачно решение, с этим у меня нет никаких противоречий. В статье об этом я также написал. Но проблема в том, что вы оставили его в core модуле.
Проблема с очередностью как уже несколько раз писал, решается одной аннотацией @Order с цифрой очередности. Если вы предлагали решение с AOP то также должны знать, что это проблема решается там аналогично.
Порядок обработки детерминирован и его можно задать без особой сложности.
Позвольте мне сделать ревью вашего ревью. Ответы на ваши комментарии имеют дополнительный отступ и указываются после ваших комментариев.
------
Прошу меньше общих слов иначе мне придётся на них отвечать также общими словами и мы так можем продолжать очень долго. Если вы считаете, что данный подход можно легко опровергнуть, то вам достаточно привести конкретный пример с кодом без наблюдателей, который по вашему мнению переписать на использование наблюдателей будет невозможно.
Я же постараюсь его переписать на использование с наблюдателями. Это позволит также исключить проблемы, которые вы видите в реализации для TM. Т.к. все важные по вашему мнению аспекты будут представлены в исходном вами примере, которые я не буду игнорировать при реализации через наблюдателей.
Комментарий удален, т.к. отправлен неполным, создаю полный
Теперь я понимаю, почему многие авторы не пытаются отвечать на вопросы. Проблема тут не в размерах дискуссии, а в игнорировании правил этикета хабра. Отвечать на подобные посты 1, 2, 3 крайне неприятно