JavaRush /Курсы /Spring Core /Listeners: @Order и ...

Listeners: @Order и condition

Spring Core
18 уровень , 2 лекция
Открыта

1. Много слушателей: порядок вызова

Как только side effects уехали из use-case сервиса в listeners, проблема сместилась. Теперь мало просто решить, что вообще выносить в событие: на одно и то же OrderCreatedEvent может откликаться несколько обработчиков, и без явных правил этот кусок быстро становится непредсказуемым.

Когда проект маленький, легко поверить, что “ну слушателей два, как-нибудь”. Но как только у вас появляется аудит, уведомления и статистика (а это почти любой реальный бизнес-сценарий, даже в учебном ContextFlow), одно событие начинает разлетаться в несколько реакций. И если вы не задаёте правила, у команды появляется любимая игра: “угадай, кто отработает первым”.

Проблема не в том, что Spring “плохой” и не умеет порядок. Проблема в том, что без явных правил порядок не обязан быть очевидным. Он может зависеть от того, как контейнер регистрировал бины, как вы меняли пакеты, как вы переименовали классы, и даже от того, как вы рефакторили конфигурацию. Иногда “вроде работает”, но это ровно тот случай, когда стабильность — случайность, а случайность — плохой фундамент.

Представим наш привычный поток в ContextFlow. Сервис опубликовал OrderCreatedEvent, а дальше мы хотим три реакции: записать аудит, обновить статистику, отправить уведомление. Схематично это выглядит так:

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

Если реакции независимы, вам в целом всё равно, кто из них первый. Но как только появляется хоть один “тонкий” момент — например, вы хотите, чтобы аудит записался до отправки уведомления (чтобы в логах было видно, что мы вообще начали обработку), порядок становится инструментом предсказуемости.

2. @Order: порядок вызова listeners

@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 // регистрируем listener как Spring-bean
public class AuditOrderEventsListener {

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

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

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

@Component // listener со статистикой
public class StatisticsOrderEventsListener {

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

И уведомления — третьими. Логика не “единственно правильная”, но часто удобная: аудит и статистика — внутренняя кухня, уведомление — внешний шум. Пусть внешний шум идёт после внутренней фиксации.

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

@Component // listener для "внешнего эффекта" (уведомления)
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 listeners и выстроим их @Order”, то поздравляю: вы изобрели “скрытый workflow-движок”, который неудобно читать, трудно тестировать и почти невозможно объяснить новичку (включая вас через три месяца).

3. condition в @EventListener: фильтрация событий

Иногда проблема не в порядке, а в том, что слушателей слишком много, и часть из них должна срабатывать только при определённых обстоятельствах. Самый простой пример из ContextFlow — канал уведомления. Если событие содержит channel = NotificationChannel.EMAIL, мы хотим отправить e-mail, а если NotificationChannel.SMS — SMS. И хочется, чтобы это было видно прямо из объявления listener-а, а не пряталось в if внутри метода.

Для этого у @EventListener есть параметр condition. Это SpEL-выражение (Spring Expression Language), которое вычисляется перед вызовом метода. Если условие ложно, метод просто не вызывается. По смыслу это похоже на “декларативный if”, только в аннотации.

У нас channel — это NotificationChannel, поэтому в условии удобнее сравнивать name() enum-а. Само событие от этого обратно в строковый мешок не превращается.

Пример: отдельный listener, который реагирует на создание заказа только если канал — 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-штука, и в статистику такие события не считаем (потому что иначе 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: counted " + event.orderId()); // STATS: counted 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-коду: либо сделать разные типы событий, либо вынести решение в сервис, либо перегруппировать listeners.

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

Аккуратная дисциплина вместо “event soup”

Когда вы ставите @Order и condition вместе, очень легко получить две крайности. Первая — “у нас нет правил, всё случайно”. Вторая — “у нас 28 listeners, и каждый с condition на 4 строки, а порядок — как расписание поездов”. Нам нужна золотая середина: ровно столько механики, чтобы поток был предсказуемым и читаемым.

Представим практический и “живой” вариант для ContextFlow. Аудит должен происходить всегда и первым. Статистика — всегда и второй. Уведомления — третьи, но причём уведомления бывают разные: e-mail и SMS.

Сразу оговорюсь: следующий split на Email/Sms listeners — это демонстрационная ветка, чтобы condition было видно максимально явно. В основном baseline 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
    }
}

Тут есть тонкость, которую полезно проговорить. Если оба listener-а имеют одинаковый @Order(3), то между ними порядок может быть не фиксирован. Но так как условия взаимоисключающие (канал не может быть одновременно EMAIL и SMS), нам порядок между ними и не нужен. Главное — что они идут после аудита и статистики.

Получается довольно читаемая картина: сначала фиксируем, потом считаем, потом уведомляем. И при этом “ветка уведомления” выбирается естественно по условию, не превращая основной use case сервис в “диспетчера всего на свете”.

Мини-инкремент в 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);

Если у вас стоят listeners из предыдущих примеров с @Order(1), @Order(2) и @Order(3), то вывод будет идти в этом порядке. Например, в максимально простом варианте:

AUDIT: created O-101
STATS: +1 created
EMAIL: O-101

Это важное ощущение: вы не “надеетесь на порядок”, вы его декларируете. И когда кто-то через неделю добавит новый listener, ему придётся либо тоже явно обозначить порядок, либо принять, что его обработчик не имеет требований к позиции в цепочке.

5. Типичные ошибки при работе с @Order и condition

Ошибка №1: полагаться на “случайный порядок”, потому что “и так работает на моём компьютере”.
Это особенно коварно, когда проект ещё маленький: кажется, что аудит всегда печатается первым, потому что “так получилось”. Но после рефакторинга пакетов, добавления нового bean-а или перестройки конфигурации порядок может измениться. Если порядок действительно важен — его нужно объявить через @Order.

Ошибка №2: перепутать направление порядка (думать, что 10 раньше 1).
Эта ошибка встречается чаще, чем хочется признать. В Spring меньшая цифра — выше приоритет. Если у вас @Order(10) на аудите и @Order(1) на уведомлении, то уведомление будет раньше аудита, и вы будете долго искать “почему логи выглядят странно”, хотя ответ спрятан в одной цифре.

Ошибка №3: пытаться построить основной бизнес-процесс через @Order.
Когда сценарий “создать заказ” разъезжается по десятку listener-ов, связь между шагами становится неявной. Да, оно может работать, но читать это сложно: основной use case сервис превращается в “публикатора события”, а логика разбросана по обработчикам. @Order должен помогать упорядочить несколько независимых side effects, а не заменять собой нормальный дизайн сценария.

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

Ошибка №5: забыть, что condition выполняется до вызова метода, и “случайный null” может сломать выражение.
Если вы пишете #event.channel.name().startsWith('E'), а channel внезапно оказался null, то выражение может упасть ещё до вашего кода. Поэтому либо держите данные события корректными и непустыми, либо пишите условия так, чтобы они были устойчивыми (например, сначала проверяйте на null), но не превращайте это в спагетти.

1
Задача
Spring Core, 18 уровень, 2 лекция
Недоступна
Явный порядок трех listeners
Явный порядок трех listeners
1
Задача
Spring Core, 18 уровень, 2 лекция
Недоступна
Фильтрация listeners по каналу через condition
Фильтрация listeners по каналу через condition
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ