Files
dev-conventions/conventions/lang/go/config.md
T
av 59a1c23f55 язык: заведён блок ПРИМЕРЫ
- пятая, необязательная часть правила: код парой «плохо → хорошо» после
  обоснования; метка добавлена в словарь (EXAMPLES) и в строку о версии
  языка во всех тринадцати файлах
- сказано, чем примеры не являются: требований в блоке нет, дословным
  сниппетом он не служит, при расхождении с нормой правят пример
- READING.md обновлён по META-30, в машинные проверки добавлен порядок
  блоков, в читательские — что примеры норму не расширяют
2026-07-26 16:09:58 +03:00

16 KiB
Raw Blame History

topic, prefix, extends
topic prefix extends
config GCFG 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:

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 — зона отображения и формат времени.
  • конвенция loggingslog, которым падает невалидный конфиг.