JavaRush /Курсы /Spring Boot /@RestController и JSO...

@RestController и JSON через Jackson

Spring Boot
12 уровень , 0 лекция
Открыта

1. Откуда берётся JSON в ответе

Если вы уже видели в браузере или в Postman JSON-ответ от нашего сервиса, легко попасть в ловушку: кажется, будто Spring Boot «сам рисует JSON», а мы просто произносим магическое заклинание @RestController. Проблема в том, что магия работает ровно до первого непонятного случая: «почему вернулось не то», «почему поле пропало», «почему вместо JSON пришёл текст», «почему в одном месте всё красиво, а в другом внезапно ошибка».

Первый controller и первый JSON-ответ у нас уже есть. Теперь важно снять ощущение, что JSON возникает просто потому, что мы поставили аннотацию. Иначе любой нетипичный случай будет выглядеть как случайная магия, хотя у него есть вполне понятный путь внутри MVC.

Нам нужен честный, но не перегруженный ментальный образ. Он простой: контроллер возвращает данные, Spring MVC выбирает способ превратить эти данные в тело HTTP-ответа, и в нашем baseline этот способ почти всегда означает «сериализовать в JSON через Jackson 3». То есть вы отдаёте структуру данных в Java, а система доставки (framework) упаковывает её в формат, который понимает клиент.

Чтобы это не было абстракцией, зафиксируем базовую терминологию. Когда Java-объект превращается в JSON, это называется сериализация. Когда JSON превращается обратно в Java-объект (например, если бы мы принимали JSON в request body), это десериализация. Сегодня нас интересует именно сериализация, потому что мы делаем read-only API и отдаём ответы наружу.

2. Что делает @RestController

Раз endpoint уже умеет отвечать, самое важное, что нужно понять про @RestController: он не «умеет JSON» сам по себе. Эта аннотация — скорее табличка на двери: «здесь методы контроллера возвращают не имена HTML-шаблонов, а тело ответа». Технически @RestController — это комбинация @Controller и @ResponseBody. А @ResponseBody как раз и означает: результат метода нужно писать в body HTTP-ответа, а не использовать как “view name”.

Почему это важно? Потому что в классическом MVC (который исторически был про HTML-страницы) @Controller часто возвращал строку с именем шаблона — например, "index", а дальше подключался механизм view rendering. В нашем курсе мы на это не уходим: мы строим backend-сервис и хотим, чтобы endpoint отдавал JSON, а не «ищи шаблон где-то там».

Небольшой пример из нашего проекта в стиле “baseline правильно”. Обратите внимание: мы не собираем JSON руками, мы возвращаем данные.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController // Говорим Spring MVC: возвращаем тело ответа, а не имя шаблона
class CatalogTitleController {

    @GetMapping("/api/catalog/title") // Маршрут для простого диагностического JSON-ответа
    Map<String, String> title() {
        // Возвращаем данные, а JSON будет построен Jackson'ом через HttpMessageConverter
        return Map.of("title", "Spring+ Catalog");
    }
}

Если вы дернёте GET /api/catalog/title, клиент получит примерно такой JSON:

{"title":"Spring+ Catalog"}

И вот здесь ключевой момент: метод вернул Map, а JSON появился «снаружи», потому что Spring MVC увидел @ResponseBody-семантику и выбрал подходящий механизм записи ответа.

3. Конвейер ответа в Spring MVC

Чтобы перестать относиться к Spring как к «чёрному ящику», полезно хотя бы раз представить путь запроса внутри MVC. Не на уровне «все классы и интерфейсы мира», а на уровне честной карты: какие крупные шаги происходят между “пришёл HTTP” и “ушёл HTTP”.

Очень грубо этот путь выглядит так: запрос приходит в DispatcherServlet, он ищет подходящий handler (наш метод контроллера), вызывает его, получает результат (Java-объект), затем выбирает стратегию превращения результата в response body и пишет байты в ответ. Выглядит как конвейер на заводе: каждый цех делает свою небольшую часть, а не весь продукт целиком.

Небольшая схема (в меру схематичная, как учебная карта метро — не для стройки тоннелей, а чтобы не заблудиться):

flowchart TD
    %% Упрощённая схема: где именно появляется JSON в ответе
    A[HTTP Request] --> B[DispatcherServlet]
    B --> C[Найти метод контроллера]
    C --> D[Вызвать метод]
    D --> E[Java-объект как результат]
    E --> F[HttpMessageConverter]
    F --> G[JSON bytes]
    G --> H[HTTP Response: status + headers + body]

Самый интересный для нас кусок сегодня — это HttpMessageConverter. Это именно тот «переводчик», который берёт Java-объект и превращает его в нужный формат: JSON, XML, plain text и т.д. В нашем baseline основной переводчик — Jackson-конвертер, потому что в проекте на classpath есть Jackson 3 (его привёл web starter), и Boot автоматически зарегистрировал нужный converter.

4. Jackson 3 и JsonMapper

На этом уровне нам нужен один ключевой факт: JSON пишет не сам @RestController, а библиотека Jackson, которую Spring Boot встроил в MVC-конвейер. Центральный объект Jackson 3 — это JsonMapper. Именно он умеет превращать Java-объекты в JSON и обратно.

Если будете читать старые статьи, там ещё часто встречается legacy namespace com.fasterxml.... В нашем baseline Spring Boot 4 + Jackson 3 используем tools.jackson.... Это не отдельная магия Boot, а просто текущая линия библиотеки.

Ниже — чистый Jackson без MVC. Он нужен только затем, чтобы один раз увидеть библиотеку отдельно от web-слоя: JSON появляется потому, что mapper умеет сериализовать данные.

import tools.jackson.databind.json.JsonMapper;

import java.util.Map;

class JacksonDemo {

    public static void main(String[] args) throws Exception {
        // Чистый Jackson без MVC: видно, что JSON делает mapper, а не @RestController
        JsonMapper mapper = new JsonMapper();

        System.out.println(mapper.writeValueAsString(Map.of("status", "ok")));
        // {"status":"ok"}
    }
}

Здесь нам пока достаточно увидеть границу ответственности: mapper делает перевод, а controller остаётся источником данных.

5. Возвращайте данные, не JSON-строку

У начинающих есть естественный соблазн: «раз клиенту нужен JSON, значит я верну String и соберу JSON руками». Это выглядит как быстрый путь, но на практике это путь к боли. Во-первых, вы начинаете вручную экранировать кавычки и следить за запятыми (а это примерно как вручную складывать ZIP-архив по байтам: технически возможно, но зачем). Во-вторых, вы теряете автоматическую работу с типами: даты, числа, вложенные объекты, коллекции. В-третьих, вы легко ошибаетесь с Content-Type, и клиент потом удивляется: «почему это текст, хотя внутри похоже на JSON?».

Spring Boot baseline предлагает вам более “взрослую” модель: контроллер возвращает структуру данных, и именно структура данных определяет форму JSON.

Полезно зафиксировать это короткой табличкой (она снимает много вопросов уровня “а что будет, если вернуть вот это?”):

Что возвращает метод контроллера Что обычно уходит в HTTP body
Map<String, Object> JSON-объект {...}
List<Something> JSON-массив [...]
record / POJO с getters JSON-объект по полям/компонентам
String Обычно plain text (даже если строка похожа на JSON)

Покажем на маленьком, но “нашем” примере из домена каталога. Пусть мы хотим вернуть кусочек карточки курса: slug и title. Для этого идеально подходит record: он короткий, типобезопасный и читается как «контейнер данных».

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // REST-контроллер: результат метода пойдёт в HTTP body
class CoursePreviewController {

    // DTO для ответа: record удобно сериализуется в JSON "из коробки"
    record CoursePreview(String slug, String title) {}

    @GetMapping("/api/catalog/course-preview")
    CoursePreview preview() {
        // Возвращаем объект, а не JSON-строку: сериализация будет выполнена Jackson'ом
        return new CoursePreview("spring-boot", "Spring Boot");
    }
}

JSON будет примерно таким:

{"slug":"spring-boot","title":"Spring Boot"}

А теперь антипример — просто чтобы вы один раз увидели и запомнили, как не надо делать в нормальном Boot-проекте:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class BadJsonController {

    @GetMapping("/api/catalog/bad-json")
    String badJson() {
        // Антипаттерн: вручную "собираем JSON" строкой (экранирование, Content-Type и т.д.)
        return "{\"title\":\"Spring Boot\"}";
    }
}

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

6. Выбор конвертера: Content-Type и Accept

Пока всё выглядит слишком гладко: вернул объект — получил JSON. В большинстве случаев так и будет, и это хорошо. Но иногда вы можете увидеть странности: например, клиент получает 406 Not Acceptable, или получаете не JSON, а что-то другое. Чтобы не паниковать, нужно знать два простых заголовка: Content-Type и Accept.

Content-Type — это то, что сервер говорит о своём ответе: «я отправляю JSON» (application/json) или «я отправляю текст» (text/plain) и т.д. Accept — это то, что клиент говорит о своих ожиданиях: «я хочу JSON», «я могу XML», «я приму что угодно». Spring MVC использует эти сигналы, чтобы выбрать, какой HttpMessageConverter применять.

На практике для нашего сервиса вы можете проверить это обычным curl. Вот пример запроса, где мы явно просим JSON:

# -i показывает статус и заголовки, чтобы было видно Content-Type и прочие детали
curl -i -H "Accept: application/json" http://localhost:8080/api/catalog/title

В ответе вы увидите что-то вроде:

HTTP/1.1 200
Content-Type: application/json
...
{"title":"Spring+ Catalog"}

Что здесь важно методически: наш контроллер не выставлял Content-Type руками. Он не писал заголовки. Он вернул Map. А Content-Type: application/json появился потому, что MVC выбрал JSON-конвертер и понял, какой формат он пишет.

Почему я так акцентирую заголовки? Потому что это быстрый способ перестать смотреть на “body-текст” как на единственный источник правды. В HTTP статус и заголовки — это половина смысла ответа, и если их игнорировать, можно долго разгадывать загадку “почему клиент ругается”, хотя всё написано в первой строке.

7. Endpoint в catalog-service

Теория работает лучше, когда её можно приложить к вашему проекту, а не к абстрактному “HelloWorldController”. Поэтому сделаем очень приземлённый шаг: добавим маленький endpoint, который возвращает титул каталога. Это полезно и как демо, и как удобная штука для ручной проверки сервиса (иногда такие “диагностические” endpoint’ы помогают быстрее понять, что вообще поднялось и отвечает).

В нашем проекте логично держать web-слой в пакете catalog.web. Создадим небольшой контроллер, который возвращает структуру данных (не строку).

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController // REST-стиль: ответ формируется из возвращаемых данных
class CatalogInfoController {

    @GetMapping("/api/catalog/title")
    Map<String, String> title() {
        // Минимальный "живой" endpoint для проверки: сервис поднялся и отдаёт JSON
        return Map.of("title", "Spring+ Catalog");
    }
}

Заметьте приятный эффект: вы не приняли ни одного “архитектурного решения на века”. Вы не ввели DTO-фреймворк, не добавили сложные слои, не полезли в кастомные мапперы. Вы просто показали: “контроллер возвращает данные”, и теперь JSON-ответ — штатное следствие web-baseline.

На этом же примере удобно объяснять (и проверять), что JSON — это не отдельная «надстройка над контроллером», а встроенная часть MVC-рантайма: если завтра вы вернёте список или record, механизм будет тем же самым.

8. Модели для предсказуемого JSON

Очень частая причина удивления новичка звучит так: «Я вернул объект, но JSON пустой / странный / без полей». В большинстве случаев это не “ошибка Spring”, а просто вопрос того, что Jackson вообще умеет сериализовать по умолчанию.

Если вы возвращаете record, всё обычно прекрасно: record — это “данные наружу”, у него понятная структура, и Jackson легко превращает его компоненты в JSON-поля. Если вы возвращаете обычный класс, ему обычно нужны читаемые свойства — проще всего в виде публичных getters. То есть Jackson должен уметь “прочитать” значения, которые вы хотите отдать клиенту.

В нашем домене у CourseCard много полей, и мы позже будем договариваться о форме JSON для дат, enum и цены. Но базовый принцип остаётся прежним: или record, или класс с getters — и тогда сериализация становится предсказуемой. А если вы сделали класс с приватными полями и не дали способов их прочитать, Jackson не станет телепатом (увы), и JSON получится не таким, как вы ожидали.

Здесь важно удержать границу: сейчас нам нужен только минимальный контракт — “данные должны быть доступны для чтения”. Этого достаточно, чтобы web-baseline работал как обещано.

9. Типичные ошибки при JSON-ответах

Ошибка №1: возвращать JSON как String и считать это нормой.
Иногда это выглядит как быстрый путь: “ну я же могу собрать JSON руками”. Но дальше начинаются кавычки, экранирование, запятые, переносы строк и бесконечные “ой, сломалось”. Плюс вы теряете пользу типизации: даты, числа и вложенные объекты превращаются в конструктор строк. В Boot-подходе правильнее вернуть Map, record или нормальный объект и позволить Jackson сделать свою работу.

Ошибка №2: путать @Controller и @RestController.
Если вы поставили @Controller и вернули объект, Spring может попытаться трактовать результат как “view name” или часть MVC-модели для шаблона. В проектах без template engine это часто заканчивается ошибками, которые выглядят как “почему он ищет страницу, если я хотел JSON?”. Для JSON-API базовый выбор — @RestController, потому что он включает @ResponseBody-семантику по умолчанию.

Ошибка №3: игнорировать HTTP-заголовки и смотреть только на body.
Когда что-то идёт не так, очень хочется смотреть исключительно на JSON (или то, что вы ожидали как JSON). Но зачастую первичный ответ уже написан в статусе и заголовках: Content-Type, 406 Not Acceptable, 400 Bad Request и т.д. Если приучить себя смотреть на -i в curl или на вкладку “Headers” в Postman, половина загадок перестанет быть загадками.

Ошибка №4: ожидать, что “любая Java-модель автоматически превращается в красивый JSON”.
Jackson не умеет читать мысли. По умолчанию ему нужны понятные свойства: record components или getters. Если класс закрыт, без getters, или внутри есть типы, которые сериализуются неожиданно, результат может удивить. Это не повод сразу писать мегаконфигурацию; это повод сначала понять baseline: какие данные вы реально отдаёте и как они устроены в Java.

1
Задача
Spring Boot, 12 уровень, 0 лекция
Недоступна
JSON-объект из `Map`
JSON-объект из `Map`
1
Задача
Spring Boot, 12 уровень, 0 лекция
Недоступна
JSON-массив из списка объектов
JSON-массив из списка объектов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ