Содержание урока · 10 разделов
- валидация конфига go: конвейер старта
- Порты, таймауты и окружение: что ловит каждый тег
- env, пустые строки и дефолты до Unmarshal
- Поле конфига — теги — что поймает
- Инварианты руками: то, что тегами не выразить
- Горячая перезагрузка: конвейер не только на старте
- Конвейер валидации конфига одним списком
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сервис поднялся, health-check зелёный, а через час дежурный ловит странные отказы: письма не уходят, соединения рвутся. Причина — таймаут в конфиге записан с опечаткой, и в поле легла нулевая длительность: каждый коннект умирает мгновенно, очередь копится, логи молчат. Рестарт ничего не меняет: конфиг тот же, и падение повторится ровно на том же шаге. Ни один тег из курса не помог бы, пока проверка не стоит на пути конфига: файл, окружение и флаги — данные из внешнего мира, равноправные с запросом клиента, и проверять их нужно тем же инструментом на входе. Разница одна: у формы ошибку видит пользователь и правит поле, а у конфига — процесс на старте, и ошибка обязана упасть понятным списком в лог запуска. Конфиг тоже проверяем: конвейер из прошлых уроков переезжает на старт приложения почти без изменений — та же структура с тегами, тот же validate.Struct, те же FieldError, тот же список ValidationErrors вместо английской простыни.
валидация конфига go: конвейер старта#
Конвейер валидации конфига go собирается из пяти шагов, и порядок здесь — контракт, а не стилистика. Сначала дефолты: viper.SetDefault заполняет ключи, которых может не быть ни в одном слое. Затем файл: viper.ReadInConfig читает конфигурацию в реестр; файл с опечаткой в синтаксисе упадёт здесь же, до всякой валидации. Третий шаг — viper.Unmarshal(&cfg): реестр раскладывается в структуру Config с учётом окружения и приоритетов источников; полную лестницу — файл, env, флаги, дефолты — разбирает курс viper, здесь мы стоим сразу после него. Четвёртый шаг — validate.Struct(&cfg): теги исполняются по полям, и ValidationErrors собирается списком, как в HTTP-хендлерах, — все провалы за один запуск, а не по одному за рестарт. Пятый — инварианты руками, о них ниже. Место конвейера — конец функции load(), которая возвращает (Config, error), и main решает: log.Fatal или ехать дальше. Курс viper строил проверки руками — метод с if-ами и errors.Join; валидатор забирает у ручного кода форматы и границы: правила живут рядом с mapstructure-тегами, одна структура описывает и раскладку, и договор, а новый ключ — это новая строка с тегами, а не новая ветка где-то в функции. Ручные проверки остаются ровно для того, что тегами не выразить: файловая система, парсинг, согласованность с внешним миром.
Config с тегами и load() по конвейеру
GOtype Config struct {
Port int `mapstructure:"port" validate:"required,min=1024,max=65535"`
Env string `mapstructure:"env" validate:"required,oneof=dev stage prod"`
ReadTimeout time.Duration `mapstructure:"read_timeout" validate:"required,min=1s"`
WriteTimeout time.Duration `mapstructure:"write_timeout" validate:"required,gtefield=ReadTimeout"`
DatabaseDSN string `mapstructure:"database_dsn" validate:"required"`
CertFile string `mapstructure:"cert_file" validate:"required_without=Autogenerate"`
Autogenerate bool `mapstructure:"autogenerate_cert"`
}
func load() (Config, error) {
viper.SetDefault("port", 8080)
viper.SetDefault("read_timeout", "30s")
viper.SetDefault("write_timeout", "30s")
viper.SetConfigFile("config.yaml")
if err := viper.ReadInConfig(); err != nil {
return Config{}, err
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return Config{}, err
}
if err := validate.Struct(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}Теги живут рядом с mapstructure-тегами: одна структура описывает и раскладку, и контракт. Дефолты ставятся до ReadInConfig — к моменту Unmarshal реестр viper уже содержит их, и required срабатывает только на ключах без значения вовсе. validate.Struct в конце load() возвращает тот же ValidationErrors, что и в HTTP-хендлерах, — обработка ошибки та же самая.
Проверка: пустая env-переменная и required
Переменная APP_ENV задана пустой строкой (APP_ENV=), ни файла, ни дефолта для этого ключа нет. Поле Env string с тегом required. Что произойдёт?
Порты, таймауты и окружение: что ловит каждый тег#
required ставим на секреты и связи с внешним миром: DSN базы, ключи API — нулевая строка означает «ключ забыли», и сервис без базы не должен подниматься. min и max держат порт в диапазоне 1024–65535: непривилегированные порты ниже 1024 в контейнере обычно не нужны, а опечатка 65350 ловится верхней границей. oneof описывает окружение: oneof=dev stage prod — и опечатка production вместо prod падает на старте, а не при выкладке в чужой кластер; семантику перечня мы разбирали в девятом уроке рядом с условными тегами. Пары значений связывают gtefield и ltefield: WriteTimeout с тегом gtefield=ReadTimeout запрещает таймаут записи короче таймаута чтения; рядом существуют строгие gtfield и ltfield. У time.Duration параметр min парсится как длительность: min=1s отсекает ноль «не задано» — нулевая длительность означает забытый ключ, и required ловил бы её наравне; связка полей — уже работа gtefield, а поля разных типов проваливают правило: держите пары в одном типе. required_without описывает альтернативу: CertFile с тегом required_without=Autogenerate обязан быть заполнен, когда автогенерация выключена. Границы у min и max включительные — полиморфизм границ разобран в третьем уроке, и на конфиге он работает так же, как на форме: диапазон честнее проверять граничными значениями, и к этому вернётся урок про тесты.
Что вернёт проверка при порте 80
Прочитайте код и выберите, что он выведет:
// в config.yaml: port: 80
// Port int `validate:"required,min=1024,max=65535"`
//
// viper прочитал файл, Unmarshal разложил значения
err := validate.Struct(&cfg)
// что в err?env, пустые строки и дефолты до Unmarshal#
Окружение приходит строками — это разобрано в курсе viper: слабая типизация декодера превращает «8080» в int, пустую строку в ноль, а длительность «30s» парсится тем же хуком, что и в GetDuration. Отсюда три честных места конвейера. Первое: пустая переменная по умолчанию считается незаданной — viper пропустит её и возьмёт значение ниже по лестнице; если по ключу нет ни файла, ни дефолта, поле останется нулевым и required упадёт. Переключатель AllowEmptyEnv делает пустоту легальным значением — итог для конвейера не меняется: битый конфиг ловится на старте. Второе: дефолты ставятся до чтения. viper.SetDefault до ReadInConfig — и к моменту Unmarshal реестр полон: required срабатывает только на ключах, которых нет нигде, а не на «порядок вызовов перепутали»; дефолт после Unmarshal в структуру не попадёт вовсе — реестр уже выгружен. Третье: nil против нуля. Поле Retries *int остаётся nil, когда ключа нет ни в одном слое, и показывает 0, когда ключ есть с нулевым значением: required на nil-указателе падает, на указателе с нулём — проходит. Звёздочка в объявлении покупает различие «не задано» и «задано нулём», которого строке не хватает. И не забывайте про BindEnv: автоматические env-ключи Unmarshal не увидит — ключи, за которые отвечают теги, привязывайте явно, иначе required упадёт на ключе, который на самом деле задан.
Поле конфига — теги — что поймает#
| Поле | Теги | Что поймает на старте |
|---|---|---|
| Port int | required,min=1024,max=65535 | забытый порт, привилегированный порт, опечатка 65350 |
| Env string | required,oneof=dev stage prod | опечатка production вместо prod, пустое окружение |
| ReadTimeout time.Duration | required,min=1s | нулевая длительность — «не задано» |
| WriteTimeout time.Duration | required,gtefield=ReadTimeout | запись короче чтения — рассинхрон пары |
| DatabaseDSN string | required | ключ без DSN: сервис не поднимется без базы |
| CertFile string | required_without=Autogenerate | ни файла сертификата, ни автогенерации |
Инварианты руками: то, что тегами не выразить#
validate.Struct — не конец конвейера. Теги видят только значения полей: они не умеют проверить, что файл сертификата существует, что каталог логов создаётся, что DSN не просто непустой, а парсится с хостом. Эти проверки — отдельная функция checkInvariants(cfg) error сразу после validate.Struct, обычный Go: os.Stat по сертификату, url.Parse по DSN с проверкой u.Host — приём тот же, что курс viper показывал на webhook-адресах. Правило разделения простое: теги — формат, границы и связи полей; руками — всё, что касается внешнего мира, потому что функция-тег обязана быть чистым предикатом, и файловая система внутри неё — нарушение контракта из десятого урока. Не тащите в инварианты и то, что проверяется использованием: доступен ли хост базы — вопрос ping, а не старта; валидация, которая ходит по сети на запуске, превращается в нестабильный health-check и через пару релизов выкидывается. Граница честная: синтаксис и наличие — на старте, живость — при использовании. Порядок тоже важен: инварианты после validate.Struct, а не до — незачем открывать файлы и парсить DSN, если теги уже нашли нулевой порт; битая структура должна останавливать запуск на самом дешёвом шаге.
Инварианты и main: fail fast в действии
GOfunc checkInvariants(cfg Config) error {
if !cfg.Autogenerate {
if _, err := os.Stat(cfg.CertFile); err != nil {
return fmt.Errorf("cert_file: %w", err)
}
}
u, err := url.Parse(cfg.DatabaseDSN)
if err != nil || u.Host == "" {
return fmt.Errorf("database_dsn: нет хоста в DSN")
}
return nil
}
func main() {
cfg, err := load()
if err != nil {
log.Fatal(err) // список полей, не сам конфиг
}
if err := checkInvariants(cfg); err != nil {
log.Fatal(err)
}
// дальше: ListenAndServe, воркеры, соединения
}Инварианты — обычный Go после validate.Struct: файл сертификата проверяется os.Stat только когда автогенерация выключена, DSN — url.Parse с проверкой хоста. Каждый шаг возвращает ошибку с именем ключа: дежурный чинит конфиг по одному логу старта, без рестарт-лотереи, и секретные значения из конфига в лог не попадают.
Починка стартовой ошибки свелась к строке log.Printf("%+v", cfg) перед log.Fatal — и в систему сбора логов уехал DatabaseDSN с паролем и ключ внешнего API: конфиг сам стал утечкой. Если хочется понять, какое поле провалилось, — печатайте Field() и Tag() каждой записи ValidationErrors: имя поля и споткнувшееся правило дают дежурному всё, а Value() для секретов не печатается никогда — там лежит живой DSN. Если после списка ошибок всё ещё хочется показать значения, вспомните правило с если-то: если поле секретное, то в логе живут только его имя и тег ошибки, а не значение.
Горячая перезагрузка: конвейер не только на старте#
Курс viper показывал WatchConfig: конфиг может перечитываться без рестарта, и каждая новая версия обязана пройти тот же конвейер — Unmarshal, validate.Struct, инварианты. Разница в реакции: на старте битая версия роняет процесс через log.Fatal, в рантайме убивать живой сервис нельзя — логируйте список Field() и Tag() и продолжайте работать прежней версией. Удобно, что конвейер один и тот же: load() возвращает ошибку — старт решает умереть, reload решает откатиться. Если проверки разъедутся — на старте теги, при перезагрузке «на глазок» — через месяц в живом процессе появится значение, которое старт никогда бы не принял. Горячая перезагрузка без валидации — способ запустить битый конфиг в обход всего, что построено выше: правило одно — нет конвейера, нет перезагрузки. Сюда же относится и честная оговорка про порядок: новая версия конфига проверяется целиком, а не диффом — теги смотрят на поля структуры, им всё равно, из какого слоя приехало значение. Тест, который докажет, что теги на Config не потерялись при рефакторинге, — тема девятнадцатого урока, и для конфига он важнее, чем для формы: цену пропущенного правила платит не пользователь, а дежурный в три ночи.
Конвейер валидации конфига одним списком#
- порядок — часть контракта: SetDefault → ReadInConfig → Unmarshal → validate.Struct → инварианты
- дефолты — до чтения файла: required срабатывает только на действительно отсутствующих ключах
- required — для секретов и DSN; min и max — для портов и таймаутов; oneof — для окружения
- gtefield и ltefield связывают поля: таймаут записи не меньше таймаута чтения
- required_without описывает альтернативу: файл сертификата или автогенерация
- теги не касаются внешнего мира: os.Stat и url.Parse — отдельной функцией после Struct
- в лог старта — Field() и Tag(), не сам конфиг: секреты не печатаются
- горячая перезагрузка проходит тот же конвейер, но падение не убивает живой процесс
Проверка: пара «сертификат или автогенерация»
CertFile пустой, Autogenerate=false, на CertFile тег required_without=Autogenerate. Что вернёт validate.Struct?
Соберите load() по конвейеру
Четыре пропуска: дефолт, чтение файла, раскладка в структуру и проверка тегов.
Соберите конвейер старта приложения
Сервис поднимается с конфигом, в котором опечатка в окружении. Расставьте шаги от запуска до порта.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: конфиг под валидацией
viper и validator — внешние модули: песочница сайта исполняет только чистый Go без go get, поэтому конвейер конфига собирается локально — go mod init, два go get, go run main.go с заведомо битым конфигом. Три запуска стоят часа дебага: порт 80, пустая APP_ENV без дефолта, Autogenerate=false без файла сертификата. Каждый должен уронить старт списком полей до первого соединения — если сервис поднялся, конвейер стоит не там.
Дальше по курсу — тесты правил: таблицы кейсов: теги на Config тоже живут в рантайме, и табличный тест на каждый тег — страховка, которую рефакторинг не обманет. Полиморфизм границ и семью required_without повторите в уроках третьем и девятом, а полную карту чтения конфига — файл, окружение, приоритеты, перезагрузка — ведёт курс viper. Проверка конфига закрывает прикладную половину курса: дальше правила делают незыблемыми тестами, и конвейер из этого урока станет у них первым кейсом — таблица, где кандидатом выступает сам Config.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Конфиг — данные из внешнего мира: проверяйте его тем же validate.Struct на входе приложения, до первого соединения.
- Порядок конвейера — контракт: SetDefault → ReadInConfig → Unmarshal → validate.Struct → инварианты руками.
- required ловит забытое и пустое: DSN без значения, ключ без дефолта, незаданная переменная.
- min и max держат порт в 1024–65535, oneof — окружение в перечне, gtefield — пары таймаутов.
- Дефолты ставятся до чтения файла: required срабатывает только на действительно отсутствующих ключах.
- Теги не касаются внешнего мира: os.Stat и url.Parse — отдельной функцией после validate.Struct.
- В лог старта — Field() и Tag(), не сам конфиг: секретам в логе не место.
Домашнее задание
Соберите Config из урока и load() по конвейеру, затем прогоните пять конфигов: порт 80, порт отсутствует при заданном дефолте, APP_ENV=production, пустая APP_ENV без дефолта, WriteTimeout короче ReadTimeout. Для каждого запишите Field() и Tag() ошибок и убедитесь, что список прилетает целиком, а не по одной записи за рестарт. Добавьте checkInvariants с os.Stat и url.Parse и проверьте конфиг, валидный для тегов, но с несуществующим файлом сертификата: старт обязан упасть уже после validate.Struct — на ручных инвариантах.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы не проверить конфиг ручными if-ами, как в курсе viper?
Можно — для трёх полей это честно, и курс viper так и делает. Теги выигрывают с ростом: правила живут рядом с полями и не рассыпаются по коду, а validate.Struct возвращает все провалы списком, а не первый if за рестарт. Ручной код остаётся там, где теги бессильны: файловая система, парсинг DSN, согласованность с внешним миром.
Что изменит AllowEmptyEnv(true)?
Пустая переменная станет легальным значением: строка уедет в поле как есть, int-поле слабая типизация превратит в ноль. required упадёт в обоих случаях — итог для конвейера тот же, битый конфиг ловится на старте, — но причина в логе будет другой: включённая пустота против незаданного ключа. Включайте переключатель, только если пустота — осмысленное значение какого-то поля.
Зачем gtefield, если можно сравнить таймауты вручную после validate.Struct?
Тег держит правило в объявлении поля: тот, кто добавляет WriteTimeout, видит связку сразу и не забудет скопировать ручной if в новый сервис. Ручная проверка — ещё одна ветка в ещё одной функции, которая теряется при копировании конвейера. Бонус — готовое сообщение: Tag() gtefield обслуживается каталогом сообщений без отдельной ветки, как любой встроенный тег.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Asynq в Go: очередь фоновых задач на Redis
Курс asynq для Go: клиенты и воркеры, отложенные и периодические задачи, ретраи, очереди с приоритетами, агрегация, Inspector и asynqmon. 25 уроков.
- Строить клиент и воркер asynq и подключать Redis
- Отправлять отложенные, периодические и уникальные задачи
- Настраивать ретраи, таймауты и приоритеты очередей
- Мониторить задачи через Inspector и asynqmon