JavaRush /Курси /Spring REST & MVC /Jackson у @RestController

Jackson у @RestController

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

1. Вступ

Після вибору конвертера картина вже стала чіткішою: Spring дивиться на Content-Type, Accept, consumes / produces і Java-типи. Але цього все ще замало, щоб зрозуміти весь шлях JSON до кінця. Навіть якщо Spring вибрав JSON-конвертер, хтось усе одно має взяти JSON-текст і перетворити його на Java-об’єкт — а потім виконати зворотну операцію під час формування відповіді.

У нашому стеку (Spring Boot 4 + Spring MVC) JSON-сценарій обслуговує Jackson. Це окрема бібліотека, яка вміє перетворювати JSON-текст на Java-об’єкти і навпаки. Spring MVC керує HTTP-ланцюжком, вибирає конвертер, визначає, куди покласти значення і що повернути клієнтові. А Jackson — це «перекладач» між світом JSON і світом Java.

Важливо проговорити це вголос, бо майже всі проблеми з body зводяться до одного питання: це проблема Spring MVC, вибору конвертера чи JSON-мапінгу на боці Jackson? Якщо не розділити ці рівні, далі дуже легко виправляти не той шар.

2. Jackson у ланцюжку Spring MVC

Якщо коротко, Spring MVC — це «режисер», HttpMessageConverter — це «оператор із камерою та мікрофоном», а Jackson — це «перекладач реплік». Без режисера актори можуть існувати, але вистави не буде. Без оператора не буде ні звуку, ні зображення. Без перекладача вхідний JSON не перетвориться на Java-об’єкт, навіть якщо режисер дуже старається.

Давайте зафіксуємо місце Jackson в одній простій схемі. Її корисно тримати в голові, коли ви налагоджуєте POST /api/v1/tasks або будь-який інший JSON-ендпоінт.

flowchart TD
    A[HTTP-запит
тіло: байти] --> B[Spring MVC] B --> C[HttpMessageConverter] C --> D["Jackson (JsonMapper)"] D --> E[Java-об’єкт
CreateTaskBody] E --> F[Метод контролера] F --> G[Рівень сервісу] G --> H[Java-об’єкт
TaskResponseBody] H --> I[HttpMessageConverter] I --> J["Jackson (JsonMapper)"] J --> K[HTTP-відповідь
тіло: байти]

З погляду відповідальності це можна звести до невеликої таблиці. Тут немає мети вивчити назви класів напам’ять; мета — перестати змішувати різні рівні в одну «автоконвертацію».

Учасник Що робить у JSON-сценарії Що не робить
Spring MVC Знаходить метод контролера, збирає аргументи, вибирає відповідний конвертер за Content-Type/Accept і типами Не зобов’язаний уміти парсити JSON сам по собі
HttpMessageConverter Читає і пише body, викликає JSON-бібліотеку для конкретного формату Не є «JSON-парсером» — це лише обгортка й точка інтеграції
Jackson (JsonMapper) Перетворює JSON ⇄ Java-об’єкт (десеріалізація і серіалізація) Не вибирає endpoint, не знає про @GetMapping, не керує статусами та заголовками

Звідси виростає природне питання: чому в звичайному застосунку @RestController усе це вже працює без ручної реєстрації конвертерів і низькорівневої збірки JSON-пайплайна? Тому що, коли у вас піднято стандартний MVC-стек і Jackson є на classpath, Spring Boot реєструє message converters, які вміють працювати з JSON, і підключає Jackson як JSON-двигун цього шляху. Отже, @RequestBody і повернення об’єктів у @RestController починають працювати не «по телепатії», а через штатну інфраструктуру застосунку.

Тепер можна на мить відокремити Jackson від Spring-картини й подивитися на нього самостійно: так простіше побачити, що саме він робить під час читання і запису JSON.

Тепер важлива ремарка для реального життя: якщо ваш JSON не читається, то винен не контролер. Найімовірніше, до контролера справа навіть не дійшла. Контролер — це вже наступний етап, до якого Spring потрапляє лише після того, як зміг зібрати аргументи. Jackson якраз перебуває до входу в метод (коли читаємо request body) і після виходу з методу (коли пишемо response body).

3. Десеріалізація

Слово «десеріалізація» звучить як заклинання з давнього манускрипта, але за змістом усе прозаїчно. Десеріалізація — це процес, коли у вас є текст (JSON) і ви хочете отримати об’єкт (Java). По суті це «розпакування» даних із текстового формату.

У Jackson 3, який ми використовуємо в лінійці Spring Boot 4 у цьому курсі, центральний об’єкт для JSON називається JsonMapper. Історично в Jackson багато хто знає ObjectMapper, але в нашій лінійці ми свідомо використовуємо JsonMapper, щоб явно підкреслити: йдеться саме про JSON.

Почнемо з максимально чесної демонстрації «у вакуумі», без Spring. Так легше побачити суть: є рядок, є Java-клас і є мапер, який виконує перетворення. На прикладі майбутнього Task Tracker API візьмемо просту модель тіла запиту на створення задачі.

public class CreateTaskBody {
    // DTO тіла запиту: те, що приходить з JSON у request body
    public String title;       // очікуємо ключ "title" у JSON
    public String description; // очікуємо ключ "description" у JSON
}

А тепер — читання JSON:

import tools.jackson.databind.json.JsonMapper;

public class JacksonReadDemo {
    public static void main(String[] args) throws Exception {
        // Початковий JSON, який потрібно перетворити на Java-об’єкт
        String json = """
                {
                  "title": "Виправити API",
                  "description": "Додати документацію"
                }
                """;

        // JsonMapper — головний об’єкт Jackson для (де)серіалізації JSON
        JsonMapper mapper = JsonMapper.builder().build();

        // readValue: читаємо JSON-текст і будуємо об’єкт потрібного типу
        CreateTaskBody body = mapper.readValue(json, CreateTaskBody.class);

        // Перевіряємо, що поля заповнилися
        System.out.println(body.title);       // Виправити API
        System.out.println(body.description); // Додати документацію
    }
}

У цьому прикладі є три думки, які вже зараз потрібні Junior-розробнику.

Перша думка: Jackson читає текст і створює об’єкт потрібного типу. Тип (CreateTaskBody.class) важливий: саме він визначає, які поля очікуються, якими мають бути їхні типи і у що перетворювати JSON.

Друга думка: Jackson не мусить знати нічого про Spring MVC. Він не знає, що це POST /api/v1/tasks. Він бачить лише рядок JSON і клас, у який треба його перетворити.

Третя думка: для мапінгу JSON має бути «схожим» на структуру Java-класу. Тут «схожий» — дуже практичне слово: ключі JSON (title, description) мають збігатися з полями або сетерами, а значення мають бути сумісними за типами (рядок у JSON має потрапляти в String, число — у int/long і так далі). Ми пізніше впорядкуємо цю частину через DTO і контрактні правила, але сьогодні важливо побачити сам принцип: Jackson не «вгадує», а зіставляє.

4. Серіалізація

Серіалізація — це зворотна операція: був об’єкт — став JSON-рядок. Саме це відбувається, коли ваш @RestController повертає об’єкт, а Spring має записати його в response body у форматі application/json.

Знову почнемо з маленького прикладу без Spring. Нехай у нас є модель відповіді:

public class TaskResponseBody {
    // DTO відповіді: те, що перетвориться на JSON і піде в response body
    public String id;    // "id" у JSON
    public String title; // "title" у JSON
}

Тепер створимо об’єкт і перетворимо його на JSON:

import tools.jackson.databind.json.JsonMapper;

public class JacksonWriteDemo {
    public static void main(String[] args) throws Exception {
        // Формуємо Java-об’єкт, який хочемо віддати клієнтові
        TaskResponseBody response = new TaskResponseBody();
        response.id = "t-1";
        response.title = "Виправити API";

        // Jackson подивиться на поля об’єкта і згенерує JSON
        JsonMapper mapper = JsonMapper.builder().build();
        String json = mapper.writeValueAsString(response);

        // Те, що реально піде мережею, якщо повернути це з @RestController
        System.out.println(json); // {"id":"t-1","title":"Виправити API"}
    }
}

Тут легко зловити дуже корисну інтуїцію: JSON-відповідь у @RestController — це не «те, що ви написали вручну». Це те, що Jackson згенерував, подивившись на поля (або гетери) об’єкта, який ви повернули.

І звідси випливає доросла думка, яка часто рятує час: коли ви змінюєте Java-клас відповіді, ви змінюєте JSON, навіть якщо метод контролера не чіпали. Саме тому в цьому курсі далі буде великий модуль про DTO та JSON-контракт, але поки нам достатньо розуміти механізм: Spring попросив «запиши об’єкт у JSON», і Jackson це зробив.

Окремий практичний момент: JsonMapper зазвичай налаштовують один раз і перевикористовують. У прикладах вище ми створюємо його вручну щоразу, тому що нам потрібна демонстрація. У реальному Spring Boot-застосунку мапер Jackson створює й налаштовує платформа, а потім використовує повторно. Інакше ви швидко дійдете до варіанта «створюю JsonMapper на кожен запит», а це приблизно як «щоразу купую новий чайник, щоб закип’ятити воду». Працює, але ваші гаманець і CPU сумують.

5. Два класи проблем із JSON

Коли у вас ламається JSON-ендпоінт, дуже важливо спершу зрозуміти, що саме зламалося. Є дві принципово різні ситуації: JSON текст структурно неправильний (malformed) і JSON структурно правильний, але він не збігається з тим, що очікує ваш Java-тип.

Malformed JSON — це коли синтаксис JSON порушено. Наприклад, зайва кома, незакрита фігурна дужка, лапки не там, де треба. Тут Jackson навіть не може почати «розкладати» дані по полях: спочатку треба хоча б прочитати документ як JSON.

Ось демонстрація:

import tools.jackson.databind.json.JsonMapper;

public class MalformedJsonDemo {
    public static void main(String[] args) {
        // Зверніть увагу: JSON синтаксично зламано (зайва кома)
        String badJson = """
                {
                  "title": "Виправити API",
                }
                """;
        JsonMapper mapper = JsonMapper.builder().build();

        try {
            // На цьому місці Jackson упаде ще ДО мапінгу полів в об’єкт
            mapper.readValue(badJson, CreateTaskBody.class);
        } catch (Exception e) {
            // У реальному застосунку тут буде 400 Bad Request від Spring MVC
            System.out.println(e.getClass().getSimpleName()); // наприклад: JsonProcessingException
        }
    }
}

У Spring MVC це означає просту річ: метод контролера може взагалі не виконатися, тому що не вдалося зібрати аргумент @RequestBody. Це не бізнес-помилка і не «помилка в сервісі» — це суто технічна проблема читання тіла запиту.

Друга ситуація — JSON сам по собі валідний, але не збігається з очікуваним Java-типом. Наприклад, ви чекаєте об’єкт, а прийшов масив. Або чекаєте число, а прийшов рядок "abc". Або чекаєте поле title, а клієнт прислав taskTitle (і ви поки не домовилися, що це допустимо). Формально це не «зламаний JSON», але для вашого ендпоінта це все одно «нечитабельний» запит: Jackson не зможе коректно побудувати об’єкт потрібного класу.

Такі помилки теж виникають на етапі перетворення body, тобто до сервісної логіки. І саме тому ми так наполегливо відділяємо «прочитати body» від «обробити бізнес-зміст».

6. Мініприклад у Task Tracker API

Зараз ми зв’яжемо все в невеликий фрагмент коду, який нагадує наш реальний Task Tracker API. Підкреслю важливу методичну думку: сьогодні нас цікавить не ідеальний дизайн DTO (це буде пізніше), а те, як Jackson працює всередині @RestController.

Спочатку — прості моделі тіла. Вони навмисно «скромні», без ускладнень: нам потрібно побачити конвертацію.

public class CreateTaskBody {
    // DTO запиту (прийде з request body)
    public String title;
    public String description;
}

public class TaskResponseBody {
    // DTO відповіді (піде в response body)
    public String id;
    public String title;
}

Тепер — контролер. Зверніть увагу на два місця: @RequestBody (Jackson буде читати JSON) і TaskResponseBody у відповіді (Jackson буде писати JSON).

import java.net.URI;

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

@RestController
public class TaskController {

    // consumes/produces допомагають Spring вибрати потрібний HttpMessageConverter (і, відповідно, Jackson)
    @PostMapping(path = "/api/v1/tasks", consumes = "application/json", produces = "application/json")
    public ResponseEntity<TaskResponseBody> create(@RequestBody CreateTaskBody body) {
        // body вже готовий: його зібрав Jackson із JSON request body
        TaskResponseBody response = new TaskResponseBody();
        response.id = "t-1";
        response.title = body.title;

        return ResponseEntity
                // Заголовок Location для створеного ресурсу
                .created(URI.create("/api/v1/tasks/" + response.id))
                // Jackson серіалізує response у JSON
                .body(response);
    }
}

Якщо надіслати такий запит (наприклад, через .http файл), картина буде максимально чесною:

### Створення задачі
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Виправити API",
  "description": "Додати документацію про конвертери"
}

Що відбудеться по кроках, без містики:

Спочатку Spring MVC побачить @PostMapping і зрозуміє, що це відповідний обробник для POST /api/v1/tasks. Потім він подивиться на параметри методу і побачить @RequestBody CreateTaskBody body. Після цього він запустить HttpMessageConverter для читання request body. Конвертер для JSON усередині себе підключить Jackson, і Jackson збере об’єкт CreateTaskBody. І тільки після цього Spring викличе ваш метод create(...).

Коли метод поверне ResponseEntity<TaskResponseBody>, Spring знову ввімкне HttpMessageConverter, але вже для запису response body. Він знову використає Jackson, але тепер у зворотний бік: перетворить TaskResponseBody на JSON-текст і покладе його у відповідь.

І це, чесно кажучи, доволі заспокійливий механізм. Тому що стає очевидно: контролер не зобов’язаний читати JSON вручну. І точно так само він не зобов’язаний руками формувати JSON-рядок. Його задача — працювати з типами Java. А перетворення «між світами» виконує стандартний механізм.

7. Типові помилки під час роботи з Jackson у Spring MVC

На початку шляху найнебезпечніше — не помилка компіляції, а неправильна картинка в голові. Вона змушує виправляти не те місце. Нижче — кілька помилок, які трапляються у новачків постійно, і які ви тепер зможете ловити швидше, тому що у вас є зрозуміла схема: Spring MVC → converter → Jackson.

Помилка №1: плутати серіалізацію та десеріалізацію.
Це здається дрібницею, але потім з’являються фрази на кшталт «у мене серіалізація не працює» під час читання запиту. Тримайте просте правило: коли JSON входить у застосунок і стає об’єктом — це десеріалізація. Коли об’єкт виходить із застосунку і стає JSON — це серіалізація. Перестанете плутати слова — перестанете плутати й напрямок проблеми.

Помилка №2: говорити «Spring парсить JSON», забуваючи про Jackson.
Фраза начебто невинна, але вона збиває діагностику. Коли ви розумієте, що парсить Jackson, ви починаєте перевіряти форму JSON і очікуваний Java-тип, а не переписувати анотації контролера навмання. Spring керує процесом, але JSON-перетворення виконує конкретна бібліотека.

Помилка №3: відносити malformed JSON до бізнес-помилок.
Якщо JSON зламано синтаксично, жоден сервіс і жодна логіка створення задачі не винні. Запит не пройшов навіть етап читання тіла. Це технічна помилка «не змогли прочитати request body», і вона відбувається ще до вашого коду в контролері. У майбутньому ми оформимо такі помилки єдиним контрактом, але сенс залишиться тим самим.

Помилка №4: намагатися виправляти код контролера, коли запит до нього не дійшов.
Це класична пастка: ви ставите breakpoint у create(...), але він не спрацьовує, і ви починаєте думати, що «Spring чомусь не викликає метод». Насправді Spring не зобов’язаний викликати метод, якщо не зміг зібрати аргументи (наприклад, @RequestBody). Тому в проблемах JSON-входу часто винна не логіка контролера, а конверсія тіла.

Помилка №5: створювати JsonMapper вручну «про всяк випадок» у кожному місці.
Після того як ви побачили JsonMapper.builder().build(), з’являється спокуса: «О, я зараз сам парситиму JSON». Це майже завжди зайве. У застосунку Spring Boot JSON-конверсія вже вбудована в pipeline. Ручне створення мапера доречне для маленьких демонстрацій (як у нас у лекції) або спеціальних задач, але як основний стиль у контролерах це швидко перетворюється на кашу і дублювання роботи фреймворка.

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