ПОЧЕМУ стало ключевым словом, язык поднят до версии 2
- метка обоснования пишется заглавными и вошла в словарь набора: скелет правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и `**Почему.**`; в переводе на другой язык метка меняется как остальные слова (ПОЧЕМУ / WHY), 235 вхождений заменены - метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице модальности - версия языка поднята до 2, потому что изменение формы меняет чтение уже написанного текста; строка о версии в двенадцати конвенциях перечисляет теперь и метки, а служебные слова сценария в неё по-прежнему не входят
This commit is contained in:
@@ -8,9 +8,9 @@ extends: arch/config.md
|
||||
Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы
|
||||
запрета на окружение.
|
||||
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||||
только тогда, когда написаны заглавными.
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 —
|
||||
тогда и только тогда, когда написаны заглавными.
|
||||
|
||||
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
|
||||
проверка их непустоты идёт вместе с остальной валидацией — как описано в
|
||||
@@ -22,7 +22,7 @@ extends: arch/config.md
|
||||
|
||||
**ДОЛЖЕН.** Конфиг — файл TOML.
|
||||
|
||||
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор
|
||||
**ПОЧЕМУ.** Базовая конвенция оставляет формат за стеком, и этот выбор
|
||||
делается один раз на язык, а не в каждом приложении: разные форматы в
|
||||
соседних сервисах означают разные загрузчики, разные шаблоны рендера
|
||||
конфига в деплое и разное поведение при синтаксической ошибке. TOML при
|
||||
@@ -35,7 +35,7 @@ extends: arch/config.md
|
||||
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
|
||||
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
|
||||
|
||||
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным;
|
||||
**ПОЧЕМУ.** Пока значение не покинуло пакет, оно может быть невалидным;
|
||||
после — уже нет, и это единственная граница, на которой такое утверждение
|
||||
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
|
||||
«проверено ли это поле» только чтением всех вызывающих, часть полей
|
||||
@@ -48,7 +48,7 @@ extends: arch/config.md
|
||||
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
|
||||
под-структур по секциям.
|
||||
|
||||
**Почему.** Один корень даёт одну точку, после которой конфиг проверен
|
||||
**ПОЧЕМУ.** Один корень даёт одну точку, после которой конфиг проверен
|
||||
целиком, и дальше передаётся как обычный аргумент. Несколько независимых
|
||||
структур конфига означают несколько загрузок и вопрос «какая из них уже
|
||||
провалидирована» на каждом использовании; связанные между собой поля
|
||||
@@ -59,7 +59,7 @@ extends: arch/config.md
|
||||
|
||||
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
|
||||
|
||||
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
|
||||
**ПОЧЕМУ.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
|
||||
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
|
||||
в код и обратно; при расхождении связь между полем файла и полем структуры
|
||||
восстанавливается чтением тегов, и проделывать это приходится для каждой
|
||||
@@ -70,7 +70,7 @@ extends: arch/config.md
|
||||
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
|
||||
накладывается поверх.
|
||||
|
||||
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
|
||||
**ПОЧЕМУ.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
|
||||
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
|
||||
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
|
||||
видно ни целиком, ни из образца, и два потребителя одного поля со временем
|
||||
@@ -83,7 +83,7 @@ extends: arch/config.md
|
||||
путь переопределяет флаг `--config=path`, образец рядом —
|
||||
`config.example.toml`.
|
||||
|
||||
**Почему.** Фиксированное имя и переопределение из командной строки требует
|
||||
**ПОЧЕМУ.** Фиксированное имя и переопределение из командной строки требует
|
||||
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
|
||||
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
|
||||
пишутся, не открывая код приложения. Соседство `config.toml` и
|
||||
@@ -102,7 +102,7 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
|
||||
func (d Duration) Std() time.Duration { … }
|
||||
```
|
||||
|
||||
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое
|
||||
**ПОЧЕМУ.** `time.Duration` — это `int64`, и TOML разбирает его в голое
|
||||
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
|
||||
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
|
||||
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
|
||||
@@ -118,7 +118,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
|
||||
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
|
||||
|
||||
**Почему.** Второй канал конфигурации — то, против чего написана базовая
|
||||
**ПОЧЕМУ.** Второй канал конфигурации — то, против чего написана базовая
|
||||
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
|
||||
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
|
||||
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
|
||||
@@ -134,7 +134,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
|
||||
```
|
||||
|
||||
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
|
||||
**ПОЧЕМУ.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
|
||||
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
|
||||
незаметно: правило числится механизированным, и глазами его больше никто не
|
||||
проверяет.
|
||||
@@ -155,7 +155,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
|
||||
| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
|
||||
|
||||
**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
|
||||
**ПОЧЕМУ.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
|
||||
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
|
||||
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
|
||||
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
|
||||
@@ -167,7 +167,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
|
||||
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
|
||||
|
||||
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
|
||||
**ПОЧЕМУ.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
|
||||
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
|
||||
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
|
||||
тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
|
||||
@@ -180,7 +180,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
|
||||
ошибка, собранная `errors.Join`.
|
||||
|
||||
**Почему.** Возврат первой ошибки превращает починку конфига в серию
|
||||
**ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
|
||||
перезапусков по одному полю за раз, причём каждый следующий запуск
|
||||
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
|
||||
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
|
||||
@@ -190,7 +190,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
|
||||
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
|
||||
|
||||
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
|
||||
**ПОЧЕМУ.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
|
||||
тогда, когда база зон его знает, и никакая проверка формата не отличит
|
||||
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
|
||||
доживает до первого форматирования времени — то есть до рантайма, мимо
|
||||
@@ -201,7 +201,7 @@ fail-fast (GCFG-15).
|
||||
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
|
||||
пакете.
|
||||
|
||||
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
|
||||
**ПОЧЕМУ.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
|
||||
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
|
||||
полагаться на системную» принадлежит собираемой программе. Со встроенной
|
||||
базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
|
||||
@@ -213,7 +213,7 @@ zoneinfo, а сообщение указывает не на ту причину
|
||||
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
|
||||
старта серверов и воркеров.
|
||||
|
||||
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
|
||||
**ПОЧЕМУ.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
|
||||
оставляет вызывающему возможности ни залогировать причину, ни дописать
|
||||
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
|
||||
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
|
||||
|
||||
@@ -7,9 +7,9 @@ extends: arch/db-identifiers.md
|
||||
|
||||
Как базовый слой выглядит в Go-приложении.
|
||||
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||||
только тогда, когда написаны заглавными.
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 —
|
||||
тогда и только тогда, когда написаны заглавными.
|
||||
|
||||
Единая точка из `KEYS-3` — пакет `internal/ident`: он
|
||||
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
|
||||
@@ -22,7 +22,7 @@ extends: arch/db-identifiers.md
|
||||
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
|
||||
`internal/ident`; других генераторов и парсеров id в коде нет.
|
||||
|
||||
**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов
|
||||
**ПОЧЕМУ.** Реализация `KEYS-3` и `KEYS-4`. Вызов
|
||||
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
|
||||
выглядит нарушением: значение получается валидное, просто мимо нормализации
|
||||
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
|
||||
@@ -35,7 +35,7 @@ ULID-библиотеки — одна строка, доступная из л
|
||||
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
|
||||
внутри `Create`-метода слоя store.
|
||||
|
||||
**Почему.** `KEYS-2` требует, чтобы значение было
|
||||
**ПОЧЕМУ.** `KEYS-2` требует, чтобы значение было
|
||||
известно до вставки, но не говорит, кто его присваивает. Store — последний
|
||||
слой, через который проходят все пути создания строки, включая импорт,
|
||||
фоновые задания и тесты. Генерация выше по стеку делает присвоение
|
||||
@@ -48,7 +48,7 @@ ULID-библиотеки — одна строка, доступная из л
|
||||
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
|
||||
вызовом `ident.NewID()` там, где операция начинается.
|
||||
|
||||
**Почему.** Смысл такого идентификатора (`KEYS-7`) —
|
||||
**ПОЧЕМУ.** Смысл такого идентификатора (`KEYS-7`) —
|
||||
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
|
||||
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
|
||||
когда операция упала до того, как что-либо записала: без общего ключа эти
|
||||
@@ -59,7 +59,7 @@ ULID-библиотеки — одна строка, доступная из л
|
||||
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
|
||||
Go-миграции, порождаются с историческим временем строки, а не с текущим.
|
||||
|
||||
**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не
|
||||
**ПОЧЕМУ.** Сортировка id тогда сохраняет историческую хронологию, а не
|
||||
момент прогона миграции. Иначе все затронутые строки получают метку одного
|
||||
момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id`
|
||||
начинает врать ровно на том массиве данных, который старше всего.
|
||||
@@ -71,7 +71,7 @@ Go-миграции, порождаются с историческим врем
|
||||
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
|
||||
callback'а бота — раньше, чем идентификатор попадёт в store.
|
||||
|
||||
**Почему.** Реализация `KEYS-5`. Граница выбрана
|
||||
**ПОЧЕМУ.** Реализация `KEYS-5`. Граница выбрана
|
||||
транспортная, потому что только на ней известен источник значения, от
|
||||
которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
|
||||
того, пришла она из URL или из собственной формы, и ответить по-разному
|
||||
@@ -82,7 +82,7 @@ callback'а бота — раньше, чем идентификатор поп
|
||||
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
|
||||
`string`.
|
||||
|
||||
**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им
|
||||
**ПОЧЕМУ.** Отдельный тип окупается только тогда, когда компилятор ловит им
|
||||
ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и
|
||||
`authorID`) он не спасает — оба будут одного типа, и различают их имена
|
||||
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
|
||||
@@ -93,7 +93,7 @@ json и шаблонами, то есть даёт цену без выгоды.
|
||||
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
|
||||
можно перепутать, для них заводятся различимые типы.
|
||||
|
||||
**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
|
||||
**ПОЧЕМУ.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
|
||||
типизацию навсегда. Условие названо ровно то, при котором тип начинает
|
||||
работать: пока все идентификаторы — `string`, подстановка одного вида
|
||||
вместо другого компилируется и обнаруживается только на данных.
|
||||
@@ -108,7 +108,7 @@ json и шаблонами, то есть даёт цену без выгоды.
|
||||
| GKEY-8.1 | путь или query URL | 404 без обращения к store |
|
||||
| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
|
||||
|
||||
**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
|
||||
**ПОЧЕМУ.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
|
||||
HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
|
||||
и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
|
||||
значение сформировало само приложение, и невалидность означает баг
|
||||
@@ -121,7 +121,7 @@ HTTP-кодов. В случае GKEY-8.1 снаружи это неотличи
|
||||
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
|
||||
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
|
||||
|
||||
**Почему.** Инверсия правила «трансляция у источника» из конвенции
|
||||
**ПОЧЕМУ.** Инверсия правила «трансляция у источника» из конвенции
|
||||
`errors`. Sentinel — сообщение от слоя, который знает факт:
|
||||
строка не найдена, потому что store её искал. Сфабрикованный транспортом,
|
||||
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
|
||||
|
||||
@@ -7,9 +7,9 @@ prefix: MIGR
|
||||
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
|
||||
Go-приложении.
|
||||
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||||
только тогда, когда написаны заглавными.
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 —
|
||||
тогда и только тогда, когда написаны заглавными.
|
||||
|
||||
## Область действия
|
||||
|
||||
@@ -25,7 +25,7 @@ Go-приложении.
|
||||
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
|
||||
goose.
|
||||
|
||||
**Почему.** Журнал применённых версий goose держит в самой базе
|
||||
**ПОЧЕМУ.** Журнал применённых версий goose держит в самой базе
|
||||
(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй
|
||||
инструмент заводит второй журнал: миграция, применённая одним, для другого
|
||||
выглядит неприменённой, и попытка накатить её повторно упирается в уже
|
||||
@@ -37,7 +37,7 @@ goose.
|
||||
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
|
||||
схемой.
|
||||
|
||||
**Почему.** Миграция и код, читающий схему, — одно изменение: колонка
|
||||
**ПОЧЕМУ.** Миграция и код, читающий схему, — одно изменение: колонка
|
||||
появляется вместе с полем структуры и запросом. Лежащие в другом конце
|
||||
дерева миграции выпадают из поля зрения при правке store, и уезжает либо
|
||||
код без миграции, либо миграция без кода; расходятся они на сервере, где
|
||||
@@ -52,7 +52,7 @@ goose.
|
||||
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
|
||||
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
|
||||
|
||||
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
|
||||
**ПОЧЕМУ.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
|
||||
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
|
||||
ошибиться, не добавляя ничего к результату.
|
||||
|
||||
@@ -68,7 +68,7 @@ goose.
|
||||
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
|
||||
ошибка исправляется новой миграцией вперёд.
|
||||
|
||||
**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует
|
||||
**ПОЧЕМУ.** Down на сервере не возвращает прежнее состояние, а имитирует
|
||||
его: колонка, которую убрал up, восстанавливается пустой, а строки,
|
||||
записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом
|
||||
происходит молча — миграция отчитывается об успехе. Исправление, приехавшее
|
||||
@@ -84,7 +84,7 @@ goose.
|
||||
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
|
||||
| MIGR-5.2 | необратимо преобразует данные | не пишется |
|
||||
|
||||
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
|
||||
**ПОЧЕМУ.** Down — инструмент разработки, где ветку переключают туда-сюда,
|
||||
и именно там он обязан действительно обращать up. Имитация опаснее
|
||||
отсутствия: разработчик применяет её, получает схему прежней формы и
|
||||
продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий
|
||||
@@ -96,7 +96,7 @@ down останавливает сразу и заставляет пересо
|
||||
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
|
||||
изменением.
|
||||
|
||||
**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл.
|
||||
**ПОЧЕМУ.** Диаграмму читают вместо DDL — в этом весь её смысл.
|
||||
Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить
|
||||
это можно, только сверив её с миграциями, то есть проделав работу, которую
|
||||
диаграмма экономит. Отложенное обновление не делается: изменение уже
|
||||
@@ -112,7 +112,7 @@ down останавливает сразу и заставляет пересо
|
||||
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
|
||||
без `CHECK`-ограничения на список значений.
|
||||
|
||||
**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
|
||||
**ПОЧЕМУ.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
|
||||
версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))`
|
||||
превращается из строки в коде в пересоздание таблицы по 12-шаговой
|
||||
процедуре, с копированием данных и восстановлением внешних ключей.
|
||||
@@ -128,7 +128,7 @@ down останавливает сразу и заставляет пересо
|
||||
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
|
||||
пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной.
|
||||
|
||||
**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает
|
||||
**ПОЧЕМУ.** Типа даты в SQLite нет, поэтому единственное, что делает
|
||||
значения сравнимыми, — договорённость о формате; сам формат выбран не
|
||||
здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется
|
||||
лексикографически в том же порядке, что и
|
||||
@@ -141,7 +141,7 @@ down останавливает сразу и заставляет пересо
|
||||
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
|
||||
на уровне схемы.
|
||||
|
||||
**Почему.** Время ставит приложение, и умолчание в схеме заводит второй
|
||||
**ПОЧЕМУ.** Время ставит приложение, и умолчание в схеме заводит второй
|
||||
источник этого значения: пропущенное приложением поле не падает, а тихо
|
||||
получает время сервера базы — расхождение обнаруживается по данным, а не
|
||||
по ошибке.
|
||||
@@ -154,7 +154,7 @@ down останавливает сразу и заставляет пересо
|
||||
|
||||
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
|
||||
|
||||
**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
|
||||
**ПОЧЕМУ.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
|
||||
колонку удерживает только договорённость о представлении. Цена ошибки
|
||||
здесь несимметрична: строка `'true'` в булевом контексте приводится к
|
||||
**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
|
||||
@@ -166,7 +166,7 @@ down останавливает сразу и заставляет пересо
|
||||
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
|
||||
приложения.
|
||||
|
||||
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение,
|
||||
**ПОЧЕМУ.** Здесь конвенция схемы ничего не решает — она реализует решение,
|
||||
принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там
|
||||
ветвление или условие значило бы завести второй источник правды, и соседние
|
||||
таблицы разъехались бы по разным ответам на один вопрос.
|
||||
@@ -179,7 +179,7 @@ down останавливает сразу и заставляет пересо
|
||||
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
|
||||
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
|
||||
|
||||
**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
|
||||
**ПОЧЕМУ.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
|
||||
удаления последней строки номер переиспользуется. Протухшая ссылка на
|
||||
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
|
||||
наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
|
||||
|
||||
@@ -8,9 +8,9 @@ prefix: GERR
|
||||
**логировать** — в конвенции `logging` (коротко: лог один раз на доменной
|
||||
границе).
|
||||
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||||
только тогда, когда написаны заглавными.
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 —
|
||||
тогда и только тогда, когда написаны заглавными.
|
||||
|
||||
Две границы, о которых говорят правила ниже:
|
||||
|
||||
@@ -30,7 +30,7 @@ prefix: GERR
|
||||
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
|
||||
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
|
||||
|
||||
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
||||
**ПОЧЕМУ.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
||||
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
|
||||
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
|
||||
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
|
||||
@@ -45,7 +45,7 @@ prefix: GERR
|
||||
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
|
||||
кодовой базы ради конкретной отладки.
|
||||
|
||||
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий
|
||||
**ПОЧЕМУ.** В коде появляются два способа устроить ошибку, и вызывающий
|
||||
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
|
||||
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
|
||||
локально, перестаёт накапливаться — а накопление и есть единственный
|
||||
@@ -56,7 +56,7 @@ prefix: GERR
|
||||
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
|
||||
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
|
||||
|
||||
**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
|
||||
**ПОЧЕМУ.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
|
||||
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
|
||||
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
|
||||
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
|
||||
@@ -72,7 +72,7 @@ prefix: GERR
|
||||
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
||||
| GERR-4.2 | причину сознательно не раскрываем | `%v` |
|
||||
|
||||
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
||||
**ПОЧЕМУ.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
||||
становится частью API» — относится к библиотекам с внешними потребителями.
|
||||
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
|
||||
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
|
||||
@@ -86,7 +86,7 @@ prefix: GERR
|
||||
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
|
||||
ошибку наружу.
|
||||
|
||||
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
|
||||
**ПОЧЕМУ.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
|
||||
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
|
||||
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
|
||||
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
|
||||
@@ -96,7 +96,7 @@ prefix: GERR
|
||||
|
||||
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
|
||||
|
||||
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка
|
||||
**ПОЧЕМУ.** Цепочка склеивается в одну строку через `": "`, и обёртка
|
||||
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
|
||||
на середине. Слова «failed» и «error» не несут информации: то, что перед
|
||||
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
|
||||
@@ -106,7 +106,7 @@ prefix: GERR
|
||||
|
||||
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
|
||||
|
||||
**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
|
||||
**ПОЧЕМУ.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
|
||||
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
|
||||
бы занять единственный полезный здесь факт — имя операции.
|
||||
|
||||
@@ -115,7 +115,7 @@ failed» не сужает ничего и при этом занимает в
|
||||
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
|
||||
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
|
||||
|
||||
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
|
||||
**ПОЧЕМУ.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
|
||||
же событие названо дважды. Читателю приходится проверять, не два ли это
|
||||
разных места в коде, — то есть заикание не просто бесполезно, оно стоит
|
||||
времени при каждом чтении лога.
|
||||
@@ -132,7 +132,7 @@ failed» не сужает ничего и при этом занимает в
|
||||
возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для
|
||||
HTTP-клиентов, файловой системы, внешних SDK.
|
||||
|
||||
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв
|
||||
**ПОЧЕМУ.** Иначе тип зависимости становится частью контракта всех слоёв
|
||||
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
|
||||
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
|
||||
адаптер, а все ветвления в приложении — притом что снаружи адаптера
|
||||
@@ -148,7 +148,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
||||
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
||||
|
||||
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
|
||||
**ПОЧЕМУ.** Sentinel — одно значение; сравнение с ним не зависит от
|
||||
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
|
||||
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
|
||||
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
|
||||
@@ -159,7 +159,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
|
||||
|
||||
**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают
|
||||
**ПОЧЕМУ.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают
|
||||
переписывать его свободно. Правка формулировки в нижнем слое молча ломает
|
||||
ветвление наверху, и компилятор этого не видит. Это то же самое, что
|
||||
публичный API из строки лога.
|
||||
@@ -175,7 +175,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
|
||||
— конвенция `logging`.
|
||||
|
||||
**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и
|
||||
**ПОЧЕМУ.** Цепочка — единственный носитель диагностики (GERR-1), и
|
||||
единственный канал, где её можно показать целиком, — тот, который видит
|
||||
владелец. Не записанная там, она не сохранится нигде: наружу идёт
|
||||
нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
|
||||
@@ -185,7 +185,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
|
||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
|
||||
|
||||
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
||||
**ПОЧЕМУ.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
||||
— у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
|
||||
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
|
||||
причём раскрывают именно в момент, когда что-то пошло не так.
|
||||
@@ -196,7 +196,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
|
||||
ошибка».
|
||||
|
||||
**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его
|
||||
**ПОЧЕМУ.** GERR-13 забирает у пользователя всю фактуру; без ключа его
|
||||
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
|
||||
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
|
||||
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
|
||||
@@ -208,7 +208,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
задаётся один раз; транспорт без статусов (бот) берёт из него только
|
||||
сообщение.
|
||||
|
||||
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
||||
**ПОЧЕМУ.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
||||
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
|
||||
важнее: единственная точка — это место, куда механически дописывается новая
|
||||
ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
||||
@@ -219,7 +219,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
|
||||
добавляется в маппинг (GERR-15) тем же изменением.
|
||||
|
||||
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
||||
**ПОЧЕМУ.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
||||
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
|
||||
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
|
||||
шуметь в логе ровно там, где по нему ищут настоящие поломки.
|
||||
@@ -230,7 +230,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
|
||||
признаком того, что маппинг её не знает.
|
||||
|
||||
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
|
||||
**ПОЧЕМУ.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
|
||||
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
|
||||
`ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус
|
||||
тоже не выбирается: известное пользовательское состояние лежало бы в
|
||||
@@ -256,7 +256,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
Появился второй зритель или публичный доступ к экрану состояния —
|
||||
поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
|
||||
|
||||
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
||||
**ПОЧЕМУ.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
||||
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
|
||||
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
|
||||
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
|
||||
@@ -269,7 +269,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
|
||||
ни в персистентную диагностику; источник вычищается на границе клиента.
|
||||
|
||||
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в
|
||||
**ПОЧЕМУ.** Запрет абсолютен, потому что персистентная диагностика живёт в
|
||||
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
|
||||
самого секрета. Вычистка на границе клиента — единственное место, где ещё
|
||||
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
|
||||
@@ -280,7 +280,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
|
||||
показывают пользователю.
|
||||
|
||||
**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
|
||||
**ПОЧЕМУ.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
|
||||
разные поля. Одно поле на оба назначения означает, что при первом же показе
|
||||
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
|
||||
одно.
|
||||
@@ -292,7 +292,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
|
||||
ошибка инициализации, из которой нельзя стартовать.
|
||||
|
||||
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте
|
||||
**ПОЧЕМУ.** Паника не оставляет вызывающему выбора: обработать её на месте
|
||||
нельзя, можно только уронить единицу обработки. Это верный ответ, когда
|
||||
состояние процесса перестало описываться кодом: работа с нарушенным
|
||||
инвариантом опаснее падения, а сервис, стартовавший без обязательной
|
||||
@@ -303,7 +303,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
|
||||
плохой ввод, отсутствующая запись возвращаются как `error`.
|
||||
|
||||
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
||||
**ПОЧЕМУ.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
||||
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
|
||||
его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
|
||||
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
|
||||
@@ -318,7 +318,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
||||
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
||||
|
||||
**Почему.** `recover` работает только в той горутине, где случилась паника,
|
||||
**ПОЧЕМУ.** `recover` работает только в той горутине, где случилась паника,
|
||||
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
|
||||
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
|
||||
неожиданным полем гасят весь сервис, включая части, к этой ошибке
|
||||
@@ -330,7 +330,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
|
||||
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
|
||||
|
||||
**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной
|
||||
**ПОЧЕМУ.** Это единственное место, где стек нужен (GERR-1): у восстановленной
|
||||
паники цепочки `%w` нет вовсе. «index out of range» без стека не
|
||||
диагностируется в принципе — сообщение не называет ни файла, ни операции,
|
||||
по нему нельзя сказать даже, в каком пакете упало.
|
||||
@@ -347,7 +347,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
|
||||
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
|
||||
|
||||
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о
|
||||
**ПОЧЕМУ.** Паника внутри обработки одного элемента почти всегда говорит о
|
||||
баге в работе с данными этого элемента, а не о порче общего состояния, —
|
||||
останавливать всё остальное не за что. Довод «let it crash» здесь работает
|
||||
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
|
||||
@@ -378,7 +378,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
|
||||
разом; проверка собранного — по-прежнему через `errors.Is`.
|
||||
|
||||
**Почему.** Возврат первой ошибки превращает починку конфига в серию
|
||||
**ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
|
||||
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
|
||||
тот же список, но убивает ветвление: `errors.Is` по такому результату не
|
||||
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
|
||||
|
||||
@@ -9,9 +9,9 @@ extends: arch/time.md
|
||||
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
||||
функциональности, живут в спеках.
|
||||
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||||
только тогда, когда написаны заглавными.
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 —
|
||||
тогда и только тогда, когда написаны заглавными.
|
||||
|
||||
Лог читают инструментами, а не глазами: повседневно — `jq`
|
||||
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
|
||||
@@ -29,7 +29,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
|
||||
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
|
||||
проде.
|
||||
|
||||
**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена
|
||||
**ПОЧЕМУ.** Довод не в том, что текстовый вывод «расходит поля»: смена
|
||||
хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым
|
||||
dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и
|
||||
поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное
|
||||
@@ -40,7 +40,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
|
||||
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
|
||||
|
||||
**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная
|
||||
**ПОЧЕМУ.** Фильтрация и агрегация работают по ключам; величина, вклеенная
|
||||
в текст, достаётся только регуляркой, а регулярка ломается при первой же
|
||||
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
|
||||
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
|
||||
@@ -50,7 +50,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
|
||||
(см. конвенцию `time`).
|
||||
|
||||
**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время
|
||||
**ПОЧЕМУ.** По умолчанию UTC не получится: встроенные хендлеры пишут время
|
||||
в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного
|
||||
процесса до и после смены TZ (или записи рядом с данными из БД) перестают
|
||||
складываться в одну хронологию, причём сдвиг на целые часы глазом не виден
|
||||
@@ -68,7 +68,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
|
||||
`log.Info("download accepted", "download_id", id)`.
|
||||
|
||||
**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция
|
||||
**ПОЧЕМУ.** `msg` — то, по чему записи группируют и считают. Интерполяция
|
||||
превращает одну категорию в множество уникальных строк, и вопрос «сколько
|
||||
раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы
|
||||
одна категория не двоилась на варианты, различающиеся только заглавной
|
||||
@@ -79,7 +79,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
|
||||
отдельное поле.
|
||||
|
||||
**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
|
||||
**ПОЧЕМУ.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
|
||||
фильтр по подсистеме становится сопоставлением с началом строки вместо
|
||||
сравнения значения поля. Заодно это второй способ записать одно и то же:
|
||||
категория дробится на варианты с префиксом и без, а совпадать они обязаны
|
||||
@@ -90,7 +90,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
|
||||
состояние и по какой причине — данные, а не текст.
|
||||
|
||||
**Почему.** С отдельной категорией на каждый переход жизненный цикл
|
||||
**ПОЧЕМУ.** С отдельной категорией на каждый переход жизненный цикл
|
||||
сущности собирается перечислением всех известных `msg` — и переход,
|
||||
добавленный в код позже, в это перечисление не попадёт: выборка тихо
|
||||
останется неполной. Единая категория даёт весь цикл одним фильтром и не
|
||||
@@ -101,7 +101,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
|
||||
запись самого перехода.
|
||||
|
||||
**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
|
||||
**ПОЧЕМУ.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
|
||||
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
|
||||
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
|
||||
что невозможно.
|
||||
@@ -120,7 +120,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
|
||||
| SLOG-8.4 | `ERROR` | владельцу, в разбор |
|
||||
|
||||
**Почему.** Адресат — единственный признак, по которому разные авторы в
|
||||
**ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в
|
||||
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
|
||||
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
|
||||
базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
|
||||
@@ -131,7 +131,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
|
||||
везде одинаково серьёзен.
|
||||
|
||||
**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
|
||||
**ПОЧЕМУ.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
|
||||
в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить
|
||||
происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть
|
||||
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
|
||||
@@ -141,7 +141,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
|
||||
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
|
||||
|
||||
**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится
|
||||
**ПОЧЕМУ.** `WARN` разбирают вручную и целиком. Как только в нём заводится
|
||||
«ничего страшного», его перестают читать — и вместе с шумом теряется то
|
||||
единственное, ради чего уровень существует: предупреждение, на которое ещё
|
||||
есть время отреагировать.
|
||||
@@ -155,7 +155,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
| SLOG-11.1 | по реальному действию или изменению | `INFO` |
|
||||
| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
|
||||
|
||||
**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
|
||||
**ПОЧЕМУ.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
|
||||
определяется долей записей, за которыми что-то стоит. Периодическая
|
||||
операция даёт ровный поток при нулевой информации, в котором настоящие
|
||||
события тонут количественно: их не отфильтровать, потому что фильтровать
|
||||
@@ -166,7 +166,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
**ДОЛЖЕН.** Фатальный сбой на старте пишется как `ERROR` и завершает процесс
|
||||
ненулевым кодом.
|
||||
|
||||
**Почему.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень
|
||||
**ПОЧЕМУ.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень
|
||||
выражает не уровень записи, а сам факт завершения. Супервизор (docker,
|
||||
journald, systemd) отличает падение от штатной остановки по коду возврата, а
|
||||
не по уровню последней записи. Процесс, который написал `ERROR` и продолжил
|
||||
@@ -180,7 +180,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
|
||||
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
|
||||
|
||||
**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той
|
||||
**ПОЧЕМУ.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той
|
||||
же величины делает любую выборку по ней молча неполной: фильтр отработает,
|
||||
часть записей в него не попадёт, и заметить это можно, только заранее зная,
|
||||
что они должны были быть.
|
||||
@@ -194,7 +194,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
|
||||
| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
|
||||
|
||||
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
|
||||
**ПОЧЕМУ.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
|
||||
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
|
||||
запрос «все внешние вызовы» пишется без перечисления имён. Заимствование
|
||||
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
|
||||
@@ -205,7 +205,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
|
||||
имени, а не уровень вложенности.
|
||||
|
||||
**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
|
||||
**ПОЧЕМУ.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
|
||||
записи независимо от её категории. Вложенность требует знать глубину
|
||||
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
|
||||
весь лог, распадаясь на запрос под каждую форму записи.
|
||||
@@ -221,7 +221,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
| SLOG-16.3 | запись об ошибке | `error` |
|
||||
| SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
|
||||
|
||||
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает
|
||||
**ПОЧЕМУ.** Набор задан не «на всякий случай»: без него запись не отвечает
|
||||
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
|
||||
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
|
||||
баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
|
||||
@@ -233,7 +233,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
|
||||
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
|
||||
|
||||
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке
|
||||
**ПОЧЕМУ.** Такое поле не несёт информации, но стоит места в каждой строке
|
||||
и внимания при чтении. Критерий один на все поля словаря — им же решается,
|
||||
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
|
||||
названо явно, поэтому правило отпадёт вместе со своей причиной: с
|
||||
@@ -248,7 +248,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
|
||||
конвенция `db-identifiers`, если взята.)
|
||||
|
||||
**Почему.** Идентификатор сущности уже существует, стабилен между
|
||||
**ПОЧЕМУ.** Идентификатор сущности уже существует, стабилен между
|
||||
процессами и во времени — по нему собираются записи не одного прохода, а
|
||||
всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое
|
||||
только внутри одной операции, то есть дублирует ключ и добавляет второй
|
||||
@@ -259,7 +259,7 @@ journald, systemd) отличает падение от штатной оста
|
||||
|
||||
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
|
||||
|
||||
**Почему.** Принадлежность записи восстанавливается только в момент
|
||||
**ПОЧЕМУ.** Принадлежность записи восстанавливается только в момент
|
||||
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
|
||||
Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
|
||||
что идентификатор стоит везде, а не в удобных местах.
|
||||
@@ -279,7 +279,7 @@ log := log.With("download_id", id)
|
||||
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
||||
```
|
||||
|
||||
**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в
|
||||
**ПОЧЕМУ.** Ручное дописывание ключа пропускают не в основном сценарии, а в
|
||||
редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее
|
||||
всего. Логгер из контекста дописывает ключ сам, и запись без
|
||||
идентификатора становится невозможной, а не маловероятной.
|
||||
@@ -290,7 +290,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
|
||||
|
||||
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
|
||||
**ПОЧЕМУ.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
|
||||
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
|
||||
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
|
||||
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
|
||||
@@ -301,7 +301,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
|
||||
оборачивает (`%w`).
|
||||
|
||||
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
|
||||
**ПОЧЕМУ.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
|
||||
и количество `ERROR` перестаёт соответствовать количеству отказов — а
|
||||
считают именно его. Контекст при этом не теряется: он накапливается в
|
||||
цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
|
||||
@@ -310,7 +310,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
|
||||
|
||||
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
|
||||
**ПОЧЕМУ.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
|
||||
этим местом выбрана доменная граница, а не транспорт, потому что там
|
||||
известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
|
||||
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
|
||||
@@ -321,7 +321,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
|
||||
(статус, сообщение пользователю) и на этом останавливается.
|
||||
|
||||
**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
|
||||
**ПОЧЕМУ.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
|
||||
только формулировкой и читается как второй сбой. Когда транспортов над
|
||||
одним доменом несколько, дублирование ещё и множится, а расследование
|
||||
начинается с вопроса, один это инцидент или два.
|
||||
@@ -338,7 +338,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||
| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||
|
||||
**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
|
||||
**ПОЧЕМУ.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
|
||||
экране — владельцу разбирать нечего; целостность первичных данных отделяет
|
||||
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
|
||||
уровень для одного и того же отказа в зависимости от того, какой транспорт
|
||||
@@ -359,7 +359,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
|
||||
она же в авто-обработке — `WARN`.
|
||||
|
||||
**Почему.** В ручном действии человек видит причину на экране и сам решает,
|
||||
**ПОЧЕМУ.** В ручном действии человек видит причину на экране и сам решает,
|
||||
что делать дальше; запись нужна только для отладки. В автоматике не увидел
|
||||
никто, задача осталась недоведённой, и лог — единственное место, где это
|
||||
вообще проявится.
|
||||
@@ -369,7 +369,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
|
||||
уровень задаёт наличие штатного повтора, а не текст ошибки.
|
||||
|
||||
**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и
|
||||
**ПОЧЕМУ.** Одиночный промах тика транзиентен — следующий тик повторит, и
|
||||
вмешательство не требуется; `ERROR` на каждый такой промах обесценивает
|
||||
уровень, на который смотрят в первую очередь. Синхронная операция повтора
|
||||
не имеет: она провалилась целиком, результат никто не восстановит, и это
|
||||
@@ -381,7 +381,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
|
||||
|
||||
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
|
||||
**ПОЧЕМУ.** Это единственный способ отличить «у нас баг» от «зависимость
|
||||
легла»: на своей стороне видно лишь то, что операция не удалась.
|
||||
Выборочное логирование ломает и второе применение — доля неуспехов и
|
||||
распределение `duration_ms` считаются, только если знаменатель полный.
|
||||
@@ -397,7 +397,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
| SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
|
||||
| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
|
||||
|
||||
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
|
||||
**ПОЧЕМУ.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
|
||||
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
|
||||
уровень непригодным для главного вопроса «зависимость доступна?».
|
||||
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
|
||||
@@ -431,7 +431,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
||||
вызывающий.
|
||||
|
||||
**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
|
||||
**ПОЧЕМУ.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
|
||||
разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис
|
||||
недоступен» с «сервис ответил нам нет» — это разные инциденты с разной
|
||||
реакцией, и различает их как раз `ext`-уровень. Что 404 значит для
|
||||
@@ -444,7 +444,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
|
||||
|
||||
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
|
||||
**ПОЧЕМУ.** Это аудит обращений, а не отладка: запись отвечает на «кто и
|
||||
когда приходил», и ценность у неё одинаковая при любом коде ответа.
|
||||
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
|
||||
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
|
||||
@@ -454,7 +454,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
|
||||
|
||||
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
|
||||
**ПОЧЕМУ.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
|
||||
правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
|
||||
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
|
||||
сущности нет — связать его записи между собой больше нечем.
|
||||
@@ -463,7 +463,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
|
||||
|
||||
**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
|
||||
**ПОЧЕМУ.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
|
||||
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
|
||||
аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
|
||||
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
|
||||
@@ -477,7 +477,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
|
||||
в ссылках.
|
||||
|
||||
**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
|
||||
**ПОЧЕМУ.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
|
||||
и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован
|
||||
с момента записи, а не с момента, когда это заметили, и вычистить его задним
|
||||
числом из уже собранных копий нельзя.
|
||||
@@ -487,7 +487,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
|
||||
`DEBUG`, с вычисткой секретов и обрезкой по длине.
|
||||
|
||||
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
|
||||
**ПОЧЕМУ.** Содержимое пришло снаружи: размер не ограничен, состав
|
||||
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
|
||||
выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
|
||||
обрезка не даёт одной записи вытеснить весь остальной лог за период.
|
||||
@@ -496,7 +496,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
|
||||
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
|
||||
|
||||
**Почему.** Для отладки почти всегда достаточно ответа «значение было или
|
||||
**ПОЧЕМУ.** Для отладки почти всегда достаточно ответа «значение было или
|
||||
не было» — потеря полезности близка к нулю, а риск снимается целиком.
|
||||
Правило нужно потому, что решение принимается в момент написания строки,
|
||||
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
|
||||
@@ -507,7 +507,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
|
||||
обёртки — раньше трансляции в доменную (конвенция `errors`).
|
||||
|
||||
**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт
|
||||
**ПОЧЕМУ.** `*url.Error` встраивает полный URL запроса, а секрет живёт
|
||||
прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль
|
||||
из userinfo, остального не трогает, поэтому ошибка уносит секрет и в
|
||||
обёртку, и в лог целиком. Порядок — часть нормы: санитизация после
|
||||
@@ -521,7 +521,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
|
||||
способа нет.
|
||||
|
||||
**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
|
||||
**ПОЧЕМУ.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
|
||||
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
|
||||
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
|
||||
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
|
||||
@@ -533,7 +533,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
|
||||
не маршрутизируем.
|
||||
|
||||
**Почему.** Приложение, которое само решает, что куда писать, дублирует
|
||||
**ПОЧЕМУ.** Приложение, которое само решает, что куда писать, дублирует
|
||||
работу супервизора и расходится с ней при первой же смене окружения: срок
|
||||
хранения, сжатие и ротация оказываются настроены в двух местах и по-разному.
|
||||
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
|
||||
@@ -543,7 +543,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
|
||||
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
|
||||
|
||||
**Почему.** Уровень — единственный регулятор объёма, доступный без
|
||||
**ПОЧЕМУ.** Уровень — единственный регулятор объёма, доступный без
|
||||
пересборки; если `DEBUG` в проде включается только правкой кода, его не
|
||||
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
|
||||
что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
|
||||
|
||||
+16
-16
@@ -8,9 +8,9 @@ extends: arch/time.md
|
||||
Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас»,
|
||||
в каком виде время попадает в базу и в логи, что делать с зонами.
|
||||
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка
|
||||
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 — тогда и
|
||||
только тогда, когда написаны заглавными.
|
||||
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
|
||||
ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2 —
|
||||
тогда и только тогда, когда написаны заглавными.
|
||||
|
||||
## Правила
|
||||
|
||||
@@ -19,7 +19,7 @@ extends: arch/time.md
|
||||
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
|
||||
`time.Now().UTC()`, а не из `time.Now()` по коду.
|
||||
|
||||
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна
|
||||
**ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат: ни одна
|
||||
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
|
||||
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
|
||||
когда-то локальной, и восстановить смещение задним числом не по чему.
|
||||
@@ -33,7 +33,7 @@ extends: arch/time.md
|
||||
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
|
||||
получить строку времени и прочитать её обратно.
|
||||
|
||||
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
|
||||
**ПОЧЕМУ.** Layout, набранный по месту вызова, превращает формат хранения в
|
||||
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
|
||||
взаимная обратимость записи и чтения держатся ровно до первого второго
|
||||
layout — а расхождение проявится не на записи, а при сравнении значений,
|
||||
@@ -49,7 +49,7 @@ layout — а расхождение проявится не на записи,
|
||||
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
||||
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
|
||||
|
||||
**Почему.** GTIM-1 без механической проверки держится на внимании, а
|
||||
**ПОЧЕМУ.** GTIM-1 без механической проверки держится на внимании, а
|
||||
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
|
||||
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
|
||||
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
|
||||
@@ -63,7 +63,7 @@ layout — а расхождение проявится не на записи,
|
||||
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
|
||||
конфигурации линтера для него не заводятся.
|
||||
|
||||
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует
|
||||
**ПОЧЕМУ.** Запись в конфиге — второй реестр тех же двух мест: она адресует
|
||||
исключение путём к файлу, отвязывается при переносе кода и продолжает
|
||||
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
|
||||
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
|
||||
@@ -77,7 +77,7 @@ layout — а расхождение проявится не на записи,
|
||||
|
||||
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
|
||||
|
||||
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
|
||||
**ПОЧЕМУ.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
|
||||
лексикографический порядок совпадает с хронологическим только при
|
||||
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
|
||||
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
|
||||
@@ -91,7 +91,7 @@ layout — а расхождение проявится не на записи,
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
|
||||
|
||||
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
||||
**ПОЧЕМУ.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
||||
от значения: соседние записи получают разную ширину, и свойство, на котором
|
||||
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
|
||||
проходит — отказывает только порядок.
|
||||
@@ -102,7 +102,7 @@ layout — а расхождение проявится не на записи,
|
||||
к каноническому виду явно, а не считается каноническим по факту успешного
|
||||
разбора.
|
||||
|
||||
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
||||
**ПОЧЕМУ.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
||||
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
|
||||
**писатель**, а не читатель; пока писатель один, этого достаточно, но
|
||||
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
|
||||
@@ -114,7 +114,7 @@ Go-механика, из-за которой его легко нарушить
|
||||
|
||||
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
|
||||
|
||||
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
||||
**ПОЧЕМУ.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
||||
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
|
||||
собственным layout, который меняется вместе с версией драйвера, а не вместе
|
||||
с конвенцией.
|
||||
@@ -132,7 +132,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
}
|
||||
```
|
||||
|
||||
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
|
||||
**ПОЧЕМУ.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
|
||||
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
|
||||
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
|
||||
неверная зона выглядит как совершенно валидное время, а записи из разных
|
||||
@@ -143,7 +143,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
|
||||
приводится к секундной точности GTIM-4.
|
||||
|
||||
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
|
||||
**ПОЧЕМУ.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
|
||||
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
|
||||
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
|
||||
нарушено. Общее у лога и базы одно — зона (GTIM-8).
|
||||
@@ -153,7 +153,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с
|
||||
локальным `//nolint`.
|
||||
|
||||
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это
|
||||
**ПОЧЕМУ.** `store.Now()` приводит время к UTC через `.UTC()`, а это
|
||||
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
|
||||
меткам, зависит от подводки часов: перевод назад даёт отрицательную
|
||||
длительность, скачок вперёд — выброс в измерениях, и оба случая
|
||||
@@ -164,7 +164,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
|
||||
**ДОЛЖЕН.** База зон вшивается в бинарь.
|
||||
|
||||
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
|
||||
**ПОЧЕМУ.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
|
||||
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
|
||||
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
|
||||
`main` держит это решение в одном видимом месте, а не в случайном пакете,
|
||||
@@ -175,7 +175,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
|
||||
представления, но не в хранимых значениях и не в вычислениях.
|
||||
|
||||
**Почему.** Зона отображения — настройка, и её меняют. Протекая в
|
||||
**ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в
|
||||
вычисления и хранение, она делает уже записанные данные зависимыми от
|
||||
текущего значения настройки: смена зоны задним числом сдвигает границы
|
||||
суток у того, что давно посчитано и сохранено.
|
||||
|
||||
Reference in New Issue
Block a user