Привет! Это моя первая статья на Хабре, поэтому если будут какие‑то недочёты, не судите строго)
Не так давно я разрабатывал микросервисное приложение для анализа предпочтений пользователей на основе их книг. Оно должно было обрабатывать тысячи событий в день, чтобы рекомендовать пользователям новые произведения. И в один момент я столкнулся с классической болью распределенных систем: если брокер сообщений (например, Kafka) был временно недоступен, мои события терялись навсегда.
Потеря даже одного события означала, что пользователь не получит рекомендацию, а это напрямую бьет по репутации сервиса. На моё счастье, решение нашлось — им оказались паттерны Outbox и Inbox. Они гарантируют доставку сообщений: сохраняют их в базу данных, а затем отправляют в брокер через фоновый процесс.
Изучив эти паттерны, я начал искать самописные реализации. Нашел их, но столкнулся с новой проблемой: из проекта в проект я перетаскивал один и тот же шаблонный код — таблицы, шедулеры, обработку ошибок и другое. Когда я насчитал 6 реализаций с практически одинаковой логикой, я понял: это нужно автоматизировать.
Предварительно я решил поискать готовые решения. На моё удивление, они даже нашлись — тот же Axon Framework неплохо покрывал Outbox. Но я не нашёл в нём реализацию для Inbox (может плохо искал, но по открытым источникам ничего найти не вышло). Более того, основная задача этого фрейма — реализовать все процессы через CQRS и Event Sourcing, что для моего решения было избыточным. Поискав ещё, я не нашёл того, что бы смогло удовлетворить мои потребности, а потому я пошёл по пути написания своего стартера.
Главное направление
Есть две цели, к которым я стремился, пока проектировал стартер:
1) Сделать его расширяемым настолько, насколько это возможно. Хотел я этого достичь за счёт использования уже готовых паттернов (Стратегия, Фабрика), что значительно облегчило жизнь;
2) Скрыть настолько много логики, насколько это возможно. Не везде это, правда, получилось достичь — некоторые трудности возникли с Inbox и немного с Outbox. Однако основная рутина была убрана под капот.
Давайте посмотрим, какие аннотации в нём есть. Не волнуйтесь — их всего 4.
-
@EnableInboxing — аннотация, указывающая, нужно ли включать Inbox‑часть стартера или нет;
-
@EnableOutboxing — аналогично предыдущей аннотации, но уже для Outbox;
-
@OutboxEntity — аннотация, указывающая, что содержимое класса, над которым она навешена, является содержимым для Outbox сообщений (payload);
-
@InboxListener — аннотация, которая навешивается над методом и указывает, что этот метод является обработчиком определённой очереди.
Кажется, выглядит нестрашно, верно? Если вы так подумали, то поняли всё верно — это действительно просто. И в разделе с практикой вы в этом убедитесь. Но сначала углубимся в устройство стартера чуть поподробнее.
Немного про архитектуру
Как по мне, логичнее всего будет описать каждую из групп компонентов отдельно. Мы пройдёмся по структуре объектов Outbox, Inbox, DLQ и Broker. Пойдём по порядку.
Из всего, что здесь представлено, единственное, что нужно разработчику для взаимодействия с Outbox компонентами — это OutboxContextManager. Он создаётся автоматически стартером, когда флаг outbox.enabled установлен в true. У него есть всего 2 метода — save (T message) и его перегруженный вариант save(T message, String topic). С их помощью вы добавляете сообщение в контекст Outbox (во втором случае явно указываете топик или очередь, куда отправляется сообщение; при вызове первого метода он подхватывает эти данные из @OutboxEntity). Затем шедулер подхватывает все необработанные сообщения, после чего проходится по каждому из них вызывает транзакционный метод из OutboxService для их обработки. В свою очередь OutboxService делегирует отправку компонентам Broker (об этом ниже).
Пара важных моментов:
-
если deduplication‑enabled = true, шедулер выбирает только последние версии сообщений с одинаковым messageId;
-
при ошибке отправки: retryCount++, сохраняется errorMessage; если retryCount >= maxRetries: сообщение удаляется из outbox и сохраняется в DLQ;
-
OutboxContextManager.save() проверяет активную транзакцию через TransactionSynchronizationManager; если транзакция неактивна — пишется предупреждение в лог.
Теперь перейдём к архитектуре компонентов Inbox. Схема их связей представлена ниже:
Когда вы добавляете аннотацию @InboxListener над методом, бин InboxListenerRegistrar (наследник BeanPostProcessor) регистрирует метод, который обрабатывает поступающие в топики/очереди сообщения. Затем каждый бин InboxListenerRegistar попадает в InboxListenerRegistry, где для каждой очереди хранится список из методов для этой очереди (это сделано на случай, если в одну очередь будут попадать разные типы сообщений). Затем, когда в топике/очереди появляется сообщение, InboxProcessor с помощью BrokerContext читает его и сохраняет в таблицу. Затем шедулер подхватывает все необработанные сообщения и отправляет их в InboxListenerRegistry, тот берёт метод, указанный разработчиком, и обрабатывает его.
Здесь также реализованы механизм дедупликации и обработки ошибок по аналогии с Outbox. Перейдём к DLQ.
Когда сообщения достигают лимита по max‑retries в Outbox или Inbox, оно сохраняется в DeadLettersRepository. Затем шедулер достаёт из таблицы неопубликованные «мёртвые» сообщения, и отправляет их в топик/очередь, указанную в настройках.
А теперь перейдём к моему любимому — к сегменту Brokers.
Есть общий для всех брокеров интерфейс — BrokerStrategy. Он содержит 3 метода — publish(OutboxMessage message), consume(String queueName, Consumer<MessageEnvelope> handler) и publishDeadLetter(DeadLettersEntity message), что покрывает все необходимые действия для работы с брокерами. Стоит немного рассказать о методе consume. Одним из его параметров является интерфейс Consumer<MessageEnvelope>. MessageEnvelope — это фактически payload сообщения + заголовки, указываемые при отправке сообщения самим стартером. Важно отметить — пока эти заголовки на настраиваемые.
Для интерфейса BrokerStrategy уже готовы две реализации — KafkaBrokerStrategy и RabbitBrokerStrategy. Однако просто так они недоступны. За получение конкретной реализации отвечает BrokerStrategyFactory. У него есть метод getStrategy(Brokers brokerType), который по типу брокера, указанного в iobox.broker берёт нужную реализацию и передаёт её в BrokerContext. BrokerContext ничего не знает о конкретной реализации и работает только с интерфейсом BrokerStrategy.
Теперь, когда мы описали архитектуру в общем, можем переходить к практике.
Ура, практика!
Для демонстрации работы стартера я создам простое REST‑приложение со структурой, приведённой ниже. Для простоты мы сделаем так, что это приложение будет и отправлять, и читать свои же сообщения.
Предварительно надо скачать стартер и добавить зависимость в проект. Т.к. я использую Maven (любители Gradle, не бейте), то в pom.xml надо написать в dependencies это:
<dependency> <groupId>org.jedi_bachelor</groupId> <artifactId>spring-iobox-starter</artifactId> <version>1.0.1-alpha</version></dependency>
А также напишем application.yaml. Скажу сразу — я буду приводить его по порядку, потому что информации слишком много. Начнём с Outbox:
outbox: enabled: true max-retries: 5 deduplication-enabled: true retention-days: 7 scheduler: enabled: true interval: 5000 cleanup-cron: "0 0 3 * * *" context-manager: enabled: true default-topic: "default"
Ниже приведу описание каждого из полей:
-
outbox.enabled — флаг, включающий или выключающий поддержку готового outbox;
-
outbox.max‑retries — максимальное количество попыток обработать входящее сообщение. Если это число достигается, сообщение удаляется (если DLQ не поддерживается) или отправляется в DLQ;
-
outbox.deduplication‑enabled — поддерживается ли дедупликация в Outbox или нет;
-
outbox.retention‑days — количество дней, которые сообщения могут находиться в таблице. Если сообщения лежат дольше этого срока, они удаляются;
-
outbox.scheduler.enabled — поддерживается ли готовый шедулер или нет;
-
outbox.scheduler.interval — период (в миллисекундах), через которое происходит новый обход шедулера;
-
outbox.scheduler.cleanup‑cron — CRON‑задача, описывающая порядок, когда будет очищаться база данных с Outbox;
-
outbox.content‑manager.enabled — поддерживается ли встроенный OutboxContentManager или нет;
-
outbox.content‑manager.default‑topic — название топика (очереди) по умолчанию.
Думаю, достаточно простая и понятная настройка. Теперь, когда мы ознакомились с Outbox, перейдём к рассмотрению Inbox:
inbox: enabled: true max-retries: 5 deduplication-enabled: true retention-days: 7 scheduler: enabled: true interval: 5000 cleanup-cron: "0 0 3 * * *" kafka: topics: "user-created,order-created,test-success"
Как видите, структура во многом повторяет Outbox. Единственное сильное отличие — inbox.kafka.topics. В этом поле указывается через запятую названия топиков, которые стартер будет прослушивать. Это сделано для того, чтобы Kafka автоматически создавала топики, если их нет (мы будем использовать только «user‑created»; остальное приведено как демонстрация, как надо писать, если топиков несколько).
Теперь перейдём к DLQ (Dead Letters Queue):
dlq: enabled: true dlqName: "dead-letters" scheduler: enabled: true interval: 5000
Во многом ситуациях похожая. dlq.dlqName — название очереди для отправки сообщений с «мёртвыми» письмами. По умолчанию принимает значение «dead‑letters», но вы всегда можете его поменять.
Ещё один важный момент — так как стартер поддерживает интеграцию как с Kafka, так и с RabbitMQ, вы можете явно указать, что именно будете использовать. Пока доступны только «kafka» и «rabbit». По умолчанию стоит значение «kafka». Можете поменять на «rabbit», как показано ниже:
iobox: broker: rabbit
Остался последний штрих — накидать настройки для Kafka:
spring: jpa: hibernate: ddl-auto: update kafka: bootstrap-servers: localhost:9093 consumer: group-id: bs-group auto-offset-reset: earliest key-deserializer: org.apache.kafka.common.serialization.StringDeserializer value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer properties: spring.json.trusted.packages: "*" producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer
Предварительно поднимите ещё сервер Kafka (если хотите — Rabbit). Для просмотра сообщений я буду использовать Kafka UI — можете его также запустить в Docker.
Перейдём к коду
Главный класс выглядит очень просто:
import org.jedi_bachelor.ioboxstarter.annotations.EnableInboxing;import org.jedi_bachelor.ioboxstarter.annotations.EnableOutboxing;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication@EnableOutboxing@EnableInboxingpublic class IoboxtestApplication { public static void main(String[] args) { SpringApplication.run(IoboxtestApplication.class, args); }}
Для проверки работы мы создадим сущность, тело которой будет перекидывать через очередь:
import lombok.Getter;import lombok.RequiredArgsConstructor;import org.jedi_bachelor.ioboxstarter.annotations.OutboxEntity;@OutboxEntity(queueName = "user-created")@Getter@RequiredArgsConstructorpublic class TestEvent { private final Long id; private final String message; private final boolean shouldFail;}
Здесь в аннотации @OutboxEntity обязательно надо указать, в какую очередь должно отправляться сообщение. Теперь реализуем класс TestController, где создадим два эндпоинта:
-
POST /success/{id} — отправка успешного сообщения (в ходе обработки должны появиться сообщения в таблицах Inbox и Outbox);
-
POST /fail/{id} — отправка провального сообщения (на нём мы увидим, что DLQ работает).
Текст класса представлен ниже:
import com.example.ioboxtest.service.TestService;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;@RestController@RequestMapping("/test")@RequiredArgsConstructor@Slf4jpublic class TestController { private final TestService testService; @PostMapping("/success/{id}") public ResponseEntity<String> sendSuccess(@PathVariable Long id, @RequestParam String message) { this.testService.sendSuccessEvent(id, message); return ResponseEntity.ok("Success event sent with id: " + id); } @PostMapping("/fail/{id}") public ResponseEntity<String> sendFail(@PathVariable Long id, @RequestParam String message) { this.testService.sendFailEvent(id, message); return ResponseEntity.ok("Fail event sent with id: " + id); }}
Теперь добавим класс TestService:
import com.example.ioboxtest.entity.TestEvent;import lombok.RequiredArgsConstructor;import lombok.extern.slf4j.Slf4j;import org.jedi_bachelor.ioboxstarter.OutboxContextManager;import org.springframework.stereotype.Service;@Service@RequiredArgsConstructor@Slf4jpublic class TestService { private final OutboxContextManager outboxContextManager; public void sendSuccessEvent(Long id, String message) { TestEvent event = new TestEvent(id, message, false); this.outboxContextManager.save(event); log.info("Sent success event: {}", message); } public void sendFailEvent(Long id, String message) { TestEvent event = new TestEvent(id, message, true); this.outboxContextManager.save(event); log.info("Sent fail event: {}", message); }}
ПРИМЕЧАНИЕ: ранее я отмечал, что компоненты Outbox следят за тем, выполняете ли вы обработку Outbox‑сообщений в транзакционных методах или нет. Запустив эти примеры, вы увидите соответствующие логи с предупреждением, но в будущем лучше просто навесить @Transactional там, где это надо;)
Последнее, что нам надо сделать — это написать обработчик для Inbox сообщений. Давайте реализуем его, создав класс TestHandler:
import com.example.ioboxtest.entity.TestEvent;import lombok.extern.slf4j.Slf4j;import org.jedi_bachelor.ioboxstarter.annotations.InboxListener;import org.springframework.stereotype.Component;@Component@Slf4jpublic class TestHandler { @InboxListener(queueName = "user-created") public void handle(TestEvent event) { log.info("Message: Processing event with id: {}, message: {}", event.getId(), event.getMessage()); if (event.isShouldFail()) { throw new RuntimeException("Test exception - message should go to DLQ"); } log.info("SUCCESS: Event processed successfully"); }}
Здесь есть один обработчик — для очереди «user‑created». При получении сообщения мы будем просто выводить о нём информацию. Если флаг сообщения shouldFail установлен в true, мы выкинем исключение.
Теперь перейдём к тесту. Когда вы запустите приложение, то увидите, что Iobox создаёт топик «user‑created». Отправим запрос с созданием успешного сообщения:
Как видим из логов — запрос был успешно отправлен, его тело помещено в Outbox таблицу, после чего шедулер находит это сообщение и обрабатывает его. Затем это же приложение видит в топике «user‑created» новое сообщение, сохраняет в Inbox, и потом шедулер обрабатывает его. Если мы залезем в Kafka UI, то действительно найдём там сообщение:
Супер! А теперь давайте посмотрим обработку негативного сценария. Как вы ранее видели, мы симулировали намеренную ошибку, но этот пример покажет, что всё и правда работает. Отправим новый запрос:
В логах после этого вы увидите всё то же самое, а затем вылетит 5 сообщений об исключении — кол‑во равно значению max‑retries. После этого в логах вы увидите это:
Сообщение, которое мы ранее отправили, было переслано в топик «dead‑letters». Проверим это, и… да, там оно действительно есть:
Таким образом, мы видим, что всё работает успешно)
Получение актуальной информации из баз данных
Остаётся ещё один вопрос — можно ли проверить состояние и значения сообщений, помимо просмотра топика? Для того, чтобы быстро получать актуальную информацию баз данных для Inbox, Outbox и Dead Letters, я создал соответствующие эндпоинты:
-
GET /outbox — выводит все сообщения из таблицы сообщений Outbox;
-
GET /inbox — аналогично как в /outbox, только для таблицы Inbox;
-
GET /dead‑letters — аналогично, только для таблицы dead letters.
Проверим их, чтобы не быть голословными!
Мы получили картину, которую и ожидали — два сообщения Outbox были отправлены, но одно из них попало в Inbox, потому что имело корректный формат, а второе отправлено в dead‑letters, потому что система не смогла его обработать. Теперь у вас есть возможность получать подробную информацию о данных в таблицах ещё и таким способом.
Итоги
Конечно, это только первая версия. Но уже сейчас стартер экономит часы разработки и гарантирует, что ваши события не потеряются. В планах добавить интеграцию с NoSQL базами данных (MongoDB и Cassandra), реактивным программированием и возможность отправки запросов не только через очереди или сессии, а также через REST‑запросы. Также уже готовлюсь добавлять метрики Micrometer, а вместе с тем намерен переработать настройки в application.yaml (пока они выглядят достаточно разрозненно).
Сам стартер лежит по этой ссылке. Я продолжу развивать проект и буду рад вашим звездам на GitHub, issue и pull request’ам.
Спасибо за внимание!
P. S. Статью я писал в тот момент, когда только перерабатывал интеграцию с брокерами посредством паттерна Стратегия (раньше всё было завязано на интерфейсах OutboxProducer и InboxConsumer). К моему удивлению, после изменений стартера в тестовом приложении, приведённом в статье, не поменялось ни строчки кода. Для меня это был шок, хотя, скорее всего, это что‑то из разряда нормы.
ссылка на оригинал статьи https://habr.com/ru/articles/1064466/