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 в конструкторе сработал сразу при старте?» Потому что контейнер поднимает приложение целиком, а не кусками. Как только вы принимаете идею жизненного цикла, многие странности превращаются в предсказуемое поведение: «контекст стартует → создаются бины → приложение готово».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ