Конфигурирование, практики и тестирование¶
Как настраивают Spring MVC и что делает за тебя Boot, какие бывают стили приложения, практики и типовые ошибки, как тестировать контроллеры и настраивать CORS. Полный путь запроса и компоненты MVC - в архитектуре.
Конфигурирование Spring MVC¶
WebMvcConfigurer¶
WebMvcConfigurer - интерфейс с default-методами для настройки MVC. Реализуешь только то, что нужно, остальное остаётся по умолчанию.
@Configuration
public class WebConfig implements WebMvcConfigurer {
// переопределяем только нужное
}
| Метод | Что настраивает | Подробно |
|---|---|---|
configureViewResolvers |
View resolvers | Ниже |
configureMessageConverters, extendMessageConverters |
HttpMessageConverter |
Ниже, converters |
addFormatters |
Converters и formatters для binding'а | Data Binding |
addInterceptors |
Interceptors | Interceptors |
addResourceHandlers |
Раздача статики | Статика |
addCorsMappings |
Глобальный CORS | CORS |
configureDefaultServletHandling |
Передача статики в default servlet контейнера | Ниже |
configureApiVersioning |
Версии API (Spring 7) | Версионирование |
View resolvers:
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.jsp("/WEB-INF/jsp/", ".jsp");
// или
registry.viewResolver(new InternalResourceViewResolver("/WEB-INF/views/", ".html"));
}
Message converters:
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(new MappingJackson2HttpMessageConverter()); // полностью заменяет список по умолчанию
}
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
converters.add(0, new CustomConverter()); // дополняет список - так обычно и делают
}
Default servlet - статику отдаёт servlet-контейнер, а не Spring:
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable();
}
@EnableWebMvc¶
@EnableWebMvc - импортирует полную MVC-конфигурацию Spring Framework в Java-конфиг. Нужна в Spring без Boot.
Warning
В Spring Boot @EnableWebMvc означает «я сам полностью управляю MVC». Автоконфигурация Boot отключает свои
настройки MVC: converters, статику, content negotiation. Частая ошибка - поставить её, когда хотели только добавить
interceptor или converter. В Boot для этого хватает WebMvcConfigurer без @EnableWebMvc.
| Аннотация | Где | Что делает |
|---|---|---|
@Configuration |
Класс | Java-конфиг с бинами и кастомайзерами |
@EnableWebMvc |
Конфиг-класс | Импортирует MVC-конфигурацию Spring напрямую, в Boot обычно не нужна |
@Bean |
Метод | Регистрирует объект как бин, например LocaleResolver или MessageSource |
Spring MVC в Spring Boot¶
Если spring-webmvc в classpath, автоконфигурация Boot (см. Автоконфигурацию)
подключает WebMvcAutoConfiguration. Она:
- регистрирует
DispatcherServletна/ - настраивает Jackson или Gson и message converters
- настраивает content negotiation, статику, view resolvers по умолчанию
Стартер:
<!-- Spring Boot 4: servlet MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Spring Boot 3.x: старое имя -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Bean Validation подключается отдельно -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
Стартер транзитивно тянет spring-webmvc, spring-web, встроенный Tomcat (spring-boot-starter-tomcat), Jackson
(jackson-databind), slf4j.
Что Boot настраивает сам:
| Компонент | По умолчанию |
|---|---|
DispatcherServlet |
Маппинг на / |
Jackson ObjectMapper |
Разумные настройки |
ContentNegotiationStrategy |
По заголовку Accept |
| Статика | /static/, /public/, /resources/ |
MessageSource |
messages.properties |
| Ошибки | /error -> BasicErrorController |
| Multipart | Включён, лимиты в spring.servlet.multipart.* |
Когда нужна своя конфигурация: свои interceptors, свой ObjectMapper, свои CORS-правила, нестандартная раздача
статики или view resolver. Пишешь WebMvcConfigurer без @EnableWebMvc - автоконфигурация Boot остаётся, ты её
только дополняешь.
Запуск - через @SpringBootApplication (см. Стартеры и запуск): Boot поднимает
контекст, встроенный Tomcat и регистрирует DispatcherServlet. Порт по умолчанию 8080.
server.port=8080
server.servlet.context-path=/app
# путь DispatcherServlet
spring.mvc.servlet.path=/mvc
Стили приложения¶
Server-side rendering (HTML) - сервер рендерит HTML шаблонизатором.
@Controller, не@RestController- Thymeleaf или FreeMarker, данные через
Model - redirect после POST (PRG) и flash attributes для сообщений (см. Сессии)
@Controller
@RequestMapping("/users")
public class UserPageController {
@GetMapping
public String list(Model model, @RequestParam(defaultValue = "0") int page) {
model.addAttribute("users", userService.findAll(PageRequest.of(page, 20)));
return "users/list";
}
@GetMapping("/new")
public String newForm(Model model) {
model.addAttribute("form", new UserForm());
return "users/form";
}
@PostMapping
public String create(@Valid @ModelAttribute UserForm form,
BindingResult result,
RedirectAttributes flash) {
if (result.hasErrors()) return "users/form";
userService.create(form);
flash.addFlashAttribute("success", "User created");
return "redirect:/users";
}
}
REST API - сервер отдаёт JSON, фронтенд отдельный: React, Vue, мобильное приложение.
@RestController, DTO на вход и выходResponseEntityи@ResponseStatusдля статусов@RestControllerAdviceдля ошибок- OpenAPI и Swagger для документации
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserApiController {
private final UserService userService;
@GetMapping
public PagedResponse<UserDto> getAll(Pageable pageable) {
return userService.findAll(pageable);
}
@GetMapping("/{id}")
public UserDto getById(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDto create(@RequestBody @Valid CreateUserRequest request) {
return userService.create(request);
}
@PutMapping("/{id}")
public UserDto update(@PathVariable Long id,
@RequestBody @Valid UpdateUserRequest request) {
return userService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
userService.delete(id);
}
}
Гибрид - HTML-страницы и JSON API в одном приложении: разные контроллеры (UserPageController и
UserApiController), разные URL (/users/** и /api/users/**), общий сервисный слой.
Ещё варианты ответа: ResponseEntity для полного контроля и асинхронные Callable, DeferredResult,
StreamingResponseBody (см. Асинхронную обработку).
HTML или REST:
| Критерий | MVC (HTML) | REST (JSON) |
|---|---|---|
| Фронтенд | На сервере (Thymeleaf) | Отдельный (React, Vue, mobile) |
| SEO | Важен | Не важен |
| Интерактивность | Умеренная | Высокая |
| API для других систем | Не нужно | Нужно |
| Архитектура | Монолит | Микросервисы, SPA |
| Кто рендерит | Сервер | Браузер |
Итого: в большинстве современных проектов - REST JSON API + отдельный фронтенд. Для админок и простого CRUD MVC с Thymeleaf удобнее и проще.
Лучшие практики¶
Тонкие контроллеры. Контроллер - только HTTP-слой, бизнес-логики в нём нет.
// плохо: проверка склада, расчёт цены, отправка email прямо в контроллере
@PostMapping("/orders")
public OrderDto create(@RequestBody CreateOrderRequest req) {
if (inventoryService.getStock(req.getProductId()) < req.getQuantity()) {
throw new IllegalStateException("Not enough stock");
}
...
}
// хорошо
@PostMapping("/orders")
public ResponseEntity<OrderDto> create(@RequestBody @Valid CreateOrderRequest req) {
return ResponseEntity.status(201).body(orderService.create(req));
}
Бизнес-логика в сервисах:
- сервис управляет транзакциями (
@Transactional, см. Транзакции) - сервис содержит бизнес-правила и оркестрирует вызовы репозиториев
- контроллер не работает с репозиторием напрямую
DTO вместо Entity. Отдельные классы для запроса и ответа: CreateUserRequest, UpdateUserRequest, UserDto,
UserSummaryDto (см. DTO).
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) // плохо: наружу уходит Entity
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) // хорошо
Единый формат ошибок. Один @RestControllerAdvice на всё приложение и один формат: свой DTO или ProblemDetail
(см. Ошибки в JSON).
public record ErrorResponse(
String code,
String message,
List<FieldError> errors,
Instant timestamp
) {
public record FieldError(String field, String message) {}
}
Валидация на границе приложения:
- синтаксис и контракт (
@NotBlank,@Email,@Size) - на DTO контроллера (см. Валидацию) - бизнес-правила (уникальность email, наличие товара, лимиты пользователя) - в сервисе
- сервис не полагается на то, что его зовёт только HTTP-контроллер: его вызывают и задачи по расписанию, и слушатели очередей, и тесты
Разделение страниц и API по пакетам:
com.example.web.pages -> @Controller с Thymeleaf (URL: /*)
com.example.web.api -> @RestController с JSON (URL: /api/*)
com.example.service -> общий сервисный слой
Частые ошибки¶
@Controller вместо @RestController - метод возвращает объект без @ResponseBody, Spring ищет view с таким именем
и падает.
@Controller
public class UserController {
@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
return userService.findById(id); // ошибка: Spring ищет view
}
}
// правильно: @RestController или @ResponseBody на методе
String значит разное - в @RestController строка уходит телом ответа, в @Controller это имя view.
@RestController
public class ApiController {
@GetMapping("/view")
public String test() {
return "hello"; // тело ответа "hello", не view
}
}
@Controller
public class PageController {
@GetMapping("/page")
public String page() {
return "home"; // ViewResolver ищет templates/home.html
}
}
BindingResult не на своём месте или не проверен:
// ошибка: BindingResult не сразу после валидируемого объекта
public String register(BindingResult result, @Valid RegisterForm form)
// ошибка: BindingResult не проверяется, форма с ошибками молча проходит
public String register(@Valid @ModelAttribute RegisterForm form, BindingResult result) {
userService.register(form); // вызовется даже при ошибках
return "redirect:/login";
}
// правильно
public String register(@Valid @ModelAttribute RegisterForm form, BindingResult result) {
if (result.hasErrors()) return "register";
userService.register(form);
return "redirect:/login";
}
Бизнес-логика во view-контроллере:
// плохо: репозиторий и расчёты прямо в контроллере
@GetMapping("/dashboard")
public String dashboard(Model model) {
List<Order> orders = orderRepository.findAll();
BigDecimal total = orders.stream().map(Order::getTotal).reduce(BigDecimal.ZERO, BigDecimal::add);
model.addAttribute("total", total);
return "dashboard";
}
// хорошо
@GetMapping("/dashboard")
public String dashboard(Model model) {
model.addAttribute("stats", dashboardService.getStats());
return "dashboard";
}
Непонимание роли DispatcherServlet:
- это один конкретный servlet, а не магия (см. DispatcherServlet)
- без Boot у него свой
WebApplicationContext, дочерний от root - можно зарегистрировать несколько
DispatcherServletна разные URL, нужно редко @EnableWebMvcв Boot (см. выше)
Путаница между Filter, Interceptor и ControllerAdvice:
- фильтр - уровень servlet, до и после всего MVC: кодировка, CORS, безопасность
- interceptor - Spring MVC, до и после контроллера: логирование, лёгкая авторизация, locale
@ControllerAdvice- Spring MVC, срабатывает только при исключении: обработка ошибок
См. Filter и Interceptor.
Тестирование¶
| Уровень | Инструмент | Что проверяет |
|---|---|---|
| Срез контроллеров | @WebMvcTest + MockMvc |
Маппинг, binding, валидацию, статусы, JSON или view |
| Полный контекст | @SpringBootTest + @AutoConfigureMockMvc |
MVC вместе с настоящей конфигурацией приложения |
| HTTP «чёрный ящик» | TestRestTemplate, WebTestClient, Rest Assured |
Приложение через настоящий HTTP-сервер |
Для большинства контроллеров лучший старт - @WebMvcTest: быстро, изолированно, без БД и полного контекста.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
@WebMvcTest + MockMvc:
@WebMvcTest(UserApiController.class)
class UserApiControllerTest {
@Autowired
MockMvc mockMvc;
@MockitoBean
UserService userService; // сервис - мок, его слой в срез не входит
@Test
void returnsUser() throws Exception {
given(userService.findById(1L))
.willReturn(new UserDto(1L, "Alice"));
mockMvc.perform(get("/api/v1/users/{id}", 1L)
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.name").value("Alice"));
}
@Test
void rejectsInvalidBody() throws Exception {
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"","email":"bad-email"}
"""))
.andExpect(status().isBadRequest()); // ошибка валидации
}
}
Частые проверки MockMvc:
mockMvc.perform(get("/api/users")
.param("page", "0")
.header("X-Request-Id", "test")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(header().exists("X-Request-Id"))
.andExpect(jsonPath("$.items").isArray());
// HTML-контроллер: view и модель
mockMvc.perform(get("/users"))
.andExpect(status().isOk())
.andExpect(view().name("users/list"))
.andExpect(model().attributeExists("users"));
| Аннотация | Где | Что делает |
|---|---|---|
@WebMvcTest |
Тестовый класс | Поднимает срез MVC: контроллеры, инфраструктура MVC, Jackson, валидация, advice. Сервисы и репозитории не поднимает |
@SpringBootTest |
Тестовый класс | Полный контекст приложения. Медленнее, но ближе к реальности |
@AutoConfigureMockMvc |
Тестовый класс | Добавляет MockMvc к полному контексту |
@MockitoBean |
Поле теста | Mockito-мок как бин в контексте. Замена старому @MockBean из Boot |
@Autowired |
Поле или конструктор | Внедряет MockMvc, ObjectMapper и другие бины в тест |
CORS¶
CORS (Cross-Origin Resource Sharing) - механизм браузера, который решает, может ли фронтенд с одного origin
обращаться к API на другом. Origin = схема + хост + порт: https://app.example.com, http://localhost:3000.
Note
CORS не заменяет аутентификацию и авторизацию. Это только политика браузера, curl и другие серверы её не
соблюдают.
Preflight - для «непростых» запросов браузер сначала шлёт OPTIONS, и только после ответа с CORS-заголовками
отправляет настоящий запрос.
OPTIONS /api/users
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
@CrossOrigin - CORS для одного контроллера или метода:
@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://app.example.com")
public class UserApiController {
@GetMapping
public List<UserDto> getUsers()
@GetMapping("/{id}")
@CrossOrigin(origins = "http://localhost:3000", methods = RequestMethod.GET)
public UserDto getById(@PathVariable Long id)
}
Глобально - через WebMvcConfigurer:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://app.example.com")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE")
.allowedHeaders("Authorization", "Content-Type")
.allowCredentials(true)
.maxAge(3600);
}
}
Warning
Со Spring Security CORS включают и в security-конфигурации (http.cors(...)): preflight OPTIONS проходит через
security-цепочку раньше MVC и иначе получит 401 или 403 (см. Фильтры Security).
| Настройка | Где | Что делает |
|---|---|---|
@CrossOrigin |
Класс или метод контроллера | Локально включает CORS для выбранных endpoint'ов |
WebMvcConfigurer#addCorsMappings |
Конфиг | Глобальные правила CORS для MVC |
allowedOrigins |
CORS-конфиг | Явный список origin'ов |
allowedOriginPatterns |
CORS-конфиг | Origin'ы по шаблону, удобно для поддоменов |
allowedMethods |
CORS-конфиг | Разрешённые HTTP-методы |
allowedHeaders |
CORS-конфиг | Разрешённые заголовки запроса |
exposedHeaders |
CORS-конфиг | Какие заголовки ответа JS сможет прочитать |
allowCredentials |
CORS-конфиг | Разрешает cookies и credentials. С ним нельзя * в allowedOrigins |
maxAge |
CORS-конфиг | Сколько браузер кеширует ответ на preflight |