1. Ідея: дві фази
Старт контейнера складається з двох фаз: спочатку Spring має зрозуміти, які біни взагалі існують і як їх створювати, а потім — справді створити їх, зв’язати залежності й підготувати до роботи. Тому рядок new AnnotationConfigApplicationContext(AppConfig.class) виглядає коротким, але всередині приховує доволі великий процес. Якщо не розрізняти ці етапи, помилки старту здаються «магією», хоча насправді це лише звичайний звіт будівельника, який не зміг зібрати шафу за інструкцією.
Ім’я, тип і статус singleton ми вже бачимо в готовому біні. Тепер важливо зрозуміти, як контейнер узагалі доходить до цього готового об’єкта.
Давайте зафіксуємо базову ідею — дуже грубо, але чесно:
- Фаза реєстрації: контейнер збирає «карту світу» — перелік бінів і правила їх створення (це і є BeanDefinition та пов’язані метадані).
- Фаза створення: контейнер перетворює цю карту на реальні об’єкти, зв’язує їхні залежності та готує застосунок до запуску.
Щоб було простіше, можна уявити це як «проєкт будинку» і саме будівництво. BeanDefinition — це не цеглини і не стіни. Це креслення, перелік матеріалів та інструкція: що, у якому порядку і з чого потрібно зібрати.
2. Фаза реєстрації: описи без об’єктів
На фазі реєстрації Spring поводиться як дуже прискіпливий бібліотекар: розкладає картки з тим, що в нас є, як це називається, якого типу й від чого залежить. У цей момент у вас уже можуть існувати BeanDefinition, але самі об’єкти ще не зобов’язані бути створеними. Це важливий момент: Spring не має «створити все негайно», щоб уже можна було говорити про структуру майбутнього застосунку.
Щоб відчути цю різницю на практиці, зручно запускати контекст не «в один рядок», а покроково — через register(...) і refresh().
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext()) {
// Порожній контекст: він ще НЕ знає про вашу конфігурацію і НЕ створював ваші біни.
context.register(AppConfig.class); // Реєструємо описи (BeanDefinition) з AppConfig.
// На цьому етапі визначення вже є (контейнер "знає", що такий бін має існувати)...
System.out.println(context.containsBeanDefinition("appConfig")); // true
// ...але singleton-екземпляр ще не створено (об’єкта немає).
System.out.println(context.getBeanFactory().containsSingleton("appConfig"));// false
context.refresh(); // Запускаємо повний життєвий цикл: обробка конфігурації + створення singleton-ів.
// Після refresh() singleton-екземпляр уже має з’явитися в контейнері.
System.out.println(context.getBeanFactory().containsSingleton("appConfig"));// true
}
Тут ми використовуємо AppConfig як «контрольний зразок». Після register(AppConfig.class) контейнер уже знає, що такий бін має існувати, тобто метадані зареєстровано, але сам об’єкт ще не створено — singleton-екземпляра немає. Після refresh() контекст проходить стартовий цикл, і singleton з’являється.
Зараз важлива не «магія refresh()», а спостереження: опис об’єкта і сам об’єкт — різні сутності. Якщо змішувати їх у голові, далі буде важко: ви почнете очікувати, що раз «бін є», то й об’єкт уже живе, а це не завжди так.
Ще один хороший спосіб відчути фазу реєстрації — подивитися на список імен визначень бінів. Він включає і ваші визначення, і інфраструктуру Spring, тобто внутрішні речі, без яких контейнер не є контейнером.
import java.util.Arrays;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext()) {
// Реєструємо конфігурацію: з’являться визначення бінів, але без "реальних" об’єктів.
context.register(AppConfig.class);
// Виводимо імена визначень: тут буде і ваш код, і інфраструктурні визначення Spring.
Arrays.stream(context.getBeanDefinitionNames())
.limit(10)
.forEach(System.out::println); // перші 10 імен (у вас може бути інакше)
}
Не лякайтеся, якщо побачите там довгі імена на кшталт «internal...». Це нормально: Spring реєструє інфраструктурні визначення, щоб потім коректно обробити анотації, конфігурацію і взагалі «оживити» застосунок. Подробиці зараз не потрібні. Важливо звикнути до думки: у контексті є не тільки ваші бізнес-біни, а й інфраструктура самого Spring.
3. Фаза створення: об’єктний граф
Після того як у контейнера з’явилася «карта світу» з описів, він починає робити те, заради чого ми взагалі його покликали: створювати об’єкти і зв’язувати залежності. На цьому етапі Spring бере bean definition, вирішує, як створити об’єкт, викликає конструктори, підставляє залежності й отримує готовий екземпляр. У нашому базовому сценарії більшість singleton-бінів створюється прямо під час старту контексту.
Дуже важливо відчути це: ви ще не встигли викликати getBean(...), а частину об’єктів уже створено.
Найпростіше побачити це можна, якщо додати в конструктор гучний друк. Так, це не бойовий стиль, але як навчальний ліхтарик — чудово.
public class ScenarioRunner {
private final OrderPlacementService service;
public ScenarioRunner(OrderPlacementService service) {
// Цей println — маркер: якщо він спрацював, значить бін уже створено контейнером.
System.out.println("ScenarioRunner створено"); // ScenarioRunner створено
this.service = service; // Залежність Spring підставляє під час створення біна.
}
}
А тепер подивимося, коли саме цей конструктор спрацьовує:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Важливо: конструктор AnnotationConfigApplicationContext(...) автоматично викликає refresh(),
// тому singleton-біни можуть бути створені ще ДО цього println.
System.out.println("Контекст запущено"); // Контекст запущено
// Явний запит біна (часто він уже існує як singleton).
context.getBean(ScenarioRunner.class);
}
Якщо ваш ScenarioRunner — звичайний singleton-бін, то в консолі ви, найімовірніше, побачите приблизно такий порядок:
ScenarioRunner створено
Контекст запущено
І ось це — центральна думка лекції в одному спостереженні. Контейнер створює singleton-біни під час старту контексту, а не «ледачо» при першому getBean(...) у нашому базовому сценарії. Тому багато проблем зі зв’язуванням залежностей справді проявляються одразу на старті, навіть якщо ви ще не чіпали бізнес-код.
У ContextFlow ця поведінка особливо корисна: якщо застосунок не може зібрати ScenarioRunner або сервіси, від яких він залежить, краще чесно впасти на старті, ніж запуститися і зламатися вже посеред сценарію — коли ви встигли «створити замовлення», «покликати сповіщення», «майже все зробити», а тепер доводиться розбирати напівзламаний стан.
4. ContextFlow: конфігурація → ScenarioRunner
Зараз ми не вигадуємо новий проєкт — ми просто перестаємо дивитися на контекст як на «чарівну коробку» і починаємо бачити, що він реально робить для нашого ContextFlow. Нагадую мінімальний сюжет: у нас є точка входу ScenarioRunner, який викликає бізнес-сервіси, ті — свої залежності, і так далі. Контейнер має побудувати цей граф, і робить він це саме за схемою «опис → створення».
Нехай у нас є базовий запуск застосунку — він уже з’являвся раніше, але зараз ми дивимося на нього під новим кутом:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Створення контексту з AppConfig зазвичай призводить до:
// 1) реєстрації визначень (BeanDefinition)
// 2) створення singleton-ів (під час старту, щоб fail-fast)
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
var runner = context.getBean(ScenarioRunner.class); // Беремо готову точку входу сценарію.
runner.run(); // Запускаємо бізнес-сценарій.
}
}
}
Що «сховано» всередині new AnnotationConfigApplicationContext(AppConfig.class) з погляду нашої моделі?
Спочатку контейнер аналізує конфігурацію і збирає описи: які біни є, як вони називаються, хто від кого залежить. Сюди ж належить розуміння, що ScenarioRunner залежить від OrderPlacementService, а той — від портів та інфраструктури на кшталт OrderStore, NotificationSender і AuditWriter. Набір залежностей у локальному фрагменті можна скорочувати, але ролі в графі від цього не змінюються. Коли описи готові, контейнер переходить до створення: піднімає singleton-екземпляри, зв’язує залежності і лише потім повертає керування в main.
Давайте зробимо маленьку «діагностичну лупу»: подивимося, які з наших singleton-ів уже існують одразу після старту контексту, тобто ще до будь-якого явного getBean.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Ми ще нічого не запитували через getBean(...),
// але singleton-біни вже могли бути створені на старті.
System.out.println(context.getBeanFactory().containsSingleton("scenarioRunner")); // true
System.out.println(context.getBeanFactory().containsSingleton("orderPlacementService")); // true
System.out.println("Готово до запуску сценарію"); // Готово до запуску сценарію
}
Якщо у вас у AppConfig справді є @Bean-методи з такими іменами або їхні еквіваленти, ви побачите true. Це означає, що контейнер не просто «знає», що такий бін має існувати. Він уже створив екземпляр і зберігає його як singleton усередині контексту.
Тепер важливе застереження, щоб не виникло плутанини. Спосіб, яким саме Spring дістає bean definitions із вашої конфігурації, включає інфраструктуру контейнера і низку внутрішніх кроків. Ми зараз спеціально не заглиблюємося в ці подробиці. Нам достатньо головного: контейнер не може створити об’єкти, поки не зібрав їхні описи, і часто створює singleton-об’єкти ще до запуску вашої логіки.
Щоб красиво закріпити, намалюємо просту схему — спеціально спрощену:
flowchart TD
A[Ви запускаєте ApplicationContext] --> B[Контейнер збирає описи бінів]
B --> C[Контейнер створює singleton-біни і зв’язує залежності]
C --> D[Контекст готовий]
D --> E[Ви берете ScenarioRunner і запускаєте сценарій]
Якщо тримати цю схему в голові, питання «чому впало на старті» перестає бути загадкою і перетворюється на цілком інженерну задачу: на якому етапі контейнер зрозумів, що щось не сходиться, і що саме не сходиться.
5. Fail-fast на старті контексту
Коли контейнер переходить до фази створення, він стає строгим і трохи прискіпливим: «Ви просили мене зібрати застосунок — я збираю. Не можу — повідомляю одразу». Таку поведінку називають fail-fast, і вона економить і ваш час, і ваші нерви. Інакше застосунок зробив би вигляд, що все добре, а потім уже посеред сценарію раптом з’ясувалося б, що половини залежностей немає.
Давайте спеціально створимо «зламану» конфігурацію, де контейнеру бракує однієї цеглини. Наприклад, ми оголосили сервіс, який хоче NotificationSender, але забули зареєструвати сам NotificationSender.
Нижче граф спеціально скорочено до одного проблемного місця: нам важливо побачити сам зрив на фазі створення, а не весь сервіс цілком.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BrokenAppConfig {
@Bean
public OrderPlacementService orderPlacementService(
OrderStore store,
NotificationSender sender // Немає біна -> контейнер не зможе зібрати граф і впаде на старті.
) {
// На фазі створення Spring спробує викликати цей метод і передати аргументи.
// store він ще може знайти, а sender — ніде взяти.
return new OrderPlacementService(store, sender);
}
}
А тепер спробуємо підняти контекст:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class BrokenStart {
public static void main(String[] args) {
// Помилка станеться прямо тут, під час створення контексту:
// контейнер почне створювати singleton-біни і зрозуміє, що залежність не задоволена.
new AnnotationConfigApplicationContext(BrokenAppConfig.class);
// До цього рядка виконання не дійде (fail-fast на старті контексту).
System.out.println("Ви не побачите цей рядок"); // не виведеться
}
}
Контекст спробує дійти до фази створення OrderPlacementService, зрозуміє, що йому ніде взяти NotificationSender, і завершить запуск з помилкою. Назви винятків тут поки що не важливі. Нам потрібен сам факт: помилка виникає не «всередині бізнес-логіки», а в момент, коли контейнер збирає об’єктний граф під час refresh(). Саме тому застосунок падає fail-fast ще до runner.run().
6. Якорі та діагностика
Реєстрація vs створення
Зараз ми вже обговорили концепцію словами й побачили її в коді, але мозку початківця-програміста часто не вистачає «якоря» — простої таблиці, до якої можна повертатися, коли все знову починає здаватися каламутним. І це нормально: Spring великий, а ви поки що одна людина, та ще й без суперздібностей.
Ось мінімально достатнє порівняння двох фаз:
| Що порівнюємо | Фаза реєстрації (definitions) | Фаза створення (instances) |
|---|---|---|
| Що є в контейнері | Опис: імена, типи, залежності «на папері» | Реальні об’єкти, з якими вже можна працювати |
| Чи можна «викликати метод сервісу» | Ні, сервіс може ще не існувати | Так, можна брати бін і працювати |
| Де частіше спливають помилки | Проблеми в описі (наприклад, конфлікт імен) | Проблеми під час складання графа (бракує залежності, є неоднозначність тощо) |
| Головний сенс | Контейнер розуміє, що треба зібрати | Контейнер реально збирає і «оживляє» |
Якщо тримати цю таблицю в голові, то BeanDefinition перестає бути «дивним словом із документації» і стає нормальною інженерною сутністю: це опис майбутнього об’єкта до того, як він з’явився.
Діагностика старту в main
Тут є тонка межа: з одного боку, хочеться підсвітити фази реєстрації та створення. З іншого — не хочеться перетворювати проєкт на вічний дебаг з getBeanFactory() і сотнею println. Нам потрібен лише один ліхтарик: побачити, що definitions і instances — справді різні сутності. Тому тримаємо такий код у точці старту, а не розмазуємо його по бізнес-класах.
Приклад акуратного діагностичного виводу поруч зі стартом:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Кількість визначень (BeanDefinition) — це "карта світу" контейнера після аналізу конфігурації.
System.out.println("Визначень бінів: " + context.getBeanDefinitionCount()); // наприклад: 18
// Перевіряємо, чи створено конкретний singleton (це вже про фазу створення).
System.out.println("Singleton scenarioRunner створено: " +
context.getBeanFactory().containsSingleton("scenarioRunner")); // true
// Запуск сценарію: залежності вже мають бути зібрані на старті (або контекст упав би раніше).
context.getBean(ScenarioRunner.class).run();
}
Так, getBeanDefinitionCount() може відрізнятися у різних студентів. Це нормально: версії Spring, набір внутрішніх infrastructure beans і навіть оточення можуть трохи змінювати число. Але сама ідея залишається незмінною: контейнер спочатку «бачить» застосунок як набір визначень, а потім уже має частину singleton-екземплярів, готових до роботи.
Найважливіше правило, яке варто засвоїти вже зараз: lookup і діагностика — в точці входу, а не всередині бізнес-логіки. Якщо ваш OrderPlacementService почне сам діставати собі залежності через контекст, ви непомітно повернетеся до ручного підключення залежностей, тільки з додатковою магією — і потім будете лікувати це все життя, трохи зітхаючи.
7. Типові помилки: дві фази старту
Коли ви вперше починаєте бачити контейнер не як «все одразу», а як процес, мозок часто робить кілька типових стрибків у неправильний бік. Це не привід засмучуватися: це звичайні «помилки росту», як синтаксична помилка в першому for-циклі. Важливо просто зрозуміти, де саме ви самі себе обманюєте, і підправити модель у голові.
Помилка №1: вважати, що «бін існує» означає «об’єкт уже створено».
Початківець бачить, що в конфігурації є @Bean, і думає: «отже, об’єкт уже є». На практиці спочатку з’являється опис (BeanDefinition), і лише потім, на фазі створення, контейнер робить реальний екземпляр. Через це виникають дивні очікування: «чому мій конструктор не відпрацював?» або «чому все впало до runner.run()?».
Помилка №2: думати, що Spring створює singleton-біни лише під час першого getBean(...).
У базовому сценарії, який у нас зараз, контейнер створює singleton-біни під час старту контексту. Тому конструктори можуть відпрацювати ще до того, як ви взагалі написали context.getBean(...). Це не фокус, а нормальна поведінка: контейнер хоче fail-fast і хоче переконатися, що застосунок узагалі збирається.
Помилка №3: лякатися того, що контекст падає «занадто рано».
Іноді здається: «Ну міг би дати мені хоча б запуститися, а потім я б розібрався». Насправді раннє падіння — це послуга. Контейнер каже: «Я не можу зібрати об’єктний граф. Якщо ви продовжите, буде тільки гірше». Навіть у проєктах трохи більших за навчальний це економить години часу.
Помилка №4: намагатися розбиратися в помилці лише за верхнім рядком stack trace.
На старті Spring може видати дуже довгий stack trace, і це виглядає як «темна магія». Але сенс майже завжди схований у причині: який бін створювався і яка залежність не знайшлася або не вибралася. Поки достатньо просто зафіксувати: така помилка належить до фази створення — контейнер збирав граф залежностей, і десь у ньому вимоги не зійшлися.
Помилка №5: перетворювати діагностику контейнера на стиль програмування.
Після перших успіхів легко почати писати код на кшталт: «у сервісі дістану ApplicationContext і сам візьму потрібний бін». Це швидко перетворює DI на service locator і руйнує саму ідею контейнера: залежності стають прихованими, тестованість падає, а зв’язування перестає бути прозорим. Навіть якщо воно «працює», це майже завжди шлях до болю, а не до архітектури.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ