1. Multipart в Spring MVC: binding по частям
Модель multipart уже зафиксирована: один запрос, внутри несколько parts, у file и metadata разные роли. Теперь важно понять, как Spring MVC видит эти части и почему upload-endpoint не требует никакого отдельного «режима магии».
Когда вы впервые видите контроллер с MultipartFile, легко подумать: «Это какой-то особый режим Spring, где действуют другие законы физики». На самом деле Spring делает ровно то же, что делал и для @RequestBody: извлекает входные данные из запроса и подставляет их в аргументы метода. Просто в multipart-сценарии источник данных не один (весь body), а несколько частей, и у каждой части есть имя и свой контент.
Полезно держать в голове простую инженерную картинку: servlet-контейнер (Tomcat/Jetty и т.п.) умеет распарсить multipart-запрос на части. Spring MVC подключает слой, который превращает эти части в удобные объекты, и дальше стандартный механизм аргументов контроллера («argument resolution») делает своё дело. Если вы уже поняли, как Spring выбирает @PathVariable, @RequestParam и @RequestBody, то multipart — это продолжение той же идеи, а не другая вселенная.
Ниже — схема на уровне «достаточно, чтобы не бояться». Мы не уходим в исходники фреймворка, но снимаем ощущение “шаманства”.
flowchart TD
A["HTTP request: multipart/form-data"] --> B["Servlet container: парсит parts"]
B --> C["Spring MultipartResolver: оборачивает запрос"]
C --> D["DispatcherServlet + HandlerMapping"]
D --> E["Контроллерный метод"]
E --> F["@RequestPart -> извлечь part по имени"]
F --> G["HttpMessageConverter -> DTO (если JSON part)"]
E --> H["@RequestParam -> form field / MultipartFile"]
Главная мысль: parts — это такие же входные данные, как query-параметры или JSON body. Просто у них другая форма.
2. MultipartFile: удобная “флешка” для контроллера
Когда вы пишете endpoint для загрузки файла в Spring MVC, в 90% учебных (и многих рабочих) сценариев вы начинаете с MultipartFile. Это Spring-тип, который даёт удобный доступ к основным “паспортным данным” файла: исходное имя, Content-Type, размер, а также способы прочитать содержимое.
Он удобен именно как тип на уровне web-слоя: вы можете быстро понять, что прислал клиент, и подготовить данные для дальнейшей обработки. Здесь важно не путать «удобно в контроллере» с «хочу протащить это по всему приложению» — но про границы слоёв мы поговорим аккуратно, не превращая лекцию в архитектурный митинг.
Мини-пример самого простого 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
Самая частая путаница у новичков в upload-endpoint’ах выглядит так: «Раз метаданные — это 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/.... Поменять имя part’а — почти как поменять имя поля в 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 полями (размером, типом, временем загрузки и т.д.). Для демонстрации binding нам достаточно одного поля:
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’а включает не только part names, но и то, какого типа каждая часть.
Пример “как обычно отправляют” (упрощённо, чтобы понять идею) через 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”, только внутри части. Хорошая новость в том, что ваш глобальный error handling уже умеет жить в мире “ошибки бывают до бизнес-логики”, поэтому вы не должны делать try/catch в контроллере.
7. Сигнатура upload-метода в Task Tracker API
Чтобы это не осталось теорией “про абстрактный upload”, приземлимся в наш проект. По ресурсной карте attachments — это подресурс задачи. Мы сегодня не строим целый сервис хранения, не придумываем storage-ключи и не обсуждаем, где и как файл лежит на диске. Наша цель проще: научиться принять multipart-запрос так, чтобы контракт читался, валидировался и укладывался в общую модель error-handling.
Вот минимальная сигнатура 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 дисциплина начинается с того, что неправильный запрос не превращается в “странное поведение”.
Чтобы уменьшить риск опечаток в part names (а это очень популярный вид боли: "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 хотя бы поможет вам не промахнуться.
Теперь на столе уже все нужные primitives: 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: автоматические ошибки десериализации, единый error-handling поток, @Valid и нормальную читаемость сигнатуры. Если часть структурированная, лучше сразу сделать @RequestPart("metadata") AttachmentUploadMetadataRequest.
Ошибка №3: забыть, что у metadata-части должен быть Content-Type: application/json.
Сервер может быть написан идеально, но клиент отправил metadata как “просто строку” (или Postman не выставил тип), и внезапно DTO не маппится или маппится странно. В multipart-контракте важно не только имя части, но и её формат. И да, это тот случай, когда один заголовок реально решает судьбу запроса.
Ошибка №4: выбирать Part “по умолчанию”, потому что он звучит серьёзнее.
Part — не “прокачанная версия MultipartFile”. Это просто другой уровень абстракции. Если вам не нужно что-то специфическое из servlet-мира, MultipartFile обычно проще для прикладного кода и понятнее для команды. Сложность ради ощущения “enterprise” почти всегда плохо заканчивается, потому что ваш мозг занят не задачей, а инструментом.
Ошибка №5: считать, что getOriginalFilename() — это надёжный идентификатор.
Исходное имя файла — это то, что сказал клиент. Оно может быть null, может содержать странные символы, может быть одинаковым у разных файлов. Для контрактного уровня его можно использовать как “человеческое имя” (например, показать пользователю), но нельзя воспринимать как уникальный ключ или как безопасный путь на диске.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ