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

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

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

Урок 15 из 25 ~16 мин +20 XP

Нормализация перед валидацией

Нормализация данных go — это TrimSpace, lower-case и канонические телефоны, сделанные до validate.Struct: валидатор проверяет то, что вы ему дали, и строку из трёх пробелов он считает честной строкой. В этом уроке выбираем, что чистим и чем, ставим нормализацию в конвейер между bind и validate и разбираем, почему в Gin этот порядок приходится собирать руками.

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

Форма заявки: пользователь скопировал почту из мессенджера, и вместе с адресом приехал невидимый неразрывный пробел. required прошёл — строка не пустая, email провалился — формат-то с мусором. Пользователь перепечатывает адрес, опечатывается и получает «адрес уже занят»: дубликат записан прошлой попыткой с другим регистром. Ни один тег из всего курса не решает эти проблемы, потому что это не задача проверки — это задача чистки, и её место до валидации. Правило курса звучит коротко: нормализуй, потом проверяй. Валидатор — чистый предикат: он отвечает, годны ли данные, и не обязан чинить то, что можно починить заранее. После этого урока ваши формы будут проходить проверку в каноническом виде, а сообщения об ошибках — говорить о реальных проблемах, а не о лишнем пробеле, который вы сами не убрали.

нормализация данных go: что чистим до проверок#

Начнём с перечня, потому что «чистить всё подряд» — тоже болезнь. Пробелы по краям: TrimSpace на каждом строковом поле, что приходит от людей — имена, заголовки, поисковые запросы; машины-клиенты это тоже оценят. Регистр: почтовые домены безусловно в нижнем регистре, ники и слаги — по правилу проекта; для секретов см. ниже. Телефоны: единая форма хранения — обычно только цифры без пробелов и дефисов; «+7 (916) 123-45-67» после чистки станет «79161234567», а «89161234567» — самим собой, и свести оба к одной строке можно, лишь отдельным правилом заменив восьмёрку на код страны. Юникодная пустота: неразрывные пробелы U+00A0 и NEL U+0085, приехавшие из копипасты, — про них отдельный разговор ниже. Чего нормализация данных go не делает: не исправляет опечатки, не расшифровывает намерения, не меняет смысл поля. Граница простая: если операцию можно повторить дважды с одинаковым результатом и она не теряет информации — это нормализация; всё остальное — бизнес-решение, и оно живёт после валидации. Место операции — граница API, один раз: данные канонизируются на входе и дальше путешествуют по системе чистыми, а не подчищаются в каждом слое заново. Аргумент «почистим в каждом слое на всякий случай» вежливо отклоняйте: у каждого слоя своя трактовка пробела и регистра, и через два релиза данные проходят через три разные чистки. Канон задаётся одной функцией на границе — и именно в эту функцию смотрят тесты.

Метод Normalize на DTO

GO
type SignupDTO struct {
        Email    string `json:"email" validate:"required,email"`
        Nickname string `json:"nickname" validate:"required,min=2,max=32"`
        Password string `json:"password" validate:"required,min=8"`
}

func (d *SignupDTO) Normalize() {
        d.Email = strings.ToLower(strings.TrimSpace(d.Email))
        d.Nickname = strings.TrimSpace(d.Nickname)
        // Password не трогаем: секреты не чистим
}

Нормализация живёт методом на структуре: рядом с полями, которые чистит, видна из любого хендлера и тестируется отдельно. Email получает трим и нижний регистр, ник — только трим, пароль не тронут сознательно. Метод обязателен к вызову до validate.Struct — порядок обеспечивает хендлер, и именно это делает его контрактом.

Проверка: required и пробелы

Nickname string с тегом required получил значение из трёх пробелов. Что решит валидатор?

trim перед валидацией: NBSP и юникод#

strings.TrimSpace режет не только ASCII-пробел: под определение юникодного whitespace попадают табуляция, переводы строк, U+00A0 (неразрывный пробел) и U+0085 — всё то, чем мессенджеры и текстовые редакторы щедро снабжают скопированный текст. Поэтому trim перед валидацией решает проблему невидимого мусора, о которой пользователь не знает и которую никогда не отловите ручным тестом: строки «ada@example.com» и «ada@example.com » выглядят одинаково, а валидатору email видно разницу. Но у трима есть граница, о которой забывают: он работает только по краям строки. Неразрывный пробел в середине — «Иван Петров» — останется как был, и для таких полей нужна своя замена через strings.Map или ReplaceAll по месту. С регистром так же честно: strings.ToLower приводит к нижнему регистру по юникодным правилам, доменная часть почты канонична в нижнем, а вот локальная часть формально чувствительна к регистру по RFC — практика практически везде складывает её тоже, но решите это правилом проекта и запишите в документацию поля. Телефоны — своя история: замените всё нецифровое на пустоту и решите, храните ли вы код страны. Все три операции дешёвые — но дело не в цене, а в порядке: после них теги видят то, что имеет смысл проверять.

Сколько ошибок без нормализации

Прочитайте код и выберите, что он выведет:

type ProfileDTO struct {
        Email    string `validate:"required,email"`
        Nickname string `validate:"required,min=2"`
}

in := ProfileDTO{Email: " ada@example.com", Nickname: "  "}
err := validate.Struct(&in)

Gin: разлепить bind и validate#

В Echo и chi порядок bind → normalize → validate пишется руками, и нормализация встаёт между шагами естественно — конвейер из тринадцатого урока принимает её без переделок. В Gin сложнее: ShouldBindJSON декодирует и валидирует одним вызовом, между половинками не вставить ни строчки. Если оставить правила в binding-тегах, проверка случится на грязных данных внутри ShouldBindJSON, и нормализация после неё бессмысленна — ошибки уже уехали. Рабочих рецепта два, и оба честные. Первый: правила живут в теге validate, ShouldBindJSON выполняет только декод (валидационных ошибок не будет), затем Normalize(), затем ручной validate.Struct — конвейер разлеплён руками, а ответ 400 собирается как в двенадцатом уроке. Второй: правила остаются в binding, а нормализация переезжает внутрь вашего валидатора — реализации интерфейса, который gin дергает перед проверкой; это путь для проектов, где binding-теги уже расставлены по всему коду. Выбирайте один рецепт на проект: два способа в одном репозитории гарантируют, что однажды кто-то почистит данные дважды или не почистит вовсе.

Рецепт Gin: decode → normalize → validate

GO
func signup(c *gin.Context) {
        var in SignupDTO // правила в теге validate, не binding
        if err := c.ShouldBindJSON(&in); err != nil {
                c.JSON(http.StatusBadRequest, gin.H{"error": "битое тело"})
                return
        }

        in.Normalize()

        if err := validate.Struct(&in); err != nil {
                var verrs validator.ValidationErrors
                if errors.As(err, &verrs) {
                        c.JSON(http.StatusBadRequest, gin.H{"errors": respondFields(verrs)})
                        return
                }
                c.JSON(http.StatusBadRequest, gin.H{"error": "проверьте поля"})
                return
        }
        c.JSON(http.StatusCreated, gin.H{"status": "ok"})
}

Первый ShouldBindJSON отвечает за декод: без binding-тегов он возвращает ошибки только разбора JSON. Normalize приводит поля к канону, и ручной validate.Struct проверяет уже чистые данные по тегу validate. Обработка ValidationErrors — та же, что в двенадцатом уроке: клиент не видит разницы, а внутри порядок честный.

Каноническая форма: база и дубликаты#

Зачем всё это, если required и email и так отработали? Каноническая форма — обязательство перед хранилищем и поиском. Уникальный индекс на email различает регистр посимвольно: без lower-case у вас появятся Ada@example.com и ada@example.com — два пользователя, одна почта, и поддержке придётся разгребать, чей аккаунт настоящий. Поиск по нику без трима не найдёт строку с краевым пробелом. Отчёты, дедупликация, сравнение строк — всё опирается на канон, и канон задаётся один раз на границе. Отсюда и проверка того, что нормализация сработала: тесты на уровне валидации должны гонять грязные входы — с NBSP, с регистром, с пробелами — и фиксировать, что до правил доходит чистое значение; это тот же табличный каркас из урока про тесты, только колонка входа грязная. Заметьте и эффект на сообщения: после нормализации тексты ошибок говорят о деле — «минимум 8 символов» вместо загадочного провала формата на строке, в которой пользователь пробелов не видел. Честное сообщение начинается с честных данных. Альтернатива «пусть база сама различает без учёта регистра» существует — в Postgres есть citext и регистронезависимые коллации, — но переносить политику канона в хранилище значит потерять её из виду: нормализация разъедется между базами и версиями, а переезд на другое хранилище потребует изобретать её заново. Приложение остаётся единственным местом, где про канон знает весь стек.

Где жить нормализации и где её не заводить#

Три легальных места, по возрастанию централизации. Метод Normalize на DTO — по умолчанию: чистка видна рядом с полями, тесты простые, а хендлер зовёт одну строку; минус — у каждой структуры свой метод, и общий приём повторяется. Функция-хелпер normalizeAndValidate(dto) — для проектов с десятками структур: одна точка порядка, но вход должен быть интерфейсом с методом Normalize, и это контракт, который придётся держать. Внутри Validation-хука фреймворка — самый централизованный вариант: Echo-валидатор из тринадцатого урока чистит перед Struct, gin-реализация то же самое делает до ValidateStruct; удобно, но чистка перестаёт быть видимой в хендлере — компенсируйте комментарием и тестом. Чего не заводить никогда: нормализацию внутри теговых функций. Функция-тег — чистый предикат из десятого урока: она отвечает bool, не мутирует данные, иначе Value() в ошибках устаревает, порядок правил в цепочке делает результат неочевидным, а повторный вызов того же правила на том же поле даёт другой ответ. Тот же запрет — на структурный уровень из одиннадцатого: он оценивает, а не исправляет. И последнее: конфигурационные данные из Viper чистят той же дисциплиной перед валидацией конфига — тема восемнадцатого урока, приёмы те же. Отдельный вопрос — мутация входа: Normalize меняет DTO на месте, и если сырой payload нужен для аудита или повторной обработки, чистите копию: одна строка копирования перед методом, зато журнал хранит то, что прислал клиент, а не то, что вы из него сделали.

Операции нормализации и их границы#

ОперацияЧто делаетГде уместнаГде не уместна
TrimSpaceсрезает краевые пробелы и юникодный whitespace, включая NBSPстроковые поля от людейсередина строки остаётся как есть
ToLowerнижний регистр по юникодным правилампочтовый домен, ники, слагисекреты и регистрозависимые идентификаторы
strings.Map — только цифрывыбрасывает всё нецифровоетелефоны к одной формерешение о коде страны — отдельное правило
ReplaceAllточечная замена конкретного символаNBSP в середине ФИО, двойные пробелыглобальный чистильщик всего

Проверка: почему не внутри теговой функции

Почему нельзя тримнуть значение прямо внутри функции RegisterValidation?

Правила нормализации одним списком#

  • порядок — часть контракта: bind → normalize → validate, никогда не наоборот
  • TrimSpace — на всех строковых полях от людей; он режет и NBSP по краям
  • середина строки тримом не чистится: точечные ReplaceAll по осознанному списку полей
  • lower-case — для почтового домена и идентификаторов; секреты не чистим вообще
  • телефоны — к одной форме цифр, решение о коде страны — правило проекта
  • канон задаётся один раз на границе API, а не в каждом слое заново
  • нормализация не живёт в теговых функциях и на структурном уровне: там только предикаты
  • тесты нормализации гоняют грязные входы: NBSP, регистр, пробелы по краям

Соберите метод Normalize

Четыре пропуска: имя метода, трим, нижний регистр и сигнал чистки в Map.

func (d *SignupDTO) () { d.Email = strings.(strings.ToLower(d.Email)) d.Phone = strings.Map(func(r rune) rune { if r >= '0' && r <= '9' { return r } return }, d.Phone) }

Соберите конвейер с нормализацией

Почта со скопированным NBSP приходит на регистрацию. Расставьте шаги.

Строки

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

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

Карточки: нормализация

Термин

bind → normalize → validate

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

Порядок конвейера: раскладка, чистка, правила. Нормализация после валидации бессмысленна — правила уже отработали на грязных данных.

1 / 6

Дальше по курсу — DTO: границы API и внутренние модели: почему нормализация и валидация живут именно на границе слоёв. Механика ShouldBindJSON разобрана в двенадцатом уроке, конвейер Echo и chi — в тринадцатом, чистка конфигов из Viper ждёт в восемнадцатом. Базовые операции со строками — TrimSpace, ToLower, Map — приходят из стандартной библиотеки: если их сигнатуры подзабылись, документация пакета strings вернёт всё за минуту. Всё из урока исполняется локально с чистым Go: строки и структуры песочница сайта тоже умеет, но связку с фреймворками проверяйте go run-ом.

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

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • Нормализуй, потом проверяй: валидатор — предикат, чистка данных — ваша работа до validate.Struct.
  • required пропускает строку из пробелов: нулевым значением она не является.
  • TrimSpace режет юникодный whitespace по краям, включая NBSP; середина строки — точечные замены.
  • Нормализуем идентификаторы — почту, ники, телефоны; секреты не чистим ни тримом, ни регистром.
  • В Gin bind и validate слеплены: разлепляйте рецептом с validate-тегами или чистите в своей реализации валидатора.
  • Каноническая форма задаётся один раз на границе — уникальные индексы и поиск на неё опираются.
  • Теговые функции и StructLevel — только предикаты: мутация внутри проверки ломает порядок и Value() в ошибках.

Домашнее задание

Возьмите SignupDTO из урока и добавьте метод Normalize с тримом почты и ника, lower-case доменной части и телефоном «только цифры». Напишите табличный тест с грязными входами: NBSP по краям, NBSP в середине, почта в верхнем регистре, телефон с дефисами и скобками. Вставьте нормализацию в конвейер gin-хендлера по рецепту урока и убедитесь тестом, что до validate.Struct доходит канон.

Личные заметкиПусто
Хранятся только в вашем браузере (Validator · Нормализация перед валидацией)

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

Почему валидатор сам не триммит значения?

Потому что он предикат: библиотека не решает, что для вас «чисто» — пробел в нике может быть легальным символом, а lower-case сломал бы регистрозависимый идентификатор. Чистка — политика данных вашего проекта, и её место до проверки, где вы управляете списком полей и операциями.

Что делать с пробелами внутри строки?

TrimSpace работает только по краям. Для мусора в середине — ReplaceAll или strings.Map по конкретному символу или классу, применённые к осознанному списку полей: неразрывный пробел в ФИО, двойные пробелы в адресе. Не делайте глобальный чистильщик: строка перестанет совпадать с оригиналом без вашего ведома. Если поле обязано оставаться точным посимвольно — например, идентификатор, сверяемый с внешним документом, — исключите его из списка нормализации и зафиксируйте это тестом, что чистка его не трогает.

Нормализовать ли пароль хотя бы по краям?

Не стоит. Пароль — секрет, и любое его преобразование — это решение о том, что пользователь «имел в виду». Если форма подставляет пробелы — правьте форму, а не данные. Исключение легко вообразить, но цена ошибки — пользователь, который не может войти, и пароль, который вы сами изменили.

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

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

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

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

Asynq в Go: очередь фоновых задач на Redis

Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.

  • Строить клиент и воркер asynq и подключать Redis
  • Отправлять отложенные, периодические и уникальные задачи
  • Настраивать ретраи, таймауты и приоритеты очередей
  • Мониторить задачи через Inspector и asynqmon