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

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

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

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

Пакет config: оборачиваем viper в свой API

Пакет config go — тонкая обёртка над viper: Load() возвращает готовую структуру Config, строки вроде viper.GetString("database.host") исчезают из всего кода, а внутренний реестр скрыт за полями. Покажем, как свернуть россыпь GetString в один пакет, что это даёт при переименовании ключей и где граница, за которой обёртка не нужна.

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

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 живёт здесь и только здесь

GO
package 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

GO
cfg, 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)

// дальше по коду — только поля структуры, ни одного GetString

main не знает про viper ничего: пакет вернул структуру, и дальше в коде только поля. Адрес сервера собирается из секции Server, хост базы — из Database; если завтра ключ переедет в другую секцию, правка коснётся config.go и мест использования поля — компилятор перечислит их сам. Соберите каркас локальным Go: go mod init, viper в зависимостях, configs/config.yaml рядом с бинарником.

Валидация на входе: Load() возвращает ошибку#

Обёртка меняет контракт ошибок: раз все чтения сходятся в Load(), у настройки появляется момент, когда её обязаны проверить. Пустой хост базы, нулевой порт, таймаут в секундах вместо миллисекунд — всё это ловится в Load до того, как сервис начнёт делать вид, что здоров: функция возвращает (Config, error), и main решает, падать на старте или ехать на безопасных значениях. Лестница источников внутри пакета та же, что на всём курсе: env выше файла, файл выше дефолтов, — но проверка существования значения из «по желанию» превращается в обязанность, потому что нулевые поля структуры отличаются от отсутствующей настройки только валидацией. Ещё одно правило пакета: Load не пишет в лог и не зовёт log.Fatal — решение о смерти принимает main, пакет лишь честно докладывает об ошибке. Это же готовит почву для следующего урока: там валидация конфига становится системой, а здесь — привычка проверять обязательное на входе.

validate внутри пакета

GO
func 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 импортируется ровно в одном файле пакета: 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.

v := viper.() v.SetEnv("APP") v.SetConfigName("config") v.AddConfigPath("./configs") if err := v.ReadInConfig(); err != nil { return Config{}, err } var cfg Config if err := v.(&cfg); err != nil { return Config{}, err } return cfg, nil

Соберите секцию Load

Расставьте строки каркаса Load() по порядку: сигнатура, создание реестра, имя файла, путь поиска, чтение и перенос значений в структуру.

Строки

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

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

Карточки: пакет config

Термин

Пакет config

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

Обёртка над viper: наружу только Load() и структура Config, внутренний реестр и строковые ключи скрыты в одном файле. viper импортируется ровно в одном месте репозитория.

1 / 6

Спор: обёртка против прямого 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.

Личные заметкиПусто
Хранятся только в вашем браузере (Viper · Пакет config: оборачиваем viper в свой API)

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

Не дублирует ли пакет config сам viper?

Viper остаётся движком: лестница источников, файлы, окружение — его работа. Обёртка меняет интерфейс доступа: вместо глобального реестра со строковыми ключами — вызов Load и поля структуры. Знания о конкретных именах ключей живут в одном файле пакета, а не по всему проекту.

Куда девать env-перекрытия, если viper скрыт?

Никуда отдельно: связка SetEnvPrefix, SetEnvKeyReplacer и AutomaticEnv включается внутри Load рядом с ReadInConfig — реестр один, лестница та же. Пользователи пакета разницы не заметят: значения приходят уже в полях структуры, откуда бы они ни взялись.

Что лучше возвращать из Load: значение или указатель?

Практика — указатель: Config имеет свойство расти, копирование больших секций не нужно, а nil возвращается вместе с ошибкой. Для маленьких конфигов проще значение: оно не меняется после загрузки, и код не заботится о мутациях. Главное — выбрать один вариант и держать его во всём проекте.

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

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

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

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

Chi: роутер для Go HTTP-сервисов

Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.

  • Строить маршруты chi: методы, параметры пути, wildcards
  • Писать middleware и понимать порядок их выполнения
  • Группировать маршруты: Route, Group и подроутеры
  • Прокидывать данные в хендлеры через context.Context