Files
dev-conventions/conventions/lang/go/config.md
T
av c1cb240540 темы: определение, объявление в шапке и манифест набора
- тема — набор правил об одном фокусе разработки, имя латиницей (нижний
  kebab-case рекомендуется, годится любой идентификатор, пригодный для имени
  файла); определение в LANGUAGE.md и README.md
- заведены META-28 и META-29: тема объявляется в шапке (`topic:`), стоит в
  манифесте набора и не переиспользуется; `topic:` добавлен во все 12 файлов
- prefixes.toml и topics.toml слиты в manifest.toml — манифест набора против
  манифеста подключения `.conventions.toml`, разделы topics/prefixes с live
  и retired
2026-07-26 15:27:55 +03:00

228 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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`, которым падает невалидный конфиг.