Shanraq.org Shanraq.org
Конфигурация в Go: флаги, переменные окружения и пароль вне кода
IT

Go: с нуля до своего блога Урок 26 из 50

Конфигурация в Go: флаги, переменные окружения и пароль вне кода

Двадцать шестой урок курса по Go. Адрес, файл базы и ключ подписи уезжают из кода. Пакет flag и что даёт -h, почему переменную окружения удобно делать значением флага по умолчанию, чем пустая переменная отличается от незаданной и почему без обязательной настройки надо падать при старте.

Зачем это нужно

В коде блога уже вписаны :8080 и путь к папке. Пока вы работаете один на своей машине, это незаметно. Проблемы начинаются в тот день, когда программа должна запуститься где-то ещё.

На сервере другой порт. У соседа занят 80-й. В тестах нужен свой. Каждое такое отличие — либо правка кода и пересборка, либо настройка снаружи.

А ещё скоро появится база данных, и вместе с ней — пароль. Пароль, попавший в git, остаётся в истории навсегда: удалить строку недостаточно, её видно в любом старом коммите. Поэтому научиться выносить настройки надо до того, как появится что выносить.

Сразу целиком

Новая папка, go mod init sabaq24, main.go:

package main

import (
	"errors"
	"flag"
	"fmt"
	"log/slog"
	"net/http"
	"os"

	_ "modernc.org/sqlite"
)

var log = slog.New(slog.NewTextHandler(os.Stdout, nil))

// Все настройки программы в одном месте. Собираются один раз при запуске,
// дальше их только читают.
type config struct {
	addr   string
	dbPath string
	secret string
	dev    bool
}

// Значение переменной окружения или запасное, если переменной нет.
func env(name, fallback string) string {
	if v, ok := os.LookupEnv(name); ok {
		return v
	}
	return fallback
}

// Три источника разом. Значение по умолчанию лежит в коде, переменная
// окружения подменяет его, а флаг подменяет обе — потому что переменная
// становится для флага значением по умолчанию.
func load() (config, error) {
	var c config
	flag.StringVar(&c.addr, "addr", env("BLOG_ADDR", ":8080"), "адрес и порт")
	flag.StringVar(&c.dbPath, "db", env("BLOG_DB_PATH", "blog.db"), "файл базы SQLite")
	flag.StringVar(&c.secret, "secret", env("BLOG_SECRET", ""), "ключ для подписи cookie")
	flag.BoolVar(&c.dev, "dev", env("BLOG_DEV", "") == "1", "подробные ответы для разработки")
	flag.Parse()

	if c.secret == "" {
		return c, errors.New("не задан ключ подписи: -secret или BLOG_SECRET")
	}
	return c, nil
}

func main() {
	c, err := load()
	if err != nil {
		fmt.Fprintln(os.Stderr, "настройки:", err)
		os.Exit(1)
	}

	// Ключ в журнал не пишут даже наполовину: печатаем только его длину.
	log.Info("запускаемся", "адрес", c.addr, "база", c.dbPath,
		"ключ задан", true, "длина ключа", len(c.secret), "разработка", c.dev)

	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Мой блог")
	})

	if err := http.ListenAndServe(c.addr, mux); err != nil {
		log.Error("сервер остановился", "ошибка", err)
		os.Exit(1)
	}
}

Соберите её отдельным файлом — так будет видно имя программы в справке:

go build -o sabaq24 .
./sabaq24
настройки: не задан ключ подписи: -secret или BLOG_SECRET

Разбор

Настройки — это структура, а не разбросанные os.Getenv

Соблазн простой: где понадобилось значение, там и написать os.Getenv("BLOG_ADDR"). Так делать не надо, и причина не в красоте.

Собранная структура отвечает на вопрос «что вообще настраивается» одним взглядом на тип. Разбросанные обращения не отвечают на него никак: чтобы узнать список, придётся искать по всему коду, и вы всё равно что-нибудь пропустите.

Второе: собранная один раз структура проверяется один раз. Если os.Getenv вызывается на каждом запросе, опечатка в имени переменной вылезет не при запуске, а через неделю в проде.

Образ. Ключи от квартиры на одной связке в кармане, а не по одному в разных карманах и в куртке в шкафу. Не потому что аккуратнее, а потому что перед выходом хочется одним движением проверить, что все на месте.

Флаги: пакет flag и что даёт -h

flag.StringVar(&c.addr, "addr", ":8080", "адрес и порт")
flag.Parse()

StringVar кладёт значение прямо в поле структуры. Четыре аргумента: куда положить, как называется флаг, что взять, если флага нет, и описание для человека.

flag.Parse() обязателен: до него в переменных лежат значения по умолчанию. Вызывают его один раз, после того как объявлены все флаги.

Описание — не для красоты. Оно превращается в справку:

./sabaq24 -h
Usage of ./sabaq24:
  -addr string
    	адрес и порт (default ":8080")
  -db string
    	файл базы SQLite (default "blog.db")
  -dev
    	подробные ответы для разработки
  -secret string
    	ключ для подписи cookie

Эту справку вы получили, не написав ни строки для неё. И заметьте: значения по умолчанию показаны, а у -secret его нет — потому что у ключа подписи разумного значения по умолчанию не бывает.

Опечатка в имени флага тоже обработана:

./sabaq24 -port 9000
flag provided but not defined: -port
Usage of ./sabaq24:

Программа не запустится с непонятым флагом, и код выхода будет 2. Это правильное поведение: молча проигнорировать -port было бы хуже.

Переменная окружения — это значение флага по умолчанию

Самый удобный приём урока умещается в одну строку:

flag.StringVar(&c.addr, "addr", env("BLOG_ADDR", ":8080"), "адрес и порт")

Третий аргумент — не константа, а вызов функции. Поэтому порядок старшинства получается сам собой, без единого if:

  • флага нет и переменной нет — берётся :8080 из кода;
  • переменная есть — берётся она;
  • есть флаг — берётся он, что бы ни лежало в переменной.

Проверьте:

BLOG_ADDR=:8290 BLOG_SECRET=k4z-secret-key ./sabaq24
level=INFO msg=запускаемся адрес=:8290 база=blog.db "ключ задан"=true "длина ключа"=14 разработка=false

Время в выдержках из журнала убрано: оно меняется с каждым запуском.

BLOG_ADDR=:8290 BLOG_SECRET=k4z-secret-key ./sabaq24 -addr :8390
level=INFO msg=запускаемся адрес=:8390 база=blog.db "ключ задан"=true "длина ключа"=14 разработка=false

Переменная сказала :8290, флаг сказал :8390, победил флаг.

Пусто и «не задано» — разные вещи

Поэтому в env стоит os.LookupEnv, а не os.Getenv.

os.Getenv возвращает пустую строку и когда переменной нет, и когда она есть, но пустая. Различить нельзя. os.LookupEnv возвращает второе значение — было ли имя вообще объявлено:

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

Разница видна сразу. Переменная не задана:

адрес=:8080

Переменная задана пустой — BLOG_ADDR= ./sabaq24 …:

адрес=""

Второй случай — не то же самое, что первый: пустой адрес для net/http означает «все адреса, порт по умолчанию», то есть восьмидесятый. Человек, написавший BLOG_ADDR= в скрипте, хотел «оставь как есть», а получил другой порт.

С Getenv вы бы этого даже не заметили. С LookupEnv пустое значение остаётся пустым и ведёт себя как явно заданное — то есть ровно так, как его и задали.

Нет обязательной настройки — падаем при старте

if c.secret == "" {
	return c, errors.New("не задан ключ подписи: -secret или BLOG_SECRET")
}

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

Три мелочи, которые делают такое падение полезным. Сообщение уходит в os.Stderr, а не в обычный вывод — журналы и ошибки разделяют. Код выхода 1, а не 0 — по нему systemd или docker поймут, что запуск не удался, и не будут делать вид, что всё хорошо. И в тексте названы оба способа задать значение, чтобы человеку не пришлось лезть в исходник.

Ключ не попадает ни в код, ни в журнал

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

Соблазн написать «ключ: abc123» ради отладки велик. Мы печатаем только то, что помогает и ничего не выдаёт:

log.Info("запускаемся", "ключ задан", true, "длина ключа", len(c.secret))
"ключ задан"=true "длина ключа"=14

Этого хватает, чтобы понять, что ключ подхватился и что он не обрезан. Самого ключа нет. А журналы уезжают в системы хранения, попадают в скриншоты и в переписку с поддержкой.

Здесь стоит запомнить общее правило, а не приём: секрет либо не печатают вовсе, либо печатают то, что о нём нельзя восстановить, — длину, признак наличия, первые два знака отпечатка. Не «половину пароля»: половины обычно хватает.

И правило, которое стоит завести сегодня: файл с настройками, где лежат настоящие ключи, называют .env и сразу добавляют в .gitignore. Не после первого коммита — до.

Где что задают

Флаги удобны на своей машине: видно в истории терминала, легко поменять, не надо ничего экспортировать.

Переменные окружения удобны на сервере: их задаёт то, что запускает программу — systemd, docker, хостинг, — и они не попадают ни в обычный вывод ps, ни в историю команд. Ключ, переданный флагом, виден в ps любому пользователю машины; переданный переменной — нет.

Оговорка, чтобы вы не считали переменные тайником. Это не защита, а всего лишь меньшая заметность: ps eww покажет окружение процесса вам самим, а root видит его у всех — проверено. Настоящая защита секрета — права на файл .env и на того, кто может запускать процессы на этой машине.

Отсюда обычное разделение: на своей машине флаги, на сервере переменные. Программе всё равно — она читает и то и другое.

Карта урока

Карта урока: одна настройка приходит из трёх мест, побеждает последнее

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Почему переменную окружения передают третьим аргументом в flag.StringVar, а не сравнивают её с флагом вручную?
  2. Чем os.LookupEnv отличается от os.Getenv и когда разница видна?
  3. Почему пароль опасно писать в журнал, если в коде его нет?

Задание

Обязательное. Добавьте в config уровень журнала. Задайте его строкой info или debug и превратите в slog.Level. Неизвестное значение — ошибка при старте, а не молчаливый info. Путь к статике настраивать не нужно и не надо: она встроена в программу через embed ещё в уроке про статику, и настраивать там нечего.

По желанию.

  • Добавьте флаг -version, который печатает версию и завершает программу с кодом 0.
  • Сделайте BLOG_ADDR= пустым и посмотрите, на каком порту окажется сервер.
  • Замените LookupEnv на Getenv и найдите случай, в котором программа начнёт вести себя иначе.

Куда это встанет в блоге

Настройки собраны в одном месте, и путь к базе уже среди них. Файла blog.db пока никто не открывает — этим займётся следующий модуль, и config там менять не придётся.

Долги. Файла настроек у нас нет: для блога хватает флагов и переменных, а yaml или toml понадобятся, когда настроек станет три десятка. Проверка пока одна — «ключ не пуст»; для адреса стоило бы проверять форму, а для длины ключа — минимум. И flag не умеет обязательных флагов: -secret обязателен только потому, что мы сами проверили его после Parse.

Ответы

Показать ответы
  1. Потому что тогда порядок старшинства получается сам собой: значение по умолчанию для флага вычисляется из переменной, а флаг, если он задан, перекрывает его. Ручное сравнение — это лишний if на каждую настройку и место, где легко перепутать, что кого перекрывает.
  2. Getenv возвращает пустую строку и для незаданной переменной, и для заданной пустой — эти случаи неразличимы. LookupEnv возвращает вторым значением признак «переменная объявлена». Разница видна, когда пустое значение задано намеренно: с Getenv программа подставит значение по умолчанию, с LookupEnv — оставит пустое, как и просили.
  3. Потому что журнал живёт дольше и путешествует дальше, чем кажется: он уходит в систему хранения логов, попадает в скриншот в переписке, его читает тот, у кого нет доступа к серверу. Строку подключения печатают без пароля — остального хватает, чтобы понять, куда программа подключалась.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

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

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.