CodeGym /Kursy /Spring Core /Mieszana konfiguracja bez chaosu

Mieszana konfiguracja bez chaosu

Spring Core
Poziom 23, Lekcja 3
Dostępny

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.

1
Zadanie
Spring Core, poziom 23, lekcja 3
Niedostępne
Podłączanie XML w Java config przez `@ImportResource`
Podłączanie XML w Java config przez `@ImportResource`
1
Zadanie
Spring Core, poziom 23, lekcja 3
Niedostępne
Częściowa migracja beana z XML do Java config
Częściowa migracja beana z XML do Java config
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION