Содержание урока · 10 разделов
- нормализация данных go: что чистим до проверок
- trim перед валидацией: NBSP и юникод
- Gin: разлепить bind и validate
- Каноническая форма: база и дубликаты
- Где жить нормализации и где её не заводить
- Операции нормализации и их границы
- Правила нормализации одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Форма заявки: пользователь скопировал почту из мессенджера, и вместе с адресом приехал невидимый неразрывный пробел. required прошёл — строка не пустая, email провалился — формат-то с мусором. Пользователь перепечатывает адрес, опечатывается и получает «адрес уже занят»: дубликат записан прошлой попыткой с другим регистром. Ни один тег из всего курса не решает эти проблемы, потому что это не задача проверки — это задача чистки, и её место до валидации. Правило курса звучит коротко: нормализуй, потом проверяй. Валидатор — чистый предикат: он отвечает, годны ли данные, и не обязан чинить то, что можно починить заранее. После этого урока ваши формы будут проходить проверку в каноническом виде, а сообщения об ошибках — говорить о реальных проблемах, а не о лишнем пробеле, который вы сами не убрали.
нормализация данных go: что чистим до проверок#
Начнём с перечня, потому что «чистить всё подряд» — тоже болезнь. Пробелы по краям: TrimSpace на каждом строковом поле, что приходит от людей — имена, заголовки, поисковые запросы; машины-клиенты это тоже оценят. Регистр: почтовые домены безусловно в нижнем регистре, ники и слаги — по правилу проекта; для секретов см. ниже. Телефоны: единая форма хранения — обычно только цифры без пробелов и дефисов; «+7 (916) 123-45-67» после чистки станет «79161234567», а «89161234567» — самим собой, и свести оба к одной строке можно, лишь отдельным правилом заменив восьмёрку на код страны. Юникодная пустота: неразрывные пробелы U+00A0 и NEL U+0085, приехавшие из копипасты, — про них отдельный разговор ниже. Чего нормализация данных go не делает: не исправляет опечатки, не расшифровывает намерения, не меняет смысл поля. Граница простая: если операцию можно повторить дважды с одинаковым результатом и она не теряет информации — это нормализация; всё остальное — бизнес-решение, и оно живёт после валидации. Место операции — граница API, один раз: данные канонизируются на входе и дальше путешествуют по системе чистыми, а не подчищаются в каждом слое заново. Аргумент «почистим в каждом слое на всякий случай» вежливо отклоняйте: у каждого слоя своя трактовка пробела и регистра, и через два релиза данные проходят через три разные чистки. Канон задаётся одной функцией на границе — и именно в эту функцию смотрят тесты.
Метод Normalize на DTO
GOtype 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
GOfunc 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 — та же, что в двенадцатом уроке: клиент не видит разницы, а внутри порядок честный.
Энтузиаст централизации добавил ToLower в общий нормализатор всех строк — и пароли проекта стали регистронезависимыми при создании, но регистрозависимыми при входе у части клиентов. Разбор занял неделю, а сводится всё к правилу: нормализация — для идентификаторов, не для секретов. Почта, ник, телефон, слаг — канонизируем смело; пароль, токен, подпись, содержимое личного сообщения — не трогаем вообще, ни трим, ни регистр. Если хочется хотя бы тримнуть пароль по краям — сначала проверьте, как именно ваша форма ввода отправляет значения, и помните: пользователь, поставивший пробел в конец пароля, узнает об этом только на странице входа.
Каноническая форма: база и дубликаты#
Зачем всё это, если required и email и так отработали? Каноническая форма — обязательство перед хранилищем и поиском. Уникальный индекс на email различает регистр посимвольно: без lower-case у вас появятся Ada@example.com и ada@example.com — два пользователя, одна почта, и поддержке придётся разгребать, чей аккаунт настоящий. Поиск по нику без трима не найдёт строку с краевым пробелом. Отчёты, дедупликация, сравнение строк — всё опирается на канон, и канон задаётся один раз на границе. Отсюда и проверка того, что нормализация сработала: тесты на уровне валидации должны гонять грязные входы — с NBSP, с регистром, с пробелами — и фиксировать, что до правил доходит чистое значение; это тот же табличный каркас из урока про тесты, только колонка входа грязная. Заметьте и эффект на сообщения: после нормализации тексты ошибок говорят о деле — «минимум 8 символов» вместо загадочного провала формата на строке, в которой пользователь пробелов не видел. Честное сообщение начинается с честных данных. Альтернатива «пусть база сама различает без учёта регистра» существует — в Postgres есть citext и регистронезависимые коллации, — но переносить политику канона в хранилище значит потерять её из виду: нормализация разъедется между базами и версиями, а переезд на другое хранилище потребует изобретать её заново. Приложение остаётся единственным местом, где про канон знает весь стек.
TrimSpace чистит края. Неразрывный пробел между словами, двойные пробелы, нулевая ширина — всё это остаётся в строке и проходит валидацию длины как полноправные символы. Для полей, где мусор внутри критичен — имена в документах, адреса — пишите точечную чистку: ReplaceAll по NBSP, схлопывание двойных пробелов, и только по осознанному списку полей. Глобальный «чистильщик всего» — путь к данным, которые больше не совпадают с оригиналом без вашего ведома.
Где жить нормализации и где её не заводить#
Три легальных места, по возрастанию централизации. Метод 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.
Соберите конвейер с нормализацией
Почта со скопированным NBSP приходит на регистрацию. Расставьте шаги.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: нормализация
Дальше по курсу — 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 доходит канон.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему валидатор сам не триммит значения?
Потому что он предикат: библиотека не решает, что для вас «чисто» — пробел в нике может быть легальным символом, а lower-case сломал бы регистрозависимый идентификатор. Чистка — политика данных вашего проекта, и её место до проверки, где вы управляете списком полей и операциями.
Что делать с пробелами внутри строки?
TrimSpace работает только по краям. Для мусора в середине — ReplaceAll или strings.Map по конкретному символу или классу, применённые к осознанному списку полей: неразрывный пробел в ФИО, двойные пробелы в адресе. Не делайте глобальный чистильщик: строка перестанет совпадать с оригиналом без вашего ведома. Если поле обязано оставаться точным посимвольно — например, идентификатор, сверяемый с внешним документом, — исключите его из списка нормализации и зафиксируйте это тестом, что чистка его не трогает.
Нормализовать ли пароль хотя бы по краям?
Не стоит. Пароль — секрет, и любое его преобразование — это решение о том, что пользователь «имел в виду». Если форма подставляет пробелы — правьте форму, а не данные. Исключение легко вообразить, но цена ошибки — пользователь, который не может войти, и пароль, который вы сами изменили.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon