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

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

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

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

Итоговый проект: конфигурируемый сервис

Viper итоговый проект — каркас конфигурируемого сервиса: один пакет internal/config, куда сходится всё, что мы собирали руками по ходу курса. Файл-база, env поверх, флаги поверх env, валидация на старте, горячая перезагрузка на atomic.Value. Нового тут мало — и в этом смысл: к концу у вас останется скелет, который переезжает из сервиса в сервис.

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

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-агенте, и утечка через репозиторий становится невозможной физически.

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 и найдите в логе строку про перечитанный конфиг без рестарта.

Пакет 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-слой и мост к флагам.

func Load(flags *pflag.FlagSet) (*Config, error) { v := viper.New() v.SetConfigName("config.dev") v.SetConfigType("yaml") v.AddConfigPath("./configs") v.("APP") // префикс: APP_PORT, а не PORT v.() // env перекрывает файл if err := v.(flags); err != nil { // мост к Cobra return nil, err } if err := v.ReadInConfig(); err != nil { return nil, err } return &Config{Port: v.GetInt("port")}, nil }

Порядок вызовов в Load

Соберите последовательность из шести строк: экземпляр, имя файла, префикс, env-слой, привязка флагов, чтение базы.

Строки

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

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

Слой каркаса → файл → урок курса#

Слой каркасаГде живётУрок курса
yaml-базаconfigs/config.dev.yamlфайлы и форматы (02, 03)
env-перекрытиеAPP_PORT и соседиокружение и префиксы (06, 07)
флаги и Cobrapflag через BindPFlagsфлаги и Cobra (08)
профили--env и config.prod.yamlпрофили окружений (16)
hot-reloadWatchConfig, OnConfigChangeследим за файлом (12)
секретытолько env, никогда yamlсекреты (17)
валидацияinternal/config/validate.goэтот проект
тесты пакетаconfig_test.go на viper.New()тестирование конфигов (20)

Чек-лист запуска сервиса#

  1. config.dev.yaml на месте и читается: ошибка ReadInConfig останавливает старт, а не уезжает в warn.
  2. SetEnvPrefix и AutomaticEnv включены до BindPFlags — лестница приоритетов собирается сама.
  3. Флаг --env выбран: dev читает config.dev.yaml, prod — config.prod.yaml, путаницы профилей нет.
  4. Секреты только в env: в yaml нет паролей, коммит не содержит креденшелов базы.
  5. Validate прогнала обязательные поля: порт в диапазоне, db.url непустая, host задан.
  6. Только после валидации — ListenAndServe: сервис физически не может слушать пустой порт.
  7. WatchConfig включён, OnConfigChange гонит перечитанный конфиг через Validate перед Store.
  8. Тесты пакета 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.

Карточки: детали каркаса

Термин

Fail fast

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

Падать на старте, а не молчать в проде: ошибка ReadInConfig и проваленная Validate останавливают сервис до Listen. Пустой порт, подхваченный ОС, дороже упавшего деплоя.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 · Итоговый проект: конфигурируемый сервис)

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

Замена viper в каркасе: когда брать envconfig или koanf?

Когда конфиг — это только env-переменные и одна структура, envconfig короче нашего каркаса, и спорить не о чем. Как только появляются файл-база, профили и hot-reload, объём готового пакета догоняет сотню строк каркаса, а лестница приоритетов перестаёт быть прозрачной. Механика слоёв из проекта переносится на любую библиотеку.

Какие значения можно перечитывать на лету, а какие нельзя?

Горячая перезагрузка — для безобидных значений: таймауты, лимиты, уровень логов. Порт, адрес базы и креденшелы читаются один раз при старте: смена порта требует переоткрытия слушателя, смена базы — переподключения пула. Разграничение фиксируйте в структуре или в комментарии к Subscribe, чтобы правило пережило автора.

Как каркас встраивается в Cobra из соседнего курса?

Cobra собирает команду и свой FlagSet, каркас получает его готовым: config.Load(cmd.Flags()) вызывает BindPFlags, и значения флагов попадают в общий реестр. Глобальные --env и --config живут на корневой команде, флаги команды serve — на ней самой. Ничего не дублируется: одна лестница приоритетов на всю программу.

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

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

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

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

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

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

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