JavaRush /Курсы /Spring REST & MVC /Multipart в Spring M...

Multipart в Spring MVC: файлы и части

Spring REST & MVC
26 уровень , 1 лекция
Открыта

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, может содержать странные символы, может быть одинаковым у разных файлов. Для контрактного уровня его можно использовать как “человеческое имя” (например, показать пользователю), но нельзя воспринимать как уникальный ключ или как безопасный путь на диске.

1
Задача
Spring REST & MVC, 26 уровень, 1 лекция
Недоступна
Один файл через `@RequestParam`
Один файл через `@RequestParam`
1
Задача
Spring REST & MVC, 26 уровень, 1 лекция
Недоступна
Файл как `Part`, metadata как DTO
Файл как `Part`, metadata как DTO
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ