JavaRush /Курси /Spring Core /Синхронний publishEvent

Синхронний publishEvent () і ContextFlow

Spring Core
Рівень 17 , Лекція 4
Відкрита

1. Синхронність подій і неочікувані помилки

Коли люди вперше чують «події», мозок часто автоматично домальовує картинку зі світу черг: ніби ми «відправили кудись повідомлення», а воно «коли-небудь потім» обробиться само, десь у фоні, і можна йти пити чай. У Spring application events (у межах сьогоднішньої теми) все набагато прозаїчніше і, чесно кажучи, корисніше для розуміння: подія — це звичайний виклик слухачів, який відбувається прямо всередині вашого потоку виконання. Не окремий сервіс, не брокер і не паралельний всесвіт.

Це означає дуже практичну річ: коли OrderPlacementService викликає publisher.publishEvent(...), він чекає, доки всі відповідні ApplicationListener відпрацюють. Якщо слухач робить щось важке (наприклад, пише у файл, виконує складну обробку, «ну я тут просто один маленький звіт порахую»), то метод place(...) почне виконуватися довше. А якщо слухач кине виняток, то виняток може «прилетіти назад» у сценарій, і код після publishEvent(...) може взагалі не виконатися.

Щоб це закріпити, далі ми зробимо те, що розробники роблять найчастіше, коли сумніваються: додамо мінімальне трасування і подивимося на реальний порядок виконання. Так, System.out.println — не система логування мрії, але як навчальний «ліхтарик» він працює чудово.

2. publishEvent() у звичайному потоці виконання

Найчесніший спосіб зрозуміти синхронність — зробити маленький експеримент у проєкті. Ми додамо три повідомлення в сервіс сценарію і по два повідомлення в кожному слухачі: «старт» і «кінець». Якщо події синхронні, то вивід буде чітко показувати, що код після publishEvent() виконується лише після того, як обробники завершили роботу.

Нижче — мінімальний варіант OrderPlacementService, де ми навмисно додали прості друки. Зверніть увагу: це не рекомендація «так логувати завжди», це просто демонстрація «як тече виконання».

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {

    private final OrderStore orderStore;
    // Публікатор подій: виклик publishEvent() виконуватиметься синхронно в поточному потоці
    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(OrderStore orderStore, ApplicationEventPublisher publisher) {
        this.orderStore = orderStore;
        this.publisher = publisher;
    }

    public void place(Order order) {
        // Навчальне трасування: хочемо побачити реальний порядок виконання
        System.out.println("place(): 1) збереження");          // place(): 1) save
        orderStore.save(order);

        // Важливо: publishEvent() НЕ "відправив і забув", а "викликав слухачів і дочекався"
        // Контракт події лишається тим самим: orderId + customerId
        System.out.println("place(): 2) публікація");       // place(): 2) publish
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));

        // Цей рядок з'явиться лише ПІСЛЯ того, як відпрацюють усі слухачі
        System.out.println("place(): 3) після публікації"); // place(): 3) after publish
    }
}

Тепер додамо слухача, який реагує на OrderCreatedEvent, і теж зробимо «start/end». Важливо, що слухач — окремий bean, і він не залежить від OrderPlacementService. Він знає тільки про подію та сервіс, який виконує реакцію.

import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

@Component
public class AuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {

    // Реакція винесена в окремий сервіс: listener залишається "тонким"
    private final AuditService auditService;

    public AuditOrderCreatedListener(AuditService auditService) {
        this.auditService = auditService;
    }

    @Override
    public void onApplicationEvent(OrderCreatedEvent event) {
        // Навчальне трасування: показуємо, що listener виконується синхронно
        System.out.println("listener(audit): початок"); // listener(audit): start
        auditService.recordCreated(event.getOrderId()); // Реальну роботу делеговано сервісу
        System.out.println("listener(audit): кінець");   // listener(audit): end
    }
}

І другий слухач — наприклад, для сповіщень:

import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

@Component
public class NotificationOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {

    // Ще одна незалежна реакція на ту саму подію
    private final NotificationDispatchService notificationService;

    public NotificationOrderCreatedListener(NotificationDispatchService notificationService) {
        this.notificationService = notificationService;
    }

    @Override
    public void onApplicationEvent(OrderCreatedEvent event) {
        // Навчальне трасування: порядок між listeners не гарантується, але синхронність — так
        System.out.println("listener(notification): початок"); // listener(notification): start
        notificationService.sendCreated(event.getOrderId());  // Надсилання сповіщення як побічна дія
        System.out.println("listener(notification): кінець");   // listener(notification): end
    }
}

Тепер найцікавіше: як виглядатиме вивід. Якщо запустити сценарій створення замовлення, типова послідовність буде така (порядок між слухачами може відрізнятися, і на це не можна спиратися, але головний факт збережеться):

place(): 1) збереження
place(): 2) публікація
listener(audit): початок
listener(audit): кінець
listener(notification): початок
listener(notification): кінець
place(): 3) після публікації

Ключовий рядок тут — останній. place(): 3) after publish з’являється після того, як слухачі відпрацювали. Тобто publishEvent() — це не «відправив і забув», а «викликав обробників і дочекався».

Щоб закріпити це не лише на рівні «всередині методу», можна подивитися на рівень вище: у ScenarioRunner. Якщо він друкує «сценарій завершено» після виклику place(), то ви побачите, що «завершено» друкується теж лише після слухачів.

import org.springframework.stereotype.Component;

@Component
public class ScenarioRunner {

    private final OrderPlacementService orderPlacementService;

    public ScenarioRunner(OrderPlacementService orderPlacementService) {
        this.orderPlacementService = orderPlacementService;
    }

    public void run(Order order) {
        // Виклик place() повернеться лише після того, як завершиться обробка подій
        orderPlacementService.place(order);

        // Цей рядок буде надруковано в самому кінці (після всіх слухачів)
        System.out.println("сценарій: завершено"); // scenario: done
    }
}

І знову вивід (спрощено) показуватиме, що scenario: done — у самому кінці, тому що place() не повернувся, доки не закінчилися слухачі.

3. Схема ContextFlow після розв’язання

Коли ми переносимо реакції в слухачів, проєкт починає виглядати структурно «дорослішим», але водночас простішим для читання. Сервіс сценарію стає коротким і чесним: він робить те, що робить сценарій, і оголошує факт. Все інше — окремі реакції.

Щоб побачити це як архітектурну картинку, корисно уявити потік створення замовлення так:

flowchart TD
    Place["OrderPlacementService.place()"]
    Store["OrderStore.save(order)"]
    Publish["publisher.publishEvent(OrderCreatedEvent)"]

    AuditL["AuditOrderCreatedListener"]
    NotifL["NotificationOrderCreatedListener"]

    AuditS["AuditService.recordCreated()"]
    NotifS["NotificationDispatchService.sendCreated()"]

    Place --> Store --> Publish
    Publish --> AuditL --> AuditS
    Publish --> NotifL --> NotifS

Тут є важлива думка: OrderPlacementService більше не тримає посилання на аудит і сповіщення. Він залежить від OrderStore (бо без збереження «замовлення створено» не вважається правдою) і від ApplicationEventPublisher (бо йому потрібно оголосити факт). А вже аудит і сповіщення вирішують, що їм робити з цим фактом.

На практиці це дає дуже прикладний ефект: коли ви додаєте нову реакцію (наприклад, просту статистику), ви не лізете в OrderPlacementService і не додаєте туди ще одну залежність та ще один рядок. Ви просто додаєте новий слухач. Це схоже на ситуацію, коли до кімнати зайшла людина і сказала: «Замовлення створено», а далі хтось записав у журнал, хтось відправив лист, хтось оновив лічильник на табло. І ніхто не змушує людину, яка зайшла, знати, де у вас журнал і як надсилати листи.

Ще один бонус — читабельність сценаріїв. У нас тепер з’являються два місця для читання:

Спочатку ви читаєте сервіс use-case і розумієте, що вважається виконанням сценарію. Він короткий. Потім ви читаєте слухачів і розумієте, які реакції на факт у нас є. Це вже інший шар відповідальності.

Якби ми залишили все прямими викликами всередині одного методу, ці шари змішалися б у «суп із коду». Іноді суп смачний, але коли там плавають і виделка, і ложка, і ополоник, стає тривожно.

4. Винятки в слухачах: вплив на сценарій

Синхронність означає ще одну важливу річ: помилки в обробниках подій впливають на код, що викликає. Це не «погана поведінка Spring», це чесна модель: якщо у вас реакція на подію впала, то сценарій, який цю подію публікував, побачить проблему.

Спочатку подивімося, як може бути боляче. Припустімо, аудиторський слухач (або внутрішня логіка AuditService) раптово кидає виняток. Для демонстрації можна зробити так:

import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

@Component
public class BrokenAuditOrderCreatedListener implements ApplicationListener<OrderCreatedEvent> {

    @Override
    public void onApplicationEvent(OrderCreatedEvent event) {
        // Демонстрація: помилка в listener за замовчуванням "прилетить назад" у publishEvent()
        throw new IllegalStateException("Не вдалося записати аудит"); // boom
    }
}

У такому разі publisher.publishEvent(...) кине виняток, і рядок place(): 3) after publish з нашого прикладу не виконається, якщо ви не обробите виняток. Це допомагає швидко зловити проблему (fail-fast), але потребує усвідомленого дизайну: не можна виносити в слухачі те, без чого сценарій вважається «юридично не завершеним».

Тепер — важливе питання: що з цим робити? У межах цієї лекції ми не йдемо в складні механізми доставки подій і не змінюємо інфраструктуру Spring. Але ми можемо зафіксувати два прості й дуже життєві підходи, які вже допомагають мислити правильно.

Перший підхід — якщо реакція справді побічна, але вам не хочеться, щоб вона ламала сценарій, ви можете спіймати виняток усередині слухача і акуратно перетворити його на технічне повідомлення. Це не ідеальна стратегія для всіх випадків, але як навчальна модель — чудова: ви контролюєте вплив помилки.

@Override
public void onApplicationEvent(OrderCreatedEvent event) {
    try {
        // Побічна реакція: намагаємося записати аудит
        auditService.recordCreated(event.getOrderId());
    } catch (RuntimeException ex) {
        // Ізолюємо збій: сценарій не падає через аудит (якщо це ваша політика)
        System.out.println("audit failed: " + ex.getMessage()); // audit failed: ...
    }
}

Другий підхід — ви можете ловити виняток на боці сценарію, якщо вважаєте, що сценарій має продовжуватися навіть за проблем у реакціях. Наприклад, так:

public void place(Order order) {
    orderStore.save(order);

    try {
        // Публікація події синхронна: виняток зі слухача може впасти сюди
        publisher.publishEvent(new OrderCreatedEvent(this, order.getId(), order.getCustomer().getId()));
    } catch (RuntimeException ex) {
        // Ізоляція помилки на рівні сценарію (застосовувати свідомо)
        System.out.println("place(): збій обробки події: " + ex.getMessage());
    }
}

Але тут важливо не потрапити в пастку: якщо ви ловите винятки «про всяк випадок» усюди підряд, ви отримуєте «тихий режим», у якому система ламається, але вдає, що все добре. Тому навіть цей простий try/catch треба застосовувати з розумінням: ви або вважаєте, що реакція справді побічна, або вважаєте, що помилка має зупинити сценарій.

І ось чому синхронність корисна для початківців: вона змушує вас чесно вибрати, що є частиною гарантії сценарію, а що — «бажано, але не завжди обов’язково». З асинхронністю це питання теж існує, просто воно стає ще менш очевидним. А ми сьогодні хочемо, щоб усе було максимально спостережуваним і зрозумілим.

Нижче невелика таблиця, щоб зафіксувати відчуття:

Де впав виняток Що відбувається за замовчуванням Що ви бачите в place()
У слухачі Виняток поширюється вгору place() падає на publishEvent()
У слухачі, але ви спіймали виняток усередині нього Помилку ізольовано place() продовжує виконання
У place(), ви спіймали виняток навколо publishEvent() Помилку ізольовано на рівні сценарію Код після publishEvent() виконується

5. «Правильний» сценарій після розв’язання

Після розв’язання подіями ContextFlow стає дуже зручним полігоном для розуміння архітектури. Ви відкриваєте OrderPlacementService і бачите: ось залежність від сховища, ось публікація події. Усе. Це як «скелет» сценарію, де немає зайвого м’яса.

Умовно «чистий» сервіс сценарію після розв’язання виглядає так:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderCancellationService {

    private final OrderStore orderStore;
    // Події публікуємо тут, а реакції (аудит/сповіщення/статистика) живуть окремо
    private final ApplicationEventPublisher publisher;

    public OrderCancellationService(OrderStore orderStore, ApplicationEventPublisher publisher) {
        this.orderStore = orderStore;
        this.publisher = publisher;
    }

    public void cancel(Order order, String reason) {
        // Основна частина сценарію: змінюємо стан доменної моделі
        order.cancel(reason);

        // Основна гарантія сценарію: зберігаємо зміни
        orderStore.save(order);

        // Оголошуємо факт: далі підуть синхронні реакції слухачів
        publisher.publishEvent(new OrderCancelledEvent(this, order.getId(), reason));
    }
}

Аудит скасування, сповіщення про скасування, статистика скасувань — усе це тепер живе в окремих слухачах. Це дуже важливо: у «скелета» сценарію менше причин змінюватися. Якщо ви завтра вирішите, що крім аудиторського запису вам потрібна ще одна реакція (наприклад, написати ще одну «службову нотатку»), ви не чіпаєте OrderCancellationService. І це не магія. Це проста інженерна вигода: менше причин для зміни одного й того самого класу.

Але є і дисципліна, яку не можна втратити. Оскільки події синхронні, ви повинні тримати слухачів «тонкими» і передбачуваними. Слухач — не місце, де ви будуєте величезний бізнес-процес, який ніхто не розуміє, бо він запускається «десь там» із публікації події. Слухач — це, по суті, окремий вхід у шматок реакції. Добрий слухач зазвичай робить дві речі: бере дані з події та делегує роботу сервісу, який ви й так могли б викликати напряму.

І тут з’являється дуже простий «мисленнєвий тест»: якщо ви читаєте код place() і бачите publishEvent(), ви повинні розуміти, що це не «чорна діра», а «зараз підуть реакції, але метод чекає їх завершення». Тобто потік виконання залишається лінійним, просто рознесеним за відповідальностями.

Тут корисно закріпити одне правило, яке знімає більшість плутанини. Усередині одного Spring-застосунку application event — це не «черга» і не «задача у фоні». Це радше «виклик набору обробників за типом», просто організований контейнером.

Саме тому корисно один раз пройти шлях через ApplicationEventPublisher і ApplicationListener<T> без скорочень. Більш компактний запис обробників на кшталт @EventListener змінює синтаксис, але не змінює сам потік виконання: факт так само публікується всередині одного ApplicationContext, а реакції за замовчуванням виконуються синхронно.

Якщо вам хочеться думати про це побутовою метафорою, можна уявити, що publishEvent() — це як дзвінок по внутрішньому офісному телефону «всім, кому цікаво: замовлення створено». Але дзвінок не закінчується, доки всі не договорять. Це не повідомлення в месенджері, яке прочитають «коли-небудь».

На практиці це дає простий критерій для проєктування ContextFlow. У слухачах ми складаємо побічні дії, які логічно мають відбуватися «після факту» і які зручно розв’язати: аудит, сповіщення, статистика. А те, що є частиною основного результату сценарію, ми залишаємо прямими викликами: зберегти замовлення, змінити статус, виконати мінімально необхідну зміну стану.

Якщо тримати це в голові, події перестають бути «магією» і перетворюються на спокійний інструмент: ви просто розрізали один довгий метод на кілька незалежних реакцій, але водночас не втратили лінійність виконання.

6. Типові помилки під час роботи з publishEvent()

Помилка №1: очікувати асинхронності «за замовчуванням» і проєктувати слухачів так, ніби вони працюють у фоні.
Це дуже часта пастка: у слухач додають важку роботу, а потім дивуються, що метод сценарію «раптом став повільним». Насправді все чесно: publishEvent() чекає слухачів, тож важка логіка в слухачі робить сценарій важким. У межах сьогоднішньої моделі це навіть корисно: вона змушує тримати реакції короткими та змістовними.

Помилка №2: переносити в слухач крок, без якого сценарій не можна вважати завершеним.
Інколи хочеться винести «заради краси» взагалі все, включно зі збереженням замовлення або зміною статусу, і залишити в сценарії одну публікацію події. Це перетворює сценарій на загадку: тепер незрозуміло, хто гарантує стан і де саме відбувається головна зміна. Події потрібні, щоб розв’язувати реакції на факт, а не щоб ховати сам факт.

Помилка №3: не думати про винятки й дивуватися, що сценарій падає через «побічну реакцію».
Синхронні події означають, що виняток у слухачі може зупинити виконання методу сценарію. Початківець бачить stack trace і думає: «чому аудит заважає створенню замовлення?». Відповідь проста: ви поки не вирішили, чи аудит критичний. Якщо не критичний, помилку потрібно ізолювати (хоча б простим try/catch у слухачі). Якщо критичний, то падіння — чесний сигнал, що сценарій не повинен «робити вигляд, що все чудово».

Помилка №4: будувати слухачів так, що вони залежать один від одного і мовчки припускають порядок виконання.
Навіть якщо в конкретному запуску «спочатку аудит, потім сповіщення», не можна перетворювати це на прихований контракт. Слухачі мають бути незалежними: кожен реагує на факт сам по собі. Щойно слухач починає покладатися на те, що «хтось до мене вже зробив X», система стає крихкою, а розслідування помилок перетворюється на археологію.

Помилка №5: перетворювати publishEvent() на «чорну діру» без спостережуваності.
Коли в проєкті багато реакцій, початківцю корисно хоча б тимчасово мати просте трасування (як ми сьогодні зробили через System.out.println) або акуратні технічні повідомлення, щоб бачити, що реально відбувається під час публікації події. Якщо ж усе сховано, то події починають здаватися магією, і довіра до коду падає — а це небезпечніше за будь-яку технічну помилку.

1
Задача
Spring Core, 17 рівень, 4 лекція
Недоступна
Видимий синхронний потік навколо publishEvent()
Видимий синхронний потік навколо publishEvent()
1
Задача
Spring Core, 17 рівень, 4 лекція
Недоступна
Виняток із listener повертається в publisher-path
Виняток із listener повертається в publisher-path
1
Опитування
Події Spring, рівень 17, лекція 4
Недоступний
Події Spring
Публікація та обробка подій
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ