JavaRush /Курси /Spring Boot /Глобальні правила JSON

Глобальні правила JSON

Spring Boot
Рівень 12 , Лекція 1
Відкрита

1. Вступ

Коли ви вперше бачите, що @RestController «сам» віддає JSON, це сприймається як магія. Але насправді вона закінчується рівно там, де ви починаєте вимагати передбачуваності. У застосунку зазвичай багато ендпоїнтів, і якщо кожен віддаватиме JSON «як вийде», ви швидко отримаєте зоопарк форматів. Від нього стане сумно навіть вашим майбутнім тестам — а їм ще жити з вами.

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

Ми вже бачили механіку відповіді: контролер повертає об’єкт, MVC обирає HttpMessageConverter, Jackson формує JSON. Щойно ендпоїнтів стає більше ніж один, наступне запитання вже не «звідки взявся JSON», а «чому в усьому застосунку він має виглядати за одними й тими самими правилами».

Невелика схема, щоб не губитися в шарах:

flowchart LR
    A["Контролер повертає
Java-об’єкт"] --> B["Spring MVC
HttpMessageConverter"] B --> C["Jackson 3
JsonMapper (bean)"] C --> D["Текст JSON"] D --> E["HTTP-відповідь
Content-Type: application/json"]

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

2. JsonMapper у Spring Boot

Дуже типова junior-логіка виглядає так: «Нам потрібен JSON — отже, я зараз створю JsonMapper через new і буду ним користуватися». І в цей момент Spring Boot тихо зітхає, тому що він уже все зробив за вас: за наявності Jackson у classpath Boot автоматично створює і налаштовує JsonMapper як bean усередині ApplicationContext.

Важливо вловити правильну думку: JsonMapper у Boot — це інфраструктура, а інфраструктура має бути єдиною й узгодженою. Якщо ви створите свій mapper «десь у кутку» (new JsonMapper()), у вас раптово зʼявляться два різні світи: у контролерах JSON формує один mapper, який створив Boot, а у вашому коді — інший, саморобний. Потім ви почнете порівнювати два JSON і думати, що Spring «зламався». Ні, ви просто випадково відкрили портал у паралельну реальність.

Якщо читатимете старі статті, часто натрапите на застарілий namespace com.fasterxml.... У нашій базовій лінії Spring Boot 4 + Jackson 3 використовуємо tools.jackson..., і всі приклади тут узгоджені саме з цією лінією.

Показовий мініприклад: ми не використовуємо JsonMapper для відповіді контролера, а просто переконуємося, що він існує як bean і серіалізує дані за тими самими правилами, що й web-шар.

import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
class JacksonDiagnosticsConfiguration {

    @Bean
    ApplicationRunner jacksonDiagnostics(JsonMapper jsonMapper) {
        // Важливо: JsonMapper не створюємо через new — він приходить зі Spring-контексту.
        return args -> {
            // Діагностика: серіалізація через той самий mapper, який використовує web-шар.
            System.out.println(jsonMapper.writeValueAsString(Map.of("status", "ok")));
            // {"status":"ok"}
        };
    }
}

Зверніть увагу: JsonMapper приходить через DI. Це правильний «Boot-стиль»: інфраструктуру ми не створюємо, а використовуємо.

3. spring.jackson.* для спільних правил

Коли початківець хоче «підкрутити JSON», перша рука тягнеться писати @Configuration і збирати власний mapper. У багатьох випадках це передчасно: Spring Boot уже дає простий і читабельний шлях — властивості spring.jackson.* у application.yaml. Це саме той випадок, коли «спочатку конфігурація, потім код» — не модна філософія, а спосіб зберегти нерви.

Логіка така: ви змінюєте налаштування в YAML, Boot бачить їх на старті та застосовує до JsonMapper, який потім використовують і конвертори повідомлень, і будь-які ваші місця, куди ви (усвідомлено!) інжектите mapper. Виходить, що правило задається один раз і працює всюди однаково — без дублювання й без прихованих перевизначень у коді.

Мінімальний «скелет» налаштувань виглядає так:

spring:
  jackson:
    # Тут задаються глобальні правила серіалізації/десеріалізації для всього застосунку
    # (це застосовується до JsonMapper, який використовує Spring MVC).

Далі ви додаєте конкретні прапорці. У цій лекції ми розберемо кілька таких прапорців, але без перетворення на «Jackson deep dive»: курс у нас про Spring Boot, а не про нескінченні налаштування серіалізації. Інакше ми ризикуємо ніколи не дійти до кінця курсу.

Невелика «карта місцевості» — чим відрізняються підходи:

Що ви хочете змінити Найчастіше правильний інструмент Чому
Правило має діяти на всі JSON-відповіді spring.jackson.* Єдина політика, мінімум коду
Правило потрібне лише для одного типу (Money, наприклад) точкове налаштування конкретного типу Глобальні прапорці занадто широкі
Ви хочете «красивий» JSON лише для розробки spring.jackson.* (обережно) Швидко і видно всюди

4. default-property-inclusion і null-поля

Майже всі починають з «ідеального» JSON, де в кожного курсу є всі поля. А потім реальність нагадує, що деякі речі бувають необов’язковими: у частини курсів може не бути shortDescription, тому що контент-менеджер ще не дописав текст або тому що в навчальному проєкті ви просто не хочете ускладнювати дані. І ось тут постає питання: якщо поле shortDescription == null, чи потрібно надсилати його в JSON?

За замовчуванням Jackson зазвичай серіалізує null-значення, і ви отримуєте щось на кшталт "shortDescription": null. Це не помилка, але це сигнал: ви як автор API кажете клієнту «поле існує, але зараз воно порожнє». Іноді це корисно, іноді — просто шум.

Якщо ви хочете, щоб null-поля не потрапляли до JSON, Boot дає дуже просте глобальне правило:

spring:
  jackson:
    # non_null: не включати поля зі значенням null до JSON-відповіді
    default-property-inclusion: non_null

Тепер покажемо це на маленькому фрагменті, близькому до нашого catalog-service. Уявімо, що CourseCard уже є (як і в ТЗ проєкту), і shortDescription може бути null.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class JsonNullDemoController {

    @GetMapping("/api/catalog/demo/nulls")
    CourseCard demo() {
        // Демонстрація: shortDescription = null (у реальності поле може бути необов’язковим).
        return new CourseCard("spring-boot", null);
    }
}

Якщо default-property-inclusion не налаштовано, клієнт побачить JSON приблизно такого вигляду:

{"slug":"spring-boot","shortDescription":null}

А якщо увімкнути non_null, відповідь стане приблизно такою:

{"slug":"spring-boot"}

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

Щоб приклад був самодостатнім, ось мінімальна навчальна версія CourseCard (у проєкті вона буде багатшою, але принцип той самий):

class CourseCard {
    // Поля, які ми хочемо бачити (або не бачити) в JSON
    private final String slug;
    private final String shortDescription;

    CourseCard(String slug, String shortDescription) {
        // Якщо shortDescription = null, то поведінка залежатиме від default-property-inclusion
        this.slug = slug;
        this.shortDescription = shortDescription;
    }

    // Jackson за замовчуванням серіалізує властивості через гетери (або поля — залежно від налаштувань).
    public String getSlug() {
        return slug;
    }

    public String getShortDescription() {
        return shortDescription;
    }
}

5. indent-output і pretty JSON

У JSON є одна підступна особливість: машині байдуже, наскільки красиво він відформатований, а людині — ні. Ви відкриваєте відповідь у браузері або HTTP-клієнті й думаєте: «От би ще пробіли й переноси рядків, бо зараз це схоже на одну довгу макаронину». І це нормально, особливо поки ви навчаєтеся.

Для цього існує глобальний прапорець, який вмикає pretty-print (відступи):

spring:
  jackson:
    serialization:
      # true: увімкнути pretty-print (відступи та переноси рядків у JSON)
      indent-output: true

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

Якщо хочеться перевірити, як це впливає на JsonMapper, можна зробити мінідіагностику через ApplicationRunner:

import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Map;

@Configuration
class JacksonIndentDemoConfiguration {

    @Bean
    ApplicationRunner indentDemo(JsonMapper jsonMapper) {
        // Тут ми просто друкуємо серіалізацію в консоль, щоб побачити вплив indent-output.
        return args -> {
            System.out.println(jsonMapper.writeValueAsString(Map.of("title", "Spring Boot")));
            // За indent-output=true вивід буде багаторядковим і з відступами
        };
    }
}

І тут корисно тримати межу в голові: indent-output — це радше режим зручності для розробника, а не важлива частина контракту. Контракт — це поля, типи значень і стабільні назви. Відступи — приємна косметика (а косметика, як відомо, не все лікує, але настрій піднімає).

6. Дати й час: рядки та timestamps

Щойно у вашому JSON зʼявляються дати, половина світу починає сперечатися про формати, а друга половина — страждати від наслідків. Добра новина: для Java Time API (LocalDate, Instant тощо) Spring Boot зазвичай уже дає розумну базову лінію — ISO-рядки. Це принаймні читабельно, передбачувано й нормально працює з більшістю клієнтів.

Проте важливо знати про глобальний прапорець, який вирішує: рядок чи timestamp. Він трапляється в реальних проєктах, і краще дізнатися про нього на лекції, а не о 2-й годині ночі, коли ваш фронт раптом отримав число замість дати.

spring:
  jackson:
    serialization:
      # false: серіалізувати дати у вигляді ISO-рядків, а не числових timestamp
      write-dates-as-timestamps: false

Щоб побачити ефект, можна серіалізувати Instant — просто як демонстрацію глобальної поведінки, не прив’язуючись до доменної моделі:

import tools.jackson.databind.json.JsonMapper;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Instant;
import java.util.Map;

@Configuration
class JacksonDateDemoConfiguration {

    @Bean
    ApplicationRunner dateDemo(JsonMapper jsonMapper) {
        return args -> {
            // Демонстрація: Instant перетвориться на рядок або число — залежно від write-dates-as-timestamps
            System.out.println(
                    jsonMapper.writeValueAsString(Map.of("now", Instant.parse("2026-03-19T12:00:00Z")))
            ); // {"now":"2026-03-19T12:00:00Z"}
        };
    }
}

Головна думка тут не в тому, щоб терміново все змінювати. Навпаки: зазвичай краще залишити ISO-рядки. Але корисно розуміти, що такі правила глобальні. Якщо ви увімкнете timestamps «заради одного місця», ви зміните поведінку всіх ендпоїнтів, де є дати, — і здивування буде масовим.

7. Межі spring.jackson.*

Є дуже спокусливий підхід: «О, у мене одне поле некрасиво серіалізується… Давайте підкрутимо spring.jackson.*, це ж зручно!» І ось ви випадково виносите один локальний випадок на рівень політики всього застосунку. Це приблизно як лагодити одну розетку, вимкнувши світло в усьому під’їзді. Формально проблему ви вирішили: у цій розетці точно немає напруги. Але сусідам чомусь не сподобалося.

Тут корисно тримати в голові критерій: глобальні властивості підходять для правил рівня «весь сервіс має так працювати». Наприклад, «не надсилати null», «не вмикати timestamps», «робити JSON читабельнішим». А якщо ви хочете змінити серіалізацію конкретного типу (наприклад, щоб Money відображався по-особливому) або одного поля (наприклад, тільки price), то глобальні властивості — занадто важкий молоток.

Поки що наша мета — навчитися спочатку шукати рішення в spring.jackson.*, але не перетворювати це на звичку «лікувати все налаштуванням». Для локальних випадків потрібні точкові інструменти: вони змінюють один тип, а не весь застосунок.

8. Типові помилки під час налаштування JSON

Помилка №1: створювати свій JsonMapper через new, бо «так зрозуміліше».
Це здається простим рішенням, але воно створює два набори правил серіалізації: один у Spring MVC (через автоконфігурацію Boot), другий у вашого саморобного mapper’а. У підсумку один і той самий об’єкт може перетворюватися на різний JSON залежно від місця, і відлагодження перетворюється на квест «знайди, який mapper зараз використовувався».

Помилка №2: використовувати JsonMapper у контролері для формування відповіді.
Іноді роблять return jsonMapper.writeValueAsString(obj); і повертають String. Це вбиває сенс @RestController: контролер має повертати дані, а не займатися серіалізацією. Плюс ви починаєте вручну керувати Content-Type, екрануванням та іншими дрібницями. Найчастіше це зайва робота і зайві помилки.

Помилка №3: вирішувати проблему одного типу глобальним прапорцем.
Наприклад, ви захотіли, щоб лише Money був рядком, і ввімкнули якесь глобальне правило, яке змінює поведінку половини моделей. Потім інший ендпоїнт раптово змінює форму, тести падають, а ви довго не пов’язуєте ці події. Глобальні правила — це політика застосунку, а не швидкий фікс.

Помилка №4: вмикати indent-output і потім забувати, що це косметика, а не контракт.
Pretty JSON зручний для навчання і ручної перевірки, але він не має ставати сенсом налаштування. Небезпека в тому, що ви починаєте судити API за красою форматування, а не за стабільністю структури. Клієнту важливі поля, значення та їхні типи, а пробіли — це бонус.

Помилка №5: змінювати default-property-inclusion без думки про контракт.
non_null справді зменшує шум, але змінює семантику: відсутність поля і null — це різні сигнали. Якщо у вас є клієнти, які очікують поле завжди, вони можуть почати ламатися. Навіть у навчальному проєкті корисно проговорювати це як інженерне рішення, а не як «зробімо красиво».

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