JavaRush /Курси /Spring Core /Слухачі: @Order і <...

Слухачі: @Order і condition

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

1. Багато слухачів: порядок виклику

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

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

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

Уявімо наш звичний потік у ContextFlow. Сервіс опублікував OrderCreatedEvent, а далі ми хочемо три реакції: записати аудит, оновити статистику, надіслати сповіщення. Схематично це виглядає так:

flowchart TD
    A["OrderPlacementService.publishEvent(OrderCreatedEvent)"] --> B[AuditOrderEventsListener]
    A --> C[StatisticsOrderEventsListener]
    A --> D[NotificationOrderEventsListener]

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

2. @Order: порядок виклику слухачів

@Order — це маленька анотація з великим психологічним ефектом. Вона дозволяє сказати Spring: «якщо ви викликаєте кілька обробників однієї події, будь ласка, зробіть це в такому порядку». Важливе саме слово «відносний»: @Order(1) не означає «перший у всесвіті», це означає «раніше, ніж @Order(2)».

У Spring правило просте і запам’ятовується майже як «у школі на фізкультурі»: менше число виконується раніше. Тобто @Order(1) спрацює раніше, ніж @Order(10). Якщо @Order немає, порядок не слід вважати стабільним.

Ось мінімальний приклад для ContextFlow: аудит хочемо виконувати найпершим, щоб він «фіксував факт реакції» ще до інших ефектів.

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component // реєструємо слухач як бін Spring
public class AuditOrderEventsListener {

    @Order(1) // чим менше число — тим раніше буде викликано слухач
    @EventListener // підписуємося на подію OrderCreatedEvent
    public void onCreated(OrderCreatedEvent event) {
        // аудит: фіксуємо факт обробки до будь-яких зовнішніх ефектів
        System.out.println("AUDIT: створено " + event.orderId()); // AUDIT: створено O-101
    }
}

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

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component // слухач зі статистикою
public class StatisticsOrderEventsListener {

    @Order(2) // буде викликано після @Order(1)
    @EventListener // слухаємо ту саму подію
    public void onCreated(OrderCreatedEvent event) {
        // тут могли б оновлюватися метрики/лічильники
        System.out.println("STATS: +1 створено"); // STATS: +1 створено
    }
}

І сповіщення — третіми. Логіка не «єдино правильна», але часто зручна: аудит і статистика — внутрішня кухня, сповіщення — зовнішній ефект. Нехай зовнішній ефект іде після внутрішньої фіксації.

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component // слухач для "зовнішнього ефекту" (сповіщення)
public class NotificationOrderEventsListener {

    @Order(3) // надсилаємо сповіщення після аудиту і статистики
    @EventListener
    public void onCreated(OrderCreatedEvent event) {
        // зазвичай тут буде інтеграція: email/sms/черга тощо
        System.out.println("NOTIFY: " + event.channel()); // NOTIFY: EMAIL
    }
}

Щоб це не виглядало як «магічні числа», корисно тримати в голові невелику таблицю смислів. Вона не про закон, а про зручність читання:

@Order Що це означає Типовий зміст у подіях
1 «спочатку зафіксувати» аудит, критична діагностика
2 «оновити внутрішні агрегати» статистика, лічильники, кеш
3 «зробити зовнішні ефекти» сповіщення, надсилання, вивід

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

3. condition у @EventListener: фільтрація подій

Іноді проблема не в порядку, а в тому, що слухачів забагато, і частина з них має спрацьовувати тільки за певних обставин. Найпростіший приклад у ContextFlow — канал сповіщення. Якщо подія містить channel = NotificationChannel.EMAIL, ми хочемо надіслати електронну пошту, а якщо NotificationChannel.SMS — SMS. І хочеться, щоб це було видно просто в оголошенні слухача, а не ховалося в if усередині методу.

Для цього у @EventListener є параметр condition. Це SpEL-вираз (Spring Expression Language), який обчислюється перед викликом методу. Якщо умова хибна, метод просто не викликається. За змістом це схоже на «декларативний if», тільки в анотації.

У нас channel — це NotificationChannel, тому в умові зручніше порівнювати name() enum-а. Саму подію від цього не зводять до рядкового мішка.

Приклад: окремий слухач, який реагує на створення замовлення тільки якщо канал — EMAIL.

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component // окремий обробник тільки для email-каналу
public class EmailOrderEventsListener {

    @EventListener(condition = "#event.channel.name() == 'EMAIL'") // SpEL-фільтр до виклику методу
    public void onCreated(OrderCreatedEvent event) {
        // сюди потрапимо лише якщо channel == EMAIL
        System.out.println("EMAIL: " + event.orderId()); // EMAIL: O-101
    }
}

Зверніть увагу на «змінну» #event. У condition Spring дає доступ до обʼєкта події, і ми можемо читати його поля, ніби пишемо звичайний код. Якщо ваш метод приймає параметр OrderCreatedEvent event, то вираз #event.channel виглядає майже як Java — і це хороший знак: умова має бути короткою і читабельною.

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

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component // статистика тільки для "бойових" подій
public class ProductionLikeStatisticsListener {

    @EventListener(condition = "#event.channel.name() != 'CONSOLE'") // відкидаємо dev/локальний канал
    public void onCreated(OrderCreatedEvent event) {
        // важливо: цей код не виконається для channel == CONSOLE
        System.out.println("STATS: пораховано " + event.orderId()); // STATS: пораховано O-101
    }
}

А ще умова може бути не тільки «дорівнює/не дорівнює». Наприклад, іноді зручно фільтрувати demo/test події за форматом id. Це не найкращий дизайн для продакшена, але в навчальному проєкті може допомогти зробити поведінку наочною.

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component // аудит тільки для demo-замовлень
public class DemoOnlyAuditListener {

    @EventListener(condition = "#event.orderId.startsWith('DEMO-')") // фільтр за префіксом id
    public void onCreated(OrderCreatedEvent event) {
        // потрапимо сюди лише якщо orderId починається з "DEMO-"
        System.out.println("DEMO AUDIT " + event.orderId()); // DEMO AUDIT DEMO-1
    }
}

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

4. Практика: @Order + condition

Акуратна дисципліна замість «event soup»

Коли ви ставите @Order і condition разом, дуже легко отримати дві крайності. Перша — «у нас немає правил, усе випадково». Друга — «у нас 28 слухачів, і кожен із condition на 4 рядки, а порядок — як розклад поїздів». Нам потрібна золота середина: рівно стільки механіки, щоб потік був передбачуваним і читабельним.

Уявімо практичний і «живий» варіант для ContextFlow. Аудит має відбуватися завжди і першим. Статистика — завжди і другою. Сповіщення — третіми, але сповіщення бувають різні: електронна пошта і SMS.

Одразу зауважу: наступне розділення на Email/Sms слухачів — це демонстраційна гілка, щоб condition було видно якомога наочніше. У базовому варіанті ContextFlow у цьому прикладі сповіщення й далі зручно тримати в одному NotificationOrderEventsListener, який уже всередині делегує в NotificationDispatchService.

Ось як це може виглядати в поєднанні:

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
public class SmsOrderEventsListener {

    @Order(3)
    @EventListener(condition = "#event.channel.name() == 'SMS'")
    public void onCreated(OrderCreatedEvent event) {
        System.out.println("SMS: " + event.orderId()); // SMS: O-101
    }
}

І симетричний обробник для e-mail (той самий порядок, та сама подія, але інша умова):

import org.springframework.context.event.EventListener;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
public class EmailOrderEventsListener {

    @Order(3)
    @EventListener(condition = "#event.channel.name() == 'EMAIL'")
    public void onCreated(OrderCreatedEvent event) {
        System.out.println("EMAIL: " + event.orderId()); // EMAIL: O-101
    }
}

Тут є тонкість, яку корисно проговорити. Якщо обидва слухачі мають однаковий @Order(3), то між ними порядок може бути не фіксованим. Але оскільки умови взаємовиключні (канал не може бути одночасно EMAIL і SMS), нам порядок між ними і не потрібен. Головне — що вони йдуть після аудиту і статистики.

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

Міні-інкремент у ContextFlow

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

Нехай у нас є такий виклик у сценарії, спрощено, без доменних подробиць:

import com.example.contextflow.domain.model.NotificationChannel;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class DemoOrderPublisher {

    private final ApplicationEventPublisher publisher;

    public DemoOrderPublisher(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void publishCreated(String orderId, NotificationChannel channel) {
        publisher.publishEvent(new OrderCreatedEvent(orderId, channel));
    }
}

І десь у ScenarioRunner (або тимчасово в demo-сценарії) ви викликаєте:

demoOrderPublisher.publishCreated("O-101", NotificationChannel.EMAIL);

Якщо у вас стоять слухачі з попередніх прикладів із @Order(1), @Order(2) і @Order(3), то вивід піде в цьому порядку. Наприклад, у максимально простому варіанті:

AUDIT: створено O-101
STATS: +1 створено
EMAIL: O-101

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

5. Типові помилки під час роботи з @Order і condition

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

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

Помилка №3: намагатися побудувати основний бізнес-процес через @Order.
Коли сценарій «створити замовлення» роз’їжджається по десятку слухачів, зв’язок між кроками стає неявним. Так, воно може працювати, але читати це складно: основний сценарій сервісу перетворюється на «публікатор події», а логіка розкидана по обробниках. @Order має допомагати впорядкувати кілька незалежних побічних ефектів, а не замінювати собою нормальний дизайн сценарію.

Помилка №4: перетворювати condition на повноцінну програму на SpEL.
Коротка умова на кшталт #event.channel.name() == 'EMAIL' читається нормально. А ось вираз на пів екрана, який обчислює щось хитромудре, — це майже завжди поганий сигнал. У такій ситуації краще перенести логіку в Java-код: або зробити окремий слухач і окремий тип події, або винести рішення в сервіс, а condition залишити як простий фільтр.

Помилка №5: забути, що condition виконується до виклику методу, і «випадковий null» може зламати вираз.
Якщо ви пишете #event.channel.name().startsWith('E'), а channel раптом виявився null, то вираз може впасти ще до вашого коду. Тому або тримайте дані події коректними й непорожніми, або пишіть умови так, щоб вони були стійкими, наприклад спочатку перевіряйте на null, але не перетворюйте це на спагеті.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ