1. Multipart w Spring MVC: binding po częściach
Model multipart jest już ustalony: jedno żądanie, wewnątrz kilka parts, file i metadata mają różne role. Teraz ważne jest zrozumienie, jak Spring MVC widzi te części i dlaczego upload-endpoint nie wymaga żadnego osobnego „trybu magii".
Gdy po raz pierwszy widzisz kontroler z MultipartFile, łatwo pomyśleć: „To jakiś specjalny tryb Spring, gdzie działają inne prawa fizyki". W rzeczywistości Spring robi dokładnie to samo, co robił dla @RequestBody: wyciąga dane wejściowe z żądania i podstawia je do argumentów metody. Po prostu w scenariuszu multipart źródłem danych nie jest jedno (całe body), ale kilka części, i każda część ma nazwę i własną treść. To raczej „rozszerzenie formatu" niż nowy paradygmat.
Warto mieć w głowie prostą inżynierską grafikę: kontener servlet (Tomcat/Jetty itp.) umie sparsować żądanie multipart na części. Spring MVC podłącza warstwę, która zamienia te części w wygodne obiekty, a dalej standardowy mechanizm argumentów kontrolera („argument resolution") robi swoje. Jeśli rozumiałeś już, jak Spring wybiera @PathVariable, @RequestParam i @RequestBody, to multipart to kontynuacja tej samej idei, a nie inny wszechświat.
Poniżej – schemat na poziomie „wystarczy, żeby się nie bać". Nie wchodzimy w źródła frameworku, ale zdejmujemy poczucie „szamanizmu".
flowchart TD
A["HTTP request: multipart/form-data"] --> B["Servlet container: parsuje parts"]
B --> C["Spring MultipartResolver: opakowuje żądanie"]
C --> D["DispatcherServlet + HandlerMapping"]
D --> E["Metoda kontrolera"]
E --> F["@RequestPart -> wyciągnij part po nazwie"]
F --> G["HttpMessageConverter -> DTO (jeśli JSON part)"]
E --> H["@RequestParam -> form field / MultipartFile"]
Główna myśl: parts to takie same dane wejściowe jak query-parametry czy JSON body. Po prostu mają inną formę.
2. MultipartFile: wygodny „pendrive" dla kontrolera
Gdy piszesz endpoint do wgrywania pliku w Spring MVC, w 90% edukacyjnych (i wielu roboczych) scenariuszy zaczynasz od MultipartFile. To typ Spring, który daje wygodny dostęp do podstawowych „danych paszportowych" pliku: oryginalna nazwa, Content-Type, rozmiar, a także sposoby odczytu zawartości.
Jest wygodny właśnie jako typ na poziomie warstwy web: możesz szybko zrozumieć, co przysłał klient, i przygotować dane do dalszego przetwarzania. Ważne jest tu niemylenie „wygodne w kontrolerze" z „chcę przetasować to przez całą aplikację" – ale o granicach warstw porozmawiamy starannie, nie zamieniając wykładu w architektoniczne zebranie.
Mini-przykład najprostszej metody upload, gdzie przyjmujemy tylko plik, bez metadanych:
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 // Ważne: oczekujemy multipart/form-data
)
void upload(@RequestParam("file") MultipartFile file) { // "file" — nazwa części/pola formularza
// Na tym poziomie zwykle robimy podstawowe sprawdzenie (pusty plik, typ itp.)
}
Tak, tutaj @RequestParam, a nie @RequestPart. I to normalne: dla „jeden plik bez złożonej struktury" taki wariant jest całkowicie funkcjonalny i czytelny.
Żeby poczuć, do czego MultipartFile się przydaje, wystarczy spojrzeć na typowe metody:
String originalName = file.getOriginalFilename(); // Nazwa, którą podał klient (nie jest „ostateczną prawdą")
String contentType = file.getContentType(); // Content-Type części (może być null)
long size = file.getSize(); // Rozmiar w bajtach
boolean empty = file.isEmpty(); // Szybkie sprawdzenie: w ogóle coś przysłano czy nie
W prawdziwym kodzie prawie zawsze patrzysz przynajmniej na isEmpty() i na contentType, bo pusty plik i plik nieoczekiwanego typu to najszybszy sposób na zamianę twojego API w bezpłatne magazynowanie plików dla całego internetu (a internet, jak wiadomo, lubi darmowe).
Przy tym ważne jest zapamiętanie jednego „drobiazgu", który potem staje się dużą sprawą. file.getBytes() brzmi jak prosta i wygodna rzecz, ale czyta plik całkowicie do pamięci. W przykładach edukacyjnych to dopuszczalne, ale mózg powinien zaznaczyć czerwonym markerem: „bajty to potencjalnie dużo". Nawet jeśli teraz nie optymalizujesz wszystkiego, przynajmniej rozumiesz, dlaczego MultipartFile ma jeszcze getInputStream().
3. Part: poziom servlet API
Jeśli MultipartFile to „wygodny wrapper od Spring", to Part to interfejs z servlet API. W Spring Boot 4 / Spring 7 to będzie jakarta.servlet.http.Part (ważne: nie javax.*, jak w starych tutorialach z czasów dinozaurów i Java 8).
Po co w ogóle wiedzieć o Part, skoro jest MultipartFile? Bo czasem chce się pracować bliżej kontenera: pobierać nagłówki części, używać write(...), patrzeć na „surowe" właściwości part itp. Plus pomaga to zrozumieć: Spring nie wymyślił multipart od zera – po prostu wygodnie opakował to, co umie świat servlet.
Mini-przykład, jak kontroler może przyjąć plik jako 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" — nazwa części multipart
String name = filePart.getSubmittedFileName(); // Oryginalna nazwa od klienta
long size = filePart.getSize(); // Rozmiar części (bajty)
// Part daje bardziej „surowy" dostęp: nagłówki, write(...), input stream itp.
}
Z punktu widzenia rozumienia Spring MVC ważne jest tu, że Part też przywiązuje się po nazwie części ("file"), podobnie jak MultipartFile. Różnica nie w tym, „jak znaleźć część", a w tym, co dostajesz na wyjściu.
Żeby porównanie było konkretne, oto mała tabela. Nie pretenduje do pełnego podręcznika, ale pomaga wybrać narzędzie bez wróżenia z fusów.
| Co porównujemy | MultipartFile | Part |
|---|---|---|
| Skąd typ | Spring (org.springframework.web.multipart) | Servlet API (jakarta.servlet.http) |
| Dla kogo wygodniejszy | Dla aplikacyjnego kontrolera | Dla bardziej „surowego" dostępu do części |
| Nazwa części | @RequestParam("file") lub @RequestPart("file") | częściej @RequestPart("file") |
| Rozmiar / typ / nazwa | są wygodne metody | też są, ale interfejs nieco bardziej „techniczny" |
| Model mentalny | „plik jako obiekt Spring" | „część żądania multipart jako obiekt kontenera" |
Jeśli robisz zwykły REST API na Spring MVC, zaczynanie od MultipartFile jest prawie zawsze prostsze. Part warto znać chociaż po to, żeby się nie bać, gdy trafi się w cudzym kodzie lub w dokumentacji.
4. @RequestPart: wiązanie części zamiast body
Najczęstsze zamieszanie u początkujących w upload-endpointach wygląda tak: „Skoro metadane to JSON, potrzebuję @RequestBody". Intuicja jest zrozumiała, ale multipart jest skonstruowany inaczej: całe body żądania w całości to kontener multipart, a JSON jest wewnątrz jednej z części. Dlatego @RequestBody tutaj nie „znajduje" twojego DTO tak, jak oczekujesz.
I tu pojawia się @RequestPart. Dosłownie oznacza: „Weź konkretną część żądania multipart po nazwie i powiąż ją z argumentem". W tym sensie @RequestPart to „@RequestBody, ale dla oddzielnej części".
Najważniejszy bonus @RequestPart – pozwala stosować do części te same mechaniki co do body: konwersję przez HttpMessageConverter i Jackson (jeśli część to JSON), plus walidację przez @Valid, jeśli wiążesz część z DTO. Dzięki temu kontrakt jest czytelny: w sygnaturze metody widać, że jest binarny plik i jest ustrukturyzowana JSON-meta-część.
Spójrz na typowy „właściwy" rytm:
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, // Binarna część z plikiem
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-część, którą można walidować
) {
// Tu można od razu opierać się na tym, że metadata już się sparsowała, a @Valid już zadziałał
}
Zwróć uwagę na dwa momenty.
Pierwszy: nazwy części "file" i "metadata" – to część publicznego kontraktu, tak samo jak ścieżka /api/v1/.... Zmiana nazwy part to prawie jak zmiana nazwy pola w JSON lub zmiana URI: klientom będzie boleć.
Drugi: @Valid działa znajomo. Jeśli AttachmentUploadMetadataRequest nie przejdzie Bean Validation – to nie jest „gdzieś w głębi serwisu", ale na granicy API, jak uzgadnialiśmy wcześniej.
5. @RequestParam i @RequestPart: granice
W praktyce szybko pojawi się pytanie: „Skoro @RequestPart jest taki świetny, po co w ogóle istnieje @RequestParam w metodach multipart?". Odpowiedź jest dość pragmatyczna: @RequestParam doskonale sprawdza się do „płaskich" pól formularza (ciągi, liczby) i do samego pliku w prostym scenariuszu. Jakby mówi: „Daj mi wartość parametru/pola formularza z taką nazwą", i dla pliku ten mechanizm też działa.
Ale gdy tylko chcesz przyjąć ustrukturyzowane metadane (JSON), @RequestPart zazwyczaj wygrywa, bo „oficjalnie" włącza konwersję wiadomości części do DTO. I sprawia to, że kod jest prostszy: nie parsujesz JSON ręcznie, nie piszesz new ObjectMapper(...) w kontrolerze (jeśli tak robisz – twój przyszły @ControllerAdvice płacze), i nie tracisz automatycznej walidacji.
Porównajmy na mini-przykładach.
Jeśli potrzebny jest tylko plik – @RequestParam wygląda całkowicie normalnie i nie budzi pytań czytelnika:
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
void uploadOnlyFile(@RequestParam("file") MultipartFile file) {
// Najprostszy scenariusz: jeden plik bez „inteligentnej" struktury
}
Jeśli potrzebny jest plik i proste pole tekstowe (np. description bez JSON) – można tak żyć:
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
void uploadWithDescription(
@RequestParam("file") MultipartFile file, // Plik
@RequestParam("description") String description // Proste pole formularza
) {
// Tutaj description przychodzi jako ciąg znaków, bez konwersji JSON
}
Ale jeśli metadata to obiekt JSON (dziś mamy „opis", jutro może być nieco więcej pól), zamienianie JSON w ciąg i ręczne parsowanie to prawie gwarantowany bilet do klubu „dlaczego mój ProblemDetail nie działa dla błędów JSON".
Z @RequestPart intencja czyta się lepiej:
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.multipart.MultipartFile;
void uploadWithMetadata(
@RequestPart("file") MultipartFile file, // Plik
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-część -> DTO
) {
// Walidacja DTO zachodzi na granicy metody, jak w przypadku @RequestBody
}
To właśnie praktyczna granica: @RequestParam jest dobry dla prostego, @RequestPart – gdy część powinna być przetworzona jak mini-body z własną konwersją (np. JSON).
6. Część JSON: Jackson i @Valid w @RequestPart
Teraz ważny szczegół, bez którego wszystko może wyglądać jak „jakoś nie mapuje się na DTO". Gdy mówisz Springowi: @RequestPart("metadata") AttachmentUploadMetadataRequest metadata, Spring musi zrozumieć format tej części. To znaczy część metadata musi mieć Content-Type: application/json. Wtedy włącza się ten sam konwejer Jackson jak dla @RequestBody.
Wpisuje się to wprost w poznany wcześniej model HttpMessageConverter: część żądania multipart staje się „małym ciałem wiadomości", a konwerter jest dobierany według Content-Type części i według typu Java argumentu.
Sam DTO może być maksymalnie prosty. W naszym projekcie metadane załącznika są celowo skromne, żeby klient nie zarządzał polami zarządzanymi przez serwer (rozmiarem, typem, czasem uploadu itp.). Do demonstracji binding wystarczy jedno pole:
import jakarta.validation.constraints.Size;
public record AttachmentUploadMetadataRequest(
@Size(max = 255) String description // Prosta walidacja na granicy API
) {
}
I teraz – ważna praktyczna uwaga dla klienta (Postman/curl/IDEA .http). Jeśli wyślesz metadata po prostu jako ciąg znaków bez podania application/json, Spring może nie zastosować konwersji JSON tak, jak oczekujesz. W efekcie dostaniesz błąd konwersji jeszcze przed logiką biznesową. To normalne: kontrakt multipart-endpointu obejmuje nie tylko nazwy parts, ale i to, jakiego typu jest każda część.
Przykład „jak zwykle wysyłają" (uproszczony, żeby zrozumieć ideę) przez curl:
# Wgrywamy plik i metadane JSON w jednym żądaniu multipart
curl -X POST "http://localhost:8080/demo/upload" \
-F 'file=@sample-files/readme.txt;type=text/plain' \
-F 'metadata={"description":"Mała notatka"};type=application/json'
# Ważne: type=application/json mówi serwerowi, że metadata należy odczytać jako JSON, a nie „po prostu ciąg"
Tutaj type=application/json to kluczowy sygnał: metadata to JSON i należy je czytać jako JSON, a nie jako przypadkowy ciąg.
Po stronie serwerowej, jeśli JSON w metadata jest uszkodzony (malformed JSON), błąd nastąpi na etapie deserializacji, czyli jeszcze przed walidacją. To ta sama klasa problemów co „malformed JSON w zwykłym @RequestBody", tylko wewnątrz części. Dobra wiadomość: twoja globalna obsługa błędów już umie żyć w świecie „błędy zdarzają się przed logiką biznesową", więc nie musisz robić try/catch w kontrolerze.
7. Sygnatura metody upload w Task Tracker API
Żeby to nie pozostało teorią „o abstrakcyjnym uploaderze", przenieśmy się na nasz projekt. Według mapy zasobów attachments to pod-zasób zadania. Dziś nie budujemy całego serwisu przechowywania, nie wymyślamy kluczy storage i nie omawiamy, gdzie i jak plik leży na dysku. Nasz cel jest prostszy: nauczyć się przyjmować żądania multipart tak, żeby kontrakt był czytelny, walidowany i wpisywał się w ogólny model obsługi błędów.
Oto minimalna sygnatura metody upload w stylu naszego projektu (krótka, bez zbędnej logiki). Wyobraź sobie, że ta metoda jest w 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 // Kontroler jawnie oczekuje multipart/form-data
)
void upload(
@PathVariable String taskId, // ID zadania nadrzędnego z URL
@RequestPart("file") MultipartFile file, // Plik jako oddzielna część multipart
@Valid @RequestPart("metadata") AttachmentUploadMetadataRequest metadata // JSON-część -> DTO + walidacja
) {
// Tutaj zwykle: sprawdzenie file (pusty/typ), potem delegowanie do warstwy serwisowej
}
Ta sygnatura już rozwiązuje połowę przyszłych problemów, bo kontrakt jest widoczny oczami: gdzie leży taskId, jak nazywają się części żądania, która część walidowana jest jako DTO. I zwróć uwagę: używamy właśnie @RequestPart, a nie @RequestBody. Bo @RequestBody działa dla jednego body, a u nas części jest kilka.
Żeby zmniejszyć ryzyko literówek w nazwach parts (a to bardzo popularny rodzaj bólu: "metdata" zamiast "metadata"), czasem warto wynieść nazwy parts do stałych. To nie obowiązkowa „architektura dla architektu", a zwykła ochrona przed czynnikiem ludzkim:
public final class AttachmentParts {
public static final String FILE = "file"; // Nazwa części z binarnym plikiem
public static final String METADATA = "metadata"; // Nazwa części z metadanymi JSON
private AttachmentParts() {
// Ochrona przed tworzeniem instancji: to klasa narzędziowa ze stałymi
}
}
Wtedy w kontrolerze można pisać @RequestPart(AttachmentParts.FILE) – i IDE przynajmniej pomoże ci się nie pomylić.
Teraz na stole są już wszystkie potrzebne prymitywy: taskId w path, file i metadata w parts, consumes = multipart/form-data, JSON-part z @Valid. To wystarczy, żeby złożyć już nie demo-metodę, ale kanoniczny POST /api/v1/tasks/{taskId}/attachments dla projektu.
8. Typowe błędy przy pracy z MultipartFile, Part i @RequestPart
Błąd nr 1: próba użycia @RequestBody w upload-endpoincie.
Pojawia się zwykle z właściwego doświadczenia „JSON czytany przez @RequestBody", ale żądanie multipart to kontener z części, a nie jeden dokument JSON. Dla metadanych JSON używaj @RequestPart("metadata"), wtedy Spring zastosuje konwersję do konkretnej części i będzie mógł walidować DTO.
Błąd nr 2: mieszanie @RequestParam i JSON-DTO tak, że wszystko zamienia się w ręczne parsowanie.
Czasem robią @RequestParam("metadata") String metadataJson, a potem parsują go ręcznie, i w tym momencie tracisz połowę zalet Spring MVC: automatyczne błędy deserializacji, jednolity przepływ obsługi błędów, @Valid i normalną czytelność sygnatury. Jeśli część jest ustrukturyzowana, lepiej od razu zrobić @RequestPart("metadata") AttachmentUploadMetadataRequest.
Błąd nr 3: zapomnienie, że część metadata musi mieć Content-Type: application/json.
Serwer może być napisany idealnie, ale klient wysłał metadata jako „po prostu ciąg" (lub Postman nie ustawił typu), i nagle DTO nie mapuje się lub mapuje się dziwnie. W kontrakcie multipart-endpointu ważna jest nie tylko nazwa części, ale i jej format. I tak, to ten przypadek, gdy jeden nagłówek naprawdę rozstrzyga losy żądania.
Błąd nr 4: wybieranie Part „domyślnie", bo brzmi poważniej.
Part to nie „ulepszona wersja MultipartFile". To po prostu inny poziom abstrakcji. Jeśli nie potrzebujesz czegoś specyficznego ze świata servlet, MultipartFile jest zwykle prostszy dla kodu aplikacyjnego i bardziej zrozumiały dla zespołu. Złożoność dla poczucia „enterprise" prawie zawsze źle się kończy, bo twój mózg zajęty jest narzędziem, a nie zadaniem.
Błąd nr 5: uważanie getOriginalFilename() za wiarygodny identyfikator.
Oryginalna nazwa pliku to to, co powiedział klient. Może być null, może zawierać dziwne znaki, może być taka sama u różnych plików. Na poziomie kontraktu można jej używać jako „ludzkiej nazwy" (np. wyświetlić użytkownikowi), ale nie można jej traktować jako unikalnego klucza ani bezpiecznej ścieżki na dysku.
GO TO FULL VERSION