JavaRush /Курсы /Spring REST & MVC /Границы вложений

Границы вложений

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

1. Границы подсистемы вложений

Когда у вложения уже есть list/detail/download/delete, следующая боль появляется не в URI и не в статус-кодах, а в границах подсистемы. Файл — это не «ещё одно поле в DTO», а отдельная вселенная: память, диски, права, имена, типы, ошибки ввода-вывода и вечный вопрос «а где он вообще лежит». Если не поставить границы, проект начинает расползаться: контроллеры толстеют, DTO раздуваются, а ошибки становятся непредсказуемыми. В нашем Task Tracker API мы специально делаем attachment-часть маленькой, чтобы она была прикладной, но не превращалась в мини-облачное хранилище.

Хороший способ удержать границы — постоянно держать в голове, кто за что отвечает. Контроллер отвечает за HTTP-контракт и форму ответа. Сервис отвечает за сценарий и координацию: найти metadata, загрузить content, удалить оба. Репозиторий отвечает за metadata в памяти. Storage отвечает за байты на диске. Если вы чувствуете желание «чуток подсмотреть путь к файлу прямо в контроллере» — это примерно как «чуток засунуть бизнес-логику в DTO»: сначала кажется удобно, потом вы сами себя не узнаете.

Чтобы зафиксировать это наглядно, вот маленькая таблица — без неё легко начать «таскать файловую систему» по всему коду:

Слой Что он должен знать Что он не должен знать
Controller (api.controller) URI, статус, заголовки, JSON vs binary, ResponseEntity Пути на диске, Files.readAllBytes(), Path.resolve(...)
Service (domain.service) Сценарий: найти attachment, проверить правила, вызвать storage Как именно строится физический путь и где лежит root-dir
Repository (infrastructure.repository.inmemory) Хранение metadata объектов Никаких Resource, никаких MultipartFile
Storage (infrastructure.storage) Сохранить/загрузить/удалить контент Не должен формировать HTTP-ответ и заголовки

Эта дисциплина кажется скучной только до первого бага вида «на Windows работает, на Linux нет» или «на маленьком файле ок, на большом падает». А такие баги, поверьте, умеют находить людей даже без тестов — чисто из любви к приключениям.

2. Память и byte[]

Базовый download-path у нас уже выбран: content отдаём отдельно от metadata и не пихаем его в JSON. Теперь важно понять, почему вариант с byte[] так быстро делает больно.

Когда вы только знакомитесь с file download, самый очевидный путь выглядит так: «прочитаем файл в byte[], вернём byte[] в ответ». Это работает, код короткий, мозг доволен. Проблема в том, что память в Java — штука не резиновая, а большие массивы байтов не только занимают место, но и нагружают GC. Если вы читаете файл целиком в массив, вы гарантируете себе, что на скачивание 50 MB нужен хотя бы один массив на 50 MB, плюс ещё немного «обвязки». А если два клиента скачивают одновременно — у вас уже мини-конкурс «кто быстрее уронит приложение».

Самое неприятное, что баг не проявляется сразу. На тестовых файлах по 10100 KB вы будете уверены, что всё идеально. Затем кто-то загрузит PDF с отсканированными страницами, и ваш сервис внезапно начнёт отвечать медленно или падать с OutOfMemoryError. Это тот случай, когда «работает на моей машине» не просто шутка, а диагноз.

Вот типичный антипример (так делать не надо для download endpoint’а):

import java.nio.file.Files;
import java.nio.file.Path;

public byte[] loadFile(Path path) throws Exception {
    // Антипаттерн для download endpoint: читаем файл целиком в память и нагружаем GC
    return Files.readAllBytes(path);
}

В upload-сценарии похожая проблема возникает, когда вы делаете file.getBytes() и потом куда-то сохраняете. Это тоже материализация всего файла в памяти. Иногда так делают «чтобы проще было валидировать», а потом удивляются, почему серверу плохо.

В нашем курсе мы держим простой и понятный принцип: metadata — это JSON и маленькие поля, content — это поток/ресурс, и мы не обязаны материализовать его целиком. Для этого Spring MVC как раз даёт нам удобную модель — Resource.

3. Resource вместо byte[]

Теперь разложим, что именно даёт Resource поверх этого контракта. Если вы впервые видите Resource в Spring, может возникнуть ощущение, что это «какая-то спрингятина». На самом деле это очень практичная абстракция: “у меня есть что-то, что можно читать как поток байтов”. Это может быть файл на диске (FileSystemResource), ресурс из classpath, input stream и так далее. Для download endpoint’а нам нужен прежде всего файл на диске, поэтому FileSystemResource — самый прямой вариант.

Ключевой выигрыш в том, что Resource позволяет Spring MVC писать тело ответа как поток, а не заставляет нас заранее собирать массив байтов. Мы остаёмся в рамках простого кода, но не делаем «взрыв памяти по кнопке скачать».

Минимальная реализация loadAsResource() в локальном storage может выглядеть так:

import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

public Resource loadAsResource(Path root, String storageKey) {
    // Storage отвечает только за доступ к байтам: формируем путь и отдаём Resource
    // Здесь мы не читаем файл в память — Spring будет стримить содержимое сам
    return new FileSystemResource(root.resolve(storageKey));
}

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

import java.nio.file.Path;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;

public Resource loadAsResource(Path root, String storageKey) {
    // Разрешаем storageKey внутри root-директории и возвращаем ресурс для стриминга
    Resource resource = new FileSystemResource(root.resolve(storageKey));

    // Важно: отсутствие файла — это контролируемая ситуация, а не "случайный 500"
    if (!resource.exists()) {
        throw new IllegalStateException("Attachment content is missing");
    }

    return resource;
}

На этом граница и фиксируется: storage возвращает Resource, а контроллеру остаётся только HTTP-часть — статус, headers и тело ответа. То есть download не превращается ни в byte[]-endpoint, ни в место, где контроллер сам лезет в файловую систему.

4. Заголовки Content-Type и Content-Disposition

Resource решает вопрос тела ответа и памяти, но не отменяет HTTP-заголовки. Клиенту всё ещё нужны Content-Type и Content-Disposition: первый говорит, что лежит внутри, второй — что ответ стоит трактовать как скачивание и под каким именем.

Практический baseline остаётся простым: media type парсим с безопасным fallback на application/octet-stream, а filename собираем через ContentDisposition, а не вручную строкой. Так мы не падаем из-за кривого contentType и не ловим странности с кавычками, пробелами и экзотическими именами файла.

Именно эта связка и держит download в нормальных границах: body идёт как Resource, заголовки формируются явно, а контроллер не превращается в смесь из byte[], ручных header-строк и low-level I/O.

5. Удаление: metadata и content

После того как list/detail/download уже собраны, delete удобно использовать как проверку всей архитектурной дисциплины. Если удаление у вас размазано по контроллеру, репозиторию и storage, то рано или поздно вы получите «половинчатое» состояние: metadata удалили, файл остался; или файл удалили, metadata осталась. В продакшене это превращается в мусор на диске и странные 404/500 в download endpoint’е. В учебном проекте это превращается в вечные вопросы: «а почему список вложений показывает запись, но скачать нельзя?».

Правильная модель в рамках курса звучит так: вложение удаляется одной сервисной операцией, даже если внутри два шага. Сервис сначала достаёт metadata, потому что только там есть storageKey. Затем просит storage удалить физический файл. Затем удаляет metadata из репозитория. Контроллер сверху остаётся тонким и возвращает 204 No Content.

Вот пример сервисной координации (порядок намеренно “file first, metadata second” — так проще не оставлять “битые ссылки” на файл):

import com.example.tasktracker.domain.model.AttachmentMetadata;

public void deleteAttachment(String taskId, String attachmentId) {
    // Подресурс без родителя не существует, поэтому сначала подтверждаем задачу.
    requireTaskExists(taskId);

    // Достаём metadata, потому что только там есть storageKey.
    AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
            .orElseThrow(() -> new AttachmentNotFoundException(attachmentId));

    // Сначала удаляем контент, чтобы не оставить "битую" metadata-запись.
    storage.delete(meta.getStorageKey());

    // Потом удаляем metadata из репозитория.
    attachmentRepository.delete(taskId, attachmentId);
}

Здесь важна ещё одна дисциплина: storage уже прячет raw java.nio-ошибки внутри AttachmentStorageException, поэтому сервис не ловит IOException и не строит низкоуровневую ветку обработки вручную. Для API это просто технический сбой storage, который дальше пойдёт в общий error flow.

Чтобы визуально закрепить последовательность, вот простая схема:

sequenceDiagram
    participant C as AttachmentController
    participant S as AttachmentService
    participant R as "AttachmentRepository (in-memory)"
    participant ST as "AttachmentStorage (local)"

    C->>S: deleteAttachment(taskId, attachmentId)
    S->>R: findByTaskIdAndId(...)
    R-->>S: metadata (storageKey)
    S->>ST: delete(storageKey)
    ST-->>S: ok
    S->>R: delete(taskId, attachmentId)
    S-->>C: done

Смысл схемы — не в “красоте Mermaid”, а в том, что delete — это сценарий, и он должен жить в сервисе, а не в контроллере.

Как не раздувать attachment API

Файлы провоцируют бесконечные улучшения. Очень легко захотеть добавить “обновление описания вложения”, “переименование файла”, “скачивание всех вложений zip’ом”, “мультизагрузка”, “версионирование вложений”, “публичные ссылки”, “thumbnail для изображений” и ещё двадцать пунктов, после которых вы внезапно пишете Google Drive, но без зарплаты Google. Поэтому в нашем проекте важно удержать не только архитектурные границы, но и продуктовые.

В канонической версии Task Tracker API вложения остаются supporting subresource: они существуют только в контексте задачи и решают одну прикладную задачу — прикрепить файл и потом получить его обратно. Поэтому нам достаточно upload, list metadata, detail metadata, download content и delete. Этот набор уже демонстрирует всё, что нужно для REST-курса: корректный бинарный ответ, заголовки, разделение metadata/content, storage abstraction и единое error handling поведение. Всё остальное — либо отдельная подсистема, либо отдельный курс, либо отдельная боль, которую мы сознательно не выращиваем в учебном проекте.

Здесь полезно ловить себя на простой мысли: если новая фича требует от вас объяснять студенту ещё три новых понятия, ещё две новые модели ошибок и ещё один новый набор endpoint’ов — скорее всего, мы вышли за рамки “подсистема вложений как часть REST API”. В учебном проекте лучше сделать меньше, но так, чтобы оно выглядело взрослым и предсказуемым, чем сделать “всё”, но хаотично и со скрытой магией.

6. Типичные ошибки при работе с вложениями

Ошибка №1: хранить содержимое файла внутри AttachmentResponse и пытаться быть “удобным для клиента”.
На старте кажется логичным: “ну раз клиент запросил вложение, отдадим и metadata, и байты”. Но это ломает саму идею контрактов. Metadata endpoint становится тяжёлым, листинг превращается в катастрофу, а JSON начинает таскать то, что он не обязан таскать. В итоге вы либо делаете 100 MB JSON, либо начинаете выдумывать “иногда поле есть, иногда нет”, и контракт становится непредсказуемым.

Ошибка №2: читать файл целиком в byte[] на download и радоваться, что «код короткий».
Это классика, которая “работает” ровно до первого большого файла или первого параллельного скачивания. Проблема не в том, что Files.readAllBytes() плохой метод; проблема в том, что вы выбрали неподходящую модель работы с данными. Download endpoint — про поток, а не про массив. Resource здесь — не украшение, а способ не привязывать успех запроса к объёму доступной памяти.

Ошибка №3: собирать Content-Disposition строкой и случайно выстрелить себе в ногу именем файла.
Конкатенация "attachment; filename=\"" + name + "\"" кажется безобидной, пока name не содержит кавычку, перевод строки или просто странные символы. В лучшем случае клиент получит кривое имя, в худшем вы получите некорректный заголовок. В учебном проекте достаточно привычки: строить ContentDisposition через builder и не превращать заголовки в “ручной протокол”.

Ошибка №4: доверять contentType как истине и падать, если он отсутствует или некорректен.
contentType — это метаданные, а не закон природы. Он может быть null, может быть “application/unknown”, может быть мусором. Если ваш endpoint падает с 500, потому что media type не распарсился, вы наказываете клиента за то, что можно было пережить одним fallback’ом. Fallback на application/octet-stream — это простое и взрослое решение.

Ошибка №5: удалять файл в контроллере, metadata — в сервисе, а потом удивляться несогласованности.
Когда delete “размазан”, вы почти гарантированно получите состояние, в котором часть операции прошла, а часть — нет. Для клиента это выглядит как баг: “в списке есть, скачать нельзя” или “скачать можно, но в списке не видно”. Для вас это выглядит как потерянный вечер. Лекарство простое: координация удаления должна быть в одном методе сервиса, который воспринимает удаление вложения как одну операцию.

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