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

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

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

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

Валидация конфигов с Viper

Валидация конфига go — это fail fast на старте: конфиг приходит из внешнего мира — файл, окружение, флаги — и битое значение должно ронять приложение сразу, а не всплывать через час загадочным сбоем. В этом уроке ставим теги валидатора прямо на структуру Config, встраиваем проверку в конвейер viper — после Unmarshal, до первого соединения — и разбираем честные места: пустые env-переменные, дефолты, которые обязаны стоять до валидации, и секреты, которые нельзя печатать в лог.

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

Сервис поднялся, 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() по конвейеру

GO
type 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 intrequired,min=1024,max=65535забытый порт, привилегированный порт, опечатка 65350
Env stringrequired,oneof=dev stage prodопечатка production вместо prod, пустое окружение
ReadTimeout time.Durationrequired,min=1sнулевая длительность — «не задано»
WriteTimeout time.Durationrequired,gtefield=ReadTimeoutзапись короче чтения — рассинхрон пары
DatabaseDSN stringrequiredключ без DSN: сервис не поднимется без базы
CertFile stringrequired_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 в действии

GO
func 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 с проверкой хоста. Каждый шаг возвращает ошибку с именем ключа: дежурный чинит конфиг по одному логу старта, без рестарт-лотереи, и секретные значения из конфига в лог не попадают.

Горячая перезагрузка: конвейер не только на старте#

Курс 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() по конвейеру

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

func load() (Config, error) { viper.("read_timeout", "30s") // дефолт до чтения viper.SetConfigFile("config.yaml") if err := viper.(); err != nil { return Config{}, err } var cfg Config if err := viper.(&cfg); err != nil { return Config{}, err } if err := validate.(&cfg); err != nil { return Config{}, err } return cfg, nil }

Соберите конвейер старта приложения

Сервис поднимается с конфигом, в котором опечатка в окружении. Расставьте шаги от запуска до порта.

Строки

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

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

Карточки: конфиг под валидацией

Термин

конвейер старта

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

SetDefault → ReadInConfig → Unmarshal → validate.Struct → инварианты руками. Битый конфиг роняет старт до ListenAndServe, а не всплывает через час работы.

1 / 6

Дальше по курсу — тесты правил: таблицы кейсов: теги на 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 — на ручных инвариантах.

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

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

Почему бы не проверить конфиг ручными if-ами, как в курсе viper?

Можно — для трёх полей это честно, и курс viper так и делает. Теги выигрывают с ростом: правила живут рядом с полями и не рассыпаются по коду, а validate.Struct возвращает все провалы списком, а не первый if за рестарт. Ручной код остаётся там, где теги бессильны: файловая система, парсинг DSN, согласованность с внешним миром.

Что изменит AllowEmptyEnv(true)?

Пустая переменная станет легальным значением: строка уедет в поле как есть, int-поле слабая типизация превратит в ноль. required упадёт в обоих случаях — итог для конвейера тот же, битый конфиг ловится на старте, — но причина в логе будет другой: включённая пустота против незаданного ключа. Включайте переключатель, только если пустота — осмысленное значение какого-то поля.

Зачем gtefield, если можно сравнить таймауты вручную после validate.Struct?

Тег держит правило в объявлении поля: тот, кто добавляет WriteTimeout, видит связку сразу и не забудет скопировать ручной if в новый сервис. Ручная проверка — ещё одна ветка в ещё одной функции, которая теряется при копировании конвейера. Бонус — готовое сообщение: Tag() gtefield обслуживается каталогом сообщений без отдельной ветки, как любой встроенный тег.

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

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

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

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

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

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

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