consumes и produces в Spring MVC

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

1. Явная фиксация формата в JSON-first API

На старте кажется, что consumes/produces — это какая-то бюрократия уровня «поставьте галочку, что вы поставили галочку». Но в API-дизайне мелочи из заголовков — это как подпись в договоре: пока всё хорошо, её не замечают, а когда всё плохо — внезапно выясняется, что именно она решает спор. Если мы не фиксируем формат явно, клиент может прислать тело запроса в неожиданном виде (или с неожиданным Content-Type), а мы потом будем долго гадать, почему контроллер «как будто не вызывается». Аналогично, если клиент просит один формат через Accept, а сервер отдаёт другой, это превращается в «плавающую» проблему, где каждый уверен, что виноват не он.

В нашем курсе мы сознательно делаем JSON-first API, то есть основной формат — application/json. Это не значит, что можно перестать думать о media types. Наоборот: раз мы сознательно выбираем один формат, логично сознательно и объявлять его в контракте endpoint'ов. Тогда любой разработчик, открыв контроллер, за 10 секунд понимает: «ага, это endpoint принимает JSON и отдаёт JSON», а не «ну, наверное, оно так по умолчанию». И это экономит вам время на отладке, время на объяснение коллегам и время на переписку с клиентами, которые присылают text/plain, потому что “так удобнее”.

Content-Type и Accept: заголовки формата

Чтобы правильно использовать consumes и produces, нужно снова быстро вспомнить смысл двух ключевых заголовков. Это не «какая-то теория из RFC», а буквально язык, на котором клиент и сервер договариваются, как читать и как писать body. Content-Type относится к телу запроса: клиент говорит серверу «вот мой body, он в таком формате». Accept относится к телу ответа: клиент говорит серверу «я хочу получить ответ вот в таком формате».

В JSON-first API это обычно выглядит так: клиент отправляет Content-Type: application/json, а также (часто) добавляет Accept: application/json. Если Accept не указан, многие клиенты по умолчанию ведут себя как будто там */* (то есть «мне подойдёт любой формат»), и сервер отдаёт то, что умеет. Но в реальной жизни наличие явного Accept сильно помогает: он превращает «надежду» в контракт.

Небольшая табличка, чтобы всё встало в голове ровно (и чтобы вы не путали это на автомате):

Сущность Где живёт Кто задаёт Про что говорит Пример
Content-Type заголовок HTTP запроса клиент «Какой формат у моего request body» Content-Type: application/json
Accept заголовок HTTP запроса клиент «В каком формате я хочу response body» Accept: application/json
consumes аннотация mapping в Spring MVC сервер (мы) «Какие Content-Type я готов принимать в request body» consumes = "application/json"
produces аннотация mapping в Spring MVC сервер (мы) «Какие форматы я готов отдавать в response body» produces = "application/json"

Обратите внимание на лёгкую «ловушку для начинающих»: Content-Type и Accept — это заголовки в запросе, а consumes/produces — это «правила» на стороне сервера, по которым Spring либо выберет ваш метод контроллера, либо скажет «нет, так мы не договаривались». И именно это поведение нам сегодня нужно.

2. consumes и produces как фильтр контракта

Если объяснить максимально по-человечески, то consumes и produces — это часть правил матчинга endpoint'а. То есть Spring не только смотрит на путь и HTTP-метод (POST /api/v1/tasks), но и смотрит: «а подходит ли этот запрос по формату?». И вот тут consumes/produces включают дисциплину: если запрос пришёл с Content-Type, который вы не принимаете, Spring может вернуть 415 Unsupported Media Type. Если запрос пришёл с Accept, который вы не можете удовлетворить, Spring может вернуть 406 Not Acceptable.

Важно почувствовать именно момент: в таких случаях ваш код внутри метода контроллера может вообще не выполниться. И это нормально. Это не «Spring сломался». Это значит, что запрос не соответствует контракту на уровне HTTP-рамки. Ваша бизнес-логика тут ни при чём: вы просто не понимаете, как читать тело, или не можете отдать ответ так, как просит клиент.

Можно представить это как турникет в метро. У вас есть билет — проходите, нет билета — не проходите. И турникет не обязан обсуждать с вами философские вопросы «а почему так», его задача — быстро и понятно фильтровать поток. consumes и produces — такой же турникет, только для формата body.

Схема на уровне «достаточно для Junior» может выглядеть так:

flowchart TD
    A[HTTP запрос] --> B[Spring MVC: поиск подходящего handler method]
    B --> C{Путь и метод совпали?}
    C -->|нет| X["404 / no handler"]
    C -->|да| D{consumes совпал с Content-Type?}
    D -->|нет| E[415 Unsupported Media Type]
    D -->|да| F{produces совпал с Accept?}
    F -->|нет| G[406 Not Acceptable]
    F -->|да| H[Вызов метода контроллера]

Мы пока не разбираем внутреннюю механику «кто именно читает body» и «какой компонент отвечает за JSON». Но уже сейчас полезно иметь в голове: consumes/produces — это часть фильтрации запроса до вашей прикладной логики, и это напрямую относится к контракту API.

3. Фиксируем media type в контроллере и методах

produces на уровне TaskController

Теперь давайте сделаем практическую вещь в нашем Task Tracker API: на уровне контроллера зафиксируем, что он возвращает JSON. Почему на уровне контроллера? Потому что так мы не будем писать одно и то же в каждом методе, а контракт станет читаемым «снаружи»: открыл класс — сразу понял, что тут у нас application/json.

Представьте, что контроллер — это “папка” с endpoint'ами. Если в этой папке почти все ответы — JSON, логично написать это один раз на папке, а не на каждом файле внутри. Для JSON-first контроллера это удобно ещё и потому, что другой produces нужен только там, где сам ответ действительно имеет другой формат. Но сегодня мы в JSON-first мире, и нам важно сделать его явным.

Минимальный каркас контроллера с produces:

package com.example.tasktracker.api.controller;

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // Говорим Spring: это REST-контроллер, ответы будут уходить в body
@RequestMapping(
        path = "/api/v1/tasks", // Базовый путь для всех endpoint'ов задач
        produces = MediaType.APPLICATION_JSON_VALUE // По умолчанию отдаём JSON
)
public class TaskController {

    // Здесь будут handler-методы (GET/POST/PUT/DELETE) для работы с задачами
}

Здесь мы делаем две важные вещи. Во-первых, мы фиксируем базовый путь /api/v1/tasks, чтобы методы внутри могли быть компактнее. Во-вторых, мы говорим Spring MVC: «всё, что выходит из этого контроллера как response body, будет отдаваться как JSON». И это не только про «чтобы клиенту было красиво». Это про то, чтобы наш API был предсказуемым: даже если вы вернёте объект напрямую, Spring выставит правильный Content-Type в ответе.

consumes для @RequestBody

Следующий логичный шаг — зафиксировать входной формат для тех операций, где мы читаем request body. В нашем проекте это создание и обновление задач. Когда в сигнатуре метода появляется @RequestBody, по смыслу мы говорим: «клиент присылает тело, мы его читаем». Значит, нам полезно сразу сказать: «мы читаем только JSON». И это как раз выражается через consumes = application/json.

Почему это не просто “красивость”? Потому что иначе клиент может прислать body без Content-Type (или с text/plain), и вы будете удивляться, почему Spring не хочет превращать это в ваш Java-объект. С consumes всё становится честно: у нас договор — JSON, и если клиент приносит не JSON, запрос не проходит.

Пример POST создания задачи:

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import java.net.URI;

import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) // Принимаем только JSON в request body
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
    // request пришёл из JSON-тела запроса и уже распарсен Spring'ом

    Task created = taskService.create(request.getTitle(), request.getDescription());

    // Для 201 Created хорошая практика — вернуть Location с URL созданного ресурса
    URI location = URI.create("/api/v1/tasks/" + created.getId());

    return ResponseEntity
            .created(location) // HTTP 201 + заголовок Location
            .body(created);    // Тело ответа (JSON, потому что produces задан на контроллере)
}

Обратите внимание на нюанс: produces мы тут не пишем, потому что уже зафиксировали его на уровне контроллера. Если вам так читается лучше, можно писать и на методе тоже, но тогда вы начнёте повторять одно и то же. В учебном проекте лучше держать повторение под контролем, чтобы код не выглядел как «обои из аннотаций».

Для PUT и PATCH подход тот же: если есть @RequestBody, значит у метода есть входной формат, и мы можем его явно задать:

import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

@PutMapping(path = "/{taskId}", consumes = MediaType.APPLICATION_JSON_VALUE) // Читаем JSON из body
public ResponseEntity<Task> replace(@PathVariable String taskId,
                                    @RequestBody TaskUpdateRequest request) {
    // taskId пришёл из URL, request — из JSON-тела
    Task updated = taskService.replace(taskId, request.getTitle(), request.getDescription());

    return ResponseEntity.ok(updated); // 200 OK + JSON-тело (produces на контроллере)
}

А вот DELETE, как правило, consumes не требует: у удаления обычно нет тела запроса. С produces тоже можно быть аккуратнее, потому что 204 No Content — это успех без body. У вас может быть общий produces на контроллере, и это будет работать, но на уровне смысла полезно помнить: 204 — это “нет тела”, а значит формат тела уже не так важен, как статус.

4. Клиентские запросы и 415/406

Сейчас будет полезный момент, который сильно экономит часы отладки. Когда вы добавили consumes/produces, часть запросов может начать возвращать 415/406, и разработчик-новичок часто воспринимает это как «у меня что-то сломалось в контроллере». Но нет: вы просто добавили контрактную дисциплину, и теперь запросы должны ей соответствовать.

Пример корректного запроса создания задачи в .http стиле:

# Корректный запрос: и Content-Type, и Accept согласованы с контрактом контроллера
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json
Accept: application/json

{
  "title": "Fix login bug",
  "description": "Users cannot sign in after last deploy"
}

Если вы случайно отправите тело без Content-Type или с неправильным типом, например так:

# Некорректный Content-Type: сервер ждёт JSON, а пришёл text/plain
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain
Accept: application/json

{"title":"Fix login bug"}

то Spring может вернуть 415 Unsupported Media Type. И важно: это не ошибка «вашей бизнес-логики». Это ошибка уровня «мы не договорились, как читать body». Ваш taskService.create(...) в этом сценарии не виноват и, скорее всего, даже не был вызван.

А теперь пример с Accept. Допустим, клиент говорит: «я хочу текст»:

# Некорректный Accept: клиент просит text/plain, а сервер по контракту отдаёт JSON
GET http://localhost:8080/api/v1/tasks
Accept: text/plain

Если у контроллера стоит produces = application/json, то Spring может вернуть 406 Not Acceptable. Это ситуация «сервер может отдать только JSON, а клиент хочет text/plain». В реальной жизни браузеры обычно не будут так делать, но некоторые инструменты и интеграции — вполне могут.

И вот здесь появляется очень полезная инженерная привычка: когда вы видите 415 или 406, вы первым делом смотрите не на сервис и не на repository, а на заголовки запроса. Это то место, где чаще всего сидит проблема.

5. Как избежать “леса аннотаций”

Теперь — про дисциплину в коде. Очень легко сделать две крайности. Первая крайность — вообще ничего не фиксировать и надеяться, что “по умолчанию и так JSON”. Вторая крайность — написать consumes и produces вообще везде, причём строками, причём иногда с опечатками, и потом героически отлаживать, почему половина endpoint'ов не матчится.

Здоровый путь для учебного проекта обычно выглядит так: produces фиксируем на уровне контроллера (или общего @RequestMapping), чтобы весь набор endpoint'ов был в одном формате. А consumes ставим только там, где действительно читаем body через @RequestBody. Это делает контракт явным, но не превращает код в занавес из аннотаций.

При этом важно избегать магических строк. Писать "application/json" руками везде — это как вручную вбивать “localhost” в каждой строке кода: один раз ошиблись — и всё, «почему-то не работает». Поэтому используйте константы Spring:

import org.springframework.http.MediaType;

// Единое место, где мы фиксируем media types нашего API
public final class ApiMediaTypes {

    // JSON — базовый формат нашего JSON-first API
    public static final String JSON = MediaType.APPLICATION_JSON_VALUE;

    private ApiMediaTypes() {
        // Запрещаем создавать экземпляры: это утилитный класс с константами
    }
}

И тогда в контроллере можно писать компактно и без опечаток:

import static com.example.tasktracker.api.ApiMediaTypes.JSON;

import org.springframework.web.bind.annotation.PostMapping;

@PostMapping(consumes = JSON)
public ResponseEntity<Task> create(@RequestBody TaskCreateRequest request) {
    // Остальной код метода (создание задачи, формирование ответа и т.д.)
}

Это не обязательная техника, но она хорошо показывает общую мысль: формат — часть контракта, а контракт хочется держать аккуратно и единообразно, а не через копипасту.

6. Типичные ошибки при работе с consumes и produces

Ошибка №1: путаница “кто за что отвечает” между Content-Type, Accept, consumes, produces.
Очень частая ситуация: разработчик ставит produces, думая, что это “мы принимаем вот это”, или ставит consumes, думая, что это “мы отдаём вот это”. В итоге endpoint внезапно начинает возвращать 415/406, а человек ищет проблему в сервисе. Полезная проверка простая: consumes — это про то, что сервер читает (как переварить request body), а produces — про то, что сервер пишет (как сформировать response body).

Ошибка №2: механически ставят consumes на GET, где нет тела запроса.
На уровне смысла это просто шум: вы фиксируете формат того, чего нет. А на уровне поведения иногда можно получить неожиданные эффекты, особенно если какой-то клиент внезапно отправляет Content-Type там, где вы его не ожидаете. В учебном проекте лучше придерживаться простого правила: consumes появляется там, где есть @RequestBody.

Ошибка №3: пишут "application/json" руками в двадцати местах и один раз ошибаются.
Опечатка вроде "applicaton/json" выглядит смешно ровно до момента, пока вы не потратили 40 минут на дебаг и не поняли, что endpoint “не находится”, потому что consumes не совпал. Используйте MediaType.APPLICATION_JSON_VALUE или свою константу, и вы уберёте целый класс ошибок из жизни.

Ошибка №4: считают, что 415/406 — это “ошибка моего кода в методе контроллера”.
На самом деле часто это означает, что до метода вы вообще не дошли. Сервис может быть идеальным, репозиторий — гениальным, а @RequestBody — красивым, но если запрос пришёл с неправильным Content-Type или с несовместимым Accept, турникет не пустит. Привычка смотреть на заголовки запроса в таких случаях — это прям взрослая инженерная экономия времени.

Ошибка №5: ставят produces = application/json на endpoint, который возвращает 204 No Content, и начинают ожидать “пустой JSON”.
204 означает “нет тела”, а не “тело пустое”. Это тонкая, но очень важная разница. Если вам хочется возвращать {} или какой-то объект подтверждения — это уже будет 200 OK с body, а не 204. Не делайте вид, что у 204 есть формат тела: формально тела там быть не должно.

1
Задача
Spring REST & MVC, 7 уровень, 3 лекция
Недоступна
JSON-only endpoint с `consumes` и `produces`
JSON-only endpoint с `consumes` и `produces`
1
Задача
Spring REST & MVC, 7 уровень, 3 лекция
Недоступна
JSON-only `GET` и ответ `406 Not Acceptable`
JSON-only `GET` и ответ `406 Not Acceptable`
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ