1. Де в net/http «живе» URL запиту
Коли ви вперше пишете обробник (handler) у Go, здається, що вам вручили два подарунки: http.ResponseWriter і *http.Request. Перший — щоб формувати відповідь, другий — щоб розуміти, на що саме відповідати. А в другому, окрім методу (GET/POST) і заголовків, є ще й URL запиту — уже розкладений у структуру. Це не магія, а просто нормальний інтерфейс сервера. До речі, http.Request ще й несе Context, щоб запит можна було коректно скасувати, але в цій лекції ми лише зауважимо цей факт і не будемо заглиблюватися.
Найпростіший «скелет» сервера виглядає приблизно так. Він нагадує класичні приклади net/http.
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/api/v1/tasks", listTasksHandler)
_ = http.ListenAndServe(":8080", nil)
}
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok") // ok
}
Поки що обробник нічого не читає, але головне вже є: r.URL — це *url.URL, і саме там лежать Path, RawQuery та все, що потрібно для query-параметрів.
2. r.URL — це вже *url.URL, а не «рядок, який треба парсити»
Дуже поширена помилка новачка — думати, що в HTTP-запиті URL зберігається лише як рядок і що сервер не дає вам справжньої структури. Насправді net/http уже зробив за вас частину роботи: r.URL — це вказівник на структуру url.URL. Там окремо зберігаються шлях, query-рядок і навіть фрагмент. Щоправда, фрагмент до сервера зазвичай не доходить, бо браузер його не надсилає, — але це вже деталі.
Давайте просто подивимося, що приходить в обробник, не намагаючись нічого обробляти. Це корисно для діагностики: іноді ви думаєте, що у вас limit=10, а насправді клієнт шле Limit=10, і ви годину шукаєте, чому нічого не працює. Програміст без діагностики — як кіт без коробки: ніби й живий, але тривожно.
package main
import (
"fmt"
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "path=%q\n", r.URL.Path) // path="/api/v1/tasks"
fmt.Fprintf(w, "rawQuery=%q\n", r.URL.RawQuery) // rawQuery="done=true&limit=10"
}
Зверніть увагу: RawQuery зберігається без символа ?. Тобто там буде "a=b&c=d", а не "?a=b&c=d".
3. Головна кнопка: r.URL.Query() → url.Values
Ось той самий момент, заради якого ми зібралися. У url.URL query-рядок зберігається як сирий текст (RawQuery), але працювати з ним вручну незручно. Тому в url.URL є метод Query(), який повертає url.Values — структуру, заточену саме під query-параметри.
Важливо зрозуміти ідею: Query() перетворює «рядок формату a=b&c=d» на нормальну структуру даних «ключ → список значень». І це одразу пояснює, чому query-параметри не варто уявляти як map[string]string: один ключ може траплятися кілька разів (tag=go&tag=http), і це нормальний, стандартний випадок.
Мінімальний приклад читання:
package main
import (
"fmt"
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fmt.Fprintln(w, "done:", q.Get("done")) // done: true
fmt.Fprintln(w, "limit:", q.Get("limit")) // limit: 10
}
Тут Get("done") поверне рядок. Навіть якщо ви очікуєте bool або int, на рівні HTTP це все одно рядок. Перетворювати на bool/int ми будемо пізніше; сьогодні наша мета — впевнено дістати значення.
4. Чому url.Values зберігає []string і чим Get відрізняється від q[...]
Нормально, якщо спочатку url.Values викликає легке здивування: «Чому там []string, якщо я хочу лише одне значення?!». Але це якраз той випадок, коли стандартна бібліотека не знущається з вас, а береже від майбутнього болю.
Розгляньмо класичний список тегів: /api/v1/tasks?tag=go&tag=http&tag=cli. Це не екзотика, а звичайний спосіб передати список значень у query. Тому url.Values зберігає усі значення за ключем.
Порівняймо два способи читання:
| Що читаємо | Як читаємо | Що отримуємо | Коли зручно |
|---|---|---|---|
| «Одне значення» (перше) | |
string (або "", якщо ключа немає) | для одиночних параметрів (limit, sort, q) |
| «Усі значення» | |
[]string (або nil, якщо ключа немає) | для списків (tag=...&tag=...) |
Покажімо це кодом:
package main
import (
"fmt"
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fmt.Fprintln(w, "first tag:", q.Get("tag")) // first tag: go
fmt.Fprintln(w, "all tags:", q["tag"]) // all tags: [go http cli]
}
Get бере перше значення. Іноді саме це і потрібно. Але якщо за контрактом параметр повторюється, читати його треба як список.
5. Мініпрактика: читаємо фільтри, пошук і теги
Давайте зробимо обробник, який ніби готується до фільтрації задач, але поки що нічого не фільтрує. Він просто читає параметри й повертає їх у відповіді. Це виглядає іграшково, але насправді це чудовий спосіб перевірити контракт: ви в браузері або через curl змінюєте query-параметри й одразу бачите, як сервер це зрозумів.
Ми працюватимемо з нашим ресурсом /api/v1/tasks і типовими параметрами: done, q, tag, limit, offset, sort, order.
package main
import (
"fmt"
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
fmt.Fprintln(w, "done =", q.Get("done"))
fmt.Fprintln(w, "q =", q.Get("q"))
fmt.Fprintln(w, "tags =", q["tag"])
}
Перевірка в голові виглядає так:
- /api/v1/tasks?done=true&q=milk&tag=go&tag=http
- сервер поверне:
- done = true
- q = milk
- tags = [go http]
Поки що все — це рядки та зрізи рядків. Це нормально. Сьогодні ми не будуємо «розумний фільтр»; сьогодні ми вчимося коректно й передбачувано діставати вхідні дані.
6. Нюанс: «ключ відсутній» і «значення порожнє» — не одне й те саме
q.Get("k") повертає порожній рядок і коли ключа немає, і коли ключ є, але значення порожнє (k=). Іноді це неважливо, але іноді — критично для контракту та значень за замовчуванням.
Наприклад, q= може означати «користувач спеціально хоче шукати порожній рядок» (хоча це дивно), а відсутність q — «пошуку немає, повертаємо все». Щоб розрізняти ці випадки, можна перевіряти наявність ключа через прямий доступ до мапи.
package main
import (
"fmt"
"net/http"
)
func listTasksHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
_, hasQ := q["q"]
fmt.Fprintln(w, "q present:", hasQ) // q present: true/false
fmt.Fprintln(w, "q value:", q.Get("q"))
}
Якщо клієнт надішле /api/v1/tasks?q=, тоді hasQ буде true, а q.Get("q") поверне "".
Це не фішка заради фішки. Це нормальна база для значень за замовчуванням і валідації: дефолт зазвичай застосовується, коли параметр відсутній, а не коли він присутній, але порожній.
7. Корисні нюанси та інструменти навколо query
url.ParseQuery: коли ви не в обробнику, але вам усе ще потрібна query-логіка
Іноді query-рядок у вас є лише як текст, але ви не всередині HTTP-обробника. Наприклад, ви пишете тести для функції розбору параметрів або робите утиліту, яка приймає рядок a=b&c=d і перетворює його на url.Values.
Для таких випадків існує url.ParseQuery. Логіка та сама, що й у r.URL.Query(), тільки вхід тут — звичайний рядок.
package main
import (
"fmt"
"net/url"
)
func main() {
q, _ := url.ParseQuery("tag=go&tag=http&q=milk")
fmt.Println("q:", q.Get("q")) // q: milk
fmt.Println("tags:", q["tag"]) // tags: [go http]
}
Це зручно для тестування: можна не піднімати сервер, а перевіряти, що розбір параметрів поводиться очікувано.
Нюанс: Query() повертає копію (і це добре)
Є тонкий момент, який у реальному коді спливає частіше, ніж здається. Коли ви робите: q := r.URL.Query() ви отримуєте url.Values. Формально це мапа, і ви навіть можете викликати q.Set(...). Але в контексті вхідного запиту на сервері це майже ніколи не потрібно, а якщо ви це робите, то не слід сприймати ці зміни як «я змінив URL запиту».
У нашому серверному сценарії правило просте: query-параметри — це вхід, а не місце для мутацій. Ми читаємо їх, валідовуємо, застосовуємо до логіки — і все. Якщо ви почнете виправляти запит на ходу, швидко отримаєте код, у якому складно зрозуміти, що прийшло від клієнта, а що ви самі собі вигадали.
Якщо ж вам колись знадобиться зібрати URL (наприклад, для клієнта або редиректу) — це робиться через url.URL + url.Values.Encode(), але це вже інша історія й інший контекст.
Схема: що відбувається, коли сервер читає query
Щоб закріпити картину, тримайте маленьку схему того, як мислить обробник. Тут немає магії — лише акуратні кроки, які легко повторювати щоразу.
flowchart TD
A[HTTP-запит: /api/v1/tasks?tag=go&tag=http&done=true] --> B[net/http створює *http.Request]
B --> C[r.URL: *url.URL]
C --> D["r.URL.Query(): url.Values"]
D --> E["Читаємо q.Get / q[...]"]
E --> F[Далі: валідація та застосування до логіки]
На цій лекції ми впевнено доходимо до кроку «Читаємо q.Get / q[...]». Наступний крок — валідація та значення за замовчуванням — ми свідомо поки що не розбираємо, щоб не змішувати «як дістати» і «як правильно перевірити».
8. Типові помилки під час читання query-параметрів на сервері
Помилка № 1: парсити query вручну через strings.Split і «нібито працює».
Зазвичай це закінчується дивними помилками з екрануванням, плюсами замість пробілів, ключами, що повторюються, і питанням «чому в мене tag лише один». r.URL.Query() розв’язує це стандартно й надійно: ви отримуєте url.Values і працюєте з ним як із даними, а не як із рядком.
Помилка № 2: читати списки через Get, а потім дивуватися, що значень менше.
Get("tag") повертає лише перше значення. Якщо за контрактом у вас tag=go&tag=http, потрібно читати q["tag"]. Інакше ви ввічливо втрачаєте дані користувача, а потім робите вигляд, що так і задумано.
Помилка № 3: вважати, що Get("k") == "" означає «ключа немає».
Це неправильно: k= і відсутність k виглядають однаково для Get. Якщо вам важливо розрізняти ці випадки, перевіряйте наявність ключа через _, ok := q["k"]. Інакше значення за замовчуванням і помилки застосовуватимуться не туди, і користувачі почнуть знаходити у вас загадкові баги.
Помилка № 4: змішувати читання query та бізнес-логіку в одне полотно.
Коли в обробнику одночасно читаються параметри, парсяться числа, перевіряються діапазони, застосовуються фільтри й будується відповідь — код стає важким навіть для автора через тиждень. Набагато зрозуміліше спочатку дістати рядки з url.Values, а далі окремим кроком перетворювати їх на потрібні типи й перевіряти, повертаючи зрозумілі помилки.
Помилка № 5: намагатися «поправити» вхідний запит, змінюючи q як мапу.
На сервері запит — це вхідні дані. Якщо ви починаєте мутувати query, ви стираєте межу між «прийшло ззовні» і «ми самі собі вигадали». Це погіршує діагностику й може призвести до неочікуваних ефектів. Правильний підхід: зчитати параметри, а потім працювати вже зі своїми змінними або структурами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ