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 | |
Если нет ни env, ни флага |
| Env | |
Если флаг не указан |
| Flags | |
Всегда сильнее env |
И в виде маленькой схемы (на неё удобно смотреть, когда сомневаетесь):
flowchart LR
D[default] --> E[env]
E --> F[flags]
D --> F
Главная мысль: флаг — это «я сейчас явно хочу вот так». Поэтому если пользователь указал -data ..., мы не имеем морального права сказать: «а у вас env стоит, так что мы вас проигнорировали».
«Env как default для флага»: простой способ без магии
Есть много способов смешивать конфиг. Некоторые быстро превращаются в детектив: «а был ли флаг задан явно?». В стандартном пакете flag это действительно не супер‑очевидно, если вы не используете продвинутые методы.
Самый простой и практичный способ соблюсти приоритет flags > env > default выглядит так:
- Сначала читаем env (если там что-то есть).
- Получаем «дефолтное значение для флага»: либо env, либо константный default.
- Объявляем флаг с этим дефолтом.
- Парсим флаги — и они автоматически побеждают.
Звучит скучно. А скучно — это хорошо: скучный код проще сопровождать.
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.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ