JavaRush /Курси /Spring Core /Фінальна збірка ContextFlo...

Фінальна збірка ContextFlow

Spring Core
Рівень 25 , Лекція 0
Відкрита

1. Вступ

Коли застосунок запускається, це приємно. Але у Spring-проєктів є підступний побічний ефект: вони часто «працюють самі», навіть тоді, коли ви не до кінця розумієте, чому саме все влаштовано так, а не інакше. У результаті в голові залишається «магія»: анотації, якісь конфіги, десь events, десь аспект… і все це ніби не про одну систему, а про різні демонстраційні фрагменти. Фінальна збірка потрібна, щоб у вас зʼявилася цілісна ментальна модель: один шлях, одна логіка, один ApplicationContext.

У цій лекції ми триматимемося простої осі: main() → створення контексту → реєстрація конфігурації → читання середовища, профілів і властивостей → створення бінів → запуск сценарію. Усе інше — ресурси, повідомлення, події, post-processors, AOP, XML-гілка, тести — ми не перелічуватимемо, а акуратно вмонтовуватимемо в цей шлях, як деталі в один механізм.

Щоб не загубитися, ось компактна схема зв’язків:

flowchart TD
  A["main()"] --> B["ApplicationContext"]
  B --> C["Модулі конфігурації через @Import"]
  B --> D["Середовище: профілі + властивості"]
  B --> E["Bean definitions → біни"]
  E --> F["ScenarioRunner.run()"]
  F --> G["Сервіси сценарію"]
  G --> H["publishEvent()"]
  H --> I["Побічні ефекти @EventListener"]
  E --> J["Підтримка: BFPP/BPP/FactoryBean/AOP"]
  C --> K["Міст до легасі XML"]
  B --> L["Тестовий контекст Spring"]

Сенс діаграми не в тому, щоб завчити стрілки. Важливо інше: це одна система, де кожен механізм має свою точку входу в спільний потік.

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 у звичайному Java-коді. Бізнес-шар не має перетворюватися на «професора getBean».

3. AppConfig: модульна конфігурація замість великої купи

Коли проєкт маленький, один конфігураційний клас здається нормальним. Але щойно ви додаєте профілі, ресурси, повідомлення, звіти, легасі 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 — це не ще один спосіб реєстрації. Це спосіб зробити зв’язування читабельним. Ваші модулі — це не шари архітектури заради діаграми, а практична навігація проєктом.

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

Якщо вам хочеться перевірити, що модульність працює, поставте собі запитання: «Якщо завтра зламається аудит у файли, у якому модулі я це шукатиму?» У хорошій модульній конфігурації відповідь буде майже автоматичною.

4. Середовище: profiles, properties і conversion

У Spring зручно те, що бізнес-логіка може залишатися чистою, а поведінка застосунку змінюється за рахунок конфігурації. Але для цього є умова: конфігурацію треба підключити раніше, ніж ви запустили сценарій. Тобто ваш ApplicationContext спочатку будує середовище й створює потрібні біни, а вже потім ви виконуєте сценарій використання. Це схоже на те, як ви спочатку готуєте кухню — дістаєте інгредієнти, вмикаєте плиту, — а вже потім смажите омлет, а не намагаєтеся ввімкнути плиту посеред готування.

У 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);
    }
}

Тут важливий момент для ментальної моделі: @Value — це не магія рядків. Це частина механізму, який контейнер виконує на старті, до того як ви запускаєте сценарій. Якщо значення відсутнє, ви дізнаєтеся про це або на старті, або в момент створення конкретного біна — залежно від лінивої чи жадібної стратегії створення. Це і є підхід fail-fast: краще впасти одразу, ніж посеред виконання замовлення.

А де тут conversion? У фінальній версії ви не хочете писати if ("text".equals(format)) ... у бізнес-сервісі. Тому формат звіту у вас стає, наприклад, enum-ом, а рядок із properties перетворюється на тип через ConversionService і ваш конвертер. Логіка та сама: усе це готується контейнером до виконання сценарію.

5. Resource і MessageSource

Коли ви вперше чуєте «ресурси» й «повідомлення», є спокуса подумати: «ну це ж про UI та веб». Але в не-веб застосунку це навіть простіше й чесніше: шаблони повідомлень і заголовки звітів — це зовнішні артефакти, які мають жити поруч із кодом, версіонуватися і завантажуватися передбачувано. А локалізовані тексти — це спосіб не вшивати рядки в 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("Шаблони готові: " + orderCreatedTemplate.exists()); // Шаблони готові: true
    }
}

Важливо не те, що ми друкуємо true. Важливо інше: ви бачите місце механізму в загальній картині. Ресурс — це не «просто файл», а залежність, яку контейнер уміє дати біну, а бін уміє перевірити й підготувати в init-фазі.

Історія з MessageSource схожа. Ви підключаєте bundles messages.properties, messages_ru.properties, messages_en.properties, а далі отримуєте повідомлення за ключем і локаллю. І це знову частина загальної збірки: контейнер надає MessageSource як інфраструктуру, а бізнес- та інфраструктурний шар використовує її для текстів.

6. Сценарій використання: сценарій → сервіс → event, а побічні ефекти — у слухачах

Коли проєкт росте, найнеприємніша деградація — це коли головний сервіс сценарію перетворюється на комбайн: він і зберігає замовлення, і рахує знижку, і пише аудит, і шле повідомлення, і оновлює статистику, і ще десь у кутку формує звіт «про всяк випадок». Такий сервіс зазвичай працює, але читати його важко: кожна зміна перетворюється на мініоперацію на відкритому серці.

Події в нашому проєкті розв’язують саме це: сценарій виконує основну дію і публікує факт, а побічні ефекти виконуються окремими слухачами.

Мініверсія 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 виконуються в поточному потоці).
    }
}

Аудит як слухач — спрощено:

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

@Component
public class AuditOrderEventsListener {

    @EventListener
    public void onOrderCreated(OrderCreatedEvent event) {
        // Побічний ефект живе окремо: сценарій не знає, що його хтось аудіює.
        System.out.println("АУДИТ orderId=" + event.orderId()); // АУДИТ orderId=...
    }
}

На цьому місці корисно згадати вашу ментальну модель із попередніх днів: події за замовчуванням синхронні. Тобто publishEvent() не ставить їх у чергу, а викликає слухачів просто в поточному потоці. Це означає, що сценарій завершився лише тоді, коли відпрацювали всі обробники. І це нормально для нашого навчального не-веб застосунку.

З погляду фінальної збірки важливо інше: сервіс сценарію не знає, хто слухає подію. Він знає лише, що публікує факт «замовлення створено». Це й є мʼяке розв’язання, яке робить систему менш зв’язаною.

7. Support-шар: BFPP, BPP, FactoryBean і AOP

Внутрішні механізми Spring легко перетворити на погану звичку: хочеться всюди додати Aware, десь зробити фабрику, десь підкрутити post-processor, і раптом бізнес-код починає нагадувати документацію Spring, а не бізнес-логіку. Тому в нашому проєкті існує окремий support.* шар: там живуть post-processors, FactoryBean, діагностичні Aware-біни та AOP. Це не ще один шар заради шарів, а санітарна зона: Spring-специфіка ізольована.

Почнімо з двох extension points, які впливають на запуск.

BeanFactoryPostProcessor живе у фазі, коли метадані ще не перетворилися на об’єкти. Це ідеально для перевірки коректності: перевірити, що ключові 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 обирає реалізацію форматера за конфігурацією. Важливо, що форматер — це продукт фабрики, а сама фабрика теж є біном. І це можна побачити, якщо ви пам’ятаєте трюк &beanName, який дає доступ до самої фабрики.

Потім — діагностичний Aware-бін. Він може отримати ім’я, Environment, контекст — але лише як ізольований компонент, щоб бізнес-сервіси не почали шукати біни вручну. Якщо бізнес-сервіс отримує ApplicationContext, це майже завжди ознака проблеми: ви переїхали з DI в service locator.

І нарешті AOP. Аспект ServiceTimingAspect додає технічну поведінку навколо методів сервісного шару, наприклад вимірювання часу. І тут фінальна збірка допомагає особливо: ви починаєте бачити, що аспект — не магія компілятора, а результат моделі на основі проксі, яку контейнер будує через інфраструктурні механізми.

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-конфігурації — це не про «покрити все тестами», а про те, щоб перестати боятися, що ви випадково зламали зв’язування, профілі, перевизначення властивостей, слухачів або 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.
    }
}

Це виглядає майже як порожній тест, але він перевіряє дуже важливе: зв’язування, профілі, джерела властивостей, конвертацію, реєстрацію слухачів, підтримку legacy XML, підтримку AOP і наявність ваших support-бінів. Тобто він перевіряє саме те, що звичайними unit-тестами перевірити неможливо.

І знову зверніть увагу на загальну лінію: тест — це просто інша точка входу в той самий ApplicationContext. У фінальній ментальній моделі «запуск застосунку» і «запуск контексту в тесті» — це не різні світи, а один механізм у різних режимах.

10. Типові помилки фінальної збірки

Помилка №1: пояснювати проєкт через список анотацій.
Коли фінал зводиться до «у нас є @Configuration, @Profile, @EventListener і @Aspect», у студента знову виникає відчуття магії. Анотації починають жити самі по собі, без зв’язку із запуском застосунку. Важливо тримати фокус на потоці: mainApplicationContext → створення бінів → запуск сценарію. Анотації — це лише спосіб описати цей шлях, а не його заміна.

Помилка №2: втрачати загальний контур через дрібні деталі.
Легко загрузнути в обговоренні «де лежить шаблон» або «як підвантажується ресурс», забувши, що це окремий випадок. Якщо не зрозумілий загальний механізм збірки, деталі не врятують — вони лише перевантажать. Спочатку має бути ясна картина цілком: як збирається контейнер, як зв’язуються біни, як запускається сценарій.

Помилка №3: змішувати координацію і побічні ефекти.
Коли сервіс сценарію починає і керувати сценарієм, і писати в лог, і надсилати події, і робити аудит, він розростається та втрачає читабельність. Події стають формальністю, а слухачі — дублюванням. Правильна структура така: сервіс описує сценарій, а побічні ефекти виносяться в окремі обробники.

Помилка №4: ставитися до XML як до «іншого Spring».
XML часто сприймається як щось чуже й застаріле, через що його починають уникати або боятися. Насправді це той самий контейнер, просто описаний іншим синтаксисом. Якщо перевести XML у звичну модель «bean → id → class → залежності», він стає зрозумілим і передбачуваним.

Помилка №5: забувати про точку входу та сценарій запуску.
У фінальній збірці важливо не лише «що є», а й «що відбувається під час запуску». Якщо не можна швидко пояснити, який бін ініціює сценарій і як він викликається з main(), значить картина все ще розмита. Чітка точка входу — це якір, який утримує всю архітектуру.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ