Содержание урока · 13 разделов
- Зачем нужна привязка: gin binding одним вызовом
- ShouldBindJSON против BindJSON: кто пишет 400
- Что лежит в err.Error()
- Теги binding и валидация gin: required, min, max, email
- Семейство биндеров: источник, тег имён, авто-400
- ShouldBindQuery: тег form, дефолты и типы
- ShouldBindUri: тег uri и параметры пути
- c.ShouldBind: Gin сам выбирает биндер
- Опциональные поля: указатели и omitempty
- Свои сообщения об ошибках: три стратегии
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Зачем нужна привязка: gin binding одним вызовом#
В уроке про первый сервер мы принимали POST-тело в map[string]any: поле пришло или не пришло — и проверять это приходилось вручную. Привязка (binding) — следующий шаг: gin binding декодирует запрос сразу в вашу структуру и заодно прогоняет её через валидатор по тегам. Поля, типы, обязательность и ограничения живут в одном объявлении структуры, а хендлер остаётся коротким: вызвать ShouldBindJSON, разобрать ошибку, работать с готовыми данными. Ручные проверки — пустая строка, нечисловой page, отрицательный priority — переезжают из тела хендлера в теги структуры. Трекер задач продолжаем на входной структуре TodoInput: на ней пройдём JSON-тело, query-фильтры списка и параметры пути. Ручную конвертацию строк через strconv, знакомую по прошлому уроку, ShouldBindQuery и ShouldBindUri снимут с вас полностью.
ShouldBindJSON против BindJSON: кто пишет 400#
В Gin два семейства методов привязки, и разница между ними — только в обработке ошибки. ShouldBindJSON привязывает и возвращает error: ответ клиенту пишете вы сами, со своим форматом и своим статусом. BindJSON — это сокращение для MustBindWith(obj, binding.JSON): при ошибке метод сам отвечает статусом 400 (а при превышении лимита размера тела — 413), кладёт ошибку в c.Errors, прерывает цепочку хендлеров через Abort и всё равно возвращает error. Паники при этом нет — это распространённый миф из старых статей; но если после BindJSON вы попробуете ответить своим c.JSON(http.StatusOK, ...), заголовки уже отправлены, и в консоли появится [WARNING] Headers were already written. Правило простое: Should-семейство — когда формат ошибок ваш (JSON API с массивом ошибок), Bind-семейство — для черновиков и внутренних утилит, где голый 400 без тела допустим. У MustBindWith есть ярлыки на все форматы: BindXML, BindYAML, BindTOML, BindQuery, BindUri, BindHeader, в свежих версиях ещё и BindPlain.
POST /todos: TodoInput с тегами binding
GOtype TodoInput struct {
Title string `json:"title" binding:"required,max=140"`
Priority int `json:"priority" binding:"min=0,max=3"`
Done bool `json:"done"`
}
r.POST("/todos", func(c *gin.Context) {
var in TodoInput
// Декодируем JSON-тело и сразу исполняем теги binding.
if err := c.ShouldBindJSON(&in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"todo": in})
})Тег у поля двойной: имя json для декодера и binding для валидатора, правила внутри разделяются запятой. Title обязан быть непустым и не длиннее 140 символов; priority — число от 0 до 3; done без тега binding — любое. Пустой title, priority=-1 или битый JSON уедут в ветку ошибки со статусом 400, валидный запрос вернёт 201 Created и эхо структуры. ShouldBindJSON принимает указатель — &in: привязывать некуда, если передать структуру по значению.
Что лежит в err.Error()#
err.Error() у ошибки валидации — техническая строка вида Key: 'TodoInput.Title' Error:Field validation for 'Title' failed on the 'required' tag. Несколько нарушений валидатор склеивает переводами строк: title пуст, у description превышен максимум — обе строки приедут одним error. Для API это некрасиво, но для отладки честно: сразу видно поле и тег, на котором споткнулись. Отдельный класс — ошибки разбора, до валидации дело просто не доходит: пустое тело вернёт EOF, битый JSON — invalid character ... looking for beginning of value. Обе ветки ловятся одинаково — if err != nil, ответ 400; и семантика у статуса общая: Bad Request честен в обоих случаях, виноват клиент. Разделять их и собирать человекочитаемые сообщения будем ниже — через errors.As и validator.ValidationErrors; там же вытащим имя поля и тег программно.
Помощник ответа об ошибке привязки
GO// respondBindError — единый ответ на любую ошибку привязки.
func respondBindError(c *gin.Context, err error) {
// Ошибки валидации: соберём по одному сообщению на поле.
var verrs validator.ValidationErrors
if errors.As(err, &verrs) {
msgs := make([]string, 0, len(verrs))
for _, e := range verrs {
// e.Field() — имя поля, e.Tag() — споткнувшийся тег.
msgs = append(msgs, e.Field()+": "+msgForTag(e.Tag()))
}
c.JSON(http.StatusBadRequest, gin.H{"errors": msgs})
return
}
// Иначе — ошибка разбора: битый JSON, EOF, нечисловой параметр.
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// msgForTag — карта тегов в короткие человеческие фразы.
func msgForTag(tag string) string {
switch tag {
case "required":
return "обязательное поле"
case "max":
return "превышен лимит длины или значения"
case "min":
return "значение меньше допустимого"
default:
return "не прошло проверку"
}
}Импорты: errors из стандартной библиотеки и github.com/go-playground/validator/v10 — та же библиотека, что крутится внутри Gin. errors.As достаёт ValidationErrors, если ошибка именно валидационная; у JSON-биндера это всегда так, когда декодирование прошло успешно. Клиент получает массив errors по полям вместо простыни Key: '...' — фронтенду проще подсветить нужные инпуты.
Теги binding и валидация gin: required, min, max, email#
Под капотом теги binding исполняет go-playground/validator/v10 — библиотека, о которой мы упоминали ещё в знакомстве с Gin. Gin инициализирует её один раз вызовом SetTagName("binding"), поэтому тег в Gin называется binding, а не validate: в Echo тот же валидатор стоит по умолчанию с тегом validate, и структуры между проектами придётся переписывать. required требует не-нулевое значение: непустая строка, число не ноль. min и max полиморфны: для строк проверяют длину в рунах, для чисел — значение, для срезов и карт — количество элементов. binding:"min=1,max=140" на строке — длина от 1 до 140 символов; те же правила на int — значение от 1 до 140. Другие частые теги: email проверяет формат адреса, oneof ограничивает набором значений (oneof=open done), gte и lte — сравнения. У валидатора есть собственный omitempty: правило после него пропускается, если поле нулевое — не путайте с json:"...,omitempty", который работает только при сериализации ответа. Про валидацию gin подробно будет отдельный урок, здесь — база, без которой привязка не собирается.
required проваливается на любом нулевом значении — и false для bool тоже ноль. Поставите binding:"required" на Done bool, и ни один запрос с "done": false не пройдёт: клиент будет обязан прислать true. На необязательные логические поля required не вешают; если нужно отличать «не прислали» от false — берите *bool и проверяйте на nil.
Семейство биндеров: источник, тег имён, авто-400#
| Метод | Источник данных | Тег имён полей | Валидация binding | Авто-400 |
|---|---|---|---|---|
| c.ShouldBindJSON | JSON-тело | json | да | нет |
| c.BindJSON | JSON-тело | json | да | да: 400 и abort |
| c.ShouldBind | по Content-Type; у GET — form | json, xml или form | да | нет |
| c.ShouldBindQuery | query-строка | form | да | нет |
| c.ShouldBindUri | параметры пути :id | uri | да | нет |
| c.ShouldBindHeader | HTTP-заголовки | header | да | нет |
| json.Unmarshal вручную | JSON-тело | json | нет — сами | нет |
ShouldBindQuery: тег form, дефолты и типы#
Query-параметры из прошлого урока приходят строками, и каждый c.Query("limit") со следующим за ним strconv — ручная работа. ShouldBindQuery делает это одним вызовом: имена полей берёт из тега form, типы конвертирует сама (int, bool, float, слайсы по повторяющимся ключам), а после заполнения запускает ту же валидацию binding — required на query-поле сработает так же, как на JSON-теле. Отсутствующий ключ поле не трогает: останется нулевое значение. В теге form можно задать дефолт — form:"limit,default=20": подставится, только если параметра нет вовсе; а вот ?limit= молча превратится в ноль, и провалится уже тег binding:"min=1". Чтобы различать «ключа нет» и «ключ пуст», берите указатель: *int останется nil при отсутствии и укажет на число при наличии — об этом ниже. Имя в form-теге чувствительно к регистру: form:"page" не поймает ?Page=2, в отличие от JSON-декодера, который матчит поля без учёта регистра.
Фильтр списка: TodoFilter через ShouldBindQuery
GOtype TodoFilter struct {
Status string `form:"status" binding:"omitempty,oneof=open done"`
Priority *int `form:"priority" binding:"omitempty,min=0,max=3"`
Limit int `form:"limit,default=20" binding:"min=1,max=100"`
}
r.GET("/todos", func(c *gin.Context) {
var f TodoFilter
// Query-строка заполняет поля по тегу form.
if err := c.ShouldBindQuery(&f); err != nil {
respondBindError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"filter": f})
})Три приёма в одной структуре: omitempty в теге binding пропускает проверку для нулевых значений — пустой status не упрётся в oneof; Priority — указатель, при отсутствии параметра останется nil, при ?priority=2 укажет на число; limit с default=20 обходится без указателя. Валидация для query запускается так же, как для тела: ?limit=500 получит 400 по тегу max=100. В JSON-ответе отсутствующий priority сериализуется как "priority": null — честный сигнал для фронтенда.
Что вернёт GET /todos?priority=abc
Прочитайте код и выберите, что он выведет:
type TodoFilter struct {
Priority *int `form:"priority" binding:"omitempty,min=0,max=3"`
Limit int `form:"limit,default=20" binding:"min=1,max=100"`
}
r.GET("/todos", func(c *gin.Context) {
var f TodoFilter
if err := c.ShouldBindQuery(&f); err != nil {
respondBindError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"filter": f})
})ShouldBindUri: тег uri и параметры пути#
Параметры пути — :id из маршрута — тоже привязываются структурой. ShouldBindUri собирает c.Params в карту, заполняет поля по тегу uri и затем исполняет ту же валидацию binding. Тип конвертируется автоматически: ID int64 вместо ручного strconv.Atoi. Синтаксис выглядит избыточно — параметр объявлен и в маршруте, и в теге, — но имена должны совпадать посимвольно: опечатка в uri-теге оставит поле нулевым молча, без ошибок. required на uri-поле бессмыслен: сегмент, не подходящий под :id, просто не сматчит маршрут; зато binding:"min=1" отсечёт /todos/0, если нулевые идентификаторы в вашей схеме не существуют. Числовой мусор вида /todos/abc вернёт ошибку ParseInt — как и в query, это ошибка разбора, а не валидации, и respondBindError её классифицирует правильно.
PATCH /todos/:id: uri плюс JSON-тело
GOtype TodoUri struct {
ID int64 `uri:"id" binding:"min=1"`
}
type TodoUpdate struct {
Done *bool `json:"done"`
Priority *int `json:"priority" binding:"omitempty,min=0,max=3"`
}
r.PATCH("/todos/:id", func(c *gin.Context) {
var u TodoUri
// Сперва параметры пути.
if err := c.ShouldBindUri(&u); err != nil {
respondBindError(c, err)
return
}
var in TodoUpdate
// Затем JSON-тело: два источника — два вызова.
if err := c.ShouldBindJSON(&in); err != nil {
respondBindError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"id": u.ID, "done": in.Done})
})Один хендлер — две привязки с разными источниками: путь читается по uri-тегу, тело — по json. Они не конфликтуют: тело одноразовое, параметры пути — нет. TodoUpdate собран из указателей: поле, которого нет в теле, останется nil — это основа частичного обновления PATCH, когда клиент присылает только done или только priority. Обе ошибки идут через общий помощник respondBindError со статусом 400.
Проверка: что делает BindJSON при ошибке
Хендлер вызывает c.BindJSON(&in) и игнорирует возвращённую ошибку. Что произойдёт при невалидном JSON?
Проверка: min и max на строке
Поле Title string с тегом binding:"min=1,max=140". Что именно проверяет max=140?
Два ShouldBindJSON на одном запросе не сработают: после первого чтения поток пуст, и второй вернёт EOF. Если одна и та же структура нужна дважды — например, для логирования и для обработки — есть c.ShouldBindBodyWith(&obj, binding.JSON): она кэширует байты тела в контексте и отдаёт их повторно. Query и uri одноразовыми не являются, их можно привязывать сколько угодно раз.
c.ShouldBind: Gin сам выбирает биндер#
ShouldBind без суффикса определяет биндер сам: binding.Default(method, contentType) смотрит на метод и заголовок Content-Type. application/json — JSON-биндер, application/xml — XML, multipart/form-data — формы с файлами, всё остальное, включая пустой заголовок, — form-биндер. У GET-запросов выбор всегда form, что бы ни стояло в заголовке: query — единственный их источник данных. Отсюда ловушка: ShouldBindJSON заголовок игнорирует — JSON с Content-Type: text/plain он привяжет, а ShouldBind отправит тот же запрос в form-биндер и, скорее всего, упрётся в required. Ручной json.Unmarshal остаётся нишевым инструментом: кастомный декодер, потоковое чтение большого тела, нестандартный формат. В Fiber из параллельного курса похожая автоопределение живёт в BodyParser. Но у ручного пути цена: валидацию binding придётся запускать самому.
Тот же хендлер руками: json.Unmarshal
GOvar in TodoInput
// Читаем тело целиком и декодируем вручную.
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "read: " + err.Error()})
return
}
if err := json.Unmarshal(body, &in); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "json: " + err.Error()})
return
}
// Unmarshal теги binding не исполняет — валидируем сами.
if err := binding.Validator.ValidateStruct(in); err != nil {
respondBindError(c, err)
return
}
c.JSON(http.StatusCreated, gin.H{"todo": in})Три шага вместо одного — чтение, декодирование, валидация: ровно столько работы делает ShouldBindJSON под капотом. binding.Validator — интерфейс валидатора Gin: ValidateStruct исполняет теги binding и возвращает знакомые ValidationErrors. Ручной путь оправдан, когда нужен нестандартный декодер или промежуточная обработка сырых байтов; в остальных случаях это дублирование стандартной механики. Учтите: json.Unmarshal молча игнорирует неизвестные поля, а в Gin за это отвечает глобальный флаг binding.EnableDecoderDisallowUnknownFields.
Опциональные поля: указатели и omitempty#
Значение типа не умеет отличать «клиент не прислал» от «прислал ноль»: Done bool после привязки равно false в обоих случаях. Указатели закрывают разрыв: bool останется nil, если поля не было в JSON, и покажет false при явном "done": false — на этом стоит частичное обновление из PATCH выше. Для строк та же логика: string — nil против пустой строки. Отдельно про json:"...,omitempty": тег действует только при маршалинге ответа — нулевые поля не попадут в исходящий JSON; на входе он ничего не делает, опциональность на входе дают указатель или валидаторский omitempty в теге binding. Form-биндер тоже умеет указатели: ключ отсутствует — поле останется nil, ключ есть — выделится значение. Цена — проверки на nil перед использованием: разыменование *in.Done при nil — паника, поэтому перед каждым использованием идёт проверка. Опциональность не нужна — берите значения: меньше аллокаций и меньше мест для nil.
Свои сообщения об ошибках: три стратегии#
Первая стратегия — карта тегов, как в msgForTag: быстро и без зависимостей, но тексты не знают про параметры тега. Параметр достаётся отдельно: e.Param() вернёт 140 из max=140, e.Value() — присланное значение, e.Field() — имя поля. Вторая — RegisterTranslation: валидатор умеет подменять сообщения на любые языки через universal-translator, движок достаётся через binding.Validator.Engine(). Третья — не переводить вовсе: API отдаёт структурированные ошибки (поле, тег, параметр), а человекочитаемые тексты рисует фронтенд на своём языке. Для одного проекта честнее первая или третья: RegisterTranslation тянет зависимость ради текстов, которые чаще всего всё равно живут во фронтенде. Чего точно не делать — парсить строку err.Error() по подстроке Key: '...': формат не контракт, и смена версии валидатора сломает парсер молча.
Допишите createTodo
Восстановите тег обязательного поля, вызов привязки и статус ответа об ошибке.
Соберите updateTodo из строк
Соберите хендлер частичного обновления: сперва параметры пути, затем JSON-тело, затем успешный ответ.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: привязка данных в Gin
Привязка и валидация живут в HTTP-слое, и прощупывать их удобнее всего curl-ом с флагом -i: статус и тело ответа видны целиком. Три запроса — {} без title, priority=-1 и битый JSON — закрывают все ветки хендлера за пару секунд. Заодно взгляните на строку [GIN] в консоли: статус 400 подскажет, что запрос дошёл до валидатора, а не упал раньше по маршруту.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Should-семейство возвращает ошибку — ответ 400 пишете вы; Bind-семейство (MustBindWith) само отвечает 400 и прерывает цепочку.
- Под тегами binding — go-playground/validator/v10; для строк min и max проверяют длину рун, для чисел — значение.
- ShouldBindQuery читает тег form, ShouldBindUri — тег uri; валидация binding исполняется в обоих случаях.
- c.ShouldBind выбирает биндер по Content-Type (у GET — всегда form), ShouldBindJSON заголовок игнорирует.
- err.Error() валидатора — техническая строка; для API разбирайте validator.ValidationErrors через errors.As.
- Опциональные поля — указатели: nil отличает «не прислали» от нулевого значения; json omitempty работает только при сериализации.
- binding:"required" на bool запрещает false — не вешайте required на необязательные логические поля.
Домашнее задание
Добавьте в TodoInput поле Description string с тегом json:"description" и ограничением max=200. Сделайте DELETE /todos/:id со структурой TodoUri и эндпоинт GET /todos с фильтром TodoFilter (status, limit с default=10) через ShouldBindQuery. Прогоните curl-ом три запроса: пустой title, priority=-1 и limit=abc — все три должны вернуть 400 с внятными сообщениями из respondBindError.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем ShouldBindJSON отличается от ручного json.Unmarshal?
ShouldBindJSON читает тело из c.Request.Body и сразу исполняет теги binding; Unmarshal — только декодирование, валидацию запускаете сами через binding.Validator.ValidateStruct. Ручной путь берут ради кастомных декодеров и нестандартных форматов.
Почему второй ShouldBindJSON возвращает EOF?
Тело запроса — поток, его читают один раз: после первой привязки декодеру уже нечего читать. Для повторной привязки тела используйте c.ShouldBindBodyWith — она кэширует байты в контексте. Query и uri одноразовыми не являются.
Как перевести сообщения валидации на русский?
Короткий путь — карта msgForTag по e.Tag() с параметрами из e.Param(). Полноценный — RegisterTranslation через universal-translator на движке binding.Validator.Engine(). Часто разумнее третий вариант: API отдаёт поле и тег, тексты рисует фронтенд.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Echo: минималистичный быстрый фреймворк
Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.
- Строить REST API на Echo
- Использовать типизированные запросы/ответы (Request/Response)
- Управлять ошибками через echo.HTTPError
- Писать middleware, включая JWT-аутентификацию