JavaRush /Курсы /Spring REST & MVC /MVC: диагностика body conversion

MVC: диагностика body conversion

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

1. Диагностика вместо магии

Развилка URL или body уже отсекла половину ложных гипотез. Но на этом конвейер не заканчивается: Spring может не найти handler, URL-параметр может не сконвертироваться, request body может не прочитаться, а response body — не записаться после return. Поэтому главный навык здесь — не запоминать ещё одно внутреннее название, а быстро локализовать фазу поломки. Иначе очень легко чинить Jackson, когда проблема была в @PathVariable, или дебажить сервис, когда метод контроллера вообще не вызывался.

Сейчас важнее не новый механизм, а рабочая схема: за 3060 секунд понять, где болит, а уже потом лезть глубже. Для обычного REST API этой глубины уже хватает, чтобы не относиться к MVC как к лотерее.

Цепочка обработки запроса

Когда у вас есть один метод контроллера, кажется, что “запрос попал в метод — и всё”. Но Spring MVC работает как конвейер: есть этапы до входа в метод, есть сам метод (и сервисы), а есть этапы после return, когда результат надо превратить в HTTP-ответ. Если вы не видите весь конвейер, то любая ошибка выглядит одинаково: “что-то не так”.

Давайте зафиксируем прикладную (не академическую) цепочку. Я сознательно не называю внутренние классы — вы не обязаны помнить их по именам, но обязаны понимать этапы:

flowchart TD
    A["HTTP-запрос пришёл"] --> B["Поиск handler: mapping"]
    B --> C["Подготовка аргументов метода"]
    C --> D1["Path/Query conversion ConversionService"]
    C --> D2["Body conversion HttpMessageConverter"]
    D1 --> E["Вход в метод контроллера"]
    D2 --> E
    E --> F["Вызов сервиса / бизнес-логика"]
    F --> G["return value"]
    G --> H["Запись HTTP-ответа"]
    H --> I["Response body conversion HttpMessageConverter"]
    H --> J["Статус / заголовки"]
    I --> K["HTTP-ответ ушёл клиенту"]
    J --> K

Здесь есть важная деталь, которую многие пропускают: чтение body и запись body — независимы. Например, вы можете читать request body (POST с @RequestBody), но не писать response body (возвращать 204 No Content). Или наоборот: у GET нет request body, но есть response body.

Мини-кусок кода для ощущения этой независимости (представьте, что это фрагмент внутри TaskController):

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

@PostMapping("/api/v1/tasks")
public ResponseEntity<Void> create(@RequestBody CreateTaskBody body) {
    // ВАЖНО: здесь Spring обязан прочитать request body и собрать CreateTaskBody,
    // иначе параметр body просто не появится.
    // Но в ответе тела не будет: мы возвращаем 204 No Content.
    return ResponseEntity.noContent().build();
}

Тут request body обязательно должен быть прочитан, иначе body не появится. Но response body не будет записан, потому что его нет.

2. Быстрая локализация проблемы

Дошёл ли запрос до метода

Первый диагностический вопрос звучит почти обидно: “А вы уверены, что ваш метод вообще выполнялся?” Он обиден, потому что иногда ответ — “нет”. И это нормально: во многих ошибках виноваты этапы до входа в метод. Если вы пытаетесь дебажить сервис, а Spring даже не смог распарсить JSON — вы буквально чините холодильник, когда у вас не включена розетка.

Самый простой и честный способ проверить “дошло/не дошло” — поставить breakpoint или (в учебных целях) временный System.out.println. В проде, конечно, лучше логгер, но сейчас важна идея.

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

@PostMapping("/api/v1/tasks")
public ResponseEntity<Void> create(@RequestBody CreateTaskBody body) {
    // Учебный маркер: если его нет в консоли/дебаге — до метода не дошло.
    // В продакшене вместо System.out используйте логгер.
    System.out.println("TaskController#create called"); // TaskController#create called
    return ResponseEntity.noContent().build();
}

Если вы отправляете запрос и не видите этот вывод (или breakpoint не срабатывает), значит проблема находится до метода. Дальше вы не гадаете, а задаёте следующий вопрос: “Почему не дошло?” — и тут уже начинаются конкретные варианты.

Очень часто новичок путает две ситуации. В одной Spring вообще не нашёл handler (не совпал путь/метод), в другой handler найден, но аргументы не удалось подготовить (query не конвертировался, JSON не прочитался). В обоих случаях “метод не вызвался”, но причины разные — и лечатся по-разному.

Источник данных: URL или body

Второй диагностический вопрос обычно звучит так: “Что именно Spring пытался преобразовать?” Ошибка “не удалось прочитать значение” может относиться и к query-параметру, и к JSON-телу, и к path variable. Если вы не разделяете эти источники, вы будете искать проблему в Jackson, когда она вообще не про JSON. А это, как минимум, лишние 20 минут жизни (и один грустный чай).

Зафиксируем простое правило: @PathVariable и @RequestParam — это строки из URL, которые нужно превратить в тип. @RequestBody — это тело сообщения, которое нужно прочитать как целый документ (например, JSON).

Сравнение удобно держать в голове в виде таблицы:

Источник данных Как выглядит “сырьё” Какой механизм Типовой пример
Path variable маленький фрагмент строки ConversionService /tasks/{taskId}
Query parameter маленький фрагмент строки ConversionService ?page=0&dueBefore=2026-03-21
Request body целый документ (JSON-текст) HttpMessageConverter + Jackson {"title":"Fix API"}
Response body целый документ (JSON-текст) HttpMessageConverter + Jackson {"id":"...","title":"..."}

Один и тот же Java-тип может появиться в обоих мирах. Например, LocalDate можно получить из query, а можно из JSON. Это разные конвейеры:

import java.time.LocalDate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public String find(@RequestParam LocalDate dueBefore) {
    // dueBefore приходит из query string, т.е. это URL conversion через ConversionService.
    return dueBefore.toString();
}

Здесь LocalDate строится из строки query-параметра.

А вот здесь LocalDate строится из JSON:

import java.time.LocalDate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;

@PostMapping("/api/v1/tasks")
public void create(@RequestBody CreateTaskBody body) {
    // body собирается Jackson'ом из JSON (request body conversion).
    LocalDate due = body.dueDate;
}

На практике это значит: если дата “не парсится”, сначала спросите себя “она пришла из query или из JSON?”. Это мгновенно отделяет ConversionService-ошибки от HttpMessageConverter/Jackson-ошибок.

3. Типовые поломки body conversion

Body conversion чаще всего ломается не потому, что Spring “не умеет JSON”, а потому, что контракт входа/выхода не совпал с ожиданиями: неверный media type, сломанный JSON или JSON не соответствует Java-типу. Это три разные проблемы, но новичок часто видит только одно: 400 Bad Request и паника. Чтобы не паниковать, нужно научиться распознавать их по симптомам.

Ниже — три типовых сценария, с которыми вы будете сталкиваться постоянно. Сразу договоримся: мы сейчас не строим идеальную модель ошибок, мы просто учимся понимать, на каком этапе “разбился запрос”. Даже если Spring вернул вам не очень дружелюбный текст, он, как правило, всё равно подсказывает источник проблемы.

Неверный Content-Type: “я отправляю JSON, но сервер не верит”

Этот сценарий выглядит так: вы реально отправляете JSON-текст, но в заголовках указали Content-Type не application/json. Для человека “ну это же всё равно JSON”, а для сервера это как прийти в банк и сказать “у меня точно деньги, просто в пакете из-под чипсов”. Возможно, деньги, но процесс уже стал подозрительным.

Типичный запрос-антимеридиан (условный .http-пример):

# Ошибка: тело похоже на JSON, но Content-Type говорит "это обычный текст".
POST http://localhost:8080/api/v1/tasks
Content-Type: text/plain

{"title":"Fix API"}

Если ваш endpoint ожидает JSON (@RequestBody + consumes = application/json или default JSON stack), Spring попробует подобрать HttpMessageConverter под text/plain и ваш Java-тип. Обычно подходящего не найдёт, и вы получите что-то вроде 415 Unsupported Media Type (или похожую историю).

Что проверять в первую очередь? Заголовок Content-Type в запросе и (если вы используете) consumes в аннотации маппинга. Если они не дружат — request body не будет прочитан.

Malformed JSON: “текст есть, но это не JSON”

Второй сценарий проще, но очень частый: JSON синтаксически сломан. Самый “классический” баг — лишняя запятая, незакрытая кавычка или фигурная скобка. И да, этот баг способен прожить 20 минут, потому что вы смотрите на Java-код, а проблема в одной запятой.

Пример “сломанного” JSON (лишняя запятая в конце):

# Ошибка: JSON синтаксически невалиден (лишняя запятая в конце).
POST http://localhost:8080/api/v1/tasks
Content-Type: application/json

{"title":"Fix API",}

Здесь Spring уже понял, что формат — JSON, выбрал JSON-конвертер, передал текст Jackson… и Jackson честно сказал: “Извините, я это читать не буду”. Важно, что это происходит до входа в метод контроллера. Поэтому вы не увидите ни breakpoint, ни System.out.println.

Это тот случай, когда новичок часто говорит: “У меня сломалась валидация” или “сервис упал”. Нет: сервис даже не запускался. Сначала должен появиться Java-объект, а он не появится, пока JSON не станет валидным синтаксически.

JSON не соответствует Java-типу: “JSON валидный, но не тот”

Третий сценарий — самый коварный, потому что JSON выглядит красивым, формат правильный, но структура/типы значений не совпадают с тем, что ожидает ваш параметр @RequestBody. Например, вы ожидаете число, а прислали строку. Или ожидаете объект, а прислали массив. Или поле называется по-другому.

Представим, что у вас request body выглядит примерно так:

class CreateTaskBody {
    // Ожидаем строку — это ок.
    public String title;

    // Ожидаем число. Если придёт строка ("a lot") — Jackson не сможет собрать объект.
    public int estimateHours;
}

А клиент отправляет:

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

{"title":"Fix API","estimateHours":"a lot"}

JSON синтаксически корректен, но "a lot" нельзя честно превратить в int. Это снова ошибка этапа body conversion: Jackson не может построить объект. И снова метод контроллера может не выполниться, потому что параметр не удалось создать.

На уровне диагностики здесь важно не спорить с реальностью: если типы не совпали, они не совпали. Не надо “чинить Spring”, надо привести контракт (или Java-тип, или JSON) к одному пониманию.

4. Поломки на выходе ответа

Новички часто думают: “Если метод отработал — значит, ответ точно уйдёт”. А вот и нет. После return Spring MVC должен записать response body, и это снова зона ответственности HttpMessageConverter. Поэтому бывают ситуации, когда вы видите, что код выполнился (даже сервис сработал), но клиент всё равно получает ошибку, потому что запись ответа не удалась.

Самый простой пример — конфликт ожиданий формата ответа. Например, метод возвращает объект, но вы объявили (или клиент потребовал через Accept) формат, под который конвертера нет.

Для примера возьмём тот же простой TaskResponseBody с полями id и title.

Плохой (и специально “ломающий”) пример маппинга:

import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;

@GetMapping(path = "/api/v1/tasks/ping", produces = MediaType.TEXT_PLAIN_VALUE)
public TaskResponseBody ping() {
    // Ошибка контракта: produces=text/plain, но возвращаем объект.
    // Spring сможет отдать text/plain только если результат — строка/байты.
    TaskResponseBody response = new TaskResponseBody();
    response.id = "t-1";
    response.title = "Fix API";
    return response;
}

Здесь вы требуете text/plain, но возвращаете объект. Spring умеет писать строку как text/plain, но объект — нет (ему нужен JSON-конвертер и application/json). В реальной жизни вы так специально не делаете, но как диагностический эксперимент это полезно: вы сразу видите, что “метод отработал, но ответ не записался”.

Более жизненный вариант — клиент прислал Accept: application/xml, а ваш сервис XML не поддерживает. Даже если метод мог бы вернуть объект, Spring не сможет подобрать конвертер под запрошенный формат. С точки зрения диагностики это снова ключевая мысль: на выходе тоже есть conversion.

5. Схема диагностики по симптомам

Чтобы эта лекция не осталась “умной теорией”, зафиксируем одну практическую схему. Её можно держать прямо в голове, без бумажки и без меморизации всех exception-классов. Схема строится на двух вопросах, которые мы уже обсудили, и на уточнении “вход или выход”.

Сначала вы определяете фазу: проблема до метода, внутри метода или после метода. Обычно это видно по тому, сработал ли breakpoint/маркер и что вернул сервер (и в какой момент). Затем вы определяете источник данных: URL или body. А уже потом — конкретный тип поломки.

Ниже — “карта симптомов”, которую удобно перечитывать, когда всё сломалось и хочется обвинить вселенную:

Симптом (как это выглядит) Где случилось Что проверить в первую очередь
Метод контроллера не вызвался, 404/405 mapping путь, HTTP-метод, базовый /api/v1, аннотации @GetMapping/@PostMapping
Метод контроллера не вызвался, ошибка про “не могу преобразовать параметр” URL conversion @RequestParam/@PathVariable, формат даты/числа/enum в URL
Метод контроллера не вызвался, ошибка про чтение JSON / parse error request body conversion Content-Type, синтаксис JSON, структура под Java-тип
Метод вызвался, сервис сделал дело, но ответ не ушёл response body conversion produces, Accept, тип возвращаемого значения, возможность сериализации
Метод вызвался, но внутри упал ваш код ваша логика уже дебажим сервис/маппинг/проверки, а не MVC-механику

Обратите внимание: в этой схеме нет пункта “срочно напишите свой HttpMessageConverter”. И это не шутка. На уровне Junior почти все проблемы решаются корректным Content-Type, валидным JSON и совпадением Java-типов с контрактом. Кастомизация нужна, но позже и по необходимости, а не как первая реакция на боль.

6. Типичные ошибки при body conversion

Ошибка №1: чинить сервис и бизнес-логику, когда запрос не дошёл до метода контроллера.
Это классика: вы открываете TaskService, правите “подозрительные” места, добавляете проверки, а запрос всё так же падает. Причина обычно проста: проблема до входа в метод (сломанный JSON, неверный Content-Type, не парсится query-параметр). Сначала убедитесь, что метод реально выполняется.

Ошибка №2: путать conversion из URL и conversion из body.
Когда не парсится LocalDate, новичок часто автоматически думает “Jackson”. Но если дата пришла из query string — Jackson тут вообще не участвовал. И наоборот: если дата внутри JSON, то ConversionService не виноват. Первое, что нужно сделать при любой конверсионной ошибке — определить источник данных.

Ошибка №3: считать, что “у меня же JSON в теле” важнее, чем заголовок Content-Type.
С точки зрения HTTP контракт — это не только тело, но и заголовки. Если вы отправили JSON и забыли Content-Type: application/json, сервер имеет полное право не угадывать ваш замысел. Тренируйте привычку проверять Content-Type так же автоматически, как вы проверяете путь запроса.

Ошибка №4: думать, что response body “пишется сам”, если метод вернул объект.
После return ещё должен найтись конвертер для записи ответа, и он зависит от produces, Accept и типа возвращаемого значения. Поэтому ситуации “метод отработал, но клиент получил ошибку” вполне реальны. Это особенно полезно помнить, когда вы уверены, что “всё же успешно прошло”.

Ошибка №5: пытаться углубляться во внутренности Spring MVC раньше, чем вы научились локализовать фазу проблемы.
Очень заманчиво начать гуглить “как заменить конвертер”, “как настроить ObjectMapper”, “как сделать кастомный binder”. Но чаще всего это преждевременная сложность. Пока вы не умеете чётко сказать “это упало на body conversion” или “это упало на query conversion”, любая тонкая настройка превращается в магический ритуал. А мы тут магию как раз выгоняем из проекта — вежливо, но уверенно.

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