Этап 4 — Spring MVC: полный путь HTTP-запроса
MVC означает Model-View-Controller. Это архитектурная идея для разделения обязанностей в web-приложении. Controller принимает запрос и выбирает, что делать дальше. Model содержит данные, которые нужны для ответа. View отвечает за то, как эти данные будут показаны пользователю. В классическом web-приложении View — это HTML-страница, которую сервер рендерит и отдаёт браузеру.
В REST API View обычно нет в виде HTML-шаблона. Вместо страницы сервер возвращает данные, чаще всего JSON. Поэтому в Spring MVC важно понимать две формы работы: controller для страниц и controller для API. В обоих случаях запрос проходит через Spring MVC pipeline, но результат формируется по-разному.
В server-rendered MVC controller кладёт данные в Model, например заголовок страницы и список заказов. Затем controller возвращает имя view, например orders. Spring находит шаблон orders.html, view достаёт данные из Model, подставляет их в HTML и отдаёт готовую страницу браузеру. В REST API этот шаг обычно заменён сериализацией объекта в JSON.
Что делает DispatcherServlet
DispatcherServlet — центральная точка входа Spring MVC в servlet-приложении. Когда HTTP-запрос уже прошёл через servlet-контейнер и фильтры, он попадает в DispatcherServlet. Дальше Spring ищет подходящий handler, обычно метод контроллера, запускает MVC-инфраструктуру вокруг него и организует создание ответа.
Новичку проще думать так: DispatcherServlet — это диспетчер на входе в Spring MVC. Он сам не содержит бизнес-логику, не сохраняет заказы и не регистрирует пользователей. Его задача — провести запрос по правильному маршруту: найти controller method, передать туда данные, обработать результат и вернуть response.
Если endpoint не вызывается, важно понять, где именно остановился запрос. Он мог быть заблокирован фильтром, не пройти CORS preflight, не найти подходящий route, упасть на JSON parsing, провалить валидацию или выбросить exception в сервисе. Поэтому web-слой нельзя отлаживать только взглядом на метод контроллера.
@Controller и @RestController
@Controller используется, когда приложение возвращает представление. Представление — это не Java-объект данных, а результат для отображения пользователю: например HTML-страница orders.html, собранная через Thymeleaf. Метод может положить данные в Model, а затем вернуть имя view. Spring найдёт шаблон, подставит данные и отдаст готовый HTML.
@Controller
class PageController {
@GetMapping("/orders/page")
String ordersPage(Model model) {
model.addAttribute("title", "Orders");
return "orders";
}
}
В этом примере строка "orders" не является текстом ответа. Это имя представления. Spring будет искать шаблон вроде orders.html, подготовит HTML и отправит его браузеру.
@RestController используется для REST API. Он объединяет @Controller и @ResponseBody, поэтому возвращаемый объект не считается именем view. Объект записывается прямо в тело HTTP-ответа. Если клиент ожидает JSON, Spring превратит Java-объект в JSON.
@RestController
class OrderApiController {
@GetMapping("/api/orders/{id}")
OrderResponse getOrder(@PathVariable Long id) {
return new OrderResponse(id, "PAID");
}
}
Здесь OrderResponse не рендерится как HTML-страница. Он становится JSON-ответом вроде {"id":1,"status":"PAID"}. Поэтому для API обычно используют @RestController, а для server-rendered страниц — @Controller.
Как JSON превращается в Java-объект и обратно
В REST API клиент часто отправляет JSON в body запроса. Например frontend отправляет POST /api/orders с телом {"productId":10,"quantity":2}. Java-метод контроллера не хочет работать со строкой JSON вручную, поэтому Spring делает преобразование на границе контроллера.
Если параметр метода помечен @RequestBody, Spring берёт тело запроса и пытается превратить его в Java DTO. DTO — это простой объект для передачи данных между API и кодом приложения. Преобразование выполняют HttpMessageConverter-ы, а в обычном Spring Boot REST-приложении JSON чаще всего обрабатывает Jackson.
record CreateOrderRequest(Long productId, int quantity) {}
@PostMapping("/api/orders")
OrderResponse create(@RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
На входе происходит deserialization: JSON становится CreateOrderRequest. На выходе происходит serialization: OrderResponse становится JSON. Если JSON сломан синтаксически, поле имеет неподходящий тип или DTO невозможно создать, ошибка может возникнуть до входа в тело метода контроллера. Поэтому фраза «контроллер не вызывается» часто означает не проблему сервиса, а проблему преобразования запроса.
Валидация и обработка исключений
JSON может быть правильным по синтаксису, но неправильным по смыслу. Например quantity пришёл как 0, email пустой, дата в прошлом, обязательное поле отсутствует. Для таких проверок используют Bean Validation: аннотации на DTO и @Valid в параметре контроллера.
record CreateOrderRequest(
@NotNull Long productId,
@Min(1) int quantity
) {}
@PostMapping("/api/orders")
OrderResponse create(@Valid @RequestBody CreateOrderRequest request) {
return orderService.create(request);
}
Если DTO не проходит валидацию, Spring выбрасывает exception на границе контроллера. Если сервис не находит заказ, он тоже может выбросить exception, например OrderNotFoundException. В нормальном API не нужно писать try/catch в каждом методе контроллера. Вместо этого делают глобальный обработчик ошибок.
@RestControllerAdvice или @ControllerAdvice помечает класс, который перехватывает исключения из контроллеров глобально. Внутри пишут методы с @ExceptionHandler. Такой метод получает exception и возвращает понятный HTTP-ответ: статус, код ошибки, сообщение и иногда список полей с ошибками. Разница практическая: @RestControllerAdvice сразу возвращает body, обычно JSON; @ControllerAdvice чаще используют и для MVC-страниц, и для более ручной настройки ответа.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ResponseEntity<ApiError> handleNotFound(OrderNotFoundException ex) {
return ResponseEntity.status(404)
.body(new ApiError("ORDER_NOT_FOUND", ex.getMessage()));
}
}
Так приложение получает единый error contract. Frontend и другие клиенты не разбирают случайные stack trace, HTML-страницы ошибок или разные форматы ответа. Они всегда получают предсказуемую структуру.
Filters и Interceptors
Filter — это объект servlet-уровня, который обрабатывает запрос до попадания в DispatcherServlet. В Spring Boot существует chain фильтров: запрос проходит через них по порядку. Фильтр может прочитать headers, добавить correlation id, залогировать запрос, проверить token, ограничить размер body, настроить CORS или остановить запрос раньше контроллера.
Свои фильтры тоже можно создавать. Например можно написать filter, который для каждого запроса генерирует request id и кладёт его в MDC для логов. Можно написать filter, который отклоняет запрос без обязательного header. Важно понимать силу фильтра: он находится очень рано в pipeline, поэтому может повлиять на все endpoints, включая те, которые ещё не дошли до Spring MVC.
Interceptor — это расширение уровня Spring MVC. Он работает вокруг найденного handler-а, то есть ближе к контроллеру. У interceptor есть точки preHandle, postHandle и afterCompletion. preHandle выполняется перед методом контроллера, postHandle после выполнения handler-а перед финальной обработкой view, afterCompletion после завершения запроса. Поэтому interceptor удобно использовать для MVC-задач: логирование выбранного handler-а, проверка атрибутов, измерение времени выполнения контроллера.
Фраза «filter обрабатывает входящий запрос, а interceptor выходящий» слишком грубая, но полезная как первый ориентир. Filter действительно стоит на самом входе и может не пустить запрос дальше. Interceptor работает внутри Spring MVC вокруг controller method и может выполнить код до handler-а и после него. Если задача связана с низкоуровневым HTTP, security chain, CORS или тем, что должно сработать до Spring MVC, чаще нужен filter. Если задача связана с конкретными MVC handler-ами, чаще подходит interceptor.
| Задача | Лучше подходит | Почему |
|---|---|---|
| Проверить raw header до контроллера | Filter | Работает до DispatcherServlet |
| Настроить CORS или security chain | Filter | Это ранняя servlet-граница |
| Измерить время выполнения controller method | Interceptor | Видит MVC handler |
| Добавить данные для view | Interceptor | Работает внутри MVC flow |
| Обработать API exception | @RestControllerAdvice | Формирует единый HTTP error response |
CORS как практический источник инцидентов
CORS означает Cross-Origin Resource Sharing. Это механизм безопасности браузера. Origin — это комбинация protocol, host и port. Например http://localhost:3000 и http://localhost:8080 — разные origins, даже если оба запускаются на одной машине. Когда frontend с одного origin вызывает backend на другом origin, браузер проверяет, разрешает ли backend такой вызов.
Важно: CORS проверяет браузер. Postman, curl и backend-to-backend запросы обычно не ограничены CORS. Поэтому ситуация «в Postman работает, а во frontend нет» часто означает не поломку endpoint-а, а отсутствие правильных CORS headers.
Для простых запросов браузер сразу отправляет запрос и смотрит на ответные headers. Для более чувствительных запросов, например с Authorization header или методом PUT, браузер сначала отправляет preflight-запрос OPTIONS. Он спрашивает backend: можно ли origin-у http://localhost:3000 выполнить такой метод с такими headers? Backend должен ответить headers вроде Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers.
В Spring CORS можно настроить на уровне контроллера через @CrossOrigin, глобально через WebMvcConfigurer, а в приложениях со Spring Security часто ещё нужно согласовать CORS с security filter chain. Для учебного проекта можно начать с явного разрешения локального frontend origin, но в production нельзя бездумно ставить «разрешить всё» вместе с cookies или authorization.
@Configuration
class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000")
.allowedMethods("GET", "POST", "PUT", "DELETE")
.allowedHeaders("Authorization", "Content-Type");
}
}
CORS нужен не для защиты backend от всех клиентов. Он нужен, чтобы браузер не позволял чужому сайту незаметно читать ответы вашего API от имени пользователя. Поэтому CORS — часть browser security model, а не замена authentication, authorization или CSRF-защиты.
Практический пример
Представь форму создания заказа на frontend. Пользователь нажимает кнопку, браузер отправляет POST http://localhost:8080/api/orders с JSON body. Перед самим POST браузер может отправить OPTIONS preflight, потому что запрос идёт с другого origin и содержит JSON или authorization header. Если CORS настроен неправильно, контроллер вообще не будет вызван, а ошибка появится в browser console.
Если CORS прошёл, запрос попадает в filter chain. Security filter может проверить token. Logging filter может добавить request id. Затем DispatcherServlet находит метод create в OrderController. Jackson пытается превратить JSON в CreateOrderRequest. Если JSON сломан, будет ошибка parsing. Если JSON корректный, но quantity < 1, сработает @Valid и выбросится validation exception.
Если DTO валиден, контроллер вызывает orderService.create(request). Сервис проверяет бизнес-правила и может выбросить ProductNotFoundException, если товара нет. И validation exception, и business exception перехватывает @RestControllerAdvice, превращая их в JSON-ответ с понятным статусом. Так frontend получает не случайную ошибку, а предсказуемый ответ: например 400 VALIDATION_ERROR или 404 PRODUCT_NOT_FOUND.
Итог этапа
Spring MVC — это не только аннотация на контроллере. Это pipeline: browser security rules, filters, DispatcherServlet, handler mapping, JSON binding, validation, controller method, service call, exception handling and response serialization. Чем яснее этот путь, тем проще отлаживать web-приложение.
Нюансы и типичные ошибки
- Возвращать JPA entity напрямую из API и получать лишние поля, циклические ссылки или неожиданные lazy-загрузки.
- Не делать единый error contract и заставлять frontend обрабатывать разные форматы ошибок.
- Писать
try/catchв каждом controller method вместо общего@RestControllerAdvice. - Путать filter и interceptor и размещать логику слишком рано или слишком поздно.
- Настраивать CORS через «разрешить всё», не понимая origins, credentials и security consequences.
Чек-лист понимания
- Могу объяснить, что такое MVC и чем view отличается от JSON response.
- Понимаю разницу между
@Controllerи@RestController. - Могу объяснить, где JSON превращается в DTO и где объект ответа превращается в JSON.
- Понимаю, как
@Validсвязан с глобальной обработкой ошибок. - Могу выбрать между filter, interceptor и
@RestControllerAdviceпо задаче. - Могу объяснить, почему CORS-ошибка бывает только в браузере.
Вопросы для самопроверки
- Почему строка, возвращённая из
@Controller, может быть именем view, а не body ответа? - Что произойдёт раньше: вызов service method или преобразование
@RequestBodyJSON в DTO? - Почему
@RestControllerAdviceлучше, чем одинаковыйtry/catchв каждом контроллере? - Чем filter отличается от interceptor на пути запроса?
- Почему запрос из Postman проходит, а такой же запрос из браузера блокируется CORS?