grep -rn "viper.GetString" ./cmd ./internal выдал 34 вхождения в 14 файлах, и это ещё приличный проект: ключ database.host опрашивается в хендлерах, воркерах, утилите миграции и одном скрипте, про который никто не помнит. Россыпь GetString — не вопрос вкуса, а зависимость всего проекта от глобального реестра: каждая строка с кавычками внутри — место, где опечатка превратится в тихий дефолт, а переименование ключа — в квест по репозиторию. Собственный пакет config решает это структурно: обёртка над viper прячет реестр внутри одного пакета, наружу выходят Load() и структура Config, а строковые ключи остаются в единственном файле. В этом уроке собираем такой пакет: чтение через viper.New(), перенос значений в поля, валидация обязательного на входе и честный разговор о том, когда обёртка не нужна вовсе.
Пакет config go: одна точка доступа#
Пакет config go устроен просто: каталог config в проекте, внутри файл config.go — и это единственное место в репозитории, где импортируется viper. Наружу пакет отдаёт две вещи: функцию Load(), которая читает источники и возвращает готовую структуру, и саму структуру Config с полями по секциям конфига. Всё остальное — реестр, имена ключей, замена разделителей — остаётся приватной деталью. Перенос значений из карты viper в поля делает Unmarshal из урока про структуры: mapstructure сверяет имя поля с ключом без учёта регистра, а там, где имена расходятся, работает тег. Отдельный инстанс вместо глобального реестра — принцип из урока про профили окружений: viper.New() вызывается внутри Load, живёт ровно на время чтения и наружу не выходит. Глобальное состояние пакета заменяется возвращаемым значением — и это меняет всё, от рефакторинга до тестов.
config/config.go: viper живёт здесь и только здесь
GOpackage config
type Server struct {
Host string
Port int
}
type Database struct {
Host string
Name string
}
type Config struct {
Server Server
Database Database
}
func Load() (Config, error) {
v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
if err := v.ReadInConfig(); err != nil {
return Config{}, err
}
v.SetEnvPrefix("APP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return Config{}, err
}
return cfg, nil
}Ни одного тега не понадобилось: поля сверяются с ключами по имени без учёта регистра, поэтому server.Host из YAML попадает в Server.Host. Пакет не держит глобальной переменной — реестр рождается в Load и умирает вместе с ней. Env-слой подключён той же связкой, что и раньше: значения из окружения перекрывают файл на чтении через Get, а карта для Unmarshal собирается из известных реестру ключей.
Методы вместо строковых ключей#
Главный выигрыш наступает сразу после Load: настройки превращаются из строк в поля. main читает cfg.Server.Port, воркер — cfg.Database.Host, и опечатка ловится компилятором, а не дефолтом в проде: cfg.Database.Hsot не скомпилируется, тогда как viper.GetString("database.hsot") молча вернул бы пустую строку. Автодополнение показывает, какие настройки вообще существуют: схема конфига теперь живёт в структуре, а не в головах и не в документации. Строковые ключи при этом не исчезают — они свёрнуты в одно место: server.port стал полем Port секции Server через совпадение имён, а где имя поля расходится с ключом, в config.go ставится тег mapstructure. Узнать, как ключ называется в YAML, теперь значит открыть один файл пакета, а не гнать grep по проекту. Для пакет config go это и есть главная смена ролей: viper перестаёт быть публичным реестром и становится деталью реализации.
main без viper
GOcfg, err := config.Load()
if err != nil {
log.Fatalf("конфигурация: %v", err)
}
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
log.Println("стартуем на", addr, "база:", cfg.Database.Host)
// дальше по коду — только поля структуры, ни одного GetStringmain не знает про viper ничего: пакет вернул структуру, и дальше в коде только поля. Адрес сервера собирается из секции Server, хост базы — из Database; если завтра ключ переедет в другую секцию, правка коснётся config.go и мест использования поля — компилятор перечислит их сам. Соберите каркас локальным Go: go mod init, viper в зависимостях, configs/config.yaml рядом с бинарником.
Ключ database.host переименовали в db.host: grep нашёл 14 мест с viper.GetString("database.host"), поправили 13. Четырнадцатое жило в сервисе рассылок, который на ревью не попал, — он молча взял дефолт localhost и неделю писал уведомления в никуда. После переезда на пакет config та же правка — это переименование поля в config.go: компилятор показывает все места использования, и пропустить одно невозможно. Россыпь GetString превращает переименование конфига в поиск по репозиторию, структура — в обычный рефакторинг.
Валидация на входе: Load() возвращает ошибку#
Обёртка меняет контракт ошибок: раз все чтения сходятся в Load(), у настройки появляется момент, когда её обязаны проверить. Пустой хост базы, нулевой порт, таймаут в секундах вместо миллисекунд — всё это ловится в Load до того, как сервис начнёт делать вид, что здоров: функция возвращает (Config, error), и main решает, падать на старте или ехать на безопасных значениях. Лестница источников внутри пакета та же, что на всём курсе: env выше файла, файл выше дефолтов, — но проверка существования значения из «по желанию» превращается в обязанность, потому что нулевые поля структуры отличаются от отсутствующей настройки только валидацией. Ещё одно правило пакета: Load не пишет в лог и не зовёт log.Fatal — решение о смерти принимает main, пакет лишь честно докладывает об ошибке. Это же готовит почву для следующего урока: там валидация конфига становится системой, а здесь — привычка проверять обязательное на входе.
validate внутри пакета
GOfunc validate(cfg Config) error {
if cfg.Server.Port <= 0 || cfg.Server.Port > 65535 {
return fmt.Errorf("server.port: некорректный порт %d", cfg.Server.Port)
}
if cfg.Database.Host == "" {
return errors.New("database.host не задан: проверьте config.yaml и APP_DATABASE_HOST")
}
return nil
}
// в Load, после Unmarshal:
// if err := validate(cfg); err != nil {
// return Config{}, err
// }Проверки тривиальны — и именно поэтому им место в одном месте: validate вызывается в Load перед возвратом, и ни один вызывающий не обязан помнить про пустые поля. Сообщение называет и ключ, и имя переменной — дежурный читает ошибку, не открывая исходники. Воспроизведите каркас локальным Go: конфиг без database.host роняет старт с внятной строкой в логе.
Было и стало#
| Было: viper по всему коду | Стало: пакет config |
|---|---|
| viper.GetString("database.host") в 14 файлах | cfg.Database.Host — поле структуры |
| Глобальный реестр viper.Get... | viper.New() внутри Load, наружу не выходит |
| Опечатка в ключе — тихий дефолт | Опечатка в поле — ошибка компиляции |
| Валидация размазана или отсутствует | Load() возвращает ошибку на входе |
| Тесты стартуют с общим глобальным состоянием | Тесты собирают Config руками |
viper.New() вместо глобального реестра#
Документация viper отвечает на вопрос о глобальном синглтоне прямо: лучше инициализировать экземпляр и передавать его, глобальный реестр усложняет тесты и ведёт к неожиданному поведению, а глобальный вариант вообще могут убрать в будущем. Пакет config — место, где совет реализуется без усилий: viper.New() вызывается внутри Load, инстанс не переживает выход из функции, и глобального состояния в проекте просто нет. В уроке про профили отдельные реестры понадобились для контуров — здесь та же механика работает на всё приложение. Главный бонус достаётся тестам: им не нужен ни конфиг-файл, ни глобальный viper, тест собирает Config руками и передаёт в код под тестом. А если хочется проверить сам Load — отдельный инстанс с SetConfigName на временный каталог, но это уже тема отдельного урока про тестирование конфигурации.
Через полгода после внедрения пакета кто-то добавил в Config метод Viper() *viper.Viper — «чтобы не плодить поля для редких настроек». Редкие настройки оказались не такими уж редкими: через месяц половина кода снова держала viper.Get со строковыми ключами, и обёртка охраняла только половину проекта. Граница публичного API пакета — Load и поля структуры; любая протечка реестра наружу отменяет преимущества, а формулировка «на всякий случай» — самая частая подпись этой протечки в code review.
Правила пакета config#
- viper импортируется ровно в одном файле пакета: grep по остальному коду должен находить ноль вхождений.
- Load() возвращает (Config, error): чтение, перенос в структуру и валидация живут в одном вызове.
- Публичный API пакета — функция Load и поля Config; реестр, ключи и replacer остаются приватными.
- Обязательные настройки проверяются на входе: пустое поле — ошибка Load, а не сюрприз первого запроса.
- Ни один метод пакета не возвращает *viper.Viper и не принимает его в аргументах.
- Внутри пакета — собственный viper.New(), никакого глобального реестра: тестам не нужен файл на диске.
Проверка: опечатка в поле
В хендлере написано cfg.Database.Hsot — опечатка в имени поля. Где она проявится?
Проверка: отдельный реестр
Чем viper.New() внутри пакета config лучше глобального реестра viper.Get?
Что выведет программа?
Прочитайте код и выберите, что он выведет:
// configs/config.yaml: db_host: "db.internal"
type Config struct {
DbHost string
}
func Load() (Config, error) {
v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
v.ReadInConfig()
var cfg Config
v.Unmarshal(&cfg)
return cfg, nil
}
// main: cfg, _ := config.Load()
fmt.Println(cfg.DbHost == "")Соберите каркас Load
Допишите Load(): создание реестра, префикс окружения, чтение файла и перенос карты в структуру Config.
Соберите секцию Load
Расставьте строки каркаса Load() по порядку: сигнатура, создание реестра, имя файла, путь поиска, чтение и перенос значений в структуру.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: пакет config
Спор: обёртка против прямого viper#
Моё правило на code review: в сервисе, который живёт дольше пары релизов, viper не выходит за пределы пакета config. Аргументы весомые: один файл с ключами, рефакторинг вместо grep, валидация в одном месте, тесты без глобального состояния. Но у прямого viper есть честная территория, и обёртку туда я не тащу: маленький CLI с пятью настройками, внутренний скрипт, одноразовый инструмент. Там GetString по трём ключам в main короче и понятнее, а ценность структуры раскрывается только когда настроек и потребителей становится много. Плата обёртки реальна: лишний слой, который надо держать в голове, копирование полей при расширении конфига, соблазн протечки из прошлого раздела. Критерий простой: конфиг, который читает больше одного файла кода или переживает переименования, — пакет config; конфиг из трёх строк в main — прямой viper и никаких церемоний. Ошибка — тащить корпоративную обёртку в скрипт на сто строк.
Пакет config собран: viper.New() внутри Load, значения переносятся в структуру, обязательные поля проверяются на входе, наружу смотрят только поля Config. Остался последний рубеж — сама валидация: пустой порт, битый URL, таймаут не в тех единицах могут проехать через validate, если проверок мало. Следующий урок делает из пары if-ов систему валидации конфигурации: какие проверки обязательны для сервиса, как писать сообщения, которые читаются в три ночи, и что проверять в принципе нельзя. Общий маршрут по темам — на странице курса viper. Начните прямо сейчас: посчитайте viper.Get в своём проекте — если сумма больше пяти, пакету config там есть место.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Россыпь viper.GetString по всему коду — зависимость каждого файла от глобального реестра: опечатка даёт тихий дефолт, переименование — обход репозитория.
- Пакет config прячет viper в одном файле: наружу — Load(), возвращающая структуру Config, и сами поля.
- Настройки становятся полями: cfg.Database.Host ловит опечатку компиляцией, автодополнение показывает схему конфига.
- Load() — единственное место чтения и валидации: обязательные значения проверяются на входе, решение о Fatal остаётся main.
- Внутри пакета — собственный viper.New(): документация viper советует передавать экземпляр вместо глобального синглтона.
- Обёртка протекает через любой возврат *viper.Viper — границу публичного API охраняют жёстче, чем остальной код.
- Маленький CLI с тремя настройками — честная территория прямого viper; обёртка окупается, когда конфиг живёт дольше релиза.
Домашнее задание
Соберите пакет config из урока: структура Config с секциями Server и Database, Load() с viper.New(), ReadInConfig и Unmarshal, проверка пустого хоста базы на входе. Переведите main на cfg.Database.Host, затем переименуйте ключ в YAML и убедитесь, что править нужно один файл, а не россыпь GetString.
Личные заметкиПусто
Вопросы и ответы по уроку
Не дублирует ли пакет config сам viper?
Viper остаётся движком: лестница источников, файлы, окружение — его работа. Обёртка меняет интерфейс доступа: вместо глобального реестра со строковыми ключами — вызов Load и поля структуры. Знания о конкретных именах ключей живут в одном файле пакета, а не по всему проекту.
Куда девать env-перекрытия, если viper скрыт?
Никуда отдельно: связка SetEnvPrefix, SetEnvKeyReplacer и AutomaticEnv включается внутри Load рядом с ReadInConfig — реестр один, лестница та же. Пользователи пакета разницы не заметят: значения приходят уже в полях структуры, откуда бы они ни взялись.
Что лучше возвращать из Load: значение или указатель?
Практика — указатель: Config имеет свойство расти, копирование больших секций не нужно, а nil возвращается вместе с ошибкой. Для маленьких конфигов проще значение: оно не меняется после загрузки, и код не заботится о мутациях. Главное — выбрать один вариант и держать его во всём проекте.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Валидация конфигурации до запуска
- Тестирование конфигурации: New и Reset
- Удалённые хранилища: etcd и Consul
- Диагностика: почему viper не видит ключ
- Секреты: почему пароли не живут в YAML
- Профили dev, staging и prod
- RegisterAlias: синонимы ключей и миграция схемы
- Sub(): разбираем конфиг на поддеревья
- Все курсы по Go
Следующая ступень учебного пути
Chi: роутер для Go HTTP-сервисов
Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.
- Строить маршруты chi: методы, параметры пути, wildcards
- Писать middleware и понимать порядок их выполнения
- Группировать маршруты: Route, Group и подроутеры
- Прокидывать данные в хендлеры через context.Context