---
title: "Пакет config: оборачиваем viper в свой API"
description: "Пакет config Go: оборачиваем viper в свой API — Load() со структурой Config, методы вместо строковых ключей, viper.New() внутри пакета и валидация."
course: "Viper: конфигурация приложений на Go"
url: "https://golangmaster.ru/courses/viper/lessons/18-paket-config/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 18
---

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

*Курс «Viper: конфигурация приложений на Go», урок 18 из 25. ~18 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/viper/lessons/18-paket-config/*

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

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 из урока про [структуры](/courses/viper/lessons/10-unmarshal/): mapstructure сверяет имя поля с ключом без учёта регистра, а там, где имена расходятся, работает тег. Отдельный инстанс вместо глобального реестра — принцип из урока про [профили окружений](/courses/viper/lessons/16-profili-okruzhenij/): 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 рядом с бинарником.

> **Переименование ключа, которое сломало 14 файлов.** Ключ database.host переименовали в db.host: grep нашёл 14 мест с viper.GetString("database.host"), поправили 13. Четырнадцатое жило в сервисе рассылок, который на ревью не попал, — он молча взял дефолт localhost и неделю писал уведомления в никуда. После переезда на пакет config та же правка — это переименование поля в config.go: компилятор показывает все места использования, и пропустить одно невозможно. Россыпь GetString превращает переименование конфига в поиск по репозиторию, структура — в обычный рефакторинг.

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

Обёртка меняет контракт ошибок: раз все чтения сходятся в Load(), у настройки появляется момент, когда её обязаны проверить. Пустой хост базы, нулевой порт, таймаут в секундах вместо миллисекунд — всё это ловится в Load до того, как сервис начнёт делать вид, что здоров: функция возвращает (Config, error), и main решает, падать на старте или ехать на безопасных значениях. Лестница источников внутри пакета та же, что [на всём курсе](/courses/viper/lessons/09-prioritety-istochnikov/): 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() *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 — опечатка в имени поля. Где она проявится?

- молча вернёт пустую строку, как GetString с опечатанным ключом
- ✅ на этапе компиляции: компилятор не найдёт такого поля
- на первом запросе — вернётся дефолт из SetDefault
- нигде: опечатки в полях структур Go не проверяются

Строка с опечаткой не скомпилируется — поля с таким именем нет. Со строковым ключом та же опечатка прошла бы незаметно: GetString вернул бы нулевое значение, и сервис жил с пустым хостом до первого коннекта к базе.

### Проверка: отдельный реестр

**Вопрос:** Чем viper.New() внутри пакета config лучше глобального реестра viper.Get?

- работает быстрее: отдельный реестр не блокируется
- поддерживает YAML: глобальный реестр читает только JSON
- ✅ тесты строят Config без файла и глобального состояния, и реестр не течёт между частями программы
- ничем: это просто другой синтаксис тех же вызовов

Документация viper прямо советует инициализировать экземпляр и передавать его: глобальный синглтон усложняет тесты и приводит к неожиданному поведению. Отдельный реестр внутри Load делает состояние пакета явным — оно возвращается структурой, а не прячется в глобальной переменной.

### Что выведет программа?

```go
// 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 == "")
```

**Что выведет программа?**

- false: mapstructure находит db_host для поля DbHost
- ✅ true: имя поля сверяется с ключом dbhost, а подчёркивание в db_host не совпадает
- панику: ключи с подчёркиваниями в viper не поддерживаются
- false: Unmarshal переносит значения по порядку объявления полей

mapstructure сверяет имя поля с ключом без учёта регистра, но подчёркивания не убирает: DbHost ищет ключ dbhost. Ключ db_host остаётся неперенесённым, и поле держит нулевое значение. Лечится это тегом mapstructure в config.go — то есть в единственном файле пакета, где и положено знать про ключи.

### Соберите каркас Load

Допишите Load(): создание реестра, префикс окружения, чтение файла и перенос карты в структуру Config.

```go
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
```

Подсказка: Реестр создаёт viper.New, префикс окружения задаёт SetEnvPrefix, карту реестра в структуру переносит Unmarshal.

Правильные ответы в пропусках: `New`, `Prefix`, `Unmarshal`

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

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

Правильный порядок строк:

1. `func Load() (Config, error) {`
2. `v := viper.New()`
3. `v.SetConfigName("config")`
4. `v.AddConfigPath("./configs")`
5. `v.ReadInConfig()`
6. `v.Unmarshal(&cfg)`

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

- **Пакет config** — Обёртка над viper: наружу только Load() и структура Config, внутренний реестр и строковые ключи скрыты в одном файле. viper импортируется ровно в одном месте репозитория.
- **viper.New()** — Отдельный экземпляр реестра со своей конфигурацией. Документация viper советует создавать экземпляр и передавать его: глобальный синглтон усложняет тесты и ведёт к неожиданному поведению.
- **Config как схема** — Настройки становятся полями: cfg.Database.Host. Опечатка ловится компилятором вместо тихого дефолта, автодополнение показывает, какие настройки существуют вообще.
- **Load() и ошибка** — Единственная точка чтения: файл, env и дефолты собираются здесь, обязательные значения проверяются на входе. log.Fatal в пакете не зовут — решение о смерти принимает main.
- **Протечка обёртки** — Возврат *viper.Viper из метода или GetString в чужих файлах сводит затею на нет: код снова зависит от реестра. Граница публичного API — Load и поля структуры.
- **Когда обёртка не нужна** — Маленький CLI с тремя-пятью настройками: прямой viper в main короче и честнее. Обёртка окупается, когда конфиг читает больше одного файла кода или переживает переименования ключей.

### Спор: обёртка против прямого viper

Моё правило на code review: в сервисе, который живёт дольше пары релизов, viper не выходит за пределы пакета config. Аргументы весомые: один файл с ключами, рефакторинг вместо grep, валидация в одном месте, тесты без глобального состояния. Но у прямого viper есть честная территория, и обёртку туда я не тащу: маленький CLI с пятью настройками, внутренний скрипт, одноразовый инструмент. Там GetString по трём ключам в main короче и понятнее, а ценность структуры раскрывается только когда настроек и потребителей становится много. Плата обёртки реальна: лишний слой, который надо держать в голове, копирование полей при расширении конфига, соблазн протечки из прошлого раздела. Критерий простой: конфиг, который читает больше одного файла кода или переживает переименования, — пакет config; конфиг из трёх строк в main — прямой viper и никаких церемоний. Ошибка — тащить корпоративную обёртку в скрипт на сто строк.

Пакет config собран: viper.New() внутри Load, значения переносятся в структуру, обязательные поля проверяются на входе, наружу смотрят только поля Config. Остался последний рубеж — сама валидация: пустой порт, битый URL, таймаут не в тех единицах могут проехать через validate, если проверок мало. Следующий урок делает из пары if-ов систему валидации конфигурации: какие проверки обязательны для сервиса, как писать сообщения, которые читаются в три ночи, и что проверять в принципе нельзя. Общий маршрут по темам — на [странице курса viper](/courses/viper/). Начните прямо сейчас: посчитайте viper.Get в своём проекте — если сумма больше пяти, пакету config там есть место.

## Коротко: что запомнить

- Россыпь 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 возвращается вместе с ошибкой. Для маленьких конфигов проще значение: оно не меняется после загрузки, и код не заботится о мутациях. Главное — выбрать один вариант и держать его во всём проекте.
