- метка обоснования пишется заглавными и вошла в словарь набора: скелет правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и `**Почему.**`; в переводе на другой язык метка меняется как остальные слова (ПОЧЕМУ / WHY), 235 вхождений заменены - метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице модальности - версия языка поднята до 2, потому что изменение формы меняет чтение уже написанного текста; строка о версии в двенадцати конвенциях перечисляет теперь и метки, а служебные слова сценария в неё по-прежнему не входят
16 KiB
prefix, extends
| prefix | extends |
|---|---|
| GCFG | arch/config.md |
Конфигурация: реализация на Go
Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы запрета на окружение.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 — тогда и только тогда, когда написаны заглавными.
Секретов 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— зона отображения и формат времени. - конвенция
logging—slog, которым падает невалидный конфиг.