1. Ручная регистрация бинов: когда надоедает
Если до сегодняшнего дня вы всё делали «правильно», ваш AppConfig или код запуска уже начал напоминать список покупок на неделю: вроде всё полезное, но растёт он быстрее, чем чувство контроля. Причина простая и вполне инженерная: контейнеру нужно сообщать, какие классы считать бинами. Вручную это работает, но удовольствие заканчивается, как только проект становится чуть больше «Hello, world».
Вспомните наш ContextFlow. Даже в учебной версии там довольно быстро появляются сервисы сценариев, хранилище заказов, аудит, уведомления, генерация id и так далее. Если каждый класс перечислять вручную, изменение в духе «добавил один класс» быстро превращается в «добавил класс, не забыл зарегистрировать, поправил импорт, не перепутал имена». Пока это ещё не катастрофа, но уже самое время посмотреть, как Spring обычно решает такую задачу в повседневных проектах.
Чтобы увидеть контраст, удобно держать в голове простую таблицу — она заменит длинные списки и сэкономит немного нервных клеток:
| Вопрос | Ручная регистрация | Component scanning |
|---|---|---|
| Где «живёт» информация о регистрации? | В конфигурации (часто далеко от класса) | Прямо рядом с классом (аннотация на классе) |
| Что меняется при добавлении нового сервиса? | Нужно не забыть «дописать в конфиг» | Достаточно пометить класс и положить в правильный пакет |
| Риск «забыл зарегистрировать» | Высокий | Ниже (но появляются другие риски: границы сканирования) |
| Что делает контейнер? | Регистрирует то, что вы явно перечислили | Сам ищет кандидатов в заданной области |
Сегодняшняя идея очень простая: мы не меняем Spring на другой Spring. Мы меняем только способ регистрации бинов: вместо «вручную перечислить» — «автоматически найти по правилам».
2. Component scanning: автоматизация регистрации
Термин «сканирование» звучит так, будто Spring берёт лазерный сканер, ходит по вашему проекту и читает мысли разработчика. К счастью или к сожалению, всё прозаичнее. Component scanning — это вполне прагматичный механизм: контейнер получает область поиска (обычно корневой пакет приложения) и ищет там классы, помеченные специальными аннотациями, чтобы зарегистрировать их как бины.
Важно сразу снять половину «магии»: scanning решает задачу регистрации, а не вопросы вроде «как внедрять зависимости», «как выбирать реализацию интерфейса» или «как жить с профилями». Сегодня нам нужна только минимальная рабочая модель:
пометили класс → задали область поиска → подняли контекст → получили bean
Вот и всё. Никаких тайных порталов в Boot и никакого «оно само как-то». Если класс не попал в область поиска, хоть обклейте его аннотациями — контейнер о нём не узнает. И наоборот: если область поиска задана слишком широко, контейнер может найти больше, чем вы ожидали, и вы снова пойдёте читать stack trace, только уже с новым сюжетом.
Есть и важная связь с прошлыми днями: найденный класс не превращается сразу в готовый объект. Сначала Spring находит его как кандидата, регистрирует BeanDefinition, а уже потом — в зависимости от стратегии старта — создаёт реальные экземпляры. Это тот же двухфазный подход, который мы уже обсуждали на Дне 4, просто источником BeanDefinition теперь становится scanning, а не ручной конфиг.
3. @Component: класс-кандидат в bean
Аннотация @Component — базовая метка, которой мы говорим Spring: «вот этот класс — кандидат на регистрацию как bean». Она находится в пакете org.springframework.stereotype и по смыслу максимально нейтральна: «компонент приложения». Позже мы увидим более узкие stereotype-аннотации, но сегодня держим фокус на базе.
Важно не перепутать два утверждения. Верное: «класс с @Component может стать bean-ом». Неверное: «класс с @Component уже стал bean-ом». Между ними есть ещё одна важная вещь — @ComponentScan, которая задаёт область поиска. Без сканирования @Component — это как наклейка «важно» на документе, который лежит в ящике, куда никто не заглядывает.
Минимальный пример на базе ContextFlow: пусть у нас есть порт AuditWriter, и первая реализация пишет аудит в консоль. Это отличный кандидат на компонент: инфраструктурный объект, который должен жить в контейнере.
import com.example.contextflow.domain.ports.AuditWriter;
import org.springframework.stereotype.Component;
// Помечаем класс как компонент: при scanning Spring зарегистрирует его как bean
@Component
public class ConsoleAuditWriter implements AuditWriter {
@Override
public void write(String message) {
// Простейшая реализация: пишем аудит в консоль (инфраструктурный код)
System.out.println("[AUDIT] " + message); // [AUDIT] ...
}
}
Обратите внимание на смысл этого куска кода: мы не создаём объект, не вызываем new и не трогаем контекст. Мы просто помечаем класс — то есть описываем намерение: «контейнер, пожалуйста, обрати на это внимание, если будешь сканировать».
И ещё один мягкий, но важный стоп-сигнал: не нужно делать bean-ом всё подряд. Доменные классы вроде Order, OrderItem, команды вроде CreateOrderCommand — это обычно обычные объекты, которые создаются по ходу сценария. Контейнер — не склад для всего подряд, а менеджер инфраструктуры и сервисов.
4. @ComponentScan: граница поиска
Если @Component — это метка на классе, то @ComponentScan — правило для контейнера: где искать такие классы. Чаще всего @ComponentScan ставят на конфигурационный класс — тот самый, с которого вы поднимаете ApplicationContext. И это логично: конфигурация — точка сборки приложения, а scanning — часть этой сборки.
Минимальная конфигурация для ContextFlow выглядит так: мы говорим Spring, что корневой пакет проекта — com.example.contextflow, и именно его нужно просканировать.
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
// Класс конфигурации: через него мы отдаём Spring стартовые инструкции
@Configuration
// Граница scanning: Spring ищет компоненты внутри указанного пакета и подпакетов
@ComponentScan(basePackages = "com.example.contextflow")
public class AppConfig {
}
Здесь важно не застрять на слове @Configuration. Сегодня мы не разбираем «режимы» конфигурации и тонкости @Bean-методов. Нам достаточно простой мысли: это класс, который вы передаёте в AnnotationConfigApplicationContext, и через него Spring получает стартовые инструкции. Одна из них — «включи scanning».
После этого наш знакомый код запуска почти не меняется: мы всё так же создаём контекст, всё так же можем сделать getBean(), всё так же корректно закрываем контекст через try-with-resources.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
// try-with-resources гарантирует корректное закрытие контекста
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Если контекст поднялся — значит конфиг прочитан и scanning сработал
System.out.println("Context started"); // Context started
}
}
}
На этом месте у новичков часто возникает ожидание: «ну всё, я поставил @ComponentScan, значит Spring нашёл всё во Вселенной». Нет. Он нашёл всё внутри заданной границы, и только то, что подходит под критерии «компонента» (в нашем случае — аннотировано соответствующим образом).
5. Старт контекста: scanning → BeanDefinition → объект
Пока мы не зафиксируем цепочку «что за чем», scanning будет казаться магией. Но на самом деле он идеально ложится на уже знакомую модель из предыдущих дней: Spring любит сначала собрать метаданные, а потом создавать объекты. Component scanning просто становится одним из источников этих метаданных.
Представьте, что AppConfig — это «карта района», а @Component — «вывеска на доме». Когда контейнер стартует, он проходит примерно такой путь: получает карту, обходит район, видит вывески, составляет список домов и только потом начинает «вселять жильцов» — то есть создавать объекты и соединять их зависимостями.
Небольшая схема, которая обычно хорошо укладывается в голове (и уменьшает желание верить в фей):
flowchart TD
A[AppConfig] --> B["@ComponentScan: basePackages"]
B --> C[Classpath scanning: поиск классов]
C --> D[Регистрация BeanDefinition]
D --> E[Создание singleton-beans]
E --> F[Готовый ApplicationContext]
Здесь самое важное — понять, что scanning не «создаёт объект». Он помогает контейнеру построить список того, что нужно создать. А дальше включаются уже знакомые механики: жизненный цикл, инъекция зависимостей и так далее. Сегодня мы не углубляемся в жизненный цикл, но фазы путать всё равно не стоит.
Если хочется увидеть это руками — а руками обычно понятнее, — можно сделать простую диагностику: попросить контекст сказать, зарегистрирован ли bean по имени. Это не «правильный бизнес-код», но для обучения и отладки — самое то.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Имя по умолчанию для ConsoleAuditWriter будет "consoleAuditWriter"
System.out.println(context.containsBean("consoleAuditWriter")); // true
}
Смысл этого фрагмента не в containsBean(). Смысл в том, что после scanning у контекста действительно появляется запись о компоненте — и это уже не догадки, а факт, который можно проверить.
6. Имена bean-ов: правила и контроль
Как только вы включаете scanning, возникает практический вопрос: «Окей, контейнер нашёл класс. А как он его назвал?» И это не праздный интерес. Даже если вы обычно получаете бины по типу, имена всплывают в диагностике, ошибках, логах, а позже — в более сложных сценариях выбора кандидата.
По умолчанию Spring генерирует имя bean-а из имени класса, делая первую букву строчной. То есть ConsoleAuditWriter становится consoleAuditWriter. Выглядит логично и в большинстве случаев этого достаточно.
Если же имя важно, например вам нужно стабильное имя для получения bean-а в учебных целях или для будущего «явного выбора», его можно задать вручную прямо в @Component. Делается это через параметр value.
import org.springframework.stereotype.Component;
// Явно задаём имя bean-а (удобно, когда дальше делаем lookup по имени)
@Component("orderPrinter")
public class OrderPrinter {
public String print(String orderId) {
// Пример простого сервиса: возвращаем строку для печати заказа
return "Order: " + orderId;
}
}
Теперь вы можете получить его по имени:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Получаем bean по имени и типу (так безопаснее, чем только по имени)
var printer = context.getBean("orderPrinter", OrderPrinter.class);
System.out.println(printer.print("ORD-1")); // Order: ORD-1
}
Здесь важно не впасть в другую крайность: не нужно сразу везде задавать имена вручную «на всякий случай». В небольших приложениях и учебных примерах это иногда полезно, но в реальном коде чаще удобнее получать зависимости по типу. Наша цель на этом этапе — понимать, что имя у bean-а есть, откуда оно берётся и как сделать его предсказуемым, если это вдруг понадобится.
И да, небольшой факт на будущее — без подробностей: имена становятся особенно интересными, когда у вас появляется несколько реализаций одного интерфейса. Но сегодня мы туда не идём — сегодня мы учимся, чтобы потом не страдать.
7. Минимальный пример для ContextFlow
Чтобы закрепить механику, сделаем маленький, но законченный кусок. Не «перенесём всё приложение», а «сделаем минимальный шаг, который точно работает». В учебных проектах это почти всегда лучший способ не потеряться: двигаться маленькими проверяемыми шагами.
Для самой механики scanning нам сейчас не нужен полный рабочий сценарий ContextFlow. Хватит маленького ScenarioRunner без зависимостей: здесь важно увидеть не финальную сборку проекта, а сам факт, что контейнер нашёл класс и умеет вернуть его по типу.
import org.springframework.stereotype.Component;
// Компонент, который должен быть найден scanning-ом и зарегистрирован в контексте
@Component
public class ScenarioRunner {
public void run() {
// Демонстрационный эффект: видно, что bean реально создан и метод выполняется
System.out.println("ContextFlow started"); // ContextFlow started
}
}
Сканирование включено в AppConfig (мы его уже показали). Тогда запуск остаётся таким:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class ContextFlowApplication {
public static void main(String[] args) {
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Получаем bean по типу: имя тут не важно, важно что он вообще в контексте
var runner = context.getBean(ScenarioRunner.class);
runner.run(); // ContextFlow started
}
}
}
И вот здесь — ключевая победа дня: ScenarioRunner попал в контекст не потому, что мы его явно зарегистрировали, а потому что контейнер нашёл его во время scanning. Именно ради этого всё и затевалось. Мы сокращаем конфиг-код и приближаем информацию о регистрации к самому классу.
Важно зафиксировать одну простую мысль: ApplicationContext остался тем же центром системы. Мы не «обошли Spring», мы просто научили контейнер автоматически находить часть бинов.
Проверка результата scanning
В реальной жизни после включения scanning самый частый вопрос звучит не философски, а очень прикладно: «Почему он не видит мой компонент?» Или, если день не задался: «Почему он увидел то, что я вообще не хотел видеть?» Поэтому на этом этапе полезно освоить пару диагностических приёмов, не превращая их в отдельный стиль программирования.
Один из самых простых способов — вывести список имён bean definition. Да, это выглядит шумно, но зато сразу видно: scanning вообще что-то зарегистрировал или нет. И вы можете быстро проверить, появилось ли имя вашего компонента.
import java.util.Arrays;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Диагностика: печатаем все зарегистрированные имена bean definition
System.out.println(Arrays.toString(context.getBeanDefinitionNames())); // [..., scenarioRunner, ...]
}
Ещё один спокойный вариант — проверить наличие конкретного типа. Он часто приятнее, потому что вы мыслите классами, а не строками.
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) {
// Быстрая проверка: если getBean не бросил исключение, значит компонент найден
System.out.println(context.getBean(ScenarioRunner.class) != null); // true
}
Смысл этих проверок не в том, чтобы тащить их в production. Смысл в том, чтобы у вас была отвёртка для диагностики: если после добавления @Component и @ComponentScan класс не находится, вы сначала проверяете границу scanning и пакет, а не лезете в бизнес-логику и не начинаете подозревать Java в заговоре.
И короткая памятка: когда компонент не найден, чаще всего виноваты три вещи — нет @ComponentScan, неверный basePackages, или класс лежит не там, где вы думаете (например, после рефакторинга пакетов). Всё остальное случается реже и обычно приходит позже, когда сборка зависимостей становится сложнее.
8. Типичные ошибки при работе с @Component и @ComponentScan
На первом знакомстве component scanning кажется слишком простым: «ну что там, две аннотации». Именно поэтому ошибки оказываются особенно обидными: вам кажется, что всё сделано, а контейнер ведёт себя так, будто вашего кода не существует. Давайте разберём самые частые промахи не по названиям исключений, а по симптомам — так они запоминаются лучше.
Ошибка №1: поставить @Component, но забыть включить scanning.
Это классика. Вы пометили ScenarioRunner как @Component, но в конфигурации не появилось @ComponentScan (или конфигурационный класс вообще не используется при старте контекста). Симптом обычно простой: NoSuchBeanDefinitionException при попытке getBean(ScenarioRunner.class). Исправление тоже простое: убедиться, что контекст стартует именно с тем AppConfig, где есть @ComponentScan.
Ошибка №2: думать, что @Component «создаёт объект прямо сейчас».
Иногда ожидают, что как только вы написали @Component, где-то в JVM раздастся щелчок, и объект материализуется. На практике @Component лишь делает класс кандидатом, а реальное создание происходит на фазе создания экземпляров внутри старта контекста. Если это перепутать, появляется странная логика вроде «почему конструктор не вызывается при компиляции?». Спойлер: потому что компиляция не запускает контейнер.
Ошибка №3: помечать @Component всё подряд, включая доменные объекты.
Есть соблазн: «раз scanning — это удобно, пусть Spring управляет всем: Order, Customer, CreateOrderCommand…». Это быстро размывает архитектуру и превращает контейнер в сущность, которая знает слишком много. Доменным объектам обычно не нужна жизнь в контейнере: они создаются на каждую операцию, несут данные, и их проще контролировать обычным new. В контейнер обычно отправляют сервисы и инфраструктуру — то, что живёт долго и должно быть единым.
Ошибка №4: задать неверную границу scanning и потом лечить симптомы, а не причину.
Если basePackages слишком узкий, часть компонентов не найдётся. Если слишком широкий — может найтись лишнее, и тогда начнутся неожиданные конфликты. Новичок часто делает наоборот: видит ошибку создания какого-то сервиса и начинает править сервис, хотя проблема в том, что нужный компонент вообще не зарегистрирован. Правильная диагностика начинается с вопроса: «Класс точно попал в область поиска?»
Ошибка №5: полностью полагаться на имя bean-а по умолчанию, когда имя важно.
В учебных примерах вы иногда делаете lookup по имени, и тогда ошибка в имени превращается в потерю времени. Например, вы ожидаете "orderPrinter", а Spring сгенерировал другое имя или вы просто ошиблись в регистре. Если вы заранее знаете, что имя будет использоваться как часть контракта, например в демонстрации, задайте его явно через @Component("orderPrinter"). Это делает поведение предсказуемым и экономит нервы.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ