JavaRush /Курсы /Java Server /Внутренний API каталога и DTO

Внутренний API каталога и DTO

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

1. Внешний контракт: не тащим в проект

Когда у вас только один запрос и один файл, кажется, что можно «быстренько» прочитать JSON прямо в main() и сразу распечатать поля. Но у внешнего API есть характер: сегодня поле называется так, завтра иначе; сегодня авторы приходят массивом, завтра объектом; сегодня null, завтра поле вообще пропало. Если эти детали размазать по проекту, вы получаете эффект «трещина в одной стене — и пошли трещины по всему дому».

Самая неприятная часть тут не в том, что что-то сломается (сломается обязательно), а в том, где именно сломается. Если внешний контракт «протёк» в разные классы, то при изменении провайдера вы будете чинить и печать, и бизнес-логику, и разбор аргументов запуска, и ещё пару мест, о существовании которых вы уже забыли. В этот момент вы начинаете подозревать, что код живёт собственной жизнью и питается вашей энергией.

«Протекший контракт» на практике

Обычно это выглядит невинно: один раз вы достали provider DTO прямо в точке входа, потом второй раз — в печати результатов, потом третий раз — «ну тут тоже надо было». И внезапно в вашем проекте появляется много мест, где все знают, что у провайдера есть поле author_name, и все по-своему решают, что делать, если оно null.

import java.util.List;

public class BadIdeaExample {
    // Provider DTO (внешняя модель) оказался внутри «обычного» кода — это и есть протечка контракта
    record ProviderBookDoc(String key, String title, List<String> authorNames) {}

    void print(ProviderBookDoc doc) {
        // UI/вывод теперь напрямую зависит от чужой структуры данных
        System.out.println(doc.key() + " " + doc.authorNames()); // [Robert C. Martin]

        // Проблема: если провайдер поменяет формат authorNames, «сломается» не клиент, а вывод
    }
}

Вроде бы всё работает. Но вы только что сделали вывод (и значит, пользователя) зависимым от чужой структуры. Это как если бы вы в квартире не только пользовались водой, но и держали в гостиной кусок городского водопровода «чтобы было ближе».

План лечения в ReadLater Starter

Идея простая: внешний мир пусть остаётся внешним. Мы создаём внутренний API клиента каталога — пару методов вроде search(query) и details(externalId), которые возвращают normalized DTO, понятные именно нашему приложению. Всё, что связано с HTTP, provider JSON и маппингом, прячется внутри клиентского слоя.

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

flowchart TD
    A[ReadLaterApplication] --> B["CatalogClient
внутренний API"] B --> C[RealCatalogClient] B --> D[MockCatalogClient] C --> E[HTTP вызов + provider DTO + mapping] D --> F[sample JSON + provider DTO + mapping] E --> G["(внешний API провайдера)"]

Главная мысль: наружу из CatalogClient выходит только нормализованная модель. Остальное — «внутренняя кухня». Пользователь получает блюдо, а не рецепт, список кастрюль и температуру плиты.

Как только основной путь выбран, вопрос уже не про синтаксис Jackson. Нужно провести границу: где заканчивается чужой JSON и начинается язык нашего приложения. Именно из этого дальше и вырастают normalized DTO, CatalogClient и отдельный mapper.

2. Нормализованные DTO: «язык» нашего приложения

Нормализованные DTO — это не «ещё одна копия данных», а способ сделать проект стабильнее и проще для чтения. Внешний провайдер может хотеть хранить десять авторов, три варианта названия, пять идентификаторов и поле edition_count. Нашему приложению на текущем этапе обычно нужно меньше: внешний id, название и один автор «как строка». И это нормально: мы не обязаны тащить весь интернет в свой код.

Важный момент: нормализованные DTO должны быть удобны для потребителя внутри проекта. В нашем случае потребитель — команды catalog search и catalog details, которые выводят результат пользователю. Значит, модель должна быть достаточно полной для вывода и при этом достаточно простой, чтобы её можно было легко собрать из provider DTO.

Минимальный пример normalized DTO для каталога

Сделаем два DTO: один для результата поиска (короткий), второй для деталей (чуть богаче). В реальном проекте это будут отдельные файлы, но как идея выглядит так:

import java.util.Objects;

public record CatalogBookSearchItem(String externalId, String title, String author) {
    public CatalogBookSearchItem {
        // Нормализованная модель: внутри приложения мы хотим предсказуемые (не-null) поля
        Objects.requireNonNull(externalId);
        Objects.requireNonNull(title);
        Objects.requireNonNull(author);
    }
}

Здесь мы сразу фиксируем три важных свойства. Во-первых, externalId — это то, что потом можно передать в команду details. Во-вторых, title — это то, что человек читает глазами. В-третьих, author — уже «нормализованная» строка, даже если у провайдера авторов было много или они были странно представлены.

Если захочется добавить ещё поля в CatalogBookDetails (например, год первого издания), это будет наш выбор, а не обязательство перед провайдером. Нормализация — это умение сказать: «спасибо, лишнее не надо».

3. CatalogClient: внутренний API приложения

Внутренний API — это ваш маленький договор внутри проекта: какие возможности «каталога» доступны остальному коду. Его задача — сделать так, чтобы ReadLaterApplication и прочие классы не знали про HttpClient, ObjectMapper, endpoint-ы провайдера и названия его JSON-полей. Они должны знать только то, что им действительно нужно: «найди книги» и «дай детали книги».

Это похоже на пульт от телевизора. Пульт умеет «включить», «громкость», «канал». Он не показывает вам схему электроники и не просит выбрать напряжение для матрицы. И это прекрасно: вы хотите смотреть сериал, а не защищать кандидатскую по видеосигналу.

Интерфейс CatalogClient: два метода

Сделаем интерфейс (или просто контракт), который возвращает только normalized DTO:

import java.util.List;

public interface CatalogClient {
    // Внутренний API: только то, что нужно приложению (без деталей HTTP/JSON)
    List<CatalogBookSearchItem> search(String query);

    // Детали по книге по внешнему идентификатору (который вернул search)
    CatalogBookDetails details(String externalId);
}

Обратите внимание, чего здесь нет. Здесь нет HttpResponse<String>, нет provider DTO, нет JsonNode, нет ObjectMapper. Внутренний API специально «туповат» и короткий — так и должно быть. Чем меньше деталей вы выдаёте наружу, тем меньше деталей потом придётся поддерживать в десяти местах.

Интерфейс: практическая польза

Для новичка интерфейс иногда выглядит как «ещё один файл ради ещё одного файла». Но здесь он решает очень практическую задачу: один и тот же внутренний API должен работать и в real-режиме, и в mock-режиме. Если у вас есть интерфейс CatalogClient, то ваш код, который печатает результат, не должен знать, откуда пришли данные. А значит, вы можете переключать режимы, не переписывая половину проекта.

И да: это то место, где вы начинаете ощущать вкус слова «подмена реализации» безо всяких фреймворков. Просто Java, просто здравый смысл.

4. Provider DTO: рядом с клиентом

Provider DTO — это ваши «перчатки» для работы с чужим JSON. Они могут быть странными, с подчёркиваниями, с необязательными полями, с неожиданными типами. И в этом нет трагедии: они отражают реальность внешнего мира. Но внутрь приложения (в наш нормальный код) эти странности попадать не должны.

Хорошая привычка: как только вы заметили, что где-то вне каталожного слоя вы импортируете ...dto.provider... — это сигнал, что внешний контракт уже начал протекать. Не конец света, но повод остановиться и вернуть границу на место.

Небольшой пример provider DTO с «неудобными» именами

Провайдер может назвать поле author_name, а мы хотим у себя authorNames. Это классическая история. На уровне provider DTO это решается локально и один раз:

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

public record ProviderBookDoc(
        String key,
        String title,
        // Привязываем «кривое» имя поля из JSON к нормальному имени в Java
        @JsonProperty("author_name") List<String> authorNames
) {
    // Важно: это DTO существует только «рядом с клиентом», а не во всём приложении
}

Теперь вся «грязь» имени author_name заперта в одном месте. В идеале ровно в одном месте на весь проект. Ваш CatalogClient и ваши normalized DTO вообще не должны знать, что кто-то там где-то любит подчёркивания.

5. Маппинг provider → normalized: один переводчик

Маппинг — это тот самый «переводчик», который превращает чужой формат данных в наш. Очень хочется иногда маппить «прямо там, где надо», потому что это быстрее. Но потом вы внезапно обнаруживаете, что один и тот же перевод «в трёх вариантах» живёт в трёх местах, и все они чуть-чуть разные. В этот момент вы получаете типичную баг-историю: «в поиске автор Unknown, а в деталях автор пустая строка, а в третьем месте автор null». Поздравляю, вы изобрели три стандарта.

Лучше завести маленький класс-маппер или хотя бы приватные методы маппинга внутри клиента. Главное — чтобы преобразование было централизовано и было рядом с provider DTO, а не рядом с UI-выводом.

Минимальный mapper для поискового элемента

Вот маленький пример маппинга ProviderBookDoc в CatalogBookSearchItem. Он специально короткий и делает только то, что нужно:

import java.util.List;

public class CatalogMapper {
    CatalogBookSearchItem toSearchItem(ProviderBookDoc doc) {
        // Внешний API нестабилен: список авторов может быть null или пустым
        List<String> authors = doc.authorNames();

        // Решение о дефолте принимаем здесь, централизованно
        String author = (authors == null || authors.isEmpty()) ? "Unknown" : authors.get(0);

        // На выходе — строго нормализованная модель, удобная для остального приложения
        return new CatalogBookSearchItem(doc.key(), doc.title(), author);
    }
}

Здесь спрятан очень важный практический нюанс. У провайдера авторы могут быть null или пустыми — и это не «редкий случай», это обычная жизнь API. Мы принимаем решение: если автора нет, мы выводим "Unknown". Это решение теперь живёт в одном месте, а не размазано по проекту.

Если позже вы решите, что нужно показывать первого автора и через запятую второго — поменяете одну функцию, а не будете искать десять одинаковых doc.authorNames().get(0) по проекту.

6. RealCatalogClient: реализация поверх HTTP

Теперь соберём всё в реальную реализацию CatalogClient, которая ходит во внешний API. Важно держать в голове цель: снаружи мы обещаем search(query) и details(externalId) с normalized DTO. Внутри мы делаем всё, что нужно, чтобы это обещание выполнить: строим URI, вызываем HTTP, получаем JSON, читаем provider DTO, затем маппим в normalized DTO.

Да, это несколько шагов. Но это честные шаги. И они будут лежать в одном месте, а не расползаться по проекту. Это как собрать «станцию очистки воды» на входе в дом, а не фильтровать воду отдельной марлей в каждой кружке.

Скелет RealCatalogClient: зависимости и конструктор

Мы уже знаем два ключевых объекта: HttpClient и ObjectMapper. Добавим к ним CatalogMapper. И ещё нам нужен baseUrl (пока можно хранить как строку/константу).

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;

public class RealCatalogClient implements CatalogClient {
    // Важно: зависимости передаются снаружи — так проще тестировать и подменять реализацию
    private final HttpClient httpClient;
    private final ObjectMapper objectMapper;
    private final CatalogMapper mapper;

    public RealCatalogClient(HttpClient httpClient, ObjectMapper objectMapper, CatalogMapper mapper) {
        this.httpClient = httpClient;
        this.objectMapper = objectMapper;
        this.mapper = mapper;
    }

    // Дальше будут методы search/details и приватные helper-методы для HTTP и парсинга
}

Смысл конструктора здесь очень приземлённый. Мы не создаём HttpClient и ObjectMapper внутри класса, чтобы не получить «объект, который сам себе всё создаёт». Такой код трудно контролировать, трудно переключать на mock и вообще он ведёт себя как кот: делает, что хочет, и смотрит на вас с осуждением.

Метод search(query): наружу список normalized DTO, внутри — provider DTO

Внутри search мы читаем provider response, затем маппим каждый документ в нормализованный элемент.

import java.util.List;

public List<CatalogBookSearchItem> search(String query) {
    // 1) Внутри клиента мы делаем HTTP и получаем сырое тело ответа (JSON)
    String json = callSearchApi(query); // внутри: HTTP GET и body как String

    // 2) Превращаем JSON в provider DTO (внешняя модель остаётся внутри этого слоя)
    ProviderSearchResponse response = readSearchResponse(json);

    // 3) Маппим provider DTO в нормализованные DTO, которые «понимает» приложение
    return response.docs().stream().map(mapper::toSearchItem).toList();
}

Заметьте приём: мы специально спрятали «громоздкие» шаги за маленькие методы callSearchApi(query) и readSearchResponse(json). Это не ради «красоты», а ради читаемости. Когда вы читаете search, вы видите бизнес-смысл: получить JSON → распарсить → превратить в результат. А детали HTTP и Jackson остаются внутри класса.

И да, ProviderSearchResponse здесь существует, но он существует внутри RealCatalogClient. Он не возвращается наружу и не упоминается в интерфейсе.

Метод details(externalId): договоримся о поведении на not found

Для деталей обычно удобно иметь чёткое поведение: либо вернули карточку, либо явно сказали, что книги нет. На уровне внутреннего API это можно оформить исключением (не потому что мы любим исключения, а потому что у нас команда, которая хочет либо распечатать карточку, либо показать понятное сообщение).

public CatalogBookDetails details(String externalId) {
    // 1) Достаём JSON деталей по внешнему идентификатору
    String json = callDetailsApi(externalId);

    // 2) Парсим provider DTO (это ещё не то, что хочет видеть приложение)
    ProviderDetailsResponse provider = readDetailsResponse(json);

    // 3) Переводим в нормализованную карточку книги
    return mapper.toDetails(provider);
}

Мы пока не углубляемся в идеальную обработку кодов ответа и исключений — это отдельная большая тема. Здесь важнее другое: даже в details наружу выходит только CatalogBookDetails, а provider-структура остаётся внутренней.

7. MockCatalogClient: тот же внутренний API, другой источник данных

Mock-режим нужен не для того, чтобы «обмануть пользователя», а чтобы сделать проект воспроизводимым. Внешний API может быть недоступен, может поменять контракт, может начать банить вас за слишком активный учебный энтузиазм. И если ваш проект работает только при счастливом расположении звёзд — это не учебный проект, это квест.

Но mock-режим имеет смысл только тогда, когда он не ломает внутренний API. То есть MockCatalogClient обязан возвращать ровно те же normalized DTO, что и реальный клиент. Режим меняет источник данных, а не формат результата.

Пример: mock search читает sample JSON и возвращает normalized результат

В mock-клиенте мы берём JSON из ресурсов, читаем его тем же ObjectMapper, получаем provider DTO и маппим тем же маппером. Это важный момент: mock должен быть максимально похож на реальность по форме данных.

import java.io.IOException;
import java.io.InputStream;
import java.util.List;

public List<CatalogBookSearchItem> search(String query) {
    // Читаем зафиксированный ответ провайдера из ресурсов (reproducible поведение)
    try (InputStream is = getClass().getResourceAsStream("/mock/search.json")) {
        if (is == null) {
            throw new IllegalStateException("Mock resource not found: /mock/search.json");
        }

        // Парсим ровно тем же ObjectMapper и в те же provider DTO, что и в real-клиенте
        ProviderSearchResponse response = objectMapper.readValue(is, ProviderSearchResponse.class);

        // Возвращаем наружу только нормализованную модель (как и обещает CatalogClient)
        return response.docs().stream().map(mapper::toSearchItem).toList();
    } catch (IOException e) {
        throw new IllegalStateException("Cannot read mock search response", e);
    }
}

Обратите внимание: сигнатура search здесь такая же, как у CatalogClient. Для остального приложения real и mock должны выглядеть одинаково: checked exceptions наружу не текут, а проблема с ресурсом или чтением mock-JSON превращается в явную runtime-ошибку.

8. Одна точка выбора режима: снаружи всегда CatalogClient

Когда в проекте появляется две реализации, появляется и типичная ловушка: начать разносить if (useMock) по всему коду. Это почти гарантированно заканчивается тем, что в одном месте вы забыли ветку, в другом перепутали, а в третьем сделали «ну тут по-быстрому». Итог: режимов как бы два, а поведения — пять.

Правильнее выбрать реализацию в одном месте (обычно в точке сборки приложения) и дальше везде работать через CatalogClient. Тогда вся остальная часть приложения живёт в счастливом неведении и не превращается в “if-else” ферму.

Мини-фабрика: выбрать реализацию и вернуть один интерфейс

Пока без конфигов и сложных систем — просто метод, который возвращает CatalogClient.

import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;

public class CatalogClients {
    public static CatalogClient create(boolean useMock, HttpClient httpClient, ObjectMapper objectMapper) {
        // Маппер общий: одинаковые правила нормализации для real и mock
        CatalogMapper mapper = new CatalogMapper();

        // Ровно одна точка выбора режима: дальше приложение работает только через CatalogClient
        return useMock
                ? new MockCatalogClient(objectMapper, mapper)
                : new RealCatalogClient(httpClient, objectMapper, mapper);
    }
}

Обратите внимание: наружу возвращается CatalogClient, а не RealCatalogClient. Это маленькое, но очень важное усилие. Оно как подписка на спортзал: само по себе не делает вас сильнее, но резко повышает шанс, что вы не превратитесь в “if-else” человека.

9. Типичные ошибки при работе с внутренним API

Ошибка №1: вернуть наружу provider DTO «потому что так быстрее».
На короткой дистанции это действительно экономит время — не нужно писать маппинг, можно сразу использовать готовую структуру. Но вы платите за это тем, что ваш ReadLaterApplication начинает знать про чужой JSON и его формат. Любое изменение у провайдера превращается в каскад правок по всему проекту. Хороший индикатор проблемы — если вы видите импорты из dto.provider вне каталожного слоя, значит граница уже протекла и её нужно вернуть.

Ошибка №2: делать маппинг прямо в месте вывода «потому что тут всего три строки».
Так почти всегда начинается дублирование. Сначала три строки, потом ещё один сценарий, потом ещё один — и в итоге у вас несколько копий почти одинакового кода, которые по-разному обрабатывают null, пустые списки и форматирование. Маппинг должен жить в одном месте: отдельный CatalogMapper или хотя бы приватные методы внутри клиента, но не “размазан” по коду.

Ошибка №3: разнести useMock по всему проекту и получить две разные логики.
Когда режим mock/real влияет не только на источник данных, но и на поведение кода, вы фактически создаёте две версии приложения. В итоге начинается классическая проблема: “в mock всё работает, а в real падает”. Причина почти всегда в том, что это уже не один поток логики. Режим должен переключать только источник данных, а формат и обработка результата обязаны оставаться едиными.

Ошибка №4: не учитывать, что внешний API может вернуть неожиданные данные.
Поля могут отсутствовать, списки — быть пустыми, значения — null. Если маппинг написан “в лоб”, приложение быстро превращается в генератор NullPointerException. Решение простое: в одном месте (в маппере) задавать понятные дефолты — "Unknown", пустые коллекции, “нет данных” — и не размазывать проверки по всему проекту.

Ошибка №5: держать защиту от null и дефолты в разных местах кода.
Иногда разработчики вроде бы учитывают нестабильность внешнего API, но делают это точечно: где-то проверка на null, где-то if (empty), где-то дефолтная строка. В итоге поведение становится непредсказуемым. Все такие решения должны быть централизованы — в маппере или адаптере. Тогда у вас один источник правды, а не набор случайных договорённостей по коду.

1
Задача
Java Server, 17 уровень, 1 лекция
Недоступна
Поиск через внутренний API каталога
Поиск через внутренний API каталога
1
Задача
Java Server, 17 уровень, 1 лекция
Недоступна
Детали книги через внутренний API
Детали книги через внутренний API
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ