--- topic: config prefix: GCFG extends: arch/config.md --- # Конфигурация: реализация на Go Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы запрета на окружение. Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ, МЕХАНИЗИРОВАНО и СНЯТО толкуются как описано в языке конвенций версии 1 — тогда и только тогда, когда написаны заглавными. Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а проверка их непустоты идёт вместе с остальной валидацией — как описано в базовой конвенции. ## Правила ### GCFG-1. Формат конфигурации — TOML **ДОЛЖЕН.** Конфиг — файл TOML. **ПОЧЕМУ.** Базовая конвенция оставляет формат за стеком, и этот выбор делается один раз на язык, а не в каждом приложении: разные форматы в соседних сервисах означают разные загрузчики, разные шаблоны рендера конфига в деплое и разное поведение при синтаксической ошибке. TOML при этом даёт секции и типизированные скаляры без значимых отступов — конфиг, поправленный руками на сервере, ломается заметно, а не меняет вложенность молча. ### GCFG-2. Разбор и валидация — целиком в `internal/config` **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в `internal/config`; наружу пакет отдаёт готовую структуру `Config`. **ПОЧЕМУ.** Пока значение не покинуло пакет, оно может быть невалидным; после — уже нет, и это единственная граница, на которой такое утверждение проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос «проверено ли это поле» только чтением всех вызывающих, часть полей неизбежно окажется непроверенной, и fail-fast (GCFG-15) выродится в отказ посреди работы. Экспортированный разбор вдобавок даёт второй способ получить конфиг — мимо умолчаний (GCFG-5). ### GCFG-3. Весь конфиг — одна корневая структура **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из под-структур по секциям. **ПОЧЕМУ.** Один корень даёт одну точку, после которой конфиг проверен целиком, и дальше передаётся как обычный аргумент. Несколько независимых структур конфига означают несколько загрузок и вопрос «какая из них уже провалидирована» на каждом использовании; связанные между собой поля (включена интеграция — заданы все её поля) при этом перестают быть проверяемыми в одном месте. ### GCFG-4. Под-структуры названы по секциям файла **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. **ПОЧЕМУ.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит в код и обратно; при расхождении связь между полем файла и полем структуры восстанавливается чтением тегов, и проделывать это приходится для каждой секции заново. ### GCFG-5. Умолчания задаёт `Default()` **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл накладывается поверх. **ПОЧЕМУ.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание, подставленное по месту использования (`if x == 0 { x = … }`), поэтому не видно ни целиком, ни из образца, и два потребителя одного поля со временем подставляют разное. `Default()` — единственное место, откуда список умолчаний читается разом и переносится в образец. ### GCFG-6. Имя файла фиксировано, путь переопределяется флагом **СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории, путь переопределяет флаг `--config=path`, образец рядом — `config.example.toml`. **ПОЧЕМУ.** Фиксированное имя и переопределение из командной строки требует базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску пишутся, не открывая код приложения. Соседство `config.toml` и `config.example.toml` вдобавок делает расхождение образца с реальным конфигом видимым обычным `diff`, а не вычиткой. ### GCFG-7. Длительности — собственный тип с `UnmarshalText` **ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим `time.Duration`: ```go type Duration time.Duration func (d *Duration) UnmarshalText(b []byte) error { … } func (d Duration) Std() time.Duration { … } ``` **ПОЧЕМУ.** `time.Duration` — это `int64`, и TOML разбирает его в голое число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз проходит и разбор, и проверку диапазона, а проявляется нагрузкой или зависшим ожиданием. Запись `poll_interval = "5s"` несёт единицу измерения в себе и разбирается тем же `time.ParseDuration`, что и остальной код. У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть раньше, чем начинает работать сбор проблем (GCFG-12). Ошибка в длительности приходит отдельно и первой, а остальные проблемы конфига в этом запуске не показываются. ### GCFG-8. Приложение не читает окружение **НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. **ПОЧЕМУ.** Второй канал конфигурации — то, против чего написана базовая конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию. Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как чтением всего кода — а узнают о нём обычно на сервере, где переменная не выставлена. ### GCFG-9. Проверка запрета покрывает всю семью `os` **ДОЛЖЕН.** Механическая проверка GCFG-8 (`forbidigo`) ловит не только `os.Getenv`: ``` ^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$ ``` **ПОЧЕМУ.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет незаметно: правило числится механизированным, и глазами его больше никто не проверяет. Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят `syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`. Проверка закрывает обычные способы — те, которыми окружение читают не нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью механизированным нельзя. ### GCFG-10. За границей приложения запрет не действует **ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое приложение: | № | Кто читает | Вердикт | |---|---|---| | GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | | GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | **ПОЧЕМУ.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один механизм. Явное разрешение нужно и потому, что нерасписанная граница лечится `//nolint` наугад: там, где легальные случаи приходится глушить руками, вместе с ними проходят и нелегальные. ### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY` **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. **ПОЧЕМУ.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а дефолтный `http.Transport` — но читает он их от имени приложения и меняет поведение приложения, а не рантайма. Оставленные окружению, они дают ровно тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут исходящих запросов отличается от машины к машине без единого следа в конфиге и в образце, а расследование начинается с вопроса «почему на сервере ходит не так, как локально». ### GCFG-12. Проблемы конфига собираются `errors.Join` **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна ошибка, собранная `errors.Join`. **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию перезапусков по одному полю за раз, причём каждый следующий запуск обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой вложенной проблеме. ### GCFG-13. Имя зоны проверяется `time.LoadLocation` **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. **ПОЧЕМУ.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно тогда, когда база зон его знает, и никакая проверка формата не отличит `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка доживает до первого форматирования времени — то есть до рантайма, мимо fail-fast (GCFG-15). ### GCFG-14. `time/tzdata` импортируется в `main` **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном пакете. **ПОЧЕМУ.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или полагаться на системную» принадлежит собираемой программе. Со встроенной базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без неё тот же конфиг валиден на машине разработчика и падает в контейнере без zoneinfo, а сообщение указывает не на ту причину. ### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main` **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до старта серверов и воркеров. **ПОЧЕМУ.** Выход именно из `main`: `os.Exit` в библиотечном пакете не оставляет вызывающему возможности ни залогировать причину, ни дописать контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно до старта воркеров: горутина, поднятая раньше валидации, успевает сходить во внешний сервис и записать в базу от имени процесса, который потом объявит, что не стартовал. ## Связано - конвенция `time` — зона отображения и формат времени. - конвенция `logging` — `slog`, которым падает невалидный конфиг.