Перейти до змісту

Патерни конфігурації

Конфігурація, розкидана по кодовій базі як виклики os.Getenv, не має жодного єдиного місця для читання, жодної перевірки, а описка стає нульовим значенням замість помилки. Одна структура, завантажена й перевірена один раз при старті, виправляє всі три проблеми.

type Config struct {
    Environment string        `env:"ENVIRONMENT"`
    Port        int           `env:"PORT"`
    Timeout     time.Duration `env:"TIMEOUT"`
    DBHost      string        `env:"DB_HOST"`
}

Одна структура, завантажена один раз

Правила, що роблять це робочим:

  • Завантажуйте в main, перш ніж запуститься щось інше.
  • Передавайте структуру вниз як параметр конструктора. Ніколи як глобальну змінну пакета, інакше ви повертаєтесь до невидимих залежностей.
  • Провалюйтесь при старті, а не на першому запиті. Відсутній хост бази даних має зупинити процес негайно, поки хтось за цим стежить.

Давайте полям справжні типи. Timeout time.Duration замість TimeoutSeconds int означає, що розбір і одиниця виміру живуть в одному місці, а не в кожному місці використання.

Спершу значення за замовчуванням, потім перевизначення

Починайте з заповненої структури, щоб кожне поле мало розумне значення, а тоді дайте середовищу перевизначити його:

func Load() (Config, error) {
    c := Config{
        Environment: "local",
        Port:        8080,
        Timeout:     5 * time.Second,
    }
    // ... застосувати середовище ...
}

Значення за замовчуванням у літералі структури самодокументовані: одне місце показує, що робить програма взагалі без жодної конфігурації.

Прив'язка через рефлексію

Для жменьки полів явні виклики os.LookupEnv цілком доречні й зрозуміліші. Коли їх стає тридцять, повторення саме стає джерелом багів, і обхід тегів структури вартий того — техніка reflect зі статті про XML, CSV і рефлексію:

v := reflect.ValueOf(&c).Elem()
t := v.Type()

for i := range t.NumField() {
    tag := t.Field(i).Tag.Get("env")
    if tag == "" || tag == "-" {
        continue
    }
    raw, ok := os.LookupEnv(tag)
    if !ok {
        continue          // залишити значення за замовчуванням
    }

    f := v.Field(i)
    switch f.Interface().(type) {
    case string:
        f.SetString(raw)
    case int:
        n, err := strconv.Atoi(raw)
        if err != nil {
            return Config{}, fmt.Errorf("%s: %w", tag, err)
        }
        f.SetInt(int64(n))
    case time.Duration:
        d, err := time.ParseDuration(raw)
        if err != nil {
            return Config{}, fmt.Errorf("%s: %w", tag, err)
        }
        f.SetInt(int64(d))
    }
}

reflect.ValueOf(&c).Elem() — це те, що робить поля придатними для запису. ok від LookupEnv відрізняє "не встановлено" від "встановлено як порожнє", тож невстановлена змінна лишає значення за замовчуванням, а не обнуляє його.

Зверніть увагу, що помилка включає ім'я змінної:

// PORT=abc
// output: PORT: strconv.Atoi: parsing "abc": invalid syntax

env:"-" позначає поле, яке завантажувач має пропустити — типово те, що виводиться з інших.

Це точно те, що роблять бібліотеки конфігурації. Знання механізму означає, що ви можете прочитати їхню, і вирішити, чи сорок рядків кращі за залежність.

Похідні поля

Обчислюйте все, зібране з інших значень, один раз, після завантаження:

c.DSN = fmt.Sprintf("postgres://%s/%s", c.DBHost, c.DBName)

Ще краще як метод, щоб воно не могло розійтися з реальністю:

func (c Config) DSN() string {
    return fmt.Sprintf("postgres://%s/%s", c.DBHost, c.DBName)
}

У будь-якому разі будуйте його в одному місці. DSN, зібраний у трьох місцях виклику, відрізнятиметься у двох із них.

Перевіряйте й повідомляйте все одразу

Повернення на першій проблемі означає виправлення однієї змінної на перезапуск. errors.Join збирає їх усі:

func (c Config) validate() error {
    var errs []error
    if c.DBHost == "" {
        errs = append(errs, errors.New("DB_HOST is required"))
    }
    if c.Port < 1 || c.Port > 65535 {
        errs = append(errs, fmt.Errorf("PORT %d out of range", c.Port))
    }
    return errors.Join(errs...)
}

errors.Join повертає nil, коли зріз порожній, тож щасливий шлях не потребує особливого випадку.

// нічого не встановлено
// output: DB_HOST is required

// PORT=99999
// output: PORT 99999 out of range

Перевіряйте також діапазони й формати, а не лише наявність. Розмір пулу нуль чи поріг подібності 1.5 інакше провалиться десь далеко від причини.

Секрети

Змінні середовища — звичайний транспорт, і вони легко просочуються. Дві звички:

  • Ніколи не логуйте структуру конфігурації. %+v на ній кладе пароль вашої бази даних у логи. Якщо хочете зведення при старті, напишіть метод Redacted() або дайте секретним полям String(), що повертає "[redacted]".
  • Не кладіть секрети у значення за замовчуванням. Пароль за замовчуванням у коді — це пароль у вашій git-історії.

Файли .env — це зручність локальної розробки. Стандартна бібліотека їх не читає; це турбота сторонньої бібліотеки, а продакшн має використовувати справжні змінні середовища чи сховище секретів.

Прапорці, середовище чи файл

Джерело Добре для
середовище конфігурація розгортання: хости, облікові дані, feature flags
прапорці вибір для конкретного запуску: --mode, --dry-run, шлях до файлу
файл велика чи структурована конфігурація, закомічена в репозиторій

Середовище — типове джерело для сервісів, бо саме це надають оркестратори. Звичний порядок — значення за замовчуванням, потім файл, потім середовище, потім прапорці — кожен перевизначає попередній. Що б ви не обрали, задокументуйте це, бо інакше на питання "чому це значення не застосовується" відповіді не буде.

З досвіду Python: це pydantic-settings, зроблений вручну. Структура — це схема, теги — псевдоніми полів, а перевірка — метод, який ви пишете. Менше магії, і все вміщується в одну зрозумілу функцію.

Швидка довідка

Питання Форма
форма одна структура, справжні типи (time.Duration, не int)
коли один раз у main, передається вниз як параметр
значення за замовчуванням заповнений літерал структури перед перевизначеннями
не встановлено проти порожнє os.LookupEnv, не os.Getenv
багато полів обхід тегів структури через reflect
пропустити поле env:"-"
похідні значення один метод, а не повтор у місцях виклику
перевірка збирати через errors.Join, провалюватися при старті
секрети ніколи не логувати структуру; жодних секретних значень за замовчуванням

Джерела