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 и на того, кто может запускать процессы на этой машине.
Отсюда обычное разделение: на своей машине флаги, на сервере переменные. Программе всё равно — она читает и то и другое.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Почему переменную окружения передают третьим аргументом в
flag.StringVar, а не сравнивают её с флагом вручную? - Чем
os.LookupEnvотличается отos.Getenvи когда разница видна? - Почему пароль опасно писать в журнал, если в коде его нет?
Задание
Обязательное. Добавьте в config уровень журнала. Задайте его строкой info или debug и превратите в slog.Level. Неизвестное значение — ошибка при старте, а не молчаливый info. Путь к статике настраивать не нужно и не надо: она встроена в программу через embed ещё в уроке про статику, и настраивать там нечего.
По желанию.
- Добавьте флаг
-version, который печатает версию и завершает программу с кодом0. - Сделайте
BLOG_ADDR=пустым и посмотрите, на каком порту окажется сервер. - Замените
LookupEnvнаGetenvи найдите случай, в котором программа начнёт вести себя иначе.
Куда это встанет в блоге
Настройки собраны в одном месте, и путь к базе уже среди них. Файла blog.db пока никто не открывает — этим займётся следующий модуль, и config там менять не придётся.
Долги. Файла настроек у нас нет: для блога хватает флагов и переменных, а yaml или toml понадобятся, когда настроек станет три десятка. Проверка пока одна — «ключ не пуст»; для адреса стоило бы проверять форму, а для длины ключа — минимум. И flag не умеет обязательных флагов: -secret обязателен только потому, что мы сами проверили его после Parse.
Ответы
Показать ответы
- Потому что тогда порядок старшинства получается сам собой: значение по умолчанию для флага вычисляется из переменной, а флаг, если он задан, перекрывает его. Ручное сравнение — это лишний
ifна каждую настройку и место, где легко перепутать, что кого перекрывает. Getenvвозвращает пустую строку и для незаданной переменной, и для заданной пустой — эти случаи неразличимы.LookupEnvвозвращает вторым значением признак «переменная объявлена». Разница видна, когда пустое значение задано намеренно: сGetenvпрограмма подставит значение по умолчанию, сLookupEnv— оставит пустое, как и просили.- Потому что журнал живёт дольше и путешествует дальше, чем кажется: он уходит в систему хранения логов, попадает в скриншот в переписке, его читает тот, у кого нет доступа к серверу. Строку подключения печатают без пароля — остального хватает, чтобы понять, куда программа подключалась.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.