JavaRush /Курсы /Spring REST & MVC /Бизнес-действия без командных URI

Бизнес-действия без командных URI

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

1. Командные URI мешают жить

Командные URI быстро превращают API в каталог команд. Когда вы впервые делаете API, в голове обычно не ресурсы, а задачи из таск-трекера (простите за каламбур). В требованиях написано: “Нужно завершить задачу”, “Нужно прикрепить файл”, “Нужно поменять статус”. И рука сама тянется сделать что-нибудь вроде POST /api/v1/completeTask. Кажется логично: есть действие — есть адрес для действия.

Проблема в том, что такой путь действительно превращает API в каталог команд. Клиенту приходится запоминать десятки “глагольных” адресов: /api/v1/completeTask, /api/v1/changeStatus, /api/v1/uploadFileToTask, /api/v1/renameTask, /api/v1/assignUser… И каждый новый сценарий почти автоматически рождает новый эндпоинт. В итоге API начинает напоминать не карту города, а список заклинаний из фэнтези-игры: “произнеси /archiveTask — и всё случится”.

Ещё хуже, что командные URI ломают идею предсказуемости. В ресурсном API вы можете почти угадать, как будет выглядеть адрес: если есть задача, значит есть /tasks/{taskId}. Если у задачи есть комментарии, значит есть /tasks/{taskId}/comments. А вот командный стиль угадать не даёт: вы пишете completeTask, ваш коллега пишет finish, третий — markDone, четвёртый — close. Клиенту остаётся только страдать и читать документацию как роман на 800 страниц.

Самое неприятное — командный стиль обычно приводит к смешению смысла HTTP-методов. Если вы сделали GET /api/v1/completeTask?taskId=..., вы уже нарушили “безопасность” GET (safe method): один невинный автоповтор запроса, один кеш на прокси — и внезапно “завершение задач” происходит само по себе. Да, звучит как баг из параллельной вселенной, но такие баги существуют ровно потому, что URI начали выражать действие вместо адреса ресурса.

2. URI — существительное, метод — глагол

Если вы запомните из этой лекции одну фразу, пусть это будет она: URI — это существительное, метод — это глагол. URI должен отвечать на вопрос “с чем мы работаем?” (ресурс), а HTTP-метод — на вопрос “что мы делаем с этим ресурсом?”.

Представьте обычный адрес дома. Адрес не меняется от того, что вы хотите сделать: посмотреть дом, купить дом, сделать ремонт или снести его (не делайте так без разрешения, это плохая практика). Адрес — это “где”, действие — это “что”. В REST API то же самое: /api/v1/tasks/{taskId} — это “где находится задача”, а GET/PUT/PATCH/DELETE — это то, “что вы хотите с ней сделать”.

Отсюда вытекает очень практичный критерий: если в URI появился глагол, спросите себя, не пытаетесь ли вы вложить в путь то, что уже должен выражать HTTP-метод. Звучит как рекурсия ради рекурсии, но на практике это работает удивительно хорошо.

Например, у нас есть две записи:

# Плохо: URI описывает действие (командный стиль)
POST /api/v1/completeTask

# Хорошо: URI описывает ресурс, а действие выражено HTTP-методом
PATCH /api/v1/tasks/{taskId}

Во втором варианте URI стабилен: он про ресурс task. А действие (частичное изменение) выражено методом PATCH. Именно поэтому во многих ресурсных API “завершить задачу” — это не отдельная команда, а частичное обновление состояния задачи.

И ещё один важный кусочек: тело запроса (request body) — это место, где вы описываете, какое именно изменение вы хотите. Не путь должен рассказывать “заверши задачу”, а тело должно говорить “статус теперь DONE” (или другое нужное состояние). Путь остаётся адресом.

3. Действие = изменение состояния

Большинство бизнес-действий в ресурсном API — это просто изменение состояния. Когда бизнес говорит “заверши задачу”, он описывает действие человеческими словами. А ресурсный API переводит это на язык состояния: у задачи есть поле status, значит “завершить” — это “перевести status в значение DONE”. Мы сейчас не обсуждаем детали правил переходов и конфликтов, мы только учимся видеть правильную форму URI.

С точки зрения URI у нас всё просто: если мы меняем задачу, мы работаем с /api/v1/tasks/{taskId}. Не с /completeTask, не с /tasks/{taskId}/complete, не с /doComplete, а именно с адресом задачи. Дальше уже подбираем метод: чаще всего для “изменить один-два атрибута” используют PATCH, а для “заменить всё изменяемое состояние” — PUT. Здесь важна сама мысль: URI не должен распухать от бизнес-действий.

Вот как это может выглядеть в виде простого .http-сценария (пока как концепт, без обсуждения статусов ответа и схем DTO — это будет позже):

# Частичное обновление состояния конкретной задачи
PATCH /api/v1/tasks/2b1f8d3a-7c0e-4e21-9a7a-0d3d0c9a7f11
Content-Type: application/json

{
  # Команда спрятана в данных: меняем статус ресурса task
  "status": "DONE"
}

Здесь “команда” спрятана не в URI, а в данных: мы говорим, что у ресурса task меняется поле status. Если потом появится действие “заблокировать задачу”, URI не меняется — меняется значение status. Это резко снижает вероятность того, что API разрастётся в тысячу ручек.

Аналогично можно думать и про другие бизнес-операции, которые на самом деле являются изменением состояния: назначение ответственного (assigneeName), добавление/удаление тега (в нашем проекте это часть задачи), изменение dueDate. Если вы каждый раз делаете отдельную команду, вы строите RPC. Если вы работаете с состоянием ресурса, вы остаётесь в ресурсной модели.

Небольшой технический трюк, который помогает мозгу: попробуйте прочитать URI вслух как существительное. /api/v1/tasks/{taskId} — “задача с таким-то id” звучит нормально. А /api/v1/completeTask — это уже “выполни задачу”, то есть чистая команда. Когда вы слышите команду, вы почти всегда чувствуете, что это не адрес.

4. Подресурсы для комментариев и файлов

Если после операции появляется новый объект, это обычно не команда, а создание ресурса. Самые понятные примеры в нашем домене — комментарии и вложения. Когда вы “добавляете комментарий”, вы не просто меняете поле у задачи — вы создаёте новый комментарий, у которого есть собственный commentId, автор, текст и время создания.

Ресурсный стиль здесь буквально просится: комментарии — это подресурс задачи. Значит, мы работаем с коллекцией комментариев конкретной задачи: /api/v1/tasks/{taskId}/comments. И действие “добавить комментарий” становится обычным созданием элемента коллекции через POST.

С вложениями история похожая: “прикрепить файл” на самом деле означает “создать новый ресурс вложения, связанный с этой задачей”. Поэтому вместо POST /api/v1/uploadFileToTask мы предпочитаем POST /api/v1/tasks/{taskId}/attachments. URI остаётся существительным, а действие выражается методом и форматом запроса (multipart будет позже, сейчас это просто идея).

Чтобы не путаться, полезно держать в голове маленькое правило: если после операции появляется новый идентификатор (например, commentId или attachmentId), то почти наверняка вы создаёте новый ресурс, а не “выполняете команду”.

Практически это означает две формы адреса:

  • POST /api/v1/tasks/{taskId}/comments
  • POST /api/v1/tasks/{taskId}/attachments

Этого уже достаточно, чтобы не скатиться к addCommentToTask и uploadFileToTask.

5. Суффикс действия как редкое исключение

Суффикс действия допустим редко — и только когда он действительно уточняет представление ресурса. Иногда вы всё делаете правильно, но сталкиваетесь с неприятной реальностью: один и тот же объект хочется получить в разных формах. Вложения — классический случай. У вложения есть метаданные (имя файла, размер, тип содержимого, описание, дата загрузки) — их удобно отдавать JSON-ом. Но есть и бинарное содержимое файла: в JSON это отдавать странно, и клиенту проще просто скачать поток байтов.

Можно было бы попытаться сделать один endpoint и переключать формат через заголовок Accept, но для учебного проекта это слишком рано и слишком легко превращается в “магический контракт”. Поэтому мы выбираем честную и ясную модель: один URI для metadata, другой — для содержимого.

И вот здесь как редкое исключение появляется суффикс действия: .../download. Он не превращает API в RPC-каталог, потому что остаётся “привязанным” к конкретному ресурсу вложения и живёт в самом конце пути.

Сравните два подхода:

# Плохо: верхнеуровневая команда и query вместо адреса ресурса
GET /downloadAttachment?attachmentId=...

# Хорошо: нормальная адресация ресурса + узкое уточнение представления данных
GET /api/v1/tasks/{taskId}/attachments/{attachmentId}/download

Во втором варианте вы всё ещё адресуетесь к attachment через его нормальный ресурсный путь, а /download всего лишь уточняет, что вы хотите не metadata, а content. Это очень важно: исключение не ломает стиль, потому что не становится самостоятельным “верхнеуровневым действием”.

Критерий, который помогает не злоупотреблять такими суффиксами, звучит так: если вы добавили /download, спросите себя, это “бизнес-команда” или “альтернативное представление данных”? Для attachments это второе. Для /complete, /archive, /changeStatus — это первое, и это уже тревожный звоночек.

Если через месяц вы обнаружите в проекте пути вроде /start, /stop, /approve, /reject, /recalculate — поздравляю, вы незаметно построили RPC. /download хорош именно тем, что остаётся редким и объяснимым.

6. Мини-алгоритм и таблица URI

Чтобы не спорить о каждом новом URI, полезно держать короткий алгоритм выбора. Когда команда обсуждает новый путь, спор обычно выглядит так: один человек хочет “как удобнее сейчас”, другой — “как правильно по REST”, третий просто хочет домой. Чтобы не превращать каждое такое обсуждение в вечный сериал, полезно прогонять в голове короткий алгоритм до того, как вы напишете новый endpoint.

Ниже — упрощённая схема, которая подходит для нашего учебного проекта. Она не претендует на RFC-идеальность, но практична и помогает держать стиль API ровным.

flowchart TD
    A["Нужно добавить новый endpoint"] --> B{"Мы работаем с ресурсом?"}
    B -->|Нет| Z["Скорее всего, модель ресурсов выбрана плохо. Сначала найдите ресурс."]
    B -->|Да| C{"Результат операции — новый объект с новым id?"}
    C -->|Да| D["Сделайте subresource коллекцию и используйте POST пример: /api/v1/tasks/{taskId}/comments"]
    C -->|Нет| E{"Это изменение состояния существующего ресурса?"}
    E -->|Да| F["Остаёмся на URI ресурса пример: /api/v1/tasks/{taskId}, а действие выражаем методом и телом"]
    E -->|Нет| G{"Это другое представление того же ресурса (metadata vs content)?"}
    G -->|Да| H["Допустим action-suffix как узкое исключение пример: .../download"]
    G -->|Нет| Y["Остановитесь и перепроверьте. Возможно, вам нужен другой ресурс или вы смешали разные ответственности."]

А теперь — таблица “в лоб”. Она часто помогает быстрее, чем длинные объяснения.

Бизнес-смысл Плохой (командный) вариант Хороший (ресурсный) вариант Почему хороший
Завершить задачу POST /api/v1/completeTask
PATCH /api/v1/tasks/{taskId}

с status=DONE

Адресуем задачу, меняем её состояние, не плодим команды
Поменять статус POST /api/v1/changeStatus
PATCH /api/v1/tasks/{taskId}

с status=...

Статус — часть состояния ресурса
Добавить комментарий POST /api/v1/addCommentToTask POST /api/v1/tasks/{taskId}/comments Комментарий — новый ресурс (подресурс)
Прикрепить файл POST /api/v1/uploadFileToTask POST /api/v1/tasks/{taskId}/attachments Вложение — новый ресурс (подресурс)
Скачать файл GET /api/v1/downloadAttachment GET /api/v1/tasks/{taskId}/ attachments/{attachmentId}/download Узкое исключение ради другого представления данных (бинарное содержимое vs JSON)

Обратите внимание: в хороших вариантах почти везде остаются одни и те же существительные: tasks, comments, attachments. Это и есть главный маркер “здорового” API: вы не сочиняете каждый раз новый язык команд, а просто комбинируете ресурсы и методы.

7. Типичные ошибки при проектировании URI

Эта тема кажется простой, пока вы не сделаете третий-четвёртый “удобный” командный endpoint, а потом не попытаетесь объяснить API новичку (или самому себе через два месяца). Ошибки здесь обычно не синтаксические, а архитектурные: всё компилируется, всё “работает”, но контракт становится всё менее предсказуемым. Ниже — самые частые грабли, на которые наступают даже хорошие разработчики.

Ошибка №1: делать отдельный endpoint на каждое действие, потому что так “быстрее”.
Когда вы добавляете /completeTask, вам действительно быстрее: один новый путь, один метод на сервере. Но потом появляются /archiveTask, /unarchiveTask, /assignTask, /unassignTask, /blockTask, /unblockTask, и API превращается в список заклинаний. В ресурсной модели многие из этих действий — всего лишь изменения состояния в одном и том же ресурсе.

Ошибка №2: прятать команды в query-параметры.
Иногда разработчик думает: “Ну ладно, глагол в пути — плохо. Сделаю так: POST /api/v1/tasks/{taskId}?action=complete”. К сожалению, это тот же RPC, только в плаще-невидимке. URI всё равно перестаёт быть адресом ресурса и превращается в команду. В хорошем контракте query обычно отвечает за фильтрацию/настройку чтения, а не за выполнение операций над состоянием.

Ошибка №3: использовать GET для операций с побочными эффектами.
Кажется удобным: “открыл в браузере — и задача завершилась”. Потом кто-то случайно поставил ссылку в чат, бот предпросмотрел страницу, браузер повторил запрос при обновлении — и “завершение задач” стало случайным фоном вашей жизни. GET должен быть безопасным (safe), а любые изменения состояния должны идти через методы записи.

Ошибка №4: превращать редкое исключение (вроде /download) в основной стиль API.
/download уместен как суффикс, когда вы отдаёте другое представление данных (бинарное содержимое вместо JSON metadata). Но если вы начнёте делать /complete, /archive, /changeStatus как суффиксы, вы снова соберёте каталог команд, только уже “вложенный” в ресурсы. Исключения должны быть редкими, иначе это уже не исключение, а ваш новый стиль.

Ошибка №5: смешивать два стиля в одной и той же ресурсной зоне.
Хуже всего выглядит гибрид: половина операций сделана ресурсно (PATCH /tasks/{taskId}), а половина командно (POST /tasks/{taskId}/complete, POST /tasks/{taskId}/changeStatus). Клиенту трудно понять, “как тут принято”. Если вы выбрали ресурсную модель, держите её последовательно: ресурс один, операции выражаются методами и телом, а специальные суффиксы оставляйте только там, где они действительно про представление данных (как download для attachments).

1
Задача
Spring REST & MVC, 4 уровень, 2 лекция
Недоступна
Замена командных URI в API заказов
Замена командных URI в API заказов
1
Задача
Spring REST & MVC, 4 уровень, 2 лекция
Недоступна
Узкий суффикс download для вложения отчёта
Узкий суффикс download для вложения отчёта
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ