Содержание урока · 10 разделов
Вторник, вечер, прод. Инженер правит host базы в config.yaml, перезапускает сервис — и тот уходит в старый хост, как будто файла не существует. Конфиг syntactically безупречен, ключ на месте, в логах чисто. Через три часа находится виновник: в docker-compose.yml от эксперимента полугодовой давности осталась переменная APP_DATABASE_HOST, а окружение в viper перекрывает файл — не баг, а документированный порядок. Первые восемь уроков каждый слой лестницы разбирался по одному: файл, дефолты, окружение с префиксами, флаги. Теперь слои собираются в одну картину: официальный порядок из документации, ловушка на стыке флага и деплоя, тест, который держит лестницу под замком. И честно про практику: примеры этого урока требуют файлов, переменных и флагов.
Официальный порядок viper: шесть слоёв#
Документация viper фиксирует порядок слияния источников одной строкой на слой: explicit call to Set, затем флаги, затем переменные окружения, затем конфиг-файлы, затем внешние key/value-хранилища и в самом низу дефолты. Каждый пункт перекрывает всё, что ниже, и порядок один на все ключи реестра — менять его нельзя и не нужно. За каждым слоем стоит конкретный вызов из предыдущих уроков: Set из первого, BindPFlag из восьмого, AutomaticEnv и BindEnv из шестого и седьмого, ReadInConfig из второго, SetDefault из пятого. KV-хранилище — единственный слой, к которому мы ещё не подходили: etcd, Consul и Firestore через remote-провайдер, и в типичном сервисе он не нужен. Нюанс, без которого лестница читается неверно: «флаги» в списке означают переданные флаги — viper проверяет flag.Changed при каждом чтении, непереданный флаг из конкуренции выпадает. Всё, что важно про этот слой, разобрано в уроке про BindPFlag; слепая зона окружения — в уроке про AutomaticEnv.
Шесть слоёв в одной программе
GOviper.SetDefault("server.port", 7070) // слой 6: дефолт
viper.SetConfigFile("config.yaml") // слой 4 готовится к чтению
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() // слой 3: окружение
if err := viper.ReadInConfig(); err != nil { // слой 4: файл
log.Fatalf("конфиг: %v", err)
}
viper.BindPFlag("server.port", rootCmd.Flags().Lookup("port")) // слой 2
viper.Set("server.port", 6000) // слой 1: самый сильный
// config.yaml задаёт port: 9000, в окружении SERVER_PORT=9090,
// сервис запущен с --port 7000
fmt.Println(viper.GetInt("server.port")) // 6000Шесть слоёв перечислены подряд — но порядок вызовов ничего не решает: силы слоёв фиксированы, и Set на последней строке всё равно перекрывает всех. Переставьте его выше ReadInConfig — результат тот же: реестр сверяет слои в момент Get, а не в момент вызова. Пощёлкать источниками вживую — флагом против файла, переменной против дефолта — можно только локальным Go: соберите бинарник и меняйте по одному слою.
server.port на каждом слое: как читать лестницу#
Одна и та же команда Get собрала шесть кандидатов на значение server.port: 6000 от Set, 7000 от переданного флага, 9090 из переменной SERVER_PORT, 9000 из config.yaml, значение из KV-хранилища и 7070 от SetDefault. Победил верхний слой — остальные не «усредняются» и не «пересматриваются», а игнорируются целиком: реестр не смешивает значения, он выбирает один. Отсюда практическое чтение лестницы: фраза «файл перекрыт» означает, что значение файла по этому ключу не видит никто; вернуть файлу силу можно только снятием слоя выше, а не добавлением ему веса снизу. Шестой слой — внешние хранилища — вписан документацией между файлом и дефолтами: etcd или Consul, подключённые remote-провайдером, отдают конфиг кластеру сервисов. Слой законный, но редкий: отдельная интеграция и отдельная точка отказа; здесь он упомянут для полноты, потому что лестница из документации насчитывает ровно шесть пунктов.
Лестница для server.port: слой, вызов, значение#
| Слой | Вызов или источник | server.port | Когда побеждает |
|---|---|---|---|
| 1. Set | viper.Set("server.port", 6000) | 6000 | программно; сильнее всех, порядок вызовов не важен |
| 2. Флаг | BindPFlag, ./app --port 7000 | 7000 | флаг передан пользователем: Changed=true |
| 3. env | AutomaticEnv, SERVER_PORT=9090 | 9090 | переменная задана в деплое |
| 4. Конфиг-файл | ReadInConfig, port: 9000 | 9000 | база проекта: ключ есть в файле |
| 5. KV-хранилище | remote-провайдер: etcd, Consul | ключ в хранилище | централизованные конфиги кластера |
| 6. Дефолт | SetDefault 7070 | 7070 | выше никто ключ не объявил |
| Хвост | дефолт непереданного флага | 8080 | последний шанс после всех слоёв |
Сервис в проде читает не то значение, которое записано в config.yaml: host базы правили в файле три часа — сервис ходил в старый. Причина: в docker-compose.yml от старого эксперимента осталась APP_DATABASE_HOST. Окружение перекрывает файл по документированному порядку, и никакой ошибки тут нет. Диагностика на будущее: печатайте конкретные ключи через Get — дамп AllSettings слеп к AutomaticEnv — и заглядывайте в переменные процесса контейнера.
Ловушка, которую восьмой урок разобрал по частям, в общей лестнице встаёт на своё место. Слой «флаг» означает переданный флаг: viper читает flag.Changed в момент каждого Get. Скрипт деплоя вызывает ./app --port 8080 — значение совпадает с дефолтом флага, пользователь «ничего не менял», но Changed равно true: флаговый слой заморожен выше файла на каждом запуске, и config.yaml по server.port мёртв навсегда, без единой ошибки в логах. Вторая половина ловушки — непереданный флаг: он из конкуренции выпадает, а его дефолт достаётся последним шансом, ниже SetDefault и KV-хранилища, отчего опечатанный ключ в yaml тихо заменяется флаговым значением. Тот же порядок со стороны CLI разобран в уроке про приоритеты конфигурации Cobra — там флаг, env и файл встречаются без реестра. Правило остаётся: флаги — для разовых перекрытий человеком, всё постоянное живёт в файле и окружении.
Тест лестницы на viper.New()
GOfunc TestLadder_envOverFileOverDefault(t *testing.T) {
v := viper.New()
v.SetConfigType("yaml")
yaml := "server:\n port: 9000\n"
if err := v.ReadConfig(strings.NewReader(yaml)); err != nil {
t.Fatal(err)
}
v.SetDefault("server.port", 7070)
t.Setenv("SERVER_PORT", "9090")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if got := v.GetInt("server.port"); got != 9090 {
t.Fatalf("env должен перекрыть файл: got %d", got)
}
v.Set("server.port", 6000)
if got := v.GetInt("server.port"); got != 6000 {
t.Fatalf("Set должен перекрыть env: got %d", got)
}
}Тест читает YAML из строки — файловая система не нужна, поведение детерминировано. viper.New() выдаёт отдельный экземпляр реестра: глобальный viper не задействован, состояние между тестами не перемешивается. t.Setenv ставит переменную на время теста и снимает её после; функция доступна с Go 1.17. Три утверждения фиксируют три ступени: env сильнее файла, файл сильнее дефолта, Set сильнее env. Запуск — go test локальным Go: viper не входит в стандартную библиотеку, и песочница сайта его не умеет.
Лестницу надо тестировать#
Порядок приоритетов деградирует тихо: коллега добавил BindPFlags(rootCmd.PersistentFlags()) на весь набор флагов — и слой флага расползся на ключи, для которых флагов никто не задумывал; тест, проверявший один ключ, остался зелёным. Реестр не отвечает, откуда пришло значение: в viper нет API «источник этого значения», и сломанную лестницу видно только по поведению. Поэтому лестница — контракт, и контракт тестируют. Схема: viper.New() даёт чистый реестр, YAML подаётся строкой через ReadConfig, переменная ставится t.Setenv, каждое ожидание — отдельное утверждение: файл сильнее дефолта, env сильнее файла, Set сильнее env, непереданный флаг не мешает. Двадцать строк теста ловят ровно те сюрпризы, что врезка выше нашла за три часа в проде: сместился слой — тест краснеет в pull request, а не в дежурство.
main вызывает ReadInConfig с файлом и префиксом, а тест собирает реестр по памяти: SetDefault есть, AutomaticEnv забыли. Тест проходит, но приоритеты в тесте отличаются от боевых, и защита, которую он обещает, фиктивна. Лечение одно: сборка реестра в общей функции, которую зовут и main, и тесты — каркас ниже.
Одна сборка реестра для main и тестов
GOfunc buildRegistry(v *viper.Viper) error {
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
v.SetConfigFile("config.yaml")
if err := v.ReadInConfig(); err != nil {
return err
}
v.SetDefault("log.level", "info")
return nil
}
func main() {
v := viper.New()
if err := buildRegistry(v); err != nil {
log.Fatal(err)
}
fmt.Println(v.GetInt("server.port"))
}Единственная точка сборки: replacer, окружение, файл и дефолты в одной функции. main создаёт чистый экземпляр viper.New() и передаёт параметром — глобальный реестр не участвует. Тест из блока выше заменяет ручную сборку на тот же buildRegistry и проверяет настоящую лестницу, а не тестовую копию. Файл config.yaml читается настоящей файловой системой — и в этом смысл проверки.
Спор: глобальный viper или viper.New() с DI#
Вокруг viper.New() идёт ровный многолетний спор. Первый лагерь: глобальный viper короче — viper.Get по всему коду, ноль церемоний, и в утилите с двумя ключами это честный выбор. Второй: v := viper.New() в main, передача реестра через зависимости — потребители не прячут глобальное состояние, тесты не дерутся за общий реестр. Моя позиция с границей: в сервисе, где конфиг читают один-два пакета, я оставляю глобальный viper ради простоты, но лестницу приоритетов всё равно гоняю тестом на viper.New() — новый экземпляр стоит вызова функции, а ловит больше, чем линтер. Как только потребителей становится три и больше или появляется второй конфиг-файл, перехожу на New с передачей целиком: глобальное состояние к этому моменту стоит дороже удобства. Команды с развитой культурой DI зовут глобальный viper легаси с первого дня — и в их контексте это рабочее мнение: спор здесь про контекст, а не про истину.
Гигиена приоритетов#
- Печатайте значения ключей на старте через Get, а не AllSettings: дамп слеп к окружению, а Get сходят по лестнице честно.
- Собирайте реестр одной функцией для main и тестов: лестница, собранная дважды разными руками, — две разные лестницы.
- Флаги — слой разовых перекрытий человеком: скрипт, передающий флаг на каждом запуске, навсегда замораживает слой выше файла.
- Дефолты объявляйте SetDefault, а не «дефолтом флага»: последний шанс ниже всех, и IsSet его не видит.
- После любого изменения сборки реестра обновляйте тест лестницы: порядок без теста деградирует молча.
- KV-хранилище подключайте осознанно: слой между файлом и дефолтом тянет интеграцию с etcd или Consul и отдельную точку отказа.
Проверка: env против файла
config.yaml задаёт server.port: 9000, SetDefault ставит 7070, replacer настроен, AutomaticEnv включена, в окружении SERVER_PORT=9090. Что вернёт viper.GetInt("server.port")?
Проверка: флаг с дефолтным значением
Скрипт деплоя запускает ./app --port 8080, где 8080 — значение по умолчанию флага, привязанного BindPFlag к ключу server.port. В config.yaml записан port: 9000. Что вернёт GetInt?
Что выведет программа?
Прочитайте код и выберите, что он выведет:
v := viper.New()
v.SetConfigType("yaml")
v.ReadConfig(strings.NewReader("server:\n port: 9000\n"))
v.SetDefault("server.port", 7070)
v.Set("server.port", 6000)
fmt.Println(v.GetInt("server.port"))Соберите тест лестницы
Допишите создание чистого реестра, чтение YAML из строки, установку переменной окружения в тесте и чтение значения.
Соберите сборку реестра по шагам
Расставьте строки: чистый экземпляр, replacer, окружение, чтение файла и вывод значения.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: приоритеты источников
Что дальше#
Лестница собрана целиком: шесть слоёв из документации, флаговый хвост на своём месте, порядок под тестом на viper.New(). Последний большой приём чтения — Unmarshal: конфиг целиком в структуру одним вызовом, вместо десятка GetString по коду; он же встретит нас новой ловушкой на стыке с окружением. Домой, к маршруту по урокам и темам, ведёт страница курса viper. Домашняя практика из реальной жизни: соберите buildRegistry, накройте тестом и сломайте слой намеренно — уберите AutomaticEnv и посмотрите, как тест ловит деградацию раньше дежурства.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Документированный порядок viper: Set → флаг → env → конфиг-файл → key/value-хранилище → дефолт; один на все ключи, менять нельзя.
- «Флаг» в лестнице — переданный: viper проверяет Changed при каждом Get; непереданный флаг из конкуренции выпадает.
- Дефолт непереданного флага — последний шанс ниже SetDefault; IsSet его не видит.
- Флаг, который скрипт деплоя передаёт на каждом запуске, замораживает слой выше файла — даже если значение равно дефолту флага.
- Реестр не отвечает, откуда пришло значение; сломанная лестница видна только по поведению — поэтому её закрывают тестом.
- viper.New() даёт чистый реестр: тест с ReadConfig из строки и t.Setenv детерминирован и не трогает глобальное состояние.
- Собирайте реестр одной функцией для main и тестов: две сборки — две разные лестницы.
Домашнее задание
Вынесите сборку реестра в функцию buildRegistry и напишите TestLadder на viper.New(): YAML из строки задаёт порт 9000, дефолт — 7070, через t.Setenv приходит 9090. Три утверждения: файл сильнее дефолта, env сильнее файла, Set сильнее env. Прогон — локальным go test.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли узнать, из какого слоя viper взял значение?
Прямого API нет: реестр не хранит происхождение значений. Рабочий способ — изоляция по одному слою: уберите переменную, закомментируйте ключ в файле, снимите привязку флага и смотрите на Get. В тесте на viper.New() слои включаются по одному — эффект виден сразу.
Порядок слоёв можно настроить?
Нет: лестница зашита в viper, и она одна на все ключи. Собственный порядок даёт слой над реестром — свой пакет config, где вы сами решаете, что перекрывает что; в курсе ему посвящён отдельный урок. Перестановка слоёв в самом viper не предусмотрена — решение авторов.
KV-хранилище — это Redis?
Нет: remote-провайдер viper работает с etcd, Consul и Firestore — список из документации. Redis в нём нет. Слой стоит между конфиг-файлом и дефолтами и подключается импортом провайдера и AddRemoteProvider; для большинства сервисов это лишняя интеграция, файл и окружение покрывают всё.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Unmarshal: конфиг целиком в структуру
- Вложенные ключи: деревья и mapstructure
- WatchConfig и OnConfigChange: живой конфиг
- WriteConfig и SafeWriteConfig: сохраняем состояние
- BindPFlag: флаги командной строки в конфиге
- SetEnvPrefix и BindEnv: разруливаем коллизии
- Переменные окружения: AutomaticEnv
- SetDefault: значения по умолчанию
- Все курсы по Go
Следующая ступень учебного пути
Chi: роутер для Go HTTP-сервисов
Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.
- Строить маршруты chi: методы, параметры пути, wildcards
- Писать middleware и понимать порядок их выполнения
- Группировать маршруты: Route, Group и подроутеры
- Прокидывать данные в хендлеры через context.Context