Содержание урока · 10 разделов
REST-каркас из прошлого урока обещал карточку заметки по GET /notes/{id} — теперь нужна механика: как хендлер узнаёт, какая именно заметка запрошена. Адрес приходит строкой /notes/42, и где-то в нём спрятан id. Роутер chi разбирает его ещё до вашего хендлера: сегмент {id} в шаблоне маршрута говорит «здесь будет произвольное значение», роутер сопоставляет фактический путь с шаблоном и складывает совпадения в контекст запроса. Достать значение оттуда — одна функция, chi.URLParam. Звучит просто, и это правда просто; сложности начинаются на границах. Параметр — всегда строка, значит рядом нужен strconv.Atoi и обработка ошибки. Имя в URLParam должно совпасть с именем в шаблоне байт в байт — иначе вернётся пустая строка: не exception, не ошибка компиляции, а молчаливая пустота, которая взорвётся дальше по цепочке. А ещё есть query-строка, которая выглядит похожей, но живёт по другим правилам и достаётся другими средствами. Этим границам и посвящён урок.
Параметры пути в chi: /notes/{id}#
Шаблон маршрута пишется фигурными скобками: r.Get("/notes/{id}", getNote). Имя внутри скобок — произвольный идентификатор: {id}, {noteID}, {slug} — это метка сегмента, а не переменная Go. Когда приходит запрос GET /notes/42, chi сопоставляет путь с шаблоном сегмент за сегментом, находит несовпадающую позицию и запоминает: id равно 42. Хендлер достаёт значение функцией chi.URLParam(r, "id") из контекста запроса, куда роутер положил разбор. Два жёстких правила. Первое: {id} ловит ровно один сегмент — до следующего слэша; /notes/42/comments в этот маршрут не влезет, для хвостов произвольной длины есть маршруты со звёздочкой, о них дальше по курсу. Второе: имя в chi.URLParam обязано совпасть с именем в шаблоне — не по смыслу, а по буквам: "ID" и "id" — разные метки. Chi не валидирует имена заранее и не ругается на незнакомое: вернёт пустую строку, о последствиях — отдельным блоком ниже. В одном маршруте параметров может быть несколько: /users/{userID}/notes/{noteID} — легальный шаблон с двумя метками.
Достаём id из пути
GOr.Get("/notes/{id}", getNote)
func getNote(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
w.Write([]byte("запрошена заметка " + id))
}Запрос GET /notes/42 напечатает «запрошена заметка 42», GET /notes/7 — про семёрку. Хендлер один на все заметки, различие лежит в параметре — в этом сила шаблонов: пятьдесят заметок не превращаются в пятьдесят регистраций. Значение достаётся ровно по имени метки из шаблона: URLParam(r, "id") соответствует {id}. Пока id используется как строка, код безупречен; проблемы начинаются, когда из строки делают число, — об этом следующий блок.
Параметры — всегда строки: strconv.Atoi#
URLParam возвращает string, всегда, без исключений. В пути нет типов: 42 — это символы «4» и «2», и пока вы только вставляете их в ответ или лог, строка устраивает. Но база ждёт int64, арифметика сравнения версий ждёт int — значит, нужен разбор: strconv.Atoi переводит строку в int и честно возвращает ошибку. Ошибка здесь не украшение: пользователь вольен вписать в адрес что угодно — /notes/hello, /notes/99999999999999999999, /notes/-5 — и все три случая Atoi отвергнет: первый не число, второй не влезает в int, третий синтаксически валиден, но пусть бизнес-логика решает сама. Правило каркаса: разбор параметра — первая строка хендлера, и на ошибке хендлер отвечает 400 Bad Request с внятным текстом, а не падает и не лезет в базу с мусором. Код ответа важен: 400 говорит «ошибка на стороне клиента», и пейджер мониторинга не будет дёргать дежурных из-за опечатки пользователя в адресе.
Число с проверкой
GOfunc getNote(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
n, err := strconv.Atoi(id)
if err != nil {
http.Error(w, "id должен быть числом", http.StatusBadRequest)
return
}
// n — int, дальше работаем с числом
fmt.Fprintf(w, "заметка %d", n)
}Atoi возвращает пару: число и ошибку. Ошибка проверяется до любого использования n — мусор не проходит дальше в сервисный слой. http.Error ставит статус и печатает текст одной строкой: для прототипа достаточно; когда появится единый JSON-формат ошибок, это место заменит его, а проверка останется на том же месте. strconv и fmt — импорты стандартной библиотеки, chi тут уже ни при чём.
Маршрут объявлен как /notes/{id}, а в хендлере написано chi.URLParam(r, "ID") — с большой буквы. Chi не вернёт ошибку, не запаникует и не упадёт: он вернёт пустую строку, потому что «метки с таким именем нет» и «метка есть, но пуста» для него неразличимы. Дальше по цепочке strconv.Atoi("") вернёт ошибку, а если её проверку забыли, — в базу уходит id 0, и прод отвечает 500. Тесты при этом зелёные: расхождение двух имён живёт в коде и не проявляется, пока путь не встретится с настоящим запросом. Лекарство дешёвое: имя параметра выносится в общую константу для шаблона и для URLParam — тогда расхождение станет невозможным.
Путь или query: /notes/42 против /notes?page=2#
Параметры пути и query-строка выглядят похоже — и то, и другое «данные в адресе», — но назначения у них разные, и путаница между ними портит API быстрее, чем любые опечатки. Параметр пути определяет, какой ресурс запрошен: /notes/42 — конкретная заметка, её идентичность, и другой заметки по этому адресу быть не может. Query-строка фильтрует или оформляет представление коллекции: /notes?page=2&tag=go — всё та же страница «списка заметок», просто в другом разрезе. Практический тест для дизайна API: если убрать параметр и адрес перестаёт указывать на конкретный ресурс — это путь; если адрес всё равно ведёт на тот же список — это query. Закладки, кэши и журналы читаются легче, когда ресурсы живут в пути, а настройки вида — после знака вопроса. Разборы в chi тоже разные: URLParam достаёт метки шаблона, а query читается средствами net/http из r.URL — роутер тут не нужен, об этом ниже.
Параметр пути или query-строка#
| Критерий | Параметр пути: /notes/42 | Query: /notes?page=2 |
|---|---|---|
| Роль | идентификатор ресурса | фильтр или оформление представления |
| Обязательность | без него адрес не указывает на ресурс | можно убрать — смысл адреса сохранится |
| Достаётся в chi | chi.URLParam(r, "id") | r.URL.Query().Get("page") |
| Типичные примеры | {id}, {slug}, {userID} | page, limit, sort, q |
| Кэши и закладки | адрес ресурса стабилен | варианты одного и того же ресурса |
Проверка: где место параметра
Нужно отсортировать список заметок по дате: какой дизайн адреса корректен?
Query-строка: r.URL.Query()#
Для query-строки в chi нет собственной функции — и правильно: query разбирает стандартная библиотека, адрес запроса и так принадлежит net/http. r.URL.Query() возвращает map[string][]string — ключи и значения, разложенные по знакам & и =; значения — срез, потому что один ключ может повторяться. На практике почти всегда берут метод Get: r.URL.Query().Get("page") возвращает первое значение или пустую строку, если ключа нет, — то же молчаливое поведение, что у URLParam, и те же проверки нужны следом. Разбор query нужен ради дефолтов: пустая page — значит 1, пустой limit — значит 20. Из строки снова делают число той же strconv.Atoi с той же обработкой ошибки. Отдельно запомните: URLParam на query-ключи не работает никогда — метки живут только в шаблоне пути, и chi.URLParam(r, "page") вернёт пустоту, даже если адрес содержит ?page=2. Промах не репортится — это самая коварная разновидность опечатки имени.
Пагинация через query
GOr.Get("/notes", listNotes)
func listNotes(w http.ResponseWriter, r *http.Request) {
page, err := strconv.Atoi(r.URL.Query().Get("page"))
if err != nil || page < 1 {
page = 1
}
fmt.Fprintf(w, "список заметок, страница %d", page)
}Отсутствующий или битый page здесь не ошибка, а повод для дефолта: список без параметров показывает первую страницу, и 400 здесь только мешал бы. Поэтому err не отдаётся наружу, а сворачивается в page 1. Отрицательные значения ловит вторая половина условия — query-параметры заслуживают проверки так же, как параметры пути.
Проверка: вложенные параметры
Зарегистрирован r.Get("/users/{userID}/notes/{noteID}", h); пришёл запрос GET /users/7/notes/3. Что вернёт chi.URLParam(r, "userID")?
Вложенные параметры и вложенные ресурсы#
Шаблон /users/{userID}/notes/{noteID} описывает классическую вложенность: заметки конкретного пользователя. Имена обязаны различаться — два {id} в одном маршруте превратили бы разбор в лотерею, — поэтому метки зовут по смыслу: userID и noteID. В хендлере обе метки достаются парой вызовов chi.URLParam, а логика «заметка 3 принадлежит пользователю 7» проверяется в сервисном слое: если заметка у другого владельца, это 404, а не 200 с чужими данными. Вложенность — удобный приём, но не бесплатный: цепочка /users/7/notes/3/comments/9 растёт в глубину быстрее, чем в полезность, и на четвёртом уровне шаблон проще развернуть в отдельный ресурс. Для сравнения: в Gin те же метки пишутся двоеточием — /users/:userID — и достаются функцией c.Param; механика совпадает вплоть до опечаток, различается синтаксис. Тот же разбор на другом фреймворке — в уроке Gin про параметры.
Что напечатает хендлер?
Прочитайте код и выберите, что он выведет:
r.Get("/users/{userID}/notes/{noteID}", func(w http.ResponseWriter, r *http.Request) {
u := chi.URLParam(r, "userID")
n := chi.URLParam(r, "noteID")
fmt.Fprintf(w, "user=%s note=%s", u, n)
})
// запрос: GET /users/7/notes/3Соберите разбор id
Восстановите шаблон маршрута, доставание параметра, разбор числа и код ошибки.
Правила работы с параметрами#
- Имя в шаблоне и в chi.URLParam совпадает байт в байт: "id" и "ID" — разные метки; имя выносится в константу.
- Параметр — всегда string: перед арифметикой нужен strconv.Atoi с проверкой ошибки.
- Ошибка разбора id — 400 Bad Request, а не 500: в кривом адресе виноват клиент.
- Query-параметры достаются r.URL.Query().Get и через URLParam не видны никогда.
- В одном маршруте — несколько меток с уникальными именами: /users/{userID}/notes/{noteID}.
Соберите хендлер по строкам
Расставьте строки хендлера по порядку: от получения параметра до ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Быстрая самопроверка: пройдитесь по файлу маршрутов и по хендлерам — каждая метка из фигурных скобок должна встретиться в chi.URLParam с тем же именем. Запуск и ручные клики этого не показывают: URLParam молчит и на правильном коде. Сверка занимает минуту и убирает целый класс 500, которых нет в тестах. Прогонку вручную делайте локальным Go: песочница сайта сеть не видит.
Карточки: параметры и query
Параметры пути разобраны, query читается с дефолтами — каркас /notes научился работать с конкретной заметкой, а не только с коллекцией. Следующая ступень — middleware: лог, восстановление после паники и счётчики навешиваются на цепочку одним r.Use, и хендлеры перестают заниматься служебной работой. А если однажды {id} станет тесным — понадобится забрать весь хвост пути, например имя файла с вложенными папками, — это тема урока про wildcards: звёздочка ловит остаток пути целиком. Для практики соберите каркас из прошлого урока и хендлеры из этого в один файл: локальный Go запустит его как есть, и curl-ом видно всё — и карточку по /notes/42, и 400 на /notes/hello.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Шаблон {id} ловит один сегмент пути; значение достаётся chi.URLParam(r, "id") ровно по имени метки.
- Параметры — всегда строки: числа разбираются strconv.Atoi с обработкой ошибки и ответом 400 на мусор.
- Несовпадение имени в шаблоне и в URLParam возвращает пустую строку молча — имя параметра выносится в общую константу.
- Query-строка не видна через URLParam: r.URL.Query().Get("page") из net/http, с дефолтами и проверкой.
- Путь идентифицирует ресурс (/notes/42), query оформляет представление (/notes?page=2) — от этого зависят кэши, закладки и логи.
- Вложенный маршрут /users/{userID}/notes/{noteID} разбирается по уникальным именам меток.
- Владение вложенным ресурсом проверяется в сервисном слое: чужая заметка — 404, а не утечка данных.
Домашнее задание
Доработайте каркас заметок: в getNote разберите id через strconv.Atoi и отвечайте 400 на нечисловой путь, а в listNotes добавьте query-параметры page и limit с дефолтами 1 и 20. Проверьте curl-ом адреса /notes/abc, /notes/-3 и /notes?page=2&limit=5 и убедитесь, что ответы совпадают с планом.
Личные заметкиПусто
Вопросы и ответы по уроку
Что вернёт chi.URLParam, если имени нет в маршруте?
Пустую строку — без ошибки и без паники: «метки нет» и «метка есть, но пуста» для chi неразличимы. Поэтому имя берут из общей константы для шаблона и вызова, а пустоту после URLParam проверяют так же серьёзно, как ошибку strconv.Atoi.
Почему URLParam не читает ?page=2?
Метки шаблона живут только в пути: chi сопоставляет сегменты до слэшей, и query-строка в маршрутизацию не входит. Query разбирает стандартная библиотека — r.URL.Query().Get("page"). Перепутать их проще всего в спешке: промах выглядит как пустая строка, а не как ошибка.
Как достать несколько параметров из одного маршрута?
Дать меткам уникальные имена: /users/{userID}/notes/{noteID}, затем два вызова chi.URLParam — по одному на метку. Имена должны различаться, потому что разбор идёт по позициям сегментов, а достаётся каждая пара по строковому имени из контекста запроса.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter