1. Multipart у Spring MVC: прив’язування по частинах
Модель multipart уже зафіксована: один запит, усередині — кілька parts, а в file та metadata — різні ролі. Тепер важливо зрозуміти, як Spring MVC бачить ці частини і чому кінцева точка завантаження не потребує жодної окремої «магії».
Коли ви вперше бачите контролер із MultipartFile, легко подумати: «Це якийсь особливий режим Spring, де діють інші закони фізики». Насправді Spring робить те саме, що й для @RequestBody: витягує вхідні дані із запиту та підставляє їх до аргументів методу. Просто в multipart-сценарії джерело даних не одне (весь body), а кілька частин, і в кожної є імʼя та власний вміст.
Корисно тримати в голові просту інженерну картинку: servlet-контейнер (Tomcat/Jetty тощо) вміє розбирати multipart-запит на частини. Spring MVC додає шар, який перетворює ці частини на зручні об’єкти, а далі стандартний механізм прив’язування аргументів контролера («argument resolution») робить свою справу. Якщо ви вже зрозуміли, як Spring обирає @PathVariable, @RequestParam і @RequestBody, то multipart — це продовження тієї самої ідеї, а не інший всесвіт.
Нижче — схема на рівні «достатньо, щоб не боятися». Ми не заглядаємо в джерельний код фреймворку, але знімаємо відчуття «шаманства».
flowchart TD
A["HTTP-запит: multipart/form-data"] --> B["Servlet-контейнер: розбирає parts"]
B --> C["Spring MultipartResolver: обгортає запит"]
C --> D["DispatcherServlet + HandlerMapping"]
D --> E["Метод контролера"]
E --> F["@RequestPart -> отримати part за імʼям"]
F --> G["HttpMessageConverter -> DTO (якщо JSON-part)"]
E --> H["@RequestParam -> поле форми / MultipartFile"]
Головна думка: parts — це такі ж вхідні дані, як query-параметри або JSON body. Просто їхня форма інша.
2. MultipartFile: зручна «флешка» для контролера
Коли ви пишете endpoint для завантаження файлу в Spring MVC, у 90 % навчальних — і в багатьох робочих — сценаріїв ви починаєте з MultipartFile. Це Spring-тип, який дає зручний доступ до основних «паспортних даних» файлу: початкового імені, Content-Type, розміру, а також способів прочитати вміст.
Він зручний саме як тип на рівні вебшару: ви можете швидко зрозуміти, що надіслав клієнт, і підготувати дані для подальшої обробки. Тут важливо не плутати «зручно в контролері» з «хочу протягнути це через усе застосування» — але про межі шарів ми поговоримо обережно, не перетворюючи лекцію на архітектурну нараду.
Мінімальний приклад найпростішого upload-методу, де ми приймаємо тільки файл, без метаданих:
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(
path = "/demo/upload",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE // Важливо: очікуємо multipart/form-data
)
void upload(@RequestParam("file") MultipartFile file) { // "file" — імʼя частини або поля форми
// На цьому рівні зазвичай робимо базову перевірку (порожній файл, тип тощо)
}
Так, тут @RequestParam, а не @RequestPart. І це нормально: для сценарію «один файл без складної структури» такий варіант цілком робочий і читабельний.
Щоб відчути, чим MultipartFile корисний, достатньо подивитися на типові методи:
String originalName = file.getOriginalFilename(); // Імʼя, яке повідомив клієнт (не "істина в останній інстанції")
String contentType = file.getContentType(); // Content-Type частини (може бути null)
long size = file.getSize(); // Розмір у байтах
boolean empty = file.isEmpty(); // Швидка перевірка: чи взагалі щось надіслали
У реальному коді ви майже завжди дивитеся щонайменше на isEmpty() і на contentType, тому що порожній файл або файл неочікуваного типу — це найшвидший спосіб перетворити ваш API на безплатне файлове сховище для всього інтернету (а інтернет, знаєте, любить халяву).
При цьому важливо памʼятати одну дрібницю, яка потім стає великою проблемою. file.getBytes() звучить як проста і зручна штука, але вона читає файл цілком у памʼять. У навчальних прикладах це допустимо, але мозок має зафіксувати червоним маркером: «байтики — це потенційно багато». Навіть якщо ви не оптимізуєте все просто зараз, ви хоча б розумієте, чому в MultipartFile є ще й getInputStream().
3. Part: рівень servlet API
Якщо MultipartFile — це «зручна обгортка від Spring», то Part — це інтерфейс із servlet API. У Spring Boot 4 / Spring 7 це буде jakarta.servlet.http.Part (важливо: не javax.*, як у старих туторіалах ще з часів динозаврів і Java 8).
Навіщо взагалі знати про Part, якщо є MultipartFile? Тому що інколи хочеться працювати ближче до контейнера: отримувати заголовки частини, використовувати write(...), дивитися на «сирі» властивості part тощо. Плюс це допомагає зрозуміти: Spring не вигадав multipart із нуля — він просто зручно загорнув те, що вміє servlet-світ.
Мінімальний приклад, як контролер може прийняти файл як Part:
import jakarta.servlet.http.Part;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
@PostMapping(path = "/demo/upload")
void upload(@RequestPart("file") Part filePart) { // "file" — імʼя multipart-частини
String name = filePart.getSubmittedFileName(); // Оригінальне імʼя від клієнта
long size = filePart.getSize(); // Розмір частини (байти)
// Part дає більш "сирий" доступ: заголовки, write(...), input stream тощо
}
З погляду розуміння Spring MVC тут важливо ось що: Part теж прив’язується за імʼям частини ("file"), як і MultipartFile. Різниця не в тому, як «знайти частину», а в тому, що ви отримуєте на виході.
Щоб порівняння стало відчутним, ось невелика таблиця. Вона не претендує на повний довідник, а допомагає обрати інструмент, не ворожачи на кавовій гущі.
| Що порівнюємо | MultipartFile | Part |
|---|---|---|
| Звідки тип | Spring (org.springframework.web.multipart) | Servlet API (jakarta.servlet.http) |
| Для кого зручніше | Для прикладного контролера | Для більш «сирого» доступу до частини |
| Імʼя частини | @RequestParam("file") або @RequestPart("file") | частіше @RequestPart("file") |
| Розмір / тип / імʼя | є зручні методи | теж є, але інтерфейс трохи більш «технічний» |
| Ментальна модель | «файл як об’єкт Spring» | «частина multipart-запиту як об’єкт контейнера» |
Якщо ви робите звичайний REST API на Spring MVC, починати з MultipartFile майже завжди простіше. Part варто знати хоча б для того, щоб не лякатися, коли він трапиться у чужому коді або в документації.
4. @RequestPart: прив’язка частини замість body
Найчастіша плутанина в новачків у кінцевих точках завантаження виглядає так: «Раз метадані — це JSON, значить мені потрібен @RequestBody». Інтуїція зрозуміла, але multipart влаштований інакше: увесь request body цілком — це multipart-контейнер, а JSON знаходиться всередині однієї з частин. Тому @RequestBody тут не «знаходить» ваш DTO так, як ви очікуєте.
І ось тут з’являється @RequestPart. Він буквально означає: «Візьми конкретну частину multipart-запиту за імʼям і прив’яжи її до аргументу». У цьому сенсі @RequestPart — це «@RequestBody, але для окремої частини».
Найважливіший бонус @RequestPart — він дозволяє застосовувати до частини ті самі механізми, що й до body: конвертацію через HttpMessageConverter і Jackson (якщо частина JSON), плюс валідацію через @Valid, якщо ви прив’язуєте частину до DTO. Це робить контракт читабельним: у самій сигнатурі методу видно, що є бінарний файл і є структурована JSON-частина з метаданими.
Подивіться на типовий «правильний» ритм:
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(path = "/demo/upload")
void upload(
@RequestPart("file") MultipartFile file, // Бінарна частина з файлом
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-частина, яку можна валідовати
) {
// Тут можна одразу спиратися на те, що metadata вже розпарсився, а @Valid вже відпрацював
}
Зверніть увагу на два моменти.
Перший: імена частин "file" і "metadata" — це частина публічного контракту, так само як path /api/v1/.... Змінити імʼя частини — майже як змінити імʼя поля в JSON або URI: клієнтам буде боляче.
Другий: @Valid працює звично. Якщо AttachmentUploadMetadataRequest не проходить Bean Validation — це відбувається не «десь у глибині сервісу», а на межі API, як ми й домовилися раніше.
5. @RequestParam і @RequestPart: межі
На практиці ви швидко поставите собі запитання: «Якщо @RequestPart такий класний, навіщо взагалі існує @RequestParam у multipart-методах?». Відповідь доволі прагматична: @RequestParam чудово підходить для «пласких» form-полів (рядки, числа) і для самого файлу у простому сценарії. Він ніби каже: «Дай мені значення параметра або поля форми з таким імʼям», і для файлу цей механізм теж працює.
Але щойно ви хочете прийняти структуровані метадані (JSON), @RequestPart зазвичай виграє, тому що він «офіційно» вмикає message conversion частини в DTO. І це робить код простішим: ви не парсите JSON вручну, не пишете new ObjectMapper(...) у контролері (якщо ви так робите — ваш майбутній @ControllerAdvice плаче), і не втрачаєте автоматичну валідацію.
Порівняймо на мінімальних прикладах.
Якщо вам потрібен лише файл — @RequestParam виглядає цілком нормально і не викликає в читача запитань:
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
void uploadOnlyFile(@RequestParam("file") MultipartFile file) {
// Найпростіший сценарій: один файл без "розумної" структури
}
Якщо вам потрібен файл і просте текстове поле (наприклад, description без JSON) — можна жити і так:
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
void uploadWithDescription(
@RequestParam("file") MultipartFile file, // Файл
@RequestParam("description") String description // Просте form-поле
) {
// Тут description приходить як рядок, без JSON-конвертації
}
Але якщо metadata — це JSON-об’єкт (сьогодні у нас це «опис», завтра може бути трохи більше полів), перетворювати JSON на рядок і парсити вручну — це майже гарантований квиток до клубу «чому в мене не працює ProblemDetail для помилок JSON».
З @RequestPart намір читається краще:
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
void uploadWithMetadata(
@RequestPart("file") MultipartFile file, // Файл
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-частина -> DTO
) {
// Валідація DTO відбувається на межі методу, як і у випадку @RequestBody
}
Це і є практична межа: @RequestParam добрий для простого, @RequestPart — коли частина має бути оброблена як міні-body зі своєю конвертацією (наприклад, JSON).
6. JSON-частина: Jackson і @Valid у @RequestPart
Тепер важлива деталь, без якої все може виглядати так, ніби воно чомусь не мапиться в DTO. Коли ви кажете Springʼу: @RequestPart("metadata") AttachmentUploadMetadataRequest metadata, Spring має зрозуміти формат цієї частини. Тобто у metadata-частини має бути Content-Type: application/json. Тоді вмикається той самий Jackson-конвеєр, що й для @RequestBody.
Це прямо вкладається в уже вивчену модель HttpMessageConverter: частина multipart-запиту стає «маленьким тілом повідомлення», і конвертер підбирається за Content-Type частини та Java-типом аргументу.
Сам DTO може бути максимально простим. У нашому проєкті метадані вкладення навмисно скромні, щоб клієнт не керував server-managed полями (розміром, типом, часом завантаження тощо). Для демонстрації прив’язування нам достатньо одного поля:
import jakarta.validation.constraints.Size;
public record AttachmentUploadMetadataRequest(
@Size(max = 255) String description // Проста валідація на межі API
) {
}
І тепер — важливий практичний момент для клієнта (Postman/curl/IDEA .http). Якщо ви надішлете metadata просто як рядок без указання application/json, Spring може не застосувати JSON-конвертацію так, як ви очікуєте. У результаті ви отримаєте помилку конверсії ще до бізнес-логіки. Це нормально: контракт multipart-endpointʼа включає не тільки імена частин, а й те, яким є вміст кожної з них.
Приклад «як зазвичай надсилають» (спрощено, щоб зрозуміти ідею) через curl:
# Завантажуємо файл і JSON-метадані в одному multipart-запиті
curl -X POST "http://localhost:8080/demo/upload" \
-F 'file=@sample-files/readme.txt;type=text/plain' \
-F 'metadata={"description":"Невелика нотатка"};type=application/json'
# Важливо: type=application/json повідомляє серверу, що metadata треба читати як JSON, а не як "просто рядок"
Тут type=application/json — ключовий сигнал: metadata — це JSON, і його потрібно читати як JSON, а не як випадковий рядок.
На серверному боці, якщо JSON у metadata пошкоджений (malformed JSON), помилка станеться на стадії десеріалізації, тобто ще до валідації. Це той самий клас проблем, що й «malformed JSON у звичайному @RequestBody», тільки всередині частини. Добра новина в тому, що ваш глобальний обробник помилок уже вміє жити у світі, де помилки трапляються ще до бізнес-логіки, тож не потрібно писати try/catch у контролері.
7. Сигнатура upload-методу в Task Tracker API
Щоб це не залишилося теорією про абстрактний upload, приземлимося в наш проєкт. За ресурсною картою attachments — це підресурс задачі. Сьогодні ми не будуємо цілий сервіс зберігання, не вигадуємо storage-ключі й не обговорюємо, де та як файл лежить на диску. Наша мета простіша: навчитися приймати multipart-запит так, щоб контракт читався, валідовувався й укладався в загальну модель обробки помилок.
Ось мінімальна сигнатура upload-методу в стилі нашого проєкту (коротко, без зайвої логіки). Уявіть, що цей метод знаходиться в AttachmentController:
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(
path = "/api/v1/tasks/{taskId}/attachments",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE // Контролер явно очікує multipart/form-data
)
void upload(
@PathVariable String taskId, // ID батьківської задачі з URL
@RequestPart("file") MultipartFile file, // Файл як окрема частина
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-частина -> DTO + валідація
) {
// Тут зазвичай: перевірка file (порожній/тип), потім делегування до сервісного шару
}
Ця сигнатура вже розв’язує половину майбутніх проблем, тому що контракт видно неозброєним оком: є taskId у path (адресуємо батьківський ресурс), є file-частина і є metadata-частина. Якщо клієнт переплутає імена частин або надішле інший тип вмісту, Spring чесно не зможе зв’язати запит із контрактом і поверне помилку. І це добре: дисципліна API починається з того, що неправильний запит не перетворюється на «дивну поведінку».
Щоб зменшити ризик помилок у назвах частин (а це дуже популярне джерело болю: "metdata" замість "metadata"), іноді корисно винести імена частин у константи. Це не обов’язкова «архітектура заради архітектури», а звичайний захист від людського фактора:
public final class AttachmentParts {
public static final String FILE = "file"; // Імʼя частини з бінарним файлом
public static final String METADATA = "metadata"; // Імʼя частини з JSON-метаданими
private AttachmentParts() {
// Захист від створення екземплярів: це утилітарний клас із константами
}
}
Тоді в контролері можна писати @RequestPart(AttachmentParts.FILE) — і IDE хоча б допоможе вам не промахнутися.
Тепер на столі вже всі потрібні складові: taskId у path, file і metadata у parts, consumes = multipart/form-data, JSON-part із @Valid. Цього достатньо, щоб зібрати вже не demo-метод, а канонічний POST /api/v1/tasks/{taskId}/attachments для проєкту.
8. Типові помилки під час роботи з MultipartFile, Part і @RequestPart
Помилка № 1: спроба використовувати @RequestBody у multipart-endpointі.
Вона зазвичай народжується з правильного досвіду: «JSON читається через @RequestBody», але multipart-запит — це контейнер із частин, а не один JSON-документ. Для JSON-метаданих використовуйте @RequestPart("metadata"), тоді Spring застосує конвертацію до конкретної частини і зможе валідовувати DTO.
Помилка № 2: змішування @RequestParam і JSON-DTO так, що все перетворюється на ручний парсинг.
Іноді роблять @RequestParam("metadata") String metadataJson, а потім парсять його вручну. У цей момент ви втрачаєте половину переваг Spring MVC: автоматичні помилки десеріалізації, єдиний потік обробки помилок, @Valid і нормальну читабельність сигнатури. Якщо частина структурована, краще одразу зробити @RequestPart("metadata") AttachmentUploadMetadataRequest.
Помилка № 3: забути, що у metadata-частини має бути Content-Type: application/json.
Сервер може бути написаний ідеально, але клієнт надіслав metadata як «просто рядок» (або Postman не виставив тип), і раптом DTO не мапиться або мапиться дивно. У multipart-контракті важливі не тільки імʼя частини, а й її формат. І так, це той випадок, коли один заголовок справді вирішує долю запиту.
Помилка № 4: обирати Part «за замовчуванням», тому що він звучить серйозніше.
Part — не «прокачана версія MultipartFile». Це просто інший рівень абстракції. Якщо вам не потрібно нічого специфічного із servlet-світу, MultipartFile зазвичай простіший для прикладного коду і зрозуміліший для команди. Складність заради відчуття «enterprise» майже завжди погано закінчується, тому що ваш мозок зайнятий не задачею, а інструментом.
Помилка № 5: вважати, що getOriginalFilename() — це надійний ідентифікатор.
Початкове імʼя файлу — це те, що сказав клієнт. Воно може бути null, може містити дивні символи, може збігатися для різних файлів. Для контрактного рівня його можна використовувати як «людське» імʼя (наприклад, показати користувачу), але не можна сприймати як унікальний ключ або як безпечний шлях на диску.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ