Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 4 из 25 ~22 мин +50 XP

Привязка данных (binding)

Привязка данных в Gin — это gin binding: один вызов ShouldBindJSON декодирует JSON-тело в структуру и сразу исполняет теги binding, а ошибка возвращается вам для аккуратного ответа 400. Разберём семейство Should и Bind, теги form и uri для query-строки и пути, обязательные поля, указатели для опциональных и свои сообщения об ошибках — на хендлерах трекера задач.

Содержание урока · 13 разделов

Зачем нужна привязка: 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

GO
type 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 подробно будет отдельный урок, здесь — база, без которой привязка не собирается.

Семейство биндеров: источник, тег имён, авто-400#

МетодИсточник данныхТег имён полейВалидация bindingАвто-400
c.ShouldBindJSONJSON-телоjsonданет
c.BindJSONJSON-телоjsonдада: 400 и abort
c.ShouldBindпо Content-Type; у GET — formjson, xml или formданет
c.ShouldBindQueryquery-строкаformданет
c.ShouldBindUriпараметры пути :iduriданет
c.ShouldBindHeaderHTTP-заголовки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

GO
type 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-тело

GO
type 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?

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

GO
var 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

Восстановите тег обязательного поля, вызов привязки и статус ответа об ошибке.

type TodoInput struct { Title string `json:"title" binding:",max=140"` Done bool `json:"done"` } func createTodo(c *gin.Context) { var in TodoInput if err := c.(&in); err != nil { c.JSON(http., gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"todo": in}) }

Соберите updateTodo из строк

Соберите хендлер частичного обновления: сперва параметры пути, затем JSON-тело, затем успешный ответ.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: привязка данных в Gin

Термин

ShouldBindJSON

нажмите, чтобы перевернуть
Определение

Декодирует JSON-тело в структуру и исполняет теги binding. Возвращает ошибку — ответ 400 пишете вы сами.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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.

Личные заметкиПусто
Хранятся только в вашем браузере (Gin · Привязка данных (binding))

Вопросы и ответы по уроку

Чем 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 отдаёт поле и тег, тексты рисует фронтенд.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Echo25 уроков~13 чпрактика25 уроков уже доступны

Echo: минималистичный быстрый фреймворк

Курс Echo для Go: маршруты, middleware, валидация, JWT, SSE, тестирование и деплой. 25 уроков с примерами и итоговым API-проектом.

  • Строить REST API на Echo
  • Использовать типизированные запросы/ответы (Request/Response)
  • Управлять ошибками через echo.HTTPError
  • Писать middleware, включая JWT-аутентификацию