1. Adnotacje jako metadane kontenera
Gdy zaczynasz pisać kod Spring, bardzo łatwo podpaść na iluzję: „postawiłem @Autowired, więc Java sama zrozumie, że tu trzeba wstrzyknąć zależność". To mniej więcej jak naklejka na laptopie „proszę, napraw się" i oczekiwanie, że sam pójdzie do serwisu. Adnotacja to po prostu znacznik w bytecode, czyli metadane, a nie kod wykonywalny. Żeby z metadanych pojawiło się prawdziwe zachowanie, ktoś musi te metadane przeczytać i wykonać odpowiednie działania. W naszym przypadku tym „kimś" jest kontener Spring — a dokładniej jego infrastrukturalne procesory.
Widzieliśmy już, że kontener potrafi wmieszywać się w życie beana wokół fazy init i nawet może zwrócić opakowanie zamiast oryginalnego obiektu. Teraz zastosujmy tę samą logikę do znajomych adnotacji: wtedy @Autowired, @PostConstruct i @EventListener przestają wyglądać jak „samo tak działa".
Jeśli chcesz poczuć różnicę rękami, możesz zrobić krótki eksperyment myślowy: obiekt stworzony przez new — to po prostu obiekt. Ma adnotacje, ale nikt ich nie przeczytał, nikt nie wstrzyknął zależności, nikt nie wywołał init-callbacków, nikt nie zarejestrował go jako listener. A bean stworzony przez kontener przechodzi przez pipeline i „ożywa".
Mini-demo (na poziomie „adnotacja istnieje, ale sama nic nie robi"):
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 {
// Patrzymy na adnotację przez refleksję: to po prostu metadane w bytecode
Method m = ReportOutputManager.class
.getDeclaredMethod("setResourceLoader", ResourceLoader.class);
// Sprawdzamy fakt obecności @Autowired, ale żadnych działań DI tu nie następuje
System.out.println(m.isAnnotationPresent(Autowired.class)); // true
// I tyle. Żadnej „iniekcji" z tego nie wynikło.
}
}
Kluczowa myśl: obecność @Autowired — to tylko fakt, że ktoś może chcieć przetworzyć tę metodę jako punkt wstrzyknięcia. Czy przetworzy — zależy od tego, czy istnieje infrastruktura kontenera i czy bean przez nią przeszedł.
2. Infrastrukturalne procesory ApplicationContext
Gdy uruchamiasz AnnotationConfigApplicationContext, zazwyczaj myślisz, że w kontenerze żyją tylko twoje serwisy jak OrderPlacementService, AuditService, NotificationTemplateCatalog. Ale w rzeczywistości razem z nimi żyją liczne serwisowe beany, które zapewniają działanie całego modelu adnotacyjnego. Rzadko pisze się je ręcznie, ale to właśnie one sprawiają, że Spring jest Springiem.
Ważny psychologiczny moment: te „magiczne" procesory nie są niczym mistycznym. W większości przypadków to zwykłe beany, które po prostu implementują specjalne interfejsy jak BeanFactoryPostProcessor i BeanPostProcessor (i jeszcze kilka kontraktów). Kontener wie, kiedy je wywoływać, i robi to we właściwej fazie startup pipeline.
Można nawet „dotknąć" faktu, że istnieją w kontekście. Infrastrukturalne procesory mają wewnętrzne nazwy (wyglądają jak długie stringi, bo Spring nie chciał przypadkowo kolidować z naszymi nazwami):
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class InfraBeansProbe {
public static void main(String[] args) {
// Ważne: kontekst startuje i sam rejestruje swoje infrastrukturalne beany
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
// AutowiredAnnotationBeanPostProcessor — ten właśnie wykonuje @Autowired
System.out.println(
ctx.containsBean("org.springframework.context.annotation.internalAutowiredAnnotationProcessor")
); // true
// CommonAnnotationBeanPostProcessor — przetwarza np. @PostConstruct itp.
System.out.println(
ctx.containsBean("org.springframework.context.annotation.internalCommonAnnotationProcessor")
); // true
// EventListenerMethodProcessor — skanuje @EventListener i rejestruje listenery
System.out.println(
ctx.containsBean("org.springframework.context.event.internalEventListenerProcessor")
); // true
}
}
}
Tu ważne jest nie wrzucać wszystkich adnotacji do jednego worka. @Configuration, @ComponentScan i @Import działają wcześnie, gdy kontener jeszcze rozszerza mapę BeanDefinition; tu pracują mechanizmy poziomu definicji jak ConfigurationClassPostProcessor, który potrafi dorejestrować nowe definicje. @Autowired i @PostConstruct działają później, gdy konkretny obiekt jest już stworzony i przechodzi fazę init; to już przetwarzanie na poziomie instancji przez AutowiredAnnotationBeanPostProcessor i CommonAnnotationBeanPostProcessor. @EventListener stoi trochę z boku: kontener przy starcie znajduje takie metody i rejestruje je jako handlery zdarzeń, więc to też nie jest „sama adnotacja robi magię".
W skrócie, oto wygodna „mapa korespondencji" (nie jako podręcznik, lecz jako kompas — gdzie patrzeć, gdy zadajesz pytanie „dlaczego to działa?"):
| Co piszesz w kodzie | Co musi się stać w kontenerze | Typ mechanizmu |
|---|---|---|
| @Configuration, @ComponentScan, @Import | kontener musi przeczytać konfigurację i stworzyć BeanDefinition dla znalezionych komponentów | wczesne przetwarzanie na poziomie definicji przez BeanDefinitionRegistryPostProcessor / ConfigurationClassPostProcessor |
| @Autowired | kontener musi znaleźć punkty wstrzyknięcia i wykonać wstrzyknięcie | poziom BPP (praca z instancją + DI) |
| @PostConstruct | kontener musi wywołać init-callback po wstrzyknięciu zależności | poziom BPP (część fazy init) |
| @EventListener | kontener musi znaleźć metody-listenery i zarejestrować je przed publikacją zdarzeń | osobna startup-infrastruktura znajdująca i rejestrująca metody listenerów |
| auto-opakowywanie beana | kontener musi zwrócić z getBean() „opakowanie" dodające zachowanie | poziom BPP (po inicjalizacji) |
Zwróć uwagę na ważny styl myślenia: przestajemy mówić „adnotacja działa" i zaczynamy mówić „kontener wykonał pracę, o którą prosiły metadane". To właśnie dorosły model mentalny.
3. Mechanika @Autowired
@Autowired ma reputację „najbardziej oczywistego" mechanizmu Springa. Ale właśnie z powodu tej oczywistości często zamienia się w magię: nowicjusz myśli, że @Autowired to prawie słowo kluczowe języka. W rzeczywistości wszystko jest bardziej prozaiczne: jest infrastrukturalny komponent kontenera, który na etapie tworzenia beana szuka punktów wstrzyknięcia i wykonuje wstrzyknięcie przez BeanFactory (czyli przez ten sam system rozwiązywania zależności, który znasz z tematów o kandydatach, @Primary, @Qualifier itp.).
Żeby nie zachęcać do field injection jako nawyku, pokażemy to na setter injection, ale podkreślimy: to jest demonstracja mechaniki, a nie „jak zawsze należy robić".
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Component;
@Component
public class ReportOutputManager {
// Pole pozostaje null dopóki kontener nie wykona wstrzyknięcia
private ResourceLoader resourceLoader;
@Autowired
public void setResourceLoader(ResourceLoader resourceLoader) {
// Kontener znajdzie tę metodę jako punkt wstrzyknięcia i wywoła ją przy tworzeniu beana
this.resourceLoader = resourceLoader;
}
public boolean isReady() {
// Prosty wskaźnik tego, czy zależność jest wstrzyknięta
return resourceLoader != null;
}
}
Teraz najważniejsze porównanie: new kontra zarządzany przez kontener.
public class WiringRealityCheck {
public static void main(String[] args) {
// Stworzyliśmy obiekt ręcznie: kontener nie uczestniczył, DI się nie wykonało
ReportOutputManager plain = new ReportOutputManager();
System.out.println(plain.isReady()); // false
}
}
Obiekt stworzony, adnotacja w klasie jest, ale wstrzyknięcia nie ma — bo kontener nie uczestniczył. Teraz to samo przez kontekst:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class WiringRealityCheckSpring {
public static void main(String[] args) {
// Tworzymy kontekst: dalej obiekt zostanie stworzony i „przejdzie" przez łańcuch BPP
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
ReportOutputManager manager = ctx.getBean(ReportOutputManager.class);
// Tu setter był już wywołany przez kontener
System.out.println(manager.isReady()); // true
}
}
}
Tu „cud" jest bardzo konkretny: kontener stworzył obiekt, potem przeszedł przez swoje procesory, zobaczył @Autowired na metodzie, znalazł w kontekście pasujący ResourceLoader i wywołał setter. To nie jest język, to nie JVM, to nie „jakoś samo". To zarządzana faza pipeline'u.
Przydatne powiązanie z poprzednimi wykładami dnia: taki mechanizm jest możliwy tylko dlatego, że kontener dopuszcza „przetwarzanie instancji" przez BeanPostProcessor. Właśnie w tym miejscu możemy „domieszać" własne zachowanie: na przykład śledzić, że bean jest gotowy, lub logować jego tworzenie, lub nawet podmienić obiekt na opakowanie.
4. Mechanika @PostConstruct
Temat lifecycle callbacków (@PostConstruct, @PreDestroy, initMethod, destroyMethod) wcześniej mógł wyglądać jak „kolejna adnotacja do zapamiętania". Teraz możemy to wyjaśnić inaczej: init-callback to część ściśle określonej fazy cyklu życia, i działa dlatego, że kontener we właściwym momencie wywołuje właściwą metodę. JVM sama w sobie nie ma obowiązku wywoływać @PostConstruct. Co więcej, jeśli obiekt jest stworzony przez new, JVM nic nie wywoła (i nie powinna).
Zróbmy krótki i bardzo naoczny sondaż:
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
@Component
public class TemplateCatalogProbe {
public TemplateCatalogProbe() {
// Konstruktor wywoła się i przy new, i przy tworzeniu beana przez kontener
System.out.println("constructor"); // constructor
}
@PostConstruct
void init() {
// Tę metodę wywoła właśnie kontener (w fazie init), a nie JVM „sama z siebie"
System.out.println("postConstruct"); // postConstruct
}
}
A teraz porównajmy ręczne tworzenie i tworzenie przez 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)) {
// Ważne: przy starcie kontekstu bean się stworzy,
// a potem kontener wykona fazę init i wywoła @PostConstruct
}
}
}
W tym małym fragmencie „widzisz" pipeline oczami System.out.println(). Przez new zobaczysz tylko constructor, a przez Spring — i constructor, i postConstruct. Czyli init-callback to nie „magia języka", to część procedury initializeBean(...) wewnątrz kontenera, wokół której obraca się łańcuch BeanPostProcessor.
I jeszcze jeden ważny wniosek, który szczególnie przyda się w prawdziwym projekcie: jeśli @PostConstruct nagle nie zadziałał, to prawie zawsze nie oznacza „adnotacja się zepsuła", lecz „obiekt nie stał się beanem" lub „kontekst nie doszedł do fazy inicjalizacji" (padł wcześniej na błędzie wirowania, na przykład).
5. Mechanika @EventListener
Z @EventListener dzieje się podobna historia: wygląda jak „po prostu adnotacja na metodzie". Ale metoda z @EventListener — to zwykła metoda, która musi być znaleziona przez kontener i zarejestrowana jako handler zdarzeń. I dopiero po tym, gdy w ContextFlow publikacja zdarzenia wykonuje publisher.publishEvent(...), kontener rozumie: „aha, oto lista handlerów, zaraz je wywołam".
Zacznijmy od prostego zdarzenia dziedziny (w naszym projekcie to już znany model):
public record OrderCreatedEvent(String orderId) {
}
Teraz listener, który po zdarzeniu pisze coś do audytu (w prawdziwym projekcie to będzie AuditWriter, a tu wystarczy println(), żeby zobaczyć fakt wywołania):
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class AuditOrderEventsListener {
@EventListener
public void onCreated(OrderCreatedEvent event) {
// Ta metoda wywoła się NIE dlatego, że jest adnotacja,
// ale dlatego że kontener zarejestrował ją jako handler zdarzeń
System.out.println("AUDIT: created " + event.orderId()); // AUDIT: created order-1
}
}
I demo-publisher, który publikuje zdarzenie przez ApplicationEventPublisher:
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
@Component
public class DemoPublisher {
private final ApplicationEventPublisher publisher;
public DemoPublisher(ApplicationEventPublisher publisher) {
// Publisher wstrzyknięty przez kontener jako infrastrukturalna zależność kontekstu
this.publisher = publisher;
}
public void run() {
// Ważne: zdarzenie jest publikowane przez publisher kontenera,
// właśnie wtedy Spring będzie mógł dostarczyć je wszystkim zarejestrowanym listenerom
publisher.publishEvent(new OrderCreatedEvent("order-1"));
}
}
Uruchomienie:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class EventListenerDemo {
public static void main(String[] args) {
try (var ctx = new AnnotationConfigApplicationContext(
DemoPublisher.class,
AuditOrderEventsListener.class
)) {
// Pobieramy publisher jako bean: gwarantuje to, że DI i rejestracja listenerów już się odbyły
ctx.getBean(DemoPublisher.class).run(); // AUDIT: created order-1
}
}
}
Co tu jest szczególnie ważne dla modelu mentalnego: @EventListener nie „włącza zdarzeń". Zdarzenia jako mechanizm już istnieją w kontekście (ApplicationEventPublisher jest częścią ApplicationContext). Adnotacja to tylko sposób poprosić kontener: „proszę, zarejestruj tę metodę jako handler".
I tu pojawia się świetny pomost do tematu dnia: żeby @EventListener zadziałał, wewnątrz kontenera jest infrastrukturalne przetwarzanie przy starcie, które przechodzi po beanach, szuka takich metod i łączy je z event multicaster-em. Czyli zdarzenia wyglądają jak „po prostu publishEvent", ale w rzeczywistości „po prostu" tam nie ma: wcześniej kontener musiał zebrać rejestr listenerów, i to też jest część startup pipeline.
6. Auto-opakowywanie beanów i auto-proxying
Jeśli wcześniej kontener wydawał się miejscem, które po prostu „stworzył obiekt i zwrócił go", to teraz można dodać ważny zwrot akcji. Po fazie init może on położyć do kontekstu nie sam target, lecz wrapper wokół niego. Na NotificationSender już było widać, że BeanPostProcessor potrafi centralnie dodać zachowanie techniczne, nie przepisując klasy źródłowej.
Jeśli wokół NotificationSender jest techniczne opakowanie TrackingNotificationSender, to na poziomie mechaniki wszystko wygląda tak:
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof NotificationSender sender) {
return new TrackingNotificationSender(sender);
}
return bean;
}
Po takim kroku pozostałe części aplikacji dostają już wrapper. Gdy zamiast ręcznego opakowania Java używa się runtime-proxy, zasada pozostaje ta sama: kontener podmienia końcowy obiekt i dodaje wokół target zachowanie techniczne. Dla tego tematu teraz wystarczy zobaczyć samą ideę — bean w kontekście nie musi być „gołą instancją" twojego klasy.
Właśnie dlatego w debuggerze, w getClass() i czasem nawet w sprawdzeniach instanceof można zobaczyć nie to, czego oczekiwałeś po klasie źródłowej. To nie „Spring znowu psoci", to bezpośrednia konsekwencja tego, że kontener zwrócił na zewnątrz już przetworzony obiekt.
7. Diagnostyka: „adnotacja nie zadziałała"
Gdy w projekcie coś nie działa (nie zostało wstrzyknięte, nie zainicjalizowane, listener się nie wywołał), pierwsza reakcja nowicjusza — podejrzewać framework. To normalne: Spring jest duży, stack trace'y długie, a kawa się kończy. Ale w 95% przypadków przyczyna jest bardziej prozaiczna i powiązana z pipeline'm, który właśnie studiujemy.
Diagnozowanie najlepiej zacząć od pytania: „Ten obiekt w ogóle jest container-managed bean?" Jeśli stworzyłeś go przez new, to dla kontenera on nie istnieje. Żadne @Autowired, @PostConstruct, @EventListener domyślnie nie mają obowiązku działać dla takich obiektów, bo obiekt nie przeszedł przez startup pipeline i łańcuch procesorów. W ContextFlow jest to szczególnie ważne, bo świadomie trzymamy regułę: serwisy biznesowe nie dostają ApplicationContext i nie tworzą sobie zależności ręcznie. Naruszyłeś regułę — i dosłownie „wyskoczyłeś ze świata Spring".
Drugie pytanie: „Kontekst na pewno wystartował pomyślnie i doszedł do fazy tworzenia singleton-beanów?" Jeśli kontekst padł na startup failure, to adnotacje „się nie zepsuły", po prostu kontener nie doszedł do fazy, gdzie są one przetwarzane. Dlatego tak ważny jest model „register → refresh → BFPP → tworzenie beanów → BPP → init".
Trzecie pytanie: „Zdarzenie jest publikowane przez kontener?" Czasem ludzie biorą new SimpleApplicationEventMulticaster() (lub po prostu wywołują metodę listenera bezpośrednio) i oczekują, że @EventListener „jakoś się podepnie". Ale @EventListener — to historia o rejestrowaniu handlera wewnątrz kontenera i o publikowaniu zdarzeń przez ApplicationEventPublisher. Poza kontenerem to będzie po prostu metoda, którą nikt nie wywoła.
I ostatni, ale bardzo praktyczny moment: jeśli sam napisałeś BeanPostProcessor (na przykład do diagnostyki), rób kryteria przetwarzania wąskie. Zbyt szeroki BPP, który „rusza wszystko z rzędu", potrafi stworzyć odczucie, że „u mnie połowa Springa się zepsuła". W rzeczywistości po prostu podmieniłeś pół kontekstu opakowaniami, i teraz equals/hashCode, typy w debuggerze i łańcuchy wywołań wyglądają inaczej.
8. Typowe błędy przy pracy z adnotacjami
Błąd nr 1: obiekt stworzony przez new, ale od niego oczekuje się @Autowired i @PostConstruct.
To najczęstszy i najuczciwszy powód „adnotacja nie zadziałała". Kontener nie uczestniczył w tworzeniu obiektu, znaczy łańcuch BPP się nie uruchomił, znaczy nikt nie wstrzyknął zależności i nie wywołał init-callbacku. Jeśli potrzebujesz obiektu zarządzanego przez Spring — musi być beanem i tworzonym przez kontener.
Błąd nr 2: @EventListener postawiony, ale zdarzenia są publikowane z pominięciem kontenera.
Czasem wygląda to tak: stworzyłeś listener jako bean, ale publisher zrobiłeś przez new w jakimś utility-klasie i w ogóle nie ma w nim ApplicationEventPublisher. W efekcie albo w ogóle nie publikujesz zdarzenia, albo publikujesz je nie przez Spring. Model zdarzeniowy Spring jest wewnętrzny, działa gdy publikacja idzie przez ApplicationEventPublisher z kontekstu.
Błąd nr 3: kontekst pada przy starcie, a ty szukasz problemu w „nie wywołał się @PostConstruct".
Jeśli ApplicationContext padł z powodu błędu wirowania (np. bean nie znaleziony lub kandydatów za dużo), to żadna faza lifecycle nie ma obowiązku się zakończyć. W takich sytuacjach ważne jest myślenie fazami startu: najpierw upewnij się, że kontekst w ogóle doszedł do tworzenia singleton-beanów, i dopiero potem sprawdzaj callbacki init/destroy.
Błąd nr 4: napisałeś zbyt szeroki BeanPostProcessor i przypadkowo zmieniłeś połowę aplikacji.
BPP, który opakowuje wszystkie beany z rzędu, wygląda jak „fajne universalne rozwiązanie", ale w projekcie dydaktycznym i w prawdziwym kodzie to częściej droga do chaosu. Kontener zacznie zwracać nieoczekiwane obiekty runtime, w debuggerze pojawią się opakowania na każdym kroku, a niektóre mechaniki (zwłaszcza związane z typami) zaczną działać dziwnie. Dobry BPP ma zazwyczaj wąskie kryterium: po typie, po nazwie, po pakiecie, po marker-interfejsie.
Błąd nr 5: mylenie poziomu metadanych z poziomem instancji.
Jeśli próbujesz „poprawić obiekt" w BeanFactoryPostProcessor, prawie na pewno robisz coś nie tak: BFPP żyje w świecie BeanDefinition. I odwrotnie: jeśli próbujesz „poprawić definicję beana" wewnątrz BeanPostProcessor, już za późno — obiekt stworzony, a zmiany opisu albo się nie zastosują, albo dadzą dziwne efekty uboczne.
GO TO FULL VERSION