1. ObjectMapper між JSON і Java
Коли ви починаєте працювати з JSON, легко потрапити в пастку: «це ж просто текст, зараз швидко його розберемо». На маленькому прикладі це навіть працює, але лише доти, доки JSON не стане трохи складнішим, не зʼявляться вкладеність, масиви, необов’язкові поля або провайдер раптом не додасть нове поле. ObjectMapper — це не магічна коробка, а цілком конкретний «перекладач»: він уміє перетворювати JSON-рядок на Java-об’єкт (десеріалізація) і Java-об’єкт назад на JSON-рядок (серіалізація). Ми просто перестаємо працювати «на рівні символів» і починаємо працювати «на рівні структури».
Схематично місце ObjectMapper у нашому клієнтському коді виглядає так:
flowchart TD A["HttpResponse<String> response"] --> B["response.body() = сирий JSON"] B --> C["ObjectMapper.readValue(...)"] C --> D["DTO (Java-об’єкт)"] D --> E["Решта коду застосунку (працює з полями, типами)"]
Терміни, які сьогодні постійно звучатимуть, і краще їх не плутати:
| Термін | Що це | Як виглядає в коді |
|---|---|---|
| Десеріалізація | JSON → Java-об’єкт | mapper.readValue(json, Dto.class) |
| Серіалізація | Java-об’єкт → JSON | mapper.writeValueAsString(dto) |
| DTO | «контейнер даних» під контракт API | record ... {} |
2. Як отримати та повторно використовувати ObjectMapper
Новачкам дуже хочеться зробити так: «мені потрібен JSON → DTO? Чудово, створю new ObjectMapper просто тут, у методі, і поїхали». Воно працюватиме. А потім ви так зробите у пʼяти місцях, потім у десяти, потім почнете додавати налаштування — і раптом виявиться, що в одному місці JSON читається, а в іншому — ні, бо в різних екземплярів маппера різні налаштування. До того ж ObjectMapper — доволі важкий об’єкт: його краще створити один раз і повторно використовувати. Це як купувати новий холодильник щоразу, коли захотілося води. Вода, звісно, буде холодною, але квартира швидко закінчиться.
І ще один важливий орієнтир: ObjectMapper та JsonProcessingException беремо з tools.jackson.*; анотації Jackson, якщо знадобляться, і далі можуть жити в com.fasterxml.jackson.annotation.*.
Найпростіший і найзрозуміліший для навчання спосіб для нашого проєкту — зробити маленький клас, який повертає один спільний ObjectMapper. Поки без ускладнень, без фреймворків і без «абстракцій заради абстракцій».
Приклад: один ObjectMapper на весь застосунок
package com.example.readlater.common.json;
import tools.jackson.databind.ObjectMapper;
public final class JsonMapper {
// Один спільний ObjectMapper на застосунок: повторно використовуємо його і не створюємо зайвих екземплярів у різних місцях.
private static final ObjectMapper INSTANCE = new ObjectMapper();
private JsonMapper() {
// Забороняємо створення екземплярів: це утилітний клас.
}
public static ObjectMapper instance() {
// Єдина точка доступу до спільного маппера.
return INSTANCE;
}
}
Зверніть увагу: ми не обговорюємо «глобальні синглтони» як філософію — ми розв’язуємо конкретну прикладну задачу: не плодити ObjectMapper у кожному методі.
І тепер у будь-якому місці проєкту можна зробити так:
import com.example.readlater.common.json.JsonMapper;
import tools.jackson.databind.ObjectMapper;
// Беремо спільний маппер, щоб усюди були однакові налаштування парсингу/серіалізації.
ObjectMapper mapper = JsonMapper.instance();
3. readValue(...): JSON → DTO
Тепер до найцікавішого: як виглядає базова десеріалізація. Ключовий момент — ObjectMapper не вгадує, у що ви хочете перетворити JSON. Йому треба явно вказати тип. Тому нам завжди потрібні два інгредієнти: рядок JSON і DTO-клас (або record). І тут починається найприємніше: JSON перестає бути просто рядком String і стає об’єктом із полями, типами та передбачуваною поведінкою.
Покажемо на найпростішому JSON-об’єкті. Так, приклад іграшковий — і це нормально: спочатку навчимося тримати в руках молоток, а вже потім забиватимемо цвяхи в стіну, а не в палець.
import tools.jackson.databind.ObjectMapper;
// DTO під очікувану форму JSON.
record BookDto(String title, String author) {
}
String rawJson = """
{"title":"Clean Code","author":"Robert C. Martin"}
""";
// У реальному проєкті краще повторно використовувати спільний ObjectMapper,
// але в прикладі показуємо саму механіку readValue.
ObjectMapper mapper = new ObjectMapper();
// Десеріалізація: JSON-рядок -> типізований DTO.
BookDto book = mapper.readValue(rawJson, BookDto.class);
// Далі працюємо як зі звичайним об’єктом, а не з рядком.
System.out.println(book.title()); // Clean Code
System.out.println(book.author()); // Robert C. Martin
Тут важливо одразу помітити дві речі.
По-перше, readValue(...) може викидати виняток, бо JSON може виявитися не тим, на що ми розраховували. У навчальних прикладах це часто ховають, але в реальному коді це треба принаймні вміти зловити й пояснити.
По-друге, DTO має відповідати формі JSON. Якщо JSON каже {"title": ...}, а DTO очікує name, то ObjectMapper не вгадує.
Мініверсія з try/catch, щоб ви не лякалися помилок десеріалізації:
import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;
// DTO, у який ми хочемо прочитати JSON.
record BookDto(String title, String author) {
}
ObjectMapper mapper = new ObjectMapper();
try {
// Якщо JSON не відповідає очікуваній формі DTO, тут буде виняток.
BookDto book = mapper.readValue("{\"title\":\"Clean Code\"}", BookDto.class);
System.out.println(book.title()); // Clean Code
} catch (JsonProcessingException e) {
// Мінімальна реакція на помилку: хоча б явно зафіксувати, що читання JSON не вдалося.
System.out.println("JSON не вдалося прочитати як BookDto");
}
Поки ми просто фіксуємо факт: десеріалізація може зламатися, і це нормально. Головне — щоб вона ламалася на межі JSON → DTO, а не через пів години в іншому місці програми.
4. JSON-обгортка: верхній рівень відповіді
Реальні API рідко повертають «рівно список книг» або «рівно одну книгу». Частіше вони віддають об’єкт-обгортку: всередині — масив результатів, лічильники, метадані, а інколи ще один об’єкт. Зверху все це загорнуте в JSON-об’єкт. Новачки намагаються прочитати одразу внутрішній список і дивуються, чому Jackson невдоволений. Jackson невдоволений недарма: якщо верхній рівень JSON — об’єкт, то і DTO на верхньому рівні має бути об’єктом, інакше форми не збігаються.
Припустімо, провайдер повертає пошук ось так (спрощено):
{
"docs": [
{ "key": "OL1M", "title": "Clean Code" }
],
"numFound": 1
}
Тоді «чесні» DTO виглядають так: один DTO під елемент списку і один DTO під усю відповідь.
import tools.jackson.databind.ObjectMapper;
import java.util.List;
// DTO під елемент масиву "docs".
record SearchDocDto(String key, String title) {
}
// DTO під верхній об’єкт відповіді: саме він відповідає JSON, який починається з { ... }.
record SearchResponseDto(List<SearchDocDto> docs, int numFound) {
}
ObjectMapper mapper = new ObjectMapper();
String rawJson = """
{"docs":[{"key":"OL1M","title":"Clean Code"}],"numFound":1}
""";
// Читаємо одразу весь верхній рівень, а не намагаємося "витягти" docs руками.
SearchResponseDto response = mapper.readValue(rawJson, SearchResponseDto.class);
System.out.println(response.numFound()); // 1
System.out.println(response.docs().get(0).key()); // OL1M
Зверніть увагу, який тут виграш у читабельності. Ми більше не шукаємо вручну "docs" і не намагаємося вирізати підрядок між [ і ]. Ми кажемо: «Ось структура відповіді» — і далі працюємо як зі звичайними об’єктами.
5. writeValueAsString(...): DTO → JSON
Десеріалізація — це найчастіша операція на боці клієнта: ми читаємо відповіді провайдера. Але серіалізація теж важлива. Навіть якщо конкретно наш провайдер працює через GET, серіалізація стане у пригоді для трьох цілком буденних речей: зібрати JSON-тіло для POST, підготувати стабільний зразок JSON або просто красиво вивести об’єкт у консоль без ручного конкатенування рядків. writeValueAsString(...) робить зворотну операцію: Java-об’єкт → JSON-рядок.
Найпростіший приклад:
import tools.jackson.databind.ObjectMapper;
// DTO для запиту: поля цього record стануть JSON-полями.
record CreateBookRequest(String title, String author) {
}
ObjectMapper mapper = new ObjectMapper();
CreateBookRequest req = new CreateBookRequest("Clean Code", "Robert C. Martin");
// Серіалізація: об’єкт -> JSON-рядок (зручно для тіла HTTP-запиту або журналів).
String json = mapper.writeValueAsString(req);
System.out.println(json); // {"title":"Clean Code","author":"Robert C. Martin"}
Як і з readValue(...), тут теж можливі винятки (наприклад, якщо об’єкт містить щось, що Jackson не вміє серіалізувати). Тому в прикладному коді зазвичай або роблять try/catch, або кидають власну зрозумілу runtime-помилку, але вже з контекстом.
І ще один зручний прийом для діагностики: «красивий JSON» з переносами рядків. Це не обов’язкова річ, але для навчання і налагодження вона дуже допомагає.
import tools.jackson.databind.ObjectMapper;
record BookDto(String title, String author) {
}
ObjectMapper mapper = new ObjectMapper();
BookDto book = new BookDto("Clean Code", "Robert C. Martin");
// Охайний вивід: корисно для налагодження та читання очима.
String prettyJson = mapper.writerWithDefaultPrettyPrinter()
.writeValueAsString(book);
System.out.println(prettyJson);
// {
// "title" : "Clean Code",
// "author" : "Robert C. Martin"
// }
6. ObjectMapper в клієнті каталогу
Найпоширеніша архітектурна помилка новачка — протягнути сирий JSON-рядок через половину проєкту, а розпарсити його «десь у кінці». Тоді ви втрачаєте сенс розділення шарів: transport-шар уже не шар, DTO — не DTO, а помилки спливають де завгодно. Нормальна точка для JSON-мапування — одразу після того, як ви перевірили статус відповіді й зрозуміли, що це успішний JSON потрібної форми. Тобто мапування має жити поруч із межею транспортного шару.
Уявімо спрощений метод транспортного шару: він уже зробив HTTP-виклик і отримав String JSON. Тепер він хоче перетворити JSON на DTO і передати DTO далі.
import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;
public class CatalogJsonReader {
private final ObjectMapper mapper;
public CatalogJsonReader(ObjectMapper mapper) {
// Важливо: передаємо спільний/налаштований ObjectMapper зовні, а не створюємо новий усередині.
this.mapper = mapper;
}
public SearchResponseDto readSearchResponse(String json) {
try {
// Точка, де сирий JSON перестає бути рядком і перетворюється на типізований DTO.
return mapper.readValue(json, SearchResponseDto.class);
} catch (JsonProcessingException e) {
// Помилка має бути "гучною" і зрозумілою саме на межі між transport-шаром і DTO.
throw new IllegalStateException("JSON провайдера не відповідає SearchResponseDto", e);
}
}
}
Тут у нас одразу два важливі ефекти.
Перший — «рядок живе недовго». Ми швидко перетворюємо String на DTO, і далі решта коду застосунку працює вже з типами.
Другий — «помилку локалізовано». Якщо JSON не збігається з DTO, ми не отримаємо десь потім NullPointerException «невідомо чому». Ми отримаємо зрозумілу проблему саме на межі: «JSON провайдера не збігся з очікуваннями».
І тепер використання виглядає приблизно так, суперспрощено, щоб не розтягувати приклад:
import com.example.readlater.common.json.JsonMapper;
import tools.jackson.databind.ObjectMapper;
// Беремо спільний ObjectMapper (єдині налаштування, єдина поведінка).
ObjectMapper mapper = JsonMapper.instance();
CatalogJsonReader reader = new CatalogJsonReader(mapper);
// На вхід приходить сирий JSON, на виході — DTO, далі рядок уже не потрібен.
SearchResponseDto dto = reader.readSearchResponse(rawJson);
System.out.println(dto.numFound()); // наприклад: 1
7. Шпаргалка з ObjectMapper
Наприкінці корисно зафіксувати в голові не «всі можливості Jackson», а рівно той мінімум, який потрібен прямо зараз. На цьому рівні достатньо впевнено володіти двома методами та розуміти, де вони стоять у потоці даних.
| Задача | Метод | Що даємо на вхід | Що отримуємо |
|---|---|---|---|
| Прочитати відповідь провайдера | readValue(...) | String json, Class<Dto> | Dto |
| Зібрати JSON-рядок | writeValueAsString(...) | Java-об’єкт | String json |
| Красивий JSON для виведення | writerWithDefaultPrettyPrinter(...) | Java-об’єкт | String prettyJson |
Якщо ви впевнено тримаєте в голові цю табличку, то на рівні «джун із базовою підготовкою» ви вже вмієте робити головне: перетворювати контрактний JSON на типізовану модель.
8. Типові помилки при роботі з ObjectMapper
Помилка №1: створювати новий ObjectMapper у кожному методі «про всяк випадок».
Зовні здається, що це просто ще один об’єкт, а значить — нічого страшного. На практиці ви отримуєте десяток маперів із потенційно різними налаштуваннями, а інколи й просто зайві витрати. Набагато спокійніше тримати один ObjectMapper і використовувати його всюди однаково, особливо в навчальному проєкті.
Помилка №2: намагатися десеріалізувати JSON до перевірки статусу відповіді.
Дуже часта пастка: «я ж очікую DTO — значить, одразу readValue(...)». Але HTTP-контракт живе не лише в body. Якщо вам прийшов не 200, то body може бути помилкою, HTML-сторінкою проксі або взагалі порожнечею. Тому спочатку перевірка statusCode, і лише потім JSON → DTO.
Помилка №3: намагатися читати «внутрішній список» без DTO-обгортки, коли JSON зверху — об’єкт.
Якщо JSON починається з { ... }, а ви намагаєтеся читати це як List<...>, Jackson цілком справедливо скаже «форми не збігаються». Рішення — описувати верхній рівень відповіді чесно: DTO-обгортка + список всередині. Це виглядає трохи довше на старті, але робить код стабільним і зрозумілим.
Помилка №4: протягувати сирий JSON-рядок по застосунку і парсити «десь потім».
Так ви перетворюєте JSON mapping на приховану міну. Помилка десеріалізації вилізе не на межі, а у випадковому місці, де ви спробували дістати поле з null. Правильніше тримати рядок лише на межі транспортного шару і одразу переводити його в DTO. Тоді «токсичність» рядка локалізується.
Помилка №5: «проковтувати» JsonProcessingException без контексту.
Іноді в catch пишуть return null; або просто друкують System.out.println("помилка"). Через тиждень це перетворюється на розслідування рівня «чому воно інколи null?». Якщо вже ловимо виняток, то принаймні кидаємо далі зрозумілу runtime-помилку з повідомленням про те, що саме не збіглося, хоча б імʼя DTO, щоб не втратити причину.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ