JavaRush /Курсы /Go SELF /Env‑конфиг в CLI: приоритеты и fail fast

Env‑конфиг в CLI: приоритеты и fail fast

Go SELF
50 уровень, 2 лекция
Открыта

1. Переменные окружения в CLI: зачем и как читать

Когда вы пишете CLI, вы неизбежно сталкиваетесь с двумя типами пользователей. Первый запускает вашу программу руками в терминале и готов указывать флаги явно. Второй запускает её в скрипте, CI или через cron — и там хочется один раз настроить среду, а не передавать 10 флагов каждый запуск. Переменные окружения как раз про это: они позволяют настраивать поведение программы «снаружи», не меняя код и не переписывая командную строку.

Переменные окружения (environment variables) — это пары ИМЯ=ЗНАЧЕНИЕ, которые операционная система передаёт вашему процессу при запуске. В Go мы читаем их через пакет os. Важно помнить одну простую, но слегка коварную вещь: в окружении всё строки. Даже если вы храните там «число», «булево», «таймаут» — в Go вы получите строку и должны явно распарсить её в нужный тип.

Чтобы разговор был не абстрактным, возьмём наш учебный проект todo и представим, что нам нужно конфигурировать:

  • путь к файлу данных (например, TODO_DATA_FILE);
  • таймаут (например, TODO_TIMEOUT_SEC), допустим, для операций, которые могут зависнуть.

Мы не будем усложнять: цель лекции — не «сделать супер‑конфиг», а научиться держать простой и предсказуемый контракт.

os.Getenv vs os.LookupEnv: «не задано» и «задано пустым»

Новички часто начинают с os.Getenv("NAME"). И это нормально: функция простая — возвращает строку. Но у неё есть неприятный UX‑эффект: если переменная не задана, она вернёт пустую строку "". А пустая строка может означать и другое: «переменная задана, но пустая». Иногда это важно различать, особенно когда конфиг обязательный.

Для этой задачи в Go есть более удобный инструмент: os.LookupEnv. Он возвращает два значения: строку и ok (найдена ли переменная).

package main

import (
	"fmt"
	"os"
)

func main() {
	v, ok := os.LookupEnv("TODO_DATA_FILE")
	fmt.Println("value =", v, "ok =", ok) // value = ... ok = true/false
}

Логика такая: если ok == false, значит переменной нет вообще. Если ok == true, значит она есть, и значение может быть хоть пустым (например, кто-то сделал TODO_DATA_FILE=). Для fail fast это важнейшее различие: «не задано» может означать «берём дефолт», а «задано пустым» часто означает «сломали конфигурацию».

2. Приоритет конфигурации: flags > env > default

Когда источников конфигурации несколько, у пользователя появляется закономерный вопрос: «а кто победит?». Если программа отвечает «зависит от фазы Луны и версии компилятора», пользователю не смешно. Нам нужен жёсткий договор:

  • default — значение по умолчанию, когда ничего не задано;
  • env — переопределяет default;
  • flags — переопределяют и env, и default.

Это можно зафиксировать табличкой:

Источник Пример Когда используется
Default
./todo.json
Если нет ни env, ни флага
Env
TODO_DATA_FILE=/tmp/todo.json
Если флаг не указан
Flags
-data /home/me/todo.json
Всегда сильнее env

И в виде маленькой схемы (на неё удобно смотреть, когда сомневаетесь):

flowchart LR
    D[default] --> E[env]
    E --> F[flags]
    D --> F

Главная мысль: флаг — это «я сейчас явно хочу вот так». Поэтому если пользователь указал -data ..., мы не имеем морального права сказать: «а у вас env стоит, так что мы вас проигнорировали».

«Env как default для флага»: простой способ без магии

Есть много способов смешивать конфиг. Некоторые быстро превращаются в детектив: «а был ли флаг задан явно?». В стандартном пакете flag это действительно не супер‑очевидно, если вы не используете продвинутые методы.

Самый простой и практичный способ соблюсти приоритет flags > env > default выглядит так:

  1. Сначала читаем env (если там что-то есть).
  2. Получаем «дефолтное значение для флага»: либо env, либо константный default.
  3. Объявляем флаг с этим дефолтом.
  4. Парсим флаги — и они автоматически побеждают.

Звучит скучно. А скучно — это хорошо: скучный код проще сопровождать.

3. Хелперы, валидация и fail fast

Если вы читаете env в десяти местах, у вас неизбежно появятся десять разных форматов ошибок и десять разных трактовок пробелов. Поэтому нормальная стратегия — сделать маленькие функции‑помощники и пользоваться ими везде одинаково.

envString: строка из env с дефолтом

Этот помощник просто выбирает env или default.

package main

import "os"

func envString(name, def string) string {
	if v, ok := os.LookupEnv(name); ok {
		return v
	}
	return def
}

Да, это три строки логики. Но это уже «единый стандарт» для всего проекта.

envInt: целое число из env с дефолтом и аккуратной ошибкой

Числа в env — это строки. Поэтому нужен strconv.Atoi.

package main

import (
	"fmt"
	"os"
	"strconv"
)

func envInt(name string, def int) (int, error) {
	s, ok := os.LookupEnv(name)
	if !ok {
		return def, nil
	}
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("%s must be integer, got %q", name, s)
	}
	return n, nil
}

Обратите внимание на %q: он печатает строку в кавычках, и в сообщении сразу видно, что пришло " 10" (с пробелом), "abc", "10\n" и т.д. Это мелочь, которая резко повышает «ремонтопригодность» CLI.

requireNonEmptyEnv: обязательная переменная без двусмысленности

Иногда конфиг должен быть строго задан. И тут os.LookupEnv снова полезен: мы можем различить «не задано» и «пусто».

package main

import (
	"fmt"
	"os"
)

func requireNonEmptyEnv(name string) (string, error) {
	v, ok := os.LookupEnv(name)
	if !ok {
		return "", fmt.Errorf("%s is required", name)
	}
	if v == "" {
		return "", fmt.Errorf("%s must not be empty", name)
	}
	return v, nil
}

В рамках нашего todo это может пригодиться, например, если вы решили, что путь к файлу данных должен быть задан явно. Но чаще для todo всё-таки приятнее иметь разумный default, поэтому обязательность оставим как приём, а не как обязательное правило.

Fail fast: плохая конфигурация — это ошибка запуска

Очень частая (и очень раздражающая) проблема CLI выглядит так: программа стартует, делает вид, что всё хорошо, а потом через 30 секунд падает где-то в глубине команды, потому что конфиг был кривой. Пользователь в этот момент думает: «почему вы не сказали сразу?».

Мы фиксируем правило: если конфигурация запуска некорректна, мы завершаемся сразу. Это и есть fail fast.

Важно отличать это от «ошибки использования команды». Например, пользователь написал todo add без обязательного -title — это usage‑ошибка конкретной команды. А вот TODO_TIMEOUT_SEC=abc — это проблема окружения запуска, и она не имеет отношения к конкретной операции «add» или «list». Это ошибка «я не могу даже нормально стартовать».

В наших соглашениях по CLI это обычно маппится на exit code 2 (usage), потому что исправляется настройкой запуска: поменять env или передать флаг. Но ключевая идея не в цифре, а в моменте: не запускать выполнение команд, пока конфиг не валиден.

Нюанс про пробелы: TrimSpace иногда спасает нервы

В идеальном мире env задают аккуратно: TODO_TIMEOUT_SEC=5. В реальном мире туда попадает что угодно, особенно если переменная пришла из .env‑файла, CI‑секретов или копипасты. Иногда внутри могут быть пробелы: " 5" или "5 ".

Можно относиться к этому строго и считать ошибкой. А можно сделать UX чуть дружелюбнее и триммить пробелы. Для чисел это часто оправдано, потому что смысл не меняется.

Вот как можно улучшить envInt (всё ещё коротко):

package main

import (
	"fmt"
	"os"
	"strconv"
	"strings"
)

func envInt(name string, def int) (int, error) {
	s, ok := os.LookupEnv(name)
	if !ok {
		return def, nil
	}
	s = strings.TrimSpace(s)
	n, err := strconv.Atoi(s)
	if err != nil {
		return 0, fmt.Errorf("%s must be integer, got %q", name, s)
	}
	return n, nil
}

Это не обязательно, но приятно: вы уменьшаете количество «глупых падений» от лишнего пробела. При этом got %q всё равно покажет, что пришло.

4. Встраиваем env‑конфиг в todo

Теперь соберём всё в небольшой, но реалистичный фрагмент. Представим, что у нас есть глобальные настройки приложения: файл данных и таймаут.

Сделаем структуру Config. Это не «архитектура ради архитектуры», а просто способ собрать настройки в одном месте, чтобы не таскать две переменные по всему коду.

package main

type Config struct {
	DataFile   string
	TimeoutSec int
}

Читаем env и валидируем

Теперь функция, которая читает env и формирует дефолты:

package main

import "fmt"

func loadEnvConfig() (Config, error) {
	timeout, err := envInt("TODO_TIMEOUT_SEC", 5)
	if err != nil {
		return Config{}, err
	}
	dataFile := envString("TODO_DATA_FILE", "./todo.json")
	if dataFile == "" {
		return Config{}, fmt.Errorf("TODO_DATA_FILE must not be empty")
	}
	return Config{DataFile: dataFile, TimeoutSec: timeout}, nil
}

Здесь мы используем наш помощник envInt, а строку берём через envString. Пустую строку запрещаем, потому что «путь к файлу = пусто» — почти всегда ошибка, а не «осознанная настройка».

Делаем флаги с дефолтами из env

Теперь в run (или в main, если у вас ещё нет run) мы делаем так: сначала env → потом флаги.

package main

import (
	"flag"
	"fmt"
	"os"
)

const exitUsage = 2

func main() {
	cfg, err := loadEnvConfig()
	if err != nil {
		fmt.Fprintln(os.Stderr, err.Error())
		os.Exit(exitUsage)
	}

	data := flag.String("data", cfg.DataFile, "data file (env TODO_DATA_FILE)")
	timeout := flag.Int("timeout", cfg.TimeoutSec, "timeout seconds (env TODO_TIMEOUT_SEC)")
	flag.Parse()

	_ = data
	_ = timeout
}

Это и есть тот самый приём «env как default флага». Если пользователь укажет -timeout 10, то 10 победит. Если не укажет — останется значение из env (если оно было), иначе — наш дефолт 5.

И да, мы прямо в тексте help пишем (env TODO_...). Это не «болтовня», а часть UX: пользователь должен увидеть альтернативный способ настройки, иначе он никогда не догадается.

Где именно читать env: один раз на старте

Очень соблазнительно сделать так: в команде add прочитать TODO_DATA_FILE, в команде list прочитать TODO_TIMEOUT_SEC, а в команде done прочитать ещё что-нибудь. В результате у вас получается «конфиг‑пюре»: часть настроек применится, часть — нет, а ошибки будут появляться в разных местах.

Гораздо спокойнее (и для пользователя, и для разработчика) держаться правила: env читается один раз, на старте, и превращается в структуру конфигурации. После этого команды получают уже готовые значения (через параметры функций или поля структуры). Это делает поведение программы стабильным: любой запуск либо стартует корректно, либо падает сразу с понятной причиной.

Если говорить проще: конфигурация — это «условия запуска процесса», а не «внутренний запрос данных во время работы». Поэтому ей логично жить на границе приложения, рядом с main.

Документирование env‑переменных: приложение должно быть честным

Когда вы пишете маленький todo, кажется, что документировать env — «слишком серьёзно». Но на практике именно отсутствие документации делает программу «магической»: вроде работает, но никто не понимает, как.

В нашем масштабе достаточно двух вещей.

Первое — упоминать env рядом с флагом в Usage/PrintDefaults, как мы уже сделали: "timeout seconds (env TODO_TIMEOUT_SEC)".

Второе — если у вас есть кастомный help, можно добавить короткий блок про env. Например, для root‑команды:

package main

import (
	"flag"
	"fmt"
	"os"
)

func setupUsage() {
	flag.Usage = func() {
		fmt.Fprintln(os.Stderr, "Usage:")
		fmt.Fprintln(os.Stderr, "  todo <cmd> [flags] [args]")
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Flags:")
		flag.PrintDefaults()
		fmt.Fprintln(os.Stderr, "")
		fmt.Fprintln(os.Stderr, "Environment:")
		fmt.Fprintln(os.Stderr, "  TODO_DATA_FILE, TODO_TIMEOUT_SEC")
	}
}

Заметьте: мы не пишем роман «о смысле жизни переменных окружения». Мы просто честно показываем, что такой механизм есть.

5. Типичные ошибки при работе с env‑конфигом

Ошибка №1: использование os.Getenv там, где важно различать «нет переменной» и «пустая».
Если вы читаете обязательную настройку через Getenv, вы не сможете понять, переменную забыли задать или её задали пустой строкой. В результате диагностика становится мутной: вы видите "" и не понимаете, это «так задумано» или «сломали конфиг». В таких местах лучше сразу брать os.LookupEnv.

Ошибка №2: нарушение приоритета — env внезапно побеждает флаг.
Иногда делают так: сначала парсят флаги, потом «подмешивают env», и в итоге флаг, который пользователь явно указал, оказывается проигнорирован. Это один из самых неприятных UX‑багов, потому что выглядит как «программа меня не слушает». Самый простой способ не попасть в это — использовать env как default при объявлении флага.

Ошибка №3: ошибки парсинга env игнорируются, и программа продолжает жить с мусором.
Если TODO_TIMEOUT_SEC=abc, а вы молча превращаете это в 0 или берёте default без предупреждения, пользователь потом будет ловить странное поведение и не понимать, откуда оно. Конфиг должен быть строгим: если значение задано, но невалидно — это ошибка запуска, и лучше завершиться сразу.

Ошибка №4: env читается «по месту», в разных командах, и поведение становится непредсказуемым.
Когда каждая подкоманда сама решает, что и как читать из окружения, появляется зоопарк: разные дефолты, разные форматы ошибок, разные трактовки пустых значений. Гораздо стабильнее читать env один раз на старте, валидировать, собрать Config и дальше передавать его в команды как обычные значения.

Ошибка №5: env существует, но пользователь про него не узнаёт.
Технически всё работает, но если help никак не упоминает TODO_TIMEOUT_SEC, то это «скрытая функция», а скрытые функции любят только авторы — пользователи их ненавидят (они же не телепаты). Достаточно маленькой приписки в PrintDefaults() и, при желании, одной строки в секции Environment вашего help.

1
Задача
Go SELF, 50 уровень, 2 лекция
Недоступна
Проверка конфигурации
Проверка конфигурации
1
Задача
Go SELF, 50 уровень, 2 лекция
Недоступна
Пул воркеров
Пул воркеров
1
Задача
Go SELF, 50 уровень, 2 лекция
Недоступна
Приоритет настроек
Приоритет настроек
1
Задача
Go SELF, 50 уровень, 2 лекция
Недоступна
Доступ по токену
Доступ по токену
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ