JavaRush /Курси /Spring Boot /Fail-fast і StartupSummaryR...

Fail-fast і StartupSummaryRunner

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

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”. Це маленька річ, яка в сумі економить багато часу, особливо коли ви повертаєтеся до проєкту після паузи.

1
Опитування
Spring Boot, рівень 6, лекція 4
Недоступний
Spring Boot
Старт застосунку та події
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ