- пятая, необязательная часть правила: код парой «плохо → хорошо» после обоснования; метка добавлена в словарь (EXAMPLES) и в строку о версии языка во всех тринадцати файлах - сказано, чем примеры не являются: требований в блоке нет, дословным сниппетом он не служит, при расхождении с нормой правят пример - READING.md обновлён по META-30, в машинные проверки добавлен порядок блоков, в читательские — что примеры норму не расширяют
228 lines
16 KiB
Markdown
228 lines
16 KiB
Markdown
---
|
||
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`, которым падает невалидный конфиг.
|