JavaRush /Курси /Spring REST & MVC /Вибір конвертера в Spring MVC

Вибір конвертера в Spring MVC

Spring REST & MVC
Рівень 8 , Лекція 1
Відкрита

1. Роль вибору конвертера в Spring MVC

Те, що між HTTP body і вашим параметром стоїть HttpMessageConverter, уже знімає частину магії. Але майже відразу постає більш практичне запитання: чому один запит спокійно читається, а інший падає з 415 або 406? Тому що конвертер треба не просто мати — його ще треба правильно вибрати.

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

У Spring MVC є набір HttpMessageConverterʼів. Кожен із них уміє працювати з певним набором media types (наприклад, application/json або text/plain) і з певним набором Java-типів (наприклад, String, byte[] або «будь-який об’єкт»). Коли надходить запит, Spring має зіставити одразу кілька сигналів: що каже Content-Type, чого хоче клієнт через Accept, що ви оголосили в consumes/produces, і які Java-типи стоять у параметрах та в return.

Нам важливо зафіксувати одну думку: конвертер вибирається не за настроєм Spring, а за правилами. І ці правила дають дуже практичну користь. Щойно ви їх розумієте, помилки 415/406 перестають бути випадковими, а перетворюються на цілком діагностовані ситуації: «у нас не збіглися очікування щодо формату» або «тип значення, що повертається, не відповідає тому, чого попросив клієнт».

Щоб далі не плутатися, тримайте в голові мінікарту сигналів — вона нам ще знадобиться:

Сигнал Де живе На що впливає Якщо помилилися — зазвичай буде
Content-Type заголовок запиту чим є request body 415 Unsupported Media Type
consumes @PostMapping/@PutMapping/... що метод вміє читати 415
Accept заголовок запиту що клієнт хоче отримати 406 Not Acceptable
produces @GetMapping/@PostMapping/... що метод обіцяє віддавати 406
Java-тип параметра сигнатура методу у що перетворювати request body помилка читання / несумісність
Java-тип відповіді return як записувати response body 406 або дивний Content-Type

Ця схема вже пояснює, звідки беруться 415 і 406. Але не слід зводити її до думки «Spring сам повністю вміє JSON». Вибір конвертера — це один шар. Сам JSON-шлях усередині вибраного конвертера — інший, і тут окремо важлива роль Jackson.

2. Вхід: Content-Type, consumes, тип параметра

На вході все просто: HTTP-запит містить байти. Але байти самі по собі нічого не означають, доки ми не знаємо, як їх читати. Саме для цього клієнт має сказати серверу, що саме лежить у body, — через заголовок Content-Type. Це схоже на ситуацію, коли вам передають флешку й кажуть: «Там документ». Який саме? PDF, Word, архів? Файл “final_final_v7(1).docx”? Без уточнення серверу лишається тільки здогадуватися, а Spring, як пристойний інженер, здогадуватися не любить.

Як Spring обробляє @RequestBody

Якщо в методі є параметр із @RequestBody, Spring розуміє: «Мені потрібно взяти body і отримати Java-об’єкт такого-то типу». Далі вмикається підбір конвертера: потрібен такий HttpMessageConverter, який одночасно:

1) уміє читати цей media type (наприклад, application/json),
2) уміє прочитати його в цей Java-тип (наприклад, CreateTaskBody).

І ось тут зʼявляється важливий зв’язок із тим, що ми вже писали в контролерах: consumes — це не «прикраса», а публічна обіцянка вашого endpointʼа.

Подивімося на мініприклад у стилі нашого проєкту. Для простоти в нас будуть такі класи тіла запиту та відповіді:

// DTO для тіла запиту (те, що клієнт надсилає в JSON)
class CreateTaskBody {
    // Заголовок задачі
    public String title;

    // Опис задачі
    public String description;
}
// DTO для тіла відповіді (те, що сервер повертає клієнту)
class TaskResponseBody {
    // Ідентифікатор створеної задачі
    public String id;

    // Заголовок задачі (як його побачить клієнт)
    public String title;
}

А тепер метод контролера, який явно фіксує, що приймає JSON і повертає JSON:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks") // Базовий шлях для задач
class TaskController {

    @PostMapping(
            consumes = MediaType.APPLICATION_JSON_VALUE, // Явно говоримо: читаємо тільки JSON
            produces = MediaType.APPLICATION_JSON_VALUE  // Явно говоримо: віддаємо тільки JSON
    )
    TaskResponseBody create(@RequestBody CreateTaskBody body) {
        // На цьому кроці Spring уже сконвертував request body (JSON) у Java-об’єкт CreateTaskBody
        TaskResponseBody response = new TaskResponseBody();

        // Для прикладу id "захардкодили": у реальному проєкті його видасть БД або сервіс
        response.id = "t-1";
        response.title = body.title;

        // На виході Spring знову вибере конвертер: із Java-об’єкта -> JSON
        return response;
    }
}

Тут consumes = application/json означає: «Цей метод готовий читати тіло запиту тільки як JSON». Якщо клієнт надішле Content-Type: text/plain, Spring не буде намагатися вгадати, що там насправді JSON-рядок. Він чесно скаже: «Я не розумію такий формат для цього endpointʼа» — і поверне 415 Unsupported Media Type.

Приклад правильного запиту (ручна перевірка)

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Виправити API",
  "description": "HttpMessageConverter — це не магія"
}

У цьому разі в Spring усе збігається: Content-Type каже «це JSON», метод consumes каже «я читаю JSON», параметр @RequestBody CreateTaskBody каже «перетвори JSON у CreateTaskBody», і Spring знаходить JSON-конвертер.

Типова помилка №1: забули Content-Type

Одна з найпоширеніших проблем у новачків: надіслали JSON, але не поставили Content-Type: application/json. Тоді для сервера body перетворюється на «якісь байти невідомого походження».

Швидше за все, ви побачите 415 Unsupported Media Type (особливо якщо в методі вказано consumes = application/json). Це не «Spring зламався» — це сервер каже: «Мені потрібно зрозуміти формат, щоб вибрати конвертер, а ви не дали мені формального сигналу».

Типова помилка №2: Content-Type не збігається з реальним вмістом

Буває й навпаки: Content-Type: application/json, а в body лежить щось на кшталт title=Fix+API&description=... (формат форми) або просто шматок тексту без JSON-структури. Тоді Spring чесно спробує прочитати JSON, але вже на етапі читання body вилетить помилка: JSON некоректний. Важливо: помилка з’являється до входу в метод контролера, тому що конвертація — це «вхідна брама», а не частина бізнес-логіки.

3. Вихід: Accept, produces, тип відповіді

Тепер подивімося на вихід. Ви написали return response; — але клієнт же не живе у світі Java-об’єктів. Клієнт живе у світі байтів HTTP-відповіді. Отже, Spring знову має вибрати HttpMessageConverter, тільки вже для запису відповіді. І вибір знову робиться не «за замовчуванням і назавжди», а за сигналами. Найголовніший сигнал від клієнта на виході — це заголовок Accept.

Якщо Content-Type відповідає на запитання «що я вам надіслав у request body», то Accept відповідає на запитання «в якому форматі я хочу отримати відповідь».

produces — це не «декорація», а ваш контракт

Коли ви пишете produces = application/json, ви фіксуєте публічну обіцянку: «Відповідь буде в JSON». Це особливо корисно в JSON-first API, тому що ви перетворюєте «ну ми начебто JSON віддаємо» на суворий контракт.

Якщо клієнт надіслав Accept: application/xml, а ваш метод оголошено як produces = application/json, то Spring не вигадуватиме XML на коліні. Він відповість 406 Not Acceptable: «Я не можу задовольнити ваш запит за форматом відповіді».

Щоб це було наочно, порівняймо два запити до одного й того самого endpointʼа.

GET http://localhost:8080/api/v1/tasks/t-1
Accept: application/json
GET http://localhost:8080/api/v1/tasks/t-1
Accept: application/xml

Якщо ваш метод робить produces = application/json, другий запит із високою ймовірністю завершиться 406. І це добра новина: клієнт не отримуватиме невідомо що, він отримає чесний сигнал, що формати не узгоджені.

Якщо Accept не надіслали

Для новачків це важливий момент: Accept часто буває неявним. Наприклад, багато HTTP-клієнтів надсилають Accept: */* (тобто «я прийму що завгодно») або взагалі нічого не надсилають. У таких випадках Spring вибирає те, що вміє, орієнтуючись на produces (якщо він є), а якщо produces не вказано — на свої розумні значення за замовчуванням і доступні конвертери.

У навчальному JSON-first проєкті добра дисципліна — усе-таки явно фіксувати produces = application/json у JSON endpointʼів, тому що це зменшує сюрпризи й робить контракт читабельним навіть для людини, яка дивиться тільки на код контролера.

4. Як Java-тип впливає на вибір: String, byte[], Void, об’єкт

Якщо заголовки та consumes/produces — це «форматні сигнали», то Java-типи в сигнатурі — це «сигнали наміру». Spring не вміє читати ваші думки, але він уміє читати ваш метод. І іноді сам метод підказує Spring: «не треба мені JSON-об’єкт, дай мені просто рядок», або навпаки: «мені потрібен об’єкт, розпарсь, будь ласка».

Вхід: @RequestBody String і @RequestBody CreateTaskBody — різні світи

З погляду HttpMessageConverterʼів це принципово різні запити.

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

public void create(@RequestBody String rawBody) {
    // Spring вибере конвертер, який може прочитати тіло як текст
    // У rawBody опиниться «сирий» body, наприклад: {"title":"Виправити API"}
    // Важливо: це НЕ розпарсений об’єкт, це просто рядок
}

У такому разі Spring може вибрати текстовий конвертер і просто віддати вам сирий текст body. Якщо клієнт надіслав JSON, ви отримаєте рядок виду {"title":"Виправити API"}. Це іноді корисно для діагностики, але для нормального API майже завжди означає, що ви самі ускладнюєте собі життя: ви вручну робите те, що мав би зробити механізм конверсії.

public void create(@RequestBody CreateTaskBody body) {
    // Spring вибере JSON-конвертер і заповнить поля body з JSON
    // Тут body.title уже витягнуто з JSON
}

Тут ви кажете Spring: «мені потрібен саме об’єкт». Тоді має бути вибраний JSON-конвертер (за умови, що Content-Type — JSON).

Вихід: String — це майже завжди text/plain (і це нормально)

Із відповідями та сама історія. Візьмімо найпростіший endpoint «перевірити, що сервер живий»:

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

@GetMapping(path = "/api/v1/ping", produces = MediaType.TEXT_PLAIN_VALUE)
public String ping() {
    // Повертаємо String => Spring пише відповідь як звичайний текст (text/plain)
    return "ok";
}

Тут Spring бачить, що return — це String, і вибирає конвертер, який пише текст. Виходить чесний text/plain, і все добре.

Але тепер уявімо, що ви намагаєтесь «робити JSON», повертаючи рядок і виставляючи produces = application/json:

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

@GetMapping(path = "/api/v1/ping-json", produces = MediaType.APPLICATION_JSON_VALUE)
public String pingJson() {
    // Контракт каже: "Я віддаю JSON", але фактично ми віддаємо просто рядок
    // "ok" без лапок — це невалідний JSON (валідний JSON-рядковий літерал був би "ok")
    return "ok";
}

З великою ймовірністю ви отримаєте відповідь із Content-Type: application/json, але body буде просто ok. А ok — це невалідний JSON (валідний JSON-рядковий літерал виглядав би як "ok"). Для клієнта це може виглядати як «сервер обіцяв JSON, але прислав щось дивне».

На практиці висновок простий: якщо endpoint оголошено як JSON — повертайте об’єкт, а не рядок. Навіть якщо об’єкт дуже простий, наприклад { "status": "ok" }. Так, це трохи більше коду, але ви виграєте в контрактній чіткості.

Окремий важливий випадок: ResponseEntity<Void> і 204 No Content

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

@DeleteMapping("/api/v1/tasks/{taskId}")
public ResponseEntity<Void> delete() {
    // 204 No Content: тіло відповіді відсутнє => конвертер для запису body не потрібен
    return ResponseEntity.noContent().build();
}

Тут узагалі немає response body. Отже, конвертер для запису відповіді не потрібен, тому що писати нічого. І цей приклад корисний саме для розуміння механіки: наявність HTTP-відповіді та наявність тіла відповіді — це різні речі. У голові має клацати: якщо 204, то друга половина ланцюжка конверсії (запис body) просто не виконується.

5. Task Tracker API: 3 сценарії

Теорія стає зрозумілою, коли ви можете рукою повторити сценарій. Зараз ми зробимо це максимально по-людськи: один і той самий проєкт, один і той самий endpoint, і три запити — один коректний, два проблемні. І ви побачите, що проблеми напряму пов’язані саме з вибором конвертера.

Сценарій А: усе правильно — JSON на вході, JSON на виході

Нехай у TaskController у нас є створення задачі:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/tasks") // Базовий шлях для задач
class TaskController {

    @PostMapping(
            consumes = MediaType.APPLICATION_JSON_VALUE, // Endpoint приймає JSON
            produces = MediaType.APPLICATION_JSON_VALUE  // Endpoint повертає JSON
    )
    TaskResponseBody create(@RequestBody CreateTaskBody body) {
        // body уже заповнений Springʼом із JSON (якщо Content-Type коректний)
        TaskResponseBody response = new TaskResponseBody();
        response.id = "t-1";          // Для прикладу — статичний id
        response.title = body.title;  // Переносимо поле із запиту у відповідь
        return response;              // Spring серіалізує об’єкт у JSON
    }
}
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Виправити API",
  "description": "Зробити вибір конвертерів передбачуваним"
}

Що відбувається всередині (на рівні нашої лекції): Spring бачить Content-Type: application/json, вибирає конвертер, який уміє читати JSON і збирати CreateTaskBody, викликає метод, потім бачить Accept: application/json і produces = application/json, вибирає конвертер, який уміє записувати JSON із TaskResponseBody, і надсилає відповідь.

Сценарій B: забули Content-Type — як прочитати request body

Запит виглядає майже так само, але без Content-Type:

POST http://localhost:8080/api/v1/tasks
Accept: application/json

{
  "title": "Виправити API"
}

Тепер у Spring немає явного сигналу, як інтерпретувати body. У поєднанні з consumes = application/json це зазвичай закінчується 415 Unsupported Media Type. І важлива деталь: ви можете поставити breakpoint у метод create й здивуватися, що він не спрацьовує. Це нормально: до методу просто не дійшли, тому що конвертер не вибрано, тіло не прочитано, а отже вхід у метод неможливий.

Сценарій C: клієнт попросив не той формат відповіді (Accept)

Припустімо, клієнт надіслав:

POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: text/plain

{
  "title": "Виправити API"
}

А ваш метод оголошено як produces = application/json. Клієнт каже: «Я хочу текст», сервер каже: «Я віддаю JSON». У такій ситуації Spring цілком чесно може повернути 406 Not Acceptable.

Це хороший контрактний механізм: клієнтам не потрібно вгадувати формат, а серверу не потрібно мовчки змінювати контракт відповіді. У продакшені це економить багато годин на розслідування на кшталт «чому в нас іноді JSON, а іноді текст?».

6. Мініалгоритм вибору конвертера

Коли ви вперше стикаєтеся з помилками формату, дуже хочеться просто додати ще одну анотацію й сподіватися, що все мине. Але набагато ефективніше мати в голові маленький алгоритм — майже як чекліст, тільки без паніки. Цей алгоритм не потребує знання внутрішніх класів Spring, він потребує лише уважності до контракту: що прийшло, що очікується, що оголошено в коді.

У практичній діагностиці зручно поділити проблему на два незалежні запитання: «ми не змогли прочитати request body» і «ми не змогли записати response body». Вони незалежні, тому що запит може бути без body (GET), а відповідь може бути без body (204), і навпаки.

Нижче — схема, яка зазвичай допомагає новачкам перестати стріляти навмання:

flowchart TD
    A[Запит надійшов] --> B{Є request body?}
    B -->|ні| C[Читаємо path/query без HttpMessageConverter]
    B -->|так| D[Дивимося на Content-Type]
    D --> E{Збігається з consumes?}
    E -->|ні| F[415 Unsupported Media Type]
    E -->|так| G["Шукаємо converter: canRead(mediaType, paramType)"]
    G --> H{Знайдено?}
    H -->|ні| F
    H -->|так| I[Читаємо body -> Java-об'єкт]
    I --> J[Виконуємо метод контролера]
    J --> K{Є response body?}
    K -->|ні: 204/void| L[Віддаємо відповідь без body]
    K -->|так| M[Дивимося на Accept + produces]
    M --> N{Є перетин?}
    N -->|ні| O[406 Not Acceptable]
    N -->|так| P["Шукаємо converter: canWrite(mediaType, returnType)"]
    P --> Q{Знайдено?}
    Q -->|ні| O
    Q -->|так| R[Пишемо body відповіді]

Якщо тримати цю схему в голові, діагностика починає виглядати майже нудно, а це найкращий комплімент для діагностики. Ви перестаєте сперечатися зі Spring і починаєте говорити з ним однією мовою: «Я бачу Content-Type, бачу consumes, розумію, чому 415» або «Я бачу Accept, бачу produces, розумію, чому 406».

Ще один дуже практичний трюк: якщо ви впевнені, що метод має виконуватися, але він не виконується, і ви не бачите логіки сервісу, майже завжди це означає, що проблема сталася до входу в метод. А отже, ваші перші підозрювані — Content-Type, consumes, некоректний JSON або несумісність Java-типу параметра. Це як із дверима під’їзду: якщо ключ не підійшов, ви навіть до ліфта не дійшли, і обговорювати «чому ліфт не їде» зарано.

7. Типові помилки під час вибору конвертера

Помилка №1: плутати Content-Type і Accept, вважаючи їх «про одне й те саме».
Це одна з найчастіших помилок початківців: людина ставить Accept: application/json і думає, що цим вона «сказала серверу, що надсилає JSON». Насправді вона сказала: «Я хочу отримати JSON у відповідь». А ось «я надсилаю JSON» — це Content-Type. Якщо тримати ці заголовки на різних полицях мозку, половина проблем із @RequestBody зникає сама.

Помилка №2: сприймати consumes і produces як декоративні підписи, які можна «якось потім».
Без consumes/produces ваш endpoint стає менш явним: у великих проєктах це призводить до розмиття контракту й сюрпризів у форматі. У навчальному проєкті це особливо боляче, тому що ви намагаєтесь вивчати механіку, а вона плаває. consumes/produces — це короткий, чесний спосіб сказати і Spring, і людині, яка читає код: «ми домовилися про JSON».

Помилка №3: очікувати, що String автоматично означає «JSON-рядок».
String — це не JSON-об’єкт, це просто рядок. Якщо ви будуєте JSON-first API і хочете віддавати JSON — повертайте об’єкт і серіалізуйте його в JSON. Інакше можна легко отримати відповідь із Content-Type: application/json, у якій лежить невалідний JSON, і клієнт падатиме на рівному місці. Це той випадок, коли «усе працювало в браузері» і «усе зламалося у клієнта» — абсолютно типовий сценарій.

Помилка №4: намагатися лагодити код контролера, коли запит навіть не дійшов до методу.
Коли прилітає 415 або помилка читання тіла, метод контролера може взагалі не викликатися. Якщо ви починаєте переписувати сервіси, мапінг, репозиторії, а потім з’ясовується, що проблема була в забутому Content-Type, ви проходите класичний шлях розробника: «я виправив усе, крім того, що було зламано». Звичка спершу перевіряти заголовки та media types економить нерви.

Помилка №5: робити висновок «конвертер не працює», не враховуючи Java-тип параметра / відповіді.
Іноді конвертер працює ідеально, просто ви попросили його зробити інше. Наприклад, @RequestBody String raw — це запит «дай мені сирий текст», а не «розпарсь мені JSON в об’єкт». Так само return String — це запит «відповідь буде текстом». Spring тут не сперечається і не розумнішає, він слухняно виконує контракт, який ви задали сигнатурою методу. І в цьому, чесно кажучи, його сильна сторона: він передбачуваний, якщо ви передбачувано формулюєте очікування.

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