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

Local storage і storageKey

Spring REST & MVC
Рівень 27 , Лекція 2
Відкрита

1. Зберігання файлів: не в контролері

Якщо ви тільки починаєте, ідея «ну там же — Files.copy() і готово» здається спокусливо простою. І це нормально: мозок економить енергію й пропонує найкоротший шлях. Проблема в тому, що контролер тоді починає жити одразу в двох світах: у світі HTTP-контракту та у світі файлової системи. Це майже гарантовано перетворює код на крихку кашу.

Контролер у нашому курсі відповідає за речі на рівні HTTP: які параметри прийняти, який статус повернути, які заголовки виставити, яке тіло віддати. Щойно ми додаємо туди Path, Files, перевірки каталогів і обробку IOException, у нас ламається акуратна архітектурна межа. Гірше того, ми отримуємо «прихований контракт» із диском: де лежать файли, як влаштовані каталоги, що робити під час помилки читання — і все це раптом стає частиною поведінки веб-рівня.

Щойно метадані живуть окремо від вмісту, одразу виникає внутрішнє запитання: де лежать самі байти й за чим їх шукати. attachmentId тут не підходить: це публічний ідентифікатор ресурсу. Для зберігання потрібен інший ключ — storageKey, який живе всередині застосунку і не витікає в API.

Щоб цього уникнути, ми робимо те, що зазвичай роблять дорослі backend-розробники (інколи крізь зуби, але роблять): вводимо абстракцію сховища — маленький інтерфейс, який уміє зберігати, читати й видаляти файл, але не розкриває назовні, як саме він це робить.

Нам важливо одразу зафіксувати думку: attachmentId — це ідентифікатор ресурсу в API, а storageKey — це внутрішній ключ, за яким інфраструктурний шар знаходить файл. Клієнту storageKey не потрібен і навіть шкідливий, а застосунку без нього буде боляче.

2. Три імені: id, імʼя, ключ

Коли ми обговорюємо файли, дуже легко почати плутатися в ідентифікаторах. У файлу є «імʼя, яке бачить користувач», «id ресурсу в API» і «те, як файл зберігається фізично». Якщо змішати це в один суп, вийде суп, у якому плавають виделки, ключі від квартири та чийсь паспорт. Їсти можна, але якось тривожно.

Давайте розкладемо ролі по поличках (і так — це той рідкісний випадок, коли таблиця справді економить нерви):

«Імʼя» Хто його придумав Де використовується Чи можна показувати клієнту
attachmentId сервер URI підресурсу /tasks/{taskId}/attachments/{attachmentId} так
originalFileName користувач Content-Disposition filename="...", UI, списки вкладень так
storageKey сервер локальний пошук файлу на диску ні

attachmentId — це публічна частина контракту. За ним клієнт звертається до вкладення як до ресурсу.

originalFileName — зручна інформація для людини. Саме її варто показувати у списку вкладень і використовувати як імʼя під час завантаження.

storageKey — суто технічна річ. Він має бути унікальним, безпечним, не залежати від примх користувацького імені й не розкривати структуру диска. Його місце — у внутрішній моделі метаданих (наприклад, у 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),
    // який безпечно зберігати в метаданих, але не можна світити назовні в API.
    String save(String taskId, MultipartFile file);

    // Завантажуємо файл за внутрішнім ключем: це саме ключ сховища, а не attachmentId.
    Resource loadAsResource(String storageKey);

    // Видаляємо файл за внутрішнім ключем. Сервіс вирішує бізнес-сценарій, storage — інфраструктурну операцію.
    void delete(String storageKey);
}

Для всієї attachment-підсистеми тримаємо одну лінію: storage ховає сирий java.nio всередині себе і назовні піднімає вже виняток рівня застосунку. Сервісу не потрібно ловити IOException і сперечатися з файловою системою — його завдання на рівень вище.

Зверніть увагу на важливу деталь: save() повертає рядок — це і є наш storageKey. Ми не повертаємо Path, не повертаємо File, не повертаємо абсолютний шлях (бо це одразу витік інфраструктури вгору). Повертаємо саме внутрішній ключ, який потім можна зберегти в метаданих.

Також важливо, що loadAsResource() приймає storageKey, а не attachmentId. Це навмисно. attachmentId — поняття рівня API і домену, а storageKey — поняття рівня зберігання. Сервіс «склеює» їх через метадані: за 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 ми ще створюватимемо підкаталоги, копіюватимемо файли й перевірятимемо існування. Ззовні — лише три операції. Це і є добра ознака абстракції: зовні — небагато, всередині може бути скільки завгодно нудної (але потрібної) інфраструктурної рутини.

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-виняток для загального потоку обробки помилок застосунку.
        throw new AttachmentStorageException("Не вдалося зберегти вкладення", e);
    }
}

Тут є кілька важливих моментів, які корисно проговорити словами, інакше код виглядає як «ну написали й написали».

По-перше, Files.createDirectories(...) безпечний як операція «зроби папку, якщо її немає». Це зручно, бо нам не потрібно окремо перевіряти існування і ловити race conditions типу «папку вже створили паралельно».

По-друге, storageKey ми формуємо рядком. Так, можна було б зберігати всередині Path, але рядок зручніший для збереження в метаданих і для серіалізації всередині застосунку. І головне — рядок підкреслює: це внутрішній ідентифікатор, а не обʼєкт файлової системи, який хтось випадково почне «таскати» між шарами.

По-третє, ми використовуємо try-with-resources, щоб потік закривався нормально. У реальності це рятує від дрібних і дуже неприємних витоків ресурсів.

І нарешті, ми загортаємо винятки у свій runtime-виняток. Це важливо для загальної архітектури: storage — інфраструктура, і якщо вона падає, сервіс не зобовʼязаний «лікувати» IOException на місці. Сервіс вирішує бізнес-рівень, а інфраструктурні збої переводяться в наш загальний потік обробки помилок (який у проєкті вже є).

Мінімальний виняток можна зробити таким:

public class AttachmentStorageException extends RuntimeException {

    // Єдиний тип винятку для проблем сховища: зберегти/прочитати/видалити.
    public AttachmentStorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

7. loadAsResource(): повертаємо Resource

Коли ми робимо 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) {
    // Перетворюємо внутрішній ключ у шлях. Ключ приходить не від клієнта, а з метаданих.
    Path filePath = root.resolve(storageKey).normalize();

    // Якщо вміст зник, це вже проблема сховища (метадані є, файлу немає).
    if (!Files.exists(filePath)) {
        throw new AttachmentStorageException("Файл не знайдено за storageKey=" + storageKey, null);
    }

    // FileSystemResource зручно віддати контролеру як response body.
    return new FileSystemResource(filePath);
}

Так, у цьому прикладі я кидаю AttachmentStorageException напряму. У реальному проєкті можна зробити окремий тип на кшталт AttachmentContentMissingException, але для навчальної лінії достатньо зрозуміти принцип: storage сам знає, що файлу немає, і сам повідомляє про це через виняток. А далі загальний шар обробки помилок вирішить, як це оформити назовні.

Ще одна думка, яку корисно тримати: контролер не приймає storageKey від клієнта. Клієнт його не знає. Клієнт надсилає taskId і attachmentId, сервіс дістає метадані, витягує storageKey, і лише потім викликає storage. Це маленький, але дуже важливий захист від того, щоб хтось почав «вгадувати шляхи» на вашому диску через API.

8. delete(): видалення за storageKey

Видалення файлу — операція нудна і небезпечна одночасно. Нудна, бо «ну видалили й видалили». Небезпечна, бо якщо в архітектурі немає явної межі, дуже швидко зʼявляються «видалення з контролера», «видалення з репозиторію» і «видалення десь в іншому місці про всяк випадок». А потім зʼясовується, що видалили не той файл, і починається археологія логів.

Storage має вміти видалити файл за ключем, а координатором сценарію буде сервіс, який видалить і метадані, і вміст узгоджено. У самому 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("Не вдалося видалити вкладення", e);
    }
}

Зверніть увагу: ми знову не повертаємо назовні ні шлях, ні лог «у якій папці видалили». На рівні API цього взагалі не має існувати. На рівні логів застосунку — будь ласка, але це вже питання логування, а не контракту API.

9. Сервіс: повʼязуємо метадані і вміст

Тепер зберемо картину того, як це має працювати в застосунку. Нам потрібен сервіс, який уміє: за taskId і attachmentId знайти метадані, взяти storageKey і вже через storage отримати Resource. Контролер при цьому залишається «HTTP-режисером»: він виставляє заголовки й віддає тіло, але не знає, де на диску лежить файл.

Для наочності — схема (дуже рекомендую подумки тримати її в голові, коли будете писати код):

flowchart TD
    C[Контролер вкладень] --> S[Сервіс вкладень]
    S --> R["Репозиторій вкладень (метадані)"]
    S --> ST["Сховище вкладень (вміст)"]
    ST --> FS[(Локальна файлова система)]

А ось мініфрагмент сервісу, який робить завантаження вмісту. Він демонструє правильну послідовність: спочатку метадані (рівень домену), потім 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) Спочатку знаходимо метадані (доменно значущі дані): чи взагалі є таке вкладення у завданні.
        AttachmentMetadata meta = attachmentRepository.findByTaskIdAndId(taskId, attachmentId)
                .orElseThrow(() -> new AttachmentNotFoundException(attachmentId));

        // 2) Лише потім ідемо в storage за внутрішнім ключем (інфраструктурна деталь).
        return storage.loadAsResource(meta.getStorageKey());
    }
}

Тут спеціально видно дві різні ситуації: якщо метадані не знайдено — це зрозуміла ситуація «ресурс не існує» (зазвичай 404). Якщо метадані є, але storage не віддає файл — це вже інфраструктурна проблема, яку треба акуратно перетворити на наш контракт обробки помилок, не віддаючи клієнту «ой, у нас /var/data/... не відкрився».

Важлива дисципліна: storageKey зберігається всередині AttachmentMetadata, але не потрапляє в response DTO. Тобто ми не будуємо API виду «покажи мені список файлів на вашому диску». Ми будуємо API виду «ось вкладення до завдання» — і це принципова різниця.

10. Типові помилки під час роботи з local storage

Помилка № 1: робота з Files.* прямо в контролері.
Це майже завжди починається однаково: «ну я ж тільки один файлик збережу». Потім додається валідація, потім обробка помилок, потім шлях до папки, потім ще один ендпоінт, і раптово ваш контролер знає про файлову систему більше, ніж про HTTP. Такий код складно тестувати й неприємно підтримувати, бо вебрівень і аспекти сховища зліплені в один ком.

Помилка № 2: використовувати originalFileName як фізичний ключ зберігання.
Навіть якщо ви вірите, що «у нас користувачі акуратні», колізії імен і перезапис файлів — питання часу. А якщо до цього додати дивні символи, різні ОС і спроби підсунути роздільники шляху, то ви отримаєте баги, які виглядають як містика: «в одного користувача все працює, у іншого файл зник». storageKey має бути згенерований сервером і унікальним.

Помилка № 3: віддавати назовні storageKey або абсолютний шлях.
Іноді це робиться «для дебагу», потім забувається, потрапляє в production — і у вас API починає розкривати внутрішню структуру диска. Це погана ідея і з точки зору безпеки, і з точки зору підтримки: ви перетворюєте внутрішню деталь реалізації на частину контракту, а потім уже не зможете спокійно змінювати зберігання.

Помилка № 4: дозволити клієнту керувати storageKey напряму.
Якщо ви раптом робите endpoint на кшталт GET /download?storageKey=..., ви відкриваєте двері у світ вгадування шляхів і тестування вашого диска через HTTP. У нашому дизайні клієнт знає лише публічні ідентифікатори ресурсу (taskId, attachmentId). Усе інше сервіс обчислює сам через метадані.

Помилка № 5: не нормалізувати шляхи й не думати про «раптові» .. всередині ключа.
Навіть якщо storageKey генеруєте ви, захист через normalize() і проста перевірка логіки — це дешевий спокій. Програмування — це мистецтво не лише написати код, а й не дати коду зробити те, чого ви не планували. Особливо коли йдеться про файлову систему, яка зазвичай не питає дозволу перед тим, як видалити щось не те.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ