1. Аннотации Jackson — специи
Если вы когда-нибудь пытались «починить» суп, насыпав туда полбанки перца, вы понимаете идею этого раздела. С Jackson-аннотациями ровно то же самое: они отлично работают, когда добавлены точечно, но легко превращаются в попытку исправить плохую модель данных «наклейками». И в итоге вы получаете DTO, который читать страшнее, чем StackTrace на проде.
Главная мысль сегодняшней лекции звучит так: сначала мы проектируем DTO и контракт, а потом добавляем минимальный набор аннотаций, если они действительно делают контракт стабильнее и понятнее. Не наоборот.
После пары удачных аннотаций такой соблазн появляется особенно быстро: хочется лечить ими вообще любую JSON-проблему.
Чтобы не быть голословным, давайте представим две ситуации.
Первая, «здоровая»: у нас есть TaskDetailsResponse, где в Java-коде мы хотим назвать поле чуть короче (например, assignee), но в JSON контракте мы хотим оставить привычное клиенту assigneeName. Это отличное место для @JsonProperty — маленькая точечная настройка, которая делает API стабильнее.
Вторая, «опасная»: мы сделали один универсальный TaskDto, пытаемся использовать его и как request, и как response, а потом добавляем в него @JsonAlias, @JsonIgnore, @JsonIgnoreProperties, ещё пару @JsonProperty, и внезапно DTO превращается в «комок компромиссов». Он вроде работает, но он больше не объясняет контракт — он маскирует хаос.
В нашем Task Tracker API (базовый пакет com.example.tasktracker) это особенно важно, потому что проект специально учебный и прозрачный: мы хотим, чтобы любой студент мог открыть DTO и понять, что API принимает и возвращает, а не угадывать это по набору аннотаций «на всякий случай».
2. Порядок решений для DTO и JSON
Когда начинаешь активно работать с JSON, легко попасть в ловушку «а давайте подкрутим Jackson». Но правильный порядок — обратный: сначала вы решаете вопросы контракта, потом — вопросы имени, потом — совместимость входа, и только в самом конце — «тяжёлая артиллерия». Этот раздел — про то, как держать в голове такой порядок, чтобы не превратить DTO в мини-конфиг файл на аннотациях.
Полезно иметь простую ментальную модель: DTO — это договор, Jackson — переводчик, аннотации — подсказки переводчику. Если договор плохой, переводчик не спасёт. Он может только аккуратно перевести то, что вы ему дали.
Ниже — «дерево решений», которое хорошо работает в учебном проекте и в реальных командах.
flowchart TD
A[Нужно настроить JSON поведение] --> B{Проблема в форме DTO?}
B -->|Да| C[Пересобрать DTO: request/response отдельно, явные поля]
B -->|Нет| D{Это request или response?}
D -->|Response| E{"Нужно другое имя поля в JSON?"}
E -->|Да| F["@JsonProperty на поле/record component"]
E -->|Нет| G[Оставить как есть]
D -->|Request| H{Нужно принять альтернативное имя?}
H -->|Да| I["@JsonAlias минимально"]
H -->|Нет| J[Каноническое имя без alias]
J --> K{Что делать с неизвестными полями?}
I --> K
K -->|Строго| L[Оставить строгую политику]
K -->|Терпимо| M["@JsonIgnoreProperties(ignoreUnknown = true)"]
F --> N{Есть внутренние поля, которые могут утечь?}
G --> N
N -->|Да| O[Лучше отдельный DTO; если нужно — @JsonIgnore]
N -->|Нет| P[Готово]
Обратите внимание на первый вопрос: «Проблема в форме DTO?». Это ключ. Часто мы начинаем с аннотаций, а надо начать с дизайна.
Чтобы закрепить это более «приземлённо», вот маленькая таблица-подсказка, чем обычно решается какая задача.
| Задача в контракте | Обычно достаточно | Почему этого хватает |
|---|---|---|
| В JSON нужно другое имя, чем в Java | @JsonProperty | Это прямой и понятный способ закрепить каноническое имя |
| Нужно принять старое/альтернативное имя поля во входе | @JsonAlias (на request DTO) | Помогает читать input, не меняя output контракт |
| Нужно скрыть внутреннее/служебное поле | лучше отдельный DTO, иногда @JsonIgnore | Скрывать можно, но лучше вообще не тащить поле в публичную модель |
| Лишние поля во входном JSON | strict или @JsonIgnoreProperties(ignoreUnknown = true) | Это именно политика приёма input, а не naming |
3. Request DTO: канон и совместимость
Request DTO первым встречает внешний JSON, поэтому именно здесь особенно легко начать “лечить всё аннотациями”. Чтобы этого не случилось, держите короткий порядок решений.
- У поля должно быть одно каноническое имя в контракте.
- Если Java-имя и JSON-имя должны расходиться, фиксируем внешний ключ через @JsonProperty.
- Если нужно временно принять старое имя того же поля, добавляем @JsonAlias.
- Если вопрос уже не в имени, а в судьбе лишних ключей, это отдельное решение: strict input или @JsonIgnoreProperties(ignoreUnknown = true).
Полезный тест простой: после десериализации сервис должен видеть одно нормальное свойство, а не помнить всю историю старых имён клиента. Если alias или tolerant input начинают протекать дальше request DTO, вы уже чините не JSON, а архитектурную трещину.
4. Response DTO: стабильный JSON
Response DTO — это ваше лицо. Он не должен угадывать, что имел в виду клиент; его задача — стабильно отдавать канонический JSON.
- Чаще всего здесь нужен только @JsonProperty, если Java-имя и публичный JSON-ключ разошлись.
- @JsonAlias почти никогда не нужен: ответ не должен говорить с разными клиентами на разных диалектах.
- @JsonIgnore или @JsonIgnoreProperties уместны, когда в Java-тип затесалось известное служебное свойство. Но если скрывать приходится много, это уже не “тонкая настройка”, а сигнал вынести отдельный response DTO.
Хороший response DTO читается как готовый JSON-контракт даже без знания внутренней модели. Если без пачки Jackson-аннотаций он совсем непонятен, проблема обычно в форме DTO, а не в нехватке ещё одной настройки.
5. Переаннотированный DTO: запахи
Есть очень хороший бытовой индикатор: если DTO трудно читать, значит контракт трудно объяснить. А если контракт трудно объяснить — клиентам будет ещё веселее (и это не тот весёлый, который мы любим).
Переаннотированность обычно проявляется не в количестве аннотаций как таковом, а в ощущении: «это поле вообще что означает и почему у него столько вариантов имён?».
Посмотрим на пример «как делать не надо». Я специально сделаю его максимально неприятным — чтобы у вас появилось лёгкое желание закрыть ноутбук и уйти в лес. Это чувство полезно: оно помогает потом писать хороший код.
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
public class BadTaskDto {
// В JSON поле называется "title", но мы почему-то храним это в text (уже тревожный звоночек)
@JsonProperty("title")
// Список alias превратился в «словарь синонимов»: так контракт перестаёт быть контрактом
@JsonAlias({"taskTitle", "name", "caption", "task_name"})
public String text;
// Внутреннее состояние спрятали аннотацией, но сам факт его наличия в DTO — запах смешения ролей
@JsonIgnore
public String internalState;
}
Что здесь не так, даже если оно «работает»:
В поле text смешаны сразу несколько смыслов: оно и “title”, и “name”, и “caption”. Мы не знаем, это одно и то же или разные понятия. Если это одно и то же, зачем четыре alias? Если это разные понятия, почему вы склеили их в одно поле? Плюс internalState спрятан, но сам факт, что он тут есть, намекает: модель данных уже наполовину внутренняя, наполовину внешняя.
Как это лечится по-взрослому, но в рамках нашего курса: вы разносите роли на разные DTO. Для входа — request DTO (где может быть alias), для выхода — response DTO (где alias не нужен). И часто после этого аннотаций становится меньше, потому что модель сама стала честнее.
6. Граница аннотаций: custom serializer
В этом разделе хочется сделать важную паузу. Когда человек узнаёт, что аннотациями можно многое, он обычно делает следующий логический шаг: «а если аннотаций не хватит, я напишу свой serializer». Это звучит героически, как “я сам напишу свой JSON”, но на практике это часто означает: вы усложняете проект раньше времени. Custom serializer/deserializer — полезный инструмент, но это уже не «настройка DTO», это код, который нужно поддерживать, тестировать и объяснять.
Custom serializer — это буквально Java-класс, который говорит Jackson: “когда будешь писать это значение в JSON, делай вот так”. Давайте посмотрим на минимальный скелет. Даже в простом виде он уже длиннее, чем @JsonProperty, и требует регистрации (а регистрация — это уже конфигурация Jackson на уровне приложения).
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
public class TaskStatusSerializer extends JsonSerializer<TaskStatus> {
@Override
public void serialize(TaskStatus value, JsonGenerator gen, SerializerProvider p)
throws IOException {
// Явно задаём формат: пишем enum как строку (например, "OPEN"), а не как объект/число
gen.writeString(value.name());
}
}
Выглядит не страшно, но теперь у вас появляются новые вопросы: где этот serializer подключается, для каких DTO он работает, что будет при обратном чтении, как это документировать, как это тестировать, и почему оно вообще отличается от дефолтного поведения.
В нашем учебном REST-проекте подавляющее большинство задач решается проще: @JsonProperty фиксирует имя, @JsonAlias помогает принять альтернативу, @JsonIgnore скрывает внутренности, @JsonIgnoreProperties(ignoreUnknown = true) делает вход терпимым. Это четыре отвёртки, которыми можно собрать очень много мебели. Custom serializer — это уже «станок в гараже». Он классный, но вы не хотите тащить его в квартиру, чтобы прикрутить ножку к стулу.
Хороший практический критерий: если вы хотите custom serializer только ради переименования поля, ради того, чтобы принять два имени во входе, или ради того, чтобы спрятать внутреннее поле — вы почти наверняка решаете проблему слишком тяжёлым способом. Аннотации и нормальный дизайн DTO закрывают эти кейсы гораздо проще и прозрачнее.
Но и у этого подхода есть граница. Как только JSON перестаёт быть полностью статичным — появляются generic wrapper'ы, tree model или частично динамические куски документа — одних аннотаций на обычном DTO уже мало. В этот момент нужны более гибкие JSON-инструменты, а не ещё один слой @Json* поверх старого класса.
7. Типичные ошибки при работе с Jackson-аннотациями
Ошибки с Jackson-аннотациями обычно коварны тем, что компилятор молчит, приложение запускается, а проблемы всплывают уже на уровне контракта: клиент перестал понимать ваш JSON, тесты начали падать, документация перестала совпадать с реальностью. Поэтому полезно заранее знать самые частые «грабли» и узнавать их по звуку.
Ошибка №1: начинать с аннотаций, а не с формы DTO.
Если вы ловите себя на мысли «сейчас добавлю ещё одну аннотацию — и станет нормально», остановитесь и спросите: а DTO вообще правильный по смыслу? Возможно, вы пытаетесь одним классом описать и вход, и выход, и внутреннее состояние. В таком случае аннотации станут косметикой на плохом дизайне, а не инструментом усиления контракта.
Ошибка №2: превращать @JsonAlias в словарь синонимов.
Alias полезен, когда у вас есть ровно один–два реальных альтернативных ключа, которые означают одно и то же. Когда alias становится списком из пяти вариантов (name, title, caption, taskTitle, task_name), DTO перестаёт быть контрактом, а становится угадайкой. Клиент начинает присылать “как получится”, и вы сами перестаёте понимать, что считается правильным.
Ошибка №3: ожидать, что @JsonAlias повлияет на ответы.
@JsonAlias нужен для чтения входного JSON. Он не должен становиться частью публичной формы ответа. Если вы поймали себя на желании «а давайте и в ответе иногда будем отдавать assignee», остановитесь: это уже разрушение контрактной дисциплины. Ответ должен быть каноничным, иначе клиенты начинают зависеть от случайностей.
Ошибка №4: скрывать половину модели через @JsonIgnore вместо создания нормального response DTO.
@JsonIgnore — полезная кнопка “не показывать лишнее”, но если вы применяете её на каждом втором поле, это почти всегда означает, что вы сериализуете наружу не то. В нормальном API доменная модель не обязана совпадать с внешним JSON. Если скрывать приходится много — проще и чище сделать отдельный DTO и явный маппинг.
Ошибка №5: включать ignoreUnknown = true везде, потому что «так проще жить».
Терпимый вход действительно иногда нужен, но если он включён по умолчанию “на всякий случай”, вы начинаете проглатывать ошибки клиента. Опечатка в поле превращается в тихую проблему, которая всплывает позже и уже не там, где возникла. Гораздо здоровее, когда строгость — базовое ожидание, а терпимость включается там, где вы прямо готовы её поддерживать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ