1. Чому повторювані параметри — це нормально
Коли ви вперше бачите URL на кшталт ...?tag=go&tag=http&tag=cli, виникає природне бажання запитати: «А можна по одному, будь ласка?» Але веб давно домовився: повторюваний ключ у query — це стандартний спосіб передати список значень. І це логічно: query — не JSON, у ньому немає масивів як окремої структури, зате є просте правило — ключ може траплятися кілька разів.
Уявіть, що наш API задач підтримує фільтр за тегами. Користувач хоче отримати «усі задачі, позначені go і http». У нас є кілька варіантів: зробити tags=go,http, зробити tags[]=go&tags[]=http, зробити filter=tags:go|http — це присвячується всім, хто любить регулярні вирази. Але найпростіший і найзрозуміліший варіант — повторювати ключ: tag=go&tag=http.
У Go це особливо зручно, бо стандартна бібліотека вже зберігає query у вигляді структури, яка від самого початку розрахована на кілька значень: url.Values.
url.Values: map зі списками значень
Якщо коротко, url.Values — це словник (map), де кожному ключу відповідає список рядків, а не один рядок. Тобто за змістом це майже map[string][]string. Саме тому повторювані параметри не виглядають як «хак» — вони природно лягають у модель даних.
Це дуже важливий психологічний момент: ви перестаєте думати «query — це рядок після ?» і починаєте думати «query — це структура “ключ → значення”». Тоді одразу стає простіше пояснити, чому Get повертає лише одне значення, чому є Add і чому Encode() взагалі існує.
Погляньмо на коротку відповідність:
| Рядок query | url.Values (ідея) |
|---|---|
|
|
|
|
|
|
|
|
Зверніть увагу: навіть «звичайні» параметри технічно теж є списками, просто списками довжиною 1.
2. Set і Add: перезаписати чи додати
Коли ви будуєте query, у вас майже завжди є два сценарії. Перший — параметр має бути рівно один, наприклад limit=20. Другий — параметр є списком, наприклад tag=go&tag=http. Для першого є Set, для другого — Add. І це не просто два схожі методи, а пряме відображення контракту.
Почнімо з найпростішого прикладу — зберемо фільтри для /api/v1/tasks:
package main
import (
"fmt"
"net/url"
)
func main() {
q := url.Values{}
q.Set("done", "true")
q.Set("limit", "20")
fmt.Println(q.Encode()) // done=true&limit=20
}
Тепер додамо теги. Тут нам важлива саме поведінка «списку», тому використаємо Add:
package main
import (
"fmt"
"net/url"
)
func main() {
q := url.Values{}
q.Add("tag", "go")
q.Add("tag", "http")
fmt.Println(q.Encode()) // tag=go&tag=http
}
Ось тут і видно, навіщо url.Values зберігає []string: інакше таке неможливо без саморобних форматів.
Окремо корисно пам’ятати: якщо ви випадково зробите Set("tag", "go"), а потім Set("tag", "http"), то go зникне — бо Set означає «залиш лише одне значення».
3. Читання списків: Get vs vals["tag"]
Коли ми читаємо query, теж треба пам’ятати про модель «ключ → список значень». Саме тому в Go є два основні способи читання.
Перший — Get("k"). Він зручний, коли параметр за контрактом одиночний: limit, offset, sort, done. У HTTP-коді це виглядає дуже знайомо: r.URL.Query().Get("guess") — класичний приклад читання одного параметра.
Другий — vals["k"], тобто прямий доступ до слайсу значень. Він потрібен, коли параметр за контрактом повторюваний: tag, id (якщо ви раптом робите batch), або щось на кшталт status=...&status=....
Покажімо різницю на маленькому прикладі:
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("/api/v1/tasks?tag=go&tag=http&tag=cli")
q := u.Query()
fmt.Println(q.Get("tag")) // go
fmt.Println(q["tag"]) // [go http cli]
}
Чому Get повертає лише перше значення? Бо він задуманий як зручний спосіб для одиночних параметрів. Це не вада дизайну, а свідомий вибір: Get допомагає не писати зайвих перевірок.
Якщо параметр за контрактом — «список», не використовуйте Get, бо ви мовчки втратите дані.
Втрата даних у фільтрах — один із найнеприємніших багів: користувач передав два теги, сервер застосував один, і все виглядає так, ніби працює, тільки результати дивні.
4. Екранування та Encode()
Тепер — до най«магічнішої» частини. У якийсь момент кожен новачок пише щось на кшталт:
raw := "/api/v1/tasks?q=" + query
…і почувається переможцем. До першого запиту з пробілом, амперсандом або плюсом.
Проблема в тому, що рядок запиту використовує спеціальні символи:
- & розділяє параметри,
- = відділяє ключ від значення,
- ? відділяє шлях від query,
- # починає фрагмент (fragment) — він узагалі не має потрапляти в query,
- пробіли, +, % і багато інших символів мають особливі правила.
Якщо ви вставляєте значення «як є», воно може «розірвати» всю структуру query. Наприклад, значення go lang & http містить &, а це розділювач параметрів. Якщо склеїти все вручну, вийде каша.
Тут і потрібен percent-encoding, тобто URL-екранування: небезпечні символи перетворюються на безпечний текстовий вигляд %XX. У Go стандартна бібліотека вміє робити це за вас через url.Values.Encode().
Є дуже показовий приклад: стандартний ланцюжок ParseQuery → Encode → ParseQuery має зберігати структуру параметрів.
Подивімося на екранування в дії:
package main
import (
"fmt"
"net/url"
)
func main() {
q := url.Values{}
q.Set("q", "go lang & http")
fmt.Println(q.Encode()) // q=go+lang+%26+http
}
Зверніть увагу на дві речі.
По-перше, пробіли перетворилися на +. Це нормальна поведінка саме для query-частини URL: там пробіл часто кодується як +. По-друге, & перетворився на %26, щоб не стати розділювачем параметрів.
Якщо вам здається, що це не надто читабельно, — це нормально. URL-кодування й не має бути читабельним для людини; воно має бути коректним для машини. Для людини в нас є структури, логи, pretty-JSON тощо.
Списки значень і Encode() в одному URL
Найприємніша частина: повторювані параметри автоматично кодуються коректно, і вам не потрібно думати ані про &, ані про порядок, ані про екранування кожного значення.
Давайте зберемо URL для списку задач із фільтрами:
- done=true
- tag=go і tag=http
- q=go lang & http (щоб перевірити екранування)
package main
import (
"fmt"
"net/url"
)
func main() {
u := url.URL{
Scheme: "https",
Host: "api.example.com",
Path: "/api/v1/tasks",
}
q := url.Values{}
q.Set("done", "true")
q.Add("tag", "go")
q.Add("tag", "http")
q.Set("q", "go lang & http")
u.RawQuery = q.Encode()
fmt.Println(u.String())
}
Тут важливі дві речі.
Перша: RawQuery заповнюється без знака ?. Тобто туди кладемо лише a=b&c=d.
Друга: ми взагалі не склеюємо руками рядок ...? — ми збираємо структуру (url.URL + url.Values) і довіряємо Encode().
Це один із тих випадків, коли «менше коду» одночасно означає «менше багів».
5. Нюанси, які економлять години налагодження
У цьому розділі зібрані деталі, які часто спливають не в момент написання коду, а тоді, коли ви вже впевнені, що все ідеально… і раптом фільтри поводяться дивно. Це нормальна частина дорослішання будь-якого HTTP-коду: спочатку ви пишете «воно працює», потім — «воно працює завжди».
u.Query() повертає копію, і її потрібно повернути назад
Дуже часта пастка: ви розпарсили URL, отримали q := u.Query(), змінили q і очікуєте, що u.String() зміниться. Але Query() повертає значення, які потім треба знову записати в u.RawQuery.
package main
import (
"fmt"
"net/url"
)
func main() {
u, _ := url.Parse("https://api.example.com/api/v1/tasks?limit=10")
q := u.Query()
q.Set("limit", "20")
fmt.Println(u.String()) // https://api.example.com/api/v1/tasks?limit=10
u.RawQuery = q.Encode()
fmt.Println(u.String()) // https://api.example.com/api/v1/tasks?limit=20
}
Цей приклад варто тримати в голові як ритуал: змінили q — не забудьте u.RawQuery = q.Encode().
Encode() впорядковує ключі
У реальних проєктах стабільність виводу важлива: тести, логи, порівняння. У Go Values.Encode() робить рядок детермінованим за ключами (зазвичай це означає сортування ключів). Це не косметика, а одна з причин, чому URL зручно порівнювати в тестах.
Це також одна з причин, чому не варто збирати query руками: ви самі зламаєте собі стабільність.
Подвійне екранування
Іноді розробник наперед робить «про всяк випадок» щось на кшталт url.QueryEscape(value), а потім кладе це в url.Values і викликає Encode(). У результаті рядок екранується двічі, і на сервері ви отримуєте сміття на кшталт %2526 (це «закодований %26»).
Правило просте: якщо ви використовуєте url.Values.Encode(), не робіть ручного екранування значень.
6. Збираємо query з опцій
Щоб закріпити ідею на рівні «схоже на реальний код», давайте зробимо маленьку структуру опцій для списку задач і функцію, яка перетворює її на рядок query.
Важливо: ми не будуємо тут HTTP-клієнт, не надсилаємо запити й не обговорюємо transport. Ми просто акуратно збираємо URL так, щоб це було безпечно й зрозуміло.
package main
import (
"net/url"
"strconv"
)
type ListTasksOptions struct {
Done *bool
Tags []string
Limit int
Offset int
Query string
}
func buildTasksQuery(opt ListTasksOptions) url.Values {
q := url.Values{}
if opt.Done != nil {
q.Set("done", strconv.FormatBool(*opt.Done))
}
for _, tag := range opt.Tags {
q.Add("tag", tag)
}
if opt.Limit > 0 {
q.Set("limit", strconv.Itoa(opt.Limit))
}
if opt.Offset > 0 {
q.Set("offset", strconv.Itoa(opt.Offset))
}
if opt.Query != "" {
q.Set("q", opt.Query)
}
return q
}
Зверніть увагу на стиль: Tags — це список, тому ми використовуємо Add. Решта параметрів за контрактом одиночні — отже, Set. А Done зроблено вказівником *bool, щоб можна було розрізняти «фільтр не задано» і «фільтр задано як false».
Тепер зберемо повний URL:
package main
import (
"fmt"
"net/url"
)
func main() {
done := true
opt := ListTasksOptions{
Done: &done,
Tags: []string{"go", "http"},
Limit: 20,
Query: "go lang & http",
}
u := url.URL{
Scheme: "https",
Host: "api.example.com",
Path: "/api/v1/tasks",
}
u.RawQuery = buildTasksQuery(opt).Encode()
fmt.Println(u.String())
}
Цей підхід хороший тим, що ви можете тестувати buildTasksQuery окремо й бути впевненими, що:
- список перетворюється саме на повторювані параметри,
- рядок запиту екранується коректно,
- порожні значення не «засмічують» URL.
7. Типові помилки
Помилка №1: вважати, що query — це map[string]string.
Це здається природним, доки ви не зустрічаєте повторювані параметри. Потім починаються саморобні формати: «розділимо комою», «розділимо крапкою з комою», «а давайте ще екранувати коми»… і ви раптово винайшли свій маленький RFC, не отримавши за це навіть медалі. Правильна модель — url.Values як «ключ → список значень».
Помилка №2: використовувати Set там, де потрібен список.
Фільтри за тегами — класика. У коді все виглядає логічно: «ну я ж встановлюю tag». Але фактично ви щоразу перезаписуєте попереднє значення, і в запит летить тільки останній тег. Якщо параметр повторюється за контрактом, під час збирання використовуйте Add, а під час читання — vals["tag"].
Помилка №3: читати список через Get і не помічати втрати значень.
Get зручний, але він повертає лише перше значення. Якщо ви читаєте tag через Get, користувачеві здаватиметься, що він передав два теги, а сервер «чомусь ігнорує другий». Це особливо підступно тим, що помилки немає, паніки немає — просто логіка тихо неправильна.
Помилка №4: збирати URL руками через конкатенацію рядків.
Поки значення прості — лише літери й цифри, — усе виглядає робочим. Потім з’являється пробіл, &, +, #, % — і запит раптово починає означати щось інше. Саме тому query потрібно кодувати через url.Values.Encode(), а не через "?k=" + v.
Помилка №5: змінити q := u.Query() і забути записати назад у u.RawQuery.
Це суто технічна пастка, але трапляється постійно. Ви змінили q, вивели u.String(), а там усе по-старому — і ви починаєте підозрювати змову net/url. Насправді потрібно явно зробити u.RawQuery = q.Encode(). Це не примха, а частина контракту API: Query() дає вам зручний об’єкт для роботи, але фінальний рядок зберігається в RawQuery.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ