остальные конвенции переведены на формальный язык
- 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у каждого модальность и обязательный блок «Почему» - классифицирующие места оформлены таблицами, файловый статус снят отовсюду, локальные регионы сохранены под прежними именами
This commit is contained in:
+137
-80
@@ -1,89 +1,146 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Категории директорий приложения
|
# Категории директорий приложения
|
||||||
|
|
||||||
Всё, что приложение пишет на диск, делится на три категории по принципу
|
Всё, что приложение пишет на диск, делится на три категории по принципу
|
||||||
создания и ценности содержимого:
|
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
|
||||||
|
отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
|
||||||
- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе
|
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
|
||||||
секреты;
|
механически выводится состав бэкапа. Форма записи — `common/language.md`.
|
||||||
- **данные** — то, что генерирует приложение и что нужно бэкапить;
|
|
||||||
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
|
|
||||||
приложение перегенерирует заново.
|
|
||||||
|
|
||||||
Цель — упростить оперирование данными. Категория сразу отвечает на два
|
|
||||||
вопроса, которые иначе приходится выяснять по коду приложения: **кто
|
|
||||||
создаёт** содержимое и **что будет, если его потерять**.
|
|
||||||
|
|
||||||
## Категории
|
|
||||||
|
|
||||||
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
|
|
||||||
| --- | --- | --- | --- | --- |
|
|
||||||
| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен |
|
|
||||||
| Данные | `data/` | приложение | невосполнима | обязателен |
|
|
||||||
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
|
|
||||||
|
|
||||||
Имена в таблице — умолчание для случая «одна директория на категорию».
|
|
||||||
**Категория может состоять из нескольких директорий**, и это нормально:
|
|
||||||
крупные файлы отделяют от базы, чтобы двигать их между дисками независимо
|
|
||||||
(`media/`, `uploads/` — та же категория «данные», что и `data/`).
|
|
||||||
Принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
|
||||||
|
|
||||||
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять
|
|
||||||
приложение заново. Поднимется само и наверстает — кеш. Не поднимется или
|
|
||||||
поднимется пустым — данные.
|
|
||||||
|
|
||||||
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты,
|
|
||||||
а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и
|
|
||||||
хранилище секретов, а не снапшот бэкапа.
|
|
||||||
|
|
||||||
## Данные, которые нельзя копировать на живую
|
|
||||||
|
|
||||||
Файловый снапшот работающей СУБД не гарантирует консистентности:
|
|
||||||
скопированный каталог может не восстановиться. Поэтому у категории «данные»
|
|
||||||
есть два способа попасть в бэкап:
|
|
||||||
|
|
||||||
- **копированием** — если файлы самодостаточны на любой момент времени;
|
|
||||||
- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда
|
|
||||||
бэкапится директория дампов, а сырой каталог базы — нет.
|
|
||||||
|
|
||||||
Директория дампов — тоже данные, просто производные. Решение «копировать
|
|
||||||
или дампить» принимается **при заведении приложения**, а не при первой
|
|
||||||
неудачной попытке восстановления.
|
|
||||||
|
|
||||||
## Контракт с приложением
|
|
||||||
|
|
||||||
Категории — не только про деплой. Приложение **разводит свои записываемые
|
|
||||||
пути по категориям в конфигурации**, а не складывает всё в один каталог:
|
|
||||||
иначе категорию нельзя определить снаружи и список бэкапа приходится
|
|
||||||
составлять вручную, читая код.
|
|
||||||
|
|
||||||
- Путь к БД, загруженным файлам, сгенерированным артефактам — данные.
|
|
||||||
- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые
|
|
||||||
перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠
|
|
||||||
невосполнимо.
|
|
||||||
- Приложение не пишет в директорию конфигурации: она может быть доступна
|
|
||||||
только на чтение.
|
|
||||||
|
|
||||||
Если приложение не умеет разделять, это его дефект, а не повод смешивать
|
|
||||||
категории в раскладке.
|
|
||||||
|
|
||||||
## Список бэкапа выводится, а не составляется
|
|
||||||
|
|
||||||
Список бэкапа получается из категорий по правилу: туда идут данные, не идут
|
|
||||||
конфигурация и кеш. Правило механическое — но его применяет человек или
|
|
||||||
шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что
|
|
||||||
и создание директорий. Независимо набранный список — источник расхождения
|
|
||||||
между тем, что бэкапится, и тем, что нужно.
|
|
||||||
|
|
||||||
## Область действия
|
## Область действия
|
||||||
|
|
||||||
Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется
|
Раскладка меняется вместе с миграцией данных, поэтому правила
|
||||||
к **новым приложениям**; существующие переезжают по мере касания, отдельной
|
распространяются на **новые приложения**; существующие переезжают по мере
|
||||||
кампанией не переписываются. Разделять данные и кеш задним числом имеет
|
касания, отдельной кампанией не переписываются. Разделять данные и кеш
|
||||||
смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
|
задним числом имеет смысл тогда, когда кеш заметен по объёму в бэкапе, а не
|
||||||
|
ради самой схемы.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### R1. Записываемые пути разложены по трём категориям
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
|
||||||
|
относится к одной из трёх категорий:
|
||||||
|
|
||||||
|
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
|
||||||
|
| R1.2 | данные | `data/` | приложение | невосполнима | да |
|
||||||
|
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
|
||||||
|
|
||||||
|
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||||
|
|
||||||
|
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно
|
||||||
|
снести при нехватке места, что переживает переезд на другой диск —
|
||||||
|
читаются из категории, а не выясняются по коду приложения. Без единой
|
||||||
|
классификации каждое такое решение принимается заново и каждый раз чуть
|
||||||
|
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
|
||||||
|
потерянные данные не стоят ничего, потому что их больше нет.
|
||||||
|
|
||||||
|
### R2. Категория может состоять из нескольких директорий
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
|
||||||
|
принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||||
|
|
||||||
|
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно
|
||||||
|
три директории». Крупные файлы отделяют от базы, чтобы двигать их между
|
||||||
|
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
|
||||||
|
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном
|
||||||
|
диске, либо выводить директорию из-под категорий вовсе. Категорию нельзя
|
||||||
|
задавать именем ровно поэтому: имён в категории несколько, и выбираются они
|
||||||
|
по содержимому.
|
||||||
|
|
||||||
|
### R3. Данные и кеш разделяются по тесту на пересоздание
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
|
||||||
|
|
||||||
|
| № | Что лежит | Категория |
|
||||||
|
|---|---|---|
|
||||||
|
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
|
||||||
|
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
|
||||||
|
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
|
||||||
|
|
||||||
|
**Почему.** Без внешнего теста граница проводится по ощущению «жалко
|
||||||
|
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
|
||||||
|
переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и
|
||||||
|
разделяет эти два свойства именно способность приложения пересоздать
|
||||||
|
содержимое. Обратная ошибка — данные, названные кешем, — тестом
|
||||||
|
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
|
||||||
|
при попытке восстановить.
|
||||||
|
|
||||||
|
### R4. В бэкап идут данные, и только они
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
|
||||||
|
кеш — нет.
|
||||||
|
|
||||||
|
**Почему.** Кеш раздувает снапшот содержимым, которое приложение
|
||||||
|
восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там
|
||||||
|
лежат секреты, а бэкапы уезжают в облако — источник истины для
|
||||||
|
конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте.
|
||||||
|
Ошибка в другую сторону дороже: директория данных, не попавшая в список,
|
||||||
|
обнаруживается в единственный момент, когда исправить её уже нечем.
|
||||||
|
|
||||||
|
### R5. Список бэкапа ссылается на те же пути, что и создание директорий
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же
|
||||||
|
объявления путей, по которым директории создаются, а не набирается
|
||||||
|
независимо.
|
||||||
|
|
||||||
|
**Почему.** Правило вывода механическое, но применяет его человек или
|
||||||
|
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
|
||||||
|
невозможным: переименование директории отражается в обоих местах сразу.
|
||||||
|
Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа
|
||||||
|
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
|
||||||
|
нужно, проявляется при восстановлении.
|
||||||
|
|
||||||
|
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
|
||||||
|
|
||||||
|
| № | Данные | В бэкап |
|
||||||
|
|---|---|---|
|
||||||
|
| R6.1 | файлы самодостаточны на любой момент времени | копированием |
|
||||||
|
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
|
||||||
|
|
||||||
|
**Почему.** Файловый снапшот работающей СУБД не гарантирует
|
||||||
|
консистентности: скопированный каталог может не восстановиться, и узнают
|
||||||
|
об этом при восстановлении. Директория дампов — тоже данные, просто
|
||||||
|
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из
|
||||||
|
списка при этом исключается: он удваивает объём снапшота и добавляет к
|
||||||
|
надёжной копии заведомо ненадёжную.
|
||||||
|
|
||||||
|
### R7. Способ выбирается при заведении приложения
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда
|
||||||
|
приложение заводят.
|
||||||
|
|
||||||
|
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
|
||||||
|
понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из
|
||||||
|
которых база не поднимется. Отложить решение — значит принять его по факту
|
||||||
|
первой неудачной попытки восстановления, то есть тогда, когда данных уже
|
||||||
|
нет.
|
||||||
|
|
||||||
|
### R8. Приложение разводит записываемые пути по категориям
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
|
||||||
|
кеша, а не один каталог на всё.
|
||||||
|
|
||||||
|
**Почему.** Снаружи категория определяется только тогда, когда разным
|
||||||
|
категориям соответствуют разные директории. Всё, сложенное в один каталог,
|
||||||
|
заставляет составлять список бэкапа вручную, читая код приложения, — и
|
||||||
|
пересматривать его при каждом обновлении, потому что новый подкаталог
|
||||||
|
появляется молча. Приложение, которое не умеет разделять, тем самым
|
||||||
|
дефектно; раскладка под этот дефект не подстраивается.
|
||||||
|
|
||||||
|
### R9. Приложение не пишет в директорию конфигурации
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
|
||||||
|
конфигурации.
|
||||||
|
|
||||||
|
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому
|
||||||
|
всё, что приложение туда записало, следующий деплой затирает без
|
||||||
|
предупреждения. Вдобавок директория конфигурации может быть подключена
|
||||||
|
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
|
||||||
|
видно в момент, когда приложение настраивают.
|
||||||
|
|
||||||
<!-- local:отступления -->
|
<!-- local:отступления -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+198
-62
@@ -1,15 +1,23 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Конфигурация приложения
|
# Конфигурация приложения
|
||||||
|
|
||||||
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
||||||
секретами и когда падает.
|
секретами и когда падает. Форма записи — `common/language.md`.
|
||||||
|
|
||||||
## Файл, а не окружение
|
## Область действия
|
||||||
|
|
||||||
**Конфигурация — файл.** Причины, по убыванию веса:
|
Правила написаны для приложений, которые мы пишем сами: только там мы
|
||||||
|
управляем тем, как конфигурация читается. Сторонний образ, живущий на
|
||||||
|
переменных окружения, вне области действия — это не повод отказываться от
|
||||||
|
конвенции для своих приложений.
|
||||||
|
|
||||||
|
## Правила
|
||||||
|
|
||||||
|
### R1. Конфигурация — файл, а не окружение
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
|
||||||
|
окружения источником конфигурации не служат.
|
||||||
|
|
||||||
|
**Почему.** Три довода, по убыванию веса:
|
||||||
|
|
||||||
- **Один типизированный источник.** Файл несёт секции, комментарии,
|
- **Один типизированный источник.** Файл несёт секции, комментарии,
|
||||||
единицы измерения и валидируется целиком. Окружение — плоский набор
|
единицы измерения и валидируется целиком. Окружение — плоский набор
|
||||||
@@ -23,94 +31,222 @@ status: рекомендуемая
|
|||||||
докера; переменные оседают в compose-файле и `.env` на диске — то есть
|
докера; переменные оседают в compose-файле и `.env` на диске — то есть
|
||||||
файл всё равно появляется, только без структуры и валидации.
|
файл всё равно появляется, только без структуры и валидации.
|
||||||
|
|
||||||
Обратите внимание, чего в списке **нет**: `/proc/<pid>/environ` не является
|
Обратите внимание, чего в этих доводах **нет**: `/proc/<pid>/environ` не
|
||||||
аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`,
|
является аргументом — он имеет права `0400` и защищён проверкой
|
||||||
то есть доступен ровно тому же кругу, что и файл под `0600`.
|
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
|
||||||
|
`0600`.
|
||||||
|
|
||||||
Запрет держится на «один источник» и на том, что все приложения свои. Для
|
### R2. Формат конфигурации — текстовый, с секциями и комментариями
|
||||||
стороннего образа, живущего на env, конвенция неприменима — это не повод
|
|
||||||
отказываться от неё для своих.
|
|
||||||
|
|
||||||
Практика:
|
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
|
||||||
|
|
||||||
- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку).
|
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг
|
||||||
- Имя по умолчанию фиксировано и ищется в рабочей директории процесса;
|
вообще читают; формат, в котором комментарий негде разместить, делает R9
|
||||||
путь переопределяется опцией командной строки.
|
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
|
||||||
- Реальный конфиг не коммитится. В репозитории лежит **образец**.
|
плоский список пар такой возможности не даёт и возвращает нас к тем же
|
||||||
|
свойствам, из-за которых отвергнуто окружение (R1).
|
||||||
|
|
||||||
## Грузим один раз, дальше не перечитываем
|
### R3. Имя файла фиксировано, путь переопределяется опцией
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
|
||||||
|
задаётся опцией командной строки.
|
||||||
|
|
||||||
|
**Почему.** Запуск без аргументов работает одинаково в разработке, в
|
||||||
|
контейнере и на сервере, и способ запуска не приходится помнить отдельно
|
||||||
|
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
|
||||||
|
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
|
||||||
|
самым каналом, который закрывает R1.
|
||||||
|
|
||||||
|
### R4. В репозитории лежит образец, а не рабочий конфиг
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
|
||||||
|
|
||||||
|
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет,
|
||||||
|
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
|
||||||
|
закоммиченный конфиг конкретной среды становится вторым источником истины:
|
||||||
|
он расходится с тем, что реально развёрнуто, и расходится молча.
|
||||||
|
|
||||||
|
### R5. Конфиг разбирается один раз при старте
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
|
||||||
|
файла конфигурации в бизнес-коде нет.
|
||||||
|
|
||||||
|
**Почему.** Второе место чтения — это второй момент времени: две части кода
|
||||||
|
начинают видеть разные значения одного параметра, и расхождение не
|
||||||
|
воспроизводится, потому что зависит от того, когда файл потрогали.
|
||||||
|
Типизированная структура вдобавок переносит ошибку формата в старт (R17),
|
||||||
|
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
|
||||||
|
|
||||||
|
### R6. Конфиг неизменяем после старта
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
|
||||||
|
|
||||||
|
**Почему.** Изменяемый конфиг делает поведение функцией момента: один
|
||||||
|
запрос обслуживается наполовину старыми, наполовину новыми значениями, а
|
||||||
|
разбор инцидента требует знать хронологию правок файла, а не его текущее
|
||||||
|
содержимое.
|
||||||
|
|
||||||
- Разбор — **один раз при старте**, в одну типизированную структуру.
|
|
||||||
Дальше по коду читаем только её: чтения файла в бизнес-коде нет.
|
|
||||||
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
|
||||||
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
||||||
умолчание.
|
умолчание.
|
||||||
- Умолчания задаются в коде, файл их перекрывает. Образец при этом
|
|
||||||
перечисляет **все** поля, включая те, у которых есть умолчание: поле,
|
|
||||||
живущее только в коде, для читателя конфига не существует.
|
|
||||||
|
|
||||||
## Образец самодокументируем
|
### R7. Умолчания живут в коде
|
||||||
|
|
||||||
Образец коммитим как единый справочник по конфигу: все секции и все поля.
|
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
|
||||||
**Каждое поле снабжаем комментарием**, из которого ясно:
|
|
||||||
|
**Почему.** Умолчание, живущее в образце, действует только для тех, кто
|
||||||
|
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
|
||||||
|
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
|
||||||
|
поведение для неполного конфига и одно место, где это значение меняется.
|
||||||
|
|
||||||
|
### R8. Образец перечисляет все поля
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
|
||||||
|
которых есть умолчание (R7).
|
||||||
|
|
||||||
|
**Почему.** Поле, живущее только в коде, для читателя конфига не
|
||||||
|
существует: он не знает, что параметр вообще можно менять, и добивается
|
||||||
|
нужного поведения обходным путём. Полнота образца — цена, которой R7
|
||||||
|
покупает себе видимость.
|
||||||
|
|
||||||
|
### R9. У каждого поля образца есть комментарий
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
|
||||||
|
|
||||||
- **зачем** поле — что оно меняет в поведении;
|
- **зачем** поле — что оно меняет в поведении;
|
||||||
- **диапазон или допустимые значения** — перечисление либо границы;
|
- **диапазон или допустимые значения** — перечисление либо границы;
|
||||||
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
||||||
`0–1`.
|
`0–1`.
|
||||||
|
|
||||||
Так конфиг читается без открывания кода — этим он и полезен.
|
**Почему.** Так конфиг читается без открывания кода — этим он и полезен;
|
||||||
|
без комментария читатель всё равно идёт в код, и образец перестаёт быть
|
||||||
|
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
|
||||||
|
дают валидное значение и работающий процесс, а ошибка обнаруживается по
|
||||||
|
последствиям — таймаут в тысячу раз не тот.
|
||||||
|
|
||||||
## Поля по дискриминатору `type`
|
### R10. Обязательность полей определяется дискриминатором `type`
|
||||||
|
|
||||||
Когда набор полей секции зависит от поля-дискриминатора (выбор одного из
|
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
|
||||||
бекендов или внешних сервисов), обязательность полей определяется его
|
бекенда или внешнего сервиса), валидация идёт по его значению:
|
||||||
значением, а не фиксирована для секции.
|
|
||||||
|
|
||||||
- **Валидация — по значению `type`**: для каждого поддерживаемого варианта
|
| № | Значение `type` | Валидация |
|
||||||
свой набор обязательных полей; поля других вариантов не требуются.
|
|---|---|---|
|
||||||
Неизвестное значение → ошибка на старте с перечислением поддерживаемых.
|
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
|
||||||
- **Образец — по значению `type`**: основной вариант предзаполнен рабочими
|
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
|
||||||
значениями, альтернативные — блоками-комментариями ниже, каждый со своим
|
|
||||||
описанием полей. Из примера видны все варианты, не открывая код.
|
|
||||||
|
|
||||||
## Секреты приносит деплой
|
**Почему.** Фиксированный на секцию набор обязательных полей оставляет
|
||||||
|
выбор из двух плохих: заполнять поля бекенда, который не используется, или
|
||||||
|
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
|
||||||
|
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
|
||||||
|
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от
|
||||||
|
неподдерживаемого варианта, и за списком приходится идти в код.
|
||||||
|
|
||||||
Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя
|
### R11. Образец показывает все варианты `type`
|
||||||
секретов в приложении нет — оно просто читает файл. Источник истины
|
|
||||||
секрета — внешнее хранилище деплоя, не репозиторий и не окружение.
|
|
||||||
|
|
||||||
- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime-
|
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
|
||||||
пользователь.
|
альтернативные — блоками-комментариями ниже, каждый со своим описанием
|
||||||
- В образце секретные поля — пустые строки.
|
полей.
|
||||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты: это
|
|
||||||
ловит криво отрендеренный шаблон до того, как он превратится в 401 от
|
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец
|
||||||
внешнего API через час работы.
|
теряет свойство справочника (R8, R9) ровно на той секции, где выбор
|
||||||
- В логи секреты не попадают.
|
действительно есть. Закомментированный блок вдобавок переключается правкой
|
||||||
|
на месте, а не сборкой секции с нуля по документации.
|
||||||
|
|
||||||
|
### R12. Секреты в конфиг приносит деплой
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
|
||||||
|
отдельного слоя секретов в приложении нет.
|
||||||
|
|
||||||
|
**Почему.** Источник истины секрета — внешнее хранилище деплоя, не
|
||||||
|
репозиторий и не окружение. Любой второй канал — переменная окружения рядом
|
||||||
|
с файлом, собственный клиент к хранилищу внутри приложения — возвращает
|
||||||
|
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
|
||||||
|
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
|
||||||
|
особенного.
|
||||||
|
|
||||||
|
### R13. Рендеренный конфиг — `0600` и владелец-рантайм
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
|
||||||
|
работает процесс.
|
||||||
|
|
||||||
|
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на
|
||||||
|
которой секреты лежат, и весь довод «файл вместо окружения» держится на его
|
||||||
|
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало
|
||||||
|
бы окружение, — и тогда R1 меняет одну утечку на другую.
|
||||||
|
|
||||||
|
### R14. В образце секретные поля — пустые строки
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
|
||||||
|
пример.
|
||||||
|
|
||||||
|
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
|
||||||
|
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
|
||||||
|
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг
|
||||||
|
механически отличимым от заполненного.
|
||||||
|
|
||||||
|
### R15. Загрузчик проверяет, что обязательные секреты не пусты
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
|
||||||
|
|
||||||
|
**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится
|
||||||
|
в 401 от внешнего API через час работы, — то есть в момент, когда причина
|
||||||
|
ещё очевидна и связана с деплоем.
|
||||||
|
|
||||||
|
### R16. Секреты не попадают в логи
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
|
||||||
|
одном уровне.
|
||||||
|
|
||||||
|
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они
|
||||||
|
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
|
||||||
|
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
|
||||||
|
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
|
||||||
|
старте.
|
||||||
|
|
||||||
<!-- local:секретные-поля -->
|
<!-- local:секретные-поля -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
## Валидация и fail-fast
|
### R17. Конфиг валидируется на старте, до приёма трафика
|
||||||
|
|
||||||
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
|
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
|
||||||
запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину».
|
кодом; процесс не стартует «наполовину».
|
||||||
|
|
||||||
Проверяем как минимум:
|
**Почему.** Наполовину стартовавший процесс проходит проверку живости и
|
||||||
|
падает позже — на первом запросе, который трогает испорченный параметр, — и
|
||||||
|
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
|
||||||
|
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
|
||||||
|
завершения, и приложение считается развёрнутым.
|
||||||
|
|
||||||
- обязательные поля заданы, обязательные секреты не пусты;
|
### R18. Минимальный набор проверок
|
||||||
- пути существуют и доступны на запись/чтение по назначению;
|
|
||||||
- числовые диапазоны и единицы (доли, таймауты, счётчики попыток);
|
|
||||||
- строки, которые парсятся во что-то (длительности, зоны, URL), реально
|
|
||||||
парсятся;
|
|
||||||
- включённые секции консистентны: если интеграция включена — заданы все её
|
|
||||||
обязательные поля.
|
|
||||||
|
|
||||||
Проблемы собираем и показываем **разом**, а не по одной за запуск.
|
**ДОЛЖЕН.** Валидация покрывает как минимум:
|
||||||
|
|
||||||
|
| № | Что проверяется | Когда всплывёт без проверки |
|
||||||
|
|---|---|---|
|
||||||
|
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает |
|
||||||
|
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
|
||||||
|
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
|
||||||
|
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит |
|
||||||
|
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
|
||||||
|
|
||||||
|
**Почему.** Список минимальный и собран по одному признаку — правый
|
||||||
|
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
|
||||||
|
потеряна, и диагностируется как дефект приложения. Проверка на старте
|
||||||
|
сводит их все к одному моменту и одному сообщению.
|
||||||
|
|
||||||
<!-- local:проверки -->
|
<!-- local:проверки -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
|
### R19. Проблемы конфига показываются разом
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
|
||||||
|
списком, а не падает на первой.
|
||||||
|
|
||||||
|
**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
|
||||||
|
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
|
||||||
|
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
|
||||||
|
одного источника: разом они читаются как одна причина, по одной — как
|
||||||
|
череда несвязанных мелочей.
|
||||||
|
|
||||||
## Связано
|
## Связано
|
||||||
|
|
||||||
- `arch/time.md` — формат времени; зона отображения — единственный
|
- `arch/time.md` — формат времени; зона отображения — единственный
|
||||||
|
|||||||
+144
-47
@@ -1,63 +1,160 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Время
|
# Время
|
||||||
|
|
||||||
Один формат времени на всё приложение: хранение, логи, API, обмен с
|
Как приложение записывает моменты и длительности: в каком формате, откуда
|
||||||
внешними системами. Разные форматы в разных слоях — источник ошибок,
|
берётся значение и где появляется не-UTC. Форма записи —
|
||||||
которые всплывают через полгода на границе перехода на летнее время.
|
`common/language.md`.
|
||||||
|
|
||||||
## Формат
|
## Область действия
|
||||||
|
|
||||||
- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`.
|
Конвенция описывает фиксацию **свершившихся моментов** — того, что уже
|
||||||
- **Ширина фиксируется на каждый носитель** и внутри него не плавает.
|
произошло и попало в базу, лог или ответ API. Планирование будущих событий —
|
||||||
Лексикографическая сортировка равна хронологии только среди строк
|
отдельный случай: там хранят локальное время плюс имя зоны, потому что
|
||||||
одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
|
правила зон меняются в промежутке между планированием и наступлением. Пока
|
||||||
хронологически позже. Ради этого формат и фиксируется — `ORDER BY
|
такой сущности нет, правил для неё в файле нет.
|
||||||
created_at` по текстовому полю обязан давать порядок событий.
|
|
||||||
- Разные носители могут иметь разную точность: строки БД и строки лога
|
|
||||||
между собой никогда не сравниваются. Требование — не «одна точность на
|
|
||||||
приложение», а «внутри колонки и внутри потока логов ширина одна».
|
|
||||||
- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в
|
|
||||||
логах, ни в JSON API.
|
|
||||||
|
|
||||||
## Генерирует приложение, а не хранилище
|
## Правила
|
||||||
|
|
||||||
- Единая точка получения «сейчас» и единая точка форматирования и разбора —
|
### R1. Единый формат — RFC 3339, UTC, суффикс `Z`
|
||||||
как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по
|
|
||||||
коду не разбросаны: иначе ни формат, ни зона не гарантированы.
|
|
||||||
- **Дефолты в схеме БД не используем.** Забытая вставка `created_at`
|
|
||||||
должна падать громко, а не тихо получать значение от БД — иначе
|
|
||||||
расходятся источник времени (сервер БД) и его формат.
|
|
||||||
|
|
||||||
## Длительность — не метка времени
|
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` —
|
||||||
|
одинаково в хранении, логах, API и обмене с внешними системами.
|
||||||
|
|
||||||
Измерение длительности операции — отдельная величина: число (обычно
|
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой
|
||||||
миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не
|
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
|
||||||
время в формате выше. Засекает её тот слой, который делает вызов.
|
полгода, на переходе на летнее время, когда реальное смещение перестаёт
|
||||||
|
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
|
||||||
|
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
|
||||||
|
|
||||||
**Интервал измеряется монотонными часами процесса**, а не вычитанием
|
### R2. Ширина строки фиксируется на каждый носитель
|
||||||
сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и
|
|
||||||
дать отрицательную длительность. Из этого следует, что источник меток
|
|
||||||
времени и источник интервалов — разные, даже если оба называются «часы».
|
|
||||||
|
|
||||||
## Зоны
|
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
|
||||||
|
строки времени одна и от записи к записи не плавает.
|
||||||
|
|
||||||
Единственное место, где появляется не-UTC, — **отображение пользователю**.
|
**Почему.** Лексикографическая сортировка совпадает с хронологией только
|
||||||
Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию —
|
среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
|
||||||
`UTC`. На хранение, сортировку и логи она не влияет.
|
произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по
|
||||||
|
текстовому полю обязан давать порядок событий. Плавающая ширина (типичный
|
||||||
|
источник — форматирование, отбрасывающее незначащие нули) ломает порядок не
|
||||||
|
везде, а только на тех парах записей, где дробная часть оказалась короче, —
|
||||||
|
то есть редко, выборочно и невоспроизводимо.
|
||||||
|
|
||||||
Если бизнес-логика оперирует календарными сущностями («сегодня»,
|
### R3. Точность разных носителей может различаться
|
||||||
«за месяц»), зона указывается **явно** в месте вычисления — молчаливое
|
|
||||||
использование системной зоны процесса запрещено: она разная на ноутбуке и в
|
|
||||||
контейнере. По умолчанию это та же зона, что и для отображения; если
|
|
||||||
календарная логика требует другой, это записывается явно.
|
|
||||||
|
|
||||||
Конвенция описывает фиксацию **свершившихся моментов**. Планирование
|
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
|
||||||
будущих событий — отдельный случай (там хранят локальное время плюс имя
|
|
||||||
зоны, потому что правила зон меняются); пока такой сущности нет, правило не
|
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что
|
||||||
формулируем.
|
строки разных носителей между собой не сравниваются: сортировка идёт внутри
|
||||||
|
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался
|
||||||
|
как «одна точность на всё приложение»: от подгонки формата логов под формат
|
||||||
|
колонки ни одна пара строк не становится сравнимой, зато точность режется до
|
||||||
|
худшего из носителей.
|
||||||
|
|
||||||
|
### R4. Локальное время не хранится и не передаётся
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
|
||||||
|
зоне.
|
||||||
|
|
||||||
|
**Почему.** Метка без зоны неинтерпретируема вне процесса, который её
|
||||||
|
записал: чтобы понять, какому моменту она соответствует, читателю нужно
|
||||||
|
знать настройки чужой машины на момент записи. И даже зная их, он не
|
||||||
|
разберёт час перехода на зимнее время: этот час идёт дважды, две записи
|
||||||
|
получают одинаковую метку, и порядок между ними не восстанавливается ничем.
|
||||||
|
|
||||||
|
### R5. Единая точка получения «сейчас», форматирования и разбора
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
|
||||||
|
метки; прямые вызовы часов по коду не разбросаны.
|
||||||
|
|
||||||
|
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех
|
||||||
|
меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
|
||||||
|
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
|
||||||
|
данных, и обнаруживается, когда испорченных записей уже накопилось.
|
||||||
|
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`).
|
||||||
|
|
||||||
|
### R6. Дефолтов времени в схеме БД нет
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
|
||||||
|
|
||||||
|
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
|
||||||
|
код: значение появляется, но приходит от сервера БД — то есть с других часов
|
||||||
|
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка
|
||||||
|
падает громко и чинится в момент написания, а не при разборе расхождения
|
||||||
|
между временем в записи и временем в логе. Правило то же, что для
|
||||||
|
идентификаторов (`arch/db-identifiers.md R2`).
|
||||||
|
|
||||||
|
### R7. Длительность — отдельная величина, а не пара меток
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Длительность операции записывается числом (обычно
|
||||||
|
миллисекундами) в поле вида `duration_ms`.
|
||||||
|
|
||||||
|
**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько».
|
||||||
|
Пара меток заставляет каждого потребителя знать, какие именно две из них
|
||||||
|
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
|
||||||
|
логе; число сравнивается, агрегируется и попадает в перцентили без этого
|
||||||
|
шага. Кроме того, разность сохранённых меток считается по стенным часам и
|
||||||
|
наследует их дефект (R9).
|
||||||
|
|
||||||
|
### R8. Длительность засекает слой, который делает вызов
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
|
||||||
|
|
||||||
|
**Почему.** Обе границы операции видит только этот слой: замер уровнем выше
|
||||||
|
приписывает операции чужие накладные расходы, уровнем ниже — теряет часть
|
||||||
|
вызова. В обоих случаях число остаётся правдоподобным и потому не
|
||||||
|
оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
|
||||||
|
|
||||||
|
### R9. Момент и интервал берутся с разных часов
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Источник зависит от того, что записывается:
|
||||||
|
|
||||||
|
| № | Величина | Источник |
|
||||||
|
|---|---|---|
|
||||||
|
| R9.1 | момент события | стенные часы через единую точку (R5) |
|
||||||
|
| R9.2 | длительность операции | монотонные часы процесса |
|
||||||
|
|
||||||
|
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
|
||||||
|
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
|
||||||
|
правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для
|
||||||
|
меток: их ноль произволен и не переживает перезапуск процесса, так что вне
|
||||||
|
процесса такое значение ничего не означает. Отсюда следствие, которое легко
|
||||||
|
упустить: источник меток времени и источник интервалов — разные, даже если
|
||||||
|
оба называются «часы».
|
||||||
|
|
||||||
|
### R10. Не-UTC существует только на слое отображения
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
|
||||||
|
проникает в хранение, сортировку и логи.
|
||||||
|
|
||||||
|
**Почему.** Как только конвертация уходит вглубь, результат вычислений
|
||||||
|
начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт
|
||||||
|
разные группировки, а порядок записей перестаёт быть общим для всех. Ещё
|
||||||
|
хуже, что при конвертации в нескольких слоях её легко выполнить дважды —
|
||||||
|
смещение удваивается, результат остаётся похожим на правду, а найти
|
||||||
|
виновный слой можно только перечитав их все.
|
||||||
|
|
||||||
|
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
|
||||||
|
по умолчанию — `UTC`.
|
||||||
|
|
||||||
|
**Почему.** Зашитая в код зона превращает переезд или второго пользователя в
|
||||||
|
другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано
|
||||||
|
потому, что оно не притворяется настроенным: показанное время совпадает с
|
||||||
|
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
|
||||||
|
как «зону не задали», а не как «где-то потерялось смещение».
|
||||||
|
|
||||||
|
### R12. В календарных вычислениях зона указывается явно
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
|
||||||
|
явно переданной зоной, а не с системной зоной процесса.
|
||||||
|
|
||||||
|
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на
|
||||||
|
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
|
||||||
|
расхождение не воспроизводится там, где его заметили, и объясняется средой,
|
||||||
|
а не кодом. Явно переданная зона делает результат функцией от аргументов.
|
||||||
|
|
||||||
|
Зона по умолчанию здесь та же, что и для отображения (R11); календарная
|
||||||
|
логика, которой нужна другая, получает её тем же явным аргументом.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+201
-90
@@ -2,28 +2,18 @@
|
|||||||
|
|
||||||
Конвенция описывает повторяющийся выбор: как называть директории, как
|
Конвенция описывает повторяющийся выбор: как называть директории, как
|
||||||
раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как
|
раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как
|
||||||
принято», а не «что здесь происходит». Одна конвенция — один файл.
|
принято», а не «что здесь происходит».
|
||||||
|
|
||||||
Как записывается сама конвенция — правила, модальность, обоснования — в
|
Как записывается сама конвенция — правила, модальность, обоснования — в
|
||||||
[language.md](language.md). Здесь — про то, зачем они заводятся, где живут
|
[language.md](language.md). Здесь — про то, зачем конвенции заводятся, где
|
||||||
и как соотносятся с соседними видами документов.
|
живут и как соотносятся с соседними видами документов.
|
||||||
|
|
||||||
## Канон и копии
|
## Область действия
|
||||||
|
|
||||||
Файлы в этой директории с шапкой `origin:` — **копии из общего канона**
|
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
|
||||||
`dev-conventions`, а не собственные документы репозитория. Отсюда:
|
правит существующий, в каноне и в копиях репозиториев. Обязательность живёт
|
||||||
|
на отдельном правиле, а не на файле; шкала модальных слов — в
|
||||||
- репозиторное пишется **только внутрь локальных регионов**
|
[language.md](language.md).
|
||||||
`<!-- local:имя --> … <!-- /local -->`: они исключены из сравнения с
|
|
||||||
каноном, и расхождение по ним — норма, а не дрейф;
|
|
||||||
- правка вне регионов означает одно из двух: улучшение, которое надо
|
|
||||||
вернуть в канон, или сознательное расхождение, записанное в ключ `local:`
|
|
||||||
шапки;
|
|
||||||
- состояние копий показывает `conv status`, различия — `conv diff`,
|
|
||||||
обновление из канона — `conv pull`; всё через раннер репозитория.
|
|
||||||
|
|
||||||
Имя региона обязательно и стабильно: перенос содержимого при обновлении
|
|
||||||
идёт по именам.
|
|
||||||
|
|
||||||
## Отличие от соседей
|
## Отличие от соседей
|
||||||
|
|
||||||
@@ -36,101 +26,222 @@
|
|||||||
- `docs/conventions/` — **правило на будущее**, применяемое многократно.
|
- `docs/conventions/` — **правило на будущее**, применяемое многократно.
|
||||||
Живой документ: правится, когда договорённость меняется.
|
Живой документ: правится, когда договорённость меняется.
|
||||||
|
|
||||||
## Направление: конвенция → код
|
## Канон и копии
|
||||||
|
|
||||||
Конвенция формулируется независимо от того, как устроено конкретное
|
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
|
||||||
приложение. Код следует конвенции, а не наоборот.
|
`dev-conventions`, а не собственные документы репозитория. Репозиторное
|
||||||
|
живёт только внутри локальных регионов `<!-- local:имя --> … <!-- /local -->`:
|
||||||
|
они исключены из сравнения с каноном, и расхождение по ним — норма, а не
|
||||||
|
дрейф. Правка вне регионов означает одно из двух: улучшение, которое
|
||||||
|
возвращают в канон, или сознательное расхождение, записанное в ключ `local:`
|
||||||
|
шапки. Состояние копий показывает `conv status`, различия — `conv diff`,
|
||||||
|
обновление из канона — `conv pull`; всё через раннер репозитория.
|
||||||
|
|
||||||
Если код расходится с правилом — это отступление, и оно записывается в
|
Имя региона обязательно и стабильно: перенос содержимого при обновлении
|
||||||
локальный регион, а не переписывает правило. Правило меняется только тогда,
|
идёт по именам, и переименование осиротит содержимое во всех копиях.
|
||||||
когда оно **неверно по существу**: содержит фактическую ошибку, внутреннее
|
|
||||||
противоречие или условие применимости, которое не даёт ответа.
|
|
||||||
|
|
||||||
Практическое следствие: в тексте конвенции не должно быть утверждений о
|
## Правила
|
||||||
текущем состоянии репозитория. «Так сделано у нас» — это регион
|
|
||||||
отступлений; норма пишется в настоящем предписывающем времени.
|
|
||||||
|
|
||||||
## Насколько правило обязательно
|
### R1. Одна конвенция — один файл
|
||||||
|
|
||||||
Обязательность живёт **на правиле**, а не на файле: один документ почти
|
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
|
||||||
всегда смешивает жёсткие требования с советами, и общая пометка на нём
|
|
||||||
неизбежно врёт про часть содержимого. Шкала модальных слов — в
|
|
||||||
[language.md](language.md).
|
|
||||||
|
|
||||||
Правило без механической проверки держится только на внимании. Для
|
**Почему.** Подписка — это набор лежащих в репозитории файлов, и берут файл
|
||||||
**СЛЕДУЕТ** это нормально, для **ДОЛЖЕН** — плохо: такое правило либо
|
целиком. Файл, собравший две темы, вынуждает репозиторий взять правила,
|
||||||
механизируется, либо честно понижается.
|
которые ему не нужны, и получать шум в `diff` по чужой половине. Разрезать
|
||||||
|
позже дорого: путь файла — часть адреса правила, и после разреза внешние
|
||||||
|
ссылки указывают не туда.
|
||||||
|
|
||||||
## Когда заводить
|
### R2. Конвенция заводится, когда решение принимается третий раз
|
||||||
|
|
||||||
Когда одно и то же решение принимается третий раз и каждый раз чуть
|
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
|
||||||
по-другому. Единичный выбор — не конвенция; если он ещё и был спорным, ему
|
каждый раз чуть по-другому.
|
||||||
место в ADR.
|
|
||||||
|
|
||||||
Путь находки: **находка → конвенция → правило линтера → удаление прозы**.
|
**Почему.** По одному-двум случаям не видно, что в решении повторяется, а
|
||||||
Первые два шага делаются в репозитории, где заболело; общая часть
|
что было частностью места: правило, выведенное из первого случая, кодирует
|
||||||
продвигается в канон.
|
частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же
|
||||||
|
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
|
||||||
|
содержание записи.
|
||||||
|
|
||||||
## Прозой — только то, что не выражается правилом
|
### R3. Новая конвенция пишется там, где заболело
|
||||||
|
|
||||||
Как только свойство удаётся проверить машиной, его формулировка перестаёт
|
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера →
|
||||||
работать: файл на несколько сотен строк размазывает внимание по
|
удаление прозы» делаются в репозитории, где случилась находка; в канон
|
||||||
тривиальному, и человек с агентом добросовестно проверят именование, не
|
продвигается общая часть.
|
||||||
дойдя до формы решения.
|
|
||||||
|
|
||||||
Но удаление прозы в общем каноне устроено иначе, чем в одиночном
|
**Почему.** Правило, написанное сразу в общем виде, не проверено ни одним
|
||||||
репозитории. Механизация — состояние **конкретного** репозитория:
|
применением, и условие применимости у него придумано, а не найдено, —
|
||||||
|
платят за это все потребители сразу. Формулировка, обкатанная на одном
|
||||||
|
репозитории, приезжает в канон уже с известной границей.
|
||||||
|
|
||||||
- **из канона формулировка не удаляется**, пока правило не механизировано
|
### R4. В тексте конвенции нет утверждений о состоянии репозитория
|
||||||
у всех потребителей: иначе те, у кого линтера нет, останутся без правила;
|
|
||||||
- **факт механизации** фиксируется в локальном регионе `механизировано` —
|
|
||||||
со ссылкой на номер правила и на конкретную проверку;
|
|
||||||
- когда механизация стала общей (правило уехало в общий конфиг линтера или
|
|
||||||
в общую роль), формулировка удаляется из канона одним `push`.
|
|
||||||
|
|
||||||
Обоснование правила («Почему») не удаляется никогда, даже когда сама норма
|
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
|
||||||
уехала в линтер: линтер сообщает, что нарушено, но не сообщает, зачем
|
описаний того, как сейчас устроен конкретный репозиторий.
|
||||||
правило существует, — а именно это нужно, чтобы понять, когда его пора
|
|
||||||
отменить.
|
|
||||||
|
|
||||||
## Трудноизменяемые слои
|
**Почему.** Такое утверждение устаревает молча и подменяет норму описанием:
|
||||||
|
читатель перестаёт понимать, что от него требуется, а что просто
|
||||||
|
констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен
|
||||||
|
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
|
||||||
|
и локально, и проверяемо.
|
||||||
|
|
||||||
У схемы БД, формата хранения и раскладки директорий не работает привычное
|
### R5. Расхождение кода с правилом — отступление, а не повод переписать правило
|
||||||
«новое пишем правильно, старое переезжает по мере касания»: таблица не
|
|
||||||
переезжает от того, что её потрогали. Для таких конвенций:
|
|
||||||
|
|
||||||
- **область действия пишется явно** — «применяется к новым таблицам и
|
**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит
|
||||||
миграциям», а не к состоянию схемы;
|
фактическую ошибку, внутреннее противоречие или условие применимости,
|
||||||
- **механизируется граница изменения, а не состояние** — линтер запрещает
|
которое не даёт ответа.
|
||||||
`AUTOINCREMENT` в новых миграциях, а не в существующей схеме: старое не
|
|
||||||
падает, новая ошибка невозможна;
|
|
||||||
- **список отступлений постоянный**, а не список задач на дочистку.
|
|
||||||
|
|
||||||
## Честный список отступлений
|
**Почему.** Правило, подогнанное под текущий код, перестаёт что-либо
|
||||||
|
требовать — оно описывает то, что и так происходит, и первое же расхождение
|
||||||
|
переписывает его снова. Направление «конвенция → код» держится ровно тем,
|
||||||
|
что факт не считается аргументом.
|
||||||
|
|
||||||
В локальном регионе перечисляем отступления, которые уже есть в коде, — со
|
### R6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается
|
||||||
ссылкой на номера правил. Иначе репозиторий делает вид, что конвенции
|
|
||||||
следует, а проверить это можно только чтением всего кода.
|
|
||||||
|
|
||||||
Пустой список отступлений почти всегда означает, что их не искали.
|
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает
|
||||||
|
машинную проверку или переводится в СЛЕДУЕТ.
|
||||||
|
|
||||||
Отступление — это «правилу не следуем здесь и вот почему». Если регион
|
**Почему.** Без проверки правило держится на внимании: нарушения копятся
|
||||||
разросся до «мы это правило вообще не применяем», значит либо у правила
|
молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ
|
||||||
неверно сформулировано условие применимости (чинить в каноне), либо
|
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько
|
||||||
репозиторию не нужна эта конвенция (не подписываться).
|
таких случаев обесценивает остальные ДОЛЖЕН в файле.
|
||||||
|
|
||||||
## Оформление
|
### R7. Факт механизации фиксируется в локальном регионе со ссылкой на номер
|
||||||
|
|
||||||
- Имя файла — kebab-case по теме: `app-directories.md`.
|
**ДОЛЖЕН.** Регион `механизировано` называет номер правила и конкретную
|
||||||
- Раздел «Связано» в конце: ADR с обоснованием, спеки, код, который эту
|
проверку.
|
||||||
конвенцию механизирует. Репо-специфичная часть «Связано» — в локальном
|
|
||||||
регионе, канонические ссылки — в общем тексте.
|
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не
|
||||||
- README директории перечисляет конвенции с однострочным описанием, чтобы
|
знает, а без записи следующий автор либо заведёт вторую проверку того же,
|
||||||
список читался без открывания файлов.
|
либо будет вычитывать глазами уже проверенное машиной. Без номера правила
|
||||||
- **Короткие инварианты дублируются туда, что агент читает безусловно**
|
читатель догадывается сам, к какому утверждению относится проверка, — и
|
||||||
(`AGENTS.md` / `CLAUDE.md`): сама по себе конвенция агенту не видна, он
|
догадывается по-разному.
|
||||||
дойдёт до неё, только если его туда отправили. Детали остаются здесь,
|
|
||||||
в файл-точку-входа едет одна строка на правило с его номером.
|
### R8. Формулировка не удаляется из канона, пока механизирована не у всех
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя
|
||||||
|
машинной проверки нет.
|
||||||
|
|
||||||
|
**Почему.** У кого линтера нет, тот после удаления остаётся без правила
|
||||||
|
вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не
|
||||||
|
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» —
|
||||||
|
значит чинить свой файл за чужой счёт.
|
||||||
|
|
||||||
|
### R9. Общая механизация разрешает удалить норму из канона
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
|
||||||
|
удаляется из канона одним `push`.
|
||||||
|
|
||||||
|
**Почему.** Формулировка, дублирующая работающую у всех проверку,
|
||||||
|
размазывает внимание: файл на несколько сотен строк заставляет человека и
|
||||||
|
агента добросовестно вычитывать тривиальное именование и не доходить до
|
||||||
|
формы решения. Явное разрешение нужно, чтобы R8 не читался как запрет
|
||||||
|
удалять вообще.
|
||||||
|
|
||||||
|
### R10. Обоснование не удаляется никогда
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в
|
||||||
|
линтер.
|
||||||
|
|
||||||
|
**Почему.** Линтер сообщает, что нарушено, но не сообщает, зачем правило
|
||||||
|
существует. Без обоснования не видно, когда причина отпала, — проверка
|
||||||
|
продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
|
||||||
|
|
||||||
|
### R11. У трудноизменяемого слоя область действия пишется явно
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий
|
||||||
|
называет, к чему применяется: к новым таблицам и миграциям, а не к
|
||||||
|
состоянию схемы.
|
||||||
|
|
||||||
|
**Почему.** Здесь не работает привычное «новое пишем правильно, старое
|
||||||
|
переезжает по мере касания»: таблица не переезжает от того, что её
|
||||||
|
потрогали. Без явной рамки правило читается как требование к текущему
|
||||||
|
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
|
||||||
|
молчаливый вывод, что конвенция не соблюдается совсем.
|
||||||
|
|
||||||
|
### R12. Механизируется граница изменения, а не состояние
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
|
||||||
|
существующей схеме.
|
||||||
|
|
||||||
|
**Почему.** Проверка состояния краснеет на легаси с первого дня: её
|
||||||
|
отключают или обвешивают вечным списком исключений — и она перестаёт ловить
|
||||||
|
новое, ради чего заводилась. Проверка границы оставляет старое в покое и
|
||||||
|
делает новую ошибку невозможной.
|
||||||
|
|
||||||
|
### R13. Список отступлений трудноизменяемого слоя — постоянный
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
|
||||||
|
как задачи на дочистку.
|
||||||
|
|
||||||
|
**Почему.** Список, записанный долгом, требует либо мигрировать живые данные
|
||||||
|
без выгоды, либо год за годом объяснять невыполненный план. Второе кончается
|
||||||
|
тем, что список перестают вести, — и пропадает единственное место, где видно,
|
||||||
|
где именно правило не действует.
|
||||||
|
|
||||||
|
### R14. Отступления перечисляются поимённо, со ссылкой на номера правил
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в
|
||||||
|
коде, с номером правила и причиной.
|
||||||
|
|
||||||
|
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
|
||||||
|
это можно только чтением всего кода. Со ссылками отступления счётны: видно,
|
||||||
|
сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при
|
||||||
|
этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
|
||||||
|
|
||||||
|
### R15. Запись в регионе отступлений разбирается по масштабу
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
|
||||||
|
|
||||||
|
| № | Что записано | Куда идёт |
|
||||||
|
|---|---|---|
|
||||||
|
| R15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
|
||||||
|
| R15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
|
||||||
|
| R15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
|
||||||
|
|
||||||
|
**Почему.** Отступление описывает исключение, и по нему видно, какая часть
|
||||||
|
правила нарушена. Запись «мы это правило вообще не применяем» такой
|
||||||
|
информации не несёт и маскирует одну из двух чинимых причин: неверную рамку
|
||||||
|
правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
|
||||||
|
где файл просто не нужен. Оставленная отступлением, она прячет обе.
|
||||||
|
|
||||||
|
### R16. Имя файла — kebab-case по теме
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** `app-directories.md`, а не вариации регистра и разделителя.
|
||||||
|
|
||||||
|
**Почему.** Имя файла — часть глобального адреса правила
|
||||||
|
(`stack/ansible/app-directories.md R4`) и значение ключа `origin` в каждой
|
||||||
|
копии. Один способ записи избавляет от нескольких написаний одного адреса,
|
||||||
|
а ошибка в адресе обнаруживается только тем, кто по нему пришёл и ничего не
|
||||||
|
нашёл.
|
||||||
|
|
||||||
|
### R17. Репо-специфичная часть «Связано» — в локальном регионе
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в
|
||||||
|
общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в
|
||||||
|
локальном регионе.
|
||||||
|
|
||||||
|
**Почему.** Общий текст уезжает `push`-ем ко всем потребителям, и ссылка на
|
||||||
|
чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому
|
||||||
|
та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`.
|
||||||
|
|
||||||
|
### R18. README директории перечисляет конвенции с однострочным описанием
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
|
||||||
|
|
||||||
|
**Почему.** Подписка — это набор лежащих файлов, и без описаний вопрос
|
||||||
|
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток
|
||||||
|
файлов это означает, что не открывают ни одной.
|
||||||
|
|
||||||
|
### R19. Короткие инварианты дублируются в точку входа агента
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
|
||||||
|
номером; детали остаются в конвенции.
|
||||||
|
|
||||||
|
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
|
||||||
|
если его туда отправили, — а безусловно он читает точку входа. Строка с
|
||||||
|
номером служит и напоминанием, и адресом, по которому за подробностями
|
||||||
|
идут; перенос деталей туда же вернул бы задачу поддержки двух текстов.
|
||||||
|
|
||||||
<!-- local:точки-входа -->
|
<!-- local:точки-входа -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+178
-43
@@ -1,26 +1,94 @@
|
|||||||
---
|
---
|
||||||
status: рекомендуемая
|
|
||||||
extends: arch/config.md
|
extends: arch/config.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Конфигурация: реализация на Go
|
# Конфигурация: реализация на Go
|
||||||
|
|
||||||
Как `arch/config.md` выглядит в Go-приложении.
|
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы
|
||||||
|
запрета на окружение. Форма записи — `common/language.md`.
|
||||||
|
|
||||||
## Формат и загрузчик
|
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
|
||||||
|
проверка их непустоты идёт вместе с остальной валидацией — как описано в
|
||||||
|
базовой конвенции.
|
||||||
|
|
||||||
- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся
|
## Правила
|
||||||
готовая структура `Config`.
|
|
||||||
- Одна корневая структура `Config` с под-структурами по секциям — имена
|
|
||||||
структур совпадают с именами секций, чтобы конфиг и код читались рядом.
|
|
||||||
- Умолчания — в `Default()`, поверх накладывается разобранный файл.
|
|
||||||
- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в
|
|
||||||
рабочей директории, образец — `config.example.toml`.
|
|
||||||
|
|
||||||
## Длительности
|
### R1. Формат конфигурации — TOML
|
||||||
|
|
||||||
`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип
|
**ДОЛЖЕН.** Конфиг — файл TOML.
|
||||||
с `UnmarshalText`, отдающий `time.Duration`:
|
|
||||||
|
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор
|
||||||
|
делается один раз на язык, а не в каждом приложении: разные форматы в
|
||||||
|
соседних сервисах означают разные загрузчики, разные шаблоны рендера
|
||||||
|
конфига в деплое и разное поведение при синтаксической ошибке. TOML при
|
||||||
|
этом даёт секции и типизированные скаляры без значимых отступов — конфиг,
|
||||||
|
поправленный руками на сервере, ломается заметно, а не меняет вложенность
|
||||||
|
молча.
|
||||||
|
|
||||||
|
### R2. Разбор и валидация — целиком в `internal/config`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
|
||||||
|
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
|
||||||
|
|
||||||
|
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным;
|
||||||
|
после — уже нет, и это единственная граница, на которой такое утверждение
|
||||||
|
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
|
||||||
|
«проверено ли это поле» только чтением всех вызывающих, часть полей
|
||||||
|
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ
|
||||||
|
посреди работы. Экспортированный разбор вдобавок даёт второй способ
|
||||||
|
получить конфиг — мимо умолчаний (R5).
|
||||||
|
|
||||||
|
### R3. Весь конфиг — одна корневая структура
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
|
||||||
|
под-структур по секциям.
|
||||||
|
|
||||||
|
**Почему.** Один корень даёт одну точку, после которой конфиг проверен
|
||||||
|
целиком, и дальше передаётся как обычный аргумент. Несколько независимых
|
||||||
|
структур конфига означают несколько загрузок и вопрос «какая из них уже
|
||||||
|
провалидирована» на каждом использовании; связанные между собой поля
|
||||||
|
(включена интеграция — заданы все её поля) при этом перестают быть
|
||||||
|
проверяемыми в одном месте.
|
||||||
|
|
||||||
|
### R4. Под-структуры названы по секциям файла
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
|
||||||
|
|
||||||
|
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
|
||||||
|
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
|
||||||
|
в код и обратно; при расхождении связь между полем файла и полем структуры
|
||||||
|
восстанавливается чтением тегов, и проделывать это приходится для каждой
|
||||||
|
секции заново.
|
||||||
|
|
||||||
|
### R5. Умолчания задаёт `Default()`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
|
||||||
|
накладывается поверх.
|
||||||
|
|
||||||
|
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
|
||||||
|
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
|
||||||
|
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
|
||||||
|
видно ни целиком, ни из образца, и два потребителя одного поля со временем
|
||||||
|
подставляют разное. `Default()` — единственное место, откуда список
|
||||||
|
умолчаний читается разом и переносится в образец.
|
||||||
|
|
||||||
|
### R6. Имя файла фиксировано, путь переопределяется флагом
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
|
||||||
|
путь переопределяет флаг `--config=path`, образец рядом —
|
||||||
|
`config.example.toml`.
|
||||||
|
|
||||||
|
**Почему.** Фиксированное имя и переопределение из командной строки требует
|
||||||
|
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
|
||||||
|
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
|
||||||
|
пишутся, не открывая код приложения. Соседство `config.toml` и
|
||||||
|
`config.example.toml` вдобавок делает расхождение образца с реальным
|
||||||
|
конфигом видимым обычным `diff`, а не вычиткой.
|
||||||
|
|
||||||
|
### R7. Длительности — собственный тип с `UnmarshalText`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
|
||||||
|
`time.Duration`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Duration time.Duration
|
type Duration time.Duration
|
||||||
@@ -29,50 +97,117 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
|
|||||||
func (d Duration) Std() time.Duration { … }
|
func (d Duration) Std() time.Duration { … }
|
||||||
```
|
```
|
||||||
|
|
||||||
Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое
|
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое
|
||||||
число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей
|
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
|
||||||
валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь
|
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
|
||||||
отдельно и первой.
|
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
|
||||||
|
зависшим ожиданием. Запись `poll_interval = "5s"` несёт единицу измерения
|
||||||
|
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
|
||||||
|
|
||||||
## Чтение окружения
|
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
|
||||||
|
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности
|
||||||
|
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
|
||||||
|
показываются.
|
||||||
|
|
||||||
Приложение не читает окружение для конфигурации. Механизируется
|
### R8. Приложение не читает окружение
|
||||||
`forbidigo`, и паттерн должен покрывать **все** входы, а не только
|
|
||||||
|
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
|
||||||
|
|
||||||
|
**Почему.** Второй канал конфигурации — то, против чего написана базовая
|
||||||
|
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
|
||||||
|
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
|
||||||
|
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
|
||||||
|
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
|
||||||
|
выставлена.
|
||||||
|
|
||||||
|
### R9. Проверка запрета покрывает все входы в окружение
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только
|
||||||
`os.Getenv`:
|
`os.Getenv`:
|
||||||
|
|
||||||
```
|
```
|
||||||
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
|
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
|
||||||
```
|
```
|
||||||
|
|
||||||
Правило про приложение, поэтому за его границей запрет не действует:
|
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
|
||||||
|
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
|
||||||
|
незаметно: правило числится механизированным, и глазами его больше никто не
|
||||||
|
проверяет.
|
||||||
|
|
||||||
- **тесты** — не приложение: интеграционному тесту нормально брать
|
### R10. За границей приложения запрет не действует
|
||||||
креды внешнего сервиса из окружения;
|
|
||||||
- **переменные рантайма** — те, что читает не наш код, а Go или ОС
|
|
||||||
(`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`).
|
|
||||||
|
|
||||||
Отдельный случай — переменные, которые читает **стандартная библиотека от
|
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
|
||||||
имени приложения**: дефолтный `http.Transport` уважает
|
приложение:
|
||||||
`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это
|
|
||||||
конфигурация поведения приложения, поэтому прокси задаётся полем конфига и
|
|
||||||
явным `Transport`, а не окружением.
|
|
||||||
|
|
||||||
## Валидация
|
| № | Кто читает | Вердикт |
|
||||||
|
|---|---|---|
|
||||||
|
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
|
||||||
|
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
|
||||||
|
|
||||||
- Проверки собираются `errors.Join`, чтобы за один запуск показать **все**
|
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не
|
||||||
проблемы конфига, а не первую.
|
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
|
||||||
- IANA-зона валидируется `time.LoadLocation`. База зон встраивается
|
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
|
||||||
импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете:
|
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
|
||||||
иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной
|
механизм. Явное разрешение нужно и потому, что нерасписанная граница
|
||||||
базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие
|
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
|
||||||
zoneinfo в контейнере.
|
руками, вместе с ними проходят и нелегальные.
|
||||||
- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до
|
|
||||||
|
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
|
||||||
|
|
||||||
|
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
|
||||||
|
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
|
||||||
|
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
|
||||||
|
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут
|
||||||
|
исходящих запросов отличается от машины к машине без единого следа в
|
||||||
|
конфиге и в образце, а расследование начинается с вопроса «почему на
|
||||||
|
сервере ходит не так, как локально».
|
||||||
|
|
||||||
|
### R12. Проблемы конфига собираются `errors.Join`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
|
||||||
|
ошибка, собранная `errors.Join`.
|
||||||
|
|
||||||
|
**Почему.** Возврат первой ошибки превращает починку конфига в серию
|
||||||
|
перезапусков по одному полю за раз, причём каждый следующий запуск
|
||||||
|
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
|
||||||
|
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
|
||||||
|
вложенной проблеме.
|
||||||
|
|
||||||
|
### R13. Имя зоны проверяется `time.LoadLocation`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
|
||||||
|
|
||||||
|
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
|
||||||
|
тогда, когда база зон его знает, и никакая проверка формата не отличит
|
||||||
|
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
|
||||||
|
доживает до первого форматирования времени — то есть до рантайма, мимо
|
||||||
|
fail-fast (R15).
|
||||||
|
|
||||||
|
### R14. `time/tzdata` импортируется в `main`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
|
||||||
|
пакете.
|
||||||
|
|
||||||
|
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
|
||||||
|
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
|
||||||
|
полагаться на системную» принадлежит собираемой программе. Со встроенной
|
||||||
|
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без
|
||||||
|
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
|
||||||
|
zoneinfo, а сообщение указывает не на ту причину.
|
||||||
|
|
||||||
|
### R15. Невалидный конфиг — `ERROR` и выход из `main`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
|
||||||
старта серверов и воркеров.
|
старта серверов и воркеров.
|
||||||
|
|
||||||
## Секреты
|
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
|
||||||
|
оставляет вызывающему возможности ни залогировать причину, ни дописать
|
||||||
Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их
|
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
|
||||||
непустоты идёт вместе с остальной валидацией — см. базу.
|
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
|
||||||
|
во внешний сервис и записать в базу от имени процесса, который потом
|
||||||
|
объявит, что не стартовал.
|
||||||
|
|
||||||
<!-- local:поля -->
|
<!-- local:поля -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+110
-30
@@ -1,47 +1,127 @@
|
|||||||
---
|
---
|
||||||
status: рекомендуемая
|
|
||||||
extends: arch/db-identifiers.md
|
extends: arch/db-identifiers.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Идентификаторы: реализация на Go
|
# Идентификаторы: реализация на Go
|
||||||
|
|
||||||
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID.
|
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID
|
||||||
|
(ветка `arch/db-identifiers.md` R1.1). Форма записи — `common/language.md`.
|
||||||
|
|
||||||
## Единая точка — `internal/ident`
|
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он
|
||||||
|
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
|
||||||
|
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
|
||||||
|
|
||||||
- `ident.NewID()` — генерация. **PK сущности** генерируется в `Create`-методах
|
## Правила
|
||||||
слоя `store`. Прочие идентификаторы (батч, задание, корреляционный ключ)
|
|
||||||
генерируются там, где начинается операция, — но тоже только через `ident`.
|
|
||||||
- `ident.NewIDAt(t)` — генерация с заданным временем, для бэкфилла в
|
|
||||||
Go-миграциях: сортировка id тогда сохраняет историческую хронологию, а не
|
|
||||||
момент прогона миграции.
|
|
||||||
- `ident.Parse()` — разбор и нормализация; зовётся на **входных границах**
|
|
||||||
(HTTP-роут, форма, callback бота), до обращения к store.
|
|
||||||
- Других генераторов и парсеров id в коде нет. Это то самое «единая точка»
|
|
||||||
из базовой конвенции; без него нормализация регистра неизбежно
|
|
||||||
где-нибудь пропускается.
|
|
||||||
|
|
||||||
## Типы
|
### R1. Генерация и разбор — только через `internal/ident`
|
||||||
|
|
||||||
В структурах store и домена id — обычный `string`. Отдельный тип `ID`
|
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
|
||||||
заводим, только если появится вторая семья идентификаторов, которую можно
|
`internal/ident`; других генераторов и парсеров id в коде нет.
|
||||||
перепутать; до этого он даёт конверсии без выгоды. От перепутывания двух id
|
|
||||||
одной семьи в сигнатуре он всё равно не спасает — там помогают имена
|
|
||||||
параметров.
|
|
||||||
|
|
||||||
## Невалидный id на границе
|
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов
|
||||||
|
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
|
||||||
|
выглядит нарушением: значение получается валидное, просто мимо нормализации
|
||||||
|
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
|
||||||
|
библиотеки где-либо, кроме `internal/ident`, находится поиском по имени
|
||||||
|
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
|
||||||
|
перестанет находить существующую запись.
|
||||||
|
|
||||||
Разбор не удался — дальше зависит от того, откуда id пришёл:
|
### R2. Первичный ключ генерируется в `Create`-методах store
|
||||||
|
|
||||||
- **из пути или query URL** — сразу 404, без обращения к store и без
|
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
|
||||||
фабрикации доменной ошибки: снаружи это неотличимо от несуществующей
|
внутри `Create`-метода слоя store.
|
||||||
записи, и хорошо;
|
|
||||||
- **из собственной формы или callback-данных кнопки** — 400 либо понятное
|
|
||||||
сообщение («кнопка устарела»): это баг интерфейса или протухший экран, и
|
|
||||||
под «не найдено» его маскировать нельзя.
|
|
||||||
|
|
||||||
Транспорт не создаёт доменные sentinel'ы, чтобы тут же их сматчить, — это
|
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было
|
||||||
инверсия правила «трансляция у источника» из `lang/go/errors.md`.
|
известно до вставки, но не говорит, кто его присваивает. Store — последний
|
||||||
|
слой, через который проходят все пути создания строки, включая импорт,
|
||||||
|
фоновые задания и тесты. Генерация выше по стеку делает присвоение
|
||||||
|
обязанностью каждого нового вызывающего, и первый забывший запишет пустую
|
||||||
|
строку в колонку ключа: для строкового PK это валидное значение, база его
|
||||||
|
не отклонит, и дефект обнаружится на второй такой вставке.
|
||||||
|
|
||||||
|
### R3. Прочие идентификаторы генерируются в точке начала операции
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
|
||||||
|
вызовом `ident.NewID()` там, где операция начинается.
|
||||||
|
|
||||||
|
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) —
|
||||||
|
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
|
||||||
|
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
|
||||||
|
когда операция упала до того, как что-либо записала: без общего ключа эти
|
||||||
|
записи из лога не собираются вообще.
|
||||||
|
|
||||||
|
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
|
||||||
|
Go-миграции, порождаются с историческим временем строки, а не с текущим.
|
||||||
|
|
||||||
|
**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не
|
||||||
|
момент прогона миграции. Иначе все затронутые строки получают метку одного
|
||||||
|
момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id`
|
||||||
|
начинает врать ровно на том массиве данных, который старше всего.
|
||||||
|
Исправить это потом нельзя: исходное время в идентификаторе не
|
||||||
|
восстановить.
|
||||||
|
|
||||||
|
### R5. Разбор — на входных границах, до обращения к store
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
|
||||||
|
callback'а бота — раньше, чем идентификатор попадёт в store.
|
||||||
|
|
||||||
|
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана
|
||||||
|
транспортная, потому что только на ней известен источник значения, от
|
||||||
|
которого зависит реакция (R8): store видит одинаковую строку независимо от
|
||||||
|
того, пришла она из URL или из собственной формы, и ответить по-разному
|
||||||
|
оттуда уже невозможно.
|
||||||
|
|
||||||
|
### R6. Id в структурах — обычный `string`
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
|
||||||
|
`string`.
|
||||||
|
|
||||||
|
**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им
|
||||||
|
ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и
|
||||||
|
`authorID`) он не спасает — оба будут одного типа, и различают их имена
|
||||||
|
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
|
||||||
|
json и шаблонами, то есть даёт цену без выгоды.
|
||||||
|
|
||||||
|
### R7. Отдельный тип — когда появляется вторая семья идентификаторов
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
|
||||||
|
можно перепутать, для них заводятся различимые типы.
|
||||||
|
|
||||||
|
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на
|
||||||
|
типизацию навсегда. Условие названо ровно то, при котором тип начинает
|
||||||
|
работать: пока все идентификаторы — `string`, подстановка одного вида
|
||||||
|
вместо другого компилируется и обнаруживается только на данных.
|
||||||
|
|
||||||
|
### R8. Реакция на невалидный id зависит от источника
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
|
||||||
|
значение:
|
||||||
|
|
||||||
|
| № | Источник | Ответ |
|
||||||
|
|---|---|---|
|
||||||
|
| R8.1 | путь или query URL | 404 без обращения к store |
|
||||||
|
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
|
||||||
|
|
||||||
|
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах
|
||||||
|
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи —
|
||||||
|
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2
|
||||||
|
значение сформировало само приложение, и невалидность означает баг
|
||||||
|
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
|
||||||
|
в логах не оставляет аномалии и тем самым съедает единственный момент,
|
||||||
|
когда дефект заметен.
|
||||||
|
|
||||||
|
### R9. Транспорт не создаёт доменные ошибки
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
|
||||||
|
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
|
||||||
|
|
||||||
|
**Почему.** Инверсия правила «трансляция у источника» из
|
||||||
|
`lang/go/errors.md`. Sentinel — сообщение от слоя, который знает факт:
|
||||||
|
строка не найдена, потому что store её искал. Сфабрикованный транспортом,
|
||||||
|
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
|
||||||
|
вообще поход в хранилище — а на этом держится вся диагностика по ошибкам.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+172
-33
@@ -1,44 +1,183 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Схема и миграции (SQLite, Go)
|
# Схема и миграции (SQLite, Go)
|
||||||
|
|
||||||
Область действия — **новые миграции**. Существующая схема не переписывается;
|
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
|
||||||
линтер проверяет то, что добавляется, а не то, что уже лежит.
|
Go-приложении. Форма записи — `common/language.md`.
|
||||||
|
|
||||||
|
## Область действия
|
||||||
|
|
||||||
|
Схема меняется тяжело: таблица не переезжает от того, что её потрогали.
|
||||||
|
Правила распространяются на **новые миграции**; существующая схема не
|
||||||
|
переписывается, и проверяется граница изменения — то, что миграция
|
||||||
|
добавляет, а не то, что уже лежит в базе.
|
||||||
|
|
||||||
## Миграции
|
## Миграции
|
||||||
|
|
||||||
- Инструмент — goose, файлы миграций лежат рядом со store-слоем.
|
### R1. Миграции ведёт goose
|
||||||
- **SQL-файл** для DDL: создание таблиц, индексы, изменение структуры.
|
|
||||||
- **Go-миграция** (`goose.AddMigrationContext`) — когда нужен код:
|
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
|
||||||
генерация идентификаторов, backfill, перенос данных между формами.
|
goose.
|
||||||
Не пытаемся выразить это SQL-ом ради единообразия.
|
|
||||||
- **В деплое движение только вперёд.** Down-миграция — инструмент
|
**Почему.** Журнал применённых версий goose держит в самой базе
|
||||||
разработки, а не отката на сервере.
|
(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй
|
||||||
- **Down пишется, когда он честно обращает up**: убрать то, что up добавил.
|
инструмент заводит второй журнал: миграция, применённая одним, для другого
|
||||||
Не пишется, когда up необратимо трансформирует данные, — тогда его
|
выглядит неприменённой, и попытка накатить её повторно упирается в уже
|
||||||
отсутствие честнее имитации, которая молча теряет колонку.
|
существующую таблицу. На сервере это означает ручной разбор состояния
|
||||||
- При изменении структуры ER-схема в спеках обновляется **в том же
|
схемы вместо автоматического деплоя.
|
||||||
изменении**, а не «потом»: разошедшаяся схема хуже отсутствующей.
|
|
||||||
|
### R2. Файлы миграций лежат рядом со store-слоем
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
|
||||||
|
схемой.
|
||||||
|
|
||||||
|
**Почему.** Миграция и код, читающий схему, — одно изменение: колонка
|
||||||
|
появляется вместе с полем структуры и запросом. Лежащие в другом конце
|
||||||
|
дерева миграции выпадают из поля зрения при правке store, и уезжает либо
|
||||||
|
код без миграции, либо миграция без кода; расходятся они на сервере, где
|
||||||
|
схема ещё старая.
|
||||||
|
|
||||||
|
### R3. Форма миграции выбирается по тому, нужен ли код
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
|
||||||
|
|
||||||
|
| № | Что делает миграция | Форма |
|
||||||
|
|---|---|---|
|
||||||
|
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
|
||||||
|
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
|
||||||
|
|
||||||
|
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
|
||||||
|
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
|
||||||
|
ошибиться, не добавляя ничего к результату.
|
||||||
|
|
||||||
|
Обратное направление дороже. Перенос данных и генерация идентификаторов
|
||||||
|
выражаются на SQL либо громоздко, либо неточно: идентификатор по
|
||||||
|
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена
|
||||||
|
завести для него второй генератор — ровно то, что запрещает
|
||||||
|
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается
|
||||||
|
дублированием логики, которая уже есть в коде.
|
||||||
|
|
||||||
|
### R4. В деплое схема движется только вперёд
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
|
||||||
|
ошибка исправляется новой миграцией вперёд.
|
||||||
|
|
||||||
|
**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует
|
||||||
|
его: колонка, которую убрал up, восстанавливается пустой, а строки,
|
||||||
|
записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом
|
||||||
|
происходит молча — миграция отчитывается об успехе. Исправление, приехавшее
|
||||||
|
следующей миграцией, оставляет целыми и данные, и журнал применённых
|
||||||
|
версий.
|
||||||
|
|
||||||
|
### R5. Down пишется, когда он честно обращает up
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
|
||||||
|
|
||||||
|
| № | Что делает up | Down |
|
||||||
|
|---|---|---|
|
||||||
|
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
|
||||||
|
| R5.2 | необратимо преобразует данные | не пишется |
|
||||||
|
|
||||||
|
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
|
||||||
|
и именно там он обязан действительно обращать up. Имитация опаснее
|
||||||
|
отсутствия: разработчик применяет её, получает схему прежней формы и
|
||||||
|
продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий
|
||||||
|
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
|
||||||
|
отладка по данным, которых уже нет.
|
||||||
|
|
||||||
|
### R6. ER-схема обновляется в том же изменении
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
|
||||||
|
изменением.
|
||||||
|
|
||||||
|
**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл.
|
||||||
|
Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить
|
||||||
|
это можно, только сверив её с миграциями, то есть проделав работу, которую
|
||||||
|
диаграмма экономит. Отложенное обновление не делается: изменение уже
|
||||||
|
влито, и повода вернуться к схеме больше нет.
|
||||||
|
|
||||||
## Типы колонок
|
## Типы колонок
|
||||||
|
|
||||||
- **Enum-поля** (`state`, `kind`, …) — обычный `TEXT` **без `CHECK`**.
|
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
|
||||||
Допустимые значения держит код. `ALTER TABLE` в SQLite не умеет менять
|
а не язык приложения.
|
||||||
ограничения ни в одной версии, поэтому каждое новое значение в
|
|
||||||
`CHECK(... IN (...))` означает пересоздание таблицы по 12-шаговой
|
### R7. Enum-поля — `TEXT`, допустимые значения держит код
|
||||||
процедуре; защита от невалидного значения всё равно нужна на уровне типов
|
|
||||||
Go.
|
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
|
||||||
- **Метки времени** — `TEXT` в формате из `arch/time.md`. Без
|
без `CHECK`-ограничения на список значений.
|
||||||
`DEFAULT (datetime('now'))`: помимо того, что время ставит приложение,
|
|
||||||
эта функция даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, то есть не
|
**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
|
||||||
тот формат.
|
версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))`
|
||||||
- **Булевы** — `INTEGER` 0/1. Отдельного типа в SQLite нет, а строка
|
превращается из строки в коде в пересоздание таблицы по 12-шаговой
|
||||||
`'true'` в булевом контексте приводится к **0** — то есть тихо
|
процедуре, с копированием данных и восстановлением внешних ключей.
|
||||||
инвертирует смысл, а не просто ломает фильтрацию.
|
|
||||||
- **Первичные ключи** — если репозиторий взял `arch/db-identifiers.md`, то
|
Платить эту цену не за что: невалидное значение отсекается типами Go
|
||||||
по ней (без `AUTOINCREMENT`); иначе автоинкремент допустим.
|
раньше, чем дойдёт до вставки, и `CHECK` лишь дублирует защиту, которая
|
||||||
|
всё равно нужна выше. `TEXT` при этом читается в дампе и в логе без
|
||||||
|
таблицы соответствия, которую пришлось бы держать в голове для числового
|
||||||
|
кода.
|
||||||
|
|
||||||
|
### R8. Метки времени — `TEXT` в формате из `arch/time.md`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
|
||||||
|
пишутся в формате из `arch/time.md`.
|
||||||
|
|
||||||
|
**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает
|
||||||
|
значения сравнимыми, — договорённость о формате. Текст в формате из
|
||||||
|
`arch/time.md` сортируется лексикографически в том же порядке, что и
|
||||||
|
хронологически: `ORDER BY` и диапазонные условия работают без функций
|
||||||
|
преобразования, а значит и без потери индекса. Соседство двух форматов в
|
||||||
|
одной колонке ломает и сравнение, и разбор на стороне Go.
|
||||||
|
|
||||||
|
### R9. Умолчание `DEFAULT (datetime('now'))` не ставится
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
|
||||||
|
на уровне схемы.
|
||||||
|
|
||||||
|
**Почему.** Время ставит приложение, и умолчание в схеме заводит второй
|
||||||
|
источник этого значения: пропущенное приложением поле не падает, а тихо
|
||||||
|
получает время сервера базы — расхождение обнаруживается по данным, а не
|
||||||
|
по ошибке.
|
||||||
|
|
||||||
|
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
|
||||||
|
то есть не тот формат, которого требует R8. В колонке оказываются строки
|
||||||
|
двух видов, и ломается ровно то, ради чего формат выбран.
|
||||||
|
|
||||||
|
### R10. Булевы поля — `INTEGER` со значениями 0 и 1
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
|
||||||
|
|
||||||
|
**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
|
||||||
|
колонку удерживает только договорённость о представлении. Цена ошибки
|
||||||
|
здесь несимметрична: строка `'true'` в булевом контексте приводится к
|
||||||
|
**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
|
||||||
|
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
|
||||||
|
проверяют, что список не пуст.
|
||||||
|
|
||||||
|
### R11. Вид первичного ключа задаёт `arch/db-identifiers.md`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид
|
||||||
|
ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется.
|
||||||
|
|
||||||
|
**Почему.** Вопрос о виде ключа решается один раз на репозиторий
|
||||||
|
(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы
|
||||||
|
второй источник правды, и соседние таблицы разъехались бы по разным
|
||||||
|
ответам на один и тот же вопрос.
|
||||||
|
|
||||||
|
`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не
|
||||||
|
касается вовсе, а целочисленному даёт единственную гарантию — что значение
|
||||||
|
rowid не будет переиспользовано после удаления строки, — ценой служебной
|
||||||
|
таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта
|
||||||
|
имеет смысл, только если старые идентификаторы живут где-то вне базы.
|
||||||
|
|
||||||
|
### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`,
|
||||||
|
берёт целочисленный автоинкрементный ключ.
|
||||||
|
|
||||||
|
**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование
|
||||||
|
подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня
|
||||||
|
репозитория, и конвенция про типы колонок его за репозиторий не принимает;
|
||||||
|
приложению, сущности которого не адресуют снаружи, целочисленный ключ
|
||||||
|
ничего не стоит.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+282
-100
@@ -1,140 +1,322 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Ошибки
|
# Ошибки
|
||||||
|
|
||||||
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
|
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
|
||||||
**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один
|
`common/language.md`. Где и когда ошибку **логировать** — в
|
||||||
раз на доменной границе).
|
`lang/go/logging.md` (коротко: лог один раз на доменной границе).
|
||||||
|
|
||||||
## Базовая идиома: stdlib
|
## Правила
|
||||||
|
|
||||||
- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`,
|
### R1. Ошибки строятся средствами стандартной библиотеки
|
||||||
а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка
|
|
||||||
сообщений локализует место не хуже стека, а стек-трейсы и Sentry
|
|
||||||
избыточны для домашнего сервиса.
|
|
||||||
- Если отладка начнёт упираться в «где именно родилась ошибка» — это
|
|
||||||
сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
|
|
||||||
- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет
|
|
||||||
вовсе (см. «panic»).
|
|
||||||
|
|
||||||
## Обёртка и контекст
|
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
|
||||||
|
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
|
||||||
|
|
||||||
Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код
|
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
|
||||||
наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью
|
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений
|
||||||
API») относится к библиотекам, поэтому внутри приложения обёртка `%w` —
|
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
|
||||||
**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои.
|
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
|
||||||
|
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
|
||||||
|
это цена без покупателя.
|
||||||
|
|
||||||
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
|
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
|
||||||
- `%w` — когда вызывающий может инспектировать причину (обычный случай).
|
неё цепочки `%w` нет вовсе (R23).
|
||||||
`%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать
|
|
||||||
вызывающего на чужой тип ошибки.
|
|
||||||
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в
|
|
||||||
цепочке, а трансляцией на внешней границе (ниже).
|
|
||||||
|
|
||||||
Стиль сообщения:
|
### R2. Дефолт не обходится точечно
|
||||||
|
|
||||||
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка
|
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
|
||||||
и так читается как «контекст: причина»;
|
кодовой базы ради конкретной отладки.
|
||||||
- контекст — операция или субъект: `"link target: %w"`, не
|
|
||||||
`"something failed"`;
|
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий
|
||||||
- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний
|
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
|
||||||
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
|
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
|
||||||
|
локально, перестаёт накапливаться — а накопление и есть единственный
|
||||||
|
сигнал, что решение R1 пора пересматривать целиком.
|
||||||
|
|
||||||
|
### R3. Каждый слой добавляет свой контекст
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
|
||||||
|
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
|
||||||
|
|
||||||
|
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько,
|
||||||
|
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
|
||||||
|
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
|
||||||
|
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
|
||||||
|
кода.
|
||||||
|
|
||||||
|
### R4. Обёртка по умолчанию — `%w`
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
|
||||||
|
вызывающему:
|
||||||
|
|
||||||
|
| № | Ситуация | Глагол |
|
||||||
|
|---|---|---|
|
||||||
|
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
|
||||||
|
| R4.2 | причину сознательно не раскрываем | `%v` |
|
||||||
|
|
||||||
|
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
|
||||||
|
становится частью API» — относится к библиотекам с внешними потребителями.
|
||||||
|
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
|
||||||
|
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
|
||||||
|
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
|
||||||
|
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
|
||||||
|
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда
|
||||||
|
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
|
||||||
|
|
||||||
|
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v`
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
|
||||||
|
ошибку наружу.
|
||||||
|
|
||||||
|
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
|
||||||
|
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
|
||||||
|
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
|
||||||
|
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
|
||||||
|
Настоящее место защиты — R13.
|
||||||
|
|
||||||
|
### R6. Текст обёртки — со строчной буквы и без служебных слов
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
|
||||||
|
|
||||||
|
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка
|
||||||
|
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
|
||||||
|
на середине. Слова «failed» и «error» не несут информации: то, что перед
|
||||||
|
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
|
||||||
|
каждом уровне и вытесняют из строки полезный контекст.
|
||||||
|
|
||||||
|
### R7. Контекст обёртки называет операцию или субъект
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
|
||||||
|
|
||||||
|
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something
|
||||||
|
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
|
||||||
|
бы занять единственный полезный здесь факт — имя операции.
|
||||||
|
|
||||||
|
### R8. Слой не повторяет смысл нижнего
|
||||||
|
|
||||||
|
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
|
||||||
|
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
|
||||||
|
|
||||||
|
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
|
||||||
|
же событие названо дважды. Читателю приходится проверять, не два ли это
|
||||||
|
разных места в коде, — то есть заикание не просто бесполезно, оно стоит
|
||||||
|
времени при каждом чтении лога.
|
||||||
|
|
||||||
## Две трансляции
|
## Две трансляции
|
||||||
|
|
||||||
Ошибка меняет форму дважды, и это разные преобразования.
|
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
|
||||||
|
доменная у источника (R9) и доменная → пользовательская на внешней границе
|
||||||
|
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
|
||||||
|
|
||||||
**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки
|
### R9. Инфраструктурная ошибка транслируется в доменную у источника
|
||||||
зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный
|
|
||||||
`store.ErrNotFound` в слое store, чтобы выше по коду не торчал
|
|
||||||
`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK.
|
|
||||||
|
|
||||||
**Вторая — на внешней границе, доменная → пользовательская.** Описана
|
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
|
||||||
ниже, в разделе про каналы.
|
возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для
|
||||||
|
HTTP-клиентов, файловой системы, внешних SDK.
|
||||||
|
|
||||||
## Sentinel vs типизированные
|
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв
|
||||||
|
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
|
||||||
|
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
|
||||||
|
адаптер, а все ветвления в приложении — притом что снаружи адаптера
|
||||||
|
состояние «нет записи» одно и то же. Трансляция у источника оставляет
|
||||||
|
знание о зависимости в единственном слое, который её и так знает.
|
||||||
|
|
||||||
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
|
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
|
||||||
на которые ветвится код: нет записи, дубликат, неподдерживаемый источник.
|
|
||||||
Проверяем `errors.Is`.
|
|
||||||
- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда
|
|
||||||
вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём
|
|
||||||
`errors.As`. Не плодим типы там, где хватает sentinel.
|
|
||||||
- Матчинг по тексту сообщения запрещён — это то же самое, что публичный
|
|
||||||
API из строки лога.
|
|
||||||
|
|
||||||
## Граница: приватный канал vs публичный
|
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
|
||||||
|
|
||||||
|
| № | Что нужно вызывающему | Форма |
|
||||||
|
|---|---|---|
|
||||||
|
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
|
||||||
|
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
|
||||||
|
|
||||||
|
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
|
||||||
|
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
|
||||||
|
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
|
||||||
|
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
|
||||||
|
каждой проверке. Две формы для одного условия — это два способа его
|
||||||
|
проверить, и про второй рано или поздно забудут.
|
||||||
|
|
||||||
|
### R11. Матчинг по тексту сообщения
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
|
||||||
|
|
||||||
|
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его
|
||||||
|
свободно. Правка формулировки в нижнем слое молча ломает ветвление
|
||||||
|
наверху, и компилятор этого не видит. Это то же самое, что публичный API из
|
||||||
|
строки лога.
|
||||||
|
|
||||||
|
## Граница: приватный канал и публичный
|
||||||
|
|
||||||
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
|
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
|
||||||
того, кто канал видит.
|
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
|
||||||
|
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
|
||||||
|
|
||||||
**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
|
### R12. Полная ошибка идёт в приватный канал
|
||||||
цепочкой `%w` и контекстом. Пишется один раз на доменной границе.
|
|
||||||
|
|
||||||
**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот).
|
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
|
||||||
Сюда отдаём:
|
— `lang/go/logging.md`.
|
||||||
|
|
||||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой
|
**Почему.** Цепочка — единственный носитель диагностики (R1), и
|
||||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
|
единственный канал, где её можно показать целиком, — тот, который видит
|
||||||
- **корреляционный ключ** для владельца — id сущности либо `request_id`,
|
владелец. Не записанная там, она не сохранится нигде: наружу идёт
|
||||||
чтобы по нему найти полную ошибку в логах. «При обработке загрузки
|
нейтральное сообщение (R13), и восстанавливать причину будет не из чего.
|
||||||
произошла ошибка, download_id=…» вместо «произошла ошибка»;
|
|
||||||
- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной
|
|
||||||
точке на все транспорты. У транспортов без статусов (бот) от маппинга
|
|
||||||
берётся только сообщение.
|
|
||||||
|
|
||||||
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
|
### R13. Публичная поверхность получает сообщение по доменной ошибке
|
||||||
**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя
|
|
||||||
ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR`
|
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
|
||||||
вместо `DEBUG`.
|
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
|
||||||
|
|
||||||
|
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
|
||||||
|
— у него есть лог (R12). Зато они раскрывают устройство системы — имена
|
||||||
|
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
|
||||||
|
причём раскрывают именно в момент, когда что-то пошло не так.
|
||||||
|
|
||||||
|
### R14. Публичное сообщение несёт корреляционный ключ
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
|
||||||
|
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
|
||||||
|
ошибка».
|
||||||
|
|
||||||
|
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его
|
||||||
|
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
|
||||||
|
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
|
||||||
|
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
|
||||||
|
видел.
|
||||||
|
|
||||||
|
### R15. Маппинг доменных ошибок — в одной точке на все транспорты
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
|
||||||
|
задаётся один раз; транспорт без статусов (бот) берёт из него только
|
||||||
|
сообщение.
|
||||||
|
|
||||||
|
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
|
||||||
|
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
|
||||||
|
важнее: единственная точка — это место, куда механически дописывается новая
|
||||||
|
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде»
|
||||||
|
ничем не проверяет.
|
||||||
|
|
||||||
|
### R16. Новая штатная ветвь отказа сразу попадает в маппинг
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
|
||||||
|
добавляется в маппинг (R15) тем же изменением.
|
||||||
|
|
||||||
|
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
|
||||||
|
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
|
||||||
|
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
|
||||||
|
шуметь в логе ровно там, где по нему ищут настоящие поломки.
|
||||||
|
|
||||||
<!-- local:маппинг -->
|
<!-- local:маппинг -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
### Транзиентный ответ vs персистентная диагностика
|
### R17. Форма текста определяется поверхностью
|
||||||
|
|
||||||
У публичной границы две разные поверхности, и правило сырого текста для них
|
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
|
||||||
разное:
|
текста для них разное:
|
||||||
|
|
||||||
|
| № | Поверхность | Текст ошибки |
|
||||||
|
|---|---|---|
|
||||||
|
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт |
|
||||||
|
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
|
||||||
|
|
||||||
- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по
|
|
||||||
результату команды) — строго нейтральный: маппинг выше, `err.Error()`
|
|
||||||
наружу не идёт, полная ошибка живёт в логах по корреляционному ключу.
|
|
||||||
- **Персистентная диагностика состояния** — причина ухода записи в
|
|
||||||
ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь
|
|
||||||
сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и
|
|
||||||
полезен — **но только пока поверхность видит исключительно владелец**.
|
|
||||||
Появился второй зритель или публичный доступ к экрану состояния —
|
Появился второй зритель или публичный доступ к экрану состояния —
|
||||||
поверхность стала публичным каналом, и правило нейтрального текста
|
поверхность стала публичным каналом, и на неё распространяется R17.1.
|
||||||
распространяется на неё. Секреты запрещены абсолютно в обоих случаях;
|
|
||||||
источник вычищается на границе клиента.
|
|
||||||
|
|
||||||
Различие работает, только если поверхности не смешиваются в одном поле.
|
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
|
||||||
Диагностику кладём в **отдельное поле**, а не в доменное.
|
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
|
||||||
|
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
|
||||||
|
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
|
||||||
|
ошибка» в таком поле не несёт ничего и делает поле бессмысленным. Условие
|
||||||
|
про единственного зрителя — ровно то, что делает вторую поверхность
|
||||||
|
приватным каналом; без него это обычная публичная поверхность.
|
||||||
|
|
||||||
|
### R18. Секретов нет ни на одной из поверхностей
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
|
||||||
|
ни в персистентную диагностику; источник вычищается на границе клиента.
|
||||||
|
|
||||||
|
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в
|
||||||
|
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
|
||||||
|
самого секрета. Вычистка на границе клиента — единственное место, где ещё
|
||||||
|
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
|
||||||
|
отличить в нём токен от идентификатора уже нельзя.
|
||||||
|
|
||||||
|
### R19. Диагностика хранится в отдельном поле
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
|
||||||
|
показывают пользователю.
|
||||||
|
|
||||||
|
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей
|
||||||
|
разные поля. Одно поле на оба назначения означает, что при первом же показе
|
||||||
|
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
|
||||||
|
одно.
|
||||||
|
|
||||||
## panic
|
## panic
|
||||||
|
|
||||||
- `panic` — только для невосстановимого: нарушенный инвариант (баг
|
### R20. `panic` — только для невосстановимого
|
||||||
программиста), ошибка инициализации, из которой нельзя стартовать.
|
|
||||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
|
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
|
||||||
ввод) — это значения `error`.
|
ошибка инициализации, из которой нельзя стартовать.
|
||||||
- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не
|
|
||||||
только у HTTP:
|
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте
|
||||||
- HTTP middleware — `net/http` сам восстанавливает панику в хендлере и
|
нельзя, можно только уронить единицу обработки. Это верный ответ, когда
|
||||||
процесс не роняет, поэтому смысл своего `recover` в другом: отдать
|
состояние процесса перестало описываться кодом: работа с нарушенным
|
||||||
контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер;
|
инвариантом опаснее падения, а сервис, стартовавший без обязательной
|
||||||
- цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
|
зависимости, всё равно откажет позже и непонятнее.
|
||||||
горутине **действительно роняет процесс**, и `recover` обязателен.
|
|
||||||
`recover` работает только в той горутине, где случилась паника.
|
### R21. Ожидаемые ошибки — значения `error`
|
||||||
- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное
|
|
||||||
место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и
|
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
|
||||||
без стека «index out of range» не диагностируется вообще.
|
плохой ввод, отсутствующая запись возвращаются как `error`.
|
||||||
|
|
||||||
|
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
|
||||||
|
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
|
||||||
|
его обработать. Дальше такая паника долетает до recover-границы (R22), где
|
||||||
|
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
|
||||||
|
«мы сломались».
|
||||||
|
|
||||||
|
### R22. `recover` — на верхней границе каждой обрабатывающей единицы
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
|
||||||
|
|
||||||
|
| № | Единица | Зачем `recover` |
|
||||||
|
|---|---|---|
|
||||||
|
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
|
||||||
|
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
|
||||||
|
|
||||||
|
**Почему.** `recover` работает только в той горутине, где случилась паника,
|
||||||
|
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
|
||||||
|
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
|
||||||
|
неожиданным полем гасят весь сервис, включая части, к этой ошибке
|
||||||
|
отношения не имеющие. У HTTP цена бездействия ниже, но не нулевая: паника
|
||||||
|
без своего `recover` уходит мимо структурированного лога, а клиент получает
|
||||||
|
оборванное соединение вместо ответа.
|
||||||
|
|
||||||
|
### R23. Recover-граница пишет `debug.Stack()`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
|
||||||
|
|
||||||
|
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной
|
||||||
|
паники цепочки `%w` нет вовсе. «index out of range» без стека не
|
||||||
|
диагностируется в принципе — сообщение не называет ни файла, ни операции,
|
||||||
|
по нему нельзя сказать даже, в каком пакете упало.
|
||||||
|
|
||||||
## Несколько ошибок
|
## Несколько ошибок
|
||||||
|
|
||||||
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
|
### R24. Независимые ошибки собираются `errors.Join`
|
||||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
|
||||||
|
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
|
||||||
|
разом; проверка собранного — по-прежнему через `errors.Is`.
|
||||||
|
|
||||||
|
**Почему.** Возврат первой ошибки превращает починку конфига в серию
|
||||||
|
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
|
||||||
|
тот же список, но убивает ветвление: `errors.Is` по такому результату не
|
||||||
|
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
|
||||||
|
(R11).
|
||||||
|
|
||||||
|
## Связано
|
||||||
|
|
||||||
|
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
|
||||||
|
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+463
-155
@@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
status: рекомендуемая
|
|
||||||
extends: arch/time.md
|
extends: arch/time.md
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -7,164 +6,404 @@ extends: arch/time.md
|
|||||||
|
|
||||||
Как и когда писать логи. Это правила оформления кода (How), а не
|
Как и когда писать логи. Это правила оформления кода (How), а не
|
||||||
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
||||||
функциональности, живут в спеках.
|
функциональности, живут в спеках. Форма записи — `common/language.md`.
|
||||||
|
|
||||||
## Принципы
|
Лог читают инструментами, а не глазами: повседневно — `jq`
|
||||||
|
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
|
||||||
- Структурированный JSON (`slog.JSONHandler`), **один формат для dev и
|
DuckDB поверх JSONL прямо из файла. Отсюда почти все правила ниже: запись
|
||||||
prod**. Не потому, что текстовый вывод «расходит поля» — смена хендлера
|
существует для запроса к ней.
|
||||||
структуру атрибутов не меняет; а потому, что с текстовым dev-выводом
|
|
||||||
перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки
|
|
||||||
словаря замечаются только в проде.
|
|
||||||
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
|
|
||||||
отдельный ключ с типизированным значением: это даёт фильтрацию и
|
|
||||||
агрегацию через `jq`/DuckDB без регулярок.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"time":"2026-06-28T11:23:45.123Z","level":"INFO","msg":"download accepted","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","media_type":"movie"}
|
{"time":"2026-06-28T11:23:45.123Z","level":"INFO","msg":"download accepted","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","media_type":"movie"}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Время в записи
|
## Формат записи
|
||||||
|
|
||||||
Поле `time` ставит `slog`, но **UTC он по умолчанию не даёт**: встроенные
|
### R1. Структурированный JSON, один формат для dev и prod
|
||||||
хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне
|
|
||||||
процесса. UTC ставится `ReplaceAttr` по `slog.TimeKey` — см.
|
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
|
||||||
`lang/go/time.md`. Точность `JSONHandler` — миллисекунды, фиксированная
|
проде.
|
||||||
ширина; это другая точность, чем в БД, и по `arch/time.md` так и должно
|
|
||||||
быть: ширина фиксируется на носитель.
|
**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена
|
||||||
|
хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым
|
||||||
|
dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и
|
||||||
|
поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное
|
||||||
|
значение) обнаруживаются только в проде, где заметить их заранее уже
|
||||||
|
некому.
|
||||||
|
|
||||||
|
### R2. Данные — в типизированных полях, а не в тексте сообщения
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
|
||||||
|
|
||||||
|
**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная
|
||||||
|
в текст, достаётся только регуляркой, а регулярка ломается при первой же
|
||||||
|
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
|
||||||
|
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
|
||||||
|
|
||||||
|
### R3. Время записи — UTC
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
|
||||||
|
(см. `lang/go/time.md`).
|
||||||
|
|
||||||
|
**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время
|
||||||
|
в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного
|
||||||
|
процесса до и после смены TZ (или записи рядом с данными из БД) перестают
|
||||||
|
складываться в одну хронологию, причём сдвиг на целые часы глазом не виден
|
||||||
|
— в отличие от явно неверной даты, он выглядит как правдоподобный порядок
|
||||||
|
событий.
|
||||||
|
|
||||||
|
Точность `JSONHandler` — миллисекунды фиксированной ширины; это другая
|
||||||
|
точность, чем в БД, и по `arch/time.md` так и должно быть: ширина
|
||||||
|
фиксируется на носитель.
|
||||||
|
|
||||||
## Сообщение
|
## Сообщение
|
||||||
|
|
||||||
- `msg` — короткая **константа** в нижнем регистре: `download accepted`,
|
### R4. `msg` — константа в нижнем регистре
|
||||||
`recognition done`, `layout failed`. Данные — в атрибутах:
|
|
||||||
|
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
|
||||||
`log.Info("download accepted", "download_id", id)`.
|
`log.Info("download accepted", "download_id", id)`.
|
||||||
- `msg` — чистая категория **без неймспейс-префикса**: `recognition done`,
|
|
||||||
а не `recognize: done`. Подсистема — отдельное поле, не текст.
|
**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция
|
||||||
- **Смена состояния сущности — единая категория** (`state transition`) с
|
превращает одну категорию в множество уникальных строк, и вопрос «сколько
|
||||||
полями `from`/`to`/`code`. Какое именно состояние и по какой причине —
|
раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы
|
||||||
это данные, а не текст. Тогда весь жизненный цикл собирается одним
|
одна категория не двоилась на варианты, различающиеся только заглавной
|
||||||
фильтром. Физический эффект сверх перехода — отдельная запись своей
|
буквой.
|
||||||
категории, она не подменяет запись перехода.
|
|
||||||
|
### R5. `msg` не несёт префикса подсистемы
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
|
||||||
|
отдельное поле.
|
||||||
|
|
||||||
|
**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
|
||||||
|
фильтр по подсистеме становится сопоставлением с началом строки вместо
|
||||||
|
сравнения значения поля. Заодно это второй способ записать одно и то же:
|
||||||
|
категория дробится на варианты с префиксом и без, а совпадать они обязаны
|
||||||
|
посимвольно.
|
||||||
|
|
||||||
|
### R6. Смена состояния сущности — единая категория
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
|
||||||
|
состояние и по какой причине — данные, а не текст.
|
||||||
|
|
||||||
|
**Почему.** С отдельной категорией на каждый переход жизненный цикл
|
||||||
|
сущности собирается перечислением всех известных `msg` — и переход,
|
||||||
|
добавленный в код позже, в это перечисление не попадёт: выборка тихо
|
||||||
|
останется неполной. Единая категория даёт весь цикл одним фильтром и не
|
||||||
|
требует обновлять запрос вслед за кодом.
|
||||||
|
|
||||||
|
### R7. Физический эффект — отдельная запись, а не вместо перехода
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
|
||||||
|
запись самого перехода.
|
||||||
|
|
||||||
|
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых
|
||||||
|
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
|
||||||
|
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
|
||||||
|
что невозможно.
|
||||||
|
|
||||||
## Уровни
|
## Уровни
|
||||||
|
|
||||||
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько
|
### R8. Уровень выбирается по адресату
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
|
||||||
громко сломалось».
|
громко сломалось».
|
||||||
|
|
||||||
| Уровень | Кому и когда |
|
| № | Уровень | Кому и когда |
|
||||||
|---|---|
|
|---|---|---|
|
||||||
| `DEBUG` | разработчику при отладке; в проде выключен |
|
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
|
||||||
| `INFO` | владельцу, аудит постфактум |
|
| R8.2 | `INFO` | владельцу, аудит постфактум |
|
||||||
| `WARN` | владельцу, «может стать проблемой» |
|
| R8.3 | `WARN` | владельцу, «может стать проблемой» |
|
||||||
| `ERROR` | владельцу, в разбор |
|
| R8.4 | `ERROR` | владельцу, в разбор |
|
||||||
|
|
||||||
Правила:
|
**Почему.** Адресат — единственный признак, по которому разные авторы в
|
||||||
|
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
|
||||||
|
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
|
||||||
|
базовый порог в проде (R40), потому что он отсекает уже не то, что
|
||||||
|
задумано.
|
||||||
|
|
||||||
- Уровень **не зависит от подсистемы**: `ERROR` везде одинаково серьёзен.
|
### R9. Уровень не зависит от подсистемы
|
||||||
- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это
|
|
||||||
не «может» — это `INFO`.
|
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
|
||||||
- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
|
везде одинаково серьёзен.
|
||||||
`DEBUG` (норма, разбирать нечего), а не `ERROR`.
|
|
||||||
- **Событийное → `INFO`, рутинно-частое → `DEBUG`.** Операция по реальному
|
**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
|
||||||
действию или изменению — `INFO`. Повторяющаяся служебная операция,
|
в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить
|
||||||
запускаемая таймером или поллингом и сама по себе не несущая события
|
происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть
|
||||||
(healthcheck, опрос статуса, авто-рефреш UI), — `DEBUG`: на `INFO` она
|
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
|
||||||
зашумляет аудит.
|
кода.
|
||||||
- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем
|
|
||||||
`ERROR` и завершаем процесс с ненулевым кодом.
|
### R10. `WARN` — только когда «может стать проблемой»
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
|
||||||
|
|
||||||
|
**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится
|
||||||
|
«ничего страшного», его перестают читать — и вместе с шумом теряется то
|
||||||
|
единственное, ради чего уровень существует: предупреждение, на которое ещё
|
||||||
|
есть время отреагировать.
|
||||||
|
|
||||||
|
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
|
||||||
|
|
||||||
|
| № | Операция | Уровень |
|
||||||
|
|---|---|---|
|
||||||
|
| R11.1 | по реальному действию или изменению | `INFO` |
|
||||||
|
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
|
||||||
|
|
||||||
|
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность
|
||||||
|
определяется долей записей, за которыми что-то стоит. Периодическая
|
||||||
|
операция даёт ровный поток при нулевой информации, в котором настоящие
|
||||||
|
события тонут количественно: их не отфильтровать, потому что фильтровать
|
||||||
|
приходится по содержанию, а не по уровню.
|
||||||
|
|
||||||
|
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
|
||||||
|
степень даёт завершение процесса.
|
||||||
|
|
||||||
|
**Почему.** Супервизор (docker, journald, systemd) отличает падение от
|
||||||
|
штатной остановки по коду возврата, а не по уровню последней записи.
|
||||||
|
Процесс, который написал `ERROR` и продолжил жить с неработающей
|
||||||
|
конфигурацией, выглядит здоровым и будет получать трафик; изобретать же
|
||||||
|
уровень выше `ERROR` не нужно — сам факт завершения информативнее.
|
||||||
|
|
||||||
## Поля: единый словарь
|
## Поля: единый словарь
|
||||||
|
|
||||||
Главное условие — **одно поле, одно имя по всему коду** (не
|
### R13. Одно поле — одно имя по всему коду
|
||||||
`mediaType`/`media`/`media_type` вперемешку).
|
|
||||||
|
|
||||||
- Бизнес-поля — плоский `snake_case`.
|
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
|
||||||
- Системные домены — точечная иерархия (адаптация OpenTelemetry): `http.*`,
|
|
||||||
`ext.*`.
|
|
||||||
- JSON плоский: все поля на верхнем уровне, без вложенности.
|
|
||||||
|
|
||||||
| Когда добавляем | Поля |
|
**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той
|
||||||
|---|---|
|
же величины делает любую выборку по ней молча неполной: фильтр отработает,
|
||||||
| входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного |
|
часть записей в него не попадёт, и заметить это можно, только заранее зная,
|
||||||
| работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
что они должны были быть.
|
||||||
| запись об ошибке | `error` |
|
|
||||||
| вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` |
|
|
||||||
|
|
||||||
`service.*` и `host.*` не заводим — для одного бинаря на одном хосте это
|
### R14. Форма имени зависит от вида поля
|
||||||
шум. Если появятся несколько инстансов, добавим `service.version` одной
|
|
||||||
строкой при старте.
|
**ДОЛЖЕН.** Две формы, третьей нет.
|
||||||
|
|
||||||
|
| № | Вид поля | Форма имени |
|
||||||
|
|---|---|---|
|
||||||
|
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
|
||||||
|
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
|
||||||
|
|
||||||
|
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
|
||||||
|
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
|
||||||
|
запрос «все внешние вызовы» пишется без перечисления имён. Заимствование
|
||||||
|
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
|
||||||
|
названо, и спорить о них на каждом ревью.
|
||||||
|
|
||||||
|
### R15. Запись плоская
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
|
||||||
|
имени, а не уровень вложенности.
|
||||||
|
|
||||||
|
**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
|
||||||
|
записи независимо от её категории. Вложенность требует знать глубину
|
||||||
|
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
|
||||||
|
весь лог, распадаясь на запрос под каждую форму записи.
|
||||||
|
|
||||||
|
### R16. Набор полей определяется ситуацией
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
|
||||||
|
|
||||||
|
| № | Когда добавляем | Поля |
|
||||||
|
|---|---|---|
|
||||||
|
| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного |
|
||||||
|
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
|
||||||
|
| R16.3 | запись об ошибке | `error` |
|
||||||
|
| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
|
||||||
|
|
||||||
|
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает
|
||||||
|
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
|
||||||
|
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
|
||||||
|
баг», запись о сущности без идентификатора не корреллируется (R19). Полный
|
||||||
|
набор делает записи однородными — один запрос работает по всем вызовам, а
|
||||||
|
не по тем, где автор вспомнил про поле.
|
||||||
|
|
||||||
|
### R17. `service.*` и `host.*` не заводим
|
||||||
|
|
||||||
|
**НЕ СЛЕДУЕТ.** Пока это один бинарь на одном хосте.
|
||||||
|
|
||||||
|
**Почему.** Поле с одним и тем же значением во всех записях не несёт
|
||||||
|
информации, но стоит места в каждой строке и внимания при чтении. Условие
|
||||||
|
названо явно, поэтому правило отпадёт вместе со своей причиной: с
|
||||||
|
появлением нескольких инстансов различающее поле (`service.version`)
|
||||||
|
добавляется одной строкой при старте.
|
||||||
|
|
||||||
<!-- local:словарь -->
|
<!-- local:словарь -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
## Корреляция по id сущности
|
## Корреляция
|
||||||
|
|
||||||
Отдельный случайный `trace_id` не заводим, **если у сущностей есть
|
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id`
|
||||||
стабильные уникальные идентификаторы** — они и служат ключом корреляции.
|
|
||||||
(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.)
|
|
||||||
|
|
||||||
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
|
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
|
||||||
Для долгой операции — scoped-логгер, протаскиваемый через
|
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
|
||||||
`context.Context` сквозь асинхронные стадии, чтобы ключ дописывался сам:
|
`arch/db-identifiers.md`, если конвенция взята.)
|
||||||
|
|
||||||
|
**Почему.** Идентификатор сущности уже существует, стабилен между
|
||||||
|
процессами и во времени — по нему собираются записи не одного прохода, а
|
||||||
|
всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое
|
||||||
|
только внутри одной операции, то есть дублирует ключ и добавляет второй
|
||||||
|
способ спросить об одном. Условие применимости названо: там, где сущности
|
||||||
|
со стабильным идентификатором нет, связывать записи больше нечем.
|
||||||
|
|
||||||
|
### R19. Запись о сущности несёт её идентификатор
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
|
||||||
|
|
||||||
|
**Почему.** Принадлежность записи восстанавливается только в момент
|
||||||
|
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
|
||||||
|
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем,
|
||||||
|
что идентификатор стоит везде, а не в удобных местах.
|
||||||
|
|
||||||
|
Все записи одной операции собираются одним фильтром:
|
||||||
|
`jq 'select(.download_id=="01jz…")' app.jsonl`. Если идентификатор
|
||||||
|
глобально уникален across сущностей, штатно работает и простой `grep` по
|
||||||
|
голому значению — он находит все упоминания независимо от имени поля.
|
||||||
|
|
||||||
|
### R20. Долгая операция ведётся scoped-логгером через `context.Context`
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные
|
||||||
|
стадии:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
log := log.With("download_id", id)
|
log := log.With("download_id", id)
|
||||||
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
||||||
```
|
```
|
||||||
|
|
||||||
- Все записи одной операции собираются одним фильтром:
|
**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в
|
||||||
`jq 'select(.download_id=="01jz…")' app.jsonl`.
|
редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее
|
||||||
- Если id глобально уникален across сущностей, штатно работает и простой
|
всего. Логгер из контекста дописывает ключ сам, и запись без
|
||||||
`grep` по голому id — он находит все упоминания независимо от имени поля.
|
идентификатора становится невозможной, а не маловероятной.
|
||||||
|
|
||||||
## Ошибки
|
## Ошибки
|
||||||
|
|
||||||
Go-ошибки логируем **атрибутом**, не текстом сообщения:
|
### R21. Ошибка логируется атрибутом `error`
|
||||||
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ —
|
|
||||||
`error` (как по умолчанию в zap/zerolog: единый ключ важнее краткости).
|
|
||||||
|
|
||||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
|
||||||
оборачивают и возвращают (`%w`), не логируя: контекст накапливается в
|
|
||||||
цепочке.
|
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и
|
||||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая
|
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
|
||||||
определяет исход операции. Логирует этот единый чокпоинт, а не каждый
|
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
|
||||||
транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей.
|
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
|
||||||
|
краткостью жертвуют.
|
||||||
|
|
||||||
|
### R22. Промежуточный слой либо логирует, либо возвращает
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
|
||||||
|
оборачивает (`%w`).
|
||||||
|
|
||||||
|
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
|
||||||
|
и количество `ERROR` перестаёт соответствовать количеству отказов — а
|
||||||
|
считают именно его. Контекст при этом не теряется: он накапливается в
|
||||||
|
цепочке обёрток и попадает в единственную запись на границе (R23).
|
||||||
|
|
||||||
|
### R23. Ошибка логируется один раз — на границе доменного слоя
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
|
||||||
|
|
||||||
|
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
|
||||||
|
этим местом выбрана доменная граница, а не транспорт, потому что там
|
||||||
|
известен исход операции целиком и, значит, класс отказа (R25) — транспорт
|
||||||
|
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
|
||||||
|
транспорты остаются тонкими.
|
||||||
|
|
||||||
<!-- local:границы -->
|
<!-- local:границы -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение
|
### R24. Транспорт не логирует ошибку повторно
|
||||||
пользователю) и **не логируют** её повторно.
|
|
||||||
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
|
|
||||||
доменной ошибки ровно один логирующий; уровень выбирает он:
|
|
||||||
|
|
||||||
| Класс отказа | Кому | Уровень |
|
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
|
||||||
|
(статус, сообщение пользователю) и на этом останавливается.
|
||||||
|
|
||||||
|
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё
|
||||||
|
только формулировкой и читается как второй сбой. Когда транспортов над
|
||||||
|
одним доменом несколько, дублирование ещё и множится, а расследование
|
||||||
|
начинается с вопроса, один это инцидент или два.
|
||||||
|
|
||||||
|
### R25. Уровень доменного отказа — по классу отказа
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по
|
||||||
|
классу, а не по месту в коде.
|
||||||
|
|
||||||
|
| № | Класс отказа | Кому | Уровень |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
||||||
|
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||||
|
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||||
|
|
||||||
|
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на
|
||||||
|
экране — владельцу разбирать нечего; целостность первичных данных отделяет
|
||||||
|
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
|
||||||
|
уровень для одного и того же отказа в зависимости от того, какой транспорт
|
||||||
|
его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с
|
||||||
|
упавшей базой.
|
||||||
|
|
||||||
|
### R26. Тот же отказ в асинхронной стадии — уровнем выше
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
|
||||||
|
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
|
||||||
|
она же в авто-обработке — `WARN`.
|
||||||
|
|
||||||
|
**Почему.** В ручном действии человек видит причину на экране и сам решает,
|
||||||
|
что делать дальше; запись нужна только для отладки. В автоматике не увидел
|
||||||
|
никто, задача осталась недоведённой, и лог — единственное место, где это
|
||||||
|
вообще проявится.
|
||||||
|
|
||||||
|
### R27. Повторяющийся сбой фонового цикла — `WARN`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
|
||||||
|
уровень задаёт наличие штатного повтора, а не текст ошибки.
|
||||||
|
|
||||||
|
**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и
|
||||||
|
вмешательство не требуется; `ERROR` на каждый такой промах обесценивает
|
||||||
|
уровень, на который смотрят в первую очередь. Синхронная операция повтора
|
||||||
|
не имеет: она провалилась целиком, результат никто не восстановит, и это
|
||||||
|
ровно тот случай, ради которого `ERROR` держат чистым.
|
||||||
|
|
||||||
|
## Внешние сервисы
|
||||||
|
|
||||||
|
### R28. Каждый вызов внешнего сервиса логируется
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4.
|
||||||
|
|
||||||
|
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
|
||||||
|
легла»: на своей стороне видно лишь то, что операция не удалась.
|
||||||
|
Выборочное логирование ломает и второе применение — доля неуспехов и
|
||||||
|
распределение `duration_ms` считаются, только если знаменатель полный.
|
||||||
|
|
||||||
|
### R29. Уровень `ext`-записи — по исходу вызова
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
|
||||||
|
|
||||||
|
| № | Исход | Уровень |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
| R29.1 | успешный событийный вызов | `INFO` |
|
||||||
| расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
|
||||||
| сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
| R29.3 | попытка не удалась, делается retry | `WARN` |
|
||||||
|
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
|
||||||
|
|
||||||
- Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт)
|
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
|
||||||
адресован уже владельцу как деградация автоматики — уровень поднимается.
|
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
|
||||||
Коллизия в ручном действии — `DEBUG` (человек видит причину на экране), в
|
уровень непригодным для главного вопроса «зависимость доступна?».
|
||||||
авто-обработке — `WARN` (автоматика не довела задачу).
|
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
|
||||||
- **Повторяющийся сбой фонового цикла — `WARN`, не `ERROR`.** Одиночный
|
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение
|
||||||
промах тика транзиентен: следующий тик повторит. Тот же класс сбоя внутри
|
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет
|
||||||
синхронной операции — `ERROR`, потому что операция провалилась целиком и
|
аудит так же, как любой другой.
|
||||||
повтора нет. Уровень задаёт не текст ошибки, а **наличие штатного
|
|
||||||
повтора**.
|
|
||||||
|
|
||||||
## Два цикла повтора — не путать
|
## Два цикла повтора — не путать
|
||||||
|
|
||||||
Слово «ретрай» означает два разных механизма, и уровень считается по
|
Слово «ретрай» означает два разных механизма, и уровень считается по
|
||||||
каждому отдельно:
|
каждому отдельно: повтор вызова внутри одной операции (ретраи HTTP-клиента)
|
||||||
|
задаёт уровень `ext`-записи, повтор тика внешним циклом (поллинг, сверка) —
|
||||||
|
уровень доменной записи об исходе тика.
|
||||||
|
|
||||||
- **Повтор вызова внутри одной операции** (ретраи HTTP-клиента) — по нему
|
```
|
||||||
выбирается уровень **`ext`-записи**: `WARN` на попытку, `ERROR` когда
|
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4)
|
||||||
попытки исчерпаны.
|
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27)
|
||||||
- **Повтор тика внешним циклом** (поллинг, сверка) — по нему выбирается
|
```
|
||||||
уровень **доменной записи** об исходе тика: `WARN`, потому что следующий
|
|
||||||
тик повторит.
|
|
||||||
|
|
||||||
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
||||||
каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а
|
каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а
|
||||||
@@ -172,71 +411,140 @@ Go-ошибки логируем **атрибутом**, не текстом с
|
|||||||
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
||||||
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
||||||
|
|
||||||
## Внешние сервисы: логируем все вызовы
|
### R30. Ответ 4xx — успех на транспортном уровне
|
||||||
|
|
||||||
**Каждый** вызов внешнего сервиса логируется — это единственный способ
|
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
|
||||||
отличить «у нас баг» от «зависимость легла». Поля: `ext.service`,
|
|
||||||
`ext.operation` (логическая операция, не URL), `ext.status_code`,
|
|
||||||
`duration_ms`, `retry`.
|
|
||||||
|
|
||||||
Уровни:
|
|
||||||
|
|
||||||
- `INFO` — успешный **событийный** вызов;
|
|
||||||
- `DEBUG` — успешный **рутинно-частый** вызов (поллинг, авто-рефреш);
|
|
||||||
- `WARN` — попытка не удалась, делаем retry;
|
|
||||||
- `ERROR` — ретраи исчерпаны, сервис недоступен.
|
|
||||||
|
|
||||||
Завершённый HTTP-ответ с 4xx — это **успех на транспортном уровне**
|
|
||||||
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
||||||
вызывающий. Тело запроса и ответа — только на `DEBUG` и после вычистки
|
вызывающий.
|
||||||
секретов.
|
|
||||||
|
**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
|
||||||
|
разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис
|
||||||
|
недоступен» с «сервис ответил нам нет» — это разные инциденты с разной
|
||||||
|
реакцией, и различает их как раз `ext`-уровень. Что 404 значит для
|
||||||
|
операции, знает только вызывающий: для одной это отказ, для другой —
|
||||||
|
штатный ответ.
|
||||||
|
|
||||||
## HTTP и healthcheck
|
## HTTP и healthcheck
|
||||||
|
|
||||||
- Входящие запросы логируем с `http.*` и `duration_ms` на **`INFO`**: это
|
### R31. Входящий запрос — `INFO` независимо от кода ответа
|
||||||
аудит обращений, а не отладка. Уровень не понижается из-за кода ответа —
|
|
||||||
4xx остаётся `INFO`-записью доступа; решение «это ошибка» принимает
|
**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа.
|
||||||
доменный слой и пишет свою запись.
|
|
||||||
- Для корреляции запроса допустим `request_id` — это отдельный слой от
|
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
|
||||||
корреляции по сущности и не противоречит отказу от `trace_id`.
|
когда приходил», и ценность у неё одинаковая при любом коде ответа.
|
||||||
- **Healthcheck, liveness, readiness — `DEBUG`.** Их дёргают периодически,
|
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
|
||||||
на `INFO` они забивают аудит; в проде с базовым `INFO` они не пишутся.
|
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
|
||||||
|
(R25) — она и адресована по-другому.
|
||||||
|
|
||||||
|
### R32. Для корреляции запроса допустим `request_id`
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
|
||||||
|
|
||||||
|
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
|
||||||
|
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где
|
||||||
|
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
|
||||||
|
сущности нет — связать его записи между собой больше нечем.
|
||||||
|
|
||||||
|
### R33. Healthcheck, liveness, readiness — `DEBUG`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
|
||||||
|
|
||||||
|
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают
|
||||||
|
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
|
||||||
|
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в
|
||||||
|
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
|
||||||
|
доступной при отладке.
|
||||||
|
|
||||||
## Безопасность: что не логируем
|
## Безопасность: что не логируем
|
||||||
|
|
||||||
Никаких секретов в полях и сообщениях: пароли и cookie сессий, API-ключи и
|
### R34. Секреты не логируются
|
||||||
токены, `Authorization`-заголовки, аутентификационные параметры в ссылках.
|
|
||||||
|
|
||||||
- Тела ответов внешних API и сырой вывод LLM (недоверенный, может быть
|
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
|
||||||
большим) — только на `DEBUG`, с вычисткой и обрезкой по длине.
|
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
|
||||||
- При сомнении — не логируем значение, логируем факт его наличия
|
в ссылках.
|
||||||
(`"has_api_key", true`).
|
|
||||||
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
|
**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
|
||||||
`*url.Error` встраивает полный URL запроса, а секрет может жить прямо в
|
и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован
|
||||||
нём: токен в пути, `api_key` в query. Go редактирует только пароль из
|
с момента записи, а не с момента, когда это заметили, и вычистить его задним
|
||||||
userinfo, остального не трогает. Санитизируем на границе клиента **до**
|
числом из уже собранных копий нельзя.
|
||||||
лога и обёртки: разворачиваем `*url.Error` в первопричину. Цена —
|
|
||||||
теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на причину
|
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
|
||||||
сохраняется); альтернатива с редактированием URL сохранила бы структуру,
|
|
||||||
но сложнее. Порядок важен: санитизация идёт **раньше** трансляции ошибки
|
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
|
||||||
в доменную (`lang/go/errors.md`), иначе секрет уедет в обёртку.
|
`DEBUG`, с вычисткой секретов и обрезкой по длине.
|
||||||
- Общее правило: **секрет не кладём в URL, если у API есть заголовок** —
|
|
||||||
тогда его нет и в ошибке транспорта.
|
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
|
||||||
|
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
|
||||||
|
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией;
|
||||||
|
обрезка не даёт одной записи вытеснить весь остальной лог за период.
|
||||||
|
|
||||||
|
### R36. При сомнении логируется факт, а не значение
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
|
||||||
|
|
||||||
|
**Почему.** Для отладки почти всегда достаточно ответа «значение было или
|
||||||
|
не было» — потеря полезности близка к нулю, а риск снимается целиком.
|
||||||
|
Правило нужно потому, что решение принимается в момент написания строки,
|
||||||
|
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
|
||||||
|
никто не придёт.
|
||||||
|
|
||||||
|
### R37. `*url.Error` санитизируется на границе клиента
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
|
||||||
|
обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
|
||||||
|
|
||||||
|
**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт
|
||||||
|
прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль
|
||||||
|
из userinfo, остального не трогает, поэтому ошибка уносит секрет и в
|
||||||
|
обёртку, и в лог целиком. Порядок — часть нормы: санитизация после
|
||||||
|
трансляции уже опоздала, секрет к этому моменту скопирован в текст обёртки.
|
||||||
|
Цена — теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на
|
||||||
|
причину сохраняется); альтернатива с редактированием URL сохранила бы
|
||||||
|
структуру, но сложнее.
|
||||||
|
|
||||||
|
### R38. Секрет не кладётся в URL, если у API есть заголовок
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
|
||||||
|
способа нет.
|
||||||
|
|
||||||
|
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и
|
||||||
|
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
|
||||||
|
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
|
||||||
|
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
|
||||||
|
|
||||||
<!-- local:секреты -->
|
<!-- local:секреты -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
## Куда пишем
|
## Куда пишем
|
||||||
|
|
||||||
- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker,
|
### R39. Логи идут в `stdout` одним потоком
|
||||||
journald). По файлам не маршрутизируем.
|
|
||||||
- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev —
|
|
||||||
`DEBUG`.
|
|
||||||
|
|
||||||
## Анализ
|
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
|
||||||
|
не маршрутизируем.
|
||||||
|
|
||||||
- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`.
|
**Почему.** Приложение, которое само решает, что куда писать, дублирует
|
||||||
- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.
|
работу супервизора и расходится с ней при первой же смене окружения: срок
|
||||||
|
хранения, сжатие и ротация оказываются настроены в двух местах и по-разному.
|
||||||
|
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
|
||||||
|
теряет его ровно там, где важен ход событий.
|
||||||
|
|
||||||
|
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
|
||||||
|
|
||||||
|
**Почему.** Уровень — единственный регулятор объёма, доступный без
|
||||||
|
пересборки; если `DEBUG` в проде включается только правкой кода, его не
|
||||||
|
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
|
||||||
|
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2).
|
||||||
|
|
||||||
|
## Связано
|
||||||
|
|
||||||
|
- `arch/time.md` — точность и зона меток времени фиксируются на носитель.
|
||||||
|
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3).
|
||||||
|
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
|
||||||
|
санитизации (R37).
|
||||||
|
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
|
||||||
|
на которых держится корреляция (R18).
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
+143
-43
@@ -1,43 +1,105 @@
|
|||||||
---
|
---
|
||||||
status: рекомендуемая
|
|
||||||
extends: arch/time.md
|
extends: arch/time.md
|
||||||
---
|
---
|
||||||
|
|
||||||
# Время: реализация на Go
|
# Время: реализация на Go
|
||||||
|
|
||||||
## Единая точка
|
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся
|
||||||
|
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
|
||||||
|
Форма записи — `common/language.md`.
|
||||||
|
|
||||||
- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по
|
## Правила
|
||||||
коду. Ценность точки — **гарантированный UTC и один формат**: `Now()`
|
|
||||||
возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом
|
|
||||||
забыть. Побочно это единственное место, которое придётся превратить в
|
|
||||||
переменную или поле, если однажды понадобится подменять часы в тестах, —
|
|
||||||
но само по себе оно тестируемости не даёт.
|
|
||||||
- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх
|
|
||||||
`time.RFC3339`.
|
|
||||||
- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений
|
|
||||||
ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит
|
|
||||||
сама себе: сама точка `Now()` и обёртка измерения длительности (ниже).
|
|
||||||
|
|
||||||
## Точность и разбор
|
### R1. «Сейчас» берётся у слоя хранилища
|
||||||
|
|
||||||
- В БД — **секундная точность**, ширина 20 символов
|
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
|
||||||
(`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не
|
`time.Now().UTC()`, а не из `time.Now()` по коду.
|
||||||
содержит долей секунды, поэтому `Format` их не выведет.
|
|
||||||
- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает
|
|
||||||
фиксированную ширину.
|
|
||||||
- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть
|
|
||||||
канонический вид гарантирует **писатель**, а не читатель. Для одного
|
|
||||||
писателя этого достаточно; чужой вход нормализуем явно.
|
|
||||||
- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка —
|
|
||||||
`TEXT`, и промежуточное преобразование драйвером нам не нужно.
|
|
||||||
|
|
||||||
## Логи
|
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна
|
||||||
|
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
|
||||||
|
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
|
||||||
|
когда-то локальной, и восстановить смещение задним числом не по чему.
|
||||||
|
|
||||||
`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне
|
Тестируемость мотивом **не является**: точка — единственное место, которое
|
||||||
самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке
|
придётся превратить в переменную или поле, если однажды понадобится
|
||||||
разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по
|
подменять часы, но само по себе оно подмены не даёт.
|
||||||
`slog.TimeKey`:
|
|
||||||
|
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
|
||||||
|
получить строку времени и прочитать её обратно.
|
||||||
|
|
||||||
|
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
|
||||||
|
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и
|
||||||
|
взаимная обратимость записи и чтения держатся ровно до первого второго
|
||||||
|
layout — а расхождение проявится не на записи, а при сравнении значений,
|
||||||
|
записанных разными местами.
|
||||||
|
|
||||||
|
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба
|
||||||
|
прописаны явно:
|
||||||
|
|
||||||
|
| № | Исключение | Почему оно не покрывается R1 |
|
||||||
|
|---|---|---|
|
||||||
|
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
|
||||||
|
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
|
||||||
|
|
||||||
|
**Почему.** R1 без механической проверки держится на внимании, а
|
||||||
|
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
|
||||||
|
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
|
||||||
|
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
|
||||||
|
себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут
|
||||||
|
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
|
||||||
|
сама себе.
|
||||||
|
|
||||||
|
### R4. В БД время хранится с секундной точностью, ширина 20 символов
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
|
||||||
|
|
||||||
|
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
|
||||||
|
лексикографический порядок совпадает с хронологическим только при
|
||||||
|
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
|
||||||
|
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
|
||||||
|
`ORDER BY`, и диапазонные условия — на конкретных данных, а не на всех
|
||||||
|
сразу.
|
||||||
|
|
||||||
|
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
|
||||||
|
поэтому `Format` их не выведет.
|
||||||
|
|
||||||
|
### R5. `time.RFC3339Nano` не используется
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
|
||||||
|
|
||||||
|
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
|
||||||
|
от значения: соседние записи получают разную ширину, и свойство, на котором
|
||||||
|
держится R4, исчезает незаметно. Проверка «формат корректен» при этом
|
||||||
|
проходит — отказывает только порядок.
|
||||||
|
|
||||||
|
### R6. Чужой вход нормализуется явно
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
|
||||||
|
к каноническому виду явно, а не считается каноническим по факту успешного
|
||||||
|
разбора.
|
||||||
|
|
||||||
|
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
|
||||||
|
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
|
||||||
|
**писатель**, а не читатель; пока писатель один, этого достаточно, но
|
||||||
|
значение из чужой системы, положенное в базу как пришло, нарушает R4 и
|
||||||
|
обнаруживается не на записи, а на первой сортировке.
|
||||||
|
|
||||||
|
### R7. В драйвер передаётся строка, а не `time.Time`
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
|
||||||
|
|
||||||
|
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
|
||||||
|
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с
|
||||||
|
собственным layout, который меняется вместе с версией драйвера, а не вместе
|
||||||
|
с конвенцией.
|
||||||
|
|
||||||
|
### R8. Время в логах приводится к UTC через `ReplaceAttr`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func utcTime(_ []string, a slog.Attr) slog.Attr {
|
func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||||
@@ -48,24 +110,62 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это
|
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
|
||||||
другая точность, чем в БД, и это нормально: ширина фиксируется на носитель
|
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
|
||||||
(см. базу).
|
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
|
||||||
|
неверная зона выглядит как совершенно валидное время, а записи из разных
|
||||||
|
мест перестают складываться в одну хронологию с метками хранилища.
|
||||||
|
|
||||||
## Длительность
|
### R9. Точность времени в логах отличается от точности в БД
|
||||||
|
|
||||||
Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и
|
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
|
||||||
без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а
|
приводится к секундной точности R4.
|
||||||
это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный
|
|
||||||
по таким меткам, зависит от подводки часов. Поэтому обёртка берёт
|
|
||||||
`time.Now()` напрямую и считает `time.Since` — с локальным `//nolint`.
|
|
||||||
|
|
||||||
## Зоны
|
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование
|
||||||
|
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
|
||||||
|
такая же фиксированная ширина, и свойство, ради которого R4 существует, не
|
||||||
|
нарушено. Общее у лога и базы одно — зона (R8).
|
||||||
|
|
||||||
`time/tzdata` импортируется в `main`, зона отображения валидируется
|
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую
|
||||||
загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в
|
|
||||||
шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону
|
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с
|
||||||
явно, как описано в базе.
|
локальным `//nolint`.
|
||||||
|
|
||||||
|
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это
|
||||||
|
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
|
||||||
|
меткам, зависит от подводки часов: перевод назад даёт отрицательную
|
||||||
|
длительность, скачок вперёд — выброс в измерениях, и оба случая
|
||||||
|
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается
|
||||||
|
как недосмотр и его «чинят».
|
||||||
|
|
||||||
|
### R11. `time/tzdata` импортируется в `main`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** База зон вшивается в бинарь.
|
||||||
|
|
||||||
|
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
|
||||||
|
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
|
||||||
|
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
|
||||||
|
`main` держит это решение в одном видимом месте, а не в случайном пакете,
|
||||||
|
откуда его удаляют при чистке зависимостей.
|
||||||
|
|
||||||
|
### R12. Зона отображения применяется только в UI
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
|
||||||
|
представления, но не в хранимых значениях и не в вычислениях.
|
||||||
|
|
||||||
|
**Почему.** Зона отображения — настройка, и её меняют. Протекая в
|
||||||
|
вычисления и хранение, она делает уже записанные данные зависимыми от
|
||||||
|
текущего значения настройки: смена зоны задним числом сдвигает границы
|
||||||
|
суток у того, что давно посчитано и сохранено.
|
||||||
|
|
||||||
|
Календарные вычисления бизнес-логики берут зону явно — как описано в
|
||||||
|
`arch/time.md`.
|
||||||
|
|
||||||
<!-- local:механизировано -->
|
<!-- local:механизировано -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|
||||||
|
## Связано
|
||||||
|
|
||||||
|
- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в
|
||||||
|
календарных вычислениях.
|
||||||
|
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига.
|
||||||
|
|||||||
+371
-123
@@ -1,54 +1,106 @@
|
|||||||
---
|
|
||||||
status: рекомендуемая
|
|
||||||
---
|
|
||||||
|
|
||||||
# Веб-UI на htmx
|
# Веб-UI на htmx
|
||||||
|
|
||||||
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
|
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
|
||||||
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
|
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
|
||||||
показывает и какие действия обязан поддерживать — в спеках, не здесь.
|
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи
|
||||||
|
— `common/language.md`.
|
||||||
|
|
||||||
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
|
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
|
||||||
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
|
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
|
||||||
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
|
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
|
||||||
Здесь — только специфика htmx-транспорта, без дублирования.
|
Здесь — только специфика htmx-транспорта, без дублирования.
|
||||||
|
|
||||||
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки
|
## Область действия
|
||||||
ответов между мажорами менялись.
|
|
||||||
|
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов
|
||||||
|
между мажорами менялись. Правила описывают то, как написан код веб-UI, а не
|
||||||
|
то, какие экраны и действия у приложения есть.
|
||||||
|
|
||||||
## Стек и границы
|
## Стек и границы
|
||||||
|
|
||||||
htmx-first: роутер + серверные шаблоны + htmx. **Без шага сборки, без Node
|
### R1. Стек: роутер, серверные шаблоны, htmx
|
||||||
и бандлера, без реактивных фреймворков.** htmx вендорится и самохостится,
|
|
||||||
без CDN.
|
|
||||||
|
|
||||||
- Свой JS сведён к минимуму: только то, что серверу знать не нужно
|
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
|
||||||
(например, копирование в буфер обмена). **Клиентского пересчёта доменного
|
без Node и бандлера, без реактивного фреймворка.
|
||||||
состояния нет** — состояние считает сервер, клиент свопит присланную
|
|
||||||
разметку.
|
|
||||||
- Реактивный слой (Alpine.js и подобное) не вводим до появления виджета,
|
|
||||||
которому он действительно нужен, и вводим отдельным решением, а не
|
|
||||||
попутно.
|
|
||||||
|
|
||||||
## Единый источник разметки: партиал = страница = фрагмент
|
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
|
||||||
|
артефакт, который расходится с исходником; приложению, где разметку целиком
|
||||||
|
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
|
||||||
|
модель состояния рядом с серверной (R2), и дальше на каждом экране
|
||||||
|
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
|
||||||
|
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму
|
||||||
|
приложения аптайм чужого хоста.
|
||||||
|
|
||||||
Переиспользуемый кусок — это именованный шаблон в `partials/`. Тот же
|
### R2. Клиент не пересчитывает доменное состояние
|
||||||
шаблон рендерится **и** инлайн на странице, **и** как ответ-фрагмент того
|
|
||||||
же обработчика. Отдельной разметки для фрагмента не заводим — иначе она
|
|
||||||
дрейфует от страницы.
|
|
||||||
|
|
||||||
**Инвариант: корень шаблона — элемент с целевым `id`.**
|
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
|
||||||
`hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не
|
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
|
||||||
несёт тот же корневой `id`, следующее действие или поллер не найдёт таргет.
|
клиент свопит присланную разметку.
|
||||||
Разметку и `id` держим в одном партиале.
|
|
||||||
|
|
||||||
Сборку view выносим в переиспользуемую функцию и зовём её и на полной
|
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
|
||||||
странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку.
|
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
|
||||||
|
базе другое». Вдобавок клиентский пересчёт по определению не работает в
|
||||||
|
деградированном режиме (R11, R12) — значит, серверную версию того же
|
||||||
|
вычисления всё равно придётся держать.
|
||||||
|
|
||||||
## Обработчик действия: ветвление htmx / редирект
|
### R3. Реактивный слой вводится отдельным решением
|
||||||
|
|
||||||
htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик зовёт
|
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
|
||||||
доменную операцию **одинаково** в обеих ветках и ветвится только после:
|
только когда есть виджет, которому он действительно нужен, и отдельным
|
||||||
|
решением.
|
||||||
|
|
||||||
|
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
|
||||||
|
списка, немедленно доступен всему остальному коду — и граница R1/R2
|
||||||
|
перестаёт держаться сама собой. Отдельное решение — единственный момент,
|
||||||
|
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
|
||||||
|
каждом экране есть выбор между двумя моделями состояния.
|
||||||
|
|
||||||
|
## Единый источник разметки
|
||||||
|
|
||||||
|
### R4. Партиал = страница = фрагмент
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
|
||||||
|
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
|
||||||
|
обработчика; отдельной разметки под фрагмент нет.
|
||||||
|
|
||||||
|
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту,
|
||||||
|
что открыта, и страница начинает выглядеть иначе, чем результат свопа того
|
||||||
|
же региона. Заметно это становится только на глаз и только тому, кто открыл
|
||||||
|
оба пути подряд.
|
||||||
|
|
||||||
|
### R5. Корень партиала — элемент с целевым `id`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
|
||||||
|
регион, и ответный фрагмент несёт тот же `id`.
|
||||||
|
|
||||||
|
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
|
||||||
|
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
|
||||||
|
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
|
||||||
|
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
|
||||||
|
логе.
|
||||||
|
|
||||||
|
### R6. Сборку view делает общая функция
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
|
||||||
|
htmx-ветка.
|
||||||
|
|
||||||
|
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не
|
||||||
|
одинаковые данные: скопированная сборка view расходится по набору полей, и
|
||||||
|
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
|
||||||
|
класс расхождений, который R4 закрывает для разметки.
|
||||||
|
|
||||||
|
## Обработчик действия
|
||||||
|
|
||||||
|
### R7. Доменный вызов одинаков для htmx и обычного запроса
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
|
||||||
|
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
|
||||||
|
на способе ответа:
|
||||||
|
|
||||||
|
| № | Запрос | Ответ |
|
||||||
|
|---|---|---|
|
||||||
|
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию |
|
||||||
|
| R7.2 | обычный | PRG-редирект (303) |
|
||||||
|
|
||||||
```go
|
```go
|
||||||
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
|
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
|
||||||
@@ -65,60 +117,137 @@ if actionErr != nil {
|
|||||||
s.render(w, "source_block", view) // фрагмент = тот же шаблон
|
s.render(w, "source_block", view) // фрагмент = тот же шаблон
|
||||||
```
|
```
|
||||||
|
|
||||||
`render` собирает именованный шаблон **в буфер** и только затем пишет
|
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
|
||||||
ответ — при ошибке шаблона клиент не получит «полустраницу».
|
дальше дефект воспроизводится только на одной поверхности — причём
|
||||||
|
деградированный путь (R11) открывают реже, то есть чинить будут не тот.
|
||||||
|
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
|
||||||
|
целиком: view, собранный из аргументов запроса, покажет намерение, а не
|
||||||
|
результат.
|
||||||
|
|
||||||
## Одно действие — два региона: `hx-swap-oob`
|
### R8. Шаблон рендерится в буфер, потом в ответ
|
||||||
|
|
||||||
Когда действие меняет не только свой регион (сменился выбор — обновилась и
|
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
|
||||||
панель действий), второй регион едет **тем же ответом** через
|
буфер пишется в ответ.
|
||||||
`hx-swap-oob="true"`. Оба фрагмента — обычные именованные партиалы с теми
|
|
||||||
же `id`, что и на странице; отдельной разметки под oob не заводим по тому
|
|
||||||
же правилу, что и для основного свопа.
|
|
||||||
|
|
||||||
Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами
|
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть
|
||||||
и лишний раунд-трип; `HX-Trigger` с последующим `hx-get` уместен только
|
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
|
||||||
если второй регион обновляется реже, чем происходит действие.
|
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
|
||||||
|
региона», и причина по такому симптому не читается.
|
||||||
|
|
||||||
|
## Одно действие — два региона
|
||||||
|
|
||||||
|
### R9. Второй регион едет тем же ответом через `hx-swap-oob`
|
||||||
|
|
||||||
|
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
|
||||||
|
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
|
||||||
|
партиалом с тем же `id`, что и на странице (R4, R5).
|
||||||
|
|
||||||
|
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
|
||||||
|
состояние в разные моменты и приезжают в произвольном порядке, поэтому
|
||||||
|
панель действий может отразить состояние до действия. Плюс лишний
|
||||||
|
раунд-трип на каждое действие.
|
||||||
|
|
||||||
|
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
|
||||||
|
регион меняется не на каждое действие.
|
||||||
|
|
||||||
|
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого
|
||||||
|
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
|
||||||
|
одинаковую разметку на каждое действие и связывает два шаблона там, где
|
||||||
|
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
|
||||||
|
|
||||||
## Graceful degradation
|
## Graceful degradation
|
||||||
|
|
||||||
Формы действий остаются обычными `<form method="post" action="…">`;
|
### R11. Форма действия работает без JS
|
||||||
`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же
|
|
||||||
форму. Без JS действие работает через POST и редирект. `action` формы —
|
|
||||||
рабочий фолбэк, а не декорация.
|
|
||||||
|
|
||||||
Фильтр, поиск и пагинация списка — **серверные**, через GET-параметры, тоже
|
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
|
||||||
без JS. Клиентской фильтрации нет намеренно.
|
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
|
||||||
|
рабочий обработчик.
|
||||||
|
|
||||||
Требование распространяется на **действия и навигацию**. Интерактивный
|
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
|
||||||
виджет выбора, у которого нет осмысленного не-JS поведения, может требовать
|
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
|
||||||
JS — но это отступление, и оно записывается, а не подразумевается.
|
ничего, молча. Тот же `action` — единственное, что делает действие
|
||||||
|
проверяемым без браузера с JS.
|
||||||
|
|
||||||
## Ошибки на htmx-пути: HTTP 200 плюс фрагмент
|
### R12. Фильтр, поиск и пагинация — серверные
|
||||||
|
|
||||||
В htmx 2.x ответы 4xx/5xx по умолчанию **не свопят DOM**. Это настраивается
|
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
|
||||||
|
клиентской фильтрации загруженной разметки нет.
|
||||||
|
|
||||||
|
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
|
||||||
|
фильтр отвечает по неполным данным и делает это молча — результат выглядит
|
||||||
|
валидным. Вдобавок состояние отбора в query переживает своп (R25) и
|
||||||
|
перезагрузку, его можно послать ссылкой и увидеть в логе.
|
||||||
|
|
||||||
|
### R13. Область обязательной деградации
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
|
||||||
|
|
||||||
|
| № | Поверхность | Поведение без JS |
|
||||||
|
|---|---|---|
|
||||||
|
| R13.1 | действия и навигация | работают полностью (R11, R12) |
|
||||||
|
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
|
||||||
|
|
||||||
|
**Почему.** Без явной границы правило деградации читается как запрет на
|
||||||
|
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
|
||||||
|
виджета, который был нужен. Запись в отступления держит список честным:
|
||||||
|
видно, какие именно места ломаются с выключенным JS, а не «где-то
|
||||||
|
что-то».
|
||||||
|
|
||||||
|
## Ошибки на htmx-пути
|
||||||
|
|
||||||
|
### R14. Ошибка действия на htmx-пути — 200 с фрагментом
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
|
||||||
|
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
|
||||||
|
|
||||||
|
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
|
||||||
|
пользователь не увидит ничего. Настроить это можно
|
||||||
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
|
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
|
||||||
`htmx:responseError`), но любая настройка — это свой JS-конфиг на клиенте,
|
`htmx:responseError`), но любая настройка — свой JS-конфиг на клиенте, и
|
||||||
что противоречит разделу «Стек и границы». Поэтому сознательно берём
|
платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус
|
||||||
**200 с фрагментом**, несущим сообщение, и доменную ошибку на htmx-пути
|
по-прежнему используется: там его кто-то читает.
|
||||||
**не** транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа
|
|
||||||
с `?err=`.
|
|
||||||
|
|
||||||
- Сообщение — нейтральный текст публичного канала; сырой `err.Error()`
|
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
|
||||||
наружу не идёт.
|
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
|
||||||
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая
|
а не по коду ответа.
|
||||||
доменные поля: у них может быть своё непустое значение, которое сообщение
|
|
||||||
перекроет.
|
### R15. Наружу идёт сообщение публичного канала
|
||||||
- **При ошибке активное состояние не меняем** — перечитанный view
|
|
||||||
показывает прежний выбор плюс сообщение.
|
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
|
||||||
- Цена: в логе доступа провалившееся действие выглядит как `200`. Искать
|
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
|
||||||
его надо по доменной записи об исходе операции (`lang/go/logging.md`), а
|
|
||||||
не по коду ответа.
|
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
|
||||||
|
легче всего забыть, что это тот же публичный канал, что и страница:
|
||||||
|
разметка уезжает в браузер пользователя целиком. Статус 200 (R14)
|
||||||
|
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
|
||||||
|
|
||||||
|
### R16. Сообщение об ошибке — в отдельном поле view
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
|
||||||
|
сообщение не переиспользуются.
|
||||||
|
|
||||||
|
**Почему.** У доменного поля может быть своё непустое значение, и сообщение
|
||||||
|
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
|
||||||
|
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
|
||||||
|
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
|
||||||
|
требует R17.
|
||||||
|
|
||||||
|
### R17. При ошибке активное состояние не меняется
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
|
||||||
|
прежний выбор плюс сообщение.
|
||||||
|
|
||||||
|
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
|
||||||
|
что пользователь узнает о состоянии. Показав намеренное состояние вместо
|
||||||
|
фактического, интерфейс расходится с сервером, и следующее действие человек
|
||||||
|
делает по ложной картине — на сервере оно применится к другому объекту.
|
||||||
|
|
||||||
## Живой поллинг
|
## Живой поллинг
|
||||||
|
|
||||||
Фрагмент-эндпоинт под `/fragments/…` плюс в разметке `hx-get`,
|
Живое обновление устроено как фрагмент-эндпоинт под `/fragments/…` плюс
|
||||||
`hx-trigger="every Ns"`, `hx-swap="outerHTML"`:
|
`hx-get`, `hx-trigger="every Ns"`, `hx-swap="outerHTML"` в разметке:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
{{define "progress"}}<div id="item-live-{{.ID}}"
|
{{define "progress"}}<div id="item-live-{{.ID}}"
|
||||||
@@ -128,81 +257,200 @@ JS — но это отступление, и оно записывается,
|
|||||||
</div>{{end}}
|
</div>{{end}}
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Поллер самозавершается.** Когда состояние выходит из «живого»,
|
### R18. Поллер самозавершается
|
||||||
фрагмент возвращается **без `hx-*`** — htmx больше не опрашивает. Условие
|
|
||||||
живости ведёт собственное состояние приложения, а не внешний сервис.
|
|
||||||
(Встроенная альтернатива — ответ со статусом 286 — не используется: она
|
|
||||||
не совместима с инвариантом «партиал = страница», свежезагруженная
|
|
||||||
страница тоже должна рендериться без поллера.)
|
|
||||||
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
|
|
||||||
поллером и инициализирует новый — двойного опроса нет **при условии
|
|
||||||
совпадения корневого `id`**.
|
|
||||||
- **Поллер не свопит контейнер с активными полями ввода.** Своп поддерева
|
|
||||||
теряет фокус, выделение и незасабмиченный текст внутри него: живость
|
|
||||||
включается только в состояниях, где редактировать нечего.
|
|
||||||
- **Инвариант: браузер не опрашивает внешний сервис напрямую** — только
|
|
||||||
свой сервер.
|
|
||||||
- Если тик **проксирует состояние внешнего сервиса**, данные берутся из
|
|
||||||
in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт
|
|
||||||
снимка узкий и не зависит от способа доставки (путь к SSE остаётся
|
|
||||||
изолированным). Тик, показывающий **собственное** состояние приложения,
|
|
||||||
читает своё хранилище — это нормально и снимка не требует.
|
|
||||||
|
|
||||||
### Поллинг полной страницы
|
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
|
||||||
|
`hx-*`-атрибутов.
|
||||||
|
|
||||||
Когда живой фрагмент — это почти вся страница, отдельный
|
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка
|
||||||
`/fragments/…`-роут дублировал бы обработчик. Тогда допустимо опрашивать
|
держит постоянный поток запросов за неизменными данными, и закрывает его
|
||||||
сам URL страницы и вырезать нужный узел на клиенте:
|
только пользователь. Условие остановки живёт в разметке ответа, потому что
|
||||||
|
это единственный канал, которым сервер управляет поллером.
|
||||||
|
|
||||||
|
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
|
||||||
|
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом
|
||||||
|
и тоже без поллера.
|
||||||
|
|
||||||
|
### R19. Условие живости ведёт собственное состояние приложения
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
|
||||||
|
приложение, а не по ответу внешнего сервиса.
|
||||||
|
|
||||||
|
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его
|
||||||
|
недоступности поллер либо останавливается, пока работа идёт, либо не
|
||||||
|
останавливается никогда. Приложение — единственный участник, который знает
|
||||||
|
про операцию всё и может ответить на каждом тике.
|
||||||
|
|
||||||
|
### R20. Поллер свопит фрагмент целиком через `outerHTML`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
|
||||||
|
содержимое.
|
||||||
|
|
||||||
|
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
|
||||||
|
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
|
||||||
|
выключается (R18). Своп содержимого оставил бы старый узел с его таймером,
|
||||||
|
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
|
||||||
|
при совпадении корневого `id` (R5).
|
||||||
|
|
||||||
|
### R21. Поллер не свопит контейнер с активными полями ввода
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
|
||||||
|
редактировать нечего.
|
||||||
|
|
||||||
|
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
|
||||||
|
внутри него. У поллера это происходит по таймеру, то есть в момент, который
|
||||||
|
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
|
||||||
|
как «приложение стирает мой ввод».
|
||||||
|
|
||||||
|
### R22. Браузер не ходит во внешний сервис напрямую
|
||||||
|
|
||||||
|
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
|
||||||
|
|
||||||
|
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные
|
||||||
|
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
|
||||||
|
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
|
||||||
|
серверным изменением.
|
||||||
|
|
||||||
|
### R23. Источник данных для тика
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
|
||||||
|
|
||||||
|
| № | Что показывает тик | Откуда берёт |
|
||||||
|
|---|---|---|
|
||||||
|
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
|
||||||
|
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
|
||||||
|
|
||||||
|
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
|
||||||
|
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
|
||||||
|
его недоступность становится недоступностью страницы. Снимок разрывает эту
|
||||||
|
связь: частоту обращений к внешнему сервису задаёт воркер, а не
|
||||||
|
пользователи. Контракт снимка держат узким, чтобы способ доставки
|
||||||
|
(поллинг сегодня, SSE потом) менялся, не задевая остальной код. Для
|
||||||
|
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
|
||||||
|
кеша добавил бы только рассинхрон.
|
||||||
|
|
||||||
|
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
|
||||||
|
на URL самой страницы, а нужный узел вырезается `hx-select`:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
|
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
|
||||||
hx-select="#item-main" hx-swap="outerHTML"
|
hx-select="#item-main" hx-swap="outerHTML"
|
||||||
```
|
```
|
||||||
|
|
||||||
Инвариант корневого `id` действует и здесь: `hx-select` должен выбирать тот
|
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
|
||||||
|
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
|
||||||
|
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
|
||||||
|
копии разметки (R4).
|
||||||
|
|
||||||
|
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот
|
||||||
же узел, который свопится.
|
же узел, который свопится.
|
||||||
|
|
||||||
## Своп сохраняет контекст; выход — навигация
|
## Своп и выход со страницы
|
||||||
|
|
||||||
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные
|
### R25. Действие не уводит со страницы, если предмет остаётся на ней
|
||||||
фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева
|
|
||||||
контекст **не** сохраняется — фокус, выделение и введённый текст теряются
|
|
||||||
(см. правило про поллер выше).
|
|
||||||
|
|
||||||
Действие **не должно уводить** пользователя со страницы, если предмет
|
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
|
||||||
остаётся на ней — своп на месте. Действие, после которого предмет
|
|
||||||
**покидает** страницу, остаётся обычной POST-формой **без `hx-*`** → полная
|
|
||||||
навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен
|
|
||||||
`HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
|
|
||||||
|
|
||||||
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
|
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
|
||||||
промежуточное состояние, работу доделывает воркер), своп отдаёт
|
пагинацию — они в query (R12). Полная навигация ради изменения одного
|
||||||
**промежуточное** состояние, а не мнимый результат; итог догоняет
|
региона возвращает пользователя в начало списка и стоит перерисовки всей
|
||||||
самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции.
|
страницы. Не сохраняется при свопе только контекст внутри самого
|
||||||
|
заменяемого поддерева — фокус, выделение, введённый текст (R21).
|
||||||
|
|
||||||
|
### R26. Выход со страницы — форма без `hx-*`
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
|
||||||
|
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
|
||||||
|
|
||||||
|
**Почему.** Своп для такого действия оставил бы на месте регион,
|
||||||
|
описывающий объект, которого на странице больше нет. Отсутствие
|
||||||
|
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
|
||||||
|
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
|
||||||
|
сменить страницу, существующий только на htmx-пути.
|
||||||
|
|
||||||
|
### R27. Асинхронное действие свопит промежуточное состояние
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
|
||||||
|
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18).
|
||||||
|
|
||||||
|
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
|
||||||
|
всё это время пользователь принимает решения по несуществующему исходу —
|
||||||
|
включая повтор действия, которое на самом деле выполняется. Промежуточное
|
||||||
|
состояние вдобавок объясняет, почему регион продолжает обновляться сам.
|
||||||
|
|
||||||
## Различение поверхности одного действия
|
## Различение поверхности одного действия
|
||||||
|
|
||||||
Если один роут зовут с разных страниц и своп-ответ должен быть разным
|
### R28. Поверхность различается скрытым полем формы
|
||||||
фрагментом, различаем **явным скрытым полем формы** (`surface=list|detail`),
|
|
||||||
а не эвристикой по `HX-Target` или `Referer`: поле самодокументируемо и не
|
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
|
||||||
зависит от резолва таргета.
|
фрагментом, поверхность передаётся явным скрытым полем
|
||||||
|
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
|
||||||
|
|
||||||
|
**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
|
||||||
|
может не прийти вовсе; и то и другое меняется без участия обработчика, и
|
||||||
|
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
|
||||||
|
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
|
||||||
|
её заводят.
|
||||||
|
|
||||||
## Статика, вендоринг, кэш
|
## Статика, вендоринг, кэш
|
||||||
|
|
||||||
Раздел не про htmx — это упаковка любого server-rendered приложения;
|
Раздел не про htmx — это упаковка любого server-rendered приложения;
|
||||||
разъедется в языковой слой, когда понадобится там.
|
разъедется в языковой слой, когда понадобится там.
|
||||||
|
|
||||||
- Ассеты встроены в бинарь (`go:embed`) и отдаются с длинным иммутабельным
|
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
|
||||||
кэшем (`Cache-Control: public, max-age=31536000, immutable`).
|
|
||||||
- Меняемые ассеты (css/js) версионируются через `?v=<hash>` — короткий
|
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
|
||||||
sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт
|
`Cache-Control: public, max-age=31536000, immutable`.
|
||||||
устаревший файл.
|
|
||||||
- Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается.
|
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
|
||||||
В git его не коммитим: идемпотентная задача добывает его по манифесту
|
шага раскладки файлов, который может отстать от бинаря и оставить новую
|
||||||
(`путь url sha256`) с проверкой контрольной суммы, и сборка от неё
|
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
|
||||||
зависит.
|
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы
|
||||||
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь
|
способом навсегда закрепить у пользователя старый файл.
|
||||||
самодостаточен, внешних ресурсов времени выполнения нет.
|
|
||||||
|
### R30. Меняемые ассеты версионируются хешем содержимого
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
|
||||||
|
строит хелпер шаблона.
|
||||||
|
|
||||||
|
**Почему.** Хеш содержимого — единственная версия, которую невозможно
|
||||||
|
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
|
||||||
|
от этого не защищают, а цена промаха при иммутабельном кэше (R29) —
|
||||||
|
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
|
||||||
|
хеш не проставляли в каждом шаблоне руками.
|
||||||
|
|
||||||
|
### R31. Вендорный ассет в `?v=` не нуждается
|
||||||
|
|
||||||
|
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
|
||||||
|
параметра версии.
|
||||||
|
|
||||||
|
**Почему.** Содержимое под этим именем не меняется: обновление вендора
|
||||||
|
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
|
||||||
|
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
|
||||||
|
явное разрешение снимает вопрос, не нарушает ли это R30.
|
||||||
|
|
||||||
|
### R32. Вендор не коммитится, а добывается по манифесту
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
|
||||||
|
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
|
||||||
|
задачи.
|
||||||
|
|
||||||
|
**Почему.** Манифест делает версию и происхождение ассета видимыми в
|
||||||
|
diff'е — у закоммиченного минифицированного файла обновление выглядит
|
||||||
|
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
|
||||||
|
единственная проверка, что скачали то же самое, что проверяли; зависимость
|
||||||
|
сборки от задачи не даёт собраться без ассета в свежем клоне.
|
||||||
|
|
||||||
|
### R33. Шрифты и скрипты — self-hosted
|
||||||
|
|
||||||
|
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
|
||||||
|
|
||||||
|
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
|
||||||
|
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
|
||||||
|
вдобавок разворачивается в сети без выхода наружу, где CDN просто не
|
||||||
|
отвечает.
|
||||||
|
|
||||||
<!-- local:эталоны -->
|
<!-- local:эталоны -->
|
||||||
<!-- /local -->
|
<!-- /local -->
|
||||||
|
|||||||
Reference in New Issue
Block a user