1. Адрес ресурса в URL
Когда клиент приходит в ваш API, он почти всегда начинает с очень простого вопроса: «Мне нужен вот этот объект… как мне его назвать?». В REST-мире «назвать» означает адресовать ресурс. И адресуем мы его не в теле запроса и не в заголовках, а прямо в URI, в его пути. Это делает контракт читаемым даже без документации: по одному URL можно понять, что происходит.
Самый простой пример в нашем домене: GET /api/v1/tasks означает «дай коллекцию задач», а GET /api/v1/tasks/{taskId} означает «дай конкретную задачу». Вторая форма отличается не магией Spring, а смыслом для клиента: он уже знает, какую именно задачу хочет получить. И вот тут появляется главная мысль лекции: идентификатор живёт в пути, потому что он отвечает на вопрос «какой именно ресурс адресуем».
Чтобы это ощущалось не как теория из “REST-книги, которую никто не дочитал”, зафиксируем мини-карту:
flowchart TD
A["/api/v1/tasks"] --> B["Коллекция задач (list)"]
C["/api/v1/tasks/{taskId}"] --> D["Одна задача (detail)"]
E["/api/v1/tasks/{taskId}/comments"] --> F["Комментарии конкретной задачи"]
G["/api/v1/tasks/{taskId}/attachments"] --> H["Вложения конкретной задачи"]
Обратите внимание: везде, где фигурирует {taskId}, мы не «параметризуем поиск», а уточняем адрес. Это очень похоже на квартиру в доме: tasks — это дом, {taskId} — конкретная квартира. А вот «покажи квартиры с видом на парк» — это уже другая история, и ей не место в номере квартиры.
2. @PathVariable в Spring MVC
Если смотреть на Spring MVC без мистики, он делает довольно прямолинейную вещь: вы в @GetMapping пишете шаблон пути с плейсхолдером {...}, а @PathVariable говорит: «возьми то, что стоит на месте этого плейсхолдера, и подставь в аргумент метода». Никакой телепатии, только сопоставление строки из URL и параметра Java-метода.
Это механизм аргумент-резолвинга: до входа в ваш метод Spring уже знает, какой метод вызвать, и теперь ему надо подготовить аргументы. В случае @PathVariable аргумент берётся из path segment.
Начнём с самого «канонического» вида — переменная называется так же, как и плейсхолдер:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public void getTask(@PathVariable String taskId) {
// taskId берётся прямо из URL: /api/v1/tasks/{taskId}
// Контроллер не "думает", а просто делегирует дальше.
taskService.getById(taskId);
}
Здесь важно два момента. Во-первых, имя taskId в шаблоне пути и имя параметра taskId в методе совпадают. Во-вторых, контроллер ничего не «понимает» про задачу, он просто принимает идентификатор и отправляет его дальше в сервис. Именно так мы удерживаем контроллер тонким.
Иногда хочется назвать параметр в методе иначе (например, короче). Тогда имя переменной пути задают явно:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public void getTask(@PathVariable("taskId") String id) {
// Явно говорим Spring: плейсхолдер в пути называется "taskId",
// а в коде мы хотим использовать переменную id.
taskService.getById(id);
}
Это полезно не для «красоты», а когда вы хотите в коде использовать другое имя, но не хотите менять внешний контракт URL. Контракт — штука более долговечная, чем ваша текущая любовь к коротким переменным.
3. Id ресурсов в Task Tracker API
В нашем проекте Task Tracker API идентификаторы — это UUID в виде строки. Почему так? Потому что UUID удобно генерировать на сервере, его сложно «угадать», и он отлично подходит как стабильный ключ. Но в сегодняшней лекции важнее не тип UUID, а сама идея: id — это адрес ресурса.
У нас есть несколько естественных ресурсов и подресурсов, и у каждого свой идентификатор. Самые «правильные» кандидаты для path variables в канонической карте проекта: taskId, commentId, attachmentId. Всё остальное (фильтры, поиск, пагинация) мы сознательно не смешиваем с адресацией.
Покажем это на примере detail endpoint’а задач, который у вас уже появился на Дне 5:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
@GetMapping("/api/v1/tasks/{taskId}")
public Task getTask(@PathVariable String taskId) {
// taskId — это адресуемый ресурс, а не "настройка выдачи"
// Дальше — сразу в сервисный слой.
return taskService.getById(taskId);
}
}
Здесь taskId — не «параметр запроса», не «настройка выдачи», а прямой ответ на вопрос «какую именно задачу ты хочешь?».
И точно так же будут устроены подресурсы. Например, когда мы будем удалять конкретный комментарий конкретной задачи, внешний контракт должен явно говорить «комментарий внутри задачи»:
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/api/v1/tasks/{taskId}/comments/{commentId}")
public void deleteComment(@PathVariable String taskId,
@PathVariable String commentId) {
// Оба значения приходят из URL:
// /api/v1/tasks/{taskId}/comments/{commentId}
// Контроллер лишь передаёт контекст и id подресурса в сервис.
commentService.delete(taskId, commentId);
}
Заметьте, в сигнатуре метода видно ровно то, что видит клиент в URL: чтобы удалить комментарий, нам нужен и taskId, и commentId. Это делает контракт прозрачным и защищает от «удали комментарий вообще где-нибудь» — без контекста родителя.
Вложенные ресурсы: два id
Вложенные ресурсы — это место, где у новичков чаще всего начинается лёгкая паника: «А почему тут два id? А кто из них главный? А можно одним обойтись?». Паника нормальная, но лечится одной идеей: второй id появляется только тогда, когда у вас есть подресурс, существование которого имеет смысл в контексте родителя.
В нашем домене комментарий — это не самостоятельная вселенная. Комментарий привязан к задаче. Поэтому URI вида /tasks/{taskId}/comments/{commentId} читается как «комментарий commentId, принадлежащий задаче taskId».
Очень удобно увидеть это прямо как «адрес в иерархии»:
flowchart LR
A["tasks"] --> B["{taskId}"]
B --> C["comments"]
C --> D["{commentId}"]
Теперь важный практический нюанс: наличие двух @PathVariable не означает, что вы обязаны делать двойную бизнес-логику в контроллере. Контроллер по-прежнему не «решает», он передаёт. Сервис уже будет проверять (когда мы дойдём до нормального error handling), существует ли задача, существует ли комментарий и принадлежит ли он этой задаче.
Аналогично для вложений (attachments). Даже если мы пока не реализуем их прямо сейчас, сам URI и @PathVariable показывают, что attachment — подресурс:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}/attachments/{attachmentId}")
public void getAttachmentMetadata(@PathVariable String taskId,
@PathVariable String attachmentId) {
// attachmentId имеет смысл только в контексте taskId,
// поэтому оба id являются частью адреса (path), а не query-параметрами.
attachmentService.getMetadata(taskId, attachmentId);
}
Почему это хорошо? Потому что через год, когда вы добавите (условно) права доступа или хранение в БД, у вас не «поплывёт» контракт. Он уже отражает реальные связи домена.
4. Имена переменных в пути
Пара неприятных истин из жизни: читать чужой код сложнее, чем писать свой, а читать чужие URI без нормальных имён — ещё сложнее. В @PathVariable имя — это не просто «переменная в Java», это часть публичного контракта. Да, клиент обычно подставляет туда значение и не думает об имени, но для документации, для дебага, для логов и для команды — имя имеет значение.
Поэтому мы сознательно используем понятные имена: taskId, commentId, attachmentId. Они чуть длиннее, зато в голове не происходит «id чего?». И это особенно важно, когда id два.
Вот хороший пример — всё явно:
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/api/v1/tasks/{taskId}/comments/{commentId}")
public void deleteComment(@PathVariable String taskId,
@PathVariable String commentId) {
// По именам сразу понятно, что за что отвечает (особенно в nested endpoints).
commentService.delete(taskId, commentId);
}
А теперь антипример, который выглядит как «я пытался сэкономить два символа, но потерял два часа жизни на дебаг»:
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
@DeleteMapping("/api/v1/tasks/{id}/comments/{id}")
public void deleteComment(@PathVariable String id) {
// Так нельзя: в одном пути два разных значения, а параметр вообще один.
// Какой id сюда приехал? И сколько их было? Вопросы без ответов.
}
Даже если компилятор и Spring вас остановят раньше (и хорошо сделают), сама идея плохая: одинаковые имена скрывают смысл. В REST-контракте смысл важнее экономии.
Ещё один момент — соответствие имён в шаблоне пути и в @PathVariable. Если вы не указали имя явно, Spring берёт его из названия параметра. Поэтому такой код читается и работает предсказуемо:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public void getTask(@PathVariable String taskId) {
// Имя в плейсхолдере и имя параметра совпадают — всё прозрачно.
taskService.getById(taskId);
}
А вот такой код — популярный «почему оно не вызывается?!» в стиле начинающего разработчика:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/api/v1/tasks/{taskId}")
public void getTask(@PathVariable String id) {
// Spring не обязан угадывать, что id = taskId, если имя не указано явно.
taskService.getById(id);
}
Технически это можно починить, просто указав имя переменной пути в аннотации. Но методически важнее понять правило: либо называем параметр так же (taskId), либо прописываем @PathVariable("taskId").
5. Граница между id и фильтром
Самая частая ошибка в REST URI — начать запихивать в path то, что не является адресацией конкретного ресурса. Обычно это выглядит так: «мне нужен список задач со статусом TODO… значит сделаю /tasks/status/TODO». И вроде бы даже работает. Но контракт становится хрупким и странным: вы начали кодировать фильтрацию как будто это часть адреса ресурса, а не уточнение запроса.
Граница простая: если значение отвечает на вопрос «какой именно объект (или подресурс)», это путь. Если значение отвечает на вопрос «каким образом выбрать/отфильтровать/отсортировать коллекцию», это не путь.
Небольшая таблица, чтобы мозг не пытался каждый раз заново изобретать REST:
| Что хочет клиент | Как это читается | Где место значению |
|---|---|---|
| «Дай задачу с id X» | адресую один ресурс | path (/tasks/{taskId}) |
| «Дай комментарий Y у задачи X» | адресую подресурс в контексте родителя | path (/tasks/{taskId}/comments/{commentId}) |
| «Дай задачи со статусом TODO» | выбираю элементы коллекции по условию | не в path |
Именно поэтому такие URI мы считаем антипаттернами для нашего проекта (даже если технически вы сможете их реализовать):
GET /api/v1/tasks/status/TODO
GET /api/v1/tasks/assignee/Alice
GET /api/v1/tasks/dueBefore/2026-12-31
Почему антипаттерн? Потому что вы смешали «адрес дома» и «условия поиска квартир». Сегодня это кажется удобным, а завтра у вас появится ещё пять фильтров, и вы внезапно проектируете не API, а лабиринт из путей, где клиенту нужно угадывать порядок сегментов.
А вот detail endpoint, наоборот, должен быть максимально «скучным», потому что скука — это предсказуемость:
GET /api/v1/tasks/{taskId}
DELETE /api/v1/tasks/{taskId}
Запомните правило дня в одной фразе: если без значения сценарий теряет смысл «какой объект», это path; если сценарий остаётся «список», а значение лишь уточняет список — это не path.
6. Читабельный TaskController
Иногда проблема с @PathVariable не в том, что вы её неправильно написали, а в том, что вы постепенно превратили контроллер в сборник строк "/api/v1/...", и любой рефакторинг становится «операцией на сердце без наркоза». Поэтому полезно закрепить ещё один аккуратный приём: базовый путь у контроллера фиксируем на уровне класса, а {taskId} добавляем на уровне метода.
Это не новая магия — это просто способ сделать контракт компактнее в коде и не повторять одно и то же десять раз.
Например, вместо:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class TaskController {
@GetMapping("/api/v1/tasks/{taskId}")
public Task getTask(String taskId) {
// Здесь "потерялась" @PathVariable: параметр не будет биндинться из пути автоматически.
// Это ровно тот случай, который потом превращается в "почему метод не вызывается?!"
return taskService.getById(taskId);
}
}
(тут, кстати, ещё и аннотация потерялась — такое тоже бывает), лучше так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/tasks") // Базовый путь фиксируем один раз на уровне контроллера
public class TaskController {
@GetMapping("/{taskId}") // А конкретный ресурс адресуем уже на уровне метода
public Task getTask(@PathVariable String taskId) {
// taskId извлекается из сегмента пути, дальше — в сервис.
return taskService.getById(taskId);
}
}
Здесь сразу видно, что контроллер отвечает за ресурс tasks, а переменная {taskId} — это адресация одного элемента коллекции. И когда вы добавите новые методы (например, delete, put, patch позже по курсу), вы не будете копировать базовый путь.
Ещё один маленький штрих, который повышает читаемость: не делать «умных» проверок id в контроллере. Например, такой код выглядит соблазнительно, но методически вреден:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@GetMapping("/{taskId}")
public Task getTask(@PathVariable String taskId) {
// Кажется логичным, но такие проверки быстро расползаются по всем контроллерам.
if (taskId.isBlank()) {
throw new IllegalArgumentException("taskId is blank");
}
// Контроллер должен оставаться тонким: приняли id → делегировали дальше.
return taskService.getById(taskId);
}
Почему вреден? Потому что у вас появится десять таких проверок в десяти контроллерах, и вы начнёте дублировать одно и то же. Сегодня мы держим контроллер тонким: принял taskId, передал дальше. А правила проверки входа и формат ошибок мы будем выстраивать последовательно, но не здесь и не сейчас.
7. Типичные ошибки при работе с @PathVariable
Ошибки с @PathVariable часто выглядят как мелочи, но они очень быстро превращаются в «у меня не вызывается метод контроллера, Spring сломан, жизнь тоже». В большинстве случаев Spring как раз не сломан — он просто строго следует контракту, который вы сами написали. Поэтому полезно заранее привыкнуть смотреть на @PathVariable глазами клиента: что он видит в URL и что вы обещаете в сигнатуре метода.
Ошибка №1: фильтры в path вместо id.
Это тот самый случай /tasks/status/TODO, который кажется удобным, пока у вас один фильтр. Потом появляются priority, assignee, q, dueBefore, dueAfter, и вы либо строите URI-лабиринт, либо переписываете всё обратно в нормальный list-endpoint. Если значение не адресует конкретный объект, а уточняет выборку — это не кандидат в @PathVariable.
Ошибка №2: «глагольные» пути и командный стиль.
Иногда @PathVariable начинают использовать как часть «команды»: /tasks/{taskId}/complete или /tasks/{taskId}/changeStatus/DONE. Это уже не адресация ресурса, а попытка спрятать действие в URI. В нашем проекте мы держим URI ресурсными: если есть операция — она выражается через понятный ресурсный контракт, а не через коллекцию глаголов в пути.
Ошибка №3: несоответствие имён переменных в шаблоне и в методе.
@GetMapping("/ {taskId}") и параметр @PathVariable String id без указания имени — классика. Spring не обязан угадывать, что вы имели в виду. Лечится либо одинаковыми именами (taskId), либо явным @PathVariable("taskId"). Хорошая привычка: если вы видите в коде {taskId}, вы должны видеть taskId и в сигнатуре.
Ошибка №4: попытка «валидировать» и «парсить» id прямо в контроллере.
Контроллер превращается в мини-центр обработки всего подряд: тут и проверка пустоты, и UUID.fromString, и попытки вернуть «человеческую ошибку». В результате контроллер пухнет, логика дублируется, и вы теряете единообразие. На текущем этапе достаточно принять id и делегировать дальше, сохраняя тонкий слой.
Ошибка №5: слишком общие имена вроде {id} везде.
Технически это работает, но логически читается плохо, особенно в nested endpoints. {id} и {id} в одном пути — это вообще путь к недопониманию (и, скорее всего, к ошибке конфигурации). В нашем API имена должны помогать: taskId, commentId, attachmentId. Да, длиннее, но вы же не платите за символы в URI (пока что).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ