JavaRush /Курси /Java Server /Jackson 3: стабільний JSON-мапінг

Jackson 3: стабільний JSON-мапінг

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

1. record добре підходить для DTO

Коли ви вперше робите JSON-мапінг, дуже легко потрапити в пастку: «Ну це ж Java, отже потрібні клас, поля, конструктори, гетери, equals, hashCode…» — і ось ваш DTO на чотири поля раптом займає пів екрана. Для транспортних моделей це особливо прикро: DTO майже завжди про дані, а не про поведінку. Саме тому в Java є чудовий інструмент — record. Він дає змогу описати «контейнер даних» одним виразом і заодно отримати нормальний toString, який потім рятує життя в логах і під час налагодження.

Порівняймо підходи у вигляді невеликої таблиці. Це не про «краще/гірше у вакуумі», а саме про DTO-шар:

Питання Звичайний DTO record для DTO
Скільки коду потрібно на 3–5 полів Часто багато шаблонного коду Зазвичай 1–3 рядки
Чи є гетери Потрібно писати або генерувати Є автоматично (title(), author())
equals/hashCode/toString Потрібно писати або генерувати Є автоматично
Змінюваність Часто роблять змінюваним за звичкою За замовчуванням «як DTO і має бути»: незмінний каркас
На що схоже в голові «Об’єкт із власним життям» «Посилка з даними»

У проєкті ReadLater Starter ми саме в тій точці, де це особливо важливо: у нас є зовнішній контракт провайдера, ми хочемо швидко й безпечно перетворити JSON на типи, а не писати ще один мінішар бюрократії поверх бюрократії.

2. Синтаксис record: компоненти й аксесори

Коли ви чуєте слово record, мозок іноді очікує чогось складного або якоїсь незрозумілої магії. Але насправді record — це просто компактна форма оголошення класу, який зберігає дані. Його «поля» називаються компонентами, і ви перелічуєте їх у круглих дужках. Ось і все: саме це і буде формою вашого JSON-контракту з боку Java-коду. Усередині IDE ви відчуєте перевагу відразу: менше коду — менше місць, де можна помилитися.

Ось мінімальний DTO для книги:

// Мінімальний DTO: імена компонентів = очікувані імена полів у JSON
record BookDto(String title, String author) {}

І тепер його можна використовувати як звичайний тип:

// record створюється як звичайний обʼєкт через new
BookDto book = new BookDto("Clean Code", "Robert C. Martin");

// Аксесори в record без префікса get: title(), author()
System.out.println(book.title());   // Clean Code
System.out.println(book.author());  // Robert C. Martin

Зверніть увагу на два моменти. По-перше, в record аксесори називаються не getTitle, а просто title(). По-друге, ви не побачите сетерів: запис даних у DTO відбувається під час створення обʼєкта. Для транспортних моделей це майже завжди ідеально, тому що заповнення DTO по частинах зазвичай призводить до помилок і непередбачуваності.

Якщо дуже хочеться «підправити поле», правильна думка така: ви не лагодите старий DTO, а створюєте новий. DTO — як чек: його можна передрукувати, але не потрібно «підтирати цифри гумкою», інакше бухгалтерія (і Jackson) вас не зрозуміє.

3. Що дає record: equals, hashCode, toString

Слова equals і hashCode звучать як щось із іспиту з Java Core, який ви намагалися не згадувати. Але в реальному бекенд-житті вони спливають несподівано часто: ви порівнюєте результати, кладете DTO в колекції, налагоджуєте їх і друкуєте в логах. І ось тут record раптом перетворюється на маленького помічника, який робить нудну роботу за вас.

Подивіться, наскільки читабельний toString у record:

record BookDto(String title, String author) {}

// Два обʼєкти з однаковими компонентами будуть рівними за equals()
BookDto a = new BookDto("Clean Code", "Robert C. Martin");
BookDto b = new BookDto("Clean Code", "Robert C. Martin");

// toString() у record автоматично друкує всі компоненти
System.out.println(a);           // BookDto[title=Clean Code, author=Robert C. Martin]
System.out.println(a.equals(b)); // true

Для навчального проєкту це корисно вже зараз, навіть без повноцінного логування (воно буде пізніше). Коли ви налагоджуєте JSON-мапінг, дуже часто хочеться просто вивести результат десеріалізації й очима перевірити: «Jackson взагалі прочитав те, що я очікував?». З record це виходить без ритуалів.

Запамʼятайте лише одне: equals/hashCode у record порівнюють усі компоненти. Для DTO це зазвичай нормально, тому що DTO справді «рівні», якщо в них однакові дані.

4. record і Jackson: JSON-мапінг

Тут важливо поєднати дві речі: JSON уже читається через ObjectMapper, а record дає для цього читання коротку й чесну форму DTO. Сама механіка readValue(...) нам уже знайома; корисно побачити, що record не потребує для неї жодної особливої магії.

Найменший приклад десеріалізації:

import tools.jackson.databind.ObjectMapper;

// DTO, у який мапимо JSON
record BookDto(String title, String author) {}

// Головний інструмент Jackson для читання/запису JSON
ObjectMapper mapper = new ObjectMapper();

// Найзручніше тестувати мапінг через text block
String json = """
    {"title":"Clean Code","author":"Robert C. Martin"}
    """;

// readValue: JSON-рядок -> Java-обʼєкт вказаного класу
BookDto book = mapper.readValue(json, BookDto.class);

// Далі працюємо вже не зі строкою, а з типізованими даними
System.out.println(book.title()); // Clean Code

Цього достатньо, щоб побачити головне: record спокійно працює на межі з JSON. Зворотне перетворення поводиться так само спокійно: той самий DTO Jackson уміє зібрати назад у JSON, якщо вам потрібні зразок відповіді, лог або тіло запиту.

Важливо: record тут не замінює Jackson. ObjectMapper залишається вашим перекладачем між JSON-рядком і Java-типами; record просто робить модель даних компактною.

Щоб зафіксувати це в голові, корисно один раз побачити схему потоку даних:

flowchart LR
    A["HttpResponse<String>.body() — сирий JSON"] --> B["ObjectMapper.readValue(...)"]
    B --> C["Транспортний DTO (record)"]
    C --> D["Далі код працює з полями: title(), author(), …"]

Якщо ви тримаєте цю схему в голові, ви перестаєте тягнути строку далі й починаєте працювати з даними нормально — як і хотів дорослий бекенд.

5. Вкладений JSON: обгортки та списки

Реальні відповіді зовнішніх API майже ніколи не виглядають як один обʼєкт із двома полями. Зазвичай у вас є обʼєкт-обгортка, наприклад відповідь пошуку, всередині — масив документів, а всередині кожного документа — поля, списки, іноді вкладені обʼєкти. І тут новачок часто помиляється: намагається десеріалізувати JSON одразу в List<...>, ігноруючи верхній рівень. Це зазвичай закінчується тим, що Jackson каже: «очікував масив, а отримав обʼєкт» — і формально він правий.

Тому правило просте: якщо JSON зверху — обʼєкт, то і DTO зверху має бути обʼєктом (record), навіть якщо нас цікавить лише масив усередині.

Приклад під наш сценарій пошук у каталозі: відповідь містить docs (список результатів) і numFound (скільки всього знайдено). Ми описуємо це двома record — обгорткою й елементом списку:

import java.util.List;

// Один елемент списку docs (одна «книга/документ» у видачі)
record ProviderDocDto(
    String key,
    String title,
    List<String> author_name // імʼя компонента повторює зовнішній JSON-контракт
) {}

// Відповідь верхнього рівня: обʼєкт-обгортка, усередині якого лежить масив docs
record ProviderSearchResponseDto(
    List<ProviderDocDto> docs,
    Integer numFound
) {}

Так, імʼя author_name виглядає не зовсім по-джавівськи. Але поки що ми чесно відображаємо зовнішній контракт: provider DTO — це дзеркало чужого API. У транспортному шарі іноді доводиться ковтати чужий стиль іменування, як гірку мікстуру. Головне — не розносити цей стиль по всьому застосунку.

Тепер десеріалізація такої відповіді виглядає цілком спокійно:

import tools.jackson.databind.ObjectMapper;
import java.util.List;

ObjectMapper mapper = new ObjectMapper();

// JSON "зверху" обʼєкт, тому читаємо в ProviderSearchResponseDto, а не в List<...>
String json = """
    {"docs":[{"key":"OL1M","title":"Clean Code","author_name":["Robert C. Martin"]}],
     "numFound":1}
    """;

ProviderSearchResponseDto dto = mapper.readValue(json, ProviderSearchResponseDto.class);

// Дістаємо дані з обгортки через аксесори record
System.out.println(dto.docs().get(0).title()); // Clean Code

Зверніть увагу, наскільки ці record схожі на JSON. І саме ця схожість — ваша головна опора в перші тижні роботи з контрактами: ви не вгадуєте структуру, а описуєте її, а потім працюєте з нею як із нормальними даними.

6. Типи полів: примітиви, обгортки, null

У шарі DTO типи полів — це не просто «який клас мені подобається більше». Це ваші очікування до даних провайдера. А зовнішній JSON, на жаль, не завжди готовий відповідати вашим очікуванням із підручника. Тому в транспортному DTO корисно бути трохи обережнішим і вибирати типи так, щоб вони виражали реальність: поле може бути відсутнім, може бути null, може бути порожнім списком — і це три різні стани.

Найчастіша розвилка — примітиви (int, long, boolean) проти обгорток (Integer, Long, Boolean). Примітиви не вміють бути null, а отже не вміють чесно виражати «значення не було». Обгортки вміють. Тому в DTO часто розумніше вибирати саме обгортки, особливо для даних із зовнішнього світу.

Ось приклад DTO з роком публікації, який може бути невідомий:

// Integer замість int: відсутність поля в JSON буде виражено як null, а не як "0"
record ProviderDetailsDto(
    String key,
    String title,
    Integer firstPublishYear
) {}

Чому Integer, а не int? Тому що якщо року немає, ви хочете отримати null, а не «магічний 0», який потім складно відрізнити від реального значення.

Та сама логіка працює зі списками. Якщо поле в JSON — масив, ви зазвичай описуєте його List<...>. Наприклад, список авторів:

import java.util.List;

record ProviderDocDto(
    String key,
    String title,
    List<String> author_name // список авторів із JSON-масиву
) {}

Тут є важливий нюанс, який новачкам часто ставить підніжку: record сам по собі незмінний, але List усередині нього — змінюваний обʼєкт. Тобто record дає вам «незмінність посилання», але не «глибоку незмінність усіх вкладених обʼєктів». Для DTO це не катастрофа, але краще тримати в голові: не потрібно змінювати списки всередині DTO по дорозі, інакше ви самі собі зробите баг, схожий на «дані раптово змінилися самі».

У межах поточного етапу ми не ускладнюємо й не будуємо окремих незмінних списків. Ми просто дисциплінуємо себе: DTO прочитали — далі з ним працюємо як із фактом, не намагаючись його редагувати.

7. Межа відповідальності транспортного DTO

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

Транспортні DTO (record) на клієнтському етапі проєкту мають залишатися простими: вони описують вхідні дані від провайдера й дають змогу їх прочитати. Складна логіка, нормалізація, перейменування, очищення даних — це окремі кроки, і ми не змішуємо їх прямо в DTO, інакше у вас вийде обʼєкт-восьминіг, який і дані зберігає, і форматує, і валідацію робить, і ще каву варить.

При цьому невеликі допоміжні методи, які не змінюють дані й допомагають із читабельністю, цілком допустимі. Наприклад, провайдер може повернути кількох авторів, але вам для виведення в консоль достатньо першого:

import java.util.List;

record ProviderDocDto(String key, String title, List<String> author_name) {

    // Безпечно отримати "першого автора" для виведення, не роздуваючи код перевірками
    String firstAuthorOrDash() {
        // Зовнішній контракт може повернути null або порожній список — обробляємо обидва випадки
        return (author_name == null || author_name.isEmpty()) ? "—" : author_name.get(0);
    }
}

Цей метод нічого не нормалізує глобально і не перетворює DTO на бізнес-сутність. Він просто робить ваш прикладний код читабельнішим: замість пʼяти перевірок на null ви викликаєте один метод. Але тримайте дисципліну: щойно ви починаєте додавати в DTO шматки логіки рівня «а давайте тут же вирішимо, що таке коректний author», — це вже натяк, що час виносити окремий шар обробки.

8. Типові помилки під час роботи з record DTO

Помилка №1: перетворювати DTO на універсальний мегаклас для всіх відповідей одразу.
Новачок часто намагається зробити один великий CatalogDto, куди запихає і поля пошуку, і поля деталей, і «про всяк випадок ще 20 полів, раптом стануть у пригоді». Це швидко перетворює мапінг на лотерею: половина полів завжди null, форма обʼєкта перестає відображати конкретний контракт, і ви починаєте плутатися, які поля взагалі мають бути заповнені в якому сценарії. Для кожної відповіді провайдера краще мати свою зрозумілу форму.

Помилка №2: робити транспортний DTO змінюваним за звичкою.
Якщо ви пишете звичайний class і додаєте сетери, зʼявляється спокуса дописувати обʼєкт по частинах: спочатку title, потім author, потім ще щось. Це погано поєднується з JSON-мапінгом, тому що DTO стає станом «у процесі», а не результатом. record допомагає позбутися цієї звички: DTO створюється одразу цілком, як результат читання контракту.

Помилка №3: використовувати примітиви там, де дані можуть бути відсутніми.
Поля зовнішніх API часто необовʼязкові, і якщо ви ставите int або long, то за відсутності поля отримаєте 0. Потім починаються дивні баги: «чому в усіх книжок рік видання 0?» або «чому кількість результатів завжди 0?». У DTO краще вибирати Integer/Long/Boolean, щоб відсутність значення виражалася чесно.

Помилка №4: очікувати «глибоку незмінність» через record.
record робить незмінними посилання на компоненти, але якщо компонент — це List, сам список можна змінювати. Якщо ви або хтось із колег почнете змінювати вміст списку всередині DTO, то обʼєкт начебто immutable, а дані вже змінилися. Для DTO в навчальному проєкті достатньо просто домовитися: DTO не змінюємо, використовуємо його як факт лише для читання.

Помилка №5: змішувати транспортний DTO й прикладну логіку виведення або обробки.
Дуже хочеться написати всередині record метод toPrettyConsoleOutput і ще кілька «зручних». Один-два маленькі helper-и допустимі, але якщо DTO починає знати про консоль, про форматування, про «як правильно відображати дані користувачеві» — це перший крок до архітектурної каші. DTO має описувати дані, а не політику їх використання.

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