Compare commits

...
10 Commits
Author SHA1 Message Date
av 840904d454 реестр префиксов перенесён в корень
- реестр покрывает и обвязку тоже (GUIDE.md), поэтому внутри conventions/
  он упирался в путь `../GUIDE.md`; теперь пути даются от корня репозитория
- README дополнен разделом про префиксы и строкой в таблице обвязки
2026-07-25 20:57:54 +03:00
av 4a7cfca8f6 обвязка: идентификаторы правил описаны через префиксы
- LANGUAGE.md: раздел «Идентификаторы» переписан под префиксы, в список
  машинных проверок добавлена сверка с реестром
- GUIDE.md перенумерован под префикс META, «номер правила» заменён на
  «идентификатор»
2026-07-25 20:55:38 +03:00
av dcdd92230b заведён реестр префиксов, правила канона перенумерованы
- идентификатор правила теперь `<ПРЕФИКС>-<номер>` вместо `R<номер>`:
  префикс уникален по всему канону, поэтому ссылка больше не требует пути
  к файлу и не зависит от того, на какой оси файл лежит
- префикс выбирается под файл, а не выводится по формуле, и хранится в
  conventions/prefixes.toml вместе с выбывшими; номера сохранены один в
  один вместе с дырами
2026-07-25 20:55:37 +03:00
av 972627f641 время: приём чужого офсета и регистрация исключений линтера
- arch R13: валидное по RFC 3339 значение с чужим офсетом нормализуется при
  разборе; канонический вид — обязательство писателя, не контракт с партнёром
- go R13: исключение регистрируется директивой //nolint на месте вызова, а не
  exclude-записью в конфиге, которая адресует путём и отвязывается
2026-07-25 20:15:36 +03:00
av f073a68f75 web-ui: сбой без фрагмента и запрос без поля поверхности
- R34: глобальный слушатель htmx:responseError — на 5xx htmx ничего не
  свопит, и интерфейс замирает без признака сбоя
- R35: 400 вместо поверхности по умолчанию, иначе своп идёт с чужим id
- в R14 снято противоречие: слушатель больше не числится среди
  отвергаемых настроек
2026-07-25 20:15:36 +03:00
av 477499877d errors: непокрытая маппингом ошибка и поведение после recover
- R25: 500 и ERROR с признаком непокрытой — без признака забытая ветвь
  неотличима в логах от упавшей базы
- R26: HTTP-запрос завершается 500, цикл продолжается со следующего
  элемента, а упавший выводится из оборота — иначе poison message
2026-07-25 20:15:36 +03:00
av 9085601db2 config: отсутствие файла и содержание сообщений валидатора
- R20: конфига нет — не стартуем; умолчания существуют для неполного файла,
  а не для отсутствующего
- R21: значение поля в сообщении валидатора печатается по тому же признаку
  секретности, что у R15 и R16, второй список не заводится
- в перечень проверок R18 добавлен формат идентификаторов сущностей
2026-07-25 20:15:36 +03:00
av 0fc1994db7 идентификатор новой сущности — всегда ULID
- R1 больше не ветвится по признаку внешней адресуемости: заранее отличить
  внутренние сущности, которые станут внешними, невозможно
- целочисленный ключ остаётся у существующих схем и идёт вместе с
  AUTOINCREMENT — переиспользованный rowid молча наводит протухшую ссылку
  на другую строку (db-schema R12)
- таблица R5 ограничена внешними источниками, регион «решение» убран
2026-07-25 20:15:35 +03:00
av 6456b81d91 исправлены дефекты формулировок в конвенциях
- убраны неверные утверждения: покрытие forbidigo сужено до честного,
  таблица классов доменного отказа больше не претендует на полноту,
  механизация не подаётся как факт канона
- введены недостающие определения (доменная и внешняя границы, объявление
  пути), критерий постоянного поля сведён к одному на R16.1 и R17
- kebab-case имени файла убран из правил в прозу: обоснование не
  формулировалось, номер R16 оставлен свободным
2026-07-25 19:34:41 +03:00
av 0842850fae конвенции отделены от обвязки
- сами конвенции переехали в conventions/, описательное — в корень:
  LANGUAGE.md (язык записи) и GUIDE.md (как ведут конвенции)
- conv синхронизирует только conventions/, пути в origin даются
  относительно неё — раскладка копий в репозиториях не меняется
2026-07-25 19:23:32 +03:00
17 changed files with 909 additions and 565 deletions
+53 -41
View File
@@ -1,3 +1,7 @@
---
prefix: META
---
# Как мы ведём конвенции
Конвенция описывает повторяющийся выбор: как называть директории, как
@@ -5,7 +9,7 @@
принято», а не «что здесь происходит».
Как записывается сама конвенция — правила, модальность, обоснования — в
[language.md](language.md). Здесь — про то, зачем конвенции заводятся, где
[LANGUAGE.md](LANGUAGE.md). Здесь — про то, зачем конвенции заводятся, где
живут и как соотносятся с соседними видами документов.
## Область действия
@@ -13,7 +17,7 @@
Правила ниже адресованы автору конвенции — тому, кто заводит новый файл или
правит существующий, в каноне и в копиях репозиториев. Обязательность живёт
на отдельном правиле, а не на файле; шкала модальных слов — в
[language.md](language.md).
[LANGUAGE.md](LANGUAGE.md).
## Отличие от соседей
@@ -26,6 +30,14 @@
- `docs/conventions/`**правило на будущее**, применяемое многократно.
Живой документ: правится, когда договорённость меняется.
## Оформление
Имя файла — kebab-case по теме: `app-directories.md`. Правилом это не
записано: обоснование сводится к «чтобы имя файла в реестре префиксов
писалось одним способом», а проверить нарушение всё равно проще глазом, чем
сформулировать норму. Номер META-16, под которым это правило существовало,
оставлен свободным и не переиспользуется.
## Канон и копии
Файлы в `docs/conventions/` с шапкой `origin:` — копии из общего канона
@@ -42,7 +54,7 @@
## Правила
### R1. Одна конвенция — один файл
### META-1. Одна конвенция — один файл
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
@@ -52,7 +64,7 @@
позже дорого: путь файла — часть адреса правила, и после разреза внешние
ссылки указывают не туда.
### R2. Конвенция заводится, когда решение принимается третий раз
### META-2. Конвенция заводится, когда решение принимается третий раз
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
каждый раз чуть по-другому.
@@ -63,7 +75,7 @@
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
содержание записи.
### R3. Новая конвенция пишется там, где заболело
### META-3. Новая конвенция пишется там, где заболело
**СЛЕДУЕТ.** Первые шаги пути «находка → конвенция → правило линтера →
удаление прозы» делаются в репозитории, где случилась находка; в канон
@@ -74,7 +86,7 @@
платят за это все потребители сразу. Формулировка, обкатанная на одном
репозитории, приезжает в канон уже с известной границей.
### R4. В тексте конвенции нет утверждений о состоянии репозитория
### META-4. В тексте конвенции нет утверждений о состоянии репозитория
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
описаний того, как сейчас устроен конкретный репозиторий.
@@ -85,7 +97,7 @@
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
и локально, и проверяемо.
### R5. Расхождение кода с правилом — отступление, а не повод переписать правило
### META-5. Расхождение кода с правилом — отступление, а не повод переписать правило
**ДОЛЖЕН.** Правило правится, только когда неверно по существу: содержит
фактическую ошибку, внутреннее противоречие или условие применимости,
@@ -96,7 +108,7 @@
переписывает его снова. Направление «конвенция → код» держится ровно тем,
что факт не считается аргументом.
### R6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается
### META-6. `ДОЛЖЕН` без механической проверки либо механизируется, либо понижается
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает
машинную проверку или переводится в СЛЕДУЕТ.
@@ -106,18 +118,22 @@
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько
таких случаев обесценивает остальные ДОЛЖЕН в файле.
### R7. Факт механизации фиксируется в локальном регионе со ссылкой на номер
Отсюда следствие: правило, машинная проверка которого невозможна в принципе
(вкус формулировки, выбор границы, суждение о ситуации), не может быть
ДОЛЖЕН — его модальность СЛЕДУЕТ по построению, а не по слабости.
**ДОЛЖЕН.** Регион `механизировано` называет номер правила и конкретную
проверку.
### META-7. Факт механизации фиксируется в локальном регионе со ссылкой на правило
**ДОЛЖЕН.** Регион `механизировано` называет идентификатор правила и
конкретную проверку.
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не
знает, а без записи следующий автор либо заведёт вторую проверку того же,
либо будет вычитывать глазами уже проверенное машиной. Без номера правила
либо будет вычитывать глазами уже проверенное машиной. Без идентификатора
читатель догадывается сам, к какому утверждению относится проверка, — и
догадывается по-разному.
### R8. Формулировка не удаляется из канона, пока механизирована не у всех
### META-8. Формулировка не удаляется из канона, пока механизирована не у всех
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя
машинной проверки нет.
@@ -127,7 +143,12 @@
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» —
значит чинить свой файл за чужой счёт.
### R9. Общая механизация разрешает удалить норму из канона
Списка подписчиков канон по построению не знает, поэтому факт «механизировано
у всех» устанавливается обходом репозиториев вручную — это часть работы по
удалению нормы, а не то, что можно проверить машиной (см. `LANGUAGE.md`,
состояние МЕХАНИЗИРОВАНО).
### META-9. Общая механизация разрешает удалить норму из канона
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
удаляется из канона одним `push`.
@@ -135,10 +156,10 @@
**Почему.** Формулировка, дублирующая работающую у всех проверку,
размазывает внимание: файл на несколько сотен строк заставляет человека и
агента добросовестно вычитывать тривиальное именование и не доходить до
формы решения. Явное разрешение нужно, чтобы R8 не читался как запрет
формы решения. Явное разрешение нужно, чтобы META-8 не читался как запрет
удалять вообще.
### R10. Обоснование не удаляется никогда
### META-10. Обоснование не удаляется никогда
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в
линтер.
@@ -147,7 +168,7 @@
существует. Без обоснования не видно, когда причина отпала, — проверка
продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
### R11. У трудноизменяемого слоя область действия пишется явно
### META-11. У трудноизменяемого слоя область действия пишется явно
**ДОЛЖЕН.** Конвенция о схеме БД, формате хранения или раскладке директорий
называет, к чему применяется: к новым таблицам и миграциям, а не к
@@ -159,7 +180,7 @@
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
молчаливый вывод, что конвенция не соблюдается совсем.
### R12. Механизируется граница изменения, а не состояние
### META-12. Механизируется граница изменения, а не состояние
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
существующей схеме.
@@ -169,7 +190,7 @@
новое, ради чего заводилась. Проверка границы оставляет старое в покое и
делает новую ошибку невозможной.
### R13. Список отступлений трудноизменяемого слоя — постоянный
### META-13. Список отступлений трудноизменяемого слоя — постоянный
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
как задачи на дочистку.
@@ -179,25 +200,25 @@
тем, что список перестают вести, — и пропадает единственное место, где видно,
где именно правило не действует.
### R14. Отступления перечисляются поимённо, со ссылкой на номера правил
### META-14. Отступления перечисляются поимённо, со ссылкой на правила
**ДОЛЖЕН.** В локальном регионе перечислены отступления, которые уже есть в
коде, с номером правила и причиной.
коде, с идентификатором правила и причиной.
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
это можно только чтением всего кода. Со ссылками отступления счётны: видно,
сколько правил конвенции репозиторий реально не соблюдает. Пустой регион при
этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
### R15. Запись в регионе отступлений разбирается по масштабу
### META-15. Запись в регионе отступлений разбирается по масштабу
**ДОЛЖЕН.** Судьба записи зависит от того, что в ней сказано:
| № | Что записано | Куда идёт |
|---|---|---|
| R15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
| R15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
| R15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
| META-15.1 | правилу не следуем в перечисленных местах, причина названа | остаётся отступлением |
| META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
| META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
**Почему.** Отступление описывает исключение, и по нему видно, какая часть
правила нарушена. Запись «мы это правило вообще не применяем» такой
@@ -205,17 +226,7 @@
правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
где файл просто не нужен. Оставленная отступлением, она прячет обе.
### R16. Имя файла — kebab-case по теме
**СЛЕДУЕТ.** `app-directories.md`, а не вариации регистра и разделителя.
**Почему.** Имя файла — часть глобального адреса правила
(`stack/ansible/app-directories.md R4`) и значение ключа `origin` в каждой
копии. Один способ записи избавляет от нескольких написаний одного адреса,
а ошибка в адресе обнаруживается только тем, кто по нему пришёл и ничего не
нашёл.
### R17. Репо-специфичная часть «Связано» — в локальном регионе
### META-17. Репо-специфичная часть «Связано» — в локальном регионе
**ДОЛЖЕН.** Раздел «Связано» стоит в конце файла; канонические ссылки — в
общем тексте, ссылки на ADR, код и файлы конкретного репозитория — в
@@ -225,7 +236,7 @@
чужой файл у них битая с первого дня. Регион исключён из сравнения, поэтому
та же ссылка внутри него никого не задевает и не даёт вечного шума в `diff`.
### R18. README директории перечисляет конвенции с однострочным описанием
### META-18. README директории перечисляет конвенции с однострочным описанием
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
@@ -233,15 +244,16 @@
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток
файлов это означает, что не открывают ни одной.
### R19. Короткие инварианты дублируются в точку входа агента
### META-19. Короткие инварианты дублируются в точку входа агента
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
номером; детали остаются в конвенции.
идентификатором; детали остаются в конвенции.
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
если его туда отправили, — а безусловно он читает точку входа. Строка с
номером служит и напоминанием, и адресом, по которому за подробностями
идут; перенос деталей туда же вернул бы задачу поддержки двух текстов.
идентификатором служит и напоминанием, и адресом, по которому за
подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух
текстов.
<!-- local:точки-входа -->
<!-- /local -->
+37 -21
View File
@@ -12,8 +12,8 @@
Не ради строгости. Три конкретные вещи, которые без адресуемых правил не
работают:
- **Механизация.** Регион `механизировано` должен говорить «правило R4
проверяет `archrules`», а не «`AUTOINCREMENT` в новых миграциях —
- **Механизация.** Регион `механизировано` должен говорить «правило
`MIGR-4` проверяет `archrules`», а не «`AUTOINCREMENT` в новых миграциях —
`archrules`»: во втором случае читатель сам догадывается, к какому
утверждению это относится, и догадывается по-разному.
- **Отступления.** «У нас не так» бесполезно, пока не сказано, что именно
@@ -28,7 +28,7 @@
## Единица — правило
```markdown
### R5. Разбор внешнего идентификатора на границе
### KEYS-5. Разбор внешнего идентификатора на границе
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
к базе.
@@ -38,8 +38,8 @@
существующую запись — отладка такого случая стоит дороже, чем сам разбор.
```
Четыре обязательные части: **номер**, **заголовок**, **модальность с
нормой**, **почему**. Норма — одна фраза; если в неё не влезает, это два
Четыре обязательные части: **идентификатор**, **заголовок**, **модальность
с нормой**, **почему**. Норма — одна фраза; если в неё не влезает, это два
правила.
## Правило без «почему» не принимается
@@ -79,7 +79,7 @@
Модальность живёт на **правиле**, а не на файле. Прежний файловый статус
(`status: рекомендуемая` / `обязательная` в шапке) отменён: он неизбежно
врал, потому что один файл смешивает жёсткие требования с советами. В шапке
остаются только `extends` и служебные ключи копии.
остаются только `prefix`, `extends` и служебные ключи копии.
Мы **не используем SHALL и прочие английские ключевые слова**. Они заняты
спецификациями (OpenSpec), и общий словарь стирал бы границу «конвенция —
@@ -87,13 +87,26 @@
## Идентификаторы
- Формат — `R<номер>`, сквозная нумерация внутри файла, начиная с `R1`.
- Строка таблицы, если на неё нужно ссылаться отдельно, — `R5.1`, `R5.2`.
- **Номера стабильны и не переиспользуются.** Удалённое правило оставляет
дыру в нумерации; занимать её новым правилом нельзя — иначе ссылка из
чужого репозитория начнёт указывать на другое утверждение.
- Глобальный адрес — путь файла плюс номер: `arch/db-identifiers.md R5`.
В пределах одного файла достаточно `R5`.
- Формат — `<ПРЕФИКС>-<номер>`: `KEYS-5`, `SLOG-27`. Префикс принадлежит
файлу, нумерация внутри файла сквозная и начинается с единицы.
- Строка таблицы, если на неё нужно ссылаться отдельно, — `KEYS-5.1`,
`KEYS-5.2`.
- **Идентификатор глобален.** Префикс уникален по всему канону, поэтому
путь файла в ссылке не нужен: `KEYS-5` адресует правило одинаково изнутри
файла, из соседней конвенции и из чужого репозитория. В собранной копии
слои разных осей лежат в одном документе, так что ссылка на базовый слой
из языкового вообще никуда не ведёт — правило рядом.
- **Идентификаторы стабильны и не переиспользуются.** Удалённое правило
оставляет дыру в нумерации; занимать её новым нельзя — иначе ссылка из
чужого репозитория начнёт указывать на другое утверждение. То же
относится к префиксам: выбывшие хранит `prefixes.toml`.
- Префикс **выбирается под файл, а не выводится по формуле**: он нужен,
чтобы по нему искать, а не чтобы его разбирать. Выводимый префикс вдобавок
привязал бы идентификатор к таксономии, которую канон перестраивает, и
упёрся бы в потолок из числа букв алфавита.
- Перенос правила в другой файл — смысловое изменение, а не переименование:
новый файл означает новый префикс и новую нумерацию. Переезд самого файла
между осями идентификаторы не трогает.
Порядок правил в файле выбирается по читаемости, не по номерам: номер — это
идентификатор, а не позиция.
@@ -105,7 +118,7 @@
чтобы оно не выглядело недописанным, место нормы занимает отметка:
```markdown
### R6. Дефолтов времени в схеме БД нет
### MIGR-6. Дефолтов времени в схеме БД нет
**МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; формулировка
удалена, потому что дублировала работающую проверку.
@@ -113,7 +126,7 @@
**Почему.** Дефолт превращает забытую вставку в тихо работающий код…
```
- Номер и заголовок сохраняются: ссылки из репозиториев продолжают
- Идентификатор и заголовок сохраняются: ссылки из репозиториев продолжают
указывать на то же утверждение.
- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не
сообщает, зачем правило существует, и без обоснования нельзя понять,
@@ -175,11 +188,11 @@ AND тик фонового цикла упал по той же причине
```markdown
<!-- local:механизировано -->
R2, R4 — `internal/archrules` (проверяются в новых миграциях).
MIGR-2, MIGR-4 — `internal/archrules` (проверяются в новых миграциях).
<!-- /local -->
<!-- local:отступления -->
R6 — не соблюдается в легаси-таблицах `show_history`, `queue`: составные
MIGR-6 — не соблюдается в легаси-таблицах `show_history`, `queue`: составные
ключи там появились до конвенции, переписывание требует миграции данных.
<!-- /local -->
```
@@ -191,12 +204,15 @@ R6 — не соблюдается в легаси-таблицах `show_histor
Сейчас не реализовано; список — на будущее для `conv`:
- префикс в шапке файла совпадает с реестром, состоит из четырёх заглавных
латинских букв и не значится в списке выбывших;
- заголовки правил файла используют только его собственный префикс;
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило
берёт следующий свободный, а не первый освободившийся);
- у каждого `### R<n>` есть модальное слово (или отметка МЕХАНИЗИРОВАНО) и
блок «Почему»;
- ссылки вида `R<n>` в локальных регионах копии указывают на правила,
которые в каноне ещё существуют;
- у каждого `### <ПРЕФИКС>-<n>` есть модальное слово (или отметка
МЕХАНИЗИРОВАНО) и блок «Почему»;
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте канона, хоть в локальных
регионах копии — указывают на правила, которые ещё существуют;
- модальные слова не встречаются вне правил.
## Порядок перевода
+40 -6
View File
@@ -5,6 +5,21 @@
`ansible-roles`: канон не источник истины во время работы, а лавка, из
которой берут и в которую возвращают улучшения.
Сами конвенции лежат в `conventions/`, обвязка — в корне:
| Файл | Что описывает |
|---|---|
| `README.md` | устройство канона, оси, синхронизация, жизненный цикл |
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» |
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
| `prefixes.toml` | реестр префиксов правил |
| `conv` | синхронизация копий |
Обвязка живёт только в каноне и в репозитории не оказывается — `conv`
синхронизирует лишь содержимое `conventions/`. Пока это осознанное
ограничение: копия конвенции ссылается на `LANGUAGE.md` как на внешний
документ.
Правило то же, что у ролей: **деплоится и читается только то, что лежит в
git репозитория**. Канон никем не подключается на лету.
@@ -30,12 +45,18 @@ git репозитория**. Канон никем не подключаетс
## Оси
```
common/ как вести сами конвенции
arch/ решения, переживающие смену языка и инструментов
lang/<язык>/ как решение реализуется и механизируется в языке
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
conventions/
arch/ решения, переживающие смену языка и инструментов
lang/<язык>/ как решение реализуется и механизируется в языке
stack/<стек>/ привязка к инструменту, хранилищу, транспорту
```
Пути **файлов** даются относительно `conventions/`: `arch/db-identifiers.md`,
а не `conventions/arch/…` — так же, как они лягут в `docs/conventions/`
репозитория. На **правила** ссылаются идентификатором без пути: `KEYS-5`.
Префикс уникален по всему канону (реестр — `prefixes.toml`), поэтому
идентификатор не зависит от того, на какой оси файл лежит сегодня.
Тест — по тому, замена чего убивает правило:
> Умирает при смене **языка** → `lang/`. Умирает при смене **инструмента,
@@ -59,9 +80,22 @@ stack/<стек>/ привязка к инструменту, хранили
пласт `stack/sqlite/` (типы колонок). Это не принцип, а незавершённая
работа.
## Префиксы
Каждый файл канона объявляет в шапке свой префикс правил:
```yaml
prefix: KEYS
```
Четыре заглавные латинские буквы, уникальные по всему канону; реестр —
[`prefixes.toml`](prefixes.toml). Префикс выбирается под файл, а не выводится
по формуле, и не переиспользуется никогда. Правила адресуются идентификатором
`KEYS-5` — без пути к файлу. Подробности формы — `LANGUAGE.md`.
## Расширение
Файл в `lang/` или `stack/` может объявить в шапке:
Файл в `lang/` или `stack/` может объявить в шапке ещё и базу:
```yaml
extends: arch/db-identifiers.md
@@ -91,7 +125,7 @@ local: нет # или: чем и почему разошлись
`origin_hash` — контент-отпечаток, а не git-SHA. Он позволяет отличать
«канон обновился» от «изменено локально»; без него `status` умеет только
«differs», а такой отчёт быстро перестают читать. Прочие ключи шапки
(`status`, `extends`) — часть документа: они сравниваются наравне с телом.
(`prefix`, `extends`) — часть документа: они сравниваются наравне с телом.
**Локальные регионы** — куски, принадлежащие репозиторию по определению.
Из сравнения исключаются, поэтому вечного шума в `diff` не дают:
+6 -5
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""conv — синхронизация конвенций между каноном и репозиторием.
Канон — эта директория. Репозиторий держит закоммиченные копии нужных
конвенций в docs/conventions/, повторяя структуру канона. Копия — источник
правды для репозитория; канон — лавка, из которой берут.
Канон — директория conventions/ рядом с этим скриптом. Репозиторий держит
закоммиченные копии нужных конвенций в docs/conventions/, повторяя её
структуру. Копия — источник правды для репозитория; канон — лавка, из
которой берут. Пути в origin даются относительно conventions/.
Служебная разметка копии:
@@ -50,8 +51,8 @@ import sys
from pathlib import Path
from typing import NoReturn
CANON = Path(__file__).resolve().parent
CANON_TREES = ("common", "arch", "lang", "stack")
CANON = Path(__file__).resolve().parent / "conventions"
CANON_TREES = ("arch", "lang", "stack")
SERVICE_KEYS = ("origin", "origin_hash", "synced", "local")
DEFAULT_DIR = "docs/conventions"
ENC = "utf-8"
@@ -1,10 +1,14 @@
---
prefix: DIRS
---
# Категории директорий приложения
Всё, что приложение пишет на диск, делится на три категории по принципу
создания и ценности содержимого: конфигурация, данные, кеш. Категория сразу
отвечает на два вопроса, которые иначе выясняются чтением кода приложения:
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
механически выводится состав бэкапа. Форма записи — `common/language.md`.
механически выводится состав бэкапа. Форма записи — `LANGUAGE.md`.
## Область действия
@@ -16,32 +20,32 @@
## Правила
### R1. Записываемые пути разложены по трём категориям
### DIRS-1. Записываемые пути разложены по трём категориям
**ДОЛЖЕН.** Каждая директория, в которую пишет приложение или деплой,
относится к одной из трёх категорий:
| № | Категория | Директория | Создаёт | Потеря содержимого | В бэкапе |
|---|---|---|---|---|---|
| R1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
| R1.2 | данные | `data/` | приложение | невосполнима | да |
| R1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
| DIRS-1.1 | конфигурация | `config/` | деплой | восстанавливается прогоном деплоя | нет |
| DIRS-1.2 | данные | `data/` | приложение | невосполнима | да |
| DIRS-1.3 | кеш | `cache/` | приложение | приложение перегенерирует | нет |
Имена в таблице — умолчание для случая «одна директория на категорию».
**Почему.** Все дальнейшие решения — что попадает в бэкап (R4), что можно
**Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно
снести при нехватке места, что переживает переезд на другой диск —
читаются из категории, а не выясняются по коду приложения. Без единой
классификации каждое такое решение принимается заново и каждый раз чуть
по-другому, а цена ошибки несимметрична: лишний кеш в снапшоте стоит места,
потерянные данные не стоят ничего, потому что их больше нет.
### R2. Категория может состоять из нескольких директорий
### DIRS-2. Категория может состоять из нескольких директорий
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
принадлежность к категории задаётся не именем, а участием в списке бэкапа.
**Почему.** Явное разрешение снимает вопрос, не читается ли R1 как «ровно
**Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно
три директории». Крупные файлы отделяют от базы, чтобы двигать их между
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном
@@ -49,15 +53,15 @@
задавать именем ровно поэтому: имён в категории несколько, и выбираются они
по содержимому.
### R3. Данные и кеш разделяются по тесту на пересоздание
### DIRS-3. Данные и кеш разделяются по тесту на пересоздание
**ДОЛЖЕН.** Записываемый путь относят к данным или к кешу по содержимому:
| № | Что лежит | Категория |
|---|---|---|
| R3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
| R3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| R3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
| DIRS-3.1 | база, загруженные файлы, сгенерированные артефакты | данные |
| DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
**Почему.** Без внешнего теста граница проводится по ощущению «жалко
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
@@ -67,7 +71,7 @@
обнаруживается в тот же момент, но дёшево: при попытке пересоздать, а не
при попытке восстановить.
### R4. В бэкап идут данные, и только они
### DIRS-4. В бэкап идут данные, и только они
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
кеш — нет.
@@ -79,12 +83,16 @@
Ошибка в другую сторону дороже: директория данных, не попавшая в список,
обнаруживается в единственный момент, когда исправить её уже нечем.
### R5. Список бэкапа ссылается на те же пути, что и создание директорий
### DIRS-5. Список бэкапа ссылается на те же пути, что и создание директорий
**ДОЛЖЕН.** Список выводится из категорий по R4 и ссылается на те же
объявления путей, по которым директории создаются, а не набирается
**ДОЛЖЕН.** Список выводится из категорий по DIRS-4 и ссылается на те же
**объявления путей**, по которым директории создаются, а не набирается
независимо.
Объявление пути — то единственное место, где путь директории записан
буквально: переменная деплоя, константа, поле конфигурации. Всё остальное
на него ссылается.
**Почему.** Правило вывода механическое, но применяет его человек или
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
невозможным: переименование директории отражается в обоих местах сразу.
@@ -92,25 +100,25 @@
на это не жалуются, — и расхождение между тем, что бэкапится, и тем, что
нужно, проявляется при восстановлении.
### R6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
### DIRS-6. Способ попадания данных в бэкап зависит от того, кто отвечает за консистентность
**ДОЛЖЕН.** Способ выбирается по тому, самодостаточны ли файлы на диске:
| № | Данные | В бэкап |
|---|---|---|
| R6.1 | файлы самодостаточны на любой момент времени | копированием |
| R6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
| DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием |
| DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
**Почему.** Файловый снапшот работающей СУБД не гарантирует
консистентности: скопированный каталог может не восстановиться, и узнают
об этом при восстановлении. Директория дампов — тоже данные, просто
производные, поэтому R4 покрывает её без оговорок. Сырой каталог базы из
производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из
списка при этом исключается: он удваивает объём снапшота и добавляет к
надёжной копии заведомо ненадёжную.
### R7. Способ выбирается при заведении приложения
### DIRS-7. Способ выбирается при заведении приложения
**ДОЛЖЕН.** Решение «копировать или дампить» (R6) принимается, когда
**ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда
приложение заводят.
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не
@@ -119,7 +127,7 @@
первой неудачной попытки восстановления, то есть тогда, когда данных уже
нет.
### R8. Приложение разводит записываемые пути по категориям
### DIRS-8. Приложение разводит записываемые пути по категориям
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
кеша, а не один каталог на всё.
@@ -131,12 +139,12 @@
появляется молча. Приложение, которое не умеет разделять, тем самым
дефектно; раскладка под этот дефект не подстраивается.
### R9. Приложение не пишет в директорию конфигурации
### DIRS-9. Приложение не пишет в директорию конфигурации
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
конфигурации.
**Почему.** Конфигурация восстанавливается прогоном деплоя (R1.1), поэтому
**Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому
всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
+90 -46
View File
@@ -1,7 +1,11 @@
---
prefix: CONF
---
# Конфигурация приложения
Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает. Форма записи — `common/language.md`.
секретами и когда падает. Форма записи — `LANGUAGE.md`.
## Область действия
@@ -12,7 +16,7 @@
## Правила
### R1. Конфигурация — файл, а не окружение
### CONF-1. Конфигурация — файл, а не окружение
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат.
@@ -36,17 +40,17 @@
`PTRACE_MODE_READ`, то есть доступен ровно тому же кругу, что и файл под
`0600`.
### R2. Формат конфигурации — текстовый, с секциями и комментариями
### CONF-2. Формат конфигурации — текстовый, с секциями и комментариями
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
**Почему.** Комментарий у каждого поля (R9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает R9
**Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же
свойствам, из-за которых отвергнуто окружение (R1).
свойствам, из-за которых отвергнуто окружение (CONF-1).
### R3. Имя файла фиксировано, путь переопределяется опцией
### CONF-3. Имя файла фиксировано, путь переопределяется опцией
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки.
@@ -55,18 +59,40 @@
контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем
самым каналом, который закрывает R1.
самым каналом, который закрывает CONF-1.
### R4. В репозитории лежит образец, а не рабочий конфиг
### CONF-20. Отсутствие файла конфигурации — ошибка старта
**ДОЛЖЕН.** Если файла нет ни по пути из опции, ни по имени по умолчанию в
рабочей директории (CONF-3), приложение не стартует: сообщение называет
искомый путь, код возврата ненулевой.
**Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
развёртывание не довело работу до конца, а не что приложение попросили
работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
чаще всего.
Старт без файла ничего не спасает: у приложения с обязательными полями или
секретами всё равно упадёт валидация (CONF-18), только вместо одного сообщения
«нет `config.toml`» получится каскад «поле пусто», за которым настоящая
причина — деплой не отрендерил файл — не видна.
Приложение, которое запускается вообще без конфигурации, этой конвенцией не
описывается: это отдельный случай и отдельная конвенция.
<!-- local:проверки -->
<!-- /local -->
### CONF-4. В репозитории лежит образец, а не рабочий конфиг
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (R12), а секрет,
**Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча.
### R5. Конфиг разбирается один раз при старте
### CONF-5. Конфиг разбирается один раз при старте
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет.
@@ -74,10 +100,10 @@
**Почему.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (R17),
Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
где она видна сразу, а не в первый вызов ветки, которая это поле читает.
### R6. Конфиг неизменяем после старта
### CONF-6. Конфиг неизменяем после старта
**ДОЛЖЕН.** Смена параметров — рестарт процесса.
@@ -89,7 +115,7 @@
Горячая перезагрузка — отдельное решение с отдельным обоснованием, а не
умолчание.
### R7. Умолчания живут в коде
### CONF-7. Умолчания живут в коде
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
@@ -98,17 +124,17 @@
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется.
### R8. Образец перечисляет все поля
### CONF-8. Образец перечисляет все поля
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (R7).
которых есть умолчание (CONF-7).
**Почему.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой R7
нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
покупает себе видимость.
### R9. У каждого поля образца есть комментарий
### CONF-9. У каждого поля образца есть комментарий
**ДОЛЖЕН.** Каждое поле сопровождается комментарием, из которого ясно:
@@ -123,35 +149,35 @@
дают валидное значение и работающий процесс, а ошибка обнаруживается по
последствиям — таймаут в тысячу раз не тот.
### R10. Обязательность полей определяется дискриминатором `type`
### CONF-10. Обязательность полей определяется дискриминатором `type`
**ДОЛЖЕН.** Когда набор полей секции зависит от поля-дискриминатора (выбор
бекенда или внешнего сервиса), валидация идёт по его значению:
| № | Значение `type` | Валидация |
|---|---|---|
| R10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| R10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
| CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
**Почему.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
значений (R10.2) нужно потому, что опечатка в `type` иначе неотличима от
значений (CONF-10.2) нужно потому, что опечатка в `type` иначе неотличима от
неподдерживаемого варианта, и за списком приходится идти в код.
### R11. Образец показывает все варианты `type`
### CONF-11. Образец показывает все варианты `type`
**СЛЕДУЕТ.** Основной вариант предзаполнен рабочими значениями,
альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (R8, R9) ровно на той секции, где выбор
теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации.
### R12. Секреты в конфиг приносит деплой
### CONF-12. Секреты в конфиг приносит деплой
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет.
@@ -163,27 +189,28 @@
этом остаётся тривиальным: оно читает файл и про секреты не знает ничего
особенного.
### R13. Рендеренный конфиг — `0600` и владелец-рантайм
### CONF-13. Рендеренный конфиг — `0600` и владелец-рантайм
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс.
**Почему.** После R1 и R12 файл конфигурации — единственная поверхность, на
которой секреты лежат, и весь довод «файл вместо окружения» держится на его
правах: конфиг, читаемый всеми на машине, раздаёт секреты шире, чем раздало
бы окружение, — и тогда R1 меняет одну утечку на другую.
**Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная
поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
другую.
### R14. В образце секретные поля — пустые строки
### CONF-14. В образце секретные поля — пустые строки
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (R15) его пропускает. Пустая строка делает недорендеренный конфиг
непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного.
### R15. Загрузчик проверяет, что обязательные секреты не пусты
### CONF-15. Загрузчик проверяет, что обязательные секреты не пусты
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
@@ -191,12 +218,12 @@
в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем.
### R16. Секреты не попадают в логи
### CONF-16. Секреты не попадают в логи
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (R13): они
**Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при
@@ -205,7 +232,7 @@
<!-- local:секретные-поля -->
<!-- /local -->
### R17. Конфиг валидируется на старте, до приёма трафика
### CONF-17. Конфиг валидируется на старте, до приёма трафика
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину».
@@ -216,27 +243,24 @@
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
завершения, и приложение считается развёрнутым.
### R18. Минимальный набор проверок
### CONF-18. Минимальный набор проверок
**ДОЛЖЕН.** Валидация покрывает как минимум:
| № | Что проверяется | Когда всплывёт без проверки |
|---|---|---|
| R18.1 | обязательные поля заданы (непустота секретов — R15) | в ветке, которая это поле читает |
| R18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
| R18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
| R18.4 | строки, которые парсятся во что-то (длительности, зоны, URL), реально парсятся | при первом обращении к тому, что за ними стоит |
| R18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
| CONF-18.1 | обязательные поля заданы (непустота секретов — CONF-15) | в ветке, которая это поле читает |
| CONF-18.2 | пути существуют и доступны на запись/чтение по назначению | при первой записи, уже после отчёта об успешном старте |
| CONF-18.3 | числовые диапазоны и единицы: доли, таймауты, счётчики попыток | искажённым поведением без единого сообщения об ошибке |
| CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
| CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению.
<!-- local:проверки -->
<!-- /local -->
### R19. Проблемы конфига показываются разом
### CONF-19. Проблемы конфига показываются разом
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой.
@@ -247,6 +271,26 @@
одного источника: разом они читаются как одна причина, по одной — как
череда несвязанных мелочей.
### CONF-21. Значение поля в сообщении валидатора — по признаку секретности
**ДОЛЖЕН.** Состав сообщения определяется тем же признаком секретности
поля, которым уже пользуются CONF-15 и CONF-16:
| № | Поле | В сообщении |
|---|---|---|
| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
| CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
**Почему.** Сообщение без значения отправляет читателя в файл — сличать
глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
или пробел в конце значения из такого сообщения не читаются вовсе. Значение
секретного поля при этом печатать некуда: вывод старта уходит в лог
супервизора и вывод CI, где круг доступа шире прав файла `0600`, — тот же
канал утечки, который закрывает CONF-16. Отдельный список «что не печатать»
не заводится: признак один на CONF-15, CONF-16 и CONF-21, а второй список
разошёлся бы с первым — и поле оказалось бы секретным для логов, но
печатаемым валидатором.
## Связано
- `arch/time.md` — формат времени; зона отображения — единственный
@@ -1,49 +1,46 @@
---
prefix: KEYS
---
# Идентификаторы сущностей
Как выбираются и как выглядят первичные ключи сущностей. Форма записи —
`common/language.md`.
`LANGUAGE.md`.
## Область действия
Схема базы меняется тяжело: таблица не переезжает от того, что её
потрогали. Поэтому правила распространяются на **новые таблицы**;
существующие живут как есть и перечисляются в отступлениях, причём этот
список постоянный, а не список задач на дочистку.
список постоянный, а не список задач на дочистку. Целочисленные ключи
существующих приложений — именно такой случай: они не мигрируют, и правила
их работы описаны в конвенции схемы, а не здесь.
## Правила
### R1. Вид первичного ключа выбирается один раз на репозиторий
### KEYS-1. Первичный ключ новой сущности — ULID
**ДОЛЖЕН.** Репозиторий отвечает на один вопрос и держит ответ для всех
своих таблиц:
**ДОЛЖЕН.** Новая сущность получает сортируемый строковый идентификатор,
который порождает приложение, — во **всех** таблицах, включая те, что
снаружи не адресуются.
> Есть ли в приложении хотя бы одна сущность, которую адресуют **извне**
> по идентификатору из URL, запроса API или callback-данных?
**Почему.** Ветвления здесь нет намеренно, хотя напрашивается: «эту
сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности
имеют привычку становиться внешними — и тогда целочисленный идентификатор
утекает в URL задним числом, а миграция ключа на живых данных стоит
несопоставимо дороже, чем взять строковый сразу. Заранее отличить те, с
кем это случится, не получается: если бы получалось, они бы уже назывались
внешними.
| № | Ответ | Вид ключа |
|---|---|---|
| R1.1 | да, хотя бы одна | сортируемый строковый идентификатор, который генерирует приложение (ULID) — во **всех** таблицах, включая внутренние |
| R1.2 | ни одной | автоинкремент |
Второй довод дешевле, но важнее в быту: одна ментальная модель избавляет от
спора при заведении каждой таблицы и делает идентификатор **глобальным**
уникальным across таблиц, а не только внутри своей. На этом держится
корреляция по логам (KEYS-7).
**Почему.** Квантор репозиторный, а не потабличный, по двум причинам.
Внутренние сущности имеют привычку становиться внешними — и тогда
целочисленный идентификатор утекает в URL задним числом, а миграция ключа
на живых данных стоит несопоставимо дороже, чем взять строковый сразу.
Вторая причина дешевле, но важнее в быту: одна ментальная модель избавляет
от спора при заведении каждой таблицы.
Правило про **сгенерированные суррогатные** ключи. Естественные и составные
ключи у таблиц-деталей (KEYS-6) — третья категория, они допустимы всегда.
Критерий — именно **адресация**: снаружи по этому идентификатору
возвращаются к системе. Не «виден в логе»: туда рано или поздно попадает
любой идентификатор, и по такому критерию ветка R1.2 была бы недостижима.
Запрет смешивания касается двух видов **сгенерированных суррогатных**
ключей. Естественные и составные ключи у таблиц-деталей (R6) — третья
категория, они допустимы при любом ответе.
<!-- local:решение -->
<!-- /local -->
### R2. При выборе R1.1 идентификатор генерирует приложение, а не база
### KEYS-2. Идентификатор генерирует приложение, а не база
**ДОЛЖЕН.** Значение ключа известно до вставки строки.
@@ -53,26 +50,26 @@
и достраивать связи вторым проходом, либо иметь два источника истины о
моменте создания.
### R3. Генерация и разбор идентификаторов — в единственной точке
### KEYS-3. Генерация и разбор идентификаторов — в единственной точке
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет.
**Почему.** Нормализация регистра (R4) и проверка формата обязаны
**Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан.
### R4. Канонический вид — нижний регистр
### KEYS-4. Канонический вид — нижний регистр
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (R3)
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3)
разный регистр появится в базе сам собой.
### R5. Внешний идентификатор разбирается до обращения к базе
### KEYS-5. Внешний идентификатор разбирается до обращения к базе
**ДОЛЖЕН.** Значение, пришедшее снаружи, проходит разбор и нормализацию
раньше, чем по нему делается запрос. Реакция на неудачный разбор зависит от
@@ -80,20 +77,28 @@
| № | Откуда пришёл | Разбор не удался → |
|---|---|---|
| R5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| R5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
| KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика.
Разделение R5.1 и R5.2 нужно, потому что источники значат разное. Мусор в
URL — это чужая или протухшая ссылка, и «не найдено» описывает ситуацию
точно. Мусор из собственной формы — это баг интерфейса или устаревший
экран; ответ «не найдено» здесь скрывает дефект и лишает диагностики
единственный момент, когда он заметен.
Разделение KEYS-5.1 и KEYS-5.2 нужно, потому что источники значат разное.
Мусор в URL — это чужая или протухшая ссылка, и «не найдено» описывает
ситуацию точно. Мусор из собственной формы — это баг интерфейса или
устаревший экран; ответ «не найдено» здесь скрывает дефект и лишает
диагностики единственный момент, когда он заметен.
### R6. У таблиц-деталей допустим естественный или составной ключ
Таблица перечисляет **внешние** источники — те, откуда значение приходит
вместе с запросом, и правило говорит, что отдать в ответ. Идентификатор из
конфигурации, из собственной базы или из фикстуры сюда не относится: он
ничего не отдаёт наружу, а его невалидность означает, что сломано у нас.
Формат идентификатора в конфигурации проверяется на старте
(`CONF-18`), невалидное значение в собственной базе — нарушенный
инвариант единой точки (KEYS-3).
### KEYS-6. У таблиц-деталей допустим естественный или составной ключ
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится.
@@ -103,10 +108,10 @@ URL — это чужая или протухшая ссылка, и «не на
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт.
### R7. Прочие генерируемые идентификаторы — через ту же точку
### KEYS-7. Прочие генерируемые идентификаторы — через ту же точку
**ДОЛЖЕН.** Идентификаторы, не являющиеся первичными ключами (батч,
задание, корреляционный ключ), порождаются тем же модулем (R3) и в том же
задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же
формате.
**Почему.** Единый формат делает работающим главный побочный эффект
@@ -117,7 +122,7 @@ URL — это чужая или протухшая ссылка, и «не на
## Почему ULID, а не UUID
Ветка R1.1 требует **сортируемый** строковый идентификатор. UUIDv4 не
KEYS-1 требует **сортируемый** строковый идентификатор. UUIDv4 не
сортируется по времени вовсе. UUIDv7 (RFC 9562) сортируется — и против него
остаются два довода: 36 символов против 26 и дефисы, из-за которых
идентификатор не берётся ни двойным кликом, ни `grep`-ом как одно слово.
+43 -23
View File
@@ -1,8 +1,12 @@
---
prefix: TIME
---
# Время
Как приложение записывает моменты и длительности: в каком формате, откуда
берётся значение и где появляется не-UTC. Форма записи —
`common/language.md`.
`LANGUAGE.md`.
## Область действия
@@ -14,7 +18,7 @@
## Правила
### R1. Единый формат — RFC 3339, UTC, суффикс `Z`
### TIME-1. Единый формат — RFC 3339, UTC, суффикс `Z`
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами.
@@ -25,7 +29,7 @@
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
убирает из данных и смещение, и сам вопрос «в какой зоне это записано».
### R2. Ширина строки фиксируется на каждый носитель
### TIME-2. Ширина строки фиксируется на каждый носитель
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает.
@@ -38,18 +42,18 @@
везде, а только на тех парах записей, где дробная часть оказалась короче, —
то есть редко, выборочно и невоспроизводимо.
### R3. Точность разных носителей может различаться
### TIME-3. Точность разных носителей может различаться
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
**Почему.** Квантор в R2 — на носитель, а не на приложение, потому что
**Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что
строки разных носителей между собой не сравниваются: сортировка идёт внутри
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы R2 не читался
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался
как «одна точность на всё приложение»: от подгонки формата логов под формат
колонки ни одна пара строк не становится сравнимой, зато точность режется до
худшего из носителей.
### R4. Локальное время не хранится и не передаётся
### TIME-4. Локальное время не хранится и не передаётся
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
зоне.
@@ -60,29 +64,45 @@
разберёт час перехода на зимнее время: этот час идёт дважды, две записи
получают одинаковую метку, и порядок между ними не восстанавливается ничем.
### R5. Единая точка получения «сейчас», форматирования и разбора
### TIME-13. Чужой вход нормализуется при разборе, а не отклоняется
**ДОЛЖЕН.** Валидное по RFC 3339 значение с офсетом, отличным от `Z`, или с
долями секунды принимается от внешней системы и приводится к каноническому
виду (TIME-1) в точке разбора (TIME-5).
**Почему.** Канонический вид — обязательство нашего писателя, а не
контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же
момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со
стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение
нарушает форму и ширину носителя (TIME-1, TIME-2) и портит сортировку
выборочно — только на записях, пришедших извне, и далеко от места разбора.
Нормализация
в единой точке разбора оставляет ровно одно место, где неканонический вид
существует, — по ту сторону границы его уже нет.
### TIME-5. Единая точка получения «сейчас», форматирования и разбора
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
метки; прямые вызовы часов по коду не разбросаны.
**Почему.** Формат, зона (R1) и ширина (R2) обязаны выполняться для всех
**Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех
меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
данных, и обнаруживается, когда испорченных записей уже накопилось.
Соображение то же, что для идентификаторов (`arch/db-identifiers.md R3`).
Соображение то же, что для идентификаторов (`arch/db-identifiers.md TIME-3`).
### R6. Дефолтов времени в схеме БД нет
### TIME-6. Дефолтов времени в схеме БД нет
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий
код: значение появляется, но приходит от сервера БД — то есть с других часов
и в формате, который выбирала не единая точка (R5). Без дефолта та же ошибка
и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка
падает громко и чинится в момент написания, а не при разборе расхождения
между временем в записи и временем в логе. Правило то же, что для
идентификаторов (`arch/db-identifiers.md R2`).
идентификаторов (`arch/db-identifiers.md TIME-2`).
### R7. Длительность — отдельная величина, а не пара меток
### TIME-7. Длительность — отдельная величина, а не пара меток
**ДОЛЖЕН.** Длительность операции записывается числом (обычно
миллисекундами) в поле вида `duration_ms`.
@@ -92,9 +112,9 @@
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
логе; число сравнивается, агрегируется и попадает в перцентили без этого
шага. Кроме того, разность сохранённых меток считается по стенным часам и
наследует их дефект (R9).
наследует их дефект (TIME-9).
### R8. Длительность засекает слой, который делает вызов
### TIME-8. Длительность засекает слой, который делает вызов
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
@@ -103,14 +123,14 @@
вызова. В обоих случаях число остаётся правдоподобным и потому не
оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
### R9. Момент и интервал берутся с разных часов
### TIME-9. Момент и интервал берутся с разных часов
**ДОЛЖЕН.** Источник зависит от того, что записывается:
| № | Величина | Источник |
|---|---|---|
| R9.1 | момент события | стенные часы через единую точку (R5) |
| R9.2 | длительность операции | монотонные часы процесса |
| TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) |
| TIME-9.2 | длительность операции | монотонные часы процесса |
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
@@ -120,7 +140,7 @@
упустить: источник меток времени и источник интервалов — разные, даже если
оба называются «часы».
### R10. Не-UTC существует только на слое отображения
### TIME-10. Не-UTC существует только на слое отображения
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
проникает в хранение, сортировку и логи.
@@ -132,7 +152,7 @@
смещение удваивается, результат остаётся похожим на правду, а найти
виновный слой можно только перечитав их все.
### R11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
### TIME-11. Зона отображения берётся из конфигурации, по умолчанию `UTC`
**ДОЛЖЕН.** Значение приходит из конфигурации (`arch/config.md`), значение
по умолчанию — `UTC`.
@@ -143,7 +163,7 @@
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
как «зону не задали», а не как «где-то потерялось смещение».
### R12. В календарных вычислениях зона указывается явно
### TIME-12. В календарных вычислениях зона указывается явно
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса.
@@ -153,7 +173,7 @@
расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов.
Зона по умолчанию здесь та же, что и для отображения (R11); календарная
Зона по умолчанию здесь та же, что и для отображения (TIME-11); календарная
логика, которой нужна другая, получает её тем же явным аргументом.
<!-- local:механизировано -->
@@ -1,11 +1,12 @@
---
prefix: GCFG
extends: arch/config.md
---
# Конфигурация: реализация на Go
Как `arch/config.md` выглядит в Go-приложении: формат, загрузчик, границы
запрета на окружение. Форма записи — `common/language.md`.
запрета на окружение. Форма записи — `LANGUAGE.md`.
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
проверка их непустоты идёт вместе с остальной валидацией — как описано в
@@ -13,7 +14,7 @@ extends: arch/config.md
## Правила
### R1. Формат конфигурации — TOML
### GCFG-1. Формат конфигурации — TOML
**ДОЛЖЕН.** Конфиг — файл TOML.
@@ -25,7 +26,7 @@ extends: arch/config.md
поправленный руками на сервере, ломается заметно, а не меняет вложенность
молча.
### R2. Разбор и валидация — целиком в `internal/config`
### GCFG-2. Разбор и валидация — целиком в `internal/config`
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `Config`.
@@ -34,11 +35,11 @@ extends: arch/config.md
после — уже нет, и это единственная граница, на которой такое утверждение
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
«проверено ли это поле» только чтением всех вызывающих, часть полей
неизбежно окажется непроверенной, и fail-fast (R15) выродится в отказ
неизбежно окажется непроверенной, и fail-fast (GCFG-15) выродится в отказ
посреди работы. Экспортированный разбор вдобавок даёт второй способ
получить конфиг — мимо умолчаний (R5).
получить конфиг — мимо умолчаний (GCFG-5).
### R3. Весь конфиг — одна корневая структура
### GCFG-3. Весь конфиг — одна корневая структура
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям.
@@ -50,7 +51,7 @@ extends: arch/config.md
(включена интеграция — заданы все её поля) при этом перестают быть
проверяемыми в одном месте.
### R4. Под-структуры названы по секциям файла
### GCFG-4. Под-структуры названы по секциям файла
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
@@ -60,7 +61,7 @@ extends: arch/config.md
восстанавливается чтением тегов, и проделывать это приходится для каждой
секции заново.
### R5. Умолчания задаёт `Default()`
### GCFG-5. Умолчания задаёт `Default()`
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх.
@@ -72,7 +73,7 @@ extends: arch/config.md
подставляют разное. `Default()` — единственное место, откуда список
умолчаний читается разом и переносится в образец.
### R6. Имя файла фиксировано, путь переопределяется флагом
### GCFG-6. Имя файла фиксировано, путь переопределяется флагом
**СЛЕДУЕТ.** По умолчанию читается `config.toml` в рабочей директории,
путь переопределяет флаг `--config=path`, образец рядом —
@@ -85,7 +86,7 @@ extends: arch/config.md
`config.example.toml` вдобавок делает расхождение образца с реальным
конфигом видимым обычным `diff`, а не вычиткой.
### R7. Длительности — собственный тип с `UnmarshalText`
### GCFG-7. Длительности — собственный тип с `UnmarshalText`
**ДОЛЖЕН.** Поля-длительности объявляются своим типом, отдающим
`time.Duration`:
@@ -105,11 +106,11 @@ func (d Duration) Std() time.Duration { … }
в себе и разбирается тем же `time.ParseDuration`, что и остальной код.
У обёртки есть цена: `UnmarshalText` вызывается на разборе TOML, то есть
раньше, чем начинает работать сбор проблем (R12). Ошибка в длительности
раньше, чем начинает работать сбор проблем (GCFG-12). Ошибка в длительности
приходит отдельно и первой, а остальные проблемы конфига в этом запуске не
показываются.
### R8. Приложение не читает окружение
### GCFG-8. Приложение не читает окружение
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
@@ -120,9 +121,9 @@ func (d Duration) Std() time.Duration { … }
чтением всего кода — а узнают о нём обычно на сервере, где переменная не
выставлена.
### R9. Проверка запрета покрывает все входы в окружение
### GCFG-9. Проверка запрета покрывает всю семью `os`
**ДОЛЖЕН.** Механическая проверка R8 (`forbidigo`) ловит не только
**ДОЛЖЕН.** Механическая проверка GCFG-8 (`forbidigo`) ловит не только
`os.Getenv`:
```
@@ -134,17 +135,23 @@ func (d Duration) Std() time.Duration { … }
незаметно: правило числится механизированным, и глазами его больше никто не
проверяет.
### R10. За границей приложения запрет не действует
Полного покрытия этот паттерн не даёт и дать не может: мимо него проходят
`syscall.Getenv`, вызов через алиас пакета и чтение `/proc/self/environ`.
Проверка закрывает обычные способы — те, которыми окружение читают не
нарочно; сознательный обход она не ловит, и считать GCFG-8 полностью
механизированным нельзя.
### GCFG-10. За границей приложения запрет не действует
**ДОПУСКАЕТСЯ.** Чтение окружения там, где читающий — не конфигурируемое
приложение:
| № | Кто читает | Вердикт |
|---|---|---|
| R10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| R10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
**Почему.** R8 — про конфигурацию приложения; расширенный до «никто не
**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
@@ -152,19 +159,19 @@ func (d Duration) Std() time.Duration { … }
лечится `//nolint` наугад: там, где легальные случаи приходится глушить
руками, вместе с ними проходят и нелегальные.
### R11. Прокси задаётся конфигом, а не `HTTP_PROXY`
### GCFG-11. Прокси задаётся конфигом, а не `HTTP_PROXY`
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает R8, и притом самый неудобный: маршрут
тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
исходящих запросов отличается от машины к машине без единого следа в
конфиге и в образце, а расследование начинается с вопроса «почему на
сервере ходит не так, как локально».
### R12. Проблемы конфига собираются `errors.Join`
### GCFG-12. Проблемы конфига собираются `errors.Join`
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`.
@@ -175,7 +182,7 @@ func (d Duration) Std() time.Duration { … }
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
вложенной проблеме.
### R13. Имя зоны проверяется `time.LoadLocation`
### GCFG-13. Имя зоны проверяется `time.LoadLocation`
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
@@ -183,9 +190,9 @@ func (d Duration) Std() time.Duration { … }
тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо
fail-fast (R15).
fail-fast (GCFG-15).
### R14. `time/tzdata` импортируется в `main`
### GCFG-14. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете.
@@ -193,11 +200,11 @@ fail-fast (R15).
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (R13) означает ровно одно — битое имя зоны; без
базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
неё тот же конфиг валиден на машине разработчика и падает в контейнере без
zoneinfo, а сообщение указывает не на ту причину.
### R15. Невалидный конфиг — `ERROR` и выход из `main`
### GCFG-15. Невалидный конфиг — `ERROR` и выход из `main`
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров.
@@ -1,24 +1,25 @@
---
prefix: GKEY
extends: arch/db-identifiers.md
---
# Идентификаторы: реализация на Go
Как `arch/db-identifiers.md` выглядит в Go-приложении, выбравшем ULID
(ветка `arch/db-identifiers.md` R1.1). Форма записи — `common/language.md`.
Как `arch/db-identifiers.md` выглядит в Go-приложении. Форма записи —
`LANGUAGE.md`.
Единая точка из `arch/db-identifiers.md` R3 — пакет `internal/ident`: он
Единая точка из `KEYS-3` — пакет `internal/ident`: он
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
(`Parse`). Правила ниже говорят, из каких мест кода эти функции зовутся.
## Правила
### R1. Генерация и разбор — только через `internal/ident`
### GKEY-1. Генерация и разбор — только через `internal/ident`
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
`internal/ident`; других генераторов и парсеров id в коде нет.
**Почему.** Реализация `arch/db-identifiers.md` R3 и R4. Вызов
**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
выглядит нарушением: значение получается валидное, просто мимо нормализации
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
@@ -26,12 +27,12 @@ ULID-библиотеки — одна строка, доступная из л
модуля, а «забытая нормализация» не находится ничем, пока запрос молча не
перестанет находить существующую запись.
### R2. Первичный ключ генерируется в `Create`-методах store
### GKEY-2. Первичный ключ генерируется в `Create`-методах store
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
внутри `Create`-метода слоя store.
**Почему.** `arch/db-identifiers.md` R2 требует, чтобы значение было
**Почему.** `KEYS-2` требует, чтобы значение было
известно до вставки, но не говорит, кто его присваивает. Store — последний
слой, через который проходят все пути создания строки, включая импорт,
фоновые задания и тесты. Генерация выше по стеку делает присвоение
@@ -39,18 +40,18 @@ ULID-библиотеки — одна строка, доступная из л
строку в колонку ключа: для строкового PK это валидное значение, база его
не отклонит, и дефект обнаружится на второй такой вставке.
### R3. Прочие идентификаторы генерируются в точке начала операции
### GKEY-3. Прочие идентификаторы генерируются в точке начала операции
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
вызовом `ident.NewID()` там, где операция начинается.
**Почему.** Смысл такого идентификатора (`arch/db-identifiers.md` R7) —
**Почему.** Смысл такого идентификатора (`KEYS-7`) —
сшивать записи лога всей операции. Созданный ниже по стеку или в момент
первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
когда операция упала до того, как что-либо записала: без общего ключа эти
записи из лога не собираются вообще.
### R4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
### GKEY-4. Бэкфилл в миграциях — `ident.NewIDAt(t)`
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
Go-миграции, порождаются с историческим временем строки, а не с текущим.
@@ -62,18 +63,18 @@ Go-миграции, порождаются с историческим врем
Исправить это потом нельзя: исходное время в идентификаторе не
восстановить.
### R5. Разбор — на входных границах, до обращения к store
### GKEY-5. Разбор — на входных границах, до обращения к store
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
callback'а бота — раньше, чем идентификатор попадёт в store.
**Почему.** Реализация `arch/db-identifiers.md` R5. Граница выбрана
**Почему.** Реализация `KEYS-5`. Граница выбрана
транспортная, потому что только на ней известен источник значения, от
которого зависит реакция (R8): store видит одинаковую строку независимо от
которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
того, пришла она из URL или из собственной формы, и ответить по-разному
оттуда уже невозможно.
### R6. Id в структурах — обычный `string`
### GKEY-6. Id в структурах — обычный `string`
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
`string`.
@@ -84,35 +85,35 @@ callback'а бота — раньше, чем идентификатор поп
параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
json и шаблонами, то есть даёт цену без выгоды.
### R7. Отдельный тип — когда появляется вторая семья идентификаторов
### GKEY-7. Отдельный тип — когда появляется вторая семья идентификаторов
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
можно перепутать, для них заводятся различимые типы.
**Почему.** Явное разрешение нужно, чтобы R6 не читался как запрет на
**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
типизацию навсегда. Условие названо ровно то, при котором тип начинает
работать: пока все идентификаторы — `string`, подстановка одного вида
вместо другого компилируется и обнаруживается только на данных.
### R8. Реакция на невалидный id зависит от источника
### GKEY-8. Реакция на невалидный id зависит от источника
**ДОЛЖЕН.** Когда разбор не удался, ответ определяется тем, откуда пришло
значение:
| № | Источник | Ответ |
|---|---|---|
| R8.1 | путь или query URL | 404 без обращения к store |
| R8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
| GKEY-8.1 | путь или query URL | 404 без обращения к store |
| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
**Почему.** Реализация `arch/db-identifiers.md` R5.1 и R5.2 в терминах
HTTP-кодов. В случае R8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае R8.2
**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
значение сформировало само приложение, и невалидность означает баг
интерфейса или устаревший экран; ответ «не найдено» здесь выглядит штатно,
в логах не оставляет аномалии и тем самым съедает единственный момент,
когда дефект заметен.
### R9. Транспорт не создаёт доменные ошибки
### GKEY-9. Транспорт не создаёт доменные ошибки
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
@@ -1,7 +1,11 @@
---
prefix: MIGR
---
# Схема и миграции (SQLite, Go)
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
Go-приложении. Форма записи — `common/language.md`.
Go-приложении. Форма записи — `LANGUAGE.md`.
## Область действия
@@ -12,7 +16,7 @@ Go-приложении. Форма записи — `common/language.md`.
## Миграции
### R1. Миграции ведёт goose
### MIGR-1. Миграции ведёт goose
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
goose.
@@ -24,7 +28,7 @@ goose.
существующую таблицу. На сервере это означает ручной разбор состояния
схемы вместо автоматического деплоя.
### R2. Файлы миграций лежат рядом со store-слоем
### MIGR-2. Файлы миграций лежат рядом со store-слоем
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
схемой.
@@ -35,14 +39,14 @@ goose.
код без миграции, либо миграция без кода; расходятся они на сервере, где
схема ещё старая.
### R3. Форма миграции выбирается по тому, нужен ли код
### MIGR-3. Форма миграции выбирается по тому, нужен ли код
**ДОЛЖЕН.** Миграция пишется в той форме, которой требует её содержимое:
| № | Что делает миграция | Форма |
|---|---|---|
| R3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| R3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
@@ -50,12 +54,12 @@ goose.
Обратное направление дороже. Перенос данных и генерация идентификаторов
выражаются на SQL либо громоздко, либо неточно: идентификатор по
`arch/db-identifiers.md` R2 порождает приложение, и SQL-миграция вынуждена
`KEYS-2` порождает приложение, и SQL-миграция вынуждена
завести для него второй генератор — ровно то, что запрещает
`arch/db-identifiers.md` R3. Единообразие формы здесь покупается
`KEYS-3`. Единообразие формы здесь покупается
дублированием логики, которая уже есть в коде.
### R4. В деплое схема движется только вперёд
### MIGR-4. В деплое схема движется только вперёд
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
ошибка исправляется новой миграцией вперёд.
@@ -67,14 +71,14 @@ goose.
следующей миграцией, оставляет целыми и данные, и журнал применённых
версий.
### R5. Down пишется, когда он честно обращает up
### MIGR-5. Down пишется, когда он честно обращает up
**ДОЛЖЕН.** Наличие down-миграции определяется тем, обратим ли up:
| № | Что делает up | Down |
|---|---|---|
| R5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| R5.2 | необратимо преобразует данные | не пишется |
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| MIGR-5.2 | необратимо преобразует данные | не пишется |
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда,
и именно там он обязан действительно обращать up. Имитация опаснее
@@ -83,7 +87,7 @@ goose.
down останавливает сразу и заставляет пересоздать базу — это дешевле, чем
отладка по данным, которых уже нет.
### R6. ER-схема обновляется в том же изменении
### MIGR-6. ER-схема обновляется в том же изменении
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
изменением.
@@ -99,7 +103,7 @@ down останавливает сразу и заставляет пересо
Правила ниже описывают хранение в SQLite: выбор типа диктует движок базы,
а не язык приложения.
### R7. Enum-поля — `TEXT`, допустимые значения держит код
### MIGR-7. Enum-поля — `TEXT`, допустимые значения держит код
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
без `CHECK`-ограничения на список значений.
@@ -115,7 +119,7 @@ down останавливает сразу и заставляет пересо
таблицы соответствия, которую пришлось бы держать в голове для числового
кода.
### R8. Метки времени — `TEXT` в формате из `arch/time.md`
### MIGR-8. Метки времени — `TEXT` в формате из `arch/time.md`
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
пишутся в формате из `arch/time.md`.
@@ -127,7 +131,7 @@ down останавливает сразу и заставляет пересо
преобразования, а значит и без потери индекса. Соседство двух форматов в
одной колонке ломает и сравнение, и разбор на стороне Go.
### R9. Умолчание `DEFAULT (datetime('now'))` не ставится
### MIGR-9. Умолчание `DEFAULT (datetime('now'))` не ставится
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
на уровне схемы.
@@ -138,10 +142,10 @@ down останавливает сразу и заставляет пересо
по ошибке.
Вдобавок `datetime('now')` даёт `YYYY-MM-DD HH:MM:SS` — без `T` и без `Z`,
то есть не тот формат, которого требует R8. В колонке оказываются строки
то есть не тот формат, которого требует MIGR-8. В колонке оказываются строки
двух видов, и ломается ровно то, ради чего формат выбран.
### R10. Булевы поля — `INTEGER` со значениями 0 и 1
### MIGR-10. Булевы поля — `INTEGER` со значениями 0 и 1
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
@@ -152,32 +156,33 @@ down останавливает сразу и заставляет пересо
типа. Такой дефект не падает, не виден в логе и переживает тесты, которые
проверяют, что список не пуст.
### R11. Вид первичного ключа задаёт `arch/db-identifiers.md`
### MIGR-11. Первичный ключ новой таблицы — TEXT ULID
**ДОЛЖЕН.** В репозитории, подписанном на `arch/db-identifiers.md`, вид
ключа выбирается по её R1, и `AUTOINCREMENT` в миграции не пишется.
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
приложения (`KEYS-1`, `KEYS-2`).
**Почему.** Вопрос о виде ключа решается один раз на репозиторий
(`arch/db-identifiers.md` R1). Повторив здесь его ветвление, мы завели бы
второй источник правды, и соседние таблицы разъехались бы по разным
ответам на один и тот же вопрос.
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение,
принятое в `arch/db-identifiers.md`. Повторить там ветвление или условие
значило бы завести второй источник правды, и соседние таблицы разъехались бы
по разным ответам на один вопрос.
`AUTOINCREMENT` не нужен ни в одной из веток R1. Строкового ключа он не
касается вовсе, а целочисленному даёт единственную гарантию — что значение
rowid не будет переиспользовано после удаления строки, — ценой служебной
таблицы `sqlite_sequence` и записи в неё на каждой вставке. Гарантия эта
имеет смысл, только если старые идентификаторы живут где-то вне базы.
`AUTOINCREMENT` в такой таблице невозможен: SQLite разрешает его только на
`INTEGER PRIMARY KEY`. То есть для новых таблиц запрещать нечего.
### R12. Вне `arch/db-identifiers.md` первичный ключ — автоинкремент
### MIGR-12. Целочисленный ключ идёт вместе с `AUTOINCREMENT`
**ДОПУСКАЕТСЯ.** Репозиторий, не подписанный на `arch/db-identifiers.md`,
берёт целочисленный автоинкрементный ключ.
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
**Почему.** Явное разрешение нужно, чтобы R11 не читался как требование
подписаться на `arch/db-identifiers.md`. Выбор вида ключа — решение уровня
репозитория, и конвенция про типы колонок его за репозиторий не принимает;
приложению, сущности которого не адресуют снаружи, целочисленный ключ
ничего не стоит.
**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
удаления последней строки номер переиспользуется. Протухшая ссылка на
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
Обнаружить это по данным нельзя: обе строки валидны.
Цена — служебная таблица `sqlite_sequence` и запись в неё на каждой
вставке — против этого пренебрежима. Для новых таблиц вопрос не возникает:
там ключ строковый (MIGR-11).
<!-- local:механизировано -->
<!-- /local -->
@@ -1,27 +1,42 @@
---
prefix: GERR
---
# Ошибки
Как ошибки строятся, оборачиваются и проверяются. Форма записи —
`common/language.md`. Где и когда ошибку **логировать** — в
`LANGUAGE.md`. Где и когда ошибку **логировать** — в
`lang/go/logging.md` (коротко: лог один раз на доменной границе).
Две границы, о которых говорят правила ниже:
- **доменная граница** — место, где определяется исход операции: use-case,
публичная команда воркера, стадия асинхронной обработки. Ниже неё ошибка
только накапливает контекст, выше — операция уже либо удалась, либо нет.
- **внешняя граница** — место, где ответ покидает процесс: обработчик HTTP,
рендер страницы, отправка сообщения ботом.
Одна операция проходит обе: сначала доменную (там её исход логируется),
потом внешнюю (там он превращается в ответ).
## Правила
### R1. Ошибки строятся средствами стандартной библиотеки
### GERR-1. Ошибки строятся средствами стандартной библиотеки
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (R3) цепочка сообщений
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
и обычно инфраструктуру доставки стеков (Sentry) — для домашнего сервиса
это цена без покупателя.
Единственное место, где стек всё-таки нужен, — восстановленная паника: у
неё цепочки `%w` нет вовсе (R23).
неё цепочки `%w` нет вовсе (GERR-23).
### R2. Дефолт не обходится точечно
### GERR-2. Дефолт не обходится точечно
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
кодовой базы ради конкретной отладки.
@@ -30,39 +45,39 @@
перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный
сигнал, что решение R1 пора пересматривать целиком.
сигнал, что решение GERR-1 пора пересматривать целиком.
### R3. Каждый слой добавляет свой контекст
### GERR-3. Каждый слой добавляет свой контекст
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: `fmt.Errorf("parse magnet: %w", err)`.
**Почему.** На этом держится R1: цепочка заменяет стек ровно настолько,
**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
стирает участок пути — по итоговому сообщению нельзя сказать, через какую
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
кода.
### R4. Обёртка по умолчанию — `%w`
### GERR-4. Обёртка по умолчанию — `%w`
**СЛЕДУЕТ.** Глагол выбирается по тому, раскрываем ли мы причину
вызывающему:
| № | Ситуация | Глагол |
|---|---|---|
| R4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| R4.2 | причину сознательно не раскрываем | `%v` |
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| GERR-4.2 | причину сознательно не раскрываем | `%v` |
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
`errors.Is` и `errors.As` для всех слоёв выше, и ветвление по sentinel'у
(R10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
`ErrNotFound`», далеко от места обрыва. R4.2 остаётся для случая, когда
(GERR-10) молча перестаёт срабатывать — дефект проявляется как «код не заметил
`ErrNotFound`», далеко от места обрыва. GERR-4.2 остаётся для случая, когда
завязывать вызывающего на чужой тип ошибки не хотят намеренно.
### R5. Утечка внутренних деталей лечится трансляцией, а не `%v`
### GERR-5. Утечка внутренних деталей лечится трансляцией, а не `%v`
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
ошибку наружу.
@@ -71,9 +86,9 @@
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
Настоящее место защиты — R13.
Настоящее место защиты — GERR-13.
### R6. Текст обёртки — со строчной буквы и без служебных слов
### GERR-6. Текст обёртки — со строчной буквы и без служебных слов
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
@@ -83,15 +98,15 @@
нами ошибка, известно из того, что это ошибка. Зато повторяются они на
каждом уровне и вытесняют из строки полезный контекст.
### R7. Контекст обёртки называет операцию или субъект
### GERR-7. Контекст обёртки называет операцию или субъект
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
**Почему.** Обёртка ценна ровно тем, что сужает место (R3). «something
**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
failed» не сужает ничего и при этом занимает в сообщении место, которое мог
бы занять единственный полезный здесь факт — имя операции.
### R8. Слой не повторяет смысл нижнего
### GERR-8. Слой не повторяет смысл нижнего
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
@@ -104,10 +119,10 @@ failed» не сужает ничего и при этом занимает в
## Две трансляции
Ошибка меняет форму дважды, и это разные преобразования: инфраструктурная →
доменная у источника (R9) и доменная → пользовательская на внешней границе
(R13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
доменная у источника (GERR-9) и доменная → пользовательская на внешней границе
(GERR-13). Первую делает слой, работающий с зависимостью, вторую — транспорт.
### R9. Инфраструктурная ошибка транслируется в доменную у источника
### GERR-9. Инфраструктурная ошибка транслируется в доменную у источника
**ДОЛЖЕН.** Граничная ошибка зависимости превращается в доменную там, где
возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для
@@ -120,14 +135,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
состояние «нет записи» одно и то же. Трансляция у источника оставляет
знание о зависимости в единственном слое, который её и так знает.
### R10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
### GERR-10. Форма доменной ошибки выбирается по тому, что нужно вызывающему
**ДОЛЖЕН.** Между sentinel'ом и типом выбирают так:
| № | Что нужно вызывающему | Форма |
|---|---|---|
| R10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| R10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных,
@@ -136,14 +151,14 @@ HTTP-клиентов, файловой системы, внешних SDK.
каждой проверке. Две формы для одного условия — это два способа его
проверить, и про второй рано или поздно забудут.
### R11. Матчинг по тексту сообщения
### GERR-11. Матчинг по тексту сообщения
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
**Почему.** Текст сообщения — не контракт: R6–R8 разрешают переписывать его
свободно. Правка формулировки в нижнем слое молча ломает ветвление
наверху, и компилятор этого не видит. Это то же самое, что публичный API из
строки лога.
**Почему.** Текст сообщения — не контракт: GERR-6GERR-8 разрешают
переписывать его свободно. Правка формулировки в нижнем слое молча ломает
ветвление наверху, и компилятор этого не видит. Это то же самое, что
публичный API из строки лога.
## Граница: приватный канал и публичный
@@ -151,39 +166,39 @@ HTTP-клиентов, файловой системы, внешних SDK.
того, кто канал видит: приватный канал — логи (их читает владелец сервиса),
публичный — пользовательские поверхности (HTTP API, web-UI, бот).
### R12. Полная ошибка идёт в приватный канал
### GERR-12. Полная ошибка идёт в приватный канал
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
`lang/go/logging.md`.
**Почему.** Цепочка — единственный носитель диагностики (R1), и
**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и
единственный канал, где её можно показать целиком, — тот, который видит
владелец. Не записанная там, она не сохранится нигде: наружу идёт
нейтральное сообщение (R13), и восстанавливать причину будет не из чего.
нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
### R13. Публичная поверхность получает сообщение по доменной ошибке
### GERR-13. Публичная поверхность получает сообщение по доменной ошибке
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
`err.Error()` и не детали реализации (`database/sql`, пути, стек).
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
у него есть лог (R12). Зато они раскрывают устройство системы — имена
у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
причём раскрывают именно в момент, когда что-то пошло не так.
### R14. Публичное сообщение несёт корреляционный ключ
### GERR-14. Публичное сообщение несёт корреляционный ключ
**ДОЛЖЕН.** Наружу вместе с сообщением идёт id сущности либо `request_id`:
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка».
**Почему.** R13 забирает у пользователя всю фактуру; без ключа его
**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его
обращение звучит как «у меня что-то не работает», и владелец ищет запись в
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
видел.
### R15. Маппинг доменных ошибок — в одной точке на все транспорты
### GERR-15. Маппинг доменных ошибок — в одной точке на все транспорты
**ДОЛЖЕН.** Соответствие «доменная ошибка → сообщение и, для HTTP, статус»
задаётся один раз; транспорт без статусов (бот) берёт из него только
@@ -192,13 +207,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
важнее: единственная точка — это место, куда механически дописывается новая
ветвь (R16). Маппинг, размазанный по хендлерам, требование «дописать везде»
ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
ничем не проверяет.
### R16. Новая штатная ветвь отказа сразу попадает в маппинг
### GERR-16. Новая штатная ветвь отказа сразу попадает в маппинг
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
добавляется в маппинг (R15) тем же изменением.
добавляется в маппинг (GERR-15) тем же изменением.
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
@@ -208,18 +223,37 @@ HTTP-клиентов, файловой системы, внешних SDK.
<!-- local:маппинг -->
<!-- /local -->
### R17. Форма текста определяется поверхностью
### GERR-25. Непокрытая маппингом ошибка — 500 и `ERROR` с признаком
**ДОЛЖЕН.** Доменная ошибка, для которой в маппинге (GERR-15) нет ветви, отдаёт
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
признаком того, что маппинг её не знает.
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
`ERROR` — уровень выбирается по адресату (`lang/go/logging.md`). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в
маппинге, а про неизвестное сказать пользователю нечего, поэтому 4xx
отпадает.
Признак нужен потому, что без него забытая ветвь неотличима от упавшей
базы: обе дают `ERROR` с текстом ошибки, и наткнуться на пропуск можно
только случайно. Отдельное поле или своя категория сообщения делают пропуск
находимым одним фильтром — и тогда громкость 500 и `ERROR` работает как
механизм обнаружения, а не как шум.
### GERR-17. Форма текста определяется поверхностью
**ДОЛЖЕН.** У публичной границы две разные поверхности, и правило сырого
текста для них разное:
| № | Поверхность | Текст ошибки |
|---|---|---|
| R17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (R15); `err.Error()` наружу не идёт |
| R17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
| GERR-17.1 | транзиентный ответ на действие: тело ответа, `?err=`, реплика бота по результату команды | строго нейтральный, из маппинга (GERR-15); `err.Error()` наружу не идёт |
| GERR-17.2 | персистентная диагностика состояния: причина ухода записи в ошибочное состояние, сохранённая в БД и показанная оператору | сырой текст ошибки (пути, фрагмент ответа внешнего сервиса) — пока поверхность видит исключительно владелец |
Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и на неё распространяется R17.1.
поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
@@ -229,7 +263,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
про единственного зрителя — ровно то, что делает вторую поверхность
приватным каналом; без него это обычная публичная поверхность.
### R18. Секретов нет ни на одной из поверхностей
### GERR-18. Секретов нет ни на одной из поверхностей
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
ни в персистентную диагностику; источник вычищается на границе клиента.
@@ -240,19 +274,19 @@ HTTP-клиентов, файловой системы, внешних SDK.
известно, какие поля запроса секретны: дальше ошибка едет как текст, и
отличить в нём токен от идентификатора уже нельзя.
### R19. Диагностика хранится в отдельном поле
### GERR-19. Диагностика хранится в отдельном поле
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
показывают пользователю.
**Почему.** Различие R17.1 и R17.2 держится на том, что у поверхностей
**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
разные поля. Одно поле на оба назначения означает, что при первом же показе
записи наружу сырой текст уедет туда же — не по решению, а потому что поле
одно.
## panic
### R20. `panic` — только для невосстановимого
### GERR-20. `panic` — только для невосстановимого
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
ошибка инициализации, из которой нельзя стартовать.
@@ -263,25 +297,25 @@ HTTP-клиентов, файловой системы, внешних SDK.
инвариантом опаснее падения, а сервис, стартовавший без обязательной
зависимости, всё равно откажет позже и непонятнее.
### R21. Ожидаемые ошибки — значения `error`
### GERR-21. Ожидаемые ошибки — значения `error`
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как `error`.
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
его обработать. Дальше такая паника долетает до recover-границы (R22), где
его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
«мы сломались».
### R22. `recover` — на верхней границе каждой обрабатывающей единицы
### GERR-22. `recover` — на верхней границе каждой обрабатывающей единицы
**ДОЛЖЕН.** Своя граница ставится у каждой единицы, мотив у них разный:
| № | Единица | Зачем `recover` |
|---|---|---|
| R22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| R22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
**Почему.** `recover` работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
@@ -291,18 +325,54 @@ HTTP-клиентов, файловой системы, внешних SDK.
без своего `recover` уходит мимо структурированного лога, а клиент получает
оборванное соединение вместо ответа.
### R23. Recover-граница пишет `debug.Stack()`
### GERR-23. Recover-граница пишет `debug.Stack()`
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
**Почему.** Это единственное место, где стек нужен (R1): у восстановленной
**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной
паники цепочки `%w` нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало.
## Несколько ошибок
### R24. Независимые ошибки собираются `errors.Join`
### GERR-26. После `recover` единица продолжает работу, исключив упавшее
**ДОЛЖЕН.** Что происходит после перехвата, зависит от того, где стоит
граница:
| № | Где перехвачена паника | Что дальше |
|---|---|---|
| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, —
останавливать всё остальное не за что. Довод «let it crash» здесь работает
не буквально: в OTP падает изолированный процесс под супервизором, а не узел
целиком, и в Go ближайшая замена такой изоляции — граница итерации, а не
граница процесса. Обратное при этом верно и делает `recover` в цикле
обязательным (GERR-22): неперехваченная паника в любой горутине завершает весь
процесс.
Продолжать, не исключив упавший элемент, нельзя: детерминированная паника
даёт бесконечный цикл — тот же элемент, тот же стек, залитый лог и нулевой
прогресс. Это классический poison message, и лекарство берём то же, что
принято в очередях: элемент выводится из оборота, а не берётся снова. У
цикла, который и так подтверждает прогресс — сдвигает офсет, помечает
строку состоянием, — механизм для этого уже есть, заводить отдельный не
нужно.
Оговорка «если ответ ещё не начат» в GERR-26.1 не формальность: статус
отправляется один раз, и после первой записи в тело поменять его нечем —
клиент получит обрывок с кодом 200. Отсюда же общее предпочтение собирать
ответ целиком до записи там, где это возможно.
Из GERR-26.1 есть одно исключение: `http.ErrAbortHandler` — сигнал «прервать
обработку намеренно», и recover-обёртка пробрасывает его дальше, а не
превращает в 500. Так поступают и стандартные обёртки вроде chi.
### GERR-24. Независимые ошибки собираются `errors.Join`
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через `errors.Is`.
@@ -311,12 +381,13 @@ HTTP-клиентов, файловой системы, внешних SDK.
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: `errors.Is` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
(R11).
(GERR-11).
## Связано
- `lang/go/logging.md` — где и когда ошибка попадает в лог.
- `arch/db-identifiers.md` R7 — формат корреляционного ключа из R14.
- `KEYS-7` (`arch/db-identifiers.md`) — формат корреляционного ключа
из `GERR-14`.
<!-- local:механизировано -->
<!-- /local -->
@@ -1,4 +1,5 @@
---
prefix: SLOG
extends: arch/time.md
---
@@ -6,7 +7,7 @@ extends: arch/time.md
Как и когда писать логи. Это правила оформления кода (How), а не
спецификация поведения: наблюдаемые требования к логам, входящие в контракт
функциональности, живут в спеках. Форма записи — `common/language.md`.
функциональности, живут в спеках. Форма записи — `LANGUAGE.md`.
Лог читают инструментами, а не глазами: повседневно — `jq`
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
@@ -19,7 +20,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
## Формат записи
### R1. Структурированный JSON, один формат для dev и prod
### SLOG-1. Структурированный JSON, один формат для dev и prod
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
проде.
@@ -31,7 +32,7 @@ dev-выводом перестаёшь ежедневно гонять собс
значение) обнаруживаются только в проде, где заметить их заранее уже
некому.
### R2. Данные — в типизированных полях, а не в тексте сообщения
### SLOG-2. Данные — в типизированных полях, а не в тексте сообщения
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
@@ -40,7 +41,7 @@ dev-выводом перестаёшь ежедневно гонять собс
правке формулировки. Тип важен отдельно от ключа: число внутри строки не
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
### R3. Время записи — UTC
### SLOG-3. Время записи — UTC
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
(см. `lang/go/time.md`).
@@ -58,7 +59,7 @@ dev-выводом перестаёшь ежедневно гонять собс
## Сообщение
### R4. `msg` — константа в нижнем регистре
### SLOG-4. `msg` — константа в нижнем регистре
**ДОЛЖЕН.** Текст сообщения не собирается из переменных:
`log.Info("download accepted", "download_id", id)`.
@@ -69,7 +70,7 @@ dev-выводом перестаёшь ежедневно гонять собс
одна категория не двоилась на варианты, различающиеся только заглавной
буквой.
### R5. `msg` не несёт префикса подсистемы
### SLOG-5. `msg` не несёт префикса подсистемы
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
отдельное поле.
@@ -80,7 +81,7 @@ dev-выводом перестаёшь ежедневно гонять собс
категория дробится на варианты с префиксом и без, а совпадать они обязаны
посимвольно.
### R6. Смена состояния сущности — единая категория
### SLOG-6. Смена состояния сущности — единая категория
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
состояние и по какой причине — данные, а не текст.
@@ -91,37 +92,37 @@ dev-выводом перестаёшь ежедневно гонять собс
останется неполной. Единая категория даёт весь цикл одним фильтром и не
требует обновлять запрос вслед за кодом.
### R7. Физический эффект — отдельная запись, а не вместо перехода
### SLOG-7. Физический эффект — отдельная запись, а не вместо перехода
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
запись самого перехода.
**Почему.** Иначе из выборки по R6 выпадают именно те переходы, у которых
**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
строки в логе; восстановление пропущенного перехода не стоит ничего, потому
что невозможно.
## Уровни
### R8. Уровень выбирается по адресату
### SLOG-8. Уровень выбирается по адресату
**ДОЛЖЕН.** Уровень отвечает на вопрос «кому сообщение», а не «насколько
громко сломалось».
| № | Уровень | Кому и когда |
|---|---|---|
| R8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| R8.2 | `INFO` | владельцу, аудит постфактум |
| R8.3 | `WARN` | владельцу, «может стать проблемой» |
| R8.4 | `ERROR` | владельцу, в разбор |
| SLOG-8.1 | `DEBUG` | разработчику при отладке; в проде выключен |
| SLOG-8.2 | `INFO` | владельцу, аудит постфактум |
| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
| SLOG-8.4 | `ERROR` | владельцу, в разбор |
**Почему.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
базовый порог в проде (R40), потому что он отсекает уже не то, что
базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
задумано.
### R9. Уровень не зависит от подсистемы
### SLOG-9. Уровень не зависит от подсистемы
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
везде одинаково серьёзен.
@@ -132,7 +133,7 @@ dev-выводом перестаёшь ежедневно гонять собс
уровень перестаёт быть фильтром и становится подсказкой, требующей знания
кода.
### R10. `WARN` — только когда «может стать проблемой»
### SLOG-10. `WARN` — только когда «может стать проблемой»
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
@@ -141,22 +142,22 @@ dev-выводом перестаёшь ежедневно гонять собс
единственное, ради чего уровень существует: предупреждение, на которое ещё
есть время отреагировать.
### R11. Событийное — `INFO`, рутинно-частое — `DEBUG`
### SLOG-11. Событийное — `INFO`, рутинно-частое — `DEBUG`
**ДОЛЖЕН.** Уровень зависит от того, стоит ли за операцией событие.
| № | Операция | Уровень |
|---|---|---|
| R11.1 | по реальному действию или изменению | `INFO` |
| R11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
| SLOG-11.1 | по реальному действию или изменению | `INFO` |
| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
**Почему.** `INFO` — аудит постфактум (R8.2), и его пригодность
**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
определяется долей записей, за которыми что-то стоит. Периодическая
операция даёт ровный поток при нулевой информации, в котором настоящие
события тонут количественно: их не отфильтровать, потому что фильтровать
приходится по содержанию, а не по уровню.
### R12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
### SLOG-12. Фатальный сбой на старте — `ERROR` и ненулевой код возврата
**ДОЛЖЕН.** `slog` не разделяет CRITICAL/FATAL, поэтому недостающую
степень даёт завершение процесса.
@@ -169,7 +170,7 @@ dev-выводом перестаёшь ежедневно гонять собс
## Поля: единый словарь
### R13. Одно поле — одно имя по всему коду
### SLOG-13. Одно поле — одно имя по всему коду
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
@@ -178,14 +179,14 @@ dev-выводом перестаёшь ежедневно гонять собс
часть записей в него не попадёт, и заметить это можно, только заранее зная,
что они должны были быть.
### R14. Форма имени зависит от вида поля
### SLOG-14. Форма имени зависит от вида поля
**ДОЛЖЕН.** Две формы, третьей нет.
| № | Вид поля | Форма имени |
|---|---|---|
| R14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| R14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
в любом проекте, от доменных, которые в каждом свои: по общему префиксу
@@ -193,7 +194,7 @@ dev-выводом перестаёшь ежедневно гонять собс
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
названо, и спорить о них на каждом ревью.
### R15. Запись плоская
### SLOG-15. Запись плоская
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
имени, а не уровень вложенности.
@@ -203,30 +204,32 @@ dev-выводом перестаёшь ежедневно гонять собс
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
весь лог, распадаясь на запрос под каждую форму записи.
### R16. Набор полей определяется ситуацией
### SLOG-16. Набор полей определяется ситуацией
**ДОЛЖЕН.** Записи каждой ситуации несут её набор целиком.
| № | Когда добавляем | Поля |
|---|---|---|
| R16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport`если транспортов больше одного |
| R16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
| R16.3 | запись об ошибке | `error` |
| R16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
| SLOG-16.1 | входящий HTTP-запрос (middleware) | `http.method`, `http.route`, `http.status_code`, `duration_ms`, `transport`пока его значение различается между записями (SLOG-17) |
| SLOG-16.2 | работа с сущностью (scoped-логгер) | `<entity>_id` и доменные атрибуты |
| SLOG-16.3 | запись об ошибке | `error` |
| SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
баг», запись о сущности без идентификатора не корреллируется (R19). Полный
баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
набор делает записи однородными — один запрос работает по всем вызовам, а
не по тем, где автор вспомнил про поле.
### R17. `service.*` и `host.*` не заводим
### SLOG-17. `service.*` и `host.*` не заводим
**НЕ СЛЕДУЕТ.** Пока это один бинарь на одном хосте.
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
**Почему.** Поле с одним и тем же значением во всех записях не несёт
информации, но стоит места в каждой строке и внимания при чтении. Условие
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке
и внимания при чтении. Критерий один на все поля словаря — им же решается,
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
названо явно, поэтому правило отпадёт вместе со своей причиной: с
появлением нескольких инстансов различающее поле (`service.version`)
добавляется одной строкой при старте.
@@ -236,7 +239,7 @@ dev-выводом перестаёшь ежедневно гонять собс
## Корреляция
### R18. Ключ корреляции — идентификатор сущности, а не `trace_id`
### SLOG-18. Ключ корреляции — идентификатор сущности, а не `trace_id`
**НЕ СЛЕДУЕТ.** Отдельный случайный `trace_id` не заводится, если у
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
@@ -249,13 +252,13 @@ dev-выводом перестаёшь ежедневно гонять собс
способ спросить об одном. Условие применимости названо: там, где сущности
со стабильным идентификатором нет, связывать записи больше нечем.
### R19. Запись о сущности несёт её идентификатор
### SLOG-19. Запись о сущности несёт её идентификатор
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
**Почему.** Принадлежность записи восстанавливается только в момент
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
Это же условие, при котором работает R18: отказ от `trace_id` оплачен тем,
Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
что идентификатор стоит везде, а не в удобных местах.
Все записи одной операции собираются одним фильтром:
@@ -263,7 +266,7 @@ dev-выводом перестаёшь ежедневно гонять собс
глобально уникален across сущностей, штатно работает и простой `grep` по
голому значению — он находит все упоминания независимо от имени поля.
### R20. Долгая операция ведётся scoped-логгером через `context.Context`
### SLOG-20. Долгая операция ведётся scoped-логгером через `context.Context`
**СЛЕДУЕТ.** Логгер с дописанным ключом протаскивается сквозь асинхронные
стадии:
@@ -280,17 +283,17 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
## Ошибки
### R21. Ошибка логируется атрибутом `error`
### SLOG-21. Ошибка логируется атрибутом `error`
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (R4) и
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
зависеть от того, кто писал конкретный вызов, и ради этого единообразия
краткостью жертвуют.
### R22. Промежуточный слой либо логирует, либо возвращает
### SLOG-22. Промежуточный слой либо логирует, либо возвращает
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
оборачивает (`%w`).
@@ -298,50 +301,59 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
и количество `ERROR` перестаёт соответствовать количеству отказов — а
считают именно его. Контекст при этом не теряется: он накапливается в
цепочке обёрток и попадает в единственную запись на границе (R23).
цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
### R23. Ошибка логируется один раз — на границе доменного слоя
### SLOG-23. Ошибка логируется один раз — на границе доменного слоя
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
этим местом выбрана доменная граница, а не транспорт, потому что там
известен исход операции целиком и, значит, класс отказа (R25) — транспорт
известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
транспорты остаются тонкими.
<!-- local:границы -->
<!-- /local -->
### R24. Транспорт не логирует ошибку повторно
### SLOG-24. Транспорт не логирует ошибку повторно
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и на этом останавливается.
**Почему.** Запись уже сделана на границе (R23); вторая отличается от неё
**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
только формулировкой и читается как второй сбой. Когда транспортов над
одним доменом несколько, дублирование ещё и множится, а расследование
начинается с вопроса, один это инцидент или два.
### R25. Уровень доменного отказа — по классу отказа
### SLOG-25. Уровень доменного отказа — по классу отказа
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (R23), и выбирает по
классу, а не по месту в коде.
**ДОЛЖЕН.** Уровень выбирает единственный логирующий (SLOG-23), и выбирает по
классу, а не по месту в коде. Классификация покрывает **доменные** отказы —
те, что операция вернула значением `error`.
| № | Класс отказа | Кому | Уровень |
|---|---|---|---|
| R25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| R25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| R25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
| SLOG-25.1 | штатный конфликт состояния или некорректный ввод | пользователю, он уже получил ответ | `DEBUG` |
| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
**Почему.** Это применение R8 к отказам: пользователь уже увидел причину на
**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
экране — владельцу разбирать нечего; целостность первичных данных отделяет
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
уровень для одного и того же отказа в зависимости от того, какой транспорт
его вызвал, — и невалидный ввод из формы копился бы в `ERROR` наравне с
упавшей базой.
### R26. Тот же отказ в асинхронной стадии — уровнем выше
Нарушение инварианта в собственном коде — паника, недостижимая ветка — в
таблицу не входит: это не доменный отказ, и логирует его recover-граница
вместе со стеком (`lang/go/errors.md`). Искать его класс здесь не нужно.
Мимо таблицы идёт и доменная ошибка, которой нет в маппинге: класса у неё
нет, потому что её просто забыли завести. Она логируется `ERROR` с
признаком непокрытой (`GERR-25`).
### SLOG-26. Тот же отказ в асинхронной стадии — уровнем выше
**ДОЛЖЕН.** Когда пользователь не ждёт результата, отказ адресован
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
@@ -352,7 +364,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
никто, задача осталась недоведённой, и лог — единственное место, где это
вообще проявится.
### R27. Повторяющийся сбой фонового цикла — `WARN`
### SLOG-27. Повторяющийся сбой фонового цикла — `WARN`
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
уровень задаёт наличие штатного повтора, а не текст ошибки.
@@ -365,32 +377,32 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
## Внешние сервисы
### R28. Каждый вызов внешнего сервиса логируется
### SLOG-28. Каждый вызов внешнего сервиса логируется
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по R16.4.
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость
легла»: на своей стороне видно лишь то, что операция не удалась.
Выборочное логирование ломает и второе применение — доля неуспехов и
распределение `duration_ms` считаются, только если знаменатель полный.
### R29. Уровень `ext`-записи — по исходу вызова
### SLOG-29. Уровень `ext`-записи — по исходу вызова
**ДОЛЖЕН.** Исход считается по одному вызову с его ретраями.
| № | Исход | Уровень |
|---|---|---|
| R29.1 | успешный событийный вызов | `INFO` |
| R29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
| R29.3 | попытка не удалась, делается retry | `WARN` |
| R29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
| SLOG-29.1 | успешный событийный вызов | `INFO` |
| SLOG-29.2 | успешный рутинно-частый вызов (поллинг, авто-рефреш) | `DEBUG` |
| SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
уровень непригодным для главного вопроса «зависимость доступна?».
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
разбираться владельцу. Различение R29.1 и R29.2 — то же самое разделение
событийного и рутинного, что в R11: поллинг внешнего сервиса зашумляет
разбираться владельцу. Различение SLOG-29.1 и SLOG-29.2 — то же самое разделение
событийного и рутинного, что в SLOG-11: поллинг внешнего сервиса зашумляет
аудит так же, как любой другой.
## Два цикла повтора — не путать
@@ -401,8 +413,10 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
уровень доменной записи об исходе тика.
```
WHEN зависимость недоступна и ретраи вызова исчерпаны → ext-запись `ERROR` (R29.4)
AND тик фонового цикла упал по той же причине → доменная запись `WARN` (R27)
WHEN зависимость недоступна и ретраи вызова исчерпаны
→ ext-запись `ERROR` (SLOG-29.4)
AND тик фонового цикла упал по той же причине
→ доменная запись `WARN` (SLOG-27)
```
Из этого следует, что у лежащей зависимости `ext`-запись пишет `ERROR`
@@ -411,7 +425,7 @@ AND тик фонового цикла упал по той же причине
`ERROR` от поллинга мешает — это лечится понижением частоты тика или
подавлением повторов в самом клиенте, а не переклассификацией уровня.
### R30. Ответ 4xx — успех на транспортном уровне
### SLOG-30. Ответ 4xx — успех на транспортном уровне
**ДОЛЖЕН.** Завершённый HTTP-ответ с 4xx логируется как успешный вызов
(`ext.status_code` записан); решение «это ошибка» принимает доменный
@@ -426,38 +440,38 @@ AND тик фонового цикла упал по той же причине
## HTTP и healthcheck
### R31. Входящий запрос — `INFO` независимо от кода ответа
### SLOG-31. Входящий запрос — `INFO` независимо от кода ответа
**ДОЛЖЕН.** Поля по R16.1; 4xx остаётся `INFO`-записью доступа.
**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и
когда приходил», и ценность у неё одинаковая при любом коде ответа.
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
(R25) — она и адресована по-другому.
(SLOG-25) — она и адресована по-другому.
### R32. Для корреляции запроса допустим `request_id`
### SLOG-32. Для корреляции запроса допустим `request_id`
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
правило R18. Не запрещает: R18 отказывается от случайного ключа там, где
правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
сущности нет — связать его записи между собой больше нечем.
### R33. Healthcheck, liveness, readiness — `DEBUG`
### SLOG-33. Healthcheck, liveness, readiness — `DEBUG`
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
**Почему.** Частный случай R11.2, названный отдельно, потому что нарушают
**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
аудита всё остальное — в проде с базовым `INFO` (R40) лог превратился бы в
аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
доступной при отладке.
## Безопасность: что не логируем
### R34. Секреты не логируются
### SLOG-34. Секреты не логируются
**НЕ ДОЛЖЕН.** Ни в полях, ни в сообщениях: пароли и cookie сессий,
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
@@ -468,17 +482,17 @@ API-ключи и токены, `Authorization`-заголовки, аутент
с момента записи, а не с момента, когда это заметили, и вычистить его задним
числом из уже собранных копий нельзя.
### R35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
### SLOG-35. Недоверенные и большие тела — только на `DEBUG`, после вычистки и обрезки
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
`DEBUG`, с вычисткой секретов и обрезкой по длине.
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
выключен в проде (R40), поэтому цена ошибки ограничена отладочной сессией;
выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
обрезка не даёт одной записи вытеснить весь остальной лог за период.
### R36. При сомнении логируется факт, а не значение
### SLOG-36. При сомнении логируется факт, а не значение
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
@@ -488,7 +502,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
никто не придёт.
### R37. `*url.Error` санитизируется на границе клиента
### SLOG-37. `*url.Error` санитизируется на границе клиента
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
обёртки — раньше трансляции в доменную (`lang/go/errors.md`).
@@ -502,12 +516,12 @@ API-ключи и токены, `Authorization`-заголовки, аутент
причину сохраняется); альтернатива с редактированием URL сохранила бы
структуру, но сложнее.
### R38. Секрет не кладётся в URL, если у API есть заголовок
### SLOG-38. Секрет не кладётся в URL, если у API есть заголовок
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
способа нет.
**Почему.** Секрет в URL попадает не только в ошибку транспорта (R37), но и
**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
в любую запись, куда URL попал целиком, — то есть обязывает помнить про
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
@@ -517,7 +531,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
## Куда пишем
### R39. Логи идут в `stdout` одним потоком
### SLOG-39. Логи идут в `stdout` одним потоком
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
не маршрутизируем.
@@ -528,23 +542,23 @@ API-ключи и токены, `Authorization`-заголовки, аутент
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
теряет его ровно там, где важен ход событий.
### R40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
### SLOG-40. Базовый уровень — `INFO` в проде и `DEBUG` в dev
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
**Почему.** Уровень — единственный регулятор объёма, доступный без
пересборки; если `DEBUG` в проде включается только правкой кода, его не
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
что на нём аудит полон (R8.2), а рутинно-частое уже отсечено (R11.2).
что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
## Связано
- `arch/time.md` — точность и зона меток времени фиксируются на носитель.
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (R3).
- `lang/go/time.md` — как ставится UTC в `ReplaceAttr` (SLOG-3).
- `lang/go/errors.md` — трансляция ошибки в доменную, порядок относительно
санитизации (R37).
санитизации (SLOG-37).
- `arch/db-identifiers.md` — откуда берутся стабильные идентификаторы,
на которых держится корреляция (R18).
на которых держится корреляция (SLOG-18).
<!-- local:механизировано -->
<!-- /local -->
+50 -31
View File
@@ -1,4 +1,5 @@
---
prefix: GTIM
extends: arch/time.md
---
@@ -6,11 +7,11 @@ extends: arch/time.md
Как требования `arch/time.md` выполняются в Go-коде: откуда берётся
«сейчас», в каком виде время попадает в базу и в логи, что делать с зонами.
Форма записи — `common/language.md`.
Форма записи — `LANGUAGE.md`.
## Правила
### R1. «Сейчас» берётся у слоя хранилища
### GTIM-1. «Сейчас» берётся у слоя хранилища
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.Now()` по коду.
@@ -24,28 +25,28 @@ extends: arch/time.md
придётся превратить в переменную или поле, если однажды понадобится
подменять часы, но само по себе оно подмены не даёт.
### R2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
### GTIM-2. Форматирование и разбор — через `store.FormatTime` / `store.ParseTime`
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (R4) и
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений,
записанных разными местами.
### R3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
### GTIM-3. Прямой `time.Now()` запрещён линтером, список исключений исчерпывающий
**ДОЛЖЕН.** Запрет механизируется `forbidigo`; исключений ровно два, и оба
прописаны явно:
**ДОЛЖЕН.** Запрет проверяется линтером (в Go — `forbidigo`); исключений
ровно два, и оба прописаны явно:
| № | Исключение | Почему оно не покрывается R1 |
| № | Исключение | Почему оно не покрывается GTIM-1 |
|---|---|---|
| R3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| R3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (R10) |
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
**Почему.** R1 без механической проверки держится на внимании, а
**Почему.** GTIM-1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по
@@ -53,7 +54,23 @@ layout — а расхождение проявится не на записи,
«починены» тем, кто увидит в них дефект, и конвенция начнёт противоречить
сама себе.
### R4. В БД время хранится с секундной точностью, ширина 20 символов
### GTIM-13. Исключение регистрируется директивой на месте вызова, а не в конфиге линтера
**ДОЛЖЕН.** Исключение из GTIM-3 оформляется как
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
конфигурации линтера для него не заводятся.
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует
исключение путём к файлу, отвязывается при переносе кода и продолжает
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
та исчерпываемость, которой требует GTIM-3, проверяется одной командой. Голый
`//nolint` без имени правила глушит на строке все проверки сразу, а без
причины неотличим от заглушенного дефекта; обе деградации штатно ловит
`nolintlint` (`require-specific`, `require-explanation`) — стандартный
способ дисциплинировать директивы в golangci-lint.
### GTIM-4. В БД время хранится с секундной точностью, ширина 20 символов
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
@@ -67,16 +84,16 @@ layout — а расхождение проявится не на записи,
Ширина достаётся даром: layout `time.RFC3339` не содержит долей секунды,
поэтому `Format` их не выведет.
### R5. `time.RFC3339Nano` не используется
### GTIM-5. `time.RFC3339Nano` не используется
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором
держится R4, исчезает незаметно. Проверка «формат корректен» при этом
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок.
### R6. Чужой вход нормализуется явно
### GTIM-6. Чужой вход нормализуется явно
**ДОЛЖЕН.** Значение времени, пришедшее не от нашего писателя, приводится
к каноническому виду явно, а не считается каноническим по факту успешного
@@ -85,19 +102,21 @@ layout — а расхождение проявится не на записи,
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает R4 и
обнаруживается не на записи, а на первой сортировке.
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
обнаруживается не на записи, а на первой сортировке. Само решение
«нормализовать, а не отклонять» — базовое (`TIME-13`); здесь —
Go-механика, из-за которой его легко нарушить незаметно.
### R7. В драйвер передаётся строка, а не `time.Time`
### GTIM-7. В драйвер передаётся строка, а не `time.Time`
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (R2), с
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией.
### R8. Время в логах приводится к UTC через `ReplaceAttr`
### GTIM-8. Время в логах приводится к UTC через `ReplaceAttr`
**ДОЛЖЕН.** Хендлер `slog` переопределяет атрибут `slog.TimeKey`:
@@ -116,17 +135,17 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
неверная зона выглядит как совершенно валидное время, а записи из разных
мест перестают складываться в одну хронологию с метками хранилища.
### R9. Точность времени в логах отличается от точности в БД
### GTIM-9. Точность времени в логах отличается от точности в БД
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности R4.
приводится к секундной точности GTIM-4.
**Почему.** Явное разрешение нужно, чтобы R4 не читался как требование
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого R4 существует, не
нарушено. Общее у лога и базы одно — зона (R8).
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
нарушено. Общее у лога и базы одно — зона (GTIM-8).
### R10. Обёртка измерения длительности берёт `time.Now()` напрямую
### GTIM-10. Обёртка измерения длительности берёт `time.Now()` напрямую
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`.
@@ -135,10 +154,10 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
меткам, зависит от подводки часов: перевод назад даёт отрицательную
длительность, скачок вперёд — выброс в измерениях, и оба случая
невоспроизводимы. Разрешение записано явно, иначе исключение R3.2 читается
невоспроизводимы. Разрешение записано явно, иначе исключение GTIM-3.2 читается
как недосмотр и его «чинят».
### R11. `time/tzdata` импортируется в `main`
### GTIM-11. `time/tzdata` импортируется в `main`
**ДОЛЖЕН.** База зон вшивается в бинарь.
@@ -148,7 +167,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
`main` держит это решение в одном видимом месте, а не в случайном пакете,
откуда его удаляют при чистке зависимостей.
### R12. Зона отображения применяется только в UI
### GTIM-12. Зона отображения применяется только в UI
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях.
@@ -166,6 +185,6 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
## Связано
- `arch/time.md` — базовая конвенция: UTC как формат хранения, явная зона в
календарных вычислениях.
- `arch/time.md` — базовая конвенция: UTC как формат хранения, нормализация
чужого входа, явная зона в календарных вычислениях.
- `lang/go/config.md` — валидация зоны отображения загрузчиком конфига.
@@ -1,11 +1,12 @@
---
prefix: ANSD
extends: arch/app-directories.md
---
# Категории директорий: реализация в Ansible
Как категории из `arch/app-directories.md` раскладываются на сервере
плейбуком. Форма записи — `common/language.md`.
плейбуком. Форма записи — `LANGUAGE.md`.
## Область действия
@@ -15,7 +16,7 @@ extends: arch/app-directories.md
## Правила
### R1. Каждая директория объявлена переменной `*_dir`
### ANSD-1. Каждая директория объявлена переменной `*_dir`
**ДОЛЖЕН.** Директория приложения объявляется переменной плейбука внутри
`base_dir`, имя оканчивается на `_dir`. Для случая «одна директория на
@@ -24,11 +25,11 @@ extends: arch/app-directories.md
`uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (R4). Литерал пути в одном из этих мест
создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении.
### R2. Директории создаются одной задачей циклом по списку
### ANSD-2. Директории создаются одной задачей циклом по списку
**СЛЕДУЕТ.** Список директорий в единственной задаче создания.
@@ -38,7 +39,7 @@ extends: arch/app-directories.md
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
разборе места на диске.
### R3. Владелец директорий — пользователь, от имени которого работает приложение
### ANSD-3. Владелец директорий — пользователь, от имени которого работает приложение
**ДОЛЖЕН.** Конкретная модель — выделенный пользователь на приложение
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
@@ -55,18 +56,18 @@ extends: arch/app-directories.md
<!-- local:модель-владельца -->
<!-- /local -->
### R4. Список бэкапа собирается из тех же переменных
### ANSD-4. Список бэкапа собирается из тех же переменных
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из R1, а не на литеральные пути.
которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
**Почему.** Правило вывода списка механическое (R5), но применяет его
**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в
единственный момент, когда это уже неисправимо.
### R5. В список бэкапа идут только данные
### ANSD-5. В список бэкапа идут только данные
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет.
@@ -75,7 +76,7 @@ extends: arch/app-directories.md
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот.
### R6. Конфигурация монтируется только на чтение
### ANSD-6. Конфигурация монтируется только на чтение
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
@@ -85,7 +86,7 @@ extends: arch/app-directories.md
незаметно. Приложение, которому запись в конфиг нужна по устройству,
монтируется на запись — это отступление, и оно записывается.
### R7. `docker-compose.yml` лежит в корне `base_dir`
### ANSD-7. `docker-compose.yml` лежит в корне `base_dir`
**ДОЛЖЕН.** Файл не переносится во вложенную директорию.
@@ -94,7 +95,7 @@ extends: arch/app-directories.md
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место.
### R8. Секреты рендерятся в файл конфигурации
### ANSD-8. Секреты рендерятся в файл конфигурации
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения.
@@ -104,14 +105,14 @@ extends: arch/app-directories.md
довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения.
### R9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
### ANSD-9. Когда приложение не умеет файловые секреты — `environment` под `no_log`
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы R8 не читался как запрет на
**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на R8 при ближайшем касании.
секреты из файла, переводится на ANSD-8 при ближайшем касании.
<!-- local:отступления -->
<!-- /local -->
@@ -1,9 +1,13 @@
---
prefix: HTMX
---
# Веб-UI на htmx
Как пишется код веб-UI: частичный своп фрагментов, поллинг живых
обновлений, обработчики действий, деградация без JS, ошибки. Что именно UI
показывает и какие действия поддерживает — в спеках, не здесь. Форма записи
`common/language.md`.
`LANGUAGE.md`.
Логирование запросов — `lang/go/logging.md` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — `lang/go/errors.md`
@@ -18,7 +22,7 @@
## Стек и границы
### R1. Стек: роутер, серверные шаблоны, htmx
### HTMX-1. Стек: роутер, серверные шаблоны, htmx
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка.
@@ -26,12 +30,12 @@
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (R2), и дальше на каждом экране
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
приходится решать, какая из них главная. Сам htmx — вендорный ассет и
живёт по правилам вендоринга (R32, R33): внешний CDN добавил бы к аптайму
приложения аптайм чужого хоста.
живёт по правилам вендоринга (HTMX-32, HTMX-33): внешний CDN добавил бы к
аптайму приложения аптайм чужого хоста.
### R2. Клиент не пересчитывает доменное состояние
### HTMX-2. Клиент не пересчитывает доменное состояние
**НЕ ДОЛЖЕН.** Свой JS делает только то, чего серверу знать не нужно
(копирование в буфер обмена и подобное); доменное состояние считает сервер,
@@ -40,24 +44,24 @@
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (R11, R12) — значит, серверную версию того же
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
вычисления всё равно придётся держать.
### R3. Реактивный слой вводится отдельным решением
### HTMX-3. Реактивный слой вводится отдельным решением
**НЕ ДОЛЖЕН.** Alpine.js и подобное не появляется попутно с задачей —
только когда есть виджет, которому он действительно нужен, и отдельным
решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница R1/R2
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на
каждом экране есть выбор между двумя моделями состояния.
## Единый источник разметки
### R4. Партиал = страница = фрагмент
### HTMX-4. Партиал = страница = фрагмент
**ДОЛЖЕН.** Переиспользуемый кусок разметки — именованный шаблон в
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
@@ -68,7 +72,7 @@
же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд.
### R5. Корень партиала — элемент с целевым `id`
### HTMX-5. Корень партиала — элемент с целевым `id`
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`.
@@ -79,19 +83,19 @@
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
логе.
### R6. Сборку view делает общая функция
### HTMX-6. Сборку view делает общая функция
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка.
**Почему.** Общий шаблон (R4) гарантирует одинаковую разметку, но не
**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который R4 закрывает для разметки.
класс расхождений, который HTMX-4 закрывает для разметки.
## Обработчик действия
### R7. Доменный вызов одинаков для htmx и обычного запроса
### HTMX-7. Доменный вызов одинаков для htmx и обычного запроса
**ДОЛЖЕН.** Обработчик определяет htmx-запрос по заголовку
`HX-Request: true`, зовёт доменную операцию до ветвления и ветвится только
@@ -99,8 +103,8 @@ htmx-ветка.
| № | Запрос | Ответ |
|---|---|---|
| R7.1 | `HX-Request: true` | фрагмент тем же партиалом (R4) по перечитанному состоянию |
| R7.2 | обычный | PRG-редирект (303) |
| HTMX-7.1 | `HX-Request: true` | фрагмент тем же партиалом (HTMX-4) по перечитанному состоянию |
| HTMX-7.2 | обычный | PRG-редирект (303) |
```go
actionErr := fn(r.Context(), id) // доменный вызов идентичен для htmx и не-htmx
@@ -119,12 +123,12 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**Почему.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (R11) открывают реже, то есть чинить будут не тот.
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
целиком: view, собранный из аргументов запроса, покажет намерение, а не
результат.
### R8. Шаблон рендерится в буфер, потом в ответ
### HTMX-8. Шаблон рендерится в буфер, потом в ответ
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ.
@@ -136,30 +140,30 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Одно действие — два региона
### R9. Второй регион едет тем же ответом через `hx-swap-oob`
### HTMX-9. Второй регион едет тем же ответом через `hx-swap-oob`
**СЛЕДУЕТ.** Когда действие меняет не только свой регион, второй фрагмент
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (R4, R5).
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие.
### R10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
### HTMX-10. Отдельный запрос за вторым регионом — когда он обновляется реже действия
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы R9 не читался как запрет любого
**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление.
## Graceful degradation
### R11. Форма действия работает без JS
### HTMX-11. Форма действия работает без JS
**ДОЛЖЕН.** Действие — обычная `<form method="post" action="…">`, на которую
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
@@ -170,24 +174,24 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS.
### R12. Фильтр, поиск и пагинация — серверные
### HTMX-12. Фильтр, поиск и пагинация — серверные
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (R25) и
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе.
### R13. Область обязательной деградации
### HTMX-13. Область обязательной деградации
**ДОЛЖЕН.** Требование работать без JS распространяется не на весь UI:
| № | Поверхность | Поведение без JS |
|---|---|---|
| R13.1 | действия и навигация | работают полностью (R11, R12) |
| R13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
@@ -197,33 +201,52 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
## Ошибки на htmx-пути
### R14. Ошибка действия на htmx-пути — 200 с фрагментом
### HTMX-14. Ошибка действия на htmx-пути — 200 с фрагментом
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Настроить это можно
(`htmx.config.responseHandling`, расширение `response-targets`, слушатель
`htmx:responseError`), но любая настройка — свой JS-конфиг на клиенте, и
платится она из R1 и R2. Для REST API и не-JS редиректа с `?err=` статус
по-прежнему используется: там его кто-то читает.
пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
Сообщить о сбое, для которого фрагмента нет вовсе, — отдельная задача, и
её решает глобальный слушатель (HTMX-34). Для REST API и не-JS редиректа с
`?err=` статус по-прежнему используется: там его кто-то читает.
Цена решения: в логе доступа провалившееся действие выглядит как `200`.
Искать его надо по доменной записи об исходе операции (`lang/go/logging.md`),
а не по коду ответа.
### R15. Наружу идёт сообщение публичного канала
### HTMX-34. Сбой без ответа-фрагмента показывается глобальным слушателем
**ДОЛЖЕН.** Один глобальный слушатель `htmx:responseError` и
`htmx:sendError` показывает нейтральное сообщение о неудаче запроса; своп
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается.
**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель
— несколько строк без доменного состояния, то есть внутри границы HTMX-2, и он
не спорит с HTMX-14: там настройки отвергнуты как замена фрагменту, который
обработчик в состоянии отдать, а здесь фрагмента нет по определению. Своп
тела ошибки в целевой регион стоил бы дороже: страница 500 не несёт
целевого `id`, и после первого же такого свопа регион перестаёт находиться
(HTMX-5).
### HTMX-15. Наружу идёт сообщение публичного канала
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст по правилам
`lang/go/errors.md`; `err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (R14)
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
### R16. Сообщение об ошибке — в отдельном поле view
### HTMX-16. Сообщение об ошибке — в отдельном поле view
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются.
@@ -232,9 +255,9 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
требует R17.
требует HTMX-17.
### R17. При ошибке активное состояние не меняется
### HTMX-17. При ошибке активное состояние не меняется
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение.
@@ -257,7 +280,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
</div>{{end}}
```
### R18. Поллер самозавершается
### HTMX-18. Поллер самозавершается
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов.
@@ -268,10 +291,10 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
это единственный канал, которым сервер управляет поллером.
Встроенная альтернатива — ответ со статусом 286 — не используется: она не
совместима с R4, ведь свежезагруженная страница рендерится тем же партиалом
совместима с HTMX-4, ведь свежезагруженная страница рендерится тем же партиалом
и тоже без поллера.
### R19. Условие живости ведёт собственное состояние приложения
### HTMX-19. Условие живости ведёт собственное состояние приложения
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса.
@@ -281,18 +304,18 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике.
### R20. Поллер свопит фрагмент целиком через `outerHTML`
### HTMX-20. Поллер свопит фрагмент целиком через `outerHTML`
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (R18). Своп содержимого оставил бы старый узел с его таймером,
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
при совпадении корневого `id` (R5).
при совпадении корневого `id` (HTMX-5).
### R21. Поллер не свопит контейнер с активными полями ввода
### HTMX-21. Поллер не свопит контейнер с активными полями ввода
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего.
@@ -302,7 +325,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод».
### R22. Браузер не ходит во внешний сервис напрямую
### HTMX-22. Браузер не ходит во внешний сервис напрямую
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
@@ -311,14 +334,14 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением.
### R23. Источник данных для тика
### HTMX-23. Источник данных для тика
**ДОЛЖЕН.** Тик читает данные там, где они уже есть, не ходя в сеть:
| № | Что показывает тик | Откуда берёт |
|---|---|---|
| R23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| R23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
@@ -329,7 +352,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
собственного состояния той же цены нет: хранилище и так своё, а лишний слой
кеша добавил бы только рассинхрон.
### R24. Поллинг URL страницы вместо отдельного фрагмент-роута
### HTMX-24. Поллинг URL страницы вместо отдельного фрагмент-роута
**ДОПУСКАЕТСЯ.** Когда живой фрагмент — почти вся страница, `hx-get` идёт
на URL самой страницы, а нужный узел вырезается `hx-select`:
@@ -342,24 +365,24 @@ hx-select="#item-main" hx-swap="outerHTML"
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (R4).
копии разметки (HTMX-4).
Инвариант корневого `id` (R5) действует и здесь: `hx-select` выбирает тот
Инвариант корневого `id` (HTMX-5) действует и здесь: `hx-select` выбирает тот
же узел, который свопится.
## Своп и выход со страницы
### R25. Действие не уводит со страницы, если предмет остаётся на ней
### HTMX-25. Действие не уводит со страницы, если предмет остаётся на ней
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (R12). Полная навигация ради изменения одного
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого
заменяемого поддерева — фокус, выделение, введённый текст (R21).
заменяемого поддерева — фокус, выделение, введённый текст (HTMX-21).
### R26. Выход со страницы — форма без `hx-*`
### HTMX-26. Выход со страницы — форма без `hx-*`
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
@@ -370,10 +393,10 @@ htmx-атрибутов при этом само работает маркеро
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
сменить страницу, существующий только на htmx-пути.
### R27. Асинхронное действие свопит промежуточное состояние
### HTMX-27. Асинхронное действие свопит промежуточное состояние
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (R18).
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу —
@@ -382,7 +405,7 @@ htmx-атрибутов при этом само работает маркеро
## Различение поверхности одного действия
### R28. Поверхность различается скрытым полем формы
### HTMX-28. Поверхность различается скрытым полем формы
**ДОЛЖЕН.** Когда один роут зовут с разных страниц и своп-ответ различается
фрагментом, поверхность передаётся явным скрытым полем
@@ -394,12 +417,28 @@ htmx-атрибутов при этом само работает маркеро
действием, поэтому связь «эта страница → этот фрагмент» читается там, где
её заводят.
### HTMX-35. Запрос без поля поверхности получает 400
**ДОЛЖЕН.** Обработчик, различающий поверхности (HTMX-28), отвечает статусом
400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
как «интерфейс иногда застывает». 400 не свопится и всплывает сообщением
глобального слушателя (HTMX-34) — сразу и на той странице, где форму сломали.
Вкладка, открытая до появления поля, получает тот же 400 и чинится
перезагрузкой; это дешевле, чем молча неверный фрагмент в актуальной
разметке.
## Статика, вендоринг, кэш
Раздел не про htmx — это упаковка любого server-rendered приложения;
разъедется в языковой слой, когда понадобится там.
### R29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
### HTMX-29. Ассеты встроены в бинарь и отдаются иммутабельным кэшем
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`.
@@ -407,21 +446,21 @@ htmx-атрибутов при этом само работает маркеро
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (R30, R31); без этого условия год кэша был бы
способом навсегда закрепить у пользователя старый файл.
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
был бы способом навсегда закрепить у пользователя старый файл.
### R30. Меняемые ассеты версионируются хешем содержимого
### HTMX-30. Меняемые ассеты версионируются хешем содержимого
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (R29) —
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
хеш не проставляли в каждом шаблоне руками.
### R31. Вендорный ассет в `?v=` не нуждается
### HTMX-31. Вендорный ассет в `?v=` не нуждается
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии.
@@ -429,9 +468,9 @@ htmx-атрибутов при этом само работает маркеро
**Почему.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это R30.
явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
### R32. Вендор не коммитится, а добывается по манифесту
### HTMX-32. Вендор не коммитится, а добывается по манифесту
**ДОЛЖЕН.** Идемпотентная задача скачивает вендорные файлы по манифесту
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
@@ -443,7 +482,7 @@ diff'е — у закоммиченного минифицированного
единственная проверка, что скачали то же самое, что проверяли; зависимость
сборки от задачи не даёт собраться без ассета в свежем клоне.
### R33. Шрифты и скрипты — self-hosted
### HTMX-33. Шрифты и скрипты — self-hosted
**ДОЛЖЕН.** Внешних хостов во время выполнения нет.
+47
View File
@@ -0,0 +1,47 @@
# Реестр префиксов правил.
#
# Префикс — четыре заглавные латинские буквы, уникальные по всему канону.
# Он выбирается под файл, а не выводится по формуле: префикс нужен, чтобы
# по нему искать, а не чтобы его разбирать. Поэтому подходящее слово лучше
# закономерности.
#
# Правила реестра:
#
# - префикс не переименовывается и не переиспользуется никогда — ссылка
# из чужого репозитория обязана продолжать указывать на то же место;
# - при удалении или разделении файла префикс уходит в [retired], а не
# освобождается;
# - переезд файла между осями префикс не меняет: идентификатор правила
# не зависит от таксономии;
# - вынос части правил в новый файл — это новый префикс и новая
# нумерация: перенос правила между документами есть смысловое
# изменение, а не переименование;
# - тот же префикс продублирован в шапке файла (`prefix:`), conv сверяет.
#
# Пути даются от корня репозитория, а не от `conventions/`: реестр покрывает
# и обвязку тоже.
#
# Локальные правила репозиториев берут свои префиксы и объявляют их в
# `.conventions.toml` копии. Они обязаны не пересекаться с этим реестром.
[live]
DIRS = "conventions/arch/app-directories.md"
CONF = "conventions/arch/config.md"
KEYS = "conventions/arch/db-identifiers.md"
TIME = "conventions/arch/time.md"
GCFG = "conventions/lang/go/config.md"
GKEY = "conventions/lang/go/db-identifiers.md"
MIGR = "conventions/lang/go/db-schema.md"
GERR = "conventions/lang/go/errors.md"
SLOG = "conventions/lang/go/logging.md"
GTIM = "conventions/lang/go/time.md"
ANSD = "conventions/stack/ansible/app-directories.md"
HTMX = "conventions/stack/htmx/web-ui.md"
# Обвязка канона: не синхронизируется в репозитории, но правила
# записаны тем же языком и цитируются по номерам, поэтому префикс нужен.
META = "GUIDE.md"
[retired]
# Пусто. Сюда попадают префиксы удалённых и разделённых файлов вместе с
# причиной и датой, чтобы их нельзя было выдать повторно.