1. Невідповідність JSON і DTO — це нормально
Якщо ви очікуєте, що зовнішній JSON завжди ідеально пасуватиме до вашого record, то маю для вас дві новини. Добра: ви оптиміст. Погана: мережа швидко лікує оптимізм. Зовнішній провайдер живе своїм життям: додає поля, перейменовує їх, іноді повертає null, іноді взагалі не повертає поле, а іноді надсилає не той тип. І це не рідкісний крайній випадок, а звичайна рутина інтеграції.
Важливо прийняти просту думку: Jackson — це не магія, яка все полагодить, а строгий перекладач. Він читає контракт (JSON) і переносить його у вашу модель (DTO). Якщо модель описана чесно — усе гаразд. Якщо модель лише «приблизно схожа», ви отримуєте або null/дефолтні значення, або виняток. Обидва варіанти небезпечні по-своєму: виняток гучний, але корисний, бо одразу показує проблему, а тихий null може дожити до пізнього NullPointerException — і вже зовсім не схожий на «помилку мапінгу», хоча почалося все саме там.
Щоб тримати картину цілісною, корисно бачити весь ланцюжок як окремий конвеєр. Він не дуже великий, але в ньому є чітка точка, де ми зобов’язані «зупинити світ», якщо контракт не збігся:
flowchart TD
%% Схема потоку: спочатку перевіряємо статус, потім мапимо JSON у DTO
A["HttpResponse<String>: статус і тіло"] --> B{"status 2xx?"}
B -- ні --> C[Обробка помилки провайдера: не намагаємося читати DTO успішної відповіді]
B -- так --> D[ObjectMapper.readValue]
D --> E[DTO провайдера]
E --> F[Далі код працює вже з типами]
D --> G["JsonProcessingException (контракт не збігся)"]
Зверніть увагу на деталь: мапінг має відбуватися після перевірки status, інакше ви легко спробуєте прочитати «помилковий JSON» як «успішний DTO» і отримаєте безглузду кашу з винятків. Це не тому, що Jackson поганий, — це тому, що ми намагалися сприйняти текст не тією мовою.
2. Імена полів і snake_case
Перші проблеми зазвичай починаються не з типів, а з імен. У Java ми звикли до camelCase, а у зовнішньому JSON дуже часто трапляється snake_case. І якщо ви просто назвete компонент record по-своєму, Jackson не зобов’язаний здогадатися, що authorNames — це те саме, що author_name. Він не телепат, а бібліотека.
Найзрозуміліший для новачка спосіб — явна прив’язка імені поля через @JsonProperty. Так, це трохи більше букв у коді, зате ви буквально показуєте: «ось це поле з JSON відповідає ось цьому компоненту в Java». Це хороша угода: кілька букв в обмін на передбачуваність.
Уявімо шматок відповіді пошуку в стилі Open Library — приблизно, без претензії на ідеальну копію:
{
"docs": [
{
"key": "OL1M",
"title": "Clean Code",
"author_name": ["Robert C. Martin"]
}
]
}
Якщо ми хочемо назвати компонент по-людськи (authorNames), то робимо так:
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record OpenLibraryDocDto(
String key,
String title,
// Явно пов'язуємо ім'я поля в JSON (snake_case) з ім'ям компонента в Java (camelCase)
@JsonProperty("author_name") List<String> authorNames
) {}
Тут важливо, що @JsonProperty("author_name") — це не «для краси». Це інструкція Jackson: «коли побачиш author_name, поклади його в authorNames».
І тепер десеріалізація виглядатиме як нормальна робота, а не як гра в здогадки:
import tools.jackson.databind.ObjectMapper;
public class Demo {
public static void main(String[] args) throws Exception {
// Той самий JSON, який прийшов від провайдера
String json = """
{"key":"OL1M","title":"Clean Code","author_name":["Robert C. Martin"]}
""";
// ObjectMapper — точка, де JSON перетворюється на типізований DTO
ObjectMapper mapper = new ObjectMapper();
OpenLibraryDocDto dto = mapper.readValue(json, OpenLibraryDocDto.class);
// Завдяки @JsonProperty список авторів справді заповнюється
System.out.println(dto.authorNames().get(0)); // Robert C. Martin
}
}
Іноді у провайдера буває ще веселіше: поле могли перейменувати, або різні ендпоінти називають одне й те саме по-різному. Для таких випадків існує @JsonAlias, де ви перелічуєте альтернативні імена. Я б не радив зловживати цим, але як «ремінь безпеки» це корисно: ви явно фіксуєте, що в JSON могли трапитися варіанти, і ви їх приймаєте.
Ключовий висновок простий: ім’я компонента record — це частина контракту з боку Java, а @JsonProperty — це місток до контракту провайдера. Чим явніший цей місток, тим менше сюрпризів.
3. Відсутні поля і null
На цьому місці зазвичай розгортається типова джуніорська трагедія у трьох актах. Акт перший: поле не прийшло, Jackson поклав null. Акт другий: ви викликаєте .isEmpty на списку. Акт третій: NullPointerException, і ви підозрюєте, що проблема в Java, хоча проблема в контракті.
Потрібно розрізняти щонайменше три стани, які зовні виглядають схожими, але зміст у них різний:
| Стан у JSON | Приклад | Що зазвичай опиниться в DTO | Чому це важливо |
|---|---|---|---|
| Поле відсутнє | { "title": "Clean Code" } | null (для посилальних типів) | Це означає, що значення не передали взагалі |
| Поле є і дорівнює null | { "first_publish_year": null } | null | Це означає, що значення є, але воно порожнє або невідоме |
| Поле є, і колекція порожня | { "author_name": [] } | List розміру 0 | Це означає, що значень немає, але сам список існує |
Якщо тип у DTO обрано невдало, ви самі додаєте собі проблем. Найчастіший приклад — використання примітивів там, де поле може бути відсутнім.
Подивіться, наскільки підступний int:
import com.fasterxml.jackson.annotation.JsonProperty;
public record DetailsDto(
String title,
// Примітивний int не може бути null: якщо поле не прийшло, буде 0 (і це легко прийняти за реальні дані)
@JsonProperty("first_publish_year") int firstPublishYear
) {}
Якщо JSON надійде без first_publish_year, Jackson (найімовірніше) поставить 0. І тепер у вашому застосунку зʼявляється «книга з нульового року». Вітаю, ви щойно винайшли античну літературу майбутнього.
Набагато чесніше використовувати Integer, щоб відсутність значення позначалася як null:
import com.fasterxml.jackson.annotation.JsonProperty;
public record DetailsDto(
String title,
// Wrapper-тип допускає null: так відсутність поля не маскується під "0"
@JsonProperty("first_publish_year") Integer firstPublishYear
) {}
І тепер поведінка прозоріша:
import tools.jackson.databind.ObjectMapper;
public class Demo {
public static void main(String[] args) throws Exception {
// Провайдер не надіслав first_publish_year
String json = """
{"title":"Clean Code"}
""";
ObjectMapper mapper = new ObjectMapper();
DetailsDto dto = mapper.readValue(json, DetailsDto.class);
// І це чесно видно в DTO: null, а не "0"
System.out.println(dto.firstPublishYear()); // null
}
}
Окремий біль — списки. Якщо провайдер іноді не повертає author_name, Jackson покладе null, і ваш код нормалізації (у наступній лекції) може легко впасти. Тут можна зробити невеликий, дуже практичний трюк: у record додати компактний конструктор і замінити null на порожній список. Це не складна архітектура, а просто захист від реальності.
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record OpenLibraryDocDto(
String key,
String title,
@JsonProperty("author_name") List<String> authorNames
) {
public OpenLibraryDocDto {
// На межі JSON → DTO нормалізуємо null у порожній список, щоб далі не ловити NPE
if (authorNames == null) authorNames = List.of();
}
}
Тепер authorNames.isEmpty можна викликати спокійно, без ризику отримати NPE.
Сенс усього розділу такий: коли поле може бути відсутнім, це має бути видно з типу (Integer, List, а не int). А коли null ламає подальшу логіку, краще один раз акуратно «почистити» його на межі DTO, ніж потім ловити випадкові NPE по всьому коду.
4. Невідомі поля і розширення DTO
Наступна класика жанру: ви описали DTO на три поля, а провайдер надіслав двадцять. Про ці двадцять ви взагалі нічого не знаєте і знати не хочете. Але Jackson може сказати: «Стоп. У вашій моделі такого поля немає — отже, контракт не збігся».
Для новачка це виглядає як «Jackson вередує». Для backend-розробника це виглядає як питання дисципліни: ми хочемо бути строгими чи терпимими до розширення контракту?
Уявімо JSON, де провайдер додав поле edition_count, а ми його не описали:
{
"key": "OL1M",
"title": "Clean Code",
"edition_count": 42
}
І DTO без цього поля:
public record BookDto(String key, String title) {}
Якщо Jackson налаштований строго, ви отримаєте виняток рівня «невідоме поле». Це корисно, коли ви хочете помічати будь-які зміни контракту. Але для зовнішнього провайдера це іноді занадто нервово: сьогодні додали поле — і у вас упав увесь клієнт, хоча ви це поле навіть не читали.
Найпростіший і дуже поширений компроміс для DTO провайдера — ігнорувати невідомі поля. Тоді провайдер може додавати нові поля, і ваш клієнт не падатиме на рівному місці.
Це робиться або анотацією на DTO:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true)
public record BookDto(String key, String title) {}
Або налаштуванням ObjectMapper (але це вже ближче до конфігурації мапера, а ми домовилися не йти в глибоке налаштування). Анотація хороша тим, що рішення «ігнорувати зайве» лежить прямо поруч із DTO і не перетворюється на глобальну магію.
Але тут важливо не піти в іншу крайність. Якщо ви ігноруєте невідомі поля всюди і завжди, ви можете пропустити власну помилку. Наприклад, ви очікували author_name, а в анотації написали @JsonProperty("autor_name") — загубили букву h. Jackson у строгому режимі міг би підказати вам: «слухай, у JSON поле author_name насправді є, але ти його не мапиш». У режимі «ігноруй усе» ви отримаєте просто null — і відлагоджуватимете це значно довше.
Тому тримайте в голові баланс: ігнорувати невідомі поля корисно, але робити це потрібно усвідомлено, зазвичай саме на DTO провайдера, де контракт чужий і може розширюватися без попередження. А от наскільки строго ми ставитимемося до своєї внутрішньої моделі — це окрема дисципліна. Нині нам достатньо навчитися не падати через «зайві поля», але при цьому не втрачати здатність помічати реальні помилки мапінгу.
5. Помилки типів під час десеріалізації
Найнеприємніша категорія проблем — коли JSON синтаксично коректний, status начебто успішний, але мапінг падає, бо типи не збіглися. І це справді неприємно: ви дивитеся на JSON і думаєте «ну, нормальний же», а Jackson каже: «ні, не нормальний».
Приклади типових невідповідностей:
- поле приходить у вигляді рядка "2008", а ви чекаєте число 2008;
- поле приходить числом, а ви чекаєте рядок;
- поле приходить об’єктом {...}, а ви чекаєте масив [...] або навпаки;
- поле іноді приходить як null, а у вас у DTO примітив (int, boolean), і ви потім не розумієте, звідки взялися 0 або false.
Ось мінімальний приклад, де рік публікації приходить рядком, а ми чекаємо Integer:
import tools.jackson.databind.ObjectMapper;
public class Demo {
public static void main(String[] args) {
// JSON синтаксично коректний, але поле має не той тип (рядок замість числа)
String json = """
{"title":"Clean Code","first_publish_year":"two thousand eight"}
""";
ObjectMapper mapper = new ObjectMapper();
try {
// Помилка спливе саме тут: на етапі JSON → DTO
mapper.readValue(json, DetailsDto.class);
} catch (Exception e) {
// У реальному коді краще логувати або обгортати з контекстом, але для прикладу вистачить маркера
System.out.println("JSON не збігається з DTO"); // JSON не збігається з DTO
}
}
}
З погляду застосунку важливі дві речі.
По-перше, помилку мапінгу потрібно ловити там само, де ви виконуєте readValue, а не десь потім. Якщо ви пропустите виняток угору без контексту, у main він перетвориться на «щось про Jackson», і студент — та майбутній ви — не розумітиме, на якому етапі все зламалося: мережа, статус, JSON чи типи.
По-друге, корисно відокремлювати помилки провайдера від помилок нашого мапінгу. Для цього не треба будувати ієрархію з 12 класів винятків — ми не на курсі «enterprise-церемонія» — достатньо хоча б обгорнути виняток у зрозуміле повідомлення.
Наприклад, простий helper на межі client-коду:
import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;
public class ProviderJsonReader {
// Один mapper на інстанс: тут зосереджено всю логіку читання provider JSON
private final ObjectMapper mapper = new ObjectMapper();
public <T> T read(String json, Class<T> type) {
try {
// Межа: перетворюємо рядковий JSON на конкретний DTO
return mapper.readValue(json, type);
} catch (JsonProcessingException e) {
// Обгортаємо у зрозумілий виняток із контекстом — на якому DTO зламалося
throw new IllegalStateException("Провайдерський JSON несумісний з DTO: " + type.getSimpleName(), e);
}
}
}
Це коротко, але дуже практично: тепер будь-яка помилка десеріалізації перетворюється на повідомлення «провайдерський JSON несумісний з DTO такого-то типу», а не просто на «JsonMappingException десь у надрах». І ви одразу бачите, на якій моделі зламалося.
Зверніть увагу: ми не робимо «розумне» автоперетворення типів. Якщо провайдер надіслав рядок замість числа, це не те, що треба тихо проковтнути. Це сигнал: або ви невірно описали DTO — найімовірніше, — або провайдер порушив власний контракт. У будь-якому разі це треба помітити явно.
6. Типові помилки під час мапінгу JSON у DTO
Помилка № 1: сподіватися, що імена полів збігатимуться самі собою.
Дуже часта пастка — назвати компонент authorNames і чекати, що він заповниться з author_name без жодних підказок. У результаті DTO приходить «порожній», а ви починаєте підозрювати HttpClient, мережу, таймаути і навіть фазу Місяця. На практиці майже завжди простіше й чесніше поставити @JsonProperty і відразу показати відповідність.
Помилка № 2: використовувати примітиви там, де поле може бути відсутнім.
int і boolean — зручні типи, але вони не вміють виражати «значення немає». Якщо поле не прийшло, ви отримуєте 0 або false, а далі намагаєтеся інтерпретувати це як реальні дані. Для зовнішнього JSON це особливо небезпечно. Integer та інші wrapper-типи зазвичай дають чеснішу поведінку, тому що null змушує вас приймати рішення усвідомлено.
Помилка № 3: не розрізняти «поля немає», «поле null» і «порожня колекція».
Ці три стани легко сплутати, особливо якщо ви тільки перейшли від рядкового JSON до типізованого DTO. Але вони означають різні речі й по-різному ламають код. Якщо список авторів може бути відсутнім, краще привести null до порожнього списку в компактному конструкторі record, ніж ловити NPE на нормалізації.
Помилка № 4: падати через невідомі поля провайдера і вважати це «помилкою Jackson».
Провайдери люблять додавати поля. Це не завжди зміна контракту в поганому сенсі, іноді це просто розширення. Якщо ваш DTO описує тільки те, що вам потрібно, і ви не хочете падати від кожного зайвого поля, використовуйте @JsonIgnoreProperties(ignoreUnknown = true) на DTO провайдера. Але робіть це усвідомлено, розуміючи, що надмірна терпимість може приховати ваші друкарські помилки.
Помилка № 5: ловити помилки десеріалізації надто далеко від межі JSON → DTO.
Коли виняток вилітає далеко вгору, він втрачає контекст і перетворюється на «щось про Jackson». Набагато корисніше, щоб помилка фіксувалася поруч із readValue: так ви розумієте, що мережа і статус тут ні до чого, проблема саме в невідповідності контракту та моделі. Мініобгортка навколо ObjectMapper часто дає більше користі, ніж сотня println у випадкових місцях.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ