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

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

Как настраивают 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.

@Configuration
@EnableWebMvc
public class WebConfig implements WebMvcConfigurer {
}

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