1. Задача lifecycle-событий в Boot
Если воспринимать старт приложения как одну точку (“запустил — и оно стартануло”), то события кажутся странной сущностью из мира “Enterprise-колдунства”. Но как только вы хотя бы пару раз ловили ситуацию “вроде стартует, но что-то не так”, вы начинаете ценить любое понятное деление процесса на этапы — как аптечку в машине: пока не нужна, кажется лишней.
Spring Boot живёт не в вакууме. Он делает много шагов: подготавливает окружение, создаёт контекст, поднимает инфраструктуру, запускает embedded server (если вы в web-режиме), выполняет ваши runners, и только потом можно честно сказать: “приложение готово”. Эти шаги важны не ради философии, а ради практики. Когда вы видите “готово”, вы хотите понимать, после чего именно наступило это “готово”.
Lifecycle events — это простая идея: Boot в ключевые моменты “стреляет сигналом” (объектом-событием), и вы можете на него подписаться. Представьте себе театр. Идёт спектакль “Старт приложения”. На сцене таблички: “Акт 1: контекст поднят”, “Акт 2: runners отработали”, “Акт 3: всё упало и все разошлись”. События — это как эти таблички, только для кода: вы перестаёте гадать, где вы находитесь по сюжету.
2. События started, ready и failed
Событий в Spring Boot существует больше, и там есть довольно ранние этапы, о которых удобно знать в более продвинутых сценариях. Но для нашего уровня и для задачи дня нам достаточно трёх самых “прикладных” и легко объяснимых событий: ApplicationStartedEvent, ApplicationReadyEvent, ApplicationFailedEvent. Они отлично раскладывают старт на понятные “до/после” и не требуют глубокого погружения во внутренности фреймворка.
Чтобы не держать в голове всё сразу, давайте сведём их в таблицу. В ней важно не зубрить формулировки, а понять смысл: что уже сделано на этом этапе, и что ещё нет.
| Событие | Когда происходит | Что уже произошло | Что ещё не произошло | Зачем полезно |
|---|---|---|---|---|
| ApplicationStartedEvent | После обновления (refresh) контекста | ApplicationContext создан и обновлён, бины уже существуют | Ваши CommandLineRunner / ApplicationRunner ещё не отработали | Понять “контекст поднялся”, поставить ранние маркеры, лёгкая диагностика |
| ApplicationReadyEvent | После выполнения runners | Стартовая логика runners завершилась | Ничего “обязательного” не осталось, приложение считается готовым | Печать “мы готовы”, запуск очень лёгкой пост-инициализации, сигнал “готово” для диагностики |
| ApplicationFailedEvent | Когда старт завершился исключением | Boot поймал ошибку старта и оформил её как событие | Приложение не поднялось (и обычно завершается) | Получить “последний шанс” зафиксировать причину падения понятным сообщением |
Ключевой момент лекции (в прямом смысле “ключевой”): started и ready — это разные состояния. У начинающих часто есть внутреннее ожидание: “ну если контекст поднят, значит всё готово”. А Boot специально показывает границу: после started вы всё ещё можете выполнять важные одноразовые действия через runners. И только после них приложение считается ready.
3. Таймлайн старта
Когда в голове нет временной карты старта, легко перепутать роли механизмов: начать класть “главный стартовый код” в слушатели событий или, наоборот, пытаться всё делать в main(). Сейчас мы соберём простую временную схему, чтобы было ясно, кто за чем следует, и что мы можем ожидать на каждом этапе.
Ниже — упрощённая диаграмма. Она не пытается показать все внутренние стадии Boot, а показывает только то, что нам нужно сегодня: факт появления ApplicationContext, момент публикации ApplicationStartedEvent, затем runners, затем ApplicationReadyEvent. Отдельно отмечено, что при исключении где-то по дороге появляется ApplicationFailedEvent.
flowchart TD
A["main()"] --> B["SpringApplication.run(...)"]
B --> C["Создание и refresh ApplicationContext"]
C --> D["Публикация ApplicationStartedEvent"]
D --> E["Выполнение ApplicationRunner / CommandLineRunner"]
E --> F["Публикация ApplicationReadyEvent"]
B -->|Исключение на старте| X["ApplicationFailedEvent"]
C -->|Исключение на старте| X
E -->|Исключение в runner| X
Здесь есть очень практическое следствие: listeners (слушатели событий) и runners стоят очень близко к финалу старта. Это хорошо: контекст уже поднят, зависимости можно инжектить, сервисы и репозитории доступны (в рамках текущего skeleton). Но это же опасно: если вы “случайно” сделаете в listener тяжёлую работу, вы удлините старт или вообще превратите его в непредсказуемую штуку.
И ещё один нюанс дня: по умолчанию события обрабатываются синхронно. То есть Boot публикует событие — и слушатели выполняются в том же потоке, и пока они не закончили, дальше по таймлайну приложение не двинется. На уровне ощущений это как “пробка на старте”: один неудачный listener может задержать всех.
4. Подписка на события через @EventListener
Слушатели событий в Spring звучат страшнее, чем есть на самом деле. На практике это “обычный Spring-бин”, внутри которого есть метод, помеченный @EventListener. Этот метод принимает параметром объект события. Когда Boot публикует событие — Spring находит подходящие методы и вызывает их. Всё. Никаких шаманских танцев вокруг main(), никакого ручного “подпишись на шину событий”.
Начнём с самого базового и полезного: выведем маркеры на ApplicationStartedEvent и ApplicationReadyEvent. Здесь System.out.println нам нужен только как простой фонарик, чтобы глазами увидеть порядок.
Обратите внимание на два момента. Во-первых, класс должен быть Spring-managed, то есть находиться в зоне component scan и иметь @Component. Во-вторых, методам не нужно ничего возвращать — они просто реагируют на событие.
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // Делаем класс Spring-бином, чтобы Spring мог вызвать обработчики событий
public class StartupPhaseListener {
@EventListener
public void onStarted(ApplicationStartedEvent e) {
// Сработает, когда контекст уже поднят, но runners ещё не запускались
System.out.println("[event] started"); // [event] started
}
@EventListener
public void onReady(ApplicationReadyEvent e) {
// Сработает после выполнения всех runners — честный маркер «приложение готово»
System.out.println("[event] ready"); // [event] ready
}
}
Это самый короткий вводный probe. Рядом со следующим более явным order-demo такой класс обычно не держат: дальше мы просто разложим тот же порядок по отдельным ролям, чтобы таймлайн было легче читать.
Это маленький, но важный практический шаг: вы начинаете видеть стадии старта именно в терминах Boot, а не только по “потоку логов”, который иногда напоминает водопад. И, что приятно, это работает даже в самом минимальном catalog-service, где ещё нет web-слоя.
Теперь важное правило, которое стоит проговорить прямо здесь, пока вы не написали себе “StartupListener на 300 строк”. Listener — это не место для тяжёлой логики. Он хорошо подходит для наблюдения (маркер, лёгкая диагностика, фиксирование тайминга), но не для длинной работы. Если хочется сделать “явное стартовое действие” — для этого в сегодняшнем дне уже есть runners.
5. Listener и Runner: роли и порядок
На старте Boot даёт два типа механизмов, и они часто путаются: runner и listener. Издалека они похожи: “и то, и то выполняется на старте, и там, и там можно что-то сделать”. Но роли у них разные. Runner — это место, где вы делаете одноразовое стартовое действие (коротко, явно, с возможностью инжектить зависимости). Listener — это место, где вы наблюдаете жизненный цикл и реагируете на переходы между фазами.
Самый быстрый способ почувствовать разницу — поставить маркеры и посмотреть порядок вывода. Чтобы этот порядок было видно совсем явно, временно заменим предыдущий упрощённый listener на более разложенный demo-набор. Идея та же, просто теперь каждая роль живёт отдельно, и таймлайн читается легче.
Сделаем три маленьких компонента: один слушает started, другой — runner, третий слушает ready. В результате вы увидите: started → runner → ready.
Слушатель started:
import org.springframework.boot.context.event.ApplicationStartedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // Этот класс должен быть бином, иначе метод-обработчик никто не вызовет
public class StartedMarker {
@EventListener
public void onStarted(ApplicationStartedEvent e) {
// Маркер для проверки: этот вывод должен быть ДО runners
System.out.println("[event] started (before runners)"); // [event] started (before runners)
}
}
Маркер-runner:
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
@Component // Runner тоже является Spring-бином и запускается на старте
public class MarkerRunner implements CommandLineRunner {
@Override
public void run(String... args) {
// Здесь место для «одноразовой стартовой работы», которая должна быть короткой и предсказуемой
System.out.println("[runner] executed"); // [runner] executed
}
}
Слушатель ready:
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // Наблюдаем момент, когда приложение уже «встало на ноги»
public class ReadyMarker {
@EventListener
public void onReady(ApplicationReadyEvent e) {
// Маркер для проверки: этот вывод должен быть ПОСЛЕ runners
System.out.println("[event] ready (after runners)"); // [event] ready (after runners)
}
}
Если запустить приложение, то среди прочего вы увидите эти строки именно в таком относительном порядке:
[event] started (before runners)
[runner] executed
[event] ready (after runners)
Почему это важно? Потому что это снимает вечную начинающую боль: “а где мне печатать ‘готово’?”. Если вам нужно честное “готово” после того, как ваша стартовая логика уже отработала, то ApplicationReadyEvent — более честный маркер, чем ApplicationStartedEvent. А если вам нужно сделать явное действие старта, то runner — хорошее место. Listener — это фонарь и датчик, runner — это инструмент и действие.
И ещё одна тонкость, про которую стоит помнить, чтобы не удивляться: если у вас несколько listeners на одно событие, Spring вызовет их всех. Порядок вызова между несколькими слушателями — отдельная тема, и в начале курса лучше не строить логику, которая зависит от порядка вызова слушателей. Слушатели должны быть максимально независимыми и “безопасными”.
6. ApplicationFailedEvent и падение старта
Когда приложение падает на старте, начинающий разработчик обычно видит огромный stack trace и испытывает два чувства: “почему он такой длинный” и “почему он такой страшный”. На самом деле, чаще всего вам нужна одна вещь: понятная причина падения и место, где эту причину можно аккуратно зафиксировать. ApplicationFailedEvent — это как раз сигнал: “старт не завершился успешно”.
Сделаем небольшой listener, который печатает тип исключения и его сообщение. Это отдельный диагностический probe, а не обязательный сосед всех маркерных компонентов выше. В production вы бы логировали это нормально, но сейчас наша цель — увидеть, что событие реально происходит и что из него можно достать полезную информацию.
import org.springframework.boot.context.event.ApplicationFailedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component // Попытаемся поймать и красиво «подсветить» падение старта
public class StartupFailedListener {
@EventListener
public void onFailed(ApplicationFailedEvent e) {
// Это «последний шанс» вывести что-то понятное до завершения приложения
System.out.println("[event] failed: " + e.getException().getMessage()); // [event] failed: ...
}
}
Здесь есть важное “жизненное” замечание. ApplicationFailedEvent — это last-chance событие, но оно всё равно зависит от того, насколько далеко успел дойти Boot. Если приложение падает совсем рано (ещё до поднятия контекста), то listener, который живёт как @Component внутри контекста, может просто не успеть появиться. На нашем текущем этапе, когда ошибки чаще проявляются ближе к финалу старта (например, в runner или уже после создания бинов), этот механизм очень полезен. Но не стоит воспринимать его как “железную гарантию”, что вы перехватите любое падение на свете.
И ещё одна ловушка для новичка: иногда хочется “перехватить ошибку и продолжить”. В контексте старта это почти всегда плохая идея. Если Boot упал на старте, то лучше, чтобы он упал честно, чем чтобы вы получили “полуживое приложение”, которое вроде работает, но на самом деле уже сломано внутри. Listener на failed — это место, чтобы зафиксировать и объяснить проблему, а не чтобы изображать героизм.
7. Типичные ошибки lifecycle-событий
Ошибка №1: считать ApplicationStartedEvent синонимом “приложение готово”.
Это очень распространённая путаница: студент увидел слово started и сделал вывод “ну раз стартовало — значит готово”. На деле started означает, что контекст уже поднят, но runners ещё не отработали. Если вы хотите печатать итоговый маркер “мы готовы” именно после своей стартовой логики, более подходящим сигналом будет ApplicationReadyEvent.
Ошибка №2: превращать listener в скрытый runner и выполнять в нём тяжёлую работу.
Listener выглядит как удобная точка “ой, здесь же можно написать код”. И вот тут легко начать делать в @EventListener всё подряд: долгие вычисления, чтение больших файлов, “подготовку чего-то важного”. Проблема в том, что события обрабатываются синхронно, и вы буквально замораживаете продвижение старта дальше. Если это действие — реальная стартовая задача, ей место в runner, и она должна быть короткой и предсказуемой.
Ошибка №3: дублировать логику в runner и в listener на ready.
Иногда разработчик пишет runner, который делает “стартовую сводку”, и параллельно пишет listener на ApplicationReadyEvent, который делает “стартовую сводку”. Потом наступает вечная жизнь багов: сводка печатается два раза, сообщения расходятся, никто не понимает, где “настоящая правда”. Хороший стиль в начале курса — выбрать одно место для одного действия: runner для действия, listener для наблюдения.
Ошибка №4: пытаться завязать бизнес-логику на порядок вызова нескольких listeners.
Даже если у вас сейчас всё “случайно работает”, это очень хрупкий стиль. Слушатели должны быть максимально независимыми и не рассчитывать, что “сначала вызовется мой, потом твой”. На старте курса лучше держать listeners простыми: печать маркера, лёгкая диагностика, вывод понятного сообщения. Сложные зависимости между слушателями — почти всегда сигнал, что архитектура поплыла.
Ошибка №5: ожидать, что ApplicationFailedEvent сработает всегда и во всех случаях.
Это событие полезно, но оно не волшебная ловушка для любых падений во Вселенной. Если приложение падает слишком рано, когда контекст ещё не создался, ваш @Component-listener может просто не существовать. Поэтому ApplicationFailedEvent — хороший инструмент, но не единственная опора: читайте сообщение исключения, смотрите верхушку stack trace и не бойтесь нормальной диагностики, а не только “магических перехватов”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ