заведён реестр префиксов, правила канона перенумерованы
- идентификатор правила теперь `<ПРЕФИКС>-<номер>` вместо `R<номер>`: префикс уникален по всему канону, поэтому ссылка больше не требует пути к файлу и не зависит от того, на какой оси файл лежит - префикс выбирается под файл, а не выводится по формуле, и хранится в conventions/prefixes.toml вместе с выбывшими; номера сохранены один в один вместе с дырами
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
---
|
||||
prefix: GCFG
|
||||
extends: arch/config.md
|
||||
---
|
||||
|
||||
@@ -13,7 +14,7 @@ extends: arch/config.md
|
||||
|
||||
## Правила
|
||||
|
||||
### R1. Формат конфигурации — TOML
|
||||
### GCFG-1. Формат конфигурации — TOML
|
||||
|
||||
**ДОЛЖЕН.** Конфиг — файл TOML.
|
||||
|
||||
@@ -25,7 +26,7 @@ extends: arch/config.md
|
||||
поправленный руками на сервере, ломается заметно, а не меняет вложенность
|
||||
молча.
|
||||
|
||||
### R2. Разбор и валидация — целиком в `internal/config`
|
||||
### GCFG-2. Разбор и валидация — целиком в `internal/config`
|
||||
|
||||
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
|
||||
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
|
||||
@@ -34,11 +35,11 @@ extends: arch/config.md
|
||||
после — уже нет, и это единственная граница, на которой такое утверждение
|
||||
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
|
||||
«проверено ли это поле» только чтением всех вызывающих, часть полей
|
||||
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ
|
||||
неизбежно окажется непроверенной, и fail-fast (GCFG-15) выродится в отказ
|
||||
посреди работы. Экспортированный разбор вдобавок даёт второй способ
|
||||
получить конфиг — мимо умолчаний (R5).
|
||||
получить конфиг — мимо умолчаний (GCFG-5).
|
||||
|
||||
### R3. Весь конфиг — одна корневая структура
|
||||
### GCFG-3. Весь конфиг — одна корневая структура
|
||||
|
||||
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
|
||||
под-структур по секциям.
|
||||
@@ -50,7 +51,7 @@ extends: arch/config.md
|
||||
(включена интеграция — заданы все её поля) при этом перестают быть
|
||||
проверяемыми в одном месте.
|
||||
|
||||
### R4. Под-структуры названы по секциям файла
|
||||
### GCFG-4. Под-структуры названы по секциям файла
|
||||
|
||||
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
|
||||
|
||||
@@ -60,7 +61,7 @@ extends: arch/config.md
|
||||
восстанавливается чтением тегов, и проделывать это приходится для каждой
|
||||
секции заново.
|
||||
|
||||
### R5. Умолчания задаёт `Default()`
|
||||
### GCFG-5. Умолчания задаёт `Default()`
|
||||
|
||||
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
|
||||
накладывается поверх.
|
||||
@@ -72,7 +73,7 @@ extends: arch/config.md
|
||||
подставляют разное. `Default()` — единственное место, откуда список
|
||||
умолчаний читается разом и переносится в образец.
|
||||
|
||||
### R6. Имя файла фиксировано, путь переопределяется флагом
|
||||
### GCFG-6. Имя файла фиксировано, путь переопределяется флагом
|
||||
|
||||
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
|
||||
путь переопределяет флаг `--config=path`, образец рядом —
|
||||
@@ -85,7 +86,7 @@ extends: arch/config.md
|
||||
`config.example.toml` вдобавок делает расхождение образца с реальным
|
||||
конфигом видимым обычным `diff`, а не вычиткой.
|
||||
|
||||
### R7. Длительности — собственный тип с `UnmarshalText`
|
||||
### GCFG-7. Длительности — собственный тип с `UnmarshalText`
|
||||
|
||||
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
|
||||
`time.Duration`:
|
||||
@@ -105,11 +106,11 @@ func (d Duration) Std() time.Duration { … }
|
||||
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
|
||||
|
||||
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
|
||||
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности
|
||||
раньше, чем начинает работать сбор проблем (GCFG-12). Ошибка в длительности
|
||||
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
|
||||
показываются.
|
||||
|
||||
### R8. Приложение не читает окружение
|
||||
### GCFG-8. Приложение не читает окружение
|
||||
|
||||
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
|
||||
|
||||
@@ -120,9 +121,9 @@ func (d Duration) Std() time.Duration { … }
|
||||
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
|
||||
выставлена.
|
||||
|
||||
### R9. Проверка запрета покрывает всю семью `os`
|
||||
### GCFG-9. Проверка запрета покрывает всю семью `os`
|
||||
|
||||
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только
|
||||
**ДОЛЖЕН.** Механическая проверка GCFG-8 (`forbidigo`) ловит не только
|
||||
`os.Getenv`:
|
||||
|
||||
```
|
||||
@@ -137,20 +138,20 @@ func (d Duration) Std() time.Duration { … }
|
||||
Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят
|
||||
`syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`.
|
||||
Проверка закрывает обычные способы — те, которыми окружение читают не
|
||||
нарочно; сознательный обход она не ловит, и считать R8 полностью
|
||||
нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью
|
||||
механизированным нельзя.
|
||||
|
||||
### R10. За границей приложения запрет не действует
|
||||
### GCFG-10. За границей приложения запрет не действует
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
|
||||
приложение:
|
||||
|
||||
| № | Кто читает | Вердикт |
|
||||
|---|---|---|
|
||||
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
|
||||
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
|
||||
| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
|
||||
| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
|
||||
|
||||
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не
|
||||
**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
|
||||
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
|
||||
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
|
||||
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
|
||||
@@ -158,19 +159,19 @@ func (d Duration) Std() time.Duration { … }
|
||||
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
|
||||
руками, вместе с ними проходят и нелегальные.
|
||||
|
||||
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY`
|
||||
### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY`
|
||||
|
||||
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
|
||||
|
||||
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
|
||||
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
|
||||
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
|
||||
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут
|
||||
тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
|
||||
исходящих запросов отличается от машины к машине без единого следа в
|
||||
конфиге и в образце, а расследование начинается с вопроса «почему на
|
||||
сервере ходит не так, как локально».
|
||||
|
||||
### R12. Проблемы конфига собираются `errors.Join`
|
||||
### GCFG-12. Проблемы конфига собираются `errors.Join`
|
||||
|
||||
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
|
||||
ошибка, собранная `errors.Join`.
|
||||
@@ -181,7 +182,7 @@ func (d Duration) Std() time.Duration { … }
|
||||
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
|
||||
вложенной проблеме.
|
||||
|
||||
### R13. Имя зоны проверяется `time.LoadLocation`
|
||||
### GCFG-13. Имя зоны проверяется `time.LoadLocation`
|
||||
|
||||
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
|
||||
|
||||
@@ -189,9 +190,9 @@ func (d Duration) Std() time.Duration { … }
|
||||
тогда, когда база зон его знает, и никакая проверка формата не отличит
|
||||
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
|
||||
доживает до первого форматирования времени — то есть до рантайма, мимо
|
||||
fail-fast (R15).
|
||||
fail-fast (GCFG-15).
|
||||
|
||||
### R14. `time/tzdata` импортируется в `main`
|
||||
### GCFG-14. `time/tzdata` импортируется в `main`
|
||||
|
||||
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
|
||||
пакете.
|
||||
@@ -199,11 +200,11 @@ fail-fast (R15).
|
||||
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
|
||||
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
|
||||
полагаться на системную» принадлежит собираемой программе. Со встроенной
|
||||
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без
|
||||
базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
|
||||
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
|
||||
zoneinfo, а сообщение указывает не на ту причину.
|
||||
|
||||
### R15. Невалидный конфиг — `ERROR` и выход из `main`
|
||||
### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main`
|
||||
|
||||
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
|
||||
старта серверов и воркеров.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
prefix: GKEY
|
||||
extends: arch/db-identifiers.md
|
||||
---
|
||||
|
||||
@@ -7,18 +8,18 @@ extends: arch/db-identifiers.md
|
||||
Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи —
|
||||
`LANGUAGE.md`.
|
||||
|
||||
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он
|
||||
Единая точка из `KEYS-3` — пакет `internal/ident`: он
|
||||
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
|
||||
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
|
||||
|
||||
## Правила
|
||||
|
||||
### R1. Генерация и разбор — только через `internal/ident`
|
||||
### GKEY-1. Генерация и разбор — только через `internal/ident`
|
||||
|
||||
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
|
||||
`internal/ident`; других генераторов и парсеров id в коде нет.
|
||||
|
||||
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов
|
||||
**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов
|
||||
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
|
||||
выглядит нарушением: значение получается валидное, просто мимо нормализации
|
||||
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
|
||||
@@ -26,12 +27,12 @@ ULID-библиотеки — одна строка, доступная из л
|
||||
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
|
||||
перестанет находить существующую запись.
|
||||
|
||||
### R2. Первичный ключ генерируется в `Create`-методах store
|
||||
### GKEY-2. Первичный ключ генерируется в `Create`-методах store
|
||||
|
||||
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
|
||||
внутри `Create`-метода слоя store.
|
||||
|
||||
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было
|
||||
**Почему.** `KEYS-2` требует, чтобы значение было
|
||||
известно до вставки, но не говорит, кто его присваивает. Store — последний
|
||||
слой, через который проходят все пути создания строки, включая импорт,
|
||||
фоновые задания и тесты. Генерация выше по стеку делает присвоение
|
||||
@@ -39,18 +40,18 @@ ULID-библиотеки — одна строка, доступная из л
|
||||
строку в колонку ключа: для строкового PK это валидное значение, база его
|
||||
не отклонит, и дефект обнаружится на второй такой вставке.
|
||||
|
||||
### R3. Прочие идентификаторы генерируются в точке начала операции
|
||||
### GKEY-3. Прочие идентификаторы генерируются в точке начала операции
|
||||
|
||||
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
|
||||
вызовом `ident.NewID()` там, где операция начинается.
|
||||
|
||||
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) —
|
||||
**Почему.** Смысл такого идентификатора (`KEYS-7`) —
|
||||
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
|
||||
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
|
||||
когда операция упала до того, как что-либо записала: без общего ключа эти
|
||||
записи из лога не собираются вообще.
|
||||
|
||||
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
|
||||
### GKEY-4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
|
||||
|
||||
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
|
||||
Go-миграции, порождаются с историческим временем строки, а не с текущим.
|
||||
@@ -62,18 +63,18 @@ Go-миграции, порождаются с историческим врем
|
||||
Исправить это потом нельзя: исходное время в идентификаторе не
|
||||
восстановить.
|
||||
|
||||
### R5. Разбор — на входных границах, до обращения к store
|
||||
### GKEY-5. Разбор — на входных границах, до обращения к store
|
||||
|
||||
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
|
||||
callback'а бота — раньше, чем идентификатор попадёт в store.
|
||||
|
||||
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана
|
||||
**Почему.** Реализация `KEYS-5`. Граница выбрана
|
||||
транспортная, потому что только на ней известен источник значения, от
|
||||
которого зависит реакция (R8): store видит одинаковую строку независимо от
|
||||
которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
|
||||
того, пришла она из URL или из собственной формы, и ответить по-разному
|
||||
оттуда уже невозможно.
|
||||
|
||||
### R6. Id в структурах — обычный `string`
|
||||
### GKEY-6. Id в структурах — обычный `string`
|
||||
|
||||
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
|
||||
`string`.
|
||||
@@ -84,35 +85,35 @@ callback'а бота — раньше, чем идентификатор поп
|
||||
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
|
||||
json и шаблонами, то есть даёт цену без выгоды.
|
||||
|
||||
### R7. Отдельный тип — когда появляется вторая семья идентификаторов
|
||||
### GKEY-7. Отдельный тип — когда появляется вторая семья идентификаторов
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
|
||||
можно перепутать, для них заводятся различимые типы.
|
||||
|
||||
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на
|
||||
**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
|
||||
типизацию навсегда. Условие названо ровно то, при котором тип начинает
|
||||
работать: пока все идентификаторы — `string`, подстановка одного вида
|
||||
вместо другого компилируется и обнаруживается только на данных.
|
||||
|
||||
### R8. Реакция на невалидный id зависит от источника
|
||||
### GKEY-8. Реакция на невалидный id зависит от источника
|
||||
|
||||
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
|
||||
значение:
|
||||
|
||||
| № | Источник | Ответ |
|
||||
|---|---|---|
|
||||
| R8.1 | путь или query URL | 404 без обращения к store |
|
||||
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
|
||||
| GKEY-8.1 | путь или query URL | 404 без обращения к store |
|
||||
| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
|
||||
|
||||
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах
|
||||
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи —
|
||||
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2
|
||||
**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
|
||||
HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
|
||||
и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
|
||||
значение сформировало само приложение, и невалидность означает баг
|
||||
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
|
||||
в логах не оставляет аномалии и тем самым съедает единственный момент,
|
||||
когда дефект заметен.
|
||||
|
||||
### R9. Транспорт не создаёт доменные ошибки
|
||||
### GKEY-9. Транспорт не создаёт доменные ошибки
|
||||
|
||||
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
|
||||
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
prefix: MIGR
|
||||
---
|
||||
|
||||
# Схема и миграции (SQLite, Go)
|
||||
|
||||
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
|
||||
@@ -12,7 +16,7 @@ Go-приложении. Форма записи — `LANGUAGE.md`.
|
||||
|
||||
## Миграции
|
||||
|
||||
### R1. Миграции ведёт goose
|
||||
### MIGR-1. Миграции ведёт goose
|
||||
|
||||
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
|
||||
goose.
|
||||
@@ -24,7 +28,7 @@ goose.
|
||||
существующую таблицу. На сервере это означает ручной разбор состояния
|
||||
схемы вместо автоматического деплоя.
|
||||
|
||||
### R2. Файлы миграций лежат рядом со store-слоем
|
||||
### MIGR-2. Файлы миграций лежат рядом со store-слоем
|
||||
|
||||
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
|
||||
схемой.
|
||||
@@ -35,14 +39,14 @@ goose.
|
||||
код без миграции, либо миграция без кода; расходятся они на сервере, где
|
||||
схема ещё старая.
|
||||
|
||||
### R3. Форма миграции выбирается по тому, нужен ли код
|
||||
### MIGR-3. Форма миграции выбирается по тому, нужен ли код
|
||||
|
||||
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
|
||||
|
||||
| № | Что делает миграция | Форма |
|
||||
|---|---|---|
|
||||
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
|
||||
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
|
||||
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
|
||||
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
|
||||
|
||||
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
|
||||
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
|
||||
@@ -50,12 +54,12 @@ goose.
|
||||
|
||||
Обратное направление дороже. Перенос данных и генерация идентификаторов
|
||||
выражаются на SQL либо громоздко, либо неточно: идентификатор по
|
||||
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена
|
||||
`KEYS-2` порождает приложение, и SQL-миграция вынуждена
|
||||
завести для него второй генератор — ровно то, что запрещает
|
||||
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается
|
||||
`KEYS-3`. Единообразие формы здесь покупается
|
||||
дублированием логики, которая уже есть в коде.
|
||||
|
||||
### R4. В деплое схема движется только вперёд
|
||||
### MIGR-4. В деплое схема движется только вперёд
|
||||
|
||||
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
|
||||
ошибка исправляется новой миграцией вперёд.
|
||||
@@ -67,14 +71,14 @@ goose.
|
||||
следующей миграцией, оставляет целыми и данные, и журнал применённых
|
||||
версий.
|
||||
|
||||
### R5. Down пишется, когда он честно обращает up
|
||||
### MIGR-5. Down пишется, когда он честно обращает up
|
||||
|
||||
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
|
||||
|
||||
| № | Что делает up | Down |
|
||||
|---|---|---|
|
||||
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
|
||||
| R5.2 | необратимо преобразует данные | не пишется |
|
||||
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
|
||||
| MIGR-5.2 | необратимо преобразует данные | не пишется |
|
||||
|
||||
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
|
||||
и именно там он обязан действительно обращать up. Имитация опаснее
|
||||
@@ -83,7 +87,7 @@ goose.
|
||||
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
|
||||
отладка по данным, которых уже нет.
|
||||
|
||||
### R6. ER-схема обновляется в том же изменении
|
||||
### MIGR-6. ER-схема обновляется в том же изменении
|
||||
|
||||
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
|
||||
изменением.
|
||||
@@ -99,7 +103,7 @@ down останавливает сразу и заставляет пересо
|
||||
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
|
||||
а не язык приложения.
|
||||
|
||||
### R7. Enum-поля — `TEXT`, допустимые значения держит код
|
||||
### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код
|
||||
|
||||
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
|
||||
без `CHECK`-ограничения на список значений.
|
||||
@@ -115,7 +119,7 @@ down останавливает сразу и заставляет пересо
|
||||
таблицы соответствия, которую пришлось бы держать в голове для числового
|
||||
кода.
|
||||
|
||||
### R8. Метки времени — `TEXT` в формате из `arch/time.md`
|
||||
### MIGR-8. Метки времени — `TEXT` в формате из `arch/time.md`
|
||||
|
||||
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
|
||||
пишутся в формате из `arch/time.md`.
|
||||
@@ -127,7 +131,7 @@ down останавливает сразу и заставляет пересо
|
||||
преобразования, а значит и без потери индекса. Соседство двух форматов в
|
||||
одной колонке ломает и сравнение, и разбор на стороне Go.
|
||||
|
||||
### R9. Умолчание `DEFAULT (datetime('now'))` не ставится
|
||||
### MIGR-9. Умолчание `DEFAULT (datetime('now'))` не ставится
|
||||
|
||||
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
|
||||
на уровне схемы.
|
||||
@@ -138,10 +142,10 @@ down останавливает сразу и заставляет пересо
|
||||
по ошибке.
|
||||
|
||||
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
|
||||
то есть не тот формат, которого требует R8. В колонке оказываются строки
|
||||
то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки
|
||||
двух видов, и ломается ровно то, ради чего формат выбран.
|
||||
|
||||
### R10. Булевы поля — `INTEGER` со значениями 0 и 1
|
||||
### MIGR-10. Булевы поля — `INTEGER` со значениями 0 и 1
|
||||
|
||||
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
|
||||
|
||||
@@ -152,10 +156,10 @@ down останавливает сразу и заставляет пересо
|
||||
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
|
||||
проверяют, что список не пуст.
|
||||
|
||||
### R11. Первичный ключ новой таблицы — TEXT ULID
|
||||
### MIGR-11. Первичный ключ новой таблицы — TEXT ULID
|
||||
|
||||
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
|
||||
приложения (`arch/db-identifiers.md` R1, R2).
|
||||
приложения (`KEYS-1`, `KEYS-2`).
|
||||
|
||||
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение,
|
||||
принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие
|
||||
@@ -165,7 +169,7 @@ down останавливает сразу и заставляет пересо
|
||||
`AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на
|
||||
`INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего.
|
||||
|
||||
### R12. Целочисленный ключ идёт вместе с `AUTOINCREMENT`
|
||||
### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT`
|
||||
|
||||
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
|
||||
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
|
||||
@@ -178,7 +182,7 @@ down останавливает сразу и заставляет пересо
|
||||
|
||||
Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой
|
||||
вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
|
||||
там ключ строковый (R11).
|
||||
там ключ строковый (MIGR-11).
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
---
|
||||
prefix: GERR
|
||||
---
|
||||
|
||||
# Ошибки
|
||||
|
||||
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
|
||||
@@ -17,22 +21,22 @@
|
||||
|
||||
## Правила
|
||||
|
||||
### R1. Ошибки строятся средствами стандартной библиотеки
|
||||
### GERR-1. Ошибки строятся средствами стандартной библиотеки
|
||||
|
||||
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
|
||||
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
|
||||
|
||||
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
||||
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений
|
||||
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
|
||||
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
|
||||
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
|
||||
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
|
||||
это цена без покупателя.
|
||||
|
||||
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
|
||||
неё цепочки `%w` нет вовсе (R23).
|
||||
неё цепочки `%w` нет вовсе (GERR-23).
|
||||
|
||||
### R2. Дефолт не обходится точечно
|
||||
### GERR-2. Дефолт не обходится точечно
|
||||
|
||||
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
|
||||
кодовой базы ради конкретной отладки.
|
||||
@@ -41,39 +45,39 @@
|
||||
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
|
||||
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
|
||||
локально, перестаёт накапливаться — а накопление и есть единственный
|
||||
сигнал, что решение R1 пора пересматривать целиком.
|
||||
сигнал, что решение GERR-1 пора пересматривать целиком.
|
||||
|
||||
### R3. Каждый слой добавляет свой контекст
|
||||
### GERR-3. Каждый слой добавляет свой контекст
|
||||
|
||||
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
|
||||
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
|
||||
|
||||
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько,
|
||||
**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
|
||||
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
|
||||
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
|
||||
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
|
||||
кода.
|
||||
|
||||
### R4. Обёртка по умолчанию — `%w`
|
||||
### GERR-4. Обёртка по умолчанию — `%w`
|
||||
|
||||
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
|
||||
вызывающему:
|
||||
|
||||
| № | Ситуация | Глагол |
|
||||
|---|---|---|
|
||||
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
||||
| R4.2 | причину сознательно не раскрываем | `%v` |
|
||||
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
||||
| GERR-4.2 | причину сознательно не раскрываем | `%v` |
|
||||
|
||||
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
||||
становится частью API» — относится к библиотекам с внешними потребителями.
|
||||
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
|
||||
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
|
||||
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
|
||||
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
|
||||
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда
|
||||
(GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
|
||||
`ErrNotFound`», далеко от места обрыва. GERR-4.2 остаётся для случая, когда
|
||||
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
|
||||
|
||||
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v`
|
||||
### GERR-5. Утечка внутренних деталей лечится трансляцией, а не `%v`
|
||||
|
||||
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
|
||||
ошибку наружу.
|
||||
@@ -82,9 +86,9 @@
|
||||
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
|
||||
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
|
||||
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
|
||||
Настоящее место защиты — R13.
|
||||
Настоящее место защиты — GERR-13.
|
||||
|
||||
### R6. Текст обёртки — со строчной буквы и без служебных слов
|
||||
### GERR-6. Текст обёртки — со строчной буквы и без служебных слов
|
||||
|
||||
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
|
||||
|
||||
@@ -94,15 +98,15 @@
|
||||
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
|
||||
каждом уровне и вытесняют из строки полезный контекст.
|
||||
|
||||
### R7. Контекст обёртки называет операцию или субъект
|
||||
### GERR-7. Контекст обёртки называет операцию или субъект
|
||||
|
||||
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
|
||||
|
||||
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something
|
||||
**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
|
||||
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
|
||||
бы занять единственный полезный здесь факт — имя операции.
|
||||
|
||||
### R8. Слой не повторяет смысл нижнего
|
||||
### GERR-8. Слой не повторяет смысл нижнего
|
||||
|
||||
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
|
||||
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
|
||||
@@ -115,10 +119,10 @@ failed» не сужает ничего и при этом занимает в
|
||||
## Две трансляции
|
||||
|
||||
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
|
||||
доменная у источника (R9) и доменная → пользовательская на внешней границе
|
||||
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
|
||||
доменная у источника (GERR-9) и доменная → пользовательская на внешней границе
|
||||
(GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
|
||||
|
||||
### R9. Инфраструктурная ошибка транслируется в доменную у источника
|
||||
### GERR-9. Инфраструктурная ошибка транслируется в доменную у источника
|
||||
|
||||
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
|
||||
возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для
|
||||
@@ -131,14 +135,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
состояние «нет записи» одно и то же. Трансляция у источника оставляет
|
||||
знание о зависимости в единственном слое, который её и так знает.
|
||||
|
||||
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
|
||||
### GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
|
||||
|
||||
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
|
||||
|
||||
| № | Что нужно вызывающему | Форма |
|
||||
|---|---|---|
|
||||
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
||||
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
||||
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
||||
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
||||
|
||||
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
|
||||
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
|
||||
@@ -147,14 +151,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
каждой проверке. Две формы для одного условия — это два способа его
|
||||
проверить, и про второй рано или поздно забудут.
|
||||
|
||||
### R11. Матчинг по тексту сообщения
|
||||
### GERR-11. Матчинг по тексту сообщения
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
|
||||
|
||||
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его
|
||||
свободно. Правка формулировки в нижнем слое молча ломает ветвление
|
||||
наверху, и компилятор этого не видит. Это то же самое, что публичный API из
|
||||
строки лога.
|
||||
**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают
|
||||
переписывать его свободно. Правка формулировки в нижнем слое молча ломает
|
||||
ветвление наверху, и компилятор этого не видит. Это то же самое, что
|
||||
публичный API из строки лога.
|
||||
|
||||
## Граница: приватный канал и публичный
|
||||
|
||||
@@ -162,39 +166,39 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
|
||||
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
|
||||
|
||||
### R12. Полная ошибка идёт в приватный канал
|
||||
### GERR-12. Полная ошибка идёт в приватный канал
|
||||
|
||||
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
|
||||
— `lang/go/logging.md`.
|
||||
|
||||
**Почему.** Цепочка — единственный носитель диагностики (R1), и
|
||||
**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и
|
||||
единственный канал, где её можно показать целиком, — тот, который видит
|
||||
владелец. Не записанная там, она не сохранится нигде: наружу идёт
|
||||
нейтральное сообщение (R13), и восстанавливать причину будет не из чего.
|
||||
нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
|
||||
|
||||
### R13. Публичная поверхность получает сообщение по доменной ошибке
|
||||
### GERR-13. Публичная поверхность получает сообщение по доменной ошибке
|
||||
|
||||
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
|
||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
|
||||
|
||||
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
||||
— у него есть лог (R12). Зато они раскрывают устройство системы — имена
|
||||
— у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
|
||||
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
|
||||
причём раскрывают именно в момент, когда что-то пошло не так.
|
||||
|
||||
### R14. Публичное сообщение несёт корреляционный ключ
|
||||
### GERR-14. Публичное сообщение несёт корреляционный ключ
|
||||
|
||||
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
|
||||
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
|
||||
ошибка».
|
||||
|
||||
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его
|
||||
**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его
|
||||
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
|
||||
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
|
||||
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
|
||||
видел.
|
||||
|
||||
### R15. Маппинг доменных ошибок — в одной точке на все транспорты
|
||||
### GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты
|
||||
|
||||
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
|
||||
задаётся один раз; транспорт без статусов (бот) берёт из него только
|
||||
@@ -203,13 +207,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
||||
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
|
||||
важнее: единственная точка — это место, куда механически дописывается новая
|
||||
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
||||
ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
||||
ничем не проверяет.
|
||||
|
||||
### R16. Новая штатная ветвь отказа сразу попадает в маппинг
|
||||
### GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг
|
||||
|
||||
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
|
||||
добавляется в маппинг (R15) тем же изменением.
|
||||
добавляется в маппинг (GERR-15) тем же изменением.
|
||||
|
||||
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
||||
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
|
||||
@@ -219,14 +223,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
<!-- local:маппинг -->
|
||||
<!-- /local -->
|
||||
|
||||
### R25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
|
||||
### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
|
||||
|
||||
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (R15) нет ветви, отдаёт
|
||||
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
|
||||
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
|
||||
признаком того, что маппинг её не знает.
|
||||
|
||||
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
|
||||
завести вопреки R16. Адресат у неё владелец в смысле «надо чинить», отсюда
|
||||
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
|
||||
`ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус
|
||||
тоже не выбирается: известное пользовательское состояние лежало бы в
|
||||
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
|
||||
@@ -238,18 +242,18 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как
|
||||
механизм обнаружения, а не как шум.
|
||||
|
||||
### R17. Форма текста определяется поверхностью
|
||||
### GERR-17. Форма текста определяется поверхностью
|
||||
|
||||
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
|
||||
текста для них разное:
|
||||
|
||||
| № | Поверхность | Текст ошибки |
|
||||
|---|---|---|
|
||||
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт |
|
||||
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
|
||||
| GERR-17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (GERR-15); `err.Error()` наружу не идёт |
|
||||
| GERR-17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
|
||||
|
||||
Появился второй зритель или публичный доступ к экрану состояния —
|
||||
поверхность стала публичным каналом, и на неё распространяется R17.1.
|
||||
поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
|
||||
|
||||
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
||||
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
|
||||
@@ -259,7 +263,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
про единственного зрителя — ровно то, что делает вторую поверхность
|
||||
приватным каналом; без него это обычная публичная поверхность.
|
||||
|
||||
### R18. Секретов нет ни на одной из поверхностей
|
||||
### GERR-18. Секретов нет ни на одной из поверхностей
|
||||
|
||||
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
|
||||
ни в персистентную диагностику; источник вычищается на границе клиента.
|
||||
@@ -270,19 +274,19 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
|
||||
отличить в нём токен от идентификатора уже нельзя.
|
||||
|
||||
### R19. Диагностика хранится в отдельном поле
|
||||
### GERR-19. Диагностика хранится в отдельном поле
|
||||
|
||||
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
|
||||
показывают пользователю.
|
||||
|
||||
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей
|
||||
**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
|
||||
разные поля. Одно поле на оба назначения означает, что при первом же показе
|
||||
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
|
||||
одно.
|
||||
|
||||
## panic
|
||||
|
||||
### R20. `panic` — только для невосстановимого
|
||||
### GERR-20. `panic` — только для невосстановимого
|
||||
|
||||
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
|
||||
ошибка инициализации, из которой нельзя стартовать.
|
||||
@@ -293,25 +297,25 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
инвариантом опаснее падения, а сервис, стартовавший без обязательной
|
||||
зависимости, всё равно откажет позже и непонятнее.
|
||||
|
||||
### R21. Ожидаемые ошибки — значения `error`
|
||||
### GERR-21. Ожидаемые ошибки — значения `error`
|
||||
|
||||
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
|
||||
плохой ввод, отсутствующая запись возвращаются как `error`.
|
||||
|
||||
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
||||
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
|
||||
его обработать. Дальше такая паника долетает до recover-границы (R22), где
|
||||
его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
|
||||
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
|
||||
«мы сломались».
|
||||
|
||||
### R22. `recover` — на верхней границе каждой обрабатывающей единицы
|
||||
### GERR-22. `recover` — на верхней границе каждой обрабатывающей единицы
|
||||
|
||||
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
|
||||
|
||||
| № | Единица | Зачем `recover` |
|
||||
|---|---|---|
|
||||
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
||||
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
||||
| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
||||
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
||||
|
||||
**Почему.** `recover` работает только в той горутине, где случилась паника,
|
||||
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
|
||||
@@ -321,26 +325,26 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
без своего `recover` уходит мимо структурированного лога, а клиент получает
|
||||
оборванное соединение вместо ответа.
|
||||
|
||||
### R23. Recover-граница пишет `debug.Stack()`
|
||||
### GERR-23. Recover-граница пишет `debug.Stack()`
|
||||
|
||||
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
|
||||
|
||||
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной
|
||||
**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной
|
||||
паники цепочки `%w` нет вовсе. «index out of range» без стека не
|
||||
диагностируется в принципе — сообщение не называет ни файла, ни операции,
|
||||
по нему нельзя сказать даже, в каком пакете упало.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
### R26. После `recover` единица продолжает работу, исключив упавшее
|
||||
### GERR-26. После `recover` единица продолжает работу, исключив упавшее
|
||||
|
||||
**ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит
|
||||
граница:
|
||||
|
||||
| № | Где перехвачена паника | Что дальше |
|
||||
|---|---|---|
|
||||
| R26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
|
||||
| R26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
|
||||
| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
|
||||
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
|
||||
|
||||
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о
|
||||
баге в работе с данными этого элемента, а не о порче общего состояния, —
|
||||
@@ -348,7 +352,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
|
||||
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
|
||||
граница процесса. Обратное при этом верно и делает `recover` в цикле
|
||||
обязательным (R22): неперехваченная паника в любой горутине завершает весь
|
||||
обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
|
||||
процесс.
|
||||
|
||||
Продолжать, не исключив упавший элемент, нельзя: детерминированная паника
|
||||
@@ -359,16 +363,16 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
строку состоянием, — механизм для этого уже есть, заводить отдельный не
|
||||
нужно.
|
||||
|
||||
Оговорка «если ответ ещё не начат» в R26.1 не формальность: статус
|
||||
Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус
|
||||
отправляется один раз, и после первой записи в тело поменять его нечем —
|
||||
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
|
||||
ответ целиком до записи там, где это возможно.
|
||||
|
||||
Из R26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать
|
||||
Из GERR-26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать
|
||||
обработку намеренно», и recover-обёртка пробрасывает его дальше, а не
|
||||
превращает в 500. Так поступают и стандартные обёртки вроде chi.
|
||||
|
||||
### R24. Независимые ошибки собираются `errors.Join`
|
||||
### GERR-24. Независимые ошибки собираются `errors.Join`
|
||||
|
||||
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
|
||||
разом; проверка собранного — по-прежнему через `errors.Is`.
|
||||
@@ -377,12 +381,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
|
||||
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
|
||||
тот же список, но убивает ветвление: `errors.Is` по такому результату не
|
||||
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
|
||||
(R11).
|
||||
(GERR-11).
|
||||
|
||||
## Связано
|
||||
|
||||
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
|
||||
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14.
|
||||
- `KEYS-7` (`arch/db-identifiers.md`) — формат корреляционного ключа
|
||||
из `GERR-14`.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
---
|
||||
prefix: SLOG
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
@@ -19,7 +20,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
|
||||
|
||||
## Формат записи
|
||||
|
||||
### R1. Структурированный JSON, один формат для dev и prod
|
||||
### SLOG-1. Структурированный JSON, один формат для dev и prod
|
||||
|
||||
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
|
||||
проде.
|
||||
@@ -31,7 +32,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
значение) обнаруживаются только в проде, где заметить их заранее уже
|
||||
некому.
|
||||
|
||||
### R2. Данные — в типизированных полях, а не в тексте сообщения
|
||||
### SLOG-2. Данные — в типизированных полях, а не в тексте сообщения
|
||||
|
||||
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
|
||||
|
||||
@@ -40,7 +41,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
|
||||
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
|
||||
|
||||
### R3. Время записи — UTC
|
||||
### SLOG-3. Время записи — UTC
|
||||
|
||||
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
|
||||
(см. `lang/go/time.md`).
|
||||
@@ -58,7 +59,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
|
||||
## Сообщение
|
||||
|
||||
### R4. `msg` — константа в нижнем регистре
|
||||
### SLOG-4. `msg` — константа в нижнем регистре
|
||||
|
||||
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
|
||||
`log.Info("download accepted", "download_id", id)`.
|
||||
@@ -69,7 +70,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
одна категория не двоилась на варианты, различающиеся только заглавной
|
||||
буквой.
|
||||
|
||||
### R5. `msg` не несёт префикса подсистемы
|
||||
### SLOG-5. `msg` не несёт префикса подсистемы
|
||||
|
||||
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
|
||||
отдельное поле.
|
||||
@@ -80,7 +81,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
категория дробится на варианты с префиксом и без, а совпадать они обязаны
|
||||
посимвольно.
|
||||
|
||||
### R6. Смена состояния сущности — единая категория
|
||||
### SLOG-6. Смена состояния сущности — единая категория
|
||||
|
||||
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
|
||||
состояние и по какой причине — данные, а не текст.
|
||||
@@ -91,37 +92,37 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
останется неполной. Единая категория даёт весь цикл одним фильтром и не
|
||||
требует обновлять запрос вслед за кодом.
|
||||
|
||||
### R7. Физический эффект — отдельная запись, а не вместо перехода
|
||||
### SLOG-7. Физический эффект — отдельная запись, а не вместо перехода
|
||||
|
||||
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
|
||||
запись самого перехода.
|
||||
|
||||
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых
|
||||
**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
|
||||
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
|
||||
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
|
||||
что невозможно.
|
||||
|
||||
## Уровни
|
||||
|
||||
### R8. Уровень выбирается по адресату
|
||||
### SLOG-8. Уровень выбирается по адресату
|
||||
|
||||
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
|
||||
громко сломалось».
|
||||
|
||||
| № | Уровень | Кому и когда |
|
||||
|---|---|---|
|
||||
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
|
||||
| R8.2 | `INFO` | владельцу, аудит постфактум |
|
||||
| R8.3 | `WARN` | владельцу, «может стать проблемой» |
|
||||
| R8.4 | `ERROR` | владельцу, в разбор |
|
||||
| SLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
|
||||
| SLOG-8.2 | `INFO` | владельцу, аудит постфактум |
|
||||
| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
|
||||
| SLOG-8.4 | `ERROR` | владельцу, в разбор |
|
||||
|
||||
**Почему.** Адресат — единственный признак, по которому разные авторы в
|
||||
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
|
||||
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
|
||||
базовый порог в проде (R40), потому что он отсекает уже не то, что
|
||||
базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
|
||||
задумано.
|
||||
|
||||
### R9. Уровень не зависит от подсистемы
|
||||
### SLOG-9. Уровень не зависит от подсистемы
|
||||
|
||||
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
|
||||
везде одинаково серьёзен.
|
||||
@@ -132,7 +133,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
|
||||
кода.
|
||||
|
||||
### R10. `WARN` — только когда «может стать проблемой»
|
||||
### SLOG-10. `WARN` — только когда «может стать проблемой»
|
||||
|
||||
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
|
||||
|
||||
@@ -141,22 +142,22 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
единственное, ради чего уровень существует: предупреждение, на которое ещё
|
||||
есть время отреагировать.
|
||||
|
||||
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG`
|
||||
### SLOG-11. Событийное — `INFO`, рутинно-частое — `DEBUG`
|
||||
|
||||
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
|
||||
|
||||
| № | Операция | Уровень |
|
||||
|---|---|---|
|
||||
| R11.1 | по реальному действию или изменению | `INFO` |
|
||||
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
|
||||
| SLOG-11.1 | по реальному действию или изменению | `INFO` |
|
||||
| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
|
||||
|
||||
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность
|
||||
**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
|
||||
определяется долей записей, за которыми что-то стоит. Периодическая
|
||||
операция даёт ровный поток при нулевой информации, в котором настоящие
|
||||
события тонут количественно: их не отфильтровать, потому что фильтровать
|
||||
приходится по содержанию, а не по уровню.
|
||||
|
||||
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
|
||||
### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
|
||||
|
||||
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
|
||||
степень даёт завершение процесса.
|
||||
@@ -169,7 +170,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
|
||||
## Поля: единый словарь
|
||||
|
||||
### R13. Одно поле — одно имя по всему коду
|
||||
### SLOG-13. Одно поле — одно имя по всему коду
|
||||
|
||||
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
|
||||
|
||||
@@ -178,14 +179,14 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
часть записей в него не попадёт, и заметить это можно, только заранее зная,
|
||||
что они должны были быть.
|
||||
|
||||
### R14. Форма имени зависит от вида поля
|
||||
### SLOG-14. Форма имени зависит от вида поля
|
||||
|
||||
**ДОЛЖЕН.** Две формы, третьей нет.
|
||||
|
||||
| № | Вид поля | Форма имени |
|
||||
|---|---|---|
|
||||
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
|
||||
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
|
||||
| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
|
||||
| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
|
||||
|
||||
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
|
||||
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
|
||||
@@ -193,7 +194,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
|
||||
названо, и спорить о них на каждом ревью.
|
||||
|
||||
### R15. Запись плоская
|
||||
### SLOG-15. Запись плоская
|
||||
|
||||
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
|
||||
имени, а не уровень вложенности.
|
||||
@@ -203,32 +204,32 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
|
||||
весь лог, распадаясь на запрос под каждую форму записи.
|
||||
|
||||
### R16. Набор полей определяется ситуацией
|
||||
### SLOG-16. Набор полей определяется ситуацией
|
||||
|
||||
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
|
||||
|
||||
| № | Когда добавляем | Поля |
|
||||
|---|---|---|
|
||||
| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — пока его значение различается между записями (R17) |
|
||||
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
||||
| R16.3 | запись об ошибке | `error` |
|
||||
| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
|
||||
| SLOG-16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — пока его значение различается между записями (SLOG-17) |
|
||||
| SLOG-16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
||||
| SLOG-16.3 | запись об ошибке | `error` |
|
||||
| SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
|
||||
|
||||
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает
|
||||
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
|
||||
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
|
||||
баг», запись о сущности без идентификатора не корреллируется (R19). Полный
|
||||
баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
|
||||
набор делает записи однородными — один запрос работает по всем вызовам, а
|
||||
не по тем, где автор вспомнил про поле.
|
||||
|
||||
### R17. `service.*` и `host.*` не заводим
|
||||
### SLOG-17. `service.*` и `host.*` не заводим
|
||||
|
||||
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
|
||||
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
|
||||
|
||||
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке
|
||||
и внимания при чтении. Критерий один на все поля словаря — им же решается,
|
||||
нужен ли `transport` (R16.1): пока транспорт один, поле постоянно. Условие
|
||||
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
|
||||
названо явно, поэтому правило отпадёт вместе со своей причиной: с
|
||||
появлением нескольких инстансов различающее поле (`service.version`)
|
||||
добавляется одной строкой при старте.
|
||||
@@ -238,7 +239,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
|
||||
## Корреляция
|
||||
|
||||
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id`
|
||||
### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id`
|
||||
|
||||
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
|
||||
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
|
||||
@@ -251,13 +252,13 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
способ спросить об одном. Условие применимости названо: там, где сущности
|
||||
со стабильным идентификатором нет, связывать записи больше нечем.
|
||||
|
||||
### R19. Запись о сущности несёт её идентификатор
|
||||
### SLOG-19. Запись о сущности несёт её идентификатор
|
||||
|
||||
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
|
||||
|
||||
**Почему.** Принадлежность записи восстанавливается только в момент
|
||||
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
|
||||
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем,
|
||||
Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
|
||||
что идентификатор стоит везде, а не в удобных местах.
|
||||
|
||||
Все записи одной операции собираются одним фильтром:
|
||||
@@ -265,7 +266,7 @@ dev-выводом перестаёшь ежедневно гонять собс
|
||||
глобально уникален across сущностей, штатно работает и простой `grep` по
|
||||
голому значению — он находит все упоминания независимо от имени поля.
|
||||
|
||||
### R20. Долгая операция ведётся scoped-логгером через `context.Context`
|
||||
### SLOG-20. Долгая операция ведётся scoped-логгером через `context.Context`
|
||||
|
||||
**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные
|
||||
стадии:
|
||||
@@ -282,17 +283,17 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
## Ошибки
|
||||
|
||||
### R21. Ошибка логируется атрибутом `error`
|
||||
### SLOG-21. Ошибка логируется атрибутом `error`
|
||||
|
||||
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
|
||||
|
||||
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и
|
||||
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
|
||||
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
|
||||
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
|
||||
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
|
||||
краткостью жертвуют.
|
||||
|
||||
### R22. Промежуточный слой либо логирует, либо возвращает
|
||||
### SLOG-22. Промежуточный слой либо логирует, либо возвращает
|
||||
|
||||
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
|
||||
оборачивает (`%w`).
|
||||
@@ -300,44 +301,44 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
|
||||
и количество `ERROR` перестаёт соответствовать количеству отказов — а
|
||||
считают именно его. Контекст при этом не теряется: он накапливается в
|
||||
цепочке обёрток и попадает в единственную запись на границе (R23).
|
||||
цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
|
||||
|
||||
### R23. Ошибка логируется один раз — на границе доменного слоя
|
||||
### SLOG-23. Ошибка логируется один раз — на границе доменного слоя
|
||||
|
||||
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
|
||||
|
||||
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
|
||||
этим местом выбрана доменная граница, а не транспорт, потому что там
|
||||
известен исход операции целиком и, значит, класс отказа (R25) — транспорт
|
||||
известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
|
||||
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
|
||||
транспорты остаются тонкими.
|
||||
|
||||
<!-- local:границы -->
|
||||
<!-- /local -->
|
||||
|
||||
### R24. Транспорт не логирует ошибку повторно
|
||||
### SLOG-24. Транспорт не логирует ошибку повторно
|
||||
|
||||
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
|
||||
(статус, сообщение пользователю) и на этом останавливается.
|
||||
|
||||
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё
|
||||
**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
|
||||
только формулировкой и читается как второй сбой. Когда транспортов над
|
||||
одним доменом несколько, дублирование ещё и множится, а расследование
|
||||
начинается с вопроса, один это инцидент или два.
|
||||
|
||||
### R25. Уровень доменного отказа — по классу отказа
|
||||
### SLOG-25. Уровень доменного отказа — по классу отказа
|
||||
|
||||
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по
|
||||
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (SLOG-23), и выбирает по
|
||||
классу, а не по месту в коде. Классификация покрывает **доменные** отказы —
|
||||
те, что операция вернула значением `error`.
|
||||
|
||||
| № | Класс отказа | Кому | Уровень |
|
||||
|---|---|---|---|
|
||||
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
||||
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||
| SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
||||
| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||
| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||
|
||||
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на
|
||||
**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
|
||||
экране — владельцу разбирать нечего; целостность первичных данных отделяет
|
||||
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
|
||||
уровень для одного и того же отказа в зависимости от того, какой транспорт
|
||||
@@ -350,9 +351,9 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё
|
||||
нет, потому что её просто забыли завести. Она логируется `ERROR` с
|
||||
признаком непокрытой (`lang/go/errors.md` R25).
|
||||
признаком непокрытой (`GERR-25`).
|
||||
|
||||
### R26. Тот же отказ в асинхронной стадии — уровнем выше
|
||||
### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше
|
||||
|
||||
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
|
||||
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
|
||||
@@ -363,7 +364,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
никто, задача осталась недоведённой, и лог — единственное место, где это
|
||||
вообще проявится.
|
||||
|
||||
### R27. Повторяющийся сбой фонового цикла — `WARN`
|
||||
### SLOG-27. Повторяющийся сбой фонового цикла — `WARN`
|
||||
|
||||
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
|
||||
уровень задаёт наличие штатного повтора, а не текст ошибки.
|
||||
@@ -376,32 +377,32 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
|
||||
## Внешние сервисы
|
||||
|
||||
### R28. Каждый вызов внешнего сервиса логируется
|
||||
### SLOG-28. Каждый вызов внешнего сервиса логируется
|
||||
|
||||
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4.
|
||||
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
|
||||
|
||||
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
|
||||
легла»: на своей стороне видно лишь то, что операция не удалась.
|
||||
Выборочное логирование ломает и второе применение — доля неуспехов и
|
||||
распределение `duration_ms` считаются, только если знаменатель полный.
|
||||
|
||||
### R29. Уровень `ext`-записи — по исходу вызова
|
||||
### SLOG-29. Уровень `ext`-записи — по исходу вызова
|
||||
|
||||
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
|
||||
|
||||
| № | Исход | Уровень |
|
||||
|---|---|---|
|
||||
| R29.1 | успешный событийный вызов | `INFO` |
|
||||
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
|
||||
| R29.3 | попытка не удалась, делается retry | `WARN` |
|
||||
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
|
||||
| SLOG-29.1 | успешный событийный вызов | `INFO` |
|
||||
| SLOG-29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
|
||||
| SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
|
||||
| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
|
||||
|
||||
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
|
||||
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
|
||||
уровень непригодным для главного вопроса «зависимость доступна?».
|
||||
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
|
||||
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение
|
||||
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет
|
||||
разбираться владельцу. Различение SLOG-29.1 и SLOG-29.2 — то же самое разделение
|
||||
событийного и рутинного, что в SLOG-11: поллинг внешнего сервиса зашумляет
|
||||
аудит так же, как любой другой.
|
||||
|
||||
## Два цикла повтора — не путать
|
||||
@@ -412,8 +413,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
|
||||
уровень доменной записи об исходе тика.
|
||||
|
||||
```
|
||||
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4)
|
||||
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27)
|
||||
WHEN зависимость недоступна и ретраи вызова исчерпаны
|
||||
→ ext-запись `ERROR` (SLOG-29.4)
|
||||
AND тик фонового цикла упал по той же причине
|
||||
→ доменная запись `WARN` (SLOG-27)
|
||||
```
|
||||
|
||||
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
||||
@@ -422,7 +425,7 @@ AND тик фонового цикла упал по той же причине
|
||||
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
||||
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
||||
|
||||
### R30. Ответ 4xx — успех на транспортном уровне
|
||||
### SLOG-30. Ответ 4xx — успех на транспортном уровне
|
||||
|
||||
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
|
||||
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
||||
@@ -437,38 +440,38 @@ AND тик фонового цикла упал по той же причине
|
||||
|
||||
## HTTP и healthcheck
|
||||
|
||||
### R31. Входящий запрос — `INFO` независимо от кода ответа
|
||||
### SLOG-31. Входящий запрос — `INFO` независимо от кода ответа
|
||||
|
||||
**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа.
|
||||
**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
|
||||
|
||||
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
|
||||
когда приходил», и ценность у неё одинаковая при любом коде ответа.
|
||||
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
|
||||
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
|
||||
(R25) — она и адресована по-другому.
|
||||
(SLOG-25) — она и адресована по-другому.
|
||||
|
||||
### R32. Для корреляции запроса допустим `request_id`
|
||||
### SLOG-32. Для корреляции запроса допустим `request_id`
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
|
||||
|
||||
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
|
||||
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где
|
||||
правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
|
||||
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
|
||||
сущности нет — связать его записи между собой больше нечем.
|
||||
|
||||
### R33. Healthcheck, liveness, readiness — `DEBUG`
|
||||
### SLOG-33. Healthcheck, liveness, readiness — `DEBUG`
|
||||
|
||||
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
|
||||
|
||||
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают
|
||||
**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
|
||||
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
|
||||
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в
|
||||
аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
|
||||
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
|
||||
доступной при отладке.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
### R34. Секреты не логируются
|
||||
### SLOG-34. Секреты не логируются
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
|
||||
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
|
||||
@@ -479,17 +482,17 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
с момента записи, а не с момента, когда это заметили, и вычистить его задним
|
||||
числом из уже собранных копий нельзя.
|
||||
|
||||
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
|
||||
### SLOG-35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
|
||||
|
||||
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
|
||||
`DEBUG`, с вычисткой секретов и обрезкой по длине.
|
||||
|
||||
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
|
||||
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
|
||||
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией;
|
||||
выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
|
||||
обрезка не даёт одной записи вытеснить весь остальной лог за период.
|
||||
|
||||
### R36. При сомнении логируется факт, а не значение
|
||||
### SLOG-36. При сомнении логируется факт, а не значение
|
||||
|
||||
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
|
||||
|
||||
@@ -499,7 +502,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
|
||||
никто не придёт.
|
||||
|
||||
### R37. `*url.Error` санитизируется на границе клиента
|
||||
### SLOG-37. `*url.Error` санитизируется на границе клиента
|
||||
|
||||
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
|
||||
обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
|
||||
@@ -513,12 +516,12 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
причину сохраняется); альтернатива с редактированием URL сохранила бы
|
||||
структуру, но сложнее.
|
||||
|
||||
### R38. Секрет не кладётся в URL, если у API есть заголовок
|
||||
### SLOG-38. Секрет не кладётся в URL, если у API есть заголовок
|
||||
|
||||
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
|
||||
способа нет.
|
||||
|
||||
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и
|
||||
**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
|
||||
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
|
||||
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
|
||||
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
|
||||
@@ -528,7 +531,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
|
||||
## Куда пишем
|
||||
|
||||
### R39. Логи идут в `stdout` одним потоком
|
||||
### SLOG-39. Логи идут в `stdout` одним потоком
|
||||
|
||||
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
|
||||
не маршрутизируем.
|
||||
@@ -539,23 +542,23 @@ API-ключи и токены, `Authorization`-заголовки, аутент
|
||||
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
|
||||
теряет его ровно там, где важен ход событий.
|
||||
|
||||
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
|
||||
### SLOG-40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
|
||||
|
||||
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
|
||||
|
||||
**Почему.** Уровень — единственный регулятор объёма, доступный без
|
||||
пересборки; если `DEBUG` в проде включается только правкой кода, его не
|
||||
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
|
||||
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2).
|
||||
что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — точность и зона меток времени фиксируются на носитель.
|
||||
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3).
|
||||
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (SLOG-3).
|
||||
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
|
||||
санитизации (R37).
|
||||
санитизации (SLOG-37).
|
||||
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
|
||||
на которых держится корреляция (R18).
|
||||
на которых держится корреляция (SLOG-18).
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
+32
-31
@@ -1,4 +1,5 @@
|
||||
---
|
||||
prefix: GTIM
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
@@ -10,7 +11,7 @@ extends: arch/time.md
|
||||
|
||||
## Правила
|
||||
|
||||
### R1. «Сейчас» берётся у слоя хранилища
|
||||
### GTIM-1. «Сейчас» берётся у слоя хранилища
|
||||
|
||||
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
|
||||
`time.Now().UTC()`, а не из `time.Now()` по коду.
|
||||
@@ -24,28 +25,28 @@ extends: arch/time.md
|
||||
придётся превратить в переменную или поле, если однажды понадобится
|
||||
подменять часы, но само по себе оно подмены не даёт.
|
||||
|
||||
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
|
||||
### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
|
||||
|
||||
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
|
||||
получить строку времени и прочитать её обратно.
|
||||
|
||||
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
|
||||
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и
|
||||
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
|
||||
взаимная обратимость записи и чтения держатся ровно до первого второго
|
||||
layout — а расхождение проявится не на записи, а при сравнении значений,
|
||||
записанных разными местами.
|
||||
|
||||
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
|
||||
### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
|
||||
|
||||
**ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений
|
||||
ровно два, и оба прописаны явно:
|
||||
|
||||
| № | Исключение | Почему оно не покрывается R1 |
|
||||
| № | Исключение | Почему оно не покрывается GTIM-1 |
|
||||
|---|---|---|
|
||||
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
||||
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
|
||||
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
||||
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
|
||||
|
||||
**Почему.** R1 без механической проверки держится на внимании, а
|
||||
**Почему.** GTIM-1 без механической проверки держится на внимании, а
|
||||
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
|
||||
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
|
||||
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
|
||||
@@ -53,23 +54,23 @@ layout — а расхождение проявится не на записи,
|
||||
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
|
||||
сама себе.
|
||||
|
||||
### R13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
|
||||
### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
|
||||
|
||||
**ДОЛЖЕН.** Исключение из R3 оформляется как `//nolint:forbidigo // <причина>`
|
||||
на строке вызова; exclude-записи в конфигурации линтера для него не
|
||||
заводятся.
|
||||
**ДОЛЖЕН.** Исключение из GTIM-3 оформляется как
|
||||
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
|
||||
конфигурации линтера для него не заводятся.
|
||||
|
||||
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует
|
||||
исключение путём к файлу, отвязывается при переносе кода и продолжает
|
||||
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
|
||||
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
|
||||
та исчерпываемость, которой требует R3, проверяется одной командой. Голый
|
||||
та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый
|
||||
`//nolint` без имени правила глушит на строке все проверки сразу, а без
|
||||
причины неотличим от заглушенного дефекта; обе деградации штатно ловит
|
||||
`nolintlint` (`require-specific`, `require-explanation`) — стандартный
|
||||
способ дисциплинировать директивы в golangci-lint.
|
||||
|
||||
### R4. В БД время хранится с секундной точностью, ширина 20 символов
|
||||
### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов
|
||||
|
||||
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
|
||||
|
||||
@@ -83,16 +84,16 @@ layout — а расхождение проявится не на записи,
|
||||
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
|
||||
поэтому `Format` их не выведет.
|
||||
|
||||
### R5. `time.RFC3339Nano` не используется
|
||||
### GTIM-5. `time.RFC3339Nano` не используется
|
||||
|
||||
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
|
||||
|
||||
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
||||
от значения: соседние записи получают разную ширину, и свойство, на котором
|
||||
держится R4, исчезает незаметно. Проверка «формат корректен» при этом
|
||||
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
|
||||
проходит — отказывает только порядок.
|
||||
|
||||
### R6. Чужой вход нормализуется явно
|
||||
### GTIM-6. Чужой вход нормализуется явно
|
||||
|
||||
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
|
||||
к каноническому виду явно, а не считается каноническим по факту успешного
|
||||
@@ -101,21 +102,21 @@ layout — а расхождение проявится не на записи,
|
||||
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
||||
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
|
||||
**писатель**, а не читатель; пока писатель один, этого достаточно, но
|
||||
значение из чужой системы, положенное в базу как пришло, нарушает R4 и
|
||||
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
|
||||
обнаруживается не на записи, а на первой сортировке. Само решение
|
||||
«нормализовать, а не отклонять» — базовое (`arch/time.md` R13); здесь —
|
||||
«нормализовать, а не отклонять» — базовое (`TIME-13`); здесь —
|
||||
Go-механика, из-за которой его легко нарушить незаметно.
|
||||
|
||||
### R7. В драйвер передаётся строка, а не `time.Time`
|
||||
### GTIM-7. В драйвер передаётся строка, а не `time.Time`
|
||||
|
||||
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
|
||||
|
||||
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
||||
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с
|
||||
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
|
||||
собственным layout, который меняется вместе с версией драйвера, а не вместе
|
||||
с конвенцией.
|
||||
|
||||
### R8. Время в логах приводится к UTC через `ReplaceAttr`
|
||||
### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr`
|
||||
|
||||
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
|
||||
|
||||
@@ -134,17 +135,17 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
неверная зона выглядит как совершенно валидное время, а записи из разных
|
||||
мест перестают складываться в одну хронологию с метками хранилища.
|
||||
|
||||
### R9. Точность времени в логах отличается от точности в БД
|
||||
### GTIM-9. Точность времени в логах отличается от точности в БД
|
||||
|
||||
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
|
||||
приводится к секундной точности R4.
|
||||
приводится к секундной точности GTIM-4.
|
||||
|
||||
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование
|
||||
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
|
||||
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
|
||||
такая же фиксированная ширина, и свойство, ради которого R4 существует, не
|
||||
нарушено. Общее у лога и базы одно — зона (R8).
|
||||
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
|
||||
нарушено. Общее у лога и базы одно — зона (GTIM-8).
|
||||
|
||||
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую
|
||||
### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую
|
||||
|
||||
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с
|
||||
локальным `//nolint`.
|
||||
@@ -153,10 +154,10 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
|
||||
меткам, зависит от подводки часов: перевод назад даёт отрицательную
|
||||
длительность, скачок вперёд — выброс в измерениях, и оба случая
|
||||
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается
|
||||
невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается
|
||||
как недосмотр и его «чинят».
|
||||
|
||||
### R11. `time/tzdata` импортируется в `main`
|
||||
### GTIM-11. `time/tzdata` импортируется в `main`
|
||||
|
||||
**ДОЛЖЕН.** База зон вшивается в бинарь.
|
||||
|
||||
@@ -166,7 +167,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
`main` держит это решение в одном видимом месте, а не в случайном пакете,
|
||||
откуда его удаляют при чистке зависимостей.
|
||||
|
||||
### R12. Зона отображения применяется только в UI
|
||||
### GTIM-12. Зона отображения применяется только в UI
|
||||
|
||||
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
|
||||
представления, но не в хранимых значениях и не в вычислениях.
|
||||
|
||||
Reference in New Issue
Block a user