JavaRush /Курсы /Spring Core /Финальная сборка ContextFl...

Финальная сборка ContextFlow

Spring Core
25 уровень, 0 лекция
Открыта

1. Введение

Когда приложение запускается — это приятно. Но у Spring-проектов есть хитрый побочный эффект: они часто запускаются даже тогда, когда вы не до конца понимаете, почему именно всё работает. В результате в голове остаётся “магия”: аннотации, какие-то конфиги, где-то events, где-то аспект… и всё это как будто не про одну систему, а про разные демо-куски. Финальная сборка нужна, чтобы у вас появилась цельная mental model: один путь, одна логика, один ApplicationContext.

В этой лекции мы будем держаться простой оси: main() → создание контекста → регистрация конфигурации → чтение окружения/профилей/свойств → создание бинов → запуск сценария. Всё остальное (ресурсы, сообщения, события, post-processors, AOP, XML-ветка, тесты) будем не перечислять, а аккуратно “вкладывать” в этот путь, как детали в общий механизм.

Чтобы не потеряться, вот компактная схема “что с чем связано”:

flowchart TD
  A["main()"] --> B["ApplicationContext"]
  B --> C["Config modules @Import"]
  B --> D["Environment: profiles + properties"]
  B --> E["Bean definitions -> beans"]
  E --> F["ScenarioRunner.run()"]
  F --> G["Use-case services"]
  G --> H["publishEvent()"]
  H --> I["@EventListener side effects"]
  E --> J["Support: BFPP/BPP/FactoryBean/AOP"]
  C --> K["Legacy XML bridge"]
  B --> L["Spring Test context"]

Смысл диаграммы не в том, чтобы выучить стрелки. Смысл в том, что это одна система, где каждый механизм имеет “точку входа” в общий поток.

2. Точка входа: main() и ApplicationContext

Нормальный старт приложения — это тот момент, когда вы должны видеть, кто здесь главный. И в нашем курсе главный не “аннотация дня”, а ApplicationContext. В Spring Boot часто кажется, что приложение стартует “само” (потому что SpringApplication.run() делает много работы за вас). Но мы сегодня сознательно остаёмся в чистом Spring Core: вы руками создаёте контекст, выбираете профили, регистрируете конфигурацию и вызываете refresh(). Это не “лишняя рутина”, это учебная рентген-съёмка.

Минимальный, честный entry-point финальной версии ContextFlow может выглядеть так:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class ContextFlowMain {

    public static void main(String[] args) {
        // Контекст лучше закрывать явно: иначе @PreDestroy/destroyMethod могут не сработать.
        try (var context = new AnnotationConfigApplicationContext()) {

            // Профиль важен ДО refresh(): он влияет на то, какие бины вообще будут зарегистрированы.
            context.getEnvironment().setActiveProfiles("demo");

            // Регистрируем корневую конфигурацию приложения (composition root).
            context.register(AppConfig.class);

            // Запускаем фазу сборки: чтение конфигов, создание bean definitions, инстанцирование бинов и т.д.
            context.refresh();

            // Запуск сценария — это граница старта, тут допустим ручной getBean().
            context.getBean(ScenarioRunner.class).run();
        }
    }
}

Обратите внимание на две вещи.

Первая — профиль выставляется до refresh(). Это важно, потому что профили влияют на то, какие бины вообще будут зарегистрированы и созданы. Если выставить профиль после refresh(), вы получите классический эффект “я переключил режим, но ничего не поменялось” (и начнёте подозревать заговор, хотя виноваты будете вы).

Вторая — try-with-resources. Закрытие контекста — это не “церемония”. Это причина, почему @PreDestroy и destroyMethod действительно срабатывают, а ресурсы (файлы, потоки, менеджеры вывода) закрываются корректно. Spring не может закрыть контекст “силой мысли”, если вы его не закрываете.

И да: вы тут видите “ручной getBean()”, но это только на границе старта, как вы и делали раньше с composition root в plain Java. Бизнес-слой не должен превращаться в “профессора getBean”.

3. AppConfig: модульная конфигурация вместо giant-кучи

Когда проект маленький, один конфигурационный класс кажется нормальным. Но как только вы добавляете profiles, resources, сообщения, reporting, legacy XML-ветку и support-слой, “один AppConfig на всё” превращается в текстовую версию гаража, где вы храните и велосипед, и холодильник, и старые дипломы. Модульная конфигурация нужна не ради красоты, а ради читабельности: вы открываете AppConfig и видите, из каких частей состоит приложение.

В финальной сборке удобно, когда верхний конфиг вообще почти пустой — он просто “складывает модули”:

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import({
        // База приложения: доменные сервисы/порты/репозитории и т.п.
        CoreConfig.class,

        // Профили (dev/demo/test) и вариативность поведения.
        ProfilesConfig.class,

        // Отчёты, форматирование и вывод.
        ReportingConfig.class,

        // Мост к легаси XML-фрагменту.
        LegacyBridgeConfig.class,

        // Инфраструктурные расширения Spring: BFPP/BPP/FactoryBean/AOP и диагностика.
        SupportConfig.class
})
public class AppConfig {
}

Теперь важная мысль: @Import — это не “ещё один способ регистрации”. Это способ сделать wiring читаемым. Ваши модули — это не “слои архитектуры ради диаграммы”, а практическая навигация по проекту.

Например, CoreConfig отвечает за базовую сборку доменных портов и сервисов, ProfilesConfig — за вариативность режима (dev/demo/test), ReportingConfig — за отчёты и output, LegacyBridgeConfig — за подключение XML-фрагмента, а SupportConfig — за всё, что относится к post-processors, factory beans, диагностике и AOP.

Если вам хочется проверить, что модульность работает, задайте себе вопрос: “Если завтра упадёт аудит в файлы, в каком модуле я это ищу?” В хорошей модульной конфигурации ответ будет почти автоматическим.

4. Окружение: profiles, properties и conversion

В Spring удобно то, что бизнес-логика может оставаться “чистой”, а поведение приложения меняется за счёт конфигурации. Но у этого есть условие: конфигурация должна быть подключена раньше, чем вы запустили сценарий. То есть ваш ApplicationContext сначала строит окружение и создаёт нужные бины, а потом вы выполняете use case. Это похоже на то, как вы сначала готовите кухню (достаёте ингредиенты, включаете плиту), а потом уже жарите омлет, а не пытаетесь включить плиту в середине жарки.

В ContextFlow мы держимся простой модели: есть базовый contextflow.properties, а также profile-specific варианты, например contextflow-demo.properties и contextflow-test.properties. Важно не перепутать это с Boot-конвенциями: в чистом Spring такие profile-specific источники подключаются явно через @PropertySource или программную регистрацию, одного совпадения имени с профилем контейнеру недостаточно. А затем вы читаете их через @Value или Environment.

Пусть, например, мы хотим управлять форматом отчёта и директорией вывода:

# contextflow.properties
contextflow.report.format=text
contextflow.report.output-dir=build/contextflow/reports

А затем в конфигурации “приземлить” это в инфраструктуру (упрощённо):

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ReportingConfig {

    @Bean
    ReportOutputManager reportOutputManager(
            // Значение будет подставлено контейнером при создании бина.
            @Value("${contextflow.report.output-dir}") String outputDir) {

        // Здесь мы сознательно держим только инфраструктуру (куда писать), не бизнес-логику.
        return new ReportOutputManager(outputDir);
    }
}

Здесь важный момент для mental model: @Value — это не “магия строк”. Это часть механизма, который контейнер делает на старте, до того как вы запускаете сценарий. Если value отсутствует, вы узнаете об этом либо на старте, либо в момент создания конкретного бина (в зависимости от ленивости/жадности создания). Это и есть fail-fast подход: лучше упасть сразу, чем “в середине выполнения заказа”.

А где тут conversion? В финальной версии вы не хотите писать if ("text".equals(format)) ... в бизнес-сервисе. Поэтому формат отчёта у вас становится, например, enum-ом, а строка из properties превращается в тип через ConversionService и ваш конвертер. Логика та же: всё это готовится контейнером до выполнения сценария.

5. Resource и MessageSource

Когда вы впервые слышите “ресурсы” и “сообщения”, есть соблазн подумать: “ну это же про UI и веб”. Но в non-web приложении это даже проще и честнее: шаблоны уведомлений и заголовки отчётов — это внешние артефакты, которые должны жить рядом с кодом, версионироваться и загружаться предсказуемо. А локализованные тексты — это способ не зашивать строки в Java-код, особенно когда они используются и в уведомлениях, и в отчётах, и в консольном выводе.

Представьте, что у вас есть шаблон уведомления о создании заказа:

src/main/resources/templates/notifications/order-created.txt

И каталог шаблонов, который загружается при старте (инициализация “живет” в lifecycle, а не в первом вызове бизнес-метода). Очень упрощённый пример:

import jakarta.annotation.PostConstruct;
import org.springframework.core.io.Resource;

public class NotificationTemplateCatalog {

    // Resource — это не "java.io.File": это абстракция Spring, которая умеет работать и с classpath, и с URL, и т.д.
    private final Resource orderCreatedTemplate;

    public NotificationTemplateCatalog(Resource orderCreatedTemplate) {
        // Зависимость приходит из контейнера так же, как и любая другая.
        this.orderCreatedTemplate = orderCreatedTemplate;
    }

    @PostConstruct
    void init() {
        // Эта фаза вызывается контейнером после создания бина: удобно делать быстрые проверки и подготовку.
        System.out.println("Templates ready: " + orderCreatedTemplate.exists()); // Templates ready: true
    }
}

Важно не то, что мы печатаем true. Важно, что вы видите место механизма в общей картине: ресурс — это не “просто файл”, это зависимость, которую контейнер умеет дать бину, а бин умеет проверить и подготовить в init-фазе.

С MessageSource история похожая. Вы подключаете bundles messages.properties, messages_ru.properties, messages_en.properties, а дальше получаете сообщение по ключу и локали. И это снова часть общей сборки: контейнер предоставляет MessageSource как инфраструктуру, а бизнес/инфраструктурный слой использует её для текста.

6. Use case: сценарий → сервис → event, а side effects — в listeners

Когда проект растёт, самая неприятная деградация — когда “главный” use case сервис превращается в комбайн: он и сохраняет заказ, и считает скидку, и пишет аудит, и шлёт уведомления, и обновляет статистику, и ещё где-то в уголке формирует отчёт “на всякий случай”. Такой сервис обычно работает, но читать его тяжело: каждое изменение превращается в мини-операцию на открытом сердце.

События в нашем проекте решают ровно это: use case делает основное действие и публикует факт, а побочные эффекты выполняются отдельными слушателями.

Мини-версия OrderPlacementService:

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;

@Service
public class OrderPlacementService {

    private final ApplicationEventPublisher publisher;

    public OrderPlacementService(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void place(String orderId) {
        // Сервис делает "главное": публикует бизнес-факт, а не тащит в себе аудит/уведомления/статистику.
        publisher.publishEvent(new OrderCreatedEvent(orderId));

        // Важно помнить: по умолчанию это синхронно (listeners выполняются в текущем потоке).
    }
}

Аудит как listener (упрощённо):

import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class AuditOrderEventsListener {

    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // Side effect живёт отдельно: use case не знает, что его кто-то аудитит.
        System.out.println("AUDIT orderId=" + event.orderId()); // AUDIT orderId=...
    }
}

На этом месте полезно вспомнить вашу mental model из предыдущих дней: события по умолчанию синхронные. То есть publishEvent() не “ставит в очередь”, а вызывает listeners прямо в текущем потоке. Это означает, что use case “завершился” только тогда, когда закончили все обработчики. И это нормально для нашего учебного non-web приложения.

С точки зрения финальной сборки важно другое: use case сервис не знает, кто слушает событие. Он знает только, что он публикует факт “заказ создан”. Это и есть мягкая развязка, которая делает систему менее связной.

7. Support-слой: BFPP, BPP, FactoryBean и AOP

Внутренние механизмы Spring легко превратить в плохую привычку: хочется везде добавить Aware, где-то сделать factory, где-то “подкрутить” post-processor, и внезапно бизнес-код начинает напоминать Spring-документацию, а не бизнес-логику. Поэтому в нашем проекте существует отдельный support.* слой: там живут post-processors, FactoryBean, диагностические Aware-бины и AOP. Это не “ещё один слой ради слоёв”, это санитарная зона: Spring-специфика изолирована.

Начнём с двух extension points, которые влияют на старт.

BeanFactoryPostProcessor живёт в фазе “метаданные ещё не превратились в объекты”. Это идеально для sanity check: проверить, что ключевые properties существуют, что нет критических противоречий, что обязательные bean definitions присутствуют. Условный пример (сильно упрощённый, чтобы не утонуть в API):

import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;

public class ContextFlowSanityCheckBeanFactoryPostProcessor
        implements BeanFactoryPostProcessor {

    @Override
    public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
        // Эта точка выполняется ДО создания бинов: мы работаем с bean definitions, а не с объектами.
        System.out.println("Sanity check: beanCount=" + bf.getBeanDefinitionCount());
    }
}

BeanPostProcessor работает уже с объектами (до/после init). Там удобно делать диагностику, “трекинг” компонентов, лёгкую обвязку. Например, логировать факт инициализации определённых бинов, не залезая в каждый класс. Это и есть “инфраструктурная магия”, которая не портит бизнес-слой.

Дальше FactoryBean. Мы используем его ровно один раз и только там, где это действительно объясняет контейнер: ReportFormatterFactoryBean выбирает реализацию форматтера по конфигурации. Важно, что “форматтер” — это продукт фабрики, а сама фабрика тоже bean. И это можно увидеть, если вы помните трюк &beanName, который даёт доступ к самой фабрике.

Потом диагностический Aware-бин. Он может получить имя, Environment, контекст — но только как изолированный компонент, чтобы бизнес-сервисы не начали искать бины вручную. Если бизнес-сервис получает ApplicationContext, это почти всегда smell: вы переехали из DI в service locator.

И наконец AOP. Аспект ServiceTimingAspect добавляет техническое поведение вокруг методов сервисного слоя (например, измерение времени). И тут финальная сборка помогает особенно: вы начинаете видеть, что аспект — не “магия компилятора”, а результат proxy-based модели, построенной контейнером через инфраструктурные механизмы.

8. Legacy XML: изолированный мост

XML в современных проектах — это не норма, но это реальность, которая встречается в легаси. И самое важное, что вы должны унести из этой части курса: XML — это тот же Spring, просто другой синтаксис описания bean model. Это не “отдельная технология”, и тем более не “тёмная магия”. Если вы понимаете bean definitions, scopes, wiring и lifecycle, то XML читается почти как “аннотации, только текстом”.

В финальной версии ContextFlow XML-фрагмент подключается как bridge, например через @ImportResource в отдельном модуле:

import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.ImportResource;

@Configuration
// Мост к легаси: отдельный модуль, чтобы XML не "размазывался" по всему проекту.
@ImportResource("classpath:legacy/legacy-notification-context.xml")
public class LegacyBridgeConfig {
}

А внутри XML вы видите знакомые вещи — bean id, class и, если нужно, зависимости:

<!-- Обычное определение бина: тот же bean model, просто в другом синтаксисе. -->
<bean id="consoleAuditWriter"
      class="com.example.contextflow.infrastructure.audit.ConsoleAuditWriter"/>

Фишка финальной сборки в том, что XML-ветка не размывает проект: она изолирована. Вы не превращаете приложение в XML-first, вы просто умеете жить с кусочком легаси, пока его не мигрировали.

9. Тесты: проверяем сборку, а не “верим в неё”

Тестирование Spring-конфигурации — это не про “покрыть всё тестами”, а про то, чтобы перестать бояться, что вы случайно сломали wiring, профили, property overrides, listeners или legacy-bridge. В идеале тесты должны использовать тот же AppConfig, что и реальный запуск, иначе вы тестируете “другую вселенную” и удивляетесь, почему в проде не так.

Минимальный smoke test может быть очень маленьким — иногда достаточно того, что контекст поднимается:

import org.junit.jupiter.api.Test;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;

@SpringJUnitConfig(AppConfig.class)
@ActiveProfiles("test")
class ContextSmokeTest {

    @Test
    void contextStarts() {
        // если контекст не стартует — тест упадёт сам (на поднятии TestContext).
    }
}

Это выглядит почти как “пустой тест”, но он проверяет очень важное: wiring, профили, property sources, конвертацию, регистрацию listeners, поддержку legacy XML, поддержку AOP и наличие ваших support beans. То есть он проверяет именно то, что plain unit-тестами проверить невозможно.

И снова обратите внимание на общую линию: тест — это просто другой entry-point в тот же ApplicationContext. В финальной mental model “запуск приложения” и “запуск контекста в тесте” — это не разные миры, это один механизм в разных режимах.

10. Типичные ошибки финальной сборки

Ошибка №1: объяснять проект через список аннотаций.
Когда финал сводится к “у нас есть @Configuration, @Profile, @EventListener и @Aspect”, у студента снова возникает ощущение магии. Аннотации начинают жить сами по себе, без связи с выполнением программы. Важно держать фокус на потоке: mainApplicationContext → создание бинов → запуск сценария. Аннотации — это лишь способ описать этот путь, а не его замена.

Ошибка №2: терять общий контур из-за мелких деталей.
Легко увязнуть в обсуждении “где лежит шаблон” или “как подгружается ресурс”, забыв, что это частный случай. Если не понятен общий механизм сборки, детали не спасают — они только перегружают. Сначала должна быть ясна картина целиком: как собирается контейнер, как связываются бины, как запускается сценарий.

Ошибка №3: смешивать orchestration и side effects.
Когда use case сервис начинает и управлять сценарием, и писать в лог, и слать события, и делать аудит — он разрастается и теряет читаемость. События становятся формальностью, а listeners — дублированием. Правильная структура: сервис описывает сценарий, а побочные эффекты выносятся в отдельные обработчики.

Ошибка №4: относиться к XML как к “другому Spring”.
XML часто воспринимается как что-то чужое и устаревшее, из-за чего его начинают избегать или бояться. На самом деле это тот же самый контейнер, просто описанный другим синтаксисом. Если перевести XML в привычную модель “bean → id → class → зависимости”, он становится понятным и предсказуемым.

Ошибка №5: забывать про точку входа и сценарий запуска.
В финальной сборке важно не только “что есть”, но и “что происходит при запуске”. Если нельзя быстро объяснить, какой бин инициирует сценарий и как он вызывается из main(), значит картина всё ещё размыта. Чёткая точка входа — это якорь, который удерживает всю архитектуру.

1
Задача
Spring Core, 25 уровень, 0 лекция
Недоступна
Модульный запуск приложения с событием
Модульный запуск приложения с событием
1
Задача
Spring Core, 25 уровень, 0 лекция
Недоступна
Загрузка шаблона из Resource и smoke test
Загрузка шаблона из Resource и smoke test
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ