1. Анотації як метадані контейнера
Коли ви починаєте писати код на Spring, дуже легко потрапити в пастку ілюзії: «я поставив @Autowired, отже Java сама зрозуміє, що сюди треба впровадити залежність». Це приблизно як наклеїти на ноутбук стікер «будь ласка, полагодься» і чекати, що він сам піде до сервісного центру. Анотація — це лише мітка в байткоді, тобто метадані, а не виконуваний код. Щоб із метаданих з’явилася реальна поведінка, потрібен хтось, хто їх прочитає й виконає відповідні дії. У нашому випадку цим «хтось» є Spring-контейнер — точніше, його інфраструктурні обробники.
Ми вже побачили, що контейнер уміє втручатися в життя біна навколо фази ініціалізації і навіть може повернути назовні обгортку замість вихідного об’єкта. Тепер застосуємо ту саму логіку до знайомих анотацій: тоді @Autowired, @PostConstruct і @EventListener перестають виглядати як «воно саме так працює».
Якщо хочете відчути різницю руками, можна провести дуже короткий мисленнєвий експеримент: об’єкт, створений через new, — це просто об’єкт. У нього є анотації, але ніхто їх не прочитав, ніхто не впровадив залежності, ніхто не викликав init-callback-и, ніхто не зареєстрував його як слухача. А ось бін, створений контейнером, проходить через увесь пайплайн і «оживає».
Мінідемо (на рівні «анотація існує, але сама нічого не робить»):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import java.lang.reflect.Method;
public class AnnotationProbe {
public static void main(String[] args) throws Exception {
// Дивимося на анотацію через reflection: це просто метадані в байткоді
Method m = ReportOutputManager.class
.getDeclaredMethod("setResourceLoader", ResourceLoader.class);
// Перевіряємо факт наявності @Autowired, але жодних дій із DI тут не відбувається
System.out.println(m.isAnnotationPresent(Autowired.class)); // true
// І все. Жодного "впровадження" через це не відбулося.
}
}
Ключова думка: наявність @Autowired — це лише факт, що хтось може захотіти обробити цей метод як точку впровадження. Чи обробить — залежить від того, чи існує інфраструктура контейнера і чи пройшов бін через неї.
2. Інфраструктурні обробники ApplicationContext
Коли ви запускаєте AnnotationConfigApplicationContext, зазвичай здається, що в контейнері живуть лише ваші сервіси на кшталт OrderPlacementService, AuditService, NotificationTemplateCatalog. Але насправді разом із ними живе чимало службових бінів, які забезпечують роботу всієї анотаційної моделі. Їх рідко пишуть вручну, але саме вони й роблять Spring Springʼом.
Важливий психологічний момент: ці «магічні» обробники не є чимось містичним. У більшості випадків це звичайні біни, які просто реалізують спеціальні інтерфейси на кшталт BeanFactoryPostProcessor і BeanPostProcessor — і ще кілька контрактів. Контейнер знає, коли їх викликати, і робить це в правильній фазі пайплайна запуску.
Можна навіть «помацати» факт, що вони існують у контексті. В інфраструктурних обробників є внутрішні імена — вони виглядають як довгі рядки, бо Spring не хотів випадково конфліктувати з нашими іменами:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class InfraBeansProbe {
public static void main(String[] args) {
// Важливо: контекст стартує і сам реєструє свої інфраструктурні біни
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// AutowiredAnnotationBeanPostProcessor — той самий, хто виконує @Autowired
System.out.println(
ctx.containsBean("org.springframework.context.annotation.internalAutowiredAnnotationProcessor")
); // true
// CommonAnnotationBeanPostProcessor — обробляє, наприклад, @PostConstruct тощо
System.out.println(
ctx.containsBean("org.springframework.context.annotation.internalCommonAnnotationProcessor")
); // true
// EventListenerMethodProcessor — сканує @EventListener і реєструє слухачів
System.out.println(
ctx.containsBean("org.springframework.context.event.internalEventListenerProcessor")
); // true
}
}
}
Тут важливо не змішувати всі анотації в одну купу. @Configuration, @ComponentScan і @Import спрацьовують рано, поки контейнер ще розширює мапу BeanDefinition; тут працюють механізми на рівні визначень, такі як ConfigurationClassPostProcessor, який уміє додатково реєструвати нові визначення. @Autowired і @PostConstruct спрацьовують пізніше, коли конкретний об’єкт уже створено і він проходить фазу ініціалізації; це вже обробка на рівні екземпляра через AutowiredAnnotationBeanPostProcessor і CommonAnnotationBeanPostProcessor. @EventListener стоїть трохи осторонь: на старті контейнер знаходить такі методи й реєструє їх як обробники подій, тож це теж не «сама анотація робить магію».
Якщо коротко, ось зручна «карта відповідностей» — не як довідник, а як компас: куди дивитися, коли ви ставите запитання «чому це працює?»:
| Що ви пишете в коді | Що має статися в контейнері | Тип механізму |
|---|---|---|
| @Configuration, @ComponentScan, @Import | контейнер має прочитати конфігурацію і створити BeanDefinition для знайдених компонентів | рання обробка на рівні визначень через BeanDefinitionRegistryPostProcessor / ConfigurationClassPostProcessor |
| @Autowired | контейнер має знайти точки впровадження і виконати інʼєкцію | рівень BPP (робота з екземпляром + DI) |
| @PostConstruct | контейнер має викликати init-callback після впровадження залежностей | рівень BPP (частина фази ініціалізації) |
| @EventListener | контейнер має знайти методи-слухачі й зареєструвати їх до публікації подій | окрема інфраструктура запуску, яка знаходить і реєструє методи-слухачі |
| автообгортання біну | контейнер має повернути з getBean() «обгортку», що додає поведінку | рівень BPP (після ініціалізації) |
Зверніть увагу на важливий стиль мислення: ми перестаємо говорити «анотація працює» і починаємо говорити «контейнер виконав роботу, на яку вказали метадані». Це і є доросла ментальна модель.
3. Механіка @Autowired
У @Autowired репутація «найочевиднішого» механізму в Spring. Але саме через цю очевидність він часто перетворюється на магію: новачок думає, що @Autowired — це майже ключове слово мови. Насправді все прозаїчніше: є інфраструктурний компонент контейнера, який на етапі створення біна шукає точки впровадження і виконує інʼєкцію через BeanFactory — тобто через ту саму систему розв’язання залежностей, яку ви вже знаєте з тем про кандидатів, @Primary, @Qualifier тощо.
Щоб не заохочувати field injection як звичку, покажемо це на setter injection, але підкреслимо: це демонстрація механіки, а не «як завжди треба робити».
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
@Component
public class ReportOutputManager {
// Поле залишається null доти, доки контейнер не виконає впровадження
private ResourceLoader resourceLoader;
@Autowired
public void setResourceLoader(ResourceLoader resourceLoader) {
// Контейнер знайде цей метод як точку впровадження і викличе його під час створення біна
this.resourceLoader = resourceLoader;
}
public boolean isReady() {
// Простий індикатор того, чи впроваджено залежність
return resourceLoader != null;
}
}
Тепер найважливіше порівняння: new проти керованого контейнером об’єкта.
public class WiringRealityCheck {
public static void main(String[] args) {
// Створили об'єкт вручну: контейнер не брав участі, DI не виконувалося
ReportOutputManager plain = new ReportOutputManager();
System.out.println(plain.isReady()); // false
}
}
Об’єкт створено, анотація в класі є, але інʼєкції немає — бо контейнер не брав участі. Тепер те саме через контекст:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class WiringRealityCheckSpring {
public static void main(String[] args) {
// Створили контекст: далі об'єкт буде створено і «прогнано» через ланцюжок BPP
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
ReportOutputManager manager = ctx.getBean(ReportOutputManager.class);
// Тут setter уже викликав контейнер
System.out.println(manager.isReady()); // true
}
}
}
Тут «чудо» дуже конкретне: контейнер створив об’єкт, потім пройшовся своїми обробниками, побачив @Autowired на методі, знайшов у контексті відповідний ResourceLoader і викликав setter. Це не мова, не JVM і не «якось само». Це керована фаза пайплайна.
Корисний зв’язок із попередніми лекціями дня: такий функціонал можливий лише тому, що контейнер допускає «обробку екземплярів» через BeanPostProcessor. Саме в цьому місці ми можемо «підмішати» свою поведінку: наприклад, відстежити, що бін готовий, або логувати його створення, або навіть замінити об’єкт на обгортку.
4. Механіка @PostConstruct
Тема lifecycle callbacks (@PostConstruct, @PreDestroy, initMethod, destroyMethod) раніше могла виглядати як «ще одна анотація, яку треба запам’ятати». Тепер ми можемо пояснити це інакше: init-callback — це частина суворо визначеної фази життєвого циклу, і він спрацьовує, бо контейнер у потрібний момент викликає потрібний метод. JVM сама по собі не зобов’язана викликати @PostConstruct. Ба більше, якщо об’єкт створено через new, JVM нічого не викличе — і не має.
Зробімо короткий і дуже наочний пробник:
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
@Component
public class TemplateCatalogProbe {
public TemplateCatalogProbe() {
// Конструктор викличеться і під час new, і під час створення біну контейнером
System.out.println("constructor"); // constructor
}
@PostConstruct
void init() {
// Цей метод викличе саме контейнер (у фазі ініціалізації), а не JVM «сама по собі»
System.out.println("postConstruct"); // postConstruct
}
}
А тепер порівняймо створення вручну та створення через Spring:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class PostConstructDemo {
public static void main(String[] args) {
System.out.println("=== new ==="); // === new ===
new TemplateCatalogProbe(); // constructor
System.out.println("=== spring ==="); // === spring ===
try (var ctx = new AnnotationConfigApplicationContext(TemplateCatalogProbe.class)) {
// Важливо: під час старту контексту бін створиться,
// потім контейнер виконає фазу ініціалізації і викличе @PostConstruct
}
}
}
У цьому маленькому фрагменті ви «бачите» пайплайн очима System.out.println(). Через new ви побачите лише constructor, а через Spring — і constructor, і postConstruct. Тобто init-callback не є «магією мови», це частина процедури initializeBean(...) всередині контейнера, навколо якої й крутиться ланцюжок BeanPostProcessor.
І ще один важливий висновок, який особливо стане в пригоді в реальному проєкті: якщо @PostConstruct раптом не спрацював, це майже завжди означає не «анотація зламалася», а «об’єкт не став bean-ом» або «контекст не дійшов до фази ініціалізації» (упав раніше на помилці впровадження, наприклад).
5. Механіка @EventListener
Із @EventListener відбувається схожа історія: здається, що це «просто анотація на методі». Але метод із @EventListener — це не вбудована функція Java. Це звичайний метод, який має бути знайдений контейнером і зареєстрований як обробник подій. І тільки після цього, коли в ContextFlow публікація події викликає publisher.publishEvent(...), контейнер розуміє: «ага, ось список обробників, зараз я їх викличу».
Почнімо з простого доменного події (у нашому проєкті це вже знайома модель):
public record OrderCreatedEvent(String orderId) {
}
Тепер слухач, який за подією пише щось в аудит (у реальному проєкті це буде AuditWriter, а тут достатньо println(), щоб побачити факт виклику):
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onCreated(OrderCreatedEvent event) {
// Цей метод викличеться НЕ тому, що є анотація,
// а тому, що контейнер зареєстрував його як обробник події
System.out.println("АУДИТ: створено " + event.orderId()); // АУДИТ: створено order-1
}
}
І демо публікації події, яке використовує ApplicationEventPublisher:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
public class DemoPublisher {
private final ApplicationEventPublisher publisher;
public DemoPublisher(ApplicationEventPublisher publisher) {
// Publisher впроваджується контейнером як інфраструктурна залежність контексту
this.publisher = publisher;
}
public void run() {
// Важливо: подія публікується через publisher контейнера,
// саме тоді Spring зможе доставити її всім зареєстрованим слухачам
publisher.publishEvent(new OrderCreatedEvent("order-1"));
}
}
Запуск:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class EventListenerDemo {
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(
DemoPublisher.class,
AuditOrderEventsListener.class
)) {
// Беремо publisher як бін: це гарантує, що DI і реєстрація слухачів уже відбулися
ctx.getBean(DemoPublisher.class).run(); // АУДИТ: створено order-1
}
}
}
Що тут особливо важливо для ментальної моделі: @EventListener не «вмикає події». Події як механізм уже існують у контексті (ApplicationEventPublisher є частиною ApplicationContext). Анотація — лише спосіб попросити контейнер: «будь ласка, зареєструй ось цей метод як обробник».
І ось тут з’являється чудовий міст до теми дня: щоб @EventListener запрацював, усередині контейнера є інфраструктурна обробка на старті, яка проходить по бінах, шукає такі методи й пов’язує їх з event multicaster-ом. Тобто події виглядають як «просто publishEvent», але насправді там немає нічого «простого»: до цього контейнер має зібрати реєстр слухачів, і це теж частина пайплайна запуску.
6. Автообгортання бінів і auto-proxying
Якщо раніше контейнер здавався місцем, яке просто «створило об’єкт і повернуло його», то тепер можна додати важливий поворот сюжету. Після фази ініціалізації він може покласти в контекст не сам target, а wrapper навколо нього. На NotificationSender уже було видно, що BeanPostProcessor здатний централізовано додати технічну поведінку, не переписуючи вихідний клас.
Якщо навколо NotificationSender є технічна обгортка TrackingNotificationSender, то на рівні механіки все виглядає так:
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof NotificationSender sender) {
return new TrackingNotificationSender(sender);
}
return bean;
}
Після такого кроку інші частини застосунку отримують уже wrapper. Коли замість ручної Java-обгортки використовується runtime-proxy, принцип залишається тим самим: контейнер підміняє підсумковий об’єкт і додає навколо target технічну поведінку. Для цієї теми зараз достатньо побачити саму ідею — бін у контексті не зобов’язаний бути «голим» екземпляром вашого класу.
Саме тому в дебагері, у getClass() і іноді навіть у instanceof-перевірках можна побачити не те, що ви очікували за вихідним класом. Це не «Spring знову бешкетує», а прямий наслідок того, що контейнер повернув назовні вже оброблений об’єкт.
7. Діагностика: «анотація не спрацювала»
Коли в проєкті щось не працює — не впровадилося, не ініціалізувалося, не викликався слухач, — перша реакція новачка: підозрювати фреймворк. Це нормально: Spring великий, стек-трейси довгі, а кава закінчується. Але в 95% випадків причина прозаїчніша й зав’язана на пайплайн, який ми зараз вивчаємо.
Починати діагностику найкорисніше з питання: «Цей об’єкт узагалі керований контейнером бін?» Якщо ви створили його через new, то для контейнера його не існує. Жодні @Autowired, @PostConstruct, @EventListener за замовчуванням не зобов’язані працювати для таких об’єктів, бо вони не пройшли через пайплайн запуску й ланцюжок обробників. У ContextFlow це особливо важливо, бо ми свідомо дотримуємося правила: бізнес-сервіси не отримують ApplicationContext і не створюють собі залежності вручну. Порушили правило — і ви буквально «вистрибнули зі світу Spring».
Друге питання: «Контекст точно успішно стартував і дійшов до фази створення singleton-beans?» Якщо контекст упав на помилці старту, то анотації «не зламалися» — просто контейнер не дійшов до фази, де вони обробляються. Тому так важлива модель «register → refresh → BFPP → створення beans → BPP → init».
Третє питання: «Подія публікується через контейнер?» Іноді люди беруть new SimpleApplicationEventMulticaster() або просто викликають метод слухача безпосередньо й очікують, що @EventListener «якось підчепиться». Але @EventListener — це історія про реєстрацію обробника всередині контейнера і про публікацію подій через ApplicationEventPublisher. Поза контейнером це буде просто метод, який ніхто не викличе.
І останній, але дуже практичний момент: якщо ви самі написали BeanPostProcessor — наприклад, для діагностики — робіть критерії обробки вузькими. Занадто широкий BPP, який «чіпляє все підряд», здатний створити враження, що «в мене половина Spring зламалася». Насправді ви просто підмінили півконтексту обгортками, і тепер equals/hashCode, типи в дебагері та ланцюжки викликів виглядають інакше.
8. Типові помилки під час роботи з анотаціями
Помилка № 1: об’єкт створено через new, але від нього чекають @Autowired і @PostConstruct.
Це найчастіша і найчесніша причина того, що «анотація не спрацювала». Контейнер не брав участі у створенні об’єкта, отже BPP-ланцюжок не запускався, отже ніхто не впровадив залежності й не викликав init-callback. Якщо вам потрібен об’єкт, керований поведінкою Spring, він має бути біном і створюватися контейнером.
Помилка № 2: @EventListener поставили, але події публікуються повз контейнер.
Іноді це виглядає так: ви створили слухача як бін, але publisher зробили через new у якомусь utility-класі й усередині нього взагалі немає ApplicationEventPublisher. У підсумку ви або не публікуєте подію взагалі, або публікуєте її не через Spring. Подієва модель Spring — внутрішня, вона працює, коли публікація йде через ApplicationEventPublisher із контексту.
Помилка № 3: контекст падає на старті, а ви шукаєте проблему в тому, що «не викликався @PostConstruct».
Якщо ApplicationContext упав через помилку впровадження — наприклад, не знайдено бін або кандидатів надто багато, — то жодна lifecycle-фаза не зобов’язана завершитися. У таких ситуаціях важливо мислити фазами запуску: спершу переконайтеся, що контекст узагалі дійшов до створення singleton-beans, і лише потім перевіряйте init/destroy callbacks.
Помилка № 4: написали занадто широкий BeanPostProcessor і випадково змінили половину застосунку.
BPP, який обгортає всі біни підряд, виглядає як «крута універсальна штука», але в навчальному проєкті й у реальному коді це частіше шлях до хаосу. Контейнер почне повертати вам неочікувані runtime-об’єкти, у дебагері з’являться обгортки на кожному кроці, а деякі механіки, особливо пов’язані з типами, почнуть поводитися незвично. Хороший BPP зазвичай має вузький критерій: за типом, за ім’ям, за пакетом, за marker-інтерфейсом.
Помилка № 5: плутати рівень метаданих і рівень екземплярів.
Якщо ви намагаєтеся «підправити об’єкт» у BeanFactoryPostProcessor, ви майже гарантовано робите щось не те: BFPP живе у світі BeanDefinition. І навпаки: якщо ви намагаєтеся «підправити bean definition» усередині BeanPostProcessor, ви вже запізнилися — об’єкт створено, і зміни опису або не застосуються, або дадуть дивні побічні ефекти.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ