JavaRush /Курсы /Spring REST & MVC /@PathVariable: id в п...

@PathVariable: id в пути

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

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 (пока что).

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