32. net/url. URL, query parameters и escaping
Архитектура парсера и структура url.URL
В большинстве скриптовых языков программирования URL часто воспринимается как простая текстовая строка, которую разработчики пытаются склеивать через конкатенацию или препарировать с помощью регулярных выражений. Это неминуемо открывает ящик Пандоры: от мелких багов с лишними или потерянными слешами до критических уязвимостей Directory Traversal и SSRF.
В Go URL — это не просто строка, а строго типизированная структура, разделяющая компоненты на логические поля. Пакет net/url предоставляет парсер, который работает как детерминированный конечный автомат, а не через регулярные выражения. Это гарантирует предсказуемое время выполнения O(n) и защиту от ReDoS-атак при обработке злонамеренно длинных или malformed строк.
Структура url.URL в памяти выглядит так:
type URL struct {
Scheme string
Opaque string // Специфично для непрозрачных схем (mailto:, data:)
User *Userinfo // username:password
Host string // host:port (включая IPv6 в квадратных скобках)
Path string // Декодированный путь
RawPath string // Закодированный путь (если отличается от Path)
ForceQuery bool // Принудительный знак вопроса без параметров
RawQuery string // Сырой запрос (a=1&b=2)
Fragment string // Декодированный фрагмент
RawFragment string // Закодированный фрагмент
}
Разделение на Path и RawPath, а также Query и RawQuery — ключевая архитектурная особенность. Go сохраняет оригинальные закодированные строки в полях Raw*, чтобы при генерации String() можно было восстановить URL без потери информации, но предоставляет декодированные версии для удобной работы в бизнес-логике. Например, если путь содержал закодированный слэш %2F, декодирование превратит его в /, что сломает обратную сборку URL. Поэтому RawPath заполняется только тогда, когда прямое кодирование Path не совпадает с оригинальной строкой.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
A["Вызов url.Parse rawURL"]:::process --> B["Сканирование схемы"]
B --> C["Поиск :// и извлечение Userinfo"]
C --> D["Парсинг пути Path и RawPath"]
D --> E["Проверка на наличие ?"]
E -->|"Да"| F["Копирование в RawQuery<br>Ленивый парсинг в map"]
E -->|"Нет"| G["Пустой RawQuery"]
F --> H["Проверка фрагмента #"]
G --> H
H --> I["Возврат структуры url.URL"]
Under the hood: url.Values и механизмы экранирования
url.Values — это просто псевдоним:
type Values map[string][]string
Почему []string, а не string? Потому что спецификация URL допускает дубликаты ключей (?tag=go&tag=backend). Стандартная библиотека корректно агрегирует их в слайс, избегая потери данных при конкурентной или последовательной обработке.
Методы Add, Set, Del и Encode работают напрямую с этой мапой. Encode() генерирует строку в формате application/x-www-form-urlencoded, экранируя специальные символы через url.QueryEscape.
Функция QueryEscape заменяет все символы, кроме букв, цифр и -_.~, на %XX. Под капотом она использует предварительное выделение буфера make([]byte, 3*len(s)) в худшем случае, чтобы избежать промежуточных реаллокаций при росте строки. Парсинг работает строго побайтово, без рефлексии.
Mechanical Sympathy: Аллокации, GC и оптимизация горячих путей
Работа с URL в высоконагруженных прокси, роутерах или HTTP-клиентах может стать источником скрытых аллокаций:
url.Parseвыделяет новую структуруURLи строки для каждого компонента.u.Query()парситRawQueryв новую мапу. Если вызывается многократно, создается множество временных мап.u.Query().Encode()создает новую строку с экранированием.
Оптимизация для Hot Paths:
Если вы знаете, что параметры не изменятся, кэшируйте url.URL или url.Values. Если вам нужно только проверить наличие ключа, не вызывайте Query() полностью — используйте строковые поиск и url.PathUnescape на сырой подстроке, либо работайте напрямую с r.URL.RawQuery.
// ❌ Плохо в цикле обработки 10k RPS
q := r.URL.Query() // Аллокация мапы + копирование строк
val := q.Get("token") // Поиск в мапе
newURL := r.URL.String() // Форматирование строки
// ✅ Хорошо: кэширование или работа с RawQuery
// Если параметры статичны, парсите один раз и сохраняйте в контексте запроса
// или используйте пулы значений для временных операций
Идиоматичные паттерны и безопасное использование
1. + против пробела: стандарт application/x-www-form-urlencoded
В query-строках пробел кодируется как +. При декодировании через Query().Get() + автоматически превращается в пробел. Если вам нужен буквальный символ +, его нужно экранировать как %2B. url.PathEscape не заменяет + на %2B, так как в пути пробел кодируется только как %20.
q := url.Values{}
q.Set("query", "hello world")
fmt.Println(q.Encode()) // query=hello+world
q2 := url.Values{}
q2.Set("formula", "A+B")
fmt.Println(q2.Encode()) // formula=A%2BB
2. Безопасное логирование: Redacted()
Никогда не логируйте u.String() в production. URL часто содержат токены, пароли или чувствительные query-параметры. Метод u.Redacted() заменяет Userinfo на xxxxx и очищает RawQuery, предотвращая утечку секретов в логи.
3. Конкатенация путей без дыр
Для безопасного соединения базового URL и относительного пути используйте url.JoinPath (Go 1.19+). Он корректно обрабатывает слеши, кодирует компоненты и предотвращает Directory Traversal.
base, _ := url.Parse("https://api.example.com/v1/")
full, _ := base.JoinPath("users", "profile")
// Результат: https://api.example.com/v1/users/profile
Вопросы с собеседований и сравнение с экосистемами
| Сценарий | Проблема | Решение |
|---|---|---|
Двойное экранирование %%20 |
url.PathUnescape не распакует валидно, вернет ошибку или оставит как есть. |
Валидируйте входные данные на клиенте. В Go используйте url.QueryUnescape с проверкой err != nil. |
IPv6 в URL http://[::1]:8080 |
Парсер корректно обрабатывает скобки. Ручная конкатенация ломает порт. | Всегда используйте url.URL.Host = net.JoinHostPort(ip, port) для сборки. |
u.RawQuery = "a=1" vs q.Set("a","1") |
Прямая запись в RawQuery инвалидирует логику кэширования и может сломать парсер. |
Используйте методы Query(), Set(), Encode() и только потом присваивайте u.RawQuery = q.Encode(). |
url.ParseRequestURI vs url.Parse |
Parse допускает относительные URL без схемы. ParseRequestURI требует абсолютного пути, как в HTTP-запросах. |
Для HTTP-хендлеров используйте url.ParseRequestURI или r.URL (уже распарсено сервером). |
Сравнение с экосистемами других языков
| Язык | Механизм | Особенности в сравнении с Go |
|---|---|---|
| Python | urllib.parse |
Функциональный API. Возвращает ParseResult. Кэширует ничего. Много аллокаций при split/encode. |
| Java | java.net.URI / URLEncoder |
URI иммутабелен. Строгая валидация, но verbose API. Требует явного указания Charset. |
| PHP | parse_url / http_build_query |
Возвращает массив. + декодируется в пробел автоматически. Меньше контроля над Raw/Decoded разделением. |
| Node.js | URL / querystring |
URL следует WHATWG стандарту. Быстрый, но требует ручного управления параметрами через URLSearchParams. |
| Go | net/url |
Баланс между производительностью и безопасностью. Ленивый парсинг query, четкое разделение Raw/Decoded, встроенный JoinPath, Redacted. |
Итог
url.URLразделяет сырые и декодированные поля.Query()парситRawQueryлениво вmap[string][]string.- В query-строках
+означает пробел,%2B— плюс.PathEscapeне кодирует+,QueryEscape— кодирует. url.URLмутируем. ИспользуйтеClone()для копирования,Redacted()для логов,JoinPathдля безопасной конкатенации.- В горячих путях избегайте многократного вызова
Query()иEncode(). Кэшируйте результаты или используйте кастомные zero-allocation парсеры. - Для сборки хостов с IPv6 всегда применяйте
net.JoinHostPort.
Разобрав адресацию и параметры, мы переходим к сердцу веб-коммуникации. Как Go управляет HTTP-запросами, маршрутизацией, мидлварами и жизненным циклом соединений? В следующей статье мы глубоко погрузимся в стандартный веб-стек: 33. net/http. HTTP-клиент и HTTP-сервер.