Spring и Spring Boot: DI, конфигурация, web-слой, что происходит под капотом
Программа на Java из предыдущих статей трека — это граф объектов, который кто-то должен
собрать. Пока объектов пять, сборка умещается в main: создать репозиторий, передать его в
сервис, сервис — в контроллер. Когда объектов триста, а половина из них зависит от настроек,
которые известны только в момент запуска в конкретной среде, ручная сборка превращается в
самый хрупкий файл проекта.
Spring — это ответ на вопрос «кто собирает граф». Не «фреймворк для веба» и не «библиотека аннотаций»: в основе лежит контейнер, который читает описания объектов, вычисляет порядок их создания, подставляет зависимости и управляет временем жизни. Всё остальное — web, данные, безопасность, планировщик — надстройки над этим контейнером.
Проблема Spring в глазах новичка — ощущение магии: вы поставили аннотацию, и «оно заработало».
Ощущение исчезает ровно в тот момент, когда становится понятна модель исполнения. Магии там
нет: есть чтение метаданных, рефлексия, генерация подклассов в рантайме и очень аккуратно
выстроенный порядок фаз. Эта статья — про механику. После неё аннотация перестаёт быть
заклинанием и становится записью в BeanDefinition.
Ниша у Spring та же, что у ASP.NET Core в треке C#/.NET: серверные приложения бизнес-логики. Различия в подходе принципиальны — про них будет отдельный честный разговор в конце, но курс здесь самостоятельный, а не сравнительный.
Карта территории: что вообще называют «Spring»
Слово перегружено. Под ним прячутся несколько независимо версионируемых проектов.
Отношения такие: Spring Framework — фундамент, ему больше двадцати лет; Spring Boot — не отдельный фреймворк, а слой соглашений поверх Framework: он решает, какие бины создать, если вы ничего не сказали. Всё остальное — модули, которые Boot умеет включать автоматически.
Практический вывод: когда что-то не работает, сначала определите слой. «Не поднимается контекст» — это Framework. «Не подхватилось свойство» — это Boot. «403 вместо 200» — это Security, и до контроллера запрос вообще не дошёл.
Контейнер: BeanDefinition как единица работы
Центральная абстракция — не бин, а описание бина. Контейнер сначала собирает карту описаний и только потом начинает что-то создавать. Это разделение объясняет почти всё поведение Spring, включая то, почему циклические зависимости иногда работают, а иногда нет.
ApplicationContext — это BeanFactory плюс события, локализация, ресурсы и интеграция со
средой. В приложении вы почти всегда работаете именно с ним, а BeanFactory встречается в
стектрейсах.
Описания попадают в контейнер тремя путями:
- Сканирование компонентов —
@ComponentScanнаходит классы с@Componentи его специализациями (@Service,@Repository,@Controller,@Configuration). - Java-конфигурация — методы с
@Beanвнутри@Configuration-класса. - Программная регистрация —
BeanDefinitionRegistryPostProcessor,@Import,ImportBeanDefinitionRegistrar. Так работают Spring Data и половина автоконфигураций.
XML-конфигурация тоже жива и поддерживается, но в новых проектах не встречается.
Внедрение зависимостей: только конструктор
@Service
public class OrderService {
private final OrderRepository repository;
private final PricingClient pricing;
private final OrderProperties props;
// Единственный конструктор — @Autowired не нужен начиная со Spring 4.3.
// Все зависимости final: объект нельзя создать в недособранном состоянии.
public OrderService(OrderRepository repository, PricingClient pricing, OrderProperties props) {
this.repository = repository;
this.pricing = pricing;
this.props = props;
}
}
Три способа внедрения существуют, но выбор давно сделан:
| Способ | Поля final |
Тестируется без Spring | Циклы | Вердикт |
|---|---|---|---|---|
| Конструктор | да | да, new OrderService(...) |
падает на старте | использовать |
| Сеттер | нет | да, но многословно | скрывает | только для опциональных |
Поле (@Autowired на поле) |
нет | нет, нужна рефлексия | скрывает | не использовать |
Внедрение в поле — самая распространённая вредная привычка в Java-проектах. Оно позволяет классу иметь пятнадцать зависимостей и не выглядеть при этом уродливо, то есть прячет симптом переусложнения. Конструктор с пятнадцатью параметрами вызывает физическое отвращение — и это правильная реакция, класс пора делить.
Когда зависимостей действительно много и они однотипные, Spring умеет внедрять коллекции:
@Service
class ValidationChain {
private final List<OrderValidator> validators; // ВСЕ бины типа OrderValidator
private final Map<String, TaxPolicy> policies; // ключ = имя бина
ValidationChain(List<OrderValidator> validators, Map<String, TaxPolicy> policies) {
this.validators = validators; // порядок задаётся @Order или Ordered
this.policies = policies;
}
}
Это идиоматичный способ реализовать стратегию или цепочку обязанностей: добавили класс с
@Component — он сам оказался в списке. Для необязательной зависимости используйте не
@Autowired(required = false), а ObjectProvider:
@Service
class Notifier {
private final ObjectProvider<SmsGateway> smsProvider;
Notifier(ObjectProvider<SmsGateway> smsProvider) { this.smsProvider = smsProvider; }
void notifyUser(String phone, String text) {
// ленивое разрешение: null, если бина нет; в циклах на старте не участвует
SmsGateway gw = smsProvider.getIfAvailable();
if (gw != null) gw.send(phone, text);
}
}
Разрешение неоднозначности
Если подходящих бинов несколько, контекст не поднимется. Инструменты разрешения — по возрастанию явности:
@Bean @Primary // «по умолчанию берите этот»
DataSource mainDataSource() { ... }
@Bean @Qualifier("reporting") // явная метка
DataSource reportingDataSource() { ... }
// В точке внедрения:
OrderService(@Qualifier("reporting") DataSource ds) { ... }
Лучше @Qualifier-строк — собственная аннотация-квалификатор: опечатка станет ошибкой
компиляции, а не рантайма.
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE})
public @interface Reporting {}
Ещё один трюк, недооценённый в Java: дженерик-тип участвует в разрешении. Бины
Repository<Order> и Repository<Customer> различимы для контейнера благодаря
ResolvableType, несмотря на стирание типов (Spring читает generic-сигнатуру из метаданных
класса — про то, что стирание не удаляет информацию из сигнатур, было в статье про
коллекции и дженерики).
Жизненный цикл бина: где вклиниваются расширения
Это диаграмма, которую стоит держать в голове. Почти каждый вопрос вида «почему поле ещё
null» или «почему аннотация не сработала» решается указанием пальцем на фазу.
Два следствия, которые экономят часы отладки.
Первое. @PostConstruct — единственное место, где объект гарантированно собран полностью.
В конструкторе поля, внедряемые не через конструктор, ещё пусты; в @PostConstruct — уже нет.
Но и злоупотреблять им нельзя: сетевые вызовы в @PostConstruct растягивают старт и делают
приложение «живым», но неработоспособным. Прогрев выносите в
ApplicationRunner/ApplicationReadyEvent.
Второе. Прокси навешивается на последнем шаге. Значит, внутри @PostConstruct
собственные @Transactional-методы ещё не обёрнуты — транзакции там не будет. Это не баг, а
прямое следствие порядка фаз.
Области видимости
singleton (по умолчанию, один экземпляр на контекст) и prototype (новый на каждый
getBean) — базовые. В web-приложении добавляются request, session, application.
Классическая ловушка: prototype, внедрённый в singleton, создаётся один раз. Синглтон
собирается один раз, значит и его зависимость разрешается один раз. Если нужен новый
экземпляр на каждое обращение — берите ObjectProvider<T> и вызывайте getObject(), либо
объявляйте бин со scopedProxyMode.
@Bean
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
ReportBuilder reportBuilder() { return new ReportBuilder(); }
Циклические зависимости
С Spring Boot 2.6 они запрещены по умолчанию: контекст падает с внятным сообщением.
Свойство spring.main.allow-circular-references=true существует, но использовать его —
значит согласиться жить с проблемой. Цикл A → B → A почти всегда означает, что есть третья,
не выделенная ответственность. @Lazy на одной из сторон разрывает цикл технически, но
оставляет архитектурный узел на месте.
Прокси: главный источник «магии» и главный источник ошибок
@Transactional, @Async, @Cacheable, @Retryable, method security — всё это работает
одинаково: контейнер подменяет ваш бин прокси-объектом, который перехватывает вызовы,
делает что-то до и после, а между ними зовёт настоящий метод.
Два механизма:
- JDK dynamic proxy — если бин реализует интерфейс. Прокси реализует тот же интерфейс,
но не наследует ваш класс. Отсюда
ClassCastExceptionпри попытке привести прокси к конкретному классу. - CGLIB — генерация подкласса в рантайме. Работает без интерфейсов, но не может
перехватить
final-классы,final- иprivate-методы. В Spring Boot это режим по умолчанию (spring.aop.proxy-target-class=true).
System.out.println(orderService.getClass());
// class com.shop.OrderService$$SpringCGLIB$$0
// (до Spring 6 имя выглядело как $$EnhancerBySpringCGLIB$$)
прокси не участвует — @Transactional
на audit() не действует T-->>TI: результат TI->>DB: commit() TI-->>P: результат P-->>C: результат
Self-invocation: ошибка номер один
@Service
public class ReportService {
@Transactional
public void generateAll(List<UUID> ids) {
for (UUID id : ids) {
generateOne(id); // ← вызов через this: прокси в стороне
}
}
// Аннотация здесь НЕ РАБОТАЕТ при вызове из generateAll.
// Ожидали независимую транзакцию на элемент — получили одну общую.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void generateOne(UUID id) { ... }
}
Симптом коварен: код работает, тесты «зелёные», а на проде одна ошибка в середине списка откатывает весь батч. Решения по убыванию честности:
// 1. Разделить классы — правильный ответ в 90% случаев:
// внешний вызов проходит через прокси ДРУГОГО бина.
@Service
class ReportBatch {
private final ReportItemService items; // отдельный бин со своим прокси
ReportBatch(ReportItemService items) { this.items = items; }
void generateAll(List<UUID> ids) { ids.forEach(items::generateOne); }
}
// 2. Программная транзакция — когда границы сложнее одной аннотации.
@Service
class ReportBatch2 {
private final TransactionTemplate tx;
ReportBatch2(PlatformTransactionManager tm) {
this.tx = new TransactionTemplate(tm);
this.tx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
void generateAll(List<UUID> ids) { ids.forEach(id -> tx.execute(st -> { ...; return null; })); }
}
Третий вариант — self-injection (ReportBatch(@Lazy ReportBatch self)) — работает, но это
признание поражения: класс просит контейнер выдать ему обёртку самого себя, чтобы обойти
собственную структуру. Встретили такое на ревью — предложите вариант 1.
Соседняя грабля: @Transactional на private- или final-методе молчит без единого
предупреждения. Spring Boot 3 умеет ругаться на часть таких случаев, но не на все.
Правило простое: аннотации AOP ставим только на public-методы не-final классов.
@Configuration full mode: тоже прокси
@Configuration // proxyBeanMethods = true по умолчанию
class AppConfig {
@Bean Clock clock() { return Clock.systemUTC(); }
@Bean Auditor auditor() {
return new Auditor(clock()); // вызов clock() перехвачен CGLIB →
} // вернётся ТОТ ЖЕ синглтон, а не новый объект
@Bean Scheduler scheduler() { return new Scheduler(clock()); } // тот же экземпляр
}
Если поставить @Configuration(proxyBeanMethods = false) (так делают все автоконфигурации
ради скорости старта), перехвата не будет и clock() создаст новый объект на каждый
вызов. Для Clock безразлично, для пула соединений — катастрофа. В lite-режиме зависимости
между бинами передавайте параметрами метода:
@Configuration(proxyBeanMethods = false)
class AppConfig {
@Bean Clock clock() { return Clock.systemUTC(); }
@Bean Auditor auditor(Clock clock) { return new Auditor(clock); } // контейнер подставит синглтон
}
Конфигурация: Environment и типобезопасные свойства
Environment — это упорядоченный список источников свойств. Поиск идёт сверху вниз и
останавливается на первом попадании. Никакого слияния значений не происходит.
Relaxed binding — свойство Boot, которое стоит понимать буквально: ключ
shop.orders.max-retries совпадает с shop.orders.maxRetries, SHOP_ORDERS_MAXRETRIES и
shop.orders.max_retries. Это позволяет писать переменные окружения в стиле Unix, не ломая
имена в YAML.
@Value против @ConfigurationProperties
@Value("${...}") подходит для одного-двух значений. Для набора связанных настроек он
плох: значения размазаны по классам, нет валидации, нет автодополнения в IDE, нет
документации. Идиоматичный способ — типобезопасный объект, и в современной Java это
record:
@ConfigurationProperties(prefix = "shop.orders")
@Validated
public record OrderProperties(
@DefaultValue("2s") Duration timeout, // Boot сам разберёт 2s, 500ms, PT1M
@Min(1) @Max(10) @DefaultValue("3") int maxRetries,
@NotBlank String currency,
@DefaultValue Discounts discounts) { // вложенный объект с дефолтами
public record Discounts(
@DefaultValue("false") boolean enabled,
@Min(0) @Max(90) @DefaultValue("0") int percent) {}
}
# application.yml
shop:
orders:
timeout: 2s
max-retries: 3
currency: EUR
discounts:
enabled: true
percent: 10
Включается через @ConfigurationPropertiesScan на главном классе или
@EnableConfigurationProperties(OrderProperties.class).
Что здесь важно и часто упускается:
- Record даёт неизменяемость бесплатно. Настройки нельзя случайно поменять в рантайме — ровно то, что нужно.
@Validatedпревращает опечатку в ошибку старта. Приложение сcurrency: ""не поднимется вообще, вместо того чтобы упасть через два часа на первом заказе. Это сознательный fail-fast: см. разбор в статье про идиоматику и ошибки.- Добавьте
spring-boot-configuration-processorв зависимости — он сгенерируетMETA-INF/spring-configuration-metadata.json, и IDE начнёт подсказывать ваши свойства наравне со встроенными.
Профили — не система конфигурации
@Profile("prod") и application-prod.yml соблазняют развести всё окружение по профилям.
Практика показывает, где предел: профиль хорош для выбора реализации
(@Profile("test") InMemoryGateway), но плох для значений (адреса, пароли, таймауты) —
их место в переменных окружения и внешнем конфиге. Иначе прод-путь кода начинает отличаться
от тестируемого, и в первый же инцидент выясняется, что профиль prod никто не проверял.
Секреты в application.yml не хранят никогда. Минимум — переменные окружения, нормально —
Vault/Secrets Manager через spring-cloud-vault или монтирование файлов и
spring.config.import=file:/run/secrets/db.properties.
Spring Boot: что делает SpringApplication.run
@SpringBootApplication // = @Configuration + @ComponentScan + @EnableAutoConfiguration
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
Три строки, за которыми стоит вполне конкретная последовательность.
по classpath: SERVLET / REACTIVE / NONE"] B --> C["Создать Environment:
аргументы, env, application.yml, профили"] C --> D["Создать ApplicationContext нужного типа"] D --> E["ApplicationContextInitializer'ы
из spring.factories"] E --> F["Зарегистрировать главный класс
как источник конфигурации"] F --> G["refresh()"] G --> G1["ConfigurationClassPostProcessor:
@ComponentScan + @Import"] G1 --> G2["AutoConfigurationImportSelector
читает AutoConfiguration.imports"] G2 --> G3["Фильтры @Conditional*
отсеивают ненужное"] G3 --> G4["Регистрация BeanPostProcessor'ов"] G4 --> G5["preInstantiateSingletons():
создание всех не-ленивых бинов"] G5 --> G6["onRefresh(): старт встроенного Tomcat,
порт занят здесь"] G6 --> G7["finishRefresh(): ContextRefreshedEvent"] G7 --> H["CommandLineRunner / ApplicationRunner"] H --> I["ApplicationReadyEvent
приложение готово"] G3 -.->|"не совпало условие"| X["Бин не создан.
--debug покажет причину"]
Порядок здесь не декоративный. Автоконфигурации обрабатываются после ваших классов —
именно поэтому @ConditionalOnMissingBean работает: к моменту проверки ваш бин уже
зарегистрирован, и Boot отступает.
Автоконфигурация без магии
Механизм целиком: в jar-файле стартера лежит текстовый файл
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports со списком
классов, по одному на строку. AutoConfigurationImportSelector читает его со всего classpath,
применяет условия и регистрирует то, что выжило. Всё. До Boot 2.7 это был spring.factories
— его вы ещё встретите в старых библиотеках.
Условия — обычные аннотации:
| Аннотация | Когда срабатывает |
|---|---|
@ConditionalOnClass |
класс есть в classpath |
@ConditionalOnMissingBean |
такого бина ещё нет — «уступи пользователю» |
@ConditionalOnProperty |
свойство имеет заданное значение |
@ConditionalOnWebApplication |
тип приложения совпал |
@ConditionalOnBean |
другой бин уже определён |
Своя автоконфигурация пишется за пять минут — это стандартный способ оформить внутреннюю библиотеку компании:
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(AuditWriter.class)
@ConditionalOnProperty(prefix = "shop.audit", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(AuditProperties.class)
public class AuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean // пользователь может объявить свой — победит он
AuditWriter auditWriter(AuditProperties props, DataSource ds) {
return new JdbcAuditWriter(ds, props.tableName());
}
}
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
---
com.shop.audit.AuditAutoConfiguration
Когда «оно не подхватилось», не гадайте — спросите приложение:
# отчёт по всем условиям: что совпало, что нет и почему
java -jar app.jar --debug
# то же самое в рантайме, если включён Actuator
curl localhost:8080/actuator/conditions | jq '.contexts.application.negativeMatches'
curl localhost:8080/actuator/beans | jq '.contexts.application.beans | keys'
curl localhost:8080/actuator/configprops
curl localhost:8080/actuator/env/shop.orders.timeout # видно источник и перекрытые значения
Отключить лишнее можно точечно:
@SpringBootApplication(exclude = {SecurityAutoConfiguration.class})
Web-слой: путь запроса через DispatcherServlet
DispatcherServlet — реализация паттерна Front Controller: один сервлет принимает всё и
раздаёт по обработчикам. Его работа — цепочка из четырёх решений: кто обработает, как
превратить HTTP в аргументы, как превратить результат в HTTP, что делать с исключением.
Контроллер, каким он должен быть
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService service;
OrderController(OrderService service) { this.service = service; }
// DTO — record: неизменяемый, с валидацией на компонентах
record CreateOrder(@NotBlank String sku, @Min(1) int quantity, @NotNull UUID customerId) {}
record OrderView(UUID id, String sku, int quantity, BigDecimal total, Instant createdAt) {
static OrderView of(Order o) {
return new OrderView(o.id(), o.sku(), o.quantity(), o.total(), o.createdAt());
}
}
@PostMapping
ResponseEntity<OrderView> create(@Valid @RequestBody CreateOrder cmd,
UriComponentsBuilder uri) {
Order order = service.place(cmd.sku(), cmd.quantity(), cmd.customerId());
return ResponseEntity
.created(uri.path("/api/orders/{id}").build(order.id()))
.body(OrderView.of(order));
}
@GetMapping("/{id}")
OrderView byId(@PathVariable UUID id) {
// Optional из сервиса разворачиваем в исключение прямо здесь
return service.find(id).map(OrderView::of)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
Что здесь принципиально:
- Сущность домена не уезжает в JSON.
OrderView— отдельный тип. Иначе рефакторинг поля ломает контракт API, а ленивые связи Hibernate превращаются в загадочные исключения сериализации (об этом — в статье про работу с данными). - Контроллер не содержит логики. Он переводит HTTP в вызов и обратно. Всё остальное — в сервисе, который можно тестировать без веб-слоя.
@Validна теле обязателен, иначе аннотации в record — просто украшение.
Ошибки: ProblemDetail вместо самописных форматов
Spring Framework 6 принёс встроенную поддержку RFC 9457 (Problem Details for HTTP APIs). Изобретать свой формат ошибок больше не нужно.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handleNotFound(OrderNotFoundException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND,
"Заказ " + e.id() + " не найден");
pd.setType(URI.create("https://errors.shop.example/order-not-found"));
pd.setTitle("Заказ не найден");
pd.setProperty("orderId", e.id());
return pd;
}
}
Ответ:
{
"type": "https://errors.shop.example/order-not-found",
"title": "Заказ не найден",
"status": 404,
"detail": "Заказ 8f14e45f-... не найден",
"orderId": "8f14e45f-..."
}
Свойство spring.mvc.problemdetails.enabled=true включает тот же формат и для встроенных
ошибок Spring (400 при кривом JSON, 405, 415), так что клиент видит один формат всегда.
Отдельно: не ловите Exception в @ControllerAdvice и не возвращайте 500 с текстом
исключения. Текст исключения — внутренняя информация, а в проде вам нужен traceId,
по которому запись найдётся в логах. Подробнее — в статье про
деплой и наблюдаемость.
HTTP-клиенты: RestClient и декларативный @HttpExchange
RestTemplate в режиме поддержки — новые проекты используют RestClient (синхронный,
fluent) или WebClient (реактивный).
@Bean
RestClient pricingRestClient(RestClient.Builder builder,
@Value("${shop.pricing.url}") String url) {
// Таймауты обязательны: клиент без них однажды повесит весь пул потоков
var settings = ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(2))
.withReadTimeout(Duration.ofSeconds(5));
return builder.baseUrl(url)
.requestFactory(ClientHttpRequestFactories.get(settings))
.build();
}
// Декларативный клиент: описываем интерфейс, реализацию генерирует Spring
public interface PricingClient {
@GetExchange("/prices/{sku}")
PriceResponse price(@PathVariable String sku);
}
@Bean
PricingClient pricingClient(RestClient restClient) {
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
.build().createClient(PricingClient.class);
}
Модель конкурентности: Servlet, WebFlux и виртуальные потоки
До Java 21 выбор был неприятным: либо блокирующий Servlet-стек с пулом на пару сотен потоков, либо реактивный WebFlux — быстрый под нагрузкой, но с чужой моделью программирования, нечитаемыми стектрейсами и требованием, чтобы весь стек, включая драйвер БД, был неблокирующим.
Виртуальные потоки этот выбор в значительной степени сняли. В Spring Boot 3.2+ достаточно одной строки:
spring:
threads:
virtual:
enabled: true # требует Java 21+
Tomcat начинает выделять виртуальный поток на запрос, @Async и планировщик — тоже. Код
остаётся обычным, блокирующим и отлаживаемым, а масштабирование по числу одновременных
запросов растёт на порядки. Механику подробно разбирали в статье про
конкурентность, здесь — только следствия для Spring:
- Пул соединений к БД остаётся узким местом. Десять тысяч виртуальных потоков будут честно ждать двадцати соединений HikariCP. Виртуальные потоки убирают лимит на потоки, а не на базу.
synchronizedбольше не приковывает несущий поток начиная с JDK 24 (JEP 491) — до этого блокировка внутриsynchronizedприводила к pinning. Если вы на Java 21, старые библиотеки сsynchronizedвокруг I/O всё ещё стоит проверять.ThreadLocalпод виртуальными потоками ведёт себя иначе по цене. Кэши наThreadLocal, рассчитанные на двести потоков, при десяти тысячах перестают быть кэшами. ИспользуйтеScopedValue, где это возможно.- WebFlux не умер, но его ниша сузилась: потоковая передача, SSE/WebSocket, gateway-слой и высококонкурентные прокси с минимальной логикой. Тащить WebFlux в CRUD-сервис ради «производительности» в 2026 году — плохо обоснованное решение.
Тестирование Spring-приложения
Детальный разбор — в статье про тестирование; здесь специфика контейнера.
// Полный контекст: медленно, но проверяет всё, включая автоконфигурацию
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class OrderApiTest {
@Autowired MockMvc mvc;
@MockitoBean PricingClient pricing; // подменяет бин в контексте (Boot 3.4+; ранее @MockBean)
@Test
void создаёт_заказ() throws Exception {
when(pricing.price("SKU-1")).thenReturn(new PriceResponse(new BigDecimal("10.00")));
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"sku":"SKU-1","quantity":2,"customerId":"8f14e45f-0000-0000-0000-000000000000"}
"""))
.andExpect(status().isCreated())
.andExpect(header().exists("Location"))
.andExpect(jsonPath("$.total").value(20.00));
}
}
// Срез: поднимается только web-слой, сервисы мокаются. Секунды вместо десятков секунд
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service;
}
Ключевая деталь, определяющая скорость сборки: Spring кэширует контексты между тестовыми
классами, ключ кэша — набор конфигурации (классы, профили, свойства, mock-бины). Один
@TestPropertySource с уникальным значением в каждом тесте — и вы получаете двадцать
поднятий контекста вместо одного. Считайте уникальные конфигурации; на большом проекте
разница измеряется в десятках минут CI.
Что Spring делает с производительностью и как это лечат
Честная картина: контекст среднего сервиса поднимается 1.5–4 секунды, потребляет 250–600 МБ и даёт стектрейсы на 60–90 кадров. Причины — рефлексия, сканирование classpath, генерация прокси. Что с этим делают:
# 1. Понять, на что уходит старт: Actuator + BufferingApplicationStartup
curl localhost:8080/actuator/startup | jq '.timeline.events | sort_by(-.duration)[:10]'
# 2. Ленивая инициализация — быстрый старт, но ошибки конфигурации всплывут в рантайме
# Годится для локальной разработки, спорно для прода
SPRING_MAIN_LAZY_INITIALIZATION=true java -jar app.jar
# 3. Class Data Sharing: JVM переиспользует прогретую метаданными архивную область
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
java -XX:SharedArchiveFile=app.jsa -jar app.jar # старт быстрее на 20-40%
# 4. AOT-обработка: Spring вычисляет конфигурацию на этапе сборки
./mvnw -Pnative native:compile # нативный образ GraalVM: старт ~50 мс, память ~100 МБ
Нативный образ — не бесплатное решение: сборка занимает минуты, рефлексия требует конфигурации, динамическая подмена классов и агенты не работают, а профилирование становится другим ремеслом. Осмысленно для serverless и CLI, спорно для долгоживущего сервиса, которому важнее пиковая пропускная способность (JIT её даёт больше — см. JVM изнутри).
Альтернатива для быстрого старта без потери JIT — CRaC (Coordinated Restore at Checkpoint):
снимок прогретого процесса, восстановление за десятки миллисекунд. Spring Boot 3.2+ умеет
корректно останавливать и возобновлять бины по протоколу Lifecycle.
Грабли: список, который стоит перечитать перед код-ревью
- Внедрение в поле.
@Autowiredна приватном поле прячет рост числа зависимостей и ломает создание объекта в тестах без контейнера. - Self-invocation. Внутренний вызов
@Transactional/@Async/@Cacheable-метода не проходит через прокси. Самая частая причина «транзакция не откатилась». - AOP на
private/final. Молча не работает. Проверяйте модификаторы. proxyBeanMethods = falseплюс вызовы между@Bean-методами. Получите несколько экземпляров там, где ожидали синглтон.- Главный класс не в корневом пакете.
@ComponentScanидёт от пакета класса с@SpringBootApplication; положили его вcom.shop.web— половина бинов не найдена. Обратная крайность — класс в пакетеcom: сканируется весь мир, старт длится минуту. - Prototype внутри синглтона. Создаётся один раз. Нужен
ObjectProviderили scoped-прокси. spring.jpa.open-in-viewвключён по умолчанию. Соединение с БД держится до конца рендеринга ответа, ленивые связи подгружаются из контроллера, N+1 расцветает. Выключайте осознанно — разбор в следующей статье.- HTTP-клиент без таймаутов. Один медленный внешний сервис выедает весь пул потоков.
- Блокирующий I/O в
@PostConstruct. Старт растягивается, readiness-проба врёт. - Отсутствие graceful shutdown.
server.shutdown=gracefulплюсspring.lifecycle.timeout-per-shutdown-phase— иначе при деплое рвутся живые запросы. @Asyncбез@EnableAsyncили с возвратомvoid— исключения исчезают бесследно. ВозвращайтеCompletableFuture.- Взрыв тестовых контекстов. Уникальные
@TestPropertySource/@MockitoBeanв каждом классе убивают кэш и удлиняют CI в разы. @Valueс дефолтом-заглушкой (@Value("${x:}")) вместо валидации: приложение поднимется с пустой строкой и упадёт позже и в другом месте.- Цикл, «починенный» через
@Lazy. Технически работает, архитектурно — незакрытый долг.
Честно о месте Spring
Где Spring выигрывает. Долгоживущие серверные приложения со сложной предметной областью, где важнее скорость разработки и предсказуемость эксплуатации, чем миллисекунды старта. Интеграционное тестирование с настоящей инфраструктурой в Java-мире лучшее, что есть. Обратная совместимость такова, что приложение на Boot 2.x поднимается на Boot 3.x за дни, а не месяцы. Кадровый рынок огромен: код, написанный идиоматично, читается любым Java-инженером.
Где Spring проигрывает. Старт и память — цена рефлексии и сканирования. Неявность:
поведение задаётся сочетанием аннотаций, свойств и присутствия классов в classpath, и «почему
так» иногда выясняется только через --debug. Огромная транзитивная поверхность зависимостей
означает регулярный поток CVE и обязательный процесс обновлений. Наконец, стектрейс из
семидесяти кадров — плохой инструмент обучения для новичка.
Куда его не стоит тащить. В библиотеки: библиотека не должна требовать контейнер, она
должна предоставлять обычные классы, а Spring-обвязку выносить в отдельный
*-spring-boot-starter. В короткоживущие лямбды без нативной компиляции. В горячий путь, где
каждый вызов проходит через три прокси-слоя: там уместны обычные объекты, созданные вручную.
Соседи по нише. Quarkus и Micronaut делают DI на этапе компиляции: генерируют код вместо рефлексии, отсюда быстрый старт и малая память ценой более длинной сборки и меньшей экосистемы. Spring отвечает AOT-обработкой и CRaC. Выбор между ними — обычно выбор между широтой экосистемы и стоимостью холодного старта.
Сравнение с ASP.NET Core (трек C#/.NET) полезно именно различиями:
| Spring | ASP.NET Core | |
|---|---|---|
| Регистрация зависимостей | сканирование аннотаций | явная в Program.cs |
| Область видимости | singleton / prototype / request | singleton / scoped / transient |
| Перехват вызовов | прокси в рантайме (AOP) | middleware и явные декораторы |
| Транзакции | @Transactional через прокси |
явный TransactionScope/UoW |
| Конфигурация | Environment + @ConfigurationProperties |
IConfiguration + Options |
| Старт | 1.5–4 с | 0.05–0.3 с |
Ключевое различие философское: Spring предпочитает соглашение и обнаружение, .NET —
явность. Отсюда у Spring нет ловушки self-invocation-эквивалента, но нет и @Transactional
как декларации. Ни один из подходов не лучше — но зная механику обоих, вы перестаёте
воспринимать чужие решения как странные.
Spring Boot 4 и Framework 7: что меняется
Ветка 4.0 (вместе со Spring Framework 7) продолжает то же направление: базовая версия Java
17+, разбиение монолитных jar-ов на более мелкие модули, null-safety на основе JSpecify
вместо собственных аннотаций, версионирование API в MVC, декларативные HTTP-клиенты как
первоклассный механизм и встроенные аннотации устойчивости (@Retryable, @ConcurrencyLimit)
без внешних библиотек. Всё, что описано выше — контейнер, прокси, автоконфигурация,
DispatcherServlet — остаётся в силе; меняются координаты артефактов и часть умолчаний.
Перед миграцией читайте официальные заметки о релизе, а не советы из блогов.
Мини-итог
- Spring — это контейнер, который строит граф объектов по описаниям (
BeanDefinition), а не набор аннотаций. - Жизненный цикл бина строго упорядочен; прокси навешивается на последней фазе, и из этого следуют почти все «неработающие аннотации».
- Внедряйте только через конструктор, поля делайте
final— так класс честно показывает свою сложность. @Transactionalи родня работают лишь при вызове извне через прокси. Внутренний вызовthis.method()их не активирует.- Конфигурация — упорядоченный стек источников без слияния. Типобезопасные
@ConfigurationPropertiesна record с@Validatedпревращают ошибку настройки в отказ старта. - Автоконфигурация — файл
AutoConfiguration.importsплюс аннотации@Conditional*. Отладка —--debugи/actuator/conditions. - Веб-запрос проходит восемь вложенных рубежей; знание, на каком из них вы находитесь, экономит часы.
- Виртуальные потоки вернули блокирующему стеку конкурентоспособность и сильно сузили нишу WebFlux.
Источники
- Spring Framework Reference: Core Technologies — контейнер, области видимости, AOP. Первоисточник по всему, что в статье названо механикой.
- Spring Framework Reference: Web on Servlet Stack —
DispatcherServlet, резолверы аргументов, конвертеры сообщений. - Spring Boot Reference: Externalized Configuration — точный и полный порядок источников свойств.
- Spring Boot Reference: Auto-configuration и Creating Your Own Auto-configuration.
- Spring Boot Reference: Testing —
срезы, кэш контекста,
@MockitoBean. - Spring Boot: Virtual Threads — включение и следствия.
- Spring Boot Actuator: Production-ready Features —
conditions,beans,configprops,env,startup. - RFC 9457: Problem Details for HTTP APIs —
стандарт, который реализует
ProblemDetail. - Craig Walls, Spring in Action, 6th Edition — лучший вход в тему для практика; Iuliana Cosmina и др., Pro Spring 6 — подробный справочник по контейнеру и модулям.
- Martin Fowler, Inversion of Control Containers and the Dependency Injection Pattern — статья 2004 года, объясняющая, зачем всё это было придумано.
- Spring Blog — заметки о релизах и миграциях от самой команды.
Что дальше
Контейнер собран, запрос доходит до сервиса, конфигурация читается из правильного источника.
Осталось самое интересное и самое опасное место любого бизнес-приложения — данные. Дальше
разберём, как Java разговаривает с базой: от голого JDBC до JPA/Hibernate, что на самом деле
делает @Transactional за границей прокси, откуда берётся проблема N+1 и почему ленивая
загрузка ломается ровно там, где вы её не ждёте.
Работа с данными: JDBC, JPA/Hibernate, транзакции, N+1 и другие ловушки