Перейти к содержанию

Конфигурирование, практики и тестирование

26. Конфигурирование Spring MVC

26.1. @EnableWebMvc

Включает полную MVC-конфигурацию Spring MVC в Java Config:

@Configuration
@EnableWebMvc
public class WebConfig

В 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:

https://app.example.com
http://localhost:3000

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.