остальные конвенции переведены на формальный язык

- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у
  каждого модальность и обязательный блок «Почему»
- классифицирующие места оформлены таблицами, файловый статус снят
  отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
av
2026-07-25 19:17:32 +03:00
parent 7701a28df1
commit 31d0620f55
11 changed files with 2404 additions and 811 deletions
+178 -43
View File
@@ -1,26 +1,94 @@
---
status: рекомендуемая
extends: arch/config.md
---
# Конфигурация: реализация на Go
Как `arch/config.md` выглядит в Go-приложении.
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы
запрета на окружение. Форма записи — `common/language.md`.
## Формат и загрузчик
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
проверка их непустоты идёт вместе с остальной валидацией — как описано в
базовой конвенции.
- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся
готовая структура `Config`.
- Одна корневая структура `Config` с под-структурами по секциям — имена
структур совпадают с именами секций, чтобы конфиг и код читались рядом.
- Умолчания — в `Default()`, поверх накладывается разобранный файл.
- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в
рабочей директории, образец — `config.example.toml`.
## Правила
## Длительности
### R1. Формат конфигурации — TOML
`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип
с `UnmarshalText`, отдающий `time.Duration`:
**ДОЛЖЕН.** Конфиг — файл TOML.
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор
делается один раз на язык, а не в каждом приложении: разные форматы в
соседних сервисах означают разные загрузчики, разные шаблоны рендера
конфига в деплое и разное поведение при синтаксической ошибке. TOML при
этом даёт секции и типизированные скаляры без значимых отступов — конфиг,
поправленный руками на сервере, ломается заметно, а не меняет вложенность
молча.
### R2. Разбор и валидация — целиком в `internal/config`
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным;
после — уже нет, и это единственная граница, на которой такое утверждение
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
«проверено ли это поле» только чтением всех вызывающих, часть полей
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ
посреди работы. Экспортированный разбор вдобавок даёт второй способ
получить конфиг — мимо умолчаний (R5).
### R3. Весь конфиг — одна корневая структура
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям.
**Почему.** Один корень даёт одну точку, после которой конфиг проверен
целиком, и дальше передаётся как обычный аргумент. Несколько независимых
структур конфига означают несколько загрузок и вопрос «какая из них уже
провалидирована» на каждом использовании; связанные между собой поля
(включена интеграция — заданы все её поля) при этом перестают быть
проверяемыми в одном месте.
### R4. Под-структуры названы по секциям файла
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
в код и обратно; при расхождении связь между полем файла и полем структуры
восстанавливается чтением тегов, и проделывать это приходится для каждой
секции заново.
### R5. Умолчания задаёт `Default()`
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх.
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
видно ни целиком, ни из образца, и два потребителя одного поля со временем
подставляют разное. `Default()` — единственное место, откуда список
умолчаний читается разом и переносится в образец.
### R6. Имя файла фиксировано, путь переопределяется флагом
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
путь переопределяет флаг `--config=path`, образец рядом —
`config.example.toml`.
**Почему.** Фиксированное имя и переопределение из командной строки требует
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
пишутся, не открывая код приложения. Соседство `config.toml` и
`config.example.toml` вдобавок делает расхождение образца с реальным
конфигом видимым обычным `diff`, а не вычиткой.
### R7. Длительности — собственный тип с `UnmarshalText`
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
`time.Duration`:
```go
type Duration time.Duration
@@ -29,50 +97,117 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
func (d Duration) Std() time.Duration { }
```
Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое
число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей
валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь
отдельно и первой.
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
зависшим ожиданием. Запись `poll_interval = "5s"` несёт единицу измерения
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
## Чтение окружения
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
показываются.
Приложение не читает окружение для конфигурации. Механизируется
`forbidigo`, и паттерн должен покрывать **все** входы, а не только
### R8. Приложение не читает окружение
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
**Почему.** Второй канал конфигурации — то, против чего написана базовая
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
выставлена.
### R9. Проверка запрета покрывает все входы в окружение
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только
`os.Getenv`:
```
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
```
Правило про приложение, поэтому за его границей запрет не действует:
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
незаметно: правило числится механизированным, и глазами его больше никто не
проверяет.
- **тесты** — не приложение: интеграционному тесту нормально брать
креды внешнего сервиса из окружения;
- **переменные рантайма** — те, что читает не наш код, а Go или ОС
(`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`).
### R10. За границей приложения запрет не действует
Отдельный случай — переменные, которые читает **стандартная библиотека от
имени приложения**: дефолтный `http.Transport` уважает
`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это
конфигурация поведения приложения, поэтому прокси задаётся полем конфига и
явным `Transport`, а не окружением.
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
приложение:
## Валидация
| № | Кто читает | Вердикт |
|---|---|---|
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
- Проверки собираются `errors.Join`, чтобы за один запуск показать **все**
проблемы конфига, а не первую.
- IANA-зона валидируется `time.LoadLocation`. База зон встраивается
импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете:
иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной
базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие
zoneinfo в контейнере.
- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до
старта серверов и воркеров.
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
механизм. Явное разрешение нужно и потому, что нерасписанная граница
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
руками, вместе с ними проходят и нелегальные.
## Секреты
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY`
Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их
непустоты идёт вместе с остальной валидацией — см. базу.
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут
исходящих запросов отличается от машины к машине без единого следа в
конфиге и в образце, а расследование начинается с вопроса «почему на
сервере ходит не так, как локально».
### R12. Проблемы конфига собираются `errors.Join`
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию
перезапусков по одному полю за раз, причём каждый следующий запуск
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
вложенной проблеме.
### R13. Имя зоны проверяется `time.LoadLocation`
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо
fail-fast (R15).
### R14. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете.
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
zoneinfo, а сообщение указывает не на ту причину.
### R15. Невалидный конфиг — `ERROR` и выход из `main`
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров.
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
оставляет вызывающему возможности ни залогировать причину, ни дописать
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
во внешний сервис и записать в базу от имени процесса, который потом
объявит, что не стартовал.
<!-- local:поля -->
<!-- /local -->