заведён канон общих конвенций для личных проектов
- 13 конвенций по осям arch / lang / stack / common; репозитории берут оттуда копии в свой docs/conventions/ и коммитят их у себя - conv — синхронизация копий: add / status / diff / pull / push, локальные регионы исключены из сравнения, поэтому расхождение не даёт шума
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
# Канон конвенций
|
||||
|
||||
Общие конвенции для личных проектов. Репозитории берут отсюда копии в свой
|
||||
`docs/conventions/`, коммитят их и живут дальше самостоятельно — как с
|
||||
`ansible-roles`: канон не источник истины во время работы, а лавка, из
|
||||
которой берут и в которую возвращают улучшения.
|
||||
|
||||
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
|
||||
git репозитория**. Канон никем не подключается на лету.
|
||||
|
||||
## Направление — конвенция → код
|
||||
|
||||
Конвенция формулируется независимо от конкретного приложения. Она задаёт
|
||||
правило; код ему следует. Обратное направление запрещено: то, что
|
||||
приложение уже делает иначе, **не является аргументом против правила** — это
|
||||
отступление, и его место в локальном регионе того репозитория, а не в
|
||||
переформулировке канона.
|
||||
|
||||
Отсюда практические следствия:
|
||||
|
||||
- в каноне нет утверждений о том, как что-то устроено в конкретном
|
||||
репозитории («у нас так в девяти плейбуках из тридцати трёх») — только
|
||||
нормы и условия их применимости;
|
||||
- в каноне нет списка, кто на что подписан: подписка — свойство
|
||||
репозитория, а не конвенции;
|
||||
- расхождение канона с кодом чинится либо кодом, либо честной записью
|
||||
отступления, либо — если правило оказалось неверным — правкой правила по
|
||||
существу, а не подгонкой под факт.
|
||||
|
||||
## Оси
|
||||
|
||||
```
|
||||
common/ как вести сами конвенции
|
||||
arch/ решения, переживающие смену языка и инструментов
|
||||
lang/<язык>/ как решение реализуется и механизируется в языке
|
||||
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
|
||||
```
|
||||
|
||||
Тест — по тому, замена чего убивает правило:
|
||||
|
||||
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
|
||||
> хранилища или транспорта** → `stack/`. Не умирает ни от того, ни от
|
||||
> другого → `arch/`.
|
||||
|
||||
`PK — ULID, генерирует приложение` не умирает ни от чего — это лежит в
|
||||
данных → `arch/`. `internal/ident`, `ident.Parse` на границах умирают со
|
||||
сменой языка → `lang/go/`. `enum как TEXT без CHECK` переживёт Go → Python,
|
||||
но не переживёт уход от SQLite → это `stack/sqlite/`.
|
||||
|
||||
Ось определяется **природой правила**, а не тем, сколько сегодня
|
||||
потребителей. Конвенция независима от приложений по построению, поэтому
|
||||
арх-слой выделяется тогда, когда правило действительно не зависит от языка,
|
||||
а не когда появился второй язык.
|
||||
|
||||
**Известный долг.** По этому тесту `lang/go/errors.md`, `lang/go/logging.md`
|
||||
и `lang/go/db-schema.md` содержат невыделенные слои: у первых двух —
|
||||
архитектурное ядро (уровень как адресат, что не логируем; трансляция ошибки
|
||||
на внешней границе, приватный канал против публичного), у третьего — целый
|
||||
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
|
||||
работа.
|
||||
|
||||
## Расширение
|
||||
|
||||
Файл в `lang/` или `stack/` может объявить в шапке:
|
||||
|
||||
```yaml
|
||||
extends: arch/db-identifiers.md
|
||||
```
|
||||
|
||||
Расширение **только реализует и сужает** базу, но не отменяет её. Если
|
||||
слою нужно противоречить базе — это сигнал одного из двух: либо у базы
|
||||
неверно сформулировано условие применимости (чинится в каноне), либо
|
||||
репозиторий на базу просто не подписан.
|
||||
|
||||
`extends` — документация связи, а не механизм: `conv` о ней только
|
||||
напоминает при `add` и никак не следит за тем, чтобы база лежала рядом.
|
||||
|
||||
## Служебная разметка
|
||||
|
||||
**Шапка копии** ставится при `conv add` и в каноне не хранится:
|
||||
|
||||
```yaml
|
||||
---
|
||||
origin: arch/time.md # откуда взято
|
||||
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
|
||||
synced: 2026-07-25
|
||||
local: нет # или: чем и почему разошлись
|
||||
---
|
||||
```
|
||||
|
||||
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать
|
||||
«канон обновился» от «изменено локально»; без него `status` умеет только
|
||||
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки
|
||||
(`status`, `extends`) — часть документа: они сравниваются наравне с телом.
|
||||
|
||||
**Локальные регионы** — куски, принадлежащие репозиторию по определению.
|
||||
Из сравнения исключаются, поэтому вечного шума в `diff` не дают:
|
||||
|
||||
```markdown
|
||||
<!-- local:механизировано -->
|
||||
`AUTOINCREMENT` в новых миграциях — `internal/archrules`.
|
||||
<!-- /local -->
|
||||
```
|
||||
|
||||
Имя обязательно — перенос при `pull` идёт по именам, безымянные регионы
|
||||
`conv` отвергает. Что всегда локально:
|
||||
|
||||
- **механизация** — канон не знает, у кого линтер уже настроен;
|
||||
- **отступления** — «у нас пока не так», честно и поимённо;
|
||||
- **разрешение условия** — «Здесь: INTEGER PK, id наружу не выходят»;
|
||||
- **эталоны и ссылки** — имена функций, файлов, ADR конкретного репозитория;
|
||||
- **список конвенций** в README репозитория.
|
||||
|
||||
Путь файла в каноне и имя региона — это API: переименование осиротит все
|
||||
копии (`origin` строковый). Переименовывать — только вместе с обходом
|
||||
потребителей.
|
||||
|
||||
После `pull` копию нужно перечитать глазами: содержимое региона могло
|
||||
устареть относительно переписанного вокруг текста, и автоматика этого не
|
||||
увидит.
|
||||
|
||||
## Раскладка в репозитории
|
||||
|
||||
Копии повторяют структуру канона:
|
||||
|
||||
```
|
||||
docs/conventions/
|
||||
README.md собственный, не синхронизируется
|
||||
arch/db-identifiers.md
|
||||
lang/go/db-identifiers.md
|
||||
```
|
||||
|
||||
Подписка не описана отдельным файлом — она **и есть** набор лежащих файлов,
|
||||
видимый в `git ls-files`. README директории перечисляет их одной плоской
|
||||
таблицей, чтобы вложенность не мешала навигации.
|
||||
|
||||
## Контракт с агентом
|
||||
|
||||
Копии — обычные файлы, и правка их агентом никак не отличима от правки
|
||||
любого другого документа. Это главный канал тихого дрейфа, поэтому
|
||||
`AGENTS.md` каждого потребителя должен явно говорить:
|
||||
|
||||
> Файлы в `docs/conventions/` с шапкой `origin:` — копии из канона
|
||||
> `dev-conventions`. Репозиторное пишется только внутрь
|
||||
> `<!-- local:… -->`. Правка вне регионов — либо `conv push` в канон, либо
|
||||
> запись причины в `local:`.
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
conv list # что есть в каноне
|
||||
conv add arch/time.md # взять к себе (можно несколько за раз)
|
||||
conv status # ok / изменено локально / канон обновился / разошлись
|
||||
conv diff [arch/time.md] # чем копия отличается, без учёта локальных регионов
|
||||
conv pull arch/time.md # забрать обновление канона (регионы переносятся)
|
||||
conv push arch/time.md # вернуть локальное улучшение в канон
|
||||
conv push --new lang/go/x.md # завести в каноне новую конвенцию
|
||||
```
|
||||
|
||||
`status` и `diff` всегда завершаются кодом 0: это отчёт, а не проверка.
|
||||
Расхождение — нормальное состояние, а постоянный шум в `diff` означает не
|
||||
«догони канон», а «пора разрезать файл». Симметрично: разросшийся до спора
|
||||
с базой локальный регион означает «пора чинить условие применимости в
|
||||
каноне».
|
||||
|
||||
Запускать из корня репозитория:
|
||||
|
||||
```bash
|
||||
~/projects/private/dev-conventions/conv status
|
||||
```
|
||||
|
||||
Обёртка в раннере репозитория (`inv conventions -- status` для ansible,
|
||||
`task conventions -- status` для Go) — тонкий проброс аргументов, чтобы
|
||||
логика не размножалась по репозиториям в двух диалектах.
|
||||
|
||||
## Жизненный цикл
|
||||
|
||||
- **В канон.** Новая конвенция пишется в том репозитории, где заболело, и
|
||||
продвигается `conv push --new`. Локальные регионы при этом опустошаются:
|
||||
в канон едет только норма.
|
||||
- **Из канона.** Устаревшая конвенция удаляется вместе с обходом
|
||||
потребителей — тихо осиротить копии нельзя.
|
||||
- **История.** Канон коммитится при каждом `push`: `origin_hash` отвечает
|
||||
на «отличается ли», но только git канона отвечает на «почему база
|
||||
сформулирована так».
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Категории директорий приложения
|
||||
|
||||
Всё, что приложение пишет на диск, делится на три категории по принципу
|
||||
создания и ценности содержимого:
|
||||
|
||||
- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе
|
||||
секреты;
|
||||
- **данные** — то, что генерирует приложение и что нужно бэкапить;
|
||||
- **кеш** — то, что генерирует приложение и что не нужно бэкапить:
|
||||
приложение перегенерирует заново.
|
||||
|
||||
Цель — упростить оперирование данными. Категория сразу отвечает на два
|
||||
вопроса, которые иначе приходится выяснять по коду приложения: **кто
|
||||
создаёт** содержимое и **что будет, если его потерять**.
|
||||
|
||||
## Категории
|
||||
|
||||
| Категория | Директория | Создаёт | Потеря содержимого | Бэкап |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен |
|
||||
| Данные | `data/` | приложение | невосполнима | обязателен |
|
||||
| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен |
|
||||
|
||||
Имена в таблице — умолчание для случая «одна директория на категорию».
|
||||
**Категория может состоять из нескольких директорий**, и это нормально:
|
||||
крупные файлы отделяют от базы, чтобы двигать их между дисками независимо
|
||||
(`media/`, `uploads/` — та же категория «данные», что и `data/`).
|
||||
Принадлежность к категории задаётся не именем, а участием в списке бэкапа.
|
||||
|
||||
Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять
|
||||
приложение заново. Поднимется само и наверстает — кеш. Не поднимется или
|
||||
поднимется пустым — данные.
|
||||
|
||||
Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты,
|
||||
а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и
|
||||
хранилище секретов, а не снапшот бэкапа.
|
||||
|
||||
## Данные, которые нельзя копировать на живую
|
||||
|
||||
Файловый снапшот работающей СУБД не гарантирует консистентности:
|
||||
скопированный каталог может не восстановиться. Поэтому у категории «данные»
|
||||
есть два способа попасть в бэкап:
|
||||
|
||||
- **копированием** — если файлы самодостаточны на любой момент времени;
|
||||
- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда
|
||||
бэкапится директория дампов, а сырой каталог базы — нет.
|
||||
|
||||
Директория дампов — тоже данные, просто производные. Решение «копировать
|
||||
или дампить» принимается **при заведении приложения**, а не при первой
|
||||
неудачной попытке восстановления.
|
||||
|
||||
## Контракт с приложением
|
||||
|
||||
Категории — не только про деплой. Приложение **разводит свои записываемые
|
||||
пути по категориям в конфигурации**, а не складывает всё в один каталог:
|
||||
иначе категорию нельзя определить снаружи и список бэкапа приходится
|
||||
составлять вручную, читая код.
|
||||
|
||||
- Путь к БД, загруженным файлам, сгенерированным артефактам — данные.
|
||||
- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые
|
||||
перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠
|
||||
невосполнимо.
|
||||
- Приложение не пишет в директорию конфигурации: она может быть доступна
|
||||
только на чтение.
|
||||
|
||||
Если приложение не умеет разделять, это его дефект, а не повод смешивать
|
||||
категории в раскладке.
|
||||
|
||||
## Список бэкапа выводится, а не составляется
|
||||
|
||||
Список бэкапа получается из категорий по правилу: туда идут данные, не идут
|
||||
конфигурация и кеш. Правило механическое — но его применяет человек или
|
||||
шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что
|
||||
и создание директорий. Независимо набранный список — источник расхождения
|
||||
между тем, что бэкапится, и тем, что нужно.
|
||||
|
||||
## Область действия
|
||||
|
||||
Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется
|
||||
к **новым приложениям**; существующие переезжают по мере касания, отдельной
|
||||
кампанией не переписываются. Разделять данные и кеш задним числом имеет
|
||||
смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы.
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
<!-- local:эталон -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Конфигурация приложения
|
||||
|
||||
Как устроена конфигурация: где лежит, как попадает в процесс, что с
|
||||
секретами и когда падает.
|
||||
|
||||
## Файл, а не окружение
|
||||
|
||||
**Конфигурация — файл.** Причины, по убыванию веса:
|
||||
|
||||
- **Один типизированный источник.** Файл несёт секции, комментарии,
|
||||
единицы измерения и валидируется целиком. Окружение — плоский набор
|
||||
нетипизированных строк, который приходится документировать отдельно;
|
||||
появление второго канала конфигурации гарантирует расхождение между ними.
|
||||
- **Окружение наследуется дочерними процессами.** Всё, что приложение
|
||||
запускает — конвертер, `git`, шелл-хук, — по умолчанию получает копию
|
||||
секретов, хотя они ему не нужны.
|
||||
- **В контейнере окружение расползается по лишним поверхностям.**
|
||||
`docker inspect` показывает его любому, у кого есть доступ к сокету
|
||||
докера; переменные оседают в compose-файле и `.env` на диске — то есть
|
||||
файл всё равно появляется, только без структуры и валидации.
|
||||
|
||||
Обратите внимание, чего в списке **нет**: `/proc/<pid>/environ` не является
|
||||
аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`,
|
||||
то есть доступен ровно тому же кругу, что и файл под `0600`.
|
||||
|
||||
Запрет держится на «один источник» и на том, что все приложения свои. Для
|
||||
стороннего образа, живущего на env, конвенция неприменима — это не повод
|
||||
отказываться от неё для своих.
|
||||
|
||||
Практика:
|
||||
|
||||
- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку).
|
||||
- Имя по умолчанию фиксировано и ищется в рабочей директории процесса;
|
||||
путь переопределяется опцией командной строки.
|
||||
- Реальный конфиг не коммитится. В репозитории лежит **образец**.
|
||||
|
||||
## Грузим один раз, дальше не перечитываем
|
||||
|
||||
- Разбор — **один раз при старте**, в одну типизированную структуру.
|
||||
Дальше по коду читаем только её: чтения файла в бизнес-коде нет.
|
||||
- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса.
|
||||
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
|
||||
умолчание.
|
||||
- Умолчания задаются в коде, файл их перекрывает. Образец при этом
|
||||
перечисляет **все** поля, включая те, у которых есть умолчание: поле,
|
||||
живущее только в коде, для читателя конфига не существует.
|
||||
|
||||
## Образец самодокументируем
|
||||
|
||||
Образец коммитим как единый справочник по конфигу: все секции и все поля.
|
||||
**Каждое поле снабжаем комментарием**, из которого ясно:
|
||||
|
||||
- **зачем** поле — что оно меняет в поведении;
|
||||
- **диапазон или допустимые значения** — перечисление либо границы;
|
||||
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
|
||||
`0–1`.
|
||||
|
||||
Так конфиг читается без открывания кода — этим он и полезен.
|
||||
|
||||
## Поля по дискриминатору `type`
|
||||
|
||||
Когда набор полей секции зависит от поля-дискриминатора (выбор одного из
|
||||
бекендов или внешних сервисов), обязательность полей определяется его
|
||||
значением, а не фиксирована для секции.
|
||||
|
||||
- **Валидация — по значению `type`**: для каждого поддерживаемого варианта
|
||||
свой набор обязательных полей; поля других вариантов не требуются.
|
||||
Неизвестное значение → ошибка на старте с перечислением поддерживаемых.
|
||||
- **Образец — по значению `type`**: основной вариант предзаполнен рабочими
|
||||
значениями, альтернативные — блоками-комментариями ниже, каждый со своим
|
||||
описанием полей. Из примера видны все варианты, не открывая код.
|
||||
|
||||
## Секреты приносит деплой
|
||||
|
||||
Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя
|
||||
секретов в приложении нет — оно просто читает файл. Источник истины
|
||||
секрета — внешнее хранилище деплоя, не репозиторий и не окружение.
|
||||
|
||||
- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime-
|
||||
пользователь.
|
||||
- В образце секретные поля — пустые строки.
|
||||
- Загрузчик на старте проверяет, что обязательные секреты не пусты: это
|
||||
ловит криво отрендеренный шаблон до того, как он превратится в 401 от
|
||||
внешнего API через час работы.
|
||||
- В логи секреты не попадают.
|
||||
|
||||
<!-- local:секретные-поля -->
|
||||
<!-- /local -->
|
||||
|
||||
## Валидация и fail-fast
|
||||
|
||||
Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг —
|
||||
запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину».
|
||||
|
||||
Проверяем как минимум:
|
||||
|
||||
- обязательные поля заданы, обязательные секреты не пусты;
|
||||
- пути существуют и доступны на запись/чтение по назначению;
|
||||
- числовые диапазоны и единицы (доли, таймауты, счётчики попыток);
|
||||
- строки, которые парсятся во что-то (длительности, зоны, URL), реально
|
||||
парсятся;
|
||||
- включённые секции консистентны: если интеграция включена — заданы все её
|
||||
обязательные поля.
|
||||
|
||||
Проблемы собираем и показываем **разом**, а не по одной за запуск.
|
||||
|
||||
<!-- local:проверки -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — формат времени; зона отображения — единственный
|
||||
конфигурируемый параметр времени, семантика описана там.
|
||||
- `arch/app-directories.md` — конфиг лежит в категории «конфигурация» и
|
||||
доступен приложению только на чтение.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,86 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Идентификаторы сущностей
|
||||
|
||||
Как выбираются и как выглядят первичные ключи. Схема БД меняется тяжело,
|
||||
поэтому конвенция применяется **к новым таблицам**; существующие живут как
|
||||
есть и перечислены в отступлениях.
|
||||
|
||||
## Условие применимости
|
||||
|
||||
Вопрос задаётся **один раз на репозиторий**, а не по таблицам:
|
||||
|
||||
> Есть ли в приложении хотя бы одна сущность, которую адресуют извне — по
|
||||
> id из URL, запроса API или callback-данных?
|
||||
>
|
||||
> - **Да** → весь репозиторий на сортируемый строковый id, который
|
||||
> генерирует приложение (ULID), включая внутренние таблицы.
|
||||
> - **Ни одной** → автоинкремент, и этого достаточно.
|
||||
|
||||
Критерий — именно **адресация**: снаружи по этому id возвращаются к
|
||||
системе. Не «id виден в логе» — туда рано или поздно попадает любой
|
||||
идентификатор, и по такому критерию вторая ветка была бы недостижима.
|
||||
|
||||
Почему квантор репозиторный, а не потабличный: внутренние сущности имеют
|
||||
привычку становиться внешними, и тогда целочисленный id утекает в URL
|
||||
задним числом. Плюс одна ментальная модель дешевле, чем спор при заведении
|
||||
каждой таблицы.
|
||||
|
||||
**Что не является смешиванием.** Запрет касается двух видов
|
||||
*сгенерированных суррогатных* ключей в одной базе. Естественные и составные
|
||||
ключи у таблиц-деталей — третья категория, они допустимы всегда (см. ниже).
|
||||
|
||||
<!-- local:решение -->
|
||||
<!-- /local -->
|
||||
|
||||
## Если ULID
|
||||
|
||||
- **PK — TEXT ULID** (26 символов Crockford base32), генерируется
|
||||
**приложением** в момент создания записи, а не БД.
|
||||
- Почему не UUID: UUIDv4 не сортируется по времени. UUIDv7 (RFC 9562)
|
||||
сортируется, и против него остаются два довода — 36 символов против 26 и
|
||||
дефисы: без них `grep` и двойной клик берут id целиком.
|
||||
- Сортировка по времени создания даёт `ORDER BY id` = хронология с
|
||||
точностью до миллисекунды. Внутри одной миллисекунды порядок произволен,
|
||||
если генератор не монотонный, — на хронологию событий это не влияет.
|
||||
- Глобальная уникальность across таблиц даёт побочный, но важный эффект:
|
||||
голый `grep` по id находит все записи сущности независимо от имени поля.
|
||||
- **Единая точка генерации и разбора.** Один модуль генерирует id и один
|
||||
разбирает; самодельных генераторов по коду нет.
|
||||
|
||||
## Канонический вид и границы
|
||||
|
||||
- Генерим и храним id в **нижнем регистре**. Сравнение строк в БД обычно
|
||||
побайтовое, поэтому регистр — не косметика, а корректность. Спецификация
|
||||
ULID канонизирует верхний регистр, и библиотеки по умолчанию отдают
|
||||
именно его — нижний обеспечивает единая точка генерации, поэтому звать
|
||||
библиотеку мимо неё нельзя.
|
||||
- Любой пришедший снаружи id **обязательно** проходит разбор до запроса к
|
||||
БД: он валидирует формат и нормализует регистр.
|
||||
- Синтаксически невалидный id, которым **адресуют ресурс**, трактуем как
|
||||
несуществующую сущность (404), **без похода в БД**: это и дешевле, и
|
||||
убирает целый класс запросов с мусором. Невалидный id, пришедший из
|
||||
собственной формы или кнопки, — не «не найдено», а некорректный ввод: там
|
||||
это признак устаревшего интерфейса или бага, и маскировать его под 404
|
||||
значит терять диагностику.
|
||||
|
||||
## Естественные и составные ключи — для деталей
|
||||
|
||||
У таблиц-деталей и связей допустим естественный или составной ключ вместо
|
||||
сгенерированного, когда он есть по природе данных. Отдельный id там —
|
||||
мёртвый вес, который ещё и создаёт второй способ адресовать ту же строку.
|
||||
|
||||
Прочие генерируемые идентификаторы (батчи, задания, корреляционные ключи) —
|
||||
через ту же единую точку: единый формат, сортируемость, корреляция в логах.
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — метки времени тоже генерирует приложение, а не схема.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Время
|
||||
|
||||
Один формат времени на всё приложение: хранение, логи, API, обмен с
|
||||
внешними системами. Разные форматы в разных слоях — источник ошибок,
|
||||
которые всплывают через полгода на границе перехода на летнее время.
|
||||
|
||||
## Формат
|
||||
|
||||
- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`.
|
||||
- **Ширина фиксируется на каждый носитель** и внутри него не плавает.
|
||||
Лексикографическая сортировка равна хронологии только среди строк
|
||||
одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
|
||||
хронологически позже. Ради этого формат и фиксируется — `ORDER BY
|
||||
created_at` по текстовому полю обязан давать порядок событий.
|
||||
- Разные носители могут иметь разную точность: строки БД и строки лога
|
||||
между собой никогда не сравниваются. Требование — не «одна точность на
|
||||
приложение», а «внутри колонки и внутри потока логов ширина одна».
|
||||
- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в
|
||||
логах, ни в JSON API.
|
||||
|
||||
## Генерирует приложение, а не хранилище
|
||||
|
||||
- Единая точка получения «сейчас» и единая точка форматирования и разбора —
|
||||
как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по
|
||||
коду не разбросаны: иначе ни формат, ни зона не гарантированы.
|
||||
- **Дефолты в схеме БД не используем.** Забытая вставка `created_at`
|
||||
должна падать громко, а не тихо получать значение от БД — иначе
|
||||
расходятся источник времени (сервер БД) и его формат.
|
||||
|
||||
## Длительность — не метка времени
|
||||
|
||||
Измерение длительности операции — отдельная величина: число (обычно
|
||||
миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не
|
||||
время в формате выше. Засекает её тот слой, который делает вызов.
|
||||
|
||||
**Интервал измеряется монотонными часами процесса**, а не вычитанием
|
||||
сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и
|
||||
дать отрицательную длительность. Из этого следует, что источник меток
|
||||
времени и источник интервалов — разные, даже если оба называются «часы».
|
||||
|
||||
## Зоны
|
||||
|
||||
Единственное место, где появляется не-UTC, — **отображение пользователю**.
|
||||
Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию —
|
||||
`UTC`. На хранение, сортировку и логи она не влияет.
|
||||
|
||||
Если бизнес-логика оперирует календарными сущностями («сегодня»,
|
||||
«за месяц»), зона указывается **явно** в месте вычисления — молчаливое
|
||||
использование системной зоны процесса запрещено: она разная на ноутбуке и в
|
||||
контейнере. По умолчанию это та же зона, что и для отображения; если
|
||||
календарная логика требует другой, это записывается явно.
|
||||
|
||||
Конвенция описывает фиксацию **свершившихся моментов**. Планирование
|
||||
будущих событий — отдельный случай (там хранят локальное время плюс имя
|
||||
зоны, потому что правила зон меняются); пока такой сущности нет, правило не
|
||||
формулируем.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/config.md` — где задаётся зона отображения.
|
||||
- `arch/db-identifiers.md` — то же правило «генерирует приложение» для id.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
status: обязательная
|
||||
---
|
||||
|
||||
# Как мы ведём конвенции
|
||||
|
||||
Конвенция описывает повторяющийся выбор: как называть директории, как
|
||||
раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как
|
||||
принято», а не «что здесь происходит». Одна конвенция — один файл.
|
||||
|
||||
Механической проверки у самой этой конвенции нет — осознанное исключение:
|
||||
проверять «правильно ли написана конвенция» нечем, а обязательный статус
|
||||
нужен, чтобы правила ниже не обсуждались заново в каждом репозитории.
|
||||
|
||||
## Канон и копии
|
||||
|
||||
Файлы в этой директории с шапкой `origin:` — **копии из общего канона**
|
||||
`dev-conventions`, а не собственные документы репозитория. Отсюда:
|
||||
|
||||
- репозиторное пишется **только внутрь локальных регионов**
|
||||
`<!-- local:имя --> … <!-- /local -->`: они исключены из сравнения с
|
||||
каноном, и расхождение по ним — норма, а не дрейф;
|
||||
- правка вне регионов означает одно из двух: улучшение, которое надо
|
||||
вернуть в канон, или сознательное расхождение, записанное в ключ `local:`
|
||||
шапки;
|
||||
- состояние копий показывает `conv status`, различия — `conv diff`,
|
||||
обновление из канона — `conv pull`; всё через раннер репозитория.
|
||||
|
||||
Имя региона обязательно и стабильно: перенос содержимого при обновлении
|
||||
идёт по именам.
|
||||
|
||||
## Отличие от соседей
|
||||
|
||||
- `docs/adr/` — **решение**, принятое однажды и постфактум («почему выбрали
|
||||
Authelia, а не Keycloak»). Запись неизменяема.
|
||||
- `docs/specs/` и OpenSpec, где они есть, — **что** система делает,
|
||||
наблюдаемое поведение как контракт. Конвенция — **как** написан код;
|
||||
в спеки она не переносится, это не capability.
|
||||
- `docs/drafts/` — оперативная хроника и черновики, «что собираюсь сделать».
|
||||
- `docs/conventions/` — **правило на будущее**, применяемое многократно.
|
||||
Живой документ: правится, когда договорённость меняется.
|
||||
|
||||
## Направление: конвенция → код
|
||||
|
||||
Конвенция формулируется независимо от того, как устроено конкретное
|
||||
приложение. Код следует конвенции, а не наоборот.
|
||||
|
||||
Если код расходится с правилом — это отступление, и оно записывается в
|
||||
локальный регион, а не переписывает правило. Правило меняется только тогда,
|
||||
когда оно **неверно по существу**: содержит фактическую ошибку, внутреннее
|
||||
противоречие или условие применимости, которое не даёт ответа.
|
||||
|
||||
Практическое следствие: в тексте конвенции не должно быть утверждений о
|
||||
текущем состоянии репозитория. «Так сделано у нас» — это регион
|
||||
отступлений; норма пишется в настоящем предписывающем времени.
|
||||
|
||||
## Статус
|
||||
|
||||
Каждая конвенция объявляет статус в шапке:
|
||||
|
||||
- **рекомендуемая** — так стоит делать в новом коде; существующий переезжает
|
||||
по мере касания, отдельной кампанией не переписывается;
|
||||
- **обязательная** — нарушение считается ошибкой; по возможности проверяется
|
||||
линтером или хуком, а не вниманием.
|
||||
|
||||
Конвенция без механической проверки держится только на внимании — это
|
||||
нормально для рекомендуемой и плохо для обязательной.
|
||||
|
||||
## Когда заводить
|
||||
|
||||
Когда одно и то же решение принимается третий раз и каждый раз чуть
|
||||
по-другому. Единичный выбор — не конвенция; если он ещё и был спорным, ему
|
||||
место в ADR.
|
||||
|
||||
Путь находки: **находка → конвенция → правило линтера → удаление прозы**.
|
||||
Первые два шага делаются в репозитории, где заболело; общая часть
|
||||
продвигается в канон.
|
||||
|
||||
## Прозой — только то, что не выражается правилом
|
||||
|
||||
Как только свойство удаётся проверить машиной, его формулировка перестаёт
|
||||
работать: файл на несколько сотен строк размазывает внимание по
|
||||
тривиальному, и человек с агентом добросовестно проверят именование, не
|
||||
дойдя до формы решения.
|
||||
|
||||
Но удаление прозы в общем каноне устроено иначе, чем в одиночном
|
||||
репозитории. Механизация — состояние **конкретного** репозитория:
|
||||
|
||||
- **из канона формулировка не удаляется**, пока правило не механизировано
|
||||
у всех потребителей: иначе те, у кого линтера нет, останутся без правила;
|
||||
- **факт механизации** фиксируется в локальном регионе `механизировано` —
|
||||
со ссылкой на конкретное правило;
|
||||
- когда механизация стала общей (правило уехало в общий конфиг линтера или
|
||||
в общую роль), формулировка удаляется из канона одним `push`.
|
||||
|
||||
## Трудноизменяемые слои
|
||||
|
||||
У схемы БД, формата хранения и раскладки директорий шкала
|
||||
«рекомендуемая → переезжает по мере касания» не работает: таблица не
|
||||
переезжает от того, что её потрогали. Для таких конвенций:
|
||||
|
||||
- **область действия пишется явно** — «применяется к новым таблицам и
|
||||
миграциям», а не к состоянию схемы;
|
||||
- **механизируется граница изменения, а не состояние** — линтер запрещает
|
||||
`AUTOINCREMENT` в новых миграциях, а не в существующей схеме: старое не
|
||||
падает, новая ошибка невозможна;
|
||||
- **список отступлений постоянный**, а не список задач на дочистку.
|
||||
|
||||
## Честный список отступлений
|
||||
|
||||
В локальном регионе перечисляем отступления, которые уже есть в коде, —
|
||||
иначе репозиторий делает вид, что правилу следует. У рекомендуемой
|
||||
конвенции пустой список отступлений почти всегда означает, что их просто не
|
||||
искали.
|
||||
|
||||
Отступление — это «правилу не следуем здесь и вот почему». Если регион
|
||||
разросся до «мы это правило вообще не применяем», значит либо у правила
|
||||
неверно сформулировано условие применимости (чинить в каноне), либо
|
||||
репозиторию не нужна эта конвенция (не подписываться).
|
||||
|
||||
## Оформление
|
||||
|
||||
- Имя файла — kebab-case по теме: `app-directories.md`.
|
||||
- Раздел «Связано» в конце: ADR с обоснованием, спеки, код, который эту
|
||||
конвенцию механизирует. Репо-специфичная часть «Связано» — в локальном
|
||||
регионе, канонические ссылки — в общем тексте.
|
||||
- README директории перечисляет конвенции с однострочным описанием, чтобы
|
||||
список читался без открывания файлов.
|
||||
- **Короткие инварианты дублируются туда, что агент читает безусловно**
|
||||
(`AGENTS.md` / `CLAUDE.md`): сама по себе конвенция агенту не видна, он
|
||||
дойдёт до неё, только если его туда отправили. Детали остаются здесь,
|
||||
в файл-точку-входа едет одна строка на правило.
|
||||
|
||||
<!-- local:точки-входа -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,515 @@
|
||||
#!/usr/bin/env python3
|
||||
"""conv — синхронизация конвенций между каноном и репозиторием.
|
||||
|
||||
Канон — эта директория. Репозиторий держит закоммиченные копии нужных
|
||||
конвенций в docs/conventions/, повторяя структуру канона. Копия — источник
|
||||
правды для репозитория; канон — лавка, из которой берут.
|
||||
|
||||
Служебная разметка копии:
|
||||
|
||||
---
|
||||
origin: arch/time.md # откуда взято
|
||||
origin_hash: a1b2c3d4 # отпечаток канона на момент синхронизации
|
||||
synced: 2026-07-25
|
||||
local: нет # или текст: чем и почему разошлись
|
||||
---
|
||||
|
||||
Прочие ключи шапки (status, extends) — часть документа: они сравниваются
|
||||
наравне с телом и приезжают из канона.
|
||||
|
||||
Локальные регионы — куски, которые по определению принадлежат репозиторию
|
||||
(механизация, отступления, «здесь решили так»). Из сравнения исключаются:
|
||||
|
||||
<!-- local:механизировано -->
|
||||
...
|
||||
<!-- /local -->
|
||||
|
||||
Имя региона обязательно: перенос при pull идёт по именам.
|
||||
|
||||
Команды:
|
||||
conv list что есть в каноне
|
||||
conv add arch/time.md [...] взять конвенцию в репозиторий
|
||||
conv status состояние копий репозитория
|
||||
conv diff [arch/time.md] чем копия отличается от канона
|
||||
conv pull arch/time.md забрать обновление канона
|
||||
conv push arch/time.md вернуть локальное улучшение в канон
|
||||
conv push --new lang/go/x.md завести в каноне новую конвенцию
|
||||
|
||||
Везде можно указать --repo <path> (по умолчанию — текущая директория)
|
||||
и --dir <subpath> (по умолчанию docs/conventions), до или после команды.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import difflib
|
||||
import hashlib
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NoReturn
|
||||
|
||||
CANON = Path(__file__).resolve().parent
|
||||
CANON_TREES = ("common", "arch", "lang", "stack")
|
||||
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
|
||||
DEFAULT_DIR = "docs/conventions"
|
||||
ENC = "utf-8"
|
||||
|
||||
# Маркеры распознаются только в начале строки: так пример разметки внутри
|
||||
# текста конвенции не превращается в настоящий регион.
|
||||
REGION_RE = re.compile(
|
||||
r"^<!--[ \t]*local(?::[ \t]*([^>]*?))?[ \t]*-->(.*?)^<!--[ \t]*/local[ \t]*-->",
|
||||
re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
OPEN_RE = re.compile(r"^<!--[ \t]*local", re.MULTILINE)
|
||||
CLOSE_RE = re.compile(r"^<!--[ \t]*/local", re.MULTILINE)
|
||||
|
||||
|
||||
def die(message: str) -> NoReturn:
|
||||
print(f"conv: {message}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# --- разметка --------------------------------------------------------------
|
||||
|
||||
|
||||
def read(path: Path) -> str:
|
||||
return path.read_text(encoding=ENC)
|
||||
|
||||
|
||||
def write(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding=ENC)
|
||||
|
||||
|
||||
def split_front(text: str) -> tuple[dict[str, str], str]:
|
||||
"""Отделяет YAML-шапку (плоский key: value) от тела."""
|
||||
if not text.startswith("---\n"):
|
||||
return {}, text
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return {}, text
|
||||
meta: dict[str, str] = {}
|
||||
for line in text[4:end].splitlines():
|
||||
if ":" in line:
|
||||
key, value = line.split(":", 1)
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta, text[end + 5 :]
|
||||
|
||||
|
||||
def join_front(meta: dict[str, str], body: str) -> str:
|
||||
if not meta:
|
||||
return body
|
||||
lines = "\n".join(f"{k}:{' ' + v if v else ''}" for k, v in meta.items())
|
||||
return f"---\n{lines}\n---\n{body}"
|
||||
|
||||
|
||||
def doc_keys(meta: dict[str, str]) -> dict[str, str]:
|
||||
return {k: v for k, v in meta.items() if k not in SERVICE_KEYS}
|
||||
|
||||
|
||||
def regions(body: str) -> dict[str, str]:
|
||||
"""Содержимое локальных регионов по имени.
|
||||
|
||||
Поднимает ValueError на разметке, из-за которой регион молча превратился
|
||||
бы в обычный текст и потерялся при pull.
|
||||
"""
|
||||
matched = len(REGION_RE.findall(body))
|
||||
if len(OPEN_RE.findall(body)) != matched or len(CLOSE_RE.findall(body)) != matched:
|
||||
raise ValueError("непарный или нераспознанный маркер локального региона")
|
||||
found: dict[str, str] = {}
|
||||
for match in REGION_RE.finditer(body):
|
||||
name = (match.group(1) or "").strip()
|
||||
content = match.group(2)
|
||||
if not name:
|
||||
if content.strip():
|
||||
raise ValueError(
|
||||
"безымянный локальный регион с содержимым — дай ему имя"
|
||||
)
|
||||
continue
|
||||
if name in found:
|
||||
raise ValueError(f"локальный регион '{name}' встречается дважды")
|
||||
found[name] = content
|
||||
return found
|
||||
|
||||
|
||||
def checked_regions(body: str, where: str) -> dict[str, str]:
|
||||
try:
|
||||
return regions(body)
|
||||
except ValueError as exc:
|
||||
die(f"{where}: {exc}")
|
||||
|
||||
|
||||
def blank_regions(body: str) -> str:
|
||||
"""Тело с опустошёнными локальными регионами — то, что сравнивается."""
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
raw = (match.group(1) or "").strip()
|
||||
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
|
||||
return f"{head}\n<!-- /local -->"
|
||||
|
||||
return REGION_RE.sub(repl, body)
|
||||
|
||||
|
||||
def fill_regions(body: str, values: dict[str, str]) -> tuple[str, list[str]]:
|
||||
"""Вставляет содержимое регионов по имени. Возвращает тело и имена,
|
||||
которым не нашлось места."""
|
||||
used: set[str] = set()
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
raw = (match.group(1) or "").strip()
|
||||
head = f"<!-- local:{raw} -->" if raw else "<!-- local -->"
|
||||
if raw in values:
|
||||
used.add(raw)
|
||||
return f"{head}{values[raw]}<!-- /local -->"
|
||||
return match.group(0)
|
||||
|
||||
filled = REGION_RE.sub(repl, body)
|
||||
lost = [n for n, v in values.items() if n not in used and v.strip()]
|
||||
return filled, lost
|
||||
|
||||
|
||||
def fingerprint(meta: dict[str, str], body: str) -> str:
|
||||
"""Отпечаток документа: ключи шапки плюс тело без локальных регионов."""
|
||||
head = "\n".join(f"{k}={v}" for k, v in sorted(doc_keys(meta).items()))
|
||||
return hashlib.sha256(f"{head}\n\n{blank_regions(body)}".encode(ENC)).hexdigest()[
|
||||
:8
|
||||
]
|
||||
|
||||
|
||||
def today() -> str:
|
||||
return datetime.date.today().isoformat()
|
||||
|
||||
|
||||
# --- канон и репозиторий ---------------------------------------------------
|
||||
|
||||
|
||||
def canon_list() -> list[str]:
|
||||
out: list[str] = []
|
||||
for tree in CANON_TREES:
|
||||
root = CANON / tree
|
||||
if root.is_dir():
|
||||
out += [str(p.relative_to(CANON)) for p in sorted(root.rglob("*.md"))]
|
||||
return out
|
||||
|
||||
|
||||
def canon_read(origin: str) -> tuple[dict[str, str], str]:
|
||||
path = CANON / origin
|
||||
if not path.is_file():
|
||||
die(f"в каноне нет {origin}")
|
||||
return split_front(read(path))
|
||||
|
||||
|
||||
def normalize_origin(name: str, *, must_exist: bool = True) -> str:
|
||||
"""Принимает 'arch/time.md', 'arch/time' и однозначный хвост вроде 'time'."""
|
||||
name = name.strip("/")
|
||||
if not name.endswith(".md"):
|
||||
name += ".md"
|
||||
candidate = (CANON / name).resolve()
|
||||
if candidate.is_relative_to(CANON):
|
||||
rel = str(candidate.relative_to(CANON))
|
||||
if rel.split("/")[0] in CANON_TREES and (not must_exist or candidate.is_file()):
|
||||
return rel
|
||||
matches = [c for c in canon_list() if c == name or c.endswith("/" + name)]
|
||||
if len(matches) == 1:
|
||||
return matches[0]
|
||||
if not matches:
|
||||
die(f"в каноне нет {name} (путь должен начинаться с {'/'.join(CANON_TREES)})")
|
||||
die(f"неоднозначно: {name} → {', '.join(matches)}")
|
||||
|
||||
|
||||
def repo_dir(args: argparse.Namespace) -> Path:
|
||||
return (Path(str(args.repo)) / str(args.dir)).resolve()
|
||||
|
||||
|
||||
def repo_copies(base: Path) -> tuple[dict[str, Path], list[Path], list[str]]:
|
||||
"""origin → копия; плюс .md без шапки и сообщения о нечитаемых файлах."""
|
||||
found: dict[str, Path] = {}
|
||||
untracked: list[Path] = []
|
||||
problems: list[str] = []
|
||||
if not base.is_dir():
|
||||
return found, untracked, problems
|
||||
for path in sorted(base.rglob("*.md")):
|
||||
try:
|
||||
meta, _ = split_front(read(path))
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
problems.append(f"{path.name}: не читается ({type(exc).__name__})")
|
||||
continue
|
||||
origin = meta.get("origin")
|
||||
if not origin:
|
||||
if path.name != "README.md":
|
||||
untracked.append(path)
|
||||
continue
|
||||
if origin in found:
|
||||
problems.append(
|
||||
f"{origin}: две копии ({found[origin]}, {path}) — вторая скрыта"
|
||||
)
|
||||
continue
|
||||
found[origin] = path
|
||||
return found, untracked, problems
|
||||
|
||||
|
||||
def locate(args: argparse.Namespace, origin: str) -> Path:
|
||||
"""Путь копии: по шапке, если она лежит не по канонному пути."""
|
||||
base = repo_dir(args)
|
||||
copies, _, _ = repo_copies(base)
|
||||
return copies.get(origin, base / origin)
|
||||
|
||||
|
||||
# --- состояние -------------------------------------------------------------
|
||||
|
||||
|
||||
def state(
|
||||
meta: dict[str, str], body: str, canon_meta: dict[str, str], canon_body: str
|
||||
) -> str:
|
||||
copy_fp = fingerprint(meta, body)
|
||||
canon_fp = fingerprint(canon_meta, canon_body)
|
||||
if copy_fp == canon_fp:
|
||||
return "ok"
|
||||
base = meta.get("origin_hash")
|
||||
if not base:
|
||||
return "нет origin_hash в шапке"
|
||||
if base == canon_fp:
|
||||
return "изменено локально"
|
||||
if base == copy_fp:
|
||||
return "канон обновился"
|
||||
return "разошлись"
|
||||
|
||||
|
||||
# --- команды ---------------------------------------------------------------
|
||||
|
||||
|
||||
def cmd_list(args: argparse.Namespace) -> int:
|
||||
for origin in canon_list():
|
||||
meta, _ = canon_read(origin)
|
||||
marks = []
|
||||
if "extends" in meta:
|
||||
marks.append(f"расширяет {meta['extends']}")
|
||||
if "status" in meta:
|
||||
marks.append(meta["status"])
|
||||
tail = f" ({'; '.join(marks)})" if marks else ""
|
||||
print(f"{origin}{tail}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_add(args: argparse.Namespace) -> int:
|
||||
base = repo_dir(args)
|
||||
added = False
|
||||
for raw in args.names:
|
||||
origin = normalize_origin(raw)
|
||||
target = base / origin
|
||||
if target.exists():
|
||||
print(f"{origin}: уже есть ({target}), пропускаю")
|
||||
continue
|
||||
canon_meta, canon_body = canon_read(origin)
|
||||
checked_regions(canon_body, f"канон/{origin}")
|
||||
meta: dict[str, str] = {
|
||||
"origin": origin,
|
||||
"origin_hash": fingerprint(canon_meta, canon_body),
|
||||
"synced": today(),
|
||||
"local": "нет",
|
||||
}
|
||||
meta.update(doc_keys(canon_meta))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
write(target, join_front(meta, canon_body))
|
||||
added = True
|
||||
print(f"{origin} → {target}")
|
||||
if "extends" in canon_meta:
|
||||
print(f" расширяет {canon_meta['extends']} — возможно, нужна и она")
|
||||
if added:
|
||||
print("не забудь строку в docs/conventions/README.md")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status(args: argparse.Namespace) -> int:
|
||||
base = repo_dir(args)
|
||||
copies, untracked, problems = repo_copies(base)
|
||||
if not copies and not untracked and not problems:
|
||||
print(f"в {base} нет копий конвенций")
|
||||
return 0
|
||||
width = max((len(o) for o in copies), default=0)
|
||||
for origin, path in copies.items():
|
||||
try:
|
||||
meta, body = split_front(read(path))
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"{origin:<{width}} не читается ({type(exc).__name__})")
|
||||
continue
|
||||
if not (CANON / origin).is_file():
|
||||
print(f"{origin:<{width}} нет в каноне")
|
||||
continue
|
||||
canon_meta, canon_body = canon_read(origin)
|
||||
try:
|
||||
regions(body)
|
||||
except ValueError as exc:
|
||||
print(f"{origin:<{width}} разметка: {exc}")
|
||||
continue
|
||||
local = meta.get("local", "нет")
|
||||
note = "" if local == "нет" else f" [{local}]"
|
||||
print(f"{origin:<{width}} {state(meta, body, canon_meta, canon_body)}{note}")
|
||||
for path in untracked:
|
||||
print(f"{path.name}: без шапки origin — не отслеживается")
|
||||
for problem in problems:
|
||||
print(problem)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_diff(args: argparse.Namespace) -> int:
|
||||
base = repo_dir(args)
|
||||
copies, _, _ = repo_copies(base)
|
||||
targets = [normalize_origin(args.name)] if args.name else list(copies)
|
||||
for origin in targets:
|
||||
path = copies.get(origin)
|
||||
if path is None:
|
||||
print(f"{origin}: нет копии в репозитории")
|
||||
continue
|
||||
if not (CANON / origin).is_file():
|
||||
print(f"{origin}: нет в каноне")
|
||||
continue
|
||||
meta, body = split_front(read(path))
|
||||
canon_meta, canon_body = canon_read(origin)
|
||||
if fingerprint(meta, body) == fingerprint(canon_meta, canon_body):
|
||||
continue
|
||||
sys.stdout.writelines(
|
||||
difflib.unified_diff(
|
||||
join_front(doc_keys(canon_meta), blank_regions(canon_body)).splitlines(
|
||||
keepends=True
|
||||
),
|
||||
join_front(doc_keys(meta), blank_regions(body)).splitlines(
|
||||
keepends=True
|
||||
),
|
||||
fromfile=f"канон/{origin}",
|
||||
tofile=f"репо/{origin}",
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_pull(args: argparse.Namespace) -> int:
|
||||
origin = normalize_origin(args.name)
|
||||
path = locate(args, origin)
|
||||
if not path.is_file():
|
||||
die(f"нет копии {origin} — сначала conv add {origin}")
|
||||
meta, body = split_front(read(path))
|
||||
canon_meta, canon_body = canon_read(origin)
|
||||
checked_regions(canon_body, f"канон/{origin}")
|
||||
local = checked_regions(body, f"репо/{origin}")
|
||||
st = state(meta, body, canon_meta, canon_body)
|
||||
if st == "ok":
|
||||
fresh = fingerprint(canon_meta, canon_body)
|
||||
if meta.get("origin_hash") != fresh:
|
||||
meta["origin_hash"] = fresh
|
||||
meta["synced"] = today()
|
||||
write(path, join_front(meta, body))
|
||||
print(f"{origin}: тексты совпадают, отпечаток освежён")
|
||||
else:
|
||||
print(f"{origin}: уже совпадает")
|
||||
return 0
|
||||
if st in ("изменено локально", "разошлись") and not args.force:
|
||||
die(
|
||||
f"{origin}: {st} — правки вне локальных регионов будут потеряны.\n"
|
||||
f" посмотри conv diff {origin}, затем conv pull --force "
|
||||
f"или conv push {origin}"
|
||||
)
|
||||
merged, lost = fill_regions(canon_body, local)
|
||||
if lost and not args.force:
|
||||
die(
|
||||
f"{origin}: в каноне нет регионов {', '.join(lost)} — их содержимое "
|
||||
f"пропадёт.\n перенеси вручную или conv pull --force"
|
||||
)
|
||||
for name in lost:
|
||||
print(f" потерян локальный регион {name}")
|
||||
new_meta = {k: meta[k] for k in SERVICE_KEYS if k in meta}
|
||||
new_meta["origin_hash"] = fingerprint(canon_meta, canon_body)
|
||||
new_meta["synced"] = today()
|
||||
new_meta.update(doc_keys(canon_meta))
|
||||
write(path, join_front(new_meta, merged))
|
||||
print(f"{origin}: обновлено из канона — перечитай глазами, регионы могли устареть")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_push(args: argparse.Namespace) -> int:
|
||||
origin = normalize_origin(args.name, must_exist=not args.new)
|
||||
path = locate(args, origin)
|
||||
if not path.is_file():
|
||||
die(f"нет копии {origin}")
|
||||
meta, body = split_front(read(path))
|
||||
checked_regions(body, f"репо/{origin}")
|
||||
target = CANON / origin
|
||||
if not target.is_file():
|
||||
if not args.new:
|
||||
die(f"в каноне нет {origin} — заведи новую конвенцию через conv push --new")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
write(target, join_front(doc_keys(meta), blank_regions(body)))
|
||||
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
|
||||
meta["synced"] = today()
|
||||
write(path, join_front(meta, body))
|
||||
print(f"{origin}: заведена в каноне")
|
||||
return 0
|
||||
canon_meta, canon_body = canon_read(origin)
|
||||
st = state(meta, body, canon_meta, canon_body)
|
||||
if st == "ok":
|
||||
print(f"{origin}: канон уже такой")
|
||||
return 0
|
||||
if st == "канон обновился":
|
||||
die(
|
||||
f"{origin}: копия не менялась, а канон ушёл вперёд — пушить нечего, нужен pull"
|
||||
)
|
||||
if st == "разошлись" and not args.force:
|
||||
die(
|
||||
f"{origin}: разошлись — канон менялся после синхронизации, "
|
||||
f"его правки затрутся.\n посмотри conv diff {origin}, "
|
||||
f"затем conv push --force"
|
||||
)
|
||||
write(target, join_front(doc_keys(meta), blank_regions(body)))
|
||||
meta["origin_hash"] = fingerprint(doc_keys(meta), blank_regions(body))
|
||||
meta["synced"] = today()
|
||||
write(path, join_front(meta, body))
|
||||
print(f"{origin}: канон обновлён из репозитория")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
common = argparse.ArgumentParser(add_help=False)
|
||||
common.add_argument("--repo", default=".", help="корень репозитория")
|
||||
common.add_argument("--dir", default=DEFAULT_DIR, help="где лежат конвенции")
|
||||
|
||||
parser = argparse.ArgumentParser(prog="conv", parents=[common], description=__doc__)
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
sub.add_parser("list", parents=[common], help="что есть в каноне").set_defaults(
|
||||
fn=cmd_list
|
||||
)
|
||||
|
||||
p_add = sub.add_parser(
|
||||
"add", parents=[common], help="взять конвенцию в репозиторий"
|
||||
)
|
||||
p_add.add_argument("names", nargs="+")
|
||||
p_add.set_defaults(fn=cmd_add)
|
||||
|
||||
sub.add_parser("status", parents=[common], help="состояние копий").set_defaults(
|
||||
fn=cmd_status
|
||||
)
|
||||
|
||||
p_diff = sub.add_parser(
|
||||
"diff", parents=[common], help="чем копия отличается от канона"
|
||||
)
|
||||
p_diff.add_argument("name", nargs="?")
|
||||
p_diff.set_defaults(fn=cmd_diff)
|
||||
|
||||
p_pull = sub.add_parser("pull", parents=[common], help="забрать обновление канона")
|
||||
p_pull.add_argument("name")
|
||||
p_pull.add_argument("--force", action="store_true")
|
||||
p_pull.set_defaults(fn=cmd_pull)
|
||||
|
||||
p_push = sub.add_parser("push", parents=[common], help="вернуть улучшение в канон")
|
||||
p_push.add_argument("name")
|
||||
p_push.add_argument("--force", action="store_true")
|
||||
p_push.add_argument("--new", action="store_true", help="завести новый файл канона")
|
||||
p_push.set_defaults(fn=cmd_push)
|
||||
|
||||
args = parser.parse_args()
|
||||
return int(args.fn(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/config.md
|
||||
---
|
||||
|
||||
# Конфигурация: реализация на Go
|
||||
|
||||
Как `arch/config.md` выглядит в Go-приложении.
|
||||
|
||||
## Формат и загрузчик
|
||||
|
||||
- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся
|
||||
готовая структура `Config`.
|
||||
- Одна корневая структура `Config` с под-структурами по секциям — имена
|
||||
структур совпадают с именами секций, чтобы конфиг и код читались рядом.
|
||||
- Умолчания — в `Default()`, поверх накладывается разобранный файл.
|
||||
- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в
|
||||
рабочей директории, образец — `config.example.toml`.
|
||||
|
||||
## Длительности
|
||||
|
||||
`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип
|
||||
с `UnmarshalText`, отдающий `time.Duration`:
|
||||
|
||||
```go
|
||||
type Duration time.Duration
|
||||
|
||||
func (d *Duration) UnmarshalText(b []byte) error { … }
|
||||
func (d Duration) Std() time.Duration { … }
|
||||
```
|
||||
|
||||
Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое
|
||||
число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей
|
||||
валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь
|
||||
отдельно и первой.
|
||||
|
||||
## Чтение окружения
|
||||
|
||||
Приложение не читает окружение для конфигурации. Механизируется
|
||||
`forbidigo`, и паттерн должен покрывать **все** входы, а не только
|
||||
`os.Getenv`:
|
||||
|
||||
```
|
||||
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
|
||||
```
|
||||
|
||||
Правило про приложение, поэтому за его границей запрет не действует:
|
||||
|
||||
- **тесты** — не приложение: интеграционному тесту нормально брать
|
||||
креды внешнего сервиса из окружения;
|
||||
- **переменные рантайма** — те, что читает не наш код, а Go или ОС
|
||||
(`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`).
|
||||
|
||||
Отдельный случай — переменные, которые читает **стандартная библиотека от
|
||||
имени приложения**: дефолтный `http.Transport` уважает
|
||||
`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это
|
||||
конфигурация поведения приложения, поэтому прокси задаётся полем конфига и
|
||||
явным `Transport`, а не окружением.
|
||||
|
||||
## Валидация
|
||||
|
||||
- Проверки собираются `errors.Join`, чтобы за один запуск показать **все**
|
||||
проблемы конфига, а не первую.
|
||||
- IANA-зона валидируется `time.LoadLocation`. База зон встраивается
|
||||
импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете:
|
||||
иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной
|
||||
базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие
|
||||
zoneinfo в контейнере.
|
||||
- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до
|
||||
старта серверов и воркеров.
|
||||
|
||||
## Секреты
|
||||
|
||||
Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их
|
||||
непустоты идёт вместе с остальной валидацией — см. базу.
|
||||
|
||||
<!-- local:поля -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `lang/go/time.md` — зона отображения и формат времени.
|
||||
- `lang/go/logging.md` — `slog`, которым падает невалидный конфиг.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/db-identifiers.md
|
||||
---
|
||||
|
||||
# Идентификаторы: реализация на Go
|
||||
|
||||
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID.
|
||||
|
||||
## Единая точка — `internal/ident`
|
||||
|
||||
- `ident.NewID()` — генерация. **PK сущности** генерируется в `Create`-методах
|
||||
слоя `store`. Прочие идентификаторы (батч, задание, корреляционный ключ)
|
||||
генерируются там, где начинается операция, — но тоже только через `ident`.
|
||||
- `ident.NewIDAt(t)` — генерация с заданным временем, для бэкфилла в
|
||||
Go-миграциях: сортировка id тогда сохраняет историческую хронологию, а не
|
||||
момент прогона миграции.
|
||||
- `ident.Parse()` — разбор и нормализация; зовётся на **входных границах**
|
||||
(HTTP-роут, форма, callback бота), до обращения к store.
|
||||
- Других генераторов и парсеров id в коде нет. Это то самое «единая точка»
|
||||
из базовой конвенции; без него нормализация регистра неизбежно
|
||||
где-нибудь пропускается.
|
||||
|
||||
## Типы
|
||||
|
||||
В структурах store и домена id — обычный `string`. Отдельный тип `ID`
|
||||
заводим, только если появится вторая семья идентификаторов, которую можно
|
||||
перепутать; до этого он даёт конверсии без выгоды. От перепутывания двух id
|
||||
одной семьи в сигнатуре он всё равно не спасает — там помогают имена
|
||||
параметров.
|
||||
|
||||
## Невалидный id на границе
|
||||
|
||||
Разбор не удался — дальше зависит от того, откуда id пришёл:
|
||||
|
||||
- **из пути или query URL** — сразу 404, без обращения к store и без
|
||||
фабрикации доменной ошибки: снаружи это неотличимо от несуществующей
|
||||
записи, и хорошо;
|
||||
- **из собственной формы или callback-данных кнопки** — 400 либо понятное
|
||||
сообщение («кнопка устарела»): это баг интерфейса или протухший экран, и
|
||||
под «не найдено» его маскировать нельзя.
|
||||
|
||||
Транспорт не создаёт доменные sentinel'ы, чтобы тут же их сматчить, — это
|
||||
инверсия правила «трансляция у источника» из `lang/go/errors.md`.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Схема и миграции (SQLite, Go)
|
||||
|
||||
Область действия — **новые миграции**. Существующая схема не переписывается;
|
||||
линтер проверяет то, что добавляется, а не то, что уже лежит.
|
||||
|
||||
## Миграции
|
||||
|
||||
- Инструмент — goose, файлы миграций лежат рядом со store-слоем.
|
||||
- **SQL-файл** для DDL: создание таблиц, индексы, изменение структуры.
|
||||
- **Go-миграция** (`goose.AddMigrationContext`) — когда нужен код:
|
||||
генерация идентификаторов, backfill, перенос данных между формами.
|
||||
Не пытаемся выразить это SQL-ом ради единообразия.
|
||||
- **В деплое движение только вперёд.** Down-миграция — инструмент
|
||||
разработки, а не отката на сервере.
|
||||
- **Down пишется, когда он честно обращает up**: убрать то, что up добавил.
|
||||
Не пишется, когда up необратимо трансформирует данные, — тогда его
|
||||
отсутствие честнее имитации, которая молча теряет колонку.
|
||||
- При изменении структуры ER-схема в спеках обновляется **в том же
|
||||
изменении**, а не «потом»: разошедшаяся схема хуже отсутствующей.
|
||||
|
||||
## Типы колонок
|
||||
|
||||
- **Enum-поля** (`state`, `kind`, …) — обычный `TEXT` **без `CHECK`**.
|
||||
Допустимые значения держит код. `ALTER TABLE` в SQLite не умеет менять
|
||||
ограничения ни в одной версии, поэтому каждое новое значение в
|
||||
`CHECK(... IN (...))` означает пересоздание таблицы по 12-шаговой
|
||||
процедуре; защита от невалидного значения всё равно нужна на уровне типов
|
||||
Go.
|
||||
- **Метки времени** — `TEXT` в формате из `arch/time.md`. Без
|
||||
`DEFAULT (datetime('now'))`: помимо того, что время ставит приложение,
|
||||
эта функция даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, то есть не
|
||||
тот формат.
|
||||
- **Булевы** — `INTEGER` 0/1. Отдельного типа в SQLite нет, а строка
|
||||
`'true'` в булевом контексте приводится к **0** — то есть тихо
|
||||
инвертирует смысл, а не просто ломает фильтрацию.
|
||||
- **Первичные ключи** — если репозиторий взял `arch/db-identifiers.md`, то
|
||||
по ней (без `AUTOINCREMENT`); иначе автоинкремент допустим.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
- `arch/time.md` — формат меток времени.
|
||||
- `arch/db-identifiers.md` — выбор первичных ключей.
|
||||
- `lang/go/errors.md` — граничные ошибки `database/sql` транслируются в
|
||||
доменные у источника, в слое store.
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Ошибки
|
||||
|
||||
Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку
|
||||
**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один
|
||||
раз на доменной границе).
|
||||
|
||||
## Базовая идиома: stdlib
|
||||
|
||||
- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`,
|
||||
а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка
|
||||
сообщений локализует место не хуже стека, а стек-трейсы и Sentry
|
||||
избыточны для домашнего сервиса.
|
||||
- Если отладка начнёт упираться в «где именно родилась ошибка» — это
|
||||
сигнал пересмотреть решение, а не дефолт, который можно обойти локально.
|
||||
- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет
|
||||
вовсе (см. «panic»).
|
||||
|
||||
## Обёртка и контекст
|
||||
|
||||
Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код
|
||||
наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью
|
||||
API») относится к библиотекам, поэтому внутри приложения обёртка `%w` —
|
||||
**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои.
|
||||
|
||||
- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`.
|
||||
- `%w` — когда вызывающий может инспектировать причину (обычный случай).
|
||||
`%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать
|
||||
вызывающего на чужой тип ошибки.
|
||||
- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в
|
||||
цепочке, а трансляцией на внешней границе (ниже).
|
||||
|
||||
Стиль сообщения:
|
||||
|
||||
- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка
|
||||
и так читается как «контекст: причина»;
|
||||
- контекст — операция или субъект: `"link target: %w"`, не
|
||||
`"something failed"`;
|
||||
- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний
|
||||
(`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`).
|
||||
|
||||
## Две трансляции
|
||||
|
||||
Ошибка меняет форму дважды, и это разные преобразования.
|
||||
|
||||
**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки
|
||||
зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный
|
||||
`store.ErrNotFound` в слое store, чтобы выше по коду не торчал
|
||||
`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK.
|
||||
|
||||
**Вторая — на внешней границе, доменная → пользовательская.** Описана
|
||||
ниже, в разделе про каналы.
|
||||
|
||||
## Sentinel vs типизированные
|
||||
|
||||
- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий,
|
||||
на которые ветвится код: нет записи, дубликат, неподдерживаемый источник.
|
||||
Проверяем `errors.Is`.
|
||||
- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда
|
||||
вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём
|
||||
`errors.As`. Не плодим типы там, где хватает sentinel.
|
||||
- Матчинг по тексту сообщения запрещён — это то же самое, что публичный
|
||||
API из строки лога.
|
||||
|
||||
## Граница: приватный канал vs публичный
|
||||
|
||||
Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от
|
||||
того, кто канал видит.
|
||||
|
||||
**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей
|
||||
цепочкой `%w` и контекстом. Пишется один раз на доменной границе.
|
||||
|
||||
**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот).
|
||||
Сюда отдаём:
|
||||
|
||||
- **человекочитаемое сообщение** по доменной ошибке — не сырой
|
||||
`err.Error()` и не детали реализации (`database/sql`, пути, стек);
|
||||
- **корреляционный ключ** для владельца — id сущности либо `request_id`,
|
||||
чтобы по нему найти полную ошибку в логах. «При обработке загрузки
|
||||
произошла ошибка, download_id=…» вместо «произошла ошибка»;
|
||||
- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной
|
||||
точке на все транспорты. У транспортов без статусов (бот) от маппинга
|
||||
берётся только сообщение.
|
||||
|
||||
Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и
|
||||
**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя
|
||||
ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR`
|
||||
вместо `DEBUG`.
|
||||
|
||||
<!-- local:маппинг -->
|
||||
<!-- /local -->
|
||||
|
||||
### Транзиентный ответ vs персистентная диагностика
|
||||
|
||||
У публичной границы две разные поверхности, и правило сырого текста для них
|
||||
разное:
|
||||
|
||||
- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по
|
||||
результату команды) — строго нейтральный: маппинг выше, `err.Error()`
|
||||
наружу не идёт, полная ошибка живёт в логах по корреляционному ключу.
|
||||
- **Персистентная диагностика состояния** — причина ухода записи в
|
||||
ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь
|
||||
сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и
|
||||
полезен — **но только пока поверхность видит исключительно владелец**.
|
||||
Появился второй зритель или публичный доступ к экрану состояния —
|
||||
поверхность стала публичным каналом, и правило нейтрального текста
|
||||
распространяется на неё. Секреты запрещены абсолютно в обоих случаях;
|
||||
источник вычищается на границе клиента.
|
||||
|
||||
Различие работает, только если поверхности не смешиваются в одном поле.
|
||||
Диагностику кладём в **отдельное поле**, а не в доменное.
|
||||
|
||||
## panic
|
||||
|
||||
- `panic` — только для невосстановимого: нарушенный инвариант (баг
|
||||
программиста), ошибка инициализации, из которой нельзя стартовать.
|
||||
- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой
|
||||
ввод) — это значения `error`.
|
||||
- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не
|
||||
только у HTTP:
|
||||
- HTTP middleware — `net/http` сам восстанавливает панику в хендлере и
|
||||
процесс не роняет, поэтому смысл своего `recover` в другом: отдать
|
||||
контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер;
|
||||
- цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в
|
||||
горутине **действительно роняет процесс**, и `recover` обязателен.
|
||||
`recover` работает только в той горутине, где случилась паника.
|
||||
- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное
|
||||
место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и
|
||||
без стека «index out of range» не диагностируется вообще.
|
||||
|
||||
## Несколько ошибок
|
||||
|
||||
Сбор независимых ошибок (валидация конфига — все проблемы разом) —
|
||||
`errors.Join`; проверка собранного по-прежнему через `errors.Is`.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,242 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
# Логирование
|
||||
|
||||
Как и когда писать логи. Это правила оформления кода (How), а не
|
||||
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
|
||||
функциональности, живут в спеках.
|
||||
|
||||
## Принципы
|
||||
|
||||
- Структурированный JSON (`slog.JSONHandler`), **один формат для dev и
|
||||
prod**. Не потому, что текстовый вывод «расходит поля» — смена хендлера
|
||||
структуру атрибутов не меняет; а потому, что с текстовым dev-выводом
|
||||
перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки
|
||||
словаря замечаются только в проде.
|
||||
- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле —
|
||||
отдельный ключ с типизированным значением: это даёт фильтрацию и
|
||||
агрегацию через `jq`/DuckDB без регулярок.
|
||||
|
||||
```json
|
||||
{"time":"2026-06-28T11:23:45.123Z","level":"INFO","msg":"download accepted","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","media_type":"movie"}
|
||||
```
|
||||
|
||||
## Время в записи
|
||||
|
||||
Поле `time` ставит `slog`, но **UTC он по умолчанию не даёт**: встроенные
|
||||
хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне
|
||||
процесса. UTC ставится `ReplaceAttr` по `slog.TimeKey` — см.
|
||||
`lang/go/time.md`. Точность `JSONHandler` — миллисекунды, фиксированная
|
||||
ширина; это другая точность, чем в БД, и по `arch/time.md` так и должно
|
||||
быть: ширина фиксируется на носитель.
|
||||
|
||||
## Сообщение
|
||||
|
||||
- `msg` — короткая **константа** в нижнем регистре: `download accepted`,
|
||||
`recognition done`, `layout failed`. Данные — в атрибутах:
|
||||
`log.Info("download accepted", "download_id", id)`.
|
||||
- `msg` — чистая категория **без неймспейс-префикса**: `recognition done`,
|
||||
а не `recognize: done`. Подсистема — отдельное поле, не текст.
|
||||
- **Смена состояния сущности — единая категория** (`state transition`) с
|
||||
полями `from`/`to`/`code`. Какое именно состояние и по какой причине —
|
||||
это данные, а не текст. Тогда весь жизненный цикл собирается одним
|
||||
фильтром. Физический эффект сверх перехода — отдельная запись своей
|
||||
категории, она не подменяет запись перехода.
|
||||
|
||||
## Уровни
|
||||
|
||||
Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько
|
||||
громко сломалось».
|
||||
|
||||
| Уровень | Кому и когда |
|
||||
|---|---|
|
||||
| `DEBUG` | разработчику при отладке; в проде выключен |
|
||||
| `INFO` | владельцу, аудит постфактум |
|
||||
| `WARN` | владельцу, «может стать проблемой» |
|
||||
| `ERROR` | владельцу, в разбор |
|
||||
|
||||
Правила:
|
||||
|
||||
- Уровень **не зависит от подсистемы**: `ERROR` везде одинаково серьёзен.
|
||||
- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это
|
||||
не «может» — это `INFO`.
|
||||
- Меняется адресат — меняется уровень. Невалидный ввод от пользователя —
|
||||
`DEBUG` (норма, разбирать нечего), а не `ERROR`.
|
||||
- **Событийное → `INFO`, рутинно-частое → `DEBUG`.** Операция по реальному
|
||||
действию или изменению — `INFO`. Повторяющаяся служебная операция,
|
||||
запускаемая таймером или поллингом и сама по себе не несущая события
|
||||
(healthcheck, опрос статуса, авто-рефреш UI), — `DEBUG`: на `INFO` она
|
||||
зашумляет аудит.
|
||||
- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем
|
||||
`ERROR` и завершаем процесс с ненулевым кодом.
|
||||
|
||||
## Поля: единый словарь
|
||||
|
||||
Главное условие — **одно поле, одно имя по всему коду** (не
|
||||
`mediaType`/`media`/`media_type` вперемешку).
|
||||
|
||||
- Бизнес-поля — плоский `snake_case`.
|
||||
- Системные домены — точечная иерархия (адаптация 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.*` не заводим — для одного бинаря на одном хосте это
|
||||
шум. Если появятся несколько инстансов, добавим `service.version` одной
|
||||
строкой при старте.
|
||||
|
||||
<!-- local:словарь -->
|
||||
<!-- /local -->
|
||||
|
||||
## Корреляция по id сущности
|
||||
|
||||
Отдельный случайный `trace_id` не заводим, **если у сущностей есть
|
||||
стабильные уникальные идентификаторы** — они и служат ключом корреляции.
|
||||
(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.)
|
||||
|
||||
- Каждая запись, относящаяся к сущности, несёт её id в поле `<entity>_id`.
|
||||
Для долгой операции — scoped-логгер, протаскиваемый через
|
||||
`context.Context` сквозь асинхронные стадии, чтобы ключ дописывался сам:
|
||||
|
||||
```go
|
||||
log := log.With("download_id", id)
|
||||
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
|
||||
```
|
||||
|
||||
- Все записи одной операции собираются одним фильтром:
|
||||
`jq 'select(.download_id=="01jz…")' app.jsonl`.
|
||||
- Если id глобально уникален across сущностей, штатно работает и простой
|
||||
`grep` по голому id — он находит все упоминания независимо от имени поля.
|
||||
|
||||
## Ошибки
|
||||
|
||||
Go-ошибки логируем **атрибутом**, не текстом сообщения:
|
||||
`log.Error("layout failed", "error", err, "download_id", id)`. Ключ —
|
||||
`error` (как по умолчанию в zap/zerolog: единый ключ важнее краткости).
|
||||
|
||||
- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только
|
||||
оборачивают и возвращают (`%w`), не логируя: контекст накапливается в
|
||||
цепочке.
|
||||
- Логируем ошибку **один раз — на границе доменного слоя**, которая
|
||||
определяет исход операции. Логирует этот единый чокпоинт, а не каждый
|
||||
транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей.
|
||||
|
||||
<!-- local:границы -->
|
||||
<!-- /local -->
|
||||
|
||||
- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение
|
||||
пользователю) и **не логируют** её повторно.
|
||||
- **Уровень доменного отказа — по адресату, а не по месту.** У каждой
|
||||
доменной ошибки ровно один логирующий; уровень выбирает он:
|
||||
|
||||
| Класс отказа | Кому | Уровень |
|
||||
|---|---|---|
|
||||
| штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
|
||||
| расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
|
||||
| сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
|
||||
|
||||
- Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт)
|
||||
адресован уже владельцу как деградация автоматики — уровень поднимается.
|
||||
Коллизия в ручном действии — `DEBUG` (человек видит причину на экране), в
|
||||
авто-обработке — `WARN` (автоматика не довела задачу).
|
||||
- **Повторяющийся сбой фонового цикла — `WARN`, не `ERROR`.** Одиночный
|
||||
промах тика транзиентен: следующий тик повторит. Тот же класс сбоя внутри
|
||||
синхронной операции — `ERROR`, потому что операция провалилась целиком и
|
||||
повтора нет. Уровень задаёт не текст ошибки, а **наличие штатного
|
||||
повтора**.
|
||||
|
||||
## Два цикла повтора — не путать
|
||||
|
||||
Слово «ретрай» означает два разных механизма, и уровень считается по
|
||||
каждому отдельно:
|
||||
|
||||
- **Повтор вызова внутри одной операции** (ретраи HTTP-клиента) — по нему
|
||||
выбирается уровень **`ext`-записи**: `WARN` на попытку, `ERROR` когда
|
||||
попытки исчерпаны.
|
||||
- **Повтор тика внешним циклом** (поллинг, сверка) — по нему выбирается
|
||||
уровень **доменной записи** об исходе тика: `WARN`, потому что следующий
|
||||
тик повторит.
|
||||
|
||||
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
|
||||
каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а
|
||||
телеметрия зависимости честно показывает, что она недоступна. Если поток
|
||||
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
|
||||
подавлением повторов в самом клиенте, а не переклассификацией уровня.
|
||||
|
||||
## Внешние сервисы: логируем все вызовы
|
||||
|
||||
**Каждый** вызов внешнего сервиса логируется — это единственный способ
|
||||
отличить «у нас баг» от «зависимость легла». Поля: `ext.service`,
|
||||
`ext.operation` (логическая операция, не URL), `ext.status_code`,
|
||||
`duration_ms`, `retry`.
|
||||
|
||||
Уровни:
|
||||
|
||||
- `INFO` — успешный **событийный** вызов;
|
||||
- `DEBUG` — успешный **рутинно-частый** вызов (поллинг, авто-рефреш);
|
||||
- `WARN` — попытка не удалась, делаем retry;
|
||||
- `ERROR` — ретраи исчерпаны, сервис недоступен.
|
||||
|
||||
Завершённый HTTP-ответ с 4xx — это **успех на транспортном уровне**
|
||||
(`ext.status_code` записан); решение «это ошибка» принимает доменный
|
||||
вызывающий. Тело запроса и ответа — только на `DEBUG` и после вычистки
|
||||
секретов.
|
||||
|
||||
## HTTP и healthcheck
|
||||
|
||||
- Входящие запросы логируем с `http.*` и `duration_ms` на **`INFO`**: это
|
||||
аудит обращений, а не отладка. Уровень не понижается из-за кода ответа —
|
||||
4xx остаётся `INFO`-записью доступа; решение «это ошибка» принимает
|
||||
доменный слой и пишет свою запись.
|
||||
- Для корреляции запроса допустим `request_id` — это отдельный слой от
|
||||
корреляции по сущности и не противоречит отказу от `trace_id`.
|
||||
- **Healthcheck, liveness, readiness — `DEBUG`.** Их дёргают периодически,
|
||||
на `INFO` они забивают аудит; в проде с базовым `INFO` они не пишутся.
|
||||
|
||||
## Безопасность: что не логируем
|
||||
|
||||
Никаких секретов в полях и сообщениях: пароли и cookie сессий, API-ключи и
|
||||
токены, `Authorization`-заголовки, аутентификационные параметры в ссылках.
|
||||
|
||||
- Тела ответов внешних API и сырой вывод LLM (недоверенный, может быть
|
||||
большим) — только на `DEBUG`, с вычисткой и обрезкой по длине.
|
||||
- При сомнении — не логируем значение, логируем факт его наличия
|
||||
(`"has_api_key", true`).
|
||||
- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.**
|
||||
`*url.Error` встраивает полный URL запроса, а секрет может жить прямо в
|
||||
нём: токен в пути, `api_key` в query. Go редактирует только пароль из
|
||||
userinfo, остального не трогает. Санитизируем на границе клиента **до**
|
||||
лога и обёртки: разворачиваем `*url.Error` в первопричину. Цена —
|
||||
теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на причину
|
||||
сохраняется); альтернатива с редактированием URL сохранила бы структуру,
|
||||
но сложнее. Порядок важен: санитизация идёт **раньше** трансляции ошибки
|
||||
в доменную (`lang/go/errors.md`), иначе секрет уедет в обёртку.
|
||||
- Общее правило: **секрет не кладём в URL, если у API есть заголовок** —
|
||||
тогда его нет и в ошибке транспорта.
|
||||
|
||||
<!-- local:секреты -->
|
||||
<!-- /local -->
|
||||
|
||||
## Куда пишем
|
||||
|
||||
- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker,
|
||||
journald). По файлам не маршрутизируем.
|
||||
- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev —
|
||||
`DEBUG`.
|
||||
|
||||
## Анализ
|
||||
|
||||
- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`.
|
||||
- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/time.md
|
||||
---
|
||||
|
||||
# Время: реализация на Go
|
||||
|
||||
## Единая точка
|
||||
|
||||
- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по
|
||||
коду. Ценность точки — **гарантированный UTC и один формат**: `Now()`
|
||||
возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом
|
||||
забыть. Побочно это единственное место, которое придётся превратить в
|
||||
переменную или поле, если однажды понадобится подменять часы в тестах, —
|
||||
но само по себе оно тестируемости не даёт.
|
||||
- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх
|
||||
`time.RFC3339`.
|
||||
- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений
|
||||
ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит
|
||||
сама себе: сама точка `Now()` и обёртка измерения длительности (ниже).
|
||||
|
||||
## Точность и разбор
|
||||
|
||||
- В БД — **секундная точность**, ширина 20 символов
|
||||
(`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не
|
||||
содержит долей секунды, поэтому `Format` их не выведет.
|
||||
- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает
|
||||
фиксированную ширину.
|
||||
- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть
|
||||
канонический вид гарантирует **писатель**, а не читатель. Для одного
|
||||
писателя этого достаточно; чужой вход нормализуем явно.
|
||||
- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка —
|
||||
`TEXT`, и промежуточное преобразование драйвером нам не нужно.
|
||||
|
||||
## Логи
|
||||
|
||||
`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне
|
||||
самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке
|
||||
разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по
|
||||
`slog.TimeKey`:
|
||||
|
||||
```go
|
||||
func utcTime(_ []string, a slog.Attr) slog.Attr {
|
||||
if a.Key == slog.TimeKey {
|
||||
a.Value = slog.TimeValue(a.Value.Time().UTC())
|
||||
}
|
||||
return a
|
||||
}
|
||||
```
|
||||
|
||||
`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это
|
||||
другая точность, чем в БД, и это нормально: ширина фиксируется на носитель
|
||||
(см. базу).
|
||||
|
||||
## Длительность
|
||||
|
||||
Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и
|
||||
без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а
|
||||
это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный
|
||||
по таким меткам, зависит от подводки часов. Поэтому обёртка берёт
|
||||
`time.Now()` напрямую и считает `time.Since` — с локальным `//nolint`.
|
||||
|
||||
## Зоны
|
||||
|
||||
`time/tzdata` импортируется в `main`, зона отображения валидируется
|
||||
загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в
|
||||
шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону
|
||||
явно, как описано в базе.
|
||||
|
||||
<!-- local:механизировано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
extends: arch/app-directories.md
|
||||
---
|
||||
|
||||
# Категории директорий: реализация в Ansible
|
||||
|
||||
Как `arch/app-directories.md` раскладывается на сервере плейбуком.
|
||||
|
||||
## Переменные и создание
|
||||
|
||||
- Директория объявляется переменной плейбука внутри `base_dir`, имя
|
||||
переменной оканчивается на `_dir`. Для случая «одна директория на
|
||||
категорию» это `config_dir`, `data_dir`, `cache_dir`; когда категория
|
||||
состоит из нескольких, имя даётся по содержимому (`media_dir`,
|
||||
`uploads_dir`, `dumps_dir`), а категория читается из списка бэкапа.
|
||||
- Директории создаются **одной задачей циклом по списку**: список и есть
|
||||
декларация того, что приложение пишет на диск. Разнесение по нескольким
|
||||
задачам прячет эту декларацию.
|
||||
- Владелец — пользователь, от имени которого работает приложение. Модель
|
||||
выбирается на репозиторий: выделенный пользователь на приложение
|
||||
(`app_owner_uid == app_owner_gid`) или общий `primary_user`. Какая модель
|
||||
принята — фиксируется ниже.
|
||||
|
||||
<!-- local:модель-владельца -->
|
||||
<!-- /local -->
|
||||
|
||||
## Список бэкапа
|
||||
|
||||
Плейбук кладёт в `base_dir` файл `backup-targets` — его читает оркестратор
|
||||
бэкапов. Строки списка собираются из **тех же** переменных `*_dir`, что и
|
||||
задача создания директорий: тогда переименование или перенос директории не
|
||||
может разойтись с бэкапом.
|
||||
|
||||
В список идут директории категории «данные», включая директорию дампов, и
|
||||
не идут конфигурация и кеш.
|
||||
|
||||
## Монтирование в контейнер
|
||||
|
||||
- Конфигурация — `:ro`, где приложение это позволяет. Приложение, которое
|
||||
переписывает свой конфиг, монтируется на запись — это отступление, и оно
|
||||
записывается.
|
||||
- Данные и кеш — на запись.
|
||||
- `docker-compose.yml` остаётся в корне `base_dir`: туда смотрит
|
||||
`project_src` модуля `docker_compose_v2`.
|
||||
|
||||
## Секреты
|
||||
|
||||
Секреты приходят из vault-переменных и рендерятся шаблоном. Два способа, в
|
||||
порядке предпочтения:
|
||||
|
||||
1. **В файл конфигурации** (роль `secrets`) — предпочтительный: секрет
|
||||
лежит под `0600` у пользователя приложения, не наследуется дочерними
|
||||
процессами и не виден в `docker inspect`.
|
||||
2. **В `environment:` compose-файла** — когда приложение не умеет читать
|
||||
секреты из файла. Задача рендера идёт с `no_log: true`.
|
||||
|
||||
Второй способ — вынужденный: он кладёт секрет в метаданные контейнера и в
|
||||
файл compose на диске. Приложение, умеющее файловые секреты, переводится на
|
||||
первый способ при ближайшем касании.
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
|
||||
## Связано
|
||||
|
||||
<!-- local:связано -->
|
||||
<!-- /local -->
|
||||
@@ -0,0 +1,211 @@
|
||||
---
|
||||
status: рекомендуемая
|
||||
---
|
||||
|
||||
# Веб-UI на htmx
|
||||
|
||||
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
|
||||
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
|
||||
показывает и какие действия обязан поддерживать — в спеках, не здесь.
|
||||
|
||||
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
|
||||
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
|
||||
(приватный канал = логи, публичный = сообщение плюс корреляционный ключ).
|
||||
Здесь — только специфика htmx-транспорта, без дублирования.
|
||||
|
||||
Утверждения о поведении htmx относятся к **2.x**: дефолты обработки
|
||||
ответов между мажорами менялись.
|
||||
|
||||
## Стек и границы
|
||||
|
||||
htmx-first: роутер + серверные шаблоны + htmx. **Без шага сборки, без Node
|
||||
и бандлера, без реактивных фреймворков.** htmx вендорится и самохостится,
|
||||
без CDN.
|
||||
|
||||
- Свой JS сведён к минимуму: только то, что серверу знать не нужно
|
||||
(например, копирование в буфер обмена). **Клиентского пересчёта доменного
|
||||
состояния нет** — состояние считает сервер, клиент свопит присланную
|
||||
разметку.
|
||||
- Реактивный слой (Alpine.js и подобное) не вводим до появления виджета,
|
||||
которому он действительно нужен, и вводим отдельным решением, а не
|
||||
попутно.
|
||||
|
||||
## Единый источник разметки: партиал = страница = фрагмент
|
||||
|
||||
Переиспользуемый кусок — это именованный шаблон в `partials/`. Тот же
|
||||
шаблон рендерится **и** инлайн на странице, **и** как ответ-фрагмент того
|
||||
же обработчика. Отдельной разметки для фрагмента не заводим — иначе она
|
||||
дрейфует от страницы.
|
||||
|
||||
**Инвариант: корень шаблона — элемент с целевым `id`.**
|
||||
`hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не
|
||||
несёт тот же корневой `id`, следующее действие или поллер не найдёт таргет.
|
||||
Разметку и `id` держим в одном партиале.
|
||||
|
||||
Сборку view выносим в переиспользуемую функцию и зовём её и на полной
|
||||
странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку.
|
||||
|
||||
## Обработчик действия: ветвление htmx / редирект
|
||||
|
||||
htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик зовёт
|
||||
доменную операцию **одинаково** в обеих ветках и ветвится только после:
|
||||
|
||||
```go
|
||||
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
|
||||
|
||||
if !isHTMX(r) {
|
||||
redirect(w, r, id, msg) // без htmx — обычный PRG-редирект (303)
|
||||
return
|
||||
}
|
||||
data, _ := s.deps.Read(r.Context(), id) // перечитать актуальное состояние
|
||||
view := buildView(id, data, "") // тем же view-builder'ом
|
||||
if actionErr != nil {
|
||||
view.BlockError = userErr(r, actionErr, id)
|
||||
}
|
||||
s.render(w, "source_block", view) // фрагмент = тот же шаблон
|
||||
```
|
||||
|
||||
`render` собирает именованный шаблон **в буфер** и только затем пишет
|
||||
ответ — при ошибке шаблона клиент не получит «полустраницу».
|
||||
|
||||
## Одно действие — два региона: `hx-swap-oob`
|
||||
|
||||
Когда действие меняет не только свой регион (сменился выбор — обновилась и
|
||||
панель действий), второй регион едет **тем же ответом** через
|
||||
`hx-swap-oob="true"`. Оба фрагмента — обычные именованные партиалы с теми
|
||||
же `id`, что и на странице; отдельной разметки под oob не заводим по тому
|
||||
же правилу, что и для основного свопа.
|
||||
|
||||
Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами
|
||||
и лишний раунд-трип; `HX-Trigger` с последующим `hx-get` уместен только
|
||||
если второй регион обновляется реже, чем происходит действие.
|
||||
|
||||
## Graceful degradation
|
||||
|
||||
Формы действий остаются обычными `<form method="post" action="…">`;
|
||||
`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же
|
||||
форму. Без JS действие работает через POST и редирект. `action` формы —
|
||||
рабочий фолбэк, а не декорация.
|
||||
|
||||
Фильтр, поиск и пагинация списка — **серверные**, через GET-параметры, тоже
|
||||
без JS. Клиентской фильтрации нет намеренно.
|
||||
|
||||
Требование распространяется на **действия и навигацию**. Интерактивный
|
||||
виджет выбора, у которого нет осмысленного не-JS поведения, может требовать
|
||||
JS — но это отступление, и оно записывается, а не подразумевается.
|
||||
|
||||
## Ошибки на htmx-пути: HTTP 200 плюс фрагмент
|
||||
|
||||
В htmx 2.x ответы 4xx/5xx по умолчанию **не свопят DOM**. Это настраивается
|
||||
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
|
||||
`htmx:responseError`), но любая настройка — это свой JS-конфиг на клиенте,
|
||||
что противоречит разделу «Стек и границы». Поэтому сознательно берём
|
||||
**200 с фрагментом**, несущим сообщение, и доменную ошибку на htmx-пути
|
||||
**не** транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа
|
||||
с `?err=`.
|
||||
|
||||
- Сообщение — нейтральный текст публичного канала; сырой `err.Error()`
|
||||
наружу не идёт.
|
||||
- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая
|
||||
доменные поля: у них может быть своё непустое значение, которое сообщение
|
||||
перекроет.
|
||||
- **При ошибке активное состояние не меняем** — перечитанный view
|
||||
показывает прежний выбор плюс сообщение.
|
||||
- Цена: в логе доступа провалившееся действие выглядит как `200`. Искать
|
||||
его надо по доменной записи об исходе операции (`lang/go/logging.md`), а
|
||||
не по коду ответа.
|
||||
|
||||
## Живой поллинг
|
||||
|
||||
Фрагмент-эндпоинт под `/fragments/…` плюс в разметке `hx-get`,
|
||||
`hx-trigger="every Ns"`, `hx-swap="outerHTML"`:
|
||||
|
||||
```html
|
||||
{{define "progress"}}<div id="item-live-{{.ID}}"
|
||||
{{if .Active}} hx-get="/fragments/items/{{.ID}}/progress"
|
||||
hx-trigger="every 3s" hx-swap="outerHTML"{{end}}>
|
||||
...
|
||||
</div>{{end}}
|
||||
```
|
||||
|
||||
- **Поллер самозавершается.** Когда состояние выходит из «живого»,
|
||||
фрагмент возвращается **без `hx-*`** — htmx больше не опрашивает. Условие
|
||||
живости ведёт собственное состояние приложения, а не внешний сервис.
|
||||
(Встроенная альтернатива — ответ со статусом 286 — не используется: она
|
||||
не совместима с инвариантом «партиал = страница», свежезагруженная
|
||||
страница тоже должна рендериться без поллера.)
|
||||
- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его
|
||||
поллером и инициализирует новый — двойного опроса нет **при условии
|
||||
совпадения корневого `id`**.
|
||||
- **Поллер не свопит контейнер с активными полями ввода.** Своп поддерева
|
||||
теряет фокус, выделение и незасабмиченный текст внутри него: живость
|
||||
включается только в состояниях, где редактировать нечего.
|
||||
- **Инвариант: браузер не опрашивает внешний сервис напрямую** — только
|
||||
свой сервер.
|
||||
- Если тик **проксирует состояние внешнего сервиса**, данные берутся из
|
||||
in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт
|
||||
снимка узкий и не зависит от способа доставки (путь к SSE остаётся
|
||||
изолированным). Тик, показывающий **собственное** состояние приложения,
|
||||
читает своё хранилище — это нормально и снимка не требует.
|
||||
|
||||
### Поллинг полной страницы
|
||||
|
||||
Когда живой фрагмент — это почти вся страница, отдельный
|
||||
`/fragments/…`-роут дублировал бы обработчик. Тогда допустимо опрашивать
|
||||
сам URL страницы и вырезать нужный узел на клиенте:
|
||||
|
||||
```html
|
||||
hx-get="/item/{{.ID}}" hx-trigger="every 3s"
|
||||
hx-select="#item-main" hx-swap="outerHTML"
|
||||
```
|
||||
|
||||
Инвариант корневого `id` действует и здесь: `hx-select` должен выбирать тот
|
||||
же узел, который свопится.
|
||||
|
||||
## Своп сохраняет контекст; выход — навигация
|
||||
|
||||
`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные
|
||||
фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева
|
||||
контекст **не** сохраняется — фокус, выделение и введённый текст теряются
|
||||
(см. правило про поллер выше).
|
||||
|
||||
Действие **не должно уводить** пользователя со страницы, если предмет
|
||||
остаётся на ней — своп на месте. Действие, после которого предмет
|
||||
**покидает** страницу, остаётся обычной POST-формой **без `hx-*`** → полная
|
||||
навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен
|
||||
`HX-Redirect`, а «уйти с экрана» выражено самой навигацией.
|
||||
|
||||
**Асинхронные действия.** Если доменное действие асинхронно (переводит в
|
||||
промежуточное состояние, работу доделывает воркер), своп отдаёт
|
||||
**промежуточное** состояние, а не мнимый результат; итог догоняет
|
||||
самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции.
|
||||
|
||||
## Различение поверхности одного действия
|
||||
|
||||
Если один роут зовут с разных страниц и своп-ответ должен быть разным
|
||||
фрагментом, различаем **явным скрытым полем формы** (`surface=list|detail`),
|
||||
а не эвристикой по `HX-Target` или `Referer`: поле самодокументируемо и не
|
||||
зависит от резолва таргета.
|
||||
|
||||
## Статика, вендоринг, кэш
|
||||
|
||||
Раздел не про htmx — это упаковка любого server-rendered приложения;
|
||||
разъедется в языковой слой, когда понадобится там.
|
||||
|
||||
- Ассеты встроены в бинарь (`go:embed`) и отдаются с длинным иммутабельным
|
||||
кэшем (`Cache-Control: public, max-age=31536000, immutable`).
|
||||
- Меняемые ассеты (css/js) версионируются через `?v=<hash>` — короткий
|
||||
sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт
|
||||
устаревший файл.
|
||||
- Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается.
|
||||
В git его не коммитим: идемпотентная задача добывает его по манифесту
|
||||
(`путь url sha256`) с проверкой контрольной суммы, и сборка от неё
|
||||
зависит.
|
||||
- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь
|
||||
самодостаточен, внешних ресурсов времени выполнения нет.
|
||||
|
||||
<!-- local:эталоны -->
|
||||
<!-- /local -->
|
||||
|
||||
<!-- local:отступления -->
|
||||
<!-- /local -->
|
||||
Reference in New Issue
Block a user