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() і проста перевірка логіки — це дешевий спокій. Програмування — це мистецтво не лише написати код, а й не дати коду зробити те, чого ви не планували. Особливо коли йдеться про файлову систему, яка зазвичай не питає дозволу перед тим, як видалити щось не те.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ