JavaRush /Курси /Java Server /Коли JSON не збігає...

Коли JSON не збігається з DTO

Java Server
Рівень 16 , Лекція 3
Відкрита

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: ловити помилки десеріалізації надто далеко від межі JSONDTO.
Коли виняток вилітає далеко вгору, він втрачає контекст і перетворюється на «щось про Jackson». Набагато корисніше, щоб помилка фіксувалася поруч із readValue: так ви розумієте, що мережа і статус тут ні до чого, проблема саме в невідповідності контракту та моделі. Мініобгортка навколо ObjectMapper часто дає більше користі, ніж сотня println у випадкових місцях.

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