JavaRush /Курси /Spring Core /Ліниві залежності через @La...

Ліниві залежності через @Lazy

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

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 — єдиний шанс запустити застосунок, це вже привід зупинитися і подивитися на архітектуру уважніше.

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