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 должны оставаться дисциплинированными и скучными — скука здесь, как ни странно, признак качества.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ