@RequestBody и HttpMessageConverter

Spring REST & MVC
8 уровень , 0 лекция
Открыта

1. Откуда берётся «магия» @RequestBody

Когда вы впервые пишете public Something create(@RequestBody Something body), мозг честно пытается упростить картину: «Ну, @RequestBody — значит Spring как-то прочитал JSON и сделал объект. Окей». Проблема в том, что без понимания промежуточного шага вы начинаете неправильно диагностировать ошибки, путать причины и последствия и, в какой-то момент, лечить «сломанный JSON» проверками в сервисе. Это примерно как лечить простуду настройкой Wi‑Fi.

Если описать ощущение новичка, оно звучит так: «Я отправил JSON. В контроллере появился объект. Значит, аннотация парсит JSON». И тут важно остановиться и сказать: аннотация не парсит. Аннотация подсказывает фреймворку, откуда брать данные. А парсить должен конкретный компонент, который умеет превращать HTTP-сообщение в Java-объект и наоборот.

Эта лекция — про «снятие заклинания». Мы не уходим в исходники Spring (это отдельный вид спорта), но даём инженерную картину, достаточную, чтобы дальше вы не воспринимали MVC как «сборник аннотаций с рандомным поведением».

После обычных POST/PUT/DELETE endpoint’ов уже видно два симптома: объект как-то появляется в параметре метода, а сломанный JSON может отвалиться ещё до сервиса. Значит, между HTTP body и Java-кодом живёт отдельный механизм. Его и разбираем.

HTTP body и Java-объекты: нужен переводчик

Внутри контроллера вы работаете с типами Java: String, UUID (или String как UUID), вашими классами и так далее. А HTTP-запрос снаружи — это, грубо говоря, текст и байты: строка запроса, заголовки, и опционально тело. Даже если тело выглядит как «красивый JSON», для сервера это просто последовательность байт, которую ещё надо прочитать из входного потока и интерпретировать.

Тут очень помогает бытовая аналогия: представьте, что клиент отправил вам посылку без распаковки. Внутри посылки лежит аккуратно сложенный конструктор LEGO (ваш будущий Java-объект), но пока вы не откроете коробку и не соберёте детали по инструкции, у вас в руках не появится «модель». HTTP body — это коробка с деталями. Java-объект — это собранная модель. И кто-то должен выступить «сборщиком по инструкции».

В Spring MVC роль такого «сборщика/переводчика» выполняет механизм HttpMessageConverter. Он отвечает за конвертацию HTTP запросов и ответов, причём не только JSON: строка "ok" в ответе — это тоже body, и её тоже надо записать в HTTP-ответ.

2. Что делает HttpMessageConverter

Если убрать лишние детали, HttpMessageConverter — это компонент Spring MVC, который умеет читать тело HTTP-запроса и превращать его в Java-объект, а также писать тело HTTP-ответа, превращая Java-объект в байты ответа. Это двусторонний мост: вход и выход.

В официальной документации Spring Boot/Spring MVC это сформулировано прямо: Spring MVC использует интерфейс HttpMessageConverter для преобразования HTTP-запросов и ответов. При этом «из коробки» существуют разумные дефолты: например, объекты можно автоматически преобразовывать в JSON (обычно с помощью Jackson), а строки — кодируются в UTF‑8.

Почему это вынесено в отдельный механизм, а не «зашито» в @RequestBody или @RestController? Потому что контроллер — это часть контракта и оркестрации (принять данные, вызвать сервис, вернуть ответ), а конвертация — это инфраструктурная работа. Контроллеру не нужно знать, как именно читать InputStream, какой JSON-парсер используется, как кодировать строку или как сериализовать объект. Если бы контроллер знал, он бы быстро превратился в монстра, который умеет «всё» — и поддерживать такого монстра было бы больно.

Ещё один практический плюс: один и тот же контроллерный метод может работать с разными форматами (в теории), а конкретные детали преобразования делегируются конвертерам. Но детали выбора конвертера мы сегодня сознательно не копаем — пока важно увидеть, что конвертер существует и что он стоит между HTTP body и вашим параметром/ответом.

3. Роль @RequestBody в Spring MVC

Теперь связываем знакомую аннотацию с новой картиной мира. @RequestBody говорит Spring MVC примерно следующее: «Этот параметр нужно получить из тела HTTP-запроса». Всё. Не «распарси JSON», не «проверь поля», не «сделай красиво». Просто: источник значения — body.

Дальше уже Spring MVC запускает нужную часть pipeline: до входа в ваш метод нужно сделать две вещи. Сначала нужно прочитать тело запроса (а тело — это байты). Затем нужно превратить это тело в Java-объект указанного типа. Для этого Spring выбирает подходящий HttpMessageConverter и просит его «прочитать» body и вернуть объект.

Важная инженерная мысль здесь такая: когда ваш метод контроллера начинает выполняться, @RequestBody-объект уже создан. Если создание не получилось (например, тело не читается или не конвертируется), то метод контроллера может не выполниться вовсе. Это и есть причина, почему сломанный JSON «ломает запрос раньше сервисной логики»: вы даже не дошли до сервиса, потому что не получилось собрать параметр метода.

Это понимание сразу снижает количество странных решений из серии «а давайте в сервисе проверим, что JSON валидный». Нет, не давайте. Если вы дошли до сервиса, то JSON уже как минимум распарсился в объект.

4. Конвертация при записи ответа

На входе мы уже разобрались: @RequestBody запускает чтение тела. Но у конвертации есть и обратная сторона — запись ответа. В @RestController вы часто возвращаете объект (или ResponseEntity), и Spring должен сделать из этого объекта HTTP-ответ: выставить статус, заголовки и (если нужно) записать body.

И вот тут важно разделить две близкие, но разные идеи. HTTP-ответ существует всегда: даже если произошла ошибка или вы вернули 204. Но body в ответе может отсутствовать. Пример с 204 No Content — прямо идеальная иллюстрация: ответ есть, статус есть, заголовки могут быть, а body нет. Значит, «шаг записи body» просто не выполняется, потому что нечего записывать.

Официальная документация говорит об этом не так прямо, но логика следует из самой модели: HttpMessageConverter участвует в преобразовании HTTP requests/responses, то есть работает там, где есть body для чтения/записи.

Поэтому в голове полезно держать двунаправленную схему: входная конверсия и выходная конверсия — независимые части. Можно прочитать request body и не писать response body (например, вернуть 204). Можно не читать request body (например, GET), но писать response body (вернуть объект в JSON). И можно не делать ни того, ни другого (редко, но возможно — например, пустой 204 на DELETE без тела запроса).

5. HttpMessageConverter в MVC-цепочке

Чтобы дальше не тонуть в деталях, зафиксируем «достаточно точную» схему. Мы не будем перечислять все внутренние классы Spring MVC, но важно понять порядок событий: запрос приходит, Spring выбирает метод контроллера, подготавливает аргументы, вызывает метод, берёт результат, формирует ответ. Конвертер стоит в двух местах: на чтении тела и на записи тела.

Ниже — схема в формате, который удобно вспоминать, когда что-то «не работает»:

flowchart TD
    %% Конвертеры подключаются только там, где реально читаем/пишем body
    A["HTTP request"] --> B["Spring MVC: ищем handler method"]
    B --> C{"Нужно собрать аргументы метода?"}
    C -->|"есть @RequestBody"| D["HttpMessageConverter: read body -> Java object"]
    C -->|"нет body"| E["Другие аргументы: path/query/etc"]
    D --> F["Controller method"]
    E --> F["Controller method"]
    F --> G{"Есть body в ответе?"}
    G -->|"да, возвращаем объект"| H["HttpMessageConverter: Java object -> response body"]
    G -->|"нет, 204/void"| I["Формируем ответ без body"]

Заметьте, что мы пока не обсуждаем, какой именно конвертер будет выбран. Это отдельная тема. Сейчас наша цель — увидеть, что @RequestBody и «возврат объекта» подключают конкретный механизм, а не работают «заклинанием аннотации».

6. Мини-примеры конвертации

Мини-пример №1: POST /api/v1/tasks — два направления конвертации

Самый наглядный пример — create-endpoint: он почти всегда читает JSON из request body и почти всегда возвращает JSON в response body. Это как двусторонняя дорога: туда и обратно. И это ровно тот случай, где без HttpMessageConverter ваш @RestController был бы вынужден вручную читать поток запроса и вручную писать поток ответа (то есть контроллер превратился бы в мини-сервлет).

Начнём с минимальных «тел» запрос/ответ. Я намеренно делаю их примитивными, чтобы мы сфокусировались на механике, а не на красоте модели.

// DTO тела запроса на создание задачи (приходит из request body)
class CreateTaskBody {
    // Заголовок задачи (ожидаем поле "title" в JSON)
    public String title;

    // Описание задачи (ожидаем поле "description" в JSON)
    public String description;
}
// DTO тела ответа (уйдёт в response body)
class TaskResponseBody {
    // Идентификатор задачи (для примера строкой)
    public String id;

    // Заголовок задачи
    public String title;
}

А вот теперь — сам контроллерный метод. Он выглядит очень «обычно», и именно в этом его сила: он не содержит ни одной строчки про JSON.

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class TaskController {

    @PostMapping("/api/v1/tasks")
    public TaskResponseBody create(@RequestBody CreateTaskBody body) {
        // На этом этапе body уже НЕ JSON-строка:
        // Spring MVC уже вызвал HttpMessageConverter и собрал объект CreateTaskBody
        TaskResponseBody response = new TaskResponseBody();

        // В реальном проекте id обычно создаёт БД или сервис, здесь — заглушка
        response.id = "t-1";

        // Просто копируем данные из DTO запроса в DTO ответа
        response.title = body.title;

        // Возвращаем объект: дальше Spring MVC снова подключит HttpMessageConverter,
        // но уже для записи response body
        return response;
    }
}

Что происходит в реальности (но без мистики): Spring MVC получает HTTP-запрос. Видит, что аргумент метода помечен @RequestBody. Значит, нужно прочитать body, превратить его в CreateTaskBody и только потом вызвать create(...). После return Spring MVC видит, что метод вернул объект (не void), и должен записать этот объект в response body. Для этого снова вызывается конвертер, только уже «в обратную сторону».

Если вы теперь отправите запрос вроде:

# Пример запроса клиента (то, что реально уходит по сети)
POST /api/v1/tasks HTTP/1.1
Content-Type: application/json

{
  "title": "Fix API",
  "description": "Explain body conversion"
}

то ваш метод вообще не увидит JSON как текст. Он увидит уже созданный объект. Это главный психологический перелом: контроллер не парсит JSON — он работает с результатом парсинга.

Мини-пример №2: DELETE и 204 No Content — ответ есть, body нет, и это нормально

Второй полезный пример — delete-сценарий. Он хорош тем, что на нём сразу видно: HTTP-ответ и тело ответа — не одно и то же. И, кстати, ваш контроллер тоже не обязан возвращать JSON «потому что REST». Иногда самая честная форма ответа — отсутствие тела.

Вот типичный DELETE:

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;

@DeleteMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<Void> delete(@PathVariable String taskId) {
    // Контракт: мы ничего не возвращаем в body, только статус 204
    // Поэтому этап "записать response body через HttpMessageConverter" тут не нужен
    return ResponseEntity.noContent().build();
}

Здесь метод возвращает «ответ без тела». Spring сформирует HTTP-ответ со статусом 204, но шаг «записать объект в response body» отсутствует. И это важно помнить, когда вы диагностируете «почему клиент не видит JSON». Иногда он его не видит, потому что вы его честно не отправили.

Этот пример ещё и психологически лечит зависимость от «всегда возвращай JSON». Нет. Возвращайте то, что соответствует контракту. Если контракт говорит 204 No Content, то в ответе действительно нет контента. Не «пустой JSON», не {}, а реально отсутствие body.

Мини-пример №3: GET /ping и String — конвертация ответа бывает не только JSON

Чтобы окончательно убрать иллюзию «конвертер = JSON», полезно увидеть пример с обычным текстом. Потому что String в HTTP body тоже кто-то должен записать. И в Spring MVC это снова делается через HttpMessageConverter — просто другого типа.

Вот самый простой endpoint:

import org.springframework.web.bind.annotation.GetMapping;

@GetMapping(path = "/ping", produces = "text/plain")
public String ping() {
    // Возвращаем строку, но в HTTP это станет response body (набор байт, обычно UTF-8).
    // Эту запись тоже сделает HttpMessageConverter, просто не JSON-конвертер.
    return "ok";
}

С точки зрения контроллера это просто строка. Но с точки зрения HTTP это body ответа: набор байт в определённой кодировке (как правило, UTF‑8). Spring MVC как раз и содержит набор «разумных дефолтов», включая работу со строками и базовыми форматами.

Этот пример полезен ещё и методически: когда вы видите ответ "ok", вы уже не можете объяснить его «Джексон парсит JSON». Тут не JSON. Значит, должна существовать более общая абстракция. И она существует: message converters.

7. Типичные ошибки: @RequestBody и конвертеры

Когда вы только начинаете работать со Spring MVC, ошибки чаще всего не в коде, а в ментальной модели. И это нормально: мозг пытается всё представить как прямой вызов метода, а на самом деле до вызова метода происходит целая подготовка. Ниже — самые частые грабли именно вокруг темы «где конвертация и почему она не магия».

Ошибка №1: думать, что @RequestBody сам парсит JSON.
Из-за этой идеи люди начинают «чинить аннотацию»: добавлять лишние преобразования в сервисе, писать ручной ObjectMapper внутри контроллера, читать HttpServletRequest.getInputStream() — и всё ради того, чтобы «помочь Spring». На деле @RequestBody — лишь маркер источника, а работу делает HttpMessageConverter. Если вы приняли объект в параметр — он уже результат конвертации, и дальше вы обязаны мыслить не «JSON-строкой», а структурой данных.

Ошибка №2: ожидать, что body есть всегда и в запросе, и в ответе.
После пары успешных POST легко привыкнуть, что «REST = JSON везде». Потом вы пишете DELETE, возвращаете 204, и клиент «не видит ответ». А он и не должен видеть body: 204 No Content означает отсутствие тела. Важно разделять «HTTP-ответ» и «тело HTTP-ответа»: статус и заголовки есть, а body может не быть, и это корректный контракт.

Ошибка №3: смешивать момент конвертации и момент бизнес-логики.
Очень частая боль — когда запрос падает, а вы лезете дебажить сервис. Но если проблема была в чтении тела, сервис просто не вызывался. Это ключевой признак: конвертация body происходит до входа в метод контроллера. Значит, часть ошибок случается «раньше вас» — и это именно то, что мы хотим уметь распознавать.

Ошибка №4: пытаться читать тело запроса вручную при наличии @RequestBody.
Иногда хочется «на всякий случай» залогировать body или прочитать его ещё раз. Но тело запроса — это поток, и если вы его прочитали вручную, конвертеру уже может быть нечего читать. Результат — странные ошибки, которые выглядят как «Spring не видит JSON». На практике правило простое: либо вы доверяете @RequestBody и конвертеру, либо вы идёте в низкоуровневый мир ручной обработки. В учебном проекте и в большинстве реальных REST API первый путь почти всегда правильнее.

Ошибка №5: считать, что Spring “сам знает всё”, и не думать об этапах.
Слово «автоконфигурация» иногда расслабляет слишком сильно. Да, в Spring Boot много настроено «по умолчанию», и это удобно. Но инженерно важно помнить: даже если вы ничего не конфигурировали, механизм всё равно существует. Spring MVC использует HttpMessageConverter для преобразования запросов и ответов, и там есть дефолтные правила, включая автоматическое преобразование объектов в JSON через Jackson и работу со строками.

1
Задача
Spring REST & MVC, 8 уровень, 0 лекция
Недоступна
Эхо JSON-сообщения через `@RequestBody`
Эхо JSON-сообщения через `@RequestBody`
1
Задача
Spring REST & MVC, 8 уровень, 0 лекция
Недоступна
Текстовый ответ и ответ без тела
Текстовый ответ и ответ без тела
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ