Содержание урока · 10 разделов
- Viper итоговый проект: каркас конфигурируемого сервиса
- Слои каркаса: yaml-база, env поверх, секреты только в env
- Флаги и профили: BindPFlag как мост к Cobra
- Hot-reload: WatchConfig и atomic.Value
- Слой каркаса → файл → урок курса
- Чек-лист запуска сервиса
- Спор: свой каркас против готового пакета
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Viper итоговый проект: каркас конфигурируемого сервиса#
Пятый сервис за год — и история повторяется: в понедельник конфигурация это os.Getenv в main, к среде там восемь переменных, yaml с паролем базы, закоммиченный «на время», и флаг, который работает только на дев-машине. К пятнице все мечтают просто скопировать чужой каркас. Итоговый проект курса viper и собирает такой каркас — единожды написанный пакет internal/config, который переезжает из репозитория в репозиторий почти без правок. Принципиально нового здесь не появится: файлы и форматы мы разбирали в начале курса, окружение и префиксы — в уроках про env, лестницу приоритетов раскладывали отдельно, флаги привязывали через BindPFlag, за изменениями следили через WatchConfig. Каждый элемент уже собран руками; проект только соединяет их в один файл, который переживёт прод.
Структура простая до скучности: internal/config с двумя файлами и cmd/app с одним. В config.go живёт Load — чтение базы, подключение env-слоя, привязка флагов. В validate.go — проверки обязательных значений; отдельным файлом, потому что валидация растёт быстрее любой другой части пакета. main.go про viper не знает ничего: получает структуру, вызывает Validate, стартует. Почему именно пакет, а не функции в main: тесты требуют свежих экземпляров, переиспользование между сервисами требует одного места, а диагностике из урока 22 нужен доступ к конкретному экземпляру, а не к глобальному реестру. Обёртку-пакет мы разворачивали в уроке про пакет config — здесь она встаёт на своё место в дереве проекта.
internal/config/config.go: Load
GO// internal/config/config.go
package config
import (
"github.com/spf13/pflag"
"github.com/spf13/viper"
)
type Config struct {
Port int
Host string
DBURL string
Cache string
}
func Load(flags *pflag.FlagSet) (*Config, error) {
v := viper.New() // не глобальный: тесты и два конфига в одном процессе
v.SetConfigName("config.dev") // база: config.dev.yaml
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.SetEnvPrefix("APP") // APP_PORT, а не PORT
v.AutomaticEnv() // env перекрывает файл
if err := v.BindPFlags(flags); err != nil { // мост к Cobra: --port
return nil, err
}
if err := v.ReadInConfig(); err != nil {
return nil, err // fail fast: нет базы — нет сервиса
}
return &Config{
Port: v.GetInt("port"),
Host: v.GetString("host"),
DBURL: v.GetString("db.url"),
Cache: v.GetString("cache.addr"),
}, nil
}Экземпляр из viper.New() вместо глобального реестра — это не педантизм: без него тесты пакета начнут таскать состояние друг у друга, а второй конфиг в одном процессе станет невозможным. Порядок вызовов до ReadInConfig технически свободен, но держите его одинаковым во всех сервисах — диагностика из урока 22 опирается на предсказуемость. Прогоните каркас локальным Go: go get для viper и pflag, затем go run ./cmd/app --port 9090 — пять минут, после которых пакет перестаёт быть абстракцией.
Слои каркаса: yaml-база, env поверх, секреты только в env#
config.dev.yaml — единственный файл, который каркас читает всегда: безопасные к коммиту значения по умолчанию и ключ, по которому видно, что это база, а не прод. Поверх работает env: SetEnvPrefix("APP") плюс AutomaticEnv — и APP_PORT перекрывает port из файла без единой строки кода в сервисе. Лестница здесь та же, что в уроке про приоритеты источников: Set выше всех, затем флаг, переданный пользователем, затем env, затем файл, в самом низу дефолты. Секреты в yaml не попадают никогда: пароль базы и токены приезжают только переменными окружения, как в уроке про секреты — тогда файл с конфигом можно коммитить, а продовый секрет живёт в окружении пода или в vault-агенте, и утечка через репозиторий становится невозможной физически.
Живое место, которое я видел минимум в трёх командах: сервис поднялся, healthcheck зелёный, алерты молчат — а трафик никуда не приходит. Виновник — viper.Get на отсутствующем ключе: порт вернулся нулём, net.Listen принял :0, ОС выдала случайный свободный порт, и балансировщик смотрит в никуда. Снаружи инцидента нет, потому что сервис честно работает — просто не там, где его ищут. Валидация на старте существует против именно этой тишины: обязательные значения проверяются до Listen, и без валидного порта процесс не стартует вовсе. Упасть на деплое дешевле, чем молча жить в проде.
internal/config/validate.go: fail fast на errors.Join
GO// internal/config/validate.go
package config
import (
"errors"
"fmt"
)
func Validate(c *Config) error {
var errs []error
if c.Port <= 0 || c.Port > 65535 {
errs = append(errs, fmt.Errorf("port: %d не попадает в 1-65535", c.Port))
}
if c.Host == "" {
errs = append(errs, errors.New("host: пусто — слушать нечего"))
}
if c.DBURL == "" {
errs = append(errs, errors.New("db.url: пусто — без базы стартовать бессмысленно"))
}
return errors.Join(errs...)
}errors.Join появился в Go 1.20 и собирает срез ошибок в одну цепочку: сервис на старте перечисляет всё, что забыли задать, а не умолкает после первого промаха. Срез вместо panic — потому что решение, как падать, принимает вызывающий: log.Fatal в CLI, os.Exit(1) в контейнере, отдельная ветка в тестах. Ноль проблем — nil, и каркас не навязывает лишнего: добавили поле в Config — добавили одну ветку в Validate.
Флаги и профили: BindPFlag как мост к Cobra#
Флаги входят в каркас через BindPFlags, и это мост к Cobra: команда serve --port 9090 из урока про флаги и Cobra попадает в тот же реестр, что yaml и env, — сервису всё равно, откуда пришло значение. Нюанс, который там же разбирали: перекрывает только флаг, переданный пользователем; непереданный проваливается ниже дефолтов viper. Поэтому порт по умолчанию держат в yaml или SetDefault, а не в flag.Int. Второй мост — профили окружений из урока 16: флаг --env выбирает базу, dev читает config.dev.yaml, prod — config.prod.yaml. В каркасе это одна строка в SetConfigName — fmt.Sprintf("config.%s", env), — а валидация и env-слой работают одинаково для любого профиля.
Проверка: непереданный флаг
В config.dev.yaml задан port: 8080. Флаг объявлен как flag.Int("port", 9000, "порт") и привязан через BindPFlags; пользователь запустил сервис без --port, APP_PORT в окружении нет. Что вернёт GetInt("port")?
Hot-reload: WatchConfig и atomic.Value#
Горячая перезагрузка — самая хрупкая деталь каркаса, и мы разбирали её в уроке про WatchConfig: v.WatchConfig() подписывается на изменения файла, v.OnConfigChange(func(e fsnotify.Event)) дёргается на каждое событие. В прод-варианте поверх живёт atomic.Value: обработчик перечитывает реестр, прогоняет результат через ту же Validate и кладёт новый снапшот в атомику; читатели достают его целиком — без мьютексов и без шанса увидеть половину старого с половиной нового. Второе правило — какие значения вообще можно перечитывать: таймауты, лимиты, уровень логов — да; порт и строку подключения к базе — только с рестартом, и никакая атомика этого не изменит. Границу фиксируйте в комментарии к Subscribe, чтобы через полгода никто не удивлялся.
cmd/app/main.go: снапшот на atomic.Value
GO// cmd/app/main.go — импорты: fmt, log, net/http, sync/atomic, pflag
func main() {
flags := pflag.NewFlagSet("app", pflag.ExitOnError) // в Cobra — cmd.Flags()
flags.Int("port", 0, "порт сервиса")
cfg, err := config.Load(flags)
if err != nil {
log.Fatal(err) // файл не прочитан или флаги не привязаны
}
if err := config.Validate(cfg); err != nil {
log.Fatal(err) // fail fast: пустой порт не доедет до Listen
}
var live atomic.Value // хранит *config.Config
live.Store(cfg)
// внутри Subscribe: тот же экземпляр viper, WatchConfig + OnConfigChange,
// перечитанный конфиг проходит Validate; при ошибке держится старый снапшот
config.Subscribe(func(next *config.Config) {
live.Store(next) // замена снапшота целиком, атомарно
log.Println("конфиг перечитан")
})
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
c := live.Load().(*config.Config) // всегда целый снапшот
fmt.Fprintf(w, "port=%d cache=%s", c.Port, c.Cache)
})
log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), mux))
}atomic.Value хранит конфигурацию снапшотом: Store заменяет её целиком, Load отдаёт либо старый объект, либо новый — никогда полусобранный. Писатель один, читателей сколько угодно, мьютексы не нужны. Subscribe живёт в пакете config рядом с Load и замыкает на себя WatchConfig из урока про WatchConfig. Горячую перезагрузку в песочнице не проверить — нужен локальный Go: запустите сервис, поменяйте config.dev.yaml и найдите в логе строку про перечитанный конфиг без рестарта.
Две ловушки hot-reload, обе из разряда «неделю работало — потом упало». Первая: редакторы часто пишут файл двумя записями, и OnConfigChange дёргается на промежуточной — ReadInConfig возвращает ошибку, и обработчик обязан пережить её молча, оставив старый снапшот, а не складывать мусор в atomic.Value. Вторая: конфиг в обычной структуре без атомики — поля переписываются по одному, и под нагрузкой сервис отвечает старым лимитом с новым портом; поймала нас не продовая авария, а go test -race в CI, который воспроизвёл гонку на ровном месте. Обе закрываются одним решением: собрать новый объект, провалидировать и сделать Store целиком — или промолчать и держать старый.
Пакет config тестируется по схеме из урока про тестирование конфигов: каждый тест берёт свежий viper.New(), а не глобальный реестр — иначе тесты начнут перетаскивать состояние друг у друга, и -count=1 станет рулеткой. Три кейса закрывают каркас целиком: файл прочитан и значения легли в структуру; env перекрывает файл — t.Setenv("APP_PORT", "9091"), и Load обязан вернуть 9091; плохой конфиг проваливает Validate с непустой ошибкой. Чтобы не возиться с tmp-файлами, базу можно скормить через ReadConfig из bytes.Buffer: yaml лежит прямо в тесте строкой, и тест читается как спецификация каркаса.
Проверка: viper.New() в тестах
Почему в каркасе Load() создаёт экземпляр через viper.New(), а не использует глобальный viper.Get?
Что вернёт GetInt?
Прочитайте код и выберите, что он выведет:
// configs/config.dev.yaml: port: 8080
v := viper.New()
v.SetConfigName("config.dev")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.SetEnvPrefix("APP")
v.AutomaticEnv()
v.SetDefault("port", 9000)
if err := v.ReadInConfig(); err != nil {
log.Fatal(err)
}
os.Setenv("APP_PORT", "9091")
fmt.Println(v.GetInt("port"))Соберите начало Load
Допишите подключение слоёв в Load: префикс, env-слой и мост к флагам.
Порядок вызовов в Load
Соберите последовательность из шести строк: экземпляр, имя файла, префикс, env-слой, привязка флагов, чтение базы.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Слой каркаса → файл → урок курса#
| Слой каркаса | Где живёт | Урок курса |
|---|---|---|
| yaml-база | configs/config.dev.yaml | файлы и форматы (02, 03) |
| env-перекрытие | APP_PORT и соседи | окружение и префиксы (06, 07) |
| флаги и Cobra | pflag через BindPFlags | флаги и Cobra (08) |
| профили | --env и config.prod.yaml | профили окружений (16) |
| hot-reload | WatchConfig, OnConfigChange | следим за файлом (12) |
| секреты | только env, никогда yaml | секреты (17) |
| валидация | internal/config/validate.go | этот проект |
| тесты пакета | config_test.go на viper.New() | тестирование конфигов (20) |
Чек-лист запуска сервиса#
- config.dev.yaml на месте и читается: ошибка ReadInConfig останавливает старт, а не уезжает в warn.
- SetEnvPrefix и AutomaticEnv включены до BindPFlags — лестница приоритетов собирается сама.
- Флаг --env выбран: dev читает config.dev.yaml, prod — config.prod.yaml, путаницы профилей нет.
- Секреты только в env: в yaml нет паролей, коммит не содержит креденшелов базы.
- Validate прогнала обязательные поля: порт в диапазоне, db.url непустая, host задан.
- Только после валидации — ListenAndServe: сервис физически не может слушать пустой порт.
- WatchConfig включён, OnConfigChange гонит перечитанный конфиг через Validate перед Store.
- Тесты пакета config на viper.New() зелёные под go test -race.
Спор: свой каркас против готового пакета#
Тезис, который команды любят оспаривать: готовые пакеты конфигурации чаще вредны, чем полезны, и свой каркас на viper из двух файлов — лучший дефолт. За готовое говорят честные аргументы: envconfig закрывает связку «переменные в структуру» почти без кода, koanf тянет больше форматов и провайдеров, и на старте проекта любое из этого быстрее. Цена проявляется позже: теги структур — магия вне компилятора, свой источник подключить нельзя без чтения чужого кода, а на вопрос «почему значение такое» отвечает grep по чужому репозиторию. Каркас на viper — около сотни строк, каждую из которых мы написали в этом курсе: лестница прозрачна, валидация своя, hot-reload понятен. Граница у спора такая: один маленький сервис, в котором конфиг — только env, живёт спокойно и без viper; второй формат, второй сервис или профили — и каркас окупается за неделю.
На этом каркас собран: Load с yaml-базой, env-слоем и привязкой флагов, Validate с errors.Join, main с atomic.Value и подпиской на изменения, тесты на свежих экземплярах. Дальше он растёт сам: новый источник — один вызов в Load, новое обязательное поле — одна ветка в Validate. Домашка ниже собирает это в рабочий запуск, а маршрут по всем темам, если какой-то элемент хочется освежить, — на странице курса viper.
Карточки: детали каркаса
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый проект — сборка: yaml-база, env, флаги, профили, валидация и hot-reload уже построены в уроках, здесь они сходятся в один пакет.
- Структура каркаса: internal/config с config.go и validate.go, cmd/app/main.go, который про viper не знает.
- Лестница приоритетов работает сама: переданный флаг выше env, env выше файла, непереданный флаг ниже дефолтов.
- Секреты только в env: yaml остаётся базой для разработки и коммитится без креденшелов.
- Валидация на старте с errors.Join перечисляет все проблемы сразу и не даёт сервису слушать пустой порт.
- Hot-reload лежит на atomic.Value: снапшот заменяется целиком, читатели не видят полусобранного конфига.
- Пакет config тестируется на viper.New(): свежий экземпляр изолирует состояние лучше глобального реестра.
Домашнее задание
Соберите каркас в своём сервисе: Load с yaml-базой и env-слоем, Validate с errors.Join, main с atomic.Value и подпиской через Subscribe. Затем сломайте конфигурацию по-крупному: уберите APP_PORT, оставьте в yaml нулевой порт и пустую db.url — и убедитесь, что сервис падает на старте с полным списком ошибок, а не поднимается молча.
Личные заметкиПусто
Вопросы и ответы по уроку
Замена viper в каркасе: когда брать envconfig или koanf?
Когда конфиг — это только env-переменные и одна структура, envconfig короче нашего каркаса, и спорить не о чем. Как только появляются файл-база, профили и hot-reload, объём готового пакета догоняет сотню строк каркаса, а лестница приоритетов перестаёт быть прозрачной. Механика слоёв из проекта переносится на любую библиотеку.
Какие значения можно перечитывать на лету, а какие нельзя?
Горячая перезагрузка — для безобидных значений: таймауты, лимиты, уровень логов. Порт, адрес базы и креденшелы читаются один раз при старте: смена порта требует переоткрытия слушателя, смена базы — переподключения пула. Разграничение фиксируйте в структуре или в комментарии к Subscribe, чтобы правило пережило автора.
Как каркас встраивается в Cobra из соседнего курса?
Cobra собирает команду и свой FlagSet, каркас получает его готовым: config.Load(cmd.Flags()) вызывает BindPFlags, и значения флагов попадают в общий реестр. Глобальные --env и --config живут на корневой команде, флаги команды serve — на ней самой. Ничего не дублируется: одна лестница приоритетов на всю программу.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Chi: роутер для Go HTTP-сервисов
Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.
- Строить маршруты chi: методы, параметры пути, wildcards
- Писать middleware и понимать порядок их выполнения
- Группировать маршруты: Route, Group и подроутеры
- Прокидывать данные в хендлеры через context.Context