JavaRush /Курсы /Spring REST & MVC /Metadata и content: два чтения

Metadata и content: два чтения

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

1. Metadata и content: два чтения

Когда мы впервые добавляем вложения в API, в голове легко рождается наивная картинка: «Ну есть же файл, значит endpoint должен вернуть файл». А потом через полчаса хочется ещё и список вложений, потом — экран “детали” вложения (имя, размер, описание), потом — удаление… и внезапно оказывается, что “файл” и “информация о файле” — это два разных сценария чтения. И это нормально: не вы первые, не вы последние, кто наступает на эти грабли.

Как только у attachment появляется отдельный download endpoint, это различие становится особенно видно: списки и карточки работают с metadata, а скачивание — с самим content.

У одного и того же attachment’а может быть несколько представлений. В нашем курсе мы не делаем сложный content negotiation-цирк и не пытаемся «одним URI отдавать всё на свете», зато делаем честный и понятный контракт. Поэтому мы сознательно разделяем ответы: один endpoint возвращает metadata в JSON, другой endpoint возвращает content (бинарное тело) для скачивания. Снаружи это выглядит как очень простая идея, но именно она сохраняет API “лёгким” и предсказуемым.

Частые сценарии: metadata vs download

Если вы когда-нибудь пользовались задачником (да хоть в корпоративном чате), вы замечали одну вещь: мы намного чаще смотрим список вложений, чем реально скачиваем каждое из них. Список — это быстрый обзор: “что прикрепили?”, “как называется?”, “какого размера?”, “это PNG или PDF?”, “когда загрузили?”. Скачивание — это отдельное действие, обычно осознанное: “Окей, мне нужен именно этот файл”.

В API это превращается в очень практическое различие. Metadata нужна для отображения интерфейса, а интерфейс обычно строится из маленьких быстрых запросов, которые можно повторять без боли. Если в “просмотр metadata” вы случайно подтянете содержимое файла, то любой список вложений станет тяжеленной операцией: вместо 20 строк JSON вы начнёте гонять десятки мегабайт. И вот вы уже сделали “приложение для задач”, которое ощущается как «приложение для тестирования сети и терпения».

Отсюда рождается базовая архитектурная мысль: metadata endpoint должен быть лёгким. Он должен быть безопасен для частого использования, для списков и для detail-view. А скачивание файла — это отдельный сценарий, который хорошо выделить в отдельный endpoint. Тогда клиент может сначала получить metadata, показать пользователю кнопку “Download”, и только при реальном желании пользователя скачать файл — идти за бинарным содержимым.

2. Файл в DTO: плохой компромисс

Очень хочется сделать “универсальный” ответ вида AttachmentResponse, где есть и имя файла, и размер, и… byte[] content. Кажется: «Один запрос — и у клиента сразу всё». На практике это не “один запрос”, а “один очень проблемный запрос”.

Во‑первых, JSON не умеет передавать бинарные данные напрямую. Когда вы кладёте byte[] в response DTO, Jackson сериализует это как Base64-строку. То есть вы не просто передаёте байты, вы передаёте байты, превращённые в текст, который становится больше примерно на треть. Для маленьких файлов это ещё терпимо, но как только файл становится “нормальным офисным”, ваш API превращается в фабрику гигантских строк.

Во‑вторых, вы начинаете терять саму идею download endpoint как корректного HTTP-ответа. Вместо Content-Type: application/pdf и нормального Content-Disposition, вы отдаёте application/json, внутри которого лежит base64. Некоторые клиенты смогут это обработать, но это уже совсем другая модель, и она гораздо менее дружелюбна к миру: к браузеру, к curl, к Postman, к “скачать и открыть двойным кликом”.

В‑третьих, вы провоцируете проблемы с памятью. Когда вы отдаёте byte[], вы фактически вынуждаете сервер полностью материализовать файл в памяти, а потом ещё и материализовать base64-строку (которая часто ещё больше). Это не “сложная инфраструктура”, это просто плохая привычка, которая “в учебном проекте прокатывает”, а потом внезапно перестаёт прокатывать, когда к вашему API приходит первый реальный пользователь с реальным файлом.

Посмотрим на антипример — как это выглядит в DTO:

package com.example.tasktracker.api.dto.response;

// ❌ Антипример: в DTO для JSON кладём бинарное содержимое файла.
// В результате оно уедет наружу как Base64-строка и сделает ответ тяжёлым.
public record AttachmentWithContentResponse(
        String id,
        String originalFileName,
        byte[] content // ❌ будет base64 в JSON и будет тяжело
) {}

Проблема тут не в record и не в том, что Java плохая. Проблема в том, что мы смешали два разных мира: “metadata как JSON” и “content как скачиваемый файл”. И получили гибрид, который неудобен сразу всем.

Правильный “public response” для metadata выглядит иначе: это информация о файле, но не сам файл.

package com.example.tasktracker.api.dto.response;

import java.time.Instant;

// ✅ DTO для metadata: только то, что нужно UI/клиенту для списка и карточки вложения.
// Никаких байтов файла внутри.
public record AttachmentResponse(
        String id,                 // публичный id вложения
        String taskId,             // принадлежность задаче (контекст подресурса)
        String originalFileName,   // имя, которое видел пользователь при загрузке
        String contentType,        // MIME-тип (например, application/pdf)
        long size,                 // размер файла в байтах
        String description,        // описание/комментарий к вложению
        Instant uploadedAt         // когда загрузили
) {}

Такой DTO лёгкий, его приятно возвращать списком, его приятно читать человеку, и клиент может строить UI без того, чтобы каждый раз платить сетевой ценой “как будто скачал файл”.

3. Один endpoint для JSON и файла: путаница

После осознания “байты в JSON — плохо” появляется вторая хитрая мысль: «Окей, тогда пусть будет один endpoint GET /attachments/{id}, но если клиент хочет скачать файл — он отправит какой-то параметр или заголовок, и сервер ответит файлом. А если не хочет — сервер вернёт JSON». Технически так можно. Но в учебном проекте (и часто в коммерческом тоже) это создаёт больше путаницы, чем пользы.

Можно пытаться делать это через query parameter вроде ?download=true. Можно пытаться делать это через заголовок Accept, где клиент говорит Accept: application/json или Accept: application/octet-stream. Но тогда ваш контракт становится неочевидным: один и тот же URL начинает вести себя по-разному, и клиенту нужно помнить “тайное заклинание”, чтобы получить нужный ответ.

В практической разработке это приводит к двум типам боли. Во-первых, люди начинают ошибаться: забывают параметр, получают JSON вместо файла, начинают парсить файл как JSON и удивляться, почему “сломался парсер”. Во-вторых, начинают появляться “полу-работающие” клиенты: один клиент скачивает, другой получает metadata, третий вообще ломается, потому что у него где-то на уровне HTTP-клиента автоматически проставляется Accept: application/json.

В нашем курсе мы хотим API, который читается глазами и почти не требует “угадывания”. Поэтому мы выбираем простую и прозрачную модель: metadata endpoint и отдельный download endpoint. Да, это плюс один URI. Зато это минус десять “почему оно так странно работает” вопросов.

Небольшая таблица, чтобы зафиксировать разницу “в одном месте”:

Что мы хотим получить Endpoint Content-Type ответа Тело ответа Зачем это нужно
Metadata вложения GET /api/v1/tasks/{taskId} /attachments/{attachmentId} application/json JSON DTO Показать имя, размер, описание, дату загрузки
Скачать файл GET /api/v1/tasks/{taskId} /attachments/{attachmentId}/download application/octet-stream бинарное тело (Resource) Реально получить содержимое файла

Эта схема простая и “самодокументируемая”: даже не зная проекта, вы по URI понимаете, где metadata, а где контент.

4. storageKey: внутренняя деталь

Теперь давайте поговорим о ещё одном соблазне: “Ну раз у нас есть какой-то путь к файлу, давайте просто вернём его клиенту”. Например: “Вот metadata, а вот storageKey или path, по нему скачаете”. Кажется, что это удобно. На деле это почти гарантированно превращается в утечку инфраструктуры наружу.

storageKey — это внутренняя штука. Его назначение — помочь серверу найти физический файл в хранилище. Клиенту не нужно знать, как именно вы храните файл: в папке, в подпапке, по UUID, по хэшу, по дате, в трёх разных директориях… клиенту это неинтересно. Клиенту интересны две вещи: “что это за файл?” и “как его скачать/удалить через API?”.

Поэтому в нашей внутренней модели storageKey может быть, но в public DTO — нет. Для иллюстрации покажем, как может выглядеть внутренняя модель metadata. Нам здесь важна сама идея: поле есть, но наружу не уходит.

package com.example.tasktracker.domain.model;

import java.time.Instant;

// Внутренняя модель: хранит всё, что нужно серверу для работы с файлом.
public class AttachmentMetadata {
    private final String id;
    private final String taskId;

    // ✅ Только для сервера: по этому ключу мы найдём файл в хранилище.
    // ❌ В public DTO это поле не должно попадать.
    private final String storageKey;

    private final String originalFileName;
    private final String contentType;
    private final long size;
    private final String description;
    private final Instant uploadedAt;

    public AttachmentMetadata(String id, String taskId, String storageKey,
                              String originalFileName, String contentType,
                              long size, String description, Instant uploadedAt) {
        this.id = id;
        this.taskId = taskId;
        this.storageKey = storageKey;
        this.originalFileName = originalFileName;
        this.contentType = contentType;
        this.size = size;
        this.description = description;
        this.uploadedAt = uploadedAt;
    }

    public String getStorageKey() { return storageKey; }

    // Остальные геттеры опущены ради краткости примера:
    // getId(), getTaskId(), getOriginalFileName(), getContentType(),
    // getSize(), getDescription(), getUploadedAt(), ...
}

А теперь ключевой момент: даже если storageKey есть внутри, mapper не должен его “протаскивать” в DTO. Это тот самый момент, где DTO действительно защищают внешний контракт: они не дают вам случайно открыть клиенту двери в подсобку, где лежат ваши швабры, провода и магические кнопки.

Мини-маппер для ответа (сильно упрощённый, чтобы не расползаться по коду):

package com.example.tasktracker.api.mapper;

import com.example.tasktracker.api.dto.response.AttachmentResponse;
import com.example.tasktracker.domain.model.AttachmentMetadata;

public final class AttachmentMapper {

    private AttachmentMapper() {
        // Утилитный класс: не создаём экземпляры
    }

    public static AttachmentResponse toResponse(AttachmentMetadata m) {
        // Важно: storageKey здесь намеренно НЕ участвует в маппинге.
        // Клиент должен видеть только публичный контракт, а не детали хранения.
        return new AttachmentResponse(
                m.getId(),
                m.getTaskId(),
                m.getOriginalFileName(),
                m.getContentType(),
                m.getSize(),
                m.getDescription(),
                m.getUploadedAt()
        );
    }
}

Да, у вас в модели, скорее всего, больше полей и геттеров — это нормально. Важно другое: storageKey остаётся внутри. Клиент никогда не должен видеть “как сервер устроен изнутри”. Клиент должен видеть контракт.

5. Два endpoint’а для одного attachmentId

Сейчас соберём всё в одну картину именно на уровне API. Для клиента вложение — это подресурс задачи. То есть “вложение” всегда живёт в контексте “задачи”, и URI отражает эту иерархию.

Metadata endpoint:

import com.example.tasktracker.api.dto.response.AttachmentResponse;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

// ✅ Endpoint для metadata: возвращаем JSON DTO (лёгкий ответ для UI).
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}")
public AttachmentResponse getAttachment(@PathVariable String taskId,
                                        @PathVariable String attachmentId) {
    // Здесь мы НЕ скачиваем файл и НЕ возвращаем байты — только metadata.
    return attachmentService.getAttachment(taskId, attachmentId);
}

Download endpoint:

import org.springframework.core.io.Resource;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

// ✅ Endpoint для скачивания: возвращаем бинарный контент (Resource).
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}/download")
public ResponseEntity<Resource> download(@PathVariable String taskId,
                                         @PathVariable String attachmentId) {
    // Happy-path: бинарное тело + нужные заголовки (Content-Type/Disposition).
    // Negative-path: может вернуться ProblemDetail в JSON (ошибка контракта).
    return attachmentService.download(taskId, attachmentId);
}

Обратите внимание на важную деталь: один и тот же attachmentId связывает metadata и download. Для клиента это “один ресурс”, просто два способа его читать. Клиент может сначала показать metadata, а потом, по клику пользователя, сходить на /download.

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

flowchart TD
    Client["Клиент"] -->|GET metadata| MetaEndpoint["/attachments/{id} (JSON)"]
    Client -->|GET download| DownloadEndpoint["/attachments/{id}/download (binary)"]

    MetaEndpoint --> Service["AttachmentService"]
    DownloadEndpoint --> Service

    Service --> Repo["AttachmentRepository (metadata)"]
    Service --> Storage["Storage (content)"]

    Repo --> Service
    Storage --> Service

С точки зрения проектирования контракта здесь красиво то, что endpoints по назначению очевидны. JSON endpoint возвращает JSON. Download endpoint возвращает файл. И никто не пытается “угадывать”, в каком режиме находится сервер.

6. Error contract для metadata и download

Мы уже построили единый error contract через ProblemDetail, и нам важно не разрушить его файловыми сценариями. Разделение metadata и content в этом смысле тоже помогает, потому что каждый endpoint остаётся однотипным по форме ответа.

Metadata endpoint в happy-path возвращает JSON, а в negative-path возвращает application/problem+json. Это привычный паттерн для любого нашего JSON endpoint’а.

Download endpoint в happy-path возвращает бинарное тело. Но в negative-path он тоже может вернуть ProblemDetail как JSON (это нормально): если вложение не найдено или задача не найдена, вы не можете скачать то, чего нет. И вместо “полуфайла” или “пустого ответа” вы отдаёте понятную ошибку в том же формате, что и в остальном API.

Если же вы делаете один endpoint, который “иногда JSON, иногда файл”, то в негативных сценариях становится сложно объяснить, какого типа ответ ждать. Клиент начинает жить в мире if/else: “если статус 200 — это файл, если 404 — это JSON, если 415 — это тоже JSON, но я ожидал файл…” И чем больше таких условий, тем быстрее клиент превращается в комбайн по обработке исключений, а не в нормальное приложение.

Разделение делает это спокойнее: клиент знает, что /download — это “файл, если всё ок”, и “ProblemDetail, если нет”. А /attachments/{id} — это “metadata, если всё ок”, и “ProblemDetail, если нет”. Это две простые модели, с которыми и клиенту, и тестам жить заметно легче.

7. Типичные ошибки при разделении metadata и content

Ошибка №1: попытка сделать “универсальный DTO”, в который добавляют byte[] content.
Обычно это начинается невинно: “ну пусть будет поле, вдруг пригодится”. Потом оказывается, что этот DTO используют и для списка, и для detail-view, и внезапно каждый список вложений начинает тащить за собой потенциально огромные base64-строки. Даже если вы “по умолчанию content не заполняете”, вы всё равно держите в модели идею, которая рано или поздно выстрелит. Лучше сразу закрепить дисциплину: metadata DTO не содержит бинарных данных.

Ошибка №2: утечка storageKey или пути к файлу наружу.
Внутренний ключ хранения — это инфраструктурная деталь. Как только вы вернули его клиенту, вы зацементировали способ хранения как часть контракта. Хуже того, вы показали клиенту то, что ему не нужно знать, а иногда это ещё и выглядит как “ссылочка на ваш диск”. Держите такие поля строго во внутренней модели, а наружу отдавайте только attachmentId и правильные endpoint’ы.

Ошибка №3: смешивание metadata и download в одном endpoint’е “по настроению”.
Когда один и тот же URL начинает вести себя по-разному (в зависимости от query param или Accept), растёт риск ошибок, а контракт становится “с секретами”. Для учебного и практичного API лучше два явных endpoint’а. Вы не потеряете функциональность, но сильно выиграете в предсказуемости.

Ошибка №4: возвращать metadata из /download и считать, что “это тоже нормально”.
Иногда делают так: “если файла нет, вернём metadata, чтобы клиент хоть что-то получил”. Это плохая идея: клиент пришёл за бинарным содержимым, а получил JSON. Даже если это “удобно”, это ломает ожидания и заставляет клиента писать лишнюю защитную логику. Лучше либо вернуть файл, либо честно вернуть ProblemDetail.

Ошибка №5: хранить в metadata DTO слишком много лишнего.
Metadata — это не свалка “всей информации, которая есть в системе”. Это то, что нужно клиенту для сценариев списка и просмотра вложения. Если вы начнёте добавлять туда внутренние поля, debug-данные, технические флаги, у вас снова появится риск “случайного public контракта” и рост связности. DTO должны оставаться дисциплинированными и скучными — скука здесь, как ни странно, признак качества.

1
Задача
Spring REST & MVC, 27 уровень, 1 лекция
Недоступна
Metadata endpoint без бинарного содержимого
Metadata endpoint без бинарного содержимого
1
Задача
Spring REST & MVC, 27 уровень, 1 лекция
Недоступна
Один attachmentId, два разных ответа
Один attachmentId, два разных ответа
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ