сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Spring и Hibernate

Spring MVC и REST

REST-контроллеры: тонкий верхний слой

Отправил в контроллер заведомо кривое тело: пустое имя, «не-почта» вместо адреса и возраст 15 при минимуме 18. Метод с аннотацией @Valid ответил 400 и перечислил все три нарушения по полям. Соседний метод без @Valid ответил 200 и принял этот мусор как есть.

Одна забытая аннотация - и валидации нет вообще, при том что ограничения на полях написаны и выглядят рабочими. Проверяют на собесе именно такие вещи: путь запроса, где стоит валидация, как отдаётся ошибка и почему объект базы нельзя возвращать наружу.

// Формулировки: «что делает DispatcherServlet?», «как валидируешь тело запроса?», «почему не отдаёшь entity из контроллера?».

Путь запроса через Spring MVC

Запрос идёт по цепочке. Веб-слой тут называется Spring MVC (model-view-controller, модель-представление-контроллер) - по классическому разделению на данные, их отображение и обработчик. Сначала запрос попадает в DispatcherServlet - единую входную дверь, через которую проходит всё. Дальше HandlerMapping подбирает метод-обработчик по пути и типу запроса - GET, POST и остальным. Потом резолверы аргументов наполняют параметры: @PathVariable берёт кусок из самого пути, @RequestParam - из строки запроса после вопросительного знака, @RequestBody - из тела. Потом выполняется твой метод, и HttpMessageConverter превращает возвращённый объект в JSON.

@RestController - это сокращение для @Controller плюс @ResponseBody: возвращённое значение становится телом ответа, без всякого слоя шаблонов. Маршруты вешаются аннотациями @GetMapping, @PostMapping и родственными.

// Путать @PathVariable и @RequestParam дорого, и коды разные. Я проверил на одном приложении: обязательный параметр запроса не передан - ответ 400; обращение по пути без нужного сегмента - 404. Ещё грабли из той же области: если проект собран без флага компилятора -parameters, Spring не знает имён параметров и падает с сообщением «Name for argument of type [java.lang.String] not specified». Сборки Spring Boot этот флаг ставят сами, ручные - нет.

DispatcherServlet
единая входная дверь: направляет запрос нужному методу
HttpMessageConverter
превращает тело запроса в объект и объект ответа в JSON

Валидация и единый формат ошибок

Вернёмся к замеру. Ограничения описываются прямо на полях объекта: @NotBlank, @Email, @Min и другие из стандарта проверки бинов. Но выполняться они начинают, только когда аргумент помечен @Valid. Тогда нарушение бросает MethodArgumentNotValidException, и клиент получает 400 с разбором по полям - у меня вышло «age: must be greater than or equal to 18; email: must be a well-formed email address; name: must not be blank». Все три нарушения сразу, без единого написанного if.

Без @Valid тот же запрос прошёл с кодом 200, и объект с пустым именем уехал дальше в приложение. Дыра тихая: ошибок нет, логи чистые, аннотации на месте - заметить можно только тестом.

Обрабатывают ошибки централизованно: класс с @RestControllerAdvice собирает обработчики @ExceptionHandler и отдаёт единый формат на всё приложение. Так у клиента один вид ошибки везде, вместо try-catch в каждом контроллере.

record UserDto(@NotBlank String name, @Email String email, @Min(18) int age) {}

@PostMapping("/checked")   UserDto a(@Valid @RequestBody UserDto d) { return d; }
@PostMapping("/unchecked") UserDto b(@RequestBody UserDto d)        { return d; }

// одно и то же кривое тело:
//   с @Valid  -> 400  age: must be greater than or equal to 18; email: ...; name: ...
//   без @Valid -> 200  {"name":"","email":"не-почта","age":15}
@Valid
включает проверку объекта по ограничениям на его полях
@RestControllerAdvice
общие обработчики исключений: единый формат ошибки на всё приложение

Почему наружу отдают не объект базы

Entity - это класс, отображённый на таблицу базы: его полями управляет ORM. DTO (data transfer object) - отдельный объект под передачу данных, в нём ровно те поля, которые нужны клиенту. На границе приложения отдают именно DTO, и вот почему.

Первое - ленивые связи. ORM (object-relational mapping, отображение объектов на таблицы) подтягивает связанные объекты не сразу, а при первом обращении к ним, и делает это в рамках открытой сессии - соединения с базой, которое живёт на время операции. Если превращение объекта в JSON происходит уже после закрытия сессии, прилетает LazyInitializationException. Вариант похуже - связи всё-таки подгрузятся, и один запрос вытянет пол-базы. Второе - утечка: наружу уедут все поля, включая служебные флаги и то, чего клиенту знать не надо. Третье - связность: форма ответа окажется прибита к схеме таблицы, и любая миграция базы сломает контракт для клиентов.

// Контроллеру можно разобрать запрос, проверить его, преобразовать и позвать сервис. Бизнес-логику в него не кладут: её не переиспользовать и не покрыть быстрым тестом. Коды ответов стоит выбирать осмысленно: 201 с заголовком Location на создание, 204 на удаление, 400, 404 и 409 по смыслу. А проверять контроллеры удобно через MockMvc - он гоняет запросы через всю цепочку Spring MVC без запуска настоящего сервера, ровно так сняты замеры этого урока.

entity / DTO
объект, отображённый на таблицу / объект для передачи данных наружу
MockMvc
вызов контроллеров в тесте без поднятия настоящего сервера

Как отвечать: «Почему нельзя отдавать entity из контроллера?»

Три причины, и все кусаются на проде. Первая - ленивые связи: сериализация в JSON происходит уже после того, как сессия базы закрыта, и обращение к недогруженной связи даёт LazyInitializationException, а если сессия ещё открыта, то один запрос может жадно вытянуть пол-базы. Вторая - утечка данных: наружу уходят все поля, включая внутренние флаги и связи, которые клиенту знать не положено. Третья - связность: форма JSON намертво прибивается к схеме таблицы, и любая миграция базы ломает контракт API. Поэтому на границе я всегда преобразую entity в DTO - ровно те поля, что нужны клиенту, и контракт живёт отдельно от хранилища. Заодно это защищает от обратной проблемы: клиент не сможет прислать поле, которое ему менять нельзя.

Почему это сильный ответ: три разноплановые причины - техническая, безопасность и архитектурная связность, - плюс замечание про обратное направление, о котором обычно забывают.

На чём валят

  • Забыть @Valid. Ограничения на полях есть, проверки нет: у меня кривое тело прошло с кодом 200.
  • Отдавать entity наружу. Ленивые связи, лишние поля и контракт, приколоченный к схеме базы.
  • Путать @PathVariable и @RequestParam. Пропущенный обязательный параметр запроса даёт 400, неверный путь - 404.
  • Ловить исключение в контроллере и возвращать 200 с телом об ошибке. Клиент считает, что всё прошло успешно.
  • Бизнес-логика прямо в контроллере. Её не переиспользовать в другом входе и не покрыть тестом без веб-слоя.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 16, остальные разбираются в тренажёре.

  1. #spring_mvc_rest1 / 5
    Что делает DispatcherServlet в Spring MVC?
    A)Единая точка входа: маршрутизирует запросы к контроллерам и формирует ответ
    B)Хранит все бины приложения и внедряет зависимости — это и есть контейнер Spring под другим именем
    C)Обслуживает только статические файлы (картинки, CSS, JS), а динамику отдаёт напрямую контроллерам
    D)Компилирует HTML-шаблоны в байткод при старте, чтобы отдавать готовые страницы без обработки запроса
    показать ответ и разбор
    +A)Единая точка входа: маршрутизирует запросы к контроллерам и формирует ответ

    // разбор: DispatcherServlet — реализация паттерна Front Controller: ЕДИНАЯ точка входа для всех HTTP-запросов. Он делегирует: HandlerMapping находит нужный контроллер/метод по URL, вызывает его через HandlerAdapter, применяет HandlerInterceptor'ы, а результат (ViewResolver для страниц или message-конвертер для REST/JSON) превращает в HTTP-ответ. Так вся веб-обвязка (парсинг, маршрутизация, сериализация, обработка ошибок) централизована, а контроллеры остаются простыми. Это ядро Spring MVC.

  2. #spring_mvc_rest2 / 5
    Чем @RestController отличается от @Controller?
    A)@RestController обрабатывает только GET-запросы, а @Controller — все остальные HTTP-методы
    B)@RestController = @Controller + @ResponseBody: возвращаемое сериализуется в тело ответа
    C)@Controller создаёт REST-эндпоинты, а @RestController рендерит серверные HTML-страницы через шаблоны
    D)Это синонимы; @RestController оставлен лишь для совместимости со старыми версиями Spring MVC
    показать ответ и разбор
    +B)@RestController = @Controller + @ResponseBody: возвращаемое сериализуется в тело ответа

    // разбор: @Controller — веб-контроллер: по умолчанию возвращаемая строка трактуется как ИМЯ ПРЕДСТАВЛЕНИЯ (view) для рендеринга страницы. @RestController — это @Controller + @ResponseBody на уровне класса: возвращаемый объект каждого метода СЕРИАЛИЗУЕТСЯ (обычно в JSON через Jackson) прямо в тело ответа — то, что нужно для REST API. То есть @RestController избавляет от @ResponseBody на каждом методе. Для страниц/шаблонов берут @Controller, для API — @RestController.

  3. #spring_mvc_rest3 / 5
    Чем различаются @PathVariable, @RequestParam и @RequestBody?
    A)Все три извлекают данные из тела запроса, отличаясь лишь форматом: JSON, XML и обычный текст
    B)@PathVariable читает заголовок, @RequestParam — куку, @RequestBody — параметр строки запроса URL
    C)Часть пути URL, параметр запроса (query), и тело запроса соответственно
    D)Это устаревшие аннотации; в современном Spring все параметры передаются одним общим @Param
    показать ответ и разбор
    +C)Часть пути URL, параметр запроса (query), и тело запроса соответственно

    // разбор: @PathVariable извлекает часть URL по шаблону: /users/{id} → id. @RequestParam берёт параметр строки запроса или формы: /search?q=java → q (можно required/defaultValue). @RequestBody десериализует ТЕЛО запроса (обычно JSON) в объект — для POST/PUT с полезной нагрузкой. Плюс @RequestHeader (заголовки) и @CookieValue (куки). Правильный выбор аннотации — часть корректного REST-дизайна: идентификатор ресурса в пути, фильтры/пагинация в query, полезные данные — в теле.

  4. #spring_mvc_rest4 / 5
    Зачем возвращать ResponseEntity вместо просто объекта?
    A)ResponseEntity автоматически кэширует ответ на стороне клиента, снижая нагрузку на сервер повторами
    B)ResponseEntity — рекомендуемый способ вернуть JSON; обычный объект Spring сериализует хуже
    C)ResponseEntity включает валидацию входных данных, отклоняя некорректные запросы до контроллера
    D)Чтобы явно управлять статус-кодом, заголовками и телом ответа
    показать ответ и разбор
    +D)Чтобы явно управлять статус-кодом, заголовками и телом ответа

    // разбор: Если метод возвращает просто объект, Spring отдаёт его с кодом 200 и минимальными заголовками. ResponseEntity<T> даёт ПОЛНЫЙ контроль над HTTP-ответом: явный статус (201 Created, 404 Not Found, 204 No Content), заголовки (Location для созданного ресурса, Cache-Control), и тело — всё вместе. Это важно для корректного REST: POST должен вернуть 201 + Location, отсутствие ресурса — 404, удаление — 204. Альтернатива — @ResponseStatus на методе/исключении для фиксированного кода. ResponseEntity гибче, когда код зависит от логики.

  5. #spring_mvc_rest5 / 5
    Как централизованно обрабатывать исключения контроллеров в Spring MVC?
    A)@RestControllerAdvice с методами @ExceptionHandler для нужных исключений
    B)Обернуть тело каждого метода контроллера в try-catch и вручную формировать ответ на ошибку
    C)Настроить глобальный фильтр сервлета, который перехватывает исключения до попадания в контроллер
    D)Никак: непойманное исключение контроллера отдаётся клиенту как стандартная HTML-страница 500
    показать ответ и разбор
    +A)@RestControllerAdvice с методами @ExceptionHandler для нужных исключений

    // разбор: @RestControllerAdvice (или @ControllerAdvice) — класс с методами @ExceptionHandler, который ЦЕНТРАЛИЗОВАННО ловит исключения, брошенные любыми контроллерами, и превращает их в аккуратные HTTP-ответы (код + тело с описанием ошибки). Так убирают дублирующий try-catch и получают единый формат ошибок API. Внутри маппят: EntityNotFound → 404, валидацию → 400 и т.д. В Spring Boot 3 также есть стандарт ProblemDetail (RFC 7807) для тела ошибки. Это идиоматичная альтернатива ручной обработке в каждом методе.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.