1. Хранение файлов: не в контроллере
Если вы только начинаете, идея «ну что там — Files.copy() и готово» кажется соблазнительно простой. И это нормально: мозг экономит энергию и предлагает самый короткий путь. Проблема в том, что контроллер тогда начинает жить сразу в двух мирах: в мире HTTP-контракта и в мире файловой системы. Это почти гарантированно превращает код в хрупкую кашу.
Контроллер в нашем курсе отвечает за вещи уровня HTTP: какие параметры принять, какой статус вернуть, какие заголовки выставить, какое тело отдать. Как только мы добавляем туда Path, Files, проверки директорий и обработку IOException, у нас ломается аккуратная архитектурная граница. Хуже того, мы получаем «скрытый контракт» с диском: где лежат файлы, как устроены директории, что делать при ошибке чтения — и всё это внезапно становится частью поведения web-layer.
Как только metadata живёт отдельно от content, сразу появляется внутренний вопрос: где лежат сами байты и по чему их искать. attachmentId здесь не подходит: это публичный идентификатор ресурса. Для хранения нужен другой ключ — storageKey, который живёт внутри приложения и не утекает в API.
Чтобы этого избежать, мы делаем то, что обычно делают взрослые backend-разработчики (иногда через зубы, но делают): вводим storage abstraction — маленький интерфейс, который умеет сохранять/читать/удалять файл, но не раскрывает наружу, как именно он это делает.
Нам важно сразу зафиксировать мысль: attachmentId — это идентификатор ресурса в API, а storageKey — это внутренний ключ, по которому инфраструктурный слой находит файл. Клиенту storageKey не нужен и даже вреден, а приложению без него будет больно.
2. Три имени: id, имя, ключ
Когда мы обсуждаем файлы, очень легко начать путаться в идентификаторах. У файла есть «имя, которое видит пользователь», «id ресурса в API» и «то, как файл хранится физически». Если смешать это в один суп, получится суп, где плавают вилки, ключи от квартиры и чей‑то паспорт. Есть можно, но как-то тревожно.
Давайте разложим роли по полочкам (и да — это тот редкий случай, когда таблица реально экономит нервы):
| “Имя” | Кто его придумал | Где используется | Можно ли показывать клиенту |
|---|---|---|---|
| attachmentId | сервер | URI подресурса /tasks/{taskId}/attachments/{attachmentId} | да |
| originalFileName | клиент (пользователь) | Content-Disposition filename="...", UI, списки вложений | да |
| storageKey | сервер | локальный поиск файла на диске | нет |
attachmentId — это публичная часть контракта. По нему клиент обращается к вложению как к ресурсу.
originalFileName — удобная информация для человека. Именно её стоит показывать в списке вложений и использовать как имя при скачивании.
storageKey — чисто прикладная штука. Он должен быть уникальным, безопасным, не зависеть от капризов пользовательского имени и не раскрывать структуру диска. Его место — во внутренней модели metadata (например, в AttachmentMetadata), но не в response DTO.
3. Интерфейс AttachmentStorage
Сейчас нам нужно придумать такую границу, чтобы сервис мог сказать: «сохрани файл» и получить в ответ «внутренний ключ», а для скачивания — сказать «дай ресурс по ключу». При этом сервис не должен знать, это локальный диск, S3 или магический шкаф в Нарнии (хотя Нарния плохо масштабируется по регионам).
Держим интерфейс маленьким и честным: сохранить, загрузить как Resource, удалить. В нашем проекте этого достаточно, чтобы прикрутить upload и download, и при этом не строить маленькую файловую ОС внутри Task Tracker API.
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
public interface AttachmentStorage {
// Сохраняем контент и возвращаем внутренний ключ хранения (storageKey),
// который безопасно хранить в metadata, но нельзя светить наружу в API.
String save(String taskId, MultipartFile file);
// Загружаем файл по внутреннему ключу: это именно ключ хранилища, а не attachmentId.
Resource loadAsResource(String storageKey);
// Удаляем файл по внутреннему ключу. Сервис решает бизнес-сценарий, storage — инфраструктурную операцию.
void delete(String storageKey);
}
Для всей attachment-подсистемы держим одну линию: storage прячет сырой java.nio внутри себя и наружу поднимает уже application-level исключение. Сервису не нужно ловить IOException и спорить с файловой системой — его задача на уровень выше.
Обратите внимание на важную деталь: save() возвращает строку — это и есть наш storageKey. Мы не возвращаем Path, не возвращаем File, не возвращаем абсолютный путь (потому что это сразу утечка инфраструктуры наверх). Возвращаем именно внутренний ключ, который потом можно сохранить в metadata.
Также важно, что loadAsResource() принимает storageKey, а не attachmentId. Это намеренно. attachmentId — понятие уровня API и домена, а storageKey — понятие уровня хранения. Сервис «склеивает» их через metadata: по attachmentId находит запись, берёт storageKey, идёт в storage.
4. Каркас LocalAttachmentStorage
Локальная реализация — это то место, где мы наконец-то легально используем java.nio.file.*. Но легально — не значит «где попало». По архитектуре проекта это инфраструктурный пакет, например com.example.tasktracker.infrastructure.storage. Контроллеры туда не ходят, DTO туда не ходят, а сервис ходит через интерфейс.
Ниже — минимальный скелет реализации. Здесь я намеренно показываю корневую директорию как относительную (data/attachments), чтобы не хардкодить абсолютные пути и не привязываться к конкретной машине.
import org.springframework.stereotype.Component;
import java.nio.file.Path;
@Component
public class LocalAttachmentStorage implements AttachmentStorage {
// Корень хранилища в файловой системе.
// Важно: это деталь инфраструктуры, наружу (в контроллер/DTO) она не должна протекать.
private final Path root = Path.of("data", "attachments");
}
Да, это пока «не самая гибкая настройка». Но она уже решает главную проблему: контроллер и сервис не знают, где именно лежат файлы, и не держат у себя логику работы с диском.
Внутри storage мы ещё будем создавать поддиректории, копировать файлы и проверять существование. Снаружи — только три операции. Это и есть хороший признак abstraction: снаружи маленько, внутри может быть сколько угодно скучной (но нужной) инфраструктурной рутины.
5. Генерация storageKey: уникальность и безопасность
Самый частый наивный вариант — сохранить файл под originalFileName. Кажется удобно: «у меня же уже есть имя, зачем что-то генерировать». Но это ломается почти сразу, причём ломается красиво: пользователь загружает spec.pdf, потом ещё раз spec.pdf — и вы либо перезаписываете файл, либо начинаете придумывать суффиксы вроде spec (final) (2).pdf. А потом кто-то загрузит ../../oops.txt, и вы внезапно почувствуете, как инфраструктура пытается уйти из вашего проекта в свободное плавание.
Поэтому мы делаем storageKey так, чтобы он был:
- первое — уникальным (обычно через UUID);
- второе — предсказуемо безопасным (никаких .., никаких абсолютных путей);
- третье — удобным для группировки (например, разложим файлы по задачам: отдельная папка на taskId).
Простейшая полезная идея: storageKey = taskId + "/" + uuid + "-" + safeOriginalName.
Чтобы не усложнять лекцию, мы сделаем «минимальную санитарную обработку» имени: уберём слеши. Это не security-курс, но базовую гигиену мы всё равно соблюдаем.
private String sanitize(String originalFileName) {
// Пустое имя — частая ситуация (или от клиента пришло что-то странное).
// Делаем предсказуемое значение, чтобы не получать "пустой" storageKey.
if (originalFileName == null || originalFileName.isBlank()) {
return "file";
}
// Минимальная защита от попыток "встроить путь" в имя файла.
// Мы не делаем идеальный нормалайзер под все ОС, но закрываем базовые случаи.
return originalFileName.replace("/", "_")
.replace("\\", "_");
}
Обратите внимание: это не «идеальная» функция для всех возможных ОС и Unicode-историй, но это хороший учебный минимум. Мы закрыли две самые очевидные проблемы: путь внутри имени и пустое имя.
6. save(): сохраняем MultipartFile на диск
Теперь соберём save() так, чтобы он делал три вещи: создавал директории, генерировал ключ, копировал содержимое файла на диск. И везде помним, что наружу мы возвращаем только storageKey. Никаких «положил в /Users/alex/... и вернул абсолютный путь клиенту». Клиенту это не надо. Клиент от этого только начнёт задавать вопросы вроде «а почему у вас прод на макбуке?».
import org.springframework.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.UUID;
@Override
public String save(String taskId, MultipartFile file) {
try {
// Готовим папку под конкретную задачу: storage группирует файлы по taskId.
Files.createDirectories(root.resolve(taskId));
// Берём оригинальное имя, но прогоняем через минимальную "санитарию".
String safeName = sanitize(file.getOriginalFilename());
// Генерируем storageKey так, чтобы он был уникальным и не зависел от совпадений имён.
String storageKey = taskId + "/" + UUID.randomUUID() + "-" + safeName;
// Превращаем storageKey в путь внутри root и нормализуем.
Path target = root.resolve(storageKey).normalize();
// Копируем содержимое файла на диск. try-with-resources гарантирует закрытие InputStream.
try (InputStream in = file.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
// Наружу возвращаем только storageKey: это внутренний идентификатор, а не путь.
return storageKey;
} catch (Exception e) {
// Инфраструктурные ошибки оборачиваем в своё runtime-исключение для общего error flow приложения.
throw new AttachmentStorageException("Failed to save attachment", e);
}
}
Здесь есть несколько важных моментов, которые полезно проговорить словами, иначе код выглядит как «ну написали и написали».
Во-первых, Files.createDirectories(...) безопасен как операция «сделай папку, если её нет». Это удобно, потому что нам не нужно отдельно проверять существование и ловить race conditions типа «папку уже создали параллельно».
Во-вторых, storageKey мы формируем строкой. Да, можно было бы хранить внутри Path, но строка удобнее для сохранения в metadata и для сериализации внутри приложения. И главное — строка подчёркивает: это внутренний идентификатор, а не объект файловой системы, который кто-то случайно начнёт “таскать” по слоям.
В-третьих, мы используем try-with-resources, чтобы поток был закрыт нормально. В реальности это спасает от мелких и очень неприятных утечек ресурсов.
И наконец, мы оборачиваем исключения в своё runtime-исключение. Это важно для общей архитектуры: storage — инфраструктура, и если она падает, сервис не обязан «лечить» IOException на месте. Сервис решает бизнес-уровень, а инфраструктурные сбои переводятся в наш общий error flow (который в проекте уже есть).
Минимальное исключение можно сделать таким:
public class AttachmentStorageException extends RuntimeException {
// Единый тип исключения для проблем хранилища: сохранить/прочитать/удалить.
public AttachmentStorageException(String message, Throwable cause) {
super(message, cause);
}
}
7. loadAsResource(): возвращаем Resource
Когда мы делаем download endpoint, контроллеру удобно вернуть тело как Resource. Spring MVC умеет отдавать Resource как тело ответа, и это хорошо ложится на HTTP-идею “тело — это файл”. Если вместо этого вернуть byte[], мы начнём строить «download через массив байтов», и в какой-то момент обнаружим, что память у приложения — не бесконечна (и это, кстати, один из тех жизненных уроков, которые сначала отрицаешь, потом торгуешься, потом принимаешь).
Реализация loadAsResource() обычно делает две вещи: превращает storageKey в Path и проверяет, что файл существует.
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import java.nio.file.Files;
import java.nio.file.Path;
@Override
public Resource loadAsResource(String storageKey) {
// Превращаем внутренний ключ в путь. Ключ приходит не от клиента, а из metadata.
Path filePath = root.resolve(storageKey).normalize();
// Если контент пропал, это уже проблема хранения (metadata есть, файла нет).
if (!Files.exists(filePath)) {
throw new AttachmentStorageException("File not found by storageKey=" + storageKey, null);
}
// FileSystemResource удобно отдать контроллеру как response body.
return new FileSystemResource(filePath);
}
Да, в этом примере я бросаю AttachmentStorageException напрямую. В реальном проекте можно сделать отдельный тип вроде AttachmentContentMissingException, но для учебной линии достаточно понять принцип: storage сам знает, что файл отсутствует, и сам сообщает об этом через исключение. А дальше общий error-handling слой решит, как это оформить наружу.
Ещё одна мысль, которую полезно удерживать: контроллер не принимает storageKey от клиента. Клиент его не знает. Клиент присылает taskId и attachmentId, сервис достаёт metadata, извлекает storageKey, и только затем вызывает storage. Это маленькая, но очень важная защита от того, чтобы кто-то начал «угадывать пути» на вашем диске через API.
8. delete(): удаление по storageKey
Удаление файла — операция скучная и опасная одновременно. Скучная, потому что «ну делитнули и делитнули». Опасная, потому что если в архитектуре нет явной границы, очень быстро появляются «удаления из контроллера», «удаления из репозитория» и «удаления где-то в ещё одном месте на всякий случай». А потом выясняется, что удалили не тот файл, и начинается археология логов.
Storage должен уметь удалить файл по ключу, а координатором сценария будет сервис (который удалит и metadata, и content согласованно). В самом storage мы делаем аккуратное deleteIfExists(), чтобы инфраструктурная операция была идемпотентной на уровне “файл уже исчез”.
import java.nio.file.Files;
import java.nio.file.Path;
@Override
public void delete(String storageKey) {
try {
// Ищем файл по внутреннему ключу. Ключ не должен приходить от клиента напрямую.
Path filePath = root.resolve(storageKey).normalize();
// Идемпотентное удаление: если файла уже нет, не падаем.
Files.deleteIfExists(filePath);
} catch (Exception e) {
// Любые ошибки ФС заворачиваем в понятное для приложения исключение.
throw new AttachmentStorageException("Failed to delete attachment", e);
}
}
Обратите внимание: мы снова не возвращаем наружу ни путь, ни лог “в какой папке удалили”. На уровне API это вообще не должно существовать. На уровне логов приложения — пожалуйста, но это уже вопрос логирования, а не контракта API.
9. Сервис: связываем metadata и content
Теперь соберём картину того, как это должно работать в приложении. Нам нужен сервис, который умеет: по taskId и attachmentId найти metadata, взять storageKey, и уже через storage получить Resource. Контроллер при этом остаётся “HTTP-режиссёром”: он выставляет заголовки и отдаёт тело, но не знает, где на диске лежит файл.
Для наглядности — схема (очень рекомендую мысленно держать её в голове, когда будете писать код):
flowchart TD
C[AttachmentController] --> S[AttachmentService]
S --> R["AttachmentRepository (metadata)"]
S --> ST["AttachmentStorage (content)"]
ST --> FS[(Local filesystem)]
А вот мини-фрагмент сервиса, который делает загрузку content. Он демонстрирует правильную последовательность: сначала metadata (уровень домена), потом storage (уровень инфраструктуры).
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
@Service
public class AttachmentService {
private final AttachmentRepository attachmentRepository;
private final AttachmentStorage storage;
public AttachmentService(AttachmentRepository attachmentRepository,
AttachmentStorage storage) {
this.attachmentRepository = attachmentRepository;
this.storage = storage;
}
public Resource loadAttachmentContent(String taskId, String attachmentId) {
// 1) Сначала находим metadata (доменные данные): есть ли вообще такое вложение у задачи.
AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
.orElseThrow(() -> new AttachmentNotFoundException(attachmentId));
// 2) Только потом идём в storage по внутреннему ключу (инфраструктурная деталь).
return storage.loadAsResource(meta.getStorageKey());
}
}
Здесь специально видно две разные ошибки: если metadata не найдена — это понятная “ресурс не существует” ситуация (обычно 404). Если metadata есть, но storage не отдаёт файл — это уже инфраструктурная проблема, которую надо аккуратно превратить в наш error contract, не отдавая клиенту «ой, у нас /var/data/... не открылся».
Важная дисциплина: storageKey хранится внутри AttachmentMetadata, но не попадает в response DTO. То есть мы не строим API вида «покажи мне список файлов на вашем диске». Мы строим API вида «вот вложения к задаче» — и это принципиальная разница.
10. Типичные ошибки при работе с local storage
Ошибка №1: работа с Files.* прямо в контроллере.
Это почти всегда начинается одинаково: «ну я же только один файлик сохраню». Потом добавляется валидация, потом обработка ошибок, потом путь к папке, потом ещё один эндпоинт, и внезапно ваш контроллер знает про файловую систему больше, чем про HTTP. Такой код сложно тестировать и неприятно поддерживать, потому что web-layer и storage concerns слеплены в один комок.
Ошибка №2: использовать originalFileName как физический ключ хранения.
Даже если вы верите, что “у нас пользователи аккуратные”, коллизии имён и перезапись файлов — вопрос времени. А если к этому добавить странные символы, разные ОС и попытки подсунуть path separators, то вы получите баги, которые выглядят как мистика: «у одного пользователя всё работает, у другого файл исчез». storageKey должен быть server-generated и уникальным.
Ошибка №3: отдавать наружу storageKey или абсолютный путь.
Иногда это делается “для дебага”, потом забывается, попадает в production — и у вас API начинает раскрывать внутреннюю структуру диска. Это плохая идея и с точки зрения безопасности, и с точки зрения поддержки: вы превращаете внутреннюю деталь реализации в часть контракта, а потом уже не сможете спокойно менять хранение.
Ошибка №4: позволить клиенту управлять storageKey напрямую.
Если вы вдруг делаете endpoint вроде GET /download?storageKey=..., вы открываете дверь в мир угадывания путей и тестирования вашего диска через HTTP. В нашем дизайне клиент знает только публичные идентификаторы ресурса (taskId, attachmentId). Всё остальное сервис вычисляет сам через metadata.
Ошибка №5: не нормализовать пути и не думать о “внезапных” .. внутри ключа.
Даже если storageKey генерируете вы, защита через normalize() и простая проверка логики — это дешёвое спокойствие. Программирование — это искусство не только написать код, но и не дать коду сделать то, чего вы не планировали. Особенно когда речь про файловую систему, которая обычно не спрашивает разрешения перед тем, как удалить что-то не то.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ