JavaRush /Курси /Spring Core /Поведінка @Bean: ful...

Поведінка @Bean: full і lite mode

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

1. Виклики @Bean-методів і режими роботи

На перший погляд здається, що @Bean — це просто «метод, який повертає об’єкт», а виклик someBean() — звичайний Java-виклик. Але в Spring є важлива деталь: інколи внутрішній виклик одного @Bean-методу з іншого поводиться «по-контейнерному», а інколи — «по-звичайному», тобто створює новий об’єкт. У підсумку ви можете отримати ситуацію, коли впевнені, що всюди використовується один і той самий singleton, а на практиці в одному місці живе «офіційний» bean із контейнера, а в іншому — його таємний клон, створений через new.

Уявіть невеликий модуль звітності: форматер готує текст, а менеджер звітності використовує цей форматер усередині себе. За логікою ви очікуєте, що менеджер працює з тим самим formatter, який зареєстровано як bean у контейнері. Інакше виходить дивно: контейнер «вважає», що formatter один, а всередині іншого bean-а живе його окремий клон. Це схоже на ситуацію, коли в команди є «офіційний» документ у Confluence, але Вася веде «свою версію» в блокноті. Обидва начебто про те саме, але в продакшені завжди перемагає хаос.

У цій лекції ми розберемо, чому це відбувається, що таке full mode і lite mode, і як налаштування proxyBeanMethods впливає на поведінку. А головне — ви отримаєте практичне правило, як писати @Bean-методи так, щоб не наступати на ці граблі, навіть якщо термінологія ще не сидить у пам’яті.

2. @Bean як рецепт для контейнера

Важливо перемкнутися з режиму «я пишу звичайний Java-код» на режим «я описую контейнеру правила збирання». @Bean-метод — це не магічна команда «створи об’єкт просто зараз», а опис того, як контейнер має отримати екземпляр, коли він йому знадобиться. На старті контексту Spring читає класи @Configuration, знаходить @Bean-методи й реєструє метадані: «є bean із такою-то назвою, такого-то типу, створюється ось цим методом».

Ключовий момент: контейнер створює bean не тому, що ви викликали метод у коді, а за своїми правилами життєвого циклу. Для singleton за замовчуванням це зазвичай відбувається під час refresh / старту контексту; lazy-створення — окремий випадок. Коли контейнеру справді потрібно створити екземпляр, він сам викликає @Bean-метод, кладе результат у контекст і далі роздає його як singleton (за замовчуванням).

А ось «неправильний» шлях виглядає так: ви всередині іншого @Bean-методу пишете return new Something(otherBean()), думаючи, що otherBean() — це «контейнер поверне мені той самий singleton». І саме тут починаються режими.

Щоб не змішувати цю тему з робочим модулем звітності ContextFlow, візьмемо окрему demo-пару типів. Вони потрібні лише для одного питання: чи побачимо ми один і той самий bean, якщо @Bean-методи викликають один одного напряму?

public interface DemoReportFormatter {
    // Контракт форматера: на вхід — "сирий" текст, на вихід — готовий до виведення
    String format(String raw);
}
public class TextDemoReportFormatter implements DemoReportFormatter {
    @Override
    public String format(String raw) {
        // Примітивна "реалізація для прикладу": додаємо префікс, щоб було видно форматування
        return "[TEXT] " + raw;
    }
}
public class DemoReportOutputManager {
    private final DemoReportFormatter formatter;

    public DemoReportOutputManager(DemoReportFormatter formatter) {
        // Залежність явно передається ззовні (DI): менеджер не створює форматер сам
        this.formatter = formatter;
    }

    public DemoReportFormatter getFormatter() {
        // Метод потрібен лише для демонстрації: порівнюємо екземпляри в прикладі нижче
        return formatter;
    }
}

З погляду DI все прозоро: DemoReportOutputManager залежить від DemoReportFormatter. Питання лише в тому, як ми опишемо цей зв’язок у конфігурації.

3. Full mode і проксі @Configuration

Full mode — це стандартна поведінка @Configuration (тобто proxyBeanMethods = true, значення за замовчуванням). Ідея проста: Spring ставиться до конфігураційного класу не просто як до «звичайного об’єкта з методами», а як до особливого джерела правил. Тому він створює не голий екземпляр вашого класу, а «посилену» версію (proxy / підклас), яка вміє перехоплювати виклики @Bean-методів і узгоджувати їх із контейнером.

На практиці це означає таке: якщо всередині одного @Bean-методу ви викликаєте інший @Bean-метод, то у full mode цей виклик не обов’язково поводиться як звичайний Java-виклик. Його можуть перехопити й перетворити на «дай мені bean із контейнера». І це саме те, що потрібно, щоб зберегти обіцянку «singleton per container» навіть усередині конфігурації.

Намалюємо спрощену схему — без байткоду і без деталей CGLIB, нам важлива логіка:

flowchart TD
    A["@Bean reportOutputManager()"] -->|"викликає reportFormatter()"| B["Проксі @Configuration"]
    B -->|"перевіряє контейнер"| C["BeanFactory"]
    C -->|"повертає singleton bean"| D["DemoReportFormatter (bean)"]
    D --> E["DemoReportOutputManager отримує той самий екземпляр"]

Тепер покажемо full mode в коді. Зверніть увагу: тут ми навмисно використовуємо «ризикований» стиль — прямий виклик reportFormatter() з іншого методу — щоб побачити, що full mode його «підстраховує».

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

@Configuration
class DemoReportingConfigFull {

    @Bean
    DemoReportFormatter reportFormatter() {
        // У full mode цей метод викликає контейнер, а результатом стає керований bean
        return new TextDemoReportFormatter();
    }

    @Bean
    DemoReportOutputManager reportOutputManager() {
        // Важливо: внутрішній виклик @Bean-методу у full mode може бути перехоплений проксі
        // і замість "нового об'єкта" буде повернуто singleton із контейнера.
        return new DemoReportOutputManager(reportFormatter());
    }
}

Перевіримо запуск через AnnotationConfigApplicationContext:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var ctx = new AnnotationConfigApplicationContext(DemoReportingConfigFull.class)) {
    var f = ctx.getBean(DemoReportFormatter.class);         // "офіційний" bean форматера з контейнера
    var m = ctx.getBean(DemoReportOutputManager.class);     // менеджер, зібраний контейнером
    System.out.println(f == m.getFormatter());              // true: екземпляри збіглися
}

Чому тут true? Тому що у full mode Spring зробив так, щоб reportFormatter() усередині reportOutputManager() фактично повернув керований контейнером singleton, а не «новий об’єкт за рецептом».

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

4. Lite mode і proxyBeanMethods = false

Lite mode виникає, коли ви вимикаєте перехоплення @Bean-методів: @Configuration(proxyBeanMethods = false). У цьому режимі Spring і далі читає ваші @Bean-методи та реєструє beans, але не забезпечує «розумну поведінку» внутрішніх викликів @Bean-методів. Тобто якщо ви в одному @Bean-методі викликали інший — це буде звичайний Java-виклик, який просто виконає метод і створить новий об’єкт (якщо всередині new).

На схемі це виглядає приблизно так:

flowchart TD
    A["@Bean reportOutputManager()"] -->|"викликає reportFormatter()"| B["Звичайний Java-виклик"]
    B -->|"повертає new TextDemoReportFormatter()"| C["Новий об'єкт (не bean)"]
    C --> D["DemoReportOutputManager отримує клон, контейнер про нього не знає"]

Покажемо той самий приклад, але в lite mode:

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

@Configuration(proxyBeanMethods = false)
class DemoReportingConfigLite {

    @Bean
    DemoReportFormatter reportFormatter() {
        // Bean у контейнері буде один (singleton), але під час прямого виклику цей метод створить новий об'єкт
        return new TextDemoReportFormatter();
    }

    @Bean
    DemoReportOutputManager reportOutputManager() {
        // У lite mode це звичайний Java-виклик: ми справді створимо новий TextDemoReportFormatter()
        // і передамо його в менеджер, оминаючи контейнер.
        return new DemoReportOutputManager(reportFormatter());
    }
}

Запуск майже такий самий:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var ctx = new AnnotationConfigApplicationContext(DemoReportingConfigLite.class)) {
    var f = ctx.getBean(DemoReportFormatter.class);         // singleton bean, створений контейнером
    var m = ctx.getBean(DemoReportOutputManager.class);     // менеджер, створений контейнером
    System.out.println(f == m.getFormatter());              // false: менеджер тримає "клон", не bean
}

Оце false — класична причина студентського «щооо?» і водночас причина багатьох реальних багів у проєктах, де хтось поставив proxyBeanMethods = false, а стиль конфігурації залишився «як раніше».

Зверніть увагу на тонкість: контейнер як і раніше створює bean reportFormatter — один, singleton. Але DemoReportOutputManager усередині отримав інший екземпляр TextDemoReportFormatter, створений прямим викликом методу. У підсумку ctx.getBean(DemoReportFormatter.class) повертає одне, а ctx.getBean(DemoReportOutputManager.class).getFormatter() — інше. І обидва об’єкти формально коректні як Java-об’єкти, тому помилка не буде «червоною і гучною». Вона буде «тихою і шкідливою».

Ще один важливий нюанс: lite mode — це не тільки proxyBeanMethods = false. Логіка @Bean-методів, оголошених не в @Configuration, теж поводиться «lite-подібно». Наприклад, якщо ви покладете @Bean-метод у звичайний @Component, він буде зареєстрований, але внутрішні виклики ніхто не зобов’язаний перехоплювати.

import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

@Component
class DemoReportingSupportFactory {

    @Bean
    DemoReportFormatter reportFormatter() {
        // Цей @Bean буде зареєстровано, але "full mode-перехоплення" внутрішніх викликів тут не гарантоване
        return new TextDemoReportFormatter();
    }
}

Так робити інколи можна — зазвичай для дуже локальних речей, — але важливо пам’ятати: на «full mode-гарантії» тут розраховувати не варто. Якщо ви захочете викликати reportFormatter() з інших методів цього класу, це буде звичайний Java-виклик.

5. Безпечний стиль: параметри @Bean

Добра новина в тому, що є стиль, який працює однаково передбачувано і у full mode, і в lite mode: пов’язувати бини через параметри @Bean-методу, а не через прямі виклики інших @Bean-методів. Спершу це виглядає трохи незвично, а потім стає настільки природним, що ви перестаєте писати інакше.

Сенс такий: замість «я сам зараз викличу reportFormatter()» ви кажете контейнеру: «мені потрібен DemoReportFormatter — передай його сюди, будь ласка». Це все той самий DI, який ми обговорювали на Дні 2, тільки точка впровадження — не конструктор сервісу, а фабричний метод конфігурації.

Ось безпечна версія нашого demo-конфіга, яка залишається коректною навіть із proxyBeanMethods = false:

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

@Configuration(proxyBeanMethods = false)
class DemoReportingConfigSafeLite {

    @Bean
    DemoReportFormatter reportFormatter() {
        // Звичайний singleton-bean форматера
        return new TextDemoReportFormatter();
    }

    @Bean
    DemoReportOutputManager reportOutputManager(DemoReportFormatter formatter) {
        // Ключовий момент: залежність приходить параметром від контейнера, а не через виклик іншого @Bean-методу
        return new DemoReportOutputManager(formatter);
    }
}

Перевірка:

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

try (var ctx = new AnnotationConfigApplicationContext(DemoReportingConfigSafeLite.class)) {
    var f = ctx.getBean(DemoReportFormatter.class);         // контейнерний singleton
    var m = ctx.getBean(DemoReportOutputManager.class);     // менеджер, зібраний з тією самою залежністю
    System.out.println(f == m.getFormatter());              // true: один і той самий екземпляр
}

Чому так краще?

По-перше, ви більше не залежите від «магії» перехоплення методів. Навіть якщо конфігурація опиниться в lite mode — за вашою волею або за обставинами — контейнер усе одно розв’яже параметри @Bean-методу й передасть саме той екземпляр, який він вважає правильним.

По-друге, залежності стають видимими прямо в сигнатурі методу. Коли ви читаєте конфіг, ви відразу розумієте: DemoReportOutputManager залежить від DemoReportFormatter. А якщо залежність захована всередині тіла через reportFormatter(), то це вже «прихована залежність» — тільки не в сервісі, а в конфігурації. Ми буквально повертаємося до симптомів, через які Spring узагалі з’явився.

Щоб зафіксувати різницю, ось невелика порівняльна таблиця — як шпаргалка для мозку, який ще не виспався:

Як пов’язуємо бини Full mode (proxyBeanMethods = true) Lite mode (false)
Внутрішнім викликом otherBean() Зазвичай безпечно Небезпечно: легко отримати «клон»
Через параметр @Bean-методу Безпечно Безпечно

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

Та сама думка на demo-конфігу

Зараз нам потрібен не робочий варіант ContextFlow, а ізольований конфіг, на якому видно одне правило: якщо @Bean-методи не викликають один одного, lite mode безпечний.

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

@Configuration(proxyBeanMethods = false)
class DemoReportingConfig {

    @Bean
    DemoReportFormatter reportFormatter() {
        return new TextDemoReportFormatter();
    }

    @Bean
    DemoReportOutputManager reportOutputManager(DemoReportFormatter formatter) {
        return new DemoReportOutputManager(formatter);
    }
}

У реальному ContextFlow логіка буде такою самою: щойно залежності приходять параметрами, proxyBeanMethods = false перестає бути міною й стає просто налаштуванням, яке не змінює сенс вашого wiring.

6. Типові помилки full/lite mode

Помилка №1: поставити proxyBeanMethods = false, але продовжувати викликати @Bean-методи один з одного.
Це найчастіший сценарій. Зовні код виглядає «як раніше», застосунок стартує, тестів поки немає, усі задоволені. А потім виявляється, що один bean використовує «клон» залежності, і поведінка розходиться з очікуваннями контейнера. Лікується це майже завжди одним рухом: передайте залежність параметром у @Bean-метод, і проблема зникає без жодних хитрощів.

Помилка №2: думати, що анотація @Bean перетворює будь-який виклик методу на «container lookup».
Анотація не змінює природу Java-виклику. Вона каже контейнеру: «цей метод — фабрика для bean-а». Але якщо ви викликаєте метод напряму в lite mode, це залишиться звичайним Java-викликом. Правильна ментальна модель така: «контейнер керує створенням bean-а, коли запит іде через контейнер», а не «анотація робить метод магічним у будь-якій точці застосунку».

Помилка №3: оголосити @Bean-методи в @Component і чекати поведінки як у @Configuration.
@Bean усередині @Component справді працює: bean буде зареєстровано. Але розраховувати на full mode-семантику тут не можна. Якщо ви хочете конфігурацію як «опис wiring», тримайте такі методи в класах @Configuration і сприймайте їх як composition root на боці контейнера.

Помилка №4: створювати залежності через вкладені new, хоча контейнер міг би передати їх параметром.
Коли конфіг перетворюється на «ручний composition root усередині Spring», ви знову отримуєте приховані залежності й крихкість. Параметри @Bean-методу — це найпростіший спосіб сказати Spring: «впровадь те, що вже зареєстровано». Це і читабельніше, і безпечніше, і краще поєднується з lite mode.

Помилка №5: змішати в голові «два екземпляри об’єкта» і «два bean-и в контейнері».
У lite mode найчастіше проблема не в тому, що в контейнері два bean-и (контейнер якраз зазвичай тримає один singleton), а в тому, що ваш код створив зайвий об’єкт, який узагалі не є bean-ом. Контейнер його не знає, не віддає через getBean, і ви не можете на нього нормально спиратися як на частину керованого графа об’єктів. Це особливо підступно, бо Spring при цьому не зобов’язаний сваритися: з погляду Java все валідно.

1
Задача
Spring Core, 6 рівень, 2 лекція
Недоступна
Full mode і прямий виклик `@Bean`-метода
Full mode і прямий виклик `@Bean`-метода
1
Задача
Spring Core, 6 рівень, 2 лекція
Недоступна
Lite mode і зайвий об'єкт при прямому виклику
Lite mode і зайвий об'єкт при прямому виклику
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ