К середине курса чтение конфига разрослось: GetString для хоста, GetInt для порта, GetDuration для таймаута, GetStringSlice для тегов — по строке на ключ, и каждая где-то в коде. Новый ключ — это уже не «добавить строчку в yaml», а найти место чтения, проверить наличие, не опечататься в строковом имени. Десяток ключей держится на внимательности, двадцать — источник багов, которых компилятор не видит. Выход напрашивался давно: описать конфиг структурой — типизированной, с автодополнением и проверкой полей на этапе компиляции — и выгрузить реестр в неё одним вызовом. Это viper.Unmarshal, и он закрывает чтение конфига целиком. Код с Unmarshal исполняется настоящим Go с импортом viper: как и раньше, примеры проверяйте локально.
viper Unmarshal: один вызов вместо десятка Get#
viper.Unmarshal(&cfg) берёт видимую часть реестра и декодирует её в структуру: один вызов вместо цепочки Get. Под капотом — пакет mapstructure: исторически mitchellh/mapstructure, сейчас viper тянет форк go-viper/mapstructure, теги и правила сопоставления не менялись. Контраст с семейством Get из четвёртого урока принципиальный: там каждый ключ читался отдельным вызовом, а несовместимое значение молча становилось нулём; здесь декодер идёт по всей карте значений сразу и возвращает одну ошибку на весь конфиг, если типы не сходятся. Сопоставление полей и ключей работает по умолчанию: поле Port находит ключ port, поле Host — ключ host, регистр не важен. Структура при этом обычная: методы, проверки, компилятор на страже — всё, что даёт язык, вместо строковых имён в рантайме.
Первый Unmarshal
GOtype Config struct {
Host string
Port int
Debug bool
Retries int
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
log.Fatalf("конфиг: %v", err)
}
fmt.Println(cfg.Host, cfg.Port, cfg.Debug, cfg.Retries)Ни одного тега: ключи host, port, debug, retries совпали с полями по имени без учёта регистра. В структуру передаётся указатель — декодер пишет по адресу. Ошибка проверяется и роняет программу на старте: с этого момента несовместимый тип в конфиге перестаёт быть тихим нулём и становится читаемой ошибкой с именем поля. Один нюанс: Unmarshal читает то, что реестр знает, — про env-ловушку ниже отдельный раздел.
mapstructure-теги: имена, регистр и вложенность#
Сопоставление по умолчанию — без учёта регистра по имени поля: ServerPort нашёл бы ключ serverport. Граница ровно на подчёркиваниях и дефисах: ключ server_port в поле ServerPort без тега не попадёт — подчёркивание часть имени, а не деталь регистра. Для таких случаев ставится тег mapstructure: поле объявляется как Port int с mapstructure:"server_port", и декодер соединяет их. Вложенность раскладывается без тегов: ключи database.host и database.port уезжают в поля Host и Port вложенной структуры Database, если у Config есть поле Database этого типа. Тег нужен, когда имя ключа не совпадает с именем поля или конфликтует с ключевым словом Go. Вложенные ключи как тема — доступ по точкам, деревья, GetStringMap — разобраны в отдельном уроке; здесь достаточно знать, что Unmarshal понимает вложенность из коробки.
Типы декодируются с оговорками, которые стоит держать в голове. Длительности: поле Timeout time.Duration примет строку «30s» — viper подключает хук StringToTimeDuration, и декодер парсит время так же, как GetDuration; целое число в таком поле станет наносекундами, врезка из четвёртого урока ещё в силе. Слабая типизация: viper включает WeaklyTypedInput, поэтому строка «8080» уедет в int-поле, число 1 — в bool, пустая строка станет нулём. Указатели — отдельный подарок: поле Retries *int останется nil, когда ключа нет ни в одном слое, и покажет 0, когда ключ есть с нулевым значением. Различие «не задано» против «задано нулём», которого не было у Get, теперь стоит одной звёздочки в объявлении поля — и одной проверки на nil перед разыменованием.
Вложенные структуры, Duration и UnmarshalKey
GOtype Database struct {
Host string
Port int
}
type Worker struct {
Timeout time.Duration `mapstructure:"timeout"`
Retries *int
}
type Config struct {
Database Database
Worker Worker
}
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
log.Fatal(err)
}
var w Worker
if err := viper.UnmarshalKey("worker", &w); err != nil {
log.Fatal(err)
}
fmt.Println(cfg.Database.Host, cfg.Worker.Timeout, *w.Retries)Вложенность без тегов: database.host уехал в cfg.Database.Host по совпадению имён. Тег на Timeout — для примера: несовпадающее имя соединяется декларацией, а не переименованием ключей. Retries *int разыменовывается только после проверки на nil — в живом коде сначала if w.Retries != nil. UnmarshalKey прочитал поддерево worker целиком: модуль получает свой срез конфига, не зная остального реестра, — к этому приёму мы вернёмся в уроке про Sub и изоляцию модулей. Запустите у себя локальным Go: конфиг из двух секций — и сравните объём кода с вариантом на Get.
Классический след миграции: код на Get показывал 9090 из переменной окружения, код на Unmarshal — дефолтные 8080, хотя конфиг не менялся. Причина не в окружении и не в viper: Unmarshal декодирует только ключи, известные реестру, а SERVER_PORT из AutomaticEnv в списке ключей не числится. Get сходил по лестнице сам, а декодер про ключ не знает — и не спрашивает. Лечение одно: объявить каждый ключ — SetDefault на каждое поле структуры или BindEnv на каждый ключ. Механика — ниже.
Почему env-значения не доезжают до структуры#
Механика в две строки. Unmarshal не обходит окружение — он берёт список известных ключей (AllKeys), собирает по нему карту значений и декодирует. Список AllKeys собирает ключи из файла, дефолтов, Set, BindEnv и привязанных флагов; значения AutomaticEnv туда не попадают — урок про окружение показывал это на AllSettings. Но есть вторая половина, которая спасает: значения резолвятся через Get, а Get проверяет окружение. Итог: если ключ объявлен — хоть дефолтом, хоть файлом, — env-значение доедет до структуры; если ключ нигде не объявлен, декодер о нём не узнает никогда. Поэтому правило при переходе на Unmarshal: SetDefault на каждое поле — дефолт объявляет ключ и заодно документирует схему, а конфиг в структуру ложится целиком, включая окружение. Альтернатива — BindEnv на каждый ключ: ключ попадает в AllKeys явно, но писать его на каждое поле утомительнее.
Дефолты объявляют ключи — env доезжает
GOtype Server struct {
Port int
}
type Config struct {
Server Server
}
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// дефолт объявляет ключ: без этой строки env в структуру не доедет
viper.SetDefault("server.port", 8080)
// запуск: SERVER_PORT=9090 ./app
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
log.Fatal(err)
}
fmt.Println(cfg.Server.Port) // 9090Ключ server.port объявлен дефолтом — AllKeys его знает, значение резолвится через Get и находит переменную: 9090. Уберите SetDefault — в AllKeys ключа не будет, декодер его пропустит, и в структуре останется ноль. Расхождение между Get и Unmarshal — верный маркер необъявленного ключа: Get вернёт 9090, структура — ноль.
fmt.Printf("%+v", cfg) печатает все поля сразу — вместе с паролем базы и ключом API, если они в структуре. Точечные Get хотя бы заставляли выбирать, что выводить; Unmarshal собрал всё в один объект, и одна отладочная строка раздаёт секреты в общий журнал. Печатайте только нечувствительные поля, а секретные держите отдельным типом со String()-маской.
Unmarshal возвращает ошибку — и это заметная смена характера после Get-семейства. cast молча превращал мусор в нули: GetInt на строке «8080порт» давал 0 без единой строки в логе. Декодер mapstructure на несовместимых типах ошибается честно: строка «abc» в int-поле даёт ошибку вида cannot parse 'Server.Port' as int — с путём до поля и первопричиной от strconv; слайс в скалярном поле — ошибка несовместимости. Слабая типизация прощает форму («8080» в int), но не содержание. Ошибку проверяйте и роняйте старт: это бесплатная валидация типов конфига, которой у Get не было вовсе. Рядом живёт UnmarshalExact — вариант декодера, который падает на незнакомых ключах: годится как проверка схемы, пока в yaml не заведутся «лишние» ключи от старых версий; тогда жёсткость станет обузой, и обычный Unmarshal спокойнее.
Get против Unmarshal: когда что#
| Критерий | Семейство Get | Unmarshal |
|---|---|---|
| Объём кода | строка на каждый ключ | один вызов на весь конфиг |
| Несовместимый тип | cast молча даёт ноль | ошибка декодирования с именем поля |
| Ключа нет | нулевое значение типа | поле нулевое; поле-указатель — nil |
| env через AutomaticEnv | видна при каждом Get | доедет, только если ключ объявлен SetDefault или BindEnv |
| Вложенные ключи | через точку: database.host | вложенные структуры |
| Поддерево | GetStringMap и вручную | UnmarshalKey в отдельную структуру |
| Опечатка в чтении | строковое имя — ошибка в рантайме | поле структуры — ошибка компиляции |
Гигиена Unmarshal#
- Дефолт на каждое поле: ключ объявлен — env доезжает, IsSet отвечает честно, схема задокументирована одним местом.
- Ошибка Unmarshal — сигнал к падению на старте: несовместимые типы ловите до поднятия серверов, а не в дежурство.
- Длительности в конфиге держите строками «30s»: числовое значение в поле time.Duration станет наносекундами.
- Поля-указатели — для различения «не задано» и «ноль»: nil перед разыменованием проверяйте всегда.
- Секретные поля не печатьте вместе со структурой: маска в String() или отдельный вывод только нечувствительных полей.
- Поддеревья модулей читайте UnmarshalKey: меньше связей с реестром, а изоляция модулей уже готова.
Проверка: ключ server_port
Ключ в конфиге — server_port, поле структуры — ServerPort без тега. Что произойдёт при Unmarshal?
Проверка: env и пустой реестр
AutomaticEnv включена, переменная SERVER_PORT=9090 задана. В коде — только viper.Unmarshal в структуру с полем Port; ни файла, ни SetDefault, ни BindEnv. Что окажется в поле?
Что выведет программа?
Прочитайте код и выберите, что он выведет:
// config.yaml: worker: {retries: "abc"}
type Worker struct {
Retries int
}
var w Worker
err := viper.UnmarshalKey("worker", &w)
fmt.Println(w.Retries, err != nil)Соберите чтение в структуру
Допишите выгрузку всего конфига, чтение поддерева, объявление ключа дефолтом и доступ к вложенному полю.
Соберите секцию, где env доезжает до структуры
Расставьте строки: replacer, окружение, объявление ключа, декодирование в структуру и вывод.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: Unmarshal и mapstructure
Что дальше#
Конфиг теперь типизированная структура: одно объявление вместо десятка строковых вызовов, ошибки типов — на старте, опечатки в именах — компилятором. Дочитали до точки, где viper почти закрыт как «чтение по ключам»: дальше — вложенные ключи во всей полноте: деревья, точки и mapstructure рядом, затем WatchConfig и живая перезагрузка без перезапуска. Прежде чем двигаться, перенесите чтение своего проекта на структуру: одна сборка Unmarshal вместо разбросанных Get обычно находит пару ключей, которые читались не оттуда. Домой, к маршруту по урокам и темам, ведёт страница курса viper.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- viper.Unmarshal(&cfg) выгружает конфиг в структуру одним вызовом вместо десятка Get: поля типизированы, опечатки ловит компилятор.
- Имена полей сопоставляются ключам без учёта регистра; для server_port и других «негладких» имён ставится тег mapstructure.
- Вложенные ключи раскладываются по вложенным структурам: database.host уезжает в Config.Database.Host; UnmarshalKey читает поддеревья.
- Строки «30s» декодируются в time.Duration хуком по умолчанию; поле-указатель различает «ключа нет» и «ноль».
- env-значения через AutomaticEnv не доезжают до Unmarshal, пока ключ не объявлен: SetDefault на каждое поле или BindEnv.
- Несовместимые типы дают ошибку декодирования с именем поля — бесплатная валидация на старте вместо молчаливых нулей cast.
- Структуру конфига не печатайте целиком: все поля, включая секреты, уходят в лог одной строкой.
Домашнее задание
Переведите чтение проекта из прошлого урока на структуру: секции Server и Worker, Timeout time.Duration, один Unmarshal и один UnmarshalKey. На каждое поле — SetDefault, затем запуск с SERVER_PORT=9090: env должен доехать до поля. Подложите в yaml retries: "abc" и прочитайте ошибку декодирования целиком.
Личные заметкиПусто
Вопросы и ответы по уроку
Unmarshal забирает секреты из env?
Забирает — если ключ объявлен дефолтом или BindEnv: значение резолвится тем же Get с проверкой окружения. Опасность в другом: печать структуры целиком показывает все поля открыто. Маскируйте секретные поля отдельным типом со String() и не отправляйте %+v от cfg в общий журнал.
Что даёт UnmarshalExact?
Падение на незнакомых ключах: опечатка в yaml перестаёт быть тихой, схема проверяется декодером. Цена — жёсткость: «лишние» ключи от старых версий конфига не дадут стартовать. Для устоявшегося набора ключей полезно, для строящейся схемы спокойнее обычный Unmarshal.
Поле с тегом не заполнилось — где искать?
Три кандидата. Путь: тег ставится на поле секции, а не на верхнем уровне — для database.host тег нужен на Host внутри Database. Имя: подчёркивания значимы, тег должен совпадать посимвольно. Тип: ошибка декодера выше по стеку — проверяйте err, а не только поле.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Вложенные ключи: деревья и mapstructure
- WatchConfig и OnConfigChange: живой конфиг
- WriteConfig и SafeWriteConfig: сохраняем состояние
- Sub(): разбираем конфиг на поддеревья
- Приоритеты источников: кто кого перекрывает
- BindPFlag: флаги командной строки в конфиге
- SetEnvPrefix и BindEnv: разруливаем коллизии
- Переменные окружения: AutomaticEnv
- Все курсы по Go
Следующая ступень учебного пути
Chi: роутер для Go HTTP-сервисов
Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.
- Строить маршруты chi: методы, параметры пути, wildcards
- Писать middleware и понимать порядок их выполнения
- Группировать маршруты: Route, Group и подроутеры
- Прокидывать данные в хендлеры через context.Context