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=котик. Это нормально; просто нужно помнить, где проходит граница.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ