1. Чому id у шляху — це мова API
Коли ви вперше бачите URL на кшталт /tasks/42, виникає спокуса подумати: «Ну й навіщо так складно? Не можна було написати /tasks?id=42?» Можна. Але в API є своя «мова»: шлях у URL зазвичай описує ресурс, а query‑параметри — це параметри запиту. Це схоже на адресу доставки: вулиця й будинок — це «куди», а коментар курʼєру — «як саме».
Якщо в нас є список задач, то /api/v1/tasks читається як «колекція задач». А /api/v1/tasks/42 — як «конкретна задача з ідентифікатором 42». Це допомагає підтримувати зрозумілу структуру API: шлях відповідає на запитання «що саме ми чіпаємо», а не перетворюється на мішанину параметрів.
Головна практична причина, без зайвої філософії, така: цей стиль простіше читати, документувати й розширювати. А ще він чудово лягає на роутинг: ми можемо сказати роутеру «ось тут стоїть {id}, підстав сюди один довільний сегмент шляху — і передай його обробнику».
2. Path‑параметр {id} у ServeMux: як він зіставляється
Коли ми говоримо «path‑параметр», ми маємо на увазі змінну частину шляху, яка займає рівно один сегмент між слешами. Це важливо: {id} — не «будь-який рядок будь-якої довжини», а саме «те, що стоїть між / і наступним /». Тому /tasks/{id} зіставляється з /tasks/42, але не перетворюється на пилосос, який засмоктує пів інтернету.
Починаючи з Go 1.22, шаблони net/http.ServeMux підтримують методи, wildcard’и та захоплення сегментів виду {id}; а значення захопленого сегмента можна дістати через r.PathValue. Це означає, що нам не потрібно вручну розбирати r.URL.Path, робити strings.Split, ловити помилку на одиницю й пояснювати собі, чому в продакшені раптом прилетів шлях із подвійним слешем.
Мінімальна реєстрація маршруту виглядає так:
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. І це добре: витягування й перевірка — різні задачі. Сьогодні ми чесно робимо лише витягування, а перевірку й перетворення на число централізуємо окремо, щоб не розмножувати копіпасту.
Ось мінімальний приклад обробника, який показує, що саме прилетіло:
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, а не 2. Ми просто просимо запит: «дай мені "userID"» і «дай мені "taskID"».
Якщо ви колись вручну розбирали шляхи через strings.Split, зараз маєте відчути легку радість. Якщо ні — нічого, відчуєте її після першого бага в продакшені.
Path vs query‑параметри: у чому різниця
Дуже часта плутанина в новачків: «А {id} — це те саме, що ?id=...?» Ні. Вони схожі тим, що обидва дають вам рядок, але сенс у них зазвичай різний.
Порівняймо на практиці:
| Що хочемо виразити | Приклад URL | Де лежить значення в Go |
|---|---|---|
| «конкретний ресурс» | |
|
| «параметри запиту» | |
|
| «фільтр/режим виводу» | |
|
Невеликий приклад: нехай у нас є кінцева точка задачі за 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. Вбудовуємо в навчальний застосунок
Зараз ми зробимо маленький, але важливий крок: додамо обробник, який витягує {id} і повертає JSON‑відповідь. Ми поки не будемо перетворювати id на int і не будемо доводити, що він «точно правильний» — наша ціль сьогодні навчитися акуратно діставати значення без ручного парсингу шляху.
Припустімо, що writeJSON у нас уже є з минулих лекцій (він ставить Content-Type, пише статус і кодує структуру в JSON). Тоді обробник може виглядати так:
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 — рядок і ми справді його отримали.
Маленька пересторога: перевірка на порожній рядок
Іноді не зайвою буде невелика допоміжна функція, яка робить порожній рядок явною умовою. Одразу важливе застереження: якщо маршрут зареєстровано правильно й ім’я параметра збігається, 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, але якщо обробник уже викликано, значить, 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}. Це той випадок, коли зайві дві літери економлять вам десятки хвилин налагодження.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ