JavaRush /Курсы /Spring REST & MVC /Binding enum, чисел...

Binding enum, чисел и дат

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

1. Задача binding в Spring MVC

Если раньше вам казалось, что @RequestParam int page — это магия уровня «Spring умеет читать мысли», то сейчас мы эту магию аккуратно разберём на детали. Ключевая идея простая: HTTP не приносит в ваше приложение int, LocalDate или TaskStatus. Он приносит символы. То есть по факту — строку. И уже потом Spring пытается понять, во что её превратить.

Представьте типичный запрос к нашему списку задач:

GET /api/v1/tasks?page=1&size=20&status=IN_PROGRESS&dueBefore=2026-03-21

С точки зрения HTTP это просто набор пар ключ=значение, где значения — текст. Даже 1 и 20 — это не числа, а последовательность символов "1" и "20". Даже дата — это строка "2026-03-21". Даже статус — строка "IN_PROGRESS".

И вот здесь важно: тип аргумента в методе контроллера — это часть контракта. Как только вы написали TaskStatus status, вы сказали клиенту: «Присылай не “что угодно”, а одно из допустимых значений статуса». Если вы написали LocalDate dueBefore, вы сказали: «Дата должна быть в ожидаемом формате, иначе запрос не считается корректным».

Непривычно? Да. Полезно? Тоже да. Потому что это делает API предсказуемым: мы не «угадываем», что имел в виду клиент, а честно и рано говорим «да/нет».

2. Где происходит преобразование параметров

Сейчас будет важный момент, который часто упускают: преобразование делается до того, как ваш метод контроллера начнёт выполняться. То есть вы можете идеально написать сервис, покрыть его логикой, но если клиент прислал page=котик, ваш сервис даже не увидит этот запрос. Spring остановит его на входе.

Чтобы уложить это в голову, удобно представить мини-пайплайн обработки параметров так:

flowchart TD
    A["HTTP запрос"] --> B["Spring извлекает raw значения из path/query/header/cookie"]
    B --> C["Type Conversion / Formatting ConversionService"]
    C -->|успех| D["Вызов метода контроллера с типизированными аргументами"]
    C -->|ошибка| E["Ошибка преобразования: метод контроллера НЕ вызывается"]

Эта же цепочка потом сработает и тогда, когда мы вместо набора отдельных параметров будем собирать query-строку в один criteria object: меняется форма аргумента, а не сама механика binding'а.

Если говорить чуть более «по-взрослому», Spring MVC для параметров из path/query/header/cookie использует механизм type conversion на базе ConversionService и форматтеров. Это не то же самое, что конвертация JSON body — там будет HttpMessageConverter, но это тема другого дня. Здесь — именно простейшие значения из запроса, которые по умолчанию приходят как текст.

Почему это важно именно вам, как разработчику API? Потому что вы перестаёте воспринимать контроллер как точку старта. Точка старта — граница API, а контроллер — уже часть обработанного запроса. То есть «что-то пошло не так» может произойти ещё до вашего taskService.findTasks(...).

3. Binding enum через TaskStatus

Enum — это один из самых приятных способов сделать контракт честным. Он работает как турникет в метро: без билета, то есть без валидного значения, не пройдёшь. И это не жестокость, а забота о будущих вас и ваших клиентах: чем меньше значений «на фантазии», тем меньше сюрпризов.

Начнём с того, что в домене у нас уже есть фиксированный набор статусов. В проекте это TaskStatus. Давайте явно покажем enum:

package com.example.tasktracker.domain.model;

/**
 * Статус задачи.
 * Важно: при binding по умолчанию Spring ожидает точное имя константы (регистр важен).
 */
public enum TaskStatus {
    TODO,         // задача создана, но ещё не в работе
    IN_PROGRESS,  // задача в работе
    BLOCKED,      // задача заблокирована внешними обстоятельствами
    DONE,         // задача выполнена
    ARCHIVED      // задача «убрана с глаз», но не удалена
}

Теперь мы хотим, чтобы клиент мог вызывать список задач и при желании фильтровать по статусу. На уровне контроллера это выглядит очень естественно: статус — это query-параметр, и он необязательный.

import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public Object findTasks(
        // Spring сам сконвертирует строку из query (?status=IN_PROGRESS) в enum TaskStatus
        // Если параметр не прислали — будет null (фильтр не применяем)
        @RequestParam(required = false) TaskStatus status
) {
    return taskService.findByStatus(status);
}

Обратите внимание, какая красота получается в сервисе: он получает либо null (фильтра нет), либо реальный enum, а не строку, которую надо вручную сравнивать с "TODO" и надеяться, что нигде не опечатались.

Для проверки руками можно добавить .http запрос или в Postman сделать то же самое:

### Filter tasks by status
GET http://localhost:8080/api/v1/tasks?status=IN_PROGRESS
Accept: application/json

Теперь про важный нюанс, который неизбежно поймает каждый новичок. Enum binding по умолчанию чувствителен к регистру и ожидает точное имя константы. То есть вот так сработает:

GET http://localhost:8080/api/v1/tasks?status=TODO

А вот так, скорее всего, упадёт ещё до контроллера:

GET http://localhost:8080/api/v1/tasks?status=todo

Spring не будет догадываться, что вы хотели сказать. Он честно скажет: «Не могу преобразовать строку todo в TaskStatus». Это строгий контракт. В коммерческой разработке это обычно нормально: вы документируете допустимые значения, и клиенты их соблюдают. Если вы захотите принимать и todo, и TODO, и ToDo — это уже отдельное решение по tolerant input, и оно требует осознанного подхода.

4. Binding чисел: page и size

С числами всё кажется простым ровно до тех пор, пока кто-нибудь не пришлёт size=много. И вот тогда вы внезапно радуетесь, что Spring остановил запрос на границе, а не дал этому «много» добраться до сервиса. Числовые параметры — классика list-endpoint'ов, и их удобно делать строго типизированными.

На текущем этапе мы не реализуем настоящую пагинацию, но контракт уже можно сделать понятным. Например, договоримся, что по умолчанию page = 0, size = 20:

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

@GetMapping("/api/v1/tasks")
public Object findTasks(
        // defaultValue делает параметр «как бы всегда присутствующим» и задаёт понятное поведение
        @RequestParam(defaultValue = "0") int page,
        // Если прислать size=много — конвертация в int упадёт до входа в метод контроллера
        @RequestParam(defaultValue = "20") int size
) {
    return taskService.findPage(page, size);
}

Тут сразу два полезных эффекта.

Во-первых, defaultValue делает поведение детерминированным: если клиент не прислал параметр, вы точно знаете, что будет. Вам не нужно разбрасывать по коду «если null — то 0».

Во-вторых, тип int (или Integer) заставляет Spring выполнить преобразование. Если клиент пришлёт мусор, запрос не дойдёт до сервиса.

Проверим руками:

### First page, size 20
GET http://localhost:8080/api/v1/tasks?page=0&size=20
Accept: application/json

А теперь антипример:

### Wrong: page is not a number
GET http://localhost:8080/api/v1/tasks?page=котик&size=20
Accept: application/json

В этом случае преобразование "котик"int невозможно, и Spring завернёт запрос раньше, чем вы успеете сказать taskService.

Примитивы и обёртки: int и Integer

Здесь есть один практичный момент. Если параметр может быть необязательным, то Integer иногда читается честнее, потому что умеет быть null. Но если вы всё равно хотите defaults, defaultValue + int обычно самый простой и чистый вариант.

Как вы пишете аргумент Может отсутствовать? Что будет, если отсутствует Когда удобно
int page + defaultValue да подставится default для page/size почти всегда
Integer page + required=false да будет null когда хотите явно различать «не пришёл»
int page + required=false без default формально да, но смысл мутный легко получить странные ожидания лучше избегать и не играть в лотерею

Если вы на старте курса хотите минимально удивлять себя будущего, выбирайте defaultValue для технических чисел. Это обычно проще читать и тестировать.

5. Binding дат: LocalDate и формат ISO

Даты в API — источник вечной боли, потому что люди любят писать их так, как привыкли: 21.03.2026, 03/21/2026, 21-03-26 и ещё десяток вариантов «как в нашей бухгалтерии». Но API — это договор, а не свободное сочинение. Поэтому мы выбираем один формат и держимся его. В Spring MVC для даты без времени почти идеальный тип — LocalDate.

В Task Tracker API у задачи есть дедлайн dueDate, и для фильтрации списка логично иметь параметры вроде dueBefore и dueAfter. Это дата без времени, поэтому LocalDate подходит идеально: никакой таймзоны, никаких «а почему у меня вчера стало сегодня».

Пример сигнатуры:

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

@GetMapping("/api/v1/tasks")
public Object findTasks(
        // Параметр необязательный: если dueBefore не пришёл — будет null
        @RequestParam(required = false)
        // Явно фиксируем формат: ожидаем ISO-дату YYYY-MM-DD (а не «как привыкли»)
        @DateTimeFormat(iso = ISO.DATE) LocalDate dueBefore
) {
    return taskService.findByDueBefore(dueBefore);
}

Здесь @DateTimeFormat(iso = ISO.DATE) — это способ сделать контракт более явным. Даже если по умолчанию у вас и так ISO-формат, аннотация работает как дорожный знак: читатель кода и вы через месяц сразу понимаете, что ожидается YYYY-MM-DD.

Запрос для проверки:

### Tasks due before a specific date
GET http://localhost:8080/api/v1/tasks?dueBefore=2026-03-21
Accept: application/json

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

### Wrong: non-ISO date format
GET http://localhost:8080/api/v1/tasks?dueBefore=21.03.2026
Accept: application/json

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

Настройки spring.mvc.format.*

Потому что формат дат и времени в Spring MVC действительно связан с настройками формата. Это можно задавать глобально через свойства вида spring.mvc.format.* в application.yml. Но важная оговорка: сегодня мы не настраиваем глобальную конвертацию, мы только понимаем, что она существует и что формат нельзя оставлять «на удачу».

Если вам нужно просто понять «где это живёт», достаточно такого мысленного примера:

spring:
  mvc:
    format:
      date: yyyy-MM-dd

В учебном проекте обычно выгоднее держаться ISO-форматов, потому что они одинаково читаются и людьми, и машинами, и не зависят от локали машины разработчика.

6. Ошибки преобразования и сигнатура

Сейчас очень хочется спросить: «Окей, а что происходит, если значение неправильное?» И это хороший вопрос — но важнее другое: когда это происходит. Ошибка преобразования возникает до вашей бизнес-логики. То есть вы можете поставить брейкпоинт в сервисе, отправить запрос, увидеть ошибку в ответе — и брейкпоинт не сработает. Не потому что Spring сломался, а потому что он не обязан вызывать ваш метод с некорректными типами.

Чтобы это прочувствовать, иногда полезно, только для себя и временно, сделать примитивную диагностику: например, вывести что-то в контроллере. Это не best practice для продакшена, но как учебный маркер — нормально.

import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public Object findTasks(
        // Здесь параметр обязательный: если статус не передали — будет 400 ещё до выполнения метода
        @RequestParam TaskStatus status
) {
    // Учебная диагностика: увидите вывод только если binding прошёл успешно
    System.out.println("Controller method invoked");
    return taskService.findByStatus(status);
}

Если вы отправите status=TODO, строка в консоли появится. Если отправите status=todo — метод даже не начнёт выполняться.

Почему это хорошо для API? Потому что граница становится чёткой. Клиент прислал некорректный параметр — запрос не считается корректным, и сервер не делает вид, что понял. Это дисциплина контракта. Позже в курсе мы научимся превращать такие ситуации в единый и красивый формат ошибок, но уже сейчас важно видеть механику: контроллер — не первая точка, где может всё пойти не так.

Мини-рефакторинг GET /api/v1/tasks

Когда у нас есть понимание binding'а, хочется собрать небольшой скелет будущего list-endpoint'а. Подчеркну: мы пока не делаем полноценную фильтрацию, сортировку и пагинацию; мы просто показываем, что сигнатура контроллера может быть типизированной и понятной. Это как поставить правильные розетки в квартире до того, как покупать технику.

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

import java.time.LocalDate;

import com.example.tasktracker.domain.model.TaskStatus;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.DateTimeFormat.ISO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@GetMapping("/api/v1/tasks")
public Object findTasks(
        // Enum: либо null (фильтра нет), либо одно из допустимых значений TaskStatus
        @RequestParam(required = false) TaskStatus status,
        // Техническая пагинация: стабильные значения по умолчанию
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size,
        // Даты: фиксируем ISO-формат и допускаем отсутствие параметра
        @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate dueBefore,
        @RequestParam(required = false) @DateTimeFormat(iso = ISO.DATE) LocalDate dueAfter
) {
    return taskService.findTasks(status, page, size, dueBefore, dueAfter);
}

А вот что приятно в сервисе: он больше не думает про строки. Он думает про смысловые типы:

import java.time.LocalDate;

import com.example.tasktracker.domain.model.TaskStatus;

public interface TaskService {
    // Контракт сервиса уже типизирован: никаких строк из HTTP — только доменные/технические типы
    Object findTasks(TaskStatus status, int page, int size, LocalDate dueBefore, LocalDate dueAfter);
}

И это, пожалуй, главный практический итог сегодняшней лекции: хороший API-контракт начинается с того, что тип данных выбран осознанно, и ошибки некорректного ввода происходят на границе, а не внутри всего приложения.

7. Типичные ошибки при binding enum, чисел и дат

В этой теме ошибки обычно не сложные, а коварные: вы почти всё сделали правильно, но одна деталь превращает запрос в 400 ещё до вашего кода. Ниже — самые частые грабли, на которые наступают в Spring MVC, особенно когда только начинаешь.

Ошибка №1: принимать статус как String, а потом вручную разбирать его в контроллере.
Такой код быстро превращает контроллер в мини-парсер: if ("TODO".equals(status)) ... else .... Контракт становится расплывчатым, а ошибки начинаются не на границе, а глубоко в приложении. Если значение ограничено фиксированным набором — enum TaskStatus делает этот набор явным, и Spring сам проверит корректность.

Ошибка №2: ожидать, что enum «сам поймёт» значения вроде in_progress или InProgress.
По умолчанию Spring биндинг enum опирается на имена констант. Если контракт говорит IN_PROGRESS, то именно это и надо прислать. Желание принимать «все варианты написания» выглядит дружелюбно, но часто заканчивается хаосом: клиенты начинают присылать кто во что горазд, а вы потом поддерживаете зоопарк. Лучше один канонический формат и нормальная документация.

Ошибка №3: использовать числовой параметр без defaultValue и потом удивляться поведению при отсутствии значения.
С техническими параметрами вроде page и size почти всегда полезнее иметь чёткие defaults. Когда вы пишете @RequestParam(defaultValue="0") int page, поведение предсказуемо. Когда вы оставляете «как получится», вы сами же создаёте будущую путаницу: то ли 0 «по умолчанию», то ли значение не прислали.

Ошибка №4: писать даты в формате «как привыкли люди», а не как привыкли компьютеры.
21.03.2026 выглядит по-человечески, но для API-формата это не аргумент. В REST API почти всегда выигрывает ISO YYYY-MM-DD. Если вы используете LocalDate, сразу давайте примеры запросов в ISO-формате и, при желании, фиксируйте это через @DateTimeFormat(iso = ISO.DATE), чтобы не было ощущения «оно само как-то догадалось».

Ошибка №5: пытаться ловить ошибки преобразования в сервисе и «обрабатывать по-бизнесовому».
Ошибка преобразования типа происходит раньше — на этапе подготовки аргументов метода контроллера. Поэтому сервис её не увидит, контроллер может даже не стартовать. Если вы ожидаете «в сервисе проверю, что page — число» — вы уже проиграли: до сервиса не дойдёт запрос с page=котик. Это нормально; просто нужно помнить, где проходит граница.

1
Задача
Spring REST & MVC, 6 уровень, 3 лекция
Недоступна
Статус заказа и номер страницы
Статус заказа и номер страницы
1
Задача
Spring REST & MVC, 6 уровень, 3 лекция
Недоступна
Фильтр статей по датам публикации
Фильтр статей по датам публикации
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ