1. Mieszana konfiguracja jako norma
Gdy XML przestał już wydawać się osobną technologią, pojawia się bardziej przyziemne pytanie: jak z nim żyć wewnątrz normalnej aplikacji, gdzie jest już Java config. Gdy słyszymy „mieszana konfiguracja", mózg nowicjusza rysuje zazwyczaj chaos: połowa projektu w XML, połowa w adnotacjach, a gdzieś pomiędzy chowa się bug, który pojawia się tylko we wtorki. W praktyce jest to o wiele spokojniejsze, jeśli z góry przyjąć prostą myśl: migracja — to proces, a nie zdarzenie. I w procesie musisz umieć trzymać dwie składnie w jednej aplikacji tak, żeby kontener pozostawał przewidywalny.
Wyobraź sobie typowy scenariusz. Masz projekt, który już przeniesiono na @Configuration + @ComponentScan, ale w nim ostał mały moduł legacy (np. powiadomienia), którego strach ruszać: „działa — nie ruszaj". A ty musisz nadal rozwijać projekt obok: dodawać nowe beany, zmieniać wiring, podłączać nowe implementacje interfejsów. Pełny „big bang" przeniesienia całego XML do Java config jest często zbyt ryzykowny i drogi — zwłaszcza gdy nie jesteś pewny, że rozumiesz każdą linię legacy-pliku.
Mieszana konfiguracja — to właśnie inżynierski kompromis: podłączamy XML do nowoczesnego kontekstu, sprawiamy, że projekt startuje, a potem przenosimy kawałkami, aż nie zostanie tylko Java config. Najważniejsze — robić to kontrolowanie, a nie „no to importnijmy wszystko i zobaczymy, co wybuchnie".
2. Legacy XML: osobny kontekst czy wspólny kontener
Gdy masz plik XML, najprostszy „techniczny" sposób — podnieść go osobnym ClassPathXmlApplicationContext i wyciągnąć z niego parę beanów. To naprawdę działa i bywa przydatne do eksperymentów. Ale jeśli twoim celem jest prawdziwa aplikacja (nasz ContextFlow), to prawie zawsze chcesz, żeby Java config i XML żyły w jednym ApplicationContext, inaczej zaczyna się wesołe poszukiwanie „jak przekazać bean z jednego kontenera do drugiego".
Porównajmy dwa podejścia w tabeli, bez wojen religijnych i bez „XML — zło, Java — dobro" (obie składnie umieją jednakowo dobrze opisać BeanDefinition):
| Podejście | Jak wygląda | Plusy | Minusy |
|---|---|---|---|
| Osobny kontekst XML | new ClassPathXmlApplicationContext("...xml") | Szybko sprawdzić fragment legacy, izolować eksperyment | Beany nie żyją w jednym kontenerze, wiring między światami staje się niewygodny |
| Wspólny kontekst przez import | @ImportResource("classpath:...xml") wewnątrz Java config | Jeden kontener, jednolite rozwiązywanie zależności, łatwiej migrować krok po kroku | Trzeba pilnować konfliktów nazw i duplikujących się definicji |
W ContextFlow potrzebujemy właśnie drugiego wariantu: jeden kontener, jeden graf zależności, jeden zestaw profili/zasobów/listenerów zdarzeń — i mała legacy-wyspa, którą stopniowo „cywilizujemy".
Oto schemat, jak to mentalnie wygląda (tak, to ta sama model beana, tylko źródeł definicji jest więcej):
flowchart TD Java["@Configuration / @Bean / scanning"] --> BD[Rejestr BeanDefinitions] XML["legacy *.xml"] --> BD BD --> Beans["Stworzone instancje beanów"] Beans --> App["ContextFlow: scenariusze, serwisy, listenery"]
Kluczowy moment: @ImportResource nie tworzy „drugiego kontenera". Po prostu dodaje jeszcze jedno źródło BeanDefinition do bieżącego kontenera.
3. @ImportResource: co to jest i co naprawdę robi
@ImportResource — to adnotacja ze świata konfiguracji Java, która mówi Springowi: „oto zasób XML, przeczytaj stamtąd definicje beanów i dodaj je do tego samego kontekstu". Żadnej osobnej magii: kontener jak zwykle buduje jednolity rejestr beanów, a potem tworzy obiekty, wstrzykuje zależności i stosuje lifecycle.
Minimalny przykład konfiguracji-mostu (zazwyczaj w naszym projekcie to pakiet config.legacybridge):
package com.example.contextflow.config.legacybridge;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
// Podłączamy legacy XML do TEGO SAMEGO ApplicationContext (nie tworzymy osobnego kontenera)
@ImportResource("classpath:/legacy/legacy-notification-context.xml")
public class LegacyBridgeConfig {
// Klasa jest pusta — potrzebna jest jako „kotwica" dla importu zasobu XML
}
Zwróć uwagę na classpath:/.... To ważne z dwóch powodów. Po pierwsze, XML leży w src/main/resources, więc w runtime będzie dostępny w classpath (w środku jar — też). Po drugie, już uczyliśmy się abstrakcji Resource: „classpath" — to nie „plik na dysku", to zasób aplikacji.
Nawet jeśli ten XML urośnie potem o kilka beanów, punkt wejścia się nie zmienia: bridge-config jak wcześniej po prostu domieszuje jego definicje do wspólnego kontenera.
Dalej budujemy główny kontekst, jak zwykle, i po prostu dodajemy LegacyBridgeConfig do listy konfiguracji:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
// Ważne: jeden AnnotationConfigApplicationContext, wewnątrz którego będą i @Bean, i definicje XML-beanów
try (var ctx = new AnnotationConfigApplicationContext(
com.example.contextflow.config.core.CoreConfig.class,
com.example.contextflow.config.legacybridge.LegacyBridgeConfig.class
)) {
// Sprawdzamy, że bean z XML naprawdę zarejestrował się w wspólnym kontekście
System.out.println(ctx.containsBean("legacyNotificationSender")); // true
}
Jeśli widzisz true, oznacza to, że legacyNotificationSender jest zarejestrowany jako definicja beana i dostępny w kontekście. Ważne: nie sięgaliśmy do niego przez osobny ClassPathXmlApplicationContext. Nie stworzyliśmy drugiego kontenera. Po prostu dodaliśmy definicje XML do wspólnego.
Jeszcze jeden praktyczny niuans. @ImportResource przyjmuje tablicę stringów, więc można podłączać kilka małych fragmentów XML (ale właśnie małych, inaczej znowu dostajesz „gigantyczny monolityczny XML", tylko teraz zaimportowany):
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
@ImportResource({
// Można importować kilka fragmentów XML, jeśli trzymasz legacy po modułach
"classpath:/legacy/legacy-notification-context.xml",
"classpath:/legacy/legacy-audit-context.xml"
})
class LegacyBridgeConfig {
}
W naszym kursie trzymamy jeden fragment legacy, więc to raczej ilustracja niż rekomendacja „podłącz wszystkie twoje 30 XML-i".
4. Wiring między Java config i XML: zależności w obu kierunkach
Najbardziej użyteczne w @ImportResource — to to, że beany z XML i beany z Java config zaczynają żyć jak sąsiedzi na klatce schodowej: wiedzą o sobie, mogą się odwoływać do siebie nawzajem i nawet spierać się o miejsce pod słońcem. To wygodne, ale właśnie tu ważne jest, żeby nie stracić głowy: jeden kontener oznacza i jednolity mechanizm rozwiązywania kandydatów.
Na jednym fragmencie widać to najlepiej. W XML żyje legacyNotificationSender, i używa on infrastrukturalnego beana messageSource ze wspólnego kontekstu:
<bean id="legacyNotificationSender"
class="com.example.contextflow.infrastructure.notification.legacy.LegacyConsoleNotificationSender"
init-method="init" destroy-method="destroy">
<property name="appName" value="ContextFlow (legacy)"/>
<property name="messageSource" ref="messageSource"/>
</bean>
A w Java config tworzymy serwis, który chce używać właśnie tej legacy-implementacji:
import com.example.contextflow.application.service.NotificationDispatchService;
import com.example.contextflow.domain.ports.NotificationSender;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class NotificationModuleConfig {
@Bean
NotificationDispatchService notificationDispatchService(
// Bierzemy właśnie legacy-bean z XML po jego id
@Qualifier("legacyNotificationSender") NotificationSender sender
) {
return new NotificationDispatchService(sender);
}
}
Tu w jednym obrazku widać obie strony powiązania. Bean XML dostaje messageSource ze wspólnego kontekstu przez ref="messageSource". Java config z kolei wybiera legacyNotificationSender przez qualifier i buduje na nim serwis. Kontener jeden, rozwiązywanie kandydatów jedno, lifecycle jedno.
Gdyby NotificationSender był jedynym kandydatem, qualifier mógłby nie być potrzebny. Ale w ContextFlow implementacji jest kilka, więc jawna nazwa jest tu spokojniejsza: od razu widać, że serwis siedzi na legacy-gałęzi.
5. Stopniowa migracja XML → Java config
Najczęstszy błąd nowicjusza — próba „po prostu przepisania XML na Java" przez jeden wieczór. To trochę jak „przepisanie całego projektu na mikroservisy" przez weekend: teoretycznie możliwe, ale zazwyczaj kończy się tym, że masz dwa projekty — stary i „prawie nowy", i żaden nie startuje. Stopniowa migracja jest po to, żeby w każdym momencie aplikacja pozostawała w stanie roboczym.
Poniżej — strategia, którą wygodnie trzymać jako plan i wymawiać na głos, gdy patrzysz na legacy XML i czujesz lekką chęć wyjechania do lasu bez internetu.
Krok A. Izolujemy fragment legacy i podłączamy przez @ImportResource
Pierwszy krok — w ogóle przyznać, że to legacy. W ContextFlow trzymamy takie pliki w src/main/resources/legacy/ i podłączamy je przez osobny konfig, np. LegacyBridgeConfig. To daje wizualną granicę: „tu jest legacy". Psychologicznie to już ułatwia życie, bo XML przestaje rozchodzić się po projekcie jak konfetti.
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
@ImportResource("classpath:/legacy/legacy-notification-context.xml")
class LegacyBridgeConfig {
}
Ważne: na tym kroku niczego nie migrujemy. Po prostu sprawiamy, że projekt startuje i jest kontrolowany.
Krok B. Robimy „mapę" XML: id → class → zależności → lifecycle
Teraz potrzebny jest mały rytuał inwentaryzacyjny. Niestraszny, po prostu inżynierski. Wypisujemy każdy <bean> i notujemy, co robi. Można to trzymać nawet jako tabelkę w notatkach albo bezpośrednio w komentarzu.
Przykład mapy dla fragmentu notification, gdzie jest już sender i powiązany z nim serwis:
| bean id | class | injection | depends on | lifecycle |
|---|---|---|---|---|
| legacyNotificationSender | LegacyConsole NotificationSender | property | messageSource (ref), appName (value) | init, destroy |
| legacyNotification DispatchService | NotificationDispatchService | constructor | legacyNotificationSender (ref) | — |
Gdy masz mapę, przestajesz „zgadywać, co chciał powiedzieć autor XML", i zaczynasz widzieć prawdziwy model kontenera.
Krok C. Wybieramy jeden bean do migracji i przenosimy go do Java config
Dobry pierwszy kandydat — lokalny bean, który nie ciągnie za sobą półprojektu. W naszym przypadku to właśnie legacyNotificationSender: ma już i właściwości, i referencję do innego beana, i lifecycle, ale graf wokół niego jest nadal mały i zrozumiały.
XML:
<bean id="legacyNotificationSender"
class="com.example.contextflow.infrastructure.notification.legacy.LegacyConsoleNotificationSender"
init-method="init" destroy-method="destroy">
<property name="appName" value="ContextFlow (legacy)"/>
<property name="messageSource" ref="messageSource"/>
</bean>
Odpowiednik w Java:
import com.example.contextflow.infrastructure.notification.legacy.LegacyConsoleNotificationSender;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class NotificationConfig {
@Bean(initMethod = "init", destroyMethod = "destroy")
LegacyConsoleNotificationSender legacyNotificationSender(MessageSource messageSource) {
var sender = new LegacyConsoleNotificationSender();
sender.setAppName("ContextFlow (legacy)");
sender.setMessageSource(messageSource);
return sender;
}
}
Świadomie zachowaliśmy nazwę legacyNotificationSender: właśnie jej już używają XML-ref, @Qualifier i kod diagnostyczny.
Krok D. Usuwamy/wyłączamy stare deklaracje z XML i znowu uruchamiamy aplikację
To krytyczny moment dyscypliny. Dopóki bean jest zadeklarowany i w XML, i w Java config, kontener albo nadpisze definicję (jeśli overriding jest dozwolony), albo padnie (jeśli jest zabroniony). I w obu przypadkach masz ryzyko cichego zepsucia: „wydaje się, że zmigrowaliśmy, ale w rzeczywistości działa stara wersja".
Dlatego reguła: przeniosłeś bean — usunąłeś z XML. Małymi krokami, ale uczciwie.
Krok E. Powtarzamy dla następnego beana
Bardzo ważny punkt psychologiczny. Migracja — to nie refaktoryzacja architektury. W momencie przenoszenia będzie pokusa: „a może przy okazji zamienić setter injection na constructor injection", „a może przy okazji przerobić interfejs", „a może przy okazji wyrzucić połowę beanów i «zrobić w końcu normalnie»". Nie teraz. To potem. Teraz celem jest zachowanie zachowania, ale zmiana składni konfiguracji.
Jeśli XML robił setter injection, migrujesz go jako setter injection w metodzie @Bean, nawet jeśli bardzo lubisz konstruktory. Konstruktory lubimy, ale migracja produkcyjna lubi stabilność.
6. Przenoszenie zależności i lifecycle
Gdy migracja dociera do bardziej skomplikowanych beanów, ważne jest nauczenie się przekładania form wiringu XML na formy Java, nie tracąc zachowania. Dobra wiadomość: znasz już wszystkie idee (konstruktor, właściwości, lifecycle). Zła wiadomość: w legacy-plikach może być to wymieszane.
Migracja constructor-arg ref="..." do Java config
XML:
<bean id="auditService" class="com.example.contextflow.application.service.AuditService">
<constructor-arg ref="auditWriter"/>
</bean>
Java config (najprostszy odpowiednik — parametr metody @Bean):
import com.example.contextflow.application.service.AuditService;
import com.example.contextflow.domain.ports.AuditWriter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class AuditConfig {
@Bean
AuditService auditService(AuditWriter auditWriter) {
return new AuditService(auditWriter);
}
}
Spring sam wstawi AuditWriter z kontekstu. Jeśli kandydatów jest kilka — używasz @Qualifier, jak już umiesz.
Migracja <property name="..."> do Java config
XML:
<bean id="reportOutputManager"
class="com.example.contextflow.support.lifecycle.ReportOutputManager">
<property name="outputDir" value="build/reports"/>
</bean>
Java config (tworzymy obiekt i wywołujemy setter):
import com.example.contextflow.support.lifecycle.ReportOutputManager;
import java.nio.file.Path;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class ReportingConfig {
@Bean
ReportOutputManager reportOutputManager() {
var m = new ReportOutputManager();
m.setOutputDir(Path.of("build/reports"));
return m;
}
}
Tak, wygląda to „nie tak ładnie" jak konstruktor. Ale to uczciwa migracja: w XML był property injection — w Java config też jest. I tu ważne, żeby nie pominąć jeszcze jednego momentu: w XML stringowe value="build/reports" kontener dodatkowo konwertował na potrzebny typ. W Java config docelowy typ zazwyczaj tworzony jest jawnie, stąd pojawia się Path.of(...).
Migracja init-method / destroy-method do Java config
XML:
<bean id="templateCatalog"
class="com.example.contextflow.infrastructure.resources.NotificationTemplateCatalog"
init-method="loadTemplates"
destroy-method="clearCache"/>
Java config:
import com.example.contextflow.infrastructure.resources.NotificationTemplateCatalog;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class TemplatesConfig {
// Bezpośrednie przeniesienie init-method/destroy-method z XML do metadanych @Bean
@Bean(initMethod = "loadTemplates", destroyMethod = "clearCache")
NotificationTemplateCatalog templateCatalog() {
return new NotificationTemplateCatalog();
}
}
To bezpośrednie przetłumaczenie ustawienia lifecycle na inną składnię. I dobrze pasuje do tego, co studiowaliśmy w dniu o lifecycle: @Bean(initMethod=...) — to normalny instrument, zwłaszcza przy migracji, gdy nie chcesz grzebać w kodzie klasy i dodawać @PostConstruct.
7. Kontrola granic: konflikty nazw, duplikaty i skanowanie z XML
Mieszana konfiguracja psuje się zazwyczaj nie dlatego, że @ImportResource jest „zły", lecz dlatego, że przestajemy kontrolować granice. W legacy XML łatwo napotkać context:component-scan, context:annotation-config, infrastrukturę placeholder i nawet jakieś niespodziewane aliasy. I jeśli importujesz to „jak jest", możesz przypadkowo włączyć coś zbędnego i dostać połowę aplikacji składaną dwukrotnie.
Pierwsza strefa ryzyka — konflikt nazw beanów. XML lubi jawne id="...", Java config lubi nazwy metod @Bean. Skanowanie lubi nazywać beany według reguł lowerCamelCase od nazwy klasy. Wszystko to spotyka się w jednym kontenerze, a więc kolizje nazw — to absolutnie realna rzecz. Najbezpieczniejszy nawyk przy migracji — zachowywać nazwy przy przenoszeniu i usuwać stare deklaracje od razu, a nie „kiedyś później".
Druga strefa ryzyka — duplikowanie skanowania. Na przykład w Java config masz już @ComponentScan("com.example.contextflow"), a legacy XML zawiera context:component-scan base-package="com.example.contextflow.infrastructure.notification". Wynik: kontener dwukrotnie spróbuje zarejestrować te same komponenty. Czasem to zauważysz od razu (po konflikcie nazw), czasem nie (jeśli nazwy są różne), i wtedy dostaniesz „dlaczego mam dwa NotificationSendery, a dodawałem jeden".
Trzecia strefa ryzyka — infrastruktura adnotacyjna i placeholdery. Jeśli XML używa ${...} placeholderów, oczekuje, że w kontekście jest infrastruktura, która umie je rozwiązywać. W naszym projekcie już podnieśliśmy Environment i wsparcie dla placeholderów w Java config, więc zazwyczaj wszystko będzie dobrze. Ale jeśli importujesz fragment XML w „gołym" kontekście bez tych rzeczy, może paść przy starcie nie dlatego, że XML jest zły, lecz dlatego, że brakowało mu infrastrukturalnych beanów.
I wreszcie, najbardziej przydatna dyscyplina: trzymaj moduł legacy małym i izolowanym. Idealnie legacy XML powinien opisywać konkretne beany, a nie „podnosić połowę aplikacji". Wtedy masz szansę migrować go w kilku krokach bez nerwowego tiku.
Minimalny przykład dla ContextFlow: podłączamy legacy jako moduł
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApp {
public static void main(String[] args) {
// Podnosimy jednolity kontekst: CoreConfig + most do legacy XML
try (var ctx = new AnnotationConfigApplicationContext(
com.example.contextflow.config.core.CoreConfig.class,
com.example.contextflow.config.legacybridge.LegacyBridgeConfig.class
)) {
// Szybka sanity-check: bean z XML naprawdę jest w kontenerze
System.out.println(ctx.containsBean("legacyNotificationSender")); // true
}
}
}
Jeśli true, to legacy bean naprawdę stał się częścią kontenera. Dalej możesz migrować go krok po kroku, nie przebudowując aplikacji od nowa i nie tworząc osobnego świata dla XML.
8. Typowe błędy przy mieszanej konfiguracji
Błąd nr 1: importować XML nie rozumiejąc jego granic.
Jeśli podłączasz plik legacy w nadziei „no kontener jakoś się połapie", sam pozbawiasz się głównej zalety Springa — przewidywalności. Właściwe podejście zaczyna się od odpowiedzi na pytanie: za co odpowiada ten fragment XML i jakie beany ma dać kontenerowi. Bez tego łatwo przypadkowo wciągnąć zbędne skanowanie, zbędne placeholdery i połowę infrastruktury „w pakiecie".
Błąd nr 2: trzymać ten sam bean i w XML, i w Java config.
To klasyka migracji. Przeniosłeś <bean id="legacyNotificationSender" .../> do @Bean legacyNotificationSender(), ale zapomniałeś usunąć stare deklaracje z XML. W najlepszym przypadku kontener padnie i uczciwie powie, że ma konflikt. W najgorszym — jedna definicja nadpisze drugą, i dostaniesz sytuację „mam niby nową konfigurację, ale działa jakoś stara". Dyscyplina prosta: przeniosłeś — usunąłeś.
Błąd nr 3: zmieniać nazwę beana „trochę, żeby ładniej".
Zmiana nazwy legacyNotificationSender na consoleNotificationSenderLegacy wydaje się niewinną, dopóki nie przypomnisz sobie, że gdzieś w XML jest ref="legacyNotificationSender", a gdzieś w kodzie Java może być @Qualifier("legacyNotificationSender"). Przy migracji lepiej zachowywać nazwy jak są, a piękno zaprowadzać po tym, gdy wszystko zostało przeniesione i ustabilizowane.
Błąd nr 4: migrować i refaktoryzować design jednocześnie.
Bardzo chcesz razem z przeniesieniem z XML zastąpić setter injection constructor injection, przepisać interfejs, wyrzucić parę klas i „zrobić w końcu normalnie". Ale wtedy przestajesz rozumieć, co dokładnie się zepsuło: migracja czy refaktoryzacja. Najpierw uzyskaj równoważne zachowanie w Java config, a potem osobnym krokiem poprawiaj design.
Błąd nr 5: zapominać, że zaimportowany XML może dodać nowych kandydatów i zepsuć autowiring.
W ContextFlow są już miejsca z kilkoma implementacjami interfejsów. Import XML może niepostrzeżenie dodać kolejną implementację, i twój constructor injection zaczyna padać z NoUniqueBeanDefinitionException. To nie powód „nienawidzić XML", to powód przypomnieć sobie narzędzia: @Qualifier, @Primary, jawne nazwy beanów i staranne granice skanowania.
GO TO FULL VERSION