JavaRush /Курсы /Spring REST & MVC /Аннотации Jackson и граница DTO

Аннотации Jackson и граница DTO

Spring REST & MVC
12 уровень , 4 лекция
Открыта

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 везде, потому что «так проще жить».
Терпимый вход действительно иногда нужен, но если он включён по умолчанию “на всякий случай”, вы начинаете проглатывать ошибки клиента. Опечатка в поле превращается в тихую проблему, которая всплывает позже и уже не там, где возникла. Гораздо здоровее, когда строгость — базовое ожидание, а терпимость включается там, где вы прямо готовы её поддерживать.

1
Задача
Spring REST & MVC, 12 уровень, 4 лекция
Недоступна
Раздельные request/response DTO для черновика объявления
Раздельные request/response DTO для черновика объявления
1
Задача
Spring REST & MVC, 12 уровень, 4 лекция
Недоступна
Минимальный набор аннотаций для черновика фото
Минимальный набор аннотаций для черновика фото
1
Опрос
Jackson Контракты, 12 уровень, 4 лекция
Недоступен
Jackson Контракты
Имена полей и алиасы
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ