← назад к разделу

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, и у него есть своя ловушка с вызовом метода внутри того же класса.