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 все валідно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ