Содержание урока · 10 разделов
- Валидация запросов в go: chi не навязывает валидатор
- limit и offset: диапазоны пагинации
- Тело JSON после декодера: обязательные поля и длины
- go-playground/validator: теги вместо сотен if
- Ранняя валидация: middleware или хендлер
- Что проверяем → где → чем → ответ
- Чек-лист валидации запросов
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Тестировщица поймала за вечер три бага, и все три — про одно: сервис верил всему, что ему прислали. GET /notes/abc вернул 200 и пустую карточку — строка abc где-то в глубине кода тихо превратилась в ноль и сошлась с условием id равно нулю. GET /notes/-5 обрушился пятисотой: минус доехал до SQL-запроса, и база ответила ошибкой, которую не ждали. GET /notes?limit=999999999 уложил демо на показе заказчику — лимит без верхней границы ушёл в запрос к базе целым. А POST /notes с пустым title создал заметку-призрак, которую потом не могли найти ни в интерфейсе, ни в выгрузке. Роутер в каждом случае отработал честно: маршрут совпал, хендлер вызван, chi сделал всё, что умел. Сломалось дальше — на границе между «запрос дошёл» и «данным можно верить». Эта граница и есть валидация, и в chi её не пишут за вас: библиотека отдаёт вам сырые значения и право решать.
Валидация запросов в go: chi не навязывает валидатор#
chi — маленький роутер, а не фреймворк: он разбирает путь, кладёт параметры в контекст и вызывает хендлер. Проверять значения — работа приложения, и в этом chi валидацию не берёт на себя ни в каком виде: ни тегов, ни встроенных проверок, ни обязательной структуры запроса. Контраст с gin показательный: там валидатор go-playground встроен по умолчанию и гоняет теги binding до хендлера; в chi ноль магии — что хотите, то и проверяете. Входов данных в сервис два. Первый — параметры пути и query-строка: это всегда строки, разбор которых chi.URLParam из урока про URLParam оставил вам. Второй — тело запроса: JSON после декодера из урока про JSON API. Валидация запросов в go начинается с правила нулевого доверия: ничто из запроса не используется до проверки — ни число, ни длина, ни поле. Все отказы при этом уходят через renderError из урока про централизованные ошибки: единый формат, одинаковые коды, никакого разнобоя.
id: число с диапазоном
GOfunc getNote(w http.ResponseWriter, r *http.Request) {
id, err := strconv.Atoi(chi.URLParam(r, "id"))
if err != nil {
renderError(w, r, http.StatusBadRequest, "invalid_id", "id должен быть числом")
return
}
if id <= 0 {
renderError(w, r, http.StatusNotFound, "not_found", "заметки с таким id нет")
return
}
// id валиден — дальше обычная работа хендлера
}Atoi возвращает пару и проверяется до любого использования: нечисло и переполнение дают ошибку и ответ 400. Отдельная ветка для диапазона: Atoi спокойно съест -5, это синтаксически валидное число, — отрицательный и нулевой id отсекает бизнес-правило: нумерация с единицы, значит, заметки с таким id не существует, и честнее 404. Проверьте обе ветки curl-ом на локальном Go — песочница сайт сеть не поднимает.
Проверка: несовпадение имени параметра
В шаблоне маршрута написано {noteID}, а хендлер читает chi.URLParam(r, "id"). Что вернёт URLParam и что произойдёт дальше?
limit и offset: диапазоны пагинации#
Query-параметры приходят строками через r.URL.Query() — Get по отсутствующему ключу отдаёт пустую строку, и это отличает отсутствие от мусора. Три случая, и у каждого свой ответ. Параметра нет — легитимное «дайте как обычно»: подставляем дефолт, двадцать записей, и ответ 200. Параметр есть, но это мусор — abc, минус пять, число в двадцать знаков: клиент ошибся осознанно, отвечаем 400 invalid_limit и ничего не выбираем. Параметр валиден, но больше максимума — тут два лагеря: одни сжимают до верхней границы, другие отвечают отказом с текстом «максимум — сто». Верхняя граница обязательна в любом варианте: LIMIT 999999999 отправляет базу листать пол-таблицы, и один такой запрос при выгрузке в память кладёт процесс ровно так же, как толпа горутин из урока про предохранители. offset проверяется на неотрицательность той же веткой. Правило простое: отсутствие — не ошибка, мусор — ошибка, превышение — выбор команды, записанный в контракте.
Что вернёт Atoi на двадцатизначном id?
Прочитайте код и выберите, что он выведет:
// в пути пришло /notes/99999999999999999999
n, err := strconv.Atoi("99999999999999999999")
fmt.Println(n, err)Тело JSON после декодера: обязательные поля и длины#
Декодер из урока про JSON API гарантирует только синтаксис: Decode вернул nil — JSON корректен и поля легли в структуру. Осмысленность он не проверяет: объект с пустым title и email-строкой без собаки декодируется без единой ошибки, и в базу уйдёт мусор с честным 201. Второй рубеж — валидация полей после декодера: обязательные значения, длины, формат. Ручной путь — метод validate() []string на структуре: каждая проверка добавляет текст проблемы, пустой срез — тело годно. Тонкость с длинами: len() в go считает байты, и заголовок из шестидесяти русских букв по этой метке «влезет» в лимит сто двадцать — рунный utf8.RuneCountInString считает то, что видит пользователь. Формат email руками — ловушка: полный RFC-паттерн не пишется, strings.Contains со собачкой пропускает ab@, честный разбор адреса даёт mail.ParseAddress из стандартной библиотеки. Рукастый валидатор работает — и ровно на третьей структуре превращается в свалку условий. Что делать дальше — следующий блок.
Ручная валидация и ответ 422
GOtype noteInput struct {
Title string
Email string
Content string
}
func (in noteInput) validate() []string {
var problems []string
if strings.TrimSpace(in.Title) == "" {
problems = append(problems, "title: обязателен")
}
if utf8.RuneCountInString(in.Title) > 120 {
problems = append(problems, "title: не длиннее 120 символов")
}
if in.Email != "" && !strings.Contains(in.Email, "@") {
problems = append(problems, "email: нужен настоящий адрес")
}
return problems
}
func createNote(w http.ResponseWriter, r *http.Request) {
var in noteInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
renderError(w, r, http.StatusBadRequest, "invalid_json", "тело не декодируется")
return
}
if problems := in.validate(); len(problems) > 0 {
renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", strings.Join(problems, "; "))
return
}
// тело валидно — сохраняем
}Два рубежа в одном хендлере: Decode отвечает за синтаксис и битый JSON, validate() — за смысл полей. TrimSpace отсекает строки из одних пробелов, которые формально не пусты. Длина в рунах: len() посчитал бы байты, и заголовок из шестидесяти русских букв прошёл бы как короткий. strings.Contains — грубая прикидка, она честно допускает ab@ — настоящий разбор формата в примере умышленно оставлен библиотеке из следующего блока.
Половина команд отвечает 400 на любую плохую заявку, половина делит: синтаксис — 400, семантика — 422. История у 422 вебдовская, в RFC 9110 код живёт под именем Unprocessable Content, и обе политики формально законны. Я делю, как в уроке про централизованные ошибки: 400 — «не смог прочитать», 422 — «прочитал, но так нельзя» — фронтенд и мониторинг ветвят по ним логику по-разному. Граница одна: выберите раз и запишите в таблицу-контракт, потому что смена политики на середине жизни API ломает всех клиентов сразу.
go-playground/validator: теги вместо сотен if#
Ручной validate() масштабируется плохо: на каждый тип запроса — свой метод, правила дублируются между хендлерами, длина и обязательность расходятся по версиям. Стандарт де-факто — go-playground/validator: go get github.com/go-playground/validator/v10, и поля структуры получают второй тег рядом с json — validate. Внутри тега правила через запятую: required, min и max для длины, email для формата, oneof для фиксированного набора значений, gte и lte для чисел, omitempty — «если поле пустое, остальные правила пропустить». Создаётся валидатор один раз на программу: validator.New(validator.WithRequiredStructEnabled()) — экземпляр кэширует разбор структур, и создавать его на каждый запрос значит терять эту экономию. Проверка — validate.Struct(in): nil, InvalidValidationError на неструктуру или ValidationErrors — срез FieldError, где Field() даёт имя поля, Tag() — нарушенное правило. Той же библиотекой gin пользуется под именем binding — опыт переносится без переучивания. Граница честности: теги закрывают формат, но не бизнес — «промокод существует» или «автора можно редактировать» останутся кодом.
validate.Struct в хендлере
GOimport (
"strings"
"github.com/go-playground/validator/v10"
)
// поля noteInput получают теги рядом с json-разметкой:
// Title string validate:"required,min=3,max=120"
// Email string validate:"omitempty,email"
// Content string validate:"omitempty,max=10000"
var validate = validator.New(validator.WithRequiredStructEnabled())
func describe(err error) string {
var parts []string
for _, fe := range err.(validator.ValidationErrors) {
parts = append(parts, fe.Field()+": "+fe.Tag())
}
return strings.Join(parts, "; ")
}
// хендлер после Decode:
if err := validate.Struct(in); err != nil {
renderError(w, r, http.StatusUnprocessableEntity, "validation_failed", describe(err))
return
}Один экземпляр валидатора живёт на весь сервис: New кэширует разбор структур, а создание на каждый запрос сжигает эту экономию. Теги в настоящем Go-коде пишутся в бэктиках над полем — здесь они в комментарии, чтобы строка не спорила с разметкой страницы. describe достаёт из FieldError имя поля и тег: сообщение честное, но парсить его клиенту по-прежнему нельзя, для машины есть code validation_failed. Модуль не запускается в песочнице — пробуйте локальным Go: go get, потом запрос с пустым title.
Проверка: что вернёт validate.Struct
validate.Struct(in) вызван на структуре с пустым Title и email без собаки, оба поля размечены тегами. Что вернёт вызов?
Ранняя валидация: middleware или хендлер#
Разбирая любое правило, задайте вопрос: оно про формат или про ресурс? id — число больше нуля, limit — в диапазоне, offset — неотрицателен: эти формулировки одинаковы для всех маршрутов сервиса, и их место — в middleware. Слой пагинации парсит query один раз, кладёт готовую структуру в контекст запроса — механикой context.WithValue и r.WithContext из урока про контекст — и все хендлеры ниже читают числа через helper с ok-формой, не занимаясь разбором. Ресурсные правила — title обязателен, email настоящий, промокод жив — знают только про конкретный ресурс, и им место в хендлере рядом с логикой. Линия раздела грубая, но рабочая: если правило формулируется без слова «заметка», это слой; нужна модель ресурса — хендлер. Выигрыш не в красоте: пагинация написана один раз, новые эндпоинты не копируют clamp, а старые не расходятся в деталях — то, что в ручной версии случалось через спринт.
Пагинацию-слой написали с максимумом сто — и забыли про отчётный эндпоинт, которому нужны выгрузки по пять тысяч строк. Слой молча сжимал limit до сотни, отчёт отдавал первые сто строк, и полгода никто не замечал: отчёт-то есть. Молчаливый clamp — та же болезнь, что пустой URLParam: решение принято за ресурс без его ведома. Фиксирую так: сжимать лимит можно только с ответом, где ограничение видно честно — поля total и limit в теле, — либо отвечать 400 на превышение. Слой вправе ограничивать формат, но не вправе подменять смысл ответа.
Что проверяем → где → чем → ответ#
| Проверка | Где | Чем | Ответ |
|---|---|---|---|
| id из пути — не число или переполнение | хендлер, первая строка | strconv.Atoi | 400 invalid_id |
| id вне диапазона: ноль или отрицательный | хендлер | бизнес-правило | 404 not_found |
| limit и offset отсутствуют | middleware пагинации | дефолт и clamp | 200 — работаем дальше |
| limit и offset — мусор | middleware пагинации | strconv.Atoi | 400 invalid_limit |
| Битый JSON в теле | хендлер | json.Decoder | 400 invalid_json |
| Пустой title, длинный content, кривой email | хендлер | validate.Struct | 422 validation_failed |
Чек-лист валидации запросов#
- Ничему из запроса не верить: путь, query, заголовки и тело — строки и байты до проверки.
- URLParam сверяется с шаблоном по буквам; Atoi проверяется ошибкой, диапазон — бизнес-правилом.
- Query: отсутствие — дефолт, мусор — 400, превышение максимума — clamp с честным телом или явный отказ.
- Тело: Decode отвечает за синтаксис и 400, смысл полей — второй рубеж и 422.
- Общее для всех маршрутов — в middleware через контекст; ресурсное — в хендлере рядом с логикой.
- Теги validator закрывают формат, бизнес-правила остаются кодом; все отказы — через renderError.
Соберите валидацию хендлера
Восстановите три вызова: создание валидатора, проверку структуры и выход из ветки ошибки.
Соберите начало getNote
Расставьте строки: от сигнатуры до закрытия ветки ошибки на нечисловом id.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: валидация параметров и тел
Каркас говорит с клиентом одним языком: битый id — 400, кривые поля — 422, всё через единый рендер ошибок, мусор не доезжает до базы и до логики. Параметры пути и query проверяются на границе — общее в middleware, ресурсное в хендлере, — тело проверяется сразу после декодера, и фронтенд ловит одну структуру ошибки вместо трёх. Дальше по курсу вернёмся к слоям: порядок middleware решает, что запрос успеет пройти до хендлера и что увидит строка лога — а неправильный порядок умеет делать 500 в логе зелёным 200. Валидация пишется один раз, а живёт в каждом ответе: держите её в таблице-контракте и тестах, а не в памяти последнего, кто правил хендлер.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- chi проверяет только маршрут: валидация запросов в go — работа хендлеров и middleware, а не роутера.
- URLParam возвращает строки: Atoi с проверкой ошибки, диапазон — бизнес-правило; чужое имя параметра — молчаливая пустота.
- Query: отсутствие — дефолт, мусор — 400, превышение максимума — clamp с честным телом или явный отказ.
- Декодер гарантирует синтаксис JSON; смысл полей — второй рубеж: обязательные поля, длины в рунах, формат адреса.
- Ручная валидация ветвится быстро; go-playground/validator закрывает формат тегами, FieldError даёт поле и правило.
- Форматные правила — в middleware через контекст, ресурсные — в хендлере; слой не подменяет смысл ответа.
- 400 — не смогли прочитать, 422 — прочитали и отказали; оба ответа — через renderError в едином формате.
Домашнее задание
Добавьте в каркас заметок валидацию по всей глубине: id через Atoi с веткой диапазона, middleware пагинации с дефолтом, максимумом и контекстом, теги validate на noteInput и ответ 422 через renderError. Прогоните curl-ом четыре запроса: /notes/abc, /notes/-5, /notes?limit=abc и POST с пустым title — каждый должен отвечать своим честным кодом из таблицы урока.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему chi не проверяет поля сам, как gin?
chi — минималистичный роутер: маршрутизация, URLParam, группы и middleware; валидация — зона приложения. В gin тот же go-playground/validator встроен по умолчанию под тегом binding, поэтому разница видна сразу. В chi валидатор подключается руками — той же библиотекой, просто без магии фреймворка и с прямым вызовом validate.Struct в хендлере.
Что отвечать на отрицательный id — 400 или 404?
Синтаксически -5 — валидное число, Atoi ошибку не вернёт. Дальше решает контракт ресурса: если нумерация начинается с единицы, заметки с id -5 не существует — честнее 404 not_found. Если отрицательные значения для ресурса бессмысленны в принципе, отвечайте 400. Главное — зафиксировать выбор в таблице статусов и не менять его между хендлерами.
Можно ли валидировать всё тело прямо в middleware?
Технически да: слой читает тело, проверяет и передаёт дальше, но тело придётся буферизовать и подменять r.Body, а правила у каждого ресурса свои. Разумная граница: формат и общие параметры — в middleware, смысл полей — в хендлере. Иначе слой превращается в свалку условий, которая растёт с каждым эндпоинтом и конфликтует сама с собой.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Порядок middleware: почему он важен
- Subrouters и структура большого сервиса
- Тестирование хендлеров: httptest и chi
- Аутентификация: JWT и сессии как middleware
- Graceful shutdown: выключаемся вежливо
- Централизованные ошибки: errorHandler и NotFound
- CORS: чиним браузерные запросы
- Timeout и Throttle: защита от перегрузки
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter