Когда HTTP-запрос приходит в приложение, данные в нём — произвольные. Имя пользователя может быть пустым, цена — отрицательной, дата — в неверном формате. Прежде чем что-то делать с этими данными, нужно убедиться: они соответствуют ожидаемому. Это и есть валидация.
В Go нет магии, которая проверяла бы данные автоматически. Каждая проверка — явный вызов в коде. Это делает поведение предсказуемым: видишь вызов — понимаешь, что тут происходит проверка.
Что происходит без валидации
После декодирования JSON структура заполнена — но её содержимое не гарантировано. Клиент мог не передать обязательное поле, передать отрицательное число там, где ожидается положительное, или строку длиннее допустимого.
Без проверки эти данные попадут в бизнес-логику — и там либо произойдёт panic, либо в базу запишется мусор, либо операция выполнится с неправильным результатом. Лучше отказать сразу, на входе, с понятным сообщением об ошибке.
Валидация по тегам: go-playground/validator
Самый распространённый способ — библиотека go-playground/validator. Правила задаются прямо в структуре через теги, а запустить проверку можно одним вызовом.
import "github.com/go-playground/validator/v10"
type CreateProductRequest struct {
Name string `json:"name" validate:"required,max=200"`
Price int `json:"price" validate:"gt=0"`
}
var validate = validator.New()
func (req CreateProductRequest) Validate() error {
return validate.Struct(req)
}
Здесь:
required— поле не должно быть пустым;max=200— строка не длиннее 200 символов;gt=0— число больше нуля.
validator.New() создают один раз: экземпляр потокобезопасен и переиспользуется на все запросы.
В обработчике проверка идёт сразу после декодирования:
func (h *ProductHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
// данные проверены — можно работать дальше
}
Ручная проверка
Иногда тегом правило не выразить. Например: скидочная цена должна быть меньше обычной цены — это зависимость между двумя полями.
type CreateDiscountRequest struct {
Price int `json:"price" validate:"gt=0"`
DiscountPrice int `json:"discount_price" validate:"gt=0"`
}
func (req CreateDiscountRequest) Validate() error {
if err := validate.Struct(req); err != nil {
return err
}
if req.DiscountPrice >= req.Price {
return fmt.Errorf("discount_price должна быть меньше price")
}
return nil
}
Сначала запускаем проверку по тегам, потом — дополнительное правило руками. Оба подхода прекрасно сочетаются в одном методе Validate().
Формат данных и бизнес-правило — разные вещи
Важно понимать разницу между двумя видами проверок:
Формат данных — можно проверить, глядя только на сам запрос. Пустое имя, отрицательная цена, некорректный email — это проблема самого запроса, никаких данных из базы не нужно. Такие проверки живут в Validate().
Бизнес-правило — требует знания о состоянии системы. «Имя продукта уже занято», «у пользователя нет прав на эту операцию», «товар нельзя удалить, пока есть активные заказы» — это нельзя решить, глядя только на запрос. Такие проверки живут глубже: в обработчике или в доменной логике, где есть доступ к базе данных.
Если тянуть запросы к базе внутрь Validate() — граница размывается. Потом сложно понять, где заканчивается проверка входных данных и начинается бизнес-логика.
Простой вопрос для ориентации: «могу ли я это проверить, не открывая базу?» Если да — это формат, и место проверки здесь. Если нет — это бизнес-правило, и место глубже.
Что делать с ошибками валидации
Когда Validate() вернул ошибку, её нужно превратить в HTTP-ответ со статусом 400. О том, как формировать единообразные ответы об ошибках и когда использовать 400, 422 и другие коды — в статье Ошибки и HTTP.
Глубже: required и нулевые значения, поля в ошибкерасширенное
У required есть ловушка, которая всплывает в первую неделю: тег проверяет, что значение не нулевое, а не что поле пришло в JSON. Price int validate:"required" отвергнет честную цену 0, а Enabled bool validate:"required" — любое false. Если ноль или false — допустимые значения, поле делают указателем (*int, *bool): отсутствие в JSON даст nil, и required проверит именно его. Для вложенных структур так же работает validator.New(validator.WithRequiredStructEnabled()) — без этой опции required на структуре проверяется по старым правилам.
Ошибка от validate.Struct — это validator.ValidationErrors, срез с одной записью на каждое сломанное правило. Клиенту отдают не её текст, а список полей: пройти по срезу через errors.As, взять e.Field() и e.Tag() и собрать {"field": "price", "rule": "gt"}. Из полезных тегов дальше понадобятся email, oneof=draft active, min/max для строк и срезов, dive — чтобы правило применилось к каждому элементу среза.
Коротко
- Данные после декодирования не гарантированы: имя может быть пустым, число — отрицательным.
go-playground/validatorпозволяет задать правила тегами прямо на структуре и проверить всё одним вызовомvalidate.Struct(req).- Для правил между несколькими полями пишут ручную проверку — в Go это просто и читаемо.
- Метод
Validate()на структуре запроса объединяет оба подхода: обработчик вызывает его одной строкой. - Формат проверяется на входе, глядя только на запрос. Бизнес-правила — глубже, где есть доступ к состоянию системы.
requiredпроверяет «не нулевое», а не «пришло в JSON»: для допустимых0иfalseполе делают указателем; ошибкуValidationErrorsразбирают по полям, а не отдают текстом.
Что пощупать
Две границы проверки в одном сервисе практикума remodov/marketplace-system-go: формат запроса ловит обработчик и отвечает 400 с именем поля в errors, а смысл (цена больше нуля, скидка не глубже половины) проверяет сам тип Product.
Код: handler.go, product.go.
Сделаем сами
Ветка step-03-commands-and-errors.
Что почитать дальше
- Обработчики и JSON в Go — как декодировать тело запроса до валидации.
- Ошибки и HTTP в Go — как превратить ошибку валидации в правильный HTTP-ответ.
- Middleware в Go — как вынести повторяющиеся проверки в цепочку обработчиков.