Содержание урока · 11 разделов
- viper Sub(): поддерево как отдельный реестр
- Изоляция: модуль БД не видит соседних ключей
- Спор: viper в конструкторе или структура на границе
- Sub и Unmarshal: типизированный срез
- Sub из Sub: вложенные деревья
- Get против Sub на одной секции
- Когда поддеревья лучше плоских ключей
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Два модуля в одном сервисе, и оба читают GetString("host") из общего реестра: первому нужен хост веб-сервера, второму — хост базы. Пока ключи уникальны, плоский реестр работает; как только названия пересекаются, начинаются тихие подмены значений, которые ловятся только в проде. viper Sub решает это структурно: конфиг разрезается на поддеревья, и каждый модуль получает свой срез — секцию database целиком, внутри которой ключи снова называются коротко. Разберём механику Sub: что возвращается, как связать поддерево с Unmarshal, зачем нужны вложенные Sub и чем срез отличается от Get("database") с картой. История о хосте веб-сервера в настройках базы покажет, где плоские ключи ломаются первыми. Примеры читают yaml-файлы, поэтому для запуска нужен локальный Go: песочница сайта конфиги не читает.
viper Sub(): поддерево как отдельный реестр#
viper.Sub("database") возвращает *viper.Viper — новый экземпляр, чей реестр содержит только содержимое секции database. Ключи внутри среза называются как в исходной секции: sub.GetString("host") читает database.host, но пишется без префикса. Возвращаемое значение — не копия данных в новом формате, а полноценный viper: доступны GetString, GetInt, GetDuration, IsSet, Unmarshal, Set — весь привычный API. Отсюда и название паттерна: модуль получает срез и дальше живёт в нём, не зная, как его секция называлась в общем конфиге. Ключевая деталь контракта: секции нет — Sub вернёт nil, а не пустой экземпляр, и первое же чтение на nil-указателе падает с паникой. Проверка на nil — часть вызова, а не перестраховка; ниже — врезка с разбором этой ловушки и соседней: скаляром вместо секции.
Первый срез
GO// config.yaml:
// database: {host: "db.local", port: 5432}
// server: {host: "web.local", port: 8080}
db := viper.Sub("database")
if db == nil {
log.Fatal("секции database нет в конфиге")
}
fmt.Println(db.GetString("host")) // db.local
fmt.Println(db.GetInt("port")) // 5432
fmt.Println(viper.GetString("server.host")) // web.localСрез читает ключи без префикса database.: host внутри db — это database.host из файла, и путаницы с server.host больше нет. Корневой реестр продолжает видеть всё целиком — срез не вырезает данные, а открывает отдельное окно на секцию.
Изоляция: модуль БД не видит соседних ключей#
Смысл поддеревьев — не краткая запись, а границы. Передайте NewStore срез database, и модуль физически не сможет прочитать server.port, worker.timeout или ключи соседей: их в его реестре просто нет. Это изоляция в действии — то же правило, что у пакетов и интерфейсов: сузили доступ, сузили поверхность ошибок. Модуль не зависит от имени своей секции: переименование database в postgres меняет одну строку на границе, а внутри модуля не меняется ничего — срезы строят снаружи и передают внутрь. И наоборот: модуль не может случайно записать Set-ключ, который попадёт в чужую секцию, — его Set работает только в его срезе. На проектах с несколькими командами это снимает целый класс споров о схеме конфига: каждая команда владеет своей секцией и её срезом, а общий корень остаётся только местом сборки. Дополнительный бонус подхода — тесты: модульный тест может собрать Sub-реестр из строки и проверить поведение модуля без общего конфига приложения.
Конструктор, принимающий срез
GOfunc NewStore(cfg *viper.Viper) (*Store, error) {
if cfg == nil {
return nil, errors.New("срез конфига БД не передан")
}
host := cfg.GetString("host")
port := cfg.GetInt("port")
timeout := cfg.GetDuration("timeout")
if host == "" || port <= 0 {
return nil, errors.New("host и port обязательны")
}
return &Store{dsn: fmt.Sprintf("postgres://%s:%d?timeout=%s", host, port, timeout)}, nil
}
// сборка на границе main:
store, err := NewStore(viper.Sub("database"))Конструктор принимает срез и читает ключи без префиксов: host здесь — database.host из файла. Проверка на nil и границы значений живут на границе модуля: пустой host ловится до первого соединения. Заметьте: NewStore не знает слова database — переименование секции его не заденет.
Спор: viper в конструкторе или структура на границе#
Передавать viper.Viper в конструкторы или маппить в структуру — самый спорный вопрос темы. Сторона среза в конструкторе выигрывает гибкостью: модуль читает всё, что появилось в его секции, без правки структур, а лениво добавленные ключи подхватываются без обновления контракта. Я в продуктовом коде выбираю обратное: на границе пакета config срез раскладывается в структуру через Unmarshal, и дальше модуль получает обычные данные — структуру с полями, проверками и тестами, в которой viper нет вообще. Моё правило жёстче, чем советуют примеры: viper не должен покидать пакет config, иначе библиотека расползается по коду, и заменить её однажды будет некому. Граница применимости честная: утилита на вечер или проект с одним main — прямой viper.Viper в конструкторе абсолютно нормален, цена замены копеечная. Как оформить такой пакет целиком — тема урока восемнадцатого, а механику маппинга разберём ниже.
Секции нет в конфиге — Sub возвращает nil, и первый же GetString на срезе падает с nil pointer dereference. Обязательный рефлекс: if db == nil до любого чтения. Вторая грань той же ловушки: если под ключом лежит скаляр вместо секции, nil вы не получите, но и валидного поддерева тоже — читаться будут нули или вовсе чужие значения. Под ключом среза живут только секции.
Sub и Unmarshal: типизированный срез#
Поддерево дружит с Unmarshal так же, как корневой реестр: db.Unmarshal(&cfg) раскладывает секцию в структуру с mapstructure-тегами. Это связка, которой я пользуюсь чаще всего: срез отрезает лишнее, Unmarshal типизирует — и модуль получает обычную структуру с полями и проверками. Бонус — изоляция работает и на этапе маппинга: чужие ключи в структуру не попадут, даже если секция разрослась. Тот же результат даёт UnmarshalKey("database", &cfg) на корневом viper — он строит временный срез под капотом. Разница в жанре: UnmarshalKey — одноразовое чтение, а срез, сохранённый в переменной, живёт дальше — его можно передать конструктору, дополнить Set-правками модуля, обернуть своей обёрткой. Для горячей перезагрузки у обеих схем одна проблема — устаревший слепок значений, о ней врезка ниже.
Срез раскладывается в структуру
GOtype DBConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Timeout time.Duration `mapstructure:"timeout"`
}
var dbCfg DBConfig
db := viper.Sub("database")
if db == nil {
log.Fatal("секции database нет в конфиге")
}
if err := db.Unmarshal(&dbCfg); err != nil {
log.Fatal(err)
}
fmt.Println(dbCfg.Host, dbCfg.Port, dbCfg.Timeout)Теги mapstructure те же, что и у корневого Unmarshal: типы приводит mapstructure, поле Timeout понимает строку 30s. Модуль дальше работает со структурой, а не с viper: конструктор с DBConfig тестируется картой значений без файлов и окружения.
Sub из Sub: вложенные деревья#
Секция внутри секции — не проблема: db.Sub("pool") вернёт поддерево второго уровня, где ключи pool снова станут корневыми. Так разбираются многоуровневые настройки: database.pool.size, payments.limits.day — глубина не мешает, каждый срез читает свой уровень короткими именами. Про вложенные ключи в общем реестре был урок одиннадцатый — Sub добавляет к ним изоляцию. Когда поддеревья лучше плоских ключей: как только настроек у модуля больше трёх-четырёх и внутри появляются свои группы — налоговый модуль с секциями rates и filings, модуль платежей с лимитами и шлюзами. Плоский реестр превращает это в имена вида tax.rates.vat_default, срезы дают каждому модулю его мир. Есть и цена: глубина в конфиге должна отражать структуру модулей, а не удобство автора файла — дерево, повторяющее оргструктуру компании, читать не сможет никто. Два уровня почти всегда достаточны, три — сигнал, что секции пора делить.
Поддерево строится из значений родителя на момент вызова Sub. WatchConfig перечитал файл, реестр обновился — а срезы, розданные модулям на старте, остались со старыми значениями. Если приложение живёт на горячей перезагрузке из урока двенадцатого, пересоздавайте срезы в хендлере OnConfigChange и заново раздавайте их модулям, иначе дамп покажет свежие значения, а база будет ходить в старый хост.
Get против Sub на одной секции#
| Вопрос | viper.Get("database") | viper.Sub("database") |
|---|---|---|
| Возвращает | map[string]any — значение секции | *viper.Viper — отдельный реестр |
| Чтение вложенных ключей | viper.Get("database.host") или обход карты | sub.GetString("host") — секция как корень |
| Секции нет | nil-значение без ошибки | nil-указатель — нужна проверка |
| Что дальше руками | type assertion и ручной обход | весь API: Get, IsSet, Unmarshal, Set |
Когда поддеревья лучше плоских ключей#
- У модуля больше трёх-четырёх настроек, и внутри них свои группы: rates, limits, retries.
- Настройки принадлежат разным командам: секция — граница владения, срез — её API.
- Названия ключей пересекаются между модулями: host и port есть и у сервера, и у базы.
- Модуль тестируется изолированно: срез собирается из карты в тесте без файлов и окружения.
- Конфиг описывает структуру системы: database, payments, tax — дерево повторяет модули.
- Планируется вынос модуля в отдельный сервис: его секция уже готова стать его конфигом.
Проверка: секция отсутствует
Секции database в конфиге нет. Что вернёт viper.Sub("database") и что случится дальше?
Проверка: Sub против Get
Чем viper.Sub("database") принципиально отличается от viper.Get("database")?
Что выведет вложенный срез?
Прочитайте код и выберите, что он выведет:
// config.yaml:
// database:
// host: "db.local"
// pool:
// size: 10
db := viper.Sub("database")
pool := db.Sub("pool")
fmt.Println(pool.GetInt("size"))Соберите связку Sub плюс Unmarshal
Допишите получение среза, проверку на nil и раскладку секции в структуру.
Соберите передачу среза в модуль
Расставьте строки: получить срез, проверить на nil, прочитать два значения и собрать хранилище.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: поддеревья конфига
Что дальше#
Срезы нарезаны, и следующий урок — регистрация алиасов: переименовываем ключи схемы, не ломая пользователей. Полный маршрут по темам лежит на странице курса viper, а база под структуры и теги — в уроке про структуры курса go-basics, если поля и mapstructure требуют повторения. Домашний опыт — локальным Go: нарежьте конфиг на срезы database и worker, передайте их конструкторам и добейтесь, чтобы попытка прочитать worker.timeout через срез базы вернула пустое значение — это и есть изоляция, которую можно потрогать руками.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- viper.Sub("database") возвращает *viper.Viper, чей реестр — только секция: ключи поддерева читаются без префикса.
- Секции нет — Sub возвращает nil, и первое чтение падает с паникой: проверка на nil стоит до любого GetString.
- Срез — изоляция: модуль не видит соседних ключей и не может записать чужой Set, а переименование секции его не заденет.
- Связка с Unmarshal: db.Unmarshal(&cfg) типизирует срез в структуру с mapstructure-тегами; одноразовая альтернатива — UnmarshalKey.
- Sub работает на срезе: db.Sub("pool") отрезает вложенный уровень — глубина конфига повторяет структуру модулей.
- Get("database") — карта для ручного обхода, Sub — типизированный API: поддеревья лучше плоских ключей, когда модулей много и ключи пересекаются.
- Правило пакета config: viper не покидает его границы — наружу идут структуры, а не *viper.Viper.
Домашнее задание
Вынесите секции database и worker в отдельные срезы через Sub и передайте их конструкторам NewStore и NewWorker. Добавьте проверку на nil для отсутствующей секции и убедитесь локальным Go, что модуль не видит чужих ключей: чтение worker.timeout через срез базы должно вернуть пустое значение.
Личные заметкиПусто
Вопросы и ответы по уроку
Видит ли срез изменения родителя после горячей перезагрузки?
Нет: поддерево строится из значений родителя на момент вызова Sub. WatchConfig перечитает файл, реестр обновится, а уже розданные срезы останутся со старыми значениями. Рабочий приём — пересоздавать поддеревья в хендлере OnConfigChange и заново раздавать их модулям.
Передавать ли срез *viper.Viper в конструкторы модулей?
Для утилит и проектов с одним main — нормально, цена замены копеечная. В продуктовом коде я раскладываю срез в структуру на границе пакета config и наружу выпускаю только данные: viper не должен расползаться по коду, иначе заменить библиотеку однажды будет некому.
Чем Sub лучше Get("database") с картой?
Карта требует ручного обхода и приведения типов, а тип в значениях зависит от формата файла. Срез даёт весь типизированный API — GetString, GetInt, Unmarshal — плюс изоляцию: соседние ключи в реестре среза отсутствуют, и модуль не может случайно прочитать или записать чужое.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- RegisterAlias: синонимы ключей и миграция схемы
- Профили dev, staging и prod
- Секреты: почему пароли не живут в YAML
- Пакет config: оборачиваем viper в свой API
- WriteConfig и SafeWriteConfig: сохраняем состояние
- WatchConfig и OnConfigChange: живой конфиг
- Вложенные ключи: деревья и mapstructure
- Unmarshal: конфиг целиком в структуру
- Все курсы по Go
Следующая ступень учебного пути
Chi: роутер для Go HTTP-сервисов
Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.
- Строить маршруты chi: методы, параметры пути, wildcards
- Писать middleware и понимать порядок их выполнения
- Группировать маршруты: Route, Group и подроутеры
- Прокидывать данные в хендлеры через context.Context