JavaRush /Курсы /Java Server /Когда JSON не совпа...

Когда JSON не совпадает с DTO

Java Server
16 уровень , 3 лекция
Открыта

1. Несовпадение JSON и DTO — это нормально

Если вы ожидаете, что внешний JSON всегда будет идеально подходить под ваш record, то у меня для вас две новости. Хорошая: вы оптимист. Плохая: сеть быстро лечит оптимизм. Внешний провайдер живёт своей жизнью: добавляет поля, переименовывает, иногда отдаёт null, иногда просто “не отдаёт поле вообще”, а иногда отдаёт не тот тип. И это не “редкий крайний случай”, а типичная рутина интеграции.

Важно принять простую мысль: Jackson — это не “магия, которая всё починит”, а строгий переводчик. Он читает контракт (JSON) и пытается переложить его на вашу модель (DTO). Если модель описана честно — всё хорошо. Если модель “примерно похожа” — вы получаете либо null/дефолтные значения, либо исключение. Оба варианта опасны по‑своему: исключение громкое, но полезное (сразу видно проблему), а тихий null может доехать до позднего NullPointerException — и вот он уже вообще не похож на “ошибку маппинга”, хотя началось всё именно там.

Чтобы держать голову в порядке, полезно видеть всю цепочку как отдельный конвейер. Он не огромный, но в нём есть явная точка, где мы обязаны “остановить мир”, если контракт не совпал:

flowchart TD
    %% (8) Схема потока: сначала проверяем статус, потом маппим JSON в DTO
    A["HttpResponse<String> status + body"] --> B{"status 2xx?"}
    B -- нет --> C[обработка ошибки провайдера не пытаемся читать success DTO]
    B -- да --> D[ObjectMapper.readValue]
    D --> E[Provider DTO]
    E --> F[дальше код работает уже с типами]
    D --> G["JsonProcessingException (контракт не совпал)"]

Заметьте деталь: маппинг должен случаться после проверки status, иначе вы легко попытаетесь прочитать “ошибочный JSON” как “успешный DTO” и получите бессмысленную кашу из исключений. Это не потому, что Jackson плохой — это потому, что мы пытались перевести на русский текст, который на самом деле был на китайском.

2. Имена полей и snake_case

Первые проблемы обычно начинаются не с “типов”, а с “имён”. В Java мы привыкли к camelCase, а во внешнем JSON очень часто встречается snake_case. И если вы просто назовёте компонент record по‑своему, Jackson не обязан догадаться, что authorNames — это то же самое, что author_name. Он не телепат, он библиотека.

Самый понятный для новичка способ — явная привязка имени поля через @JsonProperty. Да, это чуть больше букв в коде, но зато вы буквально показываете: “вот это поле из JSON соответствует вот этому компоненту в Java”. Это хорошая сделка: несколько букв в обмен на предсказуемость.

Представим кусок ответа поиска в стиле Open Library (примерно, без претензии на идеальную копию):

{
  "docs": [
    {
      "key": "OL1M",
      "title": "Clean Code",
      "author_name": ["Robert C. Martin"]
    }
  ]
}

Если мы хотим назвать компонент по‑человечески (authorNames), то делаем так:

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public record OpenLibraryDocDto(
        String key,
        String title,
        // Явно связываем имя поля в JSON (snake_case) с именем компонента в Java (camelCase)
        @JsonProperty("author_name") List<String> authorNames
) {}

Здесь важно, что @JsonProperty("author_name") — это не “для красоты”. Это инструкция Jackson: “когда увидишь author_name, положи его в authorNames”.

И теперь десериализация будет выглядеть как “нормальная работа”, а не “игра угадайку”:

import tools.jackson.databind.ObjectMapper;

public class Demo {
    public static void main(String[] args) throws Exception {
        // Тот самый JSON, который пришёл от провайдера
        String json = """
            {"key":"OL1M","title":"Clean Code","author_name":["Robert C. Martin"]}
            """;

        // ObjectMapper — точка, где JSON превращается в типизированный DTO
        ObjectMapper mapper = new ObjectMapper();
        OpenLibraryDocDto dto = mapper.readValue(json, OpenLibraryDocDto.class);

        // Благодаря @JsonProperty список авторов реально заполняется
        System.out.println(dto.authorNames().get(0)); // Robert C. Martin
    }
}

Иногда у провайдера бывает ещё веселее: поле могли переименовать, или разные эндпоинты называют одно и то же по‑разному. Для таких случаев существует @JsonAlias, где вы перечисляете альтернативные имена. Я бы не стал злоупотреблять этим, но как “ремень безопасности” — вещь полезная: вы явно фиксируете, что в JSON могли встретиться варианты, и вы их принимаете.

Ключевой вывод тут простой: имя компонента record — это часть контракта со стороны Java, а @JsonProperty — это “мостик” к контракту провайдера. Чем явнее мостик, тем меньше сюрпризов.

3. Отсутствующие поля и null

На этом месте обычно происходит типичная “джуниорская трагедия” в трёх актах. Акт первый: поле не пришло, Jackson положил null. Акт второй: вы зовёте .isEmpty на списке. Акт третий: NullPointerException, и вы подозреваете, что проблема “в Java”, хотя проблема “в контракте”.

Нужно различать как минимум три состояния, которые внешне выглядят похожими, но смысл у них разный:

Состояние в JSON Пример Что окажется в DTO (обычно) Почему это важно
Поле нет { "title": "Clean Code" } null (для ссылочных типов) Это “не передали значение вообще”
Поле есть и равно null { "first_publish_year": null } null Это “значение есть, но оно пустое/неизвестное”
Поле есть и коллекция пустая { "author_name": [] } List размера 0 Это “значений нет, но список как сущность есть”

Если тип в DTO выбран неудачно, вы сами себе добавляете проблем. Самый частый пример — использование примитивов там, где поле может отсутствовать.

Посмотрите, насколько коварен int:

import com.fasterxml.jackson.annotation.JsonProperty;

public record DetailsDto(
        String title,
        // Примитивный int не может быть null: если поле не пришло, будет 0 (и это легко принять за реальные данные)
        @JsonProperty("first_publish_year") int firstPublishYear
) {}

Если JSON придёт без first_publish_year, Jackson (скорее всего) поставит 0. И теперь у вас в приложении появляется “книга из нулевого года”. Поздравляю, вы только что изобрели античную литературу будущего.

Гораздо честнее использовать Integer, чтобы отсутствие значения было выражено как null:

import com.fasterxml.jackson.annotation.JsonProperty;

public record DetailsDto(
        String title,
        // Wrapper-тип допускает null: так отсутствие поля не маскируется под "0"
        @JsonProperty("first_publish_year") Integer firstPublishYear
) {}

И теперь поведение прозрачнее:

import tools.jackson.databind.ObjectMapper;

public class Demo {
    public static void main(String[] args) throws Exception {
        // Провайдер не прислал first_publish_year
        String json = """
            {"title":"Clean Code"}
            """;

        ObjectMapper mapper = new ObjectMapper();
        DetailsDto dto = mapper.readValue(json, DetailsDto.class);

        // И это честно видно в DTO: null, а не "0"
        System.out.println(dto.firstPublishYear()); // null
    }
}

Отдельная боль — списки. Если провайдер иногда не отдаёт author_name, Jackson положит null, и ваш код на нормализации (в следующей лекции) может легко упасть. Тут можно сделать маленький, очень практичный трюк: в record добавить компактный конструктор и заменить null на пустой список. Это не “сложная архитектура”, это просто защита от реальности.

import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;

public record OpenLibraryDocDto(
        String key,
        String title,
        @JsonProperty("author_name") List<String> authorNames
) {
    public OpenLibraryDocDto {
        // На границе JSON → DTO нормализуем null в пустой список, чтобы дальше не ловить NPE
        if (authorNames == null) authorNames = List.of();
    }
}

Теперь authorNames.isEmpty можно вызывать спокойно, без риска получить NPE.

Смысл всего раздела: когда поле может отсутствовать, это должно быть видно из типа (Integer, List, а не int). А когда null ломает дальнейшую логику, лучше один раз аккуратно “почистить” его на границе DTO, чем потом ловить случайные NPE по всему коду.

4. Неизвестные поля и расширение DTO

Следующая классика жанра: вы описали DTO на три поля, а провайдер прислал двадцать. Вы про эти двадцать вообще ничего не знаете и знать не хотите. Но Jackson может сказать: “Стоп. У вас в модели такого поля нет — значит, контракт не совпал”.

Для новичка это выглядит как “Jackson вредничает”. Для backend‑разработчика это выглядит как “вопрос дисциплины: мы хотим быть строгими или терпимыми к расширению контракта?”

Представим JSON, где провайдер добавил поле edition_count, а мы его не описали:

{
  "key": "OL1M",
  "title": "Clean Code",
  "edition_count": 42
}

И DTO без этого поля:

public record BookDto(String key, String title) {}

Если Jackson настроен строго, вы получите исключение уровня “неизвестное поле”. Это полезно, когда вы хотите замечать любые изменения контракта. Но для внешнего провайдера это иногда слишком нервно: сегодня добавили поле — и у вас упал весь клиент, хотя вы это поле даже не читали.

Самый простой и очень распространённый компромисс для provider DTO — игнорировать неизвестные поля. Тогда провайдер может добавлять новые поля, и ваш клиент не будет падать на ровном месте.

Это делается либо аннотацией на DTO:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public record BookDto(String key, String title) {}

Либо настройкой ObjectMapper (но это уже ближе к “конфигурации маппера”, а мы договорились не уходить в глубокую настройку). Аннотация хороша тем, что решение “игнорировать лишнее” лежит прямо рядом с DTO и не превращается в “глобальную магию”.

Но тут важно не уйти в другую крайность. Если вы игнорируете неизвестные поля везде и всегда, вы можете пропустить свою собственную ошибку. Например, вы ожидали author_name, а в аннотации написали @JsonProperty("autor_name") (потеряли букву h). Jackson в строгом режиме мог бы подсказать вам: “слушай, в JSON поле author_name вообще-то есть, но ты его не маппишь”. В режиме “игнорируй всё” вы получите просто null — и будете отлаживать это гораздо дольше.

Поэтому держите в голове баланс: игнорировать неизвестные поля полезно, но делать это нужно осознанно, обычно именно на provider DTO, где контракт “чужой” и может расширяться без предупреждения. А вот насколько строго мы будем относиться к своей внутренней модели — это отдельная дисциплина. Сейчас нам достаточно научиться не падать из‑за “лишних полей”, но при этом не терять способность замечать реальные ошибки маппинга.

5. Ошибки типов при десериализации

Самая неприятная категория проблем — когда JSON синтаксически корректен, status вроде бы успешный, но маппинг падает, потому что типы не совпали. И это действительно неприятно: глазами вы смотрите на JSON и думаете “ну норм же”, а Jackson говорит “нет, не норм”.

Примеры типовых “типовых” несоответствий:

  • поле приходит строкой "2008", а вы ждёте число 2008;
  • поле приходит числом, а вы ждёте строку;
  • поле приходит объектом {...}, а вы ждёте массив [...] (или наоборот);
  • поле приходит иногда как null, а у вас в DTO примитив (int, boolean) и вы потом не понимаете, откуда взялся 0 или false.

Вот минимальный пример, где год публикации приходит строкой (а мы ждём Integer):

import tools.jackson.databind.ObjectMapper;

public class Demo {
    public static void main(String[] args) {
        // JSON синтаксически валиден, но поле не того типа (строка вместо числа)
        String json = """
            {"title":"Clean Code","first_publish_year":"two thousand eight"}
            """;

        ObjectMapper mapper = new ObjectMapper();

        try {
            // Ошибка всплывёт именно здесь: на этапе JSON → DTO
            mapper.readValue(json, DetailsDto.class);
        } catch (Exception e) {
            // В реальном коде лучше логировать/оборачивать с контекстом, но для примера хватит маркера
            System.out.println("JSON не совпал с DTO"); // JSON не совпал с DTO
        }
    }
}

С точки зрения приложения важно две вещи.

Во‑первых, ошибку маппинга нужно ловить там же, где вы делаете readValue, а не “где-нибудь потом”. Если вы пропустите исключение вверх без контекста, в main оно превратится в “что-то про Jackson”, и студент (и будущий вы) будет не понимать, на каком этапе всё сломалось: сеть? статус? JSON? типы?

Во‑вторых, полезно отделять “ошибки провайдера” от “ошибок нашего маппинга”. Для этого не надо строить иерархию из 12 классов исключений (мы не на курсе “enterprise‑церемония”), достаточно хотя бы обернуть исключение в понятное сообщение.

Например, простой helper на границе client‑кода:

import tools.jackson.core.JsonProcessingException;
import tools.jackson.databind.ObjectMapper;

public class ProviderJsonReader {
    // Один mapper на инстанс: здесь же концентрируется вся логика чтения provider JSON
    private final ObjectMapper mapper = new ObjectMapper();

    public <T> T read(String json, Class<T> type) {
        try {
            // Граница: преобразуем строковый JSON в конкретный DTO
            return mapper.readValue(json, type);
        } catch (JsonProcessingException e) {
            // Оборачиваем в понятное исключение с контекстом — на каком DTO сломалось
            throw new IllegalStateException("Provider JSON is not compatible with DTO: " + type.getSimpleName(), e);
        }
    }
}

Это коротко, но очень практично: теперь любая ошибка десериализации превращается в сообщение “провайдерский JSON не совместим с DTO такого-то типа”, а не просто “JsonMappingException где-то в недрах”. И вы сразу видите, на какой модели сломалось.

Обратите внимание: мы не делаем “умный автопочин” типов. Если провайдер прислал строку вместо числа, это не “надо тихо проглотить”, это сигнал: либо вы неверно описали DTO (скорее всего), либо провайдер нарушил свой же контракт (тоже бывает). В любом случае это надо заметить явно.

6. Типичные ошибки при маппинге JSON в DTO

Ошибка №1: надеяться, что имена полей совпадут “как-нибудь сами”.
Очень частая ловушка — назвать компонент authorNames и ожидать, что он заполнится из author_name без каких‑либо подсказок. В итоге DTO приходит “пустой”, а вы начинаете подозревать HttpClient, сеть, таймауты и даже фазу Луны. На практике почти всегда проще и честнее поставить @JsonProperty и сразу показать соответствие.

Ошибка №2: использовать примитивы там, где поле может отсутствовать.
int и boolean — удобные типы, но они не умеют выражать “значения нет”. Если поле не пришло, вы получаете 0 или false, а дальше пытаетесь интерпретировать это как реальные данные. Для внешнего JSON это особенно опасно. Integer и другие wrapper‑типы обычно дают более честное поведение, потому что null заставляет вас принять решение осознанно.

Ошибка №3: не различать “поля нет”, “поле null” и “пустая коллекция”.
Эти три состояния легко спутать, особенно если вы только перешли от строкового JSON к typed DTO. Но они означают разные вещи и по‑разному ломают код. Если список авторов может быть отсутствующим, лучше привести null к пустому списку в компактном конструкторе record, чем ловить NPE на нормализации.

Ошибка №4: падать из‑за неизвестных полей провайдера и считать это “ошибкой Jackson”.
Провайдеры любят добавлять поля. Это не всегда изменение контракта “в плохом смысле”, иногда это просто расширение. Если ваш DTO описывает только то, что вам нужно, и вы не хотите падать от каждого лишнего поля, используйте @JsonIgnoreProperties(ignoreUnknown = true) на provider DTO. Но делайте это осознанно, понимая, что излишняя терпимость может скрыть ваши опечатки.

Ошибка №5: ловить ошибки десериализации слишком далеко от границы JSONDTO.
Когда исключение вылетает далеко наверх, оно теряет контекст и превращается в “что-то про Jackson”. Гораздо полезнее, чтобы ошибка фиксировалась рядом с readValue: так вы понимаете, что сеть и статус тут ни при чём, проблема именно в несовпадении контракта и модели. Мини‑обёртка вокруг ObjectMapper часто даёт больше пользы, чем сотня println в случайных местах.

1
Задача
Java Server, 16 уровень, 3 лекция
Недоступна
Привязка `author_name` к `authorNames` через `@JsonProperty`
Привязка `author_name` к `authorNames` через `@JsonProperty`
1
Задача
Java Server, 16 уровень, 3 лекция
Недоступна
Сообщение об ошибке при несовпадении типа поля
Сообщение об ошибке при несовпадении типа поля
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ