@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 ендпоінтів уже видно два симптоми: об’єкт якось зʼявляється в параметрі методу, а зламаний JSON може відпасти ще до сервісу. Отже, між HTTP body і Java-кодом живе окремий механізм. Саме його й розбираємо.

HTTP body і Java-об’єкти: потрібен перекладач

Усередині контролера ви працюєте з типами Java: String, UUID (або String як UUID), вашими класами тощо. А HTTP-запит зовні — це, грубо кажучи, текст і байти: рядок запиту, заголовки та, за потреби, тіло. Навіть якщо body виглядає як «гарний 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 запускає потрібну частину ланцюжка обробки: до входу у ваш метод потрібно зробити дві речі. Спершу — прочитати тіло запиту (а body — це байти). Потім — перетворити його на Java-об’єкт указаного типу. Для цього Spring вибирає відповідний HttpMessageConverter і просить його прочитати body та повернути об’єкт.

Важлива інженерна думка тут така: коли ваш метод контролера починає виконуватися, об’єкт із @RequestBody уже створений. Якщо створення не вдалося (наприклад, тіло не читається або не конвертується), то метод контролера може не виконатися зовсім. Це й пояснює, чому зламаний JSON ламає запит ще до сервісної логіки: ви навіть не дійшли до сервісу, бо не вдалося зібрати параметр методу.

Це розуміння одразу зменшує кількість дивних рішень із серії «а давайте в сервісі перевіримо, що JSON валідний». Ні, не треба. Якщо ви дійшли до сервісу, то JSON уже принаймні розпарсився в об’єкт.

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

На вході ми вже розібралися: @RequestBody запускає читання body. Але в конвертації є і зворотний бік — запис відповіді. У @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 вибирає метод контролера, готує аргументи, викликає метод, бере результат, формує відповідь. Конвертер стоїть у двох місцях: під час читання body і під час запису body.

Нижче — схема у форматі, який зручно згадувати, коли щось «не працює»:

flowchart TD
    %% Конвертери підключаються лише там, де ми справді читаємо або пишемо body
    A["HTTP-запит"] --> B["Spring MVC: шукаємо метод-обробник"]
    B --> C{"Потрібно зібрати аргументи методу?"}
    C -->|"є @RequestBody"| D["HttpMessageConverter: читання body -> Java-об’єкт"]
    C -->|"немає body"| E["Інші аргументи: path/query тощо"]
    D --> F["Метод контролера"]
    E --> F["Метод контролера"]
    F --> G{"Є body у відповіді?"}
    G -->|"так, повертаємо об’єкт"| H["HttpMessageConverter: Java-об’єкт -> 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 тіла запиту на створення задачі (надходить із тіла запиту)
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": "Виправити API",
  "description": "Пояснити конвертацію body"
}

то ваш метод взагалі не побачить JSON як текст. Він побачить уже створений об’єкт. Це головний психологічний злам: контролер не парсить JSON — він працює з результатом парсингу.

Мініприклад №2: DELETE і 204 No Content — відповідь є, body немає, і це нормально

Другий корисний приклад — сценарій видалення. Він хороший тим, що на ньому відразу видно: HTTP-відповідь і тіло відповіді — не одне й те саме. І, до речі, ваш контролер теж не зобов’язаний повертати JSON «бо REST». Іноді найчесніша форма відповіді — відсутність body.

Ось типовий 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, то у відповіді справді немає content. Не «порожній JSON», не {}, а справді відсутність body.

Мініприклад №3: GET /ping і String — конвертація відповіді буває не лише JSON

Щоб остаточно прибрати ілюзію «конвертер = JSON», корисно побачити приклад зі звичайним текстом. Тому що String в HTTP body теж хтось має записати. І у Spring MVC це знову робиться через HttpMessageConverter — просто іншого типу.

Ось найпростіший ендпоінт:

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, сервіс просто не викликався. Це ключова ознака: конвертація body відбувається до входу в метод контролера. Отже, частина помилок стається раніше за вас — і саме це ми хочемо вміти розпізнавати.

Помилка №4: намагатися читати body запиту вручну за наявності @RequestBody.
Іноді хочеться «про всяк випадок» залогувати body або прочитати його ще раз. Але тіло запиту — це потік, і якщо ви його прочитали вручну, конвертеру вже може бути нічого читати. Результат — дивні помилки, які виглядають як «Spring не бачить JSON». На практиці правило просте: або ви довіряєте @RequestBody і конвертеру, або йдете в низькорівневий світ ручної обробки. У навчальному проєкті та в більшості реальних REST API перший шлях майже завжди правильніший.

Помилка №5: вважати, що Spring «сам знає все» і не думати про етапи.
Слово «автоконфігурація» іноді занадто розслабляє. Так, у Spring Boot багато налаштовано за замовчуванням, і це зручно. Але інженерно важливо пам’ятати: навіть якщо ви нічого не конфігурували, механізм усе одно існує. Spring MVC використовує HttpMessageConverter для перетворення запитів і відповідей, і там є налаштування за замовчуванням, зокрема автоматичне перетворення об’єктів у JSON через Jackson і робота з рядками.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ