From 31d0620f559901852973eb89fa8114aa3eab95e6 Mon Sep 17 00:00:00 2001 From: Anton Vakhrushev Date: Sat, 25 Jul 2026 19:17:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BE=D1=81=D1=82=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BA=D0=BE=D0=BD=D0=B2=D0=B5=D0=BD=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=8F=D0=B7=D1=8B=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 11 файлов разобраны на нумерованные правила: 220 правил в каноне, у каждого модальность и обязательный блок «Почему» - классифицирующие места оформлены таблицами, файловый статус снят отовсюду, локальные регионы сохранены под прежними именами --- arch/app-directories.md | 217 ++++++++----- arch/config.md | 264 +++++++++++---- arch/time.md | 191 ++++++++--- common/conventions-guide.md | 291 +++++++++++------ lang/go/config.md | 221 ++++++++++--- lang/go/db-identifiers.md | 140 ++++++-- lang/go/db-schema.md | 205 ++++++++++-- lang/go/errors.md | 384 ++++++++++++++++------ lang/go/logging.md | 622 +++++++++++++++++++++++++++--------- lang/go/time.md | 186 ++++++++--- stack/htmx/web-ui.md | 494 +++++++++++++++++++++------- 11 files changed, 2404 insertions(+), 811 deletions(-) diff --git a/arch/app-directories.md b/arch/app-directories.md index 3872399..16652d9 100644 --- a/arch/app-directories.md +++ b/arch/app-directories.md @@ -1,89 +1,146 @@ ---- -status: рекомендуемая ---- - # Категории директорий приложения Всё, что приложение пишет на диск, делится на три категории по принципу -создания и ценности содержимого: - -- **конфигурация** — то, что восстанавливается прогоном деплоя, в том числе - секреты; -- **данные** — то, что генерирует приложение и что нужно бэкапить; -- **кеш** — то, что генерирует приложение и что не нужно бэкапить: - приложение перегенерирует заново. - -Цель — упростить оперирование данными. Категория сразу отвечает на два -вопроса, которые иначе приходится выяснять по коду приложения: **кто -создаёт** содержимое и **что будет, если его потерять**. - -## Категории - -| Категория | Директория | Создаёт | Потеря содержимого | Бэкап | -| --- | --- | --- | --- | --- | -| Конфигурация | `config/` | деплой | восстанавливается прогоном | не нужен | -| Данные | `data/` | приложение | невосполнима | обязателен | -| Кеш | `cache/` | приложение | приложение перегенерирует | не нужен | - -Имена в таблице — умолчание для случая «одна директория на категорию». -**Категория может состоять из нескольких директорий**, и это нормально: -крупные файлы отделяют от базы, чтобы двигать их между дисками независимо -(`media/`, `uploads/` — та же категория «данные», что и `data/`). -Принадлежность к категории задаётся не именем, а участием в списке бэкапа. - -Тест на границе данных и кеша: что будет, если сделать `rm -rf` и поднять -приложение заново. Поднимется само и наверстает — кеш. Не поднимется или -поднимется пустым — данные. - -Конфигурацию бэкапить не только не нужно, но и не стоит: там лежат секреты, -а бэкапы уезжают в облако. Источник истины для конфигурации — репозиторий и -хранилище секретов, а не снапшот бэкапа. - -## Данные, которые нельзя копировать на живую - -Файловый снапшот работающей СУБД не гарантирует консистентности: -скопированный каталог может не восстановиться. Поэтому у категории «данные» -есть два способа попасть в бэкап: - -- **копированием** — если файлы самодостаточны на любой момент времени; -- **дампом** — если консистентность обеспечивает только сама СУБД. Тогда - бэкапится директория дампов, а сырой каталог базы — нет. - -Директория дампов — тоже данные, просто производные. Решение «копировать -или дампить» принимается **при заведении приложения**, а не при первой -неудачной попытке восстановления. - -## Контракт с приложением - -Категории — не только про деплой. Приложение **разводит свои записываемые -пути по категориям в конфигурации**, а не складывает всё в один каталог: -иначе категорию нельзя определить снаружи и список бэкапа приходится -составлять вручную, читая код. - -- Путь к БД, загруженным файлам, сгенерированным артефактам — данные. -- Миниатюры, распакованные ассеты, кеш внешних ответов, индексы, которые - перестраиваются, — кеш. Даже если их дорого перестраивать: дорого ≠ - невосполнимо. -- Приложение не пишет в директорию конфигурации: она может быть доступна - только на чтение. - -Если приложение не умеет разделять, это его дефект, а не повод смешивать -категории в раскладке. - -## Список бэкапа выводится, а не составляется - -Список бэкапа получается из категорий по правилу: туда идут данные, не идут -конфигурация и кеш. Правило механическое — но его применяет человек или -шаблон, поэтому список обязан ссылаться на **те же** переменные путей, что -и создание директорий. Независимо набранный список — источник расхождения -между тем, что бэкапится, и тем, что нужно. +создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу +отвечает на два вопроса, которые иначе выясняются чтением кода приложения: +**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий +механически выводится состав бэкапа. Форма записи — `common/language.md`. ## Область действия -Раскладка меняется вместе с миграцией данных, поэтому конвенция применяется -к **новым приложениям**; существующие переезжают по мере касания, отдельной -кампанией не переписываются. Разделять данные и кеш задним числом имеет -смысл тогда, когда кеш заметен по объёму в бэкапе, а не ради самой схемы. +Раскладка меняется вместе с миграцией данных, поэтому правила +распространяются на **новые приложения**; существующие переезжают по мере +касания, отдельной кампанией не переписываются. Разделять данные и кеш +задним числом имеет смысл тогда, когда кеш заметен по объёму в бэкапе, а не +ради самой схемы. + +## Правила + +### R1. Записываемые пути разложены по трём категориям + +**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой, +относится к одной из трёх категорий: + +| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе | +|---|---|---|---|---|---| +| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет | +| R1.2 | данные | `data/` | приложение | невосполнима | да | +| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет | + +Имена в таблице — умолчание для случая «одна директория на категорию». + +**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно +снести при нехватке места, что переживает переезд на другой диск — +читаются из категории, а не выясняются по коду приложения. Без единой +классификации каждое такое решение принимается заново и каждый раз чуть +по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места, +потерянные данные не стоят ничего, потому что их больше нет. + +### R2. Категория может состоять из нескольких директорий + +**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; +принадлежность к категории задаётся не именем, а участием в списке бэкапа. + +**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно +три директории». Крупные файлы отделяют от базы, чтобы двигать их между +дисками независимо (`media/`, `uploads/` — та же категория «данные», что и +`data/`); запрет на такое деление вынуждал бы либо держать всё на одном +диске, либо выводить директорию из-под категорий вовсе. Категорию нельзя +задавать именем ровно поэтому: имён в категории несколько, и выбираются они +по содержимому. + +### R3. Данные и кеш разделяются по тесту на пересоздание + +**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому: + +| № | Что лежит | Категория | +|---|---|---| +| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные | +| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | +| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | + +**Почему.** Без внешнего теста граница проводится по ощущению «жалко +потерять», а оно смещено в одну сторону: дорогой в пересборке кеш +переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и +разделяет эти два свойства именно способность приложения пересоздать +содержимое. Обратная ошибка — данные, названные кешем, — тестом +обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не +при попытке восстановить. + +### R4. В бэкап идут данные, и только они + +**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и +кеш — нет. + +**Почему.** Кеш раздувает снапшот содержимым, которое приложение +восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там +лежат секреты, а бэкапы уезжают в облако — источник истины для +конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте. +Ошибка в другую сторону дороже: директория данных, не попавшая в список, +обнаруживается в единственный момент, когда исправить её уже нечем. + +### R5. Список бэкапа ссылается на те же пути, что и создание директорий + +**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же +объявления путей, по которым директории создаются, а не набирается +независимо. + +**Почему.** Правило вывода механическое, но применяет его человек или +шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок +невозможным: переименование директории отражается в обоих местах сразу. +Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа +на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что +нужно, проявляется при восстановлении. + +### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность + +**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске: + +| № | Данные | В бэкап | +|---|---|---| +| R6.1 | файлы самодостаточны на любой момент времени | копированием | +| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | + +**Почему.** Файловый снапшот работающей СУБД не гарантирует +консистентности: скопированный каталог может не восстановиться, и узнают +об этом при восстановлении. Директория дампов — тоже данные, просто +производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из +списка при этом исключается: он удваивает объём снапшота и добавляет к +надёжной копии заведомо ненадёжную. + +### R7. Способ выбирается при заведении приложения + +**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда +приложение заводят. + +**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не +понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из +которых база не поднимется. Отложить решение — значит принять его по факту +первой неудачной попытки восстановления, то есть тогда, когда данных уже +нет. + +### R8. Приложение разводит записываемые пути по категориям + +**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для +кеша, а не один каталог на всё. + +**Почему.** Снаружи категория определяется только тогда, когда разным +категориям соответствуют разные директории. Всё, сложенное в один каталог, +заставляет составлять список бэкапа вручную, читая код приложения, — и +пересматривать его при каждом обновлении, потому что новый подкаталог +появляется молча. Приложение, которое не умеет разделять, тем самым +дефектно; раскладка под этот дефект не подстраивается. + +### R9. Приложение не пишет в директорию конфигурации + +**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь +конфигурации. + +**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому +всё, что приложение туда записало, следующий деплой затирает без +предупреждения. Вдобавок директория конфигурации может быть подключена +только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не +видно в момент, когда приложение настраивают. diff --git a/arch/config.md b/arch/config.md index 5a02998..c6dcfd5 100644 --- a/arch/config.md +++ b/arch/config.md @@ -1,15 +1,23 @@ ---- -status: рекомендуемая ---- - # Конфигурация приложения Как устроена конфигурация: где лежит, как попадает в процесс, что с -секретами и когда падает. +секретами и когда падает. Форма записи — `common/language.md`. -## Файл, а не окружение +## Область действия -**Конфигурация — файл.** Причины, по убыванию веса: +Правила написаны для приложений, которые мы пишем сами: только там мы +управляем тем, как конфигурация читается. Сторонний образ, живущий на +переменных окружения, вне области действия — это не повод отказываться от +конвенции для своих приложений. + +## Правила + +### R1. Конфигурация — файл, а не окружение + +**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные +окружения источником конфигурации не служат. + +**Почему.** Три довода, по убыванию веса: - **Один типизированный источник.** Файл несёт секции, комментарии, единицы измерения и валидируется целиком. Окружение — плоский набор @@ -23,94 +31,222 @@ status: рекомендуемая докера; переменные оседают в compose-файле и `.env` на диске — то есть файл всё равно появляется, только без структуры и валидации. -Обратите внимание, чего в списке **нет**: `/proc//environ` не является -аргументом — он имеет права `0400` и защищён проверкой `PTRACE_MODE_READ`, -то есть доступен ровно тому же кругу, что и файл под `0600`. +Обратите внимание, чего в этих доводах **нет**: `/proc//environ` не +является аргументом — он имеет права `0400` и защищён проверкой +`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под +`0600`. -Запрет держится на «один источник» и на том, что все приложения свои. Для -стороннего образа, живущего на env, конвенция неприменима — это не повод -отказываться от неё для своих. +### R2. Формат конфигурации — текстовый, с секциями и комментариями -Практика: +**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. -- Формат — текстовый, с комментариями и секциями (TOML, YAML — по стеку). -- Имя по умолчанию фиксировано и ищется в рабочей директории процесса; - путь переопределяется опцией командной строки. -- Реальный конфиг не коммитится. В репозитории лежит **образец**. +**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг +вообще читают; формат, в котором комментарий негде разместить, делает R9 +невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — +плоский список пар такой возможности не даёт и возвращает нас к тем же +свойствам, из-за которых отвергнуто окружение (R1). -## Грузим один раз, дальше не перечитываем +### R3. Имя файла фиксировано, путь переопределяется опцией -- Разбор — **один раз при старте**, в одну типизированную структуру. - Дальше по коду читаем только её: чтения файла в бизнес-коде нет. -- Конфиг **неизменяем** после старта; смена параметров — рестарт процесса. - Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не - умолчание. -- Умолчания задаются в коде, файл их перекрывает. Образец при этом - перечисляет **все** поля, включая те, у которых есть умолчание: поле, - живущее только в коде, для читателя конфига не существует. +**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь +задаётся опцией командной строки. -## Образец самодокументируем +**Почему.** Запуск без аргументов работает одинаково в разработке, в +контейнере и на сервере, и способ запуска не приходится помнить отдельно +для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько +(тесты, второй инстанс): без неё их разводят переменной окружения — тем +самым каналом, который закрывает R1. -Образец коммитим как единый справочник по конфигу: все секции и все поля. -**Каждое поле снабжаем комментарием**, из которого ясно: +### R4. В репозитории лежит образец, а не рабочий конфиг + +**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. + +**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет, +попавший в историю, чинится ротацией, а не удалением файла. Кроме того, +закоммиченный конфиг конкретной среды становится вторым источником истины: +он расходится с тем, что реально развёрнуто, и расходится молча. + +### R5. Конфиг разбирается один раз при старте + +**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения +файла конфигурации в бизнес-коде нет. + +**Почему.** Второе место чтения — это второй момент времени: две части кода +начинают видеть разные значения одного параметра, и расхождение не +воспроизводится, потому что зависит от того, когда файл потрогали. +Типизированная структура вдобавок переносит ошибку формата в старт (R17), +где она видна сразу, а не в первый вызов ветки, которая это поле читает. + +### R6. Конфиг неизменяем после старта + +**ДОЛЖЕН.** Смена параметров — рестарт процесса. + +**Почему.** Изменяемый конфиг делает поведение функцией момента: один +запрос обслуживается наполовину старыми, наполовину новыми значениями, а +разбор инцидента требует знать хронологию правок файла, а не его текущее +содержимое. + +Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не +умолчание. + +### R7. Умолчания живут в коде + +**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. + +**Почему.** Умолчание, живущее в образце, действует только для тех, кто +образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно +из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое +поведение для неполного конфига и одно место, где это значение меняется. + +### R8. Образец перечисляет все поля + +**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у +которых есть умолчание (R7). + +**Почему.** Поле, живущее только в коде, для читателя конфига не +существует: он не знает, что параметр вообще можно менять, и добивается +нужного поведения обходным путём. Полнота образца — цена, которой R7 +покупает себе видимость. + +### R9. У каждого поля образца есть комментарий + +**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно: - **зачем** поле — что оно меняет в поведении; - **диапазон или допустимые значения** — перечисление либо границы; - **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля `0–1`. -Так конфиг читается без открывания кода — этим он и полезен. +**Почему.** Так конфиг читается без открывания кода — этим он и полезен; +без комментария читатель всё равно идёт в код, и образец перестаёт быть +справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды +дают валидное значение и работающий процесс, а ошибка обнаруживается по +последствиям — таймаут в тысячу раз не тот. -## Поля по дискриминатору `type` +### R10. Обязательность полей определяется дискриминатором `type` -Когда набор полей секции зависит от поля-дискриминатора (выбор одного из -бекендов или внешних сервисов), обязательность полей определяется его -значением, а не фиксирована для секции. +**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор +бекенда или внешнего сервиса), валидация идёт по его значению: -- **Валидация — по значению `type`**: для каждого поддерживаемого варианта - свой набор обязательных полей; поля других вариантов не требуются. - Неизвестное значение → ошибка на старте с перечислением поддерживаемых. -- **Образец — по значению `type`**: основной вариант предзаполнен рабочими - значениями, альтернативные — блоками-комментариями ниже, каждый со своим - описанием полей. Из примера видны все варианты, не открывая код. +| № | Значение `type` | Валидация | +|---|---|---| +| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | +| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | -## Секреты приносит деплой +**Почему.** Фиксированный на секцию набор обязательных полей оставляет +выбор из двух плохих: заполнять поля бекенда, который не используется, или +не проверять обязательность вовсе — то есть выключить валидацию ровно там, +где вариантов много и ошибиться легче всего. Перечисление поддерживаемых +значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от +неподдерживаемого варианта, и за списком приходится идти в код. -Секреты доставляет **деплой**, рендеря их прямо в конфиг. Отдельного слоя -секретов в приложении нет — оно просто читает файл. Источник истины -секрета — внешнее хранилище деплоя, не репозиторий и не окружение. +### R11. Образец показывает все варианты `type` -- Рендеренный конфиг не коммитится; права `0600`, владелец — runtime- - пользователь. -- В образце секретные поля — пустые строки. -- Загрузчик на старте проверяет, что обязательные секреты не пусты: это - ловит криво отрендеренный шаблон до того, как он превратится в 401 от - внешнего API через час работы. -- В логи секреты не попадают. +**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями, +альтернативные — блоками-комментариями ниже, каждый со своим описанием +полей. + +**Почему.** Иначе набор вариантов виден только из кода валидации, и образец +теряет свойство справочника (R8, R9) ровно на той секции, где выбор +действительно есть. Закомментированный блок вдобавок переключается правкой +на месте, а не сборкой секции с нуля по документации. + +### R12. Секреты в конфиг приносит деплой + +**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; +отдельного слоя секретов в приложении нет. + +**Почему.** Источник истины секрета — внешнее хранилище деплоя, не +репозиторий и не окружение. Любой второй канал — переменная окружения рядом +с файлом, собственный клиент к хранилищу внутри приложения — возвращает +вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при +этом остаётся тривиальным: оно читает файл и про секреты не знает ничего +особенного. + +### R13. Рендеренный конфиг — `0600` и владелец-рантайм + +**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого +работает процесс. + +**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на +которой секреты лежат, и весь довод «файл вместо окружения» держится на его +правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало +бы окружение, — и тогда R1 меняет одну утечку на другую. + +### R14. В образце секретные поля — пустые строки + +**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не +пример. + +**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее +значение: шаблон отрендерился криво, поле осталось от образца, и проверка +непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг +механически отличимым от заполненного. + +### R15. Загрузчик проверяет, что обязательные секреты не пусты + +**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. + +**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится +в 401 от внешнего API через час работы, — то есть в момент, когда причина +ещё очевидна и связана с деплоем. + +### R16. Секреты не попадают в логи + +**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на +одном уровне. + +**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они +собираются, пересылаются и попадают в бэкапы, где права исходного файла уже +ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением +записи. Типичный источник утечки — отладочный дамп разобранного конфига при +старте. -## Валидация и fail-fast +### R17. Конфиг валидируется на старте, до приёма трафика -Конфиг валидируем **на старте, до приёма трафика**. Невалидный конфиг — -запись уровня `ERROR` и выход с ненулевым кодом: не стартуем «наполовину». +**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым +кодом; процесс не стартует «наполовину». -Проверяем как минимум: +**Почему.** Наполовину стартовавший процесс проходит проверку живости и +падает позже — на первом запросе, который трогает испорченный параметр, — и +падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен, +чтобы неудачный старт увидел супервизор: без него он неотличим от штатного +завершения, и приложение считается развёрнутым. -- обязательные поля заданы, обязательные секреты не пусты; -- пути существуют и доступны на запись/чтение по назначению; -- числовые диапазоны и единицы (доли, таймауты, счётчики попыток); -- строки, которые парсятся во что-то (длительности, зоны, URL), реально - парсятся; -- включённые секции консистентны: если интеграция включена — заданы все её - обязательные поля. +### R18. Минимальный набор проверок -Проблемы собираем и показываем **разом**, а не по одной за запуск. +**ДОЛЖЕН.** Валидация покрывает как минимум: + +| № | Что проверяется | Когда всплывёт без проверки | +|---|---|---| +| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает | +| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте | +| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке | +| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит | +| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | + +**Почему.** Список минимальный и собран по одному признаку — правый +столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже +потеряна, и диагностируется как дефект приложения. Проверка на старте +сводит их все к одному моменту и одному сообщению. +### R19. Проблемы конфига показываются разом + +**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним +списком, а не падает на первой. + +**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько +раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и +деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля +одного источника: разом они читаются как одна причина, по одной — как +череда несвязанных мелочей. + ## Связано - `arch/time.md` — формат времени; зона отображения — единственный diff --git a/arch/time.md b/arch/time.md index 76f1853..6a0fd14 100644 --- a/arch/time.md +++ b/arch/time.md @@ -1,63 +1,160 @@ ---- -status: рекомендуемая ---- - # Время -Один формат времени на всё приложение: хранение, логи, API, обмен с -внешними системами. Разные форматы в разных слоях — источник ошибок, -которые всплывают через полгода на границе перехода на летнее время. +Как приложение записывает моменты и длительности: в каком формате, откуда +берётся значение и где появляется не-UTC. Форма записи — +`common/language.md`. -## Формат +## Область действия -- **RFC 3339, UTC, суффикс `Z`**: `2026-06-28T11:23:45Z`. -- **Ширина фиксируется на каждый носитель** и внутри него не плавает. - Лексикографическая сортировка равна хронологии только среди строк - одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя - хронологически позже. Ради этого формат и фиксируется — `ORDER BY - created_at` по текстовому полю обязан давать порядок событий. -- Разные носители могут иметь разную точность: строки БД и строки лога - между собой никогда не сравниваются. Требование — не «одна точность на - приложение», а «внутри колонки и внутри потока логов ширина одна». -- Локальное время не хранится и не передаётся **нигде** — ни в БД, ни в - логах, ни в JSON API. +Конвенция описывает фиксацию **свершившихся моментов** — того, что уже +произошло и попало в базу, лог или ответ API. Планирование будущих событий — +отдельный случай: там хранят локальное время плюс имя зоны, потому что +правила зон меняются в промежутке между планированием и наступлением. Пока +такой сущности нет, правил для неё в файле нет. -## Генерирует приложение, а не хранилище +## Правила -- Единая точка получения «сейчас» и единая точка форматирования и разбора — - как с идентификаторами (`arch/db-identifiers.md`). Прямые вызовы часов по - коду не разбросаны: иначе ни формат, ни зона не гарантированы. -- **Дефолты в схеме БД не используем.** Забытая вставка `created_at` - должна падать громко, а не тихо получать значение от БД — иначе - расходятся источник времени (сервер БД) и его формат. +### R1. Единый формат — RFC 3339, UTC, суффикс `Z` -## Длительность — не метка времени +**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` — +одинаково в хранении, логах, API и обмене с внешними системами. -Измерение длительности операции — отдельная величина: число (обычно -миллисекунды) в поле вида `duration_ms`, а не разность двух меток и не -время в формате выше. Засекает её тот слой, который делает вызов. +**Почему.** Разные форматы в разных слоях требуют преобразования на каждой +границе, а ошибка в таком преобразовании не видна сразу: она всплывает через +полгода, на переходе на летнее время, когда реальное смещение перестаёт +совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` +убирает из данных и смещение, и сам вопрос «в какой зоне это записано». -**Интервал измеряется монотонными часами процесса**, а не вычитанием -сохранённых меток: стенные часы подводит NTP, они могут шагнуть назад и -дать отрицательную длительность. Из этого следует, что источник меток -времени и источник интервалов — разные, даже если оба называются «часы». +### R2. Ширина строки фиксируется на каждый носитель -## Зоны +**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина +строки времени одна и от записи к записи не плавает. -Единственное место, где появляется не-UTC, — **отображение пользователю**. -Зона берётся из конфигурации (`arch/config.md`), значение по умолчанию — -`UTC`. На хранение, сортировку и логи она не влияет. +**Почему.** Лексикографическая сортировка совпадает с хронологией только +среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя +произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по +текстовому полю обязан давать порядок событий. Плавающая ширина (типичный +источник — форматирование, отбрасывающее незначащие нули) ломает порядок не +везде, а только на тех парах записей, где дробная часть оказалась короче, — +то есть редко, выборочно и невоспроизводимо. -Если бизнес-логика оперирует календарными сущностями («сегодня», -«за месяц»), зона указывается **явно** в месте вычисления — молчаливое -использование системной зоны процесса запрещено: она разная на ноутбуке и в -контейнере. По умолчанию это та же зона, что и для отображения; если -календарная логика требует другой, это записывается явно. +### R3. Точность разных носителей может различаться -Конвенция описывает фиксацию **свершившихся моментов**. Планирование -будущих событий — отдельный случай (там хранят локальное время плюс имя -зоны, потому что правила зон меняются); пока такой сущности нет, правило не -формулируем. +**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. + +**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что +строки разных носителей между собой не сравниваются: сортировка идёт внутри +колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался +как «одна точность на всё приложение»: от подгонки формата логов под формат +колонки ни одна пара строк не становится сравнимой, зато точность режется до +худшего из носителей. + +### R4. Локальное время не хранится и не передаётся + +**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной +зоне. + +**Почему.** Метка без зоны неинтерпретируема вне процесса, который её +записал: чтобы понять, какому моменту она соответствует, читателю нужно +знать настройки чужой машины на момент записи. И даже зная их, он не +разберёт час перехода на зимнее время: этот час идёт дважды, две записи +получают одинаковую метку, и порядок между ними не восстанавливается ничем. + +### R5. Единая точка получения «сейчас», форматирования и разбора + +**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает +метки; прямые вызовы часов по коду не разбросаны. + +**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех +меток без исключения, а каждый прямой вызов часов заводит ещё одно место, +где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в +данных, и обнаруживается, когда испорченных записей уже накопилось. +Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`). + +### R6. Дефолтов времени в схеме БД нет + +**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. + +**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий +код: значение появляется, но приходит от сервера БД — то есть с других часов +и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка +падает громко и чинится в момент написания, а не при разборе расхождения +между временем в записи и временем в логе. Правило то же, что для +идентификаторов (`arch/db-identifiers.md R2`). + +### R7. Длительность — отдельная величина, а не пара меток + +**ДОЛЖЕН.** Длительность операции записывается числом (обычно +миллисекундами) в поле вида `duration_ms`. + +**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько». +Пара меток заставляет каждого потребителя знать, какие именно две из них +образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в +логе; число сравнивается, агрегируется и попадает в перцентили без этого +шага. Кроме того, разность сохранённых меток считается по стенным часам и +наследует их дефект (R9). + +### R8. Длительность засекает слой, который делает вызов + +**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. + +**Почему.** Обе границы операции видит только этот слой: замер уровнем выше +приписывает операции чужие накладные расходы, уровнем ниже — теряет часть +вызова. В обоих случаях число остаётся правдоподобным и потому не +оспаривается, хотя отвечает не на тот вопрос, который к нему задают. + +### R9. Момент и интервал берутся с разных часов + +**ДОЛЖЕН.** Источник зависит от того, что записывается: + +| № | Величина | Источник | +|---|---|---| +| R9.1 | момент события | стенные часы через единую точку (R5) | +| R9.2 | длительность операции | монотонные часы процесса | + +**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда +интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — +правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для +меток: их ноль произволен и не переживает перезапуск процесса, так что вне +процесса такое значение ничего не означает. Отсюда следствие, которое легко +упустить: источник меток времени и источник интервалов — разные, даже если +оба называются «часы». + +### R10. Не-UTC существует только на слое отображения + +**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не +проникает в хранение, сортировку и логи. + +**Почему.** Как только конвертация уходит вглубь, результат вычислений +начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт +разные группировки, а порядок записей перестаёт быть общим для всех. Ещё +хуже, что при конвертации в нескольких слоях её легко выполнить дважды — +смещение удваивается, результат остаётся похожим на правду, а найти +виновный слой можно только перечитав их все. + +### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC` + +**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение +по умолчанию — `UTC`. + +**Почему.** Зашитая в код зона превращает переезд или второго пользователя в +другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано +потому, что оно не притворяется настроенным: показанное время совпадает с +тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается +как «зону не задали», а не как «где-то потерялось смещение». + +### R12. В календарных вычислениях зона указывается явно + +**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с +явно переданной зоной, а не с системной зоной процесса. + +**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на +сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта: +расхождение не воспроизводится там, где его заметили, и объясняется средой, +а не кодом. Явно переданная зона делает результат функцией от аргументов. + +Зона по умолчанию здесь та же, что и для отображения (R11); календарная +логика, которой нужна другая, получает её тем же явным аргументом. diff --git a/common/conventions-guide.md b/common/conventions-guide.md index 9a62d14..a5c080f 100644 --- a/common/conventions-guide.md +++ b/common/conventions-guide.md @@ -2,28 +2,18 @@ Конвенция описывает повторяющийся выбор: как называть директории, как раскладывать данные, как оформлять ошибки. Она отвечает на вопрос «как -принято», а не «что здесь происходит». Одна конвенция — один файл. +принято», а не «что здесь происходит». Как записывается сама конвенция — правила, модальность, обоснования — в -[language.md](language.md). Здесь — про то, зачем они заводятся, где живут -и как соотносятся с соседними видами документов. +[language.md](language.md). Здесь — про то, зачем конвенции заводятся, где +живут и как соотносятся с соседними видами документов. -## Канон и копии +## Область действия -Файлы в этой директории с шапкой `origin:` — **копии из общего канона** -`dev-conventions`, а не собственные документы репозитория. Отсюда: - -- репозиторное пишется **только внутрь локальных регионов** - ``: они исключены из сравнения с - каноном, и расхождение по ним — норма, а не дрейф; -- правка вне регионов означает одно из двух: улучшение, которое надо - вернуть в канон, или сознательное расхождение, записанное в ключ `local:` - шапки; -- состояние копий показывает `conv status`, различия — `conv diff`, - обновление из канона — `conv pull`; всё через раннер репозитория. - -Имя региона обязательно и стабильно: перенос содержимого при обновлении -идёт по именам. +Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или +правит существующий, в каноне и в копиях репозиториев. Обязательность живёт +на отдельном правиле, а не на файле; шкала модальных слов — в +[language.md](language.md). ## Отличие от соседей @@ -36,101 +26,222 @@ - `docs/conventions/` — **правило на будущее**, применяемое многократно. Живой документ: правится, когда договорённость меняется. -## Направление: конвенция → код +## Канон и копии -Конвенция формулируется независимо от того, как устроено конкретное -приложение. Код следует конвенции, а не наоборот. +Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона +`dev-conventions`, а не собственные документы репозитория. Репозиторное +живёт только внутри локальных регионов ``: +они исключены из сравнения с каноном, и расхождение по ним — норма, а не +дрейф. Правка вне регионов означает одно из двух: улучшение, которое +возвращают в канон, или сознательное расхождение, записанное в ключ `local:` +шапки. Состояние копий показывает `conv status`, различия — `conv diff`, +обновление из канона — `conv pull`; всё через раннер репозитория. -Если код расходится с правилом — это отступление, и оно записывается в -локальный регион, а не переписывает правило. Правило меняется только тогда, -когда оно **неверно по существу**: содержит фактическую ошибку, внутреннее -противоречие или условие применимости, которое не даёт ответа. +Имя региона обязательно и стабильно: перенос содержимого при обновлении +идёт по именам, и переименование осиротит содержимое во всех копиях. -Практическое следствие: в тексте конвенции не должно быть утверждений о -текущем состоянии репозитория. «Так сделано у нас» — это регион -отступлений; норма пишется в настоящем предписывающем времени. +## Правила -## Насколько правило обязательно +### R1. Одна конвенция — один файл -Обязательность живёт **на правиле**, а не на файле: один документ почти -всегда смешивает жёсткие требования с советами, и общая пометка на нём -неизбежно врёт про часть содержимого. Шкала модальных слов — в -[language.md](language.md). +**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор. -Правило без механической проверки держится только на внимании. Для -**СЛЕДУЕТ** это нормально, для **ДОЛЖЕН** — плохо: такое правило либо -механизируется, либо честно понижается. +**Почему.** Подписка — это набор лежащих в репозитории файлов, и берут файл +целиком. Файл, собравший две темы, вынуждает репозиторий взять правила, +которые ему не нужны, и получать шум в `diff` по чужой половине. Разрезать +позже дорого: путь файла — часть адреса правила, и после разреза внешние +ссылки указывают не туда. -## Когда заводить +### R2. Конвенция заводится, когда решение принимается третий раз -Когда одно и то же решение принимается третий раз и каждый раз чуть -по-другому. Единичный выбор — не конвенция; если он ещё и был спорным, ему -место в ADR. +**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и +каждый раз чуть по-другому. -Путь находки: **находка → конвенция → правило линтера → удаление прозы**. -Первые два шага делаются в репозитории, где заболело; общая часть -продвигается в канон. +**Почему.** По одному-двум случаям не видно, что в решении повторяется, а +что было частностью места: правило, выведенное из первого случая, кодирует +частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же +спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть +содержание записи. -## Прозой — только то, что не выражается правилом +### R3. Новая конвенция пишется там, где заболело -Как только свойство удаётся проверить машиной, его формулировка перестаёт -работать: файл на несколько сотен строк размазывает внимание по -тривиальному, и человек с агентом добросовестно проверят именование, не -дойдя до формы решения. +**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера → +удаление прозы» делаются в репозитории, где случилась находка; в канон +продвигается общая часть. -Но удаление прозы в общем каноне устроено иначе, чем в одиночном -репозитории. Механизация — состояние **конкретного** репозитория: +**Почему.** Правило, написанное сразу в общем виде, не проверено ни одним +применением, и условие применимости у него придумано, а не найдено, — +платят за это все потребители сразу. Формулировка, обкатанная на одном +репозитории, приезжает в канон уже с известной границей. -- **из канона формулировка не удаляется**, пока правило не механизировано - у всех потребителей: иначе те, у кого линтера нет, останутся без правила; -- **факт механизации** фиксируется в локальном регионе `механизировано` — - со ссылкой на номер правила и на конкретную проверку; -- когда механизация стала общей (правило уехало в общий конфиг линтера или - в общую роль), формулировка удаляется из канона одним `push`. +### R4. В тексте конвенции нет утверждений о состоянии репозитория -Обоснование правила («Почему») не удаляется никогда, даже когда сама норма -уехала в линтер: линтер сообщает, что нарушено, но не сообщает, зачем -правило существует, — а именно это нужно, чтобы понять, когда его пора -отменить. +**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без +описаний того, как сейчас устроен конкретный репозиторий. -## Трудноизменяемые слои +**Почему.** Такое утверждение устаревает молча и подменяет норму описанием: +читатель перестаёт понимать, что от него требуется, а что просто +констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен +с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно +и локально, и проверяемо. -У схемы БД, формата хранения и раскладки директорий не работает привычное -«новое пишем правильно, старое переезжает по мере касания»: таблица не -переезжает от того, что её потрогали. Для таких конвенций: +### R5. Расхождение кода с правилом — отступление, а не повод переписать правило -- **область действия пишется явно** — «применяется к новым таблицам и - миграциям», а не к состоянию схемы; -- **механизируется граница изменения, а не состояние** — линтер запрещает - `AUTOINCREMENT` в новых миграциях, а не в существующей схеме: старое не - падает, новая ошибка невозможна; -- **список отступлений постоянный**, а не список задач на дочистку. +**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит +фактическую ошибку, внутреннее противоречие или условие применимости, +которое не даёт ответа. -## Честный список отступлений +**Почему.** Правило, подогнанное под текущий код, перестаёт что-либо +требовать — оно описывает то, что и так происходит, и первое же расхождение +переписывает его снова. Направление «конвенция → код» держится ровно тем, +что факт не считается аргументом. -В локальном регионе перечисляем отступления, которые уже есть в коде, — со -ссылкой на номера правил. Иначе репозиторий делает вид, что конвенции -следует, а проверить это можно только чтением всего кода. +### R6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается -Пустой список отступлений почти всегда означает, что их не искали. +**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает +машинную проверку или переводится в СЛЕДУЕТ. -Отступление — это «правилу не следуем здесь и вот почему». Если регион -разросся до «мы это правило вообще не применяем», значит либо у правила -неверно сформулировано условие применимости (чинить в каноне), либо -репозиторию не нужна эта конвенция (не подписываться). +**Почему.** Без проверки правило держится на внимании: нарушения копятся +молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ +это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько +таких случаев обесценивает остальные ДОЛЖЕН в файле. -## Оформление +### R7. Факт механизации фиксируется в локальном регионе со ссылкой на номер -- Имя файла — kebab-case по теме: `app-directories.md`. -- Раздел «Связано» в конце: ADR с обоснованием, спеки, код, который эту - конвенцию механизирует. Репо-специфичная часть «Связано» — в локальном - регионе, канонические ссылки — в общем тексте. -- README директории перечисляет конвенции с однострочным описанием, чтобы - список читался без открывания файлов. -- **Короткие инварианты дублируются туда, что агент читает безусловно** - (`AGENTS.md` / `CLAUDE.md`): сама по себе конвенция агенту не видна, он - дойдёт до неё, только если его туда отправили. Детали остаются здесь, - в файл-точку-входа едет одна строка на правило с его номером. +**ДОЛЖЕН.** Регион `механизировано` называет номер правила и конкретную +проверку. + +**Почему.** Механизация — состояние конкретного репозитория, канон о ней не +знает, а без записи следующий автор либо заведёт вторую проверку того же, +либо будет вычитывать глазами уже проверенное машиной. Без номера правила +читатель догадывается сам, к какому утверждению относится проверка, — и +догадывается по-разному. + +### R8. Формулировка не удаляется из канона, пока механизирована не у всех + +**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя +машинной проверки нет. + +**Почему.** У кого линтера нет, тот после удаления остаётся без правила +вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не +говорит о прочих, поэтому удалять по факту «у нас уже проверяется» — +значит чинить свой файл за чужой счёт. + +### R9. Общая механизация разрешает удалить норму из канона + +**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль, +удаляется из канона одним `push`. + +**Почему.** Формулировка, дублирующая работающую у всех проверку, +размазывает внимание: файл на несколько сотен строк заставляет человека и +агента добросовестно вычитывать тривиальное именование и не доходить до +формы решения. Явное разрешение нужно, чтобы R8 не читался как запрет +удалять вообще. + +### R10. Обоснование не удаляется никогда + +**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в +линтер. + +**Почему.** Линтер сообщает, что нарушено, но не сообщает, зачем правило +существует. Без обоснования не видно, когда причина отпала, — проверка +продолжает работать по инерции, и возразить ей нечем, кроме как отключив. + +### R11. У трудноизменяемого слоя область действия пишется явно + +**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий +называет, к чему применяется: к новым таблицам и миграциям, а не к +состоянию схемы. + +**Почему.** Здесь не работает привычное «новое пишем правильно, старое +переезжает по мере касания»: таблица не переезжает от того, что её +потрогали. Без явной рамки правило читается как требование к текущему +состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо +молчаливый вывод, что конвенция не соблюдается совсем. + +### R12. Механизируется граница изменения, а не состояние + +**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже +существующей схеме. + +**Почему.** Проверка состояния краснеет на легаси с первого дня: её +отключают или обвешивают вечным списком исключений — и она перестаёт ловить +новое, ради чего заводилась. Проверка границы оставляет старое в покое и +делает новую ошибку невозможной. + +### R13. Список отступлений трудноизменяемого слоя — постоянный + +**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не +как задачи на дочистку. + +**Почему.** Список, записанный долгом, требует либо мигрировать живые данные +без выгоды, либо год за годом объяснять невыполненный план. Второе кончается +тем, что список перестают вести, — и пропадает единственное место, где видно, +где именно правило не действует. + +### R14. Отступления перечисляются поимённо, со ссылкой на номера правил + +**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в +коде, с номером правила и причиной. + +**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить +это можно только чтением всего кода. Со ссылками отступления счётны: видно, +сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при +этом почти всегда означает не отсутствие отступлений, а то, что их не искали. + +### R15. Запись в регионе отступлений разбирается по масштабу + +**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано: + +| № | Что записано | Куда идёт | +|---|---|---| +| R15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением | +| R15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне | +| R15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет | + +**Почему.** Отступление описывает исключение, и по нему видно, какая часть +правила нарушена. Запись «мы это правило вообще не применяем» такой +информации не несёт и маскирует одну из двух чинимых причин: неверную рамку +правила в каноне, которую чинят один раз для всех, или лишнюю подписку, +где файл просто не нужен. Оставленная отступлением, она прячет обе. + +### R16. Имя файла — kebab-case по теме + +**СЛЕДУЕТ.** `app-directories.md`, а не вариации регистра и разделителя. + +**Почему.** Имя файла — часть глобального адреса правила +(`stack/ansible/app-directories.md R4`) и значение ключа `origin` в каждой +копии. Один способ записи избавляет от нескольких написаний одного адреса, +а ошибка в адресе обнаруживается только тем, кто по нему пришёл и ничего не +нашёл. + +### R17. Репо-специфичная часть «Связано» — в локальном регионе + +**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в +общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в +локальном регионе. + +**Почему.** Общий текст уезжает `push`-ем ко всем потребителям, и ссылка на +чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому +та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`. + +### R18. README директории перечисляет конвенции с однострочным описанием + +**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он. + +**Почему.** Подписка — это набор лежащих файлов, и без описаний вопрос +«какая из них про мой случай» решается открыванием каждой. Ценой в десяток +файлов это означает, что не открывают ни одной. + +### R19. Короткие инварианты дублируются в точку входа агента + +**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его +номером; детали остаются в конвенции. + +**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только +если его туда отправили, — а безусловно он читает точку входа. Строка с +номером служит и напоминанием, и адресом, по которому за подробностями +идут; перенос деталей туда же вернул бы задачу поддержки двух текстов. diff --git a/lang/go/config.md b/lang/go/config.md index 297a97c..0d97f83 100644 --- a/lang/go/config.md +++ b/lang/go/config.md @@ -1,26 +1,94 @@ --- -status: рекомендуемая extends: arch/config.md --- # Конфигурация: реализация на Go -Как `arch/config.md` выглядит в Go-приложении. +Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы +запрета на окружение. Форма записи — `common/language.md`. -## Формат и загрузчик +Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а +проверка их непустоты идёт вместе с остальной валидацией — как описано в +базовой конвенции. -- TOML. Разбор и валидация — целиком в `internal/config`; наружу отдаётся - готовая структура `Config`. -- Одна корневая структура `Config` с под-структурами по секциям — имена - структур совпадают с именами секций, чтобы конфиг и код читались рядом. -- Умолчания — в `Default()`, поверх накладывается разобранный файл. -- Флаг `--config=path` переопределяет путь; по умолчанию `config.toml` в - рабочей директории, образец — `config.example.toml`. +## Правила -## Длительности +### R1. Формат конфигурации — TOML -`time.Duration` не разбирается из строки TOML сама по себе — нужен свой тип -с `UnmarshalText`, отдающий `time.Duration`: +**ДОЛЖЕН.** Конфиг — файл TOML. + +**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор +делается один раз на язык, а не в каждом приложении: разные форматы в +соседних сервисах означают разные загрузчики, разные шаблоны рендера +конфига в деплое и разное поведение при синтаксической ошибке. TOML при +этом даёт секции и типизированные скаляры без значимых отступов — конфиг, +поправленный руками на сервере, ломается заметно, а не меняет вложенность +молча. + +### R2. Разбор и валидация — целиком в `internal/config` + +**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в +`internal/config`; наружу пакет отдаёт готовую структуру `Config`. + +**Почему.** Пока значение не покинуло пакет, оно может быть невалидным; +после — уже нет, и это единственная граница, на которой такое утверждение +проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос +«проверено ли это поле» только чтением всех вызывающих, часть полей +неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ +посреди работы. Экспортированный разбор вдобавок даёт второй способ +получить конфиг — мимо умолчаний (R5). + +### R3. Весь конфиг — одна корневая структура + +**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из +под-структур по секциям. + +**Почему.** Один корень даёт одну точку, после которой конфиг проверен +целиком, и дальше передаётся как обычный аргумент. Несколько независимых +структур конфига означают несколько загрузок и вопрос «какая из них уже +провалидирована» на каждом использовании; связанные между собой поля +(включена интеграция — заданы все её поля) при этом перестают быть +проверяемыми в одном месте. + +### R4. Под-структуры названы по секциям файла + +**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. + +**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт +себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит +в код и обратно; при расхождении связь между полем файла и полем структуры +восстанавливается чтением тегов, и проделывать это приходится для каждой +секции заново. + +### R5. Умолчания задаёт `Default()` + +**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл +накладывается поверх. + +**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой +таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание, +подставленное по месту использования (`if x == 0 { x = … }`), поэтому не +видно ни целиком, ни из образца, и два потребителя одного поля со временем +подставляют разное. `Default()` — единственное место, откуда список +умолчаний читается разом и переносится в образец. + +### R6. Имя файла фиксировано, путь переопределяется флагом + +**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории, +путь переопределяет флаг `--config=path`, образец рядом — +`config.example.toml`. + +**Почему.** Фиксированное имя и переопределение из командной строки требует +базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во +всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску +пишутся, не открывая код приложения. Соседство `config.toml` и +`config.example.toml` вдобавок делает расхождение образца с реальным +конфигом видимым обычным `diff`, а не вычиткой. + +### R7. Длительности — собственный тип с `UnmarshalText` + +**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим +`time.Duration`: ```go type Duration time.Duration @@ -29,50 +97,117 @@ func (d *Duration) UnmarshalText(b []byte) error { … } func (d Duration) Std() time.Duration { … } ``` -Так в конфиге видна единица измерения (`poll_interval = "5s"`), а не голое -число. Цена: ошибка в длительности всплывает **на разборе TOML**, до общей -валидации, поэтому в общий сбор проблем она не попадает — про неё узнаёшь -отдельно и первой. +**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое +число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не +знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз +проходит и разбор, и проверку диапазона, а проявляется нагрузкой или +зависшим ожиданием. Запись `poll_interval = "5s"` несёт единицу измерения +в себе и разбирается тем же `time.ParseDuration`, что и остальной код. -## Чтение окружения +У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть +раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности +приходит отдельно и первой, а остальные проблемы конфига в этом запуске не +показываются. -Приложение не читает окружение для конфигурации. Механизируется -`forbidigo`, и паттерн должен покрывать **все** входы, а не только +### R8. Приложение не читает окружение + +**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. + +**Почему.** Второй канал конфигурации — то, против чего написана базовая +конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого +пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию. +Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как +чтением всего кода — а узнают о нём обычно на сервере, где переменная не +выставлена. + +### R9. Проверка запрета покрывает все входы в окружение + +**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только `os.Getenv`: ``` ^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$ ``` -Правило про приложение, поэтому за его границей запрет не действует: +**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, +поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет +незаметно: правило числится механизированным, и глазами его больше никто не +проверяет. -- **тесты** — не приложение: интеграционному тесту нормально брать - креды внешнего сервиса из окружения; -- **переменные рантайма** — те, что читает не наш код, а Go или ОС - (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`). +### R10. За границей приложения запрет не действует -Отдельный случай — переменные, которые читает **стандартная библиотека от -имени приложения**: дефолтный `http.Transport` уважает -`HTTP_PROXY`/`HTTPS_PROXY`. Формально их читает не наш код, но это -конфигурация поведения приложения, поэтому прокси задаётся полем конфига и -явным `Transport`, а не окружением. +**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое +приложение: -## Валидация +| № | Кто читает | Вердикт | +|---|---|---| +| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | +| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | -- Проверки собираются `errors.Join`, чтобы за один запуск показать **все** - проблемы конфига, а не первую. -- IANA-зона валидируется `time.LoadLocation`. База зон встраивается - импортом `_ "time/tzdata"` **в `main`**, а не в библиотечном пакете: - иначе ~450 КБ zoneinfo навязываются каждому импортёру. Со встроенной - базой ошибка `LoadLocation` означает битое имя зоны, а не отсутствие - zoneinfo в контейнере. -- Невалидный конфиг — `slog` уровня `ERROR` и `os.Exit(1)` из `main`, до - старта серверов и воркеров. +**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не +трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает +рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их +не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один +механизм. Явное разрешение нужно и потому, что нерасписанная граница +лечится `//nolint` наугад: там, где легальные случаи приходится глушить +руками, вместе с ними проходят и нелегальные. -## Секреты +### R11. Прокси задаётся конфигом, а не `HTTP_PROXY` -Go-специфики нет: секреты приходят из деплоя уже в файле, проверка их -непустоты идёт вместе с остальной валидацией — см. базу. +**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. + +**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а +дефолтный `http.Transport` — но читает он их от имени приложения и меняет +поведение приложения, а не рантайма. Оставленные окружению, они дают ровно +тот второй канал, который запрещает R8, и притом самый неудобный: маршрут +исходящих запросов отличается от машины к машине без единого следа в +конфиге и в образце, а расследование начинается с вопроса «почему на +сервере ходит не так, как локально». + +### R12. Проблемы конфига собираются `errors.Join` + +**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна +ошибка, собранная `errors.Join`. + +**Почему.** Возврат первой ошибки превращает починку конфига в серию +перезапусков по одному полю за раз, причём каждый следующий запуск +обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя +своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой +вложенной проблеме. + +### R13. Имя зоны проверяется `time.LoadLocation` + +**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. + +**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно +тогда, когда база зон его знает, и никакая проверка формата не отличит +`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка +доживает до первого форматирования времени — то есть до рантайма, мимо +fail-fast (R15). + +### R14. `time/tzdata` импортируется в `main` + +**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном +пакете. + +**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому +импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или +полагаться на системную» принадлежит собираемой программе. Со встроенной +базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без +неё тот же конфиг валиден на машине разработчика и падает в контейнере без +zoneinfo, а сообщение указывает не на ту причину. + +### R15. Невалидный конфиг — `ERROR` и выход из `main` + +**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до +старта серверов и воркеров. + +**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не +оставляет вызывающему возможности ни залогировать причину, ни дописать +контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно +до старта воркеров: горутина, поднятая раньше валидации, успевает сходить +во внешний сервис и записать в базу от имени процесса, который потом +объявит, что не стартовал. diff --git a/lang/go/db-identifiers.md b/lang/go/db-identifiers.md index 89736cd..0e273b5 100644 --- a/lang/go/db-identifiers.md +++ b/lang/go/db-identifiers.md @@ -1,47 +1,127 @@ --- -status: рекомендуемая extends: arch/db-identifiers.md --- # Идентификаторы: реализация на Go -Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID. +Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID +(ветка `arch/db-identifiers.md` R1.1). Форма записи — `common/language.md`. -## Единая точка — `internal/ident` +Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он +порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает +(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся. -- `ident.NewID()` — генерация. **PK сущности** генерируется в `Create`-методах - слоя `store`. Прочие идентификаторы (батч, задание, корреляционный ключ) - генерируются там, где начинается операция, — но тоже только через `ident`. -- `ident.NewIDAt(t)` — генерация с заданным временем, для бэкфилла в - Go-миграциях: сортировка id тогда сохраняет историческую хронологию, а не - момент прогона миграции. -- `ident.Parse()` — разбор и нормализация; зовётся на **входных границах** - (HTTP-роут, форма, callback бота), до обращения к store. -- Других генераторов и парсеров id в коде нет. Это то самое «единая точка» - из базовой конвенции; без него нормализация регистра неизбежно - где-нибудь пропускается. +## Правила -## Типы +### R1. Генерация и разбор — только через `internal/ident` -В структурах store и домена id — обычный `string`. Отдельный тип `ID` -заводим, только если появится вторая семья идентификаторов, которую можно -перепутать; до этого он даёт конверсии без выгоды. От перепутывания двух id -одной семьи в сигнатуре он всё равно не спасает — там помогают имена -параметров. +**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета +`internal/ident`; других генераторов и парсеров id в коде нет. -## Невалидный id на границе +**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов +ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не +выглядит нарушением: значение получается валидное, просто мимо нормализации +регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт +библиотеки где-либо, кроме `internal/ident`, находится поиском по имени +модуля, а «забытая нормализация» не находится ничем, пока запрос молча не +перестанет находить существующую запись. -Разбор не удался — дальше зависит от того, откуда id пришёл: +### R2. Первичный ключ генерируется в `Create`-методах store -- **из пути или query URL** — сразу 404, без обращения к store и без - фабрикации доменной ошибки: снаружи это неотличимо от несуществующей - записи, и хорошо; -- **из собственной формы или callback-данных кнопки** — 400 либо понятное - сообщение («кнопка устарела»): это баг интерфейса или протухший экран, и - под «не найдено» его маскировать нельзя. +**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` +внутри `Create`-метода слоя store. -Транспорт не создаёт доменные sentinel'ы, чтобы тут же их сматчить, — это -инверсия правила «трансляция у источника» из `lang/go/errors.md`. +**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было +известно до вставки, но не говорит, кто его присваивает. Store — последний +слой, через который проходят все пути создания строки, включая импорт, +фоновые задания и тесты. Генерация выше по стеку делает присвоение +обязанностью каждого нового вызывающего, и первый забывший запишет пустую +строку в колонку ключа: для строкового PK это валидное значение, база его +не отклонит, и дефект обнаружится на второй такой вставке. + +### R3. Прочие идентификаторы генерируются в точке начала операции + +**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся +вызовом `ident.NewID()` там, где операция начинается. + +**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) — +сшивать записи лога всей операции. Созданный ниже по стеку или в момент +первой записи в базу, он не покрывает начальные шаги — а именно они нужны, +когда операция упала до того, как что-либо записала: без общего ключа эти +записи из лога не собираются вообще. + +### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)` + +**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в +Go-миграции, порождаются с историческим временем строки, а не с текущим. + +**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не +момент прогона миграции. Иначе все затронутые строки получают метку одного +момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id` +начинает врать ровно на том массиве данных, который старше всего. +Исправить это потом нельзя: исходное время в идентификаторе не +восстановить. + +### R5. Разбор — на входных границах, до обращения к store + +**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или +callback'а бота — раньше, чем идентификатор попадёт в store. + +**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана +транспортная, потому что только на ней известен источник значения, от +которого зависит реакция (R8): store видит одинаковую строку независимо от +того, пришла она из URL или из собственной формы, и ответить по-разному +оттуда уже невозможно. + +### R6. Id в структурах — обычный `string` + +**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип +`string`. + +**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им +ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и +`authorID`) он не спасает — оба будут одного типа, и различают их имена +параметров. Зато он требует конверсий на каждой границе с sql-драйвером, +json и шаблонами, то есть даёт цену без выгоды. + +### R7. Отдельный тип — когда появляется вторая семья идентификаторов + +**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые +можно перепутать, для них заводятся различимые типы. + +**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на +типизацию навсегда. Условие названо ровно то, при котором тип начинает +работать: пока все идентификаторы — `string`, подстановка одного вида +вместо другого компилируется и обнаруживается только на данных. + +### R8. Реакция на невалидный id зависит от источника + +**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло +значение: + +| № | Источник | Ответ | +|---|---|---| +| R8.1 | путь или query URL | 404 без обращения к store | +| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | + +**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах +HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи — +и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2 +значение сформировало само приложение, и невалидность означает баг +интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно, +в логах не оставляет аномалии и тем самым съедает единственный момент, +когда дефект заметен. + +### R9. Транспорт не создаёт доменные ошибки + +**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например +`ErrNotFound`), чтобы тут же сопоставить его со своим ответом. + +**Почему.** Инверсия правила «трансляция у источника» из +`lang/go/errors.md`. Sentinel — сообщение от слоя, который знает факт: +строка не найдена, потому что store её искал. Сфабрикованный транспортом, +он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли +вообще поход в хранилище — а на этом держится вся диагностика по ошибкам. diff --git a/lang/go/db-schema.md b/lang/go/db-schema.md index e077a92..8f0a05d 100644 --- a/lang/go/db-schema.md +++ b/lang/go/db-schema.md @@ -1,44 +1,183 @@ ---- -status: рекомендуемая ---- - # Схема и миграции (SQLite, Go) -Область действия — **новые миграции**. Существующая схема не переписывается; -линтер проверяет то, что добавляется, а не то, что уже лежит. +Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в +Go-приложении. Форма записи — `common/language.md`. + +## Область действия + +Схема меняется тяжело: таблица не переезжает от того, что её потрогали. +Правила распространяются на **новые миграции**; существующая схема не +переписывается, и проверяется граница изменения — то, что миграция +добавляет, а не то, что уже лежит в базе. ## Миграции -- Инструмент — goose, файлы миграций лежат рядом со store-слоем. -- **SQL-файл** для DDL: создание таблиц, индексы, изменение структуры. -- **Go-миграция** (`goose.AddMigrationContext`) — когда нужен код: - генерация идентификаторов, backfill, перенос данных между формами. - Не пытаемся выразить это SQL-ом ради единообразия. -- **В деплое движение только вперёд.** Down-миграция — инструмент - разработки, а не отката на сервере. -- **Down пишется, когда он честно обращает up**: убрать то, что up добавил. - Не пишется, когда up необратимо трансформирует данные, — тогда его - отсутствие честнее имитации, которая молча теряет колонку. -- При изменении структуры ER-схема в спеках обновляется **в том же - изменении**, а не «потом»: разошедшаяся схема хуже отсутствующей. +### R1. Миграции ведёт goose + +**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — +goose. + +**Почему.** Журнал применённых версий goose держит в самой базе +(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй +инструмент заводит второй журнал: миграция, применённая одним, для другого +выглядит неприменённой, и попытка накатить её повторно упирается в уже +существующую таблицу. На сервере это означает ручной разбор состояния +схемы вместо автоматического деплоя. + +### R2. Файлы миграций лежат рядом со store-слоем + +**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой +схемой. + +**Почему.** Миграция и код, читающий схему, — одно изменение: колонка +появляется вместе с полем структуры и запросом. Лежащие в другом конце +дерева миграции выпадают из поля зрения при правке store, и уезжает либо +код без миграции, либо миграция без кода; расходятся они на сервере, где +схема ещё старая. + +### R3. Форма миграции выбирается по тому, нужен ли код + +**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое: + +| № | Что делает миграция | Форма | +|---|---|---| +| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | +| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | + +**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, +который уедет в базу; обёртка на Go вокруг него добавляет место, где можно +ошибиться, не добавляя ничего к результату. + +Обратное направление дороже. Перенос данных и генерация идентификаторов +выражаются на SQL либо громоздко, либо неточно: идентификатор по +`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена +завести для него второй генератор — ровно то, что запрещает +`arch/db-identifiers.md` R3. Единообразие формы здесь покупается +дублированием логики, которая уже есть в коде. + +### R4. В деплое схема движется только вперёд + +**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; +ошибка исправляется новой миграцией вперёд. + +**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует +его: колонка, которую убрал up, восстанавливается пустой, а строки, +записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом +происходит молча — миграция отчитывается об успехе. Исправление, приехавшее +следующей миграцией, оставляет целыми и данные, и журнал применённых +версий. + +### R5. Down пишется, когда он честно обращает up + +**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up: + +| № | Что делает up | Down | +|---|---|---| +| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | +| R5.2 | необратимо преобразует данные | не пишется | + +**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, +и именно там он обязан действительно обращать up. Имитация опаснее +отсутствия: разработчик применяет её, получает схему прежней формы и +продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий +down останавливает сразу и заставляет пересоздать базу — это дешевле, чем +отладка по данным, которых уже нет. + +### R6. ER-схема обновляется в том же изменении + +**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним +изменением. + +**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл. +Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить +это можно, только сверив её с миграциями, то есть проделав работу, которую +диаграмма экономит. Отложенное обновление не делается: изменение уже +влито, и повода вернуться к схеме больше нет. ## Типы колонок -- **Enum-поля** (`state`, `kind`, …) — обычный `TEXT` **без `CHECK`**. - Допустимые значения держит код. `ALTER TABLE` в SQLite не умеет менять - ограничения ни в одной версии, поэтому каждое новое значение в - `CHECK(... IN (...))` означает пересоздание таблицы по 12-шаговой - процедуре; защита от невалидного значения всё равно нужна на уровне типов - Go. -- **Метки времени** — `TEXT` в формате из `arch/time.md`. Без - `DEFAULT (datetime('now'))`: помимо того, что время ставит приложение, - эта функция даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, то есть не - тот формат. -- **Булевы** — `INTEGER` 0/1. Отдельного типа в SQLite нет, а строка - `'true'` в булевом контексте приводится к **0** — то есть тихо - инвертирует смысл, а не просто ломает фильтрацию. -- **Первичные ключи** — если репозиторий взял `arch/db-identifiers.md`, то - по ней (без `AUTOINCREMENT`); иначе автоинкремент допустим. +Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы, +а не язык приложения. + +### R7. Enum-поля — `TEXT`, допустимые значения держит код + +**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` +без `CHECK`-ограничения на список значений. + +**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной +версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))` +превращается из строки в коде в пересоздание таблицы по 12-шаговой +процедуре, с копированием данных и восстановлением внешних ключей. + +Платить эту цену не за что: невалидное значение отсекается типами Go +раньше, чем дойдёт до вставки, и `CHECK` лишь дублирует защиту, которая +всё равно нужна выше. `TEXT` при этом читается в дампе и в логе без +таблицы соответствия, которую пришлось бы держать в голове для числового +кода. + +### R8. Метки времени — `TEXT` в формате из `arch/time.md` + +**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения +пишутся в формате из `arch/time.md`. + +**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает +значения сравнимыми, — договорённость о формате. Текст в формате из +`arch/time.md` сортируется лексикографически в том же порядке, что и +хронологически: `ORDER BY` и диапазонные условия работают без функций +преобразования, а значит и без потери индекса. Соседство двух форматов в +одной колонке ломает и сравнение, и разбор на стороне Go. + +### R9. Умолчание `DEFAULT (datetime('now'))` не ставится + +**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию +на уровне схемы. + +**Почему.** Время ставит приложение, и умолчание в схеме заводит второй +источник этого значения: пропущенное приложением поле не падает, а тихо +получает время сервера базы — расхождение обнаруживается по данным, а не +по ошибке. + +Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`, +то есть не тот формат, которого требует R8. В колонке оказываются строки +двух видов, и ломается ровно то, ради чего формат выбран. + +### R10. Булевы поля — `INTEGER` со значениями 0 и 1 + +**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. + +**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя +колонку удерживает только договорённость о представлении. Цена ошибки +здесь несимметрична: строка `'true'` в булевом контексте приводится к +**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку +типа. Такой дефект не падает, не виден в логе и переживает тесты, которые +проверяют, что список не пуст. + +### R11. Вид первичного ключа задаёт `arch/db-identifiers.md` + +**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид +ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется. + +**Почему.** Вопрос о виде ключа решается один раз на репозиторий +(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы +второй источник правды, и соседние таблицы разъехались бы по разным +ответам на один и тот же вопрос. + +`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не +касается вовсе, а целочисленному даёт единственную гарантию — что значение +rowid не будет переиспользовано после удаления строки, — ценой служебной +таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта +имеет смысл, только если старые идентификаторы живут где-то вне базы. + +### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент + +**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`, +берёт целочисленный автоинкрементный ключ. + +**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование +подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня +репозитория, и конвенция про типы колонок его за репозиторий не принимает; +приложению, сущности которого не адресуют снаружи, целочисленный ключ +ничего не стоит. diff --git a/lang/go/errors.md b/lang/go/errors.md index 87bfcba..0ca68fd 100644 --- a/lang/go/errors.md +++ b/lang/go/errors.md @@ -1,140 +1,322 @@ ---- -status: рекомендуемая ---- - # Ошибки -Как ошибки строятся, оборачиваются и проверяются. Где и когда ошибку -**логировать** — в `lang/go/logging.md`, раздел «Ошибки» (коротко: лог один -раз на доменной границе). +Как ошибки строятся, оборачиваются и проверяются. Форма записи — +`common/language.md`. Где и когда ошибку **логировать** — в +`lang/go/logging.md` (коротко: лог один раз на доменной границе). -## Базовая идиома: stdlib +## Правила -- Только стандартный `errors` + `fmt.Errorf`. Контекст ошибки несёт `slog`, - а не стек: при дисциплине «каждый слой добавляет свой контекст» цепочка - сообщений локализует место не хуже стека, а стек-трейсы и Sentry - избыточны для домашнего сервиса. -- Если отладка начнёт упираться в «где именно родилась ошибка» — это - сигнал пересмотреть решение, а не дефолт, который можно обойти локально. -- Единственное исключение — восстановленная паника: у неё цепочки `%w` нет - вовсе (см. «panic»). +### R1. Ошибки строятся средствами стандартной библиотеки -## Обёртка и контекст +**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и +`fmt.Errorf`; библиотеки со стек-трейсами не подключаются. -Сервис — **приложение, а не библиотека**: внешнего Go-API нет, весь код -наш. Возражение против дефолтного `%w` («обёрнутая ошибка становится частью -API») относится к библиотекам, поэтому внутри приложения обёртка `%w` — -**дефолт**, чтобы `errors.Is` и `errors.As` работали сквозь слои. +**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. +При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений +локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт +`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки +и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса +это цена без покупателя. -- Добавляем контекст обёрткой: `fmt.Errorf("parse magnet: %w", err)`. -- `%w` — когда вызывающий может инспектировать причину (обычный случай). - `%v` — когда причину сознательно **не** раскрываем, чтобы не завязывать - вызывающего на чужой тип ошибки. -- От утечки внутренних ошибок наружу защищаемся **не** через `%v` в - цепочке, а трансляцией на внешней границе (ниже). +Единственное место, где стек всё-таки нужен, — восстановленная паника: у +неё цепочки `%w` нет вовсе (R23). -Стиль сообщения: +### R2. Дефолт не обходится точечно -- со строчной буквы, без точки в конце, без «failed to» и «error» — обёртка - и так читается как «контекст: причина»; -- контекст — операция или субъект: `"link target: %w"`, не - `"something failed"`; -- без заикания: каждый слой добавляет **свой** смысл, не повторяя нижний - (`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`). +**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте +кодовой базы ради конкретной отладки. + +**Почему.** В коде появляются два способа устроить ошибку, и вызывающий +перестаёт знать, какой перед ним: обёртки склеиваются по-разному, +`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая +локально, перестаёт накапливаться — а накопление и есть единственный +сигнал, что решение R1 пора пересматривать целиком. + +### R3. Каждый слой добавляет свой контекст + +**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с +контекстом: `fmt.Errorf("parse magnet: %w", err)`. + +**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько, +насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, +стирает участок пути — по итоговому сообщению нельзя сказать, через какую +операцию ошибка прошла, и отладка «no such file» начинается с чтения всего +кода. + +### R4. Обёртка по умолчанию — `%w` + +**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину +вызывающему: + +| № | Ситуация | Глагол | +|---|---|---| +| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | +| R4.2 | причину сознательно не раскрываем | `%v` | + +**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка +становится частью API» — относится к библиотекам с внешними потребителями. +Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт +меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает +`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у +(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил +`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда +завязывать вызывающего на чужой тип ошибки не хотят намеренно. + +### R5. Утечка внутренних деталей лечится трансляцией, а не `%v` + +**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю +ошибку наружу. + +**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу +целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, +детали утекут при любом глаголе. Подмена не решает задачу, ради которой +сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. +Настоящее место защиты — R13. + +### R6. Текст обёртки — со строчной буквы и без служебных слов + +**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». + +**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка +читается как «контекст: причина» — заглавные буквы и точки рвут эту строку +на середине. Слова «failed» и «error» не несут информации: то, что перед +нами ошибка, известно из того, что это ошибка. Зато повторяются они на +каждом уровне и вытесняют из строки полезный контекст. + +### R7. Контекст обёртки называет операцию или субъект + +**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. + +**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something +failed» не сужает ничего и при этом занимает в сообщении место, которое мог +бы занять единственный полезный здесь факт — имя операции. + +### R8. Слой не повторяет смысл нижнего + +**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: +`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. + +**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то +же событие названо дважды. Читателю приходится проверять, не два ли это +разных места в коде, — то есть заикание не просто бесполезно, оно стоит +времени при каждом чтении лога. ## Две трансляции -Ошибка меняет форму дважды, и это разные преобразования. +Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная → +доменная у источника (R9) и доменная → пользовательская на внешней границе +(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт. -**Первая — у источника, инфраструктурная → доменная.** Граничные ошибки -зависимостей транслируем там, где они возникли: `sql.ErrNoRows` → доменный -`store.ErrNotFound` в слое store, чтобы выше по коду не торчал -`database/sql`. То же для HTTP-клиентов, файловой системы, внешних SDK. +### R9. Инфраструктурная ошибка транслируется в доменную у источника -**Вторая — на внешней границе, доменная → пользовательская.** Описана -ниже, в разделе про каналы. +**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где +возникла: `sql.ErrNoRows` → `store.ErrNotFound` в слое store; то же для +HTTP-клиентов, файловой системы, внешних SDK. -## Sentinel vs типизированные +**Почему.** Иначе тип зависимости становится частью контракта всех слоёв +выше: чтобы отличить «нет записи», доменный код импортирует `database/sql` +и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не +адаптер, а все ветвления в приложении — притом что снаружи адаптера +состояние «нет записи» одно и то же. Трансляция у источника оставляет +знание о зависимости в единственном слое, который её и так знает. -- **Sentinel** (`var ErrNotFound = errors.New("not found")`) — для условий, - на которые ветвится код: нет записи, дубликат, неподдерживаемый источник. - Проверяем `errors.Is`. -- **Типизированная ошибка** (тип с полями и методом `Error()`) — когда - вызывающему нужны **данные** ошибки: поле валидации, код, лимит. Достаём - `errors.As`. Не плодим типы там, где хватает sentinel. -- Матчинг по тексту сообщения запрещён — это то же самое, что публичный - API из строки лога. +### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему -## Граница: приватный канал vs публичный +**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так: + +| № | Что нужно вызывающему | Форма | +|---|---|---| +| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | +| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | + +**Почему.** Sentinel — одно значение; сравнение с ним не зависит от +структуры ошибки и переживает добавление полей. Тип заводится ради данных, +и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой +объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на +каждой проверке. Две формы для одного условия — это два способа его +проверить, и про второй рано или поздно забудут. + +### R11. Матчинг по тексту сообщения + +**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. + +**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его +свободно. Правка формулировки в нижнем слое молча ломает ветвление +наверху, и компилятор этого не видит. Это то же самое, что публичный API из +строки лога. + +## Граница: приватный канал и публичный Внутри — богатые обёрнутые ошибки. На внешней границе форма зависит от -того, кто канал видит. +того, кто канал видит: приватный канал — логи (их читает владелец сервиса), +публичный — пользовательские поверхности (HTTP API, web-UI, бот). -**Приватный канал — логи** (владелец сервиса). Полная ошибка со всей -цепочкой `%w` и контекстом. Пишется один раз на доменной границе. +### R12. Полная ошибка идёт в приватный канал -**Публичный канал — пользовательские поверхности** (HTTP API, web-UI, бот). -Сюда отдаём: +**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно +— `lang/go/logging.md`. -- **человекочитаемое сообщение** по доменной ошибке — не сырой - `err.Error()` и не детали реализации (`database/sql`, пути, стек); -- **корреляционный ключ** для владельца — id сущности либо `request_id`, - чтобы по нему найти полную ошибку в логах. «При обработке загрузки - произошла ошибка, download_id=…» вместо «произошла ошибка»; -- **маппинг доменной ошибки → сообщение и, для HTTP, статус** — в одной - точке на все транспорты. У транспортов без статусов (бот) от маппинга - берётся только сообщение. +**Почему.** Цепочка — единственный носитель диагностики (R1), и +единственный канал, где её можно показать целиком, — тот, который видит +владелец. Не записанная там, она не сохранится нигде: наружу идёт +нейтральное сообщение (R13), и восстанавливать причину будет не из чего. -Новую штатную ветвь отказа (конфликт, валидация) заводим sentinel'ом и -**сразу добавляем в маппинг** — иначе `default` отдаст 500 «внутренняя -ошибка» на нормальный конфликт, а логирующая граница спишет его в `ERROR` -вместо `DEBUG`. +### R13. Публичная поверхность получает сообщение по доменной ошибке + +**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не +`err.Error()` и не детали реализации (`database/sql`, пути, стек). + +**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны +— у него есть лог (R12). Зато они раскрывают устройство системы — имена +таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, +причём раскрывают именно в момент, когда что-то пошло не так. + +### R14. Публичное сообщение несёт корреляционный ключ + +**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`: +«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла +ошибка». + +**Почему.** R13 забирает у пользователя всю фактуру; без ключа его +обращение звучит как «у меня что-то не работает», и владелец ищет запись в +логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной +ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже +видел. + +### R15. Маппинг доменных ошибок — в одной точке на все транспорты + +**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус» +задаётся один раз; транспорт без статусов (бот) берёт из него только +сообщение. + +**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, +и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина +важнее: единственная точка — это место, куда механически дописывается новая +ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде» +ничем не проверяет. + +### R16. Новая штатная ветвь отказа сразу попадает в маппинг + +**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и +добавляется в маппинг (R15) тем же изменением. + +**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 +«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает +его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают +шуметь в логе ровно там, где по нему ищут настоящие поломки. -### Транзиентный ответ vs персистентная диагностика +### R17. Форма текста определяется поверхностью -У публичной границы две разные поверхности, и правило сырого текста для них -разное: +**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого +текста для них разное: -- **Транзиентный ответ на действие** (тело ответа, `?err=`, реплика бота по - результату команды) — строго нейтральный: маппинг выше, `err.Error()` - наружу не идёт, полная ошибка живёт в логах по корреляционному ключу. -- **Персистентная диагностика состояния** — причина ухода записи в - ошибочное состояние, сохранённая в БД и показываемая оператору. Здесь - сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) допустим и - полезен — **но только пока поверхность видит исключительно владелец**. - Появился второй зритель или публичный доступ к экрану состояния — - поверхность стала публичным каналом, и правило нейтрального текста - распространяется на неё. Секреты запрещены абсолютно в обоих случаях; - источник вычищается на границе клиента. +| № | Поверхность | Текст ошибки | +|---|---|---| +| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт | +| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец | -Различие работает, только если поверхности не смешиваются в одном поле. -Диагностику кладём в **отдельное поле**, а не в доменное. +Появился второй зритель или публичный доступ к экрану состояния — +поверхность стала публичным каналом, и на неё распространяется R17.1. + +**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст +ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную +диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот +эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла +ошибка» в таком поле не несёт ничего и делает поле бессмысленным. Условие +про единственного зрителя — ровно то, что делает вторую поверхность +приватным каналом; без него это обычная публичная поверхность. + +### R18. Секретов нет ни на одной из поверхностей + +**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ, +ни в персистентную диагностику; источник вычищается на границе клиента. + +**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в +БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию +самого секрета. Вычистка на границе клиента — единственное место, где ещё +известно, какие поля запроса секретны: дальше ошибка едет как текст, и +отличить в нём токен от идентификатора уже нельзя. + +### R19. Диагностика хранится в отдельном поле + +**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое +показывают пользователю. + +**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей +разные поля. Одно поле на оба назначения означает, что при первом же показе +записи наружу сырой текст уедет туда же — не по решению, а потому что поле +одно. ## panic -- `panic` — только для невосстановимого: нарушенный инвариант (баг - программиста), ошибка инициализации, из которой нельзя стартовать. -- Не для управления потоком и не для ожидаемых ошибок (нет сети, плохой - ввод) — это значения `error`. -- **`recover` — на верхней границе каждой обрабатывающей единицы**, а не - только у HTTP: - - HTTP middleware — `net/http` сам восстанавливает панику в хендлере и - процесс не роняет, поэтому смысл своего `recover` в другом: отдать - контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер; - - цикл обработки апдейтов бота и фоновый воркер — вот здесь паника в - горутине **действительно роняет процесс**, и `recover` обязателен. - `recover` работает только в той горутине, где случилась паника. -- **Логирующая recover-граница пишет `debug.Stack()`.** Это единственное - место, где нужен стек-трейс: у восстановленной паники нет цепочки `%w`, и - без стека «index out of range» не диагностируется вообще. +### R20. `panic` — только для невосстановимого + +**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и +ошибка инициализации, из которой нельзя стартовать. + +**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте +нельзя, можно только уронить единицу обработки. Это верный ответ, когда +состояние процесса перестало описываться кодом: работа с нарушенным +инвариантом опаснее падения, а сервис, стартовавший без обязательной +зависимости, всё равно откажет позже и непонятнее. + +### R21. Ожидаемые ошибки — значения `error` + +**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, +плохой ввод, отсутствующая запись возвращаются как `error`. + +**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном +отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит +его обработать. Дальше такая паника долетает до recover-границы (R22), где +неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией +«мы сломались». + +### R22. `recover` — на верхней границе каждой обрабатывающей единицы + +**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный: + +| № | Единица | Зачем `recover` | +|---|---|---| +| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | +| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | + +**Почему.** `recover` работает только в той горутине, где случилась паника, +поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у +каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с +неожиданным полем гасят весь сервис, включая части, к этой ошибке +отношения не имеющие. У HTTP цена бездействия ниже, но не нулевая: паника +без своего `recover` уходит мимо структурированного лога, а клиент получает +оборванное соединение вместо ответа. + +### R23. Recover-граница пишет `debug.Stack()` + +**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. + +**Почему.** Это единственное место, где стек нужен (R1): у восстановленной +паники цепочки `%w` нет вовсе. «index out of range» без стека не +диагностируется в принципе — сообщение не называет ни файла, ни операции, +по нему нельзя сказать даже, в каком пакете упало. ## Несколько ошибок -Сбор независимых ошибок (валидация конфига — все проблемы разом) — -`errors.Join`; проверка собранного по-прежнему через `errors.Is`. +### R24. Независимые ошибки собираются `errors.Join` + +**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы +разом; проверка собранного — по-прежнему через `errors.Is`. + +**Почему.** Возврат первой ошибки превращает починку конфига в серию +перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт +тот же список, но убивает ветвление: `errors.Is` по такому результату не +находит ничего, и вызывающий остаётся с текстом, матчить который запрещено +(R11). + +## Связано + +- `lang/go/logging.md` — где и когда ошибка попадает в лог. +- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14. diff --git a/lang/go/logging.md b/lang/go/logging.md index a971272..88fa1b7 100644 --- a/lang/go/logging.md +++ b/lang/go/logging.md @@ -1,5 +1,4 @@ --- -status: рекомендуемая extends: arch/time.md --- @@ -7,164 +6,404 @@ extends: arch/time.md Как и когда писать логи. Это правила оформления кода (How), а не спецификация поведения: наблюдаемые требования к логам, входящие в контракт -функциональности, живут в спеках. +функциональности, живут в спеках. Форма записи — `common/language.md`. -## Принципы - -- Структурированный JSON (`slog.JSONHandler`), **один формат для dev и - prod**. Не потому, что текстовый вывод «расходит поля» — смена хендлера - структуру атрибутов не меняет; а потому, что с текстовым dev-выводом - перестаёшь ежедневно гонять собственные `jq`-пайплайны, и поломки - словаря замечаются только в проде. -- Сообщение (`msg`) — категория события; данные — в полях. Каждое поле — - отдельный ключ с типизированным значением: это даёт фильтрацию и - агрегацию через `jq`/DuckDB без регулярок. +Лог читают инструментами, а не глазами: повседневно — `jq` +(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) — +DuckDB поверх JSONL прямо из файла. Отсюда почти все правила ниже: запись +существует для запроса к ней. ```json {"time":"2026-06-28T11:23:45.123Z","level":"INFO","msg":"download accepted","download_id":"01jz2k7f8q9r3s4t5v6w7x8y9z","media_type":"movie"} ``` -## Время в записи +## Формат записи -Поле `time` ставит `slog`, но **UTC он по умолчанию не даёт**: встроенные -хендлеры пишут время в зоне самого `time.Time`, то есть в локальной зоне -процесса. UTC ставится `ReplaceAttr` по `slog.TimeKey` — см. -`lang/go/time.md`. Точность `JSONHandler` — миллисекунды, фиксированная -ширина; это другая точность, чем в БД, и по `arch/time.md` так и должно -быть: ширина фиксируется на носитель. +### R1. Структурированный JSON, один формат для dev и prod + +**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в +проде. + +**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена +хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым +dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и +поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное +значение) обнаруживаются только в проде, где заметить их заранее уже +некому. + +### R2. Данные — в типизированных полях, а не в тексте сообщения + +**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа. + +**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная +в текст, достаётся только регуляркой, а регулярка ломается при первой же +правке формулировки. Тип важен отдельно от ключа: число внутри строки не +сравнивается и не суммируется, то есть попадает в лог, но не в отчёт. + +### R3. Время записи — UTC + +**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey` +(см. `lang/go/time.md`). + +**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время +в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного +процесса до и после смены TZ (или записи рядом с данными из БД) перестают +складываться в одну хронологию, причём сдвиг на целые часы глазом не виден +— в отличие от явно неверной даты, он выглядит как правдоподобный порядок +событий. + +Точность `JSONHandler` — миллисекунды фиксированной ширины; это другая +точность, чем в БД, и по `arch/time.md` так и должно быть: ширина +фиксируется на носитель. ## Сообщение -- `msg` — короткая **константа** в нижнем регистре: `download accepted`, - `recognition done`, `layout failed`. Данные — в атрибутах: - `log.Info("download accepted", "download_id", id)`. -- `msg` — чистая категория **без неймспейс-префикса**: `recognition done`, - а не `recognize: done`. Подсистема — отдельное поле, не текст. -- **Смена состояния сущности — единая категория** (`state transition`) с - полями `from`/`to`/`code`. Какое именно состояние и по какой причине — - это данные, а не текст. Тогда весь жизненный цикл собирается одним - фильтром. Физический эффект сверх перехода — отдельная запись своей - категории, она не подменяет запись перехода. +### R4. `msg` — константа в нижнем регистре + +**ДОЛЖЕН.** Текст сообщения не собирается из переменных: +`log.Info("download accepted", "download_id", id)`. + +**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция +превращает одну категорию в множество уникальных строк, и вопрос «сколько +раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы +одна категория не двоилась на варианты, различающиеся только заглавной +буквой. + +### R5. `msg` не несёт префикса подсистемы + +**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — +отдельное поле. + +**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и +фильтр по подсистеме становится сопоставлением с началом строки вместо +сравнения значения поля. Заодно это второй способ записать одно и то же: +категория дробится на варианты с префиксом и без, а совпадать они обязаны +посимвольно. + +### R6. Смена состояния сущности — единая категория + +**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно +состояние и по какой причине — данные, а не текст. + +**Почему.** С отдельной категорией на каждый переход жизненный цикл +сущности собирается перечислением всех известных `msg` — и переход, +добавленный в код позже, в это перечисление не попадёт: выборка тихо +останется неполной. Единая категория даёт весь цикл одним фильтром и не +требует обновлять запрос вслед за кодом. + +### R7. Физический эффект — отдельная запись, а не вместо перехода + +**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет +запись самого перехода. + +**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых +был заметный эффект, — то есть самые интересные. Вторая запись стоит одной +строки в логе; восстановление пропущенного перехода не стоит ничего, потому +что невозможно. ## Уровни -Принцип: уровень — это **адресат** («кому сообщение»), а не «насколько +### R8. Уровень выбирается по адресату + +**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько громко сломалось». -| Уровень | Кому и когда | -|---|---| -| `DEBUG` | разработчику при отладке; в проде выключен | -| `INFO` | владельцу, аудит постфактум | -| `WARN` | владельцу, «может стать проблемой» | -| `ERROR` | владельцу, в разбор | +| № | Уровень | Кому и когда | +|---|---|---| +| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен | +| R8.2 | `INFO` | владельцу, аудит постфактум | +| R8.3 | `WARN` | владельцу, «может стать проблемой» | +| R8.4 | `ERROR` | владельцу, в разбор | -Правила: +**Почему.** Адресат — единственный признак, по которому разные авторы в +разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый +оценивает по-своему, шкала расползается — и вместе с ней теряет смысл +базовый порог в проде (R40), потому что он отсекает уже не то, что +задумано. -- Уровень **не зависит от подсистемы**: `ERROR` везде одинаково серьёзен. -- `WARN` ≠ «ничего страшного». `WARN` = «может стать проблемой». Если это - не «может» — это `INFO`. -- Меняется адресат — меняется уровень. Невалидный ввод от пользователя — - `DEBUG` (норма, разбирать нечего), а не `ERROR`. -- **Событийное → `INFO`, рутинно-частое → `DEBUG`.** Операция по реальному - действию или изменению — `INFO`. Повторяющаяся служебная операция, - запускаемая таймером или поллингом и сама по себе не несущая события - (healthcheck, опрос статуса, авто-рефреш UI), — `DEBUG`: на `INFO` она - зашумляет аудит. -- `slog` не разделяет CRITICAL/FATAL — фатальный сбой на старте логируем - `ERROR` и завершаем процесс с ненулевым кодом. +### R9. Уровень не зависит от подсистемы + +**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` +везде одинаково серьёзен. + +**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если +в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить +происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть +уровень перестаёт быть фильтром и становится подсказкой, требующей знания +кода. + +### R10. `WARN` — только когда «может стать проблемой» + +**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. + +**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится +«ничего страшного», его перестают читать — и вместе с шумом теряется то +единственное, ради чего уровень существует: предупреждение, на которое ещё +есть время отреагировать. + +### R11. Событийное — `INFO`, рутинно-частое — `DEBUG` + +**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие. + +| № | Операция | Уровень | +|---|---|---| +| R11.1 | по реальному действию или изменению | `INFO` | +| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | + +**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность +определяется долей записей, за которыми что-то стоит. Периодическая +операция даёт ровный поток при нулевой информации, в котором настоящие +события тонут количественно: их не отфильтровать, потому что фильтровать +приходится по содержанию, а не по уровню. + +### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата + +**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую +степень даёт завершение процесса. + +**Почему.** Супервизор (docker, journald, systemd) отличает падение от +штатной остановки по коду возврата, а не по уровню последней записи. +Процесс, который написал `ERROR` и продолжил жить с неработающей +конфигурацией, выглядит здоровым и будет получать трафик; изобретать же +уровень выше `ERROR` не нужно — сам факт завершения информативнее. ## Поля: единый словарь -Главное условие — **одно поле, одно имя по всему коду** (не -`mediaType`/`media`/`media_type` вперемешку). +### R13. Одно поле — одно имя по всему коду -- Бизнес-поля — плоский `snake_case`. -- Системные домены — точечная иерархия (адаптация OpenTelemetry): `http.*`, - `ext.*`. -- JSON плоский: все поля на верхнем уровне, без вложенности. +**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. -| Когда добавляем | Поля | -|---|---| -| входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного | -| работа с сущностью (scoped-логгер) | `_id` и доменные атрибуты | -| запись об ошибке | `error` | -| вызов внешнего сервиса | `ext.service`, `ext.operation`, `ext.status_code`, `duration_ms`, `retry` | +**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той +же величины делает любую выборку по ней молча неполной: фильтр отработает, +часть записей в него не попадёт, и заметить это можно, только заранее зная, +что они должны были быть. -`service.*` и `host.*` не заводим — для одного бинаря на одном хосте это -шум. Если появятся несколько инстансов, добавим `service.version` одной -строкой при старте. +### R14. Форма имени зависит от вида поля + +**ДОЛЖЕН.** Две формы, третьей нет. + +| № | Вид поля | Форма имени | +|---|---|---| +| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | +| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | + +**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые +в любом проекте, от доменных, которые в каждом свои: по общему префиксу +запрос «все внешние вызовы» пишется без перечисления имён. Заимствование +словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже +названо, и спорить о них на каждом ревью. + +### R15. Запись плоская + +**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть +имени, а не уровень вложенности. + +**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой +записи независимо от её категории. Вложенность требует знать глубину +заранее, а она у разных категорий разная — и один запрос перестаёт покрывать +весь лог, распадаясь на запрос под каждую форму записи. + +### R16. Набор полей определяется ситуацией + +**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком. + +| № | Когда добавляем | Поля | +|---|---|---| +| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport` — если транспортов больше одного | +| R16.2 | работа с сущностью (scoped-логгер) | `_id` и доменные атрибуты | +| R16.3 | запись об ошибке | `error` | +| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | + +**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает +на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, +`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас +баг», запись о сущности без идентификатора не корреллируется (R19). Полный +набор делает записи однородными — один запрос работает по всем вызовам, а +не по тем, где автор вспомнил про поле. + +### R17. `service.*` и `host.*` не заводим + +**НЕ СЛЕДУЕТ.** Пока это один бинарь на одном хосте. + +**Почему.** Поле с одним и тем же значением во всех записях не несёт +информации, но стоит места в каждой строке и внимания при чтении. Условие +названо явно, поэтому правило отпадёт вместе со своей причиной: с +появлением нескольких инстансов различающее поле (`service.version`) +добавляется одной строкой при старте. -## Корреляция по id сущности +## Корреляция -Отдельный случайный `trace_id` не заводим, **если у сущностей есть -стабильные уникальные идентификаторы** — они и служат ключом корреляции. -(Как их выбирают — `arch/db-identifiers.md`, если конвенция взята.) +### R18. Ключ корреляции — идентификатор сущности, а не `trace_id` -- Каждая запись, относящаяся к сущности, несёт её id в поле `_id`. - Для долгой операции — scoped-логгер, протаскиваемый через - `context.Context` сквозь асинхронные стадии, чтобы ключ дописывался сам: +**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у +сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — +`arch/db-identifiers.md`, если конвенция взята.) + +**Почему.** Идентификатор сущности уже существует, стабилен между +процессами и во времени — по нему собираются записи не одного прохода, а +всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое +только внутри одной операции, то есть дублирует ключ и добавляет второй +способ спросить об одном. Условие применимости названо: там, где сущности +со стабильным идентификатором нет, связывать записи больше нечем. + +### R19. Запись о сущности несёт её идентификатор + +**ДОЛЖЕН.** Поле `_id` в каждой записи, относящейся к сущности. + +**Почему.** Принадлежность записи восстанавливается только в момент +записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. +Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем, +что идентификатор стоит везде, а не в удобных местах. + +Все записи одной операции собираются одним фильтром: +`jq 'select(.download_id=="01jz…")' app.jsonl`. Если идентификатор +глобально уникален across сущностей, штатно работает и простой `grep` по +голому значению — он находит все упоминания независимо от имени поля. + +### R20. Долгая операция ведётся scoped-логгером через `context.Context` + +**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные +стадии: ```go log := log.With("download_id", id) ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии ``` -- Все записи одной операции собираются одним фильтром: - `jq 'select(.download_id=="01jz…")' app.jsonl`. -- Если id глобально уникален across сущностей, штатно работает и простой - `grep` по голому id — он находит все упоминания независимо от имени поля. +**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в +редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее +всего. Логгер из контекста дописывает ключ сам, и запись без +идентификатора становится невозможной, а не маловероятной. ## Ошибки -Go-ошибки логируем **атрибутом**, не текстом сообщения: -`log.Error("layout failed", "error", err, "download_id", id)`. Ключ — -`error` (как по умолчанию в zap/zerolog: единый ключ важнее краткости). +### R21. Ошибка логируется атрибутом `error` -- Идиома Go — **либо лог, либо возврат, не оба**. Промежуточные слои только - оборачивают и возвращают (`%w`), не логируя: контекст накапливается в - цепочке. -- Логируем ошибку **один раз — на границе доменного слоя**, которая - определяет исход операции. Логирует этот единый чокпоинт, а не каждый - транспорт: так транспорты остаются тонкими, и один сбой не даёт дублей. +**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`. + +**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и +уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, +как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна +зависеть от того, кто писал конкретный вызов, и ради этого единообразия +краткостью жертвуют. + +### R22. Промежуточный слой либо логирует, либо возвращает + +**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только +оборачивает (`%w`). + +**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, +и количество `ERROR` перестаёт соответствовать количеству отказов — а +считают именно его. Контекст при этом не теряется: он накапливается в +цепочке обёрток и попадает в единственную запись на границе (R23). + +### R23. Ошибка логируется один раз — на границе доменного слоя + +**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. + +**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и +этим местом выбрана доменная граница, а не транспорт, потому что там +известен исход операции целиком и, значит, класс отказа (R25) — транспорт +знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: +транспорты остаются тонкими. -- Транспорты переводят возвращённую ошибку в свой ответ (статус, сообщение - пользователю) и **не логируют** её повторно. -- **Уровень доменного отказа — по адресату, а не по месту.** У каждой - доменной ошибки ровно один логирующий; уровень выбирает он: +### R24. Транспорт не логирует ошибку повторно - | Класс отказа | Кому | Уровень | - |---|---|---| - | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | - | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | - | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | +**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ +(статус, сообщение пользователю) и на этом останавливается. -- Тот же класс отказа в **асинхронной стадии** (пользователь не ждёт) - адресован уже владельцу как деградация автоматики — уровень поднимается. - Коллизия в ручном действии — `DEBUG` (человек видит причину на экране), в - авто-обработке — `WARN` (автоматика не довела задачу). -- **Повторяющийся сбой фонового цикла — `WARN`, не `ERROR`.** Одиночный - промах тика транзиентен: следующий тик повторит. Тот же класс сбоя внутри - синхронной операции — `ERROR`, потому что операция провалилась целиком и - повтора нет. Уровень задаёт не текст ошибки, а **наличие штатного - повтора**. +**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё +только формулировкой и читается как второй сбой. Когда транспортов над +одним доменом несколько, дублирование ещё и множится, а расследование +начинается с вопроса, один это инцидент или два. + +### R25. Уровень доменного отказа — по классу отказа + +**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по +классу, а не по месту в коде. + +| № | Класс отказа | Кому | Уровень | +|---|---|---|---| +| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` | +| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | +| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | + +**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на +экране — владельцу разбирать нечего; целостность первичных данных отделяет +«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный +уровень для одного и того же отказа в зависимости от того, какой транспорт +его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с +упавшей базой. + +### R26. Тот же отказ в асинхронной стадии — уровнем выше + +**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован +владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, +она же в авто-обработке — `WARN`. + +**Почему.** В ручном действии человек видит причину на экране и сам решает, +что делать дальше; запись нужна только для отладки. В автоматике не увидел +никто, задача осталась недоведённой, и лог — единственное место, где это +вообще проявится. + +### R27. Повторяющийся сбой фонового цикла — `WARN` + +**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: +уровень задаёт наличие штатного повтора, а не текст ошибки. + +**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и +вмешательство не требуется; `ERROR` на каждый такой промах обесценивает +уровень, на который смотрят в первую очередь. Синхронная операция повтора +не имеет: она провалилась целиком, результат никто не восстановит, и это +ровно тот случай, ради которого `ERROR` держат чистым. + +## Внешние сервисы + +### R28. Каждый вызов внешнего сервиса логируется + +**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4. + +**Почему.** Это единственный способ отличить «у нас баг» от «зависимость +легла»: на своей стороне видно лишь то, что операция не удалась. +Выборочное логирование ломает и второе применение — доля неуспехов и +распределение `duration_ms` считаются, только если знаменатель полный. + +### R29. Уровень `ext`-записи — по исходу вызова + +**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями. + +| № | Исход | Уровень | +|---|---|---| +| R29.1 | успешный событийный вызов | `INFO` | +| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` | +| R29.3 | попытка не удалась, делается retry | `WARN` | +| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | + +**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: +операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы +уровень непригодным для главного вопроса «зависимость доступна?». +Исчерпание ретраев и есть момент, когда транспорт сдался и дальше +разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение +событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет +аудит так же, как любой другой. ## Два цикла повтора — не путать Слово «ретрай» означает два разных механизма, и уровень считается по -каждому отдельно: +каждому отдельно: повтор вызова внутри одной операции (ретраи HTTP-клиента) +задаёт уровень `ext`-записи, повтор тика внешним циклом (поллинг, сверка) — +уровень доменной записи об исходе тика. -- **Повтор вызова внутри одной операции** (ретраи HTTP-клиента) — по нему - выбирается уровень **`ext`-записи**: `WARN` на попытку, `ERROR` когда - попытки исчерпаны. -- **Повтор тика внешним циклом** (поллинг, сверка) — по нему выбирается - уровень **доменной записи** об исходе тика: `WARN`, потому что следующий - тик повторит. +``` +WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4) +AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27) +``` Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR` каждый тик. Это и есть механизм эскалации: доменный слой не паникует, а @@ -172,71 +411,140 @@ Go-ошибки логируем **атрибутом**, не текстом с `ERROR` от поллинга мешает — это лечится понижением частоты тика или подавлением повторов в самом клиенте, а не переклассификацией уровня. -## Внешние сервисы: логируем все вызовы +### R30. Ответ 4xx — успех на транспортном уровне -**Каждый** вызов внешнего сервиса логируется — это единственный способ -отличить «у нас баг» от «зависимость легла». Поля: `ext.service`, -`ext.operation` (логическая операция, не URL), `ext.status_code`, -`duration_ms`, `retry`. - -Уровни: - -- `INFO` — успешный **событийный** вызов; -- `DEBUG` — успешный **рутинно-частый** вызов (поллинг, авто-рефреш); -- `WARN` — попытка не удалась, делаем retry; -- `ERROR` — ретраи исчерпаны, сервис недоступен. - -Завершённый HTTP-ответ с 4xx — это **успех на транспортном уровне** +**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов (`ext.status_code` записан); решение «это ошибка» принимает доменный -вызывающий. Тело запроса и ответа — только на `DEBUG` и после вычистки -секретов. +вызывающий. + +**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и +разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис +недоступен» с «сервис ответил нам нет» — это разные инциденты с разной +реакцией, и различает их как раз `ext`-уровень. Что 404 значит для +операции, знает только вызывающий: для одной это отказ, для другой — +штатный ответ. ## HTTP и healthcheck -- Входящие запросы логируем с `http.*` и `duration_ms` на **`INFO`**: это - аудит обращений, а не отладка. Уровень не понижается из-за кода ответа — - 4xx остаётся `INFO`-записью доступа; решение «это ошибка» принимает - доменный слой и пишет свою запись. -- Для корреляции запроса допустим `request_id` — это отдельный слой от - корреляции по сущности и не противоречит отказу от `trace_id`. -- **Healthcheck, liveness, readiness — `DEBUG`.** Их дёргают периодически, - на `INFO` они забивают аудит; в проде с базовым `INFO` они не пишутся. +### R31. Входящий запрос — `INFO` независимо от кода ответа + +**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа. + +**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и +когда приходил», и ценность у неё одинаковая при любом коде ответа. +Уровень, зависящий от кода, делает аудит неполным именно на тех запросах, +которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись +(R25) — она и адресована по-другому. + +### R32. Для корреляции запроса допустим `request_id` + +**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности. + +**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id` +правило R18. Не запрещает: R18 отказывается от случайного ключа там, где +уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной +сущности нет — связать его записи между собой больше нечем. + +### R33. Healthcheck, liveness, readiness — `DEBUG` + +**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. + +**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают +его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из +аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в +опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся +доступной при отладке. ## Безопасность: что не логируем -Никаких секретов в полях и сообщениях: пароли и cookie сессий, API-ключи и -токены, `Authorization`-заголовки, аутентификационные параметры в ссылках. +### R34. Секреты не логируются -- Тела ответов внешних API и сырой вывод LLM (недоверенный, может быть - большим) — только на `DEBUG`, с вычисткой и обрезкой по длине. -- При сомнении — не логируем значение, логируем факт его наличия - (`"has_api_key", true`). -- **Ошибка HTTP-транспорта несёт URL — потенциальный носитель секрета.** - `*url.Error` встраивает полный URL запроса, а секрет может жить прямо в - нём: токен в пути, `api_key` в query. Go редактирует только пароль из - userinfo, остального не трогает. Санитизируем на границе клиента **до** - лога и обёртки: разворачиваем `*url.Error` в первопричину. Цена — - теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на причину - сохраняется); альтернатива с редактированием URL сохранила бы структуру, - но сложнее. Порядок важен: санитизация идёт **раньше** трансляции ошибки - в доменную (`lang/go/errors.md`), иначе секрет уедет в обёртку. -- Общее правило: **секрет не кладём в URL, если у API есть заголовок** — - тогда его нет и в ошибке транспорта. +**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий, +API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры +в ссылках. + +**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код, +и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован +с момента записи, а не с момента, когда это заметили, и вычистить его задним +числом из уже собранных копий нельзя. + +### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки + +**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — +`DEBUG`, с вычисткой секретов и обрезкой по длине. + +**Почему.** Содержимое пришло снаружи: размер не ограничен, состав +неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` +выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией; +обрезка не даёт одной записи вытеснить весь остальной лог за период. + +### R36. При сомнении логируется факт, а не значение + +**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. + +**Почему.** Для отладки почти всегда достаточно ответа «значение было или +не было» — потеря полезности близка к нулю, а риск снимается целиком. +Правило нужно потому, что решение принимается в момент написания строки, +когда чувствительность значения ещё неочевидна, а перечитывать этот выбор +никто не придёт. + +### R37. `*url.Error` санитизируется на границе клиента + +**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до +обёртки — раньше трансляции в доменную (`lang/go/errors.md`). + +**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт +прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль +из userinfo, остального не трогает, поэтому ошибка уносит секрет и в +обёртку, и в лог целиком. Порядок — часть нормы: санитизация после +трансляции уже опоздала, секрет к этому моменту скопирован в текст обёртки. +Цена — теряется `Op` и сам факт «это был HTTP-транспорт» (`errors.Is` на +причину сохраняется); альтернатива с редактированием URL сохранила бы +структуру, но сложнее. + +### R38. Секрет не кладётся в URL, если у API есть заголовок + +**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого +способа нет. + +**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и +в любую запись, куда URL попал целиком, — то есть обязывает помнить про +санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. +Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. ## Куда пишем -- JSON в `stdout` одним потоком; сбор и ротацию делает окружение (docker, - journald). По файлам не маршрутизируем. -- Базовый уровень в проде — `INFO`, `DEBUG` включается конфигом. dev — - `DEBUG`. +### R39. Логи идут в `stdout` одним потоком -## Анализ +**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам +не маршрутизируем. -- Повседневно — `jq`: `jq 'select(.download_id=="a1b2")' app.jsonl`. -- Тяжёлое (агрегации, JOIN) — DuckDB поверх JSONL прямо из файла. +**Почему.** Приложение, которое само решает, что куда писать, дублирует +работу супервизора и расходится с ней при первой же смене окружения: срок +хранения, сжатие и ротация оказываются настроены в двух местах и по-разному. +Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам +теряет его ровно там, где важен ход событий. + +### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev + +**ДОЛЖЕН.** `DEBUG` в проде включается конфигом. + +**Почему.** Уровень — единственный регулятор объёма, доступный без +пересборки; если `DEBUG` в проде включается только правкой кода, его не +включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, +что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2). + +## Связано + +- `arch/time.md` — точность и зона меток времени фиксируются на носитель. +- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3). +- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно + санитизации (R37). +- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы, + на которых держится корреляция (R18). diff --git a/lang/go/time.md b/lang/go/time.md index 1957959..f5c864c 100644 --- a/lang/go/time.md +++ b/lang/go/time.md @@ -1,43 +1,105 @@ --- -status: рекомендуемая extends: arch/time.md --- # Время: реализация на Go -## Единая точка +Как требования `arch/time.md` выполняются в Go-коде: откуда берётся +«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами. +Форма записи — `common/language.md`. -- «Сейчас» берём у слоя хранилища — `store.Now()`, а не `time.Now()` по - коду. Ценность точки — **гарантированный UTC и один формат**: `Now()` - возвращает `time.Now().UTC()`, и ни одна ветка кода не может об этом - забыть. Побочно это единственное место, которое придётся превратить в - переменную или поле, если однажды понадобится подменять часы в тестах, — - но само по себе оно тестируемости не даёт. -- Форматирование и разбор — `store.FormatTime` / `store.ParseTime` поверх - `time.RFC3339`. -- Запрет прямого `time.Now()` механизируется `forbidigo`. Исключений - ровно два, и оба обязаны быть прописаны, иначе конвенция противоречит - сама себе: сама точка `Now()` и обёртка измерения длительности (ниже). +## Правила -## Точность и разбор +### R1. «Сейчас» берётся у слоя хранилища -- В БД — **секундная точность**, ширина 20 символов - (`2026-06-28T11:23:45Z`). Она получается сама: layout `time.RFC3339` не - содержит долей секунды, поэтому `Format` их не выведет. -- `time.RFC3339Nano` не используем: он отбрасывает хвостовые нули и ломает - фиксированную ширину. -- `time.Parse(time.RFC3339, …)` принимает и доли, и не-`Z` офсеты, то есть - канонический вид гарантирует **писатель**, а не читатель. Для одного - писателя этого достаточно; чужой вход нормализуем явно. -- В драйвер отдаём строку из `FormatTime`, а не `time.Time`: колонка — - `TEXT`, и промежуточное преобразование драйвером нам не нужно. +**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего +`time.Now().UTC()`, а не из `time.Now()` по коду. -## Логи +**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна +ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением +всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она +когда-то локальной, и восстановить смещение задним числом не по чему. -`slog` по умолчанию **не даёт UTC**: встроенные хендлеры пишут время в зоне -самого `time.Time`, то есть в локальной зоне процесса, — на ноутбуке -разработчика логи молча поедут в `+03:00`. UTC ставится `ReplaceAttr` по -`slog.TimeKey`: +Тестируемость мотивом **не является**: точка — единственное место, которое +придётся превратить в переменную или поле, если однажды понадобится +подменять часы, но само по себе оно подмены не даёт. + +### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime` + +**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ +получить строку времени и прочитать её обратно. + +**Почему.** Layout, набранный по месту вызова, превращает формат хранения в +свойство каждой отдельной строки кода. Фиксированная ширина (R4) и +взаимная обратимость записи и чтения держатся ровно до первого второго +layout — а расхождение проявится не на записи, а при сравнении значений, +записанных разными местами. + +### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий + +**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба +прописаны явно: + +| № | Исключение | Почему оно не покрывается R1 | +|---|---|---| +| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | +| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) | + +**Почему.** R1 без механической проверки держится на внимании, а +`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; +нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. +Исключения перечисляются исчерпывающе, потому что каждое из них — само по +себе нарушение запрета: непрописанные, они либо роняют линтер, либо будут +«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить +сама себе. + +### R4. В БД время хранится с секундной точностью, ширина 20 символов + +**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. + +**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому +лексикографический порядок совпадает с хронологическим только при +одинаковых ширине и форме. Значение с долями секунды сортируется **раньше** +целой секунды того же момента (`.` меньше `Z`), то есть ломаются и +`ORDER BY`, и диапазонные условия — на конкретных данных, а не на всех +сразу. + +Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды, +поэтому `Format` их не выведет. + +### R5. `time.RFC3339Nano` не используется + +**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. + +**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит +от значения: соседние записи получают разную ширину, и свойство, на котором +держится R4, исчезает незаметно. Проверка «формат корректен» при этом +проходит — отказывает только порядок. + +### R6. Чужой вход нормализуется явно + +**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится +к каноническому виду явно, а не считается каноническим по факту успешного +разбора. + +**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и +офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует +**писатель**, а не читатель; пока писатель один, этого достаточно, но +значение из чужой системы, положенное в базу как пришло, нарушает R4 и +обнаруживается не на записи, а на первой сортировке. + +### R7. В драйвер передаётся строка, а не `time.Time` + +**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. + +**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование +драйверу: появляется вторая точка формата вне `FormatTime` (R2), с +собственным layout, который меняется вместе с версией драйвера, а не вместе +с конвенцией. + +### R8. Время в логах приводится к UTC через `ReplaceAttr` + +**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`: ```go func utcTime(_ []string, a slog.Attr) slog.Attr { @@ -48,24 +110,62 @@ func utcTime(_ []string, a slog.Attr) slog.Attr { } ``` -`JSONHandler` пишет миллисекунды — три знака, фиксированная ширина. Это -другая точность, чем в БД, и это нормально: ширина фиксируется на носитель -(см. базу). +**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут +время в зоне самого `time.Time`, то есть в локальной зоне процесса — на +ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое, +неверная зона выглядит как совершенно валидное время, а записи из разных +мест перестают складываться в одну хронологию с метками хранилища. -## Длительность +### R9. Точность времени в логах отличается от точности в БД -Обёртка измерения — **легитимное исключение из запрета `time.Now()`**, и -без него не обойтись: `store.Now()` приводит время к UTC через `.UTC()`, а -это **срезает монотонную составляющую** `time.Time`. Интервал, посчитанный -по таким меткам, зависит от подводки часов. Поэтому обёртка берёт -`time.Now()` напрямую и считает `time.Since` — с локальным `//nolint`. +**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не +приводится к секундной точности R4. -## Зоны +**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование +одной точности везде. Ширина фиксируется на носитель: три знака в логе — +такая же фиксированная ширина, и свойство, ради которого R4 существует, не +нарушено. Общее у лога и базы одно — зона (R8). -`time/tzdata` импортируется в `main`, зона отображения валидируется -загрузчиком конфига — см. `lang/go/config.md`. Применяется она только в -шаблонах и форматтерах UI; календарные вычисления бизнес-логики берут зону -явно, как описано в базе. +### R10. Обёртка измерения длительности берёт `time.Now()` напрямую + +**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since` — с +локальным `//nolint`. + +**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это +срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким +меткам, зависит от подводки часов: перевод назад даёт отрицательную +длительность, скачок вперёд — выброс в измерениях, и оба случая +невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается +как недосмотр и его «чинят». + +### R11. `time/tzdata` импортируется в `main` + +**ДОЛЖЕН.** База зон вшивается в бинарь. + +**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых +в минимальном образе нет: отказ происходит в рантайме, на первой же попытке +применить зону, — то есть после выкладки, а не на сборке. Импорт именно в +`main` держит это решение в одном видимом месте, а не в случайном пакете, +откуда его удаляют при чистке зависимостей. + +### R12. Зона отображения применяется только в UI + +**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах +представления, но не в хранимых значениях и не в вычислениях. + +**Почему.** Зона отображения — настройка, и её меняют. Протекая в +вычисления и хранение, она делает уже записанные данные зависимыми от +текущего значения настройки: смена зоны задним числом сдвигает границы +суток у того, что давно посчитано и сохранено. + +Календарные вычисления бизнес-логики берут зону явно — как описано в +`arch/time.md`. + +## Связано + +- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в + календарных вычислениях. +- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига. diff --git a/stack/htmx/web-ui.md b/stack/htmx/web-ui.md index 6013b98..1903abd 100644 --- a/stack/htmx/web-ui.md +++ b/stack/htmx/web-ui.md @@ -1,54 +1,106 @@ ---- -status: рекомендуемая ---- - # Веб-UI на htmx Как пишется код веб-UI: частичный своп фрагментов, поллинг живых обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI -показывает и какие действия обязан поддерживать — в спеках, не здесь. +показывает и какие действия поддерживает — в спеках, не здесь. Форма записи +— `common/language.md`. Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на `DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md` (приватный канал = логи, публичный = сообщение плюс корреляционный ключ). Здесь — только специфика htmx-транспорта, без дублирования. -Утверждения о поведении htmx относятся к **2.x**: дефолты обработки -ответов между мажорами менялись. +## Область действия + +Утверждения о поведении htmx относятся к **2.x**: дефолты обработки ответов +между мажорами менялись. Правила описывают то, как написан код веб-UI, а не +то, какие экраны и действия у приложения есть. ## Стек и границы -htmx-first: роутер + серверные шаблоны + htmx. **Без шага сборки, без Node -и бандлера, без реактивных фреймворков.** htmx вендорится и самохостится, -без CDN. +### R1. Стек: роутер, серверные шаблоны, htmx -- Свой JS сведён к минимуму: только то, что серверу знать не нужно - (например, копирование в буфер обмена). **Клиентского пересчёта доменного - состояния нет** — состояние считает сервер, клиент свопит присланную - разметку. -- Реактивный слой (Alpine.js и подобное) не вводим до появления виджета, - которому он действительно нужен, и вводим отдельным решением, а не - попутно. +**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, +без Node и бандлера, без реактивного фреймворка. -## Единый источник разметки: партиал = страница = фрагмент +**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и +артефакт, который расходится с исходником; приложению, где разметку целиком +отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую +модель состояния рядом с серверной (R2), и дальше на каждом экране +приходится решать, какая из них главная. Сам htmx — вендорный ассет и +живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму +приложения аптайм чужого хоста. -Переиспользуемый кусок — это именованный шаблон в `partials/`. Тот же -шаблон рендерится **и** инлайн на странице, **и** как ответ-фрагмент того -же обработчика. Отдельной разметки для фрагмента не заводим — иначе она -дрейфует от страницы. +### R2. Клиент не пересчитывает доменное состояние -**Инвариант: корень шаблона — элемент с целевым `id`.** -`hx-swap="outerHTML"` заменяет весь корневой узел; если ответный фрагмент не -несёт тот же корневой `id`, следующее действие или поллер не найдёт таргет. -Разметку и `id` держим в одном партиале. +**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно +(копирование в буфер обмена и подобное); доменное состояние считает сервер, +клиент свопит присланную разметку. -Сборку view выносим в переиспользуемую функцию и зовём её и на полной -странице, и во фрагменте — чтобы htmx-ветка не копипастила сборку. +**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую +никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в +базе другое». Вдобавок клиентский пересчёт по определению не работает в +деградированном режиме (R11, R12) — значит, серверную версию того же +вычисления всё равно придётся держать. -## Обработчик действия: ветвление htmx / редирект +### R3. Реактивный слой вводится отдельным решением -htmx-запрос определяем по заголовку `HX-Request: true`. Обработчик зовёт -доменную операцию **одинаково** в обеих ветках и ветвится только после: +**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей — +только когда есть виджет, которому он действительно нужен, и отдельным +решением. + +**Почему.** Реактивный слой, попавший в проект ради одного выпадающего +списка, немедленно доступен всему остальному коду — и граница R1/R2 +перестаёт держаться сама собой. Отдельное решение — единственный момент, +когда цену видно целиком: она не в килобайтах, а в том, что дальше на +каждом экране есть выбор между двумя моделями состояния. + +## Единый источник разметки + +### R4. Партиал = страница = фрагмент + +**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в +`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент +обработчика; отдельной разметки под фрагмент нет. + +**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту, +что открыта, и страница начинает выглядеть иначе, чем результат свопа того +же региона. Заметно это становится только на глаз и только тому, кто открыл +оба пути подряд. + +### R5. Корень партиала — элемент с целевым `id` + +**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют +регион, и ответный фрагмент несёт тот же `id`. + +**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с +его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его +вовсе, первый своп проходит успешно, а следующее действие и поллер уже не +находят таргет: регион застывает без единой ошибки — ни в консоли, ни в +логе. + +### R6. Сборку view делает общая функция + +**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и +htmx-ветка. + +**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не +одинаковые данные: скопированная сборка view расходится по набору полей, и +фрагмент начинает показывать не то, что показала бы страница. Это ровно тот +класс расхождений, который R4 закрывает для разметки. + +## Обработчик действия + +### R7. Доменный вызов одинаков для htmx и обычного запроса + +**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку +`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только +на способе ответа: + +| № | Запрос | Ответ | +|---|---|---| +| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию | +| R7.2 | обычный | PRG-редирект (303) | ```go actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx @@ -65,60 +117,137 @@ if actionErr != nil { s.render(w, "source_block", view) // фрагмент = тот же шаблон ``` -`render` собирает именованный шаблон **в буфер** и только затем пишет -ответ — при ошибке шаблона клиент не получит «полустраницу». +**Почему.** Ветвление до вызова даёт две реализации одного действия, и +дальше дефект воспроизводится только на одной поверхности — причём +деградированный путь (R11) открывают реже, то есть чинить будут не тот. +Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион +целиком: view, собранный из аргументов запроса, покажет намерение, а не +результат. -## Одно действие — два региона: `hx-swap-oob` +### R8. Шаблон рендерится в буфер, потом в ответ -Когда действие меняет не только свой регион (сменился выбор — обновилась и -панель действий), второй регион едет **тем же ответом** через -`hx-swap-oob="true"`. Оба фрагмента — обычные именованные партиалы с теми -же `id`, что и на странице; отдельной разметки под oob не заводим по тому -же правилу, что и для основного свопа. +**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем +буфер пишется в ответ. -Альтернатива — второй запрос с клиента — вводит гонку между двумя ответами -и лишний раунд-трип; `HX-Trigger` с последующим `hx-get` уместен только -если второй регион обновляется реже, чем происходит действие. +**Почему.** Прямая запись в ответ отправляет клиенту статус и часть +разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, +а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина +региона», и причина по такому симптому не читается. + +## Одно действие — два региона + +### R9. Второй регион едет тем же ответом через `hx-swap-oob` + +**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент +отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным +партиалом с тем же `id`, что и на странице (R4, R5). + +**Почему.** Второй запрос с клиента вводит гонку: два ответа считают +состояние в разные моменты и приезжают в произвольном порядке, поэтому +панель действий может отразить состояние до действия. Плюс лишний +раунд-трип на каждое действие. + +### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия + +**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй +регион меняется не на каждое действие. + +**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого +второго запроса. Когда регион обновляется редко, oob-ветка гоняет +одинаковую разметку на каждое действие и связывает два шаблона там, где +связи нет; гонка же тем менее наблюдаема, чем реже обновление. ## Graceful degradation -Формы действий остаются обычными `
`; -`hx-post`/`hx-target`/`hx-swap` лишь **накладываются сверху** на ту же -форму. Без JS действие работает через POST и редирект. `action` формы — -рабочий фолбэк, а не декорация. +### R11. Форма действия работает без JS -Фильтр, поиск и пагинация списка — **серверные**, через GET-параметры, тоже -без JS. Клиентской фильтрации нет намеренно. +**ДОЛЖЕН.** Действие — обычная ``, на которую +`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на +рабочий обработчик. -Требование распространяется на **действия и навигацию**. Интерактивный -виджет выбора, у которого нет осмысленного не-JS поведения, может требовать -JS — но это отступление, и оно записывается, а не подразумевается. +**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик, +медленная сеть, — и без рабочего `action` форма в этот момент не отправляет +ничего, молча. Тот же `action` — единственное, что делает действие +проверяемым без браузера с JS. -## Ошибки на htmx-пути: HTTP 200 плюс фрагмент +### R12. Фильтр, поиск и пагинация — серверные -В htmx 2.x ответы 4xx/5xx по умолчанию **не свопят DOM**. Это настраивается +**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; +клиентской фильтрации загруженной разметки нет. + +**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский +фильтр отвечает по неполным данным и делает это молча — результат выглядит +валидным. Вдобавок состояние отбора в query переживает своп (R25) и +перезагрузку, его можно послать ссылкой и увидеть в логе. + +### R13. Область обязательной деградации + +**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI: + +| № | Поверхность | Поведение без JS | +|---|---|---| +| R13.1 | действия и навигация | работают полностью (R11, R12) | +| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | + +**Почему.** Без явной границы правило деградации читается как запрет на +любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от +виджета, который был нужен. Запись в отступления держит список честным: +видно, какие именно места ломаются с выключенным JS, а не «где-то +что-то». + +## Ошибки на htmx-пути + +### R14. Ошибка действия на htmx-пути — 200 с фрагментом + +**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с +сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. + +**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть +пользователь не увидит ничего. Настроить это можно (`htmx.config.responseHandling`, расширение `response-targets`, слушатель -`htmx:responseError`), но любая настройка — это свой JS-конфиг на клиенте, -что противоречит разделу «Стек и границы». Поэтому сознательно берём -**200 с фрагментом**, несущим сообщение, и доменную ошибку на htmx-пути -**не** транслируем в HTTP-статус — в отличие от REST API и no-JS редиректа -с `?err=`. +`htmx:responseError`), но любая настройка — свой JS-конфиг на клиенте, и +платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус +по-прежнему используется: там его кто-то читает. -- Сообщение — нейтральный текст публичного канала; сырой `err.Error()` - наружу не идёт. -- Ошибку кладём в **отдельное поле** под ошибку действия, не перегружая - доменные поля: у них может быть своё непустое значение, которое сообщение - перекроет. -- **При ошибке активное состояние не меняем** — перечитанный view - показывает прежний выбор плюс сообщение. -- Цена: в логе доступа провалившееся действие выглядит как `200`. Искать - его надо по доменной записи об исходе операции (`lang/go/logging.md`), а - не по коду ответа. +Цена решения: в логе доступа провалившееся действие выглядит как `200`. +Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`), +а не по коду ответа. + +### R15. Наружу идёт сообщение публичного канала + +**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам +`lang/go/errors.md`; `err.Error()` в разметку не рендерится. + +**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём +легче всего забыть, что это тот же публичный канал, что и страница: +разметка уезжает в браузер пользователя целиком. Статус 200 (R14) +дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». + +### R16. Сообщение об ошибке — в отдельном поле view + +**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под +сообщение не переиспользуются. + +**Почему.** У доменного поля может быть своё непустое значение, и сообщение +его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — +необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба +состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего +требует R17. + +### R17. При ошибке активное состояние не меняется + +**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает +прежний выбор плюс сообщение. + +**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное, +что пользователь узнает о состоянии. Показав намеренное состояние вместо +фактического, интерфейс расходится с сервером, и следующее действие человек +делает по ложной картине — на сервере оно применится к другому объекту. ## Живой поллинг -Фрагмент-эндпоинт под `/fragments/…` плюс в разметке `hx-get`, -`hx-trigger="every Ns"`, `hx-swap="outerHTML"`: +Живое обновление устроено как фрагмент-эндпоинт под `/fragments/…` плюс +`hx-get`, `hx-trigger="every Ns"`, `hx-swap="outerHTML"` в разметке: ```html {{define "progress"}}
{{end}} ``` -- **Поллер самозавершается.** Когда состояние выходит из «живого», - фрагмент возвращается **без `hx-*`** — htmx больше не опрашивает. Условие - живости ведёт собственное состояние приложения, а не внешний сервис. - (Встроенная альтернатива — ответ со статусом 286 — не используется: она - не совместима с инвариантом «партиал = страница», свежезагруженная - страница тоже должна рендериться без поллера.) -- **`outerHTML`-своп всего фрагмента** удаляет старый узел вместе с его - поллером и инициализирует новый — двойного опроса нет **при условии - совпадения корневого `id`**. -- **Поллер не свопит контейнер с активными полями ввода.** Своп поддерева - теряет фокус, выделение и незасабмиченный текст внутри него: живость - включается только в состояниях, где редактировать нечего. -- **Инвариант: браузер не опрашивает внешний сервис напрямую** — только - свой сервер. -- Если тик **проксирует состояние внешнего сервиса**, данные берутся из - in-memory снимка, обновляемого воркером, без сети на каждый тик; контракт - снимка узкий и не зависит от способа доставки (путь к SSE остаётся - изолированным). Тик, показывающий **собственное** состояние приложения, - читает своё хранилище — это нормально и снимка не требует. +### R18. Поллер самозавершается -### Поллинг полной страницы +**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без +`hx-*`-атрибутов. -Когда живой фрагмент — это почти вся страница, отдельный -`/fragments/…`-роут дублировал бы обработчик. Тогда допустимо опрашивать -сам URL страницы и вырезать нужный узел на клиенте: +**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка +держит постоянный поток запросов за неизменными данными, и закрывает его +только пользователь. Условие остановки живёт в разметке ответа, потому что +это единственный канал, которым сервер управляет поллером. + +Встроенная альтернатива — ответ со статусом 286 — не используется: она не +совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом +и тоже без поллера. + +### R19. Условие живости ведёт собственное состояние приложения + +**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет +приложение, а не по ответу внешнего сервиса. + +**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его +недоступности поллер либо останавливается, пока работа идёт, либо не +останавливается никогда. Приложение — единственный участник, который знает +про операцию всё и может ответить на каждом тике. + +### R20. Поллер свопит фрагмент целиком через `outerHTML` + +**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его +содержимое. + +**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и +инициализирует новый — так поллер живёт ровно в одном экземпляре и так же +выключается (R18). Своп содержимого оставил бы старый узел с его таймером, +и через несколько обновлений опрос шёл бы в несколько потоков. Работает это +при совпадении корневого `id` (R5). + +### R21. Поллер не свопит контейнер с активными полями ввода + +**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где +редактировать нечего. + +**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст +внутри него. У поллера это происходит по таймеру, то есть в момент, который +пользователь не выбирал: текст исчезает посреди набора и воспроизводится +как «приложение стирает мой ввод». + +### R22. Браузер не ходит во внешний сервис напрямую + +**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. + +**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные +внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок +контракт внешнего сервиса протекает в разметку: его смена перестаёт быть +серверным изменением. + +### R23. Источник данных для тика + +**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть: + +| № | Что показывает тик | Откуда берёт | +|---|---|---| +| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | +| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | + +**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на +каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и +его недоступность становится недоступностью страницы. Снимок разрывает эту +связь: частоту обращений к внешнему сервису задаёт воркер, а не +пользователи. Контракт снимка держат узким, чтобы способ доставки +(поллинг сегодня, SSE потом) менялся, не задевая остальной код. Для +собственного состояния той же цены нет: хранилище и так своё, а лишний слой +кеша добавил бы только рассинхрон. + +### R24. Поллинг URL страницы вместо отдельного фрагмент-роута + +**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт +на URL самой страницы, а нужный узел вырезается `hx-select`: ```html hx-get="/item/{{.ID}}" hx-trigger="every 3s" hx-select="#item-main" hx-swap="outerHTML" ``` -Инвариант корневого `id` действует и здесь: `hx-select` должен выбирать тот +**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует +обработчик страницы целиком — вместе с перечитыванием состояния и сборкой +view, — и дальше два обработчика расходятся по тому же сценарию, что и две +копии разметки (R4). + +Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот же узел, который свопится. -## Своп сохраняет контекст; выход — навигация +## Своп и выход со страницы -`hx-swap="outerHTML"` не сбрасывает прокрутку и не трогает серверные -фильтр, поиск и пагинацию (они в query). Внутри свопаемого поддерева -контекст **не** сохраняется — фокус, выделение и введённый текст теряются -(см. правило про поллер выше). +### R25. Действие не уводит со страницы, если предмет остаётся на ней -Действие **не должно уводить** пользователя со страницы, если предмет -остаётся на ней — своп на месте. Действие, после которого предмет -**покидает** страницу, остаётся обычной POST-формой **без `hx-*`** → полная -навигация. Маркер «это выход» — форма без htmx-атрибутов; так не нужен -`HX-Redirect`, а «уйти с экрана» выражено самой навигацией. +**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. -**Асинхронные действия.** Если доменное действие асинхронно (переводит в -промежуточное состояние, работу доделывает воркер), своп отдаёт -**промежуточное** состояние, а не мнимый результат; итог догоняет -самозавершающийся поллер. Не обещаем в UI мгновенный итог async-операции. +**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и +пагинацию — они в query (R12). Полная навигация ради изменения одного +региона возвращает пользователя в начало списка и стоит перерисовки всей +страницы. Не сохраняется при свопе только контекст внутри самого +заменяемого поддерева — фокус, выделение, введённый текст (R21). + +### R26. Выход со страницы — форма без `hx-*` + +**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся +обычной POST-формой без htmx-атрибутов, то есть полной навигацией. + +**Почему.** Своп для такого действия оставил бы на месте регион, +описывающий объект, которого на странице больше нет. Отсутствие +htmx-атрибутов при этом само работает маркером «это выход»: намерение видно +прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ +сменить страницу, существующий только на htmx-пути. + +### R27. Асинхронное действие свопит промежуточное состояние + +**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает +промежуточное состояние, а итог догоняет самозавершающийся поллер (R18). + +**Почему.** Мнимый результат расходится с сервером до следующего тика, и +всё это время пользователь принимает решения по несуществующему исходу — +включая повтор действия, которое на самом деле выполняется. Промежуточное +состояние вдобавок объясняет, почему регион продолжает обновляться сам. ## Различение поверхности одного действия -Если один роут зовут с разных страниц и своп-ответ должен быть разным -фрагментом, различаем **явным скрытым полем формы** (`surface=list|detail`), -а не эвристикой по `HX-Target` или `Referer`: поле самодокументируемо и не -зависит от резолва таргета. +### R28. Поверхность различается скрытым полем формы + +**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается +фрагментом, поверхность передаётся явным скрытым полем +(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`. + +**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` +может не прийти вовсе; и то и другое меняется без участия обработчика, и +ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с +действием, поэтому связь «эта страница → этот фрагмент» читается там, где +её заводят. ## Статика, вендоринг, кэш Раздел не про htmx — это упаковка любого server-rendered приложения; разъедется в языковой слой, когда понадобится там. -- Ассеты встроены в бинарь (`go:embed`) и отдаются с длинным иммутабельным - кэшем (`Cache-Control: public, max-age=31536000, immutable`). -- Меняемые ассеты (css/js) версионируются через `?v=` — короткий - sha256 содержимого; URL строит хелпер шаблона. Свежий деплой не отдаёт - устаревший файл. -- Вендор адресуется по **неизменному имени файла** и в `?v=` не нуждается. - В git его не коммитим: идемпотентная задача добывает его по манифесту - (`путь url sha256`) с проверкой контрольной суммы, и сборка от неё - зависит. -- Шрифты и скрипты — **self-hosted**, без внешних хостов: бинарь - самодостаточен, внешних ресурсов времени выполнения нет. +### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем + +**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с +`Cache-Control: public, max-age=31536000, immutable`. + +**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго +шага раскладки файлов, который может отстать от бинаря и оставить новую +разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL +меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы +способом навсегда закрепить у пользователя старый файл. + +### R30. Меняемые ассеты версионируются хешем содержимого + +**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL +строит хелпер шаблона. + +**Почему.** Хеш содержимого — единственная версия, которую невозможно +забыть обновить: она меняется от самой правки. Ручной номер и дата сборки +от этого не защищают, а цена промаха при иммутабельном кэше (R29) — +устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы +хеш не проставляли в каждом шаблоне руками. + +### R31. Вендорный ассет в `?v=` не нуждается + +**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без +параметра версии. + +**Почему.** Содержимое под этим именем не меняется: обновление вендора +приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от +подмены содержимого под тем же адресом, а такой ситуации здесь нет — и +явное разрешение снимает вопрос, не нарушает ли это R30. + +### R32. Вендор не коммитится, а добывается по манифесту + +**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту +(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой +задачи. + +**Почему.** Манифест делает версию и происхождение ассета видимыми в +diff'е — у закоммиченного минифицированного файла обновление выглядит +стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 — +единственная проверка, что скачали то же самое, что проверяли; зависимость +сборки от задачи не даёт собраться без ассета в свежем клоне. + +### R33. Шрифты и скрипты — self-hosted + +**ДОЛЖЕН.** Внешних хостов во время выполнения нет. + +**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и +третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь +вдобавок разворачивается в сети без выхода наружу, где CDN просто не +отвечает.