1. JSON vs upload: когда нужен multipart
Комментарии, теги и другие supporting subresources ещё спокойно жили в чистом JSON. С вложениями этот режим ломается сразу: у ресурса появляется реальное файловое содержимое, и одного @RequestBody уже недостаточно.
Когда мы долго пишем JSON-first API, мозг начинает лениться и думать так: «Запрос — это JSON, ответ — это JSON, а всё остальное — факультативные украшения». Это нормально, так у всех бывает, и даже у тех, кто потом на собеседовании уверенно говорит “я люблю чистую архитектуру” (а потом кладёт бизнес-логику в контроллер, но это другая история). Файлы возвращают нас на землю: тело запроса на самом деле — просто поток байт, и не каждое полезное содержимое удобно выражается одним JSON-документом.
В application/json сценарии всё красиво: есть один body, в нём один структурированный документ, и Spring MVC может сказать: «Ага, это TaskCreateRequest», десериализовать его, провалидировать и передать дальше. Но в upload-сценарии нам нужно передать две разные вещи одновременно: бинарное содержимое файла (которое может быть не текстом вообще) и метаданные (например, описание вложения), которые удобно оставлять JSON-ом. И эти две сущности живут по разным правилам: у файла есть имя, тип, размер; у метаданных — структура и validation.
Теоретически можно «запихнуть файл в JSON» в виде Base64-строки. Практически это почти всегда плохая идея: payload раздувается, клиенту больно, серверу больно, логам особенно больно (потому что кто-нибудь обязательно залогирует тело запроса “для отладки”, а потом все будут вспоминать этот день как свою первую встречу с большим бинарным объектом). Поэтому для файлового сценария в HTTP есть стандартный механизм — multipart/form-data.
Чтобы почувствовать контраст, посмотрим на привычный JSON endpoint создания задачи — здесь всё укладывается в один DTO из одного body:
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@PostMapping(path = "/api/v1/tasks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
// @RequestBody: берём один JSON из body и маппим в DTO
// @Valid: прогоняем Bean Validation до входа в бизнес-логику
TaskDetailsResponse created = taskService.create(request); // контроллер только делегирует
return ResponseEntity.status(HttpStatus.CREATED).body(created); // 201 + тело ответа
}
Здесь контракт простой: клиент прислал один JSON, мы получили один Java-объект. Для файла такой «одиночный» формат быстро превращается в мучение, поэтому нам нужен другой тип тела запроса.
2. Multipart: один HTTP-запрос
Если сказать совсем по-человечески, multipart/form-data — это способ сделать один HTTP-запрос, который внутри содержит несколько отдельных частей, и у каждой части свои мини-заголовки и своё содержимое. Представьте коробку с несколькими пакетами внутри: коробка одна (HTTP request), а пакетов много (parts). Это не два запроса, не «сначала метаданные, потом файл», а именно одна операция с одним контрактом.
На уровне HTTP это выражается заголовком Content-Type: multipart/form-data; boundary=.... Слово boundary — это разделитель, который помогает серверу понять, где заканчивается одна часть и начинается другая. Мы не будем превращать лекцию в курс «парсер multipart своими руками на чистом Java» (иначе вы меня справедливо заподозрите в скрытой тяге к боли), но базовую идею понимать нужно: тело запроса в multipart — это “склейка” частей, отделённых границами.
Полезно зафиксировать отличия application/json и multipart/form-data в небольшой таблице — так проще не путаться:
| Аспект | application/json | multipart/form-data |
|---|---|---|
| Сколько частей в body | Одна | Несколько (parts) |
| Что внутри | Один JSON документ | Набор частей: каждая часть — свои headers + content |
| Тип содержимого | Обычно текст/JSON | Смешанное: JSON + бинарные данные |
| Чем удобно | CRUD, update, search | Upload файлов и смешанных payload’ов |
| Главная мысль | один DTO из одного body | несколько частей с разными ролями |
Чтобы увидеть «настоящий» вид, вот очень упрощённый (но по смыслу честный) пример сырого HTTP multipart-запроса. Он длиннее, чем JSON, и это нормально: он делает больше работы.
# boundary задаёт разделитель между частями multipart-тела
POST /api/v1/tasks/123/attachments HTTP/1.1
Content-Type: multipart/form-data; boundary=---abc
# Часть 1: JSON-метаданные (имя части — metadata)
-----abc
Content-Disposition: form-data; name="metadata"
Content-Type: application/json
{"description":"Логи запуска приложения"}
# Часть 2: файл (имя части — file, плюс filename)
-----abc
Content-Disposition: form-data; name="file"; filename="logs.txt"
Content-Type: text/plain
...байты файла...
# Закрывающая граница (обратите внимание на -- в конце)
-----abc--
Обратите внимание на две ключевые идеи: у каждой части есть имя (name="metadata" и name="file"), и у каждой части может быть свой Content-Type. Именно из этой модели дальше вырастает Spring MVC binding через @RequestPart.
3. Part: имя и заголовки
Слово part звучит очень скромно, почти как «кусочек чего-то». Но в контракте API это важная сущность: part — это именованный фрагмент multipart-запроса, который сервер обязан уметь найти, прочитать и интерпретировать. Если part отсутствует или имеет неожиданный формат, сервер обязан ответить ошибкой так же честно, как мы отвечали на malformed JSON или invalid query parameter в предыдущих модулях.
У part обычно есть собственные заголовки. Самый заметный — Content-Disposition: form-data; name="...". По нему сервер понимает, как эта часть называется. Если это файл, добавляется filename="...", то есть исходное имя файла, которое передал клиент. Также у part часто есть свой Content-Type, чтобы сервер мог понять, что внутри: application/json, text/plain, image/png, application/pdf и так далее.
Важный момент для мышления backend-разработчика: имя part — это часть публичного контракта, почти наравне с path и именами JSON-полей. Если клиент знает, что нужно отправить part file, а сервер внезапно начинает ждать part upload, то это ровно такой же breaking change, как переименовать поле title в taskTitle без объявления миграции. Клиент-то не телепат.
Для наглядности можно представить multipart-запрос как небольшую схему. Нам важна именно структура, а не детали парсинга:
flowchart TD
A["HTTP Request Content-Type: multipart/form-data"] --> B["Part: metadata Content-Type: application/json"]
A --> C["Part: file Content-Type: image/png / application/pdf / ..."]
И ещё одна мысль, которую стоит удержать: multipart — это не «хаос из form fields». В нашем курсе мы делаем его таким же аккуратным контрактом, как и JSON endpoint’ы, просто форма тела другая.
4. Части запроса: file и metadata
Сейчас мы приземлим multipart на наш домен, иначе он останется абстрактным “форматом для формочек в браузере”. В Task Tracker API вложение — это supporting subresource задачи. Клиент загружает файл и (опционально) прикладывает описание. При этом есть важная граница: клиент не должен управлять server-managed полями, которые сервер и так может определить сам.
Например, такие атрибуты, как size, contentType, originalFileName, фактически приходят вместе с файлом (или вычисляются на стороне сервера по файлу). Если мы разрешим клиенту присылать их как «метаданные», мы откроем дверь в мир странностей: клиент скажет, что файл image/png, а по факту пришлёт PDF; или скажет размер 10 байт, а пришлёт 10 мегабайт. Сервер всё равно будет вынужден проверять реальность. Значит, эти поля не должны быть частью request DTO.
Поэтому в нашем контракте удобно разделить роли так: part file несёт содержимое файла, а part metadata несёт JSON с тем, что реально контролирует клиент. Например, description.
Вот каноничный request DTO для метаданных загрузки — маленький и понятный:
import jakarta.validation.constraints.Size;
public record AttachmentUploadMetadataRequest(
// Ограничиваем длину описания: это контролируемое клиентом поле
@Size(max = 255) String description
) {
}
Это DTO идеально вписывается в уже знакомую нам модель: JSON + Bean Validation. Отличие лишь в том, что этот JSON лежит не в общем request body, а в отдельной части multipart-запроса.
А часть file — это «другая природа»: она про байты. И у неё свои свойства, которые Spring сможет дать нам через удобный API (к нему мы подойдём аккуратно, без забегания вперёд).
5. Имена частей как публичный API
Когда мы проектируем REST API, мы уже привыкли, что контракт — это не только «какой URL дернуть». Контракт — это ещё и query params, структура JSON, статусы ответов, ошибки. Multipart добавляет ещё один слой контракта: имена частей (file, metadata). И этот слой настолько же важен, как имена полей в DTO, потому что клиенту нужно воспроизводимо сформировать запрос.
Представьте ситуацию. Вы выпустили API, клиент интегрировался, отправляет part file и part metadata. Через неделю вы решили, что слово metadata звучит слишком по-взрослому, и переименовали part в info. На стороне сервера это «пара символов». На стороне клиента — внезапные ошибки, потому что сервер говорит: «А где metadata?». Клиент в ответ: «Так я же прислал info». И оба по-своему правы, но API всё равно сломано.
Вот почему при проектировании multipart-контракта мы выбираем имена частей так, чтобы они были предметными и очевидными, и потом относимся к ним как к стабильным. В этом курсе хороший стандарт — file для файла и metadata для JSON-метаданных. Эти слова не идеальны (идеальных слов не бывает), но они максимально понятны: один файл, одно описание.
Ещё один нюанс: part names — это не «внутренняя деталь Spring». Они живут на HTTP-уровне. Даже если вы завтра перепишете приложение без Spring (не надо, но представим для чистоты эксперимента), клиент всё равно будет отправлять те же части в том же формате. Это означает, что part names — это часть вашего API-договора, а не техническая мелочь.
6. Content-Type для multipart endpoint’а
В предыдущих модулях мы уже использовали consumes и produces как способ сделать контракт более явным. С multipart это становится ещё важнее, потому что сервер должен честно сказать клиенту: «Этот endpoint ждёт multipart, а не JSON». Иначе получаются странные ситуации: клиент отправил JSON, сервер попытался прочитать multipart, упал где-то в недрах конвертации — а клиент потом думает, что «Spring опять магичит».
На стороне клиента формат выражается заголовком Content-Type. Для multipart это будет multipart/form-data; boundary=... (boundary генерирует HTTP-клиент). На стороне сервера в Spring MVC мы закрепляем это через consumes = MediaType.MULTIPART_FORM_DATA_VALUE.
Для контраста ещё раз покажем два разных контракта на уровне контроллера.
JSON create endpoint (один body, один DTO):
import jakarta.validation.Valid;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
@PostMapping(path = "/api/v1/tasks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<TaskDetailsResponse> create(@Valid @RequestBody TaskCreateRequest request) {
// Важно: consumes фиксирует формат запроса на уровне контракта (здесь — JSON)
TaskDetailsResponse created = taskService.create(request); // бизнес-логика вне контроллера
return ResponseEntity.status(HttpStatus.CREATED).body(created); // отдаём 201 Created
}
Multipart upload endpoint (один запрос, но несколько parts). Здесь нам пока достаточно увидеть сам shape multipart-запроса, поэтому metadata оставим строкой:
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@PostMapping(path = "/api/v1/tasks/{taskId}/attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Void> upload(@PathVariable String taskId,
@RequestPart("file") MultipartFile file,
@RequestPart("metadata") String metadataJson) {
// @RequestPart("file"): извлекаем бинарную часть по имени "file"
// @RequestPart("metadata"): извлекаем JSON-часть по имени "metadata"
// consumes = MULTIPART_FORM_DATA: сервер явно говорит, что ждёт multipart-запрос
return ResponseEntity.status(HttpStatus.CREATED).build(); // 201 без тела
}
Да, этот метод выглядит непривычно, потому что у него нет @RequestBody. И это как раз правильный сигнал: «у запроса другая форма тела». Контроллер буквально отражает контракт.
7. Как Spring MVC связывает parts
Очень хочется думать, что multipart — это «какая-то отдельная вселенная». На самом деле он встраивается в ту же модель Spring MVC, которую мы уже разбирали: запрос приходит, фреймворк извлекает входные данные, конвертирует их в нужные Java-типы, вызывает метод контроллера. Просто теперь входные данные не в одном body, а в нескольких частях. Это скорее «расширение формата», чем новая парадигма.
И здесь есть приятная инженерная симметрия. Для файла Spring даёт удобную обёртку, из которой можно получить базовые свойства. Даже не читая содержимое, мы можем понять, что нам прислали:
import org.springframework.web.multipart.MultipartFile;
// Исходное имя файла от клиента (может быть null — зависит от клиента и реализации)
String originalName = file.getOriginalFilename(); // например: "report.pdf"
// MIME-тип, который пришёл в заголовках части (может быть null/неточным, сервер всё равно проверяет)
String contentType = file.getContentType(); // например: "application/pdf"
// Размер тела файла в байтах (полезно для ограничений и логирования)
long size = file.getSize(); // например: 12345
А JSON-часть (metadata) по своей сути остаётся JSON-ом. То есть у неё есть Content-Type: application/json, структура и правила, которые нам знакомы по @RequestBody. Разница лишь в том, что этот JSON приходит как отдельный part. Нам здесь нужен только один вывод: multipart несёт несколько входов, а Spring MVC умеет разложить их по аргументам метода — без ручного парсинга строк и без «магии ради магии».
Пока важно запомнить базовую ментальную модель: multipart — это запрос, который «несёт несколько входов», и Spring MVC умеет эти входы разложить по аргументам метода, если мы их явно и честно описали.
8. Типичные ошибки при multipart upload
Перед тем как мы начнём выбирать между MultipartFile, Part и разными вариантами @RequestPart, полезно проговорить типовые ошибки уровня мышления. Они не про синтаксис — они про то, как легко сломать контракт, даже если код компилируется, и даже если “у меня на машине всё работает”.
Ошибка №1: пытаться представить upload как обычный @RequestBody.
Очень частая идея: «А давайте сделаем AttachmentUploadRequest, где будет byte[] fileContent и String description». Формально это возможно, но практически вы получаете огромные JSON-ы, проблемы с размером, бессмысленную нагрузку на сериализацию и плохой UX. В контракте upload-сценария правильнее разделять бинарную часть и метаданные через multipart.
Ошибка №2: относиться к part names как к «внутренним» именам.
Когда вы пишете @RequestPart("file"), кажется, что это просто строка в коде, которую можно поменять “в любой момент”. Но клиент формирует запрос именно с этим именем. Переименование part names — это breaking change ровно как переименование JSON-поля. В стабильном API part names выбирают осознанно и держат постоянными.
Ошибка №3: делать имена частей абстрактными (data1, data2).
Да, код будет работать. Но контракт станет нечитаемым: клиент открывает Swagger/README/.http и видит “data1”. Что это? Файл? Метаданные? JSON? Набор байт? Понятные имена file и metadata резко снижают когнитивную нагрузку и для клиента, и для будущего вас (который через месяц уже забудет, что имел в виду).
Ошибка №4: уносить метаданные в query params «просто потому что так проще».
Иногда кажется удобным сделать POST /attachments?description=... и отдельно положить файл. Но метаданные — часть той же операции загрузки. Держать их в multipart-части metadata обычно проще для клиента, проще для валидации и лучше масштабируется, если завтра в метаданных появится ещё одно поле.
Ошибка №5: забывать, что multipart — это тоже контракт со своими ошибками.
Если часть отсутствует, если часть не того типа, если Content-Type запроса неправильный, сервер должен отвечать предсказуемо: с корректным статусом и в вашем едином ProblemDetail формате. Multipart — не “особенный хаос”, где можно отвечать как попало. Наоборот: именно здесь дисциплина контракта особенно заметна.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ