JavaRush /Курсы /Spring REST & MVC /Local storage и storageKe...

Local storage и storageKey

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

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() и простая проверка логики — это дешёвое спокойствие. Программирование — это искусство не только написать код, но и не дать коду сделать то, чего вы не планировали. Особенно когда речь про файловую систему, которая обычно не спрашивает разрешения перед тем, как удалить что-то не то.

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