1. Сигнатура как контракт API
Когда вы впервые смотрите на Spring MVC, очень хочется думать, что контракт endpoint’а — это только аннотация @GetMapping("/что-то"), а метод внутри — «обычный Java-код». Но в web-разработке метод контроллера — это не просто метод: это форма публичного договора между клиентом и сервером. И сигнатура (аргументы + возвращаемый тип) делает этот договор конкретным.
Если упрощать до рабочей модели, то аннотации говорят Spring: «какой запрос сюда подходит», а сигнатура говорит: «что мне нужно вытащить из запроса, чтобы этот метод вообще можно было вызвать» и «что нужно положить в ответ после выполнения». Поэтому сигнатура — это не косметика и не «Java-формальность», а вторая половина публичного интерфейса API, прямо рядом с URI.
Важно понять ещё одну вещь: Spring готовит аргументы до вызова метода. То есть вы не «получаете HttpServletRequest и парсите его руками» (как в старых легендах). Spring смотрит на параметры метода, на аннотации на них и пытается их «разрешить» из запроса. Если это невозможно, метод может даже не запуститься — и это нормально: API-граница должна быть строгой.
Сигнатура: вход и выход
Сигнатура метода контроллера в Spring MVC похожа на хорошо организованный заказ в кафе. Вы не просто говорите «сделайте мне что-нибудь вкусное», вы называете конкретику: что нужно принести (аргументы) и что вы хотите получить в итоге (возвращаемое значение). Если вы не уточнили заказ, официант (Spring) либо принесёт не то, либо вообще остановится и скажет: «Я не понял, что вы хотели».
На текущем уровне нам достаточно держать в голове простую схему: сначала mapping выбирает метод, затем Spring подготавливает аргументы, затем выполняется код метода, затем результат превращается в HTTP-ответ. Под «превращается» мы пока понимаем очень приземлённо: «то, что вернули из метода, уходит клиенту». Как именно это превращение устроено технически — отдельная тема, и сегодня мы в неё не ныряем.
Ниже — маленькая «карта соответствий», которая помогает перестать путаться. Это не полный справочник (его никто не выдержит), а минимум для сегодняшнего дня:
| Что мы пишем в методе | Откуда Spring это берёт | Минимальный пример |
|---|---|---|
| метод без аргументов | ниоткуда — запрос самодостаточный | public String ping() |
| @PathVariable String taskId | из пути, из сегмента {taskId} | GET /tasks/{taskId} |
| возвращаемый String | станет телом ответа | "pong" |
| возвращаемый List<String> | станет телом ответа (список) | List.of("t1", "t2") |
И да: пока мы сознательно не трогаем query-параметры, body, заголовки и прочие источники входа. Сегодня нам важен базовый навык: увидеть, как кусок URL попадает в аргумент метода.
2. Коллекция: GET /api/v1/tasks
Список ресурсов (коллекция) — это тот счастливый сценарий, в котором мы можем написать метод без входных аргументов и не чувствовать себя виноватыми. Если клиент делает GET /api/v1/tasks, то он не адресует конкретную задачу, а просит «коллекцию задач» целиком. На этом уровне нам не нужен taskId, потому что клиент его не присылал.
Важно почувствовать: отсутствие аргументов — это не «я забыл параметры», а отражение смысла endpoint’а. У списка задач есть контракт: «дай мне список». И раз запрос не содержит идентификатора конкретного ресурса, сигнатура тоже его не требует. А вот что вернуть — это уже наша ответственность как API.
Минимальный пример endpoint’а списка (в учебном виде, пока без сервисов и без настоящих моделей):
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController // Говорим Spring, что это REST-контроллер (ответ пойдёт в тело HTTP-ответа)
class DemoController {
@GetMapping("/api/v1/tasks") // Маппинг для запроса коллекции
public List<String> listTaskIds() {
// Для примера возвращаем «игрушечные» id задач
return List.of("t1", "t2");
}
}
Здесь важно не содержимое списка (оно пока игрушечное), а сам факт: метод не принимает ничего, потому что запрос не несёт входных данных в пути. Клиент попросил «коллекцию», и мы на этом уровне отвечаем «коллекцией». Да, позже у нас будут нормальные структуры, но принцип не изменится.
3. @PathVariable: taskId из URL
Когда мы переходим от коллекции к конкретному ресурсу, появляется ключевая идея REST-мышления: адресация. Конкретная задача живёт по адресу /api/v1/tasks/{taskId}, и клиент буквально говорит: «дай мне вот эту штуку по этому адресу». Это как квартира по номеру: подъезд/этаж/квартира — не фильтры и не пожелания, а точный адрес.
В Spring MVC «номер квартиры» кладётся в аргумент метода через @PathVariable. В URL вы пишете плейсхолдер в фигурных скобках, а в методе — параметр с аннотацией. И Spring делает магию… точнее, делает очень конкретную работу: берёт значение из URL и подставляет его в аргумент.
Ментальная картинка может быть такой:
flowchart LR
A["GET /api/v1/tasks/abc-123"] --> B["mapping: /api/v1/tasks/{taskId}"]
B --> C["@PathVariable taskId = 'abc-123'"]
C --> D["вызов метода контроллера"]
Минимальный пример detail endpoint’а:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}") // В URL-шаблоне есть плейсхолдер {taskId}
public String getTask(@PathVariable String taskId) { // Spring возьмёт значение из URL и подставит сюда
// Для наглядности возвращаем то, что пришло в path variable
return "taskId=" + taskId;
}
Здесь очень полезно остановиться и проверить себя: taskId — это не «какая-то переменная в коде», а часть URL. Клиент прислал её в пути, Spring достал её из пути, и теперь вы в методе получаете готовое значение. Вы не парсите строку руками, не делаете substring, не пишете split("/"), и от этого мир становится чуточку добрее.
Имена: когда нужен @PathVariable("taskId")
Самая частая мелкая, но раздражающая проблема с @PathVariable — несостыковка имён. В URL-шаблоне вы написали {taskId}, а в параметре метода — String id. Для человека это «да какая разница, я же понимаю!», а для Spring это иногда выглядит как «у меня есть переменная taskId, а ты просишь id — ты кого вообще имеешь в виду?».
Правило, которое помогает жить спокойно: если имя параметра метода совпадает с именем в фигурных скобках, можно писать коротко. Если не совпадает — лучше указать имя явно в @PathVariable("..."). Это не «бюрократия», а просто явность контракта. Особенно полезно, когда вы переименовали параметр в IDE и забыли, что URL-то остался прежним.
Пример «совпали имена — всё просто»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}") // {taskId} в шаблоне совпадает с именем параметра
public String getTask(@PathVariable String taskId) { // Поэтому имя можно не уточнять явно
// Возвращаем строку, чтобы увидеть значение в ответе
return "taskId=" + taskId;
}
Пример «имена разные — пишем явно»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}") // Во внешнем контракте (URL) всё ещё taskId
public String getTask(@PathVariable("taskId") String id) { // Явно связываем {taskId} с параметром id
// Внутри метода используем удобное нам имя переменной
return "taskId=" + id;
}
Обратите внимание на тонкую пользу: метод теперь можно читать как предложение. «У нас есть endpoint /tasks/{taskId}; вытащи taskId и положи его в переменную id». То есть внешний контракт (слово taskId) остаётся стабильным, а внутреннее имя параметра вы можете выбирать хоть id, хоть taskIdentifier, хоть pleaseDontNameMeX (но последнее не надо, вы не злодей).
4. Возвращаемое значение и ответ
Возвращаемый тип метода контроллера — это тоже часть контракта. Не в смысле «клиент видит Java-тип» (нет), а в смысле того, какую форму данных вы отправляете наружу. В @RestController возвращаемое значение становится телом HTTP-ответа. Это означает простую вещь: выбирая String или List<...> или какой-то объект, вы фактически выбираете, как будет выглядеть ответ.
На сегодняшнем уровне достаточно понимать два тезиса. Первый: если метод успешно отработал и вернул значение, обычно клиент получит 200 OK и какое-то тело. Второй: форма тела ответа напрямую связана с тем, что вы вернули из метода. Поэтому к возвращаемому типу стоит относиться не как к «ну пусть будет Object», а как к публичному решению.
Пара мини-примеров поможет почувствовать разницу.
Если вернуть String, вы отдаёте простой текст:
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/v1/ping") // Простой health-check endpoint
public String ping() {
// Возвращаем обычный текст (потом он станет телом HTTP-ответа)
return "pong";
}
Если вернуть Map, вы отдаёте структурированные данные (обычно клиент увидит JSON-объект):
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/v1/demo") // Endpoint, который возвращает «объект»
public Map<String, Object> demo() {
// Map удобно использовать как быстрый учебный «DTO»
return Map.of(
"status", "OK",
"count", 2
);
}
Если вернуть List<String>, вы отдаёте коллекцию (обычно это будет массив значений):
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
@GetMapping("/api/v1/demo-list") // Endpoint, который возвращает «массив»
public List<String> demoList() {
// Список в ответе обычно сериализуется как JSON-массив
return List.of("a", "b", "c");
}
Заметьте, мы сейчас намеренно не обсуждаем, «как именно оно превращается в JSON» и какие там настройки. Нам пока важно другое: возвращаемый тип — это не деталь реализации, а решение о форме ответа. И даже если вы сейчас отдаёте простые строки «для разогрева», сама привычка думать о форме ответа пригодится вам дальше на каждом шаге.
5. Мини-сборка: tasks через сигнатуры
Чтобы картина была цельной, давайте соберём маленький DemoTaskController. Это всё ещё учебная мини‑сборка: она нужна только для того, чтобы увидеть контраст между endpoint’ом коллекции и endpoint’ом конкретного ресурса.
Ниже пример контроллера, где данные пока «условные», зато сигнатуры очень честно отражают смысл API. Такой код удобно держать как промежуточный скелет: читается за 15 секунд, и вы сразу понимаете, что у API есть endpoint списка и endpoint детали. А если вы понимаете это по коду, то и клиенту будет проще понять это по URI.
import java.util.List;
import org.springframework.web.bind.annotation.*;
@RestController // REST-контроллер: то, что вернём из методов, уйдёт в HTTP-ответ
@RequestMapping("/api/v1/tasks") // Базовый путь для всех endpoint'ов в этом контроллере
class DemoTaskController {
@GetMapping // GET /api/v1/tasks — коллекция
public List<String> getAllTasks() {
// Коллекция: входных данных нет, поэтому сигнатура без аргументов
return List.of("t1", "t2");
}
@GetMapping("/{taskId}") // GET /api/v1/tasks/{taskId} — конкретный ресурс
public String getTaskById(@PathVariable String taskId) {
// Detail: чтобы адресовать ресурс, нужен taskId из URL
return "taskId=" + taskId;
}
}
Обратите внимание, насколько «говорящими» получаются две сигнатуры. Первая: ничего не надо на вход, потому что это коллекция. Вторая: нужен taskId, потому что это отдельный ресурс. И это именно то REST-мышление, которое мы зафиксировали на прошлых днях: коллекция и отдельный ресурс — разные точки API.
6. Типичные ошибки при работе с @PathVariable
В @PathVariable почти нет «сложной магии», но есть маленькие грабли, на которые легко наступить, особенно когда вы только начинаете. Ирония в том, что ошибки выглядят как пустяк (буква не совпала, скобку забыли), а результат — как будто «Spring сломался» и «ничего не работает». На самом деле всё работает — просто контракт стал противоречивым, и фреймворк честно отказывается угадывать ваши намерения.
Ошибка №1: плейсхолдер в пути и параметр метода называются по-разному, но имя не указано явно.
Вы пишете mapping "/{taskId}", а в методе параметр называется id и помечен просто @PathVariable. В зависимости от условий это может привести к тому, что Spring не сможет понять, какое значение подставлять. Привычка, которая спасает: либо называйте параметр так же (taskId), либо всегда пишите @PathVariable("taskId") String id.
Ошибка №2: забыли фигурные скобки или случайно «съели» слэш.
Иногда пишут @GetMapping("/taskId") вместо @GetMapping("/{taskId}"). Для человека это «почти одно и то же», но для маршрутизации это два разных мира. В первом случае URL должен буквально быть /taskId, а во втором — там может быть любое значение. Если вдруг endpoint не вызывается, первое, что нужно проверить, — правильность шаблона в mapping.
Ошибка №3: пытаются сделать detail endpoint без @PathVariable, но с «хардкодом» внутри.
Классика учебных примеров: метод называется getTaskById, но внутри он всегда возвращает одну и ту же задачу, потому что taskId нигде не принимается. Это ломает смысл API: клиент адресует конкретный ресурс, а сервер делает вид, что не заметил адрес. Правильный минимализм — это принять taskId аргументом, даже если пока вы только печатаете его в ответ.
Ошибка №4: возвращаемый тип выбирается «на глаз», а не по смыслу ответа.
Новичок часто думает: «Ну я верну Object, а там разберёмся». В @RestController это плохая привычка: вы теряете ясность контракта, и метод становится нечитаемым. Гораздо полезнее выбрать конкретный тип: String для простого текста, List<String> для списка, Map<String, Object> для простого структурированного ответа. Даже в учебных примерах это тренирует дисциплину формы ответа.
Ошибка №5: сигнатура перегружается «на всякий случай».
Иногда в метод начинают добавлять параметры, которые прямо сейчас не нужны: «вдруг пригодится». В итоге метод становится похож на рюкзак туриста-новичка, который тащит и палатку, и гитару, и кастрюлю, и утюг. На текущем этапе держим сигнатуры короткими: для коллекции — без аргументов, для одного ресурса — один @PathVariable. Это делает и код, и контракт очевидными.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ