ПОЧЕМУ стало ключевым словом, язык поднят до версии 2

- метка обоснования пишется заглавными и вошла в словарь набора: скелет
  правила теперь целиком из ключевых слов, а не смесь `**ДОЛЖЕН.**` и
  `**Почему.**`; в переводе на другой язык метка меняется как остальные слова
  (ПОЧЕМУ / WHY), 235 вхождений заменены
- метки правила выделены из шкалы в отдельный перечень: ПОЧЕМУ и
  МЕХАНИЗИРОВАНО обязательности не задают, а размечают части, и стандартом не
  даются ни в одном языке — раньше МЕХАНИЗИРОВАНО висело строкой в таблице
  модальности
- версия языка поднята до 2, потому что изменение формы меняет чтение уже
  написанного текста; строка о версии в двенадцати конвенциях перечисляет
  теперь и метки, а служебные слова сценария в неё по-прежнему не входят
This commit is contained in:
av
2026-07-26 14:28:37 +03:00
parent c8071dc438
commit 72d77d74bf
16 changed files with 346 additions and 318 deletions
+11 -8
View File
@@ -18,7 +18,7 @@ code in this repository.
## Форма правила ## Форма правила
- Четыре обязательные части: `### <ПРЕФИКС>-<N>. Заголовок`, абзац - Четыре обязательные части: `### <ПРЕФИКС>-<N>. Заголовок`, абзац
`**МОДАЛЬНОСТЬ.** норма`, абзац `**Почему.** …`. Правило без «Почему» не `**МОДАЛЬНОСТЬ.** норма`, абзац `**ПОЧЕМУ.** …`. Правило без обоснования не
принимается. принимается.
- Норма — одна фраза; если в неё не влезает, это два правила. - Норма — одна фраза; если в неё не влезает, это два правила.
- Шкала инвариантна, словарь — параметр языка набора. Канон русский, значит - Шкала инвариантна, словарь — параметр языка набора. Канон русский, значит
@@ -34,14 +34,17 @@ code in this repository.
обсуждается. обсуждается.
- **МЕХАНИЗИРОВАНО** — не модальность, а способ проверки: отметка стоит - **МЕХАНИЗИРОВАНО** — не модальность, а способ проверки: отметка стоит
рядом с модальным словом (`**ДОЛЖЕН. МЕХАНИЗИРОВАНО.**`), а не вместо него. рядом с модальным словом (`**ДОЛЖЕН. МЕХАНИЗИРОВАНО.**`), а не вместо него.
- Метки правила — **ПОЧЕМУ** и **МЕХАНИЗИРОВАНО** — тоже словарь набора и
перечислены в строке о версии языка наравне с модальными словами.
- Заглавные модальные слова не употребляются вне правил: ни в «Область - Заглавные модальные слова не употребляются вне правил: ни в «Область
действия», ни в «Связано», ни в локальной части копии, ни во вводной прозе. действия», ни в «Связано», ни в локальной части копии, ни во вводной прозе.
Исключение — строка о версии языка, которая их перечисляет. Исключение — строка о версии языка, которая их перечисляет.
- «Почему» отвечает на «что сломается, если сделать иначе», а не - Обоснование отвечает на «что сломается, если сделать иначе», а не
пересказывает норму. «Потому что так принято» — не обоснование. пересказывает норму. «Потому что так принято» — не обоснование.
- Форма «Почему» не ограничена: рамки смысловые. Длина, рассуждение, примеры, - Форма обоснования не ограничена: рамки смысловые. Длина, рассуждение,
ссылки на внешние практики и чужие проекты — всё допустимо; запрещённых слов примеры, ссылки на внешние практики и чужие проекты — всё допустимо;
нет. Обязательность несёт норма, и путаницу исключает правило о заглавных. запрещённых слов нет. Обязательность несёт норма, и путаницу исключает
правило о заглавных.
- Служебные слова сценарного блока — тоже словарь набора: **КОГДА**, - Служебные слова сценарного блока — тоже словарь набора: **КОГДА**,
**ТОГДА**, **И**, **ИЛИ** (по-английски `WHEN`/`THEN`/`AND`/`OR`). Одна **ТОГДА**, **И**, **ИЛИ** (по-английски `WHEN`/`THEN`/`AND`/`OR`). Одна
форма на роль, заглавными. Модальностью не являются, в строку о версии форма на роль, заглавными. Модальностью не являются, в строку о версии
@@ -72,9 +75,9 @@ code in this repository.
## Ссылки ## Ссылки
- META-20: норму можно исполнить, имея один этот файл. Ссылка на правило - META-20: норму можно исполнить, имея один этот файл. Ссылка на правило
чужой темы допустима в «Почему», в «Связано» и в разграничении области чужой темы допустима в обосновании, в «Связано» и в разграничении области
действия — но не в самой норме. Нужен концепт соседней темы — коротко действия — но не в самой норме. Нужен концепт соседней темы — коротко
повторить его здесь, соседа назвать в «Почему». повторить его здесь, соседа назвать в обосновании.
- META-21: на соседнюю конвенцию ссылаются именем темы (конвенция - META-21: на соседнюю конвенцию ссылаются именем темы (конвенция
`logging`), на правило — идентификатором (`SLOG-27`). Пути файлов канона в `logging`), на правило — идентификатором (`SLOG-27`). Пути файлов канона в
тексте конвенции нет (в обвязке — можно). тексте конвенции нет (в обвязке — можно).
@@ -93,7 +96,7 @@ code in this repository.
- META-6: ДОЛЖЕН без механической проверки либо механизируется, либо - META-6: ДОЛЖЕН без механической проверки либо механизируется, либо
понижается в СЛЕДУЕТ. Правило, непроверяемое машиной в принципе (вкус понижается в СЛЕДУЕТ. Правило, непроверяемое машиной в принципе (вкус
формулировки, суждение о ситуации), — СЛЕДУЕТ по построению. формулировки, суждение о ситуации), — СЛЕДУЕТ по построению.
- META-10: блок «Почему» не удаляется никогда, в том числе после того, как - META-10: блок ПОЧЕМУ не удаляется никогда, в том числе после того, как
норма уехала в линтер. норма уехала в линтер.
- META-1: один файл — ровно один повторяющийся выбор. META-2: конвенция - META-1: один файл — ровно один повторяющийся выбор. META-2: конвенция
заводится, когда решение принимается третий раз. заводится, когда решение принимается третий раз.
+30 -30
View File
@@ -62,7 +62,7 @@ prefix: META
**ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор. **ДОЛЖЕН.** Файл описывает ровно один повторяющийся выбор.
**Почему.** Подписка перечисляется по темам, и тему берут целиком. Файл, **ПОЧЕМУ.** Подписка перечисляется по темам, и тему берут целиком. Файл,
собравший две темы, вынуждает репозиторий взять правила, которые ему не собравший две темы, вынуждает репозиторий взять правила, которые ему не
нужны, и вычитывать чужую половину при каждом обновлении. Разрезать позже нужны, и вычитывать чужую половину при каждом обновлении. Разрезать позже
дорого: перенос правила в другой файл — это новый префикс и новая дорого: перенос правила в другой файл — это новый префикс и новая
@@ -73,7 +73,7 @@ prefix: META
**СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и **СЛЕДУЕТ.** Поводом служит одно и то же решение, принятое третий раз и
каждый раз чуть по-другому. каждый раз чуть по-другому.
**Почему.** По одному-двум случаям не видно, что в решении повторяется, а **ПОЧЕМУ.** По одному-двум случаям не видно, что в решении повторяется, а
что было частностью места: правило, выведенное из первого случая, кодирует что было частностью места: правило, выведенное из первого случая, кодирует
частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же частность и дальше мешает больше, чем помогает. Единичный выбор, к тому же
спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть спорный, читателю нужен вместе с мотивом — это ADR, где мотив и есть
@@ -85,7 +85,7 @@ prefix: META
удаление прозы» делаются в репозитории, где случилась находка; в канон удаление прозы» делаются в репозитории, где случилась находка; в канон
продвигается общая часть. продвигается общая часть.
**Почему.** Правило, написанное сразу в общем виде, не проверено ни одним **ПОЧЕМУ.** Правило, написанное сразу в общем виде, не проверено ни одним
применением, и условие применимости у него придумано, а не найдено, — применением, и условие применимости у него придумано, а не найдено, —
платят за это все потребители сразу. Формулировка, обкатанная на одном платят за это все потребители сразу. Формулировка, обкатанная на одном
репозитории, приезжает в канон уже с известной границей. репозитории, приезжает в канон уже с известной границей.
@@ -95,7 +95,7 @@ prefix: META
**НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без **НЕ ДОЛЖЕН.** Норма пишется в настоящем предписывающем времени, без
описаний того, как сейчас устроен конкретный репозиторий. описаний того, как сейчас устроен конкретный репозиторий.
**Почему.** Такое утверждение устаревает молча и подменяет норму описанием: **ПОЧЕМУ.** Такое утверждение устаревает молча и подменяет норму описанием:
читатель перестаёт понимать, что от него требуется, а что просто читатель перестаёт понимать, что от него требуется, а что просто
констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен констатировано. В копиях у остальных потребителей чужой факт вдобавок ложен
с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно с первого дня. «Так сделано у нас» — содержимое региона отступлений, где оно
@@ -104,12 +104,12 @@ prefix: META
### META-20. Норма самодостаточна, наружу смотрит только обоснование ### META-20. Норма самодостаточна, наружу смотрит только обоснование
**ДОЛЖЕН.** Норму правила можно исполнить, имея один этот файл. Ссылка на **ДОЛЖЕН.** Норму правила можно исполнить, имея один этот файл. Ссылка на
правило чужой темы допустима в «Почему», в «Связано» и в разграничении правило чужой темы допустима в обосновании, в «Связано» и в разграничении
области действия — но не в самой норме. Если норме нужен концепт соседней области действия — но не в самой норме. Если норме нужен концепт соседней
темы, он коротко повторяется здесь, а сосед называется в «Почему» как темы, он коротко повторяется здесь, а сосед называется в обосновании как
источник решения. источник решения.
**Почему.** Репозиторий подписывается на произвольное подмножество **ПОЧЕМУ.** Репозиторий подписывается на произвольное подмножество
конвенций, и графа зависимостей у него нет по построению. Норма, которую конвенций, и графа зависимостей у него нет по построению. Норма, которую
нельзя исполнить без отсутствующего файла, делает такое подмножество нельзя исполнить без отсутствующего файла, делает такое подмножество
невалидным молча: читатель видит связный текст и не замечает, что часть невалидным молча: читатель видит связный текст и не замечает, что часть
@@ -124,7 +124,7 @@ prefix: META
`logging`), на конкретное правило — идентификатором (`SLOG-27`); путь файла `logging`), на конкретное правило — идентификатором (`SLOG-27`); путь файла
канона в тексте конвенции не употребляется. канона в тексте конвенции не употребляется.
**Почему.** В репозитории конвенция лежит собранной: слои одной темы — это **ПОЧЕМУ.** В репозитории конвенция лежит собранной: слои одной темы — это
секции одного файла, и пути `lang/go/logging.md` там не существует. Ссылка секции одного файла, и пути `lang/go/logging.md` там не существует. Ссылка
на путь канона умирает при сборке, причём молча — текст остаётся связным. на путь канона умирает при сборке, причём молча — текст остаётся связным.
Имя темы и идентификатор правила переживают и сборку, и переезд файла между Имя темы и идентификатор правила переживают и сборку, и переезд файла между
@@ -136,7 +136,7 @@ prefix: META
**ДОПУСКАЕТСЯ.** Правило языкового или стекового слоя называет идентификатор **ДОПУСКАЕТСЯ.** Правило языкового или стекового слоя называет идентификатор
правила арх-слоя своей темы прямо в норме. правила арх-слоя своей темы прямо в норме.
**Почему.** Подписываются темой, а не слоем: собранный файл начинается с **ПОЧЕМУ.** Подписываются темой, а не слоем: собранный файл начинается с
арх-слоя независимо от того, какие язык и стек выбраны, — базовое правило в арх-слоя независимо от того, какие язык и стек выбраны, — базовое правило в
копии всегда рядом, и ссылка на него никуда не ведёт. Повтор его концепта копии всегда рядом, и ссылка на него никуда не ведёт. Повтор его концепта
здесь заводил бы второй источник правды внутри одного документа: META-20 здесь заводил бы второй источник правды внутри одного документа: META-20
@@ -152,7 +152,7 @@ prefix: META
фактическую ошибку, внутреннее противоречие или условие применимости, фактическую ошибку, внутреннее противоречие или условие применимости,
которое не даёт ответа. которое не даёт ответа.
**Почему.** Правило, подогнанное под текущий код, перестаёт что-либо **ПОЧЕМУ.** Правило, подогнанное под текущий код, перестаёт что-либо
требовать — оно описывает то, что и так происходит, и первое же расхождение требовать — оно описывает то, что и так происходит, и первое же расхождение
переписывает его снова. Направление «конвенция → код» держится ровно тем, переписывает его снова. Направление «конвенция → код» держится ровно тем,
что факт не считается аргументом. что факт не считается аргументом.
@@ -162,7 +162,7 @@ prefix: META
**ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает **ДОЛЖЕН.** Правило, нарушение которого объявлено ошибкой, получает
машинную проверку или переводится в СЛЕДУЕТ. машинную проверку или переводится в СЛЕДУЕТ.
**Почему.** Без проверки правило держится на внимании: нарушения копятся **ПОЧЕМУ.** Без проверки правило держится на внимании: нарушения копятся
молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ молча и всплывают выборочно — на том ревью, куда дошли руки. Для СЛЕДУЕТ
это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько это честно, а ДОЛЖЕН при этом обещает то, чего не делает, и через несколько
таких случаев обесценивает остальные ДОЛЖЕН в файле. таких случаев обесценивает остальные ДОЛЖЕН в файле.
@@ -173,10 +173,10 @@ prefix: META
### META-25. Высшая модальность выбирается, только когда назван вред ### META-25. Высшая модальность выбирается, только когда назван вред
**СЛЕДУЕТ.** Правило получает ДОЛЖЕН или НЕ ДОЛЖЕН, если в «Почему» сказано, **СЛЕДУЕТ.** Правило получает ДОЛЖЕН или НЕ ДОЛЖЕН, если в обосновании
что́ ломается при нарушении. сказано, что́ ломается при нарушении.
**Почему.** Машинная проверка — условие необходимое (META-6), но не **ПОЧЕМУ.** Машинная проверка — условие необходимое (META-6), но не
достаточное: проверяемых мелочей больше, чем важных вещей, и без второго достаточное: проверяемых мелочей больше, чем важных вещей, и без второго
условия единственным фильтром остаётся удобство проверки. Шкала наполняется условия единственным фильтром остаётся удобство проверки. Шкала наполняется
опрятностью, читатель перестаёт отличать «уронит прод» от «неаккуратно» — и опрятностью, читатель перестаёт отличать «уронит прод» от «неаккуратно» — и
@@ -190,7 +190,7 @@ prefix: META
**ДОЛЖЕН.** Запись о механизации называет идентификатор правила и **ДОЛЖЕН.** Запись о механизации называет идентификатор правила и
конкретную проверку. конкретную проверку.
**Почему.** Механизация — состояние конкретного репозитория, канон о ней не **ПОЧЕМУ.** Механизация — состояние конкретного репозитория, канон о ней не
знает, а без записи следующий автор либо заведёт вторую проверку того же, знает, а без записи следующий автор либо заведёт вторую проверку того же,
либо будет вычитывать глазами уже проверенное машиной. Без идентификатора либо будет вычитывать глазами уже проверенное машиной. Без идентификатора
читатель догадывается сам, к какому утверждению относится проверка, — и читатель догадывается сам, к какому утверждению относится проверка, — и
@@ -201,7 +201,7 @@ prefix: META
**НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя **НЕ ДОЛЖЕН.** Норма остаётся в каноне, пока хотя бы у одного потребителя
машинной проверки нет. машинной проверки нет.
**Почему.** У кого линтера нет, тот после удаления остаётся без правила **ПОЧЕМУ.** У кого линтера нет, тот после удаления остаётся без правила
вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не вообще: ни проверки, ни текста. Механизация у одного потребителя ничего не
говорит о прочих, поэтому удалять по факту «у нас уже проверяется» — говорит о прочих, поэтому удалять по факту «у нас уже проверяется» —
значит чинить свой файл за чужой счёт. значит чинить свой файл за чужой счёт.
@@ -216,7 +216,7 @@ prefix: META
**ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль, **ДОПУСКАЕТСЯ.** Правило, уехавшее в общий конфиг линтера или в общую роль,
удаляется из канона. удаляется из канона.
**Почему.** Формулировка, дублирующая работающую у всех проверку, **ПОЧЕМУ.** Формулировка, дублирующая работающую у всех проверку,
размазывает внимание: файл на несколько сотен строк заставляет человека и размазывает внимание: файл на несколько сотен строк заставляет человека и
агента добросовестно вычитывать тривиальное именование и не доходить до агента добросовестно вычитывать тривиальное именование и не доходить до
формы решения. Явное разрешение нужно, чтобы META-8 не читался как запрет формы решения. Явное разрешение нужно, чтобы META-8 не читался как запрет
@@ -224,10 +224,10 @@ prefix: META
### META-10. Обоснование не удаляется никогда ### META-10. Обоснование не удаляется никогда
**НЕ ДОЛЖЕН.** Блок «Почему» остаётся и после того, как норма уехала в **НЕ ДОЛЖЕН.** Блок ПОЧЕМУ остаётся и после того, как норма уехала в
линтер. линтер.
**Почему.** Линтер сообщает, что нарушено, но не сообщает, зачем правило **ПОЧЕМУ.** Линтер сообщает, что нарушено, но не сообщает, зачем правило
существует. Без обоснования не видно, когда причина отпала, — проверка существует. Без обоснования не видно, когда причина отпала, — проверка
продолжает работать по инерции, и возразить ей нечем, кроме как отключив. продолжает работать по инерции, и возразить ей нечем, кроме как отключив.
@@ -237,7 +237,7 @@ prefix: META
называет, к чему применяется: к новым таблицам и миграциям, а не к называет, к чему применяется: к новым таблицам и миграциям, а не к
состоянию схемы. состоянию схемы.
**Почему.** Здесь не работает привычное «новое пишем правильно, старое **ПОЧЕМУ.** Здесь не работает привычное «новое пишем правильно, старое
переезжает по мере касания»: таблица не переезжает от того, что её переезжает по мере касания»: таблица не переезжает от того, что её
потрогали. Без явной рамки правило читается как требование к текущему потрогали. Без явной рамки правило читается как требование к текущему
состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо состоянию, и оба исхода плохи — миграция живых данных ради опрятности либо
@@ -248,7 +248,7 @@ prefix: META
**ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже **ДОЛЖЕН.** Проверка запрещает нарушение в новых миграциях, а не в уже
существующей схеме. существующей схеме.
**Почему.** Проверка состояния краснеет на легаси с первого дня: её **ПОЧЕМУ.** Проверка состояния краснеет на легаси с первого дня: её
отключают или обвешивают вечным списком исключений — и она перестаёт ловить отключают или обвешивают вечным списком исключений — и она перестаёт ловить
новое, ради чего заводилась. Проверка границы оставляет старое в покое и новое, ради чего заводилась. Проверка границы оставляет старое в покое и
делает новую ошибку невозможной. делает новую ошибку невозможной.
@@ -258,7 +258,7 @@ prefix: META
**ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не **ДОЛЖЕН.** Перечисленные старые таблицы и раскладки живут как есть, а не
как задачи на дочистку. как задачи на дочистку.
**Почему.** Список, записанный долгом, требует либо мигрировать живые данные **ПОЧЕМУ.** Список, записанный долгом, требует либо мигрировать живые данные
без выгоды, либо год за годом объяснять невыполненный план. Второе кончается без выгоды, либо год за годом объяснять невыполненный план. Второе кончается
тем, что список перестают вести, — и пропадает единственное место, где видно, тем, что список перестают вести, — и пропадает единственное место, где видно,
где именно правило не действует. где именно правило не действует.
@@ -268,7 +268,7 @@ prefix: META
**ДОЛЖЕН.** В локальной части копии перечислены отступления, которые уже **ДОЛЖЕН.** В локальной части копии перечислены отступления, которые уже
есть в коде, с идентификатором правила и причиной. есть в коде, с идентификатором правила и причиной.
**Почему.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить **ПОЧЕМУ.** Иначе репозиторий выглядит соблюдающим конвенцию, а проверить
это можно только чтением всего кода. Со ссылками отступления счётны: видно, это можно только чтением всего кода. Со ссылками отступления счётны: видно,
сколько правил конвенции репозиторий реально не соблюдает. Пустой список при сколько правил конвенции репозиторий реально не соблюдает. Пустой список при
этом почти всегда означает не отсутствие отступлений, а то, что их не искали. этом почти всегда означает не отсутствие отступлений, а то, что их не искали.
@@ -283,7 +283,7 @@ prefix: META
| META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне | | META-15.2 | правило не применяется в репозитории целиком | чинится условие применимости — в каноне |
| META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет | | META-15.3 | конвенция репозиторию не нужна | копия удаляется, подписки нет |
**Почему.** Отступление описывает исключение, и по нему видно, какая часть **ПОЧЕМУ.** Отступление описывает исключение, и по нему видно, какая часть
правила нарушена. Запись «мы это правило вообще не применяем» такой правила нарушена. Запись «мы это правило вообще не применяем» такой
информации не несёт и маскирует одну из двух чинимых причин: неверную рамку информации не несёт и маскирует одну из двух чинимых причин: неверную рамку
правила в каноне, которую чинят один раз для всех, или лишнюю подписку, правила в каноне, которую чинят один раз для всех, или лишнюю подписку,
@@ -295,7 +295,7 @@ prefix: META
потребителей; ссылки на ADR, код и файлы конкретного репозитория — в потребителей; ссылки на ADR, код и файлы конкретного репозитория — в
локальной части копии. локальной части копии.
**Почему.** Текст канона приезжает ко всем потребителям, и ссылка на чужой **ПОЧЕМУ.** Текст канона приезжает ко всем потребителям, и ссылка на чужой
файл у них битая с первого дня. Ниже маркера та же ссылка никого не файл у них битая с первого дня. Ниже маркера та же ссылка никого не
задевает и переживает обновление, потому что обновление её не трогает. задевает и переживает обновление, потому что обновление её не трогает.
@@ -304,7 +304,7 @@ prefix: META
**ДОЛЖЕН.** Правки репозитория вносятся ниже маркера, а не в текст, **ДОЛЖЕН.** Правки репозитория вносятся ниже маркера, а не в текст,
пришедший из канона. пришедший из канона.
**Почему.** Обновление перезаписывает всё, что выше маркера, поэтому правка **ПОЧЕМУ.** Обновление перезаписывает всё, что выше маркера, поэтому правка
там живёт до первого `pull`. Заметить пропажу можно, только вычитав там живёт до первого `pull`. Заметить пропажу можно, только вычитав
`git diff` целиком — а он в этот момент и без того полон изменений канона, `git diff` целиком — а он в этот момент и без того полон изменений канона,
и своя строка теряется среди чужих. и своя строка теряется среди чужих.
@@ -314,7 +314,7 @@ prefix: META
**НЕ ДОЛЖЕН.** Файл, который развели с каноном намеренно, шапку `origin:` **НЕ ДОЛЖЕН.** Файл, который развели с каноном намеренно, шапку `origin:`
не сохраняет. не сохраняет.
**Почему.** По `origin:` решается, какие файлы пересобирать из канона. Форк, **ПОЧЕМУ.** По `origin:` решается, какие файлы пересобирать из канона. Форк,
оставивший шапку, при первом же обновлении теряет ровно то, ради чего его оставивший шапку, при первом же обновлении теряет ровно то, ради чего его
заводили. Происхождение такого документа остаётся в истории коммита, где оно заводили. Происхождение такого документа остаётся в истории коммита, где оно
никого не вводит в заблуждение. никого не вводит в заблуждение.
@@ -323,7 +323,7 @@ prefix: META
**СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он. **СЛЕДУЕТ.** Одна плоская таблица: файл и строка о том, про что он.
**Почему.** Подписка — это набор лежащих файлов, и без описаний вопрос **ПОЧЕМУ.** Подписка — это набор лежащих файлов, и без описаний вопрос
«какая из них про мой случай» решается открыванием каждой. Ценой в десяток «какая из них про мой случай» решается открыванием каждой. Ценой в десяток
файлов это означает, что не открывают ни одной. файлов это означает, что не открывают ни одной.
@@ -332,7 +332,7 @@ prefix: META
**ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его **ДОЛЖЕН.** В `AGENTS.md` / `CLAUDE.md` едет одна строка на правило с его
идентификатором; детали остаются в конвенции. идентификатором; детали остаются в конвенции.
**Почему.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только **ПОЧЕМУ.** Сама по себе конвенция агенту не видна: он дойдёт до неё, только
если его туда отправили, — а безусловно он читает точку входа. Строка с если его туда отправили, — а безусловно он читает точку входа. Строка с
идентификатором служит и напоминанием, и адресом, по которому за идентификатором служит и напоминанием, и адресом, по которому за
подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух подробностями идут; перенос деталей туда же вернул бы задачу поддержки двух
@@ -347,4 +347,4 @@ prefix: META
| Номер | Что было | Почему снято | | Номер | Что было | Почему снято |
|---|---|---| |---|---|---|
| META-16 | имя файла — kebab-case | вреда от нарушения нет (META-25); осталось прозой в «Оформлении» | | META-16 | имя файла — kebab-case | вреда от нарушения нет (META-25); осталось прозой в «Оформлении» |
| META-26 | запрет слов обязательства в «Почему» | правило о заглавных уже делает строчное слово ненормативным, а форму обоснования рамками не ограничивают | | META-26 | запрет слов обязательства в обосновании | правило о заглавных уже делает строчное слово ненормативным, а форму обоснования рамками не ограничивают |
+59 -34
View File
@@ -1,5 +1,5 @@
--- ---
version: 1 version: 2
--- ---
# Язык конвенций # Язык конвенций
@@ -8,7 +8,7 @@ version: 1
правилом, чем оно отличается от прозы вокруг, какими словами задаётся правилом, чем оно отличается от прозы вокруг, какими словами задаётся
обязательность и как на правило сослаться извне. обязательность и как на правило сослаться извне.
Версия языка — **1**. Номер называется в каждой конвенции: словарь может Версия языка — **2**. Номер называется в каждой конвенции: словарь может
пополниться, и текст, написанный по предыдущей версии, должен читаться по пополниться, и текст, написанный по предыдущей версии, должен читаться по
той, по которой написан. той, по которой написан.
@@ -77,20 +77,24 @@ version: 1
**ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса **ДОЛЖЕН.** Идентификатор, пришедший снаружи, проходит разбор до запроса
к базе. к базе.
**Почему.** Разбор валидирует формат и нормализует регистр. Сравнение строк **ПОЧЕМУ.** Разбор валидирует формат и нормализует регистр. Сравнение строк
в базе побайтовое, поэтому без нормализации запрос молча не находит в базе побайтовое, поэтому без нормализации запрос молча не находит
существующую запись — отладка такого случая стоит дороже, чем сам разбор. существующую запись — отладка такого случая стоит дороже, чем сам разбор.
``` ```
Четыре обязательные части: **идентификатор**, **заголовок**, **модальность Четыре обязательные части: **идентификатор**, **заголовок**, **модальность
с нормой**, **обоснование**. Норма — одна фраза; если в неё не влезает, это с нормой**, **обоснование под меткой ПОЧЕМУ**. Норма — одна фраза; если в неё
два правила. Требование единичности взято из ISO/IEC/IEEE 29148: составная не влезает, это два правила. Требование единичности взято из ISO/IEC/IEEE
норма не проверяема целиком, и нарушение одной её половины нечем 29148: составная норма не проверяема целиком, и нарушение одной её половины
адресовать. нечем адресовать.
Обе метки правила — модальное слово и ПОЧЕМУ — пишутся заглавными и
принадлежат словарю набора: скелет правила читается одинаково в любом языке,
на который канон переведён.
## Обоснование обязательно ## Обоснование обязательно
Правило без блока «Почему» не принимается. Это требование к форме, а не Правило без блока ПОЧЕМУ не принимается. Это требование к форме, а не
пожелание; в 29148 обоснование — атрибут требования наравне с самим пожелание; в 29148 обоснование — атрибут требования наравне с самим
требованием, и по тем же причинам: требованием, и по тем же причинам:
@@ -104,8 +108,9 @@ version: 1
Если причина не формулируется, перед нами привычка или вкусовщина; ей Если причина не формулируется, перед нами привычка или вкусовщина; ей
место в черновиках, а не в конвенции. место в черновиках, а не в конвенции.
«Почему» отвечает на «что сломается, если сделать иначе», а не пересказывает Обоснование отвечает на «что сломается, если сделать иначе», а не
норму другими словами. «Потому что так принято» — не обоснование. пересказывает норму другими словами. «Потому что так принято» — не
обоснование.
Форма обоснования при этом ничем не ограничена: рамки здесь только Форма обоснования при этом ничем не ограничена: рамки здесь только
смысловые. Абзац может быть длинным, вести рассуждение, приводить пример, смысловые. Абзац может быть длинным, вести рассуждение, приводить пример,
@@ -172,14 +177,12 @@ Directives, Part 2, по одной форме записи на ступень,
## Словарь другого языка ## Словарь другого языка
Словарь набора — это два перечня: модальные слова и служебные слова Словарь набора — три перечня, и требования к ним одни и те же.
сценарного блока (`КОГДА`, `ТОГДА`, `И`, `ИЛИ` — см. «Таблицы решений»).
Требования к ним одни и те же.
Для английского готовый словарь модальных слов даёт BCP 14. Для любого **Шкала обязательности.** Для английского готовый словарь даёт BCP 14; для
другого языка слова берут из перевода стандарта, если он есть, или переводят любого другого языка слова берут из перевода стандарта, если он есть, или
сами: шкала и семантика ступеней при этом не меняются — меняется только переводят сами. Шкала и семантика ступеней при этом не меняются — меняется
запись. только запись.
| Ступень | Русский | Английский (BCP 14) | | Ступень | Русский | Английский (BCP 14) |
|---|---|---| |---|---|---|
@@ -188,22 +191,32 @@ Directives, Part 2, по одной форме записи на ступень,
| рекомендация | СЛЕДУЕТ | SHOULD | | рекомендация | СЛЕДУЕТ | SHOULD |
| рекомендация против | НЕ СЛЕДУЕТ | SHOULD NOT | | рекомендация против | НЕ СЛЕДУЕТ | SHOULD NOT |
| разрешение | ДОПУСКАЕТСЯ | MAY | | разрешение | ДОПУСКАЕТСЯ | MAY |
| отметка о способе проверки | МЕХАНИЗИРОВАНО | MECHANIZED |
Последняя строка стандартом не даётся ни в одном языке: способа проверки в **Метки правила.** Обязательности не задают, а размечают его части.
шкале BCP 14 нет, слово подбирается под язык так же, как остальные. Стандартом не даются ни в одном языке: в BCP 14 таких понятий нет, слова
подбираются под язык так же, как остальные.
| Метка | Русский | Английский |
|---|---|---|
| обоснование | ПОЧЕМУ | WHY |
| способ проверки | МЕХАНИЗИРОВАНО | MECHANIZED |
**Служебные слова сценарного блока**`КОГДА`, `ТОГДА`, `И`, `ИЛИ`; таблица
и объяснение в разделе «Таблицы решений».
Что требуется от любого словаря: Что требуется от любого словаря:
- **одна форма на ступень.** Синонимы отклонены не из аскетизма: проверка - **одна форма на ступень и на метку.** Синонимы отклонены не из аскетизма:
«модальное слово вне правила» перечисляет формы, и синонимический ряд проверка «модальное слово вне правила» перечисляет формы, и синонимический
превращает перечисление в разбор. ряд превращает перечисление в разбор.
- **слово заглавными не встречается в обычной прозе этого языка.** Иначе - **слово заглавными не встречается в обычной прозе этого языка.** Иначе
правило «нормативно только заглавное» перестаёт спасать: проверка ловит правило «нормативно только заглавное» перестаёт спасать: проверка ловит
оформление, а не модальность. оформление, а не модальность.
- **словарь перечислен целиком в строке о версии языка.** Читателю копии он - **модальные слова и метки перечислены в строке о версии языка.** Читателю
известен из самого файла, без обращения к этому документу, — иначе копии они известны из самого файла, без обращения к этому документу, —
конвенция в чужом репозитории теряет ключ к собственному тексту. иначе конвенция в чужом репозитории теряет ключ к собственному тексту.
Служебные слова сценария в строку не входят: структура блока читается из
самого блока, и в файле без стыков правил их нет вовсе.
- **словарь один на канон.** Два словаря параллельно дают две формы записи - **словарь один на канон.** Два словаря параллельно дают две формы записи
одного требования и удваивают каждую проверку; выбор языка — свойство одного требования и удваивают каждую проверку; выбор языка — свойство
набора, а не отдельного файла. набора, а не отдельного файла.
@@ -215,16 +228,16 @@ Directives, Part 2, по одной форме записи на ступень,
Каждая конвенция называет язык одной строкой во вводной прозе: Каждая конвенция называет язык одной строкой во вводной прозе:
> Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и > Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
> отметка МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 > ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
> тогда и только тогда, когда написаны заглавными. > тогда и только тогда, когда написаны заглавными.
Слова в строке — из словаря того языка, на котором написан набор. Для Слова в строке — из словаря того языка, на котором написан набор. Для
англоязычного набора та же строка выглядит так: англоязычного набора та же строка выглядит так:
> The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the mark > The key words MUST, MUST NOT, SHOULD, SHOULD NOT, MAY and the marks WHY
> MECHANIZED are to be interpreted as described in the conventions language, > and MECHANIZED are to be interpreted as described in the conventions
> version 1, and only when written in capitals. > language, version 2, and only when written in capitals.
Форма скопирована у BCP 14, где та же задача решается тем же способом: Форма скопирована у BCP 14, где та же задача решается тем же способом:
спецификация не прикладывает к себе словарь и не указывает путь к нему, а спецификация не прикладывает к себе словарь и не указывает путь к нему, а
@@ -248,14 +261,14 @@ Directives, Part 2, по одной форме записи на ступень,
**ДОЛЖЕН. МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера; **ДОЛЖЕН. МЕХАНИЗИРОВАНО.** Проверяется общим правилом линтера;
формулировка удалена, потому что дублировала работающую проверку. формулировка удалена, потому что дублировала работающую проверку.
**Почему.** Дефолт превращает забытую вставку в тихо работающий код… **ПОЧЕМУ.** Дефолт превращает забытую вставку в тихо работающий код…
``` ```
- Идентификатор и заголовок сохраняются: ссылки из репозиториев продолжают - Идентификатор и заголовок сохраняются: ссылки из репозиториев продолжают
указывать на то же утверждение. указывать на то же утверждение.
- Модальность сохраняется, поэтому «все ли ДОЛЖЕН механизированы» - Модальность сохраняется, поэтому «все ли ДОЛЖЕН механизированы»
остаётся вычислимым вопросом, а не предметом чтения всего канона. остаётся вычислимым вопросом, а не предметом чтения всего канона.
- «Почему» остаётся навсегда — линтер сообщает, что нарушено, но не - Обоснование остаётся навсегда — линтер сообщает, что нарушено, но не
сообщает, зачем правило существует, и без обоснования нельзя понять, сообщает, зачем правило существует, и без обоснования нельзя понять,
когда проверку пора отменять. когда проверку пора отменять.
@@ -386,7 +399,7 @@ MIGR-6 не соблюдается в легаси-таблицах `show_histor
- заголовки правил файла используют только его собственный префикс; - заголовки правил файла используют только его собственный префикс;
- номера уникальны внутри файла и не имеют пропусков вниз (новое правило - номера уникальны внутри файла и не имеют пропусков вниз (новое правило
берёт следующий свободный, а не первый освободившийся); берёт следующий свободный, а не первый освободившийся);
- у каждого `### <ПРЕФИКС>-<n>` есть модальное слово и блок «Почему»; - у каждого `### <ПРЕФИКС>-<n>` есть модальное слово и блок ПОЧЕМУ;
отметка МЕХАНИЗИРОВАНО стоит рядом с модальностью, а не вместо неё; отметка МЕХАНИЗИРОВАНО стоит рядом с модальностью, а не вместо неё;
- вводная проза содержит строку о версии языка; - вводная проза содержит строку о версии языка;
- ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте канона, хоть в локальной части - ссылки вида `<ПРЕФИКС>-<n>` — хоть в тексте канона, хоть в локальной части
@@ -405,3 +418,15 @@ MIGR-6 не соблюдается в легаси-таблицах `show_histor
- перечисленные в таблице случаи покрывают область действия; - перечисленные в таблице случаи покрывают область действия;
- норма исполнима без обращения к другим файлам; - норма исполнима без обращения к другим файлам;
- обоснование отвечает на «что сломается», а не пересказывает норму. - обоснование отвечает на «что сломается», а не пересказывает норму.
## История версий
Номер версии называется в каждой конвенции, поэтому изменение формы, способное
изменить чтение уже написанного текста, меняет и номер. Смена словаря под
другой естественный язык версию не двигает: версия принадлежит шкале, меткам и
правилам формы, а не буквам.
| Версия | Что изменилось |
|---|---|
| 1 | первая запись языка: шкала из BCP 14, обязательное обоснование, идентификаторы, таблицы решений |
| 2 | обоснование получило метку ПОЧЕМУ заглавными и вошло в словарь набора; метки правила выделены из шкалы в отдельный перечень |
+1 -1
View File
@@ -10,7 +10,7 @@
| Файл | Что описывает | | Файл | Что описывает |
|---|---| |---|---|
| `README.md` | устройство канона, оси, сборка копий, жизненный цикл | | `README.md` | устройство канона, оси, сборка копий, жизненный цикл |
| [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, «Почему» | | [LANGUAGE.md](LANGUAGE.md) | язык записи правил: идентификаторы, модальность, обоснование |
| [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления | | [GUIDE.md](GUIDE.md) | как ведут конвенции: когда заводить, механизация, отступления |
| `prefixes.toml` | реестр префиксов правил | | `prefixes.toml` | реестр префиксов правил |
| `conv` | сборка копий | | `conv` | сборка копий |
+12 -12
View File
@@ -10,9 +10,9 @@ prefix: DIRS
**кто создаёт** содержимое и **что будет, если его потерять**. Из категорий **кто создаёт** содержимое и **что будет, если его потерять**. Из категорий
механически выводится состав бэкапа. механически выводится состав бэкапа.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -37,7 +37,7 @@ prefix: DIRS
Имена в таблице — умолчание для случая «одна директория на категорию». Имена в таблице — умолчание для случая «одна директория на категорию».
**Почему.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно **ПОЧЕМУ.** Все дальнейшие решения — что попадает в бэкап (DIRS-4), что можно
снести при нехватке места, что переживает переезд на другой диск — снести при нехватке места, что переживает переезд на другой диск —
читаются из категории, а не выясняются по коду приложения. Без единой читаются из категории, а не выясняются по коду приложения. Без единой
классификации каждое такое решение принимается заново и каждый раз чуть классификации каждое такое решение принимается заново и каждый раз чуть
@@ -49,7 +49,7 @@ prefix: DIRS
**ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке; **ДОПУСКАЕТСЯ.** Директорий в категории столько, сколько нужно раскладке;
принадлежность к категории задаётся не именем, а участием в списке бэкапа. принадлежность к категории задаётся не именем, а участием в списке бэкапа.
**Почему.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно **ПОЧЕМУ.** Явное разрешение снимает вопрос, не читается ли DIRS-1 как «ровно
три директории». Крупные файлы отделяют от базы, чтобы двигать их между три директории». Крупные файлы отделяют от базы, чтобы двигать их между
дисками независимо (`media/`, `uploads/` — та же категория «данные», что и дисками независимо (`media/`, `uploads/` — та же категория «данные», что и
`data/`); запрет на такое деление вынуждал бы либо держать всё на одном `data/`); запрет на такое деление вынуждал бы либо держать всё на одном
@@ -67,7 +67,7 @@ prefix: DIRS
| DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш | | DIRS-3.2 | миниатюры, распакованные ассеты, кеш внешних ответов, перестраиваемые индексы | кеш |
| DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные | | DIRS-3.3 | спорный случай | `rm -rf` и запуск приложения заново: поднялось и наверстало само — кеш; не поднялось или поднялось пустым — данные |
**Почему.** Без внешнего теста граница проводится по ощущению «жалко **ПОЧЕМУ.** Без внешнего теста граница проводится по ощущению «жалко
потерять», а оно смещено в одну сторону: дорогой в пересборке кеш потерять», а оно смещено в одну сторону: дорогой в пересборке кеш
переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и переезжает в данные и раздувает каждый снапшот. Дорого ≠ невосполнимо, и
разделяет эти два свойства именно способность приложения пересоздать разделяет эти два свойства именно способность приложения пересоздать
@@ -80,7 +80,7 @@ prefix: DIRS
**ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и **ДОЛЖЕН.** Директории категории «данные» — в списке бэкапа; конфигурация и
кеш — нет. кеш — нет.
**Почему.** Кеш раздувает снапшот содержимым, которое приложение **ПОЧЕМУ.** Кеш раздувает снапшот содержимым, которое приложение
восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там восстановит само. Конфигурацию бэкапить не только незачем, но и вредно: там
лежат секреты, а бэкапы уезжают в облако — источник истины для лежат секреты, а бэкапы уезжают в облако — источник истины для
конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте. конфигурации остаётся в репозитории и хранилище секретов, а не в снапшоте.
@@ -97,7 +97,7 @@ prefix: DIRS
буквально: переменная деплоя, константа, поле конфигурации. Всё остальное буквально: переменная деплоя, константа, поле конфигурации. Всё остальное
на него ссылается. на него ссылается.
**Почему.** Правило вывода механическое, но применяет его человек или **ПОЧЕМУ.** Правило вывода механическое, но применяет его человек или
шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок шаблон — то есть ошибиться можно. Общая ссылка делает целый класс ошибок
невозможным: переименование директории отражается в обоих местах сразу. невозможным: переименование директории отражается в обоих местах сразу.
Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа Независимо набранный список расходится тихо — ни деплой, ни прогон бэкапа
@@ -113,7 +113,7 @@ prefix: DIRS
| DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием | | DIRS-6.1 | файлы самодостаточны на любой момент времени | копированием |
| DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет | | DIRS-6.2 | консистентность обеспечивает только сама СУБД | дампом: бэкапится директория дампов, сырой каталог базы — нет |
**Почему.** Файловый снапшот работающей СУБД не гарантирует **ПОЧЕМУ.** Файловый снапшот работающей СУБД не гарантирует
консистентности: скопированный каталог может не восстановиться, и узнают консистентности: скопированный каталог может не восстановиться, и узнают
об этом при восстановлении. Директория дампов — тоже данные, просто об этом при восстановлении. Директория дампов — тоже данные, просто
производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из производные, поэтому DIRS-4 покрывает её без оговорок. Сырой каталог базы из
@@ -125,7 +125,7 @@ prefix: DIRS
**ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда **ДОЛЖЕН.** Решение «копировать или дампить» (DIRS-6) принимается, когда
приложение заводят. приложение заводят.
**Почему.** Неверный выбор ничем себя не проявляет, пока бэкап не **ПОЧЕМУ.** Неверный выбор ничем себя не проявляет, пока бэкап не
понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из понадобился: прогоны копирования проходят успешно и накапливают снапшоты, из
которых база не поднимется. Отложить решение — значит принять его по факту которых база не поднимется. Отложить решение — значит принять его по факту
первой неудачной попытки восстановления, то есть тогда, когда данных уже первой неудачной попытки восстановления, то есть тогда, когда данных уже
@@ -136,7 +136,7 @@ prefix: DIRS
**ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для **ДОЛЖЕН.** Конфигурация приложения задаёт отдельные пути для данных и для
кеша, а не один каталог на всё. кеша, а не один каталог на всё.
**Почему.** Снаружи категория определяется только тогда, когда разным **ПОЧЕМУ.** Снаружи категория определяется только тогда, когда разным
категориям соответствуют разные директории. Всё, сложенное в один каталог, категориям соответствуют разные директории. Всё, сложенное в один каталог,
заставляет составлять список бэкапа вручную, читая код приложения, — и заставляет составлять список бэкапа вручную, читая код приложения, — и
пересматривать его при каждом обновлении, потому что новый подкаталог пересматривать его при каждом обновлении, потому что новый подкаталог
@@ -148,7 +148,7 @@ prefix: DIRS
**НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь **НЕ ДОЛЖЕН.** Записываемые пути приложения не указывают внутрь
конфигурации. конфигурации.
**Почему.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому **ПОЧЕМУ.** Конфигурация восстанавливается прогоном деплоя (DIRS-1.1), поэтому
всё, что приложение туда записало, следующий деплой затирает без всё, что приложение туда записало, следующий деплой затирает без
предупреждения. Вдобавок директория конфигурации может быть подключена предупреждения. Вдобавок директория конфигурации может быть подключена
только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не только на чтение — тогда запись отказывает в рантайме. Ни то ни другое не
+24 -24
View File
@@ -7,9 +7,9 @@ prefix: CONF
Как устроена конфигурация: где лежит, как попадает в процесс, что с Как устроена конфигурация: где лежит, как попадает в процесс, что с
секретами и когда падает. секретами и когда падает.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -25,7 +25,7 @@ prefix: CONF
**ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные **ДОЛЖЕН.** Приложение читает параметры из файла конфигурации; переменные
окружения источником конфигурации не служат. окружения источником конфигурации не служат.
**Почему.** Три довода, по убыванию веса: **ПОЧЕМУ.** Три довода, по убыванию веса:
- **Один типизированный источник.** Файл несёт секции, комментарии, - **Один типизированный источник.** Файл несёт секции, комментарии,
единицы измерения и валидируется целиком. Окружение — плоский набор единицы измерения и валидируется целиком. Окружение — плоский набор
@@ -48,7 +48,7 @@ prefix: CONF
**СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку. **СЛЕДУЕТ.** Конкретный формат (TOML, YAML) выбирается по стеку.
**Почему.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг **ПОЧЕМУ.** Комментарий у каждого поля (CONF-9) — часть того, ради чего конфиг
вообще читают; формат, в котором комментарий негде разместить, делает CONF-9 вообще читают; формат, в котором комментарий негде разместить, делает CONF-9
невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, — невыполнимым. Секции дают структуру, которую валидатор проверяет целиком, —
плоский список пар такой возможности не даёт и возвращает нас к тем же плоский список пар такой возможности не даёт и возвращает нас к тем же
@@ -59,7 +59,7 @@ prefix: CONF
**СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь **СЛЕДУЕТ.** Имя по умолчанию ищется в рабочей директории процесса, а путь
задаётся опцией командной строки. задаётся опцией командной строки.
**Почему.** Запуск без аргументов работает одинаково в разработке, в **ПОЧЕМУ.** Запуск без аргументов работает одинаково в разработке, в
контейнере и на сервере, и способ запуска не приходится помнить отдельно контейнере и на сервере, и способ запуска не приходится помнить отдельно
для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько для каждой среды. Опция нужна ровно для случаев, когда конфигов несколько
(тесты, второй инстанс): без неё их разводят переменной окружения — тем (тесты, второй инстанс): без неё их разводят переменной окружения — тем
@@ -71,7 +71,7 @@ prefix: CONF
рабочей директории (CONF-3), приложение не стартует: сообщение называет рабочей директории (CONF-3), приложение не стартует: сообщение называет
искомый путь, код возврата ненулевой. искомый путь, код возврата ненулевой.
**Почему.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что **ПОЧЕМУ.** Конфиг — артефакт деплоя (CONF-12), и его отсутствие означает, что
развёртывание не довело работу до конца, а не что приложение попросили развёртывание не довело работу до конца, а не что приложение попросили
работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал работать на умолчаниях. Умолчания (CONF-7) существуют, чтобы работал
**неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается **неполный** файл, а не отсутствующий, — именно здесь читатель спотыкается
@@ -89,7 +89,7 @@ prefix: CONF
**НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец. **НЕ ДОЛЖЕН.** Реальный конфиг не коммитится; в репозитории — образец.
**Почему.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет, **ПОЧЕМУ.** Рабочий конфиг содержит отрендеренные секреты (CONF-12), а секрет,
попавший в историю, чинится ротацией, а не удалением файла. Кроме того, попавший в историю, чинится ротацией, а не удалением файла. Кроме того,
закоммиченный конфиг конкретной среды становится вторым источником истины: закоммиченный конфиг конкретной среды становится вторым источником истины:
он расходится с тем, что реально развёрнуто, и расходится молча. он расходится с тем, что реально развёрнуто, и расходится молча.
@@ -99,7 +99,7 @@ prefix: CONF
**ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения **ДОЛЖЕН.** Разбор — при старте, в одну типизированную структуру; чтения
файла конфигурации в бизнес-коде нет. файла конфигурации в бизнес-коде нет.
**Почему.** Второе место чтения — это второй момент времени: две части кода **ПОЧЕМУ.** Второе место чтения — это второй момент времени: две части кода
начинают видеть разные значения одного параметра, и расхождение не начинают видеть разные значения одного параметра, и расхождение не
воспроизводится, потому что зависит от того, когда файл потрогали. воспроизводится, потому что зависит от того, когда файл потрогали.
Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17), Типизированная структура вдобавок переносит ошибку формата в старт (CONF-17),
@@ -109,7 +109,7 @@ prefix: CONF
**ДОЛЖЕН.** Смена параметров — рестарт процесса. **ДОЛЖЕН.** Смена параметров — рестарт процесса.
**Почему.** Изменяемый конфиг делает поведение функцией момента: один **ПОЧЕМУ.** Изменяемый конфиг делает поведение функцией момента: один
запрос обслуживается наполовину старыми, наполовину новыми значениями, а запрос обслуживается наполовину старыми, наполовину новыми значениями, а
разбор инцидента требует знать хронологию правок файла, а не его текущее разбор инцидента требует знать хронологию правок файла, а не его текущее
содержимое. содержимое.
@@ -121,7 +121,7 @@ prefix: CONF
**ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает. **ДОЛЖЕН.** Значение по умолчанию задаётся в коде, файл его перекрывает.
**Почему.** Умолчание, живущее в образце, действует только для тех, кто **ПОЧЕМУ.** Умолчание, живущее в образце, действует только для тех, кто
образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно образец скопировал: конфиг без этого поля даёт другое поведение, и ни одно
из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое из двух значений не выглядит ошибкой. Умолчание в коде — одно определённое
поведение для неполного конфига и одно место, где это значение меняется. поведение для неполного конфига и одно место, где это значение меняется.
@@ -131,7 +131,7 @@ prefix: CONF
**ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у **ДОЛЖЕН.** В образце присутствуют все секции и все поля, включая те, у
которых есть умолчание (CONF-7). которых есть умолчание (CONF-7).
**Почему.** Поле, живущее только в коде, для читателя конфига не **ПОЧЕМУ.** Поле, живущее только в коде, для читателя конфига не
существует: он не знает, что параметр вообще можно менять, и добивается существует: он не знает, что параметр вообще можно менять, и добивается
нужного поведения обходным путём. Полнота образца — цена, которой CONF-7 нужного поведения обходным путём. Полнота образца — цена, которой CONF-7
покупает себе видимость. покупает себе видимость.
@@ -145,7 +145,7 @@ prefix: CONF
- **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля - **единицы измерения**, если применимо: секунды/миллисекунды, байты, доля
`01`. `01`.
**Почему.** Так конфиг читается без открывания кода — этим он и полезен; **ПОЧЕМУ.** Так конфиг читается без открывания кода — этим он и полезен;
без комментария читатель всё равно идёт в код, и образец перестаёт быть без комментария читатель всё равно идёт в код, и образец перестаёт быть
справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды справочником. Единицы стоят отдельно: перепутанные секунды и миллисекунды
дают валидное значение и работающий процесс, а ошибка обнаруживается по дают валидное значение и работающий процесс, а ошибка обнаруживается по
@@ -161,7 +161,7 @@ prefix: CONF
| CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются | | CONF-10.1 | поддерживаемое | обязательны поля этого варианта; поля прочих вариантов не требуются |
| CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений | | CONF-10.2 | неизвестное | ошибка на старте с перечислением поддерживаемых значений |
**Почему.** Фиксированный на секцию набор обязательных полей оставляет **ПОЧЕМУ.** Фиксированный на секцию набор обязательных полей оставляет
выбор из двух плохих: заполнять поля бекенда, который не используется, или выбор из двух плохих: заполнять поля бекенда, который не используется, или
не проверять обязательность вовсе — то есть выключить валидацию ровно там, не проверять обязательность вовсе — то есть выключить валидацию ровно там,
где вариантов много и ошибиться легче всего. Перечисление поддерживаемых где вариантов много и ошибиться легче всего. Перечисление поддерживаемых
@@ -174,7 +174,7 @@ prefix: CONF
альтернативные — блоками-комментариями ниже, каждый со своим описанием альтернативные — блоками-комментариями ниже, каждый со своим описанием
полей. полей.
**Почему.** Иначе набор вариантов виден только из кода валидации, и образец **ПОЧЕМУ.** Иначе набор вариантов виден только из кода валидации, и образец
теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор теряет свойство справочника (CONF-8, CONF-9) ровно на той секции, где выбор
действительно есть. Закомментированный блок вдобавок переключается правкой действительно есть. Закомментированный блок вдобавок переключается правкой
на месте, а не сборкой секции с нуля по документации. на месте, а не сборкой секции с нуля по документации.
@@ -184,7 +184,7 @@ prefix: CONF
**ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации; **ДОЛЖЕН.** Деплой рендерит значения секретов прямо в файл конфигурации;
отдельного слоя секретов в приложении нет. отдельного слоя секретов в приложении нет.
**Почему.** Источник истины секрета — внешнее хранилище деплоя, не **ПОЧЕМУ.** Источник истины секрета — внешнее хранилище деплоя, не
репозиторий и не окружение. Любой второй канал — переменная окружения рядом репозиторий и не окружение. Любой второй канал — переменная окружения рядом
с файлом, собственный клиент к хранилищу внутри приложения — возвращает с файлом, собственный клиент к хранилищу внутри приложения — возвращает
вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при вопрос «откуда взялось значение, которое сейчас в процессе». Приложение при
@@ -196,7 +196,7 @@ prefix: CONF
**ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого **ДОЛЖЕН.** Права `0600`, владелец — пользователь, от имени которого
работает процесс. работает процесс.
**Почему.** После CONF-1 и CONF-12 файл конфигурации — единственная **ПОЧЕМУ.** После CONF-1 и CONF-12 файл конфигурации — единственная
поверхность, на которой секреты лежат, и весь довод «файл вместо окружения» поверхность, на которой секреты лежат, и весь довод «файл вместо окружения»
держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты держится на его правах: конфиг, читаемый всеми на машине, раздаёт секреты
шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на шире, чем раздало бы окружение, — и тогда CONF-1 меняет одну утечку на
@@ -207,7 +207,7 @@ prefix: CONF
**ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не **ДОЛЖЕН.** Значение секретного поля в образце — пустая строка, а не
пример. пример.
**Почему.** Правдоподобная заглушка доезжает до продакшена как настоящее **ПОЧЕМУ.** Правдоподобная заглушка доезжает до продакшена как настоящее
значение: шаблон отрендерился криво, поле осталось от образца, и проверка значение: шаблон отрендерился криво, поле осталось от образца, и проверка
непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг непустоты (CONF-15) его пропускает. Пустая строка делает недорендеренный конфиг
механически отличимым от заполненного. механически отличимым от заполненного.
@@ -216,7 +216,7 @@ prefix: CONF
**ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте. **ДОЛЖЕН.** Непустота обязательных секретов проверяется на старте.
**Почему.** Это ловит криво отрендеренный шаблон до того, как он превратится **ПОЧЕМУ.** Это ловит криво отрендеренный шаблон до того, как он превратится
в 401 от внешнего API через час работы, — то есть в момент, когда причина в 401 от внешнего API через час работы, — то есть в момент, когда причина
ещё очевидна и связана с деплоем. ещё очевидна и связана с деплоем.
@@ -225,7 +225,7 @@ prefix: CONF
**НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на **НЕ ДОЛЖЕН.** Значение секретного поля не появляется в записи лога ни на
одном уровне. одном уровне.
**Почему.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они **ПОЧЕМУ.** У логов круг доступа шире, чем у файла под `0600` (CONF-13): они
собираются, пересылаются и попадают в бэкапы, где права исходного файла уже собираются, пересылаются и попадают в бэкапы, где права исходного файла уже
ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением ничего не значат. Попавший в лог секрет чинится ротацией, а не удалением
записи. Типичный источник утечки — отладочный дамп разобранного конфига при записи. Типичный источник утечки — отладочный дамп разобранного конфига при
@@ -236,7 +236,7 @@ prefix: CONF
**ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым **ДОЛЖЕН.** Невалидный конфиг — запись уровня `ERROR` и выход с ненулевым
кодом; процесс не стартует «наполовину». кодом; процесс не стартует «наполовину».
**Почему.** Наполовину стартовавший процесс проходит проверку живости и **ПОЧЕМУ.** Наполовину стартовавший процесс проходит проверку живости и
падает позже — на первом запросе, который трогает испорченный параметр, — и падает позже — на первом запросе, который трогает испорченный параметр, — и
падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен, падение выглядит дефектом кода, а не ошибкой деплоя. Ненулевой код нужен,
чтобы неудачный старт увидел супервизор: без него он неотличим от штатного чтобы неудачный старт увидел супервизор: без него он неотличим от штатного
@@ -254,7 +254,7 @@ prefix: CONF
| CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит | | CONF-18.4 | строки, которые парсятся во что-то (длительности, зоны, URL, идентификаторы сущностей), реально парсятся | при первом обращении к тому, что за ними стоит |
| CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию | | CONF-18.5 | включённые секции консистентны: у включённой интеграции заданы все обязательные поля | при первом вызове интеграции, часто по расписанию |
**Почему.** Список минимальный и собран по одному признаку — правый **ПОЧЕМУ.** Список минимальный и собран по одному признаку — правый
столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже столбец: каждая из этих ошибок иначе всплывает там, где связь с деплоем уже
потеряна, и диагностируется как дефект приложения. Проверка на старте потеряна, и диагностируется как дефект приложения. Проверка на старте
сводит их все к одному моменту и одному сообщению. сводит их все к одному моменту и одному сообщению.
@@ -264,7 +264,7 @@ prefix: CONF
**ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним **ДОЛЖЕН.** Валидация собирает все найденные проблемы и выводит их одним
списком, а не падает на первой. списком, а не падает на первой.
**Почему.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько **ПОЧЕМУ.** Иначе цикл «запуск — одна ошибка — правка» повторяется столько
раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и раз, сколько в конфиге ошибок, а на сервере каждая итерация — это ещё и
деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля деплой. Криво отрендеренный шаблон обычно ломает не одно поле, а все поля
одного источника: разом они читаются как одна причина, по одной — как одного источника: разом они читаются как одна причина, по одной — как
@@ -280,7 +280,7 @@ prefix: CONF
| CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» | | CONF-21.1 | несекретное | имя поля, ожидание и полученное значение: «ожидалось 0–1, получено `1.5`» |
| CONF-21.2 | секретное | имя поля и суть нарушения, без значения | | CONF-21.2 | секретное | имя поля и суть нарушения, без значения |
**Почему.** Сообщение без значения отправляет читателя в файл — сличать **ПОЧЕМУ.** Сообщение без значения отправляет читателя в файл — сличать
глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд» глазами каждую строку списка CONF-19; ошибки вида «секунды вместо миллисекунд»
или пробел в конце значения из такого сообщения не читаются вовсе. Значение или пробел в конце значения из такого сообщения не читаются вовсе. Значение
секретного поля при этом печатать некуда: вывод старта уходит в лог секретного поля при этом печатать некуда: вывод старта уходит в лог
+10 -10
View File
@@ -6,9 +6,9 @@ prefix: KEYS
Как выбираются и как выглядят первичные ключи сущностей. Как выбираются и как выглядят первичные ключи сущностей.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -27,7 +27,7 @@ prefix: KEYS
который порождает приложение, — во **всех** таблицах, включая те, что который порождает приложение, — во **всех** таблицах, включая те, что
снаружи не адресуются. снаружи не адресуются.
**Почему.** Ветвления здесь нет намеренно, хотя напрашивается: «эту **ПОЧЕМУ.** Ветвления здесь нет намеренно, хотя напрашивается: «эту
сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности сущность снаружи не адресуют, ей хватит целого числа». Внутренние сущности
имеют привычку становиться внешними — и тогда целочисленный идентификатор имеют привычку становиться внешними — и тогда целочисленный идентификатор
утекает в URL задним числом, а миграция ключа на живых данных стоит утекает в URL задним числом, а миграция ключа на живых данных стоит
@@ -47,7 +47,7 @@ prefix: KEYS
**ДОЛЖЕН.** Значение ключа известно до вставки строки. **ДОЛЖЕН.** Значение ключа известно до вставки строки.
**Почему.** Идентификатор нужен раньше, чем база ответит: его пишут в лог **ПОЧЕМУ.** Идентификатор нужен раньше, чем база ответит: его пишут в лог
начатой операции, кладут в связанные записи одной транзакции и возвращают начатой операции, кладут в связанные записи одной транзакции и возвращают
клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid` клиенту. Генерация на стороне базы вынуждает либо ждать `last_insert_rowid`
и достраивать связи вторым проходом, либо иметь два источника истины о и достраивать связи вторым проходом, либо иметь два источника истины о
@@ -58,7 +58,7 @@ prefix: KEYS
**ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает. **ДОЛЖЕН.** Один модуль порождает идентификаторы, он же их разбирает.
Самодельных генераторов и парсеров в коде нет. Самодельных генераторов и парсеров в коде нет.
**Почему.** Нормализация регистра (KEYS-4) и проверка формата обязаны **ПОЧЕМУ.** Нормализация регистра (KEYS-4) и проверка формата обязаны
применяться ко всем идентификаторам без исключения. Любая вторая точка применяться ко всем идентификаторам без исключения. Любая вторая точка
входа рано или поздно окажется той, где нормализацию забыли, — и дефект входа рано или поздно окажется той, где нормализацию забыли, — и дефект
проявится не там, где создан. проявится не там, где создан.
@@ -67,7 +67,7 @@ prefix: KEYS
**ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре. **ДОЛЖЕН.** Идентификаторы порождаются и хранятся в нижнем регистре.
**Почему.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не **ПОЧЕМУ.** Сравнение строк в базе обычно побайтовое, поэтому регистр — не
косметика, а корректность поиска. Спецификация ULID канонизирует **верхний** косметика, а корректность поиска. Спецификация ULID канонизирует **верхний**
регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3) регистр, и библиотеки по умолчанию отдают именно его: без единой точки (KEYS-3)
разный регистр появится в базе сам собой. разный регистр появится в базе сам собой.
@@ -83,7 +83,7 @@ prefix: KEYS
| KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу | | KEYS-5.1 | путь или query URL | «не найдено» без обращения к хранилищу |
| KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» | | KEYS-5.2 | собственная форма, данные кнопки | «некорректный ввод» либо «элемент устарел» |
**Почему.** Синтаксически невалидное значение не может соответствовать **ПОЧЕМУ.** Синтаксически невалидное значение не может соответствовать
записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на записи, поэтому поход в базу за ним — заведомо холостой; отсекая его на
границе, мы дёшево снимаем целый класс мусорного трафика. границе, мы дёшево снимаем целый класс мусорного трафика.
@@ -106,7 +106,7 @@ prefix: KEYS
**ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный **ДОПУСКАЕТСЯ.** Когда ключ есть по природе данных, отдельный
сгенерированный идентификатор не заводится. сгенерированный идентификатор не заводится.
**Почему.** Суррогат поверх естественного ключа создаёт второй способ **ПОЧЕМУ.** Суррогат поверх естественного ключа создаёт второй способ
адресовать ту же строку — а значит, возможность рассинхрона между ними и адресовать ту же строку — а значит, возможность рассинхрона между ними и
лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной лишний вопрос «по какому из них искать» на каждом запросе. Дополнительной
информации он не несёт. информации он не несёт.
@@ -117,7 +117,7 @@ prefix: KEYS
задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же задание, корреляционный ключ), порождаются тем же модулем (KEYS-3) и в том же
формате. формате.
**Почему.** Единый формат делает работающим главный побочный эффект **ПОЧЕМУ.** Единый формат делает работающим главный побочный эффект
строковых идентификаторов: `grep` по голому значению собирает все строковых идентификаторов: `grep` по голому значению собирает все
упоминания сущности в логах независимо от имени поля. Второй формат упоминания сущности в логах независимо от имени поля. Второй формат
идентификаторов эту возможность отменяет ровно для тех записей, где она идентификаторов эту возможность отменяет ровно для тех записей, где она
+16 -16
View File
@@ -7,9 +7,9 @@ prefix: TIME
Как приложение записывает моменты и длительности: в каком формате, откуда Как приложение записывает моменты и длительности: в каком формате, откуда
берётся значение и где появляется не-UTC. берётся значение и где появляется не-UTC.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -26,7 +26,7 @@ prefix: TIME
**ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z` **ДОЛЖЕН.** Момент времени записывается как `2026-06-28T11:23:45Z`
одинаково в хранении, логах, API и обмене с внешними системами. одинаково в хранении, логах, API и обмене с внешними системами.
**Почему.** Разные форматы в разных слоях требуют преобразования на каждой **ПОЧЕМУ.** Разные форматы в разных слоях требуют преобразования на каждой
границе, а ошибка в таком преобразовании не видна сразу: она всплывает через границе, а ошибка в таком преобразовании не видна сразу: она всплывает через
полгода, на переходе на летнее время, когда реальное смещение перестаёт полгода, на переходе на летнее время, когда реальное смещение перестаёт
совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z` совпадать с тем, которое подразумевалось при написании кода. UTC с явным `Z`
@@ -37,7 +37,7 @@ prefix: TIME
**ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина **ДОЛЖЕН.** Внутри одной колонки БД и внутри одного потока логов длина
строки времени одна и от записи к записи не плавает. строки времени одна и от записи к записи не плавает.
**Почему.** Лексикографическая сортировка совпадает с хронологией только **ПОЧЕМУ.** Лексикографическая сортировка совпадает с хронологией только
среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя среди строк одинаковой длины: `…00.123Z` сортируется раньше `…00.12Z`, хотя
произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по произошло позже. Ради этого ширина и фиксируется — `ORDER BY created_at` по
текстовому полю обязан давать порядок событий. Плавающая ширина (типичный текстовому полю обязан давать порядок событий. Плавающая ширина (типичный
@@ -49,7 +49,7 @@ prefix: TIME
**ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность. **ДОПУСКАЕТСЯ.** У колонки БД и у потока логов каждая своя точность.
**Почему.** Квантор в TIME-2 — на носитель, а не на приложение, потому что **ПОЧЕМУ.** Квантор в TIME-2 — на носитель, а не на приложение, потому что
строки разных носителей между собой не сравниваются: сортировка идёт внутри строки разных носителей между собой не сравниваются: сортировка идёт внутри
колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался колонки, чтение — внутри потока. Явное разрешение нужно, чтобы TIME-2 не читался
как «одна точность на всё приложение»: от подгонки формата логов под формат как «одна точность на всё приложение»: от подгонки формата логов под формат
@@ -61,7 +61,7 @@ prefix: TIME
**НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной **НЕ ДОЛЖЕН.** Ни в базе, ни в логах, ни в JSON API нет меток в локальной
зоне. зоне.
**Почему.** Метка без зоны неинтерпретируема вне процесса, который её **ПОЧЕМУ.** Метка без зоны неинтерпретируема вне процесса, который её
записал: чтобы понять, какому моменту она соответствует, читателю нужно записал: чтобы понять, какому моменту она соответствует, читателю нужно
знать настройки чужой машины на момент записи. И даже зная их, он не знать настройки чужой машины на момент записи. И даже зная их, он не
разберёт час перехода на зимнее время: этот час идёт дважды, две записи разберёт час перехода на зимнее время: этот час идёт дважды, две записи
@@ -73,7 +73,7 @@ prefix: TIME
долями секунды принимается от внешней системы и приводится к каноническому долями секунды принимается от внешней системы и приводится к каноническому
виду (TIME-1) в точке разбора (TIME-5). виду (TIME-1) в точке разбора (TIME-5).
**Почему.** Канонический вид — обязательство нашего писателя, а не **ПОЧЕМУ.** Канонический вид — обязательство нашего писателя, а не
контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же контракт, наложенный на внешние системы: `…14:23:45+03:00` называет тот же
момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со момент, что `…11:23:45Z`, и отклонять его — значит ломать интеграцию со
стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение стороной, которая стандарт соблюла. Пропущенное же как есть, такое значение
@@ -88,7 +88,7 @@ prefix: TIME
**ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает **ДОЛЖЕН.** Один модуль отдаёт текущий момент, он же форматирует и разбирает
метки; прямые вызовы часов по коду не разбросаны. метки; прямые вызовы часов по коду не разбросаны.
**Почему.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех **ПОЧЕМУ.** Формат, зона (TIME-1) и ширина (TIME-2) обязаны выполняться для всех
меток без исключения, а каждый прямой вызов часов заводит ещё одно место, меток без исключения, а каждый прямой вызов часов заводит ещё одно место,
где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в где их можно не соблюсти. Промах такого вызова проявляется не в коде, а в
данных, и обнаруживается, когда испорченных записей уже накопилось. данных, и обнаруживается, когда испорченных записей уже накопилось.
@@ -98,7 +98,7 @@ prefix: TIME
**НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом. **НЕ ДОЛЖЕН.** Колонки времени не имеют `DEFAULT` с текущим моментом.
**Почему.** Дефолт превращает забытую вставку `created_at` в тихо работающий **ПОЧЕМУ.** Дефолт превращает забытую вставку `created_at` в тихо работающий
код: значение появляется, но приходит от сервера БД — то есть с других часов код: значение появляется, но приходит от сервера БД — то есть с других часов
и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка и в формате, который выбирала не единая точка (TIME-5). Без дефолта та же ошибка
падает громко и чинится в момент написания, а не при разборе расхождения падает громко и чинится в момент написания, а не при разборе расхождения
@@ -110,7 +110,7 @@ prefix: TIME
**ДОЛЖЕН.** Длительность операции записывается числом (обычно **ДОЛЖЕН.** Длительность операции записывается числом (обычно
миллисекундами) в поле вида `duration_ms`. миллисекундами) в поле вида `duration_ms`.
**Почему.** Метка отвечает на вопрос «когда», длительность — на «сколько». **ПОЧЕМУ.** Метка отвечает на вопрос «когда», длительность — на «сколько».
Пара меток заставляет каждого потребителя знать, какие именно две из них Пара меток заставляет каждого потребителя знать, какие именно две из них
образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в образуют операцию, и вычитать их самому — в запросе, в дашборде и глазами в
логе; число сравнивается, агрегируется и попадает в перцентили без этого логе; число сравнивается, агрегируется и попадает в перцентили без этого
@@ -121,7 +121,7 @@ prefix: TIME
**СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет. **СЛЕДУЕТ.** Замер живёт там же, где вызов, границы которого он измеряет.
**Почему.** Обе границы операции видит только этот слой: замер уровнем выше **ПОЧЕМУ.** Обе границы операции видит только этот слой: замер уровнем выше
приписывает операции чужие накладные расходы, уровнем ниже — теряет часть приписывает операции чужие накладные расходы, уровнем ниже — теряет часть
вызова. В обоих случаях число остаётся правдоподобным и потому не вызова. В обоих случаях число остаётся правдоподобным и потому не
оспаривается, хотя отвечает не на тот вопрос, который к нему задают. оспаривается, хотя отвечает не на тот вопрос, который к нему задают.
@@ -135,7 +135,7 @@ prefix: TIME
| TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) | | TIME-9.1 | момент события | стенные часы через единую точку (TIME-5) |
| TIME-9.2 | длительность операции | монотонные часы процесса | | TIME-9.2 | длительность операции | монотонные часы процесса |
**Почему.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда **ПОЧЕМУ.** Стенные часы подводит NTP: они могут шагнуть назад, и тогда
интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд — интервал, посчитанный вычитанием, выйдет отрицательным, а при шаге вперёд —
правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для правдоподобным, но выдуманным. Монотонные часы, наоборот, не годятся для
меток: их ноль произволен и не переживает перезапуск процесса, так что вне меток: их ноль произволен и не переживает перезапуск процесса, так что вне
@@ -148,7 +148,7 @@ prefix: TIME
**ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не **ДОЛЖЕН.** Преобразование в зону пользователя происходит при выводе и не
проникает в хранение, сортировку и логи. проникает в хранение, сортировку и логи.
**Почему.** Как только конвертация уходит вглубь, результат вычислений **ПОЧЕМУ.** Как только конвертация уходит вглубь, результат вычислений
начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт начинает зависеть от настроек конкретного зрителя: одна и та же выборка даёт
разные группировки, а порядок записей перестаёт быть общим для всех. Ещё разные группировки, а порядок записей перестаёт быть общим для всех. Ещё
хуже, что при конвертации в нескольких слоях её легко выполнить дважды — хуже, что при конвертации в нескольких слоях её легко выполнить дважды —
@@ -160,7 +160,7 @@ prefix: TIME
**ДОЛЖЕН.** Значение приходит из конфигурации, значение по умолчанию — **ДОЛЖЕН.** Значение приходит из конфигурации, значение по умолчанию —
`UTC`. `UTC`.
**Почему.** Зашитая в код зона превращает переезд или второго пользователя в **ПОЧЕМУ.** Зашитая в код зона превращает переезд или второго пользователя в
другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано другом поясе в правку кода и релиз. Значение по умолчанию `UTC` выбрано
потому, что оно не притворяется настроенным: показанное время совпадает с потому, что оно не притворяется настроенным: показанное время совпадает с
тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается тем, что лежит в базе и в логах, поэтому несовпадение с ожиданиями читается
@@ -171,7 +171,7 @@ prefix: TIME
**ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с **ДОЛЖЕН.** «Сегодня», «за месяц» и прочие календарные границы считаются с
явно переданной зоной, а не с системной зоной процесса. явно переданной зоной, а не с системной зоной процесса.
**Почему.** Системная зона разная на ноутбуке разработчика и в контейнере на **ПОЧЕМУ.** Системная зона разная на ноутбуке разработчика и в контейнере на
сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта: сервере, поэтому граница суток уезжает, а вместе с ней — содержимое отчёта:
расхождение не воспроизводится там, где его заметили, и объясняется средой, расхождение не воспроизводится там, где его заметили, и объясняется средой,
а не кодом. Явно переданная зона делает результат функцией от аргументов. а не кодом. Явно переданная зона делает результат функцией от аргументов.
+18 -18
View File
@@ -8,9 +8,9 @@ extends: arch/config.md
Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы Как базовый слой выглядит в Go-приложении: формат, загрузчик, границы
запрета на окружение. запрета на окружение.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а Секретов Go-специфика не касается: они приходят из деплоя уже в файле, а
проверка их непустоты идёт вместе с остальной валидацией — как описано в проверка их непустоты идёт вместе с остальной валидацией — как описано в
@@ -22,7 +22,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Конфиг — файл TOML. **ДОЛЖЕН.** Конфиг — файл TOML.
**Почему.** Базовая конвенция оставляет формат за стеком, и этот выбор **ПОЧЕМУ.** Базовая конвенция оставляет формат за стеком, и этот выбор
делается один раз на язык, а не в каждом приложении: разные форматы в делается один раз на язык, а не в каждом приложении: разные форматы в
соседних сервисах означают разные загрузчики, разные шаблоны рендера соседних сервисах означают разные загрузчики, разные шаблоны рендера
конфига в деплое и разное поведение при синтаксической ошибке. TOML при конфига в деплое и разное поведение при синтаксической ошибке. TOML при
@@ -35,7 +35,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в **ДОЛЖЕН.** Чтение файла, наложение умолчаний и проверки живут в
`internal/config`; наружу пакет отдаёт готовую структуру `Config`. `internal/config`; наружу пакет отдаёт готовую структуру `Config`.
**Почему.** Пока значение не покинуло пакет, оно может быть невалидным; **ПОЧЕМУ.** Пока значение не покинуло пакет, оно может быть невалидным;
после — уже нет, и это единственная граница, на которой такое утверждение после — уже нет, и это единственная граница, на которой такое утверждение
проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос проверяемо. Валидация, размазанная по потребителям, отвечает на вопрос
«проверено ли это поле» только чтением всех вызывающих, часть полей «проверено ли это поле» только чтением всех вызывающих, часть полей
@@ -48,7 +48,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из **ДОЛЖЕН.** Конфиг представлен одним значением типа `Config`, собранным из
под-структур по секциям. под-структур по секциям.
**Почему.** Один корень даёт одну точку, после которой конфиг проверен **ПОЧЕМУ.** Один корень даёт одну точку, после которой конфиг проверен
целиком, и дальше передаётся как обычный аргумент. Несколько независимых целиком, и дальше передаётся как обычный аргумент. Несколько независимых
структур конфига означают несколько загрузок и вопрос «какая из них уже структур конфига означают несколько загрузок и вопрос «какая из них уже
провалидирована» на каждом использовании; связанные между собой поля провалидирована» на каждом использовании; связанные между собой поля
@@ -59,7 +59,7 @@ extends: arch/config.md
**СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML. **СЛЕДУЕТ.** Имя под-структуры совпадает с именем секции TOML.
**Почему.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт **ПОЧЕМУ.** Имя секции из файла — то, с чего начинают, когда конфиг ведёт
себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит себя не так, как ожидали. При совпадении имён поиск по нему сразу приводит
в код и обратно; при расхождении связь между полем файла и полем структуры в код и обратно; при расхождении связь между полем файла и полем структуры
восстанавливается чтением тегов, и проделывать это приходится для каждой восстанавливается чтением тегов, и проделывать это приходится для каждой
@@ -70,7 +70,7 @@ extends: arch/config.md
**ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл **ДОЛЖЕН.** Умолчания собраны в функции `Default()`, разобранный файл
накладывается поверх. накладывается поверх.
**Почему.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой **ПОЧЕМУ.** Нулевое значение в Go неотличимо от «поле не задано»: нулевой
таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание, таймаут и отсутствующий таймаут — один и тот же `0`. Умолчание,
подставленное по месту использования (`if x == 0 { x = … }`), поэтому не подставленное по месту использования (`if x == 0 { x = … }`), поэтому не
видно ни целиком, ни из образца, и два потребителя одного поля со временем видно ни целиком, ни из образца, и два потребителя одного поля со временем
@@ -83,7 +83,7 @@ extends: arch/config.md
путь переопределяет флаг `--config=path`, образец рядом — путь переопределяет флаг `--config=path`, образец рядом —
`config.example.toml`. `config.example.toml`.
**Почему.** Фиксированное имя и переопределение из командной строки требует **ПОЧЕМУ.** Фиксированное имя и переопределение из командной строки требует
базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во базовая конвенция, но конкретные имена она не задаёт. Одинаковые имена во
всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску всех сервисах означают, что unit-файл, `docker run` и инструкция по запуску
пишутся, не открывая код приложения. Соседство `config.toml` и пишутся, не открывая код приложения. Соседство `config.toml` и
@@ -102,7 +102,7 @@ func (d *Duration) UnmarshalText(b []byte) error { … }
func (d Duration) Std() time.Duration { } func (d Duration) Std() time.Duration { }
``` ```
**Почему.** `time.Duration` — это `int64`, и TOML разбирает его в голое **ПОЧЕМУ.** `time.Duration` — это `int64`, и TOML разбирает его в голое
число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не число: в конфиге остаётся `poll_interval = 5000`, о котором читатель не
знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз знает, секунды это, миллисекунды или наносекунды. Ошибка в тысячу раз
проходит и разбор, и проверку диапазона, а проявляется нагрузкой или проходит и разбор, и проверку диапазона, а проявляется нагрузкой или
@@ -118,7 +118,7 @@ func (d Duration) Std() time.Duration { … }
**НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения. **НЕ ДОЛЖЕН.** Значения конфигурации не берутся из переменных окружения.
**Почему.** Второй канал конфигурации — то, против чего написана базовая **ПОЧЕМУ.** Второй канал конфигурации — то, против чего написана базовая
конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого конвенция, но в Go он ещё и бесследный: `os.Getenv` вызывается из любого
пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию. пакета, не появляется ни в `Config`, ни в образце и не проходит валидацию.
Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как Заведённый так параметр нельзя ни увидеть в конфиге, ни узнать иначе как
@@ -134,7 +134,7 @@ func (d Duration) Std() time.Duration { … }
^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$ ^os\.(Getenv|LookupEnv|Environ|ExpandEnv)$
``` ```
**Почему.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое, **ПОЧЕМУ.** `LookupEnv`, `Environ` и `ExpandEnv` читают ровно то же самое,
поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет поэтому паттерн на одно имя оставляет три обхода. Хуже, что оставляет
незаметно: правило числится механизированным, и глазами его больше никто не незаметно: правило числится механизированным, и глазами его больше никто не
проверяет. проверяет.
@@ -155,7 +155,7 @@ func (d Duration) Std() time.Duration { … }
| GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда | | GCFG-10.1 | тесты, включая интеграционные | допустимо: креды и адреса внешних сервисов взять больше неоткуда |
| GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение | | GCFG-10.2 | Go-рантайм и ОС, а не наш код (`GOMEMLIMIT`, `GOMAXPROCS`, `GODEBUG`, `TZ`) | вне запрета: значение читает не приложение |
**Почему.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не **ПОЧЕМУ.** GCFG-8 — про конфигурацию приложения; расширенный до «никто не
трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает трогает окружение», он запрещает то, чем не управляет: `GOMEMLIMIT` читает
рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их рантайм, а тесту креды внешнего сервиса взять неоткуда — в репозиторий их
не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один не положишь, а отдельный конфиг тестов пришлось бы заводить как ещё один
@@ -167,7 +167,7 @@ func (d Duration) Std() time.Duration { … }
**ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`. **ДОЛЖЕН.** Исходящий прокси приходит полем конфига и явным `Transport`.
**Почему.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а **ПОЧЕМУ.** `HTTP_PROXY`/`HTTPS_PROXY` формально читает не наш код, а
дефолтный `http.Transport` — но читает он их от имени приложения и меняет дефолтный `http.Transport` — но читает он их от имени приложения и меняет
поведение приложения, а не рантайма. Оставленные окружению, они дают ровно поведение приложения, а не рантайма. Оставленные окружению, они дают ровно
тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут тот второй канал, который запрещает GCFG-8, и притом самый неудобный: маршрут
@@ -180,7 +180,7 @@ func (d Duration) Std() time.Duration { … }
**ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна **ДОЛЖЕН.** Проверки не прерываются на первой неудаче, результат — одна
ошибка, собранная `errors.Join`. ошибка, собранная `errors.Join`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
перезапусков по одному полю за раз, причём каждый следующий запуск перезапусков по одному полю за раз, причём каждый следующий запуск
обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя обнаруживает ещё одно. `errors.Join` даёт разом весь список, не требуя
своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой своего типа ошибки, и `errors.Is`/`errors.As` продолжают работать по каждой
@@ -190,7 +190,7 @@ func (d Duration) Std() time.Duration { … }
**ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации. **ДОЛЖЕН.** IANA-зона из конфига загружается на старте, в общей валидации.
**Почему.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно **ПОЧЕМУ.** Проверить имя зоны нечем, кроме загрузки: оно валидно ровно
тогда, когда база зон его знает, и никакая проверка формата не отличит тогда, когда база зон его знает, и никакая проверка формата не отличит
`Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка `Europe/Moscow` от `Europe/Moskow`. Без загрузки на старте опечатка
доживает до первого форматирования времени — то есть до рантайма, мимо доживает до первого форматирования времени — то есть до рантайма, мимо
@@ -201,7 +201,7 @@ fail-fast (GCFG-15).
**ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном **ДОЛЖЕН.** Импорт `_ "time/tzdata"` стоит в `main`, а не в библиотечном
пакете. пакете.
**Почему.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому **ПОЧЕМУ.** Импорт в библиотеке навязывает ~450 КБ zoneinfo каждому
импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или импортёру, включая тех, кому зоны не нужны: выбор «встраивать базу или
полагаться на системную» принадлежит собираемой программе. Со встроенной полагаться на системную» принадлежит собираемой программе. Со встроенной
базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без базой ошибка `LoadLocation` (GCFG-13) означает ровно одно — битое имя зоны; без
@@ -213,7 +213,7 @@ zoneinfo, а сообщение указывает не на ту причину
**ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до **ДОЛЖЕН.** `main` пишет `slog` уровня `ERROR` и вызывает `os.Exit(1)` до
старта серверов и воркеров. старта серверов и воркеров.
**Почему.** Выход именно из `main`: `os.Exit` в библиотечном пакете не **ПОЧЕМУ.** Выход именно из `main`: `os.Exit` в библиотечном пакете не
оставляет вызывающему возможности ни залогировать причину, ни дописать оставляет вызывающему возможности ни залогировать причину, ни дописать
контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно контекст, и отложенные `defer` при нём не выполняются вовсе. Выход именно
до старта воркеров: горутина, поднятая раньше валидации, успевает сходить до старта воркеров: горутина, поднятая раньше валидации, успевает сходить
+12 -12
View File
@@ -7,9 +7,9 @@ extends: arch/db-identifiers.md
Как базовый слой выглядит в Go-приложении. Как базовый слой выглядит в Go-приложении.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
Единая точка из `KEYS-3` — пакет `internal/ident`: он Единая точка из `KEYS-3` — пакет `internal/ident`: он
порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает порождает идентификаторы (`NewID`, `NewIDAt`) и он же их разбирает
@@ -22,7 +22,7 @@ extends: arch/db-identifiers.md
**ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета **ДОЛЖЕН.** Идентификаторы порождаются и разбираются функциями пакета
`internal/ident`; других генераторов и парсеров id в коде нет. `internal/ident`; других генераторов и парсеров id в коде нет.
**Почему.** Реализация `KEYS-3` и `KEYS-4`. Вызов **ПОЧЕМУ.** Реализация `KEYS-3` и `KEYS-4`. Вызов
ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не ULID-библиотеки — одна строка, доступная из любого пакета, и на ревью он не
выглядит нарушением: значение получается валидное, просто мимо нормализации выглядит нарушением: значение получается валидное, просто мимо нормализации
регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт регистра. Отдельный пакет переводит запрет в проверяемое свойство — импорт
@@ -35,7 +35,7 @@ ULID-библиотеки — одна строка, доступная из л
**ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()` **ДОЛЖЕН.** Новая сущность получает значение PK вызовом `ident.NewID()`
внутри `Create`-метода слоя store. внутри `Create`-метода слоя store.
**Почему.** `KEYS-2` требует, чтобы значение было **ПОЧЕМУ.** `KEYS-2` требует, чтобы значение было
известно до вставки, но не говорит, кто его присваивает. Store — последний известно до вставки, но не говорит, кто его присваивает. Store — последний
слой, через который проходят все пути создания строки, включая импорт, слой, через который проходят все пути создания строки, включая импорт,
фоновые задания и тесты. Генерация выше по стеку делает присвоение фоновые задания и тесты. Генерация выше по стеку делает присвоение
@@ -48,7 +48,7 @@ ULID-библиотеки — одна строка, доступная из л
**ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся **ДОЛЖЕН.** Идентификатор батча, задания или корреляционный ключ создаётся
вызовом `ident.NewID()` там, где операция начинается. вызовом `ident.NewID()` там, где операция начинается.
**Почему.** Смысл такого идентификатора (`KEYS-7`) — **ПОЧЕМУ.** Смысл такого идентификатора (`KEYS-7`) —
сшивать записи лога всей операции. Созданный ниже по стеку или в момент сшивать записи лога всей операции. Созданный ниже по стеку или в момент
первой записи в базу, он не покрывает начальные шаги — а именно они нужны, первой записи в базу, он не покрывает начальные шаги — а именно они нужны,
когда операция упала до того, как что-либо записала: без общего ключа эти когда операция упала до того, как что-либо записала: без общего ключа эти
@@ -59,7 +59,7 @@ ULID-библиотеки — одна строка, доступная из л
**ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в **ДОЛЖЕН.** Идентификаторы, проставляемые существующим строкам в
Go-миграции, порождаются с историческим временем строки, а не с текущим. Go-миграции, порождаются с историческим временем строки, а не с текущим.
**Почему.** Сортировка id тогда сохраняет историческую хронологию, а не **ПОЧЕМУ.** Сортировка id тогда сохраняет историческую хронологию, а не
момент прогона миграции. Иначе все затронутые строки получают метку одного момент прогона миграции. Иначе все затронутые строки получают метку одного
момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id` момента, склеиваются в нём и встают в порядке обхода — `ORDER BY id`
начинает врать ровно на том массиве данных, который старше всего. начинает врать ровно на том массиве данных, который старше всего.
@@ -71,7 +71,7 @@ Go-миграции, порождаются с историческим врем
**ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или **ДОЛЖЕН.** `ident.Parse()` вызывается в обработчике HTTP-роута, формы или
callback'а бота — раньше, чем идентификатор попадёт в store. callback'а бота — раньше, чем идентификатор попадёт в store.
**Почему.** Реализация `KEYS-5`. Граница выбрана **ПОЧЕМУ.** Реализация `KEYS-5`. Граница выбрана
транспортная, потому что только на ней известен источник значения, от транспортная, потому что только на ней известен источник значения, от
которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от которого зависит реакция (GKEY-8): store видит одинаковую строку независимо от
того, пришла она из URL или из собственной формы, и ответить по-разному того, пришла она из URL или из собственной формы, и ответить по-разному
@@ -82,7 +82,7 @@ callback'а бота — раньше, чем идентификатор поп
**СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип **СЛЕДУЕТ.** Поля идентификаторов в доменных и store-структурах имеют тип
`string`. `string`.
**Почему.** Отдельный тип окупается только тогда, когда компилятор ловит им **ПОЧЕМУ.** Отдельный тип окупается только тогда, когда компилятор ловит им
ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и ошибку. От перепутывания двух идентификаторов одной семьи (`userID` и
`authorID`) он не спасает — оба будут одного типа, и различают их имена `authorID`) он не спасает — оба будут одного типа, и различают их имена
параметров. Зато он требует конверсий на каждой границе с sql-драйвером, параметров. Зато он требует конверсий на каждой границе с sql-драйвером,
@@ -93,7 +93,7 @@ json и шаблонами, то есть даёт цену без выгоды.
**ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые **ДОПУСКАЕТСЯ.** Когда в коде оказываются два вида идентификаторов, которые
можно перепутать, для них заводятся различимые типы. можно перепутать, для них заводятся различимые типы.
**Почему.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на **ПОЧЕМУ.** Явное разрешение нужно, чтобы GKEY-6 не читался как запрет на
типизацию навсегда. Условие названо ровно то, при котором тип начинает типизацию навсегда. Условие названо ровно то, при котором тип начинает
работать: пока все идентификаторы — `string`, подстановка одного вида работать: пока все идентификаторы — `string`, подстановка одного вида
вместо другого компилируется и обнаруживается только на данных. вместо другого компилируется и обнаруживается только на данных.
@@ -108,7 +108,7 @@ json и шаблонами, то есть даёт цену без выгоды.
| GKEY-8.1 | путь или query URL | 404 без обращения к store | | GKEY-8.1 | путь или query URL | 404 без обращения к store |
| GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») | | GKEY-8.2 | собственная форма, callback-данные кнопки | 400 либо понятное сообщение («кнопка устарела») |
**Почему.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах **ПОЧЕМУ.** Реализация `KEYS-5.1` и `KEYS-5.2` в терминах
HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи — HTTP-кодов. В случае GKEY-8.1 снаружи это неотличимо от несуществующей записи —
и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2 и хорошо: чужая или протухшая ссылка описывается так точно. В случае GKEY-8.2
значение сформировало само приложение, и невалидность означает баг значение сформировало само приложение, и невалидность означает баг
@@ -121,7 +121,7 @@ HTTP-кодов. В случае GKEY-8.1 снаружи это неотличи
**НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например **НЕ ДОЛЖЕН.** Обработчик не конструирует доменный sentinel (например
`ErrNotFound`), чтобы тут же сопоставить его со своим ответом. `ErrNotFound`), чтобы тут же сопоставить его со своим ответом.
**Почему.** Инверсия правила «трансляция у источника» из конвенции **ПОЧЕМУ.** Инверсия правила «трансляция у источника» из конвенции
`errors`. Sentinel — сообщение от слоя, который знает факт: `errors`. Sentinel — сообщение от слоя, который знает факт:
строка не найдена, потому что store её искал. Сфабрикованный транспортом, строка не найдена, потому что store её искал. Сфабрикованный транспортом,
он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли он утверждает непроверенное, и по типу ошибки перестаёт быть видно, был ли
+15 -15
View File
@@ -7,9 +7,9 @@ prefix: MIGR
Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в Как пишутся миграции и какие типы колонок выбираются для схемы SQLite в
Go-приложении. Go-приложении.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -25,7 +25,7 @@ Go-приложении.
**ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом — **ДОЛЖЕН.** Набор миграций репозитория применяется одним инструментом —
goose. goose.
**Почему.** Журнал применённых версий goose держит в самой базе **ПОЧЕМУ.** Журнал применённых версий goose держит в самой базе
(`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй (`goose_db_version`) и по нему решает, что ещё не накатывалось. Второй
инструмент заводит второй журнал: миграция, применённая одним, для другого инструмент заводит второй журнал: миграция, применённая одним, для другого
выглядит неприменённой, и попытка накатить её повторно упирается в уже выглядит неприменённой, и попытка накатить её повторно упирается в уже
@@ -37,7 +37,7 @@ goose.
**СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой **СЛЕДУЕТ.** Миграции хранятся рядом с кодом, который работает с этой
схемой. схемой.
**Почему.** Миграция и код, читающий схему, — одно изменение: колонка **ПОЧЕМУ.** Миграция и код, читающий схему, — одно изменение: колонка
появляется вместе с полем структуры и запросом. Лежащие в другом конце появляется вместе с полем структуры и запросом. Лежащие в другом конце
дерева миграции выпадают из поля зрения при правке store, и уезжает либо дерева миграции выпадают из поля зрения при правке store, и уезжает либо
код без миграции, либо миграция без кода; расходятся они на сервере, где код без миграции, либо миграция без кода; расходятся они на сервере, где
@@ -52,7 +52,7 @@ goose.
| MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл | | MIGR-3.1 | DDL: создание таблиц, индексы, изменение структуры | SQL-файл |
| MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) | | MIGR-3.2 | требует кода: генерация идентификаторов, backfill, перенос данных между формами | Go-миграция (`goose.AddMigrationContext`) |
**Почему.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст, **ПОЧЕМУ.** DDL ничего не вычисляет, и SQL-файл показывает ровно тот текст,
который уедет в базу; обёртка на Go вокруг него добавляет место, где можно который уедет в базу; обёртка на Go вокруг него добавляет место, где можно
ошибиться, не добавляя ничего к результату. ошибиться, не добавляя ничего к результату.
@@ -68,7 +68,7 @@ goose.
**НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией; **НЕ ДОЛЖЕН.** Откат схемы на сервере не выполняется down-миграцией;
ошибка исправляется новой миграцией вперёд. ошибка исправляется новой миграцией вперёд.
**Почему.** Down на сервере не возвращает прежнее состояние, а имитирует **ПОЧЕМУ.** Down на сервере не возвращает прежнее состояние, а имитирует
его: колонка, которую убрал up, восстанавливается пустой, а строки, его: колонка, которую убрал up, восстанавливается пустой, а строки,
записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом записанные уже по новой схеме, в старую форму не ложатся. Потеря при этом
происходит молча — миграция отчитывается об успехе. Исправление, приехавшее происходит молча — миграция отчитывается об успехе. Исправление, приехавшее
@@ -84,7 +84,7 @@ goose.
| MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное | | MIGR-5.1 | добавляет структуру: таблицу, колонку, индекс | пишется, убирает добавленное |
| MIGR-5.2 | необратимо преобразует данные | не пишется | | MIGR-5.2 | необратимо преобразует данные | не пишется |
**Почему.** Down — инструмент разработки, где ветку переключают туда-сюда, **ПОЧЕМУ.** Down — инструмент разработки, где ветку переключают туда-сюда,
и именно там он обязан действительно обращать up. Имитация опаснее и именно там он обязан действительно обращать up. Имитация опаснее
отсутствия: разработчик применяет её, получает схему прежней формы и отсутствия: разработчик применяет её, получает схему прежней формы и
продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий продолжает работу, не заметив, что колонка вернулась пустой. Отсутствующий
@@ -96,7 +96,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним **ДОЛЖЕН.** Изменение структуры и правка ER-схемы в спеках едут одним
изменением. изменением.
**Почему.** Диаграмму читают вместо DDL — в этом весь её смысл. **ПОЧЕМУ.** Диаграмму читают вместо DDL — в этом весь её смысл.
Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить Разошедшаяся с базой, она не бесполезна, а даёт неверный ответ, и заметить
это можно, только сверив её с миграциями, то есть проделав работу, которую это можно, только сверив её с миграциями, то есть проделав работу, которую
диаграмма экономит. Отложенное обновление не делается: изменение уже диаграмма экономит. Отложенное обновление не делается: изменение уже
@@ -112,7 +112,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT` **ДОЛЖЕН.** Поле-перечисление (`state`, `kind`, …) объявляется как `TEXT`
без `CHECK`-ограничения на список значений. без `CHECK`-ограничения на список значений.
**Почему.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной **ПОЧЕМУ.** `ALTER TABLE` в SQLite не умеет менять ограничения ни в одной
версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))` версии. Поэтому каждое новое значение перечисления в `CHECK (... IN (...))`
превращается из строки в коде в пересоздание таблицы по 12-шаговой превращается из строки в коде в пересоздание таблицы по 12-шаговой
процедуре, с копированием данных и восстановлением внешних ключей. процедуре, с копированием данных и восстановлением внешних ключей.
@@ -128,7 +128,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения **ДОЛЖЕН.** Колонка с меткой времени объявляется как `TEXT`, значения
пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной. пишутся как RFC 3339 в UTC с суффиксом `Z` и фиксированной шириной.
**Почему.** Типа даты в SQLite нет, поэтому единственное, что делает **ПОЧЕМУ.** Типа даты в SQLite нет, поэтому единственное, что делает
значения сравнимыми, — договорённость о формате; сам формат выбран не значения сравнимыми, — договорённость о формате; сам формат выбран не
здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется здесь, а конвенцией `time` (`TIME-1`, `TIME-2`). Текст в нём сортируется
лексикографически в том же порядке, что и лексикографически в том же порядке, что и
@@ -141,7 +141,7 @@ down останавливает сразу и заставляет пересо
**НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию **НЕ ДОЛЖЕН.** Колонка с меткой времени не получает значение по умолчанию
на уровне схемы. на уровне схемы.
**Почему.** Время ставит приложение, и умолчание в схеме заводит второй **ПОЧЕМУ.** Время ставит приложение, и умолчание в схеме заводит второй
источник этого значения: пропущенное приложением поле не падает, а тихо источник этого значения: пропущенное приложением поле не падает, а тихо
получает время сервера базы — расхождение обнаруживается по данным, а не получает время сервера базы — расхождение обнаруживается по данным, а не
по ошибке. по ошибке.
@@ -154,7 +154,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1. **ДОЛЖЕН.** Булево значение хранится как `INTEGER` 0/1.
**Почему.** Отдельного булева типа в SQLite нет, поэтому от разнобоя **ПОЧЕМУ.** Отдельного булева типа в SQLite нет, поэтому от разнобоя
колонку удерживает только договорённость о представлении. Цена ошибки колонку удерживает только договорённость о представлении. Цена ошибки
здесь несимметрична: строка `'true'` в булевом контексте приводится к здесь несимметрична: строка `'true'` в булевом контексте приводится к
**0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку **0**, то есть даёт противоположный ответ, а не пустую выборку и не ошибку
@@ -166,7 +166,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из **ДОЛЖЕН.** Колонка ключа объявляется как `TEXT`, значение приходит из
приложения. приложения.
**Почему.** Здесь конвенция схемы ничего не решает — она реализует решение, **ПОЧЕМУ.** Здесь конвенция схемы ничего не решает — она реализует решение,
принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там принятое конвенцией `db-identifiers` (`KEYS-1`, `KEYS-2`). Повторить там
ветвление или условие значило бы завести второй источник правды, и соседние ветвление или условие значило бы завести второй источник правды, и соседние
таблицы разъехались бы по разным ответам на один вопрос. таблицы разъехались бы по разным ответам на один вопрос.
@@ -179,7 +179,7 @@ down останавливает сразу и заставляет пересо
**ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая **ДОЛЖЕН.** Там, где первичный ключ всё-таки целочисленный — существующая
схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`. схема, миграция легаси-таблицы, — он объявляется с `AUTOINCREMENT`.
**Почему.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после **ПОЧЕМУ.** Без него SQLite выдаёт rowid как `max(rowid)+1`, поэтому после
удаления последней строки номер переиспользуется. Протухшая ссылка на удаления последней строки номер переиспользуется. Протухшая ссылка на
удалённую запись — из закладки, из чужой таблицы, из старого лога — молча удалённую запись — из закладки, из чужой таблицы, из старого лога — молча
наводится на другую сущность и возвращает правдоподобный, но чужой ответ. наводится на другую сущность и возвращает правдоподобный, но чужой ответ.
+29 -29
View File
@@ -8,9 +8,9 @@ prefix: GERR
**логировать** — в конвенции `logging` (коротко: лог один раз на доменной **логировать** — в конвенции `logging` (коротко: лог один раз на доменной
границе). границе).
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
Две границы, о которых говорят правила ниже: Две границы, о которых говорят правила ниже:
@@ -30,7 +30,7 @@ prefix: GERR
**ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и **ДОЛЖЕН.** Ошибки создаются и оборачиваются через `errors` и
`fmt.Errorf`; библиотеки со стек-трейсами не подключаются. `fmt.Errorf`; библиотеки со стек-трейсами не подключаются.
**Почему.** Стек и цепочка обёрток решают одну задачу — локализацию места. **ПОЧЕМУ.** Стек и цепочка обёрток решают одну задачу — локализацию места.
При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений При дисциплине «каждый слой добавляет свой контекст» (GERR-3) цепочка сообщений
локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт локализует не хуже, а стоит ничего: остальной контекст ошибки уже несёт
`slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки `slog`. Библиотека со стеками добавляет зависимость, собственный тип ошибки
@@ -45,7 +45,7 @@ prefix: GERR
**НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте **НЕ ДОЛЖЕН.** Пакет со стек-трейсами не заводится в отдельном месте
кодовой базы ради конкретной отладки. кодовой базы ради конкретной отладки.
**Почему.** В коде появляются два способа устроить ошибку, и вызывающий **ПОЧЕМУ.** В коде появляются два способа устроить ошибку, и вызывающий
перестаёт знать, какой перед ним: обёртки склеиваются по-разному, перестаёт знать, какой перед ним: обёртки склеиваются по-разному,
`errors.Is` работает не везде одинаково. Хуже второе: боль, снятая `errors.Is` работает не везде одинаково. Хуже второе: боль, снятая
локально, перестаёт накапливаться — а накопление и есть единственный локально, перестаёт накапливаться — а накопление и есть единственный
@@ -56,7 +56,7 @@ prefix: GERR
**ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с **ДОЛЖЕН.** Ошибка, возвращаемая на уровень выше, оборачивается с
контекстом: `fmt.Errorf("parse magnet: %w", err)`. контекстом: `fmt.Errorf("parse magnet: %w", err)`.
**Почему.** На этом держится GERR-1: цепочка заменяет стек ровно настолько, **ПОЧЕМУ.** На этом держится GERR-1: цепочка заменяет стек ровно настолько,
насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста, насколько слои в неё пишут. Слой, пробросивший ошибку без своего контекста,
стирает участок пути — по итоговому сообщению нельзя сказать, через какую стирает участок пути — по итоговому сообщению нельзя сказать, через какую
операцию ошибка прошла, и отладка «no such file» начинается с чтения всего операцию ошибка прошла, и отладка «no such file» начинается с чтения всего
@@ -72,7 +72,7 @@ prefix: GERR
| GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` | | GERR-4.1 | вызывающий может инспектировать причину (обычный случай) | `%w` |
| GERR-4.2 | причину сознательно не раскрываем | `%v` | | GERR-4.2 | причину сознательно не раскрываем | `%v` |
**Почему.** Возражение против дефолтного `%w` — «обёрнутая ошибка **ПОЧЕМУ.** Возражение против дефолтного `%w` — «обёрнутая ошибка
становится частью API» — относится к библиотекам с внешними потребителями. становится частью API» — относится к библиотекам с внешними потребителями.
Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт Сервис же — приложение: внешнего Go-API нет, весь код наш, контракт
меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает меняется вместе с вызывающими. Зато `%v` в середине цепочки обрывает
@@ -86,7 +86,7 @@ prefix: GERR
**НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю **НЕ ДОЛЖЕН.** `%v` не используется как средство не пустить внутреннюю
ошибку наружу. ошибку наружу.
**Почему.** Обрыв цепочки внутри кода не мешает тексту уехать наружу **ПОЧЕМУ.** Обрыв цепочки внутри кода не мешает тексту уехать наружу
целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`, целиком: наружу отдаёт внешняя граница, и если она отдаёт `err.Error()`,
детали утекут при любом глаголе. Подмена не решает задачу, ради которой детали утекут при любом глаголе. Подмена не решает задачу, ради которой
сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих. сделана, а плату берёт сразу — `errors.Is` ломается у всех вызывающих.
@@ -96,7 +96,7 @@ prefix: GERR
**СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error». **СЛЕДУЕТ.** Без точки в конце, без «failed to» и «error».
**Почему.** Цепочка склеивается в одну строку через `": "`, и обёртка **ПОЧЕМУ.** Цепочка склеивается в одну строку через `": "`, и обёртка
читается как «контекст: причина» — заглавные буквы и точки рвут эту строку читается как «контекст: причина» — заглавные буквы и точки рвут эту строку
на середине. Слова «failed» и «error» не несут информации: то, что перед на середине. Слова «failed» и «error» не несут информации: то, что перед
нами ошибка, известно из того, что это ошибка. Зато повторяются они на нами ошибка, известно из того, что это ошибка. Зато повторяются они на
@@ -106,7 +106,7 @@ prefix: GERR
**СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`. **СЛЕДУЕТ.** В обёртку идёт то, что делал слой: `"link target: %w"`.
**Почему.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something **ПОЧЕМУ.** Обёртка ценна ровно тем, что сужает место (GERR-3). «something
failed» не сужает ничего и при этом занимает в сообщении место, которое мог failed» не сужает ничего и при этом занимает в сообщении место, которое мог
бы занять единственный полезный здесь факт — имя операции. бы занять единственный полезный здесь факт — имя операции.
@@ -115,7 +115,7 @@ failed» не сужает ничего и при этом занимает в
**НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже: **НЕ СЛЕДУЕТ.** Обёртка не пересказывает то, что уже сказал уровень ниже:
`"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`. `"add to qbt: %w"`, а не `"add download failed: add to qbt failed: …"`.
**Почему.** Повтор удлиняет сообщение, не добавляя локализации: одно и то **ПОЧЕМУ.** Повтор удлиняет сообщение, не добавляя локализации: одно и то
же событие названо дважды. Читателю приходится проверять, не два ли это же событие названо дважды. Читателю приходится проверять, не два ли это
разных места в коде, — то есть заикание не просто бесполезно, оно стоит разных места в коде, — то есть заикание не просто бесполезно, оно стоит
времени при каждом чтении лога. времени при каждом чтении лога.
@@ -132,7 +132,7 @@ failed» не сужает ничего и при этом занимает в
возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для возникла: `sql.ErrNoRows``store.ErrNotFound` в слое store; то же для
HTTP-клиентов, файловой системы, внешних SDK. HTTP-клиентов, файловой системы, внешних SDK.
**Почему.** Иначе тип зависимости становится частью контракта всех слоёв **ПОЧЕМУ.** Иначе тип зависимости становится частью контракта всех слоёв
выше: чтобы отличить «нет записи», доменный код импортирует `database/sql` выше: чтобы отличить «нет записи», доменный код импортирует `database/sql`
и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не и сравнивает с его sentinel'ом. Замена хранилища или SDK правит тогда не
адаптер, а все ветвления в приложении — притом что снаружи адаптера адаптер, а все ветвления в приложении — притом что снаружи адаптера
@@ -148,7 +148,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
| GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` | | GERR-10.1 | ветвление по условию: нет записи, дубликат, неподдерживаемый источник | sentinel `var ErrNotFound = errors.New("not found")`, проверка `errors.Is` |
| GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` | | GERR-10.2 | данные ошибки: поле валидации, код, лимит | тип с полями и методом `Error()`, извлечение `errors.As` |
**Почему.** Sentinel — одно значение; сравнение с ним не зависит от **ПОЧЕМУ.** Sentinel — одно значение; сравнение с ним не зависит от
структуры ошибки и переживает добавление полей. Тип заводится ради данных, структуры ошибки и переживает добавление полей. Тип заводится ради данных,
и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой и тип без данных отвечает вызывающему ровно то же, что sentinel, но ценой
объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на объявления, `errors.As` и вопроса «сравнивать по типу или по значению» на
@@ -159,7 +159,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется. **НЕ ДОЛЖЕН.** Ветвление по содержимому `err.Error()` не используется.
**Почему.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают **ПОЧЕМУ.** Текст сообщения — не контракт: GERR-6–GERR-8 разрешают
переписывать его свободно. Правка формулировки в нижнем слое молча ломает переписывать его свободно. Правка формулировки в нижнем слое молча ломает
ветвление наверху, и компилятор этого не видит. Это то же самое, что ветвление наверху, и компилятор этого не видит. Это то же самое, что
публичный API из строки лога. публичный API из строки лога.
@@ -175,7 +175,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно **ДОЛЖЕН.** В лог уходит вся цепочка `%w` с контекстом; где и когда именно
— конвенция `logging`. — конвенция `logging`.
**Почему.** Цепочка — единственный носитель диагностики (GERR-1), и **ПОЧЕМУ.** Цепочка — единственный носитель диагностики (GERR-1), и
единственный канал, где её можно показать целиком, — тот, который видит единственный канал, где её можно показать целиком, — тот, который видит
владелец. Не записанная там, она не сохранится нигде: наружу идёт владелец. Не записанная там, она не сохранится нигде: наружу идёт
нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего. нейтральное сообщение (GERR-13), и восстанавливать причину будет не из чего.
@@ -185,7 +185,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не **ДОЛЖЕН.** Наружу идёт человекочитаемый текст по доменной ошибке, а не
`err.Error()` и не детали реализации (`database/sql`, пути, стек). `err.Error()` и не детали реализации (`database/sql`, пути, стек).
**Почему.** Внутренние детали пользователю нечитаемы, а владельцу не нужны **ПОЧЕМУ.** Внутренние детали пользователю нечитаемы, а владельцу не нужны
— у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена — у него есть лог (GERR-12). Зато они раскрывают устройство системы — имена
таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен, таблиц, пути на диске, версии зависимостей — тому, кто их знать не должен,
причём раскрывают именно в момент, когда что-то пошло не так. причём раскрывают именно в момент, когда что-то пошло не так.
@@ -196,7 +196,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
«При обработке загрузки произошла ошибка, download_id=…» вместо «произошла «При обработке загрузки произошла ошибка, download_id=…» вместо «произошла
ошибка». ошибка».
**Почему.** GERR-13 забирает у пользователя всю фактуру; без ключа его **ПОЧЕМУ.** GERR-13 забирает у пользователя всю фактуру; без ключа его
обращение звучит как «у меня что-то не работает», и владелец ищет запись в обращение звучит как «у меня что-то не работает», и владелец ищет запись в
логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной логе по времени и догадкам. Ключ соединяет нейтральный ответ с полной
ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже ошибкой в логе, не раскрывая наружу ничего сверх того, что пользователь уже
@@ -208,7 +208,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
задаётся один раз; транспорт без статусов (бот) берёт из него только задаётся один раз; транспорт без статусов (бот) берёт из него только
сообщение. сообщение.
**Почему.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте, **ПОЧЕМУ.** Иначе одна и та же ошибка отвечает по-разному в HTTP и в боте,
и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина и расхождение обнаруживается не как дефект, а как жалоба. Вторая причина
важнее: единственная точка — это место, куда механически дописывается новая важнее: единственная точка — это место, куда механически дописывается новая
ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде» ветвь (GERR-16). Маппинг, размазанный по хендлерам, требование «дописать везде»
@@ -219,7 +219,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и **ДОЛЖЕН.** Ожидаемый отказ (конфликт, валидация) заводится sentinel'ом и
добавляется в маппинг (GERR-15) тем же изменением. добавляется в маппинг (GERR-15) тем же изменением.
**Почему.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500 **ПОЧЕМУ.** Ветка `default` врёт в обе стороны: транспорт отдаёт 500
«внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает «внутренняя ошибка» на нормальный конфликт, а логирующая граница списывает
его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают его в `ERROR` вместо `DEBUG`. Второе хуже первого — штатные отказы начинают
шуметь в логе ровно там, где по нему ищут настоящие поломки. шуметь в логе ровно там, где по нему ищут настоящие поломки.
@@ -230,7 +230,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с наружу 500 и нейтральное «внутренняя ошибка», а в лог идёт `ERROR` с
признаком того, что маппинг её не знает. признаком того, что маппинг её не знает.
**Почему.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли **ПОЧЕМУ.** Непокрытая ошибка — не класс отказа, а дефект: ветвь забыли
завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда завести вопреки GERR-16. Адресат у неё владелец в смысле «надо чинить», отсюда
`ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус `ERROR` — уровень выбирается по адресату (конвенция `logging`). Статус
тоже не выбирается: известное пользовательское состояние лежало бы в тоже не выбирается: известное пользовательское состояние лежало бы в
@@ -256,7 +256,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
Появился второй зритель или публичный доступ к экрану состояния — Появился второй зритель или публичный доступ к экрану состояния —
поверхность стала публичным каналом, и на неё распространяется GERR-17.1. поверхность стала публичным каналом, и на неё распространяется GERR-17.1.
**Почему.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст **ПОЧЕМУ.** Транзиентный ответ читает тот, кто нажал кнопку: сырой текст
ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную ему ничего не объясняет, а владельцу не нужен — у него лог. Персистентную
диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот диагностику читает владелец, и она отвечает на вопрос «почему сломалась вот
эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла эта запись» через месяц, когда лог уже ротировался; нейтральное «произошла
@@ -269,7 +269,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ, **НЕ ДОЛЖЕН.** Токены, пароли и ключи не попадают ни в транзиентный ответ,
ни в персистентную диагностику; источник вычищается на границе клиента. ни в персистентную диагностику; источник вычищается на границе клиента.
**Почему.** Запрет абсолютен, потому что персистентная диагностика живёт в **ПОЧЕМУ.** Запрет абсолютен, потому что персистентная диагностика живёт в
БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию БД: уезжает в бэкапы, попадает в скриншоты и выгрузки и переживает ротацию
самого секрета. Вычистка на границе клиента — единственное место, где ещё самого секрета. Вычистка на границе клиента — единственное место, где ещё
известно, какие поля запроса секретны: дальше ошибка едет как текст, и известно, какие поля запроса секретны: дальше ошибка едет как текст, и
@@ -280,7 +280,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое **ДОЛЖЕН.** Персистентная диагностика не кладётся в доменное поле, которое
показывают пользователю. показывают пользователю.
**Почему.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей **ПОЧЕМУ.** Различие GERR-17.1 и GERR-17.2 держится на том, что у поверхностей
разные поля. Одно поле на оба назначения означает, что при первом же показе разные поля. Одно поле на оба назначения означает, что при первом же показе
записи наружу сырой текст уедет туда же — не по решению, а потому что поле записи наружу сырой текст уедет туда же — не по решению, а потому что поле
одно. одно.
@@ -292,7 +292,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и **ДОЛЖЕН.** Паникой отмечается нарушенный инвариант (баг программиста) и
ошибка инициализации, из которой нельзя стартовать. ошибка инициализации, из которой нельзя стартовать.
**Почему.** Паника не оставляет вызывающему выбора: обработать её на месте **ПОЧЕМУ.** Паника не оставляет вызывающему выбора: обработать её на месте
нельзя, можно только уронить единицу обработки. Это верный ответ, когда нельзя, можно только уронить единицу обработки. Это верный ответ, когда
состояние процесса перестало описываться кодом: работа с нарушенным состояние процесса перестало описываться кодом: работа с нарушенным
инвариантом опаснее падения, а сервис, стартовавший без обязательной инвариантом опаснее падения, а сервис, стартовавший без обязательной
@@ -303,7 +303,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети, **НЕ ДОЛЖЕН.** Паника не используется для управления потоком: нет сети,
плохой ввод, отсутствующая запись возвращаются как `error`. плохой ввод, отсутствующая запись возвращаются как `error`.
**Почему.** Сигнатура — единственное, что сообщает вызывающему о возможном **ПОЧЕМУ.** Сигнатура — единственное, что сообщает вызывающему о возможном
отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит отказе; отказ, брошенный паникой, из неё не виден, и компилятор не заставит
его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где его обработать. Дальше такая паника долетает до recover-границы (GERR-22), где
неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией неотличима от бага: штатный отказ попадает в лог со стеком и с интонацией
@@ -318,7 +318,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
| GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер | | GERR-22.1 | HTTP-хендлер | `net/http` восстанавливает панику сам и процесс не роняет; свой `recover` нужен, чтобы отдать контролируемый 500 и записать событие в `slog`, а не в stdlib-логгер |
| GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине | | GERR-22.2 | цикл обработки апдейтов бота, фоновый воркер | паника в горутине роняет процесс; `recover` ставится в той же горутине |
**Почему.** `recover` работает только в той горутине, где случилась паника, **ПОЧЕМУ.** `recover` работает только в той горутине, где случилась паника,
поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у поэтому «у нас есть recover в HTTP» не защищает воркер — граница нужна у
каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с каждой единицы отдельно. Без неё один плохой апдейт бота или одна запись с
неожиданным полем гасят весь сервис, включая части, к этой ошибке неожиданным полем гасят весь сервис, включая части, к этой ошибке
@@ -330,7 +330,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек. **ДОЛЖЕН.** Логирующий `recover` кладёт в запись стек.
**Почему.** Это единственное место, где стек нужен (GERR-1): у восстановленной **ПОЧЕМУ.** Это единственное место, где стек нужен (GERR-1): у восстановленной
паники цепочки `%w` нет вовсе. «index out of range» без стека не паники цепочки `%w` нет вовсе. «index out of range» без стека не
диагностируется в принципе — сообщение не называет ни файла, ни операции, диагностируется в принципе — сообщение не называет ни файла, ни операции,
по нему нельзя сказать даже, в каком пакете упало. по нему нельзя сказать даже, в каком пакете упало.
@@ -347,7 +347,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
| GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются | | GERR-26.1 | обработчик HTTP-запроса | ответ 500, если он ещё не начат; процесс и прочие запросы не затрагиваются |
| GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся | | GERR-26.2 | итерация цикла обработки — бот, воркер | цикл продолжается со следующего элемента, упавший элемент повторно не берётся |
**Почему.** Паника внутри обработки одного элемента почти всегда говорит о **ПОЧЕМУ.** Паника внутри обработки одного элемента почти всегда говорит о
баге в работе с данными этого элемента, а не о порче общего состояния, — баге в работе с данными этого элемента, а не о порче общего состояния, —
останавливать всё остальное не за что. Довод «let it crash» здесь работает останавливать всё остальное не за что. Довод «let it crash» здесь работает
не буквально: в OTP падает изолированный процесс под супервизором, а не узел не буквально: в OTP падает изолированный процесс под супервизором, а не узел
@@ -378,7 +378,7 @@ HTTP-клиентов, файловой системы, внешних SDK.
**СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы **СЛЕДУЕТ.** Валидация конфига и подобные проверки отдают все проблемы
разом; проверка собранного — по-прежнему через `errors.Is`. разом; проверка собранного — по-прежнему через `errors.Is`.
**Почему.** Возврат первой ошибки превращает починку конфига в серию **ПОЧЕМУ.** Возврат первой ошибки превращает починку конфига в серию
перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт перезапусков, по одной проблеме за прогон. Склейка сообщений в строку даёт
тот же список, но убивает ветвление: `errors.Is` по такому результату не тот же список, но убивает ветвление: `errors.Is` по такому результату не
находит ничего, и вызывающий остаётся с текстом, матчить который запрещено находит ничего, и вызывающий остаётся с текстом, матчить который запрещено
+43 -43
View File
@@ -9,9 +9,9 @@ extends: arch/time.md
спецификация поведения: наблюдаемые требования к логам, входящие в контракт спецификация поведения: наблюдаемые требования к логам, входящие в контракт
функциональности, живут в спеках. функциональности, живут в спеках.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
Лог читают инструментами, а не глазами: повседневно — `jq` Лог читают инструментами, а не глазами: повседневно — `jq`
(`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) — (`jq 'select(.download_id=="a1b2")' app.jsonl`), тяжёлое (агрегации, JOIN) —
@@ -29,7 +29,7 @@ DuckDB поверх JSONL прямо из файла. Отсюда почти в
**ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в **ДОЛЖЕН.** Хендлер — `slog.JSONHandler`, одинаково в разработке и в
проде. проде.
**Почему.** Довод не в том, что текстовый вывод «расходит поля»: смена **ПОЧЕМУ.** Довод не в том, что текстовый вывод «расходит поля»: смена
хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым хендлера структуру атрибутов не меняет. Довод в читателе — с текстовым
dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и dev-выводом перестаёшь ежедневно гонять собственные `jq`-пайплайны, и
поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное поломки словаря (опечатка в имени поля, потерянный атрибут, склеенное
@@ -40,7 +40,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа. **ДОЛЖЕН.** Каждая величина — отдельный ключ со значением своего типа.
**Почему.** Фильтрация и агрегация работают по ключам; величина, вклеенная **ПОЧЕМУ.** Фильтрация и агрегация работают по ключам; величина, вклеенная
в текст, достаётся только регуляркой, а регулярка ломается при первой же в текст, достаётся только регуляркой, а регулярка ломается при первой же
правке формулировки. Тип важен отдельно от ключа: число внутри строки не правке формулировки. Тип важен отдельно от ключа: число внутри строки не
сравнивается и не суммируется, то есть попадает в лог, но не в отчёт. сравнивается и не суммируется, то есть попадает в лог, но не в отчёт.
@@ -50,7 +50,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey` **ДОЛЖЕН.** `time` приводится к UTC через `ReplaceAttr` по `slog.TimeKey`
(см. конвенцию `time`). (см. конвенцию `time`).
**Почему.** По умолчанию UTC не получится: встроенные хендлеры пишут время **ПОЧЕМУ.** По умолчанию UTC не получится: встроенные хендлеры пишут время
в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного в зоне самого `time.Time`, то есть в локальной зоне процесса. Записи одного
процесса до и после смены TZ (или записи рядом с данными из БД) перестают процесса до и после смены TZ (или записи рядом с данными из БД) перестают
складываться в одну хронологию, причём сдвиг на целые часы глазом не виден складываться в одну хронологию, причём сдвиг на целые часы глазом не виден
@@ -68,7 +68,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Текст сообщения не собирается из переменных: **ДОЛЖЕН.** Текст сообщения не собирается из переменных:
`log.Info("download accepted", "download_id", id)`. `log.Info("download accepted", "download_id", id)`.
**Почему.** `msg` — то, по чему записи группируют и считают. Интерполяция **ПОЧЕМУ.** `msg` — то, по чему записи группируют и считают. Интерполяция
превращает одну категорию в множество уникальных строк, и вопрос «сколько превращает одну категорию в множество уникальных строк, и вопрос «сколько
раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы раз это случилось» перестаёт решаться группировкой. Нижний регистр — чтобы
одна категория не двоилась на варианты, различающиеся только заглавной одна категория не двоилась на варианты, различающиеся только заглавной
@@ -79,7 +79,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема — **НЕ ДОЛЖЕН.** `recognition done`, а не `recognize: done`; подсистема —
отдельное поле. отдельное поле.
**Почему.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и **ПОЧЕМУ.** Префикс кладёт в текст ровно то, по чему потом фильтруют, и
фильтр по подсистеме становится сопоставлением с началом строки вместо фильтр по подсистеме становится сопоставлением с началом строки вместо
сравнения значения поля. Заодно это второй способ записать одно и то же: сравнения значения поля. Заодно это второй способ записать одно и то же:
категория дробится на варианты с префиксом и без, а совпадать они обязаны категория дробится на варианты с префиксом и без, а совпадать они обязаны
@@ -90,7 +90,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно **ДОЛЖЕН.** `state transition` с полями `from`/`to`/`code`; какое именно
состояние и по какой причине — данные, а не текст. состояние и по какой причине — данные, а не текст.
**Почему.** С отдельной категорией на каждый переход жизненный цикл **ПОЧЕМУ.** С отдельной категорией на каждый переход жизненный цикл
сущности собирается перечислением всех известных `msg` — и переход, сущности собирается перечислением всех известных `msg` — и переход,
добавленный в код позже, в это перечисление не попадёт: выборка тихо добавленный в код позже, в это перечисление не попадёт: выборка тихо
останется неполной. Единая категория даёт весь цикл одним фильтром и не останется неполной. Единая категория даёт весь цикл одним фильтром и не
@@ -101,7 +101,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет **НЕ ДОЛЖЕН.** Запись о действии, сопровождающем переход, не подменяет
запись самого перехода. запись самого перехода.
**Почему.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых **ПОЧЕМУ.** Иначе из выборки по SLOG-6 выпадают именно те переходы, у которых
был заметный эффект, — то есть самые интересные. Вторая запись стоит одной был заметный эффект, — то есть самые интересные. Вторая запись стоит одной
строки в логе; восстановление пропущенного перехода не стоит ничего, потому строки в логе; восстановление пропущенного перехода не стоит ничего, потому
что невозможно. что невозможно.
@@ -120,7 +120,7 @@ dev-выводом перестаёшь ежедневно гонять собс
| SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» | | SLOG-8.3 | `WARN` | владельцу, «может стать проблемой» |
| SLOG-8.4 | `ERROR` | владельцу, в разбор | | SLOG-8.4 | `ERROR` | владельцу, в разбор |
**Почему.** Адресат — единственный признак, по которому разные авторы в **ПОЧЕМУ.** Адресат — единственный признак, по которому разные авторы в
разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый разных местах кода выберут уровень одинаково. «Насколько серьёзно» каждый
оценивает по-своему, шкала расползается — и вместе с ней теряет смысл оценивает по-своему, шкала расползается — и вместе с ней теряет смысл
базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что базовый порог в проде (SLOG-40), потому что он отсекает уже не то, что
@@ -131,7 +131,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR` **НЕ ДОЛЖЕН.** Происхождение записи на выбор уровня не влияет: `ERROR`
везде одинаково серьёзен. везде одинаково серьёзен.
**Почему.** Фильтр по уровню собирает записи из всех подсистем сразу. Если **ПОЧЕМУ.** Фильтр по уровню собирает записи из всех подсистем сразу. Если
в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить в шумной подсистеме `ERROR` «дешевле», читателю приходится помнить
происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть происхождение каждой записи, чтобы понять, надо ли реагировать, — то есть
уровень перестаёт быть фильтром и становится подсказкой, требующей знания уровень перестаёт быть фильтром и становится подсказкой, требующей знания
@@ -141,7 +141,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`. **ДОЛЖЕН.** Если «может» не про эту запись, уровень — `INFO`.
**Почему.** `WARN` разбирают вручную и целиком. Как только в нём заводится **ПОЧЕМУ.** `WARN` разбирают вручную и целиком. Как только в нём заводится
«ничего страшного», его перестают читать — и вместе с шумом теряется то «ничего страшного», его перестают читать — и вместе с шумом теряется то
единственное, ради чего уровень существует: предупреждение, на которое ещё единственное, ради чего уровень существует: предупреждение, на которое ещё
есть время отреагировать. есть время отреагировать.
@@ -155,7 +155,7 @@ dev-выводом перестаёшь ежедневно гонять собс
| SLOG-11.1 | по реальному действию или изменению | `INFO` | | SLOG-11.1 | по реальному действию или изменению | `INFO` |
| SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` | | SLOG-11.2 | повторяющаяся служебная, по таймеру или поллингу, сама по себе события не несущая (healthcheck, опрос статуса, авто-рефреш UI) | `DEBUG` |
**Почему.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность **ПОЧЕМУ.** `INFO` — аудит постфактум (SLOG-8.2), и его пригодность
определяется долей записей, за которыми что-то стоит. Периодическая определяется долей записей, за которыми что-то стоит. Периодическая
операция даёт ровный поток при нулевой информации, в котором настоящие операция даёт ровный поток при нулевой информации, в котором настоящие
события тонут количественно: их не отфильтровать, потому что фильтровать события тонут количественно: их не отфильтровать, потому что фильтровать
@@ -166,7 +166,7 @@ dev-выводом перестаёшь ежедневно гонять собс
**ДОЛЖЕН.** Фатальный сбой на старте пишется как `ERROR` и завершает процесс **ДОЛЖЕН.** Фатальный сбой на старте пишется как `ERROR` и завершает процесс
ненулевым кодом. ненулевым кодом.
**Почему.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень **ПОЧЕМУ.** `slog` не разделяет CRITICAL и FATAL, поэтому недостающую степень
выражает не уровень записи, а сам факт завершения. Супервизор (docker, выражает не уровень записи, а сам факт завершения. Супервизор (docker,
journald, systemd) отличает падение от штатной остановки по коду возврата, а journald, systemd) отличает падение от штатной остановки по коду возврата, а
не по уровню последней записи. Процесс, который написал `ERROR` и продолжил не по уровню последней записи. Процесс, который написал `ERROR` и продолжил
@@ -180,7 +180,7 @@ journald, systemd) отличает падение от штатной оста
**ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку. **ДОЛЖЕН.** Не `mediaType`/`media`/`media_type` вперемешку.
**Почему.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той **ПОЧЕМУ.** Имя поля — и есть интерфейс запроса к логам. Второе имя для той
же величины делает любую выборку по ней молча неполной: фильтр отработает, же величины делает любую выборку по ней молча неполной: фильтр отработает,
часть записей в него не попадёт, и заметить это можно, только заранее зная, часть записей в него не попадёт, и заметить это можно, только заранее зная,
что они должны были быть. что они должны были быть.
@@ -194,7 +194,7 @@ journald, systemd) отличает падение от штатной оста
| SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` | | SLOG-14.1 | бизнес-поле | плоский `snake_case`: `download_id`, `media_type` |
| SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` | | SLOG-14.2 | системный домен | точечная иерархия (адаптация OpenTelemetry): `http.*`, `ext.*` |
**Почему.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые **ПОЧЕМУ.** Точка отделяет поля, приходящие от инфраструктуры и одинаковые
в любом проекте, от доменных, которые в каждом свои: по общему префиксу в любом проекте, от доменных, которые в каждом свои: по общему префиксу
запрос «все внешние вызовы» пишется без перечисления имён. Заимствование запрос «все внешние вызовы» пишется без перечисления имён. Заимствование
словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже словаря OpenTelemetry снимает необходимость изобретать имена тому, что уже
@@ -205,7 +205,7 @@ journald, systemd) отличает падение от штатной оста
**НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть **НЕ ДОЛЖЕН.** Вложенных объектов в записи нет; точка в имени — часть
имени, а не уровень вложенности. имени, а не уровень вложенности.
**Почему.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой **ПОЧЕМУ.** Плоский ключ адресуется одинаково в `jq`, в DuckDB и в любой
записи независимо от её категории. Вложенность требует знать глубину записи независимо от её категории. Вложенность требует знать глубину
заранее, а она у разных категорий разная — и один запрос перестаёт покрывать заранее, а она у разных категорий разная — и один запрос перестаёт покрывать
весь лог, распадаясь на запрос под каждую форму записи. весь лог, распадаясь на запрос под каждую форму записи.
@@ -221,7 +221,7 @@ journald, systemd) отличает падение от штатной оста
| SLOG-16.3 | запись об ошибке | `error` | | SLOG-16.3 | запись об ошибке | `error` |
| SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` | | SLOG-16.4 | вызов внешнего сервиса | `ext.service`, `ext.operation` (логическая операция, не URL), `ext.status_code`, `duration_ms`, `retry` |
**Почему.** Набор задан не «на всякий случай»: без него запись не отвечает **ПОЧЕМУ.** Набор задан не «на всякий случай»: без него запись не отвечает
на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию, на свой вопрос. HTTP-запись без `duration_ms` не показывает деградацию,
`ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас `ext`-запись без `ext.service` не отделяет «легла зависимость» от «у нас
баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный баг», запись о сущности без идентификатора не корреллируется (SLOG-19). Полный
@@ -233,7 +233,7 @@ journald, systemd) отличает падение от штатной оста
**НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не **НЕ СЛЕДУЕТ.** Поле, значение которого одинаково во всех записях, не
заводится — для одного бинаря на одном хосте это `service.*` и `host.*`. заводится — для одного бинаря на одном хосте это `service.*` и `host.*`.
**Почему.** Такое поле не несёт информации, но стоит места в каждой строке **ПОЧЕМУ.** Такое поле не несёт информации, но стоит места в каждой строке
и внимания при чтении. Критерий один на все поля словаря — им же решается, и внимания при чтении. Критерий один на все поля словаря — им же решается,
нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие нужен ли `transport` (SLOG-16.1): пока транспорт один, поле постоянно. Условие
названо явно, поэтому правило отпадёт вместе со своей причиной: с названо явно, поэтому правило отпадёт вместе со своей причиной: с
@@ -248,7 +248,7 @@ journald, systemd) отличает падение от штатной оста
сущностей есть стабильные уникальные идентификаторы. (Как их выбирают — сущностей есть стабильные уникальные идентификаторы. (Как их выбирают —
конвенция `db-identifiers`, если взята.) конвенция `db-identifiers`, если взята.)
**Почему.** Идентификатор сущности уже существует, стабилен между **ПОЧЕМУ.** Идентификатор сущности уже существует, стабилен между
процессами и во времени — по нему собираются записи не одного прохода, а процессами и во времени — по нему собираются записи не одного прохода, а
всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое всей истории сущности, включая вчерашнюю. `trace_id` даёт то же самое
только внутри одной операции, то есть дублирует ключ и добавляет второй только внутри одной операции, то есть дублирует ключ и добавляет второй
@@ -259,7 +259,7 @@ journald, systemd) отличает падение от штатной оста
**ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности. **ДОЛЖЕН.** Поле `<entity>_id` в каждой записи, относящейся к сущности.
**Почему.** Принадлежность записи восстанавливается только в момент **ПОЧЕМУ.** Принадлежность записи восстанавливается только в момент
записи; постфактум её не вывести — остаётся воспроизводить инцидент заново. записи; постфактум её не вывести — остаётся воспроизводить инцидент заново.
Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем, Это же условие, при котором работает SLOG-18: отказ от `trace_id` оплачен тем,
что идентификатор стоит везде, а не в удобных местах. что идентификатор стоит везде, а не в удобных местах.
@@ -279,7 +279,7 @@ log := log.With("download_id", id)
ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии ctx = logctx.With(ctx, log) // достаём логгер из ctx в каждой стадии
``` ```
**Почему.** Ручное дописывание ключа пропускают не в основном сценарии, а в **ПОЧЕМУ.** Ручное дописывание ключа пропускают не в основном сценарии, а в
редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее редких ветках — обработке ошибок и ранних выходах, где корреляция нужнее
всего. Логгер из контекста дописывает ключ сам, и запись без всего. Логгер из контекста дописывает ключ сам, и запись без
идентификатора становится невозможной, а не маловероятной. идентификатора становится невозможной, а не маловероятной.
@@ -290,7 +290,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`. **ДОЛЖЕН.** `log.Error("layout failed", "error", err, "download_id", id)`.
**Почему.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и **ПОЧЕМУ.** Ошибка, вклеенная в текст сообщения, дробит категорию (SLOG-4) и
уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же, уносит текст туда, где по нему нельзя отфильтровать. Ключ единый — так же,
как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна как по умолчанию в zap/zerolog: выборка «все записи с ошибкой» не должна
зависеть от того, кто писал конкретный вызов, и ради этого единообразия зависеть от того, кто писал конкретный вызов, и ради этого единообразия
@@ -301,7 +301,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только **НЕ ДОЛЖЕН.** Слой, возвращающий ошибку выше, её не логирует — только
оборачивает (`%w`). оборачивает (`%w`).
**Почему.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл, **ПОЧЕМУ.** Иначе один сбой даёт столько записей, сколько слоёв он прошёл,
и количество `ERROR` перестаёт соответствовать количеству отказов — а и количество `ERROR` перестаёт соответствовать количеству отказов — а
считают именно его. Контекст при этом не теряется: он накапливается в считают именно его. Контекст при этом не теряется: он накапливается в
цепочке обёрток и попадает в единственную запись на границе (SLOG-23). цепочке обёрток и попадает в единственную запись на границе (SLOG-23).
@@ -310,7 +310,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции. **ДОЛЖЕН.** Логирует единый чокпоинт, определяющий исход операции.
**Почему.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и **ПОЧЕМУ.** У ошибки нужен ровно один логирующий, иначе неизбежны дубли; и
этим местом выбрана доменная граница, а не транспорт, потому что там этим местом выбрана доменная граница, а не транспорт, потому что там
известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт известен исход операции целиком и, значит, класс отказа (SLOG-25) — транспорт
знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора: знает лишь то, что ему вернули ошибку. Побочный эффект того же выбора:
@@ -321,7 +321,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ **НЕ ДОЛЖЕН.** Транспорт переводит возвращённую ошибку в свой ответ
(статус, сообщение пользователю) и на этом останавливается. (статус, сообщение пользователю) и на этом останавливается.
**Почему.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё **ПОЧЕМУ.** Запись уже сделана на границе (SLOG-23); вторая отличается от неё
только формулировкой и читается как второй сбой. Когда транспортов над только формулировкой и читается как второй сбой. Когда транспортов над
одним доменом несколько, дублирование ещё и множится, а расследование одним доменом несколько, дублирование ещё и множится, а расследование
начинается с вопроса, один это инцидент или два. начинается с вопроса, один это инцидент или два.
@@ -338,7 +338,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
| SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` | | SLOG-25.2 | расхождение производного или учётного состояния, первичные данные целы | владельцу, «может стать проблемой» | `WARN` |
| SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` | | SLOG-25.3 | сбой БД, ФС, недоступность зависимости | владельцу, в разбор | `ERROR` |
**Почему.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на **ПОЧЕМУ.** Это применение SLOG-8 к отказам: пользователь уже увидел причину на
экране — владельцу разбирать нечего; целостность первичных данных отделяет экране — владельцу разбирать нечего; целостность первичных данных отделяет
«надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный «надо посмотреть» от «надо чинить сейчас». Привязка к месту дала бы разный
уровень для одного и того же отказа в зависимости от того, какой транспорт уровень для одного и того же отказа в зависимости от того, какой транспорт
@@ -359,7 +359,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`, владельцу как деградация автоматики: коллизия в ручном действии — `DEBUG`,
она же в авто-обработке — `WARN`. она же в авто-обработке — `WARN`.
**Почему.** В ручном действии человек видит причину на экране и сам решает, **ПОЧЕМУ.** В ручном действии человек видит причину на экране и сам решает,
что делать дальше; запись нужна только для отладки. В автоматике не увидел что делать дальше; запись нужна только для отладки. В автоматике не увидел
никто, задача осталась недоведённой, и лог — единственное место, где это никто, задача осталась недоведённой, и лог — единственное место, где это
вообще проявится. вообще проявится.
@@ -369,7 +369,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`: **ДОЛЖЕН.** Тот же класс сбоя внутри синхронной операции — `ERROR`:
уровень задаёт наличие штатного повтора, а не текст ошибки. уровень задаёт наличие штатного повтора, а не текст ошибки.
**Почему.** Одиночный промах тика транзиентен — следующий тик повторит, и **ПОЧЕМУ.** Одиночный промах тика транзиентен — следующий тик повторит, и
вмешательство не требуется; `ERROR` на каждый такой промах обесценивает вмешательство не требуется; `ERROR` на каждый такой промах обесценивает
уровень, на который смотрят в первую очередь. Синхронная операция повтора уровень, на который смотрят в первую очередь. Синхронная операция повтора
не имеет: она провалилась целиком, результат никто не восстановит, и это не имеет: она провалилась целиком, результат никто не восстановит, и это
@@ -381,7 +381,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4. **ДОЛЖЕН.** Все вызовы, включая успешные; поля — по SLOG-16.4.
**Почему.** Это единственный способ отличить «у нас баг» от «зависимость **ПОЧЕМУ.** Это единственный способ отличить «у нас баг» от «зависимость
легла»: на своей стороне видно лишь то, что операция не удалась. легла»: на своей стороне видно лишь то, что операция не удалась.
Выборочное логирование ломает и второе применение — доля неуспехов и Выборочное логирование ломает и второе применение — доля неуспехов и
распределение `duration_ms` считаются, только если знаменатель полный. распределение `duration_ms` считаются, только если знаменатель полный.
@@ -397,7 +397,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
| SLOG-29.3 | попытка не удалась, делается retry | `WARN` | | SLOG-29.3 | попытка не удалась, делается retry | `WARN` |
| SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` | | SLOG-29.4 | ретраи исчерпаны, сервис недоступен | `ERROR` |
**Почему.** Неудачная попытка, за которой следует повтор, — ещё не отказ: **ПОЧЕМУ.** Неудачная попытка, за которой следует повтор, — ещё не отказ:
операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы операция может завершиться успешно, и `ERROR` на каждую попытку сделал бы
уровень непригодным для главного вопроса «зависимость доступна?». уровень непригодным для главного вопроса «зависимость доступна?».
Исчерпание ретраев и есть момент, когда транспорт сдался и дальше Исчерпание ретраев и есть момент, когда транспорт сдался и дальше
@@ -431,7 +431,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
(`ext.status_code` записан); решение «это ошибка» принимает доменный (`ext.status_code` записан); решение «это ошибка» принимает доменный
вызывающий. вызывающий.
**Почему.** Транспорт своё дело сделал: запрос доставлен, ответ получен и **ПОЧЕМУ.** Транспорт своё дело сделал: запрос доставлен, ответ получен и
разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис разобран. Классифицировать 4xx как сбой транспорта значит смешать «сервис
недоступен» с «сервис ответил нам нет» — это разные инциденты с разной недоступен» с «сервис ответил нам нет» — это разные инциденты с разной
реакцией, и различает их как раз `ext`-уровень. Что 404 значит для реакцией, и различает их как раз `ext`-уровень. Что 404 значит для
@@ -444,7 +444,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа. **ДОЛЖЕН.** Поля по SLOG-16.1; 4xx остаётся `INFO`-записью доступа.
**Почему.** Это аудит обращений, а не отладка: запись отвечает на «кто и **ПОЧЕМУ.** Это аудит обращений, а не отладка: запись отвечает на «кто и
когда приходил», и ценность у неё одинаковая при любом коде ответа. когда приходил», и ценность у неё одинаковая при любом коде ответа.
Уровень, зависящий от кода, делает аудит неполным именно на тех запросах, Уровень, зависящий от кода, делает аудит неполным именно на тех запросах,
которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись которые чаще всего разбирают. Доменную оценку исхода даёт отдельная запись
@@ -454,7 +454,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности. **ДОПУСКАЕТСЯ.** Это отдельный слой от корреляции по сущности.
**Почему.** Явное разрешение снимает вопрос, не запрещает ли `request_id` **ПОЧЕМУ.** Явное разрешение снимает вопрос, не запрещает ли `request_id`
правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где правило SLOG-18. Не запрещает: SLOG-18 отказывается от случайного ключа там, где
уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной уже есть стабильный идентификатор сущности, а у HTTP-запроса собственной
сущности нет — связать его записи между собой больше нечем. сущности нет — связать его записи между собой больше нечем.
@@ -463,7 +463,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
**ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне. **ДОЛЖЕН.** Периодические проверки живости пишутся на отладочном уровне.
**Почему.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают **ПОЧЕМУ.** Частный случай SLOG-11.2, названный отдельно, потому что нарушают
его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из его чаще всего: проверку дёргают по таймеру, и на `INFO` она вытесняет из
аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в аудита всё остальное — в проде с базовым `INFO` (SLOG-40) лог превратился бы в
опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся опрос самого себя. На `DEBUG` она не пишется вовсе и при этом остаётся
@@ -477,7 +477,7 @@ ctx = logctx.With(ctx, log) // достаём логгер из ctx в кажд
API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры API-ключи и токены, `Authorization`-заголовки, аутентификационные параметры
в ссылках. в ссылках.
**Почему.** Лог уезжает целиком в чужое хранилище, читается шире, чем код, **ПОЧЕМУ.** Лог уезжает целиком в чужое хранилище, читается шире, чем код,
и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован и переживает ротацию самого секрета. Попавший в него секрет скомпрометирован
с момента записи, а не с момента, когда это заметили, и вычистить его задним с момента записи, а не с момента, когда это заметили, и вычистить его задним
числом из уже собранных копий нельзя. числом из уже собранных копий нельзя.
@@ -487,7 +487,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM — **ДОЛЖЕН.** Тела запросов и ответов внешних API, сырой вывод LLM —
`DEBUG`, с вычисткой секретов и обрезкой по длине. `DEBUG`, с вычисткой секретов и обрезкой по длине.
**Почему.** Содержимое пришло снаружи: размер не ограничен, состав **ПОЧЕМУ.** Содержимое пришло снаружи: размер не ограничен, состав
неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG` неизвестен, а секрет в нём возможен по недосмотру той стороны. `DEBUG`
выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией; выключен в проде (SLOG-40), поэтому цена ошибки ограничена отладочной сессией;
обрезка не даёт одной записи вытеснить весь остальной лог за период. обрезка не даёт одной записи вытеснить весь остальной лог за период.
@@ -496,7 +496,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения. **СЛЕДУЕТ.** `"has_api_key", true` вместо самого значения.
**Почему.** Для отладки почти всегда достаточно ответа «значение было или **ПОЧЕМУ.** Для отладки почти всегда достаточно ответа «значение было или
не было» — потеря полезности близка к нулю, а риск снимается целиком. не было» — потеря полезности близка к нулю, а риск снимается целиком.
Правило нужно потому, что решение принимается в момент написания строки, Правило нужно потому, что решение принимается в момент написания строки,
когда чувствительность значения ещё неочевидна, а перечитывать этот выбор когда чувствительность значения ещё неочевидна, а перечитывать этот выбор
@@ -507,7 +507,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до **ДОЛЖЕН.** Ошибка разворачивается в первопричину **до** лога и до
обёртки — раньше трансляции в доменную (конвенция `errors`). обёртки — раньше трансляции в доменную (конвенция `errors`).
**Почему.** `*url.Error` встраивает полный URL запроса, а секрет живёт **ПОЧЕМУ.** `*url.Error` встраивает полный URL запроса, а секрет живёт
прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль прямо в нём: токен в пути, `api_key` в query. Go редактирует только пароль
из userinfo, остального не трогает, поэтому ошибка уносит секрет и в из userinfo, остального не трогает, поэтому ошибка уносит секрет и в
обёртку, и в лог целиком. Порядок — часть нормы: санитизация после обёртку, и в лог целиком. Порядок — часть нормы: санитизация после
@@ -521,7 +521,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого **НЕ ДОЛЖЕН.** Аутентификация параметром ссылки — только когда другого
способа нет. способа нет.
**Почему.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и **ПОЧЕМУ.** Секрет в URL попадает не только в ошибку транспорта (SLOG-37), но и
в любую запись, куда URL попал целиком, — то есть обязывает помнить про в любую запись, куда URL попал целиком, — то есть обязывает помнить про
санитизацию в каждой такой точке, и одна забытая сводит остальные на нет. санитизацию в каждой такой точке, и одна забытая сводит остальные на нет.
Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке. Заголовок снимает задачу в источнике: чего нет в URL, того нет и в ошибке.
@@ -533,7 +533,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам **ДОЛЖЕН.** Сбор и ротацию делает окружение (docker, journald); по файлам
не маршрутизируем. не маршрутизируем.
**Почему.** Приложение, которое само решает, что куда писать, дублирует **ПОЧЕМУ.** Приложение, которое само решает, что куда писать, дублирует
работу супервизора и расходится с ней при первой же смене окружения: срок работу супервизора и расходится с ней при первой же смене окружения: срок
хранения, сжатие и ротация оказываются настроены в двух местах и по-разному. хранения, сжатие и ротация оказываются настроены в двух местах и по-разному.
Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам Один поток вдобавок сохраняет порядок записей — маршрутизация по файлам
@@ -543,7 +543,7 @@ API-ключи и токены, `Authorization`-заголовки, аутент
**ДОЛЖЕН.** `DEBUG` в проде включается конфигом. **ДОЛЖЕН.** `DEBUG` в проде включается конфигом.
**Почему.** Уровень — единственный регулятор объёма, доступный без **ПОЧЕМУ.** Уровень — единственный регулятор объёма, доступный без
пересборки; если `DEBUG` в проде включается только правкой кода, его не пересборки; если `DEBUG` в проде включается только правкой кода, его не
включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому, включают, и разбор инцидента идёт вслепую. `INFO` выбран базовым потому,
что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2). что на нём аудит полон (SLOG-8.2), а рутинно-частое уже отсечено (SLOG-11.2).
+16 -16
View File
@@ -8,9 +8,9 @@ extends: arch/time.md
Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас», Как требования базового слоя выполняются в Go-коде: откуда берётся «сейчас»,
в каком виде время попадает в базу и в логи, что делать с зонами. в каком виде время попадает в базу и в логи, что делать с зонами.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Правила ## Правила
@@ -19,7 +19,7 @@ extends: arch/time.md
**ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего **ДОЛЖЕН.** Текущее время приходит из `store.Now()`, возвращающего
`time.Now().UTC()`, а не из `time.Now()` по коду. `time.Now().UTC()`, а не из `time.Now()` по коду.
**Почему.** Единая точка даёт гарантированный UTC и один формат: ни одна **ПОЧЕМУ.** Единая точка даёт гарантированный UTC и один формат: ни одна
ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением ветка кода не может о них забыть. Разъехавшиеся зоны чинятся только чтением
всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она всех записей — по метке `2026-06-28T11:23:45Z` уже не видно, была ли она
когда-то локальной, и восстановить смещение задним числом не по чему. когда-то локальной, и восстановить смещение задним числом не по чему.
@@ -33,7 +33,7 @@ extends: arch/time.md
**ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ **ДОЛЖЕН.** Пара функций поверх `time.RFC3339` — единственный способ
получить строку времени и прочитать её обратно. получить строку времени и прочитать её обратно.
**Почему.** Layout, набранный по месту вызова, превращает формат хранения в **ПОЧЕМУ.** Layout, набранный по месту вызова, превращает формат хранения в
свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и свойство каждой отдельной строки кода. Фиксированная ширина (GTIM-4) и
взаимная обратимость записи и чтения держатся ровно до первого второго взаимная обратимость записи и чтения держатся ровно до первого второго
layout — а расхождение проявится не на записи, а при сравнении значений, layout — а расхождение проявится не на записи, а при сравнении значений,
@@ -49,7 +49,7 @@ layout — а расхождение проявится не на записи,
| GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит | | GTIM-3.1 | сама точка `store.Now()` | внутри неё вызов `time.Now()` и происходит |
| GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) | | GTIM-3.2 | обёртка измерения длительности | приведение к UTC срезает монотонную составляющую (GTIM-10) |
**Почему.** GTIM-1 без механической проверки держится на внимании, а **ПОЧЕМУ.** GTIM-1 без механической проверки держится на внимании, а
`time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке; `time.Now()` пишется рефлекторно — и в новом коде, и в мелкой правке;
нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне. нарушение молчит до тех пор, пока процесс не окажется в не-UTC зоне.
Исключения перечисляются исчерпывающе, потому что каждое из них — само по Исключения перечисляются исчерпывающе, потому что каждое из них — само по
@@ -63,7 +63,7 @@ layout — а расхождение проявится не на записи,
`//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в `//nolint:forbidigo // <причина>` на строке вызова; exclude-записи в
конфигурации линтера для него не заводятся. конфигурации линтера для него не заводятся.
**Почему.** Запись в конфиге — второй реестр тех же двух мест: она адресует **ПОЧЕМУ.** Запись в конфиге — второй реестр тех же двух мест: она адресует
исключение путём к файлу, отвязывается при переносе кода и продолжает исключение путём к файлу, отвязывается при переносе кода и продолжает
разрешать `time.Now()` там, где исключения уже нет, — молча. Директива разрешать `time.Now()` там, где исключения уже нет, — молча. Директива
переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список — переезжает вместе с кодом, и grep по `nolint:forbidigo` даёт весь список —
@@ -77,7 +77,7 @@ layout — а расхождение проявится не на записи,
**ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`. **ДОЛЖЕН.** Каноническое значение в колонке — `2026-06-28T11:23:45Z`.
**Почему.** Строки в колонке `TEXT` сравниваются побайтово, поэтому **ПОЧЕМУ.** Строки в колонке `TEXT` сравниваются побайтово, поэтому
лексикографический порядок совпадает с хронологическим только при лексикографический порядок совпадает с хронологическим только при
одинаковых ширине и форме. Значение с долями секунды сортируется **раньше** одинаковых ширине и форме. Значение с долями секунды сортируется **раньше**
целой секунды того же момента (`.` меньше `Z`), то есть ломаются и целой секунды того же момента (`.` меньше `Z`), то есть ломаются и
@@ -91,7 +91,7 @@ layout — а расхождение проявится не на записи,
**НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения. **НЕ ДОЛЖЕН.** Ни как layout вывода, ни как формат хранения.
**Почему.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит **ПОЧЕМУ.** Он отбрасывает хвостовые нули, из-за чего длина строки зависит
от значения: соседние записи получают разную ширину, и свойство, на котором от значения: соседние записи получают разную ширину, и свойство, на котором
держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом держится GTIM-4, исчезает незаметно. Проверка «формат корректен» при этом
проходит — отказывает только порядок. проходит — отказывает только порядок.
@@ -102,7 +102,7 @@ layout — а расхождение проявится не на записи,
к каноническому виду явно, а не считается каноническим по факту успешного к каноническому виду явно, а не считается каноническим по факту успешного
разбора. разбора.
**Почему.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и **ПОЧЕМУ.** `time.Parse(time.RFC3339, …)` принимает и доли секунды, и
офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует офсеты, отличные от `Z`, — разбор шире вывода. Канонический вид гарантирует
**писатель**, а не читатель; пока писатель один, этого достаточно, но **писатель**, а не читатель; пока писатель один, этого достаточно, но
значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и значение из чужой системы, положенное в базу как пришло, нарушает GTIM-4 и
@@ -114,7 +114,7 @@ Go-механика, из-за которой его легко нарушить
**СЛЕДУЕТ.** В запрос идёт результат `FormatTime`. **СЛЕДУЕТ.** В запрос идёт результат `FormatTime`.
**Почему.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование **ПОЧЕМУ.** Колонка — `TEXT`, и передача `time.Time` отдаёт форматирование
драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с драйверу: появляется вторая точка формата вне `FormatTime` (GTIM-2), с
собственным layout, который меняется вместе с версией драйвера, а не вместе собственным layout, который меняется вместе с версией драйвера, а не вместе
с конвенцией. с конвенцией.
@@ -132,7 +132,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
} }
``` ```
**Почему.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут **ПОЧЕМУ.** `slog` по умолчанию UTC не даёт: встроенные хендлеры пишут
время в зоне самого `time.Time`, то есть в локальной зоне процесса — на время в зоне самого `time.Time`, то есть в локальной зоне процесса — на
ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое, ноутбуке разработчика логи молча уезжают в `+03:00`. Умолчание тихое,
неверная зона выглядит как совершенно валидное время, а записи из разных неверная зона выглядит как совершенно валидное время, а записи из разных
@@ -143,7 +143,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не **ДОПУСКАЕТСЯ.** `JSONHandler` пишет миллисекунды — три знака, и это не
приводится к секундной точности GTIM-4. приводится к секундной точности GTIM-4.
**Почему.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование **ПОЧЕМУ.** Явное разрешение нужно, чтобы GTIM-4 не читался как требование
одной точности везде. Ширина фиксируется на носитель: три знака в логе — одной точности везде. Ширина фиксируется на носитель: три знака в логе —
такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не такая же фиксированная ширина, и свойство, ради которого GTIM-4 существует, не
нарушено. Общее у лога и базы одно — зона (GTIM-8). нарушено. Общее у лога и базы одно — зона (GTIM-8).
@@ -153,7 +153,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с **ДОПУСКАЕТСЯ.** Обёртка вызывает `time.Now()` и считает `time.Since`с
локальным `//nolint`. локальным `//nolint`.
**Почему.** `store.Now()` приводит время к UTC через `.UTC()`, а это **ПОЧЕМУ.** `store.Now()` приводит время к UTC через `.UTC()`, а это
срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким срезает монотонную составляющую `time.Time`. Интервал, посчитанный по таким
меткам, зависит от подводки часов: перевод назад даёт отрицательную меткам, зависит от подводки часов: перевод назад даёт отрицательную
длительность, скачок вперёд — выброс в измерениях, и оба случая длительность, скачок вперёд — выброс в измерениях, и оба случая
@@ -164,7 +164,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОЛЖЕН.** База зон вшивается в бинарь. **ДОЛЖЕН.** База зон вшивается в бинарь.
**Почему.** Без неё `LoadLocation` зависит от файлов зон в системе, которых **ПОЧЕМУ.** Без неё `LoadLocation` зависит от файлов зон в системе, которых
в минимальном образе нет: отказ происходит в рантайме, на первой же попытке в минимальном образе нет: отказ происходит в рантайме, на первой же попытке
применить зону, — то есть после выкладки, а не на сборке. Импорт именно в применить зону, — то есть после выкладки, а не на сборке. Импорт именно в
`main` держит это решение в одном видимом месте, а не в случайном пакете, `main` держит это решение в одном видимом месте, а не в случайном пакете,
@@ -175,7 +175,7 @@ func utcTime(_ []string, a slog.Attr) slog.Attr {
**ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах **ДОЛЖЕН.** Зона из конфига используется в шаблонах и форматтерах
представления, но не в хранимых значениях и не в вычислениях. представления, но не в хранимых значениях и не в вычислениях.
**Почему.** Зона отображения — настройка, и её меняют. Протекая в **ПОЧЕМУ.** Зона отображения — настройка, и её меняют. Протекая в
вычисления и хранение, она делает уже записанные данные зависимыми от вычисления и хранение, она делает уже записанные данные зависимыми от
текущего значения настройки: смена зоны задним числом сдвигает границы текущего значения настройки: смена зоны задним числом сдвигает границы
суток у того, что давно посчитано и сохранено. суток у того, что давно посчитано и сохранено.
+12 -12
View File
@@ -7,9 +7,9 @@ extends: arch/app-directories.md
Как категории из базового слоя раскладываются на сервере плейбуком. Как категории из базового слоя раскладываются на сервере плейбуком.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
## Область действия ## Область действия
@@ -27,7 +27,7 @@ extends: arch/app-directories.md
состоит из нескольких директорий, имя даётся по содержимому (`media_dir`, состоит из нескольких директорий, имя даётся по содержимому (`media_dir`,
`uploads_dir`, `dumps_dir`). `uploads_dir`, `dumps_dir`).
**Почему.** Переменная — единственная ссылка, которую разделяют задача **ПОЧЕМУ.** Переменная — единственная ссылка, которую разделяют задача
создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест создания директории и список бэкапа (ANSD-4). Литерал пути в одном из этих мест
означает, что переименование директории молча разойдётся с бэкапом, и означает, что переименование директории молча разойдётся с бэкапом, и
обнаружится это при восстановлении. обнаружится это при восстановлении.
@@ -36,7 +36,7 @@ extends: arch/app-directories.md
**СЛЕДУЕТ.** Список директорий в единственной задаче создания. **СЛЕДУЕТ.** Список директорий в единственной задаче создания.
**Почему.** Этот список — единственное место, где декларировано всё, что **ПОЧЕМУ.** Этот список — единственное место, где декларировано всё, что
приложение пишет на диск. Разнесённое по нескольким задачам создание приложение пишет на диск. Разнесённое по нескольким задачам создание
отвечает на вопрос «какие директории есть у приложения» только чтением отвечает на вопрос «какие директории есть у приложения» только чтением
всего плейбука, а именно этот вопрос задают при заведении бэкапа и при всего плейбука, а именно этот вопрос задают при заведении бэкапа и при
@@ -48,7 +48,7 @@ extends: arch/app-directories.md
(`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на (`app_owner_uid == app_owner_gid`) или общий `primary_user` — выбирается на
репозиторий и фиксируется ниже. репозиторий и фиксируется ниже.
**Почему.** Правило про соответствие владельца рантайму, а не про **ПОЧЕМУ.** Правило про соответствие владельца рантайму, а не про
конкретную модель: приложение в контейнере пишет от определённого uid, и конкретную модель: приложение в контейнере пишет от определённого uid, и
если директория принадлежит другому, отказ произойдёт не при деплое, а при если директория принадлежит другому, отказ произойдёт не при деплое, а при
первой записи — то есть после того, как плейбук отчитался об успехе. Выбор первой записи — то есть после того, как плейбук отчитался об успехе. Выбор
@@ -61,7 +61,7 @@ extends: arch/app-directories.md
**ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки **ДОЛЖЕН.** Плейбук кладёт в `base_dir` файл `backup-targets`, строки
которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути. которого ссылаются на переменные `*_dir` из ANSD-1, а не на литеральные пути.
**Почему.** Правило вывода списка механическое (ANSD-5), но применяет его **ПОЧЕМУ.** Правило вывода списка механическое (ANSD-5), но применяет его
человек или шаблон — то есть ошибиться можно. Общая переменная делает целый человек или шаблон — то есть ошибиться можно. Общая переменная делает целый
класс ошибок невозможным: переименовал директорию — переименовалось в класс ошибок невозможным: переименовал директорию — переименовалось в
обоих местах. Независимо набранный список расходится тихо и проявляется в обоих местах. Независимо набранный список расходится тихо и проявляется в
@@ -72,7 +72,7 @@ extends: arch/app-directories.md
**ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в **ДОЛЖЕН.** Директории категории «данные», включая директорию дампов, — в
списке; конфигурация и кеш — нет. списке; конфигурация и кеш — нет.
**Почему.** Реализация правила базовой конвенции. Кеш раздувает снапшот без **ПОЧЕМУ.** Реализация правила базовой конвенции. Кеш раздувает снапшот без
пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в пользы, а конфигурация содержит отрендеренные секреты — бэкап уезжает в
облако, и источником истины для секретов остаётся vault, а не снапшот. облако, и источником истины для секретов остаётся vault, а не снапшот.
@@ -80,7 +80,7 @@ extends: arch/app-directories.md
**СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`. **СЛЕДУЕТ.** В compose конфигурация подключается с `:ro`.
**Почему.** Плейбук — источник истины для конфигурации, и `:ro` превращает **ПОЧЕМУ.** Плейбук — источник истины для конфигурации, и `:ro` превращает
это из договорённости в свойство системы: приложение, которое втихую это из договорённости в свойство системы: приложение, которое втихую
переписывает свой конфиг, падает сразу, а не расходится с репозиторием переписывает свой конфиг, падает сразу, а не расходится с репозиторием
незаметно. Приложение, которому запись в конфиг нужна по устройству, незаметно. Приложение, которому запись в конфиг нужна по устройству,
@@ -90,7 +90,7 @@ extends: arch/app-directories.md
**ДОЛЖЕН.** Файл не переносится во вложенную директорию. **ДОЛЖЕН.** Файл не переносится во вложенную директорию.
**Почему.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило **ПОЧЕМУ.** Туда смотрит `project_src` модуля `docker_compose_v2`. Правило
внешнее по происхождению, но нарушается легко — при попытке «навести внешнее по происхождению, но нарушается легко — при попытке «навести
порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы порядок» и убрать compose в `config/`, где ему по смыслу категорий было бы
место. место.
@@ -100,7 +100,7 @@ extends: arch/app-directories.md
**СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл, **СЛЕДУЕТ.** Значения приходят из vault-переменных и попадают в файл,
принадлежащий пользователю приложения. принадлежащий пользователю приложения.
**Почему.** Файл под `0600` не наследуется дочерними процессами, не виден в **ПОЧЕМУ.** Файл под `0600` не наследуется дочерними процессами, не виден в
`docker inspect` и не оседает в compose-файле на диске. Это те же три `docker inspect` и не оседает в compose-файле на диске. Это те же три
довода, по которым базовая конвенция конфигурации выбирает файл вместо довода, по которым базовая конвенция конфигурации выбирает файл вместо
окружения. окружения.
@@ -109,7 +109,7 @@ extends: arch/app-directories.md
**ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`. **ДОПУСКАЕТСЯ.** Задача рендера идёт с `no_log: true`.
**Почему.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на **ПОЧЕМУ.** Явное разрешение нужно, чтобы ANSD-8 не читался как запрет на
деплой такого приложения. Способ вынужденный: секрет попадает в метаданные деплой такого приложения. Способ вынужденный: секрет попадает в метаданные
контейнера и в compose-файл на диске. Приложение, научившееся читать контейнера и в compose-файл на диске. Приложение, научившееся читать
секреты из файла, переводится на ANSD-8 при ближайшем касании. секреты из файла, переводится на ANSD-8 при ближайшем касании.
+38 -38
View File
@@ -8,9 +8,9 @@ prefix: HTMX
обработчики действий, деградация без JS, ошибки. Что именно UI показывает и обработчики действий, деградация без JS, ошибки. Что именно UI показывает и
какие действия поддерживает — в спеках, не здесь. какие действия поддерживает — в спеках, не здесь.
Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и отметка Ключевые слова ДОЛЖЕН, НЕ ДОЛЖЕН, СЛЕДУЕТ, НЕ СЛЕДУЕТ, ДОПУСКАЕТСЯ и метки
МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 1 тогда и ПОЧЕМУ и МЕХАНИЗИРОВАНО толкуются как описано в языке конвенций версии 2
только тогда, когда написаны заглавными. тогда и только тогда, когда написаны заглавными.
Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на Логирование запросов — конвенция `logging` (HTTP-поля, рутинно-частое на
`DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors` `DEBUG`). Трансляция доменных ошибок наружу — конвенция `errors`
@@ -30,7 +30,7 @@ prefix: HTMX
**ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки, **ДОЛЖЕН.** UI собирается из серверных шаблонов и htmx — без шага сборки,
без Node и бандлера, без реактивного фреймворка. без Node и бандлера, без реактивного фреймворка.
**Почему.** Шаг сборки — это второй язык, второй менеджер зависимостей и **ПОЧЕМУ.** Шаг сборки — это второй язык, второй менеджер зависимостей и
артефакт, который расходится с исходником; приложению, где разметку целиком артефакт, который расходится с исходником; приложению, где разметку целиком
отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую отдаёт сервер, он не покупает ничего. Реактивный фреймворк добавляет вторую
модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране модель состояния рядом с серверной (HTMX-2), и дальше на каждом экране
@@ -44,7 +44,7 @@ prefix: HTMX
(копирование в буфер обмена и подобное); доменное состояние считает сервер, (копирование в буфер обмена и подобное); доменное состояние считает сервер,
клиент свопит присланную разметку. клиент свопит присланную разметку.
**Почему.** Пересчёт на клиенте — вторая реализация той же логики, которую **ПОЧЕМУ.** Пересчёт на клиенте — вторая реализация той же логики, которую
никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в никто не сверяет: расходится она тихо, а проявляется как «на экране одно, в
базе другое». Вдобавок клиентский пересчёт по определению не работает в базе другое». Вдобавок клиентский пересчёт по определению не работает в
деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же деградированном режиме (HTMX-11, HTMX-12) — значит, серверную версию того же
@@ -56,7 +56,7 @@ prefix: HTMX
только когда есть виджет, которому он действительно нужен, и отдельным только когда есть виджет, которому он действительно нужен, и отдельным
решением. решением.
**Почему.** Реактивный слой, попавший в проект ради одного выпадающего **ПОЧЕМУ.** Реактивный слой, попавший в проект ради одного выпадающего
списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2 списка, немедленно доступен всему остальному коду — и граница HTMX-1/HTMX-2
перестаёт держаться сама собой. Отдельное решение — единственный момент, перестаёт держаться сама собой. Отдельное решение — единственный момент,
когда цену видно целиком: она не в килобайтах, а в том, что дальше на когда цену видно целиком: она не в килобайтах, а в том, что дальше на
@@ -70,7 +70,7 @@ prefix: HTMX
`partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент `partials/`, и он же рендерится инлайн на странице и как ответ-фрагмент
обработчика; отдельной разметки под фрагмент нет. обработчика; отдельной разметки под фрагмент нет.
**Почему.** Две копии одной разметки расходятся молча: правку вносят в ту, **ПОЧЕМУ.** Две копии одной разметки расходятся молча: правку вносят в ту,
что открыта, и страница начинает выглядеть иначе, чем результат свопа того что открыта, и страница начинает выглядеть иначе, чем результат свопа того
же региона. Заметно это становится только на глаз и только тому, кто открыл же региона. Заметно это становится только на глаз и только тому, кто открыл
оба пути подряд. оба пути подряд.
@@ -80,7 +80,7 @@ prefix: HTMX
**ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют **ДОЛЖЕН.** Корневой узел шаблона несёт тот `id`, по которому адресуют
регион, и ответный фрагмент несёт тот же `id`. регион, и ответный фрагмент несёт тот же `id`.
**Почему.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с **ПОЧЕМУ.** `hx-swap="outerHTML"` заменяет корневой узел целиком, вместе с
его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его его атрибутами. Если пришедший фрагмент несёт другой `id` или не несёт его
вовсе, первый своп проходит успешно, а следующее действие и поллер уже не вовсе, первый своп проходит успешно, а следующее действие и поллер уже не
находят таргет: регион застывает без единой ошибки — ни в консоли, ни в находят таргет: регион застывает без единой ошибки — ни в консоли, ни в
@@ -91,7 +91,7 @@ prefix: HTMX
**СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и **СЛЕДУЕТ.** Один view-builder зовут и обработчик полной страницы, и
htmx-ветка. htmx-ветка.
**Почему.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не **ПОЧЕМУ.** Общий шаблон (HTMX-4) гарантирует одинаковую разметку, но не
одинаковые данные: скопированная сборка view расходится по набору полей, и одинаковые данные: скопированная сборка view расходится по набору полей, и
фрагмент начинает показывать не то, что показала бы страница. Это ровно тот фрагмент начинает показывать не то, что показала бы страница. Это ровно тот
класс расхождений, который HTMX-4 закрывает для разметки. класс расхождений, который HTMX-4 закрывает для разметки.
@@ -124,7 +124,7 @@ if actionErr != nil {
s.render(w, "source_block", view) // фрагмент = тот же шаблон s.render(w, "source_block", view) // фрагмент = тот же шаблон
``` ```
**Почему.** Ветвление до вызова даёт две реализации одного действия, и **ПОЧЕМУ.** Ветвление до вызова даёт две реализации одного действия, и
дальше дефект воспроизводится только на одной поверхности — причём дальше дефект воспроизводится только на одной поверхности — причём
деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот. деградированный путь (HTMX-11) открывают реже, то есть чинить будут не тот.
Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион Перечитанное состояние в htmx-ветке нужно потому, что своп заменяет регион
@@ -136,7 +136,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем **ДОЛЖЕН.** Именованный шаблон собирается целиком в буфер, и только затем
буфер пишется в ответ. буфер пишется в ответ.
**Почему.** Прямая запись в ответ отправляет клиенту статус и часть **ПОЧЕМУ.** Прямая запись в ответ отправляет клиенту статус и часть
разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем, разметки раньше, чем шаблон дошёл до ошибки: сообщить об отказе уже нечем,
а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина а htmx свопит в DOM полученный обрывок. Внешне это «исчезла половина
региона», и причина по такому симптому не читается. региона», и причина по такому симптому не читается.
@@ -149,7 +149,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным отдаётся в том же ответе с `hx-swap-oob="true"` — обычным именованным
партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5). партиалом с тем же `id`, что и на странице (HTMX-4, HTMX-5).
**Почему.** Второй запрос с клиента вводит гонку: два ответа считают **ПОЧЕМУ.** Второй запрос с клиента вводит гонку: два ответа считают
состояние в разные моменты и приезжают в произвольном порядке, поэтому состояние в разные моменты и приезжают в произвольном порядке, поэтому
панель действий может отразить состояние до действия. Плюс лишний панель действий может отразить состояние до действия. Плюс лишний
раунд-трип на каждое действие. раунд-трип на каждое действие.
@@ -159,7 +159,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй **ДОПУСКАЕТСЯ.** `HX-Trigger` в ответе плюс отдельный `hx-get`, если второй
регион меняется не на каждое действие. регион меняется не на каждое действие.
**Почему.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого **ПОЧЕМУ.** Явное разрешение нужно, чтобы HTMX-9 не читался как запрет любого
второго запроса. Когда регион обновляется редко, oob-ветка гоняет второго запроса. Когда регион обновляется редко, oob-ветка гоняет
одинаковую разметку на каждое действие и связывает два шаблона там, где одинаковую разметку на каждое действие и связывает два шаблона там, где
связи нет; гонка же тем менее наблюдаема, чем реже обновление. связи нет; гонка же тем менее наблюдаема, чем реже обновление.
@@ -172,7 +172,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
`hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на `hx-post`/`hx-target`/`hx-swap` накладываются сверху; `action` ведёт на
рабочий обработчик. рабочий обработчик.
**Почему.** htmx может не загрузиться — ошибка вендоринга, блокировщик, **ПОЧЕМУ.** htmx может не загрузиться — ошибка вендоринга, блокировщик,
медленная сеть, — и без рабочего `action` форма в этот момент не отправляет медленная сеть, — и без рабочего `action` форма в этот момент не отправляет
ничего, молча. Тот же `action` — единственное, что делает действие ничего, молча. Тот же `action` — единственное, что делает действие
проверяемым без браузера с JS. проверяемым без браузера с JS.
@@ -182,7 +182,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере; **ДОЛЖЕН.** Отбор списка задаётся GET-параметрами и выполняется на сервере;
клиентской фильтрации загруженной разметки нет. клиентской фильтрации загруженной разметки нет.
**Почему.** Клиент видит только текущую страницу списка, поэтому клиентский **ПОЧЕМУ.** Клиент видит только текущую страницу списка, поэтому клиентский
фильтр отвечает по неполным данным и делает это молча — результат выглядит фильтр отвечает по неполным данным и делает это молча — результат выглядит
валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и валидным. Вдобавок состояние отбора в query переживает своп (HTMX-25) и
перезагрузку, его можно послать ссылкой и увидеть в логе. перезагрузку, его можно послать ссылкой и увидеть в логе.
@@ -196,7 +196,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
| HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) | | HTMX-13.1 | действия и навигация | работают полностью (HTMX-11, HTMX-12) |
| HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления | | HTMX-13.2 | интерактивный виджет выбора, у которого нет осмысленного не-JS поведения | может не работать; записывается в отступления |
**Почему.** Без явной границы правило деградации читается как запрет на **ПОЧЕМУ.** Без явной границы правило деградации читается как запрет на
любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от любой JS-виджет — и тогда его либо тихо нарушают, либо отказываются от
виджета, который был нужен. Запись в отступления держит список честным: виджета, который был нужен. Запись в отступления держит список честным:
видно, какие именно места ломаются с выключенным JS, а не «где-то видно, какие именно места ломаются с выключенным JS, а не «где-то
@@ -209,7 +209,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с **ДОЛЖЕН.** Провалившееся действие отдаёт статус 200 и фрагмент с
сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус. сообщением; доменная ошибка на htmx-пути не транслируется в HTTP-статус.
**Почему.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть **ПОЧЕМУ.** В htmx 2.x ответы 4xx/5xx по умолчанию не свопят DOM — то есть
пользователь не увидит ничего. Своп ошибочных ответов настраивается пользователь не увидит ничего. Своп ошибочных ответов настраивается
(`htmx.config.responseHandling`, расширение `response-targets`), но любая (`htmx.config.responseHandling`, расширение `response-targets`), но любая
такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2. такая настройка — свой JS-конфиг на клиенте, и платится она из HTMX-1 и HTMX-2.
@@ -228,7 +228,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
ошибочных ответов в целевые регионы (`htmx.config.responseHandling`, ошибочных ответов в целевые регионы (`htmx.config.responseHandling`,
`response-targets`) не настраивается. `response-targets`) не настраивается.
**Почему.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл. **ПОЧЕМУ.** HTMX-14 закрывает доменный отказ, до которого обработчик дошёл.
Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не Паника, сбой шаблона и обрыв сети отдают 5xx или ничего, htmx 2.x такое не
свопит — регион не меняется, интерфейс замирает без единого признака сбоя, свопит — регион не меняется, интерфейс замирает без единого признака сбоя,
и пользователь повторяет действие, которое могло уже примениться. Слушатель и пользователь повторяет действие, которое могло уже примениться. Слушатель
@@ -244,7 +244,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала; **ДОЛЖЕН.** Во фрагмент попадает нейтральный текст публичного канала;
`err.Error()` в разметку не рендерится. `err.Error()` в разметку не рендерится.
**Почему.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём **ПОЧЕМУ.** htmx-фрагмент выглядит внутренней деталью приложения, и на нём
легче всего забыть, что это тот же публичный канал, что и страница: легче всего забыть, что это тот же публичный канал, что и страница:
разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14) разметка уезжает в браузер пользователя целиком. Статус 200 (HTMX-14)
дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит». дополнительно снимает ощущение «это ошибочный ответ, его никто не увидит».
@@ -254,7 +254,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под **ДОЛЖЕН.** У view есть поле под ошибку действия; доменные поля под
сообщение не переиспользуются. сообщение не переиспользуются.
**Почему.** У доменного поля может быть своё непустое значение, и сообщение **ПОЧЕМУ.** У доменного поля может быть своё непустое значение, и сообщение
его перекроет: пользователь получит текст ошибки вместо данных, а шаблон — его перекроет: пользователь получит текст ошибки вместо данных, а шаблон —
необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба необходимость угадывать, что сейчас лежит в поле. Отдельное поле делает оба
состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего состояния — данные и ошибку — выразимыми одновременно, а это ровно то, чего
@@ -265,7 +265,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает **НЕ ДОЛЖЕН.** Фрагмент, отданный после неудачного действия, показывает
прежний выбор плюс сообщение. прежний выбор плюс сообщение.
**Почему.** Своп заменяет регион целиком, поэтому фрагмент — единственное, **ПОЧЕМУ.** Своп заменяет регион целиком, поэтому фрагмент — единственное,
что пользователь узнает о состоянии. Показав намеренное состояние вместо что пользователь узнает о состоянии. Показав намеренное состояние вместо
фактического, интерфейс расходится с сервером, и следующее действие человек фактического, интерфейс расходится с сервером, и следующее действие человек
делает по ложной картине — на сервере оно применится к другому объекту. делает по ложной картине — на сервере оно применится к другому объекту.
@@ -288,7 +288,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без **ДОЛЖЕН.** Когда состояние вышло из «живого», фрагмент возвращается без
`hx-*`-атрибутов. `hx-*`-атрибутов.
**Почему.** Иначе опрос не прекращается никогда: каждая открытая вкладка **ПОЧЕМУ.** Иначе опрос не прекращается никогда: каждая открытая вкладка
держит постоянный поток запросов за неизменными данными, и закрывает его держит постоянный поток запросов за неизменными данными, и закрывает его
только пользователь. Условие остановки живёт в разметке ответа, потому что только пользователь. Условие остановки живёт в разметке ответа, потому что
это единственный канал, которым сервер управляет поллером. это единственный канал, которым сервер управляет поллером.
@@ -302,7 +302,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет **ДОЛЖЕН.** Признак «живо ли ещё» вычисляется по состоянию, которым владеет
приложение, а не по ответу внешнего сервиса. приложение, а не по ответу внешнего сервиса.
**Почему.** Внешний сервис отвечает не всегда и не одинаково: на его **ПОЧЕМУ.** Внешний сервис отвечает не всегда и не одинаково: на его
недоступности поллер либо останавливается, пока работа идёт, либо не недоступности поллер либо останавливается, пока работа идёт, либо не
останавливается никогда. Приложение — единственный участник, который знает останавливается никогда. Приложение — единственный участник, который знает
про операцию всё и может ответить на каждом тике. про операцию всё и может ответить на каждом тике.
@@ -312,7 +312,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его **ДОЛЖЕН.** Тик заменяет весь фрагмент (`hx-swap="outerHTML"`), а не его
содержимое. содержимое.
**Почему.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и **ПОЧЕМУ.** `outerHTML` удаляет старый узел вместе с его `hx-trigger` и
инициализирует новый — так поллер живёт ровно в одном экземпляре и так же инициализирует новый — так поллер живёт ровно в одном экземпляре и так же
выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером, выключается (HTMX-18). Своп содержимого оставил бы старый узел с его таймером,
и через несколько обновлений опрос шёл бы в несколько потоков. Работает это и через несколько обновлений опрос шёл бы в несколько потоков. Работает это
@@ -323,7 +323,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где **НЕ ДОЛЖЕН.** Живость включается только в тех состояниях фрагмента, где
редактировать нечего. редактировать нечего.
**Почему.** Своп поддерева теряет фокус, выделение и незасабмиченный текст **ПОЧЕМУ.** Своп поддерева теряет фокус, выделение и незасабмиченный текст
внутри него. У поллера это происходит по таймеру, то есть в момент, который внутри него. У поллера это происходит по таймеру, то есть в момент, который
пользователь не выбирал: текст исчезает посреди набора и воспроизводится пользователь не выбирал: текст исчезает посреди набора и воспроизводится
как «приложение стирает мой ввод». как «приложение стирает мой ввод».
@@ -332,7 +332,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
**НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер. **НЕ ДОЛЖЕН.** Поллинг и прочие запросы страницы идут на свой сервер.
**Почему.** Прямой запрос из браузера выносит наружу адрес и учётные данные **ПОЧЕМУ.** Прямой запрос из браузера выносит наружу адрес и учётные данные
внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок внешнего сервиса и делает страницу заложником его CORS-политики. Вдобавок
контракт внешнего сервиса протекает в разметку: его смена перестаёт быть контракт внешнего сервиса протекает в разметку: его смена перестаёт быть
серверным изменением. серверным изменением.
@@ -346,7 +346,7 @@ s.render(w, "source_block", view) // фрагмент = тот же шаб
| HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером | | HTMX-23.1 | состояние внешнего сервиса | in-memory снимок, обновляемый воркером |
| HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется | | HTMX-23.2 | собственное состояние приложения | своё хранилище; снимок не требуется |
**Почему.** Тик умножается на число открытых вкладок, поэтому сеть на **ПОЧЕМУ.** Тик умножается на число открытых вкладок, поэтому сеть на
каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и каждом тике превращает интерфейс в генератор нагрузки на внешний сервис — и
его недоступность становится недоступностью страницы. Снимок разрывает эту его недоступность становится недоступностью страницы. Снимок разрывает эту
связь: частоту обращений к внешнему сервису задаёт воркер, а не связь: частоту обращений к внешнему сервису задаёт воркер, а не
@@ -365,7 +365,7 @@ hx-get="/item/{{.ID}}" hx-trigger="every 3s"
hx-select="#item-main" hx-swap="outerHTML" hx-select="#item-main" hx-swap="outerHTML"
``` ```
**Почему.** Отдельный `/fragments/…`-роут в этом случае дублирует **ПОЧЕМУ.** Отдельный `/fragments/…`-роут в этом случае дублирует
обработчик страницы целиком — вместе с перечитыванием состояния и сборкой обработчик страницы целиком — вместе с перечитыванием состояния и сборкой
view, — и дальше два обработчика расходятся по тому же сценарию, что и две view, — и дальше два обработчика расходятся по тому же сценарию, что и две
копии разметки (HTMX-4). копии разметки (HTMX-4).
@@ -379,7 +379,7 @@ view, — и дальше два обработчика расходятся п
**НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте. **НЕ ДОЛЖЕН.** Такое действие свопит свой регион на месте.
**Почему.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и **ПОЧЕМУ.** Своп сохраняет прокрутку и не трогает серверные фильтр, поиск и
пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного пагинацию — они в query (HTMX-12). Полная навигация ради изменения одного
региона возвращает пользователя в начало списка и стоит перерисовки всей региона возвращает пользователя в начало списка и стоит перерисовки всей
страницы. Не сохраняется при свопе только контекст внутри самого страницы. Не сохраняется при свопе только контекст внутри самого
@@ -390,7 +390,7 @@ view, — и дальше два обработчика расходятся п
**ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся **ДОЛЖЕН.** Действие, после которого предмет покидает страницу, остаётся
обычной POST-формой без htmx-атрибутов, то есть полной навигацией. обычной POST-формой без htmx-атрибутов, то есть полной навигацией.
**Почему.** Своп для такого действия оставил бы на месте регион, **ПОЧЕМУ.** Своп для такого действия оставил бы на месте регион,
описывающий объект, которого на странице больше нет. Отсутствие описывающий объект, которого на странице больше нет. Отсутствие
htmx-атрибутов при этом само работает маркером «это выход»: намерение видно htmx-атрибутов при этом само работает маркером «это выход»: намерение видно
прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ прямо в разметке, и не нужен `HX-Redirect` — то есть ещё один способ
@@ -401,7 +401,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает **ДОЛЖЕН.** Если работу доделывает воркер, ответ на действие показывает
промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18). промежуточное состояние, а итог догоняет самозавершающийся поллер (HTMX-18).
**Почему.** Мнимый результат расходится с сервером до следующего тика, и **ПОЧЕМУ.** Мнимый результат расходится с сервером до следующего тика, и
всё это время пользователь принимает решения по несуществующему исходу — всё это время пользователь принимает решения по несуществующему исходу —
включая повтор действия, которое на самом деле выполняется. Промежуточное включая повтор действия, которое на самом деле выполняется. Промежуточное
состояние вдобавок объясняет, почему регион продолжает обновляться сам. состояние вдобавок объясняет, почему регион продолжает обновляться сам.
@@ -414,7 +414,7 @@ htmx-атрибутов при этом само работает маркеро
фрагментом, поверхность передаётся явным скрытым полем фрагментом, поверхность передаётся явным скрытым полем
(`surface=list|detail`), а не выводится из `HX-Target` или `Referer`. (`surface=list|detail`), а не выводится из `HX-Target` или `Referer`.
**Почему.** `HX-Target` — свойство разметки вызывающей страницы, `Referer` **ПОЧЕМУ.** `HX-Target` — свойство разметки вызывающей страницы, `Referer`
может не прийти вовсе; и то и другое меняется без участия обработчика, и может не прийти вовсе; и то и другое меняется без участия обработчика, и
ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с ответ начинает приходить не тем фрагментом. Поле же видно в форме рядом с
действием, поэтому связь «эта страница → этот фрагмент» читается там, где действием, поэтому связь «эта страница → этот фрагмент» читается там, где
@@ -426,7 +426,7 @@ htmx-атрибутов при этом само работает маркеро
400, когда поля `surface` в запросе нет; поверхность по умолчанию не 400, когда поля `surface` в запросе нет; поверхность по умолчанию не
выбирается. выбирается.
**Почему.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие — **ПОЧЕМУ.** Поле кладёт в форму наш же шаблон, поэтому его отсутствие —
дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует дефект формы, а не вход пользователя. Поверхность по умолчанию маскирует
такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после такой дефект молча неверным фрагментом: своп с чужим `id` проходит, после
чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится чего регион перестаёт находиться таргетом (HTMX-5), и ошибка воспроизводится
@@ -446,7 +446,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с **ДОЛЖЕН.** Статика подключается через `go:embed` и отдаётся с
`Cache-Control: public, max-age=31536000, immutable`. `Cache-Control: public, max-age=31536000, immutable`.
**Почему.** Встроенные ассеты делают деплой одним артефактом: нет второго **ПОЧЕМУ.** Встроенные ассеты делают деплой одним артефактом: нет второго
шага раскладки файлов, который может отстать от бинаря и оставить новую шага раскладки файлов, который может отстать от бинаря и оставить новую
разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL разметку со старым css. Иммутабельный кэш безопасен ровно потому, что URL
меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша меняется вместе с содержимым (HTMX-30, HTMX-31); без этого условия год кэша
@@ -457,7 +457,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL **ДОЛЖЕН.** css и js адресуются с `?v=<короткий sha256 содержимого>`, и URL
строит хелпер шаблона. строит хелпер шаблона.
**Почему.** Хеш содержимого — единственная версия, которую невозможно **ПОЧЕМУ.** Хеш содержимого — единственная версия, которую невозможно
забыть обновить: она меняется от самой правки. Ручной номер и дата сборки забыть обновить: она меняется от самой правки. Ручной номер и дата сборки
от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) — от этого не защищают, а цена промаха при иммутабельном кэше (HTMX-29) —
устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы устаревший файл у пользователя до ручной очистки кэша. Хелпер нужен, чтобы
@@ -468,7 +468,7 @@ htmx-атрибутов при этом само работает маркеро
**ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без **ДОПУСКАЕТСЯ.** Вендор адресуется по неизменному имени файла, без
параметра версии. параметра версии.
**Почему.** Содержимое под этим именем не меняется: обновление вендора **ПОЧЕМУ.** Содержимое под этим именем не меняется: обновление вендора
приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от приходит новым именем файла, то есть новым URL. Кэш-бастер защищает от
подмены содержимого под тем же адресом, а такой ситуации здесь нет — и подмены содержимого под тем же адресом, а такой ситуации здесь нет — и
явное разрешение снимает вопрос, не нарушает ли это HTMX-30. явное разрешение снимает вопрос, не нарушает ли это HTMX-30.
@@ -479,7 +479,7 @@ htmx-атрибутов при этом само работает маркеро
(`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой (`путь url sha256`) с проверкой контрольной суммы; сборка зависит от этой
задачи. задачи.
**Почему.** Манифест делает версию и происхождение ассета видимыми в **ПОЧЕМУ.** Манифест делает версию и происхождение ассета видимыми в
diff'е — у закоммиченного минифицированного файла обновление выглядит diff'е — у закоммиченного минифицированного файла обновление выглядит
стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 — стеной непрозрачных изменений, и подмену в ней не разглядеть. Sha256 —
единственная проверка, что скачали то же самое, что проверяли; зависимость единственная проверка, что скачали то же самое, что проверяли; зависимость
@@ -489,7 +489,7 @@ diff'е — у закоммиченного минифицированного
**ДОЛЖЕН.** Внешних хостов во время выполнения нет. **ДОЛЖЕН.** Внешних хостов во время выполнения нет.
**Почему.** Каждый внешний хост — это чужой аптайм внутри своей страницы и **ПОЧЕМУ.** Каждый внешний хост — это чужой аптайм внутри своей страницы и
третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь третья сторона, видящая каждый запрос пользователя. Самодостаточный бинарь
вдобавок разворачивается в сети без выхода наружу, где CDN просто не вдобавок разворачивается в сети без выхода наружу, где CDN просто не
отвечает. отвечает.