JavaRush /Курсы /Spring REST & MVC /Как работает @RequestBody

Как работает @RequestBody

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

1. Request body vs path и query

Если честно, request body — это не какая-то «фишка Spring». Это нормальный, канонический способ передавать в HTTP-запросе много данных сразу. Особенно когда вы создаёте или меняете ресурс: там обычно не одно поле, а целый набор. И чем взрослее приложение, тем сильнее вы это чувствуете (и тем меньше хочется смотреть на URL длиной в полэкрана).

Представьте, что мы хотим создать задачу в Task Tracker API. У задачи есть хотя бы title и description. Уже два поля. Плюс может быть исполнитель, дедлайн, теги… Если вы начнёте передавать всё это через query-параметры, получится что-то вроде «вынужденного письма мелким шрифтом на полях»:

# Пример «как можно, но лучше не надо»: данные создания ресурса в query
POST /api/v1/tasks?title=Fix%20login&description=...&assigneeName=...&dueDate=...

Технически вы можете так сделать, но контракт станет хуже читаемым и менее устойчивым. Query-параметры хороши для фильтров, пагинации и «режимов» списка, а не для передачи «нового состояния ресурса».

Удобно держать в голове такую картину (без попытки сделать из неё догму). Это не «единственная истина», а практический ориентир, который помогает проектировать предсказуемый API:

Куда кладём данные Как это выглядит Что это означает Пример в Task Tracker API
Path (/tasks/{id}) часть URI «кого/что я адресую» GET /api/v1/tasks/{taskId}
Query (?status=...) параметры после ? «как я хочу отфильтровать/показать» GET /api/v1/tasks?status=TODO
Headers Content-Type, Accept и т.д. «в каком формате мы общаемся» Content-Type: application/json
Body JSON (или другое) «какие данные я передаю для операции» POST /api/v1/tasks + JSON с полями задачи

Тело запроса — это как посылка. Path — это адрес доставки, query — это пометки «доставить после 18:00» и «не звонить в домофон». А если вы пытаетесь засунуть саму посылку в пометки — курьер, конечно, удивится.

2. Роль @RequestBody в Spring MVC

Когда вы впервые видите @RequestBody, очень легко подумать: «О, это какая-то аннотация, которая просто делает объект из JSON». И да, по ощущениям так и происходит. Но важный момент — это не «волшебство», а часть понятного контракта между вами и Spring MVC: вы явно говорите, откуда брать значение параметра метода.

Идея простая: в методе контроллера вы хотите получить не строку JSON, а уже готовый Java-объект, с которым удобно работать. Вместо того чтобы руками читать поток байтов, собирать строку, парсить JSON и дальше вручную доставать поля, вы описываете форму входных данных Java-классом — и Spring делает привязку автоматически.

Минимально это выглядит так:

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

@RestController // Говорим Spring: это контроллер, возвращающий ответы напрямую (обычно JSON/текст)
public class DemoController {

    @PostMapping("/demo") // Маршрут, который принимает POST-запросы
    public String demo(@RequestBody DemoRequest request) {
        // @RequestBody: возьми тело HTTP-запроса и замапь его в DemoRequest (через JSON-конвертер)
        return "title=" + request.getTitle(); // например: title=Fix login
    }
}

Здесь @RequestBody означает буквально: «Spring, возьми тело HTTP-запроса и попробуй превратить его в DemoRequest».

Чтобы это вообще было возможно, клиент обычно присылает заголовок Content-Type: application/json, чтобы сервер не гадал, что именно лежит в body. Мы уже обсуждали Content-Type как «паспорт формата». Без него сервер может начать сомневаться и отвечать ошибкой — но сейчас нам важнее понять принцип: @RequestBody связывает body -> объект.

Ещё один важный момент, который часто пропускают новички: тело запроса — это один поток данных. Поэтому в одном методе контроллера обычно может быть только один параметр, помеченный @RequestBody. Нельзя «разобрать один JSON на два независимых тела». Технически это похоже на попытку дважды прочитать один и тот же лист бумаги, который вы уже сожгли в камине. Красиво звучит, но читать уже нечего.

3. Класс для JSON-тела запроса

Когда вы принимаете request body, вы должны дать Spring MVC понятную «формочку» — Java-класс, который описывает ожидаемую структуру JSON. Это и есть начало дисциплины контракта: вы прямо в коде фиксируете, какие поля вы ждёте, как они называются и каких они типов. Клиенту теперь нельзя прислать «что угодно» и надеяться, что сервер «как-нибудь разберётся».

В нашем проекте это будет запрос на создание задачи. Давайте создадим класс TaskCreateRequest. По структуре проекта логичное место — пакет api.dto.request, потому что это модель входа на границе API.

package com.example.tasktracker.api.dto.request;

public class TaskCreateRequest {

    // Поля, которые мы ожидаем увидеть в JSON от клиента
    private String title;
    private String description;

    // Getter нужен, чтобы код контроллера/сервиса мог читать значение
    public String getTitle() { return title; }

    // Setter нужен, чтобы JSON-маппер (например, Jackson) мог записать значение в объект
    public void setTitle(String title) { this.title = title; }

    public String getDescription() { return description; }

    public void setDescription(String description) { this.description = description; }
}

Почему здесь нужны getters/setters? Потому что Spring (а точнее JSON-маппинг внутри его web-стека) обычно работает с JavaBean-стилем: он умеет создавать объект и заполнять его свойства через сеттеры. Это не единственный возможный стиль в Java вообще, но для начинающего разработчика он самый понятный и распространённый.

Теперь клиент может отправить JSON примерно такой формы:

# Создание задачи: данные уходят в body, а формат описываем через Content-Type
POST /api/v1/tasks
Content-Type: application/json

{
  "title": "Fix login",
  "description": "Users cannot login after password reset"
}

И если всё хорошо, то в метод контроллера прилетит объект TaskCreateRequest, у которого:

// Эти значения будут взяты из JSON-полей "title" и "description"
request.getTitle();       // "Fix login"
request.getDescription(); // "Users cannot login after password reset"

Здесь есть очень практичная логика сопоставления: JSON-ключ "title" попадает в Java-свойство title, потому что существует setTitle(...). JSON-ключ "description" попадает в description, потому что существует setDescription(...). Не обязательно запоминать это как «правило из учебника» — достаточно помнить, что имена JSON-ключей должны совпадать с именами полей/свойств, которые вы ожидаете.

Теперь важный нюанс для начинающих, который часто всплывает в самый неожиданный момент. Если вы сделаете поле не String, а, скажем, int, и клиент не пришлёт его, Java всё равно даст значение по умолчанию 0. То есть вы уже не отличите «клиент прислал 0» от «клиент не прислал ничего». Поэтому для необязательных чисел/флагов в request-моделях чаще используют Integer, Long, Boolean, а не int/long/boolean. Для наших title/description это неактуально, потому что они строки, но привычку лучше сформировать сразу — потом спасибо скажете сами себе (и своим будущим баг-репортам).

Ещё одна мысль: request-класс должен описывать только то, что реально приходит от клиента. Если вы попытаетесь принять «полную задачу» целиком, включая id, createdAt и прочее, вы тем самым разрешите клиенту «командовать» серверными полями. В нашем проекте идентификатор задачи генерирует сервер, поэтому в TaskCreateRequest нет id. Это очень здоровая привычка для API: клиент присылает то, что он вправе задавать, а сервер — то, что он обязан контролировать сам.

4. @RequestBody + @PathVariable

Когда вы создаёте ресурс, адресовать конкретную сущность ещё нечем — её id не существует до создания. Но как только мы говорим про обновление, всё становится интереснее: нам нужно одновременно указать, какой именно ресурс мы меняем, и какими данными мы его меняем. И вот тут как раз красиво работает связка path + body.

Path остаётся «адресом» ресурса, а body становится «новым содержимым». Это очень похоже на реальную жизнь: вы можете отправить письмо по адресу (path), а что внутри конверта (body) — это уже данные операции.

Пример сигнатуры метода для «полной замены» (не вдаваясь сейчас в семантику PUT и PATCH, нам важна только механика передачи данных):

import com.example.tasktracker.api.dto.request.TaskUpdateRequest;
import com.example.tasktracker.domain.model.Task;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;

public class TaskController {

    @PutMapping("/api/v1/tasks/{taskId}")
    public Task replace(@PathVariable String taskId,
                        @RequestBody TaskUpdateRequest request) {
        // taskId — «кого меняем» (адрес ресурса в URI)
        // request — «на что меняем» (данные операции в body)
        return null;
    }
}

Обратите внимание, что taskId тут именно в path, а не в JSON. Это делает контракт менее двусмысленным: URI однозначно адресует ресурс, а тело запроса однозначно описывает данные изменения. Когда люди начинают дублировать id и там, и там, это почти всегда заканчивается вопросом «а что делать, если они разные?». Правильный ответ обычно звучит так: «не дублировать».

5. POST /api/v1/tasks с @RequestBody

Сейчас мы сделаем практический шаг в нашем проекте: добавим endpoint создания задачи. В этой лекции нам важно понять, как принять JSON в виде объекта и передать его дальше в сервис, не превращая контроллер в место для ручного парсинга и логики.

Начнём с контроллера. Предположим, у нас уже есть TaskController с GET-методами из прошлых дней, и есть TaskService, который отвечает за прикладные операции. Мы добавляем @PostMapping и @RequestBody:

package com.example.tasktracker.api.controller;

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import com.example.tasktracker.domain.model.Task;
import com.example.tasktracker.domain.service.TaskService;
import org.springframework.web.bind.annotation.*;

@RestController // Контроллер REST-стиля: возвращаем объект, который будет сериализован в JSON
@RequestMapping("/api/v1/tasks") // Базовый путь для всех методов этого контроллера
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        // Внедрение зависимости: контроллер сам не «создаёт задачи», он делегирует сервису
        this.taskService = taskService;
    }

    @PostMapping // POST /api/v1/tasks
    public Task create(@RequestBody TaskCreateRequest request) {
        // request приходит из body, а дальше мы достаём нужные поля и передаём в сервис
        return taskService.create(request.getTitle(), request.getDescription());
    }
}

Заметьте, что сервис мы вызываем простыми типами (String, String). Это помогает держать границу слоёв аккуратной: сервисный слой не обязан знать, как именно выглядел HTTP-запрос. Он знает только смысл операции: «создай задачу с таким-то заголовком и описанием».

Теперь глянем на пример сервиса. На текущем этапе курса нам достаточно простой in-memory реализации (без базы данных). Пусть сервис хранит задачи в Map, генерирует id и возвращает созданную задачу:

package com.example.tasktracker.domain.service;

import com.example.tasktracker.domain.model.Task;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

public class TaskService {

    // In-memory хранилище: живёт только пока работает приложение
    private final Map<String, Task> tasks = new ConcurrentHashMap<>();

    public Task create(String title, String description) {
        // Генерируем id на стороне сервера (клиент не должен управлять этим полем)
        String id = UUID.randomUUID().toString();

        // Собираем доменную сущность из входных данных
        Task task = new Task(id, title, description);

        // Сохраняем в наше временное хранилище
        tasks.put(id, task);

        // Возвращаем созданную сущность (её потом сериализует Spring)
        return task;
    }
}

А вот тут важное уточнение: я намеренно показываю упрощённую модель Task с конструктором new Task(id, title, description). У вас в проекте Task может уже иметь больше полей (например, статус), и тогда конструктор будет другим — это нормально. Для нас сейчас принципиально другое: мы не принимаем Task из request body напрямую, мы принимаем TaskCreateRequest, а потом создаём Task на сервере.

Теперь можно проверить, что Spring действительно «собирает» объект из JSON. Самый простой способ — .http запрос:

### Create task
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json

{
  "title": "Fix login",
  "description": "Users cannot login after password reset"
}

Если всё сделано правильно, вы получите ответ с JSON (его форма зависит от вашего Task):

{
  "id": "b63a3e79-430b-4f1e-8f2b-1f8e8b4a1e6b",
  "title": "Fix login",
  "description": "Users cannot login after password reset"
}

И это ключевой результат лекции: вы отправили JSON текстом, а в Java-коде поработали с нормальным объектом, как будто он «всегда там и был».

6. Дисциплина контракта для request body

Фокус с @RequestBody кажется простым, но он встраивает в проект очень важную дисциплину: вы начинаете относиться к форме входного JSON как к части внешнего договора. Это не «ну мы как-нибудь распарсим», а «мы ожидаем конкретную структуру». И именно поэтому request body лучше собирать в один объект, а не размазывать по десяти параметрам.

Плохой, но часто встречающийся вариант выглядит так: создание ресурса через кучу параметров (обычно query), потому что «так быстрее написать» и «что такого». В итоге контракт становится менее очевидным, а тело запроса пустует без дела:

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

public class BadController {

    @PostMapping("/api/v1/tasks")
    public String create(@RequestParam String title,
                         @RequestParam String description) {
        // Здесь данные «создания» приходят не в body, а в query-параметрах,
        // из-за чего контракт разрастается и хуже читается.
        return "ok";
    }
}

Хороший вариант — один параметр @RequestBody, который описывает структуру входа целиком. Тогда JSON сам становится «документом», а Java-класс — «описанием документа»:

import com.example.tasktracker.api.dto.request.TaskCreateRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

public class GoodController {

    @PostMapping("/api/v1/tasks")
    public String create(@RequestBody TaskCreateRequest request) {
        // Весь входной контракт собран в одном месте: в request-классе
        return "title=" + request.getTitle();
    }
}

И ещё один анти-хаос-совет, который экономит часы жизни: не дублируйте идентификатор ресурса и в path, и в body. Если у вас есть PUT /tasks/{taskId}, то taskId живёт в path. Если вы также добавляете id в JSON, вы сами создаёте ситуацию «а какой из них правильный». А у бэкенда и так много забот, не надо добавлять ему ещё одну.

7. Типичные ошибки при работе с @RequestBody

Ошибка №1: передавать данные создания/обновления через query-параметры «потому что так проще».
Так действительно проще написать один раз, но контракт становится грязнее и тяжелее поддерживается. Для операции создания, где у ресурса много полей, тело запроса с JSON почти всегда даёт более читабельный и устойчивый интерфейс.

Ошибка №2: пытаться читать JSON вручную как строку (HttpServletRequest, getReader()), а потом парсить его самому.
Это классический путь в «сам себе фреймворк». Вы тратите время на инфраструктуру, которую Spring MVC уже умеет делать за вас, и начинаете плодить баги в самом тонком месте — на границе входа данных. Контроллер должен получать объект и делегировать работу сервису, а не превращаться в мини-парсер.

Ошибка №3: ожидать, что можно иметь два параметра @RequestBody в одном методе.
HTTP-тело одно. Оно не может «раздвоиться» на два независимых объекта. Если вам кажется, что нужно два тела, значит вам нужен один объект, внутри которого два поля, или один объект с вложенной структурой.

Ошибка №4: несовпадение имён полей JSON и Java-свойств.
Если клиент прислал "taskTitle", а ваш класс ждёт title, то поле title не заполнится и останется null. Это не «каприз Spring», это естественная цена контрактной дисциплины: сервер ожидает конкретную структуру. Поэтому держите JSON примеры рядом и проверяйте, что названия совпадают.

Ошибка №5: использовать примитивы (int, boolean) там, где поле может быть опциональным.
Если поле не пришло, int станет 0, а boolean станет false. И вы потеряете смысл: было ли это прислано клиентом или просто значение по умолчанию. Для потенциально отсутствующих полей используйте обёртки (Integer, Boolean) и явно различайте «нет значения» и «значение равно нулю/false».

1
Задача
Spring REST & MVC, 7 уровень, 0 лекция
Недоступна
Создание заметки из JSON-тела
Создание заметки из JSON-тела
1
Задача
Spring REST & MVC, 7 уровень, 0 лекция
Недоступна
Замена книги по идентификатору через path и body
Замена книги по идентификатору через path и body
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ