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

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

Spring Boot
12 уровень , 1 лекция
Открыта

1. Введение

Когда вы впервые увидели, что @RestController “сам” отдаёт JSON, это ощущается как магия, но на самом деле магия заканчивается ровно там, где вы начинаете хотеть предсказуемости. У приложения обычно много endpoint’ов, и если каждый будет отдавать JSON “как получится” — вы быстро получите зоопарк форматов, от которого грустно даже вашим же будущим тестам (а им ещё жить с вами).

Глобальные правила JSON — это договорённости, которые должны действовать одинаково во всём приложении. Например, вы можете решить, что null-поля не нужно отправлять клиенту, потому что это просто лишний шум. Или наоборот — вы хотите всегда отправлять null, чтобы контракт был максимально “явный”. Самое важное: глобальное правило — это не “настройка для одного места”, это настройка на уровне политики приложения.

Механику ответа мы уже видели: controller возвращает объект, MVC выбирает HttpMessageConverter, Jackson пишет JSON. Как только endpoint’ов становится больше одного, следующий вопрос уже не “откуда взялся JSON”, а “почему он во всём приложении должен выглядеть по одним и тем же правилам”.

Небольшая схема, чтобы не теряться в слоях:

flowchart LR
    A["Controller возвращает
Java-объект"] --> B["Spring MVC
HttpMessageConverter"] B --> C["Jackson 3
JsonMapper (bean)"] C --> D["JSON текст"] D --> E["HTTP response
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()), то у вас внезапно появятся два разных мира: в controller’ах JSON формируется одним mapper’ом (Boot’овским), а в вашем коде — другим (самодельным). А потом вы будете сравнивать два JSON и думать, что Spring “сломался”. Нет, просто вы случайно открыли портал в параллельную реальность.

Если будете читать старые статьи, часто встретите legacy namespace com.fasterxml.... В нашем baseline 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, который потом используют и message converters, и любые ваши места, куда вы (осознанно!) инжектите 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"}

Здесь важно не “вау, стало короче”, а логика контракта. Когда вы скрываете 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 есть одна подлая особенность: машинe всё равно, красиво он отформатирован или нет, а человеку — не всё равно. Вы открываете ответ в браузере или в 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 обычно уже даёт разумный baseline — 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 “ради одного места”, вы измените поведение всех endpoint’ов, где есть даты — и удивление будет массовым.

7. Границы spring.jackson.*

Есть очень заманчивый подход: “О, у меня одно поле некрасиво сериализуется… Давайте подкрутим spring.jackson.*, оно же удобно!” И вот вы случайно выносите один локальный случай на уровень политики всего приложения. Это примерно как чинить одну розетку, выключив свет во всём подъезде. Формально проблему вы решили: в этой розетке точно нет напряжения. Но соседям почему-то не понравилось.

Здесь полезно держать в голове критерий: глобальные свойства подходят для правил уровня “весь сервис должен так делать”. Например, “не отправлять null”, “не включать timestamps”, “делать JSON более читаемым”. А если вы хотите изменить сериализацию конкретного типа (например, чтобы Money отображался по-особому) или одного поля (например, только price), то глобальные свойства — слишком тяжёлый молоток.

Пока что наша цель — научиться сначала искать решение в spring.jackson.*, но не превращать это в привычку “настройкой лечить всё”. Для локальных случаев нужны более точечные инструменты: они меняют один тип, а не всё приложение.

8. Типичные ошибки при настройке JSON

Ошибка №1: создавать свой JsonMapper через new, потому что “так понятнее”.
Это кажется простым решением, но оно создаёт два набора правил сериализации: один у Spring MVC (через Boot auto-configuration), второй у вашего самодельного mapper’а. В итоге один и тот же объект может превращаться в разный JSON в зависимости от места, и отладка превращается в квест “найди, какой mapper сейчас использовался”.

Ошибка №2: использовать JsonMapper в controller для формирования ответа.
Иногда делают return jsonMapper.writeValueAsString(obj); и возвращают String. Это убивает смысл @RestController: controller должен возвращать данные, а не заниматься сериализацией. Плюс вы начинаете вручную управлять Content-Type, экранированием и прочими мелочами. Чаще всего это лишняя работа и лишние ошибки.

Ошибка №3: решать проблему одного типа глобальным флагом.
Например, вы захотели, чтобы “только Money” был строкой, и включили какое-то глобальное правило, которое меняет поведение половины моделей. Потом другой endpoint неожиданно меняет форму, тесты падают, а вы долго не связываете эти события. Глобальные правила — это политика приложения, а не “быстрый фикс”.

Ошибка №4: включать indent-output и потом забывать, что это “косметика”, а не контракт.
Pretty JSON удобен для обучения и ручной проверки, но он не должен стать смыслом настройки. Опасность в том, что вы начинаете судить API по красоте форматирования, а не по стабильности структуры. Клиенту важны поля, значения и их типы, а пробелы — это бонус.

Ошибка №5: менять default-property-inclusion без мысли о контракте.
non_null реально уменьшает шум, но меняет семантику: отсутствие поля и null — это разные сигналы. Если у вас есть клиенты, которые ожидают поле всегда, они могут начать ломаться. Даже в учебном проекте полезно проговаривать это как инженерное решение, а не как “сделаем красиво”.

1
Задача
Spring Boot, 12 уровень, 1 лекция
Недоступна
Глобальное скрытие `null`-поля через `spring.jackson.*`
Глобальное скрытие `null`-поля через `spring.jackson.*`
1
Задача
Spring Boot, 12 уровень, 1 лекция
Недоступна
Использование auto-configured `JsonMapper` в `ApplicationRunner`
Использование auto-configured `JsonMapper` в `ApplicationRunner`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ