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 означает «оставь только одно значение».
4. Чтение списков: 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, потому что вы молча потеряете данные.
Потеря данных в фильтрах — это один из самых неприятных багов: пользователь передал два тега, сервер применил один, и всё выглядит «как будто работает», только результаты странные.
5. Экранирование и Encode()
Теперь к самой «магической» части. В какой-то момент каждый новичок пишет что-то вроде:
raw := "/api/v1/tasks?q=" + query
…и чувствует себя победителем. До первого запроса с пробелом, амперсандом или плюсиком.
Проблема в том, что query‑строка использует специальные символы:
- & разделяет параметры,
- = отделяет ключ от значения,
- ? отделяет путь от query,
- # начинает fragment (он вообще не должен попадать в query),
- пробелы, +, % и много других символов имеют особые правила.
Если вы вставляете значение «как есть», оно может «разорвать» всю структуру query. Например, значение go lang & http содержит &, а это разделитель параметров. Если склеить руками, получится каша.
Здесь и нужен percent-encoding (URL‑экранирование): опасные символы превращаются в безопасный текстовый вид %XX. В Go стандартная библиотека умеет делать это за вас через url.Values.Encode().
Есть очень показательный пример: стандартный workflow 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().
Это один из тех случаев, когда «меньше кода» одновременно означает «меньше багов».
6. Нюансы, которые экономят часы отладки
В этом разделе собраны детали, которые часто всплывают не в момент написания кода, а когда вы уже уверены, что всё идеально… и вдруг фильтры ведут себя странно. Это нормальная часть взросления любого 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(), не делайте ручного экранирования значений.
7. Собираем 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.
8. Типичные ошибки
Ошибка №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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ