Конфигурирование, практики и тестирование¶
26. Конфигурирование Spring MVC¶
26.1. @EnableWebMvc¶
Включает полную MVC-конфигурацию Spring MVC в Java Config:
В Spring Boot: @EnableWebMvc говорит приложению: “я сам полностью управляю MVC-конфигурацией”. Поэтому Boot MVC auto-configuration больше не добавляет свои обычные настройки поверх. В Boot-приложениях почти всегда используйте WebMvcConfigurer без @EnableWebMvc.
Аннотации раздела:
| Аннотация | Где ставится | Что делает |
|---|---|---|
@Configuration |
класс | Объявляет Java config class со Spring beans/customizers. |
@EnableWebMvc |
config class | Импортирует MVC-конфигурацию Spring Framework напрямую; в Boot обычно не нужна. |
@Bean |
метод | Регистрирует объект как Spring bean, например LocaleResolver или MessageSource. |
26.2. Java Config¶
@Configuration
public class WebConfig implements WebMvcConfigurer {
// переопределяем только нужное
}
26.3. Настройка view resolvers¶
@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
registry.jsp("/WEB-INF/jsp/", ".jsp");
// или:
registry.viewResolver(new InternalResourceViewResolver("/WEB-INF/views/", ".html"));
}
26.4. Настройка converters¶
@Override
public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
// полностью заменяет список
converters.add(new MappingJackson2HttpMessageConverter());
}
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
// расширяет список (предпочтительнее)
converters.add(0, new CustomConverter());
}
26.5. Настройка interceptors¶
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new LoggingInterceptor());
registry.addInterceptor(new AuthInterceptor())
.addPathPatterns("/admin/**")
.excludePathPatterns("/admin/login");
}
26.6. Настройка resource handlers¶
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
}
26.7. WebMvcConfigurer¶
Интерфейс с default-методами. Реализуйте только то, что нужно:
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("https://example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE");
}
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new StringToEnumConverter());
}
@Override
public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
configurer.enable(); // передать статику в default servlet
}
}
27. Spring MVC и Spring Boot¶
27.1. Как Boot автоконфигурирует MVC¶
Spring Boot через @EnableAutoConfiguration подключает WebMvcAutoConfiguration, если spring-webmvc в classpath.
WebMvcAutoConfiguration:
- регистрирует DispatcherServlet,
- конфигурирует Jackson / Gson,
- настраивает resource handlers,
- регистрирует default ViewResolver'ы,
- настраивает content negotiation,
- настраивает message converters.
27.2. spring-boot-starter-webmvc¶
В Spring Boot 4 servlet MVC starter называется spring-boot-starter-webmvc:
<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>
Транзитивно подтягивает:
- spring-webmvc
- spring-web
- embedded Tomcat (spring-boot-starter-tomcat)
- Jackson (jackson-databind)
- slf4j
Bean Validation подключается отдельно:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
27.3. Что Boot настраивает автоматически¶
| Компонент | По умолчанию |
|---|---|
DispatcherServlet |
маппинг на / |
Jackson ObjectMapper |
настроен разумно |
ContentNegotiationStrategy |
по Accept header |
| Static resources | /static/, /public/, /resources/ |
MessageSource |
messages.properties |
| Error handling | /error -> BasicErrorController |
| Multipart | включен, лимиты настраиваются через spring.servlet.multipart.* |
27.4. Когда нужна ручная конфигурация¶
- Нужны кастомные interceptors.
- Нужен кастомный
ObjectMapper. - Нужны кастомные CORS настройки.
- Нужен нестандартный resource handler.
- Нужен нестандартный ViewResolver.
@Configuration
public class WebConfig implements WebMvcConfigurer {
// Без @EnableWebMvc — Boot auto-configuration остается активной,
// мы лишь расширяем её.
}
27.5. MVC в Boot-приложении¶
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
Boot сам запускает embedded Tomcat, регистрирует DispatcherServlet, поднимает ApplicationContext.
Порт по умолчанию: 8080. Конфигурация: application.properties.
server.port=8080
server.servlet.context-path=/app
spring.mvc.servlet.path=/mvc # путь DispatcherServlet
Аннотация @SpringBootApplication объединяет:
- @SpringBootConfiguration,
- @EnableAutoConfiguration,
- @ComponentScan.
Именно @EnableAutoConfiguration включает WebMvcAutoConfiguration, если в classpath есть Spring MVC и servlet stack.
28. Практические стили использования Spring MVC¶
28.1. Server-side rendered приложение¶
Сервер рендерит HTML через шаблонизатор.
Стек:
- @Controller (не @RestController)
- Thymeleaf или FreeMarker
- Model для передачи данных
- Redirect после POST (PRG)
- Flash attributes для сообщений
28.2. REST API приложение¶
Сервер отдает JSON, фронтенд — отдельный (React, Vue, etc.).
Стек:
- @RestController
- DTO в/из JSON
- ResponseEntity для контроля статусов
- @ControllerAdvice для ошибок
- OpenAPI/Swagger для документации
28.3. Гибридное приложение¶
И HTML-страницы, и JSON API в одном приложении.
Стек:
- Разные контроллеры: UserPageController и UserApiController
- Разные URL: /users/** и /api/users/**
- Shared сервисный слой
28.4. MVC для Thymeleaf¶
@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";
}
}
28.5. MVC для JSON API¶
@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);
}
}
29. Лучшие практики¶
29.1. Тонкие контроллеры¶
Контроллер = только HTTP-слой. Никакой бизнес-логики:
// Плохо:
@PostMapping("/orders")
public OrderDto create(@RequestBody CreateOrderRequest req) {
// проверка инвентаря, расчет цены, отправка email — НЕ ЗДЕСЬ
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));
}
29.2. Вынос бизнес-логики в сервисы¶
- Сервис управляет транзакциями (
@Transactional). - Сервис содержит бизнес-правила.
- Сервис оркестрирует вызовы нескольких репозиториев.
- Контроллер не работает с репозиторием напрямую.
29.3. Использование DTO¶
// НЕ возвращайте Entity:
@GetMapping("/{id}")
public User getUser(@PathVariable Long id) // плохо
// Возвращайте DTO:
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) // хорошо
Отдельные DTO для запроса и ответа: CreateUserRequest, UpdateUserRequest, UserDto, UserSummaryDto.
29.4. Единый формат ошибок¶
public record ErrorResponse(
String code,
String message,
List<FieldError> errors,
Instant timestamp
) {
public record FieldError(String field, String message) {}
}
Один @RestControllerAdvice для всего приложения.
29.5. Валидация на границе приложения¶
Синтаксическая/контрактная валидация (@NotBlank, @Email, @Size) — на границе приложения: controller DTO.
Бизнес-правила (например, уникальность email, доступность товара, лимиты пользователя) — в сервисе.
Сервисный слой не должен слепо полагаться на то, что его всегда вызывает только HTTP-контроллер: сервисы часто переиспользуются из jobs, message listeners и tests.
29.6. Разделение page controllers и api controllers¶
com.example.web.pages -> @Controller с Thymeleaf (URL: /*)
com.example.web.api -> @RestController с JSON (URL: /api/*)
com.example.service -> shared сервисный слой
30. Частые ошибки и подводные камни¶
30.1. Путаница между @Controller и @RestController¶
// Ошибка: @Controller + возврат объекта без @ResponseBody
@Controller
public class UserController {
@GetMapping("/users/{id}")
public UserDto getUser(@PathVariable Long id) {
return userService.findById(id); // Spring ищет view "UserDto", получает ошибку!
}
}
// Правильно:
@RestController // или добавить @ResponseBody на метод
public class UserController
30.2. Неправильный возврат String¶
// @RestController: String как тело ответа
@RestController
public class Controller {
@GetMapping("/view")
public String test() {
return "hello"; // вернет строку "hello" как тело, НЕ view!
}
}
// @Controller: String как имя view
@Controller
public class Controller {
@GetMapping("/page")
public String page() {
return "home"; // ViewResolver ищет templates/home.html
}
}
30.3. Ошибки binding и validation¶
// Ошибка: BindingResult не сразу после валидируемого объекта
@PostMapping("/register")
public String register(BindingResult result, @Valid RegisterForm form) // WRONG!
// Правильно:
@PostMapping("/register")
public String register(@Valid RegisterForm form, BindingResult result) // OK
// Ошибка: не проверять BindingResult (и форма молча проходит)
@PostMapping("/register")
public String register(@Valid @ModelAttribute RegisterForm form, BindingResult result) {
userService.register(form); // вызовется даже при ошибках валидации!
return "redirect:/login";
}
// Правильно:
if (result.hasErrors()) return "register";
30.4. Смешивание 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";
}
30.5. Непонимание роли DispatcherServlet¶
DispatcherServlet— это один конкретный servlet, не магия.- У него есть свой
WebApplicationContext(дочерний от root). - Можно зарегистрировать несколько
DispatcherServletс разными URL (редко нужно). @EnableWebMvcв Boot берет MVC-конфигурацию под ручное управление — часто ошибка, если вы хотели только добавить interceptor/converter.
30.6. Путаница между Filter, Interceptor и Controller Advice¶
| Filter | Interceptor | ControllerAdvice | |
|---|---|---|---|
| Уровень | Servlet | Spring MVC | Spring MVC |
| Когда | до/после всего MVC | до/после controller | только при exception |
| Знает Spring Context | если зарегистрирован Spring'ом | да | да |
| Для чего | encoding, CORS, security | logging, auth, locale | exception handling |
31. Итоговая схема Spring Web MVC¶
31.1. Основные компоненты¶
┌─────────────────────────────────────────────────────────────┐
│ Spring Web MVC │
│ │
│ DispatcherServlet │
│ │ │
│ ├─ HandlerMapping ──────► HandlerExecutionChain │
│ │ └─ RequestMappingHandlerMapping │
│ │ │
│ ├─ HandlerAdapter ──────► ArgumentResolvers │
│ │ └─ RequestMappingHandlerAdapter ReturnValueHandlers │
│ │ │
│ ├─ HandlerInterceptors (pre/post/afterCompletion) │
│ │ │
│ ├─ HttpMessageConverter (JSON/XML ↔ Java) │
│ │ └─ MappingJackson2HttpMessageConverter │
│ │ │
│ ├─ ViewResolver ─────────► View │
│ │ └─ ThymeleafViewResolver │
│ │ │
│ └─ HandlerExceptionResolver │
│ └─ ExceptionHandlerExceptionResolver │
└─────────────────────────────────────────────────────────────┘
31.2. Полный путь HTTP-запроса¶
1. Клиент -> HTTP-запрос
2. Servlet Container принимает, создает HttpServletRequest/Response
3. Filters (CharacterEncodingFilter, Spring Security, ...)
4. DispatcherServlet.doDispatch()
5. HandlerMapping -> HandlerExecutionChain (handler + interceptors)
6. HandlerInterceptor.preHandle() [все, по порядку]
7. HandlerAdapter -> подготовка аргументов (ArgumentResolvers)
8. @PathVariable, @RequestParam, @RequestBody, @ModelAttribute, ...
9. Валидация (`@Valid`, constraint-аннотации, method validation)
10. Controller метод — вызов Java-метода
11. Controller -> Service -> Repository -> DB
12. Возврат результата (DTO, ResponseEntity, String, ...)
13. Обработка return value внутри HandlerAdapter:
-> если @ResponseBody/REST: HttpMessageConverter -> JSON/XML
-> если ResponseEntity: status + headers + body
-> если view name: готовится ModelAndView
14. HandlerInterceptor.postHandle() [после успешного handler return, в обратном порядке; ModelAndView может быть null]
15. Если нужен view render: ViewResolver -> View -> HTML
16. Если redirect: 3xx + Location header
17. Формирование HTTP response (status, headers, body)
18. HandlerInterceptor.afterCompletion() [для interceptor'ов, чей preHandle успешно прошел]
19. Servlet Container -> HTTP-ответ -> Клиент
При exception на любом шаге:
HandlerExceptionResolver -> @ExceptionHandler / @ControllerAdvice
31.3. Варианты реализации контроллеров¶
Вариант 1: HTML-приложение (Thymeleaf)
@Controller -> Model + view name -> ViewResolver -> HTML
Вариант 2: REST JSON API
@RestController -> DTO -> HttpMessageConverter -> JSON
Вариант 3: Гибридный
@Controller + @RestController в разных пакетах
Вариант 4: ResponseEntity (полный контроль)
return ResponseEntity.status(201).header(...).body(dto)
Вариант 5: Асинхронный
return Callable / DeferredResult / StreamingResponseBody
31.4. Когда использовать MVC-view, а когда REST JSON¶
| Критерий | MVC (HTML) | REST (JSON) |
|---|---|---|
| Фронтенд | На сервере (Thymeleaf) | Отдельный (React/Vue/Mobile) |
| SEO | Важен | Не важен |
| Интерактивность | Умеренная | Высокая |
| API для других | Не нужно | Нужно |
| Архитектура | Монолит | Микросервисы / SPA |
| Кто рендерит | Сервер | Браузер |
В большинстве современных проектов: REST JSON API + отдельный фронтенд. Для admin-панелей и simple CRUD: MVC с Thymeleaf удобнее и проще.
32. Тестирование Spring MVC¶
32.1. Что тестировать¶
Spring MVC обычно тестируют на нескольких уровнях:
| Уровень | Инструмент | Что проверяет |
|---|---|---|
| Controller slice | @WebMvcTest + MockMvc |
mapping, binding, validation, status codes, JSON/view |
| Full context | @SpringBootTest + @AutoConfigureMockMvc |
MVC вместе с реальной конфигурацией приложения |
| HTTP black-box | TestRestTemplate, WebTestClient, Rest Assured |
приложение через настоящий HTTP server |
Для большинства контроллеров лучший старт — @WebMvcTest: быстро, изолированно, без БД и полного application context.
Обычно для тестов подключают общий test starter:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
32.2. @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"));
}
}
32.3. Проверка validation errors¶
@Test
void rejectsInvalidBody() throws Exception {
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"","email":"bad-email"}
"""))
.andExpect(status().isBadRequest());
}
32.4. Аннотации тестов¶
| Аннотация | Где ставится | Что делает |
|---|---|---|
@WebMvcTest |
test class | Поднимает MVC slice: controllers, MVC infrastructure, Jackson, validation, controller advice. Не поднимает весь сервисный/репозиторный слой. |
@SpringBootTest |
test class | Поднимает полный application context. Медленнее, но ближе к реальному приложению. |
@AutoConfigureMockMvc |
test class | Добавляет MockMvc к полному Boot context. |
@MockitoBean |
test class/field | Регистрирует Mockito mock в Spring context. Современная замена старому Boot @MockBean. |
@Autowired |
field/constructor | Внедряет MockMvc, ObjectMapper и другие beans в тест. |
32.5. Частые проверки 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 и model:
mockMvc.perform(get("/users"))
.andExpect(status().isOk())
.andExpect(view().name("users/list"))
.andExpect(model().attributeExists("users"));
33. CORS в Spring MVC¶
33.1. Что такое CORS¶
CORS (Cross-Origin Resource Sharing) — браузерный механизм, который решает, может ли frontend с одного origin обращаться к API на другом origin.
Origin = scheme + host + port:
CORS не заменяет authentication/authorization. Это только политика браузера.
33.2. @CrossOrigin¶
Для одного контроллера или метода:
@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)
33.3. Глобальная настройка CORS¶
@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);
}
}
Если включен Spring Security, CORS часто нужно включить и в security-конфигурации, потому что preflight OPTIONS проходит через security filter chain.
33.4. Аннотации и настройки CORS¶
| Аннотация / настройка | Где | Что делает |
|---|---|---|
@CrossOrigin |
класс/метод controller | Локально включает CORS для выбранных endpoints. |
WebMvcConfigurer#addCorsMappings |
config class | Глобальные CORS rules для MVC. |
allowedOrigins |
CORS config | Явный список origins. |
allowedOriginPatterns |
CORS config | Pattern-based origins, полезно для subdomains. |
allowedMethods |
CORS config | Какие HTTP methods разрешены. |
allowedHeaders |
CORS config | Какие request headers разрешены. |
exposedHeaders |
CORS config | Какие response headers браузер может читать из JS. |
allowCredentials |
CORS config | Разрешает cookies/authorization credentials. С credentials нельзя использовать wildcard * как конкретный origin. |
maxAge |
CORS config | Сколько браузер может кешировать preflight response. |
33.5. Preflight request¶
Для “непростых” запросов браузер сначала отправляет:
OPTIONS /api/users
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
Сервер должен ответить CORS-заголовками. Только после этого браузер отправит настоящий POST.