заведён реестр префиксов, правила канона перенумерованы

- идентификатор правила теперь `<ПРЕФИКС>-<номер>` вместо `R<номер>`:
  префикс уникален по всему канону, поэтому ссылка больше не требует пути
  к файлу и не зависит от того, на какой оси файл лежит
- префикс выбирается под файл, а не выводится по формуле, и хранится в
  conventions/prefixes.toml вместе с выбывшими; номера сохранены один в
  один вместе с дырами
This commit is contained in:
av
2026-07-25 20:55:37 +03:00
parent 972627f641
commit dcdd92230b
13 changed files with 552 additions and 470 deletions
+27 -23
View File
@@ -1,3 +1,7 @@
---
prefix: DIRS
---
# Категории директорий приложения # Категории директорий приложения
Всё, что приложение пишет на диск, делится на три категории по принципу Всё, что приложение пишет на диск, делится на три категории по принципу
@@ -16,32 +20,32 @@
## Правила ## Правила
### R1. Записываемые пути разложены по трём категориям ### DIRS-1. Записываемые пути разложены по трём категориям
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой, **ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
относится к одной из трёх категорий: относится к одной из трёх категорий:
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе | | № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет | | DIRS-1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
| R1.2 | данные | `data/` | приложение | невосполнима | да | | DIRS-1.2 | данные | `data/` | приложение | невосполнима | да |
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет | | DIRS-1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
Имена в таблице — умолчание для случая «одна директория на категорию». Имена в таблице — умолчание для случая «одна директория на категорию».
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно **Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно
снести при нехватке места, что переживает переезд на другой диск — снести при нехватке места, что переживает переезд на другой диск —
читаются из категории, а не выясняются по коду приложения. Без единой читаются из категории, а не выясняются по коду приложения. Без единой
классификации каждое такое решение принимается заново и каждый раз чуть классификации каждое такое решение принимается заново и каждый раз чуть
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места, по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
потерянные данные не стоят ничего, потому что их больше нет. потерянные данные не стоят ничего, потому что их больше нет.
### R2. Категория может состоять из нескольких директорий ### DIRS-2. Категория может состоять из нескольких директорий
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; **ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
принадлежность к категории задаётся не именем, а участием в списке бэкапа. принадлежность к категории задаётся не именем, а участием в списке бэкапа.
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно **Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно
три директории». Крупные файлы отделяют от базы, чтобы двигать их между три директории». Крупные файлы отделяют от базы, чтобы двигать их между
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном `data/`); запрет на такое деление вынуждал бы либо держать всё на одном
@@ -49,15 +53,15 @@
задавать именем ровно поэтому: имён в категории несколько, и выбираются они задавать именем ровно поэтому: имён в категории несколько, и выбираются они
по содержимому. по содержимому.
### R3. Данные и кеш разделяются по тесту на пересоздание ### DIRS-3. Данные и кеш разделяются по тесту на пересоздание
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому: **ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
| № | Что лежит | Категория | | № | Что лежит | Категория |
|---|---|---| |---|---|---|
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные | | DIRS-3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | | DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | | DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
**Почему.** Без внешнего теста граница проводится по ощущению «жалко **Почему.** Без внешнего теста граница проводится по ощущению «жалко
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
@@ -67,7 +71,7 @@
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
при попытке восстановить. при попытке восстановить.
### R4. В бэкап идут данные, и только они ### DIRS-4. В бэкап идут данные, и только они
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и **ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
кеш — нет. кеш — нет.
@@ -79,9 +83,9 @@
Ошибка в другую сторону дороже: директория данных, не попавшая в список, Ошибка в другую сторону дороже: директория данных, не попавшая в список,
обнаруживается в единственный момент, когда исправить её уже нечем. обнаруживается в единственный момент, когда исправить её уже нечем.
### R5. Список бэкапа ссылается на те же пути, что и создание директорий ### DIRS-5. Список бэкапа ссылается на те же пути, что и создание директорий
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же **ДОЛЖЕН.** Список выводится из категорий по DIRS-4 и ссылается на те же
**объявления путей**, по которым директории создаются, а не набирается **объявления путей**, по которым директории создаются, а не набирается
независимо. независимо.
@@ -96,25 +100,25 @@
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
нужно, проявляется при восстановлении. нужно, проявляется при восстановлении.
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность ### DIRS-6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске: **ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
| № | Данные | В бэкап | | № | Данные | В бэкап |
|---|---|---| |---|---|---|
| R6.1 | файлы самодостаточны на любой момент времени | копированием | | DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием |
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | | DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
**Почему.** Файловый снапшот работающей СУБД не гарантирует **Почему.** Файловый снапшот работающей СУБД не гарантирует
консистентности: скопированный каталог может не восстановиться, и узнают консистентности: скопированный каталог может не восстановиться, и узнают
об этом при восстановлении. Директория дампов — тоже данные, просто об этом при восстановлении. Директория дампов — тоже данные, просто
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из
списка при этом исключается: он удваивает объём снапшота и добавляет к списка при этом исключается: он удваивает объём снапшота и добавляет к
надёжной копии заведомо ненадёжную. надёжной копии заведомо ненадёжную.
### R7. Способ выбирается при заведении приложения ### DIRS-7. Способ выбирается при заведении приложения
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда **ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда
приложение заводят. приложение заводят.
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не **Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
@@ -123,7 +127,7 @@
первой неудачной попытки восстановления, то есть тогда, когда данных уже первой неудачной попытки восстановления, то есть тогда, когда данных уже
нет. нет.
### R8. Приложение разводит записываемые пути по категориям ### DIRS-8. Приложение разводит записываемые пути по категориям
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для **ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
кеша, а не один каталог на всё. кеша, а не один каталог на всё.
@@ -135,12 +139,12 @@
появляется молча. Приложение, которое не умеет разделять, тем самым появляется молча. Приложение, которое не умеет разделять, тем самым
дефектно; раскладка под этот дефект не подстраивается. дефектно; раскладка под этот дефект не подстраивается.
### R9. Приложение не пишет в директорию конфигурации ### DIRS-9. Приложение не пишет в директорию конфигурации
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь **НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
конфигурации. конфигурации.
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому **Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому
всё, что приложение туда записало, следующий деплой затирает без всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
+61 -56
View File
@@ -1,3 +1,7 @@
---
prefix: CONF
---
# Конфигурация приложения # Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с Как устроена конфигурация: где лежит, как попадает в процесс, что с
@@ -12,7 +16,7 @@
## Правила ## Правила
### R1. Конфигурация — файл, а не окружение ### CONF-1. Конфигурация — файл, а не окружение
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат. окружения источником конфигурации не служат.
@@ -36,17 +40,17 @@
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под `PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
`0600`. `0600`.
### R2. Формат конфигурации — текстовый, с секциями и комментариями ### CONF-2. Формат конфигурации — текстовый, с секциями и комментариями
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг **Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает R9 вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же плоский список пар такой возможности не даёт и возвращает нас к тем же
свойствам, из-за которых отвергнуто окружение (R1). свойствам, из-за которых отвергнуто окружение (CONF-1).
### R3. Имя файла фиксировано, путь переопределяется опцией ### CONF-3. Имя файла фиксировано, путь переопределяется опцией
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки. задаётся опцией командной строки.
@@ -55,22 +59,22 @@
контейнере и на сервере, и способ запуска не приходится помнить отдельно контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем (тесты, второй инстанс): без неё их разводят переменной окружения — тем
самым каналом, который закрывает R1. самым каналом, который закрывает CONF-1.
### R20. Отсутствие файла конфигурации — ошибка старта ### CONF-20. Отсутствие файла конфигурации — ошибка старта
**ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в **ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в
рабочей директории (R3), приложение не стартует: сообщение называет рабочей директории (CONF-3), приложение не стартует: сообщение называет
искомый путь, код возврата ненулевой. искомый путь, код возврата ненулевой.
**Почему.** Конфиг — артефакт деплоя (R12), и его отсутствие означает, что **Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
развёртывание не довело работу до конца, а не что приложение попросили развёртывание не довело работу до конца, а не что приложение попросили
работать на умолчаниях. Умолчания (R7) существуют, чтобы работал работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается **неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
чаще всего. чаще всего.
Старт без файла ничего не спасает: у приложения с обязательными полями или Старт без файла ничего не спасает: у приложения с обязательными полями или
секретами всё равно упадёт валидация (R18), только вместо одного сообщения секретами всё равно упадёт валидация (CONF-18), только вместо одного сообщения
«нет `config.toml`» получится каскад «поле пусто», за которым настоящая «нет `config.toml`» получится каскад «поле пусто», за которым настоящая
причина — деплой не отрендерил файл — не видна. причина — деплой не отрендерил файл — не видна.
@@ -79,16 +83,16 @@
<!-- local:проверки --> <!-- local:проверки -->
<!-- /local --> <!-- /local -->
### R4. В репозитории лежит образец, а не рабочий конфиг ### CONF-4. В репозитории лежит образец, а не рабочий конфиг
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет, **Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того, попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины: закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча. он расходится с тем, что реально развёрнуто, и расходится молча.
### R5. Конфиг разбирается один раз при старте ### CONF-5. Конфиг разбирается один раз при старте
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет. файла конфигурации в бизнес-коде нет.
@@ -96,10 +100,10 @@
**Почему.** Второе место чтения — это второй момент времени: две части кода **Почему.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали. воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (R17), Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
где она видна сразу, а не в первый вызов ветки, которая это поле читает. где она видна сразу, а не в первый вызов ветки, которая это поле читает.
### R6. Конфиг неизменяем после старта ### CONF-6. Конфиг неизменяем после старта
**ДОЛЖЕН.** Смена параметров — рестарт процесса. **ДОЛЖЕН.** Смена параметров — рестарт процесса.
@@ -111,7 +115,7 @@
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание. умолчание.
### R7. Умолчания живут в коде ### CONF-7. Умолчания живут в коде
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
@@ -120,17 +124,17 @@
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется. поведение для неполного конфига и одно место, где это значение меняется.
### R8. Образец перечисляет все поля ### CONF-8. Образец перечисляет все поля
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (R7). которых есть умолчание (CONF-7).
**Почему.** Поле, живущее только в коде, для читателя конфига не **Почему.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой R7 нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
покупает себе видимость. покупает себе видимость.
### R9. У каждого поля образца есть комментарий ### CONF-9. У каждого поля образца есть комментарий
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно: **ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
@@ -145,35 +149,35 @@
дают валидное значение и работающий процесс, а ошибка обнаруживается по дают валидное значение и работающий процесс, а ошибка обнаруживается по
последствиям — таймаут в тысячу раз не тот. последствиям — таймаут в тысячу раз не тот.
### R10. Обязательность полей определяется дискриминатором `type` ### CONF-10. Обязательность полей определяется дискриминатором `type`
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор **ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
бекенда или внешнего сервиса), валидация идёт по его значению: бекенда или внешнего сервиса), валидация идёт по его значению:
| № | Значение `type` | Валидация | | № | Значение `type` | Валидация |
|---|---|---| |---|---|---|
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | | CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | | CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
**Почему.** Фиксированный на секцию набор обязательных полей оставляет **Почему.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там, не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от значений (CONF-10.2) нужно потому, что опечатка в `type` иначе неотличима от
неподдерживаемого варианта, и за списком приходится идти в код. неподдерживаемого варианта, и за списком приходится идти в код.
### R11. Образец показывает все варианты `type` ### CONF-11. Образец показывает все варианты `type`
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями, **СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
альтернативные — блоками-комментариями ниже, каждый со своим описанием альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей. полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец **Почему.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (R8, R9) ровно на той секции, где выбор теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации. на месте, а не сборкой секции с нуля по документации.
### R12. Секреты в конфиг приносит деплой ### CONF-12. Секреты в конфиг приносит деплой
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет. отдельного слоя секретов в приложении нет.
@@ -185,27 +189,28 @@
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
особенного. особенного.
### R13. Рендеренный конфиг — `0600` и владелец-рантайм ### CONF-13. Рендеренный конфиг — `0600` и владелец-рантайм
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс. работает процесс.
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на **Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная
которой секреты лежат, и весь довод «файл вместо окружения» держится на его поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
бы окружение, — и тогда R1 меняет одну утечку на другую. шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
другую.
### R14. В образце секретные поля — пустые строки ### CONF-14. В образце секретные поля — пустые строки
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример. пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее **Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного. механически отличимым от заполненного.
### R15. Загрузчик проверяет, что обязательные секреты не пусты ### CONF-15. Загрузчик проверяет, что обязательные секреты не пусты
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
@@ -213,12 +218,12 @@
в 401 от внешнего API через час работы, — то есть в момент, когда причина в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем. ещё очевидна и связана с деплоем.
### R16. Секреты не попадают в логи ### CONF-16. Секреты не попадают в логи
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне. одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они **Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при записи. Типичный источник утечки — отладочный дамп разобранного конфига при
@@ -227,7 +232,7 @@
<!-- local:секретные-поля --> <!-- local:секретные-поля -->
<!-- /local --> <!-- /local -->
### R17. Конфиг валидируется на старте, до приёма трафика ### CONF-17. Конфиг валидируется на старте, до приёма трафика
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину». кодом; процесс не стартует «наполовину».
@@ -238,24 +243,24 @@
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
завершения, и приложение считается развёрнутым. завершения, и приложение считается развёрнутым.
### R18. Минимальный набор проверок ### CONF-18. Минимальный набор проверок
**ДОЛЖЕН.** Валидация покрывает как минимум: **ДОЛЖЕН.** Валидация покрывает как минимум:
| № | Что проверяется | Когда всплывёт без проверки | | № | Что проверяется | Когда всплывёт без проверки |
|---|---|---| |---|---|---|
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает | | CONF-18.1 | обязательные поля заданы (непустота секретов — CONF-15) | в ветке, которая это поле читает |
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте | | CONF-18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке | | CONF-18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | | CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | | CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый **Почему.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению. сводит их все к одному моменту и одному сообщению.
### R19. Проблемы конфига показываются разом ### CONF-19. Проблемы конфига показываются разом
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой. списком, а не падает на первой.
@@ -266,25 +271,25 @@
одного источника: разом они читаются как одна причина, по одной — как одного источника: разом они читаются как одна причина, по одной — как
череда несвязанных мелочей. череда несвязанных мелочей.
### R21. Значение поля в сообщении валидатора — по признаку секретности ### CONF-21. Значение поля в сообщении валидатора — по признаку секретности
**ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности **ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности
поля, которым уже пользуются R15 и R16: поля, которым уже пользуются CONF-15 и CONF-16:
| № | Поле | В сообщении | | № | Поле | В сообщении |
|---|---|---| |---|---|---|
| R21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | | CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
| R21.2 | секретное | имя поля и суть нарушения, без значения | | CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
**Почему.** Сообщение без значения отправляет читателя в файл — сличать **Почему.** Сообщение без значения отправляет читателя в файл — сличать
глазами каждую строку списка R19; ошибки вида «секунды вместо миллисекунд» глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
или пробел в конце значения из такого сообщения не читаются вовсе. Значение или пробел в конце значения из такого сообщения не читаются вовсе. Значение
секретного поля при этом печатать некуда: вывод старта уходит в лог секретного поля при этом печатать некуда: вывод старта уходит в лог
супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же
канал утечки, который закрывает R16. Отдельный список «что не печатать» не канал утечки, который закрывает CONF-16. Отдельный список «что не печатать»
заводится: признак один на R15, R16 и R21, а второй список разошёлся бы с не заводится: признак один на CONF-15, CONF-16 и CONF-21, а второй список
первым — и поле оказалось бы секретным для логов, но печатаемым разошёлся бы с первым — и поле оказалось бы секретным для логов, но
валидатором. печатаемым валидатором.
## Связано ## Связано
+26 -22
View File
@@ -1,3 +1,7 @@
---
prefix: KEYS
---
# Идентификаторы сущностей # Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей. Форма записи — Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
@@ -14,7 +18,7 @@
## Правила ## Правила
### R1. Первичный ключ новой сущности — ULID ### KEYS-1. Первичный ключ новой сущности — ULID
**ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор, **ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор,
который порождает приложение, — во **всех** таблицах, включая те, что который порождает приложение, — во **всех** таблицах, включая те, что
@@ -31,12 +35,12 @@
Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от
спора при заведении каждой таблицы и делает идентификатор **глобальным** спора при заведении каждой таблицы и делает идентификатор **глобальным**
уникальным across таблиц, а не только внутри своей. На этом держится уникальным across таблиц, а не только внутри своей. На этом держится
корреляция по логам (R7). корреляция по логам (KEYS-7).
Правило про **сгенерированные суррогатные** ключи. Естественные и составные Правило про **сгенерированные суррогатные** ключи. Естественные и составные
ключи у таблиц-деталей (R6) — третья категория, они допустимы всегда. ключи у таблиц-деталей (KEYS-6) — третья категория, они допустимы всегда.
### R2. Идентификатор генерирует приложение, а не база ### KEYS-2. Идентификатор генерирует приложение, а не база
**ДОЛЖЕН.** Значение ключа известно до вставки строки. **ДОЛЖЕН.** Значение ключа известно до вставки строки.
@@ -46,26 +50,26 @@
и достраивать связи вторым проходом, либо иметь два источника истины о и достраивать связи вторым проходом, либо иметь два источника истины о
моменте создания. моменте создания.
### R3. Генерация и разбор идентификаторов — в единственной точке ### KEYS-3. Генерация и разбор идентификаторов — в единственной точке
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает. **ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет. Самодельных генераторов и парсеров в коде нет.
**Почему.** Нормализация регистра (R4) и проверка формата обязаны **Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан. проявится не там, где создан.
### R4. Канонический вид — нижний регистр ### KEYS-4. Канонический вид — нижний регистр
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре. **ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не **Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний** косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3) регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3)
разный регистр появится в базе сам собой. разный регистр появится в базе сам собой.
### R5. Внешний идентификатор разбирается до обращения к базе ### KEYS-5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию **ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
@@ -73,28 +77,28 @@
| № | Откуда пришёл | Разбор не удался → | | № | Откуда пришёл | Разбор не удался → |
|---|---|---| |---|---|---|
| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу | | KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | | KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать **Почему.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика. границе, мы дёшево снимаем целый класс мусорного трафика.
Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в Разделение KEYS-5.1 и KEYS-5.2 нужно, потому что источники значат разное.
URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает
точно. Мусор из собственной формы — это баг интерфейса или устаревший ситуацию точно. Мусор из собственной формы — это баг интерфейса или
экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает
единственный момент, когда он заметен. диагностики единственный момент, когда он заметен.
Таблица перечисляет **внешние** источники — те, откуда значение приходит Таблица перечисляет **внешние** источники — те, откуда значение приходит
вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из
конфигурации, из собственной базы или из фикстуры сюда не относится: он конфигурации, из собственной базы или из фикстуры сюда не относится: он
ничего не отдаёт наружу, а его невалидность означает, что сломано у нас. ничего не отдаёт наружу, а его невалидность означает, что сломано у нас.
Формат идентификатора в конфигурации проверяется на старте Формат идентификатора в конфигурации проверяется на старте
(`arch/config.md` R18), невалидное значение в собственной базе — нарушенный (`CONF-18`), невалидное значение в собственной базе — нарушенный
инвариант единой точки (R3). инвариант единой точки (KEYS-3).
### R6. У таблиц-деталей допустим естественный или составной ключ ### KEYS-6. У таблиц-деталей допустим естественный или составной ключ
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный **ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится. сгенерированный идентификатор не заводится.
@@ -104,10 +108,10 @@ URL — это чужая или протухшая ссылка, и «не на
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт. информации он не несёт.
### R7. Прочие генерируемые идентификаторы — через ту же точку ### KEYS-7. Прочие генерируемые идентификаторы — через ту же точку
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч, **ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же
формате. формате.
**Почему.** Единый формат делает работающим главный побочный эффект **Почему.** Единый формат делает работающим главный побочный эффект
@@ -118,7 +122,7 @@ URL — это чужая или протухшая ссылка, и «не на
## Почему ULID, а не UUID ## Почему ULID, а не UUID
R1 требует **сортируемый** строковый идентификатор. UUIDv4 не KEYS-1 требует **сортируемый** строковый идентификатор. UUIDv4 не
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
остаются два довода: 36 символов против 26 и дефисы, из-за которых остаются два довода: 36 символов против 26 и дефисы, из-за которых
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово. идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
+31 -26
View File
@@ -1,3 +1,7 @@
---
prefix: TIME
---
# Время # Время
Как приложение записывает моменты и длительности: в каком формате, откуда Как приложение записывает моменты и длительности: в каком формате, откуда
@@ -14,7 +18,7 @@
## Правила ## Правила
### R1. Единый формат — RFC 3339, UTC, суффикс `Z` ### TIME-1. Единый формат — RFC 3339, UTC, суффикс `Z`
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами. одинаково в хранении, логах, API и обмене с внешними системами.
@@ -25,7 +29,7 @@
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
убирает из данных и смещение, и сам вопрос «в какой зоне это записано». убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
### R2. Ширина строки фиксируется на каждый носитель ### TIME-2. Ширина строки фиксируется на каждый носитель
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает. строки времени одна и от записи к записи не плавает.
@@ -38,18 +42,18 @@
везде, а только на тех парах записей, где дробная часть оказалась короче, — везде, а только на тех парах записей, где дробная часть оказалась короче, —
то есть редко, выборочно и невоспроизводимо. то есть редко, выборочно и невоспроизводимо.
### R3. Точность разных носителей может различаться ### TIME-3. Точность разных носителей может различаться
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что **Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что
строки разных носителей между собой не сравниваются: сортировка идёт внутри строки разных носителей между собой не сравниваются: сортировка идёт внутри
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался
как «одна точность на всё приложение»: от подгонки формата логов под формат как «одна точность на всё приложение»: от подгонки формата логов под формат
колонки ни одна пара строк не становится сравнимой, зато точность режется до колонки ни одна пара строк не становится сравнимой, зато точность режется до
худшего из носителей. худшего из носителей.
### R4. Локальное время не хранится и не передаётся ### TIME-4. Локальное время не хранится и не передаётся
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
зоне. зоне.
@@ -60,44 +64,45 @@
разберёт час перехода на зимнее время: этот час идёт дважды, две записи разберёт час перехода на зимнее время: этот час идёт дважды, две записи
получают одинаковую метку, и порядок между ними не восстанавливается ничем. получают одинаковую метку, и порядок между ними не восстанавливается ничем.
### R13. Чужой вход нормализуется при разборе, а не отклоняется ### TIME-13. Чужой вход нормализуется при разборе, а не отклоняется
**ДОЛЖЕН.** Валидное по RFC 3339 значение с офсетом, отличным от `Z`, или с **ДОЛЖЕН.** Валидное по RFC 3339 значение с офсетом, отличным от `Z`, или с
долями секунды принимается от внешней системы и приводится к каноническому долями секунды принимается от внешней системы и приводится к каноническому
виду (R1) в точке разбора (R5). виду (TIME-1) в точке разбора (TIME-5).
**Почему.** Канонический вид — обязательство нашего писателя, а не **Почему.** Канонический вид — обязательство нашего писателя, а не
контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же
момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со
стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение
нарушает форму и ширину носителя (R1, R2) и портит сортировку выборочно — нарушает форму и ширину носителя (TIME-1, TIME-2) и портит сортировку
только на записях, пришедших извне, и далеко от места разбора. Нормализация выборочно — только на записях, пришедших извне, и далеко от места разбора.
Нормализация
в единой точке разбора оставляет ровно одно место, где неканонический вид в единой точке разбора оставляет ровно одно место, где неканонический вид
существует, — по ту сторону границы его уже нет. существует, — по ту сторону границы его уже нет.
### R5. Единая точка получения «сейчас», форматирования и разбора ### TIME-5. Единая точка получения «сейчас», форматирования и разбора
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
метки; прямые вызовы часов по коду не разбросаны. метки; прямые вызовы часов по коду не разбросаны.
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех **Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех
меток без исключения, а каждый прямой вызов часов заводит ещё одно место, меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
данных, и обнаруживается, когда испорченных записей уже накопилось. данных, и обнаруживается, когда испорченных записей уже накопилось.
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`). Соображение то же, что для идентификаторов (`arch/db-identifiers.md TIME-3`).
### R6. Дефолтов времени в схеме БД нет ### TIME-6. Дефолтов времени в схеме БД нет
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий **Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
код: значение появляется, но приходит от сервера БД — то есть с других часов код: значение появляется, но приходит от сервера БД — то есть с других часов
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка
падает громко и чинится в момент написания, а не при разборе расхождения падает громко и чинится в момент написания, а не при разборе расхождения
между временем в записи и временем в логе. Правило то же, что для между временем в записи и временем в логе. Правило то же, что для
идентификаторов (`arch/db-identifiers.md R2`). идентификаторов (`arch/db-identifiers.md TIME-2`).
### R7. Длительность — отдельная величина, а не пара меток ### TIME-7. Длительность — отдельная величина, а не пара меток
**ДОЛЖЕН.** Длительность операции записывается числом (обычно **ДОЛЖЕН.** Длительность операции записывается числом (обычно
миллисекундами) в поле вида `duration_ms`. миллисекундами) в поле вида `duration_ms`.
@@ -107,9 +112,9 @@
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
логе; число сравнивается, агрегируется и попадает в перцентили без этого логе; число сравнивается, агрегируется и попадает в перцентили без этого
шага. Кроме того, разность сохранённых меток считается по стенным часам и шага. Кроме того, разность сохранённых меток считается по стенным часам и
наследует их дефект (R9). наследует их дефект (TIME-9).
### R8. Длительность засекает слой, который делает вызов ### TIME-8. Длительность засекает слой, который делает вызов
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
@@ -118,14 +123,14 @@
вызова. В обоих случаях число остаётся правдоподобным и потому не вызова. В обоих случаях число остаётся правдоподобным и потому не
оспаривается, хотя отвечает не на тот вопрос, который к нему задают. оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
### R9. Момент и интервал берутся с разных часов ### TIME-9. Момент и интервал берутся с разных часов
**ДОЛЖЕН.** Источник зависит от того, что записывается: **ДОЛЖЕН.** Источник зависит от того, что записывается:
| № | Величина | Источник | | № | Величина | Источник |
|---|---|---| |---|---|---|
| R9.1 | момент события | стенные часы через единую точку (R5) | | TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) |
| R9.2 | длительность операции | монотонные часы процесса | | TIME-9.2 | длительность операции | монотонные часы процесса |
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда **Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
@@ -135,7 +140,7 @@
упустить: источник меток времени и источник интервалов — разные, даже если упустить: источник меток времени и источник интервалов — разные, даже если
оба называются «часы». оба называются «часы».
### R10. Не-UTC существует только на слое отображения ### TIME-10. Не-UTC существует только на слое отображения
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
проникает в хранение, сортировку и логи. проникает в хранение, сортировку и логи.
@@ -147,7 +152,7 @@
смещение удваивается, результат остаётся похожим на правду, а найти смещение удваивается, результат остаётся похожим на правду, а найти
виновный слой можно только перечитав их все. виновный слой можно только перечитав их все.
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC` ### TIME-11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение **ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
по умолчанию — `UTC`. по умолчанию — `UTC`.
@@ -158,7 +163,7 @@
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
как «зону не задали», а не как «где-то потерялось смещение». как «зону не задали», а не как «где-то потерялось смещение».
### R12. В календарных вычислениях зона указывается явно ### TIME-12. В календарных вычислениях зона указывается явно
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса. явно переданной зоной, а не с системной зоной процесса.
@@ -168,7 +173,7 @@
расхождение не воспроизводится там, где его заметили, и объясняется средой, расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов. а не кодом. Явно переданная зона делает результат функцией от аргументов.
Зона по умолчанию здесь та же, что и для отображения (R11); календарная Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная
логика, которой нужна другая, получает её тем же явным аргументом. логика, которой нужна другая, получает её тем же явным аргументом.
<!-- local:механизировано --> <!-- local:механизировано -->
+27 -26
View File
@@ -1,4 +1,5 @@
--- ---
prefix: GCFG
extends: arch/config.md extends: arch/config.md
--- ---
@@ -13,7 +14,7 @@ extends: arch/config.md
## Правила ## Правила
### R1. Формат конфигурации — TOML ### GCFG-1. Формат конфигурации — TOML
**ДОЛЖЕН.** Конфиг — файл TOML. **ДОЛЖЕН.** Конфиг — файл TOML.
@@ -25,7 +26,7 @@ extends: arch/config.md
поправленный руками на сервере, ломается заметно, а не меняет вложенность поправленный руками на сервере, ломается заметно, а не меняет вложенность
молча. молча.
### R2. Разбор и валидация — целиком в `internal/config` ### GCFG-2. Разбор и валидация — целиком в `internal/config`
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `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`, собранным из **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям. под-структур по секциям.
@@ -50,7 +51,7 @@ extends: arch/config.md
(включена интеграция — заданы все её поля) при этом перестают быть (включена интеграция — заданы все её поля) при этом перестают быть
проверяемыми в одном месте. проверяемыми в одном месте.
### R4. Под-структуры названы по секциям файла ### GCFG-4. Под-структуры названы по секциям файла
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
@@ -60,7 +61,7 @@ extends: arch/config.md
восстанавливается чтением тегов, и проделывать это приходится для каждой восстанавливается чтением тегов, и проделывать это приходится для каждой
секции заново. секции заново.
### R5. Умолчания задаёт `Default()` ### GCFG-5. Умолчания задаёт `Default()`
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх. накладывается поверх.
@@ -72,7 +73,7 @@ extends: arch/config.md
подставляют разное. `Default()` — единственное место, откуда список подставляют разное. `Default()` — единственное место, откуда список
умолчаний читается разом и переносится в образец. умолчаний читается разом и переносится в образец.
### R6. Имя файла фиксировано, путь переопределяется флагом ### GCFG-6. Имя файла фиксировано, путь переопределяется флагом
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории, **СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
путь переопределяет флаг `--config=path`, образец рядом — путь переопределяет флаг `--config=path`, образец рядом —
@@ -85,7 +86,7 @@ extends: arch/config.md
`config.example.toml` вдобавок делает расхождение образца с реальным `config.example.toml` вдобавок делает расхождение образца с реальным
конфигом видимым обычным `diff`, а не вычиткой. конфигом видимым обычным `diff`, а не вычиткой.
### R7. Длительности — собственный тип с `UnmarshalText` ### GCFG-7. Длительности — собственный тип с `UnmarshalText`
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим **ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
`time.Duration`: `time.Duration`:
@@ -105,11 +106,11 @@ func (d Duration) Std() time.Duration { … }
в себе и разбирается тем же `time.ParseDuration`, что и остальной код. в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть У обёртки есть цена: `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`: `os.Getenv`:
``` ```
@@ -137,20 +138,20 @@ func (d Duration) Std() time.Duration { … }
Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят
`syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`. `syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`.
Проверка закрывает обычные способы — те, которыми окружение читают не Проверка закрывает обычные способы — те, которыми окружение читают не
нарочно; сознательный обход она не ловит, и считать R8 полностью нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью
механизированным нельзя. механизированным нельзя.
### R10. За границей приложения запрет не действует ### GCFG-10. За границей приложения запрет не действует
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое **ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
приложение: приложение:
| № | Кто читает | Вердикт | | № | Кто читает | Вердикт |
|---|---|---| |---|---|---|
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | | GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | | GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не **Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
@@ -158,19 +159,19 @@ func (d Duration) Std() time.Duration { … }
лечится `//nolint` наугад: там, где легальные случаи приходится глушить лечится `//nolint` наугад: там, где легальные случаи приходится глушить
руками, вместе с ними проходят и нелегальные. руками, вместе с ними проходят и нелегальные.
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY` ### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY`
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а **Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
исходящих запросов отличается от машины к машине без единого следа в исходящих запросов отличается от машины к машине без единого следа в
конфиге и в образце, а расследование начинается с вопроса «почему на конфиге и в образце, а расследование начинается с вопроса «почему на
сервере ходит не так, как локально». сервере ходит не так, как локально».
### R12. Проблемы конфига собираются `errors.Join` ### GCFG-12. Проблемы конфига собираются `errors.Join`
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`. ошибка, собранная `errors.Join`.
@@ -181,7 +182,7 @@ func (d Duration) Std() time.Duration { … }
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
вложенной проблеме. вложенной проблеме.
### R13. Имя зоны проверяется `time.LoadLocation` ### GCFG-13. Имя зоны проверяется `time.LoadLocation`
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
@@ -189,9 +190,9 @@ func (d Duration) Std() time.Duration { … }
тогда, когда база зон его знает, и никакая проверка формата не отличит тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо доживает до первого форматирования времени — то есть до рантайма, мимо
fail-fast (R15). fail-fast (GCFG-15).
### R14. `time/tzdata` импортируется в `main` ### GCFG-14. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете. пакете.
@@ -199,11 +200,11 @@ fail-fast (R15).
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому **Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
неё тот же конфиг валиден на машине разработчика и падает в контейнере без неё тот же конфиг валиден на машине разработчика и падает в контейнере без
zoneinfo, а сообщение указывает не на ту причину. zoneinfo, а сообщение указывает не на ту причину.
### R15. Невалидный конфиг — `ERROR` и выход из `main` ### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main`
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров. старта серверов и воркеров.
+22 -21
View File
@@ -1,4 +1,5 @@
--- ---
prefix: GKEY
extends: arch/db-identifiers.md extends: arch/db-identifiers.md
--- ---
@@ -7,18 +8,18 @@ extends: arch/db-identifiers.md
Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи — Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи —
`LANGUAGE.md`. `LANGUAGE.md`.
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он Единая точка из `KEYS-3` — пакет `internal/ident`: он
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся. (`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
## Правила ## Правила
### R1. Генерация и разбор — только через `internal/ident` ### GKEY-1. Генерация и разбор — только через `internal/ident`
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета **ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
`internal/ident`; других генераторов и парсеров id в коде нет. `internal/ident`; других генераторов и парсеров id в коде нет.
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов **Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
выглядит нарушением: значение получается валидное, просто мимо нормализации выглядит нарушением: значение получается валидное, просто мимо нормализации
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
@@ -26,12 +27,12 @@ ULID-библиотеки — одна строка, доступная из л
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
перестанет находить существующую запись. перестанет находить существующую запись.
### R2. Первичный ключ генерируется в `Create`-методах store ### GKEY-2. Первичный ключ генерируется в `Create`-методах store
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` **ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
внутри `Create`-метода слоя store. внутри `Create`-метода слоя store.
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было **Почему.** `KEYS-2` требует, чтобы значение было
известно до вставки, но не говорит, кто его присваивает. Store — последний известно до вставки, но не говорит, кто его присваивает. Store — последний
слой, через который проходят все пути создания строки, включая импорт, слой, через который проходят все пути создания строки, включая импорт,
фоновые задания и тесты. Генерация выше по стеку делает присвоение фоновые задания и тесты. Генерация выше по стеку делает присвоение
@@ -39,18 +40,18 @@ ULID-библиотеки — одна строка, доступная из л
строку в колонку ключа: для строкового PK это валидное значение, база его строку в колонку ключа: для строкового PK это валидное значение, база его
не отклонит, и дефект обнаружится на второй такой вставке. не отклонит, и дефект обнаружится на второй такой вставке.
### R3. Прочие идентификаторы генерируются в точке начала операции ### GKEY-3. Прочие идентификаторы генерируются в точке начала операции
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся **ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
вызовом `ident.NewID()` там, где операция начинается. вызовом `ident.NewID()` там, где операция начинается.
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) — **Почему.** Смысл такого идентификатора (`KEYS-7`) —
сшивать записи лога всей операции. Созданный ниже по стеку или в момент сшивать записи лога всей операции. Созданный ниже по стеку или в момент
первой записи в базу, он не покрывает начальные шаги — а именно они нужны, первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
когда операция упала до того, как что-либо записала: без общего ключа эти когда операция упала до того, как что-либо записала: без общего ключа эти
записи из лога не собираются вообще. записи из лога не собираются вообще.
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)` ### GKEY-4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в **ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
Go-миграции, порождаются с историческим временем строки, а не с текущим. Go-миграции, порождаются с историческим временем строки, а не с текущим.
@@ -62,18 +63,18 @@ Go-миграции, порождаются с историческим врем
Исправить это потом нельзя: исходное время в идентификаторе не Исправить это потом нельзя: исходное время в идентификаторе не
восстановить. восстановить.
### R5. Разбор — на входных границах, до обращения к store ### GKEY-5. Разбор — на входных границах, до обращения к store
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или **ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
callback'а бота — раньше, чем идентификатор попадёт в store. callback'а бота — раньше, чем идентификатор попадёт в store.
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана **Почему.** Реализация `KEYS-5`. Граница выбрана
транспортная, потому что только на ней известен источник значения, от транспортная, потому что только на ней известен источник значения, от
которого зависит реакция (R8): store видит одинаковую строку независимо от которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
того, пришла она из URL или из собственной формы, и ответить по-разному того, пришла она из URL или из собственной формы, и ответить по-разному
оттуда уже невозможно. оттуда уже невозможно.
### R6. Id в структурах — обычный `string` ### GKEY-6. Id в структурах — обычный `string`
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип **СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
`string`. `string`.
@@ -84,35 +85,35 @@ callback'а бота — раньше, чем идентификатор поп
параметров. Зато он требует конверсий на каждой границе с sql-драйвером, параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
json и шаблонами, то есть даёт цену без выгоды. json и шаблонами, то есть даёт цену без выгоды.
### R7. Отдельный тип — когда появляется вторая семья идентификаторов ### GKEY-7. Отдельный тип — когда появляется вторая семья идентификаторов
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые **ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
можно перепутать, для них заводятся различимые типы. можно перепутать, для них заводятся различимые типы.
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на **Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
типизацию навсегда. Условие названо ровно то, при котором тип начинает типизацию навсегда. Условие названо ровно то, при котором тип начинает
работать: пока все идентификаторы — `string`, подстановка одного вида работать: пока все идентификаторы — `string`, подстановка одного вида
вместо другого компилируется и обнаруживается только на данных. вместо другого компилируется и обнаруживается только на данных.
### R8. Реакция на невалидный id зависит от источника ### GKEY-8. Реакция на невалидный id зависит от источника
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло **ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
значение: значение:
| № | Источник | Ответ | | № | Источник | Ответ |
|---|---|---| |---|---|---|
| R8.1 | путь или query URL | 404 без обращения к store | | GKEY-8.1 | путь или query URL | 404 без обращения к store |
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | | GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах **Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи — HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2 и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
значение сформировало само приложение, и невалидность означает баг значение сформировало само приложение, и невалидность означает баг
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно, интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
в логах не оставляет аномалии и тем самым съедает единственный момент, в логах не оставляет аномалии и тем самым съедает единственный момент,
когда дефект заметен. когда дефект заметен.
### R9. Транспорт не создаёт доменные ошибки ### GKEY-9. Транспорт не создаёт доменные ошибки
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например **НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом. `ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
+25 -21
View File
@@ -1,3 +1,7 @@
---
prefix: MIGR
---
# Схема и миграции (SQLite, Go) # Схема и миграции (SQLite, Go)
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
@@ -12,7 +16,7 @@ Go-приложении. Форма записи — `LANGUAGE.md`.
## Миграции ## Миграции
### R1. Миграции ведёт goose ### MIGR-1. Миграции ведёт goose
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
goose. goose.
@@ -24,7 +28,7 @@ goose.
существующую таблицу. На сервере это означает ручной разбор состояния существующую таблицу. На сервере это означает ручной разбор состояния
схемы вместо автоматического деплоя. схемы вместо автоматического деплоя.
### R2. Файлы миграций лежат рядом со store-слоем ### MIGR-2. Файлы миграций лежат рядом со store-слоем
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
схемой. схемой.
@@ -35,14 +39,14 @@ goose.
код без миграции, либо миграция без кода; расходятся они на сервере, где код без миграции, либо миграция без кода; расходятся они на сервере, где
схема ещё старая. схема ещё старая.
### R3. Форма миграции выбирается по тому, нужен ли код ### MIGR-3. Форма миграции выбирается по тому, нужен ли код
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое: **ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
| № | Что делает миграция | Форма | | № | Что делает миграция | Форма |
|---|---|---| |---|---|---|
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | | MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | | MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, **Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
@@ -50,12 +54,12 @@ goose.
Обратное направление дороже. Перенос данных и генерация идентификаторов Обратное направление дороже. Перенос данных и генерация идентификаторов
выражаются на SQL либо громоздко, либо неточно: идентификатор по выражаются на SQL либо громоздко, либо неточно: идентификатор по
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена `KEYS-2` порождает приложение, и SQL-миграция вынуждена
завести для него второй генератор — ровно то, что запрещает завести для него второй генератор — ровно то, что запрещает
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается `KEYS-3`. Единообразие формы здесь покупается
дублированием логики, которая уже есть в коде. дублированием логики, которая уже есть в коде.
### R4. В деплое схема движется только вперёд ### MIGR-4. В деплое схема движется только вперёд
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
ошибка исправляется новой миграцией вперёд. ошибка исправляется новой миграцией вперёд.
@@ -67,14 +71,14 @@ goose.
следующей миграцией, оставляет целыми и данные, и журнал применённых следующей миграцией, оставляет целыми и данные, и журнал применённых
версий. версий.
### R5. Down пишется, когда он честно обращает up ### MIGR-5. Down пишется, когда он честно обращает up
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up: **ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
| № | Что делает up | Down | | № | Что делает up | Down |
|---|---|---| |---|---|---|
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | | MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| R5.2 | необратимо преобразует данные | не пишется | | MIGR-5.2 | необратимо преобразует данные | не пишется |
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, **Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
и именно там он обязан действительно обращать up. Имитация опаснее и именно там он обязан действительно обращать up. Имитация опаснее
@@ -83,7 +87,7 @@ goose.
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
отладка по данным, которых уже нет. отладка по данным, которых уже нет.
### R6. ER-схема обновляется в том же изменении ### MIGR-6. ER-схема обновляется в том же изменении
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
изменением. изменением.
@@ -99,7 +103,7 @@ down останавливает сразу и заставляет пересо
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
а не язык приложения. а не язык приложения.
### R7. Enum-поля — `TEXT`, допустимые значения держит код ### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
без `CHECK`-ограничения на список значений. без `CHECK`-ограничения на список значений.
@@ -115,7 +119,7 @@ down останавливает сразу и заставляет пересо
таблицы соответствия, которую пришлось бы держать в голове для числового таблицы соответствия, которую пришлось бы держать в голове для числового
кода. кода.
### R8. Метки времени — `TEXT` в формате из `arch/time.md` ### MIGR-8. Метки времени — `TEXT` в формате из `arch/time.md`
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
пишутся в формате из `arch/time.md`. пишутся в формате из `arch/time.md`.
@@ -127,7 +131,7 @@ down останавливает сразу и заставляет пересо
преобразования, а значит и без потери индекса. Соседство двух форматов в преобразования, а значит и без потери индекса. Соседство двух форматов в
одной колонке ломает и сравнение, и разбор на стороне Go. одной колонке ломает и сравнение, и разбор на стороне 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`, Вдобавок `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. **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
@@ -152,10 +156,10 @@ down останавливает сразу и заставляет пересо
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
проверяют, что список не пуст. проверяют, что список не пуст.
### R11. Первичный ключ новой таблицы — TEXT ULID ### MIGR-11. Первичный ключ новой таблицы — TEXT ULID
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
приложения (`arch/db-identifiers.md` R1, R2). приложения (`KEYS-1`, `KEYS-2`).
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение, **Почему.** Здесь конвенция схемы ничего не решает — она реализует решение,
принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие
@@ -165,7 +169,7 @@ down останавливает сразу и заставляет пересо
`AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на `AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на
`INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего. `INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего.
### R12. Целочисленный ключ идёт вместе с `AUTOINCREMENT` ### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT`
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
@@ -178,7 +182,7 @@ down останавливает сразу и заставляет пересо
Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой
вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает: вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
там ключ строковый (R11). там ключ строковый (MIGR-11).
<!-- local:механизировано --> <!-- local:механизировано -->
<!-- /local --> <!-- /local -->
+72 -67
View File
@@ -1,3 +1,7 @@
---
prefix: GERR
---
# Ошибки # Ошибки
Как ошибки строятся, оборачиваются и проверяются. Форма записи — Как ошибки строятся, оборачиваются и проверяются. Форма записи —
@@ -17,22 +21,22 @@
## Правила ## Правила
### R1. Ошибки строятся средствами стандартной библиотеки ### GERR-1. Ошибки строятся средствами стандартной библиотеки
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и **ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются. `fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. **Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки `slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
это цена без покупателя. это цена без покупателя.
Единственное место, где стек всё-таки нужен, — восстановленная паника: у Единственное место, где стек всё-таки нужен, — восстановленная паника: у
неё цепочки `%w` нет вовсе (R23). неё цепочки `%w` нет вовсе (GERR-23).
### R2. Дефолт не обходится точечно ### GERR-2. Дефолт не обходится точечно
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте **НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
кодовой базы ради конкретной отладки. кодовой базы ради конкретной отладки.
@@ -41,39 +45,39 @@
перестаёт знать, какой перед ним: обёртки склеиваются по-разному, перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая `errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный локально, перестаёт накапливаться — а накопление и есть единственный
сигнал, что решение R1 пора пересматривать целиком. сигнал, что решение GERR-1 пора пересматривать целиком.
### R3. Каждый слой добавляет свой контекст ### GERR-3. Каждый слой добавляет свой контекст
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с **ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: `fmt.Errorf("parse magnet: %w", err)`. контекстом: `fmt.Errorf("parse magnet: %w", err)`.
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько, **Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
стирает участок пути — по итоговому сообщению нельзя сказать, через какую стирает участок пути — по итоговому сообщению нельзя сказать, через какую
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
кода. кода.
### R4. Обёртка по умолчанию — `%w` ### GERR-4. Обёртка по умолчанию — `%w`
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину **СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
вызывающему: вызывающему:
| № | Ситуация | Глагол | | № | Ситуация | Глагол |
|---|---|---| |---|---|---|
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | | GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| R4.2 | причину сознательно не раскрываем | `%v` | | GERR-4.2 | причину сознательно не раскрываем | `%v` |
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка **Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями. становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у `errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил (GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда `ErrNotFound`», далеко от места обрыва. GERR-4.2 остаётся для случая, когда
завязывать вызывающего на чужой тип ошибки не хотят намеренно. завязывать вызывающего на чужой тип ошибки не хотят намеренно.
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v` ### GERR-5. Утечка внутренних деталей лечится трансляцией, а не `%v`
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю **НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
ошибку наружу. ошибку наружу.
@@ -82,9 +86,9 @@
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
детали утекут при любом глаголе. Подмена не решает задачу, ради которой детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
Настоящее место защиты — R13. Настоящее место защиты — GERR-13.
### R6. Текст обёртки — со строчной буквы и без служебных слов ### GERR-6. Текст обёртки — со строчной буквы и без служебных слов
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». **СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
@@ -94,15 +98,15 @@
нами ошибка, известно из того, что это ошибка. Зато повторяются они на нами ошибка, известно из того, что это ошибка. Зато повторяются они на
каждом уровне и вытесняют из строки полезный контекст. каждом уровне и вытесняют из строки полезный контекст.
### R7. Контекст обёртки называет операцию или субъект ### GERR-7. Контекст обёртки называет операцию или субъект
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. **СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something **Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
failed» не сужает ничего и при этом занимает в сообщении место, которое мог failed» не сужает ничего и при этом занимает в сообщении место, которое мог
бы занять единственный полезный здесь факт — имя операции. бы занять единственный полезный здесь факт — имя операции.
### R8. Слой не повторяет смысл нижнего ### GERR-8. Слой не повторяет смысл нижнего
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: **НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. `"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
@@ -115,10 +119,10 @@ failed» не сужает ничего и при этом занимает в
## Две трансляции ## Две трансляции
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная → Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
доменная у источника (R9) и доменная → пользовательская на внешней границе доменная у источника (GERR-9) и доменная → пользовательская на внешней границе
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт. (GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
### R9. Инфраструктурная ошибка транслируется в доменную у источника ### GERR-9. Инфраструктурная ошибка транслируется в доменную у источника
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где **ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для
@@ -131,14 +135,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
состояние «нет записи» одно и то же. Трансляция у источника оставляет состояние «нет записи» одно и то же. Трансляция у источника оставляет
знание о зависимости в единственном слое, который её и так знает. знание о зависимости в единственном слое, который её и так знает.
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему ### GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так: **ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
| № | Что нужно вызывающему | Форма | | № | Что нужно вызывающему | Форма |
|---|---|---| |---|---|---|
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | | GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | | GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от **Почему.** Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных, структуры ошибки и переживает добавление полей. Тип заводится ради данных,
@@ -147,14 +151,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
каждой проверке. Две формы для одного условия — это два способа его каждой проверке. Две формы для одного условия — это два способа его
проверить, и про второй рано или поздно забудут. проверить, и про второй рано или поздно забудут.
### R11. Матчинг по тексту сообщения ### GERR-11. Матчинг по тексту сообщения
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. **НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его **Почему.** Текст сообщения — не контракт: GERR-6GERR-8 разрешают
свободно. Правка формулировки в нижнем слое молча ломает ветвление переписывать его свободно. Правка формулировки в нижнем слое молча ломает
наверху, и компилятор этого не видит. Это то же самое, что публичный API из ветвление наверху, и компилятор этого не видит. Это то же самое, что
строки лога. публичный API из строки лога.
## Граница: приватный канал и публичный ## Граница: приватный канал и публичный
@@ -162,39 +166,39 @@ HTTP-клиентов, файловой системы, внешних SDK.
того, кто канал видит: приватный канал — логи (их читает владелец сервиса), того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
публичный — пользовательские поверхности (HTTP API, web-UI, бот). публичный — пользовательские поверхности (HTTP API, web-UI, бот).
### R12. Полная ошибка идёт в приватный канал ### GERR-12. Полная ошибка идёт в приватный канал
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно **ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
`lang/go/logging.md`. `lang/go/logging.md`.
**Почему.** Цепочка — единственный носитель диагностики (R1), и **Почему.** Цепочка — единственный носитель диагностики (GERR-1), и
единственный канал, где её можно показать целиком, — тот, который видит единственный канал, где её можно показать целиком, — тот, который видит
владелец. Не записанная там, она не сохранится нигде: наружу идёт владелец. Не записанная там, она не сохранится нигде: наружу идёт
нейтральное сообщение (R13), и восстанавливать причину будет не из чего. нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
### R13. Публичная поверхность получает сообщение по доменной ошибке ### GERR-13. Публичная поверхность получает сообщение по доменной ошибке
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не **ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
`err.Error()` и не детали реализации (`database/sql`, пути, стек). `err.Error()` и не детали реализации (`database/sql`, пути, стек).
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны **Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
у него есть лог (R12). Зато они раскрывают устройство системы — имена у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
причём раскрывают именно в момент, когда что-то пошло не так. причём раскрывают именно в момент, когда что-то пошло не так.
### R14. Публичное сообщение несёт корреляционный ключ ### GERR-14. Публичное сообщение несёт корреляционный ключ
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`: **ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка». ошибка».
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его **Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его
обращение звучит как «у меня что-то не работает», и владелец ищет запись в обращение звучит как «у меня что-то не работает», и владелец ищет запись в
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
видел. видел.
### R15. Маппинг доменных ошибок — в одной точке на все транспорты ### GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус» **ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
задаётся один раз; транспорт без статусов (бот) берёт из него только задаётся один раз; транспорт без статусов (бот) берёт из него только
@@ -203,13 +207,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, **Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
важнее: единственная точка — это место, куда механически дописывается новая важнее: единственная точка — это место, куда механически дописывается новая
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде» ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
ничем не проверяет. ничем не проверяет.
### R16. Новая штатная ветвь отказа сразу попадает в маппинг ### GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и **ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
добавляется в маппинг (R15) тем же изменением. добавляется в маппинг (GERR-15) тем же изменением.
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 **Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает «внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
@@ -219,14 +223,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
<!-- local:маппинг --> <!-- local:маппинг -->
<!-- /local --> <!-- /local -->
### R25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком ### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (R15) нет ветви, отдаёт **ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
признаком того, что маппинг её не знает. признаком того, что маппинг её не знает.
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли **Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки R16. Адресат у неё владелец в смысле «надо чинить», отсюда завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
`ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус `ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в тоже не выбирается: известное пользовательское состояние лежало бы в
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
@@ -238,18 +242,18 @@ HTTP-клиентов, файловой системы, внешних SDK.
находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как
механизм обнаружения, а не как шум. механизм обнаружения, а не как шум.
### R17. Форма текста определяется поверхностью ### GERR-17. Форма текста определяется поверхностью
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого **ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
текста для них разное: текста для них разное:
| № | Поверхность | Текст ошибки | | № | Поверхность | Текст ошибки |
|---|---|---| |---|---|---|
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт | | GERR-17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (GERR-15); `err.Error()` наружу не идёт |
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец | | 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 ## panic
### R20. `panic` — только для невосстановимого ### GERR-20. `panic` — только для невосстановимого
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и **ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
ошибка инициализации, из которой нельзя стартовать. ошибка инициализации, из которой нельзя стартовать.
@@ -293,25 +297,25 @@ HTTP-клиентов, файловой системы, внешних SDK.
инвариантом опаснее падения, а сервис, стартовавший без обязательной инвариантом опаснее падения, а сервис, стартовавший без обязательной
зависимости, всё равно откажет позже и непонятнее. зависимости, всё равно откажет позже и непонятнее.
### R21. Ожидаемые ошибки — значения `error` ### GERR-21. Ожидаемые ошибки — значения `error`
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, **НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как `error`. плохой ввод, отсутствующая запись возвращаются как `error`.
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном **Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
его обработать. Дальше такая паника долетает до recover-границы (R22), где его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
«мы сломались». «мы сломались».
### R22. `recover` — на верхней границе каждой обрабатывающей единицы ### GERR-22. `recover` — на верхней границе каждой обрабатывающей единицы
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный: **ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
| № | Единица | Зачем `recover` | | № | Единица | Зачем `recover` |
|---|---|---| |---|---|---|
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | | GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | | GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
**Почему.** `recover` работает только в той горутине, где случилась паника, **Почему.** `recover` работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
@@ -321,26 +325,26 @@ HTTP-клиентов, файловой системы, внешних SDK.
без своего `recover` уходит мимо структурированного лога, а клиент получает без своего `recover` уходит мимо структурированного лога, а клиент получает
оборванное соединение вместо ответа. оборванное соединение вместо ответа.
### R23. Recover-граница пишет `debug.Stack()` ### GERR-23. Recover-граница пишет `debug.Stack()`
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. **ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной **Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной
паники цепочки `%w` нет вовсе. «index out of range» без стека не паники цепочки `%w` нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции, диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало. по нему нельзя сказать даже, в каком пакете упало.
## Несколько ошибок ## Несколько ошибок
### R26. После `recover` единица продолжает работу, исключив упавшее ### GERR-26. После `recover` единица продолжает работу, исключив упавшее
**ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит **ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит
граница: граница:
| № | Где перехвачена паника | Что дальше | | № | Где перехвачена паника | Что дальше |
|---|---|---| |---|---|---|
| R26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются | | GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| R26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся | | GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о **Почему.** Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, — баге в работе с данными этого элемента, а не о порче общего состояния, —
@@ -348,7 +352,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
не буквально: в OTP падает изолированный процесс под супервизором, а не узел не буквально: в OTP падает изолированный процесс под супервизором, а не узел
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
граница процесса. Обратное при этом верно и делает `recover` в цикле граница процесса. Обратное при этом верно и делает `recover` в цикле
обязательным (R22): неперехваченная паника в любой горутине завершает весь обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
процесс. процесс.
Продолжать, не исключив упавший элемент, нельзя: детерминированная паника Продолжать, не исключив упавший элемент, нельзя: детерминированная паника
@@ -359,16 +363,16 @@ HTTP-клиентов, файловой системы, внешних SDK.
строку состоянием, — механизм для этого уже есть, заводить отдельный не строку состоянием, — механизм для этого уже есть, заводить отдельный не
нужно. нужно.
Оговорка «если ответ ещё не начат» в R26.1 не формальность: статус Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус
отправляется один раз, и после первой записи в тело поменять его нечем — отправляется один раз, и после первой записи в тело поменять его нечем —
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
ответ целиком до записи там, где это возможно. ответ целиком до записи там, где это возможно.
Из R26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать Из GERR-26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать
обработку намеренно», и recover-обёртка пробрасывает его дальше, а не обработку намеренно», и recover-обёртка пробрасывает его дальше, а не
превращает в 500. Так поступают и стандартные обёртки вроде chi. превращает в 500. Так поступают и стандартные обёртки вроде chi.
### R24. Независимые ошибки собираются `errors.Join` ### GERR-24. Независимые ошибки собираются `errors.Join`
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через `errors.Is`. разом; проверка собранного — по-прежнему через `errors.Is`.
@@ -377,12 +381,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: `errors.Is` по такому результату не тот же список, но убивает ветвление: `errors.Is` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
(R11). (GERR-11).
## Связано ## Связано
- `lang/go/logging.md` — где и когда ошибка попадает в лог. - `lang/go/logging.md` — где и когда ошибка попадает в лог.
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14. - `KEYS-7` (`arch/db-identifiers.md`) — формат корреляционного ключа
из `GERR-14`.
<!-- local:механизировано --> <!-- local:механизировано -->
<!-- /local --> <!-- /local -->
+91 -88
View File
@@ -1,4 +1,5 @@
--- ---
prefix: SLOG
extends: arch/time.md extends: arch/time.md
--- ---
@@ -19,7 +20,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
## Формат записи ## Формат записи
### R1. Структурированный JSON, один формат для dev и prod ### SLOG-1. Структурированный JSON, один формат для dev и prod
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в **ДОЛЖЕН.** Хендлер — `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` **ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
(см. `lang/go/time.md`). (см. `lang/go/time.md`).
@@ -58,7 +59,7 @@ dev-выводом перестаёшь ежедневно гонять собс
## Сообщение ## Сообщение
### R4. `msg` — константа в нижнем регистре ### SLOG-4. `msg` — константа в нижнем регистре
**ДОЛЖЕН.** Текст сообщения не собирается из переменных: **ДОЛЖЕН.** Текст сообщения не собирается из переменных:
`log.Info("download accepted", "download_id", id)`. `log.Info("download accepted", "download_id", id)`.
@@ -69,7 +70,7 @@ dev-выводом перестаёшь ежедневно гонять собс
одна категория не двоилась на варианты, различающиеся только заглавной одна категория не двоилась на варианты, различающиеся только заглавной
буквой. буквой.
### R5. `msg` не несёт префикса подсистемы ### SLOG-5. `msg` не несёт префикса подсистемы
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — **НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
отдельное поле. отдельное поле.
@@ -80,7 +81,7 @@ dev-выводом перестаёшь ежедневно гонять собс
категория дробится на варианты с префиксом и без, а совпадать они обязаны категория дробится на варианты с префиксом и без, а совпадать они обязаны
посимвольно. посимвольно.
### R6. Смена состояния сущности — единая категория ### SLOG-6. Смена состояния сущности — единая категория
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно **ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
состояние и по какой причине — данные, а не текст. состояние и по какой причине — данные, а не текст.
@@ -91,37 +92,37 @@ dev-выводом перестаёшь ежедневно гонять собс
останется неполной. Единая категория даёт весь цикл одним фильтром и не останется неполной. Единая категория даёт весь цикл одним фильтром и не
требует обновлять запрос вслед за кодом. требует обновлять запрос вслед за кодом.
### R7. Физический эффект — отдельная запись, а не вместо перехода ### SLOG-7. Физический эффект — отдельная запись, а не вместо перехода
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет **НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
запись самого перехода. запись самого перехода.
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых **Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
строки в логе; восстановление пропущенного перехода не стоит ничего, потому строки в логе; восстановление пропущенного перехода не стоит ничего, потому
что невозможно. что невозможно.
## Уровни ## Уровни
### R8. Уровень выбирается по адресату ### SLOG-8. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько **ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
громко сломалось». громко сломалось».
| № | Уровень | Кому и когда | | № | Уровень | Кому и когда |
|---|---|---| |---|---|---|
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен | | SLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| R8.2 | `INFO` | владельцу, аудит постфактум | | SLOG-8.2 | `INFO` | владельцу, аудит постфактум |
| R8.3 | `WARN` | владельцу, «может стать проблемой» | | SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
| R8.4 | `ERROR` | владельцу, в разбор | | SLOG-8.4 | `ERROR` | владельцу, в разбор |
**Почему.** Адресат — единственный признак, по которому разные авторы в **Почему.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
базовый порог в проде (R40), потому что он отсекает уже не то, что базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
задумано. задумано.
### R9. Уровень не зависит от подсистемы ### SLOG-9. Уровень не зависит от подсистемы
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` **НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
везде одинаково серьёзен. везде одинаково серьёзен.
@@ -132,7 +133,7 @@ dev-выводом перестаёшь ежедневно гонять собс
уровень перестаёт быть фильтром и становится подсказкой, требующей знания уровень перестаёт быть фильтром и становится подсказкой, требующей знания
кода. кода.
### R10. `WARN` — только когда «может стать проблемой» ### SLOG-10. `WARN` — только когда «может стать проблемой»
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. **ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
@@ -141,22 +142,22 @@ dev-выводом перестаёшь ежедневно гонять собс
единственное, ради чего уровень существует: предупреждение, на которое ещё единственное, ради чего уровень существует: предупреждение, на которое ещё
есть время отреагировать. есть время отреагировать.
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG` ### SLOG-11. Событийное — `INFO`, рутинно-частое — `DEBUG`
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие. **ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
| № | Операция | Уровень | | № | Операция | Уровень |
|---|---|---| |---|---|---|
| R11.1 | по реальному действию или изменению | `INFO` | | SLOG-11.1 | по реальному действию или изменению | `INFO` |
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | | SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность **Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
определяется долей записей, за которыми что-то стоит. Периодическая определяется долей записей, за которыми что-то стоит. Периодическая
операция даёт ровный поток при нулевой информации, в котором настоящие операция даёт ровный поток при нулевой информации, в котором настоящие
события тонут количественно: их не отфильтровать, потому что фильтровать события тонут количественно: их не отфильтровать, потому что фильтровать
приходится по содержанию, а не по уровню. приходится по содержанию, а не по уровню.
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата ### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую **ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
степень даёт завершение процесса. степень даёт завершение процесса.
@@ -169,7 +170,7 @@ dev-выводом перестаёшь ежедневно гонять собс
## Поля: единый словарь ## Поля: единый словарь
### R13. Одно поле — одно имя по всему коду ### SLOG-13. Одно поле — одно имя по всему коду
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. **ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
@@ -178,14 +179,14 @@ dev-выводом перестаёшь ежедневно гонять собс
часть записей в него не попадёт, и заметить это можно, только заранее зная, часть записей в него не попадёт, и заметить это можно, только заранее зная,
что они должны были быть. что они должны были быть.
### R14. Форма имени зависит от вида поля ### SLOG-14. Форма имени зависит от вида поля
**ДОЛЖЕН.** Две формы, третьей нет. **ДОЛЖЕН.** Две формы, третьей нет.
| № | Вид поля | Форма имени | | № | Вид поля | Форма имени |
|---|---|---| |---|---|---|
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | | SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | | SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые **Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
в любом проекте, от доменных, которые в каждом свои: по общему префиксу в любом проекте, от доменных, которые в каждом свои: по общему префиксу
@@ -193,7 +194,7 @@ dev-выводом перестаёшь ежедневно гонять собс
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже словаря 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) | | SLOG-16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — пока его значение различается между записями (SLOG-17) |
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты | | SLOG-16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
| R16.3 | запись об ошибке | `error` | | SLOG-16.3 | запись об ошибке | `error` |
| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | | SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает **Почему.** Набор задан не «на всякий случай»: без него запись не отвечает
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас `ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
баг», запись о сущности без идентификатора не корреллируется (R19). Полный баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
набор делает записи однородными — один запрос работает по всем вызовам, а набор делает записи однородными — один запрос работает по всем вызовам, а
не по тем, где автор вспомнил про поле. не по тем, где автор вспомнил про поле.
### R17. `service.*` и `host.*` не заводим ### SLOG-17. `service.*` и `host.*` не заводим
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не **НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`. заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке **Почему.** Такое поле не несёт информации, но стоит места в каждой строке
и внимания при чтении. Критерий один на все поля словаря — им же решается, и внимания при чтении. Критерий один на все поля словаря — им же решается,
нужен ли `transport` (R16.1): пока транспорт один, поле постоянно. Условие нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
названо явно, поэтому правило отпадёт вместе со своей причиной: с названо явно, поэтому правило отпадёт вместе со своей причиной: с
появлением нескольких инстансов различающее поле (`service.version`) появлением нескольких инстансов различающее поле (`service.version`)
добавляется одной строкой при старте. добавляется одной строкой при старте.
@@ -238,7 +239,7 @@ dev-выводом перестаёшь ежедневно гонять собс
## Корреляция ## Корреляция
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id` ### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id`
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у **НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
@@ -251,13 +252,13 @@ dev-выводом перестаёшь ежедневно гонять собс
способ спросить об одном. Условие применимости названо: там, где сущности способ спросить об одном. Условие применимости названо: там, где сущности
со стабильным идентификатором нет, связывать записи больше нечем. со стабильным идентификатором нет, связывать записи больше нечем.
### R19. Запись о сущности несёт её идентификатор ### SLOG-19. Запись о сущности несёт её идентификатор
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности. **ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
**Почему.** Принадлежность записи восстанавливается только в момент **Почему.** Принадлежность записи восстанавливается только в момент
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем, Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
что идентификатор стоит везде, а не в удобных местах. что идентификатор стоит везде, а не в удобных местах.
Все записи одной операции собираются одним фильтром: Все записи одной операции собираются одним фильтром:
@@ -265,7 +266,7 @@ dev-выводом перестаёшь ежедневно гонять собс
глобально уникален across сущностей, штатно работает и простой `grep` по глобально уникален 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)`. **ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и **Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
зависеть от того, кто писал конкретный вызов, и ради этого единообразия зависеть от того, кто писал конкретный вызов, и ради этого единообразия
краткостью жертвуют. краткостью жертвуют.
### R22. Промежуточный слой либо логирует, либо возвращает ### SLOG-22. Промежуточный слой либо логирует, либо возвращает
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только **НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
оборачивает (`%w`). оборачивает (`%w`).
@@ -300,44 +301,44 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, **Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
и количество `ERROR` перестаёт соответствовать количеству отказов — а и количество `ERROR` перестаёт соответствовать количеству отказов — а
считают именно его. Контекст при этом не теряется: он накапливается в считают именно его. Контекст при этом не теряется: он накапливается в
цепочке обёрток и попадает в единственную запись на границе (R23). цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
### R23. Ошибка логируется один раз — на границе доменного слоя ### SLOG-23. Ошибка логируется один раз — на границе доменного слоя
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. **ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и **Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
этим местом выбрана доменная граница, а не транспорт, потому что там этим местом выбрана доменная граница, а не транспорт, потому что там
известен исход операции целиком и, значит, класс отказа (R25) — транспорт известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
транспорты остаются тонкими. транспорты остаются тонкими.
<!-- local:границы --> <!-- local:границы -->
<!-- /local --> <!-- /local -->
### R24. Транспорт не логирует ошибку повторно ### SLOG-24. Транспорт не логирует ошибку повторно
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ **НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и на этом останавливается. (статус, сообщение пользователю) и на этом останавливается.
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё **Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
только формулировкой и читается как второй сбой. Когда транспортов над только формулировкой и читается как второй сбой. Когда транспортов над
одним доменом несколько, дублирование ещё и множится, а расследование одним доменом несколько, дублирование ещё и множится, а расследование
начинается с вопроса, один это инцидент или два. начинается с вопроса, один это инцидент или два.
### R25. Уровень доменного отказа — по классу отказа ### SLOG-25. Уровень доменного отказа — по классу отказа
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по **ДОЛЖЕН.** Уровень выбирает единственный логирующий (SLOG-23), и выбирает по
классу, а не по месту в коде. Классификация покрывает **доменные** отказы — классу, а не по месту в коде. Классификация покрывает **доменные** отказы —
те, что операция вернула значением `error`. те, что операция вернула значением `error`.
| № | Класс отказа | Кому | Уровень | | № | Класс отказа | Кому | Уровень |
|---|---|---|---| |---|---|---|---|
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | | SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | | SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | | SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на **Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
экране — владельцу разбирать нечего; целостность первичных данных отделяет экране — владельцу разбирать нечего; целостность первичных данных отделяет
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный «надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
уровень для одного и того же отказа в зависимости от того, какой транспорт уровень для одного и того же отказа в зависимости от того, какой транспорт
@@ -350,9 +351,9 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё
нет, потому что её просто забыли завести. Она логируется `ERROR` с нет, потому что её просто забыли завести. Она логируется `ERROR` с
признаком непокрытой (`lang/go/errors.md` R25). признаком непокрытой (`GERR-25`).
### R26. Тот же отказ в асинхронной стадии — уровнем выше ### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован **ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
@@ -363,7 +364,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
никто, задача осталась недоведённой, и лог — единственное место, где это никто, задача осталась недоведённой, и лог — единственное место, где это
вообще проявится. вообще проявится.
### R27. Повторяющийся сбой фонового цикла — `WARN` ### SLOG-27. Повторяющийся сбой фонового цикла — `WARN`
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: **ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
уровень задаёт наличие штатного повтора, а не текст ошибки. уровень задаёт наличие штатного повтора, а не текст ошибки.
@@ -376,32 +377,32 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
## Внешние сервисы ## Внешние сервисы
### R28. Каждый вызов внешнего сервиса логируется ### SLOG-28. Каждый вызов внешнего сервиса логируется
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4. **ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость **Почему.** Это единственный способ отличить «у нас баг» от «зависимость
легла»: на своей стороне видно лишь то, что операция не удалась. легла»: на своей стороне видно лишь то, что операция не удалась.
Выборочное логирование ломает и второе применение — доля неуспехов и Выборочное логирование ломает и второе применение — доля неуспехов и
распределение `duration_ms` считаются, только если знаменатель полный. распределение `duration_ms` считаются, только если знаменатель полный.
### R29. Уровень `ext`-записи — по исходу вызова ### SLOG-29. Уровень `ext`-записи — по исходу вызова
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями. **ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
| № | Исход | Уровень | | № | Исход | Уровень |
|---|---|---| |---|---|---|
| R29.1 | успешный событийный вызов | `INFO` | | SLOG-29.1 | успешный событийный вызов | `INFO` |
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` | | SLOG-29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
| R29.3 | попытка не удалась, делается retry | `WARN` | | SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | | SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: **Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
уровень непригодным для главного вопроса «зависимость доступна?». уровень непригодным для главного вопроса «зависимость доступна?».
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение разбираться владельцу. Различение SLOG-29.1 и SLOG-29.2 — то же самое разделение
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет событийного и рутинного, что в SLOG-11: поллинг внешнего сервиса зашумляет
аудит так же, как любой другой. аудит так же, как любой другой.
## Два цикла повтора — не путать ## Два цикла повтора — не путать
@@ -412,8 +413,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
уровень доменной записи об исходе тика. уровень доменной записи об исходе тика.
``` ```
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4) WHEN зависимость недоступна и ретраи вызова исчерпаны
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27) → ext-запись `ERROR` (SLOG-29.4)
AND тик фонового цикла упал по той же причине
→ доменная запись `WARN` (SLOG-27)
``` ```
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR` Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
@@ -422,7 +425,7 @@ AND тик фонового цикла упал по той же причине
`ERROR` от поллинга мешает — это лечится понижением частоты тика или `ERROR` от поллинга мешает — это лечится понижением частоты тика или
подавлением повторов в самом клиенте, а не переклассификацией уровня. подавлением повторов в самом клиенте, а не переклассификацией уровня.
### R30. Ответ 4xx — успех на транспортном уровне ### SLOG-30. Ответ 4xx — успех на транспортном уровне
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов **ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
(`ext.status_code` записан); решение «это ошибка» принимает доменный (`ext.status_code` записан); решение «это ошибка» принимает доменный
@@ -437,38 +440,38 @@ AND тик фонового цикла упал по той же причине
## HTTP и healthcheck ## 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` **Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
сущности нет — связать его записи между собой больше нечем. сущности нет — связать его записи между собой больше нечем.
### R33. Healthcheck, liveness, readiness — `DEBUG` ### SLOG-33. Healthcheck, liveness, readiness — `DEBUG`
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. **ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают **Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
доступной при отладке. доступной при отладке.
## Безопасность: что не логируем ## Безопасность: что не логируем
### R34. Секреты не логируются ### SLOG-34. Секреты не логируются
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий, **НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
@@ -479,17 +482,17 @@ API-ключи и токены, `Authorization`-заголовки, аутент
с момента записи, а не с момента, когда это заметили, и вычистить его задним с момента записи, а не с момента, когда это заметили, и вычистить его задним
числом из уже собранных копий нельзя. числом из уже собранных копий нельзя.
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки ### SLOG-35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — **ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
`DEBUG`, с вычисткой секретов и обрезкой по длине. `DEBUG`, с вычисткой секретов и обрезкой по длине.
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав **Почему.** Содержимое пришло снаружи: размер не ограничен, состав
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией; выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
обрезка не даёт одной записи вытеснить весь остальной лог за период. обрезка не даёт одной записи вытеснить весь остальной лог за период.
### R36. При сомнении логируется факт, а не значение ### SLOG-36. При сомнении логируется факт, а не значение
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. **СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
@@ -499,7 +502,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
никто не придёт. никто не придёт.
### R37. `*url.Error` санитизируется на границе клиента ### SLOG-37. `*url.Error` санитизируется на границе клиента
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до **ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
обёртки — раньше трансляции в доменную (`lang/go/errors.md`). обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
@@ -513,12 +516,12 @@ API-ключи и токены, `Authorization`-заголовки, аутент
причину сохраняется); альтернатива с редактированием URL сохранила бы причину сохраняется); альтернатива с редактированием URL сохранила бы
структуру, но сложнее. структуру, но сложнее.
### R38. Секрет не кладётся в URL, если у API есть заголовок ### SLOG-38. Секрет не кладётся в URL, если у API есть заголовок
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого **НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
способа нет. способа нет.
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и **Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
в любую запись, куда URL попал целиком, — то есть обязывает помнить про в любую запись, куда URL попал целиком, — то есть обязывает помнить про
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
@@ -528,7 +531,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
## Куда пишем ## Куда пишем
### R39. Логи идут в `stdout` одним потоком ### SLOG-39. Логи идут в `stdout` одним потоком
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам **ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
не маршрутизируем. не маршрутизируем.
@@ -539,23 +542,23 @@ API-ключи и токены, `Authorization`-заголовки, аутент
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
теряет его ровно там, где важен ход событий. теряет его ровно там, где важен ход событий.
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev ### SLOG-40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом. **ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
**Почему.** Уровень — единственный регулятор объёма, доступный без **Почему.** Уровень — единственный регулятор объёма, доступный без
пересборки; если `DEBUG` в проде включается только правкой кода, его не пересборки; если `DEBUG` в проде включается только правкой кода, его не
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2). что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
## Связано ## Связано
- `arch/time.md` — точность и зона меток времени фиксируются на носитель. - `arch/time.md` — точность и зона меток времени фиксируются на носитель.
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3). - `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (SLOG-3).
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно - `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
санитизации (R37). санитизации (SLOG-37).
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы, - `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
на которых держится корреляция (R18). на которых держится корреляция (SLOG-18).
<!-- local:механизировано --> <!-- local:механизировано -->
<!-- /local --> <!-- /local -->
+32 -31
View File
@@ -1,4 +1,5 @@
--- ---
prefix: GTIM
extends: arch/time.md extends: arch/time.md
--- ---
@@ -10,7 +11,7 @@ extends: arch/time.md
## Правила ## Правила
### R1. «Сейчас» берётся у слоя хранилища ### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.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` — единственный способ **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно. получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в **Почему.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
взаимная обратимость записи и чтения держатся ровно до первого второго взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений, layout — а расхождение проявится не на записи, а при сравнении значений,
записанных разными местами. записанных разными местами.
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий ### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
**ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений **ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений
ровно два, и оба прописаны явно: ровно два, и оба прописаны явно:
| № | Исключение | Почему оно не покрывается R1 | | № | Исключение | Почему оно не покрывается GTIM-1 |
|---|---|---| |---|---|---|
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | | GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) | | GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
**Почему.** R1 без механической проверки держится на внимании, а **Почему.** GTIM-1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по Исключения перечисляются исчерпывающе, потому что каждое из них — само по
@@ -53,23 +54,23 @@ layout — а расхождение проявится не на записи,
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить «починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
сама себе. сама себе.
### R13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера ### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
**ДОЛЖЕН.** Исключение из R3 оформляется как `//nolint:forbidigo // <причина>` **ДОЛЖЕН.** Исключение из GTIM-3 оформляется как
на строке вызова; exclude-записи в конфигурации линтера для него не `//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
заводятся. конфигурации линтера для него не заводятся.
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует **Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует
исключение путём к файлу, отвязывается при переносе кода и продолжает исключение путём к файлу, отвязывается при переносе кода и продолжает
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список — переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
та исчерпываемость, которой требует R3, проверяется одной командой. Голый та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый
`//nolint` без имени правила глушит на строке все проверки сразу, а без `//nolint` без имени правила глушит на строке все проверки сразу, а без
причины неотличим от заглушенного дефекта; обе деградации штатно ловит причины неотличим от заглушенного дефекта; обе деградации штатно ловит
`nolintlint` (`require-specific`, `require-explanation`) — стандартный `nolintlint` (`require-specific`, `require-explanation`) — стандартный
способ дисциплинировать директивы в golangci-lint. способ дисциплинировать директивы в golangci-lint.
### R4. В БД время хранится с секундной точностью, ширина 20 символов ### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
@@ -83,16 +84,16 @@ layout — а расхождение проявится не на записи,
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды, Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
поэтому `Format` их не выведет. поэтому `Format` их не выведет.
### R5. `time.RFC3339Nano` не используется ### GTIM-5. `time.RFC3339Nano` не используется
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит **Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором от значения: соседние записи получают разную ширину, и свойство, на котором
держится R4, исчезает незаметно. Проверка «формат корректен» при этом держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок. проходит — отказывает только порядок.
### R6. Чужой вход нормализуется явно ### GTIM-6. Чужой вход нормализуется явно
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится **ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
к каноническому виду явно, а не считается каноническим по факту успешного к каноническому виду явно, а не считается каноническим по факту успешного
@@ -101,21 +102,21 @@ layout — а расхождение проявится не на записи,
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и **Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но **писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает R4 и значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
обнаруживается не на записи, а на первой сортировке. Само решение обнаруживается не на записи, а на первой сортировке. Само решение
«нормализовать, а не отклонять» — базовое (`arch/time.md` R13); здесь — «нормализовать, а не отклонять» — базовое (`TIME-13`); здесь —
Go-механика, из-за которой его легко нарушить незаметно. Go-механика, из-за которой его легко нарушить незаметно.
### R7. В драйвер передаётся строка, а не `time.Time` ### GTIM-7. В драйвер передаётся строка, а не `time.Time`
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование **Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией. с конвенцией.
### R8. Время в логах приводится к UTC через `ReplaceAttr` ### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr`
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`: **ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
@@ -134,17 +135,17 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
неверная зона выглядит как совершенно валидное время, а записи из разных неверная зона выглядит как совершенно валидное время, а записи из разных
мест перестают складываться в одну хронологию с метками хранилища. мест перестают складываться в одну хронологию с метками хранилища.
### R9. Точность времени в логах отличается от точности в БД ### GTIM-9. Точность времени в логах отличается от точности в БД
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности R4. приводится к секундной точности GTIM-4.
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование **Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе — одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого R4 существует, не такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
нарушено. Общее у лога и базы одно — зона (R8). нарушено. Общее у лога и базы одно — зона (GTIM-8).
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую ### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`. локальным `//nolint`.
@@ -153,10 +154,10 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким срезает монотонную составляющую `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` держит это решение в одном видимом месте, а не в случайном пакете, `main` держит это решение в одном видимом месте, а не в случайном пакете,
откуда его удаляют при чистке зависимостей. откуда его удаляют при чистке зависимостей.
### R12. Зона отображения применяется только в UI ### GTIM-12. Зона отображения применяется только в UI
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях. представления, но не в хранимых значениях и не в вычислениях.
+44
View File
@@ -0,0 +1,44 @@
# Реестр префиксов правил.
#
# Префикс — четыре заглавные латинские буквы, уникальные по всему канону.
# Он выбирается под файл, а не выводится по формуле: префикс нужен, чтобы
# по нему искать, а не чтобы его разбирать. Поэтому подходящее слово лучше
# закономерности.
#
# Правила реестра:
#
# - префикс не переименовывается и не переиспользуется никогда — ссылка
# из чужого репозитория обязана продолжать указывать на то же место;
# - при удалении или разделении файла префикс уходит в [retired], а не
# освобождается;
# - переезд файла между осями префикс не меняет: идентификатор правила
# не зависит от таксономии;
# - вынос части правил в новый файл — это новый префикс и новая
# нумерация: перенос правила между документами есть смысловое
# изменение, а не переименование;
# - тот же префикс продублирован в шапке файла (`prefix:`), conv сверяет.
#
# Локальные правила репозиториев берут свои префиксы и объявляют их в
# `.conventions.toml` копии. Они обязаны не пересекаться с этим реестром.
[live]
DIRS = "arch/app-directories.md"
CONF = "arch/config.md"
KEYS = "arch/db-identifiers.md"
TIME = "arch/time.md"
GCFG = "lang/go/config.md"
GKEY = "lang/go/db-identifiers.md"
MIGR = "lang/go/db-schema.md"
GERR = "lang/go/errors.md"
SLOG = "lang/go/logging.md"
GTIM = "lang/go/time.md"
ANSD = "stack/ansible/app-directories.md"
HTMX = "stack/htmx/web-ui.md"
# Обвязка канона: не синхронизируется в репозитории, но правила
# записаны тем же языком и цитируются по номерам, поэтому префикс нужен.
META = "../GUIDE.md"
[retired]
# Пусто. Сюда попадают префиксы удалённых и разделённых файлов вместе с
# причиной и датой, чтобы их нельзя было выдать повторно.
+15 -14
View File
@@ -1,4 +1,5 @@
--- ---
prefix: ANSD
extends: arch/app-directories.md extends: arch/app-directories.md
--- ---
@@ -15,7 +16,7 @@ extends: arch/app-directories.md
## Правила ## Правила
### R1. Каждая директория объявлена переменной `*_dir` ### ANSD-1. Каждая директория объявлена переменной `*_dir`
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри **ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на `base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
@@ -24,11 +25,11 @@ extends: arch/app-directories.md
`uploads_dir`, `dumps_dir`). `uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача **Почему.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (R4). Литерал пути в одном из этих мест создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении. обнаружится это при восстановлении.
### R2. Директории создаются одной задачей циклом по списку ### ANSD-2. Директории создаются одной задачей циклом по списку
**СЛЕДУЕТ.** Список директорий в единственной задаче создания. **СЛЕДУЕТ.** Список директорий в единственной задаче создания.
@@ -38,7 +39,7 @@ extends: arch/app-directories.md
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
разборе места на диске. разборе места на диске.
### R3. Владелец директорий — пользователь, от имени которого работает приложение ### ANSD-3. Владелец директорий — пользователь, от имени которого работает приложение
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение **ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на (`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
@@ -55,18 +56,18 @@ extends: arch/app-directories.md
<!-- local:модель-владельца --> <!-- local:модель-владельца -->
<!-- /local --> <!-- /local -->
### R4. Список бэкапа собирается из тех же переменных ### ANSD-4. Список бэкапа собирается из тех же переменных
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки **ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути. которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
**Почему.** Правило вывода списка механическое (R5), но применяет его **Почему.** Правило вывода списка механическое (ANSD-5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в обоих местах. Независимо набранный список расходится тихо и проявляется в
единственный момент, когда это уже неисправимо. единственный момент, когда это уже неисправимо.
### R5. В список бэкапа идут только данные ### ANSD-5. В список бэкапа идут только данные
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в **ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет. списке; конфигурация и кеш — нет.
@@ -75,7 +76,7 @@ extends: arch/app-directories.md
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот. облако, и источником истины для секретов остаётся vault, а не снапшот.
### R6. Конфигурация монтируется только на чтение ### ANSD-6. Конфигурация монтируется только на чтение
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. **СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
@@ -85,7 +86,7 @@ extends: arch/app-directories.md
незаметно. Приложение, которому запись в конфиг нужна по устройству, незаметно. Приложение, которому запись в конфиг нужна по устройству,
монтируется на запись — это отступление, и оно записывается. монтируется на запись — это отступление, и оно записывается.
### R7. `docker-compose.yml` лежит в корне `base_dir` ### ANSD-7. `docker-compose.yml` лежит в корне `base_dir`
**ДОЛЖЕН.** Файл не переносится во вложенную директорию. **ДОЛЖЕН.** Файл не переносится во вложенную директорию.
@@ -94,7 +95,7 @@ extends: arch/app-directories.md
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место. место.
### R8. Секреты рендерятся в файл конфигурации ### ANSD-8. Секреты рендерятся в файл конфигурации
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, **СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения. принадлежащий пользователю приложения.
@@ -104,14 +105,14 @@ extends: arch/app-directories.md
довода, по которым базовая конвенция конфигурации выбирает файл вместо довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения. окружения.
### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log` ### ANSD-9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. **ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на **Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на R8 при ближайшем касании. секреты из файла, переводится на ANSD-8 при ближайшем касании.
<!-- local:отступления --> <!-- local:отступления -->
<!-- /local --> <!-- /local -->
+79 -75
View File
@@ -1,3 +1,7 @@
---
prefix: HTMX
---
# Веб-UI на htmx # Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
@@ -18,7 +22,7 @@
## Стек и границы ## Стек и границы
### R1. Стек: роутер, серверные шаблоны, htmx ### HTMX-1. Стек: роутер, серверные шаблоны, htmx
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка. без Node и бандлера, без реактивного фреймворка.
@@ -26,12 +30,12 @@
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и **Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (R2), и дальше на каждом экране модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
приходится решать, какая из них главная. Сам htmx — вендорный ассет и приходится решать, какая из них главная. Сам htmx — вендорный ассет и
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к
приложения аптайм чужого хоста. аптайму приложения аптайм чужого хоста.
### R2. Клиент не пересчитывает доменное состояние ### HTMX-2. Клиент не пересчитывает доменное состояние
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно **НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
(копирование в буфер обмена и подобное); доменное состояние считает сервер, (копирование в буфер обмена и подобное); доменное состояние считает сервер,
@@ -40,24 +44,24 @@
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую **Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (R11, R12) — значит, серверную версию того же деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
вычисления всё равно придётся держать. вычисления всё равно придётся держать.
### R3. Реактивный слой вводится отдельным решением ### HTMX-3. Реактивный слой вводится отдельным решением
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей — **НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
только когда есть виджет, которому он действительно нужен, и отдельным только когда есть виджет, которому он действительно нужен, и отдельным
решением. решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего **Почему.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница R1/R2 списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент, перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на когда цену видно целиком: она не в килобайтах, а в том, что дальше на
каждом экране есть выбор между двумя моделями состояния. каждом экране есть выбор между двумя моделями состояния.
## Единый источник разметки ## Единый источник разметки
### R4. Партиал = страница = фрагмент ### HTMX-4. Партиал = страница = фрагмент
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в **ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
@@ -68,7 +72,7 @@
же региона. Заметно это становится только на глаз и только тому, кто открыл же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд. оба пути подряд.
### R5. Корень партиала — элемент с целевым `id` ### HTMX-5. Корень партиала — элемент с целевым `id`
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`. регион, и ответный фрагмент несёт тот же `id`.
@@ -79,19 +83,19 @@
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
логе. логе.
### R6. Сборку view делает общая функция ### HTMX-6. Сборку view делает общая функция
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка. htmx-ветка.
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не **Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который R4 закрывает для разметки. класс расхождений, который HTMX-4 закрывает для разметки.
## Обработчик действия ## Обработчик действия
### R7. Доменный вызов одинаков для htmx и обычного запроса ### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку **ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только `HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
@@ -99,8 +103,8 @@ htmx-ветка.
| № | Запрос | Ответ | | № | Запрос | Ответ |
|---|---|---| |---|---|---|
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию | | HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию |
| R7.2 | обычный | PRG-редирект (303) | | HTMX-7.2 | обычный | PRG-редирект (303) |
```go ```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
@@ -119,12 +123,12 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**Почему.** Ветвление до вызова даёт две реализации одного действия, и **Почему.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (R11) открывают реже, то есть чинить будут не тот. деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
целиком: view, собранный из аргументов запроса, покажет намерение, а не целиком: view, собранный из аргументов запроса, покажет намерение, а не
результат. результат.
### R8. Шаблон рендерится в буфер, потом в ответ ### HTMX-8. Шаблон рендерится в буфер, потом в ответ
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ. буфер пишется в ответ.
@@ -136,30 +140,30 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Одно действие — два региона ## Одно действие — два региона
### R9. Второй регион едет тем же ответом через `hx-swap-oob` ### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob`
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент **СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (R4, R5). партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают **Почему.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие. раунд-трип на каждое действие.
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия ### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие. регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого **Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление. связи нет; гонка же тем менее наблюдаема, чем реже обновление.
## Graceful degradation ## Graceful degradation
### R11. Форма действия работает без JS ### HTMX-11. Форма действия работает без JS
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую **ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
@@ -170,24 +174,24 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
ничего, молча. Тот же `action` — единственное, что делает действие ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS. проверяемым без браузера с JS.
### R12. Фильтр, поиск и пагинация — серверные ### HTMX-12. Фильтр, поиск и пагинация — серверные
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; **ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет. клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский **Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (R25) и валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе. перезагрузку, его можно послать ссылкой и увидеть в логе.
### R13. Область обязательной деградации ### HTMX-13. Область обязательной деградации
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI: **ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
| № | Поверхность | Поведение без JS | | № | Поверхность | Поведение без JS |
|---|---|---| |---|---|---|
| R13.1 | действия и навигация | работают полностью (R11, R12) | | HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | | HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на **Почему.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
@@ -197,7 +201,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Ошибки на htmx-пути ## Ошибки на htmx-пути
### R14. Ошибка действия на htmx-пути — 200 с фрагментом ### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с **ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
@@ -205,44 +209,44 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть **Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Своп ошибочных ответов настраивается пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая (`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из R1 и R2. такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
её решает глобальный слушатель (R34). Для REST API и не-JS редиректа с её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с
`?err=` статус по-прежнему используется: там его кто-то читает. `?err=` статус по-прежнему используется: там его кто-то читает.
Цена решения: в логе доступа провалившееся действие выглядит как `200`. Цена решения: в логе доступа провалившееся действие выглядит как `200`.
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`), Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
а не по коду ответа. а не по коду ответа.
### R34. Сбой без ответа-фрагмента показывается глобальным слушателем ### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем
**ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и **ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
`htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп `htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`, ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается. `response-targets`) не настраивается.
**Почему.** R14 закрывает доменный отказ, до которого обработчик дошёл. **Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя, свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель и пользователь повторяет действие, которое могло уже примениться. Слушатель
— несколько строк без доменного состояния, то есть внутри границы R2, и он — несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он
не спорит с R14: там настройки отвергнуты как замена фрагменту, который не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
целевого `id`, и после первого же такого свопа регион перестаёт находиться целевого `id`, и после первого же такого свопа регион перестаёт находиться
(R5). (HTMX-5).
### R15. Наружу идёт сообщение публичного канала ### HTMX-15. Наружу идёт сообщение публичного канала
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам **ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
`lang/go/errors.md`; `err.Error()` в разметку не рендерится. `lang/go/errors.md`; `err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём **Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница: легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (R14) разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
### R16. Сообщение об ошибке — в отдельном поле view ### HTMX-16. Сообщение об ошибке — в отдельном поле view
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под **ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются. сообщение не переиспользуются.
@@ -251,9 +255,9 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
требует R17. требует HTMX-17.
### R17. При ошибке активное состояние не меняется ### HTMX-17. При ошибке активное состояние не меняется
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает **НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение. прежний выбор плюс сообщение.
@@ -276,7 +280,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
</div>{{end}} </div>{{end}}
``` ```
### R18. Поллер самозавершается ### HTMX-18. Поллер самозавершается
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов. `hx-*`-атрибутов.
@@ -287,10 +291,10 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
это единственный канал, которым сервер управляет поллером. это единственный канал, которым сервер управляет поллером.
Встроенная альтернатива — ответ со статусом 286 — не используется: она не Встроенная альтернатива — ответ со статусом 286 — не используется: она не
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом
и тоже без поллера. и тоже без поллера.
### R19. Условие живости ведёт собственное состояние приложения ### HTMX-19. Условие живости ведёт собственное состояние приложения
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет **ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса. приложение, а не по ответу внешнего сервиса.
@@ -300,18 +304,18 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
останавливается никогда. Приложение — единственный участник, который знает останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике. про операцию всё и может ответить на каждом тике.
### R20. Поллер свопит фрагмент целиком через `outerHTML` ### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML`
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его **ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое. содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и **Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (R18). Своп содержимого оставил бы старый узел с его таймером, выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
при совпадении корневого `id` (R5). при совпадении корневого `id` (HTMX-5).
### R21. Поллер не свопит контейнер с активными полями ввода ### HTMX-21. Поллер не свопит контейнер с активными полями ввода
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где **НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего. редактировать нечего.
@@ -321,7 +325,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
пользователь не выбирал: текст исчезает посреди набора и воспроизводится пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод». как «приложение стирает мой ввод».
### R22. Браузер не ходит во внешний сервис напрямую ### HTMX-22. Браузер не ходит во внешний сервис напрямую
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. **НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
@@ -330,14 +334,14 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением. серверным изменением.
### R23. Источник данных для тика ### HTMX-23. Источник данных для тика
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть: **ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
| № | Что показывает тик | Откуда берёт | | № | Что показывает тик | Откуда берёт |
|---|---|---| |---|---|---|
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | | HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | | HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на **Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
@@ -348,7 +352,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
собственного состояния той же цены нет: хранилище и так своё, а лишний слой собственного состояния той же цены нет: хранилище и так своё, а лишний слой
кеша добавил бы только рассинхрон. кеша добавил бы только рассинхрон.
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута ### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт **ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
на URL самой страницы, а нужный узел вырезается `hx-select`: на URL самой страницы, а нужный узел вырезается `hx-select`:
@@ -361,24 +365,24 @@ hx-select="#item-main" hx-swap="outerHTML"
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует **Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (R4). копии разметки (HTMX-4).
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот
же узел, который свопится. же узел, который свопится.
## Своп и выход со страницы ## Своп и выход со страницы
### R25. Действие не уводит со страницы, если предмет остаётся на ней ### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и **Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (R12). Полная навигация ради изменения одного пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого страницы. Не сохраняется при свопе только контекст внутри самого
заменяемого поддерева — фокус, выделение, введённый текст (R21). заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).
### R26. Выход со страницы — форма без `hx-*` ### HTMX-26. Выход со страницы — форма без `hx-*`
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией. обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
@@ -389,10 +393,10 @@ htmx-атрибутов при этом само работает маркеро
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
сменить страницу, существующий только на htmx-пути. сменить страницу, существующий только на htmx-пути.
### R27. Асинхронное действие свопит промежуточное состояние ### HTMX-27. Асинхронное действие свопит промежуточное состояние
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18). промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и **Почему.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу — всё это время пользователь принимает решения по несуществующему исходу —
@@ -401,7 +405,7 @@ htmx-атрибутов при этом само работает маркеро
## Различение поверхности одного действия ## Различение поверхности одного действия
### R28. Поверхность различается скрытым полем формы ### HTMX-28. Поверхность различается скрытым полем формы
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается **ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
фрагментом, поверхность передаётся явным скрытым полем фрагментом, поверхность передаётся явным скрытым полем
@@ -413,18 +417,18 @@ htmx-атрибутов при этом само работает маркеро
действием, поэтому связь «эта страница → этот фрагмент» читается там, где действием, поэтому связь «эта страница → этот фрагмент» читается там, где
её заводят. её заводят.
### R35. Запрос без поля поверхности получает 400 ### HTMX-35. Запрос без поля поверхности получает 400
**ДОЛЖЕН.** Обработчик, различающий поверхности (R28), отвечает статусом **ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом
400, когда поля `surface` в запросе нет; поверхность по умолчанию не 400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается. выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие — **Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (R5), и ошибка воспроизводится чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
глобального слушателя (R34) — сразу и на той странице, где форму сломали. глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали.
Вкладка, открытая до появления поля, получает тот же 400 и чинится Вкладка, открытая до появления поля, получает тот же 400 и чинится
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
разметке. разметке.
@@ -434,7 +438,7 @@ htmx-атрибутов при этом само работает маркеро
Раздел не про htmx — это упаковка любого server-rendered приложения; Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там. разъедется в языковой слой, когда понадобится там.
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем ### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с **ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`. `Cache-Control: public, max-age=31536000, immutable`.
@@ -442,21 +446,21 @@ htmx-атрибутов при этом само работает маркеро
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго **Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
способом навсегда закрепить у пользователя старый файл. был бы способом навсегда закрепить у пользователя старый файл.
### R30. Меняемые ассеты версионируются хешем содержимого ### HTMX-30. Меняемые ассеты версионируются хешем содержимого
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL **ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона. строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно **Почему.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (R29) — от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
хеш не проставляли в каждом шаблоне руками. хеш не проставляли в каждом шаблоне руками.
### R31. Вендорный ассет в `?v=` не нуждается ### HTMX-31. Вендорный ассет в `?v=` не нуждается
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без **ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии. параметра версии.
@@ -464,9 +468,9 @@ htmx-атрибутов при этом само работает маркеро
**Почему.** Содержимое под этим именем не меняется: обновление вендора **Почему.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это R30. явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
### R32. Вендор не коммитится, а добывается по манифесту ### HTMX-32. Вендор не коммитится, а добывается по манифесту
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту **ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой (`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
@@ -478,7 +482,7 @@ diff'е — у закоммиченного минифицированного
единственная проверка, что скачали то же самое, что проверяли; зависимость единственная проверка, что скачали то же самое, что проверяли; зависимость
сборки от задачи не даёт собраться без ассета в свежем клоне. сборки от задачи не даёт собраться без ассета в свежем клоне.
### R33. Шрифты и скрипты — self-hosted ### HTMX-33. Шрифты и скрипты — self-hosted
**ДОЛЖЕН.** Внешних хостов во время выполнения нет. **ДОЛЖЕН.** Внешних хостов во время выполнения нет.