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

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

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

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

Русские сообщения об ошибках

Сообщения валидации go должен понимать человек, а не разработчик: «Password: проверка min не прошла» технически честно и совершенно бесполезно для пользователя. В этом уроке собираем систему русских текстов: двухуровневый каталог по тегам и полям, согласование числительных, json-имена через RegisterTagNameFunc и выбор между своим словарём и universalTranslator.

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

После пятого урока у вас на руках всё сырьё: Field() даёт имя поля, Tag() — споткнувшийся тег, Param() — его параметр. Осталось превратить сырьё в текст, который пользователь прочтёт без гугла. Разница между «тупым» и человечным ответом видна на одном поле: «Password: проверка min не прошла» против «Пароль — от 8 символов». Информация та же, цена для поддержки разная: первый вариант генерирует тикеты «что мне делать?», второй — сам себя объясняет. В этом уроке мы не будем трогать валидатор — все механики уже разобраны; речь о слое над ним, где живут русские тексты. Задача слоя простая: по каждой записи FieldError выдать строку, а правила этой выдачи — один раз и в одном месте, потому что сообщения, как и валидация, расползаются по кодовой базе мгновенно. Кладите слой рядом с валидатором, но отдельно от хендлеров: пакет messages с парой словарей и одной функцией собирается за полчаса и переезжает из проекта в проект почти без изменений — приёмы урока не зависят ни от фреймворка, ни от набора тегов, поэтому написанное здесь однажды работает и в Gin, и в Echo, и в фоновых задачах без HTTP вовсе.

Сообщения валидации go: каталог вместо веток if#

Главная ошибка стартовой версии любого API — писать тексты прямо в хендлере: switch по тегам, рядом с разбором ValidationErrors. На одном эндпоинте это выглядит невинно, а на четвёртом выясняется, что «обязательное поле» в регистрации, «укажите поле» в профиле и «поле не заполнено» в подписке — три разных текста для одного и того же required. Сообщения валидации go собираются в каталог — обычный package рядом с валидатором, где ключом служит Tag(), а значением — готовый русский текст. Хендлер умеет только одно: перебрать записи, позвать функцию-сообщалку и сложить строки в Details ответа. Двухуровневая структура каталога вытекает из самой FieldError: первый уровень — тексты по тегам, общие для всех полей проекта; второй — уточнения по конкретным полям, потому что «обязательное поле» одинаково честно и для пароля, и для города, а вот «минимум 8 символов» для пароля и «от 3 до 32 символов» для имени — разные разговоры. Порядок проверки уровней фиксированный: сначала уточнение по полю, потом общий текст по тегу — иначе уточнения никогда не сработают. И обязательная страховка на оба уровня: ветка default, которая отдаёт техническую строку с ActualTag(), а не пустоту. Незнакомый тег в каталоге — не повод молчать, а повод узнать о дыре в каталоге из ответа API или лога.

Двухуровневый каталог: уточнение сильнее общего

GO
var tagTexts = map[string]string{
        "required": "обязательное поле",
        "email":    "укажите адрес почты",
        "oneof":    "допустимые значения: %s",
}

var fieldTexts = map[string]string{
        "Phone":  "телефон в формате +7XXXXXXXXXX",
        "Promo":  "промокод — от 4 до 16 символов",
}

func humanMessage(e validator.FieldError) string {
        if t, ok := fieldTexts[e.Field()]; ok {
                return t // уточнение по полю сильнее общего текста
        }
        t, ok := tagTexts[e.Tag()]
        if !ok {
                return e.Field() + ": проверка " + e.ActualTag() + " не прошла"
        }
        return fmt.Sprintf(t, e.Param())
}

Словарь tagTexts обслуживает все поля проекта, fieldTexts — отдельные поля, которые говорят по-своему. Порядок важен: уточнение по Field() проверяется первым, иначе общий текст по Tag() перехватит запрос раньше. Для oneof параметр с перечнем подставляется в шаблон через Sprintf, для required лишний аргумент безвреден. Ветка default с ActualTag() — та же страховка, что и в мини-каталоге прошлого урока.

Шаблоны сообщений: от тега к тексту#

ТегШаблон текстаЧто подставляется
requiredобязательное поленичего
min / maxминимум N символовParam() — граница
emailукажите адрес почтыничего
oneofдопустимые значения: %sParam() — перечень
gte / lteзначение от N до MParam() — граница
defaultпроверка X не прошлаActualTag() — имя тега

Уточнение по полю: когда общего текста мало#

Общие тексты по тегам — это основа, но в каждом проекте есть поля, которые заслуживают персонального разговора. Телефон: общий текст min=10 скажет «минимум 10 символов», а пользователь не понимает, в каком формате хочет номер сервис; уточнение fieldTexts["Phone"] говорит «телефон в формате +7XXXXXXXXXX» — и поддержка получает на один тикет меньше. Промокод: min=4 одинаково справедлив для кода и для фамилии, но «промокод — от 4 до 16 символов» снимает вопрос «а что сюда вообще писать». Дата рождения: требование формата лучше сформулировать один раз на поле, чем надеяться, что общий текст datetime догадается. Правило отбора простое: если формулировка общего текста не отвечает на вопрос «что мне ввести», полю положено уточнение. Второе правило — дисциплина объёма: уточнений должно быть на порядок меньше, чем полей, иначе второй уровень разрастается в параллельный каталог, который некому поддерживать. И третье — уточнение по полю выигрывает у уточнения по типу: если хочется для числовых полей говорить «минимум N», а для строковых «минимум N символов», посмотрите на Kind() и Type() из пятого урока — каталог может ветвиться и по ним, без второго словаря.

Проверка: чей текст сильнее

В каталоге есть общий текст для required и уточнение для поля Phone. Поле Phone провалило required. Что вернёт humanMessage?

Согласование числительных: 1 символ, 2 символа, 5 символов#

Русский язык добавляет валидатору проблему, которой нет у английских туториалов: числительные склоняются. Шаблон «минимум N символов» на N=1 выдаёт «минимум 1 символов» — и это ровно тот уровень небрежности, который пользователи замечают мгновенно. Форм три: один символ, два символа, пять символов, и выбор зависит от двух последних цифр. Функция на десяток строк закрывает вопрос навсегда — и переиспользуется во всех шаблонах с числом: символы, элементы, байты, минуты. Есть и честный обходной путь, который выбирают небольшие проекты: обезличенные формулировки вроде «от N символов» и «до N символов» не требуют согласования вовсе. Компромисс рабочий, но от него страдают короткие значения: «от 1 символов» — правдоподобная цена такой экономии. И отдельная ловушка внутри этой темы: min и max на числовом поле говорят не про символы, а про значение — полиморфизм тегов из третьего урока. Один и тот же тег min=1 на строке и на числе требует разных текстов, и каталог, который не различает типы, рано или поздно напишет «минимум 1 символ» про количество детей. Различайте поля по Kind() из пятого урока или разведите уточнения по fieldTexts.

Числительные: три формы на десять строк

GO
func pluralRu(n int, one, few, many string) string {
        m10, m100 := n%10, n%100
        switch {
        case m10 == 1 && m100 != 11:
                return one // 1 символ
        case m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14):
                return few // 2 символа
        default:
                return many // 5, 11, 20 символов
        }
}

// ветка каталога для min:
case "min":
        n, _ := strconv.Atoi(e.Param())
        return fmt.Sprintf("минимум %d %s", n,
                pluralRu(n, "символ", "символа", "символов"))

Три формы русской числительной выбираются по последней цифре и её паре: 1 — единственная, 2-4 — малая, остальное — множественная, с исключением 11-14. Функция принимает формы словами, поэтому работает и для «элементов», и для «минут». Atoi на Param() делает проверку поля-конкретики: параметр тега приходит строкой, а число в шаблон едут уже согласованное.

json-имена полей: RegisterTagNameFunc#

Пятый урок оставил шрам: Field() возвращает имя поля структуры, а фронтенд знает json-имя. Пароль в ошибке — «Password», в инпуте — «password», и подсветка на snake_case-фронтах молча не срабатывает. Валидатор решает это штатно: метод RegisterTagNameFunc на экземпляре принимает функцию, которая по reflect.StructField возвращает имя поля — и валидатор дальше использует это имя в Field() и Namespace(). Стандартный вариант — отдавать json-тег. Тут две грабли, на которых спотыкаются все: json-тег с опциями вида "phone,omitempty" вернётся целиком, вместе с опцией, — имя поля получится «phone,omitempty», поэтому тег режут через strings.SplitN по запятой; а поле с json:"-" вернёт минус — имя поля «-» в ошибках не годится, и на минус возвращают пустую строку, чтобы валидатор откатился на имя из структуры. После регистрации маппинг на фронте работает один к одному: Field() говорит json-именами, каталог уточнений по полям ключуется теми же json-именами, а Namespace() строит путь из них — что оживёт в следующем уроке на вложенных структурах. Регистрация — один раз на старте, рядом с созданием экземпляра из второго урока; переключать словарь имён на живом валидаторе нельзя.

RegisterTagNameFunc: осторожно с опциями

GO
validate := validator.New()
validate.RegisterTagNameFunc(func(fld reflect.StructField) string {
        name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
        if name == "-" {
                return "" // json:"-" — вернём имя поля структуры
        }
        return name
})

// было: Field() → "Phone", Namespace() → "SignupForm.Phone"
// стало: Field() → "phone", Namespace() → "SignupForm.phone"

SplitN режет json-тег по первой запятой: «phone» остаётся из «phone,omitempty». Пустая строка на минусе возвращает валидатору право назвать поле по-структурному — без этого поля, скрытые из JSON, получили бы имя «-». После регистрации все имена — Field(), Namespace(), имена в ошибках StructLevel — живут по json-меткам, и фронтенд подсвечивает инпуты без маппинга.

Проверка: что вернёт Field()

Поле Phone string с json:"phone" провалило min=10. RegisterTagNameFunc по json-тегу зарегистрирован. Что вернёт e.Field()?

Что получит пользователь

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

type SignupForm struct {
        Promo string `validate:"omitempty,min=4"`
        Email string `validate:"required,email"`
}

// fieldTexts: {"Email": "адрес почты целиком, с доменом"}
// tagTexts: required → "обязательное поле", email → "укажите адрес почты"

// клиент прислал: Promo "", Email "не-почта"
// что вернёт humanMessage по записям ValidationErrors?

validator перевод: universalTranslator и готовые словари#

Когда API говорит на нескольких языках, словарь на одном языке перестаёт быть решением. Для этого сценария у экосистемы есть тяжёлая артиллерия: пакет universal-translator с менеджером переводов и механизм RegisterTranslation на валидаторе — каждый тег регистрируется с шаблоном и функцией подстановки параметров, а в рантайме сообщения достаются по локали запроса. В валидаторе v10 есть готовые наборы переводов для двух десятков языков, включая русский, — подпакеты translations, где тексты уже написаны сообществом. Прежде чем бежать туда, посчитайте цену: регистрация переводов — отдельный слой инициализации, шаблоны с плейсхолдерами чужого формата, ревью текстов, локали в контексте запроса. Для моноязычного сервиса на русском свой словарь из map — дешевле, прозрачнее и честнее: вы контролируете тон, числительные и уточнения по полям, которых в готовых наборах нет. Мой рабочий ориентир: один язык — свой каталог; два и больше — universalTranslator с готовыми наборами и собственными поверх. И в любом случае тексты — не место для «да» и «нет»: клиенту едут строки без эмоций, но с конкретикой, потому что каждое сообщение — интерфейс к полю формы. Подробный конверт ошибок с кодами и Details соберём в двадцать четвёртом уроке.

Правила каталога сообщений одним списком#

  • один каталог на проект, отдельный пакет: хендлеры не пишут тексты вообще
  • первый уровень — тексты по Tag(), второй — уточнения по Field() (json-имена после RegisterTagNameFunc)
  • уточнение по полю проверяется раньше общего текста — иначе оно никогда не сработает
  • ветка default с ActualTag() обязательна: незнакомый тег — техническая строка, а не пустота
  • числительные согласуются: 1 символ, 2 символа, 5 символов — или обезличенное «от N»
  • переименование поля не ломает каталог молча — тест прогоняет пустую форму и сверяет ветки
  • Value() и внутренние имена структур наружу не едут — только Field, Tag, Param и текст

Соберите функцию-сообщалку

Восстановите тип ошибки, два ключа каталога и страховку в default — четыре пропуска.

func humanMessage(e validator.) string { if t, ok := fieldTexts[e.()]; ok { return t } t, ok := tagTexts[e.()] if !ok { return e.Field() + ": проверка " + e.() + " не прошла" } return t }

Соберите путь текста до клиента

Форма сломалась на трёх полях. Разложите путь от валидации до ответа 400.

Строки

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

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

Карточки: слой сообщений

Термин

двухуровневый каталог

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

map по тегам (общие тексты) + map по полям (уточнения). Уточнение по Field() проверяется первым и перекрывает общий текст по Tag().

1 / 6

Седьмой урок — вложенные структуры: адрес внутри профиля, рекурсивная проверка дерева и Namespace() из нескольких сегментов, на который сейчас опираются уточнения каталога. Каталог в двенадцатом уроке переедет в Gin вместе с тегами binding, а единый конверт ошибок API с кодами — тема двадцать четвёртого. Каталог из этого урока соберите локально: песочница сайта исполняет только чистый Go без внешних модулей, поэтому валидатор и его сообщения живут в вашем терминале, а интерактивы закрепляют механику без запуска.

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

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

Вывод

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

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

  • Сообщения собираются в отдельный пакет-каталог: хендлеры только вызывают функцию-сообщалку, тексты не живут в коде эндпоинтов.
  • Два уровня каталога: общий текст по Tag() и уточнение по Field(); уточнение проверяется первым.
  • Ветка default с ActualTag() обязательна: незнакомый тег отвечает технической строкой, а не молчанием.
  • Русские числительные склоняются: функция на три формы или обезличенное «от N символов».
  • RegisterTagNameFunc переименовывает поля в ошибках по json-тегу; SplitN режет опции, минус откатывается на имя структуры.
  • Для мультиязычных API — universalTranslator и RegisterTranslation; для одного языка дешевле свой словарь.
  • Переименование поля убивает уточнение молча — тест на пустую форму сверяет ветки каталога.

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

Возьмите mini-каталог из пятого урока и доведите до двухуровневого: tagTexts для required, min, max, email, oneof и fieldTexts минимум для двух полей вашей формы. Добавьте pluralRu и ветку min с согласованием числительных. Напишите тест, который прогоняет пустую форму и проверяет: каждое поле из fieldTexts отвечает уточнением. Потом переименуйте одно поле в структуре и убедитесь, что тест покраснел — вы только что защитили каталог от молчаливой смерти.

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

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

Почему бы не править err.Error() регуляками по ходу?

Простыня Key: '...' — это Error() каждой записи, склеенный переводами строк; парсить его регуляками значит зависеть от формата чужой строки, который меняется между мажорными версиями. Каталог по Tag() и Field() опирается на методы, а не на формат текста, и переживает обновления библиотеки.

Где ключиться уточнения — по Field() или по Namespace()?

На плоских структурах одинаково. На вложенных Field() возвращает только последний сегмент, и два поля City в разных структурах съедят уточнение друг друга — там ключуют по Namespace(). Условие простое: как только появились вложенные структуры, переходите на полный путь, об этом следующий урок.

Стоит ли хранить тексты в YAML или базе?

YAML — да, если каталог большой или редактируется без релиза: ключи те же, загрузка — на старте. База — обычно нет: сообщения меняются вместе с правилами в коде, а запрос в базу на каждое сообщение в hot path — цена неоправданная. Если правила в базе, кэшируйте словарь в памяти и обновляйте по сигналу.

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

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

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

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

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

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

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