diff --git a/conventions/arch/app-directories.md b/conventions/arch/app-directories.md index d1dd6ef..89c13a6 100644 --- a/conventions/arch/app-directories.md +++ b/conventions/arch/app-directories.md @@ -1,3 +1,7 @@ +--- +prefix: DIRS +--- + # Категории директорий приложения Всё, что приложение пишет на диск, делится на три категории по принципу @@ -16,32 +20,32 @@ ## Правила -### R1. Записываемые пути разложены по трём категориям +### DIRS-1. Записываемые пути разложены по трём категориям **ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой, относится к одной из трёх категорий: | № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе | |---|---|---|---|---|---| -| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет | -| R1.2 | данные | `data/` | приложение | невосполнима | да | -| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет | +| DIRS-1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет | +| DIRS-1.2 | данные | `data/` | приложение | невосполнима | да | +| DIRS-1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет | Имена в таблице — умолчание для случая «одна директория на категорию». -**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно +**Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно снести при нехватке места, что переживает переезд на другой диск — читаются из категории, а не выясняются по коду приложения. Без единой классификации каждое такое решение принимается заново и каждый раз чуть по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места, потерянные данные не стоят ничего, потому что их больше нет. -### R2. Категория может состоять из нескольких директорий +### DIRS-2. Категория может состоять из нескольких директорий **ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; принадлежность к категории задаётся не именем, а участием в списке бэкапа. -**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно +**Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно три директории». Крупные файлы отделяют от базы, чтобы двигать их между дисками независимо (`media/`, `uploads/` — та же категория «данные», что и `data/`); запрет на такое деление вынуждал бы либо держать всё на одном @@ -49,15 +53,15 @@ задавать именем ровно поэтому: имён в категории несколько, и выбираются они по содержимому. -### R3. Данные и кеш разделяются по тесту на пересоздание +### DIRS-3. Данные и кеш разделяются по тесту на пересоздание **ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому: | № | Что лежит | Категория | |---|---|---| -| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные | -| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | -| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | +| DIRS-3.1 | база, загруженные файлы, сгенерированные артефакты | данные | +| DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | +| DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | **Почему.** Без внешнего теста граница проводится по ощущению «жалко потерять», а оно смещено в одну сторону: дорогой в пересборке кеш @@ -67,7 +71,7 @@ обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не при попытке восстановить. -### R4. В бэкап идут данные, и только они +### DIRS-4. В бэкап идут данные, и только они **ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и кеш — нет. @@ -79,9 +83,9 @@ Ошибка в другую сторону дороже: директория данных, не попавшая в список, обнаруживается в единственный момент, когда исправить её уже нечем. -### R5. Список бэкапа ссылается на те же пути, что и создание директорий +### DIRS-5. Список бэкапа ссылается на те же пути, что и создание директорий -**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же +**ДОЛЖЕН.** Список выводится из категорий по DIRS-4 и ссылается на те же **объявления путей**, по которым директории создаются, а не набирается независимо. @@ -96,25 +100,25 @@ на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что нужно, проявляется при восстановлении. -### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность +### DIRS-6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность **ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске: | № | Данные | В бэкап | |---|---|---| -| R6.1 | файлы самодостаточны на любой момент времени | копированием | -| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | +| DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием | +| DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | **Почему.** Файловый снапшот работающей СУБД не гарантирует консистентности: скопированный каталог может не восстановиться, и узнают об этом при восстановлении. Директория дампов — тоже данные, просто -производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из +производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из списка при этом исключается: он удваивает объём снапшота и добавляет к надёжной копии заведомо ненадёжную. -### R7. Способ выбирается при заведении приложения +### DIRS-7. Способ выбирается при заведении приложения -**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда +**ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда приложение заводят. **Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не @@ -123,7 +127,7 @@ первой неудачной попытки восстановления, то есть тогда, когда данных уже нет. -### R8. Приложение разводит записываемые пути по категориям +### DIRS-8. Приложение разводит записываемые пути по категориям **ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для кеша, а не один каталог на всё. @@ -135,12 +139,12 @@ появляется молча. Приложение, которое не умеет разделять, тем самым дефектно; раскладка под этот дефект не подстраивается. -### R9. Приложение не пишет в директорию конфигурации +### DIRS-9. Приложение не пишет в директорию конфигурации **НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь конфигурации. -**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому +**Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому всё, что приложение туда записало, следующий деплой затирает без предупреждения. Вдобавок директория конфигурации может быть подключена только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не diff --git a/conventions/arch/config.md b/conventions/arch/config.md index cfb1025..ff72197 100644 --- a/conventions/arch/config.md +++ b/conventions/arch/config.md @@ -1,3 +1,7 @@ +--- +prefix: CONF +--- + # Конфигурация приложения Как устроена конфигурация: где лежит, как попадает в процесс, что с @@ -12,7 +16,7 @@ ## Правила -### R1. Конфигурация — файл, а не окружение +### CONF-1. Конфигурация — файл, а не окружение **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные окружения источником конфигурации не служат. @@ -36,17 +40,17 @@ `PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под `0600`. -### R2. Формат конфигурации — текстовый, с секциями и комментариями +### CONF-2. Формат конфигурации — текстовый, с секциями и комментариями **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. -**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг -вообще читают; формат, в котором комментарий негде разместить, делает R9 +**Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг +вообще читают; формат, в котором комментарий негде разместить, делает CONF-9 невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — плоский список пар такой возможности не даёт и возвращает нас к тем же -свойствам, из-за которых отвергнуто окружение (R1). +свойствам, из-за которых отвергнуто окружение (CONF-1). -### R3. Имя файла фиксировано, путь переопределяется опцией +### CONF-3. Имя файла фиксировано, путь переопределяется опцией **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь задаётся опцией командной строки. @@ -55,22 +59,22 @@ контейнере и на сервере, и способ запуска не приходится помнить отдельно для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько (тесты, второй инстанс): без неё их разводят переменной окружения — тем -самым каналом, который закрывает R1. +самым каналом, который закрывает CONF-1. -### R20. Отсутствие файла конфигурации — ошибка старта +### CONF-20. Отсутствие файла конфигурации — ошибка старта **ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в -рабочей директории (R3), приложение не стартует: сообщение называет +рабочей директории (CONF-3), приложение не стартует: сообщение называет искомый путь, код возврата ненулевой. -**Почему.** Конфиг — артефакт деплоя (R12), и его отсутствие означает, что +**Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что развёртывание не довело работу до конца, а не что приложение попросили -работать на умолчаниях. Умолчания (R7) существуют, чтобы работал +работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал **неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается чаще всего. Старт без файла ничего не спасает: у приложения с обязательными полями или -секретами всё равно упадёт валидация (R18), только вместо одного сообщения +секретами всё равно упадёт валидация (CONF-18), только вместо одного сообщения «нет `config.toml`» получится каскад «поле пусто», за которым настоящая причина — деплой не отрендерил файл — не видна. @@ -79,16 +83,16 @@ -### R4. В репозитории лежит образец, а не рабочий конфиг +### CONF-4. В репозитории лежит образец, а не рабочий конфиг **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. -**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет, +**Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет, попавший в историю, чинится ротацией, а не удалением файла. Кроме того, закоммиченный конфиг конкретной среды становится вторым источником истины: он расходится с тем, что реально развёрнуто, и расходится молча. -### R5. Конфиг разбирается один раз при старте +### CONF-5. Конфиг разбирается один раз при старте **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения файла конфигурации в бизнес-коде нет. @@ -96,10 +100,10 @@ **Почему.** Второе место чтения — это второй момент времени: две части кода начинают видеть разные значения одного параметра, и расхождение не воспроизводится, потому что зависит от того, когда файл потрогали. -Типизированная структура вдобавок переносит ошибку формата в старт (R17), +Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17), где она видна сразу, а не в первый вызов ветки, которая это поле читает. -### R6. Конфиг неизменяем после старта +### CONF-6. Конфиг неизменяем после старта **ДОЛЖЕН.** Смена параметров — рестарт процесса. @@ -111,7 +115,7 @@ Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не умолчание. -### R7. Умолчания живут в коде +### CONF-7. Умолчания живут в коде **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. @@ -120,17 +124,17 @@ из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое поведение для неполного конфига и одно место, где это значение меняется. -### R8. Образец перечисляет все поля +### CONF-8. Образец перечисляет все поля **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у -которых есть умолчание (R7). +которых есть умолчание (CONF-7). **Почему.** Поле, живущее только в коде, для читателя конфига не существует: он не знает, что параметр вообще можно менять, и добивается -нужного поведения обходным путём. Полнота образца — цена, которой R7 +нужного поведения обходным путём. Полнота образца — цена, которой CONF-7 покупает себе видимость. -### R9. У каждого поля образца есть комментарий +### CONF-9. У каждого поля образца есть комментарий **ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно: @@ -145,35 +149,35 @@ дают валидное значение и работающий процесс, а ошибка обнаруживается по последствиям — таймаут в тысячу раз не тот. -### R10. Обязательность полей определяется дискриминатором `type` +### CONF-10. Обязательность полей определяется дискриминатором `type` **ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор бекенда или внешнего сервиса), валидация идёт по его значению: | № | Значение `type` | Валидация | |---|---|---| -| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | -| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | +| CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | +| CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | **Почему.** Фиксированный на секцию набор обязательных полей оставляет выбор из двух плохих: заполнять поля бекенда, который не используется, или не проверять обязательность вовсе — то есть выключить валидацию ровно там, где вариантов много и ошибиться легче всего. Перечисление поддерживаемых -значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от +значений (CONF-10.2) нужно потому, что опечатка в `type` иначе неотличима от неподдерживаемого варианта, и за списком приходится идти в код. -### R11. Образец показывает все варианты `type` +### CONF-11. Образец показывает все варианты `type` **СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями, альтернативные — блоками-комментариями ниже, каждый со своим описанием полей. **Почему.** Иначе набор вариантов виден только из кода валидации, и образец -теряет свойство справочника (R8, R9) ровно на той секции, где выбор +теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор действительно есть. Закомментированный блок вдобавок переключается правкой на месте, а не сборкой секции с нуля по документации. -### R12. Секреты в конфиг приносит деплой +### CONF-12. Секреты в конфиг приносит деплой **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; отдельного слоя секретов в приложении нет. @@ -185,27 +189,28 @@ этом остаётся тривиальным: оно читает файл и про секреты не знает ничего особенного. -### R13. Рендеренный конфиг — `0600` и владелец-рантайм +### CONF-13. Рендеренный конфиг — `0600` и владелец-рантайм **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого работает процесс. -**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на -которой секреты лежат, и весь довод «файл вместо окружения» держится на его -правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало -бы окружение, — и тогда R1 меняет одну утечку на другую. +**Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная +поверхность, на которой секреты лежат, и весь довод «файл вместо окружения» +держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты +шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на +другую. -### R14. В образце секретные поля — пустые строки +### CONF-14. В образце секретные поля — пустые строки **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не пример. **Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее значение: шаблон отрендерился криво, поле осталось от образца, и проверка -непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг +непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг механически отличимым от заполненного. -### R15. Загрузчик проверяет, что обязательные секреты не пусты +### CONF-15. Загрузчик проверяет, что обязательные секреты не пусты **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. @@ -213,12 +218,12 @@ в 401 от внешнего API через час работы, — то есть в момент, когда причина ещё очевидна и связана с деплоем. -### R16. Секреты не попадают в логи +### CONF-16. Секреты не попадают в логи **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на одном уровне. -**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они +**Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они собираются, пересылаются и попадают в бэкапы, где права исходного файла уже ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением записи. Типичный источник утечки — отладочный дамп разобранного конфига при @@ -227,7 +232,7 @@ -### R17. Конфиг валидируется на старте, до приёма трафика +### CONF-17. Конфиг валидируется на старте, до приёма трафика **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым кодом; процесс не стартует «наполовину». @@ -238,24 +243,24 @@ чтобы неудачный старт увидел супервизор: без него он неотличим от штатного завершения, и приложение считается развёрнутым. -### R18. Минимальный набор проверок +### CONF-18. Минимальный набор проверок **ДОЛЖЕН.** Валидация покрывает как минимум: | № | Что проверяется | Когда всплывёт без проверки | |---|---|---| -| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает | -| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте | -| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке | -| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | -| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | +| CONF-18.1 | обязательные поля заданы (непустота секретов — CONF-15) | в ветке, которая это поле читает | +| CONF-18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте | +| CONF-18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке | +| CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | +| CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | **Почему.** Список минимальный и собран по одному признаку — правый столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже потеряна, и диагностируется как дефект приложения. Проверка на старте сводит их все к одному моменту и одному сообщению. -### R19. Проблемы конфига показываются разом +### CONF-19. Проблемы конфига показываются разом **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним списком, а не падает на первой. @@ -266,25 +271,25 @@ одного источника: разом они читаются как одна причина, по одной — как череда несвязанных мелочей. -### R21. Значение поля в сообщении валидатора — по признаку секретности +### CONF-21. Значение поля в сообщении валидатора — по признаку секретности **ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности -поля, которым уже пользуются R15 и R16: +поля, которым уже пользуются CONF-15 и CONF-16: | № | Поле | В сообщении | |---|---|---| -| R21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | -| R21.2 | секретное | имя поля и суть нарушения, без значения | +| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | +| CONF-21.2 | секретное | имя поля и суть нарушения, без значения | **Почему.** Сообщение без значения отправляет читателя в файл — сличать -глазами каждую строку списка R19; ошибки вида «секунды вместо миллисекунд» +глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд» или пробел в конце значения из такого сообщения не читаются вовсе. Значение секретного поля при этом печатать некуда: вывод старта уходит в лог супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же -канал утечки, который закрывает R16. Отдельный список «что не печатать» не -заводится: признак один на R15, R16 и R21, а второй список разошёлся бы с -первым — и поле оказалось бы секретным для логов, но печатаемым -валидатором. +канал утечки, который закрывает CONF-16. Отдельный список «что не печатать» +не заводится: признак один на CONF-15, CONF-16 и CONF-21, а второй список +разошёлся бы с первым — и поле оказалось бы секретным для логов, но +печатаемым валидатором. ## Связано diff --git a/conventions/arch/db-identifiers.md b/conventions/arch/db-identifiers.md index e435514..a008cd2 100644 --- a/conventions/arch/db-identifiers.md +++ b/conventions/arch/db-identifiers.md @@ -1,3 +1,7 @@ +--- +prefix: KEYS +--- + # Идентификаторы сущностей Как выбираются и как выглядят первичные ключи сущностей. Форма записи — @@ -14,7 +18,7 @@ ## Правила -### R1. Первичный ключ новой сущности — ULID +### KEYS-1. Первичный ключ новой сущности — ULID **ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор, который порождает приложение, — во **всех** таблицах, включая те, что @@ -31,12 +35,12 @@ Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от спора при заведении каждой таблицы и делает идентификатор **глобальным** — уникальным across таблиц, а не только внутри своей. На этом держится -корреляция по логам (R7). +корреляция по логам (KEYS-7). Правило про **сгенерированные суррогатные** ключи. Естественные и составные -ключи у таблиц-деталей (R6) — третья категория, они допустимы всегда. +ключи у таблиц-деталей (KEYS-6) — третья категория, они допустимы всегда. -### R2. Идентификатор генерирует приложение, а не база +### KEYS-2. Идентификатор генерирует приложение, а не база **ДОЛЖЕН.** Значение ключа известно до вставки строки. @@ -46,26 +50,26 @@ и достраивать связи вторым проходом, либо иметь два источника истины о моменте создания. -### R3. Генерация и разбор идентификаторов — в единственной точке +### KEYS-3. Генерация и разбор идентификаторов — в единственной точке **ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает. Самодельных генераторов и парсеров в коде нет. -**Почему.** Нормализация регистра (R4) и проверка формата обязаны +**Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны применяться ко всем идентификаторам без исключения. Любая вторая точка входа рано или поздно окажется той, где нормализацию забыли, — и дефект проявится не там, где создан. -### R4. Канонический вид — нижний регистр +### KEYS-4. Канонический вид — нижний регистр **ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре. **Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не косметика, а корректность поиска. Спецификация ULID канонизирует **верхний** -регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3) +регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3) разный регистр появится в базе сам собой. -### R5. Внешний идентификатор разбирается до обращения к базе +### KEYS-5. Внешний идентификатор разбирается до обращения к базе **ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от @@ -73,28 +77,28 @@ | № | Откуда пришёл | Разбор не удался → | |---|---|---| -| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу | -| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | +| KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу | +| KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | **Почему.** Синтаксически невалидное значение не может соответствовать записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на границе, мы дёшево снимаем целый класс мусорного трафика. -Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в -URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию -точно. Мусор из собственной формы — это баг интерфейса или устаревший -экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики -единственный момент, когда он заметен. +Разделение KEYS-5.1 и KEYS-5.2 нужно, потому что источники значат разное. +Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает +ситуацию точно. Мусор из собственной формы — это баг интерфейса или +устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает +диагностики единственный момент, когда он заметен. Таблица перечисляет **внешние** источники — те, откуда значение приходит вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из конфигурации, из собственной базы или из фикстуры сюда не относится: он ничего не отдаёт наружу, а его невалидность означает, что сломано у нас. Формат идентификатора в конфигурации проверяется на старте -(`arch/config.md` R18), невалидное значение в собственной базе — нарушенный -инвариант единой точки (R3). +(`CONF-18`), невалидное значение в собственной базе — нарушенный +инвариант единой точки (KEYS-3). -### R6. У таблиц-деталей допустим естественный или составной ключ +### KEYS-6. У таблиц-деталей допустим естественный или составной ключ **ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный сгенерированный идентификатор не заводится. @@ -104,10 +108,10 @@ URL — это чужая или протухшая ссылка, и «не на лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной информации он не несёт. -### R7. Прочие генерируемые идентификаторы — через ту же точку +### KEYS-7. Прочие генерируемые идентификаторы — через ту же точку **ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч, -задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же +задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же формате. **Почему.** Единый формат делает работающим главный побочный эффект @@ -118,7 +122,7 @@ URL — это чужая или протухшая ссылка, и «не на ## Почему ULID, а не UUID -R1 требует **сортируемый** строковый идентификатор. UUIDv4 не +KEYS-1 требует **сортируемый** строковый идентификатор. UUIDv4 не сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него остаются два довода: 36 символов против 26 и дефисы, из-за которых идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово. diff --git a/conventions/arch/time.md b/conventions/arch/time.md index a23c189..7bee098 100644 --- a/conventions/arch/time.md +++ b/conventions/arch/time.md @@ -1,3 +1,7 @@ +--- +prefix: TIME +--- + # Время Как приложение записывает моменты и длительности: в каком формате, откуда @@ -14,7 +18,7 @@ ## Правила -### R1. Единый формат — RFC 3339, UTC, суффикс `Z` +### TIME-1. Единый формат — RFC 3339, UTC, суффикс `Z` **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` — одинаково в хранении, логах, API и обмене с внешними системами. @@ -25,7 +29,7 @@ совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` убирает из данных и смещение, и сам вопрос «в какой зоне это записано». -### R2. Ширина строки фиксируется на каждый носитель +### TIME-2. Ширина строки фиксируется на каждый носитель **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина строки времени одна и от записи к записи не плавает. @@ -38,18 +42,18 @@ везде, а только на тех парах записей, где дробная часть оказалась короче, — то есть редко, выборочно и невоспроизводимо. -### R3. Точность разных носителей может различаться +### TIME-3. Точность разных носителей может различаться **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. -**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что +**Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что строки разных носителей между собой не сравниваются: сортировка идёт внутри -колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался +колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался как «одна точность на всё приложение»: от подгонки формата логов под формат колонки ни одна пара строк не становится сравнимой, зато точность режется до худшего из носителей. -### R4. Локальное время не хранится и не передаётся +### TIME-4. Локальное время не хранится и не передаётся **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной зоне. @@ -60,44 +64,45 @@ разберёт час перехода на зимнее время: этот час идёт дважды, две записи получают одинаковую метку, и порядок между ними не восстанавливается ничем. -### R13. Чужой вход нормализуется при разборе, а не отклоняется +### TIME-13. Чужой вход нормализуется при разборе, а не отклоняется **ДОЛЖЕН.** Валидное по RFC 3339 значение с офсетом, отличным от `Z`, или с долями секунды принимается от внешней системы и приводится к каноническому -виду (R1) в точке разбора (R5). +виду (TIME-1) в точке разбора (TIME-5). **Почему.** Канонический вид — обязательство нашего писателя, а не контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение -нарушает форму и ширину носителя (R1, R2) и портит сортировку выборочно — -только на записях, пришедших извне, и далеко от места разбора. Нормализация +нарушает форму и ширину носителя (TIME-1, TIME-2) и портит сортировку +выборочно — только на записях, пришедших извне, и далеко от места разбора. +Нормализация в единой точке разбора оставляет ровно одно место, где неканонический вид существует, — по ту сторону границы его уже нет. -### R5. Единая точка получения «сейчас», форматирования и разбора +### TIME-5. Единая точка получения «сейчас», форматирования и разбора **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает метки; прямые вызовы часов по коду не разбросаны. -**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех +**Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех меток без исключения, а каждый прямой вызов часов заводит ещё одно место, где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в данных, и обнаруживается, когда испорченных записей уже накопилось. -Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`). +Соображение то же, что для идентификаторов (`arch/db-identifiers.md TIME-3`). -### R6. Дефолтов времени в схеме БД нет +### TIME-6. Дефолтов времени в схеме БД нет **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. **Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий код: значение появляется, но приходит от сервера БД — то есть с других часов -и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка +и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка падает громко и чинится в момент написания, а не при разборе расхождения между временем в записи и временем в логе. Правило то же, что для -идентификаторов (`arch/db-identifiers.md R2`). +идентификаторов (`arch/db-identifiers.md TIME-2`). -### R7. Длительность — отдельная величина, а не пара меток +### TIME-7. Длительность — отдельная величина, а не пара меток **ДОЛЖЕН.** Длительность операции записывается числом (обычно миллисекундами) в поле вида `duration_ms`. @@ -107,9 +112,9 @@ образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в логе; число сравнивается, агрегируется и попадает в перцентили без этого шага. Кроме того, разность сохранённых меток считается по стенным часам и -наследует их дефект (R9). +наследует их дефект (TIME-9). -### R8. Длительность засекает слой, который делает вызов +### TIME-8. Длительность засекает слой, который делает вызов **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. @@ -118,14 +123,14 @@ вызова. В обоих случаях число остаётся правдоподобным и потому не оспаривается, хотя отвечает не на тот вопрос, который к нему задают. -### R9. Момент и интервал берутся с разных часов +### TIME-9. Момент и интервал берутся с разных часов **ДОЛЖЕН.** Источник зависит от того, что записывается: | № | Величина | Источник | |---|---|---| -| R9.1 | момент события | стенные часы через единую точку (R5) | -| R9.2 | длительность операции | монотонные часы процесса | +| TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) | +| TIME-9.2 | длительность операции | монотонные часы процесса | **Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — @@ -135,7 +140,7 @@ упустить: источник меток времени и источник интервалов — разные, даже если оба называются «часы». -### R10. Не-UTC существует только на слое отображения +### TIME-10. Не-UTC существует только на слое отображения **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не проникает в хранение, сортировку и логи. @@ -147,7 +152,7 @@ смещение удваивается, результат остаётся похожим на правду, а найти виновный слой можно только перечитав их все. -### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC` +### TIME-11. Зона отображения берётся из конфигурации, по умолчанию `UTC` **ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение по умолчанию — `UTC`. @@ -158,7 +163,7 @@ тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается как «зону не задали», а не как «где-то потерялось смещение». -### R12. В календарных вычислениях зона указывается явно +### TIME-12. В календарных вычислениях зона указывается явно **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с явно переданной зоной, а не с системной зоной процесса. @@ -168,7 +173,7 @@ расхождение не воспроизводится там, где его заметили, и объясняется средой, а не кодом. Явно переданная зона делает результат функцией от аргументов. -Зона по умолчанию здесь та же, что и для отображения (R11); календарная +Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная логика, которой нужна другая, получает её тем же явным аргументом. diff --git a/conventions/lang/go/config.md b/conventions/lang/go/config.md index b262325..aa73d76 100644 --- a/conventions/lang/go/config.md +++ b/conventions/lang/go/config.md @@ -1,4 +1,5 @@ --- +prefix: GCFG extends: arch/config.md --- @@ -13,7 +14,7 @@ extends: arch/config.md ## Правила -### R1. Формат конфигурации — TOML +### GCFG-1. Формат конфигурации — TOML **ДОЛЖЕН.** Конфиг — файл TOML. @@ -25,7 +26,7 @@ extends: arch/config.md поправленный руками на сервере, ломается заметно, а не меняет вложенность молча. -### R2. Разбор и валидация — целиком в `internal/config` +### GCFG-2. Разбор и валидация — целиком в `internal/config` **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в `internal/config`; наружу пакет отдаёт готовую структуру `Config`. @@ -34,11 +35,11 @@ extends: arch/config.md после — уже нет, и это единственная граница, на которой такое утверждение проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос «проверено ли это поле» только чтением всех вызывающих, часть полей -неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ +неизбежно окажется непроверенной, и fail-fast (GCFG-15) выродится в отказ посреди работы. Экспортированный разбор вдобавок даёт второй способ -получить конфиг — мимо умолчаний (R5). +получить конфиг — мимо умолчаний (GCFG-5). -### R3. Весь конфиг — одна корневая структура +### GCFG-3. Весь конфиг — одна корневая структура **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из под-структур по секциям. @@ -50,7 +51,7 @@ extends: arch/config.md (включена интеграция — заданы все её поля) при этом перестают быть проверяемыми в одном месте. -### R4. Под-структуры названы по секциям файла +### GCFG-4. Под-структуры названы по секциям файла **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. @@ -60,7 +61,7 @@ extends: arch/config.md восстанавливается чтением тегов, и проделывать это приходится для каждой секции заново. -### R5. Умолчания задаёт `Default()` +### GCFG-5. Умолчания задаёт `Default()` **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл накладывается поверх. @@ -72,7 +73,7 @@ extends: arch/config.md подставляют разное. `Default()` — единственное место, откуда список умолчаний читается разом и переносится в образец. -### R6. Имя файла фиксировано, путь переопределяется флагом +### GCFG-6. Имя файла фиксировано, путь переопределяется флагом **СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории, путь переопределяет флаг `--config=path`, образец рядом — @@ -85,7 +86,7 @@ extends: arch/config.md `config.example.toml` вдобавок делает расхождение образца с реальным конфигом видимым обычным `diff`, а не вычиткой. -### R7. Длительности — собственный тип с `UnmarshalText` +### GCFG-7. Длительности — собственный тип с `UnmarshalText` **ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим `time.Duration`: @@ -105,11 +106,11 @@ func (d Duration) Std() time.Duration { … } в себе и разбирается тем же `time.ParseDuration`, что и остальной код. У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть -раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности +раньше, чем начинает работать сбор проблем (GCFG-12). Ошибка в длительности приходит отдельно и первой, а остальные проблемы конфига в этом запуске не показываются. -### R8. Приложение не читает окружение +### GCFG-8. Приложение не читает окружение **НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. @@ -120,9 +121,9 @@ func (d Duration) Std() time.Duration { … } чтением всего кода — а узнают о нём обычно на сервере, где переменная не выставлена. -### R9. Проверка запрета покрывает всю семью `os` +### GCFG-9. Проверка запрета покрывает всю семью `os` -**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только +**ДОЛЖЕН.** Механическая проверка GCFG-8 (`forbidigo`) ловит не только `os.Getenv`: ``` @@ -137,20 +138,20 @@ func (d Duration) Std() time.Duration { … } Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят `syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`. Проверка закрывает обычные способы — те, которыми окружение читают не -нарочно; сознательный обход она не ловит, и считать R8 полностью +нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью механизированным нельзя. -### R10. За границей приложения запрет не действует +### GCFG-10. За границей приложения запрет не действует **ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое приложение: | № | Кто читает | Вердикт | |---|---|---| -| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | -| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | +| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | +| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | -**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не +**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один @@ -158,19 +159,19 @@ func (d Duration) Std() time.Duration { … } лечится `//nolint` наугад: там, где легальные случаи приходится глушить руками, вместе с ними проходят и нелегальные. -### R11. Прокси задаётся конфигом, а не `HTTP_PROXY` +### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY` **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. **Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а дефолтный `http.Transport` — но читает он их от имени приложения и меняет поведение приложения, а не рантайма. Оставленные окружению, они дают ровно -тот второй канал, который запрещает R8, и притом самый неудобный: маршрут +тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут исходящих запросов отличается от машины к машине без единого следа в конфиге и в образце, а расследование начинается с вопроса «почему на сервере ходит не так, как локально». -### R12. Проблемы конфига собираются `errors.Join` +### GCFG-12. Проблемы конфига собираются `errors.Join` **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна ошибка, собранная `errors.Join`. @@ -181,7 +182,7 @@ func (d Duration) Std() time.Duration { … } своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой вложенной проблеме. -### R13. Имя зоны проверяется `time.LoadLocation` +### GCFG-13. Имя зоны проверяется `time.LoadLocation` **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. @@ -189,9 +190,9 @@ func (d Duration) Std() time.Duration { … } тогда, когда база зон его знает, и никакая проверка формата не отличит `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка доживает до первого форматирования времени — то есть до рантайма, мимо -fail-fast (R15). +fail-fast (GCFG-15). -### R14. `time/tzdata` импортируется в `main` +### GCFG-14. `time/tzdata` импортируется в `main` **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном пакете. @@ -199,11 +200,11 @@ fail-fast (R15). **Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или полагаться на системную» принадлежит собираемой программе. Со встроенной -базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без +базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без неё тот же конфиг валиден на машине разработчика и падает в контейнере без zoneinfo, а сообщение указывает не на ту причину. -### R15. Невалидный конфиг — `ERROR` и выход из `main` +### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main` **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до старта серверов и воркеров. diff --git a/conventions/lang/go/db-identifiers.md b/conventions/lang/go/db-identifiers.md index 6c40c36..6568aef 100644 --- a/conventions/lang/go/db-identifiers.md +++ b/conventions/lang/go/db-identifiers.md @@ -1,4 +1,5 @@ --- +prefix: GKEY extends: arch/db-identifiers.md --- @@ -7,18 +8,18 @@ extends: arch/db-identifiers.md Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи — `LANGUAGE.md`. -Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он +Единая точка из `KEYS-3` — пакет `internal/ident`: он порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает (`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся. ## Правила -### R1. Генерация и разбор — только через `internal/ident` +### GKEY-1. Генерация и разбор — только через `internal/ident` **ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета `internal/ident`; других генераторов и парсеров id в коде нет. -**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов +**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не выглядит нарушением: значение получается валидное, просто мимо нормализации регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт @@ -26,12 +27,12 @@ ULID-библиотеки — одна строка, доступная из л модуля, а «забытая нормализация» не находится ничем, пока запрос молча не перестанет находить существующую запись. -### R2. Первичный ключ генерируется в `Create`-методах store +### GKEY-2. Первичный ключ генерируется в `Create`-методах store **ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` внутри `Create`-метода слоя store. -**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было +**Почему.** `KEYS-2` требует, чтобы значение было известно до вставки, но не говорит, кто его присваивает. Store — последний слой, через который проходят все пути создания строки, включая импорт, фоновые задания и тесты. Генерация выше по стеку делает присвоение @@ -39,18 +40,18 @@ ULID-библиотеки — одна строка, доступная из л строку в колонку ключа: для строкового PK это валидное значение, база его не отклонит, и дефект обнаружится на второй такой вставке. -### R3. Прочие идентификаторы генерируются в точке начала операции +### GKEY-3. Прочие идентификаторы генерируются в точке начала операции **ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся вызовом `ident.NewID()` там, где операция начинается. -**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) — +**Почему.** Смысл такого идентификатора (`KEYS-7`) — сшивать записи лога всей операции. Созданный ниже по стеку или в момент первой записи в базу, он не покрывает начальные шаги — а именно они нужны, когда операция упала до того, как что-либо записала: без общего ключа эти записи из лога не собираются вообще. -### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)` +### GKEY-4. Бэкфилл в миграциях — `ident.NewIDAt(t)` **ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в Go-миграции, порождаются с историческим временем строки, а не с текущим. @@ -62,18 +63,18 @@ Go-миграции, порождаются с историческим врем Исправить это потом нельзя: исходное время в идентификаторе не восстановить. -### R5. Разбор — на входных границах, до обращения к store +### GKEY-5. Разбор — на входных границах, до обращения к store **ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или callback'а бота — раньше, чем идентификатор попадёт в store. -**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана +**Почему.** Реализация `KEYS-5`. Граница выбрана транспортная, потому что только на ней известен источник значения, от -которого зависит реакция (R8): store видит одинаковую строку независимо от +которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от того, пришла она из URL или из собственной формы, и ответить по-разному оттуда уже невозможно. -### R6. Id в структурах — обычный `string` +### GKEY-6. Id в структурах — обычный `string` **СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип `string`. @@ -84,35 +85,35 @@ callback'а бота — раньше, чем идентификатор поп параметров. Зато он требует конверсий на каждой границе с sql-драйвером, json и шаблонами, то есть даёт цену без выгоды. -### R7. Отдельный тип — когда появляется вторая семья идентификаторов +### GKEY-7. Отдельный тип — когда появляется вторая семья идентификаторов **ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые можно перепутать, для них заводятся различимые типы. -**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на +**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на типизацию навсегда. Условие названо ровно то, при котором тип начинает работать: пока все идентификаторы — `string`, подстановка одного вида вместо другого компилируется и обнаруживается только на данных. -### R8. Реакция на невалидный id зависит от источника +### GKEY-8. Реакция на невалидный id зависит от источника **ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло значение: | № | Источник | Ответ | |---|---|---| -| R8.1 | путь или query URL | 404 без обращения к store | -| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | +| GKEY-8.1 | путь или query URL | 404 без обращения к store | +| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | -**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах -HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи — -и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2 +**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах +HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи — +и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2 значение сформировало само приложение, и невалидность означает баг интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно, в логах не оставляет аномалии и тем самым съедает единственный момент, когда дефект заметен. -### R9. Транспорт не создаёт доменные ошибки +### GKEY-9. Транспорт не создаёт доменные ошибки **НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например `ErrNotFound`), чтобы тут же сопоставить его со своим ответом. diff --git a/conventions/lang/go/db-schema.md b/conventions/lang/go/db-schema.md index 0eb4460..4683c97 100644 --- a/conventions/lang/go/db-schema.md +++ b/conventions/lang/go/db-schema.md @@ -1,3 +1,7 @@ +--- +prefix: MIGR +--- + # Схема и миграции (SQLite, Go) Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в @@ -12,7 +16,7 @@ Go-приложении. Форма записи — `LANGUAGE.md`. ## Миграции -### R1. Миграции ведёт goose +### MIGR-1. Миграции ведёт goose **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — goose. @@ -24,7 +28,7 @@ goose. существующую таблицу. На сервере это означает ручной разбор состояния схемы вместо автоматического деплоя. -### R2. Файлы миграций лежат рядом со store-слоем +### MIGR-2. Файлы миграций лежат рядом со store-слоем **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой схемой. @@ -35,14 +39,14 @@ goose. код без миграции, либо миграция без кода; расходятся они на сервере, где схема ещё старая. -### R3. Форма миграции выбирается по тому, нужен ли код +### MIGR-3. Форма миграции выбирается по тому, нужен ли код **ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое: | № | Что делает миграция | Форма | |---|---|---| -| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | -| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | +| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | +| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | **Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, который уедет в базу; обёртка на Go вокруг него добавляет место, где можно @@ -50,12 +54,12 @@ goose. Обратное направление дороже. Перенос данных и генерация идентификаторов выражаются на SQL либо громоздко, либо неточно: идентификатор по -`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена +`KEYS-2` порождает приложение, и SQL-миграция вынуждена завести для него второй генератор — ровно то, что запрещает -`arch/db-identifiers.md` R3. Единообразие формы здесь покупается +`KEYS-3`. Единообразие формы здесь покупается дублированием логики, которая уже есть в коде. -### R4. В деплое схема движется только вперёд +### MIGR-4. В деплое схема движется только вперёд **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; ошибка исправляется новой миграцией вперёд. @@ -67,14 +71,14 @@ goose. следующей миграцией, оставляет целыми и данные, и журнал применённых версий. -### R5. Down пишется, когда он честно обращает up +### MIGR-5. Down пишется, когда он честно обращает up **ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up: | № | Что делает up | Down | |---|---|---| -| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | -| R5.2 | необратимо преобразует данные | не пишется | +| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | +| MIGR-5.2 | необратимо преобразует данные | не пишется | **Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, и именно там он обязан действительно обращать up. Имитация опаснее @@ -83,7 +87,7 @@ goose. down останавливает сразу и заставляет пересоздать базу — это дешевле, чем отладка по данным, которых уже нет. -### R6. ER-схема обновляется в том же изменении +### MIGR-6. ER-схема обновляется в том же изменении **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним изменением. @@ -99,7 +103,7 @@ down останавливает сразу и заставляет пересо Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, а не язык приложения. -### R7. Enum-поля — `TEXT`, допустимые значения держит код +### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` без `CHECK`-ограничения на список значений. @@ -115,7 +119,7 @@ down останавливает сразу и заставляет пересо таблицы соответствия, которую пришлось бы держать в голове для числового кода. -### R8. Метки времени — `TEXT` в формате из `arch/time.md` +### MIGR-8. Метки времени — `TEXT` в формате из `arch/time.md` **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения пишутся в формате из `arch/time.md`. @@ -127,7 +131,7 @@ down останавливает сразу и заставляет пересо преобразования, а значит и без потери индекса. Соседство двух форматов в одной колонке ломает и сравнение, и разбор на стороне Go. -### R9. Умолчание `DEFAULT (datetime('now'))` не ставится +### MIGR-9. Умолчание `DEFAULT (datetime('now'))` не ставится **НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию на уровне схемы. @@ -138,10 +142,10 @@ down останавливает сразу и заставляет пересо по ошибке. Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, -то есть не тот формат, которого требует R8. В колонке оказываются строки +то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки двух видов, и ломается ровно то, ради чего формат выбран. -### R10. Булевы поля — `INTEGER` со значениями 0 и 1 +### MIGR-10. Булевы поля — `INTEGER` со значениями 0 и 1 **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. @@ -152,10 +156,10 @@ down останавливает сразу и заставляет пересо типа. Такой дефект не падает, не виден в логе и переживает тесты, которые проверяют, что список не пуст. -### R11. Первичный ключ новой таблицы — TEXT ULID +### MIGR-11. Первичный ключ новой таблицы — TEXT ULID **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из -приложения (`arch/db-identifiers.md` R1, R2). +приложения (`KEYS-1`, `KEYS-2`). **Почему.** Здесь конвенция схемы ничего не решает — она реализует решение, принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие @@ -165,7 +169,7 @@ down останавливает сразу и заставляет пересо `AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на `INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего. -### R12. Целочисленный ключ идёт вместе с `AUTOINCREMENT` +### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT` **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. @@ -178,7 +182,7 @@ down останавливает сразу и заставляет пересо Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает: -там ключ строковый (R11). +там ключ строковый (MIGR-11). diff --git a/conventions/lang/go/errors.md b/conventions/lang/go/errors.md index a5fa28b..3847b4c 100644 --- a/conventions/lang/go/errors.md +++ b/conventions/lang/go/errors.md @@ -1,3 +1,7 @@ +--- +prefix: GERR +--- + # Ошибки Как ошибки строятся, оборачиваются и проверяются. Форма записи — @@ -17,22 +21,22 @@ ## Правила -### R1. Ошибки строятся средствами стандартной библиотеки +### GERR-1. Ошибки строятся средствами стандартной библиотеки **ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и `fmt.Errorf`; библиотеки со стек-трейсами не подключаются. **Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. -При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений +При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт `slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса это цена без покупателя. Единственное место, где стек всё-таки нужен, — восстановленная паника: у -неё цепочки `%w` нет вовсе (R23). +неё цепочки `%w` нет вовсе (GERR-23). -### R2. Дефолт не обходится точечно +### GERR-2. Дефолт не обходится точечно **НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте кодовой базы ради конкретной отладки. @@ -41,39 +45,39 @@ перестаёт знать, какой перед ним: обёртки склеиваются по-разному, `errors.Is` работает не везде одинаково. Хуже второе: боль, снятая локально, перестаёт накапливаться — а накопление и есть единственный -сигнал, что решение R1 пора пересматривать целиком. +сигнал, что решение GERR-1 пора пересматривать целиком. -### R3. Каждый слой добавляет свой контекст +### GERR-3. Каждый слой добавляет свой контекст **ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с контекстом: `fmt.Errorf("parse magnet: %w", err)`. -**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько, +**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, стирает участок пути — по итоговому сообщению нельзя сказать, через какую операцию ошибка прошла, и отладка «no such file» начинается с чтения всего кода. -### R4. Обёртка по умолчанию — `%w` +### GERR-4. Обёртка по умолчанию — `%w` **СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину вызывающему: | № | Ситуация | Глагол | |---|---|---| -| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | -| R4.2 | причину сознательно не раскрываем | `%v` | +| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | +| GERR-4.2 | причину сознательно не раскрываем | `%v` | **Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка становится частью API» — относится к библиотекам с внешними потребителями. Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает `errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у -(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил -`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда +(GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил +`ErrNotFound`», далеко от места обрыва. GERR-4.2 остаётся для случая, когда завязывать вызывающего на чужой тип ошибки не хотят намеренно. -### R5. Утечка внутренних деталей лечится трансляцией, а не `%v` +### GERR-5. Утечка внутренних деталей лечится трансляцией, а не `%v` **НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю ошибку наружу. @@ -82,9 +86,9 @@ целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, детали утекут при любом глаголе. Подмена не решает задачу, ради которой сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. -Настоящее место защиты — R13. +Настоящее место защиты — GERR-13. -### R6. Текст обёртки — со строчной буквы и без служебных слов +### GERR-6. Текст обёртки — со строчной буквы и без служебных слов **СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». @@ -94,15 +98,15 @@ нами ошибка, известно из того, что это ошибка. Зато повторяются они на каждом уровне и вытесняют из строки полезный контекст. -### R7. Контекст обёртки называет операцию или субъект +### GERR-7. Контекст обёртки называет операцию или субъект **СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. -**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something +**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something failed» не сужает ничего и при этом занимает в сообщении место, которое мог бы занять единственный полезный здесь факт — имя операции. -### R8. Слой не повторяет смысл нижнего +### GERR-8. Слой не повторяет смысл нижнего **НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: `"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. @@ -115,10 +119,10 @@ failed» не сужает ничего и при этом занимает в ## Две трансляции Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная → -доменная у источника (R9) и доменная → пользовательская на внешней границе -(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт. +доменная у источника (GERR-9) и доменная → пользовательская на внешней границе +(GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт. -### R9. Инфраструктурная ошибка транслируется в доменную у источника +### GERR-9. Инфраструктурная ошибка транслируется в доменную у источника **ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для @@ -131,14 +135,14 @@ HTTP-клиентов, файловой системы, внешних SDK. состояние «нет записи» одно и то же. Трансляция у источника оставляет знание о зависимости в единственном слое, который её и так знает. -### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему +### GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему **ДОЛЖЕН.** Между sentinel'ом и типом выбирают так: | № | Что нужно вызывающему | Форма | |---|---|---| -| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | -| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | +| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | +| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | **Почему.** Sentinel — одно значение; сравнение с ним не зависит от структуры ошибки и переживает добавление полей. Тип заводится ради данных, @@ -147,14 +151,14 @@ HTTP-клиентов, файловой системы, внешних SDK. каждой проверке. Две формы для одного условия — это два способа его проверить, и про второй рано или поздно забудут. -### R11. Матчинг по тексту сообщения +### GERR-11. Матчинг по тексту сообщения **НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. -**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его -свободно. Правка формулировки в нижнем слое молча ломает ветвление -наверху, и компилятор этого не видит. Это то же самое, что публичный API из -строки лога. +**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают +переписывать его свободно. Правка формулировки в нижнем слое молча ломает +ветвление наверху, и компилятор этого не видит. Это то же самое, что +публичный API из строки лога. ## Граница: приватный канал и публичный @@ -162,39 +166,39 @@ HTTP-клиентов, файловой системы, внешних SDK. того, кто канал видит: приватный канал — логи (их читает владелец сервиса), публичный — пользовательские поверхности (HTTP API, web-UI, бот). -### R12. Полная ошибка идёт в приватный канал +### GERR-12. Полная ошибка идёт в приватный канал **ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно — `lang/go/logging.md`. -**Почему.** Цепочка — единственный носитель диагностики (R1), и +**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и единственный канал, где её можно показать целиком, — тот, который видит владелец. Не записанная там, она не сохранится нигде: наружу идёт -нейтральное сообщение (R13), и восстанавливать причину будет не из чего. +нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего. -### R13. Публичная поверхность получает сообщение по доменной ошибке +### GERR-13. Публичная поверхность получает сообщение по доменной ошибке **ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не `err.Error()` и не детали реализации (`database/sql`, пути, стек). **Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны -— у него есть лог (R12). Зато они раскрывают устройство системы — имена +— у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, причём раскрывают именно в момент, когда что-то пошло не так. -### R14. Публичное сообщение несёт корреляционный ключ +### GERR-14. Публичное сообщение несёт корреляционный ключ **ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`: «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла ошибка». -**Почему.** R13 забирает у пользователя всю фактуру; без ключа его +**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его обращение звучит как «у меня что-то не работает», и владелец ищет запись в логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже видел. -### R15. Маппинг доменных ошибок — в одной точке на все транспорты +### GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты **ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус» задаётся один раз; транспорт без статусов (бот) берёт из него только @@ -203,13 +207,13 @@ HTTP-клиентов, файловой системы, внешних SDK. **Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина важнее: единственная точка — это место, куда механически дописывается новая -ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде» +ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде» ничем не проверяет. -### R16. Новая штатная ветвь отказа сразу попадает в маппинг +### GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг **ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и -добавляется в маппинг (R15) тем же изменением. +добавляется в маппинг (GERR-15) тем же изменением. **Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 «внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает @@ -219,14 +223,14 @@ HTTP-клиентов, файловой системы, внешних SDK. -### R25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком +### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком -**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (R15) нет ветви, отдаёт +**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с признаком того, что маппинг её не знает. **Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли -завести вопреки R16. Адресат у неё владелец в смысле «надо чинить», отсюда +завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда `ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус тоже не выбирается: известное пользовательское состояние лежало бы в маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx @@ -238,18 +242,18 @@ HTTP-клиентов, файловой системы, внешних SDK. находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как механизм обнаружения, а не как шум. -### R17. Форма текста определяется поверхностью +### GERR-17. Форма текста определяется поверхностью **ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого текста для них разное: | № | Поверхность | Текст ошибки | |---|---|---| -| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт | -| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец | +| GERR-17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (GERR-15); `err.Error()` наружу не идёт | +| GERR-17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец | Появился второй зритель или публичный доступ к экрану состояния — -поверхность стала публичным каналом, и на неё распространяется R17.1. +поверхность стала публичным каналом, и на неё распространяется GERR-17.1. **Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную @@ -259,7 +263,7 @@ HTTP-клиентов, файловой системы, внешних SDK. про единственного зрителя — ровно то, что делает вторую поверхность приватным каналом; без него это обычная публичная поверхность. -### R18. Секретов нет ни на одной из поверхностей +### GERR-18. Секретов нет ни на одной из поверхностей **НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ, ни в персистентную диагностику; источник вычищается на границе клиента. @@ -270,19 +274,19 @@ HTTP-клиентов, файловой системы, внешних SDK. известно, какие поля запроса секретны: дальше ошибка едет как текст, и отличить в нём токен от идентификатора уже нельзя. -### R19. Диагностика хранится в отдельном поле +### GERR-19. Диагностика хранится в отдельном поле **ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое показывают пользователю. -**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей +**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей разные поля. Одно поле на оба назначения означает, что при первом же показе записи наружу сырой текст уедет туда же — не по решению, а потому что поле одно. ## panic -### R20. `panic` — только для невосстановимого +### GERR-20. `panic` — только для невосстановимого **ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и ошибка инициализации, из которой нельзя стартовать. @@ -293,25 +297,25 @@ HTTP-клиентов, файловой системы, внешних SDK. инвариантом опаснее падения, а сервис, стартовавший без обязательной зависимости, всё равно откажет позже и непонятнее. -### R21. Ожидаемые ошибки — значения `error` +### GERR-21. Ожидаемые ошибки — значения `error` **НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, плохой ввод, отсутствующая запись возвращаются как `error`. **Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит -его обработать. Дальше такая паника долетает до recover-границы (R22), где +его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией «мы сломались». -### R22. `recover` — на верхней границе каждой обрабатывающей единицы +### GERR-22. `recover` — на верхней границе каждой обрабатывающей единицы **ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный: | № | Единица | Зачем `recover` | |---|---|---| -| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | -| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | +| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | +| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | **Почему.** `recover` работает только в той горутине, где случилась паника, поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у @@ -321,26 +325,26 @@ HTTP-клиентов, файловой системы, внешних SDK. без своего `recover` уходит мимо структурированного лога, а клиент получает оборванное соединение вместо ответа. -### R23. Recover-граница пишет `debug.Stack()` +### GERR-23. Recover-граница пишет `debug.Stack()` **ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. -**Почему.** Это единственное место, где стек нужен (R1): у восстановленной +**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной паники цепочки `%w` нет вовсе. «index out of range» без стека не диагностируется в принципе — сообщение не называет ни файла, ни операции, по нему нельзя сказать даже, в каком пакете упало. ## Несколько ошибок -### R26. После `recover` единица продолжает работу, исключив упавшее +### GERR-26. После `recover` единица продолжает работу, исключив упавшее **ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит граница: | № | Где перехвачена паника | Что дальше | |---|---|---| -| R26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются | -| R26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся | +| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются | +| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся | **Почему.** Паника внутри обработки одного элемента почти всегда говорит о баге в работе с данными этого элемента, а не о порче общего состояния, — @@ -348,7 +352,7 @@ HTTP-клиентов, файловой системы, внешних SDK. не буквально: в OTP падает изолированный процесс под супервизором, а не узел целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не граница процесса. Обратное при этом верно и делает `recover` в цикле -обязательным (R22): неперехваченная паника в любой горутине завершает весь +обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь процесс. Продолжать, не исключив упавший элемент, нельзя: детерминированная паника @@ -359,16 +363,16 @@ HTTP-клиентов, файловой системы, внешних SDK. строку состоянием, — механизм для этого уже есть, заводить отдельный не нужно. -Оговорка «если ответ ещё не начат» в R26.1 не формальность: статус +Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус отправляется один раз, и после первой записи в тело поменять его нечем — клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать ответ целиком до записи там, где это возможно. -Из R26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать +Из GERR-26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать обработку намеренно», и recover-обёртка пробрасывает его дальше, а не превращает в 500. Так поступают и стандартные обёртки вроде chi. -### R24. Независимые ошибки собираются `errors.Join` +### GERR-24. Независимые ошибки собираются `errors.Join` **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы разом; проверка собранного — по-прежнему через `errors.Is`. @@ -377,12 +381,13 @@ HTTP-клиентов, файловой системы, внешних SDK. перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт тот же список, но убивает ветвление: `errors.Is` по такому результату не находит ничего, и вызывающий остаётся с текстом, матчить который запрещено -(R11). +(GERR-11). ## Связано - `lang/go/logging.md` — где и когда ошибка попадает в лог. -- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14. +- `KEYS-7` (`arch/db-identifiers.md`) — формат корреляционного ключа + из `GERR-14`. diff --git a/conventions/lang/go/logging.md b/conventions/lang/go/logging.md index 2fdfd22..420c891 100644 --- a/conventions/lang/go/logging.md +++ b/conventions/lang/go/logging.md @@ -1,4 +1,5 @@ --- +prefix: SLOG extends: arch/time.md --- @@ -19,7 +20,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в ## Формат записи -### R1. Структурированный JSON, один формат для dev и prod +### SLOG-1. Структурированный JSON, один формат для dev и prod **ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в проде. @@ -31,7 +32,7 @@ dev-выводом перестаёшь ежедневно гонять собс значение) обнаруживаются только в проде, где заметить их заранее уже некому. -### R2. Данные — в типизированных полях, а не в тексте сообщения +### SLOG-2. Данные — в типизированных полях, а не в тексте сообщения **ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа. @@ -40,7 +41,7 @@ dev-выводом перестаёшь ежедневно гонять собс правке формулировки. Тип важен отдельно от ключа: число внутри строки не сравнивается и не суммируется, то есть попадает в лог, но не в отчёт. -### R3. Время записи — UTC +### SLOG-3. Время записи — UTC **ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey` (см. `lang/go/time.md`). @@ -58,7 +59,7 @@ dev-выводом перестаёшь ежедневно гонять собс ## Сообщение -### R4. `msg` — константа в нижнем регистре +### SLOG-4. `msg` — константа в нижнем регистре **ДОЛЖЕН.** Текст сообщения не собирается из переменных: `log.Info("download accepted", "download_id", id)`. @@ -69,7 +70,7 @@ dev-выводом перестаёшь ежедневно гонять собс одна категория не двоилась на варианты, различающиеся только заглавной буквой. -### R5. `msg` не несёт префикса подсистемы +### SLOG-5. `msg` не несёт префикса подсистемы **НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — отдельное поле. @@ -80,7 +81,7 @@ dev-выводом перестаёшь ежедневно гонять собс категория дробится на варианты с префиксом и без, а совпадать они обязаны посимвольно. -### R6. Смена состояния сущности — единая категория +### SLOG-6. Смена состояния сущности — единая категория **ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно состояние и по какой причине — данные, а не текст. @@ -91,37 +92,37 @@ dev-выводом перестаёшь ежедневно гонять собс останется неполной. Единая категория даёт весь цикл одним фильтром и не требует обновлять запрос вслед за кодом. -### R7. Физический эффект — отдельная запись, а не вместо перехода +### SLOG-7. Физический эффект — отдельная запись, а не вместо перехода **НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет запись самого перехода. -**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых +**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых был заметный эффект, — то есть самые интересные. Вторая запись стоит одной строки в логе; восстановление пропущенного перехода не стоит ничего, потому что невозможно. ## Уровни -### R8. Уровень выбирается по адресату +### SLOG-8. Уровень выбирается по адресату **ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько громко сломалось». | № | Уровень | Кому и когда | |---|---|---| -| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен | -| R8.2 | `INFO` | владельцу, аудит постфактум | -| R8.3 | `WARN` | владельцу, «может стать проблемой» | -| R8.4 | `ERROR` | владельцу, в разбор | +| SLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен | +| SLOG-8.2 | `INFO` | владельцу, аудит постфактум | +| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» | +| SLOG-8.4 | `ERROR` | владельцу, в разбор | **Почему.** Адресат — единственный признак, по которому разные авторы в разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый оценивает по-своему, шкала расползается — и вместе с ней теряет смысл -базовый порог в проде (R40), потому что он отсекает уже не то, что +базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что задумано. -### R9. Уровень не зависит от подсистемы +### SLOG-9. Уровень не зависит от подсистемы **НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` везде одинаково серьёзен. @@ -132,7 +133,7 @@ dev-выводом перестаёшь ежедневно гонять собс уровень перестаёт быть фильтром и становится подсказкой, требующей знания кода. -### R10. `WARN` — только когда «может стать проблемой» +### SLOG-10. `WARN` — только когда «может стать проблемой» **ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. @@ -141,22 +142,22 @@ dev-выводом перестаёшь ежедневно гонять собс единственное, ради чего уровень существует: предупреждение, на которое ещё есть время отреагировать. -### R11. Событийное — `INFO`, рутинно-частое — `DEBUG` +### SLOG-11. Событийное — `INFO`, рутинно-частое — `DEBUG` **ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие. | № | Операция | Уровень | |---|---|---| -| R11.1 | по реальному действию или изменению | `INFO` | -| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | +| SLOG-11.1 | по реальному действию или изменению | `INFO` | +| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | -**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность +**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность определяется долей записей, за которыми что-то стоит. Периодическая операция даёт ровный поток при нулевой информации, в котором настоящие события тонут количественно: их не отфильтровать, потому что фильтровать приходится по содержанию, а не по уровню. -### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата +### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата **ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую степень даёт завершение процесса. @@ -169,7 +170,7 @@ dev-выводом перестаёшь ежедневно гонять собс ## Поля: единый словарь -### R13. Одно поле — одно имя по всему коду +### SLOG-13. Одно поле — одно имя по всему коду **ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. @@ -178,14 +179,14 @@ dev-выводом перестаёшь ежедневно гонять собс часть записей в него не попадёт, и заметить это можно, только заранее зная, что они должны были быть. -### R14. Форма имени зависит от вида поля +### SLOG-14. Форма имени зависит от вида поля **ДОЛЖЕН.** Две формы, третьей нет. | № | Вид поля | Форма имени | |---|---|---| -| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | -| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | +| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | +| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | **Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые в любом проекте, от доменных, которые в каждом свои: по общему префиксу @@ -193,7 +194,7 @@ dev-выводом перестаёшь ежедневно гонять собс словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже названо, и спорить о них на каждом ревью. -### R15. Запись плоская +### SLOG-15. Запись плоская **НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть имени, а не уровень вложенности. @@ -203,32 +204,32 @@ dev-выводом перестаёшь ежедневно гонять собс заранее, а она у разных категорий разная — и один запрос перестаёт покрывать весь лог, распадаясь на запрос под каждую форму записи. -### R16. Набор полей определяется ситуацией +### SLOG-16. Набор полей определяется ситуацией **ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком. | № | Когда добавляем | Поля | |---|---|---| -| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — пока его значение различается между записями (R17) | -| R16.2 | работа с сущностью (scoped-логгер) | `_id` и доменные атрибуты | -| R16.3 | запись об ошибке | `error` | -| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | +| SLOG-16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — пока его значение различается между записями (SLOG-17) | +| SLOG-16.2 | работа с сущностью (scoped-логгер) | `_id` и доменные атрибуты | +| SLOG-16.3 | запись об ошибке | `error` | +| SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | **Почему.** Набор задан не «на всякий случай»: без него запись не отвечает на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, `ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас -баг», запись о сущности без идентификатора не корреллируется (R19). Полный +баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный набор делает записи однородными — один запрос работает по всем вызовам, а не по тем, где автор вспомнил про поле. -### R17. `service.*` и `host.*` не заводим +### SLOG-17. `service.*` и `host.*` не заводим **НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не заводится — для одного бинаря на одном хосте это `service.*` и `host.*`. **Почему.** Такое поле не несёт информации, но стоит места в каждой строке и внимания при чтении. Критерий один на все поля словаря — им же решается, -нужен ли `transport` (R16.1): пока транспорт один, поле постоянно. Условие +нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие названо явно, поэтому правило отпадёт вместе со своей причиной: с появлением нескольких инстансов различающее поле (`service.version`) добавляется одной строкой при старте. @@ -238,7 +239,7 @@ dev-выводом перестаёшь ежедневно гонять собс ## Корреляция -### R18. Ключ корреляции — идентификатор сущности, а не `trace_id` +### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id` **НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — @@ -251,13 +252,13 @@ dev-выводом перестаёшь ежедневно гонять собс способ спросить об одном. Условие применимости названо: там, где сущности со стабильным идентификатором нет, связывать записи больше нечем. -### R19. Запись о сущности несёт её идентификатор +### SLOG-19. Запись о сущности несёт её идентификатор **ДОЛЖЕН.** Поле `_id` в каждой записи, относящейся к сущности. **Почему.** Принадлежность записи восстанавливается только в момент записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. -Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем, +Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем, что идентификатор стоит везде, а не в удобных местах. Все записи одной операции собираются одним фильтром: @@ -265,7 +266,7 @@ dev-выводом перестаёшь ежедневно гонять собс глобально уникален across сущностей, штатно работает и простой `grep` по голому значению — он находит все упоминания независимо от имени поля. -### R20. Долгая операция ведётся scoped-логгером через `context.Context` +### SLOG-20. Долгая операция ведётся scoped-логгером через `context.Context` **СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные стадии: @@ -282,17 +283,17 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд ## Ошибки -### R21. Ошибка логируется атрибутом `error` +### SLOG-21. Ошибка логируется атрибутом `error` **ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`. -**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и +**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна зависеть от того, кто писал конкретный вызов, и ради этого единообразия краткостью жертвуют. -### R22. Промежуточный слой либо логирует, либо возвращает +### SLOG-22. Промежуточный слой либо логирует, либо возвращает **НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только оборачивает (`%w`). @@ -300,44 +301,44 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд **Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, и количество `ERROR` перестаёт соответствовать количеству отказов — а считают именно его. Контекст при этом не теряется: он накапливается в -цепочке обёрток и попадает в единственную запись на границе (R23). +цепочке обёрток и попадает в единственную запись на границе (SLOG-23). -### R23. Ошибка логируется один раз — на границе доменного слоя +### SLOG-23. Ошибка логируется один раз — на границе доменного слоя **ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. **Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и этим местом выбрана доменная граница, а не транспорт, потому что там -известен исход операции целиком и, значит, класс отказа (R25) — транспорт +известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: транспорты остаются тонкими. -### R24. Транспорт не логирует ошибку повторно +### SLOG-24. Транспорт не логирует ошибку повторно **НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ (статус, сообщение пользователю) и на этом останавливается. -**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё +**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё только формулировкой и читается как второй сбой. Когда транспортов над одним доменом несколько, дублирование ещё и множится, а расследование начинается с вопроса, один это инцидент или два. -### R25. Уровень доменного отказа — по классу отказа +### SLOG-25. Уровень доменного отказа — по классу отказа -**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по +**ДОЛЖЕН.** Уровень выбирает единственный логирующий (SLOG-23), и выбирает по классу, а не по месту в коде. Классификация покрывает **доменные** отказы — те, что операция вернула значением `error`. | № | Класс отказа | Кому | Уровень | |---|---|---|---| -| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | -| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | -| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | +| SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | +| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | +| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | -**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на +**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на экране — владельцу разбирать нечего; целостность первичных данных отделяет «надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный уровень для одного и того же отказа в зависимости от того, какой транспорт @@ -350,9 +351,9 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё нет, потому что её просто забыли завести. Она логируется `ERROR` с -признаком непокрытой (`lang/go/errors.md` R25). +признаком непокрытой (`GERR-25`). -### R26. Тот же отказ в асинхронной стадии — уровнем выше +### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше **ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, @@ -363,7 +364,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд никто, задача осталась недоведённой, и лог — единственное место, где это вообще проявится. -### R27. Повторяющийся сбой фонового цикла — `WARN` +### SLOG-27. Повторяющийся сбой фонового цикла — `WARN` **ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: уровень задаёт наличие штатного повтора, а не текст ошибки. @@ -376,32 +377,32 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд ## Внешние сервисы -### R28. Каждый вызов внешнего сервиса логируется +### SLOG-28. Каждый вызов внешнего сервиса логируется -**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4. +**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4. **Почему.** Это единственный способ отличить «у нас баг» от «зависимость легла»: на своей стороне видно лишь то, что операция не удалась. Выборочное логирование ломает и второе применение — доля неуспехов и распределение `duration_ms` считаются, только если знаменатель полный. -### R29. Уровень `ext`-записи — по исходу вызова +### SLOG-29. Уровень `ext`-записи — по исходу вызова **ДОЛЖЕН.** Исход считается по одному вызову с его ретраями. | № | Исход | Уровень | |---|---|---| -| R29.1 | успешный событийный вызов | `INFO` | -| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` | -| R29.3 | попытка не удалась, делается retry | `WARN` | -| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | +| SLOG-29.1 | успешный событийный вызов | `INFO` | +| SLOG-29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` | +| SLOG-29.3 | попытка не удалась, делается retry | `WARN` | +| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | **Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы уровень непригодным для главного вопроса «зависимость доступна?». Исчерпание ретраев и есть момент, когда транспорт сдался и дальше -разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение -событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет +разбираться владельцу. Различение SLOG-29.1 и SLOG-29.2 — то же самое разделение +событийного и рутинного, что в SLOG-11: поллинг внешнего сервиса зашумляет аудит так же, как любой другой. ## Два цикла повтора — не путать @@ -412,8 +413,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд уровень доменной записи об исходе тика. ``` -WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4) -AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27) +WHEN зависимость недоступна и ретраи вызова исчерпаны + → ext-запись `ERROR` (SLOG-29.4) +AND тик фонового цикла упал по той же причине + → доменная запись `WARN` (SLOG-27) ``` Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR` @@ -422,7 +425,7 @@ AND тик фонового цикла упал по той же причине `ERROR` от поллинга мешает — это лечится понижением частоты тика или подавлением повторов в самом клиенте, а не переклассификацией уровня. -### R30. Ответ 4xx — успех на транспортном уровне +### SLOG-30. Ответ 4xx — успех на транспортном уровне **ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов (`ext.status_code` записан); решение «это ошибка» принимает доменный @@ -437,38 +440,38 @@ AND тик фонового цикла упал по той же причине ## HTTP и healthcheck -### R31. Входящий запрос — `INFO` независимо от кода ответа +### SLOG-31. Входящий запрос — `INFO` независимо от кода ответа -**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа. +**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа. **Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и когда приходил», и ценность у неё одинаковая при любом коде ответа. Уровень, зависящий от кода, делает аудит неполным именно на тех запросах, которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись -(R25) — она и адресована по-другому. +(SLOG-25) — она и адресована по-другому. -### R32. Для корреляции запроса допустим `request_id` +### SLOG-32. Для корреляции запроса допустим `request_id` **ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности. **Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id` -правило R18. Не запрещает: R18 отказывается от случайного ключа там, где +правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной сущности нет — связать его записи между собой больше нечем. -### R33. Healthcheck, liveness, readiness — `DEBUG` +### SLOG-33. Healthcheck, liveness, readiness — `DEBUG` **ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. -**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают +**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из -аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в +аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся доступной при отладке. ## Безопасность: что не логируем -### R34. Секреты не логируются +### SLOG-34. Секреты не логируются **НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий, API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры @@ -479,17 +482,17 @@ API-ключи и токены, `Authorization`-заголовки, аутент с момента записи, а не с момента, когда это заметили, и вычистить его задним числом из уже собранных копий нельзя. -### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки +### SLOG-35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки **ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — `DEBUG`, с вычисткой секретов и обрезкой по длине. **Почему.** Содержимое пришло снаружи: размер не ограничен, состав неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` -выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией; +выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией; обрезка не даёт одной записи вытеснить весь остальной лог за период. -### R36. При сомнении логируется факт, а не значение +### SLOG-36. При сомнении логируется факт, а не значение **СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. @@ -499,7 +502,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент когда чувствительность значения ещё неочевидна, а перечитывать этот выбор никто не придёт. -### R37. `*url.Error` санитизируется на границе клиента +### SLOG-37. `*url.Error` санитизируется на границе клиента **ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до обёртки — раньше трансляции в доменную (`lang/go/errors.md`). @@ -513,12 +516,12 @@ API-ключи и токены, `Authorization`-заголовки, аутент причину сохраняется); альтернатива с редактированием URL сохранила бы структуру, но сложнее. -### R38. Секрет не кладётся в URL, если у API есть заголовок +### SLOG-38. Секрет не кладётся в URL, если у API есть заголовок **НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого способа нет. -**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и +**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и в любую запись, куда URL попал целиком, — то есть обязывает помнить про санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. @@ -528,7 +531,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент ## Куда пишем -### R39. Логи идут в `stdout` одним потоком +### SLOG-39. Логи идут в `stdout` одним потоком **ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам не маршрутизируем. @@ -539,23 +542,23 @@ API-ключи и токены, `Authorization`-заголовки, аутент Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам теряет его ровно там, где важен ход событий. -### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev +### SLOG-40. Базовый уровень — `INFO` в проде и `DEBUG` в dev **ДОЛЖЕН.** `DEBUG` в проде включается конфигом. **Почему.** Уровень — единственный регулятор объёма, доступный без пересборки; если `DEBUG` в проде включается только правкой кода, его не включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, -что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2). +что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2). ## Связано - `arch/time.md` — точность и зона меток времени фиксируются на носитель. -- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3). +- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (SLOG-3). - `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно - санитизации (R37). + санитизации (SLOG-37). - `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы, - на которых держится корреляция (R18). + на которых держится корреляция (SLOG-18). diff --git a/conventions/lang/go/time.md b/conventions/lang/go/time.md index 63e607e..bdb9bb7 100644 --- a/conventions/lang/go/time.md +++ b/conventions/lang/go/time.md @@ -1,4 +1,5 @@ --- +prefix: GTIM extends: arch/time.md --- @@ -10,7 +11,7 @@ extends: arch/time.md ## Правила -### R1. «Сейчас» берётся у слоя хранилища +### GTIM-1. «Сейчас» берётся у слоя хранилища **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего `time.Now().UTC()`, а не из `time.Now()` по коду. @@ -24,28 +25,28 @@ extends: arch/time.md придётся превратить в переменную или поле, если однажды понадобится подменять часы, но само по себе оно подмены не даёт. -### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime` +### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime` **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ получить строку времени и прочитать её обратно. **Почему.** Layout, набранный по месту вызова, превращает формат хранения в -свойство каждой отдельной строки кода. Фиксированная ширина (R4) и +свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и взаимная обратимость записи и чтения держатся ровно до первого второго layout — а расхождение проявится не на записи, а при сравнении значений, записанных разными местами. -### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий +### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий **ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений ровно два, и оба прописаны явно: -| № | Исключение | Почему оно не покрывается R1 | +| № | Исключение | Почему оно не покрывается GTIM-1 | |---|---|---| -| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | -| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) | +| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | +| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) | -**Почему.** R1 без механической проверки держится на внимании, а +**Почему.** GTIM-1 без механической проверки держится на внимании, а `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. Исключения перечисляются исчерпывающе, потому что каждое из них — само по @@ -53,23 +54,23 @@ layout — а расхождение проявится не на записи, «починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить сама себе. -### R13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера +### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера -**ДОЛЖЕН.** Исключение из R3 оформляется как `//nolint:forbidigo // <причина>` -на строке вызова; exclude-записи в конфигурации линтера для него не -заводятся. +**ДОЛЖЕН.** Исключение из GTIM-3 оформляется как +`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в +конфигурации линтера для него не заводятся. **Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует исключение путём к файлу, отвязывается при переносе кода и продолжает разрешать `time.Now()` там, где исключения уже нет, — молча. Директива переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список — -та исчерпываемость, которой требует R3, проверяется одной командой. Голый +та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый `//nolint` без имени правила глушит на строке все проверки сразу, а без причины неотличим от заглушенного дефекта; обе деградации штатно ловит `nolintlint` (`require-specific`, `require-explanation`) — стандартный способ дисциплинировать директивы в golangci-lint. -### R4. В БД время хранится с секундной точностью, ширина 20 символов +### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. @@ -83,16 +84,16 @@ layout — а расхождение проявится не на записи, Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды, поэтому `Format` их не выведет. -### R5. `time.RFC3339Nano` не используется +### GTIM-5. `time.RFC3339Nano` не используется **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. **Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит от значения: соседние записи получают разную ширину, и свойство, на котором -держится R4, исчезает незаметно. Проверка «формат корректен» при этом +держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом проходит — отказывает только порядок. -### R6. Чужой вход нормализуется явно +### GTIM-6. Чужой вход нормализуется явно **ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится к каноническому виду явно, а не считается каноническим по факту успешного @@ -101,21 +102,21 @@ layout — а расхождение проявится не на записи, **Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует **писатель**, а не читатель; пока писатель один, этого достаточно, но -значение из чужой системы, положенное в базу как пришло, нарушает R4 и +значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и обнаруживается не на записи, а на первой сортировке. Само решение -«нормализовать, а не отклонять» — базовое (`arch/time.md` R13); здесь — +«нормализовать, а не отклонять» — базовое (`TIME-13`); здесь — Go-механика, из-за которой его легко нарушить незаметно. -### R7. В драйвер передаётся строка, а не `time.Time` +### GTIM-7. В драйвер передаётся строка, а не `time.Time` **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. **Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование -драйверу: появляется вторая точка формата вне `FormatTime` (R2), с +драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с собственным layout, который меняется вместе с версией драйвера, а не вместе с конвенцией. -### R8. Время в логах приводится к UTC через `ReplaceAttr` +### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr` **ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`: @@ -134,17 +135,17 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { неверная зона выглядит как совершенно валидное время, а записи из разных мест перестают складываться в одну хронологию с метками хранилища. -### R9. Точность времени в логах отличается от точности в БД +### GTIM-9. Точность времени в логах отличается от точности в БД **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не -приводится к секундной точности R4. +приводится к секундной точности GTIM-4. -**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование +**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование одной точности везде. Ширина фиксируется на носитель: три знака в логе — -такая же фиксированная ширина, и свойство, ради которого R4 существует, не -нарушено. Общее у лога и базы одно — зона (R8). +такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не +нарушено. Общее у лога и базы одно — зона (GTIM-8). -### R10. Обёртка измерения длительности берёт `time.Now()` напрямую +### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с локальным `//nolint`. @@ -153,10 +154,10 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким меткам, зависит от подводки часов: перевод назад даёт отрицательную длительность, скачок вперёд — выброс в измерениях, и оба случая -невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается +невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается как недосмотр и его «чинят». -### R11. `time/tzdata` импортируется в `main` +### GTIM-11. `time/tzdata` импортируется в `main` **ДОЛЖЕН.** База зон вшивается в бинарь. @@ -166,7 +167,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { `main` держит это решение в одном видимом месте, а не в случайном пакете, откуда его удаляют при чистке зависимостей. -### R12. Зона отображения применяется только в UI +### GTIM-12. Зона отображения применяется только в UI **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах представления, но не в хранимых значениях и не в вычислениях. diff --git a/conventions/prefixes.toml b/conventions/prefixes.toml new file mode 100644 index 0000000..be783f5 --- /dev/null +++ b/conventions/prefixes.toml @@ -0,0 +1,44 @@ +# Реестр префиксов правил. +# +# Префикс — четыре заглавные латинские буквы, уникальные по всему канону. +# Он выбирается под файл, а не выводится по формуле: префикс нужен, чтобы +# по нему искать, а не чтобы его разбирать. Поэтому подходящее слово лучше +# закономерности. +# +# Правила реестра: +# +# - префикс не переименовывается и не переиспользуется никогда — ссылка +# из чужого репозитория обязана продолжать указывать на то же место; +# - при удалении или разделении файла префикс уходит в [retired], а не +# освобождается; +# - переезд файла между осями префикс не меняет: идентификатор правила +# не зависит от таксономии; +# - вынос части правил в новый файл — это новый префикс и новая +# нумерация: перенос правила между документами есть смысловое +# изменение, а не переименование; +# - тот же префикс продублирован в шапке файла (`prefix:`), conv сверяет. +# +# Локальные правила репозиториев берут свои префиксы и объявляют их в +# `.conventions.toml` копии. Они обязаны не пересекаться с этим реестром. + +[live] +DIRS = "arch/app-directories.md" +CONF = "arch/config.md" +KEYS = "arch/db-identifiers.md" +TIME = "arch/time.md" +GCFG = "lang/go/config.md" +GKEY = "lang/go/db-identifiers.md" +MIGR = "lang/go/db-schema.md" +GERR = "lang/go/errors.md" +SLOG = "lang/go/logging.md" +GTIM = "lang/go/time.md" +ANSD = "stack/ansible/app-directories.md" +HTMX = "stack/htmx/web-ui.md" + +# Обвязка канона: не синхронизируется в репозитории, но правила +# записаны тем же языком и цитируются по номерам, поэтому префикс нужен. +META = "../GUIDE.md" + +[retired] +# Пусто. Сюда попадают префиксы удалённых и разделённых файлов вместе с +# причиной и датой, чтобы их нельзя было выдать повторно. diff --git a/conventions/stack/ansible/app-directories.md b/conventions/stack/ansible/app-directories.md index c508d22..03cc9be 100644 --- a/conventions/stack/ansible/app-directories.md +++ b/conventions/stack/ansible/app-directories.md @@ -1,4 +1,5 @@ --- +prefix: ANSD extends: arch/app-directories.md --- @@ -15,7 +16,7 @@ extends: arch/app-directories.md ## Правила -### R1. Каждая директория объявлена переменной `*_dir` +### ANSD-1. Каждая директория объявлена переменной `*_dir` **ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри `base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на @@ -24,11 +25,11 @@ extends: arch/app-directories.md `uploads_dir`, `dumps_dir`). **Почему.** Переменная — единственная ссылка, которую разделяют задача -создания директории и список бэкапа (R4). Литерал пути в одном из этих мест +создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест означает, что переименование директории молча разойдётся с бэкапом, и обнаружится это при восстановлении. -### R2. Директории создаются одной задачей циклом по списку +### ANSD-2. Директории создаются одной задачей циклом по списку **СЛЕДУЕТ.** Список директорий в единственной задаче создания. @@ -38,7 +39,7 @@ extends: arch/app-directories.md всего плейбука, а именно этот вопрос задают при заведении бэкапа и при разборе места на диске. -### R3. Владелец директорий — пользователь, от имени которого работает приложение +### ANSD-3. Владелец директорий — пользователь, от имени которого работает приложение **ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение (`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на @@ -55,18 +56,18 @@ extends: arch/app-directories.md -### R4. Список бэкапа собирается из тех же переменных +### ANSD-4. Список бэкапа собирается из тех же переменных **ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки -которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути. +которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути. -**Почему.** Правило вывода списка механическое (R5), но применяет его +**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его человек или шаблон — то есть ошибиться можно. Общая переменная делает целый класс ошибок невозможным: переименовал директорию — переименовалось в обоих местах. Независимо набранный список расходится тихо и проявляется в единственный момент, когда это уже неисправимо. -### R5. В список бэкапа идут только данные +### ANSD-5. В список бэкапа идут только данные **ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в списке; конфигурация и кеш — нет. @@ -75,7 +76,7 @@ extends: arch/app-directories.md пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в облако, и источником истины для секретов остаётся vault, а не снапшот. -### R6. Конфигурация монтируется только на чтение +### ANSD-6. Конфигурация монтируется только на чтение **СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. @@ -85,7 +86,7 @@ extends: arch/app-directories.md незаметно. Приложение, которому запись в конфиг нужна по устройству, монтируется на запись — это отступление, и оно записывается. -### R7. `docker-compose.yml` лежит в корне `base_dir` +### ANSD-7. `docker-compose.yml` лежит в корне `base_dir` **ДОЛЖЕН.** Файл не переносится во вложенную директорию. @@ -94,7 +95,7 @@ extends: arch/app-directories.md порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы место. -### R8. Секреты рендерятся в файл конфигурации +### ANSD-8. Секреты рендерятся в файл конфигурации **СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, принадлежащий пользователю приложения. @@ -104,14 +105,14 @@ extends: arch/app-directories.md довода, по которым базовая конвенция конфигурации выбирает файл вместо окружения. -### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log` +### ANSD-9. Когда приложение не умеет файловые секреты — `environment` под `no_log` **ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. -**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на +**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на деплой такого приложения. Способ вынужденный: секрет попадает в метаданные контейнера и в compose-файл на диске. Приложение, научившееся читать -секреты из файла, переводится на R8 при ближайшем касании. +секреты из файла, переводится на ANSD-8 при ближайшем касании. diff --git a/conventions/stack/htmx/web-ui.md b/conventions/stack/htmx/web-ui.md index b99b858..90cffb0 100644 --- a/conventions/stack/htmx/web-ui.md +++ b/conventions/stack/htmx/web-ui.md @@ -1,3 +1,7 @@ +--- +prefix: HTMX +--- + # Веб-UI на htmx Как пишется код веб-UI: частичный своп фрагментов, поллинг живых @@ -18,7 +22,7 @@ ## Стек и границы -### R1. Стек: роутер, серверные шаблоны, htmx +### HTMX-1. Стек: роутер, серверные шаблоны, htmx **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, без Node и бандлера, без реактивного фреймворка. @@ -26,12 +30,12 @@ **Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и артефакт, который расходится с исходником; приложению, где разметку целиком отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую -модель состояния рядом с серверной (R2), и дальше на каждом экране +модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране приходится решать, какая из них главная. Сам htmx — вендорный ассет и -живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму -приложения аптайм чужого хоста. +живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к +аптайму приложения аптайм чужого хоста. -### R2. Клиент не пересчитывает доменное состояние +### HTMX-2. Клиент не пересчитывает доменное состояние **НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно (копирование в буфер обмена и подобное); доменное состояние считает сервер, @@ -40,24 +44,24 @@ **Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в базе другое». Вдобавок клиентский пересчёт по определению не работает в -деградированном режиме (R11, R12) — значит, серверную версию того же +деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же вычисления всё равно придётся держать. -### R3. Реактивный слой вводится отдельным решением +### HTMX-3. Реактивный слой вводится отдельным решением **НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей — только когда есть виджет, которому он действительно нужен, и отдельным решением. **Почему.** Реактивный слой, попавший в проект ради одного выпадающего -списка, немедленно доступен всему остальному коду — и граница R1/R2 +списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2 перестаёт держаться сама собой. Отдельное решение — единственный момент, когда цену видно целиком: она не в килобайтах, а в том, что дальше на каждом экране есть выбор между двумя моделями состояния. ## Единый источник разметки -### R4. Партиал = страница = фрагмент +### HTMX-4. Партиал = страница = фрагмент **ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент @@ -68,7 +72,7 @@ же региона. Заметно это становится только на глаз и только тому, кто открыл оба пути подряд. -### R5. Корень партиала — элемент с целевым `id` +### HTMX-5. Корень партиала — элемент с целевым `id` **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют регион, и ответный фрагмент несёт тот же `id`. @@ -79,19 +83,19 @@ находят таргет: регион застывает без единой ошибки — ни в консоли, ни в логе. -### R6. Сборку view делает общая функция +### HTMX-6. Сборку view делает общая функция **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и htmx-ветка. -**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не +**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не одинаковые данные: скопированная сборка view расходится по набору полей, и фрагмент начинает показывать не то, что показала бы страница. Это ровно тот -класс расхождений, который R4 закрывает для разметки. +класс расхождений, который HTMX-4 закрывает для разметки. ## Обработчик действия -### R7. Доменный вызов одинаков для htmx и обычного запроса +### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса **ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку `HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только @@ -99,8 +103,8 @@ htmx-ветка. | № | Запрос | Ответ | |---|---|---| -| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию | -| R7.2 | обычный | PRG-редирект (303) | +| HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию | +| HTMX-7.2 | обычный | PRG-редирект (303) | ```go actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx @@ -119,12 +123,12 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **Почему.** Ветвление до вызова даёт две реализации одного действия, и дальше дефект воспроизводится только на одной поверхности — причём -деградированный путь (R11) открывают реже, то есть чинить будут не тот. +деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот. Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион целиком: view, собранный из аргументов запроса, покажет намерение, а не результат. -### R8. Шаблон рендерится в буфер, потом в ответ +### HTMX-8. Шаблон рендерится в буфер, потом в ответ **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем буфер пишется в ответ. @@ -136,30 +140,30 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб ## Одно действие — два региона -### R9. Второй регион едет тем же ответом через `hx-swap-oob` +### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob` **СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным -партиалом с тем же `id`, что и на странице (R4, R5). +партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5). **Почему.** Второй запрос с клиента вводит гонку: два ответа считают состояние в разные моменты и приезжают в произвольном порядке, поэтому панель действий может отразить состояние до действия. Плюс лишний раунд-трип на каждое действие. -### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия +### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй регион меняется не на каждое действие. -**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого +**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого второго запроса. Когда регион обновляется редко, oob-ветка гоняет одинаковую разметку на каждое действие и связывает два шаблона там, где связи нет; гонка же тем менее наблюдаема, чем реже обновление. ## Graceful degradation -### R11. Форма действия работает без JS +### HTMX-11. Форма действия работает без JS **ДОЛЖЕН.** Действие — обычная `
`, на которую `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на @@ -170,24 +174,24 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб ничего, молча. Тот же `action` — единственное, что делает действие проверяемым без браузера с JS. -### R12. Фильтр, поиск и пагинация — серверные +### HTMX-12. Фильтр, поиск и пагинация — серверные **ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; клиентской фильтрации загруженной разметки нет. **Почему.** Клиент видит только текущую страницу списка, поэтому клиентский фильтр отвечает по неполным данным и делает это молча — результат выглядит -валидным. Вдобавок состояние отбора в query переживает своп (R25) и +валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и перезагрузку, его можно послать ссылкой и увидеть в логе. -### R13. Область обязательной деградации +### HTMX-13. Область обязательной деградации **ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI: | № | Поверхность | Поведение без JS | |---|---|---| -| R13.1 | действия и навигация | работают полностью (R11, R12) | -| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | +| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) | +| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | **Почему.** Без явной границы правило деградации читается как запрет на любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от @@ -197,7 +201,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб ## Ошибки на htmx-пути -### R14. Ошибка действия на htmx-пути — 200 с фрагментом +### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом **ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. @@ -205,44 +209,44 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб **Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть пользователь не увидит ничего. Своп ошибочных ответов настраивается (`htmx.config.responseHandling`, расширение `response-targets`), но любая -такая настройка — свой JS-конфиг на клиенте, и платится она из R1 и R2. +такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2. Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и -её решает глобальный слушатель (R34). Для REST API и не-JS редиректа с +её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с `?err=` статус по-прежнему используется: там его кто-то читает. Цена решения: в логе доступа провалившееся действие выглядит как `200`. Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`), а не по коду ответа. -### R34. Сбой без ответа-фрагмента показывается глобальным слушателем +### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем **ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и `htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп ошибочных ответов в целевые регионы (`htmx.config.responseHandling`, `response-targets`) не настраивается. -**Почему.** R14 закрывает доменный отказ, до которого обработчик дошёл. +**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл. Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не свопит — регион не меняется, интерфейс замирает без единого признака сбоя, и пользователь повторяет действие, которое могло уже примениться. Слушатель -— несколько строк без доменного состояния, то есть внутри границы R2, и он -не спорит с R14: там настройки отвергнуты как замена фрагменту, который +— несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он +не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт целевого `id`, и после первого же такого свопа регион перестаёт находиться -(R5). +(HTMX-5). -### R15. Наружу идёт сообщение публичного канала +### HTMX-15. Наружу идёт сообщение публичного канала **ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам `lang/go/errors.md`; `err.Error()` в разметку не рендерится. **Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём легче всего забыть, что это тот же публичный канал, что и страница: -разметка уезжает в браузер пользователя целиком. Статус 200 (R14) +разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14) дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». -### R16. Сообщение об ошибке — в отдельном поле view +### HTMX-16. Сообщение об ошибке — в отдельном поле view **ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под сообщение не переиспользуются. @@ -251,9 +255,9 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего -требует R17. +требует HTMX-17. -### R17. При ошибке активное состояние не меняется +### HTMX-17. При ошибке активное состояние не меняется **НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает прежний выбор плюс сообщение. @@ -276,7 +280,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб {{end}} ``` -### R18. Поллер самозавершается +### HTMX-18. Поллер самозавершается **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без `hx-*`-атрибутов. @@ -287,10 +291,10 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб это единственный канал, которым сервер управляет поллером. Встроенная альтернатива — ответ со статусом 286 — не используется: она не -совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом +совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом и тоже без поллера. -### R19. Условие живости ведёт собственное состояние приложения +### HTMX-19. Условие живости ведёт собственное состояние приложения **ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет приложение, а не по ответу внешнего сервиса. @@ -300,18 +304,18 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб останавливается никогда. Приложение — единственный участник, который знает про операцию всё и может ответить на каждом тике. -### R20. Поллер свопит фрагмент целиком через `outerHTML` +### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML` **ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его содержимое. **Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и инициализирует новый — так поллер живёт ровно в одном экземпляре и так же -выключается (R18). Своп содержимого оставил бы старый узел с его таймером, +выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером, и через несколько обновлений опрос шёл бы в несколько потоков. Работает это -при совпадении корневого `id` (R5). +при совпадении корневого `id` (HTMX-5). -### R21. Поллер не свопит контейнер с активными полями ввода +### HTMX-21. Поллер не свопит контейнер с активными полями ввода **НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где редактировать нечего. @@ -321,7 +325,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб пользователь не выбирал: текст исчезает посреди набора и воспроизводится как «приложение стирает мой ввод». -### R22. Браузер не ходит во внешний сервис напрямую +### HTMX-22. Браузер не ходит во внешний сервис напрямую **НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. @@ -330,14 +334,14 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб контракт внешнего сервиса протекает в разметку: его смена перестаёт быть серверным изменением. -### R23. Источник данных для тика +### HTMX-23. Источник данных для тика **ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть: | № | Что показывает тик | Откуда берёт | |---|---|---| -| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | -| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | +| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | +| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | **Почему.** Тик умножается на число открытых вкладок, поэтому сеть на каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и @@ -348,7 +352,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб собственного состояния той же цены нет: хранилище и так своё, а лишний слой кеша добавил бы только рассинхрон. -### R24. Поллинг URL страницы вместо отдельного фрагмент-роута +### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута **ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт на URL самой страницы, а нужный узел вырезается `hx-select`: @@ -361,24 +365,24 @@ hx-select="#item-main" hx-swap="outerHTML" **Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует обработчик страницы целиком — вместе с перечитыванием состояния и сборкой view, — и дальше два обработчика расходятся по тому же сценарию, что и две -копии разметки (R4). +копии разметки (HTMX-4). -Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот +Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот же узел, который свопится. ## Своп и выход со страницы -### R25. Действие не уводит со страницы, если предмет остаётся на ней +### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. **Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и -пагинацию — они в query (R12). Полная навигация ради изменения одного +пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного региона возвращает пользователя в начало списка и стоит перерисовки всей страницы. Не сохраняется при свопе только контекст внутри самого -заменяемого поддерева — фокус, выделение, введённый текст (R21). +заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21). -### R26. Выход со страницы — форма без `hx-*` +### HTMX-26. Выход со страницы — форма без `hx-*` **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся обычной POST-формой без htmx-атрибутов, то есть полной навигацией. @@ -389,10 +393,10 @@ htmx-атрибутов при этом само работает маркеро прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ сменить страницу, существующий только на htmx-пути. -### R27. Асинхронное действие свопит промежуточное состояние +### HTMX-27. Асинхронное действие свопит промежуточное состояние **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает -промежуточное состояние, а итог догоняет самозавершающийся поллер (R18). +промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18). **Почему.** Мнимый результат расходится с сервером до следующего тика, и всё это время пользователь принимает решения по несуществующему исходу — @@ -401,7 +405,7 @@ htmx-атрибутов при этом само работает маркеро ## Различение поверхности одного действия -### R28. Поверхность различается скрытым полем формы +### HTMX-28. Поверхность различается скрытым полем формы **ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается фрагментом, поверхность передаётся явным скрытым полем @@ -413,18 +417,18 @@ htmx-атрибутов при этом само работает маркеро действием, поэтому связь «эта страница → этот фрагмент» читается там, где её заводят. -### R35. Запрос без поля поверхности получает 400 +### HTMX-35. Запрос без поля поверхности получает 400 -**ДОЛЖЕН.** Обработчик, различающий поверхности (R28), отвечает статусом +**ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом 400, когда поля `surface` в запросе нет; поверхность по умолчанию не выбирается. **Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие — дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после -чего регион перестаёт находиться таргетом (R5), и ошибка воспроизводится +чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением -глобального слушателя (R34) — сразу и на той странице, где форму сломали. +глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали. Вкладка, открытая до появления поля, получает тот же 400 и чинится перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной разметке. @@ -434,7 +438,7 @@ htmx-атрибутов при этом само работает маркеро Раздел не про htmx — это упаковка любого server-rendered приложения; разъедется в языковой слой, когда понадобится там. -### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем +### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем **ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с `Cache-Control: public, max-age=31536000, immutable`. @@ -442,21 +446,21 @@ htmx-атрибутов при этом само работает маркеро **Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго шага раскладки файлов, который может отстать от бинаря и оставить новую разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL -меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы -способом навсегда закрепить у пользователя старый файл. +меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша +был бы способом навсегда закрепить у пользователя старый файл. -### R30. Меняемые ассеты версионируются хешем содержимого +### HTMX-30. Меняемые ассеты версионируются хешем содержимого **ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL строит хелпер шаблона. **Почему.** Хеш содержимого — единственная версия, которую невозможно забыть обновить: она меняется от самой правки. Ручной номер и дата сборки -от этого не защищают, а цена промаха при иммутабельном кэше (R29) — +от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) — устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы хеш не проставляли в каждом шаблоне руками. -### R31. Вендорный ассет в `?v=` не нуждается +### HTMX-31. Вендорный ассет в `?v=` не нуждается **ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без параметра версии. @@ -464,9 +468,9 @@ htmx-атрибутов при этом само работает маркеро **Почему.** Содержимое под этим именем не меняется: обновление вендора приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от подмены содержимого под тем же адресом, а такой ситуации здесь нет — и -явное разрешение снимает вопрос, не нарушает ли это R30. +явное разрешение снимает вопрос, не нарушает ли это HTMX-30. -### R32. Вендор не коммитится, а добывается по манифесту +### HTMX-32. Вендор не коммитится, а добывается по манифесту **ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту (`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой @@ -478,7 +482,7 @@ diff'е — у закоммиченного минифицированного единственная проверка, что скачали то же самое, что проверяли; зависимость сборки от задачи не даёт собраться без ассета в свежем клоне. -### R33. Шрифты и скрипты — self-hosted +### HTMX-33. Шрифты и скрипты — self-hosted **ДОЛЖЕН.** Внешних хостов во время выполнения нет.