1. Смешанная конфигурация как норма
Когда XML уже перестал казаться отдельной технологией, возникает более приземлённый вопрос: как жить с ним внутри нормального приложения, где уже есть Java config. Когда мы слышим «смешанная конфигурация», мозг новичка обычно рисует хаос: половина проекта в XML, половина в аннотациях, и где-то между ними прячется баг, который появляется только по вторникам. На практике всё гораздо спокойнее, если заранее принять простую мысль: миграция — это процесс, а не событие. И в процессе вам нужно уметь держать два синтаксиса в одном приложении так, чтобы контейнер оставался предсказуемым.
Представьте типичный сценарий. У вас есть проект, который уже перевели на @Configuration + @ComponentScan, но в нём остался небольшой legacy-модуль (например, уведомления), который трогать страшно: «работает — не трогай». При этом вам нужно продолжать развивать проект рядом: добавлять новые бины, менять wiring, подключать новые реализации интерфейсов. Полный «big bang» перенос всего XML в Java config часто слишком рискованный и дорогой — особенно если вы не уверены, что понимаете каждую строчку legacy-файла.
Смешанная конфигурация — это и есть инженерный компромисс: мы подключаем XML к современному контексту, делаем проект запускаемым, а потом переносим по кусочкам, пока не останется только Java config. Главное — делать это контролируемо, а не «ну давайте импортнём всё и посмотрим, что взорвётся».
2. Legacy XML: отдельный контекст или общий контейнер
Когда у вас есть XML-файл, самый простой «технический» способ — поднять его отдельным ClassPathXmlApplicationContext и достать из него пару бинов. Это реально работает и иногда полезно для экспериментов. Но если ваша цель — реальное приложение (наш ContextFlow), то почти всегда хочется, чтобы Java config и XML жили в одном ApplicationContext, иначе начинается весёлый квест «как передать бин из одного контейнера в другой».
Давайте сравним два подхода в таблице, без религиозных войн и без «XML — зло, Java — добро» (оба синтаксиса умеют одинаково хорошо описывать BeanDefinition):
| Подход | Как выглядит | Плюсы | Минусы |
|---|---|---|---|
| Отдельный XML-контекст | new ClassPathXmlApplicationContext("...xml") | Быстро проверить legacy-фрагмент, изолировать эксперимент | Бины не живут в одном контейнере, wiring между мирами становится неудобным |
| Общий контекст через импорт | @ImportResource("classpath:...xml") внутри Java config | Один контейнер, единое разрешение зависимостей, проще мигрировать по шагам | Нужно следить за конфликтами имён и дублирующими definitions |
В ContextFlow нам нужен именно второй вариант: один контейнер, один граф зависимостей, один набор профилей/ресурсов/слушателей событий — и маленький legacy-островок, который мы постепенно «цивилизуем».
Вот схема, как это мысленно выглядит (да, это та же bean model, просто источников определения больше):
flowchart TD Java["@Configuration / @Bean / scanning"] --> BD[BeanDefinitions registry] XML["legacy *.xml"] --> BD BD --> Beans["Созданные bean instances"] Beans --> App["ContextFlow: сценарии, сервисы, listeners"]
Ключевой момент: @ImportResource не создаёт «второй контейнер». Он просто добавляет ещё один источник BeanDefinition в текущий контейнер.
3. @ImportResource: что это такое и что он делает на самом деле
@ImportResource — это аннотация из мира Java-конфигурации, которая говорит Spring: «вот тебе XML-ресурс, прочитай оттуда bean definitions и добавь их в этот же контекст». Никакой отдельной магии: контейнер по-прежнему строит единый registry бинов, а потом создаёт объекты, внедряет зависимости и применяет lifecycle.
Минимальный пример bridge-конфигурации (как правило, у нас это пакет config.legacybridge):
package com.example.contextflow.config.legacybridge;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
// Подключаем legacy XML в ТОТ ЖЕ ApplicationContext (не создаём отдельный контейнер)
@ImportResource("classpath:/legacy/legacy-notification-context.xml")
public class LegacyBridgeConfig {
// Класс пустой — он нужен как «якорь» для импорта XML-ресурса
}
Обратите внимание на classpath:/.... Это важно по двум причинам. Во‑первых, XML лежит в src/main/resources, значит на runtime он будет доступен в classpath (внутри jar — тоже). Во‑вторых, мы уже учили Resource abstraction: «classpath» — это не «файл на диске», это ресурс приложения.
Даже если этот XML потом вырастет ещё парой bean-ов, точка входа не меняется: bridge-конфиг всё так же просто подмешивает его definitions в общий контейнер.
Дальше мы собираем основной контекст, как обычно, и просто добавляем LegacyBridgeConfig в список конфигураций:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
// Важно: один AnnotationConfigApplicationContext, внутри которого будут и @Bean, и XML-bean definitions
try (var ctx = new AnnotationConfigApplicationContext(
com.example.contextflow.config.core.CoreConfig.class,
com.example.contextflow.config.legacybridge.LegacyBridgeConfig.class
)) {
// Проверяем, что бин из XML реально зарегистрировался в общем контексте
System.out.println(ctx.containsBean("legacyNotificationSender")); // true
}
Если вы видите true, это означает, что legacyNotificationSender зарегистрирован как bean definition и доступен в контексте. Важно: мы не полезли за ним через отдельный ClassPathXmlApplicationContext. Мы не создали второй контейнер. Мы просто добавили XML-определения в общий.
Ещё один практический нюанс. @ImportResource принимает массив строк, так что можно подключать несколько маленьких XML-фрагментов (но именно маленьких, иначе вы снова получаете «гигантский XML-монолит», только теперь он импортирован):
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
@ImportResource({
// Можно импортировать несколько XML-фрагментов, если держите legacy по модулям
"classpath:/legacy/legacy-notification-context.xml",
"classpath:/legacy/legacy-audit-context.xml"
})
class LegacyBridgeConfig {
}
В нашем курсе мы держим один legacy fragment, так что это скорее иллюстрация, чем рекомендация «подключите все ваши 30 XML».
4. Wiring между Java config и XML: зависимости в обе стороны
Самое полезное в @ImportResource — то, что бины из XML и бины из Java config начинают жить как соседи по лестничной клетке: знают друг о друге, могут ссылаться друг на друга и даже спорить за место под солнцем. Это удобно, но именно тут важно не потерять голову: один контейнер означает и единый механизм разрешения кандидатов.
На одном фрагменте это видно лучше всего. В XML живёт legacyNotificationSender, и он уже использует инфраструктурный бин messageSource из общего контекста:
<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>
А в Java config мы создаём сервис, который хочет использовать именно эту legacy-реализацию:
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(
// Берём именно legacy-бин из XML по его id
@Qualifier("legacyNotificationSender") NotificationSender sender
) {
return new NotificationDispatchService(sender);
}
}
Здесь в одной картинке видно обе стороны связи. XML-бин получает messageSource из общего контекста через ref="messageSource". Java config, в свою очередь, выбирает legacyNotificationSender по qualifier и строит на нём сервис. Контейнер один, candidate resolution один, lifecycle один.
Если NotificationSender был бы единственным кандидатом, qualifier мог бы и не понадобиться. Но в ContextFlow реализаций несколько, поэтому явное имя здесь спокойнее: сразу видно, что сервис сидит именно на legacy-ветке.
5. Пошаговая миграция XML → Java config
Самая частая ошибка новичка — попытаться «просто переписать XML на Java» за вечер. Это примерно как «переписать весь проект на microservices» за выходные: теоретически возможно, но обычно это заканчивается тем, что у вас есть два проекта — старый и “почти новый”, и ни один не запускается. Пошаговая миграция нужна, чтобы в каждый момент времени приложение оставалось в рабочем состоянии.
Ниже — стратегия, которую удобно держать как план и проговаривать вслух, когда вы смотрите на legacy XML и чувствуете лёгкое желание уехать в лес без интернета.
Шаг A. Изолируем legacy-фрагмент и подключаем через @ImportResource
Первый шаг — вообще признать, что это legacy. В ContextFlow мы держим такие файлы в src/main/resources/legacy/ и подключаем их через отдельный конфиг, например LegacyBridgeConfig. Это даёт вам визуальную границу: «вот тут legacy». Психологически это уже облегчает жизнь, потому что XML перестаёт расползаться по проекту как конфетти.
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;
@Configuration
@ImportResource("classpath:/legacy/legacy-notification-context.xml")
class LegacyBridgeConfig {
}
Важно: на этом шаге мы ничего не мигрируем. Мы просто делаем проект запускаемым и контролируемым.
Шаг B. Делаем «карту» XML: id → class → зависимости → lifecycle
Теперь нужен маленький инвентаризационный ритуал. Не страшный, просто инженерный. Мы выписываем каждый <bean> и фиксируем, что он делает. Это можно держать даже в виде таблички в заметках или прямо в комментарии.
Пример карты для notification-фрагмента, где уже есть sender и завязанный на него сервис:
| bean id | class | injection | depends on | lifecycle |
|---|---|---|---|---|
| legacyNotificationSender | LegacyConsole NotificationSender | property | messageSource (ref), appName (value) | init, destroy |
| legacyNotification DispatchService | NotificationDispatchService | constructor | legacyNotificationSender (ref) | — |
Когда у вас есть карта, вы перестаёте «угадывать, что хотел сказать автор XML», и начинаете видеть реальную модель контейнера.
Шаг C. Выбираем один бин для миграции и переносим его в Java config
Хороший первый кандидат — локальный бин, который не тащит за собой полпроекта. В нашем случае это как раз legacyNotificationSender: у него уже есть и свойства, и ссылка на другой bean, и lifecycle, но граф вокруг него всё ещё маленький и понятный.
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>
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;
}
}
Мы сознательно сохранили имя legacyNotificationSender: именно его уже знают XML-ref, @Qualifier и диагностический код.
Шаг D. Удаляем/отключаем старое объявление из XML и снова запускаем приложение
Это критичный момент дисциплины. Пока бин объявлен и в XML, и в Java config, контейнер либо перезапишет определение (если overriding разрешён), либо упадёт (если запрещён). И в обоих случаях вы получаете риск тихой поломки: «кажется, мы мигрировали, но на самом деле работает старая версия».
Поэтому правило: перенёс бин — убрал из XML. Маленькими шагами, но честно.
Шаг E. Повторяем для следующего бина
Очень важный психологический пункт. Миграция — это не рефакторинг архитектуры. В момент переноса у вас будет соблазн: «а давайте заодно заменим setter injection на constructor injection», «а давайте заодно переделаем интерфейс», «а давайте заодно выкинем половину бинов». Не надо. Это потом. Сейчас цель — сохранить поведение, но поменять синтаксис конфигурации.
Если XML делал setter injection, вы мигрируете его как setter injection в @Bean-методе, даже если вы очень любите конструкторы. Конструкторы мы любим, но production-миграция любит стабильность.
6. Перенос зависимостей и lifecycle
Когда миграция доходит до бинов посложнее, важно научиться переводить XML-формы wiring в Java-формы, не теряя поведения. Хорошая новость: вы уже знаете все идеи (конструктор, свойства, lifecycle). Плохая новость: в legacy-файлах это может быть намешано.
Миграция constructor-arg ref="..." в Java config
XML:
<bean id="auditService" class="com.example.contextflow.application.service.AuditService">
<constructor-arg ref="auditWriter"/>
</bean>
Java config (самый прямой аналог — параметр @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 сам подставит AuditWriter из контекста. Если кандидатов несколько — используете @Qualifier, как вы уже умеете.
Миграция <property name="..."> в Java config
XML:
<bean id="reportOutputManager"
class="com.example.contextflow.support.lifecycle.ReportOutputManager">
<property name="outputDir" value="build/reports"/>
</bean>
Java config (мы создаём объект и вызываем 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;
}
}
Да, это выглядит «не так красиво», как конструктор. Но это честная миграция: в XML был property injection — в Java config он тоже есть. И здесь важно не пропустить ещё один момент: в XML строковое value="build/reports" контейнер дополнительно конвертировал в нужный тип. В Java config целевой тип обычно создаётся явно, поэтому здесь появляется Path.of(...).
Миграция init-method / destroy-method в 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 {
// Прямой перенос init-method/destroy-method из XML в @Bean-метаданные
@Bean(initMethod = "loadTemplates", destroyMethod = "clearCache")
NotificationTemplateCatalog templateCatalog() {
return new NotificationTemplateCatalog();
}
}
Это прямой перевод lifecycle-настройки в другой синтаксис. И он хорошо ложится на то, что мы изучали в дне про lifecycle: @Bean(initMethod=...) — нормальный инструмент, особенно при миграции, когда вы не хотите лезть в код класса и добавлять @PostConstruct.
7. Контроль границ: конфликты имён, дубли и scanning из XML
Смешанная конфигурация ломается обычно не потому, что @ImportResource «плохой», а потому что мы перестаём контролировать границы. В legacy XML легко встретить context:component-scan, context:annotation-config, property placeholder инфраструктуру и даже какие-нибудь неожиданные алиасы. И если вы это импортируете «как есть», можете случайно включить лишнее и получить половину приложения, собранную дважды.
Первая зона риска — конфликт имён бинов. XML любит явные id="...", Java config любит имена методов @Bean. Сканирование любит называть бины по правилам lowerCamelCase от имени класса. Всё это встречается в одном контейнере, а значит, столкновения имён — абсолютно реальная вещь. Самая безопасная привычка на миграции — сохранять имена при переносе и удалять старые объявления сразу, а не «потом когда-нибудь».
Вторая зона риска — дублирование scanning. Например, у вас в Java config уже есть @ComponentScan("com.example.contextflow"), а legacy XML вдруг содержит context:component-scan base-package="com.example.contextflow.infrastructure.notification". Итог: контейнер дважды попробует зарегистрировать одни и те же компоненты. Иногда вы это заметите сразу (по конфликту имен), иногда нет (если имена разные), и тогда вы получите «почему у меня два NotificationSender-а, я же добавлял один».
Третья зона риска — аннотационная инфраструктура и placeholder’ы. Если XML использует ${...} placeholders, он ожидает, что в контексте есть infrastructure, которая умеет их разрешать. В нашем проекте мы уже поднимали Environment и placeholder support в Java config, так что обычно всё будет хорошо. Но если вы импортируете XML-фрагмент в «голый» контекст без этих вещей, он может упасть на старте не потому, что XML плохой, а потому что ему не хватило инфраструктурных бинов.
И наконец, самая полезная дисциплина: держите legacy-модуль маленьким и изолированным. В идеале legacy XML должен описывать конкретные бины, а не «поднимать половину приложения». Тогда у вас есть шанс мигрировать его за несколько шагов без нервного тика.
Минимальный пример для ContextFlow: подключаем legacy как модуль
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApp {
public static void main(String[] args) {
// Поднимаем единый контекст: CoreConfig + мост к legacy XML
try (var ctx = new AnnotationConfigApplicationContext(
com.example.contextflow.config.core.CoreConfig.class,
com.example.contextflow.config.legacybridge.LegacyBridgeConfig.class
)) {
// Быстрая sanity-check проверка: бин из XML действительно в контейнере
System.out.println(ctx.containsBean("legacyNotificationSender")); // true
}
}
}
Если true, значит legacy бин реально стал частью контейнера. Дальше вы можете мигрировать его по одному шагу, не пересобирая приложение заново и не создавая отдельный мир для XML.
8. Типичные ошибки при смешанной конфигурации
Ошибка №1: импортировать XML, не понимая его границы.
Если вы подключаете legacy-файл в надежде «ну контейнер как-нибудь разберётся», вы сами лишаете себя главного преимущества Spring — предсказуемости. Правильный подход начинается с ответа на вопрос: за что отвечает этот XML-фрагмент и какие бины он должен дать контейнеру. Без этого легко случайно притащить лишний scanning, лишние placeholder’ы и половину инфраструктуры «в нагрузку».
Ошибка №2: держать один и тот же бин и в XML, и в Java config.
Это классика миграции. Вы перенесли <bean id="legacyNotificationSender" .../> в @Bean legacyNotificationSender(), но забыли удалить старое объявление из XML. В лучшем случае контейнер упадёт и честно скажет, что у него конфликт. В худшем — одно определение перезапишет другое, и вы получите ситуацию «у меня вроде новая конфигурация, но работает почему-то старая». Дисциплина простая: перенесли — удалили.
Ошибка №3: менять имя бина “чуть-чуть, чтобы красивее”.
Переименовать legacyNotificationSender в consoleNotificationSenderLegacy кажется невинным, пока вы не вспомните, что где-то в XML есть ref="legacyNotificationSender", а где-то в Java code может быть @Qualifier("legacyNotificationSender"). На миграции лучше сохранять имена как есть, а красоту наводить после того, как всё переведено и стабилизировано.
Ошибка №4: мигрировать и рефакторить дизайн одновременно.
Очень хочется вместе с переносом из XML заменить setter injection на constructor injection, переписать интерфейс, выкинуть пару классов и «сделать наконец нормально». Но тогда вы перестаёте понимать, что именно сломалось: миграция или рефакторинг. Сначала добейтесь эквивалентного поведения в Java config, а потом отдельным шагом улучшайте дизайн.
Ошибка №5: забыть, что импортированный XML может добавить новых кандидатов и сломать autowiring.
В ContextFlow уже есть места с несколькими реализациями интерфейсов. Импорт XML может незаметно добавить ещё одну реализацию, и ваш конструкторный injection начнёт падать с NoUniqueBeanDefinitionException. Это не повод «ненавидеть XML», это повод вспомнить инструменты: @Qualifier, @Primary, явные имена бинов и аккуратные границы scanning.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ