1. Два значення: optional і момент створення
Коли ви лише починаєте працювати зі Spring, легко з’являється враження, що всі проблеми вирішує одна кнопка — «зробити якось так, щоб контейнер не сварився». Але в контейнера, як у суворого викладача, є різні причини для «сварки»: інколи він нарікає на те, що залежності немає, а інколи — на те, що намагається створити її занадто рано. Необов’язкова залежність відповідає на питання «чи може цього біна взагалі не бути», а @Lazy — на питання «навіщо створювати цей бін на старті, якщо він потрібен лише інколи або значно пізніше».
Зафіксуймо різницю максимально прямо. Необов’язкова залежність — це частина дизайну класу: без неї клас усе одно має сенс. Ліниве створення (@Lazy) — це частина стратегії запуску: бін потрібен, але ми хочемо змінити момент, коли він з’явиться в пам’яті.
Щоб не тримати це в голові як туманну філософію, зручніше один раз поглянути на порівняльну таблицю:
| Що ми хочемо виразити | Чим виражаємо | Як це читається в конструкторі | Головна ідея |
|---|---|---|---|
| «Цього біна може не бути» | Optional<T>, @Nullable, інколи ObjectProvider<T>.getIfAvailable() | Optional<AuditWriter> або @Nullable AuditWriter | Відсутність — нормальний сценарій, який код зобов’язаний обробити |
| «Бін є, але створюй його пізніше» | @Lazy | @Lazy ReportFormatter formatter | Бін існує в контейнері, але його екземпляр з’являється не одразу |
І важливе «антизаклинання»: @Lazy не робить залежність необов’язковою. Він лише каже контейнеру: «будь ласка, не створюй це зараз, якщо можна відкласти».
2. Старт Spring за замовчуванням: eager singletons
Перш ніж говорити про лінивість, варто чесно зрозуміти норму. У чистому Spring, без жодних додаткових платформ, контейнер під час старту зазвичай поводиться як дуже відповідальна людина: він намагається запустити застосунок так, щоб потім не було сюрпризів. Це означає, що під час створення ApplicationContext Spring реєструє визначення бінів і потім створює майже всі singleton-біни одразу — на етапі refresh().
Простими словами, ви створюєте контекст — і Spring починає «розставляти меблі» по квартирі ще до того, як ви вмикаєте світло в коридорі. Зате потім, коли ви запускаєте сценарій ContextFlow, ви майже гарантовано не зіткнетеся із сюрпризом на кшталт «ой, а в нас тут узагалі немає потрібного об’єкта».
Невеличка схема, щоб закріпити порядок дій (спрощено, без занурення в майбутні теми про lifecycle і post-processors):
flowchart TD
A[Старт застосунку] --> B[Реєстрація визначень бінів]
B --> C[Створення singleton-бінів без lazy]
C --> D[Впровадження залежностей]
D --> E[Контекст готовий, можна виконувати сценарії]
Чому така поведінка часто вигідна? Тому що вона робить застосунок передбачуваним. Якщо конфігурацію зламано, ви дізнаєтеся про це не посеред робочого дня, коли система вже в роботі, а на старті. У навчальному проєкті це особливо цінно: ви швидко бачите помилку зв’язування і швидко її виправляєте.
Але є й зворотний бік. Іноді Spring створює речі, які насправді:
- важкі — навіть якщо «важкі» лише в навчальному сенсі, тобто довго ініціалізуються;
- рідко використовуються (наприклад, «SMS-канал сповіщень» є, але сьогодні ми запускаємо сценарій, де все виводиться в консоль);
- або взагалі вмикаються лише в окремих режимах.
Ось тут і з’являється сенс @Lazy: не ламати архітектуру, але зробити старт менш метушливим.
3. @Lazy на біні: відкладаємо створення компонента
Коли ви чуєте слово «лінивий», є ризик уявити собі розробника, який відклав задачу до дедлайну і тепер героїчно страждає. Із Spring це працює м’якше: @Lazy — це не «ми потім якось розберемося», а «створюй бін лише тоді, коли він справді знадобиться». Сам бін при цьому залишається частиною контейнера: його можна отримати, він бере участь у зв’язуванні, просто контейнер не зобов’язаний створювати його на старті.
@Lazy можна ставити на клас-компонент (наприклад, @Component, @Service) або на @Bean-метод у конфігурації. Почнімо з найпростішого: @Lazy на компоненті, який існує, але не використовується під час кожного запуску ContextFlow.
Уявімо, що SmsNotificationSender у нас «майже як справжній»: у конструкторі він робить якусь підготовку (у реальному світі — завантаження конфігурації клієнта, перевірку ключа, налаштування шаблонів; у нас — просто імітацію через println, щоб побачити порядок).
package com.example.contextflow.infrastructure.notification;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
@Lazy // Кажемо контейнеру: створюй цей бін не на старті, а коли він справді знадобиться
@Component
public class SmsNotificationSender implements NotificationSender {
public SmsNotificationSender() {
// Цей println потрібен лише для демонстрації порядку створення бінів у консолі
System.out.println("SmsNotificationSender створено"); // SmsNotificationSender створено
}
@Override
public void send(String message) {
// Тут ми спеціально позначаємо канал надсилання, щоб у виводі було видно, що це саме SMS
System.out.println("[SMS] " + message); // [SMS] ...
}
}
Якщо цей sender ніде не використано (наприклад, ми використовуємо ConsoleNotificationSender як типовий), то під час старту контексту він не буде створений. У контейнері він «зареєстрований», але екземпляр з’явиться лише якщо хтось справді попросить SmsNotificationSender — через залежність або через getBean() у діагностиці.
Тепер важливий нюанс, через який @Lazy часто «не працює» в очах новачка. @Lazy на самому біні каже контейнеру не робити попереднього створення цього singleton-а під час refresh(). Але якщо жадібний бін попросить цю залежність як звичайний конструкторний аргумент, контейнер усе одно почне її розв’язувати просто під час створення споживача. Щоб відкласти ще й сам момент отримання залежності жадібним споживачем, можна поставити @Lazy у точці впровадження.
Покажу це на дуже маленькому прикладі з нашого домену звітності. Нехай ReportingService залежить від ReportFormatter. Ми можемо позначити форматувач @Lazy, але якщо сервіс створюється на старті йому потрібен форматувач прямо в конструкторі, форматувач усе одно може знадобитися вже під час створення сервісу.
4. @Lazy у точці впровадження: лінивий проксі
Якщо @Lazy на біні — це «не створюй цей об’єкт наперед», то @Lazy у точці впровадження — це «навіть якщо ти створюєш мій сервіс зараз, не вимагай одразу всі його внутрішні залежності». І ось тут Spring робить штуку, яка новачкам здається магією: він впроваджує не «справжній об’єкт», а лінивий замінник, який виглядає як потрібний тип, але створює реальний бін лише під час першого використання.
Так, технічно це проксі. Для поточного завдання достатньо одного факту: замість реального об’єкта ви отримуєте «розумну заглушку», яка під час першого виклику йде в контейнер і забирає справжній бін.
Зробімо це на тому ж ReportFormatter. Для наочності нижче ми використовуємо обидва важелі одразу: @Lazy на визначенні біна підкреслює, що форматувач не потрібно жадібно підіймати на старті, а @Lazy на параметрі конструктора дає ReportingService ліниву прокладку замість негайного цільового біна. Це не обов’язкова пара на всі випадки, а просто максимально наочний сценарій: ролі у цих двох @Lazy різні.
package com.example.contextflow.config.reporting;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
@Configuration
public class ReportingConfig {
@Bean
@Lazy // Не створюємо форматувач у жадібній фазі старту, якщо його ніхто не попросив
public ReportFormatter reportFormatter() {
return new CsvReportFormatter();
}
}
А ось так виглядає ліниве отримання залежності в сервісі:
package com.example.contextflow.application.reporting;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@Service
public class ReportingService {
// У цьому полі може опинитися лінивий проксі, а не «живий» форматувач (це нормально)
private final ReportFormatter formatter;
public ReportingService(@Lazy ReportFormatter formatter) {
// @Lazy на параметрі конструктора: Spring впровадить проксі, який розв’яже цільовий бін під час першого виклику
this.formatter = formatter;
}
public String build(String value) {
System.out.println("ReportingService.build()"); // ReportingService.build()
// Саме на цій стрічці лінивий проксі дійде до реального CsvReportFormatter
return formatter.format(value);
}
}
Зверніть увагу на думку: @Lazy у конструкторі не робить залежність optional. Якщо ReportFormatter у контейнері відсутній або налаштований криво, помилка просто проявиться не на старті контексту, а в момент першого реального виклику formatter.format(...). Це свідомий обмін швидким виявленням помилок на пізніше, а іноді й рідше.
5. Міні-експеримент: порядок створення бінів
З @Lazy легко потрапити в пастку «здається, я зрозумів, але не впевнений». Тому краще один раз побачити реальний порядок подій у консолі. Ми не будемо писати профайлер і не будемо копатися в налагоджувачі — нам вистачить чесних System.out.println(...) у конструкторах і в одному сценарії запуску. Це простий, але дуже педагогічний метод: контейнер не може сперечатися з тим, що він сам вивів.
Спочатку зробімо наш «важкий» форматувач, який друкує повідомлення в конструкторі:
package com.example.contextflow.infrastructure.reporting;
public class CsvReportFormatter implements ReportFormatter {
public CsvReportFormatter() {
// Демонстраційна «важка» ініціалізація: так ми побачимо момент створення в консолі
System.out.println("CsvReportFormatter створено"); // CsvReportFormatter створено
}
@Override
public String format(String value) {
// Спрощене форматування: важливий не вміст CSV, а факт першого виклику методу
return "CSV:" + value;
}
}
А тепер точка входу — у нас усе ще чистий Spring, без Boot. Сенс простий: друкуємо «контекст запущено», а потім будуємо звіт.
package com.example.contextflow;
import com.example.contextflow.application.reporting.ReportingService;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// try-with-resources гарантує, що контекст буде коректно закрито (і буде викликано destroy-callbacks, якщо вони є)
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
System.out.println("Контекст запущено"); // Контекст запущено
String report = ctx.getBean(ReportingService.class).build("day-09");
System.out.println("Звіт = " + report); // Звіт = CSV:day-09
}
}
}
Тепер найцікавіше — що ми побачимо в консолі.
Якщо CsvReportFormatter НЕ лінивий, то він буде створений на старті, і порядок буде приблизно такий: спочатку «створено», потім «Контекст запущено».
Якщо CsvReportFormatter позначений як @Lazy, але впроваджується як звичайна залежність жадібного сервісу, він усе одно може бути створений під час створення ReportingService: споживач попросив його одразу. І ви знову побачите «створено» до «Контекст запущено».
Якщо ж ReportingService отримує форматувач через @Lazy у точці впровадження, реальний об’єкт зміщується до першого виклику. У нашому демонстраційному варіанті бін теж позначений як @Lazy, щоб намір було видно і за конфігурацією, і за поведінкою запуску. Порядок стане таким:
Контекст запущено
ReportingService.build()
CsvReportFormatter створено
Звіт = CSV:day-09
І ось це — той самий момент, коли @Lazy перестає бути теорією і стає спостережуваною поведінкою.
6. Ціна і користь @Lazy
Після першого вдалого експерименту з’являється дуже небезпечна думка: «О, клас! Давайте позначимо все @Lazy — буде швидше!» Це приблизно як вирішити, що якщо кава смачна, то можна пити її замість води. Технічно ви виживете, але організм, а за ним і проєкт, почнуть ставити запитання.
Користь @Lazy зазвичай у трьох речах: прискорення запуску, якщо справді є що прискорювати; економія ресурсів, коли ми не створюємо те, чого сьогодні не використовуємо; і акуратне відкладання важких залежностей до моменту, коли вони точно знадобляться. Для ContextFlow це може бути рідкісний канал сповіщень або рідкісний формат звіту.
Ціна теж зрозуміла: ви втрачаєте частину поведінки fail-fast. Помилка конфігурації, яку раніше ловили під час запуску, може спливти «в бою» під час першого звернення. Плюс з’являється нова точка затримки: не на старті, а під час першого виклику. Іноді це нормально (наприклад, рідкісний звіт будується раз на день), інколи — дуже неприємно (наприклад, перше замовлення після розгортання потрапляє на п’ятисекундну паузу ініціалізації чогось).
Щоб ухвалити рішення без внутрішньої боротьби «я за швидкість» проти «я за надійність», можна мислити так:
| Ситуація | @Lazy частіше допомагає | @Lazy частіше шкодить |
|---|---|---|
| Залежність рідко використовується | Так: можна не витрачати ресурси наперед | Явної шкоди немає, окрім можливої пізньої помилки |
| Залежність потрібна для основного сценарію в 100% запусків | Зазвичай ні: ви просто переносите час ініціалізації | Так: перший реальний виклик стане повільнішим і менш передбачуваним |
| Ви хочете сховати проблему зв’язування | Ні: це самообман | Так: ви отримаєте «пізній вибух» замість «раннього вибуху» |
І ще одна практична порада. Якщо ви ставите @Lazy, корисно прямо поруч — коментарем або назвою компонента — залишити пояснення «чому». За місяць ви самі собі подякуєте, тому що без причини @Lazy виглядає як «дивна магія на авось».
7. Типові помилки при роботі з @Lazy
Найприкріші помилки з @Lazy зазвичай трапляються не через складність Spring, а через те, що в голові змішалися два значення: обов’язковість і момент створення. Давайте пройдемося по типових граблях так, щоб ви впізнавали їх у своєму коді за симптомами, а не за stack trace.
Помилка № 1: плутати optional dependency і lazy dependency.
Якщо ви ставите @Lazy, а потім у коді пишете if(bean == null), ви ніби намагаєтеся вимірювати температуру лінійкою. @Lazy не означає «біна може не бути». Бін є і має бути. Лінивість означає, що він буде створений пізніше, але коли він знадобиться — він має існувати.
Помилка № 2: ставити @Lazy «для оптимізації» без розуміння, що саме оптимізуємо.
У навчальному проєкті, та й у більшості звичайних сервісів, запуск і так швидкий. Якщо ви робите все лінивим, ви просто переносите роботу з моменту запуску на момент першого реального виклику і ускладнюєте діагностику. Оптимізація добра тоді, коли ви можете пояснити, що саме покращилось і який сценарій виграє.
Помилка № 3: поставити @Lazy лише на бін і чекати, що залежність перестане створюватися на старті, хоча вона впроваджується в жадібний singleton.
Це класика: «позначив форматувач @Lazy, але він усе одно створюється». Контейнер не шкідничає — він чесно створює те, що попросив інший бін під час старту. Якщо залежність впроваджується в жадібний singleton і потрібна одразу, вона буде створена. Щоб справді відкласти створення, інколи достатньо лінивої точки впровадження, а інколи — lazy-init у самого біна. Спочатку важливо зрозуміти, що саме ви хочете відкласти: попереднє створення eager singleton-а чи отримання залежності конкретним споживачем.
Помилка № 4: забути, що помилки переїхали в runtime, і не продумати поведінку.
Fail-fast — це не занудство Spring, а ваш друг. Якщо ви робите щось лінивим, подумайте, що станеться під час першого використання, якщо конфігурація неправильна. Чи буде це зрозуміла помилка? Чи її буде спіймано в тестах? Чи користувач побачить «щось не працює» без пояснення?
Помилка № 5: лікувати @Lazy-ом архітектурні проблеми, які @Lazy не має лікувати.
Дуже часто лінивість починають використовувати як «пластир на все»: сховати поганий поділ відповідальностей, приховати незручний граф залежностей, відкласти неминучий рефакторинг. @Lazy — нормальний інструмент, але він не має ставати способом «пожити ще тиждень» із незручним дизайном. Якщо здається, що @Lazy — єдиний шанс запустити застосунок, це вже привід зупинитися і подивитися на архітектуру уважніше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ