1. Gdzie kontener w ogóle szuka komponentów
Z rolami klas jest już jasniej: @Service, @Repository i @Component mówią, kto jest przed nami. Ale samej adnotacji stereotype kontenerowi nie wystarczy. Kolejne praktyczne pytanie brzmi całkiem przyziemnie: jaką część package tree w ogóle oddajemy skanerowi?
Wyobraź sobie, że scanning — to firma kurierska. @Component mówi: „Jestem przesyłką, można mnie dostarczyć". A @ComponentScan — to adres i rejon dostawy. Jeśli podałeś zbyt mały rejon, przesyłki nie dotrą. Jeśli zbyt duży — kurier zacznie taszczić wszystko po kolei, łącznie z workami na śmieci z klatki schodowej, a ty potem będziesz się zastanawiać, dlaczego w kontekście pojawiły się „zbędne" bean-y.
W tej lekturze omówimy, czym jest base package, jak wpływa na wynik scanning i dlaczego idea „przeskanuj-wszystko-od-com-na-wszelki-wypadek" jest prawie zawsze zła nawet w projekcie szkoleniowym, nie mówiąc o prawdziwych aplikacjach.
2. @ComponentScan i base package
Ważne jest uchwycenie prostego modelu roboczego: Spring nie skanuje „całego programu". Skanuje poddrzewo pakietów, zaczynając od wskazanego korzenia. Pod „poddrzewem" rozumie się dosłownie pakiet i wszystkie jego podpakiety. Jeśli korzeniem jest com.example.contextflow, to będą przejrzane com.example.contextflow.application..., com.example.contextflow.infrastructure... i wszystko, co leży niżej w drzewie.
Z punktu widzenia kontenera wygląda to tak: podnosisz kontekst, Spring czyta konfigurację, widzi @ComponentScan, uruchamia skaner classpath, znajduje pasujące klasy (@Component i pochodne) i dla każdej znalezionej klasy rejestruje BeanDefinition. Obiekty nie „rodzą się z powietrza" w momencie skanowania — najpierw pojawia się opis, według którego potem zostanie stworzony bean.
Można trzymać ten łańcuch w głowie tak:
flowchart TD
A["@ComponentScan: gdzie szukać?"] --> B["Classpath Scanner"]
B --> C["Znaleziono klasy z adnotacjami stereotype"]
C --> D["BeanDefinitions zarejestrowane"]
D --> E["Tworzenie singleton beans przy starcie kontekstu"]
I teraz kluczowy moment dnia: najczęstszy problem scanning — to nie adnotacje, lecz granice. Klasa może być idealnie oznaczona @Service, ale jeśli fizycznie leży poza obszarem wyszukiwania, dla kontenera po prostu nie istnieje. Tak, to frustrujące. Ale za to bardzo deterministyczne.
3. Zbyt wąska granica skanowania
Gdy granica skanowania jest zbyt wąska, dostajesz znane błędy w stylu „bean nie znaleziony". W tym momencie bardzo chce się obwiniać Javę, Gradle, Księżyc i Merkurego w retrogradzie… ale zazwyczaj winna jest jedna linijka w @ComponentScan. W tym jest właśnie podstępność: kod klas może być idealny, a aplikacja nie startuje lub nie może znaleźć potrzebnego bean-a.
Typowy scenariusz w projekcie ContextFlow: położyłeś AppConfig w pakiecie com.example.contextflow.config — co samo w sobie jest normalne i czytelne — a potem napisałeś @ComponentScan bez podania korzenia i oczekujesz, że Spring „zrozumie".
Zobaczmy, jak łatwo przypadkowo zepsuć scanning.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan // <-- Domyślnie skanowany jest pakiet tej klasy i jej podpakiety, a nie „cała aplikacja"
public class AppConfig {
}
Co zrobi Spring? Weźmie pakiet klasy AppConfig, czyli com.example.contextflow.config, i będzie skanować tylko jego i podpakiety. A twoje klasy @Service zazwyczaj żyją w com.example.contextflow.application..., a @Repository — w com.example.contextflow.infrastructure.... To nie są podpakiety config. To sąsiedzi, a nie dzieci w drzewie pakietów.
Wynik łatwo zobaczyć nawet bez złożonego wiringu: wystarczy poprosić kontekst o ScenarioRunner.
import com.example.contextflow.application.scenario.ScenarioRunner;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Jeśli bean nie był znaleziony przy scanning, to na tej linii padniemy z NoSuchBeanDefinitionException
context.getBean(ScenarioRunner.class);
}
Jeśli ScenarioRunner nie trafił pod scanning, dostaniesz błąd „bean nieobecny". I właśnie tu ważne jest, żeby nie biec naprawiać ScenarioRunner, lecz najpierw sprawdzić trzy rzeczy: czy leży we właściwym pakiecie, czy ma na sobie adnotację stereotype (na przykład @Component) i czy ten pakiet trafia pod base package twojego scanning.
Inny wariant „zbyt wąsko" — gdy świadomie ograniczyłeś scanning do jednej warstwy, a potem zdziwiłeś się, że infrastruktura się nie podciągnęła.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
// Skanujemy tylko warstwę application: wszystko w sąsiednich pakietach dla kontenera „nie istnieje"
@ComponentScan(basePackages = "com.example.contextflow.application")
public class NarrowScanConfig {
}
Skanujemy tylko warstwę application. A InMemoryOrderStore u nas jest w infrastructure.store. Jeśli potem spróbujesz pobrać ten bean po typie, po prostu nie będzie zarejestrowany.
import com.example.contextflow.infrastructure.store.InMemoryOrderStore;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(NarrowScanConfig.class)) {
// Ten bean nie zostanie znaleziony, bo pakiet infrastructure nie był skanowany
context.getBean(InMemoryOrderStore.class); // NoSuchBeanDefinitionException
}
Ważna myśl inżynierska: błąd „bean nie znaleziony" przy scanning zazwyczaj oznacza nie „klasa jest nieprawidłowa", lecz „kontener do tego pakietu nawet nie zajrzał". To jak szukanie kluczy pod latarnią, bo tam jest jasno: wygodnie, ale bezużytecznie, jeśli klucze wypadły w innym miejscu.
4. Zbyt szeroka granica skanowania
Jeśli wąski scanning psuje start, to szeroki psuje zaufanie do projektu. Uruchamiasz aplikację i nagle widzisz w kontekście dziwne komponenty, których w ogóle nie planowałeś widzieć w produkcyjnym wydaniu. A dalej zaczyna się wesoło: NoUniqueBeanDefinitionException, nieoczekiwane efekty uboczne, „dlaczego tu są dwa AuditWriter?", „kto dodał ten komponent?", „dlaczego start stał się zauważalnie wolniejszy?".
Najczęstszy grzech nowicjusza — stawiać scanning zbyt wysoko w drzewie pakietów: com.example, a niekiedy nawet com. Motywacja zazwyczaj jest szlachetna: „no nie chcę, żeby nie znalazło moich klas". Problem w tym, że Spring posłusznie wykona polecenie.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
// Zbyt szeroka granica: razem z twoją aplikacją mogą „podciągnąć się" cudze komponenty z sąsiednich modułów lub pakietów treningowych
@ComponentScan(basePackages = "com.example") // <-- szerzej niż nasz projekt
public class WideScanConfig {
}
Wyobraź sobie, że w classpath aplikacji jest drugi mały kawałek kodu treningowego w pakiecie com.example.sandbox. I poeksperimentowałeś tam, a potem zapomniałeś usunąć komponent:
import org.springframework.stereotype.Component;
@Component
// Ten komponent nie należy do aplikacji, ale przy szerokim scanning spokojnie stanie się bean-em „jakby co"
public class SandboxGreeter {
public String hello() {
return "Znalazłem się tu przypadkowo";
}
}
Jeśli scanning zaczyna się od com.example, ten SandboxGreeter spokojnie zostanie bean-em. Możesz nawet nie zauważyć tego od razu — dopóki ktoś nie zacznie gdzieś wstrzykiwać SandboxGreeter lub dopóki nie pociągnie za sobą innych zależności. W małym projekcie to wygląda jak drobnostka, w średnim — już jako źródło chaosu.
Jeszcze jeden nieprzyjemny efekt zbyt szerokiego scanning — niejednoznaczność po typie, gdy w kontekście nagle pojawia się druga implementacja interfejsu i Spring nie może zdecydować, której użyć. Nawet jeśli na razie nie zagłębiamy się w strategię wyboru kandydatów, sam błąd doskonale pokazuje, dlaczego granice są tak ważne.
Na przykład, zrobiłeś eksperymentalną implementację wysyłania powiadomień w com.example.sandbox:
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.stereotype.Component;
@Component
// Druga implementacja interfejsu: przy szerokim scanning pojawi się konkurujący kandydat w kontekście
public class DebugNotificationSender implements NotificationSender {
@Override
public void send(String message) {
// Piszemy do konsoli do debugowania — wygodne w sandbox, ale niepożądane „przypadkowo" w głównym kontekście
System.out.println("[DEBUG] " + message); // [DEBUG] ...
}
}
I teraz spróbujmy pobrać NotificationSender po typie:
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(WideScanConfig.class)) {
// Jeśli implementacji jest kilka, Spring nie będzie mógł wybrać i rzuci NoUniqueBeanDefinitionException
context.getBean(NotificationSender.class); // NoUniqueBeanDefinitionException
}
Spring uczciwie powie: „Mam dwóch kandydatów". I z jego punktu widzenia ma rację: kazałeś mu przeskanować zbyt duży obszar, a więc sam „zaprosiłeś" do kontekstu i normalny ConsoleNotificationSender, i eksperymentalny DebugNotificationSender.
Oprócz logicznych konfliktów szeroki scanning daje też całkiem przyziemny efekt: start staje się cięższy. Nawet jeśli obce klasy nie zamieniają się w bean-y, samo skanowanie pakietów i czytanie metadanych po klasach — to praca. Nie katastrofa, ale w projekcie szkoleniowym ważniejsza jest dla nas przewidywalność i czytelność, a nie podejście „na wszelki wypadek przeskanujmy wszystko, co się rusza".
5. Granica skanowania: basePackages i basePackageClasses
Gdy zrozumiałeś, że granicę trzeba wybierać świadomie, kolejne pytanie brzmi tak: jak dokładnie ją zadawać, żeby nie natrafiać na grabie z literówką w łańcuchu.
W czystym Springu są dwa podstawowe sposoby, żeby to zrobić — bez egzotyki w postaci filtrów:
| Sposób | Jak wygląda | Plusy | Minusy |
|---|---|---|---|
| basePackages | @ComponentScan(basePackages = "com.example.contextflow") | prosto i zrozumiale | łańcuch — można się pomylić, IDE nie zawsze pomoże |
| basePackageClasses | @ComponentScan(basePackageClasses = ContextFlowMarker.class) | typobezpiecznie, wygodnie przy refaktoryzacji | potrzebna klasa „markerowa" we właściwym pakiecie |
Na tym etapie kursu warto wyrobić nawyk: jeśli pakiet jest ważny jako granica architektoniczna, lepiej uczynić go widocznym przez typ, a nie tylko przez łańcuch.
Na przykład, dodajmy do głównego pakietu projektu com.example.contextflow prostą klasę markerową bez logiki:
package com.example.contextflow;
// Klasa markerowa: potrzebna tylko jako „kotwica" pakietu dla basePackageClasses
public final class ContextFlowMarker {
private ContextFlowMarker() {
// Zakazujemy tworzenia egzemplarzy: klasa nie niesie logiki i nie powinna być instancjonowana
}
}
I wtedy AppConfig może wyglądać tak:
import com.example.contextflow.ContextFlowMarker;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
// Typobezpiecznie: jeśli pakiet/klasa się przeprowadzi, kompilator podpowie, że scanning się zepsuł
@ComponentScan(basePackageClasses = ContextFlowMarker.class)
public class AppConfig {
}
Teraz, jeśli zmienisz nazwę pakietu lub przeniesiesz korzeń markera, kompilator sam zastuka w ręce: konfiguracja przestanie się budować, zamiast cicho skanować nie to miejsce. Dla nowicjusza to prawie prezent: mniej mistyki i mniej pytań w stylu „dlaczego nic nie działa, choć łańcuch niby jest właściwy".
6. Szybka diagnostyka skanowania
Gdy scanning „nie działa", w 90% przypadków przydatne jest nie zgadywanie, lecz sprawdzenie, co naprawdę trafiło do kontekstu. Tak, ApplicationContext — to nie podręcznik na co dzień, ale do diagnostyki nadaje się doskonale: można wypisać nazwy zarejestrowanych bean definition i wzrokiem sprawdzić, czy trafiły tam oczekiwane scenarioRunner, orderPlacementService, inMemoryOrderStore.
import java.util.Arrays;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Diagnostyka: drukujemy wszystkie zarejestrowane nazwy bean definition
Arrays.stream(context.getBeanDefinitionNames())
.sorted()
.forEach(System.out::println);
}
Tu przydatne jest zauważenie jednego szczegółu: domyślnie nazwa bean-a — to nie nazwa pakietu i nie pełna nazwa klasy, lecz krótka, jak scenarioRunner czy orderPlacementService. Dlatego filtrowanie tej listy po łańcuchu com.example.contextflow jest bez sensu: pakiet uczestniczy w wyszukiwaniu klas, ale do domyślnej nazwy bean-a nie trafia.
Jeszcze jeden przydatny trik diagnostyczny — spojrzeć na liczbę bean definition. To nie metryka jakości, ale szybki sygnał w stylu „kontekst jest pusty / kontekst żyje".
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Szybki wskaźnik: gwałtownie stało się dużo mniej/więcej — prawdopodobnie zmieniłeś granice scanning
System.out.println("Bean definitions: " + context.getBeanDefinitionCount());
// Bean definitions: ...
}
W projekcie szkoleniowym szybko zapamiętasz przybliżony rząd wielkości. Jeśli po kolejnym refaktoryzacji było umownie 40, a stało się 6, prawdopodobnie zawęziłeś scanning lub w ogóle go wyłączyłeś. Jeśli było 40, a stało się 400, to raczej „zajęłeś za duży rejon".
7. Korzeń skanowania w AppConfig dla ContextFlow
Teraz zróbmy to, co w prawdziwym życiu przydatne jest zrobić jak najwcześniej: określmy jedną zrozumiałą granicę scanning dla całej aplikacji. Dla ContextFlow logicznie jest uważać za korzeń pakiet com.example.contextflow. Chcemy, żeby application, infrastructure i config były wewnątrz tej granicy. Obiekty domenowe (domain.model) też znajdą się wewnątrz, ale nie staną się bean-ami, dopóki nie powiesimy na nich adnotacji stereotype. A tego robić nie zamierzamy: modele domenowe powinny pozostawać zwykłymi obiektami.
Najprostszy wariant — zadać korzeń łańcuchem:
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
// Jawnie zadajemy główny pakiet aplikacji: poniżej niego Spring zobaczy stereotypy
@ComponentScan(basePackages = "com.example.contextflow")
public class AppConfig {
}
Jeśli chcesz mniej „magii łańcuchów", użyj klasy markerowej:
import com.example.contextflow.ContextFlowMarker;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
// Preferowany wariant dla refaktoryzacji: granica scanning zadana przez typ
@ComponentScan(basePackageClasses = ContextFlowMarker.class)
public class AppConfig {
}
A punkt wejścia do aplikacji pozostaje taki sam: podnosimy kontekst i bierzemy z niego ScenarioRunner.
import com.example.contextflow.application.scenario.ScenarioRunner;
import com.example.contextflow.config.AppConfig;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// Podnosimy kontekst na podstawie konfiguracji i pracujemy przez bean-y
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
context.getBean(ScenarioRunner.class).run(); // Bierzemy scenariusz z kontenera, a nie tworzymy ręcznie
}
}
}
Tu nie omawiamy, jak dokładnie są wstrzykiwane zależności i jak Spring wybiera kandydatów. Dzisiejsze zadanie jest prostsze: sprawić, żeby kontener w ogóle widział nasze komponenty przewidywalnie, bez niespodzianek.
8. Typowe błędy przy skanowaniu komponentów
Błąd №1: @ComponentScan bez podania korzenia na konfiguracji leżącej w pakiecie config.
Wygląda to logicznie: „przecież włączyłem scanning, czego mu jeszcze trzeba?". Ale domyślnie Spring skanuje pakiet samej klasy konfiguracyjnej, a nie cały projekt. Jeśli AppConfig leży w com.example.contextflow.config, to bez jawnego korzenia przeskanujesz tylko config.*, a serwisy z repozytoriami znajdą się poza mapą.
Błąd №2: zbyt wysoki korzeń jak com.example lub, strach powiedzieć, com.
Na krótkim dystansie rzeczywiście naprawia to problem „nie znaleziono bean-a", bo kontener zaczyna widzieć w ogóle wszystko, co wygląda jak komponent. Ale dalej prowadzi to do chaosu: przypadkowe eksperymentalne klasy trafiają do kontekstu, pojawiają się nieoczekiwane konflikty po typie i tracisz poczucie, że kontekst — to twoja aplikacja, a nie wszystko, co leży w twoim classpath.
Błąd №3: literówka w basePackages i cichy „pusty kontekst".
Łańcuch "com.example.contextflow" różni się od "com.example.contextfIow" tylko jedną literą, ale co do konsekwencji to jak pomylenie adresu dostawy. Kontekst może startować, ale potrzebnych bean-ów wewnątrz nie będzie. Jeśli chcesz mniej takich sytuacji, lepiej użyć basePackageClasses z klasą markerową.
Błąd №4: próba diagnozowania problemu scanning przez logikę biznesową.
Bardzo częsta droga nowicjusza: „aplikacja nie startuje — znaczy błąd w OrderPlacementService". A naprawdę OrderPlacementService nawet nie był zarejestrowany jako bean, bo leży poza obszarem skanowania. Właściwa kolejność myślenia jest taka: najpierw sprawdzamy granicę scanning i fakt rejestracji — choćby przez wypisanie nazw bean — i dopiero potem wchodzimy w logikę klasy.
Błąd №5: „adnotowałem wszystko po kolei, żeby na pewno działało".
To nie jest ściśle błąd @ComponentScan, ale często idzie z nim w parze. Jeśli zaczniesz oznaczać @Component każdą klasę, łącznie z modelami domenowymi i pomocniczymi DTO/komendami, kontekst się rozrośnie, a rozumienie tego, co w ogóle jest bean-em, rozmyje się. W efekcie scanning będzie działał, ale sam projekt stanie się gorzej czytelny. Kontener — to narzędzie dla serwisów i infrastruktury, a nie dla każdego OrderItem.
GO TO FULL VERSION