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 — это разные сигналы. Если у вас есть клиенты, которые ожидают поле всегда, они могут начать ломаться. Даже в учебном проекте полезно проговаривать это как инженерное решение, а не как “сделаем красиво”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ