1. Зачем думать ресурсами, а не командами
Когда начинаешь делать HTTP‑API, очень легко скатиться в стиль «удобных команд»: /createTask, /getTask, /markTaskDone. Это похоже на пульт управления: кнопка «Создать», кнопка «Получить», кнопка «Сделать красиво». Проблема в том, что HTTP уже придумал вам кнопки — это методы. А путь должен описывать что за сущность лежит по этому адресу.
Ресурсный подход — это когда вы представляете API как «витрину сущностей». Есть коллекция задач, есть конкретная задача, есть представление состояния «выполнено». Тогда клиенту проще предсказывать API «по форме», а не учить 30 уникальных URL‑команд.
Хорошая новость: вам не нужно знать «всю REST‑философию». Достаточно держать в голове одну простую фразу:
URL отвечает на вопрос «что это?», а метод отвечает «что сделать?»
Мини-сравнение: командный стиль vs ресурсный
| Что хотим | Командный URL (плохо) | Ресурсный URL (нормально) |
|---|---|---|
| получить список задач | |
|
| создать задачу | |
|
| получить задачу 7 | |
|
| отметить задачу 7 выполненной | |
|
Командный стиль выглядит «понятным» ровно до тех пор, пока API не разрастается. Потом начинается дрейф: createTask, addTask, newTask, makeTask — и внезапно у вас в API появляется лингвистический курс «синонимы английских глаголов».
2. Версия в пути: зачем нужен /api/v1
Версионирование в пути часто кажется бюрократией: «мы же только начали, какие версии?». Но проблема в том, что API почти невозможно не менять. Вы захотите добавить поле, переименовать поле, поменять формат, «чуть‑чуть» изменить поведение ошибок. И вот тут начинается самое интересное: старые клиенты могут продолжать жить месяцами.
Есть железное правило: если клиент уже интегрировался, то ваша «маленькая правка» для него может стать падением в проде в пятницу вечером. А пятница вечером — это священное время, когда прод лучше не трогать вообще.
В мире Go очень хорошо чувствуется важность совместимости: если вы меняете публичный API (функции, типы), вы рискуете сломать чужой код, который на него опирается. Это прямо описывается как проблема совместимости: «нельзя забирать API, иначе программы сломаются». С HTTP‑контрактами та же история, просто «чужая программа» — это клиент вашего API.
Поэтому /api/v1 — это способ честно сказать: «вот правила версии 1». Если когда‑нибудь понадобятся несовместимые изменения, у вас есть вариант /api/v2, и старые клиенты не обязаны страдать.
Небольшой пример на Go: фиксируем префикс версии
package main
const apiV1 = "/api/v1"
func tasksPath() string {
return apiV1 + "/tasks"
}
Здесь нет магии — просто привычка: все пути строим от единого префикса. Это снижает риск опечаток и помогает держать контракт ровным.
3. Коллекция задач: /api/v1/tasks
Путь /api/v1/tasks — это коллекция задач. Представьте, что это полка в магазине: на ней стоят все товары (задачи). С полкой обычно делают два действия: «посмотреть, что на полке лежит» и «положить новый товар».
Ровно так же и в API: список задач — это запрос к коллекции, создание задачи — это добавление в коллекцию. Методом вы различаете эти действия, и путь при этом остаётся один и тот же.
Здесь важна психологическая штука: когда путь один, а метод меняется, API кажется «формой», а не «зоопарком ссылок». Клиенту легче запомнить /tasks и дальше уже логически догадаться: GET — читаю, POST — создаю.
Пример: описываем endpoint как объект контракта
package main
type Endpoint struct {
Method string
Path string
}
var listTasks = Endpoint{Method: "GET", Path: "/api/v1/tasks"}
var createTask = Endpoint{Method: "POST", Path: "/api/v1/tasks"}
Если вы сейчас подумали: «зачем мне это в Go‑коде, мы же ещё сервер не пишем?», то ответ простой: так вы тренируете мышцу «контракт сначала». Этот же список потом можно будет использовать и в документации, и в тестах, и даже в логах.
Почему tasks во множественном числе
/tasks, а не /task, — потому что коллекция подразумевает множество. Да, это «правило хорошего тона», но оно реально помогает мозгу. Если видите множественное число, вы ожидаете список. Если видите /tasks/7, вы ожидаете один элемент.
4. Одна задача: /api/v1/tasks/{id}
Если /tasks — это полка, то /tasks/{id} — это карточка конкретной задачи. Здесь {id} — это шаблон в документации, а не реальные фигурные скобки в URL.
То есть в контракте вы пишете:
- /api/v1/tasks/{id}
а в реальном запросе клиент подставляет число:
- /api/v1/tasks/7
Что такое {id} по смыслу
id — это идентификатор. В рамках нашего учебного домена проще всего договориться, что id — целое число (например, int). Важно именно «договориться и зафиксировать»: клиент должен понимать, что именно он должен подставить в путь.
Если не фиксировать формат, получится хаос: один клиент шлёт UUID, другой шлёт числа, третий шлёт 0007, а четвёртый шлёт "семь" (потому что он оптимист).
Пример: функция, которая материализует путь из {id}
package main
import "fmt"
func taskPath(id int) string {
return fmt.Sprintf("/api/v1/tasks/%d", id)
}
Такие функции кажутся мелочью, но они дисциплинируют. Когда вы пишете taskPath(7), вы не забудете /api/v1, не перепутаете tasks с task, и не сделаете лишний слэш. А ещё такие функции потом очень приятно использовать в тестах: вы сравниваете строку, а не собираете её руками в каждом месте.
5. Состояние done: поле и путь /done
С done обычно возникает первая взрослая дискуссия про дизайн API. Почему? Потому что done — это вроде бы просто булево поле в задаче (done: true/false), но пользователю хочется отдельную кнопку «Отметить выполненной», а разработчику хочется отдельный endpoint, где всё просто и понятно.
В нашем домене мы хотим поддержать понятный сценарий: «пометить задачу выполненной». И в плане курса выбран пример пути состояния:
- /api/v1/tasks/{id}/done
Это читается почти как фраза: «done для задачи {id}». Здесь /done выступает как подресурс или «представление состояния». Мы не делаем вид, что done — отдельная сущность уровня /done/{id}. Мы держим его внутри задачи.
Почему /done — это нормально
Мы раньше ругали «глаголы в URL» (/createTask). Но /done — другой случай: это не «команда на сервер», это часть модели ресурса — состояние. Мы как будто говорим: «у задачи есть признак done, и у него есть отдельное место в пути».
Можно думать так: /api/v1/tasks/7/done — это «ручка» состояния выполненности для задачи 7.
Варианты, которые обычно сравнивают
Есть два популярных подхода:
- Делать изменение через обновление задачи (например, частичное) и передавать {"done": true} в теле.
- Делать отдельный endpoint состояния: /tasks/{id}/done.
В рамках этой лекции мы фиксируем именно дизайн пути /done. А вот точную семантику метода (POST/PATCH) и детали статусов лучше удерживать как часть общего контракта, но без глубокого ухода в реализацию.
Пример: функция пути done для конкретной задачи
package main
import "fmt"
func taskDonePath(id int) string {
return fmt.Sprintf("/api/v1/tasks/%d/done", id)
}
Теперь у вас есть три стабильных «кирпичика»:
- tasksPath() — коллекция
- taskPath(id) — один ресурс
- taskDonePath(id) — состояние done
Это и есть «скелет» URL‑пространства нашего API.
Небольшая схема: как всё связано
Когда в голове всё это смешивается, помогает простая визуализация. Тут нет никакой магии REST, просто дерево.
flowchart TD
A["/api/v1/tasks
Коллекция задач"] --> B["/api/v1/tasks/{id}
Одна задача"]
B --> C["/api/v1/tasks/{id}/done
Состояние done"]
Дерево показывает важную вещь: /done живёт под задачей, а не рядом с ней. Это снижает риск ошибок дизайна, где done вдруг начинает жить как отдельная сущность без смысла.
6. Консистентность словаря: tasks, done, {id}
Очень часто API портят не сложные баги, а мелкая несогласованность. Один эндпоинт использует /tasks, другой /todo, третий /items. У вас в голове это одно и то же, а клиент — не телепат. Он читает документацию буквально.
Поэтому в учебном домене мы держим один словарь:
- сущность: tasks
- идентификатор: id
- состояние: done
- версия: /api/v1
Если вам хочется добавить «красоты» и назвать задачи todos, остановитесь и задайте себе вопрос: «а что выиграет клиент?». Обычно ответ: «ничего, кроме лишнего места для ошибки».
Здесь полезна аналогия с именами переменных в Go: если вы в одном месте пишете taskID, а в другом id, а в третьем task_id, то код становится тяжелее читать. URL‑дизайн — это то же самое, только «переменные» читают другие программы.
7. Карта API в Go-коде
Даже до того, как у вас появится реальный HTTP‑сервер, полезно держать контракт в виде структуры данных. Это помогает вам не спорить словами («ну там где‑то /done»), а видеть конкретные строки.
И ещё это уменьшает шанс опечаток. Опечатка в URL — один из самых обидных багов: всё «почти правильно», но 404, и вы час ищете лишнюю букву.
Шаг 1: заведём операции и список маршрутов
package main
type Route struct {
Method string
Pattern string
Op string
}
func apiRoutes() []Route {
return []Route{
{Method: "GET", Pattern: "/api/v1/tasks", Op: "list_tasks"},
{Method: "POST", Pattern: "/api/v1/tasks", Op: "create_task"},
{Method: "GET", Pattern: "/api/v1/tasks/{id}", Op: "get_task"},
{Method: "POST", Pattern: "/api/v1/tasks/{id}/done", Op: "mark_done"},
}
}
Обратите внимание: мы используем {id} именно как документационный шаблон. Это удобно для чтения и обсуждения.
Шаг 2: сделаем печать «карты» как мини-документацию
package main
import "fmt"
func printRoutes(routes []Route) {
for _, r := range routes {
fmt.Printf("%s %-25s # %s\n", r.Method, r.Pattern, r.Op)
}
}
Шаг 3: соберём всё в main
package main
func main() {
printRoutes(apiRoutes())
}
Если запустить, вывод будет примерно такой:
GET /api/v1/tasks # list_tasks
POST /api/v1/tasks # create_task
GET /api/v1/tasks/{id} # get_task
POST /api/v1/tasks/{id}/done # mark_done
Это выглядит почти как черновик README. И в этом прелесть: контракт должен быть простым, чтобы помещаться «на один экран».
Почему менять пути почти всегда больно
Пути — это часть контракта, и их тяжело менять без последствий. Даже если вы «просто переименовали» /tasks в /todo, клиенты перестанут работать.
В Go‑мире тема совместимости — не абстракция. Если публичный API меняется, чужой код ломается, и это очень практическая проблема: нельзя «забрать API назад» без поломок. HTTP‑контракт ведёт себя так же: вы не можете незаметно «вынуть» endpoint, если на него кто‑то завязан.
И есть ещё один хороший пример того, как совместимость влияет на дизайн: когда в стандартной библиотеке Go добавляли context.Context, нельзя было просто изменить сигнатуру http.Client.Do, потому что это сломало бы кучу кода. Поэтому поддержку контекста встроили в http.Request так, чтобы не ломать старые вызовы.
Это отличный урок: дизайн интерфейса (и API) часто определяется не «идеальной архитектурой», а ответственностью перед пользователями.
8. Типичные ошибки при дизайне путей задач
Ошибка №1: командные URL (/createTask, /markDone) вместо ресурсов.
Обычно это происходит из желания сделать «как в RPC»: отдельная команда на каждое действие. В итоге у вас появляется зоопарк путей, и клиент не может предсказать API по форме. Лечится простым правилом: путь — существительное (ресурс), действие — HTTP‑метод, а исключения вроде /done должны быть частью модели состояния, а не случайной командой.
Ошибка №2: отсутствие единого словаря (/tasks, /todo, /items) и дрейф названий.
Сначала кажется, что это «мелочь». Потом внезапно оказывается, что разные команды разработки называли одно и то же по‑разному, и клиент пишет костыли «если /tasks не работает, попробуй /todo». Нормальный подход — выбрать один домен (tasks) и держать его везде: в путях, в DTO, в документации, в примерах.
Ошибка №3: {id} не определён как формат, и каждый клиент понимает его по‑своему.
Если вы не сказали, что id — это целое число (или UUID), то клиенты начнут угадывать. Особенно весело, когда один клиент шлёт "7", а другой "007", а третий "seven". В контракте нужно явно фиксировать: {id} — это, например, положительное целое число. Даже если вы пока не реализовали проверку, договорённость уже должна существовать.
Ошибка №4: путаница между состоянием и ресурсом для done.
Иногда done внезапно превращают в отдельный верхнеуровневый ресурс (/api/v1/done/{id}), и API становится странным: «где тут задача, а где признак?». В нашем домене done — это состояние задачи, поэтому оно живёт под /tasks/{id}: /tasks/{id}/done. Так клиенту проще понять, что меняется именно состояние конкретной задачи.
Ошибка №5: отсутствие версии (/api/tasks вместо /api/v1/tasks) и надежда “мы всегда будем аккуратными”.
Надежда благородная, но реальность побеждает: API меняется. Без версии вам либо придётся ломать старых клиентов, либо тащить кучу обратной совместимости без явного разделения правил. Версия в пути — простой и честный способ сказать: «вот контракт, за который мы отвечаем».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ