JavaRush /Курсы /Java Server /Request body и JSON в DTO

Request body и JSON в DTO

Java Server
23 уровень , 2 лекция
Открыта

1. Роль request body

После path, query и headers остаётся последний источник входных данных — request body. Когда вы впервые начинаете писать серверный код, есть соблазн думать так: «Ну запрос пришёл, давайте сразу прочитаем всё, что можно, а потом разберёмся». Это звучит по‑человечески, но на практике превращает обработчик в “пылесос”: он тащит в себя body даже там, где оно не нужно, и начинает делать лишнюю работу. В backend-мире (даже в маленьком учебном) мы учимся читать ровно то, что нужно, и ровно тогда, когда это нужно.

У HTTP есть простая прикладная логика: у GET /health body обычно нет и не должно быть. У GET /api/v1/reading-list body тоже не ожидается — клиент просит список и отправляет условия через query params. А вот POST /api/v1/reading-list в будущем будет создавать элемент списка чтения, и тогда тело запроса — это главная полезная нагрузка: «вот JSON, создай мне ресурс».

Из этого вытекает важное правило, которое позже у Spring MVC оформится автоматически, а пока мы делаем руками: сначала маршрутизируем, и только потом читаем body в той ветке, где оно ожидается. Иначе вы будете читать body даже для маршрута, который всё равно вернёт 404, и это выглядит примерно как «открыть посылку, чтобы потом сказать: ой, не вам адресовано».

Body в HttpExchange

Если вы привыкли к “красивому миру” фреймворков, тело запроса кажется чем‑то простым: строка, JSON, объект… но HttpServer возвращает нас на землю. В com.sun.net.httpserver.HttpExchange тело запроса — это InputStream, то есть поток байтов. Поток — это важное слово: оно означает, что данные приходят “в одну сторону”, и вы читаете их постепенно или целиком. У потока нет волшебной кнопки «сбросить и прочитать заново».

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

Ещё один нюанс: чтение потока может быть блокирующим. То есть если клиент ещё не прислал весь body (или присылает медленно), ваш поток будет ждать. Поэтому чтение body — действие не бесплатное и не всегда нужное.

Мини-пример, чтобы увидеть, где это живёт:

import com.sun.net.httpserver.HttpExchange;
import java.io.InputStream;

// Тело запроса в HttpServer — это не готовая строка/JSON, а поток байтов.
InputStream is = exchange.getRequestBody(); // Прочитать можно только один раз.

Всё. Никакого JSON, никакого DTO. Просто поток. Чтобы получить JSON, нам нужно сделать два шага: прочитать байты, декодировать их в строку с правильной кодировкой, а потом уже отдать Jackson’у.

2. Читаем body в строку

Если говорить по‑простому, чтение body у нас будет выглядеть как “прочитать все байты → превратить в строку”. В реальном продакшене есть ограничения по размеру тела, стриминг, защита от слишком больших payload’ов и прочие радости взрослой жизни. Но в нашем учебном API тела маленькие и JSON простой, поэтому readAllBytes() — это нормально и даже полезно: меньше отвлекаемся от главной идеи.

При этом есть нюанс, который легко пропустить: кодировка. Мы должны одинаково понимать текст и на стороне клиента, и на стороне сервера. В рамках курса мы фиксируем UTF-8, как это обычно и делают в JSON API.

Вот минимальный, но очень полезный helper-метод. Обратите внимание на try-with-resources: мы закрываем поток, чтобы не держать ресурсы дольше нужного.

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

private String readBody(HttpExchange exchange) throws IOException {
    // Важно: request body — одноразовый InputStream. Читаем его строго один раз.
    try (InputStream is = exchange.getRequestBody()) {
        // Для учебного API читаем целиком: так проще и понятнее.
        byte[] bytes = is.readAllBytes();

        // Явно фиксируем UTF-8, чтобы не зависеть от системной кодировки.
        return new String(bytes, StandardCharsets.UTF_8);
    }
}

Этот метод делает несколько вещей “правильно по умолчанию”. Он читает тело ровно один раз, он явно указывает UTF-8 (а не “пусть система сама догадается”), и он возвращает обычную Java-строку, с которой уже можно работать дальше.

Практический совет: не превращайте readBody(...) в универсальный комбайн «на все времена». Сейчас это 5–6 строк, которые понятны любому новичку. Если вы начнёте туда добавлять “а ещё поддержим gzip, а ещё charset из заголовка, а ещё лимит, а ещё…” — вы очень быстро построите свой мини-Spring, а по плану курса это запрещено (и методически вредно).

3. Пустое тело и некорректный JSON

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

Первая ситуация — тело запроса пустое. Клиент прислал POST, но забыл положить JSON. Это похоже на «я отправил вам конверт, но внутри ничего». Вторая ситуация — тело есть, но JSON сломан. Это похоже на «конверт полный, но письмо написано на языке, где половина букв вверх ногами, и грамматика исчезла в отпуск».

И да, это разные причины, и в будущем они могут превращаться в разные details в нашем ErrorResponse. Но даже сейчас (не углубляясь в единый error handling) мы должны хотя бы концептуально разделять эти случаи, иначе диагностика будет очень плохой.

Мини-проверка на пустое тело может выглядеть так:

// isBlank() отсекает и пустую строку, и строку из одних пробелов/переводов строк.
if (body.isBlank()) {
    throw new IllegalArgumentException("Тело запроса обязательно");
}

Это не “валидация бизнес-логики”. Это проверка на уровне HTTP-границы: без тела у нас нет даже материала, который можно пытаться разобрать.

Полезно держать в голове такую табличку, чтобы не путаться:

Ситуация Пример Это ошибка чего? Что обычно делаем
Body отсутствует/пустое "" Ошибка запроса на HTTP-границе Возвращаем 400 Bad Request
JSON синтаксически сломан { "title": "Clean Code", } Ошибка формата данных Возвращаем 400 Bad Request
JSON корректен, но данные плохие { "title": "" } Уже прикладная/семантическая ошибка Тоже 400, но причины другие

Обратите внимание: третий случай — это уже область ручной валидации, и по плану курса мы будем глубже разбирать это позже. Сейчас важно только понимать, что Jackson не решит за нас прикладные правила; он просто сможет или не сможет прочитать JSON как структуру.

4. Content-Type и JSON

В идеальном мире клиент честно отправляет заголовок Content-Type: application/json; charset=UTF-8, а сервер такой: «прекрасно, я буду читать JSON». В реальном мире клиент может забыть заголовок, прислать text/plain, или вообще отправить JSON без Content-Type. Как поступать? Можно спорить долго, но в учебном проекте важнее предсказуемость, чем философия.

Поэтому мы делаем так: мы читаем Content-Type, и если он явно не похож на JSON, мы считаем запрос некорректным. Это помогает “отсеять” странные запросы ещё до Jackson. Важно, что эта проверка не должна превращаться в парсер RFC — мы делаем её мягко.

Вот пример:

import com.sun.net.httpserver.HttpExchange;

private boolean isJsonRequest(HttpExchange exchange) {
    // Берём заголовок Content-Type (если его нет — вернётся null).
    String contentType = exchange.getRequestHeaders().getFirst("Content-Type");

    // Для учебного проекта достаточно проверки "начинается с application/json".
    return contentType != null && contentType.startsWith("application/json");
}

А дальше в нашем route‑методе можно сделать коротко и ясно:

// Проверяем media type до попытки парсинга через Jackson.
if (!isJsonRequest(exchange)) {
    throw new IllegalArgumentException("Ожидается Content-Type: application/json");
}

Да, в реальном мире можно быть либеральнее. Но для учебного API такая строгость даёт полезную привычку: клиент и сервер договариваются явно. И когда позже вы придёте в Spring MVC, вы поймёте, почему там так много внимания “какому media type соответствует body”.

Для body-ветки порядок здесь принципиален: сначала убеждаемся, что клиент вообще прислал JSON, потом читаем body, потом проверяем, что он не пустой, и только после этого зовём Jackson.

5. Request DTO vs доменная модель

Сейчас у нас в голове уже есть локальная доменная сущность ReadingListItem. И у новичка возникает естественная мысль: «Зачем отдельный request DTO? Давайте сразу в доменную сущность, и готово». Это очень распространённая ловушка. Она кажется удобной до тех пор, пока у вас не появляется вторая версия API, валидация, разные формы запросов, частичные обновления и… в общем, жизнь.

Request DTO — это “форма конверта”, в котором клиент присылает данные. Domain model — это “как мы храним и понимаем сущность внутри приложения”. Эти две модели похожи, но они не обязаны совпадать, и часто вредно заставлять их совпадать.

Для нашего проекта логично иметь DTO вроде CreateReadingItemRequest. Он отражает то, что клиент присылает при создании элемента. И да, поля будут напоминать доменную сущность, но разница в том, что здесь нет id (его создаёт сервер), и здесь могут быть поля nullable, потому что клиент может их не прислать. Доменная модель потом будет более строгой.

Мини‑DTO, который нам уже годится для чтения JSON, может выглядеть так:

package com.example.readlater.readinglist.dto;

// DTO для входящего запроса на создание элемента списка чтения.
// Здесь мы описываем контракт API, а не "идеальную доменную модель".
public record CreateReadingItemRequest(
        String title,
        String author,
        // В request DTO статус пока строкой: клиент присылает текст, а в enum мы переведём позже.
        String status,
        String externalId,
        String comment
) {}

Этого DTO достаточно и для временной preview-ветки transport-слоя. Нам не нужен второй почти такой же класс только ради того, чтобы прогнать JSON туда-обратно: труба выигрывает от одного понятного формата входа.

Обратите внимание: status здесь строкой. Да, в домене у нас будет ReadingStatus enum. Но здесь нам достаточно того, что transport-слой получил текстовое значение; перевод строки в enum и проверка допустимых значений — отдельная обязанность уже после HTTP-границы.

6. JSON → DTO через ObjectMapper

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

Базовый happy‑path:

import com.example.readlater.readinglist.dto.CreateReadingItemRequest;
import com.fasterxml.jackson.databind.ObjectMapper;

// objectMapper обычно создаётся один раз и переиспользуется (не создаём на каждый запрос).
CreateReadingItemRequest dto =
        objectMapper.readValue(body, CreateReadingItemRequest.class); // JSON -> DTO

Всё, у нас есть DTO. Но что может пойти не так?

Может прийти не JSON, а “почти JSON”. Например, лишняя запятая, не закрытая кавычка, неправильные скобки. Может прийти корректный JSON, но поля другого типа. Например, status как число. Может прийти JSON с неожиданной структурой: массив вместо объекта.

Во всех этих случаях ObjectMapper.readValue(...) бросит исключение (чаще всего наследника JsonProcessingException, который является IOException). И вот тут важно принять простую мысль: это не “ой, rare case”, это нормальный путь в обработке HTTP‑границы. Клиент может ошибаться. Postman может ошибаться. Вы сами можете ошибаться, когда тестируете руками. Поэтому мы не должны позволять исключению улетать “куда‑то наверх” и ронять серверный поток.

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

Мини‑helper:

import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;

private <T> T readJson(String body, Class<T> type) {
    try {
        // Единственная ответственность метода: распарсить JSON в указанный тип.
        return objectMapper.readValue(body, type);
    } catch (IOException e) {
        // Ошибка чтения/десериализации на HTTP-границе = "некорректный запрос".
        throw new IllegalArgumentException("Некорректный JSON");
    }
}

Эта функция делает важную методическую вещь: отделяет “техническую ошибку десериализации” от остального кода. Теперь любой маршрут может вызвать readJson(...) и не думать о деталях Jackson. Но обратите внимание: мы не строим “универсальный error framework”, мы просто прячем низкоуровневый try/catch, чтобы handler читался человечески.

7. Порядок действий в маршруте

Очень хочется после получения DTO сразу «создать объект в репозитории» и почувствовать прогресс. Но по плану дня мы сознательно не лезем в прикладную логику. Сегодня мы строим транспортный конвейер, который потом будет использоваться всеми endpoint’ами.

Поэтому возьмём временную transport-ветку POST /api/v1/reading-list/preview: она не притворяется финальным бизнес-эндпоинтом, а просто позволяет проверить, что HTTP-труба собрана правильно. В ней мы принимаем JSON, превращаем его в DTO и возвращаем небольшой кусочек данных обратно.

В нашем ReadingListHandler это может выглядеть так:

import com.example.readlater.readinglist.dto.CreateReadingItemRequest;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.util.Map;

private void handleCreatePreview(HttpExchange exchange) throws IOException {
    // 1) Body-bearing ветка начинается с проверки media type.
    if (!isJsonRequest(exchange)) {
        throw new IllegalArgumentException("Ожидается Content-Type: application/json");
    }

    // 2) Читаем тело запроса (один раз) и получаем строку.
    String body = readBody(exchange);

    // 3) Сначала отдельная проверка на пустое тело (до Jackson).
    if (body.isBlank()) {
        throw new IllegalArgumentException("Тело запроса обязательно");
    }

    // 4) Парсим JSON в DTO.
    CreateReadingItemRequest req = readJson(body, CreateReadingItemRequest.class);

    // 5) Возвращаем небольшой фрагмент данных как JSON (проверяем, что "труба работает").
    sendJson(exchange, 200, Map.of("title", req.title(), "author", req.author()));
}

Здесь есть несколько важных деталей, и все они “про механику”, а не “про бизнес”.

Во-первых, мы начинаем с Content-Type, а не с чтения потока. Во-вторых, мы читаем body один раз и сразу превращаем в строку. В-третьих, мы разделяем пустое тело и сломанный JSON. И в‑четвёртых, мы не пишем байты ответа руками — мы зовём sendJson(...) как служебный метод.

Если вам кажется, что это “слишком много работы ради простого POST” — добро пожаловать в реальность web‑layer без Spring MVC. Именно этот опыт нам и нужен: вы должны физически почувствовать, сколько рутины потом заберёт фреймворк.

8. Логирование body

Когда вы наконец-то прочитали body, появляется соблазн: «О, давайте залогируем body целиком, чтобы видеть, что пришло». И это один из самых быстрых способов превратить логи в мусор. Во‑первых, body может быть большим (даже случайно). Во‑вторых, body может содержать чувствительные данные (в нашем проекте их нет, но привычка формируется сейчас). В‑третьих, логировать каждую JSON‑простыню — это как печатать в лог каждый байт файла: смысла мало, шума много.

Лучше логировать мета‑информацию: метод, путь, статус, время обработки, и максимум — размер тела. Например, так:

import java.nio.charset.StandardCharsets;

// Логируем размер, а не содержимое: меньше шума и меньше риск утечки данных.
int size = body.getBytes(StandardCharsets.UTF_8).length;
log.debug("Request body size = {} bytes", size);

Это даст вам сигнал “тело пришло и не пустое”, но не засорит логи.

9. Типичные ошибки при работе с request body

Ошибка №1: читать exchange.getRequestBody() в маршрутизации “на всякий случай”.
Так вы теряете контроль над потоком и делаете работу даже для 404. Правильный порядок — сначала выбрать маршрут по method + path, и только потом читать body в конкретной ветке, где оно ожидается. Это делает код быстрее, проще и предсказуемее.

Ошибка №2: пытаться прочитать body дважды.
InputStream из HttpExchange — одноразовый. Если вы сначала прочитали его “для логов”, а потом ещё раз “для JSON”, второй раз вы получите пустоту или странное поведение. Правильный подход — прочитать один раз в строку, сохранить в переменную и дальше работать с ней сколько угодно.

Ошибка №3: забыть про UTF-8 и получить “кракозябры”.
Если вы делаете new String(bytes) без указания charset, вы доверяете системной кодировке, а это лотерея. Потом один студент на Windows увидит одно, другой на Linux — другое, а JSON внезапно превратится в ребус. В учебном API фиксируйте StandardCharsets.UTF_8 явно.

Ошибка №4: смешивать “пустое тело” и “сломанный JSON” в одну ошибку.
Если вы сразу вызываете objectMapper.readValue(...), то пустое тело может превратиться в непонятную ошибку Jackson. Клиенту (и вам) будет тяжелее понять, что произошло. Гораздо лучше сначала проверить isBlank(), а потом парсить. Тогда ошибки будут честнее.

Ошибка №5: маппить request JSON сразу в доменную сущность.
Кажется удобным, но быстро ломает границы ответственности. Request DTO отражает контракт, доменная модель отражает внутреннее состояние. Если смешать, вы получите каскад изменений по всему проекту при малейшем изменении API. Используйте отдельные request DTO (обычно record) и не бойтесь этого “лишнего” класса: он экономит время позже.

1
Задача
Java Server, 23 уровень, 2 лекция
Недоступна
JSON в request DTO и ответ с одним полем
JSON в request DTO и ответ с одним полем
1
Задача
Java Server, 23 уровень, 2 лекция
Недоступна
Отдельная обработка пустого тела и некорректного JSON
Отдельная обработка пустого тела и некорректного JSON
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ