1. Почему id в пути — это язык API
Когда вы впервые видите URL вроде /tasks/42, есть соблазн подумать: «Ну и зачем так сложно, нельзя было написать /tasks?id=42?» Можно. Но у API есть свой «язык», и путь в URL — это обычно про ресурс, а query‑параметры — про настройки запроса. Это похоже на адрес доставки: улица и дом — это «куда», а комментарий курьеру — «как именно».
Если у нас есть список задач, то /api/v1/tasks читается как «коллекция задач». А /api/v1/tasks/42 — как «конкретная задача с идентификатором 42». Это помогает поддерживать понятную структуру API, где путь отвечает на вопрос «что именно мы трогаем», а не превращается в мешанину параметров.
Главная практическая причина (без философии): такой стиль проще читать, проще документировать и проще расширять. А ещё он отлично ложится на роутинг: мы можем сказать роутеру «вот здесь стоит {id}, подставь туда любой один сегмент пути — и передай его handler’у».
2. Path‑параметр {id} в ServeMux: как он матчится
Когда мы говорим «path‑параметр», мы имеем в виду переменную часть пути, которая занимает ровно один сегмент между слешами. Это важно: {id} — не «любая строка любой длины», а именно «то, что стоит между / и следующим /». Поэтому /tasks/{id} матчится на /tasks/42, но не превращается в пылесос, который засасывает половину интернета.
Начиная с Go 1.22, паттерны net/http.ServeMux поддерживают методы, wildcard’ы и захват сегментов вида {id}; и значение захваченного сегмента можно достать из *http.Request. Это означает, что нам не нужно вручную парсить r.URL.Path, делать strings.Split, ловить off-by-one и объяснять себе, почему в проде внезапно прилетел путь с двойным слешем.
Минимальная регистрация маршрута выглядит так:
package main
import (
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/tasks/{id}", handleGetTask)
_ = http.ListenAndServe(":8080", mux)
}
func handleGetTask(w http.ResponseWriter, r *http.Request) {
// Пока пусто — в следующих разделах научимся доставать id.
}
Обратите внимание на две вещи. Во-первых, {id} — это имя параметра. Во-вторых, имя параметра — часть контракта: если вы написали {id}, то извлекать надо именно "id", а не "taskId" и не "ID".
3. Извлечение и имена параметров
Извлечение значения: r.PathValue("id")
Теперь к самому вкусному: как достать то, что реально пришло в URL.
r.PathValue("id") возвращает строку — то есть «сырое» значение сегмента пути. На этом этапе оно ещё не число, не UUID и не «точно корректное значение». Это просто текст из URL. И это хорошо: извлечение и проверка — разные задачи. Сегодня мы честно делаем только извлечение (а проверку и преобразование в число будем централизовать отдельно, чтобы не размножать копипасту).
Вот минимальный пример handler’а, который показывает, что именно прилетело:
package main
import (
"fmt"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, "task id = %q\n", id) // task id = "42"
}
Здесь %q полезен тем, что он печатает строку в кавычках. Если вдруг id пустой, вы это увидите как "", а не будете гадать, «оно напечаталось или нет».
Что вернёт PathValue, если параметра нет
Обычно в корректно зарегистрированном маршруте параметр будет. Но если вы ошиблись в имени (например, в паттерне {taskID}, а читаете "id"), PathValue вернёт пустую строку. В этом месте многие новички начинают «лечить симптомы» (городят проверки), хотя правильное лечение — привести в порядок имена параметров, чтобы они совпадали.
Регистр важен: имя в {...} и ключ в PathValue(...) должны совпасть
В программировании половина багов — это «мы договорились, но забыли, о чём». В path‑параметрах это проявляется особенно ярко: имя параметра в {...} и строка в PathValue(...) должны совпадать символ в символ, включая регистр.
Представьте, что {id} — это наклейка на коробке. Если вы потом ищете коробку по наклейке "ID", а наклейка была "id", то формально вы «ищете другое». Go вам ничего не должен — он просто вернёт пустую строку.
Пример правильного совпадения:
mux.HandleFunc("GET /api/v1/tasks/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
_, _ = w.Write([]byte(id + "\n"))
})
А вот пример очень жизненной ошибки:
mux.HandleFunc("GET /api/v1/tasks/{taskID}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // ошибка: имя не совпадает, id будет ""
_, _ = w.Write([]byte(id + "\n"))
})
Как лучше называть: {id} или {taskID}
Если у вас в маршруте ровно один идентификатор и по контексту ясно, чей он, {id} — нормально. Если параметров несколько (например, пользователь и задача), тогда лучше писать более конкретно: {userID} и {taskID}. Это повышает читаемость кода без всяких комментариев.
4. Несколько параметров и query‑параметры
Несколько параметров в пути: пример /users/{userID}/tasks/{taskID}
Когда API начинает жить (то есть когда вы перестаёте писать «Hello world» и начинаете писать «похоже, это настоящий сервер»), очень быстро появляются вложенные ресурсы: задачи пользователя, комментарии задачи, вложения комментария и так далее. И именно тут path‑параметры начинают реально экономить время и нервы.
Посмотрим пример маршрута с двумя параметрами:
package main
import (
"fmt"
"net/http"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/users/{userID}/tasks/{taskID}", func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("userID")
taskID := r.PathValue("taskID")
fmt.Fprintf(w, "user=%s task=%s\n", userID, taskID) // user=7 task=42
})
_ = http.ListenAndServe(":8080", mux)
}
Обратите внимание: мы не парсим r.URL.Path вручную. Мы не считаем слеши. Мы не спорим с собой в 3 часа ночи, «почему индекс 3, а не 2». Мы просто спрашиваем запрос: «дай мне "userID"» и «дай мне "taskID"».
Если вы когда‑нибудь руками разбирали пути через strings.Split, вы сейчас должны почувствовать лёгкую радость. Если не почувствовали — ничего, почувствуете после первого бага в проде.
Path vs query‑параметры: в чём разница
Очень частая путаница у новичков: «А {id} — это то же самое, что ?id=...?» Нет. Они похожи тем, что оба дают вам строку, но смысл у них обычно разный.
Сравним на практике:
| Что хотим выразить | Пример URL | Где лежит значение в Go |
|---|---|---|
| «конкретный ресурс» | |
|
| «настройка запроса» | |
|
| «фильтр/режим вывода» | |
|
Небольшой пример: пусть у нас есть endpoint задачи по id, и мы добавили «режим болтливости» через query‑параметр verbose (чисто для демонстрации различий):
package main
import (
"fmt"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
verbose := r.URL.Query().Get("verbose")
fmt.Fprintf(w, "id=%s verbose=%s\n", id, verbose) // id=42 verbose=1
}
В этом примере id — часть пути, потому что это «какую задачу мы читаем», а verbose — query, потому что это «как именно показать ответ».
5. Встраиваем в учебное приложение
Сейчас мы сделаем маленький, но важный шаг: добавим handler, который извлекает {id} и возвращает JSON‑ответ. Мы пока не будем превращать id в int и не будем доказывать, что он «точно правильный» — наша цель сегодня научиться доставать значение аккуратно и без ручного парсинга пути.
Предположим, что writeJSON у нас уже есть с прошлых лекций (он ставит Content-Type, пишет статус и кодирует структуру в JSON). Тогда handler может выглядеть так:
package main
import (
"net/http"
)
type taskResponse struct {
ID string `json:"id"`
}
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
writeJSON(w, http.StatusOK, taskResponse{ID: id})
}
Если очень хочется «пощупать» глазами без JSON‑хелпера, можно временно сделать так:
package main
import (
"fmt"
"net/http"
)
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
fmt.Fprintf(w, `{"id":%q}`+"\n", id) // {"id":"42"}
}
Этот вариант не идеален (ручная сборка JSON — как чинить часы молотком: иногда работает, но лучше не привыкать), зато хорошо показывает, что id — строка и мы реально её получили.
Маленькая страховка: проверка на пустую строку
Иногда полезно добавить крошечный helper, который превращает «пустая строка» в явное условие. Сразу важная оговорка: если ваш маршрут зарегистрирован правильно и имя параметра совпадает, id пустым быть не должен. Поэтому такая проверка — это не «валидация пользователя», а скорее «защита от наших опечаток и рефакторинга».
Например:
package main
import "net/http"
func mustPathValue(r *http.Request, key string) (string, bool) {
v := r.PathValue(key)
return v, v != ""
}
Использование:
package main
import "net/http"
func handleGetTask(w http.ResponseWriter, r *http.Request) {
id, ok := mustPathValue(r, "id")
if !ok {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = id // дальше используем id
w.WriteHeader(http.StatusOK)
}
Почему здесь 500, а не 400? Потому что если id не найден, чаще всего виноват не пользователь, а мы: маршрут и код расходятся по именам. Пользователь мог прислать какой угодно URL, но если handler уже вызван — значит, ServeMux решил, что маршрут совпал. А если совпал, но параметр не извлекается, это подозрительно похоже на ошибку в коде.
6. Типичные ошибки при работе с {id} и r.PathValue(...)
Ошибка №1: имя в {...} не совпадает с ключом в PathValue(...).
Самый частый баг — опечатка или разный стиль именования: {taskID} в маршруте и r.PathValue("taskId") в коде. В результате PathValue возвращает пустую строку, а вы потом час ищете «почему id пропал». Лечится дисциплиной: один стиль и внимательность к регистру.
Ошибка №2: попытка достать path‑параметр через r.URL.Query().
Query() работает только с частью после ?. Если вы пишете /tasks/42, то 42 — не query, это сегмент пути. Поэтому r.URL.Query().Get("id") вернёт пустоту, и это нормально. Используйте r.PathValue("id") для {id} и Query() для ?x=y.
Ошибка №3: ручной парсинг r.URL.Path вместо PathValue.
Можно сделать strings.Split(r.URL.Path, "/"), потом взять «третий элемент» и надеяться на лучшее. Но это хрупко: лишний слеш, другой префикс, версия API, и всё едет. Встроенный механизм {id} + PathValue как раз придуман, чтобы не играть в «угадай индекс».
Ошибка №4: смешивание извлечения и преобразования “в одну строку”.
Иногда пишут: strconv.Atoi(r.PathValue("id")) и радуются, пока не прилетит "abc" и не превратится в «0, но вроде работает». На уровне привычки лучше разделять шаги: сначала достали строку, потом отдельно (и централизованно) распарсили и проверили.
Ошибка №5: использование одного и того же {id} для разных сущностей в одном пути.
Маршрут /users/{id}/tasks/{id} технически выглядит «логично», но в коде превращается в кашу: какой id вы сейчас читаете? Лучше явно: {userID} и {taskID}. Это тот случай, когда лишние две буквы экономят вам десятки минут отладки.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ