1. Аннотации как метаданные контейнера
Когда начинаешь писать Spring-код, очень легко подсесть на иллюзию: «я поставил @Autowired, значит Java сама поймёт, что сюда надо внедрить зависимость». Это примерно как поставить стикер на ноутбук “пожалуйста, починись” и ждать, что он сам сходил в сервисный центр. Аннотация — это всего лишь метка в байткоде, то есть метаданные, а не исполняемый код. Чтобы из метаданных появилось реальное поведение, нужен кто-то, кто эти метаданные прочитает и выполнит соответствующие действия. В нашем случае этим “кем-то” является Spring-контейнер — точнее, его инфраструктурные обработчики.
Мы уже увидели, что контейнер умеет вмешиваться в жизнь bean-а вокруг init-фазы и даже может вернуть наружу обёртку вместо исходного объекта. Теперь приложим эту же логику к знакомым аннотациям: тогда @Autowired, @PostConstruct и @EventListener перестают выглядеть как “оно само так работает”.
Если хочется почувствовать разницу руками, можно сделать очень короткий мысленный эксперимент: объект, созданный через new, — это просто объект. У него есть аннотации, но никто их не прочитал, никто не внедрил зависимости, никто не вызвал init-callbacks, никто не зарегистрировал его как listener. А вот bean, созданный контейнером, проходит через пайплайн и «оживает».
Мини-демо (на уровне «аннотация существует, но сама ничего не делает»):
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 — это лишь факт, что кто-то может захотеть обработать этот метод как injection point. Обработает ли — зависит от того, существует ли инфраструктура контейнера и прошёл ли bean через неё.
2. Инфраструктурные обработчики ApplicationContext
Когда вы запускаете AnnotationConfigApplicationContext, вы обычно думаете, что в контейнере живут только ваши сервисы вроде OrderPlacementService, AuditService, NotificationTemplateCatalog. Но на самом деле, вместе с ними живёт довольно много служебных beans, которые обеспечивают работу всей аннотационной модели. Их редко пишут руками, но именно они делают Spring Spring-ом.
Важный психологический момент: эти «магические» обработчики не являются чем-то мистическим. В большинстве случаев это обычные beans, которые просто реализуют специальные интерфейсы вроде BeanFactoryPostProcessor и BeanPostProcessor (и ещё несколько контрактов). Контейнер знает, когда их вызывать, и делает это в правильной фазе startup pipeline.
Можно даже «потрогать» факт, что они существуют в контексте. У инфраструктурных processors есть внутренние имена (они выглядят как длинные строки, потому что 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; здесь работают definition-level механизмы вроде ConfigurationClassPostProcessor, который умеет дорегистрировать новые определения. @Autowired и @PostConstruct срабатывают позже, когда конкретный объект уже создан и проходит init-фазу; это уже instance-level обработка через AutowiredAnnotationBeanPostProcessor и CommonAnnotationBeanPostProcessor. @EventListener стоит чуть в стороне: контейнер на старте находит такие методы и регистрирует их как обработчики событий, так что это тоже не “сама аннотация делает магию”.
Если коротко, то вот удобная «карта соответствий» (не как справочник, а как компас — куда смотреть, когда вы задаёте вопрос “почему это работает?”):
| Что вы пишете в коде | Что должно произойти в контейнере | Тип механизма |
|---|---|---|
| @Configuration, @ComponentScan, @Import | контейнер должен прочитать конфигурацию и создать BeanDefinition для найденных компонентов | ранний definition-level processing через BeanDefinitionRegistryPostProcessor / ConfigurationClassPostProcessor |
| @Autowired | контейнер должен найти injection points и выполнить внедрение | BPP-уровень (работа с экземпляром + DI) |
| @PostConstruct | контейнер должен вызвать init-callback после внедрения зависимостей | BPP-уровень (часть init-фазы) |
| @EventListener | контейнер должен найти методы-слушатели и зарегистрировать их до публикации событий | отдельная startup-инфраструктура, которая находит и регистрирует listener methods |
| авто-оборачивание bean-а | контейнер должен вернуть из getBean() «обертку», добавляющую поведение | BPP-уровень (после инициализации) |
Обратите внимание на важный стиль мышления: мы перестаём говорить «аннотация работает» и начинаем говорить «контейнер сделал работу, которую попросили метаданные». Это и есть взрослая mental model.
3. Механика @Autowired
У @Autowired репутация «самого очевидного» механизма в Spring. Но именно из-за этой очевидности он часто превращается в магию: новичок думает, что @Autowired — это почти как ключевое слово языка. На деле всё прозаичнее: есть инфраструктурный компонент контейнера, который на этапе создания bean-а ищет injection points и выполняет внедрение через BeanFactory (то есть через ту же самую систему разрешения зависимостей, которую вы уже знаете из тем про candidates, @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 до тех пор, пока контейнер не выполнит injection
private ResourceLoader resourceLoader;
@Autowired
public void setResourceLoader(ResourceLoader resourceLoader) {
// Контейнер найдёт этот метод как injection point и вызовет его при создании bean-а
this.resourceLoader = resourceLoader;
}
public boolean isReady() {
// Простой индикатор того, внедрена ли зависимость
return resourceLoader != null;
}
}
Теперь самое важное сравнение: new против container-managed.
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. Именно в этом месте мы можем «подмешать» своё поведение: например, отследить, что bean готов, или логировать его создание, или даже заменить объект на обёртку.
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, и при создании bean-а контейнером
System.out.println("constructor"); // constructor
}
@PostConstruct
void init() {
// Этот метод вызовет именно контейнер (в 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)) {
// Важно: при старте контекста bean создастся,
// затем контейнер выполнит init-фазу и вызовет @PostConstruct
}
}
}
В этом маленьком фрагменте вы «видите» пайплайн глазами System.out.println(). Через new вы увидите только constructor, а через Spring — и constructor, и postConstruct. То есть init-callback не является «магией языка», это часть процедуры initializeBean(...) внутри контейнера, вокруг которой и крутится BeanPostProcessor-цепочка.
И ещё один важный вывод, который особенно пригодится в реальном проекте: если @PostConstruct вдруг не сработал, это почти всегда означает не «аннотация сломалась», а «объект не стал bean-ом» или «контекст не дошёл до фазы инициализации» (упал раньше на wiring error, например).
5. Механика @EventListener
С @EventListener происходит похожая история: кажется, что это «просто аннотация на методе». Но метод с @EventListener — это не встроенная функция Java. Это обычный метод, который должен быть найден контейнером и зарегистрирован как обработчик событий. И вот только после этого, когда в ContextFlow публикация события делает publisher.publishEvent(...), контейнер понимает: «ага, вот список обработчиков, сейчас я их вызову».
Начнём с простого доменного события (в нашем проекте это уже знакомая модель):
public record OrderCreatedEvent(String orderId) {
}
Теперь listener, который по событию пишет что-то в аудит (в реальном проекте это будет AuditWriter, а здесь достаточно println(), чтобы увидеть факт вызова):
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onCreated(OrderCreatedEvent event) {
// Этот метод вызовется НЕ потому, что есть аннотация,
// а потому что контейнер зарегистрировал его как event handler
System.out.println("AUDIT: created " + event.orderId()); // AUDIT: created order-1
}
}
И publisher-демо, который публикует событие через 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 как bean: это гарантирует, что DI и регистрация listener-ов уже произошли
ctx.getBean(DemoPublisher.class).run(); // AUDIT: created order-1
}
}
}
Что здесь особенно важно для mental model: @EventListener не «включает события». События как механизм уже существуют в контексте (ApplicationEventPublisher есть как часть ApplicationContext). Аннотация — лишь способ попросить контейнер: «пожалуйста, зарегистрируй вот этот метод как обработчик».
И вот здесь появляется отличный мост к теме дня: чтобы @EventListener заработал, внутри контейнера есть инфраструктурная обработка на старте, которая проходит по bean-ам, ищет такие методы и связывает их с event multicaster-ом. То есть события выглядят как “просто publishEvent”, но на самом деле “просто” там нет: до этого контейнер должен собрать реестр listener-ов, и это тоже часть startup pipeline.
6. Авто-оборачивание bean-ов и auto-proxying
Если раньше контейнер казался местом, которое просто “создало объект и вернуло его”, то теперь можно добавить важный поворот сюжета. После init-фазы он может положить в контекст не сам 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 техническое поведение. Для этой темы сейчас достаточно увидеть саму идею — bean в контексте не обязан быть “голым экземпляром” вашего класса.
Именно поэтому в отладчике, в getClass() и иногда даже в instanceof-проверках можно увидеть не то, что вы ожидали по исходному классу. Это не “Spring опять шалит”, а прямое следствие того, что контейнер вернул наружу уже обработанный объект.
7. Диагностика: «аннотация не сработала»
Когда в проекте что-то не срабатывает (не внедрилось, не проинициализировалось, не вызвался listener), первая реакция новичка — подозревать фреймворк. Это нормально: Spring большой, стектрейсы длинные, а кофе заканчивается. Но в 95% случаев причина прозаичнее и завязана на пайплайн, который мы сейчас изучаем.
Начинать диагностику полезнее всего с вопроса: «Этот объект вообще container-managed bean?» Если вы создали его через new, то для контейнера его не существует. Никакие @Autowired, @PostConstruct, @EventListener по умолчанию не обязаны работать для таких объектов, потому что объект не прошёл через startup pipeline и цепочку processors. В ContextFlow это особенно важно, потому что мы сознательно удерживаем правило: бизнес-сервисы не получают ApplicationContext и не создают себе зависимости руками. Нарушили правило — и вы буквально “выскочили из мира Spring”.
Второй вопрос: «Контекст точно успешно стартовал и дошёл до фазы создания singleton-beans?» Если контекст упал на startup failure, то аннотации “не сломались”, просто контейнер не дошёл до фазы, где они обрабатываются. Поэтому так важна модель “register → refresh → BFPP → создание beans → BPP → init”.
Третий вопрос: «Событие публикуется через контейнер?» Иногда люди берут new SimpleApplicationEventMulticaster() (или просто вызывают listener-метод напрямую) и ожидают, что @EventListener “как-то подцепится”. Но @EventListener — это история про регистрацию обработчика внутри контейнера и про публикацию событий через ApplicationEventPublisher. Вне контейнера это будет просто метод, который никто не вызовет.
И последний, но очень практичный момент: если вы сами написали BeanPostProcessor (например, для диагностики), делайте критерии обработки узкими. Слишком широкий BPP, который «трогает всё подряд», способен создать ощущение, что “у меня половина Spring сломалась”. На самом деле вы просто подменили полконтекста обертками, и теперь equals/hashCode, типы в отладчике и цепочки вызовов выглядят иначе.
8. Типичные ошибки при работе с аннотациями
Ошибка №1: объект создан через new, но от него ждут @Autowired и @PostConstruct.
Это самая частая и самая честная причина «аннотация не сработала». Контейнер не был участником создания объекта, значит BPP-цепочка не запускалась, значит никто не внедрил зависимости и не вызвал init-callback. Если вам нужен Spring-поведением управляемый объект — он должен быть bean-ом и создаваться контейнером.
Ошибка №2: @EventListener поставили, но события публикуются мимо контейнера.
Иногда это выглядит так: вы создали listener как bean, но publisher сделали через new в каком-нибудь utility-классе и внутри него вообще нет ApplicationEventPublisher. В итоге вы либо не публикуете событие вовсе, либо публикуете его не через Spring. Событийная модель Spring — внутренняя, она работает, когда публикация идёт через ApplicationEventPublisher из контекста.
Ошибка №3: контекст падает на старте, а вы ищете проблему в «не вызвался @PostConstruct».
Если ApplicationContext упал из‑за wiring error (например, не найден bean или кандидатов слишком много), то никакая lifecycle-фаза не обязана завершиться. В таких ситуациях важно мыслить фазами старта: сначала убедитесь, что контекст вообще дошёл до создания singleton-beans, и только потом проверяйте init/destroy callbacks.
Ошибка №4: написали слишком широкий BeanPostProcessor и случайно поменяли половину приложения.
BPP, который оборачивает все beans подряд, выглядит как «крутая универсальная штука», но в учебном проекте и в реальном коде это чаще путь к хаосу. Контейнер начнёт возвращать вам неожиданные runtime-объекты, в дебаггере появятся обёртки на каждом шаге, а некоторые механики (особенно связанные с типами) начнут вести себя непривычно. Хороший BPP обычно имеет узкий критерий: по типу, по имени, по пакету, по marker-интерфейсу.
Ошибка №5: путать уровень метаданных и уровень экземпляров.
Если вы пытаетесь «поправить объект» в BeanFactoryPostProcessor, вы почти гарантированно делаете что-то не то: BFPP живёт в мире BeanDefinition. И наоборот: если вы пытаетесь «поправить bean definition» внутри BeanPostProcessor, вы уже опоздали — объект создан, и изменения описания либо не применятся, либо дадут странные побочные эффекты.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ