JavaRush /Курси /Go SELF /Повторювані параметри та екранування — url.Values

Повторювані параметри та екранування — url.Values

Go SELF
Рівень 57 , Лекція 4
Відкрита

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 (ідея)
done=true
{"done": ["true"]}
tag=go&tag=http
{"tag": ["go", "http"]}
q=go+lang+%26+http
{"q": ["go lang & http"]}
limit=20&offset=40
{"limit":["20"], "offset":["40"]}

Зверніть увагу: навіть «звичайні» параметри технічно теж є списками, просто списками довжиною 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().

Є дуже показовий приклад: стандартний ланцюжок ParseQueryEncodeParseQuery має зберігати структуру параметрів.

Подивімося на екранування в дії:

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.

1
Опитування
URL і query-параметри, рівень 57, лекція 4
Недоступний
URL і query-параметри
URL і query-параметри
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ