Spring умеет передавать «сигналы» между частями одного приложения: один класс сообщает «произошло вот это», а другие на это реагируют — каждый своим делом. Разберём с нуля, зачем это нужно, как опубликовать событие и как его поймать.
Зачем вообще нужны события
Представьте метод, который создаёт заказ. Сразу после сохранения нужно сделать ещё несколько вещей: отправить покупателю письмо, обновить кэш, записать строчку в журнал действий. Самый простой способ — позвать всё прямо в методе:
void createOrder(...) {
orderRepository.save(order);
emailService.sendConfirmation(order); // отправить письмо
cache.evict(order.customerId()); // обновить кэш
auditLog.record(order); // записать в журнал
}
Проблема: метод «создать заказ» теперь знает про почту, кэш и журнал. Захотим добавить четвёртое действие — снова правим этот же метод. Класс разрастается и завязывается на всё подряд.
Идея событий простая: метод просто объявляет «заказ создан» и на этом заканчивает свою работу. А кто и как на это отреагирует — его уже не касается. Письмо, кэш, журнал живут в отдельных классах и подписываются на это сообщение сами.
void createOrder(...) {
orderRepository.save(order);
events.publishEvent(new OrderCreatedEvent(order.id())); // объявили — и всё
}
Так зависимости расцепляются: отправитель события ничего не знает о тех, кто его слушает. Это работает внутри одного приложения — это не способ общаться между разными сервисами (для этого есть очереди сообщений и сетевые вызовы).
Из чего состоит механизм
Тут всего три участника:
- Событие — обычный объект с данными о том, что случилось. Чаще всего
record. - Издатель — тот, кто говорит «случилось вот это». В Spring это
ApplicationEventPublisher. - Слушатель — метод, помеченный
@EventListener, который реагирует на событие.
Само событие — это просто класс без всякой магии:
public record OrderCreatedEvent(UUID orderId, UUID customerId) {}
Раньше события должны были наследоваться от специального класса ApplicationEvent. Сейчас это не нужно — публиковать можно любой объект. Спокойно используйте обычный record.
Как опубликовать событие
Издателя ApplicationEventPublisher не надо создавать руками — Spring сам передаст его через конструктор, как любую другую зависимость:
@Service
class OrderService {
private final OrderRepository orderRepository;
private final ApplicationEventPublisher events;
OrderService(OrderRepository orderRepository, ApplicationEventPublisher events) {
this.orderRepository = orderRepository;
this.events = events;
}
void createOrder(...) {
orderRepository.save(order);
events.publishEvent(new OrderCreatedEvent(order.id(), order.customerId()));
}
}
Вызвали publishEvent — и забыли. Дальше дело за слушателями.
Как поймать событие через @EventListener
Слушатель — это метод в любом бине, помеченный @EventListener. Spring смотрит на тип параметра метода и сам понимает, какое событие тот ловит:
@Component
class OrderCreatedListener {
private final EmailService email;
OrderCreatedListener(EmailService email) {
this.email = email;
}
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
email.sendConfirmation(event.customerId(), event.orderId());
}
}
На одно событие может быть сколько угодно слушателей в разных классах — Spring позовёт их все. Если важен порядок вызова, добавьте к слушателю @Order (меньше число — раньше очередь).
Синхронные слушатели: всё в одном потоке
По умолчанию слушатель работает синхронно: издатель вызывает publishEvent, и прямо в этот момент, в том же потоке, по очереди отрабатывают все слушатели. Издатель ждёт, пока они закончат, и только потом продолжает.
createOrder()
├── orderRepository.save()
├── events.publishEvent(...)
│ ├── listener1 ← по очереди, в том же потоке
│ └── listener2 ← издатель ждёт их обоих
└── продолжаем дальше
Из этого следуют три вещи, которые легко упустить:
- Если слушатель долго работает — издатель всё это время стоит и ждёт.
- Если слушатель бросит исключение — оно «прорвётся» обратно к издателю, как при обычном вызове метода.
- Если всё это внутри транзакции и слушатель пишет в базу — запись попадёт в ту же транзакцию.
Синхронные слушатели — это разумное поведение по умолчанию. К асинхронности переходят осознанно.
Асинхронные слушатели: @Async
Иногда реакция не должна тормозить основную работу. Отправка письма может занять секунду — глупо заставлять покупателя ждать ответа всё это время. Тогда слушателя делают асинхронным: он отрабатывает в отдельном потоке, а издатель не ждёт.
Сначала надо разрешить асинхронность в приложении:
@Configuration
@EnableAsync
class AsyncConfig { }
А потом добавить слушателю @Async:
@Component
class OrderCreatedListener {
@Async
@EventListener
public void onOrderCreated(OrderCreatedEvent event) {
email.sendConfirmation(...); // выполняется в другом потоке
}
}
Теперь издатель опубликовал событие и сразу пошёл дальше, а письмо уходит параллельно.
Но за это приходится платить:
- Исключение из слушателя «теряется». Издатель уже ушёл вперёд и про сбой не узнает. Чтобы такие ошибки не пропадали молча, их ловят отдельным обработчиком (
AsyncUncaughtExceptionHandler). - Слушатель работает вне исходной транзакции. Он в другом потоке, со своей транзакцией — атомарность с основной работой теряется.
Асинхронные слушатели хороши для необязательных побочных действий: письма, журналы, уведомления. Для важных вещей чаще берут @TransactionalEventListener (ниже).
@TransactionalEventListener: дождаться итога транзакции
Вот частая ловушка. Метод работает в транзакции, внутри публикует событие, а синхронный слушатель сразу отправляет письмо «заказ создан». Но что, если после этого транзакция откатится и заказ на самом деле в базу не попадёт? Письмо уже ушло — покупателю сообщили про заказ, которого нет.
Хочется, чтобы слушатель срабатывал только если транзакция действительно завершилась успешно. Для этого есть @TransactionalEventListener — он привязывает слушателя к определённой фазе транзакции:
Фаза (phase) | Когда срабатывает слушатель |
|---|---|
BEFORE_COMMIT | Перед фиксацией транзакции |
AFTER_COMMIT (по умолчанию) | После успешной фиксации |
AFTER_ROLLBACK | После отката |
AFTER_COMPLETION | После завершения — неважно, фиксация или откат |
@Component
class OrderCreatedListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommit(OrderCreatedEvent event) {
email.sendConfirmation(...); // только если транзакция зафиксировалась
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void onRollback(OrderCreatedEvent event) {
log.warn("Создание заказа откатилось: {}", event.orderId());
}
}
Главное достоинство: при откате транзакции слушатель просто не вызовется. «Заказ не сохранился — значит, и письмо отправлять не надо» получается само собой.
Если в фазе AFTER_COMMIT надо писать в базу
Тонкость. Фаза AFTER_COMMIT срабатывает после фиксации — исходная транзакция уже закрыта. Если такой слушатель попробует записать что-то в базу, запись просто не сохранится: писать некуда, транзакции больше нет. Чтобы запись прошла, слушателю нужна новая транзакция — её просят аннотацией:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onCommit(OrderCreatedEvent event) {
auditLog.save(new AuditEntry(event)); // в отдельной новой транзакции
}
Без REQUIRES_NEW запись из такого слушателя в базу не попадёт.
Какой слушатель выбрать
Короткое правило на каждый день:
- Работа должна быть частью той же транзакции (например, ещё одна запись в базу рядом с основной) — обычный
@EventListener, синхронно. - Работа должна выполниться только после успешной фиксации и в базу не пишет (письмо, уведомление) —
@TransactionalEventListenerс фазойAFTER_COMMIT. - Работа необязательная и не должна тормозить основной поток (письма, журналы) —
@Asyncплюс@EventListener, помня про потерю исключений.
Встроенные события Spring
События — это не только ваши классы. Spring сам рассылает служебные сообщения о жизни приложения, и на них тоже можно подписаться тем же @EventListener. Например, ApplicationReadyEvent приходит, когда приложение полностью поднялось и готово принимать запросы — удобное место для стартовой инициализации:
@Component
class StartupListener {
@EventListener
public void onReady(ApplicationReadyEvent event) {
log.info("Приложение запущено и готово к работе");
}
}
Где события — не лучший выбор
События Spring живут внутри одного запущенного приложения, в его памяти. Если сервер выключится сразу после фиксации транзакции, но до того, как слушатель успел отработать, событие просто пропадёт — никакой гарантии доставки тут нет.
Поэтому для пересылки событий между разными сервисами (через очередь сообщений) их не используют напрямую: там нужна гарантия, что сообщение не потеряется. Для такого применяют отдельный приём — сообщение и бизнес-данные сохраняют в базу одной транзакцией, а отдельный процесс потом досылает его в очередь. События Spring остаются для побочных действий внутри сервиса: письма, кэш, журнал.
Коротко
- События нужны, чтобы расцепить код: издатель объявляет «случилось X» и не знает, кто на это отреагирует.
- Три участника: событие (обычный объект, чаще
record), издатель (ApplicationEventPublisher), слушатель (@EventListener). - Наследовать событие от
ApplicationEventне нужно — публикуют любой объект. - По умолчанию
@EventListenerработает синхронно, в том же потоке и той же транзакции; издатель ждёт слушателей, а их исключения возвращаются к нему. @Asyncплюс@EventListener— слушатель в отдельном потоке, издатель не ждёт; но исключения теряются и работа идёт вне исходной транзакции.@TransactionalEventListenerпривязывает слушателя к фазе транзакции:BEFORE_COMMIT,AFTER_COMMIT(по умолчанию),AFTER_ROLLBACK,AFTER_COMPLETION.- В фазе
AFTER_COMMITисходная транзакция уже закрыта — для записи в базу нужна новая транзакция (REQUIRES_NEW). - На служебные события Spring (
ApplicationReadyEventи другие) тоже можно подписаться тем же@EventListener. - События живут в памяти одного приложения и не дают гарантии доставки — для надёжной пересылки между сервисами они не подходят.
Что почитать дальше
- DI/IoC, жизненный цикл бина и scopes — как Spring создаёт бины и передаёт зависимости вроде
ApplicationEventPublisher. @Transactionalглубоко — фазы транзакции, к которым привязывается@TransactionalEventListener.- Spring AOP — на нём держится
@Async, и у него есть своя ловушка с вызовом метода внутри того же класса.