1. Fail-fast: падати одразу і зрозуміло
Якщо ви тільки починаєте, є дуже людське бажання: «Лише б запустилося, а далі розберемося». Проблема в тому, що серверний застосунок, який нібито запустився, але насправді працює неправильно, — це найгірший сценарій. Він уже приймає запити, він уже пише логи, він уже витрачає ресурси… і при цьому може видавати сміття або падати на першій реальній дії користувача. Fail-fast — це звичка говорити: «Якщо щось критично зламалося, краще впасти одразу, голосно і зрозуміло».
Fail-fast у контексті старту означає дуже просту ідею: у вас є мінімальний набір умов, без яких застосунок не повинен вважатися працездатним. Щойно ви виявили порушення, не треба розводити руками чи писати “warning” — просто зупиніть запуск, кинувши виняток. Boot чесно зафіксує, що старт невдалий, і ви отримаєте явний сигнал: «Працювати не можна. Виправляйте».
Щоб відчути різницю, корисно порівняти fail-fast і його токсичного брата-близнюка fail-late (або «як-небудь потім»):
| Підхід | Що відбувається, якщо зі стартом проблеми | Як це відчувається в житті |
|---|---|---|
| Fail-fast | Застосунок не стає «ready», старт завершується помилкою | Швидко бачите проблему, швидко виправляєте, швидко запускаєте знову |
| Fail-late | Застосунок запускається напівживим, а ламається пізніше | Помилка спливає в несподіваному місці, користувач уже страждає, а ви налагоджуєте її постфактум |
У навчальному проєкті ми, звісно, не робимо продакшен-сервіс для банку (і слава богам JVM), але правильну звичку краще виробити на простому прикладі. Потім, коли проєкт стане більшим, ця звичка буквально економитиме години життя.
2. Runner наприкінці старту: зведення та перевірки
Щоб fail-fast був не «магією винятків», а усвідомленим інструментом, важливо розуміти місце runnerʼа в усій послідовності старту. Runner-и викликаються тоді, коли Spring уже зібрав контейнер, створив біни та впровадив залежності. Тобто ви можете спокійно інжектити ApplicationContext, Environment, ApplicationArguments і будь-які свої компоненти — вони вже існують. Але при цьому ви все ще у фазі старту: якщо ви кинете виняток тут, застосунок не стане готовим, і подія ApplicationReadyEvent не настане.
Узагальнена часова шкала виглядає так:
sequenceDiagram
%% Runner запускається наприкінці старту: контекст уже піднято, але стан «ready» ще не настав
participant JVM as "JVM/main()"
participant Boot as "SpringApplication.run()"
participant Ctx as "ApplicationContext"
participant R as "Runner-и"
participant App as "Застосунок готовий (ready)"
JVM->>Boot: старт
Boot->>Ctx: створити/оновити контекст
Ctx-->>Boot: контекст піднято
Boot->>R: викликати ApplicationRunner/CommandLineRunner
alt перевірка не пройшла
R-->>Boot: "кидаємо виняток (fail-fast)"
Boot-->>JVM: ApplicationFailedEvent + завершення
else усе гаразд
R-->>Boot: завершуємо роботу
Boot-->>App: "ApplicationReadyEvent (готово)"
end
Зверніть увагу на дуже практичну думку: runner — це «останній рубіж перед готовністю».
Тут корисно тримати просте правило вибору місця.
- runner — одноразова дія рівня застосунку: коротке зведення, швидка перевірка, fail-fast.
- lifecycle listener — маркер фаз started / ready / failed і легка діагностика, але не основна робоча логіка.
- @PostConstruct / @PreDestroy — локальна підготовка та прибирання конкретного біна, а не всього застосунку.
- Усе важке, довге й періодичне краще взагалі не вішати на критичний startup path.
Звідси й практичний ефект: якщо виняток летить із runnerʼа, Boot фіксує failed, а до ready справа вже не доходить.
Тому runner ідеально підходить для двох речей, які нам потрібні прямо зараз.
Перше — вивести коротке стартове зведення: що за застосунок, скільки в ньому бінів, які аргументи прийшли і взагалі «ми дійшли до кінця старту». Це як табличка “Ласкаво просимо до готелю”, тільки без килимів і з бінами.
Друге — виконати швидкі критичні перевірки. Швидкість тут — ключова. Якщо перевірка потребує хвилини часу, десятка мережевих запитів і танців шамана навколо зовнішньої системи, то це вже не fail-fast, а «fail-slow-with-drama». Нам потрібен мінімальний контроль якості старту, без перетворення runnerʼа на прихований сценарний рушій.
3. StartupSummaryRunner: мінімальне зведення
Коли розробник уперше бачить лог старту Boot-застосунку, у ньому багато рядків, половина з яких виглядає як «заклинання стародавніх». Тому ідея стартового зведення в проєкті дуже проста: додати кілька людяних сигналів, які легко читати очима і які будуть однаковими під час кожного запуску. Це не заміна фреймворковим логам і не спроба ще раз пояснити Spring. Це коротка перевірка: «застосунок дійшов до runner-фази, бачить аргументи і контекст реально піднявся».
На цьому етапі курсу, поки проєкт ще в стані skeleton, нам достатньо буквально кількох речей. Зручно тримати їх у голові як маленьку «карту сигналів»:
| Сигнал | Як отримати | Навіщо це взагалі знати |
|---|---|---|
| Кількість bean definitions | context.getBeanDefinitionCount() | Швидко зрозуміти, що контекст реально піднявся і що це не «порожній запуск» |
| Активні профілі (у загальному сенсі) | environment.getActiveProfiles() | Побачити, що стартове середовище не є «невідомим» (навіть якщо профілів немає) |
| Сирі аргументи запуску | args.getSourceArgs() | Переконатися, що ми читаємо аргументи правильно, і бачити, з чим реально стартували |
| PID процесу (необовʼязково) | ProcessHandle.current().pid() | Зручно, коли ви запускаєте кілька разів і плутаєте вікна або процеси |
А тепер — чого не треба робити в StartupSummaryRunner, інакше ви перетворите просту ідею на монстра. Не треба друкувати туди «все підряд»: список усіх бінів, увесь Environment, усі system properties, усю конфігурацію JVM, усі змінні середовища. По-перше, це шум. По-друге, це потенційно небезпечно навіть у навчальному коді: іноді в env бувають токени, ключі та інші «не для друку» речі. По-третє, це погано впливає на розуміння: ви хочете дати 3–5 сигналів, а не влаштувати «бенкет духу для дебагера».
Та й тримати це краще в одному короткому runnerʼі, а не розкладати по кількох конкуруючих компонентах: тоді старт читається як одна зрозуміла політика, а не як набір сюрпризів.
4. Реалізація StartupSummaryRunner у catalog-service
Приємна частина: зараз ми візьмемо все вищесказане і зробимо живий компонент у нашому проєкті. Важливо зберігати навчальну дисципліну: один клас — одна зрозуміла відповідальність, конструктор — лише залежності, runner — коротка логіка. Ми не будемо робити «універсальний бог-раннер», який ініціалізує світ, пояснює Spring і готує каву. Ми зробимо маленьку, але корисну штуку, яку можна читати навіть після безсонної ночі.
Куди покласти клас
Коли проєкт маленький, дуже легко почати складати все в один пакет «тому що так швидше». А потім ви відкриваєте проєкт через тиждень — і він виглядає як ящик із проводами: наче все працює, але торкатися страшно. Тому вже на етапі skeleton важливо привчати себе класти класи туди, де вони логічно живуть. У нашому проєкті є пакет catalog.bootstrap — це гарне місце для стартових компонентів, які допомагають підняти застосунок і вивести діагностику.
Для StartupSummaryRunner це виглядає так (приблизно):
com.example.catalogservice
└── catalog
└── bootstrap
└── StartupSummaryRunner.java
Так, зараз це здається «зайвою охайністю». Але ця охайність — те, що потім рятує проєкт від перетворення на кашу.
Спочатку зберемо найсухіший чернетковий варіант, а потім одразу замінимо його на читабельніший. Ці два класи одночасно в проєкті не потрібні: другий блок — це і є нормальне підсумкове зведення.
Чернетковий варіант: профілі, args, кількість бінів
Зробимо просту реалізацію на ApplicationRunner, щоб одразу мати доступ до ApplicationArguments. Нам знадобляться ApplicationContext (щоб дізнатися кількість бінів) і Environment (щоб дізнатися активні профілі). Усе через constructor injection — як ми й домовилися в курсі. Цей клас потрібен лише як чернетковий probe, щоб відчути набір сигналів; трохи нижче ми замінимо його на підсумковий StartupSummaryRunner.
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component // Чернетковий probe-runner: трохи нижче замінимо його на більш людяний підсумковий варіант
public class StartupSummaryProbeRunner implements ApplicationRunner {
private final ApplicationContext context; // Потрібен для діагностичного сигналу: скільки бінів є в контексті
private final Environment environment; // Потрібен, щоб побачити активні профілі (якщо вони є)
public StartupSummaryProbeRunner(ApplicationContext context, Environment environment) {
// Конструктор — лише для залежностей, без «логіки старту»
this.context = context;
this.environment = environment;
}
@Override
public void run(ApplicationArguments args) {
// Цей метод викликається, коли контекст уже піднято, але застосунок ще не вважається «ready»
System.out.println("Кількість визначень бінів: " + context.getBeanDefinitionCount()); // Приклад: Кількість визначень бінів: 128
System.out.println("Кількість аргументів запуску: " + args.getSourceArgs().length); // Приклад: Кількість аргументів запуску: 0
System.out.println("Кількість активних профілів: " + environment.getActiveProfiles().length); // Приклад: Кількість активних профілів: 0
}
}
Підсумковий варіант: заголовок, PID і формат
Голі рядки на кшталт Кількість визначень бінів: 128 — це вже непогано, але в логах часто хочеться рамки: щоб було видно, що це саме наша зведення, а не випадковий println() з іншого місця. Додамо простий заголовок і PID процесу. PID — річ не обовʼязкова, але вона дуже допомагає, якщо ви часто перезапускаєте застосунок і плутаєте, яке вікно до якого запуску належить.
Тепер замінюємо попередній probe нормальним StartupSummaryRunner. Саме такий варіант уже має сенс залишати в проєкті як базове стартове зведення.
import java.util.Arrays;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
@Component // Цей runner і є нормальним постійним стартовим підсумком проєкту
public class StartupSummaryRunner implements ApplicationRunner {
private final ApplicationContext context; // Швидкий сигнал: контекст піднято і в ньому реально є біни
private final Environment environment; // Друкуємо активні профілі у зрозумілому для людини вигляді
public StartupSummaryRunner(ApplicationContext context, Environment environment) {
this.context = context;
this.environment = environment;
}
@Override
public void run(ApplicationArguments args) {
long pid = ProcessHandle.current().pid(); // PID допомагає відрізняти різні запуски у різних вікнах або терміналах
System.out.println("=== стартове зведення catalog-service ==="); // Заголовок, щоб зведення було видно в логах
System.out.println("pid = " + pid); // Приклад: pid = 12345
System.out.println("Кількість визначень бінів = " + context.getBeanDefinitionCount()); // Приклад: Кількість визначень бінів = 128
System.out.println("Аргументи запуску = " + Arrays.toString(args.getSourceArgs())); // Приклад: Аргументи запуску = []
System.out.println("Активні профілі = " + Arrays.toString(environment.getActiveProfiles())); // Приклад: Активні профілі = []
}
}
Далі зручніше розвивати саме цей клас: якщо хочеться додати ще один діагностичний сигнал, краще нарощувати його, а не плодити поруч другий summary-runner.
5. Навчальний fail-fast-варіант: обовʼязковий --mode
Тепер додамо те саме «впасти красиво». Ми виберемо простий навчальний сценарій: застосунок очікує option-аргумент --mode. Наприклад, ви можете запускати так: --mode=demo. Якщо --mode не передано або передано без значення — ми вважаємо це критичною помилкою старту і кидаємо виняток.
Чому це гарний навчальний приклад? Тому що він не вимагає ні файлів конфігурації, ні мережевих викликів, ні зовнішніх ресурсів. Він цілком укладається в матеріал сьогоднішнього дня: ApplicationArguments, option-аргументи і runner.
Важливо не сплутати це з новою постійною поведінкою catalog-service. Звичайне зведення з попереднього розділу залишається нормальним варіантом проєкту. Нижче — окремий навчальний runner-варіант, який зручніше вмикати замість звичайного зведеного runnerʼа на час експерименту, щоб відчути, як Boot припиняє старт під час критичної помилки.
Зробимо це акуратно, із зрозумілим повідомленням:
import java.util.List;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component // Навчальний fail-fast probe: не зобовʼязаний залишатися в проєкті як постійна вимога для запуску
public class RequiredModeCheckRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// Критична перевірка: без mode застосунок "не вважається" коректно запущеним
String mode = readRequiredMode(args);
System.out.println("mode = " + mode); // Приклад: mode = demo
System.out.println("Стартові перевірки: OK"); // Приклад: Стартові перевірки: OK
}
private String readRequiredMode(ApplicationArguments args) {
// 1) Перевіряємо, що ключ взагалі передано: --mode
if (!args.containsOption("mode")) {
// Текст помилки має бути застосовним: одразу зрозуміло, як запустити правильно
throw new IllegalStateException("Відсутня обовʼязкова стартова опція: --mode");
}
// 2) Перевіряємо, що в ключа є значення: --mode=...
List<String> values = args.getOptionValues("mode");
if (values == null || values.isEmpty() || values.get(0).isBlank()) {
throw new IllegalStateException("Стартова опція --mode має містити значення. Приклад: --mode=demo");
}
// 3) У навчальному прикладі беремо перше значення (якщо передали кілька, це усвідомлене спрощення)
return values.get(0).trim();
}
}
Тут важливо кілька моментів, які часто пропускають початківці.
По-перше, containsOption("mode") перевіряє наявність самого ключа --mode. Але наявність ключа ще не гарантує, що в нього є нормальне значення. Тому ми додатково читаємо getOptionValues("mode") і перевіряємо null, порожнечу списку та isBlank().
По-друге, ми кидаємо IllegalStateException із зрозумілим текстом. Не “mode error”, не “something wrong”, а фраза, яка прямо говорить, що потрібно зробити. Це називається «повідомлення, яке можна використовувати». У вас може бути найкрасивіший стек-трейс, але якщо текст помилки не підказує, як виправити запуск, то це не fail-fast, а fail-annoying.
По-третє, ми не ловимо виняток. І це принципово. Fail-fast працює лише тоді, коли помилка справді зупиняє запуск. Якщо ви обгорнете це в try/catch і зробите вигляд, що все добре, ви повернетеся у світ fail-late — просто з додатковим самообманом.
У навчальному проєкті це всього лише зручний спосіб побачити fail-fast наживо; після такого експерименту catalog-service не зобовʼязаний назавжди вимагати --mode для кожного запуску.
Контрприклад (так робити не потрібно) виглядає приблизно так:
try {
readRequiredMode(args); // Ніби "перевірили", але далі все зіпсуємо
} catch (Exception e) {
System.out.println("Ой: " + e.getMessage()); // Повідомили про помилку...
// ...і продовжуємо старт, ніби нічого не сталося (погана ідея): застосунок буде "напівпрацездатним"
}
Це «успішний запуск зламаного застосунку». Так, він запуститься. Так, ви навіть побачите warning. Але якщо mode справді критичний для поведінки, ви лише відкладете проблему на потім, і вона спливе там, де ви вже найменше хочете її бачити.
6. Межі runnerʼа: без «другої фази»
Runner — потужна точка розширення, і саме тому він дуже легко перетворюється на місце, куди «пхають усе». Трохи логіки, трохи перевірки, трохи ініціалізації, потім «а давайте ще один маленький виклик»… і ось ви вже написали половину застосунку в одному методі run(). Щоб цього не сталося, корисно тримати просту рамку: runner має бути коротким, передбачуваним і швидким.
Якщо говорити зовсім практично, runner зазвичай робить одну з двох речей. Він або друкує діагностичне зведення, щоб було видно, що саме запустилося. Або виконує короткі перевірки й валить старт у разі критичних проблем. Інколи обидві речі поєднуються, але тоді вони мають залишатися короткими та очевидними, як у прикладах вище.
Ще один нюанс: runner — це не місце для «важкої ініціалізації». Якщо всередині run() ви робите довгу роботу, ви розтягуєте старт застосунку. У навчальному проєкті це може просто дратувати, а в реальному сервісі це перетворюється на проблему експлуатації: розгортання повільне, перезапуск довгий, діагностика складна. Усе важке або взагалі не повинно жити на старті, або потребує іншого механізму, не на критичному startup path.
І останнє: якщо у вас кілька runnerʼів, порядок їхнього виклику може бути неочевидним. На ранньому етапі курсу краще тримати один явний runner зі зведенням і перевірками, ніж розвішувати по проєкту пʼять різних «маленьких» runnerʼів і потім намагатися пригадати, чому вони друкують рядки у несподіваному порядку.
7. Типові помилки під час старту застосунку
Нижче — кілька помилок, які найчастіше трапляються, коли люди вперше починають писати стартову логіку. Їх легко зробити «за звичкою», а потім дуже складно пояснити, чому застосунок поводиться дивно.
Помилка №1: “fail-fast” лише на словах — виняток ловиться і ігнорується.
Найприкріша ситуація: ви написали перевірку, навіть кинули виняток… а потім обгорнули все в try/catch і продовжили старт, бо “ну нехай хоча б підніметься”. Так у вас виходить застосунок, який уміє повідомляти про помилку, але не вміє на неї реагувати. Якщо умова справді критична, виняток має зупинити запуск.
Помилка №2: StartupSummaryRunner перетворюється на «дамп усього світу».
Є спокуса «для діагностики» роздрукувати все: усі аргументи, усі env vars, усі біни, увесь контекст. У підсумку замість діагностики виходить шум. Око не має за що зачепитися, важливі сигнали губляться, а інколи ви ще й випадково друкуєте чутливі значення. Зведення має бути коротким і стабільним, а не енциклопедією.
Помилка №3: критична логіка старту ховається в конструктор або @PostConstruct.
Конструктор біна — це місце для залежностей, а не для дій. @PostConstruct — це локальний lifecycle конкретного обʼєкта, а не «офіційний старт застосунку». Коли ви ховаєте туди критичну стартову логіку, ви ускладнюєте розуміння та діагностику: хто, коли і чому це викликав. Runner робить стартову логіку явною і читабельною.
Помилка №4: runner виконує важку роботу і «тримає старт» занадто довго.
Якщо runner виконується секунди або хвилини, ви втрачаєте передбачуваність старту. Навіть у навчальному проєкті це дратує, а в реальному сервісі це ламає розгортання і перезапуски. Гарна стартова перевірка або проходить швидко, або швидко падає — без «довгих роздумів».
Помилка №5: перевірка є, але повідомлення про помилку безкорисне.
throw new IllegalStateException("bad") технічно працює, але практичної цінності майже не має. Повідомлення має підказувати, що саме не так і що зробити: наприклад, “Відсутня обовʼязкова стартова опція: --mode=demo”. Це маленька річ, яка в сумі економить багато часу, особливо коли ви повертаєтеся до проєкту після паузи.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ