← назад к разделу

Spring MVC — синхронный веб-стек поверх Servlet API. С Spring Boot 3 на нём строятся почти все типичные backend-сервисы: REST API, internal HTTP-эндпоинты, веб-админки. Альтернатива — WebFlux — нужна реже, чем многие думают.

DispatcherServlet и поток запроса

HTTP request
    ↓
[Servlet Container: Tomcat]
    ↓
[Filter Chain]                    ← OncePerRequestFilter, security filters
    ↓
[DispatcherServlet]
    ↓
[HandlerMapping]                  ← найти контроллер по URL
    ↓
[HandlerInterceptor.preHandle]    ← AOP-подобная точка до контроллера
    ↓
[HandlerAdapter → @RequestMapping] ← вызов метода контроллера
    ↓
[HandlerInterceptor.postHandle]
    ↓
[ViewResolver или MessageConverter] ← сериализация ответа
    ↓
HTTP response

Знать этот поток нужно, потому что 80% «магических» проблем («почему фильтр не вызвался», «почему @RestControllerAdvice не поймал исключение», «почему content-type не такой как ожидался») разрешаются через понимание точки в цепочке.

REST-контроллер

@RestController
@RequestMapping("/api/v1/orders")
@RequiredArgsConstructor
public class OrderController {

    private final CreateOrderUseCase createOrder;
    private final FindOrderUseCase findOrder;

    @PostMapping
    public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
        OrderId id = createOrder.handle(request.toCommand());
        return ResponseEntity
            .created(URI.create("/api/v1/orders/" + id.value()))
            .body(new OrderResponse(id.value()));
    }

    @GetMapping("/{id}")
    public OrderResponse get(@PathVariable UUID id) {
        return findOrder.handle(new OrderId(id))
            .map(OrderResponse::from)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }
}
  • @RestController = @Controller + @ResponseBody (методы возвращают тело ответа, не view).
  • @RequestMapping на классе — общий префикс.
  • @PostMapping / @GetMapping — shortcut для @RequestMapping(method = POST).
  • ResponseEntity возвращаем, когда нужно явно контролировать статус и заголовки. Иначе — обычный объект (status 200).

Bean Validation

Валидация входящих DTO — через Jakarta Bean Validation (бывший JSR-380). В Spring Boot подключается автоматически через spring-boot-starter-validation.

public record CreateOrderRequest(
    @NotBlank @Size(max = 200) String customerName,
    @NotEmpty @Size(min = 1, max = 50) List<@Valid OrderLineRequest> lines,
    @NotNull @Future LocalDateTime deliveryDate
) {}

public record OrderLineRequest(
    @NotBlank String sku,
    @Positive int quantity,
    @NotNull @PositiveOrZero BigDecimal price
) {}

Активация — через @Valid на параметре:

@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest request) { ... }

@Valid — каскадно валидирует вложенные объекты (массивы List<@Valid OrderLineRequest> тоже).

Альтернатива — @Validated на классе контроллера + @Min/@Max на параметрах метода:

@RestController
@Validated
public class ProductController {

    @GetMapping("/products")
    public List<Product> list(
            @RequestParam @Min(1) @Max(100) int limit,
            @RequestParam @PositiveOrZero int offset) { ... }
}

@Validated валидирует на уровне метода (через AOP), @Valid — на уровне объекта.

Единая обработка ошибок

@RestControllerAdvice@RestController для исключений. Один класс на сервис, в нём — все @ExceptionHandler.

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        problem.setTitle("Validation failed");
        problem.setDetail("Request body did not pass validation");
        problem.setProperty("violations", ex.getBindingResult().getFieldErrors().stream()
            .map(fe -> Map.of("field", fe.getField(), "message", fe.getDefaultMessage()))
            .toList());
        return problem;
    }

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnknown(Exception ex) {
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
            "Internal server error");
    }
}

ProblemDetail (RFC 7807)

С Spring 6/Boot 3 стандарт для ошибок — ProblemDetail (RFC 7807). Формат ответа фиксирован:

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "Request body did not pass validation",
  "instance": "/api/v1/orders",
  "violations": [
    {"field": "customerName", "message": "must not be blank"}
  ]
}

Не переизобретайте свой формат ошибок — Problem Details работает с UI/мобильными клиентами/API gateway единообразно.

HandlerInterceptor vs OncePerRequestFilter

Оба перехватывают запросы. Выбор не очевиден.

OncePerRequestFilterHandlerInterceptor
Когда срабатываетДо DispatcherServletПосле DispatcherServlet, до контроллера
Доступ к Handler (Controller method)НетДа
Зависимости от Spring MVCНет (servlet-уровень)Да (mvc-уровень)
Типичное применениеSecurity, MDC, audit logging, CORSPer-method авторизация, response shaping

Правило: если фильтру не нужно знать про конкретный контроллер — OncePerRequestFilter. Иначе — HandlerInterceptor.

@Component
public class CorrelationIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String correlationId = Optional.ofNullable(req.getHeader("X-Correlation-ID"))
            .orElseGet(() -> UUID.randomUUID().toString());
        MDC.put("correlationId", correlationId);
        try {
            res.setHeader("X-Correlation-ID", correlationId);
            chain.doFilter(req, res);
        } finally {
            MDC.remove("correlationId");
        }
    }
}

Content negotiation

Spring сам выбирает MessageConverter под Accept-заголовок клиента. Стандарт — JSON через Jackson. Подключение XML или YAML — добавлением соответствующих converters в classpath или в WebMvcConfigurer.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        configurer
            .defaultContentType(MediaType.APPLICATION_JSON)
            .ignoreAcceptHeader(false)
            .favorParameter(true)
            .parameterName("format")
            .mediaType("json", MediaType.APPLICATION_JSON)
            .mediaType("xml", MediaType.APPLICATION_XML);
    }
}

?format=xml → XML. Без параметра / Accept: application/json → JSON.

OpenAPI

Документация — через springdoc-openapi. Подключение одной зависимостью:

dependencies {
    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.x.x")
}

OpenAPI-спецификация автоматически генерируется по @RestController + @Schema-аннотациям на DTO. UI доступен по /swagger-ui.html.

Что почитать дальше

  • Spring WebFlux — reactive-альтернатива и когда она нужна.
  • @Transactional глубоко — как пометить операции в контроллере как транзакционные правильно.
  • Spring Security — authentication/authorization внутри MVC.
  • Spring Testing — @WebMvcTest, MockMvc, тестирование контроллеров.