JavaRush /Курсы /Spring Boot /Прокси и жизненный цикл бина

Прокси и жизненный цикл бина

Spring Boot
2 уровень , 4 лекция
Открыта

1. Миф «аннотация сама всё делает»

К этому моменту вам уже понятно, откуда берутся бины и как контейнер собирает зависимости. Но здесь легко сорваться обратно в мысль: «ну я же поставил аннотацию, значит она и работает». Нам важно добрать последний кусок картины: Spring часто действует не напрямую, а через framework machinery — то есть через прокси и жизненный цикл бина.

Когда вы только начинаете, очень легко поверить в сказку: «я поставил аннотацию — и оно заработало». Эта модель удобная, но вредная, потому что она превращает Spring в набор заклинаний. На самом деле аннотация — это всего лишь метка, метаданные, а реальную работу делает контейнер, который управляет объектами и иногда подставляет вместо «обычного» объекта обёртку.

Представьте, что аннотация — это наклейка на коробке: «Осторожно, стекло». Наклейка сама по себе не защищает стекло. Защита появляется потому, что дальше в логистике есть процесс: коробку кладут в пенопласт, ставят на верхнюю полку, не бросают. Так вот, Spring — это логистика, а аннотации — наклейки. И одна из причин, почему Spring может «упаковывать» ваши объекты по-особенному, — это прокси.

2. Прокси на пальцах

Прокси — это объект, который выглядит как нужный вам объект, или хотя бы умеет делать то же самое, но при этом перехватывает вызов, делает что-то дополнительно и только потом передаёт управление «настоящему» объекту. Это как турникет в метро: вы вроде бы «просто проходите», но турникет проверяет билет и только потом пускает. Не турникет везёт поезд — он просто стоит на пути и контролирует проход.

В Java прокси проще всего показать на интерфейсе: есть «настоящая» реализация и есть обёртка, которая добавляет поведение.

// Контракт: «умею читать каталог и возвращать его содержимое/представление».
public interface CatalogReader {
    String read();
}

// «Настоящая» реализация: делает работу без дополнительной инфраструктуры.
public class SimpleCatalogReader implements CatalogReader {
    @Override
    public String read() {
        return "catalog";
    }
}

А теперь обёртка, которая логирует «до» и «после». Это и есть ручной прокси: он не делает работу сам, а делегирует её дальше, добавляя свои действия вокруг.

public class LoggingCatalogReader implements CatalogReader {
    // Ссылка на «настоящий» объект, к которому мы делегируем вызовы.
    private final CatalogReader target;

    public LoggingCatalogReader(CatalogReader target) {
        this.target = target;
    }

    @Override
    public String read() {
        System.out.println("before read()"); // инфраструктурное поведение «до»
        String result = target.read();       // делегирование в реальную реализацию
        System.out.println("after read()");  // инфраструктурное поведение «после»
        return result;
    }
}

Использование выглядит максимально «обычно»: вы вызываете read(), а прокси решает, что ещё надо сделать по пути.

public class Demo {
    public static void main(String[] args) {
        // Снаружи вы работаете через интерфейс, не «зная», что внутри может быть обёртка.
        CatalogReader reader = new LoggingCatalogReader(new SimpleCatalogReader());

        // Вызов выглядит обычным, но реальный путь вызова: proxy -> target.
        System.out.println(reader.read()); // catalog
    }
}

Тут важно уловить психологический момент: прокси — не магия. Это просто объект-обёртка. Вы сами такие обёртки уже писали, даже если не называли их прокси: «валидатор вокруг сервиса», «кэш вокруг репозитория», «логирующий декоратор». Spring делает похожую вещь, только автоматизированно и в масштабе всего приложения.

3. Прокси в Spring: где появляются

Когда вы получаете бин из Spring, вы ожидаете: «вот он, мой класс, созданный контейнером». Часто так и есть. Но иногда Spring отдаёт объект, который снаружи ведёт себя как нужный бин, а внутри является обёрткой, потому что фреймворку нужно сохранить контроль: добавить инфраструктурное поведение, обеспечить единственность экземпляра, контролировать порядок вызовов или вмешаться в создание зависимостей.

Есть два полезных наблюдения для новичка. Во‑первых, прокси в Spring появляются не потому, что Spring любит усложнять жизнь, а потому что иначе многие вещи пришлось бы размазывать по вашему коду руками. Во‑вторых, прокси — это часть framework machinery: контейнер управляет тем, что вы реально получаете при инъекции или через getBean().

Иногда это видно даже по runtime-class объекта, который вы получили из контейнера: вместо ожидаемого имени класса вы встречаете что-то вроде $$SpringCGLIB$$. Но здесь важно не перепутать две разные вещи. Печать класса самого ApplicationContext показывает лишь конкретную реализацию контейнера, а не прокси вашего бина. Поэтому эффект прокси удобнее разбирать там, где Spring действительно перехватывает вызовы методов, — на @Configuration.

Главная мысль не в том, как именно называется класс, а вот в чём: контейнер может подменить «прямой» объект на управляемую обёртку, если это нужно для корректного поведения всей системы. И самый дружелюбный пример, который можно понять без AOP, транзакций и прочих «взрослых слов», — это @Configuration.

4. Прокси в @Configuration и @Bean

Если сегодня запомнить одну вещь про прокси в Spring, пусть это будет она: @Configuration — это не просто «класс, где лежат методы с @Bean». Это специальный класс, который Spring обычно превращает в прокси, чтобы ваши @Bean-методы работали как «вход в контейнер», а не как «обычный Java-метод, который каждый раз делает new».

Сначала посмотрим на чистую Java без Spring. Мы создаём объекты в конфигурации вручную. Всё честно и прозрачно — и при этом очень легко сделать не то, что мы хотели.

class CatalogRepository {
    // Здесь могла бы быть работа с БД, сетью и т.д.
}

class CatalogService {
    private final CatalogRepository repository;

    CatalogService(CatalogRepository repository) {
        // В обычной Java мы просто сохраняем зависимость.
        this.repository = repository;
    }

    CatalogRepository repository() {
        // Метод сделан для демонстрации: сравним ссылки на зависимость.
        return repository;
    }
}

Конфигурация «по-старинке»:

class AppConfig {
    CatalogRepository catalogRepository() {
        // Каждый вызов метода создаёт новый объект.
        return new CatalogRepository();
    }

    CatalogService catalogService() {
        // Тут есть ловушка: внутри вызывается метод, который каждый раз делает new.
        return new CatalogService(catalogRepository());
    }
}

А теперь маленькая проверка, которая вскрывает подвох: кажется, что репозиторий один, но на самом деле новый объект создаётся каждый раз, когда вы вызываете catalogService().

public class PlainJavaDemo {
    public static void main(String[] args) {
        // ВАЖНО: это обычный Java-объект, никакого контейнера тут нет.
        AppConfig config = new AppConfig();

        CatalogService s1 = config.catalogService();
        CatalogService s2 = config.catalogService();

        // Сравниваем ссылки на repository внутри двух сервисов.
        System.out.println(s1.repository() == s2.repository()); // false
    }
}

Почему false? Потому что config.catalogService() вызывает catalogRepository(), а catalogRepository() делает new CatalogRepository() каждый раз. Это нормальное поведение Java. Но Spring обычно хочет другой эффект: если бин singleton — а по умолчанию он singleton, — то репозиторий должен быть один на весь контекст, а не «сколько раз дёрнули метод — столько репозиториев».

Теперь та же идея, но в Spring:

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

@Configuration
class AppConfig {

    @Bean
    CatalogRepository catalogRepository() {
        // Логика создания бина описана тут, но вызывать метод руками «как фабрику» не надо.
        return new CatalogRepository();
    }

    @Bean
    CatalogService catalogService() {
        // Внутри @Configuration этот вызов обычно перехватит прокси и вернёт singleton из контейнера.
        return new CatalogService(catalogRepository());
    }
}

Наивный вопрос новичка здесь звучит так: «Подождите… но catalogService() прямо вызывает catalogRepository() — разве это не new каждый раз?» И вот тут появляется ответ про прокси: Spring перехватывает вызов catalogRepository() внутри @Configuration и возвращает бин из контейнера, а не вызывает метод как «обычную» фабрику.

flowchart TD
    A["catalogService() внутри @Configuration"] --> B["вызов catalogRepository()"]
    B --> C["прокси-конфигурация перехватывает вызов"]
    C --> D["ApplicationContext: дай бин catalogRepository"]
    D --> E["тот же singleton-экземпляр"]
    E --> F["new CatalogService(тот же repository)"]

И это объясняет важнейшую мысль: внутри Spring многие вещи работают не «напрямую», а через управляемую прослойку. Именно поэтому мы и говорим, что Spring-объект не всегда равен объекту от new.

Есть и другой стиль, который делает логику ещё понятнее: вместо вызова catalogRepository() прямо из метода вы просите зависимость параметром. Spring сам подставит нужный бин. И вам даже не придётся думать о том, «перехватил ли прокси вызов метода».

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

@Configuration
class AppConfig {

    @Bean
    CatalogRepository catalogRepository() {
        return new CatalogRepository();
    }

    @Bean
    CatalogService catalogService(CatalogRepository repository) {
        // Явно видно зависимость: сервис собирается из того, что контейнер ему передаст.
        return new CatalogService(repository);
    }
}

Этот вариант часто воспринимается проще: видно, что CatalogService зависит от CatalogRepository, и контейнер сам подставит нужный аргумент. Для начинающего это ещё и психологически комфортнее: меньше ощущение, что «под капотом кто-то подслушивает мои вызовы методов». Хотя да, технически Spring всё равно делает довольно много.

И, наконец, важная ловушка, в которую попадают многие: если вы создадите @Configuration класс через new, вы получите обычный объект без магии контейнера. То есть вызов @Bean-метода станет просто вызовом метода.

public class WrongConfigUsage {
    public static void main(String[] args) {
        // ВАЖНО: мы создали конфигурацию вручную, контейнер в этом не участвует.
        AppConfig config = new AppConfig(); // ВНЕ Spring-контекста!

        CatalogRepository r1 = config.catalogRepository();
        CatalogRepository r2 = config.catalogRepository();

        // Каждый вызов — новый объект, потому что это обычная Java.
        System.out.println(r1 == r2); // false
    }
}

Аннотации не запускают себя сами. Если объект не создан и не управляется контейнером, то и «контейнерного поведения» у него нет. Это очень честное правило, просто новички часто ждут обратного.

5. Жизненный цикл бина

Прокси объясняют, почему объект может быть не совсем «прямым». Но остаётся второй вопрос: когда вообще появляется бин? Многие думают, что бин создаётся «в момент первого использования», как обычный объект: «ну вызвали метод — он и создался». В Spring чаще всего иначе: контейнер поднимается, строит граф зависимостей и создаёт бины как часть старта. Поэтому у бина есть жизненный цикл: от «контекст стартует» до «контекст останавливается».

Самый полезный для новичка взгляд на жизненный цикл — как на несколько крупных этапов, без погружения в десятки внутренних extension points. Примерно это выглядит так:

flowchart TD
    A["SpringApplication.run(...)"] --> B["Создание ApplicationContext"]
    B --> C["Поиск/регистрация bean definitions"]
    C --> D["Создание экземпляров (singleton по умолчанию)"]
    D --> E["Внедрение зависимостей (DI)"]
    E --> F["Инициализация (внутренние шаги Spring)"]
    F --> G["Бин готов к работе"]
    G --> H["Приложение живёт"]
    H --> I["Остановка контекста"]
    I --> J["Уничтожение бинов (cleanup)"]

Чтобы увидеть этот процесс «руками», можно временно поставить маркеры в конструкторы. Я повторю: в реальном проекте так не логируют, но как учебный маркер это работает отлично — как наклейка «здесь объект родился».

import org.springframework.stereotype.Component;

@Component
class CatalogRepository {
    CatalogRepository() {
        // Учебный маркер: видно, когда именно контейнер создал бин.
        System.out.println("CatalogRepository created"); // CatalogRepository created
    }
}

И бин, который зависит от него:

import org.springframework.stereotype.Component;

@Component
class CatalogService {
    CatalogService(CatalogRepository repository) {
        // Учебный маркер: сервис создаётся после того, как контейнер подготовил зависимости.
        System.out.println("CatalogService created"); // CatalogService created
    }
}

Фокус в том, что эти сообщения вы увидите на старте контекста, а не «когда кто-то вызвал метод». То есть Spring собирает приложение заранее, как конструктор LEGO: сначала разложил детали, потом собрал, а уже потом запускает «игру».

Ещё одна важная деталь, которую новичку стоит знать: по умолчанию большинство бинов живёт как singleton в рамках контекста. Это означает, что в одном ApplicationContext будет ровно один экземпляр этого бина, и все зависимости будут ссылаться на него. Проверить это можно даже без прокси и без сложностей — просто взяв бин два раза из контекста.

import org.springframework.context.ApplicationContext;

class BeanIdentityDemo {
    static void demo(ApplicationContext ctx) {
        // Два запроса одного и того же бина из одного контекста.
        CatalogRepository r1 = ctx.getBean(CatalogRepository.class);
        CatalogRepository r2 = ctx.getBean(CatalogRepository.class);

        // Для singleton по умолчанию это будет одна и та же ссылка.
        System.out.println(r1 == r2); // true
    }
}

С жизненным циклом связано ещё одно практическое правило: если бин создаётся на старте, то всё, что вы делаете в конструкторе — или внутри создания @Bean, — влияет на время старта и на надёжность старта. И это приводит нас к следующему разделу.

6. Правило: не грузить конструктор бина

Когда вы пишете обычное консольное приложение, можно позволить себе многое: «ну пусть конструктор почитает файл», «ну пусть там будет 2 секунды ожидания». В Spring-приложении конструктор бина — это часть startup sequence. Если положить туда тяжёлую работу, вы замедлите старт и сделаете его хрупким: ошибка в одном конструкторе может завалить весь контекст.

Сделаем антипример, чтобы запомнилось. Вот бин, который «что-то долго делает» в конструкторе — в учебных целях это sleep, чтобы вы почувствовали эффект:

import org.springframework.stereotype.Component;

@Component
class SlowBean {
    SlowBean() throws InterruptedException {
        // Антипример: тяжёлая логика в конструкторе тормозит старт всего контекста.
        Thread.sleep(2000); // имитация тяжёлой инициализации
        System.out.println("SlowBean created"); // SlowBean created
    }
}

Даже без продвинутых знаний вы уже можете предсказать результат: приложение стартует медленнее, и вы будете смотреть на пустую консоль и думать «что сломалось?». А в реальности «сломалось» то, что конструктор превратился в мини-скрипт с побочными эффектами.

Нормальный конструктор в Spring обычно скучный — и это комплимент. Конструктор должен в основном принять зависимости и сохранить их. Всё. Никаких сетевых запросов, чтения мегабайт данных, запуска фоновых потоков «на всякий случай».

import org.springframework.stereotype.Component;

@Component
class SafeCatalogService {
    private final CatalogRepository repository;

    SafeCatalogService(CatalogRepository repository) {
        // Нормально: получить зависимость и сохранить её.
        this.repository = repository;
    }
}

Если вам действительно нужно выполнить какую-то инициализацию, в Spring есть специальные механизмы и точки расширения жизненного цикла. Но сегодня нам важно зафиксировать базовый принцип: бин — это не «объект, который вы создали когда захотели», а объект, который контейнер создаёт по своим правилам и в своё время. Поэтому конструктор лучше держать предсказуемым.

7. Как думать о классах в контейнере

Сейчас наш catalog-service ещё очень ранний, но эту опору важно взять уже сейчас: пишите классы так, как будто они живут не в вакууме, а внутри контейнера. Это влияет и на стиль кода, и на то, где вы создаёте объекты, и на то, как относитесь к «обычным» методам в конфигурации.

Например, мы можем сделать простую ручную «прокси-обёртку» вокруг репозитория каталога курсов, чтобы увидеть принцип на знакомом домене. Пока без базы данных и без сложной логики — только идея.

// Контракт репозитория: что-то умеет вернуть про каталог (тут — количество).
public interface CourseCatalogRepository {
    int count();
}

// Реальная (простая) реализация: хранит данные в памяти, без инфраструктуры.
public class InMemoryCourseCatalogRepository implements CourseCatalogRepository {
    @Override
    public int count() {
        return 42;
    }
}

Обёртка-прокси, которая добавляет поведение вокруг вызова:

public class LoggingCourseCatalogRepository implements CourseCatalogRepository {
    // Внутри прокси всегда есть «цель» — настоящий объект.
    private final CourseCatalogRepository target;

    public LoggingCourseCatalogRepository(CourseCatalogRepository target) {
        this.target = target;
    }

    @Override
    public int count() {
        // Дополнительное поведение «вокруг» вызова.
        System.out.println("count() called"); // count() called
        return target.count();                // делегирование реальной работе
    }
}

И теперь регистрируем это через @Configuration так, чтобы вся система зависела от «обёртки», а «реальный» репозиторий был внутри.

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

@Configuration
class CatalogConfiguration {

    @Bean
    InMemoryCourseCatalogRepository inMemoryRepository() {
        // Создаём «реальную» реализацию.
        return new InMemoryCourseCatalogRepository();
    }

    @Bean
    CourseCatalogRepository courseCatalogRepository(InMemoryCourseCatalogRepository target) {
        // Создаём обёртку и отдаём её как бин по интерфейсу (контракту).
        return new LoggingCourseCatalogRepository(target);
    }
}

Смысл этого примера не в том, что нам срочно нужно логирование count() called. Смысл в том, что вы начинаете мыслить по-spring’овски: объект можно заменить обёрткой, а сборка графа зависимостей — задача контейнера. И это напрямую связано с тем, как Spring сам использует прокси на уровне фреймворка.

И вот тут снова всплывает правило про @Configuration: если вы попытаетесь собрать это руками, вызывая методы как обычные фабрики, вы получите не «управляемые singleton’ы», а «каждый вызов — новый объект». В Spring-контексте наоборот: вы описываете намерение, а контейнер гарантирует, что зависимости будут стабильны и предсказуемы.

Именно поэтому main-класс Boot-приложения, вызов SpringApplication.run(...) и готовый Boot-каркас проекта лучше воспринимать не как набор случайных аннотаций, а как точку старта контейнера. Он поднимает контекст, собирает граф бинов и только потом даёт приложению жить как единой системе.

8. Типичные ошибки при работе с прокси

Ошибка №1: думать, что аннотация — это код, который «выполняется сам».
Аннотация — это метка. Если объект не создан контейнером — например, вы сделали new AppConfig(), — то и контейнерная механика не включится. Отсюда рождаются странные баги в стиле «почему @Bean-метод создаёт новый объект каждый раз?» — потому что вы находитесь в обычной Java, а не в управляемом контексте.

Ошибка №2: вручную вызывать @Bean-методы как «фабрики» по всему проекту.
@Bean-методы — это описание того, как контейнер создаёт бин, а не API, которым должны пользоваться остальные классы. Как только вы начинаете вызывать someConfig.someBean() из прикладного кода, вы смешиваете два мира: мир конфигурации контейнера и мир бизнес-кода. В лучшем случае получите дублирование объектов, в худшем — очень трудно объяснимые зависимости.

Ошибка №3: сравнивать классы через obj.getClass() == SomeClass.class и удивляться прокси.
Если Spring отдал вам прокси, getClass() может быть не тем, что вы ожидали. Это особенно неприятно, когда люди пишут логику вида «если класс ровно такой-то — делай так-то». В контейнерных приложениях безопаснее мыслить типами и контрактами: интерфейсами, instanceof, зависимостями через конструктор, а не через проверку «точного класса».

Ошибка №4: делать конструктор бина «умным», тяжёлым и с побочными эффектами.
Когда конструктор читает файлы, ходит в сеть, запускает потоки или делает долгие вычисления, вы превращаете старт контекста в лотерею. Любая проблема там становится проблемой всего приложения, потому что контейнер не сможет собрать граф зависимостей. Если хочется «сделать что-то на старте» — это отдельная тема и отдельные механизмы, но конструктор лучше держать скучным.

Ошибка №5: пытаться понять Spring, игнорируя жизненный цикл бина.
Новички часто ожидают, что бин создастся «по требованию», а потом удивляются: «почему мой System.out.println в конструкторе сработал сразу при старте?» Потому что контейнер поднимает приложение целиком, а не кусками. Как только вы принимаете идею жизненного цикла, многие странности превращаются в предсказуемое поведение: «контекст стартует → создаются бины → приложение готово».

1
Задача
Spring Boot, 2 уровень, 4 лекция
Недоступна
Ручной прокси для чтения текста
Ручной прокси для чтения текста
1
Задача
Spring Boot, 2 уровень, 4 лекция
Недоступна
`@Configuration` и один экземпляр зависимости
`@Configuration` и один экземпляр зависимости
1
Опрос
Spring Основы, 2 уровень, 4 лекция
Недоступен
Spring Основы
Контейнер и внедрение зависимостей
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ